Merge branch 'polish/card-gaps' into redesign/overnight
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