feat: night freight container yard works through the additional work chapter

This commit is contained in:
2026-10-11 11:52:27 +03:00
parent 4e097e5ef9
commit ca12cfda44
13 changed files with 831 additions and 0 deletions

View File

@@ -87,3 +87,11 @@ Run: `npm run build:preview` then serve `dist` (dev server shows a blank page).
- **Verdict: SHIP with fixes** (Fable, hardware GL 1440x900, fresh contexts; title and link[rel=icon] observed with MutationObservers over a full drive, park/return at Contact and Infrastructure, reverse scroll, dashcam open/close, brisk 40% scroll, reduced motion, nav jumps, #contact deep link, return visit): the title steps per chapter exactly as the HUD does (5% → 90%, ARRIVED at Contact), the metadata title holds until html[data-ignition] is removed (+4.5 s on first visit, ~3.9 s on the short form), the deep link holds the metadata title until the scene is running then writes ARRIVED · CONTACT; the dashcam owns the title ("DASHCAM · 27% · INFRASTRUCTURE · 03:12") and the route title is back within the same tick the frame unmounts (no stale title after close, scroll after close steps normally); max 3 title/icon writes in any 1 s window on a brisk scroll (2.5 Hz cap holds); rAF p50 16.7 / p95 16.7-16.8 ms with 0 frames over 34 ms during the drive and the brisk scroll; no console errors or warnings, no hydration warnings. Fixes made during review: (1) **parked title read 0%**: useScrollDrive resets its snapshot to route 0 on visibilitychange (before this composable hears it) and keeps 0 for one frame after return, so the hidden tab said "PARKED · 0% · INFRASTRUCTURE" and briefly "ROUTE 0%" on return; the composable now freezes the route while hidden and re-syncs two frames after return (unit test added, two tests that had encoded the old behaviour corrected); (2) **beams invisible at tab size** in the first chapters: at 16 px the 0-20% beams were 2-4 px dots at 35-45% opacity; floor raised (beamMin 2 → 4 px, glowMin 0.35 → 0.55; max unchanged at 14 px / 0.95) so the icon reads as "lamps on" from the first write and still grows x3.5 to Contact. Judged at 16/24/32/128 px in light and dark tab strips: the 'L' reads at every size and state (dark plate on a light strip is a strong silhouette), the parked amber dots are unmistakable, the day plate is the one state that reads as a different icon at a glance (intended), chapter accents are distinct at 32 px and still tellable at 16 px for cyan vs violet vs coral (pink vs coral is a near miss at 16 px).
- **Known weaknesses:** at 16 px the accent is a tint on a 4-14 px smear, so the chapter colour is a subliminal cue rather than a readable signal; the dashcam title shows the raw percentage (27%) while the tab shows the quantised one (25%), two vocabularies for two seconds; pink is shared by Start/Profile/Additional work (SIGN_ACCENTS), so those chapters are told apart only by the beam length; headless Chromium cannot hide a tab, so the parked states were driven by dispatching visibilitychange with document.visibilityState overridden (the unit test does the same); under that emulation the scene loop paused and did not resume (html[data-dawn] stayed 'day' through the reverse drive in the timeline sheet), which a run without the emulation does not show (dawn rewinds to night at Infrastructure in ~1.6 s) — a real tab switch should be checked once by hand; under reduced motion SceneCanvas rewrites html[data-dawn] every frame (pre-existing), which fires this composable's MutationObserver at 60 Hz for a cheap no-op; return visits open at route 0 with the short ignition (route memory does not restore the scroll position, pre-existing).
- Screenshots: docs/overnight/round10/ (00-05 favicon contact sheets from the build; 06 favicon states at 128/32/24/16 px in light and dark tab strips; 07 observed title + favicon timeline over the 35 s review drive, every title write with the icon it shipped with; 09 ignition with the metadata title and the index.html icon held; 11 tab hidden at Infrastructure, "PARKED · 25%" with amber lights; 12 dashcam owning the title; 13 END OF SHIFT, gold on peach).
## Round 11: NIGHT FREIGHT (Additional work container yard)
- **What:** the Additional work chapter is a working container yard on the free left verge. A rail-mounted gantry crane (12 m portal, 29 m high) rides the chapter and stops once per additional project (section-local 0.1/0.33/0.56/0.79, 0.06 hold): the spreader lowers onto the stack, the top container's end plate lights in the chapter accent (#ff83c2, breathing with the bass) and the two work lamps strike (>= 0.35 s ramp, at most one strike per 0.6 s); two 36 m high-mast floods warm from sodium orange to HID blue-white over 1.5 s at chapter entry and cool on the way out, washing the stacks and two pools on the apron; the crane screen-doors out while an underpass deck is within 16-34 m of it. Everything is a pure function of section-local progress (journey/yard-progress.ts), so scrolling back lifts the spreader, dims the plate and runs the crane back. Lite (low tier, touch): crane, stacks and plates only. Reduced motion / static: nothing.
- **Why:** the chapter had no world beat and its left margin was empty; the four additional projects are literally four lifts on the night shift.
- **Build:** journey/yard-progress.ts (pure), scene/world/yard.ts (lazy scene chunk), environment.ts (create, update with the deck z and the viewport aspect). 909 tests (79 files, +15), initial JS 119.08 KB gz (+0.01 KB hash noise, no yard code in the initial chunk), CSS unchanged, scene chunk 221.38 KB gz.
- **Verdict: SHIP with fixes** (Fable, hardware GL 1440x900, wheel-driven drive, frames at eleven section-local points, reverse drive, 1920x1080, 1024x700, 390x844 low tier, reduced motion, perf run): the builder's yard sat on a fixed x -28 parallel to the road, so it converged into the vanishing point under the reading panel and was mostly visible only at the chapter entry (steel also darkened late, timing unwatched). Fixes made during review: (1) **the rails are now a radial siding**: the yard group pivots on the chapter lens and its -z axis is turned by `railYaw(aspect)` so the rail line projects to NDC -0.86 (the middle of the 15% left margin) once the chapter's lens shift is applied, for any viewport aspect; every stop, plate and the crane therefore sit at the same screen x inside the margin at 1440x900 and 1920x1080 and the crane rides *up* the margin, growing, from the far stop to the near one; (2) **the stacks step down toward the camera** (4, 3, 2, 1 containers, far to near) so no stack hides the plate or the crane behind it, and the spreader lands on whichever stack it is over; (3) a raised concrete apron (quay at y 5, 12 m deep, accent bay lines) under the yard so the rolling terrain swell no longer buries the far stacks; (4) span 18 -> 12 m so the near crane fits the 216 px margin with the lens settled; (5) floods x 0.42 -> 0.5 and 9 -> 13 m lamp glows, the road pool dropped (two apron pools remain). Observed: the plates light exactly at the stops forward and back, pink on mauve-grey steel with the night palette (nothing blown: structure stays under the 0.85 bloom threshold, only the lamp glows bloom, softly); the entry is under the EXIT 06 deck with the crane screen-doored and the yard already standing (01); the crane fades before the EXIT 07 deck and never crosses a deck, sign, billboard, relay mast or milestone (the yard is 40-95 m off the road); reverse drive runs the crane back and dims plates in the same places; rAF p50 16.7 / p95 16.8 ms, 0 frames over 34 ms through the whole chapter; no console errors or warnings in any run; reduced motion renders the static poster with nothing; 390x844 (low tier) gets the lite yard, which the full-width panel hides except for pink slivers at the left edge.
- **Known weaknesses:** the lens eases into the chapter over ~1 s (camera time constant 1.05 s), so for the first second of the chapter the whole yard slides left into the margin with everything else in the world (the first stop at 0.1 can be reached before it settles on a fast scroll, when the crane's right leg sits under the panel edge); at 1024x700 the compact-rail panel is 90% wide with a 5% margin (51 px), so the yard is legible only at the chapter entry and in the gaps between cards (05), not in the margin; the 4-tier far stack is seen at a 30 degree yaw and reads as leaning at a glance; the floods light the stacks as a flat uniform term (no shadows); both work lamps share one strike; the crane's exit run (-80 -> -62 m) is short, so the last 20% of the chapter is a crane standing over the near stack while the yard fades; the yard's screen placement is tuned to the chapter composition constants, so a composition change needs RAIL_NDC rechecked.
- Screenshots: docs/overnight/round11/ (01 entry under the EXIT 06 deck, crane screen-doored; 02 second stop with the panel up, pink plate in the margin; 03 last stop, the near one-container stack with the work lamps; 04 1920x1080 third stop; 05 1024x700 chapter entry; 06 390x844 low tier; 07 contact sheet of the left margin across the drive, entry to Toolkit).

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

View File

@@ -0,0 +1,124 @@
/**
* NIGHT FREIGHT: the Additional work chapter is a working container yard. A
* rail-mounted gantry crane rides the left verge through the chapter and
* stops once per additional project: the spreader lowers onto a stack, the
* stack-top container's end plate lights and the crane's work lamps strike.
* Pure helpers for the lazy scene chunk only (src/scene/world/yard.ts):
* nothing in the initial bundle imports this module. Every placement is a
* function of section-local progress, so scrolling back lifts the spreader,
* dims the plate and runs the crane back.
*/
/** Section-local progress of each stop (one per additional project). */
export const YARD_STOPS = [0.1, 0.33, 0.56, 0.79] as const;
/** The crane stands still this far either side of a stop while the spreader works. */
export const YARD_HOLD = 0.06;
/**
* Rail coordinates: the crane rails run in a straight line from the camera's
* pivot into the distance (yard.ts turns the line by the viewport aspect so it
* stays inside the left reading margin), so a rail coordinate s is the metres
* from the camera along that line, negative ahead; world z ~ cameraZ + s * cos(angle).
* Where the crane enters and leaves its rails.
*/
export const CRANE_FROM_Z = -150;
export const CRANE_TO_Z = -62;
/** The stacks along the rails, one under each stop, far to near. */
export const STACK_ZS = [-134, -112, -94, -80] as const;
/** Containers per stack, far to near: the yard steps down toward the camera so every stack top and the crane stay in view. */
export const STACK_TIERS = [4, 3, 2, 1] as const;
/** Work lamps: no strike ramps faster than this, and no more than one strike per gap. */
export const LAMP_RAMP_SECONDS = 0.35;
export const LAMP_STRIKE_GAP_SECONDS = 0.6;
/** High-mast floods: HID warm-up from orange to blue-white, and back on exit. */
export const FLOOD_WARMUP_SECONDS = 1.5;
const clamp01 = (value: number): number => (Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0);
const smooth = (from: number, to: number, value: number): number => {
const t = clamp01((value - from) / (to - from));
return t * t * (3 - 2 * t);
};
/**
* Crane z for progress p: parked over stack i within YARD_HOLD of stop i,
* easing between stacks, from CRANE_FROM_Z at 0 to CRANE_TO_Z at 1.
* Non-decreasing in p.
*/
export function craneZ(p: number): number {
const t = clamp01(p);
const knots: Array<[number, number, number]> = [];
let fromP = 0;
let fromZ = CRANE_FROM_Z;
YARD_STOPS.forEach((stop, i) => {
knots.push([fromP, stop - YARD_HOLD, fromZ]);
fromP = stop + YARD_HOLD;
fromZ = STACK_ZS[i]!;
});
knots.push([fromP, 1, fromZ]);
let z = CRANE_FROM_Z;
knots.forEach(([a, b, startZ], i) => {
const endZ = i < STACK_ZS.length ? STACK_ZS[i]! : CRANE_TO_Z;
if (t >= a) z = startZ + (endZ - startZ) * smooth(a, b, t);
});
return z;
}
/** 0..1: how far the spreader is lowered (1 on the stack at a stop). */
export function spreaderDrop(p: number | null): number {
if (p === null) return 0;
return Math.max(0, ...YARD_STOPS.map((stop) => 1 - smooth(0.015, YARD_HOLD, Math.abs(p - stop))));
}
/** 0..1: stack i's end plate, lit as the spreader lands and from then on. */
export function plateLit(p: number | null, index: number): number {
const stop = YARD_STOPS[index];
if (p === null || stop === undefined) return 0;
return smooth(stop - 0.03, stop, p);
}
/** Stops passed at progress p (plates fully lit). */
export const yardStopsPassed = (p: number | null): number =>
p === null ? 0 : YARD_STOPS.filter((stop) => p >= stop).length;
/** Moves the flood warm-up level (0 off, 1 full blue-white) toward on/off over FLOOD_WARMUP_SECONDS. */
export function floodWarmup(level: number, on: boolean, deltaSeconds: number): number {
const step = Math.max(0, deltaSeconds) / FLOOD_WARMUP_SECONDS;
return clamp01(on ? level + step : level - step);
}
/** A warming HID lamp: output and hue (0 sodium orange, 1 blue-white) for a warm-up level. */
export function floodLight(level: number): { output: number; hue: number } {
const t = clamp01(level);
return { output: smooth(0, 0.12, t) * (0.3 + 0.7 * t), hue: smooth(0.15, 1, t) };
}
export interface LampState {
level: number;
/** Seconds since the last strike began. */
since: number;
strikes: number;
}
export const createLamp = (): LampState => ({ level: 0, since: Number.POSITIVE_INFINITY, strikes: 0 });
/**
* A work lamp following target (0..1). A strike (a dark lamp coming on) ramps
* over at least LAMP_RAMP_SECONDS and begins at most once per
* LAMP_STRIKE_GAP_SECONDS, however fast the scroll: inside the gap a dark
* lamp stays dark. Mutates and returns state.
*/
export function stepLamp(state: LampState, target: number, deltaSeconds: number): LampState {
const dt = Math.max(0, deltaSeconds);
const goal = clamp01(target);
state.since += dt;
if (goal > state.level) {
if (state.level < 0.02) {
if (state.since < LAMP_STRIKE_GAP_SECONDS) return state;
state.since = 0;
state.strikes += 1;
}
state.level = Math.min(goal, state.level + dt / LAMP_RAMP_SECONDS);
} else {
state.level = Math.max(goal, state.level - dt / LAMP_RAMP_SECONDS);
}
return state;
}

View File

@@ -41,6 +41,7 @@ import { createMilestones } from '@/scene/world/milestones';
import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard';
import { BILLBOARD_NUDGE, RELAY_NUDGE } from '@/scene/world/composition';
import { createRelayMasts } from '@/scene/world/relay-masts';
import { createYard } from '@/scene/world/yard';
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
import { dawnStep, type DawnStep } from '@/journey/dawn';
import { dawnLightUniforms } from '@/scene/world/dawn-light';
@@ -207,12 +208,16 @@ export async function createProceduralSceneGeneration(
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
fogColor: scene.fog.color,
});
// NIGHT FREIGHT: a gantry crane works the Additional work container yard.
// Low tier and touch: crane, stacks and plates; no floods, lamps or pulse.
const yard = createYard(context.resources, maintenanceTier === 'full');
let underpassShade = 0;
let underpassKey = '';
let underpassAttr = false;
scene.add(
underpass.group,
relay.group,
yard.group,
billboard.mesh,
maintenance.group,
milestones.mesh,
@@ -427,6 +432,16 @@ export async function createProceduralSceneGeneration(
musicPulse,
deltaSeconds,
);
// The deck from the previous frame (underpass updates after the sign).
const yardDeck = underpass.inspect();
yard.update(
snapshot.activeSectionId,
sectionLocalProgress(snapshot, 'additional-work'),
musicPulse,
deltaSeconds,
yardDeck.visible ? yardDeck.z : null,
camera.aspect,
);
const relayNudge = relay.update(
snapshot.activeSectionId,
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,

452
src/scene/world/yard.ts Normal file
View File

@@ -0,0 +1,452 @@
import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import { SIGN_ACCENTS } from '@/journey/sign-routes';
import {
CRANE_FROM_Z,
CRANE_TO_Z,
craneZ,
createLamp,
floodLight,
floodWarmup,
plateLit,
spreaderDrop,
stepLamp,
STACK_TIERS,
STACK_ZS,
} from '@/journey/yard-progress';
import type { SceneResourceBag } from '@/scene/resources';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
type ThreeGroup = InstanceType<typeof THREE.Group>;
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
/**
* NIGHT FREIGHT, scene side. A container yard on the free left verge of the
* Additional work chapter (the reading panel covers 15-95% of the width): four
* stacks (four, three, two and one container, far to near) on a raised apron
* along a pair of crane rails, two high-mast floods, and a rail-mounted gantry
* crane that rides the chapter. The rails are a straight siding that runs from
* the camera's pivot into the distance at the angle that keeps the whole yard
* at the same screen x for the viewport aspect (RAIL_NDC: the middle of the
* left margin), so every stop, plate and the crane stay inside the margin at
* any aspect, and the stacks step up with distance so none hides the next. Placements are pure
* functions of section-local progress (journey/yard-progress.ts). At each stop
* the spreader lowers onto the stack, the top container's end plate lights in
* the chapter accent and the two work lamps strike. The floods warm up from
* sodium orange to blue-white HID over 1.5 s at chapter entry and cool on the
* way out. Structure stays under the 0.85 bloom threshold (round 9); only the
* lamps bloom, modestly. The crane fades out (screen door) while an underpass
* deck is within reach, so it never crosses a deck or its piers.
* Lite (low tier, touch): crane, stacks and plates; no floods, pools, lamps or pulse.
*
* The world is drawn ~2.3x life size: a 20 ft box is 14 x 5.6 x 6 m here.
*/
const LENS = CHAPTER_COMPOSITIONS['additional-work'];
export const DESIGN_ASPECT = 1.6;
/** Screen x of the rail centre line (NDC): the middle of the 15% left margin, clear of its edge at -0.7. */
const RAIL_NDC = -0.86;
/** The yard floor (apron top): a quay above the verge, over most of the terrain swell. */
const YARD_Y = 5;
const BOX_L = 14;
const BOX_W = 5.6;
const BOX_H = 6;
/** Portal: legs at +- SPAN/2 around the rail line, girders at PORTAL_H, legs +- LEG_Z along the rails. */
const SPAN = 12;
const PORTAL_H = 29;
const LEG_Z = 5.5;
const SPREADER_UP = PORTAL_H - 3;
const FLOOD_X = -8.5;
const FLOOD_ZS = [-124, -88] as const;
const FLOOD_H = 36;
const APRON = { halfWidth: SPAN / 2 + 5, depth: 12, from: CRANE_FROM_Z - 10, to: CRANE_TO_Z + 12 } as const;
/**
* The rail line's yaw for a viewport aspect: the angle whose projection lands
* at RAIL_NDC once the chapter's lens shift (setViewOffset) is applied.
*/
export function railYaw(aspect: number = DESIGN_ASPECT): number {
const tanHalfHeight = Math.tan(THREE.MathUtils.degToRad(LENS.fov / 2));
const portrait = aspect < 1.18;
const raw = portrait ? -0.9 : RAIL_NDC - 2 * LENS.shift;
return Math.atan(-raw * tanHalfHeight * aspect);
}
/** The crane hides (screen door) while a deck is this close along the road (m). */
const DECK_CLEAR = { from: 16, to: 34 } as const;
const PULSE_GAIN = 0.08;
const ORANGE = new THREE.Color(1.0, 0.42, 0.12);
const HID = new THREE.Color(0.72, 0.84, 1.0);
const TINTS = [[0.42, 0.13, 0.08], [0.08, 0.26, 0.3], [0.3, 0.31, 0.34], [0.44, 0.3, 0.08]] as const;
type Box = readonly [w: number, h: number, d: number, x: number, y: number, z: number, part?: number];
/** Boxes merged into one indexed geometry, with a part id per vertex. */
function boxes(list: readonly Box[]): ThreeBufferGeometry {
const positions: number[] = [];
const normals: number[] = [];
const parts: number[] = [];
const indices: number[] = [];
for (const [w, h, d, x, y, z, part = 0] of list) {
const piece = new THREE.BoxGeometry(w, h, d).translate(x, y, z);
const offset = positions.length / 3;
const position = piece.getAttribute('position');
positions.push(...position.array);
normals.push(...piece.getAttribute('normal').array);
for (let i = 0; i < position.count; i += 1) parts.push(part);
indices.push(...Array.from(piece.getIndex()!.array as ArrayLike<number>, (i) => i + offset));
piece.dispose();
}
const merged = new THREE.BufferGeometry();
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
merged.setAttribute('aPart', new THREE.Float32BufferAttribute(parts, 1));
merged.setIndex(indices);
return merged;
}
const legs = (y: number, h: number, t: number): Box[] =>
[-1, 1].flatMap((sx) => [-1, 1].map((sz) => [t, h, t, (sx * SPAN) / 2, y, sz * LEG_Z] as Box));
/** Portal frame around the rail line (x 0): legs, sills, girders, end ties, cab. */
const portalBoxes = (): Box[] => [
...legs(PORTAL_H / 2, PORTAL_H, 1.3),
...[-1, 1].map((sx) => [1.6, 1.4, LEG_Z * 2 + 3, (sx * SPAN) / 2, 1.2, 0] as Box),
...[-1, 1].map((sx) => [1.3, 1.3, LEG_Z * 2, (sx * SPAN) / 2, PORTAL_H - 4, 0] as Box),
...[-1, 1].map((sz) => [SPAN + 3, 2, 1.5, 0, PORTAL_H, sz * LEG_Z] as Box),
...[-1, 1].map((sx) => [1.5, 2, LEG_Z * 2 + 1.5, (sx * SPAN) / 2, PORTAL_H, 0] as Box),
[3, 3, 3.4, -SPAN / 2 + 2.3, PORTAL_H - 5, LEG_Z + 1.2],
];
const SCREEN_DOOR = /* glsl */ `
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
if (presence < (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0) discard;
`;
/** Dark steel and containers, lit by a key, the floods (uFlood) and, for plates, the accent. */
function solid(uniforms: Record<string, { value: unknown }>, instanced: boolean) {
return new THREE.ShaderMaterial({
uniforms,
vertexShader: /* glsl */ `
attribute float aPart;
${instanced ? 'attribute vec2 aState;' : ''}
varying float vPart;
varying vec2 vState;
varying vec3 vNormal;
varying vec3 vWorld;
void main() {
mat4 model = modelMatrix${instanced ? ' * instanceMatrix' : ''};
vec4 world = model * vec4(position, 1.0);
vWorld = world.xyz;
vNormal = normalize(mat3(model) * normal);
vPart = aPart;
vState = ${instanced ? 'aState' : 'vec2(0.0)'};
gl_Position = projectionMatrix * viewMatrix * world;
}
`,
fragmentShader: /* glsl */ `
uniform float uPresence;
uniform vec3 uFlood;
uniform vec3 uAccent;
uniform float uPulse;
varying float vPart;
varying vec2 vState;
varying vec3 vNormal;
varying vec3 vWorld;
void main() {
float presence = uPresence;
${SCREEN_DOOR}
vec3 n = normalize(vNormal);
float key = max(0.0, dot(n, normalize(vec3(-0.45, 0.85, 0.35))));
float floodSide = max(0.0, dot(n, normalize(vec3(-0.6, 0.6, 0.5))));
vec3 light = vec3(0.1, 0.1, 0.13) + vec3(0.16, 0.15, 0.2) * key + uFlood * (0.25 + 0.55 * floodSide);
vec3 color;
${instanced ? /* glsl */ `
int tintIndex = int(vState.y + 0.5);
vec3 tint = tintIndex == 0 ? vec3(${TINTS[0].join(', ')}) : tintIndex == 1 ? vec3(${TINTS[1].join(', ')})
: tintIndex == 2 ? vec3(${TINTS[2].join(', ')}) : vec3(${TINTS[3].join(', ')});
float rib = 0.82 + 0.18 * step(0.5, fract((abs(n.x) > 0.5 ? vWorld.z : vWorld.x) * 0.9));
color = tint * light * rib * 1.6;
if (vPart > 0.5) {
// The end plate: a door at rest, the chapter accent once the spreader has set down (< 0.85).
color = mix(color * 0.7, uAccent * (0.2 + 0.5 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse)), vState.x);
}` : /* glsl */ `
color = vec3(0.16, 0.17, 0.2) * light * 1.5 + uAccent * 0.025;
if (vPart > 0.5) color = vec3(0.55, 0.42, 0.1) * light * 1.8;
if (vPart > 1.5) {
// The apron: poured concrete with a painted bay line every 14 m.
float bay = step(0.985, fract(vWorld.z * 0.0714)) * step(0.5, n.y);
color = vec3(0.09, 0.095, 0.12) * light * 1.4 + uAccent * (0.02 + 0.12 * bay);
}`}
gl_FragColor = vec4(min(color, vec3(0.82)), 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
fog: false,
});
}
/** Additive lights: camera-facing glows (billboard) or ground pools; aGlow = rgb, level. */
function additive(billboard: boolean) {
return new THREE.ShaderMaterial({
vertexShader: /* glsl */ `
attribute vec4 aGlow;
varying vec2 vUv;
varying vec4 vGlow;
void main() {
vUv = uv;
vGlow = aGlow;
${billboard ? /* glsl */ `
float size = length(instanceMatrix[0].xyz);
vec4 centre = viewMatrix * modelMatrix * instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
gl_Position = projectionMatrix * (centre + vec4(position.xy * size, 0.0, 0.0));` : `
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);`}
}
`,
fragmentShader: /* glsl */ `
varying vec2 vUv;
varying vec4 vGlow;
void main() {
float r = 1.0 - min(1.0, length(vUv - 0.5) * 2.0);
float shape = ${billboard ? 'pow(r, 3.0) * 1.3 + pow(r, 1.2) * 0.22' : 'pow(r, 1.8) * 0.7'};
gl_FragColor = vec4(vGlow.rgb * shape * vGlow.w, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false,
});
}
export interface YardInspection {
visible: boolean;
presence: number;
cranePresence: number;
/** Rail coordinate (metres from the lens along the rails, negative ahead) and its world z. */
craneZ: number;
craneWorldZ: number;
spreader: number;
/** Stack-top plates fully lit (= stops passed). */
litPlates: number;
lamp: number;
strikes: number;
flood: number;
}
export interface YardWorld {
readonly group: ThreeGroup;
/**
* Section-local progress of 'additional-work' (null outside), bass pulse,
* frame delta, the underpass deck z (null: none) and the viewport aspect.
*/
update(section: SectionId, p: number | null, pulse: number, deltaSeconds: number, deckZ?: number | null, aspect?: number): void;
inspect(): YardInspection;
}
export function createYard(resources: SceneResourceBag, full: boolean): YardWorld {
const own = <T extends { dispose(): void }>(kind: 'geometry' | 'material', value: T): T => resources.own(kind, value);
const accent = new THREE.Color(SIGN_ACCENTS['additional-work']);
const flood = new THREE.Color(0, 0, 0);
const pulse = { value: 0 };
const shared = { uFlood: { value: flood }, uAccent: { value: accent }, uPulse: pulse };
const yardPresence = { value: 0 };
const cranePresence = { value: 0 };
const group = new THREE.Group();
group.name = 'night-freight';
group.visible = false;
// The group's -z axis is the rail line, pivoting on the chapter lens.
group.position.set(LENS.x, 0, LENS.z);
let yaw = Number.NaN;
const place = (aspect: number) => {
const next = railYaw(aspect);
if (next === yaw) return;
yaw = next;
group.rotation.y = yaw;
};
place(DESIGN_ASPECT);
const yardMaterial = own('material', solid({ ...shared, uPresence: yardPresence }, false));
const craneMaterial = own('material', solid({ ...shared, uPresence: cranePresence }, false));
const mesh = (geometry: ThreeBufferGeometry, material = yardMaterial) => {
const m = new THREE.Mesh(own('geometry', geometry), material);
m.frustumCulled = false;
return m;
};
// Apron (part 2), rails and the two flood masts (heads are part 1: warm metal housings).
const apronLength = APRON.to - APRON.from;
group.add(mesh(boxes([
[APRON.halfWidth * 2, APRON.depth, apronLength, 0, YARD_Y - APRON.depth / 2, APRON.from + apronLength / 2, 2],
...[-1, 1].map((sx) => [0.9, 0.5, apronLength - 4, (sx * SPAN) / 2, YARD_Y + 0.25, APRON.from + apronLength / 2] as Box),
...FLOOD_ZS.flatMap((z): Box[] => [
[1.1, FLOOD_H, 1.1, FLOOD_X, YARD_Y + FLOOD_H / 2, z],
[6, 0.5, 0.5, FLOOD_X + 1, YARD_Y + FLOOD_H - 0.6, z],
[5.2, 2, 1.6, FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.6, z + 0.4, 1],
]),
])));
// The stacks: one InstancedMesh, the end plate (part 1) faces the camera.
const stackGeometry = own('geometry', boxes([
[BOX_W, BOX_H - 0.1, BOX_L, 0, BOX_H / 2, 0],
[BOX_W - 0.7, BOX_H - 0.8, 0.16, 0, BOX_H / 2, BOX_L / 2 + 0.06, 1],
]));
const topIndex: number[] = [];
let count = 0;
STACK_TIERS.forEach((tiers) => {
count += tiers;
topIndex.push(count - 1);
});
const stackState = new THREE.InstancedBufferAttribute(new Float32Array(count * 2), 2);
stackState.setUsage(THREE.DynamicDrawUsage);
stackGeometry.setAttribute('aState', stackState);
const stacks = new THREE.InstancedMesh(
stackGeometry,
own('material', solid({ ...shared, uPresence: yardPresence }, true)),
count,
);
stacks.name = 'yard-stacks';
stacks.frustumCulled = false;
const matrix = new THREE.Matrix4();
let i = 0;
STACK_ZS.forEach((z, s) => {
for (let tier = 0; tier < STACK_TIERS[s]!; tier += 1, i += 1) {
// A little stacking slop so the column reads as boxes, not a slab.
matrix.makeRotationY(((i * 7) % 5 - 2) * 0.012).setPosition(((i * 3) % 4 - 1.5) * 0.18, YARD_Y + tier * BOX_H, z);
stacks.setMatrixAt(i, matrix);
stackState.setXY(i, 0, (s + tier * 3) % 4);
}
});
group.add(stacks);
// The crane: portal, trolley, hoist ropes (unit height, hung from the top) and spreader.
const crane = new THREE.Group();
crane.position.y = YARD_Y;
const ropes = mesh(boxes([-1, 1].flatMap((sx) => [-1, 1].map((sz) => [0.18, 1, 0.18, sx * 2, -0.5, sz * 4.5] as Box))), craneMaterial);
ropes.position.y = PORTAL_H - 1;
const spreader = mesh(boxes([
[BOX_W + 0.3, 0.7, BOX_L - 0.4, 0, 0.35, 0],
[1.2, 1.2, BOX_L - 2, 0, 1.2, 0, 1],
]), craneMaterial);
crane.add(
mesh(boxes(portalBoxes()), craneMaterial),
mesh(boxes([[5, 2.2, LEG_Z * 2 + 1, 0, PORTAL_H + 2, 0, 1]]), craneMaterial),
ropes,
spreader,
);
group.add(crane);
// Lamps (two work lamps, two flood heads) and two flood pools on the apron: full only.
let glows: ThreeInstancedMesh | null = null;
let pools: ThreeInstancedMesh | null = null;
let glowState: ThreeInstancedBufferAttribute | null = null;
let poolState: ThreeInstancedBufferAttribute | null = null;
if (full) {
const glowGeometry = own('geometry', new THREE.PlaneGeometry(1, 1));
glowState = new THREE.InstancedBufferAttribute(new Float32Array(16), 4);
glowState.setUsage(THREE.DynamicDrawUsage);
glowGeometry.setAttribute('aGlow', glowState);
glows = new THREE.InstancedMesh(glowGeometry, own('material', additive(true)), 4);
const poolGeometry = own('geometry', new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2));
poolState = new THREE.InstancedBufferAttribute(new Float32Array(8), 4);
poolState.setUsage(THREE.DynamicDrawUsage);
poolGeometry.setAttribute('aGlow', poolState);
pools = new THREE.InstancedMesh(poolGeometry, own('material', additive(false)), 2);
const position = new THREE.Vector3();
const scale = new THREE.Vector3();
const none = new THREE.Quaternion();
FLOOD_ZS.forEach((z, i) => {
glows!.setMatrixAt(2 + i, matrix.compose(position.set(FLOOD_X + 1.2, YARD_Y + FLOOD_H + 0.2, z + 1.4), none, scale.setScalar(13)));
pools!.setMatrixAt(i, matrix.compose(position.set(-1.5, YARD_Y + 0.06, z + 8), none, scale.set(24, 1, 40)));
});
for (const m of [glows, pools]) {
m.frustumCulled = false;
m.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
group.add(m);
}
}
const lamp = createLamp();
const lampMatrix = new THREE.Matrix4();
const hue = new THREE.Color();
let warm = 0;
let state: YardInspection = {
visible: false, presence: 0, cranePresence: 0, craneZ: craneZ(0), craneWorldZ: LENS.z + craneZ(0) * Math.cos(yaw), spreader: 0, litPlates: 0, lamp: 0, strikes: 0, flood: 0,
};
return {
group,
inspect: () => ({ ...state }),
update(section, p, beat, deltaSeconds, deckZ = null, aspect = DESIGN_ASPECT) {
place(Number.isFinite(aspect) && aspect > 0 ? aspect : DESIGN_ASPECT);
const live = section === 'additional-work' && p !== null && Number.isFinite(p) ? Math.min(1, Math.max(0, p)) : null;
const edge = (from: number, to: number, v: number) => {
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 z = craneZ(live ?? 0);
const worldZ = LENS.z + z * Math.cos(yaw);
const clear = deckZ === null || !Number.isFinite(deckZ) ? 1 : edge(DECK_CLEAR.from, DECK_CLEAR.to, Math.abs(deckZ - worldZ));
const drop = spreaderDrop(live);
// The spreader lands on whichever stack the crane is over (the nearest by rail distance).
let over = 0;
STACK_ZS.forEach((stackZ, s) => { if (Math.abs(stackZ - z) < Math.abs(STACK_ZS[over]! - z)) over = s; });
const spreaderDown = STACK_TIERS[over]! * BOX_H + 0.45;
const visible = presence > 0;
yardPresence.value = presence;
cranePresence.value = presence * clear;
crane.position.z = z;
crane.visible = cranePresence.value > 0;
const spreaderY = SPREADER_UP + (spreaderDown - SPREADER_UP) * drop;
spreader.position.y = spreaderY;
ropes.scale.y = Math.max(0.1, PORTAL_H - 1 - spreaderY - 1.8);
let litPlates = 0;
STACK_ZS.forEach((_, s) => {
const lit = plateLit(live, s);
if (lit >= 1) litPlates += 1;
stackState.setX(topIndex[s]!, lit);
});
stackState.needsUpdate = true;
warm = floodWarmup(warm, full && live !== null && live < 0.93, 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;
stepLamp(lamp, full && crane.visible && drop > 0.6 ? 1 : 0, deltaSeconds);
if (glows !== null && pools !== null && glowState !== null && poolState !== null) {
const workLamp = [1.0, 0.86, 0.66] as const;
hue.copy(flood).multiplyScalar(1 / 0.5);
[-1, 1].forEach((sx, i) => {
lampMatrix.makeScale(5, 5, 5).setPosition(sx * (SPAN / 2 + 0.8), YARD_Y + PORTAL_H - 1.4, z + LEG_Z + 1.2);
glows!.setMatrixAt(i, lampMatrix);
glowState!.setXYZW(i, workLamp[0], workLamp[1], workLamp[2], (0.12 + 0.88 * lamp.level) * cranePresence.value);
glowState!.setXYZW(2 + i, hue.r, hue.g, hue.b, presence);
poolState!.setXYZW(i, hue.r, hue.g, hue.b, presence * (1 + PULSE_GAIN * pulse.value));
});
glows.instanceMatrix.needsUpdate = true;
glowState.needsUpdate = true;
poolState.needsUpdate = true;
}
group.visible = visible;
state = {
visible,
presence,
cranePresence: cranePresence.value,
craneZ: z,
craneWorldZ: worldZ,
spreader: drop,
litPlates,
lamp: lamp.level,
strikes: lamp.strikes,
flood: warm,
};
},
};
}

View File

@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest';
import {
CRANE_FROM_Z,
CRANE_TO_Z,
craneZ,
createLamp,
FLOOD_WARMUP_SECONDS,
floodLight,
floodWarmup,
LAMP_RAMP_SECONDS,
LAMP_STRIKE_GAP_SECONDS,
plateLit,
spreaderDrop,
STACK_ZS,
stepLamp,
YARD_HOLD,
YARD_STOPS,
yardStopsPassed,
} from '@/journey/yard-progress';
import { DECK_PASS_Z } from '@/journey/underpass';
import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass';
const sweep = (steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i) => i / steps);
describe('night freight progress', () => {
it('has four ordered stops and stacks far to near', () => {
expect(YARD_STOPS).toEqual([0.1, 0.33, 0.56, 0.79]);
for (let i = 1; i < YARD_STOPS.length; i += 1) {
expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD);
expect(STACK_ZS[i]!).toBeGreaterThan(STACK_ZS[i - 1]!);
}
});
it('runs the crane monotonically toward the camera, parked over each stack at its stop', () => {
let previous = Number.NEGATIVE_INFINITY;
for (const p of sweep()) {
const z = craneZ(p);
expect(z).toBeGreaterThanOrEqual(previous - 1e-9);
previous = z;
}
expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z);
expect(craneZ(1)).toBeCloseTo(CRANE_TO_Z);
YARD_STOPS.forEach((stop, i) => {
for (const d of [-YARD_HOLD, -0.02, 0, 0.02, YARD_HOLD]) expect(craneZ(stop + d)).toBeCloseTo(STACK_ZS[i]!);
});
});
it('keeps the crane in its clear range: in front of the camera, short of the deck pass line', () => {
for (const p of sweep()) {
const z = craneZ(p);
expect(z).toBeGreaterThanOrEqual(CRANE_FROM_Z);
expect(z).toBeLessThanOrEqual(CRANE_TO_Z);
// The deck is overhead at z 25 (+- half its depth): the crane never reaches it.
expect(z).toBeLessThan(DECK_PASS_Z - UNDERPASS_GEOMETRY.depth / 2 - 6);
}
});
it('lowers the spreader only at the stops, and lights each plate as it lands', () => {
expect(spreaderDrop(null)).toBe(0);
YARD_STOPS.forEach((stop, i) => {
expect(spreaderDrop(stop)).toBe(1);
expect(plateLit(stop, i)).toBe(1);
expect(plateLit(stop - 0.05, i)).toBe(0);
});
expect(spreaderDrop(0.215)).toBe(0);
expect(spreaderDrop(0.95)).toBe(0);
expect(plateLit(null, 0)).toBe(0);
expect(plateLit(0.5, 9)).toBe(0);
expect([0, 0.1, 0.2, 0.33, 0.6, 0.8, 1].map(yardStopsPassed)).toEqual([0, 1, 1, 2, 3, 4, 4]);
});
it('is symmetric in reverse: the same progress gives the same frame either way', () => {
const frame = (p: number) => [craneZ(p), spreaderDrop(p), ...YARD_STOPS.map((_, i) => plateLit(p, i))];
const ps = sweep(200);
const forward = ps.map(frame);
const backward = [...ps].reverse().map(frame).reverse();
expect(backward).toEqual(forward);
});
it('warms the floods over 1.5 s (never faster than 300 ms) and cools them back', () => {
let level = 0;
let seconds = 0;
while (level < 1) {
level = floodWarmup(level, true, 1 / 60);
seconds += 1 / 60;
}
expect(seconds).toBeGreaterThanOrEqual(0.3);
expect(seconds).toBeCloseTo(FLOOD_WARMUP_SECONDS, 1);
expect(floodWarmup(0, true, 10)).toBe(1);
expect(floodLight(0).output).toBe(0);
expect(floodLight(0.2).hue).toBeLessThan(0.1);
expect(floodLight(1)).toEqual({ output: 1, hue: 1 });
expect(floodWarmup(1, false, 0.75)).toBeCloseTo(0.5);
});
it('strikes a work lamp over at least 300 ms, at most once per 0.6 s however fast the scroll', () => {
expect(LAMP_RAMP_SECONDS).toBeGreaterThanOrEqual(0.3);
const lamp = createLamp();
stepLamp(lamp, 1, 1 / 60);
expect(lamp.strikes).toBe(1);
let t = 1 / 60;
while (lamp.level < 1) {
stepLamp(lamp, 1, 1 / 60);
t += 1 / 60;
}
expect(t).toBeGreaterThanOrEqual(0.3 - 1e-9);
// A frantic scroll toggling the stop every frame for 3 s.
const frantic = createLamp();
const starts: number[] = [];
for (let frame = 0; frame < 180; frame += 1) {
const before = frantic.strikes;
stepLamp(frantic, frame % 2, 1 / 60);
if (frantic.strikes > before) starts.push(frame / 60);
}
expect(starts.length).toBeGreaterThan(1);
for (let i = 1; i < starts.length; i += 1) expect(starts[i]! - starts[i - 1]!).toBeGreaterThanOrEqual(LAMP_STRIKE_GAP_SECONDS - 1e-9);
});
});

114
tests/unit/yard.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { SceneResourceBag } from '@/scene/resources';
import { createYard, railYaw } from '@/scene/world/yard';
import { STACK_TIERS, STACK_ZS, YARD_STOPS, yardStopsPassed } from '@/journey/yard-progress';
interface Node {
name: string;
count?: number;
isInstancedMesh?: boolean;
geometry?: Disposable;
material?: Disposable;
}
interface Disposable {
addEventListener(type: string, listener: () => void): void;
}
const nodes = (group: unknown): Node[] => {
const found: Node[] = [];
(group as { traverse(visit: (child: Node) => void): void }).traverse((child) => found.push(child));
return found;
};
const instancedMeshes = (group: unknown) => nodes(group).filter((child) => child.isInstancedMesh === true);
describe('night freight yard', () => {
it('draws the stacks as one InstancedMesh of 4 + 3 + 2 + 1 containers', () => {
const yard = createYard(new SceneResourceBag(), true);
const stacks = instancedMeshes(yard.group).filter((mesh) => mesh.name === 'yard-stacks');
expect(stacks).toHaveLength(1);
expect(stacks[0]!.count).toBe(STACK_TIERS.reduce((sum, tiers) => sum + tiers, 0));
});
it('is visible only in Additional work, and fades at the chapter edges', () => {
const yard = createYard(new SceneResourceBag(), true);
yard.update('toolkit', 0.5, 0, 1 / 60);
expect(yard.group.visible).toBe(false);
yard.update('additional-work', null, 0, 1 / 60);
expect(yard.group.visible).toBe(false);
yard.update('additional-work', 0, 0, 1 / 60);
expect(yard.group.visible).toBe(false);
yard.update('additional-work', 0.5, 0, 1 / 60);
expect(yard.group.visible).toBe(true);
expect(yard.inspect().presence).toBe(1);
});
it('lights one plate per stop passed, forward and back', () => {
const yard = createYard(new SceneResourceBag(), true);
const ps = Array.from({ length: 101 }, (_, i) => i / 100);
for (const p of [...ps, ...[...ps].reverse()]) {
yard.update('additional-work', p, 0, 1 / 60);
expect(yard.inspect().litPlates).toBe(yardStopsPassed(p));
}
});
it('lowers the spreader and strikes the work lamps at a stop, lifts and dims them after', () => {
const yard = createYard(new SceneResourceBag(), true);
for (let frame = 0; frame < 60; frame += 1) yard.update('additional-work', YARD_STOPS[1], 0, 1 / 60);
const working = yard.inspect();
expect(working.spreader).toBe(1);
expect(working.craneZ).toBe(STACK_ZS[1]);
expect(working.lamp).toBe(1);
expect(working.strikes).toBe(1);
expect(working.flood).toBeCloseTo(1 / 1.5, 2);
for (let frame = 0; frame < 60; frame += 1) yard.update('additional-work', 0.2, 0, 1 / 60);
expect(yard.inspect().spreader).toBe(0);
expect(yard.inspect().lamp).toBe(0);
});
it('hides the crane (not the yard) while an underpass deck is near it', () => {
const yard = createYard(new SceneResourceBag(), true);
yard.update('additional-work', 0.9, 0, 1 / 60, null);
expect(yard.inspect().cranePresence).toBe(1);
const z = yard.inspect().craneWorldZ;
yard.update('additional-work', 0.9, 0, 1 / 60, z + 4);
expect(yard.inspect().cranePresence).toBe(0);
expect(yard.inspect().presence).toBe(1);
});
it('lite: crane, stacks and plates; no lamps, floods or pools', () => {
const yard = createYard(new SceneResourceBag(), false);
expect(instancedMeshes(yard.group)).toHaveLength(1);
for (let frame = 0; frame < 120; frame += 1) yard.update('additional-work', YARD_STOPS[0], 1, 1 / 60);
expect(yard.inspect()).toMatchObject({ litPlates: 1, lamp: 0, flood: 0, spreader: 1 });
});
it('turns the rails to the viewport aspect so the yard keeps its place in the left margin', () => {
const yard = createYard(new SceneResourceBag(), true);
const yawAt = (aspect: number) => {
yard.update('additional-work', 0.5, 0, 1 / 60, null, aspect);
return (yard.group as { rotation: { y: number } }).rotation.y;
};
const wide = yawAt(1920 / 1080);
const design = yawAt(1440 / 900);
const laptop = yawAt(1024 / 700);
expect(wide).toBeGreaterThan(design);
expect(design).toBeGreaterThan(laptop);
expect(design).toBeCloseTo(railYaw(1.6), 6);
expect(yawAt(390 / 844)).toBeGreaterThan(0);
expect(yawAt(Number.NaN)).toBeCloseTo(railYaw(), 6);
});
it('frees every geometry and material on dispose', () => {
const resources = new SceneResourceBag();
const yard = createYard(resources, true);
const owned = new Set<Disposable>();
for (const child of nodes(yard.group)) {
if (child.geometry) owned.add(child.geometry);
if (child.material) owned.add(child.material);
}
expect(owned.size).toBeGreaterThan(6);
let disposed = 0;
owned.forEach((resource) => resource.addEventListener('dispose', () => { disposed += 1; }));
resources.dispose();
expect(disposed).toBe(owned.size);
});
});