Project_WL/Assets/WL/Island/WLIslandFlowV2.cs

1000 lines
60 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.

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>초기 플로우(단계 표) 진행 + 설치물 배치. 정적 · 씬에 남는 상태 없음.</summary>
public static class WLIslandFlowV2
{
const string RootName = "~WL_FlowInstalls";
// ── 진단(프로브·보고가 읽는다 · 실측만)
public static int InstallsMade;
public static int LastStep;
public static string LastLog = "";
static readonly List<GameObject> s_made = new List<GameObject>(8);
/// <summary>이 플로우가 지금 동작 중인가(꺼져 있으면 기존 발판 방식이 그대로 돈다).</summary>
public static bool Active(WLIslandSettings cfg)
{
return cfg != null && cfg.gateFlowEnabled != 0 && cfg.flowV2Enabled != 0 && StepCount(cfg) > 0;
}
/// <summary>켜져 있는 단계 수.</summary>
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;
}
/// <summary>켜져 있는 것 중 n 번째 단계(없으면 null).</summary>
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;
}
/// <summary>지금까지 치른 단계 수(PlayerPrefs 1키 · FI 세이브를 건드리지 않는다).</summary>
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);
}
/// <summary>n 번째 단계의 발판 자리(표가 가리킨 던전 줄의 좌표 · -1 이면 밭 구역 중심).</summary>
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);
}
/// <summary>치른 단계 중 던전 단계가 몇 개인지(= 열려 있어야 할 던전 수).</summary>
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<WLDungeonGate>(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<CryingSnow.FarmingIsland.PlayerController>();
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<CharacterController>();
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);
}
/// <summary>열린 타일(FI Island · IsUnlocked)의 중심 — 없으면 원점.</summary>
static Vector3 IslandCenter()
{
var islands = Object.FindObjectsByType<CryingSnow.FarmingIsland.Island>(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<MeshFilter>() == null || isl.GetComponent<MeshFilter>().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<Renderer>(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<Collider>(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;
}
/// <summary>
/// 그 자리 지면 높이 — 설치물(`~WL_FlowInstalls` 밑) 콜라이더 제외 · 요청 높이 +1 m 보다 높거나 0.6 m 보다 낮은 맞힘(바다·경사) 무시.
/// 반환 false = 발 디딜 땅 없음(섬 밖).
/// </summary>
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<Stall>()`)가
// 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<Collider>(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<Component>(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 와 같다(꺼진 오브젝트에서 코루틴이 시작되면 에러).
// ─────────────────────────────────────────────────────────────────────
/// <summary>섬 플레이어 크기를 이미 키웠다는 표식(WLIslandSceneSetup.ApplyPlayerScale · 1회).</summary>
public sealed class WLPlayerScaled : MonoBehaviour { }
/// <summary>FI 확장 가격판을 이웃 칸 가장자리로 옮겼다는 표식(원래 자리 기억).</summary>
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<FIPurchaser>(FindObjectsInactive.Include, FindObjectsSortMode.None);
FIIsland[] islands = null;
for (int i = 0; i < all.Length; i++)
{
var p = all[i];
if (p == null || p.GetComponent<WLPlacedPurchaser>() != null) continue;
if (scene.IsValid() && p.gameObject.scene != scene && p.gameObject.scene != SceneManager.GetActiveScene()) continue;
if (p.GetComponentInParent<WLGoldPlatform>() != null) continue; // 우리 발판
if (islands == null) islands = Object.FindObjectsByType<FIIsland>(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<BoxCollider>();
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<WLPlacedPurchaser>(); 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<Material, Material> s_tmpOverlay = new Dictionary<Material, Material>();
public static int OverlayApplied;
public static void ApplyOverlay(GameObject root, bool textAlwaysOnTop)
{
if (root == null) return;
var srs = root.GetComponentsInChildren<SpriteRenderer>(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<TMPro.TMP_Text>(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++;
}
}
/// <summary>
/// 잠긴 칸(lockedPos)의 가격판 자리 — 직교 이웃 중 **열린 칸**(다리 포함)을 고정 순서(북·동·남·서 · 땅 우선)로 고르고,
/// 그 칸 가장자리에서 `expansionPadInset` 안쪽 지점을 돌려준다. 가격판 배치와 지형(판 자리 모래·풀 없음)이 **같은 식**을 쓴다.
/// 2026-09-17 PD 「가격판은 이동 가능한 범위 안에서 최대한 가장자리」 → inset 기본 1.0 m.
/// </summary>
// 🔴 2026-09-17 PD 「다리 위 가격판 자리가 저절로 바뀌지 않게」 ① — 잠긴 칸마다 처음 정한 자리를 기억한다(더 좋은 이웃이 나중에 열려도 · 던전에서 돌아와
// FI 가 세이브에서 가격판을 다시 만들어도 그대로). PlayerPrefs 에 같이 적어 두고, 세이브 초기화(WLIslandStart) 때 함께 비운다.
const string kSpotPrefsKey = "WL_Island_PadSpots";
static readonly Dictionary<Vector3Int, Vector3> s_spotByTile = new Dictionary<Vector3Int, Vector3>();
static readonly Dictionary<Vector3Int, Vector3> s_neighborByTile = new Dictionary<Vector3Int, Vector3>();
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<MeshFilter>(); 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;
/// <summary>판 자리가 막혔는가 — 다른 확장 판(잠긴 칸 기억 자리) · 캠프 장식 · 던전 문 · 우리 발판(렌더러 경계) · 콜라이더 있는 섬 소품(동물 제외).</summary>
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<WLDungeonGate>(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<WLGoldPlatform>(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<CryingSnow.FarmingIsland.IProp>() == null) continue;
if (col.GetComponentInParent<CryingSnow.FarmingIsland.AnimalController>() != 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<Renderer>(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;
}
/// <summary>
/// 지금 가격판이 놓일(놓인) 자리 전부 — 잠긴 칸 중 「직교 이웃이 열려 있고 선행 조건(Constraint)이 풀린」 칸(FI 가 판을 만드는 조건과 같음).
/// 지형이 이 자리에 모래를 깔아 풀을 비운다.
/// </summary>
public static int CollectPadSpots(WLIslandSettings cfg, List<Vector3> 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<FIIsland>(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<MeshFilter>(); 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<global::WL.Look.Farm.WLDemoTerrain>(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;
}
/// <summary>한 점의 표면 높이 = 바닥 콜라이더(레이 · 소품·플레이어·가격판·벽 제외)와 데모 지형 높이 중 큰 값.</summary>
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;
}
/// <summary>바닥으로 치는 콜라이더 — 지형 · 우리 둔덕 콜라이더 · 섬 칸(타일·다리 바닥). 소품·가장자리 벽(세로 얇은 상자)·플레이어·가격판·발판·설치물은 제외(판이 그 위로 올라가면 안 된다).</summary>
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<FIIsland>() == null) return false;
if (c.GetComponentInParent<CryingSnow.FarmingIsland.IProp>() != 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<FIIsland> s_watched = new HashSet<FIIsland>();
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<FIIsland>(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); }
/// <summary>이미 옮긴 판의 높이·기울기만 지형에 다시 맞춘다(자리는 그대로).</summary>
public static void RefitPlaced(WLIslandSettings cfg)
{
if (cfg == null || cfg.expansionPadPlace == 0) return;
var marks = Object.FindObjectsByType<WLPlacedPurchaser>(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<Renderer> s_rends = new List<Renderer>(64);
static readonly List<Collider> s_cols = new List<Collider>(64);
/// <summary>첫 던전 승리를 이미 했는가(기능이 꺼져 있으면 항상 true = 원본 그대로).</summary>
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;
}
/// <summary>전투 승리 시 1회 — 이후 섬 확장은 FI 원본 순서대로 열린다.</summary>
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);
}
/// <summary>섬이 준비될 때마다 1회 — 아직 승리 전이면 원본 가격판을 숨기고, 승리했으면 그대로 둔다.</summary>
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<FIPurchaser>(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<WLGoldPlatform>() != null) continue; // 우리 발판은 건드리지 않는다
var rends = p.GetComponentsInChildren<Renderer>(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<Collider>(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);
}
}
/// <summary>우리가 껐던 것만 되돌린다(FI 가 스스로 끈 것은 손대지 않는다).</summary>
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();
}
}
}