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; } 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(); /// 캐릭터 몸 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx. public float CharHeightWorld { get { if (Meta.ppu <= 0.01f) return 1f; float px = Meta.bodyHeightPx > 0.5f ? Meta.bodyHeightPx : Meta.charHeightPx; return px / Meta.ppu; } } } /// 유닛 아트 로더(Resources · 캐시). 아트가 없으면 null → UnitView 가 도형으로 그린다. public static class UnitArt { public const string Root = "Units/"; static readonly Dictionary cache = new Dictionary(); 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(); } 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 }; 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; } 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; public SpriteRenderer Renderer { get { return sr; } } public string Current { get { return current; } } public string IdleClip { get { return idle; } } public UnitArtData Data { get { return data; } } public void Setup(UnitArtData d, float worldHeight, int sortingOrder) { 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; transform.localScale = new Vector3(s, s, 1f); } 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; sr.sprite = frames[0]; 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 = Mathf.Max(1f, data.Meta.fps); int n = frames.Length; int idx; if (loop) { idx = Mathf.FloorToInt(t * fps) % n; } 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; } } sr.sprite = frames[idx]; } } }