Project_WL/Assets/WL/Combat/Dodge/WLDodgeStyle.cs

509 lines
28 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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<TextAsset>(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<WLDodgeClipBank>(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<WLDodgeStyleRunner>();
}
// ── 신발 ─────────────────────────────────────────────────────────
/// <summary>장착 신발 → 동작 이름(신발 없음 · 표에 없음 = defaultStyle).</summary>
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;
}
/// <summary>표의 신발 그룹(순서 그대로) — 시작 지급 · 테스트 씬 순환.</summary>
public static List<int> ShoeGroups()
{
var r = new List<int>();
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;
}
/// <summary>테스트 씬: 표 순서로 다음 신발을 장착(가진 것 중) — 메시지.</summary>
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 "가진 신발 없음";
}
/// <summary>#1008 테스트 씬: 표 순서 number 번째(1부터) 신발을 장착(가진 것 중) — 메시지.</summary>
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;
}
/// <summary>#1008 테스트 씬 화면 목록 한 줄 — 「1 spin 2 roll …」(표 순서 · 장착 중 = [ ]).</summary>
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;
/// <summary>MyActor.Get_Damage 훅 — 실드 동안 피해 무효(막은 수를 센다).</summary>
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; } }
// ── 회피 ─────────────────────────────────────────────────────────
/// <summary>WLDoubleTapDodge 가 부른다 — dir = 탭 방향(수평 · 정규화). 성공 여부 · 기록.</summary>
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;
}
/// <summary>원본 회피(쿨타임 · 상태 · 흙먼지 · 앞 3 m). turn = 탭 방향으로 돌기 · 실패면 방향 되돌림(#923).</summary>
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;
}
/// <summary>#989 PD 「어느 방향으로 회피를 시도하든 항상 공격 중인 대상(1순위) 또는 가장 가까운 적(2순위)의 반대 방향 · 대상이 없을 경우 3m 이내 전진」</summary>
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; // 마지막 잔상 이동의 방향 · 그때 기준 적 자리(검증용)
/// <summary>1순위 = 공격 중인 대상(m_Target) · 2순위 = 반경 안 가장 가까운 적.</summary>
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;
}
/// <summary>원본 회피 이동을 덮는다 — 목적지 · 속도(거리 / 초) · 가속(끝나면 원래 값).</summary>
static void MoveFor(PCActor pc, Vector3 to, float seconds)
{
var agent = pc.GetComponent<NavMeshAgent>();
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;
/// <summary>WLDoubleTapDodge.AdjustDust 가 묻는다 — 순간이동은 흙먼지 없음.</summary>
public static bool SuppressDust { get { return Time.time < s_noDustUntil; } }
static readonly Dictionary<string, GameObject> s_fxPrefab = new Dictionary<string, GameObject>();
/// <summary>#989 PD 「회피(탄막)는 1초 이내 자연스럽게 소멸(투명해지며 소멸)」 — WL 사본(Resources)을 띄워 blinkFxHold 뒤 blinkFxFade 초 동안 투명하게 지운다(WLFxFade).
/// 원본 풀 이펙트(Effect_Teleport · 5 s 반복)는 WLFxFade 가 끝에 오브젝트를 지워 풀이 깨지므로 쓰지 않는다 — Resources 경로가 아니면 원본 그대로 띄움.</summary>
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<GameObject>(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<WLDustScaleMark>();
if (mark == null)
{
mark = fx.AddComponent<WLDustScaleMark>();
mark.baseScale = fx.transform.localScale;
foreach (var ps in fx.GetComponentsInChildren<ParticleSystem>(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<NavMeshAgent>();
if (agent != null) agent.acceleration = s_accel0;
s_accelPc = null;
}
}
internal static void Shutdown() { StopOverlay(); s_ghostPc = null; s_accelPc = null; }
}
/// <summary>WLDodgeStyle 의 프레임 처리(클립 시간 · 몸 포물선 · 잔상 · 가속 복원). 숨김 GameObject 1개.</summary>
internal sealed class WLDodgeStyleRunner : MonoBehaviour
{
void Update() { WLDodgeStyle.Tick(); }
void OnDestroy() { WLDodgeStyle.Shutdown(); }
}
}