1819 lines
98 KiB
C#
1819 lines
98 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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;
|
||
}
|
||
}
|
||
}
|