Project_WL/Assets/WL/Island/WLIslandFlowV2.cs

628 lines
36 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 원래 자리)
FIIsland best = null; float bd = float.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; // 더미 제외
float dx = isl.transform.position.x - pos.x, dz = isl.transform.position.z - pos.z;
float d = Mathf.Sqrt(dx * dx + dz * dz);
if (d <= size * 1.05f && d < bd) { bd = d; best = isl; } // 직교 이웃(다리 칸 포함 = 다리 위에 놓인다)
}
if (best == null) continue; // 이웃을 못 찾으면 FI 자리 그대로
var c = best.transform.position;
var dir = new Vector3(pos.x - c.x, 0f, pos.z - c.z);
dir = Mathf.Abs(dir.x) >= Mathf.Abs(dir.z) ? new Vector3(Mathf.Sign(dir.x), 0f, 0f) : new Vector3(0f, 0f, Mathf.Sign(dir.z));
var target = c + dir * (half - Mathf.Clamp(cfg.expansionPadInset, 0.5f, half));
float gy = PadGroundY(target, c.y);
float plateDrop = 0f; var frame = p.transform.Find("Frame"); if (frame != null) plateDrop = -frame.localPosition.y; // 판 자식들이 루트보다 아래(0.49)
p.transform.position = new Vector3(target.x, gy + plateDrop + cfg.expansionPadLift, target.z);
var mk = p.gameObject.AddComponent<WLPlacedPurchaser>(); mk.original = pos;
PurchasersPlaced++;
LastPlaceLog = "확장 가격판 " + pos.ToString("F0") + " → " + p.transform.position.ToString("F2") + "(이웃 " + c.ToString("F0") + " 가장자리 안쪽 " + cfg.expansionPadInset.ToString("F1") + " m · 지면 " + gy.ToString("F2") + ")";
WLIslandBridge.Log(cfg, LastPlaceLog);
}
}
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 (float.IsNaN(best) || y > best) best = y;
}
return float.IsNaN(best) ? fallbackY : best;
}
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;
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();
}
}
}