411 lines
20 KiB
C#
411 lines
20 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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<List<Actor>>)을 빌리고 돌려줘야 해
|
|
// 진단 명령이 게임 상태에 손을 댄다. 그래서 읽기 전용인 `FindObjectsByType<MobActor>` 로 전수 조사한다
|
|
// (프레임당 도는 코드가 아니라 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
|
|
{
|
|
/// <summary>
|
|
/// WL 런타임 QA 명령. `static` 메서드에 `[CliCommand]` 만 붙이면 리컴파일 후 `unity list` 에 뜬다.
|
|
/// 인자는 `[CliArg("이름","설명")]` — CLI 에서 `--이름 값`.
|
|
/// </summary>
|
|
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<string>();
|
|
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()
|
|
};
|
|
}
|
|
|
|
/// <summary>메인 PC 요약. 없으면 null(로비 밖·Title·Play 아님).</summary>
|
|
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()
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>씬의 몹 전수. alive = 활성 · 미사망 · total = 풀에 들어 있는 인스턴스 포함.</summary>
|
|
private static object EnemyInfo()
|
|
{
|
|
var mobs = Object.FindObjectsByType<MobActor>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
|
int alive = 0, bosses = 0, elites = 0;
|
|
var bySpawner = new Dictionary<string, int>();
|
|
|
|
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 };
|
|
}
|
|
|
|
/// <summary>현재 존 = PC 에 가장 가까운 스포너(MobControlMgr). 스포너 전수도 함께 싣는다.</summary>
|
|
private static object ZoneInfo(Actor pc)
|
|
{
|
|
var spawners = Object.FindObjectsByType<MobControlMgr>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
|
var list = new List<object>();
|
|
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<FieldBossData>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
|
var arenas = new List<object>();
|
|
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() };
|
|
}
|
|
|
|
/// <summary>씬에 살아 있는 보스(BossMobActor) 요약 + 813h 페이즈.</summary>
|
|
private static object BossInfo()
|
|
{
|
|
var bosses = Object.FindObjectsByType<BossMobActor>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
|
var list = new List<object>();
|
|
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
|
|
}
|
|
};
|
|
}
|
|
|
|
/// <summary>전투 코어(811b) 이벤트 카운터 요약 + 감속 중재자 상태.</summary>
|
|
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
|
|
};
|
|
}
|
|
|
|
/// <summary>스냅 반경 기본값(C45 — 코드 상수 아님): 맵 데이터 → 없으면 PC NavMeshAgent 치수.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>Vector3 를 그대로 반환하면 JSON 직렬화가 normalized 자기참조로 실패하므로 x/y/z 로 푼다.</summary>
|
|
private static object Vec(Vector3 v) { return new { x = v.x, y = v.y, z = v.z }; }
|
|
}
|
|
}
|
|
#endif
|