intial commit

This commit is contained in:
Brian Fertig 2026-03-07 11:40:57 -07:00
commit 8dc7762aba
22 changed files with 1209 additions and 0 deletions

View File

@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(node:*)"
]
}
}

52
CLAUDE.md Normal file
View File

@ -0,0 +1,52 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a Phaser 3 HTML game (Smash TV-style top-down arena shooter) built with vanilla JavaScript and ES6 modules. No build tools or bundlers are used — the game runs directly in the browser.
## Running the Game
Since there is no bundler, serve the files with a local HTTP server (ES6 modules require HTTP, not `file://`):
```bash
npx serve .
# or
python3 -m http.server 8080
```
Then open `http://localhost:8080` (or whatever port) in a browser.
## Tech Stack & Constraints
- **Phaser 3** (loaded via CDN or local script tag)
- **Vanilla JavaScript** with ES6 `import`/`export` — no bundler (Webpack, Vite, etc.)
- **1280x720** canvas, scaled to viewport
- Vector graphics placeholder art (no sprites required initially)
## Architecture
### Module Structure
Files use ES6 `import`/`export` directly. Each major concern lives in its own file/class and is imported where needed. Keep things modular so zones, enemies, and skills can be added without touching core logic.
### Scenes
- **IntroScene** — main menu / title screen
- **GameScene** — core gameplay loop (zones, waves, player, enemies)
- **GameOverScene** (or overlay) — shown when all lives lost; press R to return to menu
### Game Loop Concepts
- **Zones** contain sequential **waves** of enemies; waves escalate in difficulty and enemy variety per zone
- **Player**: 3 lives, 100 HP per life; WASD movement; mouse-aimed rotation (fixed turn rate); left-click fires
- **XP & Leveling**: enemies drop XP; level-up pauses the game and shows the skill tree UI
### Skill Tree
- Defined in JSON so it can be extended without code changes
- Branching structure; current root branches:
- **Defense** → Take 10% less damage
- **Offense** → Increase damage by 20% OR Increase fire rate by 40%
- On level-up, pause game and present available skill choices to the player
### Enemy Design
- Enemies increase in difficulty and attack variety with each zone
- Each enemy type should be its own class/file for easy extension

25
index.html Normal file
View File

@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Overrun</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
overflow: hidden;
}
canvas { display: block; }
</style>
</head>
<body>
<script src="phaser.min.js"></script>
<script type="module" src="js/main.js"></script>
</body>
</html>

60
js/data/skillTree.json Normal file
View File

@ -0,0 +1,60 @@
{
"nodes": [
{
"id": "defense",
"label": "Defense",
"description": "Take 10% less damage",
"parent": null,
"effect": { "stat": "damageReduction", "add": 0.10 }
},
{
"id": "offense_damage",
"label": "Offense: Power",
"description": "Increase damage by 20%",
"parent": null,
"effect": { "stat": "damage", "multiply": 1.20 }
},
{
"id": "offense_firerate",
"label": "Offense: Speed",
"description": "Increase fire rate by 40%",
"parent": null,
"effect": { "stat": "fireRate", "multiply": 1.40 }
},
{
"id": "defense_hp",
"label": "Iron Will",
"description": "Gain +25 max HP",
"parent": "defense",
"effect": { "stat": "maxHp", "add": 25 }
},
{
"id": "defense_regen",
"label": "Resilience",
"description": "Take 15% less damage",
"parent": "defense",
"effect": { "stat": "damageReduction", "add": 0.15 }
},
{
"id": "offense_pierce",
"label": "Piercing Shots",
"description": "Increase damage by 30%",
"parent": "offense_damage",
"effect": { "stat": "damage", "multiply": 1.30 }
},
{
"id": "offense_rapidfire",
"label": "Rapid Fire",
"description": "Increase fire rate by 50%",
"parent": "offense_firerate",
"effect": { "stat": "fireRate", "multiply": 1.50 }
},
{
"id": "offense_speed",
"label": "Swift",
"description": "Move 20% faster",
"parent": "offense_firerate",
"effect": { "stat": "speed", "multiply": 1.20 }
}
]
}

19
js/data/zones.json Normal file
View File

