Project_WL/Assets/WL/Combat/Weapon/WLSlashFlurry.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.

// ─────────────────────────────────────────────────────────────────────────────
// 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 이동 · 구르기 스킵으로 꺼지면 애니메이터 · 검 빛을 되돌린다
}
}
}