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

364 lines
25 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 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;
}
[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;
}
[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();
// #963 PD 「플레이어가 마법을 시전할 때는 Hyper Casual FX Pack Vol.1 의 마법진을 플레이어가 있는 바닥에 재생되었다가 시전(주황색 계열)」 — castCircleClasses 직업의 스킬 시전(SkillCast)마다 발밑에 castCircle(시전 시간 + life · 따라감)
public int[] castCircleClasses = new int[0];
public WLStaffEarthFx castCircle = new WLStaffEarthFx();
}
public static class WLStaffEarth
{
public const string ResourcesPath = "WL/WLStaffEarth";
public static int Casts, TakeOvers, Hits, Launches, Marked, Popups, Errors, Circles, Meteors;
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 HashSet<int> s_marked = new HashSet<int>();
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();
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 (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;
WLStaffEarthSkill r = null;
if (c.skills != null) foreach (var x in c.skills) if (x != null && x.skill == pb.GetType().Name) { r = x; break; }
if (r == null) return false;
var pd = pb.Get_ProjectTileData(); if (pd == null || DSUtil.CheckNull(pd.shooter)) 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;
bool mark = sh != null && !pd.IsSkill() && sh.Get_ID() == c.popup.classId && an != null && an.GetCurrentAnimatorStateInfo(0).IsName(c.popup.stateName);
if (mark) { s_marked.Add(pb.GetInstanceID()); Marked++; } else s_marked.Remove(pb.GetInstanceID());
}
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;
if (pb == null || DSUtil.CheckNull(e.victim) || !s_marked.Contains(pb.GetInstanceID())) return;
var p = Cfg.popup;
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>#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;
for (int i = 0; i < r.meteors; i++)
{
if (r.center != "caster" && !DSUtil.CheckNull(target) && !target.IsDead()) last = target.Get_position();
Vector3 c0 = r.center == "caster" ? shooter.Get_position() : last;
Vector2 d = Random.insideUnitCircle * Mathf.Max(0f, r.meteorArea);
Vector3 pt = 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 + "개 · 반경 " + 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;
}
}
}