314 lines
15 KiB
C#
314 lines
15 KiB
C#
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_<병종>(일반 슬링샷 공격 · 이동만인 추가 이동 제외).
|
||
/// 이벤트 = 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_<병종>(일반 슬링샷 공격 · 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_<병종> 키(일반 슬링샷 공격 이펙트 · 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();
|
||
}
|
||
}
|
||
}
|