// ───────────────────────────────────────────────────────────────────────────── // WLSlashFlurry.cs — 돌진 베기(Skill_DashSlash) WL 방식: 사라지지 않고 1·2타 빠른 연속 베기 + 앞쪽 여러 번 타격 // // PD 원문(#888 · 2026-10-17): 「한손검 전설 단계의 스킬 시전 시 캐릭터가 사라지지 않고 … 이펙트로 전방의 적을 여러번 공격하는 형태로 // 구성해줘. (… 이펙트 크기가 너무 크기 때문에 캐릭터 크기를 고려해 자연스럽게 수정해줘)」 // + 「돌진 베기 → 빠른 찌르기 연타가 아니라 1타와 2타 모션을 이펙트 재생시간 동안 빠르게 재생해 연속으로 빠르게 베는 연속 공격 컨셉으로 바꿔줘.」 // // ■ 원리 — 원본 Skill_DashSlash.Set 의 WL 훅 1줄(Take)이 넘겨받는다: 투명화(Set_Invisible) · 순간이동(Set_Warp) · 카메라 따라가기를 하지 않고, // 원본 이펙트(go_effect)와 판정 상자(BoxCollider)를 배율 scale 로 줄여 시전자 앞(forward · height)에 매 프레임 둔다(LateUpdate — // 원본 Update 가 대상 위치로 옮긴 뒤에 덮는다). 타격 = 원본 간격 표(list_DamgeDelayTime)대로 상자를 껐다 켠다(원본과 같은 다단 히트). // 모션 = 애니메이터 dashslash 사슬(1타 → 2타 → 1타 → 2타 → 대기 · 배속 · AgentScripts/WL888_Flurry.json) — 이 파일은 모션을 건드리지 않는다. // ■ 값 = Resources/WL/WLSlashFlurry.json (C45) · 끄기 = enabled 0 → 원본 동작 · 원본 수정 = Skill_DashSlash.Set 1줄 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using System.Reflection; using UnityEngine; namespace WL.Combat.Weapon { [System.Serializable] public class WLSlashFlurryJson { public int enabled = 0; public int[] classes = new int[0]; // 비우면 모든 직업 public float scale = 1f; // 원본 이펙트 · 판정 상자 배율 public float forward = 1f; // 시전자 앞 거리(m) public float height = 0f; // 시전자 발 기준 높이(m) public float duration = 1f; // 이펙트를 켜 두는 시간(s) — 끝나면 투사체 Off public float[] hitTimes = new float[0]; // #915 타격 시각(s · 시작 기준) — 비우면 원본 간격 표(list_DamgeDelayTime) public float stepKeep = 0f; // #916 걸음(동작이 바뀌는 순간)마다 대상 몸 표면까지 이 거리(m)가 되도록 대상 쪽으로 전진 — 0 = 전진 없음 public float stepMax = 0.35f; // #916 한 걸음 최대 전진(m) public float stepSeconds = 0.1f; // #916 한 걸음에 걸리는 시간(s) // #921 PD 「직전 연타 공격 마무리 직후 검에 짧게 모으기 이펙트를 적용한 후 검을 내리치며 Slash Collection 이펙트를 마지막타에」 — 마무리 내려치기 public int finisher = 0; // 1 = 연타 동작 끝에서 이어서 마무리 public string finisherLastState = ""; // 연타 사슬의 마지막 상태 — 이 상태가 finisherFromNormalized 에 닿으면 시작 public float finisherFromNormalized = 0.85f; public float finisherMaxAt = 1.0f; // 시작 기준 이 초가 지나도 못 잡으면 그때 시작(스킬 상태일 때만) public float flurryFxOff = 1.05f; // 연타 이펙트(찌르기)를 끄는 시각(s · 시작 기준) — 6 번째 찌르기 전 public string finisherState = ""; // 내려치기 동작 상태 public float holdNormalized = 0.2f; // 모으기 동안 멈출 자세(검을 머리 위로) public float chargeSeconds = 0.3f; public float slamSpeed = 1f; // 내려칠 때 애니메이터 배속 public float impactNormalized = 0.36f; // 검이 앞 아래에 닿는 순간 = 마지막 타격 · 이펙트 public float endNormalized = 0.55f; // 여기까지 재생하고 대기 public string swordSocket = "w_onehand"; public string glowMaterial = "WL/Fx/WL_Mat_WeaponGlow"; public float[] glowColor = { 0.8f, 1.2f, 1.8f }; public float glowMax = 0.5f, glowFill = 0.3f, glowRimPower = 2.5f, glowFadeSeconds = 0.2f; public string chargeFx = ""; public float chargeFxScale = 0.06f; // #923 PD 「검 각성스킬 마지막 타 공격 모션에서 검에 기가 모이는 연출 및 검이 빛나게(해머 스킬 참고)」 — 모으기 동안 검 크기 1 → growScale(쥔 손 = 소켓 원점 기준 · 해머 각성과 같은 방식) · 내려친 뒤 shrinkSeconds 동안 원래 크기 public float growScale = 1f; public float shrinkSeconds = 0.15f; public string slashFx = ""; public float slashForward = 1f, slashHeight = 0f, slashScale = 0.35f, slashLife = 2f, slashYaw = 0f; public int slashFaceCamera = 1; // 1 = 호가 카메라를 정면으로(에셋 데모처럼) · 0 = 시전자 방향 public float[] slashImpactLocal = new float[3]; // 이펙트 프리팹 안 땅 충격(얼음 가시) 자리(배율 1 · 로컬 m) — 이 자리가 내려친 지점(시전자 앞 slashForward)에 오게 옮긴다 public int slashFlipRight = 1; // 1 = 시전자가 화면 오른쪽을 보면 호를 180° 돌려 호가 시전자 쪽에서 와 적 쪽(충격 자리)에 꽂히게 // #923 PD 「검 각성스킬 마지막 공격의 궤적이 검이 내려치는 방향에 맞게」 — 1 = 호를 검이 지나는 면(시전자 앞 · 위)에 세운다(뒤 위에서 앞 바닥으로) · slashFaceCamera 보다 먼저 public int slashSwingPlane = 0; public float slashTilt = 0f; // 그 면을 앞 방향 축으로 기울이는 각(도) — 화면을 더 마주 보는 쪽으로(앞뒤로 보는 각에서 호가 선처럼 얇게 보이지 않게) // #924 PD 「검 각성 마지막타에 검이 내려치는 부위에 충격파를 해머 내려치기에 사용한 이펙트처럼 지면에 타격을 준 컨셉으로」 — 내려친 칼끝 아래 지면 충격 public int strikeAtTip = 0; // 1 = 타격 지점 = 내려친 순간 실제 칼끝(소켓에서 가장 먼 무기 메시 모서리)을 땅으로 내린 곳(베기 호 · 지면 충격 함께) · 0 = 시전자 앞 slashForward public string groundFx = ""; public float groundFxScale = 0.45f, groundFxLife = 2.5f; // #924 PD 「검기 각성 스킬 마지막타 모으기 기 이펙트가 좀 더 빛나는 느낌이 나야할 것 같아. (지금은 광택이 느껴지지 않음)」 — 모으기 동안 칼날 위 반짝임(별 반짝 + 빛) public string chargeShineFx = ""; public float chargeShineScale = 0.4f; public float chargeShineAlong = 0.6f; // 칼날 가운데(0) → 칼끝(1) 사이 위치 // #929 PD 「검 각성 스킬 마지막타 사용 직후 바로 다음 공격을 시도하게 하지 말고 0.5초 정도 내려친 상태로 딜레이를 주어 임펙트를 주도록」 public float impactHoldNormalized = 0.46f; // 마지막 타격 뒤 이 자세(검을 내려친 채)까지 재생하고 public float impactHoldSeconds = 0f; // 이 시간 동안 멈춘 뒤 대기(0 = 멈추지 않고 endNormalized 까지) public int holdBlocksMove = 1; // 1 = 멈춘 동안 이동 입력 무시(구르기는 허용 = 스킵) // #930 PD 「검(한손 검) 각성 스킬 중 찌르기 이펙트의 짙은 파란색을 검기 색상과 어울리게 조절해줘.」 — 원본 이펙트(FX_7Blue Stab) 렌더러 재질을 이름으로 찾아 WL 사본(Resources 경로)으로 바꿔 낀다(원본 에셋 그대로 · 넘겨받은 투사체만) public WLFxMatSwap[] fxMaterialSwap = new WLFxMatSwap[0]; public string note = ""; } [System.Serializable] public class WLFxMatSwap { public string from = ""; public string to = ""; } public static class WLSlashFlurry { public const string ResourcesPath = "WL/WLSlashFlurry"; public static int Taken, Windows, Errors, Steps, Finishers, FinisherHits, FinisherAborts, GroundFx, Holds, MatSwapped; public static float LastHoldSeconds; public static Vector3 LastStrike; public static float LastFinisherStart, LastFinisherImpact; public static float StepDistance; public static string LastLog = ""; static WLSlashFlurryJson s_cfg; internal static WLSlashFlurryJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLSlashFlurryJson(); 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; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; } /// 원본 Skill_DashSlash.Set 훅 — true 면 WL 이 이 투사체를 맡는다(원본 투명화 · 순간이동 건너뜀). public static bool Take(ProjectileBase pb, GameObject effect, Collider col, List delays) { try { var c = Cfg; if (c.enabled == 0 || pb == null || effect == null || col == null) return false; var data = pb.Get_ProjectTileData(); var shooter = data != null ? data.shooter : null; if (DSUtil.CheckNull(shooter)) return false; if (c.classes != null && c.classes.Length > 0) { var pc = shooter as PCActor; int cls = pc != null ? ClassOf(pc) : 0; if (System.Array.IndexOf(c.classes, cls) < 0) return false; } var run = pb.GetComponent(); if (run == null) run = pb.gameObject.AddComponent(); run.Begin(pb, shooter, data.target, effect, col, delays, c); Taken++; LastLog = "넘겨받음 #" + Taken + " · 배율 " + c.scale + " · 타격 " + (delays != null ? delays.Count : 0) + "회"; return true; } catch (System.Exception ex) { Errors++; LastLog = "넘겨받기 실패 — " + ex.Message; return false; } } static FieldInfo s_fId; static int ClassOf(PCActor pc) { if (s_fId == null) s_fId = typeof(Actor).GetField("m_ID", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); return s_fId != null ? (int)s_fId.GetValue(pc) : 0; } static MethodInfo s_off; internal static void Off(ProjectileBase pb) { if (s_off == null) s_off = typeof(ProjectileBase).GetMethod("Off", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, null, new[] { typeof(bool) }, null); try { if (s_off != null) s_off.Invoke(pb, new object[] { true }); else pb.gameObject.SetActive(false); } catch (System.Exception ex) { Errors++; LastLog = "Off 실패 — " + ex.Message; pb.gameObject.SetActive(false); } } } /// 넘겨받은 돌진 베기 투사체 1개의 진행(위치 · 배율 · 다단 히트 · 끝). 풀에서 다시 쓰이면 Begin 이 새로 시작한다. internal sealed class WLSlashFlurryRunner : MonoBehaviour { ProjectileBase _pb; Actor _shooter; GameObject _effect; Collider _col; WLSlashFlurryJson _c; Vector3 _baseScale; bool _hasBase, _active; Coroutine _co, _fin; // #921 마무리 내려치기 bool _finDone = true, _animOwned, _held; float _t0; readonly WLWeaponGlowFx _glow = new WLWeaponGlowFx(); Transform _socket; Vector3 _swordLocal, _tipLocal; bool _hasTip; GameObject _charge, _shine; readonly List _parts = new List(); readonly List _pos0 = new List(), _scl0 = new List(); float _grow = 1f; static readonly Dictionary s_fx = new Dictionary(); static readonly Dictionary s_mats = new Dictionary(); /// #930 이펙트 렌더러 재질(본 재질 · 꼬리 재질)을 이름으로 찾아 JSON fxMaterialSwap 의 WL 사본으로 바꾼다(풀에서 다시 쓰여도 같은 결과). static void SwapMats(GameObject effect, WLSlashFlurryJson c) { if (c.fxMaterialSwap == null || c.fxMaterialSwap.Length == 0) return; foreach (var r in effect.GetComponentsInChildren(true)) { var m = Swap(r.sharedMaterial, c); if (m != null) r.sharedMaterial = m; var t = Swap(r.trailMaterial, c); if (t != null) r.trailMaterial = t; } } static Material Swap(Material m, WLSlashFlurryJson c) { if (m == null) return null; foreach (var s in c.fxMaterialSwap) { if (s == null || s.from != m.name) continue; Material to; if (!s_mats.TryGetValue(s.to, out to) || to == null) { to = Resources.Load(s.to); s_mats[s.to] = to; } if (to == null) { WLSlashFlurry.LastLog = "재질 사본 없음 " + s.to; return null; } WLSlashFlurry.MatSwapped++; return to; } return null; } // #916 걸음 전진 Actor _target; Animator _an; UnityEngine.AI.NavMeshAgent _agent; int _lastClip; Vector3 _stepVel; float _stepUntil = -1f; public void Begin(ProjectileBase pb, Actor shooter, Actor target, GameObject effect, Collider col, List delays, WLSlashFlurryJson c) { _pb = pb; _shooter = shooter; _effect = effect; _col = col; _c = c; _target = target; var spc = shooter as PCActor; _an = spc != null ? spc.m_animation : null; _agent = shooter.Get_Agent(); _lastClip = 0; _stepUntil = -1f; if (!_hasBase) { _baseScale = transform.localScale; _hasBase = true; } if (!DSUtil.CheckNull(target)) { var to = target.Get_position() - shooter.Get_position(); to.y = 0f; if (to.sqrMagnitude > 1e-4f) shooter.transform.rotation = Quaternion.LookRotation(to); } foreach (var ps in effect.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } SwapMats(effect, c); transform.localScale = _baseScale * c.scale; _active = true; Place(); if (_co != null) StopCoroutine(_co); if (_fin != null) { StopCoroutine(_fin); _fin = null; } _t0 = Time.time; _finDone = !(c.finisher != 0 && !string.IsNullOrEmpty(c.finisherState) && _an != null); _co = StartCoroutine(Co(delays)); if (!_finDone) _fin = StartCoroutine(Fin()); } // ── #921 마무리 — 연타 동작 끝 → 검을 머리 위로(모으기 · 검 빛 · 모으기 이펙트) → 내려치기 → 마지막 타격 + Slash Collection 이펙트 → 대기 ── IEnumerator Fin() { var c = _c; var an = _an; var pc = _shooter as PCActor; // ① 시작 — 연타 사슬 마지막 상태가 finisherFromNormalized 에 닿거나 그 상태를 벗어나거나 finisherMaxAt 이 지나면(그때 스킬 상태여야) // 마지막 상태에 들어서는 순간 클립 이벤트를 끈다 — 그 클립의 원본 대기 이벤트(≈ 0.8)가 스킬 상태를 먼저 풀어 마무리가 끊겼다(Play 실측 n 0.85 프레임에 이미 Idle) bool seenLast = false; while (true) { if (pc == null || pc.IsDead() || pc.Get_CurAnim() != eAnim.Skill) { FinEnd(false); yield break; } var si = an.GetCurrentAnimatorStateInfo(0); bool inLast = !string.IsNullOrEmpty(c.finisherLastState) && si.IsName(c.finisherLastState); if (inLast && !seenLast) { seenLast = true; an.fireEvents = false; _animOwned = true; } if ((inLast && si.normalizedTime >= c.finisherFromNormalized) || (seenLast && !inLast) || Time.time - _t0 >= c.finisherMaxAt) break; yield return null; } WLSlashFlurry.Finishers++; WLSlashFlurry.LastFinisherStart = Time.time - _t0; var foe = FrontEnemy(); if (foe != null) { var to = foe.Get_position() - pc.Get_position(); to.y = 0f; if (to.sqrMagnitude > 1e-4f) pc.transform.rotation = Quaternion.LookRotation(to); } an.fireEvents = false; _animOwned = true; // 내려치기 클립의 원본 SkillEvent 등은 끈다(판정은 이 러너가) an.Play(c.finisherState, 0, c.holdNormalized); an.speed = 0f; an.Update(0f); // ② 모으기 — 검 빛 + 검 가운데 모으기 이펙트 _socket = null; if (pc.tfs_weapon != null) foreach (var s in pc.tfs_weapon) if (s != null && s.name == c.swordSocket && s.gameObject.activeInHierarchy) { _socket = s; break; } _hasTip = _socket != null && TipLocal(_socket, out _tipLocal); if (_socket != null) { GrowBegin(); _glow.Begin(_socket, c.glowMaterial); _swordLocal = WLWeaponGlowFx.CenterLocal(_socket); } _charge = Spawn(c.chargeFx, SwordCenter(), Quaternion.identity, c.chargeFxScale, c.chargeSeconds + 0.5f); _shine = Spawn(c.chargeShineFx, ShinePoint(), Quaternion.identity, c.chargeShineScale, c.chargeSeconds + 2f); var col = new Color(Get(c.glowColor, 0, 1f), Get(c.glowColor, 1, 1f), Get(c.glowColor, 2, 1f), 1f); float t = 0f; while (t < c.chargeSeconds) { if (FinBroken(pc)) { FinEnd(false); yield break; } if (an.speed != 0f) an.speed = 0f; var si = an.GetCurrentAnimatorStateInfo(0); if (!si.IsName(c.finisherState) || Mathf.Abs(si.normalizedTime - c.holdNormalized) > 0.01f) an.Play(c.finisherState, 0, c.holdNormalized); float k = Mathf.Clamp01(t / Mathf.Max(0.01f, c.chargeSeconds)); Grow(Mathf.Lerp(1f, c.growScale, k)); _glow.Set(c.glowMax * k, col, c.glowFill, c.glowRimPower); if (_charge != null) _charge.transform.position = SwordCenter(); if (_shine != null) _shine.transform.position = ShinePoint(); t += Time.deltaTime; yield return null; } Grow(c.growScale); _glow.Set(c.glowMax, col, c.glowFill, c.glowRimPower); if (_charge != null) { Destroy(_charge); _charge = null; } StopShine(); // ③ 내려치기 float sp = Mathf.Max(0.1f, c.slamSpeed); an.Play(c.finisherState, 0, c.holdNormalized); an.speed = sp; yield return null; while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < c.impactNormalized) { if (FinBroken(pc)) { FinEnd(false); yield break; } if (an.speed != sp) an.speed = sp; if (!an.GetCurrentAnimatorStateInfo(0).IsName(c.finisherState)) an.Play(c.finisherState, 0, c.holdNormalized); yield return null; } // ④ 마지막 타격 + 이펙트 WLSlashFlurry.LastFinisherImpact = Time.time - _t0; if (_effect != null && _effect.activeSelf) _effect.SetActive(false); var fwd = pc.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize(); var rot = Quaternion.LookRotation(fwd); var cam = Camera.main; if (c.slashSwingPlane != 0) { // 프리팹의 호 = 로컬 XY 면 · 땅 충격 = 로컬 −X 쪽 → 로컬 Z = 시전자 오른쪽이면 −X = 앞(내려친 지점) · +X = 뒤 위(검이 들려 있던 쪽) var right = Vector3.Cross(Vector3.up, fwd); rot = Quaternion.LookRotation(right, Vector3.up); if (c.slashTilt != 0f && cam != null) { var a = Quaternion.AngleAxis(c.slashTilt, fwd); var b = Quaternion.AngleAxis(-c.slashTilt, fwd); var cf = cam.transform.forward; rot = (Mathf.Abs(Vector3.Dot(a * right, cf)) >= Mathf.Abs(Vector3.Dot(b * right, cf)) ? a : b) * rot; } } else if (c.slashFaceCamera != 0 && cam != null) { var cf = cam.transform.forward; cf.y = 0f; if (cf.sqrMagnitude > 1e-4f) { cf.Normalize(); bool right = Vector3.Dot(fwd, Vector3.Cross(Vector3.up, cf)) > 0f; // 시전자가 화면 오른쪽을 본다 rot = Quaternion.LookRotation(c.slashFlipRight != 0 && right ? -cf : cf); } } rot = rot * Quaternion.Euler(0f, c.slashYaw, 0f); var strike = pc.Get_position() + fwd * c.slashForward + Vector3.up * c.slashHeight; if (c.strikeAtTip != 0 && _hasTip && _socket != null) { var tip = _socket.TransformPoint(_tipLocal * _grow); // 검이 커져 있으면 커진 칼끝 strike = new Vector3(tip.x, pc.Get_position().y + c.slashHeight, tip.z); } var groundAt = new Vector3(strike.x, pc.Get_position().y, strike.z); if (!string.IsNullOrEmpty(c.groundFx)) { Spawn(c.groundFx, groundAt, Quaternion.LookRotation(fwd), c.groundFxScale, c.groundFxLife); WLSlashFlurry.GroundFx++; WLSlashFlurry.LastStrike = groundAt - pc.Get_position(); } var impactLocal = new Vector3(Get(c.slashImpactLocal, 0, 0f), Get(c.slashImpactLocal, 1, 0f), Get(c.slashImpactLocal, 2, 0f)); Spawn(c.slashFx, strike - rot * (impactLocal * c.slashScale), rot, c.slashScale, c.slashLife); _col.enabled = true; WLSlashFlurry.Windows++; WLSlashFlurry.FinisherHits++; yield return new WaitForFixedUpdate(); yield return new WaitForFixedUpdate(); _col.enabled = false; // ⑤ 끝 자세까지 · 빛 꺼짐 — #929 impactHoldSeconds > 0 이면 내려친 자세(impactHoldNormalized)에서 그 시간 멈춘 뒤 대기(멈춘 동안 이동 입력 무시 · 구르기 = 스킵) float f0 = Time.time; bool hold = c.impactHoldSeconds > 0f; float stopN = hold ? Mathf.Max(c.impactNormalized, c.impactHoldNormalized) : c.endNormalized; while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < stopN && Time.time - f0 < 1f) { if (FinBroken(pc)) { FinEnd(false); yield break; } if (an.speed != sp) an.speed = sp; _glow.Set(c.glowMax * (1f - Mathf.Clamp01((Time.time - f0) / Mathf.Max(0.01f, c.glowFadeSeconds))), col, c.glowFill, c.glowRimPower); Grow(Mathf.Lerp(c.growScale, 1f, Mathf.Clamp01((Time.time - f0) / Mathf.Max(0.01f, c.shrinkSeconds)))); yield return null; } if (hold) { float holdN = an.GetCurrentAnimatorStateInfo(0).normalizedTime, h0 = Time.time; an.speed = 0f; _held = true; WLSlashFlurry.Holds++; if (c.holdBlocksMove != 0) WLSkillCancel.HoldMove(pc, c.impactHoldSeconds); while (Time.time - h0 < c.impactHoldSeconds) { if (FinBroken(pc)) { FinEnd(false); yield break; } if (an.speed != 0f) an.speed = 0f; var si = an.GetCurrentAnimatorStateInfo(0); if (!si.IsName(c.finisherState) || Mathf.Abs(si.normalizedTime - holdN) > 0.01f) { an.Play(c.finisherState, 0, holdN); an.Update(0f); } _glow.Set(c.glowMax * (1f - Mathf.Clamp01((Time.time - f0) / Mathf.Max(0.01f, c.glowFadeSeconds))), col, c.glowFill, c.glowRimPower); Grow(Mathf.Lerp(c.growScale, 1f, Mathf.Clamp01((Time.time - f0) / Mathf.Max(0.01f, c.shrinkSeconds)))); yield return null; } WLSlashFlurry.LastHoldSeconds = Time.time - h0; } FinEnd(true); } bool FinBroken(PCActor pc) { return pc == null || pc.IsDead() || pc.Get_CurAnim() != eAnim.Skill; } void FinEnd(bool toIdle) { if (!toIdle) WLSlashFlurry.FinisherAborts++; if (_animOwned && _an != null) { _an.speed = 1f; _an.fireEvents = true; } _animOwned = false; var pc = _shooter as PCActor; if (toIdle && pc != null && !pc.IsDead() && pc.Get_CurAnim() == eAnim.Skill) pc.Play_Idle(); _glow.End(); GrowEnd(); if (_charge != null) { Destroy(_charge); _charge = null; } StopShine(); if (_held) { _held = false; WLSkillCancel.EndHoldMove(pc, toIdle); } _fin = null; _finDone = true; } /// #924 반짝임 자리 — 칼날 가운데 → 칼끝 사이 chargeShineAlong(검이 커져 있으면 커진 자리). Vector3 ShinePoint() { if (_socket == null) return SwordCenter(); var local = _hasTip ? Vector3.Lerp(_swordLocal, _tipLocal, Mathf.Clamp01(_c.chargeShineAlong)) : _swordLocal; return _socket.TransformPoint(local * _grow); } void StopShine() { if (_shine == null) return; foreach (var ps in _shine.GetComponentsInChildren(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmitting); Destroy(_shine, 1.2f); _shine = null; } Vector3 SwordCenter() { if (_socket != null) return _socket.TransformPoint(_swordLocal * _grow); return _shooter != null ? _shooter.Get_Center_position() : transform.position; } static float Get(float[] a, int i, float d) { return a != null && a.Length > i ? a[i] : d; } /// #924 칼끝(소켓 로컬) — 소켓 아래 켜진 메시 경계 모서리 중 소켓 원점에서 가장 먼 점(WLImpactRunner.SocketTip 과 같은 규칙 · 해머 각성도 이 방식). static bool TipLocal(Transform socket, out Vector3 tipLocal) { tipLocal = Vector3.zero; float best = -1f; foreach (var r in socket.GetComponentsInChildren(false)) { if (r == null || !r.enabled || r.name == "[WL] Glow") continue; var mf = r.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; var lb = mf.sharedMesh.bounds; for (int k = 0; k < 8; k++) { var corner = lb.center + new Vector3((k & 1) == 0 ? -lb.extents.x : lb.extents.x, (k & 2) == 0 ? -lb.extents.y : lb.extents.y, (k & 4) == 0 ? -lb.extents.z : lb.extents.z); var local = socket.InverseTransformPoint(r.transform.TransformPoint(corner)); float d = local.magnitude; if (d > best) { best = d; tipLocal = local; } } } return best > 0f; } // #923 검 크기 — 소켓 바로 아래 켜진 자식(검 모델)의 위치 · 크기를 함께 배율(쥔 손 = 소켓 원점 기준 · WLHammerQuake.Visual 과 같은 방식) void GrowBegin() { GrowEnd(); if (_socket == null) return; foreach (Transform ch in _socket) { if (!ch.gameObject.activeSelf) continue; _parts.Add(ch); _pos0.Add(ch.localPosition); _scl0.Add(ch.localScale); } } void Grow(float s) { _grow = s; for (int i = 0; i < _parts.Count; i++) { var p = _parts[i]; if (p == null) continue; p.localPosition = _pos0[i] * s; p.localScale = _scl0[i] * s; } } void GrowEnd() { for (int i = 0; i < _parts.Count; i++) { var p = _parts[i]; if (p == null) continue; p.localPosition = _pos0[i]; p.localScale = _scl0[i]; } _parts.Clear(); _pos0.Clear(); _scl0.Clear(); _grow = 1f; } static GameObject Spawn(string path, Vector3 pos, Quaternion rot, float scale, float life) { if (string.IsNullOrEmpty(path)) return null; GameObject prefab; if (!s_fx.TryGetValue(path, out prefab)) { prefab = Resources.Load(path); s_fx[path] = prefab; } if (prefab == null) { WLSlashFlurry.LastLog = "이펙트 없음 " + path; return null; } var go = Instantiate(prefab, pos, rot * prefab.transform.rotation); foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; } go.transform.localScale = prefab.transform.localScale * Mathf.Max(0.01f, scale); Destroy(go, Mathf.Max(0.1f, life)); return go; } void LateUpdate() { if (_active) { Step(); Place(); } } /// #916 PD 「플레이어가 공격하며 적이 밀리는 거리를 고려해 스텝을 밟는 타이밍에 전진」 — 걸음 = 동작(클립)이 바뀌는 순간(1타 → 2타 → 1타 → 2타). /// 그때 대상 몸 표면까지 거리가 stepKeep 보다 멀면(밀려난 만큼) 그 차이만큼(최대 stepMax) stepSeconds 동안 대상 쪽으로 다가가며 대상을 본다(NavMesh 위 이동). void Step() { if (_c.stepKeep <= 0f || _an == null || DSUtil.CheckNull(_shooter) || _shooter.IsDead()) return; var ci = _an.GetCurrentAnimatorClipInfo(0); int clip = ci.Length > 0 && ci[0].clip != null ? ci[0].clip.GetInstanceID() : 0; if (clip != 0 && clip != _lastClip) { _lastClip = clip; var foe = _shooter.Get_CurAnim() == eAnim.Skill ? FrontEnemy() : null; // 지금 베는 적 = 정면 가장 가까운 적(투사체 대상과 다를 수 있다 · Play 실측) if (foe != null) { var to = foe.Get_position() - _shooter.Get_position(); to.y = 0f; float gap = to.magnitude - WL.Combat.WLCombatMotionSettings.BodyRadius(foe) - _c.stepKeep; if (to.sqrMagnitude > 1e-4f) _shooter.transform.rotation = Quaternion.LookRotation(to); if (gap > 0.01f) { float d = Mathf.Min(gap, _c.stepMax); _stepVel = to.normalized * (d / Mathf.Max(0.02f, _c.stepSeconds)); _stepUntil = Time.time + _c.stepSeconds; WLSlashFlurry.Steps++; WLSlashFlurry.StepDistance += d; } } } if (Time.time < _stepUntil && _agent != null && _agent.isActiveAndEnabled && _agent.isOnNavMesh) _agent.Move(_stepVel * Time.deltaTime); } /// 시전자 정면(±60°) stepSearch m 안의 살아 있는 몹 중 가장 가까운 것 — 없으면 투사체 대상. Actor FrontEnemy() { Actor best = null; float bd = float.MaxValue; var info = ActorInfo.Ins; if (info != null) { var me = _shooter.Get_position(); var fwd = _shooter.transform.forward; fwd.y = 0f; fwd.Normalize(); var list = info.Get_EnemyActors(_shooter.IsEnemy(), me, 3.5f); if (list != null) { for (int i = 0; i < list.Count; i++) { var a = list[i]; if (a == null || a.IsDead() || !(a is MobActor)) continue; var v = a.Get_position() - me; v.y = 0f; float dist = v.magnitude; if (dist > 1e-3f && Vector3.Dot(v / dist, fwd) < 0.5f) continue; if (dist < bd) { bd = dist; best = a; } } info.Release_Actors(list); } } if (best == null && !DSUtil.CheckNull(_target) && !_target.IsDead()) best = _target; return best; } void Place() { if (DSUtil.CheckNull(_shooter)) return; var fwd = _shooter.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-6f) fwd = Vector3.forward; fwd.Normalize(); transform.position = _shooter.Get_position() + fwd * _c.forward + Vector3.up * _c.height; transform.rotation = Quaternion.LookRotation(fwd); } IEnumerator Co(List delays) { float t0 = Time.time; _effect.SetActive(true); var times = _c.hitTimes; if (times != null && times.Length > 0) { // #915 PD 「이펙트나 모션은 유지한 채 공격 타격수를 8회로」 — 원본 간격 표 대신 시작 기준 타격 시각(동작 약 0.84 초 안 · 동작이 끝나면 이펙트가 꺼진다 #914) for (int i = 0; i < times.Length; i++) { _col.enabled = false; float wait = t0 + times[i] - Time.time; if (wait > 0f) yield return new WaitForSeconds(wait); if (DSUtil.CheckNull(_shooter) || _shooter.IsDead()) break; _col.enabled = true; WLSlashFlurry.Windows++; yield return new WaitForFixedUpdate(); yield return new WaitForFixedUpdate(); } } else if (delays != null) for (int i = 0; i < delays.Count; i++) { _col.enabled = false; yield return new WaitForSeconds(delays[i]); if (DSUtil.CheckNull(_shooter) || _shooter.IsDead()) break; _col.enabled = true; WLSlashFlurry.Windows++; // 원본은 1 프레임만 켰다 → 그 프레임에 물리 계산이 없으면 타격이 빠진다(0.3 배속 실측 7번 중 2~3번) — 물리 계산 2회 동안 켠다 yield return new WaitForFixedUpdate(); yield return new WaitForFixedUpdate(); } _col.enabled = false; // #921 마무리가 있으면 그 끝까지 기다린다(연타 이펙트는 flurryFxOff 에 끈다 · 마무리 내려치기 이펙트가 대신) if (!_finDone) { while (!_finDone) { if (_effect != null && _effect.activeSelf && Time.time - t0 >= _c.flurryFxOff) _effect.SetActive(false); yield return null; } _active = false; transform.localScale = _baseScale; _co = null; WLSlashFlurry.Off(_pb); yield break; } float left = _c.duration - (Time.time - t0); if (left > 0f) yield return new WaitForSeconds(left); _active = false; transform.localScale = _baseScale; _co = null; WLSlashFlurry.Off(_pb); } void OnDisable() { _active = false; if (_hasBase) transform.localScale = _baseScale; if (_fin != null) { StopCoroutine(_fin); _fin = null; } if (!_finDone) FinEnd(false); // #921 이동 · 구르기 스킵으로 꺼지면 애니메이터 · 검 빛을 되돌린다 } } }