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

310 lines
20 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 = ""; // 모으기 무기 소켓 이름(빈 값 = 무기가 달린 켜진 첫 소켓)
}
[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; // 마지막 판정 뒤 원본 투사체를 끄기까지(초)
}
[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();
}
public static class WLStaffEarth
{
public const string ResourcesPath = "WL/WLStaffEarth";
public static int Casts, TakeOvers, Hits, Launches, Marked, Popups, Errors;
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 || c.castSkills == null || System.Array.IndexOf(c.castSkills, e.skill.e_Skill.ToString()) < 0) return;
float cast = Mathf.Max(0f, e.skill.f_CastingTime);
var sock = Socket(pc, c.chargeFx.socket);
if (Spawn(c.chargeFx, pc, sock, sock != null ? Tip(sock) : 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)
{
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)); }
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;
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;
}
}
}