地图系统
This commit is contained in:
@@ -13,9 +13,9 @@ namespace BaseGames.Core.Assets
|
||||
|
||||
/// <summary>
|
||||
/// Unity 场景名(与文件名一致),用于 SceneManager.LoadScene 和 GameBootstrap。
|
||||
/// 与 <see cref="ScenePersistent"/> 值相同,显式声明以区分两种使用场景。
|
||||
/// 文件名 Persistent.unity → 场景名 "Persistent";Addressable 地址另见 <see cref="ScenePersistent"/>。
|
||||
/// </summary>
|
||||
public const string ScenePersistentName = "Scene_Persistent";
|
||||
public const string ScenePersistentName = "Persistent";
|
||||
|
||||
/// <summary>Addressable key,用于 Addressables.LoadSceneAsync。</summary>
|
||||
public const string SceneMainMenu = "Scene_MainMenu";
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.SceneManagement;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using UnityEngine.ResourceManagement.ResourceProviders;
|
||||
|
||||
namespace BaseGames.Core.Assets
|
||||
{
|
||||
/// <summary>
|
||||
/// Addressables 运行时加载工具(薄封装)。
|
||||
/// 场景卸载时配合 <see cref="AssetReleaseTracker"/> 批量 Release。
|
||||
/// 项目统一资源加载门面(基于 Addressables)。
|
||||
/// <para>
|
||||
/// 全工程的资源加载/实例化/场景加载/释放都应通过此类,<b>不直接调用</b> <see cref="Addressables"/>,
|
||||
/// 更<b>不使用</b> <c>Resources.Load</c>。集中入口便于:句柄生命周期管理、缺键安全、日志/性能/内存预算埋点、合规校验。
|
||||
/// </para>
|
||||
/// <para>地址常量见 <see cref="AddressKeys"/>;场景卸载批量释放见 <see cref="AssetReleaseTracker"/>。</para>
|
||||
/// </summary>
|
||||
public static class AssetLoader
|
||||
{
|
||||
/// <summary>异步加载资产,返回 handle 供 Release 使用。</summary>
|
||||
// ── 资产加载(按地址 key)─────────────────────────────────────────────
|
||||
|
||||
/// <summary>异步加载资产,返回 (资产, handle);handle 用于后续 <see cref="Release"/>。</summary>
|
||||
public static async Task<(T asset, AsyncOperationHandle<T> handle)> LoadAsync<T>(string addressKey)
|
||||
{
|
||||
var handle = Addressables.LoadAssetAsync<T>(addressKey);
|
||||
@@ -21,13 +28,86 @@ namespace BaseGames.Core.Assets
|
||||
return (result, handle);
|
||||
}
|
||||
|
||||
/// <summary>异步加载资产,返回原始 handle(调用方持有以便 Release)。</summary>
|
||||
public static AsyncOperationHandle<T> LoadHandle<T>(string addressKey)
|
||||
=> Addressables.LoadAssetAsync<T>(addressKey);
|
||||
|
||||
/// <summary>异步加载 AssetReference 指向的资产(设计师在 Inspector 拖入的引用)。</summary>
|
||||
public static AsyncOperationHandle<T> LoadHandle<T>(AssetReference reference)
|
||||
=> reference.LoadAssetAsync<T>();
|
||||
|
||||
/// <summary>
|
||||
/// 同步加载资产(内部 WaitForCompletion)。返回 (资产, handle),调用方负责在不再需要时 <see cref="Release"/>。
|
||||
/// 仅用于本地小资源(如配置 SO、本地化表);远程/大资源请用 <see cref="LoadAsync{T}"/>。
|
||||
/// </summary>
|
||||
public static (T asset, AsyncOperationHandle<T> handle) LoadSync<T>(string addressKey)
|
||||
{
|
||||
var handle = Addressables.LoadAssetAsync<T>(addressKey);
|
||||
var asset = handle.WaitForCompletion();
|
||||
return (asset, handle);
|
||||
}
|
||||
|
||||
/// <summary>同步检查地址是否存在对应资源位置(缺键安全:避免对不存在的 key 直接加载报错)。</summary>
|
||||
public static bool Exists(string addressKey, System.Type type = null)
|
||||
{
|
||||
var loc = type != null
|
||||
? Addressables.LoadResourceLocationsAsync(addressKey, type)
|
||||
: Addressables.LoadResourceLocationsAsync(addressKey);
|
||||
var list = loc.WaitForCompletion();
|
||||
bool exists = list != null && list.Count > 0;
|
||||
if (loc.IsValid()) Addressables.Release(loc);
|
||||
return exists;
|
||||
}
|
||||
|
||||
// ── 实例化(按地址 key / AssetReference)───────────────────────────────
|
||||
|
||||
/// <summary>异步实例化预制(按地址 key)。返回 handle;用 <see cref="ReleaseInstance"/> 释放实例。</summary>
|
||||
public static AsyncOperationHandle<GameObject> InstantiateAsync(string addressKey, Transform parent = null)
|
||||
=> Addressables.InstantiateAsync(addressKey, parent);
|
||||
|
||||
/// <summary>异步实例化预制(按地址 key,指定位置/旋转)。</summary>
|
||||
public static AsyncOperationHandle<GameObject> InstantiateAsync(string addressKey, Vector3 position, Quaternion rotation, Transform parent = null)
|
||||
=> Addressables.InstantiateAsync(addressKey, position, rotation, parent);
|
||||
|
||||
/// <summary>异步实例化预制(按 AssetReference)。</summary>
|
||||
public static AsyncOperationHandle<GameObject> InstantiateAsync(AssetReference reference, Transform parent = null)
|
||||
=> Addressables.InstantiateAsync(reference, parent);
|
||||
|
||||
// ── 场景(流式/区域加载)──────────────────────────────────────────────
|
||||
|
||||
/// <summary>异步加载场景(Addressable)。返回 handle 供卸载使用。</summary>
|
||||
public static AsyncOperationHandle<SceneInstance> LoadSceneAsync(string addressKey, LoadSceneMode mode, bool activateOnLoad = true)
|
||||
=> Addressables.LoadSceneAsync(addressKey, mode, activateOnLoad);
|
||||
|
||||
/// <summary>异步卸载由 <see cref="LoadSceneAsync"/> 加载的场景。</summary>
|
||||
public static AsyncOperationHandle<SceneInstance> UnloadSceneAsync(AsyncOperationHandle<SceneInstance> sceneHandle)
|
||||
=> Addressables.UnloadSceneAsync(sceneHandle);
|
||||
|
||||
// ── 依赖预下载(按 label / key)────────────────────────────────────────
|
||||
|
||||
/// <summary>预下载指定 label/key 的依赖(首启动预热)。</summary>
|
||||
public static AsyncOperationHandle DownloadDependenciesAsync(object keyOrLabel, bool autoReleaseHandle = false)
|
||||
=> Addressables.DownloadDependenciesAsync(keyOrLabel, autoReleaseHandle);
|
||||
|
||||
// ── 释放 ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>释放一个已加载的 handle(引用计数 -1)。</summary>
|
||||
public static void Release<T>(AsyncOperationHandle<T> handle)
|
||||
{
|
||||
if (handle.IsValid()) Addressables.Release(handle);
|
||||
}
|
||||
|
||||
/// <summary>释放一个 GameObject 实例(Addressables.ReleaseInstance)。</summary>
|
||||
/// <summary>释放一个非泛型 handle(引用计数 -1)。</summary>
|
||||
public static void Release(AsyncOperationHandle handle)
|
||||
{
|
||||
if (handle.IsValid()) Addressables.Release(handle);
|
||||
}
|
||||
|
||||
/// <summary>按已加载资产对象释放(引用计数 -1)。用于只保留了资产引用、未保留 handle 的场景。</summary>
|
||||
public static void ReleaseAsset<TObject>(TObject obj)
|
||||
=> Addressables.Release(obj);
|
||||
|
||||
/// <summary>释放一个 Addressables 实例化的 GameObject 实例。</summary>
|
||||
public static bool ReleaseInstance(GameObject go)
|
||||
=> Addressables.ReleaseInstance(go);
|
||||
}
|
||||
@@ -43,6 +123,9 @@ namespace BaseGames.Core.Assets
|
||||
public void Track<T>(AsyncOperationHandle<T> handle)
|
||||
=> _handles.Add(handle);
|
||||
|
||||
public void Track(AsyncOperationHandle handle)
|
||||
=> _handles.Add(handle);
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
foreach (var h in _handles)
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
@@ -56,6 +57,14 @@ namespace BaseGames.Core
|
||||
/// </summary>
|
||||
public IEnumerator RunBootSequenceCoroutine()
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// 编辑器下跳过 Splash 演出,直接进入主菜单,加快迭代效率。
|
||||
_splashDone = true;
|
||||
bool preloadDone = false;
|
||||
StartCoroutine(PreloadCoroutine(() => preloadDone = true));
|
||||
yield return new WaitUntil(() => preloadDone);
|
||||
yield break;
|
||||
#endif
|
||||
// 若未绑定 SplashComplete 频道,则无需等待 Splash,直接视为已完成
|
||||
_splashDone = (_onSplashComplete == null);
|
||||
|
||||
@@ -63,11 +72,11 @@ namespace BaseGames.Core
|
||||
_onSplashStartRequest?.Raise();
|
||||
|
||||
// 并行启动 Addressable 预热
|
||||
bool preloadDone = false;
|
||||
StartCoroutine(PreloadCoroutine(() => preloadDone = true));
|
||||
bool preloadDoneBuild = false;
|
||||
StartCoroutine(PreloadCoroutine(() => preloadDoneBuild = true));
|
||||
|
||||
// 等待两者均完成
|
||||
yield return new WaitUntil(() => _splashDone && preloadDone);
|
||||
yield return new WaitUntil(() => _splashDone && preloadDoneBuild);
|
||||
}
|
||||
|
||||
// ── 内部:Addressable 预热 ────────────────────────────────────────────
|
||||
@@ -83,7 +92,7 @@ namespace BaseGames.Core
|
||||
}
|
||||
|
||||
// 仅下载依赖(不实例化),最小化内存占用
|
||||
var handle = Addressables.DownloadDependenciesAsync(_preloadLabel, false);
|
||||
var handle = AssetLoader.DownloadDependenciesAsync(_preloadLabel, false);
|
||||
|
||||
while (!handle.IsDone)
|
||||
{
|
||||
@@ -97,7 +106,7 @@ namespace BaseGames.Core
|
||||
" 请检查 Addressable 标签配置与网络连接。");
|
||||
|
||||
_onPreloadProgress?.Raise(1f);
|
||||
Addressables.Release(handle);
|
||||
AssetLoader.Release(handle);
|
||||
onDone?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,13 @@ namespace BaseGames.Core
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开新局时无条件应用难度。
|
||||
/// 与 <see cref="ChangeDifficulty"/> 不同,本方法不受 SteelSoul 不可降级守卫限制——
|
||||
/// 新游戏是全新存档上下文,玩家在模式选择屏的选择应被无条件采纳。
|
||||
/// </summary>
|
||||
public void BeginNewGame(DifficultyLevel level) => Apply(level);
|
||||
|
||||
private void Apply(DifficultyLevel level)
|
||||
{
|
||||
CurrentLevel = level;
|
||||
|
||||
@@ -18,6 +18,10 @@ namespace BaseGames.Core
|
||||
/// <summary>切换到指定难度。SteelSoul 模式一旦选定不可降级。</summary>
|
||||
void ChangeDifficulty(DifficultyLevel level);
|
||||
|
||||
/// <summary>开新局时无条件应用难度(绕过 SteelSoul 不可降级守卫)。
|
||||
/// 用于主菜单"新游戏 → 模式选择"后初始化难度;同进程内从 SteelSoul 存档退出再开普通新档时不会被守卫误挡。</summary>
|
||||
void BeginNewGame(DifficultyLevel level);
|
||||
|
||||
/// <summary>按档位查找缩放器,未配置时返回 null。</summary>
|
||||
DifficultyScalerSO GetScaler(DifficultyLevel level);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,27 @@ namespace BaseGames.Core.Events
|
||||
{
|
||||
[Multiline] public string description;
|
||||
|
||||
[Tooltip("开启后记住最后一次 Raise 的值,新订阅者在 Subscribe 时立即收到该值。\n" +
|
||||
"适合 HP / MaxHP / 货币 / 形态等\"当前状态\"事件——使延迟启用的 UI(如 HUD 在加载阶段未启用、" +
|
||||
"进入 Gameplay 后才订阅)也能拿到当前值;\n" +
|
||||
"不适合死亡 / 受击 / 拾取等\"瞬时动作\"事件(回放会导致重复触发)。")]
|
||||
[SerializeField] private bool _replayLastValueToNewSubscribers;
|
||||
|
||||
// 运行时缓存(不序列化);OnEnable 时清空,避免上次 Play 的残留值污染本次。
|
||||
[NonSerialized] private bool _hasLastValue;
|
||||
[NonSerialized] private T _lastValue;
|
||||
|
||||
private event Action<T> _onEventRaisedBacking;
|
||||
#if UNITY_EDITOR
|
||||
private int _subscriberCount;
|
||||
#endif
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
_hasLastValue = false;
|
||||
_lastValue = default;
|
||||
}
|
||||
|
||||
public event Action<T> OnEventRaised
|
||||
{
|
||||
add
|
||||
@@ -35,6 +51,11 @@ namespace BaseGames.Core.Events
|
||||
|
||||
public void Raise(T value)
|
||||
{
|
||||
if (_replayLastValueToNewSubscribers)
|
||||
{
|
||||
_lastValue = value;
|
||||
_hasLastValue = true;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
EventBusMonitor.Record(name, value?.ToString() ?? "null",
|
||||
_subscriberCount,
|
||||
@@ -45,10 +66,14 @@ namespace BaseGames.Core.Events
|
||||
|
||||
/// <summary>
|
||||
/// 订阅并返回可 Dispose 的订阅句柄,配合 CompositeDisposable 使用。
|
||||
/// 若该频道开启了"粘性"(_replayLastValueToNewSubscribers)且已有缓存值,
|
||||
/// 回调会在订阅时立即收到最后一次 Raise 的值。
|
||||
/// </summary>
|
||||
public EventSubscription Subscribe(Action<T> callback)
|
||||
{
|
||||
OnEventRaised += callback;
|
||||
if (_replayLastValueToNewSubscribers && _hasLastValue)
|
||||
callback(_lastValue);
|
||||
return new EventSubscription(() => OnEventRaised -= callback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,6 +236,6 @@ namespace BaseGames.Core
|
||||
// ── 存档槽管理(主菜单 UI 用)─────────────────────────────────────────
|
||||
public System.Threading.Tasks.Task<BaseGames.Core.Save.SlotSummary> GetSlotSummaryAsync(int slotIndex)
|
||||
=> _save.GetSlotSummaryAsync(slotIndex);
|
||||
public void CreateSlot(int slotIndex) => _save.CreateSlot(slotIndex);
|
||||
public void CreateSlot(int slotIndex, bool steelSoul = false) => _save.CreateSlot(slotIndex, steelSoul);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +84,8 @@ namespace BaseGames.Core
|
||||
Task<SlotSummary> GetSlotSummaryAsync(int slotIndex);
|
||||
|
||||
/// <summary>新建存档槽(不写盘,仅初始化内存 SaveData 并设定活跃槽索引)。</summary>
|
||||
void CreateSlot(int slotIndex);
|
||||
/// <param name="slotIndex">目标槽索引。</param>
|
||||
/// <param name="steelSoul">是否以钢铁之魂(一命)模式开局,写入 Meta.IsSteelSoul。</param>
|
||||
void CreateSlot(int slotIndex, bool steelSoul = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using BaseGames.Core.Assets;
|
||||
|
||||
namespace BaseGames.Core.Pool
|
||||
{
|
||||
@@ -39,7 +40,7 @@ namespace BaseGames.Core.Pool
|
||||
{
|
||||
// 释放所有通过 Addressables.LoadAssetAsync 加载的预制件引用
|
||||
foreach (var pfx in _prefabCache.Values)
|
||||
Addressables.Release(pfx);
|
||||
AssetLoader.ReleaseAsset(pfx);
|
||||
_prefabCache.Clear();
|
||||
ServiceLocator.Unregister<IObjectPoolService>(this);
|
||||
}
|
||||
@@ -61,7 +62,7 @@ namespace BaseGames.Core.Pool
|
||||
|
||||
private async Task WarmupSingleAsync(string key, int count)
|
||||
{
|
||||
var prefab = await Addressables.LoadAssetAsync<GameObject>(key).Task;
|
||||
var prefab = (await AssetLoader.LoadAsync<GameObject>(key)).asset;
|
||||
_prefabCache[key] = prefab;
|
||||
EnsureCollections(key, count);
|
||||
for (int i = 0; i < count; i++)
|
||||
@@ -201,7 +202,7 @@ namespace BaseGames.Core.Pool
|
||||
_alive.Remove(key);
|
||||
if (_prefabCache.TryGetValue(key, out var pfx))
|
||||
{
|
||||
Addressables.Release(pfx);
|
||||
AssetLoader.ReleaseAsset(pfx);
|
||||
_prefabCache.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"name": "BaseGames.Core.Save",
|
||||
"rootNamespace": "BaseGames.Core.Save",
|
||||
"references": [
|
||||
"BaseGames.Core.Events"
|
||||
"BaseGames.Core.Events",
|
||||
"Unity.Addressables",
|
||||
"Unity.ResourceManager"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
@@ -234,21 +236,31 @@ namespace BaseGames.Core.Save
|
||||
var root = Newtonsoft.Json.Linq.JObject.Parse(json);
|
||||
return new SlotSummary
|
||||
{
|
||||
SlotIndex = slotIndex,
|
||||
Playtime = (float?)root["Meta"]?["Playtime"] ?? 0f,
|
||||
LastSaved = (string)root["Meta"]?["LastSaved"],
|
||||
SceneName = (string)root["Player"]?["Scene"],
|
||||
ActiveFormId = (string)root["Player"]?["ActiveFormId"],
|
||||
SlotIndex = slotIndex,
|
||||
Playtime = (float?)root["Meta"]?["Playtime"] ?? 0f,
|
||||
LastSaved = (string)root["Meta"]?["LastSaved"],
|
||||
SceneName = (string)root["Player"]?["Scene"],
|
||||
ActiveFormId = (string)root["Player"]?["ActiveFormId"],
|
||||
CurrentLingZhu = (int?)root["Player"]?["CurrentLingZhu"] ?? 0,
|
||||
MaxHP = (int?)root["Player"]?["MaxHP"] ?? 0,
|
||||
IsSteelSoul = (bool?)root["Meta"]?["IsSteelSoul"] ?? false,
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
public void CreateSlot(int slotIndex)
|
||||
/// <summary>
|
||||
/// 新建存档槽(不写盘,仅初始化内存 SaveData)。
|
||||
/// </summary>
|
||||
/// <param name="slotIndex">目标槽索引。</param>
|
||||
/// <param name="steelSoul">是否以钢铁之魂(一命)模式开局;写入 Meta.IsSteelSoul,
|
||||
/// 供 DifficultyManager 在首次存档/读档时锁定 SteelSoul 难度。</param>
|
||||
public void CreateSlot(int slotIndex, bool steelSoul = false)
|
||||
{
|
||||
_currentSlot = slotIndex;
|
||||
_current = new SaveData();
|
||||
_current.Meta.SlotIndex = slotIndex;
|
||||
_current.Meta.SlotIndex = slotIndex;
|
||||
_current.Meta.IsSteelSoul = steelSoul;
|
||||
}
|
||||
|
||||
public async Task DeleteSlotAsync(int slotIndex)
|
||||
@@ -279,12 +291,35 @@ namespace BaseGames.Core.Save
|
||||
return Convert.ToBase64String(hmac.ComputeHash(dataBytes));
|
||||
}
|
||||
|
||||
private string _cachedHmacKey; // 缓存解析结果(含兜底),避免每次校验都加载配置
|
||||
|
||||
private string GetHmacKey()
|
||||
{
|
||||
// ⚠️ 不使用 deviceUniqueIdentifier——设备唯一标识在玩家换设备时会改变,
|
||||
// 导致所有存档 checksum 永久失效。改用游戏固定密钥。
|
||||
const string gameSecret = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
|
||||
return gameSecret;
|
||||
if (!string.IsNullOrEmpty(_cachedHmacKey)) return _cachedHmacKey;
|
||||
|
||||
// 注意:本程序集 BaseGames.Core.Save 是底层(被 BaseGames.Core 引用),
|
||||
// 无法引用上层 AssetLoader(会循环依赖)。故此处直接用 Addressables——
|
||||
// 仍满足"统一 Addressables、不用 Resources"的原则;这是门面路由的唯一架构豁免点。
|
||||
string key = null;
|
||||
var locs = Addressables.LoadResourceLocationsAsync("SaveSecurityConfig", typeof(SaveSecurityConfig));
|
||||
var list = locs.WaitForCompletion();
|
||||
if (list != null && list.Count > 0)
|
||||
{
|
||||
var h = Addressables.LoadAssetAsync<SaveSecurityConfig>("SaveSecurityConfig");
|
||||
var cfg = h.WaitForCompletion();
|
||||
if (cfg != null && !string.IsNullOrEmpty(cfg.HmacKey)) key = cfg.HmacKey;
|
||||
if (h.IsValid()) Addressables.Release(h);
|
||||
}
|
||||
if (locs.IsValid()) Addressables.Release(locs);
|
||||
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
// 开发期兜底密钥:构建前必须注入真实密钥(CI/CD 或 SaveKeyInjector)。
|
||||
Debug.LogWarning("[SaveSecurity] SaveSecurityConfig 未找到或密钥为空,使用开发期兜底密钥。正式构建前必须修复。");
|
||||
key = "ZelingV2SaveIntegrity_v2_9a3f7c1b";
|
||||
}
|
||||
_cachedHmacKey = key;
|
||||
return key;
|
||||
}
|
||||
|
||||
private bool ValidateChecksum(SaveData data)
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace BaseGames.Core.Save
|
||||
public ChallengeRoomsSaveData ChallengeRooms = new();
|
||||
public EventChainsSaveData EventChains = new();
|
||||
public ShopsSaveData Shops = new();
|
||||
public InventorySaveData Inventory = new();
|
||||
public JournalSaveData Journal = new();
|
||||
public StatsSaveData Stats = new();
|
||||
public NGPlusSaveData NGPlus = null; // null = 非 NG+ 模式
|
||||
public TutorialSaveData Tutorial = new();
|
||||
@@ -104,6 +106,7 @@ namespace BaseGames.Core.Save
|
||||
public List<MapPin> Pins = new(); // 玩家自定义地图标记
|
||||
public string LastRegionId; // 上次进入的区域 ID(避免读档后首次进房误触发 EVT_RegionChanged)
|
||||
public HashSet<string> UnlockedTeleportRoomIds = new(); // 已解锁传送站的房间 ID(由 TeleportService 维护)
|
||||
public bool LocatorUnlocked; // 是否已获得"定位"道具(指南针);控制地图玩家位置点是否显示(由 MapManager 维护)
|
||||
}
|
||||
|
||||
/// <summary>玩家在地图上放置的自定义标记(架构 15_MapShopModule §1.5)。</summary>
|
||||
@@ -227,6 +230,28 @@ namespace BaseGames.Core.Save
|
||||
public Dictionary<string, int> PurchaseCounts = new();
|
||||
}
|
||||
|
||||
// ─── Inventory(背包道具)──────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class InventorySaveData
|
||||
{
|
||||
/// <summary>itemId → 拥有数量。不可叠加道具数量恒为 1。</summary>
|
||||
public Dictionary<string, int> ItemCounts = new();
|
||||
/// <summary>玩家手动标记为"新获得已读"前用于 HUD 提示的未读 itemId。</summary>
|
||||
public List<string> NewItemIds = new();
|
||||
}
|
||||
|
||||
// ─── Journal(图鉴:敌人 / 区域 / 收集图鉴)──────────────────────────────────
|
||||
[Serializable]
|
||||
public class JournalSaveData
|
||||
{
|
||||
/// <summary>已记录(击杀过 / 遭遇过)的敌人 ID。</summary>
|
||||
public HashSet<string> DiscoveredEnemyIds = new();
|
||||
/// <summary>敌人 ID → 击杀次数(用于图鉴完成度 / 解锁额外词条)。</summary>
|
||||
public Dictionary<string, int> EnemyKillCounts = new();
|
||||
/// <summary>已解锁的图鉴词条 ID(剧情 / 地点 / 知识条目)。</summary>
|
||||
public HashSet<string> UnlockedLoreIds = new();
|
||||
}
|
||||
|
||||
// ─── NGPlus ───────────────────────────────────────────────────────────────
|
||||
[Serializable]
|
||||
public class NGPlusSaveData
|
||||
@@ -244,6 +269,11 @@ namespace BaseGames.Core.Save
|
||||
public string LastSaved;
|
||||
public string SceneName;
|
||||
public string ActiveFormId;
|
||||
|
||||
// 存档卡摘要信息(用于前端选档界面展示)
|
||||
public int CurrentLingZhu; // 货币(灵珠)持有量,源 Player.CurrentLingZhu
|
||||
public int MaxHP; // 生命(面具)上限,源 Player.MaxHP
|
||||
public bool IsSteelSoul; // 钢铁之魂(一命)模式徽章,源 Meta.IsSteelSoul
|
||||
}
|
||||
|
||||
// ─── Tutorial ─────────────────────────────────────────────────────────────
|
||||
@@ -260,5 +290,13 @@ namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>玩家选择的语言。空字符串 = 使用系统默认。由 LocalizationManager 读写。</summary>
|
||||
public string Language = string.Empty;
|
||||
|
||||
// ── 可访问性设置(v2.3 起)────────────────────────────────────────────
|
||||
/// <summary>UI 整体缩放比例(0.8–1.5)。对应 SettingsPanelController._uiScaleSlider。</summary>
|
||||
[UnityEngine.Range(0.8f, 1.5f)] public float UIScale = 1.0f;
|
||||
/// <summary>色盲辅助模式(0=None,1=Protanopia,2=Deuteranopia,3=Tritanopia)。</summary>
|
||||
public int ColorblindMode = 0;
|
||||
/// <summary>是否启用画面震动效果。</summary>
|
||||
public bool ScreenShakeEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace BaseGames.Core.Save
|
||||
/// </summary>
|
||||
public static class SaveMigrator
|
||||
{
|
||||
public const string CurrentVersion = "2.2";
|
||||
public const string CurrentVersion = "2.3";
|
||||
|
||||
public static SaveData Migrate(SaveData data)
|
||||
{
|
||||
@@ -55,6 +55,16 @@ namespace BaseGames.Core.Save
|
||||
v = "2.2";
|
||||
}
|
||||
|
||||
// ── 2.2 → 2.3 ───────────────────────────────────────────────────────
|
||||
if (v == "2.2")
|
||||
{
|
||||
// 2.3 为 SettingsSaveData 新增 UIScale / ColorblindMode / ScreenShakeEnabled。
|
||||
// 字段默认值由构造函数提供(UIScale=1.0, ColorblindMode=0, ScreenShakeEnabled=true),
|
||||
// JSON 反序列化时缺失字段自动使用默认值,无需额外赋值。
|
||||
Debug.Log("[SaveMigrator] 从 '2.2' 迁移至 '2.3'。");
|
||||
v = "2.3";
|
||||
}
|
||||
|
||||
// ── 未识别的未来版本 ─────────────────────────────────────────────────
|
||||
if (v != CurrentVersion)
|
||||
Debug.LogWarning($"[SaveMigrator] 未知版本 '{v}',将直接使用(可能存在兼容性问题)。");
|
||||
|
||||
16
Assets/_Game/Scripts/Core/Save/SaveSecurityConfig.cs
Normal file
16
Assets/_Game/Scripts/Core/Save/SaveSecurityConfig.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace BaseGames.Core.Save
|
||||
{
|
||||
/// <summary>
|
||||
/// 存档完整性校验密钥配置 SO。
|
||||
/// 放置在 Resources/ 目录,GameSaveManager 在 Initialize() 时同步加载。
|
||||
/// 开发期:提交空占位资产,HmacKey 留空(GameSaveManager 自动降级到兜底密钥并输出警告)。
|
||||
/// 正式构建:由 CI/CD 管线或 SaveKeyInjector 在构建前注入真实密钥。
|
||||
/// </summary>
|
||||
public sealed class SaveSecurityConfig : ScriptableObject
|
||||
{
|
||||
[HideInInspector]
|
||||
public string HmacKey = string.Empty;
|
||||
}
|
||||
}
|
||||
11
Assets/_Game/Scripts/Core/Save/SaveSecurityConfig.cs.meta
Normal file
11
Assets/_Game/Scripts/Core/Save/SaveSecurityConfig.cs.meta
Normal file
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 918f120ca4dfcee4287835575723918f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -4,6 +4,7 @@ using UnityEngine.AddressableAssets;
|
||||
using UnityEngine.ResourceManagement.AsyncOperations;
|
||||
using UnityEngine.ResourceManagement.ResourceProviders;
|
||||
using UnityEngine.SceneManagement;
|
||||
using BaseGames.Core.Assets;
|
||||
using BaseGames.Core.Events;
|
||||
|
||||
namespace BaseGames.Core
|
||||
@@ -38,7 +39,7 @@ namespace BaseGames.Core
|
||||
|
||||
// 先加载新场景(Additive),成功后再卸载旧场景
|
||||
// 顺序保证:若加载失败,旧场景仍保持可用,不会出现无场景的空状态
|
||||
var loadOp = Addressables.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
|
||||
var loadOp = AssetLoader.LoadSceneAsync(request.SceneName, LoadSceneMode.Additive);
|
||||
|
||||
// 逐帧轮询以上报进度(不能直接 yield return loadOp,那样无法回调进度)
|
||||
while (!loadOp.IsDone)
|
||||
@@ -59,7 +60,7 @@ namespace BaseGames.Core
|
||||
// 新场景加载成功,再卸载旧场景
|
||||
if (!string.IsNullOrEmpty(_currentRoomScene) && _currentHandle.IsValid())
|
||||
{
|
||||
var unloadOp = Addressables.UnloadSceneAsync(_currentHandle);
|
||||
var unloadOp = AssetLoader.UnloadSceneAsync(_currentHandle);
|
||||
yield return unloadOp;
|
||||
}
|
||||
|
||||
@@ -79,7 +80,7 @@ namespace BaseGames.Core
|
||||
public IEnumerator UnloadCurrentCoroutine()
|
||||
{
|
||||
if (!_currentHandle.IsValid()) yield break;
|
||||
var unloadOp = Addressables.UnloadSceneAsync(_currentHandle);
|
||||
var unloadOp = AssetLoader.UnloadSceneAsync(_currentHandle);
|
||||
yield return unloadOp;
|
||||
_currentRoomScene = null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
using BaseGames.Core.Save;
|
||||
|
||||
namespace BaseGames.Core
|
||||
{
|
||||
@@ -8,9 +9,11 @@ namespace BaseGames.Core
|
||||
/// 全局设置管理器。从 GlobalSettingsSO 读取默认值,从文件加载用户覆盖。
|
||||
/// 任何 Setter 调用 Save() 后会触发 <see cref="SettingsChanged"/> 静态事件,
|
||||
/// 供 UIScaleApplier / ColorblindApplier / CameraShake 等订阅。
|
||||
/// 同时实现 ISaveable,将可访问性三项(UIScale / ColorblindMode / ScreenShakeEnabled)
|
||||
/// 同步到 SaveData.Settings,支持每个存档槽独立保存无障碍偏好。
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-800)]
|
||||
public class SettingsManager : MonoBehaviour, ISettingsService
|
||||
public class SettingsManager : SaveableMonoBehaviour, ISettingsService
|
||||
{
|
||||
private const string SettingsFileName = "settings.json";
|
||||
|
||||
@@ -29,6 +32,9 @@ namespace BaseGames.Core
|
||||
ServiceLocator.Register<ISettingsService>(this);
|
||||
}
|
||||
|
||||
protected override void OnEnable() => base.OnEnable();
|
||||
protected override void OnDisable() => base.OnDisable();
|
||||
|
||||
/// <summary>由 GameManager.Awake 调用。读取设置文件,应用音量/分辨率。</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
@@ -67,8 +73,14 @@ namespace BaseGames.Core
|
||||
|
||||
private void Apply(GlobalSettingsData data)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
// 编辑器下禁用 vSync 并设置高帧率,避免失焦时帧率归零导致协程卡死。
|
||||
QualitySettings.vSyncCount = 0;
|
||||
Application.targetFrameRate = 120;
|
||||
#else
|
||||
QualitySettings.vSyncCount = data.VSync ? 1 : 0;
|
||||
if (!data.VSync) Application.targetFrameRate = data.TargetFPS;
|
||||
#endif
|
||||
|
||||
if (data.FullScreen)
|
||||
Screen.fullScreenMode = FullScreenMode.FullScreenWindow;
|
||||
@@ -123,6 +135,28 @@ namespace BaseGames.Core
|
||||
Save();
|
||||
}
|
||||
|
||||
// ── ISaveable ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>存档时将可访问性偏好写入存档槽,支持多玩家各槽独立设置。</summary>
|
||||
public override void OnSave(SaveData saveData)
|
||||
{
|
||||
if (saveData.Settings == null) saveData.Settings = new SettingsSaveData();
|
||||
saveData.Settings.UIScale = _current.UIScale;
|
||||
saveData.Settings.ColorblindMode = (int)_current.ColorblindMode;
|
||||
saveData.Settings.ScreenShakeEnabled = _current.ScreenShakeEnabled;
|
||||
}
|
||||
|
||||
/// <summary>读档时从存档槽还原可访问性偏好,并同步写回 settings.json。</summary>
|
||||
public override void OnLoad(SaveData saveData)
|
||||
{
|
||||
if (saveData.Settings == null) return;
|
||||
_current.UIScale = saveData.Settings.UIScale;
|
||||
_current.ColorblindMode = (ColorblindMode)saveData.Settings.ColorblindMode;
|
||||
_current.ScreenShakeEnabled = saveData.Settings.ScreenShakeEnabled;
|
||||
Save(); // 同步写回 settings.json,重启后保持一致
|
||||
SettingsChanged?.Invoke(_current); // 立即应用 UIScaleApplier / 色盲滤镜等效果
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
ServiceLocator.Unregister<ISettingsService>(this);
|
||||
|
||||
Reference in New Issue
Block a user