Project_WL/Assets/WL/Combat/Boss/BossPatternTable.cs

387 lines
20 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>보스 1종의 패턴 행. 값이 없는 키는 건드리지 않는다(프리팹 값 유지).</summary>
[Serializable]
public class BossPatternRow
{
/// <summary>MonsterList.n_MonsterID (10001~10012).</summary>
public int id;
/// <summary>읽는 사람용 이름 · 대상 스크립트 · 메모(로직에 쓰지 않는다).</summary>
public string name, script, note;
/// <summary>페이즈 임계(내림차순 · 예 [1.0, 0.7, 0.4]). null 이면 테이블 기본값.</summary>
public float[] phaseThresholds;
/// <summary>스킬1/스킬2 해금 HP 비율. null 이면 테이블 기본값(원본 0.9 / 0.7).</summary>
public float? skill1Threshold, skill2Threshold;
/// <summary>Boss_*.cs 가 선언한 public 필드 이름 → 값(float · int 는 반올림 · bool 은 0 아니면 true).</summary>
public Dictionary<string, float> fields;
}
/// <summary>JSON 파일 전체.</summary>
[Serializable]
public class BossPatternFile
{
public string _comment, _source, _sot;
/// <summary>이 파일 자체의 스위치(false 면 로더 무동작 = 프리팹 값 · C8).</summary>
public bool enabled = true;
/// <summary>행에 phaseThresholds 가 없을 때 쓰는 기본 임계.</summary>
public float[] defaultPhaseThresholds;
/// <summary>행에 임계가 없을 때 쓰는 스킬 해금 기본값 = 원본 BossMobActor.cs:82-83 값.</summary>
public float defaultSkill1Threshold = 0.9f;
public float defaultSkill2Threshold = 0.7f;
public List<BossPatternRow> rows;
}
/// <summary>보스 패턴 JSON 로더 + 페이즈 판정. 정적 · 새 Manager 0.</summary>
public static class BossPatternTable
{
/// <summary>Resources 경로(확장자 없음) — Assets/WL/Combat/Settings/Resources/WL/WL813h_BossPattern.json</summary>
public const string ResourcesPath = "WL/WL813h_BossPattern";
/// <summary>테이블 기본 임계(JSON 에 defaultPhaseThresholds 가 없을 때) = 100 / 70 / 40 %.</summary>
static readonly float[] kFallbackPhases = { 1f, 0.7f, 0.4f };
/// <summary>임계 비교 허용 오차(부동소수 누적 오차용 · 게임 데이터 아님).</summary>
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;
/// <summary>JSON(1회 로드 후 캐시). 없거나 파싱 실패면 null.</summary>
public static BossPatternFile File
{
get
{
if (!s_lookupDone)
{
s_lookupDone = true;
var ta = Resources.Load<TextAsset>(ResourcesPath);
if (ta == null) { LoadError = "TextAsset not found: " + ResourcesPath; return null; }
try { s_file = JsonConvert.DeserializeObject<BossPatternFile>(ta.text); }
catch (Exception ex)
{
ParseFailed = true; LoadError = ex.Message; s_file = null;
Debug.LogError("[BossPatternTable] JSON 파싱 실패 → 원본 동작 유지(C8): " + ex.Message);
}
}
return s_file;
}
}
/// <summary>로더가 살아 있는가 = 코어 ON · JSON 있음 · JSON enabled.</summary>
public static bool Enabled
{
get { var f = WLCombatCoreSettings.Enabled ? File : null; return f != null && f.enabled && f.rows != null; }
}
/// <summary>행 수(로드 실패 시 0).</summary>
public static int RowCount { get { var f = File; return f != null && f.rows != null ? f.rows.Count : 0; } }
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
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);
}
/// <summary>보스 ID 의 행(없으면 null). 12행 선형 탐색 — 할당 0.</summary>
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;
}
/// <summary>이 보스의 페이즈 임계(행 없음/키 없음 → 테이블 기본 → [1, 0.7, 0.4]).</summary>
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;
}
/// <summary>스킬1/스킬2 해금 임계(행 없음/키 없음 → 원본 0.9 / 0.7).</summary>
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줄)
/// <summary>
/// [훅 1/3] BossMobActor.On_Regen — 스폰 시 JSON 행을 이 보스 인스턴스의 public 필드에 적용하고
/// 페이즈 상태를 초기화한 뒤 페이즈 1 진입을 통지한다.
/// 반환 = 적용된 필드 수(코어 off · JSON 없음 · 행 없음이면 -1 = 무동작).
/// </summary>
public static int Apply(BossMobActor boss, MonsterTableData mobtable)
{
return ApplyById(boss, mobtable != null ? mobtable.n_MonsterID : 0);
}
/// <summary>
/// [훅 2/3] BossMobActor.Get_Damage — 스킬 해금 임계를 데이터 값으로 확정한다.
/// 행이 없으면 원본 두 줄이 계산해 둔 값을 그대로 둔다(C8). 행이 있으면 데이터가 최종 권한.
/// </summary>
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);
}
/// <summary>[훅 3/3] BossMobActor.Get_Damage — HP% 가 페이즈 임계를 내려갔으면 BossPhase 를 통지한다.</summary>
public static void UpdatePhase(BossMobActor boss, MonsterTableData mobtable, float hpPercent)
{
UpdatePhaseById(boss, mobtable != null ? mobtable.n_MonsterID : 0, hpPercent);
}
// ───────────────────────────────────────── ID 직접 지정판 (프로브 · 에디트 모드)
/// <summary>행 값을 보스 인스턴스에 적용. 반환 = 적용된 필드 수(-1 = 무동작).</summary>
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;
}
/// <summary>스킬 해금 임계 적용(ID 직접 지정).</summary>
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;
}
/// <summary>페이즈 판정 + 통지(ID 직접 지정). 반환 = 새 페이즈(변화 없으면 현재 페이즈).</summary>
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;
}
/// <summary>HP 비율에 해당하는 페이즈(1부터). 임계는 내림차순 배열.</summary>
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;
}
/// <summary>현재 페이즈(상태가 없으면 0).</summary>
public static int CurrentPhase(BossMobActor boss)
{
int idx = StateIndex(boss, 0, false);
return idx >= 0 ? s_states[idx].phase : 0;
}
/// <summary>
/// 프로브/롤백용 — Apply 가 덮어쓰기 전에 캐시해 둔 원본(프리팹) 값으로 되돌린다.
/// 반환 = 되돌린 필드 수(캐시 없으면 -1).
/// </summary>
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;
}
/// <summary>페이즈/해금 래치 초기화(스폰·풀 재사용 시).</summary>
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; }
/// <summary>
/// 동시 보스 수 상한. 813b 실측 = Map_C01/C02 에 FieldBossData 9개씩(WL_Nature 는 0) → 여유 16칸.
/// 넘치면 가장 오래된 칸을 재사용한다(페이즈 상태만 잃고 전투는 그대로).
/// </summary>
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<Type, Dictionary<string, FieldInfo>> s_fieldCache =
new Dictionary<Type, Dictionary<string, FieldInfo>>();
static readonly Dictionary<int, float[]> s_originals = new Dictionary<int, float[]>();
/// <summary>그 Boss_* 클래스가 **직접 선언한** public float/int/bool 필드만(상속 필드는 제외 = 사고 방지).</summary>
static Dictionary<string, FieldInfo> FieldsOf(Type t)
{
Dictionary<string, FieldInfo> map;
if (s_fieldCache.TryGetValue(t, out map)) return map;
map = new Dictionary<string, FieldInfo>();
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<string, FieldInfo> 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;
}
}
}