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:
@@ -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