mirror of
https://github.com/GeWuYou/GFramework.git
synced 2026-05-11 12:14:30 +08:00
feat(input): 新增统一输入抽象与Godot集成
- 新增输入绑定 DTO、设备上下文和 UI 语义桥接契约。 - 实现 Game 默认输入绑定存储、动作映射和 UI 分发桥接。 - 落地 Godot InputMap 适配、测试覆盖与配套文档。 - 更新 ai-plan 恢复点、worktree 映射与采用入口。
This commit is contained in:
parent
699d0b4896
commit
ebbef321ad
52
GFramework.Game.Abstractions/Input/IInputBindingStore.cs
Normal file
52
GFramework.Game.Abstractions/Input/IInputBindingStore.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义逻辑动作绑定的查询、修改与快照导入导出契约。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 该接口承担框架输入系统的持久化与重绑定边界。
|
||||||
|
/// 宿主层可以把自己的原生输入系统适配到这里,上层业务则只依赖动作名和绑定描述,不直接接触宿主输入事件。
|
||||||
|
/// </remarks>
|
||||||
|
public interface IInputBindingStore
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定动作的当前绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
/// <returns>动作绑定快照。</returns>
|
||||||
|
InputActionBinding GetBindings(string actionName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取所有动作的当前绑定快照。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>全量输入绑定快照。</returns>
|
||||||
|
InputBindingSnapshot ExportSnapshot();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用给定快照替换当前绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">要导入的快照。</param>
|
||||||
|
void ImportSnapshot(InputBindingSnapshot snapshot);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把指定绑定设置为动作的主绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
/// <param name="binding">新绑定。</param>
|
||||||
|
/// <param name="swapIfTaken">是否在冲突时交换已占用绑定。</param>
|
||||||
|
void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将指定动作恢复为默认绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
void ResetAction(string actionName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将所有动作恢复为默认绑定。
|
||||||
|
/// </summary>
|
||||||
|
void ResetAll();
|
||||||
|
}
|
||||||
15
GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs
Normal file
15
GFramework.Game.Abstractions/Input/IInputDeviceTracker.cs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义当前活跃输入设备上下文的查询入口。
|
||||||
|
/// </summary>
|
||||||
|
public interface IInputDeviceTracker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前输入设备上下文。
|
||||||
|
/// </summary>
|
||||||
|
InputDeviceContext CurrentDevice { get; }
|
||||||
|
}
|
||||||
20
GFramework.Game.Abstractions/Input/IUiInputActionMap.cs
Normal file
20
GFramework.Game.Abstractions/Input/IUiInputActionMap.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义逻辑动作名到 UI 语义动作的映射规则。
|
||||||
|
/// </summary>
|
||||||
|
public interface IUiInputActionMap
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试把逻辑动作映射为 UI 语义动作。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">逻辑动作名称。</param>
|
||||||
|
/// <param name="action">映射出的 UI 语义动作。</param>
|
||||||
|
/// <returns>如果映射成功则返回 <see langword="true" />。</returns>
|
||||||
|
bool TryMap(string actionName, out UiInputAction action);
|
||||||
|
}
|
||||||
17
GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs
Normal file
17
GFramework.Game.Abstractions/Input/IUiInputDispatcher.cs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义面向 UI 语义动作的输入分发入口。
|
||||||
|
/// </summary>
|
||||||
|
public interface IUiInputDispatcher
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试把逻辑动作分发到当前 UI 路由。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">逻辑动作名称。</param>
|
||||||
|
/// <returns>如果该动作被映射为 UI 动作并成功分发,则返回 <see langword="true" />。</returns>
|
||||||
|
bool TryDispatch(string actionName);
|
||||||
|
}
|
||||||
37
GFramework.Game.Abstractions/Input/InputActionBinding.cs
Normal file
37
GFramework.Game.Abstractions/Input/InputActionBinding.cs
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述一个逻辑动作当前持有的绑定集合。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InputActionBinding
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个动作绑定快照。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
/// <param name="bindings">当前绑定列表。</param>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="actionName" /> 为空时抛出。</exception>
|
||||||
|
public InputActionBinding(string actionName, IReadOnlyList<InputBindingDescriptor> bindings)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actionName))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Action name cannot be null or whitespace.", nameof(actionName));
|
||||||
|
}
|
||||||
|
|
||||||
|
ActionName = actionName;
|
||||||
|
Bindings = bindings ?? Array.Empty<InputBindingDescriptor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取动作名称。
|
||||||
|
/// </summary>
|
||||||
|
public string ActionName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前绑定列表。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<InputBindingDescriptor> Bindings { get; }
|
||||||
|
}
|
||||||
67
GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs
Normal file
67
GFramework.Game.Abstractions/Input/InputBindingDescriptor.cs
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述一个框架无关的动作绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 该模型是运行时输入系统与宿主适配层之间的稳定交换格式。
|
||||||
|
/// 宿主层负责把原生输入事件转成此描述,抽象层和默认运行时只根据这些字段做查询、冲突检测和持久化。
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class InputBindingDescriptor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个动作绑定描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deviceKind">设备族。</param>
|
||||||
|
/// <param name="bindingKind">绑定类型。</param>
|
||||||
|
/// <param name="code">宿主无关的物理码值。</param>
|
||||||
|
/// <param name="displayName">用于设置界面展示的名称。</param>
|
||||||
|
/// <param name="axisDirection">轴向方向;非轴向绑定时为 <see langword="null" />。</param>
|
||||||
|
/// <exception cref="ArgumentException">当 <paramref name="code" /> 为空时抛出。</exception>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取设备族。
|
||||||
|
/// </summary>
|
||||||
|
public InputDeviceKind DeviceKind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取绑定类型。
|
||||||
|
/// </summary>
|
||||||
|
public InputBindingKind BindingKind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取宿主无关的物理码值。
|
||||||
|
/// </summary>
|
||||||
|
public string Code { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取用于展示的标签。
|
||||||
|
/// </summary>
|
||||||
|
public string DisplayName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取轴向方向。
|
||||||
|
/// </summary>
|
||||||
|
public float? AxisDirection { get; }
|
||||||
|
}
|
||||||
35
GFramework.Game.Abstractions/Input/InputBindingKind.cs
Normal file
35
GFramework.Game.Abstractions/Input/InputBindingKind.cs
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述一个逻辑绑定使用的物理输入类型。
|
||||||
|
/// </summary>
|
||||||
|
public enum InputBindingKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 未指定。
|
||||||
|
/// </summary>
|
||||||
|
Unknown = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 键盘按键。
|
||||||
|
/// </summary>
|
||||||
|
Key = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 鼠标按钮。
|
||||||
|
/// </summary>
|
||||||
|
MouseButton = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手柄按钮。
|
||||||
|
/// </summary>
|
||||||
|
GamepadButton = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 手柄轴向。
|
||||||
|
/// </summary>
|
||||||
|
GamepadAxis = 4
|
||||||
|
}
|
||||||
24
GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs
Normal file
24
GFramework.Game.Abstractions/Input/InputBindingSnapshot.cs
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述一组动作绑定的可持久化快照。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InputBindingSnapshot
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个输入绑定快照。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actions">动作绑定集合。</param>
|
||||||
|
public InputBindingSnapshot(IReadOnlyList<InputActionBinding> actions)
|
||||||
|
{
|
||||||
|
Actions = actions ?? Array.Empty<InputActionBinding>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取动作绑定集合。
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<InputActionBinding> Actions { get; }
|
||||||
|
}
|
||||||
41
GFramework.Game.Abstractions/Input/InputDeviceContext.cs
Normal file
41
GFramework.Game.Abstractions/Input/InputDeviceContext.cs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述当前活跃输入设备上下文。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InputDeviceContext
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个输入设备上下文。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="deviceKind">当前设备族。</param>
|
||||||
|
/// <param name="deviceIndex">设备索引;未知时为 <see langword="null" />。</param>
|
||||||
|
/// <param name="deviceName">宿主归一化后的设备名称。</param>
|
||||||
|
public InputDeviceContext(
|
||||||
|
InputDeviceKind deviceKind,
|
||||||
|
int? deviceIndex = null,
|
||||||
|
string? deviceName = null)
|
||||||
|
{
|
||||||
|
DeviceKind = deviceKind;
|
||||||
|
DeviceIndex = deviceIndex;
|
||||||
|
DeviceName = deviceName ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前设备族。
|
||||||
|
/// </summary>
|
||||||
|
public InputDeviceKind DeviceKind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前设备索引。
|
||||||
|
/// </summary>
|
||||||
|
public int? DeviceIndex { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取宿主归一化后的设备名称。
|
||||||
|
/// </summary>
|
||||||
|
public string DeviceName { get; }
|
||||||
|
}
|
||||||
34
GFramework.Game.Abstractions/Input/InputDeviceKind.cs
Normal file
34
GFramework.Game.Abstractions/Input/InputDeviceKind.cs
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
namespace GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 描述框架级输入设备族。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 该枚举用于跨宿主共享“当前输入来自哪一类设备”的语义。
|
||||||
|
/// 它故意避免暴露 Godot、Unity 或平台 SDK 的原生事件类型,确保上层业务只依赖稳定的设备族判断。
|
||||||
|
/// </remarks>
|
||||||
|
public enum InputDeviceKind
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 未识别或尚未产生任何输入。
|
||||||
|
/// </summary>
|
||||||
|
Unknown = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 键盘与鼠标输入。
|
||||||
|
/// </summary>
|
||||||
|
KeyboardMouse = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 游戏手柄输入。
|
||||||
|
/// </summary>
|
||||||
|
Gamepad = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 触摸输入。
|
||||||
|
/// </summary>
|
||||||
|
Touch = 3
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@
|
|||||||
- 典型使用场景:
|
- 典型使用场景:
|
||||||
- 定义 `IScene`、`IUiPage`、`ISettingsData`、`IData` 等业务对象
|
- 定义 `IScene`、`IUiPage`、`ISettingsData`、`IData` 等业务对象
|
||||||
- 让 feature 包只感知 `IConfigRegistry`、`ISaveRepository<T>`、`ISettingsModel`、`IUiRouter`、`ISceneRouter`
|
- 让 feature 包只感知 `IConfigRegistry`、`ISaveRepository<T>`、`ISettingsModel`、`IUiRouter`、`ISceneRouter`
|
||||||
|
- 在输入层共享动作绑定、设备上下文和 UI 语义桥接契约
|
||||||
- 在引擎适配层之外共享设置、场景参数、UI 参数、存档数据类型
|
- 在引擎适配层之外共享设置、场景参数、UI 参数、存档数据类型
|
||||||
|
|
||||||
## 与相邻包的关系
|
## 与相邻包的关系
|
||||||
@ -131,6 +132,18 @@ UI 页面与路由契约。
|
|||||||
|
|
||||||
`IUiRouter` 不只覆盖页面栈,还覆盖 Overlay / Modal / Toast / Topmost 等层级 UI 语义。
|
`IUiRouter` 不只覆盖页面栈,还覆盖 Overlay / Modal / Toast / Topmost 等层级 UI 语义。
|
||||||
|
|
||||||
|
### `Input/`
|
||||||
|
|
||||||
|
- `InputBindingDescriptor`
|
||||||
|
- `InputActionBinding`
|
||||||
|
- `InputBindingSnapshot`
|
||||||
|
- `IInputBindingStore`
|
||||||
|
- `IInputDeviceTracker`
|
||||||
|
- `IUiInputActionMap`
|
||||||
|
- `IUiInputDispatcher`
|
||||||
|
|
||||||
|
这一层定义的是统一输入抽象、绑定快照与 UI 语义桥接契约。
|
||||||
|
|
||||||
### `Routing/`
|
### `Routing/`
|
||||||
|
|
||||||
- `IRoute`
|
- `IRoute`
|
||||||
@ -164,6 +177,7 @@ Scene 与 UI 路由共享这套基础约定。
|
|||||||
| `Setting/` | `ISettingsData`、`ISettingsModel`、`ISettingsSystem`、`LocalizationSettings` | 看设置数据、应用语义、迁移接口和内置设置对象 |
|
| `Setting/` | `ISettingsData`、`ISettingsModel`、`ISettingsSystem`、`LocalizationSettings` | 看设置数据、应用语义、迁移接口和内置设置对象 |
|
||||||
| `Scene/` | `IScene`、`ISceneRouter`、`ISceneFactory`、`SceneTransitionEvent` | 看场景行为、路由、工厂 / root 边界与转场事件模型 |
|
| `Scene/` | `IScene`、`ISceneRouter`、`ISceneFactory`、`SceneTransitionEvent` | 看场景行为、路由、工厂 / root 边界与转场事件模型 |
|
||||||
| `UI/` | `IUiPage`、`IUiRouter`、`IUiFactory`、`UiInteractionProfile`、`UiTransitionHandlerOptions` | 看页面栈、层级 UI、输入动作与 UI 转场契约 |
|
| `UI/` | `IUiPage`、`IUiRouter`、`IUiFactory`、`UiInteractionProfile`、`UiTransitionHandlerOptions` | 看页面栈、层级 UI、输入动作与 UI 转场契约 |
|
||||||
|
| `Input/` | `InputBindingDescriptor`、`IInputBindingStore`、`IInputDeviceTracker`、`IUiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接契约 |
|
||||||
| `Routing/` `Storage/` `Asset/` `Enums/` | `IRoute`、`IRouteContext`、`IFileStorage`、`IAssetRegistry<T>`、`UiLayer`、`SceneTransitionType` | 看公共路由上下文、存储角色、资源注册表与跨层共享枚举 |
|
| `Routing/` `Storage/` `Asset/` `Enums/` | `IRoute`、`IRouteContext`、`IFileStorage`、`IAssetRegistry<T>`、`UiLayer`、`SceneTransitionType` | 看公共路由上下文、存储角色、资源注册表与跨层共享枚举 |
|
||||||
|
|
||||||
## 最小接入路径
|
## 最小接入路径
|
||||||
|
|||||||
76
GFramework.Game.Tests/Input/InputBindingStoreTests.cs
Normal file
76
GFramework.Game.Tests/Input/InputBindingStoreTests.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证默认输入绑定存储的重绑定、冲突交换与默认恢复行为。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class InputBindingStoreTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 验证主绑定冲突时,会把原绑定交换回被占用动作。
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证重置全部绑定时,会回退到初始化默认快照。
|
||||||
|
/// </summary>
|
||||||
|
[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")
|
||||||
|
])
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
GFramework.Game.Tests/Input/UiInputDispatcherTests.cs
Normal file
52
GFramework.Game.Tests/Input/UiInputDispatcherTests.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证逻辑动作到 UI 路由分发的默认桥接行为。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class UiInputDispatcherTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 `ui_cancel` 会被映射为 `UiInputAction.Cancel` 并继续分发给路由器。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void TryDispatch_WhenActionCanMapToUiAction_ForwardsToRouter()
|
||||||
|
{
|
||||||
|
var router = new Mock<IUiRouter>();
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证未映射的逻辑动作不会触发 UI 路由。
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void TryDispatch_WhenActionIsUnknown_ReturnsFalseWithoutRouting()
|
||||||
|
{
|
||||||
|
var router = new Mock<IUiRouter>();
|
||||||
|
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<UiInputAction>()), Times.Never);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
187
GFramework.Game/Input/InputBindingStore.cs
Normal file
187
GFramework.Game/Input/InputBindingStore.cs
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
using GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供基于内存快照的默认输入绑定存储实现。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 该实现聚焦于框架级动作绑定管理语义:默认值恢复、主绑定替换、冲突交换与快照导入导出。
|
||||||
|
/// 它不依赖具体宿主输入事件,适合作为 `Game` 层默认运行时与单元测试基线。
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class InputBindingStore : IInputBindingStore
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<InputBindingDescriptor>> _defaultBindings;
|
||||||
|
private readonly Dictionary<string, List<InputBindingDescriptor>> _currentBindings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化输入绑定存储。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="defaultSnapshot">默认绑定快照。</param>
|
||||||
|
public InputBindingStore(InputBindingSnapshot defaultSnapshot)
|
||||||
|
{
|
||||||
|
_defaultBindings = ToDictionary(defaultSnapshot);
|
||||||
|
_currentBindings = CloneDictionary(_defaultBindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InputActionBinding GetBindings(string actionName)
|
||||||
|
{
|
||||||
|
var bindings = GetOrCreateBindings(actionName);
|
||||||
|
return new InputActionBinding(actionName, bindings.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ImportSnapshot(InputBindingSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
|
||||||
|
_currentBindings.Clear();
|
||||||
|
foreach (var action in snapshot.Actions)
|
||||||
|
{
|
||||||
|
_currentBindings[action.ActionName] = [..action.Bindings];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAction(string actionName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||||
|
|
||||||
|
if (_defaultBindings.TryGetValue(actionName, out var bindings))
|
||||||
|
{
|
||||||
|
_currentBindings[actionName] = [..bindings];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentBindings.Remove(actionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAll()
|
||||||
|
{
|
||||||
|
_currentBindings.Clear();
|
||||||
|
foreach (var pair in _defaultBindings)
|
||||||
|
{
|
||||||
|
_currentBindings[pair.Key] = [..pair.Value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, List<InputBindingDescriptor>> ToDictionary(InputBindingSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
|
||||||
|
return snapshot.Actions.ToDictionary(
|
||||||
|
static action => action.ActionName,
|
||||||
|
static action => action.Bindings.ToList(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, List<InputBindingDescriptor>> CloneDictionary(
|
||||||
|
IReadOnlyDictionary<string, List<InputBindingDescriptor>> source)
|
||||||
|
{
|
||||||
|
return source.ToDictionary(
|
||||||
|
static pair => pair.Key,
|
||||||
|
static pair => pair.Value.ToList(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RemoveBinding(List<InputBindingDescriptor> bindings, InputBindingDescriptor binding)
|
||||||
|
{
|
||||||
|
bindings.RemoveAll(existing => AreEquivalent(existing, binding));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ReplaceBinding(
|
||||||
|
List<InputBindingDescriptor> 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<InputBindingDescriptor> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
GFramework.Game/Input/InputDeviceTracker.cs
Normal file
32
GFramework.Game/Input/InputDeviceTracker.cs
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
using GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
namespace GFramework.Game.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供可由宿主侧更新的默认输入设备跟踪器。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class InputDeviceTracker : IInputDeviceTracker
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化输入设备跟踪器。
|
||||||
|
/// </summary>
|
||||||
|
public InputDeviceTracker()
|
||||||
|
{
|
||||||
|
CurrentDevice = new InputDeviceContext(InputDeviceKind.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InputDeviceContext CurrentDevice { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用新的宿主设备上下文覆盖当前状态。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="context">新的设备上下文。</param>
|
||||||
|
public void Update(InputDeviceContext context)
|
||||||
|
{
|
||||||
|
CurrentDevice = context ?? throw new ArgumentNullException(nameof(context));
|
||||||
|
}
|
||||||
|
}
|
||||||
39
GFramework.Game/Input/UiInputActionMap.cs
Normal file
39
GFramework.Game/Input/UiInputActionMap.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供动作名称到 UI 语义动作的默认映射实现。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 默认映射只负责桥接现有 `UiInputAction` 语义,并通过字符串别名兼容 Godot 常见 `ui_*` 动作命名。
|
||||||
|
/// 更复杂的项目级 action map 可以通过自定义实现覆盖该行为。
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class UiInputActionMap : IUiInputActionMap
|
||||||
|
{
|
||||||
|
private static readonly IReadOnlyDictionary<string, UiInputAction> DefaultMappings =
|
||||||
|
new Dictionary<string, UiInputAction>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["cancel"] = UiInputAction.Cancel,
|
||||||
|
["ui_cancel"] = UiInputAction.Cancel,
|
||||||
|
["confirm"] = UiInputAction.Confirm,
|
||||||
|
["ui_accept"] = UiInputAction.Confirm,
|
||||||
|
["submit"] = UiInputAction.Confirm
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryMap(string actionName, out UiInputAction action)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(actionName))
|
||||||
|
{
|
||||||
|
action = UiInputAction.None;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultMappings.TryGetValue(actionName, out action);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
GFramework.Game/Input/UiInputDispatcher.cs
Normal file
38
GFramework.Game/Input/UiInputDispatcher.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供逻辑动作到 UI 路由语义分发的默认桥接。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiInputDispatcher : IUiInputDispatcher
|
||||||
|
{
|
||||||
|
private readonly IUiInputActionMap _actionMap;
|
||||||
|
private readonly IUiRouter _router;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化 UI 输入分发器。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionMap">动作映射表。</param>
|
||||||
|
/// <param name="router">目标 UI 路由器。</param>
|
||||||
|
public UiInputDispatcher(IUiInputActionMap actionMap, IUiRouter router)
|
||||||
|
{
|
||||||
|
_actionMap = actionMap ?? throw new ArgumentNullException(nameof(actionMap));
|
||||||
|
_router = router ?? throw new ArgumentNullException(nameof(router));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool TryDispatch(string actionName)
|
||||||
|
{
|
||||||
|
if (!_actionMap.TryMap(actionName, out var action))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _router.TryDispatchUiAction(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@
|
|||||||
- 数据与存档:`Data/`
|
- 数据与存档:`Data/`
|
||||||
- 设置系统:`Setting/`
|
- 设置系统:`Setting/`
|
||||||
- 场景与 UI 路由基类:`Scene/`、`UI/`
|
- 场景与 UI 路由基类:`Scene/`、`UI/`
|
||||||
|
- 动作绑定与 UI 输入桥接:`Input/`
|
||||||
- 序列化与文件存储:`Serializer/`、`Storage/`
|
- 序列化与文件存储:`Serializer/`、`Storage/`
|
||||||
|
|
||||||
## 与相邻包的关系
|
## 与相邻包的关系
|
||||||
@ -158,6 +159,22 @@
|
|||||||
- [场景系统](../docs/zh-CN/game/scene.md)
|
- [场景系统](../docs/zh-CN/game/scene.md)
|
||||||
- [UI 系统](../docs/zh-CN/game/ui.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/` 与 `State/`
|
||||||
|
|
||||||
- `Routing/RouterBase<TRoute, TContext>`
|
- `Routing/RouterBase<TRoute, TContext>`
|
||||||
@ -176,6 +193,7 @@
|
|||||||
| `Config/` | `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap`、`YamlConfigSchemaValidator` | 看 YAML 加载、schema 校验、模块接入与热重载边界 |
|
| `Config/` | `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap`、`YamlConfigSchemaValidator` | 看 YAML 加载、schema 校验、模块接入与热重载边界 |
|
||||||
| `Data/` `Storage/` `Serializer/` | `DataRepository`、`SaveRepository<TSaveData>`、`UnifiedSettingsDataRepository`、`FileStorage`、`JsonSerializer` | 看持久化布局、槽位存档、统一设置文件和底层序列化 / 存储实现 |
|
| `Data/` `Storage/` `Serializer/` | `DataRepository`、`SaveRepository<TSaveData>`、`UnifiedSettingsDataRepository`、`FileStorage`、`JsonSerializer` | 看持久化布局、槽位存档、统一设置文件和底层序列化 / 存储实现 |
|
||||||
| `Setting/` | `SettingsModel<TRepository>`、`SettingsSystem`、`SettingsAppliedEvent<T>` | 看初始化、应用、保存、重置等设置生命周期编排 |
|
| `Setting/` | `SettingsModel<TRepository>`、`SettingsSystem`、`SettingsAppliedEvent<T>` | 看初始化、应用、保存、重置等设置生命周期编排 |
|
||||||
|
| `Input/` | `InputBindingStore`、`InputDeviceTracker`、`UiInputActionMap`、`UiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接的默认运行时实现 |
|
||||||
| `Scene/` `UI/` `Routing/` | `SceneRouterBase`、`UiRouterBase`、`SceneTransitionPipeline`、`UiTransitionPipeline`、`RouterBase<TRoute, TContext>` | 看路由基类、转换处理器和项目层需要自己提供的 factory / root 边界 |
|
| `Scene/` `UI/` `Routing/` | `SceneRouterBase`、`UiRouterBase`、`SceneTransitionPipeline`、`UiTransitionPipeline`、`RouterBase<TRoute, TContext>` | 看路由基类、转换处理器和项目层需要自己提供的 factory / root 边界 |
|
||||||
| `Extensions/` `Internal/` `State/` | `DataLocationExtensions`、`VersionedMigrationRunner`、`GameStateMachineSystem` | 看辅助扩展、内部迁移执行逻辑和游戏态状态机封装 |
|
| `Extensions/` `Internal/` `State/` | `DataLocationExtensions`、`VersionedMigrationRunner`、`GameStateMachineSystem` | 看辅助扩展、内部迁移执行逻辑和游戏态状态机封装 |
|
||||||
|
|
||||||
|
|||||||
200
GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
Normal file
200
GFramework.Godot.Tests/Input/GodotInputBindingStoreTests.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证 Godot 输入绑定存储在纯托管后端上的动作快照、导入与冲突交换语义。
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public sealed class GodotInputBindingStoreTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 验证导出快照会反映后端提供的框架绑定描述。
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证导入快照后会把新绑定回写到后端,并能重新导出。
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 验证从纯托管绑定设置主绑定时,会保留 `Game` 层冲突交换语义。
|
||||||
|
/// </summary>
|
||||||
|
[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"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 测试用的纯托管 InputMap 后端。
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FakeInputMapBackend : IGodotInputMapBackend
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<InputBindingDescriptor>> _defaults;
|
||||||
|
private readonly Dictionary<string, List<InputBindingDescriptor>> _current;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化测试后端。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="snapshot">初始快照。</param>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> GetActionNames()
|
||||||
|
{
|
||||||
|
return [.._current.Keys.OrderBy(static key => key, StringComparer.Ordinal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName)
|
||||||
|
{
|
||||||
|
return _current.TryGetValue(actionName, out var bindings) ? [..bindings] : Array.Empty<InputBindingDescriptor>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> bindings)
|
||||||
|
{
|
||||||
|
_current[actionName] = [..bindings];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAction(string actionName)
|
||||||
|
{
|
||||||
|
if (_defaults.TryGetValue(actionName, out var bindings))
|
||||||
|
{
|
||||||
|
_current[actionName] = [..bindings];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_current.Remove(actionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAll()
|
||||||
|
{
|
||||||
|
_current.Clear();
|
||||||
|
foreach (var pair in _defaults)
|
||||||
|
{
|
||||||
|
_current[pair.Key] = [..pair.Value];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
GFramework.Godot/Input/GodotInputBindingCodec.cs
Normal file
190
GFramework.Godot/Input/GodotInputBindingCodec.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 负责在 Godot 原生输入事件与框架绑定描述之间做双向转换。
|
||||||
|
/// </summary>
|
||||||
|
internal static class GodotInputBindingCodec
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 尝试把原生输入事件转换成框架绑定描述。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inputEvent">原生输入事件。</param>
|
||||||
|
/// <param name="binding">转换后的绑定描述。</param>
|
||||||
|
/// <returns>如果转换成功则返回 <see langword="true" />。</returns>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把框架绑定描述还原为 Godot 输入事件。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="binding">绑定描述。</param>
|
||||||
|
/// <returns>可写回 `InputMap` 的输入事件。</returns>
|
||||||
|
/// <exception cref="ArgumentException">当绑定描述无法转换时抛出。</exception>
|
||||||
|
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))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 从原生输入事件推断当前设备上下文。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inputEvent">原生输入事件。</param>
|
||||||
|
/// <returns>推断出的设备上下文。</returns>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
133
GFramework.Godot/Input/GodotInputBindingStore.cs
Normal file
133
GFramework.Godot/Input/GodotInputBindingStore.cs
Normal file
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 提供基于 Godot `InputMap` 的输入绑定存储实现。
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// 该类型把 Godot 原生 `InputEvent` / `InputMap` 适配到 `GFramework.Game.Abstractions.Input` 契约。
|
||||||
|
/// 项目可以直接用它做重绑定、动作快照导出导入,以及“当前活跃设备”识别。
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class GodotInputBindingStore : IInputBindingStore, IInputDeviceTracker
|
||||||
|
{
|
||||||
|
private readonly IGodotInputMapBackend _backend;
|
||||||
|
private readonly InputBindingStore _state;
|
||||||
|
private readonly InputDeviceTracker _deviceTracker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个基于全局 `InputMap` 的输入绑定存储。
|
||||||
|
/// </summary>
|
||||||
|
public GodotInputBindingStore()
|
||||||
|
: this(new GodotInputMapBackend())
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化一个可测试的输入绑定存储。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="backend">要使用的 `InputMap` 后端。</param>
|
||||||
|
internal GodotInputBindingStore(IGodotInputMapBackend backend)
|
||||||
|
{
|
||||||
|
_backend = backend ?? throw new ArgumentNullException(nameof(backend));
|
||||||
|
_state = new InputBindingStore(CaptureSnapshotFromBackend());
|
||||||
|
_deviceTracker = new InputDeviceTracker();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InputDeviceContext CurrentDevice => _deviceTracker.CurrentDevice;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InputActionBinding GetBindings(string actionName)
|
||||||
|
{
|
||||||
|
ReloadFromBackend();
|
||||||
|
return _state.GetBindings(actionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public InputBindingSnapshot ExportSnapshot()
|
||||||
|
{
|
||||||
|
ReloadFromBackend();
|
||||||
|
return _state.ExportSnapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ImportSnapshot(InputBindingSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(snapshot);
|
||||||
|
|
||||||
|
ReloadFromBackend();
|
||||||
|
foreach (var action in snapshot.Actions)
|
||||||
|
{
|
||||||
|
ApplyActionBindings(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetPrimaryBinding(string actionName, InputBindingDescriptor binding, bool swapIfTaken = true)
|
||||||
|
{
|
||||||
|
ReloadFromBackend();
|
||||||
|
_state.SetPrimaryBinding(actionName, binding, swapIfTaken);
|
||||||
|
ApplySnapshot(_state.ExportSnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAction(string actionName)
|
||||||
|
{
|
||||||
|
_backend.ResetAction(actionName);
|
||||||
|
ReloadFromBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAll()
|
||||||
|
{
|
||||||
|
_backend.ResetAll();
|
||||||
|
ReloadFromBackend();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 使用 Godot 原生输入事件更新当前活跃设备上下文。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="inputEvent">原生输入事件。</param>
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
87
GFramework.Godot/Input/GodotInputMapBackend.cs
Normal file
87
GFramework.Godot/Input/GodotInputMapBackend.cs
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
using GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 基于 Godot `InputMap` 的默认后端实现。
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class GodotInputMapBackend : IGodotInputMapBackend
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, List<InputBindingDescriptor>> _defaults;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 初始化后端,并捕获当前 `InputMap` 作为默认快照。
|
||||||
|
/// </summary>
|
||||||
|
public GodotInputMapBackend()
|
||||||
|
{
|
||||||
|
_defaults = GetActionNames().ToDictionary(
|
||||||
|
static actionName => actionName,
|
||||||
|
actionName => GetBindings(actionName).ToList(),
|
||||||
|
StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> GetActionNames()
|
||||||
|
{
|
||||||
|
return [..InputMap.GetActions().Select(static action => action.ToString())];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName)
|
||||||
|
{
|
||||||
|
var bindings = new List<InputBindingDescriptor>();
|
||||||
|
foreach (var inputEvent in InputMap.ActionGetEvents(actionName))
|
||||||
|
{
|
||||||
|
if (GodotInputBindingCodec.TryCreateBinding(inputEvent, out var binding))
|
||||||
|
{
|
||||||
|
bindings.Add(binding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAction(string actionName)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(actionName);
|
||||||
|
|
||||||
|
if (_defaults.TryGetValue(actionName, out var bindings))
|
||||||
|
{
|
||||||
|
SetBindings(actionName, bindings);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
InputMap.ActionEraseEvents(actionName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void ResetAll()
|
||||||
|
{
|
||||||
|
foreach (var actionName in GetActionNames())
|
||||||
|
{
|
||||||
|
ResetAction(actionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
GFramework.Godot/Input/IGodotInputMapBackend.cs
Normal file
43
GFramework.Godot/Input/IGodotInputMapBackend.cs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) 2025-2026 GeWuYou
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
using GFramework.Game.Abstractions.Input;
|
||||||
|
|
||||||
|
namespace GFramework.Godot.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 定义 `GodotInputBindingStore` 依赖的最小 `InputMap` 后端能力。
|
||||||
|
/// </summary>
|
||||||
|
internal interface IGodotInputMapBackend
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 获取当前 `InputMap` 中的动作名。
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>动作名列表。</returns>
|
||||||
|
IReadOnlyList<string> GetActionNames();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取指定动作的框架绑定描述集合。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
/// <returns>框架绑定描述集合。</returns>
|
||||||
|
IReadOnlyList<InputBindingDescriptor> GetBindings(string actionName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用给定绑定集合替换动作当前绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
/// <param name="bindings">新的绑定集合。</param>
|
||||||
|
void SetBindings(string actionName, IReadOnlyList<InputBindingDescriptor> bindings);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将指定动作恢复为项目默认绑定。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="actionName">动作名称。</param>
|
||||||
|
void ResetAction(string actionName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 将所有动作恢复为项目默认绑定。
|
||||||
|
/// </summary>
|
||||||
|
void ResetAll();
|
||||||
|
}
|
||||||
@ -16,6 +16,7 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc
|
|||||||
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
||||||
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
||||||
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
||||||
|
- 基于 `InputMap` 的动作绑定适配:`GodotInputBindingStore`
|
||||||
- Godot 特化的配置、存储与设置实现:`GodotYamlConfigLoader`、`GodotFileStorage`、`GodotAudioSettings`
|
- Godot 特化的配置、存储与设置实现:`GodotYamlConfigLoader`、`GodotFileStorage`、`GodotAudioSettings`
|
||||||
- 宿主侧辅助能力:`Signal(...)` fluent API、Godot 时间源、暂停处理、富文本效果
|
- 宿主侧辅助能力:`Signal(...)` fluent API、Godot 时间源、暂停处理、富文本效果
|
||||||
|
|
||||||
@ -60,6 +61,12 @@ Scene / UI、配置、存储、设置、日志与协程能力接到 `Node`、`Sc
|
|||||||
|
|
||||||
这部分负责把 `PackedScene`、`Control`、`CanvasLayer` 等 Godot 对象接入 `GFramework.Game` 的 Scene / UI 契约。
|
这部分负责把 `PackedScene`、`Control`、`CanvasLayer` 等 Godot 对象接入 `GFramework.Game` 的 Scene / UI 契约。
|
||||||
|
|
||||||
|
### `Input/`
|
||||||
|
|
||||||
|
- `GodotInputBindingStore`
|
||||||
|
|
||||||
|
这部分负责把 `InputMap` 默认绑定、动作重绑定与快照导入导出接到 `GFramework.Game.Abstractions.Input` 契约。
|
||||||
|
|
||||||
### `Config/`、`Storage/` 与 `Setting/`
|
### `Config/`、`Storage/` 与 `Setting/`
|
||||||
|
|
||||||
- `GodotYamlConfigLoader`
|
- `GodotYamlConfigLoader`
|
||||||
@ -138,6 +145,7 @@ Godot 上。
|
|||||||
- 架构集成:[Godot 架构集成](../docs/zh-CN/godot/architecture.md)
|
- 架构集成:[Godot 架构集成](../docs/zh-CN/godot/architecture.md)
|
||||||
- 场景系统:[Godot 场景系统](../docs/zh-CN/godot/scene.md)
|
- 场景系统:[Godot 场景系统](../docs/zh-CN/godot/scene.md)
|
||||||
- UI 系统:[Godot UI 系统](../docs/zh-CN/godot/ui.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/extensions.md)
|
||||||
- 信号系统:[Godot 信号系统](../docs/zh-CN/godot/signal.md)
|
- 信号系统:[Godot 信号系统](../docs/zh-CN/godot/signal.md)
|
||||||
- 日志系统:[Godot 日志系统](../docs/zh-CN/godot/logging.md)
|
- 日志系统:[Godot 日志系统](../docs/zh-CN/godot/logging.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.
|
- 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`
|
- 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`
|
- 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
|
## 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`
|
- Branch: `feat/data-repository-persistence`
|
||||||
- Worktree hint: `GFramework-data-repository-persistence`
|
- Worktree hint: `GFramework-data-repository-persistence`
|
||||||
- Priority 1: `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`
|
- Branch: `docs/sdk-update-documentation`
|
||||||
- Worktree hint: `GFramework-update-documentation`
|
- Worktree hint: `GFramework-update-documentation`
|
||||||
- Priority 1: `documentation-full-coverage-governance`
|
- Priority 1: `documentation-full-coverage-governance`
|
||||||
|
|||||||
@ -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 与教程中的采用路径示例
|
||||||
@ -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
|
||||||
@ -40,6 +40,8 @@ description: GFramework.Game 运行时模块的入口、采用顺序与源码阅
|
|||||||
- 导航与界面
|
- 导航与界面
|
||||||
- [场景系统](./scene.md)
|
- [场景系统](./scene.md)
|
||||||
- [UI 系统](./ui.md)
|
- [UI 系统](./ui.md)
|
||||||
|
- 输入与动作绑定
|
||||||
|
- [输入系统](./input.md)
|
||||||
|
|
||||||
## 最小接入路径
|
## 最小接入路径
|
||||||
|
|
||||||
@ -110,6 +112,7 @@ shape,优先回到 raw YAML 和 schema 设计本体处理,再决定是否拆
|
|||||||
4. [数据系统](./data.md)
|
4. [数据系统](./data.md)
|
||||||
5. [设置系统](./setting.md)
|
5. [设置系统](./setting.md)
|
||||||
6. [场景系统](./scene.md)或[UI 系统](./ui.md)
|
6. [场景系统](./scene.md)或[UI 系统](./ui.md)
|
||||||
|
7. [输入系统](./input.md)
|
||||||
|
|
||||||
## 源码与 API 阅读入口
|
## 源码与 API 阅读入口
|
||||||
|
|
||||||
|
|||||||
97
docs/zh-CN/game/input.md
Normal file
97
docs/zh-CN/game/input.md
Normal file
@ -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)
|
||||||
@ -15,6 +15,7 @@ description: 以当前 GFramework.Godot 源码、测试与 CoreGrid 接线为准
|
|||||||
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
- 架构生命周期与场景树绑定:`AbstractArchitecture`、`ArchitectureAnchor`
|
||||||
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
- 节点运行时辅助:`WaitUntilReadyAsync()`、`AddChildXAsync()`、`QueueFreeX()`、`UnRegisterWhenNodeExitTree(...)`
|
||||||
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
- Godot 风格的 Scene / UI 工厂与 registry:`GodotSceneFactory`、`GodotUiFactory`
|
||||||
|
- 基于 `InputMap` 的动作绑定适配:`GodotInputBindingStore`
|
||||||
- Godot 特化的存储、设置与配置加载:`GodotFileStorage`、`GodotAudioSettings`、`GodotYamlConfigLoader`
|
- Godot 特化的存储、设置与配置加载:`GodotFileStorage`、`GodotAudioSettings`、`GodotYamlConfigLoader`
|
||||||
- 少量面向运行时交互的扩展:`Signal(...)` fluent API、`GodotLogAppender`、暂停处理、富文本效果、协程时间源
|
- 少量面向运行时交互的扩展:`Signal(...)` fluent API、`GodotLogAppender`、暂停处理、富文本效果、协程时间源
|
||||||
|
|
||||||
@ -136,6 +137,7 @@ public partial class SettingsPanel : Control
|
|||||||
- 架构锚点与模块挂接:[Godot 架构集成](./architecture.md)
|
- 架构锚点与模块挂接:[Godot 架构集成](./architecture.md)
|
||||||
- Scene / `PackedScene` 工厂与行为封装:[Godot 场景系统](./scene.md)
|
- Scene / `PackedScene` 工厂与行为封装:[Godot 场景系统](./scene.md)
|
||||||
- UI page 行为、layer 语义与工厂:[Godot UI 系统](./ui.md)
|
- UI page 行为、layer 语义与工厂:[Godot UI 系统](./ui.md)
|
||||||
|
- 动作绑定与 `InputMap` 适配:[Godot 输入集成](./input.md)
|
||||||
- Godot 文件路径与持久化适配:[Godot 存储系统](./storage.md)
|
- Godot 文件路径与持久化适配:[Godot 存储系统](./storage.md)
|
||||||
- 音频、图形与本地化设置接线:[Godot 设置系统](./setting.md)
|
- 音频、图形与本地化设置接线:[Godot 设置系统](./setting.md)
|
||||||
- `Signal(...)` fluent API 与动态连接边界:[Godot 信号系统](./signal.md)
|
- `Signal(...)` fluent API 与动态连接边界:[Godot 信号系统](./signal.md)
|
||||||
@ -162,4 +164,5 @@ public partial class SettingsPanel : Control
|
|||||||
2. [Godot 架构集成](./architecture.md)
|
2. [Godot 架构集成](./architecture.md)
|
||||||
3. [Godot 场景系统](./scene.md)
|
3. [Godot 场景系统](./scene.md)
|
||||||
4. [Godot UI 系统](./ui.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)
|
||||||
|
|||||||
67
docs/zh-CN/godot/input.md
Normal file
67
docs/zh-CN/godot/input.md
Normal file
@ -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)
|
||||||
@ -10,6 +10,7 @@ description: 以当前源码和真实消费者接线为准,说明 GFramework
|
|||||||
- 项目级配置:`project.godot` -> `AutoLoads` / `InputActions`
|
- 项目级配置:`project.godot` -> `AutoLoads` / `InputActions`
|
||||||
- 场景级样板:`[GetNode]` / `[BindNodeSignal]`
|
- 场景级样板:`[GetNode]` / `[BindNodeSignal]`
|
||||||
- 运行时辅助:节点生命周期、事件解绑、异步等待
|
- 运行时辅助:节点生命周期、事件解绑、异步等待
|
||||||
|
- 输入绑定管理:`GodotInputBindingStore`
|
||||||
|
|
||||||
它不再把旧版长篇 API 列表当事实来源,也不把 `AbstractGodotModule` / `InstallGodotModule(...)` 当成默认接入起点。
|
它不再把旧版长篇 API 列表当事实来源,也不把 `AbstractGodotModule` / `InstallGodotModule(...)` 当成默认接入起点。
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user