feat(input): 新增统一输入抽象与Godot集成

- 新增输入绑定 DTO、设备上下文和 UI 语义桥接契约。

- 实现 Game 默认输入绑定存储、动作映射和 UI 分发桥接。

- 落地 Godot InputMap 适配、测试覆盖与配套文档。

- 更新 ai-plan 恢复点、worktree 映射与采用入口。
This commit is contained in:
gewuyou 2026-05-10 22:29:26 +08:00
parent 699d0b4896
commit ebbef321ad
32 changed files with 1736 additions and 1 deletions

View 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();
}

View 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; }
}

View 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);
}

View 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);
}

View 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; }
}

View 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; }
}

View 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
}

View 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; }
}

View 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; }
}

View 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
}

View File

@ -13,6 +13,7 @@
- 典型使用场景:
- 定义 `IScene``IUiPage``ISettingsData``IData` 等业务对象
- 让 feature 包只感知 `IConfigRegistry``ISaveRepository<T>``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<T>``UiLayer``SceneTransitionType` | 看公共路由上下文、存储角色、资源注册表与跨层共享枚举 |
## 最小接入路径

View 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")
])
]));
}
}

View 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);
});
}
}

View 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;
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View File

@ -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<TRoute, TContext>`
@ -176,6 +193,7 @@
| `Config/` | `YamlConfigLoader``ConfigRegistry``GameConfigBootstrap``YamlConfigSchemaValidator` | 看 YAML 加载、schema 校验、模块接入与热重载边界 |
| `Data/` `Storage/` `Serializer/` | `DataRepository``SaveRepository<TSaveData>``UnifiedSettingsDataRepository``FileStorage``JsonSerializer` | 看持久化布局、槽位存档、统一设置文件和底层序列化 / 存储实现 |
| `Setting/` | `SettingsModel<TRepository>``SettingsSystem``SettingsAppliedEvent<T>` | 看初始化、应用、保存、重置等设置生命周期编排 |
| `Input/` | `InputBindingStore``InputDeviceTracker``UiInputActionMap``UiInputDispatcher` | 看动作绑定、设备上下文和 UI 输入桥接的默认运行时实现 |
| `Scene/` `UI/` `Routing/` | `SceneRouterBase``UiRouterBase``SceneTransitionPipeline``UiTransitionPipeline``RouterBase<TRoute, TContext>` | 看路由基类、转换处理器和项目层需要自己提供的 factory / root 边界 |
| `Extensions/` `Internal/` `State/` | `DataLocationExtensions``VersionedMigrationRunner``GameStateMachineSystem` | 看辅助扩展、内部迁移执行逻辑和游戏态状态机封装 |

View 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];
}
}
}
}

View 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");
}
}

View 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());
}
}

View 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);
}
}
}

View 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();
}

View File

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

View File

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

View File

@ -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 与教程中的采用路径示例

View File

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

View File

@ -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 阅读入口

97
docs/zh-CN/game/input.md Normal file
View 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)

View File

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

67
docs/zh-CN/godot/input.md Normal file
View 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)

View File

@ -10,6 +10,7 @@ description: 以当前源码和真实消费者接线为准,说明 GFramework
- 项目级配置:`project.godot` -> `AutoLoads` / `InputActions`
- 场景级样板:`[GetNode]` / `[BindNodeSignal]`
- 运行时辅助:节点生命周期、事件解绑、异步等待
- 输入绑定管理:`GodotInputBindingStore`
它不再把旧版长篇 API 列表当事实来源,也不把 `AbstractGodotModule` / `InstallGodotModule(...)` 当成默认接入起点。