Files
zeling_v2/Assets/_Game/Scripts/Editor/Scene/SceneScaffoldTools.cs
2026-06-05 18:41:33 +08:00

1292 lines
79 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.Generic;
using BaseGames.Audio;
using BaseGames.Camera;
using BaseGames.Core;
using BaseGames.Core.Events;
using BaseGames.Core.Save;
using BaseGames.Core.Pool;
using BaseGames.Input;
using BaseGames.UI;
using BaseGames.UI.HUD;
using BaseGames.UI.MainMenu;
using BaseGames.UI.Menus;
using BaseGames.UI.Splash;
using BaseGames.World;
using BaseGames.World.Map;
using BaseGames.World.Streaming;
using PathBerserker2d;
using Unity.Cinemachine;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.Tilemaps;
using UnityEngine.UI;
using BaseGames.Feedback;
using MoreMountains.Feedbacks;
using TMPro;
namespace BaseGames.Editor
{
public static class SceneScaffoldTools
{
[MenuItem("BaseGames/Scene/Setup/Scaffold Persistent Scene")]
public static void ScaffoldPersistentScene()
{
var report = new List<string>();
EnsureEventChannelAssets(report);
GameObject root = GetOrCreateRoot("[Persistent]");
Transform services = GetOrCreateChild(root.transform, "[Services]");
Transform input = GetOrCreateChild(root.transform, "[Input]");
Transform camera = GetOrCreateChild(root.transform, "[Camera]");
Transform ui = GetOrCreateChild(root.transform, "[UI]");
GameObject registrarGo = GetOrCreateChild(services, "GameServiceRegistrar").gameObject;
GameObject deathRespawnGo = GetOrCreateChild(services, "DeathRespawnService").gameObject;
GameObject sceneServiceGo = GetOrCreateChild(services, "SceneService").gameObject;
GameObject sceneLoaderGo = GetOrCreateChild(services, "SceneLoader").gameObject;
GameObject registryGo = GetOrCreateChild(services, "EventChannelRegistry").gameObject;
GameObject settingsGo = GetOrCreateChild(services, "SettingsManager").gameObject;
GameObject poolGo = GetOrCreateChild(services, "GlobalObjectPool").gameObject;
GameObject gameManagerGo = GetOrCreateChild(services, "GameManager").gameObject;
GameObject audioManagerGo = GetOrCreateChild(services, "AudioManager").gameObject;
GameObject saveManagerGo = GetOrCreateChild(services, "GameSaveManager").gameObject;
GameObject checkpointServiceGo = GetOrCreateChild(services, "CheckpointService").gameObject;
GameServiceRegistrar registrar = GetOrAddComponent<GameServiceRegistrar>(registrarGo);
DeathRespawnService deathRespawnService = GetOrAddComponent<DeathRespawnService>(deathRespawnGo);
SceneService sceneService = GetOrAddComponent<SceneService>(sceneServiceGo);
SceneLoader sceneLoader = GetOrAddComponent<SceneLoader>(sceneLoaderGo);
EventChannelRegistry registry = GetOrAddComponent<EventChannelRegistry>(registryGo);
SettingsManager settingsManager = GetOrAddComponent<SettingsManager>(settingsGo);
GetOrAddComponent<GlobalObjectPool>(poolGo);
GameManager gameManager = GetOrAddComponent<GameManager>(gameManagerGo);
AudioManager audioManager = GetOrAddComponent<AudioManager>(audioManagerGo);
GameSaveManager gameSaveManager = GetOrAddComponent<GameSaveManager>(saveManagerGo);
CheckpointService checkpointService = GetOrAddComponent<CheckpointService>(checkpointServiceGo);
GameObject inputHolderGo = GetOrCreateChild(input, "InputReaderHolder").gameObject;
Object inputReaderAsset = FindFirstAssetByType<InputReaderSO>("InputReader", "InputReaderSO");
if (inputReaderAsset == null)
inputReaderAsset = EnsureInputReaderAsset(report);
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);
AssignReference(inputReaderAsset, "_inputActions", FindFirstAssetWithExtension(".inputactions", "PlayerInputActions", "InputActions"), report);
}
if (inputReaderAsset == null)
report.Add("未找到 InputReaderSO 资产InputReaderBootstrap 将保持空引用。请补齐 Assets/_Game/Data/Player/Input/InputReader.asset。");
GameObject mainCameraGo = GetOrCreateChild(camera, "Main Camera").gameObject;
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);
GameObject cameraStateGo = GetOrCreateChild(camera, "CameraStateController").gameObject;
CameraStateController cameraStateController = GetOrAddComponent<CameraStateController>(cameraStateGo);
CinemachineImpulseSource impulseSource = GetOrAddComponent<CinemachineImpulseSource>(cameraStateGo);
// 垂直窥视系统独立节点CameraStateController 持引用
GameObject lookSystemGo = GetOrCreateChild(camera, "CameraLookSystem").gameObject;
CameraLookSystem lookSystem = GetOrAddComponent<CameraLookSystem>(lookSystemGo);
GameObject vcamAGo = GetOrCreateChild(camera, "VCamA").gameObject;
CinemachineCamera vcamA = GetOrAddComponent<CinemachineCamera>(vcamAGo);
GetOrAddComponent<CinemachineConfiner3D>(vcamAGo);
GetOrAddComponent<CameraAxisLockExtension>(vcamAGo);
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamAGo);
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamAGo);
// CinemachinePositionComposerBody 阶段组件必须存在ConfigureSlot 依赖它写入所有相机跟随参数
var composerA = GetOrAddComponent<CinemachinePositionComposer>(vcamAGo);
ApplyComposerDefaults(composerA);
GameObject vcamBGo = GetOrCreateChild(camera, "VCamB").gameObject;
CinemachineCamera vcamB = GetOrAddComponent<CinemachineCamera>(vcamBGo);
GetOrAddComponent<CinemachineConfiner3D>(vcamBGo);
GetOrAddComponent<CameraAxisLockExtension>(vcamBGo);
GetOrAddComponent<CameraAsymmetricDampingExtension>(vcamBGo);
GetOrAddComponent<CameraAdaptiveLookaheadExtension>(vcamBGo);
var composerB = GetOrAddComponent<CinemachinePositionComposer>(vcamBGo);
ApplyComposerDefaults(composerB);
GameObject uiRootGo = GetOrCreateChild(ui, "UIRoot").gameObject;
UIManager uiManager = GetOrAddComponent<UIManager>(uiRootGo);
GameObject hudCanvasGo = GetOrCreateCanvas(uiRootGo.transform, "HUD Canvas", 0);
GameObject hudRootGo = GetOrCreateChild(hudCanvasGo.transform, "HUDRoot").gameObject;
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;
pauseRootGo.SetActive(false);
settingsRootGo.SetActive(false);
mapRootGo.SetActive(false);
shopRootGo.SetActive(false);
GameObject deathCanvasGo = GetOrCreateCanvas(uiRootGo.transform, "DeathScreen Canvas", 10);
GameObject deathRootGo = GetOrCreateChild(deathCanvasGo.transform, "DeathScreenRoot").gameObject;
DeathScreenController deathScreenController = GetOrAddComponent<DeathScreenController>(deathRootGo);
deathRootGo.SetActive(false);
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;
BootSequencer bootSequencer = GetOrAddComponent<BootSequencer>(bootSequencerGo);
AssignAsset(bootSequencer, "_onSplashStartRequest", report, false, "EVT_SplashStartRequest");
AssignAsset(bootSequencer, "_onSplashComplete", report, false, "EVT_SplashComplete");
AssignAsset(bootSequencer, "_onPreloadProgress", report, false, "EVT_LoadingProgressUpdated");
AssignReference(gameManager, "_bootSequencer", bootSequencer);
AssignAsset(gameManager, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
AssignAsset(gameManager, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
AssignAsset(sceneLoader, "_onLoadingStarted", report, false, "EVT_LoadingStarted");
AssignAsset(sceneLoader, "_onLoadingComplete", report, false, "EVT_LoadingComplete");
AssignAsset(sceneLoader, "_onLoadingProgressUpdated", report, false, "EVT_LoadingProgressUpdated");
// ── 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");
loadingCanvasGo.SetActive(false);
// ── SceneFadeController场景切换黑幕───────────────────────────
// 纯逻辑节点:监听淡出/淡入事件后调用 SceneFeedback.Play()。
// 实际 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("SYS_SceneFadeSceneFeedback 子节点已创建并绑定。请在 FeedbackFadeOut / FeedbackFadeIn 的 MMF_Player 中配置所需效果(如全屏黑幕淡入淡出)。" +
"MMF_Player 总时长应 ≤ SceneService._sceneFadeDuration默认 0.4 s。");
EnsureAudioSources(audioManagerGo, audioManager, report);
AssignReference(registrar, "_deathRespawnService", deathRespawnService);
AssignReference(registrar, "_sceneService", sceneService);
AssignReference(registrar, "_eventChannelRegistry", registry);
AssignReference(registrar, "_saveManager", gameSaveManager);
AssignReference(registrar, "_checkpointService", checkpointService);
AssignReference(registrar, "_primaryListener", mainCameraAudioListener);
AssignReference(gameManager, "_settingsManager", settingsManager);
AssignAsset(gameManager, "_onPlayerDied", report, true, "EVT_PlayerDied");
AssignAsset(gameManager, "_onPauseRequested", report, false, "EVT_PauseRequested");
AssignAsset(gameManager, "_onResumeRequested", report, false, "EVT_ResumeRequested", "EVT_PauseResumed");
AssignAsset(gameManager, "_onBossFightStarted", report, false, "EVT_BossFightStarted", "EVT_BossFight");
AssignAsset(gameManager, "_onBossFightEnded", report, false, "EVT_BossFightEnded");
AssignAsset(gameManager, "_onDeathScreenConfirmed", report, true, "EVT_DeathScreenConfirmed");
AssignAsset(gameManager, "_onGameStateChanged", report, true, "EVT_GameStateChanged", "EVT_GameState");
AssignAsset(gameManager, "_onPlayerRespawned", report, false, "EVT_PlayerRespawned", "EVT_PlayerRespawn");
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");
AssignAsset(deathRespawnService, "_onRespawnStarted", report, false, "EVT_RespawnStarted");
AssignAsset(deathRespawnService, "_onRespawnCompleted", report, false, "EVT_RespawnCompleted");
AssignAsset(deathRespawnService, "_onDeathScreenConfirmed", report, true, "EVT_DeathScreenConfirmed");
AssignAsset(deathRespawnService, "_onSceneLoadRequest", report, false, "EVT_SceneLoadRequest");
AssignAsset(settingsManager, "_defaultSettings", report, false, "SET_GlobalSettings");
AssignAsset(checkpointService, "_onSceneLoaded", report, false, "EVT_SceneLoaded");
AssignAsset(audioManager, "_onPlayerDied", report, false, "EVT_PlayerDied");
AssignReference(cameraStateController, "_brain", brain);
AssignReference(cameraStateController, "_impulseSource", impulseSource);
AssignReference(cameraStateController, "_lookSystem", lookSystem);
AssignAsset(cameraStateController, "_onPlayerSpawned", report, true, "EVT_PlayerSpawned");
AssignAsset(cameraStateController, "_lensConfig", report, false, "CAM_LensConfig", "LensConfig", "CameraLensConfig");
AssignReference(uiManager, "_hudRoot", hudRootGo);
AssignReference(uiManager, "_deathScreenRoot", deathRootGo);
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);
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);
// ── 流式加载系统 ──────────────────────────────────────────────────
ScaffoldStreamingSystem(services, report);
MarkDirtyAndLog("Persistent 场景脚手架", root, report);
}
// ─────────────────────────────────────────────────────────────────────
// Scaffold Main Menu Scene
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 在当前活动场景中生成完整的主菜单场景层级结构:
/// [MainMenu] → [Canvas_MainMenu] → 主按钮组 / SaveSlotPanel / SettingsPanel / CreditsPanel
/// 自动绑定所有已存在的相关事件频道 SO 资产。
/// </summary>
[MenuItem("BaseGames/Scene/Setup/Scaffold Main Menu Scene", priority = 202)]
public static void ScaffoldMainMenuScene()
{
var report = new List<string>();
EnsureEventChannelAssets(report);
// ── [MainMenu] 根节点 ─────────────────────────────────────────────
GameObject root = GetOrCreateRoot("[MainMenu]");
// ── 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");
// ── 主按钮区域(底部居中竖排,带 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", "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, "_mainButtonsGroup", menuGroup);
AssignReference(menuCtrl, "_mainButtonsRect", menuPanelRt);
// ── 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 ─────────────────────────────────────────────────
var settingsPanelRt = GetOrCreateUIChild(canvasGo.transform, "SettingsPanel");
StretchFull(settingsPanelRt);
settingsPanelRt.gameObject.SetActive(false);
AssignReference(menuCtrl, "_settingsPanel", settingsPanelRt.gameObject);
// ── CreditsPanel ──────────────────────────────────────────────────
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("存档槽卡片已含完整布局与文本(区域 / 时长 / 时间 / 灵珠 / 生命 / 钢魂徽章),空槽显示\"开始新游戏\"提示。");
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
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 在当前活动场景中生成标准游戏关卡房间的完整层级结构:
/// [RoomRoot] → [Camera] / [SpawnPoints] / [Environment] / [Transitions]
/// 可配合 SceneObjectPlacerTool 在层级内快速追加更多对象。
/// </summary>
[MenuItem("BaseGames/Scene/Setup/Scaffold Game Room", priority = 201)]
public static void ScaffoldGameRoom()
{
var report = new List<string>();
// ── [RoomRoot] ─────────────────────────────────────────────────
GameObject root = GetOrCreateRoot("[RoomRoot]");
RoomController roomController = GetOrAddComponent<RoomController>(root);
// ── [Camera] ───────────────────────────────────────────────────
Transform cameraGroup = GetOrCreateChild(root.transform, "[Camera]");
// CameraArea — 定义相机区域(限位 + 混合配置 + 可选专有 VCam
GameObject cameraAreaGo = GetOrCreateChild(cameraGroup, "CameraArea").gameObject;
CameraArea cameraArea = GetOrAddComponent<CameraArea>(cameraAreaGo);
// AreaBoundary — 提供 CinemachineConfiner3D 所需的限位体积
Transform boundaryT = GetOrCreateChild(cameraAreaGo.transform, "AreaBoundary");
BoxCollider boundaryCollider = GetOrAddComponent<BoxCollider>(boundaryT.gameObject);
boundaryCollider.center = new Vector3(0f, 0f, -10f); // Z 占位符,实际深度由 SyncConfiner 按 LensConfig 计算
boundaryCollider.size = new Vector3(24f, 12f, 1f); // 默认房间尺寸占位符
AssignReference(cameraArea, "_confinerCollider", boundaryCollider);
// ── [SpawnPoints] ──────────────────────────────────────────────
Transform spawnGroup = GetOrCreateChild(root.transform, "[SpawnPoints]");
GameObject defaultSpawnGo = GetOrCreateChild(spawnGroup, "SpawnPoint_Default").gameObject;
PlayerSpawnPoint defaultSpawn = GetOrAddComponent<PlayerSpawnPoint>(defaultSpawnGo);
AssignString(defaultSpawn, "_transitionId", "default");
AssignInt(defaultSpawn, "_facingDirection", 1);
// ── [Environment] ──────────────────────────────────────────────
Transform envGroup = GetOrCreateChild(root.transform, "[Environment]");
// Ground Tilemap
GameObject gridGo = GetOrCreateChild(envGroup, "GroundGrid").gameObject;
GetOrAddComponent<Grid>(gridGo);
GameObject groundTileGo = GetOrCreateChild(gridGo.transform, "Ground").gameObject;
int groundLayer = LayerMask.NameToLayer("Platform");
if (groundLayer >= 0) groundTileGo.layer = groundLayer;
else report.Add("Layer 'Platform' 不存在,请在 Tags and Layers 中创建。");
GetOrAddComponent<Tilemap>(groundTileGo);
GetOrAddComponent<TilemapRenderer>(groundTileGo);
TilemapCollider2D tilemapCol = GetOrAddComponent<TilemapCollider2D>(groundTileGo);
tilemapCol.usedByComposite = true;
Rigidbody2D groundRb = GetOrAddComponent<Rigidbody2D>(groundTileGo);
groundRb.bodyType = RigidbodyType2D.Static;
GetOrAddComponent<CompositeCollider2D>(groundTileGo);
// NavSurface for PathBerserker2d
GameObject navGo = GetOrCreateChild(envGroup, "NavSurface").gameObject;
GetOrAddComponent<NavSurface>(navGo);
// ── [Transitions] ──────────────────────────────────────────────
GetOrCreateChild(root.transform, "[Transitions]");
// ── Wire RoomController ────────────────────────────────────────
AssignReference(roomController, "_cameraArea", cameraArea);
SerializedObject roomSO = new SerializedObject(roomController);
SerializedProperty spawnArrayProp = roomSO.FindProperty("_spawnPoints");
if (spawnArrayProp != null && spawnArrayProp.isArray)
{
spawnArrayProp.arraySize = 1;
spawnArrayProp.GetArrayElementAtIndex(0).objectReferenceValue = defaultSpawn;
roomSO.ApplyModifiedPropertiesWithoutUndo();
}
// ── MapRoomDataSO 模板(幂等:同名资产已存在时跳过)──────────────
string roomIdHint = SceneManager.GetActiveScene().name;
if (string.IsNullOrEmpty(roomIdHint) || roomIdHint == "Untitled")
roomIdHint = "Room_Unknown";
string roomSoFolder = "Assets/_Game/Data/Map/Rooms";
string roomSoPath = $"{roomSoFolder}/{roomIdHint}.asset";
bool roomSoCreated = false;
if (!System.IO.File.Exists(Application.dataPath + "/../" + roomSoPath))
{
EnsureFolder(roomSoFolder);
var roomData = ScriptableObject.CreateInstance<MapRoomDataSO>();
roomData.RoomId = roomIdHint;
roomData.RegionId = roomIdHint.Contains("_")
? roomIdHint.Split('_')[1]
: "";
AssetDatabase.CreateAsset(roomData, roomSoPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
roomSoCreated = true;
}
// ── Report ─────────────────────────────────────────────────────
report.Add("在 RoomController._roomId 填写唯一房间 ID如 \"Room_Forest_01\")。");
if (roomSoCreated)
report.Add($"已自动创建 MapRoomDataSO 模板:{roomSoPath}。请填写 GridPosition / GridSize / Exits并将其添加到 MapDatabaseSO.AllRooms 中。");
else
report.Add($"MapRoomDataSO 已存在({roomSoPath}),请确认其 RoomId / Exits 与场景保持同步。");
report.Add("绑定 LensConfig SO 后单击 Inspector 中「从可视区域更新限位区域」计算正确的 BoxCollider。");
report.Add("使用 Tile Palette 在 Ground Tilemap 上绘制地形,然后在 NavSurface Inspector 中点击 Bake。");
report.Add("[Transitions] 子节点下使用 BaseGames/Scene/Place/Room Transition 添加过渡点。");
MarkDirtyAndLog("Game Room 脚手架", root, report);
}
// ─────────────────────────────────────────────────────────────────────
// 流式加载系统RoomStreamingManager + TransitionDirector
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 在 [Services] 下创建或更新 SYS_RoomStreamingManager
/// 挂载 <see cref="RoomStreamingManager"/> 与 <see cref="TransitionDirector"/>
/// 并自动绑定已存在的事件频道与配置资产。
/// </summary>
private static void ScaffoldStreamingSystem(Transform services, List<string> report)
{
// 预算配置 SO不存在时自动创建
StreamingBudgetConfigSO budgetConfig = EnsureStreamingBudgetConfigAsset(report);
// MapDatabaseSO查找已存在的资产
Object mapDbAsset = FindFirstAssetByType<MapDatabaseSO>("MapDatabase", "MAP_Database", "MapDatabaseSO");
if (mapDbAsset == null)
report.Add("未找到 MapDatabaseSO 资产。请将 MapDatabaseSO 手工赋给 RoomStreamingManager._mapDatabase 与 TransitionDirector._mapDatabase。");
// ── SYS_RoomStreamingManager GameObject ──────────────────────────
GameObject streamingGo = GetOrCreateChild(services, "SYS_RoomStreamingManager").gameObject;
RoomStreamingManager streamingMgr = GetOrAddComponent<RoomStreamingManager>(streamingGo);
TransitionDirector transitionDir = GetOrAddComponent<TransitionDirector>(streamingGo);
// ── RoomStreamingManager 字段 ─────────────────────────────────────
AssignReference(streamingMgr, "_mapDatabase", mapDbAsset);
AssignReference(streamingMgr, "_budget", budgetConfig);
AssignAsset(streamingMgr, "_onRoomEntered", report, false, "EVT_RoomEntered");
AssignAsset(streamingMgr, "_onRoomPreloaded", report, false, "EVT_RoomPreloaded");
AssignAsset(streamingMgr, "_onRoomActivated", report, false, "EVT_RoomActivated");
// ── TransitionDirector 字段 ───────────────────────────────────────
AssignReference(transitionDir, "_streamingManagerRef", streamingMgr);
AssignReference(transitionDir, "_mapDatabase", mapDbAsset);
AssignReference(transitionDir, "_budget", budgetConfig);
AssignAsset(transitionDir, "_onFadeOutRequest", report, false, "EVT_FadeOutRequest");
AssignAsset(transitionDir, "_onFadeInRequest", report, false, "EVT_FadeInRequest");
AssignAsset(transitionDir, "_onRegionNameDisplay", report, false, "EVT_RegionNameDisplay");
AssignAsset(transitionDir, "_onSceneWorldStateRestored", report, false, "EVT_SceneWorldStateRestored");
report.Add("SYS_RoomStreamingManager流式加载系统已创建。如 EVT_RoomEntered / EVT_RoomPreloaded 频道尚未存在,请通过 DataHub > Streaming 创建后重新运行脚手架。");
}
/// <summary>
/// 在 <c>Assets/_Game/Data/Streaming/</c> 下确保默认预算配置 SO 存在。
/// 已存在时直接返回;不存在时自动创建 <c>STR_BudgetConfig_Default.asset</c>。
/// </summary>
private static StreamingBudgetConfigSO EnsureStreamingBudgetConfigAsset(List<string> report)
{
// 先查找已有资产
string[] guids = AssetDatabase.FindAssets("t:StreamingBudgetConfigSO");
if (guids != null && guids.Length > 0)
{
string path = AssetDatabase.GUIDToAssetPath(guids[0]);
var existing = AssetDatabase.LoadAssetAtPath<StreamingBudgetConfigSO>(path);
if (existing != null)
return existing;
}
// 没有则创建默认资产
const string folder = "Assets/_Game/Data/Streaming";
const string assetPath = folder + "/STR_BudgetConfig_Default.asset";
EnsureFolder(folder);
var created = ScriptableObject.CreateInstance<StreamingBudgetConfigSO>();
AssetDatabase.CreateAsset(created, assetPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
report.Add($"已自动创建流式加载预算配置:{assetPath}。可在 DataHub > 流式加载 中编辑默认参数。");
return created;
}
private static void AssignString(Object target, string propertyName, string value, List<string> report = null)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null)
{
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入字符串值。");
return;
}
property.stringValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignInt(Object target, string propertyName, int value, List<string> report = null)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null)
{
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入整型值。");
return;
}
property.intValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void EnsureEventChannelAssets(List<string> report)
{
bool hasCoreSet =
FindFirstAsset("EVT_PlayerDied") != null &&
FindFirstAsset("EVT_DeathScreenConfirmed") != null &&
(FindFirstAsset("EVT_GameStateChanged") != null || FindFirstAsset("EVT_GameState") != null) &&
FindFirstAsset("EVT_PauseRequested") != null &&
FindFirstAsset("EVT_SceneLoadRequest") != null;
if (hasCoreSet)
return;
CreateEventChannelAssets.CreateAll();
report?.Add("检测到关键事件频道缺失,已自动执行 Create Event Channel Assets。");
}
private static void EnsureAudioSources(GameObject audioManagerGo, AudioManager audioManager, List<string> report)
{
GameObject bgmAGo = GetOrCreateChild(audioManagerGo.transform, "BGM Source A").gameObject;
GameObject bgmBGo = GetOrCreateChild(audioManagerGo.transform, "BGM Source B").gameObject;
GameObject sfxRootGo = GetOrCreateChild(audioManagerGo.transform, "SFX Sources").gameObject;
AudioSource bgmA = GetOrAddComponent<AudioSource>(bgmAGo);
AudioSource bgmB = GetOrAddComponent<AudioSource>(bgmBGo);
bgmA.playOnAwake = false;
bgmB.playOnAwake = false;
bgmA.loop = true;
bgmB.loop = true;
var sfxSources = new AudioSource[6];
for (int i = 0; i < sfxSources.Length; i++)
{
GameObject sfxGo = GetOrCreateChild(sfxRootGo.transform, $"SFX Source {i + 1}").gameObject;
AudioSource sfxSource = GetOrAddComponent<AudioSource>(sfxGo);
sfxSource.playOnAwake = false;
sfxSources[i] = sfxSource;
}
AssignReference(audioManager, "_bgmSourceA", bgmA);
AssignReference(audioManager, "_bgmSourceB", bgmB);
AssignArrayReferences(audioManager, "_sfxSources", sfxSources, report);
// 尝试自动绑定 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)
{
Scene scene = SceneManager.GetActiveScene();
foreach (GameObject rootObject in scene.GetRootGameObjects())
{
if (rootObject.name == name)
return rootObject;
}
GameObject root = new GameObject(name);
Undo.RegisterCreatedObjectUndo(root, $"Create {name}");
return root;
}
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 component = go.GetComponent<T>();
if (component != null)
return component;
return Undo.AddComponent<T>(go);
}
private static GameObject GetOrCreateCanvas(Transform parent, string name, int sortOrder)
{
GameObject canvasGo = GetOrCreateChild(parent, name).gameObject;
Canvas canvas = GetOrAddComponent<Canvas>(canvasGo);
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = sortOrder;
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;
}
// ─────────────────────────────────────────────────────────────────────
// 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)
{
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);
}
private static void AssignReference(Object target, string propertyName, Object value, List<string> report)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null)
{
report?.Add($"{target.GetType().Name}.{propertyName} 字段不存在,未写入引用。");
return;
}
property.objectReferenceValue = value;
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignArrayReferences(Object target, string propertyName, IReadOnlyList<Object> values, List<string> report)
{
SerializedObject serializedObject = new SerializedObject(target);
SerializedProperty property = serializedObject.FindProperty(propertyName);
if (property == null || !property.isArray)
{
report.Add($"{target.GetType().Name}.{propertyName} 不是可写数组字段。");
return;
}
property.arraySize = values.Count;
for (int i = 0; i < values.Count; i++)
property.GetArrayElementAtIndex(i).objectReferenceValue = values[i];
serializedObject.ApplyModifiedPropertiesWithoutUndo();
}
private static void AssignAsset(Object target, string propertyName, List<string> report, bool required, params string[] candidates)
{
Object asset = FindFirstAsset(candidates);
if (asset == null && required)
report.Add($"未找到 {target.GetType().Name}.{propertyName} 需要的资产: {string.Join(" / ", candidates)}");
AssignReference(target, propertyName, asset, report);
}
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 Object FindFirstAssetByType<T>(params string[] candidates) where T : Object
{
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);
T asset = AssetDatabase.LoadAssetAtPath<T>(path);
if (asset != null && asset.name == candidate)
return asset;
}
}
return null;
}
private static Object FindFirstAssetWithExtension(string extension, 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);
if (string.IsNullOrEmpty(path) || !path.EndsWith(extension, System.StringComparison.OrdinalIgnoreCase))
continue;
Object asset = AssetDatabase.LoadMainAssetAtPath(path);
if (asset != null && asset.name == candidate)
return asset;
}
}
return null;
}
private static Object EnsureInputReaderAsset(List<string> report)
{
string[] existing = AssetDatabase.FindAssets("t:InputReaderSO");
if (existing != null && existing.Length > 0)
{
string firstPath = AssetDatabase.GUIDToAssetPath(existing[0]);
Object found = AssetDatabase.LoadMainAssetAtPath(firstPath);
if (found != null)
return found;
}
const string inputFolder = "Assets/_Game/Data/Player/Input";
EnsureFolder(inputFolder);
const string assetPath = "Assets/_Game/Data/Player/Input/InputReader.asset";
InputReaderSO created = ScriptableObject.CreateInstance<InputReaderSO>();
AssetDatabase.CreateAsset(created, assetPath);
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
report?.Add("未找到 InputReaderSO已自动创建 Assets/_Game/Data/Player/Input/InputReader.asset。");
return created;
}
private static void EnsureFolder(string fullPath)
{
string[] parts = fullPath.Split('/');
if (parts.Length == 0 || parts[0] != "Assets")
return;
string current = "Assets";
for (int i = 1; i < parts.Length; i++)
{
string next = current + "/" + parts[i];
if (!AssetDatabase.IsValidFolder(next))
AssetDatabase.CreateFolder(current, parts[i]);
current = next;
}
}
private static void AddScaffoldNote(GameObject go, string message)
{
AddScaffoldNote(go, message, null);
}
private static void AddScaffoldNote(GameObject go, string message, List<string> report)
{
// 注意:不再添加 MonoBehaviour 组件,避免 Editor 程序集组件在 Play 模式下出现 Missing Script
report?.Add($"{go.name}: {message}");
Debug.Log($"[SceneScaffold] {go.name}: {message}");
}
private static void MarkDirtyAndLog(string scaffoldName, GameObject root, List<string> report)
{
EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
Selection.activeGameObject = root;
if (report.Count == 0)
{
Debug.Log($"[SceneScaffoldTools] {scaffoldName} 完成。所有可自动补齐的对象与引用均已生成。", root);
return;
}
Debug.LogWarning($"[SceneScaffoldTools] {scaffoldName} 完成,但仍有 {report.Count} 项需要手工确认:\n- {string.Join("\n- ", report)}", root);
}
/// <summary>
/// 为 VCam 上的 CinemachinePositionComposer 写入初始默认展示参数。
/// 这些値与 <see cref="CameraArea"/> 的默认値一致,确保脆架生成后 Scene 预览即有正确感觉。
/// 运行时 CameraStateController.ConfigureSlot 会在每次 SwitchArea 时用 per-area 配置覆写。
/// </summary>
private static void ApplyComposerDefaults(CinemachinePositionComposer composer)
{
if (composer == null) return;
// 屏幕位置:玩家稍低于中心,上方有更多视野
var comp = composer.Composition;
comp.ScreenPosition = new Vector2(0f, -0.15f);
comp.DeadZone.Enabled = true;
comp.DeadZone.Size = new Vector2(0.15f, 0.05f);
composer.Composition = comp;
// 阻尼X 轻度缓冲Y = 0由 CameraAsymmetricDampingExtension 接管非对称 Y 阻尼)
composer.Damping = new Vector3(0.5f, 0f, 0f);
// Lookahead水平引领预测开启IgnoreY = true平台游戏 Y 轴不预测,避免起跳时镜头猛拉)
var lah = composer.Lookahead;
lah.Enabled = true;
lah.Time = 0.28f;
lah.Smoothing = 5f;
lah.IgnoreY = true;
composer.Lookahead = lah;
}
}
}