fix(input): 修复输入绑定快照与导入语义

- 修复 InputBindingStore 只读查询会污染导出快照的问题

- 修复 Godot 输入绑定导入时未清理残留动作绑定的问题

- 补充输入运行时与 Godot backend 的 XML 契约说明和 README 入口

- 更新 ai-plan 跟踪并补充针对 PR #346 的回归测试
This commit is contained in:
gewuyou 2026-05-11 08:53:14 +08:00
parent ebbef321ad
commit 5f9589ed3c
12 changed files with 162 additions and 11 deletions

View File

@ -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)
## 选择建议

View File

@ -56,6 +56,25 @@ public sealed class InputBindingStoreTests
Is.EqualTo("key:65"));
}
/// <summary>
/// 验证查询不存在的动作时,不会把空条目写回当前快照。
/// </summary>
[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(

View File

@ -30,8 +30,11 @@ public sealed class InputBindingStore : IInputBindingStore
/// <inheritdoc />
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<InputBindingDescriptor>());
}
/// <inheritdoc />

View File

@ -19,12 +19,21 @@ public sealed class InputDeviceTracker : IInputDeviceTracker
}
/// <inheritdoc />
/// <remarks>
/// 该属性不提供额外同步原语。
/// 宿主应在同一输入线程内调用 <see cref="Update" /> 并读取当前值,例如 Godot 的主线程或输入事件线程。
/// </remarks>
public InputDeviceContext CurrentDevice { get; private set; }
/// <summary>
/// 使用新的宿主设备上下文覆盖当前状态。
/// </summary>
/// <param name="context">新的设备上下文。</param>
/// <remarks>
/// 该方法设计给宿主输入线程串行调用。
/// 如果宿主需要跨线程读取设备上下文,应在外层提供自己的同步策略,而不是依赖此类型完成可见性保证。
/// </remarks>
/// <exception cref="ArgumentNullException">当 <paramref name="context" /> 为 <see langword="null" /> 时抛出。</exception>
public void Update(InputDeviceContext context)
{
CurrentDevice = context ?? throw new ArgumentNullException(nameof(context));

View File

@ -19,6 +19,8 @@ public sealed class UiInputDispatcher : IUiInputDispatcher
/// </summary>
/// <param name="actionMap">动作映射表。</param>
/// <param name="router">目标 UI 路由器。</param>
/// <exception cref="ArgumentNullException">当 <paramref name="actionMap" /> 为 <see langword="null" /> 时抛出。</exception>
/// <exception cref="ArgumentNullException">当 <paramref name="router" /> 为 <see langword="null" /> 时抛出。</exception>
public UiInputDispatcher(IUiInputActionMap actionMap, IUiRouter router)
{
_actionMap = actionMap ?? throw new ArgumentNullException(nameof(actionMap));

View File

@ -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)
## 什么时候不该直接依赖本包

View File

@ -82,6 +82,63 @@ public sealed class GodotInputBindingStoreTests
Assert.That(acceptBindings.Bindings[0].Code, Is.EqualTo("key:32"));
}
/// <summary>
/// 验证导入快照时,会清空快照中未出现动作的后端绑定。
/// </summary>
[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);
});
}
/// <summary>
/// 验证从纯托管绑定设置主绑定时,会保留 `Game` 层冲突交换语义。
/// </summary>

View File

@ -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<InputBindingDescriptor>());
}
foreach (var action in snapshot.Actions)
{
ApplyActionBindings(action);
}
ReloadFromBackend();
}
/// <inheritdoc />

View File

@ -32,6 +32,13 @@ internal sealed class GodotInputMapBackend : IGodotInputMapBackend
/// <inheritdoc />
public IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
if (!InputMap.HasAction(actionName))
{
return Array.Empty<InputBindingDescriptor>();
}
var bindings = new List<InputBindingDescriptor>();
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);
}
}
/// <inheritdoc />

View File

@ -13,27 +13,31 @@ internal interface IGodotInputMapBackend
/// <summary>
/// 获取当前 `InputMap` 中的动作名。
/// </summary>
/// <returns>动作名列表。</returns>
/// <returns>动作名列表;永远不会返回 <see langword="null" />。</returns>
IReadOnlyList<string> GetActionNames();
/// <summary>
/// 获取指定动作的框架绑定描述集合。
/// </summary>
/// <param name="actionName">动作名称。</param>
/// <returns>框架绑定描述集合。</returns>
/// <param name="actionName">动作名称,不能为 <see langword="null" /> 或空白字符串。</param>
/// <returns>框架绑定描述集合;永远不会返回 <see langword="null" />。</returns>
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为 <see langword="null" /> 或空白字符串时抛出。</exception>
IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName);
/// <summary>
/// 用给定绑定集合替换动作当前绑定。
/// </summary>
/// <param name="actionName">动作名称。</param>
/// <param name="bindings">新的绑定集合。</param>
/// <param name="actionName">动作名称,不能为 <see langword="null" /> 或空白字符串。</param>
/// <param name="bindings">新的绑定集合,不能为 <see langword="null" />,但可以为空集合。</param>
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为 <see langword="null" /> 或空白字符串时抛出。</exception>
/// <exception cref="ArgumentNullException">当 <paramref name="bindings" /> 为 <see langword="null" /> 时抛出。</exception>
void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> bindings);
/// <summary>
/// 将指定动作恢复为项目默认绑定。
/// </summary>
/// <param name="actionName">动作名称。</param>
/// <param name="actionName">动作名称,不能为 <see langword="null" /> 或空白字符串。</param>
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为 <see langword="null" /> 或空白字符串时抛出。</exception>
void ResetAction(string actionName);
/// <summary>

View File

@ -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` 的收益与兼容性,而不是把该风格建议与行为修复混在同一波提交

View File

@ -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-upRP-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