initial Game
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
// Synthesised sound effects using the Web Audio API (no audio files needed).
|
||||
// Browsers keep an AudioContext suspended until a user gesture, so `unlock()`
|
||||
// is called from the first keydown.
|
||||
|
||||
const MASTER_VOLUME = 0.35;
|
||||
|
||||
export class Sound {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this.noiseBuffer = null;
|
||||
this.muted = false;
|
||||
this.lastPlayed = new Map();
|
||||
}
|
||||
|
||||
unlock() {
|
||||
if (typeof AudioContext === 'undefined') return;
|
||||
if (!this.ctx) {
|
||||
this.ctx = new AudioContext();
|
||||
this.master = new GainNode(this.ctx, { gain: this.muted ? 0 : MASTER_VOLUME });
|
||||
this.master.connect(this.ctx.destination);
|
||||
// One second of white noise, shared by every noise-based effect.
|
||||
const length = this.ctx.sampleRate;
|
||||
this.noiseBuffer = new AudioBuffer({ length, sampleRate: this.ctx.sampleRate });
|
||||
const data = this.noiseBuffer.getChannelData(0);
|
||||
for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
if (this.ctx.state === 'suspended') this.ctx.resume();
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.muted = !this.muted;
|
||||
if (this.master) this.master.gain.value = this.muted ? 0 : MASTER_VOLUME;
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
get ready() {
|
||||
return this.ctx && this.ctx.state === 'running' && !this.muted;
|
||||
}
|
||||
|
||||
/** Avoid stacking dozens of identical sounds in the same instant. */
|
||||
throttle(name, seconds) {
|
||||
const now = this.ctx.currentTime;
|
||||
const last = this.lastPlayed.get(name) ?? -Infinity;
|
||||
if (now - last < seconds) return false;
|
||||
this.lastPlayed.set(name, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
tone({ freq, to = null, type = 'sine', dur = 0.1, gain = 0.4, delay = 0 }) {
|
||||
const ctx = this.ctx;
|
||||
const t = ctx.currentTime + delay;
|
||||
const osc = new OscillatorNode(ctx, { type, frequency: freq });
|
||||
if (to) {
|
||||
osc.frequency.setValueAtTime(freq, t);
|
||||
osc.frequency.exponentialRampToValueAtTime(to, t + dur);
|
||||
}
|
||||
const env = new GainNode(ctx, { gain: 0 });
|
||||
env.gain.setValueAtTime(0.0001, t);
|
||||
env.gain.exponentialRampToValueAtTime(gain, t + 0.004);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
osc.connect(env).connect(this.master);
|
||||
osc.start(t);
|
||||
osc.stop(t + dur + 0.02);
|
||||
}
|
||||
|
||||
noise({ freq = 1000, to = null, q = 1, type = 'bandpass', dur = 0.05, gain = 0.4, delay = 0 }) {
|
||||
const ctx = this.ctx;
|
||||
const t = ctx.currentTime + delay;
|
||||
const src = new AudioBufferSourceNode(ctx, { buffer: this.noiseBuffer });
|
||||
const filter = new BiquadFilterNode(ctx, { type, frequency: freq });
|
||||
filter.Q.value = q;
|
||||
if (to) {
|
||||
filter.frequency.setValueAtTime(freq, t);
|
||||
filter.frequency.exponentialRampToValueAtTime(to, t + dur);
|
||||
}
|
||||
const env = new GainNode(ctx, { gain: 0 });
|
||||
env.gain.setValueAtTime(0.0001, t);
|
||||
env.gain.exponentialRampToValueAtTime(gain, t + 0.003);
|
||||
env.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||
src.connect(filter).connect(env).connect(this.master);
|
||||
src.start(t, Math.random() * 0.5);
|
||||
src.stop(t + dur + 0.02);
|
||||
}
|
||||
|
||||
arpeggio(notes, { type = 'square', step = 0.07, dur = 0.09, gain = 0.18 } = {}) {
|
||||
notes.forEach((freq, i) => this.tone({ freq, type, dur, gain, delay: i * step }));
|
||||
}
|
||||
|
||||
/** React to a game event (see Game.emit). */
|
||||
play(event) {
|
||||
if (!this.ready) return;
|
||||
switch (event.type) {
|
||||
case 'flipper':
|
||||
if (event.up) {
|
||||
this.noise({ freq: 2200, type: 'highpass', dur: 0.03, gain: 0.3 });
|
||||
this.tone({ freq: 120, to: 60, type: 'square', dur: 0.05, gain: 0.18 });
|
||||
} else {
|
||||
this.noise({ freq: 1500, type: 'highpass', dur: 0.02, gain: 0.1 });
|
||||
}
|
||||
break;
|
||||
case 'bumper':
|
||||
this.tone({ freq: 170, to: 70, type: 'triangle', dur: 0.14, gain: 0.55 });
|
||||
this.noise({ freq: 2600, dur: 0.04, gain: 0.35, q: 2 });
|
||||
break;
|
||||
case 'sling':
|
||||
this.noise({ freq: 1300, dur: 0.05, gain: 0.45, q: 1.5 });
|
||||
this.tone({ freq: 95, to: 55, type: 'square', dur: 0.06, gain: 0.2 });
|
||||
break;
|
||||
case 'kicker':
|
||||
case 'rubber':
|
||||
if (this.throttle('rubber', 0.04)) {
|
||||
const g = Math.min(0.35, (event.speed ?? 400) / 5000);
|
||||
this.noise({ freq: 900, dur: 0.03, gain: g, q: 3 });
|
||||
}
|
||||
break;
|
||||
case 'drop':
|
||||
this.tone({ freq: 320, to: 110, type: 'square', dur: 0.08, gain: 0.22 });
|
||||
this.noise({ freq: 500, type: 'lowpass', dur: 0.06, gain: 0.3 });
|
||||
break;
|
||||
case 'dropReset':
|
||||
this.noise({ freq: 700, type: 'lowpass', dur: 0.08, gain: 0.3 });
|
||||
break;
|
||||
case 'standup':
|
||||
this.tone({ freq: 540, to: 270, type: 'triangle', dur: 0.09, gain: 0.35 });
|
||||
break;
|
||||
case 'rollover':
|
||||
if (event.kind === 'outlane') this.tone({ freq: 330, to: 150, type: 'sawtooth', dur: 0.25, gain: 0.18 });
|
||||
else if (event.kind === 'inlane') this.tone({ freq: 660, to: 990, dur: 0.08, gain: 0.3 });
|
||||
else {
|
||||
this.tone({ freq: 880, dur: 0.06, gain: 0.3 });
|
||||
this.tone({ freq: 1320, dur: 0.07, gain: 0.3, delay: 0.06 });
|
||||
}
|
||||
break;
|
||||
case 'complete':
|
||||
this.arpeggio(event.what === 'skill' ? [523, 659, 784, 1047, 1319, 1568] : [523, 659, 784, 1047]);
|
||||
break;
|
||||
case 'launch':
|
||||
this.noise({ freq: 300, to: 3000, type: 'lowpass', dur: 0.25, gain: 0.15 + 0.3 * event.power });
|
||||
break;
|
||||
case 'plungerPull':
|
||||
this.tone({ freq: 90, type: 'sawtooth', dur: 0.05, gain: 0.08 });
|
||||
break;
|
||||
case 'drain':
|
||||
this.tone({ freq: 440, to: 90, type: 'sawtooth', dur: 0.7, gain: 0.22 });
|
||||
break;
|
||||
case 'newBall':
|
||||
this.arpeggio([660, 880], { step: 0.1, type: 'triangle', gain: 0.25 });
|
||||
break;
|
||||
case 'start':
|
||||
this.arpeggio([392, 523, 659, 784], { step: 0.08, gain: 0.2 });
|
||||
break;
|
||||
case 'gameover':
|
||||
this.arpeggio([523, 392, 330, 262], { step: 0.22, dur: 0.22, type: 'triangle', gain: 0.3 });
|
||||
break;
|
||||
case 'kickback':
|
||||
this.tone({ freq: 150, to: 55, type: 'square', dur: 0.12, gain: 0.35 });
|
||||
this.noise({ freq: 450, type: 'lowpass', dur: 0.1, gain: 0.45 });
|
||||
break;
|
||||
case 'nudge':
|
||||
this.noise({ freq: 220, type: 'lowpass', dur: 0.12, gain: 0.4 });
|
||||
break;
|
||||
case 'rampEnter':
|
||||
this.noise({ freq: 500, to: 2200, type: 'bandpass', dur: 0.35, gain: 0.25, q: 0.7 });
|
||||
break;
|
||||
case 'ramp':
|
||||
this.tone({ freq: 700, to: 900, type: 'triangle', dur: 0.1, gain: 0.25 });
|
||||
break;
|
||||
case 'lock':
|
||||
this.arpeggio([440, 660], { step: 0.09, type: 'square', gain: 0.28 });
|
||||
this.noise({ freq: 300, type: 'lowpass', dur: 0.15, gain: 0.3 });
|
||||
break;
|
||||
case 'multiball':
|
||||
this.arpeggio([392, 494, 587, 784, 987], { step: 0.06, type: 'square', gain: 0.32 });
|
||||
this.noise({ freq: 1200, dur: 0.3, gain: 0.4, q: 0.6 });
|
||||
break;
|
||||
case 'jackpot':
|
||||
this.arpeggio([784, 988, 1175], { step: 0.05, type: 'square', gain: 0.35 });
|
||||
break;
|
||||
case 'superJackpot':
|
||||
this.arpeggio([523, 659, 784, 1047, 1319, 1568, 2093], { step: 0.06, type: 'square', gain: 0.4 });
|
||||
this.noise({ freq: 1800, dur: 0.4, gain: 0.35, q: 0.5 });
|
||||
break;
|
||||
case 'scoop':
|
||||
this.tone({ freq: 500, to: 300, type: 'sine', dur: 0.12, gain: 0.2 });
|
||||
break;
|
||||
case 'ballLost':
|
||||
this.tone({ freq: 300, to: 120, type: 'sawtooth', dur: 0.3, gain: 0.15 });
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user