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

558 lines
36 KiB
C#
Raw Normal View History

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