diff --git a/Assets/Tests/EditMode/SaveSystemTests.cs b/Assets/Tests/EditMode/SaveSystemTests.cs index e9378e4..6ac1b0a 100644 --- a/Assets/Tests/EditMode/SaveSystemTests.cs +++ b/Assets/Tests/EditMode/SaveSystemTests.cs @@ -60,7 +60,7 @@ namespace BaseGames.Tests.EditMode } [Test] - public void Migrate_OldVersion_FillsPlayerDeathShade() + public void Migrate_OldVersion_DeathShade_RemainsNull() { var data = new SaveData(); data.Meta.Version = "1.0"; @@ -68,7 +68,8 @@ namespace BaseGames.Tests.EditMode var result = SaveMigrator.Migrate(data); - Assert.IsNotNull(result.Player.DeathShade, "迁移后 DeathShade 不应为 null"); + Assert.IsNull(result.Player.DeathShade, + "无遗骸时 DeathShade 应保持 null,由 DeathShadeManager 按需创建"); } [Test] @@ -99,22 +100,18 @@ namespace BaseGames.Tests.EditMode public void SaveData_SerializeDeserialize_PreservesPlayerData() { var original = new SaveData(); - original.Player.CurrentHP = 55; - original.Player.MaxHP = 100; + original.Player.CurrentHP = 55; + original.Player.MaxHP = 100; original.Player.CurrentLingZhu = 1234; - original.Player.Scene = "TestRoom"; - original.Player.PosX = 3.14f; - original.Player.PosY = -2.71f; + original.Player.Scene = "TestRoom"; string json = JsonConvert.SerializeObject(original, Formatting.None); var restored = JsonConvert.DeserializeObject(json); - Assert.AreEqual(original.Player.CurrentHP, restored.Player.CurrentHP); - Assert.AreEqual(original.Player.MaxHP, restored.Player.MaxHP); + Assert.AreEqual(original.Player.CurrentHP, restored.Player.CurrentHP); + Assert.AreEqual(original.Player.MaxHP, restored.Player.MaxHP); Assert.AreEqual(original.Player.CurrentLingZhu, restored.Player.CurrentLingZhu); - Assert.AreEqual(original.Player.Scene, restored.Player.Scene); - Assert.AreEqual(original.Player.PosX, restored.Player.PosX, 0.0001f); - Assert.AreEqual(original.Player.PosY, restored.Player.PosY, 0.0001f); + Assert.AreEqual(original.Player.Scene, restored.Player.Scene); } [Test] diff --git a/Assets/_Game/Scripts/Core/AutoSaveService.cs b/Assets/_Game/Scripts/Core/AutoSaveService.cs index cbc17f0..bf26d9d 100644 --- a/Assets/_Game/Scripts/Core/AutoSaveService.cs +++ b/Assets/_Game/Scripts/Core/AutoSaveService.cs @@ -71,7 +71,12 @@ namespace BaseGames.Core _onQuestStateChanged? .Subscribe(OnQuestStateChanged) .AddTo(_subs); } - private void OnDisable() => _subs.Clear(); + private void OnDisable() + { + StopAllCoroutines(); + _onCooldown = false; + _subs.Clear(); + } // ── 触发处理 ─────────────────────────────────────────────────────────── diff --git a/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs b/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs index e1473f6..c1272b0 100644 --- a/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs +++ b/Assets/_Game/Scripts/Core/GameServiceRegistrar.cs @@ -66,7 +66,10 @@ namespace BaseGames.Core Debug.LogWarning("[GameServiceRegistrar] ⚠ _eventChannelRegistry 未绑定,IEventChannelRegistry 未注册。", this); if (_saveManager) + { + _saveManager.Initialize(new BaseGames.Core.Save.LocalFileStorage()); ServiceLocator.Register(new SaveServiceAdapter(_saveManager)); + } else Debug.LogWarning("[GameServiceRegistrar] ⚠ _saveManager 未绑定,ISaveService 未注册。", this); @@ -228,5 +231,6 @@ namespace BaseGames.Core public void SetFlag(string flagId, bool value) => _save.SetFlag(flagId, value); public System.Collections.Generic.IEnumerable GetCompletedChains() => _save.GetCompletedChains(); public void SetChainCompleted(string chainId) => _save.SetChainCompleted(chainId); + public void MarkBossDefeated(string bossId) => _save.MarkBossDefeated(bossId); } } diff --git a/Assets/_Game/Scripts/Core/ILingZhuProvider.cs b/Assets/_Game/Scripts/Core/ILingZhuProvider.cs new file mode 100644 index 0000000..6ae7592 --- /dev/null +++ b/Assets/_Game/Scripts/Core/ILingZhuProvider.cs @@ -0,0 +1,12 @@ +namespace BaseGames.Core +{ + /// + /// 提供当前玩家灵珠数量的只读接口。 + /// 置于 BaseGames.Core 程序集,使 World 程序集中的组件(如 DeathShadeManager) + /// 无需直接引用 BaseGames.Player 程序集即可读取灵珠值。 + /// + public interface ILingZhuProvider + { + int CurrentLingZhu { get; } + } +} diff --git a/Assets/_Game/Scripts/Core/ISaveService.cs b/Assets/_Game/Scripts/Core/ISaveService.cs index e1b6c34..228b078 100644 --- a/Assets/_Game/Scripts/Core/ISaveService.cs +++ b/Assets/_Game/Scripts/Core/ISaveService.cs @@ -73,5 +73,8 @@ namespace BaseGames.Core /// 标记某条事件链已完成。 void SetChainCompleted(string chainId); + + /// 标记某个 Boss 已被击败,立即写入当前 SaveData(无需等待下次存档)。 + void MarkBossDefeated(string bossId); } } diff --git a/Assets/_Game/Scripts/Core/Save/EmergencySaveService.cs b/Assets/_Game/Scripts/Core/Save/EmergencySaveService.cs index 6d8f45d..ca4cf9b 100644 --- a/Assets/_Game/Scripts/Core/Save/EmergencySaveService.cs +++ b/Assets/_Game/Scripts/Core/Save/EmergencySaveService.cs @@ -28,7 +28,7 @@ namespace BaseGames.Core.Save private void Update() { if (!_gameplayActive || _saveManager == null) return; - _timer += Time.deltaTime; + _timer += Time.unscaledDeltaTime; if (_timer >= _intervalSeconds) { _timer = 0f; diff --git a/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs b/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs index b275cda..9bbe23e 100644 --- a/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs +++ b/Assets/_Game/Scripts/Core/Save/GameSaveManager.cs @@ -39,8 +39,15 @@ namespace BaseGames.Core.Save if (_instance != null) { Destroy(gameObject); return; } _instance = this; ServiceLocator.Register(this); + } - _storage = new LocalFileStorage(); + /// + /// 注入存储后端。由 GameServiceRegistrar.Awake 在注册服务后立即调用。 + /// 允许在测试环境替换为 InMemoryStorage 等替代实现。 + /// + public void Initialize(ISaveStorage storage) + { + _storage = storage; } // ── ISaveable 注册 ──────────────────────────────────────────────────── @@ -105,8 +112,12 @@ namespace BaseGames.Core.Save if (!ValidateChecksum(loaded, json)) { + if (loaded.Meta.IsSteelSoul) + { + Debug.LogError("[SaveManager] 钢铁之魂存档 checksum 校验失败,拒绝加载以防止作弊。"); + return false; + } Debug.LogWarning("[SaveManager] 存档 checksum 校验失败。"); - // 非 SteelSoul 模式仍允许加载(只是警告) } loaded = SaveMigrator.Migrate(loaded); @@ -179,6 +190,13 @@ namespace BaseGames.Core.Save public bool IsBossDefeated(string bossId) => _current?.World.DefeatedBossIds.Contains(bossId) == true; + public void MarkBossDefeated(string bossId) + { + _current ??= new SaveData(); + if (!_current.World.DefeatedBossIds.Contains(bossId)) + _current.World.DefeatedBossIds.Add(bossId); + } + // ── 存档槽摘要 ──────────────────────────────────────────────────────── public async Task GetSlotSummaryAsync(int slotIndex) { diff --git a/Assets/_Game/Scripts/Core/Save/SaveData.cs b/Assets/_Game/Scripts/Core/Save/SaveData.cs index 042d9ef..16eeec0 100644 --- a/Assets/_Game/Scripts/Core/Save/SaveData.cs +++ b/Assets/_Game/Scripts/Core/Save/SaveData.cs @@ -50,7 +50,6 @@ namespace BaseGames.Core.Save [Serializable] public class PlayerSaveData { - public float PosX, PosY; public string Scene; public int CurrentHP, MaxHP; public int CurrentLingZhu, LifetimeLingZhu; @@ -97,7 +96,6 @@ namespace BaseGames.Core.Save public List DefeatedBossIds = new(); public List CollectedIds = new(); public List DestroyedObjectIds = new(); - public Dictionary Switches = new(); public Dictionary NpcRelations = new(); public HashSet ChallengeFirstClears = new(); } @@ -106,9 +104,9 @@ namespace BaseGames.Core.Save [Serializable] public class MapSaveData { - public List ExploredRooms = new(); // 踏入过的房间 ID - public List MappedRooms = new(); // 完整地图信息(购买/存档点揭示) - public List Pins = new(); // 玩家自定义地图标记 + public HashSet ExploredRooms = new(); // 踏入过的房间 ID + public HashSet MappedRooms = new(); // 完整地图信息(购买/存档点揭示) + public List Pins = new(); // 玩家自定义地图标记 } /// 玩家在地图上放置的自定义标记(架构 15_MapShopModule §1.5)。 @@ -171,7 +169,6 @@ namespace BaseGames.Core.Save public int LingZhuEarned, LingZhuLost; public float DistanceTraveled; public float SpeedrunTime; - public int SaveCount; public Dictionary SkillUseCounts = new(); public Dictionary DeathsByBoss = new(); } diff --git a/Assets/_Game/Scripts/Core/Save/SaveMigrator.cs b/Assets/_Game/Scripts/Core/Save/SaveMigrator.cs index adcae5f..3ccb45a 100644 --- a/Assets/_Game/Scripts/Core/Save/SaveMigrator.cs +++ b/Assets/_Game/Scripts/Core/Save/SaveMigrator.cs @@ -26,10 +26,6 @@ namespace BaseGames.Core.Save data.ChallengeRooms ??= new ChallengeRoomsSaveData(); data.NGPlus = null; // 非 NG+ 模式 - // Player 子字段兼容(旧版本可能未记录 DeathShade) - if (data.Player != null) - data.Player.DeathShade ??= new DeathShadeSaveData(); - Debug.Log($"[SaveMigrator] 从 '{v}' 迁移至 '2.0'。"); v = "2.0"; } diff --git a/Assets/_Game/Scripts/Player/PlayerStats.cs b/Assets/_Game/Scripts/Player/PlayerStats.cs index 65eba85..9d301c1 100644 --- a/Assets/_Game/Scripts/Player/PlayerStats.cs +++ b/Assets/_Game/Scripts/Player/PlayerStats.cs @@ -10,7 +10,7 @@ namespace BaseGames.Player /// 玩家数值管理组件。负责 HP、灵魂、灵气、弹簧充能、LingZhu、能力解锁与存档读写。 /// 实现 供 RewardSO 使用,避免 Quest 程序集直接依赖 Player 程序集。 /// - public class PlayerStats : MonoBehaviour, ISaveable, BaseGames.Core.IRestoreOnSave, IRewardTarget + public class PlayerStats : MonoBehaviour, ISaveable, BaseGames.Core.IRestoreOnSave, IRewardTarget, BaseGames.Core.ILingZhuProvider { [Header("配置")] [SerializeField] private PlayerStatsSO _config; @@ -36,6 +36,7 @@ namespace BaseGames.Player public int MaxSpringCharges { get; private set; } public int SpringKillPoints { get; private set; } public int CurrentLingZhu { get; private set; } + private int _lifetimeLingZhu; public bool IsInvincible => _invincibleTimer > 0f; public bool IsAlive => CurrentHP > 0; @@ -277,7 +278,8 @@ namespace BaseGames.Player public void AddLingZhu(int amount) { if (amount <= 0) return; - CurrentLingZhu += amount; + CurrentLingZhu += amount; + _lifetimeLingZhu += amount; _onLingZhuChanged?.Raise(CurrentLingZhu); } @@ -320,6 +322,7 @@ namespace BaseGames.Player p.CurrentHP = CurrentHP; p.MaxHP = MaxHP; p.CurrentLingZhu = CurrentLingZhu; + p.LifetimeLingZhu = _lifetimeLingZhu; p.AbilityFlags = (uint)_unlockedAbilities; } @@ -329,6 +332,7 @@ namespace BaseGames.Player MaxHP = p.MaxHP; CurrentHP = Mathf.Clamp(p.CurrentHP, 0, MaxHP); CurrentLingZhu = p.CurrentLingZhu; + _lifetimeLingZhu = p.LifetimeLingZhu; _unlockedAbilities = (AbilityType)p.AbilityFlags; _onHPChanged?.Raise(CurrentHP); diff --git a/Assets/_Game/Scripts/Progression/Achievement/EventTriggeredCondition.cs b/Assets/_Game/Scripts/Progression/Achievement/EventTriggeredCondition.cs index d3021ad..fc6b175 100644 --- a/Assets/_Game/Scripts/Progression/Achievement/EventTriggeredCondition.cs +++ b/Assets/_Game/Scripts/Progression/Achievement/EventTriggeredCondition.cs @@ -4,19 +4,19 @@ using BaseGames.Core.Save; namespace BaseGames.Progression { /// - /// 由事件触发的成就条件(使用 World.Switches 中的布尔标志位)。 + /// 由事件触发的成就条件(使用 EventChains.WorldFlags 中的布尔标志位)。 /// 由代码(如剧情节点、NPC 交互)写入标志,AchievementManager 轮询检查。 /// [CreateAssetMenu(menuName = "BaseGames/Achievement/Condition/EventTriggered", fileName = "COND_EventTriggered_")] public class EventTriggeredCondition : AchievementCondition { - [Tooltip("World.Switches 中用于标记此事件发生的布尔 Key")] + [Tooltip("EventChains.WorldFlags 中用于标记此事件发生的布尔 Key")] public string flagKey; public override bool IsMet(SaveData save) { - if (save?.World?.Switches == null || string.IsNullOrEmpty(flagKey)) return false; - return save.World.Switches.TryGetValue(flagKey, out bool val) && val; + if (save?.EventChains?.WorldFlags == null || string.IsNullOrEmpty(flagKey)) return false; + return save.EventChains.WorldFlags.TryGetValue(flagKey, out bool val) && val; } } } diff --git a/Assets/_Game/Scripts/Progression/Achievement/NoHealRunCondition.cs b/Assets/_Game/Scripts/Progression/Achievement/NoHealRunCondition.cs index 96462ae..94febb5 100644 --- a/Assets/_Game/Scripts/Progression/Achievement/NoHealRunCondition.cs +++ b/Assets/_Game/Scripts/Progression/Achievement/NoHealRunCondition.cs @@ -5,7 +5,7 @@ namespace BaseGames.Progression { /// /// 无治疗通关成就条件。 - /// 依赖 World.Switches 中的标志位:AchievementManager 在玩家治疗时写入 noHeal_failed=true。 + /// 依赖 EventChains.WorldFlags 中的标志位:AchievementManager 在玩家治疗时写入 noHeal_failed=true。 /// 满足条件:Boss 已击败 且 无治疗标志未被置入失败状态。 /// [CreateAssetMenu(menuName = "BaseGames/Achievement/Condition/NoHealRun", fileName = "COND_NoHealRun_")] @@ -14,7 +14,7 @@ namespace BaseGames.Progression [Tooltip("追踪的 Boss ID(击败此 Boss 时检查是否治疗过)")] public string targetBossId; - [Tooltip("Switches 中记录「已治疗」失败状态的 Key(由 AchievementManager 设置)")] + [Tooltip("WorldFlags 中记录「已治疗」失败状态的 Key(由 AchievementManager 设置)")] public string healFailFlagKey; public override bool IsMet(SaveData save) @@ -22,7 +22,7 @@ namespace BaseGames.Progression if (save?.World == null) return false; // Boss 已击败 且 未触发治疗失败标志 bool bossDefeated = save.World.DefeatedBossIds.Contains(targetBossId); - bool didHeal = save.World.Switches.TryGetValue(healFailFlagKey, out bool val) && val; + bool didHeal = save.EventChains?.WorldFlags?.TryGetValue(healFailFlagKey, out bool val) == true && val; return bossDefeated && !didHeal; } } diff --git a/Assets/_Game/Scripts/Progression/BossProgressTracker.cs b/Assets/_Game/Scripts/Progression/BossProgressTracker.cs index 6ab5dd7..afa7d05 100644 --- a/Assets/_Game/Scripts/Progression/BossProgressTracker.cs +++ b/Assets/_Game/Scripts/Progression/BossProgressTracker.cs @@ -1,4 +1,5 @@ using UnityEngine; +using BaseGames.Core; using BaseGames.Core.Events; namespace BaseGames.Progression @@ -6,7 +7,7 @@ namespace BaseGames.Progression /// /// Boss 进程追踪器(架构 09_ProgressionModule §13)。 /// 挂载在 Boss 房间的 BossTrigger 同一对象上。 - /// 监听 _onBossDefeated 事件,路由到 SaveSystem 专用频道(零耦合)。 + /// 监听 _onBossDefeated 事件,立即将胜利写入 SaveData 并推送到全局 ISaveService。 /// public class BossProgressTracker : MonoBehaviour { @@ -14,8 +15,7 @@ namespace BaseGames.Progression [SerializeField] private string[] _unlocksProgressLockIds; // 击败后应解锁的 ProgressLock ID(预留) [Header("Event Channels")] - [SerializeField] private StringEventChannelSO _onBossDefeated; // 监听(来自 BossCombat) - [SerializeField] private StringEventChannelSO _onBossDefeatedForSave; // 广播 → SaveSystem + [SerializeField] private StringEventChannelSO _onBossDefeated; // 监听(来自 BossCombat) private readonly CompositeDisposable _subs = new(); @@ -25,9 +25,7 @@ namespace BaseGames.Progression private void OnBossDefeated(string bossId) { if (bossId != _bossId) return; - - // 通过事件频道通知 SaveSystem(SaveSystem 负责写入 data.World.DefeatedBossIds) - _onBossDefeatedForSave?.Raise(bossId); + ServiceLocator.GetOrDefault()?.MarkBossDefeated(bossId); } } } diff --git a/Assets/_Game/Scripts/Support/AntiSoftlock/HardAbilityGate.cs b/Assets/_Game/Scripts/Support/AntiSoftlock/HardAbilityGate.cs index f0a32bb..51f796f 100644 --- a/Assets/_Game/Scripts/Support/AntiSoftlock/HardAbilityGate.cs +++ b/Assets/_Game/Scripts/Support/AntiSoftlock/HardAbilityGate.cs @@ -13,9 +13,9 @@ namespace BaseGames.Support.AntiSoftlock public class HardAbilityGate : AbilityGate { [Header("HardAbilityGate 设置")] - [Tooltip("是否还要求物理拾取验证(需要 World.Switches 中对应 Key = true)")] + [Tooltip("是否还要求物理拾取验证(需要 EventChains.WorldFlags 中对应 Key = true)")] [SerializeField] private bool _requirePhysicalValidation = false; - [Tooltip("物理拾取验证 Switch Key(需 SaveManager.Data.World.Switches[key] == true)")] + [Tooltip("物理拾取验证 Flag Key(需 EventChains.WorldFlags[key] == true)")] [SerializeField] private string _physicalPickupSwitchKey; [Header("引用")] @@ -29,8 +29,8 @@ namespace BaseGames.Support.AntiSoftlock // 次级检查:物理拾取确认 if (string.IsNullOrEmpty(_physicalPickupSwitchKey)) return true; var save = _saveManager != null ? _saveManager.Data : null; - if (save?.World?.Switches == null) return false; - return save.World.Switches.TryGetValue(_physicalPickupSwitchKey, out bool val) && val; + if (save?.EventChains?.WorldFlags == null) return false; + return save.EventChains.WorldFlags.TryGetValue(_physicalPickupSwitchKey, out bool val) && val; } } } diff --git a/Assets/_Game/Scripts/World/DeathShadeManager.cs b/Assets/_Game/Scripts/World/DeathShadeManager.cs new file mode 100644 index 0000000..308b8d8 --- /dev/null +++ b/Assets/_Game/Scripts/World/DeathShadeManager.cs @@ -0,0 +1,113 @@ +using UnityEngine; +using UnityEngine.SceneManagement; +using BaseGames.Core; +using BaseGames.Core.Events; +using BaseGames.Core.Save; + +namespace BaseGames.World +{ + /// + /// 管理死亡遗骸(Death Shade)的生命周期与持久化。 + /// + /// 挂载位置:Persistent 场景的根 GameObject(DontDestroyOnLoad)。 + /// + /// 流程: + /// 玩家死亡 → OnPlayerDied:记录死亡位置与灵珠量,在当前场景生成遗骸。 + /// 切换场景 → OnSceneLoaded:若新场景即遗骸所在场景,自动生成遗骸。 + /// 回收遗骸 → OnShadeCollected:清除待存储的遗骸数据。 + /// 存档 → OnSave:将遗骸数据(null = 无遗骸)写入 SaveData.Player.DeathShade。 + /// 读档 → OnLoad:从 SaveData 恢复遗骸数据;实际 GameObject 在下次进入该场景时生成。 + /// + public class DeathShadeManager : SaveableMonoBehaviour + { + [SerializeField] private DeathShade _shadePrefab; + [SerializeField] private VoidEventChannelSO _onPlayerDied; + [SerializeField] private StringEventChannelSO _onShadeCollected; + + private DeathShadeSaveData _pendingSave; + private DeathShade _activeShade; + + private readonly CompositeDisposable _subs = new(); + + protected override void OnEnable() + { + base.OnEnable(); + _onPlayerDied? .Subscribe(OnPlayerDied) .AddTo(_subs); + _onShadeCollected?.Subscribe(OnShadeCollected).AddTo(_subs); + SceneManager.sceneLoaded += OnSceneLoaded; + } + + protected override void OnDisable() + { + base.OnDisable(); + _subs.Clear(); + SceneManager.sceneLoaded -= OnSceneLoaded; + } + + private void OnPlayerDied() + { + var playerGo = GameObject.FindWithTag("Player"); + if (playerGo == null) return; + + int lingZhu = playerGo.GetComponent()?.CurrentLingZhu ?? 0; + var pos = (Vector2)playerGo.transform.position; + string sceneId = SceneManager.GetActiveScene().name; + + if (_activeShade != null) + { + Destroy(_activeShade.gameObject); + _activeShade = null; + } + + _pendingSave = new DeathShadeSaveData + { + PosX = pos.x, + PosY = pos.y, + SceneId = sceneId, + LingZhuAmount = lingZhu, + }; + + TrySpawnShade(sceneId); + } + + private void OnSceneLoaded(Scene scene, LoadSceneMode _) + { + _activeShade = null; // 前一场景的 GameObject 已被 Unity 卸载 + TrySpawnShade(scene.name); + } + + private void TrySpawnShade(string sceneName) + { + if (_shadePrefab == null || _pendingSave == null) return; + if (_pendingSave.SceneId != sceneName) return; + + _activeShade = Instantiate(_shadePrefab); + _activeShade.Initialize( + _pendingSave.LingZhuAmount, + _pendingSave.SceneId, + new Vector2(_pendingSave.PosX, _pendingSave.PosY)); + } + + private void OnShadeCollected(string _) + { + _pendingSave = null; + _activeShade = null; + } + + public override void OnSave(SaveData data) + { + data.Player.DeathShade = _pendingSave; + } + + public override void OnLoad(SaveData data) + { + if (_activeShade != null) + { + Destroy(_activeShade.gameObject); + _activeShade = null; + } + _pendingSave = data.Player.DeathShade; + // 遗骸 GameObject 将在 OnSceneLoaded 中按需生成 + } + } +} diff --git a/Assets/_Game/Scripts/World/WorldStateRegistry.cs b/Assets/_Game/Scripts/World/WorldStateRegistry.cs index ef5add5..ef64c1b 100644 --- a/Assets/_Game/Scripts/World/WorldStateRegistry.cs +++ b/Assets/_Game/Scripts/World/WorldStateRegistry.cs @@ -17,8 +17,9 @@ namespace BaseGames.World /// /// 运行时世界状态缓存。ScriptableObject,通过 [SerializeField] 注入各组件。 - /// SaveManager.SaveAsync 调用 GetAllFlags(); - /// SaveManager.LoadAsync 调用 LoadFromSave(data.World)。 + /// WorldStateRegistrySaver(ISaveable)负责将本对象与存档管道连接: + /// SaveAsync → WorldStateRegistrySaver.OnSave → 写出全部分类到 SaveData; + /// LoadAsync → WorldStateRegistrySaver.OnLoad → 调用 LoadFromSave(data) 恢复缓存。 /// [CreateAssetMenu(menuName = "BaseGames/World/WorldStateRegistry")] public class WorldStateRegistry : ScriptableObject @@ -77,37 +78,34 @@ namespace BaseGames.World // ── Persistence ─────────────────────────────────────────────────────── - /// 从存档数据恢复全部状态。由 SaveManager.LoadAsync 调用。 - public void LoadFromSave(WorldSaveData data) + /// + /// 从存档数据恢复全部状态。由 WorldStateRegistrySaver.OnLoad 调用。 + /// + public void LoadFromSave(SaveData data) { _states.Clear(); if (data == null) return; - foreach (var id in data.CollectedIds) Mark(WorldObjectCategory.Collectible, id); - foreach (var id in data.ActivatedSavePoints) Mark(WorldObjectCategory.SavePoint, id); - foreach (var id in data.OpenedDoors) Mark(WorldObjectCategory.Door, id); - foreach (var id in data.DestroyedObjectIds) Mark(WorldObjectCategory.Destroyed, id); + foreach (var id in data.World.CollectedIds) Mark(WorldObjectCategory.Collectible, id); + foreach (var id in data.World.ActivatedSavePoints) Mark(WorldObjectCategory.SavePoint, id); + foreach (var id in data.World.OpenedDoors) Mark(WorldObjectCategory.Door, id); + foreach (var id in data.World.DestroyedObjectIds) Mark(WorldObjectCategory.Destroyed, id); - if (data.Switches != null) - foreach (var kv in data.Switches) + if (data.EventChains?.WorldFlags != null) + foreach (var kv in data.EventChains.WorldFlags) if (kv.Value) Mark(WorldObjectCategory.Flag, kv.Key); } - /// 获取所有通用标记,供 SaveManager 持久化。 - public HashSet GetAllFlags() + /// + /// 返回指定分类中所有已标记的 ID(快照副本)。 + /// 由 WorldStateRegistrySaver.OnSave 调用,将运行时状态写入 SaveData。 + /// + public IReadOnlyCollection GetAllIds(WorldObjectCategory category) { - if (_states.TryGetValue(WorldObjectCategory.Flag, out var flags)) - return new HashSet(flags); - return new HashSet(); - } - - /// 获取所有已摧毁对象 ID,供 SaveManager 持久化。 - public HashSet GetAllDestroyedIds() - { - if (_states.TryGetValue(WorldObjectCategory.Destroyed, out var set)) + if (_states.TryGetValue(category, out var set)) return new HashSet(set); - return new HashSet(); + return System.Array.Empty(); } /// 重置所有状态(开始新游戏时调用)。 diff --git a/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs b/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs new file mode 100644 index 0000000..1abfb83 --- /dev/null +++ b/Assets/_Game/Scripts/World/WorldStateRegistrySaver.cs @@ -0,0 +1,42 @@ +using BaseGames.Core.Save; + +namespace BaseGames.World +{ + /// + /// 将 WorldStateRegistry 接入 ISaveable 存档管道的桥接组件。 + /// + /// 挂载位置:Persistent 场景的根 GameObject(与 GameServiceRegistrar 同级)。 + /// 需在 Inspector 中绑定场景中唯一的 WorldStateRegistry ScriptableObject 资源。 + /// + /// OnSave:从 Registry 读取 Collectible / Destroyed / Flag 三个分类,写入 SaveData。 + /// SavePoint 和 Door 两个分类由各自的 SaveableMonoBehaviour 独立写入,此处跳过。 + /// OnLoad:将完整的 SaveData 传递给 Registry,恢复所有分类的运行时缓存。 + /// 这是 Registry 从空白变为"知道世界状态"的唯一时机。 + /// + public class WorldStateRegistrySaver : SaveableMonoBehaviour + { + [UnityEngine.SerializeField] private WorldStateRegistry _registry; + + public override void OnSave(SaveData data) + { + if (_registry == null) return; + + data.World.CollectedIds.Clear(); + foreach (var id in _registry.GetAllIds(WorldObjectCategory.Collectible)) + data.World.CollectedIds.Add(id); + + data.World.DestroyedObjectIds.Clear(); + foreach (var id in _registry.GetAllIds(WorldObjectCategory.Destroyed)) + data.World.DestroyedObjectIds.Add(id); + + foreach (var id in _registry.GetAllIds(WorldObjectCategory.Flag)) + data.EventChains.WorldFlags[id] = true; + } + + public override void OnLoad(SaveData data) + { + if (_registry == null) return; + _registry.LoadFromSave(data); + } + } +}