地图系统

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

@@ -9,7 +9,7 @@ namespace BaseGames.Camera
/// 玩家水平速度越快Lookahead.Time 越接近 CameraArea 配置的最大值;
/// 静止时衰减至最大值的 <see cref="_restScale"/> 倍,避免静止时镜头无谓偏移。
///
/// 挂载位置:Persistent 场景中的 VCamA / VCamB GameObject。
/// 挂载位置:各 CameraArea 的专属 DedicatedCamera GameObject。
/// <see cref="CameraStateController.ConfigureSlot"/> 在每次切换区域时调用
/// <see cref="SetConfiguredMax"/> 传入该区域的 LookaheadTime。
/// </summary>

View File

@@ -10,7 +10,7 @@ namespace BaseGames.Camera
///
/// <para>使用须知:</para>
/// <list type="bullet">
/// <item>挂载在 VCamA / VCamB 上(<see cref="CameraStateController"/> 初始化时自动识别)。</item>
/// <item>挂载在 CameraArea 的专属 DedicatedCamera 上(<see cref="CameraStateController"/> 激活区域时自动识别)。</item>
/// <item>此扩展存在时,<see cref="CameraStateController.ConfigureSlot"/> 会自动将
/// <see cref="CinemachinePositionComposer.Damping"/> 的 Y 分量清零,避免双重阻尼。</item>
/// <item>阻尼值可由 <see cref="CameraArea"/> 通过 <c>DampingDown</c> / <c>DampingUp</c>

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

View File

@@ -0,0 +1,99 @@
using System.IO;
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.AddressableAssets;
using UnityEditor.AddressableAssets.Settings;
using UnityEngine;
using BaseGames.Core.Assets;
namespace BaseGames.Editor.Addressables
{
/// <summary>
/// 一键将核心启动场景注册为 Addressable address避免遗漏导致 Addressables.LoadSceneAsync 失败。
/// 菜单BaseGames/Addressables/Register Core Scenes
///
/// 约定映射(文件名 → addressaddress 取自 AddressKeys
/// Persistent.unity → "Scene_Persistent"
/// MainMenu.unity → "Scene_MainMenu"
/// 首个游戏场景Scene_Game_Chapter1因正式关卡命名待定提供单独方法按需注册。
///
/// 仅扫描 Assets/_Game/Scenes 根目录(排除 Testings/ 测试场景,符合 AssetFolderSpec.md §5.2)。
/// </summary>
public static class CoreSceneRegistrar
{
private const string ScenesRoot = "Assets/_Game/Scenes";
private const string GroupName = "Scenes";
[MenuItem("BaseGames/Addressables/Register Core Scenes")]
public static void RegisterCoreScenes()
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
Debug.LogError("[CoreSceneRegistrar] Addressable Settings 未初始化。请先创建 Addressables 配置。");
return;
}
// 文件名(不含扩展名)→ Addressable address
var map = new Dictionary<string, string>
{
{ "Persistent", AddressKeys.ScenePersistent },
{ "MainMenu", AddressKeys.SceneMainMenu },
};
var group = GetOrCreateScenesGroup(settings);
int registered = 0;
var report = new System.Text.StringBuilder("[CoreSceneRegistrar] 核心场景注册结果:\n");
// 仅扫描 ScenesRoot 根目录(不递归到 Testings/
foreach (var path in Directory.GetFiles(ScenesRoot, "*.unity", SearchOption.TopDirectoryOnly))
{
string assetPath = path.Replace('\\', '/');
string name = Path.GetFileNameWithoutExtension(assetPath);
if (!map.TryGetValue(name, out var address)) continue;
RegisterEntry(settings, group, assetPath, address);
report.AppendLine($" ✓ \"{address}\" → {name}.unity");
registered++;
}
if (registered == 0)
report.AppendLine(" (未找到 Persistent.unity / MainMenu.unity请确认场景文件位于 Assets/_Game/Scenes 根目录)");
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
Debug.Log(report.ToString());
}
/// <summary>按指定路径与 address 注册首个游戏场景(正式关卡命名确定后调用)。</summary>
public static void RegisterFirstGameScene(string sceneAssetPath, string address = null)
{
var settings = AddressableAssetSettingsDefaultObject.Settings;
if (settings == null) { Debug.LogError("[CoreSceneRegistrar] Addressable Settings 未初始化。"); return; }
address ??= AddressKeys.SceneGameChapter1;
RegisterEntry(settings, GetOrCreateScenesGroup(settings), sceneAssetPath, address);
EditorUtility.SetDirty(settings);
AssetDatabase.SaveAssets();
Debug.Log($"[CoreSceneRegistrar] 已注册首关场景 \"{address}\" → {sceneAssetPath}");
}
// ── 内部 ──────────────────────────────────────────────────────────────
private static AddressableAssetGroup GetOrCreateScenesGroup(AddressableAssetSettings settings)
{
foreach (var g in settings.groups)
if (g != null && g.name == GroupName) return g;
return settings.CreateGroup(GroupName, false, false, false, null);
}
private static void RegisterEntry(AddressableAssetSettings settings, AddressableAssetGroup group,
string assetPath, string address)
{
string guid = AssetDatabase.AssetPathToGUID(assetPath);
if (string.IsNullOrEmpty(guid)) return;
var entry = settings.FindAssetEntry(guid) ?? settings.CreateOrMoveEntry(guid, group, false, false);
settings.MoveEntry(entry, group, false, false);
entry.address = address;
}
}
}

View File

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

View File

@@ -33,10 +33,13 @@
"BaseGames.World.Map",
"BaseGames.World.Streaming",
"BaseGames.EventChain",
"BaseGames.Inventory",
"BaseGames.VFX",
"BaseGames.Localization",
"Unity.InputSystem",
"Unity.TextMeshPro"
"Unity.TextMeshPro",
"Unity.RenderPipelines.Universal.Runtime",
"MoreMountains.Tools"
],
"includePlatforms": [
"Editor"

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
namespace BaseGames.Editor.Debugging
{
/// <summary>
/// 调试入口:绕过尚在开发中的「新游戏 / 存档槽 / 模式选择」UI
/// 直接加载首关(<see cref="AddressKeys.SceneGameChapter1"/>,当前映射到 TestRoomA并生成玩家
/// 便于在编辑器中快速验证游戏内系统(如地图)。
/// <para>
/// 复刻 <c>MainMenuController.HandleSlotConfirmed</c> 的核心动作:建立内存存档 + 发场景过渡请求。
/// 仅在 Play 模式可用。
/// </para>
/// 菜单BaseGames ▸ Debug ▸ Enter First Room (Play)
/// </summary>
public static class DebugEnterTestRoom
{
private const string MenuPath = "BaseGames/Debug/Enter First Room (Play)";
[MenuItem(MenuPath, priority = 900)]
public static void EnterFirstRoom()
{
if (!Application.isPlaying)
{
Debug.LogWarning("[Debug] 请先进入 Play 模式再使用此入口。");
return;
}
var save = ServiceLocator.GetOrDefault<ISaveService>();
if (save != null && !save.HasSave(0))
save.CreateSlot(0, false); // 建立内存存档,确保新游戏初始状态
// 必须经事件频道 EVT_SceneLoadRequest 发请求SceneService 据此加载场景,
// GameManager 据此驱动状态机 LoadingScene → GameplayHUD/小地图随之显示)。
// 直接调 ISceneService.RequestTransition 会绕过 GameManager 状态机,导致停留在 MainMenu、HUD 隐藏。
var channel = FindSceneLoadChannel();
if (channel == null)
{
Debug.LogError("[Debug] 未找到 EVT_SceneLoadRequestSceneLoadRequestEventChannelSO资产。");
return;
}
channel.Raise(new SceneLoadRequest
{
SceneName = AddressKeys.SceneGameChapter1, // 当前映射到 TestRoomA
EntryTransitionId = null, // 默认出生点
TransitionType = TransitionType.Scene,
ShowLoadingScreen = true,
IsRespawn = false,
});
Debug.Log($"[Debug] 已经事件频道请求加载首关 '{AddressKeys.SceneGameChapter1}',将走 LoadingScene → Gameplay。");
}
/// <summary>加载 EVT_SceneLoadRequest 频道资产(与 GameManager/SceneService 共享同一实例)。</summary>
private static SceneLoadRequestEventChannelSO FindSceneLoadChannel()
{
foreach (var guid in AssetDatabase.FindAssets("t:SceneLoadRequestEventChannelSO"))
{
var path = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<SceneLoadRequestEventChannelSO>(path);
if (asset != null && asset.name == "EVT_SceneLoadRequest") return asset;
}
// 回退:任意同类型频道
foreach (var guid in AssetDatabase.FindAssets("t:SceneLoadRequestEventChannelSO"))
{
var asset = AssetDatabase.LoadAssetAtPath<SceneLoadRequestEventChannelSO>(AssetDatabase.GUIDToAssetPath(guid));
if (asset != null) return asset;
}
return null;
}
[MenuItem(MenuPath, validate = true)]
private static bool Validate() => Application.isPlaying;
}
}
#endif

View File

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

View File

@@ -75,6 +75,7 @@ namespace BaseGames.Editor
// ── UI ────────────────────────────────────────────────────────────
CreateAsset<VoidEventChannelSO> ("UI", "EVT_PauseRequested");
CreateAsset<VoidEventChannelSO> ("UI", "EVT_PauseResumed");
CreateAsset<VoidEventChannelSO> ("UI", "EVT_UICancelPressed"); // ESC / 手柄 B·Circle 全局关闭栈顶面板
CreateAsset<VoidEventChannelSO> ("UI", "EVT_FastTravelOpen");
CreateAsset<StringEventChannelSO> ("UI", "EVT_ShopOpen");
CreateAsset<VoidEventChannelSO> ("UI", "EVT_MapOpen");
@@ -82,6 +83,14 @@ namespace BaseGames.Editor
CreateAsset<BoolEventChannelSO> ("UI", "EVT_InputDeviceChanged");
CreateAsset<BoolEventChannelSO> ("UI", "EVT_SaveIndicatorVisible");
// ── UI / 背包菜单InventoryHub Tab 容器)──────
CreateAsset<VoidEventChannelSO> ("UI/Inventory", "EVT_InventoryOpen"); // 请求打开统一背包菜单
CreateAsset<IntEventChannelSO> ("UI/Inventory", "EVT_InventoryTabChanged"); // 当前激活 Tab 索引变化
CreateAsset<VoidEventChannelSO> ("UI/Inventory", "EVT_InventoryTabNext"); // L/R 肩键:切换到下一 Tab
CreateAsset<VoidEventChannelSO> ("UI/Inventory", "EVT_InventoryTabPrev"); // L/R 肩键:切换到上一 Tab
CreateAsset<StringEventChannelSO> ("UI/Inventory", "EVT_ItemAcquired"); // 道具首次获得itemId
CreateAsset<VoidEventChannelSO> ("UI/Inventory", "EVT_InventoryChanged"); // 背包内容变化(无负载)
// ── 启动流程 / Splash ─────────────────────────────────────────────
CreateAsset<VoidEventChannelSO> ("UI/Splash", "EVT_SplashStartRequest");
CreateAsset<VoidEventChannelSO> ("UI/Splash", "EVT_SplashComplete");
@@ -121,15 +130,16 @@ namespace BaseGames.Editor
// ── 玩家能力 ──────────────────────────────────────────────────────
CreateAsset<TransformEventChannelSO> ("Player", "EVT_PlayerSpawned");
CreateAsset<IntEventChannelSO> ("Player", "EVT_HPChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_MaxHPChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_SoulPowerChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_SpiritPowerChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_SpringChargesChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_LingZhuChanged");
// 状态值频道开启粘性:延迟订阅的 HUD 等 UI 立即获得当前 HP/灵珠/形态等
CreateAsset<IntEventChannelSO> ("Player", "EVT_HPChanged", stickyReplay: true);
CreateAsset<IntEventChannelSO> ("Player", "EVT_MaxHPChanged", stickyReplay: true);
CreateAsset<IntEventChannelSO> ("Player", "EVT_SoulPowerChanged", stickyReplay: true);
CreateAsset<IntEventChannelSO> ("Player", "EVT_SpiritPowerChanged", stickyReplay: true);
CreateAsset<IntEventChannelSO> ("Player", "EVT_SpringChargesChanged", stickyReplay: true);
CreateAsset<IntEventChannelSO> ("Player", "EVT_LingZhuChanged", stickyReplay: true);
CreateAsset<AbilityTypeEventChannelSO> ("Player", "EVT_AbilityUnlocked");
CreateAsset<StringEventChannelSO> ("Player", "EVT_AbilityUnlockedStr");
CreateAsset<IntEventChannelSO> ("Player", "EVT_FormChanged");
CreateAsset<IntEventChannelSO> ("Player", "EVT_FormChanged", stickyReplay: true);
CreateAsset<VoidEventChannelSO> ("Player", "EVT_SkillSetChanged");
// ── 音频 ──────────────────────────────────────────────────────────
@@ -176,14 +186,16 @@ namespace BaseGames.Editor
Debug.Log($"[CreateEventChannelAssets] 已重导入 {count} 个事件资产。");
}
private static void CreateAsset<T>(string subfolder, string assetName) where T : ScriptableObject
private static void CreateAsset<T>(string subfolder, string assetName, bool stickyReplay = false) where T : ScriptableObject
{
string folderPath = $"{RootPath}/{subfolder}";
EnsureDirectory(folderPath);
string fullPath = $"{folderPath}/{assetName}.asset";
if (AssetDatabase.LoadAssetAtPath<T>(fullPath) != null)
var existing = AssetDatabase.LoadAssetAtPath<T>(fullPath);
if (existing != null)
{
if (stickyReplay) ApplyStickyReplay(existing); // 已存在也确保粘性正确(幂等)
Debug.Log($"[CreateEventChannelAssets] 已跳过(已存在): {fullPath}");
return;
}
@@ -197,9 +209,26 @@ namespace BaseGames.Editor
T asset = ScriptableObject.CreateInstance<T>();
AssetDatabase.CreateAsset(asset, fullPath);
if (stickyReplay) ApplyStickyReplay(asset);
Debug.Log($"[CreateEventChannelAssets] 已创建: {fullPath}");
}
/// <summary>
/// 对"状态值"频道开启粘性回放BaseEventChannelSO._replayLastValueToNewSubscribers=true
/// 使延迟启用的 UI如 HUD 在加载阶段未启用、进入 Gameplay 后才订阅)订阅时立即收到当前值。
/// </summary>
private static void ApplyStickyReplay(UnityEngine.Object asset)
{
var so = new SerializedObject(asset);
var prop = so.FindProperty("_replayLastValueToNewSubscribers");
if (prop != null && !prop.boolValue)
{
prop.boolValue = true;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(asset);
}
}
/// <summary>递归创建所有缺失的中间文件夹(使用 AssetDatabase API。</summary>
private static void EnsureDirectory(string path)
{

View File

@@ -0,0 +1,185 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using BaseGames.Localization;
using BaseGames.UI;
namespace BaseGames.Editor
{
/// <summary>
/// 核心呈现层服务装配脚手架:把"本地化 + 输入图标"运行时服务放入 Persistent 的 [Services]
/// 使其在 boot 注册(这些系统已有完整代码,但此前未被实例化进启动场景)。
/// <list type="bullet">
/// <item>LocalizationManagerILocalizationService— 从 Resources/Localization 载 JSON文字本地化才会生效</item>
/// <item>InputDeviceDetector — 检测当前输入设备,广播 EVT_InputDeviceChanged</item>
/// <item>InputIconServiceIInputIconService— 按设备返回按键图标,供 InputIconImage / 交互提示使用</item>
/// </list>
/// 菜单BaseGames ▸ Scene ▸ Setup ▸ Scaffold Localization & Input Services
/// </summary>
public static class CoreUIServicesScaffoldWizard
{
[MenuItem("BaseGames/Scene/Setup/Scaffold Localization & Input Services", priority = 206)]
public static void Scaffold()
{
var report = new List<string>();
Undo.SetCurrentGroupName("Scaffold Localization & Input Services");
int undoGroup = Undo.GetCurrentGroup();
Transform host = FindServicesHost(report);
if (host == null)
{
Debug.LogWarning("[CoreUIServicesScaffold] 未找到 [Services]/[GameManagers] 宿主,已中止。请先执行 Persistent 场景脚手架。");
return;
}
// ── LocalizationManager ───────────────────────────────────────────
var locGo = GetOrCreateChild(host, "LocalizationManager").gameObject;
var loc = GetOrAddComponent<LocalizationManager>(locGo);
AssignAsset(loc, "_languageEventChannel", report, false, "EVT_LanguageChanged"); // 可选SO 驱动 UI 用
// ── InputDeviceDetector ───────────────────────────────────────────
var detGo = GetOrCreateChild(host, "InputDeviceDetector").gameObject;
var det = GetOrAddComponent<InputDeviceDetector>(detGo);
AssignAsset(det, "_onDeviceChanged", report, true, "EVT_InputDeviceChanged");
// ── InputIconService ──────────────────────────────────────────────
var iconGo = GetOrCreateChild(host, "InputIconService").gameObject;
var icon = GetOrAddComponent<InputIconService>(iconGo);
AssignAsset(icon, "_inputReader", report, false, "InputReader");
AssignAsset(icon, "_onDeviceChanged", report, true, "EVT_InputDeviceChanged");
WireIconSets(icon, report);
report.Add("已装配 LocalizationManager / InputDeviceDetector / InputIconService 于 " + GetPath(host) +
"boot 注册 ILocalizationService / IInputIconService。");
report.Add("⚠ 中文字体:需导入 CJK TMP 字体并建 FontConfig.asset否则中文显示为 □(英文不受影响)。");
Undo.CollapseUndoOperations(undoGroup);
MarkDirtyAndLog("Localization & Input Services 脚手架", host.gameObject, report);
}
/// <summary>尝试把 4 套设备图标集 SO 绑定到 InputIconService缺失时报告需用 Input Icon Studio 创建配置)。</summary>
private static void WireIconSets(Object icon, List<string> report)
{
var sets = AssetDatabase.FindAssets("t:InputDeviceIconSetSO");
if (sets == null || sets.Length == 0)
{
report.Add("⚠ 未找到任何 InputDeviceIconSetSO输入图标暂不会显示。" +
"请用 BaseGames ▸ Tools 的 Input Icon Studio 为 键鼠/Xbox/PlayStation/Switch 各建一套并配置 sprite" +
"再赋给 InputIconService 的 _kbMouseSet/_xboxSet/_playStationSet/_switchSet。");
return;
}
// 资产已存在时按 _deviceType 字段匹配赋值
string[] fields = { "_kbMouseSet", "_xboxSet", "_playStationSet", "_switchSet" };
string[] deviceNames = { "KeyboardMouse", "XboxController", "PlayStationController", "SwitchController" };
for (int i = 0; i < fields.Length; i++)
{
Object match = FindIconSetForDevice(sets, deviceNames[i]);
if (match != null) AssignRef(icon, fields[i], match);
else report.Add($"InputIconService.{fields[i]}:未找到设备类型为 {deviceNames[i]} 的 InputDeviceIconSetSO请手动赋值。");
}
}
private static Object FindIconSetForDevice(string[] guids, string deviceEnumName)
{
foreach (var g in guids)
{
var path = AssetDatabase.GUIDToAssetPath(g);
var asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset == null) continue;
var so = new SerializedObject(asset);
var dt = so.FindProperty("_deviceType");
if (dt != null && dt.enumValueIndex >= 0 && dt.enumValueIndex < dt.enumNames.Length &&
dt.enumNames[dt.enumValueIndex] == deviceEnumName)
return asset;
}
return null;
}
// ── 通用辅助(对照 MapManagersScaffoldWizard─────────────────────────
private static Transform FindServicesHost(List<string> report)
{
UnityEngine.SceneManagement.Scene scene = SceneManager.GetActiveScene();
foreach (GameObject root in scene.GetRootGameObjects())
{
var s = root.transform.Find("[Services]");
if (s != null) return s;
var g = root.transform.Find("[GameManagers]");
if (g != null) return g;
if (root.name == "[Services]" || root.name == "[GameManagers]") return root.transform;
}
report.Add("未找到 [Services]/[GameManagers]。");
return null;
}
private static Transform GetOrCreateChild(Transform parent, string name)
{
var child = parent.Find(name);
if (child != null) return child;
var go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return go.transform;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
var c = go.GetComponent<T>();
return c != null ? c : Undo.AddComponent<T>(go);
}
private static void AssignRef(Object target, string propertyName, Object value)
{
var so = new SerializedObject(target);
var prop = so.FindProperty(propertyName);
if (prop == null) { Debug.LogWarning($"[CoreUIServicesScaffold] 未找到属性 {target.GetType().Name}.{propertyName}", target); return; }
prop.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propertyName, List<string> report, bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null)
{
if (required) report.Add($"未找到 {target.GetType().Name}.{propertyName} 所需资产: {string.Join(" / ", candidates)}");
return;
}
AssignRef(target, propertyName, asset);
}
private static Object FindFirstAsset(params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate)) continue;
foreach (string guid in AssetDatabase.FindAssets(candidate))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate) return asset;
}
}
return null;
}
private static string GetPath(Transform t)
{
var stack = new Stack<string>();
for (var cur = t; cur != null; cur = cur.parent) stack.Push(cur.name);
return string.Join("/", stack);
}
private static void MarkDirtyAndLog(string scaffoldName, GameObject root, List<string> report)
{
EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
if (root != null) Selection.activeGameObject = root;
if (report.Count == 0) { Debug.Log($"[CoreUIServicesScaffold] {scaffoldName} 完成。", root); return; }
Debug.LogWarning($"[CoreUIServicesScaffold] {scaffoldName} 完成,以下 {report.Count} 项需手动确认:\n- {string.Join("\n- ", report)}", root);
}
}
}
#endif

View File

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

View File

