// ───────────────────────────────────────────────────────────────────────────── // 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