Project_WL/Assets/WL/Combat/Impact/WLImpactFx.cs

1054 lines
84 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLImpactFx.cs — 타격 임팩트 교체·보강 (#843 · 2026-10-12 · PD 「검 피격 = 파란 별 베기 · 해머 궤적 = 주황 회전 · 해머 피격 = 주황 타격 ·
// 해머 3타·대검 4타 지면 충격파 · 지팡이 마법 투사체는 몸 중심까지 날아가 피격 이펙트와 함께 소멸」)
// #844 (2026-10-12 · PD) ① 해머 궤적은 휘두르는 방향에 맞게 — 궤적 베이크(SlashTrailSettings.swingCalibrations: 평면·시작 방향·이등분·반경·sweep)에
// 회전 이펙트의 실측 프로필(파티클 방출 궤도의 축·시작·반경)을 정합 ② 충격파는 무기 끝이 지면에 닿는 순간·그 자리(WeaponTrailDriver 칼끝 추적)
// ③ 피격 이펙트는 몬스터 몸체 기준(발~정수리 중간 · 이펙트 실측 치수를 몸 높이에 맞춤).
//
// ■ 연결 지점(원본 훅 2줄 · 나머지 WL)
// · ProjectileBase.Show_HitEffect — isHitEffect && !TryHitEffect(...) : 직업별 피격 이펙트 교체(몸 중심 = 발~정수리 중간 · 몸 높이 비례).
// · ProjectileBase.ProcessCollider — Off_byHit && !DeferOff(...) : 지팡이 투사체는 맞은 뒤 끄지 않고 몸 중심까지 조준 비행 → 도달 시 피격 이펙트 + 소멸.
// · WeaponTrailDriver.TryHandleShowEffect — TrySwingOverride : 해머 궤적을 회전형 이펙트로(검기 호 대신) · 궤적 베이크 프레임에 정합.
// · CombatEvents.ComboStage 구독 — 해머 3타·대검 4타 단계 진입 시 칼끝 높이 추적 → 지면 접점에 충격파. HitboxSpawned 는 추적 불가 시 폴백.
// ■ 값·프리팹 이름 = Resources/WL/WLImpactFx.json (C45). 프리팹 = Resources/WL/Impact/(Master Stylized FX 사본 · WL842_StatusPrefabs.BuildImpact).
// ■ 프리팹 프로필(WLFxProfile) = 첫 사용 시 보이지 않는 곳에서 Simulate 로 실측(치수·중심 오프셋·방출 궤도 축) — 손으로 적은 상수 없음.
// 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Impact
{
/// <summary>피격 교체. fit &gt; 0 이면 이펙트 최대 치수 = fit × 몸 높이(프로필 실측 치수로 환산) · 0 이면 scale × 키 비례(구 방식).</summary>
public sealed class WLHitFxRule { public int[] classIds; public string prefab; public float scale = 1f; public float fit = 1.3f; public bool offsetCompensate = true; }
/// <summary>#936 스킬 투사체(원본 str_HitEffect 가 빈 것 — 강력한 일격 · 돌진 베기 · 방패 치기 · 대지 소환 · 대지 줄기 · 화살 비 …)의 무기별 스킬 피격 이펙트. skills = 투사체 이름 앞부분 목록(비우면 그 직업의 모든 스킬 투사체).</summary>
public sealed class WLSkillHitFxRule { public int[] classIds; public string[] skills; public string prefab; public float scale = 1f; public float fit = 1.6f; public bool offsetCompensate = true; }
/// <summary>궤적 교체. calibrated = 궤적 베이크 프레임에 정합(emitter = 프로필 기준 방출기 이름 · 비우면 자동) · radiusFit = 칼끝 반경 대비 이펙트 반경.</summary>
public sealed class WLSwingFxRule { public int[] classIds; public int[] stages; public string prefab; public float scale = 1f; public float[] euler; public float yOffset; public bool calibrated = true; public string emitter = ""; public float radiusFit = 1f; public bool sweepFlip; public float fallbackScale = 0.55f;
public float minFacing;
// #932 PD 「대검 1타는 모션을 내려치기로 바꾸지 말고 대각선 베기하면서 이펙트만 좀 더 잘보이게」 — 1 = 기울임 축을 호의 현(시작 → 끝 선 · 면 법선 × 이등분) 으로: 베는 방향(대각선)은 그대로 두고 초승달 가운데만 카메라 쪽으로 펼친다 · maxTilt = 최대 각(도)
public int facingAxis; public float maxTilt = 40f; // #928 0 보다 크면 초승달 면 법선과 시선의 |cos| 이 이 값보다 작을 때(카메라에서 면이 얇게 보일 때) 면을 카메라 쪽으로 그 값까지만 기울인다(피벗 고정)
// #940 PD 「창 2타 연타 때 무기 부분을 FX_Machine_Gun 으로 가려」 — calibrated 0 전용: follow = PC 에 붙여 따라감 · forward = PC 정면 거리(m) · life = 켜 둘 초(0 = 프리팹 길이) · minInterval = 같은 PC·단계의 ShowEffect 가 이 초 안에 또 오면 새로 띄우지 않고 궤적만 막는다(6연타 이벤트 → 이펙트 1개)
public bool follow; public float forward; public float side; public float life; public float minInterval; // side = PC 오른쪽 거리(m)
// #941 PD 「창 공격 시 이펙트와 캐릭터 방향이 일치하게」 — alignWeapon = 매 프레임 무기 소켓(손잡이 → 무기 끝) 방향에 맞춰 돌리고 손잡이에서 alignFraction 만큼 간 자리에 둔다(forward/side 는 안 씀) · massLocal = 프리팹 로컬 단위의 보이는 묶음 중심(원점 보정 · Play 실측)
public bool alignWeapon; public float alignFraction = 0.5f; public float[] massLocal;
// #955 PD 비수 4타·3타 부가 연출 — blinkDistance: 띄우는 순간 PC 를 정면으로 순간이동(m · 잔상 blinkGhosts 개를 길에) · camHoldSeconds: 그동안 카메라 회전·추적 고정 + camZoomOut(m) 만큼 줌아웃했다 복귀 · spinDegrees/spinSeconds: PC 를 코드로 회전(클립에 회전이 없을 때 · 0 = 안 함)
public float blinkDistance; public int blinkGhosts = 4; public float camHoldSeconds; public float camZoomOut; public float spinDegrees; public float spinSeconds = 0.6f;
// #956 PD 「3타 때 몸을 회전 1회하며 공격 · 이펙트도 회전 모션에 맞게」 — spinFx: PC 에 붙은(follow) 이펙트를 몸과 같은 각으로 돌린다(기본 켬)
public bool spinFx = true;
// #958 회전은 클립 yaw 를 덮어쓴다(클립 자체 회전과 상쇄 방지) · spinBlendOut = 끝난 뒤 애니메이터 자세로 되돌아가는 시간(초)
public float spinBlendOut = 0.12f;
// #943-⑤ PD 「타격하려는 방향을 고정한 채 이펙트가 재생되는 동안 타격 판정 … 이펙트가 캐릭터 방향에 따라 마구 바뀌면 안 됨」 — fixedDir = 띄우는 순간 PC 정면(LookAtTarget 뒤 = 표적 방향)을 잡아 두고 life 동안 이펙트 방향 고정 + PC 회전도 그 방향으로 잠금(공격 상태인 동안) · 자리 = PC 중심 + 정면 alignForward + 오른쪽 alignSide + 위 yOffset − R × massLocal × 배율(PC 위치는 따라감)
public bool fixedDir; public float alignForward = 0.5f; public float alignSide; public bool lockFacing = true;
// #964 PD 「비수 3·4타 이펙트를 1·2타와 같은 검기로」 — keepTrail = 이 규칙은 부가 연출(몸 회전 · 순간이동 · 카메라)만 하고 검기 호·리본(WeaponTrailDriver)은 막지 않는다(이펙트 프리팹 안 띄움)
// #964 PD 「4타 잔상이 플레이어를 따라 생성되도록」 — blinkTrailSeconds 동안 blinkTrailInterval 초마다 PC 가 blinkTrailMinStep(m) 이상 움직였으면 지금 자세 잔상 1개(WLGhostStrike.LiveGhost)
public bool keepTrail; public float blinkTrailSeconds; public float blinkTrailInterval = 0.04f; public float blinkTrailMinStep = 0.08f;
// #964 PD 「창 찌르기 이펙트가 창과 방향·각도가 안 맞음 → 실측해서 정확히」 — alignAxis = 무기 메시 긴 축(자루 끝 → 창끝 중심선)을 방향으로(이전 = 소켓 → bounds 의 가장 먼 모서리) · alignPitch = 위아래 기울기도 따른다(이전 = 수평으로 누름)
public bool alignAxis; public bool alignPitch; }
/// <summary>지면 충격파. tipContact = 칼끝이 지면(contactHeight 이내)에 닿는 프레임·자리에서 재생(단계 안에서 minDrop 이상 올라갔다 내려올 때) · 못 잡으면 timeout 뒤 최저점(fallbackHeight 이내)에.</summary>
/// <summary>#900 frontOnly = PC 정면 쪽 접점만 인정(해머 1타 내려치기 — 뒤로 휘두를 때 등 뒤 지면 접점을 건너뛴다 · 2026-10-17 실측 −0.8 m).</summary>
public sealed class WLGroundFxRule { public int[] classIds; public int stage; public string prefab; public float scale = 1f; public float forward; public bool tipContact = true; public float contactHeight = 0.15f; public float minDrop = 0.35f; public float timeout = 1.5f; public float fallbackHeight = 0.5f; public bool frontOnly; }
public sealed class WLDeferRule
{
public int[] classIds; public float arriveDistance = 0.12f; public float maxSeconds = 0.35f; public float minSpeed = 8f;
// #921 PD 「활 기본 공격 시 휘어지지 않고 직선으로만 날아가도록」 — 이 이름의 투사체는 쏠 때 몸 중심을 겨누고(원본은 발을 겨눠 아래로 날았다 · 실측 진행 높이 −0.23)
// 맞은 뒤에도 방향을 틀지 않고 곧게 몸 중심 깊이까지 날아간다(조준 비행으로 꺾이던 것)
public string[] straightProjectiles;
}
public sealed class WLImpactSettings
{
public bool enabled = true;
public float referenceHeight = 1f, sizeFactorMin = 0.5f, sizeFactorMax = 3f;
public float bodyCenterFraction = 0.5f; // 몸 중심 높이 = 발 + 키 × 이 값
// #941 PD 「피격 이펙트는 몬스터보다 상단에 보여야 해(몬스터에게 가려지기 때문)」 — 피격 이펙트를 몸 중심에서 카메라 쪽으로 (몸 너비 × hitTowardWidthMul · hitTowardMin~Max m) × hitTowardCamera 만큼 당겨 몸 앞에 그린다(화면 위치는 그대로) · 0 = 끔
public float hitTowardCamera = 1f, hitTowardWidthMul = 0.6f, hitTowardMin = 0.5f, hitTowardMax = 1.5f;
public float flyingGap = 0.1f; // #921 몸 그림 바닥이 발보다 이만큼(m) 넘게 높으면 떠 있는 몹 — 몸 중심 = 지금 그림(bounds) 가운데(오르내려도 정중앙) · 0 = 끔
public bool profileWarmup = true; // 프리팹 프로필 실측(끄면 구 방식 배치)
public float profileSeconds = 2.5f; // 프로필 실측 길이
public List<WLHitFxRule> hitFx = new List<WLHitFxRule>();
public List<WLSkillHitFxRule> skillHitFx = new List<WLSkillHitFxRule>(); // #936
public float skillHitMinInterval = 0.12f; // #936 같은 대상에 스킬 피격 이펙트를 다시 띄우는 최소 간격(초 · 화살 비 15회 다단 히트)
public List<WLSwingFxRule> swingFx = new List<WLSwingFxRule>();
public List<WLGroundFxRule> groundFx = new List<WLGroundFxRule>();
public WLDeferRule deferToCenter;
}
/// <summary>몸 상자 — 발·정수리(원본 HUD_Top)로 높이, 렌더러 bounds 로 가로.</summary>
public struct WLBodyBox { public Vector3 center; public float height, width; public bool valid; }
/// <summary>프리팹 1개의 실측 프로필(스케일 1 기준) — 치수·중심 오프셋 + 방출 궤도(축·시작·중심·반경).</summary>
public sealed class WLFxProfile
{
public string prefab;
public Bounds bounds; public Vector3 centerOffset; public float extent;
public bool sweepValid; public string sweepEmitter = ""; public Vector3 sweepNormal, sweepStart, sweepCenter; public float sweepRadius, sweepConsistency; public int sweepSamples;
public string log = "";
}
public static class WLImpactFx
{
public const string JsonPath = "WL/WLImpactFx";
public const string ResourcesDir = "WL/Impact/";
static WLImpactSettings s_settings; static bool s_loaded;
public static WLImpactSettings S { get { if (!s_loaded) Load(); return s_settings; } }
public static bool Enabled { get { var s = S; return s != null && s.enabled; } }
public static void Reload() { s_loaded = false; ClearPool(); }
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetForPlay() { Reload(); } // #954 도메인 리로드 끔 대비 — Play 마다 WLImpactFx.json 을 다시 읽는다(실측: swingFx 배율 변경이 다음 Play 에 안 먹었다)
static void Load()
{
s_loaded = true;
var ta = Resources.Load<TextAsset>(JsonPath);
try { s_settings = ta != null ? Newtonsoft.Json.JsonConvert.DeserializeObject<WLImpactSettings>(ta.text) : null; }
catch (System.Exception ex) { Debug.LogException(ex); s_settings = null; }
}
// ── 진단(프로브가 읽는다)
public static int HitFxShown, SwingShown, SwingCalibrated, GroundShown, GroundContacts, GroundFallbacks, Defers, Arrivals, Misses, SkillHitFxShown, SkillHitThrottled;
public static float LastArriveDist, LastArriveSeconds;
public static string LastLog = "", LastSwing = "", LastGround = "", LastHit = "";
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)]
static void Register() { CombatEvents.HitboxSpawned.Add(OnHitboxSpawned); CombatEvents.ComboStage.Add(OnComboStage); }
static bool Contains(int[] ids, int id) { if (ids == null) return false; for (int i = 0; i < ids.Length; i++) if (ids[i] == id) return true; return false; }
public static int ShooterClass(ProjectileBase pb)
{
if (pb == null) return 0;
var pd = pb.Get_ProjectTileData();
return pd != null && pd.shooter != null ? pd.shooter.Get_ID() : 0;
}
// ── 몸 상자 ──────────────────────────────────────────────────────────
static readonly Dictionary<Actor, Renderer> s_body = new Dictionary<Actor, Renderer>(32);
static Renderer BodyRenderer(Actor a)
{
Renderer r;
if (!s_body.TryGetValue(a, out r) || r == null)
{
r = a.GetComponentInChildren<SkinnedMeshRenderer>();
if (r == null) r = a.GetComponentInChildren<MeshRenderer>();
s_body[a] = r;
}
return r;
}
/// <summary>몸 상자 — 높이 = 원본 HUD_Top(정수리) − 발 · 중심 = 발 + 높이 × bodyCenterFraction · 가로 = 렌더러 bounds(발에서 키 이내일 때만 xz 채택).</summary>
public static WLBodyBox GetBodyBox(Actor a)
{
var bb = new WLBodyBox();
if (a == null) return bb;
Vector3 feet = a.Get_position();
float top;
try { top = a.Get_Top_postion().y; } catch (System.Exception) { top = feet.y; }
float h = top - feet.y;
var r = BodyRenderer(a);
Bounds b = r != null ? r.bounds : new Bounds(feet, Vector3.zero);
if (h < 0.05f) { if (r != null && b.size.y > 0.05f) { h = b.size.y; feet.y = b.min.y; } else h = 1f; }
float frac = S != null ? S.bodyCenterFraction : 0.5f;
bb.height = h;
bb.center = new Vector3(feet.x, feet.y + h * frac, feet.z);
// #921 PD 「공중 몬스터라도 항상 몸의 정중앙에」 — 떠 있는 몹(그림 바닥이 발보다 flyingGap 넘게 위)은 지금 그림 bounds 의 세로 가운데(박쥐 실측: 발 기준식 0.40 · 그림 가운데 0.54)
if (r != null && S != null && S.flyingGap > 0f && b.size.y > 0.05f && b.min.y - feet.y > S.flyingGap) bb.center.y = b.center.y;
if (r != null) bb.width = Mathf.Max(b.size.x, b.size.z); // xz 는 발(transform) — 박쥐는 스킨 bounds 중심이 몸에서 0.47 m 벗어나 있었다(2026-10-12 실측)
if (bb.width < 0.05f) bb.width = h * 0.6f;
bb.valid = true;
return bb;
}
public static Vector3 BodyCenter(Actor a) { return a == null ? Vector3.zero : GetBodyBox(a).center; }
/// <summary>#941 피격 이펙트를 몸 앞(카메라 쪽)에 두는 이동량 — 카메라가 없으면 0. 다른 피격 경로(방패 치기 · 대검 · 투사체별 명중)도 같은 식을 쓴다.</summary>
public static Vector3 TowardCamera(Vector3 at, float bodyWidth)
{
var s = S; if (s == null || s.hitTowardCamera <= 0f) return Vector3.zero;
var cam = Camera.main; if (cam == null) return Vector3.zero;
var d = cam.transform.position - at; if (d.sqrMagnitude < 1e-4f) return Vector3.zero;
float m = Mathf.Clamp(bodyWidth * s.hitTowardWidthMul, s.hitTowardMin, s.hitTowardMax) * s.hitTowardCamera;
return d.normalized * m;
}
public static Vector3 TowardCamera(Actor a) { if (a == null) return Vector3.zero; var bb = GetBodyBox(a); return TowardCamera(bb.center, bb.width); }
public static float SizeFactor(Actor actor)
{
var s = S; if (actor == null || s == null || s.referenceHeight <= 0f) return 1f;
float h = GetBodyBox(actor).height;
if (h < 0.05f) return 1f;
return Mathf.Clamp(h / s.referenceHeight, s.sizeFactorMin, s.sizeFactorMax);
}
static WLHitFxRule FindHit(int cls) { var s = S; if (s == null || s.hitFx == null) return null; for (int i = 0; i < s.hitFx.Count; i++) if (s.hitFx[i] != null && Contains(s.hitFx[i].classIds, cls)) return s.hitFx[i]; return null; }
/// <summary>원본 훅 — ProjectileBase.Show_HitEffect. true 면 원본 이펙트를 띄우지 않는다.</summary>
public static bool TryHitEffect(ProjectileBase pb, bool byHit, Actor target)
{
if (!Enabled || pb == null) return false;
var d = pb.GetComponent<WLDeferredOff>();
if (d != null && d.enabled) return true; // 도달 시점에 보여 준다
if (!byHit || target == null) return false;
var hf = pb.GetComponent<WL.Combat.Weapon.WLProjectileHitFx>(); // #917 투사체별 명중 이펙트(활 화살 · 속사 화살)가 직업 규칙보다 먼저
if (hf != null && hf.Play(target)) { HitFxShown++; return true; }
return PlayHitOverride(ShooterClass(pb), target);
}
/// <summary>직업별 피격 이펙트 교체 — 규칙이 있으면 몸 중심에 몸 높이 비례로 띄우고 true.</summary>
public static bool PlayHitOverride(int shooterClass, Actor target)
{
var rule = FindHit(shooterClass); if (rule == null || target == null) return false;
if (!PlayFit(rule.prefab, rule.scale, rule.fit, rule.offsetCompensate, target, "hit", "cls " + shooterClass)) return false;
HitFxShown++;
return true;
}
/// <summary>프리팹을 대상 몸 중심에 띄운다 — fit &gt; 0 이면 이펙트 p75 지름 = fit × 몸 높이(프로필 실측) · 0 이면 scale × 키 비례(구 방식).</summary>
static bool PlayFit(string prefab, float scale0, float fit, bool offsetCompensate, Actor target, string kind, string why)
{
var bb = GetBodyBox(target);
var prof = Profile(prefab, null);
float scale; string how;
if (fit > 0f && prof != null && prof.extent > 1e-3f) { scale = scale0 * fit * bb.height / prof.extent; how = "fit"; }
else { scale = scale0 * SizeFactor(target); how = "legacy"; }
var toCam = TowardCamera(bb.center, bb.width); // #941 몸 앞(카메라 쪽)
Vector3 pos = bb.center - (prof != null && offsetCompensate ? prof.centerOffset * scale : Vector3.zero) + toCam;
if (Play(prefab, pos, Quaternion.identity, scale) == null) return false;
LastHit = kind + " " + prefab + " → " + target.name + " (" + why + ") " + how + " scale=" + scale.ToString("F3") + " body h=" + bb.height.ToString("F2") + " w=" + bb.width.ToString("F2") + " center=" + bb.center.ToString("F2") + " toCam=" + toCam.magnitude.ToString("F2") + " feet=" + target.Get_position().ToString("F2");
LastLog = LastHit;
return true;
}
// ── #936 스킬 투사체 피격 이펙트 ──────────────────────────────────────
static readonly Dictionary<Actor, float> s_skillHitAt = new Dictionary<Actor, float>(32);
/// <summary>원본 훅 — ProjectileBase.Show_HitEffect(원본 피격 이펙트가 비어 있는 투사체가 맞은 대상). 투사체별 이펙트(WLProjectileHitFx) → 직업·스킬 규칙(skillHitFx) 순으로 몸 중심에 띄우고 true. 같은 대상에는 skillHitMinInterval 간격.</summary>
public static bool TrySkillHitEffect(ProjectileBase pb, Actor target)
{
var s = S; if (!Enabled || s == null || pb == null || target == null) return false;
var hf = pb.GetComponent<WL.Combat.Weapon.WLProjectileHitFx>();
if (hf != null && hf.Play(target)) { HitFxShown++; return true; }
return PlaySkillHit(ShooterClass(pb), pb.name, target);
}
/// <summary>#936 주인공(MyValue.MyPC) 직업 규칙으로 스킬 피격 이펙트 — 투사체가 없는 WL 판정(파츠 스킬 대지 분쇄 · 스킬 콜라이더 화살 비)에서 부른다.</summary>
public static bool PlaySkillHitByPC(Actor target, string name)
{
var pc = MyValue.MyPC as Actor; if (pc == null) return false;
return PlaySkillHit(pc.Get_ID(), name ?? "", target);
}
/// <summary>직업 cls · 투사체(스킬) 이름으로 skillHitFx 규칙을 찾아 몸 중심에 띄운다(같은 대상 skillHitMinInterval 간격).</summary>
public static bool PlaySkillHit(int cls, string name, Actor target)
{
var s = S; if (!Enabled || s == null || s.skillHitFx == null || target == null) return false;
for (int i = 0; i < s.skillHitFx.Count; i++)
{
var r = s.skillHitFx[i]; if (r == null || !Contains(r.classIds, cls)) continue;
if (r.skills != null && r.skills.Length > 0)
{
bool ok = false;
for (int k = 0; k < r.skills.Length; k++) if (!string.IsNullOrEmpty(r.skills[k]) && name.StartsWith(r.skills[k])) { ok = true; break; }
if (!ok) continue;
}
float last;
if (s.skillHitMinInterval > 0f && s_skillHitAt.TryGetValue(target, out last) && Time.time - last < s.skillHitMinInterval) { SkillHitThrottled++; return true; }
if (!PlayFit(r.prefab, r.scale, r.fit, r.offsetCompensate, target, "skill-hit", "cls " + cls + " " + name)) return false;
s_skillHitAt[target] = Time.time; SkillHitFxShown++;
return true;
}
return false;
}
/// <summary>원본 훅 — ProjectileBase.ProcessCollider. true 면 즉시 Off 하지 않는다(몸 중심 도달 뒤 WLDeferredOff 가 끈다).</summary>
public static bool DeferOff(ProjectileBase pb, Actor hitter)
{
var s = S; if (!Enabled || s == null || s.deferToCenter == null || pb == null || hitter == null) return false;
if (pb.m_Pierce) return false; // #918 관통 투사체(활 각성 화살 비 · 속사 화살)는 원본대로 지나간다 — 끄면 다단 히트 · 관통이 첫 명중에서 끊겼다(실측)
if (!Contains(s.deferToCenter.classIds, ShooterClass(pb))) return false;
var d = pb.GetComponent<WLDeferredOff>(); if (d == null) d = pb.gameObject.AddComponent<WLDeferredOff>();
d.Begin(pb, hitter, s.deferToCenter, IsStraight(pb));
Defers++;
return true;
}
/// <summary>#921 곧게 나는 투사체(deferToCenter.straightProjectiles 이름으로 시작)인가.</summary>
public static bool IsStraight(ProjectileBase pb)
{
var s = S; if (!Enabled || s == null || s.deferToCenter == null || s.deferToCenter.straightProjectiles == null || pb == null) return false;
foreach (var n in s.deferToCenter.straightProjectiles) if (!string.IsNullOrEmpty(n) && pb.name.StartsWith(n)) return true;
return false;
}
/// <summary>#921 원본 ProjectileBase.Set 끝 훅 1줄 — 곧게 나는 투사체는 쏘는 순간 대상 몸 중심을 겨눈다(원본 Shooter 시작 = 대상 발을 겨눔).</summary>
public static void AimLaunch(ProjectileBase pb, ProjectileData data)
{
if (data == null || DSUtil.CheckNull(data.target) || !IsStraight(pb)) return;
var c = BodyCenter(data.target);
if ((c - pb.transform.position).sqrMagnitude > 1e-4f) { pb.transform.LookAt(c); Aims++; }
}
public static int Aims;
// ── 궤적(회전형) 교체 — 베이크 프레임 정합 ──────────────────────────────
/// <summary>WeaponTrailDriver 분기 — ShowEffect 클립 이벤트(Effect_Slash_{cls}_{stage})를 회전형 이펙트로 바꾼다.
/// 궤적 베이크(SwingCalibration: 평면 법선·시작 방향·이등분·칼끝 반경·sweep)가 있으면 이펙트의 실측 방출 궤도(축·시작·반경)를 그 프레임에 맞춘다.</summary>
public static bool TrySwingOverride(PCActor pc, AnimationEvent ev)
{
var s = S; if (!Enabled || s == null || s.swingFx == null || pc == null || ev == null) return false;
int cls = pc.Get_ID(); int stage = ParseStage(ev.stringParameter);
for (int i = 0; i < s.swingFx.Count; i++)
{
var r = s.swingFx[i];
if (r == null || !Contains(r.classIds, cls) || (r.stages != null && r.stages.Length > 0 && !Contains(r.stages, stage))) continue;
if (r.keepTrail) // #964 검기 호는 그대로 두고(false → WeaponTrailDriver) 부가 연출만
{
if (r.blinkDistance > 0f || r.camHoldSeconds > 0f || r.spinDegrees != 0f) WLSwingExtras.Begin(pc, r, null);
KeepTrails++; LastSwing = "keepTrail stage " + stage; LastLog = LastSwing;
return false;
}
var tf = pc.transform;
Quaternion rot; Vector3 pos; float scale; string how; bool cal_used = false;
SwingCalibration cal = null;
var prof = r.calibrated ? Profile(r.prefab, r.emitter) : null;
if (r.calibrated && SlashTrailSettings.Instance != null) cal = SlashTrailSettings.Instance.FindCalibration(cls, ev.stringParameter);
if (cal != null && cal.kind != SwingKind.Stab && cal.sweepDeg > 1f && cal.tipRadius > 1e-3f && prof != null && prof.sweepValid)
{
// 목표 프레임(월드) — 회전 축은 「시작 → 이등분」 방향으로 도는 쪽(sweep > 180° 도 반각 비교로 판정) · 기준 방향 = 궤적 이등분(호의 가운데)
Vector3 nW = tf.rotation * cal.planeNormal, sW = tf.rotation * cal.startDir, bW = tf.rotation * cal.bisector;
nW.Normalize();
float a = Vector3.SignedAngle(sW, bW, nW), half = cal.sweepDeg * 0.5f;
bool positive = Mathf.Abs(Mathf.DeltaAngle(a, half)) <= Mathf.Abs(Mathf.DeltaAngle(a, -half));
Vector3 axis = (positive ? nW : -nW) * (r.sweepFlip ? -1f : 1f);
bW -= axis * Vector3.Dot(bW, axis); if (bW.sqrMagnitude < 1e-6f) bW = Vector3.Cross(axis, tf.forward); bW.Normalize();
// 이펙트 프레임(프리팹 로컬) — 프로필의 회전 축(같은 cross 정의) · 질량(머리) 방향 ↔ 궤적 이등분
Vector3 nE = prof.sweepNormal, sE = prof.sweepStart; sE -= nE * Vector3.Dot(sE, nE); sE.Normalize();
rot = Quaternion.LookRotation(axis, bW) * Quaternion.Inverse(Quaternion.LookRotation(nE, sE));
scale = r.scale * r.radiusFit * cal.tipRadius / Mathf.Max(prof.sweepRadius, 1e-3f);
float tilt = 0f;
if (r.minFacing > 0f)
{
var q = r.facingAxis == 1 ? ChordTilt(axis, Vector3.Cross(axis, bW), r.minFacing, r.maxTilt, out tilt) : FaceTilt(axis, r.minFacing, out tilt);
if (tilt != 0f) { rot = q * rot; SwingTilted++; }
}
Vector3 pivotW = tf.position + tf.rotation * cal.pivot;
pos = pivotW - rot * (prof.sweepCenter * scale) + Vector3.up * r.yOffset;
SwingCalibrated++; cal_used = true; how = "cal tilt=" + tilt.ToString("F0") + " axis=" + axis.ToString("F2") + " bis=" + bW.ToString("F2") + " sweep=" + cal.sweepDeg.ToString("F0") + (positive ? "+" : "-") + " tipR=" + cal.tipRadius.ToString("F2") + " pivot=" + pivotW.ToString("F2") + " effR=" + prof.sweepRadius.ToString("F2");
}
else
{
if (r.minInterval > 0f) // #940 연타 이벤트 묶음 — 첫 이벤트만 띄우고 나머지는 궤적만 막는다
{
long key = ((long)pc.GetInstanceID() << 4) | (long)(stage & 15); float last;
if (s_swingAt.TryGetValue(key, out last) && Time.time - last < r.minInterval) { SwingThrottled++; LastSwing = "swing throttled stage " + stage; return true; }
s_swingAt[key] = Time.time;
}
rot = tf.rotation * (r.euler != null && r.euler.Length >= 3 ? Quaternion.Euler(r.euler[0], r.euler[1], r.euler[2]) : Quaternion.identity);
scale = r.scale * r.fallbackScale * SizeFactor(pc);
pos = pc.Get_Center_position() + tf.forward * r.forward + tf.right * r.side + Vector3.up * r.yOffset;
how = "legacy(" + (cal == null ? "no-cal" : prof == null ? "no-profile" : !prof.sweepValid ? "no-sweep" : "cal-kind") + ")" + (r.follow ? " follow" : "");
}
var inst = Play(r.prefab, pos, rot, scale); if (inst == null) return false;
if (r.follow) inst.transform.SetParent(tf, true); // #940 PC 를 따라간다(풀 반환 때 WLImpactOneShot 이 원래 부모로 되돌린다)
if (r.life > 0f) { var os = inst.GetComponent<WLImpactOneShot>(); if (os != null) os.OverrideLife(r.life); }
var fw = inst.GetComponent<WLWeaponFollowFx>(); // #941 무기 방향 추종 · #943-⑤ 고정 방향
if ((r.alignWeapon || r.fixedDir) && !cal_used) { if (fw == null) fw = inst.AddComponent<WLWeaponFollowFx>(); if (fw.Arm(pc, r, scale)) how += r.fixedDir ? " fixedDir" : " alignWeapon"; else how += " alignWeapon(no-socket)"; }
else if (fw != null) fw.Disarm();
SwingShown++; LastSwing = "swing " + r.prefab + " stage " + stage + " " + how + " scale=" + scale.ToString("F3"); LastLog = LastSwing;
if (r.blinkDistance > 0f || r.camHoldSeconds > 0f || r.spinDegrees != 0f) WLSwingExtras.Begin(pc, r, inst); // #955
return true;
}
return false;
}
public static int SwingTilted, SwingThrottled, KeepTrails;
static readonly Dictionary<long, float> s_swingAt = new Dictionary<long, float>(); // #940 (PC, 단계) → 마지막으로 띄운 시각
/// <summary>#928 PD 「대검 1타 공격이 보는 방향에 따라서 너무 얇게 보여서 … 어느 방향에서 보더라도 검기처럼 보이게」 —
/// 초승달 면 법선 n 과 시선(Camera.main)의 |cos| 이 minFacing 보다 작으면 n 을 시선 쪽(가까운 쪽 부호)으로 그 값이 될 만큼만 돌리는 회전(축 = n × 시선 · 가장 작은 회전) · deg = 돌린 각.
/// 호 가운데 축으로만 돌리면 호 가운데 방향이 카메라를 향할 때(대검 1타를 동쪽으로 칠 때 |cos| 최대 0.26) 끝까지 얇았다(Play 캡처).</summary>
static Quaternion FaceTilt(Vector3 n, float minFacing, out float deg)
{
deg = 0f;
var cam = Camera.main; if (cam == null) return Quaternion.identity;
var c = cam.transform.forward; n.Normalize();
float d = Vector3.Dot(n, c);
if (Mathf.Abs(d) >= minFacing) return Quaternion.identity;
var t = d >= 0f ? c : -c; // 가까운 쪽으로
var ax = Vector3.Cross(n, t); if (ax.sqrMagnitude < 1e-8f) return Quaternion.identity; ax.Normalize();
float now = Mathf.Acos(Mathf.Clamp01(Mathf.Abs(d))) * Mathf.Rad2Deg, want = Mathf.Acos(Mathf.Clamp01(minFacing)) * Mathf.Rad2Deg;
deg = now - want;
var q = Quaternion.AngleAxis(deg, ax);
if (Mathf.Abs(Vector3.Dot(q * n, c)) + 1e-3f < minFacing) { q = Quaternion.AngleAxis(-deg, ax); deg = -deg; } // 회전 부호 확인
return q;
}
/// <summary>#932 현(chord) 축 기울임 — 면 법선 n 을 현 방향 t(= n × 이등분) 둘레로 돌려 |n·시선| 이 minFacing 에 닿는 가장 작은 각(양쪽 부호 · 1° 단위 · 최대 maxDeg) ·
/// 현 방향은 그대로라 화면에서 베는 대각선은 유지되고 초승달 가운데(이등분 쪽)만 카메라 쪽으로 펼쳐진다 · 못 닿으면 maxDeg 안에서 가장 넓게 보이는 각.</summary>
static Quaternion ChordTilt(Vector3 n, Vector3 t, float minFacing, float maxDeg, out float deg)
{
deg = 0f;
var cam = Camera.main; if (cam == null || t.sqrMagnitude < 1e-8f) return Quaternion.identity;
var c = cam.transform.forward; n.Normalize(); t.Normalize();
float best = Mathf.Abs(Vector3.Dot(n, c)); if (best >= minFacing) return Quaternion.identity;
float bestDeg = 0f;
for (float a = 1f; a <= maxDeg; a += 1f)
for (int k = 0; k < 2; k++)
{
float d = k == 0 ? a : -a, f = Mathf.Abs(Vector3.Dot(Quaternion.AngleAxis(d, t) * n, c));
if (f >= minFacing) { deg = d; return Quaternion.AngleAxis(d, t); }
if (f > best + 1e-4f) { best = f; bestDeg = d; }
}
deg = bestDeg; return Quaternion.AngleAxis(bestDeg, t);
}
static int ParseStage(string s)
{
if (string.IsNullOrEmpty(s)) return 0;
int i = s.LastIndexOf('_');
int v; return i >= 0 && int.TryParse(s.Substring(i + 1), out v) ? v : 0;
}
// ── 지면 충격파 — 콤보 단계 진입 → 칼끝 추적 → 접점 ─────────────────────
static WLGroundFxRule FindGround(int cls, int stage) { var s = S; if (s == null || s.groundFx == null) return null; for (int i = 0; i < s.groundFx.Count; i++) { var r = s.groundFx[i]; if (r != null && r.stage == stage && Contains(r.classIds, cls)) return r; } return null; }
static void OnComboStage(in ComboStageEvent e)
{
if (!Enabled || e.actor == null || !e.actor.IsMainPC()) return;
if (e.actor.Get_CurAnim() == eAnim.Skill) return; // #911 스킬이 1타 동작을 빌려 쓸 때(해머 각성 내려치기)는 기본 공격 지면 충격을 띄우지 않는다
var r = FindGround(e.actor.Get_ID(), e.stage); if (r == null || !r.tipContact) return;
var runner = e.actor.GetComponent<WLImpactRunner>(); if (runner == null) runner = e.actor.gameObject.AddComponent<WLImpactRunner>();
runner.Watch(e.actor, r, e.stage);
}
static void OnHitboxSpawned(in HitboxSpawnedEvent e)
{
var s = S; if (!Enabled || s == null || s.groundFx == null || e.shooter == null || !e.shooter.IsMainPC()) return;
if (e.shooter.Get_CurAnim() == eAnim.Skill) return; // #929 스킬이 기본 공격 상태를 빌려 쓸 때(대검 주력 = 4타 동작) 그 스킬 투사체 판정에 기본 4타 지면 충격(노란 폭발)이 같이 뜨던 것
var ct = e.shooter.GetComponent<ComboTracker>();
int stage = ct != null && ct.LastStage > 0 ? ct.LastStage : (int)e.shooter.Get_CurAnim() - (int)eAnim.Attack + 1;
int cls = e.shooter.Get_ID();
var r = FindGround(cls, stage); if (r == null) return;
var runner = e.shooter.GetComponent<WLImpactRunner>();
if (runner != null) { runner.NoteHitbox(r, stage); if (runner.FireAtHitbox(r, stage)) return; } // 접점이 먼저면 이미 떴고, 아니면 타격 시점의 무기 끝 자리에
var fwd = e.shooter.transform.forward; fwd.y = 0f; fwd.Normalize();
var pos = new Vector3(e.startPos.x, e.shooter.Get_position().y, e.startPos.z) + fwd * r.forward;
PlayGround(r, e.shooter, pos, "hitbox");
}
internal static void PlayGround(WLGroundFxRule r, Actor shooter, Vector3 pos, string how)
{
if (Play(r.prefab, pos, Quaternion.Euler(0f, shooter.transform.eulerAngles.y, 0f), r.scale * SizeFactor(shooter)) == null) return;
GroundShown++; if (how.StartsWith("contact")) GroundContacts++; else GroundFallbacks++;
LastGround = "ground " + r.prefab + " stage " + r.stage + " " + how + " @" + pos.ToString("F2"); LastLog = LastGround;
}
// ── 프리팹 프로필(스케일 1 실측) ───────────────────────────────────────
static readonly Dictionary<string, WLFxProfile> s_profiles = new Dictionary<string, WLFxProfile>();
static readonly Dictionary<string, int> s_profileTries = new Dictionary<string, int>();
static ParticleSystem.Particle[] s_buf;
/// <summary>프리팹 프로필 — 첫 호출 때 보이지 않는 곳(y −1000)에 세워 Simulate 로 잰다. emitter 가 있으면 그 방출기의 궤도를, 없으면 가장 뚜렷한 궤도를 쓴다.</summary>
public static WLFxProfile Profile(string prefab, string emitter)
{
var s = S; if (s == null || !s.profileWarmup || string.IsNullOrEmpty(prefab)) return null;
string key = prefab + "|" + (emitter ?? "");
WLFxProfile prof;
if (s_profiles.TryGetValue(key, out prof)) return prof;
prof = Measure(prefab, emitter, s.profileSeconds);
// 궤적용(emitter 지정)인데 궤도를 못 잡았으면 3회까지 다음 호출에서 다시 잰다(2026-10-12 첫 측정 1회 실패 실측 · 원인 미확인)
int tries; s_profileTries.TryGetValue(key, out tries); s_profileTries[key] = tries + 1;
if (prof == null || prof.sweepValid || string.IsNullOrEmpty(emitter) || tries >= 2) s_profiles[key] = prof;
return prof;
}
struct Orbit { public ParticleSystem ps; public List<Vector3> pts; public Vector3 normal, start, center; public float radius, consistency; public bool valid; public bool byRotation; public Vector3 rotAxis; public float rotDeg; public float sizeMed, distP90; public int count; public string why; }
/// <summary>프로필 실측 — 각 파티클 시스템을 Simulate 로 돌리며 (randomSeed 로 식별한) 새 파티클의 방출점·크기·거리를 모은다.
/// 치수 = 2 × (|위치| + 크기/2 의 75 백분위) · 중심 오프셋 = 크기 가중 위치 평균(첫 0.5 s) · 궤도 = 연속 방출점(없으면 메시 파티클의 회전 축).</summary>
static WLFxProfile Measure(string prefab, string emitter, float seconds)
{
GameObject p; if (!s_prefabs.TryGetValue(prefab, out p) || p == null) { p = Resources.Load<GameObject>(ResourcesDir + prefab); s_prefabs[prefab] = p; }
if (p == null) return null;
var prof = new WLFxProfile { prefab = prefab };
var origin = new Vector3(0f, -1000f, 0f);
var inst = Object.Instantiate(p, origin, Quaternion.identity);
inst.name = prefab + "_profile";
try
{
inst.transform.localScale = Vector3.one;
inst.SetActive(true);
var systems = inst.GetComponentsInChildren<ParticleSystem>(true);
var orbits = new List<Orbit>(systems.Length);
const float dt = 1f / 60f; int steps = Mathf.Clamp(Mathf.CeilToInt(seconds / dt), 10, 400);
if (s_buf == null || s_buf.Length < 4096) s_buf = new ParticleSystem.Particle[4096];
var seen = new HashSet<uint>();
var reach = new List<float>(4096); // |위치| + 크기/2 (전 시스템 · 시간 균등)
Vector3 wsum = Vector3.zero; float wtot = 0f; // 첫 0.5 s 크기 가중 위치 합
Bounds union = new Bounds(origin, Vector3.zero); bool any = false;
for (int i = 0; i < systems.Length; i++)
{
var ps = systems[i]; var psr = ps.GetComponent<ParticleSystemRenderer>();
var main = ps.main; bool worldSpace = main.simulationSpace == ParticleSystemSimulationSpace.World;
bool mesh = psr != null && psr.renderMode == ParticleSystemRenderMode.Mesh;
var ob = new Orbit { ps = ps, pts = new List<Vector3>(steps) };
var sizes = new List<float>(256); var dists = new List<float>(256);
// 메시 파티클 — 메시 에셋 정점(피벗 기준 평면·질량 방향·반경) + 파티클 회전값(시간 변화 부호)으로 축·머리·반경을 잰다.
// (BakeMesh 는 2026-10-12 OOM 크래시 — 쓰지 않는다. 회전 부호는 Transform 과 같은 좌표계로 가정 · JSON sweepFlip 으로 뒤집을 수 있다)
int sampleK = Mathf.RoundToInt(0.15f / dt);
uint trackSeed = 0; bool tracking = false; Vector3 prevRot = Vector3.zero, rotSum = Vector3.zero, rotAt = Vector3.zero, posAt = Vector3.zero; int rotN = 0; float sizeAt = 1f; bool sampled = false;
seen.Clear();
ps.Simulate(0f, false, true, false);
for (int k = 0; k < steps; k++)
{
ps.Simulate(dt, false, false, false);
int n = ps.GetParticles(s_buf);
Vector3 young = Vector3.zero; int yc = 0;
for (int j = 0; j < n; j++)
{
var pt = s_buf[j];
Vector3 w = (worldSpace ? pt.position : ps.transform.TransformPoint(pt.position)) - origin;
float sz = pt.GetCurrentSize(ps);
if (seen.Add(pt.randomSeed)) { young += w; yc++; }
if ((k & 3) == 0) { sizes.Add(sz); float d = w.magnitude; dists.Add(d); reach.Add(d + sz * 0.5f); }
if (k * dt <= 0.5f) { wsum += w * sz; wtot += sz; }
var bb = new Bounds(w, new Vector3(sz, sz, sz)); if (!any) { union = bb; any = true; } else union.Encapsulate(bb);
if (mesh)
{
if (!tracking) { tracking = true; trackSeed = pt.randomSeed; prevRot = pt.rotation3D; }
else if (pt.randomSeed == trackSeed) { var dr = pt.rotation3D - prevRot; prevRot = pt.rotation3D; if (dr.sqrMagnitude < 90f * 90f) { rotSum += dr; rotN++; } }
if (!sampled && k >= sampleK && pt.randomSeed == trackSeed) { sampled = true; rotAt = pt.rotation3D; posAt = w; sizeAt = sz; }
}
}
if (yc > 0) ob.pts.Add(young / yc);
}
ob.count = seen.Count;
if (sizes.Count > 0) { sizes.Sort(); dists.Sort(); ob.sizeMed = sizes[sizes.Count / 2]; ob.distP90 = dists[Mathf.Min(dists.Count - 1, (int)(dists.Count * 0.9f))]; }
AnalyzeOrbit(ref ob);
if (mesh) ob.why = "mesh=" + (psr.mesh != null ? psr.mesh.name : "null") + " sampled=" + sampled + " rotN=" + rotN + " spin=" + rotSum.ToString("F0");
if (!ob.valid && mesh && sampled && psr.mesh != null)
{
MeshShape ms = MeshShapeOf(psr.mesh);
ob.why += " shape=" + (ms.valid ? "ok v=" + ms.verts : "bad " + ms.err);
if (ms.valid)
{
// 파티클 프레임(프리팹 로컬) = 시스템 회전 × 파티클 회전(Euler · Transform 과 같은 부호 가정)
Quaternion R = ps.transform.rotation * Quaternion.Euler(rotAt);
Vector3 nM = R * ms.normal; // 메시 평면 법선(부호 임의)
Vector3 head = R * ms.massDir; // 질량 방향(초승달 이등분 · 머리 쪽)
// 회전 방향 → 축 부호: 시간 변화가 가장 큰 Euler 성분의 부호(Transform 규약: +θ about +axis = cross 정의의 +axis)
float sign = 0f;
if (rotN >= 3 && rotSum.magnitude > 3f)
{
int ax = Mathf.Abs(rotSum.x) >= Mathf.Abs(rotSum.y) && Mathf.Abs(rotSum.x) >= Mathf.Abs(rotSum.z) ? 0 : Mathf.Abs(rotSum.y) >= Mathf.Abs(rotSum.z) ? 1 : 2;
Vector3 localAx = ax == 0 ? Vector3.right : ax == 1 ? Vector3.up : Vector3.forward;
Vector3 spin = (ps.transform.rotation * localAx) * Mathf.Sign(rotSum[ax]);
sign = Vector3.Dot(spin, nM) >= 0f ? 1f : -1f; // 메시 법선을 회전 축 쪽으로 맞춘다
ob.rotDeg = rotSum[ax];
}
if (sign == 0f) sign = 1f;
ob.byRotation = true; ob.normal = ob.rotAxis = nM * sign; ob.center = posAt;
float lossy = ps.transform.lossyScale.x;
ob.radius = ms.radius * sizeAt * (lossy > 1e-3f ? lossy : 1f);
head -= ob.normal * Vector3.Dot(head, ob.normal);
if (head.sqrMagnitude < 1e-6f) { head = Vector3.Cross(ob.normal, Vector3.up); if (head.sqrMagnitude < 1e-6f) head = Vector3.Cross(ob.normal, Vector3.right); }
ob.start = head.normalized; ob.consistency = rotN >= 3 ? 0.8f : 0.4f; ob.valid = true;
}
}
orbits.Add(ob);
}
if (reach.Count > 0) { reach.Sort(); prof.extent = 2f * reach[Mathf.Min(reach.Count - 1, (int)(reach.Count * 0.75f))]; }
if (wtot > 1e-6f) prof.centerOffset = wsum / wtot;
if (any) { union.center -= origin; prof.bounds = union; }
// 궤도 선택 — 이름 지정 > 방출점 궤도(반경 × 표본 × 일관성) > 회전 축
int best = -1; float bestScore = 0f;
var sb = new System.Text.StringBuilder();
sb.Append(prefab).Append(" extent(p75 지름)=").Append(prof.extent.ToString("F2")).Append(" centerOffset=").Append(prof.centerOffset.ToString("F2")).Append(" aabb=").Append(prof.bounds.size.ToString("F1"));
for (int i = 0; i < orbits.Count; i++)
{
var ob = orbits[i];
sb.Append("\n ").Append(ob.ps.name).Append(" n=").Append(ob.count).Append(" sizeMed=").Append(ob.sizeMed.ToString("F2")).Append(" distP90=").Append(ob.distP90.ToString("F2")).Append(" pts=").Append(ob.pts.Count).Append(string.IsNullOrEmpty(ob.why) ? "" : " [" + ob.why + "]")
.Append(ob.valid ? (ob.byRotation ? " MESH axis=" + ob.rotAxis.ToString("F2") + " spinDeg=" + ob.rotDeg.ToString("F0") + " r=" + ob.radius.ToString("F2") + " head=" + ob.start.ToString("F2") + " c=" + ob.center.ToString("F2") : " ORBIT r=" + ob.radius.ToString("F2") + " cons=" + ob.consistency.ToString("F2") + " n=" + ob.normal.ToString("F2") + " s=" + ob.start.ToString("F2") + " c=" + ob.center.ToString("F2")) : "");
if (!ob.valid) continue;
bool named = !string.IsNullOrEmpty(emitter) && ob.ps.name == emitter;
float score = (named ? 1000f : 1f) * (ob.byRotation ? 0.01f * Mathf.Abs(ob.rotDeg) * ob.radius * ob.consistency : ob.radius * ob.pts.Count * ob.consistency);
if (named) score = Mathf.Max(score, 1e6f); // #925 이름 지정 방출기는 회전이 없어도(대검 잉크 베기 = 돌지 않는 초승달 메시 · UV 로 쓸고 지나감) 그대로 쓴다
if (score > bestScore) { bestScore = score; best = i; }
}
if (best >= 0)
{
var ob = orbits[best];
prof.sweepValid = true; prof.sweepEmitter = ob.ps.name; prof.sweepNormal = ob.normal; prof.sweepStart = ob.start; prof.sweepCenter = ob.center; prof.sweepRadius = ob.radius; prof.sweepConsistency = ob.consistency; prof.sweepSamples = ob.pts.Count;
sb.Append("\n → sweep ").Append(ob.ps.name).Append(ob.byRotation ? " (메시 형상+회전)" : " (방출 궤도)");
}
prof.log = sb.ToString();
}
catch (System.Exception ex) { prof.log = "profile error " + ex.Message + "\n" + ex.StackTrace; }
finally { Object.Destroy(inst); }
return prof;
}
/// <summary>메시 에셋 형상(메시 로컬) — bounds 만 쓴다(정점은 비-읽기 메시가 한 번 렌더된 뒤 0개를 돌려줘 2026-10-12 첫 측정이 상황 따라 실패했다).
/// 평면 법선 = 가장 얇은 축 · 질량(머리) 방향 = bounds 중심의 평면 내 성분 · 반경 = 평면 내 반폭 최대 + 중심 치우침.</summary>
struct MeshShape { public bool valid; public Vector3 normal, massDir; public float radius; public int verts; public string err; }
static readonly Dictionary<Mesh, MeshShape> s_meshShapes = new Dictionary<Mesh, MeshShape>();
static MeshShape MeshShapeOf(Mesh m)
{
MeshShape ms;
if (s_meshShapes.TryGetValue(m, out ms)) return ms;
ms = new MeshShape();
try
{
var b = m.bounds; var ext = b.size;
int thin = ext.x <= ext.y && ext.x <= ext.z ? 0 : ext.y <= ext.z ? 1 : 2;
ms.normal = thin == 0 ? Vector3.right : thin == 1 ? Vector3.up : Vector3.forward;
Vector3 c = b.center; Vector3 cp = c - ms.normal * Vector3.Dot(c, ms.normal);
float half = 0f; for (int i = 0; i < 3; i++) if (i != thin) half = Mathf.Max(half, ext[i] * 0.5f);
ms.radius = half + cp.magnitude;
ms.massDir = cp;
ms.verts = m.vertexCount; ms.valid = ms.radius > 1e-4f;
if (!ms.valid) ms.err = "bounds r=0";
}
catch (System.Exception ex) { ms.valid = false; ms.err = ex.Message; }
s_meshShapes[m] = ms;
return ms;
}
/// <summary>연속 방출점의 궤도 — 중심 기준 반경 벡터의 연속 cross 합 = 회전 축(같은 정의를 목표 궤적에도 쓴다) · 일관성 = |합| / 절대값 합.</summary>
static void AnalyzeOrbit(ref Orbit ob)
{
var pts = ob.pts; int n = pts.Count; ob.valid = false;
if (n < 6) return;
Vector3 c = Vector3.zero; for (int i = 0; i < n; i++) c += pts[i]; c /= n;
Vector3 sum = Vector3.zero; float abs = 0f; float rad = 0f; int cnt = 0;
for (int i = 0; i + 1 < n; i++)
{
Vector3 a = pts[i] - c, b = pts[i + 1] - c;
if (a.sqrMagnitude < 1e-6f || b.sqrMagnitude < 1e-6f) continue;
var cr = Vector3.Cross(a, b); sum += cr; abs += cr.magnitude; rad += a.magnitude; cnt++;
}
if (cnt < 5 || abs < 1e-6f) return;
float cons = sum.magnitude / abs; rad /= cnt;
if (cons < 0.6f || rad < 0.02f) return;
ob.center = c; ob.normal = sum.normalized; ob.radius = rad; ob.consistency = cons;
Vector3 s0 = pts[0] - c; s0 -= ob.normal * Vector3.Dot(s0, ob.normal);
ob.start = s0.sqrMagnitude > 1e-6f ? s0.normalized : Vector3.Cross(ob.normal, Vector3.up).normalized;
ob.valid = true;
}
// ── 위치 기반 1회 이펙트 풀 ──────────────────────────────────────────
static readonly Dictionary<string, GameObject> s_prefabs = new Dictionary<string, GameObject>();
static readonly Dictionary<string, List<GameObject>> s_pool = new Dictionary<string, List<GameObject>>();
static Transform s_root;
public static GameObject Play(string prefab, Vector3 pos, Quaternion rot, float scale)
{
if (string.IsNullOrEmpty(prefab)) return null;
GameObject p;
if (!s_prefabs.TryGetValue(prefab, out p) || p == null) { p = Resources.Load<GameObject>(ResourcesDir + prefab); s_prefabs[prefab] = p; }
if (p == null) { Misses++; LastLog = "missing prefab " + ResourcesDir + prefab; return null; }
List<GameObject> list; GameObject go = null;
if (s_pool.TryGetValue(prefab, out list)) for (int i = 0; i < list.Count; i++) if (list[i] != null && !list[i].activeSelf) { go = list[i]; break; }
if (go == null)
{
if (s_root == null) { var r = new GameObject("WL_ImpactFx"); Object.DontDestroyOnLoad(r); s_root = r.transform; }
go = Object.Instantiate(p, s_root); go.name = prefab; go.SetActive(false);
var one = go.GetComponent<WLImpactOneShot>(); if (one == null) one = go.AddComponent<WLImpactOneShot>(); one.home = s_root;
if (list == null) { list = new List<GameObject>(4); s_pool[prefab] = list; }
list.Add(go);
}
go.transform.SetPositionAndRotation(pos, rot);
go.transform.localScale = Vector3.one * Mathf.Max(0.01f, scale);
go.SetActive(true);
return go;
}
public static void ClearPool()
{
foreach (var kv in s_pool) foreach (var g in kv.Value) if (g != null) Object.Destroy(g);
s_pool.Clear(); s_prefabs.Clear(); s_body.Clear(); s_profiles.Clear(); s_skillHitAt.Clear();
s_swingAt.Clear(); // #958 도메인 리로드 꺼짐 — 지난 Play 의 시각(Time.time)이 남으면 새 Play(시각 0부터)의 같은 PC·단계 궤적 이펙트가 「throttled」로 막혀 비수 3타 이펙트·몸 회전이 안 나왔다(실측: 4타 콤보 6 s · swing +0 · spins +0)
}
}
/// <summary>#955 PD 비수 4타 「잔상을 남기며 0.5 m 쯤 순간이동하듯 나타나 베기 · 적을 뚫고 가도 카메라를 돌리지 말고 살짝 줌아웃했다 복귀」 · 3타 「한바퀴 회전」 —
/// 띄우는 순간: 정면 blinkDistance 로 워프(NavMesh 위) + 길에 잔상(WLGhostStrike 살아 있는 자세) · camHoldSeconds 동안 카메라 회전·PC 기준 오프셋 고정 + sin 곡선 줌아웃 · spinDegrees 를 spinSeconds 에 걸쳐 회전(이펙트는 PC 에 붙어 함께 돈다).</summary>
public sealed class WLSwingExtras : MonoBehaviour
{
PCActor _pc; float _spinLeft, _spinRate, _spinAcc; bool _spinning; Transform _spinBone; GameObject _fx; Vector3 _fxPos; Quaternion _fxRot;
int _axis; float _yaw0, _blendDur, _blendT; bool _blending; // #958 yaw 덮어쓰기 — 시작 때 엉덩이 수평 축 · 그때 yaw · 끝난 뒤 애니메이터 자세로 섞는 시간
public static int Blinks, Spins, CamHolds; public static string LastLog = "", SpinLog = "";
public static void Begin(PCActor pc, WLSwingFxRule r, GameObject fx)
{
if (pc == null) return;
var ex = pc.GetComponent<WLSwingExtras>(); if (ex == null) ex = pc.gameObject.AddComponent<WLSwingExtras>();
ex._pc = pc;
if (r.blinkDistance > 0f)
{
var fwd = pc.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize();
Vector3 from = pc.Get_position(), to = from + fwd * r.blinkDistance;
UnityEngine.AI.NavMeshHit hit; if (UnityEngine.AI.NavMesh.SamplePosition(to, out hit, 1.0f, UnityEngine.AI.NavMesh.AllAreas)) to = hit.position;
int ghosts = r.blinkGhosts > 0 ? WL.Combat.Weapon.WLGhostStrike.BlinkGhosts(pc, from, to, r.blinkGhosts) : 0;
try { pc.Set_Warp(to); } catch (System.Exception) { pc.transform.position = to; }
Blinks++; LastLog = "blink " + Vector3.Distance(from, to).ToString("F2") + " m · 잔상 " + ghosts;
if (r.blinkTrailSeconds > 0f) // #964 순간이동 뒤에도 PC 가 움직이는 동안 지나온 자리에 잔상을 이어 남긴다
{
ex._trailUntil = Time.time + r.blinkTrailSeconds; ex._trailInterval = Mathf.Max(0.01f, r.blinkTrailInterval); ex._trailStep = Mathf.Max(0f, r.blinkTrailMinStep);
ex._trailNext = Time.time + ex._trailInterval; ex._trailLast = to;
}
}
if (r.camHoldSeconds > 0f) { WLCamHold.Begin(pc, r.camHoldSeconds, r.camZoomOut); CamHolds++; }
if (r.spinDegrees != 0f)
{
// #956 PD 「3타 때 몸을 회전 1회하며 공격 · 이펙트도 회전 모션에 맞게」 — m_animation.transform 은 PC 루트 자체(Animator 가 루트 LH_M05 · Play 실측)라
// 「모델 자식 회전」(#955 2차)도 루트 회전이었고 원본 LookAtTarget 이 매 프레임 되돌려 클립당 61° 만 남았다 → 엉덩이 뼈(Humanoid Hips = Root_M)를 루트 축으로
// 누적 각만큼 LateUpdate(애니메이션 평가 뒤)에서 돌린다 — 애니메이터가 뼈를 매 프레임 다시 쓰므로 델타가 아니라 누적 각 · 루트·판정 방향·카메라는 그대로(보이는 몸만) · PC 에 붙은 이펙트는 같은 각으로
ex._spinBone = SpinBone(pc); ex._spinLeft = r.spinDegrees; ex._spinAcc = 0f; ex._spinRate = r.spinDegrees / Mathf.Max(0.05f, r.spinSeconds); ex._spinning = ex._spinBone != null;
// #958 프레임 실측(958_probe SpinTrace): 팔라딘 Around 클립 자체가 엉덩이를 시계 방향 약 300°/0.18 s 돌려 코드 회전(반시계)과 상쇄됐다 → 더하지 않고 「시작 yaw + 누적 각」으로 덮어쓴다(클립의 yaw 변화 제거)
ex._blending = false; ex._blendDur = Mathf.Max(0f, r.spinBlendOut);
if (ex._spinBone != null) { ex._axis = HorizAxis(ex._spinBone); ex._yaw0 = Yaw(ex._spinBone, pc.transform, ex._axis); }
ex._fx = r.spinFx && fx != null && fx.transform.parent == pc.transform ? fx : null;
if (ex._fx != null) { ex._fxPos = fx.transform.localPosition; ex._fxRot = fx.transform.localRotation; }
Spins++; SpinLog = (ex._spinning ? "spin " + r.spinDegrees + "°/" + r.spinSeconds + " s · 뼈 " + ex._spinBone.name : "spin 뼈 없음") + (ex._fx != null ? " · fx 함께" : " · fx 없음");
}
}
/// <summary>#956 돌릴 뼈 — Humanoid Hips(이 PC 는 루트 직계 Root_M) · 없으면 루트 직계 중 이름이 Root 로 시작하는 것.</summary>
static Transform SpinBone(PCActor pc)
{
var an = pc.m_animation; Transform b = an != null && an.isHuman ? an.GetBoneTransform(HumanBodyBones.Hips) : null;
if (b == null) foreach (Transform c in pc.transform) if (c.name.StartsWith("Root")) { b = c; break; }
return b;
}
/// <summary>#958 엉덩이 뼈 축 중 가장 수평인 것(0 right · 1 up · 2 forward) — 이 PC 의 Root_M 은 축이 기울어 오일러 y 로는 위 축 회전을 못 잰다(실측).</summary>
static int HorizAxis(Transform b) { Vector3[] ax = { b.right, b.up, b.forward }; int best = 0; float bd = 2f; for (int i = 0; i < 3; i++) { float d = Mathf.Abs(Vector3.Dot(ax[i], Vector3.up)); if (d < bd) { bd = d; best = i; } } return best; }
/// <summary>#958 그 축의 수평 투영이 루트 정면에서 위 축 둘레로 몇 도(시계 +).</summary>
static float Yaw(Transform b, Transform root, int axis)
{
Vector3 a = axis == 0 ? b.right : axis == 1 ? b.up : b.forward; a = Vector3.ProjectOnPlane(a, root.up); var f = Vector3.ProjectOnPlane(root.forward, root.up);
return a.sqrMagnitude > 1e-6f && f.sqrMagnitude > 1e-6f ? Vector3.SignedAngle(f, a, root.up) : 0f;
}
public static float LastSpinErr; // 검증용 — 마지막 프레임의 (목표 yaw − 덮은 뒤 yaw)
float _trailUntil, _trailNext, _trailInterval, _trailStep; Vector3 _trailLast; // #964 4타 따라오는 잔상
public static int TrailGhosts;
void TickTrail()
{
if (_trailUntil <= 0f || _pc == null) return;
if (Time.time > _trailUntil) { _trailUntil = 0f; return; }
if (Time.time < _trailNext) return;
_trailNext = Time.time + _trailInterval;
Vector3 p = _pc.Get_position(); float step = Mathf.Max(0.01f, _trailStep), d = Vector3.Distance(p, _trailLast);
if (d < step) return;
// 4타 돌진(공격 전진 표 · 약 1.1 m 를 0.07 s · Play 실측)은 한 프레임 0.3 m 넘게 가므로 지나온 길에 step 간격으로 고르게(지금 자세 · 기본 잔상 색 = 순간이동 잔상과 같음)
TrailGhosts += WL.Combat.Weapon.WLGhostStrike.BlinkGhosts(_pc, _trailLast, p, Mathf.Clamp(Mathf.FloorToInt(d / step), 1, 6));
_trailLast = p;
}
void LateUpdate()
{
TickTrail();
if ((!_spinning && !_blending) || _pc == null) return;
if (_spinBone == null) { _spinning = _blending = false; _fx = null; return; }
var root = _pc.transform; float w = 1f;
if (_spinning)
{
float step = _spinRate * Time.deltaTime; if (Mathf.Abs(step) > Mathf.Abs(_spinLeft)) step = _spinLeft;
_spinLeft -= step; _spinAcc += step;
if (Mathf.Approximately(_spinLeft, 0f)) { _spinning = false; _blending = _blendDur > 0f; _blendT = 0f; if (_fx != null) { _fx.transform.localRotation = _fxRot; _fx.transform.localPosition = _fxPos; _fx = null; } }
}
else { _blendT += Time.deltaTime; w = 1f - Mathf.Clamp01(_blendT / Mathf.Max(0.01f, _blendDur)); if (w <= 0f) { _blending = false; return; } }
// 목표 yaw = 시작 yaw + 누적 각(끝 = 한 바퀴 = 시작 yaw) · 애니메이터가 이번 프레임에 쓴 자세의 yaw 와의 차이만큼 루트 발 위치 · 위 축으로 몸 전체(뼈 아래 양손 무기 포함)를 돌린다
float cur = Yaw(_spinBone, root, _axis), d = Mathf.DeltaAngle(cur, _yaw0 + _spinAcc) * w;
if (Mathf.Abs(d) > 0.01f) _spinBone.RotateAround(root.position, root.up, d);
LastSpinErr = Mathf.DeltaAngle(Yaw(_spinBone, root, _axis), _yaw0 + _spinAcc);
if (_fx != null && _spinning) { if (!_fx.activeInHierarchy || _fx.transform.parent != root) _fx = null; else { var q = Quaternion.Euler(0f, _spinAcc, 0f); _fx.transform.localRotation = q * _fxRot; _fx.transform.localPosition = q * _fxPos; } }
}
}
/// <summary>#955 카메라 고정 + 줌아웃 펄스 — RealCamera 의 LateUpdate 뒤(실행 순서 1000)에 회전은 고정값 · 위치 = PC + 고정 오프셋 − 앞 × 줌아웃(sin 0→1→0). 끝나면 RealCamera 가 보간으로 복귀.</summary>
[DefaultExecutionOrder(1000)]
public sealed class WLCamHold : MonoBehaviour
{
PCActor _pc; Quaternion _rot; Vector3 _offset; float _t0, _seconds, _zoom; bool _on;
public static string LastLog = "";
public static void Begin(PCActor pc, float seconds, float zoomOut)
{
var cam = Camera.main; if (cam == null || pc == null) return;
var h = cam.GetComponent<WLCamHold>(); if (h == null) h = cam.gameObject.AddComponent<WLCamHold>();
h._pc = pc; h._rot = cam.transform.rotation; h._offset = cam.transform.position - pc.Get_position(); h._t0 = Time.time; h._seconds = seconds; h._zoom = zoomOut; h._on = true;
LastLog = "hold " + seconds + " s · zoom " + zoomOut + " · euler " + h._rot.eulerAngles.ToString("F0");
}
void LateUpdate()
{
if (!_on) return;
float t = Time.time - _t0; if (t >= _seconds || _pc == null) { _on = false; return; }
float k = Mathf.Sin(Mathf.Clamp01(t / _seconds) * Mathf.PI);
transform.rotation = _rot;
transform.position = _pc.Get_position() + _offset - _rot * Vector3.forward * (_zoom * k);
}
}
/// <summary>#941 궤적 교체 이펙트를 매 프레임 무기 소켓 방향(손잡이 → 무기 끝 · 수평)에 맞춘다 — 창 2타 기계총 가림처럼 PC 정면이 아니라 무기가 향한 쪽으로 쏘게. 자리 = 손잡이 + 방향 × 길이 × alignFraction + 위 yOffset − R × massLocal × 배율.</summary>
public sealed class WLWeaponFollowFx : MonoBehaviour
{
Transform _socket; Vector3 _tipLocal, _buttLocal, _mass, _dir; Quaternion _base; float _scale, _frac, _yOff, _fwd, _side, _until; PCActor _pc; bool _armed, _fixed, _lock, _axis, _pitch;
public static int Frames, Locked; public static string LastLog = "";
public bool Arm(PCActor pc, WLSwingFxRule r, float scale)
{
_pc = pc; _scale = scale; _frac = r.alignFraction; _yOff = r.yOffset; _fwd = r.alignForward; _side = r.alignSide; _fixed = r.fixedDir; _lock = r.lockFacing; _axis = r.alignAxis; _pitch = r.alignPitch;
_until = Time.time + (r.life > 0f ? r.life : 1f);
_base = r.euler != null && r.euler.Length >= 3 ? Quaternion.Euler(r.euler[0], r.euler[1], r.euler[2]) : Quaternion.identity;
_mass = r.massLocal != null && r.massLocal.Length >= 3 ? new Vector3(r.massLocal[0], r.massLocal[1], r.massLocal[2]) : Vector3.zero;
if (_fixed)
{
_dir = pc.transform.forward; _dir.y = 0f; if (_dir.sqrMagnitude < 1e-4f) _dir = Vector3.forward; _dir.Normalize(); // #943-⑤ 띄우는 순간의 표적 방향으로 고정
_armed = true; ApplyFixed(); return true;
}
_armed = _axis ? WLImpactRunner.SocketAxis(pc, out _socket, out _buttLocal, out _tipLocal) : WLImpactRunner.SocketTip(pc, out _socket, out _tipLocal);
if (_armed) Apply();
return _armed;
}
public void Disarm() { _armed = false; }
void OnDisable() { _armed = false; }
void LateUpdate() { if (!_armed) return; if (_fixed) ApplyFixed(); else Apply(); }
/// <summary>#943-⑤ 고정 방향 — 이펙트는 PC 위치만 따라가고 방향은 띄운 순간 그대로 · 공격 상태인 동안 PC 회전도 그 방향으로 잠근다(원본 LookAtTarget · 애니메이션 회전이 바꿔 놓은 것을 LateUpdate 에서 되돌림).</summary>
void ApplyFixed()
{
if (_pc == null) { _armed = false; return; }
var tf = _pc.transform;
if (_lock && Time.time < _until && _pc.Get_CurAnim() == eAnim.Attack) { tf.rotation = Quaternion.LookRotation(_dir, Vector3.up); Locked++; }
var rot = Quaternion.LookRotation(_dir, Vector3.up) * _base;
Vector3 right = Vector3.Cross(Vector3.up, _dir);
Vector3 pos = _pc.Get_Center_position() + _dir * _fwd + right * _side + Vector3.up * _yOff - rot * (_mass * _scale);
transform.SetPositionAndRotation(pos, rot); Frames++;
LastLog = "fixed dir=" + _dir.ToString("F2") + " pos=" + pos.ToString("F2") + " locked=" + Locked;
}
void Apply()
{
if (_socket == null || _pc == null) { _armed = false; return; }
Vector3 hilt = _socket.position, tip = _socket.TransformPoint(_tipLocal);
if (_axis) // #964 손(소켓)을 자루 중심선에 내린 점 → 창끝
{
Vector3 butt = _socket.TransformPoint(_buttLocal), ax = tip - butt;
if (ax.sqrMagnitude > 1e-6f) hilt = butt + Vector3.Project(hilt - butt, ax);
}
Vector3 d = tip - hilt; float len = d.magnitude; if (!_pitch) d.y = 0f;
if (d.sqrMagnitude < 1e-4f) d = _pc.transform.forward; d.Normalize();
var rot = Quaternion.LookRotation(d, Mathf.Abs(d.y) > 0.99f ? _pc.transform.forward : Vector3.up) * _base;
Vector3 pos = hilt + d * (len * _frac) + Vector3.up * _yOff - rot * (_mass * _scale);
transform.SetPositionAndRotation(pos, rot); Frames++;
LastLog = "align hilt=" + hilt.ToString("F2") + " tip=" + tip.ToString("F2") + " dir=" + d.ToString("F2") + " pos=" + pos.ToString("F2");
}
}
/// <summary>PC 에 붙는 러너 — 콤보 단계 진입 뒤 칼끝(WeaponTrailDriver) 높이를 추적해 지면 접점에서 충격파를 띄운다.</summary>
public sealed class WLImpactRunner : MonoBehaviour
{
sealed class Entry { public WLGroundFxRule rule; public int stage; public PCActor pc; public WeaponTrailDriver drv; public int slot; public Transform socket; public Vector3 tipLocal; public float startAt, prevY, maxY, minY = 999f, hitboxAt = -1f; public Vector3 minPos; public bool done, wasDescending; public string why = ""; public bool HasTip { get { return slot >= 0 || socket != null; } }
public Vector3 Tip() { if (slot >= 0 && drv != null) { var t = drv.GetTipWorld(slot); if (t != Vector3.zero) return t; } return socket != null ? socket.TransformPoint(tipLocal) : Vector3.zero; } }
readonly List<Entry> _list = new List<Entry>(2);
/// <summary>무기 소켓(PCActor.tfs_weapon)의 메시 bounds 모서리 중 소켓 원점에서 가장 먼 점 = 무기 끝(해머 머리·칼끝). WeaponTrailDriver 슬롯이 없을 때(해머는 궤적을 가로채 슬롯이 안 생긴다) 쓴다.</summary>
internal static bool SocketTip(PCActor pc, out Transform socket, out Vector3 tipLocal) // #911 해머 각성 모으기 이펙트도 쓴다
{
socket = null; tipLocal = Vector3.zero; float best = 0f;
var sockets = pc != null ? pc.tfs_weapon : null; if (sockets == null) return false;
for (int i = 0; i < sockets.Length; i++)
{
Vector3 tl; float d = SocketTipOf(sockets[i], out tl);
if (d > best) { best = d; socket = sockets[i]; tipLocal = tl; }
}
return socket != null && best > 0.05f;
}
/// <summary>#964 소켓 무기의 메시 긴 축(소켓 로컬) — 켜진 렌더러 중 bounds 가장 긴 변이 가장 긴 것의 그 변 양 끝 면 중심 · 끝(tip) = 소켓 원점에서 먼 쪽 · 자루 끝(butt) = 가까운 쪽(창 찌르기 이펙트 방향 = 자루 중심선).</summary>
internal static bool SocketAxis(PCActor pc, out Transform socket, out Vector3 buttLocal, out Vector3 tipLocal)
{
socket = null; buttLocal = tipLocal = Vector3.zero; float best = 0f;
var sockets = pc != null ? pc.tfs_weapon : null; if (sockets == null) return false;
for (int i = 0; i < sockets.Length; i++)
{
var s = sockets[i]; if (s == null || !s.gameObject.activeInHierarchy) continue;
foreach (var r in s.GetComponentsInChildren<Renderer>(false))
{
if (r == null || !r.enabled) continue;
Bounds lb; var mf = r.GetComponent<MeshFilter>(); var smr = r as SkinnedMeshRenderer;
if (mf != null && mf.sharedMesh != null) lb = mf.sharedMesh.bounds; else if (smr != null) lb = smr.localBounds; else continue;
Vector3 e = lb.extents, ax = e.x >= e.y && e.x >= e.z ? new Vector3(e.x, 0f, 0f) : e.y >= e.z ? new Vector3(0f, e.y, 0f) : new Vector3(0f, 0f, e.z);
Vector3 a = s.InverseTransformPoint(r.transform.TransformPoint(lb.center - ax)), b = s.InverseTransformPoint(r.transform.TransformPoint(lb.center + ax));
float len = (b - a).magnitude; if (len <= best) continue;
best = len; socket = s;
if (a.magnitude > b.magnitude) { tipLocal = a; buttLocal = b; } else { tipLocal = b; buttLocal = a; }
}
}
return socket != null && best > 0.05f;
}
/// <summary>#956 소켓 하나의 무기 끝(소켓 로컬) = 켜진 렌더러 bounds 모서리 중 소켓 원점에서 가장 먼 점 · 반환 = 그 거리(0 = 무기 없음). 비수처럼 양손 소켓을 따로 쓸 때(WLShadowSlash 난사 사본).</summary>
internal static float SocketTipOf(Transform s, out Vector3 tipLocal)
{
tipLocal = Vector3.zero; float best = 0f;
if (s == null || !s.gameObject.activeInHierarchy) return 0f;
foreach (var r in s.GetComponentsInChildren<Renderer>(false))
{
if (r == null || !r.enabled) continue;
Bounds lb; Transform rt = r.transform;
var mf = r.GetComponent<MeshFilter>(); var smr = r as SkinnedMeshRenderer;
if (mf != null && mf.sharedMesh != null) lb = mf.sharedMesh.bounds; else if (smr != null) lb = smr.localBounds; else continue;
Vector3 far = Vector3.zero; float farD = -1f;
for (int c = 0; c < 8; c++)
{
var corner = lb.center + new Vector3((c & 1) == 0 ? -lb.extents.x : lb.extents.x, (c & 2) == 0 ? -lb.extents.y : lb.extents.y, (c & 4) == 0 ? -lb.extents.z : lb.extents.z);
var local = s.InverseTransformPoint(rt.TransformPoint(corner)); float d = local.magnitude;
if (d > farD) { farD = d; far = local; }
}
if (farD > best) { best = farD; tipLocal = far; }
}
return best;
}
public void Watch(PCActor pc, WLGroundFxRule rule, int stage)
{
for (int i = _list.Count - 1; i >= 0; i--) if (_list[i].rule == rule) _list.RemoveAt(i); // 같은 규칙 재진입 = 새로
var drv = pc.GetComponent<WeaponTrailDriver>(); int slot = -1; float best = 0f;
if (drv != null) for (int i = 0; i < drv.ActiveSlotCount; i++) { float bl = drv.GetBladeLength(i); if (bl > best && drv.GetTipWorld(i) != Vector3.zero) { best = bl; slot = i; } }
var e = new Entry { rule = rule, stage = stage, pc = pc, drv = drv, slot = slot, startAt = Time.time };
string mode = "slot " + slot;
if (slot < 0) { Transform sk; Vector3 tl; if (SocketTip(pc, out sk, out tl)) { e.socket = sk; e.tipLocal = tl; mode = "socket " + sk.name + " tipLocal=" + tl.ToString("F2"); } }
if (!e.HasTip) { e.done = true; e.why = "no-tip"; }
else { float gy = pc.Get_position().y; var tip = e.Tip(); float y = tip.y - gy; e.prevY = e.maxY = e.minY = y; e.minPos = tip; }
_list.Add(e);
WLImpactFx.LastGround = "watch stage " + stage + (!e.HasTip ? " no-tip(fallback hitbox)" : " " + mode + " tipY=" + e.prevY.ToString("F2"));
}
/// <summary>HitboxSpawned 가 이 규칙·단계를 추적 중이면 true(폴백 차단). 추적 실패(no-tip)면 false 로 폴백 허용.</summary>
public bool Covers(WLGroundFxRule rule, int stage)
{
for (int i = 0; i < _list.Count; i++) if (_list[i].rule == rule && _list[i].stage == stage) return _list[i].HasTip;
return false;
}
public void NoteHitbox(WLGroundFxRule rule, int stage) { for (int i = 0; i < _list.Count; i++) if (_list[i].rule == rule && _list[i].stage == stage && _list[i].hitboxAt < 0f) _list[i].hitboxAt = Time.time; }
/// <summary>타격 상자가 생기는 프레임(= 클립의 타격 시점)에 아직 접점이 안 잡혔으면 그 순간의 무기 끝 자리(지면 투영)에 띄운다 — 지면까지 안 내려오는 모션(해머 머리 최저 0.24 m · 2026-10-12 실측)용.</summary>
public bool FireAtHitbox(WLGroundFxRule rule, int stage)
{
for (int i = 0; i < _list.Count; i++)
{
var e = _list[i]; if (e.rule != rule || e.stage != stage) continue;
if (e.done) return true; // 이미 접점에서 띄웠다
if (!e.HasTip) return false; // 무기 끝을 모른다 → 호출자가 타격 상자 자리로
var tip = e.Tip(); float gy = e.pc.Get_position().y; if (tip == Vector3.zero) return false;
e.done = true; e.why = "hitbox-tip";
WLImpactFx.PlayGround(rule, e.pc, new Vector3(tip.x, gy, tip.z), "contact-hitbox t=" + (Time.time - e.startAt).ToString("F2") + " tipY=" + (tip.y - gy).ToString("F2") + " min=" + e.minY.ToString("F2") + " max=" + e.maxY.ToString("F2"));
return true;
}
return false;
}
void Update()
{
for (int i = _list.Count - 1; i >= 0; i--)
{
var e = _list[i];
if (e.pc == null || !e.HasTip) { if (Time.time - e.startAt > e.rule.timeout) _list.RemoveAt(i); continue; }
float elapsed = Time.time - e.startAt;
if (!e.done)
{
Vector3 tip = e.Tip(); float gy = e.pc.Get_position().y; float y = tip.y - gy;
if (tip != Vector3.zero)
{
if (y > e.maxY) e.maxY = y;
if (y < e.minY) { e.minY = y; e.minPos = tip; }
bool descending = y < e.prevY - 1e-4f, ascending = y > e.prevY + 1e-4f;
bool behind = false;
if (e.rule.frontOnly) { var f = e.pc.transform.forward; f.y = 0f; var v = tip - e.pc.Get_position(); v.y = 0f; behind = Vector3.Dot(v, f) <= 0f; }
if (!behind && y <= e.rule.contactHeight && descending && e.maxY >= e.rule.minDrop)
{ // 지면(접점 높이 이내)에 닿는 프레임
e.done = true; e.why = "contact";
WLImpactFx.PlayGround(e.rule, e.pc, new Vector3(tip.x, gy, tip.z), "contact t=" + elapsed.ToString("F2") + " tipY=" + y.ToString("F2") + " max=" + e.maxY.ToString("F2") + (e.hitboxAt >= 0f ? " hitboxΔ=" + (Time.time - e.hitboxAt).ToString("F2") : " hitbox:none"));
}
e.wasDescending = descending || (e.wasDescending && !ascending);
e.prevY = y;
}
}
if (elapsed > e.rule.timeout)
{
if (!e.done) WLImpactFx.LastGround = "ground miss stage " + e.stage + " minY=" + e.minY.ToString("F2") + " max=" + e.maxY.ToString("F2"); // 시간 초과 — 늦게 띄우느니 안 띄운다
_list.RemoveAt(i);
}
}
}
}
/// <summary>1회 이펙트 — 켜질 때 전부 재생, 파티클 최장 수명 뒤 자동 비활성(풀 복귀).</summary>
public sealed class WLImpactOneShot : MonoBehaviour
{
ParticleSystem[] _ps; float _life = -1f, _offAt;
public Transform home; // #940 풀 루트 — follow 로 PC 에 붙었던 것을 꺼질 때 되돌린다
public void OverrideLife(float sec) { if (sec > 0f) _offAt = Time.time + sec; } // #940 규칙의 life(초)
void OnEnable()
{
if (_ps == null)
{
_ps = GetComponentsInChildren<ParticleSystem>(true);
float m = 0f;
for (int i = 0; i < _ps.Length; i++)
{
var main = _ps[i].main;
float d = main.startDelay.constantMax + (main.loop ? 1.0f : main.duration) + main.startLifetime.constantMax;
if (d > m) m = d;
}
_life = Mathf.Clamp(m, 0.3f, 6f);
}
for (int i = 0; i < _ps.Length; i++) { _ps[i].Clear(true); _ps[i].Play(true); }
_offAt = Time.time + _life;
}
// #940 follow 로 PC 에 붙었던 것은 끄기 전에 풀 루트로 되돌린다(OnDisable 안 SetParent 는 「already being activated or deactivated」 오류 — Play 실측 7건)
void Update() { if (Time.time >= _offAt) { if (home != null && transform.parent != home) transform.SetParent(home, false); gameObject.SetActive(false); } }
}
/// <summary>맞은 투사체를 몸 중심까지 조준 비행시킨 뒤 피격 이펙트와 함께 끈다(지팡이 마법 투사체).</summary>
public sealed class WLDeferredOff : MonoBehaviour
{
ProjectileBase _pb; Actor _target; float _arrive, _until, _speed, _startAt; Collider[] _cols; bool _straight;
public void Begin(ProjectileBase pb, Actor target, WLDeferRule rule, bool straight = false)
{
_pb = pb; _target = target; _arrive = Mathf.Max(0.02f, rule.arriveDistance); _until = Time.time + Mathf.Max(0.05f, rule.maxSeconds); _startAt = Time.time; _straight = straight;
var pd = pb.Get_ProjectTileData(); _speed = Mathf.Max(rule.minSpeed, pd != null ? pd.Speed : 0f);
if (_cols == null) _cols = GetComponentsInChildren<Collider>(true);
for (int i = 0; i < _cols.Length; i++) if (_cols[i] != null) _cols[i].enabled = false; // 더 이상 다른 몹에 맞지 않는다
pb.StopAllCoroutines(); // 원본 직진 이동·수명 코루틴을 끊고 이 쪽이 조준 비행
enabled = true;
}
void Update()
{
if (_target == null || !_target.gameObject.activeInHierarchy) { Finish(false, 0f); return; }
var center = WLImpactFx.BodyCenter(_target);
if (_straight)
{ // #921 방향을 틀지 않고 지금 방향 그대로 몸 중심 깊이(진행 방향으로 잰 거리)까지 — 화살이 꺾이지 않는다
var fwd = transform.forward; float ahead = Vector3.Dot(center - transform.position, fwd);
float step = Mathf.Min(_speed * Time.deltaTime, Mathf.Max(0f, ahead));
transform.position += fwd * step;
if (ahead - step <= _arrive || Time.time >= _until) Finish(true, Mathf.Max(0f, ahead - step));
return;
}
transform.position = Vector3.MoveTowards(transform.position, center, _speed * Time.deltaTime);
var dir = center - transform.position; if (dir.sqrMagnitude > 1e-6f) transform.rotation = Quaternion.LookRotation(dir);
float d = Vector3.Distance(transform.position, center);
if (d <= _arrive || Time.time >= _until) Finish(true, d);
}
void Finish(bool fx, float dist)
{
enabled = false;
if (fx && _pb != null && _target != null)
{
var pos = WLImpactFx.BodyCenter(_target);
var hf = _pb.GetComponent<WL.Combat.Weapon.WLProjectileHitFx>(); // #918 화살별 명중 이펙트(노랑 · 주황)가 직업 규칙보다 먼저
if (hf != null && hf.Play(_target)) WLImpactFx.HitFxShown++;
else if (!WLImpactFx.PlayHitOverride(WLImpactFx.ShooterClass(_pb), _target) && !string.IsNullOrEmpty(_pb.str_HitEffect) && InGameInfo.Ins != null)
InGameInfo.Ins.Show_Effect(_pb.str_HitEffect, pos);
WLImpactFx.Arrivals++; WLImpactFx.LastArriveDist = dist; WLImpactFx.LastArriveSeconds = Time.time - _startAt;
WLImpactFx.LastLog = "arrive d=" + dist.ToString("F2") + " t=" + (Time.time - _startAt).ToString("F2") + " → " + _target.name;
}
gameObject.SetActive(false); // 풀 복귀(원본 Off 와 같은 결과 · 코루틴은 이미 끊었다)
}
void OnDisable()
{
if (_cols != null) for (int i = 0; i < _cols.Length; i++) if (_cols[i] != null) _cols[i].enabled = true; // 다음 재사용을 위해 복구
enabled = false;
}
}
}