@@ -1386,10 +1386,15 @@ namespace BaseGames.Editor
SavePoint savePoint = GetOrAddComponent<SavePoint>(go);
// 自动生成唯一 _savePointId场景名 + 短 GUID避免手动填写遗漏导致存档点无法定位/复活
string sceneName = go.scene.IsValid() ? go.scene.name : "Scene";
string uid = System.Guid.NewGuid().ToString("N").Substring(0, 8);
AssignString(savePoint, "_savePointId", $"SP_{sceneName}_{uid}", report);
AssignAsset(savePoint, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
AssignAsset(savePoint, "_onSavePointActivated", report, false, "EVT_SavePointActivated");
report.Add("填写 _savePointId全局唯一字符串,用于存档点激活记录与复活定位)。");
report.Add("已自动生成唯一 _savePointId可按需改为语义化 ID如 SP_Forest_Entrance)。");
Selection.activeGameObject = go;
MarkDirtyAndLog("Save Point", go, report);

View File

@@ -22,6 +22,9 @@ using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Tilemaps;
using UnityEngine.UI;
using BaseGames.Feedback;
using MoreMountains.Feedbacks;
using TMPro;
namespace BaseGames.Editor
{
@@ -72,6 +75,8 @@ namespace BaseGames.Editor
InputReaderBootstrap inputBootstrap = GetOrAddComponent<InputReaderBootstrap>(inputHolderGo);
AssignReference(inputBootstrap, "_inputReader", inputReaderAsset, report);
// 输入模式由游戏状态驱动Gameplay/BossFight→游戏输入其余→UI 输入):绑定状态变化频道
AssignAsset(inputBootstrap, "_onGameStateChanged", report, true, "EVT_GameStateChanged", "EVT_GameState");
if (inputReaderAsset != null)
{
AssignReference(inputReaderAsset, "_onPauseRequested", FindFirstAssetByType<VoidEventChannelSO>("EVT_PauseRequested"), report);
@@ -84,6 +89,9 @@ namespace BaseGames.Editor
UnityEngine.Camera mainCamera = GetOrAddComponent<UnityEngine.Camera>(mainCameraGo);
mainCamera.orthographic = false;
mainCamera.fieldOfView = 60f;
// 2D 游戏使用纯色清除(非 Skybox避免背景层缝隙处露出 skybox/黑色;深蓝灰与场景雾色协调
mainCamera.clearFlags = UnityEngine.CameraClearFlags.SolidColor;
mainCamera.backgroundColor = new Color(0.192f, 0.302f, 0.475f, 1f);
mainCameraGo.tag = "MainCamera";
AudioListener mainCameraAudioListener = GetOrAddComponent<AudioListener>(mainCameraGo);
CinemachineBrain brain = GetOrAddComponent<CinemachineBrain>(mainCameraGo);
@@ -123,6 +131,11 @@ namespace BaseGames.Editor
HUDController hudController = GetOrAddComponent<HUDController>(hudRootGo);
GameObject pauseRootGo = GetOrCreateChild(uiRootGo.transform, "PauseMenuRoot").gameObject;
PauseMenuController pauseMenuCtrl = GetOrAddComponent<PauseMenuController>(pauseRootGo);
Button pauseBtnResume = GetOrAddComponent<Button>(GetOrCreateChild(pauseRootGo.transform, "Btn_Resume").gameObject);
Button pauseBtnSettings = GetOrAddComponent<Button>(GetOrCreateChild(pauseRootGo.transform, "Btn_Settings").gameObject);
Button pauseBtnMainMenu = GetOrAddComponent<Button>(GetOrCreateChild(pauseRootGo.transform, "Btn_MainMenu").gameObject);
Button pauseBtnQuit = GetOrAddComponent<Button>(GetOrCreateChild(pauseRootGo.transform, "Btn_Quit").gameObject);
GameObject settingsRootGo = GetOrCreateChild(uiRootGo.transform, "SettingsRoot").gameObject;
GameObject mapRootGo = GetOrCreateChild(uiRootGo.transform, "MapRoot").gameObject;
GameObject shopRootGo = GetOrCreateChild(uiRootGo.transform, "ShopRoot").gameObject;
@@ -138,6 +151,8 @@ namespace BaseGames.Editor
GameObject respawnButtonGo = GetOrCreateChild(deathRootGo.transform, "RespawnButton").gameObject;
GetOrAddComponent<Image>(respawnButtonGo);
Button respawnButton = GetOrAddComponent<Button>(respawnButtonGo);
GameObject deathMessageGo = GetOrCreateChild(deathRootGo.transform, "DeathMessage").gameObject;
TextMeshProUGUI deathMessage = GetOrAddComponent<TextMeshProUGUI>(deathMessageGo);
// ── BootSequencer启动流程──────────────────────────────────────
GameObject bootSequencerGo = GetOrCreateChild(services, "BootSequencer").gameObject;
@@ -157,12 +172,30 @@ namespace BaseGames.Editor
// ── Canvas_Splash启动演出──────────────────────────────────────
GameObject splashCanvasGo = GetOrCreateCanvas(ui.transform, "Canvas_Splash", 100);
SplashScreenController splashCtrl = GetOrAddComponent<SplashScreenController>(splashCanvasGo);
CanvasGroup splashRootGroup = GetOrAddComponent<CanvasGroup>(splashCanvasGo);
AssignReference(splashCtrl, "_splashRoot", splashRootGroup);
GameObject studioLogoGo = GetOrCreateChild(splashCanvasGo.transform, "StudioLogo").gameObject;
CanvasGroup studioLogoGroup = GetOrAddComponent<CanvasGroup>(studioLogoGo);
AssignReference(splashCtrl, "_studioLogoGroup", studioLogoGroup);
GameObject gameTitleGo = GetOrCreateChild(splashCanvasGo.transform, "GameTitle").gameObject;
CanvasGroup gameTitleGroup = GetOrAddComponent<CanvasGroup>(gameTitleGo);
AssignReference(splashCtrl, "_gameTitleGroup", gameTitleGroup);
AssignAsset(splashCtrl, "_onSplashStartRequest", report, false, "EVT_SplashStartRequest");
AssignAsset(splashCtrl, "_onSplashComplete", report, false, "EVT_SplashComplete");
// ── LoadingScreenManager加载遮罩──────────────────────────────
GameObject loadingCanvasGo = GetOrCreateCanvas(ui.transform, "Canvas_Loading", 99);
LoadingScreenManager loadingMgr = GetOrAddComponent<LoadingScreenManager>(loadingCanvasGo);
GameObject loadingRootGo = GetOrCreateChild(loadingCanvasGo.transform, "LoadingRoot").gameObject;
AssignReference(loadingMgr, "_loadingRoot", loadingRootGo);
GameObject progressFillGo = GetOrCreateChild(loadingRootGo.transform, "ProgressBarFill").gameObject;
Image progressFillImg = GetOrAddComponent<Image>(progressFillGo);
progressFillImg.type = Image.Type.Filled;
progressFillImg.fillMethod = Image.FillMethod.Horizontal;
AssignReference(loadingMgr, "_progressFill", progressFillImg);
GameObject tipTextGo = GetOrCreateChild(loadingRootGo.transform, "TipText").gameObject;
TextMeshProUGUI tipText = GetOrAddComponent<TextMeshProUGUI>(tipTextGo);
AssignReference(loadingMgr, "_tipText", tipText);
AssignAsset(loadingMgr, "_onLoadingStarted", report, false, "EVT_LoadingStarted");
AssignAsset(loadingMgr, "_onLoadingComplete", report, false, "EVT_LoadingComplete");
AssignAsset(loadingMgr, "_onLoadingProgressUpdated", report, false, "EVT_LoadingProgressUpdated");
@@ -173,13 +206,20 @@ namespace BaseGames.Editor
// 实际 UI 效果完全由 SceneFeedback 内部的 MMF_Player 负责。
GameObject fadeCtrGo = GetOrCreateChild(ui.transform, "SYS_SceneFade").gameObject;
SceneFadeController fadeCtr = GetOrAddComponent<SceneFadeController>(fadeCtrGo);
GameObject fadeOutGo = GetOrCreateChild(fadeCtrGo.transform, "FeedbackFadeOut").gameObject;
MMF_Player fadeOutPlayer = GetOrAddComponent<MMF_Player>(fadeOutGo);
SceneFeedback fadeOutFeedback = GetOrAddComponent<SceneFeedback>(fadeOutGo);
AssignReference(fadeOutFeedback, "_player", fadeOutPlayer);
AssignReference(fadeCtr, "_fadeOut", fadeOutFeedback);
GameObject fadeInGo = GetOrCreateChild(fadeCtrGo.transform, "FeedbackFadeIn").gameObject;
MMF_Player fadeInPlayer = GetOrAddComponent<MMF_Player>(fadeInGo);
SceneFeedback fadeInFeedback = GetOrAddComponent<SceneFeedback>(fadeInGo);
AssignReference(fadeInFeedback, "_player", fadeInPlayer);
AssignReference(fadeCtr, "_fadeIn", fadeInFeedback);
AssignAsset(fadeCtr, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
AssignAsset(fadeCtr, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
report.Add("Canvas_Splash请将工作室 Logo CanvasGroup 赋给 _studioLogoGroup游戏标题 CanvasGroup 赋给 _gameTitleGroup。");
report.Add("Canvas_Loading请为 LoadingScreenManager 绑定 _progressBarSlider和 _loadingPanelGameObject。");
report.Add("SYS_SceneFade请创建两个带 MMF_Player 的 SceneFeedback淡出/淡入)," +
"配置完毕后分别拖入 SceneFadeController._fadeOut / _fadeIn。" +
report.Add("SYS_SceneFadeSceneFeedback 子节点已创建并绑定。请在 FeedbackFadeOut / FeedbackFadeIn 的 MMF_Player 中配置所需效果(如全屏黑幕淡入淡出)。" +
"MMF_Player 总时长应 ≤ SceneService._sceneFadeDuration默认 0.4 s。");
EnsureAudioSources(audioManagerGo, audioManager, report);
@@ -204,6 +244,8 @@ namespace BaseGames.Editor
AssignAsset(sceneService, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
AssignAsset(sceneService, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
AssignAsset(sceneService, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
// 场景加载完毕、世界状态恢复后触发;场景物体据此应用存档状态,淡入前保证画面正确
AssignAsset(sceneService, "_onSceneWorldStateRestored", report, true, "EVT_SceneWorldStateRestored");
AssignReference(sceneService, "_sceneLoader", sceneLoader);
AssignAsset(sceneLoader, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
@@ -226,21 +268,46 @@ namespace BaseGames.Editor
AssignAsset(cameraStateController, "_lensConfig", report, false, "CAM_LensConfig", "LensConfig", "CameraLensConfig");
AssignReference(uiManager, "_hudRoot", hudRootGo);
AssignReference(uiManager, "_pauseMenuRoot", pauseRootGo);
AssignReference(uiManager, "_deathScreenRoot", deathRootGo);
AssignReference(uiManager, "_settingsRoot", settingsRootGo);
AssignReference(uiManager, "_mapRoot", mapRootGo);
AssignReference(uiManager, "_shopRoot", shopRootGo);
AssignAsset(uiManager, "_onGameStateChanged", report, true, "EVT_GameStateChanged", "EVT_GameState");
AssignAsset(uiManager, "_onPauseRequested", report, false, "EVT_PauseRequested");
AssignAsset(uiManager, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
AssignAsset(uiManager, "_onShopOpen", report, false, "EVT_ShopOpen");
AssignAsset(uiManager, "_onMapOpen", report, false, "EVT_MapOpen");
AssignReference(uiManager, "_addressablePanelParent", uiRootGo.transform);
{
// UIManager uses _panels (PanelRegistration[]) — NOT individual _pauseMenuRoot/_settingsRoot etc.
var so = new SerializedObject(uiManager);
var panelsProp = so.FindProperty("_panels");
panelsProp.arraySize = 4;
var p0 = panelsProp.GetArrayElementAtIndex(0);
p0.FindPropertyRelative("id").intValue = (int)PanelId.Pause;
p0.FindPropertyRelative("root").objectReferenceValue = pauseRootGo;
var p1 = panelsProp.GetArrayElementAtIndex(1);
p1.FindPropertyRelative("id").intValue = (int)PanelId.Settings;
p1.FindPropertyRelative("root").objectReferenceValue = settingsRootGo;
var p2 = panelsProp.GetArrayElementAtIndex(2);
p2.FindPropertyRelative("id").intValue = (int)PanelId.Map;
p2.FindPropertyRelative("root").objectReferenceValue = mapRootGo;
var p3 = panelsProp.GetArrayElementAtIndex(3);
p3.FindPropertyRelative("id").intValue = (int)PanelId.Shop;
p3.FindPropertyRelative("root").objectReferenceValue = shopRootGo;
so.ApplyModifiedPropertiesWithoutUndo();
}
AssignAsset(uiManager, "_onGameStateChanged", report, true, "EVT_GameStateChanged", "EVT_GameState");
AssignAsset(uiManager, "_onPauseRequested", report, false, "EVT_PauseRequested");
AssignAsset(uiManager, "_onFastTravelOpen", report, false, "EVT_FastTravelOpen");
AssignAsset(uiManager, "_onShopOpen", report, false, "EVT_ShopOpen");
AssignAsset(uiManager, "_onMapOpen", report, false, "EVT_MapOpen");
AssignAsset(uiManager, "_onCharmPanelOpen", report, false, "EVT_CharmPanelOpen");
AssignAsset(uiManager, "_onSpellSelectOpen", report, false, "EVT_SpellSelectOpen");
AssignReference(deathScreenController, "_btnRespawn", respawnButton);
AssignAsset(deathScreenController, "_onPlayerDied", report, true, "EVT_PlayerDied");
AssignReference(deathScreenController, "_deathMessage", deathMessage);
AssignAsset(deathScreenController, "_onDeathScreenConfirmed", report, true, "EVT_DeathScreenConfirmed");
AssignReference(pauseMenuCtrl, "_btnResume", pauseBtnResume);
AssignReference(pauseMenuCtrl, "_btnSettings", pauseBtnSettings);
AssignReference(pauseMenuCtrl, "_btnMainMenu", pauseBtnMainMenu);
AssignReference(pauseMenuCtrl, "_btnQuit", pauseBtnQuit);
AssignAsset(pauseMenuCtrl, "_onResumeRequested", report, false, "EVT_ResumeRequested");
AssignAsset(pauseMenuCtrl, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
AddScaffoldNote(hudRootGo, "HUDController 已挂载。其内部图片/文本/图标 Prefab 依赖较多,需后续手工补 UI 资源与事件频道。", report);
// ── 流式加载系统 ──────────────────────────────────────────────────
@@ -271,54 +338,315 @@ namespace BaseGames.Editor
// ── Canvas_MainMenu排序层 10显示在 HUD 之上)────────────────
GameObject canvasGo = GetOrCreateCanvas(root.transform, "Canvas_MainMenu", 10);
// ── 全屏暗色背景(幽暗基调)────────────────────────────────
GetOrCreateImage(canvasGo.transform, "Background", new Color(0.05f, 0.06f, 0.09f, 1f), false)
.transform.SetAsFirstSibling();
// ── 标题 ──────────────────────────────────────────────────────────
var titleRt = GetOrCreateUIChild(canvasGo.transform, "TitleText");
SetRect(titleRt, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f),
new Vector2(0f, -150f), new Vector2(1400f, 180f));
var titleTmp = GetOrAddComponent<TextMeshProUGUI>(titleRt.gameObject);
titleTmp.text = "ZELING"; titleTmp.fontSize = 130f; titleTmp.fontStyle = FontStyles.Bold;
titleTmp.alignment = TextAlignmentOptions.Center; titleTmp.color = GoldText; titleTmp.raycastTarget = false;
titleTmp.characterSpacing = 14f;
var subtitleRt = GetOrCreateUIChild(canvasGo.transform, "SubtitleText");
SetRect(subtitleRt, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f),
new Vector2(0f, -300f), new Vector2(1000f, 60f));
var subTmp = GetOrAddComponent<TextMeshProUGUI>(subtitleRt.gameObject);
subTmp.text = "A 2D Action Adventure"; subTmp.fontSize = 40f; subTmp.alignment = TextAlignmentOptions.Center;
subTmp.color = new Color(0.7f, 0.66f, 0.55f, 0.9f); subTmp.raycastTarget = false; subTmp.characterSpacing = 8f;
// ── 主菜单控制器 ──────────────────────────────────────────────────
MainMenuController menuCtrl = GetOrAddComponent<MainMenuController>(canvasGo);
AssignAsset(menuCtrl, "_onGameStateChanged", report, false, "EVT_GameStateChanged", "EVT_GameState");
AssignAsset(menuCtrl, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
AssignAsset(menuCtrl, "_onSlotConfirmed", report, false, "EVT_SlotConfirmed");
// ── 主按钮区域 ────────────────────────────────────────────────────
GameObject menuPanelGo = GetOrCreateChild(canvasGo.transform, "MenuPanel").gameObject;
GetOrAddComponent<VerticalLayoutGroup>(menuPanelGo);
// ── 主按钮区域(底部居中竖排,带 CanvasGroup 供入场动画)─────────────
var menuPanelRt = GetOrCreateUIChild(canvasGo.transform, "MenuPanel");
SetRect(menuPanelRt, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f),
new Vector2(0f, 170f), new Vector2(560f, 470f));
GameObject menuPanelGo = menuPanelRt.gameObject;
var menuGroup = GetOrAddComponent<CanvasGroup>(menuPanelGo);
var menuVlg = GetOrAddComponent<VerticalLayoutGroup>(menuPanelGo);
menuVlg.spacing = 12f; menuVlg.childAlignment = TextAnchor.MiddleCenter;
menuVlg.childControlWidth = true; menuVlg.childControlHeight = true;
menuVlg.childForceExpandWidth = true; menuVlg.childForceExpandHeight = false;
GameObject btnNewGameGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_NewGame", "新游戏");
GameObject btnContinueGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Continue", "继续");
GameObject btnSettingsGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Settings", "设置");
GameObject btnCreditsGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Credits", "制作团队");
GameObject btnQuitGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Quit", "退出");
GameObject btnNewGameGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_NewGame", "New Game");
GameObject btnContinueGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Continue", "Continue");
GameObject btnSettingsGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Settings", "Settings");
GameObject btnCreditsGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Credits", "Credits");
GameObject btnQuitGo = GetOrCreateButtonChild(menuPanelGo.transform, "Btn_Quit", "Quit");
foreach (var b in new[] { btnNewGameGo, btnContinueGo, btnSettingsGo, btnCreditsGo, btnQuitGo })
{
StyleAsTextButton(b);
var le = GetOrAddComponent<LayoutElement>(b);
le.preferredHeight = 64f; le.minHeight = 56f;
}
AssignReference(menuCtrl, "_btnNewGame", btnNewGameGo.GetComponent<Button>());
AssignReference(menuCtrl, "_btnContinue", btnContinueGo.GetComponent<Button>());
AssignReference(menuCtrl, "_btnSettings", btnSettingsGo.GetComponent<Button>());
AssignReference(menuCtrl, "_btnCredits", btnCreditsGo.GetComponent<Button>());
AssignReference(menuCtrl, "_btnQuit", btnQuitGo.GetComponent<Button>());
AssignReference(menuCtrl, "_menuPanel", menuPanelGo);
AssignReference(menuCtrl, "_mainButtonsGroup", menuGroup);
AssignReference(menuCtrl, "_mainButtonsRect", menuPanelRt);
// ── SaveSlotPanel ─────────────────────────────────────────────────
GameObject saveSlotPanelGo = GetOrCreateChild(canvasGo.transform, "SaveSlotPanel").gameObject;
// ── SaveSlotPanel(全屏模态:半透明遮罩 + 竖排 3 卡片)──────────────
var saveSlotPanelRt = GetOrCreateUIChild(canvasGo.transform, "SaveSlotPanel");
StretchFull(saveSlotPanelRt);
GameObject saveSlotPanelGo = saveSlotPanelRt.gameObject;
saveSlotPanelGo.SetActive(false);
SaveSlotController saveSlotCtrl = GetOrAddComponent<SaveSlotController>(saveSlotPanelGo);
AssignAsset(saveSlotCtrl, "_onSlotConfirmed", report, false, "EVT_SlotConfirmed");
AssignReference(menuCtrl, "_saveSlotPanel", saveSlotPanelGo);
AssignReference(menuCtrl, "_saveSlotController", saveSlotCtrl);
// 近乎不透明的遮罩(拦截背后点击,并遮住主菜单避免文字透出)
GetOrCreateImage(saveSlotPanelGo.transform, "Overlay", new Color(0.04f, 0.05f, 0.08f, 0.97f), true)
.transform.SetAsFirstSibling();
// 面板标题
var slotTitleRt = GetOrCreateUIChild(saveSlotPanelGo.transform, "PanelTitle");
SetRect(slotTitleRt, new Vector2(0.5f, 1f), new Vector2(0.5f, 1f), new Vector2(0.5f, 1f),
new Vector2(0f, -70f), new Vector2(900f, 80f));
var slotTitleTmp = GetOrAddComponent<TextMeshProUGUI>(slotTitleRt.gameObject);
slotTitleTmp.text = "Select Save"; slotTitleTmp.fontSize = 56f; slotTitleTmp.fontStyle = FontStyles.Bold;
slotTitleTmp.alignment = TextAlignmentOptions.Center; slotTitleTmp.color = GoldText; slotTitleTmp.raycastTarget = false;
// 卡片容器(居中竖排)
var slotsContainerRt = GetOrCreateUIChild(saveSlotPanelGo.transform, "SlotsContainer");
SetRect(slotsContainerRt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f),
new Vector2(0f, -10f), new Vector2(960f, 660f));
var slotsVlg = GetOrAddComponent<VerticalLayoutGroup>(slotsContainerRt.gameObject);
slotsVlg.spacing = 22f; slotsVlg.childAlignment = TextAnchor.MiddleCenter;
slotsVlg.childControlWidth = true; slotsVlg.childControlHeight = true;
slotsVlg.childForceExpandWidth = true; slotsVlg.childForceExpandHeight = false;
// ── 存档槽卡片 Slot_0/1/2挂 SaveSlotUI绑定到 _slotUIs─────────────
var regionRegistry = FindFirstAssetByType<BaseGames.World.Map.RegionRegistrySO>("RegionRegistry");
var slotUIs = new SaveSlotUI[3];
for (int i = 0; i < 3; i++)
slotUIs[i] = BuildSaveSlotCard(slotsContainerRt, i, regionRegistry);
// _slotUIs 数组与默认聚焦按钮
var saveSlotSO = new UnityEditor.SerializedObject(saveSlotCtrl);
var slotUIsProp = saveSlotSO.FindProperty("_slotUIs");
slotUIsProp.arraySize = 3;
for (int i = 0; i < 3; i++)
slotUIsProp.GetArrayElementAtIndex(i).objectReferenceValue = slotUIs[i];
saveSlotSO.ApplyModifiedProperties();
AssignReference(saveSlotCtrl, "_defaultFocusButton",
slotUIs[0].transform.Find("SelectButton")?.GetComponent<Button>());
if (regionRegistry == null)
report.Add("未找到 RegionRegistry 资产SaveSlotUI._regionRegistry 未绑定(存档槽背景图失效)。先运行 BaseGames/Setup/Create Project Assets。");
// 返回按钮(关闭存档槽面板 → 绑定 MainMenuController._btnCloseSaveSlot
GameObject slotBackGo = GetOrCreateButtonChild(saveSlotPanelGo.transform, "BackButton", "Back");
var slotBackRt = (RectTransform)slotBackGo.transform;
SetRect(slotBackRt, new Vector2(0.5f, 0f), new Vector2(0.5f, 0f), new Vector2(0.5f, 0f),
new Vector2(0f, 70f), new Vector2(260f, 64f));
StyleAsTextButton(slotBackGo, 30f);
AssignReference(menuCtrl, "_btnCloseSaveSlot", slotBackGo.GetComponent<Button>());
// ── ConfirmDialog覆盖 / 删除确认)─────────────────────
ConfirmDialogController confirmCtrl = BuildConfirmDialog(saveSlotPanelGo.transform);
AssignReference(saveSlotCtrl, "_confirmDialog", confirmCtrl);
// ── NewGameMode新游戏模式选择普通 / 钢铁之魂)────────────────────
NewGameModeController modeCtrl = BuildNewGameMode(saveSlotPanelGo.transform);
AssignReference(saveSlotCtrl, "_modeSelect", modeCtrl);
// ── SettingsPanel ─────────────────────────────────────────────────
GameObject settingsPanelGo = GetOrCreateChild(canvasGo.transform, "SettingsPanel").gameObject;
settingsPanelGo.SetActive(false);
AssignReference(menuCtrl, "_settingsPanel", settingsPanelGo);
var settingsPanelRt = GetOrCreateUIChild(canvasGo.transform, "SettingsPanel");
StretchFull(settingsPanelRt);
settingsPanelRt.gameObject.SetActive(false);
AssignReference(menuCtrl, "_settingsPanel", settingsPanelRt.gameObject);
// ── CreditsPanel ──────────────────────────────────────────────────
GameObject creditsPanelGo = GetOrCreateChild(canvasGo.transform, "CreditsPanel").gameObject;
creditsPanelGo.SetActive(false);
AssignReference(menuCtrl, "_creditsPanel", creditsPanelGo);
var creditsPanelRt = GetOrCreateUIChild(canvasGo.transform, "CreditsPanel");
StretchFull(creditsPanelRt);
creditsPanelRt.gameObject.SetActive(false);
AssignReference(menuCtrl, "_creditsPanel", creditsPanelRt.gameObject);
report.Add("设置 MainMenuController._firstGameSceneKey 为第一个游戏场景的 Addressable Key字符串。");
report.Add("SaveSlotPanel 需要补充 3 个存档槽 Button 引用_slot0Btn / _slot1Btn / _slot2Btn。");
report.Add("建议为 MenuPanel 添加 RectTransform 入场动画所需的锚点配置,参考 MainMenuController._menuPanel 的偏移量。");
report.Add("存档槽卡片已含完整布局与文本(区域 / 时长 / 时间 / 灵珠 / 生命 / 钢魂徽章),空槽显示\"开始新游戏\"提示。");
report.Add("ConfirmDialog / NewGameMode 已作为 SaveSlotPanel 子节点生成并接线;需补本地化键:"
+ "CONFIRM_OVERWRITE_TITLE / CONFIRM_OVERWRITE_BODY / CONFIRM_DELETE_TITLE / CONFIRM_DELETE_BODY / MODE_STEELSOUL_DESCUI 表)。");
MarkDirtyAndLog("Main Menu 场景脚手架", root, report);
}
// ─────────────────────────────────────────────────────────────────────
// Main Menu — 子结构构建器
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 构建单张存档槽卡片(含背景框 / 全覆盖选择按钮 / 空槽提示 / 有档信息区 / 删除按钮),
/// 并完成 SaveSlotUI 字段绑定。卡片由父容器的 VerticalLayoutGroup 排版,高度经 LayoutElement 固定。
/// </summary>
private static SaveSlotUI BuildSaveSlotCard(Transform parent, int index, Object regionRegistry)
{
var cardRt = GetOrCreateUIChild(parent, $"Slot_{index}");
GameObject slotGo = cardRt.gameObject;
var cardLe = GetOrAddComponent<LayoutElement>(slotGo);
cardLe.preferredHeight = 180f; cardLe.minHeight = 160f;
SaveSlotUI slotUI = GetOrAddComponent<SaveSlotUI>(slotGo);
// 卡片框底(半透明深色,作为按钮 targetGraphic 的视觉基底)
var frameImg = GetOrCreateImage(slotGo.transform, "Frame", new Color(0.12f, 0.13f, 0.18f, 0.92f), false);
frameImg.transform.SetAsFirstSibling();
// 区域背景图(默认隐藏,由 SaveSlotUI.RefreshBackground 控制)
var bgImg = GetOrCreateImage(slotGo.transform, "Background", Color.white, false);
bgImg.type = Image.Type.Simple; bgImg.preserveAspect = true; bgImg.enabled = false;
bgImg.transform.SetSiblingIndex(1);
// 全覆盖选择按钮(透明,金色高亮;位于信息层之下,靠 raycast 接收点击)
GameObject selectGo = GetOrCreateButtonChild(slotGo.transform, "SelectButton", "");
StretchFull((RectTransform)selectGo.transform);
var selImg = selectGo.GetComponent<Image>();
if (selImg != null) selImg.color = new Color(1f, 1f, 1f, 0f);
var selLabel = GetButtonLabel(selectGo);
if (selLabel != null) selLabel.gameObject.SetActive(false);
// 空槽提示
var emptyRt = GetOrCreateUIChild(slotGo.transform, "EmptyIndicator");
StretchFull(emptyRt);
GameObject emptyGo = emptyRt.gameObject;
GetOrCreateText(emptyGo.transform, "EmptyText", "Empty Slot · New Game", 34f,
new Color(0.7f, 0.66f, 0.55f, 0.85f), TextAlignmentOptions.Center);
// 有档信息区(左侧竖排:区域 / 时长 / 时间)+ 右侧(灵珠 / 生命 / 钢魂)
var dataRt = GetOrCreateUIChild(slotGo.transform, "DataIndicator");
StretchFull(dataRt, 28f);
GameObject dataGo = dataRt.gameObject;
var regionText = GetOrCreateText(dataGo.transform, "RegionText", "Region", 38f, GoldText, TextAlignmentOptions.TopLeft);
SetRect((RectTransform)regionText.transform, new Vector2(0f, 1f), new Vector2(0.7f, 1f), new Vector2(0f, 1f), new Vector2(0f, -4f), new Vector2(0f, 48f));
var playtimeText = GetOrCreateText(dataGo.transform, "PlaytimeText", "00:00:00", 26f, new Color(0.8f,0.78f,0.7f,1f), TextAlignmentOptions.TopLeft);
SetRect((RectTransform)playtimeText.transform, new Vector2(0f, 1f), new Vector2(0.7f, 1f), new Vector2(0f, 1f), new Vector2(0f, -58f), new Vector2(0f, 34f));
var lastSavedText= GetOrCreateText(dataGo.transform, "LastSavedText", "—", 22f, new Color(0.6f,0.58f,0.52f,1f), TextAlignmentOptions.TopLeft);
SetRect((RectTransform)lastSavedText.transform, new Vector2(0f, 1f), new Vector2(0.7f, 1f), new Vector2(0f, 1f), new Vector2(0f, -98f), new Vector2(0f, 30f));
var lingZhuText = GetOrCreateText(dataGo.transform, "LingZhuText", "0", 28f, new Color(0.85f,0.8f,0.55f,1f), TextAlignmentOptions.TopRight);
SetRect((RectTransform)lingZhuText.transform, new Vector2(0.7f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(0f, -4f), new Vector2(0f, 40f));
var hpText = GetOrCreateText(dataGo.transform, "HPText", "0", 28f, new Color(0.85f,0.5f,0.5f,1f), TextAlignmentOptions.TopRight);
SetRect((RectTransform)hpText.transform, new Vector2(0.7f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(0f, -48f), new Vector2(0f, 40f));
var badgeRt = GetOrCreateUIChild(dataGo.transform, "SteelSoulBadge");
SetRect(badgeRt, new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 4f), new Vector2(120f, 40f));
GetOrAddComponent<Image>(badgeRt.gameObject).color = new Color(0.5f, 0.55f, 0.6f, 0.5f);
GetOrCreateText(badgeRt.transform, "BadgeText", "STEEL", 22f, new Color(0.85f,0.9f,1f,1f), TextAlignmentOptions.Center);
GameObject badgeGo = badgeRt.gameObject;
// 删除按钮(右上角小 ×
GameObject deleteGo = GetOrCreateButtonChild(slotGo.transform, "DeleteButton", "×");
SetRect((RectTransform)deleteGo.transform, new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(1f, 1f), new Vector2(-10f, -10f), new Vector2(48f, 48f));
var delImg = deleteGo.GetComponent<Image>();
if (delImg != null) delImg.color = new Color(0.4f, 0.12f, 0.12f, 0.7f);
var delLabel = GetButtonLabel(deleteGo);
if (delLabel != null) { delLabel.fontSize = 32f; delLabel.color = new Color(1f, 0.8f, 0.8f, 1f); }
// 绑定 SaveSlotUI 字段
AssignReference(slotUI, "_emptyIndicator", emptyGo);
AssignReference(slotUI, "_dataIndicator", dataGo);
AssignReference(slotUI, "_selectButton", selectGo.GetComponent<Button>());
AssignReference(slotUI, "_deleteButton", deleteGo.GetComponent<Button>());
AssignReference(slotUI, "_backgroundImage", bgImg);
AssignReference(slotUI, "_regionText", regionText);
AssignReference(slotUI, "_playtimeText", playtimeText);
AssignReference(slotUI, "_lastSavedText", lastSavedText);
AssignReference(slotUI, "_lingZhuText", lingZhuText);
AssignReference(slotUI, "_hpText", hpText);
AssignReference(slotUI, "_steelSoulBadge", badgeGo);
if (regionRegistry != null)
AssignReference(slotUI, "_regionRegistry", regionRegistry);
// 初始隐藏数据层(运行时由 Refresh 控制;编辑器下让空槽提示可见)
emptyGo.SetActive(true);
dataGo.SetActive(false);
return slotUI;
}
/// <summary>构建通用确认对话框(居中模态:遮罩 + 对话框 + 标题 / 正文 / 确认 / 取消),返回控制器。</summary>
private static ConfirmDialogController BuildConfirmDialog(Transform parent)
{
var rootRt = GetOrCreateUIChild(parent, "ConfirmDialog");
StretchFull(rootRt);
GameObject confirmGo = rootRt.gameObject;
confirmGo.SetActive(false);
ConfirmDialogController confirmCtrl = GetOrAddComponent<ConfirmDialogController>(confirmGo);
GetOrCreateImage(confirmGo.transform, "Overlay", new Color(0f, 0f, 0f, 0.6f), true).transform.SetAsFirstSibling();
var boxRt = GetOrCreateUIChild(confirmGo.transform, "DialogBox");
SetRect(boxRt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(720f, 380f));
GetOrAddComponent<Image>(boxRt.gameObject).color = new Color(0.10f, 0.11f, 0.15f, 0.98f);
var titleTmp = GetOrCreateText(boxRt.transform, "TitleText", "Confirm", 40f, GoldText, TextAlignmentOptions.Center);
SetRect((RectTransform)titleTmp.transform, new Vector2(0f,1f), new Vector2(1f,1f), new Vector2(0.5f,1f), new Vector2(0f,-50f), new Vector2(-60f,60f));
var bodyTmp = GetOrCreateText(boxRt.transform, "BodyText", "Are you sure?", 28f, new Color(0.82f,0.8f,0.74f,1f), TextAlignmentOptions.Center);
SetRect((RectTransform)bodyTmp.transform, new Vector2(0f,0.5f), new Vector2(1f,0.5f), new Vector2(0.5f,0.5f), new Vector2(0f,10f), new Vector2(-80f,120f));
GameObject yesGo = GetOrCreateButtonChild(boxRt.transform, "Btn_Confirm", "Confirm");
SetRect((RectTransform)yesGo.transform, new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(-150f,40f), new Vector2(220f,64f));
yesGo.GetComponent<Image>().color = new Color(0.45f, 0.12f, 0.12f, 0.85f);
GameObject noGo = GetOrCreateButtonChild(boxRt.transform, "Btn_Cancel", "Cancel");
SetRect((RectTransform)noGo.transform, new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(150f,40f), new Vector2(220f,64f));
AssignReference(confirmCtrl, "_root", confirmGo);
AssignReference(confirmCtrl, "_titleText", titleTmp);
AssignReference(confirmCtrl, "_bodyText", bodyTmp);
AssignReference(confirmCtrl, "_confirmLabel", GetButtonLabel(yesGo));
AssignReference(confirmCtrl, "_cancelLabel", GetButtonLabel(noGo));
AssignReference(confirmCtrl, "_btnConfirm", yesGo.GetComponent<Button>());
AssignReference(confirmCtrl, "_btnCancel", noGo.GetComponent<Button>());
return confirmCtrl;
}
/// <summary>构建新游戏模式选择面板(居中模态:普通 / 钢铁之魂 / 返回 + 钢魂说明),返回控制器。</summary>
private static NewGameModeController BuildNewGameMode(Transform parent)
{
var rootRt = GetOrCreateUIChild(parent, "NewGameMode");
StretchFull(rootRt);
GameObject modeGo = rootRt.gameObject;
modeGo.SetActive(false);
NewGameModeController modeCtrl = GetOrAddComponent<NewGameModeController>(modeGo);
GetOrCreateImage(modeGo.transform, "Overlay", new Color(0f, 0f, 0f, 0.6f), true).transform.SetAsFirstSibling();
var boxRt = GetOrCreateUIChild(modeGo.transform, "DialogBox");
SetRect(boxRt, new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), new Vector2(0.5f, 0.5f), Vector2.zero, new Vector2(760f, 460f));
GetOrAddComponent<Image>(boxRt.gameObject).color = new Color(0.10f, 0.11f, 0.15f, 0.98f);
var modeTitle = GetOrCreateText(boxRt.transform, "TitleText", "Select Mode", 40f, GoldText, TextAlignmentOptions.Center);
SetRect((RectTransform)modeTitle.transform, new Vector2(0f,1f), new Vector2(1f,1f), new Vector2(0.5f,1f), new Vector2(0f,-46f), new Vector2(-60f,56f));
GameObject normalGo = GetOrCreateButtonChild(boxRt.transform, "Btn_Normal", "Normal");
SetRect((RectTransform)normalGo.transform, new Vector2(0.5f,1f), new Vector2(0.5f,1f), new Vector2(0.5f,1f), new Vector2(0f,-130f), new Vector2(420f,66f));
GameObject steelGo = GetOrCreateButtonChild(boxRt.transform, "Btn_SteelSoul", "Steel Soul");
SetRect((RectTransform)steelGo.transform, new Vector2(0.5f,1f), new Vector2(0.5f,1f), new Vector2(0.5f,1f), new Vector2(0f,-206f), new Vector2(420f,66f));
steelGo.GetComponent<Image>().color = new Color(0.30f, 0.33f, 0.40f, 0.85f);
var steelDesc = GetOrCreateText(boxRt.transform, "SteelSoulDesc", "Steel Soul: one life. Death wipes the save.", 22f, new Color(0.8f,0.55f,0.55f,1f), TextAlignmentOptions.Center);
SetRect((RectTransform)steelDesc.transform, new Vector2(0f,0f), new Vector2(1f,0f), new Vector2(0.5f,0f), new Vector2(0f,130f), new Vector2(-80f,60f));
GameObject backGo = GetOrCreateButtonChild(boxRt.transform, "Btn_Back", "Back");
SetRect((RectTransform)backGo.transform, new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0.5f,0f), new Vector2(0f,46f), new Vector2(260f,60f));
StyleAsTextButton(backGo, 28f);
AssignReference(modeCtrl, "_root", modeGo);
AssignReference(modeCtrl, "_btnNormal", normalGo.GetComponent<Button>());
AssignReference(modeCtrl, "_btnSteelSoul", steelGo.GetComponent<Button>());
AssignReference(modeCtrl, "_btnBack", backGo.GetComponent<Button>());
AssignReference(modeCtrl, "_steelSoulDescText", steelDesc);
return modeCtrl;
}
// ─────────────────────────────────────────────────────────────────────
// Scaffold Game Room
// ─────────────────────────────────────────────────────────────────────
@@ -578,7 +906,10 @@ namespace BaseGames.Editor
AssignReference(audioManager, "_bgmSourceA", bgmA);
AssignReference(audioManager, "_bgmSourceB", bgmB);
AssignArrayReferences(audioManager, "_sfxSources", sfxSources, report);
report.Add("AudioManager 已生成 2 个 BGM Source 和 6 个 SFX SourceAudioMixer 仍需手工指定。");
// 尝试自动绑定 AudioMixer 与 AudioConfig缺失时报告需音频资产补齐
AssignReference(audioManager, "_mixer", FindFirstAssetWithExtension(".mixer", "MainAudioMixer", "GameAudioMixer", "AudioMixer"), report);
AssignAsset(audioManager, "_audioConfig", report, false, "AUD_AudioConfig", "AudioConfig");
report.Add("AudioManager 已生成 2 个 BGM Source 和 6 个 SFX Source_mixer/_audioConfig 若缺失需补齐音频资产。");
}
private static GameObject GetOrCreateRoot(string name)
@@ -622,21 +953,140 @@ namespace BaseGames.Editor
Canvas canvas = GetOrAddComponent<Canvas>(canvasGo);
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = sortOrder;
GetOrAddComponent<CanvasScaler>(canvasGo);
var scaler = GetOrAddComponent<CanvasScaler>(canvasGo);
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.MatchWidthOrHeight;
scaler.matchWidthOrHeight = 0.5f;
GetOrAddComponent<GraphicRaycaster>(canvasGo);
return canvasGo;
}
/// <summary>在指定父节点下创建一个带 Button 的菜单按钮子节点(幂等)。文本由美术后续补充。</summary>
// ─────────────────────────────────────────────────────────────────────
// UI 布局辅助RectTransform 感知)
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 创建/获取 UI 子节点并保证其 transform 为 <see cref="RectTransform"/>。
/// 旧的普通 <see cref="Transform"/> 节点无法原地转换,会被销毁并以 RectTransform 重建(含其子树),
/// 以支持脚手架对历史场景的"重建"修复。
/// </summary>
private static RectTransform GetOrCreateUIChild(Transform parent, string name)
{
Transform child = parent.Find(name);
if (child is RectTransform existing) return existing;
if (child != null) Undo.DestroyObjectImmediate(child.gameObject);
GameObject go = new GameObject(name, typeof(RectTransform));
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return (RectTransform)go.transform;
}
/// <summary>将 RectTransform 设为四向拉伸(铺满父节点,可带统一内边距)。</summary>
private static void StretchFull(RectTransform rt, float padding = 0f)
{
rt.anchorMin = Vector2.zero;
rt.anchorMax = Vector2.one;
rt.pivot = new Vector2(0.5f, 0.5f);
rt.offsetMin = new Vector2(padding, padding);
rt.offsetMax = new Vector2(-padding, -padding);
}
/// <summary>按锚点 + 锚定位置 + 尺寸配置 RectTransform。</summary>
private static void SetRect(RectTransform rt, Vector2 anchorMin, Vector2 anchorMax,
Vector2 pivot, Vector2 anchoredPos, Vector2 size)
{
rt.anchorMin = anchorMin;
rt.anchorMax = anchorMax;
rt.pivot = pivot;
rt.anchoredPosition = anchoredPos;
rt.sizeDelta = size;
}
/// <summary>创建/获取一个 <see cref="TextMeshProUGUI"/> 文本子节点(默认铺满父节点、不拦截射线)。</summary>
private static TextMeshProUGUI GetOrCreateText(Transform parent, string name, string text,
float fontSize, Color color, TextAlignmentOptions align = TextAlignmentOptions.Center)
{
RectTransform rt = GetOrCreateUIChild(parent, name);
StretchFull(rt);
var tmp = GetOrAddComponent<TextMeshProUGUI>(rt.gameObject);
tmp.text = text;
tmp.fontSize = fontSize;
tmp.color = color;
tmp.alignment = align;
tmp.enableWordWrapping = true;
tmp.raycastTarget = false;
return tmp;
}
/// <summary>创建/获取一个铺满父节点的 <see cref="Image"/>(纯色块,可作背景 / 遮罩)。</summary>
private static Image GetOrCreateImage(Transform parent, string name, Color color, bool raycastTarget)
{
RectTransform rt = GetOrCreateUIChild(parent, name);
StretchFull(rt);
var img = GetOrAddComponent<Image>(rt.gameObject);
img.color = color;
img.raycastTarget = raycastTarget;
return img;
}
// 金色高亮配色(文字按钮 / 卡片选择)
private static readonly Color GoldText = new Color(0.92f, 0.86f, 0.66f, 1f);
private static readonly Color GoldHighlight = new Color(1f, 0.84f, 0.40f, 0.20f);
private static readonly Color GoldPressed = new Color(1f, 0.84f, 0.40f, 0.34f);
/// <summary>
/// 在父节点下创建/获取一个带 <see cref="Image"/> + <see cref="Button"/> + TMP 文本标签的按钮幂等RectTransform 化)。
/// 默认带低调的深色底 + 金色悬停 / 选中高亮;文本标签位于子节点 "Label"。
/// </summary>
private static GameObject GetOrCreateButtonChild(Transform parent, string name, string label)
{
GameObject go = GetOrCreateChild(parent, name).gameObject;
GetOrAddComponent<Image>(go);
GetOrAddComponent<Button>(go);
_ = label; // 占位:文本内容由美术在 Prefab/Scene 中设置
RectTransform rt = GetOrCreateUIChild(parent, name);
GameObject go = rt.gameObject;
var img = GetOrAddComponent<Image>(go);
img.color = new Color(0.10f, 0.11f, 0.14f, 0.55f);
img.raycastTarget = true;
var btn = GetOrAddComponent<Button>(go);
btn.targetGraphic = img;
var colors = btn.colors;
colors.normalColor = Color.white;
colors.highlightedColor = new Color(1f, 0.95f, 0.80f, 1f);
colors.pressedColor = new Color(1f, 0.84f, 0.40f, 1f);
colors.selectedColor = new Color(1f, 0.95f, 0.80f, 1f);
colors.disabledColor = new Color(0.5f, 0.5f, 0.5f, 0.4f);
colors.fadeDuration = 0.08f;
btn.colors = colors;
// 文本标签(铺满按钮,居中)
GetOrCreateText(go.transform, "Label", label ?? string.Empty, 30f, GoldText, TextAlignmentOptions.Center);
return go;
}
/// <summary>取按钮的 TMP 文本标签GetOrCreateButtonChild 生成的 "Label" 子节点)。</summary>
private static TextMeshProUGUI GetButtonLabel(GameObject buttonGo)
{
var t = buttonGo.transform.Find("Label");
return t != null ? t.GetComponent<TextMeshProUGUI>() : null;
}
/// <summary>将按钮改造为"纯文字"风格(透明底,仅金色高亮),用于主菜单主按钮列表。</summary>
private static void StyleAsTextButton(GameObject buttonGo, float fontSize = 34f)
{
var img = buttonGo.GetComponent<Image>();
if (img != null) img.color = new Color(1f, 1f, 1f, 0f); // 透明底,仍可作 raycast target
var label = GetButtonLabel(buttonGo);
if (label != null)
{
label.fontSize = fontSize;
label.fontStyle = FontStyles.Normal;
label.color = GoldText;
}
}
private static void AssignReference(Object target, string propertyName, Object value)
{
AssignReference(target, propertyName, value, null);

View File

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

View File

@@ -0,0 +1,132 @@
using UnityEditor;
using UnityEngine;
using BaseGames.Core.Save;
using BaseGames.World.Map;
namespace BaseGames.Editor.Setup
{
/// <summary>
/// 一键创建项目所需的非事件频道 ScriptableObject 资产占位件。
/// 菜单BaseGames → Setup → Create Project Assets
///
/// 包含:
/// · Assets/_Game/Data/Save/SaveSecurityConfig.asset — HMAC 密钥容器Addressable "SaveSecurityConfig",运行时经 AssetLoader 加载)
/// · Assets/_Game/Data/World/Map/RegionRegistry.asset — 区域注册表SaveSlotUI 用于查找背景图)
/// · Assets/_Game/Data/World/Map/MapDatabase.asset — 全局地图数据库
/// </summary>
public static class ProjectAssetSetup
{
[MenuItem("BaseGames/Setup/Create Project Assets")]
public static void CreateAll()
{
CreateSaveSecurityConfig();
CreateMapDatabase(); // 先于 RegionRegistry使其能自动绑定 _mapDatabase
CreateRegionRegistry();
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
Debug.Log("[ProjectAssetSetup] 项目资产初始化完成。");
}
// ── SaveSecurityConfig ────────────────────────────────────────────────
private static void CreateSaveSecurityConfig()
{
// 统一走 Addressables不放 Resources。资产置于 Data/Save并标 Addressable 地址 "SaveSecurityConfig"。
const string folder = "Assets/_Game/Data/Save";
const string assetPath = folder + "/SaveSecurityConfig.asset";
EnsureFolder("Assets/_Game/Data", "Save");
if (AssetDatabase.LoadAssetAtPath<SaveSecurityConfig>(assetPath) == null)
{
var cfg = ScriptableObject.CreateInstance<SaveSecurityConfig>();
// HmacKey 留空:开发期游戏正常运行(使用兜底密钥);构建前由 CI/CD 注入。
AssetDatabase.CreateAsset(cfg, assetPath);
Debug.Log($"[ProjectAssetSetup] 已创建 {assetPath}HmacKey 留空,构建前需注入)。");
}
else Debug.Log("[ProjectAssetSetup] SaveSecurityConfig 已存在。");
MarkAddressable(assetPath, "SaveSecurityConfig");
}
/// <summary>将资产标记为 Addressable置于默认组并设置地址。</summary>
private static void MarkAddressable(string assetPath, string address)
{
var settings = UnityEditor.AddressableAssets.AddressableAssetSettingsDefaultObject.Settings;
if (settings == null)
{
Debug.LogWarning($"[ProjectAssetSetup] 无 Addressable 设置,无法标记 '{address}'。");
return;
}
string guid = AssetDatabase.AssetPathToGUID(assetPath);
var entry = settings.CreateOrMoveEntry(guid, settings.DefaultGroup);
entry.SetAddress(address);
}
// ── RegionRegistry ────────────────────────────────────────────────────
private static void CreateRegionRegistry()
{
const string folder = "Assets/_Game/Data/World/Map";
const string assetPath = folder + "/RegionRegistry.asset";
EnsureFolder("Assets/_Game/Data", "World");
EnsureFolder("Assets/_Game/Data/World", "Map");
var registry = AssetDatabase.LoadAssetAtPath<RegionRegistrySO>(assetPath);
bool created = false;
if (registry == null)
{
registry = ScriptableObject.CreateInstance<RegionRegistrySO>();
AssetDatabase.CreateAsset(registry, assetPath);
created = true;
}
// 自动绑定 _mapDatabaseCreateMapDatabase 已先行创建);幂等,已存在的也补绑
var mapDb = AssetDatabase.LoadAssetAtPath<BaseGames.World.Map.MapDatabaseSO>(folder + "/MapDatabase.asset");
if (mapDb != null)
{
var so = new SerializedObject(registry);
var prop = so.FindProperty("_mapDatabase");
if (prop != null && prop.objectReferenceValue != mapDb)
{
prop.objectReferenceValue = mapDb;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(registry);
}
}
Debug.Log($"[ProjectAssetSetup] RegionRegistry {(created ? "" : "")}_mapDatabase {(mapDb != null ? "" : " MapDatabase")}。请在 Inspector 中将 _regions 数组填入所有 RegionDefinitionSO。");
}
// ── MapDatabase ───────────────────────────────────────────────────────
private static void CreateMapDatabase()
{
const string folder = "Assets/_Game/Data/World/Map";
const string assetPath = folder + "/MapDatabase.asset";
if (AssetDatabase.LoadAssetAtPath<BaseGames.World.Map.MapDatabaseSO>(assetPath) != null)
{
Debug.Log("[ProjectAssetSetup] MapDatabase 已存在,跳过。");
return;
}
EnsureFolder("Assets/_Game/Data", "World");
EnsureFolder("Assets/_Game/Data/World", "Map");
var db = ScriptableObject.CreateInstance<BaseGames.World.Map.MapDatabaseSO>();
AssetDatabase.CreateAsset(db, assetPath);
Debug.Log($"[ProjectAssetSetup] 已创建 {assetPath}。");
}
// ── 工具 ──────────────────────────────────────────────────────────────
private static void EnsureFolder(string parent, string newFolder)
{
string fullPath = parent + "/" + newFolder;
if (!AssetDatabase.IsValidFolder(fullPath))
AssetDatabase.CreateFolder(parent, newFolder);
}
}
}

View File

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

View File

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

View File

@@ -204,6 +204,14 @@ namespace BaseGames.Editor.UI
AssignRef(hudCtrl, "_interactPromptWidget", interactWidget);
AssignArrayRefs(hudCtrl, "_formIcons", formImages, report);
// ── HP 格子 / 回春图标 Prefab自动创建并绑定无需手工补──────────
GameObject hpCellPrefab = EnsureHUDIconPrefab("UI_HUD_HPCell",
new Color32(0xD8, 0x3A, 0x3A, 0xFF), new Vector2(40, 40), report); // 面具红
GameObject springPrefab = EnsureHUDIconPrefab("UI_HUD_SpringIcon",
new Color32(0x4A, 0xC8, 0xF0, 0xFF), new Vector2(32, 32), report); // 灵泉青
AssignRef(hudCtrl, "_hpCellPrefab", hpCellPrefab);
AssignRef(hudCtrl, "_springIconPrefab", springPrefab);
// ── 事件频道 ──────────────────────────────────────────────────────
AssignAsset(hudCtrl, "_onHPChanged", report, true, "EVT_HPChanged", "EVT_PlayerHPChanged");
AssignAsset(hudCtrl, "_onMaxHPChanged", report, false, "EVT_MaxHPChanged", "EVT_PlayerMaxHPChanged");
@@ -235,8 +243,7 @@ namespace BaseGames.Editor.UI
AssignAsset(toolHUD, "_onToolUsed", report, false, "EVT_ToolUsed");
// ── 手工步骤说明 ──────────────────────────────────────────────────
report.Add("HUDController._hpCellPrefab请将 HP 格子 Prefab 赋给该字段。");
report.Add("HUDController._springIconPrefab请将回春图标 Prefab 赋给该字段。");
// _hpCellPrefab / _springIconPrefab 已自动创建并绑定(占位红/青方块,美术可替换)。
report.Add("BossHPBar._phaseMarkerPrefab请将阶段标记点 Prefab 赋给该字段。");
report.Add("StatusEffectHUD._slotConfigs请在 Inspector 中配置各状态效果的图标映射。");
@@ -248,6 +255,39 @@ namespace BaseGames.Editor.UI
// Private helpers
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 创建(或复用)一个 HUD 图标 Prefab含 RectTransform + Image + LayoutElement
/// 用作 HUDController._hpCellPrefab / _springIconPrefab。占位纯色方块美术后续可替换 sprite。
/// 路径Assets/_Game/Prefabs/UI/{prefabName}.prefab符合 AssetFolderSpec.md §4 UI 前缀)。
/// </summary>
private static GameObject EnsureHUDIconPrefab(string prefabName, Color color, Vector2 size, List<string> report)
{
const string uiDir = "Assets/_Game/Prefabs/UI";
string path = $"{uiDir}/{prefabName}.prefab";
var existing = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (existing != null) return existing;
if (!AssetDatabase.IsValidFolder("Assets/_Game/Prefabs"))
AssetDatabase.CreateFolder("Assets/_Game", "Prefabs");
if (!AssetDatabase.IsValidFolder(uiDir))
AssetDatabase.CreateFolder("Assets/_Game/Prefabs", "UI");
var go = new GameObject(prefabName, typeof(RectTransform));
var rt = go.GetComponent<RectTransform>();
rt.sizeDelta = size;
var img = go.AddComponent<UnityEngine.UI.Image>();
img.color = color;
var le = go.AddComponent<UnityEngine.UI.LayoutElement>();
le.preferredWidth = size.x;
le.preferredHeight = size.y;
var prefab = PrefabUtility.SaveAsPrefabAsset(go, path);
Object.DestroyImmediate(go);
report.Add($"已自动创建 HUD 图标 Prefab{path}(占位纯色,美术可替换)。");
return prefab;
}
/// <summary>
/// 在活动场景中查找或创建 HUD Canvas。
/// 查找顺序:

View File

@@ -0,0 +1,369 @@
using System.Collections.Generic;
using BaseGames.UI;
using BaseGames.UI.Inventory;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
namespace BaseGames.Editor.UI
{
/// <summary>
/// 统一背包菜单脚手架(暂停菜单的 Tab Hub
/// 在当前活动场景的 UIRoot 下生成 InventoryHub Canvas 完整层级,
/// 创建 Tab 头部栏与各 Tab 内容根,自动绑定事件频道并注册到 UIManager 面板栈。
/// 执行路径BaseGames ▸ Scene ▸ Setup ▸ Scaffold Inventory Hub
///
/// 设计与命名严格对照 <see cref="HUDScaffoldWizard"/>幂等、Undo 友好、报告未尽手动项。
/// </summary>
public static class InventoryHubScaffoldWizard
{
// Tab 顺序Map · Inventory · Tools(护符) · Quests · Options
private static readonly string[] kTabNames = { "Map", "Inventory", "Tools", "Quests", "Options" };
[MenuItem("BaseGames/Scene/Setup/Scaffold Inventory Hub", priority = 204)]
public static void ScaffoldInventoryHub()
{
var report = new List<string>();
Undo.SetCurrentGroupName("Scaffold Inventory Hub");
int undoGroup = Undo.GetCurrentGroup();
// ── Canvas排序层 5HUD 之上、Pause 同级区间)─────────────────────
GameObject canvasGo = GetOrCreateHubCanvas("InventoryHub Canvas", 5);
// ── Hub 根 ─────────────────────────────────────────────────────────
GameObject hubGo = GetOrCreateChild(canvasGo.transform, "InventoryHubRoot").gameObject;
InventoryHubPanel hub = GetOrAddComponent<InventoryHubPanel>(hubGo);
CanvasGroup hubGroup = GetOrAddComponent<CanvasGroup>(hubGo);
RectTransform hubRect = GetOrAddComponent<RectTransform>(hubGo);
// ── Tab 头部栏 ─────────────────────────────────────────────────────
GameObject tabBarGo = GetOrCreateChild(hubGo.transform, "TabBar").gameObject;
var tabBarLayout = GetOrAddComponent<HorizontalLayoutGroup>(tabBarGo);
tabBarLayout.childForceExpandWidth = false;
tabBarLayout.childForceExpandHeight = false;
tabBarLayout.spacing = 8f;
// ── Tab 内容容器 ───────────────────────────────────────────────────
GameObject contentGo = GetOrCreateChild(hubGo.transform, "TabContent").gameObject;
var headerButtons = new Button[kTabNames.Length];
var headerHighlights = new GameObject[kTabNames.Length];
var contentRoots = new GameObject[kTabNames.Length];
for (int i = 0; i < kTabNames.Length; i++)
{
// Tab 头按钮
GameObject headerGo = GetOrCreateChild(tabBarGo.transform, $"Tab_{kTabNames[i]}").gameObject;
GetOrAddComponent<Image>(headerGo);
headerButtons[i] = GetOrAddComponent<Button>(headerGo);
GameObject hlGo = GetOrCreateChild(headerGo.transform, "Highlight").gameObject;
GetOrAddComponent<Image>(hlGo);
hlGo.SetActive(false);
headerHighlights[i] = hlGo;
GameObject labelGo = GetOrCreateChild(headerGo.transform, "Label").gameObject;
var label = GetOrAddComponent<TextMeshProUGUI>(labelGo);
label.text = kTabNames[i];
// Tab 内容根
GameObject tabRoot = GetOrCreateChild(contentGo.transform, $"Content_{kTabNames[i]}").gameObject;
contentRoots[i] = tabRoot;
tabRoot.SetActive(i == 0);
}
// ── 各 Tab 专属内容 ────────────────────────────────────────────────
BuildInventoryTab(contentRoots[1], report); // Inventory
BuildQuestsTab(contentRoots[3], report); // Quests
report.Add("Map TabContent_Map将现有 MapPanel 预制 / 节点作为子物体放入,或在此挂载 MapPanel 组件并配置。");
report.Add("Tools TabContent_Tools将现有 CharmEquipPanel 节点作为子物体放入。");
report.Add("Options TabContent_Options将现有 SettingsPanelController 节点作为子物体放入。");
// ── Hub 字段绑定 ───────────────────────────────────────────────────
AssignRef(hub, "_rootGroup", hubGroup);
AssignRef(hub, "_slideTarget", hubRect);
WriteTabsArray(hub, headerButtons, headerHighlights, contentRoots);
AssignAsset(hub, "_onTabNext", report, false, "EVT_InventoryTabNext");
AssignAsset(hub, "_onTabPrev", report, false, "EVT_InventoryTabPrev");
AssignAsset(hub, "_onTabChanged", report, false, "EVT_InventoryTabChanged");
// ── 注册到 UIManager 面板栈PanelId.Inventory─────────────────────
RegisterInventoryPanel(hubGo, report);
hubGo.SetActive(false); // 面板默认隐藏,由 UIManager.OpenPanel 激活
Undo.CollapseUndoOperations(undoGroup);
MarkDirtyAndLog("Inventory Hub 脚手架", canvasGo, report);
}
// ── Inventory Tab道具背包────────────────────────────────────────────
private static void BuildInventoryTab(GameObject root, List<string> report)
{
ItemInventoryPanel panel = GetOrAddComponent<ItemInventoryPanel>(root);
// 道具网格
GameObject gridGo = GetOrCreateChild(root.transform, "Grid").gameObject;
var grid = GetOrAddComponent<GridLayoutGroup>(gridGo);
grid.cellSize = new Vector2(72, 72);
grid.spacing = new Vector2(8, 8);
// 格子模板kept inactive
GameObject slotGo = GetOrCreateChild(gridGo.transform, "ItemSlotTemplate").gameObject;
ItemSlotView slotView = GetOrAddComponent<ItemSlotView>(slotGo);
GameObject slotIconGo = GetOrCreateChild(slotGo.transform, "Icon").gameObject;
Image slotIcon = GetOrAddComponent<Image>(slotIconGo);
GameObject slotCountGo = GetOrCreateChild(slotGo.transform, "Count").gameObject;
TextMeshProUGUI slotCount = GetOrAddComponent<TextMeshProUGUI>(slotCountGo);
GameObject newBadgeGo = GetOrCreateChild(slotGo.transform, "NewBadge").gameObject;
GetOrAddComponent<Image>(newBadgeGo);
newBadgeGo.SetActive(false);
Button slotBtn = GetOrAddComponent<Button>(slotGo);
GameObject slotHlGo = GetOrCreateChild(slotGo.transform, "SelectedHighlight").gameObject;
GetOrAddComponent<Image>(slotHlGo);
slotHlGo.SetActive(false);
AssignRef(slotView, "_icon", slotIcon);
AssignRef(slotView, "_countText", slotCount);
AssignRef(slotView, "_newBadge", newBadgeGo);
AssignRef(slotView, "_selectButton", slotBtn);
AssignRef(slotView, "_selectedHighlight", slotHlGo);
slotGo.SetActive(false);
// 详情面板
GameObject detailGo = GetOrCreateChild(root.transform, "Detail").gameObject;
GameObject dIconGo = GetOrCreateChild(detailGo.transform, "DetailIcon").gameObject;
Image dIcon = GetOrAddComponent<Image>(dIconGo);
GameObject dNameGo = GetOrCreateChild(detailGo.transform, "DetailName").gameObject;
TextMeshProUGUI dName = GetOrAddComponent<TextMeshProUGUI>(dNameGo);
GameObject dDescGo = GetOrCreateChild(detailGo.transform, "DetailDesc").gameObject;
TextMeshProUGUI dDesc = GetOrAddComponent<TextMeshProUGUI>(dDescGo);
GameObject dCountGo = GetOrCreateChild(detailGo.transform, "DetailCount").gameObject;
TextMeshProUGUI dCount = GetOrAddComponent<TextMeshProUGUI>(dCountGo);
GameObject emptyGo = GetOrCreateChild(root.transform, "EmptyHint").gameObject;
var emptyText = GetOrAddComponent<TextMeshProUGUI>(emptyGo);
emptyText.text = "背包空空如也";
emptyGo.SetActive(false);
AssignRef(panel, "_gridContainer", gridGo.transform);
AssignRef(panel, "_slotTemplate", slotView);
AssignRef(panel, "_detailIcon", dIcon);
AssignRef(panel, "_detailNameText", dName);
AssignRef(panel, "_detailDescText", dDesc);
AssignRef(panel, "_detailCountText", dCount);
AssignRef(panel, "_emptyHint", emptyGo);
AssignAsset(panel, "_onInventoryChanged", report, false, "EVT_InventoryChanged");
}
// ── Quests Tab任务日志───────────────────────────────────────────────
private static void BuildQuestsTab(GameObject root, List<string> report)
{
QuestLogPanel panel = GetOrAddComponent<QuestLogPanel>(root);
GameObject listGo = GetOrCreateChild(root.transform, "QuestList").gameObject;
var listLayout = GetOrAddComponent<VerticalLayoutGroup>(listGo);
listLayout.childForceExpandHeight = false;
listLayout.spacing = 4f;
GameObject rowGo = GetOrCreateChild(listGo.transform, "QuestRowTemplate").gameObject;
GetOrAddComponent<Image>(rowGo);
Button rowBtn = GetOrAddComponent<Button>(rowGo);
GameObject rowLabelGo = GetOrCreateChild(rowGo.transform, "Label").gameObject;
GetOrAddComponent<TextMeshProUGUI>(rowLabelGo);
rowGo.SetActive(false);
GameObject detailGo = GetOrCreateChild(root.transform, "QuestDetail").gameObject;
GameObject titleGo = GetOrCreateChild(detailGo.transform, "Title").gameObject;
TextMeshProUGUI title = GetOrAddComponent<TextMeshProUGUI>(titleGo);
GameObject descGo = GetOrCreateChild(detailGo.transform, "Desc").gameObject;
TextMeshProUGUI desc = GetOrAddComponent<TextMeshProUGUI>(descGo);
GameObject objContainerGo = GetOrCreateChild(detailGo.transform, "Objectives").gameObject;
var objLayout = GetOrAddComponent<VerticalLayoutGroup>(objContainerGo);
objLayout.childForceExpandHeight = false;
objLayout.spacing = 2f;
GameObject objRowGo = GetOrCreateChild(objContainerGo.transform, "ObjectiveRowTemplate").gameObject;
TextMeshProUGUI objRow = GetOrAddComponent<TextMeshProUGUI>(objRowGo);
objRowGo.SetActive(false);
GameObject emptyGo = GetOrCreateChild(root.transform, "EmptyHint").gameObject;
var emptyText = GetOrAddComponent<TextMeshProUGUI>(emptyGo);
emptyText.text = "暂无进行中的任务";
emptyGo.SetActive(false);
AssignRef(panel, "_listContainer", listGo.transform);
AssignRef(panel, "_rowTemplate", rowBtn);
AssignRef(panel, "_detailTitle", title);
AssignRef(panel, "_detailDesc", desc);
AssignRef(panel, "_objectiveContainer", objContainerGo.transform);
AssignRef(panel, "_objectiveRowTemplate", objRow);
AssignRef(panel, "_emptyHint", emptyGo);
AssignAsset(panel, "_onQuestStateChanged", report, false, "EVT_QuestStateChanged");
}
// ── UIManager 注册 ──────────────────────────────────────────────────────
private static void RegisterInventoryPanel(GameObject hubGo, List<string> report)
{
UIManager uiManager = Object.FindFirstObjectByType<UIManager>();
if (uiManager == null)
{
report.Add("未找到 UIManager。请先运行 Scaffold Persistent Scene再重新运行本向导以注册 PanelId.Inventory。");
return;
}
var so = new SerializedObject(uiManager);
var panelsProp = so.FindProperty("_panels");
if (panelsProp == null || !panelsProp.isArray)
{
report.Add("UIManager._panels 字段不可写,未能注册 Inventory 面板。");
return;
}
// 检查是否已注册 PanelId.Inventory幂等
int inventoryId = (int)PanelId.Inventory;
for (int i = 0; i < panelsProp.arraySize; i++)
{
var el = panelsProp.GetArrayElementAtIndex(i);
if (el.FindPropertyRelative("id").intValue == inventoryId)
{
el.FindPropertyRelative("root").objectReferenceValue = hubGo;
so.ApplyModifiedPropertiesWithoutUndo();
return;
}
}
int idx = panelsProp.arraySize;
panelsProp.arraySize = idx + 1;
var added = panelsProp.GetArrayElementAtIndex(idx);
added.FindPropertyRelative("id").intValue = inventoryId;
added.FindPropertyRelative("root").objectReferenceValue = hubGo;
so.ApplyModifiedPropertiesWithoutUndo();
AssignAsset(uiManager, "_onInventoryOpen", report, false, "EVT_InventoryOpen");
}
// ── Tabs 数组写入 ───────────────────────────────────────────────────────
private static void WriteTabsArray(InventoryHubPanel hub, Button[] headers, GameObject[] highlights, GameObject[] contents)
{
var so = new SerializedObject(hub);
var prop = so.FindProperty("_tabs");
if (prop == null || !prop.isArray) return;
prop.arraySize = contents.Length;
for (int i = 0; i < contents.Length; i++)
{
var el = prop.GetArrayElementAtIndex(i);
el.FindPropertyRelative("content").objectReferenceValue = contents[i];
el.FindPropertyRelative("headerButton").objectReferenceValue = headers[i];
el.FindPropertyRelative("headerHighlight").objectReferenceValue = highlights[i];
}
so.ApplyModifiedPropertiesWithoutUndo();
}
// ─────────────────────────────────────────────────────────────────────
// Helpers对照 HUDScaffoldWizard
// ─────────────────────────────────────────────────────────────────────
private static GameObject GetOrCreateHubCanvas(string name, int sortOrder)
{
Scene scene = SceneManager.GetActiveScene();
foreach (GameObject root in scene.GetRootGameObjects())
{
if (root.name == name) return root;
foreach (string path in new[] { $"[UI]/UIRoot/{name}", $"UIRoot/{name}", name })
{
Transform found = root.transform.Find(path);
if (found != null) return found.gameObject;
}
}
Transform uiRoot = null;
foreach (GameObject root in scene.GetRootGameObjects())
{
uiRoot = root.transform.Find("[UI]/UIRoot") ?? root.transform.Find("UIRoot");
if (uiRoot != null) break;
}
if (uiRoot == null)
Debug.LogWarning("[InventoryHubScaffold] 未找到 UIRoot期望 [Persistent]/[UI]/UIRoot。将在场景根创建。建议先运行 Scaffold Persistent Scene。");
GameObject canvasGo = new GameObject(name);
Undo.RegisterCreatedObjectUndo(canvasGo, $"Create {name}");
if (uiRoot != null) canvasGo.transform.SetParent(uiRoot, false);
Canvas canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = sortOrder;
CanvasScaler scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
return canvasGo;
}
private static Transform GetOrCreateChild(Transform parent, string name)
{
Transform child = parent.Find(name);
if (child != null) return child;
GameObject go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return go.transform;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
T c = go.GetComponent<T>();
return c != null ? c : Undo.AddComponent<T>(go);
}
private static void AssignRef(Object target, string propertyName, Object value)
{
var so = new SerializedObject(target);
var prop = so.FindProperty(propertyName);
if (prop == null)
{
Debug.LogWarning($"[InventoryHubScaffold] 未找到属性 {target.GetType().Name}.{propertyName}", target);
return;
}
prop.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propertyName, List<string> report, bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null)
{
if (required) report.Add($"未找到 {target.GetType().Name}.{propertyName} 所需资产: {string.Join(" / ", candidates)}");
else report.Add($"事件频道 {string.Join(" / ", candidates)} 尚未生成,{target.GetType().Name}.{propertyName} 留空。请运行 BaseGames ▸ Events ▸ Create Event Channels。");
return;
}
AssignRef(target, propertyName, asset);
}
private static Object FindFirstAsset(params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate)) continue;
string[] guids = AssetDatabase.FindAssets(candidate);
foreach (string guid in guids)
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate) return asset;
}
}
return null;
}
private static void MarkDirtyAndLog(string scaffoldName, GameObject root, List<string> report)
{
EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
Selection.activeGameObject = root;
if (report.Count == 0)
{
Debug.Log($"[InventoryHubScaffold] {scaffoldName} 完成。", root);
return;
}
Debug.LogWarning($"[InventoryHubScaffold] {scaffoldName} 完成,以下 {report.Count} 项需手动确认:\n- {string.Join("\n- ", report)}", root);
}
}
}

View File

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

View File

@@ -0,0 +1,656 @@
using System.Collections.Generic;
using TMPro;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
using BaseGames.UI;
using BaseGames.UI.Menus;
using BaseGames.World.Map;
using BaseGames.Localization;
namespace BaseGames.Editor.UI
{
/// <summary>
/// 地图 UI 脚手架(对照 <see cref="HUDScaffoldWizard"/>)。
/// 在当前活动场景生成完整的地图 UI 层级与预制,并按规范绑定引用:
/// <list type="bullet">
/// <item>Cell / Pin / ExitConnector 预制 + MapPinConfig 占位资产</item>
/// <item>HUD 下小地图MinimapHUD + MinimapInputHandler</item>
/// <item>Map Canvas 下全屏地图MapPanel + MapInputHandlerPanelStack 管理)</item>
/// <item>传送确认框ConfirmDialogController+ MapTeleportConfirmController接 MapPanel</item>
/// <item>登记 UIManager._panels[Map],绑定 EVT_MapOpen</item>
/// </list>
/// 执行路径BaseGames ▸ Scene ▸ Setup ▸ Scaffold Map UI
/// <para>占位为纯色块/空 Sprite美术后续替换运行依赖 [GameManagers] 下的
/// MapManager / MapPlayerTracker / MapPinManager / TeleportService 已存在(另由 Persistent 脚手架搭建)。</para>
/// </summary>
public static class MapUIScaffoldWizard
{
private const string UiPrefabDir = "Assets/_Game/Prefabs/UI";
private const string MapDataDir = "Assets/_Game/Data/Map";
[MenuItem("BaseGames/Scene/Setup/Scaffold Map UI", priority = 204)]
public static void ScaffoldMapUI()
{
var report = new List<string>();
Undo.SetCurrentGroupName("Scaffold Map UI");
int undoGroup = Undo.GetCurrentGroup();
// ── 共享资产Cell / Pin / Exit 预制 + PinConfig ──────────────────
MapPinConfigSO pinConfig = EnsurePinConfig(report);
GameObject cellPrefab = EnsureCellPrefab(report);
GameObject pinPrefab = EnsureSimpleImagePrefab("UI_Map_Pin",
new Color32(0xF0, 0xC0, 0x40, 0xFF), new Vector2(14, 14), report);
GameObject exitPrefab = EnsureSimpleImagePrefab("UI_Map_ExitConnector",
new Color32(0xC0, 0xC0, 0xC0, 0xCC), new Vector2(8, 16), report);
Sprite playerDotSprite = null; // 占位用纯色,留空即可
// ── 小地图HUD Canvas 下)────────────────────────────────────────
GameObject hudCanvas = FindHudCanvas();
if (hudCanvas == null)
report.Add("未找到 HUD Canvas请先执行 BaseGames/Scene/Setup/Scaffold HUD Canvas本次跳过小地图/区域名搭建。");
else
{
// 按需求不搭建右上角小地图,只保留全屏大地图;保留进入区域时的区域名横幅。
BuildRegionBanner(hudCanvas, report);
report.Add("已跳过小地图MinimapHUD搭建按需求只保留全屏大地图。");
}
// ── 全屏地图(独立 Map CanvasPanelStack 管理)───────────────────
GameObject mapPanelRoot = BuildFullMap(cellPrefab, exitPrefab, pinPrefab, pinConfig, report);
// ── 传送确认框 + 控制器 ───────────────────────────────────────────
BuildTeleportConfirm(mapPanelRoot, report);
// ── 登记 UIManager._panels[Map] + 绑定 EVT_MapOpen ────────────────
RegisterMapPanelWithUIManager(mapPanelRoot, report);
Undo.CollapseUndoOperations(undoGroup);
AssetDatabase.SaveAssets();
MarkDirtyAndLog("Map UI 脚手架", mapPanelRoot != null ? mapPanelRoot : hudCanvas, report);
}
// ─────────────────────────────────────────────────────────────────────
// 预制 / 资产
// ─────────────────────────────────────────────────────────────────────
/// <summary>创建或复用MapRoomCellUI 预制_bg 为 Raycast Target可点击传送其余子图不挡射线。</summary>
private static GameObject EnsureCellPrefab(List<string> report)
{
string path = $"{UiPrefabDir}/UI_Map_RoomCell.prefab";
var existing = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (existing != null) return existing;
EnsureFolder(UiPrefabDir);
var root = new GameObject("UI_Map_RoomCell", typeof(RectTransform));
((RectTransform)root.transform).sizeDelta = new Vector2(32, 32);
// 背景(可见 + 可点击)
var bg = MakeImage(root.transform, "BG", new Color(1f, 1f, 1f, 1f), new Vector2(32, 32), raycast: true);
// 轮廓RawImage默认禁用
var outlineGo = MakeChild(root.transform, "Outline", new Vector2(32, 32));
var outline = outlineGo.AddComponent<RawImage>();
outline.raycastTarget = false; outline.enabled = false;
// 图标 / 高亮 / 雾 / 传送标记(均不挡射线,运行时按需启用)
var icon = MakeImage(root.transform, "Icon", new Color(1, 1, 1, 1), new Vector2(20, 20), raycast: false); icon.enabled = false;
var highlight = MakeImage(root.transform, "Highlight", new Color(1f, 0.9f, 0.2f, 1f),new Vector2(34, 34), raycast: false); highlight.enabled = false;
var fog = MakeImage(root.transform, "Fog", new Color(0, 0, 0, 0.85f), new Vector2(32, 32), raycast: false); fog.enabled = false;
var teleport = MakeImage(root.transform, "TeleportMarker", new Color(0.3f, 0.8f, 1f, 1f), new Vector2(12, 12), raycast: false); teleport.enabled = false;
var cell = root.AddComponent<MapRoomCellUI>();
AssignRef(cell, "_bg", bg);
AssignRef(cell, "_icon", icon);
AssignRef(cell, "_outlineImage", outline);
AssignRef(cell, "_highlight", highlight);
AssignRef(cell, "_fogOverlay", fog);
AssignRef(cell, "_teleportMarker",teleport);
var prefab = PrefabUtility.SaveAsPrefabAsset(root, path);
Object.DestroyImmediate(root);
report.Add($"已创建 Cell 预制:{path}(占位纯色,美术可替换)。");
return prefab;
}
private static GameObject EnsureSimpleImagePrefab(string name, Color color, Vector2 size, List<string> report)
{
string path = $"{UiPrefabDir}/{name}.prefab";
var existing = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (existing != null) return existing;
EnsureFolder(UiPrefabDir);
var go = new GameObject(name, typeof(RectTransform));
((RectTransform)go.transform).sizeDelta = size;
var img = go.AddComponent<Image>();
img.color = color;
img.raycastTarget = false;
var prefab = PrefabUtility.SaveAsPrefabAsset(go, path);
Object.DestroyImmediate(go);
report.Add($"已创建预制:{path}(占位纯色,美术可替换)。");
return prefab;
}
private static MapPinConfigSO EnsurePinConfig(List<string> report)
{
string path = $"{MapDataDir}/MapPinConfig.asset";
var existing = AssetDatabase.LoadAssetAtPath<MapPinConfigSO>(path);
if (existing != null) return existing;
EnsureFolder(MapDataDir);
var so = ScriptableObject.CreateInstance<MapPinConfigSO>();
AssetDatabase.CreateAsset(so, path);
report.Add($"已创建占位 MapPinConfig{path}_entries 为空,请配置 PinType→Sprite 映射)。");
return so;
}
// ─────────────────────────────────────────────────────────────────────
// 小地图HUD
// ─────────────────────────────────────────────────────────────────────
private static void BuildMinimap(GameObject hudCanvas, GameObject cellPrefab,
GameObject pinPrefab, MapPinConfigSO pinConfig, List<string> report)
{
Transform hudRoot = hudCanvas.transform.Find("HUDRoot") ?? hudCanvas.transform;
var minimapGo = GetOrCreateChild(hudRoot, "Minimap").gameObject;
var mmRect = minimapGo.GetComponent<RectTransform>() ?? minimapGo.AddComponent<RectTransform>();
AnchorTopRight(mmRect, new Vector2(180, 180), new Vector2(-16, -16));
// 边框底图(可见,便于定位;美术可替换/移除)
var frame = minimapGo.GetComponent<Image>() ?? minimapGo.AddComponent<Image>();
frame.color = new Color(0f, 0f, 0f, 0.35f);
frame.raycastTarget = false;
// 带 RectMask2D 的内容容器cell 在此平移)
var viewportGo = GetOrCreateChild(minimapGo.transform, "Viewport").gameObject;
var vpRect = viewportGo.GetComponent<RectTransform>() ?? viewportGo.AddComponent<RectTransform>();
StretchFill(vpRect, 6f);
if (viewportGo.GetComponent<RectMask2D>() == null) viewportGo.AddComponent<RectMask2D>();
// 玩家圆点(在容器内)
var playerDot = MakeImage(viewportGo.transform, "PlayerDot", new Color(1f, 0.25f, 0.25f, 1f), new Vector2(8, 8), raycast: false);
var hud = GetOrAddComponent<MinimapHUD>(minimapGo);
var input = GetOrAddComponent<MinimapInputHandler>(minimapGo);
AssignRef(hud, "_cellPrefab", cellPrefab.GetComponent<MapRoomCellUI>());
AssignRef(hud, "_cellContainer",vpRect);
AssignRef(hud, "_playerDot", playerDot);
AssignRef(hud, "_pinPrefab", pinPrefab.GetComponent<Image>());
AssignRef(hud, "_pinConfig", pinConfig);
AssignMapIcons(hud, report);
AssignAsset(input, "_inputReader", report, true, "InputReader");
report.Add("Minimap 已搭建于 HUDRoot右上角占位框。美术可调整位置/尺寸/边框。");
}
/// <summary>进入新区域时屏幕中央渐显区域名横幅RegionNameDisplay挂 HUDRoot。</summary>
private static void BuildRegionBanner(GameObject hudCanvas, List<string> report)
{
Transform hudRoot = hudCanvas.transform.Find("HUDRoot") ?? hudCanvas.transform;
var bannerGo = GetOrCreateChild(hudRoot, "RegionNameBanner").gameObject;
var br = bannerGo.GetComponent<RectTransform>() ?? bannerGo.AddComponent<RectTransform>();
br.anchorMin = br.anchorMax = new Vector2(0.5f, 0.72f);
br.pivot = new Vector2(0.5f, 0.5f);
br.sizeDelta = new Vector2(640f, 90f);
br.anchoredPosition = Vector2.zero;
if (bannerGo.GetComponent<CanvasGroup>() == null) bannerGo.AddComponent<CanvasGroup>();
var txt = MakeText(bannerGo.transform, "RegionText", "区域名");
txt.fontSize = 48;
StretchFill((RectTransform)txt.transform, 0f);
var rnd = GetOrAddComponent<RegionNameDisplay>(bannerGo);
AssignRef(rnd, "_regionText", txt);
AssignAsset(rnd, "_onRegionChanged", report, false, "EVT_RegionChanged");
report.Add("RegionNameDisplay进入区域时渐显区域名横幅已搭建于 HUDRoot。");
}
// ─────────────────────────────────────────────────────────────────────
// 全屏地图(独立 Canvas
// ─────────────────────────────────────────────────────────────────────
private static GameObject BuildFullMap(GameObject cellPrefab, GameObject exitPrefab,
GameObject pinPrefab, MapPinConfigSO pinConfig, List<string> report)
{
GameObject canvasGo = GetOrCreateMapCanvas("Map Canvas", 25);
var panelGo = GetOrCreateChild(canvasGo.transform, "MapPanel").gameObject;
StretchFill(panelGo.GetComponent<RectTransform>() ?? panelGo.AddComponent<RectTransform>(), 0f);
// 半透明全屏底
var panelBg = panelGo.GetComponent<Image>() ?? panelGo.AddComponent<Image>();
panelBg.color = new Color(0.05f, 0.05f, 0.07f, 0.92f);
// ScrollView → Viewport(RectMask2D) → RoomContainer(content)
var scrollGo = GetOrCreateChild(panelGo.transform, "ScrollView").gameObject;
StretchFill(scrollGo.GetComponent<RectTransform>() ?? scrollGo.AddComponent<RectTransform>(), 40f);
var scrollRect = GetOrAddComponent<ScrollRect>(scrollGo);
scrollRect.horizontal = true; scrollRect.vertical = true;
scrollRect.movementType = ScrollRect.MovementType.Clamped;
scrollRect.scrollSensitivity = 0f; // 缩放/平移由 MapInputHandler 处理
var viewportGo = GetOrCreateChild(scrollGo.transform, "Viewport").gameObject;
var vpRect = viewportGo.GetComponent<RectTransform>() ?? viewportGo.AddComponent<RectTransform>();
StretchFill(vpRect, 0f);
if (viewportGo.GetComponent<RectMask2D>() == null) viewportGo.AddComponent<RectMask2D>();
var vpImg = viewportGo.GetComponent<Image>() ?? viewportGo.AddComponent<Image>();
vpImg.color = new Color(0, 0, 0, 0.01f); // 近透明,作为 ScrollRect viewport 的图形
var contentGo = GetOrCreateChild(viewportGo.transform, "RoomContainer").gameObject;
var contentRect = contentGo.GetComponent<RectTransform>() ?? contentGo.AddComponent<RectTransform>();
contentRect.anchorMin = contentRect.anchorMax = new Vector2(0.5f, 0.5f);
contentRect.pivot = new Vector2(0.5f, 0.5f);
contentRect.sizeDelta = new Vector2(4000, 4000);
scrollRect.content = contentRect;
scrollRect.viewport = vpRect;
// 玩家图标content 内)
var playerIcon = MakeImage(contentGo.transform, "PlayerIcon", new Color(1f, 0.3f, 0.3f, 1f), new Vector2(16, 16), raycast: false);
// Tooltip默认隐藏
var tooltipGo = GetOrCreateChild(panelGo.transform, "Tooltip").gameObject;
var ttRect = tooltipGo.GetComponent<RectTransform>() ?? tooltipGo.AddComponent<RectTransform>();
AnchorTopRight(ttRect, new Vector2(240, 60), new Vector2(-20, -20));
var ttImg = tooltipGo.GetComponent<Image>() ?? tooltipGo.AddComponent<Image>();
ttImg.color = new Color(0, 0, 0, 0.8f); ttImg.raycastTarget = false;
var ttText = MakeText(tooltipGo.transform, "Text", "房间");
tooltipGo.SetActive(false);
// 组件
var mapPanel = GetOrAddComponent<MapPanel>(panelGo);
var mapInput = GetOrAddComponent<MapInputHandler>(panelGo);
AssignRef(mapPanel, "_roomContainer", contentRect);
AssignRef(mapPanel, "_cellPrefab", cellPrefab.GetComponent<MapRoomCellUI>());
AssignRef(mapPanel, "_exitConnectorPrefab", exitPrefab.GetComponent<Image>());
AssignRef(mapPanel, "_scrollRect", scrollRect);
AssignRef(mapPanel, "_playerIconImg", playerIcon);
AssignRef(mapPanel, "_pinPrefab", pinPrefab.GetComponent<Image>());
AssignRef(mapPanel, "_pinConfig", pinConfig);
AssignRef(mapPanel, "_tooltipPanel", tooltipGo);
AssignRef(mapPanel, "_tooltipText", ttText);
AssignMapIcons(mapPanel, report);
AssignRefObj(mapPanel, "_iconPlayerPos", null); // 占位玩家图标用纯色,留空
AssignAsset(mapInput, "_inputReader", report, true, "InputReader");
AssignRef(mapInput, "_scrollRect", scrollRect);
AssignRef(mapInput, "_zoomTarget", contentRect);
// ── 探索进度(左上角,全局% + 当前区域%;格式串走本地化 Key──────
var progGo = GetOrCreateChild(panelGo.transform, "ProgressDisplay").gameObject;
var progRect = progGo.GetComponent<RectTransform>() ?? progGo.AddComponent<RectTransform>();
progRect.anchorMin = progRect.anchorMax = new Vector2(0f, 1f);
progRect.pivot = new Vector2(0f, 1f);
progRect.anchoredPosition = new Vector2(28f, -24f);
progRect.sizeDelta = new Vector2(380f, 96f);
var globalTxt = MakeText(progGo.transform, "GlobalProgress", "0%");
var gRt = (RectTransform)globalTxt.transform; gRt.anchorMin = gRt.anchorMax = new Vector2(0f, 1f); gRt.pivot = new Vector2(0f, 1f); gRt.anchoredPosition = Vector2.zero;
globalTxt.alignment = TextAlignmentOptions.TopLeft;
var regionTxt = MakeText(progGo.transform, "RegionProgress", "0%");
var rRt = (RectTransform)regionTxt.transform; rRt.anchorMin = rRt.anchorMax = new Vector2(0f, 1f); rRt.pivot = new Vector2(0f, 1f); rRt.anchoredPosition = new Vector2(0f, -44f);
regionTxt.alignment = TextAlignmentOptions.TopLeft; regionTxt.fontSize = 24;
var prog = GetOrAddComponent<MapProgressDisplay>(progGo);
AssignRef(prog, "_globalProgressText", globalTxt);
AssignRef(prog, "_regionProgressText", regionTxt);
AssignString(prog, "_globalFormat", "MAP_PROGRESS_GLOBAL"); // 本地化 KeyMapProgressDisplay 运行时解析为格式串
AssignString(prog, "_regionFormat", "MAP_PROGRESS_REGION");
AssignAsset(prog, "_onRegionChanged", report, false, "EVT_RegionChanged");
// ── 关闭提示(底部居中):输入图标(随设备自适应) + 本地化标签 ──────
var hintRow = GetOrCreateChild(panelGo.transform, "CloseHint").gameObject;
var hintRowRt = hintRow.GetComponent<RectTransform>() ?? hintRow.AddComponent<RectTransform>();
hintRowRt.anchorMin = hintRowRt.anchorMax = new Vector2(0.5f, 0f);
hintRowRt.pivot = new Vector2(0.5f, 0f);
hintRowRt.anchoredPosition = new Vector2(0f, 24f);
hintRowRt.sizeDelta = new Vector2(360f, 40f);
var hintLayout = GetOrAddComponent<HorizontalLayoutGroup>(hintRow);
hintLayout.childAlignment = TextAnchor.MiddleCenter; hintLayout.spacing = 8f;
hintLayout.childForceExpandWidth = false; hintLayout.childForceExpandHeight = false;
MakeInputIcon(hintRow.transform, "CloseIcon", "Cancel"); // 复用 InputIconImage键鼠/手柄自动显示对应按键图标
var hintLabel = MakeLocalizedText(hintRow.transform, "CloseLabel", "MAP_CLOSE_HINT");
hintLabel.fontSize = 22;
report.Add("关闭提示InputIconImage(动作 'Cancel') + LocalizedText('MAP_CLOSE_HINT')。" +
"动作名需与 InputActions 一致;按键图标需在 InputDeviceIconSetSO 中配置(用 Input Icon Studio。");
panelGo.SetActive(false); // 由 PanelStack 控制显隐
report.Add("MapPanel 已搭建(含探索进度 + 输入图标关闭提示;默认隐藏,由 UIManager PanelStack 管理)。");
return panelGo;
}
// ─────────────────────────────────────────────────────────────────────
// 传送确认框 + 控制器
// ─────────────────────────────────────────────────────────────────────
private static void BuildTeleportConfirm(GameObject mapPanelRoot, List<string> report)
{
if (mapPanelRoot == null) { report.Add("MapPanel 缺失,跳过传送确认框搭建。"); return; }
Transform canvas = mapPanelRoot.transform.parent ?? mapPanelRoot.transform;
// 确认框自包含SetActive 显隐)
var dialogGo = GetOrCreateChild(canvas, "TeleportConfirmDialog").gameObject;
StretchFill(dialogGo.GetComponent<RectTransform>() ?? dialogGo.AddComponent<RectTransform>(), 0f);
var dimImg = dialogGo.GetComponent<Image>() ?? dialogGo.AddComponent<Image>();
dimImg.color = new Color(0, 0, 0, 0.6f);
var boxGo = GetOrCreateChild(dialogGo.transform, "Box").gameObject;
var boxRect = boxGo.GetComponent<RectTransform>() ?? boxGo.AddComponent<RectTransform>();
boxRect.anchorMin = boxRect.anchorMax = new Vector2(0.5f, 0.5f);
boxRect.pivot = new Vector2(0.5f, 0.5f);
boxRect.sizeDelta = new Vector2(520, 260);
var boxImg = boxGo.GetComponent<Image>() ?? boxGo.AddComponent<Image>();
boxImg.color = new Color(0.12f, 0.12f, 0.15f, 1f);
var title = MakeText(boxGo.transform, "Title", "快速传送");
((RectTransform)title.transform).anchoredPosition = new Vector2(0, 90);
var body = MakeText(boxGo.transform, "Body", "传送到该地点?");
var confirmBtn = MakeButton(boxGo.transform, "ConfirmButton", "确认", new Vector2(-110, -90), out TMP_Text confirmLabel);
var cancelBtn = MakeButton(boxGo.transform, "CancelButton", "取消", new Vector2( 110, -90), out TMP_Text cancelLabel);
var dialog = GetOrAddComponent<ConfirmDialogController>(dialogGo);
AssignRef(dialog, "_root", dialogGo);
AssignRef(dialog, "_titleText", title);
AssignRef(dialog, "_bodyText", body);
AssignRef(dialog, "_confirmLabel", confirmLabel);
AssignRef(dialog, "_cancelLabel", cancelLabel);
AssignRef(dialog, "_btnConfirm", confirmBtn);
AssignRef(dialog, "_btnCancel", cancelBtn);
dialogGo.SetActive(false);
// 控制器:接 MapPanel 的 OnTeleportStationSelected
var ctrlGo = GetOrCreateChild(canvas, "MapTeleportConfirmController").gameObject;
var ctrl = GetOrAddComponent<MapTeleportConfirmController>(ctrlGo);
AssignRef(ctrl, "_mapPanel", mapPanelRoot.GetComponent<MapPanel>());
AssignRef(ctrl, "_confirmDialog", dialog);
report.Add("传送确认框 + MapTeleportConfirmController 已搭建并绑定 MapPanel。");
}
// ─────────────────────────────────────────────────────────────────────
// UIManager 登记
// ─────────────────────────────────────────────────────────────────────
private static void RegisterMapPanelWithUIManager(GameObject mapPanelRoot, List<string> report)
{
if (mapPanelRoot == null) return;
var uiManager = Object.FindFirstObjectByType<UIManager>();
if (uiManager == null)
{
report.Add("场景中无 UIManager未登记 PanelId.Map请在 UIManager._panels 手动添加 {Map, MapPanel}。");
return;
}
var so = new SerializedObject(uiManager);
var panels = so.FindProperty("_panels");
if (panels == null || !panels.isArray)
{
report.Add("UIManager._panels 不可写,请手动登记 PanelId.Map。");
}
else
{
// 已登记则跳过
bool exists = false;
for (int i = 0; i < panels.arraySize; i++)
{
var el = panels.GetArrayElementAtIndex(i);
if (el.FindPropertyRelative("id").enumValueIndex == (int)PanelId.Map)
{
el.FindPropertyRelative("root").objectReferenceValue = mapPanelRoot;
exists = true; break;
}
}
if (!exists)
{
int idx = panels.arraySize;
panels.arraySize = idx + 1;
var el = panels.GetArrayElementAtIndex(idx);
el.FindPropertyRelative("id").enumValueIndex = (int)PanelId.Map;
el.FindPropertyRelative("root").objectReferenceValue = mapPanelRoot;
}
so.ApplyModifiedPropertiesWithoutUndo();
report.Add("已登记 UIManager._panels[Map] → MapPanel。");
}
AssignAsset(uiManager, "_onMapOpen", report, false, "EVT_MapOpen", "EVT_OpenMap");
}
// ─────────────────────────────────────────────────────────────────────
// 通用辅助
// ─────────────────────────────────────────────────────────────────────
private static void AssignMapIcons(Object target, List<string> report)
{
AssignAsset(target, "_iconSavePoint", report, false, "ICN_Map_SavePoint", "ICN_SavePoint");
AssignAsset(target, "_iconBossRoom", report, false, "ICN_Map_Boss", "ICN_Boss");
AssignAsset(target, "_iconShop", report, false, "ICN_Map_Shop", "ICN_Shop");
AssignAsset(target, "_iconTeleport", report, false, "ICN_Map_Teleport", "ICN_Teleport");
}
private static GameObject FindHudCanvas()
{
Scene scene = SceneManager.GetActiveScene();
foreach (GameObject root in scene.GetRootGameObjects())
{
if (root.name == "HUD Canvas") return root;
foreach (string path in new[] { "[UI]/UIRoot/HUD Canvas", "UIRoot/HUD Canvas", "HUD Canvas" })
{
var found = root.transform.Find(path);
if (found != null) return found.gameObject;
}
}
return null;
}
private static GameObject GetOrCreateMapCanvas(string name, int sortOrder)
{
Scene scene = SceneManager.GetActiveScene();
Transform uiRoot = null;
foreach (GameObject root in scene.GetRootGameObjects())
{
if (root.name == name) return root;
foreach (string path in new[] { $"[UI]/UIRoot/{name}", $"UIRoot/{name}" })
{
var found = root.transform.Find(path);
if (found != null) return found.gameObject;
}
uiRoot ??= root.transform.Find("[UI]/UIRoot") ?? root.transform.Find("UIRoot");
}
var canvasGo = new GameObject(name);
Undo.RegisterCreatedObjectUndo(canvasGo, $"Create {name}");
if (uiRoot != null) canvasGo.transform.SetParent(uiRoot, false);
var canvas = canvasGo.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = sortOrder;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
canvasGo.AddComponent<GraphicRaycaster>();
return canvasGo;
}
// ── UI 元素构造 ──────────────────────────────────────────────────────
private static GameObject MakeChild(Transform parent, string name, Vector2 size)
{
var go = GetOrCreateChild(parent, name).gameObject;
var rt = go.GetComponent<RectTransform>() ?? go.AddComponent<RectTransform>();
rt.sizeDelta = size;
return go;
}
private static Image MakeImage(Transform parent, string name, Color color, Vector2 size, bool raycast)
{
var go = MakeChild(parent, name, size);
var img = go.GetComponent<Image>() ?? go.AddComponent<Image>();
img.color = color;
img.raycastTarget = raycast;
return img;
}
private static TMP_Text MakeText(Transform parent, string name, string text)
{
var go = MakeChild(parent, name, new Vector2(240, 40));
var t = go.GetComponent<TextMeshProUGUI>() ?? go.AddComponent<TextMeshProUGUI>();
t.text = text;
t.alignment = TextAlignmentOptions.Center;
t.fontSize = 28;
t.raycastTarget = false;
return t;
}
private static Button MakeButton(Transform parent, string name, string label, Vector2 anchoredPos, out TMP_Text labelText)
{
var go = MakeChild(parent, name, new Vector2(160, 56));
((RectTransform)go.transform).anchorMin = ((RectTransform)go.transform).anchorMax = new Vector2(0.5f, 0.5f);
((RectTransform)go.transform).anchoredPosition = anchoredPos;
var img = go.GetComponent<Image>() ?? go.AddComponent<Image>();
img.color = new Color(0.25f, 0.25f, 0.3f, 1f);
var btn = GetOrAddComponent<Button>(go);
btn.targetGraphic = img;
labelText = MakeText(go.transform, "Label", label);
return btn;
}
/// <summary>创建带 LocalizedText 的 TMP 文本(文案随语言切换自动刷新)。返回 TMP_Text 供调整字号/对齐。</summary>
private static TMP_Text MakeLocalizedText(Transform parent, string name, string locKey)
{
var go = MakeChild(parent, name, new Vector2(180f, 36f));
var t = go.GetComponent<TextMeshProUGUI>() ?? go.AddComponent<TextMeshProUGUI>();
t.alignment = TextAlignmentOptions.Center;
t.fontSize = 22;
t.raycastTarget = false;
var lt = GetOrAddComponent<LocalizedText>(go); // RequireComponent<TMP_Text> 已满足
AssignString(lt, "_key", locKey);
return t;
}
/// <summary>创建带 InputIconImage(ByActionName) 的按键图标 Image随当前设备自适应显示。</summary>
private static InputIconImage MakeInputIcon(Transform parent, string name, string actionName)
{
var go = MakeChild(parent, name, new Vector2(36f, 36f));
var img = go.GetComponent<Image>() ?? go.AddComponent<Image>();
img.raycastTarget = false;
var icon = GetOrAddComponent<InputIconImage>(go); // 默认 ByActionName 模式
AssignString(icon, "_actionName", actionName);
return icon;
}
// ── 布局 ──────────────────────────────────────────────────────────────
private static void StretchFill(RectTransform rt, float padding)
{
rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one;
rt.pivot = new Vector2(0.5f, 0.5f);
rt.offsetMin = new Vector2(padding, padding);
rt.offsetMax = new Vector2(-padding, -padding);
}
private static void AnchorTopRight(RectTransform rt, Vector2 size, Vector2 offset)
{
rt.anchorMin = rt.anchorMax = new Vector2(1f, 1f);
rt.pivot = new Vector2(1f, 1f);
rt.sizeDelta = size;
rt.anchoredPosition = offset;
}
// ── 引用 / 资产绑定(对照 HUDScaffoldWizard─────────────────────────
private static Transform GetOrCreateChild(Transform parent, string name)
{
var child = parent.Find(name);
if (child != null) return child;
var go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
go.AddComponent<RectTransform>();
return go.transform;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
var c = go.GetComponent<T>();
return c != null ? c : Undo.AddComponent<T>(go);
}
private static void AssignRef(Object target, string propertyName, Object value)
{
var so = new SerializedObject(target);
var prop = so.FindProperty(propertyName);
if (prop == null)
{
Debug.LogWarning($"[MapUIScaffold] 未找到属性 {target.GetType().Name}.{propertyName}", target);
return;
}
prop.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignRefObj(Object target, string propertyName, Object value) => AssignRef(target, propertyName, value);
private static void AssignString(Object target, string propertyName, string value)
{
var so = new SerializedObject(target);
var prop = so.FindProperty(propertyName);
if (prop == null)
{
Debug.LogWarning($"[MapUIScaffold] 未找到字符串属性 {target.GetType().Name}.{propertyName}", target);
return;
}
prop.stringValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propertyName, List<string> report,
bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null)
{
if (required)
report.Add($"未找到 {target.GetType().Name}.{propertyName} 所需资产: {string.Join(" / ", candidates)}");
return;
}
AssignRef(target, propertyName, asset);
}
private static Object FindFirstAsset(params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate)) continue;
foreach (string guid in AssetDatabase.FindAssets(candidate))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate) return asset;
}
}
return null;
}
private static void EnsureFolder(string folder)
{
if (string.IsNullOrEmpty(folder) || AssetDatabase.IsValidFolder(folder)) return;
var parts = folder.Split('/');
string cur = parts[0];
for (int i = 1; i < parts.Length; i++)
{
string next = $"{cur}/{parts[i]}";
if (!AssetDatabase.IsValidFolder(next)) AssetDatabase.CreateFolder(cur, parts[i]);
cur = next;
}
}
private static void MarkDirtyAndLog(string scaffoldName, GameObject root, List<string> report)
{
EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
if (root != null) Selection.activeGameObject = root;
if (report.Count == 0) { Debug.Log($"[MapUIScaffold] {scaffoldName} 完成。", root); return; }
Debug.LogWarning($"[MapUIScaffold] {scaffoldName} 完成,以下 {report.Count} 项需手动确认:\n- {string.Join("\n- ", report)}", root);
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEngine;
namespace BaseGames.Editor.Validation
{
/// <summary>
/// 资源加载合规校验器:扫描项目脚本,禁止裸 <c>Resources.Load</c> 与散落的 <c>Addressables.*</c> 运行时加载,
/// 强制所有资源管理统一经 <c>BaseGames.Core.Assets.AssetLoader</c> 门面(设计师拖拽的 AssetReference 实例方法不算违规)。
/// <para>
/// 白名单(允许直接用 Addressables
/// <list type="bullet">
/// <item><c>AssetLoader.cs</c> —— 门面本体。</item>
/// <item><c>GameSaveManager.cs</c> —— 位于底层 BaseGames.Core.Save 程序集,无法引用上层门面(循环依赖),已注明的唯一架构豁免点。</item>
/// </list>
/// </para>
/// 菜单BaseGames ▸ Tools ▸ Validation ▸ Validate Resource Usage
/// </summary>
public static class ResourceUsageValidator
{
private const string ScanRoot = "Assets/_Game/Scripts";
// 允许直接调用 Addressables 的文件(门面本体 + 已注明的架构豁免)
private static readonly HashSet<string> Whitelist = new()
{
"AssetLoader.cs",
"GameSaveManager.cs",
};
// 禁止的运行时加载调用
private static readonly Regex ResourcesRe = new(@"\bResources\.(Load|LoadAsync|LoadAll)\b", RegexOptions.Compiled);
private static readonly Regex AddressablesRe = new(
@"\bAddressables\.(LoadAssetAsync|InstantiateAsync|LoadSceneAsync|UnloadSceneAsync|LoadResourceLocationsAsync|DownloadDependenciesAsync|Release|ReleaseInstance)\b",
RegexOptions.Compiled);
[MenuItem("BaseGames/Tools/Validation/Validate Resource Usage")]
public static void Validate()
{
if (!Directory.Exists(ScanRoot))
{
Debug.LogWarning($"[ResourceUsageValidator] 未找到扫描目录 {ScanRoot}。");
return;
}
var resourcesHits = new List<string>();
var addressablesHits = new List<string>();
foreach (var file in Directory.GetFiles(ScanRoot, "*.cs", SearchOption.AllDirectories))
{
string fileName = Path.GetFileName(file);
// 跳过校验器自身:它的注释/消息字符串里含有这些模式字面量(属数据,非真实调用)。
if (fileName == "ResourceUsageValidator.cs") continue;
bool whitelisted = Whitelist.Contains(fileName);
string assetPath = file.Replace('\\', '/');
var lines = File.ReadAllLines(file);
for (int i = 0; i < lines.Length; i++)
{
string line = lines[i];
string trimmed = line.TrimStart();
// 跳过注释行(//、///、* 块注释、/* 开头),避免误报文档/说明
if (trimmed.StartsWith("//") || trimmed.StartsWith("*") || trimmed.StartsWith("/*"))
continue;
if (ResourcesRe.IsMatch(line)) // Resources.Load 一律禁止(含白名单文件)
resourcesHits.Add($"{assetPath}:{i + 1} {trimmed}");
if (!whitelisted && AddressablesRe.IsMatch(line))
addressablesHits.Add($"{assetPath}:{i + 1} {trimmed}");
}
}
int total = resourcesHits.Count + addressablesHits.Count;
if (total == 0)
{
Debug.Log("[ResourceUsageValidator] ✅ 合规:项目脚本无裸 Resources.Load无散落 Addressables 运行时加载(门面与豁免文件除外)。所有资源管理已统一经 AssetLoader。");
return;
}
var sb = new System.Text.StringBuilder();
sb.AppendLine($"[ResourceUsageValidator] ⚠ 发现 {total} 处资源加载违规(应改为经 AssetLoader");
if (resourcesHits.Count > 0)
{
sb.AppendLine($"\n— Resources.Load禁止改用 Addressables 并经 AssetLoader×{resourcesHits.Count}");
foreach (var h in resourcesHits) sb.AppendLine(" • " + h);
}
if (addressablesHits.Count > 0)
{
sb.AppendLine($"\n— 散落的 Addressables.*(改为经 AssetLoader 门面)×{addressablesHits.Count}");
foreach (var h in addressablesHits) sb.AppendLine(" • " + h);
}
Debug.LogWarning(sb.ToString());
}
}
}
#endif

View File

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

View File

@@ -0,0 +1,199 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using BaseGames.World.Map;
namespace BaseGames.Editor.Map
{
/// <summary>
/// 地图运行时管理器脚手架(与 <see cref="BaseGames.Editor.UI.MapUIScaffoldWizard"/> 配套)。
/// 在当前活动场景(应为 Persistent的 [Services] 下放置并绑定地图服务,使其在 boot 注册:
/// <list type="bullet">
/// <item>MapManagerIMapServiceISaveable— 绑 MapDatabase + EVT_RoomEntered/MapUpdated/RegionChanged</item>
/// <item>MapPinManagerIPinServiceISaveable— 无序列化字段</item>
/// <item>TeleportServiceITeleportServiceISaveable— 无序列化字段</item>
/// </list>
/// <para>
/// MapPlayerTrackerIPlayerPositionProvider须挂在<strong>玩家</strong>对象上(依赖 _playerTransform
/// 不在此脚手架放置——见执行后的报告说明。缺它时地图仍会渲染房间,但无玩家定位点 / 传送完成回调。
/// </para>
/// 执行路径BaseGames ▸ Scene ▸ Setup ▸ Scaffold Map Managers
/// </summary>
public static class MapManagersScaffoldWizard
{
[MenuItem("BaseGames/Scene/Setup/Scaffold Map Managers", priority = 205)]
public static void ScaffoldMapManagers()
{
var report = new List<string>();
Undo.SetCurrentGroupName("Scaffold Map Managers");
int undoGroup = Undo.GetCurrentGroup();
Transform host = FindServicesHost(report);
if (host == null)
{
Debug.LogWarning("[MapManagersScaffold] 未找到 [Services]/[GameManagers] 宿主,已中止。请先执行 Persistent 场景脚手架。");
return;
}
// ── MapManager ────────────────────────────────────────────────────
var mapMgrGo = GetOrCreateChild(host, "MapManager").gameObject;
var mapMgr = GetOrAddComponent<MapManager>(mapMgrGo);
var db = ResolveMapDatabase(report);
if (db != null) AssignRef(mapMgr, "_database", db);
AssignAsset(mapMgr, "_onRoomEntered", report, true, "EVT_RoomEntered");
AssignAsset(mapMgr, "_onMapUpdated", report, false, "EVT_MapUpdated");
AssignAsset(mapMgr, "_onRegionChanged", report, false, "EVT_RegionChanged");
// ── MapPinManager ─────────────────────────────────────────────────
var pinGo = GetOrCreateChild(host, "MapPinManager").gameObject;
GetOrAddComponent<MapPinManager>(pinGo);
// ── TeleportService ───────────────────────────────────────────────
var teleGo = GetOrCreateChild(host, "TeleportService").gameObject;
GetOrAddComponent<TeleportService>(teleGo);
report.Add("MapManager / MapPinManager / TeleportService 已放置于 " + GetPath(host) +
"boot 时注册 IMapService / IPinService / ITeleportService。");
report.Add("⚠ MapPlayerTrackerIPlayerPositionProvider未放置它依赖玩家 Transform须挂在玩家对象/预制上," +
"并设置 _playerTransform玩家根与 _databaseOverride同一 MapDatabase。缺它时无玩家定位点与传送完成回调。");
report.Add("提示MapDatabase 当前若为空0 房间),地图不渲染任何房间属正常——需先用 Room Capture Baker + 建 MapRoomDataSO 填充。");
Undo.CollapseUndoOperations(undoGroup);
MarkDirtyAndLog("Map Managers 脚手架", host.gameObject, report);
}
// ── 宿主 / 资产解析 ───────────────────────────────────────────────────
private static Transform FindServicesHost(List<string> report)
{
Scene scene = SceneManager.GetActiveScene();
foreach (GameObject root in scene.GetRootGameObjects())
{
var s = root.transform.Find("[Services]");
if (s != null) return s;
var g = root.transform.Find("[GameManagers]");
if (g != null) return g;
if (root.name == "[Services]" || root.name == "[GameManagers]") return root.transform;
}
report.Add("未找到 [Services]/[GameManagers]。");
return null;
}
private static MapDatabaseSO ResolveMapDatabase(List<string> report)
{
// 优先复用流式系统使用的 Database保证 MapManager 与 RoomStreamingManager 同源
var rsmType = System.Type.GetType("BaseGames.World.Streaming.RoomStreamingManager, BaseGames.World.Streaming");
if (rsmType != null)
{
var rsm = Object.FindFirstObjectByType(rsmType) as Component;
if (rsm != null)
{
var so = new SerializedObject(rsm);
var it = so.GetIterator();
while (it.NextVisible(true))
{
if (it.propertyType == SerializedPropertyType.ObjectReference &&
it.objectReferenceValue is MapDatabaseSO foundDb)
{
report.Add($"MapManager._database 复用 RoomStreamingManager 的库:{AssetDatabase.GetAssetPath(foundDb)}");
return foundDb;
}
}
}
}
// 退回:显式路径 / 全局搜索
var byPath = AssetDatabase.LoadAssetAtPath<MapDatabaseSO>("Assets/_Game/Data/World/Map/MapDatabase.asset");
if (byPath != null) { report.Add("MapManager._database = Assets/_Game/Data/World/Map/MapDatabase.asset"); return byPath; }
foreach (var guid in AssetDatabase.FindAssets("t:MapDatabaseSO"))
{
var p = AssetDatabase.GUIDToAssetPath(guid);
var asset = AssetDatabase.LoadAssetAtPath<MapDatabaseSO>(p);
if (asset != null) { report.Add($"MapManager._database = {p}"); return asset; }
}
report.Add("未找到任何 MapDatabaseSO请手动给 MapManager._database 赋值。");
return null;
}
// ── 通用辅助(对照 HUDScaffoldWizard─────────────────────────────────
private static Transform GetOrCreateChild(Transform parent, string name)
{
var child = parent.Find(name);
if (child != null) return child;
var go = new GameObject(name);
Undo.RegisterCreatedObjectUndo(go, $"Create {name}");
go.transform.SetParent(parent, false);
return go.transform;
}
private static T GetOrAddComponent<T>(GameObject go) where T : Component
{
var c = go.GetComponent<T>();
return c != null ? c : Undo.AddComponent<T>(go);
}
private static void AssignRef(Object target, string propertyName, Object value)
{
var so = new SerializedObject(target);
var prop = so.FindProperty(propertyName);
if (prop == null)
{
Debug.LogWarning($"[MapManagersScaffold] 未找到属性 {target.GetType().Name}.{propertyName}", target);
return;
}
prop.objectReferenceValue = value;
so.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propertyName, List<string> report,
bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null)
{
if (required)
report.Add($"未找到 {target.GetType().Name}.{propertyName} 所需资产: {string.Join(" / ", candidates)}");
return;
}
AssignRef(target, propertyName, asset);
}
private static Object FindFirstAsset(params string[] candidates)
{
foreach (string candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate)) continue;
foreach (string guid in AssetDatabase.FindAssets(candidate))
{
string path = AssetDatabase.GUIDToAssetPath(guid);
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate) return asset;
}
}
return null;
}
private static string GetPath(Transform t)
{
var stack = new Stack<string>();
for (var cur = t; cur != null; cur = cur.parent) stack.Push(cur.name);
return string.Join("/", stack);
}
private static void MarkDirtyAndLog(string scaffoldName, GameObject root, List<string> report)
{
EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
if (root != null) Selection.activeGameObject = root;
if (report.Count == 0) { Debug.Log($"[MapManagersScaffold] {scaffoldName} 完成。", root); return; }
Debug.LogWarning($"[MapManagersScaffold] {scaffoldName} 完成,以下 {report.Count} 项需手动确认:\n- {string.Join("\n- ", report)}", root);
}
}
}
#endif

