feat: add level progression and background music to all games

- Introduce `src/audio.js` module for centralized music playback and cleanup
- Add background music tracks for all four games and the main menu
- Refactor game config files to support multiple difficulty levels via `levels` object
- Update game scenes to load level-specific configurations and track progression
- Add win conditions based on level-specific objectives (e.g., bugs to pass, waves cleared)
- Update HUDs to display current progress toward level completion
This commit is contained in:
Brian Fertig 2026-05-09 13:49:47 -06:00
parent 2aabbb158a
commit 47f16c78be
19 changed files with 183 additions and 71 deletions

BIN
assets/fx/fire.mp3 Normal file

Binary file not shown.

BIN
assets/fx/fireball.mp3 Normal file

Binary file not shown.

BIN
assets/fx/laser.mp3 Normal file

Binary file not shown.

BIN
assets/fx/powerup.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

22
src/audio.js Normal file
View File

@ -0,0 +1,22 @@
const REGISTRY_KEY = 'bgMusic';
export function playMusic(scene, key) {
const prev = scene.game.registry.get(REGISTRY_KEY);
if (prev) {
prev.stop();
prev.destroy();
}
const music = scene.sound.add(key, { loop: true, volume: 0.7 });
music.play();
scene.game.registry.set(REGISTRY_KEY, music);
return music;
}
export function stopMusic(scene) {
const music = scene.game.registry.get(REGISTRY_KEY);
if (music) {
music.stop();
music.destroy();
scene.game.registry.set(REGISTRY_KEY, null);
}
}

View File

