Merge branch 'wl/ui/WL-816zc-island-flow'

This commit is contained in:
깃 관리자 2026-09-15 16:04:42 +09:00
commit 4d5aa6f546
8 changed files with 599 additions and 12 deletions

View File

@ -94,7 +94,7 @@ MonoBehaviour:
campDecorSnapToGround: 1 campDecorSnapToGround: 1
applyStartCoin: 1 applyStartCoin: 1
startCoin: 10 startCoin: 10
saveResetToken: 816g-1 saveResetToken: 816zc-1
resetSaveEveryStart: 1 resetSaveEveryStart: 1
dungeonCameraMatchIsland: 1 dungeonCameraMatchIsland: 1
camPitch: 45 camPitch: 45
@ -128,7 +128,7 @@ MonoBehaviour:
gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab
platformUseFarm: 1 platformUseFarm: 1
platformPosition: {x: 0, y: 0, z: 0} platformPosition: {x: 0, y: 0, z: 0}
- enabled_: 1 - enabled_: 0
id: 2 id: 2
displayName: "\uB358\uC804 2" displayName: "\uB358\uC804 2"
sceneName: WL_Dungeon02 sceneName: WL_Dungeon02
@ -141,7 +141,7 @@ MonoBehaviour:
gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab
platformUseFarm: 0 platformUseFarm: 0
platformPosition: {x: -2, y: 0, z: -2} platformPosition: {x: -2, y: 0, z: -2}
- enabled_: 1 - enabled_: 0
id: 3 id: 3
displayName: "\uB358\uC804 3" displayName: "\uB358\uC804 3"
sceneName: WL_Dungeon01 sceneName: WL_Dungeon01
@ -154,7 +154,7 @@ MonoBehaviour:
gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab gatePrefabPath: Assets/WL/Island/Prefabs/WL_DungeonGate_Arch.prefab
platformUseFarm: 0 platformUseFarm: 0
platformPosition: {x: -2.5, y: 0, z: 8.5} platformPosition: {x: -2.5, y: 0, z: 8.5}
- enabled_: 1 - enabled_: 0
id: 4 id: 4
displayName: "\uB358\uC804 4" displayName: "\uB358\uC804 4"
sceneName: WL_Dungeon02 sceneName: WL_Dungeon02

View File

