地图系统

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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