diff --git a/GFramework.Game.Abstractions/Input/IInputBindingStore.cs b/GFramework.Game.Abstractions/Input/IInputBindingStore.cs
new file mode 100644
index 00000000..e752d9f4
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/IInputBindingStore.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 定义逻辑动作绑定的查询、修改与快照导入导出契约。
+///
+///
+/// 该接口承担框架输入系统的持久化与重绑定边界。
+/// 宿主层可以把自己的原生输入系统适配到这里,上层业务则只依赖动作名和绑定描述,不直接接触宿主输入事件。
+///
+public interface IInputBindingStore
+{
+ ///
+ /// 获取指定动作的当前绑定。
+ ///
+ /// 动作名称。
+ /// 动作绑定快照。
+ InputActionBinding GetBindings(string actionName);
+
+ ///
+ /// 获取所有动作的当前绑定快照。
+ ///
+ /// 全量输入绑定快照。
+ InputBindingSnapshot ExportSnapshot();
+
+ ///
+ /// 使用给定快照替换当前绑定。
+ ///
+ /// 要导入的快照。
+ void ImportSnapshot(InputBindingSnapshot snapshot);
+
+ ///
+ /// 把指定绑定设置为动作的主绑定。
+ ///
+ /// 动作名称。
+ /// 新绑定。
+ /// 是否在冲突时交换已占用绑定。
+ void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true);
+
+ ///
+ /// 将指定动作恢复为默认绑定。
+ ///
+ /// 动作名称。
+ void ResetAction(string actionName);
+
+ ///
+ /// 将所有动作恢复为默认绑定。
+ ///
+ void ResetAll();
+}
diff --git a/GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs b/GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs
new file mode 100644
index 00000000..29a09853
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs
@@ -0,0 +1,15 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 定义当前活跃输入设备上下文的查询入口。
+///
+public interface IInputDeviceTracker
+{
+ ///
+ /// 获取当前输入设备上下文。
+ ///
+ InputDeviceContext CurrentDevice { get; }
+}
diff --git a/GFramework.Game.Abstractions/Input/IUiInputActionMap.cs b/GFramework.Game.Abstractions/Input/IUiInputActionMap.cs
new file mode 100644
index 00000000..cc46ccaa
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/IUiInputActionMap.cs
@@ -0,0 +1,20 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.UI;
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 定义逻辑动作名到 UI 语义动作的映射规则。
+///
+public interface IUiInputActionMap
+{
+ ///
+ /// 尝试把逻辑动作映射为 UI 语义动作。
+ ///
+ /// 逻辑动作名称。
+ /// 映射出的 UI 语义动作。
+ /// 如果映射成功则返回 。
+ bool TryMap(string actionName, out UiInputAction action);
+}
diff --git a/GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs b/GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs
new file mode 100644
index 00000000..eb7658c4
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs
@@ -0,0 +1,17 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 定义面向 UI 语义动作的输入分发入口。
+///
+public interface IUiInputDispatcher
+{
+ ///
+ /// 尝试把逻辑动作分发到当前 UI 路由。
+ ///
+ /// 逻辑动作名称。
+ /// 如果该动作被映射为 UI 动作并成功分发,则返回 。
+ bool TryDispatch(string actionName);
+}
diff --git a/GFramework.Game.Abstractions/Input/InputActionBinding.cs b/GFramework.Game.Abstractions/Input/InputActionBinding.cs
new file mode 100644
index 00000000..5cd45337
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/InputActionBinding.cs
@@ -0,0 +1,37 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 描述一个逻辑动作当前持有的绑定集合。
+///
+public sealed class InputActionBinding
+{
+ ///
+ /// 初始化一个动作绑定快照。
+ ///
+ /// 动作名称。
+ /// 当前绑定列表。
+ /// 当 为空时抛出。
+ public InputActionBinding(string actionName, IReadOnlyList bindings)
+ {
+ if (string.IsNullOrWhiteSpace(actionName))
+ {
+ throw new ArgumentException("Action name cannot be null or whitespace.", nameof(actionName));
+ }
+
+ ActionName = actionName;
+ Bindings = bindings ?? Array.Empty();
+ }
+
+ ///
+ /// 获取动作名称。
+ ///
+ public string ActionName { get; }
+
+ ///
+ /// 获取当前绑定列表。
+ ///
+ public IReadOnlyList Bindings { get; }
+}
diff --git a/GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs b/GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs
new file mode 100644
index 00000000..b1cd9cca
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs
@@ -0,0 +1,67 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 描述一个框架无关的动作绑定。
+///
+///
+/// 该模型是运行时输入系统与宿主适配层之间的稳定交换格式。
+/// 宿主层负责把原生输入事件转成此描述,抽象层和默认运行时只根据这些字段做查询、冲突检测和持久化。
+///
+public sealed class InputBindingDescriptor
+{
+ ///
+ /// 初始化一个动作绑定描述。
+ ///
+ /// 设备族。
+ /// 绑定类型。
+ /// 宿主无关的物理码值。
+ /// 用于设置界面展示的名称。
+ /// 轴向方向;非轴向绑定时为 。
+ /// 当 为空时抛出。
+ public InputBindingDescriptor(
+ InputDeviceKind deviceKind,
+ InputBindingKind bindingKind,
+ string code,
+ string displayName,
+ float? axisDirection = null)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ throw new ArgumentException("Binding code cannot be null or whitespace.", nameof(code));
+ }
+
+ DeviceKind = deviceKind;
+ BindingKind = bindingKind;
+ Code = code;
+ DisplayName = displayName ?? string.Empty;
+ AxisDirection = axisDirection;
+ }
+
+ ///
+ /// 获取设备族。
+ ///
+ public InputDeviceKind DeviceKind { get; }
+
+ ///
+ /// 获取绑定类型。
+ ///
+ public InputBindingKind BindingKind { get; }
+
+ ///
+ /// 获取宿主无关的物理码值。
+ ///
+ public string Code { get; }
+
+ ///
+ /// 获取用于展示的标签。
+ ///
+ public string DisplayName { get; }
+
+ ///
+ /// 获取轴向方向。
+ ///
+ public float? AxisDirection { get; }
+}
diff --git a/GFramework.Game.Abstractions/Input/InputBindingKind.cs b/GFramework.Game.Abstractions/Input/InputBindingKind.cs
new file mode 100644
index 00000000..2fe69fbf
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/InputBindingKind.cs
@@ -0,0 +1,35 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 描述一个逻辑绑定使用的物理输入类型。
+///
+public enum InputBindingKind
+{
+ ///
+ /// 未指定。
+ ///
+ Unknown = 0,
+
+ ///
+ /// 键盘按键。
+ ///
+ Key = 1,
+
+ ///
+ /// 鼠标按钮。
+ ///
+ MouseButton = 2,
+
+ ///
+ /// 手柄按钮。
+ ///
+ GamepadButton = 3,
+
+ ///
+ /// 手柄轴向。
+ ///
+ GamepadAxis = 4
+}
diff --git a/GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs b/GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs
new file mode 100644
index 00000000..4d20ccf8
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs
@@ -0,0 +1,24 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 描述一组动作绑定的可持久化快照。
+///
+public sealed class InputBindingSnapshot
+{
+ ///
+ /// 初始化一个输入绑定快照。
+ ///
+ /// 动作绑定集合。
+ public InputBindingSnapshot(IReadOnlyList actions)
+ {
+ Actions = actions ?? Array.Empty();
+ }
+
+ ///
+ /// 获取动作绑定集合。
+ ///
+ public IReadOnlyList Actions { get; }
+}
diff --git a/GFramework.Game.Abstractions/Input/InputDeviceContext.cs b/GFramework.Game.Abstractions/Input/InputDeviceContext.cs
new file mode 100644
index 00000000..afe66349
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/InputDeviceContext.cs
@@ -0,0 +1,41 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 描述当前活跃输入设备上下文。
+///
+public sealed class InputDeviceContext
+{
+ ///
+ /// 初始化一个输入设备上下文。
+ ///
+ /// 当前设备族。
+ /// 设备索引;未知时为 。
+ /// 宿主归一化后的设备名称。
+ public InputDeviceContext(
+ InputDeviceKind deviceKind,
+ int? deviceIndex = null,
+ string? deviceName = null)
+ {
+ DeviceKind = deviceKind;
+ DeviceIndex = deviceIndex;
+ DeviceName = deviceName ?? string.Empty;
+ }
+
+ ///
+ /// 获取当前设备族。
+ ///
+ public InputDeviceKind DeviceKind { get; }
+
+ ///
+ /// 获取当前设备索引。
+ ///
+ public int? DeviceIndex { get; }
+
+ ///
+ /// 获取宿主归一化后的设备名称。
+ ///
+ public string DeviceName { get; }
+}
diff --git a/GFramework.Game.Abstractions/Input/InputDeviceKind.cs b/GFramework.Game.Abstractions/Input/InputDeviceKind.cs
new file mode 100644
index 00000000..7559ce80
--- /dev/null
+++ b/GFramework.Game.Abstractions/Input/InputDeviceKind.cs
@@ -0,0 +1,34 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+namespace GFramework.Game.Abstractions.Input;
+
+///
+/// 描述框架级输入设备族。
+///
+///
+/// 该枚举用于跨宿主共享“当前输入来自哪一类设备”的语义。
+/// 它故意避免暴露 Godot、Unity 或平台 SDK 的原生事件类型,确保上层业务只依赖稳定的设备族判断。
+///
+public enum InputDeviceKind
+{
+ ///
+ /// 未识别或尚未产生任何输入。
+ ///
+ Unknown = 0,
+
+ ///
+ /// 键盘与鼠标输入。
+ ///
+ KeyboardMouse = 1,
+
+ ///
+ /// 游戏手柄输入。
+ ///
+ Gamepad = 2,
+
+ ///
+ /// 触摸输入。
+ ///
+ Touch = 3
+}
diff --git a/GFramework.Game.Abstractions/README.md b/GFramework.Game.Abstractions/README.md
index dbdd00c6..66954e80 100644
--- a/GFramework.Game.Abstractions/README.md
+++ b/GFramework.Game.Abstractions/README.md
@@ -13,6 +13,7 @@
- 典型使用场景:
- 定义 `IScene`、`IUiPage`、`ISettingsData`、`IData` 等业务对象
- 让 feature 包只感知 `IConfigRegistry`、`ISaveRepository`、`ISettingsModel`、`IUiRouter`、`ISceneRouter`
+ - 在输入层共享动作绑定、设备上下文和 UI 语义桥接契约
- 在引擎适配层之外共享设置、场景参数、UI 参数、存档数据类型
## 与相邻包的关系
@@ -131,6 +132,18 @@ UI 页面与路由契约。
`IUiRouter` 不只覆盖页面栈,还覆盖 Overlay / Modal / Toast / Topmost 等层级 UI 语义。
+### `Input/`
+
+- `InputBindingDescriptor`
+- `InputActionBinding`
+- `InputBindingSnapshot`
+- `IInputBindingStore`
+- `IInputDeviceTracker`
+- `IUiInputActionMap`
+- `IUiInputDispatcher`
+
+这一层定义的是统一输入抽象、绑定快照与 UI 语义桥接契约。
+
### `Routing/`
- `IRoute`
@@ -164,6 +177,7 @@ Scene 与 UI 路由共享这套基础约定。
| `Setting/` | `ISettingsData`、`ISettingsModel`、`ISettingsSystem`、`LocalizationSettings` | 看设置数据、应用语义、迁移接口和内置设置对象 |
| `Scene/` | `IScene`、`ISceneRouter`、`ISceneFactory`、`SceneTransitionEvent` | 看场景行为、路由、工厂 / root 边界与转场事件模型 |
| `UI/` | `IUiPage`、`IUiRouter`、`IUiFactory`、`UiInteractionProfile`、`UiTransitionHandlerOptions` | 看页面栈、层级 UI、输入动作与 UI 转场契约 |
+| `Input/` | `InputBindingDescriptor`、`IInputBindingStore`、`IInputDeviceTracker`、`IUiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接契约 |
| `Routing/` `Storage/` `Asset/` `Enums/` | `IRoute`、`IRouteContext`、`IFileStorage`、`IAssetRegistry`、`UiLayer`、`SceneTransitionType` | 看公共路由上下文、存储角色、资源注册表与跨层共享枚举 |
## 最小接入路径
@@ -267,6 +281,7 @@ public sealed class ContinueGameCommandHandler
- 序列化系统:[序列化系统](../docs/zh-CN/game/serialization.md)
- 场景系统:[场景系统](../docs/zh-CN/game/scene.md)
- UI 系统:[UI 系统](../docs/zh-CN/game/ui.md)
+- 输入系统:[输入系统](../docs/zh-CN/game/input.md)
## 选择建议
diff --git a/GFramework.Game.Tests/Input/InputBindingStoreTests.cs b/GFramework.Game.Tests/Input/InputBindingStoreTests.cs
new file mode 100644
index 00000000..5f3cc671
--- /dev/null
+++ b/GFramework.Game.Tests/Input/InputBindingStoreTests.cs
@@ -0,0 +1,95 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Input;
+
+namespace GFramework.Game.Tests.Input;
+
+///
+/// 验证默认输入绑定存储的重绑定、冲突交换与默认恢复行为。
+///
+[TestFixture]
+public sealed class InputBindingStoreTests
+{
+ ///
+ /// 验证主绑定冲突时,会把原绑定交换回被占用动作。
+ ///
+ [Test]
+ public void SetPrimaryBinding_WhenBindingOwnedByAnotherAction_SwapsBindings()
+ {
+ var store = CreateStore();
+ var replacement = new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:68",
+ "D");
+
+ store.SetPrimaryBinding("move_left", replacement);
+
+ var moveLeft = store.GetBindings("move_left");
+ var moveRight = store.GetBindings("move_right");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(moveLeft.Bindings[0].Code, Is.EqualTo("key:68"));
+ Assert.That(moveRight.Bindings[0].Code, Is.EqualTo("key:65"));
+ });
+ }
+
+ ///
+ /// 验证重置全部绑定时,会回退到初始化默认快照。
+ ///
+ [Test]
+ public void ResetAll_Should_Restore_DefaultSnapshot()
+ {
+ var store = CreateStore();
+ store.SetPrimaryBinding(
+ "move_left",
+ new InputBindingDescriptor(InputDeviceKind.KeyboardMouse, InputBindingKind.Key, "key:81", "Q"));
+
+ store.ResetAll();
+ var snapshot = store.ExportSnapshot();
+
+ Assert.That(
+ snapshot.Actions.Single(action => string.Equals(action.ActionName, "move_left", StringComparison.Ordinal)).Bindings[0].Code,
+ Is.EqualTo("key:65"));
+ }
+
+ ///
+ /// 验证查询不存在的动作时,不会把空条目写回当前快照。
+ ///
+ [Test]
+ public void GetBindings_WhenActionMissing_Should_NotMutateSnapshot()
+ {
+ var store = CreateStore();
+
+ var missingBindings = store.GetBindings("jump");
+ var snapshot = store.ExportSnapshot();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(missingBindings.ActionName, Is.EqualTo("jump"));
+ Assert.That(missingBindings.Bindings, Is.Empty);
+ Assert.That(snapshot.Actions.Any(action => string.Equals(action.ActionName, "jump", StringComparison.Ordinal)), Is.False);
+ });
+ }
+
+ private static InputBindingStore CreateStore()
+ {
+ return new InputBindingStore(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "move_left",
+ [
+ new InputBindingDescriptor(InputDeviceKind.KeyboardMouse, InputBindingKind.Key, "key:65", "A")
+ ]),
+ new InputActionBinding(
+ "move_right",
+ [
+ new InputBindingDescriptor(InputDeviceKind.KeyboardMouse, InputBindingKind.Key, "key:68", "D")
+ ])
+ ]));
+ }
+}
diff --git a/GFramework.Game.Tests/Input/UiInputDispatcherTests.cs b/GFramework.Game.Tests/Input/UiInputDispatcherTests.cs
new file mode 100644
index 00000000..c932cfd7
--- /dev/null
+++ b/GFramework.Game.Tests/Input/UiInputDispatcherTests.cs
@@ -0,0 +1,52 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Input;
+
+namespace GFramework.Game.Tests.Input;
+
+///
+/// 验证逻辑动作到 UI 路由分发的默认桥接行为。
+///
+[TestFixture]
+public sealed class UiInputDispatcherTests
+{
+ ///
+ /// 验证 `ui_cancel` 会被映射为 `UiInputAction.Cancel` 并继续分发给路由器。
+ ///
+ [Test]
+ public void TryDispatch_WhenActionCanMapToUiAction_ForwardsToRouter()
+ {
+ var router = new Mock();
+ router.Setup(mock => mock.TryDispatchUiAction(UiInputAction.Cancel)).Returns(true);
+
+ var dispatcher = new UiInputDispatcher(new UiInputActionMap(), router.Object);
+
+ var dispatched = dispatcher.TryDispatch("ui_cancel");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(dispatched, Is.True);
+ router.Verify(mock => mock.TryDispatchUiAction(UiInputAction.Cancel), Times.Once);
+ });
+ }
+
+ ///
+ /// 验证未映射的逻辑动作不会触发 UI 路由。
+ ///
+ [Test]
+ public void TryDispatch_WhenActionIsUnknown_ReturnsFalseWithoutRouting()
+ {
+ var router = new Mock();
+ var dispatcher = new UiInputDispatcher(new UiInputActionMap(), router.Object);
+
+ var dispatched = dispatcher.TryDispatch("inventory_toggle");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(dispatched, Is.False);
+ router.Verify(mock => mock.TryDispatchUiAction(It.IsAny()), Times.Never);
+ });
+ }
+}
diff --git a/GFramework.Game/Input/InputBindingStore.cs b/GFramework.Game/Input/InputBindingStore.cs
new file mode 100644
index 00000000..a75929d6
--- /dev/null
+++ b/GFramework.Game/Input/InputBindingStore.cs
@@ -0,0 +1,192 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+
+namespace GFramework.Game.Input;
+
+///
+/// 提供基于内存快照的默认输入绑定存储实现。
+///
+///
+/// 该实现聚焦于框架级动作绑定管理语义:默认值恢复、主绑定替换、冲突交换与快照导入导出。
+/// 它不依赖具体宿主输入事件,适合作为 `Game` 层默认运行时与单元测试基线。
+/// 该类型内部使用普通 `Dictionary` / `List` 保存可变状态,不提供额外同步原语。
+/// 宿主应在同一输入线程或受控的串行配置阶段访问它;如果存在跨线程读写需求,应由外层协调同步。
+///
+public sealed class InputBindingStore : IInputBindingStore
+{
+ private readonly Dictionary> _defaultBindings;
+ private readonly Dictionary> _currentBindings;
+
+ ///
+ /// 初始化输入绑定存储。
+ ///
+ /// 默认绑定快照。
+ public InputBindingStore(InputBindingSnapshot defaultSnapshot)
+ {
+ _defaultBindings = ToDictionary(defaultSnapshot);
+ _currentBindings = CloneDictionary(_defaultBindings);
+ }
+
+ ///
+ public InputActionBinding GetBindings(string actionName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+
+ return _currentBindings.TryGetValue(actionName, out var bindings)
+ ? new InputActionBinding(actionName, bindings.ToArray())
+ : new InputActionBinding(actionName, Array.Empty());
+ }
+
+ ///
+ public InputBindingSnapshot ExportSnapshot()
+ {
+ var actions = _currentBindings
+ .OrderBy(static pair => pair.Key, StringComparer.Ordinal)
+ .Select(static pair => new InputActionBinding(pair.Key, pair.Value.ToArray()))
+ .ToArray();
+
+ return new InputBindingSnapshot(actions);
+ }
+
+ ///
+ public void ImportSnapshot(InputBindingSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ _currentBindings.Clear();
+ foreach (var action in snapshot.Actions)
+ {
+ _currentBindings[action.ActionName] = [..action.Bindings];
+ }
+ }
+
+ ///
+ public void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+ ArgumentNullException.ThrowIfNull(binding);
+
+ var targetBindings = GetOrCreateBindings(actionName);
+ var existingOwner = FindOwner(actionName, binding);
+
+ if (existingOwner is not null)
+ {
+ if (!swapIfTaken)
+ {
+ return;
+ }
+
+ var previousPrimary = targetBindings.Count > 0 ? targetBindings[0] : null;
+ var ownerBindings = GetOrCreateBindings(existingOwner);
+ ReplaceBinding(ownerBindings, binding, previousPrimary);
+ }
+
+ RemoveBinding(targetBindings, binding);
+ targetBindings.Insert(0, binding);
+ }
+
+ ///
+ public void ResetAction(string actionName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+
+ if (_defaultBindings.TryGetValue(actionName, out var bindings))
+ {
+ _currentBindings[actionName] = [..bindings];
+ return;
+ }
+
+ _currentBindings.Remove(actionName);
+ }
+
+ ///
+ public void ResetAll()
+ {
+ _currentBindings.Clear();
+ foreach (var pair in _defaultBindings)
+ {
+ _currentBindings[pair.Key] = [..pair.Value];
+ }
+ }
+
+ private static Dictionary> ToDictionary(InputBindingSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ return snapshot.Actions.ToDictionary(
+ static action => action.ActionName,
+ static action => action.Bindings.ToList(),
+ StringComparer.Ordinal);
+ }
+
+ private static Dictionary> CloneDictionary(
+ IReadOnlyDictionary> source)
+ {
+ return source.ToDictionary(
+ static pair => pair.Key,
+ static pair => pair.Value.ToList(),
+ StringComparer.Ordinal);
+ }
+
+ private static void RemoveBinding(List bindings, InputBindingDescriptor binding)
+ {
+ bindings.RemoveAll(existing => AreEquivalent(existing, binding));
+ }
+
+ private static void ReplaceBinding(
+ List bindings,
+ InputBindingDescriptor bindingToReplace,
+ InputBindingDescriptor? replacement)
+ {
+ var index = bindings.FindIndex(existing => AreEquivalent(existing, bindingToReplace));
+ if (index < 0)
+ {
+ return;
+ }
+
+ bindings.RemoveAt(index);
+ if (replacement is not null)
+ {
+ bindings.Insert(index, replacement);
+ }
+ }
+
+ private static bool AreEquivalent(InputBindingDescriptor left, InputBindingDescriptor right)
+ {
+ return left.DeviceKind == right.DeviceKind
+ && left.BindingKind == right.BindingKind
+ && string.Equals(left.Code, right.Code, StringComparison.Ordinal)
+ && Nullable.Equals(left.AxisDirection, right.AxisDirection);
+ }
+
+ private List GetOrCreateBindings(string actionName)
+ {
+ if (!_currentBindings.TryGetValue(actionName, out var bindings))
+ {
+ bindings = [];
+ _currentBindings[actionName] = bindings;
+ }
+
+ return bindings;
+ }
+
+ private string? FindOwner(string excludedActionName, InputBindingDescriptor binding)
+ {
+ foreach (var pair in _currentBindings)
+ {
+ if (string.Equals(pair.Key, excludedActionName, StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ if (pair.Value.Any(existing => AreEquivalent(existing, binding)))
+ {
+ return pair.Key;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/GFramework.Game/Input/InputDeviceTracker.cs b/GFramework.Game/Input/InputDeviceTracker.cs
new file mode 100644
index 00000000..48fc5af3
--- /dev/null
+++ b/GFramework.Game/Input/InputDeviceTracker.cs
@@ -0,0 +1,41 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+
+namespace GFramework.Game.Input;
+
+///
+/// 提供可由宿主侧更新的默认输入设备跟踪器。
+///
+public sealed class InputDeviceTracker : IInputDeviceTracker
+{
+ ///
+ /// 初始化输入设备跟踪器。
+ ///
+ public InputDeviceTracker()
+ {
+ CurrentDevice = new InputDeviceContext(InputDeviceKind.Unknown);
+ }
+
+ ///
+ ///
+ /// 该属性不提供额外同步原语。
+ /// 宿主应在同一输入线程内调用 并读取当前值,例如 Godot 的主线程或输入事件线程。
+ ///
+ public InputDeviceContext CurrentDevice { get; private set; }
+
+ ///
+ /// 使用新的宿主设备上下文覆盖当前状态。
+ ///
+ /// 新的设备上下文。
+ ///
+ /// 该方法设计给宿主输入线程串行调用。
+ /// 如果宿主需要跨线程读取设备上下文,应在外层提供自己的同步策略,而不是依赖此类型完成可见性保证。
+ ///
+ /// 当 为 时抛出。
+ public void Update(InputDeviceContext context)
+ {
+ CurrentDevice = context ?? throw new ArgumentNullException(nameof(context));
+ }
+}
diff --git a/GFramework.Game/Input/UiInputActionMap.cs b/GFramework.Game/Input/UiInputActionMap.cs
new file mode 100644
index 00000000..ca0326c1
--- /dev/null
+++ b/GFramework.Game/Input/UiInputActionMap.cs
@@ -0,0 +1,39 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Abstractions.UI;
+
+namespace GFramework.Game.Input;
+
+///
+/// 提供动作名称到 UI 语义动作的默认映射实现。
+///
+///
+/// 默认映射只负责桥接现有 `UiInputAction` 语义,并通过字符串别名兼容 Godot 常见 `ui_*` 动作命名。
+/// 更复杂的项目级 action map 可以通过自定义实现覆盖该行为。
+///
+public sealed class UiInputActionMap : IUiInputActionMap
+{
+ private static readonly IReadOnlyDictionary DefaultMappings =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["cancel"] = UiInputAction.Cancel,
+ ["ui_cancel"] = UiInputAction.Cancel,
+ ["confirm"] = UiInputAction.Confirm,
+ ["ui_accept"] = UiInputAction.Confirm,
+ ["submit"] = UiInputAction.Confirm
+ };
+
+ ///
+ public bool TryMap(string actionName, out UiInputAction action)
+ {
+ if (string.IsNullOrWhiteSpace(actionName))
+ {
+ action = UiInputAction.None;
+ return false;
+ }
+
+ return DefaultMappings.TryGetValue(actionName, out action);
+ }
+}
diff --git a/GFramework.Game/Input/UiInputDispatcher.cs b/GFramework.Game/Input/UiInputDispatcher.cs
new file mode 100644
index 00000000..f5418aa9
--- /dev/null
+++ b/GFramework.Game/Input/UiInputDispatcher.cs
@@ -0,0 +1,40 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Abstractions.UI;
+
+namespace GFramework.Game.Input;
+
+///
+/// 提供逻辑动作到 UI 路由语义分发的默认桥接。
+///
+public sealed class UiInputDispatcher : IUiInputDispatcher
+{
+ private readonly IUiInputActionMap _actionMap;
+ private readonly IUiRouter _router;
+
+ ///
+ /// 初始化 UI 输入分发器。
+ ///
+ /// 动作映射表。
+ /// 目标 UI 路由器。
+ /// 当 为 时抛出。
+ /// 当 为 时抛出。
+ public UiInputDispatcher(IUiInputActionMap actionMap, IUiRouter router)
+ {
+ _actionMap = actionMap ?? throw new ArgumentNullException(nameof(actionMap));
+ _router = router ?? throw new ArgumentNullException(nameof(router));
+ }
+
+ ///
+ public bool TryDispatch(string actionName)
+ {
+ if (!_actionMap.TryMap(actionName, out var action))
+ {
+ return false;
+ }
+
+ return _router.TryDispatchUiAction(action);
+ }
+}
diff --git a/GFramework.Game/README.md b/GFramework.Game/README.md
index e6bd0736..2e55e53a 100644
--- a/GFramework.Game/README.md
+++ b/GFramework.Game/README.md
@@ -15,6 +15,7 @@
- 数据与存档:`Data/`
- 设置系统:`Setting/`
- 场景与 UI 路由基类:`Scene/`、`UI/`
+ - 动作绑定与 UI 输入桥接:`Input/`
- 序列化与文件存储:`Serializer/`、`Storage/`
## 与相邻包的关系
@@ -158,6 +159,22 @@
- [场景系统](../docs/zh-CN/game/scene.md)
- [UI 系统](../docs/zh-CN/game/ui.md)
+### `Input/`
+
+- `InputBindingStore`
+ - 纯托管动作绑定存储
+ - 提供默认快照恢复、主绑定替换、冲突交换与快照导入导出
+- `InputDeviceTracker`
+ - 提供当前活跃设备上下文的默认持有者
+- `UiInputActionMap`
+ - 把 `ui_accept` / `ui_cancel` 等逻辑动作桥接到 `UiInputAction`
+- `UiInputDispatcher`
+ - 把逻辑动作名继续分发给 `IUiRouter`
+
+对应文档:
+
+- [输入系统](../docs/zh-CN/game/input.md)
+
### `Routing/` 与 `State/`
- `Routing/RouterBase`
@@ -176,6 +193,7 @@
| `Config/` | `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap`、`YamlConfigSchemaValidator` | 看 YAML 加载、schema 校验、模块接入与热重载边界 |
| `Data/` `Storage/` `Serializer/` | `DataRepository`、`SaveRepository`、`UnifiedSettingsDataRepository`、`FileStorage`、`JsonSerializer` | 看持久化布局、槽位存档、统一设置文件和底层序列化 / 存储实现 |
| `Setting/` | `SettingsModel`、`SettingsSystem`、`SettingsAppliedEvent` | 看初始化、应用、保存、重置等设置生命周期编排 |
+| `Input/` | `InputBindingStore`、`InputDeviceTracker`、`UiInputActionMap`、`UiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接的默认运行时实现 |
| `Scene/` `UI/` `Routing/` | `SceneRouterBase`、`UiRouterBase`、`SceneTransitionPipeline`、`UiTransitionPipeline`、`RouterBase` | 看路由基类、转换处理器和项目层需要自己提供的 factory / root 边界 |
| `Extensions/` `Internal/` `State/` | `DataLocationExtensions`、`VersionedMigrationRunner`、`GameStateMachineSystem` | 看辅助扩展、内部迁移执行逻辑和游戏态状态机封装 |
@@ -354,6 +372,7 @@ public sealed class MyUiRouter : UiRouterBase
- 序列化系统:[序列化系统](../docs/zh-CN/game/serialization.md)
- 场景系统:[场景系统](../docs/zh-CN/game/scene.md)
- UI 系统:[UI 系统](../docs/zh-CN/game/ui.md)
+- 输入系统:[输入系统](../docs/zh-CN/game/input.md)
## 什么时候不该直接依赖本包
diff --git a/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs b/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
new file mode 100644
index 00000000..0f992040
--- /dev/null
+++ b/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
@@ -0,0 +1,316 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+using GFramework.Godot.Input;
+
+namespace GFramework.Godot.Tests.Input;
+
+///
+/// 验证 Godot 输入绑定存储在纯托管后端上的动作快照、导入与冲突交换语义。
+///
+[TestFixture]
+public sealed class GodotInputBindingStoreTests
+{
+ ///
+ /// 验证导出快照会反映后端提供的框架绑定描述。
+ ///
+ [Test]
+ public void ExportSnapshot_Should_ReturnBackendBindings()
+ {
+ var backend = new FakeInputMapBackend(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:13",
+ "Enter")
+ ])
+ ]));
+
+ var store = new GodotInputBindingStore(backend);
+ var snapshot = store.ExportSnapshot();
+ var acceptBindings = snapshot.Actions.Single(
+ action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal));
+
+ Assert.That(acceptBindings.Bindings[0].Code, Is.EqualTo("key:13"));
+ }
+
+ ///
+ /// 验证导入快照后会把新绑定回写到后端,并能重新导出。
+ ///
+ [Test]
+ public void ImportSnapshot_Should_UpdateBackendBindings()
+ {
+ var backend = new FakeInputMapBackend(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:13",
+ "Enter")
+ ])
+ ]));
+
+ var store = new GodotInputBindingStore(backend);
+ store.ImportSnapshot(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:32",
+ "Space")
+ ])
+ ]));
+
+ var snapshot = store.ExportSnapshot();
+ var acceptBindings = snapshot.Actions.Single(
+ action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal));
+
+ Assert.That(acceptBindings.Bindings[0].Code, Is.EqualTo("key:32"));
+ }
+
+ ///
+ /// 验证导入快照时,会清空快照中未出现动作的后端绑定。
+ ///
+ [Test]
+ public void ImportSnapshot_WhenActionMissingFromSnapshot_Should_ClearBackendBindings()
+ {
+ var backend = new FakeInputMapBackend(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:13",
+ "Enter")
+ ]),
+ new InputActionBinding(
+ "ui_cancel",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:27",
+ "Escape")
+ ])
+ ]));
+
+ var store = new GodotInputBindingStore(backend);
+ store.ImportSnapshot(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:32",
+ "Space")
+ ])
+ ]));
+
+ var snapshot = store.ExportSnapshot();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ snapshot.Actions.Single(action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal)).Bindings[0].Code,
+ Is.EqualTo("key:32"));
+ Assert.That(
+ snapshot.Actions.Single(action => string.Equals(action.ActionName, "ui_cancel", StringComparison.Ordinal)).Bindings,
+ Is.Empty);
+ });
+ }
+
+ ///
+ /// 验证从纯托管绑定设置主绑定时,会保留 `Game` 层冲突交换语义。
+ ///
+ [Test]
+ public void SetPrimaryBinding_WhenBindingTaken_SwapsBackendBindings()
+ {
+ var backend = new FakeInputMapBackend(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "move_left",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:65",
+ "A")
+ ]),
+ new InputActionBinding(
+ "move_right",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:68",
+ "D")
+ ])
+ ]));
+
+ var store = new GodotInputBindingStore(backend);
+ store.SetPrimaryBinding(
+ "move_left",
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:68",
+ "D"));
+
+ var snapshot = store.ExportSnapshot();
+ var moveLeft = snapshot.Actions.Single(
+ action => string.Equals(action.ActionName, "move_left", StringComparison.Ordinal));
+ var moveRight = snapshot.Actions.Single(
+ action => string.Equals(action.ActionName, "move_right", StringComparison.Ordinal));
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(moveLeft.Bindings[0].Code, Is.EqualTo("key:68"));
+ Assert.That(moveRight.Bindings[0].Code, Is.EqualTo("key:65"));
+ });
+ }
+
+ ///
+ /// 验证重置全部绑定时,会移除运行时新增且默认快照中不存在的动作。
+ ///
+ [Test]
+ public void ResetAll_WhenRuntimeActionIsNotInDefaults_Should_RemoveAction()
+ {
+ var backend = new FakeInputMapBackend(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:13",
+ "Enter")
+ ])
+ ]));
+
+ var store = new GodotInputBindingStore(backend);
+ store.ImportSnapshot(
+ new InputBindingSnapshot(
+ [
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:13",
+ "Enter")
+ ]),
+ new InputActionBinding(
+ "debug_toggle",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:192",
+ "QuoteLeft")
+ ])
+ ]));
+
+ store.ResetAll();
+
+ var snapshot = store.ExportSnapshot();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ snapshot.Actions.Any(action => string.Equals(action.ActionName, "ui_accept", StringComparison.Ordinal)),
+ Is.True);
+ Assert.That(
+ snapshot.Actions.Any(action => string.Equals(action.ActionName, "debug_toggle", StringComparison.Ordinal)),
+ Is.False);
+ });
+ }
+
+ ///
+ /// 测试用的纯托管 InputMap 后端。
+ ///
+ private sealed class FakeInputMapBackend : IGodotInputMapBackend
+ {
+ private readonly Dictionary> _defaults;
+ private readonly Dictionary> _current;
+
+ ///
+ /// 初始化测试后端。
+ ///
+ /// 初始快照。
+ public FakeInputMapBackend(InputBindingSnapshot snapshot)
+ {
+ _defaults = snapshot.Actions.ToDictionary(
+ static action => action.ActionName,
+ static action => action.Bindings.ToList(),
+ StringComparer.Ordinal);
+ _current = snapshot.Actions.ToDictionary(
+ static action => action.ActionName,
+ static action => action.Bindings.ToList(),
+ StringComparer.Ordinal);
+ }
+
+ ///
+ public IReadOnlyList GetActionNames()
+ {
+ return [.._current.Keys.OrderBy(static key => key, StringComparer.Ordinal)];
+ }
+
+ ///
+ public IReadOnlyList GetBindings(string actionName)
+ {
+ return _current.TryGetValue(actionName, out var bindings) ? [..bindings] : Array.Empty();
+ }
+
+ ///
+ public void SetBindings(string actionName, IReadOnlyList bindings)
+ {
+ _current[actionName] = [..bindings];
+ }
+
+ ///
+ public void ResetAction(string actionName)
+ {
+ if (_defaults.TryGetValue(actionName, out var bindings))
+ {
+ _current[actionName] = [..bindings];
+ return;
+ }
+
+ _current.Remove(actionName);
+ }
+
+ ///
+ public void ResetAll()
+ {
+ _current.Clear();
+ foreach (var pair in _defaults)
+ {
+ _current[pair.Key] = [..pair.Value];
+ }
+ }
+ }
+}
diff --git a/GFramework.Godot/Input/GodotInputBindingCodec.cs b/GFramework.Godot/Input/GodotInputBindingCodec.cs
new file mode 100644
index 00000000..15432dd7
--- /dev/null
+++ b/GFramework.Godot/Input/GodotInputBindingCodec.cs
@@ -0,0 +1,191 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using System.Globalization;
+using GFramework.Game.Abstractions.Input;
+
+namespace GFramework.Godot.Input;
+
+///
+/// 负责在 Godot 原生输入事件与框架绑定描述之间做双向转换。
+///
+internal static class GodotInputBindingCodec
+{
+ ///
+ /// 尝试把原生输入事件转换成框架绑定描述。
+ ///
+ /// 原生输入事件。
+ /// 转换后的绑定描述。
+ /// 如果转换成功则返回 。
+ public static bool TryCreateBinding(InputEvent inputEvent, out InputBindingDescriptor binding)
+ {
+ ArgumentNullException.ThrowIfNull(inputEvent);
+
+ switch (inputEvent)
+ {
+ case InputEventKey keyEvent:
+ var keyCode = GetKeyCode(keyEvent);
+ binding = new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ FormattableString.Invariant($"key:{(int)keyCode}"),
+ keyCode.ToString());
+ return true;
+ case InputEventMouseButton mouseButtonEvent:
+ binding = new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.MouseButton,
+ FormattableString.Invariant($"mouse:{(int)mouseButtonEvent.ButtonIndex}"),
+ mouseButtonEvent.ButtonIndex.ToString());
+ return true;
+ case InputEventJoypadButton joypadButtonEvent:
+ binding = new InputBindingDescriptor(
+ InputDeviceKind.Gamepad,
+ InputBindingKind.GamepadButton,
+ FormattableString.Invariant($"joy-button:{(int)joypadButtonEvent.ButtonIndex}"),
+ joypadButtonEvent.ButtonIndex.ToString());
+ return true;
+ case InputEventJoypadMotion joypadMotionEvent:
+ var direction = joypadMotionEvent.AxisValue >= 0f ? 1f : -1f;
+ binding = new InputBindingDescriptor(
+ InputDeviceKind.Gamepad,
+ InputBindingKind.GamepadAxis,
+ FormattableString.Invariant($"joy-axis:{(int)joypadMotionEvent.Axis}:{direction.ToString(CultureInfo.InvariantCulture)}"),
+ GetAxisDisplayName(joypadMotionEvent.Axis, direction),
+ direction);
+ return true;
+ default:
+ binding = null!;
+ return false;
+ }
+ }
+
+ ///
+ /// 把框架绑定描述还原为 Godot 输入事件。
+ ///
+ /// 绑定描述。
+ /// 可写回 `InputMap` 的输入事件。
+ /// 当绑定描述无法转换时抛出。
+ public static InputEvent CreateInputEvent(InputBindingDescriptor binding)
+ {
+ ArgumentNullException.ThrowIfNull(binding);
+
+ return binding.BindingKind switch
+ {
+ InputBindingKind.Key => CreateKeyEvent(binding),
+ InputBindingKind.MouseButton => CreateMouseButtonEvent(binding),
+ InputBindingKind.GamepadButton => CreateGamepadButtonEvent(binding),
+ InputBindingKind.GamepadAxis => CreateGamepadAxisEvent(binding),
+ _ => throw new ArgumentException($"Unsupported binding kind '{binding.BindingKind}'.", nameof(binding))
+ };
+ }
+
+ ///
+ /// 从原生输入事件推断当前设备上下文。
+ ///
+ /// 原生输入事件。
+ /// 推断出的设备上下文。
+ public static InputDeviceContext GetDeviceContext(InputEvent inputEvent)
+ {
+ ArgumentNullException.ThrowIfNull(inputEvent);
+
+ return inputEvent switch
+ {
+ InputEventKey => new InputDeviceContext(InputDeviceKind.KeyboardMouse),
+ InputEventMouse => new InputDeviceContext(InputDeviceKind.KeyboardMouse),
+ InputEventJoypadButton joypadButtonEvent => CreateGamepadContext(joypadButtonEvent.Device),
+ InputEventJoypadMotion joypadMotionEvent => CreateGamepadContext(joypadMotionEvent.Device),
+ InputEventScreenTouch => new InputDeviceContext(InputDeviceKind.Touch),
+ _ => new InputDeviceContext(InputDeviceKind.Unknown)
+ };
+ }
+
+ private static InputDeviceContext CreateGamepadContext(int deviceIndex)
+ {
+ return new InputDeviceContext(
+ InputDeviceKind.Gamepad,
+ deviceIndex,
+ "gamepad");
+ }
+
+ private static InputEventKey CreateKeyEvent(InputBindingDescriptor binding)
+ {
+ var code = ParseSingleSegment(binding.Code, "key");
+ return new InputEventKey
+ {
+ Keycode = (Key)code,
+ PhysicalKeycode = (Key)code
+ };
+ }
+
+ private static InputEventMouseButton CreateMouseButtonEvent(InputBindingDescriptor binding)
+ {
+ var buttonIndex = ParseSingleSegment(binding.Code, "mouse");
+ return new InputEventMouseButton
+ {
+ ButtonIndex = (MouseButton)buttonIndex
+ };
+ }
+
+ private static InputEventJoypadButton CreateGamepadButtonEvent(InputBindingDescriptor binding)
+ {
+ var buttonIndex = ParseSingleSegment(binding.Code, "joy-button");
+ return new InputEventJoypadButton
+ {
+ ButtonIndex = (JoyButton)buttonIndex
+ };
+ }
+
+ private static InputEventJoypadMotion CreateGamepadAxisEvent(InputBindingDescriptor binding)
+ {
+ var parts = binding.Code.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 3 || !string.Equals(parts[0], "joy-axis", StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"Binding code '{binding.Code}' is not a valid joy-axis code.", nameof(binding));
+ }
+
+ if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var axis))
+ {
+ throw new ArgumentException($"Binding code '{binding.Code}' does not contain a valid axis index.", nameof(binding));
+ }
+
+ if (!float.TryParse(parts[2], NumberStyles.Float, CultureInfo.InvariantCulture, out var direction))
+ {
+ throw new ArgumentException($"Binding code '{binding.Code}' does not contain a valid axis direction.", nameof(binding));
+ }
+
+ return new InputEventJoypadMotion
+ {
+ Axis = (JoyAxis)axis,
+ AxisValue = direction
+ };
+ }
+
+ private static int ParseSingleSegment(string code, string prefix)
+ {
+ var parts = code.Split(':', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
+ if (parts.Length != 2 || !string.Equals(parts[0], prefix, StringComparison.Ordinal))
+ {
+ throw new ArgumentException($"Binding code '{code}' is not a valid {prefix} code.", nameof(code));
+ }
+
+ if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+ {
+ throw new ArgumentException($"Binding code '{code}' does not contain a valid numeric value.", nameof(code));
+ }
+
+ return value;
+ }
+
+ private static Key GetKeyCode(InputEventKey keyEvent)
+ {
+ return keyEvent.PhysicalKeycode != Key.None ? keyEvent.PhysicalKeycode : keyEvent.Keycode;
+ }
+
+ private static string GetAxisDisplayName(JoyAxis axis, float direction)
+ {
+ return direction >= 0f
+ ? FormattableString.Invariant($"{axis} Positive")
+ : FormattableString.Invariant($"{axis} Negative");
+ }
+}
diff --git a/GFramework.Godot/Input/GodotInputBindingStore.cs b/GFramework.Godot/Input/GodotInputBindingStore.cs
new file mode 100644
index 00000000..af471746
--- /dev/null
+++ b/GFramework.Godot/Input/GodotInputBindingStore.cs
@@ -0,0 +1,148 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Input;
+
+namespace GFramework.Godot.Input;
+
+///
+/// 提供基于 Godot `InputMap` 的输入绑定存储实现。
+///
+///
+/// 该类型把 Godot 原生 `InputEvent` / `InputMap` 适配到 `GFramework.Game.Abstractions.Input` 契约。
+/// 项目可以直接用它做重绑定、动作快照导出导入,以及“当前活跃设备”识别。
+///
+public sealed class GodotInputBindingStore : IInputBindingStore, IInputDeviceTracker
+{
+ private readonly IGodotInputMapBackend _backend;
+ private readonly InputBindingStore _state;
+ private readonly InputDeviceTracker _deviceTracker;
+
+ ///
+ /// 初始化一个基于全局 `InputMap` 的输入绑定存储。
+ ///
+ public GodotInputBindingStore()
+ : this(new GodotInputMapBackend())
+ {
+ }
+
+ ///
+ /// 初始化一个可测试的输入绑定存储。
+ ///
+ /// 要使用的 `InputMap` 后端。
+ internal GodotInputBindingStore(IGodotInputMapBackend backend)
+ {
+ _backend = backend ?? throw new ArgumentNullException(nameof(backend));
+ _state = new InputBindingStore(CaptureSnapshotFromBackend());
+ _deviceTracker = new InputDeviceTracker();
+ }
+
+ ///
+ public InputDeviceContext CurrentDevice => _deviceTracker.CurrentDevice;
+
+ ///
+ public InputActionBinding GetBindings(string actionName)
+ {
+ ReloadFromBackend();
+ return _state.GetBindings(actionName);
+ }
+
+ ///
+ public InputBindingSnapshot ExportSnapshot()
+ {
+ ReloadFromBackend();
+ return _state.ExportSnapshot();
+ }
+
+ ///
+ public void ImportSnapshot(InputBindingSnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ ReloadFromBackend();
+ var snapshotActionNames = snapshot.Actions
+ .Select(static action => action.ActionName)
+ .ToHashSet(StringComparer.Ordinal);
+ var removedActionNames = _state.ExportSnapshot().Actions
+ .Select(static action => action.ActionName)
+ .Where(actionName => !snapshotActionNames.Contains(actionName))
+ .ToArray();
+
+ foreach (var actionName in removedActionNames)
+ {
+ _backend.SetBindings(actionName, Array.Empty());
+ }
+
+ foreach (var action in snapshot.Actions)
+ {
+ ApplyActionBindings(action);
+ }
+
+ ReloadFromBackend();
+ }
+
+ ///
+ public void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true)
+ {
+ ReloadFromBackend();
+ _state.SetPrimaryBinding(actionName, binding, swapIfTaken);
+ ApplySnapshot(_state.ExportSnapshot());
+ }
+
+ ///
+ public void ResetAction(string actionName)
+ {
+ _backend.ResetAction(actionName);
+ ReloadFromBackend();
+ }
+
+ ///
+ public void ResetAll()
+ {
+ _backend.ResetAll();
+ ReloadFromBackend();
+ }
+
+ ///
+ /// 使用 Godot 原生输入事件更新当前活跃设备上下文。
+ ///
+ /// 原生输入事件。
+ public void UpdateDeviceFromInput(InputEvent inputEvent)
+ {
+ var context = GodotInputBindingCodec.GetDeviceContext(inputEvent);
+ _deviceTracker.Update(context);
+ }
+
+ private void ApplyActionBindings(InputActionBinding actionBinding)
+ {
+ _backend.SetBindings(actionBinding.ActionName, actionBinding.Bindings);
+ }
+
+ private void ApplySnapshot(InputBindingSnapshot snapshot)
+ {
+ foreach (var actionBinding in snapshot.Actions)
+ {
+ ApplyActionBindings(actionBinding);
+ }
+ }
+
+ private InputBindingSnapshot CaptureSnapshotFromBackend()
+ {
+ var actions = _backend.GetActionNames()
+ .Select(CreateActionBinding)
+ .ToArray();
+
+ return new InputBindingSnapshot(actions);
+ }
+
+ private InputActionBinding CreateActionBinding(string actionName)
+ {
+ return new InputActionBinding(actionName, _backend.GetBindings(actionName).ToArray());
+ }
+
+ private void ReloadFromBackend()
+ {
+ _state.ImportSnapshot(CaptureSnapshotFromBackend());
+ }
+}
diff --git a/GFramework.Godot/Input/GodotInputMapBackend.cs b/GFramework.Godot/Input/GodotInputMapBackend.cs
new file mode 100644
index 00000000..89bcb8e7
--- /dev/null
+++ b/GFramework.Godot/Input/GodotInputMapBackend.cs
@@ -0,0 +1,99 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+
+namespace GFramework.Godot.Input;
+
+///
+/// 基于 Godot `InputMap` 的默认后端实现。
+///
+internal sealed class GodotInputMapBackend : IGodotInputMapBackend
+{
+ private readonly Dictionary> _defaults;
+
+ ///
+ /// 初始化后端,并捕获当前 `InputMap` 作为默认快照。
+ ///
+ public GodotInputMapBackend()
+ {
+ _defaults = GetActionNames().ToDictionary(
+ static actionName => actionName,
+ actionName => GetBindings(actionName).ToList(),
+ StringComparer.Ordinal);
+ }
+
+ ///
+ public IReadOnlyList GetActionNames()
+ {
+ return [..InputMap.GetActions().Select(static action => action.ToString())];
+ }
+
+ ///
+ public IReadOnlyList GetBindings(string actionName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+
+ if (!InputMap.HasAction(actionName))
+ {
+ return Array.Empty();
+ }
+
+ var bindings = new List();
+ foreach (var inputEvent in InputMap.ActionGetEvents(actionName))
+ {
+ if (GodotInputBindingCodec.TryCreateBinding(inputEvent, out var binding))
+ {
+ bindings.Add(binding);
+ }
+ }
+
+ return bindings;
+ }
+
+ ///
+ public void SetBindings(string actionName, IReadOnlyList bindings)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+ ArgumentNullException.ThrowIfNull(bindings);
+
+ if (!InputMap.HasAction(actionName))
+ {
+ InputMap.AddAction(actionName);
+ }
+
+ InputMap.ActionEraseEvents(actionName);
+ foreach (var binding in bindings)
+ {
+ InputMap.ActionAddEvent(actionName, GodotInputBindingCodec.CreateInputEvent(binding));
+ }
+ }
+
+ ///
+ public void ResetAction(string actionName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+
+ if (_defaults.TryGetValue(actionName, out var bindings))
+ {
+ SetBindings(actionName, bindings);
+ return;
+ }
+
+ if (InputMap.HasAction(actionName))
+ {
+ // Actions absent from the captured default snapshot should disappear after reset
+ // so the live InputMap matches the original project defaults exactly.
+ InputMap.EraseAction(actionName);
+ }
+ }
+
+ ///
+ public void ResetAll()
+ {
+ foreach (var actionName in GetActionNames())
+ {
+ ResetAction(actionName);
+ }
+ }
+}
diff --git a/GFramework.Godot/Input/IGodotInputMapBackend.cs b/GFramework.Godot/Input/IGodotInputMapBackend.cs
new file mode 100644
index 00000000..84db77a8
--- /dev/null
+++ b/GFramework.Godot/Input/IGodotInputMapBackend.cs
@@ -0,0 +1,47 @@
+// Copyright (c) 2025-2026 GeWuYou
+// SPDX-License-Identifier: Apache-2.0
+
+using GFramework.Game.Abstractions.Input;
+
+namespace GFramework.Godot.Input;
+
+///
+/// 定义 `GodotInputBindingStore` 依赖的最小 `InputMap` 后端能力。
+///
+internal interface IGodotInputMapBackend
+{
+ ///
+ /// 获取当前 `InputMap` 中的动作名。
+ ///
+ /// 动作名列表;永远不会返回 。
+ IReadOnlyList GetActionNames();
+
+ ///
+ /// 获取指定动作的框架绑定描述集合。
+ ///
+ /// 动作名称,不能为 或空白字符串。
+ /// 框架绑定描述集合;永远不会返回 。
+ /// 当 为 或空白字符串时抛出。
+ IReadOnlyList GetBindings(string actionName);
+
+ ///
+ /// 用给定绑定集合替换动作当前绑定。
+ ///
+ /// 动作名称,不能为 或空白字符串。
+ /// 新的绑定集合,不能为 ,但可以为空集合。
+ /// 当 为 或空白字符串时抛出。
+ /// 当 为 时抛出。
+ void SetBindings(string actionName, IReadOnlyList bindings);
+
+ ///
+ /// 将指定动作恢复为项目默认绑定。
+ ///
+ /// 动作名称,不能为 或空白字符串。
+ /// 当 为 或空白字符串时抛出。
+ void ResetAction(string actionName);
+
+ ///
+ /// 将所有动作恢复为项目默认绑定。
+ ///
+ void ResetAll();
+}
diff --git a/GFramework.Godot/README.md b/GFramework.Godot/README.md
index 7f89c45f..7c8bf7ec 100644
--- a/GFramework.Godot/README.md
+++ b/GFramework.Godot/README.md
@@ -16,6 +16,7 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
+- 基于 `InputMap` 的动作绑定适配:`GodotInputBindingStore`
- Godot 特化的配置、存储与设置实现:`GodotYamlConfigLoader`、`GodotFileStorage`、`GodotAudioSettings`
- 宿主侧辅助能力:`Signal(...)` fluent API、Godot 时间源、暂停处理、富文本效果
@@ -60,6 +61,12 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc
这部分负责把 `PackedScene`、`Control`、`CanvasLayer` 等 Godot 对象接入 `GFramework.Game` 的 Scene / UI 契约。
+### `Input/`
+
+- `GodotInputBindingStore`
+
+这部分负责把 `InputMap` 默认绑定、动作重绑定与快照导入导出接到 `GFramework.Game.Abstractions.Input` 契约。
+
### `Config/`、`Storage/` 与 `Setting/`
- `GodotYamlConfigLoader`
@@ -138,6 +145,7 @@ Godot 上。
- 架构集成:[Godot 架构集成](../docs/zh-CN/godot/architecture.md)
- 场景系统:[Godot 场景系统](../docs/zh-CN/godot/scene.md)
- UI 系统:[Godot UI 系统](../docs/zh-CN/godot/ui.md)
+- 输入系统:[Godot 输入集成](../docs/zh-CN/godot/input.md)
- 节点扩展:[Godot 节点扩展](../docs/zh-CN/godot/extensions.md)
- 信号系统:[Godot 信号系统](../docs/zh-CN/godot/signal.md)
- 日志系统:[Godot 日志系统](../docs/zh-CN/godot/logging.md)
diff --git a/ai-plan/public/README.md b/ai-plan/public/README.md
index 3804ab1b..56c46649 100644
--- a/ai-plan/public/README.md
+++ b/ai-plan/public/README.md
@@ -38,6 +38,10 @@ help the current worktree land on the right recovery documents without scanning
- Purpose: continue the data repository persistence hardening plus the settings / serialization follow-up backlog.
- Tracking: `ai-plan/public/data-repository-persistence/todos/data-repository-persistence-tracking.md`
- Trace: `ai-plan/public/data-repository-persistence/traces/data-repository-persistence-trace.md`
+- `input-system-godot-integration`
+ - Purpose: establish the shared input abstraction, default binding runtime, and Godot InputMap integration path.
+ - Tracking: `ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md`
+ - Trace: `ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md`
## Worktree To Active Topic Map
@@ -55,6 +59,9 @@ help the current worktree land on the right recovery documents without scanning
- Branch: `feat/data-repository-persistence`
- Worktree hint: `GFramework-data-repository-persistence`
- Priority 1: `data-repository-persistence`
+- Branch: `feat/input-system-godot-integration`
+ - Worktree hint: `GFramework-input-system-godot-integration`
+ - Priority 1: `input-system-godot-integration`
- Branch: `docs/sdk-update-documentation`
- Worktree hint: `GFramework-update-documentation`
- Priority 1: `documentation-full-coverage-governance`
diff --git a/ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md b/ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md
new file mode 100644
index 00000000..d39cefbd
--- /dev/null
+++ b/ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md
@@ -0,0 +1,58 @@
+# Input System Godot Integration 跟踪
+
+## 目标
+
+在 `GFramework.Game.Abstractions`、`GFramework.Game` 与 `GFramework.Godot` 之间建立统一输入抽象、默认动作绑定运行时与
+Godot `InputMap` 适配,优先服务 UI 语义动作桥接和绑定重映射能力。
+
+## 当前恢复点
+
+- 恢复点编号:`INPUT-GODOT-RP-001`
+- 当前阶段:`Phase 1`
+- 当前焦点:
+ - 已新增 `GFramework.Game.Abstractions.Input` 契约,覆盖动作绑定描述、快照、设备上下文与 UI 输入桥接接口
+ - 已新增 `GFramework.Game.Input` 默认运行时,覆盖纯托管绑定存储、设备上下文持有者和逻辑动作到 `UiInputAction` 的桥接
+ - 已新增 `GFramework.Godot.Input` 适配层,覆盖 `InputMap` 绑定读写与 descriptor-based backend 桥接
+ - 已补 `Game.Tests` 与 `Godot.Tests` 的新增回归,并补 `docs/zh-CN/game/input.md` 与 `docs/zh-CN/godot/input.md`
+ - 已处理 PR `#346` 的首轮 review follow-up,修复只读查询污染快照与 Godot 导入快照残留绑定问题,并补齐 README / XML / `ai-plan` 收尾
+
+## 当前状态摘要
+
+- 统一输入抽象已建立,但当前仍聚焦动作绑定和 UI 输入桥接,不尝试覆盖完整 gameplay input runtime
+- `GodotInputBindingStore` 当前把 `InputMap` 默认绑定和主绑定替换接到框架抽象,允许导出 / 导入 `InputBindingSnapshot`
+- `InputBindingStore.GetBindings(...)` 已改为纯读取语义,不再因查询缺失动作而把空条目带进导出快照
+- `GodotInputBindingStore.ImportSnapshot(...)` 已改为快照级覆盖语义,会清空快照中未出现动作的后端绑定
+- `GodotInputMapBackend.ResetAction(...)` / `ResetAll()` 已对齐默认快照替换语义,运行时新增动作在全量重置后不会残留在 `InputMap`
+- `project.godot -> InputActions` 生成器链路保持不变,新的输入系统直接复用动作名常量,而不是替代它
+
+## 当前风险
+
+- Godot 原生 `InputEvent` 对象在普通 `dotnet test` 宿主中的可测性仍有限
+ - 缓解措施:当前 `Godot.Tests` 只覆盖纯托管 backend 桥接语义,原生 `InputMap` 行为由 `GFramework.Godot` Release build 兜底验证
+- 当前 `UiInputActionMap` 只内置 `ui_accept` / `ui_cancel` 等最小别名集
+ - 缓解措施:后续如需更大动作表,由项目层自定义 `IUiInputActionMap`
+
+## 验证说明
+
+- `dotnet build GFramework.Game.Abstractions/GFramework.Game.Abstractions.csproj -c Release --no-restore -m:1 -nodeReuse:false`
+ - 结果:通过
+- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release --no-restore -m:1 -nodeReuse:false`
+ - 结果:通过
+- `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release --no-restore -m:1 -nodeReuse:false`
+ - 结果:通过
+- `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~InputBindingStoreTests|FullyQualifiedName~UiInputDispatcherTests" -m:1 -p:RestoreFallbackFolders= -nodeReuse:false`
+ - 结果:通过
+- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~GodotInputBindingStoreTests" -m:1 -p:RestoreFallbackFolders= -nodeReuse:false`
+ - 结果:通过
+- `python3 scripts/license-header.py --check --paths GFramework.Game.Abstractions/README.md GFramework.Game.Tests/Input/InputBindingStoreTests.cs GFramework.Game/Input/InputBindingStore.cs GFramework.Game/Input/InputDeviceTracker.cs GFramework.Game/Input/UiInputDispatcher.cs GFramework.Game/README.md GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs GFramework.Godot/Input/GodotInputBindingStore.cs GFramework.Godot/Input/GodotInputMapBackend.cs GFramework.Godot/Input/IGodotInputMapBackend.cs ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md`
+ - 结果:通过(All supported files include an Apache-2.0 license header.)
+- `dotnet build GFramework.Game/GFramework.Game.csproj -c Release -m:1 -nodeReuse:false`
+ - 结果:通过(0 warning, 0 error)
+- `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release -m:1 -nodeReuse:false`
+ - 结果:通过(0 warning, 0 error)
+
+## 下一步
+
+1. 若继续处理 PR review,可再单独评估值对象切换到 `record` 是否值得进入同一个 PR
+2. 若继续扩展输入系统,优先补更多逻辑动作与 gameplay 输入场景,而不是先扩面到品牌图标、震动预设或平台文案
+3. 若要增强 Godot 宿主覆盖,优先补真实 `InputMap` / `InputEvent` 集成测试宿主,而不是把更多原生对象直接放进普通 `dotnet test`
diff --git a/ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md b/ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md
new file mode 100644
index 00000000..16fb9bf8
--- /dev/null
+++ b/ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md
@@ -0,0 +1,95 @@
+# Input System Godot Integration 追踪
+
+## 2026-05-10
+
+### 阶段:统一输入抽象与 Godot 适配首轮落地(RP-001)
+
+- 创建长分支 `feat/input-system-godot-integration`,并在 `feat/input-system-godot-integration#346`
+ 上推进独立实现与验证
+- 在 `GFramework.Game.Abstractions/Input/` 新增:
+ - `InputBindingDescriptor`
+ - `InputActionBinding`
+ - `InputBindingSnapshot`
+ - `InputDeviceContext`
+ - `IInputBindingStore`
+ - `IInputDeviceTracker`
+ - `IUiInputActionMap`
+ - `IUiInputDispatcher`
+- 在 `GFramework.Game/Input/` 新增:
+ - `InputBindingStore`
+ - `InputDeviceTracker`
+ - `UiInputActionMap`
+ - `UiInputDispatcher`
+- 在 `GFramework.Godot/Input/` 新增:
+ - `GodotInputBindingCodec`
+ - `IGodotInputMapBackend`
+ - `GodotInputMapBackend`
+ - `GodotInputBindingStore`
+- 关键设计决策:
+ - 保留字符串动作名,直接复用 `InputActions.*` 常量
+ - 抽象层只暴露 descriptor / snapshot,不暴露 Godot `InputEvent`
+ - Godot backend 改成 descriptor-based contract,避免测试直接依赖原生 `InputEvent` 实例
+ - `SetPrimaryBinding(...)` 改为按完整快照回写后端,以保留冲突交换语义
+- 新增测试:
+ - `GFramework.Game.Tests/Input/InputBindingStoreTests.cs`
+ - `GFramework.Game.Tests/Input/UiInputDispatcherTests.cs`
+ - `GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs`
+- 文档更新:
+ - 新增 `docs/zh-CN/game/input.md`
+ - 新增 `docs/zh-CN/godot/input.md`
+ - 更新 `docs/zh-CN/game/index.md`
+ - 更新 `docs/zh-CN/godot/index.md`
+ - 更新 `docs/zh-CN/tutorials/godot-integration.md`
+ - 更新 `GFramework.Game.Abstractions/README.md`
+ - 更新 `GFramework.Game/README.md`
+ - 更新 `GFramework.Godot/README.md`
+
+### 下一步
+
+1. 若继续推进输入系统,优先定义更多逻辑动作与 gameplay 输入桥接,而不是先扩到宿主品牌文案
+2. 若要增强 Godot 验证,单独准备真实 `InputMap` / `InputEvent` 集成宿主,而不是依赖普通 VSTest process
+
+## 2026-05-11
+
+### 阶段:PR #346 review follow-up(RP-001)
+
+- 核对当前分支 PR `#346` 的 CodeRabbit review,确认以下问题仍然适用于本地代码:
+ - `InputBindingStore.GetBindings(...)` 读取缺失动作时会隐式创建空条目,并污染 `ExportSnapshot()`
+ - `GodotInputBindingStore.ImportSnapshot(...)` 只覆盖快照内动作,未清空后端残留绑定
+ - `InputDeviceTracker` / `UiInputDispatcher` / `IGodotInputMapBackend` 的 XML 文档缺少线程或异常契约
+ - `GFramework.Game.Abstractions/README.md` 与 `GFramework.Game/README.md` 缺少输入系统文档入口
+ - 公开 trace 中仍包含 worktree 目录名,已改为 `feat/input-system-godot-integration#346`
+- 本轮未跟进 `InputBindingDescriptor`、`InputActionBinding`、`InputBindingSnapshot`、`InputDeviceContext` 改成 `record` 的 nitpick
+ - 原因:这些建议偏向值语义风格统一,不是当前 PR 中已验证的行为缺陷;本轮优先收敛真实回归风险与契约缺口
+- 新增回归测试:
+ - `InputBindingStoreTests.GetBindings_WhenActionMissing_Should_NotMutateSnapshot`
+ - `GodotInputBindingStoreTests.ImportSnapshot_WhenActionMissingFromSnapshot_Should_ClearBackendBindings`
+- 验证结果:
+ - `dotnet test GFramework.Game.Tests/GFramework.Game.Tests.csproj -c Release --filter "FullyQualifiedName~InputBindingStoreTests|FullyQualifiedName~UiInputDispatcherTests"` 通过
+ - `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~GodotInputBindingStoreTests"` 通过
+
+### 阶段:PR #346 review 二次 follow-up(RP-001)
+
+- 再次抓取当前分支 PR `#346` 的 latest-head review threads,区分已在本地修复但 GitHub 线程仍未折叠的问题与仍然有效的问题
+- 确认以下 review 点已在本地代码中成立并继续处理:
+ - `InputBindingStore` 缺少共享可变状态的线程安全使用约束说明
+ - `GodotInputBindingCodec.TryCreateBinding(...)` 在键盘事件分支重复计算 `GetKeyCode(...)`
+ - `GodotInputMapBackend.ResetAll()` 对运行时新增动作只清空事件、不移除动作本身,和默认快照替换语义不一致
+- 新增回归测试:
+ - `GodotInputBindingStoreTests.ResetAll_WhenRuntimeActionIsNotInDefaults_Should_RemoveAction`
+- 验证结果:
+ - `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter "FullyQualifiedName~GodotInputBindingStoreTests"` 通过(5/5)
+ - `python3 scripts/license-header.py --check --paths GFramework.Game.Abstractions/README.md GFramework.Game.Tests/Input/InputBindingStoreTests.cs GFramework.Game/Input/InputBindingStore.cs GFramework.Game/Input/InputDeviceTracker.cs GFramework.Game/Input/UiInputDispatcher.cs GFramework.Game/README.md GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs GFramework.Godot/Input/GodotInputBindingStore.cs GFramework.Godot/Input/GodotInputMapBackend.cs GFramework.Godot/Input/IGodotInputMapBackend.cs ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md` 通过
+ - `dotnet build GFramework.Game/GFramework.Game.csproj -c Release -m:1 -nodeReuse:false` 通过(0 warning, 0 error)
+ - `dotnet build GFramework.Godot/GFramework.Godot.csproj -c Release -m:1 -nodeReuse:false` 通过(0 warning, 0 error)
+ - `git ... diff --check` 通过
+
+### 下一步
+
+1. 如需继续消化 open review threads,可再评估值对象切换到 `record` 的收益与兼容性
+2. 若需要更高置信度的宿主验证,再补真实 Godot `InputMap` 集成测试宿主
+
+### 下一步
+
+1. 运行针对本次改动文件的 license-header 检查并补录结果
+2. 如需继续消化 PR review,再单独评估值对象切换到 `record` 是否值得放进同一个 PR
diff --git a/docs/zh-CN/game/index.md b/docs/zh-CN/game/index.md
index 08036b88..6b9504d9 100644
--- a/docs/zh-CN/game/index.md
+++ b/docs/zh-CN/game/index.md
@@ -40,6 +40,8 @@ description: GFramework.Game 运行时模块的入口、采用顺序与源码阅
- 导航与界面
- [场景系统](./scene.md)
- [UI 系统](./ui.md)
+- 输入与动作绑定
+ - [输入系统](./input.md)
## 最小接入路径
@@ -110,6 +112,7 @@ shape,优先回到 raw YAML 和 schema 设计本体处理,再决定是否拆
4. [数据系统](./data.md)
5. [设置系统](./setting.md)
6. [场景系统](./scene.md)或[UI 系统](./ui.md)
+7. [输入系统](./input.md)
## 源码与 API 阅读入口
diff --git a/docs/zh-CN/game/input.md b/docs/zh-CN/game/input.md
new file mode 100644
index 00000000..2ace128f
--- /dev/null
+++ b/docs/zh-CN/game/input.md
@@ -0,0 +1,97 @@
+---
+title: 输入系统
+description: 说明 GFramework.Game 与 GFramework.Game.Abstractions 当前提供的统一输入契约、默认运行时与 UI 语义桥接边界。
+---
+
+# 输入系统
+
+`GFramework.Game.Abstractions.Input` 与 `GFramework.Game.Input` 提供的是“动作绑定管理”和“UI 语义桥接”这一层输入系统,
+而不是直接替代任何具体引擎的输入 API。
+
+当前 v1 聚焦三件事:
+
+- 用稳定 DTO 描述动作绑定,而不是把引擎原生输入事件暴露给业务层
+- 允许导出 / 导入绑定快照,并支持主绑定替换、冲突交换和默认恢复
+- 把逻辑动作名桥接到现有 `UiInputAction`,继续复用 `UiRouterBase` 的输入仲裁
+
+## 契约层入口
+
+`GFramework.Game.Abstractions.Input` 当前公开这些核心类型:
+
+- `InputBindingDescriptor`
+ - 描述一个动作绑定使用的设备族、绑定类型、稳定码值和展示名称
+- `InputActionBinding`
+ - 描述单个逻辑动作当前持有的绑定集合
+- `InputBindingSnapshot`
+ - 描述一组动作绑定的可持久化快照
+- `IInputBindingStore`
+ - 定义查询、主绑定替换、快照导入导出与默认恢复契约
+- `IInputDeviceTracker`
+ - 定义当前活跃输入设备上下文查询入口
+- `IUiInputActionMap` / `IUiInputDispatcher`
+ - 定义逻辑动作名到 `UiInputAction` 的桥接边界
+
+这里仍然保留字符串动作名,而不是额外发明新的动作 ID 类型。对 Godot 项目来说,这意味着可以直接继续使用
+`project.godot` 生成出来的 `InputActions.*` 常量。
+
+## 默认运行时
+
+`GFramework.Game.Input` 当前提供的默认实现是:
+
+- `InputBindingStore`
+ - 纯托管输入绑定存储
+ - 管理默认快照、当前快照、主绑定替换与冲突交换
+- `InputDeviceTracker`
+ - 可由宿主侧更新的活跃设备上下文持有者
+- `UiInputActionMap`
+ - 默认把 `ui_cancel` / `cancel` 映射到 `UiInputAction.Cancel`
+ - 默认把 `ui_accept` / `confirm` / `submit` 映射到 `UiInputAction.Confirm`
+- `UiInputDispatcher`
+ - 把逻辑动作名继续分发给 `IUiRouter.TryDispatchUiAction(...)`
+
+也就是说,`Game` 层现在只负责统一输入语义与默认运行时行为;实际的物理输入事件采集仍由宿主层负责。
+
+## 最小接入方式
+
+如果你的项目已经有动作名常量,只想先接入统一输入绑定和 UI 桥接,可以从这组最小组合开始:
+
+```csharp
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Abstractions.UI;
+using GFramework.Game.Input;
+
+var defaultSnapshot = new InputBindingSnapshot(
+[
+ new InputActionBinding(
+ "ui_accept",
+ [
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:13",
+ "Enter")
+ ])
+]);
+
+var bindingStore = new InputBindingStore(defaultSnapshot);
+var dispatcher = new UiInputDispatcher(new UiInputActionMap(), uiRouter);
+```
+
+随后由项目自己的宿主层决定:
+
+- 什么时候读取物理输入
+- 什么时候调用 `SetPrimaryBinding(...)`
+- 什么时候触发 `dispatcher.TryDispatch(...)`
+
+## 当前边界
+
+- 这套输入抽象当前不尝试复刻完整 `PlayerInput` / `ActionMap` 系统
+- 当前只统一动作绑定管理、快照导入导出与 UI 语义桥接
+- 设备品牌识别、平台差异文案、震动等宿主专属能力不在 `Game.Abstractions` 契约层
+- 触摸 / 手柄轴向等更复杂输入源当前只保证 DTO 能表达,不保证 `Game` 层自带完整采集策略
+
+## 相关主题
+
+- [UI 系统](./ui.md)
+- [Godot 输入集成](../godot/input.md)
+- [Godot 集成教程](../tutorials/godot-integration.md)
diff --git a/docs/zh-CN/godot/index.md b/docs/zh-CN/godot/index.md
index 5b0bfe34..95d59e22 100644
--- a/docs/zh-CN/godot/index.md
+++ b/docs/zh-CN/godot/index.md
@@ -15,6 +15,7 @@ description: 以当前 GFramework.Godot 源码、测试与 CoreGrid 接线为准
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
+- 基于 `InputMap` 的动作绑定适配:`GodotInputBindingStore`
- Godot 特化的存储、设置与配置加载:`GodotFileStorage`、`GodotAudioSettings`、`GodotYamlConfigLoader`
- 少量面向运行时交互的扩展:`Signal(...)` fluent API、`GodotLogAppender`、暂停处理、富文本效果、协程时间源
@@ -136,6 +137,7 @@ public partial class SettingsPanel : Control
- 架构锚点与模块挂接:[Godot 架构集成](./architecture.md)
- Scene / `PackedScene` 工厂与行为封装:[Godot 场景系统](./scene.md)
- UI page 行为、layer 语义与工厂:[Godot UI 系统](./ui.md)
+- 动作绑定与 `InputMap` 适配:[Godot 输入集成](./input.md)
- Godot 文件路径与持久化适配:[Godot 存储系统](./storage.md)
- 音频、图形与本地化设置接线:[Godot 设置系统](./setting.md)
- `Signal(...)` fluent API 与动态连接边界:[Godot 信号系统](./signal.md)
@@ -162,4 +164,5 @@ public partial class SettingsPanel : Control
2. [Godot 架构集成](./architecture.md)
3. [Godot 场景系统](./scene.md)
4. [Godot UI 系统](./ui.md)
-5. [Godot 项目元数据生成器](../source-generators/godot-project-generator.md)
+5. [Godot 输入集成](./input.md)
+6. [Godot 项目元数据生成器](../source-generators/godot-project-generator.md)
diff --git a/docs/zh-CN/godot/input.md b/docs/zh-CN/godot/input.md
new file mode 100644
index 00000000..c4d279b0
--- /dev/null
+++ b/docs/zh-CN/godot/input.md
@@ -0,0 +1,67 @@
+---
+title: Godot 输入集成
+description: 说明 GFramework.Godot 如何把 InputMap 与 project.godot InputActions 接到新的框架输入抽象。
+---
+
+# Godot 输入集成
+
+`GFramework.Godot.Input` 负责把 Godot 的 `InputMap` 绑定表接到 `GFramework.Game.Abstractions.Input` 契约。
+
+当前入口是:
+
+- `GodotInputBindingStore`
+ - 读取 / 写回 `InputMap`
+ - 导出 / 导入 `InputBindingSnapshot`
+ - 把逻辑动作名继续桥接给 `Game` 层的绑定存储与 UI 输入分发语义
+
+## 与 `project.godot` 的关系
+
+当前推荐组合仍然是:
+
+- `project.godot`
+ - 继续定义动作名与默认绑定
+- `GFramework.Godot.SourceGenerators`
+ - 继续生成 `InputActions.*` 字符串常量
+- `GFramework.Godot.Input.GodotInputBindingStore`
+ - 负责运行时读取默认绑定、替换主绑定、恢复默认和导出快照
+
+这意味着新的运行时输入系统不会替代 `InputActions`,而是把它当作稳定动作名入口继续使用。
+
+## 最小接入方式
+
+```csharp
+using GFramework.Game.Abstractions.Input;
+using GFramework.Game.Input;
+using GFramework.Godot.Generated;
+using GFramework.Godot.Input;
+
+var bindingStore = new GodotInputBindingStore();
+
+var acceptBinding = bindingStore.GetBindings(InputActions.UiAccept);
+bindingStore.SetPrimaryBinding(
+ InputActions.UiAccept,
+ new InputBindingDescriptor(
+ InputDeviceKind.KeyboardMouse,
+ InputBindingKind.Key,
+ "key:32",
+ "Space"));
+```
+
+如果你已经有 `UiRouterBase`,还可以继续把动作名桥接到 UI 语义:
+
+```csharp
+var dispatcher = new UiInputDispatcher(new UiInputActionMap(), uiRouter);
+dispatcher.TryDispatch(InputActions.UiCancel);
+```
+
+## 当前边界
+
+- `GodotInputBindingStore` 当前聚焦 `InputMap` 绑定管理,而不是完整 gameplay input runtime
+- 当前测试覆盖的是纯托管后端语义,不是 Godot 原生 `InputEvent` 对象在所有宿主中的行为差异
+- 设备品牌、手柄图标、震动预设等宿主特化体验仍应视为 Godot 专属扩展,不上升到 `Game.Abstractions`
+
+## 相关主题
+
+- [Game 输入系统](../game/input.md)
+- [Godot 运行时集成](./index.md)
+- [Godot 集成教程](../tutorials/godot-integration.md)
diff --git a/docs/zh-CN/tutorials/godot-integration.md b/docs/zh-CN/tutorials/godot-integration.md
index 64548938..bd028a6c 100644
--- a/docs/zh-CN/tutorials/godot-integration.md
+++ b/docs/zh-CN/tutorials/godot-integration.md
@@ -10,6 +10,7 @@ description: 以当前源码和真实消费者接线为准,说明 GFramework
- 项目级配置:`project.godot` -> `AutoLoads` / `InputActions`
- 场景级样板:`[GetNode]` / `[BindNodeSignal]`
- 运行时辅助:节点生命周期、事件解绑、异步等待
+- 输入绑定管理:`GodotInputBindingStore`
它不再把旧版长篇 API 列表当事实来源,也不把 `AbstractGodotModule` / `InstallGodotModule(...)` 当成默认接入起点。