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,10 @@
.corner {
position: fixed;
top: 16px;
right: 16px;
z-index: 10;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
@@ -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>
);
}
@@ -0,0 +1,21 @@
.tracks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
font-size: 10px;
}
.tracks label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.tracks input {
accent-color: var(--color-accent);
margin: 0;
}
@@ -0,0 +1,30 @@
import { useSound } from '../audio/SoundContext';
import { musicTracks } from '../theme/assets';
import styles from './MusicTracks.module.css';
/** One checkbox per registered music track; unchecked tracks are skipped by the playlist. */
export function MusicTracks() {
const { settings, setSettings } = useSound();
const toggleTrack = (id: string, enabled: boolean) =>
setSettings({
disabledTracks: enabled ? settings.disabledTracks.filter((t) => t !== id) : [...settings.disabledTracks, id],
});
return (
<ul className={styles.tracks}>
{musicTracks.map((track) => (
<li key={track.id}>
<label>
<input
type="checkbox"
checked={!settings.disabledTracks.includes(track.id)}
onChange={(e) => toggleTrack(track.id, e.target.checked)}
/>
{track.title}
</label>
</li>
))}
</ul>
);
}
@@ -0,0 +1,70 @@
.menu {
display: flex;
flex-direction: row-reverse;
align-items: center;
gap: 12px;
white-space: nowrap;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
background: var(--color-bg);
border: 2px solid var(--color-fg);
cursor: pointer;
}
.iconButton img {
width: 16px;
height: 16px;
image-rendering: pixelated;
}
.iconButton:hover {
border-color: var(--color-accent);
}
.iconButton:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.toggle {
flex-shrink: 0;
}
.active {
border-color: var(--color-accent);
box-shadow: 2px 2px 0 var(--color-dim);
}
.strip {
display: flex;
align-items: center;
gap: 14px;
}
.channel {
display: flex;
align-items: center;
gap: 6px;
}
.label {
font-size: 8px;
}
.channel input {
width: 80px;
margin: 0;
accent-color: var(--color-accent);
}
.muted .label,
.muted input {
opacity: 0.4;
}
@@ -0,0 +1,130 @@
import { useEffect, useRef, useState, type SyntheticEvent } from 'react';
import { useSound } from '../audio/SoundContext';
import type { SoundSettings } from '../audio/types';
import { uiAssets } from '../theme/assets';
import styles from './SoundMenu.module.css';
/** Give keyboard focus back to the game, so Space or the arrows don't operate the control. */
const blur = (e: SyntheticEvent<HTMLElement>) => e.currentTarget.blur();
interface ChannelProps {
label: string;
muted: boolean;
volume: number;
onToggle: () => void;
onVolume: (volume: number) => void;
}
function Channel({ label, muted, volume, onToggle, onVolume }: ChannelProps) {
return (
<div className={[styles.channel, muted ? styles.muted : ''].join(' ')}>
<button
type="button"
className={styles.iconButton}
onClick={(e) => {
onToggle();
blur(e);
}}
title={muted ? `Unmute ${label.toLowerCase()}` : `Mute ${label.toLowerCase()}`}
aria-pressed={muted}
>
<img src={muted ? uiAssets.soundOff : uiAssets.soundOn} alt={muted ? 'Muted' : 'On'} />
</button>
<span className={styles.label}>{label}</span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={volume}
aria-label={`${label} volume`}
onChange={(e) => onVolume(Number(e.target.value))}
onPointerUp={blur}
/>
</div>
);
}
/**
* Sound button for the upper right corner. It opens an inline strip to its left with a mute button
* and a volume slider for effects and music. It only changes settings; the game keeps running.
*/
export function SoundMenu() {
const { settings, setSettings, toggleSfx, toggleMusic } = useSound();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Publish the menu's current width (it grows when open) as a CSS variable, so headers can keep
// their content clear of it, e.g. GamePage's lobby code and track label.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const style = document.documentElement.style;
const observer = new ResizeObserver(() => style.setProperty('--sound-menu-width', `${root.offsetWidth}px`));
observer.observe(root);
return () => {
observer.disconnect();
style.removeProperty('--sound-menu-width');
};
}, []);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [open]);
// Moving a muted slider turns that channel back on.
const setVolume = (volume: 'sfxVolume' | 'musicVolume', mutedKey: 'sfxMuted' | 'musicMuted', value: number) => {
const patch: Partial<SoundSettings> = { [volume]: value };
if (settings[mutedKey]) patch[mutedKey] = false;
setSettings(patch);
};
const allMuted = settings.sfxMuted && settings.musicMuted;
return (
<div className={styles.menu} ref={rootRef}>
<button
type="button"
className={[styles.iconButton, styles.toggle, open ? styles.active : ''].join(' ')}
onClick={(e) => {
setOpen((o) => !o);
blur(e);
}}
title="Sound"
aria-expanded={open}
>
<img src={allMuted ? uiAssets.soundOff : uiAssets.soundOn} alt="Sound" />
</button>
{open && (
<div className={styles.strip}>
<Channel
label="SFX"
muted={settings.sfxMuted}
volume={settings.sfxVolume}
onToggle={toggleSfx}
onVolume={(v) => setVolume('sfxVolume', 'sfxMuted', v)}
/>
<Channel
label="MUSIC"
muted={settings.musicMuted}
volume={settings.musicVolume}
onToggle={toggleMusic}
onVolume={(v) => setVolume('musicVolume', 'musicMuted', v)}
/>
</div>
)}
</div>
);
}
@@ -39,3 +39,20 @@
color: var(--color-accent);
border-color: var(--color-accent);
}
.danger {
background: var(--color-danger);
border-color: var(--color-danger);
color: var(--color-fg);
}
.small {
padding: 6px 10px;
font-size: 8px;
box-shadow: 2px 2px 0 var(--color-dim);
}
.small:active:not(:disabled) {
transform: translate(1px, 1px);
box-shadow: 1px 1px 0 var(--color-dim);
}
@@ -2,9 +2,15 @@ import type { ButtonHTMLAttributes } from 'react';
import styles from './Button.module.css';
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary';
variant?: 'primary' | 'secondary' | 'danger';
size?: 'normal' | 'small';
}
export function Button({ variant = 'primary', className, ...rest }: Props) {
return <button className={[styles.button, styles[variant], className].filter(Boolean).join(' ')} {...rest} />;
export function Button({ variant = 'primary', size = 'normal', className, ...rest }: Props) {
return (
<button
className={[styles.button, styles[variant], size === 'small' ? styles.small : '', className].filter(Boolean).join(' ')}
{...rest}
/>
);
}