2026-11-04 04:05:46 +00:00
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 ;
2026-11-04 11:27:26 +00:00
/// <summary>대기 첫 프레임의 캐릭터 불투명 세로 픽셀(무기 포함) — bodyHeightPx 가 없을 때의 세로 크기 기준.</summary>
2026-11-04 04:05:46 +00:00
public float charHeightPx ;
2026-11-04 11:27:26 +00:00
/// <summary>몸(머리 끝~발) 세로 픽셀 · 무기 제외 — 리그 export 는 ppu(= 리그 1단위) · 정지 그림은 굵은 행 범위(2026-11-04 스켈레탈 세션 안내: charHeightPx 로 맞추면 긴 무기를 든 인물이 작아짐).</summary>
public float bodyHeightPx ;
2026-11-04 04:05:46 +00:00
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 > ( ) ;
2026-11-04 11:27:26 +00:00
/// <summary>캐릭터 몸 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx.</summary>
2026-11-04 04:05:46 +00:00
public float CharHeightWorld
{
2026-11-04 11:27:26 +00:00
get
{
if ( Meta . ppu < = 0.01f ) return 1f ;
float px = Meta . bodyHeightPx > 0.5f ? Meta . bodyHeightPx : Meta . charHeightPx ;
return px / Meta . ppu ;
}
2026-11-04 04:05:46 +00:00
}
}
/// <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 ] ;
}
}
}