// ───────────────────────────────────────────────────────────────────────────── // 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); // 이미 있으면 안에서 건너뛴다 // 2026-09-16 PD 「던전 개방 이후에는 기존 에셋과 동일한 순서로 확장 노드」 — 던전 노드가 열리면 원본 확장 가격판을 개방한다. if (d != null && cfg.expansionUnlockOnDungeonOpen != 0) WLIslandExpansionGate.Unlock(cfg); 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; // 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 와 같다(꺼진 오브젝트에서 코루틴이 시작되면 에러). // ───────────────────────────────────────────────────────────────────── 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; // 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; 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(); } } }