Retail's world-camera FOV is not a constant: the applied vertical FOV is m_fGameFOV / (viewportAspect - 0.1), recomputed on every aspect or game-FOV change (CreatureMode smartbox sites 0x00452b2f/0x00453b14), gated by Render::SetFOVRad's open (0, pi) acceptance (0x0054b2d0 - rejected results keep the previous FOV). m_fGameFOV defaults to pi/2 = 90 degrees (0x00454649) and is what the Field of View option sets in degrees (0x00451e6a; registered range [10,160] default 90 - gmClient::InitUIPreferences @0x004035b0). Net effect: the horizontal view stays ~85-90 degrees across aspect ratios; wide screens trim the vertical slice instead of ballooning the sides. acdream hardcoded FovY = pi/3 = 60 degrees on all four world cameras, aspect-independent, and the Config slider wrote raw vertical-FOV degrees. New: RetailFieldOfView (the law + gate, decomp-cited), CameraController.GameFovRadians + SetGameFov + one ApplyProjection chokepoint recomputing every camera on SetAspect/SetGameFov/ EnterChaseMode/RestoreState; ApplyFieldOfView now feeds the law; DisplaySettings.Default.FieldOfView 60 -> 90 (the retail registered default; the stored number changed MEANING with this commit). The same seam closes a second latent bug the 2026-08-13 "squished" gate report exposed: SetAspect only ever updated Orbit/Fly - the CHASE cameras (the ones the player looks through) kept their creation-time aspect across every mid-session resize, drawing the world at the old shape stretched onto the new viewport. The paperdoll camera stays outside the law by design (retail portrait mode is UseSharpMode, not smartbox - DollCamera's own doc). Tests: RetailFieldOfViewTests (golden law values at 4:3/16:9/21:9, the constant-horizontal property, the rejection gate, controller propagation incl. chase attach/restore + rejected-law aspect-still-propagates); DisplaySettingsTests + RuntimeSettingsControllerTests updated to the new semantics. App suite 4,953/3 skips; UI.Abstractions 916/0. AD-89 retired in this commit; user settings.json migrated 60->90 by hand (stale pre-port default). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
5.6 KiB
C#
132 lines
5.6 KiB
C#
using System;
|
|
using System.Numerics;
|
|
|
|
namespace AcDream.App.Rendering;
|
|
|
|
/// <summary>
|
|
/// Third-person chase camera that follows behind and above a player
|
|
/// character. Implements <see cref="ICamera"/> so it plugs into the
|
|
/// existing renderer pipeline.
|
|
/// </summary>
|
|
public sealed class ChaseCamera : ICamera
|
|
{
|
|
public Vector3 Position { get; private set; }
|
|
public float Aspect { get; set; } = 16f / 9f;
|
|
// #389: smartbox law at the 16:9 default aspect — see RetailFieldOfView.
|
|
// CameraController owns the live recompute on every aspect/game-FOV change.
|
|
public float FovY { get; set; } = RetailFieldOfView.DefaultAppliedFovY;
|
|
|
|
/// <summary>Distance behind the player. Clamped to [<see cref="DistanceMin"/>, <see cref="DistanceMax"/>].</summary>
|
|
public float Distance { get; set; } = 8f;
|
|
public const float DistanceMin = 2f;
|
|
public const float DistanceMax = 40f;
|
|
|
|
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
|
|
public float Pitch { get; set; } = 0.35f; // ~20 degrees
|
|
|
|
/// <summary>
|
|
/// Additional yaw applied on top of the player's heading when positioning
|
|
/// the camera. Used by the hold-RMB "inspect" mode to orbit around the
|
|
/// player without rotating the character. Snap to 0 to return the camera
|
|
/// to directly behind the player.
|
|
/// </summary>
|
|
public float YawOffset { get; set; } = 0f;
|
|
|
|
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
|
|
public float EyeHeight { get; set; } = 1.5f;
|
|
|
|
// Pitch range: negative values place the camera below the player's Z
|
|
// (at distance * sin(Pitch)) so the player can be viewed from a low
|
|
// angle. Clamped to -0.7 to avoid pushing the camera deep underground;
|
|
// at -0.7 and Distance=8 the camera is ~5m below player-Z which will
|
|
// clip terrain on hills but is OK on flat ground. 1.4 ≈ looking
|
|
// straight down. Wider than the old [0.05, 1.4] so mouse-Y moves the
|
|
// camera in both directions from the neutral [~20°] default.
|
|
private const float PitchMin = -0.7f;
|
|
private const float PitchMax = 1.4f;
|
|
|
|
private float _playerYaw;
|
|
private Vector3 _lookAt;
|
|
|
|
// K-fix12 (2026-04-26): retail-feel jump camera. The camera Z is
|
|
// pinned to the LAST GROUNDED Z while the player is airborne — the
|
|
// character rises above the camera on screen, visually matching
|
|
// retail's "you can see yourself jump" feedback. Walking on the
|
|
// ground tracks Z directly (no lag on hill transitions); falling
|
|
// catches up immediately so we don't end up below ground when
|
|
// landing in a pit. Only the upward-while-airborne case is pinned.
|
|
private float _trackedZ;
|
|
private bool _trackedZInitialised;
|
|
|
|
public Matrix4x4 View =>
|
|
Matrix4x4.CreateLookAt(Position, _lookAt, Vector3.UnitZ);
|
|
|
|
// Near plane 0.1 m = retail Render::znear (see RetailChaseCamera.Projection).
|
|
public Matrix4x4 Projection =>
|
|
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 0.1f, 5000f);
|
|
|
|
/// <summary>
|
|
/// Update the camera position to follow the player. <paramref name="isOnGround"/>
|
|
/// drives the airborne-pin behavior: while airborne and rising, the
|
|
/// camera stays at last-grounded Z so the jump is visible on screen.
|
|
/// </summary>
|
|
public void Update(Vector3 playerPosition, float playerYaw, bool isOnGround = true, float dt = 1f / 60f)
|
|
{
|
|
_playerYaw = playerYaw;
|
|
|
|
// K-fix12: track the camera's reference Z.
|
|
// - On ground: snap directly to player.Z (smooth slope walking).
|
|
// - Airborne + rising: stay pinned (player visibly rises above camera).
|
|
// - Airborne + falling below tracked Z: catch up so we don't lag below
|
|
// ground when landing somewhere lower (a cliff / hole).
|
|
if (!_trackedZInitialised)
|
|
{
|
|
_trackedZ = playerPosition.Z;
|
|
_trackedZInitialised = true;
|
|
}
|
|
else if (isOnGround)
|
|
{
|
|
_trackedZ = playerPosition.Z;
|
|
}
|
|
else if (playerPosition.Z < _trackedZ)
|
|
{
|
|
_trackedZ = playerPosition.Z; // catch up to falls / drops
|
|
}
|
|
// else: airborne and rising — keep _trackedZ pinned.
|
|
|
|
// Look-at uses the actual player Z so the camera always points
|
|
// at the character — when the player rises above the pinned
|
|
// camera the look-at tilts up to keep them centered in frame.
|
|
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
|
|
|
|
// Camera offset: behind the player (-forward direction) plus any
|
|
// YawOffset for the hold-RMB inspect orbit mode.
|
|
float effectiveYaw = playerYaw + YawOffset;
|
|
float forwardX = MathF.Cos(effectiveYaw);
|
|
float forwardY = MathF.Sin(effectiveYaw);
|
|
|
|
float horizontalDist = Distance * MathF.Cos(Pitch);
|
|
float verticalDist = Distance * MathF.Sin(Pitch);
|
|
|
|
Position = new Vector3(
|
|
playerPosition.X - forwardX * horizontalDist,
|
|
playerPosition.Y - forwardY * horizontalDist,
|
|
_trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne)
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adjust pitch by a delta (from mouse Y movement).
|
|
/// </summary>
|
|
public void AdjustPitch(float delta)
|
|
{
|
|
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adjust distance (zoom) by a delta, clamped to [DistanceMin, DistanceMax].
|
|
/// </summary>
|
|
public void AdjustDistance(float delta)
|
|
{
|
|
Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
|
|
}
|
|
}
|