feat: add 10-level progression for all games and new Vortex game

- Expand all existing games (ColoradoDefense, CodeBugInvaders, DotDude, SmashOut) from 2 levels to 10 levels with progressive difficulty scaling
- Add new game "Vortex" with 10 levels of increasing difficulty
- Update game config to include Vortex in the game pool
- Add Vortex music asset and configuration
- Improve GameManager robustness with duplicate event guards
- Clean up JSON formatting in all game config files
- Remove version query parameters from config loading where appropriate
This commit is contained in:
Brian Fertig 2026-05-09 19:20:32 -06:00
parent 3527a1e3ef
commit d044b1f319
15 changed files with 1124 additions and 98 deletions

BIN
assets/music/05-vortex.mp3 Normal file

Binary file not shown.

View File

@ -2,7 +2,15 @@
"lives": 3,
"levels": {
"1": { "gameCount": 3 },
"2": { "gameCount": 4 }
"2": { "gameCount": 4 },
"3": { "gameCount": 4 },
"4": { "gameCount": 4 },
"5": { "gameCount": 5 },
"6": { "gameCount": 5 },
"7": { "gameCount": 5 },
"8": { "gameCount": 5 },
"9": { "gameCount": 5 },
"10": { "gameCount": 5 }
},
"gamePool": ["ColoradoDefense", "CodeBugInvaders", "DotDude", "SmashOut"]
"gamePool": ["ColoradoDefense", "CodeBugInvaders", "DotDude", "SmashOut", "Vortex"]
}

View File

@ -4,9 +4,9 @@ export default class CodeBugInvaders extends Phaser.Scene {
constructor() { super({ key: 'CodeBugInvaders' }); }
preload() {
this.load.json('cbiConfig', 'src/games/code-bug-invaders/config.json?v=2');
this.load.json('cbiConfig', 'src/games/code-bug-invaders/config.json');
this.load.audio('cbiMusic', 'assets/music/02-code-bug-invaders.mp3');
this.load.audio('cbiComplete','assets/fx/complete.mp3');
this.load.audio('cbiComplete', 'assets/fx/complete.mp3');
this.load.audio('cbifire', 'assets/fx/fire.mp3');
this.load.audio('cbilaser', 'assets/fx/laser.mp3');
this.load.audio('cbideath', 'assets/fx/death.mp3');

View File

@ -1,24 +1,184 @@
{
"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
"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
"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": 20
},
"3": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 105,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 1010,
"bugBulletSpeed": 400,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 23
},
"4": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 120,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 795,
"bugBulletSpeed": 440,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 26
},
"5": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 133,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 642,
"bugBulletSpeed": 473,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 29
},
"6": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 144,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 530,
"bugBulletSpeed": 499,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 31
},
"7": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 152,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 447,
"bugBulletSpeed": 519,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 33
},
"8": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 159,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 384,
"bugBulletSpeed": 535,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 35
},
"9": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 164,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 336,
"bugBulletSpeed": 547,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 37
},
"10": {
"cols": 10,
"rows": 4,
"bugSpacingX": 120,
"bugSpacingY": 70,
"startX": 180,
"startY": 100,
"formationSpeed": 168,
"formationStepDown": 30,
"rightBound": 1540,
"leftBound": 60,
"bugFireInterval": 298,
"bugBulletSpeed": 557,
"playerSpeed": 420,
"playerBulletSpeed": 700,
"dangerY": 720,
"bugsToPass": 38
}
}
}

View File

