// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// 도트 모드 한정 3D→스프라이트 교체. 정적 · 러너 GameObject 1개.
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 s_entries = new List(32);
static readonly HashSet s_seen = new HashSet();
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 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);
}
// ═════════════════════════════════════════════════════════════════════
// 켜고 끄기
// ═════════════════════════════════════════════════════════════════════
/// 지금 켜야 하는 상태인가(SO + 도트 모드 조건).
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;
}
}
/// 대상 액터를 찾아 스프라이트로 바꾼다. 반환 = 새로 바뀐 수.
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;
}
/// 전부 원복한다(끈 렌더러를 다시 켜고 쿼드를 지운다) = C8.
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;
}
/// 액터 이름 → 시트 SO(SO 목록의 actorNamePrefix 로 매칭). 없으면 null.
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;
}
// ═════════════════════════════════════════════════════════════════════
// 부착 / 떼기 (에디트 모드에서도 동작 — 캡처가 이 경로를 쓴다)
// ═════════════════════════════════════════════════════════════════════
/// 액터 1명을 스프라이트로 바꾼다. 이미 바뀌었으면 기존 Entry 를 준다.
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(true);
e.set = set;
// 원본 렌더러 끄기(파괴 0 · 원값 기억)
e.smrs = actor.GetComponentsInChildren(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(true);
var keep = new List(all.Length);
for (int i = 0; i < all.Length; i++)
if (all[i] != null && all[i].GetComponent() == 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();
mf.sharedMesh = QuadMesh();
e.quadMr = e.quad.AddComponent();
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++;
}
/// 액터 1명만 원복한다(프로브용).
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)
// ═════════════════════════════════════════════════════════════════════
/// 프로브/캡처가 프레임을 직접 지정한다(Animator 폴링 대신).
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;
}
/// 전체 갱신(러너가 매 프레임 부른다). cam 이 null 이면 Camera.main.
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 s_sheetMats = new Dictionary();
/// 시트 1장당 머티리얼 1개(런타임 인스턴스 · 디스크 에셋 0). 시트가 섞여도 서로 안 덮는다.
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();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
if (!WLSpriteLookSettings.Enabled) return;
EnsureRunner();
}
/// 프로브용 — 카운터 초기화(적용 상태는 유지).
public static void ResetDiagnostics()
{
Applies = Restores = Attached = Detached = HubEvents = Sweeps = FrameChanges = 0;
LastLog = "";
s_faulted = false;
}
}
/// 스프라이트 교체의 시간 축. 숨김 GameObject 1개 · 코루틴 0.
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"); }
}
/// 🔴 에디터 전용 안전망 — 플레이모드를 벗어날 때 반드시 원복한다.
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
}
}