feat: cursor-steered headlights light the hero name, plates and world
This commit is contained in:
174
src/journey/headlight-beam.ts
Normal file
174
src/journey/headlight-beam.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user