diff --git a/README.md b/README.md index 3be302a..5be5c00 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Endless driving survival game. - **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 6** — an inhabited world: traffic, people, patrols, and a live war. +- **Phase 7** — sound, synthesised from the simulation. ## Running @@ -26,6 +27,8 @@ seeds may be numbers or words. | `Space` | handbrake (rear wheels only) | | `R` | respawn the car — **does not** repair it | | `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 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 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 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 core/ fixed-timestep loop, seeded RNG, keyboard 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 debug.ts ?debug=1 handle: step, render and capture frames to shots/ carSpec.ts shared car dimensions, so body and mesh cannot drift apart diff --git a/src/audio/audio.ts b/src/audio/audio.ts new file mode 100644 index 0000000..f48091b --- /dev/null +++ b/src/audio/audio.ts @@ -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; diff --git a/src/audio/mix.test.ts b/src/audio/mix.test.ts new file mode 100644 index 0000000..6cabc0a --- /dev/null +++ b/src/audio/mix.test.ts @@ -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); + } + }); +}); diff --git a/src/audio/mix.ts b/src/audio/mix.ts new file mode 100644 index 0000000..4f07dd8 --- /dev/null +++ b/src/audio/mix.ts @@ -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 = { + 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; diff --git a/src/core/input.ts b/src/core/input.ts index d2d233a..16e4efc 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -7,6 +7,8 @@ export interface DriverInput { respawn: boolean; /** 1-based board selection pressed this frame, or null. Consumed on read. */ select: number | null; + /** True on the frame M was pressed. Consumed on read. */ + toggleMute: boolean; } const KEYS = { @@ -16,19 +18,32 @@ const KEYS = { right: ['KeyD', 'ArrowRight'], handbrake: ['Space'], respawn: ['KeyR'], + mute: ['KeyM'], } as const; 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(); const held = (codes: readonly string[]) => codes.some((c) => down.has(c)); // 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. 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) => { + if (onFirstGesture) { + onFirstGesture(); + onFirstGesture = null; + } if (!down.has(e.code)) { + if (e.code === 'KeyM') pendingMute = true; const index = SELECT_KEYS.indexOf(e.code); if (index >= 0) pendingSelect = index + 1; } @@ -41,6 +56,7 @@ export function createInput(): { read(): DriverInput; dispose(): void } { const onBlur = () => { down.clear(); pendingSelect = null; + pendingMute = false; }; window.addEventListener('keydown', onDown); @@ -50,15 +66,33 @@ export function createInput(): { read(): DriverInput; dispose(): void } { return { read: () => { const select = pendingSelect; + const toggleMute = pendingMute; pendingSelect = null; + pendingMute = false; return { throttle: (held(KEYS.forward) ? 1 : 0) - (held(KEYS.back) ? 1 : 0), steer: (held(KEYS.left) ? 1 : 0) - (held(KEYS.right) ? 1 : 0), handbrake: held(KEYS.handbrake), respawn: held(KEYS.respawn), 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() { window.removeEventListener('keydown', onDown); window.removeEventListener('keyup', onUp); diff --git a/src/main.ts b/src/main.ts index 9046872..e9df8ae 100644 --- a/src/main.ts +++ b/src/main.ts @@ -41,6 +41,7 @@ import { createScene, updateCamera } from './render/scene'; import { createMarkers } from './render/markers'; import { createHud } from './ui/hud'; import { createBoard } from './ui/board'; +import { createAudio } from './audio/audio'; import { CAR, WHEELS } from './carSpec'; /** 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. */ 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 { const raw = new URLSearchParams(location.search).get('seed'); @@ -82,6 +85,10 @@ async function boot() { } 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 board = createBoard(); const driveState = createDriveState(); @@ -154,15 +161,24 @@ async function boot() { let lastFrontPosition = front.boundaries.liberated; /** Smoothed, because a single step's movement is far too noisy to react to. */ let driftAverage = 0; + /** Seconds R has been held, toward a full reset. */ + let resetHeld = 0; const say = (text: string, seconds = 4) => { notice = text; noticeUntil = elapsed + seconds; + audio.radio(); }; const nodeOf = (id: number) => model.roads.nodes[id]!; 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 --- const persistence = createPersistence(seed, model.roads.segments.length, intel.explored.length); @@ -251,6 +267,19 @@ async function boot() { elapsed += dt; 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(); respawnLatch = cmd.respawn; @@ -263,7 +292,10 @@ async function boot() { const distance = Math.abs(speed) * dt; // 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. + if (cmd.toggleMute) say(audio.toggleMute() ? 'Sound off.' : 'Sound on.', 2); + const lastImpact = physics.drainImpactForce(); + audio.impact(lastImpact); condition = applyWear(condition, { dt, distance, @@ -341,12 +373,14 @@ async function boot() { seed ^ (base.nodeId * 31) ^ (quests.completed * 7919), ); board.show(base, offers); + audio.ui('open'); } const chosen = cmd.select === null ? undefined : offers[cmd.select - 1]; if (chosen) { accept(quests, chosen, base); board.hide(); openBase = null; + audio.ui('accept'); say(`${MISSION_SHAPE[chosen.type].label} accepted.`); // Checkpoint: taking a job is a decision worth not having to make twice. persistence.checkpoint(elapsed, snapshot()); @@ -497,7 +531,12 @@ async function boot() { chatterRng, ); 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 --- // Front drift is measured, not narrated: the chatter about the enemy @@ -523,6 +562,19 @@ async function boot() { 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); // 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, parts: quests.parts, notice: elapsed < noticeUntil ? notice : '', + resetProgress: resetHeld / RESET_HOLD_SECONDS, + muted: audio.muted, }); }, }; diff --git a/src/physics/physics.test.ts b/src/physics/physics.test.ts index 974fd44..fc51619 100644 --- a/src/physics/physics.test.ts +++ b/src/physics/physics.test.ts @@ -12,6 +12,7 @@ const IDLE: DriverInput = { handbrake: false, respawn: false, select: null, + toggleMute: false, }; /** Rapier runs headless, so vehicle tuning is checkable without a browser. */ diff --git a/src/physics/physics.ts b/src/physics/physics.ts index f66ad31..730199d 100644 --- a/src/physics/physics.ts +++ b/src/physics/physics.ts @@ -16,6 +16,14 @@ export interface PhysicsWorld { /** Static box, added and removed at runtime as road heat rises and falls. */ addStaticBox(box: StaticBox): RAPIER.RigidBody; 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 { @@ -139,6 +147,19 @@ export async function createPhysics(model: WorldModel): Promise { 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) { const body = world.createRigidBody( RAPIER.RigidBodyDesc.fixed() diff --git a/src/sim/combat.ts b/src/sim/combat.ts index 61e8551..52475dd 100644 --- a/src/sim/combat.ts +++ b/src/sim/combat.ts @@ -31,9 +31,15 @@ export interface CombatState { rounds: Round[]; /** Rounds that hit the player since the last read, for feedback. */ 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 --------------------------------------------------------------- @@ -70,6 +76,7 @@ export interface CombatStep { } 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 dz = at.z - from.z; 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 { - /** Damage dealt to the player's car this step, if any. */ playerHit: boolean; condition: CarCondition; + /** Where shots were fired from this step, so they can be heard. */ + fired: Array<{ x: number; z: number }>; } export function stepCombat( @@ -102,6 +110,7 @@ export function stepCombat( const { dt } = step; let playerHit = false; let updated = condition; + state.firedThisStep = []; // --- Who pulls a trigger --- // Reload is counted here rather than in stepUnits: firing is this module's @@ -186,7 +195,7 @@ export function stepCombat( } state.rounds = living; - return { playerHit, condition: updated }; + return { playerHit, condition: updated, fired: state.firedThisStep }; } /** diff --git a/src/ui/hud.ts b/src/ui/hud.ts index b9a25de..0775f28 100644 --- a/src/ui/hud.ts +++ b/src/ui/hud.ts @@ -42,6 +42,9 @@ export interface HudModel { completed: number; parts: number; 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. */ @@ -112,8 +115,12 @@ export function createHud(seed: number) { `[debug] heat ${bar(model.heat.value)} ${model.heat.level ?? '—'}`, '', `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}`); el.textContent = lines.join('\n'); },