acdream/src/AcDream.App/Rendering/ChaseCamera.cs
Erik 4b6fcffa01 fix(camera): pin chase-camera Z to last-grounded while airborne
K-fix11 (150 ms exponential lag) wasn't aggressive enough — at
0.15 s time constant the camera catches up to ~96 % of the
player's Z before peak, so the visible "rise on screen" was
maybe ~0.5 m of the 3.11 m arc. User reported the jump still
looked short.

K-fix12: replace the lag with an explicit airborne-pin. The
camera's tracked Z follows player Z directly while grounded,
but stays PINNED while airborne and rising. Falling / dropping
catches up immediately so we don't end up below ground when
landing in a hole.

Effect: during a jump the player visibly rises 3 m above the
camera on screen, matching retail's "you can see yourself jump"
feel. After landing the camera's tracked Z snaps back to the
player Z so there's no lingering vertical offset.

ChaseCamera.Update gains an isOnGround parameter; GameWindow
passes result.IsOnGround from the per-frame movement controller.
The look-at point still uses raw player Z so the camera tilts up
to keep the airborne character framed.

Tests stay 1222 green.
2026-04-26 18:23:02 +02:00

129 lines
5.3 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;
public float FovY { get; set; } = MathF.PI / 3f;
/// <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);
public Matrix4x4 Projection =>
Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 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);
}
}