252 lines
17 KiB
C#
252 lines
17 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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 · 늘림 길이 보정 · 궤적 폭) · 길이는 그대로
|
||
// #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 (!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-②
|
||
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++;
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
}
|