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:
parent
391795c6a0
commit
7de6b71776
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue