[WL-813w] 런 진행 가이드: 존 이동 · 보스 우선 · 리쉬 완화 · 보스 바 이름 (#813)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
0e0c598356
commit
77f13aec22
|
|
@ -0,0 +1,559 @@
|
|||
// WL813w_Probe.cs — #813w(런 진행 가이드 · 보스 우선 타깃 · 리쉬 완화 · 보스 바 이름) 에디트 모드 검증 프로브
|
||||
// 에디트 모드 전용 · Play 불필요 · 로그인 0 · 씬/에셋 무수정(임시 GameObject·임시 SO 는 즉시 파괴)
|
||||
//
|
||||
// 상주 에디터: unity command run_script --project-path <wt> --file AgentScripts/WL813w_Probe.cs --entry WL813w_Probe.RunAll --timeout 300
|
||||
// 결과: Console + AgentScripts/WL813w_PROBE.txt (RESULT PASS / RESULT FAIL n)
|
||||
//
|
||||
// 검사 (발주서 WL-813w §1-5)
|
||||
// ① 에셋·SOT: WLRunGuideSettings 로드·값 전수 · WLBossArenaSettings 신규 3필드
|
||||
// ② 앵커: 스포너 노드(MobControlMgr.SpawnerID) 실좌표 우선 · 노드가 없으면 SO 폴백 · WL_Nature.prefab 좌표 대조
|
||||
// ③ 존 이동: 가짜 ZoneCleared(0) → 목적지 = 존2 앵커 · Travel → 도착(Hold) → 이탈 → 되돌림
|
||||
// ④ 타깃 규칙: Travel 중 타깃 해제 · Hold 중 「앵커에서 guideLeashRadius 밖」만 무시(가까운 적은 유지)
|
||||
// ⑤ 보스: 가짜 BossGateOpened → 아레나 중심 · BossStarted(boss) → 타깃 고정 + 원본 락타깃 · 사망 → 해제
|
||||
// ⑥ 피격: SimulateHit → 가이드 중단(조종 0) · 해제 → 재개
|
||||
// ⑦ 리쉬 회복 판정(임시 SO): 부분 회복 → 쿨다운 → 쿨다운 뒤 다시 회복 · percent>=1 이면 만피(813d 레거시)
|
||||
// ⑧ 보스 바 이름: StripClone · ResolveBossLabel · MonsterList/Localization 실측 행 대조
|
||||
// ⑨ GC: ForceTick 100회 0 B(대조군 = 의도적 할당)
|
||||
// ⑩ C8: WLRunGuideSettings.RuntimeDisabled → 아무 일도 하지 않는다(조종 해제 · 상태 불변)
|
||||
//
|
||||
// 에디트 모드 한계(보고서 「미확인」으로 올린다)
|
||||
// · NavMesh 가 없어 NavMeshAgent.SetDestination 실이동은 못 잰다 → Steer 는 agent 검사에서 되돌아간다.
|
||||
// 상태 전이·타깃 규칙은 PC transform 을 직접 옮겨 검증한다(원본 Get_position() 이 transform 이다).
|
||||
// · table_monsterlist/localtext 가 없어 「테이블 이름」 실경로는 JSON 행 대조로만 확인한다.
|
||||
// · 실제 보스 Heal 은 살아 있는 m_Stat 이 필요해(813d 미확인 ②와 같은 뿌리) 판정만 검증한다.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using WL.Combat.Boss;
|
||||
using WL.Combat.Core;
|
||||
using WL.Combat.Run;
|
||||
|
||||
public static class WL813w_Probe
|
||||
{
|
||||
const string OutCommit = "AgentScripts/WL813w_PROBE.txt";
|
||||
const string MonsterListJson = "Assets/ResWork/Table/Export/MonsterList.json";
|
||||
const string LocalizationJson = "Assets/ResWork/Table/Export/Localization.json";
|
||||
const string NaturePrefab = "Assets/Res_Addr/Map/WL_Nature.prefab";
|
||||
|
||||
static StringBuilder sb;
|
||||
static int s_fail;
|
||||
|
||||
static GameObject s_pcGo, s_bossGo, s_mobFarGo, s_mobNearGo;
|
||||
static GameObject[] s_spawnerGo;
|
||||
static PCActor s_pc;
|
||||
static Actor s_boss, s_mobFar, s_mobNear;
|
||||
static PCActor s_prevMyPc;
|
||||
|
||||
public static object RunAll()
|
||||
{
|
||||
sb = new StringBuilder();
|
||||
s_fail = 0;
|
||||
sb.AppendLine("# WL813w Probe " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " (edit mode · Play 0 · 로그인 0)");
|
||||
sb.AppendLine("playMode=" + Application.isPlaying);
|
||||
|
||||
try
|
||||
{
|
||||
Setup();
|
||||
Sec1_Asset();
|
||||
Sec2_Anchor();
|
||||
Sec3_ZoneMove();
|
||||
Sec4_TargetRule();
|
||||
Sec5_Boss();
|
||||
Sec6_Hit();
|
||||
Sec7_LeashHeal();
|
||||
Sec8_BossName();
|
||||
Sec9_GC();
|
||||
Sec10_C8();
|
||||
}
|
||||
catch (Exception ex) { L("[FAIL] 예외 — " + ex.GetType().Name + ": " + ex.Message); L(ex.StackTrace); s_fail++; }
|
||||
finally { Teardown(); }
|
||||
|
||||
L("");
|
||||
L(s_fail == 0 ? "RESULT PASS" : "RESULT FAIL " + s_fail);
|
||||
var text = sb.ToString();
|
||||
try { File.WriteAllText(OutCommit, text, new UTF8Encoding(false)); } catch { }
|
||||
Debug.Log(text);
|
||||
return text;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── 유틸
|
||||
static void L(string m) { sb.AppendLine(m); }
|
||||
static string P(bool ok) { if (!ok) s_fail++; return ok ? "[PASS]" : "[FAIL]"; }
|
||||
static long Alloc() { return GC.GetTotalMemory(false); }
|
||||
static string V(Vector3 v) { return "(" + v.x.ToString("F1") + "," + v.y.ToString("F1") + "," + v.z.ToString("F1") + ")"; }
|
||||
static float DXZ(Vector3 a, Vector3 b) { float dx = a.x - b.x, dz = a.z - b.z; return Mathf.Sqrt(dx * dx + dz * dz); }
|
||||
|
||||
static Actor MakeActor(string name, eRole role, eSubRol sub, Vector3 pos, out GameObject go)
|
||||
{
|
||||
go = new GameObject("__WL813w_" + name);
|
||||
go.transform.position = pos;
|
||||
var a = go.AddComponent<Actor>();
|
||||
a.m_Role = role; a.m_SubRole = sub;
|
||||
return a;
|
||||
}
|
||||
|
||||
static void Setup()
|
||||
{
|
||||
s_prevMyPc = MyValue.MyPC;
|
||||
|
||||
s_pcGo = new GameObject("__WL813w_PC");
|
||||
s_pcGo.transform.position = Vector3.zero;
|
||||
s_pc = s_pcGo.AddComponent<PCActor>();
|
||||
s_pc.m_Role = eRole.PC; s_pc.m_SubRole = eSubRol.None; // m_Enemy 기본 false → IsMainPC() true
|
||||
MyValue.MyPC = s_pc;
|
||||
|
||||
s_boss = MakeActor("Anubis(Clone)", eRole.Mob, eSubRol.Boss, Vector3.zero, out s_bossGo);
|
||||
s_mobFar = MakeActor("mobFar", eRole.Mob, eSubRol.None, Vector3.zero, out s_mobFarGo);
|
||||
s_mobNear = MakeActor("mobNear", eRole.Mob, eSubRol.None, Vector3.zero, out s_mobNearGo);
|
||||
|
||||
RunGuide.ResetDiagnostics();
|
||||
WLRunGuideSettings.RuntimeDisabled = false;
|
||||
|
||||
L("");
|
||||
L("── ⓪ 준비");
|
||||
L(" " + P(s_pc.IsMainPC()) + " 가짜 메인 PC IsMainPC()=true · MyValue.MyPC 등록");
|
||||
L(" " + P(RunGuide.Active) + " RunGuide.Active(에셋 · enabled · 런 축)");
|
||||
L(" Agent=" + (s_pc.Get_Agent() == null ? "없음(에디트 모드 · Steer 는 여기서 되돌아간다)" : "있음"));
|
||||
}
|
||||
|
||||
static void Teardown()
|
||||
{
|
||||
try
|
||||
{
|
||||
RunGuide.StopGuide("probe teardown");
|
||||
RunGuide.ResetDiagnostics();
|
||||
WLRunGuideSettings.RuntimeDisabled = false;
|
||||
MyValue.MyPC = s_prevMyPc;
|
||||
Kill(s_pcGo, s_bossGo, s_mobFarGo, s_mobNearGo);
|
||||
if (s_spawnerGo != null) for (int i = 0; i < s_spawnerGo.Length; i++) Kill(s_spawnerGo[i]);
|
||||
s_spawnerGo = null;
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
static void Kill(params GameObject[] gos)
|
||||
{
|
||||
for (int i = 0; i < gos.Length; i++)
|
||||
if (gos[i] != null) UnityEngine.Object.DestroyImmediate(gos[i]);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ① 에셋 · SOT
|
||||
static void Sec1_Asset()
|
||||
{
|
||||
L("");
|
||||
L("── ① 에셋 · SOT");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
L(" " + P(c != null) + " Resources.Load(\"" + WLRunGuideSettings.ResourcesPath + "\")");
|
||||
if (c == null) return;
|
||||
L(" enabled=" + c.enabled + " verboseLog=" + c.verboseLog + " pollSeconds=" + c.pollSeconds);
|
||||
L(" zoneGuideEnabled=" + c.zoneGuideEnabled + " guideOnRunStart=" + c.guideOnRunStart +
|
||||
" zoneArriveRadius=" + c.zoneArriveRadius + " zoneHoldRadius=" + c.zoneHoldRadius);
|
||||
L(" guideLeashRadius=" + c.guideLeashRadius + " guideStopDistance=" + c.guideStopDistance +
|
||||
" repathIntervalSeconds=" + c.repathIntervalSeconds);
|
||||
L(" resumeAfterHitSeconds=" + c.resumeAfterHitSeconds + " maxTravelSeconds=" + c.maxTravelSeconds);
|
||||
L(" bossGuideEnabled=" + c.bossGuideEnabled + " bossPriorityEnabled=" + c.bossPriorityEnabled +
|
||||
" bossArriveRadius=" + c.bossArriveRadius + " bossHoldRadius=" + c.bossHoldRadius +
|
||||
" bossLockTarget=" + c.bossLockTarget);
|
||||
L(" zoneAnchorFallbacks=" + (c.zoneAnchorFallbacks == null ? 0 : c.zoneAnchorFallbacks.Length) + "개");
|
||||
|
||||
var b = WLBossArenaSettings.Instance;
|
||||
L(" " + P(b != null) + " WLBossArenaSettings 로드");
|
||||
if (b == null) return;
|
||||
L(" leashHealPercent=" + b.leashHealPercent + " leashHealCooldownSeconds=" + b.leashHealCooldownSeconds +
|
||||
" bossNameFromTable=" + b.bossNameFromTable + " (기존 resetHealsBoss=" + b.resetHealsBoss + " resetHealRate=" + b.resetHealRate + ")");
|
||||
L(" " + P(b.leashHealPercent > 0f && b.leashHealPercent < 1f) + " 리쉬 부분 회복 구간(0<p<1) — 만피 리셋 아님");
|
||||
float leashLine = b.arenaRadius + b.playerLeaveMargin;
|
||||
L(" " + P(c.bossHoldRadius < leashLine) + " bossHoldRadius(" + c.bossHoldRadius + ") < 리쉬 발동선(" +
|
||||
leashLine + " = arenaRadius+playerLeaveMargin) → 이탈 전에 되돌린다");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ② 앵커
|
||||
static void Sec2_Anchor()
|
||||
{
|
||||
L("");
|
||||
L("── ② 존 앵커 (스포너 노드 우선 · SO 폴백)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
var rs = WLRunSettings.Instance;
|
||||
if (c == null || rs == null || rs.zoneSpawnerIds == null) { L(" [FAIL] 설정 없음"); s_fail++; return; }
|
||||
|
||||
int n = rs.zoneSpawnerIds.Length;
|
||||
if (rs.zoneClearKills != null && rs.zoneClearKills.Length < n) n = rs.zoneClearKills.Length;
|
||||
|
||||
// (a) 씬에 노드가 없을 때 = SO 폴백
|
||||
Vector3 a0;
|
||||
bool got0 = RunGuide.TryGetAnchor(c, 0, out a0);
|
||||
L(" " + P(got0) + " 존0 앵커 = " + V(a0) + " · 노드=" + RunGuide.AnchorFromNode(0) + "(씬에 스포너 없음 → 폴백 기대)");
|
||||
if (c.zoneAnchorFallbacks != null && c.zoneAnchorFallbacks.Length > 0)
|
||||
L(" " + P(!RunGuide.AnchorFromNode(0) && a0 == c.zoneAnchorFallbacks[0]) + " 폴백 값 일치");
|
||||
|
||||
// (b) 가짜 스포너 노드를 심고 다시 읽는다 = 노드 실좌표 우선
|
||||
s_spawnerGo = new GameObject[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
s_spawnerGo[i] = new GameObject("__WL813w_Spawner" + rs.zoneSpawnerIds[i]);
|
||||
s_spawnerGo[i].transform.position = new Vector3(1000f + i, 0f, 2000f + i);
|
||||
var m = s_spawnerGo[i].AddComponent<MobControlMgr>();
|
||||
m.SpawnerID = rs.zoneSpawnerIds[i];
|
||||
}
|
||||
RunGuide.ResetDiagnostics();
|
||||
Vector3 a1;
|
||||
bool got1 = RunGuide.TryGetAnchor(c, 1, out a1);
|
||||
L(" " + P(got1 && RunGuide.AnchorFromNode(1) && a1 == s_spawnerGo[1].transform.position) +
|
||||
" 존1 앵커 = " + V(a1) + " · 노드 실좌표 우선(스캔 " + RunGuide.AnchorScans + "회)");
|
||||
|
||||
// (c) WL_Nature.prefab 실좌표와 SO 폴백 대조 (원본 프리팹은 읽기만 한다)
|
||||
L(" 프리팹 실측 대조(" + NaturePrefab + "):");
|
||||
for (int i = 0; i < n && i < (c.zoneAnchorFallbacks == null ? 0 : c.zoneAnchorFallbacks.Length); i++)
|
||||
{
|
||||
Vector3 pf;
|
||||
bool found = PrefabSpawnerPos(rs.zoneSpawnerIds[i], out pf);
|
||||
bool same = found && DXZ(pf, c.zoneAnchorFallbacks[i]) < 0.5f;
|
||||
L(" " + P(found && same) + " " + rs.zoneSpawnerIds[i] + " 프리팹 " + (found ? V(pf) : "없음") +
|
||||
" ↔ SO 폴백 " + V(c.zoneAnchorFallbacks[i]));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>WL_Nature.prefab 을 텍스트로 읽어 SpawnerID 노드의 월드 좌표를 구한다(프리팹 무수정).</summary>
|
||||
static bool PrefabSpawnerPos(int spawnerId, out Vector3 pos)
|
||||
{
|
||||
pos = Vector3.zero;
|
||||
try
|
||||
{
|
||||
var go = UnityEditor.AssetDatabase.LoadAssetAtPath<GameObject>(NaturePrefab);
|
||||
if (go == null) return false;
|
||||
var mgrs = go.GetComponentsInChildren<MobControlMgr>(true);
|
||||
for (int i = 0; i < mgrs.Length; i++)
|
||||
if (mgrs[i].SpawnerID == spawnerId) { pos = mgrs[i].transform.position; return true; }
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ③ 존 이동
|
||||
static void Sec3_ZoneMove()
|
||||
{
|
||||
L("");
|
||||
L("── ③ 존 이동 (ZoneCleared → 다음 존 · 도착 · 이탈 복귀)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
RunGuide.ResetDiagnostics();
|
||||
|
||||
Vector3 anchor1;
|
||||
RunGuide.TryGetAnchor(c, 1, out anchor1);
|
||||
|
||||
// 존0 클리어 → 존1 유도
|
||||
RunGuide.BeginZone(1, "probe ZoneCleared(0)");
|
||||
L(" " + P(RunGuide.DestKind == RunGuideDest.Zone && RunGuide.ZoneIndex == 1) + " 목적지 = 존1(Zone)");
|
||||
L(" " + P(DXZ(RunGuide.Destination, anchor1) < 0.01f) + " 목적지 좌표 = " + V(RunGuide.Destination));
|
||||
L(" " + P(RunGuide.State == RunGuideState.Travel) + " 상태 Travel · GuideBegun=" + RunGuide.GuideBegun);
|
||||
|
||||
// 멀리 있을 때 = Travel 유지
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 30f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Travel) + " 30 m — Travel 유지(조종 시도 · Repaths=" + RunGuide.Repaths + ")");
|
||||
|
||||
// 도착
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, c.zoneArriveRadius - 1f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Hold && RunGuide.Arrived == 1) +
|
||||
" " + (c.zoneArriveRadius - 1f).ToString("F1") + " m — 도착 → Hold(자동전투 재개) · Arrived=" + RunGuide.Arrived);
|
||||
L(" " + P(!RunGuide.Steering) + " 도착 뒤 조종 해제(JoystickStatus=" + s_pc.JoystickStatus + ")");
|
||||
|
||||
// Hold 유지 범위
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, c.zoneHoldRadius - 2f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Hold && RunGuide.HoldPullbacks == 0) +
|
||||
" " + (c.zoneHoldRadius - 2f).ToString("F1") + " m — Hold 유지(끌어오지 않는다)");
|
||||
|
||||
// 이탈 → 되돌림
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, c.zoneHoldRadius + 5f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Travel && RunGuide.HoldPullbacks == 1) +
|
||||
" " + (c.zoneHoldRadius + 5f).ToString("F1") + " m — 이탈 → 다시 유도 · HoldPullbacks=" + RunGuide.HoldPullbacks);
|
||||
L(" 로그=" + RunGuide.LastLog);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ④ 타깃 규칙
|
||||
static void Sec4_TargetRule()
|
||||
{
|
||||
L("");
|
||||
L("── ④ 타깃 규칙 (Travel 전면 해제 · Hold 는 앵커에서 먼 것만 무시)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
RunGuide.ResetDiagnostics();
|
||||
|
||||
Vector3 anchor1;
|
||||
RunGuide.TryGetAnchor(c, 1, out anchor1);
|
||||
RunGuide.BeginZone(1, "probe target rule");
|
||||
|
||||
// Travel 중: 어떤 타깃이든 놓는다
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 25f);
|
||||
s_mobNearGo.transform.position = anchor1 + new Vector3(0f, 0f, 24f); // PC 코앞이지만 이동 중
|
||||
s_pc.Set_Target(s_mobNear);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == 1) +
|
||||
" Travel 중 타깃 해제(무시 " + RunGuide.FarTargetIgnored + " · 마지막=" + RunGuide.LastIgnoredTarget + ")");
|
||||
|
||||
// 도착 → Hold
|
||||
s_pcGo.transform.position = anchor1;
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Hold) + " 도착 → Hold");
|
||||
|
||||
// Hold: 앵커에서 먼 타깃 = 무시
|
||||
int before = RunGuide.FarTargetIgnored;
|
||||
s_mobFarGo.transform.position = anchor1 + new Vector3(0f, 0f, c.guideLeashRadius + 8f);
|
||||
s_pc.Set_Target(s_mobFar);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == before + 1) +
|
||||
" 앵커 +" + (c.guideLeashRadius + 8f).ToString("F0") + " m 타깃 무시(Del_Target)");
|
||||
|
||||
// Hold: 앵커 가까이의 타깃 = 유지(원본 자동전투가 잡는다)
|
||||
before = RunGuide.FarTargetIgnored;
|
||||
s_mobNearGo.transform.position = anchor1 + new Vector3(0f, 0f, c.guideLeashRadius - 4f);
|
||||
s_pc.Set_Target(s_mobNear);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(s_pc.isTarget && RunGuide.FarTargetIgnored == before) +
|
||||
" 앵커 +" + (c.guideLeashRadius - 4f).ToString("F0") + " m 타깃 유지(원본 자동전투)");
|
||||
|
||||
s_pc.Del_Target(true);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ⑤ 보스
|
||||
static void Sec5_Boss()
|
||||
{
|
||||
L("");
|
||||
L("── ⑤ 보스 (아레나 유도 · 우선 타깃 고정)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
RunGuide.ResetDiagnostics();
|
||||
|
||||
Vector3 center = BossArena.Center;
|
||||
RunGuide.BeginBoss("probe BossGateOpened");
|
||||
L(" " + P(RunGuide.DestKind == RunGuideDest.Boss) + " 목적지 = 보스 아레나 " + V(RunGuide.Destination) +
|
||||
" (BossArena.Center=" + V(center) + ")");
|
||||
|
||||
// 보스 등장 → 아레나 밖에서 시작
|
||||
s_bossGo.transform.position = center;
|
||||
RunGuide.SetBossForProbe(s_boss);
|
||||
s_pcGo.transform.position = center + new Vector3(0f, 0f, 25f);
|
||||
s_pc.Set_Target(s_mobFar);
|
||||
s_mobFarGo.transform.position = center + new Vector3(0f, 0f, 40f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Travel) + " 25 m — Travel(아레나로 유도)");
|
||||
L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_boss) && RunGuide.BossRetargets >= 1) +
|
||||
" 타깃 = 보스 고정(BossRetargets=" + RunGuide.BossRetargets + ")");
|
||||
L(" " + P(!c.bossLockTarget || s_boss.m_IsLockTarget) +
|
||||
" 원본 락타깃 m_IsLockTarget=" + s_boss.m_IsLockTarget + "(BossLocks=" + RunGuide.BossLocks + ")");
|
||||
|
||||
// 잡몹으로 타깃이 새면 폴링이 되돌린다
|
||||
s_pc.Set_Target(s_mobFar);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(ReferenceEquals(s_pc.Get_Target(), s_boss)) + " 잡몹 타깃 전환 → 다음 폴링에서 보스로 복귀");
|
||||
|
||||
// 아레나 도착
|
||||
s_pcGo.transform.position = center + new Vector3(0f, 0f, c.bossArriveRadius - 2f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Hold) + " " + (c.bossArriveRadius - 2f).ToString("F1") + " m — 도착 → Hold");
|
||||
|
||||
// 아레나 이탈(리쉬 발동선 전에) → 되돌림
|
||||
var b = WLBossArenaSettings.Instance;
|
||||
float leashLine = b != null ? b.arenaRadius + b.playerLeaveMargin : 24f;
|
||||
s_pcGo.transform.position = center + new Vector3(0f, 0f, c.bossHoldRadius + 2f);
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.State == RunGuideState.Travel && RunGuide.HoldPullbacks == 1) +
|
||||
" " + (c.bossHoldRadius + 2f).ToString("F1") + " m 이탈 → 되돌림(리쉬 발동선 " + leashLine.ToString("F0") + " m 前)");
|
||||
L(" " + P(c.bossHoldRadius + 2f < leashLine) + " 되돌림 지점 < 리쉬 발동선 = 리셋이 아예 안 걸린다(D-11)");
|
||||
|
||||
// 보스 사망 → 락 해제
|
||||
s_bossGo.SetActive(false); // IsDead() = !activeInHierarchy
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(!s_boss.m_IsLockTarget) + " 보스 사망/비활성 → 락타깃 해제");
|
||||
s_bossGo.SetActive(true);
|
||||
RunGuide.StopGuide("probe ⑤ 종료");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ⑥ 피격 응전
|
||||
static void Sec6_Hit()
|
||||
{
|
||||
L("");
|
||||
L("── ⑥ 피격 → 즉시 응전(가이드 일시 중단)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
RunGuide.ResetDiagnostics();
|
||||
|
||||
Vector3 anchor1;
|
||||
RunGuide.TryGetAnchor(c, 1, out anchor1);
|
||||
RunGuide.BeginZone(1, "probe hit");
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 25f);
|
||||
s_mobFarGo.transform.position = anchor1 + new Vector3(0f, 0f, 40f);
|
||||
|
||||
RunGuide.SimulateHit();
|
||||
s_pc.Set_Target(s_mobFar);
|
||||
int before = RunGuide.FarTargetIgnored;
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(s_pc.isTarget && RunGuide.FarTargetIgnored == before && !RunGuide.Steering) +
|
||||
" 피격 중 — 타깃 유지 · 조종 0(원본 자동전투가 응전) · HitSuspends=" + RunGuide.HitSuspends);
|
||||
L(" " + P(RunGuide.DestKind == RunGuideDest.Zone) + " 목적지는 유지(중단일 뿐 취소 아님)");
|
||||
|
||||
RunGuide.ClearHitSuspendForProbe();
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(!s_pc.isTarget && RunGuide.FarTargetIgnored == before + 1) + " 무피격 구간 복귀 → 가이드 재개");
|
||||
RunGuide.StopGuide("probe ⑥ 종료");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ⑦ 리쉬 회복
|
||||
static void Sec7_LeashHeal()
|
||||
{
|
||||
L("");
|
||||
L("── ⑦ 리쉬 회복 판정 (임시 SO · 에셋 무수정)");
|
||||
var tmp = ScriptableObject.CreateInstance<WLBossArenaSettings>();
|
||||
try
|
||||
{
|
||||
tmp.resetHealsBoss = true;
|
||||
tmp.leashHealPercent = 0.15f;
|
||||
tmp.leashHealCooldownSeconds = 10f;
|
||||
BossArena.ResetLeashState();
|
||||
|
||||
int v1 = BossArena.EvaluateLeashHeal(tmp, 100f);
|
||||
int v2 = BossArena.EvaluateLeashHeal(tmp, 105f);
|
||||
int v3 = BossArena.EvaluateLeashHeal(tmp, 111f);
|
||||
L(" " + P(v1 == BossArena.kHealPartial) + " t=100 첫 리쉬 → 부분 회복(+15 %)");
|
||||
L(" " + P(v2 == BossArena.kHealCooldown) + " t=105 (5 s 뒤) → 쿨다운으로 건너뜀 = 만피 복구 없음");
|
||||
L(" " + P(v3 == BossArena.kHealPartial) + " t=111 (11 s 뒤) → 다시 부분 회복 · 최대 1회/10 s");
|
||||
|
||||
tmp.leashHealPercent = 1f;
|
||||
BossArena.ResetLeashState();
|
||||
L(" " + P(BossArena.EvaluateLeashHeal(tmp, 200f) == BossArena.kHealFull) +
|
||||
" leashHealPercent=1 → 813d 원래 동작(On_Regen 만피) = C8 롤백 경로");
|
||||
|
||||
tmp.leashHealPercent = 0.15f; tmp.resetHealsBoss = false;
|
||||
BossArena.ResetLeashState();
|
||||
L(" " + P(BossArena.EvaluateLeashHeal(tmp, 300f) == BossArena.kHealNone) + " resetHealsBoss=0 → 회복 없음(워프만)");
|
||||
|
||||
// Q4b 실측 대비: 69회 리쉬 · 90 s 보스전이면 회복은 최대 몇 회인가
|
||||
var real = WLBossArenaSettings.Instance;
|
||||
if (real != null && real.leashHealCooldownSeconds > 0f)
|
||||
{
|
||||
int maxHeals = Mathf.FloorToInt(90f / real.leashHealCooldownSeconds) + 1;
|
||||
L(" 참고: Q4b 런① = 리쉬 69회 / 보스전 90 s → 이 값으로는 최대 " + maxHeals +
|
||||
"회 × " + (real.leashHealPercent * 100f).ToString("F0") + "% (예전 = 69회 × 100 %)");
|
||||
}
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(tmp); BossArena.ResetLeashState(); }
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ⑧ 보스 바 이름
|
||||
static void Sec8_BossName()
|
||||
{
|
||||
L("");
|
||||
L("── ⑧ 보스 HP 바 이름 (D-12 · Anubis(Clone))");
|
||||
L(" " + P(WL.UI.BossHpBar.StripClone("Anubis(Clone)") == "Anubis") + " StripClone(\"Anubis(Clone)\") = \"" +
|
||||
WL.UI.BossHpBar.StripClone("Anubis(Clone)") + "\"");
|
||||
L(" " + P(WL.UI.BossHpBar.StripClone("Anubis (Clone)") == "Anubis") + " StripClone(\"Anubis (Clone)\") = \"" +
|
||||
WL.UI.BossHpBar.StripClone("Anubis (Clone)") + "\"");
|
||||
|
||||
// 가짜 보스 오브젝트 이름 = "__WL813w_Anubis(Clone)"(프로브 접두사 + QA 가 본 이름)
|
||||
string objName = s_boss.name;
|
||||
string label = WL.UI.BossHpBar.ResolveBossLabel(s_boss);
|
||||
L(" " + P(label == objName.Replace("(Clone)", "") && !label.Contains("(Clone)")) +
|
||||
" 표가 없는 에디트 모드 → 폴백 = \"" + label + "\" (원본 오브젝트 이름 \"" + objName +
|
||||
"\" · 경로=" + WL.UI.BossHpBar.LastNameSource + ")");
|
||||
|
||||
var tmp = ScriptableObject.CreateInstance<WLBossArenaSettings>();
|
||||
try
|
||||
{
|
||||
tmp.bossNameFromTable = false;
|
||||
string raw = WL.UI.BossHpBar.ResolveBossLabel(s_boss, tmp);
|
||||
L(" " + P(raw == objName) + " C8 롤백(bossNameFromTable=0) → 원본 이름 그대로 \"" + raw + "\"");
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(tmp); }
|
||||
|
||||
// 인게임에서 실제로 나올 이름 = MonsterList → Localization 실측
|
||||
var b = WLBossArenaSettings.Instance;
|
||||
int mid = b != null ? b.bossMonsterId : 10006;
|
||||
string nameId = JsonField(MonsterListJson, "n_MonsterID", mid.ToString(), "n_MonsterName");
|
||||
string kor = nameId != null ? JsonField(LocalizationJson, "ID", nameId, "s_Korean") : null;
|
||||
L(" " + P(!string.IsNullOrEmpty(kor)) + " MonsterList " + mid + " → n_MonsterName=" + nameId +
|
||||
" → Localization s_Korean=\"" + (kor ?? "없음") + "\" (인게임 라벨 기대값 · 813tj 배너와 동일)");
|
||||
}
|
||||
|
||||
/// <summary>Export JSON 에서 keyField=keyValue 인 행의 outField 를 읽는다(표 로드 없이 실측).</summary>
|
||||
static string JsonField(string path, string keyField, string keyValue, string outField)
|
||||
{
|
||||
try
|
||||
{
|
||||
string txt = File.ReadAllText(path);
|
||||
string key = "\"" + keyField + "\":\"" + keyValue + "\"";
|
||||
int i = txt.IndexOf(key, StringComparison.Ordinal);
|
||||
if (i < 0) { key = "\"" + keyField + "\": \"" + keyValue + "\""; i = txt.IndexOf(key, StringComparison.Ordinal); }
|
||||
if (i < 0) return null;
|
||||
int rowStart = txt.LastIndexOf('{', i);
|
||||
int rowEnd = txt.IndexOf('}', i);
|
||||
if (rowStart < 0 || rowEnd < 0) return null;
|
||||
string row = txt.Substring(rowStart, rowEnd - rowStart);
|
||||
int j = row.IndexOf("\"" + outField + "\"", StringComparison.Ordinal);
|
||||
if (j < 0) return null;
|
||||
int q1 = row.IndexOf('"', row.IndexOf(':', j) + 1);
|
||||
int q2 = row.IndexOf('"', q1 + 1);
|
||||
if (q1 < 0 || q2 < 0) return null;
|
||||
return row.Substring(q1 + 1, q2 - q1 - 1);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ⑨ GC
|
||||
static void Sec9_GC()
|
||||
{
|
||||
L("");
|
||||
L("── ⑨ GC (ForceTick 100회)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
RunGuide.ResetDiagnostics();
|
||||
Vector3 anchor1;
|
||||
RunGuide.TryGetAnchor(c, 1, out anchor1);
|
||||
RunGuide.BeginZone(1, "probe gc");
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 8f); // Hold 도 Travel 도 아닌 중간 = 매 틱 판정
|
||||
RunGuide.ForceTick();
|
||||
|
||||
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
|
||||
long a0 = Alloc();
|
||||
for (int i = 0; i < 100; i++) RunGuide.ForceTick();
|
||||
long a1 = Alloc();
|
||||
L(" " + P(a1 - a0 <= 0) + " ForceTick ×100 = " + (a1 - a0) + " B");
|
||||
|
||||
long b0 = Alloc();
|
||||
var junk = new string[256];
|
||||
for (int i = 0; i < junk.Length; i++) junk[i] = new string('x', 4096);
|
||||
long b1 = Alloc();
|
||||
L(" 대조군(의도적 할당 256×4096자) = " + (b1 - b0) + " B · " + P(b1 - b0 > 0) + " 측정기 동작 확인");
|
||||
GC.KeepAlive(junk);
|
||||
RunGuide.StopGuide("probe ⑨ 종료");
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────── ⑩ C8
|
||||
static void Sec10_C8()
|
||||
{
|
||||
L("");
|
||||
L("── ⑩ C8 롤백 (RuntimeDisabled → 원본 자동전투 100%)");
|
||||
var c = WLRunGuideSettings.Instance;
|
||||
RunGuide.ResetDiagnostics();
|
||||
Vector3 anchor1;
|
||||
RunGuide.TryGetAnchor(c, 1, out anchor1);
|
||||
RunGuide.BeginZone(1, "probe c8");
|
||||
s_pcGo.transform.position = anchor1 + new Vector3(0f, 0f, 30f);
|
||||
s_mobFarGo.transform.position = anchor1 + new Vector3(0f, 0f, 60f);
|
||||
s_pc.Set_Target(s_mobFar);
|
||||
|
||||
WLRunGuideSettings.RuntimeDisabled = true;
|
||||
int before = RunGuide.FarTargetIgnored;
|
||||
var st = RunGuide.State;
|
||||
for (int i = 0; i < 10; i++) RunGuide.ForceTick();
|
||||
L(" " + P(!RunGuide.Active) + " RuntimeDisabled → Active=false");
|
||||
L(" " + P(RunGuide.FarTargetIgnored == before && s_pc.isTarget) + " 틱 ×10 — 타깃 무수정(원본 그대로)");
|
||||
L(" " + P(!RunGuide.Steering && s_pc.JoystickStatus == 0) + " 조종 0(JoystickStatus=" + s_pc.JoystickStatus + ")");
|
||||
L(" " + P(RunGuide.State == st) + " 상태 불변");
|
||||
|
||||
WLRunGuideSettings.RuntimeDisabled = false;
|
||||
RunGuide.ForceTick();
|
||||
L(" " + P(RunGuide.FarTargetIgnored == before + 1) + " 스위치 복귀 → 다시 동작");
|
||||
RunGuide.StopGuide("probe ⑩ 종료");
|
||||
s_pc.Del_Target(true);
|
||||
}
|
||||
}
|
||||
|
|
@ -462,15 +462,63 @@ namespace WL.Combat.Boss
|
|||
LastLeashDistance.ToString("F1") + "m");
|
||||
}
|
||||
|
||||
/// <summary>보스를 아레나 중심으로 되돌린다. 회복은 reincarnation:true 라 Spawned 가 다시 나지 않는다.</summary>
|
||||
/// <summary>마지막 부분 회복 시각(unscaled · 쿨다운 판정).</summary>
|
||||
static float s_lastLeashHeal = -999f;
|
||||
/// <summary>리쉬 회복을 실제로 넣은 횟수 · 쿨다운으로 건너뛴 횟수(진단).</summary>
|
||||
public static int LeashHealCount, LeashHealSkipped;
|
||||
/// <summary>마지막 리쉬 회복량(HP · 진단).</summary>
|
||||
public static double LastLeashHeal;
|
||||
|
||||
/// <summary>리쉬 회복 판정 — 0 없음 · 1 만피(레거시) · 2 부분 회복 · 3 쿨다운으로 건너뜀.</summary>
|
||||
public const int kHealNone = 0, kHealFull = 1, kHealPartial = 2, kHealCooldown = 3;
|
||||
|
||||
/// <summary>
|
||||
/// 리쉬 회복 판정(Actor 접근 0 · 프로브가 가짜 시각으로 그대로 부를 수 있다).
|
||||
/// 부분 회복으로 판정되면 그 시각을 쿨다운 기준으로 기록한다(호출측이 실제 Heal 을 한다).
|
||||
/// </summary>
|
||||
public static int EvaluateLeashHeal(WLBossArenaSettings cfg, float now)
|
||||
{
|
||||
if (cfg == null || !cfg.resetHealsBoss) return kHealNone;
|
||||
if (cfg.leashHealPercent >= 1f) return kHealFull;
|
||||
if (cfg.leashHealPercent <= 0f) return kHealNone;
|
||||
if (now - s_lastLeashHeal < cfg.leashHealCooldownSeconds) { LeashHealSkipped++; return kHealCooldown; }
|
||||
s_lastLeashHeal = now;
|
||||
LeashHealCount++;
|
||||
return kHealPartial;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 보스를 아레나 중심으로 되돌린다.
|
||||
/// 🔴 WL-813w §1-3 (Q4b D-11): 예전에는 매번 `On_Regen(true, 1)` = **만피 복구**였고 한 런에서 69회
|
||||
/// 일어나 보스 HP 가 한 칸도 줄지 않았다. 이제 기본은 `leashHealPercent`(0.15) **부분 회복**이고
|
||||
/// `leashHealCooldownSeconds`(10 s) 안에서는 회복을 건너뛴다(워프·타깃 해제는 그대로).
|
||||
/// `leashHealPercent >= 1` 로 두면 813d 원래 동작(On_Regen 만피)으로 정확히 되돌아간다(C8).
|
||||
/// </summary>
|
||||
static void ResetBoss(WLBossArenaSettings cfg, Vector3 center, string reason)
|
||||
{
|
||||
if (DSUtil.CheckNull(s_boss)) return;
|
||||
s_boss.Del_Target(true);
|
||||
if (cfg.resetHealsBoss) s_boss.On_Regen(true, cfg.resetHealRate);
|
||||
|
||||
string healInfo;
|
||||
switch (EvaluateLeashHeal(cfg, Application.isPlaying ? Time.unscaledTime : 0f))
|
||||
{
|
||||
case kHealFull:
|
||||
s_boss.On_Regen(true, cfg.resetHealRate); // 813d 원래 동작(만피 리셋)
|
||||
healInfo = "만피(레거시)";
|
||||
break;
|
||||
case kHealPartial:
|
||||
double before = s_boss.Get_HP();
|
||||
s_boss.Heal(cfg.leashHealPercent, false); // Actor.cs:781 — 최대 HP × 비율만큼 「더한다」
|
||||
LastLeashHeal = s_boss.Get_HP() - before;
|
||||
healInfo = "+" + (cfg.leashHealPercent * 100f).ToString("F0") + "%";
|
||||
break;
|
||||
case kHealCooldown: healInfo = "쿨다운(건너뜀)"; break;
|
||||
default: healInfo = "회복 없음"; break;
|
||||
}
|
||||
|
||||
s_boss.Set_Warp(center);
|
||||
ResetCount++;
|
||||
Log("보스 리셋 #" + ResetCount + " — " + reason);
|
||||
Log("보스 리셋 #" + ResetCount + " — " + reason + " · " + healInfo);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -573,6 +621,7 @@ namespace WL.Combat.Boss
|
|||
s_chaseBefore = s_chaseAfter = 0f;
|
||||
BossSpawned = BossKilled = CleanupDone = false;
|
||||
TotalZoneKills = ResetCount = ReleaseCount = HoldCount = PassThroughCount = 0;
|
||||
LeashHealCount = LeashHealSkipped = 0; LastLeashHeal = 0d; s_lastLeashHeal = -999f;
|
||||
LastLog = "";
|
||||
}
|
||||
|
||||
|
|
@ -591,7 +640,7 @@ namespace WL.Combat.Boss
|
|||
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
|
||||
|
||||
/// <summary>프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화).</summary>
|
||||
public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; }
|
||||
public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; s_lastLeashHeal = -999f; }
|
||||
|
||||
/// <summary>PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 −1).</summary>
|
||||
public static float PlayerOutsideSince { get { return s_playerOutsideSince; } }
|
||||
|
|
|
|||
|
|
@ -157,12 +157,27 @@ namespace WL.Combat.Boss
|
|||
[Tooltip("PC 이탈이 이 시간(초 · unscaled) 이상 이어져야 리셋한다. 경계에서 왔다 갔다 할 때 리셋이 튀는 것을 막는다.")]
|
||||
public float playerLeaveGraceSeconds = 1.5f;
|
||||
|
||||
[Tooltip("리셋 때 보스를 회복시킨다. On_Regen(reincarnation: true) 라 Spawned 이벤트는 다시 나지 않는다(연출 재발동 방지).")]
|
||||
[Tooltip("리셋 때 보스를 회복시킨다. 끄면 워프·타깃 해제만 한다(회복 0).")]
|
||||
public bool resetHealsBoss = true;
|
||||
|
||||
[Tooltip("리셋 회복 비율(1 = 풀피).")]
|
||||
[Tooltip("만피 리셋(On_Regen) 회복 비율(1 = 풀피). leashHealPercent >= 1 일 때만 쓰인다.")]
|
||||
public float resetHealRate = 1f;
|
||||
|
||||
// ── WL-813w §1-3 (Q4b D-11): 리쉬 1회당 만피 복구가 69회 = 보스 HP 가 절대 안 준다 → 부분 회복으로 완화
|
||||
[Tooltip("리쉬 리셋 때의 회복량 = 최대 HP 대비 비율(Actor.Heal(rate) · 누적 회복). "
|
||||
+ "🔴 1 이상이면 813d 원래 동작(On_Regen 만피 리셋 = C8 롤백). 0 이면 회복 없이 워프만.")]
|
||||
public float leashHealPercent = 0.15f;
|
||||
|
||||
[Tooltip("부분 회복의 최소 간격(초 · unscaled). 이 안에 리쉬가 다시 걸리면 워프·타깃 해제만 하고 회복은 건너뛴다(최대 1회/10 s).")]
|
||||
public float leashHealCooldownSeconds = 10f;
|
||||
|
||||
// ─────────────────────────────────────────── 보스 이름 (D-12)
|
||||
[Header("보스 이름 (D-12 · HP 바 라벨)")]
|
||||
[Tooltip("보스 HP 바 라벨을 MonsterList.n_MonsterName(테이블 이름 · 813tj 배너와 같은 경로)으로 쓴다. "
|
||||
+ "끄면 원본 gameObject.name 그대로(= 「Anubis(Clone)」). "
|
||||
+ "🔴 발주서 §2 가 UI 수정을 BossHpBar.cs 1파일로 묶어 UI 설정 SO 에 필드를 넣을 수 없어 여기 둔다(bossMonsterId 폴백과 같은 자리).")]
|
||||
public bool bossNameFromTable = true;
|
||||
|
||||
// ─────────────────────────────────────────── 처치 후 정리
|
||||
[Header("처치 후 정리")]
|
||||
[Tooltip("보스 처치 후 FieldBossData 의 RegenTime 재등장 코루틴을 끊는다(발주서 §1-3 「리젠 없음」).")]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,585 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// RunGuide.cs — 런 진행 가이드: 존 클리어 → 다음 존 자동 이동 · 보스 게이트 뒤 보스 우선
|
||||
//
|
||||
// PD 지시 #813 · 발주서 WL-813w §1-1·§1-2 (2026-09-09)
|
||||
// 근거 = qa/W2-4b_재채점_v2.md
|
||||
// · D-11(치명) 자동전투가 PC 를 잡몹 쪽으로 끌고 나가 아레나 이탈 → 813d 리쉬가 보스를
|
||||
// 만피로 **69회** 리셋 → 4분 런이 항상 Timeout(보스 HP 1800/1800 유지).
|
||||
// PC 를 아레나 중심으로 반복 텔레포트하면 HP 가 정상 감소(≈22 HP/s) = 전투식 문제가 아니다.
|
||||
// · D-13(중) 존 2·3·4 가 0/14 · 0/12 · 0/8 그대로 — PC 가 존1 을 안 떠난다(존 이동 로직이 없다).
|
||||
//
|
||||
// ■ 원본 훅 0 · 원본 수정 0 — 공개 API 만 쓴다 (실측 근거)
|
||||
// Actor.cs:956 `Get_Target()` 현재 타깃 읽기
|
||||
// Actor.cs:957 `Del_Target(bool forcedel)` 타깃 해제(가이드 중 먼 적 무시)
|
||||
// Actor.cs:988 `Change_Target(true, actor)` 타깃 지정 + 아웃라인 교체(원본 터치 타깃과 같은 경로)
|
||||
// Actor.cs:63 `m_IsLockTarget { get; set; }` 원본 「락 타깃」 — MyActor.cs:106·121 이 이 플래그를
|
||||
// 보고 타깃 교체를 스스로 막는다(보스 고정에 재사용)
|
||||
// Actor.cs:900 `Get_Agent()` → NavMeshAgent 목적지 지정(SetDestination)
|
||||
// Actor.cs:1812 `Get_MoveSpeed()` 이동 속도(원본 Set_Path 와 같은 값)
|
||||
// Actor.cs:2414 `Get_CurAnim()` · 1063 `IsCC()` 공격·스킬·CC 중에는 조종하지 않는다
|
||||
// PCActor.cs:461 `Set_JoystickStatus(int)` · :20 `JoystickStatus`
|
||||
// PCActor.cs:65 `WL_PlayRunNow()` 달리기 애님(타깃이 없으면 경로를 건드리지 않는다)
|
||||
// 🔴 리플렉션 0 · `PCActor.cs`·`Actor.cs`·`MobActor.cs` 변경 0.
|
||||
//
|
||||
// ■ 왜 JoystickStatus 인가 (실측)
|
||||
// PCActor.cs:139 `if (JoystickStatus > 0 || IsStop || IsDead()) return;` 가 **base.Update()
|
||||
// (= MyActor.Update 의 자동 교전 접근 루프)** 를 통째로 건너뛴다. 그 위(PCActor.cs:93)의
|
||||
// 자동 스킬 루프는 그대로 돈다 = 「플레이어가 조이스틱으로 이동하는 동안」의 원본 상태 그대로다.
|
||||
// 이 값을 쓰지 않고 목적지만 주면 원본이 매 프레임 `Play_Run() → Set_Path(타깃)`(MyActor.cs:335)
|
||||
// 으로 경로를 덮어써 두 이동이 싸운다. 원본 코드를 한 줄도 고치지 않고 자동전투를
|
||||
// 「끄지 않은 채」 잠시 비켜 세우는 유일한 공개 경로다.
|
||||
// · 값이 2 인 동안(Skill_Whirlwind·Skill_SweepAttack 이 거는 잠금)에는 조종하지 않는다.
|
||||
// · 조종을 놓을 때는 `Set_JoystickStatus(3)`(= 0 복귀) — 우리가 1 로 만든 경우에만 부른다.
|
||||
//
|
||||
// ■ 가이드 상태
|
||||
// Off 목적지 없음 — 아무 일도 하지 않는다(원본 100%).
|
||||
// Travel 목적지로 이동 중 — 조종 ON · 타깃 해제(보스는 예외).
|
||||
// Hold 도착 — 조종 OFF(원본 자동전투가 그 존/아레나에서 싸운다).
|
||||
// 다만 ① 목적지에서 guideLeashRadius 밖의 타깃은 무시(Del_Target)
|
||||
// ② PC 가 holdRadius 밖으로 밀려나면 Travel 로 되돌아가 끌어온다
|
||||
// → 존1 로 되돌아가는 것(D-13)과 아레나 이탈(D-11)이 여기서 막힌다.
|
||||
// 피격 resumeAfterHitSeconds 동안 가이드를 멈추고 원본에 넘긴다(즉시 응전 · 발주서 §1-1).
|
||||
//
|
||||
// ■ 값(C45) 전부 WLRunGuideSettings. enabled = 0 이면 러너가 뜨지 않고 구독도 하지 않는다(C8).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
using UnityEngine.AI;
|
||||
using WL.Combat.Boss;
|
||||
using WL.Combat.Core;
|
||||
|
||||
namespace WL.Combat.Run
|
||||
{
|
||||
/// <summary>가이드 상태.</summary>
|
||||
public enum RunGuideState
|
||||
{
|
||||
/// <summary>목적지 없음(원본 100%).</summary>
|
||||
Off = 0,
|
||||
/// <summary>목적지로 이동 중(조종 ON).</summary>
|
||||
Travel = 1,
|
||||
/// <summary>도착 — 그 자리에서 원본 자동전투(조종 OFF · 이탈만 감시).</summary>
|
||||
Hold = 2,
|
||||
}
|
||||
|
||||
/// <summary>목적지 종류.</summary>
|
||||
public enum RunGuideDest
|
||||
{
|
||||
None = 0,
|
||||
/// <summary>존 스포너 앵커.</summary>
|
||||
Zone = 1,
|
||||
/// <summary>보스 아레나 중심.</summary>
|
||||
Boss = 2,
|
||||
}
|
||||
|
||||
/// <summary>런 진행 가이드. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개) · GC 0.</summary>
|
||||
public static class RunGuide
|
||||
{
|
||||
static WLRunGuideSettings Cfg { get { return WLRunGuideSettings.Instance; } }
|
||||
|
||||
/// <summary>가이드가 살아 있는가(에셋 · enabled · 런타임 스위치 · 런 축). false 면 원본 자동전투 100%.</summary>
|
||||
public static bool Active { get { return WLRunGuideSettings.Enabled; } }
|
||||
|
||||
// ───────────────────────────────────────── 상태 (프로브가 읽는다)
|
||||
public static RunGuideState State { get { return s_state; } }
|
||||
public static RunGuideDest DestKind { get { return s_destKind; } }
|
||||
/// <summary>존 목적지일 때의 존 인덱스(0부터). 보스면 −1.</summary>
|
||||
public static int ZoneIndex { get { return s_zoneIndex; } }
|
||||
/// <summary>현재 목적지(월드). 보스면 BossArena.Center 를 매 틱 다시 읽는다.</summary>
|
||||
public static Vector3 Destination { get { return s_dest; } }
|
||||
/// <summary>지금 WL 이 PC 를 조종 중인가(JoystickStatus = 1).</summary>
|
||||
public static bool Steering { get { return s_steering; } }
|
||||
/// <summary>구독 상태.</summary>
|
||||
public static bool Subscribed { get { return s_subscribed; } }
|
||||
/// <summary>고장(예외) 상태 — 이후 아무 일도 하지 않는다.</summary>
|
||||
public static bool Faulted { get { return s_faulted; } }
|
||||
|
||||
// ───────────────────────────────────────── 진단 카운터
|
||||
public static int GuideBegun, Arrived, GaveUp, FarTargetIgnored, HoldPullbacks,
|
||||
Repaths, BossRetargets, BossLocks, HitSuspends, AnchorScans;
|
||||
public static string LastLog = "";
|
||||
/// <summary>마지막으로 무시한 타깃 이름(진단).</summary>
|
||||
public static string LastIgnoredTarget = "";
|
||||
|
||||
// ───────────────────────────────────────── 내부 상태
|
||||
static RunGuideState s_state;
|
||||
static RunGuideDest s_destKind;
|
||||
static int s_zoneIndex = -1;
|
||||
static Vector3 s_dest;
|
||||
static float s_nextPoll, s_nextRepath, s_travelStart, s_hitUntil;
|
||||
static bool s_steering, s_subscribed, s_faulted;
|
||||
static Actor s_boss, s_lockedBoss;
|
||||
|
||||
// 존 앵커 캐시(스포너 노드 실좌표 · 없으면 SO 폴백)
|
||||
const int kMaxZones = 16;
|
||||
static readonly Vector3[] s_anchors = new Vector3[kMaxZones];
|
||||
static readonly bool[] s_anchorFromNode = new bool[kMaxZones];
|
||||
static int s_anchorCount;
|
||||
|
||||
// 구독 핸들러 캐시(할당 0 — Add/Remove 가 같은 델리게이트를 봐야 한다)
|
||||
static readonly CombatHandler<RunStartedEvent> h_runStarted = OnRunStarted;
|
||||
static readonly CombatHandler<ZoneClearedEvent> h_zoneCleared = OnZoneCleared;
|
||||
static readonly CombatHandler<BossGateOpenedEvent> h_gate = OnBossGateOpened;
|
||||
static readonly CombatHandler<BossStartedEvent> h_bossStarted = OnBossStarted;
|
||||
static readonly CombatHandler<RunResult> h_runEnded = OnRunEnded;
|
||||
static readonly CombatHandler<DamagedEvent> h_damaged = OnDamaged;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 구독
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>RunEvents · CombatEvents.Damaged 구독(중복 안전).</summary>
|
||||
public static void EnsureSubscribed()
|
||||
{
|
||||
if (s_subscribed) return;
|
||||
s_subscribed = true;
|
||||
RunEvents.RunStarted.Add(h_runStarted);
|
||||
RunEvents.ZoneCleared.Add(h_zoneCleared);
|
||||
RunEvents.BossGateOpened.Add(h_gate);
|
||||
RunEvents.BossStarted.Add(h_bossStarted);
|
||||
RunEvents.RunEnded.Add(h_runEnded);
|
||||
CombatEvents.Damaged.Add(h_damaged);
|
||||
}
|
||||
|
||||
/// <summary>구독 해제(A/B · 프로브).</summary>
|
||||
public static void Unsubscribe()
|
||||
{
|
||||
if (!s_subscribed) return;
|
||||
s_subscribed = false;
|
||||
RunEvents.RunStarted.Remove(h_runStarted);
|
||||
RunEvents.ZoneCleared.Remove(h_zoneCleared);
|
||||
RunEvents.BossGateOpened.Remove(h_gate);
|
||||
RunEvents.BossStarted.Remove(h_bossStarted);
|
||||
RunEvents.RunEnded.Remove(h_runEnded);
|
||||
CombatEvents.Damaged.Remove(h_damaged);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 이벤트 → 목적지
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void OnRunStarted(in RunStartedEvent e)
|
||||
{
|
||||
StopGuide("런 시작");
|
||||
s_boss = null;
|
||||
s_anchorCount = 0; // 맵이 바뀌었을 수 있다 — 앵커를 다시 읽는다
|
||||
var cfg = Cfg;
|
||||
if (cfg != null && cfg.zoneGuideEnabled && cfg.guideOnRunStart) BeginZone(0, "런 시작");
|
||||
}
|
||||
|
||||
static void OnZoneCleared(in ZoneClearedEvent e)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !cfg.zoneGuideEnabled) return;
|
||||
if (e.allZonesCleared) return; // 게이트 개방 통지가 따로 온다(보스 가이드)
|
||||
BeginZone(e.zoneIndex + 1, "존 " + (e.zoneIndex + 1) + " 클리어");
|
||||
}
|
||||
|
||||
static void OnBossGateOpened(in BossGateOpenedEvent e) { BeginBoss("게이트 개방"); }
|
||||
|
||||
static void OnBossStarted(in BossStartedEvent e)
|
||||
{
|
||||
if (!DSUtil.CheckNull(e.boss)) s_boss = e.boss;
|
||||
BeginBoss("보스 등장");
|
||||
}
|
||||
|
||||
static void OnRunEnded(in RunResult e) { StopGuide("런 종료"); }
|
||||
|
||||
static void OnDamaged(in DamagedEvent e)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || cfg.resumeAfterHitSeconds <= 0f) return;
|
||||
if (s_destKind == RunGuideDest.None) return;
|
||||
var pc = MyValue.MyPC;
|
||||
if (DSUtil.CheckNull(pc) || !ReferenceEquals(e.victim, pc)) return;
|
||||
bool wasFree = s_hitUntil <= Time.unscaledTime;
|
||||
s_hitUntil = Time.unscaledTime + cfg.resumeAfterHitSeconds;
|
||||
if (wasFree) HitSuspends++;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 목적지 지정
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>존 i 로 유도한다(프로브도 직접 부를 수 있다).</summary>
|
||||
public static void BeginZone(int zoneIndex, string reason)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !cfg.zoneGuideEnabled) return;
|
||||
int n = ZoneCount(cfg);
|
||||
if (zoneIndex < 0 || zoneIndex >= n) { StopGuide("존 " + zoneIndex + " 없음"); return; }
|
||||
|
||||
Vector3 anchor;
|
||||
if (!TryGetAnchor(cfg, zoneIndex, out anchor)) { StopGuide("존 " + zoneIndex + " 앵커 없음"); return; }
|
||||
|
||||
ReleaseBossLock();
|
||||
s_destKind = RunGuideDest.Zone;
|
||||
s_zoneIndex = zoneIndex;
|
||||
s_dest = anchor;
|
||||
EnterTravel();
|
||||
GuideBegun++;
|
||||
Log("존 " + zoneIndex + " 유도 시작(" + reason + ") → " + Fmt(anchor) +
|
||||
(s_anchorFromNode[zoneIndex] ? " [스포너 노드]" : " [SO 폴백]"));
|
||||
}
|
||||
|
||||
/// <summary>보스 아레나로 유도한다(프로브도 직접 부를 수 있다).</summary>
|
||||
public static void BeginBoss(string reason)
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !cfg.bossGuideEnabled) return;
|
||||
if (s_destKind == RunGuideDest.Boss && s_state != RunGuideState.Off) return; // 이미 보스 유도 중
|
||||
s_destKind = RunGuideDest.Boss;
|
||||
s_zoneIndex = -1;
|
||||
s_dest = BossArena.Center;
|
||||
EnterTravel();
|
||||
GuideBegun++;
|
||||
Log("보스 아레나 유도 시작(" + reason + ") → " + Fmt(s_dest));
|
||||
}
|
||||
|
||||
/// <summary>가이드를 끝낸다(조종·락 해제 포함).</summary>
|
||||
public static void StopGuide(string reason)
|
||||
{
|
||||
bool had = s_destKind != RunGuideDest.None;
|
||||
ReleaseSteer();
|
||||
ReleaseBossLock();
|
||||
s_destKind = RunGuideDest.None;
|
||||
s_state = RunGuideState.Off;
|
||||
s_zoneIndex = -1;
|
||||
s_hitUntil = 0f;
|
||||
if (had) Log("가이드 종료 — " + reason);
|
||||
}
|
||||
|
||||
static void EnterTravel()
|
||||
{
|
||||
s_state = RunGuideState.Travel;
|
||||
s_travelStart = Time.unscaledTime;
|
||||
s_nextRepath = 0f;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 틱
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 부른다).</summary>
|
||||
internal static void Tick()
|
||||
{
|
||||
if (s_faulted) return;
|
||||
if (!Active) { if (s_steering) ReleaseSteer(); return; }
|
||||
|
||||
var cfg = Cfg;
|
||||
float now = Time.unscaledTime;
|
||||
if (now < s_nextPoll) return;
|
||||
s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds);
|
||||
|
||||
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1).
|
||||
try { Poll(cfg, now); }
|
||||
catch (System.Exception ex) { s_faulted = true; ReleaseSteer(); Log("중단(예외) — " + ex.Message); }
|
||||
}
|
||||
|
||||
static void Poll(WLRunGuideSettings cfg, float now)
|
||||
{
|
||||
var pc = MyValue.MyPC;
|
||||
if (DSUtil.CheckNull(pc) || pc.IsDead()) { ReleaseSteer(); return; }
|
||||
|
||||
if (s_destKind == RunGuideDest.None) { ReleaseSteer(); return; }
|
||||
|
||||
// ── 보스 우선 타깃(발주서 §1-2) — 조종/도착 상태와 무관하게 보스가 살아 있으면 건다
|
||||
if (s_destKind == RunGuideDest.Boss) ApplyBossPriority(cfg, pc);
|
||||
|
||||
// ── 피격 → 즉시 응전(가이드 일시 중단)
|
||||
if (now < s_hitUntil) { ReleaseSteer(); return; }
|
||||
|
||||
if (s_destKind == RunGuideDest.Boss) s_dest = BossArena.Center;
|
||||
|
||||
Vector3 pcPos = pc.Get_position();
|
||||
float d = DistXZ(pcPos, s_dest);
|
||||
bool isBoss = s_destKind == RunGuideDest.Boss;
|
||||
float arrive = isBoss ? cfg.bossArriveRadius : cfg.zoneArriveRadius;
|
||||
float hold = isBoss ? cfg.bossHoldRadius : cfg.zoneHoldRadius;
|
||||
|
||||
if (s_state == RunGuideState.Travel)
|
||||
{
|
||||
if (d <= arrive)
|
||||
{
|
||||
s_state = RunGuideState.Hold;
|
||||
ReleaseSteer();
|
||||
Arrived++;
|
||||
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 도착 — " + d.ToString("F1") + "m · 자동전투 재개");
|
||||
return;
|
||||
}
|
||||
if (cfg.maxTravelSeconds > 0f && now - s_travelStart >= cfg.maxTravelSeconds)
|
||||
{
|
||||
s_state = RunGuideState.Hold; // 포기하되 이탈 감시는 남긴다
|
||||
ReleaseSteer();
|
||||
GaveUp++;
|
||||
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이동 포기 — " +
|
||||
cfg.maxTravelSeconds.ToString("F0") + "s 초과(남은 " + d.ToString("F1") + "m)");
|
||||
return;
|
||||
}
|
||||
ClearTravelTarget(pc);
|
||||
Steer(cfg, pc, now);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Hold: 조종은 놓고, 먼 타깃만 무시 · 이탈하면 다시 끌어온다
|
||||
IgnoreFarTarget(cfg, pc);
|
||||
if (hold > 0f && d > hold)
|
||||
{
|
||||
EnterTravel();
|
||||
HoldPullbacks++;
|
||||
Log((isBoss ? "아레나" : "존 " + s_zoneIndex) + " 이탈 " + d.ToString("F1") + "m — 다시 유도");
|
||||
ClearTravelTarget(pc);
|
||||
Steer(cfg, pc, now);
|
||||
return;
|
||||
}
|
||||
ReleaseSteer();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 조종 · 타깃
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>이동 중에는 타깃을 놓는다(보스가 목적지일 때 보스는 예외 — 그쪽으로 가는 것이 목적이다).</summary>
|
||||
static void ClearTravelTarget(PCActor pc)
|
||||
{
|
||||
if (!pc.isTarget) return;
|
||||
var t = pc.Get_Target();
|
||||
if (DSUtil.CheckNull(t)) return;
|
||||
if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return;
|
||||
LastIgnoredTarget = t.name;
|
||||
pc.Del_Target(true);
|
||||
FarTargetIgnored++;
|
||||
}
|
||||
|
||||
/// <summary>도착 뒤: 목적지에서 guideLeashRadius 밖의 타깃만 무시한다(가까운 적은 원본이 잡는다).</summary>
|
||||
static void IgnoreFarTarget(WLRunGuideSettings cfg, PCActor pc)
|
||||
{
|
||||
if (cfg.guideLeashRadius <= 0f || !pc.isTarget) return;
|
||||
var t = pc.Get_Target();
|
||||
if (DSUtil.CheckNull(t)) return;
|
||||
if (s_destKind == RunGuideDest.Boss && !DSUtil.CheckNull(s_boss) && ReferenceEquals(t, s_boss)) return;
|
||||
if (DistXZ(t.Get_position(), s_dest) <= cfg.guideLeashRadius) return;
|
||||
LastIgnoredTarget = t.name;
|
||||
pc.Del_Target(true);
|
||||
FarTargetIgnored++;
|
||||
}
|
||||
|
||||
/// <summary>PC 를 목적지로 민다. 원본 이동 API(NavMeshAgent)만 쓴다.</summary>
|
||||
static void Steer(WLRunGuideSettings cfg, PCActor pc, float now)
|
||||
{
|
||||
// 🔴 에이전트 검사를 맨 앞에 둔다 — 에디트 모드(프로브)에는 NavMeshAgent 도 dic_ccData 초기화도 없어
|
||||
// IsCC() 가 KeyNotFoundException 을 던진다. 런타임 판정 순서는 어차피 결과가 같다.
|
||||
var agent = pc.Get_Agent();
|
||||
if (agent == null || !agent.enabled || !agent.isOnNavMesh) return; // NavMesh 밖 — 조종 포기
|
||||
|
||||
var anim = pc.Get_CurAnim();
|
||||
if (anim == eAnim.Dodge || anim >= eAnim.Attack || pc.IsCC()) return; // 공격·스킬·회피·CC 는 원본 우선
|
||||
if (pc.JoystickStatus == 2) return; // 스킬 이동 잠금(Whirlwind 등)
|
||||
|
||||
if (!s_steering && pc.JoystickStatus == 0)
|
||||
{
|
||||
pc.Set_JoystickStatus(1); // 원본 자동 교전 접근 루프를 비켜 세운다(PCActor.cs:139)
|
||||
s_steering = true;
|
||||
}
|
||||
|
||||
pc.WL_PlayRunNow(); // 타깃이 없으므로 경로를 건드리지 않는다(MyActor.cs:335)
|
||||
agent.isStopped = false;
|
||||
agent.stoppingDistance = cfg.guideStopDistance;
|
||||
|
||||
if (now >= s_nextRepath)
|
||||
{
|
||||
s_nextRepath = now + Mathf.Max(0f, cfg.repathIntervalSeconds);
|
||||
agent.speed = pc.Get_MoveSpeed();
|
||||
agent.SetDestination(s_dest);
|
||||
Repaths++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>조종을 놓는다(우리가 1 로 만든 경우에만 0 으로 되돌린다).</summary>
|
||||
static void ReleaseSteer()
|
||||
{
|
||||
if (!s_steering) return;
|
||||
s_steering = false;
|
||||
var pc = MyValue.MyPC;
|
||||
if (DSUtil.CheckNull(pc)) return;
|
||||
if (pc.JoystickStatus == 1) pc.Set_JoystickStatus(3); // 3 = 해제(PCActor.cs:461)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 보스 우선 타깃
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static void ApplyBossPriority(WLRunGuideSettings cfg, PCActor pc)
|
||||
{
|
||||
if (!cfg.bossPriorityEnabled) { ReleaseBossLock(); return; }
|
||||
|
||||
var boss = s_boss;
|
||||
if (!IsAliveBoss(boss)) { boss = BossArena.Boss; s_boss = IsAliveBoss(boss) ? boss : null; }
|
||||
if (!IsAliveBoss(s_boss)) { ReleaseBossLock(); return; }
|
||||
boss = s_boss;
|
||||
|
||||
if (cfg.bossLockTarget && !boss.m_IsLockTarget)
|
||||
{
|
||||
boss.m_IsLockTarget = true; // 원본 락 타깃(MyActor.cs:106·121 이 스스로 교체를 막는다)
|
||||
s_lockedBoss = boss;
|
||||
BossLocks++;
|
||||
}
|
||||
|
||||
if (!ReferenceEquals(pc.Get_Target(), boss))
|
||||
{
|
||||
pc.Change_Target(true, boss); // 아웃라인까지 원본 경로 그대로(Actor.cs:988)
|
||||
BossRetargets++;
|
||||
}
|
||||
}
|
||||
|
||||
static void ReleaseBossLock()
|
||||
{
|
||||
if (DSUtil.CheckNull(s_lockedBoss)) { s_lockedBoss = null; return; }
|
||||
s_lockedBoss.m_IsLockTarget = false;
|
||||
s_lockedBoss = null;
|
||||
}
|
||||
|
||||
static bool IsAliveBoss(Actor a)
|
||||
{
|
||||
return !DSUtil.CheckNull(a) && !a.IsDead() && a.gameObject.activeInHierarchy;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 존 앵커 (스포너 노드 실좌표 · 없으면 SO 폴백)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static int ZoneCount(WLRunGuideSettings cfg)
|
||||
{
|
||||
var rs = WLRunSettings.Instance;
|
||||
if (rs == null || rs.zoneSpawnerIds == null) return 0;
|
||||
int n = rs.zoneSpawnerIds.Length;
|
||||
if (rs.zoneClearKills != null && rs.zoneClearKills.Length < n) n = rs.zoneClearKills.Length;
|
||||
return n > kMaxZones ? kMaxZones : n;
|
||||
}
|
||||
|
||||
/// <summary>존 i 의 앵커. 스포너 노드를 못 찾았으면 1회 스캔하고, 그래도 없으면 SO 폴백.</summary>
|
||||
public static bool TryGetAnchor(WLRunGuideSettings cfg, int i, out Vector3 anchor)
|
||||
{
|
||||
anchor = Vector3.zero;
|
||||
int n = ZoneCount(cfg);
|
||||
if (i < 0 || i >= n) return false;
|
||||
|
||||
if (s_anchorCount != n || !s_anchorFromNode[i]) ScanAnchors(cfg, n);
|
||||
|
||||
if (s_anchorFromNode[i]) { anchor = s_anchors[i]; return true; }
|
||||
|
||||
var fb = cfg.zoneAnchorFallbacks;
|
||||
if (fb != null && i < fb.Length) { anchor = fb[i]; s_anchors[i] = anchor; return true; }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>씬의 MobControlMgr 를 1회 훑어 존 앵커를 채운다(런당 몇 회 · 매 틱 아님).</summary>
|
||||
static void ScanAnchors(WLRunGuideSettings cfg, int n)
|
||||
{
|
||||
var rs = WLRunSettings.Instance;
|
||||
if (rs == null || rs.zoneSpawnerIds == null) return;
|
||||
s_anchorCount = n;
|
||||
AnchorScans++;
|
||||
|
||||
var spawners = Object.FindObjectsByType<MobControlMgr>(FindObjectsInactive.Include, FindObjectsSortMode.None);
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
s_anchorFromNode[i] = false;
|
||||
int id = rs.zoneSpawnerIds[i];
|
||||
for (int j = 0; spawners != null && j < spawners.Length; j++)
|
||||
{
|
||||
if (DSUtil.CheckNull(spawners[j]) || spawners[j].SpawnerID != id) continue;
|
||||
s_anchors[i] = spawners[j].transform.position;
|
||||
s_anchorFromNode[i] = true;
|
||||
break;
|
||||
}
|
||||
if (!s_anchorFromNode[i])
|
||||
{
|
||||
var fb = cfg.zoneAnchorFallbacks;
|
||||
if (fb != null && i < fb.Length) s_anchors[i] = fb[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>앵커가 스포너 노드에서 온 것인가(진단).</summary>
|
||||
public static bool AnchorFromNode(int i) { return i >= 0 && i < kMaxZones && s_anchorFromNode[i]; }
|
||||
/// <summary>캐시된 앵커(진단).</summary>
|
||||
public static Vector3 AnchorOf(int i) { return i >= 0 && i < kMaxZones ? s_anchors[i] : Vector3.zero; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 유틸 · 진단
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static float DistXZ(Vector3 a, Vector3 b)
|
||||
{
|
||||
float dx = a.x - b.x, dz = a.z - b.z;
|
||||
return Mathf.Sqrt(dx * dx + dz * dz);
|
||||
}
|
||||
|
||||
static string Fmt(Vector3 v)
|
||||
{
|
||||
return "(" + v.x.ToString("F1") + ", " + v.y.ToString("F1") + ", " + v.z.ToString("F1") + ")";
|
||||
}
|
||||
|
||||
static void Log(string msg)
|
||||
{
|
||||
LastLog = msg;
|
||||
var c = Cfg;
|
||||
if (c != null && c.verboseLog) Debug.Log("[RunGuide] " + msg);
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 카운터·상태 초기화(구독은 유지).</summary>
|
||||
public static void ResetDiagnostics()
|
||||
{
|
||||
GuideBegun = Arrived = GaveUp = FarTargetIgnored = HoldPullbacks = 0;
|
||||
Repaths = BossRetargets = BossLocks = HitSuspends = AnchorScans = 0;
|
||||
LastLog = LastIgnoredTarget = "";
|
||||
ReleaseSteer(); ReleaseBossLock();
|
||||
s_state = RunGuideState.Off; s_destKind = RunGuideDest.None; s_zoneIndex = -1;
|
||||
s_dest = Vector3.zero; s_boss = null;
|
||||
s_nextPoll = s_nextRepath = s_travelStart = s_hitUntil = 0f;
|
||||
s_faulted = false; s_anchorCount = 0;
|
||||
}
|
||||
|
||||
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
|
||||
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
|
||||
|
||||
/// <summary>프로브용: 피격 응전 구간을 직접 건다.</summary>
|
||||
public static void SimulateHit() { var c = Cfg; if (c != null) { s_hitUntil = Time.unscaledTime + c.resumeAfterHitSeconds; HitSuspends++; } }
|
||||
|
||||
/// <summary>프로브용: 피격 응전 구간을 즉시 푼다.</summary>
|
||||
public static void ClearHitSuspendForProbe() { s_hitUntil = 0f; }
|
||||
|
||||
/// <summary>프로브용: 보스 참조를 직접 넣는다.</summary>
|
||||
public static void SetBossForProbe(Actor boss) { s_boss = boss; }
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 런너 (숨김 GameObject 1개 · 플레이 모드 전용)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
static RunGuideRunner s_runner;
|
||||
|
||||
internal static void EnsureRunner()
|
||||
{
|
||||
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
|
||||
var go = new GameObject("[WL813w] RunGuideRunner");
|
||||
go.hideFlags = HideFlags.HideAndDontSave;
|
||||
s_runner = go.AddComponent<RunGuideRunner>();
|
||||
}
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
static void Boot()
|
||||
{
|
||||
if (!Active) return; // 꺼져 있으면 오브젝트 0 · 구독 0(C8)
|
||||
EnsureSubscribed();
|
||||
EnsureRunner();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>RunGuide 의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
|
||||
internal sealed class RunGuideRunner : MonoBehaviour
|
||||
{
|
||||
void Update() { RunGuide.Tick(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e22
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WLRunGuideSettings.cs — 런 진행 가이드(존 이동 · 보스 우선) 값의 단일 출처(SOT · C45)
|
||||
//
|
||||
// PD 지시 #813 · 발주서 WL-813w §1 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-11 · D-13
|
||||
//
|
||||
// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLRunGuideSettings.asset
|
||||
// → Resources.Load<WLRunGuideSettings>("WL/WLRunGuideSettings") 로 1회 로드 후 캐시
|
||||
// (WLRunSettings · WLBossArenaSettings 관례 그대로).
|
||||
//
|
||||
// ■ C8 롤백 (하나라도 false 면 RunGuide 는 아무 일도 하지 않는다 = 원본 자동전투 100%)
|
||||
// ① 에셋이 없다 → Enabled false
|
||||
// ② enabled = false → Enabled false (러너 자체가 뜨지 않는다 · 구독 0)
|
||||
// ③ RuntimeDisabled = true → Enabled false (진단·A/B 전용 · 도메인 리로드로 자동 해제)
|
||||
// ④ WLRunSettings.Enabled = false → Enabled false
|
||||
// 🔴 ④ 가 필요한 이유: 가이드의 목적지는 RunEvents(존 클리어·게이트·보스)로만 정해진다.
|
||||
// 런 축이 꺼지면 이벤트가 0 이라 가이드가 목적지를 영원히 못 받는다 → 아예 붙지 않는다.
|
||||
//
|
||||
// ■ 아래 필드 기본값은 에셋 생성 시 초기값이다. 실제로 읽히는 값은 항상 .asset 이며
|
||||
// 코드에는 게임 수치 상수를 두지 않는다(C45).
|
||||
//
|
||||
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(PCActor 가 Assembly-CSharp 에 있다).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
using UnityEngine;
|
||||
|
||||
namespace WL.Combat.Run
|
||||
{
|
||||
[CreateAssetMenu(fileName = "WLRunGuideSettings", menuName = "WL/Run Guide Settings", order = 328)]
|
||||
public sealed class WLRunGuideSettings : ScriptableObject
|
||||
{
|
||||
public const string ResourcesPath = "WL/WLRunGuideSettings";
|
||||
|
||||
static WLRunGuideSettings s_cached;
|
||||
static bool s_lookupDone;
|
||||
|
||||
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||
public static WLRunGuideSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!s_lookupDone)
|
||||
{
|
||||
s_cached = Resources.Load<WLRunGuideSettings>(ResourcesPath);
|
||||
s_lookupDone = true;
|
||||
}
|
||||
return s_cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>진단·A/B 전용 런타임 스위치(도메인 리로드로 자동 false).</summary>
|
||||
public static bool RuntimeDisabled;
|
||||
|
||||
/// <summary>가이드가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 런 축 on.</summary>
|
||||
public static bool Enabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var i = Instance;
|
||||
return i != null && i.enabled && !RuntimeDisabled && WLRunSettings.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
|
||||
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
|
||||
|
||||
// ─────────────────────────────────────────── 전체 스위치
|
||||
[Header("전체 스위치")]
|
||||
[Tooltip("끄면 존 이동 가이드·보스 우선 타깃이 전부 비활성 — 원본 자동전투 100%(Q4b 상태 그대로). 롤백 1순위.")]
|
||||
public bool enabled = true;
|
||||
|
||||
[Tooltip("가이드 상태 전이·목적지·타깃 무시를 Console 에 남긴다(검증용 · 기본 off).")]
|
||||
public bool verboseLog = false;
|
||||
|
||||
[Tooltip("가이드 폴링 주기(초 · unscaled). 0.02 미만은 0.02 로 올린다.")]
|
||||
public float pollSeconds = 0.1f;
|
||||
|
||||
// ─────────────────────────────────────────── 존 이동
|
||||
[Header("존 이동 (D-13 · 존 클리어 → 다음 존)")]
|
||||
[Tooltip("끄면 존 이동 가이드를 하지 않는다(보스 가이드만 쓴다).")]
|
||||
public bool zoneGuideEnabled = true;
|
||||
|
||||
[Tooltip("런 시작(RunStarted) 때도 첫 존으로 유도한다. 기본 off — Q4b 실측상 PC 는 존1 에서 이미 교전한다.")]
|
||||
public bool guideOnRunStart = false;
|
||||
|
||||
[Tooltip("존 앵커에 이 거리(m · XZ) 안으로 들어오면 「도착」 = 조종을 놓고 원본 자동전투에 넘긴다.")]
|
||||
public float zoneArriveRadius = 3f;
|
||||
|
||||
[Tooltip("도착 뒤에도 PC 가 앵커에서 이 거리(m) 밖으로 밀려나면 다시 끌어온다(존1 로 되돌아가는 것을 막는다). 0 이하면 유지 안 함.")]
|
||||
public float zoneHoldRadius = 18f;
|
||||
|
||||
[Tooltip("스포너 노드를 못 찾았을 때 쓸 존 앵커(월드 좌표). 순서 = WLRunSettings.zoneSpawnerIds. "
|
||||
+ "런타임에는 언제나 MobControlMgr.SpawnerID 가 같은 노드의 실좌표를 먼저 쓴다.")]
|
||||
public Vector3[] zoneAnchorFallbacks = new Vector3[]
|
||||
{
|
||||
new Vector3(115f, 0.39f, -22f), // 813001
|
||||
new Vector3(147f, 2.59f, 30f), // 813002
|
||||
new Vector3( 71f, -0.29f, 18f), // 813003
|
||||
new Vector3(159f, 0.88f, -22f), // 813004
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────── 공통 조종 규칙
|
||||
[Header("조종 규칙 (원본 타깃/이동 API 만 쓴다)")]
|
||||
[Tooltip("가이드 중 PC 의 타깃이 목적지(앵커/아레나 중심)에서 이 거리(m) 밖이면 무시한다(Del_Target). "
|
||||
+ "이 안의 적은 원본 자동전투가 그대로 잡는다.")]
|
||||
public float guideLeashRadius = 12f;
|
||||
|
||||
[Tooltip("조종 중 NavMeshAgent.stoppingDistance(m). 목적지 코앞에서 멈춰 서서 도착 판정이 안 나는 것을 막는다.")]
|
||||
public float guideStopDistance = 0.5f;
|
||||
|
||||
[Tooltip("목적지 재지정(SetDestination) 주기(초). 매 폴링마다 부르지 않게 벌린다. 0 이면 매 폴링.")]
|
||||
public float repathIntervalSeconds = 0.5f;
|
||||
|
||||
[Tooltip("PC 가 맞으면 이 시간(초) 동안 가이드를 멈추고 즉시 응전한다(원본 자동전투가 잡는다). 0 이면 응전 중단 없음.")]
|
||||
public float resumeAfterHitSeconds = 3f;
|
||||
|
||||
[Tooltip("한 목적지에 이 시간(초) 안에 도착 못 하면 가이드를 포기한다(경로 불가·지형 끼임 보호). 0 이하면 무제한.")]
|
||||
public float maxTravelSeconds = 60f;
|
||||
|
||||
// ─────────────────────────────────────────── 보스
|
||||
[Header("보스 (D-11 · 아레나 이탈 → 리쉬 만피 리셋)")]
|
||||
[Tooltip("끄면 게이트 개방·보스 등장 때 아레나로 유도하지 않는다.")]
|
||||
public bool bossGuideEnabled = true;
|
||||
|
||||
[Tooltip("보스가 살아 있는 동안 PC 타깃을 보스로 고정한다(원본 Set_Target(Actor) + m_IsLockTarget). 끄면 원본 타깃 선택 그대로.")]
|
||||
public bool bossPriorityEnabled = true;
|
||||
|
||||
[Tooltip("아레나 중심에 이 거리(m) 안으로 들어오면 도착으로 본다. 813d arenaRadius 20 보다 충분히 작아야 한다.")]
|
||||
public float bossArriveRadius = 10f;
|
||||
|
||||
[Tooltip("보스전 중 PC 가 아레나 중심에서 이 거리(m) 밖으로 나가면 다시 끌어온다. "
|
||||
+ "🔴 813d 리쉬 발동선(arenaRadius + playerLeaveMargin = 24 m)보다 작아야 리셋이 아예 안 일어난다.")]
|
||||
public float bossHoldRadius = 14f;
|
||||
|
||||
[Tooltip("보스에게 원본 락타깃(m_IsLockTarget)을 건다 — 잡몹으로 타깃이 새는 원본 경로 3곳을 함께 막는다. 보스 사망 시 해제.")]
|
||||
public bool bossLockTarget = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e21
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -44,6 +44,9 @@ MonoBehaviour:
|
|||
playerLeaveGraceSeconds: 1.5
|
||||
resetHealsBoss: 1
|
||||
resetHealRate: 1
|
||||
leashHealPercent: 0.15
|
||||
leashHealCooldownSeconds: 10
|
||||
bossNameFromTable: 1
|
||||
suppressRegenAfterBossKill: 1
|
||||
cleanupDelaySeconds: 3
|
||||
deactivateZonesAfterBossKill: 0
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
%YAML 1.1
|
||||
%TAG !u! tag:unity3d.com,2011:
|
||||
--- !u!114 &11400000
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 0}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e21, type: 3}
|
||||
m_Name: WLRunGuideSettings
|
||||
m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Run.WLRunGuideSettings
|
||||
enabled: 1
|
||||
verboseLog: 0
|
||||
pollSeconds: 0.1
|
||||
zoneGuideEnabled: 1
|
||||
guideOnRunStart: 0
|
||||
zoneArriveRadius: 3
|
||||
zoneHoldRadius: 18
|
||||
zoneAnchorFallbacks:
|
||||
- {x: 115, y: 0.39, z: -22}
|
||||
- {x: 147, y: 2.59, z: 30}
|
||||
- {x: 71, y: -0.29, z: 18}
|
||||
- {x: 159, y: 0.88, z: -22}
|
||||
guideLeashRadius: 12
|
||||
guideStopDistance: 0.5
|
||||
repathIntervalSeconds: 0.5
|
||||
resumeAfterHitSeconds: 3
|
||||
maxTravelSeconds: 60
|
||||
bossGuideEnabled: 1
|
||||
bossPriorityEnabled: 1
|
||||
bossArriveRadius: 10
|
||||
bossHoldRadius: 14
|
||||
bossLockTarget: 1
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d13d0221a2b3c4d5e6f7a8b9c0d1e23
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -205,6 +205,74 @@ namespace WL.UI
|
|||
return (float)st.Get_Stat(eStat.MaxHP);
|
||||
}
|
||||
|
||||
// ── WL-813w §1-4 (Q4b D-12): HP 바 라벨이 `Anubis(Clone)` 로 뜬다 ───────────
|
||||
// 등장 배너(813tj)는 MonsterList 이름("보스 몬스터 6")을 쓰는데 HP 바만 gameObject.name 을 썼다.
|
||||
// 같은 경로(BossBanner.ResolveMonsterName = MonsterList.n_MonsterName → localtext)를 쓰고,
|
||||
// 표를 못 읽을 때만 오브젝트 이름으로 떨어지되 `(Clone)` 접미사를 지운다.
|
||||
// 이 경로는 Spawned 구독·늦은 구독(813y ⑤) **양쪽이 지나는 Bind() 안**이라 두 경우 모두 고쳐진다.
|
||||
// C8 롤백 = WLBossArenaSettings.bossNameFromTable = 0 → 원본 gameObject.name 그대로.
|
||||
|
||||
/// <summary>마지막 라벨을 어느 경로로 얻었는가(진단 · "테이블"/"오브젝트이름"/"없음").</summary>
|
||||
public static string LastNameSource = "";
|
||||
|
||||
/// <summary>보스 라벨 결정 — 테이블 이름 우선, 폴백은 `(Clone)` 을 지운 오브젝트 이름.</summary>
|
||||
public static string ResolveBossLabel(Actor actor)
|
||||
{
|
||||
return ResolveBossLabel(actor, WL.Combat.Boss.WLBossArenaSettings.Instance);
|
||||
}
|
||||
|
||||
/// <summary>설정을 직접 받는 오버로드(프로브가 임시 SO 로 A/B 한다 · 에셋을 건드리지 않는다).</summary>
|
||||
public static string ResolveBossLabel(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena)
|
||||
{
|
||||
bool useTable = arena == null || arena.bossNameFromTable;
|
||||
|
||||
if (useTable)
|
||||
{
|
||||
string t = ResolveTableName(actor, arena);
|
||||
if (!string.IsNullOrEmpty(t)) { LastNameSource = "테이블"; return t; }
|
||||
}
|
||||
|
||||
if (actor == null) { LastNameSource = "없음"; return ""; }
|
||||
string n = actor.name;
|
||||
if (!useTable) { LastNameSource = "오브젝트이름(원본)"; return n; }
|
||||
LastNameSource = "오브젝트이름";
|
||||
return StripClone(n);
|
||||
}
|
||||
|
||||
/// <summary>`Anubis(Clone)` · `Anubis (Clone)` → `Anubis`.</summary>
|
||||
public static string StripClone(string n)
|
||||
{
|
||||
if (string.IsNullOrEmpty(n)) return n;
|
||||
int i = n.IndexOf("(Clone)", System.StringComparison.Ordinal);
|
||||
if (i < 0) return n;
|
||||
return n.Substring(0, i).TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>MonsterList 이름 — 액터의 스포너 표(MonsterAppear)에서 몬스터 ID 를 얻고, 없으면 SO 의 bossMonsterId.</summary>
|
||||
private static string ResolveTableName(Actor actor, WL.Combat.Boss.WLBossArenaSettings arena)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (actor != null)
|
||||
{
|
||||
var ap = actor.Get_MonsterStatTableData(); // MobActor.cs:635 (public virtual)
|
||||
if (ap != null && ap.Get_MobTypeCount() > 0)
|
||||
{
|
||||
var ids = ap.Get_MobIds();
|
||||
if (ids != null && ids.Count > 0)
|
||||
{
|
||||
string byRow = BossBanner.ResolveMonsterName(ids[0]);
|
||||
if (!string.IsNullOrEmpty(byRow)) return byRow;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (arena != null && arena.bossMonsterId > 0)
|
||||
return BossBanner.ResolveMonsterName(arena.bossMonsterId);
|
||||
}
|
||||
catch { } // 표 미로드(에디트 모드 등)에서 연출이 전투를 끊지 않게 — 813tj 와 같은 규약
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>보스를 바에 물린다. actor 가 null 이면 합성 검증 모드(최대 HP 는 fallbackMaxHp).</summary>
|
||||
public string Bind(Actor actor, string displayName = null, float fallbackMaxHp = 100f)
|
||||
{
|
||||
|
|
@ -223,7 +291,7 @@ namespace WL.UI
|
|||
if (nameLabel != null)
|
||||
{
|
||||
string n = displayName;
|
||||
if (string.IsNullOrEmpty(n)) n = actor != null ? actor.name : "";
|
||||
if (string.IsNullOrEmpty(n)) n = ResolveBossLabel(actor);
|
||||
nameLabel.text = n;
|
||||
nameLabel.gameObject.SetActive(showName && !string.IsNullOrEmpty(n));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue