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,9 @@
Optional audio files. Nothing in here is required: every sound effect and the default music are
synthesized in the browser (src/audio/synthSounds.ts and src/audio/songs.ts).
- Sound effects: put e.g. `line_clear.wav` here and register it in `soundFiles` in
`src/theme/assets.ts` under its SoundId (`lineClear: asset('assets/sounds/line_clear.wav')`).
- Music: put tracks in `music/` and add them to `musicTracks` in `src/theme/assets.ts`
(`{ id: 'my_track', title: 'My Track', kind: 'file', src: asset('assets/sounds/music/my_track.mp3') }`).
Any format the browser can decode works (wav, mp3, ogg). Only use files you have the rights to.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="7" y="2" width="1" height="1" fill="#ffffff"/><rect x="0" y="3" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="6" y="3" width="1" height="1" fill="#ffffff"/><rect x="0" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="4" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="7" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="5" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="0" y="3" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="7" y="3" width="1" height="1" fill="#ffffff"/><rect x="0" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="4" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="7" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="5" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="5" y="5" width="1" height="1" fill="#ffffff"/><rect x="2" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="6" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,151 @@
import { soundMix } from '../config/sound';
import { musicTracks, soundFiles } from '../theme/assets';
import { MusicPlaylist } from './MusicPlaylist';
import { defaultSoundSettings } from './settings';
import { synthSounds } from './synthSounds';
import type { MusicTrack, PlayOptions, SoundId, SoundPlayer, SoundSettings } from './types';
/** The same sound is not restarted more often than this, e.g. many moves inside one snapshot. */
const THROTTLE_MS = 30;
const log = (id: SoundId) => {
if (import.meta.env.DEV) console.debug('[sound]', id);
};
/**
* Default SoundPlayer built on the Web Audio API. Effects use a registered file from
* `soundFiles` when it loaded, otherwise the synth recipe; music comes from `musicTracks`.
*/
export class WebAudioPlayer implements SoundPlayer {
private ctx: AudioContext | null = null;
private sfxGain: GainNode | null = null;
private musicGain: GainNode | null = null;
/** One gain node per effect, set from soundMix.effects. */
private readonly effectGains = new Map<SoundId, GainNode>();
private playlist: MusicPlaylist | null = null;
private settings: SoundSettings = defaultSoundSettings;
private wantMusic = false;
private tempo = 1;
private readonly buffers = new Map<string, Promise<AudioBuffer | null>>();
private readonly loaded = new Map<string, AudioBuffer>();
private readonly lastPlayed = new Map<SoundId, number>();
private readonly trackListeners = new Set<() => void>();
unlock() {
if (!this.ctx) {
try {
this.ctx = new AudioContext();
} catch {
return; // No Web Audio support: the game simply stays silent.
}
const master = this.ctx.createGain();
master.gain.value = soundMix.master;
master.connect(this.ctx.destination);
this.sfxGain = this.ctx.createGain();
this.musicGain = this.ctx.createGain();
this.sfxGain.connect(master);
this.musicGain.connect(master);
this.playlist = new MusicPlaylist(this.ctx, this.musicGain, musicTracks, (src) => this.loadBuffer(src));
this.playlist.onChange(() => this.trackListeners.forEach((l) => l()));
this.applySettings(this.settings);
this.playlist.setTempo(this.tempo);
Object.values(soundFiles).forEach((src) => void this.loadBuffer(src));
if (this.wantMusic && !this.settings.musicMuted) this.playlist.start();
}
if (this.ctx.state === 'suspended') void this.ctx.resume();
}
play(id: SoundId, options: PlayOptions = {}) {
const ctx = this.ctx;
if (!ctx || !this.sfxGain || this.settings.sfxMuted || ctx.state !== 'running') return;
if (soundMix.effects[id] <= 0) return;
const now = performance.now();
if (now - (this.lastPlayed.get(id) ?? -Infinity) < THROTTLE_MS) return;
this.lastPlayed.set(id, now);
log(id);
const file = soundFiles[id];
const buffer = file ? this.loaded.get(file) : undefined;
if (buffer) {
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(this.effectGain(id));
source.start();
} else {
synthSounds[id](ctx, this.effectGain(id), options);
}
}
private effectGain(id: SoundId): GainNode {
let gain = this.effectGains.get(id);
if (!gain) {
gain = this.ctx!.createGain();
gain.gain.value = soundMix.effects[id];
gain.connect(this.sfxGain!);
this.effectGains.set(id, gain);
}
return gain;
}
startMusic() {
this.wantMusic = true;
if (!this.settings.musicMuted) this.playlist?.start();
}
stopMusic() {
this.wantMusic = false;
this.playlist?.stop();
}
nextTrack() {
if (this.playlist?.playing) this.playlist.next();
}
currentTrack(): MusicTrack | null {
return this.playlist?.currentTrack() ?? null;
}
onTrackChange(listener: () => void) {
this.trackListeners.add(listener);
return () => void this.trackListeners.delete(listener);
}
setMusicTempo(factor: number) {
this.tempo = factor;
this.playlist?.setTempo(factor);
}
applySettings(settings: SoundSettings) {
this.settings = settings;
if (!this.ctx || !this.sfxGain || !this.musicGain) return;
const t = this.ctx.currentTime;
// Squared for a more natural loudness curve on the sliders.
this.sfxGain.gain.setTargetAtTime(settings.sfxMuted ? 0 : settings.sfxVolume ** 2 * soundMix.sfx, t, 0.02);
this.musicGain.gain.setTargetAtTime(settings.musicVolume ** 2 * soundMix.music, t, 0.02);
this.playlist?.setDisabled(settings.disabledTracks);
// Music that is switched off doesn't run at all, so the header shows no track.
if (settings.musicMuted) this.playlist?.stop();
else if (this.wantMusic) this.playlist?.start();
}
private loadBuffer(src: string): Promise<AudioBuffer | null> {
let promise = this.buffers.get(src);
if (!promise) {
const ctx = this.ctx!;
promise = fetch(src)
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(new Error(response.statusText))))
.then((data) => ctx.decodeAudioData(data))
.then((buffer) => {
this.loaded.set(src, buffer);
return buffer;
})
.catch((error: unknown) => {
console.warn(`Could not load sound ${src}, using the synth sound instead.`, error);
return null;
});
this.buffers.set(src, promise);
}
return promise;
}
}
@@ -0,0 +1,150 @@
import { soundMix } from '../config/sound';
import { SynthSongPlayer } from './music';
import type { MusicTrack } from './types';
const log = (...args: unknown[]) => {
if (import.meta.env.DEV) console.debug('[music]', ...args);
};
/**
* Plays the registered music tracks one after another in shuffled order. Every enabled track is
* played once before the order is reshuffled. File tracks advance when they end, synth tracks
* after their configured number of loops.
*/
export class MusicPlaylist {
private queue: string[] = [];
private disabled = new Set<string>();
private current: MusicTrack | null = null;
private lastPlayed: string | null = null;
private stopCurrent: (() => void) | null = null;
private synth: SynthSongPlayer | null = null;
private tempo = 1;
/** Incremented on every start/stop so late async loads and end callbacks of old tracks are ignored. */
private generation = 0;
private readonly listeners = new Set<() => void>();
constructor(
private readonly ctx: AudioContext,
private readonly out: AudioNode,
private readonly tracks: MusicTrack[],
private readonly loadBuffer: (src: string) => Promise<AudioBuffer | null>,
) {}
get playing() {
return this.stopCurrent !== null;
}
currentTrack() {
return this.current;
}
onChange(listener: () => void) {
this.listeners.add(listener);
return () => void this.listeners.delete(listener);
}
setDisabled(ids: string[]) {
this.disabled = new Set(ids);
this.queue = this.queue.filter((id) => !this.disabled.has(id));
if (this.current && this.disabled.has(this.current.id) && this.playing) this.next();
}
setTempo(factor: number) {
this.tempo = factor;
this.synth?.setTempo(factor);
}
start() {
if (!this.playing) this.next();
}
stop() {
this.generation++;
this.stopCurrent?.();
this.stopCurrent = null;
this.synth = null;
if (this.current) log('stop', this.current.id);
this.setCurrent(null);
}
next() {
this.stop();
const track = this.takeNext();
if (!track) return;
const generation = this.generation;
this.setCurrent(track);
log('play', track.id);
// Each track gets its own level from the central mix.
const trackGain = this.ctx.createGain();
trackGain.gain.value = soundMix.tracks[track.id] ?? 1;
trackGain.connect(this.out);
// Marks playback active right away so start() during a file load doesn't start a second track.
this.stopCurrent = () => trackGain.disconnect();
const ended = () => {
if (generation === this.generation) this.next();
};
if (track.kind === 'synth') {
const player = new SynthSongPlayer(this.ctx, trackGain, track.song, ended);
player.setTempo(this.tempo);
player.start();
this.synth = player;
this.stopCurrent = () => {
player.stop();
// Disconnect after the player's short fade out.
window.setTimeout(() => trackGain.disconnect(), 100);
};
return;
}
void this.loadBuffer(track.src).then((buffer) => {
if (generation !== this.generation) return;
if (!buffer) {
log('skip, could not load', track.src);
// Avoid spinning when every enabled track fails to load.
window.setTimeout(ended, 500);
return;
}
const source = this.ctx.createBufferSource();
source.buffer = buffer;
source.connect(trackGain);
source.onended = ended;
source.start();
this.stopCurrent = () => {
source.onended = null;
source.stop();
source.disconnect();
trackGain.disconnect();
};
});
}
private takeNext(): MusicTrack | null {
const enabled = this.tracks.filter((t) => !this.disabled.has(t.id));
if (enabled.length === 0) return null;
if (this.queue.length === 0) {
this.queue = shuffle(enabled.map((t) => t.id));
// Don't repeat the last track right after reshuffling.
if (this.queue.length > 1 && this.queue[0] === this.lastPlayed) this.queue.push(this.queue.shift()!);
}
const id = this.queue.shift()!;
this.lastPlayed = id;
return this.tracks.find((t) => t.id === id) ?? null;
}
private setCurrent(track: MusicTrack | null) {
if (this.current === track) return;
this.current = track;
this.listeners.forEach((l) => l());
}
}
function shuffle<T>(items: T[]): T[] {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
@@ -0,0 +1,84 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
import { muteKey, musicKey, nextTrackKey } from '../config/keybindings';
import { WebAudioPlayer } from './AudioEngine';
import { loadSoundSettings, saveSoundSettings } from './settings';
import type { SoundPlayer, SoundSettings } from './types';
interface SoundApi {
player: SoundPlayer;
settings: SoundSettings;
setSettings: (patch: Partial<SoundSettings>) => void;
toggleSfx: () => void;
toggleMusic: () => void;
}
const SoundContext = createContext<SoundApi | null>(null);
interface Props {
children: ReactNode;
/** Defaults to the Web Audio engine; pass another SoundPlayer to replace it. */
createPlayer?: () => SoundPlayer;
}
export function SoundProvider({ children, createPlayer = () => new WebAudioPlayer() }: Props) {
const [player] = useState(createPlayer);
const [settings, setSettingsState] = useState(loadSoundSettings);
useEffect(() => {
player.applySettings(settings);
saveSoundSettings(settings);
}, [player, settings]);
const setSettings = useCallback((patch: Partial<SoundSettings>) => setSettingsState((s) => ({ ...s, ...patch })), []);
const toggleSfx = useCallback(() => setSettingsState((s) => ({ ...s, sfxMuted: !s.sfxMuted })), []);
const toggleMusic = useCallback(() => setSettingsState((s) => ({ ...s, musicMuted: !s.musicMuted })), []);
// Mutes everything if anything is audible, otherwise turns everything back on.
const toggleAll = useCallback(
() =>
setSettingsState((s) => {
const mute = !s.sfxMuted || !s.musicMuted;
return { ...s, sfxMuted: mute, musicMuted: mute };
}),
[],
);
useEffect(() => {
// Browsers only allow audio after a user gesture.
const unlock = () => player.unlock();
const onKeyDown = (e: KeyboardEvent) => {
unlock();
const target = e.target as HTMLElement | null;
if (e.repeat || target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA') return;
if (e.code === muteKey) toggleAll();
else if (e.code === musicKey) toggleMusic();
else if (e.code === nextTrackKey) player.nextTrack();
};
window.addEventListener('pointerdown', unlock);
window.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('pointerdown', unlock);
window.removeEventListener('keydown', onKeyDown);
};
}, [player, toggleAll, toggleMusic]);
const api = useMemo(
() => ({ player, settings, setSettings, toggleSfx, toggleMusic }),
[player, settings, setSettings, toggleSfx, toggleMusic],
);
return <SoundContext.Provider value={api}>{children}</SoundContext.Provider>;
}
export function useSound(): SoundApi {
const api = useContext(SoundContext);
if (!api) throw new Error('useSound must be used inside <SoundProvider>');
return api;
}
/** The music track that is playing right now, or null. */
export function useCurrentTrack() {
const { player } = useSound();
return useSyncExternalStore(
useCallback((onChange) => player.onTrackChange(onChange), [player]),
() => player.currentTrack(),
);
}
+132
View File
@@ -0,0 +1,132 @@
import { noteToFreq } from './pitch';
import { noise } from './synthSounds';
import type { SynthChannel, SynthSong } from './types';
type Step = { freq: number; length: number } | { hit: 'short' | 'long' } | null;
interface ParsedChannel {
wave: SynthChannel['wave'];
volume: number;
steps: Step[];
}
function parseChannel(channel: SynthChannel): ParsedChannel {
const tokens = channel.pattern.trim().split(/\s+/);
const steps: Step[] = [];
let lastNote: { freq: number; length: number } | null = null;
for (const token of tokens) {
if (token === '-') {
if (lastNote) lastNote.length++;
steps.push(null);
} else if (token === '.') {
lastNote = null;
steps.push(null);
} else if (token === 'x' || token === 'o') {
lastNote = null;
steps.push({ hit: token === 'x' ? 'short' : 'long' });
} else {
lastNote = { freq: noteToFreq(token), length: 1 };
steps.push(lastNote);
}
}
return { wave: channel.wave, volume: channel.volume, steps };
}
const LOOKAHEAD_S = 0.12;
const TIMER_MS = 25;
/**
* Plays a SynthSong with a lookahead scheduler: a timer regularly schedules the notes of the next
* ~120 ms on the audio clock, so playback stays exact even when the timer itself is late.
*/
export class SynthSongPlayer {
private readonly channels: ParsedChannel[];
private readonly loopSteps: number;
private readonly output: GainNode;
private timer: number | undefined;
private step = 0;
private loopsDone = 0;
private nextTime = 0;
private tempo = 1;
constructor(
private readonly ctx: AudioContext,
out: AudioNode,
private readonly song: SynthSong,
private readonly onEnded: () => void,
) {
this.channels = song.channels.map(parseChannel);
this.loopSteps = Math.max(...this.channels.map((c) => c.steps.length));
this.output = ctx.createGain();
this.output.connect(out);
}
setTempo(factor: number) {
this.tempo = factor;
}
start() {
this.nextTime = this.ctx.currentTime + 0.05;
this.timer = window.setInterval(() => this.schedule(), TIMER_MS);
this.schedule();
}
stop() {
window.clearInterval(this.timer);
this.timer = undefined;
// Fade out quickly; notes already scheduled ahead are cut off with the output node.
const t = this.ctx.currentTime;
this.output.gain.setValueAtTime(this.output.gain.value, t);
this.output.gain.linearRampToValueAtTime(0, t + 0.05);
window.setTimeout(() => this.output.disconnect(), 100);
}
private stepDuration() {
return 60 / (this.song.bpm * this.tempo) / this.song.stepsPerBeat;
}
private schedule() {
while (this.timer !== undefined && this.nextTime < this.ctx.currentTime + LOOKAHEAD_S) {
const duration = this.stepDuration();
for (const channel of this.channels) this.playStep(channel, channel.steps[this.step % channel.steps.length], duration);
this.nextTime += duration;
if (++this.step >= this.loopSteps) {
this.step = 0;
if (++this.loopsDone >= this.song.loops) {
this.onEnded();
return;
}
}
}
}
private playStep(channel: ParsedChannel, step: Step, stepDuration: number) {
if (!step) return;
const start = this.nextTime - this.ctx.currentTime;
if ('hit' in step) {
const long = step.hit === 'long';
noise(this.ctx, this.output, {
start,
duration: long ? 0.12 : 0.04,
volume: channel.volume,
filter: long ? 'bandpass' : 'highpass',
filterFrom: long ? 1800 : 7000,
});
return;
}
if (channel.wave === 'noise') return;
const t = this.nextTime;
const end = t + step.length * stepDuration * 0.92;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = channel.wave;
osc.frequency.setValueAtTime(step.freq, t);
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(channel.volume, t + 0.005);
gain.gain.setValueAtTime(channel.volume * 0.8, Math.max(t + 0.006, end - 0.03));
gain.gain.linearRampToValueAtTime(0, end);
osc.connect(gain).connect(this.output);
osc.start(t);
osc.stop(end + 0.01);
}
}
+11
View File
@@ -0,0 +1,11 @@
const SEMITONES: Record<string, number> = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
/** Converts a note name like "A4", "C#5" or "Bb3" to a frequency in Hz (A4 = 440). */
export function noteToFreq(name: string): number {
const match = /^([A-G])([#b]?)(-?\d)$/.exec(name);
if (!match) throw new Error(`Invalid note name: ${name}`);
const [, letter, accidental, octave] = match;
const semitone = SEMITONES[letter] + (accidental === '#' ? 1 : accidental === 'b' ? -1 : 0);
const midi = (Number(octave) + 1) * 12 + semitone;
return 440 * 2 ** ((midi - 69) / 12);
}
@@ -0,0 +1,40 @@
import { defaultMusicVolume, defaultSfxVolume } from '../config/sound';
import type { SoundSettings } from './types';
const SETTINGS_KEY = 'actionblocks.sound';
export const defaultSoundSettings: SoundSettings = {
sfxMuted: false,
musicMuted: false,
sfxVolume: defaultSfxVolume,
musicVolume: defaultMusicVolume,
disabledTracks: [],
};
const volume = (value: unknown, fallback: number) =>
typeof value === 'number' && value >= 0 && value <= 1 ? value : fallback;
export function loadSoundSettings(): SoundSettings {
try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as Partial<SoundSettings> & { muted?: boolean };
// Older versions had a single "muted" flag for everything.
const legacyMuted = stored.muted === true;
return {
sfxMuted: stored.sfxMuted === true || legacyMuted,
musicMuted: stored.musicMuted === true || legacyMuted,
sfxVolume: volume(stored.sfxVolume, defaultSoundSettings.sfxVolume),
musicVolume: volume(stored.musicVolume, defaultSoundSettings.musicVolume),
disabledTracks: Array.isArray(stored.disabledTracks) ? stored.disabledTracks.filter((id) => typeof id === 'string') : [],
};
} catch {
return defaultSoundSettings;
}
}
export function saveSoundSettings(settings: SoundSettings) {
try {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {
// Storage may be unavailable (private mode); settings are just not remembered.
}
}
+111
View File
@@ -0,0 +1,111 @@
import type { SynthSong } from './types';
/*
* Original background tunes for the synth sequencer (see SynthChannel for the pattern format).
* Each line of a pattern is one bar of eighth notes. Channel volumes balance the voices of a
* song against each other; the overall level of each track is set in config/sound.ts.
*/
const pixelRush: SynthSong = {
bpm: 150,
stepsPerBeat: 2,
loops: 8,
channels: [
{
wave: 'triangle',
volume: 0.3,
pattern: `
A2 A3 A2 A3 A2 A3 A2 A3
F2 F3 F2 F3 F2 F3 F2 F3
C3 C4 C3 C4 C3 C4 C3 C4
G2 G3 G2 G3 G2 G3 G2 G3`,
},
{
wave: 'square',
volume: 0.07,
pattern: `
A4 . C5 . E5 . D5 C5
C5 - A4 . F4 . A4 .
G4 . C5 . E5 . G5 E5
D5 - - . B4 . G4 .
A4 . C5 . E5 . A5 .
G5 F5 E5 . C5 . A4 .
E5 . D5 . C5 . B4 .
B4 - D5 - G5 - - .`,
},
{ wave: 'noise', volume: 0.05, pattern: 'x . o . x x o .' },
],
};
const neonStack: SynthSong = {
bpm: 132,
stepsPerBeat: 2,
loops: 6,
channels: [
{
wave: 'triangle',
volume: 0.3,
pattern: `
E2 . E2 E3 . E2 E3 .
C2 . C2 C3 . C2 C3 .
G2 . G2 G3 . G2 G3 .
D2 . D2 D3 . D2 D3 .`,
},
{
wave: 'square',
volume: 0.06,
pattern: `
B4 - - . G4 - E4 .
C5 - - . E5 - D5 .
B4 - - . D5 - G5 .
F#5 - - - D5 - - .
E5 - - . B4 - G4 .
A4 - G4 - E4 - C5 .
B4 - D5 - G5 - F#5 .
E5 - - - - - - .`,
},
{
wave: 'triangle',
volume: 0.08,
pattern: `
. . G4 . . . B4 .
. . G4 . . . E4 .
. . B4 . . . D5 .
. . A4 . . . F#4 .`,
},
{ wave: 'noise', volume: 0.05, pattern: 'x . o . x x o .' },
],
};
const lastLine: SynthSong = {
bpm: 168,
stepsPerBeat: 2,
loops: 8,
channels: [
{
wave: 'triangle',
volume: 0.3,
pattern: `
D2 D2 D3 D2 D2 D3 D2 D3
A#1 A#1 A#2 A#1 A#1 A#2 A#1 A#2
C2 C2 C3 C2 C2 C3 C2 C3
A1 A1 A2 A1 A1 A2 A1 A2`,
},
{
wave: 'square',
volume: 0.06,
pattern: `
D5 F5 A5 F5 D5 F5 A5 F5
D5 F5 A#5 F5 D5 F5 A#5 F5
E5 G5 C6 G5 E5 G5 C6 G5
C#5 E5 A5 E5 C#5 E5 A5 E5
A5 - G5 - F5 - E5 F5
D5 - - . A#4 - D5 .
E5 - F5 - G5 - E5 C5
C#5 - E5 - A5 - - .`,
},
{ wave: 'noise', volume: 0.05, pattern: 'x x o x x x o x' },
],
};
export const songs = { pixelRush, neonStack, lastLine };
@@ -0,0 +1,131 @@
import { noteToFreq } from './pitch';
import type { PlayOptions, SoundId } from './types';
/*
* The volumes in these recipes shape each sound and balance its parts against each other.
* To make a whole sound louder or quieter, change its value in config/sound.ts instead.
*/
/** Builds one sound effect from oscillators and noise, connected to `out`. */
export type SynthRecipe = (ctx: AudioContext, out: AudioNode, options: PlayOptions) => void;
interface ToneOptions {
type?: OscillatorType;
from: number;
to?: number;
/** Delay in seconds from now. */
start?: number;
duration: number;
volume?: number;
}
/** A single oscillator with an exponential fade out and an optional pitch slide. */
export function tone(ctx: AudioContext, out: AudioNode, { type = 'square', from, to = from, start = 0, duration, volume = 0.2 }: ToneOptions) {
const t = ctx.currentTime + start;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(from, t);
if (to !== from) osc.frequency.exponentialRampToValueAtTime(to, t + duration);
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
osc.connect(gain).connect(out);
osc.start(t);
osc.stop(t + duration + 0.02);
}
interface NoiseOptions {
start?: number;
duration: number;
volume?: number;
filter?: BiquadFilterType;
filterFrom?: number;
filterTo?: number;
}
const noiseBuffers = new WeakMap<BaseAudioContext, AudioBuffer>();
function noiseBuffer(ctx: BaseAudioContext): AudioBuffer {
let buffer = noiseBuffers.get(ctx);
if (!buffer) {
buffer = ctx.createBuffer(1, ctx.sampleRate, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
noiseBuffers.set(ctx, buffer);
}
return buffer;
}
/** Filtered white noise burst, for drops, hits and drums. */
export function noise(
ctx: AudioContext,
out: AudioNode,
{ start = 0, duration, volume = 0.2, filter = 'lowpass', filterFrom = 4000, filterTo = filterFrom }: NoiseOptions,
) {
const t = ctx.currentTime + start;
const source = ctx.createBufferSource();
source.buffer = noiseBuffer(ctx);
const biquad = ctx.createBiquadFilter();
biquad.type = filter;
biquad.frequency.setValueAtTime(filterFrom, t);
if (filterTo !== filterFrom) biquad.frequency.exponentialRampToValueAtTime(filterTo, t + duration);
const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, t);
gain.gain.exponentialRampToValueAtTime(0.0001, t + duration);
source.connect(biquad).connect(gain).connect(out);
source.start(t);
source.stop(t + duration + 0.02);
}
/** Plays notes one after another, `step` seconds apart. */
function arpeggio(ctx: AudioContext, out: AudioNode, notes: string[], step: number, type: OscillatorType, volume: number, noteLength = step * 1.5) {
notes.forEach((note, i) =>
tone(ctx, out, { type, from: noteToFreq(note), start: i * step, duration: noteLength, volume }),
);
}
export const synthSounds: Record<SoundId, SynthRecipe> = {
move: (ctx, out) => tone(ctx, out, { from: 220, duration: 0.03, volume: 0.06 }),
rotate: (ctx, out) => tone(ctx, out, { from: 440, to: 660, duration: 0.045, volume: 0.07 }),
hold: (ctx, out) => {
tone(ctx, out, { type: 'triangle', from: 330, to: 495, duration: 0.07, volume: 0.2 });
tone(ctx, out, { type: 'triangle', from: 495, start: 0.05, duration: 0.08, volume: 0.18 });
},
hardDrop: (ctx, out) => {
noise(ctx, out, { duration: 0.12, volume: 0.25, filterFrom: 3000, filterTo: 200 });
tone(ctx, out, { from: 160, to: 60, duration: 0.1, volume: 0.12 });
},
lock: (ctx, out) => tone(ctx, out, { type: 'triangle', from: 130, to: 80, duration: 0.07, volume: 0.25 }),
lineClear: (ctx, out, { intensity = 1 }) => {
// Each extra line raises the sweep by a major third.
const base = 523 * 2 ** (((Math.min(intensity, 3) - 1) * 4) / 12);
tone(ctx, out, { from: base, to: base * 2, duration: 0.16, volume: 0.1 });
tone(ctx, out, { type: 'triangle', from: base * 1.5, to: base * 3, start: 0.04, duration: 0.16, volume: 0.15 });
noise(ctx, out, { duration: 0.18, volume: 0.06, filter: 'highpass', filterFrom: 2000, filterTo: 8000 });
},
quad: (ctx, out) => {
arpeggio(ctx, out, ['C5', 'E5', 'G5', 'C6', 'E6'], 0.065, 'square', 0.09);
tone(ctx, out, { type: 'triangle', from: noteToFreq('C6'), start: 0.33, duration: 0.4, volume: 0.2 });
},
garbageIncoming: (ctx, out) => tone(ctx, out, { type: 'sawtooth', from: 110, to: 70, duration: 0.2, volume: 0.09 }),
itemPickup: (ctx, out) => {
tone(ctx, out, { from: noteToFreq('E6'), duration: 0.06, volume: 0.08 });
tone(ctx, out, { from: noteToFreq('B6'), start: 0.06, duration: 0.12, volume: 0.08 });
},
itemUse: (ctx, out) => {
tone(ctx, out, { from: 660, to: 1760, duration: 0.14, volume: 0.08 });
noise(ctx, out, { duration: 0.12, volume: 0.06, filter: 'highpass', filterFrom: 1000, filterTo: 6000 });
},
itemHit: (ctx, out) => {
tone(ctx, out, { type: 'sawtooth', from: 600, to: 120, duration: 0.28, volume: 0.1 });
noise(ctx, out, { duration: 0.22, volume: 0.12, filterFrom: 5000, filterTo: 300 });
},
countdown: (ctx, out) => tone(ctx, out, { from: noteToFreq('A4'), duration: 0.12, volume: 0.1 }),
go: (ctx, out) => tone(ctx, out, { from: noteToFreq('A5'), duration: 0.35, volume: 0.1 }),
knockout: (ctx, out) => {
tone(ctx, out, { from: 400, to: 100, duration: 0.3, volume: 0.08 });
noise(ctx, out, { start: 0.05, duration: 0.25, volume: 0.1, filterFrom: 1500, filterTo: 100 });
},
lose: (ctx, out) => arpeggio(ctx, out, ['G4', 'E4', 'C4', 'G3'], 0.18, 'triangle', 0.25, 0.22),
win: (ctx, out) => arpeggio(ctx, out, ['C5', 'E5', 'G5', 'C6', 'G5', 'C6'], 0.1, 'square', 0.08, 0.16),
};
+77
View File
@@ -0,0 +1,77 @@
/** Every sound effect the game can play. Add an id here, a recipe in synthSounds.ts and a trigger. */
export type SoundId =
| 'move'
| 'rotate'
| 'hold'
| 'hardDrop'
| 'lock'
| 'lineClear'
| 'quad'
| 'garbageIncoming'
| 'itemPickup'
| 'itemUse'
| 'itemHit'
| 'countdown'
| 'go'
| 'knockout'
| 'lose'
| 'win';
export interface PlayOptions {
/** Effect strength, e.g. number of lines cleared. Synth recipes raise their pitch with it. */
intensity?: number;
}
/**
* One voice of a synth song. `pattern` is a space separated list of steps:
* a note name ("C4", "F#3", "A#5") starts a note, "-" holds the previous note, "." is a rest.
* Noise channels use "x" (short hit) and "o" (long hit) instead of notes.
* Channels shorter than the longest one repeat.
*/
export interface SynthChannel {
wave: OscillatorType | 'noise';
volume: number;
pattern: string;
}
export interface SynthSong {
bpm: number;
/** Steps per beat, 2 = eighth notes, 4 = sixteenth notes. */
stepsPerBeat: number;
/** How often the pattern repeats before the playlist moves to the next track. */
loops: number;
channels: SynthChannel[];
}
export type MusicTrack =
| { id: string; title: string; kind: 'synth'; song: SynthSong }
| { id: string; title: string; kind: 'file'; src: string };
export interface SoundSettings {
/** Silences the sound effects. */
sfxMuted: boolean;
/** Turns off the background music. */
musicMuted: boolean;
/** 0..1 */
sfxVolume: number;
/** 0..1 */
musicVolume: number;
/** Ids of music tracks the player switched off. */
disabledTracks: string[];
}
/** Everything the UI needs from the sound system. Implement it to replace the Web Audio engine. */
export interface SoundPlayer {
/** Must be called from a user gesture once; browsers block audio before that. */
unlock(): void;
play(id: SoundId, options?: PlayOptions): void;
startMusic(): void;
stopMusic(): void;
nextTrack(): void;
currentTrack(): MusicTrack | null;
/** Subscribe to track changes; returns the unsubscribe function. */
onTrackChange(listener: () => void): () => void;
/** 1 = normal speed. Only affects synth songs. */
setMusicTempo(factor: number): void;
applySettings(settings: SoundSettings): void;
}
@@ -0,0 +1,86 @@
import { useEffect, useRef } from 'react';
import type { ActivePieceState, MatchState, PlayerState } from '../net/types';
import { useSound } from './SoundContext';
interface Previous {
me?: PlayerState;
phase?: MatchState['phase'];
countdown?: number;
}
/** Shape of a piece independent of its position, to tell rotations from moves. */
function shapeKey(piece: ActivePieceState) {
const minX = Math.min(...piece.cells.map(([x]) => x));
const minY = Math.min(...piece.cells.map(([, y]) => y));
return piece.cells
.map(([x, y]) => `${x - minX},${y - minY}`)
.sort()
.join(' ');
}
const queueKey = (p: PlayerState) => p.next.map((n) => n.type).join('');
/**
* Plays sounds for everything that changes between two match snapshots. Snapshots are
* server-confirmed, so blocked moves or rotations stay silent.
*/
export function useGameSounds(match: MatchState, playerId: string) {
const { player } = useSound();
const previous = useRef<Previous>({});
useEffect(() => {
const before = previous.current;
const me = match.players.find((p) => p.id === playerId);
const countdown = Math.ceil(match.countdown);
if (match.phase === 'countdown' && countdown > 0 && countdown !== before.countdown) player.play('countdown');
if (match.phase === 'running' && before.phase !== 'running') {
if (before.phase === 'countdown') player.play('go');
player.startMusic();
}
if (match.phase === 'finished' && before.phase !== 'finished') {
player.stopMusic();
if (match.winnerId === playerId) player.play('win');
}
for (const event of match.events) {
if (event.type === 'itemUsed' && event.targetId === playerId) player.play('itemHit');
else if (event.type === 'eliminated' && event.playerId === playerId) player.play('lose');
// The last knockout ends the match; the win sound plays instead.
else if (event.type === 'eliminated' && match.phase !== 'finished') player.play('knockout');
}
const old = before.me;
if (me && old && me.alive) {
const cleared = me.lines - old.lines;
if (cleared >= 4) player.play('quad');
else if (cleared > 0) player.play('lineClear', { intensity: cleared });
const held = !old.holdUsed && me.holdUsed;
const spawned = queueKey(me) !== queueKey(old);
if (held) player.play('hold');
else if (spawned && cleared === 0) player.play('lock');
if (!held && !spawned && me.active && old.active) {
if (shapeKey(me.active) !== shapeKey(old.active)) player.play('rotate');
else if (me.active.cells[0][0] !== old.active.cells[0][0]) player.play('move');
}
if (me.inventory.length > old.inventory.length) player.play('itemPickup');
else if (me.inventory.length < old.inventory.length) player.play('itemUse');
if (me.pendingGarbage > old.pendingGarbage) player.play('garbageIncoming');
player.setMusicTempo(Math.min(1.3, 1 + (me.level - 1) * 0.03));
}
previous.current = { me, phase: match.phase, countdown };
}, [match, playerId, player]);
useEffect(
() => () => {
player.stopMusic();
player.setMusicTempo(1);
},
[player],
);
}
@@ -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}
/>
);
}
@@ -21,6 +21,11 @@ export const keyBindings: Record<BoundAction, string[]> = {
/** Number row keys select the item target: 1 = yourself, 2..9 = opponents in display order. */
export const targetKeys = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9'];
/** Global sound keys, active on every screen. */
export const muteKey = 'KeyM';
export const musicKey = 'KeyB';
export const nextTrackKey = 'KeyN';
/** Delayed auto shift and auto repeat rate in milliseconds. */
export const DAS_MS = 167;
export const ARR_MS = 33;
@@ -35,6 +40,9 @@ export const controlsHelp: { keys: string; action: string }[] = [
{ keys: 'C / SHIFT', action: 'Hold' },
{ keys: '1 - 9', action: 'Select target (1 = you)' },
{ keys: 'F', action: 'Use item on target' },
{ keys: 'M', action: 'All sound on/off' },
{ keys: 'B', action: 'Music on/off' },
{ keys: 'N', action: 'Next music track' },
];
export const simpleActions: Partial<Record<BoundAction, InputAction>> = {
+44
View File
@@ -0,0 +1,44 @@
import type { SoundId } from '../audio/types';
/**
* Central mix for developers. Every value multiplies the level a sound was designed with:
* 1 = as designed, 0 = silent, above 1 = louder (up to about 2 is sensible).
* The players' volume sliders apply on top of this.
*/
export const soundMix = {
/** Applied to everything. */
master: 1,
/** All sound effects. */
sfx: 1,
/** All background music. */
music: 0.1,
/** Per sound effect. Every SoundId must be listed, so new effects can't be forgotten. */
effects: {
move: 1,
rotate: 1,
hold: 1,
hardDrop: 1,
lock: 1,
lineClear: 1,
quad: 1,
garbageIncoming: 1,
itemPickup: 1,
itemUse: 1,
itemHit: 1,
countdown: 1,
go: 1,
knockout: 1,
lose: 1,
win: 1,
} satisfies Record<SoundId, number>,
/** Per music track, by its id in `musicTracks` (theme/assets.ts). Tracks not listed use 1. */
tracks: {
pixel_rush: 1,
neon_stack: 1,
last_line: 1,
} as Partial<Record<string, number>>,
};
/** Initial slider positions (0..1) for players without saved settings. */
export const defaultSfxVolume = 0.6;
export const defaultMusicVolume = 0.35;
+4 -1
View File
@@ -1,6 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import { SoundProvider } from './audio/SoundContext';
import { preloadAssets } from './theme/assets';
import './theme/global.css';
@@ -8,6 +9,8 @@ preloadAssets();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<SoundProvider>
<App />
</SoundProvider>
</StrictMode>,
);
@@ -1,6 +1,6 @@
.page {
min-height: 100vh;
padding: 12px 16px 24px;
padding: 16px 16px 24px;
}
.header {
@@ -8,7 +8,23 @@
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 26px;
margin-bottom: 14px;
/* Keep clear of the fixed sound menu in the corner (CornerControls), also while it is open. */
padding-right: calc(var(--sound-menu-width, 26px) + 18px);
}
.headerInfo {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.track {
color: var(--color-muted);
font-size: 8px;
}
.lobbyCode {
+26 -3
View File
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCurrentTrack, useSound } from '../audio/SoundContext';
import { useGameSounds } from '../audio/useGameSounds';
import { BoardCanvas } from '../components/BoardCanvas';
import { ItemBar } from '../components/ItemBar';
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
import { Logo } from '../components/Logo';
import { MiniBoard } from '../components/MiniBoard';
import { PiecePreview } from '../components/PiecePreview';
import { CornerControls } from '../components/CornerControls';
import { useKeyboardInput } from '../input/useKeyboardInput';
import type { Lobby, MatchState } from '../net/types';
import type { InputAction, Lobby, MatchState } from '../net/types';
import type { GameActions, ItemFlash } from '../net/useGameConnection';
import { itemVisual } from '../theme/assets';
import styles from './GamePage.module.css';
@@ -56,9 +59,22 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
[me, opponents, actions],
);
const { player } = useSound();
const track = useCurrentTrack();
useGameSounds(match, playerId);
// A hard drop can't be told apart from a normal lock in the snapshots, so it sounds on input.
const onAction = useCallback(
(action: InputAction) => {
if (action === 'HardDrop') player.play('hardDrop');
actions.sendInput(action);
},
[actions, player],
);
useKeyboardInput({
enabled: !!me?.alive && match.phase !== 'finished',
onAction: actions.sendInput,
onAction,
onTarget,
});
@@ -70,8 +86,15 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
<main className={styles.page}>
<header className={styles.header}>
<Logo small />
<span className={styles.lobbyCode}>LOBBY {lobby.code}</span>
<div className={styles.headerInfo}>
{track && <span className={styles.track}>MUSIC: {track.title} (N)</span>}
<span className={styles.lobbyCode}>LOBBY {lobby.code}</span>
</div>
</header>
<CornerControls
onLeave={() => void actions.leaveLobby()}
leaveConfirm={!!me?.alive && match.phase !== 'finished'}
/>
<div className={styles.arena}>
{me ? (
@@ -1,5 +1,6 @@
import { useState, type FormEvent } from 'react';
import { Logo } from '../components/Logo';
import { CornerControls } from '../components/CornerControls';
import { Button } from '../components/ui/Button';
import { Panel } from '../components/ui/Panel';
import type { GameActions } from '../net/useGameConnection';
@@ -57,6 +58,7 @@ export function HomePage({ connected, actions }: Props) {
return (
<main className={styles.page}>
<CornerControls />
<Logo />
<p className={styles.tagline}>Stack. Clear. Collect. Strike.</p>
+7 -3
View File
@@ -1,5 +1,7 @@
import { useState } from 'react';
import { Logo } from '../components/Logo';
import { MusicTracks } from '../components/MusicTracks';
import { CornerControls } from '../components/CornerControls';
import { Button } from '../components/ui/Button';
import { Panel } from '../components/ui/Panel';
import { controlsHelp } from '../config/keybindings';
@@ -43,10 +45,8 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
<main className={styles.page}>
<header className={styles.header}>
<Logo small />
<Button variant="secondary" onClick={() => void actions.leaveLobby()}>
Leave
</Button>
</header>
<CornerControls onLeave={() => void actions.leaveLobby()} />
<Panel title="Lobby code" className={styles.codePanel}>
<div className={styles.code}>{lobby.code}</div>
@@ -118,6 +118,10 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
))}
</ul>
</Panel>
<Panel title="Music">
<MusicTracks />
</Panel>
</div>
</main>
);
+23 -2
View File
@@ -1,6 +1,9 @@
import { songs } from '../audio/songs';
import type { MusicTrack, SoundId } from '../audio/types';
/**
* Central registry of every graphic the game uses. Replace the files in /public/assets
* (or point these paths elsewhere) to change the look without touching components.
* Central registry of every graphic and sound the game uses. Replace the files in /public/assets
* (or point these paths elsewhere) to change the look and sound without touching components.
*/
/** Resolves a file in /public relative to where the site is served (works under sub-paths). */
@@ -62,8 +65,26 @@ export const uiAssets = {
/** Optional logo image. When null the text logo is rendered. */
logo: null as string | null,
favicon: asset('assets/favicon.svg'),
soundOn: asset('assets/ui/sound_on.svg'),
soundOff: asset('assets/ui/sound_off.svg'),
};
/**
* Optional audio files that replace the synthesized sound effects. Put the files in
* /public/assets/sounds/. Effects without an entry (or whose file fails to load) use the synth.
*/
export const soundFiles: Partial<Record<SoundId, string>> = {
// lineClear: asset('assets/sounds/line_clear.wav'),
};
/** Background music, played as a shuffled playlist during matches. Add as many tracks as you like. */
export const musicTracks: MusicTrack[] = [
{ id: 'pixel_rush', title: 'Pixel Rush', kind: 'synth', song: songs.pixelRush },
{ id: 'neon_stack', title: 'Neon Stack', kind: 'synth', song: songs.neonStack },
{ id: 'last_line', title: 'Last Line', kind: 'synth', song: songs.lastLine },
// { id: 'my_track', title: 'My Track', kind: 'file', src: asset('assets/sounds/music/my_track.mp3') },
];
export function itemVisual(id: string): ItemVisual {
return itemVisuals[id] ?? unknownItem;
}