intial commit
This commit is contained in:
commit
8dc7762aba
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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 }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
@ -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, // 0–1 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/** 0–1 progress toward next level. */
|
||||
get progress() {
|
||||
return this.xp / this.xpToNext;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.xp = 0;
|
||||
this.level = 1;
|
||||
this.xpToNext = this._threshold(1);
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -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.
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Start a simple HTTP server on port 8000
|
||||
python3 -m http.server 8000
|
||||
Loading…
Reference in New Issue