Add sound effects, background music and a sound menu

Generate retro sound effects and music in the browser with the Web Audio
API, so no audio files are needed. Any sound can still be replaced by a
file.

Audio (src/audio/):
- 16 effects (move, rotate, hold, hard drop, lock, line clears, garbage,
  items, countdown, knockout, win/lose), triggered by comparing match
  snapshots, so the server is unchanged
- Background music as a shuffled playlist of any number of tracks,
  either synth songs or audio files; 3 original songs included, tempo
  rises with the level
- SoundPlayer interface, so the audio engine can be replaced
- Audio files and music tracks are registered in theme/assets.ts
  (soundFiles, musicTracks)
- All volume levels for developers are in config/sound.ts (master, sfx,
  music, per effect, per track, default slider positions)

UI:
- Speaker button fixed in the top right corner of every screen; opens an
  inline strip with a mute button and volume slider for SFX and music
  that doesn't cover the boards or pause the game
- Leave button below it in the lobby and the game; in a running match
  it asks for a second click because leaving gives up the match
- Lobby Music panel to switch single tracks on or off
- Keys: M all sound on/off, B music on/off, N next track
- Settings are saved in localStorage

Docs: audio section in frontend.md, sound and music guide in
extending.md, new keys in game-rules.md and the README.
This commit is contained in:
2026-10-24 04:08:09 +02:00
parent 94052573b3
commit 1996b9c83e
34 changed files with 1586 additions and 15 deletions
@@ -0,0 +1,47 @@
import { useEffect, useState, type MouseEvent } from 'react';
import { SoundMenu } from './SoundMenu';
import { Button } from './ui/Button';
import styles from './CornerControls.module.css';
const CONFIRM_MS = 3000;
interface Props {
/** Shows a Leave button below the sound button. */
onLeave?: () => void;
/** Ask for a second click before leaving, e.g. because leaving forfeits a running match. */
leaveConfirm?: boolean;
}
/** Sound menu and optional Leave button, fixed in the upper right corner on every screen. */
export function CornerControls({ onLeave, leaveConfirm = false }: Props) {
const [confirming, setConfirming] = useState(false);
useEffect(() => {
if (!confirming) return;
const timer = window.setTimeout(() => setConfirming(false), CONFIRM_MS);
return () => window.clearTimeout(timer);
}, [confirming]);
const onLeaveClick = (e: MouseEvent<HTMLButtonElement>) => {
// Keep keyboard focus off the button so Space (hard drop) doesn't press it.
e.currentTarget.blur();
if (leaveConfirm && !confirming) setConfirming(true);
else onLeave?.();
};
return (
<div className={styles.corner}>
<SoundMenu />
{onLeave && (
<Button
size="small"
variant={confirming ? 'danger' : 'secondary'}
onClick={onLeaveClick}
title={leaveConfirm ? 'Leaving gives up the running match' : 'Leave the lobby'}
>
{confirming ? 'Sure?' : 'Leave'}
</Button>
)}
</div>
);
}