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 ;
2026-11-11 14:51:31 +00:00
/// <summary>이 클립의 초당 프레임(0 = meta fps) — 대기 2~3 프레임 숨 고르기(PD 2026-11-11 · 원화 픽셀을 그대로 눌러 만든 f001·f002).</summary>
public float fps ;
/// <summary>왕복 재생(0-1-2-1-0 …) — 끝 프레임에서 첫 프레임으로 튀지 않게.</summary>
public bool pingpong ;
/// <summary>프레임 번호별 유지 초(있으면 fps 대신 · 왕복이면 돌아올 때도 같은 번호 값) · sequence 가 있으면 그 단계별 유지 초.</summary>
public float [ ] durations ;
/// <summary>재생 순서(프레임 번호 배열 · 한 방향 반복) — 대기 4 자세 0→1→2→3(Fable 제안 · PD 2026-11-11 「4장 + 깜빡임」) · 없으면 0..n-1(또는 왕복).</summary>
public int [ ] sequence ;
/// <summary>가끔 눈 깜빡임(쉼 자세 = sequence 첫 프레임일 때만 · 깜빡임 프레임은 sequence 에 넣지 않음).</summary>
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 ;
2026-11-04 04:05:46 +00:00
}
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-11 14:51:31 +00:00
/// <summary>클립별 초당 프레임(meta 클립 fps · 없으면 meta fps).</summary>
public readonly Dictionary < string , float > Fps = new Dictionary < string , float > ( ) ;
public readonly Dictionary < string , bool > PingPong = new Dictionary < string , bool > ( ) ;
/// <summary>클립별 프레임 번호 유지 초(meta 클립 durations · 없으면 fps 고른 간격).</summary>
public readonly Dictionary < string , float [ ] > Durations = new Dictionary < string , float [ ] > ( ) ;
/// <summary>클립별 재생 순서(meta 클립 sequence) · 깜빡임.</summary>
public readonly Dictionary < string , int [ ] > Sequences = new Dictionary < string , int [ ] > ( ) ;
public readonly Dictionary < string , UnitArtMeta . Blink > Blinks = new Dictionary < string , UnitArtMeta . Blink > ( ) ;
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 ;
}
/// <summary>이 클립의 깜빡임(프레임이 있으면) · 없으면 null.</summary>
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 ;
}
/// <summary>깜빡임을 끼울 수 있는 프레임(쉼 = 순서의 첫 프레임 · 순서 없으면 0).</summary>
public int RestFrame ( string clip , int n )
{
var sq = ClipSequence ( clip , n ) ;
return sq ! = null ? sq [ 0 ] : 0 ;
}
/// <summary>
/// 깜빡임 끼우기 — 쉼 프레임일 때 nextBlink 시각이 지났으면 dur 동안 깜빡임 프레임 · 다음 시각 = 무작위(every_min~max) · 쉼이 아니면 다음 쉼까지 미룸.
/// now = 재생기의 시계(초) · nextBlink 0 = 아직 안 정함 · 재생기마다 자기 값(유닛마다 엇갈림).
/// </summary>
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 ;
}
/// <summary>재생 순서 s 번째의 프레임 번호 — 왕복이면 0..n-1 다음 n-2..1.</summary>
static int SeqIndex ( int s , int n , bool pp )
{
return ! pp | | s < n ? s : 2 * n - 2 - s ;
}
/// <summary>한 바퀴 초(반복 클립 시작 시점을 흩을 때).</summary>
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 ;
}
/// <summary>재생 시간 t(초)의 프레임 번호 — 반복 · 왕복(0..n-1..1) · 프레임별 유지 초 포함.</summary>
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 ) ;
}
2026-11-09 10:23:50 +00:00
/// <summary>
/// 크기 기준 px = Resources/Units/art_size.csv 의 size_px(눈 간격 ÷ 기준 비율 = 머리 크기가 표준인 유닛의 키로 환산 · Tools/unit_art_size.py) ·
/// 0 = 표에 없음 → BodyPx. PD 2026-11-09 「캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」.
/// </summary>
public float SizePx ;
2026-11-09 10:33:00 +00:00
/// <summary>말 탄 유닛(크기 표 mounted = 1) — 게임 배치 때 머리 끝 상한(BalanceConfig.mountedArtMaxTop)을 받는다.</summary>
public bool Mounted ;
2026-11-10 04:13:46 +00:00
/// <summary>대기 그림의 눈 높이(발 위 px · 크기 표 eye_up_px) · 0 = 없음 — 영웅 목록 카드가 눈 줄을 맞춘다(PD 2026-11-10 「전위·태사자처럼 너무 위쪽에 보이는 영웅은 내려」).</summary>
public float EyeUpPx ;
2026-11-09 10:23:50 +00:00
/// <summary>그림 속 몸(머리 끝~발) 세로 px — bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx.</summary>
public float BodyPx
{
get { return Meta . bodyHeightPx > 0.5f ? Meta . bodyHeightPx : Meta . charHeightPx ; }
}
2026-11-04 04:05:46 +00:00
2026-11-09 10:23:50 +00:00
/// <summary>배율 기준 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · 크기 표 size_px 우선(모든 유닛의 머리 크기가 같아짐), 없으면 BodyPx.</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 ;
2026-11-09 10:23:50 +00:00
return ( SizePx > 0.5f ? SizePx : BodyPx ) / Meta . ppu ;
2026-11-04 11:27:26 +00:00
}
2026-11-04 04:05:46 +00:00
}
}
/// <summary>유닛 아트 로더(Resources · 캐시). 아트가 없으면 null → UnitView 가 도형으로 그린다.</summary>
public static class UnitArt
{
public const string Root = "Units/" ;
2026-11-09 10:23:50 +00:00
/// <summary>크기 표(TextAsset · utf-8-sig CSV · 열 art · size_px) = Resources/Units/art_size.csv.</summary>
public const string SizeTable = "art_size" ;
2026-11-04 04:05:46 +00:00
static readonly Dictionary < string , UnitArtData > cache = new Dictionary < string , UnitArtData > ( ) ;
2026-11-09 10:23:50 +00:00
static Dictionary < string , float > sizes ;
2026-11-10 04:13:46 +00:00
static Dictionary < string , float > eyeUps ;
2026-11-09 10:33:00 +00:00
static HashSet < string > mounted ;
2026-11-09 10:23:50 +00:00
/// <summary>크기 표의 size_px(없으면 0).</summary>
public static float SizeOf ( string id )
{
2026-11-09 10:33:00 +00:00
EnsureSizes ( ) ;
2026-11-09 10:23:50 +00:00
float v ;
return id ! = null & & sizes . TryGetValue ( id , out v ) ? v : 0f ;
}
2026-11-10 04:13:46 +00:00
/// <summary>크기 표의 eye_up_px(대기 그림 눈 높이 · 발 위 px · 없으면 0).</summary>
public static float EyeUpOf ( string id )
{
EnsureSizes ( ) ;
float v ;
return id ! = null & & eyeUps . TryGetValue ( id , out v ) ? v : 0f ;
}
2026-11-09 10:33:00 +00:00
/// <summary>크기 표 mounted = 1(말 탄 유닛).</summary>
public static bool IsMounted ( string id )
2026-11-09 10:23:50 +00:00
{
2026-11-09 10:33:00 +00:00
EnsureSizes ( ) ;
return id ! = null & & mounted . Contains ( id ) ;
}
static void EnsureSizes ( )
{
if ( sizes ! = null ) return ;
sizes = new Dictionary < string , float > ( ) ;
2026-11-10 04:13:46 +00:00
eyeUps = new Dictionary < string , float > ( ) ;
2026-11-09 10:33:00 +00:00
mounted = new HashSet < string > ( ) ;
var ta = Resources . Load < TextAsset > ( Root + SizeTable ) ;
if ( ta = = null ) return ;
2026-11-09 10:23:50 +00:00
var lines = ta . text . TrimStart ( ' ' ) . Split ( '\n' ) ;
var head = new List < string > ( lines [ 0 ] . Trim ( ) . Split ( ',' ) ) ;
2026-11-10 04:13:46 +00:00
int ia = head . IndexOf ( "art" ) , isz = head . IndexOf ( "size_px" ) , im = head . IndexOf ( "mounted" ) , ieu = head . IndexOf ( "eye_up_px" ) ;
2026-11-09 10:33:00 +00:00
if ( ia < 0 | | isz < 0 | | im < 0 )
2026-11-09 10:23:50 +00:00
{
2026-11-09 10:33:00 +00:00
Debug . LogWarning ( "[TKS] 유닛 크기 표 열(art · mounted · size_px) 없음" ) ;
return ;
2026-11-09 10:23:50 +00:00
}
2026-11-09 10:33:00 +00:00
int need = Mathf . Max ( ia , Mathf . Max ( isz , im ) ) ;
2026-11-09 10:23:50 +00:00
for ( int i = 1 ; i < lines . Length ; i + + )
{
var c = lines [ i ] . Trim ( ) . Split ( ',' ) ;
2026-11-09 10:33:00 +00:00
if ( c . Length < = need ) continue ;
2026-11-09 10:23:50 +00:00
float v ;
2026-11-09 10:33:00 +00:00
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 ] ) ;
2026-11-10 04:13:46 +00:00
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 ;
2026-11-09 10:23:50 +00:00
}
}
2026-11-04 04:05:46 +00:00
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 ( ) ;
2026-11-09 10:23:50 +00:00
sizes = null ;
2026-11-10 04:13:46 +00:00
eyeUps = null ;
2026-11-09 10:33:00 +00:00
mounted = null ;
2026-11-04 04:05:46 +00:00
}
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 ;
2026-11-10 04:13:46 +00:00
var d = new UnitArtData { Id = id , Meta = meta , SizePx = SizeOf ( id ) , Mounted = IsMounted ( id ) , EyeUpPx = EyeUpOf ( id ) } ;
2026-11-04 04:05:46 +00:00
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 ;
2026-11-11 14:51:31 +00:00
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 ;
2026-11-04 04:05:46 +00:00
}
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 ;
2026-11-11 14:51:31 +00:00
float nextBlink ;
float blinkEnd = - 1f ;
2026-11-04 04:05:46 +00:00
public SpriteRenderer Renderer
{
get { return sr ; }
}
public string Current
{
get { return current ; }
}
public string IdleClip
{
get { return idle ; }
}
public UnitArtData Data
{
get { return data ; }
}
2026-11-09 10:23:50 +00:00
/// <summary>그려진 머리 끝 높이(월드 · 발 기준) = BodyPx × 배율 — 크기 표로 커진 기마 유닛은 표준 키보다 높다.</summary>
public float TopHeight { get ; private set ; }
/// <param name="worldHeight">표준 키(월드) — 크기 표 size_px 가 이 높이가 되도록 배율(= 머리 크기 통일).</param>
2026-11-09 10:33:00 +00:00
/// <param name="mountedMaxTop">말 탄 유닛의 머리 끝 상한 = worldHeight × 이 값(0 = 상한 없음 · 넘으면 배율을 줄임 = 탄 사람은 보병보다 조금 작아짐).</param>
public void Setup ( UnitArtData d , float worldHeight , int sortingOrder , float mountedMaxTop = 0f )
2026-11-04 04:05:46 +00:00
{
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 ;
2026-11-09 10:33:00 +00:00
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 ;
}
2026-11-04 04:05:46 +00:00
transform . localScale = new Vector3 ( s , s , 1f ) ;
2026-11-09 10:33:00 +00:00
TopHeight = top ;
2026-11-04 04:05:46 +00:00
}
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 ;
2026-11-11 14:51:31 +00:00
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 ) ] ;
2026-11-04 04:05:46 +00:00
return true ;
}
public void PlayIdle ( )
{
if ( idle ! = null ) Play ( idle , true ) ;
}
void Update ( )
{
if ( frames = = null | | frames . Length = = 0 ) return ;
t + = Time . deltaTime ;
2026-11-11 14:51:31 +00:00
float fps = data . ClipFps ( current ) ;
2026-11-04 04:05:46 +00:00
int n = frames . Length ;
int idx ;
if ( loop )
{
2026-11-11 14:51:31 +00:00
idx = data . ApplyBlink ( current , n , data . FrameAt ( current , n , t ) , t , ref nextBlink , ref blinkEnd ) ;
2026-11-04 04:05:46 +00:00
}
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 ;
}
}
2026-11-11 14:51:31 +00:00
if ( sr . sprite ! = frames [ idx ] ) sr . sprite = frames [ idx ] ;
2026-11-04 04:05:46 +00:00
}
}
}