From ebbef321ad4eae490a11c2f4a9503e2dc0830e77 Mon Sep 17 00:00:00 2001 From: gewuyou <95328647+GeWuYou@users.noreply.github.com> Date: Sun, 10 May 2026 22:29:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(input):=20=E6=96=B0=E5=A2=9E=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=BE=93=E5=85=A5=E6=8A=BD=E8=B1=A1=E4=B8=8EGodot?= =?UTF-8?q?=E9=9B=86=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增输入绑定 DTO、设备上下文和 UI 语义桥接契约。 - 实现 Game 默认输入绑定存储、动作映射和 UI 分发桥接。 - 落地 Godot InputMap 适配、测试覆盖与配套文档。 - 更新 ai-plan 恢复点、worktree 映射与采用入口。 --- .../Input/IInputBindingStore.cs | 52 +++++ .../Input/IInputDeviceTracker.cs | 15 ++ .../Input/IUiInputActionMap.cs | 20 ++ .../Input/IUiInputDispatcher.cs | 17 ++ .../Input/InputActionBinding.cs | 37 ++++ .../Input/InputBindingDescriptor.cs | 67 ++++++ .../Input/InputBindingKind.cs | 35 +++ .../Input/InputBindingSnapshot.cs | 24 +++ .../Input/InputDeviceContext.cs | 41 ++++ .../Input/InputDeviceKind.cs | 34 +++ GFramework.Game.Abstractions/README.md | 14 ++ .../Input/InputBindingStoreTests.cs | 76 +++++++ .../Input/UiInputDispatcherTests.cs | 52 +++++ GFramework.Game/Input/InputBindingStore.cs | 187 ++++++++++++++++ GFramework.Game/Input/InputDeviceTracker.cs | 32 +++ GFramework.Game/Input/UiInputActionMap.cs | 39 ++++ GFramework.Game/Input/UiInputDispatcher.cs | 38 ++++ GFramework.Game/README.md | 18 ++ .../Input/GodotInputBindingStoreTests.cs | 200 ++++++++++++++++++ .../Input/GodotInputBindingCodec.cs | 190 +++++++++++++++++ .../Input/GodotInputBindingStore.cs | 133 ++++++++++++ .../Input/GodotInputMapBackend.cs | 87 ++++++++ .../Input/IGodotInputMapBackend.cs | 43 ++++ GFramework.Godot/README.md | 8 + ai-plan/public/README.md | 7 + ...input-system-godot-integration-tracking.md | 48 +++++ .../input-system-godot-integration-trace.md | 50 +++++ docs/zh-CN/game/index.md | 3 + docs/zh-CN/game/input.md | 97 +++++++++ docs/zh-CN/godot/index.md | 5 +- docs/zh-CN/godot/input.md | 67 ++++++ docs/zh-CN/tutorials/godot-integration.md | 1 + 32 files changed, 1736 insertions(+), 1 deletion(-) create mode 100644 GFramework.Game.Abstractions/Input/IInputBindingStore.cs create mode 100644 GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs create mode 100644 GFramework.Game.Abstractions/Input/IUiInputActionMap.cs create mode 100644 GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs create mode 100644 GFramework.Game.Abstractions/Input/InputActionBinding.cs create mode 100644 GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs create mode 100644 GFramework.Game.Abstractions/Input/InputBindingKind.cs create mode 100644 GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs create mode 100644 GFramework.Game.Abstractions/Input/InputDeviceContext.cs create mode 100644 GFramework.Game.Abstractions/Input/InputDeviceKind.cs create mode 100644 GFramework.Game.Tests/Input/InputBindingStoreTests.cs create mode 100644 GFramework.Game.Tests/Input/UiInputDispatcherTests.cs create mode 100644 GFramework.Game/Input/InputBindingStore.cs create mode 100644 GFramework.Game/Input/InputDeviceTracker.cs create mode 100644 GFramework.Game/Input/UiInputActionMap.cs create mode 100644 GFramework.Game/Input/UiInputDispatcher.cs create mode 100644 GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs create mode 100644 GFramework.Godot/Input/GodotInputBindingCodec.cs create mode 100644 GFramework.Godot/Input/GodotInputBindingStore.cs create mode 100644 GFramework.Godot/Input/GodotInputMapBackend.cs create mode 100644 GFramework.Godot/Input/IGodotInputMapBackend.cs create mode 100644 ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md create mode 100644 ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md create mode 100644 docs/zh-CN/game/input.md create mode 100644 docs/zh-CN/godot/input.md 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..9bcd011e 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` | 看公共路由上下文、存储角色、资源注册表与跨层共享枚举 | ## 最小接入路径 diff --git a/GFramework.Game.Tests/Input/InputBindingStoreTests.cs b/GFramework.Game.Tests/Input/InputBindingStoreTests.cs new file mode 100644 index 00000000..596283fd --- /dev/null +++ b/GFramework.Game.Tests/Input/InputBindingStoreTests.cs @@ -0,0 +1,76 @@ +// 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")); + } + + 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..3cf47a50 --- /dev/null +++ b/GFramework.Game/Input/InputBindingStore.cs @@ -0,0 +1,187 @@ +// Copyright (c) 2025-2026 GeWuYou +// SPDX-License-Identifier: Apache-2.0 + +using GFramework.Game.Abstractions.Input; + +namespace GFramework.Game.Input; + +/// +/// 提供基于内存快照的默认输入绑定存储实现。 +/// +/// +/// 该实现聚焦于框架级动作绑定管理语义:默认值恢复、主绑定替换、冲突交换与快照导入导出。 +/// 它不依赖具体宿主输入事件,适合作为 `Game` 层默认运行时与单元测试基线。 +/// +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) + { + var bindings = GetOrCreateBindings(actionName); + return new InputActionBinding(actionName, bindings.ToArray()); + } + + /// + 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..f17c9ba8 --- /dev/null +++ b/GFramework.Game/Input/InputDeviceTracker.cs @@ -0,0 +1,32 @@ +// 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); + } + + /// + 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..f6d432b3 --- /dev/null +++ b/GFramework.Game/Input/UiInputDispatcher.cs @@ -0,0 +1,38 @@ +// 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..6b19288f 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` | 看辅助扩展、内部迁移执行逻辑和游戏态状态机封装 | diff --git a/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs b/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs new file mode 100644 index 00000000..f5d910c1 --- /dev/null +++ b/GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs @@ -0,0 +1,200 @@ +// 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")); + } + + /// + /// 验证从纯托管绑定设置主绑定时,会保留 `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")); + }); + } + + /// + /// 测试用的纯托管 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..6932072f --- /dev/null +++ b/GFramework.Godot/Input/GodotInputBindingCodec.cs @@ -0,0 +1,190 @@ +// 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: + binding = new InputBindingDescriptor( + InputDeviceKind.KeyboardMouse, + InputBindingKind.Key, + FormattableString.Invariant($"key:{(int)GetKeyCode(keyEvent)}"), + GetKeyCode(keyEvent).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..2966a8c2 --- /dev/null +++ b/GFramework.Godot/Input/GodotInputBindingStore.cs @@ -0,0 +1,133 @@ +// 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(); + foreach (var action in snapshot.Actions) + { + ApplyActionBindings(action); + } + } + + /// + 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..9741ca82 --- /dev/null +++ b/GFramework.Godot/Input/GodotInputMapBackend.cs @@ -0,0 +1,87 @@ +// 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) + { + 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; + } + + InputMap.ActionEraseEvents(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..03b80318 --- /dev/null +++ b/GFramework.Godot/Input/IGodotInputMapBackend.cs @@ -0,0 +1,43 @@ +// 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..d0f23655 --- /dev/null +++ b/ai-plan/public/input-system-godot-integration/todos/input-system-godot-integration-tracking.md @@ -0,0 +1,48 @@ +# 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` + +## 当前状态摘要 + +- 统一输入抽象已建立,但当前仍聚焦动作绑定和 UI 输入桥接,不尝试覆盖完整 gameplay input runtime +- `GodotInputBindingStore` 当前把 `InputMap` 默认绑定和主绑定替换接到框架抽象,允许导出 / 导入 `InputBindingSnapshot` +- `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` + - 结果:通过 + +## 下一步 + +1. 若继续扩展输入系统,优先补更多逻辑动作与 gameplay 输入场景,而不是先扩面到品牌图标、震动预设或平台文案 +2. 若要增强 Godot 宿主覆盖,优先补真实 `InputMap` / `InputEvent` 集成测试宿主,而不是把更多原生对象直接放进普通 `dotnet test` +3. 若要开放给消费者使用,继续完善 `README.md`、模块 README 与教程中的采用路径示例 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..be4ecbe4 --- /dev/null +++ b/ai-plan/public/input-system-godot-integration/traces/input-system-godot-integration-trace.md @@ -0,0 +1,50 @@ +# Input System Godot Integration 追踪 + +## 2026-05-10 + +### 阶段:统一输入抽象与 Godot 适配首轮落地(RP-001) + +- 创建长分支 `feat/input-system-godot-integration`,并在 `GFramework-WorkTree/GFramework-input-system-godot-integration` + 建立独立 worktree +- 在 `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 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(...)` 当成默认接入起点。