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:
@@ -17,6 +17,7 @@ Online player-vs-player falling-block game with items.
|
||||
- **1-9** selects the item target (1 = you, 2.. = opponents in the order shown). **F** uses the first item on the target.
|
||||
- You never see who targets you, but every opponent's items are shown beneath their mini field.
|
||||
- Item names flash on top of the field they hit.
|
||||
- Retro sound effects and a background music playlist, all synthesized in the browser. Any sound can be swapped for an audio file. The speaker button in the upper right corner opens volume sliders and mute buttons for effects and music. **M** mutes everything, **B** toggles the music, **N** skips the track.
|
||||
|
||||
Controls: arrows or WASD to move/rotate/soft drop, Space for hard drop, Z/Q/Ctrl to rotate left, C/Shift to hold.
|
||||
|
||||
@@ -74,3 +75,4 @@ The reverse proxy (attached to both networks) should route:
|
||||
- Block colors are in `src/theme/colors.ts`.
|
||||
- Block drawing style is in `src/render/draw.ts`.
|
||||
- Key bindings are in `src/config/keybindings.ts`.
|
||||
- Sound files and music tracks are registered in `src/theme/assets.ts` (`soundFiles`, `musicTracks`). All volume levels are in `src/config/sound.ts`.
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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>> = {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ Step-by-step guides for the most common changes.
|
||||
- [Add a new match event](#add-a-new-match-event)
|
||||
- [Change graphics and colors](#change-graphics-and-colors)
|
||||
- [Change key bindings](#change-key-bindings)
|
||||
- [Add or replace sounds and music](#add-or-replace-sounds-and-music)
|
||||
|
||||
---
|
||||
|
||||
@@ -179,3 +180,81 @@ Edit `src/config/keybindings.ts`:
|
||||
- `controlsHelp` holds the texts shown in the lobby's Controls panel. Keep them ASCII.
|
||||
|
||||
To add a new **action**, add it to `BoundAction` and `keyBindings`. If it maps 1:1 to a server action, add it to `simpleActions`. A new server action also needs an `InputAction` value on the server, handled in `PlayerGame.HandleInput` or `Match.HandleInput`, plus the matching string in `src/net/types.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Add or replace sounds and music
|
||||
|
||||
The audio system is described in [Frontend > Audio](frontend.md#audio-srcaudio). Everything you can swap is registered in `src/theme/assets.ts`, next to the graphics.
|
||||
|
||||
### Balance the volumes
|
||||
|
||||
All loudness settings for developers are in **`src/config/sound.ts`**:
|
||||
|
||||
| Setting | Affects |
|
||||
|---|---|
|
||||
| `soundMix.master` | Everything |
|
||||
| `soundMix.sfx` / `soundMix.music` | All effects / all music |
|
||||
| `soundMix.effects.<id>` | One effect, e.g. `move: 0.5` makes moving half as loud |
|
||||
| `soundMix.tracks.<track id>` | One music track. Tracks not listed play at 1 |
|
||||
| `defaultSfxVolume` / `defaultMusicVolume` | Where the player's sliders start (0..1) when no settings are saved yet |
|
||||
|
||||
Every value multiplies the level the sound was designed with: `1` is unchanged, `0` is silent (an effect at `0` is not played at all), and values above `1` make it louder. The levels apply to synthesized sounds and to registered audio files alike. The player's volume sliders apply on top.
|
||||
|
||||
The `volume` numbers inside `synthSounds.ts` and `songs.ts` shape a sound (balance between its tones, or between a song's voices). Change those only when you redesign a sound itself.
|
||||
|
||||
### Replace an effect with an audio file
|
||||
|
||||
Put the file in `public/assets/sounds/` and register it under its `SoundId`:
|
||||
|
||||
```ts
|
||||
export const soundFiles: Partial<Record<SoundId, string>> = {
|
||||
lineClear: asset('assets/sounds/line_clear.wav'),
|
||||
};
|
||||
```
|
||||
|
||||
Any format the browser can decode works (wav, mp3, ogg). If the file fails to load, the synth sound is used and a warning is logged.
|
||||
|
||||
### Add music tracks
|
||||
|
||||
`musicTracks` is a playlist, and you can register as many tracks as you like. Tracks play in shuffled order, and players can switch single tracks off in the lobby.
|
||||
|
||||
```ts
|
||||
export const musicTracks: MusicTrack[] = [
|
||||
{ id: 'pixel_rush', title: 'Pixel Rush', kind: 'synth', song: songs.pixelRush },
|
||||
{ id: 'boss_fight', title: 'Boss Fight', kind: 'file', src: asset('assets/sounds/music/boss_fight.mp3') },
|
||||
];
|
||||
```
|
||||
|
||||
- **File track:** put the file in `public/assets/sounds/music/`. The next track starts when the file ends.
|
||||
- **Synth track:** add a `SynthSong` to `src/audio/songs.ts`. Each channel is a waveform, a volume and a text pattern: a note name (`C4`, `F#3`, `A#5`) starts a note, `-` holds it, `.` is a rest. Noise channels use `x` (short hit) and `o` (long hit). Shorter channels repeat until the longest one ends. After `loops` repetitions the playlist moves on.
|
||||
|
||||
```ts
|
||||
const myTune: SynthSong = {
|
||||
bpm: 140,
|
||||
stepsPerBeat: 2, // eighth notes
|
||||
loops: 6,
|
||||
channels: [
|
||||
{ wave: 'triangle', volume: 0.3, pattern: 'C3 C4 C3 C4 G2 G3 G2 G3' },
|
||||
{ wave: 'square', volume: 0.06, pattern: 'E5 - G5 . C6 - - . B5 - G5 . D5 - - .' },
|
||||
{ wave: 'noise', volume: 0.05, pattern: 'x . o .' },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The `id` must be unique and stay stable, because disabled tracks are saved by id. To change a track's level, add its id to `soundMix.tracks` in `src/config/sound.ts`.
|
||||
|
||||
### Add a new sound effect
|
||||
|
||||
1. Add the id to `SoundId` in `src/audio/types.ts`.
|
||||
2. Add a recipe to `synthSounds` in `src/audio/synthSounds.ts` (usually a few `tone()` / `noise()` calls) and a volume to `soundMix.effects` in `src/config/sound.ts`. TypeScript reports an error until both exist for every id.
|
||||
3. Trigger it with `player.play('myId')`: from `useGameSounds` for something visible in the snapshots, or anywhere through `useSound().player`.
|
||||
4. If the server should announce something the client can't detect, add a [match event](#add-a-new-match-event) and react to it in `useGameSounds`.
|
||||
|
||||
### Replace the whole engine
|
||||
|
||||
The UI only talks to the `SoundPlayer` interface (`src/audio/types.ts`). To use a different audio library, implement that interface and pass it in `main.tsx`:
|
||||
|
||||
```tsx
|
||||
<SoundProvider createPlayer={() => new MyHowlerPlayer()}>
|
||||
```
|
||||
|
||||
+52
-2
@@ -10,7 +10,9 @@ actionBlocks_website/
|
||||
│ ├── config.js # runtime config (overwritten in the container)
|
||||
│ └── assets/ # exchangeable graphics
|
||||
│ ├── favicon.svg
|
||||
│ └── items/*.svg # one icon per item id (+ unknown.svg)
|
||||
│ ├── items/*.svg # one icon per item id (+ unknown.svg)
|
||||
│ ├── ui/ # UI icons (sound menu)
|
||||
│ └── sounds/ # optional audio files (+ music/)
|
||||
├── docker/
|
||||
│ ├── nginx.conf
|
||||
│ └── 40-runtime-config.sh # writes config.js from env SERVER_URL
|
||||
@@ -25,6 +27,7 @@ actionBlocks_website/
|
||||
│ ├── hubClient.ts # typed SignalR wrapper
|
||||
│ └── useGameConnection.ts # React hook: connection + all server state
|
||||
├── input/useKeyboardInput.ts # keyboard → game actions (with DAS/ARR)
|
||||
├── audio/ # sound effects + music (see "Audio" below)
|
||||
├── render/
|
||||
│ ├── draw.ts # canvas drawing: blocks, items, ghost, board, previews
|
||||
│ └── shapes.ts # piece shapes for previews
|
||||
@@ -34,7 +37,7 @@ actionBlocks_website/
|
||||
│ └── assets.ts # registry of every graphic + item visuals
|
||||
├── pages/ # HomePage, LobbyPage, GamePage (+ .module.css)
|
||||
└── components/ # BoardCanvas, MiniBoard, PiecePreview, ItemBar,
|
||||
# ItemFlashOverlay, Logo, ui/Button, ui/Panel
|
||||
# ItemFlashOverlay, Logo, CornerControls, SoundMenu, MusicTracks, ui/Button, ui/Panel
|
||||
```
|
||||
|
||||
## Data flow
|
||||
@@ -88,6 +91,7 @@ Layout: **hold + stats + "1 YOU"**, then the **own board** with the garbage mete
|
||||
- The selected target lives in local state, `targetId`. Slot `1` is yourself and slot `n ≥ 2` is `opponents[n-2]`, in match order excluding yourself.
|
||||
- A key press updates the local state and calls `selectTarget` on the server.
|
||||
- If the target dies, an effect resets it to yourself, which mirrors the server rule. The selected board gets a cyan frame, and its slot number is filled cyan.
|
||||
- **Leave.** The Leave button in the corner calls `leaveLobby`. While you are still alive in a match, leaving gives up that match, so the first click only turns the button into a red `SURE?` for 3 s and a second click leaves. After you are out, or once the match has finished, one click is enough. No browser dialog is used, because dialogs block the page.
|
||||
- **Overlays on the own board:** `ItemFlashOverlay` (flashes whose `targetId` is you), the countdown number, and "YOU WIN!" or "<name> WINS".
|
||||
- **Blackout.** `hideStack={me.invisibleRemaining > 0}` makes `drawBoard` skip locked cells, item icons and the ghost.
|
||||
- **Garbage meter.** A red bar whose height is `pendingGarbage × cell`.
|
||||
@@ -134,6 +138,52 @@ The pixel font (*Press Start 2P*) has **no arrow, ellipsis or dash glyphs**, so
|
||||
|
||||
Asset paths go through the `asset()` helper in `assets.ts` (`import.meta.env.BASE_URL + path`), so they stay correct when the site is served under a sub-path.
|
||||
|
||||
## Audio (`src/audio/`)
|
||||
|
||||
All sound is generated in the browser with the **Web Audio API**: oscillators (square, triangle, sawtooth) and filtered noise with short volume envelopes, in the style of old console sound chips. No audio files are needed, but any effect or music track can be replaced by a file.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `../config/sound.ts` | **Central volume mix for developers**: master, sfx, music, per effect, per track, default slider positions |
|
||||
| `types.ts` | `SoundId` (all effects), `SoundPlayer` (the interface the UI talks to), `MusicTrack`, `SynthSong`, `SoundSettings` |
|
||||
| `synthSounds.ts` | One recipe per `SoundId`, plus the `tone()` / `noise()` helpers |
|
||||
| `music.ts` | `SynthSongPlayer`: plays a `SynthSong` with a lookahead scheduler on the audio clock |
|
||||
| `songs.ts` | The three built-in songs, written as text patterns |
|
||||
| `MusicPlaylist.ts` | Plays the registered tracks in shuffled order. Each track plays once before the order is reshuffled. File tracks advance when they end, synth songs after `loops` repetitions |
|
||||
| `AudioEngine.ts` | `WebAudioPlayer implements SoundPlayer`: owns the `AudioContext`, the SFX and music volume nodes, file loading with fallback to the synth, and a 30 ms per-sound throttle |
|
||||
| `settings.ts` | `sfxMuted`, `musicMuted`, both volumes and the disabled tracks, saved in `localStorage`. An old single `muted` flag is migrated to both |
|
||||
| `SoundContext.tsx` | `SoundProvider` (wraps `<App/>` in `main.tsx`) and the `useSound()` / `useCurrentTrack()` hooks. It also handles the global **M** (all sound on/off), **B** (music on/off) and **N** (next track) keys |
|
||||
| `useGameSounds.ts` | Compares each snapshot with the previous one and plays the matching sounds |
|
||||
|
||||
**When sounds play.** Everything comes from data the client already receives, so the server is unchanged. Snapshots are server-confirmed, so a blocked move or rotation stays silent.
|
||||
|
||||
| Sound | Trigger |
|
||||
|---|---|
|
||||
| `countdown`, `go` | Countdown number changes; phase changes to `running` (music starts) |
|
||||
| `move`, `rotate` | Own active piece moved sideways or changed shape |
|
||||
| `hold` | `holdUsed` became true |
|
||||
| `lock` | Next queue advanced without a line clear |
|
||||
| `hardDrop` | Sent from `GamePage` when the HardDrop input is sent (a hard drop looks like any other lock in the snapshot) |
|
||||
| `lineClear` (pitch rises per line), `quad` | `lines` increased by 1–3, or by 4 |
|
||||
| `itemPickup`, `itemUse` | Own inventory grew or shrank |
|
||||
| `itemHit` | `itemUsed` event targeting you |
|
||||
| `garbageIncoming` | `pendingGarbage` increased |
|
||||
| `knockout`, `lose`, `win` | An opponent was eliminated; you were eliminated; you won (music stops when the match finishes) |
|
||||
|
||||
The music tempo rises slightly with your level, by 3 % per level and at most 30 %.
|
||||
|
||||
**Autoplay.** Browsers block audio until the user interacts with the page. The `AudioContext` is created on the first key press or click, which always happens before a match starts.
|
||||
|
||||
**UI.**
|
||||
|
||||
- `CornerControls` is fixed in the upper right corner of every screen (16px from the edges): the `SoundMenu` speaker button, and below it a small Leave button in the lobby and the game.
|
||||
- Clicking the speaker button opens an inline strip to its left, in the header row, so it never covers a board and doesn't pause the game. The strip has a mute button and a volume slider each for `SFX` and `MUSIC`. Moving a muted slider unmutes that channel. Escape, a click outside or the button close the strip. Buttons and sliders give keyboard focus back to the page after use, so game keys keep working.
|
||||
- The speaker icons are `uiAssets.soundOn` / `soundOff` (`public/assets/ui/`).
|
||||
- `MusicTracks` lists one checkbox per track in the lobby's "Music" panel.
|
||||
- The game header shows the current track. While music is muted the playlist doesn't run at all.
|
||||
|
||||
**Debugging.** In dev builds every played sound is logged with `console.debug` as `[sound] <id>`, and music changes as `[music] play|stop <track id>`. Enable the "Verbose" level in devtools to see them.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
`src/config/runtimeConfig.ts` resolves the hub URL in this order:
|
||||
|
||||
@@ -159,5 +159,9 @@ Bindings use physical key positions (`KeyboardEvent.code`), so they work on any
|
||||
| Hold | C, Shift |
|
||||
| Select item target | 1–9 (1 = yourself) |
|
||||
| Use item | F |
|
||||
| All sound on / off | M |
|
||||
| Background music on / off (effects stay on) | B |
|
||||
| Volume and mute for effects and music | Speaker button, upper right corner |
|
||||
| Next music track | N |
|
||||
|
||||
Holding left/right repeats the move after 167 ms, then every 33 ms (DAS/ARR). When both directions are held, the most recently pressed one wins.
|
||||
|
||||
Reference in New Issue
Block a user