地图系统

This commit is contained in:
2026-06-05 18:41:33 +08:00
parent 613f2a4d13
commit fe4fd60083
234 changed files with 33090 additions and 4899 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": [],

View File

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

View File

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

View File

@@ -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}',将直接使用(可能存在兼容性问题)。");

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

View File

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

View File

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

View File

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