@ -4,12 +4,12 @@ export default class ColoradoDefense extends Phaser.Scene {
constructor() { super({ key: 'ColoradoDefense' }); }
preload() {
this.load.json('cdConfig', 'src/games/colorado-defense/config.json?v=2');
this.load.json('cdConfig', 'src/games/colorado-defense/config.json');
this.load.json('cdCities', 'src/games/colorado-defense/cities.json');
this.load.audio('cdMusic', 'assets/music/01-colorado-defense.mp3');
this.load.audio('cdComplete','assets/fx/complete.mp3');
this.load.audio('cdComplete', 'assets/fx/complete.mp3');
this.load.audio('cdShoot', 'assets/fx/shoot.mp3');
this.load.audio('cdFireball','assets/fx/fireball.mp3');
this.load.audio('cdFireball', 'assets/fx/fireball.mp3');
this.load.audio('cdDestroy', 'assets/fx/destroy.mp3');
}

View File

@ -6,8 +6,10 @@
{ "count": 9, "speed": 80, "spawnInterval": 2000 },
{ "count": 14, "speed": 110, "spawnInterval": 1400 }
],
"interceptorSpeed": 420, "explosionRadius": 75,
"maxCityDeaths": 3, "wavesToPass": 2
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 3,
"wavesToPass": 1
},
"2": {
"waves": [
@ -15,8 +17,98 @@
{ "count": 12, "speed": 100, "spawnInterval": 1600 },
{ "count": 16, "speed": 130, "spawnInterval": 1100 }
],
"interceptorSpeed": 420, "explosionRadius": 75,
"maxCityDeaths": 2, "wavesToPass": 3
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 2,
"wavesToPass": 2
},
"3": {
"waves": [
{ "count": 10, "speed": 82, "spawnInterval": 1900 },
{ "count": 14, "speed": 118, "spawnInterval": 1350 },
{ "count": 18, "speed": 148, "spawnInterval": 980 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 2,
"wavesToPass": 2
},
"4": {
"waves": [
{ "count": 11, "speed": 93, "spawnInterval": 1650 },
{ "count": 16, "speed": 132, "spawnInterval": 1175 },
{ "count": 21, "speed": 163, "spawnInterval": 875 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 2,
"wavesToPass": 2
},
"5": {
"waves": [
{ "count": 12, "speed": 102, "spawnInterval": 1455 },
{ "count": 17, "speed": 144, "spawnInterval": 1040 },
{ "count": 23, "speed": 176, "spawnInterval": 790 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 2,
"wavesToPass": 3
},
"6": {
"waves": [
{ "count": 13, "speed": 110, "spawnInterval": 1290 },
{ "count": 19, "speed": 154, "spawnInterval": 932 },
{ "count": 25, "speed": 187, "spawnInterval": 717 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 2,
"wavesToPass": 3
},
"7": {
"waves": [
{ "count": 14, "speed": 117, "spawnInterval": 1155 },
{ "count": 20, "speed": 163, "spawnInterval": 840 },
{ "count": 27, "speed": 197, "spawnInterval": 653 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 1,
"wavesToPass": 3
},
"8": {
"waves": [
{ "count": 15, "speed": 122, "spawnInterval": 1042 },
{ "count": 22, "speed": 170, "spawnInterval": 763 },
{ "count": 29, "speed": 205, "spawnInterval": 597 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 1,
"wavesToPass": 3
},
"9": {
"waves": [
{ "count": 16, "speed": 127, "spawnInterval": 946 },
{ "count": 23, "speed": 177, "spawnInterval": 696 },
{ "count": 31, "speed": 213, "spawnInterval": 548 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 1,
"wavesToPass": 3
},
"10": {
"waves": [
{ "count": 18, "speed": 130, "spawnInterval": 862 },
{ "count": 25, "speed": 183, "spawnInterval": 638 },
{ "count": 34, "speed": 220, "spawnInterval": 505 }
],
"interceptorSpeed": 420,
"explosionRadius": 75,
"maxCityDeaths": 1,
"wavesToPass": 3
}
}
}

View File

@ -7,21 +7,21 @@ const TH = 60;
const OX = (1600 - COLS * TW) / 2; // left edge of maze = 500
const LAYOUT = [
[1,1,1,1,1,1,1,1,1,1], // 0 top wall
[1,0,0,0,0,0,0,0,0,1], // 1 open corridor
[1,0,1,1,0,0,1,1,0,1], // 2 block rooms
[1,0,1,1,0,0,1,1,0,1], // 3 (thick walls, same as row 2)
[1,0,0,0,0,0,0,0,0,1], // 4 open corridor
[1,0,1,0,1,1,0,1,0,1], // 5 pre-ghost-house
[1,0,1,0,0,0,0,1,0,1], // 6 ghost house (open centre)
[1,0,1,0,1,1,0,1,0,1], // 7 post-ghost-house (mirror of 5)
[1,0,0,0,0,0,0,0,0,1], // 8 open corridor
[1,0,1,1,0,0,1,1,0,1], // 9 block rooms (mirror of 2)
[1,0,1,1,0,0,1,1,0,1], // 10 (mirror of 3)
[1,0,0,0,0,0,0,0,0,1], // 11 open corridor
[1,1,0,1,0,0,1,0,1,1], // 12 narrow passage
[1,0,0,0,0,0,0,0,0,1], // 13 open corridor (player spawn)
[1,1,1,1,1,1,1,1,1,1], // 14 bottom wall
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1], // 0 top wall
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1], // 1 open corridor
[1, 0, 1, 1, 0, 0, 1, 1, 0, 1], // 2 block rooms
[1, 0, 1, 1, 0, 0, 1, 1, 0, 1], // 3 (thick walls, same as row 2)
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1], // 4 open corridor
[1, 0, 1, 0, 1, 1, 0, 1, 0, 1], // 5 pre-ghost-house
[1, 0, 1, 0, 0, 0, 0, 1, 0, 1], // 6 ghost house (open centre)
[1, 0, 1, 0, 1, 1, 0, 1, 0, 1], // 7 post-ghost-house (mirror of 5)
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1], // 8 open corridor
[1, 0, 1, 1, 0, 0, 1, 1, 0, 1], // 9 block rooms (mirror of 2)
[1, 0, 1, 1, 0, 0, 1, 1, 0, 1], // 10 (mirror of 3)
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1], // 11 open corridor
[1, 1, 0, 1, 0, 0, 1, 0, 1, 1], // 12 narrow passage
[1, 0, 0, 0, 0, 0, 0, 0, 0, 1], // 13 open corridor (player spawn)
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1], // 14 bottom wall
];
import { playMusic, stopMusic } from '../../audio.js';
@ -30,7 +30,7 @@ export default class DotDude extends Phaser.Scene {
constructor() { super({ key: 'DotDude' }); }
preload() {
this.load.json('ddConfig', 'src/games/dot-dude/config.json?v=2');
this.load.json('ddConfig', 'src/games/dot-dude/config.json');
this.load.audio('ddMusic', 'assets/music/03-dot-dude.mp3');
this.load.audio('ddPellet', 'assets/fx/pellet.mp3');
this.load.audio('ddComplete', 'assets/fx/complete.mp3');

View File

@ -1,14 +1,74 @@
{
"levels": {
"1": {
"playerSpeed": 155, "ghostSpeed": 90,
"playerSpeed": 155,
"ghostSpeed": 90,
"ghostStartDelay": [2000, 4000, 6500],
"dotRadius": 5, "pelletsToPass": 20
"dotRadius": 5,
"pelletsToPass": 20
},
"2": {
"playerSpeed": 155, "ghostSpeed": 120,
"playerSpeed": 155,
"ghostSpeed": 120,
"ghostStartDelay": [1500, 3000, 4500],
"dotRadius": 5, "pelletsToPass": 40
"dotRadius": 5,
"pelletsToPass": 25
},
"3": {
"playerSpeed": 155,
"ghostSpeed": 143,
"ghostStartDelay": [1100, 2200, 3500],
"dotRadius": 5,
"pelletsToPass": 28
},
"4": {
"playerSpeed": 155,
"ghostSpeed": 160,
"ghostStartDelay": [850, 1700, 2700],
"dotRadius": 5,
"pelletsToPass": 31
},
"5": {
"playerSpeed": 155,
"ghostSpeed": 175,
"ghostStartDelay": [650, 1300, 2100],
"dotRadius": 5,
"pelletsToPass": 34
},
"6": {
"playerSpeed": 155,
"ghostSpeed": 188,
"ghostStartDelay": [500, 1000, 1650],
"dotRadius": 5,
"pelletsToPass": 37
},
"7": {
"playerSpeed": 155,
"ghostSpeed": 199,
"ghostStartDelay": [380, 760, 1280],
"dotRadius": 5,
"pelletsToPass": 40
},
"8": {
"playerSpeed": 155,
"ghostSpeed": 208,
"ghostStartDelay": [290, 580, 980],
"dotRadius": 5,
"pelletsToPass": 43
},
"9": {
"playerSpeed": 155,
"ghostSpeed": 215,
"ghostStartDelay": [220, 440, 750],
"dotRadius": 5,
"pelletsToPass": 46
},
"10": {
"playerSpeed": 155,
"ghostSpeed": 220,
"ghostStartDelay": [160, 320, 540],
"dotRadius": 5,
"pelletsToPass": 50
}
}
}

View File

@ -6,9 +6,9 @@ export default class SmashOut extends Phaser.Scene {
constructor() { super({ key: 'SmashOut' }); }
preload() {
this.load.json('soConfig', 'src/games/smash-out/config.json?v=2');
this.load.json('soConfig', 'src/games/smash-out/config.json');
this.load.audio('soMusic', 'assets/music/04-smash-out.mp3');
this.load.audio('soComplete','assets/fx/complete.mp3');
this.load.audio('soComplete', 'assets/fx/complete.mp3');
this.load.audio('soLaunch', 'assets/fx/launch.mp3');
this.load.audio('soBounce', 'assets/fx/bounce.mp3');
this.load.audio('soDestroy', 'assets/fx/short_death.mp3');

View File

@ -1,17 +1,173 @@
{
"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
"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": 8
},
"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,
"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": 10
},
"3": {
"paddleWidth": 150,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 555,
"ballRadius": 12,
"ballSpeed": 725,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 12
},
"4": {
"paddleWidth": 134,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 583,
"ballRadius": 12,
"ballSpeed": 800,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 15
},
"5": {
"paddleWidth": 121,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 607,
"ballRadius": 12,
"ballSpeed": 865,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 18
},
"6": {
"paddleWidth": 111,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 627,
"ballRadius": 12,
"ballSpeed": 920,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 20
},
"7": {
"paddleWidth": 103,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 643,
"ballRadius": 12,
"ballSpeed": 968,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 23
},
"8": {
"paddleWidth": 97,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 655,
"ballRadius": 12,
"ballSpeed": 1008,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 25
},
"9": {
"paddleWidth": 92,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 665,
"ballRadius": 12,
"ballSpeed": 1042,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 27
},
"10": {
"paddleWidth": 88,
"paddleHeight": 18,
"paddleY": 855,
"paddleSpeed": 672,
"ballRadius": 12,
"ballSpeed": 1070,
"blockCols": 8,
"blockRows": 5,
"blockWidth": 140,
"blockHeight": 30,
"blockGapX": 20,
"blockGapY": 12,
"blocksStartX": 170,
"blocksStartY": 70,
"bricksToPass": 30
}
}

426
src/games/vortex/Vortex.js Normal file
View File

@ -0,0 +1,426 @@
import { playMusic, stopMusic } from '../../audio.js';
const TAU = Math.PI * 2;
export default class Vortex extends Phaser.Scene {
constructor() {
super({ key: 'Vortex' });
}
preload() {
this.load.json('vortexConfig', 'src/games/vortex/config.json?v=1');
this.load.audio('vortexMusic', 'assets/music/05-vortex.mp3');
this.load.audio('vortexComplete', 'assets/fx/complete.mp3');
this.load.audio('vortexFire', 'assets/fx/laser.mp3');
this.load.audio('vortexHit', 'assets/fx/destroy.mp3');
this.load.audio('vortexBreach', 'assets/fx/short_death.mp3');
}
init(data) {
this.lives = data.lives ?? 3;
this.level = String(data.level ?? 1);
}
create() {
const all = this.cache.json.get('vortexConfig');
const cfg = all.levels[this.level] ?? all.levels['1'];
this.cfg = cfg;
this.cx = 800;
this.cy = 470;
this.TWO_PI_N = TAU / cfg.numLanes;
this.playerLane = 0;
this.moveCooldown = 0;
this.keyAPressed = false;
this.bullets = [];
this.enemies = [];
this.kills = 0;
this.misses = 0;
this.spawnTimer = cfg.spawnInterval * 0.5; // first spawn after half interval
this.gameActive = true;
this.cursors = this.input.keyboard.createCursorKeys();
this.keyA = this.input.keyboard.addKey('A');
this.bgGfx = this.add.graphics();
this.mainGfx = this.add.graphics();
this._drawBackground();
this.killsText = this.add.text(800, 16, '', {
fontSize: '26px', fontFamily: 'monospace',
color: '#00ffff', stroke: '#000000', strokeThickness: 4,
}).setOrigin(0.5, 0).setDepth(10);
this.shieldsText = this.add.text(20, 16, '', {
fontSize: '26px', fontFamily: 'monospace',
color: '#ffaa00', stroke: '#000000', strokeThickness: 4,
}).setDepth(10);
this.livesText = this.add.text(1580, 16, '', {
fontSize: '26px', fontFamily: 'monospace',
color: '#ff4444', stroke: '#000000', strokeThickness: 4,
}).setOrigin(1, 0).setDepth(10);
this.levelText = this.add.text(800, 860, `LEVEL ${this.level} | Arrow Keys: Move A: Fire`, {
fontSize: '20px', fontFamily: 'monospace',
color: '#446688', stroke: '#000000', strokeThickness: 3,
}).setOrigin(0.5, 1).setDepth(10);
playMusic(this, 'vortexMusic');
this._updateHUD();
}
update(time, delta) {
if (!this.gameActive) return;
const dt = delta / 1000;
this._handleInput(dt);
this._spawnEnemies(dt);
this._updateBullets(dt);
this._updateEnemies(dt);
this._draw();
this._updateHUD();
}
// ─── Input ────────────────────────────────────────────────────────────────
_handleInput(dt) {
this.moveCooldown = Math.max(0, this.moveCooldown - dt * 1000);
if (this.moveCooldown <= 0) {
if (this.cursors.left.isDown) {
this.playerLane = (this.playerLane - 1 + this.cfg.numLanes) % this.cfg.numLanes;
this.moveCooldown = this.cfg.moveCooldown;
} else if (this.cursors.right.isDown) {
this.playerLane = (this.playerLane + 1) % this.cfg.numLanes;
this.moveCooldown = this.cfg.moveCooldown;
}
}
if (this.keyA.isDown && !this.keyAPressed) {
this.keyAPressed = true;
this._shoot();
}
if (!this.keyA.isDown) this.keyAPressed = false;
}
_shoot() {
this.bullets.push({ lane: this.playerLane, t: 0.98 });
this._playFx('vortexFire', 0.45);
}
// ─── Spawn ────────────────────────────────────────────────────────────────
_spawnEnemies(dt) {
this.spawnTimer += dt;
if (this.spawnTimer < this.cfg.spawnInterval) return;
if (this.enemies.length >= this.cfg.maxActiveEnemies) return;
this.spawnTimer = 0;
const lane = Phaser.Math.Between(0, this.cfg.numLanes - 1);
this.enemies.push({ lane, t: 0.04, alive: true, flash: 0 });
}
// ─── Update ───────────────────────────────────────────────────────────────
_updateBullets(dt) {
const speed = this.cfg.bulletSpeed;
for (const b of this.bullets) {
b.t -= speed * dt;
}
// Bullet–enemy collision
for (const b of this.bullets) {
if (b.t <= 0) continue;
for (const e of this.enemies) {
if (!e.alive || e.lane !== b.lane) continue;
if (Math.abs(b.t - e.t) < 0.07) {
e.alive = false;
b.t = -1;
this.kills++;
this._playFx('vortexHit', 0.6);
if (this.kills >= this.cfg.killsToWin) {
this._endGame(true);
return;
}
}
}
}
this.bullets = this.bullets.filter(b => b.t > 0);
}
_updateEnemies(dt) {
const speed = this.cfg.enemySpeed;
for (const e of this.enemies) {
if (!e.alive) continue;
e.t += speed * dt;
if (e.flash > 0) e.flash -= dt;
}
// Check rim breaches
for (const e of this.enemies) {
if (!e.alive || e.t < 1.0) continue;
e.alive = false;
this.misses++;
this._playFx('vortexBreach', 0.7);
if (this.misses >= this.cfg.maxMisses) {
this._endGame(false);
return;
}
}
this.enemies = this.enemies.filter(e => e.alive);
}
// ─── Drawing ──────────────────────────────────────────────────────────────
_drawBackground() {
this.bgGfx.fillStyle(0x000814);
this.bgGfx.fillRect(0, 0, 1600, 900);
}
_draw() {
const g = this.mainGfx;
g.clear();
this._drawTube(g);
this._drawPlayerLaneHighlight(g);
this._drawEnemies(g);
this._drawBullets(g);
this._drawPlayer(g);
}
_drawTube(g) {
const { numLanes, rimRadius } = this.cfg;
const cx = this.cx, cy = this.cy;
const step = this.TWO_PI_N;
const RINGS = 7;
// Depth rings (concentric polygons shrinking toward center)
for (let r = 1; r <= RINGS; r++) {
const t = r / RINGS;
const depth = t * t; // non-linear for stronger perspective
const radius = rimRadius * depth;
const alpha = 0.15 + t * 0.25;
const blue = Math.floor(60 + t * 140);
g.lineStyle(1, Phaser.Display.Color.GetColor(0, blue / 3, blue), alpha);
g.beginPath();
for (let i = 0; i <= numLanes; i++) {
const angle = i * step - Math.PI / 2;
const x = cx + radius * Math.cos(angle);
const y = cy + radius * Math.sin(angle);
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.closePath();
g.strokePath();
}
// Lane dividers: lines from each rim vertex to center
for (let i = 0; i < numLanes; i++) {
const angle = i * step - Math.PI / 2;
const rx = cx + rimRadius * Math.cos(angle);
const ry = cy + rimRadius * Math.sin(angle);
const isEdge = i === this.playerLane || i === (this.playerLane + 1) % numLanes;
if (isEdge) {
g.lineStyle(2, 0x00eeff, 0.75);
} else {
g.lineStyle(1, 0x003355, 0.55);
}
g.lineBetween(cx, cy, rx, ry);
}
// Outer rim
g.lineStyle(3, 0x00ccff, 1.0);
g.beginPath();
for (let i = 0; i <= numLanes; i++) {
const angle = i * step - Math.PI / 2;
const x = cx + rimRadius * Math.cos(angle);
const y = cy + rimRadius * Math.sin(angle);
if (i === 0) g.moveTo(x, y); else g.lineTo(x, y);
}
g.closePath();
g.strokePath();
// Center vanishing point glow
g.fillStyle(0x00aaff, 0.6);
g.fillCircle(cx, cy, 5);
g.fillStyle(0x002244, 0.4);
g.fillCircle(cx, cy, 12);
}
_drawPlayerLaneHighlight(g) {
const { numLanes, rimRadius } = this.cfg;
const cx = this.cx, cy = this.cy;
const step = this.TWO_PI_N;
const pl = this.playerLane;
const a0 = pl * step - Math.PI / 2;
const a1 = (pl + 1) * step - Math.PI / 2;
g.fillStyle(0x00ffff, 0.06);
g.beginPath();
g.moveTo(cx, cy);
const SEGS = 6;
for (let i = 0; i <= SEGS; i++) {
const a = a0 + (a1 - a0) * (i / SEGS);
g.lineTo(cx + rimRadius * Math.cos(a), cy + rimRadius * Math.sin(a));
}
g.closePath();
g.fillPath();
}
_drawEnemies(g) {
for (const e of this.enemies) {
if (!e.alive) continue;
const pos = this._lanePos(e.lane, e.t);
const angle = this._laneAngle(e.lane);
const size = 13;
const perp = angle + Math.PI / 2;
// Triangle pointing outward (toward rim)
const tx = pos.x + Math.cos(angle) * size;
const ty = pos.y + Math.sin(angle) * size;
const lx = pos.x - Math.cos(angle) * (size * 0.4) + Math.cos(perp) * (size * 0.65);
const ly = pos.y - Math.sin(angle) * (size * 0.4) + Math.sin(perp) * (size * 0.65);
const rx = pos.x - Math.cos(angle) * (size * 0.4) - Math.cos(perp) * (size * 0.65);
const ry = pos.y - Math.sin(angle) * (size * 0.4) - Math.sin(perp) * (size * 0.65);
// Pulse red as they get closer to rim
const danger = e.t;
const r = Math.floor(200 + danger * 55);
const gv = Math.floor(80 - danger * 70);
const color = Phaser.Display.Color.GetColor(r, Math.max(0, gv), 0);
g.fillStyle(color, 1.0);
g.beginPath();
g.moveTo(tx, ty);
g.lineTo(lx, ly);
g.lineTo(rx, ry);
g.closePath();
g.fillPath();
g.lineStyle(1.5, 0xff8800, 0.7);
g.beginPath();
g.moveTo(tx, ty);
g.lineTo(lx, ly);
g.lineTo(rx, ry);
g.closePath();
g.strokePath();
}
}
_drawBullets(g) {
for (const b of this.bullets) {
const pos = this._lanePos(b.lane, b.t);
const trailPos = this._lanePos(b.lane, Math.min(1, b.t + 0.08));
g.lineStyle(2, 0xffff00, 0.55);
g.lineBetween(pos.x, pos.y, trailPos.x, trailPos.y);
g.fillStyle(0xffffff, 1.0);
g.fillCircle(pos.x, pos.y, 5);
g.fillStyle(0xffff00, 0.9);
g.fillCircle(pos.x, pos.y, 3);
}
}
_drawPlayer(g) {
const { rimRadius } = this.cfg;
const cx = this.cx, cy = this.cy;
const pl = this.playerLane;
const angle = this._laneAngle(pl);
const perp = angle + Math.PI / 2;
// Position slightly outside rim
const px = cx + (rimRadius + 22) * Math.cos(angle);
const py = cy + (rimRadius + 22) * Math.sin(angle);
const arm = 18;
const depth = 14;
// Claw: two arms that open toward center, connected at the back
const tipX = px + Math.cos(angle) * 6;
const tipY = py + Math.sin(angle) * 6;
const backX = px - Math.cos(angle) * depth;
const backY = py - Math.sin(angle) * depth;
g.lineStyle(2.5, 0x00ffff, 1.0);
// Left arm
g.lineBetween(tipX, tipY,
backX + Math.cos(perp) * arm, backY + Math.sin(perp) * arm);
// Right arm
g.lineBetween(tipX, tipY,
backX - Math.cos(perp) * arm, backY - Math.sin(perp) * arm);
// Back connector
g.lineBetween(
backX + Math.cos(perp) * arm, backY + Math.sin(perp) * arm,
backX - Math.cos(perp) * arm, backY - Math.sin(perp) * arm);
// Tip dot
g.fillStyle(0x00ffff, 1.0);
g.fillCircle(tipX, tipY, 4);
// Glow ring
g.lineStyle(1, 0x00ffff, 0.3);
g.strokeCircle(px, py, 26);
}
// ─── Geometry helpers ─────────────────────────────────────────────────────
_laneAngle(lane) {
return (lane + 0.5) * this.TWO_PI_N - Math.PI / 2;
}
_lanePos(lane, t) {
const angle = this._laneAngle(lane);
const r = t * this.cfg.rimRadius;
return {
x: this.cx + r * Math.cos(angle),
y: this.cy + r * Math.sin(angle),
};
}
// ─── HUD ──────────────────────────────────────────────────────────────────
_updateHUD() {
const cfg = this.cfg;
this.killsText.setText(`KILLS ${this.kills} / ${cfg.killsToWin}`);
const shieldsLeft = cfg.maxMisses - this.misses;
this.shieldsText.setText('■ '.repeat(shieldsLeft).trim() + '□ '.repeat(this.misses).trim());
this.livesText.setText('♥ '.repeat(this.lives).trim());
}
// ─── End game ─────────────────────────────────────────────────────────────
_endGame(success) {
if (!this.gameActive) return;
this.gameActive = false;
if (success) this._playFx('vortexComplete', 0.8);
const msg = success ? 'VICTORY!' : 'BREACHED!';
const color = success ? '#00ff44' : '#ff2222';
this.add.text(800, 440, msg, {
fontSize: '80px', fontFamily: 'monospace',
color, stroke: '#000000', strokeThickness: 6,
}).setOrigin(0.5).setDepth(20);
this.time.delayedCall(2200, () => {
this.game.events.emit('miniGameResult', { success });
});
}
shutdown() {
stopMusic(this);
}
_playFx(key, volume = 0.6) {
const snd = this.sound.add(key, { volume });
snd.once('complete', () => snd.destroy());
snd.play();
}
}

View File

@ -0,0 +1,114 @@
{
"levels": {
"1": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.18,
"spawnInterval": 2.2,
"maxActiveEnemies": 4,
"killsToWin": 5,
"bulletSpeed": 1.4,
"moveCooldown": 120,
"maxMisses": 3
},
"2": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.27,
"spawnInterval": 1.5,
"maxActiveEnemies": 6,
"killsToWin": 7,
"bulletSpeed": 1.7,
"moveCooldown": 100,
"maxMisses": 3
},
"3": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.33,
"spawnInterval": 1.25,
"maxActiveEnemies": 6,
"killsToWin": 9,
"bulletSpeed": 1.85,
"moveCooldown": 88,
"maxMisses": 3
},
"4": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.38,
"spawnInterval": 1.1,
"maxActiveEnemies": 7,
"killsToWin": 11,
"bulletSpeed": 1.95,
"moveCooldown": 78,
"maxMisses": 3
},
"5": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.43,
"spawnInterval": 0.98,
"maxActiveEnemies": 7,
"killsToWin": 13,
"bulletSpeed": 2.05,
"moveCooldown": 70,
"maxMisses": 3
},
"6": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.47,
"spawnInterval": 0.88,
"maxActiveEnemies": 8,
"killsToWin": 15,
"bulletSpeed": 2.1,
"moveCooldown": 64,
"maxMisses": 2
},
"7": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.51,
"spawnInterval": 0.80,
"maxActiveEnemies": 8,
"killsToWin": 19,
"bulletSpeed": 2.15,
"moveCooldown": 58,
"maxMisses": 2
},
"8": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.54,
"spawnInterval": 0.73,
"maxActiveEnemies": 9,
"killsToWin": 23,
"bulletSpeed": 2.2,
"moveCooldown": 54,
"maxMisses": 2
},
"9": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.57,
"spawnInterval": 0.67,
"maxActiveEnemies": 9,
"killsToWin": 27,
"bulletSpeed": 2.25,
"moveCooldown": 50,
"maxMisses": 2
},
"10": {
"numLanes": 16,
"rimRadius": 340,
"enemySpeed": 0.60,
"spawnInterval": 0.62,
"maxActiveEnemies": 10,
"killsToWin": 33,
"bulletSpeed": 2.3,
"moveCooldown": 46,
"maxMisses": 1
}
}
}

