Steer a paddle, keep the ball in play, clear the wall, and for the first time in this series you can hear it. The new toolbox module is AudioPlayer, because one audio element cannot play over itself and a browser will not play anything at all before the first click.
Play it · Source: collection-spielwerk/17-bricks · Toolbox 5.7.0 or later
The game
Paper breaks in one hit, wood in two, red in three, metal never. Where the ball hits the paddle sets its angle, and it speeds up slowly. Five walls ("Mauern") win the round, and seeded ones follow for as long as your three lives last.
- Mouse/touch: the paddle follows; a click or tap launches the ball and goes on to the next wall.
- Keyboard: arrow keys or A/D steer, Space launches.
- The two buttons, or S and M, mute the sounds and the music. "Neue Runde" starts a new round.
New in the toolbox: AudioPlayer
AssetManager could always load audio. Playing it is the part with traps.
Why one audio element can't overlap
Give every sound one HTMLAudioElement and call play() on it, and a second hit doesn't add a second "bop": it cuts the first one off. A wall coming down sounds like a stutter. A new element per hit overlaps, but nothing limits it, and enough voices at once crackle.
The idea: channels
A fixed set of elements, eight by default. A new sound takes a channel that has rung out; if all eight ring, the oldest stops, because it is nearly over anyway. Music stands apart, one track at a time, under its own volume and mute, so a flurry of hits can never drop it.
export class AudioPlayer {
public constructor(assets: AssetManager, options?: { sound_channels?: number });
public play_sound(name: string, options?: { volume?: number }): void;
public play_music(name: string, options?: { loop?: boolean }): void;
public stop_music(): void;
public resume_music(): void;
public sound_volume: number;
public music_volume: number;
public is_sound_muted: boolean;
public is_music_muted: boolean;
public destroy(): void;
}
The core picks the channel. A free one that already holds this sound comes first, since it needn't load anything:
public play_sound(name: string, options: PlaySoundOptions = {}): void {
const asset = this.find(name); // an unknown name warns once
if (asset === null || this._is_sound_muted) {
return;
}
const taken = this.free_channel(name) ?? this.new_channel() ?? this.oldest_channel();
if (taken.name !== name) {
taken.$audio.src = asset.url;
taken.name = name;
}
taken.$audio.pause();
taken.$audio.currentTime = 0;
taken.volume = clamp_volume(options.volume ?? 1);
this.sounds_played += 1;
taken.order = this.sounds_played;
this.apply_sound_settings(taken);
this.start(taken.$audio).catch(() => {}); // a refused sound is gone
}
Muting differs on purpose. A muted sound doesn't start at all. Muted music runs on silently, so unmuting finds it where it would have been.
The autoplay rule
Browsers refuse to play sound until the visitor has clicked or typed on the page, by rejecting the promise play() returns. Left alone, that is an unhandled rejection on every hit. A refused hit is simply gone. A refused track stays wanted, and resume_music() tries again.
this.start($music).catch((error: unknown) => {
const is_refused = error instanceof Error && error.name === "NotAllowedError";
if (is_refused && this.$music === $music) {
this._is_music_blocked = true;
}
});
The check of the name matters: pause() during a pending play() rejects too, with AbortError, and a track stopped on purpose must not come back on the next click.
How the game uses it
Sound goes through the shell, the same way images do. The game lists its files and names its track, and the DOM-free game only reports what happened:
public override readonly music = 'spacy-bricks';
protected override update(game: Game, delta_seconds: number): boolean {
const has_moved = game.update(delta_seconds);
for (const event of game.take_events()) {
this.properties.audio.play_sound(this.settings.event_sounds[event]);
}
return has_moved;
}
The shell keeps the rules a game would otherwise forget. Music starts when the intro is over, stops when the element disconnects, and comes back on a reconnect with the mutes as they were. With init-intro="false" no click comes before the music, so the shell retries a refused track on the next click or key.
Testing without ears
jsdom plays nothing, so the unit tests use fake elements and say what the browser does:
it("drops the oldest when all channels ring, and only that one", () => {
player.play_sound("bip");
player.play_sound("bop");
player.play_sound("bip");
const [oldest, middle, newest] = channels();
player.play_sound("bop");
expect(channels().length).toBe(3);
expect(oldest.cuts).toBe(1);
expect(middle.cuts).toBe(0);
expect(newest.cuts).toBe(0);
});
The browser check goes further, since the sounds are inlined as data URLs. It spies on HTMLMediaElement.play, sees its promise reject with NotAllowedError before a real click and resolve after one, watches currentTime move, and puts an analyser on the track to find more than silence. Whether it sounds good is still a job for a person.
The module is src/audio/AudioPlayer.ts.
From earlier steps
SeededRandomfrom step 01: the seeded walls, and which way the ball starts.StateMachinefrom step 04: the phasesaim,flying,cleared,wonandlost.SessionBestfrom step 05: the best score.circle_rect_contactandreflectfrom step 16: walls and bricks, in steps of 1/240 s, so even at top speed the ball moves 2.6 pixels at a time and cannot pass through one.settings.imagesandpaint_image()from step 15: the sprites.
Scaffold, start screen, intro and scaling are explained in Spielwerk 00: The workbench. message_card() moved there too, since Bricks would have been the fifth game to paint its own. One trap in the physics: a ball can settle into a level path or a loop between metal bricks, so it may never go flat, and ten bounces without a breakable brick knock it off course.
Embed it
<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-bricks"></script>
<tce-bricks init-seed="4711"></tce-bricks>
| Attribute | Values | Default |
|---|---|---|
init-seed |
whole number; the first round, every next round is seed + 1 | random |
init-intro |
true / false; play the GO logo first |
true |
Next
Previous: Spielwerk 16: Flummis · Next: Spielwerk 18: Rundenkampf · All steps
Comments