feat: night freight container yard works through the additional work chapter
This commit is contained in:
118
tests/unit/yard-progress.test.ts
Normal file
118
tests/unit/yard-progress.test.ts
Normal 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
114
tests/unit/yard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user