281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
import * as THREE from 'three';
|
|
import type { SectionId } from '@/content/types';
|
|
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
|
import {
|
|
createShadeLatch,
|
|
DECK_FAR_Z,
|
|
DECK_PASS_Z,
|
|
deckZ,
|
|
gapProgress,
|
|
SHADE_PEAK,
|
|
shadeFor,
|
|
slewShade,
|
|
underpassTravel,
|
|
UNDERPASS_EXITS,
|
|
} from '@/journey/underpass';
|
|
import { roadOpenness } from '@/scene/world/headlights';
|
|
import { SceneResourceBag } from '@/scene/resources';
|
|
import { createUnderpass, UNDERPASS_GEOMETRY, underpassUniforms } from '@/scene/world/underpass';
|
|
import { dampPalette, createMutableChapterPalette, paletteDampRate, writeChapterPalette } from '@/scene/world/palette';
|
|
import { readSwitchboard } from '@/audio/music-pulse';
|
|
import { playUnderpassPass } from '@/audio/underpass-pass';
|
|
|
|
const activation = (active: number, next: number | null) => ({
|
|
layoutRevision: 1,
|
|
activeHeadingOffsetVh: active,
|
|
nextHeadingOffsetVh: next,
|
|
});
|
|
const gap = (id: SectionId, active: number, next: number | null) => ({
|
|
activeSectionId: id,
|
|
sectionActivation: activation(active, next),
|
|
});
|
|
|
|
describe('underpass gap progress', () => {
|
|
it('measures the gap exactly as the road openness does', () => {
|
|
for (const [active, next] of [[0, 1], [-0.4, 0.6], [-0.95, 0.05], [-1.3, 0.4], [0.2, 1.2]] as const) {
|
|
const p = gapProgress(gap('experience', active, next));
|
|
expect(p).not.toBeNull();
|
|
const expected = THREE.MathUtils.clamp(-active / (next - active), 0, 1);
|
|
expect(p).toBeCloseTo(expected, 10);
|
|
// Same p, same openness curve.
|
|
const open = 1 - THREE.MathUtils.smoothstep(p!, 0.02, 0.12) * (1 - THREE.MathUtils.smoothstep(p!, 0.8, 0.92));
|
|
expect(roadOpenness(gap('experience', -p!, 1 - p!))).toBeCloseTo(open, 10);
|
|
}
|
|
});
|
|
|
|
it('is null with no next heading, a non-positive span or non-finite offsets', () => {
|
|
expect(gapProgress(gap('contact', -0.2, null))).toBeNull();
|
|
expect(gapProgress(gap('profile', 0.3, 0.3))).toBeNull();
|
|
expect(gapProgress(gap('profile', 0.5, 0.2))).toBeNull();
|
|
expect(gapProgress(gap('profile', Number.NaN, 0.2))).toBeNull();
|
|
expect(gapProgress(gap('profile', 0, Number.POSITIVE_INFINITY))).toBeNull();
|
|
expect(roadOpenness(gap('contact', -0.2, null))).toBe(0);
|
|
});
|
|
|
|
it("measures 'start' too (its boundary is exit 02), where the road openness is always open", () => {
|
|
expect(roadOpenness(gap('start', -0.5, 0.5))).toBe(1);
|
|
expect(gapProgress(gap('start', -0.5, 0.5))).toBeCloseTo(0.5, 10);
|
|
expect(underpassTravel(gap('start', -0.98, 0.02))?.exit).toBe('profile');
|
|
});
|
|
});
|
|
|
|
describe('underpass deck travel', () => {
|
|
it('slides from the horizon at p 0.45 to over the camera at p 1, monotonic and reversible', () => {
|
|
expect(deckZ(0)).toBe(DECK_FAR_Z);
|
|
expect(deckZ(0.45)).toBe(DECK_FAR_Z);
|
|
expect(deckZ(1)).toBeCloseTo(DECK_PASS_Z, 10);
|
|
expect(DECK_FAR_Z).toBe(-190);
|
|
// Overhead means the chase camera's z (23-26 across the chapters).
|
|
expect(DECK_PASS_Z).toBe(25);
|
|
const forward: number[] = [];
|
|
for (let i = 0; i <= 200; i += 1) forward.push(deckZ(i / 200));
|
|
for (let i = 1; i < forward.length; i += 1) expect(forward[i]!).toBeGreaterThanOrEqual(forward[i - 1]!);
|
|
const back: number[] = [];
|
|
for (let i = 200; i >= 0; i -= 1) back.push(deckZ(i / 200));
|
|
expect(back.reverse()).toEqual(forward);
|
|
});
|
|
|
|
it('does not leave the horizon before the exit sign fires, then passes over at the line', () => {
|
|
// A long chapter: the sign fires with the next heading 0.55 vh below the line.
|
|
const span = 3;
|
|
expect(underpassTravel(gap('experience', -(span - 0.6), 0.6))).toBeNull();
|
|
const fired = underpassTravel(gap('experience', -(span - 0.55), 0.55));
|
|
expect(fired?.exit).toBe('infrastructure');
|
|
expect(fired?.z).toBeCloseTo(DECK_FAR_Z, 6);
|
|
expect(underpassTravel(gap('experience', -span, 0))?.z).toBeCloseTo(DECK_PASS_Z, 6);
|
|
// Narrower trigger (a tall screen): the deck waits longer.
|
|
expect(underpassTravel(gap('experience', -(span - 0.4), 0.4), 0.23)).toBeNull();
|
|
});
|
|
|
|
it('carries the same deck on behind the camera past the cut, and scrolls back under it', () => {
|
|
const before = underpassTravel(gap('profile', -0.999, 0.001));
|
|
const after = underpassTravel(gap('experience', -0.001, 1.5));
|
|
expect(before?.exit).toBe('experience');
|
|
expect(after?.exit).toBe('experience');
|
|
expect(Math.abs(after!.z - before!.z)).toBeLessThan(1);
|
|
expect(underpassTravel(gap('experience', -0.24, 1.26))!.z).toBeGreaterThan(after!.z);
|
|
expect(underpassTravel(gap('experience', -0.3, 1.2))).toBeNull();
|
|
// Contact has no next heading, but its own deck still trails out.
|
|
expect(underpassTravel(gap('contact', -0.1, null))?.exit).toBe('contact');
|
|
// Start has no deck behind it.
|
|
expect(underpassTravel(gap('start', 0, 1))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('underpass shade', () => {
|
|
it('peaks at no more than 35% over the camera and is zero outside the 18 m band', () => {
|
|
let peak = 0;
|
|
for (let z = -200; z <= 80; z += 0.25) {
|
|
const shade = shadeFor(z, 25);
|
|
peak = Math.max(peak, shade);
|
|
expect(shade).toBeLessThanOrEqual(0.35);
|
|
if (Math.abs(z - 25) >= 18) expect(shade).toBe(0);
|
|
}
|
|
expect(peak).toBeCloseTo(SHADE_PEAK, 10);
|
|
expect(SHADE_PEAK).toBeLessThanOrEqual(0.35);
|
|
expect(shadeFor(Number.NaN)).toBe(0);
|
|
});
|
|
|
|
it('never moves faster than 0 to peak in 300 ms', () => {
|
|
let shade = 0;
|
|
let steps = 0;
|
|
while (shade < SHADE_PEAK) {
|
|
shade = slewShade(shade, 1, 1 / 120);
|
|
steps += 1;
|
|
}
|
|
expect(steps / 120).toBeGreaterThanOrEqual(0.3 - 1e-9);
|
|
expect(slewShade(SHADE_PEAK, 0, 0.1)).toBeCloseTo(SHADE_PEAK * (2 / 3), 10);
|
|
});
|
|
|
|
it('latches once per pass: brisk scrolls and explicit navigation get none, and full shades are 1 s apart', () => {
|
|
const latch = createShadeLatch();
|
|
expect(latch.update(true, 0.3, false, 0)).toBe(true);
|
|
// Held for the whole pass, even if the drive speeds up.
|
|
expect(latch.update(true, 0.9, false, 200)).toBe(true);
|
|
expect(latch.update(false, 0.3, false, 400)).toBe(false);
|
|
// The next pass within 1 s of the last full shade: none.
|
|
expect(latch.update(true, 0.3, false, 900)).toBe(false);
|
|
expect(latch.update(true, 0.3, false, 1_200)).toBe(false);
|
|
latch.update(false, 0.3, false, 1_300);
|
|
expect(latch.update(true, 0.3, false, 1_400)).toBe(true);
|
|
latch.update(false, 0.3, false, 1_500);
|
|
// Brisk: the sign's gate.
|
|
expect(latch.update(true, 0.51, false, 5_000)).toBe(false);
|
|
latch.update(false, 0.3, false, 5_100);
|
|
expect(latch.update(true, 0.5, false, 5_200)).toBe(true);
|
|
latch.update(false, 0.3, false, 5_300);
|
|
// An explicit navigation long-scroll.
|
|
expect(latch.update(true, 0.2, true, 9_000)).toBe(false);
|
|
expect(latch.passes).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe('underpass exits', () => {
|
|
it('has seven decks, exits 02-08, each in its sign accent', () => {
|
|
expect(UNDERPASS_EXITS.map((exit) => exit.label)).toEqual([
|
|
'EXIT 02', 'EXIT 03', 'EXIT 04', 'EXIT 05', 'EXIT 06', 'EXIT 07', 'EXIT 08',
|
|
]);
|
|
for (const exit of UNDERPASS_EXITS) expect(exit.accent).toBe(SIGN_ACCENTS[exit.id]);
|
|
expect(UNDERPASS_EXITS[0]!.id).toBe('profile');
|
|
expect(UNDERPASS_EXITS[6]!.id).toBe('contact');
|
|
});
|
|
});
|
|
|
|
describe('underpass deck', () => {
|
|
it('clears the scaled car and the docked exit sign, and spans the road and its shoulders', () => {
|
|
const G = UNDERPASS_GEOMETRY;
|
|
expect(G.clearance).toBeGreaterThan(G.carHeight * 2);
|
|
// The docked sign's top edge reaches 20.4 m (Infrastructure at 1440x900).
|
|
expect(G.clearance).toBeGreaterThan(20.4);
|
|
// Road 20.4 m wide, shoulders to +-12; piers stand clear of both.
|
|
expect(G.span / 2).toBeGreaterThan(12);
|
|
expect(G.pierX - G.pierWidth / 2).toBeGreaterThan(12);
|
|
expect(G.pierX + G.pierWidth / 2).toBeLessThan(G.span / 2);
|
|
expect(G.depth).toBe(10);
|
|
expect(G.span).toBe(70);
|
|
});
|
|
|
|
it('returns shade 0 with no gap, and casts it only for a calm scroll on the full tier', () => {
|
|
const sound = vi.fn();
|
|
const world = createUnderpass(new SceneResourceBag(), { full: true, sound });
|
|
expect(world.update('experience', null, 0.2, 1 / 60, 0)).toEqual({ shade: 0, z: null, exit: null });
|
|
expect(world.update('experience', 0.3, 0.2, 1 / 60, 16)).toEqual({ shade: 0, z: null, exit: null });
|
|
expect(world.group.visible).toBe(false);
|
|
// Slow scroll to the line.
|
|
let now = 16;
|
|
let peak = 0;
|
|
for (let p = 0.5; p <= 1; p += 0.002) {
|
|
now += 33;
|
|
const state = world.update('experience', p, 0.2, 0.033, now, { cameraZ: 25 });
|
|
peak = Math.max(peak, state.shade);
|
|
expect(state.shade).toBeLessThanOrEqual(0.35);
|
|
}
|
|
expect(peak).toBeGreaterThan(0.3);
|
|
expect(world.inspect().exit).toBe('infrastructure');
|
|
expect(world.inspect().label).toBe('EXIT 04');
|
|
expect(world.inspect().pool).toBe(true);
|
|
expect(sound).toHaveBeenCalledTimes(1);
|
|
expect(underpassUniforms.uShade.value).toBeGreaterThan(0.3);
|
|
// Past the deck: the shade eases out over >= 300 ms.
|
|
const out = world.update('infrastructure', null, 0.2, 0.1, now + 100);
|
|
expect(out.shade).toBeGreaterThan(0.2);
|
|
for (let i = 0; i < 10; i += 1) world.update('infrastructure', null, 0.2, 0.1, now + 200 + i * 100);
|
|
expect(world.inspect().shade).toBe(0);
|
|
expect(underpassUniforms.uShade.value).toBe(0);
|
|
});
|
|
|
|
it('a brisk pass or an explicit navigation gets the structure but no shade or sound', () => {
|
|
for (const [speed, explicit] of [[0.8, false], [0.2, true]] as const) {
|
|
const sound = vi.fn();
|
|
const world = createUnderpass(new SceneResourceBag(), { full: true, sound });
|
|
let now = 0;
|
|
for (let p = 0.5; p <= 1; p += 0.01) {
|
|
now += 16;
|
|
const state = world.update('profile', p, speed, 0.016, now, { explicit });
|
|
expect(state.shade).toBe(0);
|
|
}
|
|
expect(world.inspect().visible).toBe(true);
|
|
expect(sound).not.toHaveBeenCalled();
|
|
}
|
|
});
|
|
|
|
it('the low tier draws the deck and strips only', () => {
|
|
const sound = vi.fn();
|
|
const world = createUnderpass(new SceneResourceBag(), { full: false, sound });
|
|
let now = 0;
|
|
for (let p = 0.5; p <= 1; p += 0.002) {
|
|
now += 33;
|
|
expect(world.update('toolkit', p, 0.2, 0.033, now).shade).toBe(0);
|
|
}
|
|
expect(world.inspect().visible).toBe(true);
|
|
expect(world.inspect().pool).toBe(false);
|
|
expect(sound).not.toHaveBeenCalled();
|
|
expect(underpassUniforms.uShade.value).toBe(0);
|
|
});
|
|
|
|
it('holds behind the exit sign while it comes in', () => {
|
|
const world = createUnderpass(new SceneResourceBag(), { full: true, sound: vi.fn() });
|
|
let now = 0;
|
|
for (let p = 0.6; p <= 0.95; p += 0.01) {
|
|
now += 16;
|
|
world.update('profile', p, 0.2, 0.016, now, { signZ: -60 });
|
|
expect(world.inspect().z!).toBeLessThanOrEqual(-70 + 1e-6);
|
|
}
|
|
expect(world.inspect().held).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('underpass palette boost', () => {
|
|
it('settles x4 deep in the shade, and not on top of the storm', () => {
|
|
expect(paletteDampRate(0)).toBe(1);
|
|
expect(paletteDampRate(0.5)).toBe(1);
|
|
expect(paletteDampRate(0.8)).toBe(4);
|
|
expect(paletteDampRate(0.8, 1)).toBe(1);
|
|
expect(paletteDampRate(0.8, 0.5)).toBeCloseTo(2.5, 10);
|
|
const slow = createMutableChapterPalette('deep-night');
|
|
const fast = createMutableChapterPalette('deep-night');
|
|
const target = writeChapterPalette(createMutableChapterPalette(), 'deep-night', 'infrastructure', 1);
|
|
dampPalette(slow, target, 0.1);
|
|
dampPalette(fast, target, 0.1, 4);
|
|
const distance = (palette: typeof slow) => Math.abs(palette.warmth - target.warmth);
|
|
expect(distance(fast)).toBeLessThan(distance(slow));
|
|
});
|
|
});
|
|
|
|
describe('underpass pass swell', () => {
|
|
it('is silent with no audible soundtrack', () => {
|
|
expect(readSwitchboard()).toBeNull();
|
|
expect(playUnderpassPass()).toBe(false);
|
|
});
|
|
|
|
it('stays out of the initial bundle: only the scene chunk imports the helper', () => {
|
|
const environment = readFileSync(resolve(process.cwd(), 'src/scene/world/environment.ts'), 'utf8');
|
|
expect(environment).toContain("from '@/scene/world/underpass'");
|
|
const css = readFileSync(resolve(process.cwd(), 'src/styles/world.css'), 'utf8');
|
|
expect(css).toContain("html[data-underpass='under'] .journey-hud__section");
|
|
});
|
|
});
|