// ───────────────────────────────────────────────────────────────────────────── // WLDodgeStyle.cs — 더블 탭 회피의 동작 6가지 = 신발 기본 효과 (#988 · 2026-10-30) // // PD 원문: 「더블 클릭으로 회피 시 동작을 6가지 형태로 구현해줘. (신발 기본 효과로 사용할 예정이며, 인벤토리에 신발 6종을 배치해서 확인할 수 있게 해줘.) // * 한바퀴 돌며 지정한 방향으로 회전하며 회피(현재 상태) * 몸을 둥글게 말며 굴러 지정한 방향으로 회피 * 지정한 방향으로 즉시 순간이동 // * 지정한 방향과 관계 없이 현재 방형의 반대 방향으로 백덤블링하며 회피 * 회피하지 않고 짧게 실드 이펙트가 발생하며 피해 무효(무적) // * 회피하지 않고 즉시 적의 반대 방향으로 잔상과 함께 빠르게 이동」 // // ■ 고르기 — 장착한 신발 GroupNo → WLDodgeStyle.json shoes 행의 style · 신발 없음/표에 없음 = defaultStyle // ■ 동작 (spin · roll · blink · backflip 은 원본 PCActor.Play_Dodge 로 회피 상태 · 회피 쿨타임 · 흙먼지를 그대로 쓰고 위를 덮는다) // spin = 원본 그대로(탭 방향으로 돌아 dodge 상태 클립 · 3 m) // roll = spin 과 같은 이동 + 클립 덮어쓰기(PlayableGraph · rollClip 구간) — 회피 상태가 끝나면 덮어쓰기도 끝 // blink = 탭 방향 blinkDistance m 를 NavMesh 위로 즉시 Warp · 양 끝 blinkFx(원본 Effect_Teleport 의 WL 사본 · blinkFxHold 뒤 blinkFxFade 초에 투명 소멸 #989) · 흙먼지 없음 · 회피 상태는 다음 프레임에 끝 // backflip = 탭 방향 무시 · 바라보는 방향 그대로 등 뒤로 flipDistance m · flipClip 을 거꾸로(flipReverse) 덮어쓰기 + 몸(visualChild) 포물선 flipHeight m // shield = 이동 · 동작 없음 · shieldFx(원본 Effect_ManaShield) shieldSeconds 초 + 그동안 피해 무효(MyActor.Get_Damage 훅 1줄 · 셈 = ShieldBlocked) // ghost = 탭 방향 무시 · 공격 중인 대상(1순위) · 가장 가까운 적(2순위)의 반대 방향 ghostDistance m 를 ghostSeconds 초에 · 둘 다 없으면 바라보는 방향 // ghostNoTargetDistance m 이내 전진(#989) · 구르기 대신 ghostClip / ghostForwardClip 구간 · 잔상(DashAfterImage) ghostCount 개 // ■ 값 = Resources/WL/WLDodgeStyle.json · 클립 = Resources/WL/WLDodgeClipBank.asset(이름으로 찾기) (C45) · 원본 수정 = MyActor 훅 1줄 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Reflection; using UnityEngine; using UnityEngine.AI; using UnityEngine.Animations; using UnityEngine.Playables; using WL.Equip; namespace WL.Combat { [System.Serializable] public class WLDodgeShoe { public int group; public string style = "spin"; public string note = ""; } [System.Serializable] public class WLDodgeStyleJson { public int enabled = 1; public string defaultStyle = "spin"; public WLDodgeShoe[] shoes = new WLDodgeShoe[0]; public int grantShoes = 1; // 1 = 시작 지급(WLStarterKit · 인벤토리만 · 장착 안 함) public string clipBank = "WL/WLDodgeClipBank"; public string visualChild = "Group"; // 포물선 · 회전을 줄 몸 묶음(PC 루트 바로 밑 · 애니메이터는 루트에 있어 이 묶음은 안 움직인다) public float accel = 200f; // 덮은 이동 동안 NavMeshAgent 가속(정해진 시간 안에 닿게) · 끝나면 원래 값 // roll public string rollClip = ""; public float rollSeconds = 0.5f; public float rollFrom = 0f, rollTo = 1f; // 클립 정규화 구간 // blink public float blinkDistance = 4f; public float blinkSnap = 2f; public string blinkFx = "Effect_Teleport"; // Resources 경로(WL 사본)면 hold · fade 로 투명하게 소멸 · 아니면 원본 Addressables 이펙트 public float blinkFxHold = 0.35f, blinkFxFade = 0.55f; // backflip public string flipClip = ""; public int flipReverse = 1; public float flipFrom = 0f, flipTo = 1f; public float flipSeconds = 0.55f; public float flipDistance = 3f; public float flipHeight = 0.8f; public float flipSpin = 0f; // 클립에 회전이 없을 때 몸 묶음을 등 쪽으로 도는 각(°) public float flipCenter = 0.5f; // 회전 중심 높이(m) // shield public float shieldSeconds = 0.6f; public string shieldFx = "Effect_ManaShield"; public int shieldFxLocation = 1; // eEffectLocation Top 0 · Mid 1 · Bottom 2 public float shieldFxScale = 1f; // ghost public float ghostDistance = 4f; public float ghostSeconds = 0.18f; public int ghostCount = 4; public string ghostClip = ""; public float ghostFrom = 0f, ghostTo = 1f; public float ghostNoTargetDistance = 3f; // #989 대상 · 적 없음 = 바라보는 방향으로 이 거리 이내 전진(막히면 그 앞까지) public string ghostForwardClip = ""; public float ghostForwardFrom = 0f, ghostForwardTo = 1f; public float ghostRadius = 12f; // 「적」 찾기 반경(m) public string note = ""; } public static class WLDodgeStyle { public const string ResourcesPath = "WL/WLDodgeStyle"; public static int Spins, Rolls, Blinks, Flips, Shields, Ghosts, ShieldBlocked, GhostSpawned, ClipMissing; public static string LastLog = "", LastStyle = ""; public static Vector3 LastStart, LastEnd; static WLDodgeStyleJson s_cfg; static WLDodgeClipBank s_bank; static WLDodgeStyleRunner s_runner; static readonly MethodInfo s_setMove = typeof(Actor).GetMethod("Set_Move", BindingFlags.Instance | BindingFlags.NonPublic); static readonly MethodInfo s_isCool = typeof(Actor).GetMethod("IsAvoidCoolTime", BindingFlags.Instance | BindingFlags.NonPublic); static readonly MethodInfo s_setCool = typeof(Actor).GetMethod("Set_AvoidCoolTime", BindingFlags.Instance | BindingFlags.NonPublic); public static WLDodgeStyleJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLDodgeStyleJson(); var ta = Resources.Load(ResourcesPath); if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; } try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } return s_cfg; } } static AnimationClip Clip(string name) { if (s_bank == null) s_bank = Resources.Load(Cfg.clipBank); var c = s_bank != null ? s_bank.Find(name) : null; if (c == null && !string.IsNullOrEmpty(name)) ClipMissing++; return c; } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; s_bank = null; s_invulPc = null; s_invulUntil = 0f; Spins = Rolls = Blinks = Flips = Shields = Ghosts = ShieldBlocked = GhostSpawned = ClipMissing = 0; } static void EnsureRunner() { if (s_runner != null) return; var go = new GameObject("[WL988] DodgeStyle"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } // ── 신발 ───────────────────────────────────────────────────────── /// 장착 신발 → 동작 이름(신발 없음 · 표에 없음 = defaultStyle). public static string CurrentStyle() { var c = Cfg; if (c.enabled == 0) return "spin"; var shoe = WLInventory.Loaded ? WLInventory.EquippedInSlot(eFREquipmentType.Shose) : null; if (shoe != null && shoe.itemJson != null && c.shoes != null) for (int i = 0; i < c.shoes.Length; i++) if (c.shoes[i] != null && c.shoes[i].group == shoe.itemJson.GroupNo) return c.shoes[i].style; return c.defaultStyle; } /// 표의 신발 그룹(순서 그대로) — 시작 지급 · 테스트 씬 순환. public static List ShoeGroups() { var r = new List(); var c = Cfg; if (c.shoes != null) foreach (var s in c.shoes) if (s != null && s.group > 0 && !r.Contains(s.group)) r.Add(s.group); return r; } /// 테스트 씬: 표 순서로 다음 신발을 장착(가진 것 중) — 메시지. public static string NextShoe(int step) { if (!WLInventory.EnsureLoaded()) return "인벤토리 미로드"; var groups = ShoeGroups(); if (groups.Count == 0) return "신발 표 없음"; var cur = WLInventory.EquippedInSlot(eFREquipmentType.Shose); int idx = cur != null && cur.itemJson != null ? groups.IndexOf(cur.itemJson.GroupNo) : -1; for (int k = 1; k <= groups.Count; k++) { int g = groups[((idx + step * k) % groups.Count + groups.Count) % groups.Count]; foreach (var it in WLInventory.SortedItems()) if (it != null && it.itemJson != null && it.itemJson.GroupNo == g && WLInventory.Equip(it.rawItem._id)) return "신발 " + it.itemJson.Item_name + "(" + g + ") → 회피 " + CurrentStyle(); } return "가진 신발 없음"; } /// #1008 테스트 씬: 표 순서 number 번째(1부터) 신발을 장착(가진 것 중) — 메시지. public static string SelectShoe(int number) { if (!WLInventory.EnsureLoaded()) return "인벤토리 미로드"; var groups = ShoeGroups(); if (number < 1 || number > groups.Count) return "신발 " + number + "번 없음(1~" + groups.Count + ")"; int g = groups[number - 1]; var cur = WLInventory.EquippedInSlot(eFREquipmentType.Shose); if (cur != null && cur.itemJson != null && cur.itemJson.GroupNo == g) return "이미 신발 " + number + " " + cur.itemJson.Item_name + " → 회피 " + CurrentStyle(); foreach (var it in WLInventory.SortedItems()) if (it != null && it.itemJson != null && it.itemJson.GroupNo == g && WLInventory.Equip(it.rawItem._id)) return "신발 " + number + " " + it.itemJson.Item_name + "(" + g + ") → 회피 " + CurrentStyle(); return "신발 " + number + "번(" + g + ") 장착 실패 · " + WLInventory.LastMessage; } /// #1008 테스트 씬 화면 목록 한 줄 — 「1 spin 2 roll …」(표 순서 · 장착 중 = [ ]). public static string ShoeLine() { var groups = ShoeGroups(); var c = Cfg; var cur = WLInventory.Loaded ? WLInventory.EquippedInSlot(eFREquipmentType.Shose) : null; int curG = cur != null && cur.itemJson != null ? cur.itemJson.GroupNo : -1; var sb = new System.Text.StringBuilder(); for (int i = 0; i < groups.Count; i++) { string style = c.defaultStyle; foreach (var s in c.shoes) if (s != null && s.group == groups[i]) { style = s.style; break; } if (i > 0) sb.Append(" "); bool on = groups[i] == curG; sb.Append(on ? "[" : "").Append(i < 9 ? (i + 1).ToString() : i == 9 ? "0" : "-").Append(' ').Append(style).Append(on ? "]" : ""); } return sb.ToString(); } // ── 무적(shield) ────────────────────────────────────────────────── static Actor s_invulPc; static float s_invulUntil; /// MyActor.Get_Damage 훅 — 실드 동안 피해 무효(막은 수를 센다). public static bool Invulnerable(Actor a) { if (a == null || a != s_invulPc || Time.time >= s_invulUntil) return false; ShieldBlocked++; return true; } public static bool ShieldActive { get { return s_invulPc != null && Time.time < s_invulUntil; } } // ── 회피 ───────────────────────────────────────────────────────── /// WLDoubleTapDodge 가 부른다 — dir = 탭 방향(수평 · 정규화). 성공 여부 · 기록. public static bool Do(PCActor pc, Vector3 dir, out string log) { var c = Cfg; WLAnimEventSink.Ensure(pc.m_animation != null ? pc.m_animation.gameObject : pc.gameObject); // #989 원본 dodge 클립 'Land' 이벤트 받는 곳 string style = CurrentStyle(); LastStyle = style; LastStart = pc.Get_position(); var fwd = pc.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-6f) fwd = Vector3.forward; fwd.Normalize(); bool ok; switch (style) { case "roll": ok = Dodge(pc, dir, true); if (ok) { Rolls++; StartOverlay(pc, Clip(c.rollClip), c.rollFrom, c.rollTo, false, c.rollSeconds, 0f, 0f); } break; case "blink": ok = Blink(pc, dir); break; case "backflip": ok = Flip(pc, -fwd); break; case "shield": ok = Shield(pc); break; case "ghost": ok = Ghost(pc, fwd); break; default: style = "spin"; ok = Dodge(pc, dir, true); if (ok) Spins++; break; } log = style + (ok ? "" : " 안 됨(회피 쿨타임 · 이미 회피 중 등)"); LastLog = log; return ok; } /// 원본 회피(쿨타임 · 상태 · 흙먼지 · 앞 3 m). turn = 탭 방향으로 돌기 · 실패면 방향 되돌림(#923). static bool Dodge(PCActor pc, Vector3 dir, bool turn) { var rot0 = pc.transform.rotation; if (turn) pc.transform.rotation = Quaternion.LookRotation(dir); pc.Play_Dodge(); if (pc.Get_CurAnim() == eAnim.Dodge) return true; pc.transform.rotation = rot0; return false; } static bool Blink(PCActor pc, Vector3 dir) { var c = Cfg; s_noDustUntil = Time.time + 0.2f; if (!Dodge(pc, dir, true)) { s_noDustUntil = 0f; return false; } var start = pc.Get_position(); var to = start + dir * c.blinkDistance; NavMeshHit hit; if (NavMesh.SamplePosition(to, out hit, Mathf.Max(0.1f, c.blinkSnap), NavMesh.AllAreas)) to = hit.position; else to = start; ShowBlinkFx(start); pc.Set_Warp(to); if (s_setMove != null) s_setMove.Invoke(pc, new object[] { to }); // 제자리 경로 → 다음 프레임 원본 이동 끝 처리(달리기 → 멈춤) = 회피 상태 끝 pc.AnimationPlay(MyValue.Get_AnimName(eAnim.Idle)); ShowBlinkFx(to); LastEnd = to; Blinks++; return true; } static bool Flip(PCActor pc, Vector3 back) { var c = Cfg; if (!Dodge(pc, back, false)) return false; var to = pc.Get_position() + back * c.flipDistance; MoveFor(pc, to, c.flipSeconds); StartOverlay(pc, Clip(c.flipClip), c.flipFrom, c.flipTo, c.flipReverse != 0, c.flipSeconds, c.flipHeight, c.flipSpin); LastEnd = to; Flips++; return true; } static bool Shield(PCActor pc) { var c = Cfg; if (pc.IsDead()) return false; if (s_isCool != null && (bool)s_isCool.Invoke(pc, null)) return false; if (s_setCool != null) s_setCool.Invoke(pc, null); s_invulPc = pc; s_invulUntil = Time.time + c.shieldSeconds; if (!string.IsNullOrEmpty(c.shieldFx) && InGameInfo.Ins != null) { float k = c.shieldFxScale; InGameInfo.Ins.Show_Effect(c.shieldFx, pc, (eEffectLocation)Mathf.Clamp(c.shieldFxLocation, 0, 2), c.shieldSeconds, t => { if (t != null) ScaleFx(t.gameObject, k); }); } LastEnd = pc.Get_position(); Shields++; return true; } /// #989 PD 「어느 방향으로 회피를 시도하든 항상 공격 중인 대상(1순위) 또는 가장 가까운 적(2순위)의 반대 방향 · 대상이 없을 경우 3m 이내 전진」 static bool Ghost(PCActor pc, Vector3 fwd) { var c = Cfg; var start = pc.Get_position(); string from; var enemy = GhostEnemy(pc, c.ghostRadius, out from); // 원본 Play_Dodge 가 대상을 지우므로(Set_AutoBraking → Del_Target) 그 전에 읽는다 Vector3 dir = fwd; float dist = c.ghostNoTargetDistance; bool away = false; if (enemy != null) { var d = start - enemy.Get_position(); d.y = 0f; if (d.sqrMagnitude > 1e-4f) { dir = d.normalized; dist = c.ghostDistance; away = true; } } if (!Dodge(pc, dir, false)) return false; var to = start + dir * dist; MoveFor(pc, to, c.ghostSeconds * dist / Mathf.Max(0.1f, c.ghostDistance)); // 구르기 대신 미끄러지는 자세 — 적 반대쪽 = 뒤로 기운 ghostClip 구간 · 대상 없음(전진) = 앞으로 기운 ghostForwardClip 구간 // (AnimationPlay(idle) 은 상태 머신이 회피 상태를 끝내 이동이 1.4 m 에서 끊겼다 · #988 1차 Play) if (away) StartOverlay(pc, Clip(c.ghostClip), c.ghostFrom, c.ghostTo, false, c.ghostSeconds, 0f, 0f); else StartOverlay(pc, Clip(c.ghostForwardClip), c.ghostForwardFrom, c.ghostForwardTo, false, c.ghostSeconds, 0f, 0f); GhostFrom = enemy != null ? from + " " + enemy.name : "대상 없음 → 전진"; GhostDir = dir; GhostEnemyPos = enemy != null ? enemy.Get_position() : start; s_ghostPc = pc; s_ghostLeft = Mathf.Max(0, c.ghostCount); s_ghostNext = Time.time; s_ghostGap = c.ghostSeconds / Mathf.Max(1, c.ghostCount); EnsureRunner(); LastEnd = to; Ghosts++; return true; } public static string GhostFrom = ""; public static Vector3 GhostDir, GhostEnemyPos; // 마지막 잔상 이동의 방향 · 그때 기준 적 자리(검증용) /// 1순위 = 공격 중인 대상(m_Target) · 2순위 = 반경 안 가장 가까운 적. static Actor GhostEnemy(PCActor pc, float radius, out string from) { var t = pc.Get_Target(); // m_Target 은 속성(필드 아님 · #989 1차 Play 에서 리플렉션 필드가 비어 1순위가 안 읽혔다) if (t != null && !t.IsDead() && t.gameObject.activeInHierarchy) { from = "공격 대상"; return t; } from = "가장 가까운 적"; return NearestEnemy(pc, radius); } static Actor NearestEnemy(PCActor pc, float radius) { Actor best = null; var my = pc.Get_position(); if (ActorInfo.Ins != null) { var list = ActorInfo.Ins.Get_EnemyActors(pc.IsEnemy(), my, radius); float bd = float.MaxValue; for (int i = 0; i < list.Count; i++) { var a = list[i]; if (a == null || a.IsDead() || !a.gameObject.activeInHierarchy) continue; var d = a.Get_position() - my; d.y = 0f; if (d.sqrMagnitude < bd) { bd = d.sqrMagnitude; best = a; } } ActorInfo.Ins.Release_Actors(list); } return best; } /// 원본 회피 이동을 덮는다 — 목적지 · 속도(거리 / 초) · 가속(끝나면 원래 값). static void MoveFor(PCActor pc, Vector3 to, float seconds) { var agent = pc.GetComponent(); if (agent == null || s_setMove == null) return; NavMeshHit hit; if (NavMesh.Raycast(pc.Get_position(), to, out hit, NavMesh.AllAreas)) to = hit.position; // #989 막힌 곳(섬 가장자리 · 벽) 앞까지만 = 「3 m 이내」 else if (NavMesh.SamplePosition(to, out hit, 2f, NavMesh.AllAreas)) to = hit.position; s_setMove.Invoke(pc, new object[] { to }); float dist = Vector3.Distance(pc.Get_position(), to); agent.speed = dist / Mathf.Max(0.05f, seconds); if (s_accelPc != pc) { s_accel0 = agent.acceleration; s_accelPc = pc; } agent.acceleration = Cfg.accel; EnsureRunner(); } static PCActor s_accelPc; static float s_accel0; // ── 흙먼지 · 이펙트 ────────────────────────────────────────────── static float s_noDustUntil; /// WLDoubleTapDodge.AdjustDust 가 묻는다 — 순간이동은 흙먼지 없음. public static bool SuppressDust { get { return Time.time < s_noDustUntil; } } static readonly Dictionary s_fxPrefab = new Dictionary(); /// #989 PD 「회피(탄막)는 1초 이내 자연스럽게 소멸(투명해지며 소멸)」 — WL 사본(Resources)을 띄워 blinkFxHold 뒤 blinkFxFade 초 동안 투명하게 지운다(WLFxFade). /// 원본 풀 이펙트(Effect_Teleport · 5 s 반복)는 WLFxFade 가 끝에 오브젝트를 지워 풀이 깨지므로 쓰지 않는다 — Resources 경로가 아니면 원본 그대로 띄움. static void ShowBlinkFx(Vector3 pos) { var c = Cfg; if (string.IsNullOrEmpty(c.blinkFx)) return; GameObject pf; if (!s_fxPrefab.TryGetValue(c.blinkFx, out pf)) { pf = Resources.Load(c.blinkFx); s_fxPrefab[c.blinkFx] = pf; } if (pf == null) { if (InGameInfo.Ins != null) InGameInfo.Ins.Show_Effect(c.blinkFx, pos); return; } var go = Object.Instantiate(pf, pos, pf.transform.rotation); Weapon.WLFxFade.Attach(go, c.blinkFxHold, c.blinkFxFade, true); BlinkFxShown++; } public static int BlinkFxShown; static void ScaleFx(GameObject fx, float k) { var mark = fx.GetComponent(); if (mark == null) { mark = fx.AddComponent(); mark.baseScale = fx.transform.localScale; foreach (var ps in fx.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } } fx.transform.localScale = mark.baseScale * k; } // ── 클립 덮어쓰기 · 몸 포물선 ───────────────────────────────────── static PlayableGraph s_graph; static AnimationClipPlayable s_play; static PCActor s_ovPc, s_ghostPc; static AnimationClip s_ovClip; static float s_ovT0, s_ovSec, s_ovFrom, s_ovTo, s_ovHeight, s_ovSpin; static bool s_ovReverse; static Transform s_vis; static Vector3 s_visPos0; static Quaternion s_visRot0; static int s_ghostLeft; static float s_ghostNext, s_ghostGap; public static float OverlayU { get; private set; } public static bool OverlayActive { get { return s_ovPc != null; } } static void StartOverlay(PCActor pc, AnimationClip clip, float from, float to, bool reverse, float seconds, float height, float spin) { StopOverlay(); s_ovPc = pc; s_ovClip = clip; s_ovFrom = from; s_ovTo = to; s_ovReverse = reverse; s_ovSec = Mathf.Max(0.05f, seconds); s_ovT0 = Time.time; s_ovHeight = height; s_ovSpin = spin; OverlayU = 0f; if (clip != null && pc.m_animation != null) { s_graph = PlayableGraph.Create("WL988 Dodge"); s_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime); var output = AnimationPlayableOutput.Create(s_graph, "Dodge", pc.m_animation); s_play = AnimationClipPlayable.Create(s_graph, clip); s_play.SetApplyFootIK(false); s_play.SetSpeed(0); s_play.SetTime((reverse ? to : from) * clip.length); output.SetSourcePlayable(s_play); s_graph.Play(); } s_vis = (height != 0f || spin != 0f) ? pc.transform.Find(Cfg.visualChild) : null; if (s_vis != null) { s_visPos0 = s_vis.localPosition; s_visRot0 = s_vis.localRotation; } EnsureRunner(); } static void StopOverlay() { if (s_graph.IsValid()) s_graph.Destroy(); if (s_vis != null) { s_vis.localPosition = s_visPos0; s_vis.localRotation = s_visRot0; s_vis = null; } s_ovPc = null; s_ovClip = null; } internal static void Tick() { if (s_ovPc != null) { float u = (Time.time - s_ovT0) / s_ovSec; bool over = s_ovPc == null || s_ovPc.IsDead() || !s_ovPc.gameObject.activeInHierarchy || u >= 1f || s_ovPc.Get_CurAnim() != eAnim.Dodge; if (over) StopOverlay(); else { OverlayU = u; if (s_ovClip != null && s_play.IsValid()) { float n = Mathf.Lerp(s_ovFrom, s_ovTo, s_ovReverse ? 1f - u : u); s_play.SetTime(n * s_ovClip.length); } if (s_vis != null) { var pivot = Vector3.up * Cfg.flipCenter; var r = Quaternion.AngleAxis(-s_ovSpin * u, Vector3.right); s_vis.localRotation = r * s_visRot0; s_vis.localPosition = s_visPos0 + pivot - r * pivot + Vector3.up * (4f * s_ovHeight * u * (1f - u)); } } } if (s_ghostPc != null && s_ghostLeft > 0 && Time.time >= s_ghostNext) { Reaction.DashAfterImage.ForceSpawn(s_ghostPc); GhostSpawned++; s_ghostLeft--; s_ghostNext = Time.time + s_ghostGap; if (s_ghostLeft <= 0) s_ghostPc = null; } if (s_accelPc != null && s_ovPc == null && s_ghostPc == null && s_accelPc.Get_CurAnim() != eAnim.Dodge) { var agent = s_accelPc.GetComponent(); if (agent != null) agent.acceleration = s_accel0; s_accelPc = null; } } internal static void Shutdown() { StopOverlay(); s_ghostPc = null; s_accelPc = null; } } /// WLDodgeStyle 의 프레임 처리(클립 시간 · 몸 포물선 · 잔상 · 가속 복원). 숨김 GameObject 1개. internal sealed class WLDodgeStyleRunner : MonoBehaviour { void Update() { WLDodgeStyle.Tick(); } void OnDestroy() { WLDodgeStyle.Shutdown(); } } }