@ -0,0 +1,19 @@
[
{
"id": 1,
"waves": [
{ "enemies": [{ "type": "ChaseEnemy", "count": 5 }] },
{ "enemies": [{ "type": "ChaseEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 6 }] },
{ "enemies": [{ "type": "ChaseEnemy", "count": 3 }, { "type": "ShooterEnemy", "count": 2 }] }
]
},
{
"id": 2,
"waves": [
{ "enemies": [{ "type": "ChaseEnemy", "count": 7 }, { "type": "SwarmerEnemy", "count": 8 }] },
{ "enemies": [{ "type": "ShooterEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 10 }] },
{ "enemies": [{ "type": "ChaseEnemy", "count": 5 }, { "type": "ShooterEnemy", "count": 3 }] },
{ "enemies": [{ "type": "ChaseEnemy", "count": 6 }, { "type": "ShooterEnemy", "count": 4 }, { "type": "SwarmerEnemy", "count": 10 }] }
]
}
]

171
js/entities/Player.js Normal file
View File

@ -0,0 +1,171 @@
const TURN_RATE_DEG = 4; // degrees per frame at 60fps
const BULLET_SPEED = 700;
const BULLET_DAMAGE = 20;
const BULLET_SIZE = 5;
const INVINCIBILITY_MS = 1500;
const PLAYER_RADIUS = 16;
const PLAYER_COLOR = 0x00ccff;
const BARREL_LENGTH = 22;
export class Player {
constructor(scene, x, y) {
this.scene = scene;
this.facing = 0; // radians
// Mutable stat block — modified by skill tree
this.stats = {
speed: 200,
damage: BULLET_DAMAGE,
fireRate: 1, // multiplier applied to cooldown
damageReduction: 0, // 01 additive
maxHp: 100,
};
this.hp = this.stats.maxHp;
this.lives = 3;
this.invincible = false;
this._fireCooldown = 0;
this._baseFireInterval = 250; // ms between shots
this._buildGraphics(x, y);
this._setupKeys();
this.bullets = scene.add.group();
}
_buildGraphics(x, y) {
this.body = this.scene.add.circle(x, y, PLAYER_RADIUS, PLAYER_COLOR);
this.barrel = this.scene.add.rectangle(x + BARREL_LENGTH / 2, y, BARREL_LENGTH, 5, 0x0088cc);
this.scene.physics.add.existing(this.body);
this.body.body.setCollideWorldBounds(true);
this.body.body.setCircle(PLAYER_RADIUS);
}
_setupKeys() {
this.keys = this.scene.input.keyboard.addKeys({
up: Phaser.Input.Keyboard.KeyCodes.W,
down: Phaser.Input.Keyboard.KeyCodes.S,
left: Phaser.Input.Keyboard.KeyCodes.A,
right: Phaser.Input.Keyboard.KeyCodes.D,
});
}
get x() { return this.body.x; }
get y() { return this.body.y; }
get active() { return this.body.active; }
update(delta) {
this._move();
this._rotateFacing(delta);
this._updateBarrel();
this._handleFire(delta);
this._updateBullets();
}
_move() {
let dx = 0, dy = 0;
if (this.keys.left.isDown) dx -= 1;
if (this.keys.right.isDown) dx += 1;
if (this.keys.up.isDown) dy -= 1;
if (this.keys.down.isDown) dy += 1;
if (dx !== 0 && dy !== 0) { dx *= 0.707; dy *= 0.707; }
this.body.body.setVelocity(dx * this.stats.speed, dy * this.stats.speed);
}
_rotateFacing(delta) {
const ptr = this.scene.input.activePointer;
const targetAngle = Phaser.Math.Angle.Between(this.x, this.y, ptr.worldX, ptr.worldY);
const maxTurn = Phaser.Math.DegToRad(TURN_RATE_DEG) * (delta / (1000 / 60));
this.facing = Phaser.Math.Angle.RotateTo(this.facing, targetAngle, maxTurn);
}
_updateBarrel() {
const bx = this.x + Math.cos(this.facing) * (PLAYER_RADIUS + BARREL_LENGTH / 2);
const by = this.y + Math.sin(this.facing) * (PLAYER_RADIUS + BARREL_LENGTH / 2);
this.barrel.setPosition(bx, by);
this.barrel.setRotation(this.facing);
}
_handleFire(delta) {
this._fireCooldown -= delta;
const ptr = this.scene.input.activePointer;
if (ptr.isDown && this._fireCooldown <= 0) {
this._fireCooldown = this._baseFireInterval / this.stats.fireRate;
this._spawnBullet();
}
}
_spawnBullet() {
const bx = this.x + Math.cos(this.facing) * (PLAYER_RADIUS + BARREL_LENGTH);
const by = this.y + Math.sin(this.facing) * (PLAYER_RADIUS + BARREL_LENGTH);
const bullet = this.scene.add.circle(bx, by, BULLET_SIZE, 0xffff00);
this.scene.physics.add.existing(bullet);
bullet.body.setVelocity(
Math.cos(this.facing) * BULLET_SPEED,
Math.sin(this.facing) * BULLET_SPEED
);
bullet.damage = this.stats.damage;
this.bullets.add(bullet);
}
_updateBullets() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
this.bullets.getChildren().forEach(b => {
if (b.x < -20 || b.x > W + 20 || b.y < -20 || b.y > H + 20) {
b.destroy();
}
});
}
takeDamage(amount) {
if (this.invincible) return;
const reduced = amount * (1 - Math.min(this.stats.damageReduction, 0.9));
this.hp -= reduced;
if (this.hp <= 0) {
this.hp = 0;
this._loseLife();
}
}
_loseLife() {
this.lives--;
if (this.lives <= 0) {
this.scene.events.emit('game-over');
return;
}
this.hp = this.stats.maxHp;
this._startInvincibility();
}
_startInvincibility() {
this.invincible = true;
// Flash effect
this.scene.tweens.add({
targets: [this.body, this.barrel],
alpha: 0,
duration: 150,
yoyo: true,
repeat: 4,
onComplete: () => {
this.body.setAlpha(1);
this.barrel.setAlpha(1);
this.invincible = false;
}
});
}
respawn(x, y) {
this.body.setPosition(x, y);
this.barrel.setPosition(x, y);
this.hp = this.stats.maxHp;
this._startInvincibility();
}
destroy() {
this.body.destroy();
this.barrel.destroy();
this.bullets.clear(true, true);
}
}

View File

@ -0,0 +1,86 @@
const HP_BAR_WIDTH = 30;
const HP_BAR_HEIGHT = 4;
const HP_BAR_OFFSET_Y = -20;
export class BaseEnemy {
/**
* @param {Phaser.Scene} scene
* @param {number} x
* @param {number} y
* @param {object} player - Player instance
* @param {object} config - { color, radius, hp, speed, xp, contactDamage }
*/
constructor(scene, x, y, player, config) {
this.scene = scene;
this.player = player;
this.maxHp = config.hp;
this.hp = config.hp;
this.speed = config.speed;
this.xp = config.xp;
this.contactDamage = config.contactDamage ?? 10;
this.radius = config.radius ?? 14;
this.body = scene.add.circle(x, y, this.radius, config.color ?? 0xff4444);
scene.physics.add.existing(this.body);
this.body.body.setCircle(this.radius);
// HP bar
this._hpBg = scene.add.rectangle(x, y + HP_BAR_OFFSET_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x333333);
this._hpFill = scene.add.rectangle(x, y + HP_BAR_OFFSET_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, 0x00ff44);
this._contactTimer = 0;
}
get x() { return this.body.x; }
get y() { return this.body.y; }
get active() { return this.body?.active ?? false; }
takeDamage(amount) {
this.hp -= amount;
this._updateHpBar();
if (this.hp <= 0) this._die();
}
_updateHpBar() {
const ratio = Math.max(0, this.hp / this.maxHp);
this._hpFill.width = HP_BAR_WIDTH * ratio;
this._hpFill.x = this.x - (HP_BAR_WIDTH * (1 - ratio)) / 2;
}
_syncBarPosition() {
this._hpBg.setPosition(this.x, this.y + HP_BAR_OFFSET_Y);
this._hpFill.setPosition(
this.x - (HP_BAR_WIDTH * (1 - Math.max(0, this.hp / this.maxHp))) / 2,
this.y + HP_BAR_OFFSET_Y
);
}
_die() {
this.scene.events.emit('enemy-killed', { xp: this.xp, x: this.x, y: this.y });
this.destroy();
}
/** Check contact damage with player. Call from subclass update(). */
_checkContact(delta) {
this._contactTimer -= delta;
if (this._contactTimer > 0) return;
const dist = Phaser.Math.Distance.Between(this.x, this.y, this.player.x, this.player.y);
if (dist < this.radius + 16) {
this.player.takeDamage(this.contactDamage);
this._contactTimer = 800; // ms between contact hits
}
}
update(delta) {
this._syncBarPosition();
this._checkContact(delta);
}
destroy() {
this.body?.destroy();
this._hpBg?.destroy();
this._hpFill?.destroy();
}
}

View File

@ -0,0 +1,23 @@
import { BaseEnemy } from './BaseEnemy.js';
export class ChaseEnemy extends BaseEnemy {
constructor(scene, x, y, player) {
super(scene, x, y, player, {
color: 0xff4444,
radius: 14,
hp: 40,
speed: 80,
xp: 15,
contactDamage: 12,
});
}
update(delta) {
super.update(delta);
const angle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y);
this.body.body.setVelocity(
Math.cos(angle) * this.speed,
Math.sin(angle) * this.speed
);
}
}

View File

@ -0,0 +1,86 @@
import { BaseEnemy } from './BaseEnemy.js';
const PREFERRED_DIST = 300;
const SHOOT_INTERVAL = 2000; // ms
const PROJECTILE_SPEED = 200;
const PROJECTILE_DAMAGE = 10;
const PROJECTILE_COLOR = 0xff00ff;
export class ShooterEnemy extends BaseEnemy {
constructor(scene, x, y, player) {
super(scene, x, y, player, {
color: 0xaa44ff,
radius: 16,
hp: 60,
speed: 50,
xp: 30,
contactDamage: 8,
});
this._shootTimer = SHOOT_INTERVAL * Math.random(); // stagger first shot
this.projectiles = scene.add.group();
}
update(delta) {
super.update(delta);
this._strafe();
this._handleShoot(delta);
this._updateProjectiles();
}
_strafe() {
const dist = Phaser.Math.Distance.Between(this.x, this.y, this.player.x, this.player.y);
const angle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y);
let vx = 0, vy = 0;
if (dist > PREFERRED_DIST + 30) {
// Move closer
vx = Math.cos(angle) * this.speed;
vy = Math.sin(angle) * this.speed;
} else if (dist < PREFERRED_DIST - 30) {
// Move away
vx = -Math.cos(angle) * this.speed;
vy = -Math.sin(angle) * this.speed;
} else {
// Strafe perpendicular
const perp = angle + Math.PI / 2;
vx = Math.cos(perp) * this.speed;
vy = Math.sin(perp) * this.speed;
}
this.body.body.setVelocity(vx, vy);
}
_handleShoot(delta) {
this._shootTimer -= delta;
if (this._shootTimer <= 0) {
this._shootTimer = SHOOT_INTERVAL;
this._fireProjectile();
}
}
_fireProjectile() {
const angle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y);
const proj = this.scene.add.circle(this.x, this.y, 6, PROJECTILE_COLOR);
this.scene.physics.add.existing(proj);
proj.body.setVelocity(
Math.cos(angle) * PROJECTILE_SPEED,
Math.sin(angle) * PROJECTILE_SPEED
);
proj.damage = PROJECTILE_DAMAGE;
this.projectiles.add(proj);
// Register with scene for collision
this.scene.events.emit('enemy-projectile-spawned', proj);
}
_updateProjectiles() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
this.projectiles.getChildren().forEach(p => {
if (p.x < -30 || p.x > W + 30 || p.y < -30 || p.y > H + 30) p.destroy();
});
}
destroy() {
this.projectiles.clear(true, true);
super.destroy();
}
}

View File

@ -0,0 +1,28 @@
import { BaseEnemy } from './BaseEnemy.js';
export class SwarmerEnemy extends BaseEnemy {
constructor(scene, x, y, player) {
super(scene, x, y, player, {
color: 0xff9900,
radius: 8,
hp: 15,
speed: 160,
xp: 8,
contactDamage: 8,
});
// Slight random offset so swarms don't stack perfectly
this._offsetAngle = Math.random() * Math.PI * 2;
this._wobble = Math.random() * 0.5;
}
update(delta) {
super.update(delta);
const t = this.scene.time.now * 0.001;
const baseAngle = Phaser.Math.Angle.Between(this.x, this.y, this.player.x, this.player.y);
const angle = baseAngle + Math.sin(t * 3 + this._offsetAngle) * this._wobble;
this.body.body.setVelocity(
Math.cos(angle) * this.speed,
Math.sin(angle) * this.speed
);
}
}

24
js/main.js Normal file
View File

@ -0,0 +1,24 @@
import { IntroScene } from './scenes/IntroScene.js';
import { GameScene } from './scenes/GameScene.js';
import { GameOverScene } from './scenes/GameOverScene.js';
const config = {
type: Phaser.AUTO,
width: 1280,
height: 720,
backgroundColor: '#111118',
physics: {
default: 'arcade',
arcade: {
gravity: { y: 0 },
debug: false,
},
},
scale: {
mode: Phaser.Scale.FIT,
autoCenter: Phaser.Scale.CENTER_BOTH,
},
scene: [IntroScene, GameScene, GameOverScene],
};
window.__game = new Phaser.Game(config);

View File

@ -0,0 +1,33 @@
export class GameOverScene extends Phaser.Scene {
constructor() {
super({ key: 'GameOverScene' });
}
create() {
const W = this.scale.width;
const H = this.scale.height;
this.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.75);
this.add.text(W / 2, H / 2 - 60, 'GAME OVER', {
fontSize: '64px',
fill: '#ff3333',
fontStyle: 'bold',
stroke: '#660000',
strokeThickness: 6,
}).setOrigin(0.5);
const prompt = this.add.text(W / 2, H / 2 + 30, 'Press R to return to menu', {
fontSize: '24px',
fill: '#ffffff',
}).setOrigin(0.5);
this.tweens.add({ targets: prompt, alpha: 0, duration: 700, yoyo: true, repeat: -1 });
this.input.keyboard.once('keydown-R', () => {
this.scene.stop('GameScene');
this.scene.stop('GameOverScene');
this.scene.start('IntroScene');
});
}
}