View File

@ -6,6 +6,7 @@ import ColoradoDefense from './games/colorado-defense/ColoradoDefense.js';
import CodeBugInvaders from './games/code-bug-invaders/CodeBugInvaders.js';
import DotDude from './games/dot-dude/DotDude.js';
import SmashOut from './games/smash-out/SmashOut.js';
import Vortex from './games/vortex/Vortex.js';
new Phaser.Game({
type: Phaser.AUTO,
@ -16,5 +17,5 @@ new Phaser.Game({
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [MainMenu, GameManager, ColoradoDefense, CodeBugInvaders, DotDude, SmashOut, GameOver, LevelComplete],
scene: [MainMenu, GameManager, ColoradoDefense, CodeBugInvaders, DotDude, SmashOut, Vortex, GameOver, LevelComplete],
});

View File

@ -6,7 +6,7 @@ export default class GameManager extends Phaser.Scene {
init(data) {
this.config = data.config;
this.level = data.level || 1;
this.lives = data.lives !== undefined ? data.lives : this.config.lives;
this.lives = data.lives ?? this.config.lives ?? 3;
this.queue = [];
this.queueIndex = 0;
}
@ -14,10 +14,14 @@ export default class GameManager extends Phaser.Scene {
create() {
this.announceTween = null;
this.announceTimer = null;
this.resultHandled = false;
// Remove any stale listener from a previous session before adding a fresh one
this.game.events.off('miniGameResult', this._onResult, this);
this.game.events.on('miniGameResult', this._onResult, this);
this._buildQueue();
this._showAnnouncement();
this.game.events.on('miniGameResult', this._onResult, this);
}
shutdown() {
@ -86,12 +90,16 @@ export default class GameManager extends Phaser.Scene {
}
this.cameras.main.setBackgroundColor('#000000');
this.resultHandled = false; // ready to accept exactly one result per launch
const key = this.queue[this.queueIndex];
this.scene.launch(key, { lives: this.lives, level: this.level });
this.scene.bringToTop(key);
}
_onResult({ success }) {
if (this.resultHandled) return; // guard against duplicate events
this.resultHandled = true;
const key = this.queue[this.queueIndex];
if (this.scene.isActive(key)) {
this.scene.stop(key);
@ -131,6 +139,7 @@ export default class GameManager extends Phaser.Scene {
CodeBugInvaders: 'CODE BUG INVADERS',
DotDude: 'DOT DUDE',
SmashOut: 'SMASH OUT',
Vortex: 'VORTEX',
};
return names[key] || key;
}

View File

@ -6,7 +6,7 @@ export default class MainMenu extends Phaser.Scene {
}
preload() {
this.load.json('gameConfig', 'config/game.json');
this.load.json('gameConfig', 'config/game.json?v=2');
this.load.audio('mainTheme', 'assets/music/00-main-theme.mp3');
this.load.image('logo-title', 'assets/images/logo-title.png');
this.load.image('logo-people', 'assets/images/logo-people.png');