Seven pure-math helpers in the new RetailChaseCamera class: ComputeHeading (slope-align with flat fallback), BuildBasis (heading → orthonormal frame, near-vertical fallback), PushVelocity + AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency (linear ramp 0.20..0.45 m). 20 tests, all pass. State machine + Update() land in the next commit. Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
268 lines
9.3 KiB
C#
268 lines
9.3 KiB
C#
using System;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using Xunit;
|
|
|
|
namespace AcDream.App.Tests.Rendering;
|
|
|
|
public class RetailChaseCameraTests
|
|
{
|
|
// ── Heading source ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Heading_StationaryWithSlopeAlign_FallsBackToYawVector()
|
|
{
|
|
var avgVel = Vector3.Zero;
|
|
float yaw = MathF.PI / 4f; // 45°
|
|
|
|
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: true);
|
|
|
|
Assert.Equal(MathF.Cos(yaw), h.X, 5);
|
|
Assert.Equal(MathF.Sin(yaw), h.Y, 5);
|
|
Assert.Equal(0f, h.Z, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void Heading_MovingHorizontal_MatchesNormalizedVelocity()
|
|
{
|
|
var avgVel = new Vector3(3f, 0f, 0f);
|
|
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
|
|
Assert.Equal(1f, h.X, 5);
|
|
Assert.Equal(0f, h.Y, 5);
|
|
Assert.Equal(0f, h.Z, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void Heading_MovingUphill_HasPositiveZ()
|
|
{
|
|
var avgVel = new Vector3(1f, 0f, 0.5f);
|
|
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
|
|
Assert.True(h.Z > 0f, $"expected positive Z component, got {h.Z}");
|
|
}
|
|
|
|
[Fact]
|
|
public void Heading_SlopeAlignDisabled_IgnoresVelocity()
|
|
{
|
|
var avgVel = new Vector3(0f, 0f, 1f); // pure upward; would dominate if slope-align were on
|
|
float yaw = 0f;
|
|
|
|
var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: false);
|
|
|
|
Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0)
|
|
Assert.Equal(0f, h.Y, 5);
|
|
Assert.Equal(0f, h.Z, 5);
|
|
}
|
|
|
|
// ── Basis from heading ────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
|
|
{
|
|
var (forward, right, up) = RetailChaseCamera.BuildBasis(new Vector3(1f, 0f, 0f));
|
|
|
|
Assert.Equal(1f, forward.Length(), 5);
|
|
Assert.Equal(1f, right.Length(), 5);
|
|
Assert.Equal(1f, up.Length(), 5);
|
|
|
|
// Orthogonal
|
|
Assert.Equal(0f, Vector3.Dot(forward, right), 5);
|
|
Assert.Equal(0f, Vector3.Dot(forward, up), 5);
|
|
Assert.Equal(0f, Vector3.Dot(right, up), 5);
|
|
|
|
// forward = (1,0,0), world up = (0,0,1) → right = (0,-1,0), camera-up = (0,0,1)
|
|
Assert.Equal(0f, up.X, 5);
|
|
Assert.Equal(0f, up.Y, 5);
|
|
Assert.True(up.Z > 0f);
|
|
}
|
|
|
|
[Fact]
|
|
public void Basis_NearVerticalHeading_UsesXFallbackForRight()
|
|
{
|
|
// forward nearly straight up (rare; airborne edge case). Must not produce
|
|
// a zero-length right vector from cross(forward, worldUp).
|
|
var (_, right, up) = RetailChaseCamera.BuildBasis(new Vector3(0f, 0f, 1f));
|
|
|
|
Assert.Equal(1f, right.Length(), 5);
|
|
Assert.Equal(1f, up.Length(), 5);
|
|
}
|
|
|
|
// ── Velocity ring & averaging ────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void VelocityRing_AveragesLastN()
|
|
{
|
|
var ring = new Vector3[5];
|
|
int count = 0;
|
|
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(3, 0, 0));
|
|
|
|
Assert.Equal(5, count);
|
|
var avg = RetailChaseCamera.AverageVelocity(ring, count);
|
|
Assert.Equal(1.8f, avg.X, 5);
|
|
Assert.Equal(0f, avg.Y, 5);
|
|
Assert.Equal(0f, avg.Z, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void VelocityRing_FifoEvictsOldest()
|
|
{
|
|
var ring = new Vector3[5];
|
|
int count = 0;
|
|
|
|
// Push 6 entries; oldest (the first 1,0,0) should be evicted.
|
|
for (int i = 0; i < 5; i++)
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(10, 0, 0));
|
|
|
|
Assert.Equal(5, count); // still capped at 5
|
|
// Sum of newest 5 entries: 4*(1,0,0) + (10,0,0) = (14,0,0), avg = 2.8
|
|
var avg = RetailChaseCamera.AverageVelocity(ring, count);
|
|
Assert.Equal(2.8f, avg.X, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void VelocityRing_PartialFillUsesActualCount()
|
|
{
|
|
var ring = new Vector3[5];
|
|
int count = 0;
|
|
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
|
|
ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(4, 0, 0));
|
|
|
|
Assert.Equal(2, count);
|
|
var avg = RetailChaseCamera.AverageVelocity(ring, count);
|
|
Assert.Equal(3f, avg.X, 5); // (2+4)/2, not (2+4)/5
|
|
}
|
|
|
|
// ── Damping alpha ────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void DampingAlpha_RetailDefault_ProducesSevenAndAHalfPercent()
|
|
{
|
|
// stiffness=0.45, dt=1/60 → 0.45 * (1/60) * 10 ≈ 0.075
|
|
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f / 60f);
|
|
Assert.Equal(0.075f, alpha, 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void DampingAlpha_LargeDtClampsToOne()
|
|
{
|
|
float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f);
|
|
Assert.Equal(1f, alpha);
|
|
}
|
|
|
|
[Fact]
|
|
public void DampingAlpha_NegativeOrZero_ClampsToZero()
|
|
{
|
|
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 0f));
|
|
Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.0f, dt: 1f));
|
|
}
|
|
|
|
// ── Mouse low-pass ───────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void MouseFilter_BeyondWindow_OutputsRaw()
|
|
{
|
|
float lastDelta = 5f;
|
|
float lastTime = 0f;
|
|
float windowSec = 0.25f;
|
|
|
|
float result = RetailChaseCamera.FilterMouseAxis(
|
|
raw: 10f, weight: 0.5f, nowSec: 1.0f,
|
|
ref lastDelta, ref lastTime, windowSec);
|
|
|
|
// Beyond window, blended == raw, so out = raw * 0.5 + raw * 0.5 = raw.
|
|
Assert.Equal(10f, result, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseFilter_WithinWindow_AveragesWithPrevious()
|
|
{
|
|
float lastDelta = 10f;
|
|
float lastTime = 0f;
|
|
float windowSec = 0.25f;
|
|
|
|
float result = RetailChaseCamera.FilterMouseAxis(
|
|
raw: 20f, weight: 0.5f, nowSec: 0.1f,
|
|
ref lastDelta, ref lastTime, windowSec);
|
|
|
|
// Within window: avg = (10 + 20)/2 = 15.
|
|
// Output: 20 * 0.5 + 15 * 0.5 = 17.5
|
|
Assert.Equal(17.5f, result, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseFilter_WeightZero_OutputsRaw()
|
|
{
|
|
float lastDelta = 10f;
|
|
float lastTime = 0f;
|
|
float windowSec = 0.25f;
|
|
|
|
float result = RetailChaseCamera.FilterMouseAxis(
|
|
raw: 20f, weight: 0f, nowSec: 0.1f,
|
|
ref lastDelta, ref lastTime, windowSec);
|
|
|
|
Assert.Equal(20f, result, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseFilter_WeightOne_OutputsAveraged()
|
|
{
|
|
float lastDelta = 10f;
|
|
float lastTime = 0f;
|
|
float windowSec = 0.25f;
|
|
|
|
float result = RetailChaseCamera.FilterMouseAxis(
|
|
raw: 20f, weight: 1f, nowSec: 0.1f,
|
|
ref lastDelta, ref lastTime, windowSec);
|
|
|
|
// weight=1 → out = avg = 15
|
|
Assert.Equal(15f, result, 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void MouseFilter_UpdatesLastDeltaAndTime()
|
|
{
|
|
float lastDelta = 10f;
|
|
float lastTime = 0f;
|
|
float windowSec = 0.25f;
|
|
|
|
float result = RetailChaseCamera.FilterMouseAxis(
|
|
raw: 20f, weight: 0.5f, nowSec: 0.1f,
|
|
ref lastDelta, ref lastTime, windowSec);
|
|
|
|
Assert.Equal(result, lastDelta); // last is updated to output
|
|
Assert.Equal(0.1f, lastTime, 5); // last time advances
|
|
}
|
|
|
|
// ── Auto-fade translucency ───────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Translucency_AtFarThreshold_IsZero()
|
|
{
|
|
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 0.45f), 5);
|
|
Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 1.00f), 5);
|
|
}
|
|
|
|
[Fact]
|
|
public void Translucency_MidwayBetweenThresholds_IsHalf()
|
|
{
|
|
// Midpoint between 0.20 and 0.45 = 0.325
|
|
// t = 1 - (0.20 - 0.325) / (0.20 - 0.45)
|
|
// = 1 - (-0.125) / (-0.25)
|
|
// = 1 - 0.5 = 0.5
|
|
Assert.Equal(0.5f, RetailChaseCamera.ComputeTranslucency(distance: 0.325f), 4);
|
|
}
|
|
|
|
[Fact]
|
|
public void Translucency_AtNearThreshold_IsOne()
|
|
{
|
|
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.20f), 5);
|
|
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
|
|
Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
|
|
}
|
|
}
|