// ───────────────────────────────────────────────────────────────────────────── // WLStatusRunner.cs — 액터 1명의 상태이상 실행기 (#842). WLStatus.Ensure 가 PC·몹에 런타임 부착(프리팹 무수정). // · 매 프레임 원본 CC 타이머(Get_CCData) 상승/하강 순간만 감지 → VFX 시작/정지 · 버프/디버프 텍스트. // · 수면 플래그 · 중독/출혈 도트 코루틴 · 출혈 1초 반복 VFX. // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; namespace WL.Combat.Status { [DisallowMultipleComponent] public sealed class WLStatusRunner : MonoBehaviour { Actor _actor; public bool Sleeping { get; private set; } float _sleepUntil; // 감시 CC — 앞 3개는 루프 VFX(스턴/수면 · 중독 · 출혈), 나머지는 1회 VFX + 텍스트 static readonly eCC[] s_watch = { eCC.Stun, eCC.Poison, eCC.Blooding, eCC.ATKSPD_Up, eCC.MOVSPD_Up, eCC.ATKSPD_Down, eCC.MOVSPD_Down, eCC.ATK_Up, eCC.STR_Mul_Up, eCC.DEX_Mul_Up, eCC.INT_Mul_Up, eCC.LUK_Mul_Up, eCC.Cri_Up }; readonly bool[] _was = new bool[s_watch.Length]; WLStatusFX.Handle _stunFx, _poisonFx; float _nextBleedFx; Coroutine _poisonCo, _bleedCo; readonly List _rising = new List(4); // 도트 피해용 가짜 투사체 데이터 — Actor.Cal_Damage 는 Beater_pd 가 Skill_Ignited 면 피격 모션(Play_Hit)을 건너뛴다(Actor.cs:606). // 매초 몸이 움찔하지 않게 하려는 것뿐 · CC_AddVal2 = 0 이라 사망 분기(:589)의 버닝 전이는 타지 않는다. static ProjectileData s_dotPd; public string Info { get { if (_actor == null) return "no actor"; return _actor.name + " sleep=" + Sleeping + " stun=" + _actor.Get_CCData(eCC.Stun).CCTime.ToString("F2") + " poison=" + _actor.Get_CCData(eCC.Poison).CCTime.ToString("F2") + " blood=" + _actor.Get_CCData(eCC.Blooding).CCTime.ToString("F2") + " aspdUp=" + _actor.Get_CCData(eCC.ATKSPD_Up).CCTime.ToString("F2") + " mspdDown=" + _actor.Get_CCData(eCC.MOVSPD_Down).CCTime.ToString("F2") + " atkUp=" + _actor.Get_CCData(eCC.ATK_Up).CCTime.ToString("F2") + " isCC=" + _actor.IsCC() + " fx(stun=" + (_stunFx != null && _stunFx.Active) + " poison=" + (_poisonFx != null && _poisonFx.Active) + ")" + " dot(p=" + (_poisonCo != null) + " b=" + (_bleedCo != null) + ") hp=" + _actor.Get_HP().ToString("F0") + "/" + _actor.Get_MaxHP().ToString("F0") + " anim=" + _actor.Get_CurAnim() + " height=" + (_actor.Get_Top_postion().y - _actor.Get_position().y).ToString("F2") + " sizeK=" + WLStatusFX.SizeFactor(_actor, WLStatus.S).ToString("F2"); } } public static WLStatusRunner Ensure(Actor a) { if (a == null) return null; var r = a.GetComponent(); if (r == null) { r = a.gameObject.AddComponent(); r._actor = a; } return r; } void Awake() { if (_actor == null) _actor = GetComponent(); } // 같은 상태가 또 들어오면 남은 시간과 새 시간 중 긴 쪽으로(PD 「시간을 새로 발생된 시간에 맞게 늘린다」 · 짧은 재적용이 긴 스턴을 끊지 않게) float _stunEndAt, _poisonEndAt, _bleedEndAt; public void Apply(WLStatusType type, float duration, float value, Actor source) { var s = WLStatus.S; if (s == null || _actor == null || _actor.IsDead()) return; float now = Time.time; switch (type) { case WLStatusType.Stun: Sleeping = false; if (_actor.Get_CCData(eCC.Stun).CCTime > 0d && _stunEndAt - now > duration) { RefreshStunLoop(); break; } // 더 긴 행동 불능이 남아 있다 — 유지 _stunEndAt = now + duration; _actor.Stun(duration, false); RefreshStunLoop(); break; case WLStatusType.Sleep: Sleeping = true; if (_actor.Get_CCData(eCC.Stun).CCTime > 0d && _stunEndAt - now > duration) { _sleepUntil = Mathf.Max(_sleepUntil, _stunEndAt); RefreshStunLoop(); break; } _sleepUntil = _stunEndAt = now + duration; _actor.Stun(duration, false); RefreshStunLoop(); break; case WLStatusType.Poison: _poisonEndAt = Mathf.Max(_poisonEndAt, now + duration); _actor.Buff_or_Debuff(eCC.Poison, _poisonEndAt - now, value); StartDot(ref _poisonCo, _poisonEndAt - now, value, source, false); break; case WLStatusType.Bleed: _bleedEndAt = Mathf.Max(_bleedEndAt, now + duration); _actor.Buff_or_Debuff(eCC.Blooding, _bleedEndAt - now, value); StartDot(ref _bleedCo, _bleedEndAt - now, value, source, true); break; case WLStatusType.SpeedUp: _actor.Buff_or_Debuff(eCC.ATKSPD_Up, duration, value); _actor.Buff_or_Debuff(eCC.MOVSPD_Up, duration, value); break; case WLStatusType.SpeedDown: _actor.Buff_or_Debuff(eCC.ATKSPD_Down, duration, value); _actor.Buff_or_Debuff(eCC.MOVSPD_Down, duration, value); break; case WLStatusType.StatUp: _actor.Buff_or_Debuff(eCC.ATK_Up, duration, value); break; } WLStatus.LastLog = "apply " + type + " " + duration + "s v=" + value + " → " + _actor.name; } /// 넉백(원본 KnockBack CC)이 끝난 뒤에 상태를 건다 — 밀리는 동안 스턴이 겹치지 않게(#843). maxWait 초 안에 안 끝나면 그때 건다. public void ApplyAfterKnockback(WLStatusType type, float duration, float value, Actor source, float maxWait) { if (_afterKb != null) StopCoroutine(_afterKb); _afterKb = StartCoroutine(CoAfterKnockback(type, duration, value, source, maxWait)); } Coroutine _afterKb; IEnumerator CoAfterKnockback(WLStatusType type, float duration, float value, Actor source, float maxWait) { float until = Time.time + Mathf.Max(0.05f, maxWait); yield return null; // KnockBack 코루틴이 CCTime 을 세우는 프레임을 넘긴다 while (Time.time < until && _actor != null && !_actor.IsDead() && _actor.Get_CCData(eCC.KnockBack).CCTime > 0d) yield return null; _afterKb = null; if (_actor != null && !_actor.IsDead()) { Apply(type, duration, value, source); WLStatus.Applies++; } } /// 수면 중 직접 타격 — 100 % 치명 + 치명 피해 배율, 그리고 즉시 기상. public void ApplySleepHit(DamageInfo dinfo) { var s = WLStatus.S; var sh = dinfo.Beater; if (sh != null && sh.Get_StatInfo() != null) { var st = sh.Get_StatInfo(); dinfo.Damage = dinfo.NoCriDmg; dinfo.Cal_Cri(sh.m_AttackType, st, true); // forceCri = 원본 파라미터 — 치명 피해 1배분 가산 + 텍스트 색 double mul = sh.m_AttackType == eAttackType.Magic ? st.Get_Stat(eStat.M_CRI_DMG_Multiplier) : st.Get_Stat(eStat.CRI_DMG_Multiplier); dinfo.Damage += dinfo.NoCriDmg * mul * Mathf.Max(0f, s.sleepCritDamageMultiplier - 1f); // 나머지 배율분 WLStatus.SleepCritHits++; WLStatus.LastSleepHit = "sleep-hit " + _actor.name + " cri=" + dinfo.Critical + " nocri=" + dinfo.NoCriDmg.ToString("F0") + " dmg=" + dinfo.Damage.ToString("F0") + " mul=" + mul.ToString("F2"); } Wake("hit"); } public void Wake(string why) { if (!Sleeping) return; Sleeping = false; if (_actor != null && !_actor.IsDead()) { _actor.Get_CCData(eCC.Stun).CCTime = 0d; // CCTime 0 → IsCC 해제(TimeUpdate 는 건드리지 않는다). 원본 Co_Stun 은 남은 시간 뒤 0 을 다시 쓰고 에이전트를 켠다(무해). _actor.AnimationPlay(MyValue.Get_AnimName(eAnim.Idle)); } WLStatusFX.Stop(ref _stunFx); WLStatus.SleepWakes++; WLStatus.LastLog = "wake(" + why + ") " + (_actor != null ? _actor.name : "?"); } void RefreshStunLoop() { if (_actor == null || _actor.Get_CCData(eCC.Stun).CCTime <= 0d) return; // 아직 안 켜졌으면 다음 Update 의 상승 에지가 켠다 StartStunLoop(); _was[0] = true; } /// 스턴/수면 루프 — 종류가 바뀌면(수면↔스턴) 이전 것을 끄고, 같으면 WLStatusFX 가 중복 없이 이어 간다. void StartStunLoop() { var want = Sleeping ? WLStatusType.Sleep : WLStatusType.Stun; if (_stunFx != null && _stunFx.Active && _stunFx.type != want) WLStatusFX.Stop(ref _stunFx); _stunFx = WLStatusFX.Play(_actor, want, true); } // ── 도트 ───────────────────────────────────────────────────────────── void StartDot(ref Coroutine co, float duration, float value, Actor source, bool percentOfMaxHp) { if (co != null) StopCoroutine(co); co = StartCoroutine(CoDot(duration, value, source, percentOfMaxHp)); } IEnumerator CoDot(float duration, float value, Actor source, bool pct) { var s = WLStatus.S; float gap = Mathf.Max(0.1f, s.dotIntervalSeconds); float end = Time.time + duration; var wait = new WaitForSeconds(gap); while (Time.time < end) { yield return wait; if (_actor == null || _actor.IsDead()) break; double dmg = pct ? _actor.Get_MaxHP() * value : value; if (dmg < 1d) dmg = 1d; Actor beater = source != null && !source.IsDead() ? source : (MyValue.MyPC != null ? (Actor)MyValue.MyPC : _actor); if (s_dotPd == null) s_dotPd = new ProjectileData { m_SkillListTableData = new SkillListTableData { e_Skill = eEffect.Skill_Ignited } }; var dinfo = new DamageInfo { Beater = beater, Beater_pd = s_dotPd, BaseDmg = dmg, NoCriDmg = dmg, Damage = dmg, TextColor = pct ? s.bleedTextColor : s.poisonTextColor, Critical = eStat.None }; _actor.Cal_Damage(dinfo); WLStatus.DotTicks++; if (pct) { WLStatusFX.PlayOneShot(_actor, WLStatusType.Bleed); _nextBleedFx = Time.time + gap; } } if (pct) _bleedCo = null; else _poisonCo = null; } // ── 매 프레임: CC 에지 감지 ─────────────────────────────────────────── void Update() { if (_actor == null) return; var s = WLStatus.S; if (s == null || !s.enabled) return; bool dead = _actor.IsDead(); _rising.Clear(); bool speedUp = false, speedDown = false, statUp = false; for (int i = 0; i < s_watch.Length; i++) { var cc = s_watch[i]; bool now = !dead && _actor.Get_CCData(cc).CCTime > 0d; bool was = _was[i]; _was[i] = now; if (now == was) continue; if (now) OnRise(cc, ref speedUp, ref speedDown, ref statUp); else OnFall(cc); } if (speedUp) WLStatusFX.PlayOneShot(_actor, WLStatusType.SpeedUp); if (speedDown) WLStatusFX.PlayOneShot(_actor, WLStatusType.SpeedDown); if (statUp) WLStatusFX.PlayOneShot(_actor, WLStatusType.StatUp); if (_rising.Count > 0) ShowText(string.Join(" · ", _rising), speedDown && !speedUp && !statUp ? s.debuffTextColor : s.buffTextColor); // 출혈 = 매초 반복 재생(원본 스킬 Blooding 도 같은 길 · WL 도트는 틱에서 직접 재생) if (!dead && _bleedCo == null && _actor.Get_CCData(eCC.Blooding).CCTime > 0d && Time.time >= _nextBleedFx) { _nextBleedFx = Time.time + Mathf.Max(0.1f, s.dotIntervalSeconds); WLStatusFX.PlayOneShot(_actor, WLStatusType.Bleed); } if (Sleeping && (dead || Time.time >= _sleepUntil + 0.05f)) Sleeping = false; // 자연 종료(깨움 규칙 없음) // PD 「피격을 받아도 스턴 유지」 — 원본은 공격 쪽 부가효과(KnockBack_byHitter 끝의 Stun(val2) · 스턴 확률 적중 등)가 Stun() 을 다시 불러 // 남은 시간을 짧은 값으로 덮어쓴다(2026-10-11 실측: 해머 타격 뒤 3 s 스턴이 0.3 s 안에 0). WL 이 건 스턴/수면은 남은 시간이 있으면 되살린다. if (!dead && _stunEndAt > Time.time + 0.05f && _actor.Get_CCData(eCC.Stun).CCTime <= 0d) { _actor.Stun(_stunEndAt - Time.time, false); Reasserts++; if (_stunFx == null || !_stunFx.Active) StartStunLoop(); } } public static int Reasserts; void OnRise(eCC cc, ref bool speedUp, ref bool speedDown, ref bool statUp) { var s = WLStatus.S; switch (cc) { case eCC.Stun: StartStunLoop(); break; case eCC.Poison: _poisonFx = WLStatusFX.Play(_actor, WLStatusType.Poison, true); // 이미 떠 있으면 WLStatusFX 가 같은 인스턴스를 이어 간다 break; case eCC.Blooding: _nextBleedFx = 0f; break; case eCC.ATKSPD_Up: case eCC.MOVSPD_Up: speedUp = true; AddText(s, cc); break; case eCC.ATKSPD_Down: case eCC.MOVSPD_Down: speedDown = true; AddText(s, cc); break; default: statUp = true; AddText(s, cc); break; } } void OnFall(eCC cc) { if (cc == eCC.Stun) { WLStatusFX.Stop(ref _stunFx); Sleeping = false; } else if (cc == eCC.Poison) WLStatusFX.Stop(ref _poisonFx); } void AddText(WLStatusSettings s, eCC cc) { string t; if (s.ccTexts != null && s.ccTexts.TryGetValue(cc.ToString(), out t) && !string.IsNullOrEmpty(t) && !_rising.Contains(t)) _rising.Add(t); } void ShowText(string text, string colorTag) { var s = WLStatus.S; WLStatusText.Show(_actor, text, colorTag, s.textSeconds, s.textWorldSize, s.textYOffset, s.textRiseSpeed, s.textFadeStart); // 같은 문구가 떠 있으면 안에서 연장 WLStatus.Texts++; WLStatus.LastLog = "text \"" + text + "\" → " + _actor.name; } void OnDisable() { WLStatusFX.Stop(ref _stunFx); WLStatusFX.Stop(ref _poisonFx); if (_poisonCo != null) { StopCoroutine(_poisonCo); _poisonCo = null; } if (_bleedCo != null) { StopCoroutine(_bleedCo); _bleedCo = null; } Sleeping = false; for (int i = 0; i < _was.Length; i++) _was[i] = false; } } }