using UnityEngine; using WL.Combat; namespace WL.Player { /// /// 검 궤적 슬래시 VFX 재생기 (PD #712 ②). /// /// 콤보 타격 순간마다 슬래시 프리팹을 **검 소켓 위치·방향**으로 1회 재생한다. /// 매번 Instantiate 하지 않고 미리 만든 것을 돌려 쓴다(오브젝트 풀 · 기본 3개). /// /// 소켓 규약 — 캐릭터가 바뀌어도 같은 방식으로 찾는다: /// ① CombatSettings.slashSocketNames 의 이름을 계층에서 위에서부터 찾는다 (Kazuko = `Kazuko_claymore`) /// ② 못 찾으면 Humanoid 오른손 본 (`HumanBodyBones.RightHand`) — 두 캐릭터 모두 Humanoid 라 항상 잡힌다 /// /// 모든 수치는 CombatSettings(ScriptableObject)에서 읽는다(C45). /// public class SlashVfxPlayer : MonoBehaviour { [Header("설정 (비우면 PlayerCombat 것을 쓴다)")] [SerializeField] private CombatSettings settings; [Tooltip("검 소켓을 직접 지정하고 싶을 때. 비우면 이름 후보 → 오른손 본 순으로 찾는다")] [SerializeField] private Transform socketOverride; private Transform _socket; private Vector3 _bladeLocalDir = Vector3.forward; // 소켓 기준 검날 방향 (실측) private float _bladeLength = 1f; // 검날 길이(m) (실측) // ── PD #716 ④ 실제 궤적 추적 private Vector3 _tipPrev; // 직전 프레임 검끝 월드 위치 private bool _tipInit; private Vector3 _tipVelocity; // 검끝 속도(월드 · m/s) private Vector3 _lastNormal = Vector3.right; // 마지막 스윙 평면 법선 private float _meshTopExtent = 1f; // 슬래시 메시가 +Y 로 뻗은 길이(파티클 크기 반영) private GameObject[] _pool; private float[] _returnAt; private int _next; private Animator _animator; /// 실제로 쓰고 있는 소켓. 검증용. public Transform Socket { get { return _socket; } } /// 마지막으로 재생한 콤보 단계. 검증용. public int LastPlayedStep { get; private set; } /// 지금까지 재생한 횟수. 검증용. public int PlayCount { get; private set; } /// 검끝 월드 위치(현재 프레임). 검증용. public Vector3 TipPosition { get { return _socket != null ? _socket.TransformPoint(_bladeLocalDir * _bladeLength) : Vector3.zero; } } /// 검 손잡이(소켓 원점) 월드 위치. 검증용. public Vector3 HiltPosition { get { return _socket != null ? _socket.position : Vector3.zero; } } /// 검끝 속도(m/s). 검증용. public Vector3 TipVelocity { get { return _tipVelocity; } } /// 마지막 재생에서 쓴 스윙 평면 법선. 검증용. public Vector3 LastSwingNormal { get { return _lastNormal; } } /// 마지막으로 재생한 슬래시 오브젝트. 검증용. public Transform LastSpawned { get; private set; } private CombatSettings Config { get { if (settings != null) return settings; var pc = GetComponent(); return pc != null ? pc.Settings : null; } } private void Awake() { _animator = GetComponentInChildren(); ResolveSocket(); } private void OnEnable() { ResolveSocket(); } /// 소켓을 찾는다. 이름 후보 → Humanoid 오른손 본 순. public void ResolveSocket() { if (socketOverride != null) { _socket = socketOverride; MeasureBlade(); return; } var s = Config; if (s != null && s.slashSocketNames != null) { for (int i = 0; i < s.slashSocketNames.Length; i++) { var found = FindDeep(transform, s.slashSocketNames[i]); if (found != null) { _socket = found; MeasureBlade(); return; } // PD #715 ⑤ — 이름으로 찾은 경우에도 검날을 실측한다 } } if (_animator == null) _animator = GetComponentInChildren(); if (_animator != null && _animator.avatar != null && _animator.avatar.isHuman) _socket = _animator.GetBoneTransform(HumanBodyBones.RightHand); if (_socket == null) _socket = transform; MeasureBlade(); } /// /// 소켓(검) 메시의 로컬 바운즈에서 **검날 방향과 길이를 실측**한다 (PD #715 ⑤). /// 캐릭터마다 검의 로컬 축이 다르다 — Kazuko 클레이모어는 +Z(1.27m), P09 한손검은 -Y(0.73m). /// 고정 오프셋을 쓰면 한쪽에서 이펙트가 허공에 뜬다. 그래서 축을 재서 프레임을 만든다. /// private void MeasureBlade() { _bladeLocalDir = Vector3.forward; _bladeLength = 1f; if (_socket == null) return; var r = _socket.GetComponent(); if (r == null) r = _socket.GetComponentInChildren(); Mesh mesh = null; var smr = r as SkinnedMeshRenderer; if (smr != null) mesh = smr.sharedMesh; else { var mf = r != null ? r.GetComponent() : null; if (mf != null) mesh = mf.sharedMesh; } if (mesh == null) return; var b = mesh.bounds; int axis = (b.size.x >= b.size.y && b.size.x >= b.size.z) ? 0 : (b.size.y >= b.size.z ? 1 : 2); float sign = b.center[axis] < 0f ? -1f : 1f; // 바운즈 중심이 있는 쪽이 칼날 쪽 Vector3 dir = Vector3.zero; dir[axis] = sign; _bladeLength = b.size[axis]; // 검 메시가 소켓의 자식이면 그 로컬 회전까지 반영해 소켓 기준으로 바꾼다 if (r != null && r.transform != _socket) dir = _socket.InverseTransformDirection(r.transform.TransformDirection(dir)); _bladeLocalDir = dir.normalized; } /// 소켓 기준 검날 방향(로컬). 검증용. public Vector3 BladeLocalDir { get { return _bladeLocalDir; } } /// 실측한 검날 길이(m). 검증용. public float BladeLength { get { return _bladeLength; } } private static Transform FindDeep(Transform root, string name) { if (string.IsNullOrEmpty(name)) return null; if (root.name == name) return root; for (int i = 0; i < root.childCount; i++) { var r = FindDeep(root.GetChild(i), name); if (r != null) return r; } return null; } private void EnsurePool() { var s = Config; if (s == null || s.slashVfxPrefab == null) return; int size = Mathf.Max(s.slashVfxPoolSize, 1); if (_pool != null && _pool.Length == size) return; _pool = new GameObject[size]; _returnAt = new float[size]; for (int i = 0; i < size; i++) { var go = Instantiate(s.slashVfxPrefab); go.name = s.slashVfxPrefab.name + "_pool" + i; // PD #718 ② — 파티클을 월드 공간으로 돌린다. Local 이면 파티클까지 검을 따라 끌려다닌다. foreach (var ps in go.GetComponentsInChildren(true)) { var main = ps.main; main.simulationSpace = ParticleSystemSimulationSpace.World; } go.transform.SetParent(null, false); go.SetActive(false); _pool[i] = go; _returnAt[i] = 0f; } MeasureMeshExtent(_pool[0]); } /// /// 슬래시 메시가 로컬 +Y 로 뻗은 길이를 잰다(파티클 시작 크기 반영). /// 이 값으로 검날 길이에 맞춰 스케일을 잡는다 — 캐릭터마다 검 길이가 달라도 호가 칼끝을 덮는다. /// private void MeasureMeshExtent(GameObject sample) { _meshTopExtent = 1f; if (sample == null) return; float best = 0f; foreach (var ps in sample.GetComponentsInChildren(true)) { var psr = ps.GetComponent(); if (psr == null || psr.renderMode != ParticleSystemRenderMode.Mesh || psr.mesh == null) continue; float top = psr.mesh.bounds.max.y * ps.main.startSize.constantMax; if (top > best) best = top; } if (best > 0.0001f) _meshTopExtent = best; } /// /// 애니메이션이 적용된 뒤(LateUpdate) 검끝 위치를 샘플링해 **실제 궤적 속도**를 얻는다 (PD #716 ④). /// 이 속도가 스윙 평면을 정한다 — 검날 방향과 검끝 진행 방향이 만드는 평면이 곧 궤적의 평면이다. /// private void LateUpdate() { if (_socket == null) return; Vector3 tip = _socket.TransformPoint(_bladeLocalDir * _bladeLength); float dt = Time.deltaTime; if (_tipInit && dt > 0.00001f) _tipVelocity = (tip - _tipPrev) / dt; _tipPrev = tip; _tipInit = true; // PD #718 — 스윙 창 동안 검 두 점을 모은다(트레일 · 궤적 실측 공용) if (_swingActive) { _swingTimer -= dt; if (_trail != null) _trail.Sample(_socket.position, tip); if (_arcCount < _arcTip.Length) { _arcTip[_arcCount] = tip; _arcHilt[_arcCount] = _socket.position; _arcCount++; } if (_swingTimer <= 0f) EndSwing(); } } // ── PD #718 스윙 창 관리 private BladeTrail _trail; private bool _swingActive; private float _swingTimer; private int _swingStep; private float _pendingWindow = 0.3f; private readonly Vector3[] _arcTip = new Vector3[64]; private readonly Vector3[] _arcHilt = new Vector3[64]; private int _arcCount; /// 이번 스윙에서 모은 검끝 궤적 점 수. 검증용. public int ArcSampleCount { get { return _arcCount; } } /// 스윙 창이 열려 있는가. 검증용. public bool IsSwinging { get { return _swingActive; } } /// 트레일 컴포넌트. 검증용. public BladeTrail Trail { get { return _trail; } } /// 이번 스윙의 검끝 궤적 점(읽기용 · 개수는 ArcSampleCount). public Vector3 GetArcTip(int i) { return (i >= 0 && i < _arcCount) ? _arcTip[i] : Vector3.zero; } /// /// 공격 클립 재생 시작 시 호출 (PD #718 (2)) — 이펙트를 **스윙 시작 시점**에 연다. /// 히트 시점에 한 프레임 속도로 평면을 추정하던 방식은 검끝이 33ms 에 2.56m 까지 움직여 /// 실제 호와 어긋났다(PM 실측). 이제는 창이 열린 동안 궤적을 모아서 쓴다. /// public void OnAttackStarted(int comboStep, float clipLength) { var s = Config; if (s == null) return; if (_socket == null) ResolveSocket(); float startN = Mathf.Clamp01(s.slashSwingStartNormalized); float endN = Mathf.Clamp01(s.slashSwingEndNormalized); if (endN <= startN) endN = Mathf.Min(1f, startN + 0.3f); _swingStep = comboStep; _pendingWindow = Mathf.Max(clipLength * (endN - startN), 0.05f); CancelInvoke("BeginSwing"); Invoke("BeginSwing", Mathf.Max(clipLength * startN, 0f)); } private void BeginSwing() { var s = Config; if (s == null) return; _swingActive = true; _swingTimer = _pendingWindow; _arcCount = 0; if (s.slashMode == SlashMode.BladeTrail) { if (_trail == null) _trail = GetComponent(); if (_trail == null) _trail = gameObject.AddComponent(); _trail.Begin(); } } private void EndSwing() { _swingActive = false; var s = Config; if (_trail != null) _trail.End(); // 프리팹 방식은 **모아 둔 실제 궤적**으로 호를 잡아 스윙이 끝나는 순간 1회 배치한다. if (s != null && s.slashMode == SlashMode.PrefabArc && _arcCount >= 3) PlayArcFromSamples(_swingStep); // 트레일 방식은 원본의 fx·dust 만 검끝 자리에 터뜨려 "튀는" 느낌을 유지한다 (PD #718 ④) else if (s != null && s.slashMode == SlashMode.BladeTrail && s.trailSpawnBurst && _arcCount >= 1) PlayBurstAtTip(_arcTip[_arcCount - 1]); } /// /// 원본 프리팹의 **fx·dust 서브시스템만** 검끝 위치에 월드 스폰한다 (PD #718 ④). /// 크레센트(slash_add·slash_alp)는 트레일이 대신 그리므로 꺼 둔다. /// private void PlayBurstAtTip(Vector3 tipPos) { var s = Config; if (s == null || s.slashVfxPrefab == null) return; EnsurePool(); if (_pool == null || _pool.Length == 0) return; var go = _pool[_next]; _next = (_next + 1) % _pool.Length; if (go == null) return; StopAndClear(go); var t = go.transform; t.SetParent(null, false); t.position = tipPos; t.rotation = Quaternion.identity; t.localScale = Vector3.one * Mathf.Max(s.slashScale, 0.0001f); go.SetActive(true); foreach (var ps in go.GetComponentsInChildren(true)) { bool crescent = ps.gameObject.name == "slash_add" || ps.gameObject.name == "slash_alp"; var psr = ps.GetComponent(); if (psr != null) psr.enabled = !crescent; // 크레센트는 트레일이 그린다 if (!crescent) ps.Play(true); } _returnAt[(_next - 1 + _pool.Length) % _pool.Length] = Time.time + Mathf.Max(s.slashAutoReturnSeconds, 0.1f); LastSpawned = t; PlayCount++; } /// 스윙을 즉시 접는다(콤보 취소·피격 등). public void CancelSwing() { CancelInvoke("BeginSwing"); _swingActive = false; _arcCount = 0; if (_trail != null) _trail.Clear(); } /// /// 모아 둔 검끝 궤적으로 호의 중심·평면 법선·시작→끝 방향을 구해 프리팹을 월드에 1회 배치한다. /// (부모 없음 · 파티클도 월드 공간 — 검을 따라 끌려다니지 않는다) /// private void PlayArcFromSamples(int comboStep) { var s = Config; if (s == null || s.slashVfxPrefab == null) return; EnsurePool(); if (_pool == null || _pool.Length == 0) return; Vector3 first = _arcTip[0], last = _arcTip[_arcCount - 1]; Vector3 center = Vector3.zero; for (int i = 0; i < _arcCount; i++) center += _arcTip[i]; center /= _arcCount; // 평면 법선 — 궤적 점 **전체**로 최적 평면을 구한다(Newell 법). // 세 점(시작·중간·끝)만 쓰면 스윙이 조금만 휘어도 평면이 크게 틀어진다(실측 0.65~0.86m 이탈). Vector3 mid = _arcTip[_arcCount / 2]; Vector3 n = Vector3.zero; for (int i = 0; i < _arcCount; i++) { Vector3 a = _arcTip[i] - center; Vector3 b = _arcTip[(i + 1) % _arcCount] - center; n += Vector3.Cross(a, b); } if (n.sqrMagnitude < 0.000001f) n = Vector3.Cross(mid - first, last - mid); if (n.sqrMagnitude < 0.000001f) n = Vector3.Cross(last - first, Vector3.up); if (n.sqrMagnitude < 0.000001f) n = transform.right; n.Normalize(); Vector3 hiltMid = _arcHilt[_arcCount / 2]; Vector3 outward = (mid - hiltMid); if (outward.sqrMagnitude < 0.000001f) outward = transform.forward; outward.Normalize(); var go = _pool[_next]; _next = (_next + 1) % _pool.Length; if (go == null) return; StopAndClear(go); var t = go.transform; t.SetParent(null, false); // 월드 고정 t.rotation = Quaternion.LookRotation(n, outward); // Z=평면 법선 · Y=칼끝 방향 // 손 근처를 기준으로 삼되 **최적 평면 위로 투영**한다 — 그래야 호가 궤적 평면에 정확히 눕는다 hiltMid -= n * Vector3.Dot(hiltMid - center, n); t.position = hiltMid; float radius = Vector3.Distance(hiltMid, mid); float scale = Mathf.Max(s.slashScale, 0.0001f); if (s.slashFitToBlade && _meshTopExtent > 0.0001f) scale *= radius / _meshTopExtent; t.localScale = Vector3.one * scale; go.SetActive(true); var systems = go.GetComponentsInChildren(true); for (int i = 0; i < systems.Length; i++) systems[i].Play(true); _returnAt[(_next - 1 + _pool.Length) % _pool.Length] = Time.time + Mathf.Max(s.slashAutoReturnSeconds, 0.1f); LastPlayedStep = comboStep; LastSpawned = t; _lastNormal = n; PlayCount++; } /// 풀 인스턴스를 재사용하기 전에 반드시 멈추고 비운다 (PD #718 (3) — 잔상 방지). private static void StopAndClear(GameObject go) { var systems = go.GetComponentsInChildren(true); for (int i = 0; i < systems.Length; i++) { systems[i].Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear); systems[i].Clear(true); } go.SetActive(false); } private void Update() { if (_pool == null) return; float now = Time.time; for (int i = 0; i < _pool.Length; i++) { if (_pool[i] == null || !_pool[i].activeSelf) continue; if (_returnAt[i] > 0f && now >= _returnAt[i]) { StopAndClear(_pool[i]); // PD #718 (3) — 멈추고 비운 뒤 비활성 (잔상 방지) _returnAt[i] = 0f; } } } } }