[WL-816l] 던전 입장 카메라 안정 + 더미 캐릭터 제거 (#816)

더미 = 던전 씬 2개의 별도 루트 PC_LH_M05(전시용 치비) + ArenaWalkerRig(이 리그가 body 로 치비를 걷게 한다).
FixDuplicates 는 ArenaWalker 가 붙은 오브젝트만 꺼서 치비가 그대로 남았다 → 두 씬에서 제거(루트 10→8).
카메라 = 입장 프레임에 위치만 순간이동하고 회전은 4%/프레임만 도는 원본 CamStatus 0 가지(RealCamera.cs:199·208)와
StageDirector.Enter 의 PC 워프가 원인. WLDungeonCameraRig 를 실행순서 -100 으로 당겨 LateUpdate 에서
최종 구도로 즉시 앉히고, 던전 로딩 화면은 정착·입장 뒤에 닫게 했다. 원본 0줄 · 되돌리기 SO 스위치 4개.

실측(프레임별 · 전→후): 회전 튐 0.46°+6.89° → 0.00°+0.00° · 워프 뒤 2 m 추적 지연 → 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
깃 관리자 2026-09-15 00:55:38 +09:00
parent aae6aa8fbd
commit 9c62123291
6 changed files with 150 additions and 4091 deletions

View File

@ -90,6 +90,10 @@ MonoBehaviour:
fixDegenerateCameras: 1
cameraGuardInterval: 0.5
fixCamActionTargets: 1
dungeonCamInstantSettle: 1
dungeonCamTeleportSnap: 1
dungeonLoadingWaitForCamera: 1
dungeonLoadingMaxWaitSeconds: 8
dungeons:
- enabled_: 1
id: 1

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,11 @@ using UnityEngine;
namespace WL.Island
{
/// <summary>던전에서만 살아 있는 카메라 보정기. 씬이 내려가면 값을 원래대로 돌려놓는다(C8).</summary>
// 🔴 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";
@ -56,6 +61,7 @@ namespace WL.Island
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, scene);
var rig = go.AddComponent<WLDungeonCameraRig>();
rig._cfg = cfg;
Settled = false; // WL-816l — 새 던전에 들어갈 때마다 로딩 게이트를 다시 닫는다
return rig;
}
@ -168,6 +174,93 @@ namespace WL.Island
" · 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 = "";
/// <summary>던전 카메라가 최종 구도에 앉았는가(로딩 화면 게이트가 읽는다).</summary>
public static bool Settled;
/// <summary>검증 전용 — 0/1 이면 SO 대신 이 값을 쓴다(-1 = SO 그대로).</summary>
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;
}
/// <summary>지금 대상·설정으로 계산한 **최종 구도**(원본 Update_Cam 이 수렴하는 자리)를 돌려준다.</summary>
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;
}
/// <summary>카메라를 최종 구도로 **즉시** 옮긴다(보간 없음). 옮겼으면 1.</summary>
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;
@ -194,6 +287,7 @@ namespace WL.Island
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;

View File

