关闭 Domain Reload 后 SO 的 OnEnable/OnDisable 不再于进入/退出 Play 触发、 静态字段不再重置,运行时态会跨 Play 会话残留。补齐重置路径: - 新增 PlayModeResetHook:编辑器内在"进入 Play 前"统一回调各 SO 的重置, 复刻 Domain Reload 曾提供的干净起点;运行时构建为空实现、零开销。 - 静态态用 RuntimeInitializeOnLoadMethod 重置: ServiceLocator._services / SettingsManager.SettingsChanged / EventChainManager.OnChainExecutedInEditor。 - SO 运行时态经 PlayModeResetHook 重置: BaseEventChannelSO(粘性值+订阅委托) / VoidBaseEventChannelSO / WorldStateRegistry(状态字典+变更事件) / InputReaderSO。 - InputReaderSO 增加解绑追踪:跨会话重建前先解绑旧输入回调, 避免 InputActionAsset 存活导致同一输入重复绑定、多次触发。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
384 lines
18 KiB
C#
384 lines
18 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using UnityEngine;
|
||
using UnityEngine.InputSystem;
|
||
using BaseGames.Core.Events;
|
||
|
||
namespace BaseGames.Input
|
||
{
|
||
[CreateAssetMenu(menuName = "BaseGames/Input/InputReader")]
|
||
public class InputReaderSO : ScriptableObject
|
||
{
|
||
[SerializeField] private InputActionAsset _inputActions;
|
||
[SerializeField] private VoidEventChannelSO _onPauseRequested;
|
||
|
||
[Header("背包菜单(InventoryHub)")]
|
||
[Tooltip("打开统一背包菜单。对应 EVT_InventoryOpen。绑定到 UI/Gameplay Map 的 \"Inventory\" Action(可选)。")]
|
||
[SerializeField] private VoidEventChannelSO _onInventoryOpen;
|
||
[Tooltip("背包 Tab:下一页(L/R 肩键)。对应 EVT_InventoryTabNext。绑定 \"InventoryTabNext\" Action(可选)。")]
|
||
[SerializeField] private VoidEventChannelSO _onInventoryTabNext;
|
||
[Tooltip("背包 Tab:上一页(L/R 肩键)。对应 EVT_InventoryTabPrev。绑定 \"InventoryTabPrev\" Action(可选)。")]
|
||
[SerializeField] private VoidEventChannelSO _onInventoryTabPrev;
|
||
|
||
[Header("快速直达(Gameplay/UI Action:QuickMap,可选)")]
|
||
[Tooltip("快速打开地图 Tab。对应 EVT_QuickOpenMap。绑定 \"QuickMap\" Action(默认 M)。")]
|
||
[SerializeField] private VoidEventChannelSO _onQuickMap;
|
||
|
||
[Header("UI 操作")]
|
||
[Tooltip("UI 取消操作(ESC / 手柄 B·Circle)。由 UIManager 全局订阅,关闭当前栈顶面板。对应 EVT_UICancelPressed。")]
|
||
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
|
||
|
||
// ── Gameplay Events ───────────────────────────────────────────────────
|
||
public event Action<Vector2> MoveEvent;
|
||
public event Action JumpStartedEvent;
|
||
public event Action JumpCancelledEvent;
|
||
public event Action AttackEvent;
|
||
public event Action ParryEvent;
|
||
public event Action DashEvent;
|
||
public event Action UseSpringEvent;
|
||
public event Action SwitchTianHunFormEvent;
|
||
public event Action SwitchDiHunFormEvent;
|
||
public event Action SwitchMingHunFormEvent;
|
||
public event Action SoulSkillEvent;
|
||
public event Action SpiritSkill1StartedEvent;
|
||
public event Action SpiritSkill1CancelledEvent;
|
||
public event Action SpiritSkill2StartedEvent;
|
||
public event Action SpiritSkill2CancelledEvent;
|
||
public event Action SpellCastEvent;
|
||
public event Action InteractEvent;
|
||
public event Action InteractCancelledEvent;
|
||
|
||
// ── UI Events ─────────────────────────────────────────────────────────
|
||
public event Action PauseEvent;
|
||
public event Action<Vector2> NavigateEvent;
|
||
public event Action SubmitEvent;
|
||
public event Action CancelEvent;
|
||
public event Action<Vector2> PointEvent;
|
||
|
||
/// <summary>小地图视野档位切换(默认绑定:Tab 键 / 手柄右肩键)。</summary>
|
||
public event Action CycleMinimapZoomEvent;
|
||
|
||
/// <summary>全屏地图"居中到玩家"(默认绑定:F 键 / 手柄右摇杆按下)。</summary>
|
||
public event Action MapCenterEvent;
|
||
|
||
// ── Polling ───────────────────────────────────────────────────────────
|
||
public Vector2 MoveInput { get; private set; }
|
||
/// <summary>跳跃键当前是否处于按下状态。供 JumpState 在进入时检测短按是否已松开。</summary>
|
||
public bool IsJumpHeld => _jumpAction != null && _jumpAction.IsPressed();
|
||
|
||
// ── Runtime state ─────────────────────────────────────────────────────
|
||
private InputActionMap _gameplay;
|
||
private InputActionMap _ui;
|
||
private bool _isBound;
|
||
private InputAction _jumpAction;
|
||
// 记录每次绑定对应的解绑动作。关闭 Domain Reload 后 InputActionAsset 跨会话存活,
|
||
// 必须在重新绑定前先解绑旧回调,否则同一输入会被绑定多次、触发多次。
|
||
private readonly List<Action> _unbinders = new();
|
||
|
||
private void EnsureInitialized()
|
||
{
|
||
if (_inputActions == null)
|
||
{
|
||
Debug.LogError("[InputReaderSO] _inputActions is NULL! Asset not assigned in inspector?");
|
||
return;
|
||
}
|
||
|
||
// Always disable first so the Input System tears down stale state,
|
||
// then re-enable to create a fresh state for this Play session.
|
||
_inputActions.Disable();
|
||
_inputActions.Enable();
|
||
|
||
_gameplay = _inputActions.FindActionMap("Gameplay", throwIfNotFound: false);
|
||
if (_gameplay == null)
|
||
Debug.LogError("[InputReaderSO] Could not find 'Gameplay' action map in asset!");
|
||
|
||
_ui = _inputActions.FindActionMap("UI", throwIfNotFound: false);
|
||
if (_ui == null)
|
||
Debug.LogWarning("[InputReaderSO] Could not find 'UI' action map in asset!");
|
||
|
||
if (_gameplay != null && !_isBound)
|
||
{
|
||
|
||
BindActions();
|
||
|
||
}
|
||
}
|
||
|
||
private void OnEnable()
|
||
{
|
||
Debug.Assert(_onPauseRequested != null,
|
||
"[InputReaderSO] _onPauseRequested 未赋值,请在 Inspector 中指定 EVT_PauseRequested。", this);
|
||
ResetRuntimeState();
|
||
// 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
|
||
PlayModeResetHook.Register(ResetRuntimeState);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 重置运行时态并重建输入绑定,回到本次 Play 会话的干净起点:
|
||
/// 先解绑上一次会话残留在 InputActionAsset 上的回调(避免跨会话重复绑定导致多次触发),
|
||
/// 再清空对外事件订阅者,最后重新初始化并绑定。
|
||
/// </summary>
|
||
private void ResetRuntimeState()
|
||
{
|
||
UnbindAll();
|
||
ClearGameplayEvents();
|
||
_gameplay = null;
|
||
_ui = null;
|
||
_isBound = false;
|
||
_jumpAction = null;
|
||
EnsureInitialized();
|
||
}
|
||
|
||
/// <summary>解绑本读取器先前注册到 InputActionAsset 上的全部回调。</summary>
|
||
private void UnbindAll()
|
||
{
|
||
foreach (var unbind in _unbinders) unbind();
|
||
_unbinders.Clear();
|
||
}
|
||
|
||
/// <summary>清空所有对外事件的订阅者,回到零订阅起点(复刻 Domain Reload 的初始状态)。</summary>
|
||
private void ClearGameplayEvents()
|
||
{
|
||
MoveEvent = null;
|
||
JumpStartedEvent = null; JumpCancelledEvent = null;
|
||
AttackEvent = null; ParryEvent = null; DashEvent = null;
|
||
UseSpringEvent = null;
|
||
SwitchTianHunFormEvent = null; SwitchDiHunFormEvent = null; SwitchMingHunFormEvent = null;
|
||
SoulSkillEvent = null;
|
||
SpiritSkill1StartedEvent = null; SpiritSkill1CancelledEvent = null;
|
||
SpiritSkill2StartedEvent = null; SpiritSkill2CancelledEvent = null;
|
||
SpellCastEvent = null;
|
||
InteractEvent = null; InteractCancelledEvent = null;
|
||
PauseEvent = null; NavigateEvent = null;
|
||
SubmitEvent = null; CancelEvent = null; PointEvent = null;
|
||
CycleMinimapZoomEvent = null; MapCenterEvent = null;
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
_gameplay?.Disable();
|
||
_ui?.Disable();
|
||
_isBound = false;
|
||
}
|
||
|
||
// ── Action Map Switching ──────────────────────────────────────────────
|
||
public void EnableGameplayInput()
|
||
{
|
||
// Disable UI map if it's active
|
||
if (_ui != null && _ui.enabled)
|
||
{
|
||
|
||
_ui.Disable();
|
||
}
|
||
|
||
// Ensure gameplay map is enabled
|
||
if (_gameplay != null && !_gameplay.enabled)
|
||
{
|
||
|
||
_gameplay.Enable();
|
||
}
|
||
else if (_gameplay == null)
|
||
{
|
||
Debug.LogError("[InputReaderSO.EnableGameplayInput] _gameplay is NULL!");
|
||
}
|
||
}
|
||
|
||
public void EnableUIInput()
|
||
{
|
||
// Disable gameplay map if it's active
|
||
if (_gameplay != null && _gameplay.enabled)
|
||
{
|
||
|
||
_gameplay.Disable();
|
||
}
|
||
|
||
// Ensure UI map is enabled
|
||
if (_ui != null && !_ui.enabled)
|
||
{
|
||
|
||
_ui.Enable();
|
||
}
|
||
else if (_ui == null)
|
||
{
|
||
Debug.LogWarning("[InputReaderSO.EnableUIInput] _ui is NULL!");
|
||
}
|
||
}
|
||
|
||
public void DisableAllInput() { _gameplay?.Disable(); _ui?.Disable(); }
|
||
|
||
// ── Binding ───────────────────────────────────────────────────────────
|
||
private void BindActions()
|
||
{
|
||
if (_gameplay == null)
|
||
{
|
||
Debug.LogWarning("[InputReaderSO.BindActions] Skipped: _gameplay is NULL");
|
||
return;
|
||
}
|
||
|
||
|
||
|
||
BindPerformed(_gameplay, "Move", ctx =>
|
||
{
|
||
MoveInput = ctx.ReadValue<Vector2>();
|
||
MoveEvent?.Invoke(MoveInput);
|
||
});
|
||
BindCanceled(_gameplay, "Move", _ =>
|
||
{
|
||
MoveInput = Vector2.zero;
|
||
MoveEvent?.Invoke(Vector2.zero);
|
||
});
|
||
|
||
BindStarted(_gameplay, "Jump", () => JumpStartedEvent?.Invoke());
|
||
BindCanceled(_gameplay, "Jump", () => JumpCancelledEvent?.Invoke());
|
||
_jumpAction = _gameplay.FindAction("Jump", throwIfNotFound: false);
|
||
BindStarted(_gameplay, "Attack", () => AttackEvent?.Invoke());
|
||
BindStarted(_gameplay, "Parry", () => ParryEvent?.Invoke());
|
||
BindStarted(_gameplay, "Dash", () => DashEvent?.Invoke());
|
||
BindStarted(_gameplay, "UseSpring", () => UseSpringEvent?.Invoke());
|
||
BindStarted(_gameplay, "SwitchTianHunForm", () => SwitchTianHunFormEvent?.Invoke());
|
||
BindStarted(_gameplay, "SwitchDiHunForm", () => SwitchDiHunFormEvent?.Invoke());
|
||
BindStarted(_gameplay, "SwitchMingHunForm", () => SwitchMingHunFormEvent?.Invoke());
|
||
BindStarted(_gameplay, "SoulSkill", () => SoulSkillEvent?.Invoke());
|
||
BindStarted(_gameplay, "SpiritSkill1", () => SpiritSkill1StartedEvent?.Invoke());
|
||
BindCanceled(_gameplay, "SpiritSkill1", () => SpiritSkill1CancelledEvent?.Invoke());
|
||
BindStarted(_gameplay, "SpiritSkill2", () => SpiritSkill2StartedEvent?.Invoke());
|
||
BindCanceled(_gameplay, "SpiritSkill2", () => SpiritSkill2CancelledEvent?.Invoke());
|
||
BindStarted(_gameplay, "Spell", () => SpellCastEvent?.Invoke());
|
||
BindStarted(_gameplay, "Interact", () => InteractEvent?.Invoke());
|
||
BindCanceled(_gameplay, "Interact", () => InteractCancelledEvent?.Invoke());
|
||
|
||
|
||
BindStarted(_gameplay, "Pause", HandlePause);
|
||
// 背包菜单开关(可选 Action:默认绑定如 I 键 / 手柄 Select)。未在 Map 中定义则跳过。
|
||
BindStarted(_gameplay, "Inventory", () => _onInventoryOpen?.Raise());
|
||
// 快速直达地图(可选 Action:QuickMap,默认 M)。未定义则跳过。
|
||
BindStarted(_gameplay, "QuickMap", () => _onQuickMap?.Raise());
|
||
|
||
if (_ui != null)
|
||
{
|
||
BindPerformed(_ui, "Navigate", ctx => NavigateEvent?.Invoke(ctx.ReadValue<Vector2>()));
|
||
BindCanceled(_ui, "Navigate", _ => NavigateEvent?.Invoke(Vector2.zero));
|
||
BindStarted(_ui, "Submit", () => SubmitEvent?.Invoke());
|
||
// UI 模式下 ESC 与手柄 Start 均归为 Cancel(返回/关闭栈顶);UI map 不再有 Pause action。
|
||
BindStarted(_ui, "Cancel", () => { CancelEvent?.Invoke(); _onUICancelPressed?.Raise(); });
|
||
BindPerformed(_ui, "Point", ctx => PointEvent?.Invoke(ctx.ReadValue<Vector2>()));
|
||
// R13-N3 小地图缩放档位切换;Action 名称需在 InputActionAsset UI Map 中添加(可选)
|
||
BindStarted(_ui, "CycleMinimapZoom", () => CycleMinimapZoomEvent?.Invoke());
|
||
// R13-N4 全屏地图居中;Action 名称需在 InputActionAsset UI Map 中添加(可选)
|
||
BindStarted(_ui, "MapCenter", () => MapCenterEvent?.Invoke());
|
||
// 背包 Tab 循环(L/R 肩键);Action 名称需在 InputActionAsset UI Map 中添加(可选)
|
||
BindStarted(_ui, "InventoryTabNext", () => _onInventoryTabNext?.Raise());
|
||
BindStarted(_ui, "InventoryTabPrev", () => _onInventoryTabPrev?.Raise());
|
||
// 快速直达(UI Map 同名 Action):Hub 已开时按 M 直接跳地图 Tab。共用 Gameplay 同一频道。
|
||
BindStarted(_ui, "QuickMap", () => _onQuickMap?.Raise());
|
||
// 背包键(UI Map 同名 Action:Tab):Hub 已开时按 Tab 关闭(与 Gameplay 同频道,UIManager 做 toggle)。
|
||
BindStarted(_ui, "Inventory", () => _onInventoryOpen?.Raise());
|
||
}
|
||
|
||
_isBound = true;
|
||
}
|
||
|
||
private void HandlePause()
|
||
{
|
||
PauseEvent?.Invoke();
|
||
_onPauseRequested?.Raise();
|
||
}
|
||
|
||
// 以下绑定方法在挂接回调的同时,把对应的解绑动作记入 _unbinders,
|
||
// 供 UnbindAll 在跨会话重建前清理,避免回调在 InputActionAsset 上累积。
|
||
private void BindStarted(InputActionMap map, string name, Action callback)
|
||
{
|
||
var action = map.FindAction(name, throwIfNotFound: false);
|
||
if (action == null)
|
||
{
|
||
Debug.LogWarning($"[BindStarted] Action '{name}' not found in map");
|
||
return;
|
||
}
|
||
Action<InputAction.CallbackContext> handler = _ => callback();
|
||
action.started += handler;
|
||
_unbinders.Add(() => action.started -= handler);
|
||
}
|
||
|
||
private void BindPerformed(InputActionMap map, string name,
|
||
Action<InputAction.CallbackContext> callback)
|
||
{
|
||
var action = map.FindAction(name, throwIfNotFound: false);
|
||
if (action == null) return;
|
||
action.performed += callback;
|
||
_unbinders.Add(() => action.performed -= callback);
|
||
}
|
||
|
||
private void BindCanceled(InputActionMap map, string name,
|
||
Action<InputAction.CallbackContext> callback)
|
||
{
|
||
var action = map.FindAction(name, throwIfNotFound: false);
|
||
if (action == null) return;
|
||
action.canceled += callback;
|
||
_unbinders.Add(() => action.canceled -= callback);
|
||
}
|
||
|
||
private void BindCanceled(InputActionMap map, string name, Action callback)
|
||
{
|
||
var action = map.FindAction(name, throwIfNotFound: false);
|
||
if (action == null) return;
|
||
Action<InputAction.CallbackContext> handler = _ => callback();
|
||
action.canceled += handler;
|
||
_unbinders.Add(() => action.canceled -= handler);
|
||
}
|
||
|
||
// ── Rebinding API (P4-3) ──────────────────────────────────────────────
|
||
|
||
private const string PrefKey = "InputBindings";
|
||
|
||
/// <summary>查找单个 Action(供 RebindActionRow 显示当前绑定路径)。</summary>
|
||
public InputAction FindAction(string name)
|
||
=> _inputActions?.FindAction(name);
|
||
|
||
/// <summary>返回 Gameplay Map 所有 Action(供 ConflictDetector 扫描冲突)。</summary>
|
||
public IEnumerable<InputAction> GetAllActionMap()
|
||
{
|
||
var map = _inputActions?.FindActionMap("Gameplay");
|
||
return map != null ? (IEnumerable<InputAction>)map.actions : Array.Empty<InputAction>();
|
||
}
|
||
|
||
/// <summary>
|
||
/// 启动交互式重绑定(Unity InputSystem RebindingOperation)。
|
||
/// 调用方无需持有返回值;完成或取消后自动 Dispose。
|
||
/// </summary>
|
||
public void StartRebinding(
|
||
string actionName, int bindingIndex, Action onComplete, Action onCancel)
|
||
{
|
||
var action = _inputActions?.FindAction(actionName);
|
||
if (action == null) { onCancel?.Invoke(); return; }
|
||
|
||
action.Disable();
|
||
action.PerformInteractiveRebinding(bindingIndex)
|
||
.OnComplete(op => { op.Dispose(); action.Enable(); onComplete?.Invoke(); })
|
||
.OnCancel(op => { op.Dispose(); action.Enable(); onCancel?.Invoke(); })
|
||
.Start();
|
||
}
|
||
|
||
/// <summary>将当前绑定覆盖序列化为 JSON,存入 PlayerPrefs(key = "InputBindings")。</summary>
|
||
public void SaveBindingOverrides()
|
||
{
|
||
if (_inputActions == null) return;
|
||
PlayerPrefs.SetString(PrefKey, _inputActions.SaveBindingOverridesAsJson());
|
||
PlayerPrefs.Save();
|
||
}
|
||
|
||
/// <summary>从 PlayerPrefs 加载并应用绑定覆盖(首次启动时无操作)。</summary>
|
||
public void LoadBindingOverrides()
|
||
{
|
||
if (_inputActions != null && PlayerPrefs.HasKey(PrefKey))
|
||
_inputActions.LoadBindingOverridesFromJson(PlayerPrefs.GetString(PrefKey));
|
||
}
|
||
|
||
/// <summary>重置所有绑定为默认值并清除 PlayerPrefs 记录。</summary>
|
||
public void ResetBindings()
|
||
{
|
||
_inputActions?.RemoveAllBindingOverrides();
|
||
PlayerPrefs.DeleteKey(PrefKey);
|
||
}
|
||
}
|
||
}
|