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:
parent
18412e34f9
commit
08d23f9613
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 923 KiB |
|
|
@ -5,7 +5,9 @@
|
|||
},
|
||||
"stageClear": {
|
||||
"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": [
|
||||
{
|
||||
"characterId": "beth",
|
||||
|
|
@ -13,6 +15,7 @@
|
|||
"name": "Beth",
|
||||
"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."
|
||||
|
|
@ -23,6 +26,7 @@
|
|||
"name": "Jerry",
|
||||
"speedLevel": 2,
|
||||
"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!"
|
||||
|
|
@ -33,6 +37,7 @@
|
|||
"name": "Michael",
|
||||
"speedLevel": 3,
|
||||
"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!"
|
||||
|
|
@ -43,6 +48,7 @@
|
|||
"name": "Steve",
|
||||
"speedLevel": 4,
|
||||
"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."
|
||||
|
|
@ -53,6 +59,7 @@
|
|||
"name": "Victor",
|
||||
"speedLevel": 5,
|
||||
"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."
|
||||
|
|
@ -63,6 +70,7 @@
|
|||
"name": "Klaxon",
|
||||
"speedLevel": 6,
|
||||
"startRows": 8,
|
||||
"clearLineRows": 2,
|
||||
"intro": "MISSION OBJECTIVE: victory. All other priorities rescinded.",
|
||||
"winLine": "ERROR. DEFEAT NOT IN PARAMETERS.",
|
||||
"loseLine": "OBJECTIVE COMPLETE. RESISTANCE FUTILE."
|
||||
|
|
|
|||
|
|
@ -47,12 +47,6 @@ function imagesFrom(scene, jsonKey) {
|
|||
.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`
|
||||
// (see services/soundtrack.js). Audio bytes lazy-load only when the owning
|
||||
// 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-puzzles', path: 'data/tetrisattack-puzzles.json' },
|
||||
(scene) => sheetsFrom(scene, 'tetrisattack-artwork', ['panelSheet', 'characterSheet']),
|
||||
...TETRISATTACK_HEROES.map((hero) =>
|
||||
image(`tetrisattack-bg-${hero}`, `assets/images/tetrisattack/background-${hero}.png`)),
|
||||
// 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.
|
||||
(scene) => musicFrom(scene, 'nintendo-music'),
|
||||
],
|
||||
coloradodefense: [
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import { getGameSoundtrack } from '../../services/soundtrack.js';
|
|||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||||
import {
|
||||
COLS, ROWS, PANEL_COLORS, TUNING,
|
||||
newGame, step, moveCursor, trySwap, setRaise,
|
||||
newGame, step, moveCursor, trySwap, setRaise, advanceStage,
|
||||
} from './TetrisAttackLogic.js';
|
||||
import {
|
||||
showMenu, showPuzzleSelect, showStageIntro, showResult, createHostPortrait, FONT,
|
||||
showMenu, showPuzzleSelect, showStageSelect, showStageIntro, showResult,
|
||||
createHostPortrait, FONT,
|
||||
} from './TetrisAttackScreens.js';
|
||||
|
||||
// ── Layout ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -33,6 +34,9 @@ const PANEL_PAL = {
|
|||
};
|
||||
|
||||
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 {
|
||||
constructor() { super('TetrisAttackGame'); }
|
||||
|
|
@ -46,7 +50,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
this.acc = 0;
|
||||
this.sprites = new Map(); // panel.id -> Phaser image
|
||||
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.hostPortrait = null;
|
||||
this.heroBg = null;
|
||||
|
|
@ -87,22 +92,49 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
// ── Menus / flow (delegates to Screens) ───────────────────────────────────
|
||||
showMenu() { this.clearOverlay(); this.teardownGameplay(); showMenu(this); }
|
||||
startEndless() { this.beginGame({ mode: 'endless' }); }
|
||||
startStageClear() { this.stageIndex = 0; this.showStageIntro(); }
|
||||
startStageClear() { this.clearOverlay(); this.teardownGameplay(); showStageSelect(this); }
|
||||
startPuzzleMode() { showPuzzleSelect(this); }
|
||||
|
||||
showStageIntro() {
|
||||
const rounds = this.config?.stageClear?.rounds ?? [];
|
||||
const round = rounds[this.stageIndex];
|
||||
get rounds() { return this.config?.stageClear?.rounds ?? []; }
|
||||
get stagesPerRound() { return this.config?.stageClear?.stagesPerRound ?? 5; }
|
||||
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; }
|
||||
showStageIntro(this, round, this.stageIndex, () => {
|
||||
this.beginGame({ mode: 'stageclear', round });
|
||||
this.roundIndex = index;
|
||||
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 }); }
|
||||
|
||||
// ── Gameplay setup ────────────────────────────────────────────────────────
|
||||
beginGame({ mode, round, puzzleIndex }) {
|
||||
beginGame({ mode, round, puzzleIndex, stageNumber = 1 }) {
|
||||
this.clearOverlay();
|
||||
this.teardownGameplay();
|
||||
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 });
|
||||
} else if (mode === 'stageclear') {
|
||||
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 {
|
||||
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.playing = true;
|
||||
this.acc = 0;
|
||||
|
|
@ -134,25 +178,56 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
for (const s of this.incomingSprites) s.destroy();
|
||||
this.incomingSprites = [];
|
||||
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.boardWell) { this.boardWell.destroy(); this.boardWell = null; }
|
||||
if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; }
|
||||
if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; }
|
||||
this.setHeroBackground(null);
|
||||
this.setStageBackground(null);
|
||||
this.state = null;
|
||||
}
|
||||
|
||||
// Stage Clear background art, drop-in per host (assets/images/tetrisattack/
|
||||
// background-{hero}.png, see data/assetManifest.js). Falls back to the
|
||||
// procedural gradient backdrop when a host has no art yet.
|
||||
setHeroBackground(heroName) {
|
||||
// Stage Clear background art, drop-in per host and stage (assets/images/
|
||||
// tetrisattack/background-{hero}-r{stage}.png). Falls back to the legacy
|
||||
// unsuffixed background-{hero}.png, then to the procedural gradient backdrop.
|
||||
setStageBackground(hero, stageNumber = 1) {
|
||||
if (this.heroBg) { this.heroBg.destroy(); this.heroBg = null; }
|
||||
if (!heroName) return;
|
||||
const key = `tetrisattack-bg-${heroName.toLowerCase()}`;
|
||||
if (!this.textures.exists(key)) return;
|
||||
if (!hero) return;
|
||||
const key = [`tetrisattack-bg-${hero}-r${stageNumber}`, `tetrisattack-bg-${hero}`]
|
||||
.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);
|
||||
}
|
||||
|
||||
// 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 ─────────────────────────────────────────────────────────────
|
||||
update(time, delta) {
|
||||
if (!this.playing || !this.state) return;
|
||||
|
|
@ -172,6 +247,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
for (const e of events) {
|
||||
if (e.type === 'clear') this.onClear(e);
|
||||
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 === 'gameOver') this.onGameOver();
|
||||
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) {
|
||||
if (this.boardFrame) this.boardFrame.setStrokeStyle(6, on ? 0xff4d5e : 0x40506a);
|
||||
if (on) {
|
||||
|
|
@ -262,7 +353,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
lines: [`${round.name}: "${round.loseLine}"`],
|
||||
round,
|
||||
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() },
|
||||
],
|
||||
});
|
||||
|
|
@ -284,16 +376,19 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
playSound(this, SFX.VICTORY_SHORT);
|
||||
if (this.mode === 'stageclear') {
|
||||
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';
|
||||
// record the win so the puzzle/stage progress could be surfaced later
|
||||
const last = this.stageIndex >= (this.config.stageClear.rounds.length - 1);
|
||||
const last = this.roundIndex >= (this.rounds.length - 1);
|
||||
showResult(this, {
|
||||
title: `${round.name.toUpperCase()} DEFEATED`,
|
||||
lines: [`${round.name}: "${round.winLine}"`, `Score ${this.state.score}`],
|
||||
round,
|
||||
buttons: last
|
||||
? [{ 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() }],
|
||||
});
|
||||
} 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() {
|
||||
this.teardownGameplay();
|
||||
showResult(this, {
|
||||
|
|
@ -382,9 +501,41 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
const pos = this.cellCenter(ROWS, c);
|
||||
spr.x = pos.x; spr.y = pos.y;
|
||||
}
|
||||
this.drawClearLine();
|
||||
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() {
|
||||
if (!this.cursorGfx) this.cursorGfx = this.add.graphics().setDepth(D.cursor);
|
||||
const g = this.cursorGfx;
|
||||
|
|
@ -448,7 +599,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
this.hudTexts.best = mk(410, 'BEST');
|
||||
this.hudTexts.level = mk(520, 'SPEED');
|
||||
} 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');
|
||||
} else {
|
||||
this.hudTexts.moves = mk(410, 'SWAPS LEFT');
|
||||
|
|
@ -462,10 +614,11 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5));
|
||||
}
|
||||
|
||||
const controls = this.mode === 'puzzle'
|
||||
? ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap', '', 'Clear every panel!']
|
||||
: ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap', 'SHIFT raise stack'];
|
||||
this.hudLayer.add(this.add.text(GAME_WIDTH - 420, 700, controls.join('\n'), {
|
||||
const controls = ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap'];
|
||||
if (this.mode === 'puzzle') controls.push('', 'Clear every panel!');
|
||||
else controls.push('SHIFT raise stack');
|
||||
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,
|
||||
}));
|
||||
|
||||
|
|
@ -490,7 +643,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
|
||||
restartCurrent() {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
@ -499,16 +652,10 @@ export default class TetrisAttackGame extends Phaser.Scene {
|
|||
this.hudTexts.score?.setText(String(this.state.score));
|
||||
this.hudTexts.best?.setText(String(this.best));
|
||||
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)));
|
||||
}
|
||||
|
||||
countTargets() {
|
||||
let n = 0;
|
||||
for (const row of this.state.board) for (const p of row) if (p && p.target) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────
|
||||
registerInput() {
|
||||
const kb = this.input.keyboard;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ export const TUNING = {
|
|||
BASE_RISE: 0.0016,
|
||||
RISE_PER_LEVEL: 0.0009,
|
||||
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
|
||||
BASE_PANEL: 10,
|
||||
};
|
||||
|
|
@ -75,9 +77,15 @@ function isBoardEmpty(board) {
|
|||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -142,35 +150,47 @@ export function newGame(opts = {}) {
|
|||
nextId: 1,
|
||||
// mode-specific
|
||||
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') {
|
||||
loadPuzzle(state, opts.puzzle);
|
||||
state.riseRate = 0;
|
||||
} 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 speedStep = opts.stage?.speedStep ?? TUNING.STAGE_SPEED_STEP;
|
||||
state.level = speedLevel;
|
||||
state.riseRate = TUNING.BASE_RISE + (speedLevel - 1) * TUNING.RISE_PER_LEVEL;
|
||||
fillInitial(state, rng, opts.stage?.startRows ?? 7, true);
|
||||
state.targetsTotal = countTargets(state.board);
|
||||
state.stage = opts.stage?.stageNumber ?? 1;
|
||||
state.speed = speedLevel + (state.stage - 1) * speedStep;
|
||||
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 {
|
||||
// endless
|
||||
state.level = opts.level ?? 1;
|
||||
state.speed = state.level;
|
||||
state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL;
|
||||
fillInitial(state, rng, TUNING.START_ROWS, false);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Fill the bottom `nRows` rows with panels (no starting matches). When
|
||||
// `asTargets` the placed panels are flagged as Stage-Clear objectives.
|
||||
function fillInitial(state, rng, nRows, asTargets) {
|
||||
// Fill the bottom `nRows` rows with panels (no starting matches).
|
||||
function fillInitial(state, rng, nRows) {
|
||||
const { board } = state;
|
||||
for (let r = ROWS - nRows; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
const color = safeColor(board, r, c, rng);
|
||||
board[r][c] = newPanel(state, color, asTargets ? { target: true } : {});
|
||||
board[r][c] = newPanel(state, safeColor(board, r, c, rng));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -195,6 +215,22 @@ export function loadPuzzle(state, puzzle) {
|
|||
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 ───────────────────────────────────────────────────────────
|
||||
export function moveCursor(state, dir) {
|
||||
const { cursor } = state;
|
||||
|
|
@ -358,10 +394,21 @@ function emergeRow(state, rng) {
|
|||
state.incoming = generateRowColors(rng);
|
||||
state.cursor.row = Math.max(0, state.cursor.row - 1);
|
||||
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') {
|
||||
const lvl = 1 + Math.floor(state.rowsRaised / TUNING.ROWS_PER_LEVEL);
|
||||
if (lvl !== state.level) {
|
||||
state.level = lvl;
|
||||
state.speed = lvl;
|
||||
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];
|
||||
p.state = 'clearing';
|
||||
p.chain = false;
|
||||
p.target = false; // Stage Clear: cleared objectives count as done
|
||||
const cell = { r, c, color: g.color, id: p.id };
|
||||
cells.push(cell);
|
||||
allCells.push(cell);
|
||||
|
|
@ -493,6 +539,10 @@ export function step(state, rng = Math.random) {
|
|||
state.riseOffset -= 1;
|
||||
emergeRow(state, rng);
|
||||
events.push({ type: 'rowShift' });
|
||||
if (state._lineJustSpawned) {
|
||||
state._lineJustSpawned = false;
|
||||
events.push({ type: 'clearLineAppear', row: state.clearLine });
|
||||
}
|
||||
}
|
||||
state.dangerTicks = 0;
|
||||
}
|
||||
|
|
@ -514,7 +564,12 @@ function finishStep(state, events) {
|
|||
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 (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;
|
||||
|
|
@ -525,8 +580,8 @@ export function isSettled(state) {
|
|||
return state.clears.length === 0 && !anyFalling(state.board) && !state._justLanded;
|
||||
}
|
||||
|
||||
export function remainingTargets(state) {
|
||||
return countTargets(state.board);
|
||||
export function panelsAboveClearLine(state) {
|
||||
return countAboveLine(state);
|
||||
}
|
||||
|
||||
// Find a cursor position whose swap immediately creates a match (greedy helper
|
||||
|
|
|
|||
|
|
@ -108,6 +108,83 @@ export function showPuzzleSelect(scene) {
|
|||
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 ────────────────────────────────────────────────
|
||||
export function showStageIntro(scene, round, index, onContinue) {
|
||||
scene.clearOverlay();
|
||||
|
|
@ -116,9 +193,12 @@ export function showStageIntro(scene, round, index, onContinue) {
|
|||
const cx = GAME_WIDTH / 2;
|
||||
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',
|
||||
}).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
|
||||
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));
|
||||
|
||||
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);
|
||||
scene.overlayObjs.push(p);
|
||||
}
|
||||
|
|
@ -203,7 +283,7 @@ export function showResult(scene, { title, lines = [], buttons = [], round = nul
|
|||
}
|
||||
|
||||
// ── 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 img = buildPortraitImage(scene, round, index, 'neutral', x, y, 1.0);
|
||||
img.setDepth(21);
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { readFileSync } from 'node:fs';
|
|||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
COLS, ROWS, TUNING, mulberry32, newGame, step, trySwap, moveCursor,
|
||||
findMatchGroups, findClearingSwap, isSettled, remainingTargets,
|
||||
COLS, ROWS, TUNING, mulberry32, newGame, step, trySwap, moveCursor, setRaise, advanceStage,
|
||||
findMatchGroups, findClearingSwap, isSettled, panelsAboveClearLine,
|
||||
loadPuzzle, solvePuzzle, resolveFully, comboBonus, chainBonus,
|
||||
} from '../src/games/tetrisattack/TetrisAttackLogic.js';
|
||||
|
||||
|
|
@ -167,21 +167,111 @@ section('7. Top-out');
|
|||
check('full board tops out', over && s.over);
|
||||
}
|
||||
|
||||
// ── 8. Stage Clear win condition ────────────────────────────────────────────
|
||||
// ── 8. Stage Clear boundary line ────────────────────────────────────────────
|
||||
section('8. Stage Clear');
|
||||
{
|
||||
// one target row of 3 that clears immediately → win
|
||||
const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 0 } });
|
||||
// hand-place a single clearing target group
|
||||
for (let c = 0; c < 3; c++) s.board[ROWS - 1][c] = { color: 'green', id: c + 1, state: 'idle', chain: false, target: true };
|
||||
s.targetsTotal = 3;
|
||||
// The line appears once `clearLineRows` rows have risen, lands between the
|
||||
// row that just emerged and the stack above it, then rides that boundary up.
|
||||
const s = newGame({ mode: 'stageclear', rng: mulberry32(5), stage: { speedLevel: 1, startRows: 3, clearLineRows: 2 } });
|
||||
setRaise(s, true); // rush the rise so the line arrives within the tick budget
|
||||
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;
|
||||
for (let i = 0; i < 200 && !won; i++) {
|
||||
const ev = step(s, mulberry32(50 + i));
|
||||
if (ev.some((e) => e.type === 'win')) won = true;
|
||||
if (step(s, mulberry32(50 + i)).some((e) => e.type === 'win')) won = true;
|
||||
}
|
||||
check('clearing all targets wins the stage', won && s.won);
|
||||
check('no targets remain on win', remainingTargets(s) === 0);
|
||||
check('clearing above the line wins the stage', won && s.won);
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
|
|
@ -266,6 +356,54 @@ section('11. Self-play invariants');
|
|||
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 ─────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${failures === 0 ? '✓ ALL PASSED' : '✗ FAILURES'} — ${checks - failures}/${checks} checks passed`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
|
|
|
|||
Loading…
Reference in New Issue