From f2e89653ed15b4e203ad43abc9ff6c6f2959d315 Mon Sep 17 00:00:00 2001 From: swrring Date: Sun, 13 Sep 2026 17:14:48 +0900 Subject: [PATCH] =?UTF-8?q?[WL-816i]=20=EB=8D=98=EC=A0=84=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20MissingReference=20=EC=A0=9C=EA=B1=B0(=EB=A7=B5=20?= =?UTF-8?q?=EB=85=B8=EB=93=9C=201=EA=B0=9C=EB=A1=9C=20=EC=83=81=EC=A3=BC)?= =?UTF-8?q?=20+=20=EC=84=AC=20=EA=B5=AC=EB=8F=84=20=EC=B9=B4=EB=A9=94?= =?UTF-8?q?=EB=9D=BC=20(#816)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoadMapMgr 파생 노드를 씬마다 새로 만들지 않고 DontDestroyOnLoad 하나로 상주시킨다. 씬 전환에서 파괴된 노드를 정적 Ins 가 계속 가리켜 Get_PortalPos 의 StartCoroutine 이 MissingReferenceException 을 내던 것을 없앤다(원본 LoadMapMgr.cs 0줄 · 파생 + private 필드 주입). - 맵 프리팹이 없어 m_MapData 가 영원히 null 이라 Co_Get_PortalPos 가 끝나지 않던 것도 함께 해결 — 꺼진 오브젝트에 런타임 MapData 를 만들어 넣는다(MapData.Awake/Start 미실행 = RenderSettings 무변경). - 던전 씬의 아레나 룩 카메라(직교 · MainCamera 태그)가 전투 화면을 차지하던 것을 끄고 전투 카메라(RealCamera)만 남긴다. - WLDungeonCameraRig — 섬(FI Cinemachine) 실측 구도(각 45° · 거리 22 m · FOV 60 · 원근)를 던전 카메라에 그대로 적용한다. 값은 WLIslandSettings SO. Co-Authored-By: Claude Opus 5 --- Assets/WL/Island/WLDungeonSceneSetup.cs | 34 +++- Assets/WL/Island/WLIslandBridge.cs | 14 ++ Assets/WL/Island/WLIslandCameraRig.cs | 210 +++++++++++++++++++++ Assets/WL/Island/WLIslandCameraRig.cs.meta | 2 + Assets/WL/Island/WLIslandSceneSetup.cs | 148 ++++++++++++--- Assets/WL/Island/WLIslandSettings.cs | 27 +++ 6 files changed, 406 insertions(+), 29 deletions(-) create mode 100644 Assets/WL/Island/WLIslandCameraRig.cs create mode 100644 Assets/WL/Island/WLIslandCameraRig.cs.meta diff --git a/Assets/WL/Island/WLDungeonSceneSetup.cs b/Assets/WL/Island/WLDungeonSceneSetup.cs index 94688c04e..12c22d1f1 100644 --- a/Assets/WL/Island/WLDungeonSceneSetup.cs +++ b/Assets/WL/Island/WLDungeonSceneSetup.cs @@ -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() == 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(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(); + var combatCam = combat != null ? combat.GetComponent() : 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(); - if (al != null && al.enabled) { al.enabled = false; listeners++; } + if (!combatCam.enabled) { combatCam.enabled = true; cams++; } + var allCams = Object.FindObjectsByType(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(); + if (al != null && al.enabled) { al.enabled = false; listeners++; } + } + } + else + { + WLIslandBridge.Log(cfg, "🔴 던전 전투 카메라(RealCamera)를 못 찾았다 — 씬 카메라를 그대로 둔다"); } WLIslandBridge.Log(cfg, "던전 중복 정리 — 데모 리그 " + rigs + " · 카메라 " + cams + " · 리스너 " + listeners); diff --git a/Assets/WL/Island/WLIslandBridge.cs b/Assets/WL/Island/WLIslandBridge.cs index e3d97b16e..777015746 100644 --- a/Assets/WL/Island/WLIslandBridge.cs +++ b/Assets/WL/Island/WLIslandBridge.cs @@ -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); } diff --git a/Assets/WL/Island/WLIslandCameraRig.cs b/Assets/WL/Island/WLIslandCameraRig.cs new file mode 100644 index 000000000..b3a61c8bb --- /dev/null +++ b/Assets/WL/Island/WLIslandCameraRig.cs @@ -0,0 +1,210 @@ +// ───────────────────────────────────────────────────────────────────────────── +// 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 +{ + /// 던전에서만 살아 있는 카메라 보정기. 씬이 내려가면 값을 원래대로 돌려놓는다(C8). + public sealed class WLDungeonCameraRig : MonoBehaviour + { + public const string RigName = "~WL_DungeonCameraRig"; + + /// 보고·진단용 — 마지막으로 적용한 값. + 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; + + 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(); + rig._cfg = cfg; + return rig; + } + + // ───────────────────────────────────────────────────────────────── + // 섬 구도 → RealCamera 값 풀이 + // ───────────────────────────────────────────────────────────────── + /// + /// 원하는 구도(내려보는 각 θ · 거리 R)를 RealCamera 의 (cameraHeight, DistanceAway, DistanceUp, rotateAround) 로 푼다. + /// 결과 오프셋 = Ry(φ) · (0, R sinθ, -R cosθ) — 섬의 Cinemachine 구도와 같은 식이다. + /// + 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; + } + + /// 스테이지 진입 시 원본이 쓰는 초기 카메라 방향(MapZoneData.CamRot_Portal) 값. + 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(); + 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; + } + + 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 (!_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]; + } + } + + // ───────────────────────────────────────────────────────────────── + // 실측 보고용 — 지금 화면의 구도를 잰다(섬·던전 공통 · 캡처와 함께 표로 남긴다) + // ───────────────────────────────────────────────────────────────── + /// 현재 활성 카메라와 추적 대상 사이의 실제 구도(각·거리·FOV)를 문자열로 돌려준다. + 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"); + } + } +} diff --git a/Assets/WL/Island/WLIslandCameraRig.cs.meta b/Assets/WL/Island/WLIslandCameraRig.cs.meta new file mode 100644 index 000000000..aa4f8b75b --- /dev/null +++ b/Assets/WL/Island/WLIslandCameraRig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4b386eb2f387c634992d62d3591735a9 \ No newline at end of file diff --git a/Assets/WL/Island/WLIslandSceneSetup.cs b/Assets/WL/Island/WLIslandSceneSetup.cs index 893da5995..10ae52b23 100644 --- a/Assets/WL/Island/WLIslandSceneSetup.cs +++ b/Assets/WL/Island/WLIslandSceneSetup.cs @@ -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(); + 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")); + } + + /// 섬의 시작 지점 = FI 플레이어가 서 있는 자리(없으면 원점). + static Vector3 IslandStartPos() + { + var fiPlayer = Object.FindFirstObjectByType(); + return fiPlayer != null ? fiPlayer.transform.position : Vector3.zero; } /// 던전 씬에도 같은 이유로 `LoadMapMgr` 자리를 채운다(맵 프리팹 없음 = 주소 로드 0). 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(); + 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")); + } + + /// 던전(아레나)에는 포털이 없다 — 스테이지 표의 앵커가 시작 지점이다. + 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 } /// - /// 섬 씬 전용 `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 앰비언트·안개도 무변경). /// 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; + + /// 현재 씬의 시작 지점(보고·진단용). + public Vector3 StartPos { get { return _start != null ? _start.position : Vector3.zero; } } + protected override void Awake() { Ins = this; isIns = true; } + + /// + /// 씬을 넘어 사는 노드를 **하나만** 만들고 싱글턴 자리를 되찾는다. + /// (다른 맵의 원본 `LoadMapMgr` 가 `Ins` 를 가져갔다가 그 씬이 언로드되면 죽은 참조가 남는다 — 그것도 여기서 덮는다.) + /// + public static WLIslandMapNodes Ensure(WLIslandSettings cfg) + { + if (s_ins == null) + { + var go = new GameObject(WLIslandSceneSetup.MapNodesName); + Object.DontDestroyOnLoad(go); + s_ins = go.AddComponent(); + 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(); + var objs = new Transform[ObjSlots]; + for (int i = 0; i < ObjSlots; i++) objs[i] = _start; + + _data.m_MapZoneData = new List(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); + } + + /// 씬이 바뀔 때마다 시작 지점과 카메라 초기 방향만 갈아끼운다(객체는 그대로 산다). + 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; + } + } } /// FI 애니메이터의 파라미터를 치비 애니메이터로 그대로 흘린다(Humanoid 리타깃). diff --git a/Assets/WL/Island/WLIslandSettings.cs b/Assets/WL/Island/WLIslandSettings.cs index 65ab83674..4d0820816 100644 --- a/Assets/WL/Island/WLIslandSettings.cs +++ b/Assets/WL/Island/WLIslandSettings.cs @@ -313,6 +313,33 @@ 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; + // ───────────────────────────────── 던전 표 [Header("던전 표 — 한 줄 = 던전 하나")] public WLDungeonDef[] dungeons = new WLDungeonDef[0];