168
js/scenes/GameScene.js Normal file
View File

@ -0,0 +1,168 @@
import { Player } from '../entities/Player.js';
import { WaveManager } from '../systems/WaveManager.js';
import { XPSystem } from '../systems/XPSystem.js';
import { SkillTree } from '../systems/SkillTree.js';
import { HUD } from '../ui/HUD.js';
import { SkillTreeUI } from '../ui/SkillTreeUI.js';
export class GameScene extends Phaser.Scene {
constructor() {
super({ key: 'GameScene' });
}
preload() {
this.load.json('zones', './js/data/zones.json');
this.load.json('skillTree', './js/data/skillTree.json');
}
create() {
const W = this.scale.width;
const H = this.scale.height;
this._drawArena(W, H);
this.physics.world.setBounds(0, 0, W, H);
// Core systems — data already loaded by preload()
this.skillTree = new SkillTree();
this.skillTree.load(this.cache.json.get('skillTree'));
this.player = new Player(this, W / 2, H / 2);
this.xpSystem = new XPSystem(this);
this.waveManager = new WaveManager(this, this.player);
this.waveManager.load(this.cache.json.get('zones'));
this.hud = new HUD(this, this.player, this.xpSystem);
// Enemy projectile group for collision
this._enemyProjectiles = [];
// Event wiring
this.events.on('enemy-killed', ({ xp }) => this.xpSystem.addXP(xp));
this.events.on('enemy-projectile-spawned', proj => this._enemyProjectiles.push(proj));
this.events.on('level-up', level => this._showLevelUp(level));
this.events.on('game-over', () => this._onGameOver());
this.events.on('victory', () => this._onVictory());
this._levelUpPending = false;
this._frozen = false;
this.waveManager.start();
}
_drawArena(W, H) {
// Dark background
this.add.rectangle(W / 2, H / 2, W, H, 0x111118);
// Grid lines for depth cue
const g = this.add.graphics();
g.lineStyle(1, 0x222233, 0.5);
for (let x = 0; x <= W; x += 80) { g.lineBetween(x, 0, x, H); }
for (let y = 0; y <= H; y += 80) { g.lineBetween(0, y, W, y); }
// Arena border
g.lineStyle(3, 0x334466, 1);
g.strokeRect(2, 2, W - 4, H - 4);
}
update(time, delta) {
if (!this.player || this._frozen) return;
this.player.update(delta);
this.waveManager.update(delta);
this.hud.update();
this._checkBulletHits();
this._checkEnemyProjectileHits();
this._pruneEnemyProjectiles();
}
_checkBulletHits() {
const bullets = this.player.bullets.getChildren();
const enemies = this.waveManager.enemies;
for (const bullet of bullets) {
if (!bullet.active) continue;
for (const enemy of enemies) {
if (!enemy.active) continue;
const dist = Phaser.Math.Distance.Between(bullet.x, bullet.y, enemy.x, enemy.y);
if (dist < enemy.radius + 5) {
enemy.takeDamage(bullet.damage);
bullet.destroy();
break;
}
}
}
}
_checkEnemyProjectileHits() {
this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active);
this._enemyProjectiles.forEach(proj => {
if (!proj.active) return;
const dist = Phaser.Math.Distance.Between(proj.x, proj.y, this.player.x, this.player.y);
if (dist < 16 + 6) {
this.player.takeDamage(proj.damage);
proj.destroy();
}
});
}
_pruneEnemyProjectiles() {
this._enemyProjectiles = this._enemyProjectiles.filter(p => p?.active);
}
_showLevelUp(level) {
if (this._levelUpPending) return;
this._levelUpPending = true;
// Only show if there are skills available
const available = this.skillTree.getAvailable();
if (available.length === 0) {
this._levelUpPending = false;
return;
}
this._frozen = true;
this.physics.world.pause();
new SkillTreeUI(this, this.skillTree, this.player, () => {
this._levelUpPending = false;
this._frozen = false;
this.physics.world.resume();
});
}
_onGameOver() {
this.scene.launch('GameOverScene');
this.scene.pause();
}
_onVictory() {
const W = this.scale.width;
const H = this.scale.height;
this.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.7).setDepth(30);
this.add.text(W / 2, H / 2 - 40, 'VICTORY!', {
fontSize: '72px', fill: '#ffdd00', fontStyle: 'bold'
}).setOrigin(0.5).setDepth(31);
const prompt = this.add.text(W / 2, H / 2 + 60, 'Press R to return to menu', {
fontSize: '22px', fill: '#ffffff'
}).setOrigin(0.5).setDepth(31);
this.tweens.add({ targets: prompt, alpha: 0, duration: 700, yoyo: true, repeat: -1 });
this.input.keyboard.once('keydown-R', () => {
this.scene.start('IntroScene');
});
}
shutdown() {
this.events.removeAllListeners();
this.player?.destroy();
this.waveManager?.reset();
this.hud?.destroy();
this._enemyProjectiles = [];
}
}

