feat: cursor-steered headlights light the hero name, plates and world

This commit is contained in:
2026-10-11 05:07:05 +03:00
parent 52b4a1ad14
commit c9e6ac0f29
41 changed files with 1196 additions and 9 deletions

View 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;