Merge branch 'wl/systems/WL-816i-dungeon-enter'

This commit is contained in:
깃 관리자 2026-09-13 17:35:56 +09:00
commit f9b831ddc0
7 changed files with 488 additions and 29 deletions

View File

@ -78,6 +78,16 @@ MonoBehaviour:
applyStartCoin: 1
startCoin: 10
saveResetToken: 816g-1
dungeonCameraMatchIsland: 1
camPitch: 45
camYaw: 0
camDistance: 22
camFov: 60
camLookHeight: 0
camLockYaw: 1
camLeadMultiplier: 1
fixDegenerateCameras: 1
cameraGuardInterval: 0.5
dungeons:
- enabled_: 1
id: 1

View File

@ -48,6 +48,11 @@ namespace WL.Island
WLIslandSceneSetup.EnsureDungeonMapNodes(cfg, scene, d);
FixDuplicates(cfg, scene);
// 🔴 WL-816i — 던전 카메라를 **섬 구도**(각 45° · 거리 22 m · FOV 60)로 맞춘다.
// 씬이 내려가면 이 오브젝트도 사라지고 OnDestroy 가 원래 값을 돌려놓는다(C8).
if (Object.FindFirstObjectByType<WLDungeonCameraRig>() == null)
WLDungeonCameraRig.Create(cfg, scene);
// ① NavMesh
if (cfg.bakeDungeonNavMesh != 0) BakeNavMesh(cfg, scene);
@ -173,15 +178,28 @@ namespace WL.Island
for (int i = 0; i < walkers.Length; i++)
if (walkers[i] != null) { walkers[i].gameObject.SetActive(false); rigs++; }
var allCams = Object.FindObjectsByType<Camera>(FindObjectsSortMode.None);
for (int i = 0; i < allCams.Length; i++)
// 🔴 WL-816i — 전에는 `c == Camera.main` 을 건너뛰었는데, 아레나 복사본 씬의 `Main Camera`(직교 · orthoSize 10)가
// 바로 그 MainCamera 태그를 달고 있어서 **룩 개발용 직교 카메라가 그대로 살아 전투 화면을 차지**했다.
// 던전 씬 안의 카메라는 전부 룩용이다 → 전투 카메라(RealCamera · Ingame 씬)만 남기고 모두 끈다.
// 단 전투 카메라를 못 찾으면 화면이 까매지므로 아무것도 끄지 않는다(안전).
var combat = Object.FindFirstObjectByType<RealCamera>();
var combatCam = combat != null ? combat.GetComponent<Camera>() : null;
if (combatCam != null)
{
var c = allCams[i];
if (c == null || c.gameObject.scene != scene) continue;
if (c == Camera.main) continue;
c.enabled = false; cams++;
var al = c.GetComponent<AudioListener>();
if (al != null && al.enabled) { al.enabled = false; listeners++; }
if (!combatCam.enabled) { combatCam.enabled = true; cams++; }
var allCams = Object.FindObjectsByType<Camera>(FindObjectsSortMode.None);
for (int i = 0; i < allCams.Length; i++)
{
var c = allCams[i];
if (c == null || c == combatCam || c.gameObject.scene != scene) continue;
if (c.enabled) { c.enabled = false; cams++; }
var al = c.GetComponent<AudioListener>();
if (al != null && al.enabled) { al.enabled = false; listeners++; }
}
}
else
{
WLIslandBridge.Log(cfg, "🔴 던전 전투 카메라(RealCamera)를 못 찾았다 — 씬 카메라를 그대로 둔다");
}
WLIslandBridge.Log(cfg, "던전 중복 정리 — 데모 리그 " + rigs + " · 카메라 " + cams + " · 리스너 " + listeners);

View File

@ -199,6 +199,16 @@ namespace WL.Island
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);
}
@ -219,6 +229,10 @@ namespace WL.Island
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);
}
@ -366,11 +380,20 @@ namespace WL.Island
/// <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-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);
}
}
}

View File

