diff --git a/src/games/tetrisattack/TetrisAttackGame.js b/src/games/tetrisattack/TetrisAttackGame.js index e8e6a72..e2dc8c4 100644 --- a/src/games/tetrisattack/TetrisAttackGame.js +++ b/src/games/tetrisattack/TetrisAttackGame.js @@ -83,6 +83,8 @@ export default class TetrisAttackGame extends Phaser.Scene { this.puzzleIndex = 0; this.hostPortrait = null; this.heroBg = null; + this.endlessHeroOrder = []; // shuffled round refs for Endless mode + this.endlessHeroBgKeys = []; // pre-picked random bg keys per hero position this.onBackgroundsLoaded = null; // set by the intro cutscene while it waits on art this._voice = null; // the one character voice clip in the air this._voiceTimer = null; @@ -195,10 +197,20 @@ export default class TetrisAttackGame extends Phaser.Scene { rng, }); } else { + // endless — shuffle the hero roster so each playthrough feels different + this.endlessHeroOrder = [...this.rounds].sort(() => Math.random() - 0.5); this.state = newGame({ mode: 'endless', rng }); + // pick one random background per hero (stage 1–5), keyed by order index + this.endlessHeroBgKeys = this.endlessHeroOrder.map((round) => { + const stage = Math.floor(Math.random() * this.stagesPerRound) + 1; + return `tetrisattack-bg-${round.characterId}-r${stage}`; + }); + // kick off loading for the first hero (background + voice) + this.loadEndlessHeroAssets(0); } if (mode === 'stageclear') this.setStageBackground(round.characterId, stageNumber); + else if (mode === 'endless') this.setEndlessHeroBackground(0); else this.setStageBackground(null); this.buildHUD(); this.playing = true; @@ -220,6 +232,7 @@ export default class TetrisAttackGame extends Phaser.Scene { 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; } + if (this._heroNameLabel) { this._heroNameLabel.destroy(); this._heroNameLabel = null; } this.setStageBackground(null); this.state = null; } @@ -264,7 +277,9 @@ export default class TetrisAttackGame extends Phaser.Scene { for (let n = 1; n <= this.stagesPerRound; n++) { images.push([`tetrisattack-bg-${hero}-r${n}`, `assets/images/tetrisattack/background-${hero}-r${n}.png`]); } - images.push([`tetrisattack-bg-${hero}`, `assets/images/tetrisattack/background-${hero}.png`]); + // unsuffixed fallback (background-{hero}.png) is checked at display time + // in setStageBackground / introBackgroundKey — don't queue it here because + // the file rarely exists and would trigger a loaderror every round. const missingImages = images.filter(([key]) => !this.textures.exists(key)); for (const [key, path] of missingImages) this.load.image(key, path); @@ -282,6 +297,10 @@ export default class TetrisAttackGame extends Phaser.Scene { if (this.mode === 'stageclear' && this.currentRound) { this.setStageBackground(this.currentRound.characterId, this.stageNumber); } + if (this.mode === 'endless' && this.state) { + const idx = (this.state.level - 1) % this.endlessHeroOrder.length; + this.setEndlessHeroBackground(idx); + } // an intro cutscene waiting on this friend's art can dress itself now this.onBackgroundsLoaded?.(); }); @@ -289,6 +308,60 @@ export default class TetrisAttackGame extends Phaser.Scene { if (!this.load.isLoading()) this.load.start(); } + // ── Endless-mode hero rotation ────────────────────────────────────────────── + // Returns the round object for the hero currently on display (cycles every N levels). + // Also sets this._endlessHeroSpriteIndex to the original round index for sprite lookups. + getCurrentEndlessRound() { + if (!this.endlessHeroOrder.length) return null; + const idx = (this.state.level - 1) % this.endlessHeroOrder.length; + const round = this.endlessHeroOrder[idx]; + this._endlessHeroSpriteIndex = this.rounds.indexOf(round); + return round; + } + + // Lazily load a hero's backgrounds + voice clips (same logic as Stage Clear). + loadEndlessHeroAssets(heroOrderIndex) { + const round = this.endlessHeroOrder[heroOrderIndex]; + if (!round) return; + this.ensureRoundAssets(round); + } + + // Set the background for the hero at the given order index (endless mode). + setEndlessHeroBackground(heroOrderIndex) { + const key = this.endlessHeroBgKeys[heroOrderIndex]; + if (!key) return; + // the texture may still be loading — only display once it exists + if (!this.textures.exists(key)) return; + if (this.heroBg) { this.heroBg.destroy(); this.heroBg = null; } + this.heroBg = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, key).setDepth(D.bg + 2); + } + + // Called when the logic engine fires a levelUp event (endless mode). + onLevelUp(level) { + if (this.mode !== 'endless') return; + const heroOrderIndex = (level - 1) % this.endlessHeroOrder.length; + const round = this.endlessHeroOrder[heroOrderIndex]; + if (!round) return; + + // Load this hero's assets if not already loaded + this.loadEndlessHeroAssets(heroOrderIndex); + + // Swap the background + this.setEndlessHeroBackground(heroOrderIndex); + + // Swap the portrait (use original round index for correct spritesheet frame) + if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; } + this.hostPortrait = createHostPortrait(this, GAME_WIDTH - 260, 360, round, this.rounds.indexOf(round)); + + // Update the hero name label if it exists + if (this._heroNameLabel) { + this._heroNameLabel.setText(round.name.toUpperCase()); + } + + // Brief "happy" pose from the new hero welcoming you + this.hostPortrait.setPose('happy', 1500); + } + // ── Character voice ─────────────────────────────────────────────────────── // The friend reacts out loud to big clears and to how the round ends. Only // one line is ever in the air: a newer request replaces a pending one, and a @@ -351,6 +424,7 @@ export default class TetrisAttackGame extends Phaser.Scene { 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 === 'levelUp') this.onLevelUp(e.level); else if (e.type === 'gameOver') this.onGameOver(); else if (e.type === 'win') this.onWin(); } @@ -794,7 +868,7 @@ export default class TetrisAttackGame extends Phaser.Scene { this.hudTexts.moves = mk(410, 'SWAPS LEFT'); } - // Right side: host portrait (stage clear) + controls + // Right side: host portrait (stage clear + endless) + controls if (this.mode === 'stageclear') { this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 138, 'HELPING', { fontFamily: FONT, fontSize: '24px', color: '#9fb0c8', @@ -803,6 +877,18 @@ export default class TetrisAttackGame extends Phaser.Scene { this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 560, this.currentRound.name.toUpperCase(), { fontFamily: FONT, fontSize: '34px', color: '#ffe66e', }).setOrigin(0.5)); + } else if (this.mode === 'endless') { + const round = this.getCurrentEndlessRound(); + if (round) { + this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 138, 'WITH', { + fontFamily: FONT, fontSize: '24px', color: '#9fb0c8', + }).setOrigin(0.5)); + this.hostPortrait = createHostPortrait(this, GAME_WIDTH - 260, 360, round, this._endlessHeroSpriteIndex); + this._heroNameLabel = this.add.text(GAME_WIDTH - 260, 560, round.name.toUpperCase(), { + fontFamily: FONT, fontSize: '34px', color: '#ffe66e', + }).setOrigin(0.5); + this.hudLayer.add(this._heroNameLabel); + } } const controls = ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap']; diff --git a/src/games/tetrisattack/TetrisAttackLogic.js b/src/games/tetrisattack/TetrisAttackLogic.js index 9edab42..4b0dcd0 100644 --- a/src/games/tetrisattack/TetrisAttackLogic.js +++ b/src/games/tetrisattack/TetrisAttackLogic.js @@ -410,8 +410,10 @@ function emergeRow(state, rng) { state.level = lvl; state.speed = lvl; state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL; + return true; // signal level-up to caller } } + return false; } // ── The step ──────────────────────────────────────────────────────────────── @@ -537,8 +539,9 @@ export function step(state, rng = Math.random) { state.riseOffset += rate; if (state.riseOffset >= 1) { state.riseOffset -= 1; - emergeRow(state, rng); + const leveledUp = emergeRow(state, rng); events.push({ type: 'rowShift' }); + if (leveledUp) events.push({ type: 'levelUp', level: state.level }); if (state._lineJustSpawned) { state._lineJustSpawned = false; events.push({ type: 'clearLineAppear', row: state.clearLine });