多轮审查和修复
This commit is contained in:
@@ -50,7 +50,7 @@ namespace BaseGames.Core.Assets
|
||||
|
||||
/// <summary>
|
||||
/// 解析 key,返回对应的 Addressable 地址字符串。
|
||||
/// 若 key 未注册则返回原 key(兼容直接使用静态常量的调用方)。
|
||||
/// 若 key 未注册,直接将 key 作为 Addressable 地址使用。
|
||||
/// </summary>
|
||||
public static string Resolve(string key)
|
||||
{
|
||||
|
||||
@@ -43,10 +43,16 @@ namespace BaseGames.Core.Assets
|
||||
// ── Config ScriptableObjects ─────────────────────────────────────
|
||||
public const string DataFootstepCatalog = "Config/FootstepCatalog";
|
||||
|
||||
// ── Labels(批量加载用)──────────────────────────────────────────
|
||||
public const string LabelEnemy = "Enemy";
|
||||
public const string LabelPoolable = "Poolable";
|
||||
public const string LabelBGM = "BGM";
|
||||
public const string LabelCharms = "Charms";
|
||||
/// <summary>
|
||||
/// Addressable 标签常量(用于批量加载)。
|
||||
/// 注意:这里是标签名称而非资产地址,不会被 AddressKeyValidator 校验。
|
||||
/// </summary>
|
||||
public static class Labels
|
||||
{
|
||||
public const string Enemy = "Enemy";
|
||||
public const string Poolable = "Poolable";
|
||||
public const string BGM = "BGM";
|
||||
public const string Charms = "Charms";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ namespace BaseGames.Core
|
||||
/// <summary>
|
||||
/// 资产释放跟踪器。
|
||||
/// 事件驱动:监听 EVT_SceneLoadRequest,在新场景加载前清理旧场景的对象池缓存。
|
||||
/// ⚠️ 不使用显式注册 API;GlobalObjectPool.ClearPool 在场景切换时批量清理。
|
||||
/// </summary>
|
||||
public class AssetReleaseTracker : MonoBehaviour
|
||||
{
|
||||
@@ -16,28 +15,21 @@ namespace BaseGames.Core
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
private string _lastLoadedScene;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised += OnSceneLoadRequested;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised -= OnSceneLoadRequested;
|
||||
}
|
||||
private void OnEnable() => _onSceneLoadRequest?.Subscribe(OnSceneLoadRequested).AddTo(_subs);
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void OnSceneLoadRequested(SceneLoadRequest req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_lastLoadedScene)) { _lastLoadedScene = req.SceneName; return; }
|
||||
|
||||
// 清除旧场景的敌人对象池缓存(按需扩展)
|
||||
if (GlobalObjectPool.Instance != null)
|
||||
var pool = ServiceLocator.GetOrDefault<IObjectPoolService>();
|
||||
if (pool != null)
|
||||
{
|
||||
GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemyGrunt);
|
||||
GlobalObjectPool.Instance.ClearPool(AddressKeys.PrefabEnemySkullArch);
|
||||
pool.ClearPool(AddressKeys.PrefabEnemyGrunt);
|
||||
pool.ClearPool(AddressKeys.PrefabEnemySkullArch);
|
||||
}
|
||||
|
||||
_lastLoadedScene = req.SceneName;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
@@ -20,7 +21,7 @@ namespace BaseGames.Core
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 死亡/复活流程独立服务(Phase 0 骨架,Phase 1 完整实现)。
|
||||
/// 死亡/复活流程独立服务。
|
||||
/// </summary>
|
||||
public class DeathRespawnService : MonoBehaviour, IDeathRespawnService
|
||||
{
|
||||
@@ -30,49 +31,58 @@ namespace BaseGames.Core
|
||||
[SerializeField] private float _respawnFadeDuration = 0.4f;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnCompleted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnStarted;
|
||||
[SerializeField] private VoidEventChannelSO _onRespawnCompleted;
|
||||
[SerializeField] private SceneLoadRequestEventChannelSO _onSceneLoadRequest;
|
||||
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private VoidEventChannelSO _onDeathScreenConfirmed;
|
||||
|
||||
private bool _deathConfirmed;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onDeathScreenConfirmed != null)
|
||||
_onDeathScreenConfirmed.OnEventRaised += HandleDeathScreenConfirmed;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onDeathScreenConfirmed != null)
|
||||
_onDeathScreenConfirmed.OnEventRaised -= HandleDeathScreenConfirmed;
|
||||
}
|
||||
|
||||
private void HandleDeathScreenConfirmed() => _deathConfirmed = true;
|
||||
|
||||
public IEnumerator StartDeathSequenceCoroutine()
|
||||
{
|
||||
yield return new WaitForSeconds(_deathAnimDuration);
|
||||
yield return new WaitForSeconds(_deathScreenDelay);
|
||||
_deathConfirmed = false;
|
||||
yield return new WaitUntil(() => _deathConfirmed);
|
||||
|
||||
// 局部订阅确认事件,不依赖类级 bool 字段
|
||||
bool confirmed = false;
|
||||
var sub = _onDeathScreenConfirmed?.Subscribe(() => confirmed = true);
|
||||
yield return new WaitUntil(() => confirmed);
|
||||
sub?.Dispose();
|
||||
}
|
||||
|
||||
public IEnumerator StartRespawnCoroutine()
|
||||
{
|
||||
_onRespawnStarted?.Raise();
|
||||
yield return new WaitForSeconds(_respawnFadeDuration);
|
||||
// Phase 1:加载存档场景(TODO)
|
||||
|
||||
// 通过 SceneLoadRequest 频道触发场景重载,复用 SceneService / RoomTransition 路径
|
||||
var sm = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
_onSceneLoadRequest?.Raise(new SceneLoadRequest
|
||||
{
|
||||
SceneName = sm?.LastCheckpointScene,
|
||||
EntryTransitionId = sm?.LastCheckpointSpawnId,
|
||||
ShowLoadingScreen = true,
|
||||
IsRespawn = true,
|
||||
});
|
||||
|
||||
yield return new WaitForSeconds(_respawnFadeDuration);
|
||||
_onRespawnCompleted?.Raise();
|
||||
}
|
||||
|
||||
public IEnumerator StartGameOverCoroutine()
|
||||
{
|
||||
// Phase 1:SteelSoul 清档并返回主菜单(TODO)
|
||||
yield return null;
|
||||
// 1. 删除当前存档槽
|
||||
var saveManager = ServiceLocator.GetOrDefault<ISaveService>();
|
||||
if (saveManager != null)
|
||||
{
|
||||
var task = saveManager.DeleteSlotAsync(saveManager.ActiveSlot);
|
||||
yield return new WaitUntil(() => task.IsCompleted);
|
||||
}
|
||||
|
||||
// 2. 返回主菜单
|
||||
var sceneService = ServiceLocator.GetOrDefault<ISceneService>();
|
||||
if (sceneService != null)
|
||||
yield return sceneService.LoadMainMenuCoroutine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
Assets/Scripts/Core/Difficulty.meta
Normal file
8
Assets/Scripts/Core/Difficulty.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7982848c7ca0270419ab1c0a32fde9a0
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
79
Assets/Scripts/Core/Difficulty/DifficultyManager.cs
Normal file
79
Assets/Scripts/Core/Difficulty/DifficultyManager.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度管理器(单例 MonoBehaviour)。
|
||||
/// 统一管理当前难度档位,并通过事件频道广播变化。
|
||||
/// 订阅者通过 <see cref="CurrentScaler"/> 获取具体缩放数值。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class DifficultyManager : MonoBehaviour, ISaveable, IDifficultyService
|
||||
{
|
||||
[SerializeField] private DifficultyScalerSO[] _allScalers;
|
||||
[SerializeField] private DifficultyChangedEventChannel _onDifficultyChanged;
|
||||
|
||||
public DifficultyLevel CurrentLevel { get; private set; } = DifficultyLevel.Normal;
|
||||
public DifficultyScalerSO CurrentScaler { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (ServiceLocator.GetOrDefault<IDifficultyService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IDifficultyService>(this);
|
||||
Apply(DifficultyLevel.Normal);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IDifficultyService>(this);
|
||||
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在游戏流程中切换难度。
|
||||
/// SteelSoul 模式一旦选定,中途无法降级。
|
||||
/// </summary>
|
||||
public void ChangeDifficulty(DifficultyLevel level)
|
||||
{
|
||||
if (CurrentLevel == DifficultyLevel.SteelSoul && level != DifficultyLevel.SteelSoul)
|
||||
{
|
||||
Debug.LogWarning("[DifficultyManager] SteelSoul 模式无法在游戏中途降级。");
|
||||
return;
|
||||
}
|
||||
Apply(level);
|
||||
}
|
||||
|
||||
/// <summary>按档位查找对应的缩放器,未配置时返回 null。</summary>
|
||||
public DifficultyScalerSO GetScaler(DifficultyLevel level)
|
||||
{
|
||||
if (_allScalers == null) return null;
|
||||
foreach (var s in _allScalers)
|
||||
if (s != null && s.Level == level) return s;
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Apply(DifficultyLevel level)
|
||||
{
|
||||
CurrentLevel = level;
|
||||
CurrentScaler = GetScaler(level);
|
||||
_onDifficultyChanged?.Raise(CurrentLevel);
|
||||
}
|
||||
|
||||
// ── ISaveable ────────────────────────────────────────────────────────
|
||||
|
||||
public void OnSave(SaveData saveData)
|
||||
{
|
||||
if (saveData?.Meta != null)
|
||||
saveData.Meta.IsSteelSoul = CurrentLevel == DifficultyLevel.SteelSoul;
|
||||
}
|
||||
|
||||
public void OnLoad(SaveData saveData)
|
||||
{
|
||||
if (saveData?.Meta != null && saveData.Meta.IsSteelSoul)
|
||||
Apply(DifficultyLevel.SteelSoul);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d3e424c1787e5be4fa918201b1830192
|
||||
guid: 7a810da0a9739024d90a4f7415aeb2a6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
41
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
Normal file
41
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度缩放配置 ScriptableObject。每个难度档位对应一个实例。
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Core/DifficultyScaler")]
|
||||
public class DifficultyScalerSO : ScriptableObject
|
||||
{
|
||||
[Header("标识")]
|
||||
public DifficultyLevel Level;
|
||||
|
||||
[Header("玩家")]
|
||||
public float PlayerMaxHPMultiplier = 1.0f;
|
||||
public float PlayerDamageMultiplier = 1.0f;
|
||||
public float InvincibilityFrameScale = 1.0f;
|
||||
|
||||
[Header("敌人")]
|
||||
public float EnemyDamageMultiplier = 1.0f;
|
||||
public float EnemyHPMultiplier = 1.0f;
|
||||
public float BossDamageMultiplier = 1.0f;
|
||||
public float BossHPMultiplier = 1.0f;
|
||||
|
||||
[Header("经济")]
|
||||
public float ShopPriceMultiplier = 1.0f;
|
||||
public float GeoDropMultiplier = 1.0f;
|
||||
|
||||
[Header("机制")]
|
||||
public bool CanReviveWithGeoLoss = true;
|
||||
public bool InstantDeathOnZeroHP = false;
|
||||
public bool GeoPenaltyOnDeath = true;
|
||||
|
||||
[Header("AI")]
|
||||
public float EnemyAttackIntervalScale = 1.0f;
|
||||
public float EnemyAggroRangeScale = 1.0f;
|
||||
public float EnemyReactionTimeScale = 1.0f;
|
||||
public int EnemyAggressionLevel = 2;
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs.meta
Normal file
11
Assets/Scripts/Core/Difficulty/DifficultyScalerSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d7e8ad48b35397348a800079849ee535
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
22
Assets/Scripts/Core/Difficulty/IDifficultyService.cs
Normal file
22
Assets/Scripts/Core/Difficulty/IDifficultyService.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度服务接口(架构 §DifficultyModule)。
|
||||
/// 提供当前难度档位及缩放参数,供跨程序集的游戏系统(战斗、商店、掉落等)查询,
|
||||
/// 无需直接依赖 <see cref="DifficultyManager"/> 具体类型。
|
||||
/// </summary>
|
||||
public interface IDifficultyService
|
||||
{
|
||||
/// <summary>当前难度档位。</summary>
|
||||
DifficultyLevel CurrentLevel { get; }
|
||||
|
||||
/// <summary>当前档位对应的缩放配置。未配置时返回 null。</summary>
|
||||
DifficultyScalerSO CurrentScaler { get; }
|
||||
|
||||
/// <summary>切换到指定难度。SteelSoul 模式一旦选定不可降级。</summary>
|
||||
void ChangeDifficulty(DifficultyLevel level);
|
||||
|
||||
/// <summary>按档位查找缩放器,未配置时返回 null。</summary>
|
||||
DifficultyScalerSO GetScaler(DifficultyLevel level);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Difficulty/IDifficultyService.cs.meta
Normal file
11
Assets/Scripts/Core/Difficulty/IDifficultyService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 20984324b3111c5489d9c4c3e55ec4f6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -10,15 +10,37 @@ namespace BaseGames.Core.Events
|
||||
{
|
||||
[Multiline] public string description;
|
||||
|
||||
public event Action<T> OnEventRaised;
|
||||
private event Action<T> _onEventRaisedBacking;
|
||||
#if UNITY_EDITOR
|
||||
private int _subscriberCount;
|
||||
#endif
|
||||
|
||||
public event Action<T> OnEventRaised
|
||||
{
|
||||
add
|
||||
{
|
||||
_onEventRaisedBacking += value;
|
||||
#if UNITY_EDITOR
|
||||
_subscriberCount++;
|
||||
#endif
|
||||
}
|
||||
remove
|
||||
{
|
||||
_onEventRaisedBacking -= value;
|
||||
#if UNITY_EDITOR
|
||||
_subscriberCount--;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public void Raise(T value)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
EventBusMonitor.Record(name, value?.ToString() ?? "null",
|
||||
OnEventRaised?.GetInvocationList().Length ?? 0);
|
||||
_subscriberCount,
|
||||
Time.frameCount);
|
||||
#endif
|
||||
OnEventRaised?.Invoke(value);
|
||||
_onEventRaisedBacking?.Invoke(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -38,15 +60,37 @@ namespace BaseGames.Core.Events
|
||||
{
|
||||
[Multiline] public string description;
|
||||
|
||||
public event Action OnEventRaised;
|
||||
private event Action _onEventRaisedBacking;
|
||||
#if UNITY_EDITOR
|
||||
private int _subscriberCount;
|
||||
#endif
|
||||
|
||||
public event Action OnEventRaised
|
||||
{
|
||||
add
|
||||
{
|
||||
_onEventRaisedBacking += value;
|
||||
#if UNITY_EDITOR
|
||||
_subscriberCount++;
|
||||
#endif
|
||||
}
|
||||
remove
|
||||
{
|
||||
_onEventRaisedBacking -= value;
|
||||
#if UNITY_EDITOR
|
||||
_subscriberCount--;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
public void Raise()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
EventBusMonitor.Record(name, "<void>",
|
||||
OnEventRaised?.GetInvocationList().Length ?? 0);
|
||||
_subscriberCount,
|
||||
Time.frameCount);
|
||||
#endif
|
||||
OnEventRaised?.Invoke();
|
||||
_onEventRaisedBacking?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
11
Assets/Scripts/Core/Events/BoolEventChannelSO.cs.meta
Normal file
11
Assets/Scripts/Core/Events/BoolEventChannelSO.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5c798758acf2c64097cf4ff3b088530
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,9 +1,2 @@
|
||||
// 此文件已废弃。DamageInfo / DamageInfoEventChannelSO 已迁移至
|
||||
// Assets/Scripts/Combat/DamageInfo.cs (namespace BaseGames.Combat)
|
||||
// 程序集 BaseGames.Combat.asmdef
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
// 保留空命名空间,避免 .meta 文件冲突。
|
||||
// ReSharper disable once EmptyNamespace
|
||||
}
|
||||
// DamageInfo 及 DamageInfoEventChannelSO 定义位于 Assets/Scripts/Combat/DamageInfo.cs。
|
||||
namespace BaseGames.Core.Events { }
|
||||
|
||||
@@ -3,7 +3,7 @@ using UnityEngine;
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
/// <summary>
|
||||
/// 难度变更事件频道。Phase 2 难度系统使用。
|
||||
/// 难度变更事件频道。
|
||||
/// 发布:DifficultyScalerSO / SettingsManager
|
||||
/// 订阅:所有需要感知当前难度的系统
|
||||
/// </summary>
|
||||
|
||||
@@ -11,29 +11,53 @@ namespace BaseGames.Core.Events
|
||||
public string ChannelName;
|
||||
public string Payload;
|
||||
public int ListenerCount;
|
||||
public int FrameCount;
|
||||
public System.DateTime Timestamp;
|
||||
}
|
||||
|
||||
private static readonly System.Collections.Generic.Queue<EventRecord> _records
|
||||
= new System.Collections.Generic.Queue<EventRecord>(256);
|
||||
private const int Capacity = 256;
|
||||
|
||||
public static System.Collections.Generic.IEnumerable<EventRecord> Records => _records;
|
||||
// 固定大小环形缓冲区,避免 Queue.Enqueue/Dequeue 产生的 GC 分配
|
||||
private static readonly EventRecord[] _buffer = new EventRecord[Capacity];
|
||||
private static int _head = 0; // 下一条写入的位置
|
||||
private static int _count = 0; // 当前有效记录数(≤ Capacity)
|
||||
|
||||
public static void Record(string channelName, string payload, int listenerCount)
|
||||
/// <summary>按时间顺序(最旧→最新)枚举所有已记录事件。</summary>
|
||||
public static System.Collections.Generic.IEnumerable<EventRecord> Records
|
||||
{
|
||||
if (_records.Count >= 256) _records.Dequeue();
|
||||
_records.Enqueue(new EventRecord
|
||||
get
|
||||
{
|
||||
int start = _count < Capacity ? 0 : _head;
|
||||
int total = System.Math.Min(_count, Capacity);
|
||||
for (int i = 0; i < total; i++)
|
||||
yield return _buffer[(start + i) % Capacity];
|
||||
}
|
||||
}
|
||||
|
||||
// frameCount 使用 int(与 Time.frameCount 类型一致)。
|
||||
// 在 @1000fps 持续运行约 24.8 天后发生有符号溢出(C# 默认 unchecked,环绕为负数)。
|
||||
// 对于 Editor 调试工具此溢出无实际影响,此处不做处理。
|
||||
public static void Record(string channelName, string payload, int listenerCount, int frameCount)
|
||||
{
|
||||
_buffer[_head] = new EventRecord
|
||||
{
|
||||
ChannelName = channelName,
|
||||
Payload = payload,
|
||||
ListenerCount = listenerCount,
|
||||
FrameCount = frameCount,
|
||||
Timestamp = System.DateTime.Now
|
||||
});
|
||||
};
|
||||
_head = (_head + 1) % Capacity;
|
||||
if (_count < Capacity) _count++;
|
||||
}
|
||||
|
||||
public static void Clear() => _records.Clear();
|
||||
public static void Clear()
|
||||
{
|
||||
_head = 0;
|
||||
_count = 0;
|
||||
}
|
||||
#else
|
||||
public static void Record(string channelName, string payload, int listenerCount) { }
|
||||
public static void Record(string channelName, string payload, int listenerCount, int frameCount) { }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,16 @@ namespace BaseGames.Core.Events
|
||||
/// </summary>
|
||||
public class EventChannelRegistry : MonoBehaviour, IEventChannelRegistry
|
||||
{
|
||||
public static EventChannelRegistry Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<string, ScriptableObject> _channels = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
if (BaseGames.Core.ServiceLocator.GetOrDefault<IEventChannelRegistry>() != null)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
BaseGames.Core.ServiceLocator.Register<IEventChannelRegistry>(this);
|
||||
}
|
||||
|
||||
/// <summary>由 EventChannelRegistrar 在场景初始化时批量注册频道 SO。</summary>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 661043851605d4849bef40ea15c556b4
|
||||
guid: 147bb5b987a0a244ba3a39c71852ca51
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
|
||||
@@ -1,9 +1,2 @@
|
||||
// 此文件已废弃。HitInfo / HitConfirmedEventChannelSO 已迁移至
|
||||
// Assets/Scripts/Combat/HitInfo.cs (namespace BaseGames.Combat)
|
||||
// 程序集 BaseGames.Combat.asmdef
|
||||
|
||||
namespace BaseGames.Core.Events
|
||||
{
|
||||
// 保留空命名空间,避免 .meta 文件冲突。
|
||||
// ReSharper disable once EmptyNamespace
|
||||
}
|
||||
// HitInfo 及 HitConfirmedEventChannelSO 定义位于 Assets/Scripts/Combat/HitInfo.cs。
|
||||
namespace BaseGames.Core.Events { }
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 轻量服务定位器。通过类型键注册/查找服务,支持接口类型注册(依赖倒置)。
|
||||
/// <para><b>线程安全:</b>仅在 Unity 主线程调用。异步上下文(Task/Thread)不得访问此类。</para>
|
||||
/// </summary>
|
||||
public static class ServiceLocator
|
||||
{
|
||||
@@ -37,6 +38,22 @@ namespace BaseGames.Core
|
||||
=> _services.TryGetValue(typeof(TInterface), out var svc) && svc is TInterface typed
|
||||
? typed : fallback;
|
||||
|
||||
/// <summary>
|
||||
/// 注销服务。场景卸载或 Manager OnDestroy 时调用,防止持有已销毁对象的引用。
|
||||
/// </summary>
|
||||
public static void Unregister<TInterface>()
|
||||
=> _services.Remove(typeof(TInterface));
|
||||
|
||||
/// <summary>
|
||||
/// 安全版注销:仅当注册的实例与 <paramref name="impl"/> 相同时才移除,
|
||||
/// 避免后注册的新实例被前一个实例的 OnDestroy 错误清除。
|
||||
/// </summary>
|
||||
public static void Unregister<TInterface>(TInterface impl)
|
||||
{
|
||||
if (_services.TryGetValue(typeof(TInterface), out var svc) && ReferenceEquals(svc, impl))
|
||||
_services.Remove(typeof(TInterface));
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
/// <summary>单元测试中替换服务实现。</summary>
|
||||
public static void OverrideForTest<TInterface>(TInterface mock)
|
||||
97
Assets/Scripts/Core/GameIds.cs
Normal file
97
Assets/Scripts/Core/GameIds.cs
Normal file
@@ -0,0 +1,97 @@
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
// =====================================================================
|
||||
// GameIds —— 全局字符串 ID 常量(架构 02_CoreModule §IDs)
|
||||
// =====================================================================
|
||||
// 目的:消除散落在配置 SO 和代码中的 magic string,
|
||||
// 提供编译期校验 + IDE 自动补全 + 全局唯一检索点。
|
||||
//
|
||||
// 使用方式:
|
||||
// ① Inspector 侧:在 ScriptableObject 的 string 字段输入对应常量值(仅此处可见文本)。
|
||||
// ② 代码侧:直接引用常量,如 GameIds.Boss.ForestBoss,避免硬编码字符串。
|
||||
//
|
||||
// 新增规则:
|
||||
// - 按域划分嵌套静态类(Boss / Chain / Quest / Ability / Scene / Collectible / Npc)
|
||||
// - 命名风格:PascalCase,与 Unity 资产命名对齐(如 "Chain_BossForest_Defeated")
|
||||
// - 不要将常量值改名,只新增;如需废弃请标注 [System.Obsolete]
|
||||
// =====================================================================
|
||||
|
||||
public static class GameIds
|
||||
{
|
||||
// ── Boss IDs ───────────────────────────────────────────────────────
|
||||
/// <summary>Boss 唯一标识符,对应 BossDefeatedCondition.bossId 及 BossDataSO.bossId。</summary>
|
||||
public static class Boss
|
||||
{
|
||||
public const string ForestBoss = "Boss_Forest";
|
||||
public const string CaveBoss = "Boss_Cave";
|
||||
public const string CastleBoss = "Boss_Castle";
|
||||
public const string FinalBoss = "Boss_Final";
|
||||
}
|
||||
|
||||
// ── Chain IDs ──────────────────────────────────────────────────────
|
||||
/// <summary>事件链唯一标识符,对应 EventChainSO.chainId 及 ChainCompletedCondition.chainId。</summary>
|
||||
public static class Chain
|
||||
{
|
||||
public const string BossForestDefeated = "Chain_BossForest_Defeated";
|
||||
public const string CaveBossDefeated = "Chain_CaveBoss_Defeated";
|
||||
public const string CastleBossDefeated = "Chain_CastleBoss_Defeated";
|
||||
public const string FinalBossDefeated = "Chain_FinalBoss_Defeated";
|
||||
public const string TutorialComplete = "Chain_Tutorial_Complete";
|
||||
}
|
||||
|
||||
// ── Quest IDs ──────────────────────────────────────────────────────
|
||||
/// <summary>任务唯一标识符,对应 QuestSO.questId。</summary>
|
||||
public static class Quest
|
||||
{
|
||||
public const string MainQuest_Chapter1 = "Quest_Main_Ch1";
|
||||
public const string MainQuest_Chapter2 = "Quest_Main_Ch2";
|
||||
public const string SideQuest_Merchant = "Quest_Side_Merchant";
|
||||
}
|
||||
|
||||
// ── Ability IDs ────────────────────────────────────────────────────
|
||||
/// <summary>能力/技能唯一标识符,对应 AbilityUnlockedCondition.abilityId。</summary>
|
||||
public static class Ability
|
||||
{
|
||||
public const string DoubleJump = "Ability_DoubleJump";
|
||||
public const string Dash = "Ability_Dash";
|
||||
public const string WallJump = "Ability_WallJump";
|
||||
public const string Parry = "Ability_Parry";
|
||||
}
|
||||
|
||||
// ── Scene / Room Names ─────────────────────────────────────────────
|
||||
/// <summary>场景/房间标识符,对应 RoomEnteredCondition.sceneName。与 Unity Build Settings 场景名对齐。</summary>
|
||||
public static class Scene
|
||||
{
|
||||
public const string Forest = "Scene_Forest";
|
||||
public const string Cave = "Scene_Cave";
|
||||
public const string Castle = "Scene_Castle";
|
||||
public const string Hub = "Scene_Hub";
|
||||
public const string Tutorial = "Scene_Tutorial";
|
||||
}
|
||||
|
||||
// ── Collectible IDs ────────────────────────────────────────────────
|
||||
/// <summary>可收集物唯一标识符,对应 CollectibleCollectedCondition.itemId。</summary>
|
||||
public static class Collectible
|
||||
{
|
||||
public const string AncientKey = "Item_AncientKey";
|
||||
public const string MapFragment = "Item_MapFragment";
|
||||
}
|
||||
|
||||
// ── NPC IDs ────────────────────────────────────────────────────────
|
||||
/// <summary>NPC 唯一标识符,对应 DialogueCompletedCondition.npcId。</summary>
|
||||
public static class Npc
|
||||
{
|
||||
public const string OldMerchant = "Npc_OldMerchant";
|
||||
public const string SageGuard = "Npc_SageGuard";
|
||||
}
|
||||
|
||||
// ── Flag IDs ───────────────────────────────────────────────────────
|
||||
/// <summary>全局布尔存档标记,对应 FlagSetCondition.flagId 及 SaveManager.SetFlag/GetFlag。</summary>
|
||||
public static class Flag
|
||||
{
|
||||
public const string TutorialShown = "Flag_TutorialShown";
|
||||
public const string FirstDeath = "Flag_FirstDeath";
|
||||
public const string MerchantIntroduced = "Flag_MerchantIntroduced";
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/GameIds.cs.meta
Normal file
11
Assets/Scripts/Core/GameIds.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9133700debab31540bae36f9130c9791
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,14 +13,9 @@ namespace BaseGames.Core
|
||||
[DefaultExecutionOrder(-1000)]
|
||||
public class GameManager : MonoBehaviour
|
||||
{
|
||||
// ── 单例 ──────────────────────────────────────────────────────────
|
||||
public static GameManager Instance { get; private set; }
|
||||
|
||||
// ── Inspector 引用 ────────────────────────────────────────────────
|
||||
[Header("Managers")]
|
||||
[SerializeField] private SettingsManager _settingsManager;
|
||||
[SerializeField] private DeathRespawnService _deathRespawnService;
|
||||
[SerializeField] private SceneService _sceneService;
|
||||
|
||||
[Header("Event Channels - Listen")]
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerDied;
|
||||
@@ -35,54 +30,51 @@ namespace BaseGames.Core
|
||||
[SerializeField] private VoidEventChannelSO _onPlayerRespawned;
|
||||
|
||||
// ── 状态机 ────────────────────────────────────────────────────────
|
||||
private readonly GameStateMachine _fsm = new GameStateMachine();
|
||||
private readonly GameStateMachine _fsm = new GameStateMachine();
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
public GameStateId CurrentState => _fsm.CurrentStateId;
|
||||
|
||||
private static GameManager _instance;
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
DontDestroyOnLoad(transform.root.gameObject);
|
||||
|
||||
RegisterServices();
|
||||
RegisterStates();
|
||||
_settingsManager?.Initialize();
|
||||
|
||||
_fsm.TransitionTo(GameStates.Initializing, out _);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
private void Start()
|
||||
{
|
||||
if (_onPlayerDied) _onPlayerDied.OnEventRaised += HandlePlayerDied;
|
||||
if (_onPauseRequested) _onPauseRequested.OnEventRaised += HandlePauseRequested;
|
||||
if (_onResumeRequested) _onResumeRequested.OnEventRaised += HandleResumeRequested;
|
||||
if (_onBossFightStarted) _onBossFightStarted.OnEventRaised += HandleBossFightStarted;
|
||||
if (_onBossFightEnded) _onBossFightEnded.OnEventRaised += HandleBossFightEnded;
|
||||
if (_onDeathScreenConfirmed) _onDeathScreenConfirmed.OnEventRaised += HandleDeathScreenConfirmed;
|
||||
// 在 Start 广播初始状态,确保其他组件已在 OnEnable 中完成订阅。
|
||||
_onGameStateChanged?.Raise(new Events.GameStateId(GameStates.Initializing.Id));
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onPlayerDied) _onPlayerDied.OnEventRaised -= HandlePlayerDied;
|
||||
if (_onPauseRequested) _onPauseRequested.OnEventRaised -= HandlePauseRequested;
|
||||
if (_onResumeRequested) _onResumeRequested.OnEventRaised -= HandleResumeRequested;
|
||||
if (_onBossFightStarted) _onBossFightStarted.OnEventRaised -= HandleBossFightStarted;
|
||||
if (_onBossFightEnded) _onBossFightEnded.OnEventRaised -= HandleBossFightEnded;
|
||||
if (_onDeathScreenConfirmed) _onDeathScreenConfirmed.OnEventRaised -= HandleDeathScreenConfirmed;
|
||||
_onPlayerDied? .Subscribe(HandlePlayerDied).AddTo(_subs);
|
||||
_onPauseRequested? .Subscribe(HandlePauseRequested).AddTo(_subs);
|
||||
_onResumeRequested? .Subscribe(HandleResumeRequested).AddTo(_subs);
|
||||
_onBossFightStarted? .Subscribe(HandleBossFightStarted).AddTo(_subs);
|
||||
_onBossFightEnded? .Subscribe(HandleBossFightEnded).AddTo(_subs);
|
||||
_onDeathScreenConfirmed?.Subscribe(HandleDeathScreenConfirmed).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable() => _subs.Clear();
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_instance == this) _instance = null;
|
||||
}
|
||||
|
||||
private void Update() => _fsm.Tick(Time.deltaTime);
|
||||
|
||||
// ── 初始化 ────────────────────────────────────────────────────────
|
||||
private void RegisterServices()
|
||||
{
|
||||
if (_deathRespawnService)
|
||||
ServiceLocator.Register<IDeathRespawnService>(_deathRespawnService);
|
||||
if (_sceneService)
|
||||
ServiceLocator.Register<ISceneService>(_sceneService);
|
||||
}
|
||||
|
||||
private void RegisterStates()
|
||||
{
|
||||
_fsm.Register(new InitializingState());
|
||||
@@ -111,8 +103,13 @@ namespace BaseGames.Core
|
||||
|
||||
// ── 事件处理 ──────────────────────────────────────────────────────
|
||||
private void HandlePlayerDied() => StartCoroutine(DeathFlow());
|
||||
private void HandlePauseRequested() => RequestTransition(GameStates.Paused);
|
||||
private void HandleResumeRequested() => RequestTransition(GameStates.Gameplay);
|
||||
private void HandlePauseRequested()
|
||||
{
|
||||
_prePauseState = _fsm.CurrentStateId;
|
||||
RequestTransition(GameStates.Paused);
|
||||
}
|
||||
|
||||
private void HandleResumeRequested() => RequestTransition(_prePauseState);
|
||||
|
||||
private void HandleBossFightStarted(string bossId)
|
||||
=> RequestTransition(GameStates.BossFight);
|
||||
@@ -123,13 +120,23 @@ namespace BaseGames.Core
|
||||
else RequestTransition(GameStates.GameOver);
|
||||
}
|
||||
|
||||
private bool _deathScreenConfirmed;
|
||||
private bool _deathScreenConfirmed;
|
||||
private GameStateId _prePauseState = GameStates.Gameplay;
|
||||
private void HandleDeathScreenConfirmed() => _deathScreenConfirmed = true;
|
||||
|
||||
private IEnumerator DeathFlow()
|
||||
{
|
||||
RequestTransition(GameStates.Dead);
|
||||
var deathService = ServiceLocator.Get<IDeathRespawnService>();
|
||||
|
||||
// SteelSoul 模式:清档后返回主菜单(架构 19 §6)
|
||||
var scaler = ServiceLocator.GetOrDefault<IDifficultyService>()?.CurrentScaler;
|
||||
if (scaler != null && scaler.InstantDeathOnZeroHP)
|
||||
{
|
||||
yield return deathService.StartGameOverCoroutine();
|
||||
yield break;
|
||||
}
|
||||
|
||||
yield return deathService.StartDeathSequenceCoroutine();
|
||||
|
||||
// 等待玩家在死亡画面点击重试
|
||||
|
||||
@@ -4,7 +4,7 @@ MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
executionOrder: -1000
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using BaseGames.Core.Events;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
@@ -13,10 +15,25 @@ namespace BaseGames.Core
|
||||
[SerializeField] private DeathRespawnService _deathRespawnService;
|
||||
[SerializeField] private SceneService _sceneService;
|
||||
[SerializeField] private EventChannelRegistry _eventChannelRegistry;
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
/// <summary>
|
||||
/// Persistent 场景中唯一保留的主 AudioListener(通常挂在主相机上)。
|
||||
/// 在 Inspector 中绑定后可完全跳过 Awake 时的 FindObjectsOfType 全场景扫描。
|
||||
/// 未绑定时自动执行运行时扫描。
|
||||
/// </summary>
|
||||
[SerializeField] private AudioListener _primaryListener;
|
||||
private bool _audioListenerFixLogged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// 注册 NullAudioService 作为兜底;Phase 2 Audio 模块 Awake 后会用真实实现覆盖
|
||||
// 若 Inspector 已绑定主 AudioListener,直接使用,跳过全场景扫描
|
||||
if (_primaryListener != null)
|
||||
DisableDuplicateListenersInCurrentScenes();
|
||||
else
|
||||
EnsureSingleAudioListener(); // 未绑定时回退:全量扫描并自动缓存
|
||||
SceneManager.sceneLoaded += OnSceneLoaded;
|
||||
|
||||
// 注册 NullAudioService 作为兜底;AudioManager.Awake 后会用真实实现覆盖
|
||||
ServiceLocator.RegisterIfAbsent<IAudioService>(new NullAudioService());
|
||||
|
||||
if (_deathRespawnService)
|
||||
@@ -25,6 +42,150 @@ namespace BaseGames.Core
|
||||
ServiceLocator.Register<ISceneService>(_sceneService);
|
||||
if (_eventChannelRegistry)
|
||||
ServiceLocator.Register<IEventChannelRegistry>(_eventChannelRegistry);
|
||||
if (_saveManager)
|
||||
ServiceLocator.Register<ISaveService>(new SaveServiceAdapter(_saveManager));
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
SceneManager.sceneLoaded -= OnSceneLoaded;
|
||||
}
|
||||
|
||||
private void OnSceneLoaded(Scene scene, LoadSceneMode mode)
|
||||
{
|
||||
if (_primaryListener != null)
|
||||
{
|
||||
// 仅扫描新加载场景的根节点,避免 FindObjectsOfType 全场景遍历
|
||||
int disabled = 0;
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
foreach (var al in root.GetComponentsInChildren<AudioListener>(true))
|
||||
{
|
||||
if (al == _primaryListener || !al.enabled) continue;
|
||||
al.enabled = false;
|
||||
disabled++;
|
||||
}
|
||||
if (disabled > 0)
|
||||
Debug.LogWarning($"[GameServiceRegistrar] Scene '{scene.name}' 中禁用了 {disabled} 个多余的 AudioListener。");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 首次加载时 _primaryListener 可能尚未缓存,回退到全量扫描
|
||||
EnsureSingleAudioListener();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 当 Inspector 已预绑定 _primaryListener 时,无需全场景扫描。
|
||||
/// 仅遍历当前已加载场景的根节点,禁用多余的 AudioListener。
|
||||
/// </summary>
|
||||
private void DisableDuplicateListenersInCurrentScenes()
|
||||
{
|
||||
int sceneCount = SceneManager.sceneCount;
|
||||
int disabled = 0;
|
||||
for (int s = 0; s < sceneCount; s++)
|
||||
{
|
||||
Scene scene = SceneManager.GetSceneAt(s);
|
||||
if (!scene.isLoaded) continue;
|
||||
foreach (var root in scene.GetRootGameObjects())
|
||||
foreach (var al in root.GetComponentsInChildren<AudioListener>(true))
|
||||
{
|
||||
if (al == _primaryListener || !al.enabled) continue;
|
||||
al.enabled = false;
|
||||
disabled++;
|
||||
}
|
||||
}
|
||||
if (disabled > 0 && !_audioListenerFixLogged)
|
||||
{
|
||||
Debug.LogWarning($"[GameServiceRegistrar] Disabled {disabled} duplicate AudioListener(s). Primary: '{_primaryListener.gameObject.name}'.");
|
||||
_audioListenerFixLogged = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureSingleAudioListener()
|
||||
{
|
||||
AudioListener[] listeners = FindObjectsOfType<AudioListener>(true); AudioListener primary = null;
|
||||
int enabledCount = 0;
|
||||
|
||||
for (int i = 0; i < listeners.Length; i++)
|
||||
{
|
||||
AudioListener listener = listeners[i];
|
||||
if (listener == null || !listener.enabled || !listener.gameObject.activeInHierarchy)
|
||||
continue;
|
||||
|
||||
enabledCount++;
|
||||
if (primary == null)
|
||||
primary = listener;
|
||||
|
||||
if (listener.gameObject.scene.name == "Persistent")
|
||||
primary = listener;
|
||||
}
|
||||
|
||||
if (enabledCount <= 1 || primary == null)
|
||||
{
|
||||
_primaryListener = primary; // 缓存(含 enabledCount==1 时的单个监听器)
|
||||
return;
|
||||
}
|
||||
|
||||
int disabled = 0;
|
||||
for (int i = 0; i < listeners.Length; i++)
|
||||
{
|
||||
AudioListener listener = listeners[i];
|
||||
if (listener == null || listener == primary)
|
||||
continue;
|
||||
if (!listener.enabled || !listener.gameObject.activeInHierarchy)
|
||||
continue;
|
||||
|
||||
listener.enabled = false;
|
||||
disabled++;
|
||||
}
|
||||
|
||||
_primaryListener = primary; // 缓存主监听器
|
||||
|
||||
if (disabled > 0 && !_audioListenerFixLogged)
|
||||
{
|
||||
Debug.LogWarning($"[GameServiceRegistrar] Found {enabledCount} active AudioListeners. Kept '{primary.gameObject.name}' and disabled {disabled} duplicate listener(s).");
|
||||
_audioListenerFixLogged = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 SaveManager 适配为 ISaveService。
|
||||
/// 由于 BaseGames.Core.Save 与 BaseGames.Core 存在循环依赖风险,
|
||||
/// SaveManager 不直接声明实现 ISaveService;由此 Adapter 桥接,
|
||||
/// 保持 Core → Core.Save 的单向依赖方向不变。
|
||||
/// </summary>
|
||||
internal sealed class SaveServiceAdapter : ISaveService
|
||||
{
|
||||
private readonly BaseGames.Core.Save.SaveManager _save;
|
||||
|
||||
internal SaveServiceAdapter(BaseGames.Core.Save.SaveManager save) => _save = save;
|
||||
|
||||
// ── I/O 操作 ──────────────────────────────────────────────────────
|
||||
public System.Threading.Tasks.Task SaveAsync(int slot) => _save.SaveAsync(slot);
|
||||
public System.Threading.Tasks.Task<bool> LoadAsync(int slot) => _save.LoadAsync(slot);
|
||||
public void QuickSave() => _save.QuickSave();
|
||||
public void QuickLoad() => _save.QuickLoad();
|
||||
public System.Threading.Tasks.Task QuickLoadAsync() => _save.LoadAsync(BaseGames.Core.Save.SaveManager.QuickSaveSlot);
|
||||
public bool HasSave(int slot) => _save.SlotExists(slot);
|
||||
public int ActiveSlot => _save.CurrentSlot;
|
||||
public System.Threading.Tasks.Task DeleteSlotAsync(int slot) => _save.DeleteSlotAsync(slot);
|
||||
|
||||
// ── 存档点 ────────────────────────────────────────────────────────
|
||||
public string LastCheckpointScene => _save.LastCheckpointScene;
|
||||
public string LastCheckpointSpawnId => _save.LastCheckpointSpawnId;
|
||||
|
||||
// ── 世界状态查询 ──────────────────────────────────────────────────
|
||||
public bool IsWorldCollected(string id) => _save.IsWorldCollected(id);
|
||||
public bool IsDoorOpened(string id) => _save.IsDoorOpened(id);
|
||||
public bool IsBossDefeated(string bossId) => _save.IsBossDefeated(bossId);
|
||||
public int GetPlayerMaxHP() => _save.GetPlayerMaxHP();
|
||||
public bool IsFirstClear(string challengeId) => _save.IsFirstClear(challengeId);
|
||||
|
||||
// ── 世界标志 & 事件链 ─────────────────────────────────────────────
|
||||
public bool GetFlag(string flagId) => _save.GetFlag(flagId);
|
||||
public void SetFlag(string flagId, bool value) => _save.SetFlag(flagId, value);
|
||||
public System.Collections.Generic.IEnumerable<string> GetCompletedChains() => _save.GetCompletedChains();
|
||||
public void SetChainCompleted(string chainId) => _save.SetChainCompleted(chainId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
executionOrder: -2000
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
|
||||
@@ -41,6 +41,9 @@ namespace BaseGames.Core
|
||||
[Header("Language")]
|
||||
public string DefaultLanguage = "zh-CN";
|
||||
|
||||
[Header("Speedrun")]
|
||||
public bool ShowSpeedrunTimer = false;
|
||||
|
||||
/// <summary>将 SO 默认值填入 GlobalSettingsData。</summary>
|
||||
public GlobalSettingsData CreateDefault() => new GlobalSettingsData
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
@@ -15,6 +17,9 @@ namespace BaseGames.Core
|
||||
/// <summary>单次播放音效。</summary>
|
||||
void PlaySFX(string key);
|
||||
|
||||
/// <summary>在世界坐标播放音效片段(用于 3D 音效)。</summary>
|
||||
void PlaySFXAtPosition(AudioClip clip, Vector2 position, float volumeScale = 1f);
|
||||
|
||||
/// <summary>设置混音器音量(0–1)。group 取 AudioMixerKeys 常量。</summary>
|
||||
void SetVolume(string group, float normalizedVolume);
|
||||
}
|
||||
|
||||
23
Assets/Scripts/Core/IObjectPoolService.cs
Normal file
23
Assets/Scripts/Core/IObjectPoolService.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 全局对象池服务接口。
|
||||
/// 通过 ServiceLocator 访问,解耦调用方与 GlobalObjectPool 具体实现。
|
||||
/// </summary>
|
||||
public interface IObjectPoolService
|
||||
{
|
||||
/// <summary>从池中取出指定 Component 类型的对象,并激活到指定位置/朝向。</summary>
|
||||
T Spawn<T>(string key, Vector3 position, Quaternion rotation) where T : Component;
|
||||
|
||||
/// <summary>从池中取出 GameObject,并激活到指定位置/朝向。</summary>
|
||||
GameObject Spawn(string key, Vector3 position, Quaternion rotation);
|
||||
|
||||
/// <summary>将对象归还到池中(停用并入队)。</summary>
|
||||
void Despawn(string key, Pool.PooledObject po);
|
||||
|
||||
/// <summary>清空指定 key 的对象池(场景卸载时调用)。</summary>
|
||||
void ClearPool(string key);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/IObjectPoolService.cs.meta
Normal file
11
Assets/Scripts/Core/IObjectPoolService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: de3e4fbb738acc34a8b1e4c18eae9e3a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,23 +1,29 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档服务接口。对外暴露存档系统的高层操作,供其他模块通过 ServiceLocator 访问。
|
||||
/// 实现由 BaseGames.Core.Save 程序集的 SaveManager 提供。
|
||||
/// 存档服务接口。对外暴露存档系统的全部公开 API,供其他模块通过 ServiceLocator 访问。
|
||||
/// 实现由 BaseGames.Core.Save 程序集的 SaveManager 经 SaveServiceAdapter 桥接提供。
|
||||
/// </summary>
|
||||
public interface ISaveService
|
||||
{
|
||||
// ── I/O 操作 ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>将当前游戏状态写入指定存档槽。</summary>
|
||||
Task SaveAsync(int slot);
|
||||
|
||||
/// <summary>从指定存档槽加载游戏状态。成功返回 true,存档损坏/不存在返回 false。</summary>
|
||||
Task<bool> LoadAsync(int slot);
|
||||
|
||||
/// <summary>快速存档(覆盖当前活跃槽)。</summary>
|
||||
/// <summary>快速存档(覆盖快速存档槽,fire-and-forget)。</summary>
|
||||
void QuickSave();
|
||||
|
||||
/// <summary>快速读档(从当前活跃槽加载)。</summary>
|
||||
/// <summary>快速读档(从快速存档槽加载,fire-and-forget)。</summary>
|
||||
void QuickLoad();
|
||||
|
||||
/// <summary>快速读档(awaitable 版本)。</summary>
|
||||
Task QuickLoadAsync();
|
||||
|
||||
/// <summary>指定槽是否存在有效存档。</summary>
|
||||
@@ -25,5 +31,47 @@ namespace BaseGames.Core
|
||||
|
||||
/// <summary>当前活跃存档槽(0–2)。</summary>
|
||||
int ActiveSlot { get; }
|
||||
|
||||
/// <summary>删除指定存档槽数据。</summary>
|
||||
Task DeleteSlotAsync(int slot);
|
||||
|
||||
// ── 存档点 ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>上次存档时的场景名(用于死亡复活跳转)。</summary>
|
||||
string LastCheckpointScene { get; }
|
||||
|
||||
/// <summary>上次存档时的出生点 ID。</summary>
|
||||
string LastCheckpointSpawnId { get; }
|
||||
|
||||
// ── 世界状态查询 ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>指定收藏物 ID 是否已拾取。</summary>
|
||||
bool IsWorldCollected(string id);
|
||||
|
||||
/// <summary>指定门 / 进程锁 ID 是否已开启。</summary>
|
||||
bool IsDoorOpened(string id);
|
||||
|
||||
/// <summary>指定 Boss ID 是否已被击败。</summary>
|
||||
bool IsBossDefeated(string bossId);
|
||||
|
||||
/// <summary>当前存档中玩家最大 HP(存档未加载时返回 0)。</summary>
|
||||
int GetPlayerMaxHP();
|
||||
|
||||
/// <summary>是否为指定挑战房间的首次通关(首次调用返回 true 并标记)。</summary>
|
||||
bool IsFirstClear(string challengeId);
|
||||
|
||||
// ── 世界标志 & 事件链 ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>读取世界标志位。</summary>
|
||||
bool GetFlag(string flagId);
|
||||
|
||||
/// <summary>写入世界标志位。</summary>
|
||||
void SetFlag(string flagId, bool value);
|
||||
|
||||
/// <summary>获取所有已完成的事件链 ID。</summary>
|
||||
IEnumerable<string> GetCompletedChains();
|
||||
|
||||
/// <summary>标记某条事件链已完成。</summary>
|
||||
void SetChainCompleted(string chainId);
|
||||
}
|
||||
}
|
||||
|
||||
22
Assets/Scripts/Core/ISettingsService.cs
Normal file
22
Assets/Scripts/Core/ISettingsService.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
// Assets/Scripts/Core/ISettingsService.cs
|
||||
// 全局设置服务接口,通过 ServiceLocator 注册与查询。
|
||||
// SettingsManager 实现此接口;调用方通过 ISettingsService 解耦。
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
public interface ISettingsService
|
||||
{
|
||||
GlobalSettingsData Current { get; }
|
||||
void SetMasterVolume(float v);
|
||||
void SetBGMVolume(float v);
|
||||
void SetSFXVolume(float v);
|
||||
void SetAmbientVolume(float v);
|
||||
void SetResolution(int w, int h, FullScreenMode mode);
|
||||
void SetVSync(bool enabled);
|
||||
void SetTargetFrameRate(int fps);
|
||||
void SetLanguage(string localeCode);
|
||||
void Save();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/ISettingsService.cs.meta
Normal file
11
Assets/Scripts/Core/ISettingsService.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 691c1a15bfcb61444b5a4a342fa6bd88
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -5,7 +5,7 @@ namespace BaseGames.Core
|
||||
/// <summary>
|
||||
/// IAudioService 的空实现,作为兜底防止 NullReferenceException。
|
||||
/// 在 GameServiceRegistrar 中作为默认音频服务注册;
|
||||
/// Phase 2 Audio 模块实现完整后替换。
|
||||
/// AudioManager 初始化后会覆盖此实现。
|
||||
/// </summary>
|
||||
public sealed class NullAudioService : IAudioService
|
||||
{
|
||||
@@ -18,6 +18,9 @@ namespace BaseGames.Core
|
||||
public void PlaySFX(string key)
|
||||
=> Debug.LogWarning($"[NullAudioService] PlaySFX({key}) — 音频系统未初始化。");
|
||||
|
||||
public void PlaySFXAtPosition(AudioClip clip, Vector2 position, float volumeScale = 1f)
|
||||
=> Debug.LogWarning($"[NullAudioService] PlaySFXAtPosition({clip?.name}) — 音频系统未初始化。");
|
||||
|
||||
public void SetVolume(string group, float normalizedVolume)
|
||||
=> Debug.LogWarning($"[NullAudioService] SetVolume({group}, {normalizedVolume}) — 音频系统未初始化。");
|
||||
}
|
||||
|
||||
@@ -11,10 +11,8 @@ namespace BaseGames.Core.Pool
|
||||
/// 先调用 <see cref="WarmupAsync"/> 预热后,再通过 <see cref="Spawn"/> 取出对象。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-800)]
|
||||
public class GlobalObjectPool : MonoBehaviour
|
||||
public class GlobalObjectPool : MonoBehaviour, IObjectPoolService
|
||||
{
|
||||
public static GlobalObjectPool Instance { get; private set; }
|
||||
|
||||
[System.Serializable]
|
||||
public struct PoolConfig
|
||||
{
|
||||
@@ -26,29 +24,28 @@ namespace BaseGames.Core.Pool
|
||||
|
||||
[SerializeField] private PoolConfig[] _warmupConfigs;
|
||||
|
||||
private readonly Dictionary<string, Queue<PooledObject>> _pools = new();
|
||||
private readonly Dictionary<string, List<PooledObject>> _alive = new();
|
||||
private readonly Dictionary<string, GameObject> _prefabCache = new();
|
||||
private readonly Dictionary<string, int> _maxCounts = new();
|
||||
private readonly Dictionary<string, Queue<PooledObject>> _pools = new();
|
||||
private readonly Dictionary<string, LinkedList<PooledObject>> _alive = new();
|
||||
private readonly Dictionary<string, GameObject> _prefabCache = new();
|
||||
private readonly Dictionary<string, int> _maxCounts = new();
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
if (ServiceLocator.GetOrDefault<IObjectPoolService>() != null) { Destroy(gameObject); return; }
|
||||
ServiceLocator.Register<IObjectPoolService>(this);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<IObjectPoolService>(this);
|
||||
}
|
||||
|
||||
// ── 预热 ──────────────────────────────────────────────────────────
|
||||
/// <summary>在场景加载完成后(StartCoroutine)调用预热。</summary>
|
||||
public IEnumerator WarmupCoroutine()
|
||||
{
|
||||
foreach (var cfg in _warmupConfigs)
|
||||
{
|
||||
_maxCounts[cfg.AddressKey] = cfg.MaxCount;
|
||||
yield return WarmupSingleCoroutine(cfg.AddressKey, cfg.InitialCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>async Task 版本(可 await,供非 MonoBehaviour 调用)。</summary>
|
||||
/// <summary>
|
||||
/// 异步预热所有配置中的对象。
|
||||
/// MonoBehaviour 中可用 <c>StartCoroutine(pool.WarmupAsync().AsIEnumerator())</c> 桥接,
|
||||
/// 或直接 await(UniTask / Awaitable)。
|
||||
/// </summary>
|
||||
public async Task WarmupAsync()
|
||||
{
|
||||
foreach (var cfg in _warmupConfigs)
|
||||
@@ -58,17 +55,6 @@ namespace BaseGames.Core.Pool
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerator WarmupSingleCoroutine(string key, int count)
|
||||
{
|
||||
var loadOp = Addressables.LoadAssetAsync<GameObject>(key);
|
||||
yield return loadOp;
|
||||
var prefab = loadOp.Result;
|
||||
_prefabCache[key] = prefab;
|
||||
EnsureCollections(key, count);
|
||||
for (int i = 0; i < count; i++)
|
||||
EnqueueNew(key, prefab);
|
||||
}
|
||||
|
||||
private async Task WarmupSingleAsync(string key, int count)
|
||||
{
|
||||
var prefab = await Addressables.LoadAssetAsync<GameObject>(key).Task;
|
||||
@@ -81,7 +67,9 @@ namespace BaseGames.Core.Pool
|
||||
private void EnsureCollections(string key, int capacity)
|
||||
{
|
||||
if (!_pools.ContainsKey(key)) _pools[key] = new Queue<PooledObject>(capacity);
|
||||
if (!_alive.ContainsKey(key)) _alive[key] = new List<PooledObject>();
|
||||
// MaxCount==0 表示无上限,无需追踪活跃对象,跳过 _alive 分配
|
||||
if (_maxCounts.GetValueOrDefault(key, 0) > 0 && !_alive.ContainsKey(key))
|
||||
_alive[key] = new LinkedList<PooledObject>();
|
||||
}
|
||||
|
||||
private void EnqueueNew(string key, GameObject prefab)
|
||||
@@ -105,23 +93,25 @@ namespace BaseGames.Core.Pool
|
||||
{
|
||||
if (!_pools.TryGetValue(key, out var queue))
|
||||
{
|
||||
Debug.LogError($"[GlobalObjectPool] '{key}' 未预热,请先调用 WarmupAsync/WarmupCoroutine。");
|
||||
Debug.LogError($"[GlobalObjectPool] '{key}' 未预热,请先调用 WarmupAsync。");
|
||||
return null;
|
||||
}
|
||||
|
||||
PooledObject po;
|
||||
var aliveList = GetAliveList(key);
|
||||
int maxCount = _maxCounts.GetValueOrDefault(key, 0);
|
||||
int maxCount = _maxCounts.GetValueOrDefault(key, 0);
|
||||
// maxCount==0 时不追踪活跃对象,aliveList 保持 null
|
||||
LinkedList<PooledObject> aliveList = maxCount > 0 ? GetAliveList(key) : null;
|
||||
|
||||
if (queue.Count > 0)
|
||||
{
|
||||
po = queue.Dequeue();
|
||||
}
|
||||
else if (maxCount > 0 && aliveList.Count >= maxCount)
|
||||
else if (aliveList != null && aliveList.Count >= maxCount)
|
||||
{
|
||||
// 已达上限:LRU 回收最早 Spawn 的活跃对象
|
||||
po = aliveList[0];
|
||||
aliveList.RemoveAt(0);
|
||||
// 已达上限:LRU 回收最早 Spawn 的活跃对象(LinkedList 头节点即最老)
|
||||
po = aliveList.First.Value;
|
||||
aliveList.RemoveFirst(); // O(1)
|
||||
po.AliveNode = null;
|
||||
po.ForceReturnToPool();
|
||||
Debug.LogWarning($"[GlobalObjectPool] '{key}' 已达 MaxCount={maxCount},LRU 回收中。");
|
||||
}
|
||||
@@ -143,22 +133,30 @@ namespace BaseGames.Core.Pool
|
||||
po.transform.SetPositionAndRotation(pos, rot);
|
||||
po.gameObject.SetActive(true);
|
||||
po.OnSpawn();
|
||||
aliveList.Add(po);
|
||||
// 存储节点引用,供 Despawn 时 O(1) 移除
|
||||
if (aliveList != null)
|
||||
po.AliveNode = aliveList.AddLast(po); // 尾部 = 最新,头部 = 最老(LRU)
|
||||
return po;
|
||||
}
|
||||
|
||||
// ── Despawn ───────────────────────────────────────────────────────
|
||||
public void Despawn(string key, PooledObject po)
|
||||
{
|
||||
var aliveList = GetAliveList(key);
|
||||
aliveList.Remove(po);
|
||||
// 通过存储的节点 O(1) 移除,避免 LinkedList.Remove(value) 的 O(n) 遍历
|
||||
if (po.AliveNode != null)
|
||||
{
|
||||
if (_alive.TryGetValue(key, out var aliveRef))
|
||||
aliveRef.Remove(po.AliveNode);
|
||||
po.AliveNode = null;
|
||||
}
|
||||
po.gameObject.SetActive(false);
|
||||
po.OnDespawn();
|
||||
|
||||
int maxCount = _maxCounts.GetValueOrDefault(key, 0);
|
||||
int queueSize = _pools.TryGetValue(key, out var queue) ? queue.Count : 0;
|
||||
int aliveCount = (maxCount > 0 && _alive.TryGetValue(key, out var al)) ? al.Count : 0;
|
||||
|
||||
if (maxCount > 0 && queueSize + aliveList.Count >= maxCount)
|
||||
if (maxCount > 0 && queueSize + aliveCount >= maxCount)
|
||||
{
|
||||
Destroy(po.gameObject);
|
||||
return;
|
||||
@@ -204,10 +202,10 @@ namespace BaseGames.Core.Pool
|
||||
}
|
||||
}
|
||||
|
||||
private List<PooledObject> GetAliveList(string key)
|
||||
private LinkedList<PooledObject> GetAliveList(string key)
|
||||
{
|
||||
if (!_alive.TryGetValue(key, out var list))
|
||||
_alive[key] = list = new List<PooledObject>();
|
||||
_alive[key] = list = new LinkedList<PooledObject>();
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ namespace BaseGames.Core.Pool
|
||||
public string AddressKey { get; private set; }
|
||||
private GlobalObjectPool _pool;
|
||||
|
||||
/// <summary>
|
||||
/// 对象在 GlobalObjectPool._alive 链表中的节点引用。
|
||||
/// 存储节点可将 Despawn 时的链表移除从 O(n) 降为 O(1)。
|
||||
/// 仅 GlobalObjectPool 内部读写,业务代码不应访问。
|
||||
/// </summary>
|
||||
internal LinkedListNode<PooledObject> AliveNode;
|
||||
|
||||
// 组件缓存(避免反复 GetComponent)
|
||||
private readonly Dictionary<Type, Component> _componentCache = new();
|
||||
|
||||
|
||||
65
Assets/Scripts/Core/Save/CrashReporter.cs
Normal file
65
Assets/Scripts/Core/Save/CrashReporter.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 崩溃检测与诊断日志写入。
|
||||
/// 监听 Unity 异常日志并在 OnApplicationPause 非正常退出时触发紧急存档(槽 99)。
|
||||
/// </summary>
|
||||
public class CrashReporter : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
[SerializeField] private EmergencySaveService _emergencyService;
|
||||
|
||||
private bool _cleanExit;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
Application.logMessageReceived += OnLogMessage;
|
||||
Application.quitting += OnCleanQuit;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Application.logMessageReceived -= OnLogMessage;
|
||||
Application.quitting -= OnCleanQuit;
|
||||
}
|
||||
|
||||
private void OnCleanQuit() => _cleanExit = true;
|
||||
|
||||
private void OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
// 移动端:App 被切出且未完成正常退出流程时触发紧急存档
|
||||
if (pauseStatus && !_cleanExit && _saveManager != null)
|
||||
_ = _saveManager.SaveAsync(EmergencySaveService.EmergencySlot);
|
||||
}
|
||||
|
||||
private void OnLogMessage(string condition, string stackTrace, LogType type)
|
||||
{
|
||||
if (type == LogType.Exception || type == LogType.Error)
|
||||
WriteDiagnosticLog(condition, stackTrace);
|
||||
}
|
||||
|
||||
/// <summary>检查是否存在上次崩溃或意外退出留下的紧急存档。</summary>
|
||||
public bool HasEmergencySave()
|
||||
=> _emergencyService != null && _emergencyService.HasEmergencySave();
|
||||
|
||||
/// <summary>同步写入崩溃诊断日志——崩溃场景下 async 不可靠,改用同步 IO。</summary>
|
||||
private void WriteDiagnosticLog(string condition, string stackTrace)
|
||||
{
|
||||
try
|
||||
{
|
||||
string timestamp = DateTime.UtcNow.ToString("yyyyMMdd_HHmmss");
|
||||
string logPath = Path.Combine(Application.persistentDataPath, $"crash_{timestamp}.log");
|
||||
string content = $"[{DateTime.UtcNow:o}]\n{condition}\n\n{stackTrace}";
|
||||
File.WriteAllText(logPath, content);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 日志写入失败不能再抛异常,否则会造成无限递归
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/CrashReporter.cs.meta
Normal file
11
Assets/Scripts/Core/Save/CrashReporter.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 18aff6d8d08f33f43a5980c8c4f94c2b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -6,7 +6,7 @@ namespace BaseGames.Core.Save
|
||||
{
|
||||
public class EmergencySaveService : MonoBehaviour
|
||||
{
|
||||
private const int EmergencySlot = 99;
|
||||
public const int EmergencySlot = 99;
|
||||
|
||||
[SerializeField] private float _intervalSeconds = 120f;
|
||||
[SerializeField] private SaveManager _saveManager;
|
||||
@@ -14,16 +14,14 @@ namespace BaseGames.Core.Save
|
||||
|
||||
private bool _gameplayActive;
|
||||
private float _timer;
|
||||
private readonly CompositeDisposable _subscriptions = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onGameplayActive != null) _onGameplayActive.OnEventRaised += OnGameplayActiveChanged;
|
||||
_onGameplayActive?.Subscribe(OnGameplayActiveChanged).AddTo(_subscriptions);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onGameplayActive != null) _onGameplayActive.OnEventRaised -= OnGameplayActiveChanged;
|
||||
}
|
||||
private void OnDisable() => _subscriptions.Clear();
|
||||
|
||||
private void OnGameplayActiveChanged(bool value) => _gameplayActive = value;
|
||||
|
||||
@@ -44,8 +42,12 @@ namespace BaseGames.Core.Save
|
||||
public async Task PromoteToSlot(int targetSlot)
|
||||
{
|
||||
if (_saveManager == null) return;
|
||||
// Phase 1 stub:完整实现在 Phase 2 LocalFileStorage API
|
||||
await Task.CompletedTask;
|
||||
var storage = new LocalFileStorage();
|
||||
if (!storage.Exists(EmergencySlot)) return;
|
||||
string json = await storage.ReadAsync(EmergencySlot);
|
||||
if (json == null) return;
|
||||
await storage.WriteAsync(targetSlot, json);
|
||||
await storage.DeleteAsync(EmergencySlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
16
Assets/Scripts/Core/Save/ISaveableRegistry.cs
Normal file
16
Assets/Scripts/Core/Save/ISaveableRegistry.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// ISaveable 注册表接口。
|
||||
/// 将 ISaveable 对象的注册/注销责任与 SaveManager 的具体实现类解耦,
|
||||
/// 使 SaveableMonoBehaviour 等组件无需直接依赖 <see cref="SaveManager"/>。
|
||||
/// </summary>
|
||||
public interface ISaveableRegistry
|
||||
{
|
||||
/// <summary>将 <paramref name="saveable"/> 加入存档系统管理。</summary>
|
||||
void Register(ISaveable saveable);
|
||||
|
||||
/// <summary>将 <paramref name="saveable"/> 从存档系统移除。</summary>
|
||||
void Unregister(ISaveable saveable);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/ISaveableRegistry.cs.meta
Normal file
11
Assets/Scripts/Core/Save/ISaveableRegistry.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b59605354f1e174593553d2d09fb624
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -11,6 +11,9 @@ namespace BaseGames.Core.Save
|
||||
/// </summary>
|
||||
public class LocalFileStorage : ISaveStorage
|
||||
{
|
||||
/// <summary>存档槽位总数。调整此值时需同步更新 UI 存档槽列表。</summary>
|
||||
public const int MaxSlots = 3;
|
||||
|
||||
private readonly string _saveDir;
|
||||
|
||||
public LocalFileStorage()
|
||||
@@ -54,7 +57,7 @@ namespace BaseGames.Core.Save
|
||||
|
||||
public IEnumerable<int> GetExistingSlots()
|
||||
{
|
||||
for (int i = 0; i < 3; i++)
|
||||
for (int i = 0; i < MaxSlots; i++)
|
||||
if (Exists(i)) yield return i;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace BaseGames.Core.Save
|
||||
public ShopsSaveData Shops = new();
|
||||
public StatsSaveData Stats = new();
|
||||
public NGPlusSaveData NGPlus = null; // null = 非 NG+ 模式
|
||||
public TutorialSaveData Tutorial = new();
|
||||
public SettingsSaveData Settings = new();
|
||||
public Dictionary<string, JObject> DLC = new();
|
||||
}
|
||||
|
||||
@@ -40,6 +42,8 @@ namespace BaseGames.Core.Save
|
||||
public int NGPlusCount;
|
||||
public int SaveCount;
|
||||
public string Checksum; // HMAC-SHA256
|
||||
/// <summary>此存档是否以钢铁之魂(一命)模式开始;加载后 DifficultyManager 会锁定到 SteelSoul。</summary>
|
||||
public bool IsSteelSoul;
|
||||
}
|
||||
|
||||
// ─── Player ───────────────────────────────────────────────────────────────
|
||||
@@ -92,6 +96,7 @@ namespace BaseGames.Core.Save
|
||||
public List<string> OpenedDoors = new();
|
||||
public List<string> DefeatedBossIds = new();
|
||||
public List<string> CollectedIds = new();
|
||||
public List<string> DestroyedObjectIds = new();
|
||||
public Dictionary<string, bool> Switches = new();
|
||||
public Dictionary<string, int> NpcRelations = new();
|
||||
public HashSet<string> ChallengeFirstClears = new();
|
||||
@@ -101,8 +106,29 @@ namespace BaseGames.Core.Save
|
||||
[Serializable]
|
||||
public class MapSaveData
|
||||
{
|
||||
public Dictionary<string, List<int>> DiscoveredRooms = new();
|
||||
public Dictionary<string, bool> MapPurchased = new();
|
||||
public List<string> ExploredRooms = new(); // 踏入过的房间 ID
|
||||
public List<string> MappedRooms = new(); // 完整地图信息(购买/存档点揭示)
|
||||
public List<MapPin> Pins = new(); // 玩家自定义地图标记
|
||||
}
|
||||
|
||||
/// <summary>玩家在地图上放置的自定义标记(架构 15_MapShopModule §1.5)。</summary>
|
||||
[Serializable]
|
||||
public class MapPin
|
||||
{
|
||||
public string RoomId;
|
||||
public float NormalizedPosX;
|
||||
public float NormalizedPosY;
|
||||
public int PinTypeInt; // PinType 枚举整数值,避免 SaveData 依赖 Map 程序集
|
||||
public string Note; // 玩家备注(最多 64 字符)
|
||||
}
|
||||
|
||||
public enum PinType
|
||||
{
|
||||
Marker = 0,
|
||||
Chest = 1,
|
||||
Enemy = 2,
|
||||
Path = 3,
|
||||
Note = 4,
|
||||
}
|
||||
|
||||
// ─── Quests ───────────────────────────────────────────────────────────────
|
||||
@@ -144,6 +170,7 @@ namespace BaseGames.Core.Save
|
||||
public int EnemyKills, Deaths, ParrySuccess, ParryFail;
|
||||
public int GeoEarned, GeoLost;
|
||||
public float DistanceTraveled;
|
||||
public float SpeedrunTime;
|
||||
public int SaveCount;
|
||||
public Dictionary<string, int> SkillUseCounts = new();
|
||||
public Dictionary<string, int> DeathsByBoss = new();
|
||||
@@ -215,4 +242,20 @@ namespace BaseGames.Core.Save
|
||||
public string SceneName;
|
||||
public string ActiveFormId;
|
||||
}
|
||||
|
||||
// ─── Tutorial ─────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class TutorialSaveData
|
||||
{
|
||||
/// <summary>已完成(不再弹出)的提示 ID 列表。</summary>
|
||||
public List<string> CompletedHintIds = new();
|
||||
}
|
||||
|
||||
// ─── Settings ─────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class SettingsSaveData
|
||||
{
|
||||
/// <summary>玩家选择的语言。空字符串 = 使用系统默认。</summary>
|
||||
public string Language = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
@@ -8,33 +9,35 @@ using UnityEngine;
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档管理器(Phase 0 骨架)。
|
||||
/// 存档管理器。
|
||||
/// 完整序列化/反序列化由 Newtonsoft.Json 驱动。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class SaveManager : MonoBehaviour
|
||||
public class SaveManager : MonoBehaviour, ISaveableRegistry
|
||||
{
|
||||
public static SaveManager Instance { get; private set; }
|
||||
|
||||
public const string MinCompatibleVersion = "1.0";
|
||||
private const int QuickSaveSlot = 98;
|
||||
public const int QuickSaveSlot = 98;
|
||||
|
||||
[Header("Event Channels - Raise")]
|
||||
[SerializeField] private BaseGames.Core.Events.BoolEventChannelSO _onSaveIndicatorVisible;
|
||||
|
||||
private ISaveStorage _storage;
|
||||
private readonly List<ISaveable> _saveables = new();
|
||||
private readonly HashSet<ISaveable> _saveables = new();
|
||||
private readonly SemaphoreSlim _saveLock = new SemaphoreSlim(1, 1);
|
||||
|
||||
private SaveData _current;
|
||||
private int _currentSlot = 0;
|
||||
public int CurrentSlot => _currentSlot;
|
||||
|
||||
public static string LastCheckpointScene { get; private set; }
|
||||
public static string LastCheckpointSpawnId { get; private set; }
|
||||
public string LastCheckpointScene { get; private set; }
|
||||
public string LastCheckpointSpawnId { get; private set; }
|
||||
|
||||
private static SaveManager _instance;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null) { Destroy(gameObject); return; }
|
||||
Instance = this;
|
||||
if (_instance != null) { Destroy(gameObject); return; }
|
||||
_instance = this;
|
||||
ServiceLocator.Register<ISaveableRegistry>(this);
|
||||
|
||||
_storage = new LocalFileStorage();
|
||||
}
|
||||
@@ -46,69 +49,113 @@ namespace BaseGames.Core.Save
|
||||
// ── 存档 ──────────────────────────────────────────────────────────────
|
||||
public async Task SaveAsync(int slot = -1)
|
||||
{
|
||||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||||
_current ??= new SaveData();
|
||||
await _saveLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
int targetSlot = slot < 0 ? _currentSlot : slot;
|
||||
_current ??= new SaveData();
|
||||
|
||||
_onSaveIndicatorVisible?.Raise(true);
|
||||
foreach (var s in _saveables) s.OnSave(_current);
|
||||
_onSaveIndicatorVisible?.Raise(true);
|
||||
var snapshot = _saveables.ToList();
|
||||
foreach (var s in snapshot) s.OnSave(_current);
|
||||
|
||||
_current.Meta.LastSaved = DateTime.UtcNow.ToString("o");
|
||||
_current.Meta.SlotIndex = targetSlot;
|
||||
_current.Meta.SaveCount++;
|
||||
_current.Meta.LastSaved = DateTime.UtcNow.ToString("o");
|
||||
_current.Meta.SlotIndex = targetSlot;
|
||||
_current.Meta.SaveCount++;
|
||||
|
||||
// 先清空 checksum,序列化并计算,再序列化含 checksum 的最终版本
|
||||
_current.Meta.Checksum = null;
|
||||
string jsonForChecksum = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||||
_current.Meta.Checksum = ComputeChecksum(jsonForChecksum);
|
||||
string finalJson = JsonConvert.SerializeObject(_current, Formatting.Indented);
|
||||
// 先清空 checksum,序列化并计算,再序列化含 checksum 的最终版本
|
||||
// 使用 Formatting.None 减少序列化字符串体积和 GC 分配
|
||||
_current.Meta.Checksum = null;
|
||||
string jsonForChecksum = JsonConvert.SerializeObject(_current, Formatting.None);
|
||||
_current.Meta.Checksum = ComputeChecksum(jsonForChecksum);
|
||||
string finalJson = JsonConvert.SerializeObject(_current, Formatting.None);
|
||||
|
||||
await _storage.WriteAsync(targetSlot, finalJson);
|
||||
await _storage.WriteAsync(targetSlot, finalJson);
|
||||
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
|
||||
_onSaveIndicatorVisible?.Raise(false);
|
||||
_onSaveIndicatorVisible?.Raise(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_saveLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 读档 ──────────────────────────────────────────────────────────────
|
||||
public async Task<bool> LoadAsync(int slot)
|
||||
{
|
||||
if (!_storage.Exists(slot)) return false;
|
||||
|
||||
string json = await _storage.ReadAsync(slot);
|
||||
if (json == null) return false;
|
||||
|
||||
SaveData loaded;
|
||||
try { loaded = JsonConvert.DeserializeObject<SaveData>(json); }
|
||||
catch (Exception e)
|
||||
await _saveLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
Debug.LogError($"[SaveManager] 存档解析失败 slot={slot}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
if (!_storage.Exists(slot)) return false;
|
||||
|
||||
if (!ValidateChecksum(loaded, json))
|
||||
string json = await _storage.ReadAsync(slot);
|
||||
if (json == null) return false;
|
||||
|
||||
SaveData loaded;
|
||||
try { loaded = JsonConvert.DeserializeObject<SaveData>(json); }
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[SaveManager] 存档解析失败 slot={slot}: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ValidateChecksum(loaded, json))
|
||||
{
|
||||
Debug.LogWarning("[SaveManager] 存档 checksum 校验失败。");
|
||||
// 非 SteelSoul 模式仍允许加载(只是警告)
|
||||
}
|
||||
|
||||
loaded = SaveMigrator.Migrate(loaded);
|
||||
_current = loaded;
|
||||
_currentSlot = slot;
|
||||
|
||||
var snapshot = _saveables.ToList();
|
||||
foreach (var s in snapshot) s.OnLoad(_current);
|
||||
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Debug.LogWarning("[SaveManager] 存档 checksum 校验失败。");
|
||||
// 非 SteelSoul 模式仍允许加载(只是警告)
|
||||
_saveLock.Release();
|
||||
}
|
||||
|
||||
loaded = SaveMigrator.Migrate(loaded);
|
||||
_current = loaded;
|
||||
_currentSlot = slot;
|
||||
|
||||
foreach (var s in _saveables) s.OnLoad(_current);
|
||||
|
||||
LastCheckpointScene = _current.Player?.Scene;
|
||||
LastCheckpointSpawnId = _current.Meta?.SavePointId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SlotExists(int slot) => _storage.Exists(slot);
|
||||
public IEnumerable<int> GetExistingSlots() => _storage.GetExistingSlots();
|
||||
|
||||
// ── 具名数据访问器(替代直接访问 Data 属性) ──────────────────────────────────
|
||||
/// <summary>判断指定收藏物 ID 是否已拾取。</summary>
|
||||
public bool IsWorldCollected(string id)
|
||||
=> _current?.World.CollectedIds.Contains(id) == true;
|
||||
|
||||
/// <summary>判断指定门 / 进程锁 ID 是否已开启。</summary>
|
||||
public bool IsDoorOpened(string id)
|
||||
=> _current?.World.OpenedDoors.Contains(id) == true;
|
||||
|
||||
/// <summary>返回当前存档中玩家最大 HP(存档未加载时返回 0)。</summary>
|
||||
public int GetPlayerMaxHP() => _current?.Player.MaxHP ?? 0;
|
||||
|
||||
// ── 快速存档(挑战房间用)────────────────────────────────────────────
|
||||
public void QuickSave() => _ = SaveAsync(QuickSaveSlot);
|
||||
public void QuickLoad() => _ = LoadAsync(QuickSaveSlot);
|
||||
public void QuickSave() => RunFireAndForget(SaveAsync(QuickSaveSlot), "QuickSave");
|
||||
public void QuickLoad() => RunFireAndForget(LoadAsync(QuickSaveSlot), "QuickLoad");
|
||||
|
||||
/// <summary>
|
||||
/// 将 async Task 包裹为 async void,捕获所有异常并输出到 Log。
|
||||
/// 用于无法 await 的调用点(按钮回调、MonoBehaviour 方法等)。
|
||||
/// </summary>
|
||||
private static async void RunFireAndForget(Task task, string context)
|
||||
{
|
||||
try { await task; }
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError($"[SaveManager] {context} 失败: {e.Message}\n{e.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── 世界标志 / EventChain ─────────────────────────────────────────────
|
||||
public bool GetFlag(string flagId)
|
||||
@@ -159,6 +206,12 @@ namespace BaseGames.Core.Save
|
||||
_current.Meta.SlotIndex = slotIndex;
|
||||
}
|
||||
|
||||
public async Task DeleteSlotAsync(int slotIndex)
|
||||
{
|
||||
await _storage.DeleteAsync(slotIndex);
|
||||
if (_currentSlot == slotIndex) _current = null;
|
||||
}
|
||||
|
||||
// ── 私有工具 ──────────────────────────────────────────────────────────
|
||||
private string ComputeChecksum(string json)
|
||||
{
|
||||
@@ -170,9 +223,10 @@ namespace BaseGames.Core.Save
|
||||
|
||||
private string GetHmacKey()
|
||||
{
|
||||
string deviceId = SystemInfo.deviceUniqueIdentifier;
|
||||
const string salt = "ZelingV2SaveIntegrity_v1";
|
||||
return $"{deviceId}:{salt}";
|
||||
// ⚠️ 不使用 deviceUniqueIdentifier——设备唯一标识在玩家换设备时会改变,
|
||||
// 导致所有存档 checksum 永久失效。改用游戏固定密钥。
|
||||
const string gameSecret = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
|
||||
return gameSecret;
|
||||
}
|
||||
|
||||
private bool ValidateChecksum(SaveData data, string rawJson)
|
||||
@@ -180,9 +234,15 @@ namespace BaseGames.Core.Save
|
||||
if (string.IsNullOrEmpty(data?.Meta?.Checksum)) return true;
|
||||
var saved = data.Meta.Checksum;
|
||||
data.Meta.Checksum = null;
|
||||
string jsonNoChecksum = JsonConvert.SerializeObject(data, Formatting.Indented);
|
||||
string jsonNoChecksum = JsonConvert.SerializeObject(data, Formatting.None);
|
||||
data.Meta.Checksum = saved;
|
||||
return ComputeChecksum(jsonNoChecksum) == saved;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_instance == this) _instance = null;
|
||||
ServiceLocator.Unregister<ISaveableRegistry>(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,60 +1,68 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 处理存档版本升级(旧版 → 新版数据结构)。
|
||||
/// 处理存档版本迁移。
|
||||
/// 迁移链:旧版本 → "2.0" → "2.1"(CurrentVersion),每个分支按顺序落下执行(fall-through)。
|
||||
/// </summary>
|
||||
public static class SaveMigrator
|
||||
{
|
||||
private const string CurrentVersion = "2.1";
|
||||
public const string CurrentVersion = "2.1";
|
||||
|
||||
public static SaveData Migrate(SaveData data)
|
||||
{
|
||||
if (data?.Meta == null) return data;
|
||||
switch (data.Meta.Version)
|
||||
|
||||
string v = data.Meta.Version ?? "";
|
||||
|
||||
// ── 旧版本(< 2.0)→ 2.0 ───────────────────────────────────────────
|
||||
if (string.IsNullOrEmpty(v) || IsOlderThan(v, "2.0"))
|
||||
{
|
||||
case "1.0": data = MigrateFrom1_0(data); goto case "1.1";
|
||||
case "1.1": data = MigrateFrom1_1(data); goto case "2.0";
|
||||
case "2.0": data = MigrateFrom2_0(data); goto case "2.1";
|
||||
case "2.1": break;
|
||||
default:
|
||||
Debug.LogWarning($"[SaveMigrator] 未知存档版本 '{data.Meta.Version}',跳过迁移。");
|
||||
break;
|
||||
// 2.0 引入的新存档节:若为 null 则补充空白对象
|
||||
data.Tutorial ??= new TutorialSaveData();
|
||||
data.Settings ??= new SettingsSaveData();
|
||||
data.EventChains ??= new EventChainsSaveData();
|
||||
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";
|
||||
}
|
||||
|
||||
// ── 2.0 → 2.1 ───────────────────────────────────────────────────────
|
||||
if (v == "2.0")
|
||||
{
|
||||
// 2.1 在 SaveMeta 中增加 IsSteelSoul;JSON 反序列化已默认 false,无需额外处理。
|
||||
// Stats 新增 SpeedrunTime;默认 0f,无需额外处理。
|
||||
// Map.Pins 列表:旧版可能为 null
|
||||
if (data.Map != null)
|
||||
data.Map.Pins ??= new System.Collections.Generic.List<MapPin>();
|
||||
|
||||
Debug.Log("[SaveMigrator] 从 '2.0' 迁移至 '2.1'。");
|
||||
v = "2.1";
|
||||
}
|
||||
|
||||
// ── 未识别的未来版本 ─────────────────────────────────────────────────
|
||||
if (v != CurrentVersion)
|
||||
Debug.LogWarning($"[SaveMigrator] 未知版本 '{v}',将直接使用(可能存在兼容性问题)。");
|
||||
|
||||
data.Meta.Version = CurrentVersion;
|
||||
return data;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom1_0(SaveData d)
|
||||
{
|
||||
d.Equipment ??= new EquipmentSaveData();
|
||||
d.Player ??= new PlayerSaveData();
|
||||
d.World ??= new WorldSaveData();
|
||||
d.Equipment.UpgradedCharmIds ??= new List<string>();
|
||||
return d;
|
||||
}
|
||||
// ── 工具 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private static SaveData MigrateFrom1_1(SaveData d)
|
||||
/// <summary>简单语义版本比较:返回 <paramref name="v"/> 是否严格小于 <paramref name="target"/>。</summary>
|
||||
private static bool IsOlderThan(string v, string target)
|
||||
{
|
||||
d.Stats ??= new StatsSaveData();
|
||||
d.Stats.SkillUseCounts ??= new Dictionary<string, int>();
|
||||
if (d.Player.ShieldHP == 0 && !d.Player.ShieldIsBroken)
|
||||
d.Player.ShieldHP = -1;
|
||||
return d;
|
||||
}
|
||||
|
||||
private static SaveData MigrateFrom2_0(SaveData d)
|
||||
{
|
||||
// 2.0 → 2.1:Player.AbilityFlags (uint bitmask) 替换旧版 Dictionary<string,bool> Abilities
|
||||
// 旧版若 AbilityFlags 为 0 且 ExtensionData 含 "Abilities" 字段,则转换
|
||||
if (d.Player.AbilityFlags == 0 && d.ExtensionData.ContainsKey("Abilities"))
|
||||
{
|
||||
// 此处仅清除旧字段,具体位掩码由 AbilitySystem 在 OnLoad 时根据 ExtrensionData 转换
|
||||
d.ExtensionData.Remove("Abilities");
|
||||
}
|
||||
return d;
|
||||
return System.Version.TryParse(v, out var sv) &&
|
||||
System.Version.TryParse(target, out var tv) &&
|
||||
sv < tv;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs
Normal file
25
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using UnityEngine;
|
||||
using BaseGames.Core;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 带自动注册/注销的 ISaveable MonoBehaviour 基类。
|
||||
/// 继承此类可消除每个存档对象手动调用 ServiceLocator.GetOrDefault<SaveManager>()?.Register/Unregister 的样板代码。
|
||||
///
|
||||
/// 生命周期:
|
||||
/// OnEnable → ServiceLocator.GetOrDefault<SaveManager>()?.Register(this)
|
||||
/// OnDisable → ServiceLocator.GetOrDefault<SaveManager>()?.Unregister(this)
|
||||
///
|
||||
/// 子类只需实现 <see cref="OnSave"/> 和 <see cref="OnLoad"/>。
|
||||
/// 若子类需要自定义 OnEnable/OnDisable,请先调用 base.OnEnable() / base.OnDisable()。
|
||||
/// </summary>
|
||||
public abstract class SaveableMonoBehaviour : MonoBehaviour, ISaveable
|
||||
{
|
||||
protected virtual void OnEnable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
|
||||
protected virtual void OnDisable() => ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
|
||||
|
||||
public abstract void OnSave(SaveData saveData);
|
||||
public abstract void OnLoad(SaveData saveData);
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs.meta
Normal file
11
Assets/Scripts/Core/Save/SaveableMonoBehaviour.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d8716b07a7bca5c43bb372f78884cc13
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -9,9 +9,9 @@ using BaseGames.Core.Events;
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Addressables 场景加载器(Phase 0 骨架)。
|
||||
/// Addressables 场景加载器。
|
||||
/// 监听 EVT_SceneLoadRequest,Additive 加载指定场景,完成后发布 EVT_SceneLoaded。
|
||||
/// Phase 1 完整实现由 SceneService 包装调用。
|
||||
/// 完整实现由 SceneService 包装调用。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-950)]
|
||||
public class SceneLoader : MonoBehaviour
|
||||
@@ -24,17 +24,16 @@ namespace BaseGames.Core
|
||||
|
||||
private string _currentRoomScene;
|
||||
private AsyncOperationHandle<SceneInstance> _currentHandle;
|
||||
private readonly CompositeDisposable _subs = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised += HandleRequest;
|
||||
_onSceneLoadRequest?.Subscribe(HandleRequest).AddTo(_subs);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised -= HandleRequest;
|
||||
_subs.Clear();
|
||||
}
|
||||
|
||||
private void HandleRequest(SceneLoadRequest request)
|
||||
@@ -42,27 +41,27 @@ namespace BaseGames.Core
|
||||
|
||||
private IEnumerator LoadSceneCoroutine(SceneLoadRequest request)
|
||||
{
|
||||
// 卸载旧场景
|
||||
// 先加载新场景(Additive),成功后再卸载旧场景
|
||||
// 顺序保证:若加载失败,旧场景仍保持可用,不会出现无场景的空状态
|
||||
var loadOp = Addressables.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
|
||||
yield return loadOp;
|
||||
|
||||
if (loadOp.Status != AsyncOperationStatus.Succeeded)
|
||||
{
|
||||
Debug.LogError($"[SceneLoader] 加载场景失败:{request.SceneName}(旧场景保持不变)");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 新场景加载成功,再卸载旧场景
|
||||
if (!string.IsNullOrEmpty(_currentRoomScene) && _currentHandle.IsValid())
|
||||
{
|
||||
var unloadOp = Addressables.UnloadSceneAsync(_currentHandle);
|
||||
yield return unloadOp;
|
||||
}
|
||||
|
||||
// 加载新场景(Additive)
|
||||
var loadOp = Addressables.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
|
||||
yield return loadOp;
|
||||
|
||||
if (loadOp.Status == AsyncOperationStatus.Succeeded)
|
||||
{
|
||||
_currentHandle = loadOp;
|
||||
_currentRoomScene = request.SceneName;
|
||||
_onSceneLoaded?.Raise(request.SceneName);
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError($"[SceneLoader] 加载场景失败:{request.SceneName}");
|
||||
}
|
||||
_currentHandle = loadOp;
|
||||
_currentRoomScene = request.SceneName;
|
||||
_onSceneLoaded?.Raise(request.SceneName);
|
||||
}
|
||||
|
||||
/// <summary>手动卸载当前房间场景(供 SceneService 调用)。</summary>
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace BaseGames.Core
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 场景管理服务(Phase 0 骨架,Phase 1 完整实现)。
|
||||
/// 场景管理服务。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-900)]
|
||||
public class SceneService : MonoBehaviour, ISceneService
|
||||
@@ -35,18 +35,14 @@ namespace BaseGames.Core
|
||||
[SerializeField] private float _fadeDuration = 0.3f;
|
||||
|
||||
private string _currentRoomScene;
|
||||
private readonly CompositeDisposable _subscriptions = new();
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised += HandleSceneLoadRequest;
|
||||
_onSceneLoadRequest?.Subscribe(HandleSceneLoadRequest).AddTo(_subscriptions);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_onSceneLoadRequest != null)
|
||||
_onSceneLoadRequest.OnEventRaised -= HandleSceneLoadRequest;
|
||||
}
|
||||
private void OnDisable() => _subscriptions.Clear();
|
||||
|
||||
private void HandleSceneLoadRequest(SceneLoadRequest request)
|
||||
=> StartCoroutine(LoadSceneCoroutine(request));
|
||||
|
||||
@@ -4,7 +4,7 @@ MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
executionOrder: -900
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace BaseGames.Core
|
||||
/// 全局设置管理器。从 GlobalSettingsSO 读取默认值,从文件加载用户覆盖。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-800)]
|
||||
public class SettingsManager : MonoBehaviour
|
||||
public class SettingsManager : MonoBehaviour, ISettingsService
|
||||
{
|
||||
private const string SettingsFileName = "settings.json";
|
||||
|
||||
@@ -18,6 +18,11 @@ namespace BaseGames.Core
|
||||
|
||||
public GlobalSettingsData Current => _current;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
ServiceLocator.Register<ISettingsService>(this);
|
||||
}
|
||||
|
||||
/// <summary>由 GameManager.Awake 调用。读取设置文件,应用音量/分辨率。</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
@@ -61,7 +66,7 @@ namespace BaseGames.Core
|
||||
Screen.fullScreenMode = FullScreenMode.FullScreenWindow;
|
||||
}
|
||||
|
||||
// ── 音量设置(调用 AudioManager,Phase 1 接通)────────────────────
|
||||
// ── 音量设置(调用 AudioManager)────────────────────
|
||||
public void SetMasterVolume(float v) { _current.MasterVolume = v; Save(); }
|
||||
public void SetBGMVolume(float v) { _current.BGMVolume = v; Save(); }
|
||||
public void SetSFXVolume(float v) { _current.SFXVolume = v; Save(); }
|
||||
@@ -92,5 +97,10 @@ namespace BaseGames.Core
|
||||
_current.Language = localeCode;
|
||||
Save();
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<ISettingsService>(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
Assets/Scripts/Core/Validation.meta
Normal file
8
Assets/Scripts/Core/Validation.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 02c77a4457ac1ce45822fdcd54a16a51
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
34
Assets/Scripts/Core/Validation/IValidatable.cs
Normal file
34
Assets/Scripts/Core/Validation/IValidatable.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
public enum ValidationSeverity { Error, Warning }
|
||||
|
||||
/// <summary>
|
||||
/// 单条验证结果:严重级别 + 消息文本。
|
||||
/// 使用静态工厂方法 Error() / Warning() 创建。
|
||||
/// </summary>
|
||||
public readonly struct ValidationResult
|
||||
{
|
||||
public ValidationSeverity Severity { get; }
|
||||
public string Message { get; }
|
||||
|
||||
public ValidationResult(ValidationSeverity severity, string message)
|
||||
{
|
||||
Severity = severity;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public static ValidationResult Error(string message) => new(ValidationSeverity.Error, message);
|
||||
public static ValidationResult Warning(string message) => new(ValidationSeverity.Warning, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 可验证 ScriptableObject 接口。实现此接口的 SO 会被 SOValidationRunner 自动扫描。
|
||||
/// Validate() 返回零条结果 = 数据合法;否则每条按 <see cref="ValidationResult.Severity"/> 分类报告。
|
||||
/// </summary>
|
||||
public interface IValidatable
|
||||
{
|
||||
IEnumerable<ValidationResult> Validate();
|
||||
}
|
||||
}
|
||||
11
Assets/Scripts/Core/Validation/IValidatable.cs.meta
Normal file
11
Assets/Scripts/Core/Validation/IValidatable.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7bb0908d1e6d6684e870b43ab3da2d01
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user