// ───────────────────────────────────────────────────────────────────────────── // 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 s_rules = new Dictionary(); static readonly Dictionary s_byName = new Dictionary(); // 인스턴스 이름(…(Clone)) → 규칙(없으면 null) static readonly HashSet s_done = new HashSet(); static readonly Dictionary s_prefabs = new Dictionary(); [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(ResourcesPath); if (ta == null) return; WLProjectileVisualJson j = null; try { j = JsonUtility.FromJson(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; } } /// #923 원본 ProjectileBase.Set 끝 훅 — 켜진 직후(첫 프레임 그리기 전) 이 인스턴스에 규칙을 붙인다(인스턴스마다 1 회 · 풀 재사용 때는 자식이 남아 있다). 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(); if (sm == null) sm = pb.gameObject.AddComponent(); 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 s_baseScales = new Dictionary(); // 투사체 인스턴스 → 직접 자식들의 기본 localScale /// #948 쏜 PC 의 콤보 단계(ComboTracker.LastStage)에 맞는 stageScales 항목이 있으면 보이는 직접 자식(원본 FX 노드 · ~WL_Visual)의 localScale 과 명중 이펙트 배율을 곱한다. 콜라이더가 달린 자식(판정)은 건드리지 않는다. 항목이 없으면 기본값(풀 재사용 복원). 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() : 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() != null) continue; c.localScale = bases[i] * mul; } var hf = pb.GetComponent(); if (hf != null) hf.scale = rule.hitScale * hitMul; if (e != null) { StageScaled++; StageLog = pb.name + " 단계 " + stage + " 겉모습 × " + mul + " · 명중 × " + hitMul; } } /// #917 속사(WLRapidShot)가 쏜 직후 부른다 — Set 훅(OnSet)이 이미 붙였으면 아무것도 안 한다(1 회). 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(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()) { hr.enabled = false; hid++; } } if (!string.IsNullOrEmpty(rule.hitFx)) { var hf = pb.GetComponent(); if (hf == null) hf = pb.gameObject.AddComponent(); 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(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(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(); if (mark == null) mark = pb.gameObject.AddComponent(); 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; /// #943-② 굵기만 t 배 — 입자 시작 크기(3D 면 X·Y 만) · 늘림 빌보드는 길이 보정(lengthScale ÷ t) · 입자 궤적 폭 · TrailRenderer/LineRenderer 폭. 길이(진행 방향)는 그대로. static void ApplyThickness(GameObject go, float t) { if (go == null || t <= 0f || Mathf.Approximately(t, 1f)) return; foreach (var ps in go.GetComponentsInChildren(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(); 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(true)) tr.widthMultiplier *= t; foreach (var lr in go.GetComponentsInChildren(true)) lr.widthMultiplier *= t; Thinned++; } public static int Shortened, SpeedScaled; public static string LastSpeed = ""; /// #952-③ 투사체 속도 배율 — OnSet(ProjectileBase.Set 안)에서 곱하면 파생 클래스(Projectile_Strait)가 base.Set 뒤 prefab m_Speed 로 덮어쓴다(바람쐐기 10402 = 7 · 실측) → 발사 프레임의 LateUpdate(이동 코루틴이 첫 이동을 하기 전)에 그 발사의 ProjectileData 에 1회 적용. 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"); } } } /// #952-③ 길이만 L 배 — 늘림 빌보드 lengthScale·velocityScale(길이 = 크기 × lengthScale + 속도 × velocityScale) · 입자 궤적 수명 · TrailRenderer 시간(길이 = 시간 × 비행 속도 → 속도를 올리면 그만큼 길어지므로 함께 줄인다) · Mesh 모드 입자는 시스템 변환의 Z(진행 축 · 계층 배율). 굵기는 그대로. static void ApplyLength(GameObject go, float L) { if (go == null || L <= 0f || Mathf.Approximately(L, 1f)) return; foreach (var ps in go.GetComponentsInChildren(true)) { var r = ps.GetComponent(); 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(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(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; } } /// #917 투사체별 명중 이펙트 — WLImpactFx.TryHitEffect(원본 Show_HitEffect 훅)가 직업 규칙보다 먼저 묻는다. 몸 중심 · 몸 크기 비례(SizeFactor). public sealed class WLProjectileHitFx : MonoBehaviour { public string prefab = ""; public float scale = 1f, life = 1.5f; public static int Shown; static readonly Dictionary s_prefabs = new Dictionary(); 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(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; } } }