feat(tetrisattack): add Endless mode hero rotation

- Shuffle hero roster at game start for varied playthroughs
- Cycle through heroes as player levels up (backgrounds, portraits, names)
- Emit levelUp event from logic engine when row emergence triggers level up
- Lazy-load hero assets (backgrounds + voice) on first display
- Update HUD with "WITH" text and hero portrait in Endless mode
- Skip unsuffixed fallback backgrounds to avoid unnecessary load errors
This commit is contained in:
Brian Fertig 2026-07-27 18:50:06 -06:00
parent 391795c6a0
commit 7de6b71776
2 changed files with 92 additions and 3 deletions

View File

@ -83,6 +83,8 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.puzzleIndex = 0; this.puzzleIndex = 0;
this.hostPortrait = null; this.hostPortrait = null;
this.heroBg = 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.onBackgroundsLoaded = null; // set by the intro cutscene while it waits on art
this._voice = null; // the one character voice clip in the air this._voice = null; // the one character voice clip in the air
this._voiceTimer = null; this._voiceTimer = null;
@ -195,10 +197,20 @@ export default class TetrisAttackGame extends Phaser.Scene {
rng, rng,
}); });
} else { } 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 }); this.state = newGame({ mode: 'endless', rng });
// pick one random background per hero (stage 15), 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); if (mode === 'stageclear') this.setStageBackground(round.characterId, stageNumber);
else if (mode === 'endless') this.setEndlessHeroBackground(0);
else this.setStageBackground(null); else this.setStageBackground(null);
this.buildHUD(); this.buildHUD();
this.playing = true; 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.boardWell) { this.boardWell.destroy(); this.boardWell = null; }
if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; } if (this.boardFrame) { this.boardFrame.destroy(); this.boardFrame = null; }
if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; } if (this.hostPortrait) { this.hostPortrait.destroy(); this.hostPortrait = null; }
if (this._heroNameLabel) { this._heroNameLabel.destroy(); this._heroNameLabel = null; }
this.setStageBackground(null); this.setStageBackground(null);
this.state = null; this.state = null;
} }
@ -264,7 +277,9 @@ export default class TetrisAttackGame extends Phaser.Scene {
for (let n = 1; n <= this.stagesPerRound; n++) { 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}-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)); const missingImages = images.filter(([key]) => !this.textures.exists(key));
for (const [key, path] of missingImages) this.load.image(key, path); 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) { if (this.mode === 'stageclear' && this.currentRound) {
this.setStageBackground(this.currentRound.characterId, this.stageNumber); 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 // an intro cutscene waiting on this friend's art can dress itself now
this.onBackgroundsLoaded?.(); this.onBackgroundsLoaded?.();
}); });
@ -289,6 +308,60 @@ export default class TetrisAttackGame extends Phaser.Scene {
if (!this.load.isLoading()) this.load.start(); 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 ─────────────────────────────────────────────────────── // ── Character voice ───────────────────────────────────────────────────────
// The friend reacts out loud to big clears and to how the round ends. Only // 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 // 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 === 'rowShift') playSound(this, SFX.EIGHTBIT_MOVE);
else if (e.type === 'clearLineAppear') this.onClearLineAppear(); else if (e.type === 'clearLineAppear') this.onClearLineAppear();
else if (e.type === 'danger') this.onDanger(e.on); else if (e.type === 'danger') this.onDanger(e.on);
else if (e.type === 'levelUp') this.onLevelUp(e.level);
else if (e.type === 'gameOver') this.onGameOver(); else if (e.type === 'gameOver') this.onGameOver();
else if (e.type === 'win') this.onWin(); else if (e.type === 'win') this.onWin();
} }
@ -794,7 +868,7 @@ export default class TetrisAttackGame extends Phaser.Scene {
this.hudTexts.moves = mk(410, 'SWAPS LEFT'); 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') { if (this.mode === 'stageclear') {
this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 138, 'HELPING', { this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 138, 'HELPING', {
fontFamily: FONT, fontSize: '24px', color: '#9fb0c8', 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(), { this.hudLayer.add(this.add.text(GAME_WIDTH - 260, 560, this.currentRound.name.toUpperCase(), {
fontFamily: FONT, fontSize: '34px', color: '#ffe66e', fontFamily: FONT, fontSize: '34px', color: '#ffe66e',
}).setOrigin(0.5)); }).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']; const controls = ['◀ ▲ ▼ ▶ move', 'SPACE / Z swap'];

View File

@ -410,8 +410,10 @@ function emergeRow(state, rng) {
state.level = lvl; state.level = lvl;
state.speed = lvl; state.speed = lvl;
state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL; state.riseRate = TUNING.BASE_RISE + (state.level - 1) * TUNING.RISE_PER_LEVEL;
return true; // signal level-up to caller
} }
} }
return false;
} }
// ── The step ──────────────────────────────────────────────────────────────── // ── The step ────────────────────────────────────────────────────────────────
@ -537,8 +539,9 @@ export function step(state, rng = Math.random) {
state.riseOffset += rate; state.riseOffset += rate;
if (state.riseOffset >= 1) { if (state.riseOffset >= 1) {
state.riseOffset -= 1; state.riseOffset -= 1;
emergeRow(state, rng); const leveledUp = emergeRow(state, rng);
events.push({ type: 'rowShift' }); events.push({ type: 'rowShift' });
if (leveledUp) events.push({ type: 'levelUp', level: state.level });
if (state._lineJustSpawned) { if (state._lineJustSpawned) {
state._lineJustSpawned = false; state._lineJustSpawned = false;
events.push({ type: 'clearLineAppear', row: state.clearLine }); events.push({ type: 'clearLineAppear', row: state.clearLine });