feat: cursor-steered headlights light the hero name, plates and world
This commit is contained in:
245
tests/unit/headlight-beam.test.ts
Normal file
245
tests/unit/headlight-beam.test.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
createHeadlightBeam,
|
||||
FLASH_HOLD_MS,
|
||||
FLASH_MIN_INTERVAL_MS,
|
||||
IDLE_AFTER_MS,
|
||||
REST_INTENSITY,
|
||||
SWAY_AMPLITUDE,
|
||||
SWEEP_FROM,
|
||||
SWEEP_MS,
|
||||
SWEEP_TO,
|
||||
type HeadlightBeam,
|
||||
} from '@/journey/headlight-beam';
|
||||
|
||||
/** Samples every 16 ms from `from` up to and including `to`. */
|
||||
const run = (beam: HeadlightBeam, from: number, to: number) => {
|
||||
let sample = beam.sample(from);
|
||||
for (let now = from + 16; now <= to; now += 16) sample = beam.sample(now);
|
||||
return beam.sample(to);
|
||||
};
|
||||
|
||||
const swept = (start = 0): HeadlightBeam => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(start);
|
||||
beam.sweep(start);
|
||||
run(beam, start, start + SWEEP_MS + 2_000);
|
||||
return beam;
|
||||
};
|
||||
|
||||
describe('headlight beam: smoothing', () => {
|
||||
it('points dead ahead and dark before anything happens', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
expect(beam.sample(0)).toEqual({ x: 0, y: 0, intensity: 0 });
|
||||
expect(run(beam, 0, 1_000)).toEqual({ x: 0, y: 0, intensity: 0 });
|
||||
});
|
||||
|
||||
it('follows the pointer critically damped: no overshoot, close within half a second', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.input(0.8, -0.4, 'mouse', 0);
|
||||
let previous = 0;
|
||||
let peak = 0;
|
||||
for (let now = 16; now <= 600; now += 16) {
|
||||
const { x } = beam.sample(now);
|
||||
expect(x).toBeGreaterThanOrEqual(previous - 1e-9);
|
||||
previous = x;
|
||||
peak = Math.max(peak, x);
|
||||
if (now === 176) {
|
||||
// About the smooth time (180 ms): more than a third, not there yet.
|
||||
expect(x).toBeGreaterThan(0.8 * 0.35);
|
||||
expect(x).toBeLessThan(0.8 * 0.8);
|
||||
}
|
||||
}
|
||||
expect(peak).toBeLessThanOrEqual(0.8 + 1e-9);
|
||||
const settled = beam.sample(600);
|
||||
expect(settled.x).toBeCloseTo(0.8, 1);
|
||||
expect(settled.y).toBeCloseTo(-0.4, 1);
|
||||
});
|
||||
|
||||
it('clamps input to the viewport and ignores touch (a tap is a flash)', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.input(0.5, 0, 'touch', 0);
|
||||
expect(run(beam, 0, 800).x).toBe(0);
|
||||
beam.input(4, -9, 'pen', 800);
|
||||
const sample = run(beam, 800, 2_400);
|
||||
expect(sample.x).toBeCloseTo(1, 2);
|
||||
expect(sample.y).toBeCloseTo(-1, 2);
|
||||
});
|
||||
|
||||
it('survives a long frame gap without jumping past the target', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.input(1, 0, 'mouse', 0);
|
||||
// One 3 s gap (a backgrounded tab) is integrated as a single short step.
|
||||
const sample = beam.sample(3_000);
|
||||
expect(sample.x).toBeGreaterThan(0);
|
||||
expect(sample.x).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('returns the same sample for the same frame time', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.input(1, 0, 'mouse', 0);
|
||||
const a = beam.sample(50);
|
||||
expect(beam.sample(50)).toBe(a);
|
||||
expect(beam.sample(40)).toBe(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headlight beam: idle return', () => {
|
||||
it('holds the pointer, then drifts back to dead ahead after 4 s idle', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.input(0.9, 0.5, 'mouse', 0);
|
||||
expect(run(beam, 0, IDLE_AFTER_MS - 50).x).toBeCloseTo(0.9, 2);
|
||||
expect(beam.idleAt()).toBe(IDLE_AFTER_MS);
|
||||
// Slower than the follow: a third of a second in, it has barely moved.
|
||||
const early = run(beam, IDLE_AFTER_MS - 50, IDLE_AFTER_MS + 300);
|
||||
expect(early.x).toBeGreaterThan(0.6);
|
||||
const back = run(beam, IDLE_AFTER_MS + 300, IDLE_AFTER_MS + 5_000);
|
||||
expect(back.x).toBeCloseTo(0, 2);
|
||||
expect(back.y).toBeCloseTo(0, 2);
|
||||
expect(beam.settled(IDLE_AFTER_MS + 5_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('a new input cancels the idle drift', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.input(0.9, 0, 'mouse', 0);
|
||||
run(beam, 0, IDLE_AFTER_MS + 400);
|
||||
beam.input(-0.5, 0, 'mouse', IDLE_AFTER_MS + 400);
|
||||
expect(run(beam, IDLE_AFTER_MS + 400, IDLE_AFTER_MS + 1_400).x).toBeCloseTo(-0.5, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headlight beam: intro sweep', () => {
|
||||
it('arcs from -1 to +0.25 over 1.6 s, ease-out cubic, intensity 0 to 1', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
expect(beam.sweep(100, -0.4)).toBe(true);
|
||||
const start = beam.sample(100);
|
||||
expect(start.x).toBeCloseTo(SWEEP_FROM, 6);
|
||||
expect(start.intensity).toBeCloseTo(0, 6);
|
||||
expect(start.y).toBeCloseTo(-0.4, 6);
|
||||
// Ease-out: well past halfway in x at half time.
|
||||
const half = beam.sample(100 + SWEEP_MS / 2);
|
||||
const eased = 1 - 0.5 ** 3;
|
||||
expect(half.x).toBeCloseTo(SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * eased, 6);
|
||||
expect(half.intensity).toBeCloseTo(eased, 6);
|
||||
expect(beam.swept(100 + SWEEP_MS - 1)).toBe(false);
|
||||
const end = beam.sample(100 + SWEEP_MS);
|
||||
expect(end.x).toBeCloseTo(SWEEP_TO, 6);
|
||||
expect(end.intensity).toBeCloseTo(1, 6);
|
||||
expect(beam.swept(100 + SWEEP_MS)).toBe(true);
|
||||
});
|
||||
|
||||
it('is monotonic in x and plays only once', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.sweep(0);
|
||||
let previous = -Infinity;
|
||||
for (let now = 0; now <= SWEEP_MS; now += 16) {
|
||||
const { x } = beam.sample(now);
|
||||
expect(x).toBeGreaterThanOrEqual(previous);
|
||||
previous = x;
|
||||
}
|
||||
expect(beam.sweep(SWEEP_MS + 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('holds the arc end, then settles at dipped beam until the idle drift', () => {
|
||||
const beam = swept();
|
||||
const rest = beam.sample(SWEEP_MS + 2_000);
|
||||
expect(rest.x).toBeCloseTo(SWEEP_TO, 3);
|
||||
expect(rest.intensity).toBeCloseTo(REST_INTENSITY, 3);
|
||||
expect(run(beam, SWEEP_MS + 2_000, SWEEP_MS + IDLE_AFTER_MS + 6_000).x).toBeCloseTo(0, 2);
|
||||
});
|
||||
|
||||
it('hands over to a pointer that moved during the sweep', () => {
|
||||
const beam = createHeadlightBeam();
|
||||
beam.sample(0);
|
||||
beam.sweep(0);
|
||||
beam.input(-0.6, 0.2, 'mouse', 400);
|
||||
expect(beam.sample(800).x).toBeLessThan(SWEEP_TO);
|
||||
const after = run(beam, 800, SWEEP_MS + 1_000);
|
||||
expect(after.x).toBeCloseTo(-0.6, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('headlight beam: flash', () => {
|
||||
it('lifts to full intensity for 420 ms, then falls back to dipped beam', () => {
|
||||
const beam = swept();
|
||||
const t0 = SWEEP_MS + 2_000;
|
||||
expect(beam.flash(t0)).toBe(true);
|
||||
const lifted = run(beam, t0, t0 + 300);
|
||||
expect(lifted.intensity).toBeGreaterThan(0.98);
|
||||
const after = run(beam, t0 + 300, t0 + FLASH_HOLD_MS + 1_500);
|
||||
expect(after.intensity).toBeCloseTo(REST_INTENSITY, 2);
|
||||
});
|
||||
|
||||
it('eases in rather than stepping (no hard flash)', () => {
|
||||
const beam = swept();
|
||||
const t0 = SWEEP_MS + 2_000;
|
||||
beam.flash(t0);
|
||||
const first = beam.sample(t0 + 16);
|
||||
expect(first.intensity - REST_INTENSITY).toBeLessThan((1 - REST_INTENSITY) * 0.3);
|
||||
});
|
||||
|
||||
it('is rate-limited to one per 600 ms: never more than 3 per second', () => {
|
||||
expect(FLASH_MIN_INTERVAL_MS).toBeGreaterThanOrEqual(1000 / 3);
|
||||
const beam = swept();
|
||||
const t0 = 10_000;
|
||||
const accepted: number[] = [];
|
||||
// A visitor hammering the hero: 10 clicks a second for 5 seconds.
|
||||
for (let now = t0; now < t0 + 5_000; now += 100) {
|
||||
if (beam.flash(now)) accepted.push(now);
|
||||
}
|
||||
for (let i = 1; i < accepted.length; i++) {
|
||||
expect(accepted[i]! - accepted[i - 1]!).toBeGreaterThanOrEqual(FLASH_MIN_INTERVAL_MS);
|
||||
}
|
||||
for (let start = t0; start < t0 + 5_000; start += 100) {
|
||||
expect(accepted.filter((at) => at >= start && at < start + 1_000).length).toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect(accepted.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('the intensity crosses its midpoint at most 3 times a second under hammering', () => {
|
||||
const beam = swept();
|
||||
const t0 = 10_000;
|
||||
const mid = (1 + REST_INTENSITY) / 2;
|
||||
let above = false;
|
||||
const crossings: number[] = [];
|
||||
for (let now = t0; now < t0 + 6_000; now += 8) {
|
||||
if (now % 100 === 0) beam.flash(now);
|
||||
const high = beam.sample(now).intensity > mid;
|
||||
if (high && !above) crossings.push(now);
|
||||
above = high;
|
||||
}
|
||||
for (let start = t0; start < t0 + 5_000; start += 50) {
|
||||
expect(crossings.filter((at) => at >= start && at < start + 1_000).length).toBeLessThanOrEqual(3);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('headlight beam: auto-sway (touch, coarse, low tier)', () => {
|
||||
it('sways slowly within +-0.3 and never settles', () => {
|
||||
const beam = swept();
|
||||
beam.setAutoSway(true);
|
||||
let min = Infinity;
|
||||
let max = -Infinity;
|
||||
const t0 = SWEEP_MS + 2_000;
|
||||
for (let now = t0; now <= t0 + 25_000; now += 32) {
|
||||
const { x } = beam.sample(now);
|
||||
min = Math.min(min, x);
|
||||
max = Math.max(max, x);
|
||||
}
|
||||
expect(max).toBeLessThanOrEqual(SWEEP_AMPLITUDE_GUARD);
|
||||
expect(min).toBeGreaterThanOrEqual(-SWEEP_AMPLITUDE_GUARD);
|
||||
expect(max - min).toBeGreaterThan(SWAY_AMPLITUDE);
|
||||
expect(beam.settled(t0 + 25_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** The sway target is +-0.3; the arc end (0.25) is inside it too. */
|
||||
const SWEEP_AMPLITUDE_GUARD = SWAY_AMPLITUDE + 1e-6;
|
||||
Reference in New Issue
Block a user