mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-11 12:14:30 +08:00
fix(input): 修复输入绑定快照与导入语义
- 修复 InputBindingStore 只读查询会污染导出快照的问题 - 修复 Godot 输入绑定导入时未清理残留动作绑定的问题 - 补充输入运行时与 Godot backend 的 XML 契约说明和 README 入口 - 更新 ai-plan 跟踪并补充针对 PR #346 的回归测试
This commit is contained in:
parent
ebbef321ad
commit
5f9589ed3c
@ -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)
|
||||
|
||||
## 选择建议
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)
|
||||
|
||||
## 什么时候不该直接依赖本包
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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` 的收益与兼容性,而不是把该风格建议与行为修复混在同一波提交
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user