// ───────────────────────────────────────────────────────────────────────────── // WLSkillTestSceneBuilder.cs — 독립 스킬 테스트 씬(Assets/WL/SkillTest/WLSkillTest.unity)을 코드로 조립·저장 (PD #947 · 2026-10-20 재지시판) // // ■ 들어가는 것(= 원본 StatTest 하네스 구성 + WL 치비·더미·평지) — 로그인·HUD·섬 없음 // Directional Light · Ground(Plane · URP Lit 재질 자산 · 하네스가 런타임 NavMesh 베이크) · Mgr(AddrResourceMgr·CoroutineMgr·TimeMgr) · EventSystem · DungeonInfo(InGame.unity 씬 객체 · 웨이브 스탯 표) // · ResWork/UIPrefabs/Title/{Tables, SoundInfo, SortOrder_5(OptionInfo·Popup), ActorInfo}.prefab // · ResWork/Prefabs/Ingame/{InGameInfo(이펙트·투사체 풀), ProjectileInfo, Main Camera(RealCamera)}.prefab // · PCPos/치비 PC 프리팹(WLCharacterSwapSettings.pcPrefabName · 없으면 Ai01) · MobPos/더미 몹 프리팹(Res_Addr/Mobs/WL/RPG_Bat) // · Canvas(스케일 = 원본 NewGameUI 와 동일) + 원본 가상패드(Floating Joystick 중첩 프리팹 · RectTransform 값 = NewGameUI 의 것) + 글자 4(원본 Set_Dmglabel 이 쓰는 피해 로그) // · Ground 의 NavMeshSurface(사전 베이크 · 데이터 에셋 WLSkillTestNavMesh.asset) · WLSkillTestHarness(actors · labels · pcActor · dummyActor) // ■ 메뉴 WL/Skill Test/Build Scene · CLI: 먼저 CloseTestScene() 또는 Title 을 연 뒤 Build() // ───────────────────────────────────────────────────────────────────────────── using System.Text; using TMPro; using Unity.AI.Navigation; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; using UnityEngine.AI; using UnityEngine.EventSystems; using UnityEngine.SceneManagement; using UnityEngine.UI; namespace WL.SkillTest.Editor { public static class WLSkillTestSceneBuilder { public const string Folder = "Assets/WL/SkillTest"; public const string ScenePath = Folder + "/WLSkillTest.unity"; public const string GroundMatPath = Folder + "/WLSkillTestGround.mat"; static readonly string[] TitlePrefabs = { "Tables", "SoundInfo", "SortOrder_5", "ActorInfo" }; const string TitlePrefabFolder = "Assets/ResWork/UIPrefabs/Title/"; static readonly string[] IngamePrefabs = { "InGameInfo", "ProjectileInfo", "Main Camera" }; const string IngamePrefabFolder = "Assets/ResWork/Prefabs/Ingame/"; const string PcPrefabFolder = "Assets/Res_Addr/PC/"; const string DummyPrefab = "Assets/Res_Addr/Mobs/WL/RPG_Bat.prefab"; const string NewGameUiPrefab = "Assets/Res_Addr/MainUI/NewGameUI.prefab"; // 원본 HUD — 가상패드(Floating Joystick)·캔버스 스케일을 여기서 읽어 복제한다 public const string NavMeshPath = Folder + "/WLSkillTestNavMesh.asset"; [MenuItem("WL/Skill Test/Build Scene (다시 만들기)")] public static void BuildMenu() { Debug.Log(Build()); } [MenuItem("WL/Skill Test/Open Scene")] public static void OpenMenu() { Debug.Log(Open()); } public static string Open() { var cur = SceneManager.GetActiveScene(); if (cur.path == ScenePath) return "열림 " + ScenePath; if (cur.isDirty) return "현재 씬 " + cur.name + " 이 저장되지 않음 — 저장 없이 다시 열거나 저장한 뒤 다시"; if (!System.IO.File.Exists(ScenePath)) return "씬 없음 " + ScenePath + " — Build() 먼저"; EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single); return "열림 " + ScenePath; } static bool IsTestSceneOpen() { for (int i = 0; i < SceneManager.sceneCount; i++) if (SceneManager.GetSceneAt(i).path == ScenePath) return true; return false; } /// 테스트 씬이 열려 있으면 저장 없이 빈 씬으로(다시 만들기 전 · 저장 안 된 변경이 있으면 거절). public static string CloseTestScene() { if (Application.isPlaying) return "Play 중"; if (!IsTestSceneOpen()) return "테스트 씬 안 열림"; var a = SceneManager.GetActiveScene(); if (a.path == ScenePath && a.isDirty) return "테스트 씬이 저장되지 않음"; EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); return "빈 씬으로 바꿈"; } static string PcPrefabPath() { var s = WL.Character.WLCharacterSwapSettings.Instance; string name = s != null && !string.IsNullOrEmpty(s.pcPrefabName) ? s.pcPrefabName : "Ai01"; string p = PcPrefabFolder + name + ".prefab"; if (AssetDatabase.LoadAssetAtPath(p) == null) p = PcPrefabFolder + "Ai01.prefab"; return p; } /// 원본 HUD(NewGameUI)의 가상패드를 그대로 — 캔버스 스케일(원본 1920×1080 Expand)과 Floating Joystick 중첩 프리팹 · RectTransform 값을 원본 프리팹에서 읽어 복제한다 /// (PD 2026-10-20 #947 「원본과 동일한 가상패드 조작 + 키 입력」 · 값을 코드에 적지 않는다 C45). static string AddOriginalJoystick(GameObject canvasGo, Scene scene) { var hud = AssetDatabase.LoadAssetAtPath(NewGameUiPrefab); if (hud == null) return "🔴 NewGameUI 프리팹 없음 " + NewGameUiPrefab + "\n"; var srcScaler = hud.GetComponentInChildren(true); var dstScaler = canvasGo.GetComponent(); if (srcScaler != null && dstScaler != null) { dstScaler.uiScaleMode = srcScaler.uiScaleMode; dstScaler.referenceResolution = srcScaler.referenceResolution; dstScaler.screenMatchMode = srcScaler.screenMatchMode; dstScaler.matchWidthOrHeight = srcScaler.matchWidthOrHeight; dstScaler.referencePixelsPerUnit = srcScaler.referencePixelsPerUnit; } var srcJoy = hud.GetComponentInChildren(true); if (srcJoy == null) return "🔴 NewGameUI 안에 FloatingJoystick 없음\n"; var srcPrefab = PrefabUtility.GetCorrespondingObjectFromOriginalSource(srcJoy.gameObject); // 중첩 프리팹의 원본(Joystick Pack · Floating Joystick.prefab) var joy = srcPrefab != null ? PrefabUtility.InstantiatePrefab(srcPrefab, scene) as GameObject : Object.Instantiate(srcJoy.gameObject); joy.name = srcJoy.gameObject.name; joy.transform.SetParent(canvasGo.transform, false); joy.transform.SetAsFirstSibling(); var s = srcJoy.GetComponent(); var d = joy.GetComponent(); d.anchorMin = s.anchorMin; d.anchorMax = s.anchorMax; d.pivot = s.pivot; d.sizeDelta = s.sizeDelta; d.anchoredPosition = s.anchoredPosition; d.localScale = s.localScale; return ""; } static TextMeshProUGUI MakeLabel(Transform canvas, string name, Vector2 anchor, Vector2 pos, Vector2 size, TextAlignmentOptions align) { var go = new GameObject(name, typeof(RectTransform)); go.transform.SetParent(canvas, false); var rt = go.GetComponent(); rt.anchorMin = rt.anchorMax = rt.pivot = anchor; rt.anchoredPosition = pos; rt.sizeDelta = size; var t = go.AddComponent(); t.fontSize = 16; t.alignment = align; t.color = Color.white; t.text = ""; return t; } public static string Build() { if (Application.isPlaying) return "Play 중에는 만들지 않는다"; if (IsTestSceneOpen()) return "테스트 씬이 열려 있음 — 먼저 CloseTestScene() 또는 Title 을 연 뒤 Build()"; var sb = new StringBuilder(); var prevActive = SceneManager.GetActiveScene(); if (!AssetDatabase.IsValidFolder(Folder)) AssetDatabase.CreateFolder("Assets/WL", "SkillTest"); var cfg = WLSkillTestHarness.Cfg; // 현재 씬이 저장 안 된 Untitled(CloseTestScene() 직후)면 Additive 생성이 거절된다(InvalidOperationException · #947 실측) → 그 자리에 Single 로 만든다 bool untitled = string.IsNullOrEmpty(prevActive.path); if (untitled && prevActive.isDirty) return "저장 안 된 Untitled 씬이 열려 있음 — Title 을 연 뒤 Build()"; var scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, untitled ? NewSceneMode.Single : NewSceneMode.Additive); SceneManager.SetActiveScene(scene); var lightGo = new GameObject("Directional Light"); var light = lightGo.AddComponent(); light.type = LightType.Directional; light.intensity = 1.1f; light.shadows = LightShadows.Soft; lightGo.transform.rotation = Quaternion.Euler(50f, -30f, 0f); var ground = GameObject.CreatePrimitive(PrimitiveType.Plane); ground.name = "Ground"; float size = cfg != null && cfg.groundSize > 1f ? cfg.groundSize : 100f; ground.transform.localScale = new Vector3(size / 10f, 1f, size / 10f); var mat = AssetDatabase.LoadAssetAtPath(GroundMatPath); if (mat == null) { var sh = Shader.Find("Universal Render Pipeline/Lit"); mat = new Material(sh != null ? sh : Shader.Find("Standard")); AssetDatabase.CreateAsset(mat, GroundMatPath); } var gc = cfg != null && cfg.groundColor != null && cfg.groundColor.Length >= 3 ? new Color(cfg.groundColor[0], cfg.groundColor[1], cfg.groundColor[2]) : new Color(0.45f, 0.62f, 0.35f); if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", gc); else mat.color = gc; EditorUtility.SetDirty(mat); ground.GetComponent().sharedMaterial = mat; GameObjectUtility.SetStaticEditorFlags(ground, StaticEditorFlags.BatchingStatic); // NavMesh 사전 베이크 — 런타임 베이크(#947 실측 5.3 s)를 없앤다. 데이터는 씬 옆 에셋 · 하네스는 데이터가 있으면 베이크를 건너뛴다(NavMeshSurface.OnEnable 이 등록) var surf = ground.AddComponent(); surf.useGeometry = NavMeshCollectGeometry.PhysicsColliders; surf.collectObjects = CollectObjects.All; surf.BuildNavMesh(); if (surf.navMeshData != null) { if (AssetDatabase.LoadAssetAtPath(NavMeshPath) != null) AssetDatabase.DeleteAsset(NavMeshPath); AssetDatabase.CreateAsset(surf.navMeshData, NavMeshPath); } else sb.Append("🔴 NavMesh 베이크 실패(런타임 베이크로 대체)\n"); var mgr = new GameObject("Mgr"); mgr.AddComponent(); mgr.AddComponent(); mgr.AddComponent(); var es = new GameObject("EventSystem"); es.AddComponent(); es.AddComponent(); // InGame.unity 의 씬 객체(직렬화 참조 0 · 몬스터 웨이브 스탯 표) — 원본 ActorStatInfo.Reset 이 DungeonInfo.Ins.dic_WaveStat 를 읽어 없으면 PC.Set 이 NRE(#947 1차 Play 실측) var dg = new GameObject("DungeonInfo"); dg.AddComponent(); foreach (var n in TitlePrefabs) { var p = AssetDatabase.LoadAssetAtPath(TitlePrefabFolder + n + ".prefab"); if (p == null) { sb.Append("🔴 프리팹 없음 ").Append(n).Append('\n'); continue; } var go = PrefabUtility.InstantiatePrefab(p, scene) as GameObject; if (go != null) go.name = n; } foreach (var n in IngamePrefabs) { var p = AssetDatabase.LoadAssetAtPath(IngamePrefabFolder + n + ".prefab"); if (p == null) { sb.Append("🔴 프리팹 없음 ").Append(n).Append('\n'); continue; } var go = PrefabUtility.InstantiatePrefab(p, scene) as GameObject; if (go != null) go.name = n; } // PC(치비) · 더미 — 씬에 직접(StatTest 의 PCPos/MobPos 와 같은 꼴) Vector3 spawn = cfg != null && cfg.spawnPos != null && cfg.spawnPos.Length >= 3 ? new Vector3(cfg.spawnPos[0], cfg.spawnPos[1], cfg.spawnPos[2]) : Vector3.zero; var pcPos = new GameObject("PCPos"); pcPos.transform.position = spawn; var pcPrefab = AssetDatabase.LoadAssetAtPath(PcPrefabPath()); Actor pcActor = null; // 🔴 PC·더미는 꺼 둔 채 저장 — 하네스가 Set 직전에 켠다. 켜진 채 두면 Set(Init_CC) 전에 MobActor.Update→IsCC 가 매 프레임 KeyNotFound(Freeze) 예외(#947 1차 Play 1000건) if (pcPrefab != null) { var pc = PrefabUtility.InstantiatePrefab(pcPrefab, scene) as GameObject; pc.transform.SetParent(pcPos.transform, false); pcActor = pc.GetComponent(); pc.SetActive(false); } else sb.Append("🔴 PC 프리팹 없음\n"); var mobPos = new GameObject("MobPos"); mobPos.transform.position = spawn + new Vector3(2f, 0f, -1.5f); var mobPrefab = AssetDatabase.LoadAssetAtPath(DummyPrefab); MobActor mob = null; if (mobPrefab != null) { var m = PrefabUtility.InstantiatePrefab(mobPrefab, scene) as GameObject; m.transform.SetParent(mobPos.transform, false); mob = m.GetComponent(); m.SetActive(false); } else sb.Append("🔴 더미 프리팹 없음 " + DummyPrefab + "\n"); if (pcActor == null) sb.Append("🔴 PC 프리팹에 Actor 없음\n"); if (mob == null) sb.Append("🔴 더미 프리팹에 MobActor 없음\n"); // 글자 4(원본 분기 Set_Dmglabel 이 labels[2] 에 피해를 적는다) var canvasGo = new GameObject("WLSkillTestCanvas"); var canvas = canvasGo.AddComponent(); canvas.renderMode = RenderMode.ScreenSpaceOverlay; canvas.sortingOrder = 50; canvasGo.AddComponent(); canvasGo.AddComponent(); var labels = new TextMeshProUGUI[4]; labels[0] = MakeLabel(canvasGo.transform, "pcstat", new Vector2(0f, 0f), new Vector2(10f, 10f), new Vector2(360f, 220f), TextAlignmentOptions.BottomLeft); labels[1] = MakeLabel(canvasGo.transform, "mobstat", new Vector2(1f, 0f), new Vector2(-10f, 10f), new Vector2(360f, 220f), TextAlignmentOptions.BottomRight); labels[2] = MakeLabel(canvasGo.transform, "dmg", new Vector2(1f, 1f), new Vector2(-10f, -10f), new Vector2(420f, 300f), TextAlignmentOptions.TopRight); labels[3] = MakeLabel(canvasGo.transform, "classinfo", new Vector2(0.5f, 0f), new Vector2(0f, 10f), new Vector2(600f, 40f), TextAlignmentOptions.Bottom); foreach (var l in labels) l.raycastTarget = false; // 글자가 가상패드 터치를 가리지 않게 sb.Append(AddOriginalJoystick(canvasGo, scene)); var hGo = new GameObject("WLSkillTestHarness"); var h = hGo.AddComponent(); h.pcActor = pcActor; h.dummyActor = mob; h.actors = new Actor[] { pcActor, mob }; h.labels = labels; bool saved = EditorSceneManager.SaveScene(scene, ScenePath); sb.Append(saved ? "저장 " : "🔴 저장 실패 ").Append(ScenePath).Append(" · 루트 ").Append(scene.rootCount).Append(" · PC ").Append(PcPrefabPath()).Append('\n'); if (!untitled) { // Title 등에서 불렀으면 원래 씬으로 돌아가고 테스트 씬은 닫는다(Untitled 에서 불렀으면 저장된 테스트 씬이 그대로 열려 있다) if (prevActive.IsValid() && prevActive.isLoaded) SceneManager.SetActiveScene(prevActive); EditorSceneManager.CloseScene(scene, true); } AssetDatabase.SaveAssets(); return sb.ToString(); } } }