View File

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

View File

@@ -0,0 +1,468 @@
#if UNITY_EDITOR
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using UnityEngine.SceneManagement;
using BaseGames.Camera;
using BaseGames.World.Map;
namespace BaseGames.Editor.Map
{
/// <summary>
/// 房间底图截图烘焙器(地图系统美术管线第一步)。
/// <para>
/// 逐房间打开其对应场景,用正交相机按"玩家可视范围"<see cref="CameraArea.VisibleBounds"/>
/// 渲染一张截图,输出 PNG 作为<strong>美术加工的底图</strong>——美术在此基础上描绘/风格化后,
/// 覆盖同名 PNG 即可回到游戏(运行时由 <see cref="MapRoomDataSO.RoomOutlineTex"/> 显示)。
/// </para>
/// <para>
/// 本工具<strong>不</strong>生成风格化美术,只提供"分块场景截图"底图。
/// 渲染走 URP<see cref="UniversalRenderPipeline.SingleCameraRequest"/>),编辑器下离屏渲染。
/// </para>
/// 菜单BaseGames/Map/Room Capture Baker
/// </summary>
public class MapRoomCaptureWindow : EditorWindow
{
[MenuItem("BaseGames/Map/Room Capture Baker", priority = 101)]
public static void ShowWindow() => GetWindow<MapRoomCaptureWindow>("房间底图烘焙器");
// ── 配置 ──────────────────────────────────────────────────────────────
private MapDatabaseSO _database;
private string _outputFolder = "Assets/_Game/Art/Map/RoomCaptures";
private float _pixelsPerUnit = 16f;
private int _maxDimension = 2048;
private bool _transparentBackground = true;
private bool _assignToOutlineTex = true;
private bool _addTempGlobalLight = true; // URP 2D 离屏渲染默认偏黑,截图时临时加全局光还原精灵真实色
private float _lightIntensity = 1.2f;
private float _worldUnitsPerCell = 18f; // 与 MapPlayerTracker 一致1 格对应的世界单位
private Vector2 _worldOriginOffset = Vector2.zero; // 与 MapPlayerTracker 一致:世界原点偏移
private Vector2 _scroll;
// ── GUI ───────────────────────────────────────────────────────────────
private void OnGUI()
{
EditorGUILayout.LabelField("房间底图截图烘焙", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"逐房间打开其场景用正交相机按可视范围CameraArea渲染截图 → 输出 PNG默认透明背景。\n" +
"这是给美术加工的『底图』,不是最终美术;美术加工后覆盖同名 PNG 即回到游戏。\n" +
"⚠ 烘焙会临时切换当前打开的场景,完成后自动恢复;请先保存未保存的修改。",
MessageType.Info);
EditorGUI.BeginChangeCheck();
_database = (MapDatabaseSO)EditorGUILayout.ObjectField("Map Database", _database, typeof(MapDatabaseSO), false);
if (EditorGUI.EndChangeCheck()) Repaint();
_outputFolder = EditorGUILayout.TextField("输出目录", _outputFolder);
_pixelsPerUnit = EditorGUILayout.Slider("每世界单位像素", _pixelsPerUnit, 4f, 64f);
_maxDimension = EditorGUILayout.IntSlider("最大边像素", _maxDimension, 256, 4096);
_transparentBackground = EditorGUILayout.Toggle("透明背景", _transparentBackground);
_assignToOutlineTex = EditorGUILayout.Toggle(new GUIContent("回填 RoomOutlineTex占位",
"勾选后将截图直接赋给房间的 RoomOutlineTex 作占位;美术覆盖同名 PNG 后自动生效。"), _assignToOutlineTex);
_addTempGlobalLight = EditorGUILayout.Toggle(new GUIContent("临时全局光(推荐)",
"URP 2D 离屏渲染下精灵偏黑;截图时临时加一盏全局 Light2D 还原真实色,结束即销毁。"), _addTempGlobalLight);
using (new EditorGUI.DisabledScope(!_addTempGlobalLight))
_lightIntensity = EditorGUILayout.Slider("全局光强度", _lightIntensity, 0.5f, 2f);
EditorGUILayout.Space();
bool hasRooms = _database != null && _database.AllRooms != null && _database.AllRooms.Length > 0;
using (new EditorGUI.DisabledScope(!hasRooms))
{
if (GUILayout.Button("烘焙全部房间", GUILayout.Height(28)))
BakeAll();
}
EditorGUILayout.Space();
EditorGUILayout.LabelField("格子布局派生GridPosition / GridSize", EditorStyles.boldLabel);
EditorGUILayout.HelpBox(
"按房间场景的可视范围CameraArea缺则用渲染包围盒÷ 世界单位/格,自动推导 GridPosition/GridSize 写入 MapRoomDataSO免去手填。\n" +
"下方参数须与 Persistent 中 MapPlayerTracker 的 _worldUnitsPerCell / _worldOriginOffset 一致;调整后重新派生即可校准房间占格大小。",
MessageType.None);
_worldUnitsPerCell = EditorGUILayout.FloatField(new GUIContent("世界单位/格", "与 MapPlayerTracker._worldUnitsPerCell 一致"), _worldUnitsPerCell);
_worldOriginOffset = EditorGUILayout.Vector2Field(new GUIContent("世界原点偏移", "与 MapPlayerTracker._worldOriginOffset 一致"), _worldOriginOffset);
if (_worldUnitsPerCell < 0.01f) _worldUnitsPerCell = 0.01f;
using (new EditorGUI.DisabledScope(!hasRooms))
{
if (GUILayout.Button("派生全部房间格子布局", GUILayout.Height(24)))
DeriveGridForRooms(AllRoomsList());
}
if (!hasRooms)
{
EditorGUILayout.HelpBox("请指定含有房间的 MapDatabaseSO。", MessageType.None);
return;
}
EditorGUILayout.Space();
EditorGUILayout.LabelField($"房间({_database.AllRooms.Length}", EditorStyles.boldLabel);
_scroll = EditorGUILayout.BeginScrollView(_scroll);
foreach (var room in _database.AllRooms)
{
if (room == null) continue;
DrawRoomRow(room);
}
EditorGUILayout.EndScrollView();
}
private void DrawRoomRow(MapRoomDataSO room)
{
EditorGUILayout.BeginHorizontal(EditorStyles.helpBox);
// 预览缩略图
Rect thumb = GUILayoutUtility.GetRect(40, 40, GUILayout.Width(40), GUILayout.Height(40));
if (room.RoomOutlineTex != null) GUI.DrawTexture(thumb, room.RoomOutlineTex, ScaleMode.ScaleToFit);
else EditorGUI.DrawRect(thumb, new Color(0f, 0f, 0f, 0.2f));
string scenePath = ResolveScenePath(room.RoomId);
EditorGUILayout.BeginVertical();
EditorGUILayout.LabelField(string.IsNullOrEmpty(room.RoomId) ? "(无 RoomId)" : room.RoomId, EditorStyles.boldLabel);
EditorGUILayout.LabelField(scenePath != null ? Path.GetFileName(scenePath) : "⚠ 未找到同名场景", EditorStyles.miniLabel);
EditorGUILayout.LabelField($"格子 ({room.GridPosition.x},{room.GridPosition.y}) / {room.GridSize.x}×{room.GridSize.y}", EditorStyles.miniLabel);
EditorGUILayout.EndVertical();
using (new EditorGUI.DisabledScope(scenePath == null))
{
if (GUILayout.Button("烘焙", GUILayout.Width(52), GUILayout.Height(50)))
BakeRooms(new List<MapRoomDataSO> { room });
if (GUILayout.Button("派生格子", GUILayout.Width(60), GUILayout.Height(50)))
DeriveGridForRooms(new List<MapRoomDataSO> { room });
}
if (GUILayout.Button("定位", GUILayout.Width(44), GUILayout.Height(50)))
{
Selection.activeObject = room;
EditorGUIUtility.PingObject(room);
}
EditorGUILayout.EndHorizontal();
}
// ── 烘焙流程 ──────────────────────────────────────────────────────────
private void BakeAll()
{
BakeRooms(AllRoomsList());
}
private List<MapRoomDataSO> AllRoomsList()
{
var rooms = new List<MapRoomDataSO>();
if (_database?.AllRooms != null)
foreach (var r in _database.AllRooms)
if (r != null) rooms.Add(r);
return rooms;
}
// ── 格子布局派生 ──────────────────────────────────────────────────────
private void DeriveGridForRooms(List<MapRoomDataSO> rooms)
{
if (rooms == null || rooms.Count == 0) return;
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo()) return;
var setup = EditorSceneManager.GetSceneManagerSetup();
int done = 0, skipped = 0;
try
{
for (int i = 0; i < rooms.Count; i++)
{
var room = rooms[i];
if (room == null) { skipped++; continue; }
string scenePath = ResolveScenePath(room.RoomId);
if (scenePath == null)
{
Debug.LogWarning($"[RoomGrid] '{room.RoomId}':未找到同名场景,跳过。");
skipped++;
continue;
}
EditorUtility.DisplayProgressBar("派生格子布局",
$"{room.RoomId}{i + 1}/{rooms.Count}", (float)i / rooms.Count);
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
if (DeriveGrid(room, scene)) done++; else skipped++;
}
}
finally
{
EditorUtility.ClearProgressBar();
if (setup != null && setup.Length > 0)
EditorSceneManager.RestoreSceneManagerSetup(setup);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
Debug.Log($"[RoomGrid] 派生完成:成功 {done},跳过 {skipped}(世界单位/格={_worldUnitsPerCell},原点偏移={_worldOriginOffset})。");
Repaint();
}
/// <summary>按场景可视范围 ÷ 世界单位/格,推导 GridPosition/GridSize 并写入房间 SOfloor 下界、ceil 上界,取最小覆盖格)。</summary>
private bool DeriveGrid(MapRoomDataSO room, Scene scene)
{
Rect b = ResolveCaptureBounds(scene);
if (b.width <= 0.01f || b.height <= 0.01f)
{
Debug.LogWarning($"[RoomGrid] '{room.RoomId}':场景中无 CameraArea / 可见 Renderer无法推导跳过。");
return false;
}
int gx = Mathf.FloorToInt((b.xMin - _worldOriginOffset.x) / _worldUnitsPerCell);
int gy = Mathf.FloorToInt((b.yMin - _worldOriginOffset.y) / _worldUnitsPerCell);
int gx2 = Mathf.CeilToInt ((b.xMax - _worldOriginOffset.x) / _worldUnitsPerCell);
int gy2 = Mathf.CeilToInt ((b.yMax - _worldOriginOffset.y) / _worldUnitsPerCell);
var pos = new Vector2Int(gx, gy);
var size = new Vector2Int(Mathf.Max(1, gx2 - gx), Mathf.Max(1, gy2 - gy));
Undo.RecordObject(room, "Derive Map Grid Layout");
var so = new SerializedObject(room);
so.FindProperty("GridPosition").vector2IntValue = pos;
so.FindProperty("GridSize").vector2IntValue = size;
so.ApplyModifiedProperties();
EditorUtility.SetDirty(room);
Debug.Log($"[RoomGrid] '{room.RoomId}' → GridPos=({pos.x},{pos.y}) GridSize=({size.x},{size.y})");
return true;
}
private void BakeRooms(List<MapRoomDataSO> rooms)
{
if (rooms == null || rooms.Count == 0) return;
// 未保存修改保护:让用户决定保存/取消
if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
return;
var setup = EditorSceneManager.GetSceneManagerSetup(); // 记录当前场景布局,结束后恢复
EnsureFolder(_outputFolder);
int done = 0, skipped = 0;
try
{
for (int i = 0; i < rooms.Count; i++)
{
var room = rooms[i];
if (room == null) { skipped++; continue; }
string scenePath = ResolveScenePath(room.RoomId);
if (scenePath == null)
{
Debug.LogWarning($"[RoomCapture] '{room.RoomId}':未找到同名场景,跳过。");
skipped++;
continue;
}
EditorUtility.DisplayProgressBar("房间底图烘焙",
$"{room.RoomId}{i + 1}/{rooms.Count}", (float)i / rooms.Count);
var scene = EditorSceneManager.OpenScene(scenePath, OpenSceneMode.Single);
if (CaptureRoom(room, scene)) done++; else skipped++;
}
}
finally
{
EditorUtility.ClearProgressBar();
if (setup != null && setup.Length > 0)
EditorSceneManager.RestoreSceneManagerSetup(setup);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
Debug.Log($"[RoomCapture] 完成:成功 {done},跳过 {skipped}。输出 → {_outputFolder}");
Repaint();
}
private bool CaptureRoom(MapRoomDataSO room, Scene scene)
{
Rect bounds = ResolveCaptureBounds(scene);
if (bounds.width <= 0.01f || bounds.height <= 0.01f)
{
Debug.LogWarning($"[RoomCapture] '{room.RoomId}':场景中无 CameraArea / 可见 Renderer无法取景跳过。");
return false;
}
// 分辨率:按比例 + 限制最大边
int w = Mathf.Max(1, Mathf.RoundToInt(bounds.width * _pixelsPerUnit));
int h = Mathf.Max(1, Mathf.RoundToInt(bounds.height * _pixelsPerUnit));
float clamp = Mathf.Min(1f, (float)_maxDimension / Mathf.Max(w, h));
w = Mathf.Max(1, Mathf.RoundToInt(w * clamp));
h = Mathf.Max(1, Mathf.RoundToInt(h * clamp));
RenderTexture rt = null;
GameObject camGo = null;
GameObject lightGo = null;
RenderTexture prevActive = RenderTexture.active;
try
{
rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32);
// URP 2D 离屏渲染默认偏黑:临时全局 Light2D 还原精灵真实色finally 中销毁
if (_addTempGlobalLight)
{
lightGo = new GameObject("__MapCaptureLight") { hideFlags = HideFlags.HideAndDontSave };
var l2d = lightGo.AddComponent<Light2D>();
l2d.lightType = Light2D.LightType.Global;
l2d.intensity = _lightIntensity;
l2d.color = Color.white;
}
camGo = new GameObject("__MapCaptureCamera") { hideFlags = HideFlags.HideAndDontSave };
var cam = camGo.AddComponent<UnityEngine.Camera>();
cam.orthographic = true;
cam.orthographicSize = bounds.height * 0.5f;
cam.aspect = bounds.width / bounds.height;
cam.transform.position = new Vector3(bounds.center.x, bounds.center.y, -100f);
cam.nearClipPlane = 0.01f;
cam.farClipPlane = 1000f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.backgroundColor = _transparentBackground
? new Color(0f, 0f, 0f, 0f)
: new Color(0.10f, 0.10f, 0.12f, 1f);
int uiMask = LayerMask.GetMask("UI");
cam.cullingMask = uiMask != 0 ? ~uiMask : ~0; // 不拍 UI 层
cam.targetTexture = rt;
RenderCameraURP(cam, rt);
RenderTexture.active = rt;
var tex = new Texture2D(w, h, TextureFormat.RGBA32, false);
tex.ReadPixels(new Rect(0f, 0f, w, h), 0, 0);
tex.Apply();
byte[] png = tex.EncodeToPNG();
Object.DestroyImmediate(tex);
string assetPath = $"{_outputFolder}/{room.RoomId}.png";
File.WriteAllBytes(Path.Combine(Directory.GetCurrentDirectory(), assetPath), png);
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate);
ConfigureTextureImport(assetPath);
if (_assignToOutlineTex)
{
var imported = AssetDatabase.LoadAssetAtPath<Texture2D>(assetPath);
if (imported != null)
{
room.RoomOutlineTex = imported;
EditorUtility.SetDirty(room);
}
}
return true;
}
catch (System.Exception ex)
{
Debug.LogError($"[RoomCapture] '{room.RoomId}' 截图失败:{ex.Message}\n{ex.StackTrace}");
return false;
}
finally
{
RenderTexture.active = prevActive;
if (rt != null) Object.DestroyImmediate(rt);
if (camGo != null) Object.DestroyImmediate(camGo);
if (lightGo != null) Object.DestroyImmediate(lightGo);
}
}
/// <summary>URP 离屏渲染:优先官方 SubmitRenderRequestURP 14回退 Camera.Render()。</summary>
private static void RenderCameraURP(UnityEngine.Camera cam, RenderTexture rt)
{
var request = new UniversalRenderPipeline.SingleCameraRequest { destination = rt };
if (RenderPipeline.SupportsRenderRequest(cam, request))
RenderPipeline.SubmitRenderRequest(cam, request);
else
cam.Render(); // 非 URP 或不支持时回退
}
// ── 取景范围 ──────────────────────────────────────────────────────────
/// <summary>
/// 解析场景取景范围(世界 Rect
/// ① 全部 CameraArea.VisibleBounds 的并集(最贴近玩家所见);
/// ② 退回到场景内所有可见 Renderer 的包围盒。
/// </summary>
private static Rect ResolveCaptureBounds(Scene scene)
{
bool has = false;
Rect union = default;
foreach (var area in GetComponentsInScene<CameraArea>(scene))
{
if (area == null) continue;
var vb = area.VisibleBounds;
union = has ? Encapsulate(union, vb) : vb;
has = true;
}
if (has) return union;
bool hb = false;
Bounds b = default;
foreach (var r in GetComponentsInScene<Renderer>(scene))
{
if (r == null || !r.enabled) continue;
if (r.bounds.size == Vector3.zero) continue;
if (!hb) { b = r.bounds; hb = true; }
else b.Encapsulate(r.bounds);
}
return hb ? new Rect(b.min.x, b.min.y, b.size.x, b.size.y) : new Rect(0f, 0f, 0f, 0f);
}
// ── 工具方法 ──────────────────────────────────────────────────────────
/// <summary>按 RoomId 解析同名场景资产路径;未找到返回 null。</summary>
private static string ResolveScenePath(string roomId)
{
if (string.IsNullOrEmpty(roomId)) return null;
foreach (var guid in AssetDatabase.FindAssets($"t:Scene {roomId}"))
{
string p = AssetDatabase.GUIDToAssetPath(guid);
if (Path.GetFileNameWithoutExtension(p) == roomId) return p;
}
return null;
}
private static void ConfigureTextureImport(string assetPath)
{
if (AssetImporter.GetAtPath(assetPath) is not TextureImporter imp) return;
imp.textureType = TextureImporterType.Default;
imp.alphaIsTransparency = true;
imp.mipmapEnabled = false;
imp.SaveAndReimport();
}
private static void EnsureFolder(string folder)
{
if (string.IsNullOrEmpty(folder) || AssetDatabase.IsValidFolder(folder)) return;
var parts = folder.Split('/');
string cur = parts[0]; // "Assets"
for (int i = 1; i < parts.Length; i++)
{
string next = $"{cur}/{parts[i]}";
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(cur, parts[i]);
cur = next;
}
}
private static Rect Encapsulate(Rect a, Rect b)
{
float xMin = Mathf.Min(a.xMin, b.xMin);
float yMin = Mathf.Min(a.yMin, b.yMin);
float xMax = Mathf.Max(a.xMax, b.xMax);
float yMax = Mathf.Max(a.yMax, b.yMax);
return new Rect(xMin, yMin, xMax - xMin, yMax - yMin);
}
private static List<T> GetComponentsInScene<T>(Scene scene) where T : Component
{
var result = new List<T>();
if (!scene.IsValid()) return result;
foreach (var go in scene.GetRootGameObjects())
result.AddRange(go.GetComponentsInChildren<T>(true));
return result;
}
}
}
#endif

View File

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

View File

@@ -1,32 +1,68 @@
using UnityEngine;
using BaseGames.Core.Events;
namespace BaseGames.Input
{
/// <summary>
/// 运行时启用 InputReaderSO ActionMap。
/// 运行时初始化 InputReaderSO,并让输入 ActionMap 跟随游戏状态切换
/// 挂在 Persistent 场景的 InputReaderHolder 上。
/// _inputReader 必须在 Inspector 中赋值,框架不提供运行时自动查找回退。
///
/// 输入模式由游戏状态驱动(通过 EVT_GameStateChanged 频道订阅,避免 Core→Input 程序集循环):
/// · Gameplay / BossFight → 启用 Gameplay 输入
/// · 其余状态MainMenu / Paused / Dead / Cutscene / LoadingScene / Initializing 等)→ 启用 UI 输入
///
/// 这样主菜单等 UI 场景下 UI ActionMap 才会被启用,保证 EventSystemInputSystemUIInputModule
/// 能接收鼠标点击 / 手柄导航。Dialogue / Cutscene 等 Gameplay 内子流程的临时切换由各自管理器处理,
/// 不经过此处(其状态仍为 Gameplay本组件不干预
///
/// _inputReader 与 _onGameStateChanged 必须在 Inspector 中赋值,框架不提供运行时自动查找回退。
/// </summary>
public sealed class InputReaderBootstrap : MonoBehaviour
{
[SerializeField] private InputReaderSO _inputReader;
[Header("状态驱动输入切换")]
[Tooltip("游戏状态变化频道EVT_GameStateChanged。据此在 Gameplay 输入与 UI 输入间切换。")]
[SerializeField] private GameStateEventChannelSO _onGameStateChanged;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
Debug.Assert(_inputReader != null,
"[InputReaderBootstrap] _inputReader 未在 Inspector 中赋值!请在 Persistent 场景的 InputReaderHolder 上手动指定 InputReaderSO 资产。",
this);
Debug.Assert(_onGameStateChanged != null,
"[InputReaderBootstrap] _onGameStateChanged 未赋值,请在 Inspector 中指定 EVT_GameStateChanged否则输入模式无法跟随游戏状态切换。",
this);
}
private void OnEnable()
{
_onGameStateChanged?.Subscribe(HandleGameStateChanged).AddTo(_subs);
}
private void Start()
{
if (_inputReader == null) return;
_inputReader.LoadBindingOverrides(); // 从 PlayerPrefs 恢复用户自定义绑定
_inputReader.EnableGameplayInput();
// 启动初始状态为 Initializing → MainMenu使用 UI 输入作为兜底;
// 之后由 HandleGameStateChanged 跟随游戏状态切换(不再硬编码 EnableGameplayInput
_inputReader.EnableUIInput();
}
private void HandleGameStateChanged(GameStateId state)
{
if (_inputReader == null) return;
// Gameplay / BossFight 使用游戏输入;其余状态(菜单 / 暂停 / 死亡 / 过场 / 加载)使用 UI 输入。
bool gameplay = state.Id == "Gameplay" || state.Id == "BossFight";
if (gameplay) _inputReader.EnableGameplayInput();
else _inputReader.EnableUIInput();
}
private void OnDisable()
{
_subs.Clear();
_inputReader?.DisableAllInput();
}
}

View File

@@ -12,6 +12,18 @@ namespace BaseGames.Input
[SerializeField] private InputActionAsset _inputActions;
[SerializeField] private VoidEventChannelSO _onPauseRequested;
[Header("背包菜单InventoryHub")]
[Tooltip("打开统一背包菜单。对应 EVT_InventoryOpen。绑定到 UI/Gameplay Map 的 \"Inventory\" Action可选。")]
[SerializeField] private VoidEventChannelSO _onInventoryOpen;
[Tooltip("背包 Tab下一页L/R 肩键)。对应 EVT_InventoryTabNext。绑定 \"InventoryTabNext\" Action可选。")]
[SerializeField] private VoidEventChannelSO _onInventoryTabNext;
[Tooltip("背包 Tab上一页L/R 肩键)。对应 EVT_InventoryTabPrev。绑定 \"InventoryTabPrev\" Action可选。")]
[SerializeField] private VoidEventChannelSO _onInventoryTabPrev;
[Header("UI 操作")]
[Tooltip("UI 取消操作ESC / 手柄 B·Circle。由 UIManager 全局订阅,关闭当前栈顶面板。对应 EVT_UICancelPressed。")]
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
// ── Gameplay Events ───────────────────────────────────────────────────
public event Action<Vector2> MoveEvent;
public event Action JumpStartedEvent;
@@ -194,19 +206,24 @@ namespace BaseGames.Input
BindStarted(_gameplay, "Pause", HandlePause);
// 背包菜单开关(可选 Action默认绑定如 I 键 / 手柄 Select。未在 Map 中定义则跳过。
BindStarted(_gameplay, "Inventory", () => _onInventoryOpen?.Raise());
if (_ui != null)
{
BindPerformed(_ui, "Navigate", ctx => NavigateEvent?.Invoke(ctx.ReadValue<Vector2>()));
BindCanceled(_ui, "Navigate", _ => NavigateEvent?.Invoke(Vector2.zero));
BindStarted(_ui, "Submit", () => SubmitEvent?.Invoke());
BindStarted(_ui, "Cancel", () => CancelEvent?.Invoke());
BindStarted(_ui, "Cancel", () => { CancelEvent?.Invoke(); _onUICancelPressed?.Raise(); });
BindStarted(_ui, "Pause", HandlePause);
BindPerformed(_ui, "Point", ctx => PointEvent?.Invoke(ctx.ReadValue<Vector2>()));
// R13-N3 小地图缩放档位切换Action 名称需在 InputActionAsset UI Map 中添加(可选)
BindStarted(_ui, "CycleMinimapZoom", () => CycleMinimapZoomEvent?.Invoke());
// R13-N4 全屏地图居中Action 名称需在 InputActionAsset UI Map 中添加(可选)
BindStarted(_ui, "MapCenter", () => MapCenterEvent?.Invoke());
// 背包 Tab 循环L/R 肩键Action 名称需在 InputActionAsset UI Map 中添加(可选)
BindStarted(_ui, "InventoryTabNext", () => _onInventoryTabNext?.Raise());
BindStarted(_ui, "InventoryTabPrev", () => _onInventoryTabPrev?.Raise());
}
_isBound = true;

View File

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

View File

