refactor: replace stage clear targets with clear line boundary mechanic

Restructure Tetris Attack Stage Clear from "clear all target panels" to a
multi-stage round system with a sliding boundary line.

Gameplay changes:
- Each character now has 5 stages instead of a single match
- A CLEAR line appears after a set number of rows rise, then rides the
  stack upward; the objective is to clear everything above it
- Clearing a stage advances to the next: same board, faster speed, fresh line
- Stage progress is saved to localStorage; characters unlock sequentially
- Added a stage select screen with progress squares per character

Logic changes:
- Replaced `targetsTotal` with `clearLine` tracking and `countAboveLine`
- Added `advanceStage()` to resume play on the same stack one speed level higher
- Win condition now checks `countAboveLine() === 0` instead of target count
- Removed `target` flag from panels; panels below the line are irrelevant
- Continuous speed ramp via fractional `speed` per stage

UI changes:
- Clear line drawn as a pulsing bar with "◀ CLEAR" label outside the board
- "CLEAR LINE!" announcement on appearance, "STAGE N CLEAR!" banner on win
- HUD shows stage number instead of panel count
- Game over offers "Retry Stage", "Stage Select", or "Menu"

Asset changes:
- Backgrounds renamed to `background-{hero}-r{stage}.png` (5 per character)
- Lazy-load backgrounds per round instead of preloading all 30 images
- Updated tetrisattack.json with `stagesPerRound`, `stageSpeedStep`, and
  per-character `clearLineRows`

Tests:
- Rewrote stage clear soak tests for the new mechanic
- Added stage round progression, speed ramp, and board preservation tests
This commit is contained in:
Brian Fertig 2026-07-21 10:00:09 -06:00
parent 18412e34f9
commit 08d23f9613
8 changed files with 500 additions and 76 deletions

View File

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 923 KiB

View File