@ -255,9 +255,14 @@ namespace WL.Island
static void OnStageCleared(in StageClearResult r) static void OnStageCleared(in StageClearResult r)
{ {
var cfg = WLIslandSettings.Instance; 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 데모)는 건드리지 않는다 if (WLDungeonSceneSetup.Current == null) return; // 던전이 아닌 스테이지(#815 데모)는 건드리지 않는다
// 🔴 WL-816zc ② — 「전투 승리 후 섬 확장 노드 추가」. 보상과 무관하게 승리 그 자체로 연다.
WLIslandExpansionGate.Unlock(cfg);
if (cfg.rewardEnabled == 0) return;
int gold = ComputeGold(cfg, r); int gold = ComputeGold(cfg, r);
SetPending(cfg, gold); SetPending(cfg, gold);
ClearGrants++; ClearGrants++;

View File

@ -36,9 +36,19 @@ namespace WL.Island
{ {
Clear(); Clear();
if (cfg == null || cfg.campDecorEnabled == 0) return; 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; LastCenter = center;
var root = new GameObject(RootName); var root = new GameObject(RootName);

View File

@ -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
{
/// <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); // 이미 있으면 안에서 건너뛴다
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<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;
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<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();
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;
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;
if (scene.IsValid() && p.gameObject.scene != scene) 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;
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();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8c3f1a274b6d4e1f9a05d7c2e6b83f41

View File

@ -100,13 +100,22 @@ namespace WL.Island
s_farmCenter = farmCenter; s_farmCenter = farmCenter;
s_haveFarm = haveFarm; s_haveFarm = haveFarm;
// WL-816zc ③ — 원본 섬 확장 가격판은 첫 던전 승리 전까지 숨긴다(켜고 끄기만 · FI 순서 그대로).
WLIslandExpansionGate.Apply(cfg, scene);
RefreshPlatform(cfg, scene); RefreshPlatform(cfg, scene);
// v2 는 RefreshPlatform 안에서 캠프까지 같이 그린다 — 두 번 그리지 않는다.
if (!WLIslandFlowV2.Active(cfg))
WLIslandCampDecor.Refresh(cfg, scene); // WL-816y — 세이브 복원(해금 ≥ 1 이면 캠프 재생성) WLIslandCampDecor.Refresh(cfg, scene); // WL-816y — 세이브 복원(해금 ≥ 1 이면 캠프 재생성)
} }
static Vector3 s_farmCenter; static Vector3 s_farmCenter;
static bool s_haveFarm; static bool s_haveFarm;
/// <summary>밭 구역 중심(WL-816zc 플로우가 첫 자리로 쓴다).</summary>
public static Vector3 FarmCenter { get { return s_farmCenter; } }
public static bool HaveFarmCenter { get { return s_haveFarm; } }
/// <summary> /// <summary>
/// 🔴 던전 하나 = **발판 한 자리**(WL-816g · PD 정정 2026-09-13 /// 🔴 던전 하나 = **발판 한 자리**(WL-816g · PD 정정 2026-09-13
/// 「던전 열기는 한 번만 · 이후 같은 자리에 또 등장해서는 안 된다」). /// 「던전 열기는 한 번만 · 이후 같은 자리에 또 등장해서는 안 된다」).
@ -126,7 +135,7 @@ namespace WL.Island
} }
/// <summary>좌표 위에서 아래로 쏘아 지면 높이를 맞춘다(맞는 것이 없으면 준 값 그대로).</summary> /// <summary>좌표 위에서 아래로 쏘아 지면 높이를 맞춘다(맞는 것이 없으면 준 값 그대로).</summary>
static Vector3 SnapToGround(WLIslandSettings cfg, Vector3 p) public static Vector3 SnapToGround(WLIslandSettings cfg, Vector3 p)
{ {
if (cfg.platformSnapToGround == 0) return p; if (cfg.platformSnapToGround == 0) return p;
RaycastHit hit; RaycastHit hit;
@ -139,6 +148,9 @@ namespace WL.Island
/// <summary>발판을 지금 해금 상태에 맞게 만들거나 지운다.</summary> /// <summary>발판을 지금 해금 상태에 맞게 만들거나 지운다.</summary>
public static void RefreshPlatform(WLIslandSettings cfg, Scene scene) 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 unlocked = UnlockedCount(cfg);
int usable = UsableDungeonCount(cfg); int usable = UsableDungeonCount(cfg);
@ -188,7 +200,7 @@ namespace WL.Island
WLIslandBridge.Log(cfg, LastLog); WLIslandBridge.Log(cfg, LastLog);
} }
static bool PlayerInRange(WLIslandSettings cfg, Vector3 pos) public static bool PlayerInRange(WLIslandSettings cfg, Vector3 pos)
{ {
var fi = Object.FindFirstObjectByType<CryingSnow.FarmingIsland.PlayerController>(); var fi = Object.FindFirstObjectByType<CryingSnow.FarmingIsland.PlayerController>();
if (fi == null) return false; if (fi == null) return false;
@ -205,6 +217,9 @@ namespace WL.Island
{ {
if (cfg == null) return; if (cfg == null) return;
// WL-816zc — 초기 플로우 v2 가 켜져 있으면 「다음 단계」가 이 자리를 이어받는다.
if (WLIslandFlowV2.Active(cfg)) { WLIslandFlowV2.OnPaid(cfg, platformPos); return; }
int unlocked = UnlockedCount(cfg); int unlocked = UnlockedCount(cfg);
int usable = UsableDungeonCount(cfg); int usable = UsableDungeonCount(cfg);
if (unlocked >= usable) return; if (unlocked >= usable) return;
@ -555,7 +570,9 @@ namespace WL.Island
public const string ObjectName = "~WL_GoldPlatform"; public const string ObjectName = "~WL_GoldPlatform";
public static WLGoldPlatform Create(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int nextIndex) /// <param name="labelOverride">비우면 `platformLabelFormat`(던전 열기) · 채우면 그 글자를 그대로 띄운다(WL-816zc 단계 문구).</param>
public static WLGoldPlatform Create(WLIslandSettings cfg, Scene scene, Vector3 pos, int price, int nextIndex,
string labelOverride = null)
{ {
GameObject root = null; GameObject root = null;
@ -584,7 +601,9 @@ namespace WL.Island
// — 모델이 아니라 글자 하나고, `platformShowLabel = 0` 이면 FI 원본 모습 100 % 가 된다. // — 모델이 아니라 글자 하나고, `platformShowLabel = 0` 이면 FI 원본 모습 100 % 가 된다.
if (cfg.platformShowLabel != 0) if (cfg.platformShowLabel != 0)
WLIslandLabel.Attach(root.transform, new Vector3(0f, cfg.platformLabelHeight, 0f), 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; return self;
} }

View File

@ -85,6 +85,9 @@ namespace WL.Island
PlayerPrefs.SetString(kTokenKey, cfg.saveResetToken); PlayerPrefs.SetString(kTokenKey, cfg.saveResetToken);
PlayerPrefs.SetInt(cfg.dungeonUnlockPrefsKey, 0); // 해금한 던전도 0 개로 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(); PlayerPrefs.Save();
WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지) WLIslandBridge.ClearPending(); // 보류 보상도 비운다(정적 값까지)
} }

View File

@ -68,6 +68,58 @@ namespace WL.Island
public Vector3 platformPosition = Vector3.zero; public Vector3 platformPosition = Vector3.zero;
} }
/// <summary>
/// WL-816zc — 「섬 초기 플로우」 한 단계 = 이 표의 한 행.
/// 발판 1개가 이 행의 값(문구·가격·자리)으로 서고, 값을 치르면 이 행의 설치물이 그 자리에 남는다.
/// 코드 수정 0 — 순서·가격·설치물은 전부 여기 값이다(C45).
/// </summary>
[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)] [CreateAssetMenu(fileName = "WLIslandSettings", menuName = "WL/Island Settings", order = 340)]
public sealed class WLIslandSettings : ScriptableObject public sealed class WLIslandSettings : ScriptableObject
{ {
@ -469,6 +521,68 @@ namespace WL.Island
[Tooltip("위 대기의 상한(초). 이 시간이 지나면 조건과 무관하게 로딩 칸을 채운다(로딩 화면이 영영 안 닫히는 것 방지).")] [Tooltip("위 대기의 상한(초). 이 시간이 지나면 조건과 무관하게 로딩 칸을 채운다(로딩 화면이 영영 안 닫히는 것 방지).")]
public float dungeonLoadingMaxWaitSeconds = 8f; 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("던전 표 — 한 줄 = 던전 하나")] [Header("던전 표 — 한 줄 = 던전 하나")]
public WLDungeonDef[] dungeons = new WLDungeonDef[0]; public WLDungeonDef[] dungeons = new WLDungeonDef[0];
@ -497,6 +611,13 @@ namespace WL.Island
return null; return null;
} }
/// <summary>던전 표의 **줄 번호**로 그 행을 그대로 준다(enabled_ 와 무관 — 좌표만 빌려 쓸 때 쓴다 · WL-816zc).</summary>
public WLDungeonDef DungeonRow(int rawIndex)
{
if (dungeons == null || rawIndex < 0 || rawIndex >= dungeons.Length) return null;
return dungeons[rawIndex];
}
/// <summary>씬 이름으로 던전 행을 찾는다(같은 씬을 여러 던전이 써도 되므로 맵 ID 우선).</summary> /// <summary>씬 이름으로 던전 행을 찾는다(같은 씬을 여러 던전이 써도 되므로 맵 ID 우선).</summary>
public WLDungeonDef FindBySceneName(string sceneName) public WLDungeonDef FindBySceneName(string sceneName)
{ {