// ───────────────────────────────────────────────────────────────────────────── // WLIslandBridge.cs — 섬 ↔ 던전 왕복의 배선 (원본 코드 0줄 · 새 Manager/Singleton 0) // // PD 지시 #816 · 발주서 WL-816d §1-④ / §1-B · 816c 설계서 §B-1 표(1~6단계) // // ■ 한 바퀴 (설계서 §B-1 을 그대로 구현) // ① 섬 진입 : Load_Map(islandMapId) → 섬 씬(Level01)이 Additive 로 붙는다 // ② 던전 입구 : WLDungeonGate(더미)에 다가가면 「들어가기」 // ③ 던전 입장 : Load_Map(dungeon.mapId) → 던전 씬 → StageDirector.Enter(stageIndex) // ④ 보상 확정 : StageEvents.StageCleared 구독 → StageDef.goldMultiplier 로 코인 계산 → **보류** // ⑤ 섬 복귀 : StageExited(toLobby) → Load_Map(islandMapId) // ⑥ 지급 : 섬 씬 로드 후 IslandManager.Instance.Coin += 보류분 // (816c 실측: IslandManager.Awake 에서 로드가 끝나 있어 Start 전에 더해도 안전) // // ■ 중복 정리(설계서 §B-2) — 전부 **런타임**에 한다. FI 씬·코드는 0줄. // 섬 : WL 전투 PC(Actor) 숨김 · FI 카메라/AudioListener 중복 해제 // 던전: 아레나 데모 리그(ArenaWalkerRig)·여분 카메라 해제 // // 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다. // 🔴 어셈블리 주의: Assets/WL/Island/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; using WL.Combat.Stage; namespace WL.Island { /// 섬 ↔ 던전 왕복. 정적 진입 + 숨김 러너 1개(코루틴·입력용). public static class WLIslandBridge { // ── 진단(프로브·보고가 읽는다) public static int IslandLoads, DungeonLoads, ClearGrants, FailGrants, CoinGranted, GatesSpawned; public static string LastLog = ""; /// /// 프로브 전용 — 비어 있지 않으면 단독 Play 경로에서 이 씬 이름으로 대신 들어간다. /// 🔴 던전 복사본 씬이 아직 빌드 목록에 없어(등록은 Lead·PD 승인 사항) 로그인 없는 검증에 쓴다. /// 실기(InGameInfo 가 있는 흐름)에는 영향이 없다 — 그쪽은 Load_Map(mapId) 를 탄다. /// public static string DebugSceneOverride = ""; static bool s_installed; static int s_pendingGold; static WLIslandRunner s_runner; static readonly List s_gates = new List(8); /// 보류 중인 보상(섬에 도착하면 더해진다). public static int PendingGold { get { return s_pendingGold; } } /// 현재 씬에 살아 있는 던전 입구들. public static List Gates { get { return s_gates; } } /// 씬을 넘어 사는 코루틴 러너(WL-816g 의 발판 흐름도 이것을 쓴다). public static WLIslandRunner Runner { get { EnsureRunner(); return s_runner; } } // ───────────────────────────────────────────────────────────────── // 설치 — 씬 하나 없이 자동으로 걸린다(원본 훅 0줄) // ───────────────────────────────────────────────────────────────── [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] public static void Install() { if (s_installed) return; var cfg = WLIslandSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; s_installed = true; s_pendingGold = PlayerPrefs.GetInt(cfg.pendingPrefsKey, 0); SceneManager.sceneLoaded += OnSceneLoaded; StageEvents.StageCleared.Add(OnStageCleared); StageEvents.StageFailed.Add(OnStageFailed); StageEvents.StageExited.Add(OnStageExited); EnsureRunner(); // 이미 열려 있는 씬(에디터에서 씬을 직접 열고 Play 한 경우)도 한 번 훑는다. for (int i = 0; i < SceneManager.sceneCount; i++) { var sc = SceneManager.GetSceneAt(i); if (sc.isLoaded) OnSceneLoaded(sc, LoadSceneMode.Additive); } Log(cfg, "설치 완료 — 보류 보상 " + s_pendingGold); } static void EnsureRunner() { if (s_runner != null) return; var go = new GameObject("~WLIslandRunner"); Object.DontDestroyOnLoad(go); s_runner = go.AddComponent(); } // ───────────────────────────────────────────────────────────────── // 씬 로드 // ───────────────────────────────────────────────────────────────── static void OnSceneLoaded(Scene scene, LoadSceneMode mode) { var cfg = WLIslandSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; if (!string.IsNullOrEmpty(cfg.islandSceneName) && scene.name == cfg.islandSceneName) { IslandLoads++; EnsureRunner(); // 🔴 LoadMapMgr 는 **PCInfo.Make_Actors 보다 먼저** 있어야 한다(같은 프레임). WLIslandSceneSetup.EnsureMapNodes(cfg, scene); s_runner.StartCoroutine(Co_IslandReady(cfg, scene)); return; } // 🔴 WL-816i2 — 들어가겠다고 **요청한** 던전이 우선이다. // 표에서 씬 이름으로만 찾으면(FindBySceneName) 같은 씬을 쓰는 던전 1·3(WL_Dungeon01), // 2·4(WL_Dungeon02) 중 **언제나 앞 줄**이 잡혀, 던전 3 에 들어가도 던전 1 의 스테이지 // (몹 3마리 · 보상 배수 1)가 돌았다. 그래서 「다른 던전에 들어가도 전투가 이상하다」가 된다. var dg = WLDungeonSceneSetup.Pending; if (dg == null || dg.enabled_ == 0 || !scene.name.Equals(dg.sceneName)) dg = cfg.FindBySceneName(scene.name); if (dg != null) { DungeonLoads++; EnsureRunner(); s_runner.StartCoroutine(WLDungeonSceneSetup.Co_Prepare(cfg, scene, dg)); } } static IEnumerator Co_IslandReady(WLIslandSettings cfg, Scene scene) { // 같은 프레임에는 FI 매니저들의 Awake 가 아직 안 끝났을 수 있다 — 한 프레임 기다린다. yield return null; WLIslandSceneSetup.FixDuplicates(cfg, scene); WLIslandSceneSetup.SwapPlayerVisual(cfg, scene); SpawnGates(cfg, scene); // WL-816g — 밭을 숨기고 그 자리에 「골드 납품 발판」 1개를 놓는다(꺼져 있으면 무동작). WLIslandGateFlow.Apply(cfg, scene); // 보류 보상 지급(⑥) — IslandManager 가 살아날 때까지 잠깐 기다린다. float t = 0f; while (t < 5f && !WLIslandCoin.Ready) { t += Time.unscaledDeltaTime; yield return null; } GrantPending(cfg); // 🔴 WL 전투 PC 는 Addressables 콜백으로 **나중에** 생긴다(PCInfo.cs:16) — 나타나면 그때 숨긴다. t = 0f; while (t < cfg.hidePcWatchSeconds) { if (WLIslandSceneSetup.HideWlPc(cfg)) break; t += Time.unscaledDeltaTime; yield return null; } } // ───────────────────────────────────────────────────────────────── // 던전 입구 배치 // ───────────────────────────────────────────────────────────────── static void SpawnGates(WLIslandSettings cfg, Scene scene) { s_gates.Clear(); if (cfg.dungeons == null) return; // 🔴 WL-816g: 초기에는 던전이 **하나도** 보이지 않는다. 발판에서 골드를 낸 만큼만 열린다. // `gateFlowEnabled = 0` 이면 UnlockedCount 가 int.MaxValue = 지금까지처럼 전부 배치. int limit = WLIslandGateFlow.UnlockedCount(cfg); int made = 0; for (int i = 0; i < cfg.dungeons.Length; i++) { var d = cfg.dungeons[i]; if (d == null || d.enabled_ == 0) continue; if (made >= limit) break; var gate = WLDungeonGate.Create(cfg, d, scene); if (gate != null) { s_gates.Add(gate); GatesSpawned++; made++; } } Log(cfg, "던전 입구 " + s_gates.Count + "개 배치(해금 " + (limit == int.MaxValue ? "전체" : limit.ToString()) + ")"); } /// 발판에서 값을 치렀을 때 던전 입구 1개를 추가로 켠다(표 한 행 = 던전 하나). public static void SpawnOneGate(WLIslandSettings cfg, WLDungeonDef d, Scene scene) { if (cfg == null || d == null) return; for (int i = 0; i < s_gates.Count; i++) if (s_gates[i] != null && s_gates[i].Def == d) return; // 이미 있다 var gate = WLDungeonGate.Create(cfg, d, scene); if (gate == null) return; s_gates.Add(gate); GatesSpawned++; Log(cfg, "던전 입구 추가 — " + d.displayName + " @ " + d.gatePosition.ToString("F1")); } // ───────────────────────────────────────────────────────────────── // 던전 입장 / 복귀 // ───────────────────────────────────────────────────────────────── public static void EnterDungeon(WLDungeonDef d) { var cfg = WLIslandSettings.Instance; if (cfg == null || d == null) return; EnsureRunner(); WLIslandPrompt.Hide(); WLDungeonSceneSetup.Pending = d; WLIslandSceneSetup.RestoreDuplicates(); if (InGameInfo.Ins != null) { // 🔴 WL-816i — 씬을 바꾸기 **전에** 맵 노드를 던전 것으로 돌려 놓는다. // Load_Map 은 섬 씬 언로드와 던전 씬 로드를 같은 프레임에 건다 → 그 사이에 // `PCInfo.Make_Actors` 가 `LoadMapMgr.Ins.Get_PortalPos` 를 부르는데, // 노드가 씬과 함께 파괴되면 MissingReferenceException 이 난다(PD 실플레이 에러 ①). var nodes = WLIslandMapNodes.Ensure(cfg); if (nodes != null) { nodes.LoadSceneName = d.sceneName; nodes.SetStartPos(WLIslandSceneSetup.DungeonStartPos(d), WLDungeonCameraRig.SolvedYaw(cfg)); } Log(cfg, "던전 입장 — " + d.displayName + " (map " + d.mapId + " · stage " + d.stageIndex + ")"); InGameInfo.Ins.Load_Map(d.mapId); } else { // 에디터에서 섬 씬만 열고 Play 한 경우(로그인 없는 검증 경로). string scn = string.IsNullOrEmpty(DebugSceneOverride) ? d.sceneName : DebugSceneOverride; Log(cfg, "던전 입장(단독) — " + scn); SceneManager.LoadScene(scn, LoadSceneMode.Single); } } /// 던전에서 섬으로 돌아간다(클리어·실패·중단 공통). public static void ReturnToIsland(string reason) { var cfg = WLIslandSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; if (InGameInfo.Ins != null) { // 🔴 WL-816i — 나갈 때도 마찬가지다(던전 씬이 내려가며 노드가 죽지 않게 미리 섬 것으로). var nodes = WLIslandMapNodes.Ensure(cfg); if (nodes != null) nodes.LoadSceneName = cfg.islandSceneName; Log(cfg, "섬으로 복귀(" + reason + ") — 보류 보상 " + s_pendingGold); InGameInfo.Ins.Load_Map(cfg.islandMapId); } else { Log(cfg, "섬으로 복귀(단독 · " + reason + ")"); SceneManager.LoadScene(cfg.islandSceneName, LoadSceneMode.Single); } } // ───────────────────────────────────────────────────────────────── // 보상 (④ 확정 · ⑥ 지급) // ───────────────────────────────────────────────────────────────── static void OnStageCleared(in StageClearResult r) { var cfg = WLIslandSettings.Instance; 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++; Log(cfg, "클리어 보상 확정 — " + gold + " 코인 (처치 " + r.killed + "/" + r.total + " · 배수 " + MultiplierOf(r.index).ToString("F2") + ")"); } static void OnStageFailed(in StageFailedEvent e) { var cfg = WLIslandSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; if (WLDungeonSceneSetup.Current == null) return; SetPending(cfg, cfg.goldOnFail); FailGrants++; Log(cfg, "실패(" + e.reason + ") — 보상 " + cfg.goldOnFail); } static void OnStageExited(in StageExitedEvent e) { var cfg = WLIslandSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; if (!e.toLobby) return; if (WLDungeonSceneSetup.Current == null) return; WLDungeonSceneSetup.Current = null; ReturnToIsland(e.reason); } /// 표의 goldMultiplier(WLStageTable.cs:103) — 소비처가 없던 필드의 첫 소비처. public static float MultiplierOf(int stageIndex) { var t = WLStageTable.Instance; if (t == null) return 1f; var def = t.Get(stageIndex); if (def == null || def.goldMultiplier <= 0f) return 1f; return def.goldMultiplier; } public static int ComputeGold(WLIslandSettings cfg, in StageClearResult r) { if (!r.win) return cfg.goldOnFail; float g = cfg.goldClearBonus + cfg.goldPerKill * r.killed; if (r.isBoss) g += cfg.goldBossBonus; if (cfg.applyGoldMultiplier != 0) g *= MultiplierOf(r.index); int v = Mathf.RoundToInt(g); return v < 0 ? 0 : v; } /// 보류 보상을 비운다(WL-816g 세이브 초기화가 부른다 — 초기 골드가 10 이 되도록). public static void ClearPending() { s_pendingGold = 0; var cfg = WLIslandSettings.Instance; if (cfg != null) PlayerPrefs.SetInt(cfg.pendingPrefsKey, 0); } static void SetPending(WLIslandSettings cfg, int gold) { s_pendingGold = gold < 0 ? 0 : gold; PlayerPrefs.SetInt(cfg.pendingPrefsKey, s_pendingGold); } /// 보류분을 섬 코인에 더한다. 성공하면 보류를 비운다. public static bool GrantPending(WLIslandSettings cfg) { if (cfg == null) cfg = WLIslandSettings.Instance; if (cfg == null || s_pendingGold <= 0) return false; int before; if (!WLIslandCoin.TryGet(out before)) { Log(cfg, "보상 지급 보류 — IslandManager 없음(" + s_pendingGold + ")"); return false; } if (!WLIslandCoin.Add(s_pendingGold)) return false; CoinGranted += s_pendingGold; int after; WLIslandCoin.TryGet(out after); Log(cfg, "🔴 보상 지급 — 코인 " + before + " → " + after + " (+" + s_pendingGold + ")"); s_pendingGold = 0; PlayerPrefs.SetInt(cfg.pendingPrefsKey, 0); return true; } /// /// 검증용: 클리어 1회를 **실제 계산식 그대로** 태운다(전투만 건너뛴다). /// `ComputeGold`(표의 goldMultiplier 소비) → 보류 설정까지 실기와 같은 코드가 돈다. /// public static int DebugSimulateClear(int stageIndex, int killed, bool isBoss) { var cfg = WLIslandSettings.Instance; if (cfg == null) return 0; var t = WLStageTable.Instance; var def = t != null ? t.Get(stageIndex) : null; var r = new StageClearResult { index = stageIndex, stageId = def != null ? def.id : 0, win = true, killed = killed, total = def != null ? def.TotalCount : killed, isBoss = isBoss }; int gold = ComputeGold(cfg, r); SetPending(cfg, gold); ClearGrants++; Log(cfg, "검증 클리어 — 스테이지 " + stageIndex + " · 처치 " + killed + " · 배수 " + MultiplierOf(stageIndex).ToString("F2") + " → 보류 " + gold + " 코인"); return gold; } /// 검증용: 보상분을 직접 주입한다(전투 없이 ④⑤⑥ 경로만 태운다). public static void DebugSetPending(int gold) { var cfg = WLIslandSettings.Instance; if (cfg == null) return; SetPending(cfg, gold); Log(cfg, "검증 주입 — 보류 보상 " + gold); } internal static void Log(WLIslandSettings cfg, string msg) { LastLog = msg; if (cfg != null && cfg.verboseLog != 0) Debug.Log("[WL-816d] " + msg); } } /// 코루틴·입력을 도맡는 숨김 러너(새 Manager 가 아니다 · 씬에 남지 않는다). public sealed class WLIslandRunner : MonoBehaviour { float _guardTimer; void Update() { var cfg = WLIslandSettings.Instance; if (cfg == null || cfg.enabled_ == 0) return; WLDungeonGate.TickAll(cfg); // 🔴 WL-816i2 — 재입장 시 전투가 시작되지 않던 것: 죽은 카메라 연출 대상을 걷어낸다. // 매 프레임 · 목록이 비었거나 죽은 항목이 없으면 아무 일도 하지 않는다(근거는 WLIslandCameraRig.cs). if (cfg.fixCamActionTargets != 0) WLDungeonCameraRig.PruneCamActionTargets(MyValue._RealCamera); // WL-816i 에러 ② — 투영이 깨진 카메라 감시(자세한 근거는 WLIslandCameraRig.cs) if (cfg.fixDegenerateCameras == 0) return; _guardTimer -= Time.unscaledDeltaTime; if (_guardTimer > 0f) return; _guardTimer = cfg.cameraGuardInterval > 0f ? cfg.cameraGuardInterval : 0.5f; WLDungeonCameraRig.RepairDegenerateCameras(cfg); } } }