Project_WL/Assets/WL/Island/WLIslandBridge.cs

416 lines
21 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// 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
{
/// <summary>섬 ↔ 던전 왕복. 정적 진입 + 숨김 러너 1개(코루틴·입력용).</summary>
public static class WLIslandBridge
{
// ── 진단(프로브·보고가 읽는다)
public static int IslandLoads, DungeonLoads, ClearGrants, FailGrants, CoinGranted, GatesSpawned;
public static string LastLog = "";
/// <summary>
/// 프로브 전용 — 비어 있지 않으면 단독 Play 경로에서 이 씬 이름으로 대신 들어간다.
/// 🔴 던전 복사본 씬이 아직 빌드 목록에 없어(등록은 Lead·PD 승인 사항) 로그인 없는 검증에 쓴다.
/// 실기(InGameInfo 가 있는 흐름)에는 영향이 없다 — 그쪽은 Load_Map(mapId) 를 탄다.
/// </summary>
public static string DebugSceneOverride = "";
static bool s_installed;
static int s_pendingGold;
static WLIslandRunner s_runner;
static readonly List<WLDungeonGate> s_gates = new List<WLDungeonGate>(8);
/// <summary>보류 중인 보상(섬에 도착하면 더해진다).</summary>
public static int PendingGold { get { return s_pendingGold; } }
/// <summary>현재 씬에 살아 있는 던전 입구들.</summary>
public static List<WLDungeonGate> Gates { get { return s_gates; } }
/// <summary>씬을 넘어 사는 코루틴 러너(WL-816g 의 발판 흐름도 이것을 쓴다).</summary>
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<WLIslandRunner>();
}
// ─────────────────────────────────────────────────────────────────
// 씬 로드
// ─────────────────────────────────────────────────────────────────
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()) + ")");
}
/// <summary>발판에서 값을 치렀을 때 던전 입구 1개를 추가로 켠다(표 한 행 = 던전 하나).</summary>
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);
}
}
/// <summary>던전에서 섬으로 돌아간다(클리어·실패·중단 공통).</summary>
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);
}
/// <summary>표의 goldMultiplier(WLStageTable.cs:103) — 소비처가 없던 필드의 첫 소비처.</summary>
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;
}
/// <summary>보류 보상을 비운다(WL-816g 세이브 초기화가 부른다 — 초기 골드가 10 이 되도록).</summary>
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);
}
/// <summary>보류분을 섬 코인에 더한다. 성공하면 보류를 비운다.</summary>
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;
}
/// <summary>
/// 검증용: 클리어 1회를 **실제 계산식 그대로** 태운다(전투만 건너뛴다).
/// `ComputeGold`(표의 goldMultiplier 소비) → 보류 설정까지 실기와 같은 코드가 돈다.
/// </summary>
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;
}
/// <summary>검증용: 보상분을 직접 주입한다(전투 없이 ④⑤⑥ 경로만 태운다).</summary>
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);
}
}
/// <summary>코루틴·입력을 도맡는 숨김 러너(새 Manager 가 아니다 · 씬에 남지 않는다).</summary>
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);
}
}
}