ThreeKingdomsSlingshot/Assets/Scripts/Runtime/UnitArt.cs

289 lines
11 KiB
C#
Raw Permalink 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;
}
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>
/// 크기 기준 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;
}
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; }
}
/// <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;
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];
}
}
}