mirror of
https://github.com/github/awesome-copilot.git
synced 2026-06-18 13:41:26 +00:00
1818 lines
73 KiB
JavaScript
1818 lines
73 KiB
JavaScript
// Defender — Classic 1981 Williams side-scrolling shooter.
|
||
// Protect humanoids from alien landers across a scrolling terrain world.
|
||
import { BaseScene, W, H } from './BaseScene.js';
|
||
/* ------------------------------------------------------------------ */
|
||
/* Constants */
|
||
/* ------------------------------------------------------------------ */
|
||
let SCALE = Math.min(W / 1920, H / 1080);
|
||
let PX = Math.max(3, Math.round(4 * SCALE));
|
||
const WORLD_W_SCREENS = 6;
|
||
let WORLD_W = W * WORLD_W_SCREENS;
|
||
const PLAYER_THRUST = 1400;
|
||
const PLAYER_MAX_VX = 900;
|
||
const PLAYER_VY_SPEED = 500;
|
||
const PLAYER_FRICTION = 0.985; // high inertia — ship coasts like original
|
||
const BULLET_SPEED = 1200;
|
||
const MAX_BULLETS = 8;
|
||
const INVINCIBLE_TIME = 2000;
|
||
const RESPAWN_DELAY = 800;
|
||
const EXTRA_LIFE_SCORE = 10000;
|
||
const TERRAIN_SAMPLE = 20; // pixels between terrain height samples
|
||
const TERRAIN_MIN_Y = 0.65; // fraction of H for highest peak
|
||
const TERRAIN_MAX_Y = 0.88; // fraction of H for lowest valley
|
||
const RADAR_H = 50; // taller for visibility
|
||
const RADAR_Y = 105; // well below HUD bar (~91px tall)
|
||
const ENEMY_BULLET_SPEED = 400;
|
||
const RESPAWN_SAFE_RADIUS = 300;
|
||
const RESPAWN_SAFE_RADIUS_BAITER = 600;
|
||
const RESPAWN_PUSH_OFFSET = 150;
|
||
/* ------------------------------------------------------------------ */
|
||
/* Pixel Art Data — dimensions matched to original ROM sprite list */
|
||
/* Reference: https://www.seanriddle.com/defendersprites.txt */
|
||
/* ------------------------------------------------------------------ */
|
||
// Ship: ROM = 16×6 px (8 bytes × 6 rows)
|
||
// From MAME screenshots: sleek profile facing right
|
||
// - Tapers at top and bottom (rows 0,5 are narrow)
|
||
// - Widest at center rows (1-4)
|
||
// - Magenta engine block at rear left
|
||
// - White body, cyan nose tip at right
|
||
// - Green exhaust pixels at bottom-left
|
||
const SHIP_PIXELS = [
|
||
// Row 0 — top taper (narrow, no engine visible)
|
||
[6, 0, 0xffffff], [7, 0, 0xffffff], [8, 0, 0xffffff], [9, 0, 0xffffff],
|
||
[10, 0, 0xffffff], [11, 0, 0xffffff], [12, 0, 0xffffff], [13, 0, 0xffffff],
|
||
// Row 1 — wider, engine appears
|
||
[2, 1, 0xff00ff], [3, 1, 0xff44ff],
|
||
[4, 1, 0xffffff], [5, 1, 0xffffff], [6, 1, 0xffffff], [7, 1, 0xffffff],
|
||
[8, 1, 0xffffff], [9, 1, 0xffffff], [10, 1, 0xffffff], [11, 1, 0xffffff],
|
||
[12, 1, 0xffffff], [13, 1, 0xffffff], [14, 1, 0xffffff],
|
||
// Row 2 — full width (widest), engine + body + nose tip
|
||
[0, 2, 0xff00ff], [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xff44ff],
|
||
[4, 2, 0xffffff], [5, 2, 0xffffff], [6, 2, 0xffffff], [7, 2, 0xffffff],
|
||
[8, 2, 0xffffff], [9, 2, 0xffffff], [10, 2, 0xffffff], [11, 2, 0xffffff],
|
||
[12, 2, 0xffffff], [13, 2, 0xffffff], [14, 2, 0xffffff], [15, 2, 0x00ccff],
|
||
// Row 3 — full width (widest), engine + body + nose tip
|
||
[0, 3, 0xff00ff], [1, 3, 0xff00ff], [2, 3, 0xff00ff], [3, 3, 0xff44ff],
|
||
[4, 3, 0xffffff], [5, 3, 0xffffff], [6, 3, 0xffffff], [7, 3, 0xffffff],
|
||
[8, 3, 0xffffff], [9, 3, 0xffffff], [10, 3, 0xffffff], [11, 3, 0xffffff],
|
||
[12, 3, 0xffffff], [13, 3, 0xffffff], [14, 3, 0xffffff], [15, 3, 0x00ccff],
|
||
// Row 4 — wider, engine appears
|
||
[2, 4, 0xff00ff], [3, 4, 0xff44ff],
|
||
[4, 4, 0xffffff], [5, 4, 0xffffff], [6, 4, 0xffffff], [7, 4, 0xffffff],
|
||
[8, 4, 0xffffff], [9, 4, 0xffffff], [10, 4, 0xffffff], [11, 4, 0xffffff],
|
||
[12, 4, 0xffffff], [13, 4, 0xffffff], [14, 4, 0xffffff],
|
||
// Row 5 — bottom taper + green exhaust trail
|
||
[4, 5, 0xffffff], [5, 5, 0xffffff], [6, 5, 0xffffff], [7, 5, 0xffffff],
|
||
[8, 5, 0xffffff], [9, 5, 0xffffff], [10, 5, 0xffffff], [11, 5, 0xffffff],
|
||
[0, 5, 0x00ff00], [1, 5, 0x00ff00],
|
||
];
|
||
// Lander: ROM = 10×8 px (5 bytes × 8 rows)
|
||
// H-shaped: diamond body with grabber legs below
|
||
const LANDER_PIXELS = [
|
||
// Row 0 — top center
|
||
[4, 0, 0x00ff00], [5, 0, 0x00ff00],
|
||
// Row 1 — upper diamond
|
||
[3, 1, 0x00ff00], [4, 1, 0xffff00], [5, 1, 0xffff00], [6, 1, 0x00ff00],
|
||
// Row 2 — widest body
|
||
[2, 2, 0x00ff00], [3, 2, 0x00ff00], [4, 2, 0x00ff00], [5, 2, 0x00ff00], [6, 2, 0x00ff00], [7, 2, 0x00ff00],
|
||
// Row 3 — full width with side detail
|
||
[1, 3, 0x00ff00], [2, 3, 0x00ff00], [3, 3, 0xffff00], [4, 3, 0x00ff00], [5, 3, 0x00ff00], [6, 3, 0xffff00], [7, 3, 0x00ff00], [8, 3, 0x00ff00],
|
||
// Row 4 — lower body
|
||
[2, 4, 0x00ff00], [3, 4, 0x00ff00], [4, 4, 0x00ff00], [5, 4, 0x00ff00], [6, 4, 0x00ff00], [7, 4, 0x00ff00],
|
||
// Row 5 — narrowing
|
||
[3, 5, 0x00ff00], [4, 5, 0x00ff00], [5, 5, 0x00ff00], [6, 5, 0x00ff00],
|
||
// Row 6 — legs
|
||
[1, 6, 0xffff00], [2, 6, 0xffff00], [7, 6, 0xffff00], [8, 6, 0xffff00],
|
||
// Row 7 — leg tips
|
||
[0, 7, 0xffff00], [1, 7, 0xffff00], [8, 7, 0xffff00], [9, 7, 0xffff00],
|
||
];
|
||
// Mutant: ROM = 10×8 px (5 bytes × 8 rows)
|
||
// Composite of lander + humanoid overlay, blobby organic look
|
||
const MUTANT_PIXELS = [
|
||
// Row 0
|
||
[3, 0, 0xff00ff], [4, 0, 0xff00ff], [5, 0, 0xff00ff], [6, 0, 0xff00ff],
|
||
// Row 1
|
||
[2, 1, 0xff00ff], [3, 1, 0xcc00cc], [4, 1, 0xcc00cc], [5, 1, 0xcc00cc], [6, 1, 0xcc00cc], [7, 1, 0xff00ff],
|
||
// Row 2 — yellow-green eyes
|
||
[1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xaaff00], [4, 2, 0xff00ff], [5, 2, 0xff00ff], [6, 2, 0xaaff00], [7, 2, 0xff00ff], [8, 2, 0xff00ff],
|
||
// Row 3 — widest
|
||
[0, 3, 0xff00ff], [1, 3, 0xff00ff], [2, 3, 0xff00ff], [3, 3, 0xff00ff], [4, 3, 0xff00ff], [5, 3, 0xff00ff], [6, 3, 0xff00ff], [7, 3, 0xff00ff], [8, 3, 0xff00ff], [9, 3, 0xff00ff],
|
||
// Row 4 — widest
|
||
[0, 4, 0xff00ff], [1, 4, 0xff00ff], [2, 4, 0xff00ff], [3, 4, 0xff00ff], [4, 4, 0xff00ff], [5, 4, 0xff00ff], [6, 4, 0xff00ff], [7, 4, 0xff00ff], [8, 4, 0xff00ff], [9, 4, 0xff00ff],
|
||
// Row 5
|
||
[1, 5, 0xcc00cc], [2, 5, 0xff00ff], [3, 5, 0xff00ff], [4, 5, 0xff00ff], [5, 5, 0xff00ff], [6, 5, 0xff00ff], [7, 5, 0xff00ff], [8, 5, 0xcc00cc],
|
||
// Row 6
|
||
[2, 6, 0xcc00cc], [3, 6, 0xff00ff], [4, 6, 0xff00ff], [5, 6, 0xff00ff], [6, 6, 0xff00ff], [7, 6, 0xcc00cc],
|
||
// Row 7
|
||
[3, 7, 0xcc00cc], [4, 7, 0xcc00cc], [5, 7, 0xcc00cc], [6, 7, 0xcc00cc],
|
||
];
|
||
// Humanoid: ROM = 4×8 px (2 bytes × 8 rows)
|
||
// Multi-colored: green upper body, magenta/pink lower half
|
||
const HUMANOID_PIXELS = [
|
||
// Row 0 — head (green)
|
||
[1, 0, 0x00ff00], [2, 0, 0x00ff00],
|
||
// Row 1 — neck (green)
|
||
[1, 1, 0x00ff00], [2, 1, 0x00ff00],
|
||
// Row 2 — arms + torso (green)
|
||
[0, 2, 0x00ff00], [1, 2, 0x00ff00], [2, 2, 0x00ff00], [3, 2, 0x00ff00],
|
||
// Row 3 — torso (green)
|
||
[1, 3, 0x00ff00], [2, 3, 0x00ff00],
|
||
// Row 4 — waist (magenta transition)
|
||
[1, 4, 0xff00ff], [2, 4, 0xff00ff],
|
||
// Row 5 — hips (magenta)
|
||
[1, 5, 0xff00ff], [2, 5, 0xff00ff],
|
||
// Row 6 — legs (magenta)
|
||
[0, 6, 0xff00ff], [3, 6, 0xff00ff],
|
||
// Row 7 — feet (magenta)
|
||
[0, 7, 0xff00ff], [3, 7, 0xff00ff],
|
||
];
|
||
// Bomber: ROM = 8×8 px (4 bytes × 8 rows)
|
||
// Compact square block with segmented look, NOT a wide rectangle
|
||
const BOMBER_PIXELS = [
|
||
// Row 0 — top edge
|
||
[1, 0, 0xffff00], [2, 0, 0xffff00], [3, 0, 0xffff00], [4, 0, 0xffff00], [5, 0, 0xffff00], [6, 0, 0xffff00],
|
||
// Row 1 — top stripe with detail
|
||
[0, 1, 0xffff00], [1, 1, 0xff4400], [2, 1, 0xffff00], [3, 1, 0xff4400], [4, 1, 0xffff00], [5, 1, 0xff4400], [6, 1, 0xffff00], [7, 1, 0xffff00],
|
||
// Row 2 — solid
|
||
[0, 2, 0xffff00], [1, 2, 0xffff00], [2, 2, 0xffff00], [3, 2, 0xffff00], [4, 2, 0xffff00], [5, 2, 0xffff00], [6, 2, 0xffff00], [7, 2, 0xffff00],
|
||
// Row 3 — center detail
|
||
[0, 3, 0xffff00], [1, 3, 0xffff00], [2, 3, 0xff4400], [3, 3, 0xffff00], [4, 3, 0xffff00], [5, 3, 0xff4400], [6, 3, 0xffff00], [7, 3, 0xffff00],
|
||
// Row 4 — center detail
|
||
[0, 4, 0xffff00], [1, 4, 0xffff00], [2, 4, 0xff4400], [3, 4, 0xffff00], [4, 4, 0xffff00], [5, 4, 0xff4400], [6, 4, 0xffff00], [7, 4, 0xffff00],
|
||
// Row 5 — solid
|
||
[0, 5, 0xffff00], [1, 5, 0xffff00], [2, 5, 0xffff00], [3, 5, 0xffff00], [4, 5, 0xffff00], [5, 5, 0xffff00], [6, 5, 0xffff00], [7, 5, 0xffff00],
|
||
// Row 6 — bottom stripe
|
||
[0, 6, 0xffff00], [1, 6, 0xff4400], [2, 6, 0xffff00], [3, 6, 0xff4400], [4, 6, 0xffff00], [5, 6, 0xff4400], [6, 6, 0xffff00], [7, 6, 0xffff00],
|
||
// Row 7 — bottom edge
|
||
[1, 7, 0xffff00], [2, 7, 0xffff00], [3, 7, 0xffff00], [4, 7, 0xffff00], [5, 7, 0xffff00], [6, 7, 0xffff00],
|
||
];
|
||
// Baiter: ROM = 12×4 px (6 bytes × 4 rows)
|
||
// Thin horseshoe/C shape — narrow and aggressive
|
||
const BAITER_PIXELS = [
|
||
// Row 0 — top bar
|
||
[0, 0, 0x00ff44], [1, 0, 0x00ff44], [2, 0, 0x00ff44], [3, 0, 0x00ff44], [4, 0, 0x00ff44], [5, 0, 0x00ff44], [6, 0, 0x00ff44], [7, 0, 0x00ff44], [8, 0, 0x00ff44], [9, 0, 0x00ff44], [10, 0, 0x00ff44], [11, 0, 0x00ff44],
|
||
// Row 1 — gap in middle
|
||
[0, 1, 0x00ff44], [1, 1, 0x00ff44], [10, 1, 0x00ff44], [11, 1, 0x00ff44],
|
||
// Row 2 — gap in middle
|
||
[0, 2, 0x00ff44], [1, 2, 0x00ff44], [10, 2, 0x00ff44], [11, 2, 0x00ff44],
|
||
// Row 3 — bottom bar
|
||
[0, 3, 0x00ff44], [1, 3, 0x00ff44], [2, 3, 0x00ff44], [3, 3, 0x00ff44], [4, 3, 0x00ff44], [5, 3, 0x00ff44], [6, 3, 0x00ff44], [7, 3, 0x00ff44], [8, 3, 0x00ff44], [9, 3, 0x00ff44], [10, 3, 0x00ff44], [11, 3, 0x00ff44],
|
||
];
|
||
// Pod: ROM = 8×8 px (4 bytes × 8 rows)
|
||
// Compact oval/circle shape, not a large egg
|
||
const POD_PIXELS = [
|
||
// Row 0
|
||
[2, 0, 0xcc00cc], [3, 0, 0xcc00cc], [4, 0, 0xcc00cc], [5, 0, 0xcc00cc],
|
||
// Row 1
|
||
[1, 1, 0xcc00cc], [2, 1, 0xff00ff], [3, 1, 0xff00ff], [4, 1, 0xff00ff], [5, 1, 0xff00ff], [6, 1, 0xcc00cc],
|
||
// Row 2
|
||
[0, 2, 0xcc00cc], [1, 2, 0xff00ff], [2, 2, 0xff00ff], [3, 2, 0xff44ff], [4, 2, 0xff44ff], [5, 2, 0xff00ff], [6, 2, 0xff00ff], [7, 2, 0xcc00cc],
|
||
// Row 3
|
||
[0, 3, 0xcc00cc], [1, 3, 0xff00ff], [2, 3, 0xff44ff], [3, 3, 0xff00ff], [4, 3, 0xff00ff], [5, 3, 0xff44ff], [6, 3, 0xff00ff], [7, 3, 0xcc00cc],
|
||
// Row 4
|
||
[0, 4, 0xcc00cc], [1, 4, 0xff00ff], [2, 4, 0xff44ff], [3, 4, 0xff00ff], [4, 4, 0xff00ff], [5, 4, 0xff44ff], [6, 4, 0xff00ff], [7, 4, 0xcc00cc],
|
||
// Row 5
|
||
[0, 5, 0xcc00cc], [1, 5, 0xff00ff], [2, 5, 0xff00ff], [3, 5, 0xff44ff], [4, 5, 0xff44ff], [5, 5, 0xff00ff], [6, 5, 0xff00ff], [7, 5, 0xcc00cc],
|
||
// Row 6
|
||
[1, 6, 0xcc00cc], [2, 6, 0xff00ff], [3, 6, 0xff00ff], [4, 6, 0xff00ff], [5, 6, 0xff00ff], [6, 6, 0xcc00cc],
|
||
// Row 7
|
||
[2, 7, 0xcc00cc], [3, 7, 0xcc00cc], [4, 7, 0xcc00cc], [5, 7, 0xcc00cc],
|
||
];
|
||
// Swarmer: ROM = 6×4 px (3 bytes × 4 rows)
|
||
// Wider than tall cross/star shape
|
||
const SWARMER_PIXELS = [
|
||
// Row 0
|
||
[2, 0, 0xffff00], [3, 0, 0xffff00],
|
||
// Row 1 — full width
|
||
[0, 1, 0xffff00], [1, 1, 0xffff00], [2, 1, 0xffff00], [3, 1, 0xffff00], [4, 1, 0xffff00], [5, 1, 0xffff00],
|
||
// Row 2 — full width
|
||
[0, 2, 0xffff00], [1, 2, 0xffff00], [2, 2, 0xffff00], [3, 2, 0xffff00], [4, 2, 0xffff00], [5, 2, 0xffff00],
|
||
// Row 3
|
||
[2, 3, 0xffff00], [3, 3, 0xffff00],
|
||
];
|
||
/* ------------------------------------------------------------------ */
|
||
/* Scene */
|
||
/* ------------------------------------------------------------------ */
|
||
export class PlanetGuardianScene extends BaseScene {
|
||
/* Player state */
|
||
playerX = 0;
|
||
playerY = 0;
|
||
playerVx = 0;
|
||
playerVy = 0;
|
||
facingRight = true;
|
||
shipAlive = true;
|
||
invincibleTimer = 0;
|
||
respawnTimer = 0;
|
||
smartBombs = 3;
|
||
carriedHumanoid = -1; // index of humanoid being carried, -1 = none
|
||
nextExtraLife = EXTRA_LIFE_SCORE;
|
||
/* Game objects */
|
||
enemies = [];
|
||
humanoids = [];
|
||
bullets = [];
|
||
mines = [];
|
||
stars = [];
|
||
/* Terrain */
|
||
terrainHeights = [];
|
||
planetDestroyed = false;
|
||
/* Camera / scroll */
|
||
cameraX = 0;
|
||
spriteScale = 1; // calculated in create()
|
||
/* Game state */
|
||
wave = 0;
|
||
gameOver = false;
|
||
waveTimer = 0; // time elapsed in current wave (for baiter spawning)
|
||
waveDelay = 0;
|
||
baiterSpawned = false;
|
||
/* Graphics objects */
|
||
gameGfx; // main game graphics
|
||
radarGfx; // radar minimap
|
||
terrainGfx; // terrain graphics
|
||
hudExtraGfx; // smart bomb display
|
||
shipSprite; // player ship sprite
|
||
/* Input */
|
||
cursors;
|
||
fireKey;
|
||
bombKey;
|
||
fireWasDown = false;
|
||
bombWasDown = false;
|
||
fireCooldown = 0; // rapid-fire rate limiter
|
||
thrustSoundPlaying = false;
|
||
constructor() { super('defender'); }
|
||
get displayName() { return 'Planet Guardian'; }
|
||
getDescription() {
|
||
return 'Defend humanoids from alien landers. Rescue the falling and destroy all enemies!';
|
||
}
|
||
getControls() {
|
||
return [
|
||
{ key: '← →', action: 'Thrust / Reverse' },
|
||
{ key: '↑ ↓', action: 'Move Up / Down' },
|
||
{ key: 'SPACE', action: 'Fire Laser (hold)' },
|
||
{ key: 'Z', action: 'Smart Bomb' },
|
||
];
|
||
}
|
||
/* ================================================================
|
||
LIFECYCLE
|
||
================================================================ */
|
||
preload() {
|
||
// Load sprite PNGs (generated pixel art, CC0-compatible original designs)
|
||
this.load.image('def-ship-r', '../assets/defender/ship.png');
|
||
this.load.image('def-ship-l', '../assets/defender/ship_left.png');
|
||
this.load.image('def-lander', '../assets/defender/lander.png');
|
||
this.load.image('def-mutant', '../assets/defender/mutant.png');
|
||
this.load.image('def-humanoid', '../assets/defender/humanoid.png');
|
||
this.load.image('def-bomber', '../assets/defender/bomber.png');
|
||
this.load.image('def-pod', '../assets/defender/pod.png');
|
||
this.load.image('def-swarmer', '../assets/defender/swarmer.png');
|
||
this.load.image('def-baiter', '../assets/defender/baiter.png');
|
||
// Sounds from OpenDefender
|
||
this.load.audio('snd_laser', '../assets/defender/sounds/sound_laser.wav');
|
||
this.load.audio('snd_enemydead', '../assets/defender/sounds/sound_enemydead.wav');
|
||
this.load.audio('snd_explode', '../assets/defender/sounds/sound_explode.wav');
|
||
this.load.audio('snd_playerdead', '../assets/defender/sounds/sound_playerdead.wav');
|
||
this.load.audio('snd_bonus', '../assets/defender/sounds/sound_bonus.wav');
|
||
this.load.audio('snd_humanoiddead', '../assets/defender/sounds/sound_humanoiddead.wav');
|
||
this.load.audio('snd_start', '../assets/defender/sounds/sound_start.wav');
|
||
this.load.audio('snd_thrust', '../assets/defender/sounds/sound_thurst.wav');
|
||
this.load.audio('snd_warning', '../assets/defender/sounds/sound_warning.wav');
|
||
this.load.audio('snd_baiterwarning', '../assets/defender/sounds/sound_baiterwarning.wav');
|
||
this.load.audio('snd_player1up', '../assets/defender/sounds/sound_player1up.wav');
|
||
this.load.audio('snd_enemyshoot', '../assets/defender/sounds/sound_enemyshoot.wav');
|
||
this.load.audio('snd_enemyshoot2', '../assets/defender/sounds/sound_enemyshoot2.wav');
|
||
}
|
||
create() {
|
||
this.initBase();
|
||
// Switch Planet Guardian textures to linear filtering for smoother scaling
|
||
const defKeys = ['def-ship-r', 'def-ship-l', 'def-lander', 'def-mutant',
|
||
'def-humanoid', 'def-bomber', 'def-pod', 'def-swarmer', 'def-baiter'];
|
||
for (const k of defKeys) {
|
||
const tex = this.textures.get(k);
|
||
if (tex && tex.source[0]?.glTexture) {
|
||
tex.setFilter(Phaser.Textures.FilterMode.LINEAR);
|
||
}
|
||
}
|
||
// Recalculate screen-dependent constants
|
||
SCALE = Math.min(W / 1920, H / 1080);
|
||
PX = Math.max(3, Math.round(4 * SCALE));
|
||
WORLD_W = W * WORLD_W_SCREENS;
|
||
// Reset state
|
||
this.score = 0;
|
||
this.lives = 3;
|
||
this.wave = 0;
|
||
this.gameOver = false;
|
||
this.planetDestroyed = false;
|
||
this.smartBombs = 3;
|
||
this.carriedHumanoid = -1;
|
||
this.nextExtraLife = EXTRA_LIFE_SCORE;
|
||
this.playerX = WORLD_W / 2;
|
||
this.playerY = H * 0.4;
|
||
this.playerVx = 0;
|
||
this.playerVy = 0;
|
||
this.facingRight = true;
|
||
this.shipAlive = true;
|
||
this.invincibleTimer = 0;
|
||
this.respawnTimer = 0;
|
||
this.enemies = [];
|
||
this.humanoids = [];
|
||
this.bullets = [];
|
||
this.mines = [];
|
||
this.stars = [];
|
||
this.activeEmitters = [];
|
||
this.waveTimer = 0;
|
||
this.waveDelay = 0;
|
||
this.baiterSpawned = false;
|
||
this.ensureSparkTexture();
|
||
// Starfield
|
||
this.stars = this.createStarfield([
|
||
{ count: 50, speed: 0, size: 1, alpha: 0.25 },
|
||
{ count: 30, speed: 0, size: 1.5, alpha: 0.35 },
|
||
{ count: 15, speed: 0, size: 2, alpha: 0.45 },
|
||
]);
|
||
// Generate terrain
|
||
this.generateTerrain();
|
||
// Sprite scale — ensure sprites are visible across all monitor sizes
|
||
// At 1080p (SCALE=1.0): scale ~0.8 → ship 94px, enemies 55-64px
|
||
// At 720p (SCALE=0.67): scale ~0.6 → ship 71px, enemies 40-50px
|
||
// Floor of 0.55 ensures minimum ~46px ship, ~28px swarmer on small monitors
|
||
this.spriteScale = Math.max(0.35, 0.55 * SCALE);
|
||
// Graphics layers
|
||
this.terrainGfx = this.add.graphics().setDepth(5);
|
||
this.gameGfx = this.add.graphics().setDepth(10);
|
||
this.radarGfx = this.add.graphics().setDepth(800);
|
||
this.hudExtraGfx = this.add.graphics().setDepth(801);
|
||
// Player ship sprite (scale to match screen)
|
||
this.shipSprite = this.add.image(0, 0, 'def-ship-r').setDepth(10).setOrigin(0.5, 0.5).setScale(this.spriteScale);
|
||
// Input — set up references but don't capture yet (ready screen needs keydown)
|
||
this.cursors = this.input.keyboard.createCursorKeys();
|
||
this.fireKey = this.input.keyboard.addKey('SPACE');
|
||
this.bombKey = this.input.keyboard.addKey('Z');
|
||
this.fireWasDown = false;
|
||
this.bombWasDown = false;
|
||
this.fireCooldown = 0;
|
||
this.thrustSoundPlaying = false;
|
||
this.syncLivesToHUD();
|
||
this.syncScoreToHUD();
|
||
this.loadHighScore();
|
||
this.startWithReadyScreen(() => {
|
||
// Capture keys only after ready screen dismisses
|
||
this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT,SPACE,Z');
|
||
this.startWave();
|
||
});
|
||
}
|
||
update(_t, dtMs) {
|
||
if (this.gameOver || !this.cursors)
|
||
return;
|
||
const dt = Math.min(dtMs, 33);
|
||
const dtSec = dt / 1000;
|
||
// Respawn timer
|
||
if (this.respawnTimer > 0) {
|
||
this.respawnTimer -= dt;
|
||
if (this.respawnTimer <= 0)
|
||
this.respawnPlayer();
|
||
}
|
||
// Fire cooldown
|
||
if (this.fireCooldown > 0)
|
||
this.fireCooldown -= dt;
|
||
// Player input & physics
|
||
if (this.shipAlive) {
|
||
this.updatePlayerInput(dtSec);
|
||
this.updatePlayerPhysics(dtSec);
|
||
}
|
||
// Update camera to follow player
|
||
this.updateCamera(dtSec);
|
||
// Update entities
|
||
this.updateEnemies(dtSec);
|
||
this.updateHumanoids(dtSec);
|
||
this.updateBulletsPhysics(dtSec);
|
||
this.updateMines(dt);
|
||
this.checkCollisions();
|
||
// Wave management
|
||
this.waveTimer += dt;
|
||
if (!this.baiterSpawned && this.wave >= 2 && this.waveTimer > 30000) {
|
||
this.spawnBaiter();
|
||
this.baiterSpawned = true;
|
||
}
|
||
if (this.waveDelay > 0) {
|
||
this.waveDelay -= dt;
|
||
if (this.waveDelay <= 0)
|
||
this.startWave();
|
||
}
|
||
else if (this.enemies.filter(e => e.alive).length === 0 && this.mines.length === 0 && this.waveDelay <= 0 && this.wave > 0) {
|
||
// Wave complete
|
||
this.onWaveComplete();
|
||
}
|
||
// Invincibility blink
|
||
if (this.invincibleTimer > 0) {
|
||
this.invincibleTimer -= dt;
|
||
}
|
||
// Clean up expired emitters (handled by delayed destroy in spawnExplosion)
|
||
// Render everything
|
||
this.renderGame();
|
||
}
|
||
/* ================================================================
|
||
TERRAIN
|
||
================================================================ */
|
||
generateTerrain() {
|
||
const numSamples = Math.ceil(WORLD_W / TERRAIN_SAMPLE) + 1;
|
||
this.terrainHeights = [];
|
||
// Generate raw heights
|
||
for (let i = 0; i < numSamples; i++) {
|
||
const t = i / numSamples;
|
||
const base = H * (TERRAIN_MIN_Y + (TERRAIN_MAX_Y - TERRAIN_MIN_Y) * 0.5);
|
||
const variation = H * (TERRAIN_MAX_Y - TERRAIN_MIN_Y) * 0.5;
|
||
const h = base +
|
||
Math.sin(t * Math.PI * 12) * variation * 0.4 +
|
||
Math.sin(t * Math.PI * 25 + 1.3) * variation * 0.3 +
|
||
Math.sin(t * Math.PI * 50 + 2.7) * variation * 0.2 +
|
||
(Math.random() - 0.5) * variation * 0.3;
|
||
this.terrainHeights.push(h);
|
||
}
|
||
// Smooth
|
||
for (let pass = 0; pass < 3; pass++) {
|
||
const smoothed = [...this.terrainHeights];
|
||
for (let i = 1; i < smoothed.length - 1; i++) {
|
||
smoothed[i] = (this.terrainHeights[i - 1] + this.terrainHeights[i] + this.terrainHeights[i + 1]) / 3;
|
||
}
|
||
// Wrap edges
|
||
smoothed[0] = (this.terrainHeights[this.terrainHeights.length - 1] + this.terrainHeights[0] + this.terrainHeights[1]) / 3;
|
||
smoothed[smoothed.length - 1] = (this.terrainHeights[this.terrainHeights.length - 2] + this.terrainHeights[this.terrainHeights.length - 1] + this.terrainHeights[0]) / 3;
|
||
this.terrainHeights = smoothed;
|
||
}
|
||
}
|
||
getTerrainY(worldX) {
|
||
// Wrap x into world range
|
||
let wx = this.wrapWorldX(worldX);
|
||
const idx = wx / TERRAIN_SAMPLE;
|
||
const i0 = Math.floor(idx) % this.terrainHeights.length;
|
||
const i1 = (i0 + 1) % this.terrainHeights.length;
|
||
const frac = idx - Math.floor(idx);
|
||
return this.terrainHeights[i0] * (1 - frac) + this.terrainHeights[i1] * frac;
|
||
}
|
||
wrapWorldX(x) {
|
||
return ((x % WORLD_W) + WORLD_W) % WORLD_W;
|
||
}
|
||
/* ================================================================
|
||
PLAYER
|
||
================================================================ */
|
||
updatePlayerInput(dtSec) {
|
||
// Original Defender controls:
|
||
// - UP/DOWN = vertical movement (joystick)
|
||
// - LEFT = reverse (flip ship facing)
|
||
// - RIGHT = thrust (forward in facing direction)
|
||
// Adapted for keyboard: LEFT/RIGHT still control direction,
|
||
// but pressing opposite to facing FIRST reverses, THEN thrusts
|
||
// with a brief acceleration delay to simulate reverse→thrust feel.
|
||
const leftDown = this.cursors.left.isDown;
|
||
const rightDown = this.cursors.right.isDown;
|
||
if (rightDown && !leftDown) {
|
||
if (!this.facingRight) {
|
||
// Reversing: flip first, apply reduced thrust
|
||
this.facingRight = true;
|
||
this.playerVx += PLAYER_THRUST * dtSec * 0.3;
|
||
}
|
||
else {
|
||
// Thrusting forward
|
||
this.playerVx += PLAYER_THRUST * dtSec;
|
||
}
|
||
}
|
||
else if (leftDown && !rightDown) {
|
||
if (this.facingRight) {
|
||
// Reversing: flip first, apply reduced thrust
|
||
this.facingRight = false;
|
||
this.playerVx -= PLAYER_THRUST * dtSec * 0.3;
|
||
}
|
||
else {
|
||
// Thrusting forward
|
||
this.playerVx -= PLAYER_THRUST * dtSec;
|
||
}
|
||
}
|
||
// Vertical movement (direct, like original joystick)
|
||
if (this.cursors.up.isDown) {
|
||
this.playerVy = -PLAYER_VY_SPEED;
|
||
}
|
||
else if (this.cursors.down.isDown) {
|
||
this.playerVy = PLAYER_VY_SPEED;
|
||
}
|
||
else {
|
||
this.playerVy *= 0.9;
|
||
}
|
||
// Fire — RAPID-FIRE when held down (original Defender behavior)
|
||
if (this.fireKey.isDown) {
|
||
this.fireBullet();
|
||
}
|
||
// Smart bomb — single press
|
||
const bombDown = this.bombKey.isDown;
|
||
if (bombDown && !this.bombWasDown) {
|
||
this.useSmartBomb();
|
||
}
|
||
this.bombWasDown = bombDown;
|
||
// Thrust sound
|
||
const isThrusting = this.cursors.left.isDown || this.cursors.right.isDown;
|
||
if (isThrusting && !this.thrustSoundPlaying) {
|
||
try {
|
||
this.sound.play('snd_thrust', { volume: 0.15, loop: true });
|
||
}
|
||
catch { }
|
||
this.thrustSoundPlaying = true;
|
||
}
|
||
else if (!isThrusting && this.thrustSoundPlaying) {
|
||
try {
|
||
this.sound.stopByKey('snd_thrust');
|
||
}
|
||
catch { }
|
||
this.thrustSoundPlaying = false;
|
||
}
|
||
}
|
||
updatePlayerPhysics(dtSec) {
|
||
// Friction on horizontal
|
||
this.playerVx *= Math.pow(PLAYER_FRICTION, dtSec * 60);
|
||
// Clamp
|
||
if (this.playerVx > PLAYER_MAX_VX)
|
||
this.playerVx = PLAYER_MAX_VX;
|
||
if (this.playerVx < -PLAYER_MAX_VX)
|
||
this.playerVx = -PLAYER_MAX_VX;
|
||
this.playerX += this.playerVx * dtSec;
|
||
this.playerY += this.playerVy * dtSec;
|
||
// World wrap X
|
||
this.playerX = this.wrapWorldX(this.playerX);
|
||
// Clamp Y — only prevent going off-screen, NOT above terrain
|
||
// In original Defender, ship can fly below the mountain line
|
||
const topLimit = RADAR_Y + RADAR_H + 10;
|
||
if (this.playerY < topLimit)
|
||
this.playerY = topLimit;
|
||
if (this.playerY > H - 10)
|
||
this.playerY = H - 10;
|
||
// Carry humanoid
|
||
if (this.carriedHumanoid >= 0) {
|
||
const h = this.humanoids[this.carriedHumanoid];
|
||
if (h && h.state === 'rescued') {
|
||
h.x = this.playerX;
|
||
h.y = this.playerY + 10 * PX / 3;
|
||
// Check if touching terrain to return humanoid
|
||
if (!this.planetDestroyed) {
|
||
const tY = this.getTerrainY(h.x);
|
||
if (h.y >= tY - 5) {
|
||
h.y = tY - 3;
|
||
h.state = 'walking';
|
||
h.vx = (Math.random() > 0.5 ? 1 : -1) * 15;
|
||
this.carriedHumanoid = -1;
|
||
this.addScore(500, this.worldToScreenX(h.x), h.y);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
respawnPlayer() {
|
||
this.shipAlive = true;
|
||
this.invincibleTimer = INVINCIBLE_TIME;
|
||
this.smartBombs = 3;
|
||
this.carriedHumanoid = -1;
|
||
this.playerVx = 0;
|
||
this.playerVy = 0;
|
||
this.playerY = H * 0.4;
|
||
// Safety: push nearby enemies away from spawn point
|
||
// Baiters get pushed much further since they home aggressively
|
||
for (const e of this.enemies) {
|
||
if (!e.alive)
|
||
continue;
|
||
const safeRadius = e.type === 'baiter' ? RESPAWN_SAFE_RADIUS_BAITER : RESPAWN_SAFE_RADIUS;
|
||
const d = this.worldDist(e.x, e.y, this.playerX, this.playerY);
|
||
if (d < safeRadius) {
|
||
const angle = Math.atan2(e.y - this.playerY, e.x - this.playerX) || Math.random() * Math.PI * 2;
|
||
e.x = this.playerX + Math.cos(angle) * (safeRadius + RESPAWN_PUSH_OFFSET);
|
||
e.y = this.playerY + Math.sin(angle) * (safeRadius * 0.4);
|
||
e.x = this.wrapWorldX(e.x);
|
||
// Kill velocity so they don't rush back immediately
|
||
e.vx *= 0.1;
|
||
e.vy *= 0.1;
|
||
// Reset baiter to dormant phase so player has time to orient
|
||
if (e.type === 'baiter') {
|
||
e.zigPhase = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
killPlayer() {
|
||
if (!this.shipAlive || this.invincibleTimer > 0)
|
||
return;
|
||
this.shipAlive = false;
|
||
if (this.shipSprite)
|
||
this.shipSprite.setVisible(false);
|
||
try {
|
||
this.sound.play('snd_playerdead', { volume: 0.5 });
|
||
}
|
||
catch { }
|
||
// Stop thrust sound
|
||
try {
|
||
this.sound.stopByKey('snd_thrust');
|
||
}
|
||
catch { }
|
||
this.thrustSoundPlaying = false;
|
||
// Drop carried humanoid
|
||
if (this.carriedHumanoid >= 0) {
|
||
const h = this.humanoids[this.carriedHumanoid];
|
||
if (h) {
|
||
h.state = 'falling';
|
||
h.vy = 0;
|
||
}
|
||
this.carriedHumanoid = -1;
|
||
}
|
||
// Explosion
|
||
this.spawnExplosion(this.playerX, this.playerY, 0xff00ff, 16);
|
||
this.lives--;
|
||
this.syncLivesToHUD();
|
||
if (this.lives <= 0) {
|
||
this.gameOver = true;
|
||
this.checkHighScore();
|
||
// Release keyboard captures so game-over overlay can receive key events
|
||
try {
|
||
this.input.keyboard.removeCapture('SPACE,Z,UP,DOWN,LEFT,RIGHT');
|
||
}
|
||
catch { }
|
||
this.time.delayedCall(1000, () => {
|
||
this.showGameOver(this.score, () => this.scene.restart());
|
||
});
|
||
}
|
||
else {
|
||
this.respawnTimer = RESPAWN_DELAY;
|
||
}
|
||
}
|
||
/* ================================================================
|
||
CAMERA
|
||
================================================================ */
|
||
updateCamera(dtSec) {
|
||
// Camera tries to keep player slightly off-center in the direction of movement
|
||
let targetCamX = this.playerX - W * 0.35;
|
||
if (!this.facingRight) {
|
||
targetCamX = this.playerX - W * 0.65;
|
||
}
|
||
// Lerp
|
||
const lerpSpeed = 5;
|
||
let diff = targetCamX - this.cameraX;
|
||
// Handle wrapping
|
||
if (diff > WORLD_W / 2)
|
||
diff -= WORLD_W;
|
||
if (diff < -WORLD_W / 2)
|
||
diff += WORLD_W;
|
||
this.cameraX += diff * lerpSpeed * dtSec;
|
||
this.cameraX = this.wrapWorldX(this.cameraX);
|
||
}
|
||
worldToScreenX(worldX) {
|
||
let sx = worldX - this.cameraX;
|
||
if (sx > WORLD_W / 2)
|
||
sx -= WORLD_W;
|
||
if (sx < -WORLD_W / 2)
|
||
sx += WORLD_W;
|
||
return sx;
|
||
}
|
||
isOnScreen(worldX, margin = 100) {
|
||
const sx = this.worldToScreenX(worldX);
|
||
return sx > -margin && sx < W + margin;
|
||
}
|
||
/* ================================================================
|
||
BULLETS
|
||
================================================================ */
|
||
fireBullet() {
|
||
if (this.fireCooldown > 0)
|
||
return;
|
||
const playerBullets = this.bullets.filter(b => !b.isEnemy);
|
||
if (playerBullets.length >= MAX_BULLETS)
|
||
return;
|
||
this.fireCooldown = 80; // ms between shots (rapid fire ~12/sec)
|
||
const dir = this.facingRight ? 1 : -1;
|
||
// Spawn bullet at the nose of the ship (half the rendered ship width ahead)
|
||
const shipHalfW = 118 * this.spriteScale / 2;
|
||
const bx = this.playerX + dir * (shipHalfW + 5);
|
||
try {
|
||
this.sound.play('snd_laser', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
this.bullets.push({
|
||
x: bx, y: this.playerY,
|
||
vx: BULLET_SPEED * dir + this.playerVx * 0.5,
|
||
vy: 0,
|
||
life: 1500,
|
||
isEnemy: false,
|
||
});
|
||
}
|
||
fireEnemyBullet(ex, ey) {
|
||
if (!this.shipAlive)
|
||
return;
|
||
let adjDx = this.playerX - ex;
|
||
if (adjDx > WORLD_W / 2)
|
||
adjDx -= WORLD_W;
|
||
if (adjDx < -WORLD_W / 2)
|
||
adjDx += WORLD_W;
|
||
const dy = this.playerY - ey;
|
||
const dist = Math.sqrt(adjDx * adjDx + dy * dy) || 1;
|
||
// Predictive lead: compensate for player velocity
|
||
const leadTime = dist / ENEMY_BULLET_SPEED;
|
||
const predictX = adjDx + this.playerVx * leadTime * 0.5;
|
||
const predictY = dy + this.playerVy * leadTime * 0.5;
|
||
// Add slight random spread (±10°)
|
||
const spread = (Math.random() - 0.5) * 0.35;
|
||
const angle = Math.atan2(predictY, predictX) + spread;
|
||
try {
|
||
this.sound.play('snd_enemyshoot', { volume: 0.2 });
|
||
}
|
||
catch { }
|
||
this.bullets.push({
|
||
x: ex, y: ey,
|
||
vx: Math.cos(angle) * ENEMY_BULLET_SPEED,
|
||
vy: Math.sin(angle) * ENEMY_BULLET_SPEED,
|
||
life: 3000,
|
||
isEnemy: true,
|
||
});
|
||
}
|
||
updateBulletsPhysics(dtSec) {
|
||
for (let i = this.bullets.length - 1; i >= 0; i--) {
|
||
const b = this.bullets[i];
|
||
b.x += b.vx * dtSec;
|
||
b.y += b.vy * dtSec;
|
||
b.life -= dtSec * 1000;
|
||
// World wrap
|
||
b.x = this.wrapWorldX(b.x);
|
||
if (b.life <= 0 || b.y < 0 || b.y > H) {
|
||
this.bullets.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
/* ================================================================
|
||
SMART BOMB
|
||
================================================================ */
|
||
useSmartBomb() {
|
||
if (this.smartBombs <= 0)
|
||
return;
|
||
this.smartBombs--;
|
||
try {
|
||
this.sound.play('snd_explode', { volume: 0.5 });
|
||
}
|
||
catch { }
|
||
// Destroy all on-screen enemies
|
||
for (const e of this.enemies) {
|
||
if (!e.alive)
|
||
continue;
|
||
if (this.isOnScreen(e.x)) {
|
||
this.destroyEnemy(e);
|
||
}
|
||
}
|
||
// Destroy on-screen mines
|
||
for (let i = this.mines.length - 1; i >= 0; i--) {
|
||
if (this.isOnScreen(this.mines[i].x)) {
|
||
this.mines.splice(i, 1);
|
||
}
|
||
}
|
||
// Screen flash
|
||
const flash = this.add.graphics().setDepth(900);
|
||
flash.fillStyle(0xffffff, 0.7);
|
||
flash.fillRect(0, 0, W, H);
|
||
this.tweens.add({
|
||
targets: flash,
|
||
alpha: 0,
|
||
duration: 400,
|
||
onComplete: () => flash.destroy(),
|
||
});
|
||
}
|
||
/* ================================================================
|
||
ENEMIES
|
||
================================================================ */
|
||
createEnemy(type, x, y) {
|
||
const textureKey = 'def-' + type;
|
||
const sprite = this.add.image(0, 0, textureKey).setDepth(10).setOrigin(0.5, 0.5).setScale(this.spriteScale);
|
||
return {
|
||
type, x, y,
|
||
vx: 0, vy: 0,
|
||
alive: true,
|
||
shootTimer: 2000 + Math.random() * 3000,
|
||
targetHumanoid: -1,
|
||
hasHumanoid: false,
|
||
zigTimer: 0,
|
||
mineTimer: 3000 + Math.random() * 1000,
|
||
zigPhase: Math.random() * Math.PI * 2,
|
||
sprite,
|
||
};
|
||
}
|
||
spawnLanders(count) {
|
||
for (let i = 0; i < count; i++) {
|
||
const x = Math.random() * WORLD_W;
|
||
const y = 50 + Math.random() * 80;
|
||
const e = this.createEnemy('lander', x, y);
|
||
e.vy = 30 + Math.random() * 20;
|
||
e.vx = (Math.random() - 0.5) * 60;
|
||
this.enemies.push(e);
|
||
}
|
||
}
|
||
spawnBombers(count) {
|
||
for (let i = 0; i < count; i++) {
|
||
const x = Math.random() * WORLD_W;
|
||
const y = 100 + Math.random() * (H * 0.3);
|
||
const e = this.createEnemy('bomber', x, y);
|
||
e.vx = (Math.random() > 0.5 ? 1 : -1) * (40 + Math.random() * 30);
|
||
e.vy = (Math.random() - 0.5) * 10;
|
||
this.enemies.push(e);
|
||
}
|
||
}
|
||
spawnPods(count) {
|
||
for (let i = 0; i < count; i++) {
|
||
const x = Math.random() * WORLD_W;
|
||
const y = 80 + Math.random() * (H * 0.3);
|
||
const e = this.createEnemy('pod', x, y);
|
||
e.vx = (Math.random() - 0.5) * 40;
|
||
e.vy = (Math.random() - 0.5) * 20;
|
||
this.enemies.push(e);
|
||
}
|
||
}
|
||
spawnSwarmers(x, y, count) {
|
||
for (let i = 0; i < count; i++) {
|
||
const e = this.createEnemy('swarmer', x + (Math.random() - 0.5) * 30, y + (Math.random() - 0.5) * 30);
|
||
e.vx = (Math.random() - 0.5) * 200;
|
||
e.vy = (Math.random() - 0.5) * 200;
|
||
this.enemies.push(e);
|
||
}
|
||
}
|
||
spawnBaiter() {
|
||
// Spawn off-screen
|
||
const x = (this.playerX + W * (Math.random() > 0.5 ? 1 : -1)) % WORLD_W;
|
||
const y = 80 + Math.random() * (H * 0.3);
|
||
const e = this.createEnemy('baiter', x, y);
|
||
e.zigPhase = 0; // Start in dormant phase
|
||
e.shootTimer = 1500; // Don't shoot during dormant phase
|
||
try {
|
||
this.sound.play('snd_baiterwarning', { volume: 0.4 });
|
||
}
|
||
catch { }
|
||
this.enemies.push(e);
|
||
}
|
||
updateEnemies(dtSec) {
|
||
const speedMult = 1 + (Math.min(this.wave, 15) - 1) * 0.12; // OpenDefender-style: 1.0 at wave 1, ~2.7 at wave 15
|
||
for (const e of this.enemies) {
|
||
if (!e.alive)
|
||
continue;
|
||
switch (e.type) {
|
||
case 'lander':
|
||
this.updateLander(e, dtSec, speedMult);
|
||
break;
|
||
case 'mutant':
|
||
this.updateMutant(e, dtSec, speedMult);
|
||
break;
|
||
case 'bomber':
|
||
this.updateBomber(e, dtSec, speedMult);
|
||
break;
|
||
case 'pod':
|
||
this.updatePod(e, dtSec, speedMult);
|
||
break;
|
||
case 'swarmer':
|
||
this.updateSwarmer(e, dtSec, speedMult);
|
||
break;
|
||
case 'baiter':
|
||
this.updateBaiter(e, dtSec, speedMult);
|
||
break;
|
||
}
|
||
// World wrap
|
||
e.x = this.wrapWorldX(e.x);
|
||
// Clamp Y — keep enemies in playable area (not below terrain line)
|
||
if (e.y < RADAR_Y + RADAR_H + 10)
|
||
e.y = RADAR_Y + RADAR_H + 10;
|
||
const maxEnemyY = this.planetDestroyed ? H - 40 : H * 0.75;
|
||
if (e.y > maxEnemyY)
|
||
e.y = maxEnemyY;
|
||
// Shooting (lander, mutant, baiter, bomber)
|
||
if (e.type !== 'pod' && e.type !== 'swarmer') {
|
||
e.shootTimer -= dtSec * 1000;
|
||
if (e.shootTimer <= 0 && this.isOnScreen(e.x, 200)) {
|
||
this.fireEnemyBullet(e.x, e.y);
|
||
const dif = Math.min(this.wave, 15);
|
||
let baseInterval;
|
||
if (e.type === 'lander') {
|
||
baseInterval = e.hasHumanoid ? Math.max(500, 1500 - dif * 80) : Math.max(800, 2500 - dif * 100);
|
||
}
|
||
else if (e.type === 'mutant') {
|
||
baseInterval = Math.max(400, 1200 - dif * 60);
|
||
}
|
||
else if (e.type === 'baiter') {
|
||
baseInterval = Math.max(300, 1500 - dif * 80);
|
||
}
|
||
else {
|
||
baseInterval = Math.max(600, 2000 - dif * 80);
|
||
}
|
||
e.shootTimer = baseInterval + Math.random() * 500;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
updateLander(e, dtSec, speedMult) {
|
||
if (!e.hasHumanoid) {
|
||
// Find a target humanoid if none
|
||
if (e.targetHumanoid < 0 || this.humanoids[e.targetHumanoid]?.state !== 'walking') {
|
||
e.targetHumanoid = -1;
|
||
const walkingIdxs = this.humanoids.map((h, i) => h.state === 'walking' ? i : -1).filter(i => i >= 0);
|
||
if (walkingIdxs.length > 0) {
|
||
e.targetHumanoid = walkingIdxs[Math.floor(Math.random() * walkingIdxs.length)];
|
||
}
|
||
}
|
||
// Descend toward target humanoid
|
||
if (e.targetHumanoid >= 0) {
|
||
const h = this.humanoids[e.targetHumanoid];
|
||
if (!h || h.state === 'dead') {
|
||
e.targetHumanoid = -1;
|
||
}
|
||
else {
|
||
let dx = this.wrapDx(h.x - e.x);
|
||
e.vx += (dx > 0 ? 1 : -1) * 200 * dtSec * speedMult;
|
||
// Only descend if ABOVE the humanoid, otherwise hover at humanoid height
|
||
const dy = h.y - e.y;
|
||
if (dy > 30) {
|
||
e.vy = 120 * speedMult; // descend toward humanoid
|
||
}
|
||
else if (dy < -20) {
|
||
e.vy = -60 * speedMult; // rise back up if too low
|
||
}
|
||
else {
|
||
e.vy *= 0.9; // hover near humanoid height
|
||
}
|
||
// Zig-zag
|
||
e.zigTimer += dtSec;
|
||
e.vx += Math.sin(e.zigTimer * 3) * 120 * dtSec;
|
||
// Check grab — generous radius
|
||
if (Math.abs(dx) < 25 && Math.abs(dy) < 25 && h.state === 'walking') {
|
||
e.hasHumanoid = true;
|
||
h.state = 'grabbed';
|
||
h.vx = 0;
|
||
h.vy = 0;
|
||
try {
|
||
this.sound.play('snd_warning', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
// No humanoid to target — patrol at mid-height
|
||
e.zigTimer += dtSec;
|
||
e.vx += Math.sin(e.zigTimer * 2) * 100 * dtSec;
|
||
// Maintain patrol altitude around 30% of screen height
|
||
const patrolY = H * 0.3;
|
||
if (e.y < patrolY - 50)
|
||
e.vy = 40 * speedMult;
|
||
else if (e.y > patrolY + 50)
|
||
e.vy = -40 * speedMult;
|
||
else
|
||
e.vy += (Math.random() - 0.5) * 80 * dtSec;
|
||
}
|
||
}
|
||
else {
|
||
// Ascend with humanoid — fast!
|
||
e.vy = -180 * speedMult;
|
||
e.vx *= 0.98;
|
||
// Move humanoid with lander
|
||
const hIdx = e.targetHumanoid;
|
||
if (hIdx >= 0 && this.humanoids[hIdx]) {
|
||
this.humanoids[hIdx].x = e.x;
|
||
this.humanoids[hIdx].y = e.y + 12 * PX / 3;
|
||
}
|
||
// If reached top → mutate
|
||
if (e.y <= 40) {
|
||
// Humanoid dies
|
||
if (hIdx >= 0 && this.humanoids[hIdx]) {
|
||
this.humanoids[hIdx].state = 'dead';
|
||
this.humanoids[hIdx].sprite = this.destroyObj(this.humanoids[hIdx].sprite);
|
||
try {
|
||
this.sound.play('snd_humanoiddead', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
}
|
||
// Lander becomes mutant — swap sprite texture
|
||
e.type = 'mutant';
|
||
if (e.sprite)
|
||
e.sprite.setTexture('def-mutant');
|
||
try {
|
||
this.sound.play('snd_explode', { volume: 0.4 });
|
||
}
|
||
catch { }
|
||
e.hasHumanoid = false;
|
||
e.targetHumanoid = -1;
|
||
this.checkPlanetDestroyed();
|
||
}
|
||
}
|
||
// Apply velocity with clamping
|
||
e.vx = Math.max(-280 * speedMult, Math.min(280 * speedMult, e.vx));
|
||
e.x += e.vx * dtSec;
|
||
e.y += e.vy * dtSec;
|
||
}
|
||
updateMutant(e, dtSec, speedMult) {
|
||
// Home toward player
|
||
const dx = this.wrapDx(this.playerX - e.x);
|
||
const dy = this.playerY - e.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const speed = 300 * speedMult;
|
||
e.vx += (dx / dist) * speed * dtSec * 3;
|
||
e.vy += (dy / dist) * speed * dtSec * 3;
|
||
// Random jitter
|
||
e.vx += (Math.random() - 0.5) * 400 * dtSec;
|
||
e.vy += (Math.random() - 0.5) * 400 * dtSec;
|
||
// Clamp speed
|
||
const maxV = speed * 1.5;
|
||
const curSpeed = Math.sqrt(e.vx * e.vx + e.vy * e.vy);
|
||
if (curSpeed > maxV) {
|
||
e.vx = (e.vx / curSpeed) * maxV;
|
||
e.vy = (e.vy / curSpeed) * maxV;
|
||
}
|
||
e.x += e.vx * dtSec;
|
||
e.y += e.vy * dtSec;
|
||
}
|
||
updateBomber(e, dtSec, speedMult) {
|
||
// Slow horizontal drift
|
||
e.x += e.vx * dtSec * speedMult;
|
||
e.y += Math.sin(e.zigPhase) * 15 * dtSec;
|
||
e.zigPhase += dtSec;
|
||
// Drop mines
|
||
e.mineTimer -= dtSec * 1000;
|
||
if (e.mineTimer <= 0) {
|
||
this.mines.push({
|
||
x: e.x,
|
||
y: e.y + 10,
|
||
life: 15000,
|
||
blinkTimer: 0,
|
||
});
|
||
e.mineTimer = 3000 + Math.random() * 1000;
|
||
}
|
||
}
|
||
updatePod(e, dtSec, speedMult) {
|
||
// Slow drift
|
||
e.x += e.vx * dtSec * speedMult;
|
||
e.y += e.vy * dtSec * speedMult;
|
||
// Gentle bounce at vertical boundaries
|
||
if (e.y < 60 || e.y > H * 0.6)
|
||
e.vy = -e.vy;
|
||
}
|
||
updateSwarmer(e, dtSec, speedMult) {
|
||
// Fast zig-zag toward player
|
||
const dx = this.wrapDx(this.playerX - e.x);
|
||
const dy = this.playerY - e.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const speed = 400 * speedMult;
|
||
e.vx += (dx / dist) * speed * dtSec * 2;
|
||
e.vy += (dy / dist) * speed * dtSec * 2;
|
||
// Erratic zig-zag
|
||
e.zigPhase += dtSec * 10;
|
||
e.vx += Math.sin(e.zigPhase) * 300 * dtSec;
|
||
e.vy += Math.cos(e.zigPhase * 1.3) * 200 * dtSec;
|
||
// Clamp
|
||
const maxV = speed * 1.8;
|
||
const curSpeed = Math.sqrt(e.vx * e.vx + e.vy * e.vy);
|
||
if (curSpeed > maxV) {
|
||
e.vx = (e.vx / curSpeed) * maxV;
|
||
e.vy = (e.vy / curSpeed) * maxV;
|
||
}
|
||
e.x += e.vx * dtSec;
|
||
e.y += e.vy * dtSec;
|
||
// Smart direction change when far from player (OpenDefender: 200px)
|
||
let sdx = this.playerX - e.x;
|
||
if (sdx > WORLD_W / 2)
|
||
sdx -= WORLD_W;
|
||
if (sdx < -WORLD_W / 2)
|
||
sdx += WORLD_W;
|
||
if (Math.abs(sdx) > 300) {
|
||
e.vx += (sdx > 0 ? 1 : -1) * 500 * dtSec;
|
||
}
|
||
}
|
||
updateBaiter(e, dtSec, speedMult) {
|
||
e.zigPhase += dtSec;
|
||
// Phase 1: Brief dormant hover (first 1.5 seconds)
|
||
if (e.zigPhase < 1.5) {
|
||
e.vx *= 0.95;
|
||
e.vy *= 0.95;
|
||
e.x += e.vx * dtSec;
|
||
e.y += e.vy * dtSec;
|
||
return;
|
||
}
|
||
const dx = this.wrapDx(this.playerX - e.x);
|
||
const dy = this.playerY - e.y;
|
||
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
||
const speed = 280 * speedMult;
|
||
// Orbit behavior: if close to player, strafe around instead of sitting on top
|
||
const minDist = 150;
|
||
if (dist < minDist) {
|
||
// Too close — veer away perpendicular + strafe
|
||
const perpX = -dy / dist;
|
||
const perpY = dx / dist;
|
||
e.vx += perpX * speed * dtSec * 3;
|
||
e.vy += perpY * speed * dtSec * 3;
|
||
// Push away slightly
|
||
e.vx -= (dx / dist) * speed * dtSec * 1.5;
|
||
e.vy -= (dy / dist) * speed * dtSec * 1.5;
|
||
}
|
||
else {
|
||
// Approach but not too aggressively
|
||
e.vx += (dx / dist) * speed * dtSec * 1.5;
|
||
e.vy += (dy / dist) * speed * dtSec * 1.5;
|
||
}
|
||
// Strafing oscillation
|
||
e.vx += Math.sin(e.zigPhase * 4) * 180 * dtSec;
|
||
e.vy += Math.cos(e.zigPhase * 3) * 120 * dtSec;
|
||
// Clamp to max speed
|
||
const maxV = speed * 0.7;
|
||
const curSpeed = Math.sqrt(e.vx * e.vx + e.vy * e.vy);
|
||
if (curSpeed > maxV) {
|
||
e.vx = (e.vx / curSpeed) * maxV;
|
||
e.vy = (e.vy / curSpeed) * maxV;
|
||
}
|
||
e.x += e.vx * dtSec;
|
||
e.y += e.vy * dtSec;
|
||
}
|
||
destroyEnemy(e) {
|
||
if (!e.alive)
|
||
return;
|
||
e.alive = false;
|
||
e.sprite = this.destroyObj(e.sprite);
|
||
try {
|
||
this.sound.play('snd_enemydead', { volume: 0.4 });
|
||
}
|
||
catch { }
|
||
const colorMap = {
|
||
lander: 0x00ff00, mutant: 0xff00ff, bomber: 0xffff00,
|
||
pod: 0xcc00cc, swarmer: 0xffff00, baiter: 0x00ff44,
|
||
};
|
||
const scoreMap = {
|
||
lander: 150, mutant: 150, bomber: 250,
|
||
pod: 1000, swarmer: 150, baiter: 200,
|
||
};
|
||
const sx = this.worldToScreenX(e.x);
|
||
this.addScore(scoreMap[e.type], sx, e.y);
|
||
this.spawnExplosion(e.x, e.y, colorMap[e.type], 10);
|
||
this.checkExtraLife();
|
||
// Release humanoid if lander was carrying one
|
||
if (e.type === 'lander' && e.hasHumanoid && e.targetHumanoid >= 0) {
|
||
const h = this.humanoids[e.targetHumanoid];
|
||
if (h && h.state === 'grabbed') {
|
||
h.state = 'falling';
|
||
h.vy = 0;
|
||
}
|
||
}
|
||
// Pod splits into swarmers
|
||
if (e.type === 'pod') {
|
||
const count = 3 + Math.floor(Math.random() * 3);
|
||
this.spawnSwarmers(e.x, e.y, count);
|
||
}
|
||
}
|
||
/* ================================================================
|
||
HUMANOIDS
|
||
================================================================ */
|
||
spawnHumanoids(count) {
|
||
// Destroy existing humanoid sprites before respawning
|
||
for (const h of this.humanoids) {
|
||
h.sprite = this.destroyObj(h.sprite);
|
||
}
|
||
this.humanoids = [];
|
||
for (let i = 0; i < count; i++) {
|
||
const x = Math.random() * WORLD_W;
|
||
const tY = this.getTerrainY(x);
|
||
const sprite = this.add.image(0, 0, 'def-humanoid').setDepth(10).setOrigin(0.5, 0.5).setScale(this.spriteScale);
|
||
this.humanoids.push({
|
||
x,
|
||
y: tY - 3,
|
||
vx: (Math.random() > 0.5 ? 1 : -1) * (10 + Math.random() * 10),
|
||
vy: 0,
|
||
state: 'walking',
|
||
walkDir: Math.random() > 0.5 ? 1 : -1,
|
||
sprite,
|
||
});
|
||
}
|
||
}
|
||
updateHumanoids(dtSec) {
|
||
for (let i = 0; i < this.humanoids.length; i++) {
|
||
const h = this.humanoids[i];
|
||
switch (h.state) {
|
||
case 'walking':
|
||
if (this.planetDestroyed) {
|
||
// Planet destroyed — humanoids fall
|
||
h.state = 'falling';
|
||
h.vy = 0;
|
||
break;
|
||
}
|
||
h.x += h.vx * dtSec;
|
||
h.x = this.wrapWorldX(h.x);
|
||
const tY = this.getTerrainY(h.x);
|
||
h.y = tY - 3;
|
||
// Randomly change direction
|
||
if (Math.random() < 0.005)
|
||
h.vx = -h.vx;
|
||
break;
|
||
case 'grabbed':
|
||
// Moved by lander in updateLander
|
||
break;
|
||
case 'falling':
|
||
// Gentle gravity matching OpenDefender (fallspeed=0.01, terminal=8px/frame)
|
||
// Scaled for our coordinate system: slow accel, capped terminal velocity
|
||
h.vy += 60 * dtSec; // gentle gravity (~10× slower than before)
|
||
if (h.vy > 120)
|
||
h.vy = 120; // terminal velocity cap — keeps it catchable
|
||
h.y += h.vy * dtSec;
|
||
if (!this.planetDestroyed) {
|
||
const groundY = this.getTerrainY(h.x);
|
||
if (h.y >= groundY - 3) {
|
||
if (h.vy > 100) {
|
||
// Splat — only if falling fast (dropped from very high without catching)
|
||
h.state = 'dead';
|
||
h.sprite = this.destroyObj(h.sprite);
|
||
this.spawnExplosion(h.x, h.y, 0xffffff, 6);
|
||
try {
|
||
this.sound.play('snd_humanoiddead', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
this.checkPlanetDestroyed();
|
||
}
|
||
else {
|
||
// Soft landing
|
||
h.y = groundY - 3;
|
||
h.vy = 0;
|
||
h.state = 'walking';
|
||
h.vx = (Math.random() > 0.5 ? 1 : -1) * 15;
|
||
}
|
||
}
|
||
}
|
||
else {
|
||
// No terrain — fall to death
|
||
if (h.y > H + 50) {
|
||
h.state = 'dead';
|
||
h.sprite = this.destroyObj(h.sprite);
|
||
try {
|
||
this.sound.play('snd_humanoiddead', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
break;
|
||
case 'rescued':
|
||
// Moved by player in updatePlayerPhysics
|
||
break;
|
||
case 'dead':
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
checkPlanetDestroyed() {
|
||
if (this.planetDestroyed)
|
||
return;
|
||
const alive = this.humanoids.filter(h => h.state !== 'dead').length;
|
||
if (alive === 0) {
|
||
this.planetDestroyed = true;
|
||
// All remaining landers become mutants
|
||
for (const e of this.enemies) {
|
||
if (e.alive && e.type === 'lander') {
|
||
e.type = 'mutant';
|
||
if (e.sprite)
|
||
e.sprite.setTexture('def-mutant');
|
||
e.hasHumanoid = false;
|
||
e.targetHumanoid = -1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
/* ================================================================
|
||
MINES
|
||
================================================================ */
|
||
updateMines(dt) {
|
||
for (let i = this.mines.length - 1; i >= 0; i--) {
|
||
const m = this.mines[i];
|
||
m.life -= dt;
|
||
m.blinkTimer += dt;
|
||
if (m.life <= 0) {
|
||
this.mines.splice(i, 1);
|
||
}
|
||
}
|
||
}
|
||
/* ================================================================
|
||
COLLISIONS
|
||
================================================================ */
|
||
worldDist(x1, y1, x2, y2) {
|
||
const dx = this.wrapDx(x1 - x2);
|
||
const dy = y1 - y2;
|
||
return Math.sqrt(dx * dx + dy * dy);
|
||
}
|
||
/** Wrap a delta-X value for the toroidal world. */
|
||
wrapDx(dx) {
|
||
if (dx > WORLD_W / 2)
|
||
dx -= WORLD_W;
|
||
if (dx < -WORLD_W / 2)
|
||
dx += WORLD_W;
|
||
return dx;
|
||
}
|
||
checkCollisions() {
|
||
// Use half the ship's rendered height so the hitbox matches the visible sprite
|
||
const playerRadius = 53 * this.spriteScale / 2;
|
||
// Player bullets vs enemies
|
||
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
|
||
const b = this.bullets[bi];
|
||
if (b.isEnemy)
|
||
continue;
|
||
for (const e of this.enemies) {
|
||
if (!e.alive)
|
||
continue;
|
||
const hitR = e.type === 'swarmer' ? 12 * PX / 3 : 18 * PX / 3;
|
||
if (this.worldDist(b.x, b.y, e.x, e.y) < hitR) {
|
||
this.destroyEnemy(e);
|
||
this.bullets.splice(bi, 1);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Player bullets vs mines
|
||
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
|
||
const b = this.bullets[bi];
|
||
if (b.isEnemy)
|
||
continue;
|
||
for (let mi = this.mines.length - 1; mi >= 0; mi--) {
|
||
const m = this.mines[mi];
|
||
if (this.worldDist(b.x, b.y, m.x, m.y) < 10 * PX / 3) {
|
||
this.mines.splice(mi, 1);
|
||
this.bullets.splice(bi, 1);
|
||
this.addScore(25, this.worldToScreenX(m.x), m.y);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// Player bullets vs humanoids (friendly fire)
|
||
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
|
||
const b = this.bullets[bi];
|
||
if (b.isEnemy)
|
||
continue;
|
||
for (const h of this.humanoids) {
|
||
if (h.state !== 'walking')
|
||
continue;
|
||
const hitR = 14 * PX / 3;
|
||
if (this.worldDist(b.x, b.y, h.x, h.y) < hitR) {
|
||
if (this.carriedHumanoid >= 0 && this.humanoids[this.carriedHumanoid] === h) {
|
||
this.carriedHumanoid = -1;
|
||
}
|
||
h.state = 'dead';
|
||
try {
|
||
this.sound.play('snd_humanoiddead', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
this.spawnExplosion(h.x, h.y, 0x00ffff, 8);
|
||
this.bullets.splice(bi, 1);
|
||
this.checkPlanetDestroyed();
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!this.shipAlive)
|
||
return;
|
||
// Enemy bullets vs player
|
||
for (let bi = this.bullets.length - 1; bi >= 0; bi--) {
|
||
const b = this.bullets[bi];
|
||
if (!b.isEnemy)
|
||
continue;
|
||
if (this.worldDist(b.x, b.y, this.playerX, this.playerY) < playerRadius) {
|
||
this.bullets.splice(bi, 1);
|
||
this.killPlayer();
|
||
return;
|
||
}
|
||
}
|
||
// Enemies body vs player
|
||
if (this.invincibleTimer <= 0) {
|
||
for (const e of this.enemies) {
|
||
if (!e.alive)
|
||
continue;
|
||
const hitR = e.type === 'swarmer' ? 10 * PX / 3 : 18 * PX / 3;
|
||
if (this.worldDist(e.x, e.y, this.playerX, this.playerY) < hitR) {
|
||
this.killPlayer();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// Mines vs player
|
||
if (this.invincibleTimer <= 0) {
|
||
for (let mi = this.mines.length - 1; mi >= 0; mi--) {
|
||
const m = this.mines[mi];
|
||
if (this.worldDist(m.x, m.y, this.playerX, this.playerY) < 10 * PX / 3) {
|
||
this.mines.splice(mi, 1);
|
||
this.killPlayer();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
// Falling humanoids — catch by player
|
||
for (let i = 0; i < this.humanoids.length; i++) {
|
||
const h = this.humanoids[i];
|
||
if (h.state !== 'falling')
|
||
continue;
|
||
if (this.carriedHumanoid >= 0)
|
||
continue; // already carrying one
|
||
if (this.worldDist(h.x, h.y, this.playerX, this.playerY) < 40 * PX / 3) {
|
||
h.state = 'rescued';
|
||
h.vy = 0;
|
||
this.carriedHumanoid = i;
|
||
this.addScore(250, this.worldToScreenX(h.x), h.y);
|
||
try {
|
||
this.sound.play('snd_bonus', { volume: 0.4 });
|
||
}
|
||
catch { }
|
||
}
|
||
}
|
||
}
|
||
/* ================================================================
|
||
WAVES
|
||
================================================================ */
|
||
startWave() {
|
||
this.wave++;
|
||
this.waveTimer = 0;
|
||
this.baiterSpawned = false;
|
||
this.syncLevelToHUD(this.wave);
|
||
this.showWaveBanner(this.wave);
|
||
try {
|
||
this.sound.play('snd_start', { volume: 0.3 });
|
||
}
|
||
catch { }
|
||
// Clear old dead enemies — destroy their sprites
|
||
for (const e of this.enemies) {
|
||
if (!e.alive) {
|
||
e.sprite = this.destroyObj(e.sprite);
|
||
}
|
||
}
|
||
this.enemies = this.enemies.filter(e => e.alive);
|
||
this.bullets = [];
|
||
this.mines = [];
|
||
// Humanoids persist across waves — only spawn on wave 1 or if planet was destroyed
|
||
if (this.wave === 1) {
|
||
this.spawnHumanoids(10);
|
||
}
|
||
// Don't respawn humanoids on subsequent waves — they carry over!
|
||
// Spawn enemies
|
||
const landerCount = 5 + (this.wave - 1) * 2;
|
||
this.spawnLanders(landerCount);
|
||
if (this.wave >= 3) {
|
||
this.spawnBombers(Math.min(this.wave - 2, 4));
|
||
}
|
||
if (this.wave >= 4) {
|
||
this.spawnPods(Math.min(this.wave - 3, 3));
|
||
}
|
||
}
|
||
onWaveComplete() {
|
||
// Bonus for surviving humanoids
|
||
if (!this.planetDestroyed) {
|
||
const alive = this.humanoids.filter(h => h.state !== 'dead').length;
|
||
if (alive > 0) {
|
||
const bonus = 500 * alive;
|
||
this.addScore(bonus, W / 2, H / 2);
|
||
}
|
||
}
|
||
this.waveDelay = 2000;
|
||
}
|
||
/* ================================================================
|
||
EXTRA LIFE
|
||
================================================================ */
|
||
checkExtraLife() {
|
||
if (this.score >= this.nextExtraLife) {
|
||
this.lives++;
|
||
this.syncLivesToHUD();
|
||
this.nextExtraLife += EXTRA_LIFE_SCORE;
|
||
try {
|
||
this.sound.play('snd_player1up', { volume: 0.5 });
|
||
}
|
||
catch { }
|
||
// Flash notification
|
||
const txt = this.add.text(W / 2, H * 0.3, 'EXTRA LIFE!', {
|
||
fontFamily: '"Press Start 2P", monospace',
|
||
fontSize: '18px',
|
||
color: '#00ff00',
|
||
stroke: '#000',
|
||
strokeThickness: 3,
|
||
}).setOrigin(0.5, 0.5).setDepth(950);
|
||
this.tweens.add({
|
||
targets: txt,
|
||
y: H * 0.25,
|
||
alpha: 0,
|
||
duration: 1500,
|
||
onComplete: () => txt.destroy(),
|
||
});
|
||
}
|
||
}
|
||
/* ================================================================
|
||
EXPLOSIONS
|
||
================================================================ */
|
||
spawnExplosion(worldX, worldY, color, count) {
|
||
const sx = this.worldToScreenX(worldX);
|
||
if (sx < -200 || sx > W + 200)
|
||
return; // off-screen, skip
|
||
this.spawnParticleExplosion(sx, worldY, color, count);
|
||
}
|
||
/* ================================================================
|
||
RENDERING
|
||
================================================================ */
|
||
renderGame() {
|
||
const g = this.gameGfx;
|
||
g.clear();
|
||
// Draw terrain
|
||
this.renderTerrain();
|
||
// Draw humanoids
|
||
this.renderHumanoids(g);
|
||
// Draw enemies
|
||
this.renderEnemies(g);
|
||
// Draw mines
|
||
this.renderMines(g);
|
||
// Draw bullets
|
||
this.renderBullets(g);
|
||
// Draw player
|
||
if (this.shipAlive) {
|
||
const blink = this.invincibleTimer > 0 && Math.sin(performance.now() / 80) < 0;
|
||
this.shipSprite.setAlpha(blink ? 0.2 : 1);
|
||
this.renderPlayer(g);
|
||
}
|
||
else {
|
||
this.shipSprite.setVisible(false);
|
||
}
|
||
// Draw radar
|
||
this.renderRadar();
|
||
// Draw smart bomb HUD
|
||
this.renderSmartBombHUD();
|
||
}
|
||
renderTerrain() {
|
||
const tg = this.terrainGfx;
|
||
tg.clear();
|
||
if (this.planetDestroyed)
|
||
return;
|
||
// Draw terrain that's visible on screen
|
||
const startWorldX = this.cameraX - 20;
|
||
const endWorldX = this.cameraX + W + 20;
|
||
// Mountain line — orange/brown to match original arcade
|
||
tg.lineStyle(2, 0xcc8800, 1);
|
||
tg.beginPath();
|
||
let firstPoint = true;
|
||
for (let wx = startWorldX; wx <= endWorldX; wx += TERRAIN_SAMPLE / 2) {
|
||
const wrappedX = this.wrapWorldX(wx);
|
||
const sy = this.getTerrainY(wrappedX);
|
||
const sx = wx - this.cameraX;
|
||
if (firstPoint) {
|
||
tg.moveTo(sx, sy);
|
||
firstPoint = false;
|
||
}
|
||
else {
|
||
tg.lineTo(sx, sy);
|
||
}
|
||
}
|
||
tg.strokePath();
|
||
// Subtle fill below terrain — dark brown
|
||
tg.fillStyle(0x331800, 0.3);
|
||
tg.beginPath();
|
||
firstPoint = true;
|
||
for (let wx = startWorldX; wx <= endWorldX; wx += TERRAIN_SAMPLE / 2) {
|
||
const wrappedX = this.wrapWorldX(wx);
|
||
const sy = this.getTerrainY(wrappedX);
|
||
const sx = wx - this.cameraX;
|
||
if (firstPoint) {
|
||
tg.moveTo(sx, sy);
|
||
firstPoint = false;
|
||
}
|
||
else {
|
||
tg.lineTo(sx, sy);
|
||
}
|
||
}
|
||
// Close polygon at bottom
|
||
tg.lineTo(endWorldX - this.cameraX, H);
|
||
tg.lineTo(startWorldX - this.cameraX, H);
|
||
tg.closePath();
|
||
tg.fillPath();
|
||
}
|
||
renderPlayer(g) {
|
||
const sx = this.worldToScreenX(this.playerX);
|
||
this.shipSprite.setPosition(sx, this.playerY);
|
||
this.shipSprite.setTexture(this.facingRight ? 'def-ship-r' : 'def-ship-l');
|
||
this.shipSprite.setVisible(true);
|
||
// Engine exhaust — fires from the REAR of the ship (opposite of facing direction)
|
||
if (this.cursors.left.isDown || this.cursors.right.isDown) {
|
||
const shipHalfW = 118 * this.spriteScale / 2;
|
||
const shipHalfH = 53 * this.spriteScale / 2;
|
||
// Exhaust shoots out behind the ship
|
||
const exhaustDir = this.facingRight ? -1 : 1;
|
||
const exhaustX = sx + exhaustDir * shipHalfW;
|
||
// Main exhaust flame — large, flickering
|
||
const flameLen = 15 + Math.random() * 25; // variable length
|
||
const flameW = flameLen * SCALE;
|
||
const flameH = (6 + Math.random() * 4) * SCALE;
|
||
const fx = exhaustDir > 0 ? exhaustX : exhaustX - flameW;
|
||
// Outer glow (orange)
|
||
g.fillStyle(0xff6600, 0.3 + Math.random() * 0.2);
|
||
g.fillRect(fx - 2 * SCALE, this.playerY - flameH * 0.7, flameW + 4 * SCALE, flameH * 1.4);
|
||
// Core flame (magenta/pink — matches ship engine)
|
||
g.fillStyle(0xff00ff, 0.5 + Math.random() * 0.4);
|
||
g.fillRect(fx, this.playerY - flameH * 0.4, flameW * 0.8, flameH * 0.8);
|
||
// Hot center (white/yellow)
|
||
g.fillStyle(0xffff88, 0.4 + Math.random() * 0.4);
|
||
const coreW = flameW * 0.4;
|
||
const coreX = exhaustDir > 0 ? exhaustX : exhaustX - coreW;
|
||
g.fillRect(coreX, this.playerY - flameH * 0.2, coreW, flameH * 0.4);
|
||
// Random sparks/particles
|
||
for (let i = 0; i < 3; i++) {
|
||
const sparkX = exhaustX + exhaustDir * (Math.random() * flameW * 1.2);
|
||
const sparkY = this.playerY + (Math.random() - 0.5) * flameH * 1.5;
|
||
const sparkSize = (1 + Math.random() * 2) * SCALE;
|
||
g.fillStyle(Math.random() > 0.5 ? 0xff4400 : 0xff00ff, 0.3 + Math.random() * 0.5);
|
||
g.fillRect(sparkX, sparkY, sparkSize, sparkSize);
|
||
}
|
||
}
|
||
}
|
||
renderEnemies(g) {
|
||
for (const e of this.enemies) {
|
||
if (!e.alive) {
|
||
if (e.sprite)
|
||
e.sprite.setVisible(false);
|
||
continue;
|
||
}
|
||
const sx = this.worldToScreenX(e.x);
|
||
if (sx < -60 || sx > W + 60) {
|
||
if (e.sprite)
|
||
e.sprite.setVisible(false);
|
||
continue;
|
||
}
|
||
if (e.sprite) {
|
||
e.sprite.setPosition(sx, e.y);
|
||
e.sprite.setVisible(true);
|
||
// Mutant pulse effect
|
||
if (e.type === 'mutant') {
|
||
e.sprite.setAlpha(0.7 + Math.sin(performance.now() / 200) * 0.3);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
renderHumanoids(g) {
|
||
for (const h of this.humanoids) {
|
||
if (h.state === 'dead') {
|
||
if (h.sprite)
|
||
h.sprite.setVisible(false);
|
||
continue;
|
||
}
|
||
const sx = this.worldToScreenX(h.x);
|
||
if (sx < -30 || sx > W + 30) {
|
||
if (h.sprite)
|
||
h.sprite.setVisible(false);
|
||
continue;
|
||
}
|
||
if (h.sprite) {
|
||
h.sprite.setPosition(sx, h.y);
|
||
h.sprite.setVisible(true);
|
||
// Color tint based on state
|
||
if (h.state === 'rescued')
|
||
h.sprite.setTint(0x00ff00);
|
||
else if (h.state === 'falling')
|
||
h.sprite.setTint(0xff8800);
|
||
else if (h.state === 'grabbed')
|
||
h.sprite.setTint(0xff4444);
|
||
else
|
||
h.sprite.clearTint();
|
||
}
|
||
}
|
||
}
|
||
renderBullets(g) {
|
||
const bs = Math.max(3, Math.round(4 * SCALE)); // bullet size scales with screen
|
||
for (const b of this.bullets) {
|
||
const sx = this.worldToScreenX(b.x);
|
||
if (sx < -200 || sx > W + 200)
|
||
continue;
|
||
if (b.isEnemy) {
|
||
g.fillStyle(0xff0000);
|
||
g.fillRect(sx - bs, b.y - bs, bs * 2, bs * 2);
|
||
}
|
||
else {
|
||
// Long dashed laser beam — scales with screen
|
||
const dir = b.vx > 0 ? 1 : -1;
|
||
const beamLen = Math.round(120 * SCALE);
|
||
const segLen = Math.round(14 * SCALE);
|
||
const gapLen = Math.round(6 * SCALE);
|
||
const thick = Math.max(3, Math.round(4 * SCALE));
|
||
for (let i = 0; i < beamLen; i += segLen + gapLen) {
|
||
const segX = sx + (dir > 0 ? -i - segLen : i);
|
||
g.fillStyle(0xff4400, 1);
|
||
g.fillRect(segX, b.y - Math.floor(thick / 2), segLen, thick);
|
||
}
|
||
// Bright tip
|
||
const tipS = Math.max(4, Math.round(5 * SCALE));
|
||
g.fillStyle(0xffff00, 1);
|
||
g.fillRect(sx - tipS, b.y - Math.floor(tipS / 2), tipS * 2, tipS);
|
||
}
|
||
}
|
||
}
|
||
renderMines(g) {
|
||
for (const m of this.mines) {
|
||
const sx = this.worldToScreenX(m.x);
|
||
if (sx < -20 || sx > W + 20)
|
||
continue;
|
||
// Blink effect
|
||
const visible = Math.sin(m.blinkTimer * 0.008) > -0.3;
|
||
if (visible) {
|
||
g.fillStyle(0xff0000);
|
||
const ms = PX * 1.5;
|
||
g.fillRect(sx - ms, m.y - ms, ms * 2, ms * 2);
|
||
}
|
||
}
|
||
}
|
||
renderRadar() {
|
||
const rg = this.radarGfx;
|
||
rg.clear();
|
||
// Background
|
||
rg.fillStyle(0x000000, 0.5);
|
||
rg.fillRect(0, RADAR_Y, W, RADAR_H);
|
||
// Blue border lines (left and right edges, like original)
|
||
rg.lineStyle(2, 0x0044ff, 0.9);
|
||
rg.beginPath();
|
||
rg.moveTo(W * 0.3, RADAR_Y);
|
||
rg.lineTo(W * 0.3, RADAR_Y + RADAR_H);
|
||
rg.strokePath();
|
||
rg.beginPath();
|
||
rg.moveTo(W * 0.7, RADAR_Y);
|
||
rg.lineTo(W * 0.7, RADAR_Y + RADAR_H);
|
||
rg.strokePath();
|
||
// Top and bottom border
|
||
rg.lineStyle(1, 0x0044ff, 0.6);
|
||
rg.strokeRect(0, RADAR_Y, W, RADAR_H);
|
||
const scaleX = W / WORLD_W;
|
||
const scaleY = RADAR_H / H;
|
||
// Terrain on radar — orange to match main terrain
|
||
if (!this.planetDestroyed) {
|
||
rg.lineStyle(1, 0xcc8800, 0.6);
|
||
rg.beginPath();
|
||
let first = true;
|
||
for (let i = 0; i < this.terrainHeights.length; i += 4) {
|
||
const wx = i * TERRAIN_SAMPLE;
|
||
const rx = wx * scaleX;
|
||
const ry = RADAR_Y + this.terrainHeights[i] * scaleY;
|
||
if (first) {
|
||
rg.moveTo(rx, ry);
|
||
first = false;
|
||
}
|
||
else
|
||
rg.lineTo(rx, ry);
|
||
}
|
||
rg.strokePath();
|
||
}
|
||
// Blips
|
||
const blipSize = 3;
|
||
// Humanoids (cyan)
|
||
rg.fillStyle(0x00ffff);
|
||
for (const h of this.humanoids) {
|
||
if (h.state === 'dead')
|
||
continue;
|
||
rg.fillRect(h.x * scaleX, RADAR_Y + h.y * scaleY, blipSize, blipSize);
|
||
}
|
||
// Enemies
|
||
for (const e of this.enemies) {
|
||
if (!e.alive)
|
||
continue;
|
||
const color = e.type === 'mutant' ? 0xff00ff :
|
||
e.type === 'bomber' ? 0xffff00 :
|
||
e.type === 'baiter' ? 0x00ff44 :
|
||
e.type === 'swarmer' ? 0xffff00 :
|
||
e.type === 'pod' ? 0xcc00cc :
|
||
0x00ff00;
|
||
rg.fillStyle(color);
|
||
rg.fillRect(e.x * scaleX, RADAR_Y + e.y * scaleY, blipSize, blipSize);
|
||
}
|
||
// Player (white crosshair, like original — larger for visibility)
|
||
const px = this.playerX * scaleX;
|
||
const py = RADAR_Y + this.playerY * scaleY;
|
||
rg.fillStyle(0xffffff);
|
||
rg.fillRect(px - 1, py - 4, 3, 9); // vertical bar
|
||
rg.fillRect(px - 4, py - 1, 9, 3); // horizontal bar
|
||
}
|
||
renderSmartBombHUD() {
|
||
const hg = this.hudExtraGfx;
|
||
hg.clear();
|
||
// Draw smart bomb count below radar
|
||
const bombY = RADAR_Y + RADAR_H + 4;
|
||
for (let i = 0; i < this.smartBombs; i++) {
|
||
hg.fillStyle(0xff4400);
|
||
hg.fillRect(8 + i * 14, bombY, 10, 8);
|
||
hg.lineStyle(1, 0xff8800);
|
||
hg.strokeRect(8 + i * 14, bombY, 10, 8);
|
||
}
|
||
}
|
||
/* ================================================================
|
||
CLEANUP
|
||
================================================================ */
|
||
shutdown() {
|
||
super.shutdown();
|
||
// Stop looping sounds
|
||
try {
|
||
this.sound.stopByKey('snd_thrust');
|
||
}
|
||
catch { }
|
||
this.thrustSoundPlaying = false;
|
||
// Destroy enemy sprites
|
||
for (const e of this.enemies) {
|
||
e.sprite = this.destroyObj(e.sprite);
|
||
}
|
||
// Destroy humanoid sprites
|
||
for (const h of this.humanoids) {
|
||
h.sprite = this.destroyObj(h.sprite);
|
||
}
|
||
// Destroy player sprite
|
||
this.shipSprite = this.destroyObj(this.shipSprite);
|
||
// Destroy graphics objects
|
||
this.destroyObj(this.gameGfx);
|
||
this.destroyObj(this.radarGfx);
|
||
this.destroyObj(this.terrainGfx);
|
||
this.destroyObj(this.hudExtraGfx);
|
||
}
|
||
}
|
||
//# sourceMappingURL=PlanetGuardian.js.map
|