// ───────────────────────────────────────────────────────────────────────────── // WLJumpSlam.cs — 점프 내려찍기 타이밍: 점프 중(최고점 무렵)에 내려찍기 동작으로 넘어가 착지 순간에 지면을 찍게 (#909 · 2026-10-17) // // PD 원문: 「망치 주력 스킬 사용시 점프 최고점에서 내려찍기 모션 상태로 지면을 찍도록 해줘. (지금은 점프가 끝난 후 지면에서 내려치는 모션이 나오고 있어서 어색해)」 // ■ 원본 = 점프 이동은 Actor.Co_JumpAttack(높이 1 · landAt 초) · 동작은 컨트롤러 jump(Armed-Jump-Flip) → 끝 무렵 jumpattack(Mace-Attack-R2 · SkillEvent = 타격) // 으로 넘어가 내려치기 타격이 착지 뒤에 나왔다. // ■ 처리 — 스킬 시작(CombatEvents.SkillCast)부터 slamAt 초에 slamState 로 바로 넘기고, 그 클립의 SkillEvent(타격) 시각이 정확히 landAt 초(착지)에 오도록 // 애니메이터 속도를 맞춘다(타격 이벤트 시각은 클립에서 읽음). 착지 뒤 속도 1 로 되돌림. // ■ 값 = Resources/WL/WLJumpSlam.json (C45) · 끄기 = enabled 0 → 원본 동작 · 원본 수정 0줄 // ■ #998 (2026-10-30) PD 「창 기본 스킬은 회전하며 점프하는게 아니라 제자리에서 점프했다가 내려치기만」 · 「창 기본 스킬 점프하며 내려치는 동안 짧게 기가 모이는 연출을 발생한 후 내리쳐」 // · 「대검 기본 스킬 = 점프하는 동안 몸을 회전하며 검기로 몸을 세로 방향으로 최대 3회전 하며 공격하고 내려치는 공격(회전하는 검기는 기본 궤적 검기)」 // — 규칙 classId(직업 행 먼저) · ownJump 1 = 원본 점프 이동(Co_JumpAttack · 대상 쪽 · 앞구르기 클립 jump)을 Actor 훅 1줄(OwnJump)로 끄고 제자리 점프: // 오름 rise(내려찍기 클립을 처음부터 타격 시각 × holdAt 까지 = 무기를 든 자세) → 멈춤 hang(자세 고정 · hangFx) → 내림 fall(타격 시각이 착지에 오게 속도) · 높이 height // spins = 오름 + 멈춤 동안 엉덩이 뼈를 몸 중심 · 오른쪽 축으로 세로 회전하는 바퀴 수 · spinFx = 바퀴마다 몸 중심에 띄우는 검기(PC 를 따라감) // 🔴 Debug.LogError 금지(원본 팝업). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Weapon { [System.Serializable] public class WLJumpSlamRule { public string skill = ""; // eEffect 이름(원본 스킬 클래스) public string slamState = ""; // 내려찍기 상태 이름 public string slamClip = ""; // 그 상태의 클립 이름(타격 SkillEvent 시각을 읽는다) public float slamAt = 0.3f; // 스킬 시작 뒤 내려찍기로 넘어가는 시각(초) public float landAt = 0.8f; // 착지 시각(초) = 원본 Co_JumpAttack 지속 시간 public float crossFade = 0.08f; // 넘어가는 시간(초) public int classId; // #998 0 = 모든 직업 · > 0 = 이 직업만(직업이 맞는 행 먼저) public int ownJump; // #998 1 = 제자리 점프(아래 값) · 0 = 원본 점프 이동 + slamAt · landAt public float height = 1f, rise = 0.3f, hang = 0.25f, fall = 0.2f; // #998 높이(m) · 오름 · 멈춤 · 내림(초) public float holdAt = 0.5f; // #998 멈춤 자세 = 내려찍기 클립 타격 시각의 이 비율 자리 public WLStaffEarthFx hangFx = new WLStaffEarthFx(); // #998 멈춤 동안 모으기 이펙트(시전자 기준 · follow 1 = 따라감) public int spins; // #998 세로 회전 바퀴 수(0 = 안 돎) public WLStaffEarthFx spinFx = new WLStaffEarthFx(); // #998 바퀴마다 몸 중심에 띄우는 검기(PC 를 따라감 · euler = 정면 기준) // #998 PD 「대검 기본 스킬은 회전하는 동안 제자리에 있는게 아니라 1.5m 가량 전진하며 낙하까지 · 회전하는 동안 상승(↗)과 낙하(↘)하며 회전이 멈출 타이밍에 맞춰 내려치는 동작이 이어지도록」 // arc 1 = 높이가 착지까지 한 포물선(오름 · 멈춤 · 내림 구분 없이 ↗↘ · 회전은 rise + hang 동안 = 내려가는 중에 멈추고 바로 내림 fall 동안 내려치기) · forward = 착지까지 정면으로 가는 거리(m) public int arc; public float forward; // #1000 PD 「대검 기본 스킬 … 마지막 내려칠 때는 기본 공격 마지막타와 동일하게 내려치기동작과 이펙트로」 — landFx = 착지(타격) 순간 PC 정면 기준 offset 자리에 띄우는 지면 이펙트(기본 4타 지면 충격과 같은 프리팹) public WLStaffEarthFx landFx = new WLStaffEarthFx(); public WLStaffEarthFx landTrace = new WLStaffEarthFx(); // #1006 PD 「대검 기본 스킬 사용 후 지면에 충격 흔적이 남도록 (해머 공격에 사용 된 지면 충격 효과를 참고)」 — landFx 와 같은 순간 · 같은 기준(PC 정면 offset)에 남기는 흔적 public float impactTime; // #1001 0 보다 크면 내려찍기 타격 시각(클립 초)을 클립 SkillEvent 대신 이 값으로(대검 = SkillEvent 를 점프 시작으로 옮겨 회전 연타 판정을 WLStaffEarth 가 맡음 · 내려치기 동작 시각은 4타 그대로 0.80) // #1008 PD 「해머 주력 스킬 시전 시 해머를 내려치기 전 딜레이를 추가하고, 딜레이 시간 동안 해머가 빛나게 해줘. (해머 각성 스킬의 50% 정도의 딜레이 및 빛나는 연출)」 — // hangGlow > 0 = 멈춤(hang) 동안 무기 흰 빛 0 → hangGlow(해머 각성과 같은 덧그림 WLWeaponGlowFx) · 내림 동안 유지 · 착지(타격)부터 glowFade 초에 꺼짐 public float hangGlow; public float[] glowColor = { 1.6f, 1.6f, 1.6f }; public float glowFill = 0.35f, glowRim = 2.5f, glowFade = 0.25f; public string glowMaterial = "WL/Fx/WL_Mat_WeaponGlow"; } [System.Serializable] public class WLJumpSlamJson { public int enabled; public WLJumpSlamRule[] rules = new WLJumpSlamRule[0]; public string note = ""; } public static class WLJumpSlam { public const string ResourcesPath = "WL/WLJumpSlam"; public static int Slams, Missing, Errors; public static string LastLog = ""; static WLJumpSlamJson s_cfg; static WLJumpSlamJson Cfg { get { if (s_cfg != null) return s_cfg; s_cfg = new WLJumpSlamJson(); var ta = Resources.Load(ResourcesPath); if (ta == null) { LastLog = "설정 없음 " + ResourcesPath; return s_cfg; } try { JsonUtility.FromJsonOverwrite(ta.text, s_cfg); } catch (System.Exception ex) { LastLog = "설정 파싱 실패 — " + ex.Message; } return s_cfg; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { s_cfg = null; CombatEvents.SkillCast.Remove(OnCast); if (Cfg.enabled != 0) CombatEvents.SkillCast.Add(OnCast); } static void OnCast(in SkillCastEvent e) { try { var pc = e.actor as PCActor; if (pc == null || !pc.IsMainPC() || e.skill == null || Cfg.rules == null) return; var r = Find(pc, e.skill.e_Skill.ToString()); if (r == null) return; var run = pc.GetComponent(); if (run == null) run = pc.gameObject.AddComponent(); run.Begin(pc, r); } catch (System.Exception ex) { Errors++; LastLog = "시작 실패 — " + ex.Message; } } /// #998 직업이 맞는 행 먼저 · 없으면 classId 0 행. static WLJumpSlamRule Find(PCActor pc, string skill) { WLJumpSlamRule any = null; int cls = pc.Get_ID(); foreach (var r in Cfg.rules) { if (r == null || r.skill != skill) continue; if (r.classId == cls) return r; if (r.classId == 0 && any == null) any = r; } return any; } /// #998 Actor.Play_Skill 훅(1줄) — 이번 시전을 제자리 점프 러너가 맡았으면 true(원본 점프 이동 Co_JumpAttack 을 건너뜀). SkillCast 이벤트(Begin)가 먼저 불린다. public static bool OwnJump(Actor a) { try { var pc = a as PCActor; if (pc == null || Cfg.enabled == 0) return false; var run = pc.GetComponent(); return run != null && run.OwnActive; } catch (System.Exception ex) { Errors++; LastLog = "OwnJump 실패 — " + ex.Message; return false; } } public static int OwnJumps, SpinFxShown, HangFxShown, LandFxShown, LandTraces, Glows; public static float LastSpinDeg, LastPeak, LastGlow; /// 클립의 첫 SkillEvent 시각(초) · 없으면 -1. internal static float ImpactTime(Animator an, string clipName) { if (an == null || an.runtimeAnimatorController == null || string.IsNullOrEmpty(clipName)) return -1f; foreach (var c in an.runtimeAnimatorController.animationClips) { if (c == null || c.name != clipName) continue; foreach (var ev in c.events) if (ev.functionName == "SkillEvent") return ev.time; } return -1f; } } public sealed class WLJumpSlamRunner : MonoBehaviour { PCActor _pc; WLJumpSlamRule _r; float _t0, _impact; bool _on, _slammed; // #998 제자리 점프 bool _own, _started, _hung, _set, _landFx; float _hold, _y0, _peak, _lastAng; int _spun; Transform _bone; Vector3 _pivotLocal, _setPos, _start, _fwd; Quaternion _setRot; // #1008 멈춤 동안 무기 흰 빛 readonly WLWeaponGlowFx _glow = new WLWeaponGlowFx(); WLJumpSlamRule _glowRule; float _glowFadeAt = -1f; Color _glowCol = Color.white; public bool OwnActive { get { return _on && _own; } } void GlowSet(float g) { if (_glowRule == null) return; _glow.Set(g, _glowCol, _glowRule.glowFill, _glowRule.glowRim); WLJumpSlam.LastGlow = g; } void GlowFade() { if (_glow.Active && _glowFadeAt < 0f) _glowFadeAt = Time.time; } /// #1008 착지 뒤 흰 빛 끄기 — 시전이 끝난 뒤(_on 꺼짐)에도 돈다. void GlowTick() { if (_glowFadeAt < 0f || _glowRule == null) return; float k = 1f - Mathf.Clamp01((Time.time - _glowFadeAt) / Mathf.Max(0.01f, _glowRule.glowFade)); if (k <= 0f) { _glow.End(); _glowFadeAt = -1f; return; } GlowSet(_glowRule.hangGlow * k); } public void Begin(PCActor pc, WLJumpSlamRule r) { if (_on && _own) Restore(); _glow.End(); _glowFadeAt = -1f; _glowRule = null; // #1008 _pc = pc; _r = r; _t0 = Time.time; _slammed = false; _impact = r.impactTime > 0f ? r.impactTime : WLJumpSlam.ImpactTime(pc.m_animation, r.slamClip); _on = _impact > 0f; _own = _on && r.ownJump != 0; _started = _hung = _set = _landFx = false; _spun = 0; _lastAng = 0f; _bone = null; if (!_on) { WLJumpSlam.Missing++; WLJumpSlam.LastLog = "타격 이벤트 없음 " + r.slamClip; return; } if (!_own) return; _hold = _impact * Mathf.Clamp01(r.holdAt); _y0 = pc.transform.position.y; _peak = 0f; _start = pc.transform.position; _fwd = pc.transform.forward; if (OptionInfo.Ins != null) OptionInfo.Ins.Set_PCMove(pc, false); // 원본 Co_JumpAttack 과 같게(조작 끔 · 착지에 되돌림) if (r.spins > 0) { _bone = SpinBone(pc); _pivotLocal = pc.transform.InverseTransformPoint(WL.Combat.Impact.WLImpactFx.BodyCenter(pc)); } WLJumpSlam.OwnJumps++; } /// #956 과 같은 뼈 — Humanoid Hips · 없으면 루트 직계 중 이름이 Root 로 시작하는 것(이 PC = Root_M). 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; } void Update() { GlowTick(); // #1008 if (!_on) return; var an = _pc != null ? _pc.m_animation : null; if (an == null || _pc.IsDead() || _pc.Get_CurAnim() != eAnim.Skill) { End(an); return; } float t = Time.time - _t0; if (_own) { OwnTick(an, t); return; } if (!_slammed && t >= _r.slamAt) { _slammed = true; float remain = Mathf.Max(0.05f, _r.landAt - t); an.CrossFadeInFixedTime(_r.slamState, Mathf.Max(0f, _r.crossFade), 0, 0f); an.speed = Mathf.Clamp(_impact / remain, 0.3f, 3f); WLJumpSlam.Slams++; WLJumpSlam.LastLog = "내려찍기 " + _r.slamState + " · " + t.ToString("0.00") + " s · 속도 " + an.speed.ToString("0.00") + " · 타격 예정 " + _r.landAt.ToString("0.00") + " s"; } if (_slammed && t >= _r.landAt + 0.05f) End(an); } /// #998 제자리 점프 — 오름(무기 드는 자세까지 감기) → 멈춤(자세 고정 · 모으기) → 내림(타격 = 착지) · 높이는 매 프레임 직접. void OwnTick(Animator an, float t) { var r = _r; float rise = Mathf.Max(0.05f, r.rise), hang = Mathf.Max(0f, r.hang), fall = Mathf.Max(0.05f, r.fall), land = rise + hang + fall; if (!_started) // 첫 프레임 — Play_Skill 이 시전 이벤트 뒤에 스킬 동작(jump)을 틀므로 여기서 내려찍기로 넘긴다 { _started = true; _start = _pc.transform.position; _fwd = _pc.transform.forward; _fwd.y = 0f; if (_fwd.sqrMagnitude < 1e-4f) _fwd = Vector3.forward; _fwd.Normalize(); // Play_Skill 이 대상 쪽으로 돌린 뒤 an.CrossFadeInFixedTime(r.slamState, Mathf.Max(0f, r.crossFade), 0, 0f); an.speed = Mathf.Clamp(_hold / rise, 0.05f, 3f); WLJumpSlam.Slams++; WLJumpSlam.LastLog = "제자리 점프 " + r.slamState + " · 높이 " + r.height.ToString("0.00") + " · 오름 " + rise.ToString("0.00") + " · 멈춤 " + hang.ToString("0.00") + " · 내림 " + fall.ToString("0.00") + " · 회전 " + r.spins + " · 타격 예정 " + land.ToString("0.00") + " s"; } if (!_hung && t >= rise) { _hung = true; an.speed = 0f; if (hang > 0f && r.hangFx != null && !string.IsNullOrEmpty(r.hangFx.prefab)) { var go = WLStaffEarth.SpawnAt(r.hangFx, _pc, _pc.Get_position(), _pc.transform.forward, hang); if (go != null) { if (r.hangFx.follow != 0) go.transform.SetParent(_pc.transform, true); WLJumpSlam.HangFxShown++; } } if (r.hangGlow > 0f) // #1008 무기 흰 빛 덧그림(무기 소켓 = 끝이 가장 먼 소켓) { Transform sk; Vector3 tl; if (WL.Combat.Impact.WLImpactRunner.SocketTip(_pc, out sk, out tl) && _glow.Begin(sk, r.glowMaterial) > 0) { _glowRule = r; WLJumpSlam.Glows++; var gc = r.glowColor; _glowCol = gc != null && gc.Length >= 3 ? new Color(gc[0], gc[1], gc[2], 1f) : Color.white; } } } if (_glowRule != null && _glowFadeAt < 0f && t >= rise) GlowSet(r.hangGlow * Mathf.Clamp01((t - rise) / Mathf.Max(0.01f, hang))); // #1008 멈춤 동안 0 → hangGlow · 내림 동안 유지 if (!_slammed && t >= rise + hang) { _slammed = true; an.speed = Mathf.Clamp((_impact - _hold) / fall, 0.3f, 4f); WLJumpSlam.LastLog += " · 내림 속도 " + an.speed.ToString("0.00"); } float y; if (r.arc != 0) y = r.height * Mathf.Sin(Mathf.Clamp01(t / land) * Mathf.PI); else if (t < rise) y = r.height * Mathf.Sin(t / rise * Mathf.PI * 0.5f); else if (t < rise + hang) y = r.height; else { float k = Mathf.Clamp01((t - rise - hang) / fall); y = r.height * (1f - k * k); } var p = _pc.transform.position; p.y = _y0 + y; if (r.forward != 0f) { var h = _start + _fwd * (r.forward * Mathf.Clamp01(t / land)); p.x = h.x; p.z = h.z; } _pc.transform.position = p; if (y > _peak) { _peak = y; WLJumpSlam.LastPeak = y; } if (!_landFx && t >= land) { _landFx = true; var g = new Vector3(p.x, _y0, p.z); if (r.landFx != null && !string.IsNullOrEmpty(r.landFx.prefab) && WLStaffEarth.SpawnAt(r.landFx, _pc, g, _pc.transform.forward, 0f) != null) WLJumpSlam.LandFxShown++; if (r.landTrace != null && WLStaffEarth.SpawnAt(r.landTrace, _pc, g, _pc.transform.forward, 0f) != null) WLJumpSlam.LandTraces++; // #1006 GlowFade(); // #1008 타격부터 흰 빛 꺼짐 } if (t >= land + 0.05f) End(an); } /// #998 세로 회전 — 애니메이터가 이번 프레임 자세를 쓴 뒤 엉덩이 뼈(아래 무기 포함)를 몸 중심 · 오른쪽 축으로 돌린다(+ = 머리가 앞으로) · 바퀴마다 검기 1개. void LateUpdate() { if (!_on || !_own || _r.spins <= 0 || _pc == null) return; float dur = Mathf.Max(0.05f, _r.rise + _r.hang), t = Time.time - _t0; if (t > dur + 0.1f) return; float ang = 360f * _r.spins * Mathf.SmoothStep(0f, 1f, Mathf.Clamp01(t / dur)); var root = _pc.transform; Vector3 pivot = root.TransformPoint(_pivotLocal); if (_bone != null) { bool rewritten = !_set || Quaternion.Angle(_bone.rotation, _setRot) > 0.01f || (_bone.position - _setPos).sqrMagnitude > 1e-8f; // 애니메이터가 안 쓴 프레임이면 차이만 float d = rewritten ? ang : ang - _lastAng; if (Mathf.Abs(d) > 0.001f) _bone.RotateAround(pivot, root.right, d); _setRot = _bone.rotation; _setPos = _bone.position; _set = true; WLJumpSlam.LastSpinDeg = ang; } _lastAng = ang; if (_spun < _r.spins && ang >= 360f * _spun && _r.spinFx != null && !string.IsNullOrEmpty(_r.spinFx.prefab)) { _spun++; var go = WLStaffEarth.SpawnAt(_r.spinFx, _pc, pivot, root.forward, 0f); if (go != null) { go.transform.SetParent(root, true); WLJumpSlam.SpinFxShown++; } } } /// #998 끊김(구르기 · 사망 · 상태 바뀜) 때 발 높이 · 조작을 되돌린다. void Restore() { if (_pc == null) return; var p = _pc.transform.position; p.y = _y0; var agent = _pc.GetComponent(); UnityEngine.AI.NavMeshHit hit; // 앞으로 간 경우 에이전트 자리도 맞춘다(원본 Co_JumpAttack 끝의 Snap_toNavMesh 와 같게) if (agent != null && agent.enabled && UnityEngine.AI.NavMesh.SamplePosition(p, out hit, 1f, UnityEngine.AI.NavMesh.AllAreas)) agent.Warp(hit.position); else _pc.transform.position = p; WLJumpSlam.LastLog += " · 착지 이동 " + Vector3.Distance(new Vector3(_start.x, 0f, _start.z), new Vector3(_pc.transform.position.x, 0f, _pc.transform.position.z)).ToString("0.00") + " m"; if (OptionInfo.Ins != null) OptionInfo.Ins.Set_PCMove(_pc, true); } void End(Animator an) { if (_on && (_slammed || _own) && an != null) an.speed = 1f; if (_on && _own) Restore(); GlowFade(); // #1008 끊겨도 흰 빛은 서서히 _on = false; } void OnDisable() { if (_on && _own) Restore(); _on = false; _glow.End(); _glowFadeAt = -1f; } } }