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,174 @@
/**
* HIGH BEAM: the cursor is the car's headlights. One pure, clock-driven beam
* shared by the DOM (the hero name, plate edges, sparks) and the lazy scene
* chunk (road cones, spotlight). Coordinates are normalised to the viewport:
* x -1 (left) .. +1 (right), y -1 (top) .. +1 (bottom); intensity 0..1.
*
* Photosensitivity (round 5): a flash is a gentle lift to high beam, never a
* strobe. At most one flash per FLASH_MIN_INTERVAL_MS (<= 1.67 Hz, under the
* 3 Hz ceiling), with eased rise and fall.
*/
export interface BeamSample {
readonly x: number;
readonly y: number;
readonly intensity: number;
}
/** Pointer follow, critically damped; the "smooth time" to reach the pointer. */
export const FOLLOW_TAU_MS = 180;
/** Without pointer input for this long, the lamps drift back to dead ahead. */
export const IDLE_AFTER_MS = 4_000;
export const IDLE_TAU_MS = 900;
export const SWEEP_MS = 1_600;
export const SWEEP_FROM = -1;
export const SWEEP_TO = 0.25;
export const FLASH_HOLD_MS = 420;
export const FLASH_MIN_INTERVAL_MS = 600;
/** Dipped beam: the resting intensity after the sweep. Flash lifts it to 1. */
export const REST_INTENSITY = 0.72;
export const INTENSITY_RISE_TAU_MS = 70;
export const INTENSITY_FALL_TAU_MS = 260;
/** Touch, coarse pointers and the low tier: a slow automatic sway instead. */
export const SWAY_HZ = 0.08;
export const SWAY_AMPLITUDE = 0.3;
const MAX_STEP_MS = 100;
export interface HeadlightBeam {
/** Pointer position (normalised). Touch input is ignored: a tap is a flash. */
input(x: number, y: number, pointerType: string, now: number): void;
sample(now: number): BeamSample;
/** Plays the intro arc once. Returns false when it already played. */
sweep(now: number, y?: number): boolean;
/** Lifts to high beam; false when rate-limited. */
flash(now: number): boolean;
setAutoSway(enabled: boolean): void;
/** True once the sweep has finished (or never needs to play again). */
swept(now: number): boolean;
/** At rest: nothing will change until input, a flash, or {@link idleAt}. */
settled(now: number): boolean;
/** When the idle return starts, or null when it is not pending. */
idleAt(): number | null;
}
const clamp = (value: number, min: number, max: number): number =>
Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : 0;
const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3;
export function createHeadlightBeam(): HeadlightBeam {
let x = 0, y = 0, vx = 0, vy = 0, intensity = 0;
let targetX = 0, targetY = 0;
let lastInputAt: number | null = null;
let last: number | null = null;
let sweepStart: number | null = null;
let sweepY = 0;
let sweepDone = false;
let flashUntil = -Infinity;
let lastFlashAt = -Infinity;
let sway = false;
let swayOrigin = 0;
let cached: BeamSample = { x, y, intensity };
let dirty = false;
const sweeping = (now: number): boolean =>
sweepStart !== null && !sweepDone && now <= sweepStart + SWEEP_MS;
const finishSweep = (): void => {
if (sweepDone || sweepStart === null) return;
sweepDone = true;
x = SWEEP_TO; y = sweepY; vx = 0; vy = 0;
const end = sweepStart + SWEEP_MS;
// The arc's end counts as the last input: the lamps hold there, then the
// pointer (or the idle drift) takes over. A pointer that moved during
// the arc keeps its target.
if (lastInputAt === null || lastInputAt < sweepStart) {
targetX = SWEEP_TO; targetY = sweepY;
}
lastInputAt = Math.max(lastInputAt ?? end, end);
swayOrigin = end;
};
const intensityTarget = (now: number): number =>
now < flashUntil ? 1 : sweepDone ? REST_INTENSITY : 0;
/** Exact critically damped step (smooth time tau: omega = 2 / tau). */
const damp = (position: number, velocity: number, target: number, dt: number, tau: number): [number, number] => {
const omega = 2 / tau;
const offset = position - target;
const decay = Math.exp(-omega * dt);
const temp = (velocity + omega * offset) * dt;
return [target + (offset + temp) * decay, (velocity - omega * temp) * decay];
};
return {
input(nx, ny, pointerType, now) {
if (pointerType === 'touch') return;
targetX = clamp(nx, -1, 1);
targetY = clamp(ny, -1, 1);
lastInputAt = now;
},
sample(now) {
if (last !== null && now <= last && !dirty) return cached;
dirty = false;
const dt = last === null ? 0 : Math.min(MAX_STEP_MS, Math.max(0, now - last));
last = now;
if (sweepStart !== null && !sweepDone) {
if (sweeping(now)) {
const t = easeOutCubic(clamp((now - sweepStart) / SWEEP_MS, 0, 1));
x = SWEEP_FROM + (SWEEP_TO - SWEEP_FROM) * t;
y = sweepY;
intensity = t;
return (cached = { x, y, intensity });
}
finishSweep();
intensity = 1;
}
if (dt > 0) {
let tx = targetX, ty = targetY, tau = FOLLOW_TAU_MS;
if (sway) {
tx = SWAY_AMPLITUDE * Math.sin(2 * Math.PI * SWAY_HZ * ((now - swayOrigin) / 1000));
ty = 0;
tau = IDLE_TAU_MS;
} else if (lastInputAt === null || now - lastInputAt >= IDLE_AFTER_MS) {
tx = 0; ty = 0; tau = IDLE_TAU_MS;
}
[x, vx] = damp(x, vx, tx, dt, tau);
[y, vy] = damp(y, vy, ty, dt, tau);
const goal = intensityTarget(now);
const itau = goal > intensity ? INTENSITY_RISE_TAU_MS : INTENSITY_FALL_TAU_MS;
intensity = goal + (intensity - goal) * Math.exp(-dt / itau);
}
return (cached = { x, y, intensity });
},
sweep(now, sy = 0) {
if (sweepStart !== null) return false;
sweepStart = now;
sweepY = clamp(sy, -1, 1);
dirty = true;
return true;
},
flash(now) {
if (now - lastFlashAt < FLASH_MIN_INTERVAL_MS) return false;
lastFlashAt = now;
flashUntil = now + FLASH_HOLD_MS;
return true;
},
setAutoSway(enabled) {
if (enabled && !sway) swayOrigin = last ?? 0;
sway = enabled;
},
swept: (now) => sweepDone || (sweepStart !== null && now >= sweepStart + SWEEP_MS),
settled(now) {
if (sway || (sweepStart !== null && !sweepDone) || now < flashUntil) return false;
const idle = lastInputAt === null || now - lastInputAt >= IDLE_AFTER_MS;
const tx = idle ? 0 : targetX;
const ty = idle ? 0 : targetY;
return Math.abs(x - tx) < 5e-4 && Math.abs(y - ty) < 5e-4
&& Math.abs(vx) < 1e-6 && Math.abs(vy) < 1e-6
&& Math.abs(intensity - intensityTarget(now)) < 5e-4;
},
idleAt() {
if (lastInputAt === null || (targetX === 0 && targetY === 0)) return null;
return lastInputAt + IDLE_AFTER_MS;
},
};
}

View File

@@ -1,5 +1,6 @@
import type { SectionId } from "@/content/types";
import type { SignViewportProfile } from '@/journey/sign-routes';
import type { BeamSample } from '@/journey/headlight-beam';
export interface ScrollDriveSample {
scrollY: number;
@@ -78,4 +79,7 @@ export interface JourneySnapshot extends ScrollDriveSnapshot {
sign: SignSnapshot | null;
arrived: boolean;
sectionActivation: SectionActivationSnapshot;
/** HIGH BEAM: where the headlights point (normalised viewport), sampled per
* scene frame. Absent when no beam is driving the scene. */
beam?: BeamSample;
}