Merge branch 'wl/systems/WL-814k-sprite-bake'

This commit is contained in:
깃 관리자 2026-09-12 18:56:42 +09:00
commit 998202dc31
50 changed files with 4965 additions and 0 deletions

View File

@ -0,0 +1,35 @@
WL-814k 굽기 · 2026-09-12 18:38:16 · Unity 6000.3.23f1
──────── Assets/Res_Addr/PC/Ai01.prefab (루트 스케일 0.7)
Toon 머티리얼 교체 = 12슬롯 (인스턴스에만 · 원본 .mat 무변경)
레이어0 상태 17개: hit, die, idle, run, casting, skill2, attack, attack2, dodge, land, skill3, skill4, skill5, stun, casting_end, lying, getup
동작 idle ← 상태 [idle/land/getup] · 클립 Idle (2.000s · 4프레임 · loop True)
동작 run ← 상태 [run/dodge] · 클립 Run (0.533s · 6프레임 · loop True)
동작 attack ← 상태 [attack/attack2/skill2/skill3/skill4/skill5/casting/casting_end] · 클립 Atk2_wing (1.500s · 6프레임 · loop True)
동작 hit ← 상태 [hit/stun] · 클립 LightHit (1.000s · 2프레임 · loop True)
동작 die ← 상태 [die/lying] · 클립 death_F3441 (2.100s · 6프레임 · loop False)
실루엣 실측(프로브 192px) — x [73..119] y [79..129] · 캐릭터 높이 51 px · 폭 47 px → 프레임 56×56 · 발밑 픽셀 (28.5, 19.5)
OK — 프레임 56×56 px · 방향 8 · 총 192칸 · 시트 896×672 · PNG 165,716 B (161.8 KB) · 1.80초 · 캐릭터 51 px
PNG = Assets/WL/Look/Sprite/Baked/Ai01.png
SO = Assets/WL/Look/Sprite/Baked/Ai01_SpriteSet.asset
동작표 = idle(시작 0·4프레임·2.0fps·상태 [idle/land/getup]·클립 Idle) run(시작 32·6프레임·11.3fps·상태 [run/dodge]·클립 Run) attack(시작 80·6프레임·4.0fps·상태 [attack/attack2/skill2/skill3/skill4/skill5/casting/casting_end]·클립 Atk2_wing) hit(시작 128·2프레임·2.0fps·상태 [hit/stun]·클립 LightHit) die(시작 144·6프레임·2.9fps·상태 [die/lying]·클립 death_F3441)
발밑 픽셀 (28.5, 19.5) · 픽셀 1개 0.02833 m · 베이크 스케일 0.700
──────── Assets/Res_Addr/Mobs/Mob/Batty_A.prefab (프리팹 스케일 그대로)
Toon 머티리얼 교체 = 1슬롯 (인스턴스에만 · 원본 .mat 무변경)
레이어0 상태 8개: run, idle, attack, hit, die, stun, casting, casting_end
동작 idle ← 상태 [idle] · 클립 Idle (0.958s · 4프레임 · loop True)
동작 run ← 상태 [run] · 클립 Move (0.958s · 6프레임 · loop True)
동작 attack ← 상태 [attack/casting/casting_end] · 클립 Attack (0.583s · 6프레임 · loop True)
동작 hit ← 상태 [hit/stun] · 클립 Damage (0.583s · 2프레임 · loop True)
동작 die ← 상태 [die] · 클립 Die (1.000s · 6프레임 · loop False)
실루엣 실측(프로브 192px) — x [75..117] y [84..127] · 캐릭터 높이 44 px · 폭 43 px → 프레임 48×48 · 발밑 픽셀 (24.5, 14.5)
OK — 프레임 48×48 px · 방향 8 · 총 192칸 · 시트 768×576 · PNG 107,778 B (105.3 KB) · 1.06초 · 캐릭터 44 px
PNG = Assets/WL/Look/Sprite/Baked/Batty_A.png
SO = Assets/WL/Look/Sprite/Baked/Batty_A_SpriteSet.asset
동작표 = idle(시작 0·4프레임·4.2fps·상태 [idle]·클립 Idle) run(시작 32·6프레임·6.3fps·상태 [run]·클립 Move) attack(시작 80·6프레임·10.3fps·상태 [attack/casting/casting_end]·클립 Attack) hit(시작 128·2프레임·3.4fps·상태 [hit/stun]·클립 Damage) die(시작 144·6프레임·6.0fps·상태 [die]·클립 Die)
발밑 픽셀 (24.5, 14.5) · 픽셀 1개 0.02833 m · 베이크 스케일 1.500
런타임 SO 연결 — sets 2개 (Assets/WL/Look/Sprite/Resources/WL/WLSpriteLookSettings.asset)
합계 굽기 시간 = 2.87초 / 2종 → 1종당 1.43초

133
AgentScripts/WL814k_Bake.cs Normal file
View File

@ -0,0 +1,133 @@
// WL-814k 베이크 드라이버 (에디트 모드 전용 · 게임 코드 아님)
// 에셋 만들기 : unity command run_script --file AgentScripts/WL814k_Bake.cs --entry WL814k_Bake.MakeAssets
// 시범 2종 굽기: unity command run_script --file AgentScripts/WL814k_Bake.cs --entry WL814k_Bake.BakeDemo
// 임의 프리팹 : WL814k_Bake.Targets 를 바꾸거나 에디터 창(WL ▸ 도트 스프라이트 굽기)을 쓴다.
//
// 🔴 이 드라이버에만 「어떤 프리팹을 구울지」가 적혀 있다. 툴(Assets/WL/Look/Sprite/Editor/WLSpriteBaker.cs)
// 에는 캐릭터 이름이 0개다 — PD 가 캐릭터 에셋을 바꿔도 툴은 그대로 쓴다.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using WL.Look.SpriteBake;
using WL.Look.SpriteBake.EditorTools;
public static class WL814k_Bake
{
const string kResDir = "Assets/WL/Look/Sprite/Resources/WL";
const string kOutDir = "Assets/WL/Look/Sprite/Baked";
const string kTxt = "AgentScripts/WL814k_BAKE.txt";
// 🔴 1차 시범 = PC 1종 + 잡몹 1종 (전수 전환은 PD 승인 뒤)
// 두 번째 값 = 인게임 실제 루트 스케일(0 이면 프리팹 자체 값).
// PC 는 ClassConfig.f_Scale 0.7 로 스폰된다(814b·발주서 실측) → 그 크기로 구워야
// 스프라이트 1px 이 화면 1px 과 정확히 맞는다. 몹은 프리팹 스케일 그대로(Batty_A = 1.5).
public static readonly string[] Targets = { "Assets/Res_Addr/PC/Ai01.prefab", "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab" };
public static readonly float[] TargetScales = { 0.7f, 0f };
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
// ═════════════════════════════════════════════════════════════════════════
// ① 설정 에셋 2개 만들기
// ═════════════════════════════════════════════════════════════════════════
public static void MakeAssets()
{
s_sb = new StringBuilder();
L("WL-814k 설정 에셋 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
Directory.CreateDirectory(kResDir);
Directory.CreateDirectory(kOutDir);
var bake = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kResDir + "/WLSpriteBakeSettings.asset");
if (bake == null)
{
bake = ScriptableObject.CreateInstance<WLSpriteBakeSettings>();
AssetDatabase.CreateAsset(bake, kResDir + "/WLSpriteBakeSettings.asset");
L("만듦 " + kResDir + "/WLSpriteBakeSettings.asset");
}
else L("있음 " + kResDir + "/WLSpriteBakeSettings.asset");
var look = AssetDatabase.LoadAssetAtPath<WLSpriteLookSettings>(kResDir + "/WLSpriteLookSettings.asset");
if (look == null)
{
look = ScriptableObject.CreateInstance<WLSpriteLookSettings>();
AssetDatabase.CreateAsset(look, kResDir + "/WLSpriteLookSettings.asset");
L("만듦 " + kResDir + "/WLSpriteLookSettings.asset");
}
else L("있음 " + kResDir + "/WLSpriteLookSettings.asset");
AssetDatabase.SaveAssetIfDirty(bake);
AssetDatabase.SaveAssetIfDirty(look);
WLSpriteBakeSettings.ClearCache();
WLSpriteLookSettings.ClearCache();
L(string.Format("굽기 기본값 — 내림각 {0:0.000}° · 픽셀 {1:0.00000} m · 방향 {2} · 프레임 {3}(자동 {4}) · 팔레트 {5}단계 · 알파컷 {6}",
bake.PitchDeg, bake.WorldPerPixel, bake.dirCount, bake.frameSize, bake.autoFrameSize, bake.paletteLevels, bake.alphaCutoff));
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
}
// ═════════════════════════════════════════════════════════════════════════
// ② 시범 2종 굽기 + SO 목록 연결
// ═════════════════════════════════════════════════════════════════════════
public static void BakeDemo() { BakeList(Targets, TargetScales); }
public static void BakeList(string[] paths, float[] scales)
{
s_sb = new StringBuilder();
L("WL-814k 굽기 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion);
var sets = new List<WLSpriteSet>();
double total = 0;
for (int ti = 0; ti < paths.Length; ti++)
{
var p = paths[ti];
float scale = (scales != null && ti < scales.Length) ? scales[ti] : 0f;
L("");
L("──────── " + p + (scale > 0f ? " (루트 스케일 " + scale + ")" : " (프리팹 스케일 그대로)"));
// 🔴 굽기마다 설정 에셋을 다시 읽는다 — PNG 임포트가 AssetDatabase 를 돌려
// 참조만 들고 있던 SO 가 언로드돼 null 이 되는 것을 실측했다(첫 시도 실패 원인).
var cfg = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kResDir + "/WLSpriteBakeSettings.asset");
if (cfg == null) { L("FAIL — 굽기 설정 에셋 없음. MakeAssets 먼저."); break; }
var r = WLSpriteBaker.Bake(p, cfg, kOutDir, null, scale);
total += r.seconds;
L(r.log.ToString().TrimEnd());
foreach (var n in r.notes) L(" · 「미확인/주의」 " + n);
if (!r.ok) { L("FAIL"); continue; }
L(string.Format("OK — 프레임 {0}×{0} px · 방향 {1} · 총 {2}칸 · 시트 {3}×{4} · PNG {5:N0} B ({6:N1} KB) · {7:0.00}초 · 캐릭터 {8:0} px",
r.frameSize, r.dirCount, r.totalFrames, r.sheetW, r.sheetH, r.pngBytes, r.pngBytes / 1024.0, r.seconds, r.charPixelHeight));
L(" PNG = " + r.pngPath);
L(" SO = " + r.setPath);
var set = AssetDatabase.LoadAssetAtPath<WLSpriteSet>(r.setPath);
if (set != null)
{
sets.Add(set);
var sb2 = new StringBuilder();
foreach (var a in set.actions)
sb2.Append(string.Format("{0}(시작 {1}·{2}프레임·{3:0.0}fps·상태 [{4}]·클립 {5}) ",
a.key, a.startIndex, a.frameCount, a.fps, string.Join("/", a.stateNames), a.clipName));
L(" 동작표 = " + sb2.ToString().TrimEnd());
L(string.Format(" 발밑 픽셀 ({0:0.0}, {1:0.0}) · 픽셀 1개 {2:0.00000} m · 베이크 스케일 {3:0.000}",
set.footPixelX, set.footPixelY, set.worldPerPixel, set.bakeScale));
}
}
// 런타임 SO 에 시트 목록 연결
var look = AssetDatabase.LoadAssetAtPath<WLSpriteLookSettings>(kResDir + "/WLSpriteLookSettings.asset");
if (look != null)
{
look.sets = sets.ToArray();
EditorUtility.SetDirty(look);
AssetDatabase.SaveAssetIfDirty(look);
L("");
L("런타임 SO 연결 — sets " + sets.Count + "개 (" + kResDir + "/WLSpriteLookSettings.asset)");
}
L("");
L(string.Format("합계 굽기 시간 = {0:0.00}초 / {1}종 → 1종당 {2:0.00}초", total, paths.Length, total / Math.Max(1, paths.Length)));
Write();
}
static void Write() { File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL814k] → " + kTxt); }
}

View File

@ -0,0 +1,11 @@
WL-814k 캡처 · 2026-09-12 18:43:29
ⓐ 3D 원본 → k_1_3d_original.png
폴링 결과 — PC idle d7 f0 / 몹 idle d1 f0
ⓑ 스프라이트 교체 → k_2_sprite_baked.png
정렬 실측(PC 쪽 절반) — 3D x[21..41] y[87..120] / 스프라이트 x[19..40] y[85..121] → Δ좌 -2 · Δ우 -1 · Δ하 -2 · Δ상 1 px
밝기 실측 — 3D 평균 63.1 · 스프라이트 평균 48.4 (0~255 · 캐릭터 픽셀만) · 캐릭터 픽셀 3D 466 / 스프라이트 619 (외곽선 +153)
클로즈업 crop 116×46 px → ×9 = 1044×836 (위 3D · 아래 스프라이트)
ⓒ 클로즈업 → k_3_closeup_2x.png
ⓓ 방향 8종 → k_4_directions.png
캡처 PASS — E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k

View File

@ -0,0 +1,43 @@
WL-814k 진단 ③ · 18:24:55
Animator 살아있나 = True
샘플링 없음 덮인 426 · x[74..118] y[94..136]
Idle_f0 덮인 445 · x[75..114] y[96..136]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Idle_f1 덮인 436 · x[76..114] y[95..135]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Idle_f2 덮인 439 · x[76..113] y[94..135]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Idle_f3 덮인 441 · x[76..114] y[95..136]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Run_f0 덮인 503 · x[74..119] y[91..144]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Run_f1 덮인 496 · x[75..118] y[107..156]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Run_f2 덮인 502 · x[73..118] y[130..176]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Run_f3 덮인 503 · x[74..118] y[140..189]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Atk2_wing_f0 덮인 513 · x[71..112] y[88..132]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Atk2_wing_f1 덮인 447 · x[74..104] y[89..139]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Atk2_wing_f2 덮인 483 · x[68..109] y[91..131]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
Atk2_wing_f3 덮인 524 · x[69..104] y[83..133]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
LightHit_f0 덮인 448 · x[75..114] y[95..135]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
LightHit_f1 덮인 365 · x[75..113] y[81..113]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
LightHit_f2 덮인 449 · x[79..110] y[83..131]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
LightHit_f3 덮인 443 · x[75..114] y[87..127]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
death_F3441_f0 덮인 453 · x[78..114] y[78..121]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
death_F3441_f1 덮인 351 · x[91..109] y[0..37]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
death_F3441_f2 덮인 338 · x[86..104] y[0..35]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)
death_F3441_f3 덮인 374 · x[83..105] y[0..32]
루트 localPos (0.000, 0.000, 0.000) · lossyScale (1.000, 1.000, 1.000)

View File

@ -0,0 +1,27 @@
WL-814k 진단 ④ · 18:25:49
== Assets/Res_Addr/PC/Ai01.prefab ==
직속 자식: blen Group Mesh Root_M
클립 Run · 위치 커브가 걸린 경로 0개:
f0 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.001, 0.990, 0.016)
f1 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (-0.001, 0.845, 0.691)
f2 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (-0.004, 0.990, 1.362)
f3 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (-0.002, 0.845, 2.044)
클립 death_F3441 · 위치 커브가 걸린 경로 0개:
f0 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.021, 0.238, -0.078)
f1 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.097, -0.496, -2.949)
f2 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.066, -0.456, -2.963)
f3 rootLocal (0.000, 0.000, 0.000) | blen (0.000, 0.000, 0.000) | Group (0.000, 0.000, 0.000) | Mesh (0.000, 0.000, 0.000) | Root_M (0.077, -0.433, -3.045)
== Assets/Res_Addr/Mobs/Mob/Batty_A.prefab ==
직속 자식: Batty root
클립 Move · 위치 커브가 걸린 경로 8개: root/Center, root/Center/spine, root/Center/spine/wing_l1, root/Center/spine/wing_l1/wing_l2, root/Center/spine/wing_l1/wing_l2/wing_l3, root/Center/spine/wing_r1, root/Center/spine/wing_r1/wing_r2, root/Center/spine/wing_r1/wing_r2/wing_r3
f0 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
f1 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
f2 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
f3 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
클립 Die · 위치 커브가 걸린 경로 8개: root/Center, root/Center/spine, root/Center/spine/wing_l1, root/Center/spine/wing_l1/wing_l2, root/Center/spine/wing_l1/wing_l2/wing_l3, root/Center/spine/wing_r1, root/Center/spine/wing_r1/wing_r2, root/Center/spine/wing_r1/wing_r2/wing_r3
f0 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
f1 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
f2 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)
f3 rootLocal (0.000, 0.000, 0.000) | Batty (0.000, 0.000, 0.000) | root (0.000, 0.000, 0.000)

View File

@ -0,0 +1,45 @@
WL-814k 진단 ⑤ · 18:27:44
== Assets/Res_Addr/PC/Ai01.prefab (anim.Play + anim.Update 경로) ==
idle_f0 덮인 413 · x[86..110] y[92..136]
idle_f1 덮인 405 · x[86..110] y[92..135]
idle_f2 덮인 410 · x[86..110] y[92..135]
idle_f3 덮인 410 · x[86..110] y[92..136]
run_f0 덮인 474 · x[85..108] y[92..144]
run_f1 덮인 488 · x[85..111] y[91..139]
run_f2 덮인 486 · x[86..112] y[97..144]
run_f3 덮인 490 · x[86..110] y[91..139]
attack_f0 덮인 503 · x[75..109] y[86..132]
attack_f1 덮인 402 · x[80..101] y[89..134]
attack_f2 덮인 460 · x[78..109] y[88..131]
attack_f3 덮인 535 · x[75..103] y[83..133]
hit_f0 덮인 412 · x[86..110] y[91..135]
hit_f1 덮인 349 · x[86..110] y[89..120]
hit_f2 덮인 490 · x[86..111] y[91..141]
hit_f3 덮인 416 · x[86..110] y[92..136]
die_f0 덮인 457 · x[86..107] y[73..121]
die_f1 덮인 512 · x[77..123] y[86..116]
die_f2 덮인 518 · x[79..125] y[81..117]
die_f3 덮인 515 · x[79..123] y[78..114]
== Assets/Res_Addr/Mobs/Mob/Batty_A.prefab (anim.Play + anim.Update 경로) ==
idle_f0 덮인 231 · x[84..108] y[98..112]
idle_f1 덮인 221 · x[84..108] y[97..111]
idle_f2 덮인 231 · x[84..108] y[96..110]
idle_f3 덮인 234 · x[84..108] y[97..112]
run_f0 덮인 256 · x[84..108] y[101..117]
run_f1 덮인 254 · x[84..108] y[99..115]
run_f2 덮인 250 · x[84..108] y[98..113]
run_f3 덮인 254 · x[84..108] y[100..116]
attack_f0 덮인 223 · x[84..108] y[97..111]
attack_f1 덮인 260 · x[84..108] y[106..122]
attack_f2 덮인 256 · x[84..108] y[102..118]
attack_f3 덮인 236 · x[84..108] y[96..111]
hit_f0 덮인 223 · x[84..108] y[97..111]
hit_f1 덮인 195 · x[84..108] y[90..102]
hit_f2 덮인 213 · x[84..108] y[95..109]
hit_f3 덮인 233 · x[84..108] y[99..113]
die_f0 덮인 223 · x[84..108] y[97..111]
die_f1 덮인 215 · x[84..108] y[99..112]
die_f2 덮인 266 · x[84..108] y[87..103]
die_f3 덮인 251 · x[84..108] y[87..101]

View File

@ -0,0 +1,13 @@
WL-814k 진단 ⑥ · 18:44:46
3D f0 → 상태 해시 1203776827(attack=1203776827) nt 0.000
3D f1 → 상태 해시 1203776827(attack=1203776827) nt 0.167
3D f2 → 상태 해시 1203776827(attack=1203776827) nt 0.334
3D f3 → 상태 해시 1203776827(attack=1203776827) nt 0.500
3D f4 → 상태 해시 1203776827(attack=1203776827) nt 0.667
3D f5 → 상태 해시 1203776827(attack=1203776827) nt 0.834
스프라이트 f0 → 인덱스 122 (시작 80 + 7*6 + 0)
스프라이트 f1 → 인덱스 123 (시작 80 + 7*6 + 1)
스프라이트 f2 → 인덱스 124 (시작 80 + 7*6 + 2)
스프라이트 f3 → 인덱스 125 (시작 80 + 7*6 + 3)
스프라이트 f4 → 인덱스 126 (시작 80 + 7*6 + 4)
스프라이트 f5 → 인덱스 127 (시작 80 + 7*6 + 5)

View File

@ -0,0 +1,138 @@
// WL-814k 진단 ③ — AnimationMode 샘플링이 실루엣을 어떻게 바꾸는지 프레임별 실측
// run: unity command run_script --file AgentScripts/WL814k_Diag3.cs --entry WL814k_Diag3.Run
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class WL814k_Diag3
{
const string kTxt = "AgentScripts/WL814k_DIAG3.txt";
static string OutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k_diag";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
Directory.CreateDirectory(OutDir);
L("WL-814k 진단 ③ · " + DateTime.Now.ToString("HH:mm:ss"));
Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "Idle", "Run", "Atk2_wing", "LightHit", "death_F3441" });
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
static void Test(string prefabPath, string[] clipNames)
{
GameObject pivot = null, inst = null, camGo = null;
RenderTexture rt = null; Texture2D tex = null;
bool am = false;
try
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.212f, 0.227f, 0.259f, 1f);
RenderSettings.fog = false; RenderSettings.skybox = null;
var sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional; sun.intensity = 1.2f; sun.shadows = LightShadows.None;
sun.transform.rotation = Quaternion.Euler(26.4f, 238.6f, 0f);
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
pivot = new GameObject("Pivot");
inst = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
inst.transform.SetParent(pivot.transform, false);
inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity;
foreach (var c in inst.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in inst.GetComponentsInChildren<MonoBehaviour>(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
var anim = inst.GetComponent<Animator>();
L(" Animator 살아있나 = " + (anim != null));
float wpp = 3.4f * 2f / 240f;
int P = 192;
camGo = new GameObject("Cam");
var cam = camGo.AddComponent<Camera>();
cam.orthographic = true; cam.orthographicSize = P * wpp * 0.5f;
cam.nearClipPlane = 0.01f; cam.farClipPlane = 200f;
cam.clearFlags = CameraClearFlags.SolidColor; cam.allowHDR = false; cam.allowMSAA = false;
camGo.transform.rotation = Quaternion.Euler(43.609f, 0f, 0f);
camGo.transform.position = -camGo.transform.right * (wpp * 0.5f) - camGo.transform.up * (wpp * 0.5f) - camGo.transform.forward * 50f;
rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create();
tex = new Texture2D(P, P, TextureFormat.RGBA32, false, false);
var rac = anim.runtimeAnimatorController;
var black = new Color32[P * P]; var white = new Color32[P * P];
// ⓐ 샘플링 없이
Box(cam, rt, tex, P, black, white, "샘플링 없음", null);
AnimationMode.StartAnimationMode(); am = true;
foreach (var name in clipNames)
{
AnimationClip clip = null;
foreach (var c in rac.animationClips) if (c.name == name) { clip = c; break; }
if (clip == null) { L(" 클립 " + name + " 없음"); continue; }
for (int f = 0; f < 4; f++)
{
float t = clip.length * f / 4f;
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, clip, t);
AnimationMode.EndSampling();
inst.transform.localPosition = Vector3.zero;
inst.transform.localRotation = Quaternion.identity;
string tag = name + "_f" + f;
Box(cam, rt, tex, P, black, white, tag, (name == "Idle" && f == 0) ? OutDir + "\\am_" + tag + ".png" : null);
L(" 루트 localPos " + inst.transform.localPosition.ToString("F3") + " · lossyScale " + inst.transform.lossyScale.ToString("F3"));
}
}
AnimationMode.StopAnimationMode(); am = false;
}
catch (Exception ex) { L("EXCEPTION " + ex); }
finally
{
if (am && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode();
if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (tex != null) UnityEngine.Object.DestroyImmediate(tex);
if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo);
if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot);
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
}
static void Box(Camera cam, RenderTexture rt, Texture2D tex, int P, Color32[] black, Color32[] white, string tag, string savePng)
{
Grab(cam, rt, tex, P, Color.black, black, savePng == null ? null : savePng.Replace(".png", "_black.png"));
Grab(cam, rt, tex, P, Color.white, white, savePng == null ? null : savePng.Replace(".png", "_white.png"));
int cov = 0, minX = int.MaxValue, maxX = -1, minY = int.MaxValue, maxY = -1;
for (int y = 0; y < P; y++)
for (int x = 0; x < P; x++)
{
int i = y * P + x;
float c = 1f - (((white[i].r - black[i].r) + (white[i].g - black[i].g) + (white[i].b - black[i].b)) / 765f);
if (c < 0.5f) continue;
cov++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
L(string.Format(" {0,-18} 덮인 {1,6} · x[{2}..{3}] y[{4}..{5}]", tag, cov, minX, maxX, minY, maxY));
}
static void Grab(Camera cam, RenderTexture rt, Texture2D tex, int P, Color bg, Color32[] outPx, string file)
{
cam.backgroundColor = bg;
cam.targetTexture = rt; cam.Render(); cam.targetTexture = null;
var active = RenderTexture.active; RenderTexture.active = rt;
tex.ReadPixels(new Rect(0, 0, P, P), 0, 0); tex.Apply(false, false);
RenderTexture.active = active;
if (file != null) File.WriteAllBytes(file, tex.EncodeToPNG());
Array.Copy(tex.GetPixels32(), outPx, outPx.Length);
}
}

View File

@ -0,0 +1,86 @@
// WL-814k 진단 ④ — 루트 모션이 어느 트랜스폼에 실리는지 실측
// run: unity command run_script --file AgentScripts/WL814k_Diag4.cs --entry WL814k_Diag4.Run
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class WL814k_Diag4
{
const string kTxt = "AgentScripts/WL814k_DIAG4.txt";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
L("WL-814k 진단 ④ · " + DateTime.Now.ToString("HH:mm:ss"));
Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "Run", "death_F3441" });
Test("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", new[] { "Move", "Die" });
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
static void Test(string prefabPath, string[] clipNames)
{
GameObject pivot = null, inst = null;
bool am = false;
try
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
pivot = new GameObject("Pivot");
inst = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
inst.transform.SetParent(pivot.transform, false);
inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity;
var anim = inst.GetComponent<Animator>();
var rac = anim.runtimeAnimatorController;
L("");
L("== " + prefabPath + " ==");
var kids = new StringBuilder();
for (int i = 0; i < inst.transform.childCount; i++) kids.Append(inst.transform.GetChild(i).name + " ");
L(" 직속 자식: " + kids);
AnimationMode.StartAnimationMode(); am = true;
foreach (var name in clipNames)
{
AnimationClip clip = null;
foreach (var c in rac.animationClips) if (c.name == name) { clip = c; break; }
if (clip == null) continue;
var binds = AnimationUtility.GetCurveBindings(clip);
var paths = new System.Collections.Generic.HashSet<string>();
foreach (var b in binds) if (b.propertyName.StartsWith("m_LocalPosition")) paths.Add(b.path == "" ? "<root>" : b.path);
L(" 클립 " + name + " · 위치 커브가 걸린 경로 " + paths.Count + "개: " + string.Join(", ", new System.Collections.Generic.List<string>(paths).ToArray()));
for (int f = 0; f < 4; f++)
{
float t = clip.length * f / 4f;
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, clip, t);
AnimationMode.EndSampling();
var sb = new StringBuilder();
sb.Append(string.Format(" f{0} rootLocal {1}", f, inst.transform.localPosition.ToString("F3")));
for (int i = 0; i < inst.transform.childCount; i++)
{
var ch = inst.transform.GetChild(i);
sb.Append(" | " + ch.name + " " + ch.localPosition.ToString("F3"));
}
L(sb.ToString());
}
}
AnimationMode.StopAnimationMode(); am = false;
}
catch (Exception ex) { L("EXCEPTION " + ex); }
finally
{
if (am && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode();
if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot);
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
}
}

View File

@ -0,0 +1,134 @@
// WL-814k 진단 ⑤ — 「진짜 Animator 로 구동」 경로가 에디트 모드에서 되는지 실측
// (applyRootMotion=false 인 게임과 똑같은 포즈를 얻을 수 있으면 그게 정답 경로)
// run: unity command run_script --file AgentScripts/WL814k_Diag5.cs --entry WL814k_Diag5.Run
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
public static class WL814k_Diag5
{
const string kTxt = "AgentScripts/WL814k_DIAG5.txt";
static string OutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k_diag";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
Directory.CreateDirectory(OutDir);
L("WL-814k 진단 ⑤ · " + DateTime.Now.ToString("HH:mm:ss"));
Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "idle", "run", "attack", "hit", "die" });
Test("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", new[] { "idle", "run", "attack", "hit", "die" });
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
static void Test(string prefabPath, string[] states)
{
GameObject pivot = null, inst = null, camGo = null;
RenderTexture rt = null; Texture2D tex = null;
try
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
RenderSettings.ambientLight = new Color(0.212f, 0.227f, 0.259f, 1f);
RenderSettings.fog = false; RenderSettings.skybox = null;
var sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional; sun.intensity = 1.2f; sun.shadows = LightShadows.None;
sun.transform.rotation = Quaternion.Euler(26.4f, 238.6f, 0f);
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
pivot = new GameObject("Pivot");
inst = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
inst.transform.SetParent(pivot.transform, false);
inst.transform.localPosition = Vector3.zero; inst.transform.localRotation = Quaternion.identity;
foreach (var c in inst.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in inst.GetComponentsInChildren<MonoBehaviour>(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
foreach (var sm in inst.GetComponentsInChildren<SkinnedMeshRenderer>(true)) if (sm != null) sm.updateWhenOffscreen = true;
var anim = inst.GetComponent<Animator>();
anim.applyRootMotion = false;
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
anim.Rebind();
float wpp = 3.4f * 2f / 240f;
int P = 192;
camGo = new GameObject("Cam");
var cam = camGo.AddComponent<Camera>();
cam.orthographic = true; cam.orthographicSize = P * wpp * 0.5f;
cam.nearClipPlane = 0.01f; cam.farClipPlane = 200f;
cam.clearFlags = CameraClearFlags.SolidColor; cam.allowHDR = false; cam.allowMSAA = false;
camGo.transform.rotation = Quaternion.Euler(43.609f, 0f, 0f);
camGo.transform.position = -camGo.transform.right * (wpp * 0.5f) - camGo.transform.up * (wpp * 0.5f) - camGo.transform.forward * 50f;
rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create();
tex = new Texture2D(P, P, TextureFormat.RGBA32, false, false);
var black = new Color32[P * P]; var white = new Color32[P * P];
L("");
L("== " + prefabPath + " (anim.Play + anim.Update 경로) ==");
foreach (var st in states)
{
int hash = Animator.StringToHash(st);
if (!anim.HasState(0, hash)) { L(" 상태 " + st + " 없음"); continue; }
for (int f = 0; f < 4; f++)
{
float nt = f / 4f;
anim.Play(hash, 0, nt);
anim.Update(0f);
inst.transform.localPosition = Vector3.zero;
inst.transform.localRotation = Quaternion.identity;
string tag = st + "_f" + f;
Box(cam, rt, tex, P, black, white, tag, (f == 0) ? OutDir + "\\play_" + Path.GetFileNameWithoutExtension(prefabPath) + "_" + tag + ".png" : null);
}
}
}
catch (Exception ex) { L("EXCEPTION " + ex); }
finally
{
if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (tex != null) UnityEngine.Object.DestroyImmediate(tex);
if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo);
if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot);
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
}
static void Box(Camera cam, RenderTexture rt, Texture2D tex, int P, Color32[] black, Color32[] white, string tag, string savePng)
{
Grab(cam, rt, tex, P, Color.black, black, null);
Grab(cam, rt, tex, P, Color.white, white, savePng);
int cov = 0, minX = int.MaxValue, maxX = -1, minY = int.MaxValue, maxY = -1;
for (int y = 0; y < P; y++)
for (int x = 0; x < P; x++)
{
int i = y * P + x;
float c = 1f - (((white[i].r - black[i].r) + (white[i].g - black[i].g) + (white[i].b - black[i].b)) / 765f);
if (c < 0.5f) continue;
cov++;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
L(string.Format(" {0,-14} 덮인 {1,6} · x[{2}..{3}] y[{4}..{5}]", tag, cov, minX, maxX, minY, maxY));
}
static void Grab(Camera cam, RenderTexture rt, Texture2D tex, int P, Color bg, Color32[] outPx, string file)
{
cam.backgroundColor = bg;
cam.targetTexture = rt; cam.Render(); cam.targetTexture = null;
var active = RenderTexture.active; RenderTexture.active = rt;
tex.ReadPixels(new Rect(0, 0, P, P), 0, 0); tex.Apply(false, false);
RenderTexture.active = active;
if (file != null) File.WriteAllBytes(file, tex.EncodeToPNG());
Array.Copy(tex.GetPixels32(), outPx, outPx.Length);
}
}

View File

@ -0,0 +1,178 @@
// WL-814k 진단 ⑥ — 3D 프레임 k 와 스프라이트 프레임 k 가 정말 같은 그림인가(격자 비교)
// run: unity command run_script --file AgentScripts/WL814k_Diag6.cs --entry WL814k_Diag6.Run
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using WL.Look.SpriteBake;
public static class WL814k_Diag6
{
const string kOut = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k_diag";
const string kTxt = "AgentScripts/WL814k_DIAG6.txt";
const float kOrtho = 3.4f, kPitch = 43.609f;
const int kLowH = 240;
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
Directory.CreateDirectory(kOut);
L("WL-814k 진단 ⑥ · " + DateTime.Now.ToString("HH:mm:ss"));
try
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
var sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional; sun.shadows = LightShadows.None;
sun.color = new Color(1f, 0.92f, 0.703f, 1f); sun.intensity = 1.2f;
sun.transform.rotation = Quaternion.Euler(26.4f, 238.6f, 0f);
var fill = new GameObject("Fill").AddComponent<Light>();
fill.type = LightType.Directional; fill.shadows = LightShadows.None;
fill.color = new Color(0.596f, 0.703f, 0.839f, 1f); fill.intensity = 0.3f;
fill.transform.rotation = Quaternion.Euler(36.1f, 116.1f, 0f);
RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource<Material>("Default-Skybox.mat");
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox;
RenderSettings.ambientIntensity = 1f; RenderSettings.fog = false;
DynamicGI.UpdateEnvironment();
var look = WLSpriteLookSettings.Instance;
var actor = Spawn("Assets/Res_Addr/PC/Ai01.prefab", 0.7f);
var set = SpriteBillboard.MatchSet(actor.name, look);
var act = set.FindByKey("attack");
var anim = actor.GetComponent<Animator>();
int cell = 64, cols = act.frameCount, rows = 2, dir = 7;
var rig = MakeCam(cell, cell);
var sheet = new Color32[cols * cell * rows * cell];
// 위 줄 = 3D (매 컷 직전에 포즈를 다시 찍는다)
for (int f = 0; f < cols; f++)
{
actor.transform.rotation = Quaternion.Euler(0f, dir * 45f, 0f);
anim.Play(Animator.StringToHash("attack"), 0, (f + 0.001f) / act.frameCount);
anim.Update(0f);
var st = anim.GetCurrentAnimatorStateInfo(0);
L(string.Format(" 3D f{0} → 상태 해시 {1}(attack={2}) nt {3:0.000}", f, st.shortNameHash, Animator.StringToHash("attack"), st.normalizedTime));
BlitInto(rig.Grab(), cell, cell, sheet, cols * cell, f * cell, 1 * cell);
}
// 아래 줄 = 스프라이트
var e = SpriteBillboard.TryAttach(actor, set);
for (int f = 0; f < cols; f++)
{
actor.transform.rotation = Quaternion.Euler(0f, dir * 45f, 0f);
SpriteBillboard.SetManualFrame(actor, "attack", dir, f);
SpriteBillboard.UpdateAll(rig.cam);
L(string.Format(" 스프라이트 f{0} → 인덱스 {1} (시작 {2} + {3}*{4} + {5})", f,
act.startIndex + dir * act.frameCount + f, act.startIndex, dir, act.frameCount, f));
BlitInto(rig.Grab(), cell, cell, sheet, cols * cell, f * cell, 0 * cell);
}
SpriteBillboard.RestoreNow("diag6");
Save(sheet, cols * cell, rows * cell, kOut + @"\frames_attack_dir7.png", 6);
rig.Dispose();
}
catch (Exception ex) { L("EXCEPTION " + ex); }
finally
{
SpriteBillboard.RestoreNow("diag6-end");
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
}
static GameObject Spawn(string p, float scale)
{
var src = AssetDatabase.LoadAssetAtPath<GameObject>(p);
var go = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
go.name = Path.GetFileNameWithoutExtension(p) + "(Clone)";
go.transform.position = Vector3.zero;
go.transform.localScale = Vector3.one * scale;
foreach (var c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
foreach (var sm in go.GetComponentsInChildren<SkinnedMeshRenderer>(true)) sm.updateWhenOffscreen = true;
var an = go.GetComponent<Animator>();
an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.Rebind();
var bake = WLSpriteBakeSettings.Instance;
foreach (var rend in go.GetComponentsInChildren<Renderer>(true))
{
if (rend == null || rend is ParticleSystemRenderer) continue;
var mats = rend.sharedMaterials; bool any = false;
for (int i = 0; i < mats.Length; i++)
{
if (mats[i] == null) continue;
var t = AssetDatabase.LoadAssetAtPath<Material>(bake.toonMaterialDir + "/" + mats[i].name + bake.toonMaterialSuffix + ".mat");
if (t != null) { mats[i] = t; any = true; }
}
if (any) rend.sharedMaterials = mats;
}
return go;
}
sealed class Rig
{
public Camera cam; public RenderTexture rt; public Texture2D tex; public int w, h;
public Color32[] Grab()
{
cam.targetTexture = rt; cam.Render(); cam.targetTexture = null;
var a = RenderTexture.active; RenderTexture.active = rt;
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(false, false);
RenderTexture.active = a;
return tex.GetPixels32();
}
public void Dispose()
{
RenderTexture.active = null;
if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (tex != null) UnityEngine.Object.DestroyImmediate(tex);
if (cam != null) UnityEngine.Object.DestroyImmediate(cam.gameObject);
}
}
static Rig MakeCam(int w, int h)
{
var go = new GameObject("Cam");
var c = go.AddComponent<Camera>();
c.orthographic = true;
c.orthographicSize = h * (2f * kOrtho / kLowH) * 0.5f;
c.nearClipPlane = 0.01f; c.farClipPlane = 200f;
c.clearFlags = CameraClearFlags.SolidColor;
c.backgroundColor = new Color(0.34f, 0.40f, 0.25f, 1f);
c.allowHDR = false; c.allowMSAA = false;
go.transform.rotation = Quaternion.Euler(kPitch, 0f, 0f);
go.transform.position = new Vector3(0f, 0.6f, 0f) - go.transform.forward * 40f;
var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create();
var tex = new Texture2D(w, h, TextureFormat.RGBA32, false, false);
return new Rig { cam = c, rt = rt, tex = tex, w = w, h = h };
}
static void BlitInto(Color32[] src, int sw, int sh, Color32[] dst, int dw, int dx, int dy)
{
for (int y = 0; y < sh; y++)
for (int x = 0; x < sw; x++)
{
int di = (dy + y) * dw + (dx + x);
if (di >= 0 && di < dst.Length) dst[di] = src[y * sw + x];
}
}
static void Save(Color32[] px, int w, int h, string file, int zoom)
{
int ow = w * zoom, oh = h * zoom;
var o = new Color32[ow * oh];
for (int y = 0; y < oh; y++) for (int x = 0; x < ow; x++) o[y * ow + x] = px[(y / zoom) * w + (x / zoom)];
var t = new Texture2D(ow, oh, TextureFormat.RGBA32, false, false);
t.SetPixels32(o); t.Apply(false, false);
File.WriteAllBytes(file, t.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(t);
}
}

View File

@ -0,0 +1,18 @@
WL-814k 이름 규약 표 · 2026-09-12 18:50:03
후보 idle = [idle / Idle / wait / IdleA / IdleB] (+보기만: land / getup)
후보 run = [run / Run / move / Move / walk] (+보기만: dodge)
후보 attack = [attack / Attack / atk / ATK0 / ATK1] (+보기만: ATK2 / ATK3 / ATK4 / Shoot / attack2 / skill2 / skill3 / skill4 / skill5 / casting / casting_end)
후보 hit = [hit / Hit / damage / Damage] (+보기만: stun / Swoon)
후보 die = [die / Die / death / Die0 / Die1] (+보기만: lying / Die2)
== 개별 프리팹 ==
Ai01.prefab 컨트롤러 pcanim_mage 상태 17개 · 매칭 5/5 · idle→idle run→run attack→attack hit→hit die→die
Batty_A.prefab 컨트롤러 Batty_Animations_Check 상태 8개 · 매칭 5/5 · idle→idle run→run attack→attack hit→hit die→die
F06.prefab 컨트롤러 Animations_check 상태 32개 · 매칭 5/5 · idle→IdleA run→Run attack→ATK0 hit→Damage die→Die0
M05.prefab 컨트롤러 Animations_check 상태 32개 · 매칭 5/5 · idle→IdleA run→Run attack→ATK0 hit→Damage die→Die0
== Res_Addr/Mobs 전수 (확장 견적 근거) ==
Animator+Controller 보유 프리팹 = 299 · 동작 5종 전부 매칭 = 288 (96.3 %)
매칭 4/5 동작 = 11종
매칭 5/5 동작 = 288종
못 찾은 동작 'hit' = 11종

View File

@ -0,0 +1,109 @@
// WL-814k 이름 규약 표 — 「굽기 SO 의 상태 이름 후보」가 어떤 캐릭터에 얼마나 걸리는지 실측 (읽기만 · 굽기 0)
// run: unity command run_script --file AgentScripts/WL814k_Naming.cs --entry WL814k_Naming.Run
// 🔴 여기서도 굽지 않는다 — 1차 시범은 PC 1종 + 잡몹 1종(발주서). 이 표는 「확장하면 뭐가 걸리나」의 근거다.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using WL.Look.SpriteBake;
public static class WL814k_Naming
{
const string kTxt = "AgentScripts/WL814k_NAMING.txt";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
L("WL-814k 이름 규약 표 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
var cfg = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>("Assets/WL/Look/Sprite/Resources/WL/WLSpriteBakeSettings.asset");
if (cfg == null) { L("FAIL — 굽기 SO 없음"); File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false)); return; }
foreach (var a in cfg.actions)
L(" 후보 " + a.key.PadRight(7) + " = [" + string.Join(" / ", a.stateNames) + "] (+보기만: " + string.Join(" / ", a.alsoMapStateNames) + ")");
// ── ① 시범 2종 + PD 가 방금 임포트한 치비 팩(Little Heroes) ─────────────
L("");
L("== 개별 프리팹 ==");
foreach (var p in new[]
{
"Assets/Res_Addr/PC/Ai01.prefab",
"Assets/Res_Addr/Mobs/Mob/Batty_A.prefab",
"Assets/Suriyun/Characters/Nana/Prefab/F06.prefab",
"Assets/Suriyun/Characters/RedKnight/Prefab/M05/M05.prefab",
})
One(p, cfg);
// ── ② 몹 전수 — 확장했을 때 몇 %가 그냥 걸리는지 ───────────────────────
L("");
L("== Res_Addr/Mobs 전수 (확장 견적 근거) ==");
var guids = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/Mobs" });
int total = 0, full = 0;
var hist = new Dictionary<int, int>();
var missing = new Dictionary<string, int>();
foreach (var g in guids)
{
var path = AssetDatabase.GUIDToAssetPath(g);
var go = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (go == null) continue;
var anim = go.GetComponentInChildren<Animator>(true);
if (anim == null || anim.runtimeAnimatorController == null) continue;
total++;
var states = States(anim);
int hit = 0;
foreach (var a in cfg.actions)
{
if (Match(a, states) != null) hit++;
else { int c; missing.TryGetValue(a.key, out c); missing[a.key] = c + 1; }
}
if (hit == cfg.actions.Length) full++;
int n; hist.TryGetValue(hit, out n); hist[hit] = n + 1;
}
L(" Animator+Controller 보유 프리팹 = " + total + " · 동작 5종 전부 매칭 = " + full + " (" + (100f * full / Mathf.Max(1, total)).ToString("0.0") + " %)");
var keys = new List<int>(hist.Keys); keys.Sort();
foreach (var k in keys) L(" 매칭 " + k + "/5 동작 = " + hist[k] + "종");
foreach (var kv in missing) L(" 못 찾은 동작 '" + kv.Key + "' = " + kv.Value + "종");
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
static void One(string path, WLSpriteBakeSettings cfg)
{
var go = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (go == null) { L(" " + path + " → 없음"); return; }
var anim = go.GetComponentInChildren<Animator>(true);
if (anim == null || anim.runtimeAnimatorController == null) { L(" " + path + " → Animator/Controller 없음"); return; }
var states = States(anim);
var sb = new StringBuilder();
int hit = 0;
foreach (var a in cfg.actions)
{
var m = Match(a, states);
if (m != null) hit++;
sb.Append(a.key + "→" + (m ?? "✗") + " ");
}
L(string.Format(" {0,-56} 컨트롤러 {1,-24} 상태 {2,2}개 · 매칭 {3}/{4} · {5}",
Path.GetFileName(path), anim.runtimeAnimatorController.name, states.Count, hit, cfg.actions.Length, sb.ToString().Trim()));
}
static Dictionary<string, string> States(Animator anim)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var ac = anim.runtimeAnimatorController as UnityEditor.Animations.AnimatorController;
if (ac != null && ac.layers.Length > 0 && ac.layers[0].stateMachine != null)
foreach (var ch in ac.layers[0].stateMachine.states)
if (ch.state.motion != null && !d.ContainsKey(ch.state.name)) d[ch.state.name] = ch.state.name;
return d;
}
static string Match(WLSpriteBakeAction a, Dictionary<string, string> states)
{
foreach (var n in a.stateNames) { string real; if (states.TryGetValue(n, out real)) return real; }
return null;
}
}

View File

@ -0,0 +1,49 @@
WL-814k 프로브 · 2026-09-12 18:43:19 · Unity 6000.3.23f1
== A. 에셋 · 메타 ==
PASS A1 굽기 SO — 내림각 43.609° · 픽셀 0.02833 m
PASS A2 런타임 SO — sets 2
PASS A3 시트 Ai01_SpriteSet — Ai01 · 프레임 56×56 · 16열×12행 · 방향 8 · 총 192칸 · 텍스처 896×672
PASS A4 임포트 Ai01_SpriteSet — filter Point · mipmap False · 압축 Uncompressed · alphaIsTransparency True · maxSize 1024 · type Default
PASS A5 격자 Ai01_SpriteSet — 칸 192 ≥ 필요 192 · 텍스처 896×672
PASS A6 동작표 Ai01_SpriteSet — 5개 · idle[0+4] run[32+6] attack[80+6] hit[128+2] die[144+6]
PASS A3 시트 Batty_A_SpriteSet — Batty_A · 프레임 48×48 · 16열×12행 · 방향 8 · 총 192칸 · 텍스처 768×576
PASS A4 임포트 Batty_A_SpriteSet — filter Point · mipmap False · 압축 Uncompressed · alphaIsTransparency True · maxSize 1024 · type Default
PASS A5 격자 Batty_A_SpriteSet — 칸 192 ≥ 필요 192 · 텍스처 768×576
PASS A6 동작표 Batty_A_SpriteSet — 5개 · idle[0+4] run[32+6] attack[80+6] hit[128+2] die[144+6]
== B. 부착 → 원복 (원본 파괴 0 · 렌더러 원값 복원) ==
PASS B1 이름 매칭 — 액터 'Ai01(Clone)' → Ai01_SpriteSet
PASS B2 원본 끔 — SMR 11개 → 켜져 있는 것 0개
PASS B3 쿼드 1개 — [WL814k] SpriteQuad · 부모 Ai01(Clone) · 레이어 3
PASS B4 파괴 0 — 노드 223 → 224 (쿼드 +1)
PASS B5 원복 — SMR 켜짐 11/11 · 노드 223/223
== C. 프레임 · 방향 선택 (Animator 폴링 · 원본 코드 수정 0) ==
PASS C1 상태→동작 — 17/17개 상태가 정확한 동작으로 간다
PASS C2 시간→프레임 — nt 0.13→f0 nt 0.38→f1 nt 0.63→f2 nt 0.88→f3
PASS C2b 상태 폴링(에디트 모드) — 5/5 · idle→idle run→run attack→attack hit→hit die→die
PASS C3 각도→방향 — 0°→d0 45°→d1 90°→d2 135°→d3 180°→d4 225°→d5 270°→d6 315°→d7
PASS C4 쿼드 크기 — 월드 1.5867 m (기대 1.5867) · 스케일 보정 1.000
PASS C5 쿼드 자세 — 카메라 정렬 오차 0.000°
== D. GC (프레임당 할당 0) ==
PASS D1 UpdateAll ×200k — mono Δ 0 B · gen0 0회
== E. 시트 내용 (도트화가 실제로 강제됐는가) ==
PASS E1 알파 이진화 Ai01_SpriteSet — 불투명 63548 · 반투명 0(0 이어야 한다)
PASS E2 팔레트 양자화 Ai01_SpriteSet — 밝기 6단계 [0 51 102 153 204 255] 밖 픽셀 0개 · 실제 7가지 [0 17 51 102 153 204 255] (외곽선 색 14,11,17 제외) · 색 1819종
PASS E3 외곽선 Ai01_SpriteSet — 어두운 테두리 픽셀 22159 / 불투명 63548 (34.9 %)
PASS E4 PNG 크기 Ai01_SpriteSet — 165,716 B (161.8 KB) · 896×672 · RGBA32 런타임 2.30 MB
PASS E1 알파 이진화 Batty_A_SpriteSet — 불투명 53848 · 반투명 0(0 이어야 한다)
PASS E2 팔레트 양자화 Batty_A_SpriteSet — 밝기 6단계 [0 51 102 153 204 255] 밖 픽셀 0개 · 실제 6가지 [17 51 102 153 204 255] (외곽선 색 14,11,17 제외) · 색 398종
PASS E3 외곽선 Batty_A_SpriteSet — 어두운 테두리 픽셀 12096 / 불투명 53848 (22.5 %)
PASS E4 PNG 크기 Batty_A_SpriteSet — 107,778 B (105.3 KB) · 768×576 · RGBA32 런타임 1.69 MB
== F. C8 롤백 (SO off → 원본 3D 100 %) ==
PASS F1 게이트 — RuntimeDisabled → Enabled=false · ShouldBeOn=false
PASS F2 렌더러 100 % 복원 — SMR 11/11 · MR 2/2
PASS F3 쿼드 0 — 노드 223/223 · 대상 0
PASS F4 목록 밖 액터 — 'Ai02(Clone)' → 매칭 0 = 원본 3D 그대로
RESULT PASS — 34 pass / 0 fail

View File

