Add sound effects, background music and a sound menu
Generate retro sound effects and music in the browser with the Web Audio API, so no audio files are needed. Any sound can still be replaced by a file. Audio (src/audio/): - 16 effects (move, rotate, hold, hard drop, lock, line clears, garbage, items, countdown, knockout, win/lose), triggered by comparing match snapshots, so the server is unchanged - Background music as a shuffled playlist of any number of tracks, either synth songs or audio files; 3 original songs included, tempo rises with the level - SoundPlayer interface, so the audio engine can be replaced - Audio files and music tracks are registered in theme/assets.ts (soundFiles, musicTracks) - All volume levels for developers are in config/sound.ts (master, sfx, music, per effect, per track, default slider positions) UI: - Speaker button fixed in the top right corner of every screen; opens an inline strip with a mute button and volume slider for SFX and music that doesn't cover the boards or pause the game - Leave button below it in the lobby and the game; in a running match it asks for a second click because leaving gives up the match - Lobby Music panel to switch single tracks on or off - Keys: M all sound on/off, B music on/off, N next track - Settings are saved in localStorage Docs: audio section in frontend.md, sound and music guide in extending.md, new keys in game-rules.md and the README.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user