Project_WL/Assets/WL/Combat/Weapon/WLGreatWave.cs

558 lines
36 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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 };
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<string, GameObject> s_fx = new Dictionary<string, GameObject>();
internal static WLGreatWaveJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLGreatWaveJson();
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;
}
}
[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<WLGreatWaveCaster>();
if (run == null) run = pc.gameObject.AddComponent<WLGreatWaveCaster>();
run.Begin(pc, Cfg);
Casts++; LastLog = "시전 #" + Casts;
}
catch (System.Exception ex) { Errors++; LastLog = "시전 실패 — " + ex.Message; }
}
/// <summary>원본 Actor.Play_Skill 훅 — true 면 원본 점프(Co_JumpAttack)를 하지 않는다.</summary>
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; }
}
/// <summary>원본 Skill_FatalBlow.Set 훅 — true 면 WL 이 이 투사체를 검기로 맡는다(원본 판정 상자 · 이펙트 · LookAt 건너뜀).</summary>
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<WLGreatWaveShot>();
if (run == null) run = pb.gameObject.AddComponent<WLGreatWaveShot>();
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<GameObject>(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<ParticleSystem>(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; }
}
/// <summary>PC 에 붙는 시전 러너 — 동작(기본 4타) · 모으기 · 반짝임 · 내려치기 · 발사 · 내려친 자세 멈춤 · 끝 · 몸을 땅에 붙임(#926).</summary>
[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<Transform> _feet = new List<Transform>();
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<Transform>(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;
}
/// <summary>#926 기본 4타 = 점프 내려치기 → 가장 낮은 발 뼈가 땅(+groundPad)보다 높으면 그만큼 몸(엉덩이 뼈 = 뼈대 맨 위)을 내린다. 애니메이터가 매 프레임 자세를 다시 쓰므로 쌓이지 않는다.</summary>
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); }
/// <summary>그 자세로 멈춤 유지 — 다른 연출이 애니메이터 속도 · 상태를 되돌리면 다시 멈춘다.</summary>
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);
_charge = WLGreatWave.Spawn(c.chargeFx, Blade(c.chargeFxAlong), Quaternion.identity, c.chargeFxScale, c.chargeSeconds + 1f);
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));
_glow.Set(c.glowMax * k, col, c.glowFill, c.glowRimPower);
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;
}
_glow.Set(c.glowMax, col, c.glowFill, c.glowRimPower);
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)
{
_glow.Set(_c.glowMax * (1f - Mathf.Clamp01((Time.time - since) / Mathf.Max(0.01f, _c.glowFadeSeconds))), col, _c.glowFill, _c.glowRimPower);
}
/// <summary>#929 내려친 자리(칼끝을 땅으로 · 없으면 시전자 앞 waveStartForward) 지면 이펙트 + 검기가 지나갈 길(시전자 정면 · 앞머리 시작 → waveDistance) 지면 이펙트.</summary>
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++;
}
}
/// <summary>끝 — 애니메이터 되돌림 · 대기(toIdle) · 멈춤 동안 미룬 뗌 적용 · 빛 · 이펙트 정리. 발사 전에 끊기면(이동 · 구르기) 발사하지 않는다(원본 이동 · 구르기가 기다리던 스킬을 지운다).</summary>
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<ParticleSystem>(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmitting);
Destroy(go, after); go = null;
}
/// <summary>칼날 위 자리 — 가운데(0) → 칼끝(1) 사이 a(음수 = 손잡이 쪽).</summary>
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);
}
/// <summary>#927 공격할 대상 — 지금 대상(살아 있고 faceRadius 안)이 우선 · 없으면 faceRadius 안 가장 가까운 적 · 고른 대상이 죽으면 다시 고른다.</summary>
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;
}
/// <summary>대상 쪽(수평)으로 돈다 — now = 즉시 · 아니면 turnDegPerSec.</summary>
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);
}
}
/// <summary>넘겨받은 Skill_FatalBlow 투사체 1개 = 검기 1개(날기 · 줄어듦 · 띠 판정 · 다단 · 밀기 · 끝). 풀에서 다시 쓰이면 Begin 이 새로 시작한다.</summary>
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<Renderer> _hidden = new List<Renderer>();
readonly Dictionary<int, Foe> _foes = new Dictionary<int, Foe>();
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);
}
/// <summary>띠 판정 — 검기 원점에서 반지름 R 안쪽 band 안(몸 반지름 여유) · 정면 ±waveHalfAngle° 의 적. 적마다 hitInterval 간격 · 최대 maxHits 회 · 그 전엔 앞머리와 함께 밀기.</summary>
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<Collider>() }; _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<ParticleSystem>(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
foreach (var r in GetComponentsInChildren<Renderer>(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); }
}
}