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:
BIN
docs/overnight/polish/gaps-relay-before-after.webp
Normal file
BIN
docs/overnight/polish/gaps-relay-before-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
docs/overnight/polish/gaps-relight-before-after.webp
Normal file
BIN
docs/overnight/polish/gaps-relight-before-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
BIN
docs/overnight/polish/gaps-yard-before-after.webp
Normal file
BIN
docs/overnight/polish/gaps-yard-before-after.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 245 KiB |
61
src/journey/card-gaps.ts
Normal file
61
src/journey/card-gaps.ts
Normal file
@@ -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 });
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
75
tests/unit/card-gaps.test.ts
Normal file
75
tests/unit/card-gaps.test.ts
Normal file
@@ -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]!); });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user