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:
parent
cf611fa669
commit
6fd4510b8b
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
|
@ -11,5 +11,5 @@
|
|||
"title": "Pet me in the Scruffy"
|
||||
}
|
||||
],
|
||||
"volume": 0.8
|
||||
"volume": 0.4
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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**.
|
||||
|
|
|
|||
Loading…
Reference in New Issue