ThreeKingdomsSlingshot/Assets/Scripts/Runtime/UnitArt.cs

576 lines
24 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;
}
/// <summary>
/// 클립 프레임 지연 로드(Fable 검토 2026-11-12 · PD 「모바일 출시 · 리소스 구성 검토 후 진행」) — 사전처럼 쓰되 처음 물을 때 그 클립만 Resources.LoadAll.
/// 초상만 쓰는 화면(영웅 목록·HUD·교역)이 대기·준비·공격 클립까지 통째로 올리던 것(영웅 72명 ≈ 0.94 GB → 대기 프레임 뒤 ≈ 2 GB)을 막는다.
/// </summary>
public class UnitClipStore : IEnumerable<KeyValuePair<string, Sprite[]>>
{
readonly string root;
readonly List<string> names = new List<string>();
readonly Dictionary<string, Sprite[]> loaded = new Dictionary<string, Sprite[]>();
public UnitClipStore(string resourceRoot)
{
root = resourceRoot;
}
internal void Declare(string clip)
{
if (!string.IsNullOrEmpty(clip) && !names.Contains(clip)) names.Add(clip);
}
/// <summary>meta 에 적힌 클립 수(불러온 수 아님).</summary>
public int Count
{
get { return names.Count; }
}
/// <summary>지금 메모리에 올라온 클립 수(테스트·점검용).</summary>
public int LoadedCount
{
get
{
int n = 0;
foreach (var kv in loaded)
if (kv.Value != null) n++;
return n;
}
}
Sprite[] Get(string clip)
{
if (string.IsNullOrEmpty(clip) || !names.Contains(clip)) return null;
Sprite[] fr;
if (loaded.TryGetValue(clip, out fr)) return fr;
fr = Resources.LoadAll<Sprite>(root + clip);
if (fr != null && fr.Length > 0) Array.Sort(fr, (a, b) => string.CompareOrdinal(a.name, b.name));
else fr = null;
loaded[clip] = fr;
return fr;
}
public bool TryGetValue(string clip, out Sprite[] frames)
{
frames = Get(clip);
return frames != null;
}
public bool ContainsKey(string clip)
{
return Get(clip) != null;
}
public Sprite[] this[string clip]
{
get
{
var fr = Get(clip);
if (fr == null) throw new KeyNotFoundException(clip);
return fr;
}
}
/// <summary>meta 의 모든 클립을 미리 읽음(전투 유닛 = 첫 공격 때 읽느라 한 프레임 멈추지 않게).</summary>
public void LoadAll()
{
foreach (var n in names) Get(n);
}
public IEnumerator<KeyValuePair<string, Sprite[]>> GetEnumerator()
{
foreach (var n in names)
{
var fr = Get(n);
if (fr != null) yield return new KeyValuePair<string, Sprite[]>(n, fr);
}
}
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
public class UnitArtData
{
public string Id;
public UnitArtMeta Meta;
public UnitClipStore Clips;
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();
portraits.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), Clips = new UnitClipStore(Root + id + "/") };
foreach (var c in meta.clips)
{
if (c == null || string.IsNullOrEmpty(c.name)) continue;
d.Clips.Declare(c.name); // 프레임은 처음 쓸 때 그 클립만 읽음(UnitClipStore)
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;
}
if (d.Clips.Count == 0) return null;
// 그림이 실제로 있는지 = 첫 클립 f000 한 장만 확인(없으면 null → 도형 · 옛 동작과 같음 · 이 한 장 = 초상과 같은 텍스처)
string first = null;
foreach (var c in meta.clips)
if (c != null && !string.IsNullOrEmpty(c.name))
{
first = c.name;
break;
}
return Resources.Load<Sprite>(Root + id + "/" + first + "/f000") != null ? d : null;
}
static readonly Dictionary<string, Sprite> portraits = new Dictionary<string, Sprite>();
/// <summary>
/// 초상 = front_idle 첫 프레임 한 장만(없으면 다른 클립 첫 프레임 · 없으면 null) — 영웅 목록·HUD·교역은 이것만 쓴다
/// (클립 전체를 올리지 않음 · Fable 검토 2026-11-12 「초상은 f000 한 장」).
/// </summary>
public static Sprite Portrait(string id)
{
if (string.IsNullOrEmpty(id)) return null;
Sprite sp;
if (portraits.TryGetValue(id, out sp)) return sp;
sp = Resources.Load<Sprite>(Root + id + "/front_idle/f000");
if (sp == null)
{
var d = Load(id);
if (d != null)
foreach (var kv in d.Clips)
{
sp = kv.Value[0];
break;
}
}
portraits[id] = sp;
return sp;
}
}
/// <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];
}
}
}