52
js/scenes/IntroScene.js Normal file
View File

@ -0,0 +1,52 @@
export class IntroScene extends Phaser.Scene {
constructor() {
super({ key: 'IntroScene' });
}
create() {
const W = this.scale.width;
const H = this.scale.height;
this.add.rectangle(W / 2, H / 2, W, H, 0x000000);
this.add.text(W / 2, H / 2 - 100, 'OVERRUN', {
fontSize: '72px',
fill: '#00ccff',
fontStyle: 'bold',
stroke: '#003366',
strokeThickness: 6,
}).setOrigin(0.5);
this.add.text(W / 2, H / 2, 'Survive the waves. Choose your power.', {
fontSize: '20px',
fill: '#aaaaaa',
}).setOrigin(0.5);
const prompt = this.add.text(W / 2, H / 2 + 80, 'Press ENTER or click to start', {
fontSize: '22px',
fill: '#ffffff',
}).setOrigin(0.5);
// Blink the prompt
this.tweens.add({
targets: prompt,
alpha: 0,
duration: 600,
yoyo: true,
repeat: -1,
});
// Controls hint
this.add.text(W / 2, H - 60, 'WASD — Move Mouse — Aim Left Click — Fire', {
fontSize: '14px',
fill: '#666666',
}).setOrigin(0.5);
this.input.keyboard.once('keydown-ENTER', () => this._start());
this.input.once('pointerdown', () => this._start());
}
_start() {
this.scene.start('GameScene');
}
}

