using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using UnityEngine;
namespace TKS
{
///
/// 스킬 이펙트(VFX) 재생 — WL 방식(2026-11-09 PD 「WL 에서 스킬을 구현하던 방식과 동일하게 유닛별 스킬 구현」):
/// 값 = 표 Resources/Skills/skill_vfx.csv(코드 상수 없음 · C45) · 이펙트 = 팩 프리팹의 사본 Resources/Fx/TKS_Fx_*.prefab
/// (원본 팩 Assets/VFX/** 는 고치지 않는다 · WL 의 WL_Fx_* 사본과 같은 규칙).
/// 키 = 스킬 id(skills.csv · 액티브) 또는 basic_<병종>(일반 슬링샷 공격 · 이동만인 추가 이동 제외).
/// 이벤트 = cast(액티브 발동 · 예약 스킬은 슬링샷 출발 순간 · 즉시형은 버튼) · travel(슬링샷 돌진 동안 · 따라가거나 f_every 마다 다시 띄움 · 끝나면 방출 멈춤) ·
/// hit(행동 중인 유닛이 상대에게 피해를 준 순간 · 대상마다 1번).
/// 표 다시 읽기 = 전투 시작마다(스킬 테스트 씬 「다시 배치」) · .
/// 가상 실행(Sim.Active · 적 AI 예측) 중에는 아무것도 띄우지 않는다.
///
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";
/// 정렬 순서 기준(화살 8 · 광역 7 · 스킬 이름 글자 21 · 유닛 0 위) — 행 n_order 를 더한다.
public const int SortingBase = 12;
public class Row
{
public string skill = "";
public string evt = "";
public string fx = "";
/// feet(발) · body(몸 가운데) · head(머리 끝) · target(피격 대상 몸 가운데 · hit 기본).
public string anchor = "";
/// 유닛 머리 끝 높이(ArtHeight) 대비 크기 — 1 = 팩 원본 1 단위를 유닛 키로.
public float scale = 1f;
public float delay;
/// 0 = 입자가 다 사라질 때까지(최대 MaxAutoLife).
public float life;
/// X 축 기울기(°) · 0 = 팩 원본(옆에서 본 모습) · −90 = 위에서 내려다봄(바닥 원·충격파).
public float tilt;
/// true = 진행 방향(cast·travel = 슬링샷 방향 · hit = 때린 쪽 → 대상)으로 프리팹 +Z 를 돌림(투사체·베기).
public bool face;
/// 1 = 유닛을 따라다님 · 0 = 생긴 자리에 남음 · −1(빈칸) = 기본(travel 따라감 · 그 밖 남음).
public int follow = -1;
public float speed = 1f;
public float dx;
public float dy;
public int order;
/// travel 전용: 0 보다 크면 돌진하는 동안 이 간격(초)마다 다시 띄움(WL 창 돌진 감싸기 0.3 · 흙먼지 0.15).
public float every;
}
/// travel 이펙트 묶음(행동 끝에 Stop) — 따라다니는 것만 담는다(남는 것은 제 수명대로).
public class Handle
{
public readonly List Objects = new List();
public bool Stopped;
}
/// life 0 일 때 입자 길이 상한(초) — 루프 입자가 끝없이 남지 않게.
public const float MaxAutoLife = 6f;
static List rows;
static readonly Dictionary prefabCache = new Dictionary();
public static void Reload()
{
rows = null;
prefabCache.Clear();
}
public static IReadOnlyList Rows
{
get
{
Ensure();
return rows;
}
}
static void Ensure()
{
if (rows != null) return;
rows = new List();
var ta = Resources.Load(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;
}
/// 따옴표를 아는 CSV 한 줄 나누기(스킬 테스트 씬 표도 사용).
public static string[] SplitCsv(string line)
{
var res = new List();
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(FxFolder + name);
if (p == null) Debug.LogWarning("[TKS] 스킬 이펙트 사본 없음: Resources/" + FxFolder + name + " (skill_vfx.csv s_fx)");
prefabCache[name] = p;
return p;
}
///
/// 표의 (키, 이벤트) 행을 모두 띄운다. 키 = 스킬 id(skills.csv) 또는 basic_<병종>(일반 슬링샷 공격 · infantry · spear · cavalry · archer · strategist).
/// dir = 진행 방향(face) · target = hit 대상. 반환 = travel 정지용 묶음(지연·반복 행은 나중에 생김).
///
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;
}
/// basic_<병종> 키(일반 슬링샷 공격 이펙트 · 2026-11-09 PD 시험 「보병 · 창 · 기병 슬링샷 공격에 WL 창 돌진 이펙트」).
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)에 방출을 멈추고 떼어 놓을 것
}
///
/// 프리팹 1개 띄우기(표 행 · 스킬 테스트 씬 이펙트 보기 공용). worldScale = 루트 배율 · 입자 크기 모드를 전부 Hierarchy 로(Local 이면 루트 배율이 안 먹음 · K-WL-325) ·
/// 트레일·선 굵기도 배율 · 렌더러 정렬 = order · face = 프리팹 +Z 를 그 방향으로(위 = 카메라 쪽) · 아니면 X 축 tilt°.
///
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(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(true)) tr.widthMultiplier *= s;
foreach (var lr in go.GetComponentsInChildren(true)) lr.widthMultiplier *= s;
foreach (var rd in go.GetComponentsInChildren(true)) rd.sortingOrder = order;
if (follow != null) go.transform.SetParent(follow, true);
return go;
}
/// 입자 길이(시작 지연 + 길이 + 최대 수명) 중 가장 긴 것 · 루프는 MaxAutoLife.
public static float AutoLife(GameObject go)
{
float best = 0.5f;
foreach (var ps in go.GetComponentsInChildren(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);
}
/// 붙어 있던 이펙트(travel) 끝: 방출을 멈추고 떼어 놓은 뒤 남은 입자가 사라지면 지운다.
public static void Stop(Handle h)
{
if (h == null) return;
h.Stopped = true; // 반복(f_every) 멈춤
Stop(h.Objects);
}
public static void Stop(List 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(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(true)) tr.emitting = false;
Object.Destroy(go, Mathf.Min(rest, MaxAutoLife));
}
list.Clear();
}
}
}