ThreeKingdomsSlingshot/Assets/Scripts/Runtime/Skills/SkillVfx.cs

469 lines
26 KiB
C#
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS
{
/// <summary>
/// 스킬 이펙트(VFX) 재생 — WL 방식(2026-11-09 PD 「WL 에서 스킬을 구현하던 방식과 동일하게 유닛별 스킬 구현」):
/// 값 = 표 <c>Resources/Skills/skill_vfx.csv</c>(코드 상수 없음 · C45) · 이펙트 = 팩 프리팹의 사본 <c>Resources/Fx/TKS_Fx_*.prefab</c>
/// (원본 팩 <c>Assets/VFX/**</c> 는 고치지 않는다 · WL 의 <c>WL_Fx_*</c> 사본과 같은 규칙).
/// 키 = 스킬 id(skills.csv · 액티브) 또는 basic_&lt;병종&gt;(일반 슬링샷 공격 · 이동만인 추가 이동 제외).
/// 이벤트 = cast(액티브 발동 · 예약 스킬은 슬링샷 출발 순간 · 즉시형은 버튼) · travel(슬링샷 돌진 동안 · 따라가거나 f_every 마다 다시 띄움 · 끝나면 방출 멈춤) ·
/// hit(행동 중인 유닛이 상대에게 피해를 준 순간 · 대상마다 1번).
/// 표 다시 읽기 = 전투 시작마다(스킬 테스트 씬 「다시 배치」) · <see cref="Reload"/>.
/// 가상 실행(Sim.Active · 적 AI 예측) 중에는 아무것도 띄우지 않는다.
/// </summary>
public static class SkillVfx
{
public const string TablePath = "Skills/skill_vfx";
public const string FxFolder = "Fx/";
public const string EventCast = "cast";
public const string EventTravel = "travel";
public const string EventHit = "hit";
/// <summary>
/// 시험 키(스킬 테스트 씬 「기운 시험」 스위치 · PD 2026-11-10 「몸을 뒤덮는 연출을 스킬용으로 시험」): 비어 있지 않으면 슬링샷 공격 돌진(travel) 때
/// 이 키의 행(표 test_aura 등)을 함께 띄운다 · 실제 전투는 늘 빈칸.
/// </summary>
public static string TestTravelKey = "";
/// <summary>정렬 순서 기준(화살 8 · 광역 7 · 스킬 이름 글자 21 · 유닛 0 위) — 행 n_order 를 더한다.</summary>
public const int SortingBase = 12;
public class Row
{
/// <summary>키 원문(s_skill · ; 로 여러 개 = 그중 하나와 같으면 해당).</summary>
public string skill = "";
public string[] keys = new string[0];
/// <summary>진영(s_camp · shu · wei · wu · other) — 빈칸 = 모든 진영 · 유닛 진영이 빈칸(적 병사 등)이면 other 로 본다.</summary>
public string camp = "";
public bool HasKey(string key)
{
for (int i = 0; i < keys.Length; i++)
if (keys[i] == key) return true;
return false;
}
public string evt = "";
public string fx = "";
/// <summary>feet(발) · body(몸 가운데) · head(머리 끝) · target(피격 대상 몸 가운데 · hit 기본).</summary>
public string anchor = "";
/// <summary>유닛 머리 끝 높이(ArtHeight) 대비 크기 — 1 = 팩 원본 1 단위를 유닛 키로.</summary>
public float scale = 1f;
public float delay;
/// <summary>0 = 입자가 다 사라질 때까지(최대 MaxAutoLife).</summary>
public float life;
/// <summary>기울기(°) · 0 = 옆에서 본 모습 · −90 = 위에서 내려다본 모습(바닥 원·충격파) · face 행은 진행 축을 중심으로 카메라 쪽으로 기울임(<see cref="FxRotation"/>).</summary>
public float tilt;
/// <summary>true = 이펙트 정면(+Z)을 진행 방향(cast·travel = 슬링샷 방향 · hit = 때린 쪽 → 대상)으로 화면 안에서 돌림(투사체·베기·돌진).</summary>
public bool face;
/// <summary>travel 전용 · WL sustain = 시간당 방출 층(버스트 없는 층)을 반복으로 바꿔 돌진 내내 유지(버스트 층은 처음 1번).</summary>
public bool sustain;
/// <summary>travel 전용 · WL ClearEnd = 돌진이 끝나는 순간 남은 입자까지 지움(0 = 방출만 멈추고 남은 입자는 수명대로).</summary>
public bool clear;
/// <summary>입자 자체 이동 배율(시작 속도 · 속도 모듈) — 1 = 팩 원본 · 0.1 = 거의 제자리(스스로 앞으로 날아가는 데모용 돌진 이펙트를 유닛에 붙여 감싸게 · MasterStylizedFX Dash).</summary>
public float drift = 1f;
/// <summary>진행 방향 기준 위치 보정(유닛 키 배수 · + 앞 · − 뒤) — 예: 돌진 잔상(Dash)을 캐릭터 뒤쪽에(f_fwd).</summary>
public float fwd;
/// <summary>travel 전용 · 슬링샷 이동이 끝날 때 그 자리에서 이 초 동안 흐려지며 사라짐(f_fade · 0 = b_clear/방출만 멈춤 규칙 · b_clear 보다 우선).</summary>
public float fade;
/// <summary>유닛 위에 그릴 자식 층 이름(s_over · ; 로 여러 개) — 나머지 층은 n_order 그대로(이중 레이어 · 예: 혜성 머리 실드만 캐릭터 앞 · PD 2026-11-10).</summary>
public string[] over = new string[0];
/// <summary>
/// 2D 평면 이펙트(b_flat · PD 2026-11-10 「돌진 이펙트가 너무 3D 느낌 → 2D 처럼」): 진행 방향으로 화면 안에서만 돌림(Z 축) · f_tilt 와 팩 루트 회전 무시 ·
/// 프리팹은 화면 평면에 그린 셀 그림(정면 = +X · Assets/VFX/TKS2D).
/// </summary>
public bool flat;
/// <summary>1 = 유닛을 따라다님 · 0 = 생긴 자리에 남음 · −1(빈칸) = 기본(travel 따라감 · 그 밖 남음).</summary>
public int follow = -1;
public float speed = 1f;
public float dx;
public float dy;
public int order;
/// <summary>travel 전용: 0 보다 크면 돌진하는 동안 이 간격(초)마다 다시 띄움(WL 창 돌진 감싸기 0.3 · 흙먼지 0.15).</summary>
public float every;
}
/// <summary>travel 이펙트 묶음(행동 끝에 Stop) — 따라다니는 것만 담는다(남는 것은 제 수명대로).</summary>
public class Handle
{
public readonly List<GameObject> Objects = new List<GameObject>();
/// <summary>Stop 때 남은 입자까지 지울 것(b_clear).</summary>
public readonly HashSet<GameObject> ClearOnStop = new HashSet<GameObject>();
/// <summary>Stop 때 그 자리에서 흐려지며 사라질 것(f_fade 초).</summary>
public readonly Dictionary<GameObject, float> FadeOnStop = new Dictionary<GameObject, float>();
public bool Stopped;
}
/// <summary>life 0 일 때 입자 길이 상한(초) — 루프 입자가 끝없이 남지 않게.</summary>
public const float MaxAutoLife = 6f;
static List<Row> rows;
static readonly Dictionary<string, GameObject> prefabCache = new Dictionary<string, GameObject>();
public static void Reload()
{
rows = null;
prefabCache.Clear();
}
public static IReadOnlyList<Row> Rows
{
get
{
Ensure();
return rows;
}
}
static void Ensure()
{
if (rows != null) return;
rows = new List<Row>();
var ta = Resources.Load<TextAsset>(TablePath);
if (ta == null) return;
var lines = ta.text.Replace("\r", "").Split('\n');
string[] head = null;
foreach (var raw in lines)
{
var line = raw.TrimStart('');
if (line.Trim().Length == 0 || line.TrimStart().StartsWith("#")) continue;
var cells = SplitCsv(line);
if (head == null)
{
head = cells;
continue;
}
var r = new Row();
for (int i = 0; i < head.Length && i < cells.Length; i++)
{
string k = head[i].Trim(), v = cells[i].Trim();
if (v.Length == 0) continue;
switch (k)
{
case "s_skill":
r.skill = v;
r.keys = v.Split(';');
for (int j = 0; j < r.keys.Length; j++) r.keys[j] = r.keys[j].Trim();
break;
case "s_camp": r.camp = v.ToLowerInvariant(); break;
case "e_event": r.evt = v.ToLowerInvariant(); break;
case "s_fx": r.fx = v; break;
case "e_anchor": r.anchor = v.ToLowerInvariant(); break;
case "f_scale": r.scale = F(v, 1f); break;
case "f_delay": r.delay = F(v, 0f); break;
case "f_life": r.life = F(v, 0f); break;
case "f_tilt": r.tilt = F(v, 0f); break;
case "b_face": r.face = v == "1" || v.ToLowerInvariant() == "true"; break;
case "b_follow": r.follow = v == "1" || v.ToLowerInvariant() == "true" ? 1 : 0; break;
case "f_speed": r.speed = F(v, 1f); break;
case "f_dx": r.dx = F(v, 0f); break;
case "f_dy": r.dy = F(v, 0f); break;
case "n_order": int.TryParse(v, out r.order); break;
case "f_every": r.every = F(v, 0f); break;
case "b_sustain": r.sustain = v == "1" || v.ToLowerInvariant() == "true"; break;
case "b_clear": r.clear = v == "1" || v.ToLowerInvariant() == "true"; break;
case "f_drift": r.drift = F(v, 1f); break;
case "f_fwd": r.fwd = F(v, 0f); break;
case "f_fade": r.fade = F(v, 0f); break;
case "s_over":
r.over = v.Split(';');
for (int j = 0; j < r.over.Length; j++) r.over[j] = r.over[j].Trim();
break;
case "b_flat": r.flat = v == "1" || v.ToLowerInvariant() == "true"; break;
}
}
if (r.skill.Length > 0 && r.evt.Length > 0 && r.fx.Length > 0) rows.Add(r);
}
}
static float F(string v, float def)
{
float f;
return float.TryParse(v, NumberStyles.Float, CultureInfo.InvariantCulture, out f) ? f : def;
}
/// <summary>따옴표를 아는 CSV 한 줄 나누기(스킬 테스트 씬 표도 사용).</summary>
public static string[] SplitCsv(string line)
{
var res = new List<string>();
var sb = new System.Text.StringBuilder();
bool q = false;
for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (q)
{
if (c == '"' && i + 1 < line.Length && line[i + 1] == '"')
{
sb.Append('"');
i++;
}
else if (c == '"') q = false;
else sb.Append(c);
}
else if (c == '"') q = true;
else if (c == ',')
{
res.Add(sb.ToString());
sb.Length = 0;
}
else sb.Append(c);
}
res.Add(sb.ToString());
return res.ToArray();
}
public static bool Has(string skillId, string evt)
{
if (string.IsNullOrEmpty(skillId)) return false;
Ensure();
foreach (var r in rows)
if (r.evt == evt && r.HasKey(skillId)) return true;
return false;
}
public static GameObject LoadFx(string name)
{
GameObject p;
if (prefabCache.TryGetValue(name, out p) && p != null) return p;
p = Resources.Load<GameObject>(FxFolder + name);
if (p == null) Debug.LogWarning("[TKS] 스킬 이펙트 사본 없음: Resources/" + FxFolder + name + " (skill_vfx.csv s_fx)");
prefabCache[name] = p;
return p;
}
/// <summary>
/// 표의 (키, 이벤트) 행을 모두 띄운다. 키 = 스킬 id(skills.csv) 또는 basic_&lt;병종&gt;(일반 슬링샷 공격 · infantry · spear · cavalry · archer · strategist).
/// dir = 진행 방향(face) · target = hit 대상. 반환 = travel 정지용 묶음(지연·반복 행은 나중에 생김).
/// </summary>
public static Handle Play(Unit u, string key, string evt, Vector2 dir, Unit target = null)
{
var h = new Handle();
if (Sim.Active || u == null || string.IsNullOrEmpty(key)) return h;
Ensure();
string camp = string.IsNullOrEmpty(u.Camp) ? "other" : u.Camp.ToLowerInvariant();
foreach (var r in rows)
{
if (r.evt != evt || !r.HasKey(key)) continue;
if (r.camp.Length > 0 && r.camp != camp) continue; // 진영별 행(s_camp)
var prefab = LoadFx(r.fx);
if (prefab == null) continue;
if (r.delay > 0f || (evt == EventTravel && r.every > 0f)) Fx.Run(Timed(r, prefab, u, dir, target, evt, h));
else Spawn(r, prefab, u, dir, target, evt, h);
}
return h;
}
/// <summary>basic_&lt;병종&gt; 키(일반 슬링샷 공격 이펙트 · 2026-11-09 PD 시험 「보병 · 창 · 기병 슬링샷 공격에 WL 창 돌진 이펙트」).</summary>
public static string BasicKey(UnitType t)
{
return "basic_" + t.ToString().ToLowerInvariant();
}
static IEnumerator Timed(Row r, GameObject prefab, Unit u, Vector2 dir, Unit target, string evt, Handle h)
{
if (r.delay > 0f) yield return Fx.Wait(r.delay);
do
{
if (u == null || h.Stopped) yield break;
Spawn(r, prefab, u, dir, target, evt, h);
if (!(evt == EventTravel && r.every > 0f)) yield break;
yield return Fx.Wait(r.every);
} while (true);
}
static void Spawn(Row r, GameObject prefab, Unit u, Vector2 dir, Unit target, string evt, Handle into)
{
string anchor = r.anchor.Length > 0 ? r.anchor : evt == EventHit ? "target" : "feet";
Unit at = anchor == "target" && target != null ? target : u;
Vector2 pos = at.Position;
float h = at.ArtHeight > 0f ? at.ArtHeight : at.Radius * 2f;
if (anchor == "body" || anchor == "target") pos.y += h * 0.45f;
else if (anchor == "head") pos.y += h;
else if (anchor == "dash")
{
// 돌진(PD 2026-11-10 「복귀할 때 이펙트가 캐릭터보다 위쪽에 생겨 안 보임 → 유닛 바닥에 보이도록」): 위·옆으로 갈 때 = 몸 가운데 ·
// 아래로 갈수록 발 쪽으로(곧장 아래 = 발) — 뒤로 끌리는 빛이 머리 위가 아니라 몸·발 둘레에 오게
float down = dir.sqrMagnitude > 1e-6f ? Mathf.Max(0f, -dir.normalized.y) : 0f;
pos.y += h * 0.45f * (1f - down);
}
pos += new Vector2(r.dx, r.dy) * h;
Vector2 face = dir;
if (evt == EventHit && target != null)
{
var d = target.Position - u.Position;
if (d.sqrMagnitude > 1e-4f) face = d.normalized;
}
if (Mathf.Abs(r.fwd) > 1e-4f && face.sqrMagnitude > 1e-6f) pos += face.normalized * (r.fwd * h); // 진행 방향 앞(+) · 뒤(−)
bool follow = r.follow == 1 || (r.follow < 0 && evt == EventTravel);
var go = SpawnPrefab(prefab, pos, r.scale * h, r.tilt, r.face ? (Vector2?)face : null, SortingBase + r.order, r.speed,
follow ? at.transform : null, r.flat);
if (r.over.Length > 0) // 이중 레이어: 지정한 층만 유닛 위(정렬 SortingBase · 유닛 0 · 이름 글자 21 사이)
foreach (var rd in go.GetComponentsInChildren<Renderer>(true))
if (System.Array.IndexOf(r.over, rd.gameObject.name) >= 0) rd.sortingOrder = SortingBase;
if (evt == EventTravel && r.sustain) Sustain(go);
if (Mathf.Abs(r.drift - 1f) > 1e-3f) ScaleSelfMotion(go, r.drift);
float life = r.life > 0f ? r.life : (evt == EventTravel && follow) ? 0f : AutoLife(go);
if (life > 0f) Object.Destroy(go, life);
if (follow || (evt == EventTravel && (r.clear || r.fade > 0f))) into.Objects.Add(go); // 이동 끝(Stop)에 방출을 멈춤(따라가는 것은 떼어 놓음)
if (evt == EventTravel && r.fade > 0f) into.FadeOnStop[go] = r.fade;
else if (evt == EventTravel && r.clear) into.ClearOnStop.Add(go);
}
/// <summary>
/// 팩 3D 이펙트(위 = +Y · 정면 = +Z)를 2D 전장 화면에 놓는 회전(2026-11-09 PD 「돌진하는 동안 이펙트가 정상적으로 안 보임」 수정).
/// face 없음 = X 축 tilt°(0 = 옆모습 · −90 = 바로 위에서 본 모습).
/// face 있음 = 옆에서 본 모습으로 화면에 눕혀(정면 → 화면 진행 방향 · 위 → 화면 쪽 수직) 진행 방향으로 화면 안에서 돌린다 — 정면·위로 세운 판(찌르기 섬광 · 돌진 줄기)이
/// 어느 방향으로 가든 납작하게 보이지 않게(3D 그대로 두면 위쪽 돌진 때 판의 옆면만 보임 · 실측) · tilt = 진행 축을 중심으로 카메라 쪽으로 기울이는 각도(−90 = 위에서 본 모습 · 바닥 원이 보임) ·
/// 왼쪽으로 갈 때는 위아래가 뒤집히지 않게 반대로 돌림.
/// </summary>
/// <summary>
/// 프리팹 원본 루트 회전 r0 을 살린 화면 회전(2026-11-10 PD 「스크린샷 분석해서 다시 적용」 — MasterStylizedFX 투사체·Dash 는 루트에 (0, 90, 270) 같은 회전이 들어 있어
/// 덮어쓰면 혜성 판이 옆으로 서서 가늘게만 보임 · 실측): 팩 데모 세계에서 r0 로 놓인 이펙트의 진행 축(r0 · +Z 의 수평 성분)을 +Z 로 돌려 맞춘 뒤 FxRotation 을 건다.
/// 루트 회전이 0 인 사본(WL 창 돌진 등)은 FxRotation 과 같다.
/// </summary>
public static Quaternion FxRotation(float tilt, Vector2? face, Quaternion r0)
{
var fwd = r0 * Vector3.forward;
fwd.y = 0f;
var align = fwd.sqrMagnitude > 1e-6f ? Quaternion.Inverse(Quaternion.LookRotation(fwd.normalized, Vector3.up)) : Quaternion.identity;
return FxRotation(tilt, face) * align * r0;
}
public static Quaternion FxRotation(float tilt, Vector2? face)
{
if (!face.HasValue || face.Value.sqrMagnitude <= 1e-6f) return Quaternion.Euler(tilt, 0f, 0f);
var f = face.Value;
float ang = Mathf.Atan2(f.y, f.x) * Mathf.Rad2Deg;
float roll = Mathf.Abs(ang) > 90f ? 180f - tilt : tilt;
return Quaternion.Euler(0f, 0f, ang) * Quaternion.AngleAxis(roll, Vector3.right) * Quaternion.LookRotation(Vector3.right, Vector3.up);
}
/// <summary>2D 평면 이펙트 회전(b_flat): 정면 +X 를 진행 방향으로 화면 안에서만 돌림 · 방향 없음 = 그대로.</summary>
public static Quaternion FlatRotation(Vector2? face)
{
if (!face.HasValue || face.Value.sqrMagnitude <= 1e-6f) return Quaternion.identity;
return Quaternion.Euler(0f, 0f, Mathf.Atan2(face.Value.y, face.Value.x) * Mathf.Rad2Deg);
}
/// <summary>입자 자체 이동(시작 속도 · 속도 모듈)을 k 배로(f_drift).</summary>
static void ScaleSelfMotion(GameObject go, float k)
{
k = Mathf.Max(0f, k);
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var m = ps.main;
m.startSpeedMultiplier *= k;
var v = ps.velocityOverLifetime;
if (v.enabled) v.speedModifierMultiplier *= k;
}
}
/// <summary>WL WLStaffEarth.Sustain 과 같음 — 시간당 방출(초당 5 이상) · 버스트 없는 층만 반복으로(돌진 내내 이어짐 · 버스트 층은 처음 1번만).</summary>
static void Sustain(GameObject go)
{
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var em = ps.emission;
if (!em.enabled || em.rateOverTime.constantMax < 5f) continue;
bool burst = false;
for (int i = 0; i < em.burstCount; i++)
if (em.GetBurst(i).count.constantMax > 0f) burst = true;
if (burst) continue;
var m = ps.main;
m.loop = true;
}
}
/// <summary>
/// 프리팹 1개 띄우기(표 행 · 스킬 테스트 씬 이펙트 보기 공용). worldScale = 루트 배율 · 입자 크기 모드를 전부 Hierarchy 로(Local 이면 루트 배율이 안 먹음 · K-WL-325) ·
/// 트레일·선 굵기도 배율 · 렌더러 정렬 = order · 회전은 <see cref="FxRotation"/>.
/// </summary>
public static GameObject SpawnPrefab(GameObject prefab, Vector2 pos, float worldScale, float tilt, Vector2? face, int order, float speed, Transform follow,
bool flat = false)
{
Quaternion rot = flat ? FlatRotation(face) : FxRotation(tilt, face, prefab.transform.localRotation);
var go = Object.Instantiate(prefab, new Vector3(pos.x, pos.y, 0f), rot);
go.name = prefab.name;
float s = Mathf.Max(0.001f, worldScale);
go.transform.localScale = prefab.transform.localScale * s;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var m = ps.main;
m.scalingMode = ParticleSystemScalingMode.Hierarchy;
if (speed > 0f && Mathf.Abs(speed - 1f) > 1e-3f) m.simulationSpeed *= speed;
}
foreach (var tr in go.GetComponentsInChildren<TrailRenderer>(true)) tr.widthMultiplier *= s;
foreach (var lr in go.GetComponentsInChildren<LineRenderer>(true)) lr.widthMultiplier *= s;
foreach (var rd in go.GetComponentsInChildren<Renderer>(true)) rd.sortingOrder = order;
if (follow != null) go.transform.SetParent(follow, true);
return go;
}
/// <summary>입자 길이(시작 지연 + 길이 + 최대 수명) 중 가장 긴 것 · 루프는 MaxAutoLife.</summary>
public static float AutoLife(GameObject go)
{
float best = 0.5f;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
var m = ps.main;
float sp = Mathf.Max(0.01f, m.simulationSpeed);
float t = m.loop ? MaxAutoLife : (m.startDelay.constantMax + m.duration + m.startLifetime.constantMax) / sp;
if (t > best) best = t;
}
return Mathf.Min(best, MaxAutoLife);
}
/// <summary>붙어 있던 이펙트(travel) 끝: 방출을 멈추고 떼어 놓은 뒤 남은 입자가 사라지면 지운다.</summary>
public static void Stop(Handle h)
{
if (h == null) return;
h.Stopped = true; // 반복(f_every) 멈춤
foreach (var kv in h.FadeOnStop)
{
var go = kv.Key;
if (go == null) continue;
go.transform.SetParent(null, true); // 이동이 끝난 자리에 남아 흐려짐
SkillVfxFade.Begin(go, kv.Value);
h.Objects.Remove(go);
}
h.FadeOnStop.Clear();
foreach (var go in h.ClearOnStop)
{
if (go == null) continue;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true)) ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);
h.Objects.Remove(go);
Object.Destroy(go); // WL ClearEnd — 돌진 끝 = 이펙트 끝
}
h.ClearOnStop.Clear();
Stop(h.Objects);
}
public static void Stop(List<GameObject> list)
{
if (list == null) return;
foreach (var go in list)
{
if (go == null) continue;
go.transform.SetParent(null, true);
float rest = 0.2f;
foreach (var ps in go.GetComponentsInChildren<ParticleSystem>(true))
{
ps.Stop(true, ParticleSystemStopBehavior.StopEmitting);
rest = Mathf.Max(rest, ps.main.startLifetime.constantMax / Mathf.Max(0.01f, ps.main.simulationSpeed));
}
foreach (var tr in go.GetComponentsInChildren<TrailRenderer>(true))
{
tr.emitting = false;
rest = Mathf.Max(rest, tr.time); // 꼬리가 제 길이만큼 줄어 사라질 때까지(IB 속도 꼬리 0.6초 · 2026-11-10)
}
Object.Destroy(go, Mathf.Min(rest, MaxAutoLife));
}
list.Clear();
}
}
}