多轮审查和修复

This commit is contained in:
2026-05-12 15:34:08 +08:00
parent f55d2a57c3
commit ebbbb7332e
805 changed files with 838724 additions and 1905 deletions

View File

@@ -50,7 +50,7 @@ namespace BaseGames.Core.Assets
/// <summary>
/// 解析 key返回对应的 Addressable 地址字符串。
/// 若 key 未注册则返回原 key兼容直接使用静态常量的调用方
/// 若 key 未注册,直接将 key 作为 Addressable 地址使用
/// </summary>
public static string Resolve(string key)
{

View File

@@ -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";
}
}
}

View File

@@ -8,7 +8,6 @@ namespace BaseGames.Core
/// <summary>
/// 资产释放跟踪器。
/// 事件驱动:监听 EVT_SceneLoadRequest在新场景加载前清理旧场景的对象池缓存。
/// ⚠️ 不使用显式注册 APIGlobalObjectPool.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;

View File

@@ -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 1SteelSoul 清档并返回主菜单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();
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7982848c7ca0270419ab1c0a32fde9a0
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: d3e424c1787e5be4fa918201b1830192
guid: 7a810da0a9739024d90a4f7415aeb2a6
MonoImporter:
externalObjects: {}
serializedVersion: 2

View 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;
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7e8ad48b35397348a800079849ee535
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 20984324b3111c5489d9c4c3e55ec4f6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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>

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d5c798758acf2c64097cf4ff3b088530
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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 { }

View File

@@ -3,7 +3,7 @@ using UnityEngine;
namespace BaseGames.Core.Events
{
/// <summary>
/// 难度变更事件频道。Phase 2 难度系统使用。
/// 难度变更事件频道。
/// 发布DifficultyScalerSO / SettingsManager
/// 订阅:所有需要感知当前难度的系统
/// </summary>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 661043851605d4849bef40ea15c556b4
guid: 147bb5b987a0a244ba3a39c71852ca51
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -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 { }

View File

@@ -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)

View 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";
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9133700debab31540bae36f9130c9791
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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();
// 等待玩家在死亡画面点击重试

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -1000
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -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);
}
}

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -2000
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -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
{

View File

@@ -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>设置混音器音量01。group 取 AudioMixerKeys 常量。</summary>
void SetVolume(string group, float normalizedVolume);
}

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: de3e4fbb738acc34a8b1e4c18eae9e3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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>当前活跃存档槽02。</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);
}
}

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 691c1a15bfcb61444b5a4a342fa6bd88
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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}) — 音频系统未初始化。");
}

View File

@@ -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> 桥接,
/// 或直接 awaitUniTask / 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;
}
}

View File

@@ -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();

View 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
{
// 日志写入失败不能再抛异常,否则会造成无限递归
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 18aff6d8d08f33f43a5980c8c4f94c2b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b59605354f1e174593553d2d09fb624
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 中增加 IsSteelSoulJSON 反序列化已默认 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.1Player.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;
}
}
}

View File

@@ -0,0 +1,25 @@
using UnityEngine;
using BaseGames.Core;
namespace BaseGames.Core.Save
{
/// <summary>
/// 带自动注册/注销的 ISaveable MonoBehaviour 基类。
/// 继承此类可消除每个存档对象手动调用 ServiceLocator.GetOrDefault&lt;SaveManager&gt;()?.Register/Unregister 的样板代码。
///
/// 生命周期:
/// OnEnable → ServiceLocator.GetOrDefault&lt;SaveManager&gt;()?.Register(this)
/// OnDisable → ServiceLocator.GetOrDefault&lt;SaveManager&gt;()?.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);
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d8716b07a7bca5c43bb372f78884cc13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -9,9 +9,9 @@ using BaseGames.Core.Events;
namespace BaseGames.Core
{
/// <summary>
/// Addressables 场景加载器Phase 0 骨架)
/// Addressables 场景加载器。
/// 监听 EVT_SceneLoadRequestAdditive 加载指定场景,完成后发布 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>

View File

@@ -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));

View File

@@ -4,7 +4,7 @@ MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -900
icon: {instanceID: 0}
userData:
assetBundleName:

View File

@@ -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;
}
// ── 音量设置(调用 AudioManagerPhase 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);
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 02c77a4457ac1ce45822fdcd54a16a51
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7bb0908d1e6d6684e870b43ab3da2d01
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: