// ───────────────────────────────────────────────────────────────────────────── // WLIslandLook.cs — **실제 게임이 로드하는 섬 씬**(FarmingIsland/Scenes/Level01)에 // 데모 룩과 풀밭을 런타임으로 얹는다 (WL-816e · #816) // // ■ 왜 필요한가(발주서 §0) // 816a 는 복사본 씬 `Assets/WL/Look/Farm/Scenes/WL_FarmLook.unity` 에만 룩을 저장했다. // 게임은 `Level01` 을 로드하므로 PD 캡처의 섬은 여전히 **원본 색**(쨍한 파란 하늘·원색 초록)이었다. // → 원본 씬을 고치지 않고(`Assets/FarmingIsland/**` 0줄) **로드 직후** 얹는다. // // ■ 걸리는 지점 // `[RuntimeInitializeOnLoadMethod]` + `SceneManager.sceneLoaded` — 기존 파일 **0줄 수정**. // (816d 의 `WLIslandBridge` 를 고치지 않는다 = 다른 worktree 와 충돌 0) // // ■ C8 되돌리기 — `WLIslandLookSettings.enabled_ = 0` → 아무것도 얹지 않는다. // 씬·머티리얼·프리팹 **파일을 쓰지 않으므로** 복원 작업 자체가 없다. // // 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업). // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.SceneManagement; using CryingSnow.FarmingIsland; using FIIsland = CryingSnow.FarmingIsland.Island; using FISoil = CryingSnow.FarmingIsland.Soil; using FIFarm = CryingSnow.FarmingIsland.Farm; namespace WL.Look.Farm { /// 섬 씬에 룩·풀밭을 얹는 정적 진입점. public static class WLIslandLook { public const string RunnerName = "~WLIslandLookRunner"; public const string ReferenceLookName = "WL_ReferenceLook"; // ── 진단 ──────────────────────────────────────────────────────── public static int SwappedRenderers, SwappedSlots, Rescans, LightingApplied, GrassRebuilds; public static bool LookApplied, ReferenceLookApplied, GrassSpawned; public static string LastLog = ""; static bool s_installed; static WLIslandLookRunner s_runner; static readonly HashSet s_done = new HashSet(); static readonly HashSet s_hooked = new HashSet(); [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] public static void Install() { if (s_installed) return; var cfg = WLIslandLookSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; s_installed = true; SceneManager.sceneLoaded += OnSceneLoaded; SceneManager.sceneUnloaded += OnSceneUnloaded; for (int i = 0; i < SceneManager.sceneCount; i++) { var sc = SceneManager.GetSceneAt(i); if (sc.isLoaded) OnSceneLoaded(sc, LoadSceneMode.Additive); } } /// 에디터 프로브가 Play 중에 다시 걸 때 쓴다. public static void ForceApply(Scene scene) { var cfg = WLIslandLookSettings.Instance; if (cfg == null) return; s_done.Clear(); EnsureRunner(); s_runner.StartCoroutine(Co_Apply(cfg, scene)); } static void OnSceneLoaded(Scene scene, LoadSceneMode mode) { var cfg = WLIslandLookSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; if (!IsIslandScene(cfg, scene)) return; EnsureRunner(); s_runner.StartCoroutine(Co_Apply(cfg, scene)); } static void OnSceneUnloaded(Scene scene) { s_done.Clear(); s_hooked.Clear(); s_tonedFarms.Clear(); LookApplied = false; GrassSpawned = false; ReferenceLookApplied = false; } static bool IsIslandScene(WLIslandLookSettings cfg, Scene scene) { if (!scene.IsValid() || !scene.isLoaded) return false; if (cfg.islandSceneNames != null) for (int i = 0; i < cfg.islandSceneNames.Length; i++) if (scene.name == cfg.islandSceneNames[i]) return true; // 이름을 몰라도 IslandManager 가 있으면 섬이다. var roots = scene.GetRootGameObjects(); for (int i = 0; i < roots.Length; i++) if (roots[i].GetComponentInChildren(true) != null) return true; return false; } static void EnsureRunner() { if (s_runner != null) return; var go = new GameObject(RunnerName); go.hideFlags = HideFlags.DontSave; Object.DontDestroyOnLoad(go); s_runner = go.AddComponent(); } // ───────────────────────────────────────────────────────────────── // 본체 // ───────────────────────────────────────────────────────────────── static IEnumerator Co_Apply(WLIslandLookSettings cfg, Scene scene) { yield return null; // FI 매니저들의 Awake 를 기다린다 if (cfg.disableFiGraphicsManager != 0) DisableGraphicsManager(scene); if (cfg.applyLook != 0) { SwapMaterials(cfg, scene); LookApplied = true; } if (cfg.applyLighting != 0) ApplyLighting(cfg, scene); if (cfg.applyLook != 0 && cfg.applyReferenceLook != 0) ApplyReferenceLook(cfg, scene); if (cfg.soilToneEnabled != 0) ToneSoil(cfg, scene); if (cfg.grassEnabled != 0) SpawnGrass(cfg, scene); if (cfg.shoreFoamEnabled != 0) SpawnShoreFoam(cfg, scene); HookIslands(cfg, scene); cfg.Log("섬 룩 적용 — 렌더러 " + SwappedRenderers + " · 슬롯 " + SwappedSlots + " · 조명 " + (cfg.applyLighting != 0 ? "데모" : "원본") + " · ReferenceLook " + (ReferenceLookApplied ? "on" : "off") + " · 풀 " + WLIslandGrass.LastLog + " · 둘레거품 " + WLShoreFoam.LastLog); // 늦게 생기는 오브젝트(상인·작물·아이템)까지 다시 훑는다 if (cfg.rescanAtSeconds != null) { float prev = 0f; for (int i = 0; i < cfg.rescanAtSeconds.Length; i++) { float w = cfg.rescanAtSeconds[i] - prev; prev = cfg.rescanAtSeconds[i]; if (w > 0f) yield return new WaitForSeconds(w); if (!scene.isLoaded) yield break; if (cfg.applyLook != 0) { SwapMaterials(cfg, scene); Rescans++; } // 🔴 816f 실측 — PD 실행 경로(Additive)에서 `RenderSettings` 는 **활성 씬(InGame)** // 소유이고, 그 씬에는 조명을 덮어쓰는 코드(`MapData`)가 없다(실측). // 그래서 조명은 재훑기에서 **다시 걸지 않는다** — 다시 걸면 `WLReferenceLook` 의 // 무드(Flat)를 Skybox 로 되돌려 오히려 룩이 바뀐다(실측으로 확인). if (cfg.soilToneEnabled != 0) ToneSoil(cfg, scene); HookIslands(cfg, scene); } } // 섬 확장 감시 — 새로 열린 타일에도 자동으로 풀이 깔린다 if (cfg.rebuildPollSeconds > 0f) { int last = CountUnlocked(scene); float nextCloud = Time.time + Mathf.Max(0f, cfg.cloudEdgeRefreshSeconds); float nextView = Time.time + Mathf.Max(0f, cfg.viewPollSeconds); float nextPoll = Time.time + cfg.rebuildPollSeconds; while (scene.isLoaded) { // 816q — 카메라 감시는 확장 감시(1 s)보다 촘촘해야 해서 따로 돈다 float wait = cfg.viewCullEnabled != 0 && cfg.viewPollSeconds > 0f ? Mathf.Min(cfg.rebuildPollSeconds, cfg.viewPollSeconds) : cfg.rebuildPollSeconds; yield return new WaitForSeconds(wait); if (!scene.isLoaded) yield break; if (cfg.viewCullEnabled != 0 && cfg.viewPollSeconds > 0f && Time.time >= nextView) { nextView = Time.time + cfg.viewPollSeconds; var gv = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (gv != null && gv.ViewMovedEnough()) gv.RefreshView(); } if (Time.time < nextPoll) continue; nextPoll = Time.time + cfg.rebuildPollSeconds; HookIslands(cfg, scene); int now = CountUnlocked(scene); if (now != last) { last = now; if (cfg.applyLook != 0) SwapMaterials(cfg, scene); RequestGrassRebuild(cfg); nextCloud = Time.time + Mathf.Max(0f, cfg.cloudEdgeRefreshSeconds); continue; } // 816o — 구름이 흐른 만큼 띠를 다시 고른다(타일·제외 사각형은 다시 훑지 않는다) if (cfg.cloudEdgeEnabled != 0 && cfg.cloudEdgeRefreshSeconds > 0f && Time.time >= nextCloud) { nextCloud = Time.time + cfg.cloudEdgeRefreshSeconds; var g = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (g != null) g.RefreshCloudEdges(); } } } } static int CountUnlocked(Scene scene) { var islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); int n = 0; for (int i = 0; i < islands.Length; i++) if (islands[i] != null && islands[i].IsUnlocked && islands[i].gameObject.activeInHierarchy) n++; return n; } /// FI `FIIsland.OnActivated`(public event)에 붙는다 — FI 코드 0줄. static void HookIslands(WLIslandLookSettings cfg, Scene scene) { var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < islands.Length; i++) { var isl = islands[i]; if (isl == null || isl.gameObject.scene != scene) continue; if (s_hooked.Contains(isl)) continue; s_hooked.Add(isl); isl.OnActivated += () => OnIslandActivated(cfg, isl); } } static void OnIslandActivated(WLIslandLookSettings cfg, FIIsland isl) { if (cfg == null || cfg.enabled_ == 0) return; EnsureRunner(); s_runner.StartCoroutine(Co_AfterActivate(cfg, isl)); } static IEnumerator Co_AfterActivate(WLIslandLookSettings cfg, FIIsland isl) { // 확장 애니메이션(DOTween DOScale 1초) + 소품 배치가 끝나기를 기다린다 yield return new WaitForSeconds(cfg.rebuildDelaySeconds); if (isl == null) yield break; if (cfg.applyLook != 0) SwapMaterials(cfg, isl.gameObject.scene); RequestGrassRebuild(cfg); } static void RequestGrassRebuild(WLIslandLookSettings cfg) { if (cfg.grassEnabled != 0) { var g = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (g != null) { g.Rebuild(); GrassRebuilds++; } } // 🔴 816j2 — 섬이 확장되면 **둘레 거품 띠도 같은 훅으로** 다시 만든다(새 가장자리 자동). if (cfg.shoreFoamEnabled != 0) { var f = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (f != null) { f.cfg = cfg; f.Rebuild(); ShoreFoamRebuilds++; } } } public static int ShoreFoamRebuilds; public static bool ShoreFoamSpawned; /// 섬 둘레 거품 띠 — 풀과 같은 자리에서 만들고 같은 훅으로 다시 만든다(816j2). public static void SpawnShoreFoam(WLIslandLookSettings cfg, Scene scene) { var f = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (f == null) { var go = new GameObject(WLShoreFoam.ObjectName); SceneManager.MoveGameObjectToScene(go, scene); go.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); f = go.AddComponent(); f.cfg = cfg; f.Rebuild(); // OnEnable 이 cfg 전에 돌 수 있다 } else { f.cfg = cfg; f.Rebuild(); } ShoreFoamSpawned = true; } // ───────────────────────────────────────────────────────────────── // ① 머티리얼 — 원본을 **읽기만** 하고 렌더러의 sharedMaterials 만 바꾼다 // ───────────────────────────────────────────────────────────────── public static void SwapMaterials(WLIslandLookSettings cfg, Scene scene) { if (cfg.materialRemap == null || cfg.materialRemap.Length == 0) return; var rends = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null || r.gameObject.scene != scene) continue; if (s_done.Contains(r)) continue; if (r is ParticleSystemRenderer) continue; // 🔴 농사 타일은 코드가 `material.color` 로 직접 색을 칠한다(Soil.Initialize). // Toon 셰이더에는 그 프로퍼티가 없어 갈아끼우면 밭 색이 죽는다 → 원본 유지. if (cfg.skipSoilRenderers != 0 && r.GetComponent() != null) { s_done.Add(r); continue; } bool isIslandBody = r.GetComponent() != null; var mats = r.sharedMaterials; bool changed = false; for (int m = 0; m < mats.Length; m++) { var src = mats[m]; if (src == null) continue; Material dst = null; if (isIslandBody && cfg.islandTopMaterial != null) dst = cfg.islandTopMaterial; else dst = Lookup(cfg, src); if (dst == null || dst == src) continue; mats[m] = dst; changed = true; SwappedSlots++; } if (changed) { r.sharedMaterials = mats; SwappedRenderers++; } s_done.Add(r); } } /// /// 🔴 816h — 섬 둘레의 바다(`Water` · 1000×1000 평면)를 **WL 워터 셰이더**로. /// `Assets/FarmingIsland/**` 는 0줄 — 렌더러의 `sharedMaterials` 만 런타임에 바꾼다. /// `waterMode = 0` 이면 이 판정 자체를 건너뛰어 지금(FarmingIsland 물)으로 100 % 돌아간다. /// static bool IsWater(WLIslandLookSettings cfg, Material src) { if (src == null) return false; if (cfg.waterFrom != null && src == cfg.waterFrom) return true; if (src.shader != null && src.shader.name == "Shader Graphs/Simple Water") return true; // 이미 우리 복사본(Farm_SimpleWater_Demo)으로 바뀐 뒤 다시 훑는 경우 return src.name.StartsWith("Farm_SimpleWater"); } public static int WaterSwapped; static Material Lookup(WLIslandLookSettings cfg, Material src) { if (cfg.waterMode != 0 && cfg.waterTo != null && IsWater(cfg, src)) { if (src != cfg.waterTo) WaterSwapped++; return cfg.waterTo; } for (int i = 0; i < cfg.materialRemap.Length; i++) { var e = cfg.materialRemap[i]; if (e == null || e.enabled_ == 0 || e.from == null || e.to == null) continue; if (e.from == src) return e.to; // 런타임 인스턴스("Palette (Instance)")도 잡는다 if (src.name.StartsWith(e.from.name) && src.shader == e.from.shader) return e.to; } return null; } // ───────────────────────────────────────────────────────────────── // ② 조명 · 앰비언트 · 스카이박스 (816a 실측 = 데모/아레나 값) // ───────────────────────────────────────────────────────────────── public static void ApplyLighting(WLIslandLookSettings cfg, Scene scene) { // 🔴 816h — 하늘(앰비언트·스카이박스·안개·반사)은 **활성 씬(InGame)** 소유라 // 여기서 그냥 쓰면 섬에서 나간 뒤 전투 맵·로비까지 그대로 남는다. // 그래서 `WLSkyScope` 가 **스냅샷 → 적용 → 나갈 때 원복** 을 맡는다. // (값은 그대로 이 SO 것 = 아레나/데모 값. 새 값 0) if (cfg.skyScopeEnabled != 0) WLSkyScope.Acquire(cfg, scene.name); else { RenderSettings.ambientMode = AmbientMode.Skybox; RenderSettings.ambientSkyColor = cfg.ambientSky; RenderSettings.ambientEquatorColor = cfg.ambientEquator; RenderSettings.ambientGroundColor = cfg.ambientGround; RenderSettings.ambientLight = cfg.ambientSky; RenderSettings.ambientIntensity = cfg.ambientIntensity; if (cfg.fogOff != 0) RenderSettings.fog = false; if (cfg.skybox != null) RenderSettings.skybox = cfg.skybox; RenderSettings.defaultReflectionMode = DefaultReflectionMode.Skybox; } var lights = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); for (int i = 0; i < lights.Length; i++) { var l = lights[i]; if (l == null || l.gameObject.scene != scene) continue; if (l.type != LightType.Directional) continue; l.color = cfg.dirLightColor; l.intensity = cfg.dirLightIntensity; if (cfg.dirLightSoftShadows != 0 && l.shadows != LightShadows.None) l.shadows = LightShadows.Soft; RenderSettings.sun = l; LightingApplied++; } } // ───────────────────────────────────────────────────────────────── // ②-b 밭(Soil) 칸 표시 — **기능은 그대로 두고 색만** 데모 톤으로 (816f · PD 지시) // FI `Farm` 의 4색(마름1·마름2·젖음1·젖음2)을 런타임에 낮추고 // `Soil.Initialize(farm)`(public) 를 다시 불러 칠만 갱신한다. // → 물주기 피드백(마름↔젖음)·칸 구분은 전부 살아 있다. `Assets/FarmingIsland/**` 0줄. // ───────────────────────────────────────────────────────────────── static readonly string[] s_soilFields = { "soilDryColor1", "soilDryColor2", "soilWetColor1", "soilWetColor2" }; static readonly HashSet s_tonedFarms = new HashSet(); public static int SoilFarmsToned, SoilTilesRepainted; public static void ToneSoil(WLIslandLookSettings cfg, Scene scene) { var farms = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); var t = typeof(FIFarm); for (int i = 0; i < farms.Length; i++) { var farm = farms[i]; if (farm == null || farm.gameObject.scene != scene) continue; if (s_tonedFarms.Contains(farm)) continue; // 1) 마름1/마름2 · 젖음1/젖음2 각각의 평균으로 모으고(대비 축소) 틴트를 곱한다 var vals = new Color[4]; var fis = new System.Reflection.FieldInfo[4]; bool ok = true; for (int k = 0; k < 4; k++) { fis[k] = t.GetField(s_soilFields[k], System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (fis[k] == null) { ok = false; break; } vals[k] = (Color)fis[k].GetValue(farm); } if (!ok) continue; // 「마름」 중점 밝기 = 기준. 젖음은 이 비를 그대로 유지한다(물주기 피드백 보존). float dryLum = SoilLum((vals[0] + vals[1]) * 0.5f); Apply(cfg, fis, farm, vals, 0, 1, dryLum); // 마름 한 쌍 Apply(cfg, fis, farm, vals, 2, 3, dryLum); // 젖음 한 쌍 s_tonedFarms.Add(farm); SoilFarmsToned++; // 2) 이미 칠해진 타일을 다시 칠한다 — FI 의 public 진입점 그대로 var soils = farm.GetComponentsInChildren(true); for (int s = 0; s < soils.Length; s++) { if (soils[s] == null) continue; try { soils[s].Initialize(farm); SoilTilesRepainted++; } catch { } } } } static void Apply(WLIslandLookSettings cfg, System.Reflection.FieldInfo[] fis, FIFarm farm, Color[] v, int a, int b, float dryLum) { var mid = (v[a] + v[b]) * 0.5f; float c = cfg.soilCheckerContrast; var ca = Mul(Color.Lerp(mid, v[a], c), cfg.soilTint); var cb = Mul(Color.Lerp(mid, v[b], c), cfg.soilTint); // 816r — 목표 흙색으로 통째로 옮긴다. 칸 차이(ca−mid, cb−mid)와 마름↔젖음 밝기비는 보존. if (cfg.soilTargetEnabled != 0 && dryLum > 0.0001f) { var baseMid = Mul(mid, cfg.soilTint); float k = SoilLum(mid) / dryLum; // 마름 = 1 · 젖음 = 젖음/마름 var t = cfg.soilTargetColor * k; ca = new Color(t.r + (ca.r - baseMid.r), t.g + (ca.g - baseMid.g), t.b + (ca.b - baseMid.b), ca.a); cb = new Color(t.r + (cb.r - baseMid.r), t.g + (cb.g - baseMid.g), t.b + (cb.b - baseMid.b), cb.a); } fis[a].SetValue(farm, ca); fis[b].SetValue(farm, cb); } static Color Mul(Color a, Color b) { return new Color(a.r * b.r, a.g * b.g, a.b * b.b, a.a); } static float SoilLum(Color c) { return 0.299f * c.r + 0.587f * c.g + 0.114f * c.b; } // ───────────────────────────────────────────────────────────────── // ③ 아레나와 같은 외곽선·대비·무드 (씬 비의존 — 816a 실측) // ───────────────────────────────────────────────────────────────── public static void ApplyReferenceLook(WLIslandLookSettings cfg, Scene scene) { var existing = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (existing != null) { if (!existing.gameObject.activeSelf) existing.gameObject.SetActive(true); ReferenceLookApplied = true; return; } var go = new GameObject(ReferenceLookName); SceneManager.MoveGameObjectToScene(go, scene); go.AddComponent(); // OnEnable 이 적용한다 ReferenceLookApplied = true; } // ───────────────────────────────────────────────────────────────── // ④ 풀밭 // ───────────────────────────────────────────────────────────────── public static void SpawnGrass(WLIslandLookSettings cfg, Scene scene) { var g = Object.FindFirstObjectByType(FindObjectsInactive.Include); if (g == null) { var go = new GameObject(WLIslandGrass.ObjectName); SceneManager.MoveGameObjectToScene(go, scene); go.transform.SetPositionAndRotation(Vector3.zero, Quaternion.identity); g = go.AddComponent(); // OnEnable 이 만든다 g.cfg = cfg; } else { g.cfg = cfg; g.Rebuild(); } GrassSpawned = true; } // ───────────────────────────────────────────────────────────────── // ⑤ FI GraphicsManager — Play 중 FI 의 URP 에셋 **파일**을 고친다(816a 실측) // 우리 활성 URP 가 아니라 화면 영향 0. `Assets/FarmingIsland/**` 0줄을 지키려면 꺼야 한다. // ───────────────────────────────────────────────────────────────── static void DisableGraphicsManager(Scene scene) { var roots = scene.GetRootGameObjects(); for (int i = 0; i < roots.Length; i++) { var comps = roots[i].GetComponentsInChildren(true); for (int c = 0; c < comps.Length; c++) { if (comps[c] == null) continue; if (comps[c].GetType().Name != "GraphicsManager") continue; comps[c].enabled = false; comps[c].gameObject.SetActive(false); } } } } /// 코루틴 숙주. 씬을 넘어 살아남는다. public sealed class WLIslandLookRunner : MonoBehaviour { } }