diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs index 174df3a..96712cb 100644 --- a/src/AcDream.App/Rendering/ChaseCamera.cs +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -46,21 +46,15 @@ public sealed class ChaseCamera : ICamera private float _playerYaw; private Vector3 _lookAt; - // K-fix11 (2026-04-26): smoothed Z follow. Without this the camera - // tracks player.Z 1:1 — during a 3 m jump the camera also rises 3 m - // and the player's screen position never changes, making the jump - // visually invisible. A retail observer sees ACdream's jump arc - // clearly because retail's chase camera lags the Z follow. Mirror - // that: the camera Z target uses an exponential low-pass filter - // toward player.Z with a ~150 ms time constant. Initialised on - // first Update so spawn doesn't snap. - private float _smoothedPlayerZ; - private bool _smoothedZInitialised; - /// Time constant for the camera-Z smoothing filter (seconds). - /// Smaller = camera catches up faster (less perceptible jump arc on - /// screen). Larger = jumps look bigger but camera lags on slope - /// transitions. 0.15 = retail-feel feedback for a ~0.5–1.0 s jump. - public float ZFollowTimeConstant { get; set; } = 0.15f; + // 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); @@ -69,30 +63,37 @@ public sealed class ChaseCamera : ICamera Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f); /// - /// Update the camera position to follow the player. - /// drives the Z-smoothing filter. + /// Update the camera position to follow the player. + /// drives the airborne-pin behavior: while airborne and rising, the + /// camera stays at last-grounded Z so the jump is visible on screen. /// - public void Update(Vector3 playerPosition, float playerYaw, float dt = 1f / 60f) + public void Update(Vector3 playerPosition, float playerYaw, bool isOnGround = true, float dt = 1f / 60f) { _playerYaw = playerYaw; - // K-fix11: smooth the camera's Z target using an exponential - // low-pass filter. Camera Z lags player Z so airborne motion - // visibly shows up on screen. - if (!_smoothedZInitialised) + // 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) { - _smoothedPlayerZ = playerPosition.Z; - _smoothedZInitialised = true; + _trackedZ = playerPosition.Z; + _trackedZInitialised = true; } - else + else if (isOnGround) { - float alpha = 1f - MathF.Exp(-MathF.Max(dt, 1e-4f) / MathF.Max(ZFollowTimeConstant, 1e-4f)); - _smoothedPlayerZ += (playerPosition.Z - _smoothedPlayerZ) * alpha; + _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 player rises above the lagged camera - // the camera tilts up slightly to keep them in frame. + // 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 @@ -107,7 +108,7 @@ public sealed class ChaseCamera : ICamera Position = new Vector3( playerPosition.X - forwardX * horizontalDist, playerPosition.Y - forwardY * horizontalDist, - _smoothedPlayerZ + EyeHeight + verticalDist); // ← uses smoothed Z, not raw + _trackedZ + EyeHeight + verticalDist); // ← uses tracked Z (pinned to ground while airborne) } /// diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index a456fd9..92a7913 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -3987,11 +3987,16 @@ public sealed class GameWindow : IDisposable _worldState.RelocateEntity(pe, currentLb); } - // Update chase camera. K-fix11 (2026-04-26): pass dt so the - // Z-smoothing low-pass filter tracks correctly on variable - // frame rates — without this the camera tracks player.Z 1:1 - // and jumps look invisible (camera rises with player). - _chaseCamera.Update(result.Position, _playerController.Yaw, (float)dt); + // Update chase camera. K-fix12 (2026-04-26): pass isOnGround + // so the camera pins its Z to last-grounded while the + // player is airborne — without this the camera follows + // player.Z 1:1 during a jump and the player's screen + // position never changes. With the pin: player visibly + // rises above the camera, matching retail "you can see + // yourself jump" feedback. + _chaseCamera.Update(result.Position, _playerController.Yaw, + isOnGround: result.IsOnGround, + dt: (float)dt); // Send outbound movement messages to the live server. if (_liveSession is not null)