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

286 lines
21 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.

// ─────────────────────────────────────────────────────────────────────────────
// WLProjectileVisual.cs — 보이지 않는 기본 공격 투사체에 시각 효과를 붙인다 (PD 2026-10-10)
//
// PD 원문: 「활 및 마법 투사체에 적합한 투사체를 배치해서 자연스러운 공격이 가능하도록 해줘」
//
// ■ 실측 — 지팡이 직업의 기본 공격 투사체 `Projectile_10301`·`Projectile_10302` 는 Projectile_Strait 에 렌더러·파티클이
// 하나도 없다(보이지 않는 직선 판정 상자 + 명중 이펙트 Effect_Hit_1030x 만). 활 `Projectile_10402` 는 화살 메시가 있다.
// ■ 방법 — 원본 프리팹·EffectList 표(수명)·판정은 그대로 두고, 투사체 인스턴스(ProjectileInfo 풀)에 **시각 자식**
// (Resources/WL/WL_MagicBoltVisual 등 · WL 프리팹)을 1회 붙인다. 풀 재사용 때도 자식이 남아 다시 보인다.
// 규칙 = Resources/WL/WLProjectileVisual.json (투사체 이름 → 시각 프리팹 · 배율).
// ■ 시점 — #923 원본 ProjectileBase.Set 끝 훅 1줄(OnSet) · 쏘는 그 순간(켜진 직후 · 첫 프레임 전) 인스턴스에 바로 붙인다.
// (#923 전 = Shoot_Projectile 첫 줄의 HitboxSpawned 에서 풀을 뒤졌다 — 투사체가 만들어지기 전이라, 풀에 이미 붙인 화살이 하나라도
// 있으면 「찾음」으로 끝나 새로 늘어난 풀 인스턴스는 안 붙은 채 원래 화살 메시로 날았다 · PD 「구 버전 화살 이펙트가 간헐적으로」)
// 🔴 Debug.LogError 금지.
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
namespace WL.Combat.Weapon
{
[System.Serializable]
public class WLProjectileVisualRule
{
public string projectile = "";
public string visual = "";
public float scale = 1f;
public float thickness = 1f; // #943-② PD 「발사되는 화살이 좀 더 샤프하게 두께를 줄여줘」 — 시각(일반·강화)의 굵기만 배율(입자 크기 · 3D 크기 X/Y · 늘림 길이 보정 · 궤적 폭) · 길이는 그대로
public float length = 1f; // #952-③ PD 「1, 2타 화살의 투사체 길이를 3타 투사체와 비슷하게 줄이고」 — 기본 겉모습(visual)의 길이만 배율(늘림 빌보드 lengthScale · 입자 궤적 수명 · TrailRenderer 시간 · Mesh 모드 입자의 진행 축 Z) · 굵기 그대로 · 강화(3타) 겉모습에는 안 씀
public float speedMul = 1f; // #952-③ PD 「활 투사체 속도를 2배로」 — 쏘는 순간 ProjectileData.Speed 배율(원본 prefab 은 Speed 를 직렬화하지 않아 클래스 기본값 5 + 스탯 보정 · 매 발사 적용 · 1 = 그대로)
// #839 활 3타 강화 화살 — WLBowChargeReceiver.ArmedUntil 창 안에 켜지는 인스턴스에 WLChargedMark 가 적용(크기 × chargedScale + 글로우 자식)
public string chargedVisual = "";
public float chargedScale = 1.6f;
// #919 PD 「활 3타 … 발사되는 투사체를 지금 기본 공격 투사체로 변경」 · 「1, 2타는 Master Stylized Projectile 이펙트」 — true 면 강화 화살이 기본 겉모습(~WL_Visual)을 끄고 chargedVisual 만 보인다(덧붙이기 대신 바꾸기)
public bool chargedReplaces;
// #917 PD 「화살 투사체 오브젝트 및 활 히트 이펙트를 교체(Casual RPG VFX)」 — 원래 화살 메시를 숨기고(hideMesh) · 이 투사체의 명중 이펙트(hitFx · 몸 중심 · 몸 크기 비례 hitScale)
public bool hideMesh;
// #934 PD 「지팡이 기본 공격 투사체에 세로로 긴 이펙트가 숨겨져 있는 것 같아. 세로로 긴 화살형 이펙트는 보이지 않게 해줘.」 — 원본 투사체 안 자식(투사체 기준 경로)의 렌더러를 끈다(풀 재사용에도 꺼진 채)
public string[] hideNames = new string[0];
public string hitFx = "";
public float hitScale = 1f;
public float hitLife = 1.5f;
// #948 PD 「마법검 3타 공격의 크기를 절반으로」 — 쏜 PC 의 콤보 단계별 겉모습(보이는 자식)·명중 이펙트 배율. 판정(루트 콜라이더)은 그대로 · 항목 없는 단계 = 1
public WLProjectileStageScale[] stageScales = new WLProjectileStageScale[0];
}
[System.Serializable]
public class WLProjectileStageScale { public int stage; public float scale = 1f; public float hitScale = 1f; }
[System.Serializable]
public class WLProjectileVisualJson
{
public WLProjectileVisualRule[] rules = new WLProjectileVisualRule[0];
}
public sealed class WLProjectileVisual : MonoBehaviour
{
public const string ResourcesPath = "WL/WLProjectileVisual";
public static int Attached, Events;
public static string LastLog = "";
static bool s_loaded;
static readonly Dictionary<string, WLProjectileVisualRule> s_rules = new Dictionary<string, WLProjectileVisualRule>();
static readonly Dictionary<string, WLProjectileVisualRule> s_byName = new Dictionary<string, WLProjectileVisualRule>(); // 인스턴스 이름(…(Clone)) → 규칙(없으면 null)
static readonly HashSet<int> s_done = new HashSet<int>();
static readonly Dictionary<string, GameObject> s_prefabs = new Dictionary<string, GameObject>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
static void ResetStatics() { s_loaded = false; s_rules.Clear(); s_byName.Clear(); s_done.Clear(); s_prefabs.Clear(); s_baseScales.Clear(); } // #948-5 도메인 리로드 끔 대비 — JSON(hideNames 등)을 Play 마다 다시 읽는다(실측: 이전 값이 도메인에 남아 연기 층까지 숨겨짐)
static void Load()
{
s_loaded = true; s_rules.Clear(); s_byName.Clear();
var ta = Resources.Load<TextAsset>(ResourcesPath);
if (ta == null) return;
WLProjectileVisualJson j = null;
try { j = JsonUtility.FromJson<WLProjectileVisualJson>(ta.text); } catch (System.Exception ex) { LastLog = "규칙 파싱 실패 — " + ex.Message; }
if (j == null || j.rules == null) return;
for (int i = 0; i < j.rules.Length; i++)
{
var r = j.rules[i];
if (r == null || string.IsNullOrEmpty(r.projectile) || (string.IsNullOrEmpty(r.visual) && string.IsNullOrEmpty(r.chargedVisual) && string.IsNullOrEmpty(r.hitFx))) continue;
s_rules[r.projectile] = r;
}
}
/// <summary>#923 원본 ProjectileBase.Set 끝 훅 — 켜진 직후(첫 프레임 그리기 전) 이 인스턴스에 규칙을 붙인다(인스턴스마다 1 회 · 풀 재사용 때는 자식이 남아 있다).</summary>
public static void OnSet(ProjectileBase pb)
{
if (pb == null) return;
WLStaffEarth.OnProjectileSet(pb); // #935 지팡이 3타 투사체 표시(쏠 때마다 · 이미 겉모습을 붙인 풀 인스턴스도)
if (!s_loaded) Load();
WLProjectileVisualRule rule;
var n = pb.name;
if (!s_byName.TryGetValue(n, out rule))
{
int k = n.IndexOf('('); var key = k > 0 ? n.Substring(0, k).TrimEnd() : n;
s_rules.TryGetValue(key, out rule); s_byName[n] = rule;
}
if (rule == null) return;
if (rule.speedMul > 0f && !Mathf.Approximately(rule.speedMul, 1f)) { var sm = pb.GetComponent<WLProjectileSpeedMul>(); if (sm == null) sm = pb.gameObject.AddComponent<WLProjectileSpeedMul>(); sm.mul = rule.speedMul; sm.Arm(pb); } // #952-③ 매 발사 · 적용은 그 프레임 LateUpdate(Projectile_Strait.Set 이 base.Set 뒤에 m_Speed 로 덮어쓰므로 — 10402 prefab m_Speed 7 실측)
if (!s_done.Contains(pb.GetInstanceID())) { Events++; if (Attach(pb, rule)) s_done.Add(pb.GetInstanceID()); }
ApplyStageScale(pb, rule); // #948 매 발사(풀 재사용 포함) — 콤보 단계별 배율
}
public static int StageScaled; public static string StageLog = "";
static readonly Dictionary<int, Vector3[]> s_baseScales = new Dictionary<int, Vector3[]>(); // 투사체 인스턴스 → 직접 자식들의 기본 localScale
/// <summary>#948 쏜 PC 의 콤보 단계(ComboTracker.LastStage)에 맞는 stageScales 항목이 있으면 보이는 직접 자식(원본 FX 노드 · ~WL_Visual)의 localScale 과 명중 이펙트 배율을 곱한다. 콜라이더가 달린 자식(판정)은 건드리지 않는다. 항목이 없으면 기본값(풀 재사용 복원).</summary>
static void ApplyStageScale(ProjectileBase pb, WLProjectileVisualRule rule)
{
if (rule.stageScales == null || rule.stageScales.Length == 0) return;
var pd = pb.Get_ProjectTileData(); var shooter = pd != null ? pd.shooter as PCActor : null;
var ct = shooter != null ? shooter.GetComponent<WL.Combat.Core.ComboTracker>() : null;
int stage = ct != null ? ct.LastStage : 0;
WLProjectileStageScale e = null;
foreach (var s in rule.stageScales) if (s != null && s.stage == stage) { e = s; break; }
float mul = e != null ? e.scale : 1f, hitMul = e != null ? e.hitScale : 1f;
int id = pb.GetInstanceID(); var t = pb.transform; Vector3[] bases;
if (!s_baseScales.TryGetValue(id, out bases) || bases.Length != t.childCount)
{
bases = new Vector3[t.childCount];
for (int i = 0; i < t.childCount; i++) bases[i] = t.GetChild(i).localScale;
s_baseScales[id] = bases;
}
for (int i = 0; i < t.childCount; i++) { var c = t.GetChild(i); if (c.GetComponent<Collider>() != null) continue; c.localScale = bases[i] * mul; }
var hf = pb.GetComponent<WLProjectileHitFx>(); if (hf != null) hf.scale = rule.hitScale * hitMul;
if (e != null) { StageScaled++; StageLog = pb.name + " 단계 " + stage + " 겉모습 × " + mul + " · 명중 × " + hitMul; }
}
/// <summary>#917 속사(WLRapidShot)가 쏜 직후 부른다 — Set 훅(OnSet)이 이미 붙였으면 아무것도 안 한다(1 회).</summary>
public static void ApplyTo(ProjectileBase pb, string name)
{
if (pb == null || string.IsNullOrEmpty(name)) return;
if (!s_loaded) Load();
WLProjectileVisualRule rule;
if (!s_rules.TryGetValue(name, out rule)) return;
int id = pb.GetInstanceID();
if (s_done.Contains(id)) return;
if (Attach(pb, rule)) s_done.Add(id);
}
static bool Attach(ProjectileBase pb, WLProjectileVisualRule rule)
{
int pss = 0; GameObject normal = null;
// #917 원래 화살 메시 숨김(풀 재사용에도 꺼진 채) · 투사체별 명중 이펙트
if (rule.hideMesh) foreach (var mr in pb.GetComponentsInChildren<MeshRenderer>(true)) if (mr.transform.parent == pb.transform || mr.transform == pb.transform) mr.enabled = false;
int hid = 0;
if (rule.hideNames != null) foreach (var hn in rule.hideNames) { var ht = string.IsNullOrEmpty(hn) ? null : pb.transform.Find(hn); if (ht != null) foreach (var hr in ht.GetComponents<Renderer>()) { hr.enabled = false; hid++; } }
if (!string.IsNullOrEmpty(rule.hitFx))
{
var hf = pb.GetComponent<WLProjectileHitFx>(); if (hf == null) hf = pb.gameObject.AddComponent<WLProjectileHitFx>();
hf.prefab = rule.hitFx; hf.scale = rule.hitScale; hf.life = rule.hitLife;
}
if (!string.IsNullOrEmpty(rule.visual))
{
var go = InstantiateVisual(rule.visual, pb.transform, rule.scale, "~WL_Visual");
if (go == null) { LastLog = "시각 프리팹 없음 " + rule.visual; return false; }
ApplyThickness(go, rule.thickness); // #943-②
ApplyLength(go, rule.length); // #952-③ 기본 겉모습만(강화 화살은 그대로)
var arr = go.GetComponentsInChildren<ParticleSystem>(true);
for (int i = 0; i < arr.Length; i++)
{
var main = arr[i].main;
main.playOnAwake = true; // 풀 재사용(SetActive) 때 다시 재생
arr[i].Play(true);
}
pss = arr.Length; normal = go;
// #948 — 덧씌움 자식(~WL_Visual/…)도 hideNames 로 끌 수 있게 붙인 뒤 한 번 더(위 첫 패스는 덧씌움이 생기기 전이라 원본 자식만 잡는다)
if (rule.hideNames != null) foreach (var hn in rule.hideNames) { var ht = string.IsNullOrEmpty(hn) ? null : pb.transform.Find(hn); if (ht != null) foreach (var hr in ht.GetComponentsInChildren<Renderer>(true)) { if (hr.enabled) { hr.enabled = false; hid++; } } }
}
// #839 — 강화 화살: 글로우 자식(꺼둠) + WLChargedMark(켜질 때 창 열려 있으면 적용)
if (!string.IsNullOrEmpty(rule.chargedVisual))
{
var cv = InstantiateVisual(rule.chargedVisual, pb.transform, 1f, "~WL_Charged");
if (cv == null) LastLog = "강화 프리팹 없음 " + rule.chargedVisual;
else { ApplyThickness(cv, rule.thickness); cv.SetActive(false); } // #943-② 강화 화살도 같은 굵기
var mark = pb.GetComponent<WLChargedMark>();
if (mark == null) mark = pb.gameObject.AddComponent<WLChargedMark>();
mark.scale = rule.chargedScale; mark.visual = cv; mark.normal = rule.chargedReplaces ? normal : null;
mark.ApplyNow();
}
Attached++;
LastLog = pb.name + " ← " + rule.visual + (string.IsNullOrEmpty(rule.chargedVisual) ? "" : (rule.chargedReplaces ? " / 3타 " : " +charged ") + rule.chargedVisual) + (rule.hideMesh ? " · 메시 숨김" : "") + (hid > 0 ? " · 숨김 " + hid : "") + (string.IsNullOrEmpty(rule.hitFx) ? "" : " · 명중 " + rule.hitFx) + " (파티클 " + pss + ")";
return true;
}
public static int Thinned;
/// <summary>#943-② 굵기만 t 배 — 입자 시작 크기(3D 면 X·Y 만) · 늘림 빌보드는 길이 보정(lengthScale ÷ t) · 입자 궤적 폭 · TrailRenderer/LineRenderer 폭. 길이(진행 방향)는 그대로.</summary>
static void ApplyThickness(GameObject go, float t)
{
if (go == null || t <= 0f || Mathf.Approximately(t, 1f)) return;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var m = ps.main;
if (m.startSize3D) { m.startSizeX = Mul(m.startSizeX, t); m.startSizeY = Mul(m.startSizeY, t); }
else m.startSize = Mul(m.startSize, t);
var r = ps.GetComponent<ParticleSystemRenderer>();
if (r != null && r.renderMode == ParticleSystemRenderMode.Stretch && !m.startSize3D) r.lengthScale = r.lengthScale / t; // 길이 = 크기 × lengthScale → 길이 유지
var tr = ps.trails; if (tr.enabled) tr.widthOverTrailMultiplier = tr.widthOverTrailMultiplier * t;
}
foreach (var tr in go.GetComponentsInChildren<TrailRenderer>(true)) tr.widthMultiplier *= t;
foreach (var lr in go.GetComponentsInChildren<LineRenderer>(true)) lr.widthMultiplier *= t;
Thinned++;
}
public static int Shortened, SpeedScaled; public static string LastSpeed = "";
/// <summary>#952-③ 투사체 속도 배율 — OnSet(ProjectileBase.Set 안)에서 곱하면 파생 클래스(Projectile_Strait)가 base.Set 뒤 prefab m_Speed 로 덮어쓴다(바람쐐기 10402 = 7 · 실측) → 발사 프레임의 LateUpdate(이동 코루틴이 첫 이동을 하기 전)에 그 발사의 ProjectileData 에 1회 적용.</summary>
public sealed class WLProjectileSpeedMul : MonoBehaviour
{
public float mul = 1f; ProjectileBase _pb; bool _pending; ProjectileData _last;
public void Arm(ProjectileBase pb) { _pb = pb; _pending = true; }
void LateUpdate()
{
if (!_pending || _pb == null) return;
var pd = _pb.Get_ProjectTileData(); if (pd == null) return;
_last = pd; _pending = false; // 발사마다 Arm(OnSet) 이 다시 켠다 — 풀 인스턴스는 ProjectileData 객체를 재사용하므로(최초 로드 때 1회 new · Set 은 값 복사) 참조 비교로는 발사를 못 가른다(실측: 첫 발만 ×2)
if (mul > 0f && !Mathf.Approximately(mul, 1f)) { pd.Speed *= mul; SpeedScaled++; LastSpeed = _pb.name + " 속도 " + pd.Speed.ToString("F1"); }
}
}
/// <summary>#952-③ 길이만 L 배 — 늘림 빌보드 lengthScale·velocityScale(길이 = 크기 × lengthScale + 속도 × velocityScale) · 입자 궤적 수명 · TrailRenderer 시간(길이 = 시간 × 비행 속도 → 속도를 올리면 그만큼 길어지므로 함께 줄인다) · Mesh 모드 입자는 시스템 변환의 Z(진행 축 · 계층 배율). 굵기는 그대로.</summary>
static void ApplyLength(GameObject go, float L)
{
if (go == null || L <= 0f || Mathf.Approximately(L, 1f)) return;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var r = ps.GetComponent<ParticleSystemRenderer>();
if (r != null && r.renderMode == ParticleSystemRenderMode.Stretch) { r.lengthScale *= L; r.velocityScale *= L; }
else if (r != null && r.renderMode == ParticleSystemRenderMode.Mesh) { var s = ps.transform.localScale; ps.transform.localScale = new Vector3(s.x, s.y, s.z * L); }
var m = ps.main;
if (m.simulationSpace == ParticleSystemSimulationSpace.World) m.startLifetime = Mul(m.startLifetime, L); // 월드 공간 발광(flares 0.6 s · lines 0.2 s)은 지나온 길에 남아 꼬리 = 수명 × 비행 속도(14 m/s 면 8 m · Play AABB 실측 4.3 m 가 length 와 무관하게 같았던 이유) → 수명도 같은 배율
var tr = ps.trails; if (tr.enabled) tr.lifetime = Mul(tr.lifetime, L);
}
foreach (var tr in go.GetComponentsInChildren<TrailRenderer>(true)) tr.time *= L;
Shortened++;
}
static ParticleSystem.MinMaxCurve Mul(ParticleSystem.MinMaxCurve c, float t)
{
switch (c.mode)
{
case ParticleSystemCurveMode.Constant: return new ParticleSystem.MinMaxCurve(c.constant * t);
case ParticleSystemCurveMode.TwoConstants: return new ParticleSystem.MinMaxCurve(c.constantMin * t, c.constantMax * t);
default: c.curveMultiplier *= t; return c;
}
}
internal static GameObject InstantiateVisual(string path, Transform parent, float scale, string name)
{
GameObject prefab;
if (!s_prefabs.TryGetValue(path, out prefab) || prefab == null)
{
prefab = Resources.Load<GameObject>(path);
s_prefabs[path] = prefab;
}
if (prefab == null) return null;
var go = Instantiate(prefab, parent);
go.name = name;
go.transform.localPosition = Vector3.zero;
go.transform.localRotation = Quaternion.identity;
go.transform.localScale = Vector3.one * Mathf.Max(scale, 0.01f);
return go;
}
}
/// <summary>#917 투사체별 명중 이펙트 — WLImpactFx.TryHitEffect(원본 Show_HitEffect 훅)가 직업 규칙보다 먼저 묻는다. 몸 중심 · 몸 크기 비례(SizeFactor).</summary>
public sealed class WLProjectileHitFx : MonoBehaviour
{
public string prefab = "";
public float scale = 1f, life = 1.5f;
public static int Shown;
static readonly Dictionary<string, GameObject> s_prefabs = new Dictionary<string, GameObject>();
public bool Play(Actor target)
{
if (target == null || string.IsNullOrEmpty(prefab)) return false;
GameObject p;
if (!s_prefabs.TryGetValue(prefab, out p) || p == null) { p = Resources.Load<GameObject>(prefab); s_prefabs[prefab] = p; }
if (p == null) return false;
var go = Instantiate(p, WL.Combat.Impact.WLImpactFx.BodyCenter(target) + WL.Combat.Impact.WLImpactFx.TowardCamera(target), p.transform.rotation); // #941 몸 앞(카메라 쪽)
go.transform.localScale = p.transform.localScale * (scale * WL.Combat.Impact.WLImpactFx.SizeFactor(target));
Destroy(go, Mathf.Max(0.2f, life));
Shown++;
return true;
}
}
}