chore: publish from staged

This commit is contained in:
github-actions[bot]
2026-06-17 09:29:58 +00:00
parent 2a34c9cc3b
commit d99c92d943
128 changed files with 11730 additions and 0 deletions
@@ -0,0 +1,812 @@
// AlienOnslaught — Space Invaders-style arcade shooter.
// Rows of aliens march across the screen, descending as they reach the
// edges. The player defends from the bottom with destructible shields.
// All graphics are procedural (Phaser Graphics) — no external sprite sheets.
import { BaseScene, W, H } from './BaseScene.js';
/* ------------------------------------------------------------------ */
/* Constants — recalculated in create() for responsive sizing */
/* ------------------------------------------------------------------ */
let SCALE = Math.min(W / 1920, H / 1080);
// Grid layout
const ALIEN_COLS = 11;
const ALIEN_ROWS = 5;
// Alien types per row (top → bottom): squid, crab, crab, octopus, octopus
const ALIEN_TYPES = [
{ name: 'squid', points: 30, color: 0xff4444 }, // row 0 (top)
{ name: 'crab', points: 20, color: 0x44ff44 }, // rows 1-2
{ name: 'octopus', points: 10, color: 0x44aaff }, // rows 3-4
];
// Timing / speeds
const BASE_MARCH_INTERVAL = 700; // ms between march steps at full grid
const MIN_MARCH_INTERVAL = 60; // fastest march with few aliens left
const MARCH_DROP = 0; // calculated in create()
const PLAYER_SPEED = 350; // px/s
const PLAYER_BULLET_SPEED = 500; // px/s
const ALIEN_BULLET_SPEED = 250; // px/s
const ALIEN_FIRE_INTERVAL = 1200; // ms between alien shots (base)
const MYSTERY_INTERVAL_MIN = 15000;
const MYSTERY_INTERVAL_MAX = 30000;
const MYSTERY_SPEED = 150; // px/s
const INVINCIBLE_TIME = 2000; // ms
// Shield config
const SHIELD_COUNT = 4;
const SHIELD_BLOCK_COLS = 22;
const SHIELD_BLOCK_ROWS = 16;
/* ------------------------------------------------------------------ */
/* Scene */
/* ------------------------------------------------------------------ */
export class AlienOnslaughtScene extends BaseScene {
/* player */
playerGfx;
playerX = 0;
playerY = 0;
playerAlive = true;
/* aliens */
aliens = [];
alienCellW = 0;
alienCellH = 0;
alienGridX = 0; // grid origin
alienGridY = 0;
marchDir = 1; // 1 = right, -1 = left
marchTimer = 0;
marchInterval = BASE_MARCH_INTERVAL;
marchStepX = 0;
marchDrop = 0;
/* bullets */
playerBullets = [];
alienBullets = [];
alienFireTimer = 0;
/* mystery ship */
mystery = null;
mysteryTimer = 0;
/* shields */
shields = []; // [shieldIdx][blockIdx]
/* starfield */
stars = [];
/* game state */
wave = 0;
invincibleTimer = 0;
respawnTimer = 0;
gameOverFlag = false;
waveDelay = 0;
/* input */
cursors;
spaceKey;
spaceWasDown = false;
/* sizing (calculated in create) */
alienW = 0;
alienH = 0;
playerW = 0;
playerH = 0;
bulletW = 0;
bulletH = 0;
constructor() { super('alien-onslaught'); }
get displayName() { return 'Alien Onslaught'; }
getDescription() {
return 'Blast waves of descending aliens before they reach the bottom!';
}
getControls() {
return [
{ key: '← →', action: 'Move Left / Right' },
{ key: 'SPACE', action: 'Fire' },
];
}
/* ================================================================
LIFECYCLE
================================================================ */
preload() {
// Reuse existing sound effects
this.load.audio('ao_laser', '../assets/galaxy-blaster/sounds/sfx_laser1.ogg');
this.load.audio('ao_explosion', '../assets/galaxy-blaster/sounds/sfx_explosion.ogg');
this.load.audio('ao_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg');
this.load.audio('ao_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg');
this.load.audio('ao_shieldHit', '../assets/galaxy-blaster/sounds/sfx_zap.ogg');
this.load.audio('ao_mystery', '../assets/galaxy-blaster/sounds/sfx_twoTone.ogg');
}
create() {
this.initBase();
// Responsive sizing — scale the grid to fill ~70% of screen width
SCALE = Math.min(W / 1920, H / 1080);
const s = Math.max(SCALE, 0.5);
// Size the grid relative to screen, not a fixed pixel size
this.alienCellW = Math.round(W * 0.055); // ~85% of original — tighter grid
this.alienCellH = Math.round(this.alienCellW * 0.8);
this.alienW = Math.round(this.alienCellW * 0.6);
this.alienH = Math.round(this.alienCellH * 0.55);
this.playerW = Math.round(this.alienCellW * 0.85);
this.playerH = Math.round(this.playerW * 0.55);
this.bulletW = Math.round(4 * s);
this.bulletH = Math.round(12 * s);
this.marchStepX = Math.round(this.alienCellW * 0.25); // bigger steps → hit edges sooner
this.marchDrop = Math.round(this.alienCellH * 0.6); // bigger drops → descend faster
// Reset state
this.score = 0;
this.lives = 3;
this.wave = 0;
this.playerAlive = true;
this.gameOverFlag = false;
this.invincibleTimer = INVINCIBLE_TIME;
this.respawnTimer = 0;
this.waveDelay = 0;
this.marchDir = 1;
this.marchTimer = 0;
this.marchInterval = BASE_MARCH_INTERVAL;
this.playerBullets = [];
this.alienBullets = [];
this.aliens = [];
this.shields = [];
this.mystery = null;
this.mysteryTimer = MYSTERY_INTERVAL_MIN + Math.random() * (MYSTERY_INTERVAL_MAX - MYSTERY_INTERVAL_MIN);
this.stars = [];
this.ensureSparkTexture();
// Starfield
this.stars = this.createStarfield([
{ count: 40, speed: 10, size: 1, alpha: 0.2 },
{ count: 25, speed: 20, size: 1.5, alpha: 0.3 },
{ count: 10, speed: 40, size: 2, alpha: 0.4 },
]);
// Player position — bottom of screen with padding
this.playerX = W / 2;
this.playerY = H * 0.92;
this.playerGfx = this.add.graphics().setDepth(10);
this.drawPlayer();
// Input
this.cursors = this.input.keyboard.createCursorKeys();
this.spaceKey = this.input.keyboard.addKey('SPACE');
this.spaceWasDown = false;
// HUD
this.syncLivesToHUD();
this.syncScoreToHUD();
this.loadHighScore();
this.startWithReadyScreen(() => this.startWave());
}
update(_t, dtMs) {
if (this.gameOverFlag || !this.cursors)
return;
const dt = Math.min(dtMs, 33);
const dtSec = dt / 1000;
this.updateStarfield(this.stars, dt);
// Respawn delay
if (this.respawnTimer > 0) {
this.respawnTimer -= dt;
if (this.respawnTimer <= 0)
this.respawnPlayer();
}
// Player input
if (this.playerAlive) {
this.updatePlayerInput(dtSec);
}
// Invincibility flicker
if (this.invincibleTimer > 0) {
this.invincibleTimer -= dt;
if (this.playerGfx) {
this.playerGfx.setAlpha(Math.sin(performance.now() / 80) > 0 ? 1 : 0.2);
}
}
else if (this.playerGfx) {
this.playerGfx.setAlpha(1);
}
// Alien march
this.marchTimer += dt;
if (this.marchTimer >= this.marchInterval) {
this.marchTimer = 0;
this.marchAliens();
}
// Alien shooting
this.alienFireTimer += dt;
const fireInterval = Math.max(400, ALIEN_FIRE_INTERVAL - this.wave * 80);
if (this.alienFireTimer >= fireInterval) {
this.alienFireTimer = 0;
this.alienShoot();
}
// Update bullets
this.updatePlayerBullets(dtSec);
this.updateAlienBullets(dtSec);
// Mystery ship
this.updateMystery(dt, dtSec);
// Collisions
this.checkCollisions();
// Wave clear
if (this.waveDelay > 0) {
this.waveDelay -= dt;
if (this.waveDelay <= 0)
this.startWave();
}
else if (this.aliens.filter(a => a.alive).length === 0 && this.waveDelay <= 0) {
this.waveDelay = 1500;
}
}
/* ================================================================
PLAYER
================================================================ */
drawPlayer() {
const g = this.playerGfx;
g.clear();
g.setPosition(this.playerX, this.playerY);
const hw = this.playerW / 2;
const hh = this.playerH / 2;
const turretW = hw * 0.2;
const turretH = hh * 0.6;
// Shadow
g.fillStyle(0x000000, 0.5);
g.fillRect(-hw - 1, -hh - 1, this.playerW + 2, this.playerH + 2);
g.fillRect(-turretW - 1, -hh - turretH - 1, turretW * 2 + 2, turretH + 2);
// Body (bright green)
g.fillStyle(0x00ff66, 1);
g.fillRect(-hw, -hh, this.playerW, this.playerH);
// Turret
g.fillStyle(0x00ff66, 1);
g.fillRect(-turretW, -hh - turretH, turretW * 2, turretH);
// Cockpit highlight
g.fillStyle(0xaaffcc, 0.6);
g.fillRect(-hw * 0.3, -hh * 0.5, hw * 0.6, hh * 0.6);
}
updatePlayerInput(dtSec) {
if (!this.cursors)
return;
if (this.cursors.left.isDown) {
this.playerX -= PLAYER_SPEED * dtSec;
}
if (this.cursors.right.isDown) {
this.playerX += PLAYER_SPEED * dtSec;
}
// Clamp to screen
const hw = this.playerW / 2;
this.playerX = Math.max(hw, Math.min(W - hw, this.playerX));
this.drawPlayer();
// Fire
const spaceDown = this.spaceKey.isDown;
if (spaceDown && !this.spaceWasDown && this.playerBullets.length < 2) {
this.firePlayerBullet();
}
this.spaceWasDown = spaceDown;
}
respawnPlayer() {
this.playerX = W / 2;
this.playerAlive = true;
this.invincibleTimer = INVINCIBLE_TIME;
if (this.playerGfx) {
this.playerGfx.setVisible(true);
}
this.drawPlayer();
}
/* ================================================================
PLAYER BULLETS
================================================================ */
firePlayerBullet() {
this.sound.play('ao_laser', { volume: 0.3 });
const gfx = this.add.graphics().setDepth(8);
const bx = this.playerX;
const by = this.playerY - this.playerH / 2;
// Glow
gfx.fillStyle(0x00ffff, 0.3);
gfx.fillRect(-this.bulletW, -this.bulletH, this.bulletW * 2, this.bulletH * 2);
// Solid
gfx.fillStyle(0x00ffff, 1);
gfx.fillRect(-this.bulletW / 2, -this.bulletH / 2, this.bulletW, this.bulletH);
gfx.setPosition(bx, by);
this.playerBullets.push({ gfx, x: bx, y: by });
}
updatePlayerBullets(dtSec) {
for (let i = this.playerBullets.length - 1; i >= 0; i--) {
const b = this.playerBullets[i];
b.y -= PLAYER_BULLET_SPEED * dtSec;
b.gfx.setPosition(b.x, b.y);
if (b.y < -this.bulletH) {
b.gfx.destroy();
this.playerBullets.splice(i, 1);
}
}
}
/* ================================================================
ALIENS
================================================================ */
startWave() {
this.wave++;
this.level = this.wave;
this.syncLevelToHUD();
this.showWaveBanner(this.wave);
// Clear leftover bullets
for (const b of this.playerBullets)
b.gfx.destroy();
this.playerBullets = [];
for (const b of this.alienBullets)
b.gfx.destroy();
this.alienBullets = [];
// Reset march
this.marchDir = 1;
this.marchTimer = 0;
this.alienFireTimer = 0;
// Calculate grid start position (centered)
const gridW = ALIEN_COLS * this.alienCellW;
this.alienGridX = (W - gridW) / 2;
this.alienGridY = Math.max(H * 0.20, 120);
// Create aliens
for (const a of this.aliens)
a.gfx.destroy();
this.aliens = [];
for (let row = 0; row < ALIEN_ROWS; row++) {
const typeIdx = row === 0 ? 0 : row <= 2 ? 1 : 2;
for (let col = 0; col < ALIEN_COLS; col++) {
const x = this.alienGridX + col * this.alienCellW + this.alienCellW / 2;
const y = this.alienGridY + row * this.alienCellH + this.alienCellH / 2;
const gfx = this.add.graphics().setDepth(5);
const alien = { gfx, row, col, type: typeIdx, alive: true, x, y, frame: 0 };
this.drawAlien(alien);
this.aliens.push(alien);
}
}
this.updateMarchInterval();
// Recreate shields on first wave only
if (this.wave === 1) {
this.createShields();
}
}
drawAlien(alien) {
const g = alien.gfx;
g.clear();
g.setPosition(alien.x, alien.y);
const type = ALIEN_TYPES[alien.type];
const hw = this.alienW / 2;
const hh = this.alienH / 2;
const px = Math.max(2, Math.round(this.alienW / 10)); // pixel unit size
// Draw pixel-art alien based on type
g.fillStyle(type.color, 1);
if (type.name === 'squid') {
// Squid alien — narrow top, wider middle
g.fillRect(-px, -hh, px * 2, px); // top antenna
g.fillRect(-px * 2, -hh + px, px * 4, px); // head top
g.fillRect(-px * 3, -hh + px * 2, px * 6, px * 2); // head body
g.fillRect(-px * 4, -hh + px * 4, px * 8, px); // wider
g.fillRect(-px * 3, -hh + px * 5, px * 6, px); // middle
if (alien.frame === 0) {
// legs out
g.fillRect(-px * 4, -hh + px * 6, px * 2, px);
g.fillRect(px * 2, -hh + px * 6, px * 2, px);
}
else {
// legs in
g.fillRect(-px * 2, -hh + px * 6, px * 2, px);
g.fillRect(0, -hh + px * 6, px * 2, px);
}
}
else if (type.name === 'crab') {
// Crab alien — classic shape with claws
g.fillRect(-px, -hh, px * 2, px); // antenna
g.fillRect(-px * 3, -hh + px, px * 6, px); // top
g.fillRect(-px * 4, -hh + px * 2, px * 8, px * 2); // body
g.fillRect(-px * 5, -hh + px * 4, px * 10, px); // wide row
g.fillRect(-px * 4, -hh + px * 5, px * 8, px); // narrower
// Eyes (dark cutouts)
g.fillStyle(0x000000, 1);
g.fillRect(-px * 2, -hh + px * 2, px, px);
g.fillRect(px, -hh + px * 2, px, px);
g.fillStyle(type.color, 1);
if (alien.frame === 0) {
g.fillRect(-px * 5, -hh + px * 5, px, px * 2);
g.fillRect(px * 4, -hh + px * 5, px, px * 2);
}
else {
g.fillRect(-px * 3, -hh + px * 6, px * 2, px);
g.fillRect(px, -hh + px * 6, px * 2, px);
}
}
else {
// Octopus alien — round with tentacles
g.fillRect(-px * 2, -hh, px * 4, px); // top
g.fillRect(-px * 4, -hh + px, px * 8, px * 2); // upper body
g.fillRect(-px * 5, -hh + px * 3, px * 10, px * 2); // body
g.fillRect(-px * 4, -hh + px * 5, px * 8, px); // lower
// Eyes
g.fillStyle(0x000000, 1);
g.fillRect(-px * 3, -hh + px * 2, px * 2, px);
g.fillRect(px, -hh + px * 2, px * 2, px);
g.fillStyle(type.color, 1);
if (alien.frame === 0) {
// tentacles down/out
g.fillRect(-px * 5, -hh + px * 6, px * 2, px);
g.fillRect(-px * 2, -hh + px * 6, px, px);
g.fillRect(px, -hh + px * 6, px, px);
g.fillRect(px * 3, -hh + px * 6, px * 2, px);
}
else {
// tentacles up/in
g.fillRect(-px * 4, -hh + px * 6, px * 2, px);
g.fillRect(-px, -hh + px * 6, px * 2, px);
g.fillRect(px * 2, -hh + px * 6, px * 2, px);
}
}
}
marchAliens() {
const alive = this.aliens.filter(a => a.alive);
if (alive.length === 0)
return;
// Check if any alien hit the edge
let hitEdge = false;
const margin = this.alienCellW * 0.3;
for (const a of alive) {
if (this.marchDir === 1 && a.x + this.alienW / 2 + this.marchStepX > W - margin) {
hitEdge = true;
break;
}
if (this.marchDir === -1 && a.x - this.alienW / 2 - this.marchStepX < margin) {
hitEdge = true;
break;
}
}
if (hitEdge) {
// Drop down and reverse
this.marchDir *= -1;
for (const a of alive) {
a.y += this.marchDrop;
a.frame = 1 - a.frame;
this.drawAlien(a);
// Check if aliens reached player row — instant game over (classic rules)
if (a.y + this.alienH / 2 >= this.playerY - this.playerH / 2) {
this.triggerGameOver();
return;
}
}
}
else {
// March sideways
for (const a of alive) {
a.x += this.marchStepX * this.marchDir;
a.frame = 1 - a.frame;
this.drawAlien(a);
}
}
// Play march sound (alternate tone)
this.sound.play('ao_twoTone', { volume: 0.15 });
}
updateMarchInterval() {
const aliveCount = this.aliens.filter(a => a.alive).length;
const total = ALIEN_COLS * ALIEN_ROWS;
if (total === 0)
return;
// Exponential speed-up as aliens are destroyed
const ratio = aliveCount / total;
this.marchInterval = MIN_MARCH_INTERVAL + (BASE_MARCH_INTERVAL - MIN_MARCH_INTERVAL) * ratio;
// Wave speed bonus
this.marchInterval = Math.max(MIN_MARCH_INTERVAL, this.marchInterval - this.wave * 20);
}
/* ================================================================
ALIEN SHOOTING
================================================================ */
alienShoot() {
const alive = this.aliens.filter(a => a.alive);
if (alive.length === 0)
return;
// Find bottommost alien in each column, then pick one at random
const bottomAliens = [];
for (let col = 0; col < ALIEN_COLS; col++) {
const colAliens = alive.filter(a => a.col === col);
if (colAliens.length > 0) {
colAliens.sort((a, b) => b.row - a.row);
bottomAliens.push(colAliens[0]);
}
}
if (bottomAliens.length === 0)
return;
const shooter = bottomAliens[Math.floor(Math.random() * bottomAliens.length)];
const gfx = this.add.graphics().setDepth(7);
// Alien bullet — different color (yellow/red)
gfx.fillStyle(0xffaa00, 0.4);
gfx.fillRect(-this.bulletW, -this.bulletH / 2, this.bulletW * 2, this.bulletH);
gfx.fillStyle(0xff4444, 1);
gfx.fillRect(-this.bulletW / 2, -this.bulletH / 2, this.bulletW, this.bulletH);
gfx.setPosition(shooter.x, shooter.y + this.alienH / 2);
this.alienBullets.push({ gfx, x: shooter.x, y: shooter.y + this.alienH / 2 });
}
updateAlienBullets(dtSec) {
for (let i = this.alienBullets.length - 1; i >= 0; i--) {
const b = this.alienBullets[i];
b.y += ALIEN_BULLET_SPEED * dtSec;
b.gfx.setPosition(b.x, b.y);
if (b.y > H + this.bulletH) {
b.gfx.destroy();
this.alienBullets.splice(i, 1);
}
}
}
/* ================================================================
MYSTERY SHIP
================================================================ */
spawnMystery() {
const dir = Math.random() < 0.5 ? 1 : -1;
const x = dir === 1 ? -40 : W + 40;
// Position just above the alien grid, below the HUD
const y = this.alienGridY - this.alienCellH * 1.2;
const gfx = this.add.graphics().setDepth(12);
this.mystery = { gfx, x, y, direction: dir, active: true };
this.drawMystery();
this.sound.play('ao_mystery', { volume: 0.2 });
}
drawMystery() {
if (!this.mystery)
return;
const g = this.mystery.gfx;
g.clear();
g.setPosition(this.mystery.x, this.mystery.y);
const s = Math.max(SCALE, 0.5);
const w = 30 * s;
const h = 12 * s;
// Saucer shape
g.fillStyle(0x000000, 0.5);
g.fillEllipse(0, 0, w * 2 + 2, h + 2);
g.fillStyle(0xff00ff, 0.8);
g.fillEllipse(0, 0, w * 2, h);
// Dome
g.fillStyle(0xff66ff, 1);
g.fillEllipse(0, -h * 0.4, w, h * 0.7);
// Lights
g.fillStyle(0xffff00, 1);
g.fillCircle(-w * 0.5, 0, 2 * s);
g.fillCircle(0, 0, 2 * s);
g.fillCircle(w * 0.5, 0, 2 * s);
}
updateMystery(dt, dtSec) {
if (this.mystery && this.mystery.active) {
this.mystery.x += MYSTERY_SPEED * this.mystery.direction * dtSec;
this.drawMystery();
// Off screen?
if ((this.mystery.direction === 1 && this.mystery.x > W + 60) ||
(this.mystery.direction === -1 && this.mystery.x < -60)) {
this.mystery.gfx.destroy();
this.mystery = null;
}
}
else {
this.mysteryTimer -= dt;
if (this.mysteryTimer <= 0) {
this.mysteryTimer = MYSTERY_INTERVAL_MIN + Math.random() * (MYSTERY_INTERVAL_MAX - MYSTERY_INTERVAL_MIN);
this.spawnMystery();
}
}
}
/* ================================================================
SHIELDS
================================================================ */
createShields() {
// Clear existing
for (const shield of this.shields) {
for (const block of shield) {
if (block.gfx)
block.gfx.destroy();
}
}
this.shields = [];
const s = Math.max(SCALE, 0.5);
// Original shields were ~6% of screen height tall; derive block size from that
const targetShieldH = H * 0.055;
const blockH = Math.max(2, Math.round(targetShieldH / SHIELD_BLOCK_ROWS));
const blockW = blockH;
const shieldW = SHIELD_BLOCK_COLS * blockW;
const shieldH = SHIELD_BLOCK_ROWS * blockH;
const totalShieldsW = SHIELD_COUNT * shieldW;
const gap = (W - totalShieldsW) / (SHIELD_COUNT + 1);
const shieldY = this.playerY - this.playerH - shieldH - 20;
// Classic shield shape mask (inverted U)
const shieldMask = this.generateShieldMask();
for (let si = 0; si < SHIELD_COUNT; si++) {
const shieldX = gap + si * (shieldW + gap);
const blocks = [];
for (let r = 0; r < SHIELD_BLOCK_ROWS; r++) {
for (let c = 0; c < SHIELD_BLOCK_COLS; c++) {
if (!shieldMask[r][c])
continue;
const bx = shieldX + c * blockW;
const by = shieldY + r * blockH;
const gfx = this.add.graphics().setDepth(6);
gfx.fillStyle(0x00ff66, 1);
gfx.fillRect(0, 0, blockW, blockH);
gfx.setPosition(bx, by);
blocks.push({ gfx, x: bx, y: by, w: blockW, h: blockH, alive: true });
}
}
this.shields.push(blocks);
}
}
generateShieldMask() {
const mask = [];
for (let r = 0; r < SHIELD_BLOCK_ROWS; r++) {
mask[r] = [];
for (let c = 0; c < SHIELD_BLOCK_COLS; c++) {
// Round top
if (r < 4) {
const center = SHIELD_BLOCK_COLS / 2;
const dist = Math.abs(c - center + 0.5);
const maxDist = (SHIELD_BLOCK_COLS / 2) * (1 - r * 0.05);
mask[r][c] = dist < maxDist;
}
// Middle — solid
else if (r < SHIELD_BLOCK_ROWS - 5) {
mask[r][c] = true;
}
// Bottom — cut out arch
else {
const center = SHIELD_BLOCK_COLS / 2;
const dist = Math.abs(c - center + 0.5);
const archRow = r - (SHIELD_BLOCK_ROWS - 5);
const archWidth = 3 + archRow * 0.8;
mask[r][c] = dist > archWidth;
}
}
}
return mask;
}
/* ================================================================
COLLISIONS
================================================================ */
checkCollisions() {
// Player bullets vs aliens
for (let bi = this.playerBullets.length - 1; bi >= 0; bi--) {
const b = this.playerBullets[bi];
let hit = false;
for (const a of this.aliens) {
if (!a.alive)
continue;
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, a.x - this.alienW / 2, a.y - this.alienH / 2, this.alienW, this.alienH)) {
a.alive = false;
a.gfx.setVisible(false);
this.addScore(ALIEN_TYPES[a.type].points, a.x, a.y);
this.spawnExplosion(a.x, a.y, ALIEN_TYPES[a.type].color);
this.sound.play('ao_explosion', { volume: 0.25 });
this.updateMarchInterval();
hit = true;
break;
}
}
// Player bullets vs mystery
if (!hit && this.mystery && this.mystery.active) {
const mw = 30 * Math.max(SCALE, 0.5);
const mh = 12 * Math.max(SCALE, 0.5);
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, this.mystery.x - mw, this.mystery.y - mh / 2, mw * 2, mh)) {
const mysteryPoints = [50, 100, 150, 300][Math.floor(Math.random() * 4)];
this.addScore(mysteryPoints, this.mystery.x, this.mystery.y);
this.spawnExplosion(this.mystery.x, this.mystery.y, 0xff00ff);
this.sound.play('ao_explosion', { volume: 0.3 });
this.mystery.gfx.destroy();
this.mystery = null;
hit = true;
}
}
// Player bullets vs shields
if (!hit) {
for (const shield of this.shields) {
for (const block of shield) {
if (!block.alive)
continue;
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, block.x, block.y, block.w, block.h)) {
block.alive = false;
block.gfx.destroy();
hit = true;
break;
}
}
if (hit)
break;
}
}
if (hit) {
b.gfx.destroy();
this.playerBullets.splice(bi, 1);
}
}
// Alien bullets vs player
if (this.playerAlive && this.invincibleTimer <= 0) {
for (let bi = this.alienBullets.length - 1; bi >= 0; bi--) {
const b = this.alienBullets[bi];
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, this.playerX - this.playerW / 2, this.playerY - this.playerH / 2, this.playerW, this.playerH)) {
b.gfx.destroy();
this.alienBullets.splice(bi, 1);
this.playerHit();
break;
}
}
}
// Alien bullets vs shields
for (let bi = this.alienBullets.length - 1; bi >= 0; bi--) {
const b = this.alienBullets[bi];
let hit = false;
for (const shield of this.shields) {
for (const block of shield) {
if (!block.alive)
continue;
if (this.rectOverlap(b.x - this.bulletW / 2, b.y - this.bulletH / 2, this.bulletW, this.bulletH, block.x, block.y, block.w, block.h)) {
block.alive = false;
block.gfx.destroy();
hit = true;
break;
}
}
if (hit)
break;
}
if (hit) {
b.gfx.destroy();
this.alienBullets.splice(bi, 1);
}
}
// Aliens vs shields (aliens marching into shields)
for (const a of this.aliens) {
if (!a.alive)
continue;
for (const shield of this.shields) {
for (const block of shield) {
if (!block.alive)
continue;
if (this.rectOverlap(a.x - this.alienW / 2, a.y - this.alienH / 2, this.alienW, this.alienH, block.x, block.y, block.w, block.h)) {
block.alive = false;
block.gfx.destroy();
}
}
}
}
}
rectOverlap(x1, y1, w1, h1, x2, y2, w2, h2) {
return x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2;
}
playerHit() {
if (this.gameOverFlag)
return;
this.lives--;
this.syncLivesToHUD();
this.sound.play('ao_lose', { volume: 0.4 });
this.spawnExplosion(this.playerX, this.playerY, 0x00ff66);
if (this.lives <= 0) {
this.triggerGameOver();
}
else {
this.playerAlive = false;
this.playerGfx.setVisible(false);
this.respawnTimer = 1200;
}
}
triggerGameOver() {
this.gameOverFlag = true;
this.playerAlive = false;
this.playerGfx.setVisible(false);
// Clear all bullets
for (const b of this.playerBullets)
b.gfx.destroy();
this.playerBullets = [];
for (const b of this.alienBullets)
b.gfx.destroy();
this.alienBullets = [];
this.showGameOver(this.score, () => {
this.gameOverFlag = false;
this.scene.restart();
});
}
/* ================================================================
EFFECTS
================================================================ */
spawnExplosion(x, y, color) {
this.spawnParticleExplosion(x, y, color, 10);
}
/* ================================================================
SHUTDOWN
================================================================ */
shutdown() {
super.shutdown();
// Clean up transient DOM
const banner = document.getElementById('wave-banner');
if (banner)
banner.remove();
// Clean up graphics
for (const a of this.aliens)
a.gfx?.destroy();
for (const b of this.playerBullets)
b.gfx?.destroy();
for (const b of this.alienBullets)
b.gfx?.destroy();
if (this.mystery)
this.mystery.gfx?.destroy();
for (const shield of this.shields) {
for (const block of shield)
block.gfx?.destroy();
}
}
}
//# sourceMappingURL=AlienOnslaught.js.map
@@ -0,0 +1,785 @@
// BaseScene — shared contract for all Agent Arcade mini-games.
// Provides score bridge to the HTML HUD, pause/resume hooks, and
// a consistent lifecycle so the game bootstrap can swap scenes.
export let W = window.innerWidth;
export let H = window.innerHeight;
/** Call before creating the Phaser game to ensure dimensions are current. */
export function refreshDimensions() {
W = window.innerWidth;
H = window.innerHeight;
}
export class BaseScene extends Phaser.Scene {
score = 0;
highScore = 0;
lives = 3;
level = 0;
scoreAnimTimer;
gameOverKeyListener;
/** Full-screen dark backdrop controlled by the transparency slider. */
_backdrop = null;
/** Ready-screen state */
_readyOverlay = null;
_readyKeyListener;
_readyOnStart;
_wasOnReadyScreen = false;
/** Timer for game-over delayed callback (cancel on shutdown to prevent leaks). */
_gameOverDelayTimer = null;
/** Tracked particle emitters for cleanup on shutdown. */
activeEmitters = [];
constructor(key) {
super(key);
}
/** Safe localStorage helpers */
storageGet(key) {
try {
return localStorage.getItem(key);
}
catch {
return null;
}
}
storageSet(key, value) {
try {
localStorage.setItem(key, value);
}
catch { /* quota exceeded or disabled */ }
}
storageRemove(key) {
try {
localStorage.removeItem(key);
}
catch { /* ignore */ }
}
/** Safely destroy a Phaser game object and return null for assignment. */
destroyObj(obj) {
if (obj) {
try {
obj.destroy();
}
catch { }
}
return null;
}
/** Spawn a particle explosion, track the emitter, and auto-cleanup. */
spawnParticleExplosion(x, y, color, count, lifespan = 400) {
try {
const emitter = this.add.particles(x, y, 'spark', {
speed: { min: 60, max: 180 },
angle: { min: 0, max: 360 },
scale: { start: 1.2, end: 0 },
lifespan,
quantity: count,
tint: color,
emitting: false,
});
emitter.setDepth(20);
emitter.explode(count);
this.activeEmitters.push(emitter);
this.time.delayedCall(lifespan + 100, () => {
const idx = this.activeEmitters.indexOf(emitter);
if (idx >= 0)
this.activeEmitters.splice(idx, 1);
emitter.destroy();
});
}
catch {
// Particle system unavailable, skip
}
}
/** Load high score for this scene from localStorage. */
loadHighScore() {
// Clean up old agentBreak keys (from before rename)
this.storageRemove(`agentBreak_board_${this.scene.key}`);
this.storageRemove(`agentBreak_hi_${this.scene.key}`);
const stored = this.storageGet(`agentArcade_hi_${this.scene.key}`);
this.highScore = stored ? parseInt(stored, 10) || 0 : 0;
this.gameOverShown = false;
this.syncHighScoreToHUD();
}
/**
* Common create() setup. Call at the start of every scene's create().
* Registers pause bridge, shutdown listener, and resets shared state.
*/
initBase() {
this.setupPauseBridge();
this.events.once('shutdown', () => this.shutdown());
this.createBackdrop();
}
/** Create a full-screen dark backdrop whose alpha is controlled by the settings slider. */
createBackdrop() {
const g = this.add.graphics().setDepth(-100);
g.fillStyle(0x000000, 1);
g.fillRect(0, 0, W, H);
g.setScrollFactor(0);
// Read saved transparency (1100 → alpha 0.011.0)
let alpha = 1;
try {
const saved = localStorage.getItem('agentArcade_bgTransparency');
if (saved !== null)
alpha = Math.max(0.01, Math.min(1, parseInt(saved, 10) / 100));
}
catch { /* ignore */ }
g.setAlpha(alpha);
this._backdrop = g;
}
/** Called by the HUD slider to update the backdrop opacity in real time. */
setBackdropAlpha(percent) {
if (this._backdrop) {
this._backdrop.setAlpha(Math.max(0.01, Math.min(1, percent / 100)));
}
}
/** Save high score if current score exceeds it. */
checkHighScore() {
if (this.score > this.highScore) {
this.highScore = this.score;
this.storageSet(`agentArcade_hi_${this.scene.key}`, String(this.highScore));
this.syncHighScoreToHUD();
}
}
/** Push current score into the HTML HUD element. */
syncScoreToHUD() {
const el = document.getElementById('score-value');
if (el)
el.textContent = String(this.score);
}
/** Push high score into the HTML HUD element. */
syncHighScoreToHUD() {
const el = document.getElementById('hi-value');
if (el)
el.textContent = String(this.highScore);
}
/** Push lives count into the HTML HUD element. */
syncLivesToHUD() {
const el = document.getElementById('lives-value');
if (el)
el.textContent = String(this.lives);
}
/** Push level/wave number into the HTML HUD element. */
syncLevelToHUD(value) {
const el = document.getElementById('level-value');
if (el)
el.textContent = String(value ?? this.level);
}
/** Animated score bump (count-up + pop class). */
addScore(points, worldX, worldY) {
const prev = this.score;
this.score += points;
// Floating "+N" text at world position
if (worldX !== undefined && worldY !== undefined) {
const txt = this.add.text(worldX, worldY, `+${points}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '14px',
color: '#ffff00',
stroke: '#000',
strokeThickness: 3,
});
txt.setOrigin(0.5, 0.5).setDepth(900);
this.tweens.add({
targets: txt,
y: worldY - 50,
alpha: 0,
duration: 800,
onComplete: () => txt.destroy(),
});
}
// Count-up animation in HUD
const el = document.getElementById('score-value');
if (!el)
return;
if (this.scoreAnimTimer)
clearInterval(this.scoreAnimTimer);
const start = prev;
const end = this.score;
const duration = 450;
const startTime = performance.now();
this.scoreAnimTimer = window.setInterval(() => {
const t = Math.min(1, (performance.now() - startTime) / duration);
const ease = 1 - Math.pow(1 - t, 3);
el.textContent = String(Math.round(start + (end - start) * ease));
if (t >= 1) {
clearInterval(this.scoreAnimTimer);
this.scoreAnimTimer = undefined;
el.classList.remove('pop');
void el.offsetWidth;
el.classList.add('pop');
}
}, 16);
this.checkHighScore();
}
/** Get top 10 scores for this game from localStorage. */
getLeaderboard() {
const stored = this.storageGet(`agentArcade_board_${this.scene.key}`);
if (!stored)
return [];
try {
const parsed = JSON.parse(stored);
if (!Array.isArray(parsed))
return [];
return parsed.filter((n) => typeof n === 'number');
}
catch {
return [];
}
}
/** Add a score to the leaderboard, keep top 10, return rank (1-based, 0 = not in top 10). */
addToLeaderboard(score) {
if (score <= 0)
return 0;
const board = this.getLeaderboard();
board.push(score);
board.sort((a, b) => b - a);
const trimmed = board.slice(0, 10);
this.storageSet(`agentArcade_board_${this.scene.key}`, JSON.stringify(trimmed));
this.checkHighScore();
const rank = trimmed.indexOf(score) + 1;
return rank <= 10 ? rank : 0;
}
gameOverShown = false;
/** Show game over overlay with leaderboard. Call restartFn when dismissed. */
showGameOver(finalScore, restartFn) {
if (this.gameOverShown)
return;
this.gameOverShown = true;
const rank = this.addToLeaderboard(finalScore);
let board = this.getLeaderboard();
// Reconcile: if stored high score isn't on the board, add it
if (this.highScore > 0 && (board.length === 0 || this.highScore > board[0])) {
board.push(this.highScore);
board.sort((a, b) => b - a);
board = board.slice(0, 10);
this.storageSet(`agentArcade_board_${this.scene.key}`, JSON.stringify(board));
}
const overlay = document.createElement('div');
overlay.id = 'gameover-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 9999;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.75); pointer-events: auto;
animation: fadeIn 0.4s ease-out;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: linear-gradient(145deg, #0d1b2a 0%, #1b2838 50%, #0d1b2a 100%);
border: 2px solid rgba(255,215,0,0.4);
border-radius: 20px; padding: 36px 48px;
text-align: center; min-width: 460px; max-width: 540px;
box-shadow: 0 0 60px rgba(255,215,0,0.15), 0 0 100px rgba(0,0,0,0.8), inset 0 1px 0 rgba(255,255,255,0.05);
font-family: 'Press Start 2P', 'SF Mono', monospace;
animation: scaleIn 0.3s ease-out;
`;
// Title
const title = document.createElement('h2');
title.textContent = 'GAME OVER';
title.style.cssText = `
color: #ff4444; font-size: 28px; margin: 0 0 20px;
text-shadow: 0 0 20px rgba(255,68,68,0.6), 0 0 40px rgba(255,0,0,0.3);
letter-spacing: 4px;
`;
modal.appendChild(title);
// Divider
const div1 = document.createElement('div');
div1.style.cssText = 'height: 1px; background: linear-gradient(90deg, transparent, rgba(255,215,0,0.3), transparent); margin: 0 0 20px;';
modal.appendChild(div1);
// Score
const scoreLine = document.createElement('p');
scoreLine.innerHTML = `YOUR SCORE<br><span style="font-size:28px; color:#ffeb3b; text-shadow: 0 0 12px rgba(255,235,59,0.5);">${finalScore.toLocaleString()}</span>`;
scoreLine.style.cssText = 'color: #8899aa; font-size: 10px; margin: 0 0 12px; letter-spacing: 2px; line-height: 2.2;';
modal.appendChild(scoreLine);
// Rank badge
if (rank === 1) {
const badge = document.createElement('div');
badge.innerHTML = '🏆 NEW HIGH SCORE!';
badge.style.cssText = `
color: #ffd700; font-size: 13px; margin: 8px 0 16px;
padding: 8px 16px; border-radius: 8px;
background: rgba(255,215,0,0.1); border: 1px solid rgba(255,215,0,0.3);
display: inline-block;
text-shadow: 0 0 8px rgba(255,215,0,0.4);
`;
modal.appendChild(badge);
}
else if (rank > 0) {
const badge = document.createElement('div');
badge.textContent = `#${rank} ON LEADERBOARD`;
badge.style.cssText = `
color: #4fc3f7; font-size: 11px; margin: 8px 0 16px;
padding: 6px 14px; border-radius: 8px;
background: rgba(79,195,247,0.1); border: 1px solid rgba(79,195,247,0.2);
display: inline-block;
`;
modal.appendChild(badge);
}
else {
const spacer = document.createElement('div');
spacer.style.cssText = 'height: 12px;';
modal.appendChild(spacer);
}
// Divider
const div2 = document.createElement('div');
div2.style.cssText = 'height: 1px; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent); margin: 12px 0 16px;';
modal.appendChild(div2);
// Leaderboard header
const boardTitle = document.createElement('p');
boardTitle.textContent = '─── TOP 10 ───';
boardTitle.style.cssText = 'color: #667; font-size: 9px; margin: 0 0 10px; letter-spacing: 3px;';
modal.appendChild(boardTitle);
// Score list
const table = document.createElement('div');
table.style.cssText = 'margin: 0 auto; display: inline-block; width: 100%;';
board.forEach((s, i) => {
const isMe = (i === rank - 1);
const row = document.createElement('div');
row.style.cssText = `
display: flex; justify-content: space-between; align-items: center;
font-size: 16px; padding: 8px 16px; margin: 3px 0;
border-radius: 8px;
background: ${isMe ? 'rgba(255,235,59,0.12)' : (i % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent')};
${isMe ? 'border: 1px solid rgba(255,235,59,0.25);' : ''}
`;
const rankEl = document.createElement('span');
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}.`;
rankEl.textContent = medal;
rankEl.style.cssText = `
color: ${isMe ? '#ffeb3b' : '#778'};
min-width: 42px; text-align: left;
font-size: ${i < 3 ? '20px' : '16px'};
`;
const scoreEl = document.createElement('span');
scoreEl.textContent = s.toLocaleString();
scoreEl.style.cssText = `
color: ${isMe ? '#ffeb3b' : '#bcc'};
font-size: ${i < 3 ? '20px' : '16px'};
font-weight: ${i < 3 ? '900' : '700'};
${isMe ? 'text-shadow: 0 0 10px rgba(255,235,59,0.5);' : ''}
`;
if (isMe) {
const youTag = document.createElement('span');
youTag.textContent = '◄';
youTag.style.cssText = 'color: #ffeb3b; font-size: 10px; margin-left: 6px;';
scoreEl.appendChild(youTag);
}
row.appendChild(rankEl);
row.appendChild(scoreEl);
table.appendChild(row);
});
// Fill empty slots
for (let i = board.length; i < 10; i++) {
const row = document.createElement('div');
row.style.cssText = `
display: flex; justify-content: space-between;
font-size: 16px; padding: 8px 16px; margin: 3px 0;
color: #334;
`;
row.innerHTML = `<span>${i + 1}.</span><span>---</span>`;
table.appendChild(row);
}
modal.appendChild(table);
// Restart button — matches .help-close style from settings/help dialogs
const restartBtn = document.createElement('button');
restartBtn.textContent = 'RESTART';
restartBtn.style.cssText = `
display: block; margin: 22px auto 0; width: 100%; padding: 9px;
background: linear-gradient(180deg, #ffd54a 0%, #c9a020 100%);
border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 8px;
color: #1a1a1a; font-weight: 700; letter-spacing: 1px; font-size: 13px;
cursor: pointer; transition: filter 120ms;
`;
restartBtn.addEventListener('mouseenter', () => { restartBtn.style.filter = 'brightness(1.15)'; });
restartBtn.addEventListener('mouseleave', () => { restartBtn.style.filter = ''; });
modal.appendChild(restartBtn);
overlay.appendChild(modal);
document.body.appendChild(overlay);
// Disable click-through so the overlay is interactive
const ti = window.__TAURI_INTERNALS__;
if (ti)
ti.invoke('set_click_through', { enabled: false });
const dismiss = () => {
this.gameOverShown = false;
document.removeEventListener('keydown', onKey);
overlay.remove();
// Re-enable click-through
if (ti)
ti.invoke('set_click_through', { enabled: true });
restartFn();
};
const onKey = (ev) => {
if (ev.code === 'Space' || ev.code === 'Enter') {
ev.preventDefault();
dismiss();
}
};
this.gameOverKeyListener = onKey;
// Brief delay before accepting input (prevent accidental dismiss).
// Guard against the scene being stopped during the delay.
this._gameOverDelayTimer = this.time.delayedCall(500, () => {
if (!this.scene.isActive())
return;
document.addEventListener('keydown', onKey);
restartBtn.addEventListener('click', dismiss);
});
}
// ── Ready screen ───────────────────────────────────────────────────────────
/**
* Freeze the scene and show the "Press any key to start" screen.
* Call as the LAST statement in every scene's create() so all game objects
* exist but nothing moves until the player is ready.
* @param onStart Optional callback invoked the moment the player presses a
* key and the scene resumes — use this to defer first-wave setup so it
* doesn't render on top of the ready screen.
*/
startWithReadyScreen(onStart) {
this._readyOnStart = onStart;
this.scene.pause();
this.sound.stopAll(); // stop any sounds that fired during create()
this._showPressAnyKey();
}
_showPressAnyKey() {
this._cleanupReadyScreen();
if (!document.getElementById('ready-screen-style')) {
const style = document.createElement('style');
style.id = 'ready-screen-style';
style.textContent = `
@keyframes readyBlink { 0%,100%{opacity:1} 50%{opacity:.3} }
@keyframes readyGlow { 0%,100%{text-shadow:0 0 10px rgba(0,200,255,0.6),0 0 30px rgba(0,200,255,0.3)} 50%{text-shadow:0 0 20px rgba(0,200,255,0.9),0 0 50px rgba(0,200,255,0.5),0 0 80px rgba(0,100,255,0.2)} }
@keyframes titleShimmer { 0%{background-position:200% center} 100%{background-position:-200% center} }
@keyframes titleFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
@keyframes neonPulse { 0%,100%{filter:drop-shadow(0 0 15px rgba(0,255,136,0.8)) drop-shadow(0 0 40px rgba(0,255,136,0.4)) drop-shadow(0 0 80px rgba(0,255,136,0.2))} 50%{filter:drop-shadow(0 0 25px rgba(0,255,136,1)) drop-shadow(0 0 60px rgba(0,255,136,0.6)) drop-shadow(0 0 120px rgba(0,255,136,0.3))} }
@keyframes dividerPulse { 0%,100%{opacity:0.6;width:280px} 50%{opacity:1;width:360px} }
@keyframes fadeSlideUp { from{opacity:0;transform:translateY(20px)} to{opacity:1;transform:translateY(0)} }
@keyframes starTwinkle { 0%,100%{opacity:0.2} 50%{opacity:1} }
`;
document.head.appendChild(style);
}
const overlay = document.createElement('div');
overlay.id = 'ready-overlay';
overlay.style.cssText = `
position:fixed;inset:0;z-index:8000;pointer-events:none;
display:flex;flex-direction:column;align-items:center;justify-content:center;
background:radial-gradient(ellipse at 50% 40%,rgba(0,15,60,0.80) 0%,rgba(0,5,20,0.92) 60%,rgba(0,0,0,0.95) 100%);
`;
// Decorative star particles
for (let i = 0; i < 40; i++) {
const star = document.createElement('div');
const size = Math.random() < 0.3 ? 3 : 2;
const x = Math.random() * 100;
const y = Math.random() * 100;
const delay = Math.random() * 3;
const dur = 1.5 + Math.random() * 2;
star.style.cssText = `
position:absolute;left:${x}%;top:${y}%;width:${size}px;height:${size}px;
background:#fff;border-radius:50%;
animation:starTwinkle ${dur}s ease-in-out ${delay}s infinite;
opacity:0.3;
`;
overlay.appendChild(star);
}
// Main content wrapper — styled panel matching the game-over dialog
const content = document.createElement('div');
content.style.cssText = `
display:flex;flex-direction:column;align-items:center;
animation:fadeSlideUp 0.6s ease-out both;
position:relative;z-index:1;
background:linear-gradient(145deg,#0d1b2a 0%,#1b2838 50%,#0d1b2a 100%);
border:2px solid rgba(0,200,255,0.25);
border-radius:20px;padding:42px 56px;
box-shadow:0 0 60px rgba(0,200,255,0.1),0 0 100px rgba(0,0,0,0.8),inset 0 1px 0 rgba(255,255,255,0.05);
max-width:700px;
`;
const title = document.createElement('div');
title.textContent = this.displayName.toUpperCase();
title.style.cssText = `
font-family:'Press Start 2P',monospace;font-size:48px;letter-spacing:6px;
-webkit-text-stroke:2px rgba(0,255,136,0.3);
background:linear-gradient(90deg,#00ff88,#ffffff,#00ff88,#ffffff,#00ff88);
background-size:200% auto;
-webkit-background-clip:text;-webkit-text-fill-color:transparent;
background-clip:text;
animation:titleShimmer 8s linear infinite,titleFloat 4s ease-in-out infinite,neonPulse 3s ease-in-out infinite;
margin-bottom:22px;
`;
const divider = document.createElement('div');
divider.style.cssText = `
width:320px;height:2px;margin-bottom:20px;
background:linear-gradient(90deg,transparent 0%,#00c8ff 20%,#ff6b35 50%,#00c8ff 80%,transparent 100%);
border-radius:1px;box-shadow:0 0 12px rgba(0,200,255,0.4);
animation:dividerPulse 3s ease-in-out infinite;
`;
const prompt = document.createElement('div');
prompt.textContent = 'PRESS ANY KEY TO START';
prompt.style.cssText = `
font-family:'Press Start 2P',monospace;font-size:16px;letter-spacing:4px;
color:#fff;
animation:readyBlink 1.4s ease-in-out infinite,readyGlow 2s ease-in-out infinite;
text-shadow:0 0 15px rgba(0,200,255,0.8);
`;
content.appendChild(title);
const desc = this.getDescription();
if (desc) {
const descEl = document.createElement('div');
descEl.textContent = desc;
descEl.style.cssText = `
font-family:'Press Start 2P',monospace;font-size:14px;letter-spacing:1px;
color:#d0e8ff;max-width:700px;text-align:center;line-height:2;
margin-bottom:18px;
text-shadow:0 0 10px rgba(150,210,255,0.4);
`;
content.appendChild(descEl);
}
content.appendChild(divider);
// Show control hints if the scene provides them
const controls = this.getControls();
if (controls.length > 0) {
const controlsDiv = document.createElement('div');
controlsDiv.style.cssText = `
margin-top:24px;padding:18px 28px;
background:linear-gradient(135deg,rgba(0,20,60,0.6) 0%,rgba(0,10,40,0.7) 100%);
border:1px solid rgba(0,200,255,0.2);
border-radius:12px;display:inline-block;
box-shadow:0 4px 20px rgba(0,0,0,0.3),inset 0 1px 0 rgba(255,255,255,0.05);
backdrop-filter:blur(4px);
`;
const controlsTitle = document.createElement('div');
controlsTitle.textContent = 'CONTROLS';
controlsTitle.style.cssText = `
font-family:'Press Start 2P',monospace;font-size:13px;letter-spacing:5px;
color:rgba(200,230,255,0.9);margin-bottom:16px;text-align:center;
text-shadow:0 0 8px rgba(150,200,255,0.4);
`;
controlsDiv.appendChild(controlsTitle);
for (const { key, action } of controls) {
const row = document.createElement('div');
row.style.cssText = `
display:flex;justify-content:space-between;align-items:center;
margin:8px 0;gap:28px;
`;
const keyEl = document.createElement('span');
keyEl.textContent = key;
keyEl.style.cssText = `
font-family:'Press Start 2P',monospace;font-size:15px;
color:#ffd54a;background:rgba(255,213,74,0.08);
padding:7px 16px;border-radius:6px;border:1px solid rgba(255,213,74,0.25);
min-width:90px;text-align:center;
box-shadow:0 2px 6px rgba(0,0,0,0.2),inset 0 1px 0 rgba(255,255,255,0.05);
text-shadow:0 0 6px rgba(255,213,74,0.3);
`;
const actionEl = document.createElement('span');
actionEl.textContent = action;
actionEl.style.cssText = `
font-family:'Press Start 2P',monospace;font-size:14px;
color:#d0dde8;text-align:left;
`;
row.appendChild(keyEl);
row.appendChild(actionEl);
controlsDiv.appendChild(row);
}
content.appendChild(controlsDiv);
}
prompt.style.cssText += 'margin-top:32px;';
content.appendChild(prompt);
overlay.appendChild(content);
document.body.appendChild(overlay);
this._readyOverlay = overlay;
const onKey = (e) => {
if (['Meta', 'Alt', 'Control', 'Shift'].includes(e.key))
return;
document.removeEventListener('keydown', onKey);
this._readyKeyListener = undefined;
this._cleanupReadyScreen();
if (e.key === 'Escape') {
// Let the normal pause system take over; re-show ready screen on resume
this._wasOnReadyScreen = true;
return;
}
e.preventDefault();
this.scene.resume();
this._fireReadyOnStart();
};
this._readyKeyListener = onKey;
document.addEventListener('keydown', onKey);
}
_cleanupReadyScreen() {
if (this._readyOverlay) {
this._readyOverlay.remove();
this._readyOverlay = null;
}
if (this._readyKeyListener) {
document.removeEventListener('keydown', this._readyKeyListener);
this._readyKeyListener = undefined;
}
}
_fireReadyOnStart() {
if (this._readyOnStart) {
const fn = this._readyOnStart;
this._readyOnStart = undefined;
fn();
}
}
/** Called by the pause system. Override if the scene needs custom cleanup. */
pauseGame() {
this.scene.pause();
this.sound.pauseAll();
}
/** Called by the resume system. Override if needed. */
resumeGame() {
if (this._wasOnReadyScreen) {
// Re-show the ready screen instead of resuming gameplay
this._wasOnReadyScreen = false;
this._showPressAnyKey();
return;
}
this.scene.resume();
this.sound.resumeAll();
this._fireReadyOnStart();
}
/**
* Wire up the pause/resume bridge between the HUD and the Phaser scene.
* Call from create() — replaces the per-scene boilerplate that was duplicated
* in every scene previously.
*/
setupPauseBridge() {
// __agentArcadePauseScene: pauses/resumes the Phaser scene ONLY (no Rust call).
// Used by Rust-originated pause/resume to avoid feedback loops.
window.__agentArcadePauseScene = (shouldPause) => {
if (shouldPause)
this.pauseGame();
else
this.resumeGame();
};
// __agentArcadePause: called from in-page UI (HUD buttons, game-switcher).
// Pauses scene AND notifies Rust to shrink/expand window.
window.__agentArcadePause = (shouldPause) => {
const ab = window.agentArcade;
if (shouldPause)
this.pauseGame();
else
this.resumeGame();
if (ab && ab.setClickThrough)
ab.setClickThrough(shouldPause);
if (ab && ab.setPaused)
ab.setPaused(shouldPause);
};
const ab = window.agentArcade;
if (ab && ab.onResumeRequest) {
ab.onResumeRequest(() => {
const hud = document.getElementById('hud');
if (hud)
hud.classList.remove('paused');
this.resumeGame();
});
}
}
/**
* Show a "WAVE N" banner overlay — shared by space game scenes.
* Auto-animates in/out and removes itself after ~2.2 seconds.
*/
showWaveBanner(waveNum) {
const existing = document.getElementById('wave-banner');
if (existing)
existing.remove();
const banner = document.createElement('div');
banner.id = 'wave-banner';
banner.style.cssText = `
position: fixed; top: 45%; left: 50%; transform: translate(-50%, -50%);
padding: 12px 36px;
background: linear-gradient(180deg, #1a1f3a 0%, #0a0e22 100%);
border: 2px solid #ffd54a;
border-radius: 12px;
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
0 6px 24px rgba(0, 0, 0, 0.7),
0 0 22px rgba(255, 213, 74, 0.45);
font-family: -apple-system, system-ui, 'Helvetica Neue', sans-serif;
font-size: 22px; font-weight: 700; letter-spacing: 2px;
color: #ffd54a;
text-shadow: 0 0 8px rgba(255, 213, 74, 0.6);
z-index: 50; pointer-events: none; user-select: none;
animation: waveBannerIn 0.3s ease-out;
`;
banner.textContent = `WAVE ${waveNum}`;
document.body.appendChild(banner);
if (!document.getElementById('wave-banner-style')) {
const style = document.createElement('style');
style.id = 'wave-banner-style';
style.textContent = `
@keyframes waveBannerIn { from { opacity: 0; transform: translate(-50%, -50%) scale(0.85); } to { opacity: 1; transform: translate(-50%, -50%) scale(1); } }
@keyframes waveBannerOut { from { opacity: 1; } to { opacity: 0; } }
`;
document.head.appendChild(style);
}
setTimeout(() => {
banner.style.animation = 'waveBannerOut 0.6s ease-in forwards';
setTimeout(() => banner.remove(), 700);
}, 1500);
}
/** Create the shared 'spark' texture used for particle effects. */
ensureSparkTexture() {
if (this.textures.exists('spark'))
return;
const g = this.add.graphics();
g.fillStyle(0xffffff);
g.fillCircle(4, 4, 4);
g.generateTexture('spark', 8, 8);
g.destroy();
}
/**
* Create a parallax starfield. Returns the Star array for use with updateStarfield().
* Each scene provides its own layer config (count, speed, size, alpha per layer).
*/
createStarfield(layers) {
const stars = [];
for (const l of layers) {
for (let i = 0; i < l.count; i++) {
const gfx = this.add.graphics();
const x = Math.random() * W;
const y = Math.random() * H;
gfx.fillStyle(0xffffff, l.alpha);
gfx.fillCircle(0, 0, l.size);
gfx.setPosition(x, y).setDepth(-9);
stars.push({ x, y, speed: l.speed, size: l.size, alpha: l.alpha, gfx });
}
}
return stars;
}
/** Update parallax starfield positions (call from update). */
updateStarfield(stars, dt) {
for (const s of stars) {
s.y += s.speed * (dt / 1000);
if (s.y > H)
s.y -= H;
s.gfx.setPosition(s.x, s.y);
}
}
/** Clean up timers and listeners on scene shutdown. */
shutdown() {
if (this.scoreAnimTimer) {
clearInterval(this.scoreAnimTimer);
this.scoreAnimTimer = undefined;
}
if (this.gameOverKeyListener) {
document.removeEventListener('keydown', this.gameOverKeyListener);
this.gameOverKeyListener = undefined;
}
if (this._gameOverDelayTimer) {
this._gameOverDelayTimer.remove();
this._gameOverDelayTimer = null;
}
this._cleanupReadyScreen();
this._readyOnStart = undefined;
this._wasOnReadyScreen = false;
this.time.removeAllEvents();
this.activeEmitters.forEach(e => this.destroyObj(e));
this.activeEmitters = [];
const overlay = document.getElementById('gameover-overlay');
if (overlay)
overlay.remove();
}
/** Return a one-line description for the ready screen. Override in each scene. */
getDescription() {
return '';
}
/** Return control hints for the ready screen. Override in each scene. */
getControls() {
return [];
}
}
//# sourceMappingURL=BaseScene.js.map
@@ -0,0 +1,697 @@
// CosmicRocks — Asteroids-style space shooter.
// Ship rotates and thrusts through space, destroying asteroids that split
// into smaller fragments. Vector-style graphics drawn with Phaser Graphics.
import { BaseScene, W, H } from './BaseScene.js';
/* ------------------------------------------------------------------ */
/* Constants — SCALE/SHIP_SIZE recalculated in create() */
/* ------------------------------------------------------------------ */
let SCALE = Math.min(W / 1920, H / 1080);
let SHIP_SIZE = 20 * Math.max(SCALE, 0.6);
const ROTATE_SPEED = 4; // rad/s
const THRUST = 400; // px/s²
const FRICTION = 0.98;
const BULLET_SPEED = 600;
const BULLET_LIFE = 3000; // ms
const MAX_BULLETS = 4;
const INITIAL_ASTEROIDS = 5;
const INVINCIBLE_TIME = 2000; // ms
const RESPAWN_DELAY = 800; // ms before respawn
const ASTEROID_SIZES = [
{ radius: [40, 60], speed: [40, 80], score: 20 }, // large (size index 0)
{ radius: [25, 40], speed: [60, 120], score: 50 }, // medium (size index 1)
{ radius: [12, 20], speed: [80, 160], score: 100 }, // small (size index 2)
];
const BULLET_COLORS = [0x00ff88, 0xff8800, 0x00ccff];
/* ------------------------------------------------------------------ */
/* Scene */
/* ------------------------------------------------------------------ */
export class CosmicRocksScene extends BaseScene {
/* ship state */
shipGfx;
shipX = 0;
shipY = 0;
shipVx = 0;
shipVy = 0;
shipAngle = -Math.PI / 2; // pointing up
thrustGfx;
/* game objects */
asteroids = [];
bullets = [];
stars = [];
/* UFO */
ufo = null;
ufoBullets = [];
ufoTimer = 0;
/* game state */
wave = 0;
invincibleTimer = 0;
respawnTimer = 0;
shipAlive = true;
gameOver = false;
waveDelay = 0;
/* input */
cursors;
spaceKey;
spaceWasDown = false;
constructor() { super('cosmic-rocks'); }
get displayName() { return 'Cosmic Rocks'; }
getDescription() {
return 'Survive the asteroid field. Shoot rocks to break them apart!';
}
getControls() {
return [
{ key: '← →', action: 'Rotate' },
{ key: '↑', action: 'Thrust' },
{ key: 'SPACE', action: 'Fire' },
];
}
/* ================================================================
LIFECYCLE
================================================================ */
preload() {
this.load.audio('sfx_laser', '../assets/cosmic-rocks/sounds/sfx_laser1.ogg');
this.load.audio('sfx_zap', '../assets/cosmic-rocks/sounds/sfx_explosion.ogg');
this.load.audio('sfx_lose', '../assets/cosmic-rocks/sounds/sfx_lose.ogg');
this.load.audio('sfx_twoTone', '../assets/cosmic-rocks/sounds/sfx_twoTone.ogg');
}
create() {
this.initBase();
// Recalculate screen-dependent constants
SCALE = Math.min(W / 1920, H / 1080);
SHIP_SIZE = 20 * Math.max(SCALE, 0.6);
this.score = 0;
this.lives = 3;
this.wave = 0;
this.shipX = W / 2;
this.shipY = H / 2;
this.shipVx = 0;
this.shipVy = 0;
this.shipAngle = -Math.PI / 2;
this.invincibleTimer = 0;
this.respawnTimer = 0;
this.shipAlive = true;
this.gameOver = false;
this.waveDelay = 0;
this.asteroids = [];
this.bullets = [];
this.stars = [];
this.activeEmitters = [];
this.ufo = null;
this.ufoBullets = [];
this.ufoTimer = 15000 + Math.random() * 10000;
this.ensureSparkTexture();
this.stars = this.createStarfield([
{ count: 40, speed: 15, size: 1, alpha: 0.25 },
{ count: 25, speed: 30, size: 1.5, alpha: 0.35 },
{ count: 15, speed: 55, size: 2, alpha: 0.45 },
]);
this.createShip();
this.cursors = this.input.keyboard.createCursorKeys();
this.spaceKey = this.input.keyboard.addKey('SPACE');
this.spaceWasDown = false;
this.syncLivesToHUD();
this.syncScoreToHUD();
this.loadHighScore();
this.startWithReadyScreen(() => this.startWave());
}
update(_t, dtMs) {
if (this.gameOver || !this.cursors)
return;
const dt = Math.min(dtMs, 33);
const dtSec = dt / 1000;
this.updateStarfield(this.stars, dt);
if (this.respawnTimer > 0) {
this.respawnTimer -= dt;
if (this.respawnTimer <= 0)
this.respawnShip();
}
if (this.shipAlive) {
this.updateShipInput(dtSec);
this.updateShipPhysics(dtSec);
this.drawShip();
}
this.updateBullets(dtSec);
this.updateAsteroids(dtSec);
this.updateUfo(dt, dtSec);
this.checkCollisions();
this.checkUfoCollisions();
if (this.waveDelay > 0) {
this.waveDelay -= dt;
if (this.waveDelay <= 0 && this.asteroids.length === 0)
this.startWave();
}
if (this.invincibleTimer > 0) {
this.invincibleTimer -= dt;
if (this.shipGfx) {
this.shipGfx.setAlpha(Math.sin(performance.now() / 80) > 0 ? 1 : 0.2);
}
}
else if (this.shipGfx) {
this.shipGfx.setAlpha(1);
}
}
/* ================================================================
SHIP
================================================================ */
createShip() {
this.shipGfx = this.add.graphics().setDepth(10);
this.thrustGfx = this.add.graphics().setDepth(9);
this.drawShip();
}
updateShipInput(dtSec) {
if (!this.cursors)
return;
if (this.cursors.left.isDown)
this.shipAngle -= ROTATE_SPEED * dtSec;
if (this.cursors.right.isDown)
this.shipAngle += ROTATE_SPEED * dtSec;
if (this.cursors.up.isDown) {
this.shipVx += Math.cos(this.shipAngle) * THRUST * dtSec;
this.shipVy += Math.sin(this.shipAngle) * THRUST * dtSec;
}
// Fire
const spaceDown = this.spaceKey.isDown;
if (spaceDown && !this.spaceWasDown && this.bullets.length < MAX_BULLETS) {
this.fireBullet();
}
this.spaceWasDown = spaceDown;
}
updateShipPhysics(dtSec) {
// Friction (time-based)
const friction = Math.pow(FRICTION, dtSec / (1 / 60));
this.shipVx *= friction;
this.shipVy *= friction;
this.shipX += this.shipVx * dtSec;
this.shipY += this.shipVy * dtSec;
// Screen wrap
if (this.shipX < -SHIP_SIZE)
this.shipX = W + SHIP_SIZE;
else if (this.shipX > W + SHIP_SIZE)
this.shipX = -SHIP_SIZE;
if (this.shipY < -SHIP_SIZE)
this.shipY = H + SHIP_SIZE;
else if (this.shipY > H + SHIP_SIZE)
this.shipY = -SHIP_SIZE;
}
drawShip() {
const g = this.shipGfx;
g.clear();
g.setPosition(this.shipX, this.shipY);
const cos = Math.cos(this.shipAngle);
const sin = Math.sin(this.shipAngle);
const s = SHIP_SIZE;
// Triangle ship
const nose = { x: cos * s, y: sin * s };
const leftWing = { x: Math.cos(this.shipAngle + 2.4) * s * 0.85, y: Math.sin(this.shipAngle + 2.4) * s * 0.85 };
const rightWing = { x: Math.cos(this.shipAngle - 2.4) * s * 0.85, y: Math.sin(this.shipAngle - 2.4) * s * 0.85 };
// Dark shadow backdrop for visibility on light backgrounds
g.lineStyle(6, 0x000000, 0.5);
g.beginPath();
g.moveTo(nose.x, nose.y);
g.lineTo(leftWing.x, leftWing.y);
g.lineTo(rightWing.x, rightWing.y);
g.closePath();
g.strokePath();
// Outer glow (soft cyan)
g.lineStyle(4, 0x00ffff, 0.2);
g.beginPath();
g.moveTo(nose.x, nose.y);
g.lineTo(leftWing.x, leftWing.y);
g.lineTo(rightWing.x, rightWing.y);
g.closePath();
g.strokePath();
// Solid ship outline (bright cyan)
g.lineStyle(2.5, 0x00ffff, 1);
g.beginPath();
g.moveTo(nose.x, nose.y);
g.lineTo(leftWing.x, leftWing.y);
g.lineTo(rightWing.x, rightWing.y);
g.closePath();
g.strokePath();
// Thrust flame
const tg = this.thrustGfx;
tg.clear();
if (this.cursors && this.cursors.up.isDown) {
tg.setPosition(this.shipX, this.shipY);
const tailLen = s * (0.6 + Math.random() * 0.4);
const tailX = -cos * tailLen;
const tailY = -sin * tailLen;
const spread = 0.4;
const tl = { x: Math.cos(this.shipAngle + Math.PI - spread) * s * 0.35, y: Math.sin(this.shipAngle + Math.PI - spread) * s * 0.35 };
const tr = { x: Math.cos(this.shipAngle + Math.PI + spread) * s * 0.35, y: Math.sin(this.shipAngle + Math.PI + spread) * s * 0.35 };
// Dark shadow for thrust
tg.lineStyle(5, 0x000000, 0.3);
tg.beginPath();
tg.moveTo(tl.x, tl.y);
tg.lineTo(tailX, tailY);
tg.lineTo(tr.x, tr.y);
tg.strokePath();
tg.lineStyle(3, 0xff8800, 0.25);
tg.beginPath();
tg.moveTo(tl.x, tl.y);
tg.lineTo(tailX, tailY);
tg.lineTo(tr.x, tr.y);
tg.strokePath();
tg.lineStyle(2.5, 0xff8800, 0.9);
tg.beginPath();
tg.moveTo(tl.x, tl.y);
tg.lineTo(tailX, tailY);
tg.lineTo(tr.x, tr.y);
tg.strokePath();
}
}
respawnShip() {
this.shipX = W / 2;
this.shipY = H / 2;
this.shipVx = 0;
this.shipVy = 0;
this.shipAngle = -Math.PI / 2;
this.shipAlive = true;
this.invincibleTimer = INVINCIBLE_TIME;
if (this.shipGfx)
this.shipGfx.setVisible(true);
if (this.thrustGfx)
this.thrustGfx.setVisible(true);
}
/* ================================================================
BULLETS
================================================================ */
fireBullet() {
this.sound.play('sfx_laser', { volume: 0.3 });
const color = BULLET_COLORS[Math.floor(Math.random() * BULLET_COLORS.length)];
const gfx = this.add.graphics().setDepth(8);
// Dark backdrop
gfx.fillStyle(0x000000, 0.5);
gfx.fillCircle(0, 0, 10);
// Glow
gfx.fillStyle(color, 0.3);
gfx.fillCircle(0, 0, 8);
// Solid center
gfx.fillStyle(color, 1);
gfx.fillCircle(0, 0, 4);
const bx = this.shipX + Math.cos(this.shipAngle) * SHIP_SIZE;
const by = this.shipY + Math.sin(this.shipAngle) * SHIP_SIZE;
gfx.setPosition(bx, by);
this.bullets.push({
gfx,
x: bx, y: by,
vx: Math.cos(this.shipAngle) * BULLET_SPEED,
vy: Math.sin(this.shipAngle) * BULLET_SPEED,
life: BULLET_LIFE,
color,
});
}
updateBullets(dtSec) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
b.x += b.vx * dtSec;
b.y += b.vy * dtSec;
b.life -= dtSec * 1000;
b.gfx.setPosition(b.x, b.y);
// Destroy bullet when it leaves the screen or expires
if (b.life <= 0 || b.x < 0 || b.x > W || b.y < 0 || b.y > H) {
b.gfx.destroy();
this.bullets.splice(i, 1);
}
}
}
/* ================================================================
ASTEROIDS
================================================================ */
generateAsteroidVertices(radius) {
const verts = [];
const sides = 12;
for (let i = 0; i < sides; i++) {
const angle = (i / sides) * Math.PI * 2;
const r = radius * (0.7 + Math.random() * 0.3);
verts.push({ x: Math.cos(angle) * r, y: Math.sin(angle) * r });
}
return verts;
}
spawnAsteroid(sizeIdx, x, y, aimAtShip = false) {
const info = ASTEROID_SIZES[sizeIdx];
const radius = info.radius[0] + Math.random() * (info.radius[1] - info.radius[0]);
const scaledRadius = radius * Math.max(SCALE, 0.5);
// Position: at edges if not specified
let ax, ay;
if (x !== undefined && y !== undefined) {
ax = x;
ay = y;
}
else {
const edge = Math.floor(Math.random() * 4);
if (edge === 0) {
ax = Math.random() * W;
ay = -scaledRadius;
}
else if (edge === 1) {
ax = Math.random() * W;
ay = H + scaledRadius;
}
else if (edge === 2) {
ax = -scaledRadius;
ay = Math.random() * H;
}
else {
ax = W + scaledRadius;
ay = Math.random() * H;
}
// Make sure not too close to player
const dx = ax - this.shipX;
const dy = ay - this.shipY;
if (Math.sqrt(dx * dx + dy * dy) < 150) {
ax = (ax + W / 2) % W;
ay = (ay + H / 2) % H;
}
}
const speed = info.speed[0] + Math.random() * (info.speed[1] - info.speed[0]);
const speedBoost = Math.random() < 0.4 ? 1.5 : 1.0; // 40% chance of fast asteroid
// Aim toward the ship if requested, otherwise random direction
let angle;
let finalSpeed = speed * speedBoost;
if (aimAtShip) {
angle = Math.atan2(this.shipY - ay, this.shipX - ax);
// Add slight random spread (±15°) so it's not a perfect snipe
angle += (Math.random() - 0.5) * (Math.PI / 6);
// Ensure it arrives in ~3-4s regardless of base speed
const dist = Math.sqrt((this.shipX - ax) ** 2 + (this.shipY - ay) ** 2);
const minSpeed = dist / (3 + Math.random());
finalSpeed = Math.max(finalSpeed, minSpeed);
}
else {
angle = Math.random() * Math.PI * 2;
}
const vertices = this.generateAsteroidVertices(scaledRadius);
const gfx = this.add.graphics().setDepth(5);
this.drawAsteroid(gfx, vertices);
this.asteroids.push({
gfx,
x: ax, y: ay,
vx: Math.cos(angle) * finalSpeed,
vy: Math.sin(angle) * finalSpeed,
radius: scaledRadius,
sizeIdx,
rotation: 0,
rotSpeed: (Math.random() - 0.5) * 2,
vertices,
});
}
drawAsteroid(gfx, vertices) {
gfx.clear();
// Dark shadow backdrop for visibility on light backgrounds
gfx.lineStyle(5, 0x000000, 0.5);
gfx.beginPath();
gfx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
gfx.lineTo(vertices[i].x, vertices[i].y);
}
gfx.closePath();
gfx.strokePath();
// Outer glow (soft green)
gfx.lineStyle(3, 0x44ff44, 0.25);
gfx.beginPath();
gfx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
gfx.lineTo(vertices[i].x, vertices[i].y);
}
gfx.closePath();
gfx.strokePath();
// Solid outline (bright green-white)
gfx.lineStyle(2.5, 0x88ff88, 1);
gfx.beginPath();
gfx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
gfx.lineTo(vertices[i].x, vertices[i].y);
}
gfx.closePath();
gfx.strokePath();
}
updateAsteroids(dtSec) {
for (const a of this.asteroids) {
a.x += a.vx * dtSec;
a.y += a.vy * dtSec;
a.rotation += a.rotSpeed * dtSec;
// Screen wrap
if (a.x < -a.radius)
a.x = W + a.radius;
else if (a.x > W + a.radius)
a.x = -a.radius;
if (a.y < -a.radius)
a.y = H + a.radius;
else if (a.y > H + a.radius)
a.y = -a.radius;
a.gfx.setPosition(a.x, a.y);
a.gfx.setRotation(a.rotation);
}
}
destroyAsteroid(idx) {
const a = this.asteroids[idx];
const info = ASTEROID_SIZES[a.sizeIdx];
this.addScore(info.score, a.x, a.y - 10);
this.spawnExplosion(a.x, a.y);
this.sound.play('sfx_zap', { volume: 0.3 });
// Spawn children
if (a.sizeIdx < 2) {
const childSize = a.sizeIdx + 1;
for (let i = 0; i < 3; i++) {
this.spawnAsteroid(childSize, a.x, a.y);
}
}
a.gfx.destroy();
this.asteroids.splice(idx, 1);
// Check if wave cleared
if (this.asteroids.length === 0 && this.waveDelay <= 0) {
this.waveDelay = 2000;
}
}
/* ================================================================
COLLISIONS (manual rect/circle overlap — same pattern as Galaxy)
================================================================ */
checkCollisions() {
// Bullets vs asteroids
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
const b = this.bullets[bi];
for (let ai = this.asteroids.length - 1; ai >= 0; ai--) {
const a = this.asteroids[ai];
const dx = b.x - a.x;
const dy = b.y - a.y;
if (dx * dx + dy * dy < a.radius * a.radius) {
b.gfx.destroy();
this.bullets.splice(bi, 1);
this.destroyAsteroid(ai);
break;
}
}
}
// Ship vs asteroids
if (this.shipAlive && this.invincibleTimer <= 0) {
for (let ai = this.asteroids.length - 1; ai >= 0; ai--) {
const a = this.asteroids[ai];
const dx = this.shipX - a.x;
const dy = this.shipY - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < a.radius + SHIP_SIZE * 0.6) {
this.hitShip();
break;
}
}
}
}
/* ================================================================
SHIP DEATH / LIVES
================================================================ */
hitShip() {
this.lives--;
this.syncLivesToHUD();
this.spawnExplosion(this.shipX, this.shipY);
this.sound.play('sfx_zap', { volume: 0.5 });
this.sound.play('sfx_lose', { volume: 0.4 });
if (this.lives <= 0) {
this.shipAlive = false;
if (this.shipGfx)
this.shipGfx.setVisible(false);
if (this.thrustGfx)
this.thrustGfx.setVisible(false);
this.gameOver = true;
this.time.delayedCall(1000, () => {
this.showGameOver(this.score, () => this.scene.restart());
});
}
else {
this.shipAlive = false;
if (this.shipGfx)
this.shipGfx.setVisible(false);
if (this.thrustGfx)
this.thrustGfx.setVisible(false);
this.respawnTimer = RESPAWN_DELAY;
}
}
/* ================================================================
PARTICLES
================================================================ */
spawnExplosion(x, y) {
this.spawnParticleExplosion(x, y, 0xffffff, 8);
}
/* ================================================================
UFO ENEMY
================================================================ */
spawnUfo() {
const fromRight = Math.random() < 0.5;
const x = fromRight ? W + 30 : -30;
const y = H * (0.15 + Math.random() * 0.3);
const vx = (fromRight ? -1 : 1) * (120 + Math.random() * 80);
const gfx = this.add.graphics().setDepth(12);
this.drawUfo(gfx);
gfx.setPosition(x, y);
this.ufo = { gfx, x, y, vx, shootTimer: 1500 + Math.random() * 1000, active: true };
}
drawUfo(gfx) {
gfx.clear();
const s = SHIP_SIZE * 1.2;
// Dark shadow backdrop
gfx.lineStyle(5, 0x000000, 0.5);
gfx.strokeEllipse(0, 0, s * 2, s * 0.7);
gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5);
// Outer glow (soft magenta)
gfx.lineStyle(3, 0xff44ff, 0.25);
gfx.strokeEllipse(0, 0, s * 2, s * 0.7);
gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5);
// Solid
gfx.lineStyle(2.5, 0xff88ff, 1);
gfx.strokeEllipse(0, 0, s * 2, s * 0.7);
gfx.strokeEllipse(0, -s * 0.2, s, s * 0.5);
}
updateUfo(dt, dtSec) {
// Spawn timer
if (!this.ufo) {
this.ufoTimer -= dt;
if (this.ufoTimer <= 0) {
this.spawnUfo();
this.ufoTimer = 15000 + Math.random() * 10000;
}
// Update UFO bullets even when no UFO
this.updateUfoBullets(dtSec);
return;
}
const u = this.ufo;
u.x += u.vx * dtSec;
u.gfx.setPosition(u.x, u.y);
// Off-screen — remove
if ((u.vx > 0 && u.x > W + 60) || (u.vx < 0 && u.x < -60)) {
u.gfx.destroy();
this.ufo = null;
return;
}
// Shoot at player
u.shootTimer -= dt;
if (u.shootTimer <= 0 && this.shipAlive) {
u.shootTimer = 1200 + Math.random() * 800;
const angle = Math.atan2(this.shipY - u.y, this.shipX - u.x);
const speed = 250;
const bGfx = this.add.graphics().setDepth(8);
bGfx.fillStyle(0x000000, 0.5);
bGfx.fillCircle(0, 0, 9);
bGfx.fillStyle(0xff44ff, 0.3);
bGfx.fillCircle(0, 0, 7);
bGfx.fillStyle(0xff88ff, 1);
bGfx.fillCircle(0, 0, 3);
bGfx.setPosition(u.x, u.y);
this.ufoBullets.push({
gfx: bGfx, x: u.x, y: u.y,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
life: 3000,
});
}
this.updateUfoBullets(dtSec);
}
updateUfoBullets(dtSec) {
for (let i = this.ufoBullets.length - 1; i >= 0; i--) {
const b = this.ufoBullets[i];
b.x += b.vx * dtSec;
b.y += b.vy * dtSec;
b.life -= dtSec * 1000;
b.gfx.setPosition(b.x, b.y);
if (b.life <= 0 || b.x < -50 || b.x > W + 50 || b.y < -50 || b.y > H + 50) {
b.gfx.destroy();
this.ufoBullets.splice(i, 1);
}
}
}
checkUfoCollisions() {
if (!this.ufo)
return;
const u = this.ufo;
// Player bullets vs UFO
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
const b = this.bullets[bi];
const dx = b.x - u.x;
const dy = b.y - u.y;
if (dx * dx + dy * dy < (SHIP_SIZE * 1.5) ** 2) {
b.gfx.destroy();
this.bullets.splice(bi, 1);
this.addScore(500, u.x, u.y - 10);
this.spawnExplosion(u.x, u.y);
this.sound.play('sfx_zap', { volume: 0.4 });
u.gfx.destroy();
this.ufo = null;
return;
}
}
// UFO bullets vs player
if (this.shipAlive && this.invincibleTimer <= 0) {
for (let i = this.ufoBullets.length - 1; i >= 0; i--) {
const b = this.ufoBullets[i];
const dx = b.x - this.shipX;
const dy = b.y - this.shipY;
if (dx * dx + dy * dy < (SHIP_SIZE * 0.8) ** 2) {
b.gfx.destroy();
this.ufoBullets.splice(i, 1);
this.hitShip();
return;
}
}
}
// UFO body vs player
if (this.shipAlive && this.invincibleTimer <= 0) {
const dx = this.shipX - u.x;
const dy = this.shipY - u.y;
if (dx * dx + dy * dy < (SHIP_SIZE * 1.8) ** 2) {
this.spawnExplosion(u.x, u.y);
u.gfx.destroy();
this.ufo = null;
this.hitShip();
}
}
}
/* ================================================================
WAVE SYSTEM
================================================================ */
startWave() {
this.wave++;
this.syncLevelToHUD(this.wave);
this.sound.play('sfx_twoTone', { volume: 0.3 });
const count = INITIAL_ASTEROIDS + (this.wave - 1) * 2;
// Aim the first 2 asteroids at the ship so the player must act quickly
for (let i = 0; i < count; i++) {
this.spawnAsteroid(0, undefined, undefined, i < 2);
}
this.showWaveBanner(this.wave);
}
/* ================================================================
CLEANUP
================================================================ */
shutdown() {
super.shutdown();
if (this.ufo) {
this.ufo.gfx.destroy();
this.ufo = null;
}
this.ufoBullets.forEach(b => b.gfx.destroy());
this.ufoBullets = [];
const banner = document.getElementById('wave-banner');
if (banner)
banner.remove();
}
}
//# sourceMappingURL=CosmicRocks.js.map
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff