Project_WL/Assets/WL/Combat/Weapon/WLStaffEarth.cs

443 lines
34 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// WLStaffEarth.cs — 지팡이(현자의 지팡이 · 클래스 10302) 주력 · 각성 · 3타를 대지(Stylized Earth Skills) WL 방식으로 (#935 · 2026-10-18)
//
// PD 원문(#935):
// ③ 「지팡이 주력 스킬 시전 시 캐릭터의 주문 시전 모션에 모으기 이펙트를 재생시키고, Casual RPG VFX 에셋의 첨부한 스크린샷과 같은 이펙트를 시전한 캐릭터몸에 맞게 발생시켜줘.」
// ④ 「지팡이 주력 스킬 시전 시 타겟이 된 대상에게 Stylized Earth Skills에셋의 "FX_EarthSkill_Summon_04" 이펙트를 재생시켜 공격하고
// 타겟이 된 대상 및 주변의 적에게도 광역 피해 및 스턴 효과를 1.5초간 부여해줘」
// ⑤ 「지팡이 각성 스킬 시전 시간도 0.5초로 줄이고, 주력 스킬과 동일하게 모으기 이펙트 및 캐릭터 주변 시전 이펙트를 적용해. 그리고 시전한 스킬을
// FX_Earthskill_Path_03_1을 캐릭터를 중심으로 시전시키켜 범위 내 명중 된 적을 2m 띄운 후 떨어질 때 2초간 기절 시키게 해줘.」
// ⑥ 「지팡이 스킬 3타에는 타겟이 된 대상에게 FX_EarthSkill_Popup_04 이펙트를 재생시켜줘. 이 공격에 맞은 적은 피해를 받을 때 1.5m 띄워졌다가 0.5초 기절해야 해.」
//
// ■ 시전 — CombatEvents.SkillCast(시전 시작)에 castSkills 의 스킬이면 무기 끝에 모으기(chargeFx) · 발밑에 몸 기둥(auraFx · 몸 크기 비례) — 둘 다 따라가고
// 시전 시간 + life 뒤 fade 초 동안 흐려진다
// ■ 주력 · 각성 — 원본 Set() 의 훅 1줄(TakeOver)이 원본 진행(소용돌이 15 초 · 운석 3.75 초)을 건너뛴다: center(대상 · 시전자) 발밑에 fx →
// hitDelay(+ 중심에서 거리 / spreadSpeed) 뒤 radius 안의 적에게 원본 투사체 ProcessCollider(스킬 피해 · 피격 반응) + WLLaunch(띄우기 · 기절) →
// endAfter 뒤 원본 투사체 끄기(풀). 진행 중엔 원본 겉모습 · 판정 상자 · Update 를 끈다(끈 뒤 되돌림)
// ■ 3타 — 투사체 Set 끝 훅(WLProjectileVisual.OnSet → OnProjectileSet)이 쏘는 순간 애니메이터 현재 상태가 popup.stateName(attack3)이면 그 투사체를 표시 →
// 그 투사체의 피해 확정(HitConfirmed)에 맞은 적 발밑에 popup fx + WLLaunch(띄우기 · 내려온 뒤 기절)
// ■ 값 = Resources/WL/WLStaffEarth.json (C45) · enabled 0 = 구독 0 · 훅은 false(원본 그대로)
// 🔴 Debug.LogError 금지(원본 ErrorLogHook 이 팝업을 띄운다) — 예외는 LastLog 로만
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using WL.Combat.Core;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLStaffEarthFx
{
public string prefab = "";
public float scale = 1f;
public int bodyScale; // 1 = scale × 시전자 몸 크기 비례(WLImpactFx.SizeFactor)
public float[] offset = new float[3]; // 시전자 정면 기준 (오른쪽, 위, 앞) m
public float[] euler = new float[3];
public float life = 2f; // 남는 시간(초) — 시전 이펙트는 시전 시간 + life
public float fade = 0.4f; // 0 보다 크면 마지막 fade 초 동안 흐려지며 사라진다(WLFxFade)
public int follow; // 1 = 무기 끝 · 시전자를 따라간다
public string socket = ""; // 모으기 무기 소켓 이름(빈 값 = 무기가 달린 켜진 첫 소켓)
public int head; // #963 1 = 무기 메시 머리(로컬 경계 긴 축 먼 쪽 끝 − headPull · 끝 빛과 같은 점) · 0 = 이전(소켓 아래 렌더러 월드 상자 꼭대기 · 기울면 머리에서 벗어남)
public float headPull = 0.08f;
}
[System.Serializable]
public class WLStaffEarthSkill
{
public string skill = ""; // 원본 스킬 클래스 이름(Skill_Hurricane · Skill_MeteoStorm)
public int classId; // #975 0 = 모든 직업 · > 0 = 시전자 직업이 이 값일 때만(같은 스킬 클래스를 직업별로 다르게 — 해머 기본 = 운석 소환 복사본 → 대지 줄기) · 직업이 맞는 행이 0 행보다 먼저
public string center = "target"; // target = 대상 발밑 · caster = 시전자 발밑
public WLStaffEarthFx fx = new WLStaffEarthFx();
public float hitDelay = 0.4f; // fx 뒤 판정까지(초)
public float spreadSpeed; // 0 보다 크면 중심에서 거리 / 이 속도(m/s)만큼 판정이 늦어진다(퍼져 나가는 바위 줄기)
public float radius = 2.5f; // 판정 반경(m · 적 몸 반경을 더함)
public float launchHeight; // 띄우기 높이(m · 0 = 띄우지 않음)
public float launchSeconds = 0.6f; // 떠 있는 시간(초)
public float stunSeconds; // 내려온 뒤 기절(초)
public float endAfter = 1f; // 마지막 판정 뒤 원본 투사체를 끄기까지(초)
// #963 PD 「지팡이 각성 = Hyper Casual FX Pack Vol.2 메테오 — 타겟 대상 주위 5 m 에 운석 10개를 랜덤 낙하 · 낙하 자리의 적에게 피해 · 맞은 적 0.5 초 스턴」 —
// meteors > 0 = 메테오 방식: meteorInterval 마다 center 둘레 meteorArea(m) 원 안 무작위 자리에 fx(운석 1개 = 낙하 + 충돌)를 띄우고 meteorImpact 초 뒤(사본이 땅에 닿는 시각) 그 자리 radius 안 적에게 원본 투사체 피해 + 기절(stunSeconds) ·
// meteorYaw = 운석이 날아오는 쪽(카메라 수평 정면을 이 각만큼 돌린 방향 · 사본 로컬 +Z 위쪽 앞에서 떨어진다)
public int meteors;
public float meteorArea = 5f, meteorInterval = 0.12f, meteorImpact = 0.67f, meteorYaw = 30f;
// #983 PD 「오브 기본 스킬 시전 시 플레이어의 전방(공격 대상이 있는 방향)으로 번개 3발이 순차적으로 낙하하며 번개 피해가 전진하며 피해를 입히는 형태 (쾅, 쾅, 쾅! 0.3초 간격으로 전진 된 위치에 순차 낙하하는 번개)」 —
// meteorPattern "line" = 무작위 대신 시전 순간 시전자 자리에서 대상 쪽(없으면 정면) 직선 위 meteorStart + meteorStep × i(m) 자리에 차례로(이펙트 앞 = 그 방향)
public string meteorPattern = "";
public float meteorStart = 1.5f, meteorStep = 1.8f;
}
/// <summary>#966 PD 「비수 각성 스킬에 시전 모션 및 시전 이펙트 — Hyper Casual FX Pack Vol.2 스크린샷 이펙트를 캐릭터에게 한번 재생시킨 후 발동」 — 이 원본 스킬(e_Skill)의 시전(SkillCast) 순간 시전자에게 fx(시전 시간 + life · follow 1 = 따라감)</summary>
[System.Serializable]
public class WLSkillCastFx
{
public string skill = "";
public WLStaffEarthFx fx = new WLStaffEarthFx();
}
[System.Serializable]
public class WLStaffEarthPopup
{
public int classId = 10302;
public string stateName = "attack3"; // 쏘는 순간 애니메이터 현재 상태 이름(지팡이 콤보는 애니메이터 전환으로 이어져 CurAnim · ComboTracker 단계가 1 에 머문다)
public WLStaffEarthFx fx = new WLStaffEarthFx();
public float launchHeight = 1.5f, launchSeconds = 0.6f, stunSeconds = 0.5f;
// #967 PD 「지팡이 공격 마지막 타 — 기본 공격 투사체 대신 메테오(각성 스킬에 사용중인 이펙트) 한 발이 대상에게 떨어지도록」 — 1 = 3타 투사체를 쏘는 순간(겉모습을 붙인 뒤) 넘겨받아
// 숨기고 meteorRule(메테오 방식 · 운석 1개 · 대상 자리)로 떨어뜨려 땅에 닿는 순간 그 투사체 피해 · 이 경우 바위 솟음(fx)은 띄우지 않는다 · 0 = 이전(투사체 + 맞은 자리 바위)
public int meteor;
public WLStaffEarthSkill meteorRule = new WLStaffEarthSkill();
// #983 PD 「오브 "기본 공격" 마지막 타(3타)에서는 공격 직후 다시 모션을 캔슬하는 모습인데, 공격 상태의 손을 내지른 상태를 잠시 유지한 채 투사체를 날리지 말고 타겟중인 대상에게 번개가 1발을 낙하」 —
// holdSeconds > 0 = 넘겨받는 순간(발사 시점) holdDelay 뒤 애니메이터를 holdSeconds 동안 멈춤(자세 유지 · 다른 상태로 바뀌거나 다른 코드가 속도를 바꾸면 바로 손 뗌)
public float holdDelay, holdSeconds;
}
[System.Serializable]
public class WLStaffEarthJson
{
public int enabled = 0;
public string[] castSkills = new string[0];
public WLStaffEarthFx chargeFx = new WLStaffEarthFx();
public WLStaffEarthFx auraFx = new WLStaffEarthFx();
public WLStaffEarthSkill[] skills = new WLStaffEarthSkill[0];
public WLStaffEarthPopup popup = new WLStaffEarthPopup();
public WLStaffEarthPopup[] popups = new WLStaffEarthPopup[0]; // #983 popup 과 같은 규칙을 다른 직업에(오브 10301 3타 = 번개 1발 · 자세 유지) — 직업 · 상태가 맞는 첫 규칙(popup 먼저)
// #963 PD 「플레이어가 마법을 시전할 때는 Hyper Casual FX Pack Vol.1 의 마법진을 플레이어가 있는 바닥에 재생되었다가 시전(주황색 계열)」 — castCircleClasses 직업의 스킬 시전(SkillCast)마다 발밑에 castCircle(시전 시간 + life · 따라감)
public int[] castCircleClasses = new int[0];
public WLStaffEarthFx castCircle = new WLStaffEarthFx();
public WLSkillCastFx[] skillCastFx = new WLSkillCastFx[0]; // #966 스킬별 시전 이펙트(시전자)
}
public static class WLStaffEarth
{
public const string ResourcesPath = "WL/WLStaffEarth";
public static int Casts, TakeOvers, Hits, Launches, Marked, Popups, Errors, Circles, Meteors, SkillCastFxShown, Attack3Meteors; // #967 Attack3Meteors = 3타 투사체를 메테오로 넘겨받은 수
static ProjectileBase s_pendingMeteor; // #967 OnProjectileSet 이 고른 3타 투사체 — 겉모습 훅이 끝난 뒤(AfterProjectileSet) 넘겨받는다
static WLStaffEarthPopup s_pendingPopup; // #983 그 투사체에 맞은 3타 규칙(popup · popups)
public static int Holds; public static string HoldLog = ""; // #983 3타 자세 유지 횟수 · 마지막 기록
public static string MeteorSrc = ""; public static Vector3 MeteorCenter; public static readonly List<Vector3> MeteorPts = new List<Vector3>(), MeteorCenters = new List<Vector3>(); // #964 검증용(메테오 중심 출처 · 낙하 자리)
public static string LastLog = "";
static WLStaffEarthJson s_cfg;
static readonly Dictionary<string, GameObject> s_prefabs = new Dictionary<string, GameObject>();
static readonly Dictionary<int, WLStaffEarthPopup> s_marked = new Dictionary<int, WLStaffEarthPopup>();
static WLStaffEarthRunner s_runner;
static MethodInfo s_off;
static WLStaffEarthJson Cfg
{
get
{
if (s_cfg != null) return s_cfg;
s_cfg = new WLStaffEarthJson();
var ta = Resources.Load<TextAsset>(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; s_prefabs.Clear(); s_marked.Clear(); s_pendingMeteor = null; s_pendingPopup = null;
CombatEvents.SkillCast.Remove(OnSkillCast); CombatEvents.HitConfirmed.Remove(OnHitConfirmed);
if (Cfg.enabled == 0) return;
CombatEvents.SkillCast.Add(OnSkillCast); CombatEvents.HitConfirmed.Add(OnHitConfirmed);
}
// ── 시전: 모으기(무기 끝) · 몸 기둥(발밑) ──────────────────────────────────────────
static void OnSkillCast(in SkillCastEvent e)
{
try
{
var c = Cfg; var pc = e.actor as PCActor;
if (pc == null || e.skill == null) return;
float cast = Mathf.Max(0f, e.skill.f_CastingTime);
if (pc.IsMainPC() && c.castCircleClasses != null && System.Array.IndexOf(c.castCircleClasses, pc.Get_ID()) >= 0 && Spawn(c.castCircle, pc, pc.transform, pc.Get_position(), cast) != null) Circles++; // #963 마법 시전 마법진
if (pc.IsMainPC() && c.skillCastFx != null) // #966 스킬별 시전 이펙트(비수 각성 = HCFX Energy_01 사본)
foreach (var sf in c.skillCastFx) if (sf != null && sf.skill == e.skill.e_Skill.ToString() && Spawn(sf.fx, pc, pc.transform, pc.Get_position(), cast) != null) SkillCastFxShown++;
if (c.castSkills == null || System.Array.IndexOf(c.castSkills, e.skill.e_Skill.ToString()) < 0) return;
var sock = Socket(pc, c.chargeFx.socket);
if (Spawn(c.chargeFx, pc, sock, sock != null ? Tip(sock, c.chargeFx) : pc.Get_Center_position(), cast) != null) Casts++;
Spawn(c.auraFx, pc, pc.transform, pc.Get_position(), cast);
}
catch (System.Exception ex) { Errors++; LastLog = "시전 이펙트 실패 — " + ex.Message; }
}
/// <summary>무기 소켓 — 이름이 맞는 켜진 소켓, 없으면 무기(자식)가 달린 켜진 첫 소켓.</summary>
static Transform Socket(PCActor pc, string name)
{
if (pc == null || pc.tfs_weapon == null) return null;
Transform first = null;
foreach (var s in pc.tfs_weapon)
{
if (s == null || !s.gameObject.activeInHierarchy) continue;
if (!string.IsNullOrEmpty(name) && s.name == name) return s;
if (first == null && s.childCount > 0) first = s;
}
return first;
}
/// <summary>무기 끝(소켓 아래 렌더러 경계의 가장 높은 곳) — 렌더러가 없으면 소켓 자리.</summary>
static Vector3 Tip(Transform sock, WLStaffEarthFx fx)
{
if (fx != null && fx.head != 0) // #963 무기 메시 머리(끝 빛과 같은 점) — 이전 방식은 기운 지팡이에서 월드 상자 꼭대기 · 가운데라 머리에서 떨어졌다(PD 「모으는 연출이 둥근 머리에 안 모인다」)
for (int i = 0; i < sock.childCount; i++)
{
var w = sock.GetChild(i); if (!w.gameObject.activeInHierarchy) continue;
Vector3 hl; if (WLWeaponTipGlow.HeadLocal(w, sock, fx.headPull, out hl)) return w.TransformPoint(hl);
}
bool any = false; Bounds b = new Bounds(sock.position, Vector3.zero);
foreach (var r in sock.GetComponentsInChildren<Renderer>(false))
{
if (r is ParticleSystemRenderer || !r.enabled) continue;
if (!any) { b = r.bounds; any = true; } else b.Encapsulate(r.bounds);
}
return any ? new Vector3(b.center.x, b.max.y, b.center.z) : sock.position;
}
static GameObject Spawn(WLStaffEarthFx fx, Actor actor, Transform parent, Vector3 pos, float extra)
{
var fwd = actor != null ? actor.transform.forward : Vector3.forward;
var go = SpawnAt(fx, actor, pos, fwd, extra);
if (go != null && fx.follow != 0 && parent != null) go.transform.SetParent(parent, true); // 월드 크기 · 자리 유지한 채 따라감
return go;
}
internal static GameObject SpawnAt(WLStaffEarthFx fx, Actor actor, Vector3 pos, Vector3 fwd, float extra)
{
if (fx == null || string.IsNullOrEmpty(fx.prefab)) return null;
var p = Prefab(fx.prefab); if (p == null) return null;
fwd.y = 0f; if (fwd.sqrMagnitude < 1e-4f) fwd = Vector3.forward;
var basis = Quaternion.LookRotation(fwd.normalized);
var go = Object.Instantiate(p, pos + basis * V(fx.offset), basis * Quaternion.Euler(V(fx.euler)));
float s = fx.scale * (fx.bodyScale != 0 && actor != null ? WL.Combat.Impact.WLImpactFx.SizeFactor(actor) : 1f);
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; m.scalingMode = ParticleSystemScalingMode.Hierarchy; }
go.transform.localScale = Vector3.one * s;
float total = Mathf.Max(0f, extra) + Mathf.Max(0.1f, fx.life);
if (fx.fade > 0f) WLFxFade.Attach(go, Mathf.Max(0f, total - fx.fade), fx.fade);
else Object.Destroy(go, total);
LastLog = "생성 " + fx.prefab + " @" + pos.ToString("F1") + " ×" + s.ToString("F2");
return go;
}
// ── 주력 · 각성: 원본 Set() 훅 ─────────────────────────────────────────────────────
/// <summary>원본 Set() 훅(1줄) — 표에 있는 스킬이면 원본 진행을 건너뛰고 WL 진행을 시작한다(true). 꺼져 있거나 표에 없으면 false(원본 그대로).</summary>
public static bool TakeOver(ProjectileBase pb)
{
try
{
var c = Cfg; if (c.enabled == 0 || pb == null) return false;
var pd = pb.Get_ProjectTileData(); if (pd == null || DSUtil.CheckNull(pd.shooter)) return false;
WLStaffEarthSkill r = null; int cls = pd.shooter.Get_ID(); // #975 직업이 맞는 행 먼저 · 없으면 classId 0 행
if (c.skills != null) foreach (var x in c.skills) if (x != null && x.skill == pb.GetType().Name && x.classId == cls) { r = x; break; }
if (r == null && c.skills != null) foreach (var x in c.skills) if (x != null && x.skill == pb.GetType().Name && x.classId == 0) { r = x; break; }
if (r == null) return false;
if (s_runner == null) { var go = new GameObject("[WL935] StaffEarth"); go.hideFlags = HideFlags.HideInHierarchy; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent<WLStaffEarthRunner>(); }
s_runner.Begin(pb, pd, r);
TakeOvers++;
return true;
}
catch (System.Exception ex) { Errors++; LastLog = "넘겨받기 실패 — " + ex.Message; return false; }
}
/// <summary>center 에서 reach(≤ radius) 안의 적(아직 안 맞은)에게 원본 투사체 피해 + 띄우기 · 기절.</summary>
internal static void HitAll(ProjectileBase pb, Actor shooter, Vector3 center, WLStaffEarthSkill r, float reach, HashSet<Actor> done)
{
var info = ActorInfo.Ins; if (info == null || pb == null || DSUtil.CheckNull(shooter)) return;
var list = info.Get_EnemyActors(shooter.IsEnemy(), center, r.radius + 3f); if (list == null) return;
try
{
for (int k = 0; k < list.Count; k++)
{
var a = list[k]; if (a == null || a.IsDead() || done.Contains(a)) continue;
var v = a.Get_position() - center; v.y = 0f;
if (v.magnitude > Mathf.Min(reach, r.radius) + WL.Combat.WLCombatMotionSettings.BodyRadius(a)) continue;
var col = a.GetComponent<Collider>(); if (col == null || !col.enabled) continue;
done.Add(a);
if (!pb.ProcessCollider(col, false)) continue;
Hits++;
if ((r.launchHeight > 0f || r.stunSeconds > 0f) && WLLaunch.Apply(a, r.launchHeight, r.launchSeconds, r.stunSeconds)) Launches++;
}
}
finally { info.Release_Actors(list); }
}
/// <summary>원본 투사체 끄기(풀 반납) — ProjectileBase.Off(bool, bool) 는 protected 라 리플렉션.</summary>
internal static void OffOriginal(ProjectileBase pb)
{
if (pb == null || !pb.gameObject.activeInHierarchy) return;
if (s_off == null) s_off = typeof(ProjectileBase).GetMethod("Off", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, null, new[] { typeof(bool), typeof(bool) }, null);
try { if (s_off != null) s_off.Invoke(pb, new object[] { true, false }); else pb.gameObject.SetActive(false); }
catch (System.Exception ex) { Errors++; LastLog = "Off 실패 — " + ex.Message; pb.gameObject.SetActive(false); }
}
// ── 3타: 쏘는 순간 표시 → 피해 확정에 바위 솟음 · 띄우기 · 기절 ─────────────────────
/// <summary>투사체 Set 끝 훅(WLProjectileVisual.OnSet 첫 줄) — 쏘는 순간 애니메이터 상태가 popup.stateName 이면 그 투사체(인스턴스)를 표시 · 아니면 지운다(풀 재사용).</summary>
public static void OnProjectileSet(ProjectileBase pb)
{
try
{
var c = Cfg; if (c.enabled == 0 || pb == null) return;
var pd = pb.Get_ProjectTileData(); var sh = pd != null ? pd.shooter as PCActor : null;
var an = sh != null ? sh.m_animation : null;
WLStaffEarthPopup hit = null;
if (sh != null && !pd.IsSkill() && an != null)
for (int i = -1; i < (c.popups != null ? c.popups.Length : 0) && hit == null; i++) // #983 popup 먼저 · 이어서 popups(직업별)
{
var p = i < 0 ? c.popup : c.popups[i]; if (p == null || sh.Get_ID() != p.classId) continue;
// #967 3타 발사 이벤트(attack3 0.08 s)가 attack2 → attack3 전환 중에 나와 현재 상태가 아직 attack2 였다(Play 실측 3타 표시 0) → 전환 중이면 다음 상태도 본다
if (an.GetCurrentAnimatorStateInfo(0).IsName(p.stateName) || (an.IsInTransition(0) && an.GetNextAnimatorStateInfo(0).IsName(p.stateName))) hit = p;
}
bool meteor = hit != null && hit.meteor != 0 && hit.meteorRule != null && hit.meteorRule.meteors > 0;
if (meteor) { s_pendingMeteor = pb; s_pendingPopup = hit; } // #967 3타 = 메테오(바위 솟음 표시 안 함)
if (hit != null && !meteor) { s_marked[pb.GetInstanceID()] = hit; Marked++; } else s_marked.Remove(pb.GetInstanceID());
}
catch (System.Exception ex) { Errors++; LastLog = "3타 표시 실패 — " + ex.Message; }
}
/// <summary>#967 투사체 Set 끝 훅(WLProjectileVisual.OnSet 마지막 · 겉모습 자식까지 붙은 뒤) — OnProjectileSet 이 고른 3타 투사체를 메테오 러너로 넘겨받는다(겉모습 · 판정 상자 · 이동을 끄고 대상 자리에 운석 1개).</summary>
public static void AfterProjectileSet(ProjectileBase pb)
{
if (pb == null || !ReferenceEquals(s_pendingMeteor, pb)) return;
var rule = s_pendingPopup; s_pendingMeteor = null; s_pendingPopup = null;
try
{
var c = Cfg; var pd = pb.Get_ProjectTileData(); if (pd == null || DSUtil.CheckNull(pd.shooter)) return;
if (rule == null) rule = c.popup;
if (s_runner == null) { var go = new GameObject("[WL935] StaffEarth"); go.hideFlags = HideFlags.HideInHierarchy; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent<WLStaffEarthRunner>(); }
s_runner.Begin(pb, pd, rule.meteorRule);
if (rule.holdSeconds > 0f) s_runner.Hold(pd.shooter as PCActor, rule); // #983 손 내지른 자세 유지
Attack3Meteors++;
}
catch (System.Exception ex) { Errors++; LastLog = "3타 메테오 실패 — " + ex.Message; }
}
static void OnHitConfirmed(in HitConfirmedEvent e)
{
try
{
var pb = e.projectile != null ? e.projectile.m_ProjectileBase : null; WLStaffEarthPopup p;
if (pb == null || DSUtil.CheckNull(e.victim) || !s_marked.TryGetValue(pb.GetInstanceID(), out p) || p == null) return;
var fwd = !DSUtil.CheckNull(e.attacker) ? e.victim.Get_position() - e.attacker.Get_position() : e.victim.transform.forward;
SpawnAt(p.fx, e.attacker, e.victim.Get_position(), fwd, 0f);
Popups++;
if ((p.launchHeight > 0f || p.stunSeconds > 0f) && WLLaunch.Apply(e.victim, p.launchHeight, p.launchSeconds, p.stunSeconds)) Launches++;
}
catch (System.Exception ex) { Errors++; LastLog = "3타 명중 실패 — " + ex.Message; }
}
static GameObject Prefab(string path)
{
GameObject p;
if (s_prefabs.TryGetValue(path, out p) && p != null) return p;
p = Resources.Load<GameObject>(path);
if (p == null) LastLog = "이펙트 없음 " + path;
s_prefabs[path] = p;
return p;
}
static Vector3 V(float[] a) { return a != null && a.Length >= 3 ? new Vector3(a[0], a[1], a[2]) : Vector3.zero; }
}
/// <summary>#935 넘겨받은 원본 스킬 투사체 1 개의 진행(이펙트 → 판정 → 끄기). 숨김 러너 1 개가 코루틴을 돌린다(원본 투사체가 먼저 꺼져도 판정은 이어감).</summary>
public sealed class WLStaffEarthRunner : MonoBehaviour
{
public void Begin(ProjectileBase pb, ProjectileData pd, WLStaffEarthSkill r) { StartCoroutine(Run(pb, pd, r)); }
/// <summary>#983 3타 자세 유지 — holdDelay 뒤 그 상태(stateName)면 애니메이터를 holdSeconds 동안 멈춘다. 그 사이 상태가 바뀌거나 전환이 시작되거나 다른 코드가 속도를 바꾸면 바로 손 뗀다(내가 멈춘 그대로일 때만 되돌림).</summary>
public void Hold(PCActor pc, WLStaffEarthPopup p) { if (pc != null && pc.m_animation != null && p != null) StartCoroutine(HoldRun(pc, p)); }
IEnumerator HoldRun(PCActor pc, WLStaffEarthPopup p)
{
if (p.holdDelay > 0f) yield return new WaitForSeconds(p.holdDelay);
var an = pc != null ? pc.m_animation : null; if (an == null) yield break;
var st = an.GetCurrentAnimatorStateInfo(0);
if (!st.IsName(p.stateName) || an.IsInTransition(0)) { WLStaffEarth.HoldLog = "안 멈춤(상태 " + (st.IsName(p.stateName) ? "전환 중" : "다름") + ")"; yield break; }
float sp = an.speed, n0 = st.normalizedTime, t = 0f; string why = "끝";
an.speed = 0f; WLStaffEarth.Holds++;
while (t < p.holdSeconds)
{
yield return null; t += Time.deltaTime;
if (pc == null || an == null) { why = "PC 없음"; break; }
if (an.speed != 0f) { why = "다른 코드가 속도 바꿈"; break; }
if (an.IsInTransition(0) || !an.GetCurrentAnimatorStateInfo(0).IsName(p.stateName)) { why = "상태 바뀜"; break; }
}
if (an != null && an.speed == 0f) an.speed = sp > 0f ? sp : 1f;
WLStaffEarth.HoldLog = "멈춤 n" + n0.ToString("F2") + " · " + t.ToString("F2") + " s · " + why;
}
/// <summary>#963 운석 1개 — 땅에 닿는 시각(meteorImpact)에 그 자리 radius 안 적(운석마다 따로 셈)에게 원본 투사체 피해 + 기절.</summary>
IEnumerator MeteorHit(ProjectileBase pb, Actor shooter, Vector3 pt, WLStaffEarthSkill r)
{
if (r.meteorImpact > 0f) yield return new WaitForSeconds(r.meteorImpact);
if (pb == null || DSUtil.CheckNull(shooter)) yield break;
WLStaffEarth.HitAll(pb, shooter, pt, r, r.radius, new HashSet<Actor>());
}
IEnumerator Run(ProjectileBase pb, ProjectileData pd, WLStaffEarthSkill r)
{
// 원본 겉모습 · 판정 상자 · Update(대상 따라가기 · 대상 없으면 끄기) 끔 — 끈 뒤 되돌린다(풀 재사용)
var ren = new List<Renderer>(); foreach (var x in pb.GetComponentsInChildren<Renderer>(true)) if (x.enabled) { x.enabled = false; ren.Add(x); }
var col = new List<Collider>(); foreach (var x in pb.GetComponentsInChildren<Collider>(true)) if (x.enabled) { x.enabled = false; col.Add(x); }
foreach (var ps in pb.GetComponentsInChildren<ParticleSystem>(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
bool wasEnabled = pb.enabled; pb.enabled = false;
var shooter = pd.shooter; var target = pd.target;
if (r.meteors > 0) // #963 메테오 — 운석마다 무작위 자리 · 땅에 닿는 시각에 그 자리 판정 + 기절
{
// #964 PD 「지팡이 각성 스킬이 플레이어 주위가 아니라 타겟 된 적 중심으로 3 m 범위 내 랜덤」 — 투사체 대상이 비거나 죽었으면 PC 가 겨눈 대상(Get_Target)으로 · 그것도 없으면 이전처럼 시전자 앞 3 m
WLStaffEarth.MeteorSrc = !DSUtil.CheckNull(target) && !target.IsDead() ? "투사체 대상" : "투사체 대상 없음";
if ((DSUtil.CheckNull(target) || target.IsDead()) && shooter != null) { var t2 = shooter.Get_Target(); if (!DSUtil.CheckNull(t2) && !t2.IsDead()) { target = t2; WLStaffEarth.MeteorSrc += " → PC 대상"; } else WLStaffEarth.MeteorSrc += " → 시전자 앞"; }
WLStaffEarth.MeteorPts.Clear(); WLStaffEarth.MeteorCenters.Clear();
Vector3 last = DSUtil.CheckNull(target) ? shooter.Get_position() + shooter.transform.forward * 3f : target.Get_position();
var cam = Camera.main; Vector3 cf = cam != null ? cam.transform.forward : shooter.transform.forward; cf.y = 0f; if (cf.sqrMagnitude < 1e-4f) cf = Vector3.forward;
Vector3 from = Quaternion.Euler(0f, r.meteorYaw, 0f) * cf.normalized;
// #983 직선(오브 기본) — 시전 순간 시전자 자리 · 대상 쪽 방향을 고정하고 그 위로 전진(대상 없으면 시전자 정면)
bool line = r.meteorPattern == "line"; Vector3 lineO = shooter.Get_position(), lineD = !DSUtil.CheckNull(target) && !target.IsDead() ? target.Get_position() - lineO : shooter.transform.forward;
lineD.y = 0f; if (lineD.sqrMagnitude < 1e-4f) { lineD = shooter.transform.forward; lineD.y = 0f; } if (lineD.sqrMagnitude < 1e-4f) lineD = Vector3.forward; lineD.Normalize();
if (line) from = lineD;
for (int i = 0; i < r.meteors; i++)
{
if (r.center != "caster" && !DSUtil.CheckNull(target) && !target.IsDead()) last = target.Get_position();
Vector3 c0 = line ? lineO : r.center == "caster" ? shooter.Get_position() : last;
Vector2 d = line ? Vector2.zero : Random.insideUnitCircle * Mathf.Max(0f, r.meteorArea);
Vector3 pt = line ? lineO + lineD * (r.meteorStart + r.meteorStep * i) : new Vector3(c0.x + d.x, c0.y, c0.z + d.y);
WLStaffEarth.MeteorPts.Add(pt); WLStaffEarth.MeteorCenters.Add(c0); WLStaffEarth.MeteorCenter = c0; // #964 검증용(낙하 자리 · 그때 중심)
WLStaffEarth.SpawnAt(r.fx, shooter, pt, from, 0f); WLStaffEarth.Meteors++;
StartCoroutine(MeteorHit(pb, shooter, pt, r));
if (r.meteorInterval > 0f && i < r.meteors - 1) yield return new WaitForSeconds(r.meteorInterval);
}
WLStaffEarth.LastLog = r.skill + " 메테오 " + r.meteors + "개 · " + (line ? "직선 " + r.meteorStart + " + " + r.meteorStep + " m 씩" : "반경 " + r.meteorArea + " m");
yield return new WaitForSeconds(Mathf.Max(0f, r.meteorImpact) + Mathf.Max(0f, r.endAfter));
if (pb != null) { pb.enabled = wasEnabled; WLStaffEarth.OffOriginal(pb); }
foreach (var x in ren) if (x != null) x.enabled = true;
foreach (var x in col) if (x != null) x.enabled = true;
yield break;
}
Vector3 center = r.center == "caster" || DSUtil.CheckNull(target) ? shooter.Get_position() : target.Get_position();
Vector3 fwd = center - shooter.Get_position(); if (r.center == "caster" || fwd.sqrMagnitude < 0.01f) fwd = shooter.transform.forward;
WLStaffEarth.SpawnAt(r.fx, shooter, center, fwd, 0f);
WLStaffEarth.LastLog = r.skill + " → " + r.fx.prefab + " @" + center.ToString("F1");
if (r.hitDelay > 0f) yield return new WaitForSeconds(r.hitDelay);
var done = new HashSet<Actor>();
if (r.spreadSpeed > 0f)
{
float t0 = Time.time, last = r.radius / r.spreadSpeed + 0.05f;
while (true)
{
float t = Time.time - t0;
WLStaffEarth.HitAll(pb, shooter, center, r, t * r.spreadSpeed, done);
if (t >= last) break;
yield return null;
}
}
else WLStaffEarth.HitAll(pb, shooter, center, r, r.radius, done);
if (r.endAfter > 0f) yield return new WaitForSeconds(r.endAfter);
if (pb != null) { pb.enabled = wasEnabled; WLStaffEarth.OffOriginal(pb); }
foreach (var x in ren) if (x != null) x.enabled = true;
foreach (var x in col) if (x != null) x.enabled = true;
}
}
}