diff --git a/GFramework.Game.Abstractions/README.md b/GFramework.Game.Abstractions/README.md
index 9bcd011e..66954e80 100644
--- a/GFramework.Game.Abstractions/README.md
+++ b/GFramework.Game.Abstractions/README.md
@@ -281,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
index 596283fd..5f3cc671 100644
--- a/GFramework.Game.Tests/Input/InputBindingStoreTests.cs
+++ b/GFramework.Game.Tests/Input/InputBindingStoreTests.cs
@@ -56,6 +56,25 @@ public sealed class InputBindingStoreTests
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(
diff --git a/GFramework.Game/Input/InputBindingStore.cs b/GFramework.Game/Input/InputBindingStore.cs
index 3cf47a50..8cad92a2 100644
--- a/GFramework.Game/Input/InputBindingStore.cs
+++ b/GFramework.Game/Input/InputBindingStore.cs
@@ -30,8 +30,11 @@ public sealed class InputBindingStore : IInputBindingStore
///
public InputActionBinding GetBindings(string actionName)
{
- var bindings = GetOrCreateBindings(actionName);
- return new InputActionBinding(actionName, bindings.ToArray());
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
+
+ return _currentBindings.TryGetValue(actionName, out var bindings)
+ ? new InputActionBinding(actionName, bindings.ToArray())
+ : new InputActionBinding(actionName, Array.Empty());
}
///
diff --git a/GFramework.Game/Input/InputDeviceTracker.cs b/GFramework.Game/Input/InputDeviceTracker.cs
index f17c9ba8..48fc5af3 100644
--- a/GFramework.Game/Input/InputDeviceTracker.cs
+++ b/GFramework.Game/Input/InputDeviceTracker.cs
@@ -19,12 +19,21 @@ public sealed class InputDeviceTracker : IInputDeviceTracker
}
///
+ ///
+ /// 该属性不提供额外同步原语。
+ /// 宿主应在同一输入线程内调用 并读取当前值,例如 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/UiInputDispatcher.cs b/GFramework.Game/Input/UiInputDispatcher.cs
index f6d432b3..f5418aa9 100644
--- a/GFramework.Game/Input/UiInputDispatcher.cs
+++ b/GFramework.Game/Input/UiInputDispatcher.cs
@@ -19,6 +19,8 @@ public sealed class UiInputDispatcher : IUiInputDispatcher
///
/// 动作映射表。
/// 目标 UI 路由器。
+ /// 当 为 时抛出。
+ /// 当 为 时抛出。
public UiInputDispatcher(IUiInputActionMap actionMap, IUiRouter router)
{
_actionMap = actionMap ?? throw new ArgumentNullException(nameof(actionMap));
diff --git a/GFramework.Game/README.md b/GFramework.Game/README.md
index 6b19288f..2e55e53a 100644
--- a/GFramework.Game/README.md
+++ b/GFramework.Game/README.md
@@ -372,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
index f5d910c1..731d112c 100644
--- a/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
+++ b/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
@@ -82,6 +82,63 @@ public sealed class GodotInputBindingStoreTests
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` 层冲突交换语义。
///
diff --git a/GFramework.Godot/Input/GodotInputBindingStore.cs b/GFramework.Godot/Input/GodotInputBindingStore.cs
index 2966a8c2..af471746 100644
--- a/GFramework.Godot/Input/GodotInputBindingStore.cs
+++ b/GFramework.Godot/Input/GodotInputBindingStore.cs
@@ -61,10 +61,25 @@ public sealed class GodotInputBindingStore : IInputBindingStore, IInputDeviceTra
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();
}
///
diff --git a/GFramework.Godot/Input/GodotInputMapBackend.cs b/GFramework.Godot/Input/GodotInputMapBackend.cs
index 9741ca82..49c7c84c 100644
--- a/GFramework.Godot/Input/GodotInputMapBackend.cs
+++ b/GFramework.Godot/Input/GodotInputMapBackend.cs
@@ -32,6 +32,13 @@ internal sealed class GodotInputMapBackend : IGodotInputMapBackend
///
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))
{
@@ -73,7 +80,10 @@ internal sealed class GodotInputMapBackend : IGodotInputMapBackend
return;
}
- InputMap.ActionEraseEvents(actionName);
+ if (InputMap.HasAction(actionName))
+ {
+ InputMap.ActionEraseEvents(actionName);
+ }
}
///
diff --git a/GFramework.Godot/Input/IGodotInputMapBackend.cs b/GFramework.Godot/Input/IGodotInputMapBackend.cs
index 03b80318..84db77a8 100644
--- a/GFramework.Godot/Input/IGodotInputMapBackend.cs
+++ b/GFramework.Godot/Input/IGodotInputMapBackend.cs
@@ -13,27 +13,31 @@ internal interface IGodotInputMapBackend
///
/// 获取当前 `InputMap` 中的动作名。
///
- /// 动作名列表。
+ /// 动作名列表;永远不会返回 。
IReadOnlyList GetActionNames();
///
/// 获取指定动作的框架绑定描述集合。
///
- /// 动作名称。
- /// 框架绑定描述集合。
+ /// 动作名称,不能为 或空白字符串。
+ /// 框架绑定描述集合;永远不会返回 。
+ /// 当 为 或空白字符串时抛出。
IReadOnlyList GetBindings(string actionName);
///
/// 用给定绑定集合替换动作当前绑定。
///
- /// 动作名称。
- /// 新的绑定集合。
+ /// 动作名称,不能为 或空白字符串。
+ /// 新的绑定集合,不能为 ,但可以为空集合。
+ /// 当 为 或空白字符串时抛出。
+ /// 当 为 时抛出。
void SetBindings(string actionName, IReadOnlyList bindings);
///
/// 将指定动作恢复为项目默认绑定。
///
- /// 动作名称。
+ /// 动作名称,不能为 或空白字符串。
+ /// 当 为 或空白字符串时抛出。
void ResetAction(string actionName);
///
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
index d0f23655..a69cc020 100644
--- 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
@@ -14,11 +14,14 @@ Godot `InputMap` 适配,优先服务 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(...)` 已改为快照级覆盖语义,会清空快照中未出现动作的后端绑定
- `project.godot -> InputActions` 生成器链路保持不变,新的输入系统直接复用动作名常量,而不是替代它
## 当前风险
@@ -40,9 +43,12 @@ Godot `InputMap` 适配,优先服务 UI 语义动作桥接和绑定重映射
- 结果:通过
- `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`
+ - 结果:待本轮验证补录
## 下一步
1. 若继续扩展输入系统,优先补更多逻辑动作与 gameplay 输入场景,而不是先扩面到品牌图标、震动预设或平台文案
2. 若要增强 Godot 宿主覆盖,优先补真实 `InputMap` / `InputEvent` 集成测试宿主,而不是把更多原生对象直接放进普通 `dotnet test`
3. 若要开放给消费者使用,继续完善 `README.md`、模块 README 与教程中的采用路径示例
+4. 若继续处理 PR review,可再评估值对象改成 `record` 的收益与兼容性,而不是把该风格建议与行为修复混在同一波提交
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
index be4ecbe4..58d5b82b 100644
--- 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
@@ -4,8 +4,8 @@
### 阶段:统一输入抽象与 Godot 适配首轮落地(RP-001)
-- 创建长分支 `feat/input-system-godot-integration`,并在 `GFramework-WorkTree/GFramework-input-system-godot-integration`
- 建立独立 worktree
+- 创建长分支 `feat/input-system-godot-integration`,并在 `feat/input-system-godot-integration#346`
+ 上推进独立实现与验证
- 在 `GFramework.Game.Abstractions/Input/` 新增:
- `InputBindingDescriptor`
- `InputActionBinding`
@@ -48,3 +48,27 @@
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"` 通过
+
+### 下一步
+
+1. 运行针对本次改动文件的 license-header 检查并补录结果
+2. 如需继续消化 PR review,再单独评估值对象切换到 `record` 是否值得放进同一个 PR