@ -5,7 +5,9 @@
}, },
"stageClear": { "stageClear": {
"name": "Stage Clear", "name": "Stage Clear",
"description": "Clear the starting stack before it tops out. Six challengers, one at a time.", "description": "Six challengers, five stages each. Survive until the CLEAR line slides in, then wipe out every panel above it.",
"stagesPerRound": 5,
"stageSpeedStep": 0.2,
"rounds": [ "rounds": [
{ {
"characterId": "beth", "characterId": "beth",
@ -13,6 +15,7 @@
"name": "Beth", "name": "Beth",
"speedLevel": 1, "speedLevel": 1,
"startRows": 6, "startRows": 6,
"clearLineRows": 3,
"intro": "I ain't seen you 'round these parts before. Let's see whatcha got.", "intro": "I ain't seen you 'round these parts before. Let's see whatcha got.",
"winLine": "Well shoot, you got the better of me!", "winLine": "Well shoot, you got the better of me!",
"loseLine": "Told ya you weren't ready, sugar." "loseLine": "Told ya you weren't ready, sugar."
@ -23,6 +26,7 @@
"name": "Jerry", "name": "Jerry",
"speedLevel": 2, "speedLevel": 2,
"startRows": 6, "startRows": 6,
"clearLineRows": 3,
"intro": "Well now, y'all know I came to play some'a them games!", "intro": "Well now, y'all know I came to play some'a them games!",
"winLine": "Hoo-wee, you got me good!", "winLine": "Hoo-wee, you got me good!",
"loseLine": "Better luck next time, partner!" "loseLine": "Better luck next time, partner!"
@ -33,6 +37,7 @@
"name": "Michael", "name": "Michael",
"speedLevel": 3, "speedLevel": 3,
"startRows": 7, "startRows": 7,
"clearLineRows": 3,
"intro": "Hey mon! I been waitin' ta play on da Fertig Games — let's go mon!", "intro": "Hey mon! I been waitin' ta play on da Fertig Games — let's go mon!",
"winLine": "Ya beat me fair, mon. Respect!", "winLine": "Ya beat me fair, mon. Respect!",
"loseLine": "Not today, mon, ya!" "loseLine": "Not today, mon, ya!"
@ -43,6 +48,7 @@
"name": "Steve", "name": "Steve",
"speedLevel": 4, "speedLevel": 4,
"startRows": 7, "startRows": 7,
"clearLineRows": 2,
"intro": "I traveled WAY too far across the galaxy for this. Don't waste my time.", "intro": "I traveled WAY too far across the galaxy for this. Don't waste my time.",
"winLine": "Impossible... a lifeform this primitive bested me?", "winLine": "Impossible... a lifeform this primitive bested me?",
"loseLine": "Exactly as I calculated. Predictable." "loseLine": "Exactly as I calculated. Predictable."
@ -53,6 +59,7 @@
"name": "Victor", "name": "Victor",
"speedLevel": 5, "speedLevel": 5,
"startRows": 8, "startRows": 8,
"clearLineRows": 2,
"intro": "Patience. Every panel falls exactly where the ancients intended.", "intro": "Patience. Every panel falls exactly where the ancients intended.",
"winLine": "The ancients smile upon you today.", "winLine": "The ancients smile upon you today.",
"loseLine": "Patience was never your virtue." "loseLine": "Patience was never your virtue."
@ -63,6 +70,7 @@
"name": "Klaxon", "name": "Klaxon",
"speedLevel": 6, "speedLevel": 6,
"startRows": 8, "startRows": 8,
"clearLineRows": 2,
"intro": "MISSION OBJECTIVE: victory. All other priorities rescinded.", "intro": "MISSION OBJECTIVE: victory. All other priorities rescinded.",
"winLine": "ERROR. DEFEAT NOT IN PARAMETERS.", "winLine": "ERROR. DEFEAT NOT IN PARAMETERS.",
"loseLine": "OBJECTIVE COMPLETE. RESISTANCE FUTILE." "loseLine": "OBJECTIVE COMPLETE. RESISTANCE FUTILE."

View File

@ -47,12 +47,6 @@ function imagesFrom(scene, jsonKey) {
.map((a) => ({ type: 'image', key: a.key, path: a.path })); .map((a) => ({ type: 'image', key: a.key, path: a.path }));
} }
// Tetris Attack Stage Clear host backgrounds — drop-in art named
// background-{hero}.png (hero = lowercase host name from data/tetrisattack.json's
// stageClear.rounds). Speculatively declared for every host; hosts without art
// yet just log a load warning and the scene's textures.exists fallback applies.
const TETRISATTACK_HEROES = ['beth', 'jerry', 'michael', 'steve', 'victor', 'klaxon'];
// Drop-in per-game soundtrack tracks declared in a cached `<name>-music.json` // Drop-in per-game soundtrack tracks declared in a cached `<name>-music.json`
// (see services/soundtrack.js). Audio bytes lazy-load only when the owning // (see services/soundtrack.js). Audio bytes lazy-load only when the owning
// game is entered — never part of the shared default-soundtrack preload. // game is entered — never part of the shared default-soundtrack preload.
@ -230,8 +224,10 @@ export const MANIFEST = {
{ type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' }, { type: 'json', key: 'tetrisattack', path: 'data/tetrisattack.json' },
{ type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' }, { type: 'json', key: 'tetrisattack-puzzles', path: 'data/tetrisattack-puzzles.json' },
(scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']), (scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']),
...TETRISATTACK_HEROES.map((hero) => // Stage Clear backgrounds (background-{hero}-r{stage}.png, 6 hosts × 5
image(`tetrisattack-bg-${hero}`, `assets/images/tetrisattack/background-${hero}.png`)), // 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.
(scene) => musicFrom(scene, 'nintendo-music'), (scene) => musicFrom(scene, 'nintendo-music'),
], ],
coloradodefense: [ coloradodefense: [

View File

@ -5,10 +5,11 @@ import { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound, SFX } from '../../ui/Sounds.js'; import { playSound, SFX } from '../../ui/Sounds.js';
import { import {
COLS, ROWS, PANEL_COLORS, TUNING, COLS, ROWS, PANEL_COLORS, TUNING,
newGame, step, moveCursor, trySwap, setRaise, newGame, step, moveCursor, trySwap, setRaise, advanceStage,
} from './TetrisAttackLogic.js'; } from './TetrisAttackLogic.js';
import { import {
showMenu, showPuzzleSelect, showStageIntro, showResult, createHostPortrait, FONT, showMenu, showPuzzleSelect, showStageSelect, showStageIntro, showResult,
createHostPortrait, FONT,
} from './TetrisAttackScreens.js'; } from './TetrisAttackScreens.js';
// ── Layout ────────────────────────────────────────────────────────────────── // ── Layout ──────────────────────────────────────────────────────────────────
@ -33,6 +34,9 @@ const PANEL_PAL = {
}; };
const BEST_KEY = 'tetrisattack-best'; const BEST_KEY = 'tetrisattack-best';
// Stage Clear progress: { [characterId]: highest stage number completed }.
// Drives the stage-select squares and which characters are unlocked.
const PROGRESS_KEY = 'tetrisattack-stage-progress';
export default class TetrisAttackGame extends Phaser.Scene { export default class TetrisAttackGame extends Phaser.Scene {
constructor() { super('TetrisAttackGame'); } constructor() { super('TetrisAttackGame'); }
@ -46,7 +50,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.acc = 0; this.acc = 0;
this.sprites = new Map(); // panel.id -> Phaser image this.sprites = new Map(); // panel.id -> Phaser image
this.incomingSprites = []; this.incomingSprites = [];
this.stageIndex = 0; this.roundIndex = 0; // which character (0-5)
this.stageNumber = 1; // stage within that character's round (1-5)
this.puzzleIndex = 0; this.puzzleIndex = 0;
this.hostPortrait = null; this.hostPortrait = null;
this.heroBg = null; this.heroBg = null;
@ -87,22 +92,49 @@ export default class TetrisAttackGame extends Phaser.Scene {
// ── Menus / flow (delegates to Screens) ─────────────────────────────────── // ── Menus / flow (delegates to Screens) ───────────────────────────────────
showMenu() { this.clearOverlay(); this.teardownGameplay(); showMenu(this); } showMenu() { this.clearOverlay(); this.teardownGameplay(); showMenu(this); }
startEndless() { this.beginGame({ mode: 'endless' }); } startEndless() { this.beginGame({ mode: 'endless' }); }
startStageClear() { this.stageIndex = 0; this.showStageIntro(); } startStageClear() { this.clearOverlay(); this.teardownGameplay(); showStageSelect(this); }
startPuzzleMode() { showPuzzleSelect(this); } startPuzzleMode() { showPuzzleSelect(this); }
showStageIntro() { get rounds() { return this.config?.stageClear?.rounds ?? []; }
const rounds = this.config?.stageClear?.rounds ?? []; get stagesPerRound() { return this.config?.stageClear?.stagesPerRound ?? 5; }
const round = rounds[this.stageIndex]; get stageSpeedStep() { return this.config?.stageClear?.stageSpeedStep ?? TUNING.STAGE_SPEED_STEP; }
// ── Stage Clear progress (localStorage) ───────────────────────────────────
loadStageProgress() {
try { return JSON.parse(localStorage.getItem(PROGRESS_KEY) ?? '{}') ?? {}; } catch (_) { return {}; }
}
markStageComplete(characterId, stageNumber) {
const progress = this.loadStageProgress();
if ((progress[characterId] ?? 0) >= stageNumber) return;
progress[characterId] = stageNumber;
try { localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); } catch (_) { /* ignore */ }
}
// The first character is always open; each later one unlocks when the
// previous character's whole round has been beaten.
isRoundUnlocked(index) {
if (index <= 0) return true;
const prev = this.rounds[index - 1];
return (this.loadStageProgress()[prev?.characterId] ?? 0) >= this.stagesPerRound;
}
// Start a character's round from its first stage.
beginRound(index) {
const round = this.rounds[index];
if (!round) { this.showStageComplete(); return; } if (!round) { this.showStageComplete(); return; }
showStageIntro(this, round, this.stageIndex, () => { this.roundIndex = index;
this.beginGame({ mode: 'stageclear', round }); this.stageNumber = 1;
this.ensureRoundBackgrounds(round);
showStageIntro(this, round, index, () => {
this.beginGame({ mode: 'stageclear', round, stageNumber: 1 });
}); });
} }
beginStagePuzzleFromSelect(index) { this.puzzleIndex = index; this.beginGame({ mode: 'puzzle', puzzleIndex: index }); } beginStagePuzzleFromSelect(index) { this.puzzleIndex = index; this.beginGame({ mode: 'puzzle', puzzleIndex: index }); }
// ── Gameplay setup ──────────────────────────────────────────────────────── // ── Gameplay setup ────────────────────────────────────────────────────────
beginGame({ mode, round, puzzleIndex }) { beginGame({ mode, round, puzzleIndex, stageNumber = 1 }) {
this.clearOverlay(); this.clearOverlay();
this.teardownGameplay(); this.teardownGameplay();
this.mode = mode; this.mode = mode;
@ -115,12 +147,24 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.state = newGame({ mode: 'puzzle', puzzle: { grid: p.grid, maxMoves: p.maxMoves }, rng }); this.state = newGame({ mode: 'puzzle', puzzle: { grid: p.grid, maxMoves: p.maxMoves }, rng });
} else if (mode === 'stageclear') { } else if (mode === 'stageclear') {
this.currentRound = round; this.currentRound = round;
this.state = newGame({ mode: 'stageclear', stage: { speedLevel: round.speedLevel, startRows: round.startRows }, rng }); this.stageNumber = stageNumber;
this.state = newGame({
mode: 'stageclear',
stage: {
speedLevel: round.speedLevel,
startRows: round.startRows,
clearLineRows: round.clearLineRows,
stageNumber,
speedStep: this.stageSpeedStep,
},
rng,
});
} else { } else {
this.state = newGame({ mode: 'endless', rng }); this.state = newGame({ mode: 'endless', rng });
} }
this.setHeroBackground(mode === 'stageclear' ? round.name : null); if (mode === 'stageclear') this.setStageBackground(round.characterId, stageNumber);
else this.setStageBackground(null);
this.buildHUD(); this.buildHUD();
this.playing = true; this.playing = true;
this.acc = 0; this.acc = 0;
@ -134,25 +178,56 @@ export default class TetrisAttackGame extends Phaser.Scene {
for (const s of this.incomingSprites) s.destroy(); for (const s of this.incomingSprites) s.destroy();
this.incomingSprites = []; this.incomingSprites = [];
if (this.cursorGfx) { this.cursorGfx.destroy(); this.cursorGfx = null; } if (this.cursorGfx) { this.cursorGfx.destroy(); this.cursorGfx = null; }
if (this.clearLineGfx) { this.clearLineGfx.destroy(); this.clearLineGfx = null; }
if (this.clearLineLabel) { this.clearLineLabel.destroy(); this.clearLineLabel = null; }
if (this.hudLayer) { this.hudLayer.destroy(); this.hudLayer = null; } if (this.hudLayer) { this.hudLayer.destroy(); this.hudLayer = null; }
if (this.boardWell) { this.boardWell.destroy(); this.boardWell = null; } if (this.boardWell) { this.boardWell.destroy(); this.boardWell = null; }
if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; } if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; }
if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; } if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; }
this.setHeroBackground(null); this.setStageBackground(null);
this.state = null; this.state = null;
} }
// Stage Clear background art, drop-in per host (assets/images/tetrisattack/ // Stage Clear background art, drop-in per host and stage (assets/images/
// background-{hero}.png, see data/assetManifest.js). Falls back to the // tetrisattack/background-{hero}-r{stage}.png). Falls back to the legacy
// procedural gradient backdrop when a host has no art yet. // unsuffixed background-{hero}.png, then to the procedural gradient backdrop.
setHeroBackground(heroName) { setStageBackground(hero, stageNumber = 1) {
if (this.heroBg) { this.heroBg.destroy(); this.heroBg = null; } if (this.heroBg) { this.heroBg.destroy(); this.heroBg = null; }
if (!heroName) return; if (!hero) return;
const key = `tetrisattack-bg-${heroName.toLowerCase()}`; const key = [`tetrisattack-bg-${hero}-r${stageNumber}`, `tetrisattack-bg-${hero}`]
if (!this.textures.exists(key)) return; .find((k) => this.textures.exists(k));
if (!key) return;
this.heroBg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(D.bg + 2); 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) {
const hero = round?.characterId;
if (!hero) return;
const wanted = [];
for (let n = 1; n <= this.stagesPerRound; n++) {
wanted.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}`);
this.load.on('loaderror', onError);
this.load.once('complete', () => {
this.load.off('loaderror', onError);
// re-apply: the art for the stage in play may only just have arrived
if (this.mode === 'stageclear' && this.currentRound) {
this.setStageBackground(this.currentRound.characterId, this.stageNumber);
}
});
// files queued mid-pass ride along with the load already in flight
if (!this.load.isLoading()) this.load.start();
}
// ── Main loop ───────────────────────────────────────────────────────────── // ── Main loop ─────────────────────────────────────────────────────────────
update(time, delta) { update(time, delta) {
if (!this.playing || !this.state) return; if (!this.playing || !this.state) return;
@ -172,6 +247,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
for (const e of events) { for (const e of events) {
if (e.type === 'clear') this.onClear(e); if (e.type === 'clear') this.onClear(e);
else if (e.type === 'rowShift') playSound(this, SFX.EIGHTBIT_MOVE); else if (e.type === 'rowShift') playSound(this, SFX.EIGHTBIT_MOVE);
else if (e.type === 'clearLineAppear') this.onClearLineAppear();
else if (e.type === 'danger') this.onDanger(e.on); else if (e.type === 'danger') this.onDanger(e.on);
else if (e.type === 'gameOver') this.onGameOver(); else if (e.type === 'gameOver') this.onGameOver();
else if (e.type === 'win') this.onWin(); else if (e.type === 'win') this.onWin();
@ -224,6 +300,21 @@ export default class TetrisAttackGame extends Phaser.Scene {
} }
} }
// The boundary line has slid in under the stack: everything above it is now
// the objective. Announce it over the board.
onClearLineAppear() {
playSound(this, SFX.EIGHTBIT_ACTIVATE);
const t = this.add.text(BOARD_LEFT + BOARD_W / 2, BOARD_TOP + BOARD_H / 2, 'CLEAR LINE!', {
fontFamily: FONT, fontSize: '54px', color: '#ffffff', stroke: '#101018', strokeThickness: 8,
}).setOrigin(0.5).setDepth(D.fx);
this.tweens.add({
targets: t, scale: { from: 0.4, to: 1.15 }, duration: 320, ease: 'Back.easeOut',
onComplete: () => this.tweens.add({
targets: t, alpha: 0, y: t.y - 50, delay: 550, duration: 450, onComplete: () => t.destroy(),
}),
});
}
onDanger(on) { onDanger(on) {
if (this.boardFrame) this.boardFrame.setStrokeStyle(6, on ? 0xff4d5e : 0x40506a); if (this.boardFrame) this.boardFrame.setStrokeStyle(6, on ? 0xff4d5e : 0x40506a);
if (on) { if (on) {
@ -262,7 +353,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
lines: [`${round.name}: "${round.loseLine}"`], lines: [`${round.name}: "${round.loseLine}"`],
round, round,
buttons: [ buttons: [
{ label: 'Retry', action: () => this.showStageIntro() }, { label: 'Retry Stage', action: () => this.beginGame({ mode: 'stageclear', round, stageNumber: this.stageNumber }) },
{ label: 'Stage Select', action: () => this.startStageClear() },
{ label: 'Menu', action: () => this.showMenu() }, { label: 'Menu', action: () => this.showMenu() },
], ],
}); });
@ -284,16 +376,19 @@ export default class TetrisAttackGame extends Phaser.Scene {
playSound(this, SFX.VICTORY_SHORT); playSound(this, SFX.VICTORY_SHORT);
if (this.mode === 'stageclear') { if (this.mode === 'stageclear') {
const round = this.currentRound; const round = this.currentRound;
this.markStageComplete(round.characterId, this.stageNumber);
// 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 = 'worried';
// record the win so the puzzle/stage progress could be surfaced later const last = this.roundIndex >= (this.rounds.length - 1);
const last = this.stageIndex >= (this.config.stageClear.rounds.length - 1);
showResult(this, { showResult(this, {
title: `${round.name.toUpperCase()} DEFEATED`, title: `${round.name.toUpperCase()} DEFEATED`,
lines: [`${round.name}: "${round.winLine}"`, `Score ${this.state.score}`], lines: [`${round.name}: "${round.winLine}"`, `Score ${this.state.score}`],
round, round,
buttons: last buttons: last
? [{ label: 'Finish', action: () => this.showStageComplete() }] ? [{ label: 'Finish', action: () => this.showStageComplete() }]
: [{ label: 'Next Challenger', action: () => { this.stageIndex++; this.showStageIntro(); } }, : [{ label: 'Next Challenger', action: () => this.beginRound(this.roundIndex + 1) },
{ label: 'Menu', action: () => this.showMenu() }], { label: 'Menu', action: () => this.showMenu() }],
}); });
} else if (this.mode === 'puzzle') { } else if (this.mode === 'puzzle') {
@ -315,6 +410,30 @@ export default class TetrisAttackGame extends Phaser.Scene {
} }
} }
// Stage cleared inside a round: hold the board for a beat under a banner,
// then keep playing on the same stack one notch faster.
advanceToNextStage() {
const round = this.currentRound;
const banner = this.add.text(BOARD_LEFT + BOARD_W / 2, BOARD_TOP + BOARD_H / 2, `STAGE ${this.stageNumber}\nCLEAR!`, {
fontFamily: FONT, fontSize: '64px', color: '#ffe66e', align: 'center',
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');
this.time.delayedCall(1400, () => {
this.tweens.add({
targets: banner, alpha: 0, duration: 300, onComplete: () => banner.destroy(),
});
if (!this.state) return;
this.stageNumber++;
advanceStage(this.state, { speedStep: this.stageSpeedStep });
this.setStageBackground(round.characterId, this.stageNumber);
this.updateHUD();
this.playing = true;
});
}
showStageComplete() { showStageComplete() {
this.teardownGameplay(); this.teardownGameplay();
showResult(this, { showResult(this, {
@ -382,9 +501,41 @@ export default class TetrisAttackGame extends Phaser.Scene {
const pos = this.cellCenter(ROWS, c); const pos = this.cellCenter(ROWS, c);
spr.x = pos.x; spr.y = pos.y; spr.x = pos.x; spr.y = pos.y;
} }
this.drawClearLine();
this.drawCursor(); this.drawCursor();
} }
// Stage Clear boundary line: a pulsing white bar sitting on the top edge of
// row state.clearLine, riding the stack (it shares riseOffset with the
// panels), with a "CLEAR" label just outside the playfield to its right.
drawClearLine() {
const line = this.state.clearLine;
if (line == null) {
if (this.clearLineGfx) this.clearLineGfx.setVisible(false);
if (this.clearLineLabel) this.clearLineLabel.setVisible(false);
return;
}
if (!this.clearLineGfx) {
// inside boardLayer so the mask clips it and the danger shake carries it
this.clearLineGfx = this.add.graphics().setDepth(D.cursor);
this.boardLayer.add(this.clearLineGfx);
// the label lives OUTSIDE the masked playfield, so not in boardLayer
this.clearLineLabel = this.add.text(BOARD_LEFT + BOARD_W + 30, 0, '◀ CLEAR', {
fontFamily: FONT, fontSize: '30px', color: '#ffffff', stroke: '#101018', strokeThickness: 6,
}).setOrigin(0, 0.5).setDepth(D.hud);
}
const y = this.cellCenter(line, 0).y - CELL / 2;
const pulse = 0.55 + 0.45 * Math.sin(this.time.now / 160);
const g = this.clearLineGfx;
g.clear();
g.setVisible(true);
g.fillStyle(0xffffff, 0.22 * pulse);
g.fillRect(BOARD_LEFT, y - 9, BOARD_W, 18);
g.fillStyle(0xffffff, 0.45 + 0.55 * pulse);
g.fillRect(BOARD_LEFT, y - 3, BOARD_W, 6);
this.clearLineLabel.setVisible(true).setY(y).setAlpha(0.5 + 0.5 * pulse);
}
drawCursor() { drawCursor() {
if (!this.cursorGfx) this.cursorGfx = this.add.graphics().setDepth(D.cursor); if (!this.cursorGfx) this.cursorGfx = this.add.graphics().setDepth(D.cursor);
const g = this.cursorGfx; const g = this.cursorGfx;
@ -448,7 +599,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.hudTexts.best = mk(410, 'BEST'); this.hudTexts.best = mk(410, 'BEST');
this.hudTexts.level = mk(520, 'SPEED'); this.hudTexts.level = mk(520, 'SPEED');
} else if (this.mode === 'stageclear') { } else if (this.mode === 'stageclear') {
this.hudTexts.targets = mk(410, 'PANELS LEFT'); // no objective counter — the CLEAR line itself is the indicator
this.hudTexts.stage = mk(410, 'STAGE');
this.hudTexts.level = mk(520, 'SPEED'); this.hudTexts.level = mk(520, 'SPEED');
} else { } else {
this.hudTexts.moves = mk(410, 'SWAPS LEFT'); this.hudTexts.moves = mk(410, 'SWAPS LEFT');
@ -462,10 +614,11 @@ export default class TetrisAttackGame extends Phaser.Scene {
}).setOrigin(0.5)); }).setOrigin(0.5));
} }
const controls = this.mode === 'puzzle' const controls = ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap'];
? ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap', '', 'Clear every panel!'] if (this.mode === 'puzzle') controls.push('', 'Clear every panel!');
: ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap', 'SHIFT raise stack']; else controls.push('SHIFT raise stack');
this.hudLayer.add(this.add.text(GAME_WIDTH - 420, 700, controls.join('\n'), { if (this.mode === 'stageclear') controls.push('', 'Clear everything above', 'the CLEAR line!');
this.hudLayer.add(this.add.text(GAME_WIDTH - 420, 660, controls.join('\n'), {
fontFamily: FONT, fontSize: '26px', color: '#9fb0c8', lineSpacing: 10, fontFamily: FONT, fontSize: '26px', color: '#9fb0c8', lineSpacing: 10,
})); }));
@ -490,7 +643,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
restartCurrent() { restartCurrent() {
if (this.mode === 'endless') this.startEndless(); if (this.mode === 'endless') this.startEndless();
else if (this.mode === 'stageclear') this.beginGame({ mode: 'stageclear', round: this.currentRound }); else if (this.mode === 'stageclear') this.beginGame({ mode: 'stageclear', round: this.currentRound, stageNumber: this.stageNumber });
else this.beginStagePuzzleFromSelect(this.puzzleIndex); else this.beginStagePuzzleFromSelect(this.puzzleIndex);
} }
@ -499,16 +652,10 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.hudTexts.score?.setText(String(this.state.score)); this.hudTexts.score?.setText(String(this.state.score));
this.hudTexts.best?.setText(String(this.best)); this.hudTexts.best?.setText(String(this.best));
this.hudTexts.level?.setText(String(this.state.level)); this.hudTexts.level?.setText(String(this.state.level));
if (this.hudTexts.targets) this.hudTexts.targets.setText(String(this.state.targetsTotal ? this.countTargets() : 0)); this.hudTexts.stage?.setText(`${this.state.stage}/${this.stagesPerRound}`);
if (this.hudTexts.moves) this.hudTexts.moves.setText(String(Math.max(0, this.state.movesLeft ?? 0))); if (this.hudTexts.moves) this.hudTexts.moves.setText(String(Math.max(0, this.state.movesLeft ?? 0)));
} }
countTargets() {
let n = 0;
for (const row of this.state.board) for (const p of row) if (p && p.target) n++;
return n;
}
// ── Input ───────────────────────────────────────────────────────────────── // ── Input ─────────────────────────────────────────────────────────────────
registerInput() { registerInput() {
const kb = this.input.keyboard; const kb = this.input.keyboard;

View File

@ -29,6 +29,8 @@ export const TUNING = {
BASE_RISE: 0.0016, BASE_RISE: 0.0016,
RISE_PER_LEVEL: 0.0009, RISE_PER_LEVEL: 0.0009,
ROWS_PER_LEVEL: 8, // emerged rows between speed-ups ROWS_PER_LEVEL: 8, // emerged rows between speed-ups
CLEAR_LINE_ROWS: 3, // Stage Clear: rows that must rise before the line appears
STAGE_SPEED_STEP: 0.2, // Stage Clear: speed-level bump per stage within a round
// Scoring // Scoring
BASE_PANEL: 10, BASE_PANEL: 10,
}; };
@ -75,9 +77,15 @@ function isBoardEmpty(board) {
return board.every((row) => row.every((p) => p === null)); return board.every((row) => row.every((p) => p === null));
} }
function countTargets(board) { // Panels still sitting above the Stage Clear boundary line (the objective).
// The line is drawn along the TOP edge of row `state.clearLine`, so rows
// 0..clearLine-1 are above it. Zero while the line has not appeared yet.
function countAboveLine(state) {
if (state.clearLine == null) return 0;
let n = 0; let n = 0;
for (const row of board) for (const p of row) if (p && p.target) n++; for (let r = 0; r < state.clearLine; r++) {
for (const p of state.board[r]) if (p) n++;
}
return n; return n;
} }
@ -142,35 +150,47 @@ export function newGame(opts = {}) {
nextId: 1, nextId: 1,
// mode-specific // mode-specific
movesLeft: null, movesLeft: null,
targetsTotal: 0, // Stage Clear: index of the row whose top edge carries the boundary line
// (null until it appears). Rides the stack — decremented on every row shift.
clearLine: null,
clearLineRows: TUNING.CLEAR_LINE_ROWS,
clearLineAt: TUNING.CLEAR_LINE_ROWS, // rowsRaised value that spawns the line
stage: 1, // stage within the round (1..stagesPerRound)
speed: opts.level ?? 1, // fractional speed level (level + per-stage steps)
}; };
if (mode === 'puzzle') { if (mode === 'puzzle') {
loadPuzzle(state, opts.puzzle); loadPuzzle(state, opts.puzzle);
state.riseRate = 0; state.riseRate = 0;
} else if (mode === 'stageclear') { } else if (mode === 'stageclear') {
// A round is `stagesPerRound` stages hosted by one character. `level` is the
// round's integer difficulty (what the HUD and the select screen show);
// `speed` adds a fraction of a level per stage for a slow continuous ramp.
const speedLevel = opts.stage?.speedLevel ?? 1; const speedLevel = opts.stage?.speedLevel ?? 1;
const speedStep = opts.stage?.speedStep ?? TUNING.STAGE_SPEED_STEP;
state.level = speedLevel; state.level = speedLevel;
state.riseRate = TUNING.BASE_RISE + (speedLevel - 1) * TUNING.RISE_PER_LEVEL; state.stage = opts.stage?.stageNumber ?? 1;
fillInitial(state, rng, opts.stage?.startRows ?? 7, true); state.speed = speedLevel + (state.stage - 1) * speedStep;
state.targetsTotal = countTargets(state.board); state.riseRate = TUNING.BASE_RISE + (state.speed - 1) * TUNING.RISE_PER_LEVEL;
state.clearLineRows = opts.stage?.clearLineRows ?? TUNING.CLEAR_LINE_ROWS;
state.clearLineAt = state.clearLineRows;
fillInitial(state, rng, opts.stage?.startRows ?? 7);
} else { } else {
// endless // endless
state.level = opts.level ?? 1; state.level = opts.level ?? 1;
state.speed = state.level;
state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL; state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL;
fillInitial(state, rng, TUNING.START_ROWS, false); fillInitial(state, rng, TUNING.START_ROWS, false);
} }
return state; return state;
} }
// Fill the bottom `nRows` rows with panels (no starting matches). When // Fill the bottom `nRows` rows with panels (no starting matches).
// `asTargets` the placed panels are flagged as Stage-Clear objectives. function fillInitial(state, rng, nRows) {
function fillInitial(state, rng, nRows, asTargets) {
const { board } = state; const { board } = state;
for (let r = ROWS - nRows; r < ROWS; r++) { for (let r = ROWS - nRows; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) { for (let c = 0; c < COLS; c++) {
const color = safeColor(board, r, c, rng); board[r][c] = newPanel(state, safeColor(board, r, c, rng));
board[r][c] = newPanel(state, color, asTargets ? { target: true } : {});
} }
} }
} }
@ -195,6 +215,22 @@ export function loadPuzzle(state, puzzle) {
return state; return state;
} }
// ── Stage Clear: next stage, same board ─────────────────────────────────────
// Clearing a stage does not end the match — the stack that was below the line
// keeps rising exactly where it is, a fresh line is scheduled a few rows out,
// and the rise speeds up a notch. Board / riseOffset / score / cursor are all
// deliberately left alone; only the objective and the pace change.
export function advanceStage(state, opts = {}) {
state.won = false;
state.clearLine = null;
state.clearLineRows = opts.clearLineRows ?? state.clearLineRows;
state.clearLineAt = state.rowsRaised + state.clearLineRows;
state.stage++;
state.speed += opts.speedStep ?? TUNING.STAGE_SPEED_STEP;
state.riseRate = TUNING.BASE_RISE + (state.speed - 1) * TUNING.RISE_PER_LEVEL;
return state;
}
// ── Cursor & swap ─────────────────────────────────────────────────────────── // ── Cursor & swap ───────────────────────────────────────────────────────────
export function moveCursor(state, dir) { export function moveCursor(state, dir) {
const { cursor } = state; const { cursor } = state;
@ -358,10 +394,21 @@ function emergeRow(state, rng) {
state.incoming = generateRowColors(rng); state.incoming = generateRowColors(rng);
state.cursor.row = Math.max(0, state.cursor.row - 1); state.cursor.row = Math.max(0, state.cursor.row - 1);
state.rowsRaised++; state.rowsRaised++;
// Stage Clear boundary line: once placed it is glued to the two rows it sits
// between, so it moves up one index with them. It appears between the row
// that just emerged and the one above it, making the whole current stack the
// objective — everything fed in afterwards arrives below the line.
if (state.clearLine != null) {
state.clearLine--;
} else if (state.mode === 'stageclear' && state.rowsRaised >= state.clearLineAt) {
state.clearLine = ROWS - 1;
state._lineJustSpawned = true;
}
if (state.mode === 'endless') { if (state.mode === 'endless') {
const lvl = 1 + Math.floor(state.rowsRaised / TUNING.ROWS_PER_LEVEL); const lvl = 1 + Math.floor(state.rowsRaised / TUNING.ROWS_PER_LEVEL);
if (lvl !== state.level) { if (lvl !== state.level) {
state.level = lvl; state.level = lvl;
state.speed = lvl;
state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL; state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL;
} }
} }
@ -446,7 +493,6 @@ export function step(state, rng = Math.random) {
const p = board[r][c]; const p = board[r][c];
p.state = 'clearing'; p.state = 'clearing';
p.chain = false; p.chain = false;
p.target = false; // Stage Clear: cleared objectives count as done
const cell = { r, c, color: g.color, id: p.id }; const cell = { r, c, color: g.color, id: p.id };
cells.push(cell); cells.push(cell);
allCells.push(cell); allCells.push(cell);
@ -493,6 +539,10 @@ export function step(state, rng = Math.random) {
state.riseOffset -= 1; state.riseOffset -= 1;
emergeRow(state, rng); emergeRow(state, rng);
events.push({ type: 'rowShift' }); events.push({ type: 'rowShift' });
if (state._lineJustSpawned) {
state._lineJustSpawned = false;
events.push({ type: 'clearLineAppear', row: state.clearLine });
}
} }
state.dangerTicks = 0; state.dangerTicks = 0;
} }
@ -514,7 +564,12 @@ function finishStep(state, events) {
if (isBoardEmpty(board)) { state.won = true; events.push({ type: 'win' }); } if (isBoardEmpty(board)) { state.won = true; events.push({ type: 'win' }); }
else if (idleSettled && state.movesLeft <= 0) { state.over = true; events.push({ type: 'gameOver' }); } else if (idleSettled && state.movesLeft <= 0) { state.over = true; events.push({ type: 'gameOver' }); }
} else if (state.mode === 'stageclear') { } else if (state.mode === 'stageclear') {
if (countTargets(board) === 0) { state.won = true; events.push({ type: 'win' }); } // Win once every panel above the boundary line is gone. Clearing panels
// stay on the board until they pop, so this waits for the pop naturally.
if (state.clearLine != null && countAboveLine(state) === 0) {
state.won = true;
events.push({ type: 'win' });
}
} }
} }
return events; return events;
@ -525,8 +580,8 @@ export function isSettled(state) {
return state.clears.length === 0 && !anyFalling(state.board) && !state._justLanded; return state.clears.length === 0 && !anyFalling(state.board) && !state._justLanded;
} }
export function remainingTargets(state) { export function panelsAboveClearLine(state) {
return countTargets(state.board); return countAboveLine(state);
} }
// Find a cursor position whose swap immediately creates a match (greedy helper // Find a cursor position whose swap immediately creates a match (greedy helper

View File

@ -108,6 +108,83 @@ export function showPuzzleSelect(scene) {
textButton(scene, cx, GAME_HEIGHT - 100, 'BACK', () => scene.showMenu(), { width: 240 }); textButton(scene, cx, GAME_HEIGHT - 100, 'BACK', () => scene.showMenu(), { width: 240 });
} }
// ── Stage Clear round select ─────────────────────────────────────────────────
// The SNES "NEXT STAGE" overview: one card per character, each showing their
// five stage squares (filled once cleared) and the round's difficulty level.
// Characters unlock in order — you may restart any round you have reached, but
// always from its first stage.
export function showStageSelect(scene) {
scene.clearOverlay();
scene.teardownGameplay();
dim(scene, 0.92);
const cx = GAME_WIDTH / 2;
const rounds = scene.rounds;
const perRound = scene.stagesPerRound;
const progress = scene.loadStageProgress();
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', {
fontFamily: FONT, fontSize: '26px', color: '#7de6ff',
}).setOrigin(0.5).setDepth(OVL_UI));
const cardW = 700, cardH = 200, gapX = 60, gapY = 26;
const startX = cx - (cardW + gapX / 2) + cardW / 2;
const startY = 330;
rounds.forEach((round, i) => {
const x = startX + (i % 2) * (cardW + gapX);
const y = startY + Math.floor(i / 2) * (cardH + gapY);
const unlocked = scene.isRoundUnlocked(i);
const done = progress[round.characterId] ?? 0;
const bg = scene.add.rectangle(x, y, cardW, cardH, unlocked ? 0x1a2440 : 0x12161f, 1)
.setStrokeStyle(4, unlocked ? 0x9a5ee8 : 0x2e3644).setDepth(OVL_UI);
scene.overlayObjs.push(bg);
// portrait thumb on the left of the card
const px = x - cardW / 2 + 110;
const frame = scene.add.rectangle(px, y, 170, 170, 0x0e1526, 1)
.setStrokeStyle(3, unlocked ? 0x4a6ea8 : 0x2e3644).setDepth(OVL_UI);
scene.overlayObjs.push(frame);
const img = buildPortraitImage(scene, round, i, 'neutral', px, y, 0.45);
img.setDepth(OVL_UI + 1);
if (!unlocked) img.setTint(0x333a48);
scene.overlayObjs.push(img);
const tx = px + 300;
scene.overlayObjs.push(scene.add.text(tx, y - 66, `ROUND ${i + 1}`, {
fontFamily: FONT, fontSize: '38px', color: unlocked ? '#7de6ff' : '#4d5768',
}).setOrigin(0.5).setDepth(OVL_UI));
// the five stage squares, filled for stages already cleared
const sq = 34, sgap = 14;
let sx = tx - ((perRound * (sq + sgap) - sgap) / 2) + sq / 2;
for (let n = 1; n <= perRound; n++) {
const cleared = done >= n;
scene.overlayObjs.push(scene.add.rectangle(sx, y + 4, sq, sq, cleared ? 0xffe66e : 0x0e1526, 1)
.setStrokeStyle(3, unlocked ? 0x9fb0c8 : 0x39414f).setDepth(OVL_UI));
scene.overlayObjs.push(scene.add.text(sx, y - 30, String(n), {
fontFamily: FONT, fontSize: '20px', color: unlocked ? '#9fb0c8' : '#4d5768',
}).setOrigin(0.5).setDepth(OVL_UI));
sx += sq + sgap;
}
scene.overlayObjs.push(scene.add.text(tx, y + 62, unlocked ? `LEVEL - ${round.speedLevel}` : 'LOCKED', {
fontFamily: FONT, fontSize: '30px', color: unlocked ? '#e07be0' : '#4d5768',
}).setOrigin(0.5).setDepth(OVL_UI));
if (unlocked) {
bg.setInteractive({ useHandCursor: true });
bg.on('pointerover', () => bg.setStrokeStyle(4, 0xffe66e));
bg.on('pointerout', () => bg.setStrokeStyle(4, 0x9a5ee8));
bg.on('pointerdown', () => { playSound(scene, SFX.UI_ACTIVATE); scene.beginRound(i); });
}
});
textButton(scene, cx, GAME_HEIGHT - 60, 'BACK', () => scene.showMenu(), { width: 240, height: 58 });
}
// ── Stage Clear intro cutscene ──────────────────────────────────────────────── // ── Stage Clear intro cutscene ────────────────────────────────────────────────
export function showStageIntro(scene, round, index, onContinue) { export function showStageIntro(scene, round, index, onContinue) {
scene.clearOverlay(); scene.clearOverlay();
@ -116,9 +193,12 @@ export function showStageIntro(scene, round, index, onContinue) {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const total = (scene.config?.stageClear?.rounds ?? []).length; const total = (scene.config?.stageClear?.rounds ?? []).length;
scene.overlayObjs.push(scene.add.text(cx, 120, `STAGE ${index + 1} OF ${total}`, { scene.overlayObjs.push(scene.add.text(cx, 120, `ROUND ${index + 1} OF ${total}`, {
fontFamily: FONT, fontSize: '40px', color: '#7de6ff', fontFamily: FONT, fontSize: '40px', color: '#7de6ff',
}).setOrigin(0.5).setDepth(OVL_UI)); }).setOrigin(0.5).setDepth(OVL_UI));
scene.overlayObjs.push(scene.add.text(cx, 172, `${scene.stagesPerRound} STAGES`, {
fontFamily: FONT, fontSize: '28px', color: '#9fb0c8',
}).setOrigin(0.5).setDepth(OVL_UI));
// portrait slides in from the left // portrait slides in from the left
const portrait = buildPortraitImage(scene, round, index, 'neutral', 480, GAME_HEIGHT / 2 + 40, 2.0); const portrait = buildPortraitImage(scene, round, index, 'neutral', 480, GAME_HEIGHT / 2 + 40, 2.0);
@ -181,7 +261,7 @@ export function showResult(scene, { title, lines = [], buttons = [], round = nul
}).setOrigin(0.5).setDepth(OVL_UI)); }).setOrigin(0.5).setDepth(OVL_UI));
if (round) { if (round) {
const p = buildPortraitImage(scene, round, scene.stageIndex, scene._resultPose ?? 'neutral', cx, cy - 60, 0.85); const p = buildPortraitImage(scene, round, scene.roundIndex, scene._resultPose ?? 'neutral', cx, cy - 60, 0.85);
p.setDepth(OVL_UI); p.setDepth(OVL_UI);
scene.overlayObjs.push(p); scene.overlayObjs.push(p);
} }
@ -203,7 +283,7 @@ export function showResult(scene, { title, lines = [], buttons = [], round = nul
} }
// ── Host portrait (in-game HUD) ─────────────────────────────────────────────── // ── Host portrait (in-game HUD) ───────────────────────────────────────────────
export function createHostPortrait(scene, x, y, round, index = scene.stageIndex) { export function createHostPortrait(scene, x, y, round, index = scene.roundIndex) {
const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 1).setStrokeStyle(5, 0x4a6ea8).setDepth(20); const frameBg = scene.add.rectangle(x, y, 300, 380, 0x0e1526, 1).setStrokeStyle(5, 0x4a6ea8).setDepth(20);
const img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0); const img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0);
img.setDepth(21); img.setDepth(21);

View File

@ -5,8 +5,8 @@ import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { import {
COLS, ROWS, TUNING, mulberry32, newGame, step, trySwap, moveCursor, COLS, ROWS, TUNING, mulberry32, newGame, step, trySwap, moveCursor, setRaise, advanceStage,
findMatchGroups, findClearingSwap, isSettled, remainingTargets, findMatchGroups, findClearingSwap, isSettled, panelsAboveClearLine,
loadPuzzle, solvePuzzle, resolveFully, comboBonus, chainBonus, loadPuzzle, solvePuzzle, resolveFully, comboBonus, chainBonus,
} from '../src/games/tetrisattack/TetrisAttackLogic.js'; } from '../src/games/tetrisattack/TetrisAttackLogic.js';
@ -167,21 +167,111 @@ section('7. Top-out');
check('full board tops out', over && s.over); check('full board tops out', over && s.over);
} }
// ── 8. Stage Clear win condition ──────────────────────────────────────────── // ── 8. Stage Clear boundary line ────────────────────────────────────────────
section('8. Stage Clear'); section('8. Stage Clear');
{ {
// one target row of 3 that clears immediately → win // The line appears once `clearLineRows` rows have risen, lands between the
const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 0 } }); // row that just emerged and the stack above it, then rides that boundary up.
// hand-place a single clearing target group const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 3, clearLineRows: 2 } });
for (let c = 0; c < 3; c++) s.board[ROWS - 1][c] = { color: 'green', id: c + 1, state: 'idle', chain: false, target: true }; setRaise(s, true); // rush the rise so the line arrives within the tick budget
s.targetsTotal = 3; check('no line on a fresh stage', s.clearLine === null);
let spawns = 0;
let lineAtSpawn = null;
let rowsAtSpawn = null;
let shiftsAfterSpawn = 0;
let glued = true;
let wonBeforeLine = false;
for (let i = 0; i < 4000; i++) {
const before = s.clearLine;
const events = step(s, mulberry32(100 + i));
for (const e of events) {
if (e.type === 'clearLineAppear') { spawns++; lineAtSpawn = s.clearLine; rowsAtSpawn = s.rowsRaised; }
if (e.type === 'win' && spawns === 0) wonBeforeLine = true;
if (e.type === 'rowShift' && before !== null) {
shiftsAfterSpawn++;
if (s.clearLine !== before - 1) glued = false;
}
}
if (s.over || s.won) break;
}
check('the line appears exactly once', spawns === 1, `${spawns}`);
check('the line appears after clearLineRows rows', rowsAtSpawn === 2, `${rowsAtSpawn}`);
check('the line lands under the whole stack', lineAtSpawn === ROWS - 1, `${lineAtSpawn}`);
check('no stage win before the line exists', !wonBeforeLine);
check('the line rises with the stack', glued && shiftsAfterSpawn > 0, `${shiftsAfterSpawn} shifts`);
}
{
// Clearing everything above the line wins — panels below it are irrelevant.
const s = fromGrid(['ggg...', 'rybrgb', 'brygbr', 'ybgrby'], 'stageclear');
s.clearLine = ROWS - 3;
check('objective counts only panels above the line', panelsAboveClearLine(s) === 3, `${panelsAboveClearLine(s)}`);
let won = false; let won = false;
for (let i = 0; i < 200 && !won; i++) { for (let i = 0; i < 200 && !won; i++) {
const ev = step(s, mulberry32(50 + i)); if (step(s, mulberry32(50 + i)).some((e) => e.type === 'win')) won = true;
if (ev.some((e) => e.type === 'win')) won = true;
} }
check('clearing all targets wins the stage', won && s.won); check('clearing above the line wins the stage', won && s.won);
check('no targets remain on win', remainingTargets(s) === 0); check('nothing remains above the line on win', panelsAboveClearLine(s) === 0);
let below = 0;
for (let r = s.clearLine; r < ROWS; r++) for (const p of s.board[r]) if (p) below++;
check('panels below the line survive the win', below === 18, `${below}`);
}
{
// Same board with no line yet: the stage can never be won.
const s = fromGrid(['ggg...', 'rybrgb'], 'stageclear');
let won = false;
for (let i = 0; i < 300 && !won; i++) {
if (step(s, mulberry32(9 + i)).some((e) => e.type === 'win')) won = true;
}
check('an emptied board without a line does not win', !won && !s.won);
}
// ── 8b. Stage rounds (5 stages per character, same board) ───────────────────
section('8b. Stage rounds');
{
const stageOpts = { speedLevel: 2, startRows: 4, clearLineRows: 2, stageNumber: 1, speedStep: 0.2 };
const s = newGame({ mode: 'stageclear', rng: mulberry32(11), stage: stageOpts });
check('a round opens on stage 1', s.stage === 1 && s.speed === 2);
// stage 4 of the same round starts faster than stage 1 but keeps the round level
const later = newGame({ mode: 'stageclear', rng: mulberry32(11), stage: { ...stageOpts, stageNumber: 4 } });
check('a later stage starts faster', later.riseRate > s.riseRate && later.stage === 4);
check('the round level is unchanged by the stage', later.level === s.level, `${later.level}`);
// run until the line appears, then hand it a cleared objective
setRaise(s, true);
for (let i = 0; i < 4000 && s.clearLine === null; i++) step(s, mulberry32(200 + i));
check('stage 1 got its line', s.clearLine !== null);
for (let r = 0; r < s.clearLine; r++) for (let c = 0; c < COLS; c++) s.board[r][c] = null;
let won = false;
for (let i = 0; i < 60 && !won; i++) {
if (step(s, mulberry32(300 + i)).some((e) => e.type === 'win')) won = true;
}
check('emptying above the line ends stage 1', won && s.won);
const idsBefore = s.board.map((row) => row.map((p) => p?.id ?? 0).join(',')).join('|');
const rateBefore = s.riseRate;
const scoreBefore = s.score;
const rowsAtAdvance = s.rowsRaised;
advanceStage(s, { speedStep: 0.2 });
check('advancing resumes play on stage 2', !s.won && s.stage === 2);
check('advancing keeps the whole stack', s.board.map((row) => row.map((p) => p?.id ?? 0).join(',')).join('|') === idsBefore);
check('advancing keeps the score', s.score === scoreBefore);
check('advancing clears the old line', s.clearLine === null);
check('advancing speeds up the rise', s.riseRate > rateBefore && Math.abs(s.speed - 2.2) < 1e-9, `speed ${s.speed}`);
check('advancing re-arms the line', s.clearLineAt === rowsAtAdvance + 2, `${s.clearLineAt}`);
// and the next line really does show up, clearLineRows further on
let respawned = false;
let rowsAtRespawn = null;
for (let i = 0; i < 4000 && !respawned && !s.over; i++) {
if (step(s, mulberry32(500 + i)).some((e) => e.type === 'clearLineAppear')) {
respawned = true;
rowsAtRespawn = s.rowsRaised;
}
}
check('stage 2 gets a fresh line', respawned && s.clearLine === ROWS - 1);
check('the fresh line waits clearLineRows rows', rowsAtRespawn === rowsAtAdvance + 2, `${rowsAtRespawn} vs ${rowsAtAdvance}`);
} }
// ── 9. Puzzle bank ────────────────────────────────────────────────────────── // ── 9. Puzzle bank ──────────────────────────────────────────────────────────
@ -266,6 +356,54 @@ section('11. Self-play invariants');
console.log(` (ran ${games} games, ${totalTicks} ticks, ${gameOvers} top-outs, peak score ${maxScore})`); console.log(` (ran ${games} games, ${totalTicks} ticks, ${gameOvers} top-outs, peak score ${maxScore})`);
} }
// ── 12. Stage Clear soak (every shipped round must be beatable) ─────────────
section('12. Stage Clear soak');
{
const cfg = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'tetrisattack.json'), 'utf8'));
const rounds = cfg.stageClear?.rounds ?? [];
const perRound = cfg.stageClear?.stagesPerRound;
const speedStep = cfg.stageClear?.stageSpeedStep;
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)));
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;
check('the difficulty ramp stays monotonic',
rounds.every((r, i) => i === rounds.length - 1 || lastStageSpeed(r) <= rounds[i + 1].speedLevel),
`step ${speedStep}`);
let lineless = 0;
const won = [];
for (const round of rounds) {
let wins = 0;
for (let g = 0; g < 3; g++) {
const rng = mulberry32(7000 + g);
const s = newGame({
mode: 'stageclear',
stage: { speedLevel: round.speedLevel, startRows: round.startRows, clearLineRows: round.clearLineRows },
rng,
});
for (let t = 0; t < 20000 && !s.over && !s.won; t++) {
if (t % 5 === 0) {
const mv = findClearingSwap(s);
if (mv) { s.cursor = { row: mv.row, col: mv.col }; trySwap(s); }
}
step(s, rng);
}
if (s.won) wins++;
if (s.clearLine === null) lineless++;
}
won.push(`${round.name} ${wins}/3`);
}
// The line must always show up before the stack tops out — otherwise the
// round has no reachable objective at all. (Winning is a different bar: the
// greedy 1-ply player only beats the first two rounds, which was equally true
// of the old clear-the-whole-starting-stack rule.)
check('every soak game saw its CLEAR line appear', lineless === 0, `${lineless} without a line`);
check('the opening rounds are beatable by a greedy player', won.slice(0, 2).every((w) => !w.endsWith('0/3')), won.slice(0, 2).join(', '));
console.log(` (greedy wins — ${won.join(', ')})`);
}
// ── Summary ───────────────────────────────────────────────────────────────── // ── Summary ─────────────────────────────────────────────────────────────────
console.log(`\n${failures === 0 ? '✓ ALL PASSED' : '✗ FAILURES'}${checks - failures}/${checks} checks passed`); console.log(`\n${failures === 0 ? '✓ ALL PASSED' : '✗ FAILURES'}${checks - failures}/${checks} checks passed`);
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);