// ─────────────────────────────────────────────────────────────────────────────
// WLIslandFlowV2.cs — WL-816zc 「섬 초기 플로우 v2」
//
// PD 원문(2026-09-15)
// 「원본 에셋과 같이 섬이 확장되는 방식과 순서는 유지하되, 내가 수정한 초기 플로우만 바뀌어야 해.
// <최초 시작 시 밭 생성은 보류>
// ① 캠프파이어 설치 → 상인 설치 → 사과 나무 설치 → 던전 생성
// ② 전투 승리 후 섬 확장 노드 추가 → 이후 원본 에셋의 확장 순서대로 확장시켜야 함.」
//
// ■ 발판은 **한 번에 하나**다. 값을 치르면 그 자리에 그 단계의 설치물이 남고, 다음 단계의 발판이 선다.
// 순서·문구·가격·자리·설치물은 전부 `WLIslandSettings.flowSteps[]` 값이다(코드 수정 0 · C45).
// ■ 어디까지 왔는지는 PlayerPrefs 키 **1개**(`flowStepPrefsKey`)로만 센다.
// 섬으로 돌아오면 이 숫자를 보고 이미 치른 설치물을 다시 놓는다(세이브 복원).
// ■ 원본 섬 확장 가격판(FI `Purchaser`)은 **첫 던전 승리 전까지 숨긴다**(렌더러·콜라이더만 끈다).
// 승리하면 그대로 다시 켜지고, 이후 확장 순서는 **FI 원본**(`Island.Constraint`)이 정한다 — 우리는 켜고 끄기만.
// ■ 되돌리기 = `flowV2Enabled = 0` → 지금까지의 「던전 발판 4개」 방식 그대로.
//
// 🔴 원본(Assets/FarmingIsland/** · Assets/Script/**)은 한 줄도 고치지 않는다 — 인스턴스만 만든다.
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다(ErrorLogHookManager 가 플레이어 팝업을 띄운다).
// ─────────────────────────────────────────────────────────────────────────────
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;
using FIPurchaser = CryingSnow.FarmingIsland.Purchaser;
using FIIsland = CryingSnow.FarmingIsland.Island;
using FIIslandManager = CryingSnow.FarmingIsland.IslandManager;
namespace WL.Island
{
/// 초기 플로우(단계 표) 진행 + 설치물 배치. 정적 · 씬에 남는 상태 없음.
public static class WLIslandFlowV2
{
const string RootName = "~WL_FlowInstalls";
// ── 진단(프로브·보고가 읽는다 · 실측만)
public static int InstallsMade;
public static int LastStep;
public static string LastLog = "";
static readonly List s_made = new List(8);
/// 이 플로우가 지금 동작 중인가(꺼져 있으면 기존 발판 방식이 그대로 돈다).
public static bool Active(WLIslandSettings cfg)
{
return cfg != null && cfg.gateFlowEnabled != 0 && cfg.flowV2Enabled != 0 && StepCount(cfg) > 0;
}
/// 켜져 있는 단계 수.
public static int StepCount(WLIslandSettings cfg)
{
if (cfg == null || cfg.flowSteps == null) return 0;
int n = 0;
for (int i = 0; i < cfg.flowSteps.Length; i++)
if (cfg.flowSteps[i] != null && cfg.flowSteps[i].enabled_ != 0) n++;
return n;
}
/// 켜져 있는 것 중 n 번째 단계(없으면 null).
public static WLFlowStepDef Nth(WLIslandSettings cfg, int n)
{
if (cfg == null || cfg.flowSteps == null || n < 0) return null;
int k = 0;
for (int i = 0; i < cfg.flowSteps.Length; i++)
{
var s = cfg.flowSteps[i];
if (s == null || s.enabled_ == 0) continue;
if (k == n) return s;
k++;
}
return null;
}
/// 지금까지 치른 단계 수(PlayerPrefs 1키 · FI 세이브를 건드리지 않는다).
public static int Step(WLIslandSettings cfg)
{
if (cfg == null || string.IsNullOrEmpty(cfg.flowStepPrefsKey)) return 0;
return Mathf.Max(0, PlayerPrefs.GetInt(cfg.flowStepPrefsKey, 0));
}
public static void SetStep(WLIslandSettings cfg, int n)
{
if (cfg == null || string.IsNullOrEmpty(cfg.flowStepPrefsKey)) return;
PlayerPrefs.SetInt(cfg.flowStepPrefsKey, Mathf.Max(0, n));
PlayerPrefs.Save();
LastStep = Mathf.Max(0, n);
}
/// n 번째 단계의 발판 자리(표가 가리킨 던전 줄의 좌표 · -1 이면 밭 구역 중심).
public static Vector3 PosFor(WLIslandSettings cfg, int n)
{
if (cfg == null) return Vector3.zero;
var s = Nth(cfg, n);
Vector3 p;
if (s == null) p = cfg.platformPosition;
else if (s.platformFromDungeonRow >= 0)
{
var d = cfg.DungeonRow(s.platformFromDungeonRow);
p = d != null ? d.platformPosition : s.platformPosition;
}
else if (cfg.platformUseFarmPosition != 0 && WLIslandGateFlow.HaveFarmCenter) p = WLIslandGateFlow.FarmCenter;
else p = s.platformPosition;
p += cfg.platformOffset;
return WLIslandGateFlow.SnapToGround(cfg, p);
}
// ─────────────────────────────────────────────────────────────────
// 발판 + 설치물 다시 그리기 (섬 준비 직후 · 지불 직후 둘 다 여기로)
// ─────────────────────────────────────────────────────────────────
public static void Refresh(WLIslandSettings cfg, Scene scene)
{
if (!Active(cfg)) return;
int step = Step(cfg);
int total = StepCount(cfg);
LastStep = step;
// ① 이미 치른 단계의 설치물을 다시 놓는다(세이브 복원 · 매번 지우고 새로 놓는다).
ClearInstalls();
InstallsMade = 0;
for (int i = 0; i < step && i < total; i++)
if (Install(cfg, scene, Nth(cfg, i), PosFor(cfg, i))) InstallsMade++;
// 캠프 장식은 전용 코드가 자기 자리(1단계 자리)에 놓는다.
WLIslandCampDecor.Refresh(cfg, scene);
// ② 다음 발판 1개
if (WLGoldPlatform.Instance != null)
{
Object.Destroy(WLGoldPlatform.Instance.gameObject);
WLGoldPlatform.Instance = null;
}
if (step >= total)
{
LastLog = "발판 없음 — 초기 플로우 " + step + "/" + total + " 단계 완료";
WLIslandBridge.Log(cfg, LastLog);
return;
}
var next = Nth(cfg, step);
Vector3 pos = PosFor(cfg, step);
string label = string.Format(cfg.flowLabelFormat, next.label, next.price);
// 🔴 값을 치른 자리에 다음 발판이 곧바로 생기면 **가만히 서 있기만 해도** 연달아 팔린다(816g 실측).
// FI 섬 확장처럼 「한 번 내려왔다가 다시 올라오기」를 요구한다.
var runner = WLIslandBridge.Runner;
if (cfg.platformRequireStepOff != 0 && runner != null && WLIslandGateFlow.PlayerInRange(cfg, pos))
{
runner.StartCoroutine(Co_SpawnWhenClear(cfg, scene, pos, next.price, step, label, total));
LastLog = "발판 대기 — 자리에서 내려오면 " + (step + 1) + "단계 「" + next.label + "」 발판을 놓는다";
WLIslandBridge.Log(cfg, LastLog);
return;
}
Spawn(cfg, scene, pos, next.price, step, label, total);
}
static void Spawn(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int step, string label, int total)
{
var p = WLGoldPlatform.Create(cfg, scene, pos, price, step, label);
if (p != null) { WLIslandGateFlow.PlatformsMade++; WLIslandGateFlow.LastPlatformPos = pos; }
LastLog = "플로우 발판 배치 — " + (step + 1) + "/" + total + " 「" + label.Replace("\n", " ") +
"」 @ " + pos.ToString("F2");
WLIslandBridge.Log(cfg, LastLog);
}
static IEnumerator Co_SpawnWhenClear(WLIslandSettings cfg, Scene scene, Vector3 pos,
int price, int step, string label, int total)
{
float t = 0f;
while (t < cfg.platformStepOffTimeoutSeconds && WLIslandGateFlow.PlayerInRange(cfg, pos))
{
t += Time.unscaledDeltaTime;
yield return null;
}
if (WLGoldPlatform.Instance != null) yield break; // 그 사이 다른 경로로 이미 생겼다
Spawn(cfg, scene, pos, price, step, label, total);
}
// ─────────────────────────────────────────────────────────────────
// 지불 완료 → 그 단계의 설치물이 그 자리에 남는다
// ─────────────────────────────────────────────────────────────────
public static void OnPaid(WLIslandSettings cfg, Vector3 platformPos)
{
if (!Active(cfg)) return;
int step = Step(cfg);
int total = StepCount(cfg);
if (step >= total) return;
var s = Nth(cfg, step);
SetStep(cfg, step + 1);
var scene = SceneManager.GetSceneByName(cfg.islandSceneName);
if (!scene.IsValid() || !scene.isLoaded) scene = SceneManager.GetActiveScene();
// 던전 단계 = 원래의 해금 카운터도 같이 올린다 → 섬 복귀 시 `SpawnGates` 가 입구를 다시 놓는다.
if (s != null && s.kind == "dungeon")
{
WLIslandGateFlow.SetUnlockedCount(cfg, DungeonStepsDone(cfg, step + 1));
WLIslandGateFlow.Unlocks++;
}
LastLog = "🔴 지불 완료 — " + (step + 1) + "/" + total + " 「" + (s != null ? s.label : "?") +
"」 · 이 자리(" + platformPos.ToString("F1") + ")의 발판은 영영 사라진다";
WLIslandBridge.Log(cfg, LastLog);
Refresh(cfg, scene);
}
/// 치른 단계 중 던전 단계가 몇 개인지(= 열려 있어야 할 던전 수).
static int DungeonStepsDone(WLIslandSettings cfg, int stepsDone)
{
int n = 0;
for (int i = 0; i < stepsDone; i++)
{
var s = Nth(cfg, i);
if (s != null && s.kind == "dungeon") n++;
}
return n;
}
// ─────────────────────────────────────────────────────────────────
// 설치물 하나
// ─────────────────────────────────────────────────────────────────
static bool Install(WLIslandSettings cfg, Scene scene, WLFlowStepDef s, Vector3 pos)
{
if (cfg == null || s == null) return false;
if (s.kind == "camp") return false; // 캠프는 WLIslandCampDecor 가 놓는다
if (s.kind == "dungeon")
{
var d = cfg.DungeonRow(s.dungeonRow);
if (d != null) WLIslandBridge.SpawnOneGate(cfg, d, scene); // 이미 있으면 안에서 건너뛴다
// 2026-09-16 PD 「던전 개방 이후에는 기존 에셋과 동일한 순서로 확장 노드」 — 던전 노드가 열리면 원본 확장 가격판을 개방한다.
if (d != null && cfg.expansionUnlockOnDungeonOpen != 0) WLIslandExpansionGate.Unlock(cfg);
// 🔴 2026-09-16 PD 「생성된 오브젝트에 플레이어가 끼임」 — 입구(아치)는 발판 자리에 서 있는 플레이어 위로 생긴다 → 밀어낸다.
if (d != null)
{
var gates = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
for (int i = 0; i < gates.Length; i++) if (gates[i] != null && gates[i].Def == d) { PushPlayerOut(cfg, gates[i].gameObject); break; }
}
return d != null;
}
// kind = prefab
if (string.IsNullOrEmpty(s.prefabPath)) return false;
var root = EnsureRoot(scene);
var go = SpawnPrefab(cfg, s.prefabPath, root.transform, pos, s.yaw, s.scale, s.keepCollider != 0);
if (go == null) return false;
// 덧붙일 것(예: 좌판에 상인이 안 들어 있을 때만 상인을 넣는다) — 원본 프리팹 그대로, 설치물의 자식으로.
if (!string.IsNullOrEmpty(s.extraPrefabPath) && !HasComponentNamed(go, s.extraSkipIfComponent))
{
var rot = Quaternion.Euler(0f, s.yaw, 0f);
SpawnPrefab(cfg, s.extraPrefabPath, go.transform, pos + rot * s.extraOffset,
s.yaw, s.scale, s.keepCollider != 0);
}
PushPlayerOut(cfg, go);
return true;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「가판대가 갑자기 생기며 플레이어가 프랍에 끼어버린다 — 생성 위치에 캐릭터가 있으면 밀려나게」
// 설치물의 월드 범위(렌더러+콜라이더)를 플레이어 반지름만큼 넓힌 상자 안에 플레이어가 있으면, 설치물 중심 → 플레이어 방향으로
// 상자 밖까지 밀어낸다(CharacterController 는 잠시 끄고 옮긴다 · FI 텔레포트와 같은 방식). 높이 = 그 자리 지면(설치물 자신은 제외).
// ─────────────────────────────────────────────────────────────────
public static int PlayerPushes;
public static string LastPushLog = "";
public static void PushPlayerOut(WLIslandSettings cfg, GameObject install)
{
if (cfg.installPushPlayer == 0 || install == null) return;
var pc = Object.FindFirstObjectByType();
if (pc == null) return;
Bounds b; if (!WorldBounds(install, out b)) return;
float margin = Mathf.Max(0.1f, cfg.installPushMargin);
var box = new Bounds(b.center, b.size);
box.Expand(new Vector3(margin * 2f, 100f, margin * 2f));
var p = pc.transform.position;
if (!box.Contains(new Vector3(p.x, b.center.y, p.z))) return;
// 후보 방향: ① 설치물 중심 → 플레이어 ② 섬(열린 타일) 중심 쪽 ③ ±x ④ ±z — **발 디딜 땅이 있는** 첫 방향을 쓴다
// (2026-09-16 실측: 남쪽 끝 사과나무 자리에서 ①만 쓰면 섬 밖 물로 떨어졌다).
var away = new Vector3(p.x - b.center.x, 0f, p.z - b.center.z);
if (away.sqrMagnitude < 1e-4f) away = -pc.transform.forward;
away.y = 0f; if (away.sqrMagnitude < 1e-6f) away = Vector3.back; away.Normalize();
var isl = IslandCenter(); var toIsland = new Vector3(isl.x - b.center.x, 0f, isl.z - b.center.z);
if (toIsland.sqrMagnitude < 1e-4f) toIsland = -away; toIsland.Normalize();
var dirs = new[] { away, toIsland, Vector3.right, Vector3.left, Vector3.forward, Vector3.back };
Vector3 target = p; float t = 0f; bool ok = false; string how = "";
for (int k = 0; k < dirs.Length && !ok; k++)
{
var dir = dirs[k];
float tx = Mathf.Abs(dir.x) > 1e-4f ? ((dir.x > 0f ? box.max.x : box.min.x) - p.x) / dir.x : float.MaxValue;
float tz = Mathf.Abs(dir.z) > 1e-4f ? ((dir.z > 0f ? box.max.z : box.min.z) - p.z) / dir.z : float.MaxValue;
t = Mathf.Min(tx, tz) + 0.15f;
var cand = p + dir * t;
float gy; if (!GroundY(cand, p.y, out gy)) continue; // 땅 없음(섬 밖) → 다음 방향
cand.y = gy; target = cand; ok = true; how = k == 0 ? "바깥쪽" : k == 1 ? "섬 안쪽" : "축 방향 " + k;
}
if (!ok) { LastPushLog = "밀어낼 땅을 못 찾음 — " + install.name; WLIslandBridge.Log(cfg, LastPushLog); return; }
var cc = pc.GetComponent();
if (cc != null) { cc.enabled = false; pc.transform.position = target; cc.enabled = true; }
else pc.transform.position = target;
PlayerPushes++;
LastPushLog = "설치물 " + install.name + " 범위 " + b.size.x.ToString("F1") + "×" + b.size.z.ToString("F1") + " m 안의 플레이어를 " +
p.ToString("F2") + " → " + target.ToString("F2") + " 로 밀어냄(" + t.ToString("F2") + " m · " + how + ")";
WLIslandBridge.Log(cfg, LastPushLog);
}
/// 열린 타일(FI Island · IsUnlocked)의 중심 — 없으면 원점.
static Vector3 IslandCenter()
{
var islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
Vector3 sum = Vector3.zero; int n = 0;
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue;
if (isl.GetComponent() == null || isl.GetComponent().sharedMesh == null) continue; // 더미(발판 타깃) 제외
sum += isl.transform.position; n++;
}
return n > 0 ? sum / n : Vector3.zero;
}
static bool WorldBounds(GameObject go, out Bounds b)
{
b = new Bounds(); bool set = false;
var rends = go.GetComponentsInChildren(true);
for (int i = 0; i < rends.Length; i++)
{
if (rends[i] == null || rends[i] is ParticleSystemRenderer || rends[i] is TrailRenderer) continue;
if (!set) { b = rends[i].bounds; set = true; } else b.Encapsulate(rends[i].bounds);
}
var cols = go.GetComponentsInChildren(true);
for (int i = 0; i < cols.Length; i++)
{
if (cols[i] == null || cols[i].isTrigger) continue;
if (!set) { b = cols[i].bounds; set = true; } else b.Encapsulate(cols[i].bounds);
}
return set;
}
///
/// 그 자리 지면 높이 — 설치물(`~WL_FlowInstalls` 밑) 콜라이더 제외 · 요청 높이 +1 m 보다 높거나 −0.6 m 보다 낮은 맞힘(바다·경사) 무시.
/// 반환 false = 발 디딜 땅 없음(섬 밖).
///
static bool GroundY(Vector3 p, float refY, out float groundY)
{
var hits = Physics.RaycastAll(new Vector3(p.x, refY + 3f, p.z), Vector3.down, 10f, ~0, QueryTriggerInteraction.Ignore);
float best = float.NaN;
for (int i = 0; i < hits.Length; i++)
{
var c = hits[i].collider; if (c == null) continue;
var tr = c.transform; bool ours = false;
while (tr != null) { if (tr.name == RootName) { ours = true; break; } tr = tr.parent; }
if (ours) continue;
float y = hits[i].point.y;
if (y > refY + 1f || y < refY - 0.6f) continue;
if (float.IsNaN(best) || y > best) best = y;
}
groundY = float.IsNaN(best) ? refY : best + 0.02f;
return !float.IsNaN(best);
}
static GameObject EnsureRoot(Scene scene)
{
for (int i = 0; i < s_made.Count; i++)
if (s_made[i] != null && s_made[i].name == RootName) return s_made[i];
var root = new GameObject(RootName);
if (scene.IsValid() && scene.isLoaded) SceneManager.MoveGameObjectToScene(root, scene);
s_made.Add(root);
return root;
}
static GameObject SpawnPrefab(WLIslandSettings cfg, string path, Transform parent, Vector3 pos,
float yaw, float scale, bool keepCollider)
{
var prefab = WLIslandAssets.LoadPrefab(path);
if (prefab == null) { WLIslandBridge.Log(cfg, "플로우 설치물 프리팹 없음 — " + path); return null; }
// 🔴 부모를 주고 만든다 — FI 스크립트(예: `Merchant.Awake` 의 `GetComponentInParent()`)가
// Awake 시점에 부모를 볼 수 있어야 한다. 부모의 씬에 그대로 들어가므로 씬 이동도 필요 없다.
var go = Object.Instantiate(prefab, parent);
go.name = "~WL_Flow_" + prefab.name;
go.transform.position = Snap(cfg, pos);
go.transform.rotation = Quaternion.Euler(0f, yaw, 0f);
float sc = scale > 0.001f ? scale : 1f;
go.transform.localScale = prefab.transform.localScale * sc;
if (!keepCollider)
{
// 장식 = 이동 방해 0 · NavMesh 재베이크 없음(816y 캠프 장식과 같은 원칙).
var cols = go.GetComponentsInChildren(true);
for (int i = 0; i < cols.Length; i++) if (cols[i] != null) cols[i].enabled = false;
}
return go;
}
static bool HasComponentNamed(GameObject go, string typeName)
{
if (go == null || string.IsNullOrEmpty(typeName)) return false;
var comps = go.GetComponentsInChildren(true);
for (int i = 0; i < comps.Length; i++)
if (comps[i] != null && comps[i].GetType().Name == typeName) return true;
return false;
}
static Vector3 Snap(WLIslandSettings cfg, Vector3 p)
{
if (cfg.platformSnapToGround == 0) return p;
// 2026-09-16 PD 「상인이 공중에 떠 있다」 실측: 재훑기마다 다시 스냅하는데, 광선이 **방금 놓은 설치물 자신의 콜라이더**(좌판 3 m 상자) 위를
// 맞혀 한 번 훑을 때마다 3 m 씩 떠올랐다(y 3.00 · 콜라이더 위 6.00 실측). 설치물(`~WL_FlowInstalls` 밑)은 건너뛰고,
// 요청 높이보다 `platformSnapMaxRise` 이상 위로 솟는 맞힘(다른 소품 위)도 무시한다 — 설치물은 타일 윗면에 놓는 것이 목적이다.
var hits = Physics.RaycastAll(new Vector3(p.x, p.y + 30f, p.z), Vector3.down, 60f, ~0, QueryTriggerInteraction.Ignore);
float best = float.NaN;
for (int i = 0; i < hits.Length; i++)
{
var c = hits[i].collider; if (c == null) continue;
var t = c.transform; bool ours = false;
while (t != null) { if (t.name == RootName) { ours = true; break; } t = t.parent; }
if (ours) continue;
float y = hits[i].point.y;
if (y > p.y + Mathf.Max(0f, cfg.platformSnapMaxRise)) continue;
if (float.IsNaN(best) || y > best) best = y;
}
return float.IsNaN(best) ? p : new Vector3(p.x, best, p.z);
}
public static void ClearInstalls()
{
for (int i = 0; i < s_made.Count; i++)
if (s_made[i] != null) Object.Destroy(s_made[i]);
s_made.Clear();
}
}
// ─────────────────────────────────────────────────────────────────────
// 원본 섬 확장 노드 — 첫 던전 승리 전까지 숨김
//
// 🔴 FI 의 확장 **순서·조건은 건드리지 않는다**(`IslandManager.RefreshIsland` · `Island.Constraint` 그대로).
// 우리는 이미 만들어진 가격판의 **렌더러·콜라이더만** 껐다 켠다 = FI 세이브·격자에 흔적 0.
// `SetActive(false)` 를 쓰지 않는 이유는 816g 와 같다(꺼진 오브젝트에서 코루틴이 시작되면 에러).
// ─────────────────────────────────────────────────────────────────────
/// 섬 플레이어 크기를 이미 키웠다는 표식(WLIslandSceneSetup.ApplyPlayerScale · 1회).
public sealed class WLPlayerScaled : MonoBehaviour { }
/// FI 확장 가격판을 이웃 칸 가장자리로 옮겼다는 표식(원래 자리 기억).
public sealed class WLPlacedPurchaser : MonoBehaviour { public Vector3 original; }
public static class WLIslandExpansionGate
{
// ── 진단(프로브·보고가 읽는다 · 실측만)
public static int PurchasersHidden, PurchasersPlaced;
public static string LastLog = "", LastPlaceLog = "";
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-16 PD 「확장 가격판이 맵에 가려져 안 보인다 → 위에 · 확장 방향과 가장 가까운 걸어갈 수 있는 자리」
// FI 는 가격판을 **잠긴 칸 중앙**(이웃 칸 가장자리에서 4 m 바다 쪽 · y −0.5)에 눕힌다 → 우리 해변 경사(모래)가 판의 안쪽 절반을 덮는다.
// → 확장 방향의 이웃(열린) 칸 가장자리에서 `expansionPadInset` 안쪽 지면 위로 옮긴다. FI 의 `activePurchasers` 는 칸 좌표로 기억하므로
// 트랜스폼만 옮겨도 격자·세이브·구매 로직은 그대로고, 트리거 상자(십자 ±5 m)도 함께 옮겨져 걸어가 밟을 수 있다.
// ─────────────────────────────────────────────────────────────────
public static void PlacePurchasers(WLIslandSettings cfg, Scene scene)
{
if (cfg == null || cfg.expansionPadPlace == 0) return;
var mgr = FIIslandManager.Instance;
float size = mgr != null && mgr.IslandSize > 0.1f ? mgr.IslandSize : 8f, half = size * 0.5f;
var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
FIIsland[] islands = null;
for (int i = 0; i < all.Length; i++)
{
var p = all[i];
if (p == null || p.GetComponent() != null) continue;
if (scene.IsValid() && p.gameObject.scene != scene && p.gameObject.scene != SceneManager.GetActiveScene()) continue;
if (p.GetComponentInParent() != null) continue; // 우리 발판
if (islands == null) islands = Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
var pos = p.transform.position; // 잠긴 칸 중앙(FI 원래 자리)
Vector3 target, c;
if (!ComputePadSpot(cfg, pos, islands, size, out target, out c)) continue; // 이웃을 못 찾으면 FI 자리 그대로
float plateDropLocal = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y; // 판 자식들이 루트보다 아래(로컬 −0.49)
// 🔴 2026-09-17 PD 「확장 가격판이 너무 크다 → 캠프파이어 발판과 같은 크기」 = 우리 발판과 같은 배율(platformScale 0.5 = expansionPadScale)
float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f;
p.transform.localScale = Vector3.one * ps;
// 🔴 2026-09-17 PD 「가격판이 경사에 가려지지 않게」 — 판 넓이(반지름 ≈ 2.56×배율) 안 5점의 지형·콜라이더 높이 중 **최고점** 위에 놓고,
// 지형 법선에 맞춰 살짝 눕힌다(최대 expansionPadTiltMax°) → 해변 경사·둔덕 어느 쪽에도 묻히지 않는다.
Quaternion rot;
float gy = PadSurfaceY(new Vector3(target.x, c.y, target.z), c.y, 2.56f * ps, cfg.expansionPadTiltMax, out rot);
p.transform.rotation = rot;
p.transform.position = new Vector3(target.x, gy + plateDropLocal * ps + cfg.expansionPadLift, target.z);
// 🔴 2026-09-17 — 원본 트리거는 십자(10×5×5 두 개 · ±5 m)라 가장자리로 옮긴 판끼리 겹쳐 두 구매가 동시에 시작됐다(원본 활성화 경쟁 → 「Bridge (0) inactive」).
// 판 크기(expansionPadTriggerSize · 월드 m)만큼의 상자 하나로 줄인다 = 판을 밟아야 산다(우리 발판과 같은 규칙). 로컬 치수는 배율로 역보정.
if (cfg.expansionPadTriggerSize > 0.1f)
{
// 🔴 2026-09-17 PD 화면 실측 「NullReference — FI AnimalController.CreateAgent」 = 같은 칸 `Activate()` 2회 → 동물 NavMeshAgent 2회 추가.
// 원인 = 트리거 상자 **두 개**(원본 십자)가 같은 크기로 겹쳐 OnTriggerEnter 가 2번 → BeginPurchase 코루틴 2개가 둘 다 Activate() 호출.
// → 상자는 **하나만** 남긴다(나머지는 끔).
var boxes = p.GetComponents();
float ts = cfg.expansionPadTriggerSize / ps;
for (int b = 0; b < boxes.Length; b++)
{
if (boxes[b] == null) continue;
if (b > 0) { boxes[b].enabled = false; continue; }
boxes[b].enabled = true;
boxes[b].size = new Vector3(ts, 4f / ps, ts);
boxes[b].center = new Vector3(0f, -plateDropLocal + 2f / ps, 0f); // 판 높이에서 위로 4 m(월드)
}
}
if (cfg.expansionPadOverlay != 0) ApplyOverlay(p.gameObject, cfg.expansionPadOverlay >= 2); // 1 = 깊이 검사(캐릭터가 가림) · 2 = 글자까지 항상 위
var mk = p.gameObject.AddComponent(); mk.original = pos;
PurchasersPlaced++;
LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 · 옆으로 " + LastSlide.ToString("F1") + " m · 표면 " + gy.ToString("F2") + " · " + LastFitLog + ")";
WLIslandBridge.Log(cfg, LastPlaceLog);
}
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「가격판이 풀·오브젝트에 가려지지 않게」 → 「가격 판이 캐릭터를 가리지 않게 · 바닥에 붙은 느낌」(같은 날 두 번째 지시)
// 스프라이트(Frame·Coin)는 `WL/SpriteOverlay`(깊이 검사 LEqual + 깊이 오프셋 = 바닥과 z-파이팅 없이 붙고, 캐릭터·소품이 앞에 있으면 가려진다).
// 가격 글자(TMP)는 **원본 재질 그대로**(깊이 검사) — `expansionPadOverlay 2` 일 때만 Overlay 변형(항상 위) 복사본을 물린다(에셋 0줄).
// 풀은 판 자리 모래 원(padClearRadius)이 비우므로 항상-위 그리기가 더는 필요 없다.
// ─────────────────────────────────────────────────────────────────
static Material s_spriteOverlay;
static readonly Dictionary s_tmpOverlay = new Dictionary();
public static int OverlayApplied;
public static void ApplyOverlay(GameObject root, bool textAlwaysOnTop)
{
if (root == null) return;
var srs = root.GetComponentsInChildren(true);
if (srs.Length > 0)
{
if (s_spriteOverlay == null)
{
var sh = Shader.Find("WL/SpriteOverlay");
if (sh != null) s_spriteOverlay = new Material(sh) { name = "WL_SpriteOverlay(runtime)", hideFlags = HideFlags.DontSave };
}
if (s_spriteOverlay != null)
for (int i = 0; i < srs.Length; i++) if (srs[i] != null && srs[i].sharedMaterial != s_spriteOverlay) { srs[i].sharedMaterial = s_spriteOverlay; OverlayApplied++; }
}
if (!textAlwaysOnTop) return;
var texts = root.GetComponentsInChildren(true);
for (int i = 0; i < texts.Length; i++)
{
var t = texts[i]; if (t == null) continue;
var src = t.fontSharedMaterial; if (src == null || src.shader == null) continue;
if (src.shader.name.IndexOf("Overlay", System.StringComparison.OrdinalIgnoreCase) >= 0) continue;
Material dst;
if (!s_tmpOverlay.TryGetValue(src, out dst) || dst == null)
{
var sh = Shader.Find("TextMeshPro/Mobile/Distance Field Overlay");
if (sh == null) return;
dst = new Material(src) { name = src.name + "(WL overlay)", hideFlags = HideFlags.DontSave, shader = sh };
s_tmpOverlay[src] = dst;
}
t.fontSharedMaterial = dst; OverlayApplied++;
}
}
///
/// 잠긴 칸(lockedPos)의 가격판 자리 — 직교 이웃 중 **열린 칸**(다리 포함)을 고정 순서(북·동·남·서 · 땅 우선)로 고르고,
/// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다.
/// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m.
///
// 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ① — 잠긴 칸마다 처음 정한 자리를 기억한다(더 좋은 이웃이 나중에 열려도 · 던전에서 돌아와
// FI 가 세이브에서 가격판을 다시 만들어도 그대로). PlayerPrefs 에 같이 적어 두고, 세이브 초기화(WLIslandStart) 때 함께 비운다.
const string kSpotPrefsKey = "WL_Island_PadSpots";
static readonly Dictionary s_spotByTile = new Dictionary();
static readonly Dictionary s_neighborByTile = new Dictionary();
static bool s_spotsLoaded;
public static int SpotMemory { get { return s_spotByTile.Count; } }
public static void ClearSpotMemory() { s_spotByTile.Clear(); s_neighborByTile.Clear(); s_spotsLoaded = true; PlayerPrefs.DeleteKey(kSpotPrefsKey); }
static void LoadSpots()
{
if (s_spotsLoaded) return;
s_spotsLoaded = true;
var s = PlayerPrefs.GetString(kSpotPrefsKey, "");
if (string.IsNullOrEmpty(s)) return;
var ci = System.Globalization.CultureInfo.InvariantCulture;
var entries = s.Split(';');
for (int i = 0; i < entries.Length; i++)
{
var f = entries[i].Split(',');
if (f.Length != 6) continue;
try
{
var key = new Vector3Int(int.Parse(f[0], ci), 0, int.Parse(f[1], ci));
s_spotByTile[key] = new Vector3(float.Parse(f[2], ci), 0f, float.Parse(f[3], ci));
s_neighborByTile[key] = new Vector3(float.Parse(f[4], ci), 0f, float.Parse(f[5], ci));
}
catch (System.Exception) { }
}
}
static void SaveSpots()
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
var sb = new System.Text.StringBuilder();
foreach (var kv in s_spotByTile)
{
Vector3 n; if (!s_neighborByTile.TryGetValue(kv.Key, out n)) n = kv.Value;
if (sb.Length > 0) sb.Append(';');
sb.Append(kv.Key.x.ToString(ci)).Append(',').Append(kv.Key.z.ToString(ci)).Append(',')
.Append(kv.Value.x.ToString("F2", ci)).Append(',').Append(kv.Value.z.ToString("F2", ci)).Append(',')
.Append(n.x.ToString("F2", ci)).Append(',').Append(n.z.ToString("F2", ci));
}
PlayerPrefs.SetString(kSpotPrefsKey, sb.ToString());
PlayerPrefs.Save();
}
public static bool ComputePadSpot(WLIslandSettings cfg, Vector3 lockedPos, FIIsland[] islands, float size, out Vector3 spot, out Vector3 neighborCenter)
{
spot = lockedPos; neighborCenter = lockedPos;
LoadSpots();
var key = Vector3Int.RoundToInt(lockedPos);
if (s_spotByTile.TryGetValue(key, out spot)) { neighborCenter = s_neighborByTile[key]; return true; }
float half = size * 0.5f;
Vector3[] dirs = { Vector3.forward, Vector3.right, Vector3.back, Vector3.left };
FIIsland best = null; Vector3 bestDir = Vector3.zero; int bestRank = int.MaxValue;
for (int k = 0; k < islands.Length; k++)
{
var isl = islands[k];
if (isl == null || !isl.IsUnlocked || !isl.gameObject.activeInHierarchy) continue;
var mf = isl.GetComponent(); if (mf == null || mf.sharedMesh == null) continue; // 더미 제외
var ip = isl.transform.position;
for (int d = 0; d < 4; d++)
{
var expect = lockedPos - dirs[d] * size; // 이웃이 잠긴 칸의 dirs[d] 반대편에 있으면 판은 이웃의 dirs[d] 쪽 가장자리
if (Mathf.Abs(ip.x - expect.x) > size * 0.1f || Mathf.Abs(ip.z - expect.z) > size * 0.1f) continue;
int rank = d + (isl.IsBridge ? 4 : 0); // 땅 우선 · 북동남서 순
if (rank < bestRank) { bestRank = rank; best = isl; bestDir = dirs[d]; }
}
}
if (best == null) return false;
neighborCenter = best.transform.position;
// 🔴 2026-09-17 PD ③ 「확장 방향에 맞게 · 십자 방향 판끼리 안 겹치게 간격·크기」
// 자리 = 확장 방향 가장자리 **중앙**, 안쪽 거리 = max(설정, 판 반지름 + 0.15)(판·트리거가 칸 밖으로 안 나간다).
// 그 자리가 텐트·모닥불·던전 문·발판·다른 확장 판·소품과 겹치면 가장자리를 따라 옆으로(±1 · ±2 · 끝) 비킨다.
float ps = cfg.expansionPadScale > 0.05f ? cfg.expansionPadScale : 1f;
float padR = 2.56f * ps;
float inset = Mathf.Clamp(Mathf.Max(cfg.expansionPadInset, padR + 0.15f), 0.3f, half);
var basePos = neighborCenter + bestDir * (half - inset);
var perp = new Vector3(bestDir.z, 0f, bestDir.x);
float maxSlide = Mathf.Max(0f, half - padR - 0.25f);
float[] cands = { 0f, 1f, -1f, 2f, -2f, maxSlide, -maxSlide };
spot = basePos; LastSlide = 0f; bool found = false;
for (int k = 0; k < cands.Length; k++)
{
float s = Mathf.Clamp(cands[k], -maxSlide, maxSlide);
var cand = basePos + perp * s;
if (IsSpotBlocked(cfg, cand, padR, key, islands)) continue;
spot = cand; LastSlide = s; found = true; break;
}
if (!found) BlockedSpots++;
s_spotByTile[key] = new Vector3(spot.x, 0f, spot.z); s_neighborByTile[key] = new Vector3(neighborCenter.x, 0f, neighborCenter.z);
SaveSpots();
return true;
}
public static float LastSlide; public static int BlockedSpots;
/// 판 자리가 막혔는가 — 다른 확장 판(잠긴 칸 기억 자리) · 캠프 장식 · 던전 문 · 우리 발판(렌더러 경계) · 콜라이더 있는 섬 소품(동물 제외).
static bool IsSpotBlocked(WLIslandSettings cfg, Vector3 c, float padR, Vector3Int selfKey, FIIsland[] islands)
{
float sep = Mathf.Max(2f * padR, cfg.expansionPadTriggerSize) + 0.4f;
foreach (var kv in s_spotByTile)
{
if (kv.Key == selfKey || IsTileUnlocked(kv.Key, islands)) continue;
float dx = kv.Value.x - c.x, dz = kv.Value.z - c.z;
if (dx * dx + dz * dz < sep * sep) return true;
}
float r = padR + 0.2f;
var made = WLIslandCampDecor.Made;
for (int i = 0; i < made.Count; i++)
{
if (made[i] == null) continue;
var t = made[i].transform;
if (t.childCount == 0) { if (RectHitsCircle(RendererBounds(made[i]), c, r)) return true; continue; }
for (int j = 0; j < t.childCount; j++) if (RectHitsCircle(RendererBounds(t.GetChild(j).gameObject), c, r)) return true; // 텐트·모닥불 하나씩
}
var gates = Object.FindObjectsByType(FindObjectsSortMode.None);
for (int i = 0; i < gates.Length; i++) if (gates[i] != null && RectHitsCircle(RendererBounds(gates[i].gameObject), c, r)) return true;
var plats = Object.FindObjectsByType(FindObjectsSortMode.None);
for (int i = 0; i < plats.Length; i++) if (plats[i] != null && RectHitsCircle(RendererBounds(plats[i].gameObject), c, r)) return true;
var cols = Physics.OverlapSphere(new Vector3(c.x, c.y + 0.5f, c.z), r, ~0, QueryTriggerInteraction.Collide);
for (int i = 0; i < cols.Length; i++)
{
var col = cols[i]; if (col == null) continue;
if (col.GetComponentInParent() == null) continue;
if (col.GetComponentInParent() != null) continue;
return true;
}
return false;
}
static bool IsTileUnlocked(Vector3Int key, FIIsland[] islands)
{
if (islands == null) return false;
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i]; if (isl == null) continue;
if (Vector3Int.RoundToInt(isl.transform.position) == key) return isl.IsUnlocked;
}
return false;
}
static Bounds RendererBounds(GameObject go)
{
var rs = go.GetComponentsInChildren(false);
if (rs.Length == 0) return new Bounds(go.transform.position, Vector3.zero);
var b = rs[0].bounds;
for (int i = 1; i < rs.Length; i++) b.Encapsulate(rs[i].bounds);
return b;
}
static bool RectHitsCircle(Bounds b, Vector3 c, float r)
{
if (b.size.x <= 0f && b.size.z <= 0f) return false;
float dx = Mathf.Max(b.min.x - c.x, 0f, c.x - b.max.x), dz = Mathf.Max(b.min.z - c.z, 0f, c.z - b.max.z);
return dx * dx + dz * dz < r * r;
}
///
/// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음).
/// 지형이 이 자리에 모래를 깔아 풀을 비운다.
///
public static int CollectPadSpots(WLIslandSettings cfg, List outSpots)
{
if (cfg == null || cfg.expansionPadPlace == 0) return 0;
var mgr = FIIslandManager.Instance;
float size = mgr != null && mgr.IslandSize > 0.1f ? mgr.IslandSize : 8f;
var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
int n = 0;
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || isl.IsUnlocked) continue;
var mf = isl.GetComponent(); if (mf == null || mf.sharedMesh == null) continue;
if (isl.Constraint != null && !isl.Constraint.IsUnlocked) continue;
Vector3 spot, c;
if (!ComputePadSpot(cfg, isl.transform.position, islands, size, out spot, out c)) continue;
outSpots.Add(spot); n++;
}
return n;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「가격판이 경사에 가려지지 않게」
// 판 평면 = 중심 표면 높이 + 지형 법선 기울기(최대 expansionPadTiltMax°). 판 테두리 8점 어디서든 표면(지형·바닥 콜라이더)이
// 평면보다 높으면 그만큼 평면을 올린다 → 둔덕(안쪽) · 해변 경사(바깥쪽) 어느 쪽에도 판이 묻히지 않는다.
// ─────────────────────────────────────────────────────────────────
public static string LastFitLog = "";
static float PadSurfaceY(Vector3 center, float fallbackY, float r, float tiltMax, out Quaternion rot)
{
var dt = Object.FindFirstObjectByType(FindObjectsInactive.Include);
var terrain = dt != null ? dt.TerrainComponent : null;
// 중심 + 테두리 8점의 표면 높이로 최소제곱 평면 y = a·x + b·z + c 를 맞춘다(테두리는 대칭 → Σx² = Σz² = 4r²) → 둔덕·해변 경사·다리 아치를 모두 따라 눕는다.
var os = new Vector3[9]; var ys = new float[9];
float sum = 0f, sx = 0f, sz = 0f;
for (int k = 0; k < 9; k++)
{
os[k] = k < 8 ? new Vector3(Mathf.Cos(k * Mathf.PI * 0.25f) * r, 0f, Mathf.Sin(k * Mathf.PI * 0.25f) * r) : Vector3.zero;
ys[k] = SurfaceAt(center + os[k], fallbackY, terrain);
sum += ys[k]; sx += ys[k] * os[k].x; sz += ys[k] * os[k].z;
}
float a = sx / (4f * r * r), b = sz / (4f * r * r), c = sum / 9f;
rot = PadTilt(new Vector3(-a, 1f, -b), tiltMax);
float raise = float.MinValue; // 어느 표본도 판 아래에 묻히지 않게 평면을 올린다
for (int k = 0; k < 9; k++) { float d = ys[k] - (c + (rot * os[k]).y); if (d > raise) raise = d; }
if (raise < 0f) raise = 0f;
LastFitLog = "평면 " + c.ToString("F2") + " 기울기 " + Quaternion.Angle(Quaternion.identity, rot).ToString("F1") + "° 올림 " + raise.ToString("F2");
return c + raise;
}
/// 한 점의 표면 높이 = 바닥 콜라이더(레이 · 소품·플레이어·가격판·벽 제외)와 데모 지형 높이 중 큰 값.
static float SurfaceAt(Vector3 p, float fallbackY, Terrain terrain)
{
float h = PadGroundY(p, fallbackY);
if (terrain != null)
{
var td = terrain.terrainData; var tp = terrain.transform.position;
if (td != null && p.x >= tp.x && p.z >= tp.z && p.x <= tp.x + td.size.x && p.z <= tp.z + td.size.z)
{
float th = terrain.SampleHeight(p) + tp.y;
if (th > h) h = th;
}
}
return h;
}
static Quaternion PadTilt(Vector3 normal, float maxDeg)
{
if (maxDeg <= 0f || normal.sqrMagnitude < 1e-6f) return Quaternion.identity;
var q = Quaternion.FromToRotation(Vector3.up, normal.normalized);
float ang = Quaternion.Angle(Quaternion.identity, q);
if (ang > maxDeg) q = Quaternion.Slerp(Quaternion.identity, q, maxDeg / ang);
return q;
}
static float PadGroundY(Vector3 p, float fallbackY)
{
var hits = Physics.RaycastAll(new Vector3(p.x, fallbackY + 5f, p.z), Vector3.down, 20f, ~0, QueryTriggerInteraction.Ignore);
float best = float.NaN;
for (int i = 0; i < hits.Length; i++)
{
float y = hits[i].point.y;
if (y > fallbackY + 1.5f) continue;
if (!IsGroundCollider(hits[i].collider)) continue;
if (float.IsNaN(best) || y > best) best = y;
}
return float.IsNaN(best) ? fallbackY : best;
}
/// 바닥으로 치는 콜라이더 — 지형 · 우리 둔덕 콜라이더 · 섬 칸(타일·다리 바닥). 소품·가장자리 벽(세로 얇은 상자)·플레이어·가격판·발판·설치물은 제외(판이 그 위로 올라가면 안 된다).
static bool IsGroundCollider(Collider c)
{
if (c == null || c.isTrigger) return false;
if (c is TerrainCollider) return true;
if (c.gameObject.name.StartsWith("~WL_Relief")) return true;
if (c.GetComponentInParent() == null) return false;
if (c.GetComponentInParent() != null) return false;
var box = c as BoxCollider;
if (box != null)
{
var s = Vector3.Scale(box.size, c.transform.lossyScale);
if (s.y > 0.6f && Mathf.Min(Mathf.Abs(s.x), Mathf.Abs(s.z)) < 3f) return false; // 가장자리 벽(8×2×0.3)·다리 난간(2.6×2×8)
}
return true;
}
// ─────────────────────────────────────────────────────────────────
// 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ②
// FI 는 칸을 연 순간(OnActivated) 새 가격판을 잠긴 칸 중앙에 만든다 → 3 s 훑기가 오기 전까지 원래 자리(바다 쪽)에 보였다가 옮겨졌다.
// → 칸이 열리는 **그 프레임**에 바로 옮긴다. 지형이 다시 구워지면(솟아오르기 끝) 높이·기울기만 다시 맞춘다(자리 x·z 고정).
// ─────────────────────────────────────────────────────────────────
static readonly HashSet s_watched = new HashSet();
static bool s_bakedHooked; static WLIslandSettings s_cfgForBaked;
public static int Refits, ActivationPlaces;
static void Watch(WLIslandSettings cfg, Scene scene)
{
s_watched.RemoveWhere(i => i == null);
var islands = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < islands.Length; i++)
{
var isl = islands[i];
if (isl == null || s_watched.Contains(isl)) continue;
s_watched.Add(isl);
// 🔴 실측: OnActivated 는 DOScale 완료 콜백 — 같은 프레임의 DOJump 착지가 아직 안 끝나 칸이 ~9 cm 떠 있다 → 자리는 즉시, 높이는 다음 프레임에 한 번 더 맞춘다.
isl.OnActivated += () =>
{
if (cfg == null) return;
ActivationPlaces++;
PlacePurchasers(cfg, scene);
var runner = WLIslandBridge.Runner;
if (runner != null) runner.StartCoroutine(Co_RefitNextFrame(cfg));
};
}
s_cfgForBaked = cfg;
if (!s_bakedHooked) { s_bakedHooked = true; global::WL.Look.Farm.WLDemoTerrain.Baked += OnTerrainBaked; }
}
static void OnTerrainBaked() { if (s_cfgForBaked != null) RefitPlaced(s_cfgForBaked); }
static IEnumerator Co_RefitNextFrame(WLIslandSettings cfg) { yield return null; RefitPlaced(cfg); }
/// 이미 옮긴 판의 높이·기울기만 지형에 다시 맞춘다(자리는 그대로).
public static void RefitPlaced(WLIslandSettings cfg)
{
if (cfg == null || cfg.expansionPadPlace == 0) return;
var marks = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < marks.Length; i++)
{
var m = marks[i]; if (m == null) continue;
var t = m.transform;
float ps = t.localScale.x > 0.05f ? t.localScale.x : 1f;
float plateDropLocal = 0f; var frame = t.Find("Frame"); if (frame != null) plateDropLocal = -frame.localPosition.y;
Quaternion rot; var pos = t.position;
float gy = PadSurfaceY(new Vector3(pos.x, m.original.y, pos.z), m.original.y, 2.56f * ps, cfg.expansionPadTiltMax, out rot);
t.rotation = rot;
t.position = new Vector3(pos.x, gy + plateDropLocal * ps + cfg.expansionPadLift, pos.z);
Refits++;
}
}
static readonly List s_rends = new List(64);
static readonly List s_cols = new List(64);
/// 첫 던전 승리를 이미 했는가(기능이 꺼져 있으면 항상 true = 원본 그대로).
public static bool IsUnlocked(WLIslandSettings cfg)
{
if (cfg == null || cfg.expansionAfterFirstWin == 0) return true;
if (string.IsNullOrEmpty(cfg.expansionUnlockPrefsKey)) return true;
return PlayerPrefs.GetInt(cfg.expansionUnlockPrefsKey, 0) != 0;
}
/// 전투 승리 시 1회 — 이후 섬 확장은 FI 원본 순서대로 열린다.
public static void Unlock(WLIslandSettings cfg)
{
if (cfg == null || cfg.expansionAfterFirstWin == 0) return;
if (string.IsNullOrEmpty(cfg.expansionUnlockPrefsKey)) return;
if (PlayerPrefs.GetInt(cfg.expansionUnlockPrefsKey, 0) != 0) return;
PlayerPrefs.SetInt(cfg.expansionUnlockPrefsKey, 1);
PlayerPrefs.Save();
// 2026-09-16 실측: 플래그만 세우면 재숨김 코루틴(20 s)이 끝난 뒤의 개방은 다음 섬 로드까지 가격판이 숨겨진 채였다 → 즉시 되살린다.
Restore();
LastLog = "🔴 확장 노드 개방(던전 개방 또는 첫 전투 승리) — 원본 가격판 표시 · 이후 순서는 FI 원본이 정한다";
WLIslandBridge.Log(cfg, LastLog);
}
/// 섬이 준비될 때마다 1회 — 아직 승리 전이면 원본 가격판을 숨기고, 승리했으면 그대로 둔다.
public static void Apply(WLIslandSettings cfg, Scene scene)
{
if (cfg == null || cfg.gateFlowEnabled == 0 || cfg.expansionAfterFirstWin == 0) return;
Watch(cfg, scene); // 칸이 열리는 프레임에 바로 옮기기 · 지형 재굽기 뒤 높이 재맞춤
PlacePurchasers(cfg, scene); // 자리 옮기기(숨김 여부와 무관 · 1회)
if (IsUnlocked(cfg))
{
Restore();
LastLog = "확장 노드 표시 — 첫 승리 완료(FI 원본 순서)";
WLIslandBridge.Log(cfg, LastLog);
return;
}
HideNow(cfg, scene);
// FI 는 세이브 복원·이웃 갱신 시점에 가격판을 **나중에** 만들기도 한다 — 잠시 더 훑는다.
var runner = WLIslandBridge.Runner;
if (runner != null && cfg.expansionHideSeconds > 0f)
runner.StartCoroutine(Co_Guard(cfg, scene));
}
public static int HideNow(WLIslandSettings cfg, Scene scene)
{
int n = 0;
var all = Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None);
for (int i = 0; i < all.Length; i++)
{
var p = all[i];
if (p == null) continue;
// 2026-09-15 Lead 실측 — FI 가 Instantiate 한 `Purchaser(Clone)` 은 섬 씬이 아니라 **활성 씬(InGame)** 에 생긴다 → 활성 씬도 허용
if (scene.IsValid() && p.gameObject.scene != scene && p.gameObject.scene != SceneManager.GetActiveScene()) continue;
if (p.GetComponentInParent() != null) continue; // 우리 발판은 건드리지 않는다
var rends = p.GetComponentsInChildren(true);
for (int r = 0; r < rends.Length; r++)
if (rends[r] != null && rends[r].enabled) { rends[r].enabled = false; s_rends.Add(rends[r]); n++; }
var cols = p.GetComponentsInChildren(true);
for (int c = 0; c < cols.Length; c++)
if (cols[c] != null && cols[c].enabled) { cols[c].enabled = false; s_cols.Add(cols[c]); }
}
if (n > 0)
{
PurchasersHidden += n;
LastLog = "원본 확장 가격판 숨김 — 렌더러 " + n + "개(첫 던전 승리 전)";
WLIslandBridge.Log(cfg, LastLog);
}
return n;
}
static IEnumerator Co_Guard(WLIslandSettings cfg, Scene scene)
{
float wait = Mathf.Max(0.1f, cfg.expansionHideInterval);
float t = 0f;
while (t < cfg.expansionHideSeconds)
{
yield return new WaitForSeconds(wait);
t += wait;
if (cfg == null || cfg.expansionAfterFirstWin == 0) yield break;
PlacePurchasers(cfg, scene);
if (IsUnlocked(cfg)) { Restore(); yield break; }
HideNow(cfg, scene);
}
}
/// 우리가 껐던 것만 되돌린다(FI 가 스스로 끈 것은 손대지 않는다).
public static void Restore()
{
for (int i = 0; i < s_rends.Count; i++) if (s_rends[i] != null) s_rends[i].enabled = true;
for (int i = 0; i < s_cols.Count; i++) if (s_cols[i] != null) s_cols[i].enabled = true;
s_rends.Clear();
s_cols.Clear();
}
}
}