// ───────────────────────────────────────────────────────────────────────────── // 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; } } }