feat(app): Phase B.2 — ChaseCamera (third-person follow camera)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-12 14:26:20 +02:00
parent 631fd3c9bb
commit 84d7d06008
2 changed files with 130 additions and 0 deletions

View file

@ -0,0 +1,66 @@
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.</summary>
public float Distance { get; set; } = 8f;
/// <summary>Camera pitch above horizontal (radians). Positive = look down.</summary>
public float Pitch { get; set; } = 0.35f; // ~20 degrees
/// <summary>Vertical offset from the player's feet to the look-at point (eye height).</summary>
public float EyeHeight { get; set; } = 1.5f;
private const float PitchMin = 0.05f;
private const float PitchMax = 1.4f; // ~80 degrees
private float _playerYaw;
private Vector3 _lookAt;
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.
/// </summary>
public void Update(Vector3 playerPosition, float playerYaw)
{
_playerYaw = playerYaw;
_lookAt = playerPosition + new Vector3(0f, 0f, EyeHeight);
// Camera offset: behind the player (-forward direction) and above.
float forwardX = MathF.Cos(playerYaw);
float forwardY = MathF.Sin(playerYaw);
float horizontalDist = Distance * MathF.Cos(Pitch);
float verticalDist = Distance * MathF.Sin(Pitch);
Position = new Vector3(
playerPosition.X - forwardX * horizontalDist,
playerPosition.Y - forwardY * horizontalDist,
playerPosition.Z + EyeHeight + verticalDist);
}
/// <summary>
/// Adjust pitch by a delta (from mouse Y movement).
/// </summary>
public void AdjustPitch(float delta)
{
Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
}
}

View file

@ -0,0 +1,64 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
using Xunit;
namespace AcDream.Core.Tests.Rendering;
public class ChaseCameraTests
{
[Fact]
public void Position_BehindPlayer_WhenYawIsZero()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f);
// Yaw=0 means facing +X (cos(0)=1, sin(0)=0).
// Camera should be BEHIND the player: negative X direction.
Assert.True(camera.Position.X < -1f, $"Camera X={camera.Position.X} should be behind player (negative X)");
Assert.True(camera.Position.Z > 0f, $"Camera Z={camera.Position.Z} should be above player");
}
[Fact]
public void Position_BehindPlayer_WhenYawIsHalfPi()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
camera.Update(playerPosition: Vector3.Zero, playerYaw: MathF.PI / 2f);
// Yaw=PI/2 means facing +Y. Camera should be behind: negative Y.
Assert.True(camera.Position.Y < -1f, $"Camera Y={camera.Position.Y} should be behind player (negative Y)");
}
[Fact]
public void Position_FollowsPlayerPosition()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
var playerPos = new Vector3(100f, 200f, 50f);
camera.Update(playerPosition: playerPos, playerYaw: 0f);
// Camera should be near the player, not at the origin.
Assert.InRange(camera.Position.X, 85f, 100f); // behind but close
Assert.InRange(camera.Position.Y, 195f, 205f); // roughly same Y
}
[Fact]
public void PitchAdjustment_ChangesHeight()
{
var camera = new ChaseCamera { Aspect = 16f / 9f };
camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f);
float z1 = camera.Position.Z;
camera.AdjustPitch(0.2f);
camera.Update(playerPosition: Vector3.Zero, playerYaw: 0f);
float z2 = camera.Position.Z;
Assert.True(z2 > z1, "Increasing pitch should raise the camera");
}
[Fact]
public void ImplementsICamera()
{
ICamera camera = new ChaseCamera { Aspect = 16f / 9f };
camera.ToString(); // just proves interface is implemented
}
}