mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-18 05:31:27 +00:00
chore: publish from staged
This commit is contained in:
@@ -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 (1–100 → alpha 0.01–1.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
Reference in New Issue
Block a user