@ -0,0 +1,686 @@
// WL-814k 프로브 + 캡처 4장 (게임 코드 아님 · 에디트 모드 전용 · 임시 씬 저장 0 · 커밋 0)
// probe: unity command run_script --file AgentScripts/WL814k_Probe.cs --entry WL814k_Probe.RunAll
// cap : unity command run_script --file AgentScripts/WL814k_Probe.cs --entry WL814k_Probe.Capture
// 🔴 원본 프리팹/머티리얼/애니메이션 수정 0. 씬 저장 0.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.Profiling;
using UnityEngine.SceneManagement;
using WL.Look.SpriteBake;
public static class WL814k_Probe
{
const string kOutDir = @"E:\NerdNavis\nn_himminji\Screenshots_WL\WL814k";
const string kTxt = "AgentScripts/WL814k_PROBE.txt";
const string kTxtCap = "AgentScripts/WL814k_CAPTURE.txt";
const string kRes = "Assets/WL/Look/Sprite/Resources/WL";
const string kPcPrefab = "Assets/Res_Addr/PC/Ai01.prefab";
const string kMobPrefab = "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab";
// 도트A 프리셋 실측 = orthoSize 3.4 · pixelHeight 240 · 내림각 43.609°
const float kOrtho = 3.4f, kPitch = 43.609f;
const int kLowH = 240, kLowW = 135, kHiW = 1080, kHiH = 1920;
static StringBuilder s_sb;
static int s_pass, s_fail;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
static void Chk(string n, bool ok, string d) { if (ok) s_pass++; else s_fail++; L((ok ? " PASS " : " FAIL ") + n + " — " + d); }
static void Write(string p) { File.WriteAllText(p, s_sb.ToString(), new UTF8Encoding(false)); Debug.Log("[WL814k] → " + p); }
// ═════════════════════════════════════════════════════════════════════════
// 프로브
// ═════════════════════════════════════════════════════════════════════════
public static void RunAll()
{
s_sb = new StringBuilder(); s_pass = 0; s_fail = 0;
L("WL-814k 프로브 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion);
try
{
A_Assets();
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
B_AttachRestore();
C_FrameDir();
D_Gc();
E_Sheet();
F_C8();
}
catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); }
finally
{
SpriteBillboard.RestoreNow("probe-end");
WLSpriteLookSettings.RuntimeDisabled = false;
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
L("");
L("RESULT " + (s_fail == 0 ? "PASS" : "FAIL") + " — " + s_pass + " pass / " + s_fail + " fail");
Write(kTxt);
}
// ── A. 에셋 · 메타 ──────────────────────────────────────────────────────
static void A_Assets()
{
L("");
L("== A. 에셋 · 메타 ==");
var bake = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
var look = AssetDatabase.LoadAssetAtPath<WLSpriteLookSettings>(kRes + "/WLSpriteLookSettings.asset");
Chk("A1 굽기 SO", bake != null, bake == null ? "없음" : "내림각 " + bake.PitchDeg.ToString("0.000") + "° · 픽셀 " + bake.WorldPerPixel.ToString("0.00000") + " m");
Chk("A2 런타임 SO", look != null && look.sets != null && look.sets.Length == 2, look == null ? "없음" : "sets " + look.sets.Length);
if (look == null) return;
foreach (var s in look.sets)
{
Chk("A3 시트 " + (s == null ? "null" : s.name), s != null && s.IsUsable,
s == null ? "null" : string.Format("{0} · 프레임 {1}×{2} · {3}열×{4}행 · 방향 {5} · 총 {6}칸 · 텍스처 {7}×{8}",
s.actorNamePrefix, s.frameWidth, s.frameHeight, s.columns, s.rows, s.dirCount, s.TotalFrames,
s.sheet == null ? 0 : s.sheet.width, s.sheet == null ? 0 : s.sheet.height));
if (s == null || s.sheet == null) continue;
var ti = AssetImporter.GetAtPath(AssetDatabase.GetAssetPath(s.sheet)) as TextureImporter;
Chk("A4 임포트 " + s.name, ti != null && ti.filterMode == FilterMode.Point && !ti.mipmapEnabled
&& ti.textureCompression == TextureImporterCompression.Uncompressed && ti.alphaIsTransparency,
ti == null ? "임포터 없음" : "filter " + ti.filterMode + " · mipmap " + ti.mipmapEnabled + " · 압축 " + ti.textureCompression
+ " · alphaIsTransparency " + ti.alphaIsTransparency + " · maxSize " + ti.maxTextureSize + " · type " + ti.textureType);
int need = s.TotalFrames;
int cap = s.columns * s.rows;
Chk("A5 격자 " + s.name, cap >= need && s.sheet.width == s.columns * s.frameWidth && s.sheet.height == s.rows * s.frameHeight,
"칸 " + cap + " ≥ 필요 " + need + " · 텍스처 " + s.sheet.width + "×" + s.sheet.height);
var act = new StringBuilder();
foreach (var a in s.actions) act.Append(a.key + "[" + a.startIndex + "+" + a.frameCount + "] ");
Chk("A6 동작표 " + s.name, s.actions.Length >= 4, s.actions.Length + "개 · " + act.ToString().TrimEnd());
}
}
// ── B. 부착 → 원복 ──────────────────────────────────────────────────────
static void B_AttachRestore()
{
L("");
L("== B. 부착 → 원복 (원본 파괴 0 · 렌더러 원값 복원) ==");
var look = WLSpriteLookSettings.Instance;
var actor = Spawn(kPcPrefab, Vector3.zero, 0.7f);
var set = SpriteBillboard.MatchSet(actor.name, look);
Chk("B1 이름 매칭", set != null, "액터 '" + actor.name + "' → " + (set == null ? "매칭 실패" : set.name));
if (set == null) { UnityEngine.Object.DestroyImmediate(actor); return; }
var smrs = actor.GetComponentsInChildren<SkinnedMeshRenderer>(true);
int before = 0; foreach (var r in smrs) if (r.enabled) before++;
int childBefore = CountAll(actor);
var e = SpriteBillboard.TryAttach(actor, set);
int after = 0; foreach (var r in smrs) if (r.enabled) after++;
Chk("B2 원본 끔", e != null && after == 0 && before > 0, "SMR " + before + "개 → 켜져 있는 것 " + after + "개");
Chk("B3 쿼드 1개", e != null && e.quad != null, e == null ? "-" : e.quad.name + " · 부모 " + e.quad.transform.parent.name + " · 레이어 " + e.quad.layer);
Chk("B4 파괴 0", CountAll(actor) == childBefore + 1, "노드 " + childBefore + " → " + CountAll(actor) + " (쿼드 +1)");
SpriteBillboard.Detach(actor);
int restored = 0; foreach (var r in smrs) if (r != null && r.enabled) restored++;
Chk("B5 원복", restored == before && CountAll(actor) == childBefore, "SMR 켜짐 " + restored + "/" + before + " · 노드 " + CountAll(actor) + "/" + childBefore);
UnityEngine.Object.DestroyImmediate(actor);
}
// ── C. 프레임 · 방향 선택 ───────────────────────────────────────────────
static void C_FrameDir()
{
L("");
L("== C. 프레임 · 방향 선택 (Animator 폴링 · 원본 코드 수정 0) ==");
var look = WLSpriteLookSettings.Instance;
var cam = MakeCam(kLowW, kLowH);
var actor = Spawn(kPcPrefab, Vector3.zero, 0.7f);
var set = SpriteBillboard.MatchSet(actor.name, look);
var anim = actor.GetComponent<Animator>();
anim.applyRootMotion = false; anim.cullingMode = AnimatorCullingMode.AlwaysAnimate; anim.Rebind();
var e = SpriteBillboard.TryAttach(actor, set);
// ⓐ 상태 해시 → 동작
int okState = 0, allState = 0;
foreach (var a in set.actions)
foreach (var n in a.stateNames)
{
allState++;
var found = set.FindByStateHash(Animator.StringToHash(n));
if (found == a) okState++;
}
Chk("C1 상태→동작", okState == allState, okState + "/" + allState + "개 상태가 정확한 동작으로 간다");
// ⓑ normalizedTime → 프레임
var idle = set.FindByKey("idle");
var lines = new StringBuilder();
bool frameOk = true;
for (int f = 0; f < idle.frameCount; f++)
{
float nt = (f + 0.5f) / idle.frameCount;
anim.Play(Animator.StringToHash("idle"), 0, nt);
anim.Update(0f);
SpriteBillboard.UpdateAll(cam.cam);
if (e.frame != f) frameOk = false;
lines.Append("nt " + nt.ToString("0.00") + "→f" + e.frame + " ");
}
Chk("C2 시간→프레임", frameOk, lines.ToString().Trim());
// ⓑ-2 기본 상태가 아닌 동작도 폴링으로 맞는가 (에디트 모드 실측)
lines.Length = 0; int hit = 0, tot = 0;
foreach (var a in set.actions)
{
int h = Animator.StringToHash(a.stateNames[0]);
if (!anim.HasState(0, h)) continue;
tot++;
anim.Play(h, 0, 0.5f);
anim.Update(0f);
SpriteBillboard.UpdateAll(cam.cam);
bool ok = e.action != null && e.action.key == a.key;
if (ok) hit++;
lines.Append(a.key + "→" + (e.action == null ? "-" : e.action.key) + (ok ? " " : "✗ "));
}
Chk("C2b 상태 폴링(에디트 모드)", hit == tot, hit + "/" + tot + " · " + lines.ToString().Trim());
// ⓒ 상대 yaw → 방향
lines.Length = 0; bool dirOk = true;
anim.Play(Animator.StringToHash("idle"), 0, 0f); anim.Update(0f);
for (int d = 0; d < set.dirCount; d++)
{
actor.transform.rotation = Quaternion.Euler(0f, d * (360f / set.dirCount), 0f);
SpriteBillboard.UpdateAll(cam.cam);
if (e.dir != d) dirOk = false;
lines.Append(d * 45 + "°→d" + e.dir + " ");
}
Chk("C3 각도→방향", dirOk, lines.ToString().Trim());
// ⓓ 쿼드 크기 = 프레임 px × 픽셀 월드 크기 (스케일 보정 포함)
actor.transform.rotation = Quaternion.identity;
SpriteBillboard.UpdateAll(cam.cam);
float expect = set.frameWidth * set.worldPerPixel * (0.7f / set.bakeScale);
float got = e.quadTf.lossyScale.x;
Chk("C4 쿼드 크기", Mathf.Abs(got - expect) < 0.002f,
string.Format("월드 {0:0.0000} m (기대 {1:0.0000}) · 스케일 보정 {2:0.000}", got, expect, e.scaleK));
Chk("C5 쿼드 자세", Quaternion.Angle(e.quadTf.rotation, cam.cam.transform.rotation) < 0.01f,
"카메라 정렬 오차 " + Quaternion.Angle(e.quadTf.rotation, cam.cam.transform.rotation).ToString("0.000") + "°");
SpriteBillboard.Detach(actor);
UnityEngine.Object.DestroyImmediate(actor);
cam.Dispose();
}
// ── D. GC ───────────────────────────────────────────────────────────────
static void D_Gc()
{
L("");
L("== D. GC (프레임당 할당 0) ==");
var look = WLSpriteLookSettings.Instance;
var cam = MakeCam(kLowW, kLowH);
var a1 = Spawn(kPcPrefab, Vector3.zero, 0.7f);
var a2 = Spawn(kMobPrefab, new Vector3(1.5f, 0f, 0f), 0f);
SpriteBillboard.TryAttach(a1, SpriteBillboard.MatchSet(a1.name, look));
SpriteBillboard.TryAttach(a2, SpriteBillboard.MatchSet(a2.name, look));
for (int i = 0; i < 200; i++) SpriteBillboard.UpdateAll(cam.cam); // 워밍업
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
long m0 = Profiler.GetMonoUsedSizeLong();
int g0 = GC.CollectionCount(0);
for (int i = 0; i < 200000; i++) SpriteBillboard.UpdateAll(cam.cam);
long m1 = Profiler.GetMonoUsedSizeLong();
int g1 = GC.CollectionCount(0);
Chk("D1 UpdateAll ×200k", m1 - m0 == 0 && g1 == g0, "mono Δ " + (m1 - m0) + " B · gen0 " + (g1 - g0) + "회");
SpriteBillboard.RestoreNow("gc-probe");
UnityEngine.Object.DestroyImmediate(a1); UnityEngine.Object.DestroyImmediate(a2);
cam.Dispose();
}
// ── E. 시트 내용 ────────────────────────────────────────────────────────
static void E_Sheet()
{
L("");
L("== E. 시트 내용 (도트화가 실제로 강제됐는가) ==");
var look = WLSpriteLookSettings.Instance;
foreach (var s in look.sets)
{
if (s == null || s.sheet == null) continue;
var path = AssetDatabase.GetAssetPath(s.sheet);
var bytes = File.ReadAllBytes(path);
var t = new Texture2D(2, 2, TextureFormat.RGBA32, false, false);
t.LoadImage(bytes);
var px = t.GetPixels32();
// 기대 팔레트 = 채널당 levels 단계 · 여기에 외곽선 색(SO)만 예외로 허용한다
var bake = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
int lv = bake != null ? bake.paletteLevels : 6;
var allowed = new HashSet<int>();
for (int i = 0; i < lv; i++) allowed.Add(Mathf.RoundToInt((float)i / (lv - 1) * 255f));
Color32 oc = bake != null ? (Color32)bake.outlineColor : new Color32(14, 11, 17, 255);
bool bands = bake == null || bake.paletteValueBands;
int opaque = 0, semi = 0, outline = 0, offPalette = 0;
var levels = new HashSet<int>();
var colors = new HashSet<int>();
for (int i = 0; i < px.Length; i++)
{
if (px[i].a == 0) continue;
if (px[i].a == 255) opaque++; else semi++;
levels.Add(bands ? Mathf.Max(px[i].r, Mathf.Max(px[i].g, px[i].b)) : px[i].r);
if (!bands) { levels.Add(px[i].g); levels.Add(px[i].b); }
colors.Add((px[i].r << 16) | (px[i].g << 8) | px[i].b);
bool isOutline = px[i].r == oc.r && px[i].g == oc.g && px[i].b == oc.b;
if (isOutline) { outline++; continue; }
if (bands)
{
if (!allowed.Contains(Mathf.Max(px[i].r, Mathf.Max(px[i].g, px[i].b)))) offPalette++;
}
else if (!allowed.Contains(px[i].r) || !allowed.Contains(px[i].g) || !allowed.Contains(px[i].b)) offPalette++;
}
Chk("E1 알파 이진화 " + s.name, semi == 0, "불투명 " + opaque + " · 반투명 " + semi + "(0 이어야 한다)");
Chk("E2 팔레트 양자화 " + s.name, offPalette == 0,
(bands ? "밝기 " : "채널 ") + lv + "단계 " + Join(allowed) + " 밖 픽셀 " + offPalette + "개 · 실제 " + levels.Count + "가지 " + Join(levels)
+ " (외곽선 색 " + oc.r + "," + oc.g + "," + oc.b + " 제외) · 색 " + colors.Count + "종");
Chk("E3 외곽선 " + s.name, outline > opaque * 0.05f, "어두운 테두리 픽셀 " + outline + " / 불투명 " + opaque
+ " (" + (100f * outline / Mathf.Max(1, opaque)).ToString("0.0") + " %)");
Chk("E4 PNG 크기 " + s.name, bytes.Length > 0, bytes.Length.ToString("N0") + " B (" + (bytes.Length / 1024.0).ToString("0.0") + " KB) · "
+ s.sheet.width + "×" + s.sheet.height + " · RGBA32 런타임 " + (s.sheet.width * s.sheet.height * 4 / 1024.0 / 1024.0).ToString("0.00") + " MB");
UnityEngine.Object.DestroyImmediate(t);
}
}
static string Join(HashSet<int> s)
{
var l = new List<int>(s); l.Sort();
var sb = new StringBuilder("[");
foreach (var v in l) sb.Append(v).Append(' ');
return sb.ToString().TrimEnd() + "]";
}
// ── F. C8 (SO off → 원본 3D 100 %) ─────────────────────────────────────
static void F_C8()
{
L("");
L("== F. C8 롤백 (SO off → 원본 3D 100 %) ==");
var look = WLSpriteLookSettings.Instance;
var actor = Spawn(kPcPrefab, Vector3.zero, 0.7f);
var smrs = actor.GetComponentsInChildren<SkinnedMeshRenderer>(true);
var mrs = actor.GetComponentsInChildren<MeshRenderer>(true);
int smrOn0 = 0; foreach (var r in smrs) if (r.enabled) smrOn0++;
int mrOn0 = 0; foreach (var r in mrs) if (r.enabled) mrOn0++;
int nodes0 = CountAll(actor);
SpriteBillboard.TryAttach(actor, SpriteBillboard.MatchSet(actor.name, look));
WLSpriteLookSettings.RuntimeDisabled = true;
Chk("F1 게이트", !WLSpriteLookSettings.Enabled && !SpriteBillboard.ShouldBeOn, "RuntimeDisabled → Enabled=false · ShouldBeOn=false");
int n = SpriteBillboard.RestoreNow("C8");
WLSpriteLookSettings.RuntimeDisabled = false;
int smrOn1 = 0; foreach (var r in smrs) if (r != null && r.enabled) smrOn1++;
int mrOn1 = 0; foreach (var r in mrs) if (r != null && r.enabled) mrOn1++;
Chk("F2 렌더러 100 % 복원", smrOn1 == smrOn0 && mrOn1 == mrOn0, "SMR " + smrOn1 + "/" + smrOn0 + " · MR " + mrOn1 + "/" + mrOn0);
Chk("F3 쿼드 0", CountAll(actor) == nodes0 && SpriteBillboard.ActiveCount == 0, "노드 " + CountAll(actor) + "/" + nodes0 + " · 대상 " + SpriteBillboard.ActiveCount);
// 시트를 안 가진 액터는 아예 안 걸린다
var other = Spawn("Assets/Res_Addr/PC/Ai02.prefab", Vector3.zero, 0.7f);
if (other != null)
{
Chk("F4 목록 밖 액터", SpriteBillboard.MatchSet(other.name, look) == null, "'" + other.name + "' → 매칭 0 = 원본 3D 그대로");
UnityEngine.Object.DestroyImmediate(other);
}
UnityEngine.Object.DestroyImmediate(actor);
}
// ═════════════════════════════════════════════════════════════════════════
// 캡처 4장
// ═════════════════════════════════════════════════════════════════════════
public static void Capture()
{
s_sb = new StringBuilder(); s_pass = 0; s_fail = 0;
L("WL-814k 캡처 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
Directory.CreateDirectory(kOutDir);
try
{
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
World();
var look = WLSpriteLookSettings.Instance;
// 두 캐릭터를 나란히 (PC 왼쪽 · 몹 오른쪽)
var pc = Spawn(kPcPrefab, new Vector3(-1.0f, 0f, 0f), 0.7f);
var mob = Spawn(kMobPrefab, new Vector3(1.1f, 0f, 0f), 0f);
// 🔴 구운 프레임과 **정확히 같은 시각**으로 3D 를 세운다(프레임 양자화 차이를 A/B 에서 빼기 위해)
PoseFrame(pc, look, "attack", 2, 315f);
PoseFrame(mob, look, "attack", 3, 45f);
var ground = Ground();
var cam = MakeCam(kLowW, kLowH);
cam.cam.transform.position = new Vector3(0f, 1.0f, 0f) - cam.cam.transform.forward * 40f;
cam.cam.orthographicSize = kOrtho;
// 지면만 찍은 기준 컷(실루엣을 차분으로 정확히 잰다)
pc.SetActive(false); mob.SetActive(false);
var bg = cam.Grab();
pc.SetActive(true); mob.SetActive(true);
// ⓐ 3D 원본
var a = cam.Grab();
SavePoint(a, kLowW, kLowH, kOutDir + @"\k_1_3d_original.png");
L(" ⓐ 3D 원본 → k_1_3d_original.png");
// ⓑ 구운 스프라이트로 교체(같은 구도 · 같은 포즈 · 같은 방향)
var ePc = SpriteBillboard.TryAttach(pc, SpriteBillboard.MatchSet(pc.name, look));
var eMob = SpriteBillboard.TryAttach(mob, SpriteBillboard.MatchSet(mob.name, look));
SpriteBillboard.UpdateAll(cam.cam);
L(string.Format(" 폴링 결과 — PC {0} d{1} f{2} / 몹 {3} d{4} f{5}",
ePc.action != null ? ePc.action.key : "-", ePc.dir, ePc.frame,
eMob.action != null ? eMob.action.key : "-", eMob.dir, eMob.frame));
// 🔴 A/B 는 「같은 포즈」여야 하므로 프레임을 못 박는다(폴링 정확도는 프로브 C 가 따로 잰다)
SpriteBillboard.SetManualFrame(pc, "attack", 7, 2);
SpriteBillboard.SetManualFrame(mob, "attack", 1, 3);
SpriteBillboard.UpdateAll(cam.cam);
var b = cam.Grab();
SavePoint(b, kLowW, kLowH, kOutDir + @"\k_2_sprite_baked.png");
L(" ⓑ 스프라이트 교체 → k_2_sprite_baked.png");
// 정렬 실측 — 같은 구도에서 3D 실루엣과 스프라이트 실루엣이 몇 px 어긋나는가
var boxA = Box(a, bg, kLowW, kLowH, 0, kLowW / 2);
var boxB = Box(b, bg, kLowW, kLowH, 0, kLowW / 2);
L(string.Format(" 정렬 실측(PC 쪽 절반) — 3D x[{0}..{1}] y[{2}..{3}] / 스프라이트 x[{4}..{5}] y[{6}..{7}] → Δ좌 {8} · Δ우 {9} · Δ하 {10} · Δ상 {11} px",
boxA[0], boxA[1], boxA[2], boxA[3], boxB[0], boxB[1], boxB[2], boxB[3],
boxB[0] - boxA[0], boxB[1] - boxA[1], boxB[2] - boxA[2], boxB[3] - boxA[3]));
L(string.Format(" 밝기 실측 — 3D 평균 {0:0.0} · 스프라이트 평균 {1:0.0} (0~255 · 캐릭터 픽셀만) · 캐릭터 픽셀 3D {2} / 스프라이트 {3} (외곽선 +{4})",
MeanLum(a, bg), MeanLum(b, bg), CountDiff(a, bg), CountDiff(b, bg), CountDiff(b, bg) - CountDiff(a, bg)));
// ⓒ 2배 클로즈업 — 위 3D / 아래 스프라이트
CloseUp(a, b, bg, kLowW, kLowH, kOutDir + @"\k_3_closeup_2x.png");
L(" ⓒ 클로즈업 → k_3_closeup_2x.png");
// ⓓ 방향 8종 한 장
SpriteBillboard.RestoreNow("capture-dir");
Directions(cam, pc, mob, ground, kOutDir + @"\k_4_directions.png");
L(" ⓓ 방향 8종 → k_4_directions.png");
cam.Dispose();
}
catch (Exception ex) { s_fail++; L("EXCEPTION " + ex); }
finally
{
SpriteBillboard.RestoreNow("capture-end");
EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
L("");
L("캡처 " + (s_fail == 0 ? "PASS" : "FAIL") + " — " + kOutDir);
Write(kTxtCap);
}
// 방향 8종을 한 장에 — 위 줄 = 3D · 아래 줄 = 스프라이트
static void Directions(CamRig cam, GameObject pc, GameObject mob, GameObject ground, string file)
{
int cell = 64; // 셀 하나의 저해상도 픽셀 크기
var rig = MakeCam(cell, cell);
rig.cam.transform.rotation = cam.cam.transform.rotation;
rig.cam.orthographicSize = cell * (2f * kOrtho / kLowH) * 0.5f;
mob.SetActive(false);
pc.transform.position = Vector3.zero;
int cols = 8, rows = 2;
var sheet = new Color32[cols * cell * rows * cell];
var look = WLSpriteLookSettings.Instance;
for (int pass = 0; pass < 2; pass++)
{
if (pass == 1)
{
var e = SpriteBillboard.TryAttach(pc, SpriteBillboard.MatchSet(pc.name, look));
}
for (int d = 0; d < cols; d++)
{
Pose(pc, "idle", 0.0f, d * 45f);
rig.cam.transform.position = new Vector3(0f, 0.6f, 0f) - rig.cam.transform.forward * 40f;
if (pass == 1) SpriteBillboard.UpdateAll(rig.cam);
var px = rig.Grab();
BlitInto(px, cell, cell, sheet, cols * cell, d * cell, (rows - 1 - pass) * cell);
}
}
SpriteBillboard.RestoreNow("dir-capture");
mob.SetActive(true);
SavePoint(sheet, cols * cell, rows * cell, file, 4);
rig.Dispose();
}
// 클로즈업 — 캐릭터 주변을 잘라 확대해 위/아래로 붙인다
static void CloseUp(Color32[] a, Color32[] b, Color32[] bg, int w, int h, string file)
{
var box = Box(a, bg, w, h, 0, w);
int pad = 6;
int x0 = Mathf.Max(0, box[0] - pad), x1 = Mathf.Min(w - 1, box[1] + pad);
int y0 = Mathf.Max(0, box[2] - pad), y1 = Mathf.Min(h - 1, box[3] + pad);
int cw = x1 - x0 + 1, ch = y1 - y0 + 1;
int zoom = Mathf.Max(2, Mathf.Min(1080 / Mathf.Max(1, cw), 900 / Mathf.Max(1, ch)));
int outW = cw * zoom, outH = ch * zoom * 2 + 8;
var outPx = new Color32[outW * outH];
for (int i = 0; i < outPx.Length; i++) outPx[i] = new Color32(24, 26, 30, 255);
// 아래 = 스프라이트 · 위 = 3D
CropZoom(b, w, h, x0, y0, cw, ch, zoom, outPx, outW, 0, 0);
CropZoom(a, w, h, x0, y0, cw, ch, zoom, outPx, outW, 0, ch * zoom + 8);
L(string.Format(" 클로즈업 crop {0}×{1} px → ×{2} = {3}×{4} (위 3D · 아래 스프라이트)", cw, ch, zoom, outW, outH));
SaveRaw(outPx, outW, outH, file);
}
static void CropZoom(Color32[] src, int w, int h, int x0, int y0, int cw, int ch, int zoom,
Color32[] dst, int dw, int dx, int dy)
{
for (int y = 0; y < ch * zoom; y++)
for (int x = 0; x < cw * zoom; x++)
{
int sx = x0 + x / zoom, sy = y0 + y / zoom;
if (sx < 0 || sy < 0 || sx >= w || sy >= h) continue;
int di = (dy + y) * dw + (dx + x);
if (di < 0 || di >= dst.Length) continue;
dst[di] = src[sy * w + sx];
}
}
/// <summary>지면만 찍은 컷과 다른 픽셀 = 캐릭터 픽셀.</summary>
static bool Diff(Color32[] px, Color32[] bg, int i)
{
if (bg == null) return px[i].a >= 8;
return Mathf.Abs(px[i].r - bg[i].r) + Mathf.Abs(px[i].g - bg[i].g) + Mathf.Abs(px[i].b - bg[i].b) > 8
|| Mathf.Abs(px[i].a - bg[i].a) > 8;
}
static float MeanLum(Color32[] px, Color32[] bg)
{
double sum = 0; int n = 0;
for (int i = 0; i < px.Length; i++)
{
if (!Diff(px, bg, i)) continue;
sum += 0.299 * px[i].r + 0.587 * px[i].g + 0.114 * px[i].b; n++;
}
return n == 0 ? 0f : (float)(sum / n);
}
static int CountDiff(Color32[] px, Color32[] bg)
{
int n = 0;
for (int i = 0; i < px.Length; i++) if (Diff(px, bg, i)) n++;
return n;
}
static int[] Box(Color32[] px, Color32[] bg, int w, int h, int xFrom, int xTo)
{
int minX = int.MaxValue, maxX = -1, minY = int.MaxValue, maxY = -1;
for (int y = 0; y < h; y++)
for (int x = xFrom; x < Mathf.Min(w, xTo); x++)
{
if (!Diff(px, bg, y * w + x)) continue;
if (x < minX) minX = x; if (x > maxX) maxX = x;
if (y < minY) minY = y; if (y > maxY) maxY = y;
}
return new[] { minX, maxX, minY, maxY };
}
/// <summary>지면 — PD 가 「게임 화면처럼」 볼 수 있게 단색 평면 1장(커밋 0 · 임시 씬).</summary>
static GameObject Ground()
{
var go = GameObject.CreatePrimitive(PrimitiveType.Plane);
go.name = "[WL814k] Ground";
go.transform.position = Vector3.zero;
go.transform.localScale = new Vector3(6f, 1f, 6f);
UnityEngine.Object.DestroyImmediate(go.GetComponent<Collider>());
var mr = go.GetComponent<MeshRenderer>();
var sh = Shader.Find("Universal Render Pipeline/Lit");
var m = new Material(sh != null ? sh : Shader.Find("Standard"));
m.hideFlags = HideFlags.HideAndDontSave;
m.SetColor("_BaseColor", new Color(0.34f, 0.40f, 0.25f, 1f));
m.SetFloat("_Smoothness", 0f);
mr.sharedMaterial = m;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
return go;
}
// ═════════════════════════════════════════════════════════════════════════
// 공통
// ═════════════════════════════════════════════════════════════════════════
static void World()
{
var bake = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
RenderSettings.fog = false;
RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource<Material>("Default-Skybox.mat");
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox;
RenderSettings.ambientIntensity = 1f;
DynamicGI.UpdateEnvironment();
var sun = new GameObject("Sun").AddComponent<Light>();
sun.type = LightType.Directional; sun.shadows = LightShadows.None;
sun.color = bake != null ? bake.sunColor : Color.white;
sun.intensity = bake != null ? bake.sunIntensity : 1.2f;
sun.transform.rotation = Quaternion.Euler(bake != null ? bake.sunEuler : new Vector3(26.4f, 238.6f, 0f));
if (bake != null && bake.fillIntensity > 0f)
{
var fill = new GameObject("Fill").AddComponent<Light>();
fill.type = LightType.Directional; fill.shadows = LightShadows.None;
fill.color = bake.fillColor; fill.intensity = bake.fillIntensity;
fill.transform.rotation = Quaternion.Euler(bake.fillEuler);
}
}
static GameObject Spawn(string prefabPath, Vector3 pos, float scale)
{
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (src == null) return null;
var go = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(go, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
go.name = Path.GetFileNameWithoutExtension(prefabPath) + "(Clone)";
go.transform.position = pos; go.transform.rotation = Quaternion.identity;
if (scale > 0.0001f) go.transform.localScale = Vector3.one * scale;
foreach (var c in go.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in go.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in go.GetComponentsInChildren<MonoBehaviour>(true)) if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
foreach (var sm in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{ sm.updateWhenOffscreen = true; sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; }
var an = go.GetComponent<Animator>();
if (an != null) { an.applyRootMotion = false; an.cullingMode = AnimatorCullingMode.AlwaysAnimate; an.Rebind(); }
// 굽기와 같은 Toon 머티리얼로(같은 조건 비교)
var bake = AssetDatabase.LoadAssetAtPath<WLSpriteBakeSettings>(kRes + "/WLSpriteBakeSettings.asset");
if (bake != null && bake.useToonMaterials)
foreach (var rend in go.GetComponentsInChildren<Renderer>(true))
{
if (rend == null || rend is ParticleSystemRenderer) continue;
var mats = rend.sharedMaterials; bool any = false;
for (int i = 0; i < mats.Length; i++)
{
if (mats[i] == null) continue;
var t = AssetDatabase.LoadAssetAtPath<Material>(bake.toonMaterialDir + "/" + mats[i].name + bake.toonMaterialSuffix + ".mat");
if (t != null) { mats[i] = t; any = true; }
}
if (any) rend.sharedMaterials = mats;
}
return go;
}
static void Pose(GameObject go, string state, float nt, float yaw)
{
go.transform.rotation = Quaternion.Euler(0f, yaw, 0f);
var an = go.GetComponent<Animator>();
if (an == null) return;
int h = Animator.StringToHash(state);
if (!an.HasState(0, h)) return;
an.Play(h, 0, nt);
an.Update(0f);
}
/// <summary>시트가 가진 프레임 f 와 **같은 시각**으로 3D 를 세운다(A/B 를 같은 포즈로 맞추기 위해).</summary>
static void PoseFrame(GameObject go, WLSpriteLookSettings look, string key, int frame, float yaw)
{
var set = SpriteBillboard.MatchSet(go.name, look);
var a = set != null ? set.FindByKey(key) : null;
if (a == null) { Pose(go, key, 0f, yaw); return; }
float nt = a.loop ? (frame + 0.001f) / a.frameCount : (a.frameCount > 1 ? (float)frame / (a.frameCount - 1) : 0f);
Pose(go, key, nt, yaw);
}
static int CountAll(GameObject go) { return go.GetComponentsInChildren<Transform>(true).Length; }
sealed class CamRig
{
public Camera cam; public RenderTexture rt; public Texture2D tex; public int w, h;
public Color32[] Grab()
{
cam.targetTexture = rt; cam.Render(); cam.targetTexture = null;
var active = RenderTexture.active; RenderTexture.active = rt;
tex.ReadPixels(new Rect(0, 0, w, h), 0, 0); tex.Apply(false, false);
RenderTexture.active = active;
return tex.GetPixels32();
}
public void Dispose()
{
RenderTexture.active = null;
if (rt != null) { rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (tex != null) UnityEngine.Object.DestroyImmediate(tex);
if (cam != null) UnityEngine.Object.DestroyImmediate(cam.gameObject);
}
}
static CamRig MakeCam(int w, int h)
{
var go = new GameObject("[WL814k] CapCam");
var c = go.AddComponent<Camera>();
c.orthographic = true;
c.orthographicSize = h * (2f * kOrtho / kLowH) * 0.5f;
c.nearClipPlane = 0.01f; c.farClipPlane = 200f;
c.clearFlags = CameraClearFlags.SolidColor;
c.backgroundColor = new Color(0.27f, 0.34f, 0.22f, 1f); // 지면 평면 밖은 이 색(지면이 화면을 덮는다)
c.allowHDR = false; c.allowMSAA = false; c.useOcclusionCulling = false;
go.transform.rotation = Quaternion.Euler(kPitch, 0f, 0f);
go.transform.position = new Vector3(0f, 1.0f, 0f) - go.transform.forward * 40f;
var rt = new RenderTexture(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.filterMode = FilterMode.Point; rt.antiAliasing = 1; rt.Create();
var tex = new Texture2D(w, h, TextureFormat.RGBA32, false, false);
return new CamRig { cam = c, rt = rt, tex = tex, w = w, h = h };
}
// 저해상도 → Point 확대 저장(도트 모드가 화면에 내보내는 방식 그대로)
static void SavePoint(Color32[] px, int w, int h, string file, int zoomOverride = 0)
{
int zoom = zoomOverride > 0 ? zoomOverride : Mathf.Max(1, kHiH / h);
int ow = w * zoom, oh = h * zoom;
var outPx = new Color32[ow * oh];
for (int y = 0; y < oh; y++)
for (int x = 0; x < ow; x++)
outPx[y * ow + x] = px[(y / zoom) * w + (x / zoom)];
SaveRaw(outPx, ow, oh, file);
}
static void SaveRaw(Color32[] px, int w, int h, string file)
{
var t = new Texture2D(w, h, TextureFormat.RGBA32, false, false);
t.SetPixels32(px); t.Apply(false, false);
File.WriteAllBytes(file, t.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(t);
}
static void BlitInto(Color32[] src, int sw, int sh, Color32[] dst, int dw, int dx, int dy)
{
for (int y = 0; y < sh; y++)
for (int x = 0; x < sw; x++)
{
int di = (dy + y) * dw + (dx + x);
if (di >= 0 && di < dst.Length) dst[di] = src[y * sw + x];
}
}
}

View File

@ -0,0 +1,147 @@
WL-814k 실측 조사 · 2026-09-12 18:04:40 · Unity 6000.3.23f1
== ① 카메라 각 (WLLookModeSettings 프리셋 실측) ==
[0] 기본 pixelRig=0 h=80 d=3 up=3 ortho=3.8 pxH=480 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m
[1] 도트A pixelRig=1 h=80 d=3 up=3 ortho=3.4 pxH=240 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m
[2] 도트B(넓게) pixelRig=1 h=80 d=3 up=3 ortho=4.6 pxH=240 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m
[3] 도트C(굵게) pixelRig=1 h=80 d=3 up=3 ortho=4.6 pxH=180 → 내림각 43.609° · 높이 5.433 m · 수평 4.591 m
도트A: orthoSize 3.4 · pixelHeight 240 → 월드 1 m = 35.294 px · 픽셀 1개 = 0.02833 m
== ② 프리팹 Assets/Res_Addr/PC/Ai01.prefab ==
Animator = Ai01
avatar = AiAvatar · isHuman=True · isValid=True
applyRootMotion = False · cullingMode = CullUpdateTransforms
controller = pcanim_mage (Assets/Res_Addr/Animations/pcanim_mage.controller)
clips = 26
· LightHit len 1.000s fps 30 frames 30 loop False legacy False hasMotion True hasRoot True empty False
· death_F3441 len 2.100s fps 30 frames 63 loop False legacy False hasMotion False hasRoot True empty False
· Idle len 2.000s fps 30 frames 60 loop True legacy False hasMotion True hasRoot True empty False
· Run len 0.533s fps 30 frames 16 loop True legacy False hasMotion True hasRoot True empty False
· CastspellB_wing len 1.208s fps 24 frames 29 loop True legacy False hasMotion False hasRoot True empty False
· RangeAttack1 len 2.100s fps 30 frames 63 loop False legacy False hasMotion True hasRoot True empty False
· Atk2_wing len 1.500s fps 30 frames 45 loop True legacy False hasMotion False hasRoot True empty False
· Atk_A1_wing len 1.167s fps 24 frames 28 loop True legacy False hasMotion False hasRoot True empty False
· DashForward len 1.333s fps 30 frames 40 loop False legacy False hasMotion True hasRoot True empty False
· Land len 0.500s fps 30 frames 15 loop False legacy False hasMotion True hasRoot True empty False
· RangeAttack2 len 2.167s fps 30 frames 65 loop False legacy False hasMotion True hasRoot True empty False
· SpecialAttack1 len 2.167s fps 30 frames 65 loop False legacy False hasMotion True hasRoot True empty False
· SpecialAttack2 len 2.167s fps 30 frames 65 loop False legacy False hasMotion True hasRoot True empty False
· Stunned len 2.000s fps 30 frames 60 loop True legacy False hasMotion True hasRoot True empty False
· CastspellC_wing len 2.667s fps 24 frames 64 loop False legacy False hasMotion False hasRoot True empty False
· LyingFront len 1.333s fps 30 frames 40 loop True legacy False hasMotion False hasRoot True empty False
· LyingFront_WakeUp len 0.667s fps 30 frames 20 loop False legacy False hasMotion False hasRoot True empty False
· Face_SmileA len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_Ang1 len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_SmileB len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_Ang3 len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_Ang2 len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_SadA len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_SadB len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_Surprise len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
· Face_EyeClose len 1.633s fps 30 frames 49 loop False legacy False hasMotion False hasRoot False empty False
layers = 2
layer[0] Base Layer · states 17 · default=idle
hit|LightHit|hash 1523721793|full -62788552
die|death_F3441|hash 964231127|full -1611975250
idle|Idle|hash -601574123|full -178524345
run|Run|hash 1349952704|full -152736071
casting|CastspellB_wing|hash -786711984|full 1053661652
skill2|RangeAttack1|hash -722835594|full -673593035
attack|Atk2_wing|hash 1203776827|full 1156809592
attack2|Atk_A1_wing|hash -1415997692|full 1146074240
dodge|DashForward|hash -1854944626|full 337872140
land|Land|hash -1476340264|full -2122346614
skill3|RangeAttack2|hash -1544726560|full -1595999837
skill4|SpecialAttack1|hash 1032438339|full 1052404736
skill5|SpecialAttack2|hash 1250882261|full 1237146774
stun|Stunned|hash -210448114|full -636777636
casting_end|CastspellC_wing|hash 380729335|full -1453968558
lying|LyingFront|hash -478560198|full 1714786232
getup|LyingFront_WakeUp|hash -1333958798|full 892414192
layer[1] Emotion · states 11 · default=New State
New State|-|hash -499022955|full 833042322
SmileA (Emotion)|Face_SmileA|hash 371072321|full -1047046492
ANG1 (Emotion) 0|Face_Ang1|hash 1042426016|full -374643899
Indifferent (Emotion)|-|hash 1209094866|full -995248490
SmileB (Emotion)|Face_SmileB|hash -1879278784|full 1484075173
ANG3 (Emotion)|Face_Ang3|hash 199512751|full 749628860
ANG2 (Emotion)|Face_Ang2|hash -898986641|full -316624260
SadA (Emotion)|Face_SadA|hash -774383980|full -157423225
SadB (Emotion)|Face_SadB|hash 1211478165|full 1870630790
Surprise (Emotion)|Face_Surprise|hash -1812836920|full 1375012336
EyeClose (Emotion)|Face_EyeClose|hash -302154122|full 804927054
SkinnedMeshRenderer = 11 · MeshRenderer = 2
삼각형 합계 = 16645 · 머티리얼 10종
Ai_hair [Toon/Toon]
Ai_cloth [Toon/Toon]
Ai_skin [Toon/Toon]
bikini10 [Toon/Toon]
Ai_eyeline [Toon/Toon]
Ai_eyeL [Unlit/Transparent]
Ai_eyeR [Unlit/Transparent]
Ai_face [Toon/Toon]
Ai_Lash [Toon/Toon]
cheek [Unlit/Transparent]
bones[0] = 41
transform 노드 = 223
root localScale = (1.0000, 1.0000, 1.0000)
root comp: UnityEngine.Transform
root comp: UnityEngine.Animator
root comp: WizardActor
root comp: UnityEngine.AI.NavMeshAgent
root comp: UnityEngine.Rigidbody
root comp: UnityEngine.CapsuleCollider
root comp: SkinnedMeshAfterImage
바운드(스케일 1) center (0.0000, 0.7372, -0.0195) size (1.3047, 1.7017, 1.0770) · 높이 1.7017 m
바운드(스케일 0.7) 높이 1.1912 m · 폭 0.9133 m · 깊이 0.7539 m
== ② 프리팹 Assets/Res_Addr/Mobs/Mob/Batty_A.prefab ==
Animator = Batty_A
avatar = BattyAvatar · isHuman=False · isValid=True
applyRootMotion = False · cullingMode = CullUpdateTransforms
controller = Batty_Animations_Check (Assets/ResWork/Suriyun/Monster Pack Darkness/Animations/Batty/Batty_Animations_Check.controller)
clips = 8
· Move len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False
· Idle len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False
· Attack len 0.583s fps 24 frames 14 loop True legacy False hasMotion False hasRoot False empty False
· Damage len 0.583s fps 24 frames 14 loop True legacy False hasMotion False hasRoot False empty False
· Die len 1.000s fps 24 frames 24 loop False legacy False hasMotion False hasRoot False empty False
· stun len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False
· casting len 0.958s fps 24 frames 23 loop True legacy False hasMotion False hasRoot False empty False
· casting_end len 0.583s fps 24 frames 14 loop True legacy False hasMotion False hasRoot False empty False
layers = 1
layer[0] Base Layer · states 8 · default=idle
run|Move|hash 1349952704|full -152736071
idle|Idle|hash -601574123|full -178524345
attack|Attack|hash 1203776827|full 1156809592
hit|Damage|hash 1523721793|full -62788552
die|Die|hash 964231127|full -1611975250
stun|stun|hash -210448114|full -636777636
casting|casting|hash -786711984|full 1053661652
casting_end|casting_end|hash 380729335|full -1453968558
SkinnedMeshRenderer = 1 · MeshRenderer = 0
삼각형 합계 = 896 · 머티리얼 1종
T_Batty_A [MK/Toon/URP/Standard/Simple]
bones[0] = 8
transform 노드 = 11
root localScale = (1.5000, 1.5000, 1.5000)
root comp: UnityEngine.Transform
root comp: UnityEngine.Animator
root comp: MobActor
root comp: UnityEngine.BoxCollider
root comp: NoSeeLongDMob
root comp: UnityEngine.AI.NavMeshAgent
root comp: UnityEngine.Rigidbody
바운드(스케일 1) center (0.0000, 0.3453, -0.0006) size (0.9355, 0.5372, 0.3566) · 높이 0.5372 m
바운드(스케일 0.7) 높이 0.2507 m · 폭 0.4366 m · 깊이 0.1664 m
== ③ 캐릭터 프리팹 수 (확장 견적 근거) ==
Res_Addr/PC 프리팹 = 46
Res_Addr/Mobs 프리팹 = 299
그중 Animator+Controller 보유 = 299 · 서로 다른 컨트롤러 79종
== ④ 814c Toon 머티리얼 ==
Assets/WL/Look/Env/Materials = 258개
'Ai_*' → 6개: Ai_cloth_Toon.mat, Ai_eyeline_Toon.mat, Ai_face_Toon.mat, Ai_hair_Toon.mat, Ai_Lash_Toon.mat, Ai_skin_Toon.mat
'Batty*' → 0개:
WLEnvLookSettings = 있음

View File

@ -0,0 +1,38 @@
WL-814k 실측 ② · 2026-09-12 18:06:23
== 방향광 (Res_Addr/Map/WL_Nature.prefab + Ingame 씬) ==
[map] Sun type=Directional color=RGBA(1.000, 0.920, 0.703, 1.000) intensity=1.2 shadows=Soft euler=(26.40, 238.60, 0.00)
[map] Sun_Ambient type=Directional color=RGBA(0.596, 0.703, 0.839, 1.000) intensity=0.3 shadows=None euler=(36.10, 116.10, 0.00)
Assets/Scenes/Ingame.unity · Light(dir) 블록 없음 · 파일 21830 B
Assets/Scenes/Title.unity · Light(dir) 블록 있음 · 파일 54278 B
RenderSettings.ambientMode(현재 씬) = Skybox · ambientLight = RGBA(0.212, 0.227, 0.259, 1.000) · ambientIntensity = 1 · sun = null
== Toon 매칭 Assets/Res_Addr/PC/Ai01.prefab ==
Ai_hair [Toon/Toon] → Toon 있음
Ai_cloth [Toon/Toon] → Toon 있음
Ai_skin [Toon/Toon] → Toon 있음
bikini10 [Toon/Toon] → Toon 있음
Ai_eyeline [Toon/Toon] → Toon 있음
cheek [Unlit/Transparent] → Toon 없음
Ai_eyeL [Unlit/Transparent] → Toon 없음
Ai_eyeR [Unlit/Transparent] → Toon 없음
Ai_face [Toon/Toon] → Toon 있음
Ai_Lash [Toon/Toon] → Toon 있음
== Toon 매칭 Assets/Res_Addr/Mobs/Mob/Batty_A.prefab ==
T_Batty_A [MK/Toon/URP/Standard/Simple] → Toon 있음
== 샘플링 경로 (AnimationMode.SampleAnimationClip) ==
Ai01 · clip Idle · probe joint31 · Δpos 0.0000 · Δrot 0.00° → 변화 없음(확인필요)
Batty_A · clip Attack · probe wing_l1 · Δpos 0.0000 · Δrot 49.40° → 샘플링 OK
== 렌더 파이프라인 ==
defaultRenderPipeline = URP-Performant (Assets/Settings/URP-Performant.asset)
currentRenderPipeline = URP-Balanced
QualitySettings.renderPipeline = URP-Balanced
SystemInfo.graphicsDeviceType = Direct3D11 · supportsComputeShaders = True
Application.isBatchMode = True
Shader.Find("Sprites/Default") = OK
Shader.Find("Unlit/Transparent") = OK
Shader.Find("Universal Render Pipeline/Unlit") = OK
Shader.Find("Universal Render Pipeline/2D/Sprite-Unlit-Default") = OK

View File

@ -0,0 +1,15 @@
WL-814k 실측 ③ 샘플링 · 2026-09-12 18:07:00
== Assets/Res_Addr/PC/Ai01.prefab ==
Idle len 2.00s · AnimationMode Δbounds 2.3995 m · SampleAnimation Δbounds 2.3995 m
Run len 0.53s · AnimationMode Δbounds 2.7951 m · SampleAnimation Δbounds 4.2027 m
Atk2_wing len 1.50s · AnimationMode Δbounds 2.6478 m · SampleAnimation Δbounds 3.8505 m
LightHit len 1.00s · AnimationMode Δbounds 3.1135 m · SampleAnimation Δbounds 3.1256 m
death_F3441 len 2.10s · AnimationMode Δbounds 2.7651 m · SampleAnimation Δbounds 5.0011 m
== Assets/Res_Addr/Mobs/Mob/Batty_A.prefab ==
Idle len 0.96s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m
Move len 0.96s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m
Attack len 0.58s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m
Damage len 0.58s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m
Die len 1.00s · AnimationMode Δbounds 1.1362 m · SampleAnimation Δbounds 1.1362 m

View File

@ -0,0 +1,205 @@
// WL-814k 실측 조사 (게임 코드 아님 · 에디트 모드 전용 · 에셋 수정 0 · 읽기만)
// run: unity command run_script --file AgentScripts/WL814k_Survey.cs --entry WL814k_Survey.Run
// 🔴 원본 프리팹/머티리얼/애니메이션 수정 0. AssetDatabase.SaveAssets 호출 0.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL814k_Survey
{
const string kTxt = "AgentScripts/WL814k_SURVEY.txt";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
L("WL-814k 실측 조사 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " · Unity " + Application.unityVersion);
try
{
Cam();
Prefab("Assets/Res_Addr/PC/Ai01.prefab");
Prefab("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab");
Counts();
ToonMats();
}
catch (Exception ex) { L("EXCEPTION " + ex); }
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
// ── ① 카메라 각 (811o 수식 그대로 · 814d 프리셋 값) ──────────────────────
static void Cam()
{
L("");
L("== ① 카메라 각 (WLLookModeSettings 프리셋 실측) ==");
var so = Resources.Load<WL.Look.Toggle.WLLookModeSettings>("WL/WLLookModeSettings");
if (so == null) { L(" WLLookModeSettings 없음"); return; }
for (int i = 0; i < so.presets.Length; i++)
{
var p = so.presets[i];
float down = WL.CameraFraming.CameraFramingOverride.DownAngleDeg(p.cameraHeight, p.distance, p.distanceUp);
float above = WL.CameraFraming.CameraFramingOverride.CamHeightAbove(p.cameraHeight, p.distance, p.distanceUp);
float horiz = WL.CameraFraming.CameraFramingOverride.CamHorizontal(p.cameraHeight, p.distance);
L(string.Format(" [{0}] {1} pixelRig={2} h={3} d={4} up={5} ortho={6} pxH={7} → 내림각 {8:0.000}° · 높이 {9:0.000} m · 수평 {10:0.000} m",
i, p.name, p.pixelRig ? 1 : 0, p.cameraHeight, p.distance, p.distanceUp, p.orthoSize, p.pixelHeight, down, above, horiz));
}
// 도트A 프리셋 기준 캐릭터 픽셀
var d = so.presets.Length > 1 ? so.presets[1] : null;
if (d != null)
L(string.Format(" 도트A: orthoSize {0} · pixelHeight {1} → 월드 1 m = {2:0.000} px · 픽셀 1개 = {3:0.00000} m",
d.orthoSize, d.pixelHeight, d.pixelHeight / (2f * d.orthoSize), (2f * d.orthoSize) / d.pixelHeight));
}
// ── ② 프리팹 전수 ────────────────────────────────────────────────────────
static void Prefab(string path)
{
L("");
L("== ② 프리팹 " + path + " ==");
var go = AssetDatabase.LoadAssetAtPath<GameObject>(path);
if (go == null) { L(" 로드 실패"); return; }
var anim = go.GetComponentInChildren<Animator>(true);
L(" Animator = " + (anim == null ? "없음" : anim.gameObject.name));
if (anim != null)
{
L(" avatar = " + (anim.avatar == null ? "null" : anim.avatar.name + " · isHuman=" + anim.avatar.isHuman + " · isValid=" + anim.avatar.isValid));
L(" applyRootMotion = " + anim.applyRootMotion + " · cullingMode = " + anim.cullingMode);
var rac = anim.runtimeAnimatorController;
L(" controller = " + (rac == null ? "null" : rac.name + " (" + AssetDatabase.GetAssetPath(rac) + ")"));
if (rac != null)
{
var clips = rac.animationClips;
L(" clips = " + clips.Length);
var seen = new HashSet<string>();
foreach (var c in clips)
{
if (c == null || !seen.Add(c.name)) continue;
L(string.Format(" · {0,-28} len {1,6:0.000}s fps {2,5:0.#} frames {3,4} loop {4} legacy {5} hasMotion {6} hasRoot {7} empty {8}",
c.name, c.length, c.frameRate, Mathf.RoundToInt(c.length * c.frameRate), c.isLooping, c.legacy, c.hasMotionCurves, c.hasRootCurves, c.empty));
}
var ac = rac as UnityEditor.Animations.AnimatorController;
if (ac != null)
{
L(" layers = " + ac.layers.Length);
for (int li = 0; li < ac.layers.Length; li++)
{
var lay = ac.layers[li];
var sm = lay.stateMachine;
if (sm == null) { L(" layer[" + li + "] " + lay.name + " (stateMachine null)"); continue; }
var names = new List<string>();
foreach (var st in sm.states)
names.Add(st.state.name + "|" + (st.state.motion == null ? "-" : st.state.motion.name) + "|hash " + Animator.StringToHash(st.state.name)
+ "|full " + Animator.StringToHash(lay.name + "." + st.state.name));
L(" layer[" + li + "] " + lay.name + " · states " + names.Count + " · default=" + (sm.defaultState == null ? "-" : sm.defaultState.name));
foreach (var n in names) L(" " + n);
}
}
}
}
var smrs = go.GetComponentsInChildren<SkinnedMeshRenderer>(true);
var mrs = go.GetComponentsInChildren<MeshRenderer>(true);
L(" SkinnedMeshRenderer = " + smrs.Length + " · MeshRenderer = " + mrs.Length);
int tri = 0; var mats = new HashSet<string>();
foreach (var r in smrs)
{
if (r.sharedMesh != null) tri += r.sharedMesh.triangles.Length / 3;
foreach (var m in r.sharedMaterials) if (m != null) mats.Add(m.name + " [" + (m.shader == null ? "-" : m.shader.name) + "]");
}
foreach (var r in mrs)
{
var mf = r.GetComponent<MeshFilter>();
if (mf != null && mf.sharedMesh != null) tri += mf.sharedMesh.triangles.Length / 3;
foreach (var m in r.sharedMaterials) if (m != null) mats.Add(m.name + " [" + (m.shader == null ? "-" : m.shader.name) + "]");
}
L(" 삼각형 합계 = " + tri + " · 머티리얼 " + mats.Count + "종");
foreach (var m in mats) L(" " + m);
// 본 수
if (smrs.Length > 0 && smrs[0].bones != null) L(" bones[0] = " + smrs[0].bones.Length);
var allT = go.GetComponentsInChildren<Transform>(true);
L(" transform 노드 = " + allT.Length);
// 스케일·ClassConfig
L(" root localScale = " + go.transform.localScale.ToString("F4"));
foreach (var c in go.GetComponents<Component>())
if (c != null) L(" root comp: " + c.GetType().FullName);
// 인스턴스를 임시로 만들어 바운드 실측(씬 저장 0)
var inst = (GameObject)PrefabUtility.InstantiatePrefab(go);
try
{
inst.transform.position = Vector3.zero;
inst.transform.rotation = Quaternion.identity;
var b = WorldBounds(inst);
L(string.Format(" 바운드(스케일 1) center {0} size {1} · 높이 {2:0.0000} m", b.center.ToString("F4"), b.size.ToString("F4"), b.size.y));
inst.transform.localScale = Vector3.one * 0.7f;
var b7 = WorldBounds(inst);
L(string.Format(" 바운드(스케일 0.7) 높이 {0:0.0000} m · 폭 {1:0.0000} m · 깊이 {2:0.0000} m", b7.size.y, b7.size.x, b7.size.z));
}
finally { UnityEngine.Object.DestroyImmediate(inst); }
}
static Bounds WorldBounds(GameObject root)
{
var rs = root.GetComponentsInChildren<Renderer>(true);
bool any = false; var b = new Bounds();
foreach (var r in rs)
{
if (r == null || r is ParticleSystemRenderer) continue;
if (!any) { b = r.bounds; any = true; }
else b.Encapsulate(r.bounds);
}
return b;
}
// ── ③ 전 캐릭터 수 (확장 견적용) ────────────────────────────────────────
static void Counts()
{
L("");
L("== ③ 캐릭터 프리팹 수 (확장 견적 근거) ==");
var pc = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/PC" });
var mob = AssetDatabase.FindAssets("t:Prefab", new[] { "Assets/Res_Addr/Mobs" });
L(" Res_Addr/PC 프리팹 = " + pc.Length);
L(" Res_Addr/Mobs 프리팹 = " + mob.Length);
int mobWithAnim = 0; var ctrls = new HashSet<string>();
foreach (var g in mob)
{
var p = AssetDatabase.GUIDToAssetPath(g);
var go = AssetDatabase.LoadAssetAtPath<GameObject>(p);
if (go == null) continue;
var a = go.GetComponentInChildren<Animator>(true);
if (a != null && a.runtimeAnimatorController != null) { mobWithAnim++; ctrls.Add(a.runtimeAnimatorController.name); }
}
L(" 그중 Animator+Controller 보유 = " + mobWithAnim + " · 서로 다른 컨트롤러 " + ctrls.Count + "종");
}
// ── ④ Toon 머티리얼 (814c) ──────────────────────────────────────────────
static void ToonMats()
{
L("");
L("== ④ 814c Toon 머티리얼 ==");
string dir = "Assets/WL/Look/Env/Materials";
var all = AssetDatabase.FindAssets("t:Material", new[] { dir });
L(" " + dir + " = " + all.Length + "개");
foreach (var key in new[] { "Ai_", "Batty" })
{
var hits = new List<string>();
foreach (var g in all)
{
var p = AssetDatabase.GUIDToAssetPath(g);
if (Path.GetFileName(p).StartsWith(key, StringComparison.OrdinalIgnoreCase)) hits.Add(Path.GetFileName(p));
}
L(" '" + key + "*' → " + hits.Count + "개: " + string.Join(", ", hits.ToArray()));
}
var envSo = Resources.Load<ScriptableObject>("WL/WLEnvLookSettings");
L(" WLEnvLookSettings = " + (envSo == null ? "없음" : "있음"));
}
}

View File

@ -0,0 +1,133 @@
// WL-814k 실측 조사 ② — 조명 · Toon 머티리얼 매칭 · 샘플링 경로 (읽기만)
// run: unity command run_script --file AgentScripts/WL814k_Survey2.cs --entry WL814k_Survey2.Run
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL814k_Survey2
{
const string kTxt = "AgentScripts/WL814k_SURVEY2.txt";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
L("WL-814k 실측 ② · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
try
{
Lights();
ToonMatch("Assets/Res_Addr/PC/Ai01.prefab");
ToonMatch("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab");
SamplePath();
Srp();
}
catch (Exception ex) { L("EXCEPTION " + ex); }
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
// 게임 맵의 방향광 실측
static void Lights()
{
L("");
L("== 방향광 (Res_Addr/Map/WL_Nature.prefab + Ingame 씬) ==");
var map = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/Res_Addr/Map/WL_Nature.prefab");
if (map != null)
{
foreach (var lt in map.GetComponentsInChildren<Light>(true))
L(string.Format(" [map] {0} type={1} color={2} intensity={3} shadows={4} euler={5}",
lt.name, lt.type, lt.color, lt.intensity, lt.shadows, lt.transform.eulerAngles.ToString("F2")));
}
else L(" WL_Nature.prefab 없음");
foreach (var sc in new[] { "Assets/Scenes/Ingame.unity", "Assets/Scenes/Title.unity" })
{
if (!File.Exists(sc)) { L(" " + sc + " 없음"); continue; }
var txt = File.ReadAllText(sc);
int i = txt.IndexOf("m_Type: 1");
L(" " + sc + " · Light(dir) 블록 " + (i >= 0 ? "있음" : "없음") + " · 파일 " + new FileInfo(sc).Length + " B");
}
L(" RenderSettings.ambientMode(현재 씬) = " + RenderSettings.ambientMode + " · ambientLight = " + RenderSettings.ambientLight
+ " · ambientIntensity = " + RenderSettings.ambientIntensity + " · sun = " + (RenderSettings.sun == null ? "null" : RenderSettings.sun.name));
}
// 캐릭터 머티리얼 → *_Toon.mat 존재 여부
static void ToonMatch(string prefab)
{
L("");
L("== Toon 매칭 " + prefab + " ==");
var go = AssetDatabase.LoadAssetAtPath<GameObject>(prefab);
if (go == null) { L(" 로드 실패"); return; }
var seen = new HashSet<string>();
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
foreach (var m in r.sharedMaterials)
{
if (m == null || !seen.Add(m.name)) continue;
string toon = "Assets/WL/Look/Env/Materials/" + m.name + "_Toon.mat";
var t = AssetDatabase.LoadAssetAtPath<Material>(toon);
L(" " + m.name.PadRight(16) + " [" + (m.shader == null ? "-" : m.shader.name) + "] → " + (t != null ? "Toon 있음" : "Toon 없음"));
}
}
// 샘플링 경로 확인 — Generic/Humanoid 둘 다 AnimationMode 로 찍히는가
static void SamplePath()
{
L("");
L("== 샘플링 경로 (AnimationMode.SampleAnimationClip) ==");
foreach (var pair in new[] {
new[]{ "Assets/Res_Addr/PC/Ai01.prefab", "Idle" },
new[]{ "Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", "Attack" } })
{
var go = AssetDatabase.LoadAssetAtPath<GameObject>(pair[0]);
if (go == null) { L(" " + pair[0] + " 로드 실패"); continue; }
var inst = (GameObject)PrefabUtility.InstantiatePrefab(go);
try
{
var anim = inst.GetComponentInChildren<Animator>(true);
AnimationClip clip = null;
foreach (var c in anim.runtimeAnimatorController.animationClips) if (c.name == pair[1]) { clip = c; break; }
if (clip == null) { L(" 클립 " + pair[1] + " 없음"); continue; }
// 대표 본의 t=0 / t=len/2 포즈 차이를 잰다
var bones = inst.GetComponentsInChildren<Transform>(true);
Transform probe = bones.Length > 3 ? bones[bones.Length / 2] : inst.transform;
AnimationMode.StartAnimationMode();
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, clip, 0f);
AnimationMode.EndSampling();
Vector3 p0 = probe.localPosition; Quaternion r0 = probe.localRotation;
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, clip, clip.length * 0.5f);
AnimationMode.EndSampling();
Vector3 p1 = probe.localPosition; Quaternion r1 = probe.localRotation;
AnimationMode.StopAnimationMode();
L(string.Format(" {0} · clip {1} · probe {2} · Δpos {3:0.0000} · Δrot {4:0.00}° → {5}",
Path.GetFileNameWithoutExtension(pair[0]), clip.name, probe.name,
Vector3.Distance(p0, p1), Quaternion.Angle(r0, r1),
(Vector3.Distance(p0, p1) > 0.0001f || Quaternion.Angle(r0, r1) > 0.05f) ? "샘플링 OK" : "변화 없음(확인필요)"));
}
catch (Exception ex) { L(" EXCEPTION " + ex.Message); if (AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); }
finally { UnityEngine.Object.DestroyImmediate(inst); }
}
}
static void Srp()
{
L("");
L("== 렌더 파이프라인 ==");
var rp = UnityEngine.Rendering.GraphicsSettings.defaultRenderPipeline;
L(" defaultRenderPipeline = " + (rp == null ? "null(빌트인)" : rp.name + " (" + AssetDatabase.GetAssetPath(rp) + ")"));
L(" currentRenderPipeline = " + (UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline == null ? "null" : UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline.name));
L(" QualitySettings.renderPipeline = " + (QualitySettings.renderPipeline == null ? "null" : QualitySettings.renderPipeline.name));
L(" SystemInfo.graphicsDeviceType = " + SystemInfo.graphicsDeviceType + " · supportsComputeShaders = " + SystemInfo.supportsComputeShaders);
L(" Application.isBatchMode = " + Application.isBatchMode);
// Unlit 투명 셰이더 후보
foreach (var s in new[] { "Sprites/Default", "Unlit/Transparent", "Universal Render Pipeline/Unlit", "Universal Render Pipeline/2D/Sprite-Unlit-Default" })
L(" Shader.Find(\"" + s + "\") = " + (Shader.Find(s) != null ? "OK" : "없음"));
}
}

View File

@ -0,0 +1,87 @@
// WL-814k 실측 조사 ③ — Humanoid/Generic 샘플링 경로 확정 (읽기만)
// run: unity command run_script --file AgentScripts/WL814k_Survey3.cs --entry WL814k_Survey3.Run
using System;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
public static class WL814k_Survey3
{
const string kTxt = "AgentScripts/WL814k_SURVEY3.txt";
static StringBuilder s_sb;
static void L(string s) { s_sb.AppendLine(s); Debug.Log("[WL814k] " + s); }
public static void Run()
{
s_sb = new StringBuilder();
L("WL-814k 실측 ③ 샘플링 · " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
try
{
Test("Assets/Res_Addr/PC/Ai01.prefab", new[] { "Idle", "Run", "Atk2_wing", "LightHit", "death_F3441" });
Test("Assets/Res_Addr/Mobs/Mob/Batty_A.prefab", new[] { "Idle", "Move", "Attack", "Damage", "Die" });
}
catch (Exception ex) { L("EXCEPTION " + ex); }
finally { if (AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); }
File.WriteAllText(kTxt, s_sb.ToString(), new UTF8Encoding(false));
Debug.Log("[WL814k] → " + kTxt);
}
static void Test(string prefab, string[] clipNames)
{
L("");
L("== " + prefab + " ==");
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefab);
var inst = (GameObject)PrefabUtility.InstantiatePrefab(src);
inst.transform.position = Vector3.zero; inst.transform.rotation = Quaternion.identity;
try
{
var anim = inst.GetComponentInChildren<Animator>(true);
var rac = anim.runtimeAnimatorController;
foreach (var name in clipNames)
{
AnimationClip clip = null;
foreach (var c in rac.animationClips) if (c.name == name) { clip = c; break; }
if (clip == null) { L(" " + name + " → 클립 없음"); continue; }
// ⓐ AnimationMode 경로
float aSpread = Spread(inst, clip, true);
// ⓑ clip.SampleAnimation 경로
float bSpread = Spread(inst, clip, false);
L(string.Format(" {0,-14} len {1,5:0.00}s · AnimationMode Δbounds {2:0.0000} m · SampleAnimation Δbounds {3:0.0000} m",
name, clip.length, aSpread, bSpread));
}
}
finally { if (AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode(); UnityEngine.Object.DestroyImmediate(inst); }
}
// 클립을 8등분해 찍고, 전체 렌더러 바운드 중심의 최대 이동량(= 실제로 포즈가 바뀌었는가)
static float Spread(GameObject inst, AnimationClip clip, bool useAnimationMode)
{
var rs = inst.GetComponentsInChildren<SkinnedMeshRenderer>(true);
Vector3 min = Vector3.positiveInfinity, max = Vector3.negativeInfinity;
for (int i = 0; i < 8; i++)
{
float t = clip.length * i / 8f;
if (useAnimationMode)
{
AnimationMode.StartAnimationMode();
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, clip, t);
AnimationMode.EndSampling();
AnimationMode.StopAnimationMode();
}
else
{
clip.SampleAnimation(inst, t);
}
Bounds b = new Bounds(); bool any = false;
foreach (var r in rs) { if (!any) { b = r.bounds; any = true; } else b.Encapsulate(r.bounds); }
if (!any) continue;
min = Vector3.Min(min, b.min); max = Vector3.Max(max, b.max);
}
if (float.IsInfinity(min.x)) return -1f;
return (max - min).magnitude;
}
}

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3b1e4280b824eb84c9918c582b2df1b1
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3f60da21b8811ca438a64ff9cf16bd2e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@ -0,0 +1,181 @@
fileFormatVersion: 2
guid: d5e256fc5c31a4943b1afb9820681cec
TextureImporter:
internalIDToNameTable:
- first:
213: 5980685433440855488
second: Ai01_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 0
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 1024
resizeAlgorithm: 0
textureFormat: 4
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 1024
resizeAlgorithm: 0
textureFormat: 4
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: Ai01_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 2048
height: 1536
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: 0c56895f6b9aff250800000000000000
internalID: 5980685433440855488
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,86 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7485ff60a4b963544b28a1b345440fb9, type: 3}
m_Name: Ai01_SpriteSet
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteSet
sourcePrefab: Assets/Res_Addr/PC/Ai01.prefab
actorNamePrefix: Ai01
bakeStamp: "2026-09-12 18:38 \xB7 WL-814k"
sheet: {fileID: 2800000, guid: d5e256fc5c31a4943b1afb9820681cec, type: 3}
frameWidth: 56
frameHeight: 56
columns: 16
rows: 12
dirCount: 8
worldPerPixel: 0.028333334
bakeScale: 0.7
footPixelX: 28.5
footPixelY: 19.5
pitchDeg: 43.60939
actions:
- key: idle
stateNames:
- idle
- land
- getup
clipName: Idle
startIndex: 0
frameCount: 4
fps: 2
loop: 1
clipLength: 2
- key: run
stateNames:
- run
- dodge
clipName: Run
startIndex: 32
frameCount: 6
fps: 11.249999
loop: 1
clipLength: 0.53333336
- key: attack
stateNames:
- attack
- attack2
- skill2
- skill3
- skill4
- skill5
- casting
- casting_end
clipName: Atk2_wing
startIndex: 80
frameCount: 6
fps: 4
loop: 1
clipLength: 1.5
- key: hit
stateNames:
- hit
- stun
clipName: LightHit
startIndex: 128
frameCount: 2
fps: 2
loop: 1
clipLength: 1
- key: die
stateNames:
- die
- lying
clipName: death_F3441
startIndex: 144
frameCount: 6
fps: 2.8571427
loop: 0
clipLength: 2.1000001

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 9afd69d5485f7584b8f40e7673bca04e
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -0,0 +1,181 @@
fileFormatVersion: 2
guid: 1437ff6eefe10ba4cb73442f8f923071
TextureImporter:
internalIDToNameTable:
- first:
213: -3965868031581062660
second: Batty_A_0
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 0
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 1024
resizeAlgorithm: 0
textureFormat: 4
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Server
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Android
maxTextureSize: 1024
resizeAlgorithm: 0
textureFormat: 4
textureCompression: 0
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 1
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: iOS
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: Batty_A_0
rect:
serializedVersion: 2
x: 0
y: 0
width: 768
height: 576
alignment: 0
pivot: {x: 0, y: 0}
border: {x: 0, y: 0, z: 0, w: 0}
customData:
outline: []
physicsShape: []
tessellationDetail: -1
bones: []
spriteID: cf9788f890866f8c0800000000000000
internalID: -3965868031581062660
vertices: []
indices:
edges: []
weights: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,77 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7485ff60a4b963544b28a1b345440fb9, type: 3}
m_Name: Batty_A_SpriteSet
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteSet
sourcePrefab: Assets/Res_Addr/Mobs/Mob/Batty_A.prefab
actorNamePrefix: Batty_A
bakeStamp: "2026-09-12 18:38 \xB7 WL-814k"
sheet: {fileID: 2800000, guid: 1437ff6eefe10ba4cb73442f8f923071, type: 3}
frameWidth: 48
frameHeight: 48
columns: 16
rows: 12
dirCount: 8
worldPerPixel: 0.028333334
bakeScale: 1.5
footPixelX: 24.5
footPixelY: 14.5
pitchDeg: 43.60939
actions:
- key: idle
stateNames:
- idle
clipName: Idle
startIndex: 0
frameCount: 4
fps: 4.173913
loop: 1
clipLength: 0.9583333
- key: run
stateNames:
- run
clipName: Move
startIndex: 32
frameCount: 6
fps: 6.2608695
loop: 1
clipLength: 0.9583334
- key: attack
stateNames:
- attack
- casting
- casting_end
clipName: Attack
startIndex: 80
frameCount: 6
fps: 10.285716
loop: 1
clipLength: 0.58333325
- key: hit
stateNames:
- hit
- stun
clipName: Damage
startIndex: 128
frameCount: 2
fps: 3.4285705
loop: 1
clipLength: 0.5833335
- key: die
stateNames:
- die
clipName: Die
startIndex: 144
frameCount: 6
fps: 6
loop: 0
clipLength: 1

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 02557ce4e57225d4db041caa2e6e44d7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 518e04114c590e847b8493855c603c59
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,696 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSpriteBaker.cs — 3D 캐릭터 프리팹 → 「도트 스프라이트 시트」 굽기 (에디터 전용 · WL-814k · #814)
//
// PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」
//
// ■ 🔴 이 툴은 **프리팹 무관**하다 — 대상 프리팹은 인자로 받는다. 캐릭터 이름이 코드에 0개.
// (PD 가 캐릭터 에셋 교체를 검토 중 → 특정 프리팹에 하드코딩하면 툴이 통째로 버려진다.)
//
// ■ 절차
// ① 임시 씬(Empty · Single)에 프리팹을 인스턴스화하고 **프리팹 링크를 끊는다**(Unpack).
// → 이후 무슨 짓을 해도 원본 프리팹 에셋에 한 글자도 안 쓴다. 씬 저장 0.
// ② 814c Toon 머티리얼(`<이름>_Toon.mat`)이 있으면 **인스턴스에만** 갈아끼운다(원본 .mat 무변경).
// ③ 인게임과 같은 **내림각**(811o 궤도 수식 · 실측 43.609°)의 **직교 카메라**를 만들고,
// 픽셀 1개의 월드 크기를 도트 프리셋과 같게 맞춘다(= 스프라이트 1px 이 화면 1px).
// ④ `AnimationMode.SampleAnimationClip` 로 포즈를 고정(Generic·Humanoid 둘 다 실측 OK) →
// 방향(피벗 Y 회전) × 동작 × 프레임마다 **2번** 렌더한다(배경 검정 / 배경 흰색).
// 두 장의 차이로 **알파를 정확히 복원**한다 — URP 가 불투명 패스에서 알파를 어떻게 쓰든 안 흔들린다.
// ⑤ 도트화를 **굽는 시점에 강제**: 알파 이진화(0.5) → 팔레트 양자화(채널당 6단계) → 1px 검정 외곽선.
// ⑥ 전 프레임의 실루엣을 합쳐 **꽉 차는 프레임 크기**를 8의 배수로 자동 산출하고, 그 크기로 잘라
// 시트 1장(PNG)에 격자로 붙인다 + 메타 SO(WLSpriteSet)를 만든다.
//
// ■ 왜 서브 스프라이트(spriteImportMode Multiple)가 아니라 「단일 시트 + UV 산술」인가
// 런타임 빌보드는 `Sprite` 객체가 아니라 **쿼드 메시 + MaterialPropertyBlock 의 `_BaseMap_ST`** 로 그린다.
// Multiple 로 자르면 프레임 수만큼(시범 2종 = 384개) 서브 에셋과 .meta 줄이 생기고 런타임 이득은 0이다.
// 시트 1장 + `UvRect()` 산술이면 서브 에셋 0 · 프레임 전환 시 할당 0.
//
// 🔴 금지 준수: 원본 프리팹/머티리얼/애니메이션 **쓰기 0**. `AssetDatabase.SaveAssets()` 는
// 이 툴이 만든 에셋에만 쓰고, 임포트 설정은 대상 PNG 하나에만 건다.
// ─────────────────────────────────────────────────────────────────────────────
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace WL.Look.SpriteBake.EditorTools
{
public static class WLSpriteBaker
{
public sealed class Result
{
public bool ok;
public string prefabPath = "";
public string pngPath = "";
public string setPath = "";
public int frameSize, columns, rows, sheetW, sheetH;
public int dirCount, totalFrames;
public long pngBytes;
public double seconds;
public float worldPerPixel, pitchDeg, bakeScale;
public float charPixelHeight;
public bool clipped;
public readonly List<string> notes = new List<string>();
public readonly StringBuilder log = new StringBuilder();
public string Summary
{
get
{
return string.Format("{0} · {1}×{1} px · {2}방향 × {3}프레임 · 시트 {4}×{5} · {6:N0} KB · {7:0.0}초",
Path.GetFileNameWithoutExtension(prefabPath), frameSize, dirCount, totalFrames / Mathf.Max(1, dirCount),
sheetW, sheetH, pngBytes / 1024.0, seconds);
}
}
}
// ═════════════════════════════════════════════════════════════════════
// 진입점 — 🔴 대상 프리팹은 **인자**
// ═════════════════════════════════════════════════════════════════════
public static Result Bake(string prefabPath, WLSpriteBakeSettings cfg,
string outputDirOverride = null, string actorNamePrefixOverride = null,
float rootScale = 0f)
{
var r = new Result { prefabPath = prefabPath };
var sw = System.Diagnostics.Stopwatch.StartNew();
if (cfg == null) { r.log.AppendLine("FAIL — WLSpriteBakeSettings 없음"); return r; }
var src = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
if (src == null) { r.log.AppendLine("FAIL — 프리팹 로드 실패: " + prefabPath); return r; }
string outDir = string.IsNullOrEmpty(outputDirOverride) ? cfg.outputDir : outputDirOverride;
string baseName = Path.GetFileNameWithoutExtension(prefabPath);
string prefix = string.IsNullOrEmpty(actorNamePrefixOverride) ? baseName : actorNamePrefixOverride;
if (!Application.isBatchMode && !EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
{ r.log.AppendLine("취소 — 열린 씬 저장 거부"); return r; }
Scene temp = default(Scene);
GameObject pivot = null, inst = null, camGo = null;
RenderTexture rt = null;
Texture2D readTex = null;
bool animMode = false;
try
{
temp = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
SetupWorld(cfg);
// ── ① 인스턴스 (프리팹 링크 완전 절단 → 원본 에셋 쓰기 0) ────
pivot = new GameObject("[WL814k] Pivot");
inst = (GameObject)PrefabUtility.InstantiatePrefab(src);
PrefabUtility.UnpackPrefabInstance(inst, PrefabUnpackMode.Completely, InteractionMode.AutomatedAction);
inst.transform.SetParent(pivot.transform, false);
inst.transform.localPosition = Vector3.zero;
inst.transform.localRotation = Quaternion.identity;
// 🔴 인게임에서 실제로 쓰는 크기로 굽는다(인자) — 0 이면 프리팹 자체 스케일.
// 런타임 스케일과 같게 구워야 스프라이트 1px = 화면 1px 이 유지된다.
if (rootScale > 0.0001f) inst.transform.localScale = Vector3.one * rootScale;
StripNonVisual(inst);
r.bakeScale = Mathf.Abs(inst.transform.lossyScale.y);
if (r.bakeScale < 0.0001f) r.bakeScale = 1f;
int toonHit = cfg.useToonMaterials ? SwapToonMaterials(inst, cfg, r) : 0;
r.log.AppendLine("Toon 머티리얼 교체 = " + toonHit + "슬롯 (인스턴스에만 · 원본 .mat 무변경)");
Vector3 srcLocalPos = inst.transform.localPosition;
Quaternion srcLocalRot = inst.transform.localRotation;
// ── ② 동작 수집 (이름 규약 · 프리팹 무관) ────────────────────
var anim = inst.GetComponentInChildren<Animator>(true);
if (anim == null || anim.runtimeAnimatorController == null)
{ r.log.AppendLine("FAIL — Animator/Controller 없음"); return r; }
var plan = CollectActions(anim, cfg, r);
if (plan.Count == 0) { r.log.AppendLine("FAIL — 구울 동작을 하나도 못 찾았다"); return r; }
// 🔴 샘플링 경로 — 기본은 **진짜 Animator 구동**(anim.Play + anim.Update).
// 실측(814k): AnimationMode.SampleAnimationClip 은 **루트 모션을 포즈에 얹는다**
// (Ai01 Run 이 2.0 m, death 가 3.0 m 밀려 프레임 밖으로 나갔다) — 그런데 게임의
// Animator 는 applyRootMotion=false 라 그 이동을 버린다. 즉 AnimationMode 로 구우면
// **게임에 안 나오는 그림**이 구워진다. Animator 구동은 컨트롤러·리타깃까지 게임과 같다.
anim.applyRootMotion = false;
anim.cullingMode = AnimatorCullingMode.AlwaysAnimate;
anim.Rebind();
// ── ③ 카메라 ────────────────────────────────────────────────
float pitch = cfg.PitchDeg;
float wpp = cfg.WorldPerPixel;
r.pitchDeg = pitch; r.worldPerPixel = wpp;
int P = Mathf.Max(32, cfg.probeSize);
camGo = new GameObject("[WL814k] BakeCam");
var cam = camGo.AddComponent<Camera>();
cam.orthographic = true;
cam.orthographicSize = P * wpp * 0.5f;
cam.nearClipPlane = 0.01f;
cam.farClipPlane = 200f;
cam.clearFlags = CameraClearFlags.SolidColor;
cam.allowHDR = false;
cam.allowMSAA = false;
cam.useOcclusionCulling = false;
camGo.transform.rotation = Quaternion.Euler(pitch, 0f, 0f);
// 월드 원점이 (P/2, P/2) 픽셀의 **중심**에 찍히도록 반 픽셀 민다.
Vector3 aim = Vector3.zero
- camGo.transform.right * (wpp * 0.5f)
- camGo.transform.up * (wpp * 0.5f);
camGo.transform.position = aim - camGo.transform.forward * 50f;
rt = new RenderTexture(P, P, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
rt.filterMode = FilterMode.Point;
rt.antiAliasing = 1;
rt.Create();
readTex = new Texture2D(P, P, TextureFormat.RGBA32, false, false);
// ── ④ 전 프레임 렌더 (프로브 해상도) ─────────────────────────
int dirN = Mathf.Max(1, cfg.dirCount);
r.dirCount = dirN;
float step = 360f / dirN;
var frames = new List<Color32[]>(256);
int minX = int.MaxValue, maxX = int.MinValue, minY = int.MaxValue, maxY = int.MinValue;
if (cfg.sampleWithAnimationMode) { AnimationMode.StartAnimationMode(); animMode = true; }
var black = new Color32[P * P];
var white = new Color32[P * P];
for (int ai = 0; ai < plan.Count; ai++)
{
var pa = plan[ai];
for (int d = 0; d < dirN; d++)
{
pivot.transform.rotation = Quaternion.Euler(0f, d * step, 0f);
for (int f = 0; f < pa.frames; f++)
{
float nt = pa.loop
? (float)f / pa.frames
: (pa.frames > 1 ? (float)f / (pa.frames - 1) : 0f);
if (animMode)
{
AnimationMode.BeginSampling();
AnimationMode.SampleAnimationClip(inst, pa.clip, pa.clip.length * nt);
AnimationMode.EndSampling();
}
else
{
anim.Play(pa.stateHash, 0, nt);
anim.Update(0f);
}
// 제자리로(빌보드는 액터 위치에 붙으므로 스프라이트는 제자리 애니메이션이어야 한다)
inst.transform.localPosition = srcLocalPos;
inst.transform.localRotation = srcLocalRot;
RenderInto(cam, rt, readTex, Color.black, black);
RenderInto(cam, rt, readTex, Color.white, white);
var px = Compose(black, white, P, cfg);
frames.Add(px);
TightBox(px, P, ref minX, ref maxX, ref minY, ref maxY);
}
}
}
if (animMode) { AnimationMode.StopAnimationMode(); animMode = false; }
if (minX > maxX) { r.log.AppendLine("FAIL — 렌더 결과가 전부 비었다(알파 0)"); return r; }
// ── ⑤ 프레임 크기 · 발밑 픽셀 산출 ──────────────────────────
int origin = P / 2; // 월드 원점이 찍힌 픽셀 인덱스
int margin = Mathf.Max(0, cfg.marginPx);
int needW = 2 * Mathf.Max(origin - minX, maxX - origin) + 2 * margin;
int needH = (maxY - minY + 1) + 2 * margin;
int need = Mathf.Max(needW, needH);
int S = cfg.autoFrameSize
? Mathf.Clamp(RoundUp8(need), Mathf.Max(8, cfg.frameSizeMin), Mathf.Max(8, cfg.frameSizeMax))
: Mathf.Max(8, cfg.frameSize);
int below = (origin - minY) + margin;
int above = (maxY - origin) + margin + 1;
int fpxI = S / 2;
int fpyI;
if (below + above <= S) fpyI = below;
else { fpyI = Mathf.RoundToInt(S * (float)below / (below + above)); r.clipped = true; r.notes.Add("프레임 상한 초과 — 실루엣이 잘렸다(needed " + need + " px)"); }
fpyI = Mathf.Clamp(fpyI, 0, S);
r.frameSize = S;
r.charPixelHeight = (maxY - minY + 1);
r.log.AppendLine(string.Format("실루엣 실측(프로브 {0}px) — x [{1}..{2}] y [{3}..{4}] · 캐릭터 높이 {5} px · 폭 {6} px → 프레임 {7}×{7} · 발밑 픽셀 ({8}.5, {9}.5)",
P, minX, maxX, minY, maxY, maxY - minY + 1, maxX - minX + 1, S, fpxI, fpyI));
// ── ⑥ 자르기 + 외곽선 → 시트 ────────────────────────────────
int total = frames.Count;
r.totalFrames = total;
int cols = cfg.sheetColumns > 0 ? cfg.sheetColumns : Mathf.CeilToInt(Mathf.Sqrt(total));
while (cols > 1 && cols * S > Mathf.Max(S, cfg.sheetMaxWidth)) cols--;
int rows = Mathf.CeilToInt(total / (float)cols);
r.columns = cols; r.rows = rows;
r.sheetW = cols * S; r.sheetH = rows * S;
var sheet = new Color32[r.sheetW * r.sheetH]; // 기본 = 완전 투명(0,0,0,0)
for (int i = 0; i < total; i++)
{
var cropped = Crop(frames[i], P, S, origin - fpxI, origin - fpyI);
if (cfg.outline) Outline(cropped, S, cfg);
int col = i % cols, row = i / cols;
Blit(cropped, S, sheet, r.sheetW, col * S, r.sheetH - (row + 1) * S);
}
// ── ⑦ PNG + 임포트 설정 ────────────────────────────────────
if (!Directory.Exists(outDir)) Directory.CreateDirectory(outDir);
string png = (outDir.TrimEnd('/') + "/" + baseName + ".png").Replace('\\', '/');
var tex = new Texture2D(r.sheetW, r.sheetH, TextureFormat.RGBA32, false, false);
tex.SetPixels32(sheet); tex.Apply(false, false);
File.WriteAllBytes(png, tex.EncodeToPNG());
UnityEngine.Object.DestroyImmediate(tex);
AssetDatabase.ImportAsset(png, ImportAssetOptions.ForceUpdate | ImportAssetOptions.ForceSynchronousImport);
ApplyImportSettings(png, Mathf.Max(r.sheetW, r.sheetH));
r.pngPath = png;
r.pngBytes = new FileInfo(png).Length;
// ── ⑧ 메타 SO ─────────────────────────────────────────────
string setPath = (outDir.TrimEnd('/') + "/" + baseName + "_SpriteSet.asset").Replace('\\', '/');
var set = AssetDatabase.LoadAssetAtPath<WLSpriteSet>(setPath);
bool created = set == null;
if (created) set = ScriptableObject.CreateInstance<WLSpriteSet>();
set.sourcePrefab = prefabPath;
set.actorNamePrefix = prefix;
set.bakeStamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm") + " · WL-814k";
set.sheet = AssetDatabase.LoadAssetAtPath<Texture2D>(png);
set.frameWidth = S; set.frameHeight = S;
set.columns = cols; set.rows = rows;
set.dirCount = dirN;
set.worldPerPixel = wpp;
set.bakeScale = r.bakeScale;
set.footPixelX = fpxI + 0.5f;
set.footPixelY = fpyI + 0.5f;
set.pitchDeg = pitch;
var acts = new WLSpriteAction[plan.Count];
int start = 0;
for (int i = 0; i < plan.Count; i++)
{
var pa = plan[i];
acts[i] = new WLSpriteAction
{
key = pa.key,
stateNames = pa.stateNames.ToArray(),
clipName = pa.clip.name,
startIndex = start,
frameCount = pa.frames,
fps = pa.frames / Mathf.Max(0.0001f, pa.clip.length),
loop = pa.loop,
clipLength = pa.clip.length,
};
start += pa.frames * dirN;
}
set.actions = acts;
if (created) AssetDatabase.CreateAsset(set, setPath);
else EditorUtility.SetDirty(set);
AssetDatabase.SaveAssetIfDirty(set);
r.setPath = setPath;
r.ok = true;
}
catch (Exception ex)
{
r.log.AppendLine("EXCEPTION " + ex);
}
finally
{
if (animMode && AnimationMode.InAnimationMode()) AnimationMode.StopAnimationMode();
if (rt != null) { RenderTexture.active = null; rt.Release(); UnityEngine.Object.DestroyImmediate(rt); }
if (readTex != null) UnityEngine.Object.DestroyImmediate(readTex);
if (camGo != null) UnityEngine.Object.DestroyImmediate(camGo);
if (pivot != null) UnityEngine.Object.DestroyImmediate(pivot);
// 🔴 임시 씬은 저장하지 않는다. 빈 씬으로 갈아 끼워 흔적 0.
if (temp.IsValid()) EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
}
sw.Stop();
r.seconds = sw.Elapsed.TotalSeconds;
return r;
}
// ═════════════════════════════════════════════════════════════════════
// 월드(조명 · 환경광)
// ═════════════════════════════════════════════════════════════════════
static void SetupWorld(WLSpriteBakeSettings cfg)
{
RenderSettings.fog = false;
if (cfg.useSkyboxAmbient)
{
// 🔴 인게임 `Assets/Scenes/Ingame.unity` 실측 = m_AmbientMode 0(Skybox) + 기본 스카이박스.
// Flat 0.212 회색으로 구우면 게임보다 어둡게 구워진다(실측으로 확인).
RenderSettings.skybox = AssetDatabase.GetBuiltinExtraResource<Material>("Default-Skybox.mat");
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Skybox;
RenderSettings.ambientIntensity = cfg.ambientIntensity;
RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Skybox;
DynamicGI.UpdateEnvironment();
}
else
{
RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
RenderSettings.ambientLight = cfg.ambient;
RenderSettings.skybox = null;
RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Custom;
RenderSettings.customReflectionTexture = null;
}
var sun = new GameObject("[WL814k] Sun").AddComponent<Light>();
sun.type = LightType.Directional;
sun.color = cfg.sunColor;
sun.intensity = cfg.sunIntensity;
sun.shadows = LightShadows.None; // 🔴 그림자 off (시트에 그림자가 구워지면 안 된다)
sun.transform.rotation = Quaternion.Euler(cfg.sunEuler);
if (cfg.fillIntensity > 0.0001f)
{
var fill = new GameObject("[WL814k] Fill").AddComponent<Light>();
fill.type = LightType.Directional;
fill.color = cfg.fillColor;
fill.intensity = cfg.fillIntensity;
fill.shadows = LightShadows.None;
fill.transform.rotation = Quaternion.Euler(cfg.fillEuler);
}
}
/// <summary>렌더에 필요 없는 것(콜라이더·에이전트·리지드바디·파티클·게임 스크립트)을 인스턴스에서 뗀다.</summary>
static void StripNonVisual(GameObject inst)
{
foreach (var c in inst.GetComponentsInChildren<Collider>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<UnityEngine.AI.NavMeshAgent>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<UnityEngine.AI.NavMeshObstacle>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<Rigidbody>(true)) UnityEngine.Object.DestroyImmediate(c);
foreach (var c in inst.GetComponentsInChildren<ParticleSystem>(true)) UnityEngine.Object.DestroyImmediate(c.gameObject);
foreach (var mb in inst.GetComponentsInChildren<MonoBehaviour>(true))
if (mb != null) UnityEngine.Object.DestroyImmediate(mb);
foreach (var sm in inst.GetComponentsInChildren<SkinnedMeshRenderer>(true))
{
if (sm == null) continue;
sm.updateWhenOffscreen = true; // 에디트 모드에서 컬링으로 사라지지 않게
sm.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
sm.receiveShadows = false;
}
foreach (var mr in inst.GetComponentsInChildren<MeshRenderer>(true))
{
if (mr == null) continue;
mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
mr.receiveShadows = false;
}
}
/// <summary>814c Toon 머티리얼로 교체 — 🔴 인스턴스의 sharedMaterials 배열만 바꾼다(디스크 .mat 무변경).</summary>
static int SwapToonMaterials(GameObject inst, WLSpriteBakeSettings cfg, Result r)
{
int hit = 0;
var cache = new Dictionary<string, Material>();
foreach (var rend in inst.GetComponentsInChildren<Renderer>(true))
{
if (rend == null || rend is ParticleSystemRenderer) continue;
var mats = rend.sharedMaterials;
bool any = false;
for (int i = 0; i < mats.Length; i++)
{
var m = mats[i];
if (m == null) continue;
Material toon;
if (!cache.TryGetValue(m.name, out toon))
{
string p = cfg.toonMaterialDir.TrimEnd('/') + "/" + m.name + cfg.toonMaterialSuffix + ".mat";
toon = AssetDatabase.LoadAssetAtPath<Material>(p);
cache[m.name] = toon;
}
if (toon != null) { mats[i] = toon; any = true; hit++; }
}
if (any) rend.sharedMaterials = mats;
}
return hit;
}
// ═════════════════════════════════════════════════════════════════════
// 동작 수집 — 이름 규약(SO)만 본다. 캐릭터 이름 0.
// ═════════════════════════════════════════════════════════════════════
sealed class PlanAction
{
public string key;
public AnimationClip clip;
public string stateName;
public int stateHash;
public int frames;
public bool loop;
public readonly List<string> stateNames = new List<string>();
}
static List<PlanAction> CollectActions(Animator anim, WLSpriteBakeSettings cfg, Result r)
{
var list = new List<PlanAction>();
var ac = anim.runtimeAnimatorController as UnityEditor.Animations.AnimatorController;
var byState = new Dictionary<string, AnimationClip>(StringComparer.Ordinal);
var realName = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); // 후보(대소문자 무시) → 실제 상태 이름
if (ac != null && ac.layers.Length > 0 && ac.layers[0].stateMachine != null)
{
foreach (var ch in ac.layers[0].stateMachine.states)
{
var clip = FirstClip(ch.state.motion);
if (clip == null) continue;
if (!byState.ContainsKey(ch.state.name)) byState[ch.state.name] = clip;
if (!realName.ContainsKey(ch.state.name)) realName[ch.state.name] = ch.state.name;
}
}
else
{
// AnimatorOverrideController 등 — 클립 이름으로만 맞춘다
foreach (var c in anim.runtimeAnimatorController.animationClips)
if (c != null && !byState.ContainsKey(c.name)) { byState[c.name] = c; realName[c.name] = c.name; }
r.notes.Add("AnimatorController 가 아니어서 상태 이름 대신 클립 이름으로 맞췄다");
}
r.log.AppendLine("레이어0 상태 " + byState.Count + "개: " + string.Join(", ", new List<string>(byState.Keys).ToArray()));
foreach (var ba in cfg.actions)
{
if (ba == null) continue;
AnimationClip clip = null; string matched = null;
foreach (var n in ba.stateNames)
{
if (string.IsNullOrEmpty(n)) continue;
string real;
if (!realName.TryGetValue(n, out real)) continue;
if (byState.TryGetValue(real, out clip)) { matched = real; break; }
}
if (clip == null || matched == null || clip.length <= 0.0001f)
{
r.notes.Add("동작 '" + ba.key + "' — 상태를 못 찾음(후보: " + string.Join("/", ba.stateNames) + ") → 「미확인」 · 건너뜀");
continue;
}
var pa = new PlanAction { key = ba.key, clip = clip, frames = Mathf.Max(1, ba.frames), loop = ba.loop,
stateName = matched, stateHash = Animator.StringToHash(matched) };
if (ac != null && !anim.HasState(0, pa.stateHash))
{ r.notes.Add("동작 '" + ba.key + "' — 상태 '" + matched + "' 가 레이어0 에 없다 → 건너뜀"); continue; }
// 실제로 컨트롤러에 있는 상태 이름만 메타에 넣는다(런타임 해시 비교용)
pa.stateNames.Add(matched);
foreach (var n in ba.stateNames) { string real2; if (realName.TryGetValue(n, out real2) && !pa.stateNames.Contains(real2)) pa.stateNames.Add(real2); }
foreach (var n in ba.alsoMapStateNames) { string real3; if (realName.TryGetValue(n, out real3) && !pa.stateNames.Contains(real3)) pa.stateNames.Add(real3); }
list.Add(pa);
r.log.AppendLine(string.Format(" 동작 {0,-7} ← 상태 [{1}] · 클립 {2} ({3:0.000}s · {4}프레임 · loop {5})",
ba.key, string.Join("/", pa.stateNames.ToArray()), clip.name, clip.length, pa.frames, pa.loop));
}
return list;
}
static AnimationClip FirstClip(UnityEngine.Motion m)
{
var c = m as AnimationClip;
if (c != null) return c;
var bt = m as UnityEditor.Animations.BlendTree;
if (bt == null) return null;
foreach (var ch in bt.children)
{
var r = FirstClip(ch.motion);
if (r != null) return r;
}
return null;
}
// ═════════════════════════════════════════════════════════════════════
// 렌더 · 도트화
// ═════════════════════════════════════════════════════════════════════
static void RenderInto(Camera cam, RenderTexture rt, Texture2D read, Color bg, Color32[] outPx)
{
cam.backgroundColor = bg;
var prev = cam.targetTexture;
cam.targetTexture = rt;
cam.Render();
cam.targetTexture = prev;
var active = RenderTexture.active;
RenderTexture.active = rt;
read.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0);
read.Apply(false, false);
RenderTexture.active = active;
var src = read.GetPixels32();
Array.Copy(src, outPx, outPx.Length);
}
/// <summary>검정 배경 · 흰 배경 두 장에서 알파를 복원하고 도트화한다(이진화 → 양자화).</summary>
static Color32[] Compose(Color32[] black, Color32[] white, int P, WLSpriteBakeSettings cfg)
{
var outPx = new Color32[P * P];
int levels = cfg.paletteLevels;
bool quant = levels >= 2 && levels <= 256;
float cut = Mathf.Clamp01(cfg.alphaCutoff);
for (int i = 0; i < outPx.Length; i++)
{
Color32 b = black[i], w = white[i];
// 배경만 다르다 → 차이가 곧 「덜 덮인 정도」
float cov = 1f - (((w.r - b.r) + (w.g - b.g) + (w.b - b.b)) / (3f * 255f));
if (cov < cut) { outPx[i] = new Color32(0, 0, 0, 0); continue; }
// 검정 배경 위 값 = cov × 색 → 색 = 값 ÷ cov
float inv = cov > 0.001f ? 1f / cov : 1f;
float fr = Mathf.Clamp(b.r * inv, 0f, 255f);
float fg = Mathf.Clamp(b.g * inv, 0f, 255f);
float fb = Mathf.Clamp(b.b * inv, 0f, 255f);
if (quant && cfg.paletteValueBands)
{
// 밝기만 단계로 끊는다(색상·채도 유지) → 도트 특유의 평평한 명암, 색 얼룩 0
float v = Mathf.Max(fr, Mathf.Max(fg, fb));
if (v > 0.5f)
{
float n = levels - 1;
float qv = Mathf.Round(v / 255f * n) / n * 255f;
float k = qv / v;
fr *= k; fg *= k; fb *= k;
}
outPx[i] = new Color32((byte)Mathf.Clamp(Mathf.RoundToInt(fr), 0, 255),
(byte)Mathf.Clamp(Mathf.RoundToInt(fg), 0, 255),
(byte)Mathf.Clamp(Mathf.RoundToInt(fb), 0, 255), 255);
}
else outPx[i] = new Color32(Q(fr, levels, quant), Q(fg, levels, quant), Q(fb, levels, quant), 255);
}
return outPx;
}
static byte Q(float v, int levels, bool quant)
{
v = Mathf.Clamp(v, 0f, 255f);
if (!quant) return (byte)Mathf.RoundToInt(v);
float n = levels - 1;
float q = Mathf.Round(v / 255f * n) / n;
return (byte)Mathf.RoundToInt(q * 255f);
}
static void TightBox(Color32[] px, int P, ref int minX, ref int maxX, ref int minY, ref int maxY)
{
for (int y = 0; y < P; y++)
{
int row = y * P;
for (int x = 0; x < P; x++)
{
if (px[row + x].a == 0) continue;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
/// <summary>프로브 프레임에서 S×S 를 잘라낸다. (ox, oy) = 잘라낼 좌하단의 프로브 좌표.</summary>
static Color32[] Crop(Color32[] src, int P, int S, int ox, int oy)
{
var dst = new Color32[S * S];
for (int y = 0; y < S; y++)
{
int sy = oy + y;
if (sy < 0 || sy >= P) continue;
int srow = sy * P, drow = y * S;
for (int x = 0; x < S; x++)
{
int sx = ox + x;
if (sx < 0 || sx >= P) continue;
dst[drow + x] = src[srow + sx];
}
}
return dst;
}
/// <summary>알파 경계 **바깥**에 1px 외곽선(색 = SO).</summary>
static void Outline(Color32[] px, int S, WLSpriteBakeSettings cfg)
{
var col = (Color32)cfg.outlineColor;
col.a = 255;
var add = new List<int>(S * 4);
for (int y = 0; y < S; y++)
{
int row = y * S;
for (int x = 0; x < S; x++)
{
int i = row + x;
if (px[i].a != 0) continue;
if (Solid(px, S, x - 1, y) || Solid(px, S, x + 1, y) || Solid(px, S, x, y - 1) || Solid(px, S, x, y + 1)
|| (cfg.outlineDiagonal && (Solid(px, S, x - 1, y - 1) || Solid(px, S, x + 1, y - 1) || Solid(px, S, x - 1, y + 1) || Solid(px, S, x + 1, y + 1))))
add.Add(i);
}
}
for (int i = 0; i < add.Count; i++) px[add[i]] = col;
}
static bool Solid(Color32[] px, int S, int x, int y)
{
if (x < 0 || y < 0 || x >= S || y >= S) return false;
return px[y * S + x].a != 0;
}
static void Blit(Color32[] src, int S, Color32[] dst, int dstW, int dx, int dy)
{
for (int y = 0; y < S; y++)
{
int srow = y * S, drow = (dy + y) * dstW + dx;
Array.Copy(src, srow, dst, drow, S);
}
}
static int RoundUp8(int v) { return ((v + 7) / 8) * 8; }
// ═════════════════════════════════════════════════════════════════════
// 임포트 설정 — 🔴 코드로 지정(Point · 압축 없음 · mipmap off)
// ═════════════════════════════════════════════════════════════════════
public static void ApplyImportSettings(string pngPath, int maxSize)
{
var ti = AssetImporter.GetAtPath(pngPath) as TextureImporter;
if (ti == null) return;
ti.textureType = TextureImporterType.Default; // 🔴 Sprite(Multiple) 가 아니다 — UV 는 런타임 산술
ti.npotScale = TextureImporterNPOTScale.None;
ti.filterMode = FilterMode.Point;
ti.mipmapEnabled = false;
ti.wrapMode = TextureWrapMode.Clamp;
ti.alphaIsTransparency = true;
ti.alphaSource = TextureImporterAlphaSource.FromInput;
ti.sRGBTexture = true;
ti.isReadable = false;
ti.textureCompression = TextureImporterCompression.Uncompressed;
ti.maxTextureSize = NextPow2AtLeast(maxSize);
var def = ti.GetDefaultPlatformTextureSettings();
def.format = TextureImporterFormat.RGBA32;
def.textureCompression = TextureImporterCompression.Uncompressed;
def.maxTextureSize = ti.maxTextureSize;
ti.SetPlatformTextureSettings(def);
var android = ti.GetPlatformTextureSettings("Android");
android.overridden = true;
android.format = TextureImporterFormat.RGBA32;
android.textureCompression = TextureImporterCompression.Uncompressed;
android.maxTextureSize = ti.maxTextureSize;
ti.SetPlatformTextureSettings(android);
ti.SaveAndReimport();
}
static int NextPow2AtLeast(int v)
{
int p = 32;
while (p < v && p < 8192) p <<= 1;
return p;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 19bec13898ca6d64d908bce11eacb109

View File

@ -0,0 +1,92 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSpriteBakerWindow.cs — 「도트로 굽기」 에디터 창 (WL-814k · #814)
//
// 메뉴: WL ▸ 도트 스프라이트 굽기
// 🔴 캐릭터 이름이 코드에 0개 — 창에 **아무 캐릭터 프리팹이나** 끌어다 놓으면 그것을 굽는다.
// (PD 가 캐릭터 에셋 교체를 검토 중 → 특정 프리팹에 하드코딩하지 않는다.)
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
namespace WL.Look.SpriteBake.EditorTools
{
public sealed class WLSpriteBakerWindow : EditorWindow
{
[SerializeField] List<GameObject> _targets = new List<GameObject>();
[SerializeField] WLSpriteBakeSettings _cfg;
[SerializeField] string _outDir = "";
[SerializeField] Vector2 _scroll;
[SerializeField] string _report = "";
[MenuItem("WL/도트 스프라이트 굽기 (3D → 도트 시트)", false, 30)]
static void Open()
{
var w = GetWindow<WLSpriteBakerWindow>(false, "도트 굽기", true);
w.minSize = new Vector2(460f, 360f);
if (w._cfg == null) w._cfg = WLSpriteBakeSettings.Instance;
w.Show();
}
void OnGUI()
{
EditorGUILayout.HelpBox(
"3D 캐릭터 프리팹을 인게임 도트 카메라와 같은 각도로 미리 렌더해서 스프라이트 시트(PNG) + 메타 SO 로 굽는다.\n" +
"· 어떤 캐릭터 프리팹이든 아래 목록에 넣으면 된다(툴은 프리팹 이름을 모른다).\n" +
"· 굽는 시점에 1px 외곽선 · 팔레트 양자화 · 알파 이진화를 강제한다.\n" +
"· 원본 프리팹 / 머티리얼 / 애니메이션은 한 글자도 안 고친다(읽기·샘플링만).", MessageType.Info);
_cfg = (WLSpriteBakeSettings)EditorGUILayout.ObjectField("굽기 설정(SO)", _cfg, typeof(WLSpriteBakeSettings), false);
if (_cfg == null)
{
EditorGUILayout.HelpBox("Resources/WL/WLSpriteBakeSettings.asset 을 넣어라.", MessageType.Warning);
if (GUILayout.Button("기본 설정 에셋 찾기")) _cfg = WLSpriteBakeSettings.Instance;
return;
}
_outDir = EditorGUILayout.TextField(new GUIContent("출력 폴더(비우면 SO 값)"), _outDir);
EditorGUILayout.LabelField(" → " + (string.IsNullOrEmpty(_outDir) ? _cfg.outputDir : _outDir), EditorStyles.miniLabel);
EditorGUILayout.LabelField(string.Format(" 내림각 {0:0.000}° · 픽셀 1개 {1:0.00000} m · 방향 {2} · 프레임 {3}×{3}{4}",
_cfg.PitchDeg, _cfg.WorldPerPixel, _cfg.dirCount, _cfg.frameSize, _cfg.autoFrameSize ? " (자동)" : ""), EditorStyles.miniLabel);
EditorGUILayout.Space();
EditorGUILayout.LabelField("구울 프리팹 (드래그해서 넣기)", EditorStyles.boldLabel);
var so = new SerializedObject(this);
EditorGUILayout.PropertyField(so.FindProperty("_targets"), new GUIContent("대상"), true);
so.ApplyModifiedProperties();
using (new EditorGUI.DisabledScope(_targets.Count == 0))
{
if (GUILayout.Button("굽기 시작", GUILayout.Height(30f))) BakeAll();
}
EditorGUILayout.Space();
_scroll = EditorGUILayout.BeginScrollView(_scroll);
EditorGUILayout.TextArea(_report, GUILayout.ExpandHeight(true));
EditorGUILayout.EndScrollView();
}
void BakeAll()
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < _targets.Count; i++)
{
var go = _targets[i];
if (go == null) continue;
string path = AssetDatabase.GetAssetPath(go);
if (string.IsNullOrEmpty(path)) { sb.AppendLine("건너뜀(프리팹 에셋이 아님): " + go.name); continue; }
EditorUtility.DisplayProgressBar("도트로 굽는 중", path, (i + 0.5f) / _targets.Count);
var r = WLSpriteBaker.Bake(path, _cfg, string.IsNullOrEmpty(_outDir) ? null : _outDir);
sb.AppendLine((r.ok ? "OK " : "FAIL ") + r.Summary);
sb.AppendLine(r.log.ToString());
foreach (var n in r.notes) sb.AppendLine(" · " + n);
sb.AppendLine();
}
EditorUtility.ClearProgressBar();
AssetDatabase.Refresh();
_report = sb.ToString();
Debug.Log("[WL814k] 굽기 완료\n" + _report);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2a3e14cd2c39a344caa5c31b7582ba03

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bf6fc714ae9b81a49bc424ea8cfac3b7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 948528c800001d74d8e2f24cddc3b799
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,119 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: e9dcf8e4e8011b84ab5e5df43a7e0255, type: 3}
m_Name: WLSpriteBakeSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteBakeSettings
pitchDegOverride: 0
orbitCameraHeight: 80
orbitDistance: 3
orbitDistanceUp: 3
dirCount: 8
worldPerPixelOverride: 0
refOrthoSize: 3.4
refPixelHeight: 240
frameSize: 64
autoFrameSize: 1
frameSizeMin: 16
frameSizeMax: 128
marginPx: 2
probeSize: 192
outline: 1
outlineColor: {r: 0.055, g: 0.043, b: 0.067, a: 1}
outlineDiagonal: 0
paletteLevels: 6
alphaCutoff: 0.5
useToonMaterials: 1
toonMaterialDir: Assets/WL/Look/Env/Materials
toonMaterialSuffix: _Toon
sunEuler: {x: 26.4, y: 238.6, z: 0}
sunColor: {r: 1, g: 0.92, b: 0.703, a: 1}
sunIntensity: 1.2
fillEuler: {x: 36.1, y: 116.1, z: 0}
fillColor: {r: 0.596, g: 0.703, b: 0.839, a: 1}
fillIntensity: 0.3
ambient: {r: 0.212, g: 0.227, b: 0.259, a: 1}
outputDir: Assets/WL/Look/Sprite/Baked
sheetColumns: 16
sheetMaxWidth: 2048
actions:
- key: idle
stateNames:
- idle
- Idle
- wait
- IdleA
- IdleB
alsoMapStateNames:
- land
- getup
frames: 4
loop: 1
required: 0
- key: run
stateNames:
- run
- Run
- move
- Move
- walk
alsoMapStateNames:
- dodge
frames: 6
loop: 1
required: 0
- key: attack
stateNames:
- attack
- Attack
- atk
- ATK0
- ATK1
alsoMapStateNames:
- ATK2
- ATK3
- ATK4
- Shoot
- attack2
- skill2
- skill3
- skill4
- skill5
- casting
- casting_end
frames: 6
loop: 1
required: 0
- key: hit
stateNames:
- hit
- Hit
- damage
- Damage
alsoMapStateNames:
- stun
- Swoon
frames: 2
loop: 1
required: 0
- key: die
stateNames:
- die
- Die
- death
- Die0
- Die1
alsoMapStateNames:
- lying
- Die2
frames: 6
loop: 0
required: 0

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5b40b06e2f8e6234580661b2c962fc01
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,31 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 8f39fe71df28ff94ebdc9f76766e84e9, type: 3}
m_Name: WLSpriteLookSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.Look.SpriteBake.WLSpriteLookSettings
enabled_: 1
verboseLog: 0
onlyInPixelMode: 1
sweepSeconds: 0.5
maxActors: 24
sets:
- {fileID: 11400000, guid: 9afd69d5485f7584b8f40e7673bca04e, type: 2}
- {fileID: 11400000, guid: 02557ce4e57225d4db041caa2e6e44d7, type: 2}
yAxisOnly: 0
yAxisOnlyCompensate: 1
depthBias: 0.02
alphaCutoff: 0.5
quadLayer: -1
alsoHideMeshRenderers: 1
castShadows: 0
statePollSeconds: 0
fallbackActionKey: idle

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 481482cb77548cb47959bef245114fc2
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,620 @@
// ─────────────────────────────────────────────────────────────────────────────
// SpriteBillboard.cs — 도트 모드에서 3D 캐릭터를 「구운 도트 스프라이트」로 바꿔 그린다 (WL-814k · #814)
//
// PD 지시(2026-09-12) 「치비 느낌이 나지 않아도 도트 그림으로 구워 쓰는 방식으로 해봐.」
//
// ■ 이 파일이 하는 것 — 🔴 원본 코드 수정 0 · 원본 파괴 0
// ① 814d `LookModeHub.CameraModeChanged` 를 구독해 **도트 모드(1~3)에서만** 켠다.
// ② 대상 액터의 원본 `SkinnedMeshRenderer` 를 **끄고**(`enabled=false` · 파괴 0),
// 자식으로 **빌보드 쿼드 1개**(MeshRenderer + Unlit 알파컷 · 런타임 인스턴스)를 붙인다.
// ③ 프레임은 원본 `Animator` **폴링**으로 고른다(813x/813t2 방식):
// 상태 해시(레이어 0 shortNameHash) → 동작 · `normalizedTime` → 프레임 · 상대 yaw → 방향.
// ④ 모드에서 나가거나 SO 를 끄면 **전부 원복**한다(끈 렌더러를 다시 켜고 쿼드를 지운다) = C8.
//
// ■ 왜 「카메라 정렬」이 기본인가 (yAxisOnly = false)
// 스프라이트는 내림각 43.6° 카메라로 **이미 눌린 그림**이다. 그걸 수직 쿼드에 붙이면 한 번 더 눌린다
// (cos 43.6° = 0.724 → 키가 28 % 줄어든다). 쿼드를 카메라와 같은 자세로 두면 구운 픽셀이
// 화면 픽셀과 1:1 로 맞는다. 도트 모드의 카메라는 롤도 없고 내림각도 고정이라 기울어 보이지 않는다.
// 「Y축만」 모드도 SO 스위치로 남겨 뒀다(발주서 문구 · 눌림 보정 옵션 포함).
//
// ■ GC 0 — 부착할 때만 배열을 만든다. 매 프레임은 구조체 읽기 + MaterialPropertyBlock.SetVector 뿐이다
// (문자열·람다·LINQ·GetComponent 0). 로그 문자열은 만들기 전에 verboseLog 로 막는다(811b FIX-3).
//
// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections.Generic;
using UnityEngine;
using WL.Look.Toggle;
namespace WL.Look.SpriteBake
{
/// <summary>도트 모드 한정 3D→스프라이트 교체. 정적 · 러너 GameObject 1개.</summary>
public static class SpriteBillboard
{
// ── 액터 1명의 교체 상태 ──────────────────────────────────────────────
public sealed class Entry
{
public Transform actor;
public Animator anim;
public WLSpriteSet set;
public SkinnedMeshRenderer[] smrs;
public bool[] smrWas;
public MeshRenderer[] mrs;
public bool[] mrWas;
public GameObject quad;
public Transform quadTf;
public MeshRenderer quadMr;
public MaterialPropertyBlock mpb;
public WLSpriteAction action;
public int dir = -1;
public int frame = -1;
public int lastIndex = -1;
public float scaleK = 1f;
public bool manual; // 프로브/캡처가 프레임을 직접 지정한 상태
}
static WLSpriteLookSettings Cfg { get { return WLSpriteLookSettings.Instance; } }
static readonly List<Entry> s_entries = new List<Entry>(32);
static readonly HashSet<int> s_seen = new HashSet<int>();
static Material s_mat;
static Mesh s_mesh;
static bool s_applied;
static bool s_hooked;
static bool s_faulted;
static float s_nextSweep;
// ── 진단(프로브가 읽는다 · 실측만) ────────────────────────────────────
public static int Applies, Restores, Attached, Detached, HubEvents, Sweeps, FrameChanges;
public static string LastLog = "";
public static bool IsApplied { get { return s_applied; } }
public static int ActiveCount { get { return s_entries.Count; } }
public static List<Entry> EntriesForProbe { get { return s_entries; } }
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[WL814k SpriteBillboard] " + msg);
}
// ═════════════════════════════════════════════════════════════════════
// 켜고 끄기
// ═════════════════════════════════════════════════════════════════════
/// <summary>지금 켜야 하는 상태인가(SO + 도트 모드 조건).</summary>
public static bool ShouldBeOn
{
get
{
var c = Cfg;
if (c == null || !WLSpriteLookSettings.Enabled) return false;
if (!c.onlyInPixelMode) return true;
if (!WLLookModeSettings.Enabled) return false;
return LookModeHub.IsPixelMode;
}
}
/// <summary>대상 액터를 찾아 스프라이트로 바꾼다. 반환 = 새로 바뀐 수.</summary>
public static int ApplyNow(string reason)
{
var c = Cfg;
if (c == null || !WLSpriteLookSettings.Enabled) { Log("건너뜀(SO off) — " + reason); return 0; }
s_applied = true;
Applies++;
int n = Sweep();
Log("적용(" + reason + ") — 대상 " + s_entries.Count + "명(+ " + n + ")");
return n;
}
/// <summary>전부 원복한다(끈 렌더러를 다시 켜고 쿼드를 지운다) = C8.</summary>
public static int RestoreNow(string reason)
{
int n = s_entries.Count;
for (int i = s_entries.Count - 1; i >= 0; i--) DetachAt(i);
s_entries.Clear();
s_seen.Clear();
s_applied = false;
if (n > 0) Restores++;
Log("원복(" + reason + ") — " + n + "명");
return n;
}
// ═════════════════════════════════════════════════════════════════════
// 스윕 — 새로 스폰된 액터 찾기 (캐시 히트 시 할당 0 · 814c SweepNewSpawns 방식)
// ═════════════════════════════════════════════════════════════════════
public static int Sweep()
{
var c = Cfg;
if (c == null || !s_applied) return 0;
Sweeps++;
int added = 0;
// ① PC
var pc = PcTransform();
if (pc != null) added += TryAttachIfNew(pc, c);
// ② 몹 루트의 직속 자식(814c 와 같은 경로 = InGameInfo.tf_Objs)
var root = MobRoot();
if (root != null)
{
int cnt = root.childCount;
for (int i = 0; i < cnt; i++)
{
var ch = root.GetChild(i);
if (ch == null) continue;
added += TryAttachIfNew(ch, c);
}
}
return added;
}
static Transform PcTransform()
{
if (!Application.isPlaying) return null;
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) return null;
return pc.transform;
}
static Transform MobRoot()
{
if (!Application.isPlaying) return null;
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return null;
return info.tf_Objs;
}
static int TryAttachIfNew(Transform t, WLSpriteLookSettings c)
{
if (t == null) return 0;
int id = t.GetInstanceID();
if (s_seen.Contains(id)) return 0;
s_seen.Add(id);
if (s_entries.Count >= Mathf.Max(1, c.maxActors)) return 0;
var set = MatchSet(t.name, c);
if (set == null) return 0;
return TryAttach(t.gameObject, set) != null ? 1 : 0;
}
/// <summary>액터 이름 → 시트 SO(SO 목록의 actorNamePrefix 로 매칭). 없으면 null.</summary>
public static WLSpriteSet MatchSet(string actorName, WLSpriteLookSettings c)
{
if (c == null || c.sets == null || string.IsNullOrEmpty(actorName)) return null;
for (int i = 0; i < c.sets.Length; i++)
{
var s = c.sets[i];
if (s == null || !s.IsUsable || string.IsNullOrEmpty(s.actorNamePrefix)) continue;
if (actorName.StartsWith(s.actorNamePrefix, System.StringComparison.Ordinal)) return s;
}
return null;
}
// ═════════════════════════════════════════════════════════════════════
// 부착 / 떼기 (에디트 모드에서도 동작 — 캡처가 이 경로를 쓴다)
// ═════════════════════════════════════════════════════════════════════
/// <summary>액터 1명을 스프라이트로 바꾼다. 이미 바뀌었으면 기존 Entry 를 준다.</summary>
public static Entry TryAttach(GameObject actor, WLSpriteSet set)
{
if (actor == null || set == null || !set.IsUsable) return null;
for (int i = 0; i < s_entries.Count; i++) if (s_entries[i].actor == actor.transform) return s_entries[i];
var c = Cfg;
var e = new Entry();
e.actor = actor.transform;
e.anim = actor.GetComponentInChildren<Animator>(true);
e.set = set;
// 원본 렌더러 끄기(파괴 0 · 원값 기억)
e.smrs = actor.GetComponentsInChildren<SkinnedMeshRenderer>(true);
e.smrWas = new bool[e.smrs.Length];
for (int i = 0; i < e.smrs.Length; i++)
{
if (e.smrs[i] == null) continue;
e.smrWas[i] = e.smrs[i].enabled;
e.smrs[i].enabled = false;
}
if (c == null || c.alsoHideMeshRenderers)
{
var all = actor.GetComponentsInChildren<MeshRenderer>(true);
var keep = new List<MeshRenderer>(all.Length);
for (int i = 0; i < all.Length; i++)
if (all[i] != null && all[i].GetComponent<ParticleSystem>() == null) keep.Add(all[i]);
e.mrs = keep.ToArray();
e.mrWas = new bool[e.mrs.Length];
for (int i = 0; i < e.mrs.Length; i++) { e.mrWas[i] = e.mrs[i].enabled; e.mrs[i].enabled = false; }
}
else { e.mrs = new MeshRenderer[0]; e.mrWas = new bool[0]; }
// 빌보드 쿼드
e.quad = new GameObject("[WL814k] SpriteQuad");
e.quad.hideFlags = HideFlags.DontSave;
e.quadTf = e.quad.transform;
e.quadTf.SetParent(actor.transform, false);
int layer = (c != null && c.quadLayer >= 0) ? c.quadLayer : actor.layer;
e.quad.layer = layer;
var mf = e.quad.AddComponent<MeshFilter>();
mf.sharedMesh = QuadMesh();
e.quadMr = e.quad.AddComponent<MeshRenderer>();
e.quadMr.sharedMaterial = SheetMaterial(set.sheet); // 시트별 1개(액터끼리 공유 · 시트가 섞여도 안 덮인다)
e.quadMr.shadowCastingMode = (c != null && c.castShadows)
? UnityEngine.Rendering.ShadowCastingMode.On : UnityEngine.Rendering.ShadowCastingMode.Off;
e.quadMr.receiveShadows = false;
e.quadMr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off;
e.quadMr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off;
e.mpb = new MaterialPropertyBlock();
// 스케일 보정(액터가 구울 때와 다른 크기로 스폰될 수 있다)
float bs = set.bakeScale > 0.0001f ? set.bakeScale : 1f;
e.scaleK = Mathf.Abs(actor.transform.lossyScale.y) / bs;
if (e.scaleK < 0.0001f) e.scaleK = 1f;
s_entries.Add(e);
Attached++;
return e;
}
static void DetachAt(int i)
{
var e = s_entries[i];
if (e == null) return;
if (e.smrs != null)
for (int k = 0; k < e.smrs.Length; k++) if (e.smrs[k] != null) e.smrs[k].enabled = e.smrWas[k];
if (e.mrs != null)
for (int k = 0; k < e.mrs.Length; k++) if (e.mrs[k] != null) e.mrs[k].enabled = e.mrWas[k];
if (e.quad != null)
{
if (Application.isPlaying) Object.Destroy(e.quad);
else Object.DestroyImmediate(e.quad);
}
s_entries.RemoveAt(i);
Detached++;
}
/// <summary>액터 1명만 원복한다(프로브용).</summary>
public static bool Detach(GameObject actor)
{
if (actor == null) return false;
for (int i = 0; i < s_entries.Count; i++)
if (s_entries[i].actor == actor.transform) { DetachAt(i); s_seen.Remove(actor.transform.GetInstanceID()); return true; }
return false;
}
// ═════════════════════════════════════════════════════════════════════
// 프레임 · 방향 · 자세 갱신 (프레임당 할당 0)
// ═════════════════════════════════════════════════════════════════════
/// <summary>프로브/캡처가 프레임을 직접 지정한다(Animator 폴링 대신).</summary>
public static bool SetManualFrame(GameObject actor, string actionKey, int dir, int frame)
{
for (int i = 0; i < s_entries.Count; i++)
{
var e = s_entries[i];
if (e.actor != actor.transform) continue;
var a = e.set.FindByKey(actionKey);
if (a == null) return false;
e.manual = true; e.action = a;
e.dir = Mathf.Clamp(dir, 0, e.set.dirCount - 1);
e.frame = Mathf.Clamp(frame, 0, a.frameCount - 1);
e.lastIndex = -1;
return true;
}
return false;
}
/// <summary>전체 갱신(러너가 매 프레임 부른다). cam 이 null 이면 Camera.main.</summary>
public static void UpdateAll(Camera cam)
{
if (cam == null) cam = Camera.main;
if (cam == null) return;
var c = Cfg;
if (c == null) return;
Vector3 camFwd = cam.transform.forward;
Vector3 camUp = cam.transform.up;
Vector3 camRight = cam.transform.right;
Quaternion camRot = cam.transform.rotation;
// yAxisOnly 용 수평 전방 · 눌림 보정
Vector3 flatFwd = camFwd; flatFwd.y = 0f;
if (flatFwd.sqrMagnitude < 1e-6f) flatFwd = Vector3.forward; else flatFwd.Normalize();
float comp = 1f;
if (c.yAxisOnly && c.yAxisOnlyCompensate)
{
float cosP = Mathf.Cos(Mathf.Abs(Mathf.Asin(Mathf.Clamp(-camFwd.y, -1f, 1f))));
comp = cosP > 0.05f ? 1f / cosP : 1f;
}
for (int i = s_entries.Count - 1; i >= 0; i--)
{
var e = s_entries[i];
if (e == null || e.actor == null || e.quadTf == null) { if (e != null && e.actor != null) s_seen.Remove(e.actor.GetInstanceID()); s_entries.RemoveAt(i); continue; }
UpdateEntry(e, c, camFwd, camUp, camRight, camRot, flatFwd, comp);
}
}
static void UpdateEntry(Entry e, WLSpriteLookSettings c,
Vector3 camFwd, Vector3 camUp, Vector3 camRight, Quaternion camRot,
Vector3 flatFwd, float comp)
{
var set = e.set;
// ── 동작 · 프레임 (Animator 폴링 · 원본 코드 수정 0) ──────────────
if (!e.manual && e.anim != null && e.anim.runtimeAnimatorController != null)
{
var st = e.anim.GetCurrentAnimatorStateInfo(0);
int hash = st.shortNameHash;
float nt = st.normalizedTime;
if (e.anim.IsInTransition(0))
{
var nx = e.anim.GetNextAnimatorStateInfo(0);
if (set.FindByStateHash(nx.shortNameHash) != null) { hash = nx.shortNameHash; nt = nx.normalizedTime; }
}
var act = set.FindByStateHash(hash);
if (act == null) act = set.FindByKey(c.fallbackActionKey);
if (act == null && set.actions.Length > 0) act = set.actions[0];
if (act != null)
{
e.action = act;
int fc = Mathf.Max(1, act.frameCount);
int f = act.loop
? Mathf.FloorToInt(Mathf.Repeat(nt, 1f) * fc)
: Mathf.FloorToInt(Mathf.Clamp01(nt) * fc);
e.frame = Mathf.Clamp(f, 0, fc - 1);
}
}
if (e.action == null && set.actions.Length > 0) { e.action = set.actions[0]; e.frame = 0; }
if (e.action == null) return;
// ── 방향 (캐릭터 forward 와 카메라 forward 의 상대 yaw) ───────────
if (!e.manual)
{
Vector3 af = e.actor.forward; af.y = 0f;
if (af.sqrMagnitude > 1e-6f)
{
float rel = Vector3.SignedAngle(flatFwd, af, Vector3.up);
float step = 360f / Mathf.Max(1, set.dirCount);
int d = Mathf.RoundToInt(Mathf.Repeat(rel, 360f) / step);
if (d >= set.dirCount) d -= set.dirCount;
e.dir = d;
}
else if (e.dir < 0) e.dir = 0;
}
// ── UV (바뀔 때만 MPB 를 다시 넣는다) ─────────────────────────────
int index = e.action.startIndex + e.dir * e.action.frameCount + e.frame;
if (index != e.lastIndex)
{
e.lastIndex = index;
FrameChanges++;
Vector4 st4 = set.UvRect(index);
e.mpb.SetVector(IdBaseMapST, st4);
e.mpb.SetVector(IdMainTexST, st4);
e.quadMr.SetPropertyBlock(e.mpb);
}
// ── 자세 · 크기 (발밑 정렬 · 픽셀 격자) ───────────────────────────
float wpp = set.worldPerPixel * e.scaleK;
float w = set.frameWidth * wpp;
float h = set.frameHeight * wpp;
Vector3 foot = e.actor.position;
// 지면 파묻힘 방지 — 쿼드의 발밑 아래쪽이 지면 밑으로 내려간 만큼 시선 방향으로 당긴다.
// 직교 카메라에서는 시선 방향 이동이 화면 위치를 바꾸지 않으므로 그림은 그대로다.
float bias = c.depthBias;
if (c.autoDepthBias)
{
float sinP = Mathf.Clamp(-camFwd.y, 0.02f, 1f);
float cosP = Mathf.Sqrt(Mathf.Max(0f, 1f - sinP * sinP));
bias += (set.footPixelY * wpp) * cosP / sinP;
}
if (!c.yAxisOnly)
{
Vector3 center = foot
+ camRight * ((set.frameWidth * 0.5f - set.footPixelX) * wpp)
+ camUp * ((set.frameHeight * 0.5f - set.footPixelY) * wpp)
- camFwd * bias;
e.quadTf.SetPositionAndRotation(center, camRot);
e.quadTf.localScale = new Vector3(w / SafeScale(e.actor.lossyScale.x),
h / SafeScale(e.actor.lossyScale.y),
1f / SafeScale(e.actor.lossyScale.z));
}
else
{
float hh = h * comp;
Quaternion rot = Quaternion.LookRotation(flatFwd, Vector3.up);
Vector3 right = rot * Vector3.right;
Vector3 center = foot
+ right * ((set.frameWidth * 0.5f - set.footPixelX) * wpp)
+ Vector3.up * ((set.frameHeight * 0.5f - set.footPixelY) * wpp * comp)
- flatFwd * c.depthBias; // 수직 쿼드는 지면에 안 파묻힌다 → 기본 여유분만
e.quadTf.SetPositionAndRotation(center, rot);
e.quadTf.localScale = new Vector3(w / SafeScale(e.actor.lossyScale.x),
hh / SafeScale(e.actor.lossyScale.y),
1f / SafeScale(e.actor.lossyScale.z));
}
}
static float SafeScale(float v) { v = Mathf.Abs(v); return v < 0.0001f ? 1f : v; }
static readonly int IdBaseMapST = Shader.PropertyToID("_BaseMap_ST");
static readonly int IdMainTexST = Shader.PropertyToID("_MainTex_ST");
static readonly int IdBaseMap = Shader.PropertyToID("_BaseMap");
static readonly int IdMainTex = Shader.PropertyToID("_MainTex");
// ═════════════════════════════════════════════════════════════════════
// 쿼드 메시 · 머티리얼 (런타임 인스턴스 · 디스크 에셋 0)
// ═════════════════════════════════════════════════════════════════════
public static Mesh QuadMesh()
{
if (s_mesh != null) return s_mesh;
s_mesh = new Mesh();
s_mesh.name = "[WL814k] BillboardQuad";
s_mesh.hideFlags = HideFlags.HideAndDontSave;
s_mesh.vertices = new[]
{
new Vector3(-0.5f, -0.5f, 0f), new Vector3(0.5f, -0.5f, 0f),
new Vector3(-0.5f, 0.5f, 0f), new Vector3(0.5f, 0.5f, 0f),
};
s_mesh.uv = new[] { new Vector2(0f, 0f), new Vector2(1f, 0f), new Vector2(0f, 1f), new Vector2(1f, 1f) };
s_mesh.normals = new[] { -Vector3.forward, -Vector3.forward, -Vector3.forward, -Vector3.forward };
s_mesh.triangles = new[] { 0, 1, 2, 2, 1, 3 };
s_mesh.RecalculateBounds();
return s_mesh;
}
public static Material QuadMaterial(Texture2D sheet)
{
var c = Cfg;
if (s_mat == null)
{
var sh = Shader.Find("Universal Render Pipeline/Unlit");
if (sh == null) sh = Shader.Find("Unlit/Transparent");
s_mat = new Material(sh);
s_mat.name = "[WL814k] SpriteBillboard (런타임 인스턴스)";
s_mat.hideFlags = HideFlags.HideAndDontSave;
s_mat.SetFloat("_Surface", 0f); // Opaque + AlphaClip → 깊이 정상
s_mat.SetFloat("_AlphaClip", 1f);
s_mat.SetFloat("_Cutoff", c != null ? c.alphaCutoff : 0.5f);
s_mat.SetFloat("_Cull", 0f); // 양면 — 감기 방향 신경 안 쓴다
s_mat.SetFloat("_ZWrite", 1f);
s_mat.EnableKeyword("_ALPHATEST_ON");
s_mat.renderQueue = 2450; // AlphaTest
}
if (sheet != null)
{
s_mat.SetTexture(IdBaseMap, sheet);
s_mat.SetTexture(IdMainTex, sheet);
}
return s_mat;
}
static readonly Dictionary<Texture2D, Material> s_sheetMats = new Dictionary<Texture2D, Material>();
/// <summary>시트 1장당 머티리얼 1개(런타임 인스턴스 · 디스크 에셋 0). 시트가 섞여도 서로 안 덮는다.</summary>
public static Material SheetMaterial(Texture2D sheet)
{
if (sheet == null) return QuadMaterial(null);
Material m;
if (s_sheetMats.TryGetValue(sheet, out m) && m != null) return m;
m = new Material(QuadMaterial(null));
m.name = "[WL814k] Sprite " + sheet.name;
m.hideFlags = HideFlags.HideAndDontSave;
m.SetTexture(IdBaseMap, sheet);
m.SetTexture(IdMainTex, sheet);
s_sheetMats[sheet] = m;
return m;
}
// ═════════════════════════════════════════════════════════════════════
// 틱 · 허브 · 러너
// ═════════════════════════════════════════════════════════════════════
public static void TickForProbe() { Tick(); }
public static void EnsureRunnerForProbe() { EnsureRunner(); }
internal static void Tick()
{
if (s_faulted || !WLSpriteLookSettings.Enabled) return;
var c = Cfg;
if (c == null) return;
bool want = ShouldBeOn;
if (want != s_applied)
{
try { if (want) ApplyNow("모드"); else RestoreNow("모드"); }
catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외) — " + ex.Message; RestoreNow("fault"); return; }
}
if (!s_applied) return;
float now = Time.unscaledTime;
if (c.sweepSeconds > 0f && now >= s_nextSweep)
{
s_nextSweep = now + Mathf.Max(0.1f, c.sweepSeconds);
try { Sweep(); }
catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외·스윕) — " + ex.Message; RestoreNow("fault"); return; }
}
UpdateAll(null);
}
internal static void HookHub()
{
if (s_hooked) return;
s_hooked = true;
LookModeHub.CameraModeChanged += OnCameraMode;
}
internal static void UnhookHub()
{
if (!s_hooked) return;
s_hooked = false;
LookModeHub.CameraModeChanged -= OnCameraMode;
}
static void OnCameraMode(int mode)
{
HubEvents++;
// 실제 적용은 Tick 이 ShouldBeOn 으로 판정한다(모드 전환 타이밍과 카메라 조립 순서를 안 탄다).
}
static SpriteBillboardRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || s_runner != null) return;
var go = new GameObject("[WL814k] SpriteBillboardRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<SpriteBillboardRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (!WLSpriteLookSettings.Enabled) return;
EnsureRunner();
}
/// <summary>프로브용 — 카운터 초기화(적용 상태는 유지).</summary>
public static void ResetDiagnostics()
{
Applies = Restores = Attached = Detached = HubEvents = Sweeps = FrameChanges = 0;
LastLog = "";
s_faulted = false;
}
}
/// <summary>스프라이트 교체의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class SpriteBillboardRunner : MonoBehaviour
{
void Update() { SpriteBillboard.Tick(); }
void OnEnable() { SpriteBillboard.HookHub(); }
void OnDisable() { SpriteBillboard.UnhookHub(); SpriteBillboard.RestoreNow("runner-disable"); }
void OnApplicationQuit() { SpriteBillboard.RestoreNow("app-quit"); }
}
/// <summary>🔴 에디터 전용 안전망 — 플레이모드를 벗어날 때 반드시 원복한다.</summary>
internal static class SpriteEditorSafetyNet
{
#if UNITY_EDITOR
[UnityEditor.InitializeOnLoadMethod]
static void Hook()
{
UnityEditor.EditorApplication.playModeStateChanged -= OnPlayMode;
UnityEditor.EditorApplication.playModeStateChanged += OnPlayMode;
}
static void OnPlayMode(UnityEditor.PlayModeStateChange s)
{
if (s == UnityEditor.PlayModeStateChange.ExitingPlayMode) SpriteBillboard.RestoreNow("exit-playmode");
}
#endif
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d2ca3b74c48b2f34fbd64fa57133fb66

View File

@ -0,0 +1,204 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSpriteBakeSettings.cs — 「3D → 도트 스프라이트」 굽기 값의 단일 출처 (WL-814k · #814)
//
// 🔴 이 에셋에는 **특정 캐릭터 이름이 하나도 없다**. 굽는 대상 프리팹은 **인자**로 받는다
// (PD 가 캐릭터 에셋 교체를 검토 중 → 프리팹에 하드코딩하면 툴이 통째로 버려진다).
// 여기 있는 값은 「어떤 프리팹이든 이렇게 굽는다」는 규칙뿐이다.
//
// ■ 값을 코드 상수로 두지 않는다(C45) — 방향 수 · 프레임 수 · 해상도 · 팔레트 단계 · 외곽선 색 전부 에셋.
// ■ 동작 표는 **이름 규약**으로만 적는다 — Animator 상태 이름 후보 목록(첫 번째로 찾은 것을 쓴다).
// 실측(814k): PC `pcanim_mage` 와 잡몹 `Batty_Animations_Check` 가 **같은 상태 이름**을 쓴다
// (idle · run · attack · hit · die) → 이 표 하나로 PC·몹이 동시에 걸린다.
//
// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Look.SpriteBake
{
/// <summary>구울 동작 1줄 — 「이 이름의 상태를 찾아 이만큼 프레임을 뽑아라」.</summary>
[System.Serializable]
public sealed class WLSpriteBakeAction
{
[Tooltip("동작 키(시트 메타에 그대로 들어간다).")]
public string key = "idle";
[Tooltip("Animator 상태 이름 후보 — 위에서부터 찾아 처음 맞는 것을 굽는다.")]
public string[] stateNames = new string[] { "idle" };
[Tooltip("굽지는 않지만 런타임에서 이 동작으로 **볼** 상태 이름들(예: 스킬 상태를 공격 그림으로). " +
"컨트롤러에 실제로 있는 것만 시트 메타에 들어간다.")]
public string[] alsoMapStateNames = new string[0];
[Tooltip("이 동작을 몇 프레임으로 구울지(클립 길이를 균등 분할).")]
public int frames = 4;
[Tooltip("루프 동작인가(사망은 끄면 마지막 프레임에서 멈춘다).")]
public bool loop = true;
[Tooltip("이 동작을 못 찾으면 굽기를 실패로 볼지(끄면 「미확인」으로 건너뛴다).")]
public bool required = false;
}
[CreateAssetMenu(fileName = "WLSpriteBakeSettings", menuName = "WL/Sprite Bake Settings (도트 굽기)", order = 37)]
public sealed class WLSpriteBakeSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLSpriteBakeSettings";
static WLSpriteBakeSettings s_instance;
static bool s_tried;
/// <summary>에셋이 없으면 null. 에디터 툴만 쓴다.</summary>
public static WLSpriteBakeSettings Instance
{
get
{
if (s_instance == null && !s_tried) { s_tried = true; s_instance = Resources.Load<WLSpriteBakeSettings>(ResourcesPath); }
return s_instance;
}
}
public static void ClearCache() { s_instance = null; s_tried = false; }
// ── 카메라 (인게임 도트 카메라와 같게) ────────────────────────────────
[Header("카메라 — 인게임 도트 모드와 같은 구도로 굽는다")]
[Tooltip("내림각(도). 0 이면 아래 3값(811o 궤도 파라미터)에서 자동 산출한다. 실측 기본 = 43.609°.")]
public float pitchDegOverride = 0f;
[Tooltip("811o 궤도 파라미터 cameraHeight — pitchDegOverride 가 0 일 때 여기서 내림각을 만든다.")]
public float orbitCameraHeight = 80f;
public float orbitDistance = 3f;
public float orbitDistanceUp = 3f;
[Tooltip("방향 수 — 카메라 기준 상대 yaw 를 이 수로 나눈다. 8 = 45° 간격.")]
public int dirCount = 8;
// ── 픽셀 크기 ────────────────────────────────────────────────────────
[Header("픽셀 크기 — 도트 모드 화면 픽셀과 1:1 로 맞춘다")]
[Tooltip("픽셀 1개의 월드 크기(m). 0 이면 아래 두 값(도트 프리셋)에서 (2×orthoSize)/pixelHeight 로 산출. 실측 도트A = 0.0283333.")]
public float worldPerPixelOverride = 0f;
[Tooltip("도트 프리셋 orthoSize(직교 반높이 m) — worldPerPixelOverride 가 0 일 때 쓴다.")]
public float refOrthoSize = 3.4f;
[Tooltip("도트 프리셋 세로 픽셀 수 — worldPerPixelOverride 가 0 일 때 쓴다.")]
public int refPixelHeight = 240;
[Header("프레임 크기")]
[Tooltip("기본 프레임 해상도(px). autoFrameSize 가 꺼져 있으면 이 값을 그대로 쓴다.")]
public int frameSize = 64;
[Tooltip("켜면 실제 실루엣을 재서 프레임을 8의 배수로 자동 조정한다(꽉 차게 · 아래 min/max 안에서).")]
public bool autoFrameSize = true;
public int frameSizeMin = 16;
public int frameSizeMax = 128;
[Tooltip("실루엣 둘레에 남길 여백(px) — 외곽선 1px 이 잘리지 않게 최소 2 권장.")]
public int marginPx = 2;
[Tooltip("실루엣을 재는 1차 렌더 해상도(px). 캐릭터가 이보다 크면 잘린다 — 넉넉히.")]
public int probeSize = 192;
[Header("샘플링 경로")]
[Tooltip("🔴 기본(끔) = **진짜 Animator 구동**(anim.Play + anim.Update · 게임과 같은 컨트롤러·리타깃 · applyRootMotion=false). " +
"켜면 AnimationMode.SampleAnimationClip 로 굽는다 — 실측(814k)에서 이 경로는 **루트 모션을 포즈에 얹어** " +
"Ai01 Run 이 2.0 m, death 가 3.0 m 밀려 프레임 밖으로 나갔다(게임에는 안 나오는 그림). 진단용으로만 켠다.")]
public bool sampleWithAnimationMode = false;
// ── 도트화 (굽는 시점에 강제) ────────────────────────────────────────
[Header("도트화 — 굽는 시점에 강제한다")]
[Tooltip("외곽선 1px 을 알파 경계 바깥에 두른다.")]
public bool outline = true;
public Color outlineColor = new Color(0.055f, 0.043f, 0.067f, 1f);
[Tooltip("외곽선을 대각선 이웃까지 볼지(켜면 모서리가 더 두껍다).")]
public bool outlineDiagonal = false;
[Tooltip("팔레트 양자화 — 단계 수(2~256 · 6 = 도트 느낌). 0/1 이면 끈다.")]
public int paletteLevels = 6;
[Tooltip("🔴 켜면 **밝기만** 단계로 끊고 색상·채도는 유지한다(명암이 도트처럼 평평해진다). " +
"끄면 R·G·B 를 각각 끊는다(발주서 문구 그대로) — 실측(814k): 어두운 색이 채널마다 다른 쪽으로 " +
"반올림돼 **없던 붉은/초록 얼룩**이 생겼다. 그래서 기본을 밝기 단계로 뒀다.")]
public bool paletteValueBands = true;
[Tooltip("알파 이진화 임계(0~1). 이 값 이상만 남기고 나머지는 완전 투명 → 가장자리가 또렷해진다.")]
public float alphaCutoff = 0.5f;
// ── 렌더 ─────────────────────────────────────────────────────────────
[Header("렌더")]
[Tooltip("814c Toon 머티리얼(<이름>_Toon.mat)이 있으면 그것으로 바꿔 굽는다(원본 .mat 수정 0 · 인스턴스에만).")]
public bool useToonMaterials = true;
public string toonMaterialDir = "Assets/WL/Look/Env/Materials";
public string toonMaterialSuffix = "_Toon";
[Tooltip("주 방향광 — 게임 맵(WL_Nature) Sun 실측값.")]
public Vector3 sunEuler = new Vector3(26.4f, 238.6f, 0f);
public Color sunColor = new Color(1f, 0.92f, 0.703f, 1f);
public float sunIntensity = 1.2f;
[Tooltip("보조 방향광(그림자 없음) — 게임 맵 Sun_Ambient 실측값. intensity 0 이면 안 만든다.")]
public Vector3 fillEuler = new Vector3(36.1f, 116.1f, 0f);
public Color fillColor = new Color(0.596f, 0.703f, 0.839f, 1f);
public float fillIntensity = 0.3f;
[Tooltip("🔴 켜면 인게임과 같은 **Skybox 환경광**(Ingame.unity 실측 = m_AmbientMode 0 + 기본 스카이박스). " +
"끄면 아래 단색으로 굽는다(더 어둡다 · 재현성 우선일 때).")]
public bool useSkyboxAmbient = true;
[Tooltip("Skybox 환경광 세기(인게임 m_AmbientIntensity 실측 = 1).")]
public float ambientIntensity = 1f;
[Tooltip("useSkyboxAmbient 가 꺼졌을 때 쓰는 환경광 단색.")]
public Color ambient = new Color(0.212f, 0.227f, 0.259f, 1f);
// ── 출력 ─────────────────────────────────────────────────────────────
[Header("출력")]
[Tooltip("시트 PNG · 메타 SO 가 들어갈 폴더.")]
public string outputDir = "Assets/WL/Look/Sprite/Baked";
[Tooltip("시트 가로 칸 수. 0 이면 정사각에 가깝게 자동.")]
public int sheetColumns = 16;
[Tooltip("시트 가로 픽셀 상한(초과하면 칸 수를 줄인다).")]
public int sheetMaxWidth = 2048;
// ── 동작 표 ──────────────────────────────────────────────────────────
[Header("동작 표 — 🔴 프리팹 이름은 여기 없다(대상은 인자)")]
public WLSpriteBakeAction[] actions = new WLSpriteBakeAction[]
{
new WLSpriteBakeAction { key = "idle", stateNames = new[]{ "idle", "Idle", "wait", "IdleA", "IdleB" }, frames = 4, loop = true,
alsoMapStateNames = new[]{ "land", "getup" } },
new WLSpriteBakeAction { key = "run", stateNames = new[]{ "run", "Run", "move", "Move", "walk" }, frames = 6, loop = true,
alsoMapStateNames = new[]{ "dodge" } },
new WLSpriteBakeAction { key = "attack", stateNames = new[]{ "attack", "Attack", "atk", "ATK0", "ATK1" }, frames = 6, loop = true,
alsoMapStateNames = new[]{ "ATK2", "ATK3", "ATK4", "Shoot", "attack2", "skill2", "skill3", "skill4", "skill5", "casting", "casting_end" } },
new WLSpriteBakeAction { key = "hit", stateNames = new[]{ "hit", "Hit", "damage", "Damage" }, frames = 2, loop = true,
alsoMapStateNames = new[]{ "stun", "Swoon" } },
new WLSpriteBakeAction { key = "die", stateNames = new[]{ "die", "Die", "death", "Die0", "Die1" }, frames = 6, loop = false,
alsoMapStateNames = new[]{ "lying", "Die2" } },
};
/// <summary>내림각(도) — override 가 0 이면 811o 궤도 수식으로 산출.</summary>
public float PitchDeg
{
get
{
if (pitchDegOverride > 0.01f) return pitchDegOverride;
return WL.CameraFraming.CameraFramingOverride.DownAngleDeg(orbitCameraHeight, orbitDistance, orbitDistanceUp);
}
}
/// <summary>픽셀 1개의 월드 크기(m).</summary>
public float WorldPerPixel
{
get
{
if (worldPerPixelOverride > 0.000001f) return worldPerPixelOverride;
int ph = Mathf.Max(1, refPixelHeight);
return (2f * refOrthoSize) / ph;
}
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e9dcf8e4e8011b84ab5e5df43a7e0255

View File

@ -0,0 +1,101 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLSpriteLookSettings.cs — 런타임 빌보드 교체(도트 모드 한정)의 값 단일 출처 (WL-814k · #814)
//
// ■ C8 롤백: `enabled_ = 0` → 러너를 아예 만들지 않는다 = 원본 3D 100 %.
// ■ 🔴 여기에도 특정 캐릭터 이름은 **SO 참조 목록**으로만 들어간다(코드 상수 0).
// 시트 SO(WLSpriteSet)가 `actorNamePrefix` 를 들고 있으므로, 목록에 SO 를 넣기만 하면 대상이 된다.
//
// 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것(Assembly-CSharp 단일).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Look.SpriteBake
{
[CreateAssetMenu(fileName = "WLSpriteLookSettings", menuName = "WL/Sprite Look Settings (도트 빌보드)", order = 38)]
public sealed class WLSpriteLookSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLSpriteLookSettings";
static WLSpriteLookSettings s_instance;
static bool s_tried;
/// <summary>에셋이 없으면 null(호출부는 전부 건너뛴다 = C8).</summary>
public static WLSpriteLookSettings Instance
{
get
{
if (s_instance == null && !s_tried) { s_tried = true; s_instance = Resources.Load<WLSpriteLookSettings>(ResourcesPath); }
return s_instance;
}
}
public static void ClearCache() { s_instance = null; s_tried = false; }
/// <summary>프로브·QA 용 런타임 스위치(에셋을 안 건드리고 끈다).</summary>
public static bool RuntimeDisabled;
/// <summary>이 축이 살아 있는가 = 에셋 존재 · enabled_ · 런타임 스위치 off.</summary>
public static bool Enabled
{
get { if (RuntimeDisabled) return false; var s = Instance; return s != null && s.enabled_; }
}
[Header("공통 (C8 롤백)")]
[Tooltip("끄면 러너 0 · 교체 0 = 원본 3D 100 %.")]
public bool enabled_ = true;
[Tooltip("켜면 교체/복원마다 Console 에 1줄(문자열은 이 값으로 막고 만든다 · GC 주의).")]
public bool verboseLog = false;
[Header("언제 켜는가")]
[Tooltip("🔴 도트 모드(814d LookModeHub 프리셋 pixelRig)에서만 켠다. 끄면 항상 켠다(디버그).")]
public bool onlyInPixelMode = true;
[Tooltip("새로 스폰된 액터를 찾아보는 주기(초). 0.5 = 프레임당 비용 0에 가깝다.")]
public float sweepSeconds = 0.5f;
[Tooltip("동시에 스프라이트로 바꿀 액터 수 상한(초과분은 원본 3D 그대로).")]
public int maxActors = 24;
[Header("시트 목록 — 대상 = 여기 있는 SO 의 actorNamePrefix")]
public WLSpriteSet[] sets = new WLSpriteSet[0];
[Header("빌보드")]
[Tooltip("🔴 기본(끔) = 카메라 정렬(구운 각도 그대로 · 스프라이트 1px = 화면 1px). " +
"켜면 Y축만 회전하는 수직 쿼드 — 구울 때의 내림각만큼 세로가 눌린다(cos 43.6° = 0.72).")]
public bool yAxisOnly = false;
[Tooltip("yAxisOnly 일 때 눌림을 1/cos(내림각) 로 보정한다.")]
public bool yAxisOnlyCompensate = true;
[Tooltip("쿼드를 카메라 쪽으로 살짝 당긴다(m) — z-파이팅 여유분.")]
public float depthBias = 0.02f;
[Tooltip("🔴 켜면 쿼드가 지면에 파묻히지 않도록 필요한 만큼 자동으로 더 당긴다. " +
"쿼드는 카메라와 같은 각도로 기울어 있어서 **발밑보다 아래쪽 부분이 지면 속으로 들어간다** — " +
"실측(814k): 끄면 캐릭터 아랫부분 7 px 이 지면에 잘렸다. 직교 카메라(도트 모드)에서는 " +
"시선 방향으로 당겨도 화면 위치가 1 px 도 안 움직이므로 공짜다.")]
public bool autoDepthBias = true;
[Tooltip("알파 컷 임계(구울 때 이미 이진화했으므로 0.5 고정 권장).")]
[Range(0.01f, 0.99f)] public float alphaCutoff = 0.5f;
[Tooltip("빌보드 쿼드가 그려질 레이어(-1 = 액터와 같은 레이어).")]
public int quadLayer = -1;
[Tooltip("SkinnedMeshRenderer 외에 일반 MeshRenderer(무기·장식)도 같이 끈다. " +
"구운 시트에는 그것들도 그려져 있으므로 켜 두는 것이 맞다. 파티클은 건드리지 않는다.")]
public bool alsoHideMeshRenderers = true;
[Tooltip("그림자 — 구운 스프라이트는 그림자를 못 만든다(한계). 켜면 쿼드 모양 그림자가 생겨 더 이상하다.")]
public bool castShadows = false;
[Header("동작 폴링")]
[Tooltip("Animator 상태를 몇 초마다 다시 볼지. 0 = 매 프레임(가장 정확 · 그래도 할당 0).")]
public float statePollSeconds = 0f;
[Tooltip("모르는 상태에 들어갔을 때 쓸 동작 키.")]
public string fallbackActionKey = "idle";
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8f39fe71df28ff94ebdc9f76766e84e9

View File

@ -0,0 +1,163 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>동작 1개(대기·이동·공격·피격·사망 …)가 시트 어디에 있는지.</summary>
[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;
}
/// <summary>구운 스프라이트 시트 1장 + 읽는 법. 프리팹 1종당 1개.</summary>
[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];
/// <summary>방향 1개 기준 총 프레임 수(= 시트 총 칸 수 ÷ 방향 수).</summary>
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);
}
}
/// <summary>Animator 상태 해시(레이어 0 shortNameHash)로 동작을 찾는다. 없으면 null.</summary>
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;
}
/// <summary>키로 동작을 찾는다(폴백용 · 예: "idle").</summary>
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]);
}
/// <summary>시트가 실제로 쓸 수 있는 상태인가.</summary>
public bool IsUsable
{
get
{
return sheet != null && frameWidth > 0 && frameHeight > 0 && columns > 0
&& dirCount > 0 && actions != null && actions.Length > 0;
}
}
/// <summary>globalIndex → 시트 UV 사각형(x, y, w, h · 0~1). 할당 0.</summary>
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 규약
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 7485ff60a4b963544b28a1b345440fb9