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(); } } }