Merge branch 'polish/card-gaps' into redesign/overnight

This commit is contained in:
2026-10-12 19:22:21 +03:00
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;