@ -0,0 +1,259 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLIslandCameraRig.cs — 던전(전장)의 카메라 구도를 **섬 구도 그대로** 만든다
//
// PD 지시(2026-09-13) 「카메라 구도는 섬에서 조작할 때의 각도와 뷰를 말한거야.
// 전장에 진입해도 바뀌지 않아야 하기 때문」
// → 기준은 **섬**. 던전 카메라를 섬 값에 맞춘다(반대가 아니다).
//
// ■ 섬 카메라 실측(Assets/FarmingIsland/Scenes/Level01.unity · 씬 파일 값 그대로)
// PlayerVirtualCamera(Cinemachine) — 회전 오일러 (45, 0, 0) · Follow = Player
// Body = CinemachineFramingTransposer · m_CameraDistance 22 · ScreenX/Y 0.5(대상 화면 정중앙)
// Lens — FieldOfView 60 · 원근(orthographic 0) · Near 0.1 · Far 500
// = 「대상에서 22 m · 45° 위 · 화각 60° · 플레이어를 따라 돌지 않는 고정 방향」
//
// ■ 던전 카메라는 힘민지 `RealCamera`(Assets/Script/Util/RealCamera.cs) 다. 원본 0줄이 원칙이라
// **그 컴포넌트의 값만** 매 프레임 채워 넣는다(Update → RealCamera.LateUpdate 순서).
// 그러면 흔들림(ShakeCamera)·스킬 뷰(CamStatus 2·3)·가림 처리는 전부 원본 그대로 산다.
//
// ■ RealCamera 의 위치 식(원본 Update_Cam)
// rotateVector = Quaternion.Euler(cameraHeight, rotateAround, cameraPan) * Vector3.one
// camPosition = target.position + Vector3.up * DistanceUp - rotateVector * DistanceAway
// Vector3.one(1,1,1) 을 돌리는 식이라 각도 = 구도 가 아니다 → 아래에서 **역으로 풀어** 값을 만든다.
// 결과 오프셋이 정확히 (0, R·sinθ, -R·cosθ) 를 yaw φ 로 돌린 것이 되게 한다(= 섬과 같은 구도).
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Island
{
/// <summary>던전에서만 살아 있는 카메라 보정기. 씬이 내려가면 값을 원래대로 돌려놓는다(C8).</summary>
public sealed class WLDungeonCameraRig : MonoBehaviour
{
public const string RigName = "~WL_DungeonCameraRig";
/// <summary>보고·진단용 — 마지막으로 적용한 값.</summary>
public static string LastApplied = "";
public static int Applies;
WLIslandSettings _cfg;
RealCamera _rc;
Camera _cam;
// 되돌리기(C8) — 우리가 만지기 전 값
bool _saved;
float _oMin, _oMax, _oHeight, _oUp, _oRot, _oPan, _oFov;
bool _oOrtho;
Vector3 _oLookAt;
float[] _oCamMin, _oCamMax, _oCamHeight, _oCamUp;
bool _leadTouched; float _oLead = 1f;
public static WLDungeonCameraRig Create(WLIslandSettings cfg, UnityEngine.SceneManagement.Scene scene)
{
if (cfg == null || cfg.dungeonCameraMatchIsland == 0) return null;
var go = new GameObject(RigName);
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, scene);
var rig = go.AddComponent<WLDungeonCameraRig>();
rig._cfg = cfg;
return rig;
}
// ─────────────────────────────────────────────────────────────────
// 섬 구도 → RealCamera 값 풀이
// ─────────────────────────────────────────────────────────────────
/// <summary>
/// 원하는 구도(내려보는 각 θ · 거리 R)를 RealCamera 의 (cameraHeight, DistanceAway, DistanceUp, rotateAround) 로 푼다.
/// 결과 오프셋 = Ry(φ) · (0, R sinθ, -R cosθ) — 섬의 Cinemachine 구도와 같은 식이다.
/// </summary>
public static bool Solve(float pitchDeg, float distance, float yawDeg,
out float camHeight, out float away, out float up, out float rotateAround)
{
camHeight = pitchDeg; away = 0f; up = 0f; rotateAround = yawDeg;
float p = pitchDeg * Mathf.Deg2Rad;
float c = Mathf.Cos(p), s = Mathf.Sin(p);
float k = c - s, m = s + c; // Rx(p)·(1,1,1) = (1, k, m)
if (Mathf.Abs(m) < 1e-4f) return false;
float a = -Mathf.Atan(1f / m); // cos a + m·sin a = 0 → 오프셋의 x 성분 0
float z = -Mathf.Sin(a) + m * Mathf.Cos(a);
if (Mathf.Abs(z) < 1e-4f) return false;
away = distance * c / z;
up = distance * s + away * k;
rotateAround = a * Mathf.Rad2Deg + yawDeg;
return true;
}
/// <summary>스테이지 진입 시 원본이 쓰는 초기 카메라 방향(MapZoneData.CamRot_Portal) 값.</summary>
public static float SolvedYaw(WLIslandSettings cfg)
{
if (cfg == null) return 0f;
float h, d, u, r;
if (!Solve(cfg.camPitch, cfg.camDistance, cfg.camYaw, out h, out d, out u, out r)) return cfg.camYaw;
return r;
}
// ─────────────────────────────────────────────────────────────────
// 적용 — Update 는 모든 LateUpdate 보다 먼저 돈다(= RealCamera 가 이 값을 그대로 쓴다)
// ─────────────────────────────────────────────────────────────────
void Update()
{
if (_cfg == null) _cfg = WLIslandSettings.Instance;
if (_cfg == null || _cfg.dungeonCameraMatchIsland == 0) return;
if (_rc == null)
{
_rc = MyValue.m_RealCamera;
if (_rc == null) return;
_cam = _rc.GetComponent<Camera>();
if (_cam == null) _cam = Camera.main;
Save();
}
float camHeight, away, up, rot;
if (!Solve(_cfg.camPitch, _cfg.camDistance, _cfg.camYaw, out camHeight, out away, out up, out rot)) return;
_rc.cameraHeight = camHeight;
_rc.minDistance = away;
_rc.maxDistance = away;
_rc.DistanceUp = up;
_rc.cameraPan = 0f;
_rc.m_LookAt = new Vector3(0f, _cfg.camLookHeight, 0f);
if (_cfg.camLockYaw != 0) _rc.rotateAround = rot;
// Init_Cam()/Set_DistanceUp() 이 이 배열에서 값을 되읽으므로 같은 값을 넣어 둔다(1프레임 튐 방지).
if (_rc.arr_cCamData != null)
for (int i = 0; i < _rc.arr_cCamData.Length; i++)
{
var d = _rc.arr_cCamData[i];
if (d == null) continue;
d.minDistance = away; d.maxDistance = away;
d.cameraHeight = camHeight; d.DistanceUp = up;
}
if (_cam != null)
{
if (_cam.orthographic) _cam.orthographic = false;
if (!Mathf.Approximately(_cam.fieldOfView, _cfg.camFov)) _cam.fieldOfView = _cfg.camFov;
}
// 이동 방향 리드(#755) 배율 — 기본 1 = 아무것도 만지지 않는다(다른 팀 기능 그대로).
// 0 으로 두면 던전에서 화면 중심이 흔들리지 않아 섬과 완전히 같은 고정 구도가 된다.
if (_cfg.camLeadMultiplier >= 0f && !Mathf.Approximately(_cfg.camLeadMultiplier, 1f))
{
if (!_leadTouched) { _leadTouched = true; _oLead = CameraMoveLead.LeadMultiplier; }
CameraMoveLead.LeadMultiplier = _cfg.camLeadMultiplier;
}
Applies++;
LastApplied = "pitch " + camHeight.ToString("F2") + "° · 거리 " + _cfg.camDistance.ToString("F2") +
" m · FOV " + _cfg.camFov.ToString("F1") + " · (RealCamera away " + away.ToString("F4") +
" · up " + up.ToString("F4") + " · rotateAround " + rot.ToString("F3") + ")";
}
void Save()
{
if (_saved || _rc == null) return;
_saved = true;
_oMin = _rc.minDistance; _oMax = _rc.maxDistance; _oHeight = _rc.cameraHeight;
_oUp = _rc.DistanceUp; _oRot = _rc.rotateAround; _oPan = _rc.cameraPan; _oLookAt = _rc.m_LookAt;
if (_cam != null) { _oFov = _cam.fieldOfView; _oOrtho = _cam.orthographic; }
if (_rc.arr_cCamData != null)
{
int n = _rc.arr_cCamData.Length;
_oCamMin = new float[n]; _oCamMax = new float[n]; _oCamHeight = new float[n]; _oCamUp = new float[n];
for (int i = 0; i < n; i++)
{
var d = _rc.arr_cCamData[i];
if (d == null) continue;
_oCamMin[i] = d.minDistance; _oCamMax[i] = d.maxDistance;
_oCamHeight[i] = d.cameraHeight; _oCamUp[i] = d.DistanceUp;
}
}
WLIslandBridge.Log(_cfg, "던전 카메라 원래 값 보관 — 거리 " + _oMin.ToString("F2") + "~" + _oMax.ToString("F2") +
" · cameraHeight " + _oHeight.ToString("F1") + " · DistanceUp " + _oUp.ToString("F2") +
" · FOV " + _oFov.ToString("F1") + (_oOrtho ? " · 직교" : " · 원근"));
}
void OnDestroy()
{
if (_leadTouched) { CameraMoveLead.LeadMultiplier = _oLead; _leadTouched = false; }
if (!_saved || _rc == null) return;
_rc.minDistance = _oMin; _rc.maxDistance = _oMax; _rc.cameraHeight = _oHeight;
_rc.DistanceUp = _oUp; _rc.rotateAround = _oRot; _rc.cameraPan = _oPan; _rc.m_LookAt = _oLookAt;
if (_cam != null) { _cam.fieldOfView = _oFov; _cam.orthographic = _oOrtho; }
if (_rc.arr_cCamData != null && _oCamMin != null)
for (int i = 0; i < _rc.arr_cCamData.Length && i < _oCamMin.Length; i++)
{
var d = _rc.arr_cCamData[i];
if (d == null) continue;
d.minDistance = _oCamMin[i]; d.maxDistance = _oCamMax[i];
d.cameraHeight = _oCamHeight[i]; d.DistanceUp = _oCamUp[i];
}
}
// ─────────────────────────────────────────────────────────────────
// WL-816i 에러 ② — "Screen position out of view frustum" 감시·수리
//
// 🔴 실측(이 프로젝트 에디터 · 같은 문구 그대로 재현):
// 이 에러는 **좌표가 화면 밖이라서** 나는 것이 아니다. 화면 밖·클립 밖 좌표(뷰포트 1,0 · z 1000)를
// 넣어도 정상 카메라에서는 에러가 **나지 않는다**. 에러가 나는 유일한 조건은 **카메라의 투영이 깨진 것**이다:
// · near == far → 에러
// · 직교인데 orthographicSize ≤ 0 → 에러
// · aspect ≤ 0 → 에러
// · 카메라를 꺼 두거나 오브젝트를 꺼 둔 것 → 에러 아님(정상 동작)
// PD 콘솔의 「screen pos 1080, 0, 1000」 = (pixelRect.xMax, pixelRect.yMin, 대상 깊이) —
// NGUI `UIAnchor.Update`(UIAnchor.cs:206-207)가 화면 우하단 앵커를 계산할 때 넣는 값 모양 그대로다.
// → 그래서 값을 넣는 쪽이 아니라 **깨진 카메라**를 찾아 되살린다. 깨진 카메라는 어차피 아무것도 못 그린다.
// ─────────────────────────────────────────────────────────────────
public static int Repairs;
public static string LastRepair = "";
/// <summary>투영이 깨진 카메라를 찾아 되살린다. 되살린 수를 돌려준다(정상이면 0 · 로그도 없다).</summary>
public static int RepairDegenerateCameras(WLIslandSettings cfg)
{
if (cfg == null || cfg.fixDegenerateCameras == 0) return 0;
var cams = Object.FindObjectsByType<Camera>(FindObjectsInactive.Include, FindObjectsSortMode.None);
int n = 0;
for (int i = 0; i < cams.Length; i++)
{
var c = cams[i];
if (c == null) continue;
string why = null;
if (c.nearClipPlane >= c.farClipPlane) { why = "near " + c.nearClipPlane + " ≥ far " + c.farClipPlane; c.nearClipPlane = 0.3f; c.farClipPlane = 500f; }
if (c.orthographic && c.orthographicSize <= 0f) { why = (why == null ? "" : why + " · ") + "직교 size " + c.orthographicSize; c.orthographicSize = 5f; }
if (c.aspect <= 0f || float.IsNaN(c.aspect)) { why = (why == null ? "" : why + " · ") + "aspect " + c.aspect; c.ResetAspect(); }
if (why == null) continue;
n++; Repairs++;
LastRepair = c.name + "(" + (c.gameObject.scene.IsValid() ? c.gameObject.scene.name : "-") + ") — " + why;
WLIslandBridge.Log(cfg, "🔴 투영이 깨진 카메라를 되살렸다 — " + LastRepair);
}
return n;
}
// ─────────────────────────────────────────────────────────────────
// 실측 보고용 — 지금 화면의 구도를 잰다(섬·던전 공통 · 캡처와 함께 표로 남긴다)
// ─────────────────────────────────────────────────────────────────
/// <summary>현재 활성 카메라와 추적 대상 사이의 실제 구도(각·거리·FOV)를 문자열로 돌려준다.</summary>
public static string Measure(Transform target)
{
var cam = Camera.main;
if (cam == null) return "카메라 없음";
if (target == null) return "카메라 " + cam.name + " · 대상 없음 · FOV " + cam.fieldOfView.ToString("F2") +
(cam.orthographic ? (" · 직교 size " + cam.orthographicSize.ToString("F2")) : " · 원근") +
" · 오일러 " + cam.transform.eulerAngles.ToString("F2");
Vector3 off = cam.transform.position - target.position;
float horiz = new Vector2(off.x, off.z).magnitude;
float pitch = Mathf.Atan2(off.y, horiz) * Mathf.Rad2Deg;
float yaw = Mathf.Atan2(-off.x, -off.z) * Mathf.Rad2Deg;
return "카메라 " + cam.name + " · 거리 " + off.magnitude.ToString("F3") + " m · 내려보는 각 " +
pitch.ToString("F2") + "° · 수평각 " + yaw.ToString("F2") + "° · 높이 " + off.y.ToString("F3") +
" m · FOV " + cam.fieldOfView.ToString("F2") +
(cam.orthographic ? (" · 직교 size " + cam.orthographicSize.ToString("F2")) : " · 원근") +
" · near/far " + cam.nearClipPlane.ToString("F2") + "/" + cam.farClipPlane.ToString("F0") +
" · 카메라 오일러 " + cam.transform.eulerAngles.ToString("F2");
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4b386eb2f387c634992d62d3591735a9

View File

@ -41,32 +41,36 @@ namespace WL.Island
public static void EnsureMapNodes(WLIslandSettings cfg, Scene scene)
{
if (InGameInfo.Ins == null) return; // 섬 씬만 단독 Play = 원본 흐름이 없다 → 필요 없다
if (LoadMapMgr.isIns && LoadMapMgr.Ins != null) return;
var go = new GameObject(MapNodesName);
SceneManager.MoveGameObjectToScene(go, scene);
// 🔴 원본 LoadMapMgr 를 그대로 붙이면 Awake 가 빈 주소로 Addressables 를 때려 에러가 난다.
// 파생 클래스가 Awake 만 갈아끼운다 = 원본 0줄 · 주소 로드 0 · Ins 만 채운다.
var mgr = go.AddComponent<WLIslandMapNodes>();
var mgr = WLIslandMapNodes.Ensure(cfg);
if (mgr == null) return;
mgr.LoadSceneName = cfg.islandSceneName;
mgr.LoadMapPrefab = "";
MapNodesMade++;
WLIslandBridge.Log(cfg, "섬 맵 노드 생성(LoadMapMgr) — " + scene.name);
mgr.SetStartPos(IslandStartPos(), WLDungeonCameraRig.SolvedYaw(cfg));
WLIslandBridge.Log(cfg, "섬 맵 노드 준비(LoadMapMgr) — " + scene.name + " · 시작 " + mgr.StartPos.ToString("F1"));
}
/// <summary>섬의 시작 지점 = FI 플레이어가 서 있는 자리(없으면 원점).</summary>
static Vector3 IslandStartPos()
{
var fiPlayer = Object.FindFirstObjectByType<PlayerController>();
return fiPlayer != null ? fiPlayer.transform.position : Vector3.zero;
}
/// <summary>던전 씬에도 같은 이유로 `LoadMapMgr` 자리를 채운다(맵 프리팹 없음 = 주소 로드 0).</summary>
public static void EnsureDungeonMapNodes(WLIslandSettings cfg, Scene scene, WLDungeonDef d)
{
if (InGameInfo.Ins == null) return;
if (LoadMapMgr.isIns && LoadMapMgr.Ins != null && LoadMapMgr.Ins.gameObject.scene == scene) return;
var go = new GameObject(MapNodesName);
SceneManager.MoveGameObjectToScene(go, scene);
var mgr = go.AddComponent<WLIslandMapNodes>();
var mgr = WLIslandMapNodes.Ensure(cfg);
if (mgr == null) return;
mgr.LoadSceneName = d.sceneName;
mgr.LoadMapPrefab = "";
MapNodesMade++;
WLIslandBridge.Log(cfg, "던전 맵 노드 생성(LoadMapMgr) — " + scene.name);
mgr.SetStartPos(DungeonStartPos(d), WLDungeonCameraRig.SolvedYaw(cfg));
WLIslandBridge.Log(cfg, "던전 맵 노드 준비(LoadMapMgr) — " + scene.name + " · 시작 " + mgr.StartPos.ToString("F1"));
}
/// <summary>던전(아레나)에는 포털이 없다 — 스테이지 표의 앵커가 시작 지점이다.</summary>
public static Vector3 DungeonStartPos(WLDungeonDef d)
{
var def = WLDungeonSceneSetup.StageDefOf(d);
return def != null ? def.anchor : Vector3.zero;
}
// ─────────────────────────────────────────────────────────────────
@ -263,13 +267,115 @@ namespace WL.Island
}
/// <summary>
/// 섬 씬 전용 `LoadMapMgr`. 원본은 Awake 에서 맵 프리팹을 Addressables 로 받아 오는데,
/// 섬은 씬 자체가 맵이라 받을 것이 없다 → Awake 만 갈아끼워 **주소 로드 0**·싱글턴만 채운다.
/// 🔴 원본 `LoadMapMgr.cs` 는 한 줄도 고치지 않는다(파생으로 해결).
/// 섬·던전 공용 `LoadMapMgr`. 원본은 Awake 에서 맵 프리팹을 Addressables 로 받아 오는데,
/// 섬·던전은 씬 자체가 맵이라 받을 것이 없다 → Awake 만 갈아끼워 **주소 로드 0**·싱글턴만 채운다.
/// 🔴 원본 `LoadMapMgr.cs` 는 한 줄도 고치지 않는다(파생 + private 필드 주입으로 해결).
///
/// ■ WL-816i 가 고친 것 2가지 (PD 실플레이 에러 ①)
/// ① `MissingReferenceException … WLIslandMapNodes has been destroyed` —
/// 씬마다 새로 만들던 노드가 `LoadSceneMode` 전환에서 파괴되는데 정적 `Ins` 는 그대로 남아
/// `Get_PortalPos` 의 `StartCoroutine` 이 죽은 객체를 때렸다.
/// → **DontDestroyOnLoad 단 하나**만 두고 씬이 바뀌면 시작 지점만 갈아끼운다.
/// ② `Co_Get_PortalPos` 의 `while (CheckNull(m_MapData)) yield return null;` 이 끝나지 않던 것 —
/// 우리는 맵 프리팹이 없어 `m_MapData` 가 영원히 null 이라 `Make_Actors` 의 콜백(펫·일꾼)과
/// `PCActor.Set_Warp`·`MobControlMgr` 의 도달성 검사가 **영영 안 불렸다**.
/// → 런타임 `MapData` 를 하나 만들어 넣는다. **꺼진 오브젝트**에 붙여서
/// `MapData.Awake/Start` 가 돌지 않게 한다(= `MapData.Ins` 도, RenderSettings 앰비언트·안개도 무변경).
/// </summary>
public sealed class WLIslandMapNodes : LoadMapMgr
{
public const int ZoneSlots = 16; // n_PortalIndex 가 무엇이 오든 IndexOutOfRange 가 안 나게
public const int ObjSlots = 8; // Get_tfObj(index) 여유분
static WLIslandMapNodes s_ins;
static readonly System.Reflection.FieldInfo F_MapData =
typeof(LoadMapMgr).GetField("m_MapData", System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
static readonly System.Reflection.FieldInfo F_GoMap =
typeof(LoadMapMgr).GetField("go_map", System.Reflection.BindingFlags.NonPublic |
System.Reflection.BindingFlags.Instance);
Transform _start;
MapData _data;
GameObject _dummyMap;
/// <summary>현재 씬의 시작 지점(보고·진단용).</summary>
public Vector3 StartPos { get { return _start != null ? _start.position : Vector3.zero; } }
protected override void Awake() { Ins = this; isIns = true; }
/// <summary>
/// 씬을 넘어 사는 노드를 **하나만** 만들고 싱글턴 자리를 되찾는다.
/// (다른 맵의 원본 `LoadMapMgr` 가 `Ins` 를 가져갔다가 그 씬이 언로드되면 죽은 참조가 남는다 — 그것도 여기서 덮는다.)
/// </summary>
public static WLIslandMapNodes Ensure(WLIslandSettings cfg)
{
if (s_ins == null)
{
var go = new GameObject(WLIslandSceneSetup.MapNodesName);
Object.DontDestroyOnLoad(go);
s_ins = go.AddComponent<WLIslandMapNodes>();
s_ins.LoadMapPrefab = "";
s_ins.Build();
WLIslandSceneSetup.MapNodesMade++;
}
Ins = s_ins; isIns = true; // 죽은 참조로 남지 않게 매번 되찾는다
return s_ins;
}
void Build()
{
// ① 시작 지점 노드 — 포털/부활/농부/오브젝트 자리를 전부 이 하나로 답한다(던전은 아레나라 포털이 없다).
var holder = new GameObject("MapNodes");
holder.transform.SetParent(transform, false);
holder.SetActive(false); // 🔴 꺼진 채로 AddComponent → MapData.Awake/Start 가 아예 안 돈다
var startGo = new GameObject("start");
startGo.transform.SetParent(holder.transform, false);
_start = startGo.transform;
_data = holder.AddComponent<MapData>();
var objs = new Transform[ObjSlots];
for (int i = 0; i < ObjSlots; i++) objs[i] = _start;
_data.m_MapZoneData = new List<MapZoneData>(ZoneSlots);
for (int i = 0; i < ZoneSlots; i++)
{
_data.m_MapZoneData.Add(new MapZoneData
{
go_portal = startGo,
go_reincarnation = startGo,
go_mazeNextPortal = null, // Set_MazeNextPortal 은 null 이면 그냥 넘어간다(원본 확인)
tf_Farmer = _start,
tf_Obj = objs,
CamRot_Portal = 0f,
CamRot_Reincarnation = 0f,
});
}
// ② SetActive_Map(bool) 이 만지는 자리(비어 있으면 NullReference) — 아무것도 안 든 더미.
_dummyMap = new GameObject("map");
_dummyMap.transform.SetParent(transform, false);
// ③ 원본의 private 필드에 넣는다 — 원본 코드 0줄로 `Co_Get_*` 의 대기를 즉시 끝낸다.
if (F_MapData != null) F_MapData.SetValue(this, _data);
if (F_GoMap != null) F_GoMap.SetValue(this, _dummyMap);
}
/// <summary>씬이 바뀔 때마다 시작 지점과 카메라 초기 방향만 갈아끼운다(객체는 그대로 산다).</summary>
public void SetStartPos(Vector3 pos, float camYaw)
{
if (_start == null) Build();
_start.position = pos - Vector3.forward; // 원본이 +Vector3.forward 를 더해 돌려준다 → 결과가 pos 가 되게
if (_data == null || _data.m_MapZoneData == null) return;
for (int i = 0; i < _data.m_MapZoneData.Count; i++)
{
var z = _data.m_MapZoneData[i];
if (z == null) continue;
z.CamRot_Portal = camYaw;
z.CamRot_Reincarnation = camYaw;
}
}
}
/// <summary>FI 애니메이터의 파라미터를 치비 애니메이터로 그대로 흘린다(Humanoid 리타깃).</summary>

View File

@ -313,6 +313,47 @@ namespace WL.Island
"비우면 아무것도 지우지 않는다. PD 에게 새 상태를 보여 줄 때 값을 바꾼다.")]
public string saveResetToken = "816g-1";
// ───────────────────────────────── WL-816i · 카메라 구도 고정
// PD 「카메라 구도는 섬에서 조작할 때의 각도와 뷰를 말한거야. 전장에 진입해도 바뀌지 않아야 하기 때문」
// → 기준은 **섬**. 아래 기본값은 섬(FI) 카메라 실측값 그대로다.
// Level01.unity `PlayerVirtualCamera` — 회전 오일러 (45,0,0) · FramingTransposer m_CameraDistance 22
// · 렌즈 FOV 60 · 원근(orthographic 0) · Near 0.1 / Far 500 · ScreenX/Y 0.5(대상 화면 중앙)
[Header("카메라 구도 (WL-816i · 섬 기준을 던전에 그대로)")]
[Tooltip("1 이면 던전(전장)의 카메라를 아래 **섬 구도** 값으로 맞춘다. 0 이면 던전은 힘민지 기본 전투 카메라 그대로(= 816g 까지의 동작).")]
public int dungeonCameraMatchIsland = 1;
[Tooltip("섬 카메라 내려보는 각(도). 섬 실측 = 45.")]
public float camPitch = 45f;
[Tooltip("섬 카메라 수평 방향(도). 섬은 플레이어를 따라 돌지 않는 고정 구도라 0.")]
public float camYaw = 0f;
[Tooltip("섬 카메라와 대상 사이 거리(m). 섬 실측 = 22 (CinemachineFramingTransposer.m_CameraDistance).")]
public float camDistance = 22f;
[Tooltip("섬 카메라 화각(도). 섬 실측 = 60 · 원근 투영.")]
public float camFov = 60f;
[Tooltip("카메라가 바라보는 점의 높이(대상 발밑 기준 m). 섬은 대상 원점을 화면 중앙에 두므로 0.")]
public float camLookHeight = 0f;
[Tooltip("1 이면 던전에서 카메라를 드래그로 돌릴 수 없다(섬과 같은 고정 구도). 0 이면 회전 허용.")]
public int camLockYaw = 1;
[Tooltip("던전에서 「이동 방향 리드」(#755 · CameraLeadSettings)를 얼마나 쓸지. " +
"1 = 지금 그대로(리드 살아 있음 · 움직이면 화면 중심이 최대 1.5 m 흔들린다) · " +
"0 = 리드 끔(섬과 완전히 같은 고정 구도). 음수면 만지지 않는다. " +
"🔴 후진 피치(backwardPitchAdd 6°)는 이 값으로 꺼지지 않는다 — 그건 CameraLeadSettings 소관.")]
public float camLeadMultiplier = 1f;
[Tooltip("1 이면 투영이 깨진 카메라(near==far · 직교 size ≤ 0 · aspect ≤ 0)를 찾아 되살린다. " +
"이 상태의 카메라에 ScreenToWorldPoint 를 쓰면 유니티가 " +
"\"Screen position out of view frustum\" 에러를 낸다(WL-816i 실측). 0 이면 감시도 수리도 안 한다.")]
public int fixDegenerateCameras = 1;
[Tooltip("위 감시 주기(초).")]
public float cameraGuardInterval = 0.5f;
// ───────────────────────────────── 던전 표
[Header("던전 표 — 한 줄 = 던전 하나")]
public WLDungeonDef[] dungeons = new WLDungeonDef[0];