地图系统
This commit is contained in:
@@ -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:
|
||||
Reference in New Issue
Block a user