// ───────────────────────────────────────────────────────────────────────────── // WLHammerQuake.cs — 해머 각성 「대지 분쇄」 (#911 · #912 · 2026-10-17) — 파츠 스킬 행동 hammer_quake // // PD 원문: 「첫 타 내려치기 모션 직전(망치를 든 상태)를 0.5초 재생하고, 이때 망치에 이펙트 재생(Master Stylized Projectile 에셋의 모으기를 망치에 · // 망치 크기에 맞게) · 이후 내려치기 모션을 빠르게 3회 반복하며, 전방으로 Stylized Earth Skills 에셋의 충격파를 내려친 방향의 전방 방사형 // 3갈래로 3번 연속 내뻗으며 공격하고, 피격 된 적을 공중에 타격받을 때마다 2미터씩 띄우고 적들에게 2초의 스턴 · // 망치로 내려친 지면은 FX_EarthSkill_Popup_05 에서 바위 형태만 숨긴 상태로 망치가 내려친 위치에」 → 「내려치기 횟수를 3회가 아니라 1회로」 // #912 PD: 「모으기 이펙트가 재생될 때 망치가 점차 하얗게 빛날 수 있게」 · 「모으기 이펙트가 2D 같은 느낌 · 좀 더 빛나는 느낌이 나게」 · // 「망치를 내려치기 직전까지 망치를 1.5배까지 크기를 키우고」 · 「망치 내려친 후 바로 움직이지 않고 임펙트 있게 내려친 상태로 캐릭터가 잠시 멈추게(0.5초 내외)」 // #913 PD: 「모으기 공격의 이펙트 위치가 망치 머리에」 · 「빛 광원이 주변을 비추지 않고 망치 색상만 빛나게(완전 하얗게 빛나지 않고 망치의 외형이 살짝은 보일 수 있도록)」 · // 「망치 각성 스킬을 내려칠 때도 광원이 비추면 안돼」 → 모으기 이펙트 · 내려친 지점 = 망치 머리 중심(HeadCenter) · 점광원 lightIntensity 0(만들지 않음) · // 흰 빛 몸통 덮기 glowFill · 테두리 glowRimPower 를 JSON 으로 // #914 PD: 「원형 모이는 이펙트 위치를 망치 기준 조금 더 아래로(원형 이펙트가 망치 머리에 거의 가려지도록)」 · 「내려친 위치의 이펙트 크기를 조금 더 키워주고 // 망치 주변 반경에 있는 적들도 광역 피해」 · 「해머를 내려친 위치를 기준으로 원형 3m 범위 내 적에게도 동일한 피해」 → chargeFxDrop · groundFxScale · // impactRadius 3 m(내려친 순간 · 충격파와 같은 피해 + 띄움 · 기절 · 같은 내려치기에서 적 1 회) // ■ 흐름 — 원본 Play_Skill(1타 상태)로 스킬 상태(자동 공격 · 이동 끼어들기 없음 · 대상 보기) → 애니메이터 이벤트를 끄고(1타 판정 · 이펙트가 같이 나가지 않게) // ① holdNormalized 자세로 멈춰 chargeSeconds 동안 — 망치(쥔 손 = 소켓 원점 기준)가 1 → growScale 배로 커지고 · 흰 빛(WL/WeaponGlow 덧그림 · HDR → 블룸)이 // 0 → glowMax · 망치 끝에 모으기 이펙트 + 점광원(lightIntensity · 주변을 비춘다) // ② 내려치기를 slams 번: holdNormalized → 타격 시점(클립 Projectile 이벤트)에서 지면 이펙트 + 3 갈래 충격파(prongAngles) → slamEndNormalized 까지 · 배속 slamSpeed. // 마지막 한 번은 내려친 자세(impactHoldNormalized · 망치가 땅에 닿은 프레임)에서 impactHoldSeconds 멈춤 — 그동안 이동 입력 무시(구르기 = 스킵 · WLSkillCancel.HoldMove) · // 흰 빛 · 점광원은 내려친 뒤 glowFadeSeconds 동안 꺼진다 // ③ 끝 = 이벤트 · 속도 되돌리고 대기 · 망치는 shrinkSeconds 동안 원래 크기. // ■ 충격파 판정 = 갈래마다 앞으로 prongTravelSeconds 동안 prongLength 까지 나아가는 앞줄 · 폭 prongWidth(+ 몸 반지름) 안의 적 · 한 번 내려칠 때 적 1 회 · // 피해 = 레벨표 dmg_pct · 맞을 때마다 launchHeight m 띄우고 내려온 뒤 stunSeconds 기절(WLLaunch.Apply · 보스 제외). // ■ 이동 · 구르기로 스킬 상태가 끊기면(WLSkillCancel 기본 = 스킵) 남은 내려치기 · 이펙트 · 판정을 바로 멈춘다. // ■ 값 = Resources/WL/WLHammerQuake.json (C45) · 쿨타임 · 피해 = WLPartSkillLevelsWL.csv 900201 · 정의 = WLPartSkillDefs.csv hammer_quake // 🔴 Debug.LogError 금지(원본 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; namespace WL.PartSkill { [System.Serializable] public class WLHammerQuakeJson { public int enabled = 1; public string slamState = "attack"; public string slamClip = "Attack1_S_10501"; public float holdNormalized = 0.27f; public float chargeSeconds = 0.5f; public int slams = 3; public float slamSpeed = 1.6f; public float slamEndNormalized = 0.55f; public string chargeFx = "WL/Fx/WL_Fx_HammerCharge"; public float chargeFxScale = 0.3f; public string groundFx = "WL/Fx/WL_Fx_QuakeGround"; public float groundFxScale = 0.35f; public float groundFxLife = 2.5f; public string pathFx = "WL/Fx/WL_Fx_QuakePath"; public float pathFxNativeLength = 8f; public float pathFxLife = 2.5f; public float[] prongAngles = { -30f, 0f, 30f }; public float prongLength = 4.5f; public float prongWidth = 1.0f; public float prongTravelSeconds = 0.4f; public float strikeForward = 0.85f; public float launchHeight = 2f; public float launchSeconds = 0.7f; public float stunSeconds = 2f; // #912 망치 연출 · 내려친 자세 멈춤 public float growScale = 1.5f; // 모으기 동안 망치 크기 1 → 이 배율(내려치기 직전에 도달) public float shrinkSeconds = 0.15f; // 끝난 뒤 원래 크기로 돌아오는 시간 public float headFraction = 0.25f; // #913 망치 머리 = 손잡이 축으로 먼 쪽 이 비율의 정점(그 상자 중심 = 모으기 이펙트 · 내려친 지점) public float chargeFxDrop = 0.25f; // #914 모으기 이펙트를 망치 머리 중심에서 아래로(m · 망치 배율 곱) — 원형 이펙트가 머리에 거의 가려지게 public float impactRadius = 3f; // #914 내려친 지점 둘레 이 반경(m · + 적 몸 반지름) 안의 적 = 충격파와 같은 피해 · 0 = 없음 public int impactLaunch = 1; // #914 1 = 둘레 피해에도 충격파와 같은 띄움 · 기절 public string glowMaterial = "WL/Fx/WL_Mat_WeaponGlow"; public float[] glowColor = { 2.2f, 2.2f, 2.2f }; // 흰 빛 색(HDR · 블룸 문턱을 넘으면 번진다) public float glowMax = 1f; // 모으기 끝 흰 빛 세기(0~1) public float glowFill = 0.6f; // #913 몸통 덮기(낮을수록 망치 외형이 비친다) public float glowRimPower = 2f; // #913 테두리 빛 폭(클수록 가장자리만) public float glowFadeSeconds = 0.25f; // 내려친 뒤 흰 빛 · 점광원이 꺼지는 시간 public float[] lightColor = { 0.85f, 0.65f, 1f }; public float lightRange = 3f; public float lightIntensity = 3f; // 0 = 점광원 없음 public float impactHoldNormalized = 0.46f; // 내려친 자세(망치가 땅에 닿은 프레임) public float impactHoldSeconds = 0.5f; // 그 자세로 멈추는 시간 public int holdBlocksMove = 1; // 1 = 멈춘 동안 이동 입력 무시(구르기는 허용 = 스킵) public string note = ""; } public sealed class SkHammerQuake : PSkill { public const string ResourcesPath = "WL/WLHammerQuake"; public static int CastCount, SlamCount, HitCount, AreaHits, Aborted, Holds; public static string LastLog = ""; static WLHammerQuakeJson s_cfg; static readonly Dictionary s_fx = new Dictionary(); static Material s_glowMat; static readonly int kGlow = Shader.PropertyToID("_Glow"), kGlowColor = Shader.PropertyToID("_GlowColor"), kFill = Shader.PropertyToID("_Fill"), kRim = Shader.PropertyToID("_RimPower"); static WLHammerQuakeJson C { get { if (s_cfg != null) return s_cfg; s_cfg = new WLHammerQuakeJson(); var ta = Resources.Load(ResourcesPath); if (ta != null) { try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } } return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] static void Reset() { s_cfg = null; s_fx.Clear(); s_glowMat = null; } sealed class Wave { public Vector3 o, d; public float t0; public int slam; public GameObject fx; public readonly HashSet hit = new HashSet(); } readonly List _waves = new List(); readonly Dictionary _slamHit = new Dictionary(); Coroutine _co, _tailCo; GameObject _charge; bool _running, _animOwned; int _slam; // #912 망치 연출 — 크기(소켓 원점 = 쥔 손 기준) · 흰 빛 덧그림 · 점광원 PCActor _pc; Transform _socket; Vector3 _tipLocal, _headLocal; bool _hasTip; float _scale = 1f, _glow, _light; readonly List _parts = new List(); readonly List _pos0 = new List(), _scl0 = new List(); readonly List _glowGos = new List(); readonly List _glowRs = new List(); MaterialPropertyBlock _mpb; Light _lamp; static PCActor Pc { get { return MyValue.bMyPC ? MyValue.MyPC as PCActor : null; } } public override bool WantCast() { var pc = Pc; if (_running || C.enabled == 0 || pc == null || pc.IsDead() || pc.IsCC() || pc.Get_CurAnim() >= eAnim.Skill) return false; return R.AnyEnemyWithin(R.PcPos + R.PcFwd * (C.prongLength * 0.5f), C.prongLength * 0.6f); } public override void Cast() { var pc = Pc; if (_running || C.enabled == 0 || pc == null || pc.IsDead() || pc.m_animation == null) return; casts++; CastCount++; _co = R.StartCoroutine(Run(pc)); } IEnumerator Run(PCActor pc) { var c = C; var an = pc.m_animation; float impactN = ImpactNormalized(an, c.slamClip); if (impactN <= c.holdNormalized) { LastLog = "타격 이벤트 없음 " + c.slamClip; yield break; } _running = true; _waves.Clear(); _slamHit.Clear(); _slam = 0; pc.Del_Magic(); pc.Play_Skill(c.slamState); // 원본 — 스킬 상태 · 대상 보기 · 이동 멈춤 an.fireEvents = false; _animOwned = true; // 1타의 판정 · 이펙트 · 대기 이벤트를 끈다 an.Play(c.slamState, 0, c.holdNormalized); an.speed = 0f; an.Update(0f); // #913 자세를 지금 적용 — 첫 프레임 모으기 이펙트가 이전 자세의 망치 머리에 찍히던 것(실측) 방지 // ① 모으기 — 망치가 점점 커지고 하얗게 빛나며 · 망치 끝에 모으기 이펙트 + 점광원 if (_tailCo != null) { R.StopCoroutine(_tailCo); _tailCo = null; } VisualBegin(pc); _charge = Spawn(c.chargeFx, Tip(), null, c.chargeFxScale, c.chargeSeconds + 0.6f); float t = 0f; while (t < c.chargeSeconds) { if (Broken(pc)) { Abort(pc); yield break; } Pin(an, c.slamState, c.holdNormalized); float k = Mathf.Clamp01(t / Mathf.Max(0.01f, c.chargeSeconds)), e = 1f - (1f - k) * (1f - k); Visual(Mathf.Lerp(1f, c.growScale, e), c.glowMax * k, c.lightIntensity * e); t += Time.deltaTime; yield return null; } Visual(c.growScale, c.glowMax, c.lightIntensity); if (_charge != null) { Object.Destroy(_charge); _charge = null; } // ② 내려치기 × slams — 마지막 한 번은 내려친 자세로 멈춘다 int n = Mathf.Max(1, c.slams); for (int k = 0; k < n; k++) { _slam = k + 1; bool last = k == n - 1; an.Play(c.slamState, 0, c.holdNormalized); an.speed = Mathf.Max(0.1f, c.slamSpeed); yield return null; float sp = Mathf.Max(0.1f, c.slamSpeed); while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < impactN) { if (Broken(pc)) { Abort(pc); yield break; } if (an.speed != sp) an.speed = sp; Visual(_scale, _glow, _light); yield return null; } Impact(pc); float endN = last ? c.impactHoldNormalized : c.slamEndNormalized, f0 = Time.time; while (an.GetCurrentAnimatorStateInfo(0).normalizedTime < endN) { if (Broken(pc)) { Abort(pc); yield break; } if (an.speed != sp) an.speed = sp; if (last) Fade(f0); else Visual(_scale, _glow, _light); yield return null; } if (!last) continue; // ③ 내려친 자세로 멈춤 — 이동 입력 무시(구르기 = 스킵) float holdN = an.GetCurrentAnimatorStateInfo(0).normalizedTime; an.speed = 0f; Holds++; if (c.holdBlocksMove != 0) WL.Combat.Weapon.WLSkillCancel.HoldMove(pc, c.impactHoldSeconds); float h = 0f; while (h < c.impactHoldSeconds) { if (Broken(pc)) { Abort(pc); yield break; } Pin(an, c.slamState, holdN); Fade(f0); h += Time.deltaTime; yield return null; } } Release(pc, true, true); } void Impact(PCActor pc) { var c = C; var fwd = pc.transform.forward; fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward; fwd.Normalize(); var at = _hasTip ? Tip() : pc.Get_position() + fwd * c.strikeForward; at.y = pc.Get_position().y; Spawn(c.groundFx, at, Quaternion.LookRotation(fwd), c.groundFxScale, c.groundFxLife); // #914 내려친 지점 둘레 impactRadius m 안의 적 = 충격파와 같은 피해(+ 띄움 · 기절) · 같은 내려치기의 충격파와 겹쳐도 적 1 회 int area = 0; if (c.impactRadius > 0f) for (int e = 0; e < R.Enemies.Count; e++) { var a = R.Enemies[e]; if (!WLPartSkillCombat.Valid(a)) continue; var v = a.Get_position() - at; v.y = 0f; if (v.magnitude > c.impactRadius + WL.Combat.WLCombatMotionSettings.BodyRadius(a)) continue; if (Hit(a, _slam, c.impactLaunch != 0)) area++; } AreaHits += area; float pathScale = c.prongLength / Mathf.Max(0.1f, c.pathFxNativeLength); foreach (var ang in c.prongAngles ?? new float[0]) { var d = Quaternion.Euler(0f, ang, 0f) * fwd; var w = new Wave { o = at, d = d, t0 = Time.time, slam = _slam }; w.fx = Spawn(c.pathFx, at, Quaternion.LookRotation(d), pathScale, c.pathFxLife); _waves.Add(w); } SlamCount++; LastLog = "내려치기 " + _slam + "/" + c.slams + " · 충격파 " + (c.prongAngles != null ? c.prongAngles.Length : 0) + " 갈래 · 둘레 " + c.impactRadius + " m 명중 " + area + " · 지점 " + at.ToString("F1") + " · 망치 " + _scale.ToString("F2") + " 배"; } /// 한 번 내려칠 때 적 1 회 — 피해(레벨표 dmg_pct) · launch 면 띄움 · 기절(보스 제외). bool Hit(Actor a, int slam, bool launch) { int id = a.GetInstanceID(); int last; if (_slamHit.TryGetValue(id, out last) && last == slam) return false; _slamHit[id] = slam; if (!WLPartSkillCombat.Damage(a, Dmg(row != null ? row.dmgPct : 100f), true, Key)) return false; HitCount++; WL.Combat.Impact.WLImpactFx.PlaySkillHitByPC(a, "hammer_quake"); // #936 맞은 적 몸 중심에 해머 스킬 피격 이펙트(WLImpactFx.json skillHitFx 10501 · 투사체가 없는 WL 판정이라 직접 부른다) var c = C; if (launch && !a.IsSubRole(eSubRol.Boss)) WL.Combat.Weapon.WLLaunch.Apply(a, c.launchHeight, c.launchSeconds, c.stunSeconds); return true; } public override void Tick(float dt) { if (_waves.Count == 0) return; var c = C; float now = Time.time; for (int i = _waves.Count - 1; i >= 0; i--) { var w = _waves[i]; float age = now - w.t0; float front = Mathf.Min(c.prongLength, age / Mathf.Max(0.05f, c.prongTravelSeconds) * c.prongLength); for (int e = 0; e < R.Enemies.Count; e++) { var a = R.Enemies[e]; if (!WLPartSkillCombat.Valid(a)) continue; var v = a.Get_position() - w.o; v.y = 0f; float along = Vector3.Dot(v, w.d); if (along < -0.3f || along > front) continue; float perp = (v - w.d * along).magnitude; if (perp > c.prongWidth * 0.5f + WL.Combat.WLCombatMotionSettings.BodyRadius(a)) continue; if (!w.hit.Add(a.GetInstanceID())) continue; Hit(a, w.slam, true); // 한 번 내려칠 때 적 1 회(둘레 피해와도) } if (age > c.prongTravelSeconds + 0.05f) _waves.RemoveAt(i); } } bool Broken(PCActor pc) { return pc == null || pc.IsDead() || pc.Get_CurAnim() != eAnim.Skill; } /// 그 자세로 멈춤 유지 — 다른 연출(돌진 끝 · 공격 배속 등)이 애니메이터 속도를 되돌리면 다시 멈춘다(#912 실측: 모으기 중 속도 1.0 · n 0.38 로 풀림). static void Pin(Animator an, string state, float n) { if (an.speed != 0f) an.speed = 0f; if (Mathf.Abs(an.GetCurrentAnimatorStateInfo(0).normalizedTime - n) > 0.01f) an.Play(state, 0, n); } /// 이동 · 구르기로 끊김 = 스킵 — 남은 판정 · 이펙트를 바로 멈춘다(애니메이터는 입력이 이미 넘겨받았다). void Abort(PCActor pc) { Aborted++; LastLog = "끊김(이동 · 구르기) — 내려치기 " + _slam; foreach (var w in _waves) if (w.fx != null) Object.Destroy(w.fx); _waves.Clear(); Release(pc, false, true); } /// 끝 — 애니메이터 되돌림 · 대기(toIdle) · 멈춤 동안 미룬 뗌 적용 · 망치는 tail 이면 shrinkSeconds 동안 원래 크기(아니면 즉시). void Release(PCActor pc, bool toIdle, bool tail) { if (_charge != null) { Object.Destroy(_charge); _charge = null; } var an = pc != null ? pc.m_animation : null; if (_animOwned && an != null) { an.speed = 1f; an.fireEvents = true; } _animOwned = false; _running = false; _co = null; if (toIdle && pc != null && !pc.IsDead() && pc.Get_CurAnim() == eAnim.Skill) pc.Play_Idle(); WL.Combat.Weapon.WLSkillCancel.EndHoldMove(pc, toIdle); if (_tailCo != null && R != null) { R.StopCoroutine(_tailCo); _tailCo = null; } if (tail && R != null && R.isActiveAndEnabled && (_parts.Count > 0 || _glowGos.Count > 0 || _lamp != null)) _tailCo = R.StartCoroutine(Shrink()); else VisualEnd(); } public override void Dispose() { if (_co != null && R != null) R.StopCoroutine(_co); if (_running) Release(Pc, false, false); if (_tailCo != null && R != null) R.StopCoroutine(_tailCo); _tailCo = null; VisualEnd(); foreach (var w in _waves) if (w.fx != null) Object.Destroy(w.fx); _waves.Clear(); } public override string State { get { return "running=" + _running + " slam=" + _slam + " waves=" + _waves.Count + " hits=" + HitCount + " scale=" + _scale.ToString("F2") + " glow=" + _glow.ToString("F2"); } } // ── #912 망치 연출 ───────────────────────────────────────────────────── /// 망치 찾기(WLImpactRunner.SocketTip = 가장 먼 렌더러 꼭짓점) · 소켓 자식 크기 기록 · 흰 빛 덧그림(메시 복제 자식) · 점광원. void VisualBegin(PCActor pc) { VisualEnd(); var c = C; _pc = pc; _hasTip = WL.Combat.Impact.WLImpactRunner.SocketTip(pc, out _socket, out _tipLocal); if (_hasTip) { _headLocal = HeadCenter(_socket, _tipLocal, c.headFraction); foreach (Transform ch in _socket) { if (!ch.gameObject.activeSelf) continue; _parts.Add(ch); _pos0.Add(ch.localPosition); _scl0.Add(ch.localScale); } if (s_glowMat == null && !string.IsNullOrEmpty(c.glowMaterial)) s_glowMat = Resources.Load(c.glowMaterial); if (s_glowMat == null) LastLog = "흰 빛 재질 없음 " + c.glowMaterial; else foreach (var mf in _socket.GetComponentsInChildren(false)) { var mr = mf.GetComponent(); if (mr == null || !mr.enabled || mf.sharedMesh == null) continue; var go = new GameObject("[WL912] Glow") { layer = mf.gameObject.layer }; go.transform.SetParent(mf.transform, false); go.AddComponent().sharedMesh = mf.sharedMesh; var r = go.AddComponent(); var mats = new Material[Mathf.Max(1, mf.sharedMesh.subMeshCount)]; for (int i = 0; i < mats.Length; i++) mats[i] = s_glowMat; r.sharedMaterials = mats; r.enabled = false; r.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; r.receiveShadows = false; r.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; r.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; _glowGos.Add(go); _glowRs.Add(r); } } if (c.lightIntensity > 0f) { _lamp = new GameObject("[WL912] ChargeLight").AddComponent(); _lamp.type = LightType.Point; _lamp.range = Mathf.Max(0.1f, c.lightRange); _lamp.color = Rgb(c.lightColor, 1f); _lamp.shadows = LightShadows.None; _lamp.intensity = 0f; _lamp.enabled = false; } _scale = 1f; _glow = 0f; _light = 0f; } /// 망치 크기(소켓 원점 기준 — 위치 · 크기 함께 배율) · 흰 빛 세기 · 점광원 세기를 쓰고 · 점광원 · 모으기 이펙트를 망치 끝에 둔다. void Visual(float scale, float glow, float light) { _scale = scale; _glow = glow; _light = light; for (int i = 0; i < _parts.Count; i++) { var p = _parts[i]; if (p == null) continue; p.localPosition = _pos0[i] * scale; p.localScale = _scl0[i] * scale; } if (_glowRs.Count > 0) { if (_mpb == null) _mpb = new MaterialPropertyBlock(); var c = C; var col = Rgb(c.glowColor, 2f); for (int i = 0; i < _glowRs.Count; i++) { var r = _glowRs[i]; if (r == null) continue; r.enabled = glow > 0.001f; _mpb.Clear(); _mpb.SetFloat(kGlow, glow); _mpb.SetColor(kGlowColor, col); _mpb.SetFloat(kFill, c.glowFill); _mpb.SetFloat(kRim, c.glowRimPower); r.SetPropertyBlock(_mpb); } } var tip = Tip(); if (_lamp != null) { _lamp.transform.position = tip; _lamp.intensity = light; _lamp.enabled = light > 0.01f; } if (_charge != null) _charge.transform.position = tip + Vector3.down * (C.chargeFxDrop * scale); // #914 머리에 거의 가려지게 아래로 } void Fade(float since) { var c = C; float k = 1f - Mathf.Clamp01((Time.time - since) / Mathf.Max(0.01f, c.glowFadeSeconds)); Visual(_scale, c.glowMax * k, c.lightIntensity * k); } IEnumerator Shrink() { float s0 = _scale, g0 = _glow, l0 = _light, dur = Mathf.Max(0.01f, C.shrinkSeconds), t = 0f; while (t < dur) { t += Time.deltaTime; float k = Mathf.Clamp01(t / dur); Visual(Mathf.Lerp(s0, 1f, k), g0 * (1f - k), l0 * (1f - k)); yield return null; } _tailCo = null; VisualEnd(); } void VisualEnd() { for (int i = 0; i < _parts.Count; i++) { var p = _parts[i]; if (p == null) continue; p.localPosition = _pos0[i]; p.localScale = _scl0[i]; } _parts.Clear(); _pos0.Clear(); _scl0.Clear(); for (int i = 0; i < _glowGos.Count; i++) if (_glowGos[i] != null) Object.Destroy(_glowGos[i]); _glowGos.Clear(); _glowRs.Clear(); if (_lamp != null) Object.Destroy(_lamp.gameObject); _lamp = null; _scale = 1f; _glow = 0f; _light = 0f; } /// 망치 머리 중심(커진 크기 반영) — 모으기 이펙트 · 내려친 지점. 망치가 없으면 PC 가슴 높이. Vector3 Tip() { if (_hasTip && _socket != null) return _socket.TransformPoint(_headLocal * _scale); return _pc != null ? _pc.Get_position() + Vector3.up : Vector3.zero; } /// #913 망치 머리 중심(소켓 로컬) — 소켓 아래 메시마다 가장 긴 축을 손잡이 축으로(쥔 손에서 먼 쪽이 머리) 보고, 그 축으로 먼 쪽 frac 비율 안의 /// 정점 상자 중심. 가장 긴 메시(= 망치)를 쓴다 · 정점을 못 읽으면 가장 먼 꼭짓점(tipLocal). static Vector3 HeadCenter(Transform socket, Vector3 tipLocal, float frac) { Vector3 best = tipLocal; float bestLen = -1f; foreach (var mf in socket.GetComponentsInChildren(false)) { var mr = mf.GetComponent(); var mesh = mf.sharedMesh; if (mr == null || !mr.enabled || mesh == null || !mesh.isReadable) continue; var M = socket.worldToLocalMatrix * mf.transform.localToWorldMatrix; var b = mesh.bounds; var e = b.extents; int ax = e.x >= e.y && e.x >= e.z ? 0 : (e.y >= e.z ? 1 : 2); var dirL = ax == 0 ? Vector3.right : ax == 1 ? Vector3.up : Vector3.forward; var dir = M.MultiplyVector(dirL).normalized; if (M.MultiplyPoint3x4(b.center - dirL * e[ax]).sqrMagnitude > M.MultiplyPoint3x4(b.center + dirL * e[ax]).sqrMagnitude) dir = -dir; var verts = mesh.vertices; if (verts.Length == 0) continue; float mn = float.MaxValue, mx = float.MinValue; for (int i = 0; i < verts.Length; i++) { float pr = Vector3.Dot(M.MultiplyPoint3x4(verts[i]), dir); if (pr < mn) mn = pr; if (pr > mx) mx = pr; } float cut = mx - Mathf.Clamp01(frac) * (mx - mn); Vector3 lo = Vector3.one * float.MaxValue, hi = Vector3.one * float.MinValue; int n = 0; for (int i = 0; i < verts.Length; i++) { var p = M.MultiplyPoint3x4(verts[i]); if (Vector3.Dot(p, dir) < cut) continue; lo = Vector3.Min(lo, p); hi = Vector3.Max(hi, p); n++; } if (n > 0 && mx - mn > bestLen) { bestLen = mx - mn; best = (lo + hi) * 0.5f; } } return best; } static Color Rgb(float[] a, float d) { return new Color(a != null && a.Length > 0 ? a[0] : d, a != null && a.Length > 1 ? a[1] : d, a != null && a.Length > 2 ? a[2] : d, 1f); } static float ImpactNormalized(Animator an, string clipName) { if (an == null || an.runtimeAnimatorController == null) return -1f; foreach (var clip in an.runtimeAnimatorController.animationClips) { if (clip == null || clip.name != clipName || clip.length <= 0f) continue; foreach (var ev in clip.events) if (ev.functionName == "Projectile") return ev.time / clip.length; } return -1f; } static GameObject Spawn(string path, Vector3 pos, Quaternion? rot, float scale, float life) { if (string.IsNullOrEmpty(path)) return null; GameObject prefab; if (!s_fx.TryGetValue(path, out prefab)) { prefab = Resources.Load(path); s_fx[path] = prefab; } if (prefab == null) { LastLog = "이펙트 없음 " + path; return null; } var go = Object.Instantiate(prefab, pos, rot.HasValue ? rot.Value * prefab.transform.rotation : prefab.transform.rotation); go.transform.localScale = prefab.transform.localScale * Mathf.Max(0.01f, scale); Object.Destroy(go, Mathf.Max(0.1f, life)); return go; } } }