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

351 lines
26 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;
// #985 PD 「활 투사체에 화살이 같이 보이는게 좋을 것 같아. (화살과 전혀 동떨어진 투사체로 보여서 어색해)」 — arrow = 날아가는 투사체에 시위 화살과 같은 화살 메시(WL_NockArrow · +Z = 촉)를 자식으로
// 길이 arrowLength(m · 월드) · 굵기 배율 arrowWidth · 촉 = 투사체 자리에서 앞으로 arrowTip(m) · 강화(3타) 겉모습으로 바뀌어도 그대로 보인다
public string arrow = "";
public float arrowLength = 0.7f, arrowWidth = 1f, arrowTip = 0f;
// #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];
// #968 PD 「오브 각성 스킬 이펙트 크기를 50% 줄이고」 — 1 이 아니면 쏠 때마다(풀 재사용 포함) 투사체 루트 크기 = 처음 크기 × fxScale (원본 이펙트 전체 · 입자는 계층 배율일 때만 따라 줄어든다 · 판정 반경은 그대로)
public float fxScale = 1f;
// #974 1 = 원본 코드가 쏠 때 띄우는 시작 이펙트(검기 분출 Effect_Hit_Skill_EnergySlashFlash)를 끈다 — 같은 섬광을 스킬 연출 impact(푸른 WL 사본)가 띄우므로 두 번 겹치던 것
public int hideStartFx;
}
[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) && (r.fxScale <= 0f || Mathf.Approximately(r.fxScale, 1f)) && r.hideStartFx == 0)) continue; // #968 fxScale 만 있는 규칙(블랙홀 0.5 배)도 · #974 hideStartFx 만 있는 규칙도 읽는다
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타 투사체 표시(쏠 때마다 · 이미 겉모습을 붙인 풀 인스턴스도)
try { OnSetVisual(pb); }
finally { WLStaffEarth.AfterProjectileSet(pb); } // #967 3타 메테오 = 겉모습 자식까지 붙은 뒤 넘겨받아 모두 끈다
}
static WLProjectileVisualRule RuleOf(ProjectileBase pb)
{
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;
}
return rule;
}
public static int StartFxHidden;
/// <summary>#974 원본 스킬 투사체의 시작 이펙트 줄 훅 — true = 띄우지 않는다(규칙 hideStartFx).</summary>
public static bool HideStartFx(ProjectileBase pb)
{
try { var r = pb != null ? RuleOf(pb) : null; if (r == null || r.hideStartFx == 0) return false; StartFxHidden++; return true; }
catch (System.Exception) { return false; }
}
static void OnSetVisual(ProjectileBase pb)
{
var rule = RuleOf(pb);
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 매 발사(풀 재사용 포함) — 콤보 단계별 배율
ApplyFxScale(pb, rule); // #968 원본 이펙트 전체 배율
}
public static int FxScaled;
static readonly Dictionary<int, Vector3> s_baseRoot = new Dictionary<int, Vector3>(); // #968 투사체 인스턴스 → 처음 루트 localScale
/// <summary>#968 rule.fxScale 이 1 이 아니면 투사체 루트 localScale = 처음 값 × fxScale(매 발사 · 누적 없음). 판정은 원본 코드 값(반경 · 범위)이라 바뀌지 않는다.</summary>
static void ApplyFxScale(ProjectileBase pb, WLProjectileVisualRule rule)
{
if (rule.fxScale <= 0f || Mathf.Approximately(rule.fxScale, 1f)) return;
int id = pb.GetInstanceID(); Vector3 b;
if (!s_baseRoot.TryGetValue(id, out b))
{
b = pb.transform.localScale; s_baseRoot[id] = b;
// #974 입자가 Local 배율이면 루트 크기를 안 따라 그대로 보인다(검기 분출 FX_SlashLight) → 이 인스턴스 입자만 계층 배율로(원본 프리팹 무수정 · 처음 1회)
foreach (var ps in pb.GetComponentsInChildren<ParticleSystem>(true)) { var m = ps.main; if (m.scalingMode != ParticleSystemScalingMode.Hierarchy) m.scalingMode = ParticleSystemScalingMode.Hierarchy; }
}
pb.transform.localScale = b * rule.fxScale; FxScaled++;
}
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++; } } }
}
if (!string.IsNullOrEmpty(rule.arrow)) AttachArrow(pb, rule); // #985 화살 메시
// #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 Arrows; public static string LastArrow = "";
/// <summary>#985 화살 메시 자식 — 메시 경계로 길이(꼬리 → 촉)를 재서 월드 길이 arrowLength 가 되게(투사체 루트 배율 보정) · 촉을 투사체 자리 + arrowTip 에.</summary>
static void AttachArrow(ProjectileBase pb, WLProjectileVisualRule rule)
{
var go = InstantiateVisual(rule.arrow, pb.transform, 1f, "~WL_Arrow"); if (go == null) { LastArrow = "화살 프리팹 없음 " + rule.arrow; return; }
float zmin = float.MaxValue, zmax = float.MinValue;
foreach (var mf in go.GetComponentsInChildren<MeshFilter>(true))
{
if (mf.sharedMesh == null) continue; var b = mf.sharedMesh.bounds; var m = go.transform.worldToLocalMatrix * mf.transform.localToWorldMatrix;
for (int i = 0; i < 8; i++) { var v = m.MultiplyPoint3x4(new Vector3((i & 1) == 0 ? b.min.x : b.max.x, (i & 2) == 0 ? b.min.y : b.max.y, (i & 4) == 0 ? b.min.z : b.max.z)); zmin = Mathf.Min(zmin, v.z); zmax = Mathf.Max(zmax, v.z); }
}
float len = zmin < zmax ? zmax - zmin : 1f, s = rule.arrowLength / Mathf.Max(1e-3f, len); var ls = pb.transform.lossyScale;
float lx = Mathf.Max(1e-4f, Mathf.Abs(ls.x)), ly = Mathf.Max(1e-4f, Mathf.Abs(ls.y)), lz = Mathf.Max(1e-4f, Mathf.Abs(ls.z));
go.transform.localScale = new Vector3(s * rule.arrowWidth / lx, s * rule.arrowWidth / ly, s / lz);
go.transform.localPosition = new Vector3(0f, 0f, (rule.arrowTip - s * (zmin < zmax ? zmax : 0f)) / lz); // 촉(zmax)이 투사체 자리 + arrowTip
Arrows++; LastArrow = pb.name + " 화살 " + rule.arrowLength + " m (원본 " + len.ToString("F2") + " · 배율 " + s.ToString("F3") + ")";
}
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;
}
}
}