From f5378e5941f7c10ca26ce2ceaaff230fd3f98d1c Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 01:15:19 +0900 Subject: [PATCH] =?UTF-8?q?[WL-802c]=20=EC=BB=A4=EC=8A=A4=ED=85=80=20CLI?= =?UTF-8?q?=20=EB=AA=85=EB=A0=B9=203=EC=A2=85=20+=20BottomBarFitter=20?= =?UTF-8?q?=EC=9B=90=EC=9D=B8=20+=20TMP=20=EC=A1=B0=EC=B9=98=EC=95=88=20(#?= =?UTF-8?q?802/#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assets/WL/Tools/WLPipelineCommands.cs (NEW · WL.Tools · UNITY_EDITOR || DEVELOPMENT_BUILD) wl_info : 씬·Play·timeScale·fps·플레이어(위치/HP/MP/레벨/타겟/anim)·적 수(생존/전체·스포너별) ·현재 존(최근접 MobControlMgr)·보스(페이즈)·CombatEvents/BossEvents/TimeScaleArbiter 카운터 wl_timescale : 0~100 범위 검증 · before/after wl_teleport_player : Play 게이트 → NavMesh 스냅(맵 데이터 반경) → Actor.Set_Warp · from/requested/to 힘민지 매핑 = MyValue.MyPC / Actor.Get_HP·Get_MaxHP / FindObjectsByType / NavMeshAgent.Warp 데이터 값 하드코딩 0(C45) — 스냅 반경은 MapData.spawnSnapRadius → mobNavSnapRadius → NavMeshAgent 치수 - AgentScripts/WL802c_FitterDiag.cs (NEW · 진단 전용 · 코드 수정 0) Run() : BottomBarFitter 미컴파일 원인 · Assets 밖 + .cs.draft 확장자 2중 원인 실측 + 비파괴 재현 Tmp() : TMP 경고(#751) 조치안 근거 — assetVersion/s_CurrentAssetVersion 실측 Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL802c_FitterDiag.cs | 213 +++++++++++ Assets/WL/Tools.meta | 8 + Assets/WL/Tools/WLPipelineCommands.cs | 410 +++++++++++++++++++++ Assets/WL/Tools/WLPipelineCommands.cs.meta | 2 + 4 files changed, 633 insertions(+) create mode 100644 AgentScripts/WL802c_FitterDiag.cs create mode 100644 Assets/WL/Tools.meta create mode 100644 Assets/WL/Tools/WLPipelineCommands.cs create mode 100644 Assets/WL/Tools/WLPipelineCommands.cs.meta diff --git a/AgentScripts/WL802c_FitterDiag.cs b/AgentScripts/WL802c_FitterDiag.cs new file mode 100644 index 000000000..85b534ae1 --- /dev/null +++ b/AgentScripts/WL802c_FitterDiag.cs @@ -0,0 +1,213 @@ +// WL802c_FitterDiag.cs — 발주서 WL-802c ⓖ: BottomBarFitter.cs 가 Assembly-CSharp 에 없는 원인 진단 +// +// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) +// unity command run_script --file AgentScripts/WL802c_FitterDiag.cs --entry WL802c_FitterDiag.Run --timeout 300 +// +// 재는 것 — 전부 읽기 전용(하나만 예외: (4) 비파괴 재현이 더미 파일 1개를 만들고 즉시 지운다): +// (1) AssetDatabase.FindAssets("BottomBarFitter") 경로 · 디스크 전수 탐색(Assets 안팎) +// (2) Assembly-CSharp / Assembly-CSharp-Editor 의 Location · WL.* 네임스페이스 타입 목록 · 직접 GetType +// (3) CompilationPipeline 이 보는 Assembly-CSharp 소스 파일에 BottomBarFitter 가 있는가 + +// "임포트 대상 판정" = 경로가 Assets/(또는 Packages/) 아래인가 · 확장자가 .cs 인가 · MonoScript 로 임포트됐는가 +// (4) 비파괴 재현: Assets/WL/Tools/ 에 같은 이름 규칙의 더미(.cs.draft)를 두고 Refresh → +// 임포트/컴파일 대상이 되는지 확인 후 **즉시 삭제**(컴파일을 일으키지 않는 확장자라 리컴파일 0) +// +// 경로·이름 외의 상수는 두지 않는다. 판정은 전부 에디터 API 실측이다. + +public static class WL802c_FitterDiag +{ + const string kName = "BottomBarFitter"; + const string kDraft = "AgentScripts/staging/BottomBarFitter.cs.draft"; + const string kProbeDir = "Assets/WL/Tools"; + const string kProbeDraft = "Assets/WL/Tools/WL802c_ImportProbe.cs.draft"; + + public static object Run() + { + var sb = new System.Text.StringBuilder(); + string root = System.IO.Path.GetDirectoryName(UnityEngine.Application.dataPath).Replace("\\", "/"); + sb.AppendLine("projectRoot = " + root); + sb.AppendLine("dataPath = " + UnityEngine.Application.dataPath.Replace("\\", "/")); + + // ── (1) 에셋 DB 검색 + 디스크 전수 탐색 + sb.AppendLine(); + sb.AppendLine("== (1) 자산 검색 =="); + var guids = UnityEditor.AssetDatabase.FindAssets(kName); + sb.AppendLine("AssetDatabase.FindAssets(\"" + kName + "\") = " + guids.Length + "건"); + foreach (var g in guids) + sb.AppendLine(" " + UnityEditor.AssetDatabase.GUIDToAssetPath(g)); + var g2 = UnityEditor.AssetDatabase.FindAssets(kName + " t:MonoScript"); + sb.AppendLine("FindAssets(\"" + kName + " t:MonoScript\") = " + g2.Length + "건"); + + sb.AppendLine("-- 디스크 전수(Library/Temp/.git 제외) --"); + int hits = 0; + foreach (var f in System.IO.Directory.GetFiles(root, "*" + kName + "*", System.IO.SearchOption.AllDirectories)) + { + string rel = f.Replace("\\", "/").Substring(root.Length + 1); + if (rel.StartsWith("Library/") || rel.StartsWith("Temp/") || rel.StartsWith(".git/") || rel.StartsWith("obj/")) continue; + var fi = new System.IO.FileInfo(f); + sb.AppendLine(" " + rel + " (" + fi.Length + " B · " + fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm") + ")"); + hits++; + } + sb.AppendLine(" 디스크 히트 = " + hits + "건"); + + // ── (2) 어셈블리 · 타입 + sb.AppendLine(); + sb.AppendLine("== (2) 어셈블리 · 타입 =="); + foreach (var asm in System.AppDomain.CurrentDomain.GetAssemblies()) + { + string an = asm.GetName().Name; + if (!an.StartsWith("Assembly-CSharp")) continue; + sb.AppendLine("asm = " + an + " loc = " + asm.Location); + System.Type[] types; + try { types = asm.GetTypes(); } + catch (System.Reflection.ReflectionTypeLoadException e) { types = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.Where(e.Types, t => t != null)); } + var wl = System.Linq.Enumerable.ToArray(System.Linq.Enumerable.OrderBy( + System.Linq.Enumerable.Select( + System.Linq.Enumerable.Where(types, t => t != null && t.Namespace != null && t.Namespace.StartsWith("WL.")), + t => t.Namespace + "." + t.Name), x => x)); + sb.AppendLine(" WL.* 타입 " + wl.Length + "개: " + string.Join(", ", wl)); + } + sb.AppendLine("Type.GetType(\"WL.UI." + kName + ", Assembly-CSharp\") = " + + (System.Type.GetType("WL.UI." + kName + ", Assembly-CSharp") != null)); + + // ── (3) 컴파일 파이프라인 + 임포트 대상 판정 + sb.AppendLine(); + sb.AppendLine("== (3) 컴파일 파이프라인 · 임포트 대상 판정 =="); + foreach (var a in UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Editor)) + { + if (!a.name.StartsWith("Assembly-CSharp")) continue; + int n = a.sourceFiles.Length; + int match = 0; + foreach (var s in a.sourceFiles) if (s.Contains(kName)) match++; + sb.AppendLine(a.name + ": sourceFiles = " + n + "개 · \"" + kName + "\" 포함 = " + match + "개 · outputPath = " + a.outputPath); + } + + string draftAbs = root + "/" + kDraft; + bool draftExists = System.IO.File.Exists(draftAbs); + sb.AppendLine("draft = " + kDraft + " · 존재 = " + draftExists); + sb.AppendLine(" 확장자 = \"" + System.IO.Path.GetExtension(kDraft) + "\" (스크립트 컴파일 대상은 \".cs\" 만)"); + sb.AppendLine(" Assets/ 아래인가 = " + kDraft.StartsWith("Assets/")); + sb.AppendLine(" AssetPathToGUID = \"" + UnityEditor.AssetDatabase.AssetPathToGUID(kDraft) + "\" (빈 문자열 = 에셋 DB 가 모르는 파일)"); + sb.AppendLine(" .meta 존재 = " + System.IO.File.Exists(draftAbs + ".meta")); + sb.AppendLine(" LoadAssetAtPath = " + (UnityEditor.AssetDatabase.LoadAssetAtPath(kDraft) != null)); + sb.AppendLine(" IsValidFolder(AgentScripts) = " + UnityEditor.AssetDatabase.IsValidFolder("AgentScripts")); + + // 배치 후보(UI 소유 · W2)에 같은 이름의 스크립트가 있는지 + sb.AppendLine("-- 배치 후보 폴더의 실제 .cs 목록 --"); + foreach (var dir in new[] { "Assets/WL/UI/Scripts", "Assets/WL/Tools" }) + { + if (!System.IO.Directory.Exists(root + "/" + dir)) { sb.AppendLine(" " + dir + " : 폴더 없음"); continue; } + var cs = System.IO.Directory.GetFiles(root + "/" + dir, "*.cs", System.IO.SearchOption.TopDirectoryOnly); + var names = new System.Collections.Generic.List(); + foreach (var c in cs) names.Add(System.IO.Path.GetFileName(c)); + sb.AppendLine(" " + dir + " : " + names.Count + "개 — " + string.Join(", ", names.ToArray())); + } + + // ── (4) 비파괴 재현 — Assets 안이어도 .cs.draft 는 스크립트가 아니다 + sb.AppendLine(); + sb.AppendLine("== (4) 비파괴 재현(더미 1개 · 즉시 삭제) =="); + string probeAbs = root + "/" + kProbeDraft; + bool made = false; + try + { + if (!System.IO.Directory.Exists(root + "/" + kProbeDir)) System.IO.Directory.CreateDirectory(root + "/" + kProbeDir); + System.IO.File.WriteAllText(probeAbs, "namespace WL.Tools { internal class WL802c_ImportProbeDummy { } }\n"); + made = true; + UnityEditor.AssetDatabase.Refresh(UnityEditor.ImportAssetOptions.ForceSynchronousImport); + + sb.AppendLine("더미 = " + kProbeDraft + " (Assets/ **안**, 확장자 .cs.draft)"); + sb.AppendLine(" AssetPathToGUID = \"" + UnityEditor.AssetDatabase.AssetPathToGUID(kProbeDraft) + "\""); + sb.AppendLine(" LoadAssetAtPath = " + (UnityEditor.AssetDatabase.LoadAssetAtPath(kProbeDraft) != null)); + sb.AppendLine(" MonoScript 로 임포트 = " + (UnityEditor.AssetDatabase.LoadAssetAtPath(kProbeDraft) != null)); + sb.AppendLine(" .meta 생성 = " + System.IO.File.Exists(probeAbs + ".meta")); + int inSrc = 0; + foreach (var a in UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Editor)) + { + if (!a.name.StartsWith("Assembly-CSharp")) continue; + foreach (var s in a.sourceFiles) if (s.Contains("WL802c_ImportProbe")) inSrc++; + } + sb.AppendLine(" Assembly-CSharp sourceFiles 포함 = " + inSrc + "개 (0 이면 컴파일 대상 아님 = 재현 성공)"); + } + catch (System.Exception ex) { sb.AppendLine(" 재현 실패: " + ex.Message); } + finally + { + if (made) + { + try { UnityEditor.AssetDatabase.DeleteAsset(kProbeDraft); } catch { } + if (System.IO.File.Exists(probeAbs)) System.IO.File.Delete(probeAbs); + if (System.IO.File.Exists(probeAbs + ".meta")) System.IO.File.Delete(probeAbs + ".meta"); + UnityEditor.AssetDatabase.Refresh(); + sb.AppendLine(" 더미 삭제 완료 · 잔존 = " + System.IO.File.Exists(probeAbs)); + } + } + + // ── (5) 이번 태스크가 추가한 명령 파일이 실제로 컴파일 대상인지(대조군) + sb.AppendLine(); + sb.AppendLine("== (5) 대조군: Assets/WL/Tools/WLPipelineCommands.cs =="); + const string kCmd = "Assets/WL/Tools/WLPipelineCommands.cs"; + sb.AppendLine(" AssetPathToGUID = \"" + UnityEditor.AssetDatabase.AssetPathToGUID(kCmd) + "\""); + sb.AppendLine(" MonoScript = " + (UnityEditor.AssetDatabase.LoadAssetAtPath(kCmd) != null)); + int cmdSrc = 0; + foreach (var a in UnityEditor.Compilation.CompilationPipeline.GetAssemblies(UnityEditor.Compilation.AssembliesType.Editor)) + { + if (!a.name.StartsWith("Assembly-CSharp")) continue; + foreach (var s in a.sourceFiles) if (s.Contains("WLPipelineCommands")) cmdSrc++; + } + sb.AppendLine(" Assembly-CSharp sourceFiles 포함 = " + cmdSrc + "개"); + sb.AppendLine(" Type.GetType(\"WL.Tools.WLPipelineCommands, Assembly-CSharp\") = " + + (System.Type.GetType("WL.Tools.WLPipelineCommands, Assembly-CSharp") != null)); + + return sb.ToString(); + } + + // ── 발주서 WL-802c ⓗ: TMP 경고(#751) 조치안 근거 실측. 읽기 전용 — 아무것도 고치지 않는다. + // unity command run_script --file AgentScripts/WL802c_FitterDiag.cs --entry WL802c_FitterDiag.Tmp --timeout 300 + public static object Tmp() + { + var sb = new System.Text.StringBuilder(); + const string kAsset = "Assets/ThirdParty/TextMesh Pro/Resources/TMP Settings.asset"; + string root = System.IO.Path.GetDirectoryName(UnityEngine.Application.dataPath).Replace("\\", "/"); + + sb.AppendLine("== TMP Settings 자산 =="); + sb.AppendLine("path = " + kAsset); + sb.AppendLine("exists = " + System.IO.File.Exists(root + "/" + kAsset)); + foreach (var line in System.IO.File.ReadAllLines(root + "/" + kAsset)) + if (line.Contains("assetVersion") || line.Contains("m_Name:") || line.Contains("m_warningsDisabled")) + sb.AppendLine(" yaml> " + line.Trim()); + + sb.AppendLine(); + sb.AppendLine("== 런타임 판정 (TMP_Settings.cs:459 조건) =="); + var so = UnityEngine.Resources.Load("TMP Settings"); + sb.AppendLine("Resources.Load(\"TMP Settings\") = " + (so != null ? so.GetType().FullName : "null") + " → isTMPSettingsNull = " + (so == null)); + if (so != null) + { + var t = so.GetType(); + var fi = t.GetField("assetVersion", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + var cur = t.GetField("s_CurrentAssetVersion", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public); + string a = fi != null ? (string)fi.GetValue(so) : "(필드 없음)"; + string c = cur != null ? (string)cur.GetValue(null) : "(필드 없음)"; + sb.AppendLine("assetVersion(직렬화 값) = \"" + a + "\" s_CurrentAssetVersion = \"" + c + "\" 같음 = " + (a == c)); + sb.AppendLine("→ 임포터 창 조건 (assetVersion != s_CurrentAssetVersion) = " + (a != c)); + sb.AppendLine(" asm = " + t.Assembly.GetName().Name + " · " + t.Assembly.Location); + } + + sb.AppendLine(); + sb.AppendLine("== TMP Essentials 리소스 존재 여부(재임포트 = (b) 안의 영향 범위) =="); + foreach (var d in new[] { "Assets/ThirdParty/TextMesh Pro", "Assets/TextMesh Pro" }) + { + string abs = root + "/" + d; + if (!System.IO.Directory.Exists(abs)) { sb.AppendLine(" " + d + " : 폴더 없음"); continue; } + var files = System.IO.Directory.GetFiles(abs, "*", System.IO.SearchOption.AllDirectories); + long bytes = 0; int metas = 0; + foreach (var f in files) { if (f.EndsWith(".meta")) metas++; else bytes += new System.IO.FileInfo(f).Length; } + sb.AppendLine(" " + d + " : 파일 " + files.Length + "개(.meta " + metas + ") · " + System.Math.Round(bytes / 1048576.0, 1) + " MB"); + } + + sb.AppendLine(); + sb.AppendLine("== 이 프로젝트에서 TMP 를 실제로 쓰는 자산 수(재임포트 위험 범위) =="); + sb.AppendLine(" t:TMP_FontAsset = " + UnityEditor.AssetDatabase.FindAssets("t:TMP_FontAsset").Length + "개"); + sb.AppendLine(" t:TMP_SpriteAsset= " + UnityEditor.AssetDatabase.FindAssets("t:TMP_SpriteAsset").Length + "개"); + sb.AppendLine(" t:TMP_StyleSheet = " + UnityEditor.AssetDatabase.FindAssets("t:TMP_StyleSheet").Length + "개"); + + return sb.ToString(); + } +} diff --git a/Assets/WL/Tools.meta b/Assets/WL/Tools.meta new file mode 100644 index 000000000..9733c0283 --- /dev/null +++ b/Assets/WL/Tools.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: cbd6a5caaf203394c8c1a44122f122c5 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Tools/WLPipelineCommands.cs b/Assets/WL/Tools/WLPipelineCommands.cs new file mode 100644 index 000000000..9e670896b --- /dev/null +++ b/Assets/WL/Tools/WLPipelineCommands.cs @@ -0,0 +1,410 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLPipelineCommands.cs — WL 전용 Pipeline 커스텀 CLI 명령 3종 (QA 도구) +// +// PD 지시 #802 · #813(핵앤슬래시 QA) · 발주서 WL-802c (2026-09-07) +// 원본 = 구 WL 프로토타입 `Backup/WL_Prototype_2026-09-04/Assets_WL/Scripts/Debug/WLPipelineCommands.cs` +// (91줄 · PlayerSwitcher/PlayerController/Enemy.All/CharacterController 기반 — 힘민지에는 없는 타입들이라 전면 재매핑) +// +// ■ 쓰는 법 (에디터가 떠 있으면 리컴파일만으로 등록된다 · 등록 코드 0) +// unity command wl_info --project-path <프로젝트> +// unity command wl_timescale --project-path <프로젝트> --scale 0.2 +// unity command wl_teleport_player --project-path <프로젝트> --x 139 --y 0.8 --z -20 +// +// ■ 힘민지 매핑 (구 WL → 이 프로젝트) +// PlayerSwitcher / PlayerController → `MyValue.MyPC`(static PCActor · `Assets/Script/Util/MyValue.cs:77`) +// pc.CurrentHp / MaxHp → `Actor.Get_HP() / Get_MaxHP() / Get_HPPercent()`(`Actor.cs:951-953`) +// pc.CurrentWakeState / ActState → 대응 없음. 대신 `Actor.Get_CurAnim()`(eAnim)·`IsCC()`·`IsDead()`·`Get_Target()` 를 싣는다 +// Enemy.All → 씬의 `MobActor` 전수(아래 주석 참고) +// CharacterController.enabled 토글 → `Actor.Set_Warp(pos)`(= `NavMeshAgent.Warp` · `Actor.cs:2111`) +// +// ■ 왜 `ActorInfo.Get_Actors(eRole.Mob)` 를 쓰지 않는가 (발주서 §8 「적 범위」 미확인 해소) +// `ActorInfo.dic_actor` 는 [역할][**스포너ID**][프리팹] 3중 딕셔너리이고(`ActorInfo.cs:17`), +// `Get_Actors(role, spanwerId = 0)` 의 기본값 0 은 **스포너 0 버킷만** 돌려준다. +// 몹은 `MobControlMgr` 이 `Add_Actor(mob, eRole.Mob, SpawnerID, …)`(`MobControlMgr.cs:141`)로 넣으므로 +// WL_Nature 의 존(813001~813005·813010)은 그 기본 호출에 **하나도 잡히지 않는다**. +// 스포너 목록을 따로 모아 순회할 수도 있지만 그러면 풀(ObjectPool>)을 빌리고 돌려줘야 해 +// 진단 명령이 게임 상태에 손을 댄다. 그래서 읽기 전용인 `FindObjectsByType` 로 전수 조사한다 +// (프레임당 도는 코드가 아니라 QA 가 수동으로 부르는 명령이다). +// +// ■ 규칙 +// · 데이터 값(거리·시간·배율·ID)을 코드 상수로 두지 않는다(C45). +// - 텔레포트 NavMesh 스냅 반경 기본값 = 맵 데이터 `MapData.spawnSnapRadius`(없으면 `mobNavSnapRadius`, +// 그래도 없으면 PC `NavMeshAgent.height`) — `--snap` 으로 덮어쓸 수 있다. +// - `wl_timescale` 의 0~100 은 게임 튜닝 값이 아니라 **CLI 인자 가드**다(발주서 ⓔ). +// · Unity 객체(Transform·Actor·Vector3)를 반환 그래프에 넣지 않는다 — 직렬화 순환. 전부 원시값·문자열로 푼다. +// · 게임 상태를 바꾸는 것은 `wl_timescale`·`wl_teleport_player` 둘뿐이고, 둘 다 무엇을 바꿨는지 before/after 로 돌려준다. +// · 파괴된 인스턴스(#746 계열)는 Unity `== null` 로 거른다. +// +// 🔴 어셈블리 주의: `Assets/WL/Tools/` 에 .asmdef 를 만들지 말 것. 힘민지 게임 코드는 Assembly-CSharp 단일이고 +// `Unity.Pipeline` asmdef 는 `autoReferenced: true` 라 Assembly-CSharp 에서 `[CliCommand]` 를 그냥 쓸 수 있다. +// 🔴 릴리즈 빌드에는 들어가지 않는다(`UNITY_EDITOR || DEVELOPMENT_BUILD`). +// ───────────────────────────────────────────────────────────────────────────── + +#if UNITY_EDITOR || DEVELOPMENT_BUILD +using System.Collections.Generic; +using Unity.Pipeline.Commands; +using UnityEngine; +using UnityEngine.AI; +using UnityEngine.SceneManagement; +using WL.Combat.Boss; +using WL.Combat.Core; + +namespace WL.Tools +{ + /// + /// WL 런타임 QA 명령. `static` 메서드에 `[CliCommand]` 만 붙이면 리컴파일 후 `unity list` 에 뜬다. + /// 인자는 `[CliArg("이름","설명")]` — CLI 에서 `--이름 값`. + /// + internal static class WLPipelineCommands + { + // CLI 인자 가드(게임 튜닝 값 아님 · 발주서 ⓔ). 0 = 완전 정지 · 100 = 실수 방지 상한. + private const float kScaleMin = 0f; + private const float kScaleMax = 100f; + + // ─────────────────────────────────────────────────────────── wl_info + + [CliCommand("wl_info", + "WL 런타임 요약: 씬 · Play · timeScale · FPS · 플레이어(위치/HP/MP/레벨/타겟) · 적 수(생존/전체) · 현재 존 · 보스 상태 · 전투 코어 이벤트 카운터. 읽기 전용(게임 상태를 바꾸지 않는다)", + Tags = new[] { "wl" })] + public static object Info() + { + var scene = SceneManager.GetActiveScene(); + var scenes = new List(); + for (int i = 0; i < SceneManager.sceneCount; i++) + { + var s = SceneManager.GetSceneAt(i); + if (s.isLoaded) scenes.Add(s.name); + } + + // Unity 의 `==` 오버로드가 파괴된 인스턴스(#746 계열)를 null 로 걸러 준다. + PCActor pc = MyValue.MyPC != null ? MyValue.MyPC : null; + + return new + { + scene = scene.name, + scenes = scenes.ToArray(), + isPlaying = Application.isPlaying, + timeScale = Time.timeScale, + fps = Application.isPlaying && Time.unscaledDeltaTime > 0f ? 1f / Time.unscaledDeltaTime : 0f, + frame = Time.frameCount, + player = PlayerInfo(pc), + enemies = EnemyInfo(), + zone = ZoneInfo(pc), + boss = BossInfo(), + combat = CombatInfo() + }; + } + + /// 메인 PC 요약. 없으면 null(로비 밖·Title·Play 아님). + private static object PlayerInfo(Actor pc) + { + if (pc == null) return null; + + var stat = pc.Get_StatInfo(); + var target = pc.Get_Target(); + var agent = pc.Get_Agent(); + var sdata = pc.Get_ServerData(); + + return new + { + name = pc.name, + id = pc.Get_ID(), + position = Vec(pc.Get_position()), + rotationY = pc.transform.eulerAngles.y, + hp = stat != null ? pc.Get_HP() : 0d, + maxHp = stat != null ? pc.Get_MaxHP() : 0d, + hpPercent = stat != null ? pc.Get_HPPercent() : 0f, + mp = stat != null ? stat.Get_Stat(eStat.MP) : 0d, + maxMp = stat != null ? pc.Get_MaxMP() : 0d, + level = sdata != null && sdata.PC != null ? (int)sdata.PC.Lv : 0, + exp = sdata != null && sdata.PC != null ? (double)(uint)sdata.PC.Exp : 0d, + anim = pc.Get_CurAnim().ToString(), + isCC = pc.IsCC(), + isDead = pc.IsDead(), + role = pc.m_Role.ToString(), + subRole = pc.m_SubRole.ToString(), + moveSpeed = stat != null ? stat.Get_Stat(eStat.FinalMoveSpeed) : 0d, + agentSpeed = agent != null && agent.enabled ? agent.velocity.magnitude : 0f, + onNavMesh = agent != null && agent.enabled && agent.isOnNavMesh, + target = target == null ? null : new + { + name = target.name, + id = target.Get_ID(), + position = Vec(target.Get_position()), + hpPercent = target.Get_StatInfo() != null ? target.Get_HPPercent() : 0f, + distance = Vector3.Distance(pc.Get_position(), target.Get_position()), + isDead = target.IsDead() + } + }; + } + + /// 씬의 몹 전수. alive = 활성 · 미사망 · total = 풀에 들어 있는 인스턴스 포함. + private static object EnemyInfo() + { + var mobs = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + int alive = 0, bosses = 0, elites = 0; + var bySpawner = new Dictionary(); + + for (int i = 0; i < mobs.Length; i++) + { + var m = mobs[i]; + if (m == null) continue; + if (m.IsDead()) continue; + alive++; + if (m.IsSubRole(eSubRol.Boss)) bosses++; + else if (m.IsSubRole(eSubRol.Elite)) elites++; + + var key = m.Get_ID().ToString(); // 몹의 m_ID = 스포너 ID(MobControlMgr.cs:160) + int n; + bySpawner[key] = bySpawner.TryGetValue(key, out n) ? n + 1 : 1; + } + + return new { alive, total = mobs.Length, bosses, elites, aliveBySpawner = bySpawner }; + } + + /// 현재 존 = PC 에 가장 가까운 스포너(MobControlMgr). 스포너 전수도 함께 싣는다. + private static object ZoneInfo(Actor pc) + { + var spawners = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + var list = new List(); + object nearest = null; + float nearestDist = float.MaxValue; + bool hasPc = pc != null; + Vector3 pcPos = hasPc ? pc.Get_position() : Vector3.zero; + + for (int i = 0; i < spawners.Length; i++) + { + var s = spawners[i]; + if (s == null) continue; + float d = hasPc ? Vector3.Distance(pcPos, s.transform.position) : -1f; + var entry = new + { + spawnerId = s.SpawnerID, + name = s.name, + position = Vec(s.transform.position), + genRange = s.MobGenRange, + distance = d, + active = s.gameObject.activeInHierarchy, + loadComplete = Application.isPlaying && s.Get_LoadComplete() + }; + list.Add(entry); + if (hasPc && d < nearestDist) { nearestDist = d; nearest = entry; } + } + + var fieldBosses = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + var arenas = new List(); + for (int i = 0; i < fieldBosses.Length; i++) + { + var f = fieldBosses[i]; + if (f == null) continue; + arenas.Add(new + { + monsterId = f.n_MonsterID, + spawnerId = f.n_SpawnerId, + regenTime = f.RegenTime, + isChapterBoss = f.isChapterBoss, + position = Vec(f.transform.position), + distance = hasPc ? Vector3.Distance(pcPos, f.transform.position) : -1f + }); + } + + return new { spawnerCount = list.Count, nearest, spawners = list.ToArray(), bossArenas = arenas.ToArray() }; + } + + /// 씬에 살아 있는 보스(BossMobActor) 요약 + 813h 페이즈. + private static object BossInfo() + { + var bosses = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + var list = new List(); + for (int i = 0; i < bosses.Length; i++) + { + var b = bosses[i]; + if (b == null) continue; + bool dead = b.IsDead(); + list.Add(new + { + name = b.name, + spawnerId = b.Get_ID(), + position = Vec(b.Get_position()), + hp = b.Get_StatInfo() != null ? b.Get_HP() : 0d, + maxHp = b.Get_StatInfo() != null ? b.Get_MaxHP() : 0d, + hpPercent = b.Get_StatInfo() != null ? b.Get_HPPercent() : 0f, + isDead = dead, + active = b.gameObject.activeInHierarchy, + phase = BossPatternTable.CurrentPhase(b) + }); + } + + return new + { + count = list.Count, + bosses = list.ToArray(), + lastPhaseEvent = new + { + raised = BossEvents.BossPhase.Raised, + subscribers = BossEvents.BossPhase.Count, + monsterId = BossEvents.LastMonsterId, + prevPhase = BossEvents.LastPrevPhase, + phase = BossEvents.LastPhase, + hpPercent = BossEvents.LastHpPercent, + threshold = BossEvents.LastThreshold, + time = BossEvents.LastEventTime + } + }; + } + + /// 전투 코어(811b) 이벤트 카운터 요약 + 감속 중재자 상태. + private static object CombatInfo() + { + return new + { + enabled = CombatEvents.Enabled, + totalRaised = CombatEvents.TotalRaised, + lastEvent = CombatEvents.LastEvent, + lastEventTime = CombatEvents.LastEventTime, + lastIsKill = CombatEvents.LastIsKill, + killConfirmed = CombatEvents.KillConfirmedCount, + raised = new + { + attackStarted = CombatEvents.AttackStarted.Raised, + comboStage = CombatEvents.ComboStage.Raised, + swingEffect = CombatEvents.SwingEffect.Raised, + hitboxSpawned = CombatEvents.HitboxSpawned.Raised, + hitboxHit = CombatEvents.HitboxHit.Raised, + hitConfirmed = CombatEvents.HitConfirmed.Raised, + killed = CombatEvents.Killed.Raised, + dash = CombatEvents.Dash.Raised, + skillCast = CombatEvents.SkillCast.Raised, + skillFired = CombatEvents.SkillFired.Raised, + damaged = CombatEvents.Damaged.Raised, + spawned = CombatEvents.Spawned.Raised, + levelUp = CombatEvents.LevelUp.Raised + }, + subscribers = new + { + attackStarted = CombatEvents.AttackStarted.Count, + hitConfirmed = CombatEvents.HitConfirmed.Count, + killed = CombatEvents.Killed.Count, + damaged = CombatEvents.Damaged.Count, + spawned = CombatEvents.Spawned.Count, + levelUp = CombatEvents.LevelUp.Count + }, + timeArbiter = new + { + enabled = TimeScaleArbiter.Enabled, + active = TimeScaleArbiter.Active.ToString(), + writtenScale = TimeScaleArbiter.WrittenScale, + lastResult = TimeScaleArbiter.LastResult.ToString(), + applied = TimeScaleArbiter.AppliedCount, + throttled = TimeScaleArbiter.ThrottledCount, + skippedHigher = TimeScaleArbiter.SkippedHigherCount, + skippedPause = TimeScaleArbiter.SkippedPauseCount + } + }; + } + + // ────────────────────────────────────────────────────── wl_timescale + + [CliCommand("wl_timescale", + "Time.timeScale 을 바꾼다 (0 = 일시정지 · 1 = 정상 · 0~100). 게임 코드가 되덮어쓸 수 있다 — " + + "InGameInfo 의 일시정지(0)·Set_TimeScale·펫 결과창 슬로우(0.3), WL 감속 중재자(히트스톱·킬캠)가 같은 값을 쓴다. " + + "검증이 끝나면 반드시 --scale 1 로 되돌릴 것", + Tags = new[] { "wl" })] + public static object SetTimeScale( + [CliArg("scale", "배속 (0 ~ 100 · 0 = 완전 정지)", Required = true)] float scale) + { + if (float.IsNaN(scale) || scale < kScaleMin || scale > kScaleMax) + throw new System.ArgumentException( + "scale 은 " + kScaleMin + " ~ " + kScaleMax + " 범위여야 합니다: " + scale + + " (timeScale 은 바뀌지 않았습니다: " + Time.timeScale + ")"); + + var before = Time.timeScale; + Time.timeScale = scale; + return new + { + before, + after = Time.timeScale, + isPlaying = Application.isPlaying, + arbiterActive = TimeScaleArbiter.Active.ToString() + }; + } + + // ─────────────────────────────────────────────── wl_teleport_player + + [CliCommand("wl_teleport_player", + "메인 PC 를 월드 좌표로 이동한다 (NavMeshAgent.Warp · 기본은 NavMesh 위로 스냅). Play 모드 전용. " + + "예) 보스 아레나 --x 139 --y 0.8 --z -20", + Tags = new[] { "wl" })] + public static object TeleportPlayer( + [CliArg("x", "월드 X", Required = true)] float x, + [CliArg("y", "월드 Y", Required = true)] float y, + [CliArg("z", "월드 Z", Required = true)] float z, + [CliArg("snap", "NavMesh 스냅 반경(m). 0 = 스냅하지 않고 그대로 이동 · 음수(기본) = 맵 데이터 값 사용")] float snap = -1f) + { + if (!Application.isPlaying) + throw new System.InvalidOperationException("Play 모드에서만 동작합니다 (editor_play 후 재시도)"); + + var pc = MyValue.MyPC; + if (pc == null) + throw new System.InvalidOperationException( + "메인 PC(MyValue.MyPC)가 없습니다 — 로그인 후 인게임 맵에 들어간 뒤 재시도하세요"); + if (pc.IsDead()) + throw new System.InvalidOperationException("PC 가 사망 상태라 이동할 수 없습니다 (Actor.Set_Warp 가 무시합니다)"); + + float radius = snap >= 0f ? snap : DefaultSnapRadius(pc); + var from = pc.Get_position(); + var want = new Vector3(x, y, z); + var to = want; + bool snapped = false; + float snapDist = 0f; + + if (radius > 0f) + { + NavMeshHit hit; + if (NavMesh.SamplePosition(want, out hit, radius, NavMesh.AllAreas)) + { + to = hit.position; + snapped = true; + snapDist = Vector3.Distance(want, to); + } + } + + pc.Set_Warp(to); // NavMeshAgent 를 켜고 Warp — Actor.cs:2111 (트랜스폼 직접 대입 금지) + var after = pc.Get_position(); + var agent = pc.Get_Agent(); + + return new + { + player = pc.name, + from = Vec(from), + requested = Vec(want), + to = Vec(after), + snapRadius = radius, + snapped, + snapDistance = snapDist, + error = Vector3.Distance(after, to), + onNavMesh = agent != null && agent.enabled && agent.isOnNavMesh + }; + } + + /// 스냅 반경 기본값(C45 — 코드 상수 아님): 맵 데이터 → 없으면 PC NavMeshAgent 치수. + private static float DefaultSnapRadius(Actor pc) + { + if (MapData.isIns) + { + var map = MapData.Ins; + if (map != null) + { + if (map.spawnSnapRadius > 0f) return map.spawnSnapRadius; + if (map.mobNavSnapRadius > 0f) return map.mobNavSnapRadius; + } + } + var agent = pc != null ? pc.Get_Agent() : null; + return agent != null ? Mathf.Max(agent.radius, agent.height) : 0f; + } + + /// Vector3 를 그대로 반환하면 JSON 직렬화가 normalized 자기참조로 실패하므로 x/y/z 로 푼다. + private static object Vec(Vector3 v) { return new { x = v.x, y = v.y, z = v.z }; } + } +} +#endif diff --git a/Assets/WL/Tools/WLPipelineCommands.cs.meta b/Assets/WL/Tools/WLPipelineCommands.cs.meta new file mode 100644 index 000000000..dbfd11873 --- /dev/null +++ b/Assets/WL/Tools/WLPipelineCommands.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a89fb522e0f5778448f0be8aa3fcdec2 \ No newline at end of file