// ───────────────────────────────────────────────────────────────────────────── // 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). // 🔴 WL-816l — 실행 순서를 앞으로 당긴다. // 입장 연출(`Set_Target`)은 **코루틴 구간**(SceneInfo.Co_Control_AddScene → addloadend)에서 걸리는데, // 코루틴은 모든 Update 뒤 · 모든 LateUpdate 앞에 돈다. 그래서 그 프레임에 우리가 손쓸 수 있는 유일한 자리가 // **RealCamera 보다 먼저 도는 LateUpdate** 다. 기본 실행 순서(0)로는 늦어서 한 프레임이 그대로 새어 나간다. [DefaultExecutionOrder(-100)] 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; 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(); rig._cfg = cfg; Settled = false; // WL-816l — 새 던전에 들어갈 때마다 로딩 게이트를 다시 닫는다 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; } /// /// 던전에서 쓸 카메라 거리(m). WL-816i2 — PD 「던전에서는 카메라를 조금만 더 당기자」. /// 각도·yaw·FOV 는 섬 그대로 두고 **거리만** 던전 전용 값을 쓴다. 0 이하면 섬 값을 그대로 쓴다. /// public static float DungeonDistance(WLIslandSettings cfg) { if (cfg == null) return 22f; if (DistanceOverride > 0f) return DistanceOverride; // 검증용(프로브) return cfg.dungeonCamDistance > 0f ? cfg.dungeonCamDistance : cfg.camDistance; } /// 검증 전용 — 0 보다 크면 그 거리를 쓴다(3단 비교 캡처용 · 실기에는 영향 0). public static float DistanceOverride = 0f; /// 스테이지 진입 시 원본이 쓰는 초기 카메라 방향(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 dist = DungeonDistance(_cfg); // WL-816i2 — 던전만 조금 더 당긴다(각도·FOV 는 섬 그대로) float camHeight, away, up, rot; if (!Solve(_cfg.camPitch, dist, _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") + "° · 거리 " + dist.ToString("F2") + " m · FOV " + _cfg.camFov.ToString("F1") + " · (RealCamera away " + away.ToString("F4") + " · up " + up.ToString("F4") + " · rotateAround " + rot.ToString("F3") + ")"; } // ───────────────────────────────────────────────────────────────── // WL-816l — 입장 직후 「카메라가 휙휙 바뀐다」(PD) 를 없앤다 // // 🔴 실측한 원본 동작(RealCamera.cs · 라인 그대로): // ① `Set_Target(pc, CamRot_Portal)`(PCActor.cs:251) 이 `CamStatus = 0` 으로 만든다. // ② 그 프레임의 `LateUpdate` CamStatus==0 가지는 // · 위치: `Vector3.Lerp(pos, camPosition, 1f)` = **즉시 순간이동**(:208) // · 회전: `Quaternion.Slerp(rot, LookRotation(...), Time.deltaTime * 2.5f)` = **한 프레임에 4 % 만**(:199) // → 위치는 던전인데 방향은 아직 섬 쪽인 **엉뚱한 한 프레임**이 그대로 화면에 나간다. // ③ 위치가 목표와 같아지므로(:210 거리 0) 목록을 비우고 `CamStatus = 1` · `Start_Game()`. // ④ 다음 프레임 `Update_Cam` 의 `transform.LookAt`(:283) 이 회전을 **한 번에** 맞춘다 → 두 번째 튐. // ⑤ 그 뒤 `StageDirector.Enter → WarpPlayerToAnchor` 가 PC 를 앵커 뒤(startRadius m)로 **순간이동**시킨다. // 원본 추적은 `Lerp(pos, camPosition, Time.deltaTime * smooth)`(:269) 라 그 거리를 0.5 초쯤 끌고 간다 → 세 번째 튐. // → ①~④ 는 **입장 프레임에 최종 구도로 미리 앉혀** 없앤다(원본 계산과 같은 값이라 :210 의 거리 판정과 // `Start_Game()` 도달은 그대로 산다). ⑤ 는 **대상의 순간이동을 감지**해 같은 프레임에 따라 붙는다. // 원본 `RealCamera.cs` 0줄. // ───────────────────────────────────────────────────────────────── public static int Snaps; public static string LastSnap = ""; /// 던전 카메라가 최종 구도에 앉았는가(로딩 화면 게이트가 읽는다). public static bool Settled; /// 검증 전용 — 0/1 이면 SO 대신 이 값을 쓴다(-1 = SO 그대로). public static int InstantSettleOverride = -1; Vector3 _lastTargetPos; bool _haveLastTarget; static bool InstantSettleOn(WLIslandSettings cfg) { if (InstantSettleOverride >= 0) return InstantSettleOverride != 0; return cfg != null && cfg.dungeonCamInstantSettle != 0; } /// 지금 대상·설정으로 계산한 **최종 구도**(원본 Update_Cam 이 수렴하는 자리)를 돌려준다. public static bool IdealPose(RealCamera rc, WLIslandSettings cfg, out Vector3 pos, out Quaternion rot) { pos = Vector3.zero; rot = Quaternion.identity; if (rc == null || cfg == null || rc.target == null) return false; float camHeight, away, up, yaw; if (!Solve(cfg.camPitch, DungeonDistance(cfg), cfg.camYaw, out camHeight, out away, out up, out yaw)) return false; if (cfg.camLockYaw == 0) yaw = rc.rotateAround; Vector3 targetOffset = rc.target.position; Vector3 rotateVector = Quaternion.Euler(camHeight, yaw, 0f) * Vector3.one; // 원본과 같은 식(RealCamera.cs:204) pos = targetOffset + Vector3.up * up - rotateVector * away; Vector3 look = targetOffset + new Vector3(0f, cfg.camLookHeight, 0f) - pos; // 원본과 같은 시선(RealCamera.cs:283) if (look.sqrMagnitude < 1e-6f) return false; rot = Quaternion.LookRotation(look); return true; } /// 카메라를 최종 구도로 **즉시** 옮긴다(보간 없음). 옮겼으면 1. public static int SnapToPose(RealCamera rc, string why) { var cfg = WLIslandSettings.Instance; Vector3 pos; Quaternion rot; if (!IdealPose(rc, cfg, out pos, out rot)) return 0; rc.transform.position = pos; rc.transform.rotation = rot; Snaps++; LastSnap = why + " — pos " + pos.ToString("F3") + " · euler " + rot.eulerAngles.ToString("F2"); return 1; } // 🔴 RealCamera 보다 **먼저** 도는 LateUpdate(실행 순서 -100). 코루틴에서 걸린 Set_Target·워프를 같은 프레임에 받는다. void LateUpdate() { if (_cfg == null) _cfg = WLIslandSettings.Instance; if (_rc == null || _cfg == null || !InstantSettleOn(_cfg)) return; var tf = _rc.target; if (tf == null) { _haveLastTarget = false; Settled = false; return; } bool teleported = false; if (_haveLastTarget && _cfg.dungeonCamTeleportSnap > 0f) teleported = Vector3.Distance(tf.position, _lastTargetPos) >= _cfg.dungeonCamTeleportSnap; _lastTargetPos = tf.position; _haveLastTarget = true; // ⓐ 입장 연출 프레임(CamStatus 0) ⓑ 대상 순간이동(워프·부활) — 둘 다 같은 프레임에 최종 구도로 앉힌다. if (_rc.IsEnter()) { SnapToPose(_rc, "입장 연출(CamStatus 0)"); return; } if (teleported) SnapToPose(_rc, "대상 순간이동"); // 로딩 화면 게이트가 읽는 값 — 연출이 끝났고(= Start_Game 도달) 구도 오차가 충분히 작다. Vector3 pos; Quaternion rot; Settled = IdealPose(_rc, _cfg, out pos, out rot) && Vector3.Distance(_rc.transform.position, pos) <= 0.25f && Quaternion.Angle(_rc.transform.rotation, rot) <= 2f; } 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() { Settled = false; // WL-816l — 던전을 나가면 다시 닫힌 상태로 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 = ""; // ───────────────────────────────────────────────────────────────── // WL-816i2 — 「재입장하면 전투가 진행되지 않는다」(PD) 의 원인 지점 // // 원본 흐름(실측): 전투는 이렇게 **시작**된다. // InGameInfo.StartCam → PCActor.StartCam(PCActor.cs:245) // → LoadMapMgr.Get_PortalPos 콜백 → RealCamera.Set_Target(pc, CamRot_Portal) // → CamStatus = 0 · list_tfCamactionTarget.Add(pc) (RealCamera.cs:84·87) // → LateUpdate 의 CamStatus==0 가지가 목록을 **다 비우면** Start_Game() (RealCamera.cs:212) // → ActorInfo.All_Stop(false) + Time.timeScale + DungeonInfo.Start_Dungeon(true) // 🔴 `list_tfCamactionTarget` 은 원본에 **비우는 곳이 없다**(Add 와 RemoveAt(0) 뿐 · RealCamera.cs:52·122). // 맵을 바꾸면 그 목록의 Transform(이전 맵 PC)은 파괴되는데 항목은 남는다. // 그 상태로 다시 입장하면 CamStatus==0 가지의 첫 줄 `camTarget.position`(RealCamera.cs:197·199)이 // **MissingReferenceException** 을 던져 LateUpdate 가 매 프레임 중단된다 → // 목록이 영영 안 비고 → **Start_Game() 이 안 불려 전투가 멈춘 채로 남는다.** // → 원본 0줄 유지를 위해 **죽은 항목만 걷어낸다**. 다 걷어내 비면 지금 대상을 한 개 넣어 // 원본이 정상적으로 목록을 소진하고 Start_Game() 에 도달하게 한다(= 첫 입장과 같은 경로). // ───────────────────────────────────────────────────────────────── public static int CamTargetsPruned, CamTargetsRefilled; static readonly System.Reflection.FieldInfo F_CamActionList = typeof(RealCamera).GetField("list_tfCamactionTarget", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); /// 죽은 카메라 연출 대상만 걷어낸다. 걷어낸 개수를 돌려준다(정상이면 0 · 아무것도 안 한다). public static int PruneCamActionTargets(RealCamera rc) { if (rc == null || F_CamActionList == null) return 0; var list = F_CamActionList.GetValue(rc) as System.Collections.Generic.List; if (list == null || list.Count == 0) return 0; int removed = 0; for (int i = list.Count - 1; i >= 0; i--) if (list[i] == null) { list.RemoveAt(i); removed++; } if (removed == 0) return 0; CamTargetsPruned += removed; // 🔴 다 걷어내 비었는데 CamStatus 가 0 이면 원본이 list[0] 에서 터진다 → 지금 대상을 한 개 채워 준다. if (list.Count == 0 && rc.target != null) { list.Add(rc.target); CamTargetsRefilled++; } return removed; } /// 투영이 깨진 카메라를 찾아 되살린다. 되살린 수를 돌려준다(정상이면 0 · 로그도 없다). public static int RepairDegenerateCameras(WLIslandSettings cfg) { if (cfg == null || cfg.fixDegenerateCameras == 0) return 0; var cams = Object.FindObjectsByType(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; } // ───────────────────────────────────────────────────────────────── // 실측 보고용 — 지금 화면의 구도를 잰다(섬·던전 공통 · 캡처와 함께 표로 남긴다) // ───────────────────────────────────────────────────────────────── /// 현재 활성 카메라와 추적 대상 사이의 실제 구도(각·거리·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"); } } }