558 lines
36 KiB
C#
558 lines
36 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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); }
|
||
}
|
||
}
|