diff --git a/src/App.css b/src/App.css index 3e13e98..083bd2e 100644 --- a/src/App.css +++ b/src/App.css @@ -17,7 +17,15 @@ padding: clamp(10px, 2vw, 22px); } -header { grid-area: head; } +/* The title on the left, the sound on the right, the state of play under + both -- so the toggle is findable without taking a line of its own. */ +header { + grid-area: head; + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + column-gap: 12px; +} h1 { margin: 0; @@ -29,7 +37,21 @@ h1 { paint-order: stroke fill; } -header p { margin: 2px 0 0; font-size: 0.9rem; } +header p { grid-column: 1 / -1; margin: 2px 0 0; font-size: 0.9rem; } + +.sound { + font: inherit; + font-weight: 700; + font-size: 0.85rem; + color: #f2e9d8; + background: #2c3b57; + border: 2px solid #2b2318; + border-radius: 6px; + padding: 3px 10px; + cursor: pointer; +} + +.sound[aria-pressed='false'] { color: #8c98ab; background: #1b2437; } header select { font: inherit; diff --git a/src/App.tsx b/src/App.tsx index 57b196e..a7bfd86 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { playCues, playEnding } from './audio/play' +import { ARMISTICE, THE_FRONT, THE_PUSH } from './audio/score' +import { synth } from './audio/synth' import { Board, COLOURS, POWER_NAMES } from './components/Board' import { BuildPanel, RetreatPanel } from './components/AdjustPanel' import { OrderPanel, type Step } from './components/OrderPanel' @@ -18,6 +21,7 @@ import { reachableFrom } from './game/layout' import { POWERS, PROVINCES, base, type Power } from './game/map' import { canStep, validate, type Order, type Unit } from './game/orders' import type { Proposal } from './game/press' +import { endingSounded, mergeCues, type Cue } from './game/sound' import { adjustmentFor, centreCount, type AdjustOrder, type RetreatOrder } from './game/turn' import './App.css' @@ -36,6 +40,48 @@ export default function App() { const [orders, setOrders] = useState>(new Map()) const [retreats, setRetreats] = useState>(new Map()) const [builds, setBuilds] = useState([]) + const [sound, setSound] = useState(true) + + // ------------------------------------------------------------ audio glue + + /* + * A browser will not make a noise until somebody has touched the page, so + * the context is opened on the first click rather than on load. Everything + * before that is silent whatever the toggle says, which is the rule and + * not a bug. + */ + const started = useRef(false) + + /* + * Which piece is playing is not decoration: spring is the march, autumn is + * the same march at a hundred and thirty-two, and autumn is the season + * that counts centres. You can hear what time of year it is. + */ + const tune = game.phase === 'over' ? ARMISTICE : game.season === 'spring' ? THE_FRONT : THE_PUSH + + const wake = useCallback(() => { + if (started.current || !sound) return + started.current = true + synth.ensure() + synth.playTune(tune, false) + }, [sound, tune]) + + useEffect(() => { + synth.setMusic(sound) + synth.setSfx(sound) + }, [sound]) + + useEffect(() => { + if (started.current) synth.playTune(tune, false) + }, [tune]) + + /** The ending, once, when there is one. */ + const ended = useRef(false) + useEffect(() => { + if (game.phase !== 'over' || ended.current) return + ended.current = true + playEnding(endingSounded(game.winner, game.out, power)) + }, [game.phase, game.winner, game.out, power]) /* * The talking happens once per orders phase. Keeping a note of which turn @@ -89,9 +135,14 @@ export default function App() { const write = useCallback((order: Order) => { setOrders((prev) => new Map(prev).set(base(order.at), order)) setStep({ kind: 'idle' }) + if (synth.sfxOn) synth.written() }, []) const click = (province: string) => { + wake() + // Guarded rather than left to the muted bus, so a game played with the + // sound off never opens an audio context at all. + if (synth.sfxOn) synth.tap() const here = units.get(province) if (step.kind === 'idle' || !offering.has(province)) { if (here?.power === power) setStep({ kind: 'move', at: province }) @@ -123,6 +174,7 @@ export default function App() { /** Yes or no to somebody's approach. Neither answer binds anybody. */ const answer = (overture: Overture, yes: boolean) => { + if (synth.sfxOn) synth.telegraph(yes) setAsked((prev) => prev.filter((o) => o.proposal.id !== overture.proposal.id)) if (yes) setGame((g) => ({ ...g, agreements: [...g.agreements, overture.proposal] })) } @@ -138,6 +190,7 @@ export default function App() { } const theirs = { power: to, ledger: game.ledger, agreements: game.agreements } const reply = consider(pos, theirs, proposal, turnOf(game)) + if (synth.sfxOn) synth.telegraph(reply.reply === 'accept') if (reply.reply === 'accept') { setGame((g) => ({ ...g, agreements: [...g.agreements, proposal] })) } @@ -155,27 +208,38 @@ export default function App() { const advance = useCallback( (from: Game): Game => { let g = from + /* + * Each phase reports its own sounds, so a submission that runs through + * three of them would otherwise arrive with only the last one's. They + * are collected here and played together. + */ + let heard: Cue[] = g.sounds for (;;) { if (g.phase === 'retreats') { const mineBeaten = [...(g.outcome?.dislodged.values() ?? [])].some( (d) => d.unit.power === power, ) - if (mineBeaten) return g + if (mineBeaten) break g = resolveRetreats(g, power, []) + heard = mergeCues(heard, g.sounds) continue } if (g.phase === 'builds') { - if (adjustmentFor(g.own, g.board, power) !== 0) return g + if (adjustmentFor(g.own, g.board, power) !== 0) break g = resolveBuilds(g, power, []) + heard = mergeCues(heard, g.sounds) continue } - return g + break } + playCues(heard) + return g }, [power], ) const submit = () => { + wake() setOrders(new Map()) setStep({ kind: 'idle' }) setAsked([]) @@ -199,6 +263,18 @@ export default function App() {

Great Powers

+

{game.phase === 'over' ? game.winner diff --git a/src/audio/play.ts b/src/audio/play.ts new file mode 100644 index 0000000..f915418 --- /dev/null +++ b/src/audio/play.ts @@ -0,0 +1,35 @@ +import type { Cue, Ending } from '../game/sound' +import { synth } from './synth' + +/** + * Cues to actual noise, spaced out. + * + * A turn's sounds all land at once if you let them, and three of them at once + * is one indistinct crash. So each gets its own moment. The gaps are the + * lengths of the sounds themselves, near enough -- the guns run about a + * second, and the escort a little longer. + */ +const GAP = 900 + +export function playCues(cues: readonly Cue[]) { + if (!synth.sfxOn) return + for (const [i, cue] of cues.entries()) { + window.setTimeout(() => { + if (!synth.sfxOn) return + if (cue === 'ground') synth.ground() + else if (cue === 'naval') synth.naval() + else if (cue === 'escort') synth.escort() + else if (cue === 'retreat') synth.retreat() + else if (cue === 'disband') synth.disband() + else synth.build() + }, i * GAP) + } +} + +export function playEnding(ending: Ending) { + if (!synth.sfxOn) return + if (ending === 'victory') synth.victory() + else if (ending === 'defeat') synth.defeat() + else if (ending === 'eliminated') synth.eliminated() + else synth.armistice() +} diff --git a/src/audio/score.test.ts b/src/audio/score.test.ts new file mode 100644 index 0000000..331965d --- /dev/null +++ b/src/audio/score.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { ARMISTICE, THE_FRONT, THE_PUSH } from './score' +import { noteToFreq, type Tune } from './synth' + +/** + * The music cannot be listened to by a test, but it can be proved to be + * playable, which is the half that fails silently. + * + * A mistyped note name does not throw: `noteToFreq` returns 0, the oscillator + * is set to 0 Hz, and that voice is simply not there for the rest of the + * piece. You would have to notice a missing inner part by ear, in a tune you + * have heard forty times while testing something else. So every step of every + * track is checked here instead. + * + * The key is checked too, which is not pedantry: three pieces meant to be the + * same war have to agree about what note the war is in, and a B natural typed + * for a B flat would be a wrong note nobody would trace back to a keyboard. + */ + +const KIT = new Set(['K', 'S', 'C', 'H', 'O']) +const TUNES: [string, Tune][] = [ + ['THE_FRONT', THE_FRONT], + ['THE_PUSH', THE_PUSH], + ['ARMISTICE', ARMISTICE], +] + +/** D minor, with the raised leading note and the picardy third allowed. */ +const IN_KEY = new Set(['D', 'E', 'F', 'G', 'A', 'A#', 'C', 'C#', 'F#']) + +describe.each(TUNES)('%s', (_name, tune) => { + it('names a note the synth can find, on every step of every track', () => { + for (const track of tune.tracks) { + for (const step of track.notes) { + if (step === '.' || step === '=') continue + if (track.wave === 'noise') { + expect(KIT).toContain(step) + continue + } + // Chords are stacked with slashes; every voice in one has to parse. + for (const note of step.split('/')) { + expect(noteToFreq(note), `${note} in ${step}`).toBeGreaterThan(0) + } + } + } + }) + + it('stays in D minor', () => { + for (const track of tune.tracks) { + if (track.wave === 'noise') continue + for (const step of track.notes) { + if (step === '.' || step === '=') continue + for (const note of step.split('/')) { + expect(IN_KEY, note).toContain(note.replace(/-?\d$/, '')) + } + } + } + }) + + it('is the same length on every track, so the loop does not drift', () => { + const lengths = new Set(tune.tracks.map((t) => t.notes.length)) + expect(lengths.size).toBe(1) + }) + + it('starts on a note rather than a tie', () => { + // '=' sustains the previous step, and there is no previous step at zero. + for (const track of tune.tracks) expect(track.notes[0]).not.toBe('=') + }) +}) + +it('marches faster in autumn than in spring', () => { + // Not decoration: which piece is playing is how the year is counted. + expect(THE_PUSH.bpm).toBeGreaterThan(THE_FRONT.bpm) + expect(ARMISTICE.bpm).toBeLessThan(THE_FRONT.bpm) +}) diff --git a/src/audio/score.ts b/src/audio/score.ts new file mode 100644 index 0000000..d4ae1f8 --- /dev/null +++ b/src/audio/score.ts @@ -0,0 +1,267 @@ +import type { Tune } from './synth' + +/** + * The score, and what it is and is not. + * + * It is not a quotation. There is a great deal of music from and about that + * war and a fair amount of it is still in copyright somewhere, and it would + * be a strange sort of care to build the map from a licensed source, credit + * it properly, and then lift eight bars of somebody's march. + * + * What it *is* is the idiom, which belongs to nobody: a minor key, dotted + * rhythms, low brass moving in whole bars, and a side drum that never stops. + * That is how music has meant "an army is moving" since long before 1914 -- + * it is Beethoven's funeral marches before it is anybody's, and the dominant + * chord borrowed from the harmonic minor, which does most of the work here, + * is older than that again. Nobody owns a raised leading note. + * + * D minor throughout, so the three pieces are the same war. The instruments + * are the six voices the sibling games use, leant on differently: + * + * - **brass** is a detuned saw with the filter closing across each note; + * - **the band** is a quiet pulse-50 holding actual chords, one a bar; + * - **the bass** is a triangle, low, doubling the root; + * - **the drums** are the kit's kick as a bass drum and its snare as a + * side drum, on separate tracks because they play at once. + * + * There are three, and which one you are hearing tells you something: + * + * - `THE_FRONT` in spring, when the armies move and nothing is decided; + * - `THE_PUSH` in autumn, when centres change hands and the year is + * counted -- same key, twice the pulse, and the drum never leaves; + * - `ARMISTICE` when it is over, whoever won. + */ + +const bar = (...steps: string[]) => steps + +/** A chord held for a whole bar. */ +const held = (chord: string) => [chord, '=', '=', '=', '=', '=', '=', '='] + +/** The bass, doing the same. */ +const pedal = held + +const BRASS = { + wave: 'saw', + gain: 0.12, + gate: 0.94, + detune: 6, + filter: { from: 1600, to: 850, q: 1.3 }, +} as const + +const BAND = { wave: 'pulse50', gain: 0.045, gate: 1 } as const +const BASS = { wave: 'triangle', gain: 0.24, gate: 1 } as const + +/** + * Spring. + * + * Eight bars. It opens on the dotted figure that is the whole march -- a + * long D, a short E, a long F -- and climbs that same shape twice, the + * second time from a fourth higher so it can reach the top D and stop. + * + * The third bar is where it stops being a parade: the band goes to A major + * rather than A minor, which is a note that does not belong to the key and + * is the reason the bar sounds like a warning. + */ +export const THE_FRONT: Tune = { + bpm: 100, + stepsPerBeat: 2, + tracks: [ + { + ...BRASS, + notes: [ + ...bar('D4', '=', '=', 'E4', 'F4', '=', '=', '='), + ...bar('E4', '=', '=', 'F4', 'G4', '=', '=', '='), + ...bar('A4', '=', '=', '=', 'G4', '=', 'F4', '='), + ...bar('E4', '=', '=', '=', '=', '=', '.', '.'), + ...bar('A4', '=', '=', 'A#4', 'A4', '=', '=', '='), + ...bar('G4', '=', '=', 'A4', 'A#4', '=', '=', '='), + ...bar('C5', '=', '=', '=', 'A#4', '=', 'A4', '='), + ...bar('D5', '=', '=', '=', '=', '=', '=', '.'), + ], + }, + { + ...BAND, + notes: [ + ...held('D3/F3/A3'), + ...held('D3/F3/A3'), + ...held('A2/C#3/E3'), + ...held('D3/F3/A3'), + ...held('D3/F3/A3'), + ...held('A#2/D3/F3'), + ...held('A2/C#3/E3'), + ...held('D3/F3/A3'), + ], + }, + { + ...BASS, + notes: [ + ...pedal('D2'), + ...pedal('D2'), + ...pedal('A1'), + ...pedal('D2'), + ...pedal('D2'), + ...pedal('A#1'), + ...pedal('A1'), + ...pedal('D2'), + ], + }, + { + // The side drum. The same four-bar figure twice, opening out at the + // end of each half -- a drummer marks the turn, he does not vamp. + wave: 'noise', + gain: 0.16, + notes: [ + ...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'), + ...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'), + ...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'), + ...bar('S', 'S', 'S', 'S', 'S', '.', 'S', '.'), + ...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'), + ...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'), + ...bar('S', '.', 'S', 'S', '.', 'S', '.', '.'), + ...bar('S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'), + ], + }, + { + // The bass drum, on the tread. + wave: 'noise', + gain: 0.3, + notes: Array.from({ length: 64 }, (_, i) => (i % 4 === 0 ? 'K' : '.')), + }, + ], +} + +/** + * Autumn. + * + * The same key and the same drum, at a hundred and thirty-two, with the bass + * on every quaver instead of holding. Four bars rather than eight, because + * the point of it is that it comes round again too soon. + * + * The melody hammers one note before it moves, which is the cheapest way + * music has of sounding out of patience, and the last bar is the dominant + * seventh left hanging -- so the loop does not settle, it restarts. + */ +export const THE_PUSH: Tune = { + bpm: 132, + stepsPerBeat: 2, + tracks: [ + { + ...BRASS, + gain: 0.13, + notes: [ + ...bar('D5', 'D5', 'D5', '=', 'C#5', '=', 'D5', '='), + ...bar('F5', '=', 'E5', '=', 'D5', '=', 'C#5', '='), + ...bar('D5', 'D5', 'D5', '=', 'E5', '=', 'F5', '='), + ...bar('A5', '=', '=', '=', 'G5', '=', 'F5', 'E5'), + ], + }, + { + ...BAND, + gain: 0.05, + notes: [ + ...held('D3/F3/A3'), + ...held('A2/C#3/E3'), + ...held('D3/F3/A3'), + ...held('A2/C#3/G3'), + ], + }, + { + ...BASS, + gate: 0.6, + notes: [ + ...bar('D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2'), + ...bar('A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1'), + ...bar('D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2', 'D2'), + ...bar('A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1', 'A1'), + ], + }, + { + wave: 'noise', + gain: 0.17, + notes: [ + ...bar('S', '.', 'S', '.', 'S', '.', 'S', 'S'), + ...bar('S', '.', 'S', '.', 'S', '.', 'S', 'S'), + ...bar('S', '.', 'S', '.', 'S', '.', 'S', 'S'), + ...bar('S', 'S', 'S', 'S', 'S', 'S', 'S', 'S'), + ], + }, + { + wave: 'noise', + gain: 0.32, + notes: Array.from({ length: 32 }, (_, i) => (i % 2 === 0 ? 'K' : '.')), + }, + ], +} + +/** + * The end of it, whoever won. + * + * Eight bars at sixty, one line over the band, and no drum after the fourth + * bar -- which is the sound the piece is actually about. It turns to D major + * in the seventh, which is a device four hundred years old and still the + * only way music has of saying that something ended without saying it ended + * well. + */ +export const ARMISTICE: Tune = { + bpm: 60, + stepsPerBeat: 2, + tracks: [ + { + ...BRASS, + gain: 0.11, + filter: { from: 1200, to: 700, q: 1.1 }, + notes: [ + ...bar('A4', '=', '=', '=', 'G4', '=', 'F4', '='), + ...bar('E4', '=', '=', '=', '=', '=', '=', '.'), + ...bar('F4', '=', '=', '=', 'G4', '=', 'A4', '='), + ...bar('A#4', '=', '=', '=', '=', '=', '=', '.'), + ...bar('A4', '=', '=', '=', 'G4', '=', 'F4', '='), + ...bar('E4', '=', '=', '=', 'D4', '=', '=', '='), + ...bar('F#4', '=', '=', '=', 'A4', '=', '=', '='), + ...bar('D5', '=', '=', '=', '=', '=', '=', '='), + ], + }, + { + ...BAND, + notes: [ + ...held('D3/F3/A3'), + ...held('A2/C#3/E3'), + ...held('D3/F3/A3'), + ...held('A#2/D3/F3'), + ...held('D3/F3/A3'), + ...held('A2/C#3/E3'), + ...held('D3/F#3/A3'), + ...held('D3/F#3/A3'), + ], + }, + { + ...BASS, + gain: 0.2, + notes: [ + ...pedal('D2'), + ...pedal('A1'), + ...pedal('D2'), + ...pedal('A#1'), + ...pedal('D2'), + ...pedal('A1'), + ...pedal('D2'), + ...pedal('D2'), + ], + }, + { + // The drum walks out. Four bars of it, then thirty-two rests. + wave: 'noise', + gain: 0.22, + notes: [ + ...bar('K', '.', '.', '.', 'K', '.', '.', '.'), + ...bar('K', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', '.', '.', '.', '.', '.', '.'), + ...bar('K', '.', '.', '.', '.', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ...bar('.', '.', '.', '.', '.', '.', '.', '.'), + ], + }, + ], +} diff --git a/src/audio/synth.ts b/src/audio/synth.ts new file mode 100644 index 0000000..b3dc242 --- /dev/null +++ b/src/audio/synth.ts @@ -0,0 +1,725 @@ +/** + * The synth, shared with its siblings. + * + * The engine below -- the scheduler, the six voices and the small kit -- is + * the one written for the lemonade stand and carried through the cave game + * and the starship. Same author, same licence, and deliberately not forked: a + * look-ahead scheduler is not the part of a game worth writing four times. + * + * What is written *for this game* is everything under "sfx", and the score in + * `score.ts`. A war needs guns, and the difference between a field gun and a + * naval gun is most of what makes a map of Europe sound like 1914. + * + * Pulse waves for voices, filtered white noise for percussion, and the + * scheduler so patterns stay in time even when React is busy re-rendering. + */ + +const NOTE_INDEX: Record = { + C: 0, 'C#': 1, D: 2, 'D#': 3, E: 4, F: 5, + 'F#': 6, G: 7, 'G#': 8, A: 9, 'A#': 10, B: 11, +} + +/** "C#4" -> Hz. A4 = 440. */ +export function noteToFreq(note: string): number { + const m = /^([A-G]#?)(-?\d)$/.exec(note) + if (!m) return 0 + const semis = NOTE_INDEX[m[1]] + (Number(m[2]) + 1) * 12 + return 440 * Math.pow(2, (semis - 69) / 12) +} + +export type Wave = 'pulse12' | 'pulse25' | 'pulse50' | 'triangle' | 'saw' | 'noise' + +export interface Track { + wave: Wave + gain: number + /** + * One entry per step. '.' rest, '=' sustain previous, otherwise a note. + * Slashes stack notes into a chord: "F4/A4/C5". On a noise track the + * letter picks the drum: K kick, S snare, C clap, H closed hat, O open hat. + */ + notes: string[] + /** Sweeping lowpass, the whole point of a funk bass. */ + filter?: { from: number; to: number; q?: number } + /** Fraction of the note's length actually sounded; low values are stabs. */ + gate?: number + /** Cents, for a fatter unison. */ + detune?: number +} + +export interface Tune { + bpm: number + stepsPerBeat: number + tracks: Track[] + /** 0 is straight, ~0.15 is a light funk shuffle. Delays every other step. */ + swing?: number +} + +/** Fourier series for a pulse wave of the given duty cycle. */ +function pulseWave(ctx: AudioContext, duty: number, harmonics = 24): PeriodicWave { + const real = new Float32Array(harmonics + 1) + const imag = new Float32Array(harmonics + 1) + for (let n = 1; n <= harmonics; n++) { + imag[n] = (2 / (n * Math.PI)) * Math.sin(n * Math.PI * duty) + } + return ctx.createPeriodicWave(real, imag, { disableNormalization: false }) +} + +/** + * The dial-in, as a schedule rather than a number buried in the audio. + * + * The boot screen prints what is happening while it happens, so both have to + * agree about when each stage starts -- and the only way to keep two clocks in + * step is to have one clock. All values are seconds from the moment of DIAL. + */ + +export class Synth { + private ctx: AudioContext | null = null + private master!: GainNode + private musicBus!: GainNode + private sfxBus!: GainNode + private waves: Partial> = {} + private noiseBuffer!: AudioBuffer + + private tune: Tune | null = null + private step = 0 + private nextStepTime = 0 + private timer: number | null = null + + musicOn = true + sfxOn = true + + /** Must be called from a user gesture the first time. */ + ensure(): AudioContext { + if (this.ctx) { + if (this.ctx.state === 'suspended') void this.ctx.resume() + return this.ctx + } + const ctx = new AudioContext() + this.ctx = ctx + this.master = ctx.createGain() + this.master.gain.value = 0.5 + this.master.connect(ctx.destination) + + /* + * The buses open at whatever the toggles already say, not at full. + * A one-shot calls `ensure` itself, so a game started with the sound + * turned off would otherwise make exactly one noise -- the first one -- + * before anything got round to muting it. + */ + this.musicBus = ctx.createGain() + this.musicBus.gain.value = this.musicOn ? 0.55 : 0 + this.musicBus.connect(this.master) + + this.sfxBus = ctx.createGain() + this.sfxBus.gain.value = this.sfxOn ? 0.9 : 0 + this.sfxBus.connect(this.master) + + this.waves.pulse12 = pulseWave(ctx, 0.125) + this.waves.pulse25 = pulseWave(ctx, 0.25) + this.waves.pulse50 = pulseWave(ctx, 0.5) + + const len = Math.floor(ctx.sampleRate * 1.5) + const buf = ctx.createBuffer(1, len, ctx.sampleRate) + const data = buf.getChannelData(0) + for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1 + this.noiseBuffer = buf + + return ctx + } + + setMusic(on: boolean) { + this.musicOn = on + if (!this.ctx) return + this.musicBus.gain.setTargetAtTime(on ? 0.55 : 0, this.ctx.currentTime, 0.05) + } + + setSfx(on: boolean) { + this.sfxOn = on + if (!this.ctx) return + this.sfxBus.gain.setTargetAtTime(on ? 0.9 : 0, this.ctx.currentTime, 0.02) + } + + // ---------------------------------------------------------------- voices + + private voice( + dest: AudioNode, + wave: Wave, + freq: number, + at: number, + dur: number, + gain: number, + opts: { filter?: Track['filter']; detune?: number } = {}, + ) { + const ctx = this.ensure() + const osc = ctx.createOscillator() + if (wave === 'triangle') osc.type = 'triangle' + else if (wave === 'saw') osc.type = 'sawtooth' + else osc.setPeriodicWave(this.waves[wave] ?? this.waves.pulse50!) + osc.frequency.setValueAtTime(freq, at) + if (opts.detune) osc.detune.setValueAtTime(opts.detune, at) + + const env = ctx.createGain() + const peak = Math.max(0.0001, gain) + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(peak, at + 0.008) + env.gain.setValueAtTime(peak, at + Math.max(0.02, dur * 0.6)) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + + let node: AudioNode = osc + if (opts.filter) { + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.Q.value = opts.filter.q ?? 6 + lp.frequency.setValueAtTime(opts.filter.from, at) + lp.frequency.exponentialRampToValueAtTime( + Math.max(60, opts.filter.to), + at + Math.max(0.05, dur), + ) + osc.connect(lp) + node = lp + } + + node.connect(env).connect(dest) + osc.start(at) + osc.stop(at + dur + 0.02) + } + + /** A small kit: pitched-sine kick, noise-and-tone snare, clap, two hats. */ + private drum(dest: AudioNode, kind: string, at: number, gain: number) { + const ctx = this.ensure() + + if (kind === 'K') { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.setValueAtTime(130, at) + osc.frequency.exponentialRampToValueAtTime(42, at + 0.11) + const env = ctx.createGain() + env.gain.setValueAtTime(gain * 1.5, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.24) + osc.connect(env).connect(dest) + osc.start(at) + osc.stop(at + 0.26) + return + } + + const noise = ctx.createBufferSource() + noise.buffer = this.noiseBuffer + const filter = ctx.createBiquadFilter() + const env = ctx.createGain() + let dur = 0.05 + + if (kind === 'S' || kind === 'C') { + filter.type = 'bandpass' + filter.frequency.value = kind === 'S' ? 1900 : 1300 + filter.Q.value = kind === 'S' ? 0.9 : 2.4 + dur = kind === 'S' ? 0.16 : 0.1 + if (kind === 'S') { + // A little body under the crack. + const tone = ctx.createOscillator() + tone.type = 'triangle' + tone.frequency.setValueAtTime(210, at) + tone.frequency.exponentialRampToValueAtTime(150, at + 0.09) + const tenv = ctx.createGain() + tenv.gain.setValueAtTime(gain * 0.6, at) + tenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.1) + tone.connect(tenv).connect(dest) + tone.start(at) + tone.stop(at + 0.12) + } + } else { + filter.type = 'highpass' + filter.frequency.value = 7200 + dur = kind === 'O' ? 0.22 : 0.035 + } + + env.gain.setValueAtTime(gain, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + noise.connect(filter).connect(env).connect(dest) + noise.start(at) + noise.stop(at + dur + 0.02) + } + + // ------------------------------------------------------------- sequencer + + playTune(tune: Tune, restart = true) { + this.ensure() + if (this.tune === tune && this.timer !== null && !restart) return + this.stopTune() + this.tune = tune + this.step = 0 + this.nextStepTime = this.ctx!.currentTime + 0.08 + this.timer = window.setInterval(() => this.schedule(), 25) + } + + stopTune() { + if (this.timer !== null) window.clearInterval(this.timer) + this.timer = null + this.tune = null + } + + get playing() { + return this.timer !== null + } + + private schedule() { + const ctx = this.ctx + const tune = this.tune + if (!ctx || !tune) return + const stepDur = 60 / tune.bpm / tune.stepsPerBeat + const length = Math.max(...tune.tracks.map((t) => t.notes.length)) + const swing = tune.swing ?? 0 + + while (this.nextStepTime < ctx.currentTime + 0.2) { + // A shuffle pushes every other step late without moving the downbeats. + const at = this.nextStepTime + (this.step % 2 === 1 ? swing * stepDur : 0) + + for (const track of tune.tracks) { + const note = track.notes[this.step % track.notes.length] + if (!note || note === '.' || note === '=') continue + + // A note runs until the next step that is not a sustain marker. + let held = 1 + for (let i = 1; i < length; i++) { + if (track.notes[(this.step + i) % track.notes.length] === '=') held++ + else break + } + const dur = held * stepDur * (track.gate ?? 0.95) + + if (track.wave === 'noise') { + this.drum(this.musicBus, note, at, track.gain) + continue + } + + for (const part of note.split('/')) { + const f = noteToFreq(part) + if (!f) continue + this.voice(this.musicBus, track.wave, f, at, dur, track.gain, { + filter: track.filter, + detune: track.detune, + }) + } + } + this.nextStepTime += stepDur + this.step = (this.step + 1) % length + } + } + + // ------------------------------------------------------------------ sfx + + private seq(notes: [string, number][], wave: Wave = 'pulse25', gain = 0.22) { + const ctx = this.ensure() + let t = ctx.currentTime + 0.01 + for (const [note, dur] of notes) { + if (note !== '.') this.voice(this.sfxBus, wave, noteToFreq(note), t, dur, gain) + t += dur + } + } + + /** A click on the map. Small, dry, and not a musical note. */ + tap() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = 2600 + bp.Q.value = 1.4 + const env = ctx.createGain() + env.gain.setValueAtTime(0.1, at) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.035) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(at) + src.stop(at + 0.05) + } + + /** An order written down. Pencil on a map board. */ + written() { + this.seq([['B4', 0.045], ['E5', 0.08]], 'triangle', 0.14) + } + + /** An order the rules will not take. */ + reject() { + this.seq([['A3', 0.09], ['D#3', 0.18]], 'saw', 0.16) + } + + /** + * The field telegraph, for the press. + * + * Not Morse -- a rhythm that only *reads* as Morse, because a real letter + * would say something and somebody would eventually read it. A carrier tone + * gated into short and long, which is the whole sound of a key being worked. + */ + telegraph(long = false) { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + const pattern = long ? [0.06, 0.14, 0.06, 0.06] : [0.06, 0.06, 0.14] + let t = at + for (const len of pattern) { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = 720 + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, t) + env.gain.exponentialRampToValueAtTime(0.075, t + 0.006) + env.gain.setValueAtTime(0.075, t + len - 0.01) + env.gain.exponentialRampToValueAtTime(0.0001, t + len) + osc.connect(env).connect(this.sfxBus) + osc.start(t) + osc.stop(t + len + 0.02) + t += len + 0.05 + } + } + + // -------------------------------------------------------------- the war + + /** + * A field gun, and the shell landing. + * + * The two halves are the sound: a crack with almost no body, then a + * lowpassed thump a beat later with plenty. Artillery at a distance is + * mostly the second one, so the first is kept quiet and quick -- turn it up + * and the whole thing collapses into a drum. + */ + private gun(at: number, gain: number, pitch: number) { + const ctx = this.ensure() + + const crack = ctx.createBufferSource() + crack.buffer = this.noiseBuffer + crack.playbackRate.value = 1.2 + const hp = ctx.createBiquadFilter() + hp.type = 'highpass' + hp.frequency.value = 1100 + const cenv = ctx.createGain() + cenv.gain.setValueAtTime(gain * 0.5, at) + cenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.09) + crack.connect(hp).connect(cenv).connect(this.sfxBus) + crack.start(at) + crack.stop(at + 0.11) + + const body = ctx.createBufferSource() + body.buffer = this.noiseBuffer + body.playbackRate.value = 0.45 + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.Q.value = 1.2 + lp.frequency.setValueAtTime(900, at + 0.02) + lp.frequency.exponentialRampToValueAtTime(110, at + 0.5) + const benv = ctx.createGain() + benv.gain.setValueAtTime(0.0001, at + 0.02) + benv.gain.exponentialRampToValueAtTime(gain, at + 0.05) + benv.gain.exponentialRampToValueAtTime(0.0001, at + 0.55) + body.connect(lp).connect(benv).connect(this.sfxBus) + body.start(at + 0.02) + body.stop(at + 0.6) + + const boom = ctx.createOscillator() + boom.type = 'sine' + boom.frequency.setValueAtTime(pitch, at + 0.03) + boom.frequency.exponentialRampToValueAtTime(pitch * 0.35, at + 0.4) + const oenv = ctx.createGain() + oenv.gain.setValueAtTime(gain * 0.8, at + 0.03) + oenv.gain.exponentialRampToValueAtTime(0.0001, at + 0.45) + boom.connect(oenv).connect(this.sfxBus) + boom.start(at + 0.03) + boom.stop(at + 0.5) + } + + /** + * A province changing hands on land. + * + * Five guns at uneven spacing, because a barrage that keeps time is a + * drum machine. The offsets are fixed rather than random so the sound is + * the same every turn -- a battle that sounds different each time reads as + * a glitch, not as variety. + */ + ground() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [i, offset] of [0, 0.13, 0.19, 0.36, 0.47].entries()) { + this.gun(at + offset, 0.22 - i * 0.02, 90 - i * 6) + } + + // Rifle fire under it: noise chopped fast enough to lose its pitch. + for (let i = 0; i < 14; i++) { + const t = at + 0.08 + i * 0.043 + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + src.playbackRate.value = 2 + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = 2200 + bp.Q.value = 0.8 + const env = ctx.createGain() + env.gain.setValueAtTime(0.05, t) + env.gain.exponentialRampToValueAtTime(0.0001, t + 0.03) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(t) + src.stop(t + 0.04) + } + } + + /** + * A fight at sea. + * + * The same guns, bigger and slower, and no rifles -- there is nobody + * within a mile to fire one. What replaces them is the sea: filtered noise + * held under the whole thing, which is what keeps this from being the land + * battle at a lower pitch. + */ + naval() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + const swell = ctx.createBufferSource() + swell.buffer = this.noiseBuffer + swell.loop = true + swell.playbackRate.value = 0.3 + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 420 + lp.Q.value = 0.7 + const senv = ctx.createGain() + senv.gain.setValueAtTime(0.0001, at) + senv.gain.exponentialRampToValueAtTime(0.12, at + 0.3) + senv.gain.setValueAtTime(0.12, at + 0.9) + senv.gain.exponentialRampToValueAtTime(0.0001, at + 1.5) + swell.connect(lp).connect(senv).connect(this.sfxBus) + swell.start(at) + swell.stop(at + 1.55) + + // A salvo is several barrels at once, which is why they land together. + for (const offset of [0.05, 0.09, 0.13, 0.55, 0.6, 0.66]) { + this.gun(at + offset, 0.2, 62) + } + } + + /** + * An escort seeing a convoy across. + * + * A steam whistle answered by a second one further off, over the engine. + * This is the one battle sound in the game that is not a battle: a convoy + * that gets through is a quiet crossing, and it should sound like relief. + */ + escort() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + for (const [i, f] of [330, 246].entries()) { + const t = at + i * 0.34 + // A whistle is two close pitches beating against each other. + for (const detune of [-14, 12]) { + const osc = ctx.createOscillator() + osc.type = 'sawtooth' + osc.frequency.value = f + osc.detune.value = detune + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 1300 + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, t) + env.gain.exponentialRampToValueAtTime(0.08 - i * 0.03, t + 0.08) + env.gain.setValueAtTime(0.08 - i * 0.03, t + 0.3) + env.gain.exponentialRampToValueAtTime(0.0001, t + 0.55) + osc.connect(lp).connect(env).connect(this.sfxBus) + osc.start(t) + osc.stop(t + 0.6) + } + } + + const engine = ctx.createBufferSource() + engine.buffer = this.noiseBuffer + engine.playbackRate.value = 0.25 + const lp = ctx.createBiquadFilter() + lp.type = 'lowpass' + lp.frequency.value = 260 + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(0.09, at + 0.2) + env.gain.exponentialRampToValueAtTime(0.0001, at + 1.1) + engine.connect(lp).connect(env).connect(this.sfxBus) + engine.start(at) + engine.stop(at + 1.2) + } + + /** + * Falling back. + * + * A bugle call down rather than up, and a drum walking away behind it. + * The interval matters: the same three notes rising would be a charge, and + * that is the entire difference between the two sounds. + */ + retreat() { + this.seq([['G4', 0.16], ['E4', 0.16], ['C4', 0.36]], 'saw', 0.13) + const ctx = this.ensure() + const at = ctx.currentTime + 0.02 + for (const [i, offset] of [0, 0.3, 0.6, 0.9].entries()) { + this.drum(this.sfxBus, 'K', at + offset, 0.28 - i * 0.05) + } + } + + /** A unit lost for good: no bugle, just the drum stopping. */ + disband() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + this.drum(this.sfxBus, 'K', at, 0.3) + this.seq([['C4', 0.18], ['B3', 0.5]], 'triangle', 0.12) + } + + /** + * A unit raised over the winter. + * + * A yard, not a battle: rivets, then the ship or the column moving off. + * Three hammer blows on metal and a rising tone under them. + */ + build() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + + for (const [i, offset] of [0, 0.16, 0.32].entries()) { + const t = at + offset + const src = ctx.createBufferSource() + src.buffer = this.noiseBuffer + src.playbackRate.value = 1.6 + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = 3200 + i * 400 + bp.Q.value = 6 + const env = ctx.createGain() + env.gain.setValueAtTime(0.14, t) + env.gain.exponentialRampToValueAtTime(0.0001, t + 0.16) + src.connect(bp).connect(env).connect(this.sfxBus) + src.start(t) + src.stop(t + 0.18) + } + + const rise = ctx.createOscillator() + rise.type = 'triangle' + rise.frequency.setValueAtTime(98, at + 0.3) + rise.frequency.exponentialRampToValueAtTime(196, at + 0.85) + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at + 0.3) + env.gain.exponentialRampToValueAtTime(0.14, at + 0.45) + env.gain.exponentialRampToValueAtTime(0.0001, at + 0.95) + rise.connect(env).connect(this.sfxBus) + rise.start(at + 0.3) + rise.stop(at + 1) + } + + // ---------------------------------------------------------- the endings + + /** + * Eighteen centres. + * + * Bells, and the guns firing with nothing to hit. A victory in 1918 was + * church bells before it was anything else, and this is the only sound in + * the game allowed to be straightforwardly happy. + */ + victory() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [i, note] of ['D5', 'A4', 'F#4', 'D4', 'A4', 'D5'].entries()) { + const t = at + i * 0.42 + const f = noteToFreq(note) + // A bell is a struck partial well above the note, decaying fast. + for (const [mult, gain, dur] of [ + [1, 0.13, 2.6], + [2.76, 0.05, 1.1], + [5.4, 0.025, 0.5], + ] as const) { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = f * mult + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, t) + env.gain.exponentialRampToValueAtTime(gain, t + 0.01) + env.gain.exponentialRampToValueAtTime(0.0001, t + dur) + osc.connect(env).connect(this.sfxBus) + osc.start(t) + osc.stop(t + dur + 0.05) + } + } + for (const offset of [0.6, 1.4, 2.1]) this.gun(at + offset, 0.12, 80) + } + + /** + * Somebody else's eighteen. + * + * The same bells, in a minor key, ringing for a capital that is not yours. + * Reusing the victory sound rather than writing a second one is the point: + * a solo sounds the same from the outside, and you only find out which one + * it was by looking at the board. + */ + defeat() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [i, note] of ['D4', 'F4', 'A3', 'D3'].entries()) { + const t = at + i * 0.5 + const f = noteToFreq(note) + for (const [mult, gain, dur] of [ + [1, 0.13, 3], + [2.76, 0.04, 1.2], + ] as const) { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = f * mult + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, t) + env.gain.exponentialRampToValueAtTime(gain, t + 0.01) + env.gain.exponentialRampToValueAtTime(0.0001, t + dur) + osc.connect(env).connect(this.sfxBus) + osc.start(t) + osc.stop(t + dur + 0.05) + } + } + } + + /** Your last centre. A single low bell and nothing after it. */ + eliminated() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + for (const [mult, gain, dur] of [ + [1, 0.16, 4], + [2.76, 0.05, 1.6], + ] as const) { + const osc = ctx.createOscillator() + osc.type = 'sine' + osc.frequency.value = noteToFreq('D2') * mult + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at) + env.gain.exponentialRampToValueAtTime(gain, at + 0.01) + env.gain.exponentialRampToValueAtTime(0.0001, at + dur) + osc.connect(env).connect(this.sfxBus) + osc.start(at) + osc.stop(at + dur + 0.05) + } + } + + /** + * The armistice. + * + * A draw is not a defeat and it is certainly not a victory, so it gets the + * one thing neither of those has: the guns stopping. A last salvo, and + * then a chord that resolves and simply stays there. + */ + armistice() { + const ctx = this.ensure() + const at = ctx.currentTime + 0.01 + this.gun(at, 0.18, 70) + for (const note of ['D3', 'A3', 'D4', 'F#4']) { + const osc = ctx.createOscillator() + osc.type = 'triangle' + osc.frequency.value = noteToFreq(note) + const env = ctx.createGain() + env.gain.setValueAtTime(0.0001, at + 0.8) + env.gain.exponentialRampToValueAtTime(0.07, at + 1.4) + env.gain.setValueAtTime(0.07, at + 3) + env.gain.exponentialRampToValueAtTime(0.0001, at + 4.5) + osc.connect(env).connect(this.sfxBus) + osc.start(at + 0.8) + osc.stop(at + 4.6) + } + } +} + +export const synth = new Synth() diff --git a/src/game/game.ts b/src/game/game.ts index b246bb4..96d7c1f 100644 --- a/src/game/game.ts +++ b/src/game/game.ts @@ -26,6 +26,7 @@ import { type Ownership, type RetreatOrder, } from './turn' +import { buildsSounded, movesSounded, retreatsSounded, type Cue } from './sound' /** * The year, and the loop it goes round. @@ -72,6 +73,12 @@ export interface Game { agreements: Agreement[] /** Everything that happened, newest last. */ log: string[] + /** + * How the last phase sounded, which is a fact about the turn rather than + * about the interface -- so it is worked out here, where the orders are, + * and tested like anything else. See `sound.ts`. + */ + sounds: Cue[] /** Set while retreats are outstanding. */ outcome: Outcome | null winner: Power | null @@ -99,6 +106,7 @@ export function newGame(): Game { ledger: emptyLedger(), agreements: [], log: ['Spring 1901. Nobody has said anything yet.'], + sounds: [], outcome: null, winner: null, drawn: [], @@ -205,7 +213,14 @@ export function resolveOrders( ...report(outcome, all), ] - const next: Game = { ...g, board: after, ledger, log, outcome } + const next: Game = { + ...g, + board: after, + ledger, + log, + outcome, + sounds: movesSounded(g.board, all, outcome), + } void rng return outcome.dislodged.size > 0 @@ -240,7 +255,7 @@ export function resolveRetreats( `${PROVINCES[base(u.at)]!.name}: nowhere to go.`, ), ] - return afterRetreats({ ...g, board, log, outcome: null }) + return afterRetreats({ ...g, board, log, outcome: null, sounds: retreatsSounded(orders) }) } /** The winter, or the next season. */ @@ -307,6 +322,9 @@ export function resolveBuilds( let board = g.board const log = [...g.log] + // Everybody's winter, not only the player's: a shipyard in Trieste is + // still a shipyard. + const adjusted: AdjustOrder[] = [...playerAdjust] for (const power of POWERS) { if (g.out.includes(power)) continue @@ -324,21 +342,15 @@ export function resolveBuilds( const wanted = buildOptions(g.own, board, power) .filter((o) => o.type === 'army' || o.at.includes('/') === false) .slice(0, owed) - board = applyAdjustments( - g.own, - board, - power, - wanted.map((o) => ({ type: 'build' as const, at: o.at, unit: o.type })), - ).board + const raised = wanted.map((o) => ({ type: 'build' as const, at: o.at, unit: o.type })) + board = applyAdjustments(g.own, board, power, raised).board + adjusted.push(...raised) if (wanted.length > 0) log.push(`${power} builds ${wanted.length}.`) } else { const going = civilDisorderDisbands(g.own, board, power, -owed) - board = applyAdjustments( - g.own, - board, - power, - going.map((u) => ({ type: 'disband' as const, at: u.at, unit: u.type })), - ).board + const paidOff = going.map((u) => ({ type: 'disband' as const, at: u.at, unit: u.type })) + board = applyAdjustments(g.own, board, power, paidOff).board + adjusted.push(...paidOff) if (going.length > 0) log.push(`${power} gives up ${going.length}.`) } } @@ -347,6 +359,7 @@ export function resolveBuilds( ...g, board, log: [...log, `Spring ${g.year + 1}.`], + sounds: buildsSounded(adjusted), year: g.year + 1, season: 'spring', phase: 'orders', diff --git a/src/game/sound.test.ts b/src/game/sound.test.ts new file mode 100644 index 0000000..9796adb --- /dev/null +++ b/src/game/sound.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from 'vitest' +import { adjudicate } from './adjudicate' +import type { Power } from './map' +import { boardFrom, type Order, type Unit } from './orders' +import { buildsSounded, endingSounded, movesSounded, retreatsSounded } from './sound' + +/** + * What a turn sounds like, which is a question with a right answer. + * + * The interesting cases are the quiet ones. A turn in which everybody walks + * into empty country should make no sound at all, and getting that wrong is + * the failure that matters -- guns on every turn are the same as no guns. + */ + +const A = (power: Power, at: string): Unit => ({ power, type: 'army', at }) +const F = (power: Power, at: string): Unit => ({ power, type: 'fleet', at }) + +const mv = (at: string, to: string, viaConvoy = false): Order => ({ + type: 'move', + at, + to, + viaConvoy, +}) +const sup = (at: string, from: string, to: string): Order => ({ type: 'support', at, from, to }) +const hold = (at: string): Order => ({ type: 'hold', at }) +const cvy = (at: string, from: string, to: string): Order => ({ type: 'convoy', at, from, to }) + +const sounds = (units: Unit[], orders: Order[]) => { + const board = boardFrom(units) + return movesSounded(board, orders, adjudicate(board, orders)) +} + +describe('the movement phase', () => { + it('is silent when nobody meets anybody', () => { + expect(sounds([A('austria', 'vie'), A('italy', 'rom')], [mv('vie', 'tyr'), mv('rom', 'ven')])) + .toEqual([]) + }) + + it('fires the guns when two armies want the same province', () => { + expect(sounds([A('austria', 'vie'), A('italy', 'ven')], [mv('vie', 'tyr'), mv('ven', 'tyr')])) + .toEqual(['ground']) + }) + + it('fires them when an attack fails against somebody standing there', () => { + expect(sounds([A('germany', 'ruh'), A('france', 'bur')], [mv('ruh', 'bur'), hold('bur')])) + .toEqual(['ground']) + }) + + it('fires them when somebody is thrown out', () => { + const units = [A('germany', 'ruh'), A('germany', 'mun'), A('france', 'bur')] + const orders = [mv('ruh', 'bur'), sup('mun', 'ruh', 'bur'), hold('bur')] + expect(sounds(units, orders)).toEqual(['ground']) + }) + + it('calls it naval when the fight is at sea', () => { + const units = [F('england', 'lon'), F('france', 'bel')] + expect(sounds(units, [mv('lon', 'nth'), mv('bel', 'nth')])).toEqual(['naval']) + }) + + it('calls it naval when two fleets contest a coast', () => { + const units = [F('england', 'nth'), F('germany', 'hol')] + expect(sounds(units, [mv('nth', 'bel'), mv('hol', 'bel')])).toEqual(['naval']) + }) + + it('calls a landing ground, however it arrived', () => { + // A fleet against an army ashore is a landing, and a landing is ground. + const units = [F('england', 'nth'), A('france', 'bel')] + expect(sounds(units, [mv('nth', 'bel'), hold('bel')])).toEqual(['ground']) + }) + + it('sounds the escort when a convoy carries somebody', () => { + const units = [A('england', 'lon'), F('england', 'nth')] + expect(sounds(units, [mv('lon', 'nwy', true), cvy('nth', 'lon', 'nwy')])).toEqual(['escort']) + }) + + it('does not sound one for a fleet that carried nobody', () => { + const units = [A('england', 'lon'), F('england', 'nth')] + expect(sounds(units, [hold('lon'), cvy('nth', 'lon', 'nwy')])).toEqual([]) + }) + + it('reports both when a convoy lands into a fight', () => { + const units = [A('england', 'lon'), F('england', 'nth'), F('england', 'nwg'), A('russia', 'nwy')] + const orders = [ + mv('lon', 'nwy', true), + cvy('nth', 'lon', 'nwy'), + sup('nwg', 'lon', 'nwy'), + hold('nwy'), + ] + expect(sounds(units, orders)).toEqual(['escort', 'ground']) + }) + + it('does not sound the escort for a landing that was thrown back', () => { + // The ships sailed, and the army is still in London. The game's account + // of the turn is that nothing crossed, so nothing crossed. + const units = [A('england', 'lon'), F('england', 'nth'), A('russia', 'nwy')] + const orders = [mv('lon', 'nwy', true), cvy('nth', 'lon', 'nwy'), hold('nwy')] + expect(sounds(units, orders)).toEqual(['ground']) + }) + + it('says each kind once, however many battles there were', () => { + const units = [ + A('austria', 'vie'), + A('italy', 'ven'), + A('germany', 'ruh'), + A('france', 'bur'), + ] + const orders = [mv('vie', 'tyr'), mv('ven', 'tyr'), mv('ruh', 'bur'), hold('bur')] + expect(sounds(units, orders)).toEqual(['ground']) + }) +}) + +describe('the other phases', () => { + it('tells falling back from being finished', () => { + expect(retreatsSounded([{ type: 'retreat', at: 'bur', to: 'par' }])).toEqual(['retreat']) + expect(retreatsSounded([{ type: 'disband', at: 'bur' }])).toEqual(['disband']) + expect( + retreatsSounded([ + { type: 'retreat', at: 'bur', to: 'par' }, + { type: 'disband', at: 'mun' }, + ]), + ).toEqual(['retreat', 'disband']) + }) + + it('tells a yard from a paying-off', () => { + expect(buildsSounded([{ type: 'build', at: 'vie', unit: 'army' }])).toEqual(['build']) + expect(buildsSounded([{ type: 'disband', at: 'vie' }])).toEqual(['disband']) + }) + + it('is silent over a winter where nothing was ordered', () => { + expect(buildsSounded([])).toEqual([]) + expect(retreatsSounded([])).toEqual([]) + }) +}) + +describe('the endings', () => { + it('separates your solo from somebody else’s', () => { + expect(endingSounded('austria', [], 'austria')).toBe('victory') + expect(endingSounded('russia', [], 'austria')).toBe('defeat') + }) + + it('puts your own elimination ahead of who eventually won', () => { + // You were out in 1904. Russia's eighteenth centre in 1911 is news you + // read, not an ending you had. + expect(endingSounded('russia', ['austria'], 'austria')).toBe('eliminated') + }) + + it('calls a game nobody won an armistice', () => { + expect(endingSounded(null, [], 'austria')).toBe('armistice') + }) +}) diff --git a/src/game/sound.ts b/src/game/sound.ts new file mode 100644 index 0000000..1fe02c3 --- /dev/null +++ b/src/game/sound.ts @@ -0,0 +1,141 @@ +import type { Outcome } from './adjudicate' +import { PROVINCES, base } from './map' +import type { Board, Order } from './orders' +import type { AdjustOrder, RetreatOrder } from './turn' + +/** + * What a turn sounded like. + * + * The sounds are chosen from the orders and the outcome rather than fired + * from inside the interface, for the same reason the orders are validated in + * the rules rather than in the panel: this is a question with a right answer, + * and a right answer can be tested. Nothing here touches an AudioContext. + * + * Each kind plays at most once. A turn with four land battles in it is not + * four times as loud as a turn with one -- it is mud, and the player learns + * nothing from it. So the report says *what kinds of thing happened*, and the + * player looks at the board to find out where. + */ +export type Cue = 'escort' | 'naval' | 'ground' | 'retreat' | 'disband' | 'build' + +/** Fixed, so a turn always plays its sounds in the same order. */ +const ORDER: Cue[] = ['escort', 'naval', 'ground', 'retreat', 'disband', 'build'] + +const sorted = (cues: Set): Cue[] => ORDER.filter((c) => cues.has(c)) + +/** + * The movement phase. + * + * A province was fought over when somebody was thrown out of it, when two + * units were sent to it, or when one unit was sent against another and did + * not get in. A unit walking into empty country is not a battle, and a turn + * where every power does that should be silent -- which happens, and is + * worth hearing, because it means nobody committed to anything. + */ +export function movesSounded( + board: Board, + orders: readonly Order[], + outcome: Outcome, +): Cue[] { + const cues = new Set() + + const arrivals = new Map() + for (const order of orders) { + if (order.type !== 'move') continue + const dest = base(order.to) + const list = arrivals.get(dest) + if (list) list.push(order) + else arrivals.set(dest, [order]) + } + + for (const [dest, movers] of arrivals) { + const held = board.get(dest) + const contested = + outcome.dislodged.has(dest) || + movers.length > 1 || + (held !== undefined && !outcome.success.get(base(movers[0]!.at))) + if (!contested) continue + + /* + * Naval when it happened at sea, or when everybody involved was a fleet + * -- two fleets contesting a coast is a fight between ships wherever the + * pin is. A fleet against an army on a coast is a landing, and a landing + * is ground. + */ + const involved = [...movers.map((m) => board.get(base(m.at))), held].filter( + (u) => u !== undefined, + ) + const atSea = PROVINCES[dest]?.terrain === 'sea' + cues.add(atSea || involved.every((u) => u.type === 'fleet') ? 'naval' : 'ground') + } + + /* + * An escort is a convoy that actually carried somebody across. + * + * Three things have to be true, and the two easy ones are not enough. A + * fleet ordered to convoy an army that stayed at home did not escort + * anything -- and a held order *succeeds*, so asking whether the army's + * order worked is the wrong question. Ask whether it was ordered to make + * this crossing, and whether it arrived. + */ + const crossings = new Map() + for (const order of orders) { + if (order.type === 'move') crossings.set(base(order.at), base(order.to)) + } + for (const order of orders) { + if (order.type !== 'convoy') continue + if (!outcome.success.get(base(order.at))) continue + if (crossings.get(base(order.from)) !== base(order.to)) continue + if (!outcome.success.get(base(order.from))) continue + cues.add('escort') + } + + return sorted(cues) +} + +/** The retreat phase: somebody fell back, or somebody did not. */ +export function retreatsSounded(chosen: readonly RetreatOrder[]): Cue[] { + const cues = new Set() + for (const order of chosen) cues.add(order.type === 'retreat' ? 'retreat' : 'disband') + return sorted(cues) +} + +/** The winter. */ +export function buildsSounded(chosen: readonly AdjustOrder[]): Cue[] { + const cues = new Set() + for (const order of chosen) cues.add(order.type === 'build' ? 'build' : 'disband') + return sorted(cues) +} + +/** + * The four endings. + * + * They are four rather than two because losing has more than one shape. Being + * eliminated in 1904 and watching Russia take an eighteenth centre in 1911 + * are both defeats, and they do not feel remotely alike -- one is your own + * ending and the other is somebody else's, which you merely attended. + */ +export type Ending = 'victory' | 'defeat' | 'eliminated' | 'armistice' + +export function endingSounded( + winner: string | null, + out: readonly string[], + player: string, +): Ending { + if (winner === player) return 'victory' + if (out.includes(player)) return 'eliminated' + if (winner) return 'defeat' + return 'armistice' +} + +/** + * Two reports, run together. + * + * A submitted turn can pass through the movement, the retreats and the winter + * before it stops anywhere the player has a say, and each of those has + * something to say about how it sounded. Merging keeps the fixed order rather + * than the order they were collected in, so a winter's guns still come before + * its shipyards. + */ +export const mergeCues = (a: readonly Cue[], b: readonly Cue[]): Cue[] => + sorted(new Set([...a, ...b]))