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"; /// /// 시험 키(스킬 테스트 씬 「기운 시험」 스위치 · PD 2026-11-10 「몸을 뒤덮는 연출을 스킬용으로 시험」): 비어 있지 않으면 슬링샷 공격 돌진(travel) 때 /// 이 키의 행(표 test_aura 등)을 함께 띄운다 · 실제 전투는 늘 빈칸. /// public static string TestTravelKey = ""; /// 정렬 순서 기준(화살 8 · 광역 7 · 스킬 이름 글자 21 · 유닛 0 위) — 행 n_order 를 더한다. public const int SortingBase = 12; public class Row { /// 키 원문(s_skill · ; 로 여러 개 = 그중 하나와 같으면 해당). public string skill = ""; public string[] keys = new string[0]; /// 진영(s_camp · shu · wei · wu · other) — 빈칸 = 모든 진영 · 유닛 진영이 빈칸(적 병사 등)이면 other 로 본다. 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 = ""; /// feet(발) · body(몸 가운데) · head(머리 끝) · target(피격 대상 몸 가운데 · hit 기본). public string anchor = ""; /// 유닛 머리 끝 높이(ArtHeight) 대비 크기 — 1 = 팩 원본 1 단위를 유닛 키로. public float scale = 1f; public float delay; /// 0 = 입자가 다 사라질 때까지(최대 MaxAutoLife). public float life; /// 기울기(°) · 0 = 옆에서 본 모습 · −90 = 위에서 내려다본 모습(바닥 원·충격파) · face 행은 진행 축을 중심으로 카메라 쪽으로 기울임(). public float tilt; /// true = 이펙트 정면(+Z)을 진행 방향(cast·travel = 슬링샷 방향 · hit = 때린 쪽 → 대상)으로 화면 안에서 돌림(투사체·베기·돌진). public bool face; /// travel 전용 · WL sustain = 시간당 방출 층(버스트 없는 층)을 반복으로 바꿔 돌진 내내 유지(버스트 층은 처음 1번). public bool sustain; /// travel 전용 · WL ClearEnd = 돌진이 끝나는 순간 남은 입자까지 지움(0 = 방출만 멈추고 남은 입자는 수명대로). public bool clear; /// 입자 자체 이동 배율(시작 속도 · 속도 모듈) — 1 = 팩 원본 · 0.1 = 거의 제자리(스스로 앞으로 날아가는 데모용 돌진 이펙트를 유닛에 붙여 감싸게 · MasterStylizedFX Dash). public float drift = 1f; /// 진행 방향 기준 위치 보정(유닛 키 배수 · + 앞 · − 뒤) — 예: 돌진 잔상(Dash)을 캐릭터 뒤쪽에(f_fwd). public float fwd; /// travel 전용 · 슬링샷 이동이 끝날 때 그 자리에서 이 초 동안 흐려지며 사라짐(f_fade · 0 = b_clear/방출만 멈춤 규칙 · b_clear 보다 우선). public float fade; /// 유닛 위에 그릴 자식 층 이름(s_over · ; 로 여러 개) — 나머지 층은 n_order 그대로(이중 레이어 · 예: 혜성 머리 실드만 캐릭터 앞 · PD 2026-11-10). public string[] over = new string[0]; /// /// 2D 평면 이펙트(b_flat · PD 2026-11-10 「돌진 이펙트가 너무 3D 느낌 → 2D 처럼」): 진행 방향으로 화면 안에서만 돌림(Z 축) · f_tilt 와 팩 루트 회전 무시 · /// 프리팹은 화면 평면에 그린 셀 그림(정면 = +X · Assets/VFX/TKS2D). /// public bool flat; /// 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(); /// Stop 때 남은 입자까지 지울 것(b_clear). public readonly HashSet ClearOnStop = new HashSet(); /// Stop 때 그 자리에서 흐려지며 사라질 것(f_fade 초). public readonly Dictionary FadeOnStop = new Dictionary(); 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; 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; } /// 따옴표를 아는 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.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(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(); 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; } /// 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; 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(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); } /// /// 팩 3D 이펙트(위 = +Y · 정면 = +Z)를 2D 전장 화면에 놓는 회전(2026-11-09 PD 「돌진하는 동안 이펙트가 정상적으로 안 보임」 수정). /// face 없음 = X 축 tilt°(0 = 옆모습 · −90 = 바로 위에서 본 모습). /// face 있음 = 옆에서 본 모습으로 화면에 눕혀(정면 → 화면 진행 방향 · 위 → 화면 쪽 수직) 진행 방향으로 화면 안에서 돌린다 — 정면·위로 세운 판(찌르기 섬광 · 돌진 줄기)이 /// 어느 방향으로 가든 납작하게 보이지 않게(3D 그대로 두면 위쪽 돌진 때 판의 옆면만 보임 · 실측) · tilt = 진행 축을 중심으로 카메라 쪽으로 기울이는 각도(−90 = 위에서 본 모습 · 바닥 원이 보임) · /// 왼쪽으로 갈 때는 위아래가 뒤집히지 않게 반대로 돌림. /// /// /// 프리팹 원본 루트 회전 r0 을 살린 화면 회전(2026-11-10 PD 「스크린샷 분석해서 다시 적용」 — MasterStylizedFX 투사체·Dash 는 루트에 (0, 90, 270) 같은 회전이 들어 있어 /// 덮어쓰면 혜성 판이 옆으로 서서 가늘게만 보임 · 실측): 팩 데모 세계에서 r0 로 놓인 이펙트의 진행 축(r0 · +Z 의 수평 성분)을 +Z 로 돌려 맞춘 뒤 FxRotation 을 건다. /// 루트 회전이 0 인 사본(WL 창 돌진 등)은 FxRotation 과 같다. /// 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); } /// 2D 평면 이펙트 회전(b_flat): 정면 +X 를 진행 방향으로 화면 안에서만 돌림 · 방향 없음 = 그대로. 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); } /// 입자 자체 이동(시작 속도 · 속도 모듈)을 k 배로(f_drift). static void ScaleSelfMotion(GameObject go, float k) { k = Mathf.Max(0f, k); foreach (var ps in go.GetComponentsInChildren(true)) { var m = ps.main; m.startSpeedMultiplier *= k; var v = ps.velocityOverLifetime; if (v.enabled) v.speedModifierMultiplier *= k; } } /// WL WLStaffEarth.Sustain 과 같음 — 시간당 방출(초당 5 이상) · 버스트 없는 층만 반복으로(돌진 내내 이어짐 · 버스트 층은 처음 1번만). static void Sustain(GameObject go) { foreach (var ps in go.GetComponentsInChildren(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; } } /// /// 프리팹 1개 띄우기(표 행 · 스킬 테스트 씬 이펙트 보기 공용). worldScale = 루트 배율 · 입자 크기 모드를 전부 Hierarchy 로(Local 이면 루트 배율이 안 먹음 · K-WL-325) · /// 트레일·선 굵기도 배율 · 렌더러 정렬 = order · 회전은 . /// 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(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) 멈춤 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(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 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; rest = Mathf.Max(rest, tr.time); // 꼬리가 제 길이만큼 줄어 사라질 때까지(IB 속도 꼬리 0.6초 · 2026-11-10) } Object.Destroy(go, Mathf.Min(rest, MaxAutoLife)); } list.Clear(); } } }