feat: interactive traffic with car-following, overtakes and flash-to-pass

This commit is contained in:
2026-10-11 23:13:05 +03:00
parent 8c9ec69565
commit 50da616a46
22 changed files with 2513 additions and 230 deletions

View File

@@ -0,0 +1,78 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { createPulseTap, readSwitchboard, setMusicPulseAudible } from '@/audio/music-pulse';
import { TRAFFIC_PASS_GAP_SECONDS, TRAFFIC_PASS_PAN, playTrafficPass } from '@/audio/traffic-pass';
/** A minimal audio graph that records what the swell builds. */
function fakeContext() {
const pans: number[] = [];
const param = () => ({ setValueAtTime: (v: number) => pans.push(v), exponentialRampToValueAtTime: () => undefined });
const node = (extra: object = {}) => ({ connect: () => undefined, disconnect: () => undefined, ...extra });
const context = {
state: 'running',
currentTime: 10,
sampleRate: 8000,
started: 0,
createGain: () => node({ context, gain: { setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined } }),
createAnalyser: () => {
throw new Error('no analyser');
},
createBuffer: (_channels: number, length: number) => ({ sampleRate: 8000, getChannelData: () => new Float32Array(length) }),
createBufferSource: () =>
node({
buffer: null,
onended: null,
start: () => {
context.started += 1;
},
stop: () => undefined,
}),
createBiquadFilter: () =>
node({
type: '',
Q: { setValueAtTime: () => undefined },
frequency: { setValueAtTime: () => undefined, exponentialRampToValueAtTime: () => undefined },
}),
createStereoPanner: () => node({ pan: param() }),
};
return { context, pans };
}
describe('traffic pass swell', () => {
afterEach(() => setMusicPulseAudible(false));
it('is silent with no audible soundtrack', () => {
expect(readSwitchboard()).toBeNull();
expect(playTrafficPass(1)).toBe(false);
});
it('plays once per gap, panned to the passing side', () => {
const { context, pans } = fakeContext();
createPulseTap(context as unknown as BaseAudioContext, { connect: () => undefined } as unknown as AudioNode);
setMusicPulseAudible(true);
expect(playTrafficPass(-1)).toBe(true);
expect(pans).toContain(-TRAFFIC_PASS_PAN);
expect(playTrafficPass(1)).toBe(false);
context.currentTime += TRAFFIC_PASS_GAP_SECONDS + 0.01;
expect(playTrafficPass(1)).toBe(true);
expect(pans).toContain(TRAFFIC_PASS_PAN);
expect(context.started).toBe(2);
});
it('never plays on a suspended context', () => {
const { context } = fakeContext();
context.state = 'suspended';
createPulseTap(context as unknown as BaseAudioContext, { connect: () => undefined } as unknown as AudioNode);
setMusicPulseAudible(true);
expect(playTrafficPass(1)).toBe(false);
});
it('stays out of the initial bundle: only the scene chunk imports it, lazily, never on lite', () => {
const environment = readFileSync(resolve(process.cwd(), 'src/scene/world/environment.ts'), 'utf8');
expect(environment).toContain("import('@/audio/traffic-pass')");
expect(environment).not.toMatch(/from '@\/audio\/traffic-pass'/u);
const budgets = readFileSync(resolve(process.cwd(), 'scripts/lib/bundle-budgets.mjs'), 'utf8');
expect(budgets).toContain('traffic-pass-');
});
});