[WL-813d] 보스전 게이트·아레나 (#813)
존 전멸 → 보스 등장 게이트 · 아레나 경계 · 추적 반경 데이터화 · 처치 후 정리. - BossArena.cs (NEW) — CombatEvents.Killed/Spawned 구독으로 813e 존 5개(813001~813005)의 생존/처치를 세고, 게이트 존(813004·813005) 전멸 또는 누적 처치 >= 54 면 보스를 등장시킨다. - 원본 스포너 무수정 — FieldBossData.Start 훅 1줄이 자기 자신을 Action 으로 넘기고, 게이트가 열릴 때 그 델리게이트를 다시 부른다. 스폰 로직 복제 0 이고 On_Regen 을 원본이 부르므로 Spawned(isBoss) 가 등장 시점에 난다(811d 보스 캠 · 813c HP 바 · 813t 배너 구독). - 아레나 — 등장 직전에 MonsterList 10006 행 f_BaseChaseRange 50 → 20(SO 값 · 코드 상수 0), 보스/PC 가 아레나를 벗어나면 복귀(리셋) · 처치 후 RegenTime 코루틴 차단(리젠 없음). - 값 = Assets/WL/Combat/Settings/Resources/WL/WLBossArenaSettings.asset (C8 · 29필드). - Boss_*.cs / MobActor.cs / Actor.cs / Combat Core / 프리팹 변경 0.
This commit is contained in:
parent
6d9d8755c1
commit
353fc71640
|
|
@ -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 이어야 한다)
|
||||
|
||||
===== 끝 =====
|
||||
|
|
@ -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<FieldBossData>();
|
||||
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<int, MonsterTableData>();
|
||||
dic.Add(row.n_MonsterID, row);
|
||||
|
||||
s_tableGo = new UnityEngine.GameObject("WL813d_FakeMonsterList");
|
||||
var tbl = s_tableGo.AddComponent<table_monsterlist>();
|
||||
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<FieldBossData>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Export 테이블(1줄 minified · 값은 문자열)에서 keyField=keyValue 인 행의 field 를 float 로 읽는다.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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<GameObject>(fieldbossdata.s_MonsterPrefab, handle =>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>보스전 게이트·아레나. 정적 · 새 Manager/Singleton 0(런너는 숨김 GameObject 1개).</summary>
|
||||
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<Held> s_held = new List<Held>(2);
|
||||
static readonly Dictionary<int, Zone> s_zones = new Dictionary<int, Zone>(8);
|
||||
static readonly List<int> s_gateZones = new List<int>(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; } }
|
||||
/// <summary>구독 여부 — 게이트가 실제로 CombatEvents 에 붙었는지 확인용.</summary>
|
||||
public static bool Subscribed { get { return s_subscribed; } }
|
||||
/// <summary>등장한 보스(리쉬 대상). 미등장이면 null.</summary>
|
||||
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줄)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 이 FieldBossData 의 스폰을 붙잡을지 판정한다.
|
||||
/// true = 원본 Start 가 그 자리에서 return(보스 미등장) · 게이트가 열릴 때 resume 으로 Start 를 다시 부른다.
|
||||
/// false = 원본 스폰 코드 그대로 진행(= 813e 상시 배치).
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>CombatEvents 구독(중복 구독은 CombatEventList.Add 가 막는다).</summary>
|
||||
public static void EnsureSubscribed()
|
||||
{
|
||||
if (s_subscribed) return;
|
||||
CombatEvents.Killed.Add(OnKilled);
|
||||
CombatEvents.Spawned.Add(OnSpawned);
|
||||
s_subscribed = true;
|
||||
}
|
||||
|
||||
/// <summary>구독 해제(프로브·롤백용).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>스폰 1건 반영. 프로브가 가짜 스폰을 넣을 때도 이 경로를 쓴다.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>처치 1건 반영 + 게이트 재판정. 프로브가 가짜 Killed 를 넣을 때도 이 경로를 쓴다.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 게이트 판정 · 개방
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>조건을 다시 보고 충족하면 게이트를 연다. 열려 있으면 아무 일도 하지 않는다.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>게이트를 강제로 연다(QA·프로브용 · 조건 무시).</summary>
|
||||
public static void ForceOpenGate()
|
||||
{
|
||||
var cfg = Cfg;
|
||||
if (cfg == null || !s_armed) return;
|
||||
s_gateOpen = true;
|
||||
Log("게이트 강제 개방");
|
||||
Release(cfg);
|
||||
}
|
||||
|
||||
/// <summary>보류 중인 FieldBossData 를 실제로 등장시킨다(원본 Start 재진입).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 아레나 — 추적 반경 데이터화 · 리쉬 · 리셋
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 등장 직전에 MonsterList 행의 f_BaseChaseRange 를 아레나 반경으로 바꾼다(코드 상수 0 · MobActor 무수정).
|
||||
/// MobActor.Set(mobtable,…) 이 이 값을 읽어 복사하므로 「등장 전」에 해야 한다.
|
||||
/// </summary>
|
||||
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"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MonsterList 행에 추적 반경을 쓰는 유일한 이음매(프로브가 같은 경로를 직접 부를 수 있다).
|
||||
/// MobActor.cs:92 가 Set 시점에 이 값을 복사하고(Check_Battle), MobActor.cs:356(Check_Patrol)은 이 행을 직접 읽는다.
|
||||
/// </summary>
|
||||
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 가 던진다
|
||||
}
|
||||
|
||||
/// <summary>아레나 중심(보류/등록된 FieldBossData 기준). 없으면 SO 값.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>리쉬 판정 결과 — 0 유지 · 1 PC 이탈 리셋 · 2 보스 이탈 리셋.</summary>
|
||||
public const int kLeashNone = 0, kLeashPlayerOut = 1, kLeashBossOut = 2;
|
||||
|
||||
/// <summary>
|
||||
/// 리쉬 판정(순수 함수 · Actor 접근 0). 프로브가 가짜 좌표·시각으로 그대로 부를 수 있다.
|
||||
/// hasPlayer = PC 참조가 있는가(없으면 PC 이탈 판정을 건너뛴다).
|
||||
/// 유예 시간 상태(s_playerOutsideSince)만 갱신하고, 실제 리셋은 호출측(LeashTick)이 한다.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>마지막 리쉬 판정에서 쓴 거리(진단).</summary>
|
||||
public static float LastLeashDistance;
|
||||
|
||||
/// <summary>경계 검사 1틱. 런너가 부르고, 프로브도 직접 부를 수 있다.</summary>
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>보스를 아레나 중심으로 되돌린다. 회복은 reincarnation:true 라 Spawned 가 다시 나지 않는다.</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);
|
||||
s_boss.Set_Warp(center);
|
||||
ResetCount++;
|
||||
Log("보스 리셋 #" + ResetCount + " — " + reason);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 처치 후 정리
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>보스 처치 후 정리 — 재등장 코루틴 차단 · 반경 원복 · (옵션) 존 비활성.</summary>
|
||||
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<MobControlMgr>(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<BossArenaRunner>();
|
||||
}
|
||||
|
||||
/// <summary>런너가 매 프레임 부른다(에디트 모드에서는 프로브가 직접 부를 수 있다).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>프로브·재시작용: 모든 상태를 비운다(구독은 Unsubscribe 로 따로 끊는다).</summary>
|
||||
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 = "";
|
||||
}
|
||||
|
||||
/// <summary>프로브용 — 보스 참조를 직접 넣는다(에디트 모드에는 실제 스폰이 없다).</summary>
|
||||
public static void SetBossForProbe(MobActor boss) { s_boss = boss; }
|
||||
|
||||
/// <summary>프로브용 — PC 이탈 유예 타이머만 비운다(리쉬 시나리오 사이 초기화).</summary>
|
||||
public static void ResetLeashState() { s_playerOutsideSince = -1f; s_leashFaulted = false; }
|
||||
|
||||
/// <summary>PC 가 아레나 밖으로 나간 시각(unscaled · 안이면 −1).</summary>
|
||||
public static float PlayerOutsideSince { get { return s_playerOutsideSince; } }
|
||||
}
|
||||
|
||||
/// <summary>BossArena 의 시간 축(개장 지연 · 정리 지연 · 리쉬 주기). 숨김 GameObject 1개.</summary>
|
||||
internal sealed class BossArenaRunner : MonoBehaviour
|
||||
{
|
||||
void Update() { BossArena.Tick(); }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d13d0022b5f4d8bbf2daac3806b7f61
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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<WLBossArenaSettings>("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
|
||||
{
|
||||
/// <summary>보스 등장 게이트 판정 방식.</summary>
|
||||
public enum eBossGateMode
|
||||
{
|
||||
/// <summary>게이트 존이 「동시에」 생존 0 이 되는 순간(전멸).</summary>
|
||||
ZoneCleared = 0,
|
||||
/// <summary>관측 존들의 누적 처치 수가 requiredKills 이상.</summary>
|
||||
KillCount = 1,
|
||||
/// <summary>둘 중 먼저 만족하는 쪽(권장 — 리젠형 존에서 전멸이 안 잡혀도 열린다).</summary>
|
||||
Either = 2,
|
||||
/// <summary>둘 다 만족해야 열린다.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
|
||||
public static WLBossArenaSettings Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!s_lookupDone)
|
||||
{
|
||||
s_cached = Resources.Load<WLBossArenaSettings>(ResourcesPath);
|
||||
s_lookupDone = true;
|
||||
}
|
||||
return s_cached;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>진단·A/B 전용 런타임 스위치. 에셋을 건드리지 않고 게이트만 끈다(도메인 리로드로 자동 false).</summary>
|
||||
public static bool RuntimeDisabled;
|
||||
|
||||
/// <summary>게이트가 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off · 코어 on. false 면 813e 상시 배치 그대로.</summary>
|
||||
public static bool Active
|
||||
{
|
||||
get
|
||||
{
|
||||
var i = Instance;
|
||||
return i != null && i.enabled && !RuntimeDisabled && WLCombatCoreSettings.Enabled;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다(에셋 제거/복구 실험).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d13d0011a4e4c7fae1c9b2f7d3a6e50
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
fileFormatVersion: 2
|
||||
guid: 8d13d0033c6a4e9ccf3ebbd4917c8072
|
||||
NativeFormatImporter:
|
||||
externalObjects: {}
|
||||
mainObjectFileID: 11400000
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Loading…
Reference in New Issue