// ─────────────────────────────────────────────────────────────────────────────
// 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]; }
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 * 0.25f;
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;
}
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; }
// ─────────────────────────────────────────────────────────────────────────
// 수명 관리
// ─────────────────────────────────────────────────────────────────────────
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;
}
}
}