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