Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Fertig 6fd4510b8b 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
2026-07-22 21:06:03 -06:00
Brian Fertig cf611fa669 Re-theme stage clear to helping friends with multi-line intros
Refactor Stage Clear narrative from battling opponents to helping friends.
Update character poses: friends cheer on clears and fret during danger.
Implement multi-line intro cutscenes with step-by-step progression and random background art.
Switch dialog to randomized arrays (introLines, winLines, loseLines).
Add new sound effects for Beth and Jerry.
Update validation and documentation to match new data structure.
2026-07-22 20:52:05 -06:00
23 changed files with 363 additions and 74 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@ -1,11 +1,12 @@
{
"_comment": "Stage Clear rounds are six FRIENDS you help, not opponents. introLines plays IN ORDER during the intro cutscene, one line per keypress, until the friend runs out and the stage begins — add or remove lines freely, any length works. winLines / loseLines are picked at random from their arrays.",
"endless": {
"name": "Endless",
"description": "Clear panels for as long as you can. The stack rises faster as you go."
},
"stageClear": {
"name": "Stage Clear",
"description": "Six challengers, five stages each. Survive until the CLEAR line slides in, then wipe out every panel above it.",
"description": "Six friends, five stages each. Help each one dig out — survive until the CLEAR line slides in, then wipe out every panel above it.",
"stagesPerRound": 5,
"stageSpeedStep": 0.2,
"rounds": [
@ -16,9 +17,21 @@
"speedLevel": 1,
"startRows": 6,
"clearLineRows": 3,
"intro": "I ain't seen you 'round these parts before. Let's see whatcha got.",
"winLine": "Well shoot, you got the better of me!",
"loseLine": "Told ya you weren't ready, sugar."
"introLines": [
"Well hey there, sugar! Ain't you a sight for sore eyes.",
"These panels are stackin' up faster than I can sort 'em, and I've been at it all mornin'.",
"Lend me a hand and we'll have this cleared by supper."
],
"winLines": [
"Well shoot, you cleared the whole mess! Bless your heart.",
"You saved my bacon, sugar. I owe you a pie.",
"Would ya look at that — spotless. Thank you kindly!"
],
"loseLines": [
"Aw, don't fret none. Take a breath and we'll try her again.",
"It got away from us, sugar. Happens to the best of 'em.",
"No harm done. Dust yourself off and come on back."
]
},
{
"characterId": "jerry",
@ -27,9 +40,22 @@
"speedLevel": 1,
"startRows": 6,
"clearLineRows": 3,
"intro": "Well now, y'all know I came to play some'a them games!",
"winLine": "Hoo-wee, you got me good!",
"loseLine": "Better luck next time, partner!"
"introLines": [
"Well now, partner — y'all showed up right on time.",
"I got myself in a jam again. Pile's near up to my hat.",
"Reckon you could help me dig outta this?",
"Much obliged. Let's get to it!"
],
"winLines": [
"Hoo-wee, you cleaned that up right quick! Much obliged.",
"Clear as a summer sky. You're a good friend, partner.",
"That's how it's done! Drinks are on me."
],
"loseLines": [
"Ah, it buried us both. Ain't your fault, partner.",
"We near had it! Give her another go when you're ready.",
"No shame in that one. Stack was stubborn."
]
},
{
"characterId": "michael",
@ -38,9 +64,21 @@
"speedLevel": 2,
"startRows": 7,
"clearLineRows": 3,
"intro": "Hey mon! I been waitin' ta play on da Fertig Games — let's go mon!",
"winLine": "Ya beat me fair, mon. Respect!",
"loseLine": "Not today, mon, ya!"
"introLines": [
"Hey mon! Irie timing, ya know.",
"Dese panels been pilin' up on me all day and I hold dem back alone too long.",
"Two pair a hands beat one, mon — come clear it wit' me!"
],
"winLines": [
"Ya cleared it all, mon! Big respect, big respect.",
"Sweet work mon — dat board is clean like new!",
"Ya come through for me, mon. Dat's a real friend."
],
"loseLines": [
"No worries mon, we take a breath and go again.",
"Dat stack fought hard, ya. Nothin' lost.",
"Easy mon, easy. Come back when ya ready."
]
},
{
"characterId": "steve",
@ -49,9 +87,22 @@
"speedLevel": 2,
"startRows": 7,
"clearLineRows": 2,
"intro": "I traveled WAY too far across the galaxy for this. Don't waste my time.",
"winLine": "Impossible... a lifeform this primitive bested me?",
"loseLine": "Exactly as I calculated. Predictable."
"introLines": [
"I traveled a considerable distance across the galaxy to find you.",
"My calculations are unambiguous: I cannot clear this stack alone.",
"You are, improbably, my best available option.",
"Assistance requested, friend. Shall we begin?"
],
"winLines": [
"Remarkable. A lifeform this resourceful is a valuable ally.",
"The board is empty. I shall record your name in my logs.",
"Efficient. I did not expect gratitude to feel like this."
],
"loseLines": [
"The stack exceeded our tolerances. Recalibrate and return.",
"A failed attempt is merely data. We proceed.",
"Do not process this as defeat. Process it as a first pass."
]
},
{
"characterId": "victor",
@ -60,9 +111,22 @@
"speedLevel": 3,
"startRows": 8,
"clearLineRows": 2,
"intro": "Patience. Every panel falls exactly where the ancients intended.",
"winLine": "The ancients smile upon you today.",
"loseLine": "Patience was never your virtue."
"introLines": [
"Ah. The ancients said help would come.",
"I have carried this stack a long while, and my arms grow tired.",
"Sit with me. Share the weight a moment.",
"Patience, friend — together the panels fall exactly where they should."
],
"winLines": [
"The ancients smile upon you today — and so do I.",
"Stillness at last. You have my gratitude, friend.",
"You saw the pattern I could not. Thank you."
],
"loseLines": [
"The stack rose. So will we. Breathe, and begin again.",
"Nothing is lost that can be tried once more.",
"Patience, friend. The board will wait for us."
]
},
{
"characterId": "klaxon",
@ -71,9 +135,22 @@
"speedLevel": 4,
"startRows": 8,
"clearLineRows": 2,
"intro": "MISSION OBJECTIVE: victory. All other priorities rescinded.",
"winLine": "ERROR. DEFEAT NOT IN PARAMETERS.",
"loseLine": "OBJECTIVE COMPLETE. RESISTANCE FUTILE."
"introLines": [
"DISTRESS SIGNAL BROADCAST. FRIEND DETECTED. RELIEF SUBROUTINE: RUNNING.",
"MY BUFFERS ARE OVERFLOWING AND I CANNOT FLUSH THEM ALONE.",
"OBJECTIVE: CLEAR THE STACK. CREW REQUIRED: TWO. PRESENT: ONE.",
"ASSISTANCE REQUEST TRANSMITTED. ACCEPT?"
],
"winLines": [
"BUFFERS CLEAR. GRATITUDE SUBROUTINE: RUNNING AT MAXIMUM.",
"OBJECTIVE COMPLETE. FRIENDSHIP PROTOCOL PERMANENTLY INSTALLED.",
"ERROR: CANNOT COMPUTE HOW GOOD THIS FEELS. THANK YOU."
],
"loseLines": [
"OVERFLOW. NO BLAME ASSIGNED. RETRY WHEN READY.",
"ATTEMPT LOGGED. FRIENDSHIP UNAFFECTED BY OUTCOME.",
"SYSTEM RESET. WE GO AGAIN, PARTNER-UNIT."
]
}
]
},

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

