polish: time the scene payoffs to the gaps between cards

One shared window (journey/card-gaps.ts, CARD_GAP) describes the open road
after each chapter's panel in section-local progress; the district relight
and cutover, the last yard lift and the relay station's beams now key off
it instead of their own numbers, and the chapter margin widens from 44vh
to 48vh so the gap reads as a stretch of road. Unit tests prove every
payoff's peak sits inside the window for every chapter span.
This commit is contained in:
2026-10-12 19:20:43 +03:00
parent c6ce7c24f5
commit 75108562a4
15 changed files with 193 additions and 28 deletions

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { SceneResourceBag } from '@/scene/resources';
import { CARD_GAP } from '@/journey/card-gaps';
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
/**
@@ -7,7 +8,8 @@ import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-pro
* window opens over the city: one district powers down lamp by lamp, amber
* backup packets stream to an offsite silhouette on the far right, rain
* starts on the windshield, then the service re-lights in the infrastructure
* accent. Everything is a pure function of section-local scroll progress, so
* accent at cutover, in the open road after the card (CARD_GAP), where the
* skyline is in frame. Everything is a pure function of section-local scroll progress, so
* scrolling back up replays it in reverse. Only the distant lightning keeps
* its own (bounded) clock.
*
@@ -48,9 +50,9 @@ export function maintenanceTimeline(p: number): MaintenanceFrame {
return {
step: maintenanceStep(x),
dim: smooth(0.1, 0.35, x),
relight: smooth(0.35, 0.65, x),
relight: smooth(CARD_GAP.from, CARD_GAP.peak, x),
rain: MAINTENANCE_RAIN_PEAK * smooth(0.1, 0.3, x) * (1 - smooth(0.7, 0.9, x)),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - 0.67) / 0.21),
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - (CARD_GAP.from - 0.21)) / 0.21),
offsite: smooth(0.14, 0.36, x),
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, x)),
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),

View File

@@ -1,7 +1,8 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit } from '@/journey/relay-progress';
import { CARD_GAP } from '@/journey/card-gaps';
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit, stationLive } from '@/journey/relay-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { CHAPTER_COMPOSITIONS, RELAY_NUDGE } from './composition';
@@ -18,8 +19,9 @@ type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
* RELAY STATION, scene side. Four lattice transmitter masts, one per toolkit
* group, stand on the far shoulders, alternating sides (first on the right).
* As the chapter scrolls each comes online in turn: its lamp rungs ignite
* bottom-up (one per technology), a red beacon blinks at 0.5 Hz and a thin
* additive beam sweeps the sky every 6 s. Every beat is a pure function of
* bottom-up (one per technology) and a red beacon blinks at 0.5 Hz. The
* station goes live in the open road after the card (CARD_GAP): every
* online mast's thin additive beam sweeps the sky every 6 s. Every beat is a pure function of
* section-local progress, so scrolling back takes the masts offline again.
* One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a
* technology flashes its rung. Lite (low tier, touch, data saver): all rungs
@@ -133,7 +135,7 @@ const smooth = (from: number, to: number, value: number): number => {
const halfWidthAt = (y: number): number => THREE.MathUtils.lerp(BASE_HALF, TOP_HALF, y / MAST_HEIGHT);
/** The chapter fades the station in and out at its edges. */
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(CARD_GAP.to, 1, p));
export interface RelayRung {
/** World height of the rung's centre. */
@@ -453,6 +455,7 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
const dt = Math.max(0, deltaSeconds);
const live = section === 'toolkit' ? p : null;
const masts = relayLayout(live, readRelay().counts, full, aspect);
const beams = stationLive(live);
const changed = section !== lastSection;
lastSection = section;
@@ -498,9 +501,9 @@ export function createRelayMasts(resources: SceneResourceBag, full: boolean, ton
if (beamMesh !== null && beamState !== null) {
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
rotation.setFromAxisAngle(forward, sweep);
const shown = drawn && mast.level > 0 ? mast.scale : 0;
const shown = drawn && mast.level > 0 && beams > 0 ? mast.scale : 0;
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
beamState.setXYZ(m, mast.level, 0, mast.presence);
beamState.setXYZ(m, mast.level * beams, 0, mast.presence);
rotation.identity();
}

View File

@@ -1,5 +1,6 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { CARD_GAP } from '@/journey/card-gaps';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import {
CRANE_FROM_Z,
@@ -434,7 +435,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
const t = Math.min(1, Math.max(0, (v - from) / (to - from)));
return t * t * (3 - 2 * t);
};
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(0.95, 1, live));
const presence = live === null ? 0 : edge(0, 0.05, live) * (1 - edge(CARD_GAP.to, 1, live));
const z = craneZ(live ?? 0);
const worldZ = group.position.z + z * Math.cos(yaw);
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
@@ -461,7 +462,7 @@ export function createYard(resources: SceneResourceBag, full: boolean): YardWorl
});
stackState.needsUpdate = true;
warm = floodWarmup(warm, full && live !== null && live < 0.93, deltaSeconds);
warm = floodWarmup(warm, full && live !== null && live < CARD_GAP.to, deltaSeconds);
const light = floodLight(warm);
flood.copy(ORANGE).lerp(HID, light.hue).multiplyScalar(full ? light.output * 0.5 : 0);
pulse.value = full ? Math.min(1, Math.max(0, beat)) : 0;