feat: add in-game sound effects and browser notifications (#27)
CI / Lint, Build & Test (pull_request) Failing after 5s
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Failing after 4s

Adds Web Audio API sound effects and browser notifications for key game
events: achievement unlocked, quest completed, quest failed, boss
defeated, prestige, transcendence, and apotheosis. Both features are
toggled via profile settings, with notification permission requested
on first enable.
This commit is contained in:
2026-03-08 15:06:28 -07:00
committed by Naomi Carrigan
parent 746fd36fb5
commit 49bfc6a109
9 changed files with 409 additions and 2 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* @file Sound effect utilities using the Web Audio API.
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type SoundEvent =
| "achievement"
| "apotheosis"
| "bossVictory"
| "prestige"
| "questCompleted"
| "questFailed"
| "transcendence";
interface SoundPattern {
frequencies: Array<number>;
gain: number;
noteDuration: number;
type: OscillatorType;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- intentional constant name
const SOUND_PATTERNS: Record<SoundEvent, SoundPattern> = {
achievement: {
frequencies: [ 523, 659, 784, 1047 ],
gain: 0.3,
noteDuration: 0.12,
type: "triangle",
},
apotheosis: {
frequencies: [ 1047, 880, 784, 659, 523 ],
gain: 0.35,
noteDuration: 0.25,
type: "sine",
},
bossVictory: {
frequencies: [ 523, 784, 1047 ],
gain: 0.4,
noteDuration: 0.18,
type: "square",
},
prestige: {
frequencies: [ 392, 523, 659, 784 ],
gain: 0.35,
noteDuration: 0.15,
type: "sawtooth",
},
questCompleted: {
frequencies: [ 523, 659 ],
gain: 0.25,
noteDuration: 0.15,
type: "sine",
},
questFailed: {
frequencies: [ 392, 330, 261 ],
gain: 0.25,
noteDuration: 0.18,
type: "triangle",
},
transcendence: {
frequencies: [ 261, 329, 392, 523 ],
gain: 0.3,
noteDuration: 0.3,
type: "sine",
},
};
// eslint-disable-next-line @typescript-eslint/init-declarations -- lazily initialised on first use
let audioContext: AudioContext | undefined;
const getAudioContext = (): AudioContext => {
if (audioContext === undefined) {
audioContext = new AudioContext();
}
return audioContext;
};
/**
* Plays a sound effect for a given game event using the Web Audio API.
* @param event - The game event to play a sound for.
*/
const playSound = (event: SoundEvent): void => {
try {
const context = getAudioContext();
const pattern = SOUND_PATTERNS[event];
for (const [ index, frequency ] of pattern.frequencies.entries()) {
const oscillator = context.createOscillator();
const gainNode = context.createGain();
oscillator.connect(gainNode);
gainNode.connect(context.destination);
oscillator.type = pattern.type;
oscillator.frequency.value = frequency;
const noteOffset = index * pattern.noteDuration;
const startTime = context.currentTime + noteOffset;
const endTime = startTime + pattern.noteDuration;
gainNode.gain.setValueAtTime(0, startTime);
gainNode.gain.linearRampToValueAtTime(pattern.gain, startTime + 0.01);
gainNode.gain.linearRampToValueAtTime(0, endTime);
oscillator.start(startTime);
oscillator.stop(endTime);
}
} catch {
// Silently ignore — audio may not be available in all environments
}
};
export type { SoundEvent };
export { playSound };