ThreeKingdomsSlingshot/Assets/Scripts/Runtime/UnitArt.cs

202 lines
6.6 KiB
C#

using System;
using System.Collections.Generic;
using UnityEngine;
namespace TKS
{
/// <summary>
/// Resources/Units/&lt;art&gt;/meta.json — 조직 스킬 skeletal-anim-pipeline 의 `rig_frames_export.py` 가 쓴다.
/// 프레임 PNG 는 Resources/Units/&lt;art&gt;/&lt;clip&gt;/f000.png … (임포트 설정 = Editor/UnitArtImporter).
/// </summary>
[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;
/// <summary>발 위치(리그 원점) — 왼쪽 아래 (0,0) 기준 비율.</summary>
public float[] pivot;
/// <summary>대기 첫 프레임의 캐릭터 불투명 세로 픽셀(무기 포함) — bodyHeightPx 가 없을 때의 세로 크기 기준.</summary>
public float charHeightPx;
/// <summary>몸(머리 끝~발) 세로 픽셀 · 무기 제외 — 리그 export 는 ppu(= 리그 1단위) · 정지 그림은 굵은 행 범위(2026-11-04 스켈레탈 세션 안내: charHeightPx 로 맞추면 긴 무기를 든 인물이 작아짐).</summary>
public float bodyHeightPx;
public Clip[] clips;
}
public class UnitArtData
{
public string Id;
public UnitArtMeta Meta;
public readonly Dictionary<string, Sprite[]> Clips = new Dictionary<string, Sprite[]>();
public readonly Dictionary<string, bool> Loop = new Dictionary<string, bool>();
/// <summary>캐릭터 몸 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx.</summary>
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;
}
}
}
/// <summary>유닛 아트 로더(Resources · 캐시). 아트가 없으면 null → UnitView 가 도형으로 그린다.</summary>
public static class UnitArt
{
public const string Root = "Units/";
static readonly Dictionary<string, UnitArtData> cache = new Dictionary<string, UnitArtData>();
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<TextAsset>(Root + id + "/meta");
if (metaAsset == null) return null;
UnitArtMeta meta;
try
{
meta = JsonUtility.FromJson<UnitArtMeta>(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<Sprite>(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;
}
}
/// <summary>프레임 애니 재생기. 비반복 클립은 끝난 뒤(유지 시간 포함) 대기 클립으로 돌아갈 수 있다.</summary>
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<SpriteRenderer>();
if (sr == null) sr = gameObject.AddComponent<SpriteRenderer>();
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;
}
/// <param name="holdSeconds">비반복 클립을 최소 이만큼 보여준 뒤 대기로(1프레임 자세용).</param>
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];
}
}
}