feat(tetrisattack): add optional character voice reactions and adjust BGM volume

- Implement per-character voice clip system (match, supermatch, win, lose) that
  loads lazily per round and falls back gracefully when clips are missing
- Queue voices to play after arcade cues, with intelligent interruption handling
  so only one line plays at a time
- Rename ensureRoundBackgrounds to ensureRoundAssets to handle both images and audio
- Reduce background music volume from 0.8 to 0.4
- Update character sprite sheet and document voice clip structure in sprites.md
This commit is contained in:
Brian Fertig 2026-07-22 21:06:03 -06:00
parent cf611fa669
commit 6fd4510b8b
7 changed files with 119 additions and 21 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -11,5 +11,5 @@
"title": "Pet me in the Scruffy"
}
],
"volume": 0.8
"volume": 0.4
}

View File

@ -224,10 +224,12 @@ export const MANIFEST = {
{ type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' },
{ type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' },
(scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']),
// Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 hosts × 5
// stages) are NOT declared here — 30 full-screen images is far too much to
// pull on entering the game room. TetrisAttackGame.ensureRoundBackgrounds()
// loads one character's set as their round begins.
// Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 friends × 5
// stages) and character voice clips (assets/fx/tetrisattack/{hero}-*.mp3)
// are NOT declared here — 30 full-screen images is far too much to pull on
// entering the game room, and the voice sets are still being filled in.
// TetrisAttackGame.ensureRoundAssets() loads one character's set as their
// round begins; anything missing just never plays.
(scene) => musicFrom(scene, 'nintendo-music'),
],
coloradodefense: [

View File

@ -44,6 +44,23 @@ const PANEL_PAL = {
blue: { base: 0x3a7bf0, hi: 0xaec6ff, lo: 0x123f8f },
};
// ── Character voice clips ────────────────────────────────────────────────────
// Optional per-friend drop-ins at assets/fx/tetrisattack/{characterId}-{name}.mp3.
// Every clip is optional and lazily loaded with that friend's round: a missing
// file simply never plays, so adding a new character's voice is a pure drop-in.
const VOICE_VARIANTS = {
match: ['match-01', 'match-02', 'match-03'], // 4-tile clear
supermatch: ['supermatch-01', 'supermatch-02'], // 5+-tile clear
win: ['win'], // round beaten
lose: ['lose'], // topped out
};
const voiceKey = (hero, name) => `ta-voice-${hero}-${name}`;
// A voice line waits out the arcade cue that fires with it, so the two don't
// talk over each other. Values are those cues' own lengths — 8bit-action 2.88s
// (4-match), 8bit-win 1.92s (5+), victory-short 3.08s, 8bit-explode 0.91s.
// Trim any of them if you'd rather the friend chimed in sooner.
const VOICE_DELAY = { match: 2900, supermatch: 1950, win: 3100, lose: 950 };
const BEST_KEY = 'tetrisattack-best';
// Stage Clear progress: { [characterId]: highest stage number completed }.
// Drives the stage-select squares and which characters are unlocked.
@ -67,6 +84,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.hostPortrait = null;
this.heroBg = null;
this.onBackgroundsLoaded = null; // set by the intro cutscene while it waits on art
this._voice = null; // the one character voice clip in the air
this._voiceTimer = null;
this.uiLayer = null;
this.overlayObjs = [];
this.best = 0;
@ -93,6 +112,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.boardLayer.setMask(maskShape.createGeometryMask());
this.registerInput();
// voice clips live on the global sound manager, so cut them if we leave
this.events.once('shutdown', () => this.stopVoice());
this.showMenu();
}
@ -139,7 +160,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
if (!round) { this.showStageComplete(); return; }
this.roundIndex = index;
this.stageNumber = 1;
this.ensureRoundBackgrounds(round);
this.ensureRoundAssets(round);
showStageIntro(this, round, index, () => {
this.beginGame({ mode: 'stageclear', round, stageNumber: 1 });
});
@ -187,6 +208,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
teardownGameplay() {
this.playing = false;
this.stopVoice();
for (const s of this.sprites.values()) s.destroy();
this.sprites.clear();
for (const s of this.incomingSprites) s.destroy();
@ -230,22 +252,29 @@ export default class TetrisAttackGame extends Phaser.Scene {
return keys[Math.floor(Math.random() * keys.length)];
}
// Pull in a round's five stage backgrounds on demand — 30 full-screen images
// is far too much to preload up front (see data/assetManifest.js). Called as
// a round begins so the download hides behind the intro cutscene; art that
// arrives late just swaps in over the gradient.
ensureRoundBackgrounds(round) {
// Pull in a round's five stage backgrounds and the friend's voice clips on
// demand — 30 full-screen images is far too much to preload up front (see
// data/assetManifest.js). Called as a round begins so the download hides
// behind the intro cutscene; art that arrives late just swaps in over the
// gradient, and any clip a character doesn't have yet simply never plays.
ensureRoundAssets(round) {
const hero = round?.characterId;
if (!hero) return;
const wanted = [];
const images = [];
for (let n = 1; n <= this.stagesPerRound; n++) {
wanted.push([`tetrisattack-bg-${hero}-r${n}`, `assets/images/tetrisattack/background-${hero}-r${n}.png`]);
images.push([`tetrisattack-bg-${hero}-r${n}`, `assets/images/tetrisattack/background-${hero}-r${n}.png`]);
}
wanted.push([`tetrisattack-bg-${hero}`, `assets/images/tetrisattack/background-${hero}.png`]);
const missing = wanted.filter(([key]) => !this.textures.exists(key));
if (!missing.length) return;
for (const [key, path] of missing) this.load.image(key, path);
const onError = (file) => console.warn(`[tetrisattack] no background ${file.key}`);
images.push([`tetrisattack-bg-${hero}`, `assets/images/tetrisattack/background-${hero}.png`]);
const missingImages = images.filter(([key]) => !this.textures.exists(key));
for (const [key, path] of missingImages) this.load.image(key, path);
const audio = Object.values(VOICE_VARIANTS).flat()
.map((name) => [voiceKey(hero, name), `assets/fx/tetrisattack/${hero}-${name}.mp3`])
.filter(([key]) => !this.cache.audio.exists(key));
for (const [key, path] of audio) this.load.audio(key, path);
if (!missingImages.length && !audio.length) return;
const onError = (file) => console.warn(`[tetrisattack] missing asset ${file.key}`);
this.load.on('loaderror', onError);
this.load.once('complete', () => {
this.load.off('loaderror', onError);
@ -260,6 +289,46 @@ export default class TetrisAttackGame extends Phaser.Scene {
if (!this.load.isLoading()) this.load.start();
}
// ── Character voice ───────────────────────────────────────────────────────
// The friend reacts out loud to big clears and to how the round ends. Only
// one line is ever in the air: a newer request replaces a pending one, and a
// line already playing is left alone unless something more important (the
// round ending) interrupts it.
playVoice(kind, { interrupt = false } = {}) {
if (this.mode !== 'stageclear') return;
const hero = this.currentRound?.characterId;
if (!hero) return;
const keys = (VOICE_VARIANTS[kind] ?? [])
.map((name) => voiceKey(hero, name))
.filter((key) => this.cache.audio.exists(key));
if (!keys.length) return; // this friend has no clip of this kind yet
if (this._voice?.isPlaying) {
if (!interrupt) return;
this._voice.stop();
}
this._voice?.destroy();
this._voice = this.sound.add(keys[Math.floor(Math.random() * keys.length)]);
this._voice.play();
}
// Queue a reaction line to land once its arcade cue has finished (see
// VOICE_DELAY). Only the latest request survives, and it's dropped if the
// stage ended while the cue was still playing.
queueVoice(kind, { interrupt = false } = {}) {
this._voiceTimer?.remove(false);
this._voiceTimer = this.time.delayedCall(VOICE_DELAY[kind] ?? 0, () => {
this._voiceTimer = null;
if (!interrupt && !this.playing) return;
this.playVoice(kind, { interrupt });
});
}
stopVoice() {
this._voiceTimer?.remove(false);
this._voiceTimer = null;
if (this._voice) { this._voice.stop(); this._voice.destroy(); this._voice = null; }
}
// ── Main loop ─────────────────────────────────────────────────────────────
update(time, delta) {
if (!this.playing || !this.state) return;
@ -301,11 +370,13 @@ export default class TetrisAttackGame extends Phaser.Scene {
// add 8bit-win instead, once the whole 8bit-card set has played out.
let popped = 0;
const total = ordered.length;
// Once the burst is done the friend cheers you on over the top of it: a
// 4-tile clear draws a "match" line, 5+ tiles the rarer "supermatch" one.
const onTileExplode = () => {
popped++;
if (popped < total) return;
if (biggest === 4) playSound(this, SFX.EIGHTBIT_ACTION);
else if (biggest >= 5) playSound(this, SFX.EIGHTBIT_WIN);
if (biggest === 4) { playSound(this, SFX.EIGHTBIT_ACTION); this.queueVoice('match'); }
else if (biggest >= 5) { playSound(this, SFX.EIGHTBIT_WIN); this.queueVoice('supermatch'); }
};
let cx = 0, cy = 0, n = 0;
@ -461,6 +532,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
});
} else if (this.mode === 'stageclear') {
const round = this.currentRound;
this.queueVoice('lose', { interrupt: true });
this._resultPose = 'worried';
showResult(this, {
title: 'TOPPED OUT',
@ -494,6 +566,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
// Mid-round: the match doesn't end. Banner, new background, faster rise,
// fresh CLEAR line — but the same stack keeps coming up.
if (this.stageNumber < this.stagesPerRound) { this.advanceToNextStage(); return; }
// whole round beaten — the friend gets their say over the fanfare
this.queueVoice('win', { interrupt: true });
this._resultPose = 'happy';
const last = this.roundIndex >= (this.rounds.length - 1);
showResult(this, {

View File

@ -202,7 +202,7 @@ export function showStageIntro(scene, round, index, onContinue) {
const total = (scene.config?.stageClear?.rounds ?? []).length;
// Backdrop: one of this friend's own stage backgrounds, picked at random. The
// art loads lazily (see scene.ensureRoundBackgrounds) so it may still be in
// art loads lazily (see scene.ensureRoundAssets) so it may still be in
// flight when the cutscene opens — scene.onBackgroundsLoaded re-tries then.
let bgImage = null;
const applyBg = () => {

View File

@ -78,3 +78,25 @@ with that array.
One 1920×1080 image per friend per stage (`-r1`..`-r5`). These are lazy-loaded as
that friend's round begins, are shown behind their stage, and **one of them is
picked at random as the backdrop of their intro cutscene**.
---
## 4. Voice clips — `assets/fx/tetrisattack/{characterId}-{name}.mp3`
Optional spoken reactions, per friend. Every clip is independent — a character
with none is simply silent, so new voices are a pure drop-in.
| file | when it plays |
|-----------------------------------|------------------------------------------------------|
| `{id}-match-01 … -03` | you clear **4** panels at once (random pick of three) |
| `{id}-supermatch-01 … -02` | you clear **5 or more** at once (random pick of two) |
| `{id}-win` | you finish all five of their stages |
| `{id}-lose` | the stack tops out |
Lines land *after* the pop animation and the arcade cue that goes with it, and
only one plays at a time — a new reaction replaces a pending one rather than
stacking, and the win/lose line interrupts whatever is talking. Timing lives in
`VOICE_DELAY` at the top of `TetrisAttackGame.js`. Loaded lazily with the
friend's round (`ensureRoundAssets`), like the backgrounds above.
Shipped so far: **beth**, **jerry**.