40
js/systems/SkillTree.js Normal file
View File

@ -0,0 +1,40 @@
export class SkillTree {
constructor() {
this.nodes = [];
this.chosen = new Set();
}
load(data) {
this.nodes = data.nodes;
}
/** Returns nodes available to pick at next level-up (roots or children of chosen). */
getAvailable() {
return this.nodes.filter(n => {
if (this.chosen.has(n.id)) return false;
if (n.parent === null) return true;
return this.chosen.has(n.parent);
});
}
/** Apply a node's effect to the player stats object and mark it chosen. */
applyNode(nodeId, playerStats) {
const node = this.nodes.find(n => n.id === nodeId);
if (!node) return;
const { stat, add, multiply } = node.effect;
if (add !== undefined) {
playerStats[stat] = (playerStats[stat] || 0) + add;
}
if (multiply !== undefined) {
playerStats[stat] = (playerStats[stat] || 1) * multiply;
}
this.chosen.add(nodeId);
return node;
}
reset() {
this.chosen.clear();
}
}

110
js/systems/WaveManager.js Normal file
View File

@ -0,0 +1,110 @@
import { ChaseEnemy } from '../entities/enemies/ChaseEnemy.js';
import { ShooterEnemy } from '../entities/enemies/ShooterEnemy.js';
import { SwarmerEnemy } from '../entities/enemies/SwarmerEnemy.js';
const ENEMY_CLASSES = { ChaseEnemy, ShooterEnemy, SwarmerEnemy };
const SPAWN_MARGIN = 40;
export class WaveManager {
constructor(scene, player) {
this.scene = scene;
this.player = player;
this.zones = [];
this.zoneIndex = 0;
this.waveIndex = 0;
this.enemies = [];
this.active = false;
}
load(data) {
this.zones = data;
}
start() {
this.active = true;
this._spawnWave();
}
get currentZone() { return this.zones[this.zoneIndex]; }
get currentWave() { return this.currentZone?.waves[this.waveIndex]; }
get zoneNum() { return this.zoneIndex + 1; }
get waveNum() { return this.waveIndex + 1; }
get totalWaves() { return this.currentZone?.waves.length ?? 0; }
_spawnWave() {
const wave = this.currentWave;
if (!wave) return;
this.scene.events.emit('wave-start', { zone: this.zoneNum, wave: this.waveNum, totalWaves: this.totalWaves });
wave.enemies.forEach(({ type, count }) => {
const EnemyClass = ENEMY_CLASSES[type];
if (!EnemyClass) return;
for (let i = 0; i < count; i++) {
const [x, y] = this._edgePosition();
const enemy = new EnemyClass(this.scene, x, y, this.player);
this.enemies.push(enemy);
}
});
}
_edgePosition() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
const side = Phaser.Math.Between(0, 3);
switch (side) {
case 0: return [Phaser.Math.Between(0, W), -SPAWN_MARGIN];
case 1: return [W + SPAWN_MARGIN, Phaser.Math.Between(0, H)];
case 2: return [Phaser.Math.Between(0, W), H + SPAWN_MARGIN];
case 3: return [-SPAWN_MARGIN, Phaser.Math.Between(0, H)];
}
}
update(delta) {
if (!this.active) return;
// Prune dead enemies
this.enemies = this.enemies.filter(e => e.active);
// Update living enemies
this.enemies.forEach(e => e.update(delta));
// Check wave clear
if (this.enemies.length === 0) {
this.active = false;
this.scene.time.delayedCall(1500, () => this._advance());
}
}
_advance() {
this.waveIndex++;
if (this.waveIndex >= this.currentZone.waves.length) {
// Zone complete
this.waveIndex = 0;
this.zoneIndex++;
if (this.zoneIndex >= this.zones.length) {
this.scene.events.emit('victory');
return;
}
this.scene.events.emit('zone-clear', { zone: this.zoneNum });
this.scene.time.delayedCall(2500, () => {
this.active = true;
this._spawnWave();
});
} else {
this.active = true;
this._spawnWave();
}
}
reset() {
this.enemies.forEach(e => { if (e.active) e.destroy(); });
this.enemies = [];
this.zoneIndex = 0;
this.waveIndex = 0;
this.active = false;
}
}

34
js/systems/XPSystem.js Normal file
View File

@ -0,0 +1,34 @@
export class XPSystem {
constructor(scene) {
this.scene = scene;
this.xp = 0;
this.level = 1;
this.xpToNext = this._threshold(1);
}
/** XP needed to go from level `lvl` to `lvl+1`. */
_threshold(lvl) {
return 100 + (lvl - 1) * 75;
}
addXP(amount) {
this.xp += amount;
while (this.xp >= this.xpToNext) {
this.xp -= this.xpToNext;
this.level++;
this.xpToNext = this._threshold(this.level);
this.scene.events.emit('level-up', this.level);
}
}
/** 01 progress toward next level. */
get progress() {
return this.xp / this.xpToNext;
}
reset() {
this.xp = 0;
this.level = 1;
this.xpToNext = this._threshold(1);
}
}

78
js/ui/HUD.js Normal file
View File

@ -0,0 +1,78 @@
const PAD = 14;
const HP_W = 180;
const HP_H = 16;
const XP_W = 180;
const XP_H = 10;
const LIFE_R = 8;
const LIFE_GAP = 22;
export class HUD {
constructor(scene, player, xpSystem) {
this.scene = scene;
this.player = player;
this.xpSystem = xpSystem;
// Fix to camera
const cam = scene.cameras.main;
const cx = cam.x;
const cy = cam.y;
// --- HP Bar ---
this._hpLabel = scene.add.text(PAD, PAD, 'HP', { fontSize: '13px', fill: '#ffffff' }).setScrollFactor(0).setDepth(10);
this._hpBg = scene.add.rectangle(PAD + HP_W / 2, PAD + 8, HP_W, HP_H, 0x333333).setScrollFactor(0).setDepth(10);
this._hpFill = scene.add.rectangle(PAD + HP_W / 2, PAD + 8, HP_W, HP_H, 0xff3333).setScrollFactor(0).setDepth(10);
this._hpLabel.setY(PAD - 2);
this._hpBg.setY(PAD + 20);
this._hpFill.setY(PAD + 20);
// --- Life icons ---
this._lifeIcons = [];
for (let i = 0; i < 3; i++) {
const lx = PAD + LIFE_R + i * LIFE_GAP;
const icon = scene.add.circle(lx, PAD + 48, LIFE_R, 0x00ccff).setScrollFactor(0).setDepth(10);
this._lifeIcons.push(icon);
}
// --- XP Bar ---
const xpY = PAD + 64;
this._xpBg = scene.add.rectangle(PAD + XP_W / 2, xpY, XP_W, XP_H, 0x333333).setScrollFactor(0).setDepth(10);
this._xpFill = scene.add.rectangle(PAD + XP_W / 2, xpY, XP_W, XP_H, 0x44ffaa).setScrollFactor(0).setDepth(10);
this._xpLabel = scene.add.text(PAD, xpY + 8, 'Level 1', { fontSize: '12px', fill: '#aaffcc' }).setScrollFactor(0).setDepth(10);
// --- Zone/Wave indicator ---
this._zoneText = scene.add.text(0, PAD, 'Zone 1 — Wave 1/3', { fontSize: '14px', fill: '#ffffff' })
.setScrollFactor(0).setDepth(10).setOrigin(1, 0);
this._zoneText.setX(scene.scale.width - PAD);
// Listen for wave/zone updates
scene.events.on('wave-start', ({ zone, wave, totalWaves }) => {
this._zoneText.setText(`Zone ${zone} — Wave ${wave}/${totalWaves}`);
});
scene.events.on('zone-clear', ({ zone }) => {
this._zoneText.setText(`Zone ${zone} Complete!`);
});
}
update() {
// HP bar
const hpRatio = Math.max(0, this.player.hp / this.player.stats.maxHp);
this._hpFill.width = HP_W * hpRatio;
this._hpFill.x = PAD + (HP_W * hpRatio) / 2;
// Lives
this._lifeIcons.forEach((icon, i) => {
icon.setAlpha(i < this.player.lives ? 1 : 0.2);
});
// XP bar
const xpRatio = this.xpSystem.progress;
this._xpFill.width = XP_W * xpRatio;
this._xpFill.x = PAD + (XP_W * xpRatio) / 2;
this._xpLabel.setText(`Level ${this.xpSystem.level}`);
}
destroy() {
[this._hpLabel, this._hpBg, this._hpFill, this._xpBg, this._xpFill, this._xpLabel, this._zoneText, ...this._lifeIcons]
.forEach(o => o?.destroy());
}
}

77
js/ui/SkillTreeUI.js Normal file
View File

@ -0,0 +1,77 @@
const CARD_W = 220;
const CARD_H = 100;
const CARD_GAP = 30;
const CARD_NORMAL = 0x223355;
const CARD_HOVER = 0x3355aa;
const CARD_BORDER = 0x4488ff;
export class SkillTreeUI {
constructor(scene, skillTree, player, onClose) {
this.scene = scene;
this.skillTree = skillTree;
this.player = player;
this.onClose = onClose;
this._elements = [];
this._build();
}
_build() {
const W = this.scene.scale.width;
const H = this.scene.scale.height;
// Dim background
const overlay = this.scene.add.rectangle(W / 2, H / 2, W, H, 0x000000, 0.6).setDepth(20);
this._elements.push(overlay);
// Title
const title = this.scene.add.text(W / 2, H / 2 - 160, `LEVEL UP!`, {
fontSize: '36px', fill: '#ffdd44', fontStyle: 'bold'
}).setOrigin(0.5).setDepth(21);
this._elements.push(title);
const sub = this.scene.add.text(W / 2, H / 2 - 115, 'Choose a skill:', {
fontSize: '18px', fill: '#aaaaaa'
}).setOrigin(0.5).setDepth(21);
this._elements.push(sub);
// Skill cards
const available = this.skillTree.getAvailable();
const total = available.length;
const startX = W / 2 - ((CARD_W + CARD_GAP) * (total - 1)) / 2;
available.forEach((node, i) => {
const cx = startX + i * (CARD_W + CARD_GAP);
const cy = H / 2 + 20;
const card = this.scene.add.rectangle(cx, cy, CARD_W, CARD_H, CARD_NORMAL)
.setStrokeStyle(2, CARD_BORDER)
.setInteractive({ useHandCursor: true })
.setDepth(21);
const label = this.scene.add.text(cx, cy - 18, node.label, {
fontSize: '16px', fill: '#ffffff', fontStyle: 'bold', wordWrap: { width: CARD_W - 20 }
}).setOrigin(0.5).setDepth(22);
const desc = this.scene.add.text(cx, cy + 18, node.description, {
fontSize: '13px', fill: '#aaccff', wordWrap: { width: CARD_W - 20 }
}).setOrigin(0.5).setDepth(22);
card.on('pointerover', () => card.setFillStyle(CARD_HOVER));
card.on('pointerout', () => card.setFillStyle(CARD_NORMAL));
card.on('pointerdown', () => this._select(node.id));
this._elements.push(card, label, desc);
});
}
_select(nodeId) {
this.skillTree.applyNode(nodeId, this.player.stats);
this._destroy();
this.onClose();
}
_destroy() {
this._elements.forEach(e => e?.destroy());
this._elements = [];
}
}

1
phaser.min.js vendored Normal file

File diff suppressed because one or more lines are too long

31
software.md Normal file
View File

@ -0,0 +1,31 @@
# Build Guidelines
Create an HTML Phaser 3 video game. The game should be similar to the old video game "Smash TV". The player will work his way through multiple zones. Each zone will consist of attack waves of different types of enemies. The enemies will get harder with each zone and have more attack options as well. The player will have three lives 100 base health points per life. When the player kills enemies he will earn XP. Create a meaningful XP and Level schedule as part of the game development. When the player earns enough XP and gets to the next level, the game should pause and a UI should show a skill tree that the player can pick a new skill from. This skill tree should be something that branches and I can edit later with JSON. The first initial options to the player in the skill tree are going to be:
- Defense
- Take 10% less damage
- Offense
- Increase damage by 20%
- Increase fire rate by 40%
## Tools and Organization
- Phaser version 3 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
- 1280 x 720 view
- Scale view to user's viewport.
- Use basic termporary vector graphics that can later be replaced by sprites
## Game Flow
- Create an intro scene before the game starts.
- When the player loses all lives show a Game Over overlay, and allow the player to press R to return to the main menu
## Controls
- the player should face in the direction of the mouse, as the mouse moves around the player's direction follows it but also can only turn at a fixed rate (you pick the rate that makes sense).
- W A S D should control the direction the players body moves.
- Left Mouse button fires.

4
start_web.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash
# Start a simple HTTP server on port 8000
python3 -m http.server 8000