Phase 7: sound, synthesised from the simulation

No audio files. The build stays self-contained, so the engine is detuned
oscillators, tyres and gunfire are shaped noise, ambience is a filtered drone.
The constraint pays: sound generated from the sim can carry the sim's state
instead of decorating it.

The engine has gears, so the note climbs and drops rather than tracking the
speedometer forever. A worn engine sounds wrong rather than merely slow —
roughness rises and the top of the rev range falls away as condition drops, so
decline is audible as well as felt. Bald tyres squeal sooner than good ones, off
the lateral slip the physics actually reports. Gunfire falls off with the square
of distance and pans as the car turns, so a firefight is a direction. Territory
has a tone: the drone drops and thickens past the line, matching the palette.

Split so it can be tested: audio/mix.ts holds every decision as plain numbers and
is covered; audio/audio.ts is only wiring, which cannot run outside a browser.
A test caught panning being mirrored — +X is the car's left in this world, so the
sine needed negating.

Also adds hold-R-for-five-seconds to wipe the campaign, with a filling meter so
it cannot happen by accident, and M to mute. Tapping R still just respawns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dejvino 2026-08-07 21:31:49 +02:00
parent f4ed3cdc48
commit 62c67ea649
10 changed files with 738 additions and 6 deletions

View File

@ -9,6 +9,7 @@ Endless driving survival game.
- **Phase 4** — the front line moves, from your work and on its own. - **Phase 4** — the front line moves, from your work and on its own.
- **Phase 5** — four distinct jobs, radio texture, and work found in the field. - **Phase 5** — four distinct jobs, radio texture, and work found in the field.
- **Phase 6** — an inhabited world: traffic, people, patrols, and a live war. - **Phase 6** — an inhabited world: traffic, people, patrols, and a live war.
- **Phase 7** — sound, synthesised from the simulation.
## Running ## Running
@ -26,6 +27,8 @@ seeds may be numbers or words.
| `Space` | handbrake (rear wheels only) | | `Space` | handbrake (rear wheels only) |
| `R` | respawn the car — **does not** repair it | | `R` | respawn the car — **does not** repair it |
| `1``3` | accept a mission, when parked at a base | | `1``3` | accept a mission, when parked at a base |
| `M` | sound on/off |
| hold `R` | wipe the campaign and start over (5 seconds) |
Progress saves itself every 20 seconds, and immediately whenever you take or Progress saves itself every 20 seconds, and immediately whenever you take or
finish a job. Saves are per seed; `?seed=x` is a separate campaign. finish a job. Saves are per seed; `?seed=x` is a separate campaign.
@ -232,6 +235,30 @@ is permanent in the same way everything else is.
a target. They open up only past the line *and* on a road they have already a target. They open up only past the line *and* on a road they have already
checkpointed — that is, a road you personally made notorious. checkpointed — that is, a road you personally made notorious.
## Sound
Everything is **synthesised** — there are no audio files. The build has to stay
self-contained, so the engine is detuned oscillators, the tyres and gunfire are
shaped noise, and the ambience is a filtered drone. That constraint turned out to
be an advantage: sound generated from the simulation can carry the simulation's
state rather than just decorating it.
- **The engine has gears**, so the note climbs and drops rather than rising
forever with the speedometer.
- **A worn engine sounds wrong**, not just slow: roughness rises and the top of
the rev range falls away as condition drops. The car's decline is audible.
- **Bald tyres squeal sooner** than good ones, off the same lateral slip the
physics actually reports.
- **Gunfire is positional** — it falls off with the square of distance and pans
as you turn, so a firefight two streets away is a direction, not just a noise.
- **Territory has a tone.** The drone drops and thickens the further past the
line you are, matching what the palette does visually.
The split is deliberate: `audio/mix.ts` holds every decision as plain numbers
and is unit-tested; `audio/audio.ts` is only Web Audio wiring, which cannot be
tested outside a browser. Browsers refuse to make noise before the user has
interacted, so the context is resumed on the first keypress or click.
## Regional control ## Regional control
The map is cut by a single **front line** into four bands, running from your own The map is cut by a single **front line** into four bands, running from your own
@ -339,6 +366,7 @@ src/
render/ three.js scene, roads, markers, chase camera render/ three.js scene, roads, markers, chase camera
core/ fixed-timestep loop, seeded RNG, keyboard core/ fixed-timestep loop, seeded RNG, keyboard
ui/ debug HUD, quest board, sketch minimap ui/ debug HUD, quest board, sketch minimap
audio/ mix (pure: what it should sound like), audio (Web Audio wiring)
persistence.ts integration layer: saves in localStorage persistence.ts integration layer: saves in localStorage
debug.ts ?debug=1 handle: step, render and capture frames to shots/ debug.ts ?debug=1 handle: step, render and capture frames to shots/
carSpec.ts shared car dimensions, so body and mesh cannot drift apart carSpec.ts shared car dimensions, so body and mesh cannot drift apart

290
src/audio/audio.ts Normal file
View File