@ -9,7 +9,7 @@ import {
} from './TetrisAttackLogic.js';
import {
showMenu, showPuzzleSelect, showStageSelect, showStageIntro, showResult,
createHostPortrait, FONT,
createHostPortrait, pickLine, FONT,
} from './TetrisAttackScreens.js';
// ── Layout ──────────────────────────────────────────────────────────────────
@ -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.
@ -66,6 +83,9 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.puzzleIndex = 0;
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;
@ -92,12 +112,16 @@ 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();
}
clearOverlay() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
// any screen waiting on lazily-loaded art is gone with it
this.onBackgroundsLoaded = null;
}
// ── Menus / flow (delegates to Screens) ───────────────────────────────────
@ -130,13 +154,13 @@ export default class TetrisAttackGame extends Phaser.Scene {
return (this.loadStageProgress()[prev?.characterId] ?? 0) >= this.stagesPerRound;
}
// Start a character's round from its first stage.
// Start a friend's round from its first stage.
beginRound(index) {
const round = this.rounds[index];
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 });
});
@ -184,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();
@ -211,22 +236,45 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.heroBg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(D.bg + 2);
}
// 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) {
// A random one of this friend's own stage backgrounds, used to dress their
// intro cutscene. Returns null while the art is still loading (or if the
// friend has none yet) — the cutscene retries via onBackgroundsLoaded.
introBackgroundKey(round) {
const hero = round?.characterId;
if (!hero) return null;
const keys = [];
for (let n = 1; n <= this.stagesPerRound; n++) {
const k = `tetrisattack-bg-${hero}-r${n}`;
if (this.textures.exists(k)) keys.push(k);
}
if (!keys.length && this.textures.exists(`tetrisattack-bg-${hero}`)) keys.push(`tetrisattack-bg-${hero}`);
if (!keys.length) return null;
return keys[Math.floor(Math.random() * keys.length)];
}
// 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);
@ -234,11 +282,53 @@ export default class TetrisAttackGame extends Phaser.Scene {
if (this.mode === 'stageclear' && this.currentRound) {
this.setStageBackground(this.currentRound.characterId, this.stageNumber);
}
// an intro cutscene waiting on this friend's art can dress itself now
this.onBackgroundsLoaded?.();
});
// files queued mid-pass ride along with the load already in flight
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;
@ -280,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;
@ -301,8 +393,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
fontFamily: FONT, fontSize: '34px', color, stroke: '#101018', strokeThickness: 6,
}).setOrigin(0.5).setDepth(D.fx);
this.tweens.add({ targets: t, y: t.y - 70, alpha: 0, duration: 900, ease: 'Cubic.easeOut', onComplete: () => t.destroy() });
// host reacts: player doing well → worried for a beat, then back to rest
this.hostPortrait?.setPose('worried', POSE_HOLD_MS);
// friend reacts: you're clearing for them → cheer for a beat, then rest
this.hostPortrait?.setPose('happy', POSE_HOLD_MS);
}
// floating score
if (n && e.score) {
@ -410,7 +502,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
onDanger(on) {
if (this.boardFrame) this.boardFrame.setStrokeStyle(6, on ? 0xff4d5e : 0x40506a);
if (on) {
this.hostPortrait?.setPose('happy');
// the stack is near the top — the friend frets alongside you
this.hostPortrait?.setPose('worried');
if (!this._dangerShake) {
this._dangerShake = this.tweens.add({
targets: this.boardLayer, x: { from: -6, to: 6 }, duration: 70, yoyo: true, repeat: -1,
@ -439,10 +532,11 @@ export default class TetrisAttackGame extends Phaser.Scene {
});
} else if (this.mode === 'stageclear') {
const round = this.currentRound;
this._resultPose = 'happy';
this.queueVoice('lose', { interrupt: true });
this._resultPose = 'worried';
showResult(this, {
title: 'TOPPED OUT',
lines: [`${round.name}: "${round.loseLine}"`],
lines: [`${round.name}: "${pickLine(round.loseLines ?? round.loseLine, 'We can try that again.')}"`],
round,
buttons: [
{ label: 'Retry Stage', action: () => this.beginGame({ mode: 'stageclear', round, stageNumber: this.stageNumber }) },
@ -472,15 +566,17 @@ 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; }
this._resultPose = 'worried';
// 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, {
title: `${round.name.toUpperCase()} DEFEATED`,
lines: [`${round.name}: "${round.winLine}"`, `Score ${this.state.score}`],
title: `YOU HELPED ${round.name.toUpperCase()}!`,
lines: [`${round.name}: "${pickLine(round.winLines ?? round.winLine, 'Thank you, friend!')}"`, `Score ${this.state.score}`],
round,
buttons: last
? [{ label: 'Finish', action: () => this.showStageComplete() }]
: [{ label: 'Next Challenger', action: () => this.beginRound(this.roundIndex + 1) },
: [{ label: 'Next Friend', action: () => this.beginRound(this.roundIndex + 1) },
{ label: 'Menu', action: () => this.showMenu() }],
});
} else if (this.mode === 'puzzle') {
@ -511,7 +607,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
stroke: '#3a2a00', strokeThickness: 9, lineSpacing: 10,
}).setOrigin(0.5).setDepth(D.fx);
this.tweens.add({ targets: banner, scale: { from: 0.4, to: 1 }, duration: 340, ease: 'Back.easeOut' });
this.hostPortrait?.setPose('worried', POSE_HOLD_MS);
this.hostPortrait?.setPose('happy', POSE_HOLD_MS);
this.time.delayedCall(1400, () => {
this.tweens.add({
@ -529,8 +625,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
showStageComplete() {
this.teardownGameplay();
showResult(this, {
title: 'ALL CHALLENGERS CLEARED!',
lines: ['You beat every stage. Nicely done.'],
title: 'EVERY FRIEND HELPED!',
lines: ['You cleared all six boards. Nicely done.'],
buttons: [{ label: 'Menu', action: () => this.showMenu() }],
});
}
@ -700,6 +796,9 @@ export default class TetrisAttackGame extends Phaser.Scene {
// Right side: host portrait (stage clear) + controls
if (this.mode === 'stageclear') {
this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 138, 'HELPING', {
fontFamily: FONT, fontSize: '24px', color: '#9fb0c8',
}).setOrigin(0.5));
this.hostPortrait = createHostPortrait(this, GAME_WIDTH - 260, 360, this.currentRound);
this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 560, this.currentRound.name.toUpperCase(), {
fontFamily: FONT, fontSize: '34px', color: '#ffe66e',

View File

@ -12,6 +12,14 @@ const OVL = 60, OVL_UI = 62;
// Character↔portrait fallback: opponents.png frame per round (see sprites.md).
const POSE_OFFSET = { neutral: 0, happy: 1, worried: 2 };
// Character dialog is authored as an array per friend (data/tetrisattack.json →
// stageClear.rounds) so they say something different each visit. A bare string
// still works, and an empty/missing entry falls back to `fallback`.
export function pickLine(value, fallback = '') {
if (Array.isArray(value)) return value.length ? value[Math.floor(Math.random() * value.length)] : fallback;
return value || fallback;
}
function dim(scene, alpha = 0.72) {
const r = scene.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x05070f, alpha).setDepth(OVL);
scene.overlayObjs.push(r);
@ -125,7 +133,7 @@ export function showStageSelect(scene) {
scene.overlayObjs.push(scene.add.text(cx, 110, 'STAGE CLEAR', {
fontFamily: FONT, fontSize: '72px', color: '#ffe66e',
}).setOrigin(0.5).setDepth(OVL_UI));
scene.overlayObjs.push(scene.add.text(cx, 180, 'Pick a challenger — six rounds of five stages', {
scene.overlayObjs.push(scene.add.text(cx, 180, 'Pick a friend to help — six rounds of five stages', {
fontFamily: FONT, fontSize: '26px', color: '#7de6ff',
}).setOrigin(0.5).setDepth(OVL_UI));
@ -189,10 +197,25 @@ export function showStageSelect(scene) {
export function showStageIntro(scene, round, index, onContinue) {
scene.clearOverlay();
scene.teardownGameplay();
dim(scene, 0.92);
const veil = dim(scene, 0.92);
const cx = GAME_WIDTH / 2;
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.ensureRoundAssets) so it may still be in
// flight when the cutscene opens — scene.onBackgroundsLoaded re-tries then.
let bgImage = null;
const applyBg = () => {
if (bgImage) return;
const key = scene.introBackgroundKey?.(round);
if (!key) return;
bgImage = scene.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(OVL - 1);
scene.overlayObjs.push(bgImage);
veil.setAlpha(0.5); // lift the dim so the art reads behind the cutscene
};
applyBg();
scene.onBackgroundsLoaded = applyBg;
scene.overlayObjs.push(scene.add.text(cx, 120, `ROUND ${index + 1} OF ${total}`, {
fontFamily: FONT, fontSize: '40px', color: '#7de6ff',
}).setOrigin(0.5).setDepth(OVL_UI));
@ -220,33 +243,85 @@ export function showStageIntro(scene, round, index, onContinue) {
bubble.fillTriangle(bx - 380, by - 20, bx - 440, by + 10, bx - 380, by + 40);
bubble.setAlpha(0);
scene.overlayObjs.push(bubble);
const line = scene.add.text(bx, by, `"${round.intro}"`, {
// The friend says every one of their introLines in order, one per keypress;
// the last one starts the stage. (A bare string still works as a one-liner.)
const script = introScript(round);
let lineIndex = 0;
const line = scene.add.text(bx, by, `"${script[0]}"`, {
fontFamily: FONT, fontSize: '34px', color: '#1a2036', align: 'center', wordWrap: { width: 680 },
}).setOrigin(0.5).setDepth(OVL_UI).setAlpha(0);
scene.overlayObjs.push(line);
scene.tweens.add({ targets: [bubble, line], alpha: 1, delay: 500, duration: 400 });
const prompt = scene.add.text(cx, GAME_HEIGHT - 90, 'PRESS ANY KEY OR CLICK TO BEGIN', {
// one dot per remaining line, so the player can see the conversation's length
const dots = script.length > 1 ? scene.add.graphics().setDepth(OVL_UI) : null;
if (dots) scene.overlayObjs.push(dots);
const drawDots = () => {
if (!dots) return;
const gap = 30, y = by + 186;
let dx = bx - ((script.length - 1) * gap) / 2;
dots.clear();
for (let i = 0; i < script.length; i++) {
dots.fillStyle(0xffffff, i === lineIndex ? 1 : 0.3);
dots.fillCircle(dx, y, i === lineIndex ? 8 : 6);
dx += gap;
}
};
drawDots();
const promptLabel = () => (lineIndex < script.length - 1
? 'PRESS ANY KEY OR CLICK TO CONTINUE' : 'PRESS ANY KEY OR CLICK TO HELP');
const prompt = scene.add.text(cx, GAME_HEIGHT - 90, promptLabel(), {
fontFamily: FONT, fontSize: '30px', color: '#ffffff',
}).setOrigin(0.5).setDepth(OVL_UI);
scene.overlayObjs.push(prompt);
scene.tweens.add({ targets: prompt, alpha: 0.25, duration: 600, yoyo: true, repeat: -1 });
let done = false;
const go = () => {
// input gate: each line has to settle before the next press counts, so one
// eager keypress can't blow through the whole conversation
let ready = false;
const arm = (ms) => { ready = false; scene.time.delayedCall(ms, () => { ready = true; }); };
const finish = () => {
if (done) return; done = true;
scene.input.keyboard.off('keydown', go);
scene.input.off('pointerdown', go);
scene.onBackgroundsLoaded = null;
scene.input.keyboard.off('keydown', advance);
scene.input.off('pointerdown', advance);
playSound(scene, SFX.UI_ACTIVATE);
scene.clearOverlay();
onContinue();
};
// small delay so the cutscene reads before an accidental keypress skips it
scene.time.delayedCall(350, () => {
if (done) return;
scene.input.keyboard.once('keydown', go);
scene.input.once('pointerdown', go);
});
const advance = () => {
if (done || !ready) return;
if (lineIndex >= script.length - 1) { finish(); return; }
lineIndex++;
playSound(scene, SFX.UI_PICK);
// blink the bubble text over to the next line so it reads as a new beat
scene.tweens.add({
targets: line, alpha: 0, duration: 110,
onComplete: () => {
line.setText(`"${script[lineIndex]}"`);
scene.tweens.add({ targets: line, alpha: 1, duration: 160 });
},
});
drawDots();
prompt.setText(promptLabel());
arm(260);
};
scene.input.keyboard.on('keydown', advance);
scene.input.on('pointerdown', advance);
// small delay so the first line reads before an accidental keypress skips it
arm(350);
}
// A friend's intro dialog, normalised to a non-empty array of lines in order.
function introScript(round) {
const raw = round.introLines ?? round.intro;
const list = (Array.isArray(raw) ? raw : [raw]).filter((l) => typeof l === 'string' && l.trim());
return list.length ? list : ['Glad you made it — help me clear these!'];
}
// ── Result overlay ────────────────────────────────────────────────────────────
@ -294,7 +369,7 @@ export function createHostPortrait(scene, x, y, round, index = scene.roundIndex)
img.setFrame(index * 3 + (POSE_OFFSET[pose] ?? 0));
} else {
// opponents fallback has no poses — pulse instead
const tint = pose === 'happy' ? 0xffd0d0 : pose === 'worried' ? 0xd0ffe0 : 0xffffff;
const tint = pose === 'happy' ? 0xd0ffe0 : pose === 'worried' ? 0xffd0d0 : 0xffffff;
img.setTint(tint);
scene.tweens.add({ targets: img, scale: img.scaleX * 1.06, duration: 120, yoyo: true });
}

View File

@ -54,15 +54,49 @@ Six characters, **3 poses each** = 18 frames. Character order (rows), pose order
| 4 | Victor | 12, 13, 14 |
| 5 | Klaxon | 15, 16, 17 |
- **neutral** — used in the Stage Clear intro cutscene and as the idle host.
- **happy** — the host is winning / you just topped out (they cheer).
- **worried** — you're clearing well / you just beat them.
The six characters are **friends you are helping**, not opponents — they watch
you dig their board out and react to how it's going:
- **neutral** — used in the Stage Clear intro cutscene and as the idle friend.
- **happy** — you just pulled off a chain/combo, or cleared their round (they cheer).
- **worried** — the stack is near the top, or you just topped out (they fret *with* you).
Until this sheet is supplied, characters fall back to their existing round
portraits from the shared `opponents.png` sheet (Beth 18, Jerry 10, Michael 15,
Steve 17, Victor 2, Klaxon 29).
To re-theme, re-order, or swap which opponents appear, edit
To re-theme, re-order, or swap which friends appear, edit
`data/tetrisattack.json` → `stageClear.rounds` (each round's `characterId`,
`spriteIndex`, and lines). The character↔frame mapping above is by round order,
so keep the sheet rows in sync with that array.
`spriteIndex`, and its `introLines` / `winLines` / `loseLines` arrays —
`introLines` plays in order, one line per keypress, so write as many beats as you
like; win/lose lines are picked at random). The
character↔frame mapping above is by round order, so keep the sheet rows in sync
with that array.
## 3. Backgrounds — `assets/images/tetrisattack/background-{characterId}-r{1..5}.png`
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**.

View File

@ -363,9 +363,11 @@ section('12. Stage Clear soak');
const rounds = cfg.stageClear?.rounds ?? [];
const perRound = cfg.stageClear?.stagesPerRound;
const speedStep = cfg.stageClear?.stageSpeedStep;
// dialog is authored as arrays so each friend can say something different
const linesOk = (v) => Array.isArray(v) && v.length > 0 && v.every((l) => typeof l === 'string' && l.trim());
check('every round is fully configured', rounds.every((r) => (
Number.isInteger(r.clearLineRows) && Number.isInteger(r.startRows) && Number.isInteger(r.speedLevel)
&& r.characterId && r.intro && r.winLine && r.loseLine)));
&& r.characterId && linesOk(r.introLines) && linesOk(r.winLines) && linesOk(r.loseLines))));
check('the ladder is 6 rounds × 5 stages', rounds.length === 6 && perRound === 5, `${rounds.length}×${perRound}`);
// the per-stage ramp must not overtake the next character's opening stage
const lastStageSpeed = (r) => r.speedLevel + (perRound - 1) * speedStep;