Skip to content

Commit 5850f61

Browse files
committed
Add procedural sound effects for all game events
Web Audio API synthesized sounds (no assets needed): - Phase change alert (two-tone chime when your turn starts) - Confirm beep (ascending tone on order submission) - Thrust rumble (filtered noise on movement) - Combat laser (descending sawtooth) - Explosion (filtered noise burst for destruction/crashes) - Victory fanfare (ascending C-E-G-C arpeggio) - Defeat sound (descending tones) - Match found notification Audio context auto-resumes on first user interaction per browser policy. 138 tests passing.
1 parent c0dc6c7 commit 5850f61

File tree

2 files changed

+207
-2
lines changed

2 files changed

+207
-2
lines changed

src/client/audio.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Procedural sound effects using Web Audio API.
3+
* No audio assets needed — everything is synthesized.
4+
*/
5+
6+
let ctx: AudioContext | null = null;
7+
8+
function getCtx(): AudioContext {
9+
if (!ctx) {
10+
ctx = new AudioContext();
11+
}
12+
return ctx;
13+
}
14+
15+
/** Resume audio context after user gesture (required by browsers). */
16+
export function initAudio() {
17+
const resume = () => {
18+
if (ctx?.state === 'suspended') {
19+
ctx.resume();
20+
}
21+
document.removeEventListener('click', resume);
22+
document.removeEventListener('touchstart', resume);
23+
};
24+
document.addEventListener('click', resume);
25+
document.addEventListener('touchstart', resume);
26+
}
27+
28+
/** Short blip for UI interactions (button clicks, selections). */
29+
export function playSelect() {
30+
const ac = getCtx();
31+
const osc = ac.createOscillator();
32+
const gain = ac.createGain();
33+
osc.connect(gain);
34+
gain.connect(ac.destination);
35+
osc.type = 'sine';
36+
osc.frequency.setValueAtTime(800, ac.currentTime);
37+
osc.frequency.exponentialRampToValueAtTime(1200, ac.currentTime + 0.05);
38+
gain.gain.setValueAtTime(0.08, ac.currentTime);
39+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.1);
40+
osc.start(ac.currentTime);
41+
osc.stop(ac.currentTime + 0.1);
42+
}
43+
44+
/** Confirm/submit sound — ascending tone. */
45+
export function playConfirm() {
46+
const ac = getCtx();
47+
const osc = ac.createOscillator();
48+
const gain = ac.createGain();
49+
osc.connect(gain);
50+
gain.connect(ac.destination);
51+
osc.type = 'sine';
52+
osc.frequency.setValueAtTime(400, ac.currentTime);
53+
osc.frequency.exponentialRampToValueAtTime(800, ac.currentTime + 0.15);
54+
gain.gain.setValueAtTime(0.1, ac.currentTime);
55+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.2);
56+
osc.start(ac.currentTime);
57+
osc.stop(ac.currentTime + 0.2);
58+
}
59+
60+
/** Thruster sound for movement. */
61+
export function playThrust() {
62+
const ac = getCtx();
63+
const bufSize = ac.sampleRate * 0.3;
64+
const buf = ac.createBuffer(1, bufSize, ac.sampleRate);
65+
const data = buf.getChannelData(0);
66+
for (let i = 0; i < bufSize; i++) {
67+
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufSize * 0.3));
68+
}
69+
const src = ac.createBufferSource();
70+
src.buffer = buf;
71+
const filter = ac.createBiquadFilter();
72+
filter.type = 'lowpass';
73+
filter.frequency.setValueAtTime(300, ac.currentTime);
74+
filter.frequency.exponentialRampToValueAtTime(100, ac.currentTime + 0.3);
75+
const gain = ac.createGain();
76+
gain.gain.setValueAtTime(0.06, ac.currentTime);
77+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.3);
78+
src.connect(filter);
79+
filter.connect(gain);
80+
gain.connect(ac.destination);
81+
src.start(ac.currentTime);
82+
}
83+
84+
/** Laser/beam sound for combat. */
85+
export function playCombat() {
86+
const ac = getCtx();
87+
const osc = ac.createOscillator();
88+
const gain = ac.createGain();
89+
osc.connect(gain);
90+
gain.connect(ac.destination);
91+
osc.type = 'sawtooth';
92+
osc.frequency.setValueAtTime(2000, ac.currentTime);
93+
osc.frequency.exponentialRampToValueAtTime(100, ac.currentTime + 0.3);
94+
gain.gain.setValueAtTime(0.06, ac.currentTime);
95+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.35);
96+
osc.start(ac.currentTime);
97+
osc.stop(ac.currentTime + 0.35);
98+
}
99+
100+
/** Explosion sound for ship destruction or detonation. */
101+
export function playExplosion() {
102+
const ac = getCtx();
103+
const bufSize = ac.sampleRate * 0.5;
104+
const buf = ac.createBuffer(1, bufSize, ac.sampleRate);
105+
const data = buf.getChannelData(0);
106+
for (let i = 0; i < bufSize; i++) {
107+
data[i] = (Math.random() * 2 - 1) * Math.exp(-i / (bufSize * 0.15));
108+
}
109+
const src = ac.createBufferSource();
110+
src.buffer = buf;
111+
const filter = ac.createBiquadFilter();
112+
filter.type = 'lowpass';
113+
filter.frequency.setValueAtTime(800, ac.currentTime);
114+
filter.frequency.exponentialRampToValueAtTime(60, ac.currentTime + 0.5);
115+
const gain = ac.createGain();
116+
gain.gain.setValueAtTime(0.12, ac.currentTime);
117+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.5);
118+
src.connect(filter);
119+
filter.connect(gain);
120+
gain.connect(ac.destination);
121+
src.start(ac.currentTime);
122+
}
123+
124+
/** Alert tone for phase changes. */
125+
export function playPhaseChange() {
126+
const ac = getCtx();
127+
const osc = ac.createOscillator();
128+
const gain = ac.createGain();
129+
osc.connect(gain);
130+
gain.connect(ac.destination);
131+
osc.type = 'triangle';
132+
osc.frequency.setValueAtTime(600, ac.currentTime);
133+
gain.gain.setValueAtTime(0.06, ac.currentTime);
134+
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.12);
135+
osc.start(ac.currentTime);
136+
osc.stop(ac.currentTime + 0.12);
137+
// Second tone slightly delayed
138+
const osc2 = ac.createOscillator();
139+
const gain2 = ac.createGain();
140+
osc2.connect(gain2);
141+
gain2.connect(ac.destination);
142+
osc2.type = 'triangle';
143+
osc2.frequency.setValueAtTime(900, ac.currentTime + 0.12);
144+
gain2.gain.setValueAtTime(0.06, ac.currentTime + 0.12);
145+
gain2.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.25);
146+
osc2.start(ac.currentTime + 0.12);
147+
osc2.stop(ac.currentTime + 0.25);
148+
}
149+
150+
/** Victory fanfare. */
151+
export function playVictory() {
152+
const ac = getCtx();
153+
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
154+
notes.forEach((freq, i) => {
155+
const osc = ac.createOscillator();
156+
const gain = ac.createGain();
157+
osc.connect(gain);
158+
gain.connect(ac.destination);
159+
osc.type = 'sine';
160+
const t = ac.currentTime + i * 0.15;
161+
osc.frequency.setValueAtTime(freq, t);
162+
gain.gain.setValueAtTime(0.08, t);
163+
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
164+
osc.start(t);
165+
osc.stop(t + 0.3);
166+
});
167+
}
168+
169+
/** Defeat sound. */
170+
export function playDefeat() {
171+
const ac = getCtx();
172+
const notes = [400, 350, 300, 200]; // Descending
173+
notes.forEach((freq, i) => {
174+
const osc = ac.createOscillator();
175+
const gain = ac.createGain();
176+
osc.connect(gain);
177+
gain.connect(ac.destination);
178+
osc.type = 'sine';
179+
const t = ac.currentTime + i * 0.2;
180+
osc.frequency.setValueAtTime(freq, t);
181+
gain.gain.setValueAtTime(0.07, t);
182+
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
183+
osc.start(t);
184+
osc.stop(t + 0.35);
185+
});
186+
}

