// ───────────────────────────────────────────────────────────────────────────── // WeaponTrailDriver.cs — 힘민지(WL) 무기 궤적 트레일 훅 // // PD 지시 #747 (2026-09-05): // "공격 이펙트가 플레이어 무기 궤적에 맞게 보이도록 수정해줘. // (WL 프로젝트에 구현한 방식 참고) 현재 힘민지에서는 이펙트가 공격 중인 무기에 // 맞춰진게 아니라 캐릭터 기준으로 보이도록 되어있어." // // ── 현재 동작(실측) ───────────────────────────────────────────────────────── // 공격 클립 이벤트 ShowEffect(int, string) // → Actor.ShowEffect(AnimationEvent) [Actor.cs:2349] // → case 0: InGameInfo.Show_Effect(prefab, Get_Center_position(), transform.eulerAngles) // case 1: InGameInfo.Show_Effect(prefab, Get_position(), transform.eulerAngles) // 즉 위치·회전이 **캐릭터 기준**이라 무기가 실제로 지나간 자리와 무관하다. 이것이 #747 의 증상. // // ── 이 컴포넌트가 하는 일 ─────────────────────────────────────────────────── // ① Actor.ShowEffect 에서 근접 PC 인 경우에만 가로채(TryHandleShowEffect) // ② 무기 소켓(PCActor.tfs_weapon)의 무기 메시에서 손잡이·검끝을 실측하고 // ③ 스윙 창 동안 LateUpdate 마다 두 점을 BladeTrail 에 넘겨 월드 리본을 굽고 // ④ 스윙이 끝나면 검끝 자리에 원본 슬래시 프리팹을 1회 터뜨린다(버스트) // 몬스터·원거리 클래스·설정 미비 시에는 false 를 돌려 **기존 경로를 그대로 태운다**. // // ── 설계 결정: 스윙 창을 무엇으로 여닫는가 ────────────────────────────────── // 선택 = **개폐 시작은 ShowEffect 애니메이션 이벤트 · 종료는 클립 정규화 시간(normalizedTime)** // 근거(모두 힘민지 코드 실측): // · Actor.Play_Attack 은 m_animation.speed 를 스탯/CC(ATKSPD_Up·ATKSPD_Down)로 // 매번 바꾼다[Actor.cs:1938~1946]. 그래서 "클립 길이 × 비율"을 공격 시작 시점에 // 초 단위로 계산해 Invoke 로 지연시키는 구 WL 방식(SlashVfxPlayer.OnAttackStarted)은 // 공격 속도가 바뀌면 그대로 어긋난다. // · 반면 ShowEffect 이벤트는 클립에 **작가가 스윙 순간에 찍어 둔 것**이고 // (예: Paladin attack.FBX 0.173s), 애니메이션 이벤트는 애니메이터 시간으로 // 발화하므로 m_animation.speed 가 얼마든 항상 같은 포즈에서 뜬다. 드리프트가 없다. // · 종료도 초가 아니라 normalizedTime 증분으로 잰다 — 속도·클립 길이와 무관하게 // 같은 구간을 잡는다. 안전 상한(swingMaxSeconds)과 상태 전환 감지를 함께 둔다. // 기각안: Animator 상태를 매 프레임 폴링해 공격 상태 진입을 감지하는 방식. // → Actor 는 m_animation.Play(aniname) 로 즉시 전환하고[Actor.cs:2021] 클립마다 // 스윙 시작 위상이 달라, 폴링만으로는 "언제가 스윙 시작인가"를 알 수 없다. // 그 정보는 이미 ShowEffect 이벤트에 들어 있으므로 이벤트를 쓰는 편이 정확하고 싸다. // // ── 구 WL 교훈 준수 (#718) ────────────────────────────────────────────────── // · 궤적 이펙트를 무기 본 자식으로 붙이지 않는다 → BladeTrail 은 부모 없는 월드 오브젝트 // · 강체 호(크레센트) 프리팹으로 임의 스윙을 맞추려 하지 않는다 → 2점 리본 // · 검끝 버스트는 스윙 시작이 아니라 **스윙이 끝난 검끝 자리**에 월드 공간 1회 스폰 // // ── 힘민지 코드 수정 범위 (2단계) ─────────────────────────────────────────── // Actor.cs 에 2줄(using + 가로채기 1줄)만 추가하면 된다. PCActor.cs 는 수정 불필요 // (tfs_weapon 이 public, Get_ID()/IsRole() 이 public 이라 밖에서 다 읽힌다). // 상세 diff = PLAN_2단계.md // // 🔴 어셈블리 주의: 힘민지 Assets/Script/ 에는 .asmdef 가 없다(전부 Assembly-CSharp). // 이 파일이 놓일 Assets/WL/Combat/ 에도 .asmdef 를 만들지 말 것. // // 1단계 산출물(스테이징). 2단계에 PM 지시로 Assets/WL/Combat/ 로 이동한다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Combat { /// /// PC 의 근접 공격에서 무기가 실제로 지나간 자리에 궤적 리본을 그린다. /// 가 필요할 때 런타임으로 자동 부착하므로 /// 프리팹을 미리 손댈 필요가 없다. /// [DisallowMultipleComponent] public sealed class WeaponTrailDriver : MonoBehaviour { /// 소켓 하나에 물린 무기의 실측 결과 + 그 무기 전용 트레일. private sealed class WeaponSlot { public int socketIndex; public Transform socket; public Transform bladeRoot; // 검날 메시를 가진 렌더러의 트랜스폼 public Vector3 hiltLocal; // 소켓 로컬 — 자루 끝 public Vector3 tipLocal; // 소켓 로컬 — 칼끝 public float bladeLength; // 월드 길이(m) public float bladeAspect; // 최장축 / 두 번째 축 public int socketChildCount; // 캐시 무효화용 public BladeTrail trail; public bool IsAlive { get { return socket != null && bladeRoot != null && socket.childCount == socketChildCount; } } public Vector3 HiltWorld { get { return socket.TransformPoint(hiltLocal); } } public Vector3 TipWorld { get { return socket.TransformPoint(tipLocal); } } } private PCActor _pc; private Animator _animator; private SlashTrailSettings _settings; private readonly List _slots = new List(); private bool _slotsResolved; private int _resolvedClassId = -1; // ── 스윙 창 상태 ───────────────────────────────────────────────────────── private bool _swingActive; private int _swingStateHash; private float _swingStartNormalized; private float _swingStartTime; private string _burstPrefab; // ── #792 매핑 이펙트 상태 ──────────────────────────────────────────── /// 이번 스윙에서 이벤트 시점에 이미 매핑 이펙트를 띄웠는가(중복 방지). private bool _mappedSpawned; /// 이번 스윙의 캘리브레이션 키(클래스ID + 이벤트 stringParameter). private string _mappedKey; /// 이벤트가 뜬 시각(초). 스폰 지연을 재는 데 쓴다. private float _eventTime; /// 런타임에 실측해 채운 캘리브레이션 캐시. SO 에 구운 값이 1순위다. private static readonly Dictionary s_runtimeCal = new Dictionary(); /// 프리팹 기하 런타임 폴백 캐시(SO 에 베이크가 없을 때만 쓴다). private static readonly Dictionary s_arcFallback = new Dictionary(); private static readonly Dictionary s_stabFallback = new Dictionary(); /// 런타임 캘리브레이션 캐시(에디터 베이크가 SO 로 옮긴다). public static Dictionary RuntimeCalibrations { get { return s_runtimeCal; } } /// 캐시를 비운다(설정을 바꾼 뒤 재실측 · 검증용). public static void ClearMappedCache() { s_runtimeCal.Clear(); s_arcFallback.Clear(); s_stabFallback.Clear(); s_warmed.Clear(); } /// 마지막 #792 배치를 사람이 읽는 한 줄로. **검증 전용** — CLI 덤프가 읽는다. public static string LastMappedInfo; /// /// #792 배치 1회의 **목표값과 실제 결과값**. 검증 전용(발주 §4 실측표) — 게임 로직은 읽지 않는다. /// 목표(스윙 실측)와 결과(배치된 인스턴스 트랜스폼에서 역산)를 함께 담으므로 /// 오차를 추정이 아니라 실측으로 낼 수 있다(C39). /// public struct MappedPlacement { public int classId; public string source; // 원본 이벤트 프리팹 이름 public string effect; // 실제로 띄운 이펙트 이름 public SwingKind kind; public bool fromCache; // 이벤트 시점 즉시 배치였는가 public float delayMs; // 이벤트 → 배치까지 걸린 시간 // 목표(스윙 실측 · 월드) public Vector3 targetNormal, targetBisector, targetPivot; public float tipRadius, hiltRadius, sweepSwing; // 결과(배치된 인스턴스에서 역산 · 월드) public Vector3 arcNormal, arcBisector, arcCenter; public float arcRadius, scale, sweepMesh; // 찌르기 public Vector3 stabAxisTarget, stabAxisPlaced, stabStartTarget, stabStartPlaced; public float stabLenTarget, stabLenPlaced; } /// 마지막 배치의 목표/결과. 검증 전용. public static MappedPlacement LastPlacement; /// #792 경로로 배치한 총 횟수. 검증 전용. public static int MappedSpawnCount; // ── 검증용 공개 프로퍼티 (2단계 CSV 샘플러가 읽는다) ──────────────────── /// 스윙 창이 열려 있는가. public bool IsSwinging { get { return _swingActive; } } /// 궤적을 그리는 무기 슬롯 수(양손 단검이면 2). public int ActiveSlotCount { get { return _slots.Count; } } /// i 번째 슬롯의 트레일. 없으면 null. public BladeTrail GetTrail(int i) { return (i >= 0 && i < _slots.Count) ? _slots[i].trail : null; } /// i 번째 슬롯의 현재 검끝 월드 좌표. public Vector3 GetTipWorld(int i) { return (i >= 0 && i < _slots.Count && _slots[i].IsAlive) ? _slots[i].TipWorld : Vector3.zero; } /// i 번째 슬롯의 현재 손잡이 월드 좌표. public Vector3 GetHiltWorld(int i) { return (i >= 0 && i < _slots.Count && _slots[i].IsAlive) ? _slots[i].HiltWorld : Vector3.zero; } /// i 번째 슬롯에서 실측한 검날 길이(m). public float GetBladeLength(int i) { return (i >= 0 && i < _slots.Count) ? _slots[i].bladeLength : 0f; } /// 지금까지 스윙을 연 횟수. public int SwingCount { get; private set; } // ───────────────────────────────────────────────────────────────────────── // 진입점 — Actor.ShowEffect 가 부른다 // ───────────────────────────────────────────────────────────────────────── /// /// ShowEffect 애니메이션 이벤트를 궤적 트레일 경로로 처리했는지 돌려준다. /// true 면 호출부(Actor.ShowEffect)는 기존 캐릭터 기준 경로를 **타지 않는다**. /// false 면 아무것도 하지 않았으므로 호출부는 기존 동작을 그대로 이어간다 /// — 몬스터·원거리 클래스·설정 에셋 부재·무기 미장착이 모두 여기에 해당한다(C8 롤백 경로). /// public static bool TryHandleShowEffect(Actor actor, AnimationEvent ev) { if (actor == null || ev == null) return false; var settings = SlashTrailSettings.Instance; if (settings == null || !settings.enableWeaponTrail) return false; // 플레이어만 대상. 몬스터·펫·NPC 는 기존 경로 그대로(기존 동작 불변). if (!actor.IsRole(eRole.PC)) return false; var pc = actor as PCActor; if (pc == null) return false; // 근접 클래스인지 데이터로 판정한다(C45 — 코드에 클래스 ID 를 넣지 않는다). SlashTrailClassOverride ov; if (!IsTrailClass(actor.Get_ID(), settings, out ov)) { // 근접 → 원거리 전환(예: 10501 → 10401)이면 이전 클래스의 트레일 오브젝트가 // 부모 없는 월드 오브젝트로 남는다. 여기서 확실히 정리한다(실측 2026-09-05). var stale = actor.GetComponent(); if (stale != null) stale.ReleaseSlots(); return false; } var driver = actor.GetComponent(); if (driver == null) driver = actor.gameObject.AddComponent(); bool handled = driver.HandleAttackEvent(pc, settings, ov, ev); WL.Combat.Core.CombatEvents.RaiseSwingEffect(pc, ev, handled); // WL #811b 코어 통지(검기 창 시작) return handled; } /// /// 이 클래스에 궤적 트레일을 적용할지 판정한다. /// 우선순위 = 설정 에셋의 클래스 오버라이드 → e_BattleType → f_AttackRange. /// 테이블을 못 읽으면 false(기존 동작 유지)를 돌려 안전 측으로 떨어진다. /// /// 실측 근거(ClassConfig.json 전 12행): /// 근접 OneHand 10101·10102 / DualWeapon 10201·10202 / Shield 10501·10502 /// → 이 6개 클래스에만 Effect_Slash_{classId}_* 프리팹이 존재한다(20종). /// 원거리 Staff 10301~10303(사거리 5) / Bow 10401·10402(사거리 6) → 슬래시 프리팹 없음. /// TwoHand 10601 은 무기 프리팹이 양손 모두 빈칸이라 어차피 궤적을 못 그린다. /// private static bool IsTrailClass(int classId, SlashTrailSettings settings, out SlashTrailClassOverride ov) { ov = settings.FindOverride(classId); if (ov != null) { if (ov.mode == TrailClassMode.ForceOn) return true; if (ov.mode == TrailClassMode.ForceOff) return false; } if (table_classconfig.Ins == null) return false; var cfg = table_classconfig.Ins.Get_Data_orNull(classId); if (cfg == null) return false; // 1순위 — 전투 타입(의미가 명확하고 사거리 튜닝에 흔들리지 않는다). var types = settings.meleeBattleTypes; if (types != null && types.Length > 0) { for (int i = 0; i < types.Length; i++) if (types[i] == cfg.e_BattleType) return true; return false; } // 2순위 — 사거리(전투 타입 목록을 비워 뒀을 때). return cfg.f_AttackRange <= settings.meleeMaxAttackRange; } // ───────────────────────────────────────────────────────────────────────── // 스윙 창 // ───────────────────────────────────────────────────────────────────────── private bool HandleAttackEvent(PCActor pc, SlashTrailSettings settings, SlashTrailClassOverride ov, AnimationEvent ev) { _pc = pc; _settings = settings; if (_animator == null) _animator = pc.m_animation; int classId = pc.Get_ID(); if (classId != _resolvedClassId) { ReleaseSlots(); _resolvedClassId = classId; } if (!EnsureSlots(ov)) return false; // 무기를 못 찾으면 기존 경로로 넘긴다 // 애니메이터 상태를 못 읽으면 종료 조건을 잴 수 없다 → 기존 경로로 넘긴다. int hash; float norm; if (!TryReadState(out hash, out norm)) return false; _burstPrefab = ev.stringParameter; // PD #792 — (클래스, 클립) 캘리브레이션이 이미 있으면 **이벤트가 뜬 이 프레임에** 배치한다. // 원본 이펙트와 같은 타이밍에 뜨는 것이 요구 사양이고, 스윙 평면·반지름·피벗은 // 이미 캐릭터 로컬로 캐시돼 있어 지금 바로 월드로 펼칠 수 있다(스폰 지연 0 프레임). // 캐시가 없으면 스윙이 끝난 뒤 실측해 배치하고 그 결과를 캐시에 넣는다(조합당 1회만). _mappedSpawned = false; _mappedKey = null; _eventTime = Time.realtimeSinceStartup; if (_settings.useMappedEffects && !string.IsNullOrEmpty(_burstPrefab)) { WarmUpMappedEffects(); _mappedKey = classId + "/" + _burstPrefab; var cached = FindCalibration(classId, _burstPrefab); if (cached != null) { SpawnMapped(cached, classId, true); _mappedSpawned = true; } } // 같은 상태에서 이벤트가 또 뜬 경우 = 한 클립에 ShowEffect 가 여러 개인 클립. // 실측: 10201·10202 의 DualBlades_Attack1 은 0.0435 / 0.1292 두 번 뜬다(쌍검 2연타). // 이때 트레일을 다시 Begin() 하면 앞 절반이 잘려 나가므로 **창만 연장**한다. bool refire = _swingActive && hash == _swingStateHash; if (refire) { _swingStartNormalized = norm; _swingStartTime = Time.time; } else { _swingActive = true; _swingStateHash = hash; _swingStartNormalized = norm; _swingStartTime = Time.time; SwingCount++; for (int i = 0; i < _slots.Count; i++) if (_slots[i].trail != null) _slots[i].trail.Begin(); } // 창이 열린 프레임의 두 점을 곧바로 하나 넣어 둔다(첫 프레임 공백 방지). SampleAll(); // 이벤트마다 버스트를 터뜨리는 모드 — 기존 스폰 횟수가 그대로 보존된다. // PrefabArc 는 스윙 궤적 전체가 있어야 평면을 구할 수 있으므로 항상 EndSwing 에서만 배치한다. // #792 경로가 켜져 있으면 그쪽이 이펙트를 책임지므로 레거시 버스트는 건너뛴다(이중 스폰 방지). if (!_settings.useMappedEffects && _settings.burstMode != BurstMode.PrefabArc && _settings.burstTiming == BurstTiming.OnEvent) SpawnBurstAtCurrentTip(); #if UNITY_EDITOR if (_settings.verboseLog) Debug.Log(string.Format( "[WL #747] 스윙 {0} class={1} slots={2} prefab={3} norm={4:F3} speed={5:F2}", refire ? "연장" : "시작", classId, _slots.Count, _burstPrefab, norm, _animator != null ? _animator.speed : 0f), this); #endif return true; } /// 현재 프레임의 검끝·스윙 방향으로 버스트를 터뜨린다(BurstTiming.OnEvent). private void SpawnBurstAtCurrentTip() { for (int i = 0; i < _slots.Count; i++) { var slot = _slots[i]; if (slot.trail == null || slot.trail.SampleCount < 1 || !slot.IsAlive) continue; Vector3 tip = slot.TipWorld; int n = slot.trail.SampleCount; Vector3 swingDir = n >= 2 ? (tip - slot.trail.GetTip(n - 2)) : transform.forward; SpawnTipBurst(tip, swingDir, tip - slot.HiltWorld); return; // 이벤트 1개 = 버스트 1개(기존 동작과 같은 횟수) } } private void LateUpdate() { if (!_swingActive) return; SampleAll(); if (ShouldEndSwing()) EndSwing(); } /// 스윙 창을 닫을 때가 됐는지. 정규화 시간 · 상태 전환 · 안전 상한 3중 판정. private bool ShouldEndSwing() { float elapsed = Time.time - _swingStartTime; // ① 안전 상한 — 애니메이터가 멈추거나 상태를 못 읽어도 반드시 닫힌다. float maxSec = _settings != null ? Mathf.Max(_settings.swingMaxSeconds, 0.05f) : 0.6f; if (elapsed >= maxSec) return true; int hash; float norm; if (!TryReadState(out hash, out norm)) return true; // ② 상태 전환 — 콤보 다음 타로 넘어갔다. 유예 시간 안에는 무시한다. float grace = _settings != null ? Mathf.Max(_settings.swingStateGraceSeconds, 0f) : 0.03f; if (hash != _swingStateHash) return (_settings == null || _settings.endSwingOnStateChange) && elapsed >= grace; // ③ 정규화 시간 증분 — 공격 속도·클립 길이와 무관하게 같은 구간을 잡는다. float window = _settings != null ? Mathf.Clamp(_settings.swingWindowNormalized, 0.02f, 1f) : 0.35f; return (norm - _swingStartNormalized) >= window; } private void EndSwing() { if (!_swingActive) return; _swingActive = false; // PD #792 — 매핑 이펙트 경로. 궤적 샘플이 필요하므로 trail.End() 보다 먼저 처리한다. // 이벤트 시점에 이미 배치했으면(_mappedSpawned) 여기서는 아무것도 띄우지 않는다. // 아직이면(그 조합의 첫 스윙) 지금 실측해 배치하고 결과를 런타임 캐시에 넣는다. if (_settings != null && _settings.useMappedEffects) { // 🔴 이벤트 시점에 이미 띄웠더라도 **매번 다시 잰다**. // 콤보가 끊기면 스윙 창이 일찍 닫혀 잘린 스윙이 캐시에 박히는데(실측 — 칼끝 0.09 m), // 그 상태로는 영원히 고쳐지지 않는다. 매 스윙 재측정 + 더 긴 궤적이면 교체로 스스로 낫는다. var measured = MeasureSwingCalibration(); if (measured != null && !string.IsNullOrEmpty(_mappedKey)) { SwingCalibration prev; bool better = !s_runtimeCal.TryGetValue(_mappedKey, out prev) || prev == null || measured.quality > prev.quality * 1.05f; if (better) s_runtimeCal[_mappedKey] = measured; } if (!_mappedSpawned && measured != null) SpawnMapped(measured, _resolvedClassId, false); for (int k = 0; k < _slots.Count; k++) if (_slots[k].trail != null) _slots[k].trail.End(); #if UNITY_EDITOR if (_settings.verboseLog) Debug.Log("[WL #792] 스윙 종료 " + (_mappedSpawned ? "(이벤트 시점 배치)" : "(스윙 끝 실측 배치)") + " " + LastMappedInfo, this); #endif return; } // PD #761 — 원본 프리팹을 스윙 평면에 눕히는 모드. 궤적 샘플 전체가 필요하므로 // trail.End() 로 샘플이 지워지기 전에 먼저 처리한다. if (_settings != null && _settings.burstMode == BurstMode.PrefabArc) { bool placed = TrySpawnPrefabArc(); for (int k = 0; k < _slots.Count; k++) if (_slots[k].trail != null) _slots[k].trail.End(); #if UNITY_EDITOR if (_settings.verboseLog) Debug.Log(string.Format("[WL #761] 스윙 종료(PrefabArc) placed={0} prefab={1}", placed, _burstPrefab), this); #endif if (placed) return; // 샘플이 모자라 평면을 못 구했다 — 아래 검끝 버스트 경로로 떨어진다(안전 측). } Vector3 burstPos = Vector3.zero; Vector3 burstDir = Vector3.zero; Vector3 bladeDir = Vector3.up; bool haveBurst = false; for (int i = 0; i < _slots.Count; i++) { var slot = _slots[i]; if (slot.trail == null) continue; // 버스트 기준점 = 샘플이 2개 이상인 **첫 슬롯**의 마지막 검끝. // CollectSockets 가 오른손(RH)을 먼저 넣으므로 쌍검이면 오른손 검끝이 기준이 된다. if (!haveBurst && slot.trail.SampleCount >= 2) { int n = slot.trail.SampleCount; burstPos = slot.trail.GetTip(n - 1); burstDir = burstPos - slot.trail.GetTip(n - 2); bladeDir = burstPos - slot.trail.GetHilt(n - 1); haveBurst = true; } slot.trail.End(); } if (haveBurst && _settings != null && _settings.burstTiming == BurstTiming.OnSwingEnd) SpawnTipBurst(burstPos, burstDir, bladeDir); #if UNITY_EDITOR if (_settings != null && _settings.verboseLog) Debug.Log(string.Format("[WL #747] 스윙 종료 samples={0} burst={1}", _slots.Count > 0 && _slots[0].trail != null ? _slots[0].trail.SampleCount : 0, haveBurst ? _burstPrefab : "(없음)"), this); #endif } /// 스윙을 즉시 접는다(피격·사망·콤보 취소). public void CancelSwing() { _swingActive = false; for (int i = 0; i < _slots.Count; i++) if (_slots[i].trail != null) _slots[i].trail.Clear(); } private void SampleAll() { for (int i = 0; i < _slots.Count; i++) { var slot = _slots[i]; if (!slot.IsAlive || slot.trail == null) continue; slot.trail.Sample(slot.HiltWorld, slot.TipWorld); } } /// 현재(또는 전환 중이면 목표) 애니메이터 상태의 해시·정규화 시간을 읽는다. private bool TryReadState(out int hash, out float normalizedTime) { hash = 0; normalizedTime = 0f; if (_animator == null || !_animator.isActiveAndEnabled) return false; if (_animator.runtimeAnimatorController == null) return false; if (_animator.layerCount <= 0) return false; var cur = _animator.GetCurrentAnimatorStateInfo(0); // 전환 중이고 목표 상태가 우리가 추적하던 상태라면 그쪽을 읽는다. if (_swingActive && _animator.IsInTransition(0)) { var next = _animator.GetNextAnimatorStateInfo(0); if (next.fullPathHash == _swingStateHash) { hash = next.fullPathHash; normalizedTime = next.normalizedTime; return true; } } hash = cur.fullPathHash; normalizedTime = cur.normalizedTime; return true; } // ───────────────────────────────────────────────────────────────────────── // 무기 소켓 해석 · 검날 실측 // ───────────────────────────────────────────────────────────────────────── /// 무기 슬롯을 해석한다(캐시). 궤적을 그릴 무기가 하나라도 있으면 true. private bool EnsureSlots(SlashTrailClassOverride ov) { if (_slotsResolved) { bool allAlive = _slots.Count > 0; for (int i = 0; i < _slots.Count; i++) if (!_slots[i].IsAlive) { allAlive = false; break; } if (allAlive) return true; ReleaseSlots(); // 무기가 바뀌었다(Change_Weapon) → 다시 잰다 } _slotsResolved = true; if (_pc == null || _settings == null) return false; var sockets = CollectSockets(ov); for (int i = 0; i < sockets.Count; i++) { var slot = MeasureSlot(sockets[i].Key, sockets[i].Value); if (slot == null) continue; slot.trail = BladeTrail.Create(_settings, gameObject.name + "_BladeTrail_" + slot.socketIndex); if (ov != null && ov.overrideLook) slot.trail.SetLook(ov.colorAdditive, ov.colorAlpha, ov.widthScale); _slots.Add(slot); } #if UNITY_EDITOR if (_settings.verboseLog) { for (int i = 0; i < _slots.Count; i++) Debug.Log(string.Format( "[WL #747] 무기 실측 slot={0} blade='{1}' length={2:F3}m aspect={3:F2} hasMat={4}", _slots[i].socketIndex, _slots[i].bladeRoot != null ? _slots[i].bladeRoot.name : "?", _slots[i].bladeLength, _slots[i].bladeAspect, _slots[i].trail != null && _slots[i].trail.HasMaterial), this); if (_slots.Count == 0) Debug.LogWarning("[WL #747] 궤적을 그릴 무기를 찾지 못했다 — 기존 경로로 되돌아간다", this); } #endif return _slots.Count > 0; } /// /// 대상 소켓 목록을 모은다. (인덱스, 트랜스폼) 쌍. /// /// 실측 — PC 프리팹(Assets/Res_Addr/PC/Ai01.prefab)의 tfs_weapon 순서는 /// [0] w_shield [1] w_righthand [2] w_onehand /// [3] w_bladedancer_l [4] w_bladedancer_r [5] w_orb [6] w_archer_l /// 이고 ClassConfig 의 n_WeaponLHIndex / n_WeaponRHIndex 가 이 배열을 가리킨다 /// (무기가 없는 쪽은 -1). 방패도 LH 무기로 선언돼 있으므로(10501·10502) /// 최종 배제는 MeasureSlot 의 형상 판정(minBladeAspect)이 맡는다. /// private List> CollectSockets(SlashTrailClassOverride ov) { var result = new List>(2); var sockets = _pc.tfs_weapon; if (sockets != null && sockets.Length > 0) { // ① 클래스 오버라이드가 슬롯을 직접 지정한 경우 if (ov != null && ov.overrideWeaponSlots && ov.weaponSlotIndices != null && ov.weaponSlotIndices.Length > 0) { for (int i = 0; i < ov.weaponSlotIndices.Length; i++) AddSocket(result, sockets, ov.weaponSlotIndices[i]); if (result.Count > 0) return result; } // ② 테이블이 선언한 소켓만 본다(클래스 전환 시 남은 이전 무기를 피한다) if (_settings.useTableWeaponIndices && table_classconfig.Ins != null) { var cfg = table_classconfig.Ins.Get_Data_orNull(_pc.Get_ID()); if (cfg != null) { AddSocket(result, sockets, cfg.n_WeaponRHIndex); AddSocket(result, sockets, cfg.n_WeaponLHIndex); if (result.Count > 0) return result; } } // ③ 자식이 있는 소켓을 전부 훑는다 for (int i = 0; i < sockets.Length; i++) if (sockets[i] != null && sockets[i].childCount > 0) result.Add(new KeyValuePair(i, sockets[i])); if (result.Count > 0) return result; } // 소켓 배열이 비었을 때만 이름 후보로 찾는다(설정에 후보가 있을 때). var names = _settings.socketNameCandidates; if (names != null) { for (int i = 0; i < names.Length; i++) { var found = FindDeep(transform, names[i]); if (found != null) { result.Add(new KeyValuePair(-1 - i, found)); break; } } } return result; } /// /// 소켓에 물린 무기 메시에서 자루 끝·칼끝을 실측한다. /// 검날로 인정되지 않으면(짧거나, 납작한 방패꼴) null 을 돌려 그 슬롯을 건너뛴다. /// /// 구 WL(SlashVfxPlayer.MeasureBlade) 대비 개선: /// · 길이를 메시 로컬 단위가 아니라 **월드 두 끝점 거리**로 잰다(스케일 반영). /// · 손잡이를 소켓 원점이 아니라 **메시의 소켓에 가까운 끝**으로 잡는다. /// · 최장축/차축 비율로 방패·오브를 배제한다(이름 하드코딩 없이). /// private WeaponSlot MeasureSlot(int socketIndex, Transform socket) { if (socket == null) return null; Renderer best = null; Mesh bestMesh = null; float bestExtent = 0f; var renderers = socket.GetComponentsInChildren(true); for (int i = 0; i < renderers.Length; i++) { var mesh = GetSharedMesh(renderers[i]); if (mesh == null) continue; Vector3 ws = WorldSize(mesh.bounds, renderers[i].transform.lossyScale); float longest = Mathf.Max(ws.x, Mathf.Max(ws.y, ws.z)); if (longest > bestExtent) { bestExtent = longest; best = renderers[i]; bestMesh = mesh; } if (!_settings.pickLongestRenderer && best != null) break; } if (best == null || bestMesh == null) return null; var b = bestMesh.bounds; Vector3 worldSize = WorldSize(b, best.transform.lossyScale); int axis = DominantAxis(worldSize); float longestSide = worldSize[axis]; float secondSide = SecondLongest(worldSize, axis); float aspect = secondSide > 1e-5f ? longestSide / secondSide : 999f; // 검날 판정 — 길이·형상 모두 설정 에셋의 임계값으로(C45). if (longestSide < _settings.minBladeLength) return null; if (aspect < _settings.minBladeAspect) return null; Vector3 axisDir = Vector3.zero; axisDir[axis] = 1f; Vector3 endALocal = b.center - axisDir * b.extents[axis]; Vector3 endBLocal = b.center + axisDir * b.extents[axis]; Vector3 endAWorld = best.transform.TransformPoint(endALocal); Vector3 endBWorld = best.transform.TransformPoint(endBLocal); // 소켓(손)에 가까운 끝이 자루다. bool aIsHilt = (endAWorld - socket.position).sqrMagnitude <= (endBWorld - socket.position).sqrMagnitude; Vector3 hiltWorld = aIsHilt ? endAWorld : endBWorld; Vector3 tipWorld = aIsHilt ? endBWorld : endAWorld; float length = Vector3.Distance(hiltWorld, tipWorld); if (length < 1e-4f) length = _settings.bladeLengthFallback; return new WeaponSlot { socketIndex = socketIndex, socket = socket, bladeRoot = best.transform, hiltLocal = socket.InverseTransformPoint(hiltWorld), tipLocal = socket.InverseTransformPoint(tipWorld), bladeLength = length, bladeAspect = aspect, socketChildCount = socket.childCount }; } /// 소켓 인덱스를 검사해 목록에 넣는다(중복·범위 밖·빈 소켓 제외). private static void AddSocket(List> list, Transform[] sockets, int index) { if (index < 0 || index >= sockets.Length) return; // 무기 없음 = -1 var t = sockets[index]; if (t == null || t.childCount == 0) return; for (int i = 0; i < list.Count; i++) if (list[i].Key == index) return; list.Add(new KeyValuePair(index, t)); } private static Mesh GetSharedMesh(Renderer r) { if (r == null) return null; var smr = r as SkinnedMeshRenderer; if (smr != null) return smr.sharedMesh; var mf = r.GetComponent(); return mf != null ? mf.sharedMesh : null; } private static Vector3 WorldSize(Bounds localBounds, Vector3 lossyScale) { return new Vector3( localBounds.size.x * Mathf.Abs(lossyScale.x), localBounds.size.y * Mathf.Abs(lossyScale.y), localBounds.size.z * Mathf.Abs(lossyScale.z)); } private static int DominantAxis(Vector3 size) { if (size.x >= size.y && size.x >= size.z) return 0; return size.y >= size.z ? 1 : 2; } private static float SecondLongest(Vector3 size, int longestAxis) { float second = 0f; for (int i = 0; i < 3; i++) if (i != longestAxis && size[i] > second) second = size[i]; return second; } private static Transform FindDeep(Transform root, string targetName) { if (root == null || string.IsNullOrEmpty(targetName)) return null; if (root.name == targetName) return root; for (int i = 0; i < root.childCount; i++) { var r = FindDeep(root.GetChild(i), targetName); if (r != null) return r; } return null; } // ───────────────────────────────────────────────────────────────────────── // 검끝 버스트 // ───────────────────────────────────────────────────────────────────────── /// /// 스폰된 이펙트 인스턴스를 넘겨 주는 선택적 경로. /// /// 기본(null)이면 아래 가 기존 /// InGameInfo.Show_Effect(prefab, pos, euler) 를 그대로 호출한다 — /// **힘민지 코드를 한 줄도 고치지 않고 동작한다**. /// /// 2단계에 InGameInfo 에 스폰 콜백 오버로드를 추가하면 그 한 줄에서 이 델리게이트를 /// 연결한다. 그러면 크레센트 이미터를 런타임에 골라 끌 수 있다(프리팹 에셋 무수정 · /// 설정만 비우면 즉시 원복 · C8). 상세 diff = PLAN_2단계.md /// /// 시그니처: (프리팹 이름, 위치, 오일러각, 스폰된 인스턴스를 받는 콜백) /// public static System.Action> ExternalSpawner; /// /// 검끝 자리에 원본 슬래시 프리팹을 1회 재생한다. /// 기존 InGameInfo 풀(dic_str_Effect + TurnOff_GO)을 그대로 쓴다 — 풀을 새로 만들지 않는다(C11). /// private void SpawnTipBurst(Vector3 tipPos, Vector3 swingDir, Vector3 bladeDir) { if (_settings == null || _settings.burstMode == BurstMode.None) return; if (string.IsNullOrEmpty(_burstPrefab)) return; if (swingDir.sqrMagnitude < 1e-8f) swingDir = transform.forward; swingDir.Normalize(); if (bladeDir.sqrMagnitude < 1e-8f) bladeDir = Vector3.up; bladeDir.Normalize(); Vector3 pos = tipPos + swingDir * _settings.burstForwardOffset; // Z = 스윙 진행 방향 · Y = 칼끝 방향. 두 축이 평행하면 LookRotation 이 실패하므로 보정한다. Quaternion rot; if (Mathf.Abs(Vector3.Dot(swingDir, bladeDir)) > 0.999f) rot = Quaternion.LookRotation(swingDir); else rot = Quaternion.LookRotation(swingDir, bladeDir); var rule = _settings.FindCrescentRule(_burstPrefab); var mode = _settings.suppressMode; var keep = _settings.ResolveKeepNames(rule); if (ExternalSpawner != null) { string prefab = _burstPrefab; // PD #777 — 스폰 직후 인스턴스를 PC 에 묶는다(부모 변경 없이 좌표만 추종). Transform followAnchor = transform; bool follow = _settings.arcFollowPlayer; bool followRot = _settings.arcFollowRotation; float followSec = _settings.arcFollowSeconds; ExternalSpawner(prefab, pos, rot.eulerAngles, go => { ApplyCrescentRule(go, rule, mode, keep); ApplyFollow(go, followAnchor, follow, followRot, followSec); }); return; } // 기본 경로 — 힘민지 코드 무수정. 크레센트 억제는 하지 못한다. if (InGameInfo.Ins == null) return; InGameInfo.Ins.Show_Effect(_burstPrefab, pos, rot.eulerAngles); } // ───────────────────────────────────────────────────────────────────────── // PD #761 — PrefabArc: 원본 프리팹을 스윙 평면에 눕혀 배치 // // 구 WL `SlashVfxPlayer.PlayArcFromSamples`(328~390행)의 알고리즘을 이식했다. // · 평면 법선 n = 검끝 샘플 **전체**로 구한 최적 평면(Newell 법). // 세 점만 쓰면 스윙이 조금만 휘어도 평면이 크게 틀어진다(구 WL 실측 0.65~0.86m 이탈). // · outward = 중간 프레임의 (검끝 − 자루) = 칼끝이 향하는 방향. // · 목표 프레임 = LookRotation(n, outward) (Z = 평면 법선 · Y = 칼끝 방향) // // 구 WL 과 다른 점 (힘민지 프리팹 실측 2026-09-06 반영): // 구 WL 프리팹(NamuFX)은 "메시가 로컬 +Y 로 뻗는다"는 하나의 규약을 따랐지만, // 힘민지 슬래시 20종은 원본 5종이 제각각이라 크레센트의 로컬 축이 통일돼 있지 않다. // Blue_Slash_1 : slash_L01(Mesh Sphere001) localEuler (84,17,321), 부모 (12,180,157) // Blue_Sword : Center_flash(Mesh pzpl) localEuler (4,290,203) // Orange_Slash_1: slash_a(Billboard·Local) localEuler (0,0,0), 부모 (0,0,180)/(45,43,275) // Slash_fire_once: shadow/glow(Billboard·Local) localEuler (90,140,0) // 그래서 프리팹 루트를 그대로 놓으면 호가 엉뚱한 평면에 선다(= PD #747 원증상). // → 인스턴스에서 **크레센트 이미터를 실측**해 그 자체의 프레임을 구하고, // 그 프레임이 목표 프레임과 일치하도록 루트 회전을 역산한다(arcAutoOrient). // 크레센트 판정 = 카메라 정렬(align=View)이 아닌 렌더러 중 시각 지름 최대. // (View 정렬은 항상 카메라를 보므로 방향 정보를 담고 있지 않다 — glow·smoke 류) // ───────────────────────────────────────────────────────────────────────── /// 프리팹 인스턴스에서 실측한 크레센트 프레임. 프리팹 이름으로 캐시한다. private struct ArcMeasure { public Quaternion frame; // 루트 기준 크레센트 프레임 (forward = 면 법선, up = 호가 뻗는 방향) public Vector3 centerLocal; // 루트 로컬(스케일 1) 기준 크레센트 시각 중심 public float diameter; // 루트 스케일 1 기준 크레센트 시각 지름(m) public string emitter; // 실측에 쓴 이미터 이름(검증용) } private static readonly Dictionary s_arcCache = new Dictionary(); /// 마지막 PrefabArc 배치 결과. **검증 전용**(#761 판정 ⓒ). public static string LastArcInfo; /// PrefabArc 캐시를 비운다(설정을 바꾼 뒤 재실측할 때 · 검증용). public static void ClearArcCache() { s_arcCache.Clear(); } /// /// 모아 둔 궤적으로 평면을 구해 원본 프리팹을 1회 배치한다. /// 샘플이 모자라면 false 를 돌려 호출부가 기존 검끝 버스트로 떨어지게 한다. /// private bool TrySpawnPrefabArc() { if (_settings == null || string.IsNullOrEmpty(_burstPrefab)) return false; BladeTrail trail = null; int minSamples = Mathf.Max(_settings.arcMinSamples, 3); for (int i = 0; i < _slots.Count; i++) { var t = _slots[i].trail; if (t != null && t.SampleCount >= minSamples) { trail = t; break; } } if (trail == null) return false; int n = trail.SampleCount; int midIdx = n / 2; Vector3 center = Vector3.zero; for (int i = 0; i < n; i++) center += trail.GetTip(i); center /= n; Vector3 first = trail.GetTip(0); Vector3 last = trail.GetTip(n - 1); Vector3 midTip = trail.GetTip(midIdx); Vector3 midHilt = trail.GetHilt(midIdx); // 최적 평면 법선(Newell 법) — 궤적 점 전체를 쓴다. Vector3 nrm = Vector3.zero; for (int i = 0; i < n; i++) { Vector3 a = trail.GetTip(i) - center; Vector3 b = trail.GetTip((i + 1) % n) - center; nrm += Vector3.Cross(a, b); } if (nrm.sqrMagnitude < 1e-6f) nrm = Vector3.Cross(midTip - first, last - midTip); if (nrm.sqrMagnitude < 1e-6f) nrm = Vector3.Cross(last - first, Vector3.up); if (nrm.sqrMagnitude < 1e-6f) nrm = transform.right; nrm.Normalize(); Vector3 outward = midTip - midHilt; if (outward.sqrMagnitude < 1e-6f) outward = transform.forward; outward.Normalize(); // 법선과 칼끝 방향이 평행하면 LookRotation 이 실패한다 — 궤적 진행 방향으로 대체. if (Mathf.Abs(Vector3.Dot(nrm, outward)) > 0.999f) { outward = last - first; if (outward.sqrMagnitude < 1e-6f) outward = transform.forward; outward.Normalize(); } float radius = Vector3.Distance(midHilt, midTip); if (radius < 1e-3f) radius = _settings.bladeLengthFallback; var rule = _settings.FindCrescentRule(_burstPrefab); var arc = _settings.ResolveArc(rule); // 앵커 — 평면 위로 투영해 호가 평면에 정확히 눕게 한다(구 WL 373행과 같은 처리). Vector3 anchor; switch (arc.anchor) { case ArcAnchor.Tip: anchor = midTip; break; case ArcAnchor.Center: anchor = (midHilt + midTip) * 0.5f; break; default: anchor = midHilt; break; } anchor -= nrm * Vector3.Dot(anchor - center, nrm); Quaternion target = Quaternion.LookRotation(nrm, outward) * Quaternion.Euler(arc.eulerOffset); string prefab = _burstPrefab; // PD #777 — 배치가 끝난 인스턴스를 PC 에 묶는다(부모 변경 없이 좌표만 추종). Transform followAnchor = transform; bool follow = _settings.arcFollowPlayer; bool followRot = _settings.arcFollowRotation; float followSec = _settings.arcFollowSeconds; if (ExternalSpawner != null) { var r = rule; bool ao = _settings.arcAutoOrient; ExternalSpawner(prefab, anchor, target.eulerAngles, go => { ArrangeArc(go, prefab, target, anchor, radius, arc, ao, r); ApplyFollow(go, followAnchor, follow, followRot, followSec); }); } else if (InGameInfo.Ins != null) { // 콜백 경로가 없으면 회전만 맞춘 기본 배치(구 WL 원안). 크기 보정은 못 한다. InGameInfo.Ins.Show_Effect(prefab, anchor, target.eulerAngles); } else return false; return true; } /// /// 스폰 직후(활성화 전) 인스턴스를 실측해 회전·크기·위치를 확정한다. /// 억제()는 **적용하지 않고 원복만** 한다 — /// PD #761 의 핵심이 "원본 이펙트를 원본 색 그대로" 이기 때문이다. /// private static void ArrangeArc(GameObject go, string prefabName, Quaternion target, Vector3 anchor, float radius, ArcPlacement arc, bool autoOrient, SlashPrefabEmitterRule rule) { if (go == null) return; var t = go.transform; // 풀 인스턴스는 이전 배치의 스케일·억제가 남아 있다. 원본으로 되돌린 뒤에 다시 잰다. var mask = go.GetComponent(); if (mask == null) mask = go.AddComponent(); mask.CaptureScale(); mask.RestoreOriginal(); // 이미터 enabled + 루트 스케일 원복 (억제 0) mask.MarkApplied(); // 이번 활성화는 우리가 관리한다(OnEnable 이중 원복 방지) LastMask = mask; LastMaskPrefab = go.name; ArcMeasure m; if (!s_arcCache.TryGetValue(prefabName, out m)) { m = Measure(go, rule); s_arcCache[prefabName] = m; } float scale = arc.scale; if (arc.fitToBlade && m.diameter > 1e-4f) scale *= (radius * 2f) / m.diameter; t.rotation = autoOrient ? target * Quaternion.Inverse(m.frame) : target; t.localScale = Vector3.one * scale; // 크레센트의 시각 중심이 앵커에 오도록 루트를 민다(프리팹마다 크레센트가 // 루트에서 얼마나 떨어져 있는지가 제각각 — 10101_1 은 (0,0.80,0.30) 만큼 떨어져 있다). t.position = anchor - t.TransformVector(m.centerLocal); LastArcInfo = string.Format("{0} emitter='{1}' d={2:F2} scale={3:F2} radius={4:F2} pos={5}", prefabName, m.emitter, m.diameter, scale, radius, t.position.ToString("F2")); } /// /// 인스턴스에서 크레센트 이미터를 골라 프레임(면 법선·호 방향)·중심·지름을 실측한다. /// 호출 시점의 루트 회전·스케일과 무관하도록 **루트 로컬 좌표**로 환산해 담는다. /// private static ArcMeasure Measure(GameObject go, SlashPrefabEmitterRule rule) { var root = go.transform; var result = new ArcMeasure { frame = Quaternion.identity, centerLocal = Vector3.zero, diameter = 0f, emitter = "(none)" }; var systems = go.GetComponentsInChildren(true); float bestScore = 0f; bool bestIsOriented = false; bool bestIsNamed = false; // 🔴 실측 (2026-09-06 Play 캡처): "가장 큰 이미터 = 크레센트" 는 틀린다. // Effect_Slash_10101_1 에서는 글로우 링 glow_blue_01(Quad · 지름 4.5)이 // 실제 호 slash_L01 보다 커서 이겼고, 두 이미터의 로컬 평면이 서로 달라 // (glow (277,180,180) vs slash (84,17,321)) 호가 스윙 평면에서 벗어났다. // → 이미 PM 이 실측해 둔 crescentRules.crescentEmitterNames 를 1순위로 쓴다. // (그 목록의 뜻 자체가 "이 프리팹에서 호 본체인 이미터" 다 · C45 데이터 SOT) var named = rule != null ? rule.crescentEmitterNames : null; for (int i = 0; i < systems.Length; i++) { var ps = systems[i]; var psr = ps.GetComponent(); if (psr == null || !psr.enabled) continue; float startSize = Mathf.Max(ps.main.startSize.constantMax, 0f); if (startSize <= 0f) continue; // 이미터의 루트 기준 상대 회전·스케일 (루트 자체의 자세를 상쇄한다) Quaternion rel = Quaternion.Inverse(root.rotation) * ps.transform.rotation; Vector3 relScale = InvScale(ps.transform.lossyScale, root.lossyScale); Vector3 nLocal, uLocal, sizeVec; bool oriented; // 방향 정보를 담고 있는가(카메라 정렬이 아닌가) if (psr.renderMode == ParticleSystemRenderMode.Mesh && psr.mesh != null) { Vector3 ms = psr.mesh.bounds.size; sizeVec = new Vector3(ms.x * Mathf.Abs(relScale.x), ms.y * Mathf.Abs(relScale.y), ms.z * Mathf.Abs(relScale.z)) * startSize; int thin = MinAxis(sizeVec); int longAxis = DominantAxis(sizeVec); if (longAxis == thin) longAxis = (thin + 1) % 3; nLocal = Axis(thin); uLocal = Axis(longAxis); oriented = psr.alignment != ParticleSystemRenderSpace.View; } else { // 빌보드·스트레치 — 쿼드는 이미터 로컬 XY 평면에 놓이고 법선은 +Z 다 // (alignment = Local 일 때. View 는 항상 카메라를 보므로 방향 정보가 없다) float s = startSize * Mathf.Max(Mathf.Abs(relScale.x), Mathf.Abs(relScale.y)); sizeVec = new Vector3(s, s, 0f); nLocal = Vector3.forward; uLocal = Vector3.up; oriented = psr.alignment == ParticleSystemRenderSpace.Local; } float diameter = Mathf.Max(sizeVec.x, Mathf.Max(sizeVec.y, sizeVec.z)); if (diameter <= 1e-4f) continue; bool isNamed = Contains(named, ps.gameObject.name); // 우선순위 ① 규칙에 이름이 적힌 크레센트 ② 방향 정보가 있는 이미터 ③ 큰 쪽. bool better; if (isNamed != bestIsNamed) better = isNamed; else if (oriented != bestIsOriented) better = oriented; else better = diameter > bestScore; if (!better) continue; bestScore = diameter; bestIsOriented = oriented; bestIsNamed = isNamed; result.diameter = diameter; result.emitter = ps.gameObject.name; result.centerLocal = root.InverseTransformPoint(ps.transform.position); result.frame = oriented ? rel * Quaternion.LookRotation(nLocal, uLocal) : Quaternion.identity; } return result; } private static bool Contains(string[] names, string value) { if (names == null) return false; for (int i = 0; i < names.Length; i++) if (names[i] == value) return true; return false; } private static Vector3 InvScale(Vector3 child, Vector3 parent) { return new Vector3( Mathf.Abs(parent.x) > 1e-6f ? child.x / parent.x : child.x, Mathf.Abs(parent.y) > 1e-6f ? child.y / parent.y : child.y, Mathf.Abs(parent.z) > 1e-6f ? child.z / parent.z : child.z); } private static Vector3 Axis(int i) { var v = Vector3.zero; v[i] = 1f; return v; } private static int MinAxis(Vector3 s) { if (s.x <= s.y && s.x <= s.z) return 0; return s.y <= s.z ? 1 : 2; } /// /// 스폰된 인스턴스에서 크레센트(호 본체) 이미터의 렌더러만 끈다. 호는 트레일이 그리므로 /// 원본 크레센트가 겹치면 방향이 어긋나 보인다(#747 증상 그대로). /// 규칙이 없으면 원래 상태로 되돌린다 — 기본값은 "끄지 않음"이다. /// /// 🔴 인스턴스는 InGameInfo 가 **재사용**하므로(dic_str_Effect 풀) 렌더러를 그냥 끄면 /// 영구히 꺼진 채로 돌아다닌다. 그래서 가 최초 1회 /// 원래 상태를 기억하고, 매 스폰마다 그 상태에서 다시 계산한다. /// 특히 원본에 이미 꺼져 있는 렌더러(Blue_Slash_1 루트 · eff · Slash_fire_once 루트 등 /// m_Enabled = 0 인 비렌더 이미터)를 실수로 켜지 않는다. /// private static void ApplyCrescentRule(GameObject go, SlashPrefabEmitterRule rule, SuppressMode mode, string[] keepNames) { if (go == null) return; var mask = go.GetComponent(); if (mask == null) mask = go.AddComponent(); mask.Apply(rule, mode, keepNames); LastMask = mask; LastMaskPrefab = go.name; } /// /// PD #777 — 스폰된 이펙트가 PC 를 따라가게 한다. /// /// 부모를 바꾸지 않고 가 매 LateUpdate 마다 좌표만 갱신한다. /// 풀(dic_str_Effect) 구조를 건드리지 않으므로 설정을 끄면 그 즉시(다음 스폰부터) /// 기존 월드 고정 동작으로 돌아간다(C8 롤백 경로). 이미 붙어 있던 인스턴스도 /// 로 그 자리에서 해제한다. /// private static void ApplyFollow(GameObject go, Transform anchor, bool follow, bool followRotation, float seconds) { if (go == null) return; if (follow && anchor != null) SlashArcFollower.Attach(go, anchor, followRotation, seconds); else SlashArcFollower.Detach(go); } /// /// 마지막으로 억제 규칙을 적용한 마스크 · 프리팹 이름. **검증 전용**(#760 판정 ⓓ) — /// 런타임 이미터 on/off 상태를 CLI 에서 덤프하기 위한 것이고 게임 로직은 읽지 않는다. /// public static SlashCrescentMask LastMask; public static string LastMaskPrefab; // ───────────────────────────────────────────────────────────────────────── // PD #792 — 무기 스윙에 "딱 맞게" 배치 // // PD 지시 원문(2026-09-07 01:00): // "무기 궤적을 그대로 따라가야 한다"가 아니라 **무기를 휘두르는 방향에 맞게 // 이펙트의 각도와 위치, 크기를 무기에 딱 맞게 배치**시켜 자연스럽게 보이게 할 것. // PD 지시(01:0x): 휘두르기 = NamuFX Slash_B_recolor_1 · // 찌르기 = EricWang FX_Blue Stab (2D 스프라이트 계열) // // ── 기존 #761 PrefabArc 가 어긋난 이유(기하학) ────────────────────────── // #761 의 Measure() 는 크레센트의 **바운딩 박스 최장축**을 "호가 뻗는 방향"으로 보고 // 그것을 스윙의 outward(칼끝−손잡이 = 반경 방향)에 맞췄다. 그런데 원호의 바운딩 박스 // 최장축은 언제나 **현(chord · 접선 방향)** 이다: // 반각 θ · 반지름 r 일 때 현 = 2r·sinθ, 반경 폭 = r_o − r_i·cosθ // θ=60° → 1.73r vs 0.77r │ θ=90° → 2.00r vs 1.00r │ θ=135° → 2.00r vs 1.71r // 즉 호가 스윙 평면 안에서 **90° 돌아간 채** 놓였다. 이것이 PD 가 본 "안 맞는다"이다. // // ── 이 경로가 맞추는 것 (요구 사양 그대로) ───────────────────────────── // 휘두르기 — 호의 원 중심 = 스윙 피벗 · 반지름 = 칼끝 반경 · 평면 = 스윙 평면 · // 호 시작→끝 = 스윙 진행 방향 · 볼록 = 스윙 바깥. 균등 스케일. // 찌르기 — 축 = 칼끝 시작→최대 전진점 · 길이 = 찌르기 거리 · // 위치 = 경로 시작(옵션: 중앙/끝) · 롤 = 이펙트 면이 카메라를 보게. // // 모든 수치·이름은 SlashTrailSettings 에서 읽는다(C45 — 코드에 게임 값 없음). // ───────────────────────────────────────────────────────────────────────── /// SO 베이크 → 런타임 실측 순으로 캘리브레이션을 찾는다. 없으면 null. private SwingCalibration FindCalibration(int classId, string sourcePrefab) { var baked = _settings.FindCalibration(classId, sourcePrefab); if (baked != null) return baked; SwingCalibration rt; if (s_runtimeCal.TryGetValue(classId + "/" + sourcePrefab, out rt)) return rt; return null; } /// /// 이번 스윙의 궤적 샘플로 (휘두르기/찌르기) 프레임을 실측해 **캐릭터 로컬**로 담는다. /// 샘플이 모자라거나 피팅이 실패하면 null — 호출부는 아무것도 띄우지 않는다(원본과 같은 /// "이펙트 없음" 이 아니라, 다음 스윙에서 다시 시도한다). /// private SwingCalibration MeasureSwingCalibration() { if (_settings == null) return null; BladeTrail trail = null; int minSamples = Mathf.Max(_settings.arcMinSamples, 3); for (int i = 0; i < _slots.Count; i++) { var t = _slots[i].trail; if (t != null && t.SampleCount >= minSamples) { trail = t; break; } } if (trail == null) return null; int raw = trail.SampleCount; var rawTips = new Vector3[raw]; var rawHilts = new Vector3[raw]; for (int i = 0; i < raw; i++) { rawTips[i] = trail.GetTip(i); rawHilts[i] = trail.GetHilt(i); } // ── 실제로 휘두른 구간만 남긴다 ────────────────────────────────── // 창은 정규화 시간으로 열고 닫히므로 타격이 끝난 뒤의 **정지 구간**까지 들어온다. // 실측 — Attack3_S_10501 은 2 프레임(칼끝 1.0 m) 만에 타격이 끝나고 18 프레임을 버틴다. // 그 정지 샘플이 원피팅을 지배하면 반지름이 0.04 m 로 붕괴한다(스케일 0.035 → 이펙트 소멸). int lo = 0, hi = raw - 1; float ratio = _settings.swingTrimSpeedRatio; if (ratio > 0f && raw >= 4) { float maxStep = 0f; var step = new float[raw]; for (int i = 1; i < raw; i++) { step[i] = Vector3.Distance(rawTips[i], rawTips[i - 1]); if (step[i] > maxStep) maxStep = step[i]; } float th = maxStep * ratio; int f = -1, l = -1; for (int i = 1; i < raw; i++) if (step[i] >= th) { if (f < 0) f = i - 1; l = i; } if (f >= 0 && l > f) { lo = f; hi = l; } } int n = hi - lo + 1; if (n < 3) { lo = 0; hi = raw - 1; n = raw; } var tips = new Vector3[n]; var hilts = new Vector3[n]; for (int i = 0; i < n; i++) { tips[i] = rawTips[lo + i]; hilts[i] = rawHilts[lo + i]; } // ── #816v 감아올리기 트림 — 속도 트림 뒤에도 칼끝이 한 바퀴 넘게 감기면 원 피팅이 깨진다 ── // (실측 — Attack1_S_* 는 타격 4~5 프레임 뒤에도 계속 감겨 sweep 399~417°·편차비 0.27) if (_settings.arcTrimWindupWrap) n = SlashArcMeasure.TrimWindupWrap(tips, n, _settings.arcFitMaxDeviation, minSamples); var fit = SlashArcMeasure.FitArcOrdered(tips, n); // ── 분류 (칼끝 궤적으로) ──────────────────────────────────────── // 전진 최대점까지의 직진도 · 전방 내적 · 전진 거리 · 스윕 각도를 함께 본다. int kMax = 0; float dMax = 0f; for (int i = 1; i < n; i++) { float d = (tips[i] - tips[0]).sqrMagnitude; if (d > dMax) { dMax = d; kMax = i; } } float chord = Mathf.Sqrt(dMax); float pathLen = 0f; for (int i = 1; i <= kMax; i++) pathLen += Vector3.Distance(tips[i], tips[i - 1]); float straight = pathLen > 1e-5f ? chord / pathLen : 0f; float fullPath = 0f; for (int i = 1; i < n; i++) fullPath += Vector3.Distance(tips[i], tips[i - 1]); Vector3 dir = kMax > 0 ? (tips[kMax] - tips[0]).normalized : transform.forward; float fwdDot = Vector3.Dot(dir, transform.forward); // ── 분류 — **칼날이 실제로 얼마나 휘둘러졌는가**로 가른다 ────────── // 🔴 처음에는 "칼끝 궤적의 스윕 각"과 "전방 내적"으로 갈랐는데, 둘 다 흔들렸다(실측): // · 스윕 각은 궤적 원피팅에서 나오므로 창이 잘리면 118°~485° 로 요동친다. // · 전방 내적은 캐릭터가 스윙 도중 회전하면 부호가 뒤집힌다 // (같은 Attack3 가 fwd 0.84 → Stab, fwd 0.24 → Swing 으로 뒤바뀌었다). // → 칼날 벡터(칼끝−손잡이)가 프레임마다 얼마나 돌았는지 누적한다. 이것은 캐릭터의 // 위치·방향과 무관한 순수 무기 회전량이고, 휘두르기와 찌르기를 물리적으로 가른다. // 실측 — 클립 전체 칼날 회전량: Attack1 566° · Attack2 545° · Attack3 74.5°. float bladeAng = 0f; { Vector3 prevDir = tips[0] - hilts[0]; if (prevDir.sqrMagnitude > 1e-8f) prevDir.Normalize(); for (int i = 1; i < n; i++) { Vector3 d = tips[i] - hilts[i]; if (d.sqrMagnitude < 1e-8f) continue; d.Normalize(); bladeAng += Vector3.Angle(prevDir, d); prevDir = d; } } // 손잡이가 함께 이동했는가 — 찌르기(런지)는 손과 칼끝이 같이 나가고, // 휘두르기는 손이 거의 제자리인 채 칼끝만 크게 돈다. float hiltPath = 0f; for (int i = 1; i < n; i++) hiltPath += Vector3.Distance(hilts[i], hilts[i - 1]); float tipPerHilt = fullPath / Mathf.Max(hiltPath, 1e-3f); var kind = SwingKind.Swing; bool stabShape = chord >= _settings.stabMinDistance && straight >= _settings.stabMinStraightness; bool swingShape = bladeAng >= _settings.swingMinSweepDegrees || tipPerHilt >= _settings.swingMinTipHiltRatio; if (!swingShape) kind = stabShape ? SwingKind.Stab : SwingKind.Swing; var cal = new SwingCalibration { classId = _resolvedClassId, clipName = _burstPrefab, kind = kind, quality = fullPath, source = string.Format("runtime {0:HH:mm:ss} samples={1}/{2}(trim {3}~{4}) path={5:F2} 칼날회전={10:F0}° 칼끝/손잡이={11:F2} sweep={6:F1}° straight={7:F2} fwd={8:F2} chord={9:F2}", System.DateTime.Now, n, raw, lo, hi, fullPath, fit.valid ? fit.sweepDeg : 0f, straight, fwdDot, chord, bladeAng, tipPerHilt) }; // ── 휘두르기 프레임 ───────────────────────────────────────────── if (fit.valid) { Vector3 nrm = fit.normal; Vector3 pivot = fit.center; float tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm); float hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm); float dev = SlashArcMeasure.RadiusDeviation(tips, n, pivot, nrm, tipR); string pivotSrc = "칼끝원"; // 🔴 칼끝 원 피팅이 스윙 피벗을 못 찾는 경우가 있다 (실측 2026-09-07 · Attack3_S_10501): // 칼끝이 거의 직선으로 지나가거나 손목만 꺾이면 원 피팅이 병적으로 작은 원을 잡아 // tipR=0.042 · hiltR=0.682 처럼 **안팎이 뒤집힌다**(스케일 0.035 → 이펙트가 사라진다). // 물리적으로 칼끝은 항상 손잡이보다 피벗에서 멀고, 반지름 편차는 작아야 한다. // 둘 중 하나라도 어기면 발주 정의 그대로 "**손잡이 궤적의 중심**"을 피벗으로 쓴다. bool bad = tipR < 1e-3f || tipR <= hiltR * 1.15f || dev > tipR * Mathf.Max(_settings.arcFitMaxDeviation, 0.01f); // #816v — SO 로 이관(C45) if (bad) { Vector3 tipCentroid = SlashArcMeasure.CentroidOnPlane(tips, n, nrm, tips[0]); pivot = SlashArcMeasure.CentroidOnPlane(hilts, n, nrm, tipCentroid); tipR = SlashArcMeasure.MeanRadius(tips, n, pivot, nrm); hiltR = SlashArcMeasure.MeanRadius(hilts, n, pivot, nrm); pivotSrc = "손잡이중심"; } Vector3 sd, bis; float sweep; if (!SlashArcMeasure.RecomputeAboutPivot(tips, n, pivot, ref nrm, out sd, out bis, out sweep)) { if (kind == SwingKind.Swing) return null; } else { cal.planeNormal = ToLocalDir(nrm); cal.bisector = ToLocalDir(bis); cal.startDir = ToLocalDir(sd); cal.pivot = ToLocalPos(pivot); cal.tipRadius = tipR; cal.hiltRadius = hiltR; cal.sweepDeg = sweep; // 품질 = 궤적 길이 × 원피팅 정합도. 길기만 하고 원에서 벗어난 측정보다 // 조금 짧아도 깨끗한 원이 배치에 유리하다(같은 클립도 콤보 타이밍에 따라 // 스윕이 118°~261° 로 흔들린다 — 가장 깨끗한 한 번을 골라 SO 에 굽는다). float fitQ = Mathf.Clamp01(1f - (dev / Mathf.Max(tipR, 1e-3f)) * 2f); cal.quality = fullPath * Mathf.Max(fitQ, 0.05f); cal.source += string.Format(" pivot={0} tipR={1:F3} hiltR={2:F3} dev={3:F3} fitQ={4:F2} q={5:F2}", pivotSrc, tipR, hiltR, dev, fitQ, cal.quality); // 피벗을 다시 잡았으면 스윕도 달라진다 — 분류를 한 번 더 본다. cal.kind = kind; // ── 퇴화 창 대비 — 칼날 평면으로 재구성 ────────────────── // 🔴 실측(2026-09-07 · Attack3_S_10501): 이 클립의 ShowEffect 는 칼이 거의 멈춰 있는 // 순간에 뜬다 — 창 0.35 정규화 구간 동안 칼끝이 0.20 m 밖에 안 움직인다 // (Attack1 4.70 m · Attack2 5.09 m). 그러면 궤적으로는 평면을 정할 수 없고 // (스윕 2.5° · 이동의 대부분이 피팅 평면 밖) 호가 엉뚱하게 눕는다. // 원본 이벤트 시각은 손대지 않기로 했으므로(발주 범위 밖), 배치 쪽에서 버틴다: // **칼날 방향과 칼끝 이동이 이루는 평면**을 그대로 스윙 평면으로 쓴다. // 이 평면은 실제로 측정된 두 벡터가 만드는 것이라 임의값이 아니다. // 법선 부호는 cross(칼날, 이동) 이 자동으로 진행 방향(CCW)을 맞춘다. if (kind == SwingKind.Swing && sweep < _settings.swingMinSweepDegrees) { int mid = n / 2; Vector3 bladeDir = tips[mid] - hilts[mid]; float bladeLen = bladeDir.magnitude; if (bladeLen > 1e-3f) { bladeDir /= bladeLen; Vector3 move = tips[n - 1] - tips[0]; move -= bladeDir * Vector3.Dot(move, bladeDir); if (move.sqrMagnitude < 1e-6f) move = Vector3.Cross(bladeDir, transform.up); if (move.sqrMagnitude < 1e-6f) move = Vector3.Cross(bladeDir, transform.forward); Vector3 bn = Vector3.Cross(bladeDir, move); if (bn.sqrMagnitude > 1e-8f) { bn.Normalize(); cal.planeNormal = ToLocalDir(bn); cal.bisector = ToLocalDir(bladeDir); cal.startDir = ToLocalDir((Quaternion.AngleAxis(-45f, bn) * bladeDir).normalized); cal.pivot = ToLocalPos(hilts[mid]); cal.tipRadius = bladeLen; cal.hiltRadius = 0f; cal.sweepDeg = 90f; // 공칭값 — 배치에는 쓰이지 않는다(법선·볼록·반지름만 쓴다) cal.source += " [칼날평면 폴백 · 창이 퇴화]"; } } } } } else if (kind == SwingKind.Swing) { return null; // 휘두르기인데 평면을 못 구했다 — 다음 스윙에 다시 시도 } // ── 찌르기 프레임 ─────────────────────────────────────────────── cal.stabAxis = ToLocalDir(dir); cal.stabStart = ToLocalPos(tips[0]); cal.stabLength = Mathf.Max(chord, 1e-3f); return cal; } /// /// 🔴 풀 예열 — 새 이펙트 2종은 table_effectlist 에 없어서 InGameInfo 가 시작 시 미리 로드하지 않는다. /// Get_Effect 는 풀에 인스턴스가 없으면 Addressables 비동기 로드를 걸고 **콜백에서** 스폰하므로 /// 첫 스폰이 몇 프레임 늦는다(발주 §4 "스폰 지연 ≤ 1 프레임"에 걸린다). /// 그래서 첫 ShowEffect 때 화면 밖 좌표로 한 번 띄워 풀을 채운다. 눈에 보이지 않고, /// TurnOff_GO 가 수명 뒤 스스로 꺼서 그대로 풀 인스턴스가 된다. /// private static readonly HashSet s_warmed = new HashSet(); private void WarmUpMappedEffects() { if (ExternalSpawner == null || _settings == null) return; TryWarm(_settings.swingArcEffect); TryWarm(_settings.stabEffect); if (_settings.effectRemaps == null) return; for (int i = 0; i < _settings.effectRemaps.Count; i++) { var r = _settings.effectRemaps[i]; if (r == null) continue; TryWarm(r.swingEffect); TryWarm(r.stabEffect); } } private static void TryWarm(string name) { if (string.IsNullOrEmpty(name) || s_warmed.Contains(name)) return; s_warmed.Add(name); ExternalSpawner(name, Vector3.one * 100000f, Vector3.zero, go => { if (go != null) go.transform.localScale = Vector3.one * 0.0001f; // 만에 하나 보여도 티가 안 나게 }); } /// 캘리브레이션(캐릭터 로컬)을 지금의 캐릭터 자세로 펼쳐 이펙트를 1회 배치한다. private void SpawnMapped(SwingCalibration cal, int classId, bool fromCache) { if (cal == null || _settings == null) return; SwingKind forced; string effect = _settings.ResolveEffectName(_burstPrefab, classId, cal.kind, out forced); var kind = forced != SwingKind.Auto ? forced : cal.kind; if (string.IsNullOrEmpty(effect)) return; float life = kind == SwingKind.Stab ? _settings.stabLifetimeSeconds : _settings.arcLifetimeSeconds; bool followRot = _settings.mappedFollowRotation; float followSec = _settings.arcFollowSeconds; bool follow = _settings.arcFollowPlayer; Transform anchorTf = transform; var settings = _settings; // 배치는 인스턴스를 손에 쥔 뒤에 계산한다 — 베이크가 없으면 인스턴스에서 폴백 실측을 // 해야 하기 때문. ExternalSpawner 에 넘기는 pos/euler 는 잠정값이고 콜백이 확정한다. Vector3 provisional = ToWorldPos(kind == SwingKind.Stab ? cal.stabStart : cal.pivot); if (ExternalSpawner == null) { // 콜백 경로가 없으면 정합을 할 수 없다 — 조용히 아무것도 하지 않는다(오배치보다 낫다). #if UNITY_EDITOR if (settings.verboseLog) Debug.LogWarning("[WL #792] ExternalSpawner 가 없어 매핑 배치를 건너뛴다", this); #endif return; } var p = new MappedPlacement { classId = classId, source = _burstPrefab, effect = effect, kind = kind, fromCache = fromCache, tipRadius = cal.tipRadius, hiltRadius = cal.hiltRadius, sweepSwing = cal.sweepDeg, targetNormal = ToWorldDir(cal.planeNormal), targetBisector = ToWorldDir(cal.bisector), targetPivot = ToWorldPos(cal.pivot), stabAxisTarget = ToWorldDir(cal.stabAxis), stabStartTarget = ToWorldPos(cal.stabStart), stabLenTarget = cal.stabLength * Mathf.Max(settings.stabSizeScale, 0.01f) }; float evTime = _eventTime; ExternalSpawner(effect, provisional, Vector3.zero, go => { if (go == null) return; if (kind == SwingKind.Stab) PlaceStab(go, effect, cal, settings, ref p); else PlaceArc(go, effect, cal, settings, ref p); p.delayMs = (Time.realtimeSinceStartup - evTime) * 1000f; LastPlacement = p; MappedSpawnCount++; // 수명 — FX_Blue Stab 루트는 duration 5 초 · looping 이라 반드시 눌러 준다. if (life > 0f) { var off = go.GetComponent(); if (off != null) { off.NoUseParticleTime = true; off.OffTime = life; } } ApplyFollow(go, anchorTf, follow, followRot, followSec); }); } /// /// 배치 직전 정리 — 풀에서 꺼낸 인스턴스에 이전 배치의 스케일·억제가 남아 있으면 되돌린다. /// /// 🔴 은 "이번 활성화 프레임에 Apply 가 없었다"고 /// 판단하면 스케일까지 원복해 버린다. Show_EffectEx 는 콜백 → SetActive 순서라 /// 우리가 세팅한 스케일이 그 직후에 지워질 수 있다. 그래서 배치를 시작하기 전에 /// 원본 스케일을 캡처하고(최초 1회) 이번 프레임을 "우리가 관리한다"고 표시한다. /// 마스크가 없는 프리팹에는 새로 붙이지 않는다 — 필요 없는 컴포넌트를 만들지 않는다(C11). /// private static SlashCrescentMask PrepareInstance(GameObject go, bool createIfMissing) { var mask = go.GetComponent(); if (mask == null) { if (!createIfMissing) return null; mask = go.AddComponent(); } mask.CaptureScale(); // 최초 1회만 — 프리팹의 원본 스케일을 기억한다 mask.RestoreOriginal(); // 이전 배치의 스케일·억제를 지운다 mask.MarkApplied(); // 이번 활성화는 우리가 관리한다(OnEnable 이중 원복 방지) return mask; } /// 호(휘두르기) 배치 — 원 중심·반지름·평면·진행 방향을 모두 맞춘다. private void PlaceArc(GameObject go, string effect, SwingCalibration cal, SlashTrailSettings settings, ref MappedPlacement p) { PrepareInstance(go, false); var geo = settings.FindGeometry(effect); CrescentFrame mf; if (geo != null && geo.arcValid) { mf = new CrescentFrame { valid = true, emitter = geo.emitter, normal = geo.arcNormal, center = geo.arcCenter, bisector = geo.arcBisector, startDir = geo.arcStartDir, radiusOuter = geo.arcRadiusOuter, radiusInner = geo.arcRadiusInner, sweepDeg = geo.arcSweepDeg }; } else if (!s_arcFallback.TryGetValue(effect, out mf) || !mf.valid) { // 베이크가 없다 — 바운딩 박스 폴백. 정확도가 떨어지므로 베이크를 권한다. var rule = settings.FindCrescentRule(effect); mf = SlashArcMeasure.MeasureCrescentFallback(go, rule != null ? rule.crescentEmitterNames : null); s_arcFallback[effect] = mf; } if (!mf.valid || mf.radiusOuter < 1e-4f) return; // ── 목표 프레임(월드) ─────────────────────────────────────────── Vector3 nW = ToWorldDir(cal.planeNormal); Vector3 bW = ToWorldDir(cal.bisector); bW -= nW * Vector3.Dot(bW, nW); // 직교화(수치 오차 정리) if (bW.sqrMagnitude < 1e-8f) return; nW.Normalize(); bW.Normalize(); Vector3 pivotW = ToWorldPos(cal.pivot); // ── 균등 스케일 ──────────────────────────────────────────────── float s; switch (settings.arcRadiusFit) { case ArcRadiusFit.Inner: s = mf.radiusInner > 1e-4f ? cal.hiltRadius / mf.radiusInner : cal.tipRadius / mf.radiusOuter; break; case ArcRadiusFit.Both: { float d = mf.radiusOuter * mf.radiusOuter + mf.radiusInner * mf.radiusInner; s = d > 1e-8f ? (mf.radiusOuter * cal.tipRadius + mf.radiusInner * cal.hiltRadius) / d : cal.tipRadius / mf.radiusOuter; break; } default: s = cal.tipRadius / mf.radiusOuter; break; } // ── #816t 공격 범위 정합 — 호 바깥 반지름을 플레이어 사거리에 맞춘다(PD 지시) ── // 출처는 WLRangeRing 과 같은 SOT(테이블 f_AttackRange + 버프 + 즉시공격 여유). 상수 0 개(C45). // 사거리를 못 읽으면(로비·스탯 미초기화) 0 → 위의 칼끝 반경 정합을 그대로 쓴다. float reach = settings.arcFitAttackRange ? ResolveAttackReach() : 0f; if (reach > 0.01f && mf.radiusOuter > 1e-4f) s = reach * Mathf.Max(settings.arcRangeScale, 0.01f) / mf.radiusOuter; s *= Mathf.Max(settings.arcSizeScale, 0.01f); // ── 회전 · 위치 ──────────────────────────────────────────────── Quaternion target = Quaternion.LookRotation(nW, bW); Quaternion rot = target * Quaternion.Inverse(mf.FrameRotation); Vector3 anchor = pivotW + nW * settings.arcPlaneOffset + bW * settings.arcRadialOffset; var t = go.transform; t.rotation = rot; t.localScale = Vector3.one * s; t.position = anchor - rot * (mf.center * s); // ── 검증 실측 — 배치 결과를 인스턴스 트랜스폼에서 역산한다(추정 아님 · C39) ── p.arcNormal = (rot * mf.normal).normalized; p.arcBisector = (rot * mf.bisector).normalized; p.arcCenter = t.TransformPoint(mf.center); p.arcRadius = mf.radiusOuter * s; p.scale = s; p.sweepMesh = mf.sweepDeg; p.targetNormal = nW; p.targetBisector = bW; p.targetPivot = pivotW; LastMappedInfo = string.Format( "ARC {0} emitter='{1}' scale={2:F3} tipR={3:F3} hiltR={4:F3} meshR={5:F3}/{6:F3} " + "sweep(swing/mesh)={7:F1}/{8:F1}° pivot={9}", effect, mf.emitter, s, cal.tipRadius, cal.hiltRadius, mf.radiusOuter, mf.radiusInner, cal.sweepDeg, mf.sweepDeg, pivotW.ToString("F2")); } /// 찌르기 배치 — 축·길이·위치를 맞추고 이펙트 면이 카메라를 보도록 롤을 잡는다. private void PlaceStab(GameObject go, string effect, SwingCalibration cal, SlashTrailSettings settings, ref MappedPlacement p) { var keep = settings.stabKeepEmitterNames; var mask = PrepareInstance(go, keep != null && keep.Length > 0); var geo = settings.FindGeometry(effect); StabFrame sf; if (geo != null && geo.stabValid) { sf = new StabFrame { valid = true, note = "baked", axis = geo.stabAxis, start = geo.stabStart, length = geo.stabLength, planeNormal = geo.stabPlaneNormal }; } else if (!s_stabFallback.TryGetValue(effect, out sf) || !sf.valid) { sf = SlashArcMeasure.MeasureStab(go, geo != null && geo.flipAxis, keep); s_stabFallback[effect] = sf; } if (!sf.valid || sf.length < 1e-4f) return; // ── 목표 축(월드) ────────────────────────────────────────────── Vector3 axW = ToWorldDir(cal.stabAxis); if (axW.sqrMagnitude < 1e-8f) return; axW.Normalize(); Vector3 startW = ToWorldPos(cal.stabStart); float len = Mathf.Max(cal.stabLength, 1e-3f) * Mathf.Max(settings.stabSizeScale, 0.01f); // ── 롤 — 이펙트의 납작한 면이 카메라를 향하게 ────────────────── // FX_Blue Stab 계열은 이미터가 전부 Local 정렬이라 스스로 카메라를 보지 않는다. // 축에 수직인 성분만 남겨 축 정합을 깨지 않는다. Vector3 up; var cam = Camera.main; if (settings.stabFaceCamera && cam != null) up = cam.transform.position - (startW + axW * len * 0.5f); else up = transform.up; up -= axW * Vector3.Dot(up, axW); if (up.sqrMagnitude < 1e-8f) up = Vector3.Cross(axW, Vector3.up); if (up.sqrMagnitude < 1e-8f) up = Vector3.Cross(axW, Vector3.right); up.Normalize(); Quaternion target = Quaternion.LookRotation(axW, up); Quaternion mFrame = Quaternion.LookRotation(sf.axis, OrthoUp(sf.axis, sf.planeNormal)); Quaternion rot = target * Quaternion.Inverse(mFrame); float s = len / sf.length; // ── 앵커 ─────────────────────────────────────────────────────── float f; switch (settings.stabAnchor) { case StabAnchor.Center: f = 0.5f; break; case StabAnchor.End: f = 1f; break; default: f = 0f; break; } Vector3 anchorW = startW + axW * (len * f); Vector3 refLocal = sf.start + sf.axis * (sf.length * f); var t = go.transform; t.rotation = rot; t.localScale = Vector3.one * s; t.position = anchorW - rot * (refLocal * s); // ── 검증 실측 ────────────────────────────────────────────────── p.stabAxisTarget = axW; p.stabAxisPlaced = (rot * sf.axis).normalized; p.stabStartTarget = startW; p.stabStartPlaced = t.TransformPoint(sf.start); p.stabLenTarget = len; p.stabLenPlaced = sf.length * s; p.scale = s; // ── 예산 정리 — 남길 이미터만 남긴다(비우면 전부 남긴다) ─────── // PrepareInstance 가 이미 원본 상태로 되돌려 놨으므로 여기서는 억제만 건다. if (mask != null && keep != null && keep.Length > 0) mask.Apply(null, SuppressMode.AllExceptListed, keep); LastMappedInfo = string.Format( "STAB {0} scale={1:F3} len(swing/mesh)={2:F3}/{3:F3} axis={4} start={5}", effect, s, len, sf.length, axW.ToString("F2"), startW.ToString("F2")); } /// 축에 수직인 성분만 남긴 up 벡터(LookRotation 이 실패하지 않도록). private static Vector3 OrthoUp(Vector3 axis, Vector3 up) { Vector3 u = up - axis * Vector3.Dot(up, axis); if (u.sqrMagnitude < 1e-8f) u = Vector3.Cross(axis, Vector3.up); if (u.sqrMagnitude < 1e-8f) u = Vector3.Cross(axis, Vector3.right); return u.normalized; } // ── 캐릭터 로컬 ↔ 월드 (스케일 무시 — 회전 + 이동만) ──────────────── // 액터 스케일이 바뀌어도 캘리브레이션이 흔들리지 않게 한다. 길이·반지름은 // 미터 단위로 따로 저장하므로 여기서는 회전·이동만 다룬다. private Vector3 ToLocalDir(Vector3 world) { return Quaternion.Inverse(transform.rotation) * world; } private Vector3 ToWorldDir(Vector3 local) { return transform.rotation * local; } private Vector3 ToLocalPos(Vector3 world) { return Quaternion.Inverse(transform.rotation) * (world - transform.position); } private Vector3 ToWorldPos(Vector3 local) { return transform.position + transform.rotation * local; } // ── #816t 공격 범위(사거리) 해석 — WLRangeRing.Poll 과 같은 출처·같은 수식 ────── // stoppingDistance 는 타겟 반경·크기 보정으로 커질 수 있어(MyActor.cs:207·212·213) // **양수 최솟값**을 기억한다 = 보정 없는 평시 값 = ClassConfig f_AttackRange(PCActor.cs:381). private float _baseStop; /// 마지막으로 해석한 공격 범위(m). 0 = 아직 못 읽음(검증용 읽기 전용 · 게임 로직은 쓰지 않는다). public static float LastAttackReach; private float ResolveAttackReach() { if (_pc == null) return 0f; var agent = _pc.Get_Agent(); if (agent != null) { float sd = agent.stoppingDistance; if (sd > 0.01f && (_baseStop <= 0f || sd < _baseStop)) _baseStop = sd; } if (_baseStop <= 0f) return 0f; float up = 0f; var stat = _pc.Get_StatInfo(); if (stat != null) up = (float)stat.Get_Stat(eStat.AttackRange_Up); float reach = _baseStop + up + WL.Settings.WLTargetingSettings.ImmediateAttackMargin; LastAttackReach = reach; return reach; } // ───────────────────────────────────────────────────────────────────────── // 수명 관리 // ───────────────────────────────────────────────────────────────────────── private void OnDisable() { CancelSwing(); } private void OnDestroy() { ReleaseSlots(); } /// /// 트레일 오브젝트를 정리한다. BladeTrail 은 **부모가 없는** 월드 오브젝트라 /// 액터가 사라져도 자동으로 파괴되지 않는다 — 반드시 여기서 지운다(누수 방지). /// public void ReleaseSlots() { for (int i = 0; i < _slots.Count; i++) { var slot = _slots[i]; if (slot == null || slot.trail == null) continue; var go = slot.trail.gameObject; if (go == null) continue; if (Application.isPlaying) Destroy(go); else DestroyImmediate(go); } _slots.Clear(); _slotsResolved = false; _swingActive = false; } } }