Project_WL/Assets/WL/Island/WLIslandGateFlow.cs

831 lines
46 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>초기 상태(밭·던전 숨김) + 골드 납품 발판 + 던전 해금 카운터. 정적 · 씬에 남는 상태 없음.</summary>
public static class WLIslandGateFlow
{
// ── 진단(프로브·보고가 읽는다)
public static int FarmsHidden, RenderersHidden, PlatformsMade, Unlocks;
public static string LastLog = "";
public static Vector3 LastPlatformPos;
/// <summary>해금된 던전 수(PlayerPrefs · FI 세이브를 건드리지 않는다).</summary>
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();
}
/// <summary>다음 던전 1개를 여는 값(표에 없으면 마지막 값을 이어 쓴다).</summary>
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]);
}
/// <summary>표에서 실제로 쓸 수 있는(켜져 있는) 던전 행 수 · `maxDungeons` 로 더 줄일 수 있다.</summary>
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);
WLIslandTileFenceStrip.Apply(cfg, scene); // 2026-09-15 Lead 실측 — 타일 메시에 구워진 난간 제거
// 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;
/// <summary>밭 구역 중심(WL-816zc 플로우가 첫 자리로 쓴다).</summary>
public static Vector3 FarmCenter { get { return s_farmCenter; } }
public static bool HaveFarmCenter { get { return s_haveFarm; } }
/// <summary>
/// 🔴 던전 하나 = **발판 한 자리**(WL-816g · PD 정정 2026-09-13
/// 「던전 열기는 한 번만 · 이후 같은 자리에 또 등장해서는 안 된다」).
/// 값을 치르면 그 자리는 영영 비고, 다음 던전의 발판은 **표가 지정한 다른 자리**에 선다.
/// </summary>
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);
}
/// <summary>좌표 위에서 아래로 쏘아 지면 높이를 맞춘다(맞는 것이 없으면 준 값 그대로).</summary>
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;
}
/// <summary>발판을 지금 해금 상태에 맞게 만들거나 지운다.</summary>
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<CryingSnow.FarmingIsland.PlayerController>();
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<Renderer> s_hiddenRends = new List<Renderer>(128);
static readonly List<Collider> s_hiddenCols = new List<Collider>(128);
static readonly HashSet<FIIsland> s_hooked = new HashSet<FIIsland>();
static bool HideFarms(WLIslandSettings cfg, Scene scene, out Vector3 center)
{
center = Vector3.zero;
if (cfg.hideFarms == 0) return false;
var farms = Object.FindObjectsByType<FIFarm>(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<Renderer>(true);
for (int r = 0; r < rends.Length; r++)
{
if (rends[r] == null || !rends[r].enabled) continue;
if (visible)
{
if (!boundsSet) { b = rends[r].bounds; boundsSet = true; }
else b.Encapsulate(rends[r].bounds);
if (!zoneSet) { zone = rends[r].bounds; zoneSet = true; } // WL-816u — 구역 전체
else zone.Encapsulate(rends[r].bounds);
}
// 🔴 WL-816w ③ — 흙칸(Soil)은 남긴다(밭 자리가 흙으로 보이게). 콜라이더는 아래에서 끈다.
if (IsSoilRenderer(cfg, rends[r])) { SoilRenderersKept++; continue; }
rends[r].enabled = false; s_hiddenRends.Add(rends[r]); RenderersHidden++;
}
var cols = f.GetComponentsInChildren<Collider>(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<Renderer>(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;
}
/// <summary>🔴 WL-816u 진단 — 「열림 구역」(숨긴 밭 전부)의 월드 바운즈. 보고·프로브가 오차를 잰다.</summary>
public static Bounds ZoneBounds;
public static bool ZoneBoundsValid;
/// <summary>WL-816w 진단 — 남겨 둔 흙칸 렌더러 수 · 끈 울타리 렌더러 수.</summary>
public static int SoilRenderersKept, FenceRenderersHidden;
/// <summary>WL-816w ③ — 이 렌더러가 흙칸(FI `Soil`)의 것인가(자기 자신 또는 부모).</summary>
static bool IsSoilRenderer(WLIslandSettings cfg, Renderer r)
{
if (cfg.showFarmSoil == 0 || r == null) return false;
return r.GetComponentInParent<FISoil>(true) != null;
}
/// <summary>
/// 🔴 WL-816w ② — FI 섬 타일의 장식 **울타리**를 화면에서 없앤다(렌더러만 · 원본 0줄).
/// 실측(816w): `IslandManager/Island (0)/Fence01`·`(1)`·`(2)` 3개가 밭 옆 (6·8·10, 0, 4) 에 서 있고
/// **Farm 자식이 아니라 섬 타일 자식**이라 816g 의 밭 숨김이 훑지 못했다.
/// `isl` 이 null 이면 씬 전체, 아니면 그 섬만 훑는다(확장으로 새로 열린 타일).
/// </summary>
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<FIIsland>(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<Transform>(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<Renderer>(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<Collider>(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]);
}
}
}
/// <summary>섬이 나중에 열리면 ① 그 섬의 밭을 다시 숨기고 ② 타일 재질을 기존 타일과 맞춘다(§1).</summary>
static void HookIslandActivation(WLIslandSettings cfg, Scene scene)
{
if (cfg.hideFarms == 0 && cfg.matchNewTileMaterials == 0) return;
var islands = Object.FindObjectsByType<FIIsland>(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;
static IEnumerator Co_HideWorkers(Scene scene)
{
float t = 0f;
while (t < 600f)
{
if (!scene.IsValid() || !scene.isLoaded) yield break;
var ws = Object.FindObjectsByType<WorkerActor>(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에 가깝다) — 늦게 활성화되는 타일·밭까지 덮는다.
for (int k = 0; k < 400; k++)
{
yield return new WaitForSeconds(wait);
if (cfg == null || cfg.hideIslandFences == 0 || !scene.IsValid() || !scene.isLoaded) yield break;
HideIslandFences(cfg, scene, null);
WLIslandTileFenceStrip.Apply(cfg, scene); // 확장·재구성으로 타일 메시가 바뀌면 다시 잘라낸다(멱등)
if (cfg.hideFarms != 0) { Vector3 _c; HideFarms(cfg, scene, out _c); } // 늦게 생긴 밭의 울타리·소품도
}
}
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<FIFarm>(true);
for (int i = 0; i < farms.Length; i++)
{
if (farms[i] == null) continue;
var rends = farms[i].GetComponentsInChildren<Renderer>(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<Collider>(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) return;
var mine = isl.GetComponentsInChildren<Renderer>(true);
if (mine.Length == 0) return;
// 기준 = 이미 열려 있고 **슬롯이 더 많은** 타일(= 외곽선을 받은 타일)
FIIsland refIsl = null;
int bestSlots = 0;
var all = Object.FindObjectsByType<FIIsland>(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<MeshRenderer>();
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<Renderer>(true);
var byPath = new Dictionary<string, Renderer>(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 + ")과 같은 배열로(외곽선 포함)");
}
}
/// <summary>타일 루트 기준 상대 경로(프리팹 인스턴스끼리 짝 짓는 열쇠 · 루트 자신은 빈 문자열).</summary>
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;
}
/// <summary>우리가 끈 것만 되살린다(C8).</summary>
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 재사용
// ─────────────────────────────────────────────────────────────────────
/// <summary>
/// 「골드 납품 발판」 1개. 모양은 **더미**(원판 + 테두리 + 안내)로 충분하다(PD 합의).
/// 지불은 **FI `Purchaser` 원본 프리팹**이 그대로 한다 — 우리는 보이지 않는 더미 `Island` 를
/// 물려 주고 `OnActivated` 만 받는다. 교체는 `platformPrefabPath` 값 하나.
/// </summary>
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";
/// <param name="labelOverride">비우면 `platformLabelFormat`(던전 열기) · 채우면 그 글자를 그대로 띄운다(WL-816zc 단계 문구).</param>
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<WLGoldPlatform>();
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;
}
/// <summary>
/// 선택 사항 — 낮은 원판(발 디딜 자리 표시). 🔴 기본은 **꺼져 있다**(PD: 새 모델 금지).
/// FI 가격판이 바닥에 눕는 판(`Price` · 4×2 m · X 90°)이라 원판을 깔면 **그 판을 덮어 버린다**(실측).
/// </summary>
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<Collider>();
if (col != null) Object.Destroy(col); // 더미 — 길·NavMesh 를 건드리지 않는다
var rd = go.GetComponent<Renderer>();
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<Island>()` 로만 섬을 모은다).
var dummyGo = new GameObject("~WL_GoldPlatformTarget");
dummyGo.SetActive(false); // Awake 를 Activate() 시점까지 미룬다
dummyGo.transform.SetParent(transform, false);
var dummy = dummyGo.AddComponent<FIIsland>();
dummy.HasRoad = true; // 🔴 false 면 Awake 가 비어 있는 roadMesh 를 건드려 죽는다
dummy.IsBridge = false;
dummy.IsUnlocked = false;
var mf = dummyGo.GetComponent<MeshFilter>();
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<BoxCollider>(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;
/// <summary>
/// 🔴 WL-816u — 「**보이는** 프레임」의 월드 바운즈.
/// visibleOnly 면 파티클·트레일·꺼진 렌더러·꺼진 오브젝트를 제외한다(FI Purchaser 는 물보라 파티클을 품고 있다).
/// </summary>
public static bool VisibleBounds(Transform root, bool visibleOnly, out Bounds b)
{
b = new Bounds();
bool set = false;
var rends = root.GetComponentsInChildren<Renderer>(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;
/// <summary>FI 가 섬 구입에 쓰는 **바로 그 프리팹**(가격판·코인 아이콘·물보라·SFX 가 전부 들어 있다).</summary>
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; }
}
}