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

314 lines
15 KiB
C#
Raw 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>정렬 순서 기준(화살 8 · 광역 7 · 스킬 이름 글자 21 · 유닛 0 위) — 행 n_order 를 더한다.</summary>
public const int SortingBase = 12;
public class Row
{
public string skill = "";
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>X 축 기울기(°) · 0 = 팩 원본(옆에서 본 모습) · −90 = 위에서 내려다봄(바닥 원·충격파).</summary>
public float tilt;
/// <summary>true = 진행 방향(cast·travel = 슬링샷 방향 · hit = 때린 쪽 → 대상)으로 프리팹 +Z 를 돌림(투사체·베기).</summary>
public bool face;
/// <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>();
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; 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;
}
}
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.skill == 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();
foreach (var r in rows)
{
if (r.evt != evt || r.skill != key) continue;
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;
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;
}
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);
float life = r.life > 0f ? r.life : (evt == EventTravel && follow) ? 0f : AutoLife(go);
if (life > 0f) Object.Destroy(go, life);
if (follow) into.Objects.Add(go); // 행동 끝(Stop)에 방출을 멈추고 떼어 놓을 것
}
/// <summary>
/// 프리팹 1개 띄우기(표 행 · 스킬 테스트 씬 이펙트 보기 공용). worldScale = 루트 배율 · 입자 크기 모드를 전부 Hierarchy 로(Local 이면 루트 배율이 안 먹음 · K-WL-325) ·
/// 트레일·선 굵기도 배율 · 렌더러 정렬 = order · face = 프리팹 +Z 를 그 방향으로(위 = 카메라 쪽) · 아니면 X 축 tilt°.
/// </summary>
public static GameObject SpawnPrefab(GameObject prefab, Vector2 pos, float worldScale, float tilt, Vector2? face, int order, float speed, Transform follow)
{
Quaternion rot = face.HasValue && face.Value.sqrMagnitude > 1e-6f
? Quaternion.LookRotation(new Vector3(face.Value.x, face.Value.y, 0f), Vector3.back)
: Quaternion.Euler(tilt, 0f, 0f);
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) 멈춤
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;
Object.Destroy(go, Mathf.Min(rest, MaxAutoLife));
}
list.Clear();
}
}
}