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.
This commit is contained in:
Brian Fertig 2026-07-22 20:52:05 -06:00
parent ca1c9e48c2
commit cf611fa669
19 changed files with 245 additions and 54 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.

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

@ -9,7 +9,7 @@ import {
} from './TetrisAttackLogic.js';
import {
showMenu, showPuzzleSelect, showStageSelect, showStageIntro, showResult,
createHostPortrait, FONT,
createHostPortrait, pickLine, FONT,
} from './TetrisAttackScreens.js';
// ── Layout ──────────────────────────────────────────────────────────────────
@ -66,6 +66,7 @@ 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.uiLayer = null;
this.overlayObjs = [];
this.best = 0;
@ -98,6 +99,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
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,7 +133,7 @@ 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; }
@ -211,6 +214,22 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.heroBg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(D.bg + 2);
}
// 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 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
@ -234,6 +253,8 @@ 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();
@ -301,8 +322,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 +431,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 +461,10 @@ export default class TetrisAttackGame extends Phaser.Scene {
});
} else if (this.mode === 'stageclear') {
const round = this.currentRound;
this._resultPose = 'happy';
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 +494,15 @@ 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';
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 +533,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 +551,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 +722,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.ensureRoundBackgrounds) 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,27 @@ 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**.

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;