First Commit
This commit is contained in:
commit
b6002fed00
|
|
@ -0,0 +1,73 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Running the Game
|
||||||
|
|
||||||
|
No bundler required. Serve with any local HTTP server and open `http://localhost:8080`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m http.server 8080
|
||||||
|
# or
|
||||||
|
npx serve .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Entry Point & Scene Flow
|
||||||
|
|
||||||
|
`index.html` loads `src/main.js` as an ES6 module. Phaser is loaded via CDN (no bundler). Scene flow:
|
||||||
|
|
||||||
|
1. `MainMenu` — title screen, starts game
|
||||||
|
2. `GameManager` — reads `config/game.json`, randomly selects N mini-games for the current level, sequences through them, and manages global state (lives, current level)
|
||||||
|
3. Each mini-game scene — runs its level, then signals completion or failure back to `GameManager`
|
||||||
|
|
||||||
|
### Mini-Game Contract
|
||||||
|
|
||||||
|
Each mini-game is a self-contained Phaser `Scene` exported from its subdirectory. Every mini-game must implement the same interface so `GameManager` can plug it in without knowing its internals:
|
||||||
|
|
||||||
|
- Accept a `onComplete` and `onFail` callback (passed via Phaser scene data or events)
|
||||||
|
- Emit/call `onComplete` when the level is beaten; `onFail` when a life is lost
|
||||||
|
|
||||||
|
To add a new mini-game: create a subdirectory under `src/games/`, implement the Scene contract, and register it in `config/game.json`.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
index.html
|
||||||
|
config/
|
||||||
|
game.json # level-to-game-count mapping, lives, active game pool
|
||||||
|
src/
|
||||||
|
main.js # Phaser game init, registers all scenes
|
||||||
|
scenes/
|
||||||
|
MainMenu.js
|
||||||
|
GameManager.js # sequences mini-games, tracks lives/level
|
||||||
|
games/
|
||||||
|
colorado-defense/
|
||||||
|
ColoradoDefense.js
|
||||||
|
config.json
|
||||||
|
code-bug-invaders/
|
||||||
|
CodeBugInvaders.js
|
||||||
|
config.json
|
||||||
|
dot-dude/
|
||||||
|
DotDude.js
|
||||||
|
config.json
|
||||||
|
smash-out/
|
||||||
|
SmashOut.js
|
||||||
|
config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- `config/game.json` — top-level settings: number of games per level, lives count, which games are in the pool
|
||||||
|
- `src/games/<name>/config.json` — per-game tuning: speeds, enemy counts, timing, etc.
|
||||||
|
|
||||||
|
## Tech Stack & Constraints
|
||||||
|
|
||||||
|
- **Phaser 3.90** — loaded via CDN, not npm
|
||||||
|
- **Vanilla JS ES6 modules** — `import`/`export` only, no webpack/Vite/bundler
|
||||||
|
- **Canvas:** 1600×900 with `Phaser.Scale.FIT` for responsive scaling
|
||||||
|
- **Graphics:** Vector graphics only during initial development (no sprite sheets yet)
|
||||||
|
- **Controls:** Arrow keys + `A` to fire for all games; mouse for aiming in Colorado Defense only
|
||||||
|
- **Lives:** 3 lives global; game over on third loss
|
||||||
|
- **Level 1:** 3 randomly selected games; **Level 2:** 4 randomly selected games
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"lives": 3,
|
||||||
|
"levels": {
|
||||||
|
"1": { "gameCount": 3 },
|
||||||
|
"2": { "gameCount": 4 }
|
||||||
|
},
|
||||||
|
"gamePool": ["ColoradoDefense", "CodeBugInvaders", "DotDude", "SmashOut"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Build Guidelines
|
||||||
|
|
||||||
|
Create an HTML Phaser 3.90 video game. The game will be called "Attention Retro Disorder" and will be a game that will feature several different sub-games that are all retro themed. We will start with 4 sub-games, and add on to this over time. The idea with the game will be that each sub-game will feature a short level which can be completed in under 2 minutes. After completing one of the sub-game levels the player will move onto another sub-game. Once all sub-game levels of the current level are completed, the player will Level up and move on to level 2 of the games. I want to be able to control and adjust most of the settings for the parent game and the sub games via JSON files. I would like the directory structure to have a sub-folder for each sub-game with json files I can adjust in them. Each game will be developed independently and I would like to be able to add games in the future as well, and bolt them onto the parent game easily. At the beginning of each level (starting with level 1) randomly select X number (configurable per level) of the game from the game pool. 1 level of that game will need to be completed before moving onto the next level. For all games, in the initial development of this project, use temporary vector graphics or temporary sprites (whichever is most applicable per game)
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
- Phaser version 3.90 HTML game
|
||||||
|
- Use JavaScript
|
||||||
|
- Have JavaScript objects reference each other directly via IMPORT and EXPORT using ES6 standards
|
||||||
|
- Do **NOT** require a web packager.
|
||||||
|
- Create files and classes in a manner that allows future modifications and scaling at a modular level
|
||||||
|
|
||||||
|
## Basic Framework
|
||||||
|
|
||||||
|
- 1600 x 900 view
|
||||||
|
- Scale view to user's viewport.
|
||||||
|
|
||||||
|
## Gameplay
|
||||||
|
|
||||||
|
- Use Vector Graphics for all games initially
|
||||||
|
|
||||||
|
### Initial Games to develop
|
||||||
|
|
||||||
|
- Missile Command like game called "Colorado Defense"
|
||||||
|
- Space Invaders like game called "Code Bug Invaders"
|
||||||
|
- Pac Man like game called "Dot Dude"
|
||||||
|
- Akanoid like game called "Smash Out"
|
||||||
|
|
||||||
|
## Controls
|
||||||
|
|
||||||
|
- Arrow keys + 'A' to fire
|
||||||
|
- Mouse controls for aiming in "Colorado Defense" game
|
||||||
|
|
||||||
|
## Mechanics
|
||||||
|
|
||||||
|
- Three Lives -- Once three lives are lost game over
|
||||||
|
- Main Menu Screen
|
||||||
|
- Leveling
|
||||||
|
- Level 1
|
||||||
|
- 3 randomly selected games
|
||||||
|
- Level 2
|
||||||
|
- 4 randomly selected games
|
||||||
|
|
||||||
|
More Development will take place on this game after an initial structure is set up.
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Attention Retro Disorder</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.min.js"></script>
|
||||||
|
<script type="module" src="src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,288 @@
|
||||||
|
export default class CodeBugInvaders extends Phaser.Scene {
|
||||||
|
constructor() { super({ key: 'CodeBugInvaders' }); }
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.json('cbiConfig', 'src/games/code-bug-invaders/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.globalLives = data.lives;
|
||||||
|
this.gameActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.cfg = this.cache.json.get('cbiConfig');
|
||||||
|
this.bugs = [];
|
||||||
|
this.playerBullets = [];
|
||||||
|
this.bugBullets = [];
|
||||||
|
this.gameActive = true;
|
||||||
|
this.formationDir = 1; // 1 = right, -1 = left
|
||||||
|
this.formationX = 0;
|
||||||
|
this.formationY = 0;
|
||||||
|
this.bugFireTimer = this.cfg.bugFireInterval;
|
||||||
|
|
||||||
|
this._buildBackground();
|
||||||
|
this._buildBugs();
|
||||||
|
this._buildPlayer();
|
||||||
|
this.mainGfx = this.add.graphics();
|
||||||
|
this._buildHUD();
|
||||||
|
this._setupInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
// input listeners auto-cleaned by Phaser scene shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBackground() {
|
||||||
|
const bg = this.add.graphics();
|
||||||
|
bg.fillStyle(0x000011);
|
||||||
|
bg.fillRect(0, 0, 1600, 900);
|
||||||
|
for (let i = 0; i < 150; i++) {
|
||||||
|
bg.fillStyle(0xffffff, Math.random() * 0.7 + 0.1);
|
||||||
|
bg.fillRect(Phaser.Math.Between(0, 1599), Phaser.Math.Between(0, 899), 2, 2);
|
||||||
|
}
|
||||||
|
this.add.text(800, 15, 'CODE BUG INVADERS', {
|
||||||
|
fontSize: '20px', fontFamily: 'monospace', color: '#00ff88', alpha: 0.7,
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBugs() {
|
||||||
|
const { cols, rows, bugSpacingX, bugSpacingY, startX, startY } = this.cfg;
|
||||||
|
const colors = [0xff2222, 0xff8800, 0xffff00, 0x00ff44];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
for (let c = 0; c < cols; c++) {
|
||||||
|
this.bugs.push({
|
||||||
|
col: c, row: r,
|
||||||
|
x: startX + c * bugSpacingX,
|
||||||
|
y: startY + r * bugSpacingY,
|
||||||
|
alive: true,
|
||||||
|
color: colors[r % colors.length],
|
||||||
|
frame: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildPlayer() {
|
||||||
|
this.player = { x: 800, y: 845, alive: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHUD() {
|
||||||
|
this.livesText = this.add.text(800, 40, '♥ '.repeat(this.globalLives).trim(), {
|
||||||
|
fontSize: '24px', fontFamily: 'monospace', color: '#ff4444',
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
this.bugsText = this.add.text(1580, 40, '', {
|
||||||
|
fontSize: '22px', fontFamily: 'monospace', color: '#00ff88',
|
||||||
|
}).setOrigin(1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupInput() {
|
||||||
|
this.cursors = this.input.keyboard.createCursorKeys();
|
||||||
|
this.keyA = this.input.keyboard.addKey('A');
|
||||||
|
this.keyAPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
const dt = delta / 1000;
|
||||||
|
|
||||||
|
this._movePlayer(dt);
|
||||||
|
this._moveFormation(dt);
|
||||||
|
this._moveProjectiles(dt);
|
||||||
|
this._handleBugFire(delta);
|
||||||
|
this._checkCollisions();
|
||||||
|
this._checkWinLose();
|
||||||
|
this._draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
_movePlayer(dt) {
|
||||||
|
if (!this.player.alive) return;
|
||||||
|
const speed = this.cfg.playerSpeed;
|
||||||
|
if (this.cursors.left.isDown) this.player.x -= speed * dt;
|
||||||
|
if (this.cursors.right.isDown) this.player.x += speed * dt;
|
||||||
|
this.player.x = Phaser.Math.Clamp(this.player.x, 50, 1550);
|
||||||
|
|
||||||
|
if (this.keyA.isDown && !this.keyAPressed) {
|
||||||
|
this.keyAPressed = true;
|
||||||
|
// Limit to 2 bullets at once
|
||||||
|
if (this.playerBullets.length < 2) {
|
||||||
|
this.playerBullets.push({ x: this.player.x, y: this.player.y - 20 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!this.keyA.isDown) this.keyAPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_moveFormation(dt) {
|
||||||
|
const aliveBugs = this.bugs.filter(b => b.alive);
|
||||||
|
if (aliveBugs.length === 0) return;
|
||||||
|
|
||||||
|
// Speed scales up as fewer bugs remain
|
||||||
|
const speedScale = 1 + (1 - aliveBugs.length / (this.cfg.cols * this.cfg.rows)) * 2.5;
|
||||||
|
const move = this.cfg.formationSpeed * speedScale * dt * this.formationDir;
|
||||||
|
|
||||||
|
// Check bounds first
|
||||||
|
const { rightBound, leftBound } = this.cfg;
|
||||||
|
let minX = Infinity, maxX = -Infinity;
|
||||||
|
aliveBugs.forEach(b => { minX = Math.min(minX, b.x); maxX = Math.max(maxX, b.x); });
|
||||||
|
|
||||||
|
if (maxX + move > rightBound || minX + move < leftBound) {
|
||||||
|
// Step down, reverse
|
||||||
|
this.formationDir *= -1;
|
||||||
|
aliveBugs.forEach(b => { b.y += this.cfg.formationStepDown; });
|
||||||
|
} else {
|
||||||
|
aliveBugs.forEach(b => { b.x += move; });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_moveProjectiles(dt) {
|
||||||
|
const pbSpeed = this.cfg.playerBulletSpeed * dt;
|
||||||
|
this.playerBullets = this.playerBullets.filter(b => {
|
||||||
|
b.y -= pbSpeed;
|
||||||
|
return b.y > -10;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bbSpeed = this.cfg.bugBulletSpeed * dt;
|
||||||
|
this.bugBullets = this.bugBullets.filter(b => {
|
||||||
|
b.y += bbSpeed;
|
||||||
|
return b.y < 920;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleBugFire(delta) {
|
||||||
|
this.bugFireTimer -= delta;
|
||||||
|
if (this.bugFireTimer <= 0) {
|
||||||
|
this.bugFireTimer = this.cfg.bugFireInterval * (0.7 + Math.random() * 0.6);
|
||||||
|
// Pick a random bug from the bottom of each column
|
||||||
|
const cols = {};
|
||||||
|
this.bugs.filter(b => b.alive).forEach(b => {
|
||||||
|
if (!cols[b.col] || b.row > cols[b.col].row) cols[b.col] = b;
|
||||||
|
});
|
||||||
|
const shooters = Object.values(cols);
|
||||||
|
if (shooters.length > 0) {
|
||||||
|
const shooter = Phaser.Utils.Array.GetRandom(shooters);
|
||||||
|
this.bugBullets.push({ x: shooter.x, y: shooter.y + 20 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkCollisions() {
|
||||||
|
// Player bullets vs bugs
|
||||||
|
for (let bi = this.playerBullets.length - 1; bi >= 0; bi--) {
|
||||||
|
const bullet = this.playerBullets[bi];
|
||||||
|
let hit = false;
|
||||||
|
for (const bug of this.bugs) {
|
||||||
|
if (!bug.alive) continue;
|
||||||
|
if (Math.abs(bullet.x - bug.x) < 28 && Math.abs(bullet.y - bug.y) < 22) {
|
||||||
|
bug.alive = false;
|
||||||
|
hit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit) this.playerBullets.splice(bi, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bug bullets vs player
|
||||||
|
if (this.player.alive) {
|
||||||
|
for (const bb of this.bugBullets) {
|
||||||
|
if (Math.abs(bb.x - this.player.x) < 30 && Math.abs(bb.y - this.player.y) < 25) {
|
||||||
|
this.player.alive = false;
|
||||||
|
this._endGame(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkWinLose() {
|
||||||
|
const alive = this.bugs.filter(b => b.alive);
|
||||||
|
if (alive.length === 0) { this._endGame(true); return; }
|
||||||
|
|
||||||
|
// Bugs reach danger line
|
||||||
|
if (alive.some(b => b.y >= this.cfg.dangerY)) {
|
||||||
|
this._endGame(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_endGame(success) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
this.gameActive = false;
|
||||||
|
const msg = success ? 'BUGS SQUASHED!' : 'BUGS WIN!';
|
||||||
|
const color = success ? '#00ff44' : '#ff2222';
|
||||||
|
this.add.text(800, 440, msg, {
|
||||||
|
fontSize: '72px', fontFamily: 'monospace',
|
||||||
|
color, stroke: '#000000', strokeThickness: 5,
|
||||||
|
}).setOrigin(0.5).setDepth(20);
|
||||||
|
this.time.delayedCall(2200, () => {
|
||||||
|
this.game.events.emit('miniGameResult', { success });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawBug(g, x, y, color, frame) {
|
||||||
|
// Simple bug shape: body + 6 legs + antenna
|
||||||
|
g.fillStyle(color);
|
||||||
|
g.fillEllipse(x, y, 36, 26);
|
||||||
|
// Head
|
||||||
|
g.fillCircle(x, y - 16, 10);
|
||||||
|
// Antennae
|
||||||
|
g.lineStyle(2, color);
|
||||||
|
g.lineBetween(x - 4, y - 24, x - 10, y - 34);
|
||||||
|
g.lineBetween(x + 4, y - 24, x + 10, y - 34);
|
||||||
|
// Legs (animated: offset based on frame)
|
||||||
|
const legOff = (frame % 2 === 0) ? 4 : -4;
|
||||||
|
g.lineBetween(x - 18, y - 4 + legOff, x - 30, y - 8);
|
||||||
|
g.lineBetween(x - 18, y + 4 - legOff, x - 30, y + 8);
|
||||||
|
g.lineBetween(x + 18, y - 4 + legOff, x + 30, y - 8);
|
||||||
|
g.lineBetween(x + 18, y + 4 - legOff, x + 30, y + 8);
|
||||||
|
// Eyes
|
||||||
|
g.fillStyle(0x000000);
|
||||||
|
g.fillCircle(x - 4, y - 16, 3);
|
||||||
|
g.fillCircle(x + 4, y - 16, 3);
|
||||||
|
g.fillStyle(0xffffff);
|
||||||
|
g.fillCircle(x - 3, y - 17, 1);
|
||||||
|
g.fillCircle(x + 5, y - 17, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw() {
|
||||||
|
const g = this.mainGfx;
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
const tick = Math.floor(this.time.now / 300);
|
||||||
|
|
||||||
|
// Bugs
|
||||||
|
this.bugs.forEach(b => {
|
||||||
|
if (!b.alive) return;
|
||||||
|
this._drawBug(g, b.x, b.y, b.color, tick);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player ship
|
||||||
|
if (this.player.alive) {
|
||||||
|
const { x, y } = this.player;
|
||||||
|
g.fillStyle(0x00ff88);
|
||||||
|
g.fillTriangle(x, y - 30, x - 30, y + 15, x + 30, y + 15);
|
||||||
|
g.fillStyle(0x00aa55);
|
||||||
|
g.fillRect(x - 35, y + 10, 70, 12);
|
||||||
|
g.fillStyle(0x88ffcc);
|
||||||
|
g.fillRect(x - 6, y - 28, 12, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ground line for player area
|
||||||
|
g.lineStyle(2, 0x00ff44, 0.3);
|
||||||
|
g.lineBetween(0, 870, 1600, 870);
|
||||||
|
|
||||||
|
// Player bullets
|
||||||
|
g.fillStyle(0xffffff);
|
||||||
|
this.playerBullets.forEach(b => g.fillRect(b.x - 2, b.y - 8, 4, 16));
|
||||||
|
|
||||||
|
// Bug bullets
|
||||||
|
g.fillStyle(0xff4400);
|
||||||
|
this.bugBullets.forEach(b => g.fillRect(b.x - 3, b.y - 10, 6, 18));
|
||||||
|
|
||||||
|
// Danger line
|
||||||
|
g.lineStyle(1, 0xff0000, 0.3);
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
export default class ColoradoDefense extends Phaser.Scene {
|
||||||
|
constructor() { super({ key: 'ColoradoDefense' }); }
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.json('cdConfig', 'src/games/colorado-defense/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.globalLives = data.lives;
|
||||||
|
this.gameActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.cfg = this.cache.json.get('cdConfig');
|
||||||
|
this.cities = [];
|
||||||
|
this.missiles = [];
|
||||||
|
this.interceptors = [];
|
||||||
|
this.explosions = [];
|
||||||
|
this.wave = 0;
|
||||||
|
this.spawnTimer = 0;
|
||||||
|
this.waveSpawned = 0;
|
||||||
|
this.waveCleared = false;
|
||||||
|
this.gameActive = true;
|
||||||
|
this.mouseX = 800;
|
||||||
|
this.mouseY = 400;
|
||||||
|
|
||||||
|
this._buildBackground();
|
||||||
|
this._buildCities();
|
||||||
|
this.mainGfx = this.add.graphics();
|
||||||
|
this._buildHUD();
|
||||||
|
this._setupInput();
|
||||||
|
this._nextWave();
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
this.input.off('pointerdown');
|
||||||
|
this.input.off('pointermove');
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBackground() {
|
||||||
|
const bg = this.add.graphics();
|
||||||
|
// Starfield
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
const alpha = Math.random() * 0.6 + 0.1;
|
||||||
|
bg.fillStyle(0xffffff, alpha);
|
||||||
|
bg.fillRect(Phaser.Math.Between(0, 1599), Phaser.Math.Between(0, 860), 2, 2);
|
||||||
|
}
|
||||||
|
// Ground
|
||||||
|
bg.fillStyle(0x003300);
|
||||||
|
bg.fillRect(0, 865, 1600, 35);
|
||||||
|
bg.lineStyle(2, 0x00bb00);
|
||||||
|
bg.lineBetween(0, 865, 1600, 865);
|
||||||
|
// Title banner
|
||||||
|
this.add.text(800, 15, 'COLORADO DEFENSE', {
|
||||||
|
fontSize: '20px', fontFamily: 'monospace', color: '#00ff00', alpha: 0.7,
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCities() {
|
||||||
|
const xs = [110, 300, 490, 1110, 1300, 1490];
|
||||||
|
xs.forEach((x, i) => {
|
||||||
|
this.cities.push({ x, y: 865, alive: true, index: i });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHUD() {
|
||||||
|
this.waveText = this.add.text(20, 40, 'WAVE 1', {
|
||||||
|
fontSize: '24px', fontFamily: 'monospace', color: '#00ff00',
|
||||||
|
});
|
||||||
|
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',
|
||||||
|
}).setOrigin(1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupInput() {
|
||||||
|
this.input.on('pointermove', p => { this.mouseX = p.x; this.mouseY = p.y; });
|
||||||
|
this.input.on('pointerdown', p => this._fire(p.x, p.y));
|
||||||
|
this.keyA = this.input.keyboard.addKey('A');
|
||||||
|
this.keyAPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextWave() {
|
||||||
|
this.wave++;
|
||||||
|
if (this.wave > this.cfg.waves.length) {
|
||||||
|
this._endGame(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.currentWave = this.cfg.waves[this.wave - 1];
|
||||||
|
this.waveSpawned = 0;
|
||||||
|
this.waveCleared = false;
|
||||||
|
this.spawnTimer = 800; // small delay before first spawn
|
||||||
|
this.waveText.setText(`WAVE ${this.wave}`);
|
||||||
|
|
||||||
|
const waveLabel = this.add.text(800, 450, `WAVE ${this.wave}`, {
|
||||||
|
fontSize: '72px', fontFamily: 'monospace', color: '#ffff00',
|
||||||
|
stroke: '#885500', strokeThickness: 4,
|
||||||
|
}).setOrigin(0.5).setDepth(10);
|
||||||
|
this.time.delayedCall(1200, () => waveLabel.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
_fire(tx, ty) {
|
||||||
|
if (!this.gameActive || ty >= 855) return;
|
||||||
|
this.interceptors.push({
|
||||||
|
sx: 800, sy: 855,
|
||||||
|
tx, ty,
|
||||||
|
dx: tx - 800, dy: ty - 855,
|
||||||
|
dist: Math.max(1, Math.hypot(tx - 800, ty - 855)),
|
||||||
|
progress: 0,
|
||||||
|
speed: this.cfg.interceptorSpeed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
const dt = delta / 1000;
|
||||||
|
|
||||||
|
if (this.keyA.isDown && !this.keyAPressed) {
|
||||||
|
this.keyAPressed = true;
|
||||||
|
this._fire(this.mouseX, this.mouseY);
|
||||||
|
}
|
||||||
|
if (!this.keyA.isDown) this.keyAPressed = false;
|
||||||
|
|
||||||
|
// Spawn enemy missiles
|
||||||
|
if (this.waveSpawned < this.currentWave.count) {
|
||||||
|
this.spawnTimer -= delta;
|
||||||
|
if (this.spawnTimer <= 0) {
|
||||||
|
this._spawnMissile();
|
||||||
|
this.waveSpawned++;
|
||||||
|
this.spawnTimer = this.currentWave.spawnInterval;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move interceptors
|
||||||
|
this.interceptors = this.interceptors.filter(i => {
|
||||||
|
i.progress += (i.speed / i.dist) * dt;
|
||||||
|
i.x = i.sx + i.dx * i.progress;
|
||||||
|
i.y = i.sy + i.dy * i.progress;
|
||||||
|
if (i.progress >= 1) {
|
||||||
|
this.explosions.push({
|
||||||
|
x: i.tx, y: i.ty, r: 0,
|
||||||
|
maxR: this.cfg.explosionRadius,
|
||||||
|
growing: true,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move enemy missiles
|
||||||
|
this.missiles = this.missiles.filter(m => {
|
||||||
|
if (m.dead) return false;
|
||||||
|
m.progress += (m.speed / m.dist) * dt;
|
||||||
|
m.x = m.sx + m.dx * m.progress;
|
||||||
|
m.y = m.sy + m.dy * m.progress;
|
||||||
|
if (m.y >= 865) {
|
||||||
|
this._cityHit(m.x);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update explosions + kill missiles inside
|
||||||
|
this.explosions = this.explosions.filter(e => {
|
||||||
|
if (e.growing) {
|
||||||
|
e.r += e.maxR * 2.8 * dt;
|
||||||
|
if (e.r >= e.maxR) e.growing = false;
|
||||||
|
} else {
|
||||||
|
e.r -= e.maxR * 2.2 * dt;
|
||||||
|
}
|
||||||
|
this.missiles.forEach(m => {
|
||||||
|
if (!m.dead && Math.hypot(m.x - e.x, m.y - e.y) < e.r) m.dead = true;
|
||||||
|
});
|
||||||
|
return e.r > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check wave clear
|
||||||
|
if (!this.waveCleared &&
|
||||||
|
this.waveSpawned >= this.currentWave.count &&
|
||||||
|
this.missiles.length === 0 &&
|
||||||
|
this.interceptors.length === 0) {
|
||||||
|
this.waveCleared = true;
|
||||||
|
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._draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
_spawnMissile() {
|
||||||
|
const target = this._pickTarget();
|
||||||
|
const sx = Phaser.Math.Between(80, 1520);
|
||||||
|
const sy = -10;
|
||||||
|
const dist = Math.max(1, Math.hypot(target.x - sx, target.y - sy));
|
||||||
|
this.missiles.push({
|
||||||
|
sx, sy, dx: target.x - sx, dy: target.y - sy,
|
||||||
|
dist, speed: this.currentWave.speed,
|
||||||
|
progress: 0, x: sx, y: sy, dead: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_pickTarget() {
|
||||||
|
const alive = this.cities.filter(c => c.alive);
|
||||||
|
if (alive.length > 0 && Math.random() < 0.65) {
|
||||||
|
return Phaser.Utils.Array.GetRandom(alive);
|
||||||
|
}
|
||||||
|
return { x: Phaser.Math.Between(100, 1500), y: 865 };
|
||||||
|
}
|
||||||
|
|
||||||
|
_cityHit(mx) {
|
||||||
|
for (const c of this.cities) {
|
||||||
|
if (c.alive && Math.abs(c.x - mx) < 70) {
|
||||||
|
c.alive = false;
|
||||||
|
// Explosion on city
|
||||||
|
this.explosions.push({ x: c.x, y: c.y - 15, r: 5, maxR: 50, growing: true });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_endGame(success) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
this.gameActive = false;
|
||||||
|
const msg = success ? 'COLORADO DEFENDED!' : 'CITIES DESTROYED!';
|
||||||
|
const color = success ? '#00ff00' : '#ff2200';
|
||||||
|
this.add.text(800, 440, msg, {
|
||||||
|
fontSize: '64px', fontFamily: 'monospace',
|
||||||
|
color, stroke: '#000000', strokeThickness: 5,
|
||||||
|
}).setOrigin(0.5).setDepth(20);
|
||||||
|
this.time.delayedCall(2200, () => {
|
||||||
|
this.game.events.emit('miniGameResult', { success });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw() {
|
||||||
|
const g = this.mainGfx;
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Turret
|
||||||
|
g.fillStyle(0x00ff00);
|
||||||
|
g.fillTriangle(785, 865, 815, 865, 800, 840);
|
||||||
|
g.fillStyle(0x00aa00);
|
||||||
|
g.fillRect(794, 850, 12, 15);
|
||||||
|
|
||||||
|
// Crosshair
|
||||||
|
g.lineStyle(1, 0x00ff00, 0.6);
|
||||||
|
g.strokeCircle(this.mouseX, this.mouseY, 16);
|
||||||
|
g.lineBetween(this.mouseX - 22, this.mouseY, this.mouseX + 22, this.mouseY);
|
||||||
|
g.lineBetween(this.mouseX, this.mouseY - 22, this.mouseX, this.mouseY + 22);
|
||||||
|
|
||||||
|
// Cities
|
||||||
|
this.cities.forEach(c => {
|
||||||
|
if (c.alive) {
|
||||||
|
g.fillStyle(0x0055ff);
|
||||||
|
g.fillRect(c.x - 30, c.y - 30, 60, 30);
|
||||||
|
g.fillStyle(0x3388ff);
|
||||||
|
g.fillRect(c.x - 22, c.y - 50, 14, 22);
|
||||||
|
g.fillRect(c.x + 6, c.y - 44, 12, 16);
|
||||||
|
g.fillRect(c.x - 5, c.y - 56, 10, 8);
|
||||||
|
} else {
|
||||||
|
g.fillStyle(0x553311);
|
||||||
|
g.fillRect(c.x - 30, c.y - 8, 60, 8);
|
||||||
|
g.fillStyle(0x884422);
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
g.fillRect(c.x - 25 + i * 14, c.y - 16, 10, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enemy missiles
|
||||||
|
this.missiles.forEach(m => {
|
||||||
|
if (m.dead) return;
|
||||||
|
g.lineStyle(2, 0xff4400, 0.9);
|
||||||
|
g.lineBetween(m.sx, m.sy, m.x, m.y);
|
||||||
|
g.fillStyle(0xff6600);
|
||||||
|
g.fillCircle(m.x, m.y, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Interceptors
|
||||||
|
this.interceptors.forEach(i => {
|
||||||
|
g.lineStyle(2, 0x00ffff, 0.9);
|
||||||
|
g.lineBetween(800, 855, i.x, i.y);
|
||||||
|
g.fillStyle(0xffffff);
|
||||||
|
g.fillCircle(i.x, i.y, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explosions
|
||||||
|
this.explosions.forEach(e => {
|
||||||
|
const alpha = e.growing ? 0.9 : e.r / e.maxR * 0.9;
|
||||||
|
g.lineStyle(3, 0xffff00, alpha);
|
||||||
|
g.strokeCircle(e.x, e.y, e.r);
|
||||||
|
g.lineStyle(2, 0xff8800, alpha * 0.7);
|
||||||
|
g.strokeCircle(e.x, e.y, e.r * 0.65);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,342 @@
|
||||||
|
const LAYOUT = [
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
[1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,1],
|
||||||
|
[1,0,1,1,0,1,0,1,1,0,0,1,1,0,1,0,1,1,0,1],
|
||||||
|
[1,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1],
|
||||||
|
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
||||||
|
[1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,0,1],
|
||||||
|
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
||||||
|
[1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,0,1],
|
||||||
|
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
||||||
|
[1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,0,1],
|
||||||
|
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
||||||
|
[1,0,1,1,0,1,1,0,1,1,1,1,0,1,1,0,1,1,0,1],
|
||||||
|
[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],
|
||||||
|
[1,0,1,1,0,0,0,1,1,0,0,1,1,0,0,0,1,1,0,1],
|
||||||
|
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROWS = 15;
|
||||||
|
const COLS = 20;
|
||||||
|
|
||||||
|
export default class DotDude extends Phaser.Scene {
|
||||||
|
constructor() { super({ key: 'DotDude' }); }
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.json('ddConfig', 'src/games/dot-dude/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.globalLives = data.lives;
|
||||||
|
this.gameActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.cfg = this.cache.json.get('ddConfig');
|
||||||
|
this.TW = 80;
|
||||||
|
this.TH = 60;
|
||||||
|
|
||||||
|
// Dots grid: copy of open cells
|
||||||
|
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;
|
||||||
|
|
||||||
|
this._initPlayer();
|
||||||
|
this._initGhosts();
|
||||||
|
|
||||||
|
this.mazeGfx = this.add.graphics();
|
||||||
|
this.mainGfx = this.add.graphics();
|
||||||
|
this.gameActive = true;
|
||||||
|
this.mouthAngle = 0.4;
|
||||||
|
this.mouthDir = -1;
|
||||||
|
|
||||||
|
this._drawMaze();
|
||||||
|
this._buildHUD();
|
||||||
|
this._setupInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
_initPlayer() {
|
||||||
|
const startCol = 10, startRow = 12;
|
||||||
|
this.player = {
|
||||||
|
x: (startCol + 0.5) * 80,
|
||||||
|
y: (startRow + 0.5) * 60,
|
||||||
|
dir: { dx: 0, dy: 0 },
|
||||||
|
wantDir: { dx: 1, dy: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_initGhosts() {
|
||||||
|
const ghostCols = [7, 10, 13];
|
||||||
|
const ghostRow = 6;
|
||||||
|
const colors = [0xff4444, 0xff88ff, 0x44ffff];
|
||||||
|
this.ghosts = ghostCols.map((col, i) => ({
|
||||||
|
x: (col + 0.5) * 80,
|
||||||
|
y: (ghostRow + 0.5) * 60,
|
||||||
|
dir: { dx: i === 1 ? -1 : 1, dy: 0 },
|
||||||
|
color: colors[i],
|
||||||
|
active: false,
|
||||||
|
}));
|
||||||
|
this.cfg.ghostStartDelay.forEach((delay, i) => {
|
||||||
|
this.time.delayedCall(delay, () => {
|
||||||
|
if (this.gameActive && this.ghosts[i]) this.ghosts[i].active = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHUD() {
|
||||||
|
this.livesText = this.add.text(800, 6, '♥ '.repeat(this.globalLives).trim(), {
|
||||||
|
fontSize: '20px', fontFamily: 'monospace', color: '#ff4444',
|
||||||
|
}).setOrigin(0.5, 0).setDepth(10);
|
||||||
|
this.dotsText = this.add.text(1580, 6, `DOTS: ${this.totalDots}`, {
|
||||||
|
fontSize: '20px', fontFamily: 'monospace', color: '#ffff00',
|
||||||
|
}).setOrigin(1, 0).setDepth(10);
|
||||||
|
this.add.text(20, 6, 'DOT DUDE', {
|
||||||
|
fontSize: '20px', fontFamily: 'monospace', color: '#ffaa00',
|
||||||
|
}).setDepth(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupInput() {
|
||||||
|
this.cursors = this.input.keyboard.createCursorKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
_wallAt(col, row) {
|
||||||
|
if (col < 0 || col >= COLS || row < 0 || row >= ROWS) return true;
|
||||||
|
return LAYOUT[row][col] === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
_canMoveTo(px, py) {
|
||||||
|
const r = 15;
|
||||||
|
const tileOf = (x, y) => ({
|
||||||
|
col: Math.floor(x / this.TW),
|
||||||
|
row: Math.floor(y / this.TH),
|
||||||
|
});
|
||||||
|
const corners = [
|
||||||
|
tileOf(px - r + 1, py - r + 1),
|
||||||
|
tileOf(px + r - 1, py - r + 1),
|
||||||
|
tileOf(px - r + 1, py + r - 1),
|
||||||
|
tileOf(px + r - 1, py + r - 1),
|
||||||
|
];
|
||||||
|
return corners.every(({ col, row }) => !this._wallAt(col, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
_canGhostMoveTo(px, py) {
|
||||||
|
const r = 13;
|
||||||
|
const tileOf = (x, y) => ({
|
||||||
|
col: Math.floor(x / this.TW),
|
||||||
|
row: Math.floor(y / this.TH),
|
||||||
|
});
|
||||||
|
const corners = [
|
||||||
|
tileOf(px - r + 1, py - r + 1),
|
||||||
|
tileOf(px + r - 1, py - r + 1),
|
||||||
|
tileOf(px - r + 1, py + r - 1),
|
||||||
|
tileOf(px + r - 1, py + r - 1),
|
||||||
|
];
|
||||||
|
return corners.every(({ col, row }) => !this._wallAt(col, row));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
const dt = delta / 1000;
|
||||||
|
|
||||||
|
// Input
|
||||||
|
if (this.cursors.left.isDown) this.player.wantDir = { dx: -1, dy: 0 };
|
||||||
|
else if (this.cursors.right.isDown) this.player.wantDir = { dx: 1, dy: 0 };
|
||||||
|
else if (this.cursors.up.isDown) this.player.wantDir = { dx: 0, dy: -1 };
|
||||||
|
else if (this.cursors.down.isDown) this.player.wantDir = { dx: 0, dy: 1 };
|
||||||
|
|
||||||
|
const speed = this.cfg.playerSpeed;
|
||||||
|
const { wantDir, dir } = this.player;
|
||||||
|
|
||||||
|
// Try wanted direction
|
||||||
|
const wx = this.player.x + wantDir.dx * speed * dt;
|
||||||
|
const wy = this.player.y + wantDir.dy * speed * dt;
|
||||||
|
if (this._canMoveTo(wx, wy)) {
|
||||||
|
this.player.dir = { ...wantDir };
|
||||||
|
this.player.x = wx;
|
||||||
|
this.player.y = wy;
|
||||||
|
} else {
|
||||||
|
// Keep current direction
|
||||||
|
const dx2 = this.player.x + dir.dx * speed * dt;
|
||||||
|
const dy2 = this.player.y + dir.dy * speed * dt;
|
||||||
|
if (this._canMoveTo(dx2, dy2)) {
|
||||||
|
this.player.x = dx2;
|
||||||
|
this.player.y = dy2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eat dot
|
||||||
|
const col = Math.floor(this.player.x / this.TW);
|
||||||
|
const row = Math.floor(this.player.y / this.TH);
|
||||||
|
if (row >= 0 && row < ROWS && col >= 0 && col < COLS && this.dots[row][col] === 1) {
|
||||||
|
this.dots[row][col] = 0;
|
||||||
|
this.dotsEaten++;
|
||||||
|
this.dotsText.setText(`DOTS: ${this.totalDots - this.dotsEaten}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ghosts
|
||||||
|
this.ghosts.forEach(g => this._updateGhost(g, dt));
|
||||||
|
|
||||||
|
// Ghost collision
|
||||||
|
for (const g of this.ghosts) {
|
||||||
|
if (g.active && Math.hypot(g.x - this.player.x, g.y - this.player.y) < 26) {
|
||||||
|
this._endGame(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win check
|
||||||
|
if (this.dotsEaten >= this.totalDots) {
|
||||||
|
this._endGame(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mouth animation
|
||||||
|
this.mouthAngle += this.mouthDir * 3 * dt;
|
||||||
|
if (this.mouthAngle < 0.02) { this.mouthAngle = 0.02; this.mouthDir = 1; }
|
||||||
|
if (this.mouthAngle > 0.45) { this.mouthAngle = 0.45; this.mouthDir = -1; }
|
||||||
|
|
||||||
|
this._drawDynamic();
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateGhost(ghost, dt) {
|
||||||
|
if (!ghost.active) return;
|
||||||
|
const speed = this.cfg.ghostSpeed;
|
||||||
|
const { dir } = ghost;
|
||||||
|
|
||||||
|
const nx = ghost.x + dir.dx * speed * dt;
|
||||||
|
const ny = ghost.y + dir.dy * speed * dt;
|
||||||
|
|
||||||
|
if (this._canGhostMoveTo(nx, ny)) {
|
||||||
|
ghost.x = nx;
|
||||||
|
ghost.y = ny;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Near tile center: rechoose direction
|
||||||
|
const col = Math.floor(ghost.x / this.TW);
|
||||||
|
const row = Math.floor(ghost.y / this.TH);
|
||||||
|
const cx = (col + 0.5) * this.TW;
|
||||||
|
const cy = (row + 0.5) * this.TH;
|
||||||
|
const snapD = speed * dt * 4;
|
||||||
|
|
||||||
|
if (Math.abs(ghost.x - cx) < snapD && Math.abs(ghost.y - cy) < snapD) {
|
||||||
|
ghost.x = cx;
|
||||||
|
ghost.y = cy;
|
||||||
|
ghost.dir = this._chooseGhostDir(ghost, col, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_chooseGhostDir(ghost, col, row) {
|
||||||
|
const dirs = [{ dx: 1, dy: 0 }, { dx: -1, dy: 0 }, { dx: 0, dy: 1 }, { dx: 0, dy: -1 }];
|
||||||
|
const reverse = { dx: -ghost.dir.dx, dy: -ghost.dir.dy };
|
||||||
|
const pCol = Math.floor(this.player.x / this.TW);
|
||||||
|
const pRow = Math.floor(this.player.y / this.TH);
|
||||||
|
|
||||||
|
let best = null, bestScore = Infinity;
|
||||||
|
for (const d of dirs) {
|
||||||
|
if (d.dx === reverse.dx && d.dy === reverse.dy) continue;
|
||||||
|
const nc = col + d.dx, nr = row + d.dy;
|
||||||
|
if (this._wallAt(nc, nr)) continue;
|
||||||
|
const score = Math.abs(nc - pCol) + Math.abs(nr - pRow);
|
||||||
|
if (score < bestScore) { bestScore = score; best = d; }
|
||||||
|
}
|
||||||
|
return best || reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawMaze() {
|
||||||
|
const g = this.mazeGfx;
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Background
|
||||||
|
g.fillStyle(0x000022);
|
||||||
|
g.fillRect(0, 0, 1600, 900);
|
||||||
|
|
||||||
|
// Walls
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
if (LAYOUT[r][c] === 1) {
|
||||||
|
const x = c * this.TW;
|
||||||
|
const y = r * this.TH;
|
||||||
|
g.fillStyle(0x0000aa);
|
||||||
|
g.fillRect(x + 1, y + 1, this.TW - 2, this.TH - 2);
|
||||||
|
g.lineStyle(2, 0x4444ff);
|
||||||
|
g.strokeRect(x + 2, y + 2, this.TW - 4, this.TH - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawDynamic() {
|
||||||
|
const g = this.mainGfx;
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Dots
|
||||||
|
for (let r = 0; r < ROWS; r++) {
|
||||||
|
for (let c = 0; c < COLS; c++) {
|
||||||
|
if (this.dots[r][c] === 1) {
|
||||||
|
const x = (c + 0.5) * this.TW;
|
||||||
|
const y = (r + 0.5) * this.TH;
|
||||||
|
g.fillStyle(0xffd700);
|
||||||
|
g.fillCircle(x, y, this.cfg.dotRadius);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghosts
|
||||||
|
this.ghosts.forEach(gh => {
|
||||||
|
if (!gh.active) return;
|
||||||
|
this._drawGhost(g, gh.x, gh.y, gh.color);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Player
|
||||||
|
this._drawPlayer(g, this.player.x, this.player.y, this.player.dir, this.mouthAngle);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawPlayer(g, x, y, dir, mouthAngle) {
|
||||||
|
const r = 18;
|
||||||
|
// Yellow circle
|
||||||
|
g.fillStyle(0xffff00);
|
||||||
|
g.fillCircle(x, y, r);
|
||||||
|
// Black mouth triangle cutout
|
||||||
|
const rot = Math.atan2(dir.dy, dir.dx);
|
||||||
|
g.fillStyle(0x000022);
|
||||||
|
g.fillTriangle(
|
||||||
|
x, y,
|
||||||
|
x + Math.cos(rot - mouthAngle) * (r + 2), y + Math.sin(rot - mouthAngle) * (r + 2),
|
||||||
|
x + Math.cos(rot + mouthAngle) * (r + 2), y + Math.sin(rot + mouthAngle) * (r + 2)
|
||||||
|
);
|
||||||
|
// Eye
|
||||||
|
g.fillStyle(0x000000);
|
||||||
|
g.fillCircle(x + Math.cos(rot - 0.8) * 8, y + Math.sin(rot - 0.8) * 8, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawGhost(g, x, y, color) {
|
||||||
|
// Body
|
||||||
|
g.fillStyle(color);
|
||||||
|
g.fillCircle(x, y - 6, 16);
|
||||||
|
g.fillRect(x - 16, y - 6, 32, 20);
|
||||||
|
// Wavy bottom
|
||||||
|
g.fillTriangle(x - 16, y + 14, x - 8, y + 20, x, y + 14);
|
||||||
|
g.fillTriangle(x, y + 14, x + 8, y + 20, x + 16, y + 14);
|
||||||
|
// Eyes
|
||||||
|
g.fillStyle(0xffffff);
|
||||||
|
g.fillCircle(x - 6, y - 8, 5);
|
||||||
|
g.fillCircle(x + 6, y - 8, 5);
|
||||||
|
g.fillStyle(0x0000cc);
|
||||||
|
g.fillCircle(x - 5, y - 8, 2);
|
||||||
|
g.fillCircle(x + 7, y - 8, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
_endGame(success) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
this.gameActive = false;
|
||||||
|
const msg = success ? 'ALL DOTS EATEN!' : 'GHOST GOT YOU!';
|
||||||
|
const color = success ? '#ffff00' : '#ff4444';
|
||||||
|
this.add.text(800, 440, msg, {
|
||||||
|
fontSize: '72px', fontFamily: 'monospace',
|
||||||
|
color, stroke: '#000000', strokeThickness: 5,
|
||||||
|
}).setOrigin(0.5).setDepth(20);
|
||||||
|
this.time.delayedCall(2200, () => {
|
||||||
|
this.game.events.emit('miniGameResult', { success });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"playerSpeed": 155,
|
||||||
|
"ghostSpeed": 105,
|
||||||
|
"ghostStartDelay": [2000, 3500, 5000],
|
||||||
|
"dotRadius": 5,
|
||||||
|
"powerDotRadius": 10
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,257 @@
|
||||||
|
const BLOCK_COLORS = [0xff2222, 0xff8800, 0xffff00, 0x00ff44, 0x00aaff];
|
||||||
|
|
||||||
|
export default class SmashOut extends Phaser.Scene {
|
||||||
|
constructor() { super({ key: 'SmashOut' }); }
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.json('soConfig', 'src/games/smash-out/config.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.globalLives = data.lives;
|
||||||
|
this.gameActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.cfg = this.cache.json.get('soConfig');
|
||||||
|
this.blocks = [];
|
||||||
|
this.gameActive = true;
|
||||||
|
this.ballLaunched = false;
|
||||||
|
|
||||||
|
this._buildBackground();
|
||||||
|
this._buildBlocks();
|
||||||
|
this._buildPaddle();
|
||||||
|
this._buildBall();
|
||||||
|
this.mainGfx = this.add.graphics();
|
||||||
|
this._buildHUD();
|
||||||
|
this._setupInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBackground() {
|
||||||
|
const bg = this.add.graphics();
|
||||||
|
bg.fillStyle(0x050510);
|
||||||
|
bg.fillRect(0, 0, 1600, 900);
|
||||||
|
// Grid lines for retro feel
|
||||||
|
bg.lineStyle(1, 0x111133, 0.4);
|
||||||
|
for (let x = 0; x < 1600; x += 80) bg.lineBetween(x, 0, x, 900);
|
||||||
|
for (let y = 0; y < 900; y += 60) bg.lineBetween(0, y, 1600, y);
|
||||||
|
this.add.text(800, 15, 'SMASH OUT', {
|
||||||
|
fontSize: '20px', fontFamily: 'monospace', color: '#aaaaff', alpha: 0.7,
|
||||||
|
}).setOrigin(0.5, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBlocks() {
|
||||||
|
const { blockCols, blockRows, blockWidth, blockHeight, blockGapX, blockGapY, blocksStartX, blocksStartY } = this.cfg;
|
||||||
|
for (let r = 0; r < blockRows; r++) {
|
||||||
|
for (let c = 0; c < blockCols; c++) {
|
||||||
|
const x = blocksStartX + c * (blockWidth + blockGapX);
|
||||||
|
const y = blocksStartY + r * (blockHeight + blockGapY);
|
||||||
|
this.blocks.push({ x, y, w: blockWidth, h: blockHeight, alive: true, color: BLOCK_COLORS[r] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildPaddle() {
|
||||||
|
const { paddleWidth, paddleHeight, paddleY } = this.cfg;
|
||||||
|
this.paddle = { x: 800, y: paddleY, w: paddleWidth, h: paddleHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildBall() {
|
||||||
|
const r = this.cfg.ballRadius;
|
||||||
|
this.ball = {
|
||||||
|
x: this.paddle.x,
|
||||||
|
y: this.paddle.y - this.paddle.h / 2 - r - 1,
|
||||||
|
vx: 0, vy: 0,
|
||||||
|
r,
|
||||||
|
launched: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildHUD() {
|
||||||
|
this.livesText = this.add.text(800, 40, '♥ '.repeat(this.globalLives).trim(), {
|
||||||
|
fontSize: '24px', fontFamily: 'monospace', color: '#ff4444',
|
||||||
|
}).setOrigin(0.5, 0).setDepth(10);
|
||||||
|
this.blocksText = this.add.text(1580, 40, '', {
|
||||||
|
fontSize: '22px', fontFamily: 'monospace', color: '#aaaaff',
|
||||||
|
}).setOrigin(1, 0).setDepth(10);
|
||||||
|
this.launchHint = this.add.text(800, 820, 'PRESS A TO LAUNCH', {
|
||||||
|
fontSize: '24px', fontFamily: 'monospace', color: '#ffffff',
|
||||||
|
}).setOrigin(0.5, 0).setDepth(10);
|
||||||
|
this.tweens.add({ targets: this.launchHint, alpha: 0, duration: 600, yoyo: true, repeat: -1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
_setupInput() {
|
||||||
|
this.cursors = this.input.keyboard.createCursorKeys();
|
||||||
|
this.keyA = this.input.keyboard.addKey('A');
|
||||||
|
this.keyAPressed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(time, delta) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
const dt = delta / 1000;
|
||||||
|
|
||||||
|
// Move paddle
|
||||||
|
const pSpeed = this.cfg.paddleSpeed;
|
||||||
|
const halfW = this.paddle.w / 2;
|
||||||
|
if (this.cursors.left.isDown) this.paddle.x -= pSpeed * dt;
|
||||||
|
if (this.cursors.right.isDown) this.paddle.x += pSpeed * dt;
|
||||||
|
this.paddle.x = Phaser.Math.Clamp(this.paddle.x, halfW, 1600 - halfW);
|
||||||
|
|
||||||
|
// Launch
|
||||||
|
if (!this.ball.launched) {
|
||||||
|
this.ball.x = this.paddle.x;
|
||||||
|
if (this.keyA.isDown && !this.keyAPressed) {
|
||||||
|
this.keyAPressed = true;
|
||||||
|
this._launch();
|
||||||
|
}
|
||||||
|
if (!this.keyA.isDown) this.keyAPressed = false;
|
||||||
|
} else {
|
||||||
|
this._moveBall(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
this._draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
_launch() {
|
||||||
|
this.ball.launched = true;
|
||||||
|
this.launchHint.setVisible(false);
|
||||||
|
const angle = -Math.PI / 2 + (Math.random() - 0.5) * Math.PI / 3;
|
||||||
|
const s = this.cfg.ballSpeed;
|
||||||
|
this.ball.vx = Math.cos(angle) * s;
|
||||||
|
this.ball.vy = Math.sin(angle) * s;
|
||||||
|
}
|
||||||
|
|
||||||
|
_moveBall(dt) {
|
||||||
|
const ball = this.ball;
|
||||||
|
const r = ball.r;
|
||||||
|
|
||||||
|
ball.x += ball.vx * dt;
|
||||||
|
ball.y += ball.vy * dt;
|
||||||
|
|
||||||
|
// Wall bounces
|
||||||
|
if (ball.x - r < 0) { ball.x = r; ball.vx = Math.abs(ball.vx); }
|
||||||
|
if (ball.x + r > 1600) { ball.x = 1600 - r; ball.vx = -Math.abs(ball.vx); }
|
||||||
|
if (ball.y - r < 0) { ball.y = r; ball.vy = Math.abs(ball.vy); }
|
||||||
|
|
||||||
|
// Paddle bounce
|
||||||
|
const { paddle } = this;
|
||||||
|
const pTop = paddle.y - paddle.h / 2;
|
||||||
|
if (ball.vy > 0 &&
|
||||||
|
ball.y + r >= pTop && ball.y + r <= pTop + paddle.h + 2 &&
|
||||||
|
ball.x >= paddle.x - paddle.w / 2 - r &&
|
||||||
|
ball.x <= paddle.x + paddle.w / 2 + r) {
|
||||||
|
const hitOffset = (ball.x - paddle.x) / (paddle.w / 2);
|
||||||
|
const angle = -Math.PI / 2 + hitOffset * (Math.PI / 3);
|
||||||
|
const speed = Math.hypot(ball.vx, ball.vy);
|
||||||
|
ball.vx = Math.cos(angle) * speed;
|
||||||
|
ball.vy = -Math.abs(Math.sin(angle) * speed);
|
||||||
|
ball.y = pTop - r - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block collisions
|
||||||
|
this._checkBlockCollisions();
|
||||||
|
|
||||||
|
// Ball lost
|
||||||
|
if (ball.y - r > 900) {
|
||||||
|
this._endGame(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_checkBlockCollisions() {
|
||||||
|
const ball = this.ball;
|
||||||
|
const r = ball.r;
|
||||||
|
|
||||||
|
for (const block of this.blocks) {
|
||||||
|
if (!block.alive) continue;
|
||||||
|
|
||||||
|
const bLeft = block.x;
|
||||||
|
const bRight = block.x + block.w;
|
||||||
|
const bTop = block.y;
|
||||||
|
const bBottom = block.y + block.h;
|
||||||
|
|
||||||
|
// AABB vs circle
|
||||||
|
const nearX = Phaser.Math.Clamp(ball.x, bLeft, bRight);
|
||||||
|
const nearY = Phaser.Math.Clamp(ball.y, bTop, bBottom);
|
||||||
|
const dist = Math.hypot(ball.x - nearX, ball.y - nearY);
|
||||||
|
|
||||||
|
if (dist < r) {
|
||||||
|
block.alive = false;
|
||||||
|
|
||||||
|
// Determine bounce axis: use which edge the ball is closest to
|
||||||
|
const overlapX = (ball.x < bLeft || ball.x > bRight)
|
||||||
|
? Math.min(Math.abs(ball.x - bLeft), Math.abs(ball.x - bRight))
|
||||||
|
: 0;
|
||||||
|
const overlapY = (ball.y < bTop || ball.y > bBottom)
|
||||||
|
? Math.min(Math.abs(ball.y - bTop), Math.abs(ball.y - bBottom))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// overlapX==0 → ball center inside block horizontally → top/bottom hit
|
||||||
|
if (overlapX === 0 || (overlapY !== 0 && overlapX > overlapY)) {
|
||||||
|
ball.vy = -ball.vy;
|
||||||
|
} else {
|
||||||
|
ball.vx = -ball.vx;
|
||||||
|
}
|
||||||
|
break; // one block per frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Win check
|
||||||
|
if (this.blocks.every(b => !b.alive)) {
|
||||||
|
this._endGame(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_endGame(success) {
|
||||||
|
if (!this.gameActive) return;
|
||||||
|
this.gameActive = false;
|
||||||
|
const msg = success ? 'SMASHED IT!' : 'BALL LOST!';
|
||||||
|
const color = success ? '#00ff44' : '#ff2222';
|
||||||
|
this.add.text(800, 440, msg, {
|
||||||
|
fontSize: '80px', fontFamily: 'monospace',
|
||||||
|
color, stroke: '#000000', strokeThickness: 5,
|
||||||
|
}).setOrigin(0.5).setDepth(20);
|
||||||
|
this.time.delayedCall(2200, () => {
|
||||||
|
this.game.events.emit('miniGameResult', { success });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_draw() {
|
||||||
|
const g = this.mainGfx;
|
||||||
|
g.clear();
|
||||||
|
|
||||||
|
// Blocks
|
||||||
|
const alive = this.blocks.filter(b => b.alive).length;
|
||||||
|
this.blocksText.setText(`BLOCKS: ${alive}`);
|
||||||
|
|
||||||
|
this.blocks.forEach(b => {
|
||||||
|
if (!b.alive) return;
|
||||||
|
g.fillStyle(b.color);
|
||||||
|
g.fillRect(b.x, b.y, b.w, b.h);
|
||||||
|
g.lineStyle(2, 0xffffff, 0.4);
|
||||||
|
g.strokeRect(b.x + 1, b.y + 1, b.w - 2, b.h - 2);
|
||||||
|
// Highlight
|
||||||
|
g.lineStyle(2, 0xffffff, 0.6);
|
||||||
|
g.lineBetween(b.x + 3, b.y + 3, b.x + b.w - 3, b.y + 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Paddle
|
||||||
|
const { paddle } = this;
|
||||||
|
g.fillStyle(0xaaaaff);
|
||||||
|
g.fillRoundedRect(paddle.x - paddle.w / 2, paddle.y - paddle.h / 2, paddle.w, paddle.h, 6);
|
||||||
|
g.lineStyle(2, 0xffffff, 0.8);
|
||||||
|
g.lineBetween(paddle.x - paddle.w / 2 + 6, paddle.y - paddle.h / 2 + 4, paddle.x + paddle.w / 2 - 6, paddle.y - paddle.h / 2 + 4);
|
||||||
|
|
||||||
|
// Ball
|
||||||
|
const { ball } = this;
|
||||||
|
g.fillStyle(0xffffff);
|
||||||
|
g.fillCircle(ball.x, ball.y, ball.r);
|
||||||
|
g.fillStyle(0xccccff);
|
||||||
|
g.fillCircle(ball.x - 3, ball.y - 3, ball.r * 0.4);
|
||||||
|
|
||||||
|
// Walls
|
||||||
|
g.lineStyle(2, 0x333366, 0.6);
|
||||||
|
g.lineBetween(0, 0, 0, 900);
|
||||||
|
g.lineBetween(1600, 0, 1600, 900);
|
||||||
|
g.lineBetween(0, 0, 1600, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import MainMenu from './scenes/MainMenu.js';
|
||||||
|
import GameManager from './scenes/GameManager.js';
|
||||||
|
import GameOver from './scenes/GameOver.js';
|
||||||
|
import LevelComplete from './scenes/LevelComplete.js';
|
||||||
|
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';
|
||||||
|
|
||||||
|
new Phaser.Game({
|
||||||
|
type: Phaser.AUTO,
|
||||||
|
width: 1600,
|
||||||
|
height: 900,
|
||||||
|
backgroundColor: '#000000',
|
||||||
|
scale: {
|
||||||
|
mode: Phaser.Scale.FIT,
|
||||||
|
autoCenter: Phaser.Scale.CENTER_BOTH,
|
||||||
|
},
|
||||||
|
scene: [MainMenu, GameManager, ColoradoDefense, CodeBugInvaders, DotDude, SmashOut, GameOver, LevelComplete],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
export default class GameManager extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'GameManager' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.config = data.config;
|
||||||
|
this.level = data.level || 1;
|
||||||
|
this.lives = data.lives !== undefined ? data.lives : this.config.lives;
|
||||||
|
this.queue = [];
|
||||||
|
this.queueIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
this.announceTween = null;
|
||||||
|
this.announceTimer = null;
|
||||||
|
this._buildQueue();
|
||||||
|
this._showAnnouncement();
|
||||||
|
|
||||||
|
this.game.events.on('miniGameResult', this._onResult, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
this.game.events.off('miniGameResult', this._onResult, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildQueue() {
|
||||||
|
const pool = [...this.config.gamePool];
|
||||||
|
const count = this.config.levels[String(this.level)]?.gameCount ?? 3;
|
||||||
|
Phaser.Utils.Array.Shuffle(pool);
|
||||||
|
this.queue = pool.slice(0, count);
|
||||||
|
this.queueIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_showAnnouncement() {
|
||||||
|
// Clean up any in-flight tween/timer from a previous announcement
|
||||||
|
if (this.announceTween) { this.announceTween.remove(); this.announceTween = null; }
|
||||||
|
if (this.announceTimer) { this.announceTimer.remove(); this.announceTimer = null; }
|
||||||
|
|
||||||
|
this.cameras.main.setBackgroundColor('#000000');
|
||||||
|
|
||||||
|
if (this.announcementGroup) this.announcementGroup.destroy(true);
|
||||||
|
this.announcementGroup = this.add.group();
|
||||||
|
|
||||||
|
const cx = 800, cy = 450;
|
||||||
|
const gameNum = this.queueIndex + 1;
|
||||||
|
const total = this.queue.length;
|
||||||
|
const gameName = this._displayName(this.queue[this.queueIndex]);
|
||||||
|
|
||||||
|
const levelText = this.add.text(cx, 320, `LEVEL ${this.level} — GAME ${gameNum} OF ${total}`, {
|
||||||
|
fontSize: '32px', fontFamily: 'monospace', color: '#888888',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const nameText = this.add.text(cx, 420, gameName, {
|
||||||
|
fontSize: '72px', fontFamily: 'monospace', color: '#00ff00',
|
||||||
|
stroke: '#005500', strokeThickness: 4,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const livesText = this.add.text(cx, 530, '♥ '.repeat(this.lives).trim(), {
|
||||||
|
fontSize: '40px', fontFamily: 'monospace', color: '#ff4444',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const goText = this.add.text(cx, 640, 'GET READY!', {
|
||||||
|
fontSize: '48px', fontFamily: 'monospace', color: '#ffff00',
|
||||||
|
}).setOrigin(0.5).setAlpha(0);
|
||||||
|
|
||||||
|
this.announcementGroup.addMultiple([levelText, nameText, livesText, goText]);
|
||||||
|
|
||||||
|
this.announceTween = this.tweens.add({
|
||||||
|
targets: goText,
|
||||||
|
alpha: 1,
|
||||||
|
duration: 400,
|
||||||
|
delay: 800,
|
||||||
|
onComplete: () => {
|
||||||
|
this.announceTimer = this.time.delayedCall(1000, () => this._launchCurrent());
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_launchCurrent() {
|
||||||
|
this.announceTween = null;
|
||||||
|
this.announceTimer = null;
|
||||||
|
if (this.announcementGroup) {
|
||||||
|
this.announcementGroup.destroy(true);
|
||||||
|
this.announcementGroup = null;
|
||||||
|
}
|
||||||
|
this.cameras.main.setBackgroundColor('#000000');
|
||||||
|
|
||||||
|
const key = this.queue[this.queueIndex];
|
||||||
|
this.scene.launch(key, { lives: this.lives, level: this.level });
|
||||||
|
this.scene.bringToTop(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
_onResult({ success }) {
|
||||||
|
const key = this.queue[this.queueIndex];
|
||||||
|
if (this.scene.isActive(key)) {
|
||||||
|
this.scene.stop(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
this.lives -= 1;
|
||||||
|
if (this.lives <= 0) {
|
||||||
|
this.scene.start('GameOver', { level: this.level });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Retry same mini-game
|
||||||
|
this._showAnnouncement();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queueIndex += 1;
|
||||||
|
if (this.queueIndex >= this.queue.length) {
|
||||||
|
// All games in this level done
|
||||||
|
const nextLevel = this.level + 1;
|
||||||
|
const hasNext = !!this.config.levels[String(nextLevel)];
|
||||||
|
this.scene.start('LevelComplete', {
|
||||||
|
config: this.config,
|
||||||
|
level: this.level,
|
||||||
|
lives: this.lives,
|
||||||
|
hasNextLevel: hasNext,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._showAnnouncement();
|
||||||
|
}
|
||||||
|
|
||||||
|
_displayName(key) {
|
||||||
|
const names = {
|
||||||
|
ColoradoDefense: 'COLORADO DEFENSE',
|
||||||
|
CodeBugInvaders: 'CODE BUG INVADERS',
|
||||||
|
DotDude: 'DOT DUDE',
|
||||||
|
SmashOut: 'SMASH OUT',
|
||||||
|
};
|
||||||
|
return names[key] || key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
export default class GameOver extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'GameOver' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.level = data.level || 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const cx = 800, cy = 450;
|
||||||
|
|
||||||
|
this.add.text(cx, 340, 'GAME OVER', {
|
||||||
|
fontSize: '96px', fontFamily: 'monospace',
|
||||||
|
color: '#ff0000', stroke: '#550000', strokeThickness: 6,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.add.text(cx, 470, `You reached Level ${this.level}`, {
|
||||||
|
fontSize: '36px', fontFamily: 'monospace', color: '#aaaaaa',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const prompt = this.add.text(cx, 580, 'PRESS SPACE OR CLICK TO RESTART', {
|
||||||
|
fontSize: '28px', fontFamily: 'monospace', color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.tweens.add({ targets: prompt, alpha: 0, duration: 600, yoyo: true, repeat: -1 });
|
||||||
|
|
||||||
|
this.input.keyboard.once('keydown-SPACE', () => this.scene.start('MainMenu'));
|
||||||
|
this.input.once('pointerdown', () => this.scene.start('MainMenu'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
export default class LevelComplete extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'LevelComplete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.config = data.config;
|
||||||
|
this.level = data.level;
|
||||||
|
this.lives = data.lives;
|
||||||
|
this.hasNextLevel = data.hasNextLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const cx = 800, cy = 450;
|
||||||
|
|
||||||
|
this.add.text(cx, 300, `LEVEL ${this.level} COMPLETE!`, {
|
||||||
|
fontSize: '80px', fontFamily: 'monospace',
|
||||||
|
color: '#00ff00', stroke: '#005500', strokeThickness: 4,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.add.text(cx, 420, '♥ '.repeat(this.lives).trim(), {
|
||||||
|
fontSize: '48px', fontFamily: 'monospace', color: '#ff4444',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
if (this.hasNextLevel) {
|
||||||
|
this.add.text(cx, 530, `PREPARE FOR LEVEL ${this.level + 1}`, {
|
||||||
|
fontSize: '40px', fontFamily: 'monospace', color: '#ffff00',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const prompt = this.add.text(cx, 650, 'PRESS SPACE OR CLICK TO CONTINUE', {
|
||||||
|
fontSize: '28px', fontFamily: 'monospace', color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.tweens.add({ targets: prompt, alpha: 0, duration: 600, yoyo: true, repeat: -1 });
|
||||||
|
|
||||||
|
this.input.keyboard.once('keydown-SPACE', () => this._nextLevel());
|
||||||
|
this.input.once('pointerdown', () => this._nextLevel());
|
||||||
|
} else {
|
||||||
|
this.add.text(cx, 530, 'YOU BEAT THE GAME!', {
|
||||||
|
fontSize: '48px', fontFamily: 'monospace', color: '#ffff00',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
const prompt = this.add.text(cx, 650, 'PRESS SPACE OR CLICK TO RESTART', {
|
||||||
|
fontSize: '28px', fontFamily: 'monospace', color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
this.tweens.add({ targets: prompt, alpha: 0, duration: 600, yoyo: true, repeat: -1 });
|
||||||
|
|
||||||
|
this.input.keyboard.once('keydown-SPACE', () => this.scene.start('MainMenu'));
|
||||||
|
this.input.once('pointerdown', () => this.scene.start('MainMenu'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_nextLevel() {
|
||||||
|
this.scene.start('GameManager', {
|
||||||
|
config: this.config,
|
||||||
|
level: this.level + 1,
|
||||||
|
lives: this.lives,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
export default class MainMenu extends Phaser.Scene {
|
||||||
|
constructor() {
|
||||||
|
super({ key: 'MainMenu' });
|
||||||
|
}
|
||||||
|
|
||||||
|
preload() {
|
||||||
|
this.load.json('gameConfig', 'config/game.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
create() {
|
||||||
|
const cx = 800, cy = 450;
|
||||||
|
|
||||||
|
// Title
|
||||||
|
this.add.text(cx, 280, 'ATTENTION RETRO DISORDER', {
|
||||||
|
fontSize: '64px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#00ff00',
|
||||||
|
stroke: '#005500',
|
||||||
|
strokeThickness: 4,
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.add.text(cx, 370, 'A MULTI-GAME RETRO EXPERIENCE', {
|
||||||
|
fontSize: '28px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#00aa00',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
// Game list
|
||||||
|
const games = [
|
||||||
|
'★ COLORADO DEFENSE (Missile Command)',
|
||||||
|
'★ CODE BUG INVADERS (Space Invaders)',
|
||||||
|
'★ DOT DUDE (Pac-Man)',
|
||||||
|
'★ SMASH OUT (Arkanoid)',
|
||||||
|
];
|
||||||
|
games.forEach((txt, i) => {
|
||||||
|
this.add.text(cx, 470 + i * 38, txt, {
|
||||||
|
fontSize: '22px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#ffff00',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Blink "Press SPACE"
|
||||||
|
const prompt = this.add.text(cx, 730, 'PRESS SPACE OR CLICK TO START', {
|
||||||
|
fontSize: '32px',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: '#ffffff',
|
||||||
|
}).setOrigin(0.5);
|
||||||
|
|
||||||
|
this.tweens.add({
|
||||||
|
targets: prompt,
|
||||||
|
alpha: 0,
|
||||||
|
duration: 600,
|
||||||
|
yoyo: true,
|
||||||
|
repeat: -1,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.input.keyboard.once('keydown-SPACE', () => this.startGame());
|
||||||
|
this.input.once('pointerdown', () => this.startGame());
|
||||||
|
}
|
||||||
|
|
||||||
|
startGame() {
|
||||||
|
const cfg = this.cache.json.get('gameConfig');
|
||||||
|
this.scene.start('GameManager', { config: cfg, level: 1, lives: cfg.lives });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Start a simple HTTP server on port 8000
|
||||||
|
python3 -m http.server 8000
|
||||||
Loading…
Reference in New Issue