2026-10-18 15:09:05 +00:00
// ─────────────────────────────────────────────────────────────────────────────
// 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 ;
2026-10-18 17:27:34 +00:00
WLGreatWave . Spawn ( c . hitFx , a . Get_Center_position ( ) + WL . Combat . Impact . WLImpactFx . TowardCamera ( a ) , Quaternion . LookRotation ( _dir ) , c . hitFxScale , c . hitFxLife ) ; // #941 몸 앞(카메라 쪽)
2026-10-18 15:09:05 +00:00
}
}
// 밀기 — 최대 횟수 전인 동안 앞머리와 함께(보스 제외 · 내비 위)
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 ) ; }
}
}