// ───────────────────────────────────────────────────────────────────────────── // WLSpriteSet.cs — 「3D 를 도트로 구운」 스프라이트 시트 1장의 메타 (WL-814k · #814) // // PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」 // // ■ 이 에셋이 들고 있는 것 — 그림(PNG) 1장 + 그 그림을 「어떻게 읽는지」의 표 // · 시트 = 방향 × 동작 × 프레임을 격자로 붙인 PNG 1장(서브 스프라이트 0 · UV 는 런타임 산술). // · 동작표 = Animator **상태 이름** → 시트 시작 인덱스 · 프레임 수 · fps. // 런타임(SpriteBillboard)은 원본 Animator 를 **폴링**만 한다 — 원본 코드 수정 0. // · worldPerPixel = 구울 때 픽셀 1개가 차지한 월드 크기(m). 런타임 쿼드 크기 = 프레임 px × 이 값. // 도트 모드의 화면 픽셀 크기와 같게 구우면 **스프라이트 1픽셀 = 화면 1픽셀**이 된다. // · footPixelX/Y = 월드 원점(캐릭터 발밑)이 프레임 안 어느 픽셀에 찍혔는지(좌하단 원점). // 런타임은 이 값으로 쿼드를 발밑에 맞춘다 — 동작마다 키가 달라져도 발이 안 뜬다. // // ■ 인덱스 규약 (시트 안 위치) // globalIndex = action.startIndex + dir * action.frameCount + frame // col = globalIndex % columns · row = globalIndex / columns (row 0 = 시트 **위**) // // 🔴 이 파일은 데이터만 든다. 굽는 쪽은 Editor/WLSpriteBaker.cs, 쓰는 쪽은 SpriteBillboard.cs. // 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일). // ───────────────────────────────────────────────────────────────────────────── using UnityEngine; namespace WL.Look.SpriteBake { /// 동작 1개(대기·이동·공격·피격·사망 …)가 시트 어디에 있는지. [System.Serializable] public sealed class WLSpriteAction { [Tooltip("동작 키 — idle / run / attack / hit / die (SO 의 동작 표와 같은 문자열).")] public string key = "idle"; [Tooltip("이 동작으로 볼 Animator 상태 이름들(레이어 0 shortNameHash 로 비교).")] public string[] stateNames = new string[0]; [Tooltip("실제로 구운 AnimationClip 이름(보고·디버그용).")] public string clipName = ""; [Tooltip("시트 안 시작 인덱스(방향 0 · 프레임 0).")] public int startIndex; [Tooltip("방향 1개당 프레임 수.")] public int frameCount = 1; [Tooltip("재생 fps(클립 길이 ÷ 프레임 수 로 산출).")] public float fps = 8f; [Tooltip("클립이 루프인가(사망은 false → 마지막 프레임에서 멈춘다).")] public bool loop = true; [Tooltip("원본 클립 길이(초).")] public float clipLength = 1f; // 런타임 캐시(직렬화 안 함 · 첫 사용 때 1회 만든다 → 이후 할당 0) [System.NonSerialized] public int[] hashes; } /// 구운 스프라이트 시트 1장 + 읽는 법. 프리팹 1종당 1개. [CreateAssetMenu(fileName = "WLSpriteSet", menuName = "WL/Sprite Set (구운 도트)", order = 36)] public sealed class WLSpriteSet : ScriptableObject { [Header("출처")] [Tooltip("어느 프리팹을 구웠는가(경로).")] public string sourcePrefab = ""; [Tooltip("액터 이름이 이 문자열로 시작하면 이 시트를 쓴다(예: Ai01 → 'Ai01(Clone)' 매칭).")] public string actorNamePrefix = ""; [Tooltip("구운 시각 · 툴 버전(보고용).")] public string bakeStamp = ""; [Header("시트")] public Texture2D sheet; public int frameWidth = 64; public int frameHeight = 64; public int columns = 16; public int rows = 1; [Header("굽기 파라미터")] [Tooltip("방향 수(카메라 기준 상대 yaw 를 이 수로 나눈다).")] public int dirCount = 8; [Tooltip("구울 때 픽셀 1개가 차지한 월드 크기(m). 런타임 쿼드 크기 = 프레임 px × 이 값.")] public float worldPerPixel = 0.0283333f; [Tooltip("구울 때 루트 lossyScale.y. 런타임 액터 스케일이 다르면 이 비율로 쿼드를 키운다.")] public float bakeScale = 1f; [Tooltip("월드 원점(발밑)이 프레임 안 어느 픽셀에 찍혔는지 — 좌하단 원점 · 픽셀 단위.")] public float footPixelX = 32f; public float footPixelY = 4f; [Tooltip("구울 때 카메라 내림각(도). 런타임 도트 카메라와 같아야 한다.")] public float pitchDeg = 43.609f; [Header("동작 표")] public WLSpriteAction[] actions = new WLSpriteAction[0]; /// 방향 1개 기준 총 프레임 수(= 시트 총 칸 수 ÷ 방향 수). public int TotalFrames { get { int n = 0; if (actions != null) for (int i = 0; i < actions.Length; i++) if (actions[i] != null) n += actions[i].frameCount; return n * Mathf.Max(1, dirCount); } } /// Animator 상태 해시(레이어 0 shortNameHash)로 동작을 찾는다. 없으면 null. public WLSpriteAction FindByStateHash(int shortNameHash) { if (actions == null) return null; for (int i = 0; i < actions.Length; i++) { var a = actions[i]; if (a == null) continue; if (a.hashes == null) BuildHashes(a); for (int h = 0; h < a.hashes.Length; h++) if (a.hashes[h] == shortNameHash) return a; } return null; } /// 키로 동작을 찾는다(폴백용 · 예: "idle"). public WLSpriteAction FindByKey(string key) { if (actions == null || string.IsNullOrEmpty(key)) return null; for (int i = 0; i < actions.Length; i++) if (actions[i] != null && actions[i].key == key) return actions[i]; return null; } static void BuildHashes(WLSpriteAction a) { var src = a.stateNames ?? new string[0]; a.hashes = new int[src.Length]; for (int i = 0; i < src.Length; i++) a.hashes[i] = Animator.StringToHash(src[i]); } /// 시트가 실제로 쓸 수 있는 상태인가. public bool IsUsable { get { return sheet != null && frameWidth > 0 && frameHeight > 0 && columns > 0 && dirCount > 0 && actions != null && actions.Length > 0; } } /// globalIndex → 시트 UV 사각형(x, y, w, h · 0~1). 할당 0. public Vector4 UvRect(int globalIndex) { int cols = Mathf.Max(1, columns); int col = globalIndex % cols; int row = globalIndex / cols; float tw = sheet != null ? sheet.width : 1f; float th = sheet != null ? sheet.height : 1f; float u = (col * frameWidth) / tw; float v = (th - (row + 1) * frameHeight) / th; // row 0 = 시트 위 return new Vector4(frameWidth / tw, frameHeight / th, u, v); // (scaleX, scaleY, offsetX, offsetY) = _ST 규약 } } }