// ───────────────────────────────────────────────────────────────────────────── // EnvLook.cs — Critter Environment 룩 런타임 스왑 (인게임 맵 한정 · 원본 훅 0 · 에셋 파일 수정 0) // // PD 지시 #814 · 발주서 WL-814c §1-3/4/5/6 (2026-09-10) // // ■ 무엇을 하나 (맵 진입 경계 = 814a PixelLook / 811q EffectWarmup 과 **같은 판정**) // ① 스왑 : SO 표(원본 머티리얼 → Toon 머티리얼)에 **있는 것만** sharedMaterials 를 갈아끼운다. // 표에 없는 파티클·이펙트·UI 머티리얼은 절대 건드리지 않는다. // ② 새 스폰 : InGameInfo.tf_Objs 직속 자식 중 **처음 보는 인스턴스만** 훑어 같은 스왑을 적용(813m3 방식). // ③ 외곽선 : URP 렌더러의 PixelOutlineSetupFeature 를 SetActive(true) — 깊이·노멀 텍스처를 켠다. // Toon 셰이더가 그 텍스처로 오브젝트 안에서 1px 외곽선을 그린다. // ④ 잔디 : 지면 메시에 Critter MeshInstancesBehaviour 를 런타임 부착(GPU 인스턴싱 · 렌더 전용). // ⑤ 물 : 물 렌더러를 Critter Water 머티리얼로 스왑(반사 카메라는 붙이지 않는다 = 성능). // 맵을 벗어나면 · enabled=0 · OnApplicationQuit · 에디터 플레이모드 종료에서 **전부 원복**. // ■ 814d 허브 브리지 (WL-814e · SO `followLookModeHub` 기본 1) — 맵 진입 자동 적용을 허브 // `LookModeHub.EnvLookOn` 으로 게이트하고 `EnvLookChanged` 를 구독한다(구독 시 현재 값 1회 반영). // `followLookModeHub 0` = 814c 원래 동작(허브 참조 0). // // ■ 🔴 왜 「되돌리기」가 중요한가 // 머티리얼·렌더러 피처는 디스크 위의 프로젝트 에셋이다. 값을 바꾼 채로 두면 PD 가 「Save Project」 를 // 누르는 순간 프로젝트가 오염된다. 그래서 // · 원본 .mat 파일에는 어떤 값도 쓰지 않는다(참조만 바꾼다) // · EditorUtility.SetDirty 호출 0 // · 4중 복원: 맵 이탈 · 러너 OnDisable · OnApplicationQuit · playModeStateChanged // // ■ 성능 / GC // Tick 은 「지금이 폴링 시각인가」 float 비교뿐이다(경계에서만 실제 작업). 새 스폰 스윕은 인스턴스 ID // 캐시가 히트하면 할당 0(List·HashSet 재사용). 잔디는 Graphics.DrawMeshInstancedIndirect = 지면당 2 드로우콜. // // ■ C8 롤백 // WLEnvLookSettings.enabled = 0 → 러너 오브젝트 0 · 스왑 0 · 피처 0 · 잔디 0. // // 🔴 Assets/WL/Look/Env/ 에 .asmdef 를 만들지 말 것. // 🔴 이 파일은 Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). // 🔴 Critter 는 전역 네임스페이스 `Environment` 를 쓴다(System.Environment 와 충돌) → global:: 로 명시한다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using WL.Look.Toggle; namespace WL.Look.Env { /// Critter 룩 스왑. 정적 · 새 Manager/Singleton 0(러너는 숨김 GameObject 1개). public static class EnvLook { static WLEnvLookSettings Cfg { get { return WLEnvLookSettings.Instance; } } public static bool Active { get { return WLEnvLookSettings.Enabled; } } // ───────────────────────────────────────── 상태 struct Rec { public Renderer r; public Material[] orig; } static readonly Dictionary s_table = new Dictionary(64); static readonly List s_recs = new List(512); static readonly HashSet s_seenRenderers = new HashSet(); static readonly HashSet s_seenSpawns = new HashSet(); static readonly List s_rbuf = new List(256); static readonly List s_grass = new List(8); static readonly List s_groundBuf = new List(64); static bool s_applied; static bool s_tableBuilt; static ScriptableRendererFeature s_feature; static bool s_featureOrig; static bool s_featureChanged; // ───────────────────────────────────────── 경계 감시 static PCActor s_lastPc; static int s_lastMapId = int.MinValue; static float s_nextPoll; static float s_nextSpawnSweep; static bool s_faulted; // ───────────────────────────────────────── 진단(프로브가 읽는다) public static int Boundaries, Applies, Restores, SkippedLobby; public static int SwappedRenderers, SwappedSlots, RestoredRenderers; public static int SpawnSweeps, SpawnSwapped; public static int GrassObjects, GrassInstances, GrassDrawCalls; public static int WaterSwapped; public static bool IsApplied { get { return s_applied; } } public static int TablePairs { get { return s_table.Count; } } public static bool FeatureActiveNow { get { return s_feature != null && s_feature.isActive; } } public static string FeatureName { get { return s_feature != null ? s_feature.name : ""; } } public static string LastLog = ""; // ═════════════════════════════════════════════════════════════════════ // 표 // ═════════════════════════════════════════════════════════════════════ static void BuildTable(WLEnvLookSettings c) { if (s_tableBuilt) return; s_tableBuilt = true; s_table.Clear(); if (c.originals == null || c.toons == null) return; int n = Mathf.Min(c.originals.Length, c.toons.Length); for (int i = 0; i < n; i++) { var o = c.originals[i]; var t = c.toons[i]; if (o == null || t == null) continue; s_table[o.GetInstanceID()] = t; } } /// SO 를 바꿔치기했을 때 표를 다시 만들게 한다(프로브·에디터 스크립트). public static void InvalidateTable() { s_tableBuilt = false; } /// 프로브·캡처 전용 — 맵 경계 판정 없이 표만 만들고 「적용 중」 상태로 들어간다. public static void BeginForProbe() { var c = Cfg; if (c == null) return; BuildTable(c); s_applied = true; } // ═════════════════════════════════════════════════════════════════════ // 적용 / 복원 // ═════════════════════════════════════════════════════════════════════ /// 맵·캐릭터·잔디·물·외곽선 피처를 한 번에 적용한다. 이미 적용 중이면 아무 것도 하지 않는다. public static bool ApplyNow(string reason) { var c = Cfg; if (c == null || !WLEnvLookSettings.Enabled) return false; if (s_applied) return false; BuildTable(c); s_applied = true; Applies++; Transform mapRoot = FindMapRoot(); // 🔴 물이 먼저다 — 맵 스윕이 「스왑할 것 없음」으로 표시한 렌더러는 다시 보지 않기 때문. if (c.waterEnabled) SwapWater(mapRoot, c); if (c.swapMap && mapRoot != null) SweepRoot(mapRoot); if (c.swapCharacters) SweepCharacters(); if (c.outlineFeature) SetFeature(true); if (c.grassEnabled) AttachGrass(mapRoot, c); s_seenSpawns.Clear(); s_nextSpawnSweep = 0f; if (c.verboseLog) Debug.Log("[WL814c EnvLook] 적용(" + reason + ") 렌더러 " + SwappedRenderers + " · 슬롯 " + SwappedSlots + " · 물 " + WaterSwapped + " · 잔디 " + GrassObjects + "(" + GrassInstances + "개) · 피처 " + FeatureActiveNow); LastLog = "적용 " + reason; return true; } /// 원본 머티리얼로 되돌리고 잔디를 지우고 피처를 원래 상태로 돌린다. public static bool RestoreNow(string reason) { if (!s_applied) return false; s_applied = false; for (int i = 0; i < s_recs.Count; i++) { var rec = s_recs[i]; if (rec.r == null) continue; rec.r.sharedMaterials = rec.orig; RestoredRenderers++; } s_recs.Clear(); s_seenRenderers.Clear(); s_seenSpawns.Clear(); SwappedRenderers = 0; SwappedSlots = 0; WaterSwapped = 0; DetachGrass(); var cc = Cfg; if (cc == null || cc.outlineFeatureOnlyInBattleMap) SetFeature(false); // 인게임 한정이면 끈다 Restores++; var c = Cfg; if (c != null && c.verboseLog) Debug.Log("[WL814c EnvLook] 복원(" + reason + ") 렌더러 " + RestoredRenderers); LastLog = "복원 " + reason; return true; } /// 프로브·A/B 용: 처음 상태로 되돌린다. public static void ResetForProbe() { UnhookHub(); HubEvents = HubGated = 0; // 🔴 814e — 구독을 남기지 않는다(누수 0) RestoreNow("probe-reset"); // 🔴 RestoreNow 는 「적용 중이 아니면」 곧장 돌아가므로 캐시를 여기서 무조건 비운다. // (안 비우면 이전에 훑어 「스왑할 것 없음」으로 표시된 렌더러가 영영 건너뛰어진다) s_seenRenderers.Clear(); s_seenSpawns.Clear(); s_recs.Clear(); s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f; s_nextSpawnSweep = 0f; s_faulted = false; s_tableBuilt = false; s_table.Clear(); Boundaries = Applies = Restores = SkippedLobby = 0; SwappedRenderers = SwappedSlots = RestoredRenderers = 0; SpawnSweeps = SpawnSwapped = 0; GrassObjects = GrassInstances = GrassDrawCalls = 0; WaterSwapped = 0; LastLog = ""; } // ═════════════════════════════════════════════════════════════════════ // 스왑 // ═════════════════════════════════════════════════════════════════════ /// 루트 아래 렌더러 전수 스왑(표에 있는 슬롯만). 프로브가 직접 부를 수 있다. public static int SweepRoot(Transform root) { if (root == null) return 0; s_rbuf.Clear(); root.GetComponentsInChildren(true, s_rbuf); int n = 0; for (int i = 0; i < s_rbuf.Count; i++) if (SwapRenderer(s_rbuf[i])) n++; return n; } /// 렌더러 하나. 표에 걸리는 슬롯이 하나도 없으면 아무 것도 하지 않는다(할당 0). public static bool SwapRenderer(Renderer r) { if (r == null || r is ParticleSystemRenderer) return false; int id = r.GetInstanceID(); if (s_seenRenderers.Contains(id)) return false; s_seenRenderers.Add(id); var cur = r.sharedMaterials; bool any = false; for (int i = 0; i < cur.Length; i++) { var m = cur[i]; if (m != null && s_table.ContainsKey(m.GetInstanceID())) { any = true; break; } } if (!any) return false; var orig = new Material[cur.Length]; System.Array.Copy(cur, orig, cur.Length); var next = new Material[cur.Length]; int slots = 0; for (int i = 0; i < cur.Length; i++) { Material t; if (cur[i] != null && s_table.TryGetValue(cur[i].GetInstanceID(), out t)) { next[i] = t; slots++; } else next[i] = cur[i]; } r.sharedMaterials = next; s_recs.Add(new Rec { r = r, orig = orig }); SwappedRenderers++; SwappedSlots += slots; return true; } static void SweepCharacters() { var pc = MyValue.MyPC; if (!DSUtil.CheckNull(pc)) SweepRoot(pc.transform.root); var objs = MobRoot(); if (objs != null) SweepRoot(objs); } static Transform MobRoot() { var info = InGameInfo.Ins; if (DSUtil.CheckNull(info)) return null; return info.tf_Objs; } /// 맵 루트 = MapData 싱글턴의 게임오브젝트(LoadMapMgr 이 만든 go_map). public static Transform FindMapRoot() { var md = Object.FindFirstObjectByType(FindObjectsInactive.Include); return md != null ? md.transform : null; } // ═════════════════════════════════════════════════════════════════════ // 새 스폰(813m3 풀 루트 스윕 방식) — 캐시 히트 시 할당 0 // ═════════════════════════════════════════════════════════════════════ /// 몹 루트의 직속 자식 중 처음 보는 것만 스왑한다. public static int SweepNewSpawns() { if (!s_applied) return 0; var c = Cfg; if (c == null || !c.swapCharacters) return 0; var root = MobRoot(); if (root == null) return 0; SpawnSweeps++; int n = 0; int cnt = root.childCount; for (int i = 0; i < cnt; i++) { var ch = root.GetChild(i); if (ch == null) continue; int id = ch.GetInstanceID(); if (s_seenSpawns.Contains(id)) continue; s_seenSpawns.Add(id); n += SweepRoot(ch); } SpawnSwapped += n; return n; } // ═════════════════════════════════════════════════════════════════════ // 물 // ═════════════════════════════════════════════════════════════════════ static void SwapWater(Transform mapRoot, WLEnvLookSettings c) { if (mapRoot == null || c.waterMaterial == null) return; s_rbuf.Clear(); mapRoot.GetComponentsInChildren(true, s_rbuf); for (int i = 0; i < s_rbuf.Count; i++) { var r = s_rbuf[i]; if (r == null || r is ParticleSystemRenderer) continue; if (!IsWater(r)) continue; int id = r.GetInstanceID(); if (s_seenRenderers.Contains(id)) continue; s_seenRenderers.Add(id); var cur = r.sharedMaterials; var orig = new Material[cur.Length]; System.Array.Copy(cur, orig, cur.Length); var next = new Material[cur.Length]; for (int k = 0; k < next.Length; k++) next[k] = c.waterMaterial; r.sharedMaterials = next; s_recs.Add(new Rec { r = r, orig = orig }); WaterSwapped++; } } static bool IsWater(Renderer r) { if (r.name.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0) return true; var m = r.sharedMaterial; if (m != null && m.name.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0) return true; if (m != null && m.shader != null && m.shader.name.IndexOf("water", System.StringComparison.OrdinalIgnoreCase) >= 0) return true; return false; } // ═════════════════════════════════════════════════════════════════════ // 외곽선 렌더러 피처 // ═════════════════════════════════════════════════════════════════════ /// 깊이·노멀 텍스처를 켜는 Critter 피처. 없으면 아무 것도 하지 않는다(외곽선만 안 나온다). public static bool SetFeature(bool on) { var f = FindFeature(); if (f == null) return false; if (on) { if (!s_featureChanged) { s_featureOrig = f.isActive; s_featureChanged = true; } if (!f.isActive) f.SetActive(true); // 🔴 SetDirty 0 return true; } if (!s_featureChanged) return false; f.SetActive(s_featureOrig); s_featureChanged = false; return true; } static ScriptableRendererFeature FindFeature() { if (s_feature != null) return s_feature; var all = Resources.FindObjectsOfTypeAll(); if (all != null && all.Length > 0) s_feature = all[0]; return s_feature; } // ═════════════════════════════════════════════════════════════════════ // 잔디 인스턴싱 // ═════════════════════════════════════════════════════════════════════ /// 지면 메시(면적 큰 순)에 Critter MeshInstancesBehaviour 를 붙인다. 렌더 전용(NavMesh·충돌 무관). public static int AttachGrass(Transform mapRoot, WLEnvLookSettings c) { if (mapRoot == null || c == null) return 0; if (c.grassMesh == null || c.grassMaterial == null) return 0; s_groundBuf.Clear(); mapRoot.GetComponentsInChildren(true, s_groundBuf); // 기준점 = 메인 PC(없으면 맵 원점). 잔디 예산을 「플레이어가 보는 지면」에 몰아준다. Vector3 refPos = mapRoot.position; var pcRef = MyValue.MyPC; if (!DSUtil.CheckNull(pcRef)) refPos = pcRef.transform.position; var cands = new List(); for (int i = 0; i < s_groundBuf.Count; i++) { var mr = s_groundBuf[i]; if (mr == null || !mr.gameObject.activeInHierarchy) continue; var mf = mr.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; if (IsWater(mr)) continue; // 물 위에 잔디를 심지 않는다 var b = mr.bounds; if (b.size.x * b.size.z < c.minGroundFootprint) continue; if (b.size.y > c.maxGroundBoundsHeight) continue; // 배경 산·절벽 제외 if (b.size.y > c.groundFlatness * Mathf.Min(b.size.x, b.size.z)) continue; // 나무 수관·바위 제외(평평한 것만) cands.Add(mr); } cands.Sort((a, b) => SqrTo(a.bounds, refPos).CompareTo(SqrTo(b.bounds, refPos))); int made = 0; int budget = c.maxInstances; for (int i = 0; i < cands.Count && made < c.maxGroundMeshes && budget > 0; i++) { int used = AttachOne(cands[i], c, budget); if (used > 0) { made++; budget -= used; } } GrassObjects = made; GrassDrawCalls = made * 2; // 잔디 1 + 꽃 1 (DrawMeshInstancedIndirect) return made; } static float SqrTo(Bounds b, Vector3 p) { return (b.ClosestPoint(p) - p).sqrMagnitude; } /// 목표 높이(m) ÷ 메시 높이. 메시가 없거나 목표가 0 이면 SO 의 원시 Scale. public static float ScaleFor(Mesh m, WLEnvLookSettings c) { if (m == null || c == null || c.grassHeightMeters <= 0f) return c != null ? c.grassScale : 1f; float h = m.bounds.size.y; if (h <= 0.0001f) return c.grassScale; return c.grassHeightMeters / h; } static int AttachOne(MeshRenderer ground, WLEnvLookSettings c, int budget) { var mf = ground.GetComponent(); var mesh = mf.sharedMesh; Mesh surface = mesh != null && mesh.isReadable ? mesh : BuildProbeMesh(ground, c); if (surface == null) return 0; float area; try { area = global::Environment.Utilities.MeshUtilities.GetMeshArea(surface); } catch { return 0; } if (area <= 0f) return 0; // 🔴 성긴 면(나무 수관·바위)은 지면이 아니다 — 메시 면적이 수평 투영 면적에 못 미치면 건너뛴다. var gb = ground.bounds; float footprint = gb.size.x * gb.size.z; if (footprint > 0f && area < c.groundAreaRatio * footprint) return 0; float ratio = Mathf.Max(0.01f, c.grassDensity); float density = Mathf.Max(0.01f, c.demoDensity / ratio); // Critter Density = 면적/인스턴스 int want = Mathf.CeilToInt(area / density); if (want > budget) { density = area / Mathf.Max(1, budget); want = budget; } if (want <= 0) return 0; var go = new GameObject("[WL814c] Grass_" + ground.name); go.SetActive(false); // 🔴 필드를 다 채운 뒤에 OnEnable 이 돌게 한다 go.transform.SetParent(ground.transform, false); go.layer = 0; // Critter 인스턴싱은 Default 레이어로 그린다 var gmf = go.AddComponent(); gmf.sharedMesh = surface; var gmr = go.AddComponent(); gmr.sharedMaterial = ground.sharedMaterial; // 빈 슬롯 마젠타 방지(813m3) gmr.enabled = false; // 지면을 두 번 그리지 않는다 gmr.shadowCastingMode = ShadowCastingMode.Off; var mib = go.AddComponent(); mib.UseSubMesh = false; mib.Density = density; var settings = new List(2); settings.Add(new global::Environment.Instancing.InstancingSettings { Mesh = c.grassMesh, Material = c.grassMaterial, Probability = c.grassProbability, Scale = ScaleFor(c.grassMesh, c), NormalOffset = c.grassNormalOffset }); if (c.flowerMesh != null && c.flowerMaterial != null) settings.Add(new global::Environment.Instancing.InstancingSettings { Mesh = c.flowerMesh, Material = c.flowerMaterial, Probability = c.flowerProbability, Scale = ScaleFor(c.flowerMesh, c), NormalOffset = c.grassNormalOffset }); mib.InstancingSettings = settings.ToArray(); go.SetActive(true); // 여기서 OnEnable → 인스턴스 생성 s_grass.Add(go); GrassInstances += want; return want; } /// 지면 메시가 읽기 불가일 때: 바운드 위에서 아래로 레이캐스트해 읽기 가능한 격자 메시를 만든다. static Mesh BuildProbeMesh(MeshRenderer ground, WLEnvLookSettings c) { int n = Mathf.Clamp(c.groundProbeGrid, 4, 128); var b = ground.bounds; float top = b.max.y + 20f, len = b.size.y + 60f; var tr = ground.transform; var verts = new Vector3[(n + 1) * (n + 1)]; var ok = new bool[(n + 1) * (n + 1)]; int groundId = ground.gameObject.GetInstanceID(); var hits = new RaycastHit[8]; for (int z = 0; z <= n; z++) for (int x = 0; x <= n; x++) { float wx = Mathf.Lerp(b.min.x, b.max.x, x / (float)n); float wz = Mathf.Lerp(b.min.z, b.max.z, z / (float)n); int idx = z * (n + 1) + x; int cnt = Physics.RaycastNonAlloc(new Vector3(wx, top, wz), Vector3.down, hits, len); float best = float.MaxValue; bool found = false; for (int h = 0; h < cnt; h++) { var col = hits[h].collider; if (col == null) continue; if (col.gameObject.GetInstanceID() != groundId && col.transform.root != tr.root) continue; if (hits[h].distance < best) { best = hits[h].distance; found = true; } } if (!found && cnt > 0) { best = hits[0].distance; found = true; } if (found) { verts[idx] = tr.InverseTransformPoint(new Vector3(wx, top - best, wz)); ok[idx] = true; } else { verts[idx] = tr.InverseTransformPoint(new Vector3(wx, b.min.y, wz)); ok[idx] = false; } } var tris = new List(n * n * 6); for (int z = 0; z < n; z++) for (int x = 0; x < n; x++) { int a = z * (n + 1) + x, bb = a + 1, cc = a + (n + 1), d = cc + 1; if (!ok[a] || !ok[bb] || !ok[cc] || !ok[d]) continue; tris.Add(a); tris.Add(cc); tris.Add(bb); tris.Add(bb); tris.Add(cc); tris.Add(d); } if (tris.Count == 0) return null; var m = new Mesh(); m.name = "[WL814c] GrassSurface_" + ground.name; m.indexFormat = verts.Length > 65000 ? UnityEngine.Rendering.IndexFormat.UInt32 : UnityEngine.Rendering.IndexFormat.UInt16; m.vertices = verts; m.SetTriangles(tris, 0); m.RecalculateNormals(); m.RecalculateBounds(); return m; } static void DetachGrass() { for (int i = 0; i < s_grass.Count; i++) { var go = s_grass[i]; if (go == null) continue; var gmf = go.GetComponent(); if (gmf != null && gmf.sharedMesh != null && gmf.sharedMesh.name.StartsWith("[WL814c]")) { if (Application.isPlaying) Object.Destroy(gmf.sharedMesh); else Object.DestroyImmediate(gmf.sharedMesh); } if (Application.isPlaying) Object.Destroy(go); else Object.DestroyImmediate(go); } s_grass.Clear(); GrassObjects = 0; GrassInstances = 0; GrassDrawCalls = 0; } // ═════════════════════════════════════════════════════════════════════ // 틱 — 프레임당 할당 0 // ═════════════════════════════════════════════════════════════════════ public static void TickForProbe() { Tick(); } public static void EnsureRunnerForProbe() { EnsureRunner(); } internal static void Tick() { if (s_faulted || !WLEnvLookSettings.Enabled) return; var c = Cfg; float now = Time.unscaledTime; if (now >= s_nextPoll) { s_nextPoll = now + Mathf.Max(0.05f, c.pollSeconds); try { PollBoundary(c); } catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외) — " + ex.Message; RestoreNow("fault"); return; } } if (s_applied && c.respawnSweepSeconds > 0f && now >= s_nextSpawnSweep) { s_nextSpawnSweep = now + Mathf.Max(0.1f, c.respawnSweepSeconds); try { SweepNewSpawns(); } catch (System.Exception ex) { s_faulted = true; LastLog = "중단(예외·스윕) — " + ex.Message; RestoreNow("fault"); } } } /// 맵 진입/이탈 경계 — AutoCombatOnEnter / 814a PixelLook 과 같은 규칙. static void PollBoundary(WLEnvLookSettings c) { if (!Application.isPlaying) return; var pc = MyValue.MyPC; if (DSUtil.CheckNull(pc)) { s_lastPc = null; s_lastMapId = int.MinValue; if (s_applied && c.restoreOnExit) RestoreNow("map-exit(no-pc)"); return; } var info = InGameInfo.Ins; if (DSUtil.CheckNull(info)) return; int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; bool pcChanged = !ReferenceEquals(pc, s_lastPc); bool mapChanged = map != s_lastMapId; if (!pcChanged && !mapChanged) return; s_lastPc = pc; s_lastMapId = map; Boundaries++; bool lobby = info.Get_GameMode() == eGameMode.Lobby; if (c.applyOnlyInBattleMap && lobby) { SkippedLobby++; if (s_applied && c.restoreOnExit) RestoreNow("map-exit(lobby)"); return; } MapEnter(); } // ── 814d 허브 브리지 (WL-814e) — SO followLookModeHub 가 1 일 때만 산다 ───────── static bool s_hooked; public static int HubEvents, HubGated; public static bool HubHooked { get { return s_hooked; } } /// 허브 추종 스위치. 에셋이 없으면 false = 814c 원래 동작(허브 참조 0). public static bool FollowHub { get { var c = Cfg; return c != null && c.followLookModeHub; } } /// 맵 진입 적용 — 추종 중이면 허브가 ON 일 때만(프로브가 부르는 경계와 같은 코드). public static bool MapEnter() { if (FollowHub && !LookModeHub.EnvLookOn) { HubGated++; return false; } return ApplyNow("map-enter"); } /// 구독 + 현재 값 1회 반영(이벤트는 「변할 때만」 오므로). 중복 구독 0. public static bool HookHub() { if (s_hooked || !FollowHub) return false; s_hooked = true; LookModeHub.EnvLookChanged -= OnHubEnvLook; LookModeHub.EnvLookChanged += OnHubEnvLook; OnHubEnvLook(LookModeHub.EnvLookOn); return true; } /// 구독 해제(누수 0). 이미 해제되어 있으면 무동작. public static bool UnhookHub() { if (!s_hooked) return false; s_hooked = false; LookModeHub.EnvLookChanged -= OnHubEnvLook; return true; } static void OnHubEnvLook(bool on) { HubEvents++; if (on) ApplyNow("814d 토글"); else RestoreNow("814d 토글"); } // ═════════════════════════════════════════════════════════════════════ // 러너 (숨김 GameObject 1개 · SO off 면 아예 만들지 않는다 = C8) // ═════════════════════════════════════════════════════════════════════ static EnvLookRunner s_runner; internal static void EnsureRunner() { if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; var go = new GameObject("[WL814c] EnvLookRunner"); go.hideFlags = HideFlags.HideAndDontSave; s_runner = go.AddComponent(); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (!WLEnvLookSettings.Enabled) return; EnsureRunner(); var c = Cfg; if (c != null && c.outlineFeature && !c.outlineFeatureOnlyInBattleMap) SetFeature(true); } } /// Critter 룩 스왑의 시간 축. 숨김 GameObject 1개 · 코루틴 0. internal sealed class EnvLookRunner : MonoBehaviour { void Update() { EnvLook.Tick(); } void OnEnable() { EnvLook.HookHub(); } void OnDisable() { EnvLook.UnhookHub(); EnvLook.RestoreNow("runner-disable"); } void OnApplicationQuit() { EnvLook.RestoreNow("app-quit"); } } /// 🔴 에디터 전용 안전망 — 플레이모드를 벗어날 때 반드시 원복한다. internal static class EnvEditorSafetyNet { #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) EnvLook.RestoreNow("exit-playmode"); } #endif } }