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