src/client/main.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SHIP_STATS, ORDNANCE_MASS } from '../shared/constants';
55
import { Renderer } from './renderer';
66
import { InputHandler } from './input';
77
import { UIManager } from './ui';
8+
import { initAudio, playSelect, playConfirm, playThrust, playCombat, playExplosion, playPhaseChange, playVictory, playDefeat } from './audio';
89

910
type ClientState =
1011
| 'menu'
@@ -79,7 +80,8 @@ class GameClient {
7980
}
8081
});
8182

82-
// Start render loop
83+
// Start render loop and audio
84+
initAudio();
8385
this.renderer.start();
8486

8587
// Check for auto-join code in URL
@@ -245,7 +247,7 @@ class GameClient {
245247
break;
246248

247249
case 'matchFound':
248-
// Game is about to start
250+
playPhaseChange();
249251
break;
250252

251253
case 'gameStart':
@@ -264,8 +266,12 @@ class GameClient {
264266
this.renderer.setGameState(this.gameState);
265267
this.input.setGameState(this.gameState);
266268
this.setState('playing_movementAnim');
269+
playThrust();
267270
if (msg.events.length > 0) {
268271
this.renderer.showMovementEvents(msg.events);
272+
// Play explosion sound for destructive events
273+
const hasDestruction = msg.events.some(e => e.damageType === 'eliminated' || e.type === 'crash');
274+
if (hasDestruction) setTimeout(() => playExplosion(), 500);
269275
}
270276
this.renderer.animateMovements(msg.movements, msg.ordnanceMovements, () => {
271277
this.onAnimationComplete();
@@ -278,6 +284,10 @@ class GameClient {
278284
this.input.setGameState(this.gameState);
279285
this.renderer.showCombatResults(msg.results);
280286
this.renderer.planningState.combatTargetId = null;
287+
playCombat();
288+
if (msg.results.some(r => r.damageType === 'eliminated')) {
289+
setTimeout(() => playExplosion(), 300);
290+
}
281291
this.transitionToPhase();
282292
break;
283293

@@ -296,6 +306,11 @@ class GameClient {
296306
msg.winner === this.playerId,
297307
msg.reason,
298308
);
309+
if (msg.winner === this.playerId) {
310+
playVictory();
311+
} else {
312+
playDefeat();
313+
}
299314
break;
300315

301316
case 'rematchPending':
@@ -356,6 +371,7 @@ class GameClient {
356371
orders.push(order);
357372
}
358373

374+
playConfirm();
359375
this.send({ type: 'astrogation', orders });
360376
}
361377

@@ -372,10 +388,13 @@ class GameClient {
372388

373389
if (this.gameState.phase === 'combat' && isMyTurn) {
374390
this.setState('playing_combat');
391+
playPhaseChange();
375392
} else if (this.gameState.phase === 'ordnance' && isMyTurn) {
376393
this.setState('playing_ordnance');
394+
playPhaseChange();
377395
} else if (this.gameState.phase === 'astrogation' && isMyTurn) {
378396
this.setState('playing_astrogation');
397+
playPhaseChange();
379398
} else {
380399
this.setState('playing_opponentTurn');
381400
}

0 commit comments

Comments
 (0)