feat: cursor-steered headlights light the hero name, plates and world
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
138
tests/unit/headlights.test.ts
Normal file
138
tests/unit/headlights.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user