diff --git a/AgentScripts/WL813d_PROBE.txt b/AgentScripts/WL813d_PROBE.txt new file mode 100644 index 000000000..ab0a9af85 --- /dev/null +++ b/AgentScripts/WL813d_PROBE.txt @@ -0,0 +1,101 @@ +===== WL-813d 보스전 게이트 · 아레나 프로브 ===== +playMode=False time=2026-09-09 02:03:44 + +── ⓪ 준비 + 가짜 FieldBossData = spawnerId 813010 · monsterId 10006 · pos (139.0, 0.8, -20.0) · RegenTime 60 + MonsterList 10006 행 f_BaseChaseRange (실 JSON) = 50.0 dic_Data 주입 = True + +── ① 설정 에셋 + Instance = OK + 코어 Enabled = True + 게이트 Active = True + enabled/verboseLog = True / False + bossSpawnerId/Monster = 813010 / 10006 + gateEnabled/mode = True / Either + zoneSpawnerIds = 813001, 813002, 813003, 813004, 813005 + gateZoneSpawnerIds = 813004, 813005 + requiredKills = 54 requireZoneSpawnedOnce=True + gateOpenDelaySeconds = 0 + arena center/radius = FieldBossData 좌표 / 20 + bossChaseRange = 20 restoreOnCleanup=True + leash margin/PC margin= 2 / 4 grace=1.5s interval=0.25s + resetHeals/rate = True / 1 + cleanup delay/regen = 3s / suppressRegen=True + deactivateZones/rearm = False / False + +── ② 게이트 A — 게이트 존 전멸 → 보스 등장 + HoldSpawn() = True (true = 원본 Start 가 return · 보스 미등장) + GateArmed/Subscribed = True / True + CombatEvents 구독 수 = Killed 0→1 · Spawned 0→1 + resume 호출 수 = 0 (게이트가 닫혀 있으므로 0 이어야 한다) + 반경 적용 여부 = False (등장 직전에만 적용) + + [스폰] 존별 n_MaxMonsterCount 만큼 가짜 Spawned + 존 813001 : 스폰 12 → alive 12 + 존 813002 : 스폰 15 → alive 15 + 존 813003 : 스폰 14 → alive 14 + 존 813004 : 스폰 8 → alive 8 + 존 813005 : 스폰 5 → alive 5 + GateOpen = False (아직 닫혀 있어야 한다) + + [처치] 게이트 존만 전멸 — 비게이트 존은 살려 둔다(KillCount 경로와 분리) + 존 813004 첫 처치 → alive 7 · GateOpen False + 존 813004 마지막 처치 → alive 0 · 누적처치 8/54 · GateOpen False · resume 0 + 존 813005 첫 처치 → alive 4 · GateOpen False + ▶ resume() 호출 = 원본 FieldBossData.Start 재진입(보스 활성) #1 + 존 813005 마지막 처치 → alive 0 · 누적처치 13/54 · GateOpen True · resume 1 + + 결과 GateOpen = True + 결과 resume 호출 수 = 1 (1 = 원본 FieldBossData.Start 재진입 = 보스 활성) + ReleaseCount = 1 + 누적 처치 = 13 / requiredKills 54 → KillCount 조건은 아직 미충족(= ZoneCleared 단독 개방) + LastLog = 보스 등장 개시 — released=1 + +── ④ 추적 반경 데이터화 (등장 직전 · MonsterList 행) + ChaseRangeApplied = True + before → after = 50.0 → 20.0 (SO bossChaseRange=20) + 테이블 행 실측 = 20.0 (MobActor.cs:92 Check_Battle · :356 Check_Patrol 이 이 값을 읽는다) + 재진입 HoldSpawn() = False (false = 원본 스폰 코드 진행 · 이중 스폰 없음) + 재진입 후 resume 호출 = 1 (증가하지 않아야 한다) + +── ③ 게이트 B — 누적 처치 ≥ requiredKills (리젠형 존에서 전멸이 안 잡힐 때) + ▶ resume() 호출 = 원본 FieldBossData.Start 재진입(보스 활성) #1 + 리젠 흉내 (존 813001 처치↔리젠 반복 · 어떤 존도 전멸하지 않음) + 게이트 존 alive = 813004, 813005 → 8, 5 + 누적 처치 / 임계 = 54 / 54 + GateOpen / resume = True / 1 + 반복 횟수(안전 상한 500) = 54 + LastLog = 보스 등장 개시 — released=1 + +── ⑤ 리쉬 판정표 (EvaluateLeash · 순수 함수 · Actor 접근 0) + 중심 (139.0, 0.8, -20.0) · 반경 20 · leashMargin 2 · playerLeaveMargin 4 · grace 1.5s + 보스 거리 PC 거리 경과(s) 판정 + 0.0m - 0.0 유지 + 19.0m - 0.0 유지 + 23.0m - 0.0 리셋 (보스 이탈) + 0.0m 25.0m 0.0 유지 + 0.0m 25.0m 1.6 리셋 (PC 이탈) + 0.0m 23.0m 0.0 유지 + ※ 실제 리셋 실행(Del_Target · On_Regen(reincarnation:true) · Set_Warp)은 살아 있는 MobActor 가 필요해 + 에디트 모드에서 측정 불가 = 「미확인」. Boss 참조 없음 → LeashTick() 는 무동작: + LeashTick() ResetCount 0 → 0 + +── ⑥ 보스 처치 → 정리 + ▶ resume() 호출 = 원본 FieldBossData.Start 재진입(보스 활성) #1 + 강제 개방 후 resume = 1 · 반경 50.0 → 20.0 + 보스 Spawned(isBoss) → BossSpawned=True (811d 보스 캠 · 813c HP 바 · 813t 배너가 구독하는 그 이벤트) + 보스 Killed(Boss) → BossKilled=True · CleanupDone=True (에디트 모드라 cleanupDelaySeconds 무시 · 즉시 실행) + 반경 원복 후 테이블 값 = 50.0 restoreChaseRangeOnCleanup=True + ChaseRangeApplied = False + LastLog = 정리 완료 — 리젠 없음 + ※ 리젠 차단은 FieldBossData.StopAllCoroutines() — 코루틴은 Play 에서만 돌아 「미확인」. + 가짜 FieldBossData 생존 = True (정리가 오브젝트를 파괴하지 않는다) + +── ⑦ C8 롤백 — 어느 스위치든 끄면 813e 상시 배치 100% + WLBossArenaSettings.RuntimeDisabled=true → HoldSpawn=False · Active=False · PassThrough=1 + WLCombatCoreSettings.RuntimeDisabled=true → HoldSpawn=False · 코어 Enabled=False · 게이트 Active=False + 다른 FieldBossData(spawnerId 999999) → HoldSpawn=False (false = 원본 즉시 배치 그대로) + gateEnabled=false / enabled=false / 에셋 삭제도 같은 경로(HoldSpawn 첫 3줄) — 에셋을 건드리지 않아 미실행 + resume 호출 총합 = 0 (C8 구간에서 0 이어야 한다) + +===== 끝 ===== diff --git a/AgentScripts/WL813d_Probe.cs b/AgentScripts/WL813d_Probe.cs new file mode 100644 index 000000000..bbaebc5d4 --- /dev/null +++ b/AgentScripts/WL813d_Probe.cs @@ -0,0 +1,433 @@ +// PD 지시 #813 · 발주서 WL-813d §2 ⓒ — 보스전 게이트 · 아레나 에디트 모드 프로브 +// +// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기 · 첫 Roslyn 컴파일은 --timeout 60) +// unity command run_script --file AgentScripts/WL813d_Probe.cs --entry WL813d_Probe.Run --timeout 300 +// +// 무엇을 확인하나 (발주서 §2 ⓒ ⓓ) +// ① 설정 에셋 로드 · Active · 값 전수 덤프 +// ② 게이트 A — 가짜 Spawned/Killed 로 존 카운트 → 게이트 존(813004·813005) 전멸 → resume() 호출(= 보스 활성) +// ③ 게이트 B — 누적 처치 ≥ requiredKills 경로(리젠형 존에서 전멸이 안 잡힐 때) +// ④ 추적 반경 데이터화 — MonsterList 10006 행(실 JSON 값)에 SO 값이 실제로 쓰이는지(원 코드 경로) +// ⑤ 리쉬 판정표 — 보스 이탈 · PC 이탈(유예 포함) 가짜 좌표 +// ⑥ 보스 처치 → 정리 — RegenTime 코루틴 차단 · 반경 원복 +// ⑦ C8 — RuntimeDisabled / 코어 off 면 HoldSpawn 이 false(= 813e 상시 배치 100%) +// +// Play 하지 않는다. 프리팹·씬·에셋을 저장하지 않는다(만든 GameObject 는 DestroyImmediate 로 되돌린다). +// 수치는 전부 .asset / MonsterList.json / MonsterAppear.json 에서 읽는다(C45). + +public static class WL813d_Probe +{ + const string kLog = "AgentScripts/WL813d_PROBE.txt"; + const string kMonsterList = "Assets/ResWork/Table/Export/MonsterList.json"; + const string kMonsterAppear = "Assets/ResWork/Table/Export/MonsterAppear.json"; + + static System.Text.StringBuilder s; + static int s_resumeCount; + static UnityEngine.GameObject s_fbdGo, s_tableGo; + static FieldBossData s_fbd; + + public static string Run() + { + s = new System.Text.StringBuilder(); + s_resumeCount = 0; + + L("===== WL-813d 보스전 게이트 · 아레나 프로브 ====="); + L("playMode=" + UnityEngine.Application.isPlaying + " time=" + System.DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")); + + WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; + WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = false; + WL.Combat.Boss.WLBossArenaSettings.ClearCache(); + WL.Combat.Boss.BossArena.ResetAll(); + + try + { + Setup(); + Section1_Settings(); + Section2_GateZoneCleared(); + Section3_GateKillCount(); + Section5_Leash(); + Section6_Cleanup(); + Section7_Rollback(); + } + catch (System.Exception ex) { L("!! 예외: " + ex); } + finally { Teardown(); } + + L(""); + L("===== 끝 ====="); + return Flush(); + } + + // ───────────────────────────────────────── 준비 + static void Setup() + { + // 가짜 FieldBossData — 실제 훅과 같은 계약으로 HoldSpawn 을 부른다(원본 Start 는 부르지 않는다). + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + s_fbdGo = new UnityEngine.GameObject("WL813d_FakeFieldBoss"); + s_fbdGo.transform.position = cfg != null ? cfg.arenaCenter : UnityEngine.Vector3.zero; + s_fbd = s_fbdGo.AddComponent(); + s_fbd.isChapterBoss = false; + s_fbd.n_MonsterID = cfg != null ? cfg.bossMonsterId : 10006; + s_fbd.n_SpawnerId = cfg != null ? cfg.bossSpawnerId : 813010; + s_fbd.RegenTime = 60f; + + // 가짜 MonsterList 싱글턴 — 실 JSON 의 10006 행 값을 그대로 넣어 「원 코드 경로」로 반경 적용을 측정한다. + // (에디트 모드에는 table_monsterlist 인스턴스가 없다. Ins 는 public static, dic_Data 만 private 이라 리플렉션 1회.) + float chaseFromJson = ReadFloatField(kMonsterList, "n_MonsterID", s_fbd.n_MonsterID.ToString(), "f_BaseChaseRange", -1f); + var row = new MonsterTableData(); + row.n_MonsterID = s_fbd.n_MonsterID; + row.f_BaseChaseRange = chaseFromJson; + var dic = new System.Collections.Generic.Dictionary(); + dic.Add(row.n_MonsterID, row); + + s_tableGo = new UnityEngine.GameObject("WL813d_FakeMonsterList"); + var tbl = s_tableGo.AddComponent(); + var fi = typeof(table_monsterlist).GetField("dic_Data", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + if (fi != null) fi.SetValue(tbl, dic); + table_monsterlist.Ins = tbl; + + L(""); + L("── ⓪ 준비"); + L(" 가짜 FieldBossData = spawnerId " + s_fbd.n_SpawnerId + " · monsterId " + s_fbd.n_MonsterID + + " · pos " + s_fbdGo.transform.position.ToString("F1") + " · RegenTime " + s_fbd.RegenTime); + L(" MonsterList 10006 행 f_BaseChaseRange (실 JSON) = " + chaseFromJson.ToString("F1") + + " dic_Data 주입 = " + (fi != null)); + } + + static void Teardown() + { + WL.Combat.Boss.BossArena.Unsubscribe(); + WL.Combat.Boss.BossArena.ResetAll(); + WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = false; + WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; + table_monsterlist.Ins = null; + if (s_fbdGo != null) UnityEngine.Object.DestroyImmediate(s_fbdGo); + if (s_tableGo != null) UnityEngine.Object.DestroyImmediate(s_tableGo); + } + + // ───────────────────────────────────────── ① 설정 + static void Section1_Settings() + { + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + L(""); + L("── ① 설정 에셋"); + L(" Instance = " + (cfg != null ? "OK" : "null (Resources/WL/WLBossArenaSettings 없음)")); + L(" 코어 Enabled = " + WL.Combat.Core.WLCombatCoreSettings.Enabled); + L(" 게이트 Active = " + WL.Combat.Boss.WLBossArenaSettings.Active); + if (cfg == null) return; + L(" enabled/verboseLog = " + cfg.enabled + " / " + cfg.verboseLog); + L(" bossSpawnerId/Monster = " + cfg.bossSpawnerId + " / " + cfg.bossMonsterId); + L(" gateEnabled/mode = " + cfg.gateEnabled + " / " + cfg.gateMode); + L(" zoneSpawnerIds = " + Join(cfg.zoneSpawnerIds)); + L(" gateZoneSpawnerIds = " + Join(cfg.gateZoneSpawnerIds)); + L(" requiredKills = " + cfg.requiredKills + " requireZoneSpawnedOnce=" + cfg.requireZoneSpawnedOnce); + L(" gateOpenDelaySeconds = " + cfg.gateOpenDelaySeconds); + L(" arena center/radius = " + (cfg.useFieldBossPositionAsCenter ? "FieldBossData 좌표" : cfg.arenaCenter.ToString("F1")) + + " / " + cfg.arenaRadius); + L(" bossChaseRange = " + cfg.bossChaseRange + " restoreOnCleanup=" + cfg.restoreChaseRangeOnCleanup); + L(" leash margin/PC margin= " + cfg.leashMargin + " / " + cfg.playerLeaveMargin + + " grace=" + cfg.playerLeaveGraceSeconds + "s interval=" + cfg.leashCheckIntervalSeconds + "s"); + L(" resetHeals/rate = " + cfg.resetHealsBoss + " / " + cfg.resetHealRate); + L(" cleanup delay/regen = " + cfg.cleanupDelaySeconds + "s / suppressRegen=" + cfg.suppressRegenAfterBossKill); + L(" deactivateZones/rearm = " + cfg.deactivateZonesAfterBossKill + " / " + cfg.rearmGateAfterBossKill); + } + + // ───────────────────────────────────────── ② 게이트 A (존 전멸) + static void Section2_GateZoneCleared() + { + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + L(""); + L("── ② 게이트 A — 게이트 존 전멸 → 보스 등장"); + if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } + + WL.Combat.Boss.BossArena.ResetAll(); + s_resumeCount = 0; + + int killedBefore = WL.Combat.Core.CombatEvents.Killed.Count; + int spawnedBefore = WL.Combat.Core.CombatEvents.Spawned.Count; + + bool held = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); + L(" HoldSpawn() = " + held + " (true = 원본 Start 가 return · 보스 미등장)"); + L(" GateArmed/Subscribed = " + WL.Combat.Boss.BossArena.GateArmed + " / " + WL.Combat.Boss.BossArena.Subscribed); + L(" CombatEvents 구독 수 = Killed " + killedBefore + "→" + WL.Combat.Core.CombatEvents.Killed.Count + + " · Spawned " + spawnedBefore + "→" + WL.Combat.Core.CombatEvents.Spawned.Count); + L(" resume 호출 수 = " + s_resumeCount + " (게이트가 닫혀 있으므로 0 이어야 한다)"); + L(" 반경 적용 여부 = " + WL.Combat.Boss.BossArena.ChaseRangeApplied + " (등장 직전에만 적용)"); + + // 존 스폰 — 마릿수는 MonsterAppear.json 의 n_MaxMonsterCount 실값 + L(""); + L(" [스폰] 존별 n_MaxMonsterCount 만큼 가짜 Spawned"); + for (int i = 0; i < cfg.zoneSpawnerIds.Length; i++) + { + int id = cfg.zoneSpawnerIds[i]; + int n = (int)ReadFloatField(kMonsterAppear, "n_SpawnerID", id.ToString(), "n_MaxMonsterCount", 0f); + for (int k = 0; k < n; k++) WL.Combat.Boss.BossArena.NoteSpawn(id, false); + L(" 존 " + id + " : 스폰 " + n + " → alive " + WL.Combat.Boss.BossArena.AliveOf(id)); + } + L(" GateOpen = " + WL.Combat.Boss.BossArena.GateOpen + " (아직 닫혀 있어야 한다)"); + + // 게이트 존만 전멸시킨다(비게이트 존은 살려 둔다 → ZoneCleared 경로만 검증) + L(""); + L(" [처치] 게이트 존만 전멸 — 비게이트 존은 살려 둔다(KillCount 경로와 분리)"); + for (int i = 0; i < cfg.gateZoneSpawnerIds.Length; i++) + { + int id = cfg.gateZoneSpawnerIds[i]; + int alive = WL.Combat.Boss.BossArena.AliveOf(id); + for (int k = 0; k < alive; k++) + { + WL.Combat.Boss.BossArena.NoteKill(id, false); + if (k == alive - 1) + L(" 존 " + id + " 마지막 처치 → alive " + WL.Combat.Boss.BossArena.AliveOf(id) + + " · 누적처치 " + WL.Combat.Boss.BossArena.TotalZoneKills + "/" + cfg.requiredKills + + " · GateOpen " + WL.Combat.Boss.BossArena.GateOpen + " · resume " + s_resumeCount); + else if (k == 0) + L(" 존 " + id + " 첫 처치 → alive " + WL.Combat.Boss.BossArena.AliveOf(id) + + " · GateOpen " + WL.Combat.Boss.BossArena.GateOpen); + } + } + + L(""); + L(" 결과 GateOpen = " + WL.Combat.Boss.BossArena.GateOpen); + L(" 결과 resume 호출 수 = " + s_resumeCount + " (1 = 원본 FieldBossData.Start 재진입 = 보스 활성)"); + L(" ReleaseCount = " + WL.Combat.Boss.BossArena.ReleaseCount); + L(" 누적 처치 = " + WL.Combat.Boss.BossArena.TotalZoneKills + " / requiredKills " + cfg.requiredKills + + " → KillCount 조건은 아직 미충족(= ZoneCleared 단독 개방)"); + L(" LastLog = " + WL.Combat.Boss.BossArena.LastLog); + + // ④ 추적 반경 — Release 안에서 원 코드 경로로 적용된다 + L(""); + L("── ④ 추적 반경 데이터화 (등장 직전 · MonsterList 행)"); + L(" ChaseRangeApplied = " + WL.Combat.Boss.BossArena.ChaseRangeApplied); + L(" before → after = " + WL.Combat.Boss.BossArena.ChaseRangeBefore.ToString("F1") + " → " + + WL.Combat.Boss.BossArena.ChaseRangeAfter.ToString("F1") + " (SO bossChaseRange=" + cfg.bossChaseRange + ")"); + var live = table_monsterlist.Ins != null ? table_monsterlist.Ins.Get_Data_orNull(cfg.bossMonsterId) : null; + L(" 테이블 행 실측 = " + (live != null ? live.f_BaseChaseRange.ToString("F1") : "행 없음") + + " (MobActor.cs:92 Check_Battle · :356 Check_Patrol 이 이 값을 읽는다)"); + + // 두 번째 진입(resume → Start 재진입)에서 통과하는지 + bool second = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); + L(" 재진입 HoldSpawn() = " + second + " (false = 원본 스폰 코드 진행 · 이중 스폰 없음)"); + L(" 재진입 후 resume 호출 = " + s_resumeCount + " (증가하지 않아야 한다)"); + } + + // ───────────────────────────────────────── ③ 게이트 B (누적 처치) + static void Section3_GateKillCount() + { + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + L(""); + L("── ③ 게이트 B — 누적 처치 ≥ requiredKills (리젠형 존에서 전멸이 안 잡힐 때)"); + if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } + + WL.Combat.Boss.BossArena.ResetAll(); + s_resumeCount = 0; + WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); + + // 모든 존을 스폰시켜 두되, 게이트 존은 계속 리젠되는 상황을 흉내낸다(처치할 때마다 다시 스폰). + int zone0 = cfg.zoneSpawnerIds.Length > 0 ? cfg.zoneSpawnerIds[0] : 813001; + for (int i = 0; i < cfg.zoneSpawnerIds.Length; i++) + { + int id = cfg.zoneSpawnerIds[i]; + int n = (int)ReadFloatField(kMonsterAppear, "n_SpawnerID", id.ToString(), "n_MaxMonsterCount", 0f); + for (int k = 0; k < n; k++) WL.Combat.Boss.BossArena.NoteSpawn(id, false); + } + + int guard = 0; + while (!WL.Combat.Boss.BossArena.GateOpen && guard++ < 500) + { + WL.Combat.Boss.BossArena.NoteKill(zone0, false); + WL.Combat.Boss.BossArena.NoteSpawn(zone0, false); // 리젠 — alive 가 0 이 되지 않는다 + } + + L(" 리젠 흉내 (존 " + zone0 + " 처치↔리젠 반복 · 어떤 존도 전멸하지 않음)"); + L(" 게이트 존 alive = " + Join(cfg.gateZoneSpawnerIds) + " → " + AliveList(cfg.gateZoneSpawnerIds)); + L(" 누적 처치 / 임계 = " + WL.Combat.Boss.BossArena.TotalZoneKills + " / " + cfg.requiredKills); + L(" GateOpen / resume = " + WL.Combat.Boss.BossArena.GateOpen + " / " + s_resumeCount); + L(" 반복 횟수(안전 상한 500) = " + guard); + L(" LastLog = " + WL.Combat.Boss.BossArena.LastLog); + } + + // ───────────────────────────────────────── ⑤ 리쉬 + static void Section5_Leash() + { + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + L(""); + L("── ⑤ 리쉬 판정표 (EvaluateLeash · 순수 함수 · Actor 접근 0)"); + if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } + + var c = cfg.arenaCenter; + float r = cfg.arenaRadius; + L(" 중심 " + c.ToString("F1") + " · 반경 " + r + " · leashMargin " + cfg.leashMargin + + " · playerLeaveMargin " + cfg.playerLeaveMargin + " · grace " + cfg.playerLeaveGraceSeconds + "s"); + L(" " + Pad("보스 거리", 12) + Pad("PC 거리", 10) + Pad("경과(s)", 9) + "판정"); + + LeashCase(c, r, 0f, 0f, 0f); // 둘 다 안 + LeashCase(c, r, r - 1f, 0f, 0f); // 보스 경계 안 + LeashCase(c, r, r + cfg.leashMargin + 1f, 0f, 0f); // 보스 이탈 + LeashCase(c, r, 0f, r + cfg.playerLeaveMargin + 1f, 0f); // PC 이탈 · 유예 전 + LeashCase(c, r, 0f, r + cfg.playerLeaveMargin + 1f, cfg.playerLeaveGraceSeconds + 0.1f); // PC 이탈 · 유예 후 + WL.Combat.Boss.BossArena.ResetLeashState(); + LeashCase(c, r, 0f, r + cfg.playerLeaveMargin - 1f, 0f); // PC 여유 안 (이탈 아님) + + L(" ※ 실제 리셋 실행(Del_Target · On_Regen(reincarnation:true) · Set_Warp)은 살아 있는 MobActor 가 필요해"); + L(" 에디트 모드에서 측정 불가 = 「미확인」. Boss 참조 없음 → LeashTick() 는 무동작:"); + WL.Combat.Boss.BossArena.SetBossForProbe(null); + int before = WL.Combat.Boss.BossArena.ResetCount; + WL.Combat.Boss.BossArena.LeashTick(); + L(" LeashTick() ResetCount " + before + " → " + WL.Combat.Boss.BossArena.ResetCount); + } + + static void LeashCase(UnityEngine.Vector3 c, float r, float bossDist, float pcDist, float elapsed) + { + WL.Combat.Boss.BossArena.ResetLeashState(); + var bossPos = c + new UnityEngine.Vector3(bossDist, 0f, 0f); + var pcPos = c + new UnityEngine.Vector3(pcDist, 0f, 0f); + bool hasPc = pcDist > 0f; + int v = WL.Combat.Boss.BossArena.EvaluateLeash(bossPos, pcPos, hasPc, c, 0f); + if (v == WL.Combat.Boss.BossArena.kLeashNone && elapsed > 0f) + v = WL.Combat.Boss.BossArena.EvaluateLeash(bossPos, pcPos, hasPc, c, elapsed); // 유예 경과 후 재판정 + L(" " + Pad(bossDist.ToString("F1") + "m", 12) + Pad(hasPc ? pcDist.ToString("F1") + "m" : "-", 10) + + Pad(elapsed.ToString("F1"), 9) + Verdict(v)); + } + + static string Verdict(int v) + { + if (v == WL.Combat.Boss.BossArena.kLeashPlayerOut) return "리셋 (PC 이탈)"; + if (v == WL.Combat.Boss.BossArena.kLeashBossOut) return "리셋 (보스 이탈)"; + return "유지"; + } + + // ───────────────────────────────────────── ⑥ 처치 후 정리 + static void Section6_Cleanup() + { + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + L(""); + L("── ⑥ 보스 처치 → 정리"); + if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } + + WL.Combat.Boss.BossArena.ResetAll(); + s_resumeCount = 0; + WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); + WL.Combat.Boss.BossArena.ForceOpenGate(); + L(" 강제 개방 후 resume = " + s_resumeCount + " · 반경 " + + WL.Combat.Boss.BossArena.ChaseRangeBefore.ToString("F1") + " → " + + WL.Combat.Boss.BossArena.ChaseRangeAfter.ToString("F1")); + + WL.Combat.Boss.BossArena.NoteSpawn(cfg.bossSpawnerId, true); + L(" 보스 Spawned(isBoss) → BossSpawned=" + WL.Combat.Boss.BossArena.BossSpawned + + " (811d 보스 캠 · 813c HP 바 · 813t 배너가 구독하는 그 이벤트)"); + + WL.Combat.Boss.BossArena.NoteKill(cfg.bossSpawnerId, true); + L(" 보스 Killed(Boss) → BossKilled=" + WL.Combat.Boss.BossArena.BossKilled + + " · CleanupDone=" + WL.Combat.Boss.BossArena.CleanupDone + + " (에디트 모드라 cleanupDelaySeconds 무시 · 즉시 실행)"); + var live = table_monsterlist.Ins != null ? table_monsterlist.Ins.Get_Data_orNull(cfg.bossMonsterId) : null; + L(" 반경 원복 후 테이블 값 = " + (live != null ? live.f_BaseChaseRange.ToString("F1") : "행 없음") + + " restoreChaseRangeOnCleanup=" + cfg.restoreChaseRangeOnCleanup); + L(" ChaseRangeApplied = " + WL.Combat.Boss.BossArena.ChaseRangeApplied); + L(" LastLog = " + WL.Combat.Boss.BossArena.LastLog); + L(" ※ 리젠 차단은 FieldBossData.StopAllCoroutines() — 코루틴은 Play 에서만 돌아 「미확인」."); + L(" 가짜 FieldBossData 생존 = " + (s_fbd != null) + " (정리가 오브젝트를 파괴하지 않는다)"); + } + + // ───────────────────────────────────────── ⑦ C8 + static void Section7_Rollback() + { + var cfg = WL.Combat.Boss.WLBossArenaSettings.Instance; + L(""); + L("── ⑦ C8 롤백 — 어느 스위치든 끄면 813e 상시 배치 100%"); + if (cfg == null) { L(" 설정 없음 — 건너뜀"); return; } + + // ⑦-1 게이트 SO 런타임 스위치 + WL.Combat.Boss.BossArena.ResetAll(); + s_resumeCount = 0; + WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = true; + bool a = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); + L(" WLBossArenaSettings.RuntimeDisabled=true → HoldSpawn=" + a + + " · Active=" + WL.Combat.Boss.WLBossArenaSettings.Active + + " · PassThrough=" + WL.Combat.Boss.BossArena.PassThroughCount); + WL.Combat.Boss.WLBossArenaSettings.RuntimeDisabled = false; + + // ⑦-2 코어 off — 이벤트가 0 이라 게이트를 걸면 보스가 영영 안 나온다 → 걸지 않는다 + WL.Combat.Boss.BossArena.ResetAll(); + WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = true; + bool b = WL.Combat.Boss.BossArena.HoldSpawn(s_fbd, FakeResume); + L(" WLCombatCoreSettings.RuntimeDisabled=true → HoldSpawn=" + b + + " · 코어 Enabled=" + WL.Combat.Core.WLCombatCoreSettings.Enabled + + " · 게이트 Active=" + WL.Combat.Boss.WLBossArenaSettings.Active); + WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; + + // ⑦-3 다른 FieldBossData(다른 spawnerId)는 손대지 않는다 + WL.Combat.Boss.BossArena.ResetAll(); + var otherGo = new UnityEngine.GameObject("WL813d_OtherFieldBoss"); + var other = otherGo.AddComponent(); + other.n_MonsterID = 10001; other.n_SpawnerId = 999999; + bool c = WL.Combat.Boss.BossArena.HoldSpawn(other, FakeResume); + L(" 다른 FieldBossData(spawnerId 999999) → HoldSpawn=" + c + " (false = 원본 즉시 배치 그대로)"); + UnityEngine.Object.DestroyImmediate(otherGo); + + // ⑦-4 gateEnabled=false 는 에셋 값이라 여기서 바꾸지 않는다(에셋 무변경) — 코드 경로만 명시 + L(" gateEnabled=false / enabled=false / 에셋 삭제도 같은 경로(HoldSpawn 첫 3줄) — 에셋을 건드리지 않아 미실행"); + L(" resume 호출 총합 = " + s_resumeCount + " (C8 구간에서 0 이어야 한다)"); + } + + // ───────────────────────────────────────── 유틸 + static void FakeResume() + { + s_resumeCount++; + L(" ▶ resume() 호출 = 원본 FieldBossData.Start 재진입(보스 활성) #" + s_resumeCount); + } + + /// Export 테이블(1줄 minified · 값은 문자열)에서 keyField=keyValue 인 행의 field 를 float 로 읽는다. + static float ReadFloatField(string path, string keyField, string keyValue, string field, float fallback) + { + try + { + string txt = System.IO.File.ReadAllText(path); + string anchor = "\"" + keyField + "\": \"" + keyValue + "\""; + int at = txt.IndexOf(anchor, System.StringComparison.Ordinal); + if (at < 0) return fallback; + int end = txt.IndexOf('}', at); + if (end < 0) end = txt.Length; + string row = txt.Substring(at, end - at); + var m = System.Text.RegularExpressions.Regex.Match(row, "\"" + field + "\"\\s*:\\s*\"([^\"]*)\""); + if (!m.Success) return fallback; + float v; + return float.TryParse(m.Groups[1].Value, out v) ? v : fallback; + } + catch (System.Exception) { return fallback; } + } + + static string AliveList(int[] ids) + { + if (ids == null) return ""; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < ids.Length; i++) + { + if (i > 0) sb.Append(", "); + sb.Append(WL.Combat.Boss.BossArena.AliveOf(ids[i])); + } + return sb.ToString(); + } + + static string Join(int[] a) + { + if (a == null) return "(null)"; + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < a.Length; i++) { if (i > 0) sb.Append(", "); sb.Append(a[i]); } + return sb.ToString(); + } + + static string Pad(string t, int n) { return t.Length >= n ? t + " " : t + new string(' ', n - t.Length); } + + static void L(string line) { s.AppendLine(line); } + + static string Flush() + { + try + { + System.IO.File.WriteAllText(kLog, s.ToString(), new System.Text.UTF8Encoding(true)); + } + catch (System.Exception ex) { s.AppendLine("!! 로그 저장 실패: " + ex.Message); } + return s.ToString(); + } +} diff --git a/Assets/Script/Map/FieldBossData.cs b/Assets/Script/Map/FieldBossData.cs index 037210c29..056aa06e5 100644 --- a/Assets/Script/Map/FieldBossData.cs +++ b/Assets/Script/Map/FieldBossData.cs @@ -11,6 +11,7 @@ public class FieldBossData : MyCoroutine private void Start() { + if (WL.Combat.Boss.BossArena.HoldSpawn(this, Start)) return; // #813d 존 전멸 게이트 — 열릴 때 이 Start 를 다시 부른다(꺼져 있으면 즉시 false = 원본 100%) // 필드 몬스터 생성 var fieldbossdata = table_monsterlist.Ins.Get_Data_orNull(n_MonsterID); AddrResourceMgr.Ins.LoadObject(fieldbossdata.s_MonsterPrefab, handle => diff --git a/Assets/WL/Combat/Boss/BossArena.cs b/Assets/WL/Combat/Boss/BossArena.cs new file mode 100644 index 000000000..becacb301 --- /dev/null +++ b/Assets/WL/Combat/Boss/BossArena.cs @@ -0,0 +1,594 @@ +// ───────────────────────────────────────────────────────────────────────────── +// BossArena.cs — 존 전멸 → 보스 등장 게이트 · 아레나 경계 · 처치 후 정리 +// +// PD 지시 #813 · 발주서 WL-813d §1 (2026-09-09) +// 규격 = 813e 완료보고(아레나 (139,0.8,−20) r20 · FieldBossData 상시 배치 · 존 6행) +// + 813h 완료보고(BossEvents.BossPhase · BossPatternTable) + 813b §6-7/6-8 +// +// ■ 무엇을 하나 +// ① 게이트 — 813e 존 5개(813001~813005)의 생존/처치를 CombatEvents.Killed/Spawned 로 세고, +// 게이트 존(기본 813004·813005) 전멸 또는 누적 처치 ≥ N 이면 보스를 등장시킨다. +// ② 아레나 — 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 낮추고(코드 상수 0), +// 보스가 아레나 밖으로 나가거나 PC 가 아레나를 떠나면 중심으로 복귀(리셋)시킨다. +// ③ 정리 — 보스 처치 후 FieldBossData 의 RegenTime 재등장 코루틴을 끊는다(리젠 없음 · SO). +// +// ■ 「보스를 나중에 등장시키는」 방법 — 원본 스포너 무수정 (발주서 §2) +// FieldBossData.Start() 가 유일한 스폰 경로다(813e 는 코드 0 으로 배치했다). +// Unity 의 Start() 는 컴포넌트당 1회뿐이라 SetActive 토글로는 다시 부를 수 없고, +// 스폰 코드를 이 파일에 복제하면 원본과 두 벌이 된다. +// → 훅 1줄이 자기 자신(Start)을 System.Action 으로 넘긴다. 게이트가 닫혀 있으면 true 를 돌려 +// 원본이 그 자리에서 return 하고, 게이트가 열릴 때 그 델리게이트를 다시 부른다. +// 두 번째 진입에서는 HoldSpawn 이 false 를 주므로 원본 스폰 코드가 그대로 실행된다. +// → 스폰 로직 복제 0 · AddrResourceMgr/ActorInfo 등록 경로 동일 · On_Regen 도 원본이 부른다 +// (= Spawned(isBoss) 가 「등장 시점」에 제대로 난다 → 811d 보스 캠 · 813c HP 바 · 813t 배너). +// +// ■ C8 롤백 — WLBossArenaSettings.Active 가 false 면 HoldSpawn 이 즉시 false. +// 에셋 없음 / enabled=false / RuntimeDisabled / 코어 off 중 하나면 813e 의 상시 배치 100%. +// 🔴 코어(WLCombatCoreSettings)가 꺼지면 Killed/Spawned 가 0 이라 게이트가 영영 안 열린다. +// 그래서 Active 가 코어 상태까지 포함한다 — 코어를 끄면 게이트도 안 걸린다(보스가 사라지지 않는다). +// +// ■ 값(C45) — 게임 수치 상수 0. 전부 Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset. +// 추적 반경은 MonsterList 행(table_monsterlist)의 f_BaseChaseRange 에 「등장 직전」 대입한다. +// MobActor.Set(mobtable,…) 이 그 값을 복사(MobActor.cs:92 · Check_Battle)하고, +// Check_Patrol(MobActor.cs:356)은 테이블 값을 직접 읽으므로 어그로·귀환 양쪽에 같은 반경이 걸린다. +// MonsterList.json(Systems 소유 · xlsm SOT)을 고치지 않고 값만 데이터화하는 경로다. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// ───────────────────────────────────────────────────────────────────────────── + +using System.Collections.Generic; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Boss +{ + /// 보스전 게이트·아레나. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개). + public static class BossArena + { + // ───────────────────────────────────────── 내부 상태 + sealed class Held + { + public FieldBossData fbd; + public System.Action resume; + public Vector3 center; + public bool released; + } + + sealed class Zone { public int alive, kills, spawns; } + + static readonly List s_held = new List(2); + static readonly Dictionary s_zones = new Dictionary(8); + static readonly List s_gateZones = new List(4); + + static bool s_subscribed, s_armed, s_gateOpen, s_leashFaulted; + static bool s_chaseApplied; + static float s_chaseBefore, s_chaseAfter; + static MobActor s_boss; + static float s_openDueTime = -1f, s_cleanupDueTime = -1f; + static float s_playerOutsideSince = -1f, s_nextLeashCheck; + + // ───────────────────────────────────────── 진단(프로브·QA 가 읽는다) + public static bool GateArmed { get { return s_armed; } } + public static bool GateOpen { get { return s_gateOpen; } } + public static bool BossSpawned, BossKilled, CleanupDone; + public static int TotalZoneKills, ResetCount, ReleaseCount, HoldCount, PassThroughCount; + public static string LastLog = ""; + public static bool ChaseRangeApplied { get { return s_chaseApplied; } } + public static float ChaseRangeBefore { get { return s_chaseBefore; } } + public static float ChaseRangeAfter { get { return s_chaseAfter; } } + /// 구독 여부 — 게이트가 실제로 CombatEvents 에 붙었는지 확인용. + public static bool Subscribed { get { return s_subscribed; } } + /// 등장한 보스(리쉬 대상). 미등장이면 null. + public static MobActor Boss { get { return s_boss; } } + + public static int AliveOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.alive : 0; } + public static int KillsOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.kills : 0; } + public static int SpawnsOf(int spawnerId) { Zone z; return s_zones.TryGetValue(spawnerId, out z) ? z.spawns : 0; } + + static WLBossArenaSettings Cfg { get { return WLBossArenaSettings.Instance; } } + static bool Active { get { return WLBossArenaSettings.Active; } } + + // ───────────────────────────────────────────────────────────────────── + // 훅 (Assets/Script/Map/FieldBossData.cs Start 첫 줄 · 1줄) + // ───────────────────────────────────────────────────────────────────── + + /// + /// 이 FieldBossData 의 스폰을 붙잡을지 판정한다. + /// true = 원본 Start 가 그 자리에서 return(보스 미등장) · 게이트가 열릴 때 resume 으로 Start 를 다시 부른다. + /// false = 원본 스폰 코드 그대로 진행(= 813e 상시 배치). + /// + public static bool HoldSpawn(FieldBossData fbd, System.Action resume) + { + if (DSUtil.CheckNull(fbd)) return false; + + var cfg = Cfg; + if (!Active || cfg == null || !cfg.gateEnabled) { PassThroughCount++; return false; } + if (cfg.bossSpawnerId != 0 && fbd.n_SpawnerId != cfg.bossSpawnerId) { PassThroughCount++; return false; } + + // 맵을 나갔다 다시 들어오면 이전 FieldBossData 가 파괴된 채 정적 상태만 남는다. + // 보류 목록이 통째로 비었는데 새 FieldBossData 가 들어오면 「새 맵 세션」으로 보고 게이트를 다시 잠근다. + for (int i = s_held.Count - 1; i >= 0; i--) + if (DSUtil.CheckNull(s_held[i].fbd)) s_held.RemoveAt(i); + if (s_armed && s_held.Count == 0) { ResetAll(); Log("새 맵 세션 — 게이트 재무장"); } + + // 두 번째 진입(= 게이트가 열려 resume 으로 다시 들어온 경우) — 원본 스폰을 통과시킨다. + for (int i = 0; i < s_held.Count; i++) + if (ReferenceEquals(s_held[i].fbd, fbd)) + { + if (s_held[i].released) { PassThroughCount++; return false; } + return true; // 아직 닫혀 있다(중복 Start 방어) + } + + Arm(cfg); + + var h = new Held + { + fbd = fbd, + resume = resume, + center = cfg.arenaEnabled && !cfg.useFieldBossPositionAsCenter ? cfg.arenaCenter : fbd.transform.position, + }; + s_held.Add(h); + HoldCount++; + EnsureRunner(); + + Log("보스 스폰 보류 — spawnerId=" + fbd.n_SpawnerId + " monsterId=" + fbd.n_MonsterID + + " center=" + h.center.ToString("F1") + " mode=" + cfg.gateMode); + + // 이미 열려 있던 게이트(재등장 시나리오)라면 붙잡지 않는다 — resume 을 부르면 이중 스폰이 된다. + if (s_gateOpen) + { + h.released = true; + h.resume = null; + ApplyChaseRange(cfg); + PassThroughCount++; + return false; + } + return true; + } + + // ───────────────────────────────────────────────────────────────────── + // 존 카운트 (CombatEvents 구독 · 프로브는 NoteSpawn/NoteKill 을 직접 부른다) + // ───────────────────────────────────────────────────────────────────── + + static void Arm(WLBossArenaSettings cfg) + { + if (s_armed) return; + s_armed = true; + s_gateOpen = false; + s_zones.Clear(); + s_gateZones.Clear(); + + if (cfg.zoneSpawnerIds != null) + for (int i = 0; i < cfg.zoneSpawnerIds.Length; i++) + if (!s_zones.ContainsKey(cfg.zoneSpawnerIds[i])) s_zones.Add(cfg.zoneSpawnerIds[i], new Zone()); + + if (cfg.gateZoneSpawnerIds != null) + for (int i = 0; i < cfg.gateZoneSpawnerIds.Length; i++) + { + int id = cfg.gateZoneSpawnerIds[i]; + if (!s_zones.ContainsKey(id)) s_zones.Add(id, new Zone()); + if (!s_gateZones.Contains(id)) s_gateZones.Add(id); + } + + EnsureSubscribed(); + } + + /// CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다). + public static void EnsureSubscribed() + { + if (s_subscribed) return; + CombatEvents.Killed.Add(OnKilled); + CombatEvents.Spawned.Add(OnSpawned); + s_subscribed = true; + } + + /// 구독 해제(프로브·롤백용). + public static void Unsubscribe() + { + if (!s_subscribed) return; + CombatEvents.Killed.Remove(OnKilled); + CombatEvents.Spawned.Remove(OnSpawned); + s_subscribed = false; + } + + static void OnKilled(in KilledEvent e) + { + int sid = DSUtil.CheckNull(e.victim) ? 0 : e.victim.Get_Group(); + NoteKill(sid, e.subRole == eSubRol.Boss); + } + + static void OnSpawned(in SpawnedEvent e) + { + int sid = DSUtil.CheckNull(e.actor) ? 0 : e.actor.Get_Group(); + if (e.isBoss && IsBossSpawner(sid)) s_boss = e.actor as MobActor; + NoteSpawn(sid, e.isBoss); + } + + static bool IsBossSpawner(int spawnerId) + { + var cfg = Cfg; + return cfg != null && (cfg.bossSpawnerId == 0 || spawnerId == cfg.bossSpawnerId); + } + + /// 스폰 1건 반영. 프로브가 가짜 스폰을 넣을 때도 이 경로를 쓴다. + public static void NoteSpawn(int spawnerId, bool isBoss) + { + if (!s_armed) return; + Zone z; + if (s_zones.TryGetValue(spawnerId, out z)) { z.alive++; z.spawns++; } + + if (isBoss && IsBossSpawner(spawnerId)) + { + BossSpawned = true; + s_playerOutsideSince = -1f; + Log("보스 등장 확인 — Spawned(isBoss) spawnerId=" + spawnerId); + } + } + + /// 처치 1건 반영 + 게이트 재판정. 프로브가 가짜 Killed 를 넣을 때도 이 경로를 쓴다. + public static void NoteKill(int spawnerId, bool isBoss) + { + if (!s_armed) return; + + Zone z; + if (s_zones.TryGetValue(spawnerId, out z)) + { + z.kills++; + if (z.alive > 0) z.alive--; + TotalZoneKills++; + } + + if (isBoss && IsBossSpawner(spawnerId)) + { + if (!BossKilled) + { + BossKilled = true; + var c0 = Cfg; + float delay = c0 != null ? c0.cleanupDelaySeconds : 0f; + bool deferred = Application.isPlaying && delay > 0f; + Log(deferred ? ("보스 처치 — 정리 예약(+" + delay.ToString("F1") + "s)") : "보스 처치 — 정리 즉시"); + if (deferred) s_cleanupDueTime = Time.unscaledTime + delay; + else Cleanup(); + } + return; + } + + if (!s_gateOpen) EvaluateGate(); + } + + // ───────────────────────────────────────────────────────────────────── + // 게이트 판정 · 개방 + // ───────────────────────────────────────────────────────────────────── + + /// 조건을 다시 보고 충족하면 게이트를 연다. 열려 있으면 아무 일도 하지 않는다. + public static void EvaluateGate() + { + var cfg = Cfg; + if (cfg == null || s_gateOpen || !s_armed) return; + + bool cleared = ZonesCleared(cfg); + bool killed = cfg.requiredKills > 0 && TotalZoneKills >= cfg.requiredKills; + + bool ok; + switch (cfg.gateMode) + { + case eBossGateMode.ZoneCleared: ok = cleared; break; + case eBossGateMode.KillCount: ok = killed; break; + case eBossGateMode.Both: ok = cleared && killed; break; + default: ok = cleared || killed; break; // Either + } + if (!ok) return; + + s_gateOpen = true; + Log("게이트 열림 — cleared=" + cleared + " kills=" + TotalZoneKills + "/" + cfg.requiredKills + + " mode=" + cfg.gateMode); + + if (Application.isPlaying && cfg.gateOpenDelaySeconds > 0f) + s_openDueTime = Time.unscaledTime + cfg.gateOpenDelaySeconds; + else + Release(cfg); + } + + static bool ZonesCleared(WLBossArenaSettings cfg) + { + if (s_gateZones.Count == 0) return false; + for (int i = 0; i < s_gateZones.Count; i++) + { + Zone z; + if (!s_zones.TryGetValue(s_gateZones[i], out z)) return false; + if (cfg.requireZoneSpawnedOnce && z.spawns <= 0) return false; + if (z.alive > 0) return false; + } + return true; + } + + /// 게이트를 강제로 연다(QA·프로브용 · 조건 무시). + public static void ForceOpenGate() + { + var cfg = Cfg; + if (cfg == null || !s_armed) return; + s_gateOpen = true; + Log("게이트 강제 개방"); + Release(cfg); + } + + /// 보류 중인 FieldBossData 를 실제로 등장시킨다(원본 Start 재진입). + static void Release(WLBossArenaSettings cfg) + { + s_openDueTime = -1f; + ApplyChaseRange(cfg); + + for (int i = 0; i < s_held.Count; i++) + { + var h = s_held[i]; + if (h.released) continue; + if (DSUtil.CheckNull(h.fbd)) { h.released = true; continue; } + + h.released = true; + ReleaseCount++; + var act = h.resume; + h.resume = null; + if (act != null) + { + try { act(); } + catch (System.Exception ex) { Debug.LogException(ex); } + } + } + Log("보스 등장 개시 — released=" + ReleaseCount); + } + + // ───────────────────────────────────────────────────────────────────── + // 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋 + // ───────────────────────────────────────────────────────────────────── + + /// + /// 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 바꾼다(코드 상수 0 · MobActor 무수정). + /// MobActor.Set(mobtable,…) 이 이 값을 읽어 복사하므로 「등장 전」에 해야 한다. + /// + static void ApplyChaseRange(WLBossArenaSettings cfg) + { + if (s_chaseApplied || !cfg.arenaEnabled || cfg.bossChaseRange <= 0f || cfg.bossMonsterId == 0) return; + var row = GetMonsterRow(cfg.bossMonsterId); + if (row == null) { Log("추적 반경 미적용 — MonsterList 행 없음(id=" + cfg.bossMonsterId + ")"); return; } + + s_chaseBefore = row.f_BaseChaseRange; + s_chaseAfter = WriteChaseRange(row, cfg.bossChaseRange); + s_chaseApplied = true; + Log("추적 반경 데이터 적용 — id=" + cfg.bossMonsterId + " " + s_chaseBefore.ToString("F1") + + " → " + s_chaseAfter.ToString("F1")); + } + + /// + /// MonsterList 행에 추적 반경을 쓰는 유일한 이음매(프로브가 같은 경로를 직접 부를 수 있다). + /// MobActor.cs:92 가 Set 시점에 이 값을 복사하고(Check_Battle), MobActor.cs:356(Check_Patrol)은 이 행을 직접 읽는다. + /// + public static float WriteChaseRange(MonsterTableData row, float value) + { + if (row == null) return 0f; + row.f_BaseChaseRange = value; + return row.f_BaseChaseRange; + } + + static void RestoreChaseRange(WLBossArenaSettings cfg) + { + if (!s_chaseApplied || !cfg.restoreChaseRangeOnCleanup) return; + var row = GetMonsterRow(cfg.bossMonsterId); + if (row != null) WriteChaseRange(row, s_chaseBefore); + s_chaseApplied = false; + Log("추적 반경 원복 — " + s_chaseBefore.ToString("F1")); + } + + static MonsterTableData GetMonsterRow(int monsterId) + { + try + { + if (DSUtil.CheckNull(table_monsterlist.Ins)) return null; + return table_monsterlist.Ins.Get_Data_orNull(monsterId); + } + catch (System.Exception) { return null; } // 행이 없으면 Get_Data_orNull 의 Dictionary 가 던진다 + } + + /// 아레나 중심(보류/등록된 FieldBossData 기준). 없으면 SO 값. + public static Vector3 Center + { + get + { + for (int i = 0; i < s_held.Count; i++) + if (!DSUtil.CheckNull(s_held[i].fbd)) return s_held[i].center; + var cfg = Cfg; + return cfg != null ? cfg.arenaCenter : 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); + } + + /// 리쉬 판정 결과 — 0 유지 · 1 PC 이탈 리셋 · 2 보스 이탈 리셋. + public const int kLeashNone = 0, kLeashPlayerOut = 1, kLeashBossOut = 2; + + /// + /// 리쉬 판정(순수 함수 · Actor 접근 0). 프로브가 가짜 좌표·시각으로 그대로 부를 수 있다. + /// hasPlayer = PC 참조가 있는가(없으면 PC 이탈 판정을 건너뛴다). + /// 유예 시간 상태(s_playerOutsideSince)만 갱신하고, 실제 리셋은 호출측(LeashTick)이 한다. + /// + public static int EvaluateLeash(Vector3 bossPos, Vector3 pcPos, bool hasPlayer, Vector3 center, float now) + { + var cfg = Cfg; + if (cfg == null || !cfg.arenaEnabled || !cfg.leashEnabled) return kLeashNone; + + if (cfg.resetWhenPlayerLeavesArena && hasPlayer) + { + float pcd = DistXZ(pcPos, center); + if (pcd > cfg.arenaRadius + cfg.playerLeaveMargin) + { + if (s_playerOutsideSince < 0f) s_playerOutsideSince = now; + if (now - s_playerOutsideSince >= cfg.playerLeaveGraceSeconds) + { + s_playerOutsideSince = -1f; + LastLeashDistance = pcd; + return kLeashPlayerOut; + } + } + else s_playerOutsideSince = -1f; + } + + float bd = DistXZ(bossPos, center); + if (bd > cfg.arenaRadius + cfg.leashMargin) { LastLeashDistance = bd; return kLeashBossOut; } + return kLeashNone; + } + + /// 마지막 리쉬 판정에서 쓴 거리(진단). + public static float LastLeashDistance; + + /// 경계 검사 1틱. 런너가 부르고, 프로브도 직접 부를 수 있다. + public static void LeashTick() + { + var cfg = Cfg; + if (cfg == null || !Active || !cfg.arenaEnabled || !cfg.leashEnabled || s_leashFaulted) return; + if (DSUtil.CheckNull(s_boss) || s_boss.IsDead()) { s_playerOutsideSince = -1f; return; } + + Vector3 center = Center; + float now = Application.isPlaying ? Time.unscaledTime : 0f; + var pc = MyValue.MyPC; + bool hasPc = !DSUtil.CheckNull(pc); + + int verdict = EvaluateLeash(s_boss.Get_position(), hasPc ? pc.Get_position() : Vector3.zero, hasPc, center, now); + if (verdict == kLeashNone) return; + + ResetBoss(cfg, center, (verdict == kLeashPlayerOut ? "PC 아레나 이탈 " : "보스 아레나 이탈 ") + + LastLeashDistance.ToString("F1") + "m"); + } + + /// 보스를 아레나 중심으로 되돌린다. 회복은 reincarnation:true 라 Spawned 가 다시 나지 않는다. + 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); + s_boss.Set_Warp(center); + ResetCount++; + Log("보스 리셋 #" + ResetCount + " — " + reason); + } + + // ───────────────────────────────────────────────────────────────────── + // 처치 후 정리 + // ───────────────────────────────────────────────────────────────────── + + /// 보스 처치 후 정리 — 재등장 코루틴 차단 · 반경 원복 · (옵션) 존 비활성. + public static void Cleanup() + { + var cfg = Cfg; + s_cleanupDueTime = -1f; + if (cfg == null || CleanupDone) return; + CleanupDone = true; + + if (cfg.suppressRegenAfterBossKill) + { + for (int i = 0; i < s_held.Count; i++) + if (!DSUtil.CheckNull(s_held[i].fbd)) + s_held[i].fbd.StopAllCoroutines(); // FieldBossData.AfterDie 의 RegenTime 코루틴을 끊는다 + } + + if (cfg.deactivateZonesAfterBossKill && cfg.zoneSpawnerIds != null) + { + var spawners = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); + for (int i = 0; i < spawners.Length; i++) + { + for (int j = 0; j < cfg.zoneSpawnerIds.Length; j++) + if (spawners[i].SpawnerID == cfg.zoneSpawnerIds[j]) + { + spawners[i].StopAllCoroutines(); + spawners[i].gameObject.SetActive(false); + break; + } + } + } + + RestoreChaseRange(cfg); + + if (cfg.rearmGateAfterBossKill) + { + s_gateOpen = false; BossKilled = false; BossSpawned = false; CleanupDone = false; + for (int i = 0; i < s_held.Count; i++) s_held[i].released = false; + Log("정리 완료 — 게이트 재무장"); + } + else Log("정리 완료 — 리젠 없음"); + } + + // ───────────────────────────────────────────────────────────────────── + // 런너 (숨김 GameObject 1개 · 플레이 모드 전용) + // ───────────────────────────────────────────────────────────────────── + + static BossArenaRunner s_runner; + + static void EnsureRunner() + { + if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; + // HideAndDontSave 는 씬 전환에도 살아남는다(DontDestroyOnLoad 불필요) · 씬·프리팹에 저장되지 않는다. + var go = new GameObject("[WL813d] BossArenaRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + s_runner = go.AddComponent(); + } + + /// 런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다). + internal static void Tick() + { + var cfg = Cfg; + if (cfg == null) return; + float now = Time.unscaledTime; + + if (s_openDueTime > 0f && now >= s_openDueTime) Release(cfg); + if (s_cleanupDueTime > 0f && now >= s_cleanupDueTime) Cleanup(); + + if (now < s_nextLeashCheck) return; + s_nextLeashCheck = now + Mathf.Max(0f, cfg.leashCheckIntervalSeconds); + try { LeashTick(); } + catch (System.Exception ex) { s_leashFaulted = true; Debug.LogException(ex); } + } + + // ───────────────────────────────────────────────────────────────────── + // 진단 · 초기화 + // ───────────────────────────────────────────────────────────────────── + + static void Log(string msg) + { + LastLog = msg; + var cfg = Cfg; + if (cfg != null && cfg.verboseLog) Debug.Log("[BossArena] " + msg); + } + + /// 프로브·재시작용: 모든 상태를 비운다(구독은 Unsubscribe 로 따로 끊는다). + public static void ResetAll() + { + var cfg = Cfg; + if (cfg != null) RestoreChaseRange(cfg); + s_held.Clear(); s_zones.Clear(); s_gateZones.Clear(); + s_armed = s_gateOpen = s_leashFaulted = s_chaseApplied = false; + s_boss = null; + s_openDueTime = s_cleanupDueTime = -1f; + s_playerOutsideSince = -1f; s_nextLeashCheck = 0f; + s_chaseBefore = s_chaseAfter = 0f; + BossSpawned = BossKilled = CleanupDone = false; + TotalZoneKills = ResetCount = ReleaseCount = HoldCount = PassThroughCount = 0; + LastLog = ""; + } + + /// 프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다). + public static void SetBossForProbe(MobActor boss) { s_boss = boss; } + + /// 프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화). + public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; } + + /// PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 −1). + public static float PlayerOutsideSince { get { return s_playerOutsideSince; } } + } + + /// BossArena 의 시간 축(개장 지연 · 정리 지연 · 리쉬 주기). 숨김 GameObject 1개. + internal sealed class BossArenaRunner : MonoBehaviour + { + void Update() { BossArena.Tick(); } + } +} diff --git a/Assets/WL/Combat/Boss/BossArena.cs.meta b/Assets/WL/Combat/Boss/BossArena.cs.meta new file mode 100644 index 000000000..613be1bdb --- /dev/null +++ b/Assets/WL/Combat/Boss/BossArena.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0022b5f4d8bbf2daac3806b7f61 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs new file mode 100644 index 000000000..973e70437 --- /dev/null +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs @@ -0,0 +1,180 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLBossArenaSettings.cs — 보스전 게이트 · 아레나 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-813d §1-4 (2026-09-09) +// +// 에셋 경로(고정): Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset +// → Resources.Load("WL/WLBossArenaSettings") 로 1회 로드 후 캐시 +// (WLCombatCoreSettings·WLLootSettings 등 기존 설정 SO 관례 그대로). +// +// ■ C8 롤백 (하나라도 false 면 BossArena 는 아무 일도 하지 않는다 = 813e 상시 배치 100%) +// ① 에셋이 없다 → Active false +// ② enabled = false → Active false +// ③ RuntimeDisabled = true → Active false (진단·A/B 전용 · 도메인 리로드로 자동 해제) +// ④ WLCombatCoreSettings.Enabled = false → Active false +// 🔴 ④ 가 반드시 필요하다: 게이트는 CombatEvents.Killed/Spawned 구독으로만 열린다. +// 코어가 꺼진 채 게이트를 걸면 이벤트가 0 이라 보스가 영원히 안 나온다. +// 그래서 "코어가 꺼지면 게이트도 안 건다"(= 맵 로드 즉시 보스 배치)로 떨어뜨린다. +// +// ■ 아래 필드 기본값은 "SOT 부재 시의 실패 처리"가 아니라 에셋 생성 시 초기값이다. +// 실제로 읽히는 값은 항상 .asset 이며, 코드에는 게임 수치 상수를 두지 않는다(C45). +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(FieldBossData/MobActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Boss +{ + /// 보스 등장 게이트 판정 방식. + public enum eBossGateMode + { + /// 게이트 존이 「동시에」 생존 0 이 되는 순간(전멸). + ZoneCleared = 0, + /// 관측 존들의 누적 처치 수가 requiredKills 이상. + KillCount = 1, + /// 둘 중 먼저 만족하는 쪽(권장 — 리젠형 존에서 전멸이 안 잡혀도 열린다). + Either = 2, + /// 둘 다 만족해야 열린다. + Both = 3, + } + + [CreateAssetMenu(fileName = "WLBossArenaSettings", menuName = "WL/Boss Arena Settings", order = 326)] + public sealed class WLBossArenaSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLBossArenaSettings"; + + static WLBossArenaSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLBossArenaSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 게이트만 끈다(도메인 리로드로 자동 false). + public static bool RuntimeDisabled; + + /// 게이트가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on. false 면 813e 상시 배치 그대로. + public static bool Active + { + get + { + var i = Instance; + return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled; + } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험). + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 보스 등장 게이트·아레나 경계·처치 후 정리가 전부 비활성 — 813e 의 「맵 로드 즉시 상시 배치」 100%. 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("게이트 판정·리셋·정리를 Console 에 남긴다(검증용 · 기본 off).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── 대상 보스 + [Header("대상 보스 (WL_Nature · Anubis)")] + [Tooltip("게이트를 걸 FieldBossData 의 n_SpawnerId. 다른 값의 FieldBossData 는 손대지 않는다(원본 즉시 배치). 0 이면 모든 FieldBossData 가 대상.")] + public int bossSpawnerId = 813010; + + [Tooltip("보스의 MonsterList n_MonsterID. 추적 반경 데이터화 대상 행이다(0 이면 반경을 건드리지 않는다).")] + public int bossMonsterId = 10006; + + // ─────────────────────────────────────────── 등장 게이트 + [Header("등장 게이트 (존 전멸 → 보스 등장)")] + [Tooltip("끄면 게이트를 걸지 않는다 — 보스는 맵 로드 즉시 등장(813e 그대로). 아레나 경계·정리만 쓰고 싶을 때.")] + public bool gateEnabled = true; + + [Tooltip("생존/처치를 세는 존 SpawnerID 전체. 누적 처치(KillCount) 판정의 모수다.")] + public int[] zoneSpawnerIds = new int[] { 813001, 813002, 813003, 813004, 813005 }; + + [Tooltip("「전멸」을 봐야 하는 존 SpawnerID. 이 존들이 동시에 생존 0 이 되면 게이트가 열린다(ZoneCleared).")] + public int[] gateZoneSpawnerIds = new int[] { 813004, 813005 }; + + [Tooltip("판정 방식. 813e 존은 리젠형(Random/Order)이라 전멸이 순간적이다 → Either 권장.")] + public eBossGateMode gateMode = eBossGateMode.Either; + + [Tooltip("KillCount 방식의 임계 — zoneSpawnerIds 누적 처치 수. 813e 존 동시 생존 합 = 12+15+14+8+5 = 54.")] + public int requiredKills = 54; + + [Tooltip("true 면 「한 번도 스폰된 적 없는 존」은 전멸로 치지 않는다(맵 로드 직후 alive 0 오판 방지).")] + public bool requireZoneSpawnedOnce = true; + + [Tooltip("조건 충족 후 실제 등장까지의 지연(초 · unscaled). 0 이면 같은 프레임에 등장. 컷인·배너를 앞세우려면 여기서 벌린다.")] + public float gateOpenDelaySeconds = 0f; + + // ─────────────────────────────────────────── 아레나 + [Header("아레나 경계 (813e 실측 중심 (139, 0.8, −20) · 반경 20)")] + [Tooltip("끄면 추적 반경 데이터화·리쉬·리셋을 하지 않는다(게이트만 쓴다).")] + public bool arenaEnabled = true; + + [Tooltip("true 면 아레나 중심 = FieldBossData 의 월드 좌표(813e 가 심은 그 자리). false 면 arenaCenter 를 쓴다.")] + public bool useFieldBossPositionAsCenter = true; + + [Tooltip("useFieldBossPositionAsCenter = false 일 때의 아레나 중심(월드).")] + public Vector3 arenaCenter = new Vector3(139f, 0.8f, -20f); + + [Tooltip("아레나 반경(m · XZ 평면). 813e 실측: 최근접 스포너까지 20.1 m.")] + public float arenaRadius = 20f; + + [Tooltip("보스 등장 직전 MonsterList 행(bossMonsterId)의 f_BaseChaseRange 에 넣을 값. 0 이하면 테이블 값 그대로(원본 50).")] + public float bossChaseRange = 20f; + + [Tooltip("보스 처치 후 정리 때 f_BaseChaseRange 를 원래 값으로 되돌린다(같은 세션에서 다른 맵이 같은 몬스터를 쓸 때 대비).")] + public bool restoreChaseRangeOnCleanup = true; + + // ─────────────────────────────────────────── 리쉬 · 리셋 + [Header("리쉬 · 리셋 (아레나 이탈)")] + [Tooltip("끄면 보스가 아레나 밖으로 나가도 되돌리지 않는다(테이블 반경만으로 제어).")] + public bool leashEnabled = true; + + [Tooltip("경계 검사 주기(초 · unscaled). 0 이하면 매 프레임.")] + public float leashCheckIntervalSeconds = 0.25f; + + [Tooltip("보스가 아레나 중심에서 (반경 + 이 값) 을 넘으면 복귀시킨다.")] + public float leashMargin = 2f; + + [Tooltip("PC 가 아레나 밖으로 나가면 보스를 리셋한다(발주서 §1-2 「SO 옵션」).")] + public bool resetWhenPlayerLeavesArena = true; + + [Tooltip("PC 이탈 판정 여유 — PC 가 (반경 + 이 값) 밖일 때만 이탈로 본다.")] + public float playerLeaveMargin = 4f; + + [Tooltip("PC 이탈이 이 시간(초 · unscaled) 이상 이어져야 리셋한다. 경계에서 왔다 갔다 할 때 리셋이 튀는 것을 막는다.")] + public float playerLeaveGraceSeconds = 1.5f; + + [Tooltip("리셋 때 보스를 회복시킨다. On_Regen(reincarnation: true) 라 Spawned 이벤트는 다시 나지 않는다(연출 재발동 방지).")] + public bool resetHealsBoss = true; + + [Tooltip("리셋 회복 비율(1 = 풀피).")] + public float resetHealRate = 1f; + + // ─────────────────────────────────────────── 처치 후 정리 + [Header("처치 후 정리")] + [Tooltip("보스 처치 후 FieldBossData 의 RegenTime 재등장 코루틴을 끊는다(발주서 §1-3 「리젠 없음」).")] + public bool suppressRegenAfterBossKill = true; + + [Tooltip("처치 → 정리까지의 지연(초 · unscaled). 킬캠(811d)·폭발 드랍(813f)·디졸브가 끝난 뒤에 손대려고 벌린다. RegenTime(60) 보다 작아야 한다.")] + public float cleanupDelaySeconds = 3f; + + [Tooltip("정리 때 아레나 존 스포너(zoneSpawnerIds)를 비활성화한다. 기본 off — 켜면 남은 잡몹이 자식째 사라진다.")] + public bool deactivateZonesAfterBossKill = false; + + [Tooltip("정리 때 게이트를 다시 잠근다(존을 다시 밀면 보스 재등장). 기본 off = 1회성.")] + public bool rearmGateAfterBossKill = false; + } +} diff --git a/Assets/WL/Combat/Boss/WLBossArenaSettings.cs.meta b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs.meta new file mode 100644 index 000000000..3c825b8bf --- /dev/null +++ b/Assets/WL/Combat/Boss/WLBossArenaSettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8d13d0011a4e4c7fae1c9b2f7d3a6e50 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset new file mode 100644 index 000000000..209dd32f6 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset @@ -0,0 +1,50 @@ +%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: 8d13d0011a4e4c7fae1c9b2f7d3a6e50, type: 3} + m_Name: WLBossArenaSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.Combat.Boss.WLBossArenaSettings + enabled: 1 + verboseLog: 0 + bossSpawnerId: 813010 + bossMonsterId: 10006 + gateEnabled: 1 + zoneSpawnerIds: + - 813001 + - 813002 + - 813003 + - 813004 + - 813005 + gateZoneSpawnerIds: + - 813004 + - 813005 + gateMode: 2 + requiredKills: 54 + requireZoneSpawnedOnce: 1 + gateOpenDelaySeconds: 0 + arenaEnabled: 1 + useFieldBossPositionAsCenter: 1 + arenaCenter: {x: 139, y: 0.8, z: -20} + arenaRadius: 20 + bossChaseRange: 20 + restoreChaseRangeOnCleanup: 1 + leashEnabled: 1 + leashCheckIntervalSeconds: 0.25 + leashMargin: 2 + resetWhenPlayerLeavesArena: 1 + playerLeaveMargin: 4 + playerLeaveGraceSeconds: 1.5 + resetHealsBoss: 1 + resetHealRate: 1 + suppressRegenAfterBossKill: 1 + cleanupDelaySeconds: 3 + deactivateZonesAfterBossKill: 0 + rearmGateAfterBossKill: 0 diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset.meta b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset.meta new file mode 100644 index 000000000..5ff1094ab --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d13d0033c6a4e9ccf3ebbd4917c8072 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: