Files
zeling_v2/Assets/_Game/Scripts/Input/InputReaderSO.cs
Joywayer 4189a6210b perf(editor): 关闭 Domain/Scene Reload 加速进入 Play,并补齐域重载安全
关闭 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>
2026-06-11 14:40:37 +08:00

384 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ActionQuickMap可选")]
[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());
// 快速直达地图(可选 ActionQuickMap默认 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 同名 ActionHub 已开时按 M 直接跳地图 Tab。共用 Gameplay 同一频道。
BindStarted(_ui, "QuickMap", () => _onQuickMap?.Raise());
// 背包键UI Map 同名 ActionTabHub 已开时按 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存入 PlayerPrefskey = "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);
}
}
}