@ -0,0 +1,290 @@
/**
* The audio graph. Integration layer: this is the only file that touches Web
* Audio, and it makes no decisions every number it uses comes from mix.ts.
*
* Everything is synthesised. The build has to stay self-contained (no network
* at runtime), so there are no sample files: the engine is detuned oscillators,
* the tyres and gunfire are shaped noise, the ambience is a filtered drone.
* That also means the car can *sound* worn out rather than just performing worse.
*/
import type { CarCondition } from '../sim/car';
import type { Control } from '../sim/regions';
import {
ambienceFor,
engineTone,
impactLevel,
roadNoise,
spatial,
windGain,
GUNSHOT_RANGE,
IMPACT_FLOOR,
IMPACT_RANGE,
} from './mix';
const MUTE_KEY = 'dbtl.muted';
/** One second of white noise, reused as the source for everything hissy. */
function noiseBuffer(context: AudioContext): AudioBuffer {
const buffer = context.createBuffer(1, context.sampleRate, context.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
return buffer;
}
function loopingNoise(context: AudioContext, buffer: AudioBuffer): AudioBufferSourceNode {
const source = context.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.start();
return source;
}
export interface AudioModel {
speed: number;
throttle: number;
condition: CarCondition;
/** Total lateral slip across the wheels, normalised-ish. */
sideSlip: number;
wheelsOnGround: number;
control: Control;
listener: { x: number; z: number; heading: number };
}
export function createAudio() {
const context = new AudioContext();
const noise = noiseBuffer(context);
// Master chain. The compressor is not for polish — a firefight plus an engine
// plus impacts will clip without it.
const master = context.createGain();
const compressor = context.createDynamicsCompressor();
compressor.threshold.value = -18;
compressor.ratio.value = 6;
master.connect(compressor).connect(context.destination);
let muted = localStorage.getItem(MUTE_KEY) === '1';
master.gain.value = muted ? 0 : 0.9;
// --- Engine: two detuned saws through a lowpass, plus a noise bed that
// stands in for combustion roughness as the engine wears out.
const engineGain = context.createGain();
engineGain.gain.value = 0;
const engineFilter = context.createBiquadFilter();
engineFilter.type = 'lowpass';
engineFilter.frequency.value = 900;
engineGain.connect(engineFilter).connect(master);
const oscillators = [0, 1].map((index) => {
const osc = context.createOscillator();
osc.type = index === 0 ? 'sawtooth' : 'square';
osc.frequency.value = 40;
osc.detune.value = index === 0 ? 0 : 9;
const gain = context.createGain();
gain.gain.value = index === 0 ? 0.7 : 0.3;
osc.connect(gain).connect(engineGain);
osc.start();
return osc;
});
const roughGain = context.createGain();
roughGain.gain.value = 0;
const roughFilter = context.createBiquadFilter();
roughFilter.type = 'bandpass';
roughFilter.frequency.value = 180;
roughFilter.Q.value = 1.4;
loopingNoise(context, noise).connect(roughGain);
roughGain.connect(roughFilter).connect(engineGain);
// --- Road roar ---
const roadGain = context.createGain();
roadGain.gain.value = 0;
const roadFilter = context.createBiquadFilter();
roadFilter.type = 'lowpass';
roadFilter.frequency.value = 600;
loopingNoise(context, noise).connect(roadGain);
roadGain.connect(roadFilter).connect(master);
// --- Tyre squeal ---
const squealGain = context.createGain();
squealGain.gain.value = 0;
const squealFilter = context.createBiquadFilter();
squealFilter.type = 'bandpass';
squealFilter.frequency.value = 1500;
squealFilter.Q.value = 7;
loopingNoise(context, noise).connect(squealGain);
squealGain.connect(squealFilter).connect(master);
// --- Wind ---
const windGainNode = context.createGain();
windGainNode.gain.value = 0;
const windFilter = context.createBiquadFilter();
windFilter.type = 'highpass';
windFilter.frequency.value = 900;
loopingNoise(context, noise).connect(windGainNode);
windGainNode.connect(windFilter).connect(master);
// --- Ambience: a drone plus an airy layer, both shifting with territory ---
const droneOsc = context.createOscillator();
droneOsc.type = 'triangle';
droneOsc.frequency.value = 55;
const droneGain = context.createGain();
droneGain.gain.value = 0;
droneOsc.connect(droneGain).connect(master);
droneOsc.start();
const airGain = context.createGain();
airGain.gain.value = 0;
const airFilter = context.createBiquadFilter();
airFilter.type = 'bandpass';
airFilter.frequency.value = 2400;
airFilter.Q.value = 0.6;
loopingNoise(context, noise).connect(airGain);
airGain.connect(airFilter).connect(master);
/** Smoothly move a parameter; stepping them creates clicks. */
const ramp = (param: AudioParam, value: number, seconds = 0.06) => {
param.setTargetAtTime(value, context.currentTime, seconds);
};
/** A one-shot burst of shaped noise: gunfire, impacts, hits. */
function burst(options: {
level: number;
duration: number;
type: BiquadFilterType;
frequency: number;
Q?: number;
pan?: number;
sweepTo?: number;
}) {
if (options.level <= 0.001) return;
const now = context.currentTime;
const source = context.createBufferSource();
source.buffer = noise;
source.loop = true;
const filter = context.createBiquadFilter();
filter.type = options.type;
filter.frequency.value = options.frequency;
if (options.Q !== undefined) filter.Q.value = options.Q;
if (options.sweepTo !== undefined) {
filter.frequency.exponentialRampToValueAtTime(options.sweepTo, now + options.duration);
}
const gain = context.createGain();
gain.gain.setValueAtTime(options.level, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + options.duration);
const panner = context.createStereoPanner();
panner.pan.value = options.pan ?? 0;
source.connect(filter).connect(gain).connect(panner).connect(master);
source.start(now);
source.stop(now + options.duration + 0.02);
}
return {
get muted() {
return muted;
},
get suspended() {
return context.state === 'suspended';
},
/**
* Browsers refuse to make noise until the user has interacted, so this is
* called from the first keypress or click rather than at boot.
*/
resume() {
if (context.state === 'suspended') void context.resume();
},
toggleMute(): boolean {
muted = !muted;
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
ramp(master.gain, muted ? 0 : 0.9, 0.02);
return muted;
},
/** Continuous sounds, driven from the sim every frame. */
update(model: AudioModel) {
const engine = engineTone(model.speed, model.throttle, model.condition);
for (const osc of oscillators) ramp(osc.frequency, engine.frequency, 0.05);
ramp(engineGain.gain, engine.gain);
ramp(engineFilter.frequency, 500 + engine.frequency * 7);
// A worn engine gets louder in the wrong way, not quieter.
ramp(roughGain.gain, engine.roughness * 0.05);
ramp(roughFilter.frequency, engine.frequency * 3);
const road = roadNoise(model.speed, model.sideSlip, model.wheelsOnGround, model.condition);
ramp(roadGain.gain, road.gain);
ramp(roadFilter.frequency, road.cutoff);
ramp(squealGain.gain, road.squeal * 0.07, 0.03);
ramp(windGainNode.gain, windGain(model.speed));
const ambience = ambienceFor(model.control);
// Slow, so crossing a border is felt rather than heard as a switch.
ramp(droneGain.gain, ambience.droneGain, 1.5);
ramp(droneOsc.frequency, ambience.droneFrequency, 2);
ramp(airGain.gain, ambience.airGain, 1.5);
},
/** The car hit something. */
impact(force: number, at?: { x: number; z: number }, listener?: AudioModel['listener']) {
if (force < IMPACT_FLOOR) return;
const level = impactLevel(force);
const placed = at && listener ? spatial(listener, at, IMPACT_RANGE) : { gain: 1, pan: 0 };
burst({
level: level * 0.8 * placed.gain,
duration: 0.16 + level * 0.2,
type: 'lowpass',
frequency: 500,
sweepTo: 90,
pan: placed.pan,
});
},
/** Somebody fired, somewhere. */
shot(at: { x: number; z: number }, listener: AudioModel['listener']) {
const placed = spatial(listener, at, GUNSHOT_RANGE);
if (placed.gain <= 0.004) return;
burst({
level: 0.5 * placed.gain,
duration: 0.1,
type: 'highpass',
frequency: 700,
sweepTo: 180,
pan: placed.pan,
});
},
/** A round hit the car. Sharper and closer than anything else. */
hit() {
burst({ level: 0.55, duration: 0.14, type: 'bandpass', frequency: 2600, Q: 4 });
},
/** Radio traffic: a short burst of static under the text. */
radio() {
burst({ level: 0.09, duration: 0.22, type: 'bandpass', frequency: 1300, Q: 2.5 });
},
/** Board and menu blips. */
ui(kind: 'open' | 'accept') {
const osc = context.createOscillator();
const gain = context.createGain();
const now = context.currentTime;
osc.type = 'square';
osc.frequency.setValueAtTime(kind === 'accept' ? 520 : 380, now);
if (kind === 'accept') osc.frequency.exponentialRampToValueAtTime(780, now + 0.09);
gain.gain.setValueAtTime(0.05, now);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.12);
osc.connect(gain).connect(master);
osc.start(now);
osc.stop(now + 0.14);
},
};
}
export type Audio = ReturnType<typeof createAudio>;

136
src/audio/mix.test.ts Normal file
View File

@ -0,0 +1,136 @@
import { describe, expect, it } from 'vitest';
import { freshCondition, type CarCondition } from '../sim/car';
import { CONTROLS } from '../sim/regions';
import {
ambienceFor,
engineTone,
impactLevel,
roadNoise,
spatial,
windGain,
GUNSHOT_RANGE,
} from './mix';
const worn = (level: number): CarCondition => ({
level: { engine: level, tires: level, chassis: level },
ceiling: { engine: 1, tires: 1, chassis: 1 },
});
describe('engine note', () => {
it('climbs with speed', () => {
const idle = engineTone(0, 0, freshCondition());
const moving = engineTone(6, 1, freshCondition());
expect(moving.frequency).toBeGreaterThan(idle.frequency);
});
it('drops on an upshift instead of rising forever', () => {
// Without gears the engine is a siren wired to the speedometer.
const beforeShift = engineTone(8.9, 1, freshCondition());
const afterShift = engineTone(9.2, 1, freshCondition());
expect(afterShift.gear).toBe(beforeShift.gear + 1);
expect(afterShift.frequency).toBeLessThan(beforeShift.frequency);
});
it('keeps climbing overall, gear after gear', () => {
const first = engineTone(2, 1, freshCondition());
const last = engineTone(34, 1, freshCondition());
expect(last.gear).toBeGreaterThan(first.gear);
});
it('sounds rough and runs out of revs as the engine wears out', () => {
const good = engineTone(30, 1, freshCondition());
const bad = engineTone(30, 1, worn(0.2));
// The car's decline is audible, not just felt through the wheel.
expect(bad.roughness).toBeGreaterThan(good.roughness);
expect(bad.frequency).toBeLessThan(good.frequency);
expect(good.roughness).toBe(0);
});
it('is the same note whichever way the car is pointing', () => {
expect(engineTone(-20, 1, freshCondition()).frequency).toBeCloseTo(
engineTone(20, 1, freshCondition()).frequency,
9,
);
});
});
describe('tyres and wind', () => {
it('is silent with the wheels off the ground', () => {
const airborne = roadNoise(25, 0.5, 0, freshCondition());
expect(airborne.gain).toBe(0);
expect(airborne.squeal).toBe(0);
});
it('gets louder and brighter with speed', () => {
const slow = roadNoise(5, 0, 4, freshCondition());
const fast = roadNoise(30, 0, 4, freshCondition());
expect(fast.gain).toBeGreaterThan(slow.gain);
expect(fast.cutoff).toBeGreaterThan(slow.cutoff);
expect(windGain(30)).toBeGreaterThan(windGain(5));
});
it('stays quiet when the car is just going straight', () => {
expect(roadNoise(25, 0, 4, freshCondition()).squeal).toBe(0);
});
it('squeals sooner on bald tyres than on good ones', () => {
const slip = 0.6;
const good = roadNoise(25, slip, 4, freshCondition()).squeal;
const bald = roadNoise(25, slip, 4, worn(0.15)).squeal;
expect(bald).toBeGreaterThan(good);
});
});
describe('positional sound', () => {
const listener = { x: 0, z: 0, heading: 0 };
it('drops off with distance and cuts out past its range', () => {
const near = spatial(listener, { x: 0, z: 20 }, GUNSHOT_RANGE);
const far = spatial(listener, { x: 0, z: 200 }, GUNSHOT_RANGE);
expect(near.gain).toBeGreaterThan(far.gain);
expect(spatial(listener, { x: 0, z: GUNSHOT_RANGE + 1 }, GUNSHOT_RANGE).gain).toBe(0);
});
it('puts a sound on the correct side', () => {
// Forward is +Z and left is +X, so something at +X should be to the left.
expect(spatial(listener, { x: 40, z: 0 }, 200).pan).toBeLessThan(0);
expect(spatial(listener, { x: -40, z: 0 }, 200).pan).toBeGreaterThan(0);
expect(spatial(listener, { x: 0, z: 40 }, 200).pan).toBeCloseTo(0, 6);
});
it('follows the car round, rather than being fixed to the world', () => {
const source = { x: 40, z: 0 };
const ahead = spatial({ x: 0, z: 0, heading: -Math.PI / 2 }, source, 200);
// Turned to face it, the same sound should now be in front, not beside.
expect(Math.abs(ahead.pan)).toBeLessThan(0.2);
});
it('never pans beyond the speakers', () => {
for (const heading of [0, 1, 2, 3, 4, 5, 6]) {
const { pan } = spatial({ x: 0, z: 0, heading }, { x: 5, z: -5 }, 200);
expect(pan).toBeGreaterThanOrEqual(-1);
expect(pan).toBeLessThanOrEqual(1);
}
});
});
describe('impacts', () => {
it('ignores kerb scrapes and saturates on a real crash', () => {
expect(impactLevel(1000)).toBe(0);
expect(impactLevel(40000)).toBeGreaterThan(0);
expect(impactLevel(40000)).toBeLessThan(1);
expect(impactLevel(500000)).toBe(1);
});
});
describe('ambience', () => {
it('gets lower and heavier the further past the line you are', () => {
const drones = CONTROLS.map((c) => ambienceFor(c));
for (let i = 1; i < drones.length; i++) {
expect(drones[i]!.droneGain).toBeGreaterThan(drones[i - 1]!.droneGain);
expect(drones[i]!.droneFrequency).toBeLessThan(drones[i - 1]!.droneFrequency);
// And the airy top thins out as it does.
expect(drones[i]!.airGain).toBeLessThan(drones[i - 1]!.airGain);
}
});
});

152
src/audio/mix.ts Normal file
View File

@ -0,0 +1,152 @@
/**
* What the game should sound like, as numbers. Pure no Web Audio, no engine.
*
* The audio graph itself cannot be tested outside a browser, so everything that
* involves a decision how engine pitch tracks speed, how a dying engine
* sounds, how far away a gunshot is still worth hearing lives here where it
* can be. src/audio/audio.ts is then only wiring.
*/
import type { CarCondition } from '../sim/car';
import type { Control } from '../sim/regions';
// --- Engine ---------------------------------------------------------------
/** Idle note, in Hz. Low enough to feel like an engine and not a wasp. */
const IDLE_HZ = 38;
const REDLINE_HZ = 150;
/** Metres per second covered in each gear before it shifts up. */
const GEAR_SPAN = 9;
const GEARS = 4;
export interface EngineTone {
/** Fundamental frequency of the engine note. */
frequency: number;
gain: number;
/**
* 0..1 of added noise and instability. This is the car's decline made
* audible: a worn engine does not just go slower, it sounds wrong.
*/
roughness: number;
gear: number;
}
export function engineTone(speed: number, throttle: number, condition: CarCondition): EngineTone {
const fast = Math.abs(speed);
// Gears, so the note climbs and drops rather than rising forever. Without
// this the engine is a siren that tracks the speedometer.
const gear = Math.min(GEARS - 1, Math.floor(fast / GEAR_SPAN));
const withinGear = Math.min(1, (fast - gear * GEAR_SPAN) / GEAR_SPAN);
// A tired engine cannot reach the top of its range any more.
const ceiling = IDLE_HZ + (REDLINE_HZ - IDLE_HZ) * (0.55 + 0.45 * condition.level.engine);
const revs = IDLE_HZ + (ceiling - IDLE_HZ) * withinGear;
return {
// Leaning on the throttle lifts the note a little even at constant speed.
frequency: revs * (1 + 0.06 * Math.abs(throttle)),
gain: 0.09 + 0.06 * Math.abs(throttle) + 0.04 * Math.min(1, fast / 20),
roughness: Math.min(1, (1 - condition.level.engine) * 1.3),
gear,
};
}
// --- Tyres and wind -------------------------------------------------------
export interface RoadNoise {
/** Rolling noise. */
gain: number;
/** Squeal, from the tyres being asked for more grip than they have. */
squeal: number;
/** Cutoff for the rolling noise filter; faster is brighter. */
cutoff: number;
}
export function roadNoise(
speed: number,
sideSlip: number,
wheelsOnGround: number,
condition: CarCondition,
): RoadNoise {
const fast = Math.abs(speed);
const contact = wheelsOnGround / 4;
// Bald tyres let go sooner, so they protest sooner.
const grip = 0.35 + 0.65 * condition.level.tires;
return {
gain: Math.min(0.16, fast * 0.006) * contact,
squeal: Math.min(1, Math.max(0, (sideSlip / grip - 0.25) * 1.6)) * contact,
cutoff: 400 + Math.min(2600, fast * 130),
};
}
export const windGain = (speed: number): number => Math.min(0.1, Math.abs(speed) * 0.0042);
// --- Positional sound -----------------------------------------------------
export interface Spatial {
gain: number;
/** -1 hard left, +1 hard right. */
pan: number;
}
/**
* Gain and stereo position for something happening at a point in the world.
*
* Rolls off with the square of distance rather than linearly: a firefight two
* streets away should be present but not level with one you are parked in.
*/
export function spatial(
listener: { x: number; z: number; heading: number },
source: { x: number; z: number },
range: number,
): Spatial {
const dx = source.x - listener.x;
const dz = source.z - listener.z;
const distance = Math.hypot(dx, dz);
if (distance > range) return { gain: 0, pan: 0 };
const falloff = 1 - distance / range;
// Bearing relative to where the car is pointing, so panning turns with you.
const bearing = Math.atan2(dx, dz) - listener.heading;
return {
gain: falloff * falloff,
// Negated: in this world +X is to the car's *left*, so a positive bearing
// is a sound on the left and belongs in the left speaker.
pan: Math.max(-1, Math.min(1, -Math.sin(bearing))),
};
}
/** How far a gunshot carries. Generous: hearing it is the warning. */
export const GUNSHOT_RANGE = 260;
export const IMPACT_RANGE = 120;
// --- Ambience -------------------------------------------------------------
export interface Ambience {
/** Low drone under everything, tenser the further past the line you are. */
droneGain: number;
droneFrequency: number;
/** Airy top layer, thinner in hostile ground. */
airGain: number;
}
/**
* The tone of the place, matching what the palette does visually: home is open
* and quiet, the frontier is a low uneasy hum.
*/
const AMBIENCE: Record<Control, Ambience> = {
liberated: { droneGain: 0.012, droneFrequency: 55, airGain: 0.03 },
contested: { droneGain: 0.022, droneFrequency: 48, airGain: 0.022 },
occupied: { droneGain: 0.034, droneFrequency: 41, airGain: 0.014 },
frontier: { droneGain: 0.046, droneFrequency: 33, airGain: 0.008 },
};
export const ambienceFor = (control: Control): Ambience => AMBIENCE[control];
// --- One-shots ------------------------------------------------------------
/** Loudness of a collision, from the impact force the physics reported. */
export const impactLevel = (force: number): number =>
Math.min(1, Math.max(0, (force - 3000) / 90000));
/** Impacts below this are kerbs and scrapes, not worth a bang. */
export const IMPACT_FLOOR = 3500;

