202 lines
6.6 KiB
C#
202 lines
6.6 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using UnityEngine;
|
|
|
|
namespace TKS
|
|
{
|
|
/// <summary>
|
|
/// Resources/Units/<art>/meta.json — 조직 스킬 skeletal-anim-pipeline 의 `rig_frames_export.py` 가 쓴다.
|
|
/// 프레임 PNG 는 Resources/Units/<art>/<clip>/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];
|
|
}
|
|
}
|
|
}
|