diff --git a/assets/images/tetrisattack/background-beth.png b/assets/images/tetrisattack/background-beth-r1.png similarity index 100% rename from assets/images/tetrisattack/background-beth.png rename to assets/images/tetrisattack/background-beth-r1.png diff --git a/assets/images/tetrisattack/ta-stage.png b/assets/images/tetrisattack/ta-stage.png new file mode 100644 index 0000000..4190cfc Binary files /dev/null and b/assets/images/tetrisattack/ta-stage.png differ diff --git a/data/tetrisattack.json b/data/tetrisattack.json index 00a2bbb..4824fca 100644 --- a/data/tetrisattack.json +++ b/data/tetrisattack.json @@ -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." diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index c222726..5fa81ab 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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 `-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: [ diff --git a/src/games/tetrisattack/TetrisAttackGame.js b/src/games/tetrisattack/TetrisAttackGame.js index 0511964..bbdc1b8 100644 --- a/src/games/tetrisattack/TetrisAttackGame.js +++ b/src/games/tetrisattack/TetrisAttackGame.js @@ -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; diff --git a/src/games/tetrisattack/TetrisAttackLogic.js b/src/games/tetrisattack/TetrisAttackLogic.js index 59ccadd..9edab42 100644 --- a/src/games/tetrisattack/TetrisAttackLogic.js +++ b/src/games/tetrisattack/TetrisAttackLogic.js @@ -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 diff --git a/src/games/tetrisattack/TetrisAttackScreens.js b/src/games/tetrisattack/TetrisAttackScreens.js index b7c3a15..50a6b6d 100644 --- a/src/games/tetrisattack/TetrisAttackScreens.js +++ b/src/games/tetrisattack/TetrisAttackScreens.js @@ -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); diff --git a/tools/verifyTetrisAttack.js b/tools/verifyTetrisAttack.js index 80644ed..9654a7e 100644 --- a/tools/verifyTetrisAttack.js +++ b/tools/verifyTetrisAttack.js @@ -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);