View File

@ -7,6 +7,8 @@ export interface DriverInput {
respawn: boolean; respawn: boolean;
/** 1-based board selection pressed this frame, or null. Consumed on read. */ /** 1-based board selection pressed this frame, or null. Consumed on read. */
select: number | null; select: number | null;
/** True on the frame M was pressed. Consumed on read. */
toggleMute: boolean;
} }
const KEYS = { const KEYS = {
@ -16,19 +18,32 @@ const KEYS = {
right: ['KeyD', 'ArrowRight'], right: ['KeyD', 'ArrowRight'],
handbrake: ['Space'], handbrake: ['Space'],
respawn: ['KeyR'], respawn: ['KeyR'],
mute: ['KeyM'],
} as const; } as const;
const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4']; const SELECT_KEYS = ['Digit1', 'Digit2', 'Digit3', 'Digit4'];
export function createInput(): { read(): DriverInput; dispose(): void } { export function createInput(): {
read(): DriverInput;
onGesture(callback: () => void): void;
dispose(): void;
} {
const down = new Set<string>(); const down = new Set<string>();
const held = (codes: readonly string[]) => codes.some((c) => down.has(c)); const held = (codes: readonly string[]) => codes.some((c) => down.has(c));
// Buffered rather than polled: a keypress must not be missed just because it // Buffered rather than polled: a keypress must not be missed just because it
// landed between two fixed steps, and must not fire twice if it spans three. // landed between two fixed steps, and must not fire twice if it spans three.
let pendingSelect: number | null = null; let pendingSelect: number | null = null;
let pendingMute = false;
/** Called on the first real interaction, to satisfy autoplay policy. */
let onFirstGesture: (() => void) | null = null;
const onDown = (e: KeyboardEvent) => { const onDown = (e: KeyboardEvent) => {
if (onFirstGesture) {
onFirstGesture();
onFirstGesture = null;
}
if (!down.has(e.code)) { if (!down.has(e.code)) {
if (e.code === 'KeyM') pendingMute = true;
const index = SELECT_KEYS.indexOf(e.code); const index = SELECT_KEYS.indexOf(e.code);
if (index >= 0) pendingSelect = index + 1; if (index >= 0) pendingSelect = index + 1;
} }
@ -41,6 +56,7 @@ export function createInput(): { read(): DriverInput; dispose(): void } {
const onBlur = () => { const onBlur = () => {
down.clear(); down.clear();
pendingSelect = null; pendingSelect = null;
pendingMute = false;
}; };
window.addEventListener('keydown', onDown); window.addEventListener('keydown', onDown);
@ -50,15 +66,33 @@ export function createInput(): { read(): DriverInput; dispose(): void } {
return { return {
read: () => { read: () => {
const select = pendingSelect; const select = pendingSelect;
const toggleMute = pendingMute;
pendingSelect = null; pendingSelect = null;
pendingMute = false;
return { return {
throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0),
steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0),
handbrake: held(KEYS.handbrake), handbrake: held(KEYS.handbrake),
respawn: held(KEYS.respawn), respawn: held(KEYS.respawn),
select, select,
toggleMute,
}; };
}, },
/**
* Browsers will not let anything make noise until the user has interacted,
* so the audio context is resumed from here rather than at boot.
*/
onGesture(callback: () => void) {
onFirstGesture = callback;
window.addEventListener('pointerdown', function once() {
window.removeEventListener('pointerdown', once);
if (onFirstGesture) {
onFirstGesture();
onFirstGesture = null;
}
});
},
dispose() { dispose() {
window.removeEventListener('keydown', onDown); window.removeEventListener('keydown', onDown);
window.removeEventListener('keyup', onUp); window.removeEventListener('keyup', onUp);

View File

@ -41,6 +41,7 @@ import { createScene, updateCamera } from './render/scene';
import { createMarkers } from './render/markers'; import { createMarkers } from './render/markers';
import { createHud } from './ui/hud'; import { createHud } from './ui/hud';
import { createBoard } from './ui/board'; import { createBoard } from './ui/board';
import { createAudio } from './audio/audio';
import { CAR, WHEELS } from './carSpec'; import { CAR, WHEELS } from './carSpec';
/** Debug handle and frame capture, for inspecting a build that cannot be seen. */ /** Debug handle and frame capture, for inspecting a build that cannot be seen. */
@ -48,6 +49,8 @@ export const DEBUG = new URLSearchParams(location.search).has('debug');
/** How far the driver can see well enough to fill in the map, metres. */ /** How far the driver can see well enough to fill in the map, metres. */
const SIGHT_RADIUS = 85; const SIGHT_RADIUS = 85;
/** Seconds R must be held down to wipe the save and start a fresh campaign. */
const RESET_HOLD_SECONDS = 5;
function resolveSeed(): number { function resolveSeed(): number {
const raw = new URLSearchParams(location.search).get('seed'); const raw = new URLSearchParams(location.search).get('seed');
@ -82,6 +85,10 @@ async function boot() {
} }
const input = createInput(); const input = createInput();
// Audio is built now but stays silent until the browser lets it speak, which
// is not until the player has actually touched something.
const audio = createAudio();
input.onGesture(() => audio.resume());
const hud = createHud(seed); const hud = createHud(seed);
const board = createBoard(); const board = createBoard();
const driveState = createDriveState(); const driveState = createDriveState();
@ -154,15 +161,24 @@ async function boot() {
let lastFrontPosition = front.boundaries.liberated; let lastFrontPosition = front.boundaries.liberated;
/** Smoothed, because a single step's movement is far too noisy to react to. */ /** Smoothed, because a single step's movement is far too noisy to react to. */
let driftAverage = 0; let driftAverage = 0;
/** Seconds R has been held, toward a full reset. */
let resetHeld = 0;
const say = (text: string, seconds = 4) => { const say = (text: string, seconds = 4) => {
notice = text; notice = text;
noticeUntil = elapsed + seconds; noticeUntil = elapsed + seconds;
audio.radio();
}; };
const nodeOf = (id: number) => model.roads.nodes[id]!; const nodeOf = (id: number) => model.roads.nodes[id]!;
const controlOf = (x: number, z: number) => controlAt(front, x, z); const controlOf = (x: number, z: number) => controlAt(front, x, z);
/** Car yaw about +Y, measured from world +Z, which is the car's forward. */
const headingOf = (): number => {
const r = physics.chassis.rotation();
return Math.atan2(2 * (r.w * r.y + r.x * r.z), 1 - 2 * (r.y * r.y + r.x * r.x));
};
// --- Persistence --- // --- Persistence ---
const persistence = createPersistence(seed, model.roads.segments.length, intel.explored.length); const persistence = createPersistence(seed, model.roads.segments.length, intel.explored.length);
@ -251,6 +267,19 @@ async function boot() {
elapsed += dt; elapsed += dt;
const cmd = input.read(); const cmd = input.read();
// Tap R to get unstuck; hold it to wipe the campaign and start over.
// A reset has to be hard to do by accident — it throws away every mile
// of wear, every road the enemy has learned, and the whole map you built.
if (cmd.respawn) {
resetHeld += dt;
if (resetHeld >= RESET_HOLD_SECONDS) {
persistence.clear();
location.reload();
return;
}
} else {
resetHeld = 0;
}
if (cmd.respawn && !respawnLatch) physics.respawn(); if (cmd.respawn && !respawnLatch) physics.respawn();
respawnLatch = cmd.respawn; respawnLatch = cmd.respawn;
@ -263,7 +292,10 @@ async function boot() {
const distance = Math.abs(speed) * dt; const distance = Math.abs(speed) * dt;
// Read once: both the car's wear and any cargo aboard are damaged by the // Read once: both the car's wear and any cargo aboard are damaged by the
// same knock, and draining the queue twice would lose one of them. // same knock, and draining the queue twice would lose one of them.
if (cmd.toggleMute) say(audio.toggleMute() ? 'Sound off.' : 'Sound on.', 2);
const lastImpact = physics.drainImpactForce(); const lastImpact = physics.drainImpactForce();
audio.impact(lastImpact);
condition = applyWear(condition, { condition = applyWear(condition, {
dt, dt,
distance, distance,
@ -341,12 +373,14 @@ async function boot() {
seed ^ (base.nodeId * 31) ^ (quests.completed * 7919), seed ^ (base.nodeId * 31) ^ (quests.completed * 7919),
); );
board.show(base, offers); board.show(base, offers);
audio.ui('open');
} }
const chosen = cmd.select === null ? undefined : offers[cmd.select - 1]; const chosen = cmd.select === null ? undefined : offers[cmd.select - 1];
if (chosen) { if (chosen) {
accept(quests, chosen, base); accept(quests, chosen, base);
board.hide(); board.hide();
openBase = null; openBase = null;
audio.ui('accept');
say(`${MISSION_SHAPE[chosen.type].label} accepted.`); say(`${MISSION_SHAPE[chosen.type].label} accepted.`);
// Checkpoint: taking a job is a decision worth not having to make twice. // Checkpoint: taking a job is a decision worth not having to make twice.
persistence.checkpoint(elapsed, snapshot()); persistence.checkpoint(elapsed, snapshot());
@ -497,7 +531,12 @@ async function boot() {
chatterRng, chatterRng,
); );
condition = shooting.condition; condition = shooting.condition;
if (shooting.playerHit && elapsed > noticeUntil) say('Taking fire.', 2.5); const listener = { x: at.x, z: at.z, heading: headingOf() };
for (const muzzle of shooting.fired) audio.shot(muzzle, listener);
if (shooting.playerHit) {
audio.hit();
if (elapsed > noticeUntil) say('Taking fire.', 2.5);
}
// --- Radio --- // --- Radio ---
// Front drift is measured, not narrated: the chatter about the enemy // Front drift is measured, not narrated: the chatter about the enemy
@ -523,6 +562,19 @@ async function boot() {
if (line) say(line, 8); if (line) say(line, 8);
} }
// Continuous audio follows the sim, not the frame rate, so it is driven
// from the fixed step like everything else that has to stay consistent.
const wheels = physics.telemetry();
audio.update({
speed,
throttle: cmd.throttle,
condition,
sideSlip: wheels.sideSlip,
wheelsOnGround: wheels.wheelsOnGround,
control,
listener: { x: at.x, z: at.z, heading: headingOf() },
});
persistence.tick(elapsed, snapshot); persistence.tick(elapsed, snapshot);
// Once the cargo is dropped or the survey is done, the target is no longer // Once the cargo is dropped or the survey is done, the target is no longer
@ -611,6 +663,8 @@ async function boot() {
completed: quests.completed, completed: quests.completed,
parts: quests.parts, parts: quests.parts,
notice: elapsed < noticeUntil ? notice : '', notice: elapsed < noticeUntil ? notice : '',
resetProgress: resetHeld / RESET_HOLD_SECONDS,
muted: audio.muted,
}); });
}, },
}; };

View File

@ -12,6 +12,7 @@ const IDLE: DriverInput = {
handbrake: false, handbrake: false,
respawn: false, respawn: false,
select: null, select: null,
toggleMute: false,
}; };
/** Rapier runs headless, so vehicle tuning is checkable without a browser. */ /** Rapier runs headless, so vehicle tuning is checkable without a browser. */

View File

@ -16,6 +16,14 @@ export interface PhysicsWorld {
/** Static box, added and removed at runtime as road heat rises and falls. */ /** Static box, added and removed at runtime as road heat rises and falls. */
addStaticBox(box: StaticBox): RAPIER.RigidBody; addStaticBox(box: StaticBox): RAPIER.RigidBody;
removeBody(body: RAPIER.RigidBody): void; removeBody(body: RAPIER.RigidBody): void;
/** What the wheels are doing, for anything that needs to react to grip. */
telemetry(): Telemetry;
}
export interface Telemetry {
/** Sum of lateral impulses across the wheels, scaled to roughly 0..1+. */
sideSlip: number;
wheelsOnGround: number;
} }
export interface StaticBox { export interface StaticBox {
@ -139,6 +147,19 @@ export async function createPhysics(model: WorldModel): Promise<PhysicsWorld> {
return v; return v;
}, },
telemetry() {
let sideSlip = 0;
let wheelsOnGround = 0;
for (let i = 0; i < WHEELS.length; i++) {
if (!vehicle.wheelIsInContact(i)) continue;
wheelsOnGround++;
// The lateral impulse the tyre had to generate to hold the line. Scaled
// against the car's weight, so it reads as "how hard is it working".
sideSlip += Math.abs(vehicle.wheelSideImpulse(i) ?? 0);
}
return { sideSlip: sideSlip / (CAR.mass * 0.09), wheelsOnGround };
},
addStaticBox(box) { addStaticBox(box) {
const body = world.createRigidBody( const body = world.createRigidBody(
RAPIER.RigidBodyDesc.fixed() RAPIER.RigidBodyDesc.fixed()

View File

@ -31,9 +31,15 @@ export interface CombatState {
rounds: Round[]; rounds: Round[];
/** Rounds that hit the player since the last read, for feedback. */ /** Rounds that hit the player since the last read, for feedback. */
playerHits: number; playerHits: number;
/** Muzzle positions this step, drained by the caller. */
firedThisStep: Array<{ x: number; z: number }>;
} }
export const createCombat = (): CombatState => ({ rounds: [], playerHits: 0 }); export const createCombat = (): CombatState => ({
rounds: [],
playerHits: 0,
firedThisStep: [],
});
// --- Tuning --------------------------------------------------------------- // --- Tuning ---------------------------------------------------------------
@ -70,6 +76,7 @@ export interface CombatStep {
} }
function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng: Rng): void { function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng: Rng): void {
state.firedThisStep.push({ x: from.x, z: from.z });
const dx = at.x - from.x; const dx = at.x - from.x;
const dz = at.z - from.z; const dz = at.z - from.z;
const bearing = Math.atan2(dx, dz) + (rng() - 0.5) * 2 * SPREAD; const bearing = Math.atan2(dx, dz) + (rng() - 0.5) * 2 * SPREAD;
@ -87,9 +94,10 @@ function fire(state: CombatState, from: Unit, at: { x: number; z: number }, rng:
} }
export interface CombatResult { export interface CombatResult {
/** Damage dealt to the player's car this step, if any. */
playerHit: boolean; playerHit: boolean;
condition: CarCondition; condition: CarCondition;
/** Where shots were fired from this step, so they can be heard. */
fired: Array<{ x: number; z: number }>;
} }
export function stepCombat( export function stepCombat(
@ -102,6 +110,7 @@ export function stepCombat(
const { dt } = step; const { dt } = step;
let playerHit = false; let playerHit = false;
let updated = condition; let updated = condition;
state.firedThisStep = [];
// --- Who pulls a trigger --- // --- Who pulls a trigger ---
// Reload is counted here rather than in stepUnits: firing is this module's // Reload is counted here rather than in stepUnits: firing is this module's
@ -186,7 +195,7 @@ export function stepCombat(
} }
state.rounds = living; state.rounds = living;
return { playerHit, condition: updated }; return { playerHit, condition: updated, fired: state.firedThisStep };
} }
/** /**

View File

@ -42,6 +42,9 @@ export interface HudModel {
completed: number; completed: number;
parts: number; parts: number;
notice: string; notice: string;
/** 0..1 toward wiping the campaign, while R is held. */
resetProgress: number;
muted: boolean;
} }
/** Eight-point compass, starting at "straight ahead" and turning left. */ /** Eight-point compass, starting at "straight ahead" and turning left. */
@ -112,8 +115,12 @@ export function createHud(seed: number) {
`[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`, `[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`,
'', '',
`seed ${seed}`, `seed ${seed}`,
'WASD drive · space handbrake · R respawn', `WASD drive · space handbrake · R respawn · M sound ${model.muted ? 'off' : 'on'}`,
'hold R to reset the campaign',
]; ];
if (model.resetProgress > 0.06) {
lines.push('', `RESETTING ${bar(Math.min(1, model.resetProgress))} keep holding R`);
}
if (model.notice) lines.push('', `» ${model.notice}`); if (model.notice) lines.push('', `» ${model.notice}`);
el.textContent = lines.join('\n'); el.textContent = lines.join('\n');
}, },