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.
122 lines
4.6 KiB
TypeScript
122 lines
4.6 KiB
TypeScript
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 { CARD_GAP } from '@/journey/card-gaps';
|
|
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.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]!);
|
|
}
|
|
});
|
|
|
|
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);
|
|
// 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]!);
|
|
});
|
|
});
|
|
|
|
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.51)).toBe(0);
|
|
expect(plateLit(null, 0)).toBe(0);
|
|
expect(plateLit(0.5, 9)).toBe(0);
|
|
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', () => {
|
|
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);
|
|
});
|
|
});
|