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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user