Project_WL/Assets/WL/PartSkill/WLHammerQuake.cs

451 lines
30 KiB
C#
Raw Permalink 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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<string, GameObject> s_fx = new Dictionary<string, GameObject>();
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<TextAsset>(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<int> hit = new HashSet<int>(); }
readonly List<Wave> _waves = new List<Wave>();
readonly Dictionary<int, int> _slamHit = new Dictionary<int, int>();
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<Transform> _parts = new List<Transform>();
readonly List<Vector3> _pos0 = new List<Vector3>(), _scl0 = new List<Vector3>();
readonly List<GameObject> _glowGos = new List<GameObject>();
readonly List<Renderer> _glowRs = new List<Renderer>();
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") + " 배";
}
/// <summary>한 번 내려칠 때 적 1 회 — 피해(레벨표 dmg_pct) · launch 면 띄움 · 기절(보스 제외).</summary>
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; }
/// <summary>그 자세로 멈춤 유지 — 다른 연출(돌진 끝 · 공격 배속 등)이 애니메이터 속도를 되돌리면 다시 멈춘다(#912 실측: 모으기 중 속도 1.0 · n 0.38 로 풀림).</summary>
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);
}
/// <summary>이동 · 구르기로 끊김 = 스킵 — 남은 판정 · 이펙트를 바로 멈춘다(애니메이터는 입력이 이미 넘겨받았다).</summary>
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);
}
/// <summary>끝 — 애니메이터 되돌림 · 대기(toIdle) · 멈춤 동안 미룬 뗌 적용 · 망치는 tail 이면 shrinkSeconds 동안 원래 크기(아니면 즉시).</summary>
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 망치 연출 ─────────────────────────────────────────────────────
/// <summary>망치 찾기(WLImpactRunner.SocketTip = 가장 먼 렌더러 꼭짓점) · 소켓 자식 크기 기록 · 흰 빛 덧그림(메시 복제 자식) · 점광원.</summary>
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<Material>(c.glowMaterial);
if (s_glowMat == null) LastLog = "흰 빛 재질 없음 " + c.glowMaterial;
else
foreach (var mf in _socket.GetComponentsInChildren<MeshFilter>(false))
{
var mr = mf.GetComponent<MeshRenderer>();
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<MeshFilter>().sharedMesh = mf.sharedMesh;
var r = go.AddComponent<MeshRenderer>();
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<Light>();
_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;
}
/// <summary>망치 크기(소켓 원점 기준 — 위치 · 크기 함께 배율) · 흰 빛 세기 · 점광원 세기를 쓰고 · 점광원 · 모으기 이펙트를 망치 끝에 둔다.</summary>
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;
}
/// <summary>망치 머리 중심(커진 크기 반영) — 모으기 이펙트 · 내려친 지점. 망치가 없으면 PC 가슴 높이.</summary>
Vector3 Tip()
{
if (_hasTip && _socket != null) return _socket.TransformPoint(_headLocal * _scale);
return _pc != null ? _pc.Get_position() + Vector3.up : Vector3.zero;
}
/// <summary>#913 망치 머리 중심(소켓 로컬) — 소켓 아래 메시마다 가장 긴 축을 손잡이 축으로(쥔 손에서 먼 쪽이 머리) 보고, 그 축으로 먼 쪽 frac 비율 안의
/// 정점 상자 중심. 가장 긴 메시(= 망치)를 쓴다 · 정점을 못 읽으면 가장 먼 꼭짓점(tipLocal).</summary>
static Vector3 HeadCenter(Transform socket, Vector3 tipLocal, float frac)
{
Vector3 best = tipLocal; float bestLen = -1f;
foreach (var mf in socket.GetComponentsInChildren<MeshFilter>(false))
{
var mr = mf.GetComponent<MeshRenderer>(); 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<GameObject>(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;
}
}
}