// ───────────────────────────────────────────────────────────────────────────── // WLIslandGateFlow.cs — 「초기에는 밭·던전을 숨기고, 밭 자리의 발판에 10 골드를 내면 던전이 열린다」 // // PD 지시 원문(2026-09-13 · 발주서 WL-816g §2·§3) // 「최초에는 작물을 심을 수 있는 밭과 던전을 모두 안보이게 하고, // 현재 밭 위치에 골드를 납품할 수 있는 발판 1개만 배치해줘. // 초기 보유 골드는 10 골드이고, 이 발판 위로 올라와 10 골드를 지불하면 던전이 생성되어야 해.」 // // ■ 🔴 PD 정정(2026-09-13) 「**던전 열기 골드 발판은 원래 에셋에 있던 것을 활용하면 돼.**」 // → 발판 = FI 가 잠긴 섬 옆에 세우는 **그 가격판 프리팹 그대로**다(`Purchaser.prefab`). // 모양·숫자·지불 감각이 전부 섬 확장과 같다. **새 모델을 만들지 않는다**(더미 원판은 기본 꺼짐). // 실측: 그 가격판(`Price`)은 루트보다 1 m 앞에서 **바닥에 눕는 4×2 m 판**이다 — 그 자체가 발판이다. // // ■ 🔴 지불 방식 = FI `Purchaser` **그대로 재사용**한다(발주서 §2 지시). // FI 섬 확장과 **똑같은 조작·똑같은 연출**이다 — 발판(트리거) 위에 서 있으면 코인이 초당 // 빨려 들어가고(최대 5초 · 피치가 올라가는 Pop SFX · 가격판이 줄어듦), 다 내면 물보라가 // 터지고 대상이 `Activate()` 된다. 학습 비용 0. // 재사용 방법: `Purchaser.Init(island, price, data)` 가 요구하는 `Island` 자리에 // **보이지 않는 더미 `Island`**(메시 없음)를 물려 주고, 그 `Island.OnActivated`(public event)를 // 받아 「던전 1개 켜기」를 한다. → **FI 코드 0줄 · FI 프리팹 0줄**. // // ■ 되돌리기(C8) — `gateFlowEnabled = 0` 하나면 지금 상태(밭·던전 처음부터 보임) 100 % 복귀. // // 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 팝업 방지). // 🔴 `Assets/FarmingIsland/**` 와 `Assets/Script/**` 는 이 파일 때문에 한 줄도 바뀌지 않는다. // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using FIFarm = CryingSnow.FarmingIsland.Farm; using FIIsland = CryingSnow.FarmingIsland.Island; using FIIslandManager = CryingSnow.FarmingIsland.IslandManager; using FISoil = CryingSnow.FarmingIsland.Soil; using FIPurchaser = CryingSnow.FarmingIsland.Purchaser; using FIPurchaserData = CryingSnow.FarmingIsland.PurchaserData; namespace WL.Island { /// 초기 상태(밭·던전 숨김) + 골드 납품 발판 + 던전 해금 카운터. 정적 · 씬에 남는 상태 없음. public static class WLIslandGateFlow { // ── 진단(프로브·보고가 읽는다) public static int FarmsHidden, RenderersHidden, PlatformsMade, Unlocks; public static string LastLog = ""; public static Vector3 LastPlatformPos; /// 해금된 던전 수(PlayerPrefs · FI 세이브를 건드리지 않는다). public static int UnlockedCount(WLIslandSettings cfg) { if (cfg == null || cfg.gateFlowEnabled == 0) return int.MaxValue; // 꺼져 있으면 전부 보인다 return Mathf.Max(0, PlayerPrefs.GetInt(cfg.dungeonUnlockPrefsKey, 0)); } public static void SetUnlockedCount(WLIslandSettings cfg, int n) { if (cfg == null) return; PlayerPrefs.SetInt(cfg.dungeonUnlockPrefsKey, Mathf.Max(0, n)); PlayerPrefs.Save(); } /// 다음 던전 1개를 여는 값(표에 없으면 마지막 값을 이어 쓴다). public static int PriceFor(WLIslandSettings cfg, int nextIndex) { if (cfg == null || cfg.dungeonUnlockPrices == null || cfg.dungeonUnlockPrices.Length == 0) return 10; int i = Mathf.Clamp(nextIndex, 0, cfg.dungeonUnlockPrices.Length - 1); return Mathf.Max(0, cfg.dungeonUnlockPrices[i]); } /// 표에서 실제로 쓸 수 있는(켜져 있는) 던전 행 수 · `maxDungeons` 로 더 줄일 수 있다. public static int UsableDungeonCount(WLIslandSettings cfg) { if (cfg == null || cfg.dungeons == null) return 0; int n = 0; for (int i = 0; i < cfg.dungeons.Length; i++) if (cfg.dungeons[i] != null && cfg.dungeons[i].enabled_ != 0) n++; if (cfg.maxDungeons > 0) n = Mathf.Min(n, cfg.maxDungeons); return n; } // ───────────────────────────────────────────────────────────────── // 진입점 — 섬 준비가 끝난 뒤 1회 // ───────────────────────────────────────────────────────────────── public static void Apply(WLIslandSettings cfg, Scene scene) { if (cfg == null || cfg.gateFlowEnabled == 0) return; Vector3 farmCenter; bool haveFarm = HideFarms(cfg, scene, out farmCenter); HideIslandFences(cfg, scene, null); // 2026-09-15 Lead 실측(광선·전수 조회) — PD 화면의 「허공에 뜬 울타리·바위」 = 전투 스테이지 「보이는 장벽」(WL815c · HideAndDontSave)이 // 던전 → 섬 전환 뒤에도 남은 것. 섬 진입마다 즉시 풀에 반납한다(슬롯 없으면 비용 0). WL.Combat.Stage.StageBarrier.Clear(); // 2026-09-16 — 던전에서 열린 채 돌아온 장벽 상태부터 닫는다 int barrier = WL.Combat.Stage.StageBarrierVisual.HideImmediate(); if (barrier > 0) WLIslandBridge.Log(cfg, "전투 장벽 그림 " + barrier + "개 즉시 정리(섬 진입)"); WLIslandTileFenceStrip.Apply(cfg, scene); // 2026-09-15 Lead 실측 — 타일 메시에 구워진 난간 제거 HookFenceGuard(cfg, scene); // 2026-09-15 — 그리기 직전마다 fence 렌더러 차단 + 정체 기록 // 2026-09-15 PD 「게임 시작 시 섬에 울타리가 여전히 남아 있다」 — 시작 직후 FI 타일 활성화가 렌더러를 다시 켜므로 // farmReHideDelaySeconds 간격으로 3회 더 훑는다(이미 꺼진 것은 건너뛴다 · 값 0 이면 무동작). if (cfg.hideIslandFences != 0 && WLIslandBridge.Runner != null) WLIslandBridge.Runner.StartCoroutine(Co_ReHideFences(cfg, scene)); // 2026-09-15 PD 「에러 나는 부분 수정」 — 섬에서 원본 일꾼(WorkerActor · Goblin_Baby)이 DropItemInfo 없이 // 매 프레임 NullReference 를 냈다(실측 199회/3초) → 나타나는 즉시 끈다(처음 20초는 0.2 s · 이후 3 s 간격). if (WLIslandBridge.Runner != null) WLIslandBridge.Runner.StartCoroutine(Co_HideWorkers(scene)); HookIslandActivation(cfg, scene); s_farmCenter = farmCenter; s_haveFarm = haveFarm; // WL-816zc ③ — 원본 섬 확장 가격판은 첫 던전 승리 전까지 숨긴다(켜고 끄기만 · FI 순서 그대로). WLIslandExpansionGate.Apply(cfg, scene); RefreshPlatform(cfg, scene); // v2 는 RefreshPlatform 안에서 캠프까지 같이 그린다 — 두 번 그리지 않는다. if (!WLIslandFlowV2.Active(cfg)) WLIslandCampDecor.Refresh(cfg, scene); // WL-816y — 세이브 복원(해금 ≥ 1 이면 캠프 재생성) } static Vector3 s_farmCenter; static bool s_haveFarm; /// 밭 구역 중심(WL-816zc 플로우가 첫 자리로 쓴다). public static Vector3 FarmCenter { get { return s_farmCenter; } } public static bool HaveFarmCenter { get { return s_haveFarm; } } /// /// 🔴 던전 하나 = **발판 한 자리**(WL-816g · PD 정정 2026-09-13 /// 「던전 열기는 한 번만 · 이후 같은 자리에 또 등장해서는 안 된다」). /// 값을 치르면 그 자리는 영영 비고, 다음 던전의 발판은 **표가 지정한 다른 자리**에 선다. /// public static Vector3 PlatformPosFor(WLIslandSettings cfg, int index) { var d = NthEnabled(cfg, index); Vector3 p; if (d != null && d.platformUseFarm == 0) p = d.platformPosition; else if (cfg.platformUseFarmPosition != 0 && s_haveFarm) p = s_farmCenter; else if (d != null) p = d.platformPosition; else p = cfg.platformPosition; p += cfg.platformOffset; return SnapToGround(cfg, p); } /// 좌표 위에서 아래로 쏘아 지면 높이를 맞춘다(맞는 것이 없으면 준 값 그대로). public static Vector3 SnapToGround(WLIslandSettings cfg, Vector3 p) { if (cfg.platformSnapToGround == 0) return p; RaycastHit hit; if (Physics.Raycast(new Vector3(p.x, p.y + 30f, p.z), Vector3.down, out hit, 60f, ~0, QueryTriggerInteraction.Ignore)) return new Vector3(p.x, hit.point.y, p.z); return p; } /// 발판을 지금 해금 상태에 맞게 만들거나 지운다. public static void RefreshPlatform(WLIslandSettings cfg, Scene scene) { // WL-816zc — 초기 플로우 v2 가 켜져 있으면 발판 순서는 단계 표가 정한다(flowV2Enabled = 0 이면 아래 그대로). if (WLIslandFlowV2.Active(cfg)) { WLIslandFlowV2.Refresh(cfg, scene); return; } int unlocked = UnlockedCount(cfg); int usable = UsableDungeonCount(cfg); if (WLGoldPlatform.Instance != null) { Object.Destroy(WLGoldPlatform.Instance.gameObject); WLGoldPlatform.Instance = null; } if (unlocked >= usable) { LastLog = "발판 없음 — 던전 " + unlocked + "/" + usable + " 전부 해금됨"; WLIslandBridge.Log(cfg, LastLog); return; } int price = PriceFor(cfg, unlocked); Vector3 pos = PlatformPosFor(cfg, unlocked); // 🔴 값을 치른 자리에 다음 발판이 곧바로 생기면, **가만히 서 있기만 해도** 던전이 연달아 팔린다 // (골드가 많을 때 실측됨). FI 섬 확장처럼 「한 번 내려왔다가 다시 올라오기」를 요구한다. var runner = WLIslandBridge.Runner; if (cfg.platformRequireStepOff != 0 && runner != null && PlayerInRange(cfg, pos)) { runner.StartCoroutine(Co_SpawnWhenClear(cfg, scene, pos, price, unlocked, usable)); LastLog = "발판 대기 — 플레이어가 자리에서 내려오면 다음 발판(가격 " + price + ")을 놓는다"; WLIslandBridge.Log(cfg, LastLog); return; } var p = WLGoldPlatform.Create(cfg, scene, pos, price, unlocked); if (p != null) { PlatformsMade++; LastPlatformPos = pos; } LastLog = "골드 납품 발판 배치 — " + pos.ToString("F2") + " · 가격 " + price + " · 다음 던전 #" + (unlocked + 1) + "/" + usable; WLIslandBridge.Log(cfg, LastLog); } static IEnumerator Co_SpawnWhenClear(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int unlocked, int usable) { float t = 0f; while (t < cfg.platformStepOffTimeoutSeconds && PlayerInRange(cfg, pos)) { t += Time.unscaledDeltaTime; yield return null; } if (WLGoldPlatform.Instance != null) yield break; // 그 사이 다른 경로로 이미 생겼다 var p = WLGoldPlatform.Create(cfg, scene, pos, price, unlocked); if (p != null) { PlatformsMade++; LastPlatformPos = pos; } LastLog = "골드 납품 발판 배치 — " + pos.ToString("F2") + " · 가격 " + price + " · 다음 던전 #" + (unlocked + 1) + "/" + usable; WLIslandBridge.Log(cfg, LastLog); } public static bool PlayerInRange(WLIslandSettings cfg, Vector3 pos) { var fi = Object.FindFirstObjectByType(); if (fi == null) return false; float r = cfg.platformTriggerSize * 0.5f + 0.6f; var d = fi.transform.position - pos; d.y = 0f; return d.sqrMagnitude <= r * r; } // ───────────────────────────────────────────────────────────────── // 지불 완료 → 던전 1개 생성 // ───────────────────────────────────────────────────────────────── public static void OnPaid(WLIslandSettings cfg, Vector3 platformPos) { if (cfg == null) return; // WL-816zc — 초기 플로우 v2 가 켜져 있으면 「다음 단계」가 이 자리를 이어받는다. if (WLIslandFlowV2.Active(cfg)) { WLIslandFlowV2.OnPaid(cfg, platformPos); return; } int unlocked = UnlockedCount(cfg); int usable = UsableDungeonCount(cfg); if (unlocked >= usable) return; SetUnlockedCount(cfg, unlocked + 1); Unlocks++; var d = NthEnabled(cfg, unlocked); var scene = SceneManager.GetSceneByName(cfg.islandSceneName); if (!scene.IsValid() || !scene.isLoaded) scene = SceneManager.GetActiveScene(); if (d != null) WLIslandBridge.SpawnOneGate(cfg, d, scene); LastLog = "🔴 지불 완료 — 던전 생성 " + (d != null ? d.displayName : "(표 없음)") + " · 해금 " + (unlocked + 1) + "/" + usable + " · 이 자리(" + platformPos.ToString("F1") + ")의 발판은 영영 사라진다"; WLIslandBridge.Log(cfg, LastLog); RefreshPlatform(cfg, scene); WLIslandCampDecor.Refresh(cfg, scene); // WL-816y — 값을 치른 그 자리에 캠프가 남는다 } public static WLDungeonDef NthEnabled(WLIslandSettings cfg, int n) { if (cfg == null || cfg.dungeons == null) return null; int k = 0; for (int i = 0; i < cfg.dungeons.Length; i++) { var d = cfg.dungeons[i]; if (d == null || d.enabled_ == 0) continue; if (k == n) return d; k++; } return null; } // ───────────────────────────────────────────────────────────────── // 밭 숨기기 // 🔴 `GameObject.SetActive(false)` 를 쓰지 않는다 — 나중에 섬이 열릴 때 // `Farm.Animate(instant:false)` 가 그 오브젝트에서 코루틴을 시작하는데, // 꺼져 있으면 Unity 가 「inactive 라 코루틴을 못 돌린다」 에러를 찍는다(콘솔 error 0 위반). // 그래서 **렌더러·콜라이더만** 끈다 = FI 흐름은 그대로 돌고 화면에서만 사라진다. // ───────────────────────────────────────────────────────────────── static readonly List s_hiddenRends = new List(128); static readonly List s_hiddenCols = new List(128); static readonly HashSet s_hooked = new HashSet(); static bool HideFarms(WLIslandSettings cfg, Scene scene, out Vector3 center) { center = Vector3.zero; if (cfg.hideFarms == 0) return false; var farms = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); bool haveCenter = false; // 🔴 WL-816u — 「열림 구역」 = 이 발판이 여는 자리 **전부**(보이는 밭 전체)다. // 816g 는 **처음 만난 밭 하나**의 중심을 썼다 → 밭이 여러 칸이면 구역 정중앙이 아니다(PD 지적). Bounds zone = new Bounds(); bool zoneSet = false; ZoneBoundsValid = false; for (int i = 0; i < farms.Length; i++) { var f = farms[i]; if (f == null) continue; if (scene.IsValid() && f.gameObject.scene != scene) continue; // 발판 자리 = **지금 보이는 밭**(= 해금된 섬 위의 밭) **하나**의 한가운데 bool visible = f.gameObject.activeInHierarchy; Bounds b = new Bounds(); bool boundsSet = false; var rends = f.GetComponentsInChildren(true); for (int r = 0; r < rends.Length; r++) { var rr = rends[r]; if (rr == null) continue; // 🔴 WL-816zf — 구역 바운즈는 **이미 꺼 둔 렌더러까지 센다**. // 3 s 재훑기에서는 밭 렌더러가 전부 꺼져 있어, 켜진 것만 세면 두 번째 훑기부터 // 구역을 통째로 잃는다(ZoneBoundsValid = false → 흙 데칼·발판 자리 소실). // 끈 렌더러도 `bounds` 는 그대로 유효하다. if (visible) { if (!boundsSet) { b = rr.bounds; boundsSet = true; } else b.Encapsulate(rr.bounds); if (!zoneSet) { zone = rr.bounds; zoneSet = true; } // WL-816u — 구역 전체 else zone.Encapsulate(rr.bounds); } if (!rr.enabled) continue; // 🔴 WL-816w ③ — 흙칸(Soil)은 남긴다(밭 자리가 흙으로 보이게). 콜라이더는 아래에서 끈다. // 816zf 데칼이 켜져 있으면 `IsSoilRenderer` 가 false 라 흙칸도 여기서 꺼진다(배타). if (IsSoilRenderer(cfg, rr)) { SoilRenderersKept++; continue; } rr.enabled = false; s_hiddenRends.Add(rr); RenderersHidden++; } var cols = f.GetComponentsInChildren(true); for (int c = 0; c < cols.Length; c++) { if (cols[c] == null || !cols[c].enabled) continue; cols[c].enabled = false; s_hiddenCols.Add(cols[c]); } FarmsHidden++; if (visible && !haveCenter) { center = boundsSet ? new Vector3(b.center.x, b.min.y, b.center.z) : f.transform.position; haveCenter = true; } } // 🔴 WL-816u — 구역 전체의 XZ 정중앙으로 덮어쓴다(값 1개로 816g 동작 복귀). if (zoneSet) { ZoneBounds = zone; ZoneBoundsValid = true; if (cfg.platformCenterOnZone != 0 && haveCenter) center = new Vector3(zone.center.x, zone.min.y, zone.center.z); // 2026-09-15 PD 「흙 지역도 라운딩」 — 밭 사각형 네 모서리 근처의 흙칸을 숨겨(값 soilCornerRadius · 0 이면 무동작) // 흙 자리가 둥글게 읽히게 한다(모서리 자리는 816za 의 풀이 채운다). 콜라이더는 이미 전부 꺼져 있다. if (cfg.showFarmSoil != 0 && cfg.soilCornerRadius > 0f) { for (int i = 0; i < farms.Length; i++) { var f = farms[i]; if (f == null || (scene.IsValid() && f.gameObject.scene != scene)) continue; var rends = f.GetComponentsInChildren(true); for (int r = 0; r < rends.Length; r++) { var rr = rends[r]; if (rr == null || !rr.enabled || !IsSoilRenderer(cfg, rr)) continue; var c = rr.bounds.center; float dx = Mathf.Min(c.x - zone.min.x, zone.max.x - c.x); float dz = Mathf.Min(c.z - zone.min.z, zone.max.z - c.z); if (dx + dz < cfg.soilCornerRadius) { rr.enabled = false; s_hiddenRends.Add(rr); } } } } } return haveCenter; } /// 🔴 WL-816u 진단 — 「열림 구역」(숨긴 밭 전부)의 월드 바운즈. 보고·프로브가 오차를 잰다. public static Bounds ZoneBounds; public static bool ZoneBoundsValid; /// WL-816w 진단 — 남겨 둔 흙칸 렌더러 수 · 끈 울타리 렌더러 수. public static int SoilRenderersKept, FenceRenderersHidden; /// /// WL-816w ③ — 이 렌더러가 흙칸(FI `Soil`)의 것인가(자기 자신 또는 부모). /// 🔴 WL-816zf — 부드러운 흙 데칼(`WLSoftDirt`)이 켜져 있으면 **false 를 돌려 흙 타일을 숨긴다**. /// 각진 1×1 타일과 둥근 데칼이 겹치면 데칼 가장자리 밖으로 타일 모서리가 삐져나온다(배타). /// static bool IsSoilRenderer(WLIslandSettings cfg, Renderer r) { if (cfg.showFarmSoil == 0 || r == null) return false; if (global::WL.Look.Farm.WLSoftDirt.Active) return false; return r.GetComponentInParent(true) != null; } /// /// 🔴 WL-816w ② — FI 섬 타일의 장식 **울타리**를 화면에서 없앤다(렌더러만 · 원본 0줄). /// 실측(816w): `IslandManager/Island (0)/Fence01`·`(1)`·`(2)` 3개가 밭 옆 (6·8·10, 0, 4) 에 서 있고 /// **Farm 자식이 아니라 섬 타일 자식**이라 816g 의 밭 숨김이 훑지 못했다. /// `isl` 이 null 이면 씬 전체, 아니면 그 섬만 훑는다(확장으로 새로 열린 타일). /// static void HideIslandFences(WLIslandSettings cfg, Scene scene, FIIsland isl) { if (cfg == null || cfg.hideIslandFences == 0) return; var pre = cfg.islandFenceNamePrefixes; if (pre == null || pre.Length == 0) return; if (isl != null) { HideFencesUnder(pre, isl.transform); return; } var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < islands.Length; i++) { if (islands[i] == null) continue; if (scene.IsValid() && islands[i].gameObject.scene != scene) continue; HideFencesUnder(pre, islands[i].transform); } } static void HideFencesUnder(string[] pre, Transform root) { // 2026-09-15 PD 「기본 섬에 울타리가 여전히 남아 있다」 — 직접 자식만 보던 것을 **모든 하위**로 넓히고, // 이름 접두어뿐 아니라 이름 **어디든** 접두어 문자열이 들어가면 울타리로 본다(Props/Fence_Wood_01 등). var all = root.GetComponentsInChildren(true); for (int c = 0; c < all.Length; c++) { var ch = all[c]; if (ch == null || ch == root) continue; bool hit = false; for (int p = 0; p < pre.Length; p++) { if (string.IsNullOrEmpty(pre[p])) continue; if (ch.name.IndexOf(pre[p], System.StringComparison.OrdinalIgnoreCase) >= 0) { hit = true; break; } } if (!hit) continue; var rends = ch.GetComponentsInChildren(true); for (int r = 0; r < rends.Length; r++) { if (rends[r] == null || !rends[r].enabled) continue; rends[r].enabled = false; s_hiddenRends.Add(rends[r]); FenceRenderersHidden++; } // 2026-09-15 PD 「플레이어 위치에 투명 벽」 — 렌더러만 끈 울타리의 콜라이더가 보이지 않는 벽으로 남았다 → 같이 끈다. var fcols = ch.GetComponentsInChildren(true); for (int r = 0; r < fcols.Length; r++) { if (fcols[r] == null || !fcols[r].enabled) continue; fcols[r].enabled = false; s_hiddenCols.Add(fcols[r]); } } } /// 섬이 나중에 열리면 ① 그 섬의 밭을 다시 숨기고 ② 타일 재질을 기존 타일과 맞춘다(§1). static void HookIslandActivation(WLIslandSettings cfg, Scene scene) { if (cfg.hideFarms == 0 && cfg.matchNewTileMaterials == 0) return; var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < islands.Length; i++) { var isl = islands[i]; if (isl == null) continue; if (scene.IsValid() && isl.gameObject.scene != scene) continue; if (s_hooked.Contains(isl)) continue; s_hooked.Add(isl); var captured = isl; isl.OnActivated += () => OnIslandActivated(cfg, captured); } } static void OnIslandActivated(WLIslandSettings cfg, FIIsland isl) { if (cfg == null || cfg.gateFlowEnabled == 0) return; if (cfg.hideFarms == 0 && cfg.matchNewTileMaterials == 0) return; var runner = WLIslandBridge.Runner; if (runner == null || isl == null) return; runner.StartCoroutine(Co_ReHide(cfg, isl)); } public static int WorkersHidden; // ───────────────────────────────────────────────────────────────── // 2026-09-15 Lead 실측 — 울타리는 오브젝트 숨김·타일 메시 자르기 뒤에도 화면에 남았고, isVisible 목록에도 안 잡혔다. // 마지막 수단: 카메라가 그리기 **직전**(beginCameraRendering)마다 섬 씬의 렌더러를 훑어 이름 또는 메시 이름에 // 「fence」 가 들어간 것을 끄고, 무엇을 껐는지 `FenceGuardLog` 에 남긴다(정체 확인용). 씬이 내려가면 해제. // ───────────────────────────────────────────────────────────────── public static string FenceGuardLog = ""; public static int FenceGuardHidden; static bool s_guardHooked; static Scene s_guardScene; static WLIslandSettings s_guardCfg; static void HookFenceGuard(WLIslandSettings cfg, Scene scene) { s_guardCfg = cfg; s_guardScene = scene; if (s_guardHooked) return; UnityEngine.Rendering.RenderPipelineManager.beginCameraRendering += OnBeginCameraFenceGuard; s_guardHooked = true; } static void OnBeginCameraFenceGuard(UnityEngine.Rendering.ScriptableRenderContext ctx, Camera cam) { if (s_guardCfg == null || s_guardCfg.hideIslandFences == 0) return; if (!s_guardScene.IsValid() || !s_guardScene.isLoaded) { UnityEngine.Rendering.RenderPipelineManager.beginCameraRendering -= OnBeginCameraFenceGuard; s_guardHooked = false; return; } var rends = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null || !r.enabled) continue; bool hit = r.gameObject.name.IndexOf("fence", System.StringComparison.OrdinalIgnoreCase) >= 0; if (!hit) { var mf = r.GetComponent(); // 🔴 2026-09-16 실측: 난간을 잘라낸 복제본 이름이 「…_nofence」라 「fence」에 걸려 **다리(Bridge_nofence)** 가 매 프레임 꺼졌다 → nofence 는 제외 if (mf != null && mf.sharedMesh != null && mf.sharedMesh.name.IndexOf("fence", System.StringComparison.OrdinalIgnoreCase) >= 0 && mf.sharedMesh.name.IndexOf("nofence", System.StringComparison.OrdinalIgnoreCase) < 0) hit = true; } if (!hit) continue; r.enabled = false; FenceGuardHidden++; var t = r.transform; string path = t.name; while (t.parent != null) { t = t.parent; path = t.name + "/" + path; } if (FenceGuardLog.Length < 1200) FenceGuardLog += " | " + path + " @" + r.transform.position.ToString("F1") + " scn=" + r.gameObject.scene.name; } } static IEnumerator Co_HideWorkers(Scene scene) { float t = 0f; while (t < 600f) { if (!scene.IsValid() || !scene.isLoaded) yield break; var ws = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); for (int i = 0; i < ws.Length; i++) if (ws[i] != null && ws[i].gameObject.activeSelf) { ws[i].gameObject.SetActive(false); WorkersHidden++; } float wait = t < 20f ? 0.2f : 3f; t += wait; yield return new WaitForSeconds(wait); } } static IEnumerator Co_ReHideFences(WLIslandSettings cfg, Scene scene) { float wait = Mathf.Max(0.5f, cfg.farmReHideDelaySeconds); // 섬이 살아 있는 동안 3 s 마다 계속 훑는다(이미 꺼진 것은 건너뛰어 비용 0에 가깝다) — 늦게 활성화되는 타일·밭까지 덮는다. // 2026-09-16 PD 「울타리가 또 보인다(던전 갔다 섬으로 복귀 후)」 실측 원인: 이 훑기가 400회(20분)에서 끝나 그 뒤 복귀한 장벽 그림을 아무도 안 지웠다 // → 섬 씬이 살아 있는 동안 **무한** 반복(3 s 간격 · 지울 게 없으면 비용 0). 장벽 상태(StageBarrier)도 함께 닫아 재표시를 막는다. for (;;) { yield return new WaitForSeconds(wait); if (cfg == null || cfg.hideIslandFences == 0 || !scene.IsValid() || !scene.isLoaded) yield break; HideIslandFences(cfg, scene, null); WL.Combat.Stage.StageBarrier.Clear(); // 던전에서 열린 채 돌아온 장벽 상태 닫기(이미 닫혀 있으면 무동작) WL.Combat.Stage.StageBarrierVisual.HideImmediate(); // 늦게 페이드 중이던 전투 장벽 그림까지(슬롯 없으면 비용 0) WLIslandTileFenceStrip.Apply(cfg, scene); // 확장·재구성으로 타일 메시가 바뀌면 다시 잘라낸다(멱등) if (cfg.hideFarms != 0) { Vector3 _c; HideFarms(cfg, scene, out _c); } // 늦게 생긴 밭의 울타리·소품도 WLIslandExpansionGate.PlacePurchasers(cfg, scene); // 2026-09-16 — 확장 뒤 새로 생긴 FI 가격판도 이웃 칸 가장자리로(표식 있으면 비용 0) } } static IEnumerator Co_ReHide(WLIslandSettings cfg, FIIsland isl) { // 확장 애니메이션(DOScale 1초) + 소품 배치(AnimateSoils 0.05초 × 30칸) + 룩 재적용 // (`WLIslandLookSettings.rebuildDelaySeconds` = 1.4 실측)이 끝나기를 기다린다. yield return new WaitForSeconds(cfg.farmReHideDelaySeconds); if (isl == null) yield break; MatchTileMaterials(cfg, isl); HideIslandFences(cfg, isl.gameObject.scene, isl); // WL-816w ② — 새로 열린 타일의 울타리도 if (cfg.hideFarms == 0) yield break; var farms = isl.GetComponentsInChildren(true); for (int i = 0; i < farms.Length; i++) { if (farms[i] == null) continue; var rends = farms[i].GetComponentsInChildren(true); for (int r = 0; r < rends.Length; r++) { if (rends[r] == null || !rends[r].enabled) continue; if (IsSoilRenderer(cfg, rends[r])) { SoilRenderersKept++; continue; } // WL-816w ③ rends[r].enabled = false; s_hiddenRends.Add(rends[r]); RenderersHidden++; } var cols = farms[i].GetComponentsInChildren(true); for (int c = 0; c < cols.Length; c++) if (cols[c] != null && cols[c].enabled) { cols[c].enabled = false; s_hiddenCols.Add(cols[c]); } } } // ───────────────────────────────────────────────────────────────── // §1 새 타일 경계 색차 — 남은 몫(외곽선 패스) // // 실측(2026-09-13 · Play): // · **바닥 색 자체는 Lead 의 `b1e58d795`(대비 복제 제외)로 해결됐다** — 새로 연 타일도 // 기존 타일과 **같은 머티리얼 인스턴스** `Farm_IslandTop_Demo#83780` 을 쓴다. // · 다만 새로 연 타일은 슬롯이 **1개**뿐이다(기존 타일은 2개 = 바닥 + `WL/HullOutline`). // `WLReferenceLook.Apply` 가 씬 로드 때 **활성 렌더러만** 훑는데 잠긴 타일은 꺼져 있어 // 외곽선 패스를 못 받는다. 그래서 **경계에서 테두리 유무가 갈린다.** // 조치: 열린 직후, 이미 열려 있는 타일의 머티리얼 배열을 **그대로** 물려준다(새 머티리얼 0개). // ───────────────────────────────────────────────────────────────── public static int TilesMatched, SlotsMatched; static void MatchTileMaterials(WLIslandSettings cfg, FIIsland isl) { if (cfg.matchNewTileMaterials == 0 || isl == null || isl.IsBridge) return; // 다리 타일은 원본 재질 유지(2026-09-16) var mine = isl.GetComponentsInChildren(true); if (mine.Length == 0) return; // 기준 = 이미 열려 있고 **슬롯이 더 많은** 타일(= 외곽선을 받은 타일) FIIsland refIsl = null; int bestSlots = 0; var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); for (int i = 0; i < all.Length; i++) { var o = all[i]; if (o == null || o == isl || !o.IsUnlocked || !o.gameObject.activeInHierarchy) continue; if (o.gameObject.scene != isl.gameObject.scene) continue; var r = o.GetComponent(); if (r == null) continue; int n = r.sharedMaterials != null ? r.sharedMaterials.Length : 0; if (n > bestSlots) { bestSlots = n; refIsl = o; } } if (refIsl == null) return; // 🔴 이름으로 짝을 지으면 **타일 본체**(GameObject 이름이 `Island (3)` / `Island (6)` 처럼 다 다르다)가 // 안 맞는다(실측 — 첫 시도에서 본체만 슬롯 1개로 남았다). 루트 기준 **상대 경로**로 짝 짓는다. var refs = refIsl.GetComponentsInChildren(true); var byPath = new Dictionary(refs.Length); for (int j = 0; j < refs.Length; j++) if (refs[j] != null) byPath[RelPath(refIsl.transform, refs[j].transform)] = refs[j]; int changed = 0; for (int i = 0; i < mine.Length; i++) { var m = mine[i]; if (m == null || m is ParticleSystemRenderer) continue; var mm = m.sharedMaterials; if (mm == null || mm.Length == 0 || mm[0] == null) continue; Renderer r; if (!byPath.TryGetValue(RelPath(isl.transform, m.transform), out r) || r == null) continue; var rm = r.sharedMaterials; if (rm == null || rm.Length <= mm.Length || rm[0] != mm[0]) continue; m.sharedMaterials = rm; // 같은 인스턴스 배열 = 픽셀이 같아진다 changed++; SlotsMatched += rm.Length - mm.Length; } if (changed > 0) { TilesMatched++; WLIslandBridge.Log(cfg, "새 타일 재질 맞춤 — " + isl.name + " 렌더러 " + changed + "개를 기존 타일(" + refIsl.name + ")과 같은 배열로(외곽선 포함)"); } } /// 타일 루트 기준 상대 경로(프리팹 인스턴스끼리 짝 짓는 열쇠 · 루트 자신은 빈 문자열). static string RelPath(Transform root, Transform t) { if (t == root) return ""; string s = t.name; var p = t.parent; while (p != null && p != root) { s = p.name + "/" + s; p = p.parent; } return s; } /// 우리가 끈 것만 되살린다(C8). public static void RestoreFarms() { for (int i = 0; i < s_hiddenRends.Count; i++) if (s_hiddenRends[i] != null) s_hiddenRends[i].enabled = true; for (int i = 0; i < s_hiddenCols.Count; i++) if (s_hiddenCols[i] != null) s_hiddenCols[i].enabled = true; s_hiddenRends.Clear(); s_hiddenCols.Clear(); s_hooked.Clear(); } } // ───────────────────────────────────────────────────────────────────── // 골드 납품 발판 — 더미 모양 + FI Purchaser 재사용 // ───────────────────────────────────────────────────────────────────── /// /// 「골드 납품 발판」 1개. 모양은 **더미**(원판 + 테두리 + 안내)로 충분하다(PD 합의). /// 지불은 **FI `Purchaser` 원본 프리팹**이 그대로 한다 — 우리는 보이지 않는 더미 `Island` 를 /// 물려 주고 `OnActivated` 만 받는다. 교체는 `platformPrefabPath` 값 하나. /// public sealed class WLGoldPlatform : MonoBehaviour { public static WLGoldPlatform Instance; public int Price; public int NextDungeonIndex; public FIPurchaser Purchaser; public FIIsland DummyTarget; public bool Paid; public const string ObjectName = "~WL_GoldPlatform"; /// 비우면 `platformLabelFormat`(던전 열기) · 채우면 그 글자를 그대로 띄운다(WL-816zc 단계 문구). public static WLGoldPlatform Create(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int nextIndex, string labelOverride = null) { GameObject root = null; if (!string.IsNullOrEmpty(cfg.platformPrefabPath)) { var prefab = WLIslandAssets.LoadPrefab(cfg.platformPrefabPath); if (prefab != null) root = Object.Instantiate(prefab); } // 🔴 PD 정정(2026-09-13) 「던전 열기 골드 발판은 **원래 에셋에 있던 것**을 활용하면 돼.」 // → 기본값은 **빈 루트 + FI `Purchaser` 프리팹 하나**다. 새 모델을 만들지 않는다. // platformShowPad = 1 로 켜면 예전의 더미 원판이 다시 깔린다(기본 0). if (root == null) root = cfg.platformShowPad != 0 ? BuildPad(cfg) : new GameObject("platform"); root.name = ObjectName; root.transform.position = pos; if (scene.IsValid()) SceneManager.MoveGameObjectToScene(root, scene); var self = root.AddComponent(); self.Price = price; self.NextDungeonIndex = nextIndex; Instance = self; self.AttachPurchaser(cfg, price); // 🔴 가격 숫자는 **FI 가격판 그대로**다(위 프리팹이 「10」을 띄운다). 여기 글자는 **무엇이 열리는지**만 알린다 // — 모델이 아니라 글자 하나고, `platformShowLabel = 0` 이면 FI 원본 모습 100 % 가 된다. if (cfg.platformShowLabel != 0) WLIslandLabel.Attach(root.transform, new Vector3(0f, cfg.platformLabelHeight, 0f), string.IsNullOrEmpty(labelOverride) ? string.Format(cfg.platformLabelFormat, price) : labelOverride); return self; } /// /// 선택 사항 — 낮은 원판(발 디딜 자리 표시). 🔴 기본은 **꺼져 있다**(PD: 새 모델 금지). /// FI 가격판이 바닥에 눕는 판(`Price` · 4×2 m · X 90°)이라 원판을 깔면 **그 판을 덮어 버린다**(실측). /// static GameObject BuildPad(WLIslandSettings cfg) { float r = cfg.platformRadius <= 0f ? 1.6f : cfg.platformRadius; var root = new GameObject("platform"); var baseMat = WLIslandLook.ToonMaterial(cfg.platformColor * 0.55f, cfg.platformUseToon != 0); MakeDisc(root.transform, "rim", new Vector3(0f, 0.005f, 0f), r * 2f, 0.01f, baseMat); return root; } static void MakeDisc(Transform parent, string name, Vector3 pos, float diameter, float height, Material mat) { var go = GameObject.CreatePrimitive(PrimitiveType.Cylinder); go.name = name; go.transform.SetParent(parent, false); go.transform.localPosition = pos; go.transform.localScale = new Vector3(diameter, height * 0.5f, diameter); // Cylinder 는 높이가 2 단위 var col = go.GetComponent(); if (col != null) Object.Destroy(col); // 더미 — 길·NavMesh 를 건드리지 않는다 var rd = go.GetComponent(); if (rd != null && mat != null) rd.sharedMaterial = mat; } // ───────────────────────────────────────────────────────────── // 🔴 FI Purchaser 재사용 // ───────────────────────────────────────────────────────────── void AttachPurchaser(WLIslandSettings cfg, int price) { var mgr = FIIslandManager.Instance; if (mgr == null) { WLIslandBridge.Log(cfg, "발판: IslandManager 없음 — Purchaser 재사용 불가"); return; } var prefab = PurchaserPrefab(mgr); if (prefab == null) { WLIslandBridge.Log(cfg, "발판: FI Purchaser 프리팹을 못 찾았다 — 지불 불가"); return; } // 보이지 않는 더미 `Island` — Purchaser 가 요구하는 자리만 채운다(메시 없음 = 안 보인다). // 🔴 `IslandManager` 의 자식이 **아니다** → 섬 격자·가격·세이브에 절대 끼어들지 않는다 // (`IslandManager.Awake` 는 `GetComponentsInChildren()` 로만 섬을 모은다). var dummyGo = new GameObject("~WL_GoldPlatformTarget"); dummyGo.SetActive(false); // Awake 를 Activate() 시점까지 미룬다 dummyGo.transform.SetParent(transform, false); var dummy = dummyGo.AddComponent(); dummy.HasRoad = true; // 🔴 false 면 Awake 가 비어 있는 roadMesh 를 건드려 죽는다 dummy.IsBridge = false; dummy.IsUnlocked = false; var mf = dummyGo.GetComponent(); if (mf != null) mf.sharedMesh = null; // 메시 0 = 화면에 아무것도 안 나온다 DummyTarget = dummy; dummy.OnActivated += OnPurchaseComplete; var p = Object.Instantiate(prefab, transform.position, Quaternion.identity); p.name = "purchaser"; p.transform.SetParent(transform, true); Purchaser = p; // 🔴 WL-816u — PD 「프레임과 글자 아이콘 크기 모두 50%로 줄여줘」. // **인스턴스 스케일만** 건드린다(프리팹 원본 0줄). 프레임·가격 글자·코인 아이콘이 한 덩어리로 줄어든다. // 스케일은 **바운즈 재기 전에** 적용해야 중심 보정이 줄어든 크기 기준으로 맞는다. float ps = cfg.platformScale > 0.001f ? cfg.platformScale : 1f; AppliedScale = ps; if (ps != 1f) p.transform.localScale = p.transform.localScale * ps; // 🔴 FI 가격판은 프리팹 안에서 **루트보다 앞·아래**에 놓여 있다(실측 2026-09-13: // Frame/Price/Coin 이 전부 y = -0.49 · z = 루트+(-1/0/+1) — 잠긴 섬 자리의 **물 위**에 눕는 판이다). // 섬 위(y≈0)에 그대로 놓으면 **지면 아래로 들어가 안 보인다** → 프리팹 전체의 렌더 범위를 재서 // 「밭 한가운데 · 바닥 바로 위」로 맞춘다. 프리팹은 한 글자도 고치지 않는다. if (cfg.platformCenterOnPlate != 0) { Bounds b; if (VisibleBounds(p.transform, cfg.platformCenterVisibleOnly != 0, out b)) { var t = transform.position; p.transform.position += new Vector3(t.x - b.center.x, t.y + cfg.platformPlateLift - b.min.y, t.z - b.center.z); VisibleBounds(p.transform, cfg.platformCenterVisibleOnly != 0, out PlateBounds); PlateBoundsValid = true; } } // 트리거를 **발판(가격판) 크기**로 줄인다(원본 프리팹은 섬 1칸 8 m 를 덮는 十자 두 개다). // 🔴 WL-816u — 판을 50 % 로 줄여도 **밟는 자리는 그대로**여야 한다(PD 는 크기만 말했다). // size 는 부모(p) 로컬 단위라 스케일만큼 역보정하면 월드 미터가 유지된다. var boxes = p.GetComponentsInChildren(true); float s = cfg.platformTriggerSize <= 0f ? 3.2f : cfg.platformTriggerSize; float inv = 1f / ps; var localCenter = p.transform.InverseTransformPoint(transform.position); for (int i = 0; i < boxes.Length; i++) { if (boxes[i] == null) continue; boxes[i].size = new Vector3(s * inv, cfg.platformTriggerHeight * inv, s * inv); boxes[i].center = new Vector3(localCenter.x, cfg.platformTriggerHeight * inv * 0.5f, localCenter.z); } // 🔴 `Init` 은 `Purchaser.Start()` 보다 먼저여야 한다(Start 가 가격판을 그린다) — 같은 프레임이라 안전. p.Init(dummy, price, new FIPurchaserData(Vector3Int.RoundToInt(transform.position), price)); WLIslandBridge.Log(cfg, "발판: FI Purchaser 재사용 — 가격 " + price + " · 트리거 " + s.ToString("F1") + " m · 더미 Island 연결"); } // ── WL-816u 진단(프로브·보고가 읽는다 · 실측만) public static float AppliedScale = 1f; public static Bounds PlateBounds; public static bool PlateBoundsValid; /// /// 🔴 WL-816u — 「**보이는** 프레임」의 월드 바운즈. /// visibleOnly 면 파티클·트레일·꺼진 렌더러·꺼진 오브젝트를 제외한다(FI Purchaser 는 물보라 파티클을 품고 있다). /// public static bool VisibleBounds(Transform root, bool visibleOnly, out Bounds b) { b = new Bounds(); bool set = false; var rends = root.GetComponentsInChildren(true); for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null) continue; if (visibleOnly) { if (r is ParticleSystemRenderer || r is TrailRenderer || r is LineRenderer) continue; if (!r.enabled || !r.gameObject.activeInHierarchy) continue; } if (!set) { b = r.bounds; set = true; } else b.Encapsulate(r.bounds); } return set; } static System.Reflection.FieldInfo s_purchaserField; /// FI 가 섬 구입에 쓰는 **바로 그 프리팹**(가격판·코인 아이콘·물보라·SFX 가 전부 들어 있다). static FIPurchaser PurchaserPrefab(FIIslandManager mgr) { if (s_purchaserField == null) s_purchaserField = typeof(FIIslandManager).GetField("purchaserPrefab", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic); if (s_purchaserField == null) return null; return s_purchaserField.GetValue(mgr) as FIPurchaser; } void OnPurchaseComplete() { if (Paid) return; Paid = true; var cfg = WLIslandSettings.Instance; var pos = transform.position; var runner = WLIslandBridge.Runner; // 🔴 이 오브젝트는 곧 지워진다 — 코루틴은 씬을 넘어 사는 러너가 돌린다. if (runner != null) runner.StartCoroutine(Co_AfterPaid(cfg, pos)); else WLIslandGateFlow.OnPaid(cfg, pos); } static IEnumerator Co_AfterPaid(WLIslandSettings cfg, Vector3 pos) { // 물보라·DOJump(1초)가 끝난 뒤에 발판을 갈아 끼운다(진행 중 트윈의 대상이 사라지지 않게). float w = cfg != null ? cfg.platformRespawnDelaySeconds : 0.4f; if (w > 0f) yield return new WaitForSeconds(w); WLIslandGateFlow.OnPaid(cfg, pos); } void OnDestroy() { if (Instance == this) Instance = null; } } }