diff --git a/assets/images/tetrisattack/tetrisattack-characters.png b/assets/images/tetrisattack/tetrisattack-characters.png index 58f1f1d..cae4741 100644 Binary files a/assets/images/tetrisattack/tetrisattack-characters.png and b/assets/images/tetrisattack/tetrisattack-characters.png differ diff --git a/assets/images/tetrisattack/tetrisattack-characters.psd b/assets/images/tetrisattack/tetrisattack-characters.psd index 4215f35..4f11c1d 100644 Binary files a/assets/images/tetrisattack/tetrisattack-characters.psd and b/assets/images/tetrisattack/tetrisattack-characters.psd differ diff --git a/data/nintendo-music.json b/data/nintendo-music.json index a6bca6c..5cea20b 100644 --- a/data/nintendo-music.json +++ b/data/nintendo-music.json @@ -11,5 +11,5 @@ "title": "Pet me in the Scruffy" } ], - "volume": 0.8 + "volume": 0.4 } diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 5fa81ab..0d2630d 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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: [ diff --git a/src/games/tetrisattack/TetrisAttackGame.js b/src/games/tetrisattack/TetrisAttackGame.js index 1204c02..e8e6a72 100644 --- a/src/games/tetrisattack/TetrisAttackGame.js +++ b/src/games/tetrisattack/TetrisAttackGame.js @@ -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, { diff --git a/src/games/tetrisattack/TetrisAttackScreens.js b/src/games/tetrisattack/TetrisAttackScreens.js index 11d9bb1..33e4c2c 100644 --- a/src/games/tetrisattack/TetrisAttackScreens.js +++ b/src/games/tetrisattack/TetrisAttackScreens.js @@ -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 = () => { diff --git a/src/games/tetrisattack/sprites.md b/src/games/tetrisattack/sprites.md index 83eb18c..d55ea46 100644 --- a/src/games/tetrisattack/sprites.md +++ b/src/games/tetrisattack/sprites.md @@ -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**.