// ───────────────────────────────────────────────────────────────────────────── // SlashArcFollower.cs — 슬래시 궤적 이펙트가 플레이어를 따라가게 한다 (PD 지시 #777) // // PD 원문 (2026-09-06): // "무기 궤적 이펙트가 플레이어 움직임에 맞게 위치가 자연스럽게 이동되어야하는데 // 지금은 고정 된 위치에 나와서 이펙트와 무기가 떨어진 느낌이야. 수정해줘" // // ── 왜 떨어져 보이는가 (구조) ─────────────────────────────────────────────── // #761 BurstMode.PrefabArc 는 스윙이 끝난 순간(OnSwingEnd) 검끝 궤적으로 평면을 구해 // 원본 슬래시 프리팹을 **월드 고정**으로 1회 배치한다(부모 없음). 배치 뒤에는 아무도 // 그 인스턴스를 움직이지 않는다. // 거기에 #762 AttackStepDriver 가 공격마다 캐릭터를 0.30/0.70/0.65 m 전진시키므로 // (0.25 s 동안 NavMeshAgent.Move) **이펙트는 스윙 지점에 남고 캐릭터만 앞으로 나간다.** // → 이펙트 수명(0.4~1.0 s) 동안 그 거리만큼 무기와 이펙트가 벌어진다. // // ── 이 컴포넌트가 하는 일 ─────────────────────────────────────────────────── // 스폰 콜백에서 인스턴스에 붙어, 매 LateUpdate 마다 **스폰 시점 PC 기준 상대 오프셋**을 // 유지하도록 위치를 갱신한다. 회전 추종은 선택(arcFollowRotation). // · 위치만 추종 : pos = anchor.position + (스폰 시점 월드 오프셋) // · 회전까지 추종: pos/rot = anchor 로컬 좌표계에 고정 (PC 가 돌면 이펙트도 돈다) // // ── 왜 부모로 붙이지 않는가 (설계 결정) ───────────────────────────────────── // InGameInfo 는 이펙트 인스턴스를 풀(dic_str_Effect)에서 재사용하고 TurnOff_GO 가 // 수명이 끝나면 SetActive(false) 로 돌려놓는다. 이때 부모가 PC 로 바뀌어 있으면 // · PC 가 파괴/비활성될 때 풀 인스턴스가 같이 사라지거나 비활성 트리에 갇히고 // · 다음 재사용 때 부모가 남아 있어 엉뚱한 곳에서 재생된다. // 풀 구조를 건드리지 않는 것이 조건이므로(발주 A2) **부모는 그대로 두고 좌표만 따라간다**. // 기각안: transform.SetParent(pc) — 위 이유로 기각. 원복도 어렵다. // // ── 파티클 시뮬레이션 공간 ────────────────────────────────────────────────── // 이미터가 Local(moveWithTransform=0)이면 이미 방출된 입자도 루트를 따라오므로 잔상이 없다. // World 인 이미터가 섞여 있으면 그 입자만 제자리에 남는다 → 실측해 보고한다(발주 A2). // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Combat { /// /// 스폰된 슬래시 이펙트 인스턴스가 플레이어를 따라 움직이게 한다. /// 런타임에만 AddComponent 로 붙으므로 프리팹 에셋은 건드리지 않는다(즉시 원복 · C8). /// [DisallowMultipleComponent] public sealed class SlashArcFollower : MonoBehaviour { private Transform _anchor; private bool _followRotation; private float _expireTime; // Time.time 기준. 0 이하 = 이펙트 수명 전체 private bool _active; private Vector3 _worldOffset; // 위치 추종 — 스폰 시점 (이펙트 − PC) 월드 오프셋 private Vector3 _localPos; // 회전 추종 — PC 로컬 위치 private Quaternion _localRot; // 회전 추종 — PC 로컬 회전 // ── 검증용 (Play 중 CLI 로 읽는다 · 게임 로직은 쓰지 않는다) ──────────── /// 지금까지 추종을 시작한 횟수. public static int AttachCount; /// 마지막으로 추종을 시작한 인스턴스. public static SlashArcFollower Last; /// 마지막 추종 시작 요약. public static string LastInfo; /// 추종 중인가. public bool IsFollowing { get { return _active && _anchor != null; } } /// 스폰 시점 (이펙트 − PC) 월드 오프셋. public Vector3 SpawnOffset { get { return _worldOffset; } } /// 추종 기준(PC) 트랜스폼. 해제됐으면 null. public Transform Anchor { get { return _anchor; } } /// /// 인스턴스에 추종을 건다. 이미 붙어 있으면 기준을 다시 잡는다(풀 재사용 대응). /// /// 스폰된 이펙트 인스턴스 /// 따라갈 기준(PC 루트 트랜스폼) /// PC 회전까지 따라갈지 /// 추종 유지 시간(초). 0 이하 = 이펙트 수명 전체 public static SlashArcFollower Attach(GameObject go, Transform anchor, bool followRotation, float seconds) { if (go == null || anchor == null) return null; var f = go.GetComponent(); if (f == null) f = go.AddComponent(); f.Begin(anchor, followRotation, seconds); return f; } /// 추종을 끈다(설정에서 arcFollowPlayer 를 껐을 때 · 풀 인스턴스 원복). public static void Detach(GameObject go) { if (go == null) return; var f = go.GetComponent(); if (f != null) f.Release(); } private void Begin(Transform anchor, bool followRotation, float seconds) { _anchor = anchor; _followRotation = followRotation; _expireTime = seconds > 0f ? Time.time + seconds : 0f; _active = true; _worldOffset = transform.position - anchor.position; _localPos = anchor.InverseTransformPoint(transform.position); _localRot = Quaternion.Inverse(anchor.rotation) * transform.rotation; AttachCount++; Last = this; LastInfo = string.Format("{0} anchor={1} offset={2} rot={3} sec={4:F2}", gameObject.name, anchor.name, _worldOffset.ToString("F2"), followRotation, seconds); } /// 추종을 즉시 해제한다. 위치는 그 자리에 남는다(기존 동작과 같아진다). public void Release() { _active = false; _anchor = null; } private void LateUpdate() { if (!_active) return; if (_anchor == null) { _active = false; return; } if (_expireTime > 0f && Time.time >= _expireTime) { _active = false; return; } if (_followRotation) { transform.position = _anchor.TransformPoint(_localPos); transform.rotation = _anchor.rotation * _localRot; } else { transform.position = _anchor.position + _worldOffset; } } // 풀에 반환될 때(SetActive(false)) 반드시 끊는다 — 다음 재사용에서 이전 PC 를 // 계속 따라다니는 일이 없도록. 다시 쓸 때는 스폰 콜백이 Attach 로 새로 건다. private void OnDisable() { Release(); } } }