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

@@ -162,3 +162,17 @@ describe('the 03:12 window on the server', () => {
expect(subline?.textContent).toBe('');
});
});
describe('HIGH BEAM on the server', () => {
it('ships no beam state: the name renders fully lit and its text is unchanged', async () => {
const html = await render('/', validInputs);
expect(html).not.toContain('data-beam');
expect(html).not.toContain('--beam-');
const document = await renderedDocument();
expect(document.documentElement.hasAttribute('data-beam')).toBe(false);
expect(document.querySelector('#start h1')?.textContent).toBe('Luca Pîslaru');
const name = document.querySelector('.hero-name__text');
expect(name?.getAttribute('data-name')).toBe('Luca Pîslaru');
expect(name?.textContent).toBe('Luca Pîslaru');
});
});

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;

View File

@@ -0,0 +1,138 @@
import { describe, expect, it } from 'vitest';
import * as THREE from 'three';
import { SceneResourceBag } from '@/scene/resources';
import { renderQualityFor } from '@/scene/render-quality';
import { createRoad } from '@/scene/world/road';
import {
beamLevels,
beamUniforms,
createHeadlights,
roadOpenness,
} from '@/scene/world/headlights';
import { REST_INTENSITY } from '@/journey/headlight-beam';
const activation = (active: number, next: number | null) => ({
layoutRevision: 1,
activeHeadingOffsetVh: active,
nextHeadingOffsetVh: next,
});
const car = () => {
const group = new THREE.Group();
group.position.set(0, 0.4, 0.72);
return group;
};
const camera = () => {
const lens = new THREE.PerspectiveCamera(40, 1.6, 0.1, 1000);
lens.position.set(4, 4.5, 15);
return lens;
};
/* three ships untyped here (see src/scene/three.d.ts). */
interface Spot {
castShadow: boolean;
angle: number;
distance: number;
intensity: number;
color: { getHex(): number };
position: { x: number; z: number };
target: { position: { x: number; z: number } };
}
interface Uniforms { material: { uniforms: Record<string, { value: number }> } }
const isSpot = (light: unknown): light is Spot => light instanceof THREE.SpotLight;
const open = { activeSectionId: 'start' as const, sectionActivation: activation(0, 100) };
describe('high beam: scene', () => {
it('opens the road at the hero and between plates, closes it under a plate', () => {
expect(roadOpenness(open)).toBe(1);
// A chapter heading just reached the top: its plate is on screen.
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(-40, 60) })).toBeLessThan(0.05);
// Just past the heading, and again in the gap before the next one.
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(0, 100) })).toBe(1);
expect(roadOpenness({ activeSectionId: 'experience', sectionActivation: activation(-95, 5) })).toBeGreaterThan(0.95);
// The destination has no next heading: the plate holds the screen.
expect(roadOpenness({ activeSectionId: 'contact', sectionActivation: activation(-20, null) })).toBe(0);
});
it('ramps presence with the sweep and lifts only above dipped beam', () => {
expect(beamLevels(undefined)).toEqual({ presence: 0, lift: 0 });
expect(beamLevels({ x: 0, y: 0, intensity: REST_INTENSITY / 2 }).presence).toBeCloseTo(0.5);
expect(beamLevels({ x: 0, y: 0, intensity: REST_INTENSITY })).toEqual({ presence: 1, lift: 0 });
expect(beamLevels({ x: 0, y: 0, intensity: 1 }).lift).toBeCloseTo(1);
});
it('high adds one shadowless warm spotlight and a haze cone; medium the spot only; low neither', () => {
for (const tier of ['high', 'medium', 'low'] as const) {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, tier, parent);
const spots = headlights.lights.filter(isSpot);
expect(spots).toHaveLength(tier === 'low' ? 0 : 1);
for (const spot of spots) {
expect(spot.castShadow).toBe(false);
expect(spot.angle).toBeCloseTo(0.42);
expect(spot.distance).toBe(70);
expect(spot.color.getHex()).toBe(0xffd6a8);
}
expect(parent.getObjectByName('high-beam-cone') !== undefined).toBe(tier === 'high');
resources.dispose();
}
});
it('aims the spot and the shader beam with the pointer, dark until the sweep', () => {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, 'high', parent);
const spot = headlights.lights.find(isSpot)!;
const lens = camera();
headlights.update(undefined, open, lens, 1, 0.016);
expect(spot.intensity).toBe(0);
expect(beamUniforms.uBeamGain.value).toBe(0);
const right = headlights.update({ x: 1, y: 0, intensity: REST_INTENSITY }, open, lens, 1, 0.016);
expect(right.yaw).toBeCloseTo(1);
expect(right.lift).toBe(0);
expect(spot.intensity).toBeGreaterThan(0);
expect(spot.target.position.x).toBeGreaterThan(spot.position.x);
expect(spot.target.position.z).toBeLessThan(spot.position.z);
expect(beamUniforms.uBeamDir.value.x).toBeGreaterThan(0);
const left = headlights.update({ x: -1, y: 0, intensity: 1 }, open, lens, 1, 0.016);
expect(left.yaw).toBeCloseTo(-1);
expect(left.lift).toBeCloseTo(1);
expect(spot.target.position.x).toBeLessThan(spot.position.x);
// Parked at the destination: the lamps are off.
headlights.update({ x: 0, y: 0, intensity: 1 }, open, lens, 0, 0.016);
expect(spot.intensity).toBe(0);
resources.dispose();
});
it('fades the haze cone out under a reading plate', () => {
const resources = new SceneResourceBag();
const parent = car();
const headlights = createHeadlights(resources, 'high', parent);
const cone = parent.getObjectByName('high-beam-cone');
const lens = camera();
const beam = { x: 0.3, y: 0, intensity: REST_INTENSITY };
headlights.update(beam, open, lens, 1, 0.016);
expect(cone.visible).toBe(true);
const plate = { activeSectionId: 'experience' as const, sectionActivation: activation(-40, 60) };
for (let frame = 0; frame < 120; frame++) headlights.update(beam, plate, lens, 1, 0.016);
expect(cone.visible).toBe(false);
resources.dispose();
});
it('the road takes the yaw and the lift as clamped uniforms', () => {
const resources = new SceneResourceBag();
const road = createRoad(resources, renderQualityFor('high', 1, false));
road.setBeam(4, -2);
const surface = road.group.getObjectByName('road-surface') as Uniforms;
expect(surface.material.uniforms.uBeamYaw?.value).toBe(1);
expect(surface.material.uniforms.uBeamLift?.value).toBe(0);
resources.dispose();
});
});