Project_WL/Assets/WL/Combat/WeaponTrailDriver.cs

1819 lines
98 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>
/// PC 의 근접 공격에서 무기가 실제로 지나간 자리에 궤적 리본을 그린다.
/// <see cref="TryHandleShowEffect"/> 가 필요할 때 런타임으로 자동 부착하므로
/// 프리팹을 미리 손댈 필요가 없다.
/// </summary>
[DisallowMultipleComponent]
public sealed class WeaponTrailDriver : MonoBehaviour
{
/// <summary>소켓 하나에 물린 무기의 실측 결과 + 그 무기 전용 트레일.</summary>
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<WeaponSlot> _slots = new List<WeaponSlot>();
private bool _slotsResolved;
private int _resolvedClassId = -1;
// ── 스윙 창 상태 ─────────────────────────────────────────────────────────
private bool _swingActive;
private int _swingStateHash;
private float _swingStartNormalized;
private float _swingStartTime;
private string _burstPrefab;
// ── #792 매핑 이펙트 상태 ────────────────────────────────────────────
/// <summary>이번 스윙에서 이벤트 시점에 이미 매핑 이펙트를 띄웠는가(중복 방지).</summary>
private bool _mappedSpawned;
/// <summary>이번 스윙의 캘리브레이션 키(클래스ID + 이벤트 stringParameter).</summary>
private string _mappedKey;
/// <summary>이벤트가 뜬 시각(초). 스폰 지연을 재는 데 쓴다.</summary>
private float _eventTime;
/// <summary>런타임에 실측해 채운 캘리브레이션 캐시. SO 에 구운 값이 1순위다.</summary>
private static readonly Dictionary<string, SwingCalibration> s_runtimeCal =
new Dictionary<string, SwingCalibration>();
/// <summary>프리팹 기하 런타임 폴백 캐시(SO 에 베이크가 없을 때만 쓴다).</summary>
private static readonly Dictionary<string, CrescentFrame> s_arcFallback =
new Dictionary<string, CrescentFrame>();
private static readonly Dictionary<string, StabFrame> s_stabFallback =
new Dictionary<string, StabFrame>();
/// <summary>런타임 캘리브레이션 캐시(에디터 베이크가 SO 로 옮긴다).</summary>
public static Dictionary<string, SwingCalibration> RuntimeCalibrations { get { return s_runtimeCal; } }
/// <summary>캐시를 비운다(설정을 바꾼 뒤 재실측 · 검증용).</summary>
public static void ClearMappedCache()
{
s_runtimeCal.Clear();
s_arcFallback.Clear();
s_stabFallback.Clear();
s_warmed.Clear();
}
/// <summary>마지막 #792 배치를 사람이 읽는 한 줄로. **검증 전용** — CLI 덤프가 읽는다.</summary>
public static string LastMappedInfo;
/// <summary>
/// #792 배치 1회의 **목표값과 실제 결과값**. 검증 전용(발주 §4 실측표) — 게임 로직은 읽지 않는다.
/// 목표(스윙 실측)와 결과(배치된 인스턴스 트랜스폼에서 역산)를 함께 담으므로
/// 오차를 추정이 아니라 실측으로 낼 수 있다(C39).
/// </summary>
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;
}
/// <summary>마지막 배치의 목표/결과. 검증 전용.</summary>
public static MappedPlacement LastPlacement;
/// <summary>#792 경로로 배치한 총 횟수. 검증 전용.</summary>
public static int MappedSpawnCount;
// ── 검증용 공개 프로퍼티 (2단계 CSV 샘플러가 읽는다) ────────────────────
/// <summary>스윙 창이 열려 있는가.</summary>
public bool IsSwinging { get { return _swingActive; } }
/// <summary>궤적을 그리는 무기 슬롯 수(양손 단검이면 2).</summary>
public int ActiveSlotCount { get { return _slots.Count; } }
/// <summary>i 번째 슬롯의 트레일. 없으면 null.</summary>
public BladeTrail GetTrail(int i) { return (i >= 0 && i < _slots.Count) ? _slots[i].trail : null; }
/// <summary>i 번째 슬롯의 현재 검끝 월드 좌표.</summary>
public Vector3 GetTipWorld(int i) { return (i >= 0 && i < _slots.Count && _slots[i].IsAlive) ? _slots[i].TipWorld : Vector3.zero; }
/// <summary>i 번째 슬롯의 현재 손잡이 월드 좌표.</summary>
public Vector3 GetHiltWorld(int i) { return (i >= 0 && i < _slots.Count && _slots[i].IsAlive) ? _slots[i].HiltWorld : Vector3.zero; }
/// <summary>i 번째 슬롯에서 실측한 검날 길이(m).</summary>
public float GetBladeLength(int i) { return (i >= 0 && i < _slots.Count) ? _slots[i].bladeLength : 0f; }
/// <summary>지금까지 스윙을 연 횟수.</summary>
public int SwingCount { get; private set; }
// ─────────────────────────────────────────────────────────────────────────
// 진입점 — Actor.ShowEffect 가 부른다
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// ShowEffect 애니메이션 이벤트를 궤적 트레일 경로로 처리했는지 돌려준다.
/// <c>true</c> 면 호출부(Actor.ShowEffect)는 기존 캐릭터 기준 경로를 **타지 않는다**.
/// <c>false</c> 면 아무것도 하지 않았으므로 호출부는 기존 동작을 그대로 이어간다
/// — 몬스터·원거리 클래스·설정 에셋 부재·무기 미장착이 모두 여기에 해당한다(C8 롤백 경로).
/// </summary>
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<WeaponTrailDriver>();
if (stale != null) stale.ReleaseSlots();
return false;
}
var driver = actor.GetComponent<WeaponTrailDriver>();
if (driver == null) driver = actor.gameObject.AddComponent<WeaponTrailDriver>();
bool handled = driver.HandleAttackEvent(pc, settings, ov, ev);
WL.Combat.Core.CombatEvents.RaiseSwingEffect(pc, ev, handled); // WL #811b 코어 통지(검기 창 시작)
return handled;
}
/// <summary>
/// 이 클래스에 궤적 트레일을 적용할지 판정한다.
/// 우선순위 = 설정 에셋의 클래스 오버라이드 → 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 은 무기 프리팹이 양손 모두 빈칸이라 어차피 궤적을 못 그린다.
/// </summary>
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;
}
/// <summary>현재 프레임의 검끝·스윙 방향으로 버스트를 터뜨린다(BurstTiming.OnEvent).</summary>
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();
}
/// <summary>스윙 창을 닫을 때가 됐는지. 정규화 시간 · 상태 전환 · 안전 상한 3중 판정.</summary>
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
}
/// <summary>스윙을 즉시 접는다(피격·사망·콤보 취소).</summary>
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);
}
}
/// <summary>현재(또는 전환 중이면 목표) 애니메이터 상태의 해시·정규화 시간을 읽는다.</summary>
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;
}
// ─────────────────────────────────────────────────────────────────────────
// 무기 소켓 해석 · 검날 실측
// ─────────────────────────────────────────────────────────────────────────
/// <summary>무기 슬롯을 해석한다(캐시). 궤적을 그릴 무기가 하나라도 있으면 true.</summary>
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;
}
/// <summary>
/// 대상 소켓 목록을 모은다. (인덱스, 트랜스폼) 쌍.
///
/// 실측 — 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)이 맡는다.
/// </summary>
private List<KeyValuePair<int, Transform>> CollectSockets(SlashTrailClassOverride ov)
{
var result = new List<KeyValuePair<int, Transform>>(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<int, Transform>(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<int, Transform>(-1 - i, found)); break; }
}
}
return result;
}
/// <summary>
/// 소켓에 물린 무기 메시에서 자루 끝·칼끝을 실측한다.
/// 검날로 인정되지 않으면(짧거나, 납작한 방패꼴) null 을 돌려 그 슬롯을 건너뛴다.
///
/// 구 WL(SlashVfxPlayer.MeasureBlade) 대비 개선:
/// · 길이를 메시 로컬 단위가 아니라 **월드 두 끝점 거리**로 잰다(스케일 반영).
/// · 손잡이를 소켓 원점이 아니라 **메시의 소켓에 가까운 끝**으로 잡는다.
/// · 최장축/차축 비율로 방패·오브를 배제한다(이름 하드코딩 없이).
/// </summary>
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<Renderer>(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
};
}
/// <summary>소켓 인덱스를 검사해 목록에 넣는다(중복·범위 밖·빈 소켓 제외).</summary>
private static void AddSocket(List<KeyValuePair<int, Transform>> 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<int, Transform>(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<MeshFilter>();
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;
}
// ─────────────────────────────────────────────────────────────────────────
// 검끝 버스트
// ─────────────────────────────────────────────────────────────────────────
/// <summary>
/// 스폰된 이펙트 인스턴스를 넘겨 주는 선택적 경로.
///
/// 기본(null)이면 아래 <see cref="SpawnTipBurst"/> 가 기존
/// <c>InGameInfo.Show_Effect(prefab, pos, euler)</c> 를 그대로 호출한다 —
/// **힘민지 코드를 한 줄도 고치지 않고 동작한다**.
///
/// 2단계에 InGameInfo 에 스폰 콜백 오버로드를 추가하면 그 한 줄에서 이 델리게이트를
/// 연결한다. 그러면 크레센트 이미터를 런타임에 골라 끌 수 있다(프리팹 에셋 무수정 ·
/// 설정만 비우면 즉시 원복 · C8). 상세 diff = PLAN_2단계.md
///
/// 시그니처: (프리팹 이름, 위치, 오일러각, 스폰된 인스턴스를 받는 콜백)
/// </summary>
public static System.Action<string, Vector3, Vector3, System.Action<GameObject>> ExternalSpawner;
/// <summary>
/// 검끝 자리에 원본 슬래시 프리팹을 1회 재생한다.
/// 기존 InGameInfo 풀(dic_str_Effect + TurnOff_GO)을 그대로 쓴다 — 풀을 새로 만들지 않는다(C11).
/// </summary>
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 류)
// ─────────────────────────────────────────────────────────────────────────
/// <summary>프리팹 인스턴스에서 실측한 크레센트 프레임. 프리팹 이름으로 캐시한다.</summary>
private struct ArcMeasure
{
public Quaternion frame; // 루트 기준 크레센트 프레임 (forward = 면 법선, up = 호가 뻗는 방향)
public Vector3 centerLocal; // 루트 로컬(스케일 1) 기준 크레센트 시각 중심
public float diameter; // 루트 스케일 1 기준 크레센트 시각 지름(m)
public string emitter; // 실측에 쓴 이미터 이름(검증용)
}
private static readonly Dictionary<string, ArcMeasure> s_arcCache = new Dictionary<string, ArcMeasure>();
/// <summary>마지막 PrefabArc 배치 결과. **검증 전용**(#761 판정 ⓒ).</summary>
public static string LastArcInfo;
/// <summary>PrefabArc 캐시를 비운다(설정을 바꾼 뒤 재실측할 때 · 검증용).</summary>
public static void ClearArcCache() { s_arcCache.Clear(); }
/// <summary>
/// 모아 둔 궤적으로 평면을 구해 원본 프리팹을 1회 배치한다.
/// 샘플이 모자라면 false 를 돌려 호출부가 기존 검끝 버스트로 떨어지게 한다.
/// </summary>
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;
}
/// <summary>
/// 스폰 직후(활성화 전) 인스턴스를 실측해 회전·크기·위치를 확정한다.
/// 억제(<see cref="SlashCrescentMask"/>)는 **적용하지 않고 원복만** 한다 —
/// PD #761 의 핵심이 "원본 이펙트를 원본 색 그대로" 이기 때문이다.
/// </summary>
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<SlashCrescentMask>();
if (mask == null) mask = go.AddComponent<SlashCrescentMask>();
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"));
}
/// <summary>
/// 인스턴스에서 크레센트 이미터를 골라 프레임(면 법선·호 방향)·중심·지름을 실측한다.
/// 호출 시점의 루트 회전·스케일과 무관하도록 **루트 로컬 좌표**로 환산해 담는다.
/// </summary>
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<ParticleSystem>(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<ParticleSystemRenderer>();
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;
}
/// <summary>
/// 스폰된 인스턴스에서 크레센트(호 본체) 이미터의 렌더러만 끈다. 호는 트레일이 그리므로
/// 원본 크레센트가 겹치면 방향이 어긋나 보인다(#747 증상 그대로).
/// 규칙이 없으면 원래 상태로 되돌린다 — 기본값은 "끄지 않음"이다.
///
/// 🔴 인스턴스는 InGameInfo 가 **재사용**하므로(dic_str_Effect 풀) 렌더러를 그냥 끄면
/// 영구히 꺼진 채로 돌아다닌다. 그래서 <see cref="SlashCrescentMask"/> 가 최초 1회
/// 원래 상태를 기억하고, 매 스폰마다 그 상태에서 다시 계산한다.
/// 특히 원본에 이미 꺼져 있는 렌더러(Blue_Slash_1 루트 · eff · Slash_fire_once 루트 등
/// m_Enabled = 0 인 비렌더 이미터)를 실수로 켜지 않는다.
/// </summary>
private static void ApplyCrescentRule(GameObject go, SlashPrefabEmitterRule rule,
SuppressMode mode, string[] keepNames)
{
if (go == null) return;
var mask = go.GetComponent<SlashCrescentMask>();
if (mask == null) mask = go.AddComponent<SlashCrescentMask>();
mask.Apply(rule, mode, keepNames);
LastMask = mask;
LastMaskPrefab = go.name;
}
/// <summary>
/// PD #777 — 스폰된 이펙트가 PC 를 따라가게 한다.
///
/// 부모를 바꾸지 않고 <see cref="SlashArcFollower"/> 가 매 LateUpdate 마다 좌표만 갱신한다.
/// 풀(dic_str_Effect) 구조를 건드리지 않으므로 설정을 끄면 그 즉시(다음 스폰부터)
/// 기존 월드 고정 동작으로 돌아간다(C8 롤백 경로). 이미 붙어 있던 인스턴스도
/// <see cref="SlashArcFollower.Detach"/> 로 그 자리에서 해제한다.
/// </summary>
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);
}
/// <summary>
/// 마지막으로 억제 규칙을 적용한 마스크 · 프리팹 이름. **검증 전용**(#760 판정 ⓓ) —
/// 런타임 이미터 on/off 상태를 CLI 에서 덤프하기 위한 것이고 게임 로직은 읽지 않는다.
/// </summary>
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 — 코드에 게임 값 없음).
// ─────────────────────────────────────────────────────────────────────────
/// <summary>SO 베이크 → 런타임 실측 순으로 캘리브레이션을 찾는다. 없으면 null.</summary>
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;
}
/// <summary>
/// 이번 스윙의 궤적 샘플로 (휘두르기/찌르기) 프레임을 실측해 **캐릭터 로컬**로 담는다.
/// 샘플이 모자라거나 피팅이 실패하면 null — 호출부는 아무것도 띄우지 않는다(원본과 같은
/// "이펙트 없음" 이 아니라, 다음 스윙에서 다시 시도한다).
/// </summary>
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;
}
/// <summary>
/// 🔴 풀 예열 — 새 이펙트 2종은 table_effectlist 에 없어서 InGameInfo 가 시작 시 미리 로드하지 않는다.
/// Get_Effect 는 풀에 인스턴스가 없으면 Addressables 비동기 로드를 걸고 **콜백에서** 스폰하므로
/// 첫 스폰이 몇 프레임 늦는다(발주 §4 "스폰 지연 ≤ 1 프레임"에 걸린다).
/// 그래서 첫 ShowEffect 때 화면 밖 좌표로 한 번 띄워 풀을 채운다. 눈에 보이지 않고,
/// TurnOff_GO 가 수명 뒤 스스로 꺼서 그대로 풀 인스턴스가 된다.
/// </summary>
private static readonly HashSet<string> s_warmed = new HashSet<string>();
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; // 만에 하나 보여도 티가 안 나게
});
}
/// <summary>캘리브레이션(캐릭터 로컬)을 지금의 캐릭터 자세로 펼쳐 이펙트를 1회 배치한다.</summary>
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<TurnOff_GO>();
if (off != null) { off.NoUseParticleTime = true; off.OffTime = life; }
}
ApplyFollow(go, anchorTf, follow, followRot, followSec);
});
}
/// <summary>
/// 배치 직전 정리 — 풀에서 꺼낸 인스턴스에 이전 배치의 스케일·억제가 남아 있으면 되돌린다.
///
/// 🔴 <see cref="SlashCrescentMask.OnEnable"/> 은 "이번 활성화 프레임에 Apply 가 없었다"고
/// 판단하면 스케일까지 원복해 버린다. Show_EffectEx 는 콜백 → SetActive 순서라
/// 우리가 세팅한 스케일이 그 직후에 지워질 수 있다. 그래서 배치를 시작하기 전에
/// 원본 스케일을 캡처하고(최초 1회) 이번 프레임을 "우리가 관리한다"고 표시한다.
/// 마스크가 없는 프리팹에는 새로 붙이지 않는다 — 필요 없는 컴포넌트를 만들지 않는다(C11).
/// </summary>
private static SlashCrescentMask PrepareInstance(GameObject go, bool createIfMissing)
{
var mask = go.GetComponent<SlashCrescentMask>();
if (mask == null)
{
if (!createIfMissing) return null;
mask = go.AddComponent<SlashCrescentMask>();
}
mask.CaptureScale(); // 최초 1회만 — 프리팹의 원본 스케일을 기억한다
mask.RestoreOriginal(); // 이전 배치의 스케일·억제를 지운다
mask.MarkApplied(); // 이번 활성화는 우리가 관리한다(OnEnable 이중 원복 방지)
return mask;
}
/// <summary>호(휘두르기) 배치 — 원 중심·반지름·평면·진행 방향을 모두 맞춘다.</summary>
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"));
}
/// <summary>찌르기 배치 — 축·길이·위치를 맞추고 이펙트 면이 카메라를 보도록 롤을 잡는다.</summary>
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"));
}
/// <summary>축에 수직인 성분만 남긴 up 벡터(LookRotation 이 실패하지 않도록).</summary>
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;
/// <summary>마지막으로 해석한 공격 범위(m). 0 = 아직 못 읽음(검증용 읽기 전용 · 게임 로직은 쓰지 않는다).</summary>
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();
}
/// <summary>
/// 트레일 오브젝트를 정리한다. BladeTrail 은 **부모가 없는** 월드 오브젝트라
/// 액터가 사라져도 자동으로 파괴되지 않는다 — 반드시 여기서 지운다(누수 방지).
/// </summary>
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;
}
}
}