From 5f9589ed3c00a94d0585428f81a493d813360593 Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Mon, 11 May 2026 08:53:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(input):=20=E4=BF=AE=E5=A4=8D=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E7=BB=91=E5=AE=9A=E5=BF=AB=E7=85=A7=E4=B8=8E=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 InputBindingStore 只读查询会污染导出快照的问题 - 修复 Godot 输入绑定导入时未清理残留动作绑定的问题 - 补充输入运行时与 Godot backend 的 XML 契约说明和 README 入口 - 更新 ai-plan 跟踪并补充针对 PR #346 的回归测试 --- GFramework.Game.Abstractions/README.md | 1 + .../Input/InputBindingStoreTests.cs | 19 +++++++ GFramework.Game/Input/InputBindingStore.cs | 7 ++- GFramework.Game/Input/InputDeviceTracker.cs | 9 +++ GFramework.Game/Input/UiInputDispatcher.cs | 2 + GFramework.Game/README.md | 1 + .../Input/GodotInputBindingStoreTests.cs | 57 +++++++++++++++++++ .../Input/GodotInputBindingStore.cs | 15 +++++ .../Input/GodotInputMapBackend.cs | 12 +++- .../Input/IGodotInputMapBackend.cs | 16 ++++-- ...input-system-godot-integration-tracking.md | 6 ++ .../input-system-godot-integration-trace.md | 28 ++++++++- 12 files changed, 162 insertions(+), 11 deletions(-) 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