@ -1,26 +1,35 @@
import { playMusic, stopMusic } from '../../audio.js';
export default class CodeBugInvaders extends Phaser.Scene {
constructor() { super({ key: 'CodeBugInvaders' }); }
preload() {
this.load.json('cbiConfig', 'src/games/code-bug-invaders/config.json');
this.load.json('cbiConfig', 'src/games/code-bug-invaders/config.json?v=2');
this.load.audio('cbiMusic', 'assets/music/02-code-bug-invaders.mp3');
}
init(data) {
this.globalLives = data.lives;
this.level = data.level || 1;
this.gameActive = false;
}
create() {
this.cfg = this.cache.json.get('cbiConfig');
const all = this.cache.json.get('cbiConfig');
const lvl = String(this.level);
this.cfg = all.levels ? (all.levels[lvl] ?? all.levels['1']) : all;
this.bugs = [];
this.playerBullets = [];
this.bugBullets = [];
this.bugsHit = 0;
this.gameActive = true;
this.formationDir = 1; // 1 = right, -1 = left
this.formationX = 0;
this.formationY = 0;
this.bugFireTimer = this.cfg.bugFireInterval;
playMusic(this, 'cbiMusic');
this._buildBackground();
this._buildBugs();
this._buildPlayer();
@ -30,7 +39,7 @@ export default class CodeBugInvaders extends Phaser.Scene {
}
shutdown() {
// input listeners auto-cleaned by Phaser scene shutdown
stopMusic(this);
}
_buildBackground() {
@ -174,11 +183,18 @@ export default class CodeBugInvaders extends Phaser.Scene {
if (!bug.alive) continue;
if (Math.abs(bullet.x - bug.x) < 28 && Math.abs(bullet.y - bug.y) < 22) {
bug.alive = false;
this.bugsHit++;
hit = true;
break;
}
}
if (hit) this.playerBullets.splice(bi, 1);
if (hit) {
this.playerBullets.splice(bi, 1);
if (this.bugsHit >= this.cfg.bugsToPass) {
this._endGame(true);
return;
}
}
}
// Bug bullets vs player
@ -282,7 +298,6 @@ export default class CodeBugInvaders extends Phaser.Scene {
g.lineBetween(0, this.cfg.dangerY, 1600, this.cfg.dangerY);
// HUD update
const aliveCount = this.bugs.filter(b => b.alive).length;
this.bugsText.setText(`BUGS: ${aliveCount}`);
this.bugsText.setText(`BUGS ${this.bugsHit}/${this.cfg.bugsToPass}`);
}
}

View File

@ -1,17 +1,24 @@
{
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 60,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 1800,
"bugBulletSpeed": 280,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720
"levels": {
"1": {
"cols": 10, "rows": 4,
"bugSpacingX": 120, "bugSpacingY": 70,
"startX": 180, "startY": 100,
"formationSpeed": 60, "formationStepDown": 30,
"rightBound": 1540, "leftBound": 60,
"bugFireInterval": 1800, "bugBulletSpeed": 280,
"playerSpeed": 420, "playerBulletSpeed": 700,
"dangerY": 720, "bugsToPass": 15
},
"2": {
"cols": 10, "rows": 4,
"bugSpacingX": 120, "bugSpacingY": 70,
"startX": 180, "startY": 100,
"formationSpeed": 85, "formationStepDown": 30,
"rightBound": 1540, "leftBound": 60,
"bugFireInterval": 1300, "bugBulletSpeed": 350,
"playerSpeed": 420, "playerBulletSpeed": 700,
"dangerY": 720, "bugsToPass": 28
}
}
}

View File

@ -1,22 +1,29 @@
import { playMusic, stopMusic } from '../../audio.js';
export default class ColoradoDefense extends Phaser.Scene {
constructor() { super({ key: 'ColoradoDefense' }); }
preload() {
this.load.json('cdConfig', 'src/games/colorado-defense/config.json');
this.load.json('cdConfig', 'src/games/colorado-defense/config.json?v=2');
this.load.audio('cdMusic', 'assets/music/01-colorado-defense.mp3');
}
init(data) {
this.globalLives = data.lives;
this.level = data.level || 1;
this.gameActive = false;
}
create() {
this.cfg = this.cache.json.get('cdConfig');
const all = this.cache.json.get('cdConfig');
const lvl = String(this.level);
this.cfg = all.levels ? (all.levels[lvl] ?? all.levels['1']) : all;
this.cities = [];
this.missiles = [];
this.interceptors = [];
this.explosions = [];
this.wave = 0;
this.wavesCleared = 0;
this.spawnTimer = 0;
this.waveSpawned = 0;
this.waveCleared = false;
@ -24,6 +31,8 @@ export default class ColoradoDefense extends Phaser.Scene {
this.mouseX = 800;
this.mouseY = 400;
playMusic(this, 'cdMusic');
this._buildBackground();
this._buildCities();
this.mainGfx = this.add.graphics();
@ -33,6 +42,7 @@ export default class ColoradoDefense extends Phaser.Scene {
}
shutdown() {
stopMusic(this);
this.input.off('pointerdown');
this.input.off('pointermove');
}
@ -70,8 +80,8 @@ export default class ColoradoDefense extends Phaser.Scene {
this.livesText = this.add.text(800, 40, '♥ '.repeat(this.globalLives).trim(), {
fontSize: '24px', fontFamily: 'monospace', color: '#ff4444',
}).setOrigin(0.5, 0);
this.statusText = this.add.text(1580, 40, 'CITIES: 6/6', {
fontSize: '24px', fontFamily: 'monospace', color: '#4488ff',
this.statusText = this.add.text(1580, 40, `CITIES: 6/6\nWAVES: 0/${this.cfg.wavesToPass}`, {
fontSize: '22px', fontFamily: 'monospace', color: '#4488ff',
}).setOrigin(1, 0);
}
@ -182,14 +192,19 @@ export default class ColoradoDefense extends Phaser.Scene {
this.missiles.length === 0 &&
this.interceptors.length === 0) {
this.waveCleared = true;
this.wavesCleared++;
if (this.wavesCleared >= this.cfg.wavesToPass) {
this.time.delayedCall(1800, () => this._endGame(true));
} else {
this.time.delayedCall(1800, () => this._nextWave());
}
}
// Check city deaths
const dead = this.cities.filter(c => !c.alive).length;
if (dead >= this.cfg.maxCityDeaths) this._endGame(false);
this.statusText.setText(`CITIES: ${6 - dead}/6`);
this.statusText.setText(`CITIES: ${6 - dead}/6\nWAVES: ${this.wavesCleared}/${this.cfg.wavesToPass}`);
this._draw();
}

View File

@ -1,10 +1,22 @@
{
"levels": {
"1": {
"waves": [
{ "count": 6, "speed": 55, "spawnInterval": 2800 },
{ "count": 9, "speed": 80, "spawnInterval": 2000 },
{ "count": 14, "speed": 110, "spawnInterval": 1400 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 3
"interceptorSpeed": 420, "explosionRadius": 75,
"maxCityDeaths": 3, "wavesToPass": 2
},
"2": {
"waves": [
{ "count": 8, "speed": 70, "spawnInterval": 2200 },
{ "count": 12, "speed": 100, "spawnInterval": 1600 },
{ "count": 16, "speed": 130, "spawnInterval": 1100 }
],
"interceptorSpeed": 420, "explosionRadius": 75,
"maxCityDeaths": 2, "wavesToPass": 3
}
}
}

View File

@ -24,25 +24,37 @@ const LAYOUT = [
[1,1,1,1,1,1,1,1,1,1], // 14 bottom wall
];
import { playMusic, stopMusic } from '../../audio.js';
export default class DotDude extends Phaser.Scene {
constructor() { super({ key: 'DotDude' }); }
preload() {
this.load.json('ddConfig', 'src/games/dot-dude/config.json');
this.load.json('ddConfig', 'src/games/dot-dude/config.json?v=2');
this.load.audio('ddMusic', 'assets/music/03-dot-dude.mp3');
}
shutdown() {
stopMusic(this);
}
init(data) {
this.globalLives = data.lives;
this.level = data.level || 1;
this.gameActive = false;
}
create() {
this.cfg = this.cache.json.get('ddConfig');
const all = this.cache.json.get('ddConfig');
const lvl = String(this.level);
this.cfg = all.levels ? (all.levels[lvl] ?? all.levels['1']) : all;
this.dots = LAYOUT.map(row => row.map(cell => (cell === 0 ? 1 : 0)));
this.totalDots = this.dots.flat().filter(d => d === 1).length;
this.dotsEaten = 0;
playMusic(this, 'ddMusic');
this._initPlayer();
this._initGhosts();
@ -133,12 +145,12 @@ export default class DotDude extends Phaser.Scene {
}).setOrigin(0.5).setDepth(10);
// Right panel
this.add.text(1350, 80, 'DOTS', {
this.add.text(1350, 80, 'EAT', {
fontSize: '26px', fontFamily: 'monospace', color: '#aaaaaa',
}).setOrigin(0.5).setDepth(10);
this.dotsText = this.add.text(1350, 140, `${this.totalDots}`, {
fontSize: '48px', fontFamily: 'monospace', color: '#ffff00',
this.dotsText = this.add.text(1350, 140, `0 / ${this.cfg.pelletsToPass}`, {
fontSize: '36px', fontFamily: 'monospace', color: '#ffff00',
}).setOrigin(0.5).setDepth(10);
}
@ -166,7 +178,7 @@ export default class DotDude extends Phaser.Scene {
if (col >= 0 && col < COLS && row >= 0 && row < ROWS && this.dots[row][col] === 1) {
this.dots[row][col] = 0;
this.dotsEaten++;
this.dotsText.setText(`${this.totalDots - this.dotsEaten}`);
this.dotsText.setText(`${this.dotsEaten} / ${this.cfg.pelletsToPass}`);
}
// Ghosts
@ -179,7 +191,7 @@ export default class DotDude extends Phaser.Scene {
}
}
if (this.dotsEaten >= this.totalDots) { this._endGame(true); return; }
if (this.dotsEaten >= this.cfg.pelletsToPass) { this._endGame(true); return; }
// Mouth animation
this.mouthAngle += this.mouthDir * 3 * dt;

View File

@ -1,7 +1,14 @@
{
"playerSpeed": 155,
"ghostSpeed": 105,
"ghostStartDelay": [2000, 3500, 5000],
"dotRadius": 5,
"powerDotRadius": 10
"levels": {
"1": {
"playerSpeed": 155, "ghostSpeed": 90,
"ghostStartDelay": [2000, 4000, 6500],
"dotRadius": 5, "pelletsToPass": 20
},
"2": {
"playerSpeed": 155, "ghostSpeed": 120,
"ghostStartDelay": [1500, 3000, 4500],
"dotRadius": 5, "pelletsToPass": 40
}
}
}

View File

@ -1,23 +1,36 @@
const BLOCK_COLORS = [0xff2222, 0xff8800, 0xffff00, 0x00ff44, 0x00aaff];
import { playMusic, stopMusic } from '../../audio.js';
export default class SmashOut extends Phaser.Scene {
constructor() { super({ key: 'SmashOut' }); }
preload() {
this.load.json('soConfig', 'src/games/smash-out/config.json');
this.load.json('soConfig', 'src/games/smash-out/config.json?v=2');
this.load.audio('soMusic', 'assets/music/04-smash-out.mp3');
}
shutdown() {
stopMusic(this);
}
init(data) {
this.globalLives = data.lives;
this.level = data.level || 1;
this.gameActive = false;
}
create() {
this.cfg = this.cache.json.get('soConfig');
const all = this.cache.json.get('soConfig');
const lvl = String(this.level);
this.cfg = all.levels ? (all.levels[lvl] ?? all.levels['1']) : all;
this.blocks = [];
this.bricksDestroyed = 0;
this.gameActive = true;
this.ballLaunched = false;
playMusic(this, 'soMusic');
this._buildBackground();
this._buildBlocks();
this._buildPaddle();
@ -176,6 +189,7 @@ export default class SmashOut extends Phaser.Scene {
if (dist < r) {
block.alive = false;
this.bricksDestroyed++;
// Determine bounce axis: use which edge the ball is closest to
const overlapX = (ball.x < bLeft || ball.x > bRight)
@ -191,14 +205,13 @@ export default class SmashOut extends Phaser.Scene {
} else {
ball.vx = -ball.vx;
}
if (this.bricksDestroyed >= this.cfg.bricksToPass) {
this._endGame(true);
}
break; // one block per frame
}
}
// Win check
if (this.blocks.every(b => !b.alive)) {
this._endGame(true);
}
}
_endGame(success) {
@ -219,9 +232,7 @@ export default class SmashOut extends Phaser.Scene {
const g = this.mainGfx;
g.clear();
// Blocks
const alive = this.blocks.filter(b => b.alive).length;
this.blocksText.setText(`BLOCKS: ${alive}`);
this.blocksText.setText(`SMASH ${this.bricksDestroyed}/${this.cfg.bricksToPass}`);
this.blocks.forEach(b => {
if (!b.alive) return;

View File

@ -1,16 +1,18 @@
{
"paddleWidth": 200,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 480,
"ballRadius": 12,
"ballSpeed": 530,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70
"levels": {
"1": {
"paddleWidth": 200, "paddleHeight": 18, "paddleY": 855,
"paddleSpeed": 480, "ballRadius": 12, "ballSpeed": 530,
"blockCols": 8, "blockRows": 5, "blockWidth": 140, "blockHeight": 30,
"blockGapX": 20, "blockGapY": 12, "blocksStartX": 170, "blocksStartY": 70,
"bricksToPass": 15
},
"2": {
"paddleWidth": 170, "paddleHeight": 18, "paddleY": 855,
"paddleSpeed": 520, "ballRadius": 12, "ballSpeed": 640,
"blockCols": 8, "blockRows": 5, "blockWidth": 140, "blockHeight": 30,
"blockGapX": 20, "blockGapY": 12, "blocksStartX": 170, "blocksStartY": 70,
"bricksToPass": 30
}
}
}

View File

@ -1,3 +1,5 @@
import { playMusic, stopMusic } from '../audio.js';
export default class MainMenu extends Phaser.Scene {
constructor() {
super({ key: 'MainMenu' });
@ -5,9 +7,16 @@ export default class MainMenu extends Phaser.Scene {
preload() {
this.load.json('gameConfig', 'config/game.json');
this.load.audio('mainTheme', 'assets/music/00-main-theme.mp3');
}
shutdown() {
stopMusic(this);
}
create() {
playMusic(this, 'mainTheme');
const cx = 800, cy = 450;
// Title