@@ -0,0 +1,19 @@
{
"name": "BaseGames.Inventory",
"rootNamespace": "BaseGames.Inventory",
"references": [
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"BaseGames.Localization"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9e81405e158a5a0438e877577a9a2d84
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,54 @@
using System.Collections.Generic;
namespace BaseGames.Inventory
{
/// <summary>
/// 背包管理服务接口。UI 层ItemInventoryPanel通过此接口读取道具状态
/// 与 <see cref="InventoryManager"/> 具体实现解耦,便于独立测试和跨场景复用。
/// 设计对照 <see cref="BaseGames.Equipment.IEquipmentService"/>。
/// </summary>
public interface IInventoryService
{
/// <summary>当前持有的全部道具条目(只读视图,含数量)。</summary>
IReadOnlyList<InventoryEntry> Items { get; }
/// <summary>持有道具种类数(不含数量)。</summary>
int DistinctCount { get; }
/// <summary>查询指定道具的持有数量;未持有返回 0。</summary>
int GetCount(string itemId);
/// <summary>是否持有指定道具(数量 ≥ 1。</summary>
bool Has(string itemId);
/// <summary>
/// 增加道具。stackable 道具按 amount 叠加(受 maxStack 限制);
/// 非 stackable 恒为 1。返回实际新增数量0 表示已满或道具无效)。
/// </summary>
int AddItem(string itemId, int amount = 1);
/// <summary>移除道具(如消耗品使用 / 剧情消耗)。返回实际移除数量。</summary>
int RemoveItem(string itemId, int amount = 1);
/// <summary>清除某道具的"新获得"未读标记(玩家查看背包后调用)。</summary>
void MarkSeen(string itemId);
/// <summary>道具集合变化(增删 / 数量变更时触发UI 据此刷新。</summary>
event System.Action OnInventoryChanged;
}
/// <summary>背包条目:道具数据 + 当前数量 + 未读标记。</summary>
public readonly struct InventoryEntry
{
public readonly ItemSO Item;
public readonly int Count;
public readonly bool IsNew;
public InventoryEntry(ItemSO item, int count, bool isNew)
{
Item = item;
Count = count;
IsNew = isNew;
}
}
}

View File

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

View File

@@ -0,0 +1,209 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
namespace BaseGames.Inventory
{
/// <summary>
/// 运行时背包管理器。
/// 挂在 Persistent 场景 [Services] 下,事件驱动记录玩家持有的道具,
/// 实现 <see cref="ISaveable"/> 持久化。设计对照 <see cref="BaseGames.World.Map.MapManager"/>
/// 与 <see cref="BaseGames.Equipment.EquipmentManager"/> 的服务注册 / 存档模式。
/// </summary>
[DefaultExecutionOrder(-700)]
public class InventoryManager : MonoBehaviour, ISaveable, IInventoryService
{
[SerializeField] private ItemDatabaseSO _database;
[Header("Event Channels - Listen")]
[Tooltip("道具拾取itemId。Collectible 拾取 Item 类型时广播 EVT_ItemPickup。")]
[SerializeField] private StringEventChannelSO _onItemPickup;
[Header("Event Channels - Raise")]
[Tooltip("道具首次获得时广播itemId供 HUD Toast / 地图碎片揭示等订阅。")]
[SerializeField] private StringEventChannelSO _onItemAcquired;
[Tooltip("背包内容变化时广播(无负载),供 UI 轻量刷新。")]
[SerializeField] private VoidEventChannelSO _onInventoryChanged;
[Tooltip("地图碎片道具获得时广播揭示区域regionId。对应 EVT_RevealRegion。")]
[SerializeField] private StringEventChannelSO _onRevealRegion;
private readonly Dictionary<string, int> _counts = new();
private readonly HashSet<string> _newItems = new();
private readonly List<InventoryEntry> _view = new();
private bool _viewDirty = true;
private bool _isDuplicate;
private readonly CompositeDisposable _subs = new();
public event Action OnInventoryChanged;
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (ServiceLocator.GetOrDefault<IInventoryService>() != null) { _isDuplicate = true; Destroy(gameObject); return; }
ServiceLocator.Register<IInventoryService>(this);
Debug.Assert(_database != null, "[InventoryManager] _database 未赋值,请在 Inspector 中指定 ItemDatabaseSO。", this);
}
private void OnEnable()
{
if (_isDuplicate) return;
_onItemPickup?.Subscribe(OnItemPickup).AddTo(_subs);
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Register(this);
}
private void OnDisable()
{
if (_isDuplicate) return;
_subs.Clear();
ServiceLocator.GetOrDefault<ISaveableRegistry>()?.Unregister(this);
}
private void OnDestroy()
{
if (_isDuplicate) return;
ServiceLocator.Unregister<IInventoryService>(this);
}
// ── 事件驱动拾取 ──────────────────────────────────────────────────────
private void OnItemPickup(string itemId) => AddItem(itemId, 1);
// ── IInventoryService ─────────────────────────────────────────────────
public IReadOnlyList<InventoryEntry> Items
{
get { RebuildViewIfDirty(); return _view; }
}
public int DistinctCount => _counts.Count;
public int GetCount(string itemId)
=> !string.IsNullOrEmpty(itemId) && _counts.TryGetValue(itemId, out var c) ? c : 0;
public bool Has(string itemId) => GetCount(itemId) > 0;
public int AddItem(string itemId, int amount = 1)
{
if (string.IsNullOrEmpty(itemId) || amount <= 0) return 0;
var item = _database != null ? _database.Find(itemId) : null;
if (item == null) { Debug.LogWarning($"[InventoryManager] 找不到道具: {itemId}", this); return 0; }
bool firstTime = !_counts.ContainsKey(itemId);
int current = firstTime ? 0 : _counts[itemId];
int target;
if (!item.stackable)
{
if (current >= 1) return 0; // 非叠加道具已持有,忽略
target = 1;
}
else
{
target = current + amount;
if (item.maxStack > 0) target = Mathf.Min(target, item.maxStack);
}
int added = target - current;
if (added <= 0) return 0;
_counts[itemId] = target;
_newItems.Add(itemId);
_viewDirty = true;
if (firstTime)
{
_onItemAcquired?.Raise(itemId);
// 地图碎片:揭示对应区域
if (item.category == ItemCategory.MapShard && !string.IsNullOrEmpty(item.revealRegionId))
_onRevealRegion?.Raise(item.revealRegionId);
}
RaiseChanged();
return added;
}
public int RemoveItem(string itemId, int amount = 1)
{
if (string.IsNullOrEmpty(itemId) || amount <= 0) return 0;
if (!_counts.TryGetValue(itemId, out var current) || current <= 0) return 0;
int removed = Mathf.Min(current, amount);
int target = current - removed;
if (target <= 0)
{
_counts.Remove(itemId);
_newItems.Remove(itemId);
}
else
{
_counts[itemId] = target;
}
_viewDirty = true;
RaiseChanged();
return removed;
}
public void MarkSeen(string itemId)
{
if (string.IsNullOrEmpty(itemId)) return;
if (_newItems.Remove(itemId))
{
_viewDirty = true;
RaiseChanged();
}
}
// ── 内部 ──────────────────────────────────────────────────────────────
private void RaiseChanged()
{
_onInventoryChanged?.Raise();
OnInventoryChanged?.Invoke();
}
private void RebuildViewIfDirty()
{
if (!_viewDirty) return;
_view.Clear();
foreach (var kv in _counts)
{
var item = _database != null ? _database.Find(kv.Key) : null;
if (item == null) continue;
_view.Add(new InventoryEntry(item, kv.Value, _newItems.Contains(kv.Key)));
}
// 按分类枚举顺序、再按本地化名/ID 稳定排序,供 UI 分组展示
_view.Sort((a, b) =>
{
int c = a.Item.category.CompareTo(b.Item.category);
return c != 0 ? c : string.CompareOrdinal(a.Item.itemId, b.Item.itemId);
});
_viewDirty = false;
}
// ── ISaveable ─────────────────────────────────────────────────────────
public void OnSave(SaveData data)
{
data.Inventory.ItemCounts.Clear();
foreach (var kv in _counts) data.Inventory.ItemCounts[kv.Key] = kv.Value;
data.Inventory.NewItemIds.Clear();
data.Inventory.NewItemIds.AddRange(_newItems);
}
public void OnLoad(SaveData data)
{
_counts.Clear();
_newItems.Clear();
if (data.Inventory?.ItemCounts != null)
foreach (var kv in data.Inventory.ItemCounts)
if (!string.IsNullOrEmpty(kv.Key) && kv.Value > 0)
_counts[kv.Key] = kv.Value;
if (data.Inventory?.NewItemIds != null)
foreach (var id in data.Inventory.NewItemIds)
if (_counts.ContainsKey(id)) _newItems.Add(id);
_viewDirty = true;
RaiseChanged();
}
}
}

View File

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

View File

@@ -0,0 +1,20 @@
namespace BaseGames.Inventory
{
/// <summary>
/// 道具分类(背包 Tab 内分组与排序依据)。
/// 新增分类时在此追加ItemInventoryPanel 的分组顺序与此枚举声明顺序一致。
/// </summary>
public enum ItemCategory
{
/// <summary>关键剧情道具(推进主线 / 解锁区域,不可丢弃)。</summary>
KeyItem = 0,
/// <summary>钥匙类(开启特定门 / 机关)。</summary>
Key = 1,
/// <summary>可消耗品(药水、临时增益等,拥有数量可叠加)。</summary>
Consumable = 2,
/// <summary>地图碎片(拾取后揭示对应区域地图)。</summary>
MapShard = 3,
/// <summary>收藏 / 杂项(图鉴、纪念物,无直接用途)。</summary>
Collectible = 4,
}
}

View File

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

View File

@@ -0,0 +1,46 @@
using System.Collections.Generic;
using UnityEngine;
namespace BaseGames.Inventory
{
/// <summary>
/// 道具目录 SO背包系统
/// 全局唯一资产(建议 Assets/_Game/Data/Inventory/ItemDatabase.asset
/// 通过 itemId 查找 <see cref="ItemSO"/> 引用。
/// 由 <see cref="InventoryManager"/> 在拾取 / OnLoad 时查询。
/// 设计对照 <see cref="BaseGames.Equipment.CharmCatalogSO"/>,但内部用字典缓存以支持高频查询。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Inventory/ItemDatabase")]
public class ItemDatabaseSO : ScriptableObject
{
[SerializeField] private ItemSO[] _items;
public IReadOnlyList<ItemSO> AllItems => _items;
private Dictionary<string, ItemSO> _index;
/// <summary>按 itemId 查找道具,找不到返回 null。</summary>
public ItemSO Find(string itemId)
{
if (string.IsNullOrEmpty(itemId) || _items == null) return null;
EnsureIndex();
return _index.TryGetValue(itemId, out var item) ? item : null;
}
private void EnsureIndex()
{
if (_index != null) return;
_index = new Dictionary<string, ItemSO>(_items.Length);
foreach (var item in _items)
if (item != null && !string.IsNullOrEmpty(item.itemId))
_index[item.itemId] = item;
}
/// <summary>编辑器修改 _items 后清空索引,下次 Find 重建。</summary>
public void InvalidateIndex() => _index = null;
#if UNITY_EDITOR
private void OnValidate() => _index = null;
#endif
}
}

View File

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

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Inventory
{
/// <summary>
/// 道具数据 SO背包系统
/// 资产路径建议: Assets/_Game/Data/Inventory/Items/Item_{Name}.asset
/// 命名/本地化约定与 <see cref="BaseGames.Equipment.CharmSO"/> 保持一致Items 表)。
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Inventory/Item")]
public class ItemSO : ScriptableObject, ILocalizableAsset
{
[Header("Identity")]
[Tooltip("全局唯一 ID如 \"Item_AncientKey\"。与拾取广播的 itemId 对应。")]
public string itemId;
[Tooltip("本地化 KeyItems 表)。")]
public string displayNameKey;
[TextArea(2, 4)]
public string descriptionKey;
[Header("Classification")]
public ItemCategory category;
[Header("Visual")]
public Sprite icon;
[Header("Stacking")]
[Tooltip("可叠加消耗品等。false 时同 ID 只记 1 份。")]
public bool stackable;
[Tooltip("叠加上限stackable=true 时生效,<=0 表示不限)。")]
public int maxStack = 99;
[Header("Map Reveal")]
[Tooltip("category=MapShard 时,拾取后揭示的区域 ID对应 MapManager 区域)。")]
public string revealRegionId;
[Header("Lore")]
[Tooltip("是否唯一(剧情关键 / 一次性)。")]
public bool isUnique;
public IEnumerable<LocalizationKeyRef> GetLocalizationKeys()
{
if (!string.IsNullOrEmpty(displayNameKey))
yield return new LocalizationKeyRef(displayNameKey, LocalizationTable.Items, nameof(displayNameKey));
if (!string.IsNullOrEmpty(descriptionKey))
yield return new LocalizationKeyRef(descriptionKey, LocalizationTable.Items, nameof(descriptionKey));
}
}
}

View File

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

View File

@@ -5,7 +5,9 @@
"BaseGames.Core",
"BaseGames.Core.Events",
"BaseGames.Core.Save",
"Unity.TextMeshPro"
"Unity.TextMeshPro",
"Unity.Addressables",
"Unity.ResourceManager"
],
"includePlatforms": [],
"excludePlatforms": [],

View File

@@ -23,6 +23,7 @@ using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Save;
namespace BaseGames.Localization
@@ -197,7 +198,7 @@ namespace BaseGames.Localization
if (dict == null)
Debug.LogWarning(
$"[LocalizationManager] PreloadTables{language}/{table} 未找到," +
$"请确认 Resources/Localization/{language}/{table}.json 存在。");
$"请确认 Addressable 地址 'Localization/{language}/{table}' 已配置。");
#endif
_cache[ck] = dict;
}
@@ -217,20 +218,16 @@ namespace BaseGames.Localization
var ck = new CacheKey(language, table);
if (_cache.ContainsKey(ck)) { yield return null; continue; }
string path = $"Localization/{language}/{table}";
var request = Resources.LoadAsync<TextAsset>(path);
yield return request;
var asset = request.asset as TextAsset;
var dict = asset == null ? null : ParseTableText(asset.text);
// 经 AssetLoader 门面加载(本地小 JSON同步开销可忽略分帧 yield 避免一次性卡顿)
var dict = LoadTable(language, table);
#if UNITY_EDITOR || DEVELOPMENT_BUILD
if (dict == null)
Debug.LogWarning(
$"[LocalizationManager] PreloadTablesAsync{language}/{table} 未找到," +
$"请确认 Resources/Localization/{language}/{table}.json 存在。");
$"请确认 Addressable 地址 'Localization/{language}/{table}' 已配置。");
#endif
_cache[ck] = dict;
yield return null; // 分帧:每帧加载一个表,避免一次性卡顿
}
onComplete?.Invoke();
@@ -296,8 +293,8 @@ namespace BaseGames.Localization
private static readonly Dictionary<string, Dictionary<string, string>> s_editorPreviewCache = new();
/// <summary>
/// 编辑器工具专用:不依赖运行时服务实例,直接从 Resources 读取本地化文本
/// 结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。
/// 编辑器工具专用:不依赖运行时服务实例,直接用 AssetDatabase 按路径读取本地化文本
/// (编辑器期无需初始化 Addressables结果缓存在静态字典中,同一编辑器会话内同一表只加载一次。
/// 找不到时返回 null区别于运行时的 key 回退,便于调用方判断是否显示 key
/// </summary>
public static string GetEditorPreview(string key, string table = LocalizationTable.UI,
@@ -319,8 +316,9 @@ namespace BaseGames.Localization
if (s_editorPreviewCache.TryGetValue(cacheKey, out var cached))
return cached;
string path = $"Localization/{language}/{table}";
var asset = Resources.Load<TextAsset>(path);
// 编辑器按资产路径读取(与运行时 Addressable 地址对应的物理位置)
string path = $"Assets/_Game/Data/Localization/{language}/{table}.json";
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<TextAsset>(path);
var dict = asset == null ? null : ParseTableText(asset.text);
s_editorPreviewCache[cacheKey] = dict;
return dict;
@@ -345,14 +343,20 @@ namespace BaseGames.Localization
}
/// <summary>
/// 从 Resources/Localization/{language}/{table}.json 加载字符串表
/// 返回 null 表示文件不存在
/// 通过 Addressables 同步加载字符串表(地址 "Localization/{language}/{table}"
/// 项目统一用 Addressables 管理资源,不使用 Resources
/// 表不存在时返回 null先用 LoadResourceLocations 判存在,避免缺表时的错误日志刷屏)。
/// </summary>
private static Dictionary<string, string> LoadTable(Language language, string table)
{
string path = $"Localization/{language}/{table}";
var asset = Resources.Load<TextAsset>(path);
return asset == null ? null : ParseTableText(asset.text);
string address = $"Localization/{language}/{table}";
// 统一经 AssetLoader 门面:缺键安全检查 + 同步加载 + 释放
if (!AssetLoader.Exists(address, typeof(TextAsset))) return null;
var (asset, handle) = AssetLoader.LoadSync<TextAsset>(address);
var dict = asset == null ? null : ParseTableText(asset.text);
AssetLoader.Release(handle); // 已解析为字典,释放资源句柄
return dict;
}
/// <summary>

View File

@@ -372,11 +372,25 @@ namespace BaseGames.Player
public void OnLoad(SaveData saveData)
{
var p = saveData.Player;
MaxHP = p.MaxHP;
CurrentHP = Mathf.Clamp(p.CurrentHP, 0, MaxHP);
CurrentLingZhu = p.CurrentLingZhu;
_lifetimeLingZhu = p.LifetimeLingZhu;
_unlockedAbilities = (AbilityType)p.AbilityFlags;
// 新档判定MaxHP<=0 表示存档尚未初始化玩家数值CreateSlot 创建的空档)。
// 此时保留 Awake 从 _config 设置的初始值(避免被空档清零),并回写存档保持一致。
if (p.MaxHP <= 0)
{
p.MaxHP = MaxHP;
p.CurrentHP = CurrentHP;
p.CurrentLingZhu = CurrentLingZhu;
p.LifetimeLingZhu = _lifetimeLingZhu;
p.AbilityFlags = (uint)_unlockedAbilities;
}
else
{
MaxHP = p.MaxHP;
CurrentHP = Mathf.Clamp(p.CurrentHP, 0, MaxHP);
CurrentLingZhu = p.CurrentLingZhu;
_lifetimeLingZhu = p.LifetimeLingZhu;
_unlockedAbilities = (AbilityType)p.AbilityFlags;
}
_onHPChanged?.Raise(CurrentHP);
_onMaxHPChanged?.Raise(MaxHP);

View File

@@ -1,33 +0,0 @@
using UnityEngine;
using BaseGames.Player;
namespace BaseGames.Progression
{
/// <summary>
/// 区域定义 SO架构 09_ProgressionModule §11
/// 集中管理区域元数据:解锁条件、关联场景、地图展示数据。
/// 资产路径: Assets/ScriptableObjects/Progression/Regions/Region_{RegionId}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Progression/RegionDefinition")]
public class RegionDefinitionSO : ScriptableObject
{
[Header("Identity")]
public string regionId; // 如 "Cave"(与 AudioZone.regionId 一致)
public string displayName; // 如 "腐蚀洞穴"
[Header("Map")]
public Color mapColor;
public Sprite mapIconSprite; // P1地图图标
[Header("解锁条件")]
[Tooltip("击败指定 Boss 后解锁;留空 = 无条件")]
public string requiredBossDefeated;
[Tooltip("需持有指定能力None = 无要求")]
public AbilityType requiredAbility;
[Header("关联房间")]
public string[] roomSceneNames; // 该区域包含的所有场景名
public string bossSceneName; // Boss 房间场景名
public string entrySceneName; // 从外部进入该区域的第一个房间
}
}

View File

@@ -5,6 +5,7 @@ using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
using BaseGames.Enemies;
using BaseGames.Player;
@@ -94,7 +95,7 @@ namespace BaseGames.Challenge
int remaining = keys.Count;
foreach (var key in keys)
{
var handle = Addressables.LoadAssetAsync<GameObject>(key);
var handle = AssetLoader.LoadHandle<GameObject>(key);
_preloadHandles.Add(handle);
handle.Completed += _ =>
{
@@ -108,7 +109,7 @@ namespace BaseGames.Challenge
private void ReleasePreloadedAssets()
{
foreach (var h in _preloadHandles)
if (h.IsValid()) Addressables.Release(h);
if (h.IsValid()) AssetLoader.Release(h);
_preloadHandles.Clear();
}
@@ -138,7 +139,7 @@ namespace BaseGames.Challenge
Debug.LogWarning($"[ChallengeRoomManager] encounter[{index}] 中的 enemyAddressKey='{entry.enemyAddressKey}' 未配置 spawnPoint将在 Vector3.zero 生成。请在 ChallengeRoomSO 中补全配置。", this);
pos = Vector3.zero;
}
Addressables.InstantiateAsync(entry.enemyAddressKey, pos, Quaternion.identity)
AssetLoader.InstantiateAsync(entry.enemyAddressKey, pos, Quaternion.identity)
.Completed += handle =>
{
if (handle.Result != null &&

View File

@@ -21,9 +21,11 @@
"BaseGames.Spells",
"BaseGames.Quest",
"BaseGames.Skills",
"BaseGames.Inventory",
"Unity.Addressables",
"Unity.ResourceManager",
"BaseGames.Feedback"
"BaseGames.Feedback",
"BaseGames.World.Map"
],
"autoReferenced": true,
"overrideReferences": false,

View File

@@ -54,8 +54,10 @@ namespace BaseGames.UI.HUD
private void OnEnable()
{
_onHPChanged?.Subscribe(UpdateHP).AddTo(_subs);
// MaxHP 必须先于 HP 订阅:粘性回放时先 RebuildHPCells 建满格子,再 UpdateHP 设激活数,
// 否则受伤档CurrentHP<MaxHP回放顺序颠倒会导致 UpdateHP 作用于空列表后被全量重建覆盖。
_onMaxHPChanged?.Subscribe(RebuildHPCells).AddTo(_subs);
_onHPChanged?.Subscribe(UpdateHP).AddTo(_subs);
_onSoulPowerChanged?.Subscribe(UpdateSoul).AddTo(_subs);
_onSpiritPowerChanged?.Subscribe(UpdateSpirit).AddTo(_subs);
_onLingZhuChanged?.Subscribe(UpdateLingZhu).AddTo(_subs);

View File

@@ -26,8 +26,9 @@ namespace BaseGames.UI
private void OnDeviceChanged(InputDeviceType _)
{
// 通过静态注册表刷新O(n) 遍历已启用实例,无需全场景搜索
InputIconImage.RefreshAll();
// InputIconImage 已通过订阅 IInputIconService.OnIconSetChanged 自主刷新,
// 无需重复调用 RefreshAll()——否则每次设备切换每个 InputIconImage 会执行两次 Refresh。
// 此处保留供将来添加设备切换时的其他 UI 响应(提示动画、音效反馈等)。
}
}
@@ -93,6 +94,14 @@ namespace BaseGames.UI
{
if (_image == null) return;
// 若组件在 IInputIconService 注册前 Enable此处补重试并补订阅
if (_iconService == null)
{
_iconService = ServiceLocator.GetOrDefault<IInputIconService>();
if (_iconService != null)
_iconService.OnIconSetChanged += Refresh;
}
Sprite sprite = null;
if (_mode == LookupMode.ByActionName && !string.IsNullOrEmpty(_actionName))

View File

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

View File

@@ -0,0 +1,191 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 统一背包菜单容器(暂停菜单的 Tab Hub
///
/// 单一职责:管理一组 TabMap / Inventory / Tools / Journal / Quests / Options
/// 处理 L/R 肩键循环、Tab 头部高亮、进出动画、并将焦点委托给当前 Tab。
///
/// 解耦设计Hub 不引用任何具体 Tab 类型,只持有 Tab 根 <see cref="GameObject"/>
/// (与 <see cref="UIManager"/> 的 PanelRegistration 同理。Tab 内容若实现
/// <see cref="IFocusable"/>,激活时自动接管焦点。
///
/// 生命周期:由 <see cref="UIManager"/> 面板栈管理PanelId.Inventory
/// OnEnable 时打开上次停留的 Tab并播放滑入动画。
/// </summary>
public class InventoryHubPanel : MonoBehaviour, IFocusable
{
// ── Tab 注册 ──────────────────────────────────────────────────────────
[Serializable]
public struct TabEntry
{
[Tooltip("Tab 内容根节点(激活时 SetActive(true))。")]
public GameObject content;
[Tooltip("Tab 头部按钮(点击直接跳转;高亮显示当前选中)。可空。")]
public Button headerButton;
[Tooltip("Tab 头部高亮节点(选中时 SetActive(true))。可空。")]
public GameObject headerHighlight;
}
[Header("Tabs按显示顺序")]
[SerializeField] private TabEntry[] _tabs;
[Tooltip("默认打开的 Tab 索引。")]
[SerializeField] private int _defaultTabIndex = 0;
[Tooltip("关闭后是否记住上次停留的 Tabfalse 则每次打开回到默认 Tab。")]
[SerializeField] private bool _rememberLastTab = true;
[Header("进场动画")]
[SerializeField] private CanvasGroup _rootGroup;
[SerializeField] private RectTransform _slideTarget;
[Tooltip("滑入起始偏移(像素,向下)。")]
[SerializeField] private float _entrySlideOffset = 60f;
[SerializeField] private float _entryDuration = 0.3f;
[Header("Event Channels - Listen")]
[Tooltip("L/R 肩键:下一 Tab。对应 EVT_InventoryTabNext。")]
[SerializeField] private VoidEventChannelSO _onTabNext;
[Tooltip("L/R 肩键:上一 Tab。对应 EVT_InventoryTabPrev。")]
[SerializeField] private VoidEventChannelSO _onTabPrev;
[Header("Event Channels - Raise")]
[Tooltip("当前 Tab 变化时广播索引。对应 EVT_InventoryTabChanged。")]
[SerializeField] private IntEventChannelSO _onTabChanged;
private int _currentIndex = -1;
private static int _persistedIndex = -1; // 跨开关记忆(静态:面板重建后仍保留)
private Coroutine _entryAnim;
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_tabs != null)
for (int i = 0; i < _tabs.Length; i++)
{
int captured = i;
if (_tabs[i].headerButton != null)
{
_tabs[i].headerButton.onClick.RemoveAllListeners();
_tabs[i].headerButton.onClick.AddListener(() => SelectTab(captured));
}
if (_tabs[i].content != null) _tabs[i].content.SetActive(false);
if (_tabs[i].headerHighlight != null) _tabs[i].headerHighlight.SetActive(false);
}
}
private void OnEnable()
{
_onTabNext?.Subscribe(NextTab).AddTo(_subs);
_onTabPrev?.Subscribe(PrevTab).AddTo(_subs);
int start = _rememberLastTab && _persistedIndex >= 0 ? _persistedIndex : _defaultTabIndex;
_currentIndex = -1; // 强制 SelectTab 执行切换逻辑
SelectTab(start, raise: true, animateEntry: true);
}
private void OnDisable()
{
_subs.Clear();
if (_entryAnim != null) { StopCoroutine(_entryAnim); _entryAnim = null; }
}
// ── Tab 切换 API ──────────────────────────────────────────────────────
public void NextTab() => StepTab(+1);
public void PrevTab() => StepTab(-1);
private void StepTab(int dir)
{
if (_tabs == null || _tabs.Length == 0) return;
int next = _currentIndex;
// 跳过未配置 content 的空槽,最多绕一圈
for (int i = 0; i < _tabs.Length; i++)
{
next = (next + dir + _tabs.Length) % _tabs.Length;
if (_tabs[next].content != null) break;
}
SelectTab(next);
}
/// <summary>切换到指定 Tab。无效索引或与当前相同则忽略。</summary>
public void SelectTab(int index) => SelectTab(index, raise: true, animateEntry: false);
private void SelectTab(int index, bool raise, bool animateEntry)
{
if (_tabs == null || _tabs.Length == 0) return;
index = Mathf.Clamp(index, 0, _tabs.Length - 1);
if (index == _currentIndex) return;
// 关闭旧 Tab
if (_currentIndex >= 0 && _currentIndex < _tabs.Length)
{
if (_tabs[_currentIndex].content != null) _tabs[_currentIndex].content.SetActive(false);
if (_tabs[_currentIndex].headerHighlight != null) _tabs[_currentIndex].headerHighlight.SetActive(false);
}
_currentIndex = index;
_persistedIndex = index;
// 打开新 Tab
var tab = _tabs[index];
if (tab.content != null) tab.content.SetActive(true);
if (tab.headerHighlight != null) tab.headerHighlight.SetActive(true);
// 焦点委托Tab 内容若实现 IFocusable 则接管,否则聚焦其 Tab 头按钮
var focusable = tab.content != null ? tab.content.GetComponent<IFocusable>() : null;
if (focusable != null) focusable.OnFocusRestored();
else if (tab.headerButton != null)
EventSystem.current?.SetSelectedGameObject(tab.headerButton.gameObject);
if (raise) _onTabChanged?.Raise(index);
if (animateEntry) PlayEntryAnim();
}
// ── 进场动画 ──────────────────────────────────────────────────────────
private void PlayEntryAnim()
{
if (_entryAnim != null) StopCoroutine(_entryAnim);
_entryAnim = StartCoroutine(EntryRoutine());
}
private System.Collections.IEnumerator EntryRoutine()
{
Vector2 endPos = _slideTarget != null ? _slideTarget.anchoredPosition : Vector2.zero;
Vector2 startPos = endPos - new Vector2(0f, _entrySlideOffset);
float elapsed = 0f;
while (elapsed < _entryDuration)
{
float t = Mathf.SmoothStep(0f, 1f, elapsed / _entryDuration);
if (_rootGroup != null) _rootGroup.alpha = t;
if (_slideTarget != null) _slideTarget.anchoredPosition = Vector2.Lerp(startPos, endPos, t);
elapsed += Time.unscaledDeltaTime;
yield return null;
}
if (_rootGroup != null) _rootGroup.alpha = 1f;
if (_slideTarget != null) _slideTarget.anchoredPosition = endPos;
_entryAnim = null;
}
// ── IFocusable ────────────────────────────────────────────────────────
/// <summary>面板恢复为栈顶(关闭子面板后)时,将焦点交还当前 Tab。</summary>
public void OnFocusRestored()
{
if (_currentIndex < 0 || _currentIndex >= (_tabs?.Length ?? 0)) return;
var tab = _tabs[_currentIndex];
var focusable = tab.content != null ? tab.content.GetComponent<IFocusable>() : null;
if (focusable != null) focusable.OnFocusRestored();
else if (tab.headerButton != null)
EventSystem.current?.SetSelectedGameObject(tab.headerButton.gameObject);
}
}
}

View File

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

View File

@@ -0,0 +1,166 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Inventory;
using BaseGames.Localization;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 背包道具 TabInventory 分栏)。
/// 左侧网格列出持有道具(按分类分组排序),右侧详情面板显示选中道具的名称/描述/数量。
///
/// 数据来源ServiceLocator 获取 <see cref="IInventoryService"/>。
/// 反应式更新:订阅 <see cref="IInventoryService.OnInventoryChanged"/> 重建格子。
/// 设计对照 <see cref="BaseGames.UI.CharmEquipPanel"/>(对象池 + 重建模式)。
/// </summary>
public class ItemInventoryPanel : MonoBehaviour, IFocusable
{
[Header("道具网格")]
[SerializeField] private Transform _gridContainer;
[SerializeField] private ItemSlotView _slotTemplate; // kept inactive作为对象池原型
[Header("详情面板")]
[SerializeField] private Image _detailIcon;
[SerializeField] private TMP_Text _detailNameText;
[SerializeField] private TMP_Text _detailDescText;
[SerializeField] private TMP_Text _detailCountText;
[Tooltip("空背包提示节点(无道具时显示)。")]
[SerializeField] private GameObject _emptyHint;
[Header("Event Channels")]
[Tooltip("背包内容变化(无负载)。与 InventoryManager._onInventoryChanged 共享同一 SO。")]
[SerializeField] private VoidEventChannelSO _onInventoryChanged;
private IInventoryService _service;
private readonly List<ItemSlotView> _activeSlots = new();
private readonly Queue<ItemSlotView> _pool = new();
private ItemSlotView _selected;
private readonly CompositeDisposable _subs = new();
// ── 生命周期 ──────────────────────────────────────────────────────────
private void Awake()
{
if (_slotTemplate != null) _slotTemplate.gameObject.SetActive(false);
}
private void OnEnable()
{
_service = ServiceLocator.GetOrDefault<IInventoryService>();
// 双通道C# 事件(直接)+ SO 频道编辑器可视任一触发即重建Rebuild 幂等)
if (_service != null) _service.OnInventoryChanged += Rebuild;
_onInventoryChanged?.Subscribe(Rebuild).AddTo(_subs);
Rebuild();
}
private void OnDisable()
{
if (_service != null) _service.OnInventoryChanged -= Rebuild;
_subs.Clear();
RecycleAll();
}
// ── 重建 ──────────────────────────────────────────────────────────────
private void Rebuild()
{
RecycleAll();
if (_service == null || _gridContainer == null || _slotTemplate == null) return;
var items = _service.Items;
if (_emptyHint != null) _emptyHint.SetActive(items.Count == 0);
foreach (var entry in items)
{
if (entry.Item == null) continue;
var slot = Spawn();
slot.Bind(entry, OnSlotSelected);
_activeSlots.Add(slot);
}
// 默认选中第一格,刷新详情
if (_activeSlots.Count > 0) SelectSlot(_activeSlots[0]);
else ClearDetail();
}
private ItemSlotView Spawn()
{
ItemSlotView slot = _pool.Count > 0 ? _pool.Dequeue() : Instantiate(_slotTemplate, _gridContainer);
slot.transform.SetParent(_gridContainer, false);
slot.gameObject.SetActive(true);
return slot;
}
private void RecycleAll()
{
foreach (var slot in _activeSlots)
{
if (slot == null) continue;
slot.SetSelected(false);
slot.gameObject.SetActive(false);
_pool.Enqueue(slot);
}
_activeSlots.Clear();
_selected = null;
}
// ── 选中 & 详情 ───────────────────────────────────────────────────────
private void OnSlotSelected(ItemSO item)
{
foreach (var slot in _activeSlots)
if (slot != null && slot.Item == item) { SelectSlot(slot); break; }
}
private void SelectSlot(ItemSlotView slot)
{
if (slot == null) return;
if (_selected != null) _selected.SetSelected(false);
_selected = slot;
_selected.SetSelected(true);
var item = slot.Item;
_service?.MarkSeen(item.itemId); // 查看后清除未读角标
ShowDetail(slot);
}
private void ShowDetail(ItemSlotView slot)
{
var item = slot.Item;
if (_detailIcon != null)
{
_detailIcon.sprite = item.icon;
_detailIcon.enabled = item.icon != null;
}
if (_detailNameText != null) _detailNameText.text = slot.ResolveName();
if (_detailDescText != null)
{
string loc = LocalizationManager.Get(item.descriptionKey, LocalizationTable.Items);
_detailDescText.text = !string.IsNullOrEmpty(loc) && loc != item.descriptionKey ? loc : string.Empty;
}
if (_detailCountText != null)
{
int count = _service?.GetCount(item.itemId) ?? 0;
_detailCountText.text = item.stackable ? $"x{count}" : string.Empty;
_detailCountText.enabled = item.stackable;
}
}
private void ClearDetail()
{
if (_detailIcon != null) _detailIcon.enabled = false;
if (_detailNameText != null) _detailNameText.text = string.Empty;
if (_detailDescText != null) _detailDescText.text = string.Empty;
if (_detailCountText != null) _detailCountText.text = string.Empty;
}
// ── IFocusable ────────────────────────────────────────────────────────
public void OnFocusRestored()
{
if (_selected != null && _selected.SelectButton != null)
EventSystem.current?.SetSelectedGameObject(_selected.SelectButton.gameObject);
}
}
}

View File

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

View File

