diff --git a/docs/overnight/polish/gaps-relay-before-after.webp b/docs/overnight/polish/gaps-relay-before-after.webp new file mode 100644 index 0000000..4b9a012 Binary files /dev/null and b/docs/overnight/polish/gaps-relay-before-after.webp differ diff --git a/docs/overnight/polish/gaps-relight-before-after.webp b/docs/overnight/polish/gaps-relight-before-after.webp new file mode 100644 index 0000000..98dbdcc Binary files /dev/null and b/docs/overnight/polish/gaps-relight-before-after.webp differ diff --git a/docs/overnight/polish/gaps-yard-before-after.webp b/docs/overnight/polish/gaps-yard-before-after.webp new file mode 100644 index 0000000..9830abe Binary files /dev/null and b/docs/overnight/polish/gaps-yard-before-after.webp differ diff --git a/src/journey/card-gaps.ts b/src/journey/card-gaps.ts new file mode 100644 index 0000000..e6edcfe --- /dev/null +++ b/src/journey/card-gaps.ts @@ -0,0 +1,61 @@ +import { ACTIVATION_LINE_VH } from './section-controller'; + +/** + * CARD GAPS: the open road between chapter cards. A chapter opens with its + * title standing over the road, then one reading panel, then (route.css) a + * stretch of open road before the next title: the section's bottom padding + * plus the next section's top margin. Section-local progress runs 0 with a + * chapter's heading on the activation line to 1 with the next heading there, + * so the gap after a chapter's panel always sits at the END of its progress, + * and it is the one stretch where a scene payoff can be watched rather than + * read over. The payoffs (the district relight, the last yard lift, the relay + * beams) key off CARD_GAP instead of their own tuned numbers, so this window + * is the single source of truth. Pure and tiny: shared by the DOM + * (maintenance steps) and the lazy scene chunk. + */ + +/** Open road below a panel, in vh: the section's bottom padding plus the next section's top margin (route.css). */ +export const CARD_GAP_OPEN_ROAD_VH = 20 + 48; +/** The next chapter's title stack: its heading stands this far above its panel (about 197 px; vh at the 900 px design height). */ +export const CARD_TITLE_VH = 22; +/** The viewport band a payoff must have clear of panels, as fractions of the viewport height. */ +export const CARD_CLEAR_BAND = { top: 0.3, bottom: 0.85 } as const; + +export interface GapWindow { + /** The previous panel has cleared the band (section-local progress). */ + from: number; + /** The open road is centred in the viewport. */ + peak: number; + /** The next panel reaches the band. */ + to: number; +} + +/** + * Where the open road after a chapter's panel fills the clear band, for a + * chapter whose heading-to-heading span is `spanVh` viewport heights. Not + * called per frame: payoffs use the static CARD_GAP below, which this + * validates in tests. + */ +export function gapWindow(spanVh: number): GapWindow { + const line = ACTIVATION_LINE_VH * 100; + // Panel edges relative to the next heading, in vh: the previous panel's bottom and the next panel's top. + const previousBottom = -CARD_GAP_OPEN_ROAD_VH; + const nextTop = CARD_TITLE_VH; + return { + from: 1 + (previousBottom + line - CARD_CLEAR_BAND.top * 100) / spanVh, + peak: 1 + ((previousBottom + nextTop) / 2 + line - 50) / spanVh, + to: 1 + (nextTop + line - CARD_CLEAR_BAND.bottom * 100) / spanVh, + }; +} + +/** Chapter spans (vh) the static window must hold for: the shortest chapter on a tall screen to the tallest on a short one. */ +export const CARD_GAP_SPANS_VH = { min: 140, max: 330 } as const; + +/** + * The gap window every payoff keys off, in section-local progress. Valid for + * all spans in CARD_GAP_SPANS_VH (tests/unit/card-gaps.test.ts proves it): + * payoffs begin once the previous panel has cleared the band and complete + * with the road centred, before the chapter's set dressing fades for the + * boundary (`to`). + */ +export const CARD_GAP: GapWindow = Object.freeze({ from: 0.9, peak: 0.94, to: 0.96 }); diff --git a/src/journey/maintenance-progress.ts b/src/journey/maintenance-progress.ts index cc7fb3c..d141d77 100644 --- a/src/journey/maintenance-progress.ts +++ b/src/journey/maintenance-progress.ts @@ -1,4 +1,5 @@ import type { JourneySnapshot } from './types'; +import { CARD_GAP } from './card-gaps'; import { createRewindLatch, sectionLocalProgress } from './route-log'; /** @@ -12,8 +13,8 @@ import { createRewindLatch, sectionLocalProgress } from './route-log'; */ export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done'; -/** Upper bounds of each step, in section-local progress. */ -export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: 0.9 } as const; +/** Upper bounds of each step, in section-local progress. Cutover lands where the card clears the road (CARD_GAP). */ +export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: CARD_GAP.from } as const; /** * The clock reads live Bucharest time again from here. The window keeps its diff --git a/src/journey/relay-progress.ts b/src/journey/relay-progress.ts index b4628fd..5d95623 100644 --- a/src/journey/relay-progress.ts +++ b/src/journey/relay-progress.ts @@ -6,6 +6,8 @@ * pure. Scroll-driven, so scrolling back takes the masts offline again. */ +import { CARD_GAP } from './card-gaps'; + /** Chapter-local progress at which each mast comes online. */ export const RELAY_STOPS = [0.04, 0.22, 0.4, 0.58] as const; /** A mast's rungs light bottom-up over this much chapter progress after its stop. */ @@ -13,6 +15,17 @@ export const RELAY_RAMP = 0.12; /** Beacon blink rate (photosensitivity: well under 3 Hz). */ export const BEACON_HZ = 0.5; +/** + * 0..1: the station goes live, every online mast's beam sweeping the sky + * together, in the open road after the Toolkit card (CARD_GAP), where the + * masts are in frame. Scrolling back takes the beams down again. + */ +export function stationLive(p: number | null): number { + if (p === null) return 0; + const t = Math.min(1, Math.max(0, (p - CARD_GAP.from) / (CARD_GAP.peak - CARD_GAP.from))); + return t * t * (3 - 2 * t); +} + /** How many masts are online at chapter progress p (0 outside the chapter). */ export const relayOnline = (p: number | null): number => p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length; diff --git a/src/journey/yard-progress.ts b/src/journey/yard-progress.ts index aeb3773..f5a3d9d 100644 --- a/src/journey/yard-progress.ts +++ b/src/journey/yard-progress.ts @@ -9,8 +9,10 @@ * 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; +import { CARD_GAP } from './card-gaps'; + +/** Section-local progress of each stop (one per additional project); the last lift lands in the open road after the card. */ +export const YARD_STOPS = [0.12, 0.38, 0.64, CARD_GAP.peak] as const; /** The crane stands still this far either side of a stop while the spreader works. */ export const YARD_HOLD = 0.06; /** @@ -40,7 +42,9 @@ const smooth = (from: number, to: number, value: number): number => { /** * 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. + * easing between stacks from CRANE_FROM_Z at 0; it would run on to + * CRANE_TO_Z after the last hold, but the last stop sits in the card gap + * (CARD_GAP.peak) so the crane stays parked to the chapter boundary. * Non-decreasing in p. */ export function craneZ(p: number): number { diff --git a/src/scene/world/maintenance-window.ts b/src/scene/world/maintenance-window.ts index 185bd59..3b6e984 100644 --- a/src/scene/world/maintenance-window.ts +++ b/src/scene/world/maintenance-window.ts @@ -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)), diff --git a/src/scene/world/relay-masts.ts b/src/scene/world/relay-masts.ts index 4dcda53..343f288 100644 --- a/src/scene/world/relay-masts.ts +++ b/src/scene/world/relay-masts.ts @@ -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; * 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(); } diff --git a/src/scene/world/yard.ts b/src/scene/world/yard.ts index 7edb0b8..7edbe16 100644 --- a/src/scene/world/yard.ts +++ b/src/scene/world/yard.ts @@ -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; diff --git a/src/styles/route.css b/src/styles/route.css index c35ad06..359f6f5 100644 --- a/src/styles/route.css +++ b/src/styles/route.css @@ -9,7 +9,8 @@ /* ---------- Chapter openers over the road ---------- */ @media screen { .reading-stage .portfolio-section { - margin-block: clamp(220px, 44vh, 480px) 0; + /* CARD GAPS: with the padding below, the open road between chapters (journey/card-gaps.ts). */ + margin-block: clamp(240px, 48vh, 520px) 0; padding-block-end: clamp(150px, 20vh, 230px); } .reading-stage .portfolio-section:not([data-layout='project-stage']) { diff --git a/tests/unit/card-gaps.test.ts b/tests/unit/card-gaps.test.ts new file mode 100644 index 0000000..9bec011 --- /dev/null +++ b/tests/unit/card-gaps.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { CARD_CLEAR_BAND, CARD_GAP, CARD_GAP_OPEN_ROAD_VH, CARD_GAP_SPANS_VH, CARD_TITLE_VH, gapWindow } from '@/journey/card-gaps'; +import { MAINTENANCE_BOUNDS, maintenanceStep } from '@/journey/maintenance-progress'; +import { RELAY_STOPS, stationLive } from '@/journey/relay-progress'; +import { plateLit, spreaderDrop, YARD_HOLD, YARD_STOPS } from '@/journey/yard-progress'; +import { maintenanceTimeline } from '@/scene/world/maintenance-window'; +import { relayPresence } from '@/scene/world/relay-masts'; + +const sweep = (from: number, to: number, steps = 200): number[] => + Array.from({ length: steps + 1 }, (_, i) => from + ((to - from) * i) / steps); +const spans = sweep(CARD_GAP_SPANS_VH.min, CARD_GAP_SPANS_VH.max, 40); + +describe('card gaps', () => { + it('matches the open road the stylesheet leaves between chapters', () => { + const css = readFileSync(resolve(process.cwd(), 'src/styles/route.css'), 'utf8'); + const rule = /\.reading-stage \.portfolio-section \{[^}]*margin-block: clamp\(\d+px, (\d+)vh, \d+px\) 0;\s*padding-block-end: clamp\(\d+px, (\d+)vh, \d+px\);/u.exec(css); + expect(rule).not.toBeNull(); + expect(Number(rule![1]) + Number(rule![2])).toBe(CARD_GAP_OPEN_ROAD_VH); + expect(CARD_TITLE_VH).toBeGreaterThan(0); + expect(CARD_CLEAR_BAND.top).toBeLessThan(CARD_CLEAR_BAND.bottom); + }); + + it('keeps the static window inside the measured gap for every chapter span', () => { + for (const span of spans) { + const window = gapWindow(span); + expect(window.from).toBeLessThanOrEqual(CARD_GAP.from); + expect(window.to).toBeGreaterThanOrEqual(CARD_GAP.to); + // The static peak sits where the gap is fully in frame, whatever the span. + expect(CARD_GAP.peak).toBeGreaterThan(window.from); + expect(CARD_GAP.peak).toBeLessThan(window.to); + } + expect(CARD_GAP.from).toBeLessThan(CARD_GAP.peak); + expect(CARD_GAP.peak).toBeLessThan(CARD_GAP.to); + expect(CARD_GAP.to).toBeLessThan(1); + }); + + it('relights the district and cuts over in the gap', () => { + expect(maintenanceTimeline(CARD_GAP.from).relight).toBe(0); + expect(maintenanceTimeline(CARD_GAP.from - 0.01).relight).toBe(0); + expect(maintenanceTimeline(CARD_GAP.peak).relight).toBe(1); + expect(maintenanceTimeline(CARD_GAP.from).packetPhase).toBe(2); + expect(MAINTENANCE_BOUNDS.verify).toBe(CARD_GAP.from); + expect(maintenanceStep(CARD_GAP.peak)).toBe('done'); + // The peak of the relight (its steepest frame) lies inside the window. + const ps = sweep(0, 1, 1000); + const slopes = ps.map((p, i) => (i === 0 ? 0 : maintenanceTimeline(p).relight - maintenanceTimeline(ps[i - 1]!).relight)); + const steepest = ps[slopes.indexOf(Math.max(...slopes))]!; + expect(steepest).toBeGreaterThan(CARD_GAP.from); + expect(steepest).toBeLessThan(CARD_GAP.to); + }); + + it('lands the last yard lift in the gap, with the spreader down and the plate lit before the yard fades', () => { + const last = YARD_STOPS[YARD_STOPS.length - 1]!; + expect(last).toBe(CARD_GAP.peak); + expect(spreaderDrop(last)).toBe(1); + expect(plateLit(last, YARD_STOPS.length - 1)).toBe(1); + expect(last + YARD_HOLD).toBeGreaterThan(CARD_GAP.to); + // The spreader is still up (no work-lamp strike yet) as the gap comes fully into frame. + expect(spreaderDrop(CARD_GAP.from)).toBeLessThan(0.6); + expect(plateLit(CARD_GAP.from, YARD_STOPS.length - 1)).toBe(0); + for (let i = 1; i < YARD_STOPS.length; i += 1) expect(YARD_STOPS[i]!).toBeGreaterThan(YARD_STOPS[i - 1]! + 2 * YARD_HOLD); + }); + + it('brings the relay station live in the gap, after every mast is online and before they fade', () => { + expect(stationLive(null)).toBe(0); + expect(stationLive(CARD_GAP.from)).toBe(0); + expect(stationLive(CARD_GAP.peak)).toBe(1); + expect(Math.max(...RELAY_STOPS)).toBeLessThan(CARD_GAP.from); + for (const p of sweep(CARD_GAP.from, CARD_GAP.to)) expect(relayPresence(p)).toBe(1); + const forward = sweep(0, 1).map(stationLive); + forward.forEach((v, i) => { if (i > 0) expect(v).toBeGreaterThanOrEqual(forward[i - 1]!); }); + }); +}); diff --git a/tests/unit/maintenance-progress.test.ts b/tests/unit/maintenance-progress.test.ts index 9a74fb6..16a5c39 100644 --- a/tests/unit/maintenance-progress.test.ts +++ b/tests/unit/maintenance-progress.test.ts @@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils'; import { describe, expect, it } from 'vitest'; import { defineComponent, h, nextTick, shallowRef } from 'vue'; import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow'; +import { CARD_GAP } from '@/journey/card-gaps'; import type { JourneySnapshot } from '@/journey/types'; import { createMaintenanceDirection, @@ -48,8 +49,8 @@ describe('maintenance progress', () => { expect(maintenanceStep(0.35)).toBe('transfer'); expect(maintenanceStep(0.649)).toBe('transfer'); expect(maintenanceStep(0.65)).toBe('verify'); - expect(maintenanceStep(0.899)).toBe('verify'); - expect(maintenanceStep(0.9)).toBe('done'); + expect(maintenanceStep(CARD_GAP.from - 0.001)).toBe('verify'); + expect(maintenanceStep(CARD_GAP.from)).toBe('done'); expect(maintenanceStep(1)).toBe('done'); }); @@ -57,7 +58,7 @@ describe('maintenance progress', () => { expect(maintenanceClock(0, live)).toBe('03:12'); expect(maintenanceClock(0.1, live)).toBe('03:12'); expect(maintenanceClock(0.5, live)).toBe('03:32'); - expect(maintenanceClock(0.9, live)).toBe('03:51'); + expect(maintenanceClock(CARD_GAP.from, live)).toBe('03:51'); expect(maintenanceClock(0.999, live)).toBe('03:51'); expect(maintenanceClock(1, live)).toBe('14:07'); let previous = -1; diff --git a/tests/unit/maintenance-window.test.ts b/tests/unit/maintenance-window.test.ts index 3c2e9de..7fe277b 100644 --- a/tests/unit/maintenance-window.test.ts +++ b/tests/unit/maintenance-window.test.ts @@ -57,8 +57,8 @@ describe('the 03:12 window timeline', () => { expect(maintenanceTimeline(0.1).packetPhase).toBe(0); expect(maintenanceTimeline(0.36).packetPhase).toBe(1); expect(maintenanceTimeline(0.6).packetPhase).toBe(1); - expect(maintenanceTimeline(0.66).packetPhase).toBe(1); - expect(maintenanceTimeline(0.89).packetPhase).toBe(2); + expect(maintenanceTimeline(0.65).packetPhase).toBe(1); + expect(maintenanceTimeline(0.9).packetPhase).toBe(2); }); }); @@ -111,8 +111,8 @@ describe('maintenance window meshes', () => { it('drops rain, packets and lightning on the lite tier but keeps the power-down', () => { const resources = new SceneResourceBag(); const window = createMaintenanceWindow(resources); - let state = window.update(0.5, 1, 0.016, 'lite'); - for (let i = 0; i < 200; i += 1) state = window.update(0.5, 1, 0.05, 'lite'); + let state = window.update(0.95, 1, 0.016, 'lite'); + for (let i = 0; i < 200; i += 1) state = window.update(0.95, 1, 0.05, 'lite'); expect(state.rain).toBe(0); expect(state.flash).toBe(0); expect(state.lightning).toBe(0); diff --git a/tests/unit/yard-progress.test.ts b/tests/unit/yard-progress.test.ts index 32466b1..03dbfaa 100644 --- a/tests/unit/yard-progress.test.ts +++ b/tests/unit/yard-progress.test.ts @@ -17,6 +17,7 @@ import { YARD_STOPS, yardStopsPassed, } from '@/journey/yard-progress'; +import { CARD_GAP } from '@/journey/card-gaps'; import { DECK_PASS_Z } from '@/journey/underpass'; import { UNDERPASS_GEOMETRY } from '@/scene/world/underpass'; @@ -24,7 +25,7 @@ const sweep = (steps = 400): number[] => Array.from({ length: steps + 1 }, (_, i 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]); + expect(YARD_STOPS).toEqual([0.12, 0.38, 0.64, CARD_GAP.peak]); 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]!); @@ -39,7 +40,9 @@ describe('night freight progress', () => { previous = z; } expect(craneZ(0)).toBeCloseTo(CRANE_FROM_Z); - expect(craneZ(1)).toBeCloseTo(CRANE_TO_Z); + // The last lift is the chapter's final beat (CARD_GAP): the crane stays parked over the near stack to the boundary. + expect(craneZ(1)).toBeCloseTo(STACK_ZS[STACK_ZS.length - 1]!); + expect(CRANE_TO_Z).toBeGreaterThan(STACK_ZS[STACK_ZS.length - 1]!); 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]!); }); @@ -63,10 +66,10 @@ describe('night freight progress', () => { expect(plateLit(stop - 0.05, i)).toBe(0); }); expect(spreaderDrop(0.215)).toBe(0); - expect(spreaderDrop(0.95)).toBe(0); + expect(spreaderDrop(0.51)).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]); + expect([0, 0.12, 0.2, 0.38, 0.7, 0.95, 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', () => {