using System; using System.Collections.Generic; using UnityEngine; namespace TKS { /// /// Resources/Units/<art>/meta.json — 조직 스킬 skeletal-anim-pipeline 의 `rig_frames_export.py` 가 쓴다. /// 프레임 PNG 는 Resources/Units/<art>/<clip>/f000.png … (임포트 설정 = Editor/UnitArtImporter). /// [Serializable] public class UnitArtMeta { [Serializable] public class Clip { public string name; public int frames; public bool loop; /// 이 클립의 초당 프레임(0 = meta fps) — 대기 2~3 프레임 숨 고르기(PD 2026-11-11 · 원화 픽셀을 그대로 눌러 만든 f001·f002). public float fps; /// 왕복 재생(0-1-2-1-0 …) — 끝 프레임에서 첫 프레임으로 튀지 않게. public bool pingpong; /// 프레임 번호별 유지 초(있으면 fps 대신 · 왕복이면 돌아올 때도 같은 번호 값) · sequence 가 있으면 그 단계별 유지 초. public float[] durations; /// 재생 순서(프레임 번호 배열 · 한 방향 반복) — 대기 4 자세 0→1→2→3(Fable 제안 · PD 2026-11-11 「4장 + 깜빡임」) · 없으면 0..n-1(또는 왕복). public int[] sequence; /// 가끔 눈 깜빡임(쉼 자세 = sequence 첫 프레임일 때만 · 깜빡임 프레임은 sequence 에 넣지 않음). public Blink blink; } [Serializable] public class Blink { public int frame = -1; public float every_min = 2.5f; public float every_max = 5f; public float dur = 0.1f; } public float fps = 15f; public float ppu = 100f; public int[] frame; /// 발 위치(리그 원점) — 왼쪽 아래 (0,0) 기준 비율. public float[] pivot; /// 대기 첫 프레임의 캐릭터 불투명 세로 픽셀(무기 포함) — bodyHeightPx 가 없을 때의 세로 크기 기준. public float charHeightPx; /// 몸(머리 끝~발) 세로 픽셀 · 무기 제외 — 리그 export 는 ppu(= 리그 1단위) · 정지 그림은 굵은 행 범위(2026-11-04 스켈레탈 세션 안내: charHeightPx 로 맞추면 긴 무기를 든 인물이 작아짐). public float bodyHeightPx; public Clip[] clips; } public class UnitArtData { public string Id; public UnitArtMeta Meta; public readonly Dictionary Clips = new Dictionary(); public readonly Dictionary Loop = new Dictionary(); /// 클립별 초당 프레임(meta 클립 fps · 없으면 meta fps). public readonly Dictionary Fps = new Dictionary(); public readonly Dictionary PingPong = new Dictionary(); /// 클립별 프레임 번호 유지 초(meta 클립 durations · 없으면 fps 고른 간격). public readonly Dictionary Durations = new Dictionary(); /// 클립별 재생 순서(meta 클립 sequence) · 깜빡임. public readonly Dictionary Sequences = new Dictionary(); public readonly Dictionary Blinks = new Dictionary(); int[] ClipSequence(string clip, int n) { int[] sq; if (clip == null || !Sequences.TryGetValue(clip, out sq) || sq == null || sq.Length == 0) return null; foreach (int k in sq) if (k < 0 || k >= n) return null; return sq; } /// 이 클립의 깜빡임(프레임이 있으면) · 없으면 null. public UnitArtMeta.Blink ClipBlink(string clip, int n) { UnitArtMeta.Blink b; return clip != null && Blinks.TryGetValue(clip, out b) && b != null && b.frame >= 0 && b.frame < n ? b : null; } /// 깜빡임을 끼울 수 있는 프레임(쉼 = 순서의 첫 프레임 · 순서 없으면 0). public int RestFrame(string clip, int n) { var sq = ClipSequence(clip, n); return sq != null ? sq[0] : 0; } /// /// 깜빡임 끼우기 — 쉼 프레임일 때 nextBlink 시각이 지났으면 dur 동안 깜빡임 프레임 · 다음 시각 = 무작위(every_min~max) · 쉼이 아니면 다음 쉼까지 미룸. /// now = 재생기의 시계(초) · nextBlink 0 = 아직 안 정함 · 재생기마다 자기 값(유닛마다 엇갈림). /// public int ApplyBlink(string clip, int n, int idx, float now, ref float nextBlink, ref float blinkEnd) { var b = ClipBlink(clip, n); if (b == null) return idx; int rest = RestFrame(clip, n); if (now < blinkEnd) return idx == rest ? b.frame : idx; if (nextBlink <= 0f) nextBlink = now + UnityEngine.Random.Range(b.every_min, b.every_max); if (now >= nextBlink && idx == rest) { blinkEnd = now + b.dur; nextBlink = blinkEnd + UnityEngine.Random.Range(b.every_min, b.every_max); return b.frame; } return idx; } public float ClipFps(string clip) { float f; return clip != null && Fps.TryGetValue(clip, out f) && f > 0.01f ? f : Mathf.Max(0.01f, Meta.fps); } bool IsPingPong(string clip) { bool pp; return clip != null && PingPong.TryGetValue(clip, out pp) && pp; } float[] ClipDurations(string clip, int n) { float[] du; return clip != null && Durations.TryGetValue(clip, out du) && du != null && du.Length >= n ? du : null; } /// 재생 순서 s 번째의 프레임 번호 — 왕복이면 0..n-1 다음 n-2..1. static int SeqIndex(int s, int n, bool pp) { return !pp || s < n ? s : 2 * n - 2 - s; } /// 한 바퀴 초(반복 클립 시작 시점을 흩을 때). public float CycleSeconds(string clip, int n) { var sq = ClipSequence(clip, n); if (sq != null) { float[] sd; bool hasD = Durations.TryGetValue(clip, out sd) && sd != null && sd.Length >= sq.Length; float tot = 0f; for (int s = 0; s < sq.Length; s++) tot += hasD ? Mathf.Max(0.01f, sd[s]) : 1f / ClipFps(clip); return tot; } bool pp = IsPingPong(clip); int steps = pp && n > 1 ? 2 * n - 2 : n; var du = ClipDurations(clip, n); if (du == null) return steps / ClipFps(clip); float total = 0f; for (int s = 0; s < steps; s++) total += Mathf.Max(0.01f, du[SeqIndex(s, n, pp)]); return total; } /// 재생 시간 t(초)의 프레임 번호 — 반복 · 왕복(0..n-1..1) · 프레임별 유지 초 포함. public int FrameAt(string clip, int n, float t) { if (n <= 1) return 0; var sq = ClipSequence(clip, n); if (sq != null) { float[] sd; bool hasD = Durations.TryGetValue(clip, out sd) && sd != null && sd.Length >= sq.Length; float x = Mathf.Repeat(t, CycleSeconds(clip, n)); for (int s = 0; s < sq.Length; s++) { x -= hasD ? Mathf.Max(0.01f, sd[s]) : 1f / ClipFps(clip); if (x < 0f) return sq[s]; } return sq[sq.Length - 1]; } bool pp = IsPingPong(clip); int steps = pp ? 2 * n - 2 : n; var du = ClipDurations(clip, n); if (du != null) { float x = Mathf.Repeat(t, CycleSeconds(clip, n)); for (int s = 0; s < steps; s++) { int k = SeqIndex(s, n, pp); x -= Mathf.Max(0.01f, du[k]); if (x < 0f) return k; } return SeqIndex(steps - 1, n, pp); } int i = Mathf.FloorToInt(t * ClipFps(clip)) % steps; return SeqIndex(i, n, pp); } /// /// 크기 기준 px = Resources/Units/art_size.csv 의 size_px(눈 간격 ÷ 기준 비율 = 머리 크기가 표준인 유닛의 키로 환산 · Tools/unit_art_size.py) · /// 0 = 표에 없음 → BodyPx. PD 2026-11-09 「캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」. /// public float SizePx; /// 말 탄 유닛(크기 표 mounted = 1) — 게임 배치 때 머리 끝 상한(BalanceConfig.mountedArtMaxTop)을 받는다. public bool Mounted; /// 대기 그림의 눈 높이(발 위 px · 크기 표 eye_up_px) · 0 = 없음 — 영웅 목록 카드가 눈 줄을 맞춘다(PD 2026-11-10 「전위·태사자처럼 너무 위쪽에 보이는 영웅은 내려」). public float EyeUpPx; /// 그림 속 몸(머리 끝~발) 세로 px — bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx. public float BodyPx { get { return Meta.bodyHeightPx > 0.5f ? Meta.bodyHeightPx : Meta.charHeightPx; } } /// 배율 기준 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · 크기 표 size_px 우선(모든 유닛의 머리 크기가 같아짐), 없으면 BodyPx. public float CharHeightWorld { get { if (Meta.ppu <= 0.01f) return 1f; return (SizePx > 0.5f ? SizePx : BodyPx) / Meta.ppu; } } } /// 유닛 아트 로더(Resources · 캐시). 아트가 없으면 null → UnitView 가 도형으로 그린다. public static class UnitArt { public const string Root = "Units/"; /// 크기 표(TextAsset · utf-8-sig CSV · 열 art · size_px) = Resources/Units/art_size.csv. public const string SizeTable = "art_size"; static readonly Dictionary cache = new Dictionary(); static Dictionary sizes; static Dictionary eyeUps; static HashSet mounted; /// 크기 표의 size_px(없으면 0). public static float SizeOf(string id) { EnsureSizes(); float v; return id != null && sizes.TryGetValue(id, out v) ? v : 0f; } /// 크기 표의 eye_up_px(대기 그림 눈 높이 · 발 위 px · 없으면 0). public static float EyeUpOf(string id) { EnsureSizes(); float v; return id != null && eyeUps.TryGetValue(id, out v) ? v : 0f; } /// 크기 표 mounted = 1(말 탄 유닛). public static bool IsMounted(string id) { EnsureSizes(); return id != null && mounted.Contains(id); } static void EnsureSizes() { if (sizes != null) return; sizes = new Dictionary(); eyeUps = new Dictionary(); mounted = new HashSet(); var ta = Resources.Load(Root + SizeTable); if (ta == null) return; var lines = ta.text.TrimStart('').Split('\n'); var head = new List(lines[0].Trim().Split(',')); int ia = head.IndexOf("art"), isz = head.IndexOf("size_px"), im = head.IndexOf("mounted"), ieu = head.IndexOf("eye_up_px"); if (ia < 0 || isz < 0 || im < 0) { Debug.LogWarning("[TKS] 유닛 크기 표 열(art · mounted · size_px) 없음"); return; } int need = Mathf.Max(ia, Mathf.Max(isz, im)); for (int i = 1; i < lines.Length; i++) { var c = lines[i].Trim().Split(','); if (c.Length <= need) continue; float v; if (float.TryParse(c[isz], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v) && v > 0f) sizes[c[ia]] = v; if (c[im] == "1") mounted.Add(c[ia]); if (ieu >= 0 && ieu < c.Length && float.TryParse(c[ieu], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out v) && v > 0f) eyeUps[c[ia]] = v; } } public static UnitArtData Load(string id) { if (string.IsNullOrEmpty(id)) return null; UnitArtData d; if (cache.TryGetValue(id, out d)) return d; d = LoadUncached(id); cache[id] = d; return d; } public static void ClearCache() { cache.Clear(); sizes = null; eyeUps = null; mounted = null; } static UnitArtData LoadUncached(string id) { var metaAsset = Resources.Load(Root + id + "/meta"); if (metaAsset == null) return null; UnitArtMeta meta; try { meta = JsonUtility.FromJson(metaAsset.text); } catch (Exception e) { Debug.LogWarning("[TKS] 유닛 아트 meta 해석 실패 " + id + ": " + e.Message); return null; } if (meta == null || meta.clips == null) return null; var d = new UnitArtData { Id = id, Meta = meta, SizePx = SizeOf(id), Mounted = IsMounted(id), EyeUpPx = EyeUpOf(id) }; foreach (var c in meta.clips) { if (c == null || string.IsNullOrEmpty(c.name)) continue; var sprites = Resources.LoadAll(Root + id + "/" + c.name); if (sprites == null || sprites.Length == 0) continue; Array.Sort(sprites, (a, b) => string.CompareOrdinal(a.name, b.name)); d.Clips[c.name] = sprites; d.Loop[c.name] = c.loop; if (c.fps > 0.01f) d.Fps[c.name] = c.fps; d.PingPong[c.name] = c.pingpong; if (c.durations != null && c.durations.Length > 0) d.Durations[c.name] = c.durations; if (c.sequence != null && c.sequence.Length > 0) d.Sequences[c.name] = c.sequence; if (c.blink != null && c.blink.frame >= 0) d.Blinks[c.name] = c.blink; } return d.Clips.Count > 0 ? d : null; } } /// 프레임 애니 재생기. 비반복 클립은 끝난 뒤(유지 시간 포함) 대기 클립으로 돌아갈 수 있다. public class FrameAnimator : MonoBehaviour { SpriteRenderer sr; UnitArtData data; Sprite[] frames; string current; string idle; bool loop; float t; float hold; bool backToIdle; float nextBlink; float blinkEnd = -1f; public SpriteRenderer Renderer { get { return sr; } } public string Current { get { return current; } } public string IdleClip { get { return idle; } } public UnitArtData Data { get { return data; } } /// 그려진 머리 끝 높이(월드 · 발 기준) = BodyPx × 배율 — 크기 표로 커진 기마 유닛은 표준 키보다 높다. public float TopHeight { get; private set; } /// 표준 키(월드) — 크기 표 size_px 가 이 높이가 되도록 배율(= 머리 크기 통일). /// 말 탄 유닛의 머리 끝 상한 = worldHeight × 이 값(0 = 상한 없음 · 넘으면 배율을 줄임 = 탄 사람은 보병보다 조금 작아짐). public void Setup(UnitArtData d, float worldHeight, int sortingOrder, float mountedMaxTop = 0f) { data = d; sr = GetComponent(); if (sr == null) sr = gameObject.AddComponent(); sr.sortingOrder = sortingOrder; float h = d.CharHeightWorld; float s = h > 0.001f ? worldHeight / h : 1f; float top = d.Meta.ppu > 0.01f ? d.BodyPx / d.Meta.ppu * s : worldHeight; if (d.Mounted && mountedMaxTop > 0.01f && top > worldHeight * mountedMaxTop) { s *= worldHeight * mountedMaxTop / top; top = worldHeight * mountedMaxTop; } transform.localScale = new Vector3(s, s, 1f); TopHeight = top; } public bool Has(string clip) { return data != null && clip != null && data.Clips.ContainsKey(clip); } public void SetIdle(string clip) { idle = clip; } /// 비반복 클립을 최소 이만큼 보여준 뒤 대기로(1프레임 자세용). public bool Play(string clip, bool loopPlay, float holdSeconds = 0f, bool returnToIdle = false) { if (!Has(clip)) return false; frames = data.Clips[clip]; current = clip; loop = loopPlay; hold = holdSeconds; backToIdle = returnToIdle; t = 0f; nextBlink = 0f; blinkEnd = -1f; // 여러 프레임 반복(대기 숨 고르기) = 유닛마다 시작 시점을 달리(모두 같이 숨 쉬면 어색) if (loopPlay && frames.Length > 1) t = UnityEngine.Random.value * data.CycleSeconds(clip, frames.Length); sr.sprite = frames[data.FrameAt(clip, frames.Length, t)]; return true; } public void PlayIdle() { if (idle != null) Play(idle, true); } void Update() { if (frames == null || frames.Length == 0) return; t += Time.deltaTime; float fps = data.ClipFps(current); int n = frames.Length; int idx; if (loop) { idx = data.ApplyBlink(current, n, data.FrameAt(current, n, t), t, ref nextBlink, ref blinkEnd); } else { idx = Mathf.Min(n - 1, Mathf.FloorToInt(t * fps)); float dur = n / fps; if (t >= dur && t >= hold && backToIdle && idle != null && current != idle) { PlayIdle(); return; } } if (sr.sprite != frames[idx]) sr.sprite = frames[idx]; } } }