@@ -0,0 +1,77 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Inventory;
using BaseGames.Localization;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 背包道具格子视图(对照 <see cref="BaseGames.UI.CharmCardView"/> 的显式序列化绑定风格)。
/// 由 <see cref="ItemInventoryPanel"/> 通过对象池实例化并调用 <see cref="Bind"/>。
/// </summary>
[DisallowMultipleComponent]
public class ItemSlotView : MonoBehaviour
{
[Header("Visual")]
[SerializeField] private Image _icon;
[SerializeField] private TMP_Text _countText;
[Tooltip("\"新获得\"角标节点IsNew 时显示)。")]
[SerializeField] private GameObject _newBadge;
[Header("Interaction")]
[SerializeField] private Button _selectButton;
[Tooltip("选中高亮节点(可空)。")]
[SerializeField] private GameObject _selectedHighlight;
private ItemSO _item;
public ItemSO Item => _item;
public Button SelectButton => _selectButton;
/// <summary>绑定道具条目。点击时回调 onSelect用于右侧详情面板。</summary>
public void Bind(InventoryEntry entry, Action<ItemSO> onSelect)
{
_item = entry.Item;
if (_item == null) return;
if (_icon != null)
{
_icon.sprite = _item.icon;
_icon.enabled = _item.icon != null;
}
if (_countText != null)
{
// 仅可叠加且数量 > 1 时显示数量角标
bool show = _item.stackable && entry.Count > 1;
_countText.text = show ? entry.Count.ToString() : string.Empty;
_countText.enabled = show;
}
if (_newBadge != null) _newBadge.SetActive(entry.IsNew);
if (_selectButton != null)
{
_selectButton.onClick.RemoveAllListeners();
var captured = _item;
if (onSelect != null)
_selectButton.onClick.AddListener(() => onSelect(captured));
}
}
public void SetSelected(bool selected)
{
if (_selectedHighlight != null) _selectedHighlight.SetActive(selected);
}
/// <summary>本地化道具名(容错:未找到键回落 itemId。</summary>
public string ResolveName()
{
if (_item == null) return string.Empty;
string loc = LocalizationManager.Get(_item.displayNameKey, LocalizationTable.Items);
return !string.IsNullOrEmpty(loc) && loc != _item.displayNameKey ? loc : _item.itemId;
}
}
}

View File

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

View File

@@ -0,0 +1,203 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Localization;
using BaseGames.Quest;
using QuestStateEnum = BaseGames.Core.Events.QuestState;
namespace BaseGames.UI.Inventory
{
/// <summary>
/// 全屏任务日志 TabJournal/Quests 分栏)。
/// 左侧列出进行中 / 已完成任务,右侧详情显示选中任务的名称、描述与目标进度。
///
/// 数据来源ServiceLocator 获取 <see cref="IQuestManager"/>,读取 GetQuestsInState 快照。
/// 与 HUD 的 <see cref="BaseGames.UI.HUD.QuestTrackerWidget"/> 不同:后者只显示当前追踪任务,
/// 本面板提供完整列表浏览。本地化统一使用 Quest 表。
/// </summary>
public class QuestLogPanel : MonoBehaviour, IFocusable
{
[Header("任务列表")]
[SerializeField] private Transform _listContainer;
[Tooltip("任务行模板(含 Button + TMP_Textkept inactive。")]
[SerializeField] private Button _rowTemplate;
[Header("详情")]
[SerializeField] private TMP_Text _detailTitle;
[SerializeField] private TMP_Text _detailDesc;
[SerializeField] private Transform _objectiveContainer;
[Tooltip("目标行模板TMP_Textkept inactive。")]
[SerializeField] private TMP_Text _objectiveRowTemplate;
[Tooltip("无进行中任务时的提示节点。")]
[SerializeField] private GameObject _emptyHint;
[Header("Event Channels - 刷新触发(任一即重建)")]
[SerializeField] private QuestStateChangedEventChannel _onQuestStateChanged;
private IQuestManager _quests;
private readonly List<string> _activeIds = new();
private readonly List<Button> _rows = new();
private readonly Queue<Button> _rowPool = new();
private readonly List<TMP_Text> _objRows = new();
private readonly Queue<TMP_Text> _objPool = new();
private string _selectedQuestId;
private readonly CompositeDisposable _subs = new();
private void Awake()
{
if (_rowTemplate != null) _rowTemplate.gameObject.SetActive(false);
if (_objectiveRowTemplate != null) _objectiveRowTemplate.gameObject.SetActive(false);
}
private void OnEnable()
{
_quests = ServiceLocator.GetOrDefault<IQuestManager>();
_onQuestStateChanged?.Subscribe(OnStateChanged).AddTo(_subs);
Rebuild();
}
private void OnDisable()
{
_subs.Clear();
RecycleRows();
RecycleObjectives();
}
private void OnStateChanged(QuestStateChangedEvent _) => Rebuild();
// ── 列表重建 ──────────────────────────────────────────────────────────
private void Rebuild()
{
RecycleRows();
if (_quests == null || _listContainer == null || _rowTemplate == null) return;
_activeIds.Clear();
CollectInto(QuestStateEnum.Active);
CollectInto(QuestStateEnum.Completed);
if (_emptyHint != null) _emptyHint.SetActive(_activeIds.Count == 0);
foreach (var id in _activeIds)
{
var row = SpawnRow();
BindRow(row, id);
_rows.Add(row);
}
if (_activeIds.Count > 0) SelectQuest(_activeIds[0]);
else ClearDetail();
}
private void CollectInto(QuestStateEnum state)
{
var snapshot = _quests.GetQuestsInState(state);
for (int i = 0; i < snapshot.Count; i++)
if (!_activeIds.Contains(snapshot[i])) _activeIds.Add(snapshot[i]);
}
private Button SpawnRow()
{
Button row = _rowPool.Count > 0 ? _rowPool.Dequeue() : Instantiate(_rowTemplate, _listContainer);
row.transform.SetParent(_listContainer, false);
row.gameObject.SetActive(true);
return row;
}
private void BindRow(Button row, string questId)
{
var label = row.GetComponentInChildren<TMP_Text>(includeInactive: true);
if (label != null) label.text = ResolveQuestTitle(questId);
row.onClick.RemoveAllListeners();
string captured = questId;
row.onClick.AddListener(() => SelectQuest(captured));
}
private void RecycleRows()
{
foreach (var row in _rows)
{
if (row == null) continue;
row.gameObject.SetActive(false);
_rowPool.Enqueue(row);
}
_rows.Clear();
}
// ── 详情 ──────────────────────────────────────────────────────────────
private void SelectQuest(string questId)
{
_selectedQuestId = questId;
if (_quests == null || !_quests.TryGetQuest(questId, out var quest) || quest == null)
{
ClearDetail();
return;
}
if (_detailTitle != null) _detailTitle.text = ResolveQuestTitle(questId);
if (_detailDesc != null)
{
string loc = LocalizationManager.Get(quest.descriptionKey, LocalizationTable.Quest);
_detailDesc.text = !string.IsNullOrEmpty(loc) && loc != quest.descriptionKey ? loc : string.Empty;
}
RebuildObjectives(quest);
}
private void RebuildObjectives(QuestSO quest)
{
RecycleObjectives();
if (_objectiveContainer == null || _objectiveRowTemplate == null || quest.objectives == null) return;
foreach (var obj in quest.objectives)
{
if (obj == null) continue;
var row = _objPool.Count > 0 ? _objPool.Dequeue() : Instantiate(_objectiveRowTemplate, _objectiveContainer);
row.transform.SetParent(_objectiveContainer, false);
row.gameObject.SetActive(true);
string loc = LocalizationManager.Get(obj.displayTextKey, LocalizationTable.Quest);
row.text = !string.IsNullOrEmpty(loc) && loc != obj.displayTextKey ? loc : obj.displayTextKey;
_objRows.Add(row);
}
}
private void RecycleObjectives()
{
foreach (var row in _objRows)
{
if (row == null) continue;
row.gameObject.SetActive(false);
_objPool.Enqueue(row);
}
_objRows.Clear();
}
private void ClearDetail()
{
if (_detailTitle != null) _detailTitle.text = string.Empty;
if (_detailDesc != null) _detailDesc.text = string.Empty;
RecycleObjectives();
}
private string ResolveQuestTitle(string questId)
{
if (_quests != null && _quests.TryGetQuest(questId, out var quest) && quest != null
&& !string.IsNullOrEmpty(quest.displayNameKey))
{
string loc = LocalizationManager.Get(quest.displayNameKey, LocalizationTable.Quest);
return !string.IsNullOrEmpty(loc) && loc != quest.displayNameKey ? loc : questId;
}
return questId;
}
// ── IFocusable ────────────────────────────────────────────────────────
public void OnFocusRestored()
{
if (_rows.Count > 0 && _rows[0] != null)
EventSystem.current?.SetSelectedGameObject(_rows[0].gameObject);
}
}
}

View File

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

View File

@@ -32,6 +32,8 @@ namespace BaseGames.UI.MainMenu
[SerializeField] private CanvasGroup _mainButtonsGroup;
[SerializeField] private RectTransform _mainButtonsRect; // 用于滑入动画
[SerializeField] private GameObject _saveSlotPanel;
[Tooltip("存档槽面板控制器。打开前调用 SetMode 区分新游戏 / 继续语境。")]
[SerializeField] private BaseGames.UI.Menus.SaveSlotController _saveSlotController;
[SerializeField] private GameObject _settingsPanel;
[SerializeField] private GameObject _creditsPanel;
@@ -157,8 +159,16 @@ namespace BaseGames.UI.MainMenu
// ── 按钮回调 ─────────────────────────────────────────────────────────
private void OnNewGameClicked() => SetPanel(_saveSlotPanel, true);
private void OnContinueClicked() => SetPanel(_saveSlotPanel, true);
private void OnNewGameClicked()
{
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.NewGame);
SetPanel(_saveSlotPanel, true);
}
private void OnContinueClicked()
{
_saveSlotController?.SetMode(BaseGames.UI.Menus.SaveSlotPanelMode.Continue);
SetPanel(_saveSlotPanel, true);
}
private void OnSettingsClicked() => SetPanel(_settingsPanel, true);
private void OnCreditsClicked() => SetPanel(_creditsPanel, true);
@@ -168,9 +178,16 @@ namespace BaseGames.UI.MainMenu
{
SetPanel(_saveSlotPanel, false);
// 继续游戏:存档已记录检查点场景时加载该场景并落在存档点出生位;
// 否则(新游戏 / 存档尚无检查点)加载首关。
var svc = ServiceLocator.GetOrDefault<ISaveService>();
string checkpointScene = svc?.LastCheckpointScene;
bool hasCheckpoint = !string.IsNullOrEmpty(checkpointScene);
_onSceneLoadRequest?.Raise(new SceneLoadRequest
{
SceneName = _firstGameSceneKey,
SceneName = hasCheckpoint ? checkpointScene : _firstGameSceneKey,
EntryTransitionId = hasCheckpoint ? svc.LastCheckpointSpawnId : null,
TransitionType = TransitionType.Scene,
ShowLoadingScreen = true,
});

View File

@@ -0,0 +1,107 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.UI.MainMenu
{
/// <summary>
/// 新游戏模式选择面板(普通 / 钢铁之魂):开新档前选择难度模式。
///
/// 设计:
/// · 自包含、场景无关——本地 SetActive 显隐 + 回调,不走 UIManager 面板栈,
/// 与 MainMenuController 现有的子面板管理方式一致。
/// · 选定后通过 onModeChosen 回调把 DifficultyLevel 交还给调用方SaveSlotController
/// 由调用方负责 CreateSlot(slot, steelSoul) + IDifficultyService.BeginNewGame(level)。
/// · 钢铁之魂为破坏性/高难选项,默认焦点置于普通,并显示一段警示文案。
/// </summary>
public class NewGameModeController : MonoBehaviour
{
[Header("根节点(显隐用,留空则用本 GameObject")]
[SerializeField] private GameObject _root;
[Header("按钮")]
[SerializeField] private Button _btnNormal;
[SerializeField] private Button _btnSteelSoul;
[SerializeField] private Button _btnBack;
[Header("钢铁之魂说明")]
[Tooltip("选中钢铁之魂时显示的警示文案(一命模式,死亡即清档)。走本地化键 MODE_STEELSOUL_DESC。")]
[SerializeField] private TMP_Text _steelSoulDescText;
[SerializeField] private string _steelSoulDescKey = "MODE_STEELSOUL_DESC";
private Action<DifficultyLevel> _onModeChosen;
private Action _onBack;
private void Awake()
{
_btnNormal? .onClick.AddListener(() => Choose(DifficultyLevel.Normal));
_btnSteelSoul?.onClick.AddListener(() => Choose(DifficultyLevel.SteelSoul));
_btnBack? .onClick.AddListener(HandleBack);
SetVisible(false);
}
/// <summary>
/// 弹出模式选择。
/// </summary>
/// <param name="onModeChosen">玩家选定模式后回调(面板已自动关闭),携带难度档位。</param>
/// <param name="onBack">点击返回 / 取消后回调(可选)。</param>
public void Show(Action<DifficultyLevel> onModeChosen, Action onBack = null)
{
_onModeChosen = onModeChosen;
_onBack = onBack;
if (_steelSoulDescText != null && !string.IsNullOrEmpty(_steelSoulDescKey))
{
string s = LocalizationManager.Get(_steelSoulDescKey, LocalizationTable.UI);
_steelSoulDescText.text = string.IsNullOrEmpty(s) ? _steelSoulDescKey : s;
}
SetVisible(true);
// 默认焦点置于普通模式(避免误选一命模式)
EventSystem.current?.SetSelectedGameObject(_btnNormal != null
? _btnNormal.gameObject
: _btnSteelSoul?.gameObject);
}
/// <summary>外部强制关闭,不触发回调。</summary>
public void Close()
{
_onModeChosen = null;
_onBack = null;
SetVisible(false);
}
// ── 回调 ──────────────────────────────────────────────────────────────
private void Choose(DifficultyLevel level)
{
var cb = _onModeChosen;
SetVisible(false);
_onModeChosen = null;
_onBack = null;
cb?.Invoke(level);
}
private void HandleBack()
{
var cb = _onBack;
SetVisible(false);
_onModeChosen = null;
_onBack = null;
cb?.Invoke();
}
// ── 工具 ──────────────────────────────────────────────────────────────
private void SetVisible(bool visible)
{
var go = _root != null ? _root : gameObject;
go.SetActive(visible);
}
}
}

View File

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

View File

@@ -0,0 +1,121 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using TMPro;
using BaseGames.Localization;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 通用模态确认对话框(是/否):用于删除 / 覆盖 / 退出等确认场景。
///
/// 设计:
/// · 自包含、场景无关——通过本地 SetActive 显隐 + 回调 API 工作,不依赖 UIManager 面板栈,
/// 因此既能用于主菜单场景(不走 UIManager也能在游戏内复用。
/// · 标题 / 正文 / 按钮文案均走本地化键LocalizationManager.Get传 null 则保留 Inspector 原文。
/// · 默认焦点置于"取消"按钮,防止手柄连按误触确认(破坏性操作安全默认)。
///
/// 用法:
/// _confirmDialog.Show("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY",
/// onConfirm: () => DoDelete(),
/// onCancel: () => {});
/// </summary>
public class ConfirmDialogController : MonoBehaviour
{
[Header("根节点(显隐用,留空则用本 GameObject")]
[SerializeField] private GameObject _root;
[Header("文本")]
[SerializeField] private TMP_Text _titleText;
[SerializeField] private TMP_Text _bodyText;
[Tooltip("确认按钮标签(可选,传 confirmKey 时覆盖)")]
[SerializeField] private TMP_Text _confirmLabel;
[Tooltip("取消按钮标签(可选,传 cancelKey 时覆盖)")]
[SerializeField] private TMP_Text _cancelLabel;
[Header("按钮")]
[SerializeField] private Button _btnConfirm;
[SerializeField] private Button _btnCancel;
private Action _onConfirm;
private Action _onCancel;
private void Awake()
{
_btnConfirm?.onClick.AddListener(HandleConfirm);
_btnCancel? .onClick.AddListener(HandleCancel);
SetVisible(false);
}
/// <summary>
/// 弹出确认框。
/// </summary>
/// <param name="titleKey">标题本地化键null 保留原文。</param>
/// <param name="bodyKey">正文本地化键null 保留原文。</param>
/// <param name="onConfirm">点击确认后回调(确认框已自动关闭)。</param>
/// <param name="onCancel">点击取消后回调(可选)。</param>
/// <param name="confirmKey">确认按钮文案本地化键(可选)。</param>
/// <param name="cancelKey">取消按钮文案本地化键(可选)。</param>
public void Show(string titleKey, string bodyKey, Action onConfirm, Action onCancel = null,
string confirmKey = null, string cancelKey = null)
{
_onConfirm = onConfirm;
_onCancel = onCancel;
if (_titleText != null && titleKey != null) _titleText.text = Loc(titleKey);
if (_bodyText != null && bodyKey != null) _bodyText.text = Loc(bodyKey);
if (_confirmLabel != null && confirmKey != null) _confirmLabel.text = Loc(confirmKey);
if (_cancelLabel != null && cancelKey != null) _cancelLabel.text = Loc(cancelKey);
SetVisible(true);
// 安全默认:焦点置于取消,避免手柄/键盘连按直接确认破坏性操作
EventSystem.current?.SetSelectedGameObject(_btnCancel != null
? _btnCancel.gameObject
: _btnConfirm?.gameObject);
}
/// <summary>外部强制关闭(如父面板被关闭时)。不触发任何回调。</summary>
public void Close()
{
_onConfirm = null;
_onCancel = null;
SetVisible(false);
}
// ── 按钮回调 ──────────────────────────────────────────────────────────
private void HandleConfirm()
{
var cb = _onConfirm;
SetVisible(false);
_onConfirm = null;
_onCancel = null;
cb?.Invoke();
}
private void HandleCancel()
{
var cb = _onCancel;
SetVisible(false);
_onConfirm = null;
_onCancel = null;
cb?.Invoke();
}
// ── 工具 ──────────────────────────────────────────────────────────────
private void SetVisible(bool visible)
{
var go = _root != null ? _root : gameObject;
go.SetActive(visible);
}
private static string Loc(string key)
{
string s = LocalizationManager.Get(key, LocalizationTable.UI);
return string.IsNullOrEmpty(s) ? key : s;
}
}
}

View File

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

View File

@@ -0,0 +1,98 @@
using UnityEngine;
using BaseGames.Core;
using BaseGames.Localization;
using BaseGames.World.Map;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 地图传送确认控制器UI 侧桥接)。
/// <para>
/// 订阅 <see cref="MapPanel.OnTeleportStationSelected"/>:玩家在全屏地图点击一个可传送站点时,
/// 弹出 <see cref="ConfirmDialogController"/> 二次确认,确认后调用
/// <see cref="ITeleportService.RequestTeleport"/> 发起传送,并按需关闭地图面板。
/// </para>
/// <para>
/// 放在 UI 程序集(已引用 BaseGames.World.Map——MapPanel 自身不反向依赖 UI避免循环引用。
/// 这是"补全传送"闭环的最后一环:地图选点 → 确认 → 传送。
/// </para>
/// </summary>
public class MapTeleportConfirmController : MonoBehaviour
{
[Header("引用")]
[Tooltip("全屏地图面板(订阅其 OnTeleportStationSelected。")]
[SerializeField] private MapPanel _mapPanel;
[Tooltip("通用确认框;留空则点击站点后直接传送(无二次确认)。")]
[SerializeField] private ConfirmDialogController _confirmDialog;
[Header("文案(本地化键)")]
[SerializeField] private string _confirmTitleKey = "TELEPORT_CONFIRM_TITLE";
[Tooltip("确认正文前缀本地化键;后面拼接目的地房间显示名。")]
[SerializeField] private string _confirmBodyPrefixKey = "TELEPORT_CONFIRM_BODY";
[SerializeField] private string _confirmYesKey = "CONFIRM_YES";
[SerializeField] private string _confirmNoKey = "CONFIRM_NO";
[Header("行为")]
[Tooltip("确认传送后是否关闭地图面板CloseTopPanel。")]
[SerializeField] private bool _closeMapOnConfirm = true;
private void OnEnable()
{
if (_mapPanel != null)
_mapPanel.OnTeleportStationSelected += OnStationSelected;
}
private void OnDisable()
{
if (_mapPanel != null)
_mapPanel.OnTeleportStationSelected -= OnStationSelected;
}
private void OnStationSelected(string roomId)
{
if (string.IsNullOrEmpty(roomId)) return;
// 无确认框:直接传送
if (_confirmDialog == null)
{
DoTeleport(roomId);
return;
}
string destName = ResolveDestName(roomId);
string prefix = LocalizationManager.Get(_confirmBodyPrefixKey, LocalizationTable.UI);
if (string.IsNullOrEmpty(prefix) || prefix == _confirmBodyPrefixKey)
prefix = "传送到:"; // 本地化键缺失时的兜底前缀
string body = prefix + destName;
// ConfirmDialog 对 body 走 Loc 查找,未命中则原样显示——拼接串可直接呈现;
// 确认/取消按钮文案也走本地化键,随语言切换。
_confirmDialog.Show(_confirmTitleKey, body, onConfirm: () => DoTeleport(roomId),
onCancel: null, confirmKey: _confirmYesKey, cancelKey: _confirmNoKey);
}
private void DoTeleport(string roomId)
{
var teleportSvc = ServiceLocator.GetOrDefault<ITeleportService>();
if (teleportSvc == null)
{
Debug.LogWarning("[MapTeleportConfirmController] ITeleportService 未注册,无法传送。");
return;
}
teleportSvc.RequestTeleport(roomId);
if (_closeMapOnConfirm)
ServiceLocator.GetOrDefault<IUIManager>()?.CloseTopPanel();
}
/// <summary>解析目的地房间的玩家可读名DisplayName 走本地化;无则回退 RoomId。</summary>
private static string ResolveDestName(string roomId)
{
var room = ServiceLocator.GetOrDefault<IMapService>()?.Database?.GetRoom(roomId);
if (room == null || string.IsNullOrEmpty(room.DisplayName)) return roomId;
// DisplayName 为本地化 Key 时解析为译文;为普通名称时原样返回(向后兼容)。
return LocalizationManager.Get(room.DisplayName, LocalizationTable.UI);
}
}
}

View File

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

View File

@@ -9,19 +9,49 @@ using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Localization;
using BaseGames.World.Map;
using BaseGames.UI.MainMenu;
namespace BaseGames.UI.Menus
{
/// <summary>主菜单存档槽面板的打开语境。</summary>
public enum SaveSlotPanelMode
{
/// <summary>继续游戏:仅有档槽可选,点击即读档进入。</summary>
Continue,
/// <summary>新游戏:空槽 → 模式选择 → 建档;有档槽 → 覆盖确认 → 模式选择 → 建档。</summary>
NewGame,
}
/// <summary>
/// 驱动主菜单存档槽选择面板(新游戏 / 继续 / 删除)。
///
/// 前端选档流程:
/// · 新游戏开档前先选模式(普通 / 钢铁之魂),占用槽位需覆盖确认。
/// · 删除强制走通用确认对话框(无静默删除旁路)。
/// · 卡片展示游玩时长 / 区域 / 货币 / 生命 / 钢魂徽章。
///
/// 模式由 MainMenuController 在打开面板前通过 <see cref="SetMode"/> 指定。
/// ConfirmDialog 与 NewGameMode 面板经 Inspector 注入(同处 MainMenu 场景)。
/// </summary>
public class SaveSlotController : MonoBehaviour
public class SaveSlotController : MonoBehaviour, IFocusable
{
[SerializeField] private SaveSlotUI[] _slotUIs;
[SerializeField] private SaveSlotUI[] _slotUIs;
[Header("子面板Inspector 注入,同处 MainMenu 场景)")]
[Tooltip("通用确认对话框,用于覆盖 / 删除确认。为空时:覆盖退化为直接建档,删除被忽略(绝不静默删除)。")]
[SerializeField] private ConfirmDialogController _confirmDialog;
[Tooltip("新游戏模式选择面板。为空时新游戏退化为普通模式。")]
[SerializeField] private NewGameModeController _modeSelect;
[Header("Event Channels")]
[SerializeField] private IntEventChannelSO _onSlotConfirmed;
[Header("焦点")]
[Tooltip("面板恢复为栈顶时自动聚焦的默认按钮,通常为第一个存档槽的选择按钮。")]
[SerializeField] private Button _defaultFocusButton;
private SaveSlotPanelMode _mode = SaveSlotPanelMode.NewGame;
private CancellationTokenSource _cts;
private void Awake()
@@ -30,6 +60,19 @@ namespace BaseGames.UI.Menus
if (_slotUIs[i] != null) _slotUIs[i].Init(i, this);
}
/// <summary>由 MainMenuController 在 SetActive(true) 之前调用,决定本次打开语境。</summary>
public void SetMode(SaveSlotPanelMode mode) => _mode = mode;
// ── IFocusable ────────────────────────────────────────────────────────
public void OnFocusRestored() => StartCoroutine(RestoreFocusNextFrame());
private System.Collections.IEnumerator RestoreFocusNextFrame()
{
yield return null;
if (UnityEngine.EventSystems.EventSystem.current != null && _defaultFocusButton != null)
UnityEngine.EventSystems.EventSystem.current.SetSelectedGameObject(_defaultFocusButton.gameObject);
}
private void OnEnable()
{
_cts = new CancellationTokenSource();
@@ -44,6 +87,10 @@ namespace BaseGames.UI.Menus
private void OnDisable()
{
// 关闭子对话框,避免下次打开残留
_confirmDialog?.Close();
_modeSelect?.Close();
_cts?.Cancel();
_cts?.Dispose();
_cts = null;
@@ -64,35 +111,96 @@ namespace BaseGames.UI.Menus
for (int i = 0; i < _slotUIs.Length; i++)
{
if (_slotUIs[i] == null) continue;
_slotUIs[i].Refresh(summaries[i]);
_slotUIs[i].Refresh(summaries[i], _mode);
}
}
/// <summary>选中指定槽位(新局或继续)。由 SaveSlotUI 内部按钮调用。</summary>
// ── 选槽 ────────────────────────────────────────────────────────────────
/// <summary>选中指定槽位。行为取决于当前模式与槽位是否有档。由 SaveSlotUI 内部按钮调用。</summary>
public void OnSlotSelected(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
_ = SelectSlotAsync(slotIndex);
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
bool hasData = svc.HasSave(slotIndex);
if (_mode == SaveSlotPanelMode.Continue)
{
if (!hasData) return; // 继续模式:空槽不可选
_ = ContinueSlotAsync(slotIndex);
return;
}
// ── 新游戏模式 ──
if (hasData)
{
// 占用槽位:先覆盖确认
if (_confirmDialog != null)
_confirmDialog.Show("CONFIRM_OVERWRITE_TITLE", "CONFIRM_OVERWRITE_BODY",
onConfirm: () => BeginNewGameFlow(slotIndex));
else
BeginNewGameFlow(slotIndex); // 无对话框时退化为直接建档
}
else
{
BeginNewGameFlow(slotIndex);
}
}
private async Task SelectSlotAsync(int slotIndex)
/// <summary>开新档流程:选模式 → 建档。</summary>
private void BeginNewGameFlow(int slotIndex)
{
if (_modeSelect != null)
_modeSelect.Show(level => _ = StartNewGameAsync(slotIndex, level));
else
_ = StartNewGameAsync(slotIndex, DifficultyLevel.Normal); // 无模式面板时退化为普通
}
private async Task StartNewGameAsync(int slotIndex, DifficultyLevel level)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
// 覆盖既有档:先删盘,确保旧数据不残留(建档仅初始化内存,写盘发生在首次存档)
if (svc.HasSave(slotIndex))
await svc.LoadAsync(slotIndex);
else
svc.CreateSlot(slotIndex);
await svc.DeleteSlotAsync(slotIndex);
bool steel = level == DifficultyLevel.SteelSoul;
svc.CreateSlot(slotIndex, steel);
ServiceLocator.GetOrDefault<IDifficultyService>()?.BeginNewGame(level);
_onSlotConfirmed?.Raise(slotIndex);
}
/// <summary>删除指定槽位存档并刷新 UI。由 SaveSlotUI 内部按钮调用。</summary>
private async Task ContinueSlotAsync(int slotIndex)
{
var svc = ServiceLocator.GetOrDefault<ISaveService>();
if (svc == null) return;
bool ok = await svc.LoadAsync(slotIndex);
if (!ok) return; // 损坏 / 不存在:不前进(后续可接错误提示)
_onSlotConfirmed?.Raise(slotIndex);
}
// ── 删除(强制确认)────────────────────────────────────────────────────
/// <summary>请求删除指定槽位。强制经通用确认对话框;无对话框时忽略,绝不静默删除。</summary>
public void OnSlotDeleteRequested(int slotIndex)
{
if (slotIndex < 0 || slotIndex >= _slotUIs.Length) return;
_ = DeleteAndRefreshAsync(slotIndex);
if (_confirmDialog == null)
{
Debug.LogWarning("[SaveSlotController] 未配置 ConfirmDialog删除请求被忽略防止静默删除。");
return;
}
_confirmDialog.Show("CONFIRM_DELETE_TITLE", "CONFIRM_DELETE_BODY",
onConfirm: () => _ = DeleteAndRefreshAsync(slotIndex));
}
private async Task DeleteAndRefreshAsync(int slotIndex)
@@ -103,143 +211,4 @@ namespace BaseGames.UI.Menus
await RefreshAsync(_cts?.Token ?? CancellationToken.None);
}
}
/// <summary>
/// 单个存档槽卡片组件,负责显示存档摘要或空槽提示。
/// </summary>
public class SaveSlotUI : MonoBehaviour
{
[SerializeField] private TMP_Text _playtimeText;
[SerializeField] private TMP_Text _regionText;
[SerializeField] private TMP_Text _lastSavedText;
[SerializeField] private Image _formIcon;
[SerializeField] private GameObject _emptyIndicator; // 空槽时显示的"新游戏"提示
[SerializeField] private GameObject _dataIndicator; // 有数据时显示的内容根
[SerializeField] private Button _selectButton;
[SerializeField] private Button _deleteButton;
[Header("删除确认(可选)")]
[Tooltip("删除确认对话框根节点,为 null 时删除按钮直接执行。")]
[SerializeField] private GameObject _deleteConfirmRoot;
[Tooltip("确认删除按钮。")]
[SerializeField] private Button _btnConfirmDelete;
[Tooltip("取消删除按钮。")]
[SerializeField] private Button _btnCancelDelete;
private int _slotIndex;
private SaveSlotController _controller;
/// <summary>由 SaveSlotController 在 Awake 或初始化时调用以完成按钮绑定。</summary>
public void Init(int slotIndex, SaveSlotController controller)
{
_slotIndex = slotIndex;
_controller = controller;
if (_selectButton != null)
{
_selectButton.onClick.RemoveAllListeners();
_selectButton.onClick.AddListener(() => _controller.OnSlotSelected(_slotIndex));
}
if (_deleteButton != null)
{
_deleteButton.onClick.RemoveAllListeners();
// 配置了确认对话框时先弹出确认,未配置时直接删除
_deleteButton.onClick.AddListener(ShowDeleteConfirm);
}
if (_btnConfirmDelete != null)
{
_btnConfirmDelete.onClick.RemoveAllListeners();
_btnConfirmDelete.onClick.AddListener(() =>
{
HideDeleteConfirm();
_controller.OnSlotDeleteRequested(_slotIndex);
});
}
if (_btnCancelDelete != null)
{
_btnCancelDelete.onClick.RemoveAllListeners();
_btnCancelDelete.onClick.AddListener(HideDeleteConfirm);
}
HideDeleteConfirm();
}
/// <summary>用摘要数据刷新显示summary 为 null 表示空槽。</summary>
public void Refresh(SlotSummary summary)
{
bool hasData = summary != null;
if (_emptyIndicator != null) _emptyIndicator.SetActive(!hasData);
if (_dataIndicator != null) _dataIndicator.SetActive(hasData);
if (_deleteButton != null) _deleteButton.gameObject.SetActive(hasData);
// 刷新时重置确认对话框状态
HideDeleteConfirm();
if (!hasData) return;
if (_playtimeText != null) _playtimeText.text = FormatPlaytime(summary.Playtime);
if (_regionText != null)
{
string key = summary.SceneName ?? string.Empty;
string loc = !string.IsNullOrEmpty(key)
? LocalizationManager.Get(key, LocalizationTable.UI)
: null;
_regionText.text = !string.IsNullOrEmpty(loc) && loc != key ? loc : key;
}
if (_lastSavedText != null) _lastSavedText.text = FormatDateTime(summary.LastSaved);
}
// ── 删除确认 ──────────────────────────────────────────────────────────
/// <summary>
/// 显示删除确认对话框。
/// 未配置 _deleteConfirmRoot 时直接执行删除(向下兼容旧 Prefab
/// </summary>
private void ShowDeleteConfirm()
{
if (_deleteConfirmRoot == null)
{
// 旧 Prefab 未添加确认根节点,直接删除
_controller.OnSlotDeleteRequested(_slotIndex);
return;
}
_deleteConfirmRoot.SetActive(true);
// 手柄导航:将焦点移至"确认"按钮,防止误触选择按钮
EventSystem.current?.SetSelectedGameObject(_btnConfirmDelete?.gameObject);
}
private void HideDeleteConfirm()
{
if (_deleteConfirmRoot != null) _deleteConfirmRoot.SetActive(false);
}
// ── 格式化工具 ────────────────────────────────────────────────────────
private static string FormatPlaytime(float seconds)
{
int h = (int)(seconds / 3600);
int m = (int)((seconds % 3600) / 60);
int s = (int)(seconds % 60);
return $"{h:D2}:{m:D2}:{s:D2}";
}
private static string FormatDateTime(string iso8601)
{
if (string.IsNullOrEmpty(iso8601)) return string.Empty;
if (DateTime.TryParse(iso8601,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out DateTime dt))
{
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
return iso8601;
}
}
}

View File

@@ -0,0 +1,143 @@
using System;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using BaseGames.Core.Save;
using BaseGames.Localization;
using BaseGames.World.Map;
namespace BaseGames.UI.Menus
{
/// <summary>
/// 单个存档槽卡片组件,负责显示存档摘要或空槽提示。
///
/// ⚠ 必须独立成文件Unity 仅为与文件名同名的类生成 MonoScript 资产,
/// 若与 SaveSlotController 合并在同一文件AddComponent 后脚本引用会丢失Missing Script
/// </summary>
public class SaveSlotUI : MonoBehaviour
{
[Header("基本信息")]
[SerializeField] private TMP_Text _playtimeText;
[SerializeField] private TMP_Text _regionText;
[SerializeField] private TMP_Text _lastSavedText;
[SerializeField] private Image _formIcon;
[Header("扩展信息(可选)")]
[Tooltip("货币(灵珠)持有量文本。")]
[SerializeField] private TMP_Text _lingZhuText;
[Tooltip("生命(面具)上限文本。")]
[SerializeField] private TMP_Text _hpText;
[Tooltip("钢铁之魂徽章根节点,仅一命模式存档显示。")]
[SerializeField] private GameObject _steelSoulBadge;
[Header("区域背景图")]
[Tooltip("RegionRegistry.asset用于根据 SceneName 查找区域背景图。")]
[SerializeField] private RegionRegistrySO _regionRegistry;
[Tooltip("存档点不在任何已注册区域时显示的默认背景图。")]
[SerializeField] private Sprite _fallbackBackground;
[Tooltip("显示区域背景图的 Image 组件。")]
[SerializeField] private Image _backgroundImage;
[Header("槽位状态")]
[SerializeField] private GameObject _emptyIndicator; // 空槽时显示的"新游戏"提示
[SerializeField] private GameObject _dataIndicator; // 有数据时显示的内容根
[SerializeField] private Button _selectButton;
[SerializeField] private Button _deleteButton;
private int _slotIndex;
private SaveSlotController _controller;
/// <summary>由 SaveSlotController 在 Awake 时调用以完成按钮绑定。</summary>
public void Init(int slotIndex, SaveSlotController controller)
{
_slotIndex = slotIndex;
_controller = controller;
if (_selectButton != null)
{
_selectButton.onClick.RemoveAllListeners();
_selectButton.onClick.AddListener(() => _controller.OnSlotSelected(_slotIndex));
}
if (_deleteButton != null)
{
_deleteButton.onClick.RemoveAllListeners();
// 删除统一走 Controller → 通用确认对话框(不再使用每卡内联确认)
_deleteButton.onClick.AddListener(() => _controller.OnSlotDeleteRequested(_slotIndex));
}
}
/// <summary>用摘要数据刷新显示summary 为 null 表示空槽。</summary>
/// <param name="summary">槽位摘要null 为空槽。</param>
/// <param name="mode">当前面板模式:继续模式下空槽不可选。</param>
public void Refresh(SlotSummary summary, SaveSlotPanelMode mode)
{
bool hasData = summary != null;
if (_emptyIndicator != null) _emptyIndicator.SetActive(!hasData);
if (_dataIndicator != null) _dataIndicator.SetActive(hasData);
if (_deleteButton != null) _deleteButton.gameObject.SetActive(hasData);
// 继续模式下空槽不可选;新游戏模式下空槽可选(建新档)
if (_selectButton != null)
_selectButton.interactable = hasData || mode == SaveSlotPanelMode.NewGame;
if (_steelSoulBadge != null)
_steelSoulBadge.SetActive(hasData && summary.IsSteelSoul);
if (!hasData) return;
if (_playtimeText != null) _playtimeText.text = FormatPlaytime(summary.Playtime);
if (_regionText != null)
{
string key = summary.SceneName ?? string.Empty;
string loc = !string.IsNullOrEmpty(key)
? LocalizationManager.Get(key, LocalizationTable.UI)
: null;
_regionText.text = !string.IsNullOrEmpty(loc) && loc != key ? loc : key;
}
if (_lastSavedText != null) _lastSavedText.text = FormatDateTime(summary.LastSaved);
if (_lingZhuText != null) _lingZhuText.text = summary.CurrentLingZhu.ToString();
if (_hpText != null) _hpText.text = summary.MaxHP.ToString();
RefreshBackground(summary);
}
private void RefreshBackground(SlotSummary summary)
{
if (_backgroundImage == null) return;
Sprite bg = null;
if (summary != null && !string.IsNullOrEmpty(summary.SceneName))
bg = _regionRegistry?.FindBySceneName(summary.SceneName)?.saveSlotBackground;
_backgroundImage.sprite = bg != null ? bg : _fallbackBackground;
_backgroundImage.enabled = _backgroundImage.sprite != null;
}
// ── 格式化工具 ────────────────────────────────────────────────────────
private static string FormatPlaytime(float seconds)
{
int h = (int)(seconds / 3600);
int m = (int)((seconds % 3600) / 60);
int s = (int)(seconds % 60);
return $"{h:D2}:{m:D2}:{s:D2}";
}
private static string FormatDateTime(string iso8601)
{
if (string.IsNullOrEmpty(iso8601)) return string.Empty;
if (DateTime.TryParse(iso8601,
System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.RoundtripKind,
out DateTime dt))
{
return dt.ToLocalTime().ToString("yyyy-MM-dd HH:mm");
}
return iso8601;
}
}
}

View File

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

View File

@@ -3,6 +3,7 @@ using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using BaseGames.Core;
using BaseGames.Core.Assets;
using BaseGames.Core.Events;
namespace BaseGames.UI
@@ -19,6 +20,7 @@ namespace BaseGames.UI
Shop,
CharmPanel,
SpellSelect,
Inventory,
}
[DefaultExecutionOrder(+50)]
@@ -49,6 +51,10 @@ namespace BaseGames.UI
[SerializeField] private VoidEventChannelSO _onMapOpen;
[SerializeField] private VoidEventChannelSO _onCharmPanelOpen;
[SerializeField] private VoidEventChannelSO _onSpellSelectOpen;
[Tooltip("打开统一背包菜单InventoryHub。对应 EVT_InventoryOpen。")]
[SerializeField] private VoidEventChannelSO _onInventoryOpen;
[Tooltip("UI 取消操作ESC / 手柄 B·Circle全局关闭栈顶面板。对应 EVT_UICancelPressed。")]
[SerializeField] private VoidEventChannelSO _onUICancelPressed;
// ── 面板栈结构 ────────────────────────────────────────────────────────
private readonly Stack<GameObject> _panelStack = new();
@@ -115,6 +121,8 @@ namespace BaseGames.UI
_onMapOpen?.Subscribe(OpenMap).AddTo(_subs);
_onCharmPanelOpen?.Subscribe(OpenCharmPanel).AddTo(_subs);
_onSpellSelectOpen?.Subscribe(OpenSpellSelect).AddTo(_subs);
_onInventoryOpen?.Subscribe(OpenInventory).AddTo(_subs);
_onUICancelPressed?.Subscribe(HandleUICancelPressed).AddTo(_subs);
}
private void OnDisable()
@@ -137,7 +145,7 @@ namespace BaseGames.UI
}
else if (h.Handle.IsValid())
{
Addressables.Release(h.Handle);
AssetLoader.Release(h.Handle);
}
}
_addressableHandles.Clear();
@@ -257,6 +265,12 @@ namespace BaseGames.UI
}
// ── 快捷事件回调 ──────────────────────────────────────────────────────
private void HandleUICancelPressed()
{
if (_panelStack.Count > 0)
CloseTopPanel();
}
private void TogglePause()
{
if (_panelRegistry.TryGetValue(PanelId.Pause, out var pausePanel)
@@ -269,6 +283,7 @@ namespace BaseGames.UI
private void OpenMap() => OpenPanel(PanelId.Map);
private void OpenCharmPanel() => OpenPanel(PanelId.CharmPanel);
private void OpenSpellSelect() => OpenPanel(PanelId.SpellSelect);
private void OpenInventory() => OpenPanel(PanelId.Inventory);
// ── 编辑器工具 ────────────────────────────────────────────────────────
[ContextMenu("验证面板注册表")]

View File

@@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using BaseGames.Core.Assets;
namespace BaseGames.VFX
{
@@ -73,7 +74,7 @@ namespace BaseGames.VFX
Quaternion rotation,
float maxLifetime)
{
var op = Addressables.InstantiateAsync(vfxRef, transform);
var op = AssetLoader.InstantiateAsync(vfxRef, transform);
yield return op;
if (op.Result == null)
{
@@ -84,7 +85,7 @@ namespace BaseGames.VFX
if (ps == null)
{
Debug.LogError($"[VFXPool] No ParticleSystem on VFX prefab: {vfxRef.RuntimeKey}");
Addressables.ReleaseInstance(op.Result);
AssetLoader.ReleaseInstance(op.Result);
yield break;
}
ps.gameObject.SetActive(false);
@@ -126,11 +127,11 @@ namespace BaseGames.VFX
{
for (int i = 0; i < count; i++)
{
var op = Addressables.InstantiateAsync(vfxRef, transform);
var op = AssetLoader.InstantiateAsync(vfxRef, transform);
yield return op;
if (op.Result == null) continue;
var ps = op.Result.GetComponent<ParticleSystem>();
if (ps == null) { Addressables.ReleaseInstance(op.Result); continue; }
if (ps == null) { AssetLoader.ReleaseInstance(op.Result); continue; }
ps.gameObject.SetActive(false);
Enqueue(vfxRef, ps);
}

View File

@@ -14,6 +14,7 @@
"BaseGames.Core.Events",
"BaseGames.Input",
"BaseGames.Localization",
"BaseGames.Player",
"Unity.TextMeshPro"
],
"autoReferenced": true,

View File

@@ -33,6 +33,24 @@ namespace BaseGames.World.Map
/// <summary>返回属于指定区域的所有房间数据regionId 为空时返回空数组。</summary>
MapRoomDataSO[] GetRoomsByRegion(string regionId);
// ── 定位门控Locator / 指南针)────────────────────────────────────────
/// <summary>
/// 是否已启用"定位"能力(由指南针类护符解锁)。
/// 为 false 时,地图 UI 应隐藏玩家位置点——玩家能看地图但不知自己在哪。
/// 由 <see cref="SetLocatorEnabled"/> 控制,跨存档持久化。
/// </summary>
bool IsLocatorEnabled { get; }
/// <summary>
/// 设置定位能力开关(拾取/装备指南针道具时调用 true
/// 状态变化时触发 <see cref="OnLocatorChanged"/> 并写入存档。
/// </summary>
void SetLocatorEnabled(bool enabled);
/// <summary>定位能力开关变化时触发;地图 UI 据此显示/隐藏玩家位置点。</summary>
event Action OnLocatorChanged;
/// <summary>
/// 当地图数据库结构发生变化(房间增删/编辑器热改/运行时热更)时触发。
/// MapPanel、MinimapHUD 等 UI 应订阅此事件以执行完整重建。

View File

@@ -31,6 +31,7 @@ namespace BaseGames.World.Map
private HashSet<string> _exploredRooms = new();
private HashSet<string> _mappedRooms = new();
private string _currentRegionId;
private bool _locatorEnabled; // 定位能力(指南针);控制地图玩家点是否显示,存档持久化
private int _totalRoomCount = -1; // -1 = 未缓存OnLoad 后重置
private Dictionary<string, MapRoomDataSO[]> _regionCache; // R11-N6 GetRoomsByRegion 结果缓存
private bool _isDuplicate; // Awake 检测到重复实例时置位OnEnable/OnDisable 提前 return
@@ -60,9 +61,10 @@ namespace BaseGames.World.Map
public void OnSave(SaveData data)
{
data.Map.ExploredRooms = new HashSet<string>(_exploredRooms);
data.Map.MappedRooms = new HashSet<string>(_mappedRooms);
data.Map.LastRegionId = _currentRegionId;
data.Map.ExploredRooms = new HashSet<string>(_exploredRooms);
data.Map.MappedRooms = new HashSet<string>(_mappedRooms);
data.Map.LastRegionId = _currentRegionId;
data.Map.LocatorUnlocked = _locatorEnabled;
}
public void OnLoad(SaveData data)
@@ -70,10 +72,13 @@ namespace BaseGames.World.Map
_exploredRooms = data.Map.ExploredRooms != null ? new HashSet<string>(data.Map.ExploredRooms) : new HashSet<string>();
_mappedRooms = data.Map.MappedRooms != null ? new HashSet<string>(data.Map.MappedRooms) : new HashSet<string>();
_currentRegionId = data.Map.LastRegionId; // 恢复区域 ID避免读档后首次进房误触发 EVT_RegionChanged
_locatorEnabled = data.Map.LocatorUnlocked;
_totalRoomCount = -1; // 强制下次调用 GetExplorationProgress 时重新计数
// 读档后广播UI 仅需轻量刷新(不重建结构);订阅 OnExplorationChanged 的 UI 会 RefreshAllCells
OnExplorationChanged?.Invoke();
// 定位状态可能随存档变化,通知 UI 显示/隐藏玩家位置点
OnLocatorChanged?.Invoke();
}
// ── 事件驱动房间发现 ──────────────────────────────────────────────────
@@ -136,6 +141,23 @@ namespace BaseGames.World.Map
public bool IsExplored(string roomId) => _exploredRooms.Contains(roomId);
public bool IsMapped(string roomId) => _mappedRooms.Contains(roomId);
public string CurrentRegionId => _currentRegionId;
// ── 定位门控(指南针)─────────────────────────────────────────────────
/// <inheritdoc/>
public bool IsLocatorEnabled => _locatorEnabled;
/// <inheritdoc/>
public event Action OnLocatorChanged;
/// <inheritdoc/>
public void SetLocatorEnabled(bool enabled)
{
if (_locatorEnabled == enabled) return; // 幂等:状态未变化不广播
_locatorEnabled = enabled;
OnLocatorChanged?.Invoke();
}
public MapDatabaseSO Database => _database;
public int ExploredRoomCount => _exploredRooms.Count;

View File

@@ -7,6 +7,7 @@ using TMPro;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Localization;
namespace BaseGames.World.Map
{
@@ -42,6 +43,17 @@ namespace BaseGames.World.Map
[Header("玩家位置")]
[SerializeField] private Image _playerIconImg; // _roomContainer 内的玩家图标
[Tooltip("勾选后未启用定位能力IMapService.IsLocatorEnabled时隐藏玩家图标与当前房间高亮——\n" +
"设计:拿到指南针护符前能看地图但不知自己在哪。\n" +
"默认 false 保持现有\"恒显\"行为。与 MinimapHUD._requireLocatorForPlayerDot 对称。")]
[SerializeField] private bool _requireLocatorForPlayerIcon;
[Header("传送选择")]
[Tooltip("勾选后,点击地图上\"已解锁且已探索\"的传送站房间会触发 OnTeleportStationSelected\n" +
"由 UI 侧 MapTeleportConfirmController 弹出确认框并调用 ITeleportService.RequestTeleport。\n" +
"取消勾选可禁止从全屏地图发起传送(如改为仅在传送站处选择目的地)。")]
[SerializeField] private bool _allowTeleportFromMap = true;
[Header("地图标记")]
[SerializeField] private Image _pinPrefab;
[SerializeField] private MapPinConfigSO _pinConfig; // R12-N3 集中 PinType→Sprite 映射,替代旧的 PinSpriteEntry[]
@@ -74,6 +86,15 @@ namespace BaseGames.World.Map
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
private ITeleportService _teleportSvc;
/// <summary>
/// 玩家在地图上点击了一个"可传送"的已解锁站点(参数 RoomId
/// UI 侧MapTeleportConfirmController位于 BaseGames.UI 程序集)订阅此事件,
/// 弹出确认框并在确认后调用 ITeleportService.RequestTeleport——
/// MapPanel 自身不依赖 UI 程序集,避免与 BaseGames.UI 形成循环引用。
/// </summary>
public event Action<string> OnTeleportStationSelected;
private void Awake()
{
@@ -99,6 +120,7 @@ namespace BaseGames.World.Map
RenderPins();
UpdatePlayerIcon();
RefreshTeleportMarks(); // 每次打开刷新可传送标记(覆盖在站点新解锁后重开地图的场景)
CenterOnCurrentRoom();
// R12-N8移除 _onMapUpdated 订阅,避免与 OnExplorationChanged 双重刷新;
// _onMapUpdated 字段保留但标记 HideInInspector防止旧 Prefab 数据丢失。
@@ -144,10 +166,12 @@ namespace BaseGames.World.Map
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
_mapSvc.OnRoomMapped += OnRoomMappedAnim;
_mapSvc.OnLocatorChanged += OnLocatorChanged;
}
}
_playerProvider ??= ServiceLocator.GetOrDefault<IPlayerPositionProvider>();
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
_teleportSvc ??= ServiceLocator.GetOrDefault<ITeleportService>(); // 可选:未注册时地图仍正常工作,仅不显示可传送标记
if (_mapSvc != null && _playerProvider != null && _pinService != null)
_servicesReady = true;
}
@@ -162,6 +186,7 @@ namespace BaseGames.World.Map
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc.OnRoomMapped -= OnRoomMappedAnim;
_mapSvc.OnLocatorChanged -= OnLocatorChanged;
}
}
@@ -190,6 +215,13 @@ namespace BaseGames.World.Map
RebuildAll();
}
/// <summary>定位能力开关变化:重置 dirty 标记强制 UpdatePlayerIcon 重新评估门控。</summary>
private void OnLocatorChanged()
{
_lastIconRoomId = null; // 绕过 LateUpdate 的 roomId/normPos 脏检查,确保门控状态立即生效
if (isActiveAndEnabled) UpdatePlayerIcon();
}
/// <summary>R10-N12 探索进度变化:仅刷新格子可见性,不重建结构(轻量级)。</summary>
private void OnExplorationChanged()
{
@@ -252,6 +284,27 @@ namespace BaseGames.World.Map
if (cell == null) continue;
cell.SetVisibility(_mapSvc.GetVisibility(roomId));
}
RefreshTeleportMarks(); // 探索状态影响 CanTeleportTo同步刷新可传送标记
}
// ── 传送选择 ──────────────────────────────────────────────────────────
/// <summary>房间是否可作为传送目的地:允许从地图传送 + 传送服务存在 + 已解锁且已探索。</summary>
private bool IsTeleportable(string roomId)
=> _allowTeleportFromMap && _teleportSvc != null && _teleportSvc.CanTeleportTo(roomId);
/// <summary>格子点击回调:仅对可传送站点转发选择事件,交由 UI 侧确认并执行传送。</summary>
private void OnCellClicked(string roomId)
{
if (IsTeleportable(roomId))
OnTeleportStationSelected?.Invoke(roomId);
}
/// <summary>刷新所有格子的"可传送"标记(探索/传送解锁变化、面板重开时调用)。</summary>
private void RefreshTeleportMarks()
{
foreach (var (roomId, cell) in _cells)
if (cell != null) cell.SetTeleportable(IsTeleportable(roomId));
}
// ── 格子 & 出口连接 ──────────────────────────────────────────────────
@@ -279,6 +332,8 @@ namespace BaseGames.World.Map
// R11-N8 布局单独调用 SetGridLayout与 MinimapHUD.PlaceCell 职责对称
cell.SetGridLayout(room, MapGridConstants.FullMapCellPixels);
cell.SetColors(_colorExplored, _colorMapped, _colorUnknown);
cell.SetClickHandler(OnCellClicked);
cell.SetTeleportable(IsTeleportable(room.RoomId));
_cells[room.RoomId] = cell;
}
DrawExits();
@@ -343,6 +398,15 @@ namespace BaseGames.World.Map
private void UpdatePlayerIcon()
{
if (_playerIconImg == null || _playerProvider == null) return;
// 定位门控:未启用定位能力时隐藏玩家图标与当前房间高亮(由指南针护符解锁)
if (_requireLocatorForPlayerIcon && (_mapSvc == null || !_mapSvc.IsLocatorEnabled))
{
_playerIconImg.enabled = false;
UpdateCellHighlight(null);
return;
}
var roomId = _playerProvider.CurrentRoomId;
if (string.IsNullOrEmpty(roomId) || !_cells.TryGetValue(roomId, out var cell))
{
@@ -469,7 +533,8 @@ namespace BaseGames.World.Map
private void ShowTooltip(string text)
{
if (_tooltipPanel == null || string.IsNullOrEmpty(text)) return;
if (_tooltipText != null) _tooltipText.text = text;
// 房间名走本地化text 为本地化 Key 时解析为译文;为普通名称(未命中 Key时原样显示向后兼容
if (_tooltipText != null) _tooltipText.text = LocalizationManager.Get(text, LocalizationTable.UI);
_tooltipPanel.SetActive(true);
}

View File

@@ -1,6 +1,7 @@
using System;
using UnityEngine;
using BaseGames.Core;
using BaseGames.Core.Events;
namespace BaseGames.World.Map
{
@@ -29,6 +30,10 @@ namespace BaseGames.World.Map
"例如:关卡第一个房间的世界左下角在 (-36, -18) 时,此处填 (-36, -18)。")]
[SerializeField] private Vector2 _worldOriginOffset = Vector2.zero;
[Tooltip("玩家进入新房间时广播此频道EVT_RoomEnteredMapManager 据此标记已探索、" +
"RoomStreamingManager 重算流式集、EventChainManager 触发房间条件。留空则不广播。")]
[SerializeField] private StringEventChannelSO _onRoomEntered;
/// <summary>玩家当前所在房间 ID未在任何已知房间内时为 null。</summary>
public string CurrentRoomId { get; private set; }
@@ -101,7 +106,10 @@ namespace BaseGames.World.Map
_currentRoom = _database.GetRoom(newRoomId);
if (newRoomId != prevRoomId)
{
OnRoomChanged?.Invoke(newRoomId);
_onRoomEntered?.Raise(newRoomId); // 生产者:进房广播,驱动地图揭示/流式/事件链
}
}
// 每帧从世界坐标精确计算归一化位置,实现平滑图标跟随

View File

@@ -4,6 +4,7 @@ using TMPro;
using System.Collections.Generic;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Localization;
namespace BaseGames.World.Map
{
@@ -88,7 +89,7 @@ namespace BaseGames.World.Map
float progress = _mapSvc.GetExplorationProgress();
try
{
_globalProgressText.text = string.Format(_globalFormat, progress);
_globalProgressText.text = string.Format(ResolveFormat(_globalFormat, "{0:P0}"), progress);
}
catch (System.FormatException)
{
@@ -111,7 +112,7 @@ namespace BaseGames.World.Map
string regionDisplayName = ResolveRegionDisplayName(_currentRegionId);
try
{
_regionProgressText.text = string.Format(_regionFormat, regionProgress, regionDisplayName);
_regionProgressText.text = string.Format(ResolveFormat(_regionFormat, "{1} {0:P0}"), regionProgress, regionDisplayName);
}
catch (System.FormatException)
{
@@ -124,6 +125,21 @@ namespace BaseGames.World.Map
// ── 辅助方法 ──────────────────────────────────────────────────────────
/// <summary>
/// 将格式串字段解析为本地化格式串,并保证始终含 {0} 占位符:
/// ① 字段填本地化 Key如 "MAP_PROGRESS_GLOBAL")→ 返回本地化值(如 "已探索 {0:P0}"
/// ② 字段直接是格式串 → Get 原样返回(向后兼容);
/// ③ 本地化服务缺失 / Key 未命中(返回值无 {0})→ 回退到 <paramref name="fallback"/>
/// 确保即使本地化未就绪也不丢失百分比数值。
/// </summary>
private static string ResolveFormat(string keyOrFormat, string fallback)
{
if (string.IsNullOrEmpty(keyOrFormat)) return fallback;
string s = LocalizationManager.Get(keyOrFormat, LocalizationTable.UI);
// 检测 "{0"(兼容 "{0}"、"{0:P0}"、"{0:F1}" 等格式说明符);缺占位符视为未解析,用回退
return (!string.IsNullOrEmpty(s) && s.Contains("{0")) ? s : fallback;
}
/// <summary>预建 RegionId → Entry 字典O(1) 查询。</summary>
private void BuildRegionDict()
=> _regionDict = MapServiceExtensions.BuildRegionDict(_regionNames);

View File

@@ -15,13 +15,14 @@ namespace BaseGames.World.Map
/// 颜色通过 <see cref="SetColors"/> 从外部注入,不在此处硬编码。
/// <para><see cref="RT"/> 属性在 Awake 中缓存,避免调用方反复 GetComponent。</para>
/// </summary>
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
public class MapRoomCellUI : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler, IPointerClickHandler
{
[SerializeField] private Image _bg;
[SerializeField] private Image _icon;
[SerializeField] private RawImage _outlineImage; // 可选:房间非矩形轮廓纹理
[SerializeField] private Image _highlight; // 可选:当前房间高亮描边(玩家所在时激活)
[SerializeField] private Image _fogOverlay; // 可选未知房间雾效覆盖层R12-FD
[SerializeField] private Image _teleportMarker;// 可选可传送站点标记CanTeleportTo 为真时激活)
// 实例颜色(默认值与原硬编码保持一致);可通过 SetColors 统一覆盖
private Color _colExplored = Color.white;
@@ -30,8 +31,10 @@ namespace BaseGames.World.Map
private RoomVisibility _currentVisibility;
private string _displayName;
private string _roomId; // 供点击回调携带(传送选择)
private Action<string> _onHover;
private Action _onHoverExit;
private Action<string> _onClick; // 点击回调(传送选择),由 MapPanel 注入
/// <summary>格子的 RectTransformAwake 中缓存,外部直接访问无需 GetComponent。</summary>
public RectTransform RT { get; private set; }
@@ -47,6 +50,7 @@ namespace BaseGames.World.Map
Action<string> onHover = null, Action onHoverExit = null)
{
_displayName = room.DisplayName;
_roomId = room.RoomId;
_onHover = onHover;
_onHoverExit = onHoverExit;
@@ -113,6 +117,15 @@ namespace BaseGames.World.Map
if (_highlight != null) _highlight.enabled = v;
}
/// <summary>注入点击回调(携带 RoomId由 MapPanel 在创建格子时设置MinimapHUD 不设置即不可点击。</summary>
public void SetClickHandler(Action<string> onClick) => _onClick = onClick;
/// <summary>设置"可传送站点"标记显隐CanTeleportTo 为真时由 MapPanel 调用)。</summary>
public void SetTeleportable(bool v)
{
if (_teleportMarker != null) _teleportMarker.enabled = v;
}
/// <summary>
/// 新发现房间时播放闪白淡出动画R12-FC
/// 由 MapPanel.OnRoomMappedAnim 调用;协程安全:组件被销毁后 Unity 自动终止。
@@ -138,5 +151,8 @@ namespace BaseGames.World.Map
}
public void OnPointerExit(PointerEventData _) => _onHoverExit?.Invoke();
/// <summary>点击格子:转发 RoomId 给注入的回调MapPanel 据 CanTeleportTo 决定是否发起传送选择)。</summary>
public void OnPointerClick(PointerEventData _) => _onClick?.Invoke(_roomId);
}
}

View File

@@ -27,6 +27,12 @@ namespace BaseGames.World.Map
[SerializeField, Min(1)] private int _viewRadiusCells = 3; // 以玩家房间中心为圆心的可视半径(格)
[SerializeField] private float _cellPixels = 16f; // 每格显示像素数
[Header("定位门控(指南针)")]
[Tooltip("勾选后未启用定位能力IMapService.IsLocatorEnabled时隐藏玩家位置点——\n" +
"设计:拿到指南针护符前能看地图但不知自己在哪。\n" +
"默认 false 保持现有\"恒显\"行为,需要该玩法时再开启。")]
[SerializeField] private bool _requireLocatorForPlayerDot;
[Header("颜色Inspector 覆盖)")]
[SerializeField] private Color _colorExplored = Color.white;
[SerializeField] private Color _colorMapped = new Color(0.45f, 0.45f, 0.45f, 1f);
@@ -53,6 +59,12 @@ namespace BaseGames.World.Map
[SerializeField] private int[] _zoomLevels = { 2, 3, 5 }; // R12-FA 可用视野半径档位(格)
private int _zoomLevelIndex;
[Header("自动贴边(可选)")]
[Tooltip("勾选后,启用时把小地图定位到所在 Canvas 的右上角(按 _screenMargin 像素留边)。\n" +
"用于父节点非 RectTransform如普通 Transform 的 HUDRoot时锚定失效的情况保证运行时正确贴边。")]
[SerializeField] private bool _autoAnchorTopRight = true;
[SerializeField] private Vector2 _screenMargin = new Vector2(16f, 16f);
private IMapService _mapSvc;
private IPlayerPositionProvider _playerProvider;
private IPinService _pinService;
@@ -90,6 +102,8 @@ namespace BaseGames.World.Map
// 启动顺序兜底
SubscribeServices();
if (_autoAnchorTopRight) AnchorToCanvasTopRight();
// R10-N1/N2 应用关闭期间累积的状态变化
if (_databaseDirty)
{
@@ -142,6 +156,7 @@ namespace BaseGames.World.Map
{
_mapSvc.OnDatabaseChanged += OnDatabaseChanged;
_mapSvc.OnExplorationChanged += OnExplorationChanged;
_mapSvc.OnLocatorChanged += OnLocatorChanged;
}
}
_pinService ??= ServiceLocator.GetOrDefault<IPinService>();
@@ -163,6 +178,7 @@ namespace BaseGames.World.Map
{
_mapSvc.OnDatabaseChanged -= OnDatabaseChanged;
_mapSvc.OnExplorationChanged -= OnExplorationChanged;
_mapSvc.OnLocatorChanged -= OnLocatorChanged;
_mapSvc = null;
}
_pinService = null;
@@ -219,6 +235,13 @@ namespace BaseGames.World.Map
if (cell != null) cell.SetVisibility(_mapSvc.GetVisibility(id));
}
/// <summary>定位能力开关变化:重置 dirty 标记强制 UpdatePlayerDot 重新评估门控。</summary>
private void OnLocatorChanged()
{
_lastDotRoomId = null; // 绕过 UpdatePlayerDot 的 roomId/normPos 脏检查,确保门控状态立即生效
if (isActiveAndEnabled) UpdatePlayerDot();
}
/// <summary>数据库结构变更时完整重建:清空所有格子,下次 RefreshView 重新实例化。</summary>
private void OnDatabaseChanged()
{
@@ -386,6 +409,13 @@ namespace BaseGames.World.Map
{
if (_playerDot == null || _playerProvider == null) return;
// 定位门控:未启用定位能力时隐藏玩家点(由指南针护符解锁)
if (_requireLocatorForPlayerDot && (_mapSvc == null || !_mapSvc.IsLocatorEnabled))
{
_playerDot.enabled = false;
return;
}
var roomId = _playerProvider.CurrentRoomId;
var normPos = _playerProvider.NormalizedPositionInRoom;
@@ -429,5 +459,26 @@ namespace BaseGames.World.Map
_viewDirty = true;
}
}
// ── 自动贴边 ──────────────────────────────────────────────────────────
/// <summary>
/// 将小地图定位到所在 Canvas 的右上角pivot 设为右上position 设到画布右上角内缩 _screenMargin
/// 不依赖父节点为 RectTransform——直接用世界坐标定位规避父节点为普通 Transform 时锚定失效的问题。
/// </summary>
private void AnchorToCanvasTopRight()
{
var canvas = GetComponentInParent<Canvas>();
if (canvas == null) return;
if (canvas.transform is not RectTransform canvasRt) return;
if (transform is not RectTransform rt) return;
var corners = new Vector3[4];
canvasRt.GetWorldCorners(corners); // 0=BL, 1=TL, 2=TR, 3=BR
rt.pivot = new Vector2(1f, 1f);
rt.position = new Vector3(corners[2].x - _screenMargin.x,
corners[2].y - _screenMargin.y,
rt.position.z);
}
}
}

View File

@@ -0,0 +1,40 @@
using UnityEngine;
using BaseGames.Player;
namespace BaseGames.World.Map
{
/// <summary>
/// 区域定义 SO。集中管理区域元数据Identity、地图展示、存档槽背景图、解锁条件。
///
/// 房间归属关系由 <see cref="MapRoomDataSO.RegionId"/> 维护(单一权威来源)。
/// <see cref="RegionRegistrySO"/> 运行时从 <see cref="MapDatabaseSO"/> 构建 SceneName → Region 缓存,
/// 不再在此 SO 维护 roomSceneNames 冗余字段。
///
/// 资产路径: Assets/_Game/Data/Map/Regions/Region_{regionId}.asset
/// </summary>
[CreateAssetMenu(menuName = "BaseGames/Map/RegionDefinition", fileName = "Region_")]
public class RegionDefinitionSO : ScriptableObject
{
[Header("Identity")]
public string regionId;
public string displayName;
[Header("Map")]
public Color mapColor;
public Sprite mapIconSprite;
[Header("存档槽展示")]
[Tooltip("存档槽卡片背景图。建议 480×270放于 Assets/_Game/Art/UI/SaveSlot/ 下,命名 SaveSlot_BG_{regionId}.png。")]
public Sprite saveSlotBackground;
[Header("解锁条件")]
[Tooltip("击败指定 Boss 后解锁此区域;留空 = 无条件。")]
public string requiredBossDefeated;
[Tooltip("需持有指定能力方可进入None = 无要求。")]
public AbilityType requiredAbility;
[Header("导航")]
[Tooltip("从区域外部进入时的首个房间场景名RoomId。")]
public string entrySceneName;
}
}

Some files were not shown because too many files have changed in this diff Show More