// ───────────────────────────────────────────────────────────────────────────── // WLGreatWave.cs — 대검 주력 스킬(회심의 일격 Skill_FatalBlow) WL 방식: 기본 4타 내려치기 + 모으기 · 반짝임 → 검기 발사 (#925 · 2026-10-18) // // PD 원문(#925): 「대검 주력 스킬을 대검 기본 4타 공격 모션으로 변경하고, 내려치기 전 검기가 모이는 연출 및 검이 반짝이는 연출을 추가해줘.」 // 「변경 될 대검 주력 스킬에서 검을 내리치면 "Stylized Projectile FX Kit Mini Vol1" 에셋의 횡으로 날아가는 검기를 발사해 경로상 적들을 밀치며 // 최대 3회까지 다단히트 피해가 발생하도록 해줘.(밀리는 과정에 히트 판정 범위 내 있으면 또 히트가 발생되어야하며 0.2초 간격으로 피해 판정 발생) // → 피격 된 적에게 히트 이펙트도 발생되어야 함」 · 「변경 될 대검 주력스킬인 검기는 10m 거리를 날아가며 점차 크기가 줄어들며 소멸해야 함」 // ■ 흐름 // ① 시전(CombatEvents.SkillCast · 원본 Play_Skill) — 이 PC(classes) 의 skill(eEffect 이름)이면 시전 러너가 같은 프레임 LateUpdate 에 애니메이터를 넘겨받는다 // (원본 fatalblow 동작 대신 state = 기본 4타 · 클립 이벤트 끔 = 4타 판정 · 궤적이 같이 나가지 않게) · 원본 점프(Co_JumpAttack)는 Actor.Play_Skill 훅 1줄(NoJump)로 끈다 // ② 모으기 — holdNormalized(검을 든 자세 · #928 chargeState 가 있으면 그 상태의 chargeNormalized 자세)로 chargeSeconds 동안 멈춤: 검 흰 빛 0 → glowMax · 칼날 위 모으기 이펙트(chargeFx) · // 모으기 shineStart 지점부터 칼날을 따라 칼끝 쪽으로 미끄러지는 반짝임(shineFx) · 대상 쪽으로 돈다(turnDegPerSec) // ③ 내려치기 — slamSpeed 배속으로 impactNormalized(검이 땅에 닿는 프레임)까지 → 원본 발사(Actor.SkillEvent → Skill_FatalBlow 투사체) → // Skill_FatalBlow.Set 훅 1줄(TakeShot)이 그 투사체를 검기로 넘겨받는다 · #926 impactHoldNormalized(내려친 자세)에서 impactHoldSeconds 동안 멈춘 뒤 대기 // (멈춘 동안 이동 입력 무시 · 구르기 = 스킵 · PD 「사용한 직후 바로 내려친 상대로 0.5초가량 대기 … 스킬 시전 직 후 바로 적을 추척하는 상황을 피하기 위한 목적」) // ⑤ #926 땅 붙이기 — 기본 4타는 점프 내려치기 동작이라 매 프레임(LateUpdate · 애니메이션 적용 뒤) 몸(hipsBone)을 내려 가장 낮은 발 뼈를 땅에 붙인다 // (PD 「캐릭터가 공중에 뜨지 않고 제자리에서 사용하도록」) // ④ 검기 — 초승달 앞머리가 시전자 앞 waveStartForward m 에서 정면으로 waveDistance m 를 waveSeconds 동안 나아가며 크기 waveScale → 0(곡선 waveShrinkPower) · // 판정 = 초승달 띠(검기 원점에서 반지름 R 안쪽 band m · 정면 ±waveHalfAngle° · R · band 는 크기와 함께 줄어듦) 안의 적 · 적마다 hitInterval 초 간격 · 최대 maxHits 회 // (원본 투사체 ProcessCollider = 스킬 피해 · 피격 반응) + 맞은 적 몸 중심에 hitFx · 최대 횟수를 다 맞기 전까지 띠 안의 적은 앞머리 이동 × pushMul 만큼 함께 밀림 // (NavMeshAgent.Move · 보스는 pushBoss 0 이면 밀지 않음) // ■ 이동 · 구르기 — 발사 전에 스킬 상태를 벗어나면 모으기 · 빛을 되돌리고 발사하지 않는다 · 발사된 검기는 그대로 날아감(WLSkillCancel noSkip) // ■ 값 = Resources/WL/WLGreatWave.json (C45) · 끄기 = enabled 0 → 원본 회심의 일격 · 원본 수정 = Actor.Play_Skill 1줄 · Skill_FatalBlow.Set 1줄 // 🔴 Debug.LogError 금지(원본 ErrorLogHook 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Weapon { [System.Serializable] public class WLGreatWaveJson { public int enabled = 0; public int[] classes = new int[0]; public string skill = "Skill_FatalBlow"; // eEffect 이름 public int noJump = 1; // 1 = 원본 점프(Co_JumpAttack)를 하지 않는다 // 동작 — 기본 4타 상태를 빌려 쓴다 public string state = "Attack4"; public float holdNormalized = 0.36f; // 모으기 동안 멈출 자세(검을 든 채) — chargeState 가 비었을 때 state 의 이 자세 // #928 PD 「검기를 모을 때 들고 있는 검이 곧게 세워져서 보이도록 (양손으로 검을 잡고 뒤양팔 뒤로 검이 곧게 서 있는 형태)」 · 「공격할 대상을 바라보며 검을 들고 기를 모으게」 public string chargeState = ""; // 모으기 자세를 다른 상태에서(비우면 state · holdNormalized) public float chargeNormalized = 0.12f; public float slamFromNormalized = -1f; // 내려치기 시작 = state 의 이 자세로 넘어감(음수 = holdNormalized 에서 그대로 재생) public float slamCrossFade = 0.06f; // 그때 섞어 넘어가는 시간(초) public float chargeSeconds = 0.5f; public float slamSpeed = 1.3f; // 내려칠 때 애니메이터 배속 public float impactNormalized = 0.52f; // 검이 땅에 닿는 순간 = 검기 발사 public float endNormalized = 0.72f; // 여기까지 재생하고 대기(impactHoldSeconds 0 일 때) public float turnDegPerSec = 720f; // 모으기 · 내려치기 동안 대상 쪽으로 도는 속도(0 = 돌지 않음) // #927 PD 「대검 주력 스킬을 쓸 때 공격할 대상의 방향을 바라보며 공격하도록」 — 시작 · 발사 순간엔 대상 쪽으로 바로 돌린다 public int faceSnap = 1; // 1 = 시작 · 발사 순간 대상 방향으로 즉시 public float faceRadius = 12f; // 대상 = 지금 대상(살아 있고 이 거리 안) · 없으면 이 거리 안 가장 가까운 적 // #926 PD 「사용한 직후 바로 내려친 상대로 0.5초가량 대기하도록 해서 임펙트를 주면 좋겠어」 — 발사 뒤 내려친 자세로 멈춤 public float impactHoldNormalized = 0.46f; // 발사 뒤 이 자세(검이 땅에 박힌 채 착지)까지 재생하고 public float impactHoldSeconds = 0.5f; // 이 시간 동안 멈춘 뒤 대기(0 = 멈추지 않고 endNormalized 까지) public int holdBlocksMove = 1; // 1 = 멈춘 동안 이동 입력 무시(구르기는 허용 = 스킵) // #926 PD 「캐릭터가 공중에 뜨지 않고 제자리에서 사용하도록」 — 기본 4타 = 점프 내려치기 동작(엉덩이 최대 +0.33 m · 발끝 +0.4 m 실측) public int groundLock = 1; // 1 = 스킬 동안 매 프레임 몸(hipsBone)을 내려 groundBones 중 가장 낮은 뼈를 땅(+groundPad)에 붙인다 public string hipsBone = "Root_M"; public string[] groundBones = { "ToesEnd_L", "ToesEnd_R" }; public float groundPad = 0f; // 모으기 연출 public string glowMaterial = "WL/Fx/WL_Mat_WeaponGlow"; public float[] glowColor = { 1.2f, 2.8f, 2.4f }; // #958 PD 「대검 주력 스킬 모으기에 그라데이션 형태의 색상(노란색 계열)」 — glowColor2 = 칼끝 쪽 색(glowColor = 손잡이 쪽 · 비면 단색) · chargeFxGradient = 모으기 입자 수명 색(r,g,b 셋씩 2개 이상 · 비면 원래 색) public float[] glowColor2 = new float[0]; public float[] chargeFxGradient = new float[0]; // #958 PD 「대검 주력 스킬 발동 중 파란색 궤적 이펙트는 보이지 않도록」 — 모으기 이펙트에서 끌 입자계 이름(사본 이름의 (Clone) 무시 · 호 모양 ChargePath = 루트) public string[] chargeFxHide = new string[0]; public float glowMax = 1f, glowFill = 0.35f, glowRimPower = 2.5f, glowFadeSeconds = 0.25f; public string chargeFx = ""; public float chargeFxScale = 0.12f; public float chargeFxAlong = 0.4f; // 칼날 가운데(0) → 칼끝(1) public string shineFx = ""; public float shineScale = 0.5f; public float shineStart = 0.35f; // 모으기 진행(0~1) 이 지점에서 반짝임 시작 public float shineFrom = 0f, shineTo = 1f; // 반짝임이 미끄러지는 칼날 위치(가운데 0 → 칼끝 1 · 음수 = 손잡이 쪽) // 검기 public string waveFx = ""; public float waveScale = 0.5f; // 시작 크기(프리팹 배율) public float waveRadius = 3f; // 프리팹 배율 1 의 초승달 반지름(m · 중심 = 프리팹 원점 · 위에서 본 실측) public float waveBand = 1f; // 프리팹 배율 1 의 판정 띠 두께(m · 반지름 안쪽으로) public float waveHalfAngle = 100f; // 판정 각(정면 ±°) public float waveDistance = 10f; // 초승달 앞머리가 나아가는 거리(m) public float waveSeconds = 0.9f; public float waveShrinkPower = 1.5f; // 크기 = waveScale × (1 − 진행^이 값) — 1 = 일정하게 · 클수록 처음엔 크게 · 끝에 빠르게 public int waveShrink = 1; // #928 0 = 크기 그대로(줄이지 않음) public float waveFadeDistance = 0f; // #928 사정거리 끝 이 거리(m) 앞부터 투명해지며 사라짐(WLFxFade · 0 = 없음) public float waveHeight = 0.6f; // 발 기준 높이(m) public float waveStartForward = 0.6f; // 앞머리 시작 = 시전자 앞 이 거리(m) // 판정 public float hitInterval = 0.2f; public int maxHits = 3; public float pushMul = 1f; // 최대 횟수 전인 띠 안의 적을 앞머리 이동 × 이 값만큼 민다(0 = 밀지 않음) public int pushBoss = 0; public string hitFx = ""; // #929 PD 「검기가 지나가는 경로의 지면은 Stylized Earth Skills 에셋의 FX_EarthSkill_Path_03을 지면에 깔아줘」 · 「내려친 위치에 FX_EarthSkill_Popup_05 이펙트를 배치」 public string groundFx = ""; // 내려친 자리(칼끝을 땅으로 내린 곳) 지면 이펙트 public float groundFxScale = 0.45f, groundFxLife = 2.5f; public string pathFx = ""; // 검기가 지나가는 길 지면 이펙트(프리팹 +Z 로 pathFxNativeLength m · 배율 = waveDistance / 그 길이) public float pathFxNativeLength = 8f, pathFxLife = 2.5f, pathFxWidth = 1f; // pathFxWidth = 옆 폭 배율(길이 배율에 곱) // #931 PD 「대검을 내려친 지점부터 3m 정도 간격까지만 충격이 퍼쳐나간 느낌으로」 — 경로 지면 이펙트 길이(m · 0 = waveDistance) public float pathFxLength = 0f; public float hitFxScale = 0.3f; public float hitFxLife = 1.5f; public string note = ""; } public static class WLGreatWave { public const string ResourcesPath = "WL/WLGreatWave"; public static int Casts, Takeovers, Fired, Shots, Hits, Pushes, Aborts, Errors, MaxHitsOnOne, Holds, GroundShown, PathShown; public static float LastFront, LastScaleEnd, LastShotSeconds, PushDistance, MaxLower, LastHoldSeconds; public static string LastLog = ""; static WLGreatWaveJson s_cfg; internal static readonly Dictionary s_fx = new Dictionary(); internal static WLGreatWaveJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLGreatWaveJson(); 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; s_fx.Clear(); CombatEvents.SkillCast.Remove(OnCast); CombatEvents.SkillCast.Add(OnCast); } static bool Mine(Actor a, out PCActor pc) { pc = a as PCActor; var c = Cfg; if (c.enabled == 0 || pc == null || !pc.IsMainPC()) return false; return c.classes == null || c.classes.Length == 0 || System.Array.IndexOf(c.classes, pc.Get_ID()) >= 0; } static void OnCast(in SkillCastEvent e) { try { PCActor pc; if (e.skill == null || !Mine(e.actor, out pc) || e.skill.e_Skill.ToString() != Cfg.skill) return; var run = pc.GetComponent(); if (run == null) run = pc.gameObject.AddComponent(); run.Begin(pc, Cfg); Casts++; LastLog = "시전 #" + Casts; } catch (System.Exception ex) { Errors++; LastLog = "시전 실패 — " + ex.Message; } } /// 원본 Actor.Play_Skill 훅 — true 면 원본 점프(Co_JumpAttack)를 하지 않는다. public static bool NoJump(Actor a) { try { PCActor pc; return Mine(a, out pc) && Cfg.noJump != 0; } catch (System.Exception ex) { Errors++; LastLog = "점프 확인 실패 — " + ex.Message; return false; } } /// 원본 Skill_FatalBlow.Set 훅 — true 면 WL 이 이 투사체를 검기로 맡는다(원본 판정 상자 · 이펙트 · LookAt 건너뜀). public static bool TakeShot(ProjectileBase pb, Collider box) { try { var data = pb != null ? pb.Get_ProjectTileData() : null; PCActor pc; if (data == null || !Mine(data.shooter, out pc)) return false; var run = pb.GetComponent(); if (run == null) run = pb.gameObject.AddComponent(); run.Begin(pb, pc, box, Cfg); Shots++; LastLog = "검기 #" + Shots; return true; } catch (System.Exception ex) { Errors++; LastLog = "검기 넘겨받기 실패 — " + ex.Message; return false; } } internal 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 == null) { prefab = Resources.Load(path); s_fx[path] = prefab; } if (prefab == null) { LastLog = "이펙트 없음 " + path; return null; } var go = Object.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); if (life > 0f) Object.Destroy(go, life); return go; } internal static float Get(float[] a, int i, float d) { return a != null && a.Length > i ? a[i] : d; } /// #958 이름이 names 에 든 입자계의 렌더러를 끈다(이름 끝 (Clone) 무시) — 끈 수. internal static int HideLayers(GameObject go, string[] names) { if (go == null || names == null || names.Length == 0) return 0; int n = 0; foreach (var ps in go.GetComponentsInChildren(true)) { string nm = ps.name.Replace("(Clone)", "").Trim(); if (System.Array.IndexOf(names, nm) < 0) continue; var r = ps.GetComponent(); if (r != null && r.enabled) { r.enabled = false; n++; } } return n; } /// #958 입자 색을 수명 그라데이션(g = r,g,b 셋씩 · 2색 이상)으로 — 시작색은 흰색(알파 유지) · 수명 알파는 원래 것(없으면 1). internal static void TintGradient(GameObject go, float[] g) { if (go == null || g == null || g.Length < 6) return; int n = g.Length / 3; var keys = new GradientColorKey[Mathf.Min(n, 8)]; for (int i = 0; i < keys.Length; i++) keys[i] = new GradientColorKey(new Color(g[i * 3], g[i * 3 + 1], g[i * 3 + 2]), (float)i / (keys.Length - 1)); foreach (var ps in go.GetComponentsInChildren(true)) { var sc0 = ps.main.startColor; if (sc0.mode == ParticleSystemGradientMode.Color && Mathf.Max(sc0.color.r, Mathf.Max(sc0.color.g, sc0.color.b)) < 0.25f) continue; // 어둡게 까는 층(DarkBG)은 그대로 var col = ps.colorOverLifetime; GradientAlphaKey[] ak = col.enabled && col.color.mode == ParticleSystemGradientMode.Gradient && col.color.gradient != null ? col.color.gradient.alphaKeys : new[] { new GradientAlphaKey(1f, 0f), new GradientAlphaKey(1f, 1f) }; var gr = new Gradient(); gr.SetKeys(keys, ak); col.enabled = true; col.color = new ParticleSystem.MinMaxGradient(gr); var m = ps.main; var sc = m.startColor; float a = sc.mode == ParticleSystemGradientMode.Color ? sc.color.a : 1f; m.startColor = new Color(1f, 1f, 1f, a); } } } /// PC 에 붙는 시전 러너 — 동작(기본 4타) · 모으기 · 반짝임 · 내려치기 · 발사 · 내려친 자세 멈춤 · 끝 · 몸을 땅에 붙임(#926). [DefaultExecutionOrder(1000)] internal sealed class WLGreatWaveCaster : MonoBehaviour { PCActor _pc; Animator _an; WLGreatWaveJson _c; bool _pending, _running, _animOwned, _fired, _held; Coroutine _co; readonly WLWeaponGlowFx _glow = new WLWeaponGlowFx(); Transform _socket; Vector3 _midLocal, _tipLocal; bool _hasTip; GameObject _charge, _shine; float _shineAlong; Actor _foe; Transform _hips; readonly List _feet = new List(); public void Begin(PCActor pc, WLGreatWaveJson c) { if (_running) { if (_co != null) StopCoroutine(_co); Finish(false); } _pc = pc; _an = pc.m_animation; _c = c; _pending = true; _fired = false; _foe = null; } // 원본 Play_Skill 이 SkillCast 뒤에 원본 동작(fatalblow)을 재생한다 → 그 프레임 애니메이터 갱신 뒤(LateUpdate)에 덮고 바로 적용(Update(0)) // #926 매 프레임 애니메이션이 자세를 쓴 뒤 몸을 땅에 붙이고(GroundLock) · 그 뒤 칼날 기준 이펙트 자리를 잡는다(PlaceFx) void LateUpdate() { if (_pending) { _pending = false; if (_pc == null || _an == null || _pc.IsDead() || _pc.Get_CurAnim() != eAnim.Skill) { WLGreatWave.Aborts++; WLGreatWave.LastLog = "시작 전 끊김"; return; } _running = true; _animOwned = true; _an.fireEvents = false; _an.Play(_c.state, 0, _c.holdNormalized); _an.speed = 0f; _an.Update(0f); if (!string.IsNullOrEmpty(_c.chargeState)) { _an.Play(_c.chargeState, 0, _c.chargeNormalized); _an.Update(0f); } FindBones(); _foe = Foe(); if (_c.faceSnap != 0) Face(_foe, true); WLGreatWave.Takeovers++; _co = StartCoroutine(Run()); } if (!_running) return; GroundLock(); PlaceFx(); } void FindBones() { _hips = null; _feet.Clear(); if (_c.groundLock == 0) return; foreach (var t in _pc.GetComponentsInChildren(true)) { if (t.name == _c.hipsBone) _hips = t; else if (_c.groundBones != null && System.Array.IndexOf(_c.groundBones, t.name) >= 0) _feet.Add(t); } if (_hips == null || _feet.Count == 0) WLGreatWave.LastLog = "땅 붙이기 뼈 없음 " + _c.hipsBone; } /// #926 기본 4타 = 점프 내려치기 → 가장 낮은 발 뼈가 땅(+groundPad)보다 높으면 그만큼 몸(엉덩이 뼈 = 뼈대 맨 위)을 내린다. 애니메이터가 매 프레임 자세를 다시 쓰므로 쌓이지 않는다. void GroundLock() { if (_hips == null || _feet.Count == 0) return; float gy = _pc.Get_position().y, low = float.MaxValue; for (int i = 0; i < _feet.Count; i++) if (_feet[i] != null) low = Mathf.Min(low, _feet[i].position.y - gy); float d = low - _c.groundPad; if (d <= 0f || d > 2f) return; _hips.position -= Vector3.up * d; if (d > WLGreatWave.MaxLower) WLGreatWave.MaxLower = d; } void PlaceFx() { if (_charge != null) _charge.transform.position = Blade(_c.chargeFxAlong); if (_shine != null) _shine.transform.position = Blade(_shineAlong); } bool Broken() { return _pc == null || _pc.IsDead() || _pc.Get_CurAnim() != eAnim.Skill; } bool InState() { return _an.GetCurrentAnimatorStateInfo(0).IsName(_c.state); } /// 그 자세로 멈춤 유지 — 다른 연출이 애니메이터 속도 · 상태를 되돌리면 다시 멈춘다. void Pin(float n) { Pin(_c.state, n); } void Pin(string state, float n) { if (_an.speed != 0f) _an.speed = 0f; if (!_an.GetCurrentAnimatorStateInfo(0).IsName(state) || Mathf.Abs(_an.GetCurrentAnimatorStateInfo(0).normalizedTime - n) > 0.01f) { _an.Play(state, 0, n); _an.Update(0f); } } IEnumerator Run() { var c = _c; var an = _an; // ② 모으기 — 검 빛 · 모으기 이펙트 · 반짝임 · 대상 쪽으로 돌기 Transform sk; Vector3 tl; _hasTip = WL.Combat.Impact.WLImpactRunner.SocketTip(_pc, out sk, out tl); _socket = sk; _tipLocal = tl; if (_socket != null) { _glow.Begin(_socket, c.glowMaterial); _midLocal = WLWeaponGlowFx.CenterLocal(_socket); } var col = new Color(WLGreatWave.Get(c.glowColor, 0, 1f), WLGreatWave.Get(c.glowColor, 1, 1f), WLGreatWave.Get(c.glowColor, 2, 1f), 1f); _grad = c.glowColor2 != null && c.glowColor2.Length >= 3; // #958 손잡이 → 칼끝 그라데이션 if (_grad) _col2 = new Color(c.glowColor2[0], c.glowColor2[1], c.glowColor2[2], 1f); _charge = WLGreatWave.Spawn(c.chargeFx, Blade(c.chargeFxAlong), Quaternion.identity, c.chargeFxScale, c.chargeSeconds + 1f); WLGreatWave.TintGradient(_charge, c.chargeFxGradient); // #958 모으기 입자 노란 계열 그라데이션 WLGreatWave.HideLayers(_charge, c.chargeFxHide); // #958 파란 호(ChargePath) 숨김 bool other = !string.IsNullOrEmpty(c.chargeState); string cs = other ? c.chargeState : c.state; float cn = other ? c.chargeNormalized : c.holdNormalized; float t = 0f; while (t < c.chargeSeconds) { if (Broken()) { Finish(false); yield break; } Pin(cs, cn); float k = Mathf.Clamp01(t / Mathf.Max(0.01f, c.chargeSeconds)); GlowSet(c.glowMax * k, col); Face(Foe(), false); if (_shine == null && k >= c.shineStart && !string.IsNullOrEmpty(c.shineFx)) _shine = WLGreatWave.Spawn(c.shineFx, Blade(c.shineFrom), Quaternion.identity, c.shineScale, c.chargeSeconds + 3f); _shineAlong = Mathf.Lerp(c.shineFrom, c.shineTo, Mathf.InverseLerp(c.shineStart, 1f, k)); t += Time.deltaTime; yield return null; } GlowSet(c.glowMax, col); StopFx(ref _charge, 0.6f); _shineAlong = c.shineTo; // ③ 내려치기 float sp = Mathf.Max(0.1f, c.slamSpeed); float from = c.slamFromNormalized >= 0f ? c.slamFromNormalized : c.holdNormalized; an.speed = sp; if (c.slamFromNormalized >= 0f) // #928 모으기 자세 → state 의 내려치기 자세(정규화 시각 from)로 섞어 넘어감 · 섞는 시간은 지금 상태 길이 기준 정규화 an.CrossFade(c.state, Mathf.Max(0f, c.slamCrossFade) / Mathf.Max(0.05f, an.GetCurrentAnimatorStateInfo(0).length), 0, from); else an.Play(c.state, 0, from); yield return null; float s0 = Time.time; while (true) { if (Broken()) { Finish(false); yield break; } if (an.speed != sp) an.speed = sp; bool fading = an.IsInTransition(0); if (!fading && InState() && an.GetCurrentAnimatorStateInfo(0).normalizedTime >= c.impactNormalized) break; if (!fading && !InState() && Time.time - s0 > c.slamCrossFade + 0.1f) an.Play(c.state, 0, from); // 섞기가 끊겼으면 바로 Face(Foe(), false); yield return null; } StopFx(ref _shine, 1.2f); // 발사 — 대상 쪽으로 즉시 돌린 뒤 원본 SkillEvent(→ Skill_FatalBlow 투사체 → TakeShot 이 시전자 정면으로 검기) if (c.faceSnap != 0) Face(Foe(), true); Ground(c); _fired = true; WLGreatWave.Fired++; _pc.SkillEvent(0); float f0 = Time.time; if (c.impactHoldSeconds > 0f) { // ④ #926 내려친 자세(impactHoldNormalized)까지 → impactHoldSeconds 동안 멈춤(이동 입력 무시 · 구르기 = 스킵) — 시전 직후 바로 적을 쫓아가지 않게 while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < c.impactHoldNormalized && Time.time - f0 < 1f) { if (Broken()) { Finish(false); yield break; } if (an.speed != sp) an.speed = sp; Fade(f0, col); yield return null; } float holdN = an.GetCurrentAnimatorStateInfo(0).normalizedTime, h0 = Time.time; an.speed = 0f; _held = true; WLGreatWave.Holds++; if (c.holdBlocksMove != 0) WLSkillCancel.HoldMove(_pc, c.impactHoldSeconds); while (Time.time - h0 < c.impactHoldSeconds) { if (Broken()) { Finish(false); yield break; } Pin(holdN); Fade(f0, col); yield return null; } WLGreatWave.LastHoldSeconds = Time.time - h0; } else // ④ 끝 자세까지 · 빛 꺼짐 while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < c.endNormalized && Time.time - f0 < 1.5f) { if (Broken()) { Finish(false); yield break; } if (an.speed != sp) an.speed = sp; Fade(f0, col); yield return null; } Finish(true); } void Fade(float since, Color col) { GlowSet(_c.glowMax * (1f - Mathf.Clamp01((Time.time - since) / Mathf.Max(0.01f, _c.glowFadeSeconds))), col); } // #958 단색(glowColor) 또는 손잡이 glowColor → 칼끝 glowColor2 그라데이션 Color _col2; bool _grad; void GlowSet(float g, Color col) { if (_grad) _glow.Set(g, col, _col2, _c.glowFill, _c.glowRimPower); else _glow.Set(g, col, _c.glowFill, _c.glowRimPower); } /// #929 내려친 자리(칼끝을 땅으로 · 없으면 시전자 앞 waveStartForward) 지면 이펙트 + 검기가 지나갈 길(시전자 정면 · 앞머리 시작 → waveDistance) 지면 이펙트. void Ground(WLGreatWaveJson c) { var fwd = _pc.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize(); float gy = _pc.Get_position().y; var look = Quaternion.LookRotation(fwd); var hit = _pc.Get_position() + fwd * c.waveStartForward; if (_socket != null && _hasTip) { var tip = _socket.TransformPoint(_tipLocal); hit = new Vector3(tip.x, gy, tip.z); } hit.y = gy; if (WLGreatWave.Spawn(c.groundFx, hit, look, c.groundFxScale, c.groundFxLife) != null) WLGreatWave.GroundShown++; var start = _pc.Get_position() + fwd * c.waveStartForward; start.y = gy; var path = WLGreatWave.Spawn(c.pathFx, start, look, 1f, c.pathFxLife); if (path != null) { float k = (c.pathFxLength > 0f ? c.pathFxLength : c.waveDistance) / Mathf.Max(0.1f, c.pathFxNativeLength); path.transform.localScale = new Vector3(k * c.pathFxWidth, k, k); WLGreatWave.PathShown++; } } /// 끝 — 애니메이터 되돌림 · 대기(toIdle) · 멈춤 동안 미룬 뗌 적용 · 빛 · 이펙트 정리. 발사 전에 끊기면(이동 · 구르기) 발사하지 않는다(원본 이동 · 구르기가 기다리던 스킬을 지운다). void Finish(bool toIdle) { if (!toIdle) { WLGreatWave.Aborts++; WLGreatWave.LastLog = "끊김 — " + (_fired ? (_held ? "멈춤 중" : "발사 뒤") : "발사 전"); } if (_animOwned && _an != null) { _an.speed = 1f; _an.fireEvents = true; } _animOwned = false; _running = false; _co = null; if (toIdle && _pc != null && !_pc.IsDead() && _pc.Get_CurAnim() == eAnim.Skill) _pc.Play_Idle(); if (_held) { _held = false; WLSkillCancel.EndHoldMove(_pc, toIdle); } _glow.End(); StopFx(ref _charge, 0.6f); StopFx(ref _shine, 1.2f); } void OnDisable() { if (_running) { if (_co != null) StopCoroutine(_co); Finish(false); } } static void StopFx(ref GameObject go, float after) { if (go == null) return; foreach (var ps in go.GetComponentsInChildren(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmitting); Destroy(go, after); go = null; } /// 칼날 위 자리 — 가운데(0) → 칼끝(1) 사이 a(음수 = 손잡이 쪽). Vector3 Blade(float a) { if (_socket == null) return _pc != null ? _pc.Get_Center_position() : transform.position; var local = _hasTip ? Vector3.LerpUnclamped(_midLocal, _tipLocal, a) : _midLocal; return _socket.TransformPoint(local); } /// #927 공격할 대상 — 지금 대상(살아 있고 faceRadius 안)이 우선 · 없으면 faceRadius 안 가장 가까운 적 · 고른 대상이 죽으면 다시 고른다. Actor Foe() { if (Alive(_foe)) return _foe; var tg = _pc.Get_Target(); if (Alive(tg)) return _foe = tg; var info = ActorInfo.Ins; if (info == null) return _foe = null; return _foe = info.Get_Nearest_EnemyActor_orNull(_pc.IsEnemy(), _pc.Get_position(), _c.faceRadius); } bool Alive(Actor a) { if (DSUtil.CheckNull(a) || a.IsDead() || !a.gameObject.activeInHierarchy) return false; var v = a.Get_position() - _pc.Get_position(); v.y = 0f; return v.magnitude <= _c.faceRadius; } /// 대상 쪽(수평)으로 돈다 — now = 즉시 · 아니면 turnDegPerSec. void Face(Actor foe, bool now) { if (DSUtil.CheckNull(foe) || foe.IsDead()) return; if (!now && _c.turnDegPerSec <= 0f) return; var to = foe.Get_position() - _pc.Get_position(); to.y = 0f; if (to.sqrMagnitude < 1e-4f) return; var want = Quaternion.LookRotation(to); _pc.transform.rotation = now ? want : Quaternion.RotateTowards(_pc.transform.rotation, want, _c.turnDegPerSec * Time.deltaTime); } } /// 넘겨받은 Skill_FatalBlow 투사체 1개 = 검기 1개(날기 · 줄어듦 · 띠 판정 · 다단 · 밀기 · 끝). 풀에서 다시 쓰이면 Begin 이 새로 시작한다. internal sealed class WLGreatWaveShot : MonoBehaviour { sealed class Foe { public Actor a; public Collider col; public int hits; public float last = -99f; } ProjectileBase _pb; PCActor _pc; Collider _box; WLGreatWaveJson _c; GameObject _fx; Vector3 _fxScale0, _dir, _front0; float _t0, _prevFront, _y; bool _running, _fading; readonly List _hidden = new List(); readonly Dictionary _foes = new Dictionary(); public void Begin(ProjectileBase pb, PCActor pc, Collider box, WLGreatWaveJson c) { if (_running) End(false); _pb = pb; _pc = pc; _box = box; _c = c; _foes.Clear(); if (_box != null) _box.enabled = false; Hide(); _dir = pc.transform.forward; _dir.y = 0f; if (_dir.sqrMagnitude < 1e-6f) _dir = Vector3.forward; _dir.Normalize(); _y = pc.Get_position().y + c.waveHeight; _front0 = pc.Get_position() + _dir * c.waveStartForward; _front0.y = _y; transform.SetPositionAndRotation(_front0, Quaternion.LookRotation(_dir)); _fx = WLGreatWave.Spawn(c.waveFx, _front0 - _dir * (c.waveRadius * c.waveScale), Quaternion.LookRotation(_dir), 1f, 0f); if (_fx != null) { _fxScale0 = _fx.transform.localScale; _fx.transform.localScale = _fxScale0 * Mathf.Max(0.01f, c.waveScale); } _t0 = Time.time; _prevFront = 0f; _running = true; _fading = false; } void Update() { if (!_running) return; var c = _c; float u = Mathf.Clamp01((Time.time - _t0) / Mathf.Max(0.05f, c.waveSeconds)); float scale = c.waveShrink != 0 ? c.waveScale * (1f - Mathf.Pow(u, Mathf.Max(0.01f, c.waveShrinkPower))) : c.waveScale; float R = c.waveRadius * scale, band = c.waveBand * scale; float front = c.waveDistance * u, dFront = front - _prevFront; _prevFront = front; var frontPos = _front0 + _dir * front; var pivot = frontPos - _dir * R; transform.position = frontPos; if (_fx != null) { _fx.transform.position = pivot; _fx.transform.localScale = _fxScale0 * Mathf.Max(0.001f, scale); } WLGreatWave.LastFront = front; WLGreatWave.LastScaleEnd = scale; // #928 PD 「검기의 크기는 변동 없이 사정거리에서 1m 남짓 남을 때부터 점차 투명해지며 사라지게」 — 남은 거리를 남은 시간 동안 흐려 지움(방출 멈춤 + 입자 알파 → 0) if (!_fading && c.waveFadeDistance > 0f && _fx != null && front >= c.waveDistance - c.waveFadeDistance) { _fading = true; float speed = c.waveDistance / Mathf.Max(0.05f, c.waveSeconds); WLFxFade.Attach(_fx, 0f, Mathf.Max(0.05f, (c.waveDistance - front) / Mathf.Max(0.01f, speed))); } Tick(pivot, R, band, dFront); if (u >= 1f) End(true); } /// 띠 판정 — 검기 원점에서 반지름 R 안쪽 band 안(몸 반지름 여유) · 정면 ±waveHalfAngle° 의 적. 적마다 hitInterval 간격 · 최대 maxHits 회 · 그 전엔 앞머리와 함께 밀기. void Tick(Vector3 pivot, float R, float band, float dFront) { var info = ActorInfo.Ins; if (info == null || _pb == null || _pc == null) return; var c = _c; float now = Time.time; var list = info.Get_EnemyActors(_pc.IsEnemy(), pivot, R + 3f); if (list == null) return; float cosHalf = Mathf.Cos(Mathf.Clamp(c.waveHalfAngle, 0f, 180f) * Mathf.Deg2Rad); for (int k = 0; k < list.Count; k++) { var a = list[k]; if (a == null || a.IsDead() || !a.gameObject.activeInHierarchy) continue; var v = a.Get_position() - pivot; v.y = 0f; float r = v.magnitude, body = WL.Combat.WLCombatMotionSettings.BodyRadius(a); if (r - body > R || r + body < R - band) continue; if (r > 1e-3f && Vector3.Dot(v / r, _dir) < cosHalf) continue; int id = a.GetInstanceID(); Foe f; if (!_foes.TryGetValue(id, out f)) { f = new Foe { a = a, col = a.GetComponent() }; _foes[id] = f; } if (f.hits >= c.maxHits) continue; if (now - f.last >= c.hitInterval - 1e-3f && f.col != null && f.col.enabled) { if (_pb.ProcessCollider(f.col, false)) { f.hits++; f.last = now; WLGreatWave.Hits++; if (f.hits > WLGreatWave.MaxHitsOnOne) WLGreatWave.MaxHitsOnOne = f.hits; WLGreatWave.Spawn(c.hitFx, a.Get_Center_position() + WL.Combat.Impact.WLImpactFx.TowardCamera(a), Quaternion.LookRotation(_dir), c.hitFxScale, c.hitFxLife); // #941 몸 앞(카메라 쪽) } } // 밀기 — 최대 횟수 전인 동안 앞머리와 함께(보스 제외 · 내비 위) if (f.hits < c.maxHits && c.pushMul > 0f && dFront > 0f && !a.IsDead() && (c.pushBoss != 0 || !a.IsSubRole(eSubRol.Boss))) { var ag = a.Get_Agent(); if (ag != null && ag.enabled && ag.isOnNavMesh) { ag.Move(_dir * (dFront * c.pushMul)); WLGreatWave.Pushes++; WLGreatWave.PushDistance += dFront * c.pushMul; } } } info.Release_Actors(list); } void Hide() { _hidden.Clear(); foreach (var ps in GetComponentsInChildren(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); foreach (var r in GetComponentsInChildren(true)) { if (!r.enabled) continue; r.enabled = false; _hidden.Add(r); } } void End(bool offProjectile) { _running = false; WLGreatWave.LastShotSeconds = Time.time - _t0; if (_fx != null) { Destroy(_fx); _fx = null; } foreach (var r in _hidden) if (r != null) r.enabled = true; _hidden.Clear(); _foes.Clear(); if (offProjectile && _pb != null && _pb.gameObject.activeSelf) WLSlashFlurry.Off(_pb); } void OnDisable() { if (_running) End(false); } } }