diff --git a/Assets/WL/Island/Resources/WL/WLIslandSettings.asset b/Assets/WL/Island/Resources/WL/WLIslandSettings.asset
index d888a30bb..2faf667ca 100644
--- a/Assets/WL/Island/Resources/WL/WLIslandSettings.asset
+++ b/Assets/WL/Island/Resources/WL/WLIslandSettings.asset
@@ -94,7 +94,7 @@ MonoBehaviour:
campDecorSnapToGround: 1
applyStartCoin: 1
startCoin: 10
- saveResetToken: 816g-1
+ saveResetToken: 816zc-1
resetSaveEveryStart: 1
dungeonCameraMatchIsland: 1
camPitch: 45
@@ -128,7 +128,7 @@ MonoBehaviour:
gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab
platformUseFarm: 1
platformPosition: {x: 0, y: 0, z: 0}
- - enabled_: 1
+ - enabled_: 0
id: 2
displayName: "\uB358\uC804 2"
sceneName: WL_Dungeon02
@@ -141,7 +141,7 @@ MonoBehaviour:
gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab
platformUseFarm: 0
platformPosition: {x: -2, y: 0, z: -2}
- - enabled_: 1
+ - enabled_: 0
id: 3
displayName: "\uB358\uC804 3"
sceneName: WL_Dungeon01
@@ -154,7 +154,7 @@ MonoBehaviour:
gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab
platformUseFarm: 0
platformPosition: {x: -2.5, y: 0, z: 8.5}
- - enabled_: 1
+ - enabled_: 0
id: 4
displayName: "\uB358\uC804 4"
sceneName: WL_Dungeon02
diff --git a/Assets/WL/Island/WLIslandBridge.cs b/Assets/WL/Island/WLIslandBridge.cs
index 14631f09d..014f5b203 100644
--- a/Assets/WL/Island/WLIslandBridge.cs
+++ b/Assets/WL/Island/WLIslandBridge.cs
@@ -255,9 +255,14 @@ namespace WL.Island
static void OnStageCleared(in StageClearResult r)
{
var cfg = WLIslandSettings.Instance;
- if (cfg == null || cfg.enabled_ == 0 || cfg.rewardEnabled == 0) return;
+ if (cfg == null || cfg.enabled_ == 0) return;
if (WLDungeonSceneSetup.Current == null) return; // 던전이 아닌 스테이지(#815 데모)는 건드리지 않는다
+ // 🔴 WL-816zc ② — 「전투 승리 후 섬 확장 노드 추가」. 보상과 무관하게 승리 그 자체로 연다.
+ WLIslandExpansionGate.Unlock(cfg);
+
+ if (cfg.rewardEnabled == 0) return;
+
int gold = ComputeGold(cfg, r);
SetPending(cfg, gold);
ClearGrants++;
diff --git a/Assets/WL/Island/WLIslandCampDecor.cs b/Assets/WL/Island/WLIslandCampDecor.cs
index 1834a72e3..23c256afb 100644
--- a/Assets/WL/Island/WLIslandCampDecor.cs
+++ b/Assets/WL/Island/WLIslandCampDecor.cs
@@ -36,9 +36,19 @@ namespace WL.Island
{
Clear();
if (cfg == null || cfg.campDecorEnabled == 0) return;
- if (WLIslandGateFlow.UnlockedCount(cfg) < 1) { LastLog = "캠프 없음 — 첫 자리 미개방"; return; }
- Vector3 center = WLIslandGateFlow.PlatformPosFor(cfg, 0);
+ // WL-816zc — 초기 플로우 v2 에서는 「캠프파이어 설치」가 **1단계**다(단계 표의 첫 줄 자리).
+ Vector3 center;
+ if (WLIslandFlowV2.Active(cfg))
+ {
+ if (WLIslandFlowV2.Step(cfg) < 1) { LastLog = "캠프 없음 — 1단계(캠프파이어 설치) 미지불"; return; }
+ center = WLIslandFlowV2.PosFor(cfg, 0);
+ }
+ else
+ {
+ if (WLIslandGateFlow.UnlockedCount(cfg) < 1) { LastLog = "캠프 없음 — 첫 자리 미개방"; return; }
+ center = WLIslandGateFlow.PlatformPosFor(cfg, 0);
+ }
LastCenter = center;
var root = new GameObject(RootName);
diff --git a/Assets/WL/Island/WLIslandFlowV2.cs b/Assets/WL/Island/WLIslandFlowV2.cs
new file mode 100644
index 000000000..6126ae15d
--- /dev/null
+++ b/Assets/WL/Island/WLIslandFlowV2.cs
@@ -0,0 +1,427 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// 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;
+
+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); // 이미 있으면 안에서 건너뛴다
+ 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);
+ }
+ return true;
+ }
+
+ 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;
+ RaycastHit hit;
+ if (Physics.Raycast(new Vector3(p.x, p.y + 30f, p.z), Vector3.down, out hit, 60f, ~0, QueryTriggerInteraction.Ignore))
+ return new Vector3(p.x, hit.point.y, p.z);
+ return p;
+ }
+
+ public static void 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 와 같다(꺼진 오브젝트에서 코루틴이 시작되면 에러).
+ // ─────────────────────────────────────────────────────────────────────
+ public static class WLIslandExpansionGate
+ {
+ // ── 진단(프로브·보고가 읽는다 · 실측만)
+ public static int PurchasersHidden;
+ public static string LastLog = "";
+
+ 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();
+ 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;
+
+ 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;
+ if (scene.IsValid() && p.gameObject.scene != scene) 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;
+ 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();
+ }
+ }
+}
diff --git a/Assets/WL/Island/WLIslandFlowV2.cs.meta b/Assets/WL/Island/WLIslandFlowV2.cs.meta
new file mode 100644
index 000000000..820374bb6
--- /dev/null
+++ b/Assets/WL/Island/WLIslandFlowV2.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 8c3f1a274b6d4e1f9a05d7c2e6b83f41
diff --git a/Assets/WL/Island/WLIslandGateFlow.cs b/Assets/WL/Island/WLIslandGateFlow.cs
index 0dcbb41b0..7b4a21314 100644
--- a/Assets/WL/Island/WLIslandGateFlow.cs
+++ b/Assets/WL/Island/WLIslandGateFlow.cs
@@ -100,13 +100,22 @@ namespace WL.Island
s_farmCenter = farmCenter;
s_haveFarm = haveFarm;
+ // WL-816zc ③ — 원본 섬 확장 가격판은 첫 던전 승리 전까지 숨긴다(켜고 끄기만 · FI 순서 그대로).
+ WLIslandExpansionGate.Apply(cfg, scene);
+
RefreshPlatform(cfg, scene);
- WLIslandCampDecor.Refresh(cfg, scene); // WL-816y — 세이브 복원(해금 ≥ 1 이면 캠프 재생성)
+ // v2 는 RefreshPlatform 안에서 캠프까지 같이 그린다 — 두 번 그리지 않는다.
+ if (!WLIslandFlowV2.Active(cfg))
+ WLIslandCampDecor.Refresh(cfg, scene); // WL-816y — 세이브 복원(해금 ≥ 1 이면 캠프 재생성)
}
static Vector3 s_farmCenter;
static bool s_haveFarm;
+ /// 밭 구역 중심(WL-816zc 플로우가 첫 자리로 쓴다).
+ public static Vector3 FarmCenter { get { return s_farmCenter; } }
+ public static bool HaveFarmCenter { get { return s_haveFarm; } }
+
///
/// 🔴 던전 하나 = **발판 한 자리**(WL-816g · PD 정정 2026-09-13
/// 「던전 열기는 한 번만 · 이후 같은 자리에 또 등장해서는 안 된다」).
@@ -126,7 +135,7 @@ namespace WL.Island
}
/// 좌표 위에서 아래로 쏘아 지면 높이를 맞춘다(맞는 것이 없으면 준 값 그대로).
- static Vector3 SnapToGround(WLIslandSettings cfg, Vector3 p)
+ public static Vector3 SnapToGround(WLIslandSettings cfg, Vector3 p)
{
if (cfg.platformSnapToGround == 0) return p;
RaycastHit hit;
@@ -139,6 +148,9 @@ namespace WL.Island
/// 발판을 지금 해금 상태에 맞게 만들거나 지운다.
public static void RefreshPlatform(WLIslandSettings cfg, Scene scene)
{
+ // WL-816zc — 초기 플로우 v2 가 켜져 있으면 발판 순서는 단계 표가 정한다(flowV2Enabled = 0 이면 아래 그대로).
+ if (WLIslandFlowV2.Active(cfg)) { WLIslandFlowV2.Refresh(cfg, scene); return; }
+
int unlocked = UnlockedCount(cfg);
int usable = UsableDungeonCount(cfg);
@@ -188,7 +200,7 @@ namespace WL.Island
WLIslandBridge.Log(cfg, LastLog);
}
- static bool PlayerInRange(WLIslandSettings cfg, Vector3 pos)
+ public static bool PlayerInRange(WLIslandSettings cfg, Vector3 pos)
{
var fi = Object.FindFirstObjectByType();
if (fi == null) return false;
@@ -205,6 +217,9 @@ namespace WL.Island
{
if (cfg == null) return;
+ // WL-816zc — 초기 플로우 v2 가 켜져 있으면 「다음 단계」가 이 자리를 이어받는다.
+ if (WLIslandFlowV2.Active(cfg)) { WLIslandFlowV2.OnPaid(cfg, platformPos); return; }
+
int unlocked = UnlockedCount(cfg);
int usable = UsableDungeonCount(cfg);
if (unlocked >= usable) return;
@@ -555,7 +570,9 @@ namespace WL.Island
public const string ObjectName = "~WL_GoldPlatform";
- public static WLGoldPlatform Create(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int nextIndex)
+ /// 비우면 `platformLabelFormat`(던전 열기) · 채우면 그 글자를 그대로 띄운다(WL-816zc 단계 문구).
+ public static WLGoldPlatform Create(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int nextIndex,
+ string labelOverride = null)
{
GameObject root = null;
@@ -584,7 +601,9 @@ namespace WL.Island
// — 모델이 아니라 글자 하나고, `platformShowLabel = 0` 이면 FI 원본 모습 100 % 가 된다.
if (cfg.platformShowLabel != 0)
WLIslandLabel.Attach(root.transform, new Vector3(0f, cfg.platformLabelHeight, 0f),
- string.Format(cfg.platformLabelFormat, price));
+ string.IsNullOrEmpty(labelOverride)
+ ? string.Format(cfg.platformLabelFormat, price)
+ : labelOverride);
return self;
}
diff --git a/Assets/WL/Island/WLIslandSave.cs b/Assets/WL/Island/WLIslandSave.cs
index 074f06599..1064b66b8 100644
--- a/Assets/WL/Island/WLIslandSave.cs
+++ b/Assets/WL/Island/WLIslandSave.cs
@@ -85,6 +85,9 @@ namespace WL.Island
PlayerPrefs.SetString(kTokenKey, cfg.saveResetToken);
PlayerPrefs.SetInt(cfg.dungeonUnlockPrefsKey, 0); // 해금한 던전도 0 개로
+ // WL-816zc — 초기 플로우 단계·확장 개방도 같이 0 으로(시작마다 초기화 대상)
+ if (!string.IsNullOrEmpty(cfg.flowStepPrefsKey)) PlayerPrefs.SetInt(cfg.flowStepPrefsKey, 0);
+ if (!string.IsNullOrEmpty(cfg.expansionUnlockPrefsKey)) PlayerPrefs.SetInt(cfg.expansionUnlockPrefsKey, 0);
PlayerPrefs.Save();
WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지)
}
diff --git a/Assets/WL/Island/WLIslandSettings.cs b/Assets/WL/Island/WLIslandSettings.cs
index 0f3ee21f5..fd435f0cf 100644
--- a/Assets/WL/Island/WLIslandSettings.cs
+++ b/Assets/WL/Island/WLIslandSettings.cs
@@ -68,6 +68,58 @@ namespace WL.Island
public Vector3 platformPosition = Vector3.zero;
}
+ ///
+ /// WL-816zc — 「섬 초기 플로우」 한 단계 = 이 표의 한 행.
+ /// 발판 1개가 이 행의 값(문구·가격·자리)으로 서고, 값을 치르면 이 행의 설치물이 그 자리에 남는다.
+ /// 코드 수정 0 — 순서·가격·설치물은 전부 여기 값이다(C45).
+ ///
+ [Serializable]
+ public class WLFlowStepDef
+ {
+ [Tooltip("0 이면 이 단계는 없는 것으로 친다(발판도 안 선다).")]
+ public int enabled_ = 1;
+
+ [Tooltip("발판 위에 뜨는 문구(가격은 아래 flowLabelFormat 이 붙인다).")]
+ public string label = "설치";
+
+ [Tooltip("이 단계를 여는 값(골드).")]
+ public int price = 10;
+
+ [Tooltip("발판 자리 = `dungeons[이 번호]` 행의 platformPosition 을 그대로 쓴다(기존 발판 #2·#3·#4 좌표 재사용).\n" +
+ "-1 이면 밭 구역 중심(= 지금의 첫 발판 자리) · 표 번호는 enabled_ 와 무관한 **줄 번호**다.")]
+ public int platformFromDungeonRow = -1;
+
+ [Tooltip("위가 -1 이고 밭 자리도 못 찾았을 때 쓸 월드 좌표.")]
+ public Vector3 platformPosition = Vector3.zero;
+
+ [Tooltip("설치물 종류 — camp(캠프 장식) · prefab(아래 프리팹) · dungeon(던전 입구).")]
+ public string kind = "prefab";
+
+ [Tooltip("kind=prefab 일 때 그 자리에 놓을 프리팹 경로.")]
+ public string prefabPath = "";
+
+ [Tooltip("같이 놓을 것이 있을 때(예: 좌판 프리팹에 상인이 안 들어 있으면 상인을 덧붙인다). 비우면 안 놓는다.")]
+ public string extraPrefabPath = "";
+
+ [Tooltip("덧붙일 것이 이미 설치물 안에 있으면 건너뛸 컴포넌트 이름(비우면 항상 덧붙인다).")]
+ public string extraSkipIfComponent = "";
+
+ [Tooltip("덧붙일 것의 설치물 기준 위치 차이.")]
+ public Vector3 extraOffset = new Vector3(0f, 0f, -1.1f);
+
+ [Tooltip("설치물의 Y 회전(도).")]
+ public float yaw;
+
+ [Tooltip("설치물 크기 배수(1 = 프리팹 원본).")]
+ public float scale = 1f;
+
+ [Tooltip("kind=dungeon 일 때 열 던전의 **줄 번호**(`dungeons[이 번호]` · enabled_ 와 무관).")]
+ public int dungeonRow;
+
+ [Tooltip("1 이면 설치물의 콜라이더를 그대로 둔다. 0 = 전부 끈다(이동 방해 0 · NavMesh 재베이크 없음).")]
+ public int keepCollider;
+ }
+
[CreateAssetMenu(fileName = "WLIslandSettings", menuName = "WL/Island Settings", order = 340)]
public sealed class WLIslandSettings : ScriptableObject
{
@@ -469,6 +521,68 @@ namespace WL.Island
[Tooltip("위 대기의 상한(초). 이 시간이 지나면 조건과 무관하게 로딩 칸을 채운다(로딩 화면이 영영 안 닫히는 것 방지).")]
public float dungeonLoadingMaxWaitSeconds = 8f;
+ // ───────────────────────────────── WL-816zc 섬 초기 플로우 v2
+ [Header("WL-816zc — 섬 초기 플로우(캠프파이어 → 상인 → 사과나무 → 던전)")]
+
+ [Tooltip("1 = 아래 단계 표 순서대로 발판이 하나씩 선다(PD 2026-09-15).\n" +
+ "0 = 되돌리기 — 지금까지의 「던전 발판 4개」 방식으로 돌아간다.")]
+ public int flowV2Enabled = 1;
+
+ [Tooltip("어디까지 치렀는지 세는 PlayerPrefs 키 1개(시작마다 초기화 대상).")]
+ public string flowStepPrefsKey = "WL_Island_FlowStep";
+
+ [Tooltip("발판 문구 서식 — {0} = 단계 문구 · {1} = 가격.")]
+ public string flowLabelFormat = "{0}\n{1} 골드";
+
+ [Tooltip("한 줄 = 한 단계. 위에서부터 차례로 발판이 선다.")]
+ public WLFlowStepDef[] flowSteps = new WLFlowStepDef[]
+ {
+ new WLFlowStepDef
+ {
+ label = "캠프파이어 설치", price = 10,
+ platformFromDungeonRow = -1, // 밭 구역 중심
+ kind = "camp",
+ },
+ new WLFlowStepDef
+ {
+ label = "상인 설치", price = 30,
+ platformFromDungeonRow = 1, // 기존 발판 #2 좌표
+ kind = "prefab",
+ prefabPath = "Assets/FarmingIsland/Prefabs/Buildings/Stall01.prefab",
+ extraPrefabPath = "Assets/FarmingIsland/Prefabs/Characters/Merchant.prefab",
+ extraSkipIfComponent = "Merchant", // 좌판 안에 이미 상인이 있으면 안 덧붙인다
+ extraOffset = new Vector3(0f, 0f, -1.1f),
+ yaw = 180f,
+ },
+ new WLFlowStepDef
+ {
+ label = "사과나무 설치", price = 60,
+ platformFromDungeonRow = 2, // 기존 발판 #3 좌표
+ kind = "prefab",
+ prefabPath = "Assets/FarmingIsland/Prefabs/FruitTrees/Tree_Apple.prefab",
+ },
+ new WLFlowStepDef
+ {
+ label = "던전 생성", price = 100,
+ platformFromDungeonRow = 3, // 기존 발판 #4 좌표
+ kind = "dungeon",
+ dungeonRow = 0, // 던전 1 입구(Arch 포탈)
+ },
+ };
+
+ [Tooltip("1 = 원본 에셋의 섬 확장 가격판(FI Purchaser)을 **첫 던전 승리 전까지** 숨긴다.\n" +
+ "승리하면 그대로 다시 켜져서 이후는 FI 원본 순서(Island.Constraint)대로 확장된다. 0 = 처음부터 보인다.")]
+ public int expansionAfterFirstWin = 1;
+
+ [Tooltip("첫 승리 여부를 적는 PlayerPrefs 키(시작마다 초기화 대상).")]
+ public string expansionUnlockPrefsKey = "WL_Island_ExpansionUnlocked";
+
+ [Tooltip("확장 가격판 재숨김 주기(초) — FI 가 나중에 만들어 내는 가격판까지 잡는다.")]
+ public float expansionHideInterval = 0.5f;
+
+ [Tooltip("위 재숨김을 계속하는 시간(초). 0 이면 처음 1회만 숨긴다.")]
+ public float expansionHideSeconds = 20f;
+
// ───────────────────────────────── 던전 표
[Header("던전 표 — 한 줄 = 던전 하나")]
public WLDungeonDef[] dungeons = new WLDungeonDef[0];
@@ -497,6 +611,13 @@ namespace WL.Island
return null;
}
+ /// 던전 표의 **줄 번호**로 그 행을 그대로 준다(enabled_ 와 무관 — 좌표만 빌려 쓸 때 쓴다 · WL-816zc).
+ public WLDungeonDef DungeonRow(int rawIndex)
+ {
+ if (dungeons == null || rawIndex < 0 || rawIndex >= dungeons.Length) return null;
+ return dungeons[rawIndex];
+ }
+
/// 씬 이름으로 던전 행을 찾는다(같은 씬을 여러 던전이 써도 되므로 맵 ID 우선).
public WLDungeonDef FindBySceneName(string sceneName)
{