// ───────────────────────────────────────────────────────────────────────────── // WLStatus.cs — WL 상태이상 7종 (#842 · 2026-10-11 · PD 「Master Stylized FX 로 스턴·속도 증가/저하·수면·중독·출혈·능력치 상승 구현」) // // ■ 원칙 — 원본(힘민지) CC 체계를 그대로 쓴다. 훅은 ProjectileBase.ProcessCollider 1줄(BeforeDamage · 수면 치명타 규칙)뿐. // · 행동 불능(스턴·수면) = 원본 Actor.Stun → dic_ccData[Stun] → IsCC() 가 이동·피격 모션·넉백·경직을 막는다(피격받아도 유지 = 원본 규칙). // · 수면 = 스턴 CC 슬롯 + 이 쪽 플래그(Sleeping). 직접 타격(ProjectileBase)만 깨우고 도트는 깨우지 않는다(PD 결정 2026-10-11). // 깨우는 타격 = 100 % 치명 + 치명 피해 ×sleepCritDamageMultiplier(기본 2). // · 중독(고정값/초)·출혈(최대 HP %/초) = 이 쪽 코루틴이 Actor.Cal_Damage 로 넣는다(원본 DotDamage 는 공격자 피해 비율이라 사양과 다름). // CC 슬롯(Poison·Blooding)도 함께 켜 두어 원본 스킬이 거는 독·출혈과 같은 길로 VFX 가 뜬다. // · 속도 증가/저하·능력치 상승 = 원본 CC 타이머(ATKSPD_Up … Cri_Up)의 상승 순간을 러너가 감지 → 1회 VFX + 텍스트 1.5 s 페이드. 원본 버프 코드 무수정. // ■ 트리거 — triggers[] (classIds × 콤보 단계) : HitConfirmed 에서 넉백(ignorePreknockback) 뒤 상태 적용. 첫 항목 = 해머 1타 → 넉백 + 스턴 0.5 s. // ■ 값·프리팹 이름·문구 = Resources/WL/WLStatus.json (C45 · 코드 상수 0). enabled=false 면 전부 무동작(C8). // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Status { public enum WLStatusType { Stun, Sleep, Poison, Bleed, SpeedUp, SpeedDown, StatUp } public sealed class WLStatusVfx { public string prefab; public string loc = "Top"; public bool loop; public float scale = 1f; public float yOffset; } public sealed class WLStatusTrigger { public int[] classIds; public int stage = 1; public string status = "Stun"; public float duration = 0.5f; public float value; public float knockbackSeconds; public float knockbackSpeed = 4f; public bool stunAfterKnockback = true; public float knockbackMaxWait = 0.6f; } public sealed class WLStatusSettings { public bool enabled = true; public float vfxScale = 1f; /// 크기 기준 키(m) — 액터 키(Top−발)÷이 값을 VFX 크기에 곱한다(PD 「몬스터 크기에 맞게」). 0 이면 비례 없음. public float referenceHeight = 1f; public float sizeFactorMin = 0.5f, sizeFactorMax = 3f; public float oneShotSeconds = 1f; public float textSeconds = 1.5f; public float textYOffset = 0.15f; /// 월드 라벨 글자 크기(TextMeshPro fontSize · 섬 몹 라벨 Lv 는 2.4) · 상승 속도(m/s) · 투명해지기 시작하는 비율. public float textWorldSize = 2.2f, textRiseSpeed = 0.35f, textFadeStart = 0.5f; public string buffTextColor = "", debuffTextColor = "", poisonTextColor = "", bleedTextColor = ""; public float dotIntervalSeconds = 1f; public float sleepCritDamageMultiplier = 2f; public Dictionary vfx = new Dictionary(); public Dictionary ccTexts = new Dictionary(); public List triggers = new List(); } public static class WLStatus { public const string JsonPath = "WL/WLStatus"; static WLStatusSettings s_settings; static bool s_loaded; public static WLStatusSettings S { get { if (!s_loaded) Load(); return s_settings; } } public static bool Enabled { get { var s = S; return s != null && s.enabled; } } public static void Reload() { s_loaded = false; } static void Load() { s_loaded = true; var ta = Resources.Load(JsonPath); try { s_settings = ta != null ? Newtonsoft.Json.JsonConvert.DeserializeObject(ta.text) : null; } catch (System.Exception ex) { Debug.LogException(ex); s_settings = null; } } // ── 진단(프로브가 읽는다) public static int Applies, Texts, DotTicks, SleepWakes, SleepCritHits, TriggerFires, Skipped, LastStage; public static string LastLog = "", LastSleepHit = ""; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.HitConfirmed.Add(OnHitConfirmed); CombatEvents.Spawned.Add(OnSpawned); } static void OnSpawned(in SpawnedEvent e) { if (Enabled && e.actor != null) WLStatusRunner.Ensure(e.actor); } static void OnHitConfirmed(in HitConfirmedEvent e) { if (!Enabled || e.victim == null || e.attacker == null) return; WLStatusRunner.Ensure(e.victim); // 스폰 이벤트 전에 있던 몹도 피격 때 러너를 받는다 if (e.isKill) return; var s = S; if (s.triggers == null || s.triggers.Count == 0) return; int cls = e.attacker.Get_ID(); // 콤보 단계 = ComboTracker(Animator 상태 클립 이름 Attack{N}) · WL 콤보는 Animator 전이로 이어져 CurAnim 이 1타에 머문다(2026-10-11 실측 · 모든 타에 발동하던 원인). 트래커가 없으면 CurAnim 으로. var ct = e.attacker.GetComponent(); int stage = ct != null && ct.LastStage > 0 ? ct.LastStage : (int)e.attacker.Get_CurAnim() - (int)eAnim.Attack + 1; LastStage = stage; for (int i = 0; i < s.triggers.Count; i++) { var t = s.triggers[i]; if (t == null || t.classIds == null || t.stage != stage) continue; bool hit = false; for (int k = 0; k < t.classIds.Length; k++) if (t.classIds[k] == cls) { hit = true; break; } if (!hit) continue; Fire(t, e.attacker, e.victim); } } static void Fire(WLStatusTrigger t, Actor attacker, Actor victim) { if (victim.IsDead()) return; if (t.knockbackSeconds > 0f) { victim.KnockBack(t.knockbackSeconds, t.knockbackSpeed, null, true); // 원본 넉백(이미 밀리는 중이어도 다시) — 스턴 전에 걸어야 IsCC 에 안 막힌다 var kb = victim.Get_CCData(eCC.KnockBack); if (kb.CCTime > 0d && kb.CCTime < t.knockbackSeconds) kb.CCTime = t.knockbackSeconds; // 원본의 연속 넉백 감쇠(CCReduceTime · 최소 0.1 s)를 이 트리거엔 적용하지 않는다(해머 1타는 매번 같은 거리). WL 절반 규칙(MobKnockbackScale)은 그대로 받는다. } WLStatusType type; if (!System.Enum.TryParse(t.status, true, out type)) return; if (t.knockbackSeconds > 0f && t.stunAfterKnockback && (type == WLStatusType.Stun || type == WLStatusType.Sleep)) { // #843 PD 「스턴 중 이동 금지」 — 밀려나는 동안엔 스턴을 걸지 않고, 넉백이 끝난 자리에서 스턴 시작 var r = WLStatusRunner.Ensure(victim); if (r != null) r.ApplyAfterKnockback(type, t.duration, t.value, attacker, t.knockbackMaxWait); } else Apply(victim, type, t.duration, t.value, attacker); TriggerFires++; LastLog = "trigger " + t.status + " " + t.duration + "s kb=" + t.knockbackSeconds + " → " + victim.name + " by " + attacker.name + " cls " + attacker.Get_ID(); } /// 상태 적용 API. duration 초 · value = 독 고정 피해/초 · 출혈 최대 HP 비율/초 · 속도·능력치 배율. 성공하면 true. public static bool Apply(Actor target, WLStatusType type, float duration, float value = 0f, Actor source = null) { if (!Enabled || target == null || target.IsDead()) { Skipped++; return false; } var r = WLStatusRunner.Ensure(target); if (r == null) { Skipped++; return false; } r.Apply(type, duration, value, source); Applies++; return true; } /// ProjectileBase.ProcessCollider 훅 — 직접 타격이 수면 중인 대상에 닿기 직전. 100 % 치명 + 치명 피해 배율 뒤 즉시 깨운다. /// #1001 0 이상이면 이번 직접 타격 피해(Damage · NoCriDmg · PiercingDmg)에 곱한다 — 대검 기본 회전 연타(WLStaffEarth spinDamage)가 판정 동안만 켠다(그 밖 = −1). public static float DamageScale = -1f; public static DamageInfo BeforeDamage(Actor victim, DamageInfo dinfo) { if (DamageScale >= 0f && dinfo != null) { dinfo.Damage *= DamageScale; dinfo.NoCriDmg *= DamageScale; dinfo.PiercingDmg *= DamageScale; } if (!Enabled || victim == null || dinfo == null) return dinfo; var r = victim.GetComponent(); if (r != null && r.Sleeping) r.ApplySleepHit(dinfo); return dinfo; } public static bool IsSleeping(Actor a) { var r = a != null ? a.GetComponent() : null; return r != null && r.Sleeping; } } /// 메인 PC 에 러너를 보장한다(씬 전환·캐릭터 교체 대응 · WLBowChargeBoot 와 같은 0.5 s 폴링). public sealed class WLStatusBoot : MonoBehaviour { float _next; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (FindFirstObjectByType() != null) return; var go = new GameObject("WL_StatusBoot"); DontDestroyOnLoad(go); go.AddComponent(); } void Update() { if (Time.unscaledTime < _next) return; _next = Time.unscaledTime + 0.5f; if (!WLStatus.Enabled) return; var pc = MyValue.MyPC; if (pc != null) WLStatusRunner.Ensure(pc); } } }