diff --git a/src/AcDream.App/Rendering/ChaseCamera.cs b/src/AcDream.App/Rendering/ChaseCamera.cs new file mode 100644 index 0000000..d70950a --- /dev/null +++ b/src/AcDream.App/Rendering/ChaseCamera.cs @@ -0,0 +1,66 @@ +using System; +using System.Numerics; + +namespace AcDream.App.Rendering; + +/// +/// Third-person chase camera that follows behind and above a player +/// character. Implements so it plugs into the +/// existing renderer pipeline. +/// +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; + + /// Distance behind the player. + public float Distance { get; set; } = 8f; + + /// Camera pitch above horizontal (radians). Positive = look down. + public float Pitch { get; set; } = 0.35f; // ~20 degrees + + /// Vertical offset from the player's feet to the look-at point (eye height). + 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); + + /// + /// Update the camera position to follow the player. + /// + 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); + } + + /// + /// Adjust pitch by a delta (from mouse Y movement). + /// + public void AdjustPitch(float delta) + { + Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs b/tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs new file mode 100644 index 0000000..bd1ebfe --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/ChaseCameraTests.cs @@ -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 + } +}