diff --git a/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs b/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs
index fbc8404..20add25 100644
--- a/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs
+++ b/Assets/_Game/Scripts/Core/Events/BaseEventChannelSO.cs
@@ -26,9 +26,24 @@ namespace BaseGames.Core.Events
#endif
private void OnEnable()
+ {
+ ResetRuntimeState();
+ // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
+ PlayModeResetHook.Register(ResetRuntimeState);
+ }
+
+ ///
+ /// 重置运行时态:清空粘性缓存值与订阅委托,回到"零订阅、无缓存"的干净起点
+ /// (复刻 Domain Reload 每次进入 Play 提供的初始状态)。
+ ///
+ private void ResetRuntimeState()
{
_hasLastValue = false;
_lastValue = default;
+ _onEventRaisedBacking = null;
+#if UNITY_EDITOR
+ _subscriberCount = 0;
+#endif
}
public event Action OnEventRaised
@@ -90,6 +105,22 @@ namespace BaseGames.Core.Events
private int _subscriberCount;
#endif
+ private void OnEnable()
+ {
+ ResetRuntimeState();
+ // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
+ PlayModeResetHook.Register(ResetRuntimeState);
+ }
+
+ /// 重置运行时态:清空订阅委托,回到"零订阅"的干净起点。
+ private void ResetRuntimeState()
+ {
+ _onEventRaisedBacking = null;
+#if UNITY_EDITOR
+ _subscriberCount = 0;
+#endif
+ }
+
public event Action OnEventRaised
{
add
diff --git a/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs
new file mode 100644
index 0000000..e964d03
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs
@@ -0,0 +1,56 @@
+using System;
+using System.Collections.Generic;
+#if UNITY_EDITOR
+using UnityEditor;
+#endif
+
+namespace BaseGames.Core.Events
+{
+ ///
+ /// 关闭 Domain Reload(Project Settings → Editor → Enter Play Mode Settings)后,
+ /// ScriptableObject 资产不再于进入/退出 Play 时被卸载重载,其 OnEnable/OnDisable 也就不再触发,
+ /// 导致 SO 的运行时态(粘性缓存值、订阅委托、运行时字典等)在多次 Play 会话间残留。
+ ///
+ /// 本工具提供一条可靠的"进入 Play 前重置"通道,复刻 Domain Reload 曾提供的"干净起点":
+ /// 持有运行时态的 SO 在首次 OnEnable(编辑器加载资产时仍会触发一次)调用
+ /// 登记自身的重置方法;此后每次"即将进入 Play"由编辑器事件统一回调全部重置方法。
+ ///
+ /// 运行时构建(非编辑器)下为空实现、零开销;构建中没有 Domain Reload 概念,
+ /// SO 的 OnEnable 仍照常负责重置,无需本工具介入。
+ ///
+ public static class PlayModeResetHook
+ {
+#if UNITY_EDITOR
+ // 以委托相等性去重:同一 SO 实例的同一重置方法多次 Register 只保留一份
+ // (Delegate 的 Equals/GetHashCode 比较 Target 实例与方法)。
+ private static readonly HashSet _resets = new();
+ private static bool _subscribed;
+
+ /// 登记一个"进入 Play 前"执行的重置回调(仅编辑器有效)。
+ public static void Register(Action reset)
+ {
+ if (reset == null) return;
+ _resets.Add(reset);
+ if (_subscribed) return;
+ _subscribed = true;
+ EditorApplication.playModeStateChanged += OnPlayModeStateChanged;
+ }
+
+ private static void OnPlayModeStateChanged(PlayModeStateChange change)
+ {
+ // 仅在"退出编辑态、即将进入 Play"的瞬间重置,使后续 Awake 执行时各 SO 处于干净起点。
+ if (change != PlayModeStateChange.ExitingEditMode) return;
+ foreach (var reset in _resets)
+ {
+ // SO 资产被卸载后其托管对象可能已失效(Unity 重载 == null),跳过避免无谓调用。
+ if (reset.Target is UnityEngine.Object uo && uo == null) continue;
+ try { reset(); }
+ catch (Exception e) { UnityEngine.Debug.LogException(e); } // 暴露错误而非静默吞掉
+ }
+ }
+#else
+ /// 运行时构建:空实现。SO 的 OnEnable 在构建中照常重置运行时态。
+ public static void Register(Action reset) { }
+#endif
+ }
+}
diff --git a/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta
new file mode 100644
index 0000000..40e0d92
--- /dev/null
+++ b/Assets/_Game/Scripts/Core/Events/PlayModeResetHook.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5cebf2bf42383c74a86dd7138932143c
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs b/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs
index 9d17d3c..ba46461 100644
--- a/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs
+++ b/Assets/_Game/Scripts/Core/Events/ServiceLocator.cs
@@ -12,6 +12,13 @@ namespace BaseGames.Core
{
private static readonly Dictionary _services = new();
+ ///
+ /// 关闭 Domain Reload 后静态字段不再于进入 Play 时重置,
+ /// 残留的服务注册会指向上一次运行已销毁的对象。此钩子在任何 Awake 之前清空注册表。
+ ///
+ [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
+ private static void ResetOnEnterPlayMode() => _services.Clear();
+
/// 以接口类型 TInterface 注册实现 impl。
public static void Register(TInterface impl)
=> _services[typeof(TInterface)] = impl;
diff --git a/Assets/_Game/Scripts/Core/SettingsManager.cs b/Assets/_Game/Scripts/Core/SettingsManager.cs
index 73ec248..3ee3b25 100644
--- a/Assets/_Game/Scripts/Core/SettingsManager.cs
+++ b/Assets/_Game/Scripts/Core/SettingsManager.cs
@@ -27,6 +27,13 @@ namespace BaseGames.Core
/// 设置变更后触发(用于 UIScaleApplier、色盲滤镜、Camera Shake 等订阅)。
public static event Action SettingsChanged;
+ ///
+ /// 关闭 Domain Reload 后静态事件不再于进入 Play 时清空,残留订阅者会指向上一次运行已销毁的对象。
+ /// 此钩子在任何 Awake 之前将其重置为 null。
+ ///
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
+ private static void ResetStaticState() => SettingsChanged = null;
+
private void Awake()
{
ServiceLocator.Register(this);
diff --git a/Assets/_Game/Scripts/EventChain/EventChainManager.cs b/Assets/_Game/Scripts/EventChain/EventChainManager.cs
index cc92df6..7985a74 100644
--- a/Assets/_Game/Scripts/EventChain/EventChainManager.cs
+++ b/Assets/_Game/Scripts/EventChain/EventChainManager.cs
@@ -79,6 +79,13 @@ namespace BaseGames.EventChain
/// EventChainEditorWindow 在 OnEnable 中订阅此事件。
///
public static event Action OnChainExecutedInEditor;
+
+ ///
+ /// 关闭 Domain Reload 后静态事件不再于进入 Play 时清空,此钩子在任何 Awake 之前将其重置为 null,
+ /// 避免上一次运行残留的编辑器窗口订阅者累积。
+ ///
+ [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)]
+ private static void ResetEditorStaticState() => OnChainExecutedInEditor = null;
#endif
private readonly HashSet _completedChains = new();
diff --git a/Assets/_Game/Scripts/Input/InputReaderSO.cs b/Assets/_Game/Scripts/Input/InputReaderSO.cs
index efefa99..05a88e4 100644
--- a/Assets/_Game/Scripts/Input/InputReaderSO.cs
+++ b/Assets/_Game/Scripts/Input/InputReaderSO.cs
@@ -71,6 +71,9 @@ namespace BaseGames.Input
private InputActionMap _ui;
private bool _isBound;
private InputAction _jumpAction;
+ // 记录每次绑定对应的解绑动作。关闭 Domain Reload 后 InputActionAsset 跨会话存活,
+ // 必须在重新绑定前先解绑旧回调,否则同一输入会被绑定多次、触发多次。
+ private readonly List _unbinders = new();
private void EnsureInitialized()
{
@@ -105,9 +108,20 @@ namespace BaseGames.Input
{
Debug.Assert(_onPauseRequested != null,
"[InputReaderSO] _onPauseRequested 未赋值,请在 Inspector 中指定 EVT_PauseRequested。", this);
- // Reset private state on every OnEnable so stale ScriptableObject
- // references from a previous Play session don't cause
- // 'Map must be contained in state' errors.
+ ResetRuntimeState();
+ // 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,改由编辑器钩子在进入 Play 前兜住重置。
+ PlayModeResetHook.Register(ResetRuntimeState);
+ }
+
+ ///
+ /// 重置运行时态并重建输入绑定,回到本次 Play 会话的干净起点:
+ /// 先解绑上一次会话残留在 InputActionAsset 上的回调(避免跨会话重复绑定导致多次触发),
+ /// 再清空对外事件订阅者,最后重新初始化并绑定。
+ ///
+ private void ResetRuntimeState()
+ {
+ UnbindAll();
+ ClearGameplayEvents();
_gameplay = null;
_ui = null;
_isBound = false;
@@ -115,6 +129,31 @@ namespace BaseGames.Input
EnsureInitialized();
}
+ /// 解绑本读取器先前注册到 InputActionAsset 上的全部回调。
+ private void UnbindAll()
+ {
+ foreach (var unbind in _unbinders) unbind();
+ _unbinders.Clear();
+ }
+
+ /// 清空所有对外事件的订阅者,回到零订阅起点(复刻 Domain Reload 的初始状态)。
+ 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();
@@ -245,38 +284,46 @@ namespace BaseGames.Input
_onPauseRequested?.Raise();
}
- private static void BindStarted(InputActionMap map, string name, Action callback)
+ // 以下绑定方法在挂接回调的同时,把对应的解绑动作记入 _unbinders,
+ // 供 UnbindAll 在跨会话重建前清理,避免回调在 InputActionAsset 上累积。
+ private void BindStarted(InputActionMap map, string name, Action callback)
{
var action = map.FindAction(name, throwIfNotFound: false);
- if (action != null)
- {
- action.started += _ => callback();
-
- }
- else
+ if (action == null)
{
Debug.LogWarning($"[BindStarted] Action '{name}' not found in map");
+ return;
}
+ Action handler = _ => callback();
+ action.started += handler;
+ _unbinders.Add(() => action.started -= handler);
}
- private static void BindPerformed(InputActionMap map, string name,
+ private void BindPerformed(InputActionMap map, string name,
Action callback)
{
var action = map.FindAction(name, throwIfNotFound: false);
- if (action != null) action.performed += callback;
+ if (action == null) return;
+ action.performed += callback;
+ _unbinders.Add(() => action.performed -= callback);
}
- private static void BindCanceled(InputActionMap map, string name,
+ private void BindCanceled(InputActionMap map, string name,
Action callback)
{
var action = map.FindAction(name, throwIfNotFound: false);
- if (action != null) action.canceled += callback;
+ if (action == null) return;
+ action.canceled += callback;
+ _unbinders.Add(() => action.canceled -= callback);
}
- private static void BindCanceled(InputActionMap map, string name, Action callback)
+ private void BindCanceled(InputActionMap map, string name, Action callback)
{
var action = map.FindAction(name, throwIfNotFound: false);
- if (action != null) action.canceled += _ => callback();
+ if (action == null) return;
+ Action handler = _ => callback();
+ action.canceled += handler;
+ _unbinders.Add(() => action.canceled -= handler);
}
// ── Rebinding API (P4-3) ──────────────────────────────────────────────
diff --git a/Assets/_Game/Scripts/World/WorldStateRegistry.cs b/Assets/_Game/Scripts/World/WorldStateRegistry.cs
index dd5637f..70b650a 100644
--- a/Assets/_Game/Scripts/World/WorldStateRegistry.cs
+++ b/Assets/_Game/Scripts/World/WorldStateRegistry.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using BaseGames.Core;
+using BaseGames.Core.Events;
using BaseGames.Core.Save;
using UnityEngine;
@@ -41,10 +42,23 @@ namespace BaseGames.World
public event Action OnBatchStateChanged;
///
- /// Editor 重新进入 Play Mode 时 ScriptableObject 保留上一次运行的状态,
- /// OnEnable 在域重载(Domain Reload)和每次 Play 开始时都会调用,确保状态干净。
+ /// ScriptableObject 会在多次 Play 会话间保留运行时状态,进入 Play 前必须清干净。
+ /// 关闭 Domain Reload 后 OnEnable 不再于每次进入 Play 触发,故同时登记到 PlayModeResetHook,
+ /// 由编辑器钩子在进入 Play 前兜住重置(构建中仍由 OnEnable 负责)。
///
- private void OnEnable() => _states.Clear();
+ private void OnEnable()
+ {
+ ResetRuntimeState();
+ PlayModeResetHook.Register(ResetRuntimeState);
+ }
+
+ /// 清空运行时状态字典与变更事件订阅者,回到干净起点。
+ private void ResetRuntimeState()
+ {
+ _states.Clear();
+ OnStateChanged = null;
+ OnBatchStateChanged = null;
+ }
// ── 泛化 API ─────────────────────────────────────────────────────────
/// 检查指定类别中 id 是否已标记。
diff --git a/ProjectSettings/EditorSettings.asset b/ProjectSettings/EditorSettings.asset
index 7eeb408..44ec361 100644
--- a/ProjectSettings/EditorSettings.asset
+++ b/ProjectSettings/EditorSettings.asset
@@ -24,10 +24,10 @@ EditorSettings:
m_EnableEditorAsyncCPUTextureLoading: 0
m_AsyncShaderCompilation: 1
m_PrefabModeAllowAutoSave: 1
- m_EnterPlayModeOptionsEnabled: 0
- m_EnterPlayModeOptions: 1
+ m_EnterPlayModeOptionsEnabled: 1
+ m_EnterPlayModeOptions: 3
m_GameObjectNamingDigits: 1
- m_GameObjectNamingScheme: 0
+ m_GameObjectNamingScheme: 2
m_AssetNamingUsesSpace: 1
m_InspectorUseIMGUIDefaultInspector: 0
m_UseLegacyProbeSampleCount: 0