ThreeKingdomsSlingshot/Assets/Scripts/Runtime/UnitArt.cs

451 lines
19 KiB
C#
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
/// <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;
}
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>클립별 초당 프레임(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);
}
/// <summary>
/// 크기 기준 px = Resources/Units/art_size.csv 의 size_px(눈 간격 ÷ 기준 비율 = 머리 크기가 표준인 유닛의 키로 환산 · Tools/unit_art_size.py) ·
/// 0 = 표에 없음 → BodyPx. PD 2026-11-09 「캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」.
/// </summary>
public float SizePx;
/// <summary>말 탄 유닛(크기 표 mounted = 1) — 게임 배치 때 머리 끝 상한(BalanceConfig.mountedArtMaxTop)을 받는다.</summary>
public bool Mounted;
/// <summary>대기 그림의 눈 높이(발 위 px · 크기 표 eye_up_px) · 0 = 없음 — 영웅 목록 카드가 눈 줄을 맞춘다(PD 2026-11-10 「전위·태사자처럼 너무 위쪽에 보이는 영웅은 내려」).</summary>
public float EyeUpPx;
/// <summary>그림 속 몸(머리 끝~발) 세로 px — bodyHeightPx(무기 제외) 우선, 없으면 charHeightPx.</summary>
public float BodyPx
{
get { return Meta.bodyHeightPx > 0.5f ? Meta.bodyHeightPx : Meta.charHeightPx; }
}
/// <summary>배율 기준 세로 크기(월드) — 임포터 PPU = meta.ppu 전제 · 크기 표 size_px 우선(모든 유닛의 머리 크기가 같아짐), 없으면 BodyPx.</summary>
public float CharHeightWorld
{
get
{
if (Meta.ppu <= 0.01f) return 1f;
return (SizePx > 0.5f ? SizePx : BodyPx) / Meta.ppu;
}
}
}
/// <summary>유닛 아트 로더(Resources · 캐시). 아트가 없으면 null → UnitView 가 도형으로 그린다.</summary>
public static class UnitArt
{
public const string Root = "Units/";
/// <summary>크기 표(TextAsset · utf-8-sig CSV · 열 art · size_px) = Resources/Units/art_size.csv.</summary>
public const string SizeTable = "art_size";
static readonly Dictionary<string, UnitArtData> cache = new Dictionary<string, UnitArtData>();
static Dictionary<string, float> sizes;
static Dictionary<string, float> eyeUps;
static HashSet<string> mounted;
/// <summary>크기 표의 size_px(없으면 0).</summary>
public static float SizeOf(string id)
{
EnsureSizes();
float v;
return id != null && sizes.TryGetValue(id, out v) ? v : 0f;
}
/// <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;
}
/// <summary>크기 표 mounted = 1(말 탄 유닛).</summary>
public static bool IsMounted(string id)
{
EnsureSizes();
return id != null && mounted.Contains(id);
}
static void EnsureSizes()
{
if (sizes != null) return;
sizes = new Dictionary<string, float>();
eyeUps = new Dictionary<string, float>();
mounted = new HashSet<string>();
var ta = Resources.Load<TextAsset>(Root + SizeTable);
if (ta == null) return;
var lines = ta.text.TrimStart('').Split('\n');
var head = new List<string>(lines[0].Trim().Split(','));
int ia = head.IndexOf("art"), isz = head.IndexOf("size_px"), im = head.IndexOf("mounted"), ieu = head.IndexOf("eye_up_px");
if (ia < 0 || isz < 0 || im < 0)
{
Debug.LogWarning("[TKS] 유닛 크기 표 열(art · mounted · size_px) 없음");
return;
}
int need = Mathf.Max(ia, Mathf.Max(isz, im));
for (int i = 1; i < lines.Length; i++)
{
var c = lines[i].Trim().Split(',');
if (c.Length <= need) continue;
float v;
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]);
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;
}
}
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();
sizes = null;
eyeUps = null;
mounted = null;
}
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, SizePx = SizeOf(id), Mounted = IsMounted(id), EyeUpPx = EyeUpOf(id) };
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;
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;
}
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;
float nextBlink;
float blinkEnd = -1f;
public SpriteRenderer Renderer
{
get { return sr; }
}
public string Current
{
get { return current; }
}
public string IdleClip
{
get { return idle; }
}
public UnitArtData Data
{
get { return data; }
}
/// <summary>그려진 머리 끝 높이(월드 · 발 기준) = BodyPx × 배율 — 크기 표로 커진 기마 유닛은 표준 키보다 높다.</summary>
public float TopHeight { get; private set; }
/// <param name="worldHeight">표준 키(월드) — 크기 표 size_px 가 이 높이가 되도록 배율(= 머리 크기 통일).</param>
/// <param name="mountedMaxTop">말 탄 유닛의 머리 끝 상한 = worldHeight × 이 값(0 = 상한 없음 · 넘으면 배율을 줄임 = 탄 사람은 보병보다 조금 작아짐).</param>
public void Setup(UnitArtData d, float worldHeight, int sortingOrder, float mountedMaxTop = 0f)
{
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;
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;
}
transform.localScale = new Vector3(s, s, 1f);
TopHeight = top;
}
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;
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)];
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 = data.ClipFps(current);
int n = frames.Length;
int idx;
if (loop)
{
idx = data.ApplyBlink(current, n, data.FrameAt(current, n, t), t, ref nextBlink, ref blinkEnd);
}
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;
}
}
if (sr.sprite != frames[idx]) sr.sprite = frames[idx];
}
}
}