Files
awesome-copilot/extensions/arcade-canvas/game/scenes/GalaxyBlaster.js
T
2026-06-17 09:29:58 +00:00

1307 lines
55 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// GalaxyBlaster — Galaga-style space shooter.
// Direct port of WesleyEdwards/galaga mechanics: manual position math,
// De Casteljau bezier smoothing, hop+figure-eight attack patterns.
// Phaser sprites used ONLY for rendering (setPosition, setRotation, destroy).
import { BaseScene, W, H } from './BaseScene.js';
function overlap(a, b) {
return a.x < b.x + b.w && a.x + a.w > b.x &&
a.y < b.y + b.h && a.y + a.h > b.y;
}
function computeDistance(a, b) {
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
}
/* ------------------------------------------------------------------ */
/* De Casteljau bezier — exact port from PathFollower.ts */
/* ------------------------------------------------------------------ */
function getBezierPoint(t, points) {
if (points.length === 1)
return { x: points[0].x, y: points[0].y };
const next = [];
for (let i = 0; i < points.length - 1; i++) {
next.push({
x: (1 - t) * points[i].x + t * points[i + 1].x,
y: (1 - t) * points[i].y + t * points[i + 1].y,
});
}
return getBezierPoint(t, next);
}
function generatePointsOnBezierCurve(points, numOfPoints) {
const bezierPoints = [];
for (let i = 0; i <= numOfPoints; i++) {
const t = i / numOfPoints;
bezierPoints.push(getBezierPoint(t, points));
}
return bezierPoints;
}
const ENEMY_INFO = {
bug: { tex: 'space', frame: 'enemyRed1.png', hp: 1, formPts: 50, divePts: 100 },
drone: { tex: 'space', frame: 'enemyBlack4.png', hp: 1, formPts: 60, divePts: 120 },
moth: { tex: 'space', frame: 'enemyBlue3.png', hp: 2, formPts: 80, divePts: 160 },
scout: { tex: 'space', frame: 'enemyRed5.png', hp: 2, formPts: 90, divePts: 180 },
heavy: { tex: 'space', frame: 'enemyBlue5.png', hp: 3, formPts: 120, divePts: 300 },
boss: { tex: 'space', frame: 'enemyGreen2.png', hp: 4, formPts: 150, divePts: 400 },
commander: { tex: 'space', frame: 'enemyGreen2.png', hp: 2, formPts: 250, divePts: 500 },
};
function waveDef(n) {
const cycle = ((n - 1) % 5) + 1;
const tier = Math.floor((n - 1) / 5);
const extra = tier;
const cmds = n >= 2 ? Math.min(1 + Math.floor(n / 4), 2) : 0;
if (cycle === 1)
return { bugs: 8 + extra, drones: 0, moths: 0, scouts: 0, heavies: 0, bosses: 0, commanders: cmds };
if (cycle === 2)
return { bugs: 4 + extra, drones: 4, moths: 0, scouts: 0, heavies: 0, bosses: 0, commanders: cmds };
if (cycle === 3)
return { bugs: 4, drones: 2, moths: 4 + extra, scouts: 0, heavies: 0, bosses: 0, commanders: cmds };
if (cycle === 4)
return { bugs: 3, drones: 2, moths: 2, scouts: 2 + extra, heavies: 2, bosses: 0, commanders: cmds };
return { bugs: 3, drones: 2, moths: 2, scouts: 2, heavies: 1 + extra, bosses: 2, commanders: cmds };
}
/* ------------------------------------------------------------------ */
/* Constants — ref uses 500×500 design grid */
/* Recalculated in create() to pick up correct W/H after Tauri resize */
/* ------------------------------------------------------------------ */
let CONV_X = W / 500;
let CONV_Y = H / 500;
let SCALE = Math.min(CONV_X, CONV_Y);
let OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);
let ENTRY_SPEED = 0.4 * SCALE; // px/ms
let ATTACK_SPEED = 0.3 * SCALE; // px/ms
const ENTRANCE_INTERVAL = 100; // ms between spawns in a trail
let SHIP_SPEED = 0.25 * 1000 * SCALE;
let BULLET_SPEED = 0.45 * 1000 * SCALE;
const MAX_BULLETS = 3;
let ENEMY_BULLET_SPEED = 0.3 * 1000 * SCALE;
const BASE_MAX_DIVERS = 4;
const MAX_ENEMY_BULLETS = 3; // authentic Galaga: max 3 enemy bullets on screen
/* Formation: 5 rows × 10 cols, centered, in design coords scaled to screen */
const FORM_COLS = 10;
const FORM_ROWS = 5;
let COL_SPACING = OPPONENT_SIZE + 10 * CONV_X;
function formationSlot(row, col) {
const totalW = (FORM_COLS - 1) * COL_SPACING;
const startX = (W - totalW) / 2;
return {
x: startX + col * COL_SPACING,
y: (row + 1) * OPPONENT_SIZE,
};
}
/* Build full grid: row 0 = bosses, 1-2 = moths, 3-4 = bugs */
function buildFormationGrid() {
const slots = [];
for (let r = 0; r < FORM_ROWS; r++) {
for (let c = 0; c < FORM_COLS; c++) {
slots.push(formationSlot(r, c));
}
}
return slots;
}
/* ------------------------------------------------------------------ */
/* Entry path generation (ref waveOneInfo.ts style) */
/* ------------------------------------------------------------------ */
/** Bee-style entry: top-center, swoop through bottom-left, spiral up */
function beeEntryControlPoints(targetX, targetY, mirror) {
const s = mirror ? -1 : 1;
const cx = CONV_X;
const cy = CONV_Y;
return [
{ x: 300 * cx, y: -32 * cy },
{ x: (300 + s * 30) * cx, y: 50 * cy },
{ x: (300 + s * 80) * cx, y: 130 * cy },
{ x: (250 + s * 150) * cx, y: 220 * cy },
{ x: (250 + s * 180) * cx, y: 290 * cy },
{ x: (250 + s * 140) * cx, y: 340 * cy },
{ x: (250 + s * 80) * cx, y: 330 * cy },
{ x: (250 + s * 20) * cx, y: 300 * cy },
{ x: (250 - s * 30) * cx, y: 260 * cy },
{ x: (250 - s * 50) * cx, y: 210 * cy },
{ x: (250 - s * 30) * cx, y: 170 * cy },
{ x: targetX, y: targetY },
];
}
/** Moth-style entry: top-center other side, swoop through bottom-right, spiral up */
function mothEntryControlPoints(targetX, targetY, mirror) {
const s = mirror ? -1 : 1;
const cx = CONV_X;
const cy = CONV_Y;
return [
{ x: 200 * cx, y: -32 * cy },
{ x: (200 - s * 30) * cx, y: 50 * cy },
{ x: (200 - s * 80) * cx, y: 130 * cy },
{ x: (250 - s * 150) * cx, y: 220 * cy },
{ x: (250 - s * 180) * cx, y: 290 * cy },
{ x: (250 - s * 140) * cx, y: 340 * cy },
{ x: (250 - s * 80) * cx, y: 330 * cy },
{ x: (250 - s * 20) * cx, y: 300 * cy },
{ x: (250 + s * 30) * cx, y: 260 * cy },
{ x: (250 + s * 50) * cx, y: 210 * cy },
{ x: (250 + s * 30) * cx, y: 170 * cy },
{ x: targetX, y: targetY },
];
}
/** Boss entry: center spiral down */
function bossEntryControlPoints(targetX, targetY, mirror) {
const s = mirror ? -1 : 1;
const cx = CONV_X;
const cy = CONV_Y;
return [
{ x: 250 * cx, y: -32 * cy },
{ x: (250 + s * 60) * cx, y: 40 * cy },
{ x: (250 + s * 120) * cx, y: 120 * cy },
{ x: (250 + s * 100) * cx, y: 200 * cy },
{ x: (250 + s * 40) * cx, y: 280 * cy },
{ x: (250 - s * 30) * cx, y: 330 * cy },
{ x: (250 - s * 80) * cx, y: 310 * cy },
{ x: (250 - s * 60) * cx, y: 260 * cy },
{ x: (250 - s * 20) * cx, y: 200 * cy },
{ x: (250 + s * 10) * cx, y: 150 * cy },
{ x: targetX, y: targetY },
];
}
/** Side-sweep entry for variety */
function sideEntryControlPoints(targetX, targetY, fromRight) {
const cx = CONV_X;
const cy = CONV_Y;
const sx = fromRight ? 530 * cx : -30 * cx;
const mid = 250 * cx;
return [
{ x: sx, y: 200 * cy },
{ x: fromRight ? 420 * cx : 80 * cx, y: 150 * cy },
{ x: fromRight ? 350 * cx : 150 * cx, y: 100 * cy },
{ x: mid, y: 80 * cy },
{ x: fromRight ? 150 * cx : 350 * cx, y: 120 * cy },
{ x: fromRight ? 100 * cx : 400 * cx, y: 200 * cy },
{ x: fromRight ? 80 * cx : 420 * cx, y: 280 * cy },
{ x: fromRight ? 120 * cx : 380 * cx, y: 330 * cy },
{ x: fromRight ? 200 * cx : 300 * cx, y: 310 * cy },
{ x: mid, y: 260 * cy },
{ x: targetX, y: targetY },
];
}
/** Bottom-loop entry for variety */
function bottomLoopControlPoints(targetX, targetY, fromRight) {
const cx = CONV_X;
const cy = CONV_Y;
const sx = fromRight ? 530 * cx : -30 * cx;
return [
{ x: sx, y: 250 * cy },
{ x: fromRight ? 400 * cx : 100 * cx, y: 300 * cy },
{ x: fromRight ? 350 * cx : 150 * cx, y: 340 * cy },
{ x: 250 * cx, y: 340 * cy },
{ x: fromRight ? 150 * cx : 350 * cx, y: 320 * cy },
{ x: fromRight ? 100 * cx : 400 * cx, y: 280 * cy },
{ x: fromRight ? 80 * cx : 420 * cx, y: 230 * cy },
{ x: fromRight ? 120 * cx : 380 * cx, y: 170 * cy },
{ x: 250 * cx, y: 130 * cy },
{ x: targetX, y: targetY },
];
}
/* ------------------------------------------------------------------ */
/* Attack path — exact port from AttackPatterns.ts */
/* ------------------------------------------------------------------ */
function hop(currPos, path) {
const cx = CONV_X;
const cy = CONV_Y;
// 8 points: move up then arc right (in design coords scaled)
path.push({ x: currPos.x, y: currPos.y });
path.push({ x: currPos.x + 5 * cx, y: currPos.y - 10 * cy });
path.push({ x: currPos.x + 10 * cx, y: currPos.y - 25 * cy });
path.push({ x: currPos.x + 15 * cx, y: currPos.y - 40 * cy });
path.push({ x: currPos.x + 25 * cx, y: currPos.y - 50 * cy });
path.push({ x: currPos.x + 35 * cx, y: currPos.y - 45 * cy });
path.push({ x: currPos.x + 40 * cx, y: currPos.y - 30 * cy });
path.push({ x: currPos.x + 35 * cx, y: currPos.y - 15 * cy });
}
function leftAttackPattern(path) {
const cx = CONV_X;
const cy = CONV_Y;
const last = path[path.length - 1];
const bx = last.x;
const by = last.y;
// Wide figure-eight pattern (~30 points) — convX/convY scaled
path.push({ x: bx + 20 * cx, y: by + 10 * cy });
path.push({ x: bx + 40 * cx, y: by + 30 * cy });
path.push({ x: bx + 60 * cx, y: by + 60 * cy });
path.push({ x: bx + 80 * cx, y: by + 100 * cy });
path.push({ x: bx + 90 * cx, y: by + 140 * cy });
path.push({ x: bx + 85 * cx, y: by + 180 * cy });
path.push({ x: bx + 70 * cx, y: by + 210 * cy });
path.push({ x: bx + 45 * cx, y: by + 230 * cy });
path.push({ x: bx + 15 * cx, y: by + 235 * cy });
path.push({ x: bx - 15 * cx, y: by + 225 * cy });
path.push({ x: bx - 40 * cx, y: by + 200 * cy });
path.push({ x: bx - 55 * cx, y: by + 170 * cy });
path.push({ x: bx - 60 * cx, y: by + 135 * cy });
path.push({ x: bx - 55 * cx, y: by + 100 * cy });
path.push({ x: bx - 40 * cx, y: by + 70 * cy });
path.push({ x: bx - 20 * cx, y: by + 50 * cy });
path.push({ x: bx, y: by + 40 * cy });
path.push({ x: bx + 20 * cx, y: by + 50 * cy });
path.push({ x: bx + 45 * cx, y: by + 70 * cy });
path.push({ x: bx + 65 * cx, y: by + 100 * cy });
path.push({ x: bx + 75 * cx, y: by + 135 * cy });
path.push({ x: bx + 70 * cx, y: by + 170 * cy });
path.push({ x: bx + 55 * cx, y: by + 200 * cy });
path.push({ x: bx + 30 * cx, y: by + 220 * cy });
path.push({ x: bx, y: by + 230 * cy });
path.push({ x: bx - 30 * cx, y: by + 220 * cy });
path.push({ x: bx - 55 * cx, y: by + 195 * cy });
path.push({ x: bx - 70 * cx, y: by + 160 * cy });
path.push({ x: bx - 75 * cx, y: by + 120 * cy });
path.push({ x: bx - 65 * cx, y: by + 80 * cy });
path.push({ x: bx - 40 * cx, y: by + 50 * cy });
path.push({ x: bx - 10 * cx, y: by + 30 * cy });
}
function rightAttackPattern(path) {
const cx = CONV_X;
const cy = CONV_Y;
const last = path[path.length - 1];
const bx = last.x;
const by = last.y;
// Mirror of left pattern
path.push({ x: bx - 20 * cx, y: by + 10 * cy });
path.push({ x: bx - 40 * cx, y: by + 30 * cy });
path.push({ x: bx - 60 * cx, y: by + 60 * cy });
path.push({ x: bx - 80 * cx, y: by + 100 * cy });
path.push({ x: bx - 90 * cx, y: by + 140 * cy });
path.push({ x: bx - 85 * cx, y: by + 180 * cy });
path.push({ x: bx - 70 * cx, y: by + 210 * cy });
path.push({ x: bx - 45 * cx, y: by + 230 * cy });
path.push({ x: bx - 15 * cx, y: by + 235 * cy });
path.push({ x: bx + 15 * cx, y: by + 225 * cy });
path.push({ x: bx + 40 * cx, y: by + 200 * cy });
path.push({ x: bx + 55 * cx, y: by + 170 * cy });
path.push({ x: bx + 60 * cx, y: by + 135 * cy });
path.push({ x: bx + 55 * cx, y: by + 100 * cy });
path.push({ x: bx + 40 * cx, y: by + 70 * cy });
path.push({ x: bx + 20 * cx, y: by + 50 * cy });
path.push({ x: bx, y: by + 40 * cy });
path.push({ x: bx - 20 * cx, y: by + 50 * cy });
path.push({ x: bx - 45 * cx, y: by + 70 * cy });
path.push({ x: bx - 65 * cx, y: by + 100 * cy });
path.push({ x: bx - 75 * cx, y: by + 135 * cy });
path.push({ x: bx - 70 * cx, y: by + 170 * cy });
path.push({ x: bx - 55 * cx, y: by + 200 * cy });
path.push({ x: bx - 30 * cx, y: by + 220 * cy });
path.push({ x: bx, y: by + 230 * cy });
path.push({ x: bx + 30 * cx, y: by + 220 * cy });
path.push({ x: bx + 55 * cx, y: by + 195 * cy });
path.push({ x: bx + 70 * cx, y: by + 160 * cy });
path.push({ x: bx + 75 * cx, y: by + 120 * cy });
path.push({ x: bx + 65 * cx, y: by + 80 * cy });
path.push({ x: bx + 40 * cx, y: by + 50 * cy });
path.push({ x: bx + 10 * cx, y: by + 30 * cy });
}
function getAttackPath(currPos) {
const path = [];
hop(currPos, path);
if (currPos.x < W / 2) {
leftAttackPattern(path);
}
else {
rightAttackPattern(path);
}
path.push({ x: currPos.x, y: currPos.y }); // return to formation
// Scale the dive deeper so enemies reach the player's zone.
// Find how deep the pattern goes vs how deep it SHOULD go (near the ship).
const targetY = H - OPPONENT_SIZE * 4; // just above the player ship
let maxY = -Infinity;
for (const p of path) {
if (p.y > maxY)
maxY = p.y;
}
if (maxY > currPos.y && maxY < targetY) {
const yScale = (targetY - currPos.y) / (maxY - currPos.y);
for (const p of path) {
if (p !== path[path.length - 1]) { // don't scale the return-to-formation point
p.y = currPos.y + (p.y - currPos.y) * yScale;
}
}
}
return generatePointsOnBezierCurve(path, 75);
}
/* ================================================================== */
/* SCENE */
/* ================================================================== */
export class GalaxyBlasterScene extends BaseScene {
/* player */
ship;
shipX = W / 2;
shipVx = 0;
shipY = H - OPPONENT_SIZE * 3;
bullets = [];
invincible = 0;
/* shield */
shieldActive = false;
shieldSprite;
shieldPickups = [];
/* dual-shot power-up */
dualShot = false;
dualShotTimer = 0;
dualShotPickups = [];
dualShotGlow;
normalShipWidth = 0;
normalShipHeight = 0;
/* enemies */
enemies = [];
enemyBullets = [];
formation = [];
enemyOffset = -50 * CONV_X;
driftDirection = 1;
driftTimer = 0;
allStationary = false;
breatheTimer = 0;
breathePhase = 'breathe-in';
attackTimer = 0;
offsetLerping = false;
/* wave / spawn */
wave = 0;
waveDelay = 0;
spawnQueue = [];
spawnTimer = 0;
waveTextSprite = null;
/* starfield */
stars = [];
/* input */
cursors;
spaceKey;
spaceWasDown = false;
/* meteors */
meteors = [];
meteorTimer = 0;
/* game over */
gameOver = false;
constructor() { super('galaxy-blaster'); }
get displayName() { return 'Galaxy Blaster'; }
getDescription() {
return 'Battle alien formations in deep space. Clear each wave to advance!';
}
getControls() {
return [
{ key: '← →', action: 'Move Left / Right' },
{ key: 'SPACE', action: 'Fire' },
];
}
/* ================================================================
LIFECYCLE
================================================================ */
preload() {
this.load.atlasXML('space', '../assets/galaxy-blaster/space_sheet-2.png', '../assets/galaxy-blaster/space_sheet-2.xml');
this.load.image('space_bg', '../assets/galaxy-blaster/space_bg.png');
this.load.audio('sfx_laser', '../assets/galaxy-blaster/sounds/sfx_laser1.ogg');
this.load.audio('sfx_zap', '../assets/galaxy-blaster/sounds/sfx_explosion.ogg');
this.load.audio('sfx_lose', '../assets/galaxy-blaster/sounds/sfx_lose.ogg');
this.load.audio('sfx_shieldUp', '../assets/galaxy-blaster/sounds/sfx_shieldUp.ogg');
this.load.audio('sfx_shieldDown', '../assets/galaxy-blaster/sounds/sfx_shieldDown.ogg');
this.load.audio('sfx_twoTone', '../assets/galaxy-blaster/sounds/sfx_twoTone.ogg');
}
create() {
this.initBase();
// Recalculate screen-dependent constants now that W/H are correct
CONV_X = W / 500;
CONV_Y = H / 500;
SCALE = Math.min(CONV_X, CONV_Y);
OPPONENT_SIZE = Math.min(32 * SCALE, W / 35);
ENTRY_SPEED = 0.4 * SCALE;
ATTACK_SPEED = 0.3 * SCALE;
SHIP_SPEED = 0.25 * 1000 * SCALE;
BULLET_SPEED = 0.45 * 1000 * SCALE;
ENEMY_BULLET_SPEED = 0.3 * 1000 * SCALE;
COL_SPACING = OPPONENT_SIZE + 10 * CONV_X;
this.score = 0;
this.lives = 3;
this.wave = 0;
this.waveDelay = 0;
this.enemies = [];
this.bullets = [];
this.enemyBullets = [];
this.spawnQueue = [];
this.activeEmitters = [];
this.enemyOffset = -50 * CONV_X;
this.driftDirection = 1;
this.driftTimer = 0;
this.allStationary = false;
this.breatheTimer = 0;
this.breathePhase = 'breathe-in';
this.attackTimer = 0;
this.offsetLerping = false;
this.invincible = 0;
this.gameOver = false;
this.shipX = W / 2;
this.shipVx = 0;
this.shieldActive = false;
if (this.shieldSprite && this.shieldSprite.active) {
this.shieldSprite.destroy();
this.shieldSprite = undefined;
}
this.shieldPickups.forEach(p => { if (p.sprite && p.sprite.active)
p.sprite.destroy(); });
this.shieldPickups = [];
this.meteors.forEach(m => { if (m.sprite && m.sprite.active)
m.sprite.destroy(); });
this.meteors = [];
this.meteorTimer = 0;
this.ensureSparkTexture();
this.createGalaxyStarfield();
this.formation = buildFormationGrid();
this.ship = this.add.sprite(this.shipX, this.shipY, 'space', 'playerShip1_blue.png').setDepth(10);
this.ship.setDisplaySize(OPPONENT_SIZE * 1.2, OPPONENT_SIZE * 0.9);
this.normalShipWidth = OPPONENT_SIZE * 1.2;
this.normalShipHeight = OPPONENT_SIZE * 0.9;
this.dualShot = false;
this.dualShotTimer = 0;
this.dualShotPickups = [];
this.dualShotGlow = undefined;
this.cursors = this.input.keyboard.createCursorKeys();
this.spaceKey = this.input.keyboard.addKey('SPACE');
this.spaceWasDown = false;
this.syncLivesToHUD();
this.syncLevelToHUD(this.wave);
this.syncScoreToHUD();
this.loadHighScore();
this.startWithReadyScreen(() => this.startWave());
}
update(_t, dtMs) {
if (this.gameOver)
return;
const dt = Math.min(dtMs, 33);
this.updateGalaxyStarfield(dt);
this.updateShip(dt);
this.updateBullets(dt);
this.updateEnemies(dt);
this.updateEnemyBullets(dt);
this.checkCollisions();
this.updateShieldPickups(dt);
this.updateDualShotPickups(dt);
this.updateDualShot(dt);
this.updateMeteors(dt);
this.updateWave(dt);
}
/* ================================================================
STARFIELD
================================================================ */
createGalaxyStarfield() {
const bgTile = 256;
const cols = Math.ceil(W / bgTile) + 1;
const rows = Math.ceil(H / bgTile) + 1;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
this.add.image(c * bgTile + bgTile / 2, r * bgTile + bgTile / 2, 'space_bg')
.setAlpha(0.25)
.setDepth(-10);
}
}
this.stars = this.createStarfield([
{ count: 30, speed: 20, size: 1, alpha: 0.3 },
{ count: 20, speed: 40, size: 1.5, alpha: 0.4 },
{ count: 15, speed: 70, size: 2, alpha: 0.5 },
]);
}
updateGalaxyStarfield(dt) {
this.updateStarfield(this.stars, dt);
}
/* ================================================================
WAVE SYSTEM
================================================================ */
startWave() {
this.wave++;
this.syncLevelToHUD(this.wave);
const def = waveDef(this.wave);
this.spawnQueue = [];
const usedSlots = new Set(this.enemies.map(e => {
// Find slot index from resting pos
for (let i = 0; i < this.formation.length; i++) {
if (this.formation[i].x === e.restingPosX && this.formation[i].y === e.restingPosY)
return i;
}
return -1;
}));
// Determine which entry path style based on wave for variety
const waveStyle = (this.wave - 1) % 5;
// Helper: find next free slot in given rows
const findSlot = (preferredRows) => {
for (const row of preferredRows) {
for (let c = 0; c < FORM_COLS; c++) {
const idx = row * FORM_COLS + c;
if (!usedSlots.has(idx)) {
usedSlots.add(idx);
return idx;
}
}
}
// Fallback: any free slot
for (let i = 0; i < this.formation.length; i++) {
if (!usedSlots.has(i)) {
usedSlots.add(i);
return i;
}
}
return -1;
};
// Build trails: bosses → moths → bugs, each group with its own entry curve
const addTrail = (kind, count, rows, pathFn, mirror) => {
for (let i = 0; i < count; i++) {
const slotIdx = findSlot(rows);
if (slotIdx === -1)
continue;
const target = this.formation[slotIdx];
const controlPts = pathFn(target.x, target.y, mirror);
const entryPath = generatePointsOnBezierCurve(controlPts, 25);
this.spawnQueue.push({ kind, entryPath, slotIdx });
}
};
// Pick entry curve variants based on wave style
if (def.bosses > 0) {
addTrail('boss', def.bosses, [0], bossEntryControlPoints, waveStyle % 2 === 1);
}
if (def.heavies > 0) {
addTrail('heavy', def.heavies, [0, 1], sideEntryControlPoints, waveStyle % 2 === 0);
}
if (def.moths > 0) {
const mothPath = waveStyle >= 3 ? sideEntryControlPoints : mothEntryControlPoints;
addTrail('moth', def.moths, [1, 2], mothPath, waveStyle % 2 === 0);
}
if (def.scouts > 0) {
addTrail('scout', def.scouts, [2, 3], bottomLoopControlPoints, waveStyle % 2 === 1);
}
if (def.drones > 0) {
addTrail('drone', def.drones, [3, 4], beeEntryControlPoints, waveStyle % 2 === 0);
}
if (def.bugs > 0) {
const bugPath = waveStyle >= 4 ? bottomLoopControlPoints : beeEntryControlPoints;
addTrail('bug', def.bugs, [3, 4], bugPath, waveStyle % 2 === 1);
}
if (def.commanders > 0) {
addTrail('commander', def.commanders, [1, 2], sideEntryControlPoints, waveStyle % 2 === 0);
}
this.spawnTimer = 0;
this.attackTimer = 0;
this.enemyOffset = -50 * CONV_X;
this.driftDirection = 1;
this.driftTimer = 0;
this.allStationary = false;
this.offsetLerping = false;
// Clean up old wave text sprite if any
if (this.waveTextSprite) {
this.tweens.killTweensOf(this.waveTextSprite);
this.waveTextSprite.destroy();
this.waveTextSprite = null;
}
this.showWaveBanner(this.wave);
}
updateWave(dt) {
// Spawn queued enemies with entrance interval timing
if (this.spawnQueue.length > 0) {
this.spawnTimer -= dt;
if (this.spawnTimer <= 0) {
const next = this.spawnQueue.shift();
this.spawnEnemy(next.kind, next.entryPath, next.slotIdx);
this.spawnTimer = ENTRANCE_INTERVAL;
}
}
// Next wave when all enemies gone and spawn queue empty
if (this.enemies.length === 0 && this.spawnQueue.length === 0) {
this.waveDelay -= dt;
if (this.waveDelay <= 0) {
this.waveDelay = 1500;
this.sound.play('sfx_twoTone', { volume: 0.3 });
this.startWave();
}
}
else {
this.waveDelay = 1500;
}
}
/* ================================================================
ENEMY SPAWN — manual path following, NO PathFollower
================================================================ */
spawnEnemy(kind, entryPath, slotIdx) {
const info = ENEMY_INFO[kind];
const startPos = entryPath[0];
const target = this.formation[slotIdx];
const sprite = this.add.sprite(startPos.x, startPos.y, info.tex, info.frame).setDepth(5);
sprite.setDisplaySize(OPPONENT_SIZE, OPPONENT_SIZE * 0.85);
if (kind === 'commander')
sprite.setTint(0xffd700);
const e = {
sprite,
kind,
hp: info.hp,
pos: { x: startPos.x, y: startPos.y },
restingPosX: target.x,
restingPosY: target.y,
state: 'entrance',
secondaryState: 'breathe-in',
activePath: entryPath,
pathIndex: 0,
speed: ENTRY_SPEED,
breathTimer: 0,
breathingOffsetX: 0,
breathingOffsetY: 0,
attackPath: [],
shotsFired: 0,
shotTimer: 0,
};
this.enemies.push(e);
}
/* ================================================================
followPath — exact port from Opponent.ts
================================================================ */
followPath(e, dt, onCompletion) {
if (e.pathIndex >= e.activePath.length - 1)
return;
let distTraveled = e.speed * dt;
// Consume distance through multiple waypoints if needed (handles lag spikes)
let distRemaining = computeDistance(e.pos, e.activePath[e.pathIndex + 1]);
while (distTraveled > distRemaining && e.pathIndex < e.activePath.length - 1) {
distTraveled -= distRemaining;
e.pos.x = e.activePath[e.pathIndex + 1].x;
e.pos.y = e.activePath[e.pathIndex + 1].y;
e.pathIndex++;
if (e.pathIndex < e.activePath.length - 1) {
distRemaining = computeDistance(e.pos, e.activePath[e.pathIndex + 1]);
}
}
if (e.pathIndex < e.activePath.length - 1) {
let dirX = e.activePath[e.pathIndex + 1].x - e.pos.x;
let dirY = e.activePath[e.pathIndex + 1].y - e.pos.y;
const dirMag = Math.sqrt(dirX * dirX + dirY * dirY);
if (dirMag > 0.001) {
dirX /= dirMag;
dirY /= dirMag;
e.pos.x += distTraveled * dirX;
e.pos.y += distTraveled * dirY;
}
}
else {
onCompletion();
}
}
/* ================================================================
UPDATE ENEMIES — exact port of state machine
================================================================ */
updateEnemies(dt) {
const dtScale = dt / 16.67; // frame-rate independence (ref ~60fps)
// Determine whether all enemies have finished entering
const hasEntering = this.enemies.some(e => e.state === 'entrance');
const wasAllStationary = this.allStationary;
this.allStationary = !hasEntering && this.spawnQueue.length === 0 && this.enemies.length > 0;
if (!this.allStationary) {
// Phase 1: Drifting (before breathing starts)
this.driftTimer += dt;
if (this.driftTimer >= 2000) {
this.driftTimer -= 2000;
this.driftDirection *= -1;
}
this.enemyOffset += this.driftDirection * 0.05 * CONV_X * dt;
}
else {
// Phase 2: Lerp enemyOffset to 0, then start breathing
if (!wasAllStationary) {
this.offsetLerping = true;
}
if (this.offsetLerping) {
const lerpRate = 0.05 * CONV_X * dt;
if (Math.abs(this.enemyOffset) <= lerpRate) {
this.enemyOffset = 0;
this.offsetLerping = false;
this.breathePhase = 'breathe-in';
this.breatheTimer = 0;
for (const e of this.enemies) {
if (e.state === 'stationary') {
e.state = 'breathe-in';
e.breathingOffsetX = 0;
e.breathingOffsetY = 0;
}
}
}
else {
this.enemyOffset -= Math.sign(this.enemyOffset) * lerpRate;
}
}
else {
this.breatheTimer += dt;
if (this.breatheTimer >= 2000) {
this.breatheTimer -= 2000;
this.breathePhase = this.breathePhase === 'breathe-in' ? 'breathe-out' : 'breathe-in';
for (const e of this.enemies) {
if (e.state === 'breathe-in' || e.state === 'breathe-out') {
e.state = this.breathePhase;
}
if (e.state === 'attack') {
e.secondaryState = this.breathePhase;
}
}
}
}
}
// Attack coordination: scale max divers with wave (4 base, +1 per 2 waves, cap at 8)
const maxDivers = Math.min(BASE_MAX_DIVERS + Math.floor((this.wave - 1) / 2), 8);
this.attackTimer += dt;
if (this.attackTimer >= 600) {
this.attackTimer -= 600;
const attackers = this.enemies.filter(e => e.state === 'attack').length;
if (attackers < maxDivers) {
this.triggerDive();
}
}
// Update each enemy
for (let i = this.enemies.length - 1; i >= 0; i--) {
const e = this.enemies[i];
if (e.state === 'entrance') {
this.followPath(e, dt, () => {
e.pos.x = e.restingPosX + this.enemyOffset;
e.pos.y = e.restingPosY;
e.state = 'stationary';
});
}
else if (e.state === 'stationary') {
e.pos.x = e.restingPosX + this.enemyOffset;
e.pos.y = e.restingPosY;
}
else if (e.state === 'breathe-in' || e.state === 'breathe-out') {
const dir = e.state === 'breathe-in' ? 1 : -1;
e.breathingOffsetX += dir * ((e.restingPosX - W / 2) / (W / 2)) * 0.3 * dtScale;
e.breathingOffsetY += dir * (e.restingPosY / (H / 2)) * 0.4 * dtScale;
const maxOff = OPPONENT_SIZE * 1.5;
e.breathingOffsetX = Math.max(-maxOff, Math.min(maxOff, e.breathingOffsetX));
e.breathingOffsetY = Math.max(-maxOff, Math.min(maxOff, e.breathingOffsetY));
e.pos.x = e.restingPosX + e.breathingOffsetX;
e.pos.y = e.restingPosY + e.breathingOffsetY;
}
else if (e.state === 'attack') {
// Continue breathing independently via secondaryState
if (e.secondaryState === 'breathe-in' || e.secondaryState === 'breathe-out') {
const dir = e.secondaryState === 'breathe-in' ? 1 : -1;
e.breathingOffsetX += dir * ((e.restingPosX - W / 2) / (W / 2)) * 0.3 * dtScale;
e.breathingOffsetY += dir * (e.restingPosY / (H / 2)) * 0.4 * dtScale;
}
// activePath is set to attackPath when dive starts
this.followPath(e, dt, () => {
// Attack complete — return to formation / breathing state
e.pos.x = e.restingPosX + e.breathingOffsetX;
e.pos.y = e.restingPosY + e.breathingOffsetY;
e.state = (e.secondaryState === 'breathe-in' || e.secondaryState === 'breathe-out')
? e.secondaryState
: (this.allStationary ? this.breathePhase : 'stationary');
e.activePath = [];
e.pathIndex = 0;
e.shotsFired = 0;
e.shotTimer = 0;
});
// Shooting during attack: fire on start, then every ~1200ms during dive
if (e.state === 'attack') {
e.shotTimer += dt;
if (e.shotsFired >= 1 && e.shotTimer >= 400 + (e.shotsFired - 1) * 1200) {
this.fireEnemyBullet(e.pos.x, e.pos.y);
e.shotsFired++;
}
}
}
// Rendering: position sprite from manual pos
e.sprite.setPosition(e.pos.x + OPPONENT_SIZE / 2, e.pos.y + OPPONENT_SIZE / 2);
// Rotation from path direction
if ((e.state === 'entrance' || e.state === 'attack') && e.pathIndex < e.activePath.length - 1) {
const next = e.activePath[e.pathIndex + 1];
e.sprite.setRotation(Math.atan2(next.y - e.pos.y, next.x - e.pos.x) + Math.PI / 2);
}
else {
e.sprite.setRotation(0);
}
}
}
/* ================================================================
ATTACK DIVE
================================================================ */
triggerDive() {
const candidates = this.enemies.filter(e => e.state === 'stationary' || e.state === 'breathe-in' || e.state === 'breathe-out');
if (candidates.length === 0)
return;
const e = candidates[Math.floor(Math.random() * candidates.length)];
if (e.state === 'entrance')
return; // guard against race condition
// Remember breathing state as secondary so it continues independently
if (e.state === 'breathe-in' || e.state === 'breathe-out') {
e.secondaryState = e.state;
}
else {
e.secondaryState = this.breathePhase;
}
e.state = 'attack';
const atkPath = getAttackPath({ x: e.pos.x, y: e.pos.y });
e.attackPath = atkPath;
e.activePath = atkPath;
e.pathIndex = 0;
e.speed = ATTACK_SPEED;
// Fire 1 bullet immediately on attack start
this.fireEnemyBullet(e.pos.x, e.pos.y);
e.shotsFired = 1;
e.shotTimer = 0;
}
fireEnemyBullet(x, y) {
// Cap on-screen enemy bullets (authentic Galaga: max 3)
if (this.enemyBullets.length >= MAX_ENEMY_BULLETS)
return;
// Don't fire if enemy is below or at the player's level
if (y >= this.shipY)
return;
// Galaga-authentic: bullets go nearly straight down with discrete
// 3-direction aiming (straight, slight-left, slight-right).
const dx = this.shipX - x;
const horizontalBias = 0.18;
let vx = 0;
if (dx < -OPPONENT_SIZE)
vx = -ENEMY_BULLET_SPEED * horizontalBias;
else if (dx > OPPONENT_SIZE)
vx = ENEMY_BULLET_SPEED * horizontalBias;
const vy = ENEMY_BULLET_SPEED;
const sprite = this.add.sprite(x, y + 8, 'space', 'laserRed01.png').setDepth(5);
sprite.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.5);
this.enemyBullets.push({ sprite, vx, vy });
}
/* ================================================================
SHIP
================================================================ */
updateShip(dt) {
if (this.invincible > 0) {
this.invincible -= dt;
this.ship.setAlpha(Math.sin(this.invincible * 0.02) > 0 ? 1 : 0.3);
if (this.invincible <= 0)
this.ship.setAlpha(1);
}
const left = this.cursors.left.isDown;
const right = this.cursors.right.isDown;
const accel = SHIP_SPEED * 4; // accelerate to full speed quickly
const friction = 0.88; // smooth deceleration when no key held
if (left)
this.shipVx -= accel * (dt / 1000);
if (right)
this.shipVx += accel * (dt / 1000);
if (!left && !right)
this.shipVx *= friction;
// Clamp velocity
this.shipVx = Math.max(-SHIP_SPEED, Math.min(SHIP_SPEED, this.shipVx));
if (Math.abs(this.shipVx) < 1)
this.shipVx = 0;
this.shipX += this.shipVx * (dt / 1000);
this.shipX = Math.max(10, Math.min(W - 10, this.shipX));
this.ship.setPosition(this.shipX, this.shipY);
if (this.shieldSprite && this.shieldActive) {
this.shieldSprite.setPosition(this.shipX, this.shipY);
this.shieldSprite.setAlpha(0.4 + Math.sin(this.time.now / 200) * 0.2);
}
// fire (edge-detect)
const spaceDown = this.spaceKey.isDown;
if (spaceDown && !this.spaceWasDown && this.bullets.length < MAX_BULLETS) {
if (this.dualShot) {
// Dual shot — fire two parallel bullets
const offset = this.normalShipWidth * 0.3;
const s1 = this.add.sprite(this.shipX - offset, this.shipY - 12, 'space', 'laserBlue01.png').setDepth(5);
s1.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.55);
const s2 = this.add.sprite(this.shipX + offset, this.shipY - 12, 'space', 'laserBlue01.png').setDepth(5);
s2.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.55);
this.bullets.push({ sprite: s1, vx: 0, vy: -BULLET_SPEED });
this.bullets.push({ sprite: s2, vx: 0, vy: -BULLET_SPEED });
}
else {
const s = this.add.sprite(this.shipX, this.shipY - 12, 'space', 'laserBlue01.png').setDepth(5);
s.setDisplaySize(OPPONENT_SIZE * 0.15, OPPONENT_SIZE * 0.55);
this.bullets.push({ sprite: s, vx: 0, vy: -BULLET_SPEED });
}
this.sound.play('sfx_laser', { volume: 0.3 });
}
this.spaceWasDown = spaceDown;
}
/* ================================================================
BULLETS
================================================================ */
updateBullets(dt) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
b.sprite.x += b.vx * (dt / 1000);
b.sprite.y += b.vy * (dt / 1000);
if (b.sprite.y < -10) {
b.sprite.destroy();
this.bullets.splice(i, 1);
}
}
}
updateEnemyBullets(dt) {
for (let i = this.enemyBullets.length - 1; i >= 0; i--) {
const b = this.enemyBullets[i];
b.sprite.x += b.vx * (dt / 1000);
b.sprite.y += b.vy * (dt / 1000);
if (b.sprite.y > H + 10 || b.sprite.y < -10 || b.sprite.x < -10 || b.sprite.x > W + 10) {
b.sprite.destroy();
this.enemyBullets.splice(i, 1);
}
}
}
/* ================================================================
COLLISIONS
================================================================ */
checkCollisions() {
const halfSize = OPPONENT_SIZE / 2;
const halfH = OPPONENT_SIZE * 0.85 / 2;
// Player bullets vs enemies
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
const b = this.bullets[bi];
const bRect = { x: b.sprite.x - 3, y: b.sprite.y - OPPONENT_SIZE * 0.25, w: 6, h: OPPONENT_SIZE * 0.5 };
for (let ei = this.enemies.length - 1; ei >= 0; ei--) {
const e = this.enemies[ei];
const eRect = {
x: e.sprite.x - halfSize,
y: e.sprite.y - halfH,
w: OPPONENT_SIZE,
h: OPPONENT_SIZE * 0.85,
};
if (overlap(bRect, eRect)) {
b.sprite.destroy();
this.bullets.splice(bi, 1);
e.hp--;
if (e.hp <= 0) {
const info = ENEMY_INFO[e.kind];
const inFormation = e.state === 'stationary' || e.state === 'breathe-in' || e.state === 'breathe-out';
const pts = inFormation ? info.formPts : info.divePts;
this.spawnExplosion(e.sprite.x, e.sprite.y, e.kind);
this.addScore(pts, e.sprite.x, e.sprite.y - 10);
this.sound.play('sfx_zap', { volume: 0.3 });
const ex = e.sprite.x;
const ey = e.sprite.y;
e.sprite.destroy();
this.enemies.splice(ei, 1);
// Shield pickup chance (not from commanders)
if (e.kind !== 'commander' && Math.random() < 0.08) {
const pu = this.add.sprite(ex, ey, 'space', 'powerupBlue_shield.png').setDepth(5);
pu.setDisplaySize(OPPONENT_SIZE * 0.6, OPPONENT_SIZE * 0.6);
this.shieldPickups.push({ sprite: pu, vy: 180 * SCALE });
}
// Commanders always drop dual-shot pickup
if (e.kind === 'commander') {
this.spawnDualShotPickup(ex, ey);
}
}
else {
e.sprite.setTint(0xffffff);
this.time.delayedCall(80, () => { if (e.sprite && e.sprite.active)
e.sprite.clearTint(); });
}
break;
}
}
}
// Player bullets vs meteors
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
const b = this.bullets[bi];
const bRect = { x: b.sprite.x - 3, y: b.sprite.y - OPPONENT_SIZE * 0.25, w: 6, h: OPPONENT_SIZE * 0.5 };
for (let mi = this.meteors.length - 1; mi >= 0; mi--) {
const m = this.meteors[mi];
const mSize = m.sprite.displayWidth * 0.4;
const mRect = { x: m.sprite.x - mSize, y: m.sprite.y - mSize, w: mSize * 2, h: mSize * 2 };
if (overlap(bRect, mRect)) {
b.sprite.destroy();
this.bullets.splice(bi, 1);
m.hp--;
if (m.hp <= 0) {
const pts = m.sprite.displayWidth > OPPONENT_SIZE ? 150 : 75;
this.spawnExplosion(m.sprite.x, m.sprite.y, 'bug');
this.addScore(pts, m.sprite.x, m.sprite.y - 10);
this.sound.play('sfx_zap', { volume: 0.2 });
m.sprite.destroy();
this.meteors.splice(mi, 1);
}
else {
m.sprite.setTint(0xffffff);
this.time.delayedCall(80, () => { if (m.sprite && m.sprite.active)
m.sprite.clearTint(); });
}
break;
}
}
}
// Enemy bullets vs player
if (this.invincible <= 0) {
const pRect = { x: this.shipX - OPPONENT_SIZE * 0.5, y: this.shipY - OPPONENT_SIZE * 0.4, w: OPPONENT_SIZE, h: OPPONENT_SIZE * 0.8 };
for (let i = this.enemyBullets.length - 1; i >= 0; i--) {
const b = this.enemyBullets[i];
const bRect = { x: b.sprite.x - 3, y: b.sprite.y - 3, w: 6, h: 6 };
if (overlap(pRect, bRect)) {
b.sprite.destroy();
this.enemyBullets.splice(i, 1);
this.hitPlayer();
break;
}
}
}
// Enemies vs player (dive collision)
if (this.invincible <= 0) {
const pRect = { x: this.shipX - OPPONENT_SIZE * 0.5, y: this.shipY - OPPONENT_SIZE * 0.4, w: OPPONENT_SIZE, h: OPPONENT_SIZE * 0.8 };
for (let ei = this.enemies.length - 1; ei >= 0; ei--) {
const e = this.enemies[ei];
if (e.state === 'entrance')
continue;
const eRect = {
x: e.sprite.x - halfSize,
y: e.sprite.y - halfH,
w: OPPONENT_SIZE,
h: OPPONENT_SIZE * 0.85,
};
if (overlap(pRect, eRect)) {
this.spawnExplosion(e.sprite.x, e.sprite.y, e.kind);
e.sprite.destroy();
this.enemies.splice(ei, 1);
this.hitPlayer();
break;
}
}
}
}
/* ================================================================
PLAYER HIT / GAME OVER
================================================================ */
hitPlayer() {
if (this.shieldActive) {
this.shieldActive = false;
this.sound.play('sfx_shieldDown', { volume: 0.4 });
if (this.shieldSprite) {
this.shieldSprite.destroy();
this.shieldSprite = undefined;
}
this.invincible = 500;
return;
}
// Cancel dual-shot on hit
if (this.dualShot)
this.deactivateDualShot();
this.lives--;
this.syncLivesToHUD();
this.spawnExplosion(this.shipX, this.shipY, 'player');
this.sound.play('sfx_lose', { volume: 0.4 });
if (this.lives <= 0) {
this.ship.setVisible(false);
this.gameOver = true;
this.showGameOver(this.score, () => {
this.scene.restart();
});
}
else {
this.invincible = 2000;
}
}
/* ================================================================
SHIELD PICKUPS
================================================================ */
updateShieldPickups(dt) {
for (let i = this.shieldPickups.length - 1; i >= 0; i--) {
const pu = this.shieldPickups[i];
pu.sprite.y += pu.vy * (dt / 1000);
if (pu.sprite.y > H) {
pu.sprite.destroy();
this.shieldPickups.splice(i, 1);
continue;
}
const dx = Math.abs(pu.sprite.x - this.shipX);
const dy = Math.abs(pu.sprite.y - this.shipY);
if (dx < OPPONENT_SIZE * 0.8 && dy < OPPONENT_SIZE * 0.8) {
pu.sprite.destroy();
this.shieldPickups.splice(i, 1);
this.activateShield();
}
}
}
activateShield() {
if (this.shieldActive)
return;
this.shieldActive = true;
this.sound.play('sfx_shieldUp', { volume: 0.4 });
this.shieldSprite = this.add.sprite(this.shipX, this.shipY, 'space', 'shield1.png').setDepth(11);
this.shieldSprite.setDisplaySize(OPPONENT_SIZE * 1.6, OPPONENT_SIZE * 1.4);
this.shieldSprite.setAlpha(0.6);
}
/* ================================================================
DUAL-SHOT POWER-UP
================================================================ */
spawnDualShotPickup(x, y) {
const pu = this.add.sprite(x, y, 'space', 'powerupYellow_bolt.png').setDepth(5);
pu.setDisplaySize(OPPONENT_SIZE * 0.6, OPPONENT_SIZE * 0.6);
pu.setTint(0xffd700);
// Pulsing glow
this.tweens.add({
targets: pu, alpha: { from: 1, to: 0.5 },
duration: 400, yoyo: true, repeat: -1,
});
this.dualShotPickups.push({ sprite: pu, vy: 160 * SCALE });
}
updateDualShotPickups(dt) {
for (let i = this.dualShotPickups.length - 1; i >= 0; i--) {
const pu = this.dualShotPickups[i];
pu.sprite.y += pu.vy * (dt / 1000);
if (pu.sprite.y > H) {
pu.sprite.destroy();
this.dualShotPickups.splice(i, 1);
continue;
}
const dx = Math.abs(pu.sprite.x - this.shipX);
const dy = Math.abs(pu.sprite.y - this.shipY);
if (dx < OPPONENT_SIZE * 0.8 && dy < OPPONENT_SIZE * 0.8) {
pu.sprite.destroy();
this.dualShotPickups.splice(i, 1);
this.activateDualShot();
}
}
}
activateDualShot() {
this.dualShot = true;
this.dualShotTimer = 15000; // 15 seconds
this.sound.play('sfx_shieldUp', { volume: 0.4 });
// Widen ship
this.ship.setDisplaySize(this.normalShipWidth * 1.5, this.normalShipHeight);
// Add glow effect
if (this.dualShotGlow)
this.dualShotGlow.destroy();
this.dualShotGlow = this.add.sprite(this.shipX, this.shipY, 'space', 'playerShip1_blue.png').setDepth(9);
this.dualShotGlow.setDisplaySize(this.normalShipWidth * 1.8, this.normalShipHeight * 1.3);
this.dualShotGlow.setTint(0xffd700);
this.dualShotGlow.setAlpha(0.25);
}
updateDualShot(dt) {
if (!this.dualShot)
return;
this.dualShotTimer -= dt;
// Update glow position
if (this.dualShotGlow) {
this.dualShotGlow.setPosition(this.shipX, this.shipY);
this.dualShotGlow.setAlpha(0.15 + Math.sin(this.time.now / 200) * 0.1);
}
// Flash warning when about to expire
if (this.dualShotTimer < 3000 && this.dualShotTimer > 0) {
this.ship.setAlpha(Math.sin(this.dualShotTimer * 0.01) > 0 ? 1 : 0.6);
}
if (this.dualShotTimer <= 0) {
this.deactivateDualShot();
}
}
deactivateDualShot() {
this.dualShot = false;
this.dualShotTimer = 0;
this.ship.setDisplaySize(this.normalShipWidth, this.normalShipHeight);
this.ship.setAlpha(1);
if (this.dualShotGlow) {
this.dualShotGlow.destroy();
this.dualShotGlow = undefined;
}
}
/* ================================================================
METEORS
================================================================ */
static METEOR_FRAMES = [
'meteorBrown_big1.png', 'meteorBrown_big2.png', 'meteorBrown_big3.png', 'meteorBrown_big4.png',
'meteorGrey_big1.png', 'meteorGrey_big2.png', 'meteorGrey_big3.png', 'meteorGrey_big4.png',
'meteorBrown_med1.png', 'meteorBrown_med3.png',
'meteorGrey_med1.png', 'meteorGrey_med2.png',
];
updateMeteors(dt) {
// Spawn timer — one every 3-6 seconds
this.meteorTimer -= dt;
if (this.meteorTimer <= 0) {
this.meteorTimer = 3000 + Math.random() * 3000;
this.spawnMeteor();
}
// Move meteors
const dtS = dt / 1000;
for (let i = this.meteors.length - 1; i >= 0; i--) {
const m = this.meteors[i];
m.sprite.y += m.vy * dtS;
m.sprite.x += m.vx * dtS;
m.sprite.rotation += m.rotSpeed * dtS;
if (m.sprite.y > H + 80 || m.sprite.x < -80 || m.sprite.x > W + 80) {
m.sprite.destroy();
this.meteors.splice(i, 1);
}
}
// Collision with player
if (this.invincible <= 0) {
const pRect = { x: this.shipX - OPPONENT_SIZE * 0.5, y: this.shipY - OPPONENT_SIZE * 0.4, w: OPPONENT_SIZE, h: OPPONENT_SIZE * 0.8 };
for (let i = this.meteors.length - 1; i >= 0; i--) {
const m = this.meteors[i];
const mSize = m.sprite.displayWidth * 0.4;
const mRect = { x: m.sprite.x - mSize, y: m.sprite.y - mSize, w: mSize * 2, h: mSize * 2 };
if (overlap(pRect, mRect)) {
this.spawnExplosion(m.sprite.x, m.sprite.y, 'bug');
m.sprite.destroy();
this.meteors.splice(i, 1);
this.hitPlayer();
break;
}
}
}
}
spawnMeteor() {
const frame = GalaxyBlasterScene.METEOR_FRAMES[Math.floor(Math.random() * GalaxyBlasterScene.METEOR_FRAMES.length)];
const isBig = frame.includes('big');
const size = isBig ? OPPONENT_SIZE * (1.2 + Math.random() * 0.8) : OPPONENT_SIZE * (0.6 + Math.random() * 0.4);
const x = Math.random() * W;
const sprite = this.add.sprite(x, -size, 'space', frame).setDepth(3);
sprite.setDisplaySize(size, size);
sprite.setAlpha(0.85);
const vy = 60 + Math.random() * 80;
const vx = (Math.random() - 0.5) * 40;
const rotSpeed = (Math.random() - 0.5) * 2;
const hp = isBig ? 2 : 1;
this.meteors.push({ sprite, vy, vx, rotSpeed, hp });
}
/* ================================================================
EXPLOSIONS — Phaser Particle Emitter
================================================================ */
spawnExplosion(x, y, kind) {
const tintMap = {
bug: 0xffff00,
drone: 0x888888,
moth: 0x4444ff,
scout: 0xff0000,
heavy: 0x0066ff,
boss: 0x00ff00,
player: 0xffffff,
};
const tint = tintMap[kind] || tintMap.player;
const count = kind === 'player' ? 40 : 25;
this.spawnParticleExplosion(x, y, tint, count);
}
shutdown() {
super.shutdown();
// Destroy player ship
this.destroyObj(this.ship);
// Destroy bullet sprites
for (const b of this.bullets)
this.destroyObj(b.sprite);
this.bullets = [];
// Destroy enemy sprites
for (const e of this.enemies)
this.destroyObj(e.sprite);
this.enemies = [];
// Destroy enemy bullet sprites
for (const b of this.enemyBullets)
this.destroyObj(b.sprite);
this.enemyBullets = [];
// Destroy shield and pickups
this.destroyObj(this.shieldSprite);
this.shieldSprite = undefined;
for (const p of this.shieldPickups)
this.destroyObj(p);
this.shieldPickups = [];
// Destroy dual-shot pickups and glow
for (const p of this.dualShotPickups)
this.destroyObj(p.sprite);
this.dualShotPickups = [];
this.destroyObj(this.dualShotGlow);
this.dualShotGlow = undefined;
// Destroy meteors
for (const m of this.meteors)
this.destroyObj(m.sprite);
this.meteors = [];
// Destroy wave text
this.destroyObj(this.waveTextSprite);
this.waveTextSprite = null;
}
}
//# sourceMappingURL=GalaxyBlaster.js.map