@ -56,12 +56,13 @@ namespace WL.Island
if (!match) match = IsDungeonScene(scene.name);
if (!match) return;
bool dungeon = IsDungeonScene(scene.name);
var host = new GameObject("~WL_IslandLoadingFix");
host.hideFlags = HideFlags.HideAndDontSave;
host.AddComponent<WLIslandLoadingFix>().StartCoroutine(Run(host));
host.AddComponent<WLIslandLoadingFix>().StartCoroutine(Run(host, dungeon));
}
static System.Collections.IEnumerator Run(GameObject host)
static System.Collections.IEnumerator Run(GameObject host, bool dungeon)
{
// 원본이 스스로 채울 기회를 먼저 준다(전투 맵과 섞여 로드되는 경우 대비).
for (float t = 0f; t < 5f; t += Time.unscaledDeltaTime)
@ -71,8 +72,18 @@ namespace WL.Island
if (LoadingUI.isIns)
{
// 🔴 WL-816l — 던전은 **카메라가 최종 구도에 앉을 때까지** 로딩 화면을 닫지 않는다.
// 원본 순서상 로딩 칸 1(NewGameUI.SetUI_byGameMode)은 `StartCam()` **바로 뒤**에 채워진다
// (SceneInfo.Co_Control_AddScene → addloadend → InGameInfo.Load_Map 의 람다 · 씬 로드 1 초 뒤).
// 그래서 여기서 칸 0 을 먼저 채워 두면 화면이 **카메라 연출과 같은 프레임에 열려** 튐이 그대로 보인다.
// 카메라 정착(WLDungeonCameraRig.Settled) + 스테이지 입장(EnterCalls) 뒤에 채우면
// PD 가 보는 첫 화면이 이미 최종 구도다. 상한(SO)을 넘기면 조건과 무관하게 채운다(무한 대기 방지).
if (dungeon) yield return WaitForCamera();
LoadingUI.Ins.End_Loading(0);
Debug.Log("[WL-816] 섬에는 MobControlMgrList 가 없어 로딩 칸 0 을 대신 채웠다(90% 멈춤 해소)");
Debug.Log(dungeon
? ("[WL-816l] 던전 로딩 칸 0 — 카메라 정착 " + WLDungeonCameraRig.Settled +
" · 스냅 " + WLDungeonCameraRig.Snaps + " · 스테이지 입장 " + WLDungeonSceneSetup.EnterCalls + " 회 뒤에 채웠다")
: "[WL-816] 섬에는 MobControlMgrList 가 없어 로딩 칸 0 을 대신 채웠다(90% 멈춤 해소)");
if (host != null) Destroy(host);
yield break;
}
@ -80,5 +91,23 @@ namespace WL.Island
}
if (host != null) Destroy(host);
}
/// <summary>던전 전용 — 카메라가 최종 구도에 앉고 스테이지가 입장할 때까지 기다린다(상한 = SO).</summary>
static System.Collections.IEnumerator WaitForCamera()
{
var cfg = WLIslandSettings.Instance;
if (cfg == null || cfg.dungeonLoadingWaitForCamera == 0) yield break;
// 카메라 보정기 자체가 꺼져 있으면 기다릴 대상이 없다(= 816g 동작 그대로).
if (cfg.dungeonCameraMatchIsland == 0 || cfg.dungeonCamInstantSettle == 0) yield break;
int enterAtStart = WLDungeonSceneSetup.EnterCalls;
float limit = cfg.dungeonLoadingMaxWaitSeconds > 0f ? cfg.dungeonLoadingMaxWaitSeconds : 8f;
for (float t = 0f; t < limit; t += Time.unscaledDeltaTime)
{
if (WLDungeonCameraRig.Settled && WLDungeonSceneSetup.EnterCalls > enterAtStart) yield break;
yield return null;
}
Debug.Log("[WL-816l] 던전 로딩 대기 상한(" + limit.ToString("F1") + "s) 도달 — 정착 " +
WLDungeonCameraRig.Settled + " · 입장 " + (WLDungeonSceneSetup.EnterCalls - enterAtStart) + " 회");
}
}
}

View File

@ -366,6 +366,24 @@ namespace WL.Island
"0 이면 무동작(= 816i 까지의 동작).")]
public int fixCamActionTargets = 1;
[Tooltip("1 이면 던전 입장 연출에서 카메라가 **보간 없이 곧바로 최종 구도**에 놓인다(WL-816l · PD 「휙휙 바뀐다」). " +
"원본은 ⓐ 입장 프레임에 위치만 순간이동하고 회전은 초당 2.5 비율로만 돌려서 그 한 프레임이 엉뚱한 방향을 비추고 " +
"ⓑ 다음 프레임 `transform.LookAt` 이 회전을 튕기듯 맞춘다(RealCamera.cs:199·283 실측). " +
"0 이면 무동작(= 816i2 까지의 동작).")]
public int dungeonCamInstantSettle = 1;
[Tooltip("추적 대상이 한 프레임에 이 거리(m) 이상 움직이면 **순간이동**으로 보고 카메라를 즉시 따라 붙인다. " +
"던전 입장의 `StageDirector.Enter → WarpPlayerToAnchor`(앵커 뒤 startRadius m)가 여기 걸린다. " +
"걸어서는 한 프레임에 이만큼 못 움직이므로 평소 추적(부드러운 보간)에는 영향이 없다. 0 이하 = 끔.")]
public float dungeonCamTeleportSnap = 1f;
[Tooltip("1 이면 던전 로딩 화면을 **카메라가 최종 구도에 앉고 스테이지가 입장할 때까지** 닫지 않는다(WL-816l). " +
"0 이면 씬이 붙자마자 칸을 채운다(= 816g 동작) — 그러면 카메라가 자리를 잡기 전에 화면이 열려 튐이 그대로 보인다.")]
public int dungeonLoadingWaitForCamera = 1;
[Tooltip("위 대기의 상한(초). 이 시간이 지나면 조건과 무관하게 로딩 칸을 채운다(로딩 화면이 영영 안 닫히는 것 방지).")]
public float dungeonLoadingMaxWaitSeconds = 8f;
// ───────────────────────────────── 던전 표
[Header("던전 표 — 한 줄 = 던전 하나")]
public WLDungeonDef[] dungeons = new WLDungeonDef[0];