From 2b43190037531db8134ab94448cf3df53cd58790 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 00:19:49 +0900 Subject: [PATCH] =?UTF-8?q?[WL-813h]=20=EB=B3=B4=EC=8A=A4=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=C2=B7=EC=98=88=EA=B3=A0=C2=B7=EC=BF=A8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=ED=99=94=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Assets/WL/Combat/Boss/BossEvents.cs (NEW): BossPhase 통지 허브. 코어 CombatEvents.cs 는 무수정 — 811d~j 와 충돌 방지. 같은 규약(정적 · struct 페이로드 in 전달 · 사전 할당 구독자 배열 · 예외 격리 · WLCombatCoreSettings enabled/RuntimeDisabled 준수). 실측 GC = 1000회 디스패치 0 byte. - Assets/WL/Combat/Boss/BossPatternTable.cs (NEW): 보스 패턴 JSON 로더. 스폰(On_Regen) 시 그 Boss_*.cs 가 직접 선언한 public float/int/bool 필드에만 대입(DeclaredOnly) → Boss_*.cs 수정 0 · 프리팹 값 변경 0. 행이 없으면 무동작. - Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json (NEW): 보스 12행(10001~10012). 813b 실측(프리팹 인스펙터) 값 그대로이며, Anubis(10006) 의 예고(차징) 3 s → 1.2 s 만 발주서 제안값으로 데이터에서 바꾼다. 페이즈 임계 기본 [1.0, 0.7, 0.4] · 스킬 해금 임계 기본 0.9 / 0.7(원본과 동일). - Assets/Script/Character/Mob/BossMobActor.cs: 훅 3줄(로더 적용 1 · 임계 조회 1 · 페이즈 통지 1). 원본 라인은 그대로 둬서 C8 시 100% 원본 동작. - AgentScripts/WL813h_Probe.cs + WL813h_PROBE.txt: 에디트 모드 프로브와 실행 덤프 (로더 적용 · 행 없음 원복 · 페이즈 3회 · GC 0 · RuntimeDisabled 롤백 전부 PASS). 게이트 A PASS(oneshot · exit 0 · error CS 0줄 · 0.8분) · 콘솔 error 0 · Play 0. Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL813h_PROBE.txt | 68 +++ AgentScripts/WL813h_Probe.cs | 260 ++++++++++++ Assets/Script/Character/Mob/BossMobActor.cs | 3 + Assets/WL/Combat/Boss.meta | 8 + Assets/WL/Combat/Boss/BossEvents.cs | 104 +++++ Assets/WL/Combat/Boss/BossEvents.cs.meta | 2 + Assets/WL/Combat/Boss/BossPatternTable.cs | 386 ++++++++++++++++++ .../WL/Combat/Boss/BossPatternTable.cs.meta | 2 + .../Resources/WL/WL813h_BossPattern.json | 128 ++++++ .../Resources/WL/WL813h_BossPattern.json.meta | 7 + 10 files changed, 968 insertions(+) create mode 100644 AgentScripts/WL813h_PROBE.txt create mode 100644 AgentScripts/WL813h_Probe.cs create mode 100644 Assets/WL/Combat/Boss.meta create mode 100644 Assets/WL/Combat/Boss/BossEvents.cs create mode 100644 Assets/WL/Combat/Boss/BossEvents.cs.meta create mode 100644 Assets/WL/Combat/Boss/BossPatternTable.cs create mode 100644 Assets/WL/Combat/Boss/BossPatternTable.cs.meta create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json create mode 100644 Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json.meta diff --git a/AgentScripts/WL813h_PROBE.txt b/AgentScripts/WL813h_PROBE.txt new file mode 100644 index 000000000..7c0508c0a --- /dev/null +++ b/AgentScripts/WL813h_PROBE.txt @@ -0,0 +1,68 @@ +===== WL-813h 보스 패턴 로더 · 페이즈 통지 프로브 ===== +playMode=False time=2026-09-09 00:18:21 + +── ① 로더 상태 + 코어 Enabled = True + 로더 Enabled = True + RowCount = 12 + ParseFailed = False + +── ① 임계 조회표 (skill1 / skill2 = 해금 임계 · phases = 페이즈 임계) + ID 이름 skill1 skill2 phases 필드수 + 10001 Lusif 0.9 0.7 [1, 0.7, 0.4] 8 + 10002 Lusif_B 0.9 0.7 [1, 0.7, 0.4] 7 + 10003 Mega_Ice_Golem 0.9 0.7 [1, 0.7, 0.4] 11 + 10004 Minotos 0.9 0.7 [1, 0.7, 0.4] 7 + 10005 Golem_Child 0.9 0.7 [1, 0.7, 0.4] 8 + 10006 Anubis 0.9 0.7 [1, 0.7, 0.4] 9 + 10007 Queen_Undead 0.9 0.7 [1, 0.7, 0.4] 10 + 10008 Flower Dryad 0.9 0.7 [1, 0.7, 0.4] 15 + 10009 Angez_B 0.9 0.7 [1, 0.7, 0.4] 11 + 10010 Angez_C 0.9 0.7 [1, 0.7, 0.4] 9 + 10011 Brother_Penguin_C 0.9 0.7 [1, 0.7, 0.4] 14 + 10012 Shaga_B 0.9 0.7 [1, 0.7, 0.4] 12 + +── ② Anubis(10006) 프리팹 인스턴스에 행 적용 (LoadPrefabContents · 저장 없음) + 대상 = Boss_Anubis on 'Anubis' + ApplyById(10006) → 적용 필드 9개 · 미지원 키 0개 + + 필드 프리팹값 → 적용후 (변화) + Skill1CoolTime 18 → 18 + Skill1Dmg 2.2 → 2.2 + Skill1ChargingTime 3 → 1.2 <<< 변경 + Perforation_Line False → False + Skill2CoolTime 15 → 15 + Skill2ChargingTime 3 → 1.2 <<< 변경 + Skill2AttackDelay 1 → 1 + Skill2Dmg 3 → 3 + Skill2Speed 15 → 15 + +── ③ 행 없음(ID 99999) → 값 유지 검증 + Revert(10006) = 9개 되돌림 (프리팹 원본 값으로 복구) + ApplyById(99999) = -1 (−1 = 무동작) · 변경된 필드 0개 + 판정 = PASS (행 없으면 프리팹 값 그대로) + +── ④ BossPhase 통지 (가짜 HP 100 → 70 → 40 %) + #1 phase 0→1/3 id=10006 hp=100 % 임계=1 spawn=True + #2 phase 1→2/3 id=10006 hp=70 % 임계=0.7 spawn=False + #3 phase 2→3/3 id=10006 hp=40 % 임계=0.4 spawn=False + + 이벤트 3회 · Raised=3 Dispatched=3 최종 페이즈=3 + 판정 = PASS (100→70→40 · 3회 · 회복 시 되돌림 없음) + +── ④ 스킬 해금 임계 (원본 0.9 / 0.7 · 행이 값을 주면 그 값) + HP 100 % → CanUseSkill1=False CanUseSkill2=False + HP 95 % → CanUseSkill1=False CanUseSkill2=False + HP 90 % → CanUseSkill1=True CanUseSkill2=False + HP 80 % → CanUseSkill1=True CanUseSkill2=False + HP 70 % → CanUseSkill1=True CanUseSkill2=True + HP 50 % → CanUseSkill1=True CanUseSkill2=True + +── ⑤ 디스패치 GC (Raise 1000회 · 구독자는 카운터만 = 로그 문자열 할당 배제) + 구독자 1명 · 수신 1000회 · 할당 델타 = 0 byte (회당 0 byte) + +── ⑥ C8 롤백 (RuntimeDisabled = true → 로더·통지 무동작) + 로더 Enabled=False · ApplyById=-1 · 변경 필드=0 · 게이트(HP 10%)=False/False · UpdatePhase=0 · 이벤트=0 + 판정 = PASS (프리팹 값 · 원본 게이트 · 통지 0) + +===== 끝 ===== diff --git a/AgentScripts/WL813h_Probe.cs b/AgentScripts/WL813h_Probe.cs new file mode 100644 index 000000000..06bf7cf82 --- /dev/null +++ b/AgentScripts/WL813h_Probe.cs @@ -0,0 +1,260 @@ +// PD 지시 #813 · 발주서 WL-813h §1-4 — 보스 패턴 로더 · 페이즈 통지 에디트 모드 프로브 (읽기 전용) +// +// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기 · 첫 Roslyn 컴파일은 --timeout 60) +// unity command run_script --file AgentScripts/WL813h_Probe.cs --entry WL813h_Probe.Run --timeout 180 +// +// 무엇을 확인하나 (발주서 §3 ⓒ ⓓ ⓔ) +// ① 로더 상태(JSON 행 수 · 파싱) 와 12행 임계 조회표 +// ② Anubis(10006) 프리팹 인스턴스에 행 값이 적용되는지 — 적용 전/후 필드 덤프 +// ③ 행 없음(가짜 ID) → 값 무변경(원본 유지) +// ④ BossPhase 통지 — 가짜 HP 100 → 70 → 40 % 에서 이벤트 3회 + 페이로드 +// ⑤ 디스패치 GC 할당(1000회 Raise 전후 GC.GetTotalMemory 차) +// ⑥ C8 — RuntimeDisabled = true 면 로더/통지 무동작 +// +// Play 하지 않는다. 프리팹은 LoadPrefabContents → (저장 없이) UnloadPrefabContents 라 에셋 무변경. +// 수치는 전부 JSON/프리팹에서 읽는다(C45). 프로브의 반복 횟수·가짜 HP 는 "측정 조건"이지 게임 데이터가 아니다. + +public static class WL813h_Probe +{ + const string kAnubisPrefab = "Assets/Res_Addr/Mobs/FieldBoss/Anubis.prefab"; + const int kAnubisId = 10006; + const int kMissingId = 99999; // JSON 에 없는 ID (행 없음 경로 검증) + const int kGcIterations = 1000; // 측정 조건 + const string kLog = "AgentScripts/WL813h_PROBE.txt"; // 발주서 §4 허용 경로(AgentScripts/WL813h_*) + + static System.Text.StringBuilder s; + static int s_eventCount; + static string s_eventLog; + + public static string Run() + { + s = new System.Text.StringBuilder(); + s_eventCount = 0; s_eventLog = ""; + + L("===== WL-813h 보스 패턴 로더 · 페이즈 통지 프로브 ====="); + 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.BossPatternTable.ClearCache(); + WL.Combat.Boss.BossEvents.ResetDiagnostics(); + + Section1_Table(); + Section2_Apply(); // ②③ 안에서 ④⑤(페이즈·GC)까지 같은 인스턴스로 이어서 돈다 + Section4_Rollback(); + + L(""); + L("===== 끝 ====="); + return Flush(); + } + + // ───────────────────────────────────────── ① 로더 상태 + 임계 조회표 + static void Section1_Table() + { + L(""); + L("── ① 로더 상태"); + L(" 코어 Enabled = " + WL.Combat.Core.WLCombatCoreSettings.Enabled); + L(" 로더 Enabled = " + WL.Combat.Boss.BossPatternTable.Enabled); + L(" RowCount = " + WL.Combat.Boss.BossPatternTable.RowCount); + L(" ParseFailed = " + WL.Combat.Boss.BossPatternTable.ParseFailed + + (WL.Combat.Boss.BossPatternTable.LoadError.Length > 0 ? (" (" + WL.Combat.Boss.BossPatternTable.LoadError + ")") : "")); + + L(""); + L("── ① 임계 조회표 (skill1 / skill2 = 해금 임계 · phases = 페이즈 임계)"); + L(" ID 이름 skill1 skill2 phases 필드수"); + var f = WL.Combat.Boss.BossPatternTable.File; + if (f == null || f.rows == null) { L(" (JSON 없음)"); return; } + for (int i = 0; i < f.rows.Count; i++) + { + var r = f.rows[i]; + float t1, t2; + WL.Combat.Boss.BossPatternTable.GetSkillThresholds(r.id, out t1, out t2); + var th = WL.Combat.Boss.BossPatternTable.GetPhaseThresholds(r.id); + L(string.Format(" {0,-7} {1,-20} {2,-7} {3,-7} {4,-21} {5}", + r.id, r.name, t1.ToString("0.###"), t2.ToString("0.###"), + "[" + string.Join(", ", System.Array.ConvertAll(th, x => x.ToString("0.##"))) + "]", + r.fields != null ? r.fields.Count : 0)); + } + } + + // ───────────────────────────────────────── ② 행 적용 · ③ 행 없음 + static void Section2_Apply() + { + L(""); + L("── ② Anubis(10006) 프리팹 인스턴스에 행 적용 (LoadPrefabContents · 저장 없음)"); + var root = UnityEditor.PrefabUtility.LoadPrefabContents(kAnubisPrefab); + if (root == null) { L(" [에러] 프리팹 로드 실패: " + kAnubisPrefab); return; } + try + { + var boss = root.GetComponentInChildren(true); + if (boss == null) { L(" [에러] BossMobActor 컴포넌트 없음"); return; } + L(" 대상 = " + boss.GetType().Name + " on '" + boss.gameObject.name + "'"); + + var before = Dump(boss); + int applied = WL.Combat.Boss.BossPatternTable.ApplyById(boss, kAnubisId); + var after = Dump(boss); + L(" ApplyById(10006) → 적용 필드 " + applied + "개 · 미지원 키 " + WL.Combat.Boss.BossPatternTable.LastUnknownKeys + "개"); + L(""); + L(" 필드 프리팹값 → 적용후 (변화)"); + foreach (var kv in before) + { + string a = after.ContainsKey(kv.Key) ? after[kv.Key] : "?"; + L(string.Format(" {0,-24} {1,-9} → {2,-9} {3}", kv.Key, kv.Value, a, kv.Value == a ? "" : "<<< 변경")); + } + + L(""); + L("── ③ 행 없음(ID " + kMissingId + ") → 값 유지 검증"); + int reverted = WL.Combat.Boss.BossPatternTable.Revert(boss, kAnubisId); + L(" Revert(10006) = " + reverted + "개 되돌림 (프리팹 원본 값으로 복구)"); + var baseline = Dump(boss); + int applied2 = WL.Combat.Boss.BossPatternTable.ApplyById(boss, kMissingId); + var afterMissing = Dump(boss); + int changed = 0; + foreach (var kv in baseline) if (!afterMissing.ContainsKey(kv.Key) || afterMissing[kv.Key] != kv.Value) changed++; + L(" ApplyById(" + kMissingId + ") = " + applied2 + " (−1 = 무동작) · 변경된 필드 " + changed + "개"); + L(" 판정 = " + (applied2 == -1 && changed == 0 ? "PASS (행 없으면 프리팹 값 그대로)" : "FAIL")); + + // 페이즈 검사는 같은 인스턴스로 이어서 한다. + s_probeBoss = boss; + Section3_PhaseInner(); + } + finally + { + s_probeBoss = null; + UnityEditor.PrefabUtility.UnloadPrefabContents(root); // 저장하지 않으므로 프리팹 에셋 무변경 + } + } + + static BossMobActor s_probeBoss; + + // ───────────────────────────────────────── ④ 페이즈 통지 · ⑤ GC + static void Section3_PhaseInner() + { + var boss = s_probeBoss; + L(""); + L("── ④ BossPhase 통지 (가짜 HP 100 → 70 → 40 %)"); + + WL.Combat.Core.CombatHandler h = OnPhase; + WL.Combat.Boss.BossEvents.BossPhase.Add(h); + WL.Combat.Boss.BossEvents.ResetDiagnostics(); + s_eventCount = 0; s_eventLog = ""; + try + { + WL.Combat.Boss.BossPatternTable.ApplyById(boss, kAnubisId); // 스폰 = 페이즈 1 + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 1.00f); + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.85f); + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.70f); + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.55f); + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.40f); + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.90f); // 회복 — 되돌아가지 않아야 한다 + WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.05f); + L(s_eventLog); + L(" 이벤트 " + s_eventCount + "회 · Raised=" + WL.Combat.Boss.BossEvents.BossPhase.Raised + + " Dispatched=" + WL.Combat.Boss.BossEvents.BossPhase.Dispatched + + " 최종 페이즈=" + WL.Combat.Boss.BossPatternTable.CurrentPhase(boss)); + L(" 판정 = " + (s_eventCount == 3 ? "PASS (100→70→40 · 3회 · 회복 시 되돌림 없음)" : "FAIL (기대 3회)")); + + L(""); + L("── ④ 스킬 해금 임계 (원본 0.9 / 0.7 · 행이 값을 주면 그 값)"); + bool c1 = false, c2 = false; + float[] hps = { 1.00f, 0.95f, 0.90f, 0.80f, 0.70f, 0.50f }; + for (int i = 0; i < hps.Length; i++) + { + WL.Combat.Boss.BossPatternTable.ApplySkillGatesById(boss, kAnubisId, hps[i], ref c1, ref c2); + L(string.Format(" HP {0,5:P0} → CanUseSkill1={1,-5} CanUseSkill2={2}", hps[i], c1, c2)); + } + + L(""); + L("── ⑤ 디스패치 GC (Raise " + kGcIterations + "회 · 구독자는 카운터만 = 로그 문자열 할당 배제)"); + WL.Combat.Boss.BossEvents.BossPhase.Remove(h); + WL.Combat.Core.CombatHandler hc = OnPhaseCount; + WL.Combat.Boss.BossEvents.BossPhase.Add(hc); + s_gcCount = 0; + System.GC.Collect(); + long m0 = System.GC.GetTotalMemory(true); + for (int i = 0; i < kGcIterations; i++) + WL.Combat.Boss.BossEvents.RaiseBossPhase(boss, kAnubisId, 1, 2, 3, 0.7f, 0.7f, false); + long m1 = System.GC.GetTotalMemory(false); + WL.Combat.Boss.BossEvents.BossPhase.Remove(hc); + L(" 구독자 1명 · 수신 " + s_gcCount + "회 · 할당 델타 = " + (m1 - m0) + " byte (회당 " + + ((m1 - m0) / (double)kGcIterations).ToString("0.##") + " byte)"); + } + finally { WL.Combat.Boss.BossEvents.BossPhase.Remove(h); } + } + + static int s_gcCount; + + /// GC 측정용 — 할당이 전혀 없는 구독자(문자열 조립 없음). + static void OnPhaseCount(in WL.Combat.Boss.BossPhaseEvent e) { s_gcCount++; } + + static void OnPhase(in WL.Combat.Boss.BossPhaseEvent e) + { + s_eventCount++; + s_eventLog += string.Format(" #{0} phase {1}→{2}/{3} id={4} hp={5:P0} 임계={6:0.##} spawn={7}\n", + s_eventCount, e.prevPhase, e.phase, e.phaseCount, e.monsterId, e.hpPercent, e.threshold, e.isSpawn); + } + + // ───────────────────────────────────────── ⑥ C8 롤백 + static void Section4_Rollback() + { + L(""); + L("── ⑥ C8 롤백 (RuntimeDisabled = true → 로더·통지 무동작)"); + var root = UnityEditor.PrefabUtility.LoadPrefabContents(kAnubisPrefab); + try + { + var boss = root.GetComponentInChildren(true); + WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = true; + WL.Combat.Boss.BossEvents.ResetDiagnostics(); + var before = Dump(boss); + int applied = WL.Combat.Boss.BossPatternTable.ApplyById(boss, kAnubisId); + var after = Dump(boss); + int changed = 0; + foreach (var kv in before) if (after[kv.Key] != kv.Value) changed++; + bool c1 = false, c2 = false; + WL.Combat.Boss.BossPatternTable.ApplySkillGatesById(boss, kAnubisId, 0.10f, ref c1, ref c2); + int ph = WL.Combat.Boss.BossPatternTable.UpdatePhaseById(boss, kAnubisId, 0.10f); + L(" 로더 Enabled=" + WL.Combat.Boss.BossPatternTable.Enabled + " · ApplyById=" + applied + + " · 변경 필드=" + changed + " · 게이트(HP 10%)=" + c1 + "/" + c2 + + " · UpdatePhase=" + ph + " · 이벤트=" + WL.Combat.Boss.BossEvents.TotalRaised); + L(" 판정 = " + (applied == -1 && changed == 0 && !c1 && !c2 && ph == 0 && + WL.Combat.Boss.BossEvents.TotalRaised == 0 + ? "PASS (프리팹 값 · 원본 게이트 · 통지 0)" : "FAIL")); + } + finally + { + UnityEditor.PrefabUtility.UnloadPrefabContents(root); + WL.Combat.Core.WLCombatCoreSettings.RuntimeDisabled = false; + WL.Combat.Boss.BossPatternTable.ClearCache(); + } + } + + // ───────────────────────────────────────── 유틸 + static System.Collections.Generic.Dictionary Dump(BossMobActor boss) + { + var d = new System.Collections.Generic.Dictionary(); + var fields = boss.GetType().GetFields(System.Reflection.BindingFlags.Public | + System.Reflection.BindingFlags.Instance | + System.Reflection.BindingFlags.DeclaredOnly); + for (int i = 0; i < fields.Length; i++) + { + var t = fields[i].FieldType; + if (t == typeof(float) || t == typeof(int) || t == typeof(bool) || t == typeof(string)) + d[fields[i].Name] = System.Convert.ToString(fields[i].GetValue(boss)); + } + return d; + } + + static void L(string line) { s.Append(line).Append("\n"); } + + static string Flush() + { + var text = s.ToString(); + try + { + var dir = System.IO.Path.GetDirectoryName(kLog); + if (!System.IO.Directory.Exists(dir)) System.IO.Directory.CreateDirectory(dir); + System.IO.File.WriteAllText(kLog, text, new System.Text.UTF8Encoding(false)); + } + catch (System.Exception ex) { text += "\n[로그 저장 실패] " + ex.Message; } + return text; + } +} diff --git a/Assets/Script/Character/Mob/BossMobActor.cs b/Assets/Script/Character/Mob/BossMobActor.cs index 78424ea95..df07e94f0 100644 --- a/Assets/Script/Character/Mob/BossMobActor.cs +++ b/Assets/Script/Character/Mob/BossMobActor.cs @@ -16,6 +16,7 @@ public class BossMobActor : MobActor base.On_Regen(reincarnation, healrate); CanUseSkill1 = CanUseSkill2 = false; CoolTime_PassiveSkill = 0; + WL.Combat.Boss.BossPatternTable.Apply(this, m_MonsterTableData); // #813h 패턴 데이터 적용 + 페이즈 1 통지 (행 없으면 무동작 = 프리팹 값 · C8) } protected override void Update() @@ -81,6 +82,8 @@ public class BossMobActor : MobActor base.Get_Damage(_dinfo); if (!CanUseSkill1) CanUseSkill1 = (Get_HP() / Get_MaxHP()) <= 0.9; if (!CanUseSkill2) CanUseSkill2 = (Get_HP() / Get_MaxHP()) <= 0.7; + WL.Combat.Boss.BossPatternTable.ApplySkillGates(this, m_MonsterTableData, Get_HPPercent(), ref CanUseSkill1, ref CanUseSkill2); // #813h 임계 조회 (행 없으면 위 두 줄 결과 유지 · C8) + WL.Combat.Boss.BossPatternTable.UpdatePhase(this, m_MonsterTableData, Get_HPPercent()); // #813h 페이즈 임계 통과 시 BossPhase 통지 } protected override void Set_Die() diff --git a/Assets/WL/Combat/Boss.meta b/Assets/WL/Combat/Boss.meta new file mode 100644 index 000000000..e33a13416 --- /dev/null +++ b/Assets/WL/Combat/Boss.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: becf3d4af4b59364eac35f55c5bf1919 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Combat/Boss/BossEvents.cs b/Assets/WL/Combat/Boss/BossEvents.cs new file mode 100644 index 000000000..e6acfe088 --- /dev/null +++ b/Assets/WL/Combat/Boss/BossEvents.cs @@ -0,0 +1,104 @@ +// ───────────────────────────────────────────────────────────────────────────── +// BossEvents.cs — WL 전투 액션 · 보스 페이즈 통지 허브(정적) +// +// PD 지시 #813 · 발주서 WL-813h §1-1 (2026-09-09) +// +// ■ 왜 별도 파일인가 +// 코어 허브 `Assets/WL/Combat/Core/CombatEvents.cs` 는 811d~j 가 동시에 물고 있어 수정 금지다. +// 보스 페이즈(BossPhase)는 811 12종 이벤트에 없던 새 축이므로 같은 규약으로 이 파일에 따로 둔다. +// · 정적 클래스(새 Manager/Singleton 0) · 구조체 페이로드를 `in` 으로 전달(복사·박싱 0) +// · 구독자 = 사전 할당 배열(코어의 CombatEventList 재사용 — Assembly-CSharp 단일 어셈블리) +// · 구독자 예외는 try/catch 로 격리(코어 CombatEventList.Dispatch 안) → 원본 전투 흐름을 끊지 않는다 +// · WLCombatCoreSettings.enabled / RuntimeDisabled 를 그대로 준수(C8 롤백 1순위 스위치 공유) +// +// ■ 발생 지점 (원본 훅은 BossMobActor.cs 1줄뿐 — 실제 판정은 BossPatternTable 안) +// BossMobActor.On_Regen ─ BossPatternTable.Apply → 페이즈 1 진입 통지(스폰) +// BossMobActor.Get_Damage ─ BossPatternTable.UpdatePhase → HP% 가 임계를 내려갈 때만 통지 +// 페이즈는 단조 증가(회복해도 되돌아가지 않는다) — 연출·UI 가 페이즈를 왕복하지 않게 한다. +// +// ■ 임계값의 출처 (C45) +// 코드 상수 0. 임계는 `Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json` +// (보스별 phaseThresholds · 없으면 테이블 기본 [1.0, 0.7, 0.4]). +// JSON 이 없거나 코어가 꺼져 있으면 Raise 자체가 일어나지 않는다 = 원본 100%. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것(BossMobActor 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Boss +{ + /// 보스 페이즈 전환 페이로드(발생 시점 스냅샷 · struct · GC 0). + public struct BossPhaseEvent + { + /// 페이즈가 바뀐 보스(에디트 모드 프로브에서는 프리팹 인스턴스일 수 있다). + public BossMobActor boss; + /// MonsterList 의 n_MonsterID (Anubis = 10006). + public int monsterId; + /// 이전 페이즈(스폰 통지는 0) · 새 페이즈(1부터). + public int prevPhase, phase; + /// 이 보스의 총 페이즈 수(임계 배열 길이). + public int phaseCount; + /// 전환 시점의 HP 비율(0~1) · 이 페이즈에 들어오게 한 임계값. + public float hpPercent, threshold; + /// 스폰(페이즈 1 진입) 통지면 true — 피해로 인한 전환과 구분한다. + public bool isSpawn; + public float time; + public int frame; + } + + /// 보스 이벤트 허브. 정적 · GC 0 · 구독은 BossPhase.Add / Remove. + public static class BossEvents + { + /// 페이즈 전환 구독자 목록(코어와 같은 구현 · 예외 격리 · Raised/Dispatched 카운터). + public static readonly CombatEventList BossPhase = new CombatEventList(4); + + /// 코어 활성 여부 = WLCombatCoreSettings.Enabled(에셋 · enabled · RuntimeDisabled). + public static bool Enabled { get { return WLCombatCoreSettings.Enabled; } } + + // ── 진단(프로브가 읽는다) + public static int TotalRaised; + public static string LastEvent = ""; + public static float LastEventTime; + public static int LastPhase, LastPrevPhase, LastMonsterId; + public static float LastHpPercent, LastThreshold; + + /// + /// 페이즈 전환 통지. 호출측(BossPatternTable)이 이미 "바뀌었다"를 판정한 뒤에만 부른다. + /// 코어가 꺼져 있으면 즉시 반환(C8) — 구독자도 카운터도 움직이지 않는다. + /// + public static void RaiseBossPhase(BossMobActor boss, int monsterId, int prevPhase, int phase, + int phaseCount, float hpPercent, float threshold, bool isSpawn) + { + if (!Enabled) return; + var e = new BossPhaseEvent + { + boss = boss, monsterId = monsterId, + prevPhase = prevPhase, phase = phase, phaseCount = phaseCount, + hpPercent = hpPercent, threshold = threshold, isSpawn = isSpawn, + time = Time.unscaledTime, frame = Time.frameCount + }; + TotalRaised++; + LastEvent = "BossPhase"; + LastEventTime = e.time; + LastPrevPhase = prevPhase; LastPhase = phase; LastMonsterId = monsterId; + LastHpPercent = hpPercent; LastThreshold = threshold; + BossPhase.Dispatch(in e); + + var c = WLCombatCoreSettings.Instance; + if (c != null && c.verboseLog) + Debug.Log("[BossEvents] Phase " + prevPhase + "→" + phase + " id=" + monsterId + + " hp=" + hpPercent.ToString("F3") + " th=" + threshold.ToString("F3")); + } + + /// 프로브용: 카운터 초기화(구독은 유지). + public static void ResetDiagnostics() + { + TotalRaised = 0; LastEvent = ""; LastEventTime = 0f; + LastPhase = LastPrevPhase = LastMonsterId = 0; + LastHpPercent = LastThreshold = 0f; + BossPhase.Raised = BossPhase.Dispatched = 0; + } + } +} diff --git a/Assets/WL/Combat/Boss/BossEvents.cs.meta b/Assets/WL/Combat/Boss/BossEvents.cs.meta new file mode 100644 index 000000000..7ebddbb06 --- /dev/null +++ b/Assets/WL/Combat/Boss/BossEvents.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1cd4589f49587da428cae4c43d82488b \ No newline at end of file diff --git a/Assets/WL/Combat/Boss/BossPatternTable.cs b/Assets/WL/Combat/Boss/BossPatternTable.cs new file mode 100644 index 000000000..086a191f9 --- /dev/null +++ b/Assets/WL/Combat/Boss/BossPatternTable.cs @@ -0,0 +1,386 @@ +// ───────────────────────────────────────────────────────────────────────────── +// BossPatternTable.cs — 보스 패턴(쿨 · 예고(차징) · 피해 배율 · 속도 · 페이즈 임계) 데이터 로더 +// +// PD 지시 #813 · 발주서 WL-813h §1-2 (2026-09-09) · 실측 근거 = WL-813b 완료보고 §1 +// +// ■ 무엇을 하나 (C45 — 코드 상수 0) +// 힘민지 보스 12종의 튜닝 값은 테이블이 아니라 **프리팹 인스펙터에 직렬화된 public 필드**에 들어 있다 +// (813b 실측: 코드 기본값과 4곳 불일치 — Minotos Skill2CoolTime 10, Anubis Skill2Dmg 3, QueenUndead +// Skill2BackDuration 2, FlowerDryad Skill1Heal 0). 12개 프리팹을 손으로 고치는 대신, +// JSON 한 장을 SOT 로 두고 **스폰 시점(On_Regen)에 그 보스의 public 필드에 덮어쓴다**. +// · `Boss_*.cs` 는 한 줄도 고치지 않는다(로더가 리플렉션으로 대입). +// · 행이 있는 보스만 덮어쓴다. 행이 없으면 프리팹 인스펙터 값 그대로 = 변경 0 (C8). +// · 덮어쓰는 필드는 그 Boss_* 클래스가 **직접 선언한**(DeclaredOnly) public float/int/bool 로 제한한다 +// → MobActor/Actor 의 공용 필드는 어떤 JSON 키로도 건드릴 수 없다(사고 방지). +// +// ■ 페이즈 +// 페이즈 임계도 같은 JSON(`phaseThresholds` · 기본 [1.0, 0.7, 0.4] = 100/70/40 %). +// HP% 가 임계를 내려갈 때 BossEvents.RaiseBossPhase 로 통지한다(단조 증가 · 회복해도 되돌아가지 않는다). +// `BossMobActor.cs:82-83` 의 스킬 해금 임계(원본 0.9 / 0.7)는 `skill1Threshold`/`skill2Threshold` 로 +// 보스별 조정이 가능하고, 키가 없으면 테이블 기본값(0.9 / 0.7) = 원본과 동일하다. +// +// ■ 롤백 (C8 · 3중) +// ① `WLCombatCoreSettings` 에셋 없음 / enabled=false / RuntimeDisabled → 로더·통지 전부 무동작 +// ② JSON 없음 / 파싱 실패 → 무동작(에러 1줄만 로그) ③ JSON 의 `"enabled": false` → 무동작 +// 무동작 = 스킬 해금 임계 0.9/0.7(원본 두 줄이 이미 계산해 둔 값을 그대로 통과) + 프리팹 인스펙터 값. +// +// ■ 성능 +// 필드 대입은 스폰 1회(보스 1기 · 필드 ~10개)뿐이고 FieldInfo 는 타입별로 캐시한다. +// 페이즈 판정·통지 경로(Get_Damage)는 배열/구조체만 쓴다 = 할당 0. +// +// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. +// 🔴 이 JSON 은 butler.xlsm 밖의 **WL 전용 파일**이다(G 폴더 · 추후 테이블 편입 후보). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using System.Collections.Generic; +using System.Reflection; +using Newtonsoft.Json; +using UnityEngine; +using WL.Combat.Core; + +namespace WL.Combat.Boss +{ + /// 보스 1종의 패턴 행. 값이 없는 키는 건드리지 않는다(프리팹 값 유지). + [Serializable] + public class BossPatternRow + { + /// MonsterList.n_MonsterID (10001~10012). + public int id; + /// 읽는 사람용 이름 · 대상 스크립트 · 메모(로직에 쓰지 않는다). + public string name, script, note; + /// 페이즈 임계(내림차순 · 예 [1.0, 0.7, 0.4]). null 이면 테이블 기본값. + public float[] phaseThresholds; + /// 스킬1/스킬2 해금 HP 비율. null 이면 테이블 기본값(원본 0.9 / 0.7). + public float? skill1Threshold, skill2Threshold; + /// Boss_*.cs 가 선언한 public 필드 이름 → 값(float · int 는 반올림 · bool 은 0 아니면 true). + public Dictionary fields; + } + + /// JSON 파일 전체. + [Serializable] + public class BossPatternFile + { + public string _comment, _source, _sot; + /// 이 파일 자체의 스위치(false 면 로더 무동작 = 프리팹 값 · C8). + public bool enabled = true; + /// 행에 phaseThresholds 가 없을 때 쓰는 기본 임계. + public float[] defaultPhaseThresholds; + /// 행에 임계가 없을 때 쓰는 스킬 해금 기본값 = 원본 BossMobActor.cs:82-83 값. + public float defaultSkill1Threshold = 0.9f; + public float defaultSkill2Threshold = 0.7f; + public List rows; + } + + /// 보스 패턴 JSON 로더 + 페이즈 판정. 정적 · 새 Manager 0. + public static class BossPatternTable + { + /// Resources 경로(확장자 없음) — Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json + public const string ResourcesPath = "WL/WL813h_BossPattern"; + + /// 테이블 기본 임계(JSON 에 defaultPhaseThresholds 가 없을 때) = 100 / 70 / 40 %. + static readonly float[] kFallbackPhases = { 1f, 0.7f, 0.4f }; + + /// 임계 비교 허용 오차(부동소수 누적 오차용 · 게임 데이터 아님). + const float kEpsilon = 1e-4f; + + static BossPatternFile s_file; + static bool s_lookupDone; + + // ── 진단(프로브가 읽는다) + public static bool ParseFailed; + public static string LoadError = ""; + public static int LastAppliedFields = -1, LastAppliedId, LastUnknownKeys; + + /// JSON(1회 로드 후 캐시). 없거나 파싱 실패면 null. + public static BossPatternFile File + { + get + { + if (!s_lookupDone) + { + s_lookupDone = true; + var ta = Resources.Load(ResourcesPath); + if (ta == null) { LoadError = "TextAsset not found: " + ResourcesPath; return null; } + try { s_file = JsonConvert.DeserializeObject(ta.text); } + catch (Exception ex) + { + ParseFailed = true; LoadError = ex.Message; s_file = null; + Debug.LogError("[BossPatternTable] JSON 파싱 실패 → 원본 동작 유지(C8): " + ex.Message); + } + } + return s_file; + } + } + + /// 로더가 살아 있는가 = 코어 ON · JSON 있음 · JSON enabled. + public static bool Enabled + { + get { var f = WLCombatCoreSettings.Enabled ? File : null; return f != null && f.enabled && f.rows != null; } + } + + /// 행 수(로드 실패 시 0). + public static int RowCount { get { var f = File; return f != null && f.rows != null ? f.rows.Count : 0; } } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() + { + s_file = null; s_lookupDone = false; ParseFailed = false; LoadError = ""; + s_fieldCache.Clear(); s_originals.Clear(); + for (int i = 0; i < s_states.Length; i++) s_states[i] = default(PhaseState); + } + + /// 보스 ID 의 행(없으면 null). 12행 선형 탐색 — 할당 0. + public static BossPatternRow Get(int monsterId) + { + var f = WLCombatCoreSettings.Enabled ? File : null; + if (f == null || !f.enabled || f.rows == null) return null; + for (int i = 0; i < f.rows.Count; i++) + if (f.rows[i] != null && f.rows[i].id == monsterId) return f.rows[i]; + return null; + } + + /// 이 보스의 페이즈 임계(행 없음/키 없음 → 테이블 기본 → [1, 0.7, 0.4]). + public static float[] GetPhaseThresholds(int monsterId) + { + var row = Get(monsterId); + if (row != null && row.phaseThresholds != null && row.phaseThresholds.Length > 0) return row.phaseThresholds; + var f = File; + if (f != null && f.defaultPhaseThresholds != null && f.defaultPhaseThresholds.Length > 0) return f.defaultPhaseThresholds; + return kFallbackPhases; + } + + /// 스킬1/스킬2 해금 임계(행 없음/키 없음 → 원본 0.9 / 0.7). + public static void GetSkillThresholds(int monsterId, out float t1, out float t2) + { + var f = File; + t1 = f != null ? f.defaultSkill1Threshold : 0.9f; + t2 = f != null ? f.defaultSkill2Threshold : 0.7f; + var row = Get(monsterId); + if (row == null) return; + if (row.skill1Threshold.HasValue) t1 = row.skill1Threshold.Value; + if (row.skill2Threshold.HasValue) t2 = row.skill2Threshold.Value; + } + + // ───────────────────────────────────────── 원본 훅이 부르는 3개 (BossMobActor.cs · 각 1줄) + + /// + /// [훅 1/3] BossMobActor.On_Regen — 스폰 시 JSON 행을 이 보스 인스턴스의 public 필드에 적용하고 + /// 페이즈 상태를 초기화한 뒤 페이즈 1 진입을 통지한다. + /// 반환 = 적용된 필드 수(코어 off · JSON 없음 · 행 없음이면 -1 = 무동작). + /// + public static int Apply(BossMobActor boss, MonsterTableData mobtable) + { + return ApplyById(boss, mobtable != null ? mobtable.n_MonsterID : 0); + } + + /// + /// [훅 2/3] BossMobActor.Get_Damage — 스킬 해금 임계를 데이터 값으로 확정한다. + /// 행이 없으면 원본 두 줄이 계산해 둔 값을 그대로 둔다(C8). 행이 있으면 데이터가 최종 권한. + /// + public static void ApplySkillGates(BossMobActor boss, MonsterTableData mobtable, float hpPercent, + ref bool canUseSkill1, ref bool canUseSkill2) + { + ApplySkillGatesById(boss, mobtable != null ? mobtable.n_MonsterID : 0, hpPercent, ref canUseSkill1, ref canUseSkill2); + } + + /// [훅 3/3] BossMobActor.Get_Damage — HP% 가 페이즈 임계를 내려갔으면 BossPhase 를 통지한다. + public static void UpdatePhase(BossMobActor boss, MonsterTableData mobtable, float hpPercent) + { + UpdatePhaseById(boss, mobtable != null ? mobtable.n_MonsterID : 0, hpPercent); + } + + // ───────────────────────────────────────── ID 직접 지정판 (프로브 · 에디트 모드) + + /// 행 값을 보스 인스턴스에 적용. 반환 = 적용된 필드 수(-1 = 무동작). + public static int ApplyById(BossMobActor boss, int monsterId) + { + LastAppliedId = monsterId; LastAppliedFields = -1; LastUnknownKeys = 0; + if (boss == null) return -1; + var row = Get(monsterId); + if (row == null) return -1; // C8 — 행이 없으면 상태 칸도 쓰지 않는다 + ResetState(boss, monsterId); // 풀 재사용 대비 래치 초기화 + + int applied = 0; + if (row.fields != null && row.fields.Count > 0) + { + var map = FieldsOf(boss.GetType()); + CacheOriginals(boss, row, map); + foreach (var kv in row.fields) + { + FieldInfo fi; + if (!map.TryGetValue(kv.Key, out fi)) { LastUnknownKeys++; continue; } + if (SetField(fi, boss, kv.Value)) applied++; else LastUnknownKeys++; + } + } + LastAppliedFields = applied; + + // 스폰 = 페이즈 1 진입 통지(HP 는 On_Regen 직후라 만피). + var th = GetPhaseThresholds(monsterId); + int idx = StateIndex(boss, monsterId, true); + s_states[idx].phase = 1; + BossEvents.RaiseBossPhase(boss, monsterId, 0, 1, th.Length, 1f, th.Length > 0 ? th[0] : 1f, true); + return applied; + } + + /// 스킬 해금 임계 적용(ID 직접 지정). + public static void ApplySkillGatesById(BossMobActor boss, int monsterId, float hpPercent, + ref bool canUseSkill1, ref bool canUseSkill2) + { + if (boss == null || float.IsNaN(hpPercent) || float.IsInfinity(hpPercent)) return; + var row = Get(monsterId); + if (row == null) return; // C8 — 원본 0.9 / 0.7 결과를 그대로 둔다 + + float t1, t2; GetSkillThresholds(monsterId, out t1, out t2); + int idx = StateIndex(boss, monsterId, true); + if (hpPercent <= t1 + kEpsilon) s_states[idx].skill1 = true; // 한 번 열리면 유지(원본과 같은 래치) + if (hpPercent <= t2 + kEpsilon) s_states[idx].skill2 = true; + canUseSkill1 = s_states[idx].skill1; + canUseSkill2 = s_states[idx].skill2; + } + + /// 페이즈 판정 + 통지(ID 직접 지정). 반환 = 새 페이즈(변화 없으면 현재 페이즈). + public static int UpdatePhaseById(BossMobActor boss, int monsterId, float hpPercent) + { + if (boss == null || float.IsNaN(hpPercent) || float.IsInfinity(hpPercent)) return 0; + var row = Get(monsterId); + if (row == null) return 0; // C8 — 행이 없으면 통지도 없다 + + var th = GetPhaseThresholds(monsterId); + int idx = StateIndex(boss, monsterId, true); + int cur = s_states[idx].phase; + int next = PhaseOf(th, hpPercent); + if (next <= cur) return cur; // 단조 증가 — 회복해도 되돌리지 않는다 + s_states[idx].phase = next; + BossEvents.RaiseBossPhase(boss, monsterId, cur, next, th.Length, hpPercent, th[next - 1], false); + return next; + } + + /// HP 비율에 해당하는 페이즈(1부터). 임계는 내림차순 배열. + public static int PhaseOf(float[] thresholds, float hpPercent) + { + if (thresholds == null || thresholds.Length == 0) return 1; + int p = 1; + for (int i = 0; i < thresholds.Length; i++) + if (hpPercent <= thresholds[i] + kEpsilon) p = i + 1; + return p; + } + + /// 현재 페이즈(상태가 없으면 0). + public static int CurrentPhase(BossMobActor boss) + { + int idx = StateIndex(boss, 0, false); + return idx >= 0 ? s_states[idx].phase : 0; + } + + /// + /// 프로브/롤백용 — Apply 가 덮어쓰기 전에 캐시해 둔 원본(프리팹) 값으로 되돌린다. + /// 반환 = 되돌린 필드 수(캐시 없으면 -1). + /// + public static int Revert(BossMobActor boss, int monsterId) + { + if (boss == null) return -1; + float[] orig; + if (!s_originals.TryGetValue(monsterId, out orig)) return -1; + var row = Get(monsterId); + if (row == null || row.fields == null) return -1; + var map = FieldsOf(boss.GetType()); + int i = 0, n = 0; + foreach (var kv in row.fields) + { + FieldInfo fi; + if (map.TryGetValue(kv.Key, out fi) && i < orig.Length && !float.IsNaN(orig[i])) + if (SetField(fi, boss, orig[i])) n++; + i++; + } + return n; + } + + /// 페이즈/해금 래치 초기화(스폰·풀 재사용 시). + public static void ResetState(BossMobActor boss, int monsterId) + { + int idx = StateIndex(boss, monsterId, true); + s_states[idx].phase = 0; s_states[idx].skill1 = false; s_states[idx].skill2 = false; + } + + // ───────────────────────────────────────── 인스턴스별 상태(사전 할당 배열 · 할당 0) + + struct PhaseState { public int instanceId, monsterId, phase; public bool skill1, skill2; } + + /// + /// 동시 보스 수 상한. 813b 실측 = Map_C01/C02 에 FieldBossData 9개씩(WL_Nature 는 0) → 여유 16칸. + /// 넘치면 가장 오래된 칸을 재사용한다(페이즈 상태만 잃고 전투는 그대로). + /// + static readonly PhaseState[] s_states = new PhaseState[16]; + static int s_cursor; + + static int StateIndex(BossMobActor boss, int monsterId, bool create) + { + if (boss == null) return -1; + int id = boss.GetInstanceID(); + for (int i = 0; i < s_states.Length; i++) + if (s_states[i].instanceId == id) return i; + if (!create) return -1; + for (int i = 0; i < s_states.Length; i++) + if (s_states[i].instanceId == 0) + { s_states[i] = new PhaseState { instanceId = id, monsterId = monsterId }; return i; } + int c = s_cursor; s_cursor = (s_cursor + 1) % s_states.Length; + s_states[c] = new PhaseState { instanceId = id, monsterId = monsterId }; + return c; + } + + // ───────────────────────────────────────── 리플렉션 캐시 + + static readonly Dictionary> s_fieldCache = + new Dictionary>(); + static readonly Dictionary s_originals = new Dictionary(); + + /// 그 Boss_* 클래스가 **직접 선언한** public float/int/bool 필드만(상속 필드는 제외 = 사고 방지). + static Dictionary FieldsOf(Type t) + { + Dictionary map; + if (s_fieldCache.TryGetValue(t, out map)) return map; + map = new Dictionary(); + var fields = t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); + for (int i = 0; i < fields.Length; i++) + { + var ft = fields[i].FieldType; + if (ft == typeof(float) || ft == typeof(int) || ft == typeof(bool)) + map[fields[i].Name] = fields[i]; + } + s_fieldCache[t] = map; + return map; + } + + static void CacheOriginals(BossMobActor boss, BossPatternRow row, Dictionary map) + { + if (s_originals.ContainsKey(row.id)) return; + var vals = new float[row.fields.Count]; + int i = 0; + foreach (var kv in row.fields) + { + FieldInfo fi; + vals[i++] = map.TryGetValue(kv.Key, out fi) ? GetField(fi, boss) : float.NaN; + } + s_originals[row.id] = vals; + } + + static bool SetField(FieldInfo fi, object target, float v) + { + var t = fi.FieldType; + if (t == typeof(float)) { fi.SetValue(target, v); return true; } + if (t == typeof(int)) { fi.SetValue(target, Mathf.RoundToInt(v)); return true; } + if (t == typeof(bool)) { fi.SetValue(target, Mathf.Abs(v) > kEpsilon); return true; } + return false; + } + + static float GetField(FieldInfo fi, object target) + { + var t = fi.FieldType; + if (t == typeof(float)) return (float)fi.GetValue(target); + if (t == typeof(int)) return (int)fi.GetValue(target); + if (t == typeof(bool)) return ((bool)fi.GetValue(target)) ? 1f : 0f; + return float.NaN; + } + } +} diff --git a/Assets/WL/Combat/Boss/BossPatternTable.cs.meta b/Assets/WL/Combat/Boss/BossPatternTable.cs.meta new file mode 100644 index 000000000..304c2c568 --- /dev/null +++ b/Assets/WL/Combat/Boss/BossPatternTable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5ead0415803444748bcfed484db1c33a \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json b/Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json new file mode 100644 index 000000000..a0c9c4e37 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json @@ -0,0 +1,128 @@ +{ + "_comment": "WL-813h 보스 패턴 데이터 (쿨 · 예고(차징) · 피해 배율 · 속도 · 페이즈 임계). 로더 = Assets/WL/Combat/Boss/BossPatternTable.cs. 값은 보스 스폰(On_Regen) 시 그 Boss_*.cs 가 선언한 public 필드에 대입된다. 행이 없는 보스는 프리팹 인스펙터 값 그대로(변경 0 · C8).", + "_source": "WL-813b 실측(프리팹 인스펙터 YAML 값). 프리팹에 직렬화되지 않은 필드는 Boss_*.cs 의 코드 기본값을 그대로 적었다(= 런타임 실효값). 문자열 필드(Skill1Indicator 등)는 로더가 숫자만 다루므로 제외.", + "_sot": "WL 전용 파일 — butler.xlsm(Assets/ResWork/Table/Export) 밖이다. 추후 테이블 편입 후보.", + "_rollback": "이 파일을 지우거나 enabled 를 false 로 두면 로더가 무동작 → 프리팹 값 + 원본 임계 0.9 / 0.7 로 100% 복귀.", + "enabled": true, + + "_defaultPhaseThresholds_comment": "페이즈 임계(HP 비율 · 내림차순). 100 / 70 / 40 % = 3페이즈. 보스별로 phaseThresholds 를 적으면 그 값이 우선.", + "defaultPhaseThresholds": [1.0, 0.7, 0.4], + + "_defaultSkillThreshold_comment": "스킬 해금 임계 = 원본 BossMobActor.cs:82-83 의 0.9 / 0.7. 보스별 skill1Threshold / skill2Threshold 로 덮어쓸 수 있다.", + "defaultSkill1Threshold": 0.9, + "defaultSkill2Threshold": 0.7, + + "rows": [ + { + "id": 10001, "name": "Lusif", "script": "Boss_LusifActor", + "note": "813b 실측값 그대로(변경 0).", + "fields": { + "Skill1CoolTime": 8, "Skill1Dmg": 3.5, "Perforation_Line": 0, + "Skill2CoolTime": 1.5, "Skill2Dist": 10, "Skill2ProjectileCount": 5, + "Skill2Delay": 0.8, "Skill2Dmg": 1.5 + } + }, + { + "id": 10002, "name": "Lusif_B", "script": "Boss_LusifB_Actor", + "note": "813b 실측값 그대로(변경 0).", + "fields": { + "Skill1CoolTime": 12, "Skill1Dmg": 3.5, "Skill1ChargingTime": 4, "Perforation_Line": 0, + "Skill2CoolTime": 20, "Skill2Dmg": 5, "Skill2ChargingTime": 4 + } + }, + { + "id": 10003, "name": "Mega_Ice_Golem", "script": "Boss_IceGolem", + "note": "813b 실측값 그대로(변경 0). 인디케이터 이름 2종은 문자열이라 로더 대상 밖 = 프리팹 값 유지.", + "fields": { + "Skill1CoolTime": 8, "Skill1TargetRange": 2, "Skill1Speed": 5, "Skill1Dmg": 3.5, + "Skill1StunTime": 2, "Perforation_Line": 0, + "Skill2CoolTime": 10, "Skill2Speed": 7, "Skill2Dmg": 3.5, + "Skill2KnockBackTime": 0.3, "Skill2StunTime": 1 + } + }, + { + "id": 10004, "name": "Minotos", "script": "Boss_Minotos", + "note": "813b 실측값 그대로(변경 0). Skill2CoolTime 10 은 프리팹 값 — 코드 기본값 25 와 다르다(813b 불일치 1/4).", + "fields": { + "Skill1CoolTime": 15, "Skill1Dmg": 1.2, "Perforation_Line": 0, + "Skill2CoolTime": 10, "Skill2LifeTime": 15, "Skill2MoveSpeed": 0.5, "Skill2AttackSpeed": 0.5 + } + }, + { + "id": 10005, "name": "Golem_Child", "script": "Boss_GolemChild", + "note": "813b 실측값 그대로(변경 0). DmgAfterSkill2 는 프리팹에 직렬화돼 있지 않아 코드 기본값 2 = 런타임 실효값.", + "fields": { + "Skill1CoolTime": 8, "Skill1Dmg": 3.8, "Skill1AreaDmg": 1.2, "Perforation_Line": 0, + "Skill2CoolTime": 8, "Skill2ChargingTime": 1, "Skill2Dmg": 3, "DmgAfterSkill2": 2 + } + }, + { + "id": 10006, "name": "Anubis", "script": "Boss_Anubis", + "note": "첫 보스 후보(813b). 예고(차징) 3 s → 1.2 s 는 발주서 WL-813h §1-2 의 제안값이며 이 파일에서만 바뀐다(프리팹 무변경). 나머지는 실측값. Skill2Dmg 3 은 프리팹 값 — 코드 기본값 2.8 과 다르다(813b 불일치 2/4).", + "fields": { + "Skill1CoolTime": 18, "Skill1Dmg": 2.2, "Skill1ChargingTime": 1.2, "Perforation_Line": 0, + "Skill2CoolTime": 15, "Skill2ChargingTime": 1.2, "Skill2AttackDelay": 1, + "Skill2Dmg": 3, "Skill2Speed": 15 + } + }, + { + "id": 10007, "name": "Queen_Undead", "script": "Boss_QueenUndead", + "note": "813b 실측값 그대로(변경 0). Skill2BackDuration 2 는 프리팹 값 — 코드 기본값 1.5 와 다르다(813b 불일치 3/4).", + "fields": { + "Skill1CoolTime": 6, "Skill1LifeTime": 5, "Skill1Manaburn": 0.02, "Skill1Distance": 15, + "Perforation_Line": 0, + "Skill2CoolTime": 18, "Skill2ChargingTime": 2, "Skill2Dmg": 3.2, + "Skill2BackDist": 7.5, "Skill2BackDuration": 2 + } + }, + { + "id": 10008, "name": "Flower Dryad", "script": "Boss_FlowerDryad", + "note": "813b 실측값 그대로(변경 0). Skill1Heal 0 은 프리팹 값 — 즉시회복이 꺼져 있다(코드 기본값 0.1 · 813b 불일치 4/4 · 켤지는 PD 결정). Skill1ChargingTime · Skill2AirborneTime 은 프리팹 미직렬화라 코드 기본값이 런타임 실효값.", + "fields": { + "Skill1CoolTime": 20, "Skill1ChargingTime": 3, "Skill1Heal": 0, "Skill1FindDist": 3, + "Skill1LifeTime": 6, "Skill1DelayTime": 1, "Skill1PerHeal": 0.01, "Perforation_Line": 0, + "Skill2CoolTime": 18, "Skill2ChargingTime": 3, "Skill2Dmg": 5.2, + "Skill2PoisonTime": 5, "Skill2PoisonGapTime": 1, "Skill2PoisonDmg": 0.3, "Skill2AirborneTime": 1.5 + } + }, + { + "id": 10009, "name": "Angez_B", "script": "Boss_Angez_B", + "note": "813b 실측값 그대로(변경 0). 챕터 보스(Map_C01/02 의 isChapterBoss=1).", + "fields": { + "Skill1CoolTime": 20, "Skill1ChargingTime": 3, "Skill1Dmg": 2.8, "Skill1DelayTime": 0.5, + "Skill1Count": 10, "Skill1StunTime": 0.3, "Perforation_Line": 0, + "Skill2CoolTime": 15, "Skill2ChargingTime": 3, "Skill2Dmg": 1.2, "Skill2StunTime": 0.2 + } + }, + { + "id": 10010, "name": "Angez_C", "script": "Boss_Angez_C", + "note": "813b 실측값 그대로(변경 0). 어느 맵에도 배치돼 있지 않다(813b §1).", + "fields": { + "Skill1CoolTime": 12, "Skill1ChargingTime": 3, "Skill1Dmg": 3.2, "Skill1DelayTime": 0.5, + "Skill1Count": 8, "Perforation_Line": 0, + "Skill2CoolTime": 18, "Skill2ChargingTime": 3, "Skill2Dmg": 1.5 + } + }, + { + "id": 10011, "name": "Brother_Penguin_C", "script": "Boss_BrotherPenguin", + "note": "813b 실측값 그대로(변경 0). Skill1KnockBackTime · Skill1KnockBackSpeed · Skill2Dmg · Skill2ChargingTime 은 프리팹 미직렬화라 코드 기본값이 런타임 실효값. 어느 맵에도 배치돼 있지 않다.", + "fields": { + "Skill1CoolTime": 12, "Skill1ChargingTime": 3, "Skill1Dmg": 3.2, "Skill1FrostTime": 3, + "Skill1AtkSpd": 0.7, "Skill1MovSpd": 0.7, "Skill1KnockBackTime": 0.5, "Skill1KnockBackSpeed": 4, + "Perforation_Line": 0, + "Skill2CoolTime": 20, "Skill2Dmg": 5, "Skill2LifeTime": 12, "Skill2BarrierRate": 0.25, + "Skill2ChargingTime": 3 + } + }, + { + "id": 10012, "name": "Shaga_B", "script": "Boss_Shaga", + "note": "813b 실측값 그대로(변경 0). Skill2SummonMobID 176 은 소환몹 ID(MonsterList) — 값을 바꾸면 소환 대상이 바뀐다. 어느 맵에도 배치돼 있지 않다.", + "fields": { + "Skill1CoolTime": 18, "Skill1Dmg": 1.6, "Skill1FrostTime": 6, + "Skill1AtkSpd": 0.5, "Skill1MovSpd": 0.5, "Perforation_Line": 0, + "Skill2CoolTime": 20, "Skill2ChargingTime": 3, "Skill2SummonLifeTime": 12, + "Skill2SummonMobID": 176, "Skill2SummonCount": 2, "Skill2StatRate": 0.5 + } + } + ] +} diff --git a/Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json.meta b/Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json.meta new file mode 100644 index 000000000..2c2173206 --- /dev/null +++ b/Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 1f4771cdbcdfb5d4a967adf208bdee6b +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: