mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-17 21:21:20 +00:00
1307 lines
55 KiB
JavaScript
1307 lines
55 KiB
JavaScript
// 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
|