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