From 79fb74d9b28217e5fb0f4f3c191ef0c905829ec2 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 6 Sep 2026 15:14:42 -0600 Subject: [PATCH 1/3] Add Defender arcade game with wireframe rendering and headless verificat - Implement full Defender gameplay: boids swarmers, walkers, abductors, boss fights, humanoid rescue/escape state machine, combo multiplier, and overdrive timescale across 5 levels - Render all entities as procedural Graphics strokes (glow-stroke pair) with a toroidal wraparound world, per-level accent palettes, CRT overlay, and a custom vector font for banners/title - Register the game in the registry, scene dispatch, asset manifest, and soundtrack overrides so it appears alongside other arcade titles - Add tools/verifyDefender.js covering wrap math, seam-adjacent boids, every rescue transition, overdrive thresholds, entity leak bounds, boss/level tally, spiral-of-death guard, determinism, and a Monte-Carlo bot soak --- src/data/assetManifest.js | 5 + src/data/gamesRegistry.js | 1 + src/games/defender/DefenderFx.js | 125 ++++ src/games/defender/DefenderGame.js | 725 ++++++++++++++++++++ src/games/defender/DefenderLogic.js | 813 +++++++++++++++++++++++ src/games/defender/DefenderVectorFont.js | 90 +++ src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- src/services/soundtrack.js | 1 + tools/verifyDefender.js | 376 +++++++++++ 10 files changed, 2139 insertions(+), 1 deletion(-) create mode 100644 src/games/defender/DefenderFx.js create mode 100644 src/games/defender/DefenderGame.js create mode 100644 src/games/defender/DefenderLogic.js create mode 100644 src/games/defender/DefenderVectorFont.js create mode 100644 tools/verifyDefender.js diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index f9f9cdb..de0fa17 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -483,6 +483,11 @@ export const MANIFEST = { // so its audio only downloads once Tempest is actually entered. (scene) => musicFrom(scene, 'arcadedark-music'), ], + defender: [ + // arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here + // so its audio only downloads once Defender is actually entered. + (scene) => musicFrom(scene, 'arcadedark-music'), + ], mastermind: [ // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so // its audio only downloads once Mastermind is actually entered. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index c359188..4cbe37a 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -124,3 +124,4 @@ registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-co registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 }); registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 }); registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 }); +registerGame({ slug: 'defender', name: 'Defender', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 97 }); diff --git a/src/games/defender/DefenderFx.js b/src/games/defender/DefenderFx.js new file mode 100644 index 0000000..788db6b --- /dev/null +++ b/src/games/defender/DefenderFx.js @@ -0,0 +1,125 @@ +// Defender — procedural wireframe combat effects. +// +// Every bullet and explosion is a Graphics stroke, not a sprite: a capped TTL +// list is fed from sim events and redrawn each frame using the repo's +// glow-stroke pair (one wide low-alpha pass, then a thin opaque one on top) — +// the same idiom as Total Annihilation's TAFx.js and Star Control's fxList. +// Explosions render as radiating line-segment "shatter" bursts rather than +// filled particles, to match the wireframe aesthetic. + +const MAX_FX = 420; + +// Every explosion — enemy kills, the boss dying, the player dying — uses this +// one bright, fixed orange rather than the target's own color, so a kill +// always reads as a kill: a consistent, high-contrast burst against every +// level's cyan/magenta/green/yellow/red wireframe palette. +export const EXPLOSION_COLOR = 0xff7a1a; + +export default class DefenderFx { + constructor(scene, depths) { + this.scene = scene; + this.gUnder = scene.add.graphics().setDepth(depths.fxUnder); + this.gOver = scene.add.graphics().setDepth(depths.fxOver); + this.list = []; + } + + destroy() { + this.gUnder.destroy(); + this.gOver.destroy(); + this.list.length = 0; + } + + push(fx) { + if (this.list.length >= MAX_FX) this.list.shift(); + fx.age = 0; + this.list.push(fx); + } + + shatter(x, y, color, count = 8, speed = 220) { + for (let i = 0; i < count; i += 1) { + const a = (i / count) * Math.PI * 2 + Math.random() * 0.4; + const len = 10 + Math.random() * 14; + const spd = speed * (0.6 + Math.random() * 0.6); + this.push({ + kind: 'shard', x, y, ang: a, len, color, + dx: Math.cos(a) * spd, dy: Math.sin(a) * spd, spin: (Math.random() - 0.5) * 6, + ttl: 360 + Math.random() * 220, + }); + } + } + + ring(x, y, color, r = 40, ttl = 320) { + this.push({ kind: 'ring', x, y, r, color, ttl }); + } + + tracer(x1, y1, x2, y2, color, width = 2, ttl = 90) { + this.push({ kind: 'tracer', x1, y1, x2, y2, color, width, ttl }); + } + + spark(x, y, color, ttl = 220) { + this.push({ kind: 'spark', x, y, color, ttl }); + } + + onEvent(ev) { + switch (ev.type) { + case 'enemyKilled': + this.shatter(ev.x, ev.y, EXPLOSION_COLOR, ev.enemyType === 'walker' ? 12 : 8, + ev.enemyType === 'walker' ? 260 : 200); + this.ring(ev.x, ev.y, EXPLOSION_COLOR); + break; + case 'humanoidLost': + this.spark(ev.x ?? 0, ev.y ?? 0, 0xff5566, 300); + break; + default: break; + } + } + + draw(delta) { + const gu = this.gUnder; const go = this.gOver; + gu.clear(); go.clear(); + + const keep = []; + for (const fx of this.list) { + fx.age += delta; + if (fx.age >= fx.ttl) continue; + keep.push(fx); + const t = fx.age / fx.ttl; + const a = 1 - t; + + switch (fx.kind) { + case 'shard': { + const cx = fx.x + fx.dx * (fx.age / 1000); + const cy = fx.y + fx.dy * (fx.age / 1000); + const ang = fx.ang + fx.spin * (fx.age / 1000); + const hx = Math.cos(ang) * fx.len * 0.5; + const hy = Math.sin(ang) * fx.len * 0.5; + go.lineStyle(4, fx.color, 0.18 * a); + go.lineBetween(cx - hx, cy - hy, cx + hx, cy + hy); + go.lineStyle(1.6, fx.color, a); + go.lineBetween(cx - hx, cy - hy, cx + hx, cy + hy); + break; + } + case 'ring': { + const r = fx.r * (0.3 + t * 1.1); + go.lineStyle(5 * a + 1, fx.color, 0.6 * a); + go.strokeCircle(fx.x, fx.y, r); + break; + } + case 'tracer': { + go.lineStyle(fx.width * 3, fx.color, 0.16 * a); + go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2); + go.lineStyle(fx.width, fx.color, a); + go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2); + break; + } + case 'spark': { + go.fillStyle(fx.color, a); + go.fillCircle(fx.x, fx.y, 3 * a + 1); + break; + } + default: break; + } + } + this.list = keep; + } +} diff --git a/src/games/defender/DefenderGame.js b/src/games/defender/DefenderGame.js new file mode 100644 index 0000000..4b75ba4 --- /dev/null +++ b/src/games/defender/DefenderGame.js @@ -0,0 +1,725 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { getGameSoundtrack } from '../../services/soundtrack.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js'; +import { + WORLD_W, Y_MIN, Y_GROUND, Y_MAX, TUNE, + wrap, tdelta, createGame, setInput, step, +} from './DefenderLogic.js'; +import DefenderFx, { EXPLOSION_COLOR } from './DefenderFx.js'; +import { drawVectorText } from './DefenderVectorFont.js'; + +// Depth layers — one Graphics object per layer, cleared and redrawn every +// frame from current sim state (never persistent GameObjects), same idiom as +// Tempest/Total Annihilation. +const D = { + bgFar: -6, bgNear: -5, groundLine: -4, extraction: -3, + fxUnder: -2, humanoids: -1, tractorBeams: -0.5, enemies: 0, + player: 1, shots: 1.5, fxOver: 2, banner: 5, ui: 30, overlay: 61, +}; + +const BEST_KEY = 'defender-best'; + +// One accent palette per level (1-indexed) — cycles if a level ever exceeds +// the authored count. Chosen to read clearly as wireframe strokes on black. +const PALETTES = [ + { accent: 0x33e6ff, swarmer: 0x33e6ff, walker: 0xff8a3c, abductor: 0xff4fd8, humanoid: 0xffffff, boss: 0xff4fd8, ground: 0x2a3a4a }, + { accent: 0xff4fd8, swarmer: 0xff4fd8, walker: 0xffd23c, abductor: 0x33e6ff, humanoid: 0xffffff, boss: 0x33e6ff, ground: 0x3a2a4a }, + { accent: 0x7cff5a, swarmer: 0x7cff5a, walker: 0xff8a3c, abductor: 0xffd23c, humanoid: 0xffffff, boss: 0xffd23c, ground: 0x1a3a2a }, + { accent: 0xffd23c, swarmer: 0xffd23c, walker: 0xff4fd8, abductor: 0x7cff5a, humanoid: 0xffffff, boss: 0x7cff5a, ground: 0x3a3a1a }, + { accent: 0xff5a5a, swarmer: 0xff5a5a, walker: 0x33e6ff, abductor: 0xffd23c, humanoid: 0xffffff, boss: 0xffffff, ground: 0x3a1a1a }, +]; +function paletteFor(level) { return PALETTES[(level - 1) % PALETTES.length]; } + +// Vivid, deliberately off-palette pink — every level accent above is a cyan/ +// magenta/green/yellow/red, so this reads as "alert" rather than blending in +// as just another enemy color. +const ALERT_PINK = 0xff17c4; + +// The player's own bolts, distinct from every level's accent color and from +// the red enemy shots, so incoming vs. outgoing fire is unmistakable at a glance. +const PLAYER_SHOT_COLOR = 0xffe135; + +// Mini-map panel — a squashed side-view of the whole wrapped world, tucked +// under MusicPlayer's buttons + track-name readout (src/ui/MusicPlayer.js: +// PAD=12, BTN=32 buttons at y=12-44, track info text at y=58, so the music +// block's footprint ends around y=74-80). +const MINI_W = 220; +const MINI_H = 64; +const MINI_X = GAME_WIDTH - 20 - MINI_W; +const MINI_Y = 92; + +export default class DefenderGame extends Phaser.Scene { + constructor() { super('DefenderGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'defender', name: 'Defender' }; + this.mode = 'title'; // 'title' | 'playing' | 'gameover' | 'victory' + this.sim = null; + this.camX = WORLD_W / 2; + this.spin = 0; + this.banner = null; + this.multPulseMs = 0; + } + + create() { + try { + const { tracks, volume } = getGameSoundtrack(this); + if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); + } catch (_) { /* optional */ } + + this.bgRect = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x05060c) + .setDepth(D.bgFar - 1); + this.bgFarG = this.add.graphics().setDepth(D.bgFar); + this.bgNearG = this.add.graphics().setDepth(D.bgNear); + this.groundG = this.add.graphics().setDepth(D.groundLine); + this.extractG = this.add.graphics().setDepth(D.extraction); + this.humanoidG = this.add.graphics().setDepth(D.humanoids); + this.beamG = this.add.graphics().setDepth(D.tractorBeams); + this.enemyG = this.add.graphics().setDepth(D.enemies); + this.playerG = this.add.graphics().setDepth(D.player); + this.shotG = this.add.graphics().setDepth(D.shots); + this.bannerG = this.add.graphics().setDepth(D.banner); + this.hudG = this.add.graphics().setDepth(D.ui); + this.minimapG = this.add.graphics().setDepth(D.ui); + this.minimapLabel = this.add.text(MINI_X + MINI_W / 2, MINI_Y - 16, 'MAP', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex, + }).setOrigin(0.5, 1).setDepth(D.ui); + + this.fx = new DefenderFx(this, { fxUnder: D.fxUnder, fxOver: D.fxOver }); + + this.crt = applyArcadeCRTOverlay(this, { + accentTint: paletteFor(1).accent, scanlineTint: paletteFor(1).accent, + }); + this.events.once('shutdown', () => { this.crt.destroy(); this.fx.destroy(); }); + + this.buildHud(); + this.bindInput(); + this.showTitle(); + } + + // ── Input ───────────────────────────────────────────────────────────────── + + bindInput() { + this.cursors = this.input.keyboard.createCursorKeys(); + this.wasd = this.input.keyboard.addKeys('W,A,S,D,SPACE,SHIFT'); + } + + readInput() { + return { + left: this.cursors.left.isDown || this.wasd.A.isDown, + right: this.cursors.right.isDown || this.wasd.D.isDown, + up: this.cursors.up.isDown || this.wasd.W.isDown, + down: this.cursors.down.isDown || this.wasd.S.isDown, + fire: this.cursors.space?.isDown || this.wasd.SPACE.isDown, + overdrive: this.wasd.SHIFT.isDown, + }; + } + + // ── Title screen ───────────────────────────────────────────────────────── + + showTitle() { + this.mode = 'title'; + this.titleUi = this.add.container(0, 0).setDepth(D.ui + 1); + this.titleG = this.add.graphics().setDepth(D.ui); + + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + const sub = this.add.text(GAME_WIDTH / 2, 470, + 'ARROWS/WASD: FLY • SPACE: FIRE • SHIFT: OVERDRIVE (when charged)', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + const sub2 = this.add.text(GAME_WIDTH / 2, 508, + 'Free the humanoids from abductors, catch them before they fall, and fly them to a beacon.', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.titleUi.add([sub, sub2]); + if (best > 0) { + this.titleUi.add(this.add.text(GAME_WIDTH / 2, 546, `BEST ${best}`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5)); + } + const play = new Button(this, GAME_WIDTH / 2, 640, 'Play', () => this.startRun(), + { width: 280, height: 66, fontSize: 28 }); + this.titleUi.add(play); + } + + drawTitle(delta) { + const g = this.titleG; + g.clear(); + this.spin += delta * 0.0002; + const pal = paletteFor(1); + for (let i = 0; i < 40; i += 1) { + const a = this.spin + (i / 40) * Math.PI * 2; + const r = 260 + Math.sin(a * 3) * 20; + const x = GAME_WIDTH / 2 + Math.cos(a) * r; + const y = 300 + Math.sin(a) * r * 0.32; + g.lineStyle(1.4, pal.accent, 0.12); + g.lineBetween(GAME_WIDTH / 2, 300, x, y); + } + drawVectorText(g, 'DEFENDER', GAME_WIDTH / 2, 300, 24, pal.accent, + { lineWidth: 5, glowWidth: 18, glowAlpha: 0.22 }); + } + + startRun() { + playSound(this, SFX.EIGHTBIT_ACTIVATE); + this.titleUi.destroy(true); + this.titleG.destroy(); + this.titleUi = null; + this.titleG = null; + this.sim = createGame({ seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 }); + this.camX = this.sim.player.x; + this.mode = 'playing'; + this.applyPalette(); + this.showBanner('LEVEL 1', paletteFor(1).accent, 1600); + playSound(this, SFX.COUNTDOWN_GO); + } + + applyPalette() { + const pal = paletteFor(this.sim.level); + this.crt.setIntensity({ accentTint: pal.accent, scanlineTint: pal.accent }); + } + + // ── HUD ─────────────────────────────────────────────────────────────────── + + buildHud() { + const font = { fontFamily: 'm6x11, "Julius Sans One"' }; + this.scoreLabel = this.add.text(40, 26, 'SCORE', { ...font, fontSize: '18px', color: COLORS.mutedHex }).setDepth(D.ui); + this.scoreText = this.add.text(40, 46, '0', { ...font, fontSize: '40px', color: COLORS.textHex }).setDepth(D.ui); + this.levelText = this.add.text(GAME_WIDTH / 2, 30, '', { ...font, fontSize: '28px', color: COLORS.goldHex }) + .setOrigin(0.5, 0).setDepth(D.ui); + this.bestText = this.add.text(GAME_WIDTH - 40, 30, `BEST ${Number(localStorage.getItem(BEST_KEY) ?? 0)}`, + { ...font, fontSize: '22px', color: COLORS.mutedHex }).setOrigin(1, 0).setDepth(D.ui); + this.multText = this.add.text(GAME_WIDTH - 40, 66, '', { ...font, fontSize: '26px', color: COLORS.goldHex }) + .setOrigin(1, 0).setDepth(D.ui); + this.overdriveLabel = this.add.text(40, 150, 'OVERDRIVE', { ...font, fontSize: '16px', color: COLORS.mutedHex }).setDepth(D.ui); + } + + updateHud() { + const sim = this.sim; + this.scoreText.setText(String(sim.score)); + this.levelText.setText(`LEVEL ${sim.level} • WAVE ${Math.min(sim.wave, TUNE.WAVES_PER_LEVEL)}/${TUNE.WAVES_PER_LEVEL}`); + if (sim.multiplier > 1) { + this.multText.setText(`×${sim.multiplier}`); + this.multText.setVisible(true); + } else { + this.multText.setVisible(false); + } + + const g = this.hudG; + g.clear(); + const pal = paletteFor(sim.level); + + // Lives as little chevron glyphs under the score. + for (let i = 0; i < Math.min(sim.lives, 8); i += 1) { + const x = 46 + i * 34; const y = 108; + this.strokeChevron(g, x, y, 12, 1, pal.accent, [[5, 0.18], [2, 1]]); + } + + // Overdrive meter bar. + const bx = 40; const by = 172; const bw = 260; const bh = 18; + g.lineStyle(2, COLORS.muted, 0.6); + g.strokeRect(bx, by, bw, bh); + const fillColor = sim.overdriveActive ? 0xffffff : pal.accent; + g.fillStyle(fillColor, sim.overdriveMeter >= 1 && !sim.overdriveActive ? 0.5 + 0.5 * Math.sin(this.time.now * 0.01) : 0.8); + g.fillRect(bx + 2, by + 2, Math.max(0, (bw - 4) * sim.overdriveMeter), bh - 4); + } + + // ── Frame loop ──────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.mode === 'title') { this.drawTitle(delta); return; } + if (!this.sim) return; + + if (this.mode === 'playing') { + setInput(this.sim, this.readInput()); + const events = step(this.sim, delta); + for (const e of events) this.handleEvent(e); + } + this.syncGraphics(delta); + if (this.mode === 'playing') this.updateHud(); + } + + handleEvent(e) { + const pal = paletteFor(this.sim.level); + switch (e.type) { + case 'shotFired': + if (!e.enemy) playSound(this, SFX.LASER_ZAP); + break; + case 'enemyKilled': + playSound(this, e.enemyType === 'walker' ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE); + // Sim events carry world-space x/y; DefenderFx draws in screen space, so the x has + // to go through screenX() here or the burst lands wherever the world coordinate + // happens to fall on screen instead of where the kill actually happened. + this.fx.onEvent({ ...e, x: this.screenX(e.x) }); + break; + case 'humanoidGrabbed': + playSound(this, SFX.CASINO_LOSE); + this.showBanner('HUMAN CAPTURED!', ALERT_PINK, 1500); + this.crt.pulse(0.5, 220); + break; + case 'humanoidPickedUp': + playSound(this, SFX.UI_CHIME); + break; + case 'humanoidRescued': + playSound(this, SFX.VICTORY_SHORT); + break; + case 'humanoidLost': + playSound(this, SFX.CASINO_LOSE); + this.fx.onEvent({ ...e, x: this.screenX(e.x) }); + break; + case 'overdriveReady': + playSound(this, SFX.EIGHTBIT_COUNT); + break; + case 'overdriveStart': + playSound(this, SFX.ENERGY_HUM); + this.crt.pulse(0.6, 260); + break; + case 'overdriveEnd': + playSound(this, SFX.SCIFI_WOOSH); + break; + case 'waveStart': + this.showBanner(`WAVE ${e.wave}`, pal.accent, 1200); + break; + case 'waveClear': + playSound(this, SFX.UI_ACTIVATE); + break; + case 'bossSpawn': + playSound(this, SFX.SCIFI_REVEAL); + this.showBanner('WARNING', 0xff4040, 1800); + break; + case 'bossPhaseChange': + playSound(this, SFX.EIGHTBIT_EXPLODE_2); + this.crt.pulse(0.7, 300); + break; + case 'bossDefeated': + playSound(this, SFX.SCIFI_EXPLODE); + this.fx.shatter(this.screenX(this.sim.boss?.x ?? this.camX), (Y_MIN + Y_GROUND) / 2, EXPLOSION_COLOR, 24, 320); + this.crt.pulse(1, 400); + break; + case 'levelComplete': + this.showBanner(e.fullRescue ? 'LEVEL CLEAR — ALL RESCUED!' : 'LEVEL CLEAR', COLORS.gold, 2000); + playSound(this, SFX.EIGHTBIT_WIN); + break; + case 'playerDied': + playSound(this, SFX.EIGHTBIT_EXPLODE_2); + // Player position is world-space; convert through the camera or the burst + // renders at whatever raw world-x maps to in screen pixels, not the ship. + this.fx.shatter(this.screenX(this.sim.player.x), this.sim.player.y, EXPLOSION_COLOR, 14, 260); + this.crt.pulse(0.8, 320); + break; + case 'playerRespawned': + playSound(this, SFX.COUNTDOWN_GO); + break; + case 'victory': + this.onVictory(e); + break; + case 'gameOver': + this.onGameOver(e); + break; + default: break; + } + if (this.sim.level && (e.type === 'levelComplete')) { + this.time.delayedCall(50, () => this.applyPalette()); + } + } + + // ── World → screen mapping & camera ───────────────────────────────────── + + screenX(worldX) { + return GAME_WIDTH / 2 + tdelta(this.camX, worldX); + } + + updateCamera() { + const target = this.sim.player.x; + const d = tdelta(this.camX, target); + this.camX = wrap(this.camX + d * 0.14); + } + + strokeChevron(g, x, y, r, facing, color, passes) { + for (const [lw, a] of passes) { + g.lineStyle(lw, color, a); + g.beginPath(); + g.moveTo(x - facing * r * 0.8, y - r * 0.7); + g.lineTo(x + facing * r, y); + g.lineTo(x - facing * r * 0.8, y + r * 0.7); + g.lineTo(x - facing * r * 0.3, y); + g.closePath(); + g.strokePath(); + } + } + + strokeNgon(g, x, y, r, sides, rot = 0) { + g.beginPath(); + for (let i = 0; i <= sides; i += 1) { + const a = rot + (i / sides) * Math.PI * 2; + const px = x + Math.cos(a) * r; const py = y + Math.sin(a) * r; + if (i === 0) g.moveTo(px, py); else g.lineTo(px, py); + } + g.strokePath(); + } + + strokeStick(g, x, y, r, color) { + for (const [lw, a] of [[4, 0.16], [1.6, 0.9]]) { + g.lineStyle(lw, color, a); + g.strokeCircle(x, y - r * 1.3, r * 0.45); + g.beginPath(); + g.moveTo(x, y - r * 0.85); g.lineTo(x, y + r * 0.3); + g.moveTo(x - r * 0.6, y - r * 0.4); g.lineTo(x + r * 0.6, y - r * 0.4); + g.moveTo(x, y + r * 0.3); g.lineTo(x - r * 0.4, y + r); + g.moveTo(x, y + r * 0.3); g.lineTo(x + r * 0.4, y + r); + g.strokePath(); + } + } + + // ── Draw ────────────────────────────────────────────────────────────────── + + drawBackground(delta) { + this.spin += delta * 0.00006; + const pal = paletteFor(this.sim.level); + this.drawSkyline(this.bgFarG, this.camX * 0.35, 30, 0.0014, pal.ground, 0.5); + this.drawSkyline(this.bgNearG, this.camX * 0.7, 60, 0.0026, pal.ground, 0.85); + + const gg = this.groundG; + gg.clear(); + gg.lineStyle(3, paletteFor(this.sim.level).ground, 0.9); + gg.lineBetween(0, Y_GROUND, GAME_WIDTH, Y_GROUND); + gg.lineStyle(1, paletteFor(this.sim.level).ground, 0.35); + gg.lineBetween(0, Y_MAX, GAME_WIDTH, Y_MAX); + } + + drawSkyline(g, camOffset, amp, freq, color, alpha) { + g.clear(); + g.lineStyle(2, color, alpha); + g.beginPath(); + const baseY = Y_GROUND - 40; + for (let sx = 0; sx <= GAME_WIDTH; sx += 24) { + const wx = camOffset + sx; + const h = amp * (Math.sin(wx * freq) + 0.5 * Math.sin(wx * freq * 2.7 + 1.3)); + const y = baseY - Math.abs(h) - amp * 0.4; + if (sx === 0) g.moveTo(sx, y); else g.lineTo(sx, y); + } + g.strokePath(); + } + + drawExtractionZones() { + const g = this.extractG; + g.clear(); + const pal = paletteFor(this.sim.level); + const pulse = 0.5 + 0.5 * Math.sin(this.time.now * 0.006); + for (const z of this.sim.extractionZones) { + const sx = this.screenX(z.x); + if (sx < -80 || sx > GAME_WIDTH + 80) continue; + g.lineStyle(2, pal.accent, 0.3 + 0.3 * pulse); + g.lineBetween(sx, Y_MIN - 30, sx, Y_GROUND); + g.lineStyle(3, pal.accent, 0.7 + 0.3 * pulse); + this.strokeNgon(g, sx, Y_MIN - 30, 14, 4, Math.PI / 4); + drawVectorText(g, 'BEACON', sx, Y_MIN - 60, 1.6, pal.accent, { lineWidth: 1.4, glowWidth: 4, alpha: 0.85 }); + } + } + + drawHumanoids() { + const g = this.humanoidG; const bg = this.beamG; + g.clear(); bg.clear(); + const pal = paletteFor(this.sim.level); + for (const h of this.sim.humanoids) { + if (h.status === 'rescued' || h.status === 'lost') continue; + const sx = this.screenX(h.x); + if (sx < -60 || sx > GAME_WIDTH + 60) continue; + this.strokeStick(g, sx, h.y, TUNE.HUMANOID_RADIUS, h.status === 'idle' ? COLORS.muted : pal.humanoid); + if (h.status === 'grabbed') { + const e = this.sim.enemies.find((ee) => ee.id === h.grabberId); + if (e) { + const ex = this.screenX(e.x); + bg.lineStyle(5, pal.abductor, 0.18); + bg.lineBetween(ex, e.y, sx, h.y); + bg.lineStyle(2, pal.abductor, 0.8); + bg.lineBetween(ex, e.y, sx, h.y); + } + } + } + } + + drawEnemies() { + const g = this.enemyG; + g.clear(); + const pal = paletteFor(this.sim.level); + const t = this.time.now; + for (const e of this.sim.enemies) { + const sx = this.screenX(e.x); + if (sx < -80 || sx > GAME_WIDTH + 80) continue; + if (e.type === 'swarmer') { + const rot = Math.atan2(e.vy, e.vx || 0.001); + g.lineStyle(4, pal.swarmer, 0.16); + this.strokeNgon(g, sx, e.y, TUNE.SWARMER_RADIUS, 3, rot); + g.lineStyle(1.6, pal.swarmer, 1); + this.strokeNgon(g, sx, e.y, TUNE.SWARMER_RADIUS, 3, rot); + } else if (e.type === 'walker') { + const r = TUNE.WALKER_RADIUS; + for (const [lw, a] of [[5, 0.16], [1.8, 0.95]]) { + g.lineStyle(lw, pal.walker, a); + g.beginPath(); + g.moveTo(sx - r, e.y + r * 0.6); g.lineTo(sx - r * 0.6, e.y - r * 0.6); + g.lineTo(sx + r * 0.6, e.y - r * 0.6); g.lineTo(sx + r, e.y + r * 0.6); + g.closePath(); + g.strokePath(); + const aim = Math.atan2(0, tdelta(e.x, this.sim.player.x) || 1) + (tdelta(e.x, this.sim.player.x) < 0 ? Math.PI : 0); + g.beginPath(); + g.moveTo(sx, e.y - r * 0.3); + g.lineTo(sx + Math.cos(aim) * r * 1.2, e.y - r * 0.3 + Math.sin(aim) * r * 0.3); + g.strokePath(); + } + } else if (e.type === 'abductor') { + const spin = t * 0.003; + g.lineStyle(4, pal.abductor, 0.18); + this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS, 8, spin); + g.lineStyle(1.8, pal.abductor, 1); + this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS, 8, spin); + this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS * 0.5, 8, -spin); + } + } + + if (this.sim.boss) { + const b = this.sim.boss; + const sx = this.screenX(b.x); + const spin = t * 0.0015; + const hpFrac = Math.max(0, b.hp / b.maxHp); + for (const [lw, a] of [[6, 0.18], [2.2, 1]]) { + g.lineStyle(lw, pal.boss, a); + this.strokeNgon(g, sx, b.y, TUNE.BOSS_RADIUS, 5, spin); + this.strokeNgon(g, sx, b.y, TUNE.BOSS_RADIUS * 0.6, 5, -spin * 1.6); + } + // boss health bar + const bw = 300; const bx = sx - bw / 2; + g.lineStyle(2, COLORS.muted, 0.7); + g.strokeRect(bx, b.y - TUNE.BOSS_RADIUS - 40, bw, 14); + g.fillStyle(pal.boss, 0.85); + g.fillRect(bx + 2, b.y - TUNE.BOSS_RADIUS - 38, Math.max(0, (bw - 4) * hpFrac), 10); + } + } + + drawPlayerAndShots() { + const g = this.playerG; const sg = this.shotG; + g.clear(); sg.clear(); + const pal = paletteFor(this.sim.level); + const p = this.sim.player; + if (p.alive && (Math.floor(p.invulnMs / 90) % 2 === 0 || p.invulnMs <= 0)) { + const sx = this.screenX(p.x); + const color = this.sim.overdriveActive ? 0xffffff : pal.accent; + this.strokeChevron(g, sx, p.y, TUNE.PLAYER_RADIUS, p.facing, color, [[6, 0.2], [2.2, 1]]); + if (p.carrying != null) { + // (drawn in drawHumanoids since the humanoid entity itself already + // tracks the carry offset each tick) + } + } + for (const s of this.sim.shots) { + const sx = this.screenX(s.x); + sg.lineStyle(4, PLAYER_SHOT_COLOR, 0.22); + sg.lineBetween(sx - 10 * Math.sign(s.vx || 1), s.y, sx + 10 * Math.sign(s.vx || 1), s.y); + sg.lineStyle(1.8, PLAYER_SHOT_COLOR, 1); + sg.lineBetween(sx - 10 * Math.sign(s.vx || 1), s.y, sx + 10 * Math.sign(s.vx || 1), s.y); + } + for (const s of this.sim.enemyShots) { + const sx = this.screenX(s.x); + sg.lineStyle(4, 0xff5050, 0.18); + sg.fillStyle(0xff5050, 1); + sg.fillCircle(sx, s.y, 4); + sg.lineStyle(1.6, 0xffb0b0, 1); + sg.strokeCircle(sx, s.y, 4); + } + } + + // A squashed side-view of the entire wrapped world: the player sits fixed + // at the horizontal center, and every other entity is placed left/right of + // it by tdelta() scaled against half the world's width — so an entity's + // offset from center IS its shortest-path direction and distance, with no + // separate case needed for "which way around the ring is closer". Vertical + // position mirrors world Y, so a climbing abductor visibly rises toward the + // top of the panel — a glance tells you both which way to fly and how much + // time is left before an escape. + minimapX(worldX) { + const half = MINI_W / 2 - 12; + return MINI_X + MINI_W / 2 + (tdelta(this.sim.player.x, worldX) / (WORLD_W / 2)) * half; + } + + minimapY(worldY) { + const top = MINI_Y + 8; const h = MINI_H - 16; + const f = Math.max(0, Math.min(1, (worldY - Y_MIN) / (Y_GROUND - Y_MIN))); + return top + f * h; + } + + drawMinimap() { + const g = this.minimapG; + g.clear(); + if (!this.sim) return; + const pal = paletteFor(this.sim.level); + + const dangerHumanoids = this.sim.humanoids.filter((h) => h.status === 'grabbed' || h.status === 'falling'); + const alerting = dangerHumanoids.length > 0; + const pulse = 0.5 + 0.5 * Math.sin(this.time.now * 0.014); + const frameColor = alerting ? ALERT_PINK : pal.accent; + + g.fillStyle(0x000000, 0.5); + g.fillRoundedRect(MINI_X, MINI_Y, MINI_W, MINI_H, 8); + g.lineStyle(alerting ? 3 : 2, frameColor, alerting ? 0.6 + 0.4 * pulse : 0.7); + g.strokeRoundedRect(MINI_X, MINI_Y, MINI_W, MINI_H, 8); + + // Extraction beacons — always visible, so "where do I take them" never gets lost. + for (const z of this.sim.extractionZones) { + const x = this.minimapX(z.x); + if (x < MINI_X || x > MINI_X + MINI_W) continue; + g.lineStyle(2, pal.accent, 0.9); + this.strokeNgon(g, x, MINI_Y + MINI_H - 8, 4, 4, Math.PI / 4); + } + + // Enemies — small dots tinted by type, same palette colors as the world view. + for (const en of this.sim.enemies) { + const x = this.minimapX(en.x); + if (x < MINI_X - 4 || x > MINI_X + MINI_W + 4) continue; + const y = this.minimapY(en.y); + const color = pal[en.type] ?? pal.accent; + g.fillStyle(color, 0.8); + g.fillCircle(x, y, en.type === 'walker' ? 3 : 2); + } + + // Boss, if active. + if (this.sim.boss) { + const x = this.minimapX(this.sim.boss.x); + if (x >= MINI_X && x <= MINI_X + MINI_W) { + g.fillStyle(pal.boss, 0.6 + 0.4 * pulse); + g.fillCircle(x, this.minimapY(this.sim.boss.y), 5); + } + } + + // Humanoids — idle dim, carried safe-white, grabbed/falling alert-pink and pulsing. + for (const h of this.sim.humanoids) { + if (h.status === 'rescued' || h.status === 'lost') continue; + const x = this.minimapX(h.x); + if (x < MINI_X - 6 || x > MINI_X + MINI_W + 6) continue; + const y = this.minimapY(h.y); + if (h.status === 'grabbed' || h.status === 'falling') { + g.fillStyle(ALERT_PINK, 0.3 + 0.35 * pulse); + g.fillCircle(x, y, 7 + 3 * pulse); + g.fillStyle(0xffffff, 1); + g.fillCircle(x, y, 2.6); + } else { + g.fillStyle(h.status === 'carried' ? 0xffffff : COLORS.muted, h.status === 'carried' ? 1 : 0.75); + g.fillCircle(x, y, h.status === 'carried' ? 3.4 : 2.6); + } + } + + // Player — fixed at horizontal center; vertical position still tracks real altitude. + const px = MINI_X + MINI_W / 2; const py = this.minimapY(this.sim.player.y); + g.fillStyle(this.sim.overdriveActive ? 0xffffff : pal.accent, 1); + g.beginPath(); + g.moveTo(px, py - 5); g.lineTo(px - 4, py + 4); g.lineTo(px + 4, py + 4); + g.closePath(); + g.fillPath(); + + // Directional alert arrow, just outside the panel, toward the nearest capture. + if (alerting) { + dangerHumanoids.sort((a, b) => Math.abs(tdelta(this.sim.player.x, a.x)) - Math.abs(tdelta(this.sim.player.x, b.x))); + const nearest = dangerHumanoids[0]; + const dir = tdelta(this.sim.player.x, nearest.x) >= 0 ? 1 : -1; + const ax = dir > 0 ? MINI_X + MINI_W + 16 : MINI_X - 16; + const ay = MINI_Y + MINI_H / 2; + g.fillStyle(ALERT_PINK, 0.6 + 0.4 * pulse); + g.beginPath(); + g.moveTo(ax + dir * 8, ay); g.lineTo(ax - dir * 6, ay - 8); g.lineTo(ax - dir * 6, ay + 8); + g.closePath(); + g.fillPath(); + } + } + + syncGraphics(delta) { + this.updateCamera(); + this.drawBackground(delta); + this.drawExtractionZones(); + this.drawHumanoids(); + this.drawEnemies(); + this.drawPlayerAndShots(); + this.fx.draw(delta); + this.drawMinimap(); + + const bg = this.bannerG; + bg.clear(); + if (this.banner) { + this.banner.ageMs += delta; + if (this.banner.ageMs >= this.banner.lifeMs) { + this.banner = null; + } else { + const f = this.banner.ageMs / this.banner.lifeMs; + const alpha = f < 0.15 ? f / 0.15 : (f > 0.75 ? (1 - f) / 0.25 : 1); + drawVectorText(bg, this.banner.text, GAME_WIDTH / 2, 200, 6, this.banner.color, + { lineWidth: 3, glowWidth: 12, glowAlpha: 0.22, alpha }); + } + } + } + + showBanner(text, color, lifeMs) { + this.banner = { text, color, lifeMs, ageMs: 0 }; + } + + // ── Game over / victory ─────────────────────────────────────────────────── + + recordScore(score, result) { + const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0); + const newBest = score > prevBest; + if (newBest) localStorage.setItem(BEST_KEY, String(score)); + api.post('/history/single-player', { + slug: 'defender', score, opponentScores: [], result, + }).catch(() => { /* best effort */ }); + return { prevBest, newBest }; + } + + onGameOver(e) { + this.mode = 'gameover'; + this.crt.pulse(1, 400); + const { prevBest, newBest } = this.recordScore(e.score, 'loss'); + this.time.delayedCall(600, () => this.showEndPanel('GAME OVER', COLORS.dangerHex, e, prevBest, newBest, `You reached level ${e.level}.`)); + } + + onVictory(e) { + this.mode = 'victory'; + this.crt.pulse(1, 500); + const { prevBest, newBest } = this.recordScore(e.score, 'win'); + playSound(this, SFX.EIGHTBIT_WIN); + this.time.delayedCall(600, () => this.showEndPanel('VICTORY!', COLORS.goldHex, e, prevBest, newBest, 'All 5 levels defended.')); + } + + showEndPanel(title, titleColor, e, prevBest, newBest, subtitle) { + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(D.overlay); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive()); + + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 380, cy - 260, 760, 520, 22); + panel.lineStyle(3, paletteFor(this.sim.level).accent, 1); + panel.strokeRoundedRect(cx - 380, cy - 260, 760, 520, 22); + root.add(panel); + + root.add(this.add.text(cx, cy - 192, title, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '52px', color: titleColor }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 130, subtitle, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0.5)); + + const scoreText = this.add.text(cx, cy - 30, '0', { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '88px', color: COLORS.goldHex }).setOrigin(0.5); + root.add(scoreText); + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, v: e.score, duration: 900, ease: 'Cubic.easeOut', + onUpdate: () => scoreText.setText(String(Math.round(counter.v))), + }); + root.add(this.add.text(cx, cy + 30, 'SCORE', { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex }).setOrigin(0.5)); + root.add(this.add.text(cx, cy + 74, newBest ? '★ NEW BEST ★' : (prevBest > 0 ? `Best: ${prevBest}` : ''), + { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: newBest ? COLORS.goldHex : COLORS.mutedHex }).setOrigin(0.5)); + + const again = new Button(this, cx - 170, cy + 190, 'Play Again', () => this.scene.restart({ game: this.gameDef }), + { width: 280, height: 62, fontSize: 26 }); + const menu = new Button(this, cx + 170, cy + 190, 'Menu', () => this.scene.start('GameMenu'), + { width: 280, height: 62, fontSize: 26, variant: 'ghost' }); + root.add([again, menu]); + } +} diff --git a/src/games/defender/DefenderLogic.js b/src/games/defender/DefenderLogic.js new file mode 100644 index 0000000..250cb13 --- /dev/null +++ b/src/games/defender/DefenderLogic.js @@ -0,0 +1,813 @@ +// Pure simulation for Defender (Resogun-style wireframe swarm shooter). No +// Phaser dependency — fully unit-testable headlessly via tools/verifyDefender.js. +// +// World model: a single 2D plane that WRAPS horizontally (a "ring", like a +// side view of a cylinder) and is bounded vertically. Every position/velocity +// update and every AI distance/heading calc that touches X must go through +// wrap()/tdelta() below — a naive `dx = a.x - b.x` will make swarms visibly +// split at the wrap seam. Same toroidal-math idiom as Star Control +// (src/games/starcontrol/StarControlLogic.js), reduced to one wrapped axis. +// +// Fixed-tick loop: same accumulator/spiral-of-death-guard pattern as Total +// Annihilation (src/games/totalannihilation/TALogic.js) rather than a raw +// per-frame delta — swarm flocking and rescue timers behave identically +// regardless of render framerate. state.alpha is the leftover fraction the +// view uses to interpolate between the last two ticks. + +// --------------------------------------------------------------------------- +// Seeded RNG (same generator every other game in this repo uses). +export function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// --------------------------------------------------------------------------- +// World / wrap math +export const WORLD_W = 6000; +export const Y_SKY = 60; // an abductor carrying a humanoid past this height = escaped +export const Y_MIN = 90; // top of the flight band +export const Y_GROUND = 900; // ground band: walkers, idle humanoids, falling humanoids land here +export const Y_MAX = 940; // bottom clamp for the player + +export const STEP_MS = 1000 / 60; +export const MAX_STEPS = 4; + +export function wrap(v, size = WORLD_W) { + return ((v % size) + size) % size; +} + +// Shortest signed delta from a to b on the wrapped X axis. +export function tdelta(a, b, size = WORLD_W) { + let d = (b - a) % size; + if (d > size / 2) d -= size; + else if (d < -size / 2) d += size; + return d; +} + +export function tdist(ax, ay, bx, by) { + const dx = tdelta(ax, bx); + const dy = ay - by; + return Math.hypot(dx, dy); +} + +// --------------------------------------------------------------------------- +// Tuning — every gameplay constant lives here, named, so feel-tuning never +// touches game logic (same convention as Tempest's TUNE table). +export const TUNE = { + // Horizontal flight is deliberately much quicker than vertical — this is a + // side-view wraparound shooter, so covering ground left/right is the ship's + // primary job and should feel snappy; vertical is fine-grained dodging. + PLAYER_ACCEL_X: 3600, + PLAYER_MAX_SPEED_X: 1150, + PLAYER_ACCEL_Y: 1400, + PLAYER_MAX_SPEED_Y: 460, + PLAYER_DRAG: 5.2, + PLAYER_RADIUS: 18, + PLAYER_FIRE_COOLDOWN_MS: 130, + // Fast + long-lived enough to cross from the player (screen-centered by the + // camera) all the way past either screen edge before expiring. TTL carries + // extra margin over the bare half-screen distance because at top horizontal + // speed the camera's smoothed follow lags the ship by ~100+px. + PLAYER_SHOT_SPEED: 1100, + PLAYER_SHOT_TTL_MS: 1100, + PLAYER_SHOT_RADIUS: 6, + RESPAWN_DELAY_MS: 1500, + RESPAWN_INVULN_MS: 1200, + PLAYER_LIVES_START: 3, + + SWARMER_RADIUS: 14, + SWARMER_SPEED: 220, + SWARMER_HP: 1, + SEPARATION_RADIUS: 30, + COHESION_RADIUS: 130, + ALIGNMENT_RADIUS: 100, + SEPARATION_W: 1.5, + COHESION_W: 0.45, + ALIGNMENT_W: 0.6, + SEEK_PLAYER_W: 0.55, + SEEK_RADIUS: 520, + SWARM_PACK_SIZE_MIN: 10, + SWARM_PACK_SIZE_MAX: 16, + SWARM_MAX_CONCURRENT: 48, + + WALKER_RADIUS: 24, + WALKER_HP: 3, + WALKER_SPEED: 70, + WALKER_PATROL_RANGE: 260, + WALKER_FIRE_COOLDOWN_MS: 1500, + WALKER_SHOT_SPEED: 360, + WALKER_SHOT_RADIUS: 7, + WALKER_AIM_RANGE: 640, + + ABDUCTOR_RADIUS: 26, + ABDUCTOR_HP: 2, + ABDUCTOR_SPEED: 160, + ABDUCTOR_RISE_SPEED: 65, + ABDUCTOR_GRAB_RADIUS: 46, + + HUMANOID_RADIUS: 14, + PICKUP_RADIUS: 46, + // A freed humanoid falls from roughly Y_SKY..Y_MIN down to Y_GROUND (~790-840px) + // at this constant speed, so a typical fall takes ~5.5-6s; GRAB_WINDOW_MS sits + // comfortably above that so hitting the ground — not the window — is normally + // what ends an uncaught fall, with the window only as a backstop. + FALL_SPEED: 140, + GRAB_WINDOW_MS: 8000, + CARRY_TIMEOUT_MS: 12000, + EXTRACT_RADIUS: 80, + EXTRACTION_ZONES_PER_LEVEL: 2, + HUMANOIDS_PER_LEVEL: 6, + + COMBO_WINDOW_MS: 1800, + MULT_MAX: 8, + OVERDRIVE_FILL_PER_KILL: 0.04, + OVERDRIVE_DURATION_MS: 8000, + OVERDRIVE_TIMESCALE: 0.35, + OVERDRIVE_SCORE_MULT: 5, + + LEVEL_COUNT: 5, + WAVES_PER_LEVEL: 4, + WAVE_BREATHER_MS: 2200, + BOSS_INTRO_MS: 2600, + BOSS_OUTRO_MS: 1800, + BOSS_HP_BASE: 60, + BOSS_HP_STEP: 30, + BOSS_RADIUS: 70, + BOSS_SPEED: 90, + BOSS_SHOT_SPEED: 300, + BOSS_ATTACK_COOLDOWN_MS: 1600, + BOSS_SPOKE_COUNT: 10, + + FULL_RESCUE_BONUS: 5000, + ENEMY_KILL_SCORE: { swarmer: 50, walker: 150, abductor: 120 }, + BOSS_KILL_SCORE: 3000, + HUMANOID_RESCUE_SCORE: 400, +}; + +// Entity ids are assigned from a per-state counter (not a module-level one) so +// that replaying the same seed from a fresh createGame() is fully +// reproducible regardless of how many other games ran earlier in the process. +function nid(state) { return state.nextId += 1; } + +// --------------------------------------------------------------------------- +// Wave / boss authoring — formula-based per (level, wave), same idiom as +// Tempest's per-level tuning functions (flipperSpeed(level), spawnInterval(level), …). +export function waveSpec(level, wave) { + const p = (level - 1) * TUNE.WAVES_PER_LEVEL + (wave - 1); // 0..19 overall progress index + return { + swarmerCount: 8 + p * 3, + packSize: Math.min(TUNE.SWARM_PACK_SIZE_MAX, TUNE.SWARM_PACK_SIZE_MIN + Math.floor(p / 2)), + walkerCount: Math.max(0, Math.floor((p - 1) / 3)), + abductorCount: Math.max(1, Math.floor(p / 3) + 1), + spawnIntervalMs: Math.max(260, 900 - p * 26), + }; +} + +const BOSS_KINDS = ['sentinel', 'crusher', 'swarmlord']; +export function bossSpec(level) { + const kind = BOSS_KINDS[(level - 1) % BOSS_KINDS.length]; + const dual = level >= 4; + const secondaryKind = dual ? BOSS_KINDS[level % BOSS_KINDS.length] : null; + return { + kind, secondaryKind, + hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP, + }; +} + +// --------------------------------------------------------------------------- +// Entity factories +function makePlayer() { + return { + x: WORLD_W / 2, y: (Y_MIN + Y_GROUND) / 2, vx: 0, vy: 0, facing: 1, + alive: true, invulnMs: 0, respawnMs: 0, + fireCooldownMs: 0, carrying: null, + }; +} + +function makeExtractionZones(rng) { + const zones = []; + const spacing = WORLD_W / TUNE.EXTRACTION_ZONES_PER_LEVEL; + for (let i = 0; i < TUNE.EXTRACTION_ZONES_PER_LEVEL; i += 1) { + zones.push({ x: wrap(spacing * i + spacing * 0.5 + (rng() - 0.5) * spacing * 0.3) }); + } + return zones; +} + +function spawnSwarmerPack(state, count) { + const cx = wrap(state.rng() * WORLD_W); + const cy = Y_MIN + state.rng() * (Y_GROUND - Y_MIN - 200); + for (let i = 0; i < count && state.enemies.filter((e) => e.type === 'swarmer').length < TUNE.SWARM_MAX_CONCURRENT; i += 1) { + state.enemies.push({ + id: nid(state), type: 'swarmer', hp: TUNE.SWARMER_HP, radius: TUNE.SWARMER_RADIUS, + x: wrap(cx + (state.rng() - 0.5) * 80), + y: cy + (state.rng() - 0.5) * 80, + vx: (state.rng() - 0.5) * 40, vy: (state.rng() - 0.5) * 40, + }); + } +} + +function spawnWalker(state) { + const x = wrap(state.rng() * WORLD_W); + state.enemies.push({ + id: nid(state), type: 'walker', hp: TUNE.WALKER_HP, radius: TUNE.WALKER_RADIUS, + x, y: Y_GROUND, vx: 0, vy: 0, + homeX: x, dir: state.rng() < 0.5 ? -1 : 1, fireCooldownMs: TUNE.WALKER_FIRE_COOLDOWN_MS * state.rng(), + }); +} + +function spawnAbductor(state) { + state.enemies.push({ + id: nid(state), type: 'abductor', hp: TUNE.ABDUCTOR_HP, radius: TUNE.ABDUCTOR_RADIUS, + x: wrap(state.rng() * WORLD_W), y: Y_MIN + 20, vx: 0, vy: 0, + targetHumanoidId: null, carryingId: null, + }); +} + +function spawnHumanoids(state, count) { + const used = new Set(); + for (let i = 0; i < count; i += 1) { + let x; + do { x = wrap(state.rng() * WORLD_W); } while (used.has(Math.floor(x / 120))); + used.add(Math.floor(x / 120)); + state.humanoids.push({ + id: nid(state), status: 'idle', x, y: Y_GROUND, vx: 0, vy: 0, + grabberId: null, carrierIsPlayer: false, timerMs: 0, + }); + } +} + +function startWave(state, wave) { + state.wave = wave; + state.phase = 'waveIntro'; + state.phaseMs = 0; + const spec = waveSpec(state.level, wave); + const queue = []; + for (let i = 0; i < Math.ceil(spec.swarmerCount / spec.packSize); i += 1) { + queue.push({ kind: 'swarmerPack', count: Math.min(spec.packSize, spec.swarmerCount - i * spec.packSize) }); + } + for (let i = 0; i < spec.walkerCount; i += 1) queue.push({ kind: 'walker' }); + for (let i = 0; i < spec.abductorCount; i += 1) queue.push({ kind: 'abductor' }); + state.spawnQueue = queue; + state.spawnTimerMs = 0; + state.spawnIntervalMs = spec.spawnIntervalMs; +} + +export function createGame(opts = {}) { + const seed = opts.seed ?? 1; + const rng = mulberry32(seed); + const state = { + seed, rng, nextId: 1, + level: opts.startLevel ?? 1, + wave: 1, phase: 'waveIntro', phaseMs: 0, + accumulatorMs: 0, alpha: 0, timeMs: 0, + player: makePlayer(), + enemies: [], humanoids: [], shots: [], enemyShots: [], boss: null, + score: 0, lives: TUNE.PLAYER_LIVES_START, + multiplier: 1, lastKillMs: -Infinity, + overdriveMeter: 0, overdriveActive: false, overdriveMsLeft: 0, + rescuedThisLevel: 0, lostThisLevel: 0, + extractionZones: makeExtractionZones(rng), + spawnQueue: [], spawnTimerMs: 0, spawnIntervalMs: 800, + input: { up: false, down: false, left: false, right: false, fire: false, overdrive: false }, + over: false, victory: false, + }; + spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL); + startWave(state, 1); + return state; +} + +export function setInput(state, patch) { + Object.assign(state.input, patch); +} + +// --------------------------------------------------------------------------- +// Per-tick subsystems + +function timescale(state) { + return state.overdriveActive ? TUNE.OVERDRIVE_TIMESCALE : 1; +} + +function updatePlayer(state, dt, events) { + const p = state.player; + if (!p.alive) { + p.respawnMs -= dt; + if (p.respawnMs <= 0) { + p.alive = true; + p.x = wrap(p.x); + p.y = (Y_MIN + Y_GROUND) / 2; + p.vx = 0; p.vy = 0; + p.invulnMs = TUNE.RESPAWN_INVULN_MS; + p.carrying = null; + events.push({ type: 'playerRespawned' }); + } + return; + } + if (p.invulnMs > 0) p.invulnMs -= dt; + + const { input } = state; + const ax = (input.right ? 1 : 0) - (input.left ? 1 : 0); + const ay = (input.down ? 1 : 0) - (input.up ? 1 : 0); + const dtS = dt / 1000; + p.vx += ax * TUNE.PLAYER_ACCEL_X * dtS; + p.vy += ay * TUNE.PLAYER_ACCEL_Y * dtS; + const drag = 1 / (1 + TUNE.PLAYER_DRAG * dtS); + p.vx *= drag; p.vy *= drag; + // Independent per-axis clamps (not a combined-magnitude clamp) so the much + // higher horizontal cap isn't diluted whenever the player is also holding + // a vertical direction. + p.vx = Math.max(-TUNE.PLAYER_MAX_SPEED_X, Math.min(TUNE.PLAYER_MAX_SPEED_X, p.vx)); + p.vy = Math.max(-TUNE.PLAYER_MAX_SPEED_Y, Math.min(TUNE.PLAYER_MAX_SPEED_Y, p.vy)); + p.x = wrap(p.x + p.vx * dtS); + p.y = Math.min(Y_MAX, Math.max(Y_MIN, p.y + p.vy * dtS)); + if (ax > 0) p.facing = 1; else if (ax < 0) p.facing = -1; + + p.fireCooldownMs -= dt; + if (input.fire && p.fireCooldownMs <= 0) { + p.fireCooldownMs = TUNE.PLAYER_FIRE_COOLDOWN_MS; + state.shots.push({ + x: p.x, y: p.y, vx: TUNE.PLAYER_SHOT_SPEED * p.facing, vy: 0, ttlMs: TUNE.PLAYER_SHOT_TTL_MS, + }); + events.push({ type: 'shotFired' }); + } + + // Overdrive trigger + if (input.overdrive && !state.overdriveActive && state.overdriveMeter >= 1) { + state.overdriveActive = true; + state.overdriveMsLeft = TUNE.OVERDRIVE_DURATION_MS; + events.push({ type: 'overdriveStart' }); + } + + // Carried humanoid follows the player, and can be dropped at an extraction zone. + if (p.carrying != null) { + const h = state.humanoids.find((hh) => hh.id === p.carrying); + if (h) { + h.x = wrap(p.x - p.facing * 24); + h.y = p.y + 24; + h.timerMs += dt; + const nearZone = state.extractionZones.some((z) => Math.abs(tdelta(p.x, z.x)) < TUNE.EXTRACT_RADIUS); + if (nearZone) { + h.status = 'rescued'; + p.carrying = null; + state.rescuedThisLevel += 1; + state.score += TUNE.HUMANOID_RESCUE_SCORE; + events.push({ type: 'humanoidRescued', id: h.id }); + } else if (h.timerMs >= TUNE.CARRY_TIMEOUT_MS) { + h.status = 'lost'; + p.carrying = null; + state.lostThisLevel += 1; + events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'carryTimeout' }); + } + } else { + p.carrying = null; + } + } else { + // Auto-pickup: a falling humanoid within pickup radius, if not already carried. + for (const h of state.humanoids) { + if (h.status !== 'falling') continue; + if (tdist(p.x, p.y, h.x, h.y) <= TUNE.PICKUP_RADIUS) { + h.status = 'carried'; + h.timerMs = 0; + p.carrying = h.id; + events.push({ type: 'humanoidPickedUp', id: h.id }); + break; + } + } + } +} + +function killPlayer(state, events) { + const p = state.player; + if (!p.alive || p.invulnMs > 0) return; + p.alive = false; + p.respawnMs = TUNE.RESPAWN_DELAY_MS; + if (p.carrying != null) { + const h = state.humanoids.find((hh) => hh.id === p.carrying); + if (h) { + h.status = 'lost'; + state.lostThisLevel += 1; + events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'playerDied' }); + } + p.carrying = null; + } + state.lives -= 1; + events.push({ type: 'playerDied' }); + if (state.lives < 0) { + state.over = true; + state.phase = 'gameOver'; + events.push({ type: 'gameOver', score: state.score, level: state.level }); + } +} + +function neighborForces(e, list, dt) { + let sepX = 0; let sepY = 0; let cohX = 0; let cohY = 0; let aliX = 0; let aliY = 0; let n = 0; + for (const o of list) { + if (o === e) continue; + const dx = tdelta(e.x, o.x); + const dy = o.y - e.y; + const d = Math.hypot(dx, dy) || 0.001; + if (d < TUNE.SEPARATION_RADIUS) { sepX -= dx / d; sepY -= dy / d; } + if (d < TUNE.COHESION_RADIUS) { cohX += dx; cohY += dy; n += 1; } + if (d < TUNE.ALIGNMENT_RADIUS) { aliX += o.vx; aliY += o.vy; } + } + if (n > 0) { cohX /= n; cohY /= n; aliX /= n; aliY /= n; } + return { + fx: sepX * TUNE.SEPARATION_W + cohX * TUNE.COHESION_W * 0.02 + aliX * TUNE.ALIGNMENT_W * 0.02, + fy: sepY * TUNE.SEPARATION_W + cohY * TUNE.COHESION_W * 0.02 + aliY * TUNE.ALIGNMENT_W * 0.02, + }; +} + +function updateSwarmers(state, dt) { + const dtS = dt / 1000; + const swarmers = state.enemies.filter((e) => e.type === 'swarmer'); + const p = state.player; + for (const e of swarmers) { + const { fx, fy } = neighborForces(e, swarmers, dt); + e.vx += fx * dt; e.vy += fy * dt; + const dToPlayer = tdist(e.x, e.y, p.x, p.y); + if (dToPlayer < TUNE.SEEK_RADIUS && p.alive) { + const dx = tdelta(e.x, p.x); const dy = p.y - e.y; + const d = Math.hypot(dx, dy) || 1; + e.vx += (dx / d) * TUNE.SEEK_PLAYER_W * dt; + e.vy += (dy / d) * TUNE.SEEK_PLAYER_W * dt; + } + const sp = Math.hypot(e.vx, e.vy); + if (sp > TUNE.SWARMER_SPEED) { const k = TUNE.SWARMER_SPEED / sp; e.vx *= k; e.vy *= k; } + e.x = wrap(e.x + e.vx * dtS); + e.y = Math.min(Y_GROUND - 40, Math.max(Y_MIN, e.y + e.vy * dtS)); + } +} + +function updateWalkers(state, dt, events) { + const dtS = dt / 1000; + for (const e of state.enemies) { + if (e.type !== 'walker') continue; + const dHome = tdelta(e.homeX, e.x); + if (Math.abs(dHome) > TUNE.WALKER_PATROL_RANGE) e.dir = dHome > 0 ? -1 : 1; + e.x = wrap(e.x + e.dir * TUNE.WALKER_SPEED * dtS); + e.fireCooldownMs -= dt; + const dToPlayer = tdist(e.x, e.y, state.player.x, state.player.y); + if (state.player.alive && dToPlayer < TUNE.WALKER_AIM_RANGE && e.fireCooldownMs <= 0) { + e.fireCooldownMs = TUNE.WALKER_FIRE_COOLDOWN_MS; + const dx = tdelta(e.x, state.player.x); const dy = state.player.y - e.y; + const d = Math.hypot(dx, dy) || 1; + state.enemyShots.push({ + x: e.x, y: e.y, vx: (dx / d) * TUNE.WALKER_SHOT_SPEED, vy: (dy / d) * TUNE.WALKER_SHOT_SPEED, + radius: TUNE.WALKER_SHOT_RADIUS, ttlMs: 2200, + }); + events.push({ type: 'shotFired', enemy: true }); + } + } +} + +function updateAbductors(state, dt, events) { + const dtS = dt / 1000; + for (const e of state.enemies) { + if (e.type !== 'abductor') continue; + if (e.carryingId == null) { + // seek an idle humanoid + if (e.targetHumanoidId == null) { + const idle = state.humanoids.filter((h) => h.status === 'idle'); + if (idle.length) { + idle.sort((a, b) => Math.abs(tdelta(e.x, a.x)) - Math.abs(tdelta(e.x, b.x))); + e.targetHumanoidId = idle[0].id; + } + } + const target = state.humanoids.find((h) => h.id === e.targetHumanoidId && h.status === 'idle'); + if (target) { + const dx = tdelta(e.x, target.x); const dy = target.y - e.y; + const d = Math.hypot(dx, dy) || 1; + e.x = wrap(e.x + (dx / d) * TUNE.ABDUCTOR_SPEED * dtS); + e.y += (dy / d) * TUNE.ABDUCTOR_SPEED * dtS; + if (d < TUNE.ABDUCTOR_GRAB_RADIUS) { + target.status = 'grabbed'; + target.grabberId = e.id; + e.carryingId = target.id; + events.push({ type: 'humanoidGrabbed', id: target.id }); + } + } else { + e.targetHumanoidId = null; + } + } else { + const h = state.humanoids.find((hh) => hh.id === e.carryingId); + if (!h || h.status !== 'grabbed') { e.carryingId = null; continue; } + e.y -= TUNE.ABDUCTOR_RISE_SPEED * dtS; + h.x = e.x; h.y = e.y + 20; + if (e.y <= Y_SKY) { + h.status = 'lost'; + state.lostThisLevel += 1; + events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'escaped' }); + e.carryingId = null; + e.targetHumanoidId = null; + // The abductor escapes with its prize — remove it from play. + e.dead = true; + } + } + } +} + +function updateHumanoids(state, dt, events) { + const dtS = dt / 1000; + for (const h of state.humanoids) { + if (h.status === 'falling') { + h.timerMs += dt; + // Constant, gentle descent — no acceleration, so a catch attempt is just as + // makeable in the last moment as it was at the start of the fall. + h.y += TUNE.FALL_SPEED * dtS; + if (h.y >= Y_GROUND || h.timerMs >= TUNE.GRAB_WINDOW_MS) { + h.status = 'lost'; + state.lostThisLevel += 1; + events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: h.y >= Y_GROUND ? 'hitGround' : 'grabWindow' }); + } + } + } +} + +// A grabbed humanoid's carrying abductor may die mid-carry — releases it to fall. +function releaseGrabbedHumanoids(state, deadAbductorIds, events) { + if (!deadAbductorIds.size) return; + for (const h of state.humanoids) { + if (h.status === 'grabbed' && deadAbductorIds.has(h.grabberId)) { + h.status = 'falling'; + h.vy = TUNE.FALL_SPEED; + h.timerMs = 0; + events.push({ type: 'humanoidFreed', id: h.id }); + } + } +} + +function updateShots(state, dt) { + const dtS = dt / 1000; + for (const s of state.shots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; } + state.shots = state.shots.filter((s) => s.ttlMs > 0); + for (const s of state.enemyShots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; } + state.enemyShots = state.enemyShots.filter((s) => s.ttlMs > 0); +} + +function circleHit(ax, ay, ar, bx, by, br) { + const dx = tdelta(ax, bx); const dy = ay - by; + const r = ar + br; + return dx * dx + dy * dy <= r * r; +} + +function registerKill(state, enemy, events) { + const now = state.timeMs; + if (now - state.lastKillMs <= TUNE.COMBO_WINDOW_MS) { + state.multiplier = Math.min(TUNE.MULT_MAX, state.multiplier + 1); + } else { + state.multiplier = 1; + } + state.lastKillMs = now; + state.overdriveMeter = Math.min(1, state.overdriveMeter + TUNE.OVERDRIVE_FILL_PER_KILL); + if (state.overdriveMeter >= 1) events.push({ type: 'overdriveReady' }); + const base = TUNE.ENEMY_KILL_SCORE[enemy.type] ?? 50; + const mult = state.overdriveActive ? TUNE.OVERDRIVE_SCORE_MULT : state.multiplier; + state.score += base * mult; + events.push({ type: 'enemyKilled', enemyType: enemy.type, x: enemy.x, y: enemy.y, multiplier: state.multiplier }); +} + +function handleCollisions(state, events) { + const p = state.player; + const deadAbductorIds = new Set(); + + // player shots vs enemies + for (const s of state.shots) { + for (const e of state.enemies) { + if (e.dead || s.dead) continue; + if (circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, e.x, e.y, e.radius)) { + s.dead = true; + e.hp -= 1; + events.push({ type: 'enemyHit', id: e.id }); + if (e.hp <= 0) { + e.dead = true; + if (e.type === 'abductor') deadAbductorIds.add(e.id); + registerKill(state, e, events); + } + } + } + // player shots vs boss + if (state.boss && !s.dead && circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) { + s.dead = true; + state.boss.hp -= 1; + events.push({ type: 'enemyHit', boss: true }); + } + } + state.shots = state.shots.filter((s) => !s.dead); + releaseGrabbedHumanoids(state, deadAbductorIds, events); + state.enemies = state.enemies.filter((e) => !e.dead); + + // enemy bodies / enemy shots vs player + if (p.alive && p.invulnMs <= 0 && !state.overdriveActive) { + for (const e of state.enemies) { + if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, e.x, e.y, e.radius)) { killPlayer(state, events); break; } + } + if (p.alive) { + for (const s of state.enemyShots) { + if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, s.x, s.y, s.radius)) { s.dead = true; killPlayer(state, events); break; } + } + } + if (p.alive && state.boss && circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) { + killPlayer(state, events); + } + } + state.enemyShots = state.enemyShots.filter((s) => !s.dead); +} + +// --------------------------------------------------------------------------- +// Boss + +function spawnBoss(state, events) { + const spec = bossSpec(state.level); + state.boss = { + kind: spec.kind, secondaryKind: spec.secondaryKind, + hp: spec.hp, maxHp: spec.hp, + x: wrap(state.player.x + WORLD_W / 2), y: (Y_MIN + Y_GROUND) / 2, + dir: 1, attackCooldownMs: TUNE.BOSS_ATTACK_COOLDOWN_MS, phase: 1, + }; + events.push({ type: 'bossSpawn', kind: spec.kind }); +} + +function activeBossKind(boss) { + if (!boss.secondaryKind) return boss.kind; + return boss.hp > boss.maxHp / 2 ? boss.kind : boss.secondaryKind; +} + +function updateBoss(state, dt, events) { + const boss = state.boss; + if (!boss) return; + const dtS = dt / 1000; + const wasPhase = boss.phase; + boss.phase = boss.hp > boss.maxHp / 2 ? 1 : 2; + if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: activeBossKind(boss) }); + + const kind = activeBossKind(boss); + boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * dtS * (kind === 'crusher' ? 2.4 : 1)); + const dHome = tdelta(state.player.x - WORLD_W / 2, boss.x); // roam the far side of the ring + if (Math.abs(dHome) > WORLD_W * 0.3) boss.dir *= -1; + + boss.attackCooldownMs -= dt; + if (boss.attackCooldownMs <= 0 && state.player.alive) { + boss.attackCooldownMs = TUNE.BOSS_ATTACK_COOLDOWN_MS; + if (kind === 'sentinel' || kind === 'swarmlord') { + for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) { + const a = (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2; + state.enemyShots.push({ + x: boss.x, y: boss.y, vx: Math.cos(a) * TUNE.BOSS_SHOT_SPEED, vy: Math.sin(a) * TUNE.BOSS_SHOT_SPEED, + radius: 8, ttlMs: 2600, + }); + } + events.push({ type: 'shotFired', enemy: true, boss: true }); + } + if (kind === 'swarmlord') spawnSwarmerPack(state, 6); + } + + if (boss.hp <= 0) { + events.push({ type: 'bossDefeated', kind: boss.kind }); + state.score += TUNE.BOSS_KILL_SCORE; + state.boss = null; + } +} + +// --------------------------------------------------------------------------- +// Wave / level progression + +function trySpawnFromQueue(state, dt) { + if (!state.spawnQueue.length) return; + state.spawnTimerMs -= dt; + if (state.spawnTimerMs > 0) return; + state.spawnTimerMs = state.spawnIntervalMs; + const next = state.spawnQueue.shift(); + if (next.kind === 'swarmerPack') spawnSwarmerPack(state, next.count); + else if (next.kind === 'walker') spawnWalker(state); + else if (next.kind === 'abductor') spawnAbductor(state); +} + +function updatePhase(state, dt, events) { + state.phaseMs += dt; + switch (state.phase) { + case 'waveIntro': + if (state.phaseMs >= TUNE.WAVE_BREATHER_MS * 0.4) { + state.phase = 'wave'; + state.phaseMs = 0; + events.push({ type: 'waveStart', level: state.level, wave: state.wave }); + } + break; + case 'wave': + trySpawnFromQueue(state, dt); + if (!state.spawnQueue.length && state.enemies.length === 0) { + state.phase = 'waveClear'; + state.phaseMs = 0; + events.push({ type: 'waveClear', level: state.level, wave: state.wave }); + } + break; + case 'waveClear': + if (state.phaseMs >= TUNE.WAVE_BREATHER_MS) { + if (state.wave < TUNE.WAVES_PER_LEVEL) { + startWave(state, state.wave + 1); + } else { + state.phase = 'bossIntro'; + state.phaseMs = 0; + } + } + break; + case 'bossIntro': + if (state.phaseMs >= TUNE.BOSS_INTRO_MS) { + spawnBoss(state, events); + state.phase = 'boss'; + state.phaseMs = 0; + } + break; + case 'boss': + updateBoss(state, dt, events); + if (!state.boss) { + state.phase = 'levelComplete'; + state.phaseMs = 0; + const fullRescue = state.lostThisLevel === 0 && state.rescuedThisLevel === TUNE.HUMANOIDS_PER_LEVEL; + if (fullRescue) { + state.score += TUNE.FULL_RESCUE_BONUS; + state.lives += 1; + } + events.push({ + type: 'levelComplete', level: state.level, + rescued: state.rescuedThisLevel, lost: state.lostThisLevel, fullRescue, + }); + } + break; + case 'levelComplete': + if (state.phaseMs >= TUNE.BOSS_OUTRO_MS) { + if (state.level >= TUNE.LEVEL_COUNT) { + state.victory = true; + state.over = true; + state.phase = 'victory'; + events.push({ type: 'victory', score: state.score }); + } else { + state.level += 1; + state.rescuedThisLevel = 0; + state.lostThisLevel = 0; + state.humanoids = state.humanoids.filter((h) => h.status !== 'rescued' && h.status !== 'lost'); + spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL); + state.extractionZones = makeExtractionZones(state.rng); + startWave(state, 1); + } + } + break; + default: break; + } +} + +function updateOverdrive(state, dt, events) { + if (!state.overdriveActive) return; + state.overdriveMsLeft -= dt; + state.overdriveMeter = Math.max(0, state.overdriveMeter - dt / TUNE.OVERDRIVE_DURATION_MS); + if (state.overdriveMsLeft <= 0 || state.overdriveMeter <= 0) { + state.overdriveActive = false; + state.overdriveMeter = 0; + events.push({ type: 'overdriveEnd' }); + } +} + +function updateCombo(state) { + if (state.timeMs - state.lastKillMs > TUNE.COMBO_WINDOW_MS) state.multiplier = 1; +} + +// --------------------------------------------------------------------------- +// Master tick — one fixed STEP_MS of simulation. +function tick(state) { + const events = []; + if (state.over) return events; + const dt = STEP_MS * timescale(state); + state.timeMs += dt; + + updatePlayer(state, state.player.alive ? dt : STEP_MS, events); + updateSwarmers(state, dt); + updateWalkers(state, dt, events); + updateAbductors(state, dt, events); + updateHumanoids(state, dt, events); + updateShots(state, dt); + handleCollisions(state, events); + updatePhase(state, dt, events); + updateOverdrive(state, dt, events); + updateCombo(state); + + return events; +} + +export function step(state, deltaMs) { + const out = []; + state.accumulatorMs += deltaMs; + let n = 0; + while (state.accumulatorMs >= STEP_MS && n < MAX_STEPS) { + state.accumulatorMs -= STEP_MS; + const ev = tick(state); + for (let i = 0; i < ev.length; i += 1) out.push(ev[i]); + n += 1; + if (state.over) break; + } + if (n === MAX_STEPS && state.accumulatorMs >= STEP_MS) state.accumulatorMs = 0; // spiral-of-death guard + state.alpha = Math.min(1, state.accumulatorMs / STEP_MS); + return out; +} diff --git a/src/games/defender/DefenderVectorFont.js b/src/games/defender/DefenderVectorFont.js new file mode 100644 index 0000000..7440f89 --- /dev/null +++ b/src/games/defender/DefenderVectorFont.js @@ -0,0 +1,90 @@ +// A stroked vector font for Defender — letters and digits as line segments in +// a 4x6 unit cell, drawn as a glow-stroke pair to match the game's wireframe +// look (same convention as Tempest's TempestVectorFont.js, copied verbatim +// since the glyph set itself is generic). + +const GLYPH_W = 4; +const GLYPH_H = 6; +const GLYPH_GAP = 1.2; +const SPACE_W = 2.6; + +const GLYPHS = { + A: [[[0, 6], [2, 0], [4, 6]], [[1, 3.4], [3, 3.4]]], + B: [[[0, 0], [0, 6]], [[0, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [0, 2.8]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [0, 6]]], + C: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3.6, 5]]], + D: [[[0, 0], [2.2, 0], [4, 1.6], [4, 4.4], [2.2, 6], [0, 6], [0, 0]]], + E: [[[3.4, 0], [0, 0], [0, 6], [3.4, 6]], [[0, 3], [2.4, 3]]], + F: [[[3.4, 0], [0, 0], [0, 6]], [[0, 3], [2.4, 3]]], + G: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3, 6], [4, 4.6], [4, 3.2], [2.2, 3.2]]], + H: [[[0, 0], [0, 6]], [[4, 0], [4, 6]], [[0, 3], [4, 3]]], + I: [[[1, 0], [3, 0]], [[2, 0], [2, 6]], [[1, 6], [3, 6]]], + J: [[[4, 0], [4, 5], [3, 6], [1, 6], [0, 5]]], + K: [[[0, 0], [0, 6]], [[4, 0], [0, 3.2]], [[1.4, 2.2], [4, 6]]], + L: [[[0, 0], [0, 6], [3.4, 6]]], + M: [[[0, 6], [0, 0], [2, 2.6], [4, 0], [4, 6]]], + N: [[[0, 6], [0, 0], [4, 6], [4, 0]]], + O: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]], + P: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.6], [0, 2.6]]], + Q: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]], [[2.5, 4.4], [4, 6]]], + R: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.4], [0, 2.4]], [[1.6, 2.4], [4, 6]]], + S: [[[4, 1], [3, 0], [1, 0], [0, 1], [0, 2], [1, 2.8], [3, 3.2], [4, 4], [4, 5], [3, 6], [1, 6], [0, 5]]], + T: [[[0, 0], [4, 0]], [[2, 0], [2, 6]]], + U: [[[0, 0], [0, 5], [1, 6], [3, 6], [4, 5], [4, 0]]], + V: [[[0, 0], [2, 6], [4, 0]]], + W: [[[0, 0], [0.8, 6], [2, 3.2], [3.2, 6], [4, 0]]], + X: [[[0, 0], [4, 6]], [[4, 0], [0, 6]]], + Y: [[[0, 0], [2, 2.8], [4, 0]], [[2, 2.8], [2, 6]]], + Z: [[[0, 0], [4, 0], [0, 6], [4, 6]]], + 0: [[[1.2, 0], [2.8, 0], [4, 1.6], [4, 4.4], [2.8, 6], [1.2, 6], [0, 4.4], [0, 1.6], [1.2, 0]]], + 1: [[[1, 1], [2, 0], [2, 6]], [[1, 6], [3, 6]]], + 2: [[[0, 1], [1, 0], [3, 0], [4, 1], [4, 2.4], [0, 6], [4, 6]]], + 3: [[[0, 0], [4, 0], [2.4, 2.4], [4, 3.4], [4, 5], [3, 6], [1, 6], [0, 5]]], + 4: [[[3, 6], [3, 0], [0, 4], [4, 4]]], + 5: [[[4, 0], [0, 0], [0, 2.6], [3, 2.6], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5]]], + 6: [[[3.5, 0], [1, 0], [0, 1.5], [0, 5], [1, 6], [3, 6], [4, 5], [4, 3.6], [3, 2.6], [0, 2.6]]], + 7: [[[0, 0], [4, 0], [1.5, 6]]], + 8: [[[1, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [1, 2.8], [0, 2], [0, 1], [1, 0]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5], [0, 3.6], [1, 2.8]]], + 9: [[[0.5, 6], [3, 6], [4, 4.5], [4, 1], [3, 0], [1, 0], [0, 1], [0, 2.4], [1, 3.4], [4, 3.4]]], + '-': [[[0.8, 3], [3.2, 3]]], + '.': [[[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]], + ',': [[[2.2, 5.2], [2.2, 6], [1.6, 7]]], + ':': [[[1.8, 1.4], [2.2, 1.4], [2.2, 2], [1.8, 2], [1.8, 1.4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]], + '!': [[[2, 0], [2, 4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]], + '×': [[[0, 0], [4, 6]], [[4, 0], [0, 6]]], +}; + +function strokePoly(g, pts) { + g.beginPath(); + g.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i][0], pts[i][1]); + g.strokePath(); +} + +export function measureVectorText(text, scale) { + let w = 0; + for (const ch of text.toUpperCase()) { + w += (ch === ' ' ? SPACE_W : GLYPH_W + GLYPH_GAP) * scale; + } + return w - GLYPH_GAP * scale; +} + +export function drawVectorText(g, text, cx, cy, scale, color, options = {}) { + const { lineWidth = 3, glowWidth = 9, glowAlpha = 0.18, alpha = 1 } = options; + const totalW = measureVectorText(text, scale); + let x = cx - totalW / 2; + const y = cy - (GLYPH_H * scale) / 2; + for (const ch of text.toUpperCase()) { + if (ch === ' ') { x += SPACE_W * scale; continue; } + const strokes = GLYPHS[ch]; + if (strokes) { + for (const poly of strokes) { + const pts = poly.map(([ux, uy]) => [x + ux * scale, y + uy * scale]); + g.lineStyle(glowWidth, color, glowAlpha * alpha); + strokePoly(g, pts); + g.lineStyle(lineWidth, color, alpha); + strokePoly(g, pts); + } + } + x += (GLYPH_W + GLYPH_GAP) * scale; + } +} diff --git a/src/main.js b/src/main.js index 10d6f01..d7f98a5 100644 --- a/src/main.js +++ b/src/main.js @@ -110,6 +110,7 @@ import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js'; import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js'; import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js'; import TentsGame from './games/tents/TentsGame.js'; +import DefenderGame from './games/defender/DefenderGame.js'; const config = { type: Phaser.AUTO, @@ -233,6 +234,7 @@ const config = { WolfensteinEditor, PipePuzzleGame, TentsGame, + DefenderGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 8dc0478..0a95bc7 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game', defender: 'DefenderGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 0101209..17cc872 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -24,6 +24,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { totalannihilation: 'hacker', coloradodefense: 'arcadedark', tempest: 'arcadedark', + defender: 'arcadedark', mastermind: 'hacker', balatro: 'hacker', hexsweeper: 'hacker', diff --git a/tools/verifyDefender.js b/tools/verifyDefender.js new file mode 100644 index 0000000..edf5be9 --- /dev/null +++ b/tools/verifyDefender.js @@ -0,0 +1,376 @@ +// Headless verification for Defender. +// node tools/verifyDefender.js +// Exits non-zero on any failure. +// +// 1. Wraparound math (wrap/tdelta) round-trip and shortest-path correctness. +// 2. Boids seam correctness (neighbors across the wrap seam attract/repel +// as if adjacent, not as if worlds apart). +// 3. Rescue state machine — every transition in the humanoid lifecycle. +// 4. Overdrive meter thresholds and combo multiplier behavior. +// 5. No entity leaks across a long soak. +// 6. Boss defeat always precedes exactly one levelComplete, tally correct. +// 7. Spiral-of-death guard on a huge injected deltaMs. +// 8. Determinism — same seed + same input sequence replayed twice. +// 9. Monte-carlo bot soak across many seeds through all 5 levels. + +import { + WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE, + wrap, tdelta, tdist, createGame, setInput, step, +} from '../src/games/defender/DefenderLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures += 1; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +function runTicks(state, count, input = {}) { + setInput(state, input); + const events = []; + for (let i = 0; i < count; i += 1) events.push(...step(state, STEP_MS)); + return events; +} + +// --------------------------------------------------------------------------- +console.log('1. Wraparound math'); +{ + check('wrap() folds negative into range', wrap(-10) === WORLD_W - 10); + check('wrap() folds overflow into range', wrap(WORLD_W + 25) === 25); + check('wrap() is identity inside range', wrap(1234) === 1234); + check('tdelta shortest path across seam is small', Math.abs(tdelta(5, WORLD_W - 5)) === 10, + `got ${tdelta(5, WORLD_W - 5)}`); + check('tdelta sign points the short way', tdelta(5, WORLD_W - 5) < 0); + check('tdelta of equal points is 0', tdelta(500, 500) === 0); + check('tdelta magnitude never exceeds half the world', Math.abs(tdelta(0, WORLD_W / 2)) <= WORLD_W / 2 + 1e-9); +} + +// --------------------------------------------------------------------------- +console.log('2. Boids seam correctness'); +{ + const state = createGame({ seed: 1 }); + state.phase = 'wave'; + state.spawnQueue = []; + state.enemies = [ + { id: 901, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: 5, y: 400, vx: 0, vy: 0 }, + { id: 902, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: WORLD_W - 5, y: 400, vx: 0, vy: 0 }, + ]; + state.player.x = 3000; // far from both, out of seek range + const before = tdist(state.enemies[0].x, state.enemies[0].y, state.enemies[1].x, state.enemies[1].y); + runTicks(state, 30); + const [a, b] = state.enemies; + const after = tdist(a.x, a.y, b.x, b.y); + check('seam-adjacent swarmers perceive each other as close', before < 20, `raw seam gap ${before}`); + check('seam-adjacent swarmers stay bounded, not flung apart', after < 400, + `wrapped distance grew to ${after}`); + check('no NaN positions after seam interaction', Number.isFinite(a.x) && Number.isFinite(b.x)); +} + +// --------------------------------------------------------------------------- +console.log('3. Rescue state machine'); +{ + // grabbed -> lost (escaped past Y_SKY) + { + const state = createGame({ seed: 2 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + h.status = 'grabbed'; h.grabberId = 777; + state.enemies = [{ id: 777, type: 'abductor', hp: 2, radius: TUNE.ABDUCTOR_RADIUS, x: h.x, y: Y_SKY + 2, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }]; + const ev = runTicks(state, 5); + check('grabbed humanoid lost on reaching Y_SKY', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'escaped')); + } + // grabbed -> falling (carrying abductor dies) + { + const state = createGame({ seed: 3 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + h.status = 'grabbed'; h.grabberId = 778; h.x = 3000; h.y = 400; + state.enemies = [{ id: 778, type: 'abductor', hp: 1, radius: TUNE.ABDUCTOR_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }]; + state.shots = [{ x: 3000, y: 400, vx: 0, vy: 0, ttlMs: 500 }]; + const ev = runTicks(state, 1); + check('humanoid freed when its abductor dies', h.status === 'falling' && ev.some((e) => e.type === 'humanoidFreed')); + } + // falling -> lost (hits ground) + { + const state = createGame({ seed: 4 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + h.status = 'falling'; h.y = Y_GROUND - 2; h.vy = TUNE.FALL_SPEED; h.timerMs = 0; + state.player.x = wrap(h.x + 3000); // keep the player far away so it can't intercept + const ev = runTicks(state, 3); + check('falling humanoid lost on hitting ground', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'hitGround')); + } + // falling -> lost (grab window elapses, never reaches ground) + { + const state = createGame({ seed: 5 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + // Fall distance over a full GRAB_WINDOW_MS at the constant FALL_SPEED is + // ~1120px; start well above that so "hits ground" can't pre-empt this + // test of the "grab window elapses" path. + h.status = 'falling'; h.y = Y_GROUND - 1400; h.vy = TUNE.FALL_SPEED; h.timerMs = 0; + state.player.x = wrap(h.x + 3000); + const justBefore = Math.floor((TUNE.GRAB_WINDOW_MS - 3 * STEP_MS) / STEP_MS); + runTicks(state, justBefore); + const stillFalling = h.status === 'falling'; + const ev = runTicks(state, 10); + check('grab window not triggered early', stillFalling); + check('falling humanoid lost when grab window elapses', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'grabWindow')); + } + // falling -> carried (player proximity) + { + const state = createGame({ seed: 6 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + h.status = 'falling'; h.y = 400; h.vy = 0; h.timerMs = 0; + state.player.x = h.x; state.player.y = h.y; state.player.vx = 0; state.player.vy = 0; + const ev = runTicks(state, 1); + check('nearby falling humanoid is auto-picked-up', h.status === 'carried' && state.player.carrying === h.id + && ev.some((e) => e.type === 'humanoidPickedUp')); + } + // carried -> rescued (extraction zone) + { + const state = createGame({ seed: 7 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + const zoneX = state.extractionZones[0].x; + h.status = 'carried'; h.timerMs = 0; + state.player.carrying = h.id; state.player.x = zoneX; state.player.y = 400; state.player.vx = 0; state.player.vy = 0; + const ev = runTicks(state, 1); + check('carried humanoid rescued at extraction zone', h.status === 'rescued' && state.player.carrying === null + && state.rescuedThisLevel === 1 && ev.some((e) => e.type === 'humanoidRescued')); + } + // carried -> lost (timeout) + { + const state = createGame({ seed: 8 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + h.status = 'carried'; h.timerMs = 0; + state.player.carrying = h.id; + state.player.x = wrap(state.extractionZones[0].x + WORLD_W / 2); // far from every zone + state.player.y = 400; state.player.vx = 0; state.player.vy = 0; + const ticks = Math.ceil(TUNE.CARRY_TIMEOUT_MS / STEP_MS) + 2; + const ev = runTicks(state, ticks); + check('carried humanoid lost after carry timeout', h.status === 'lost' && state.player.carrying === null + && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'carryTimeout')); + } + // carried -> lost (player dies) + { + const state = createGame({ seed: 9 }); + state.phase = 'wave'; state.spawnQueue = []; + const h = state.humanoids[0]; + h.status = 'carried'; h.timerMs = 0; + state.player.carrying = h.id; state.player.invulnMs = 0; + state.player.x = 3000; state.player.y = 400; + state.enemies = [{ id: 555, type: 'walker', hp: 3, radius: TUNE.WALKER_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, homeX: 3000, dir: 1, fireCooldownMs: 9999 }]; + const ev = runTicks(state, 1); + check('carried humanoid lost when player dies', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'playerDied')); + check('player death event also fired', ev.some((e) => e.type === 'playerDied')); + } + // illegal transition: can't pick up a second humanoid while already carrying one + { + const state = createGame({ seed: 10 }); + state.phase = 'wave'; state.spawnQueue = []; + const [h1, h2] = state.humanoids; + h1.status = 'carried'; h1.timerMs = 0; + h2.status = 'falling'; h2.y = 400; h2.vy = 0; h2.timerMs = 0; h2.x = wrap(h1.x); + state.player.carrying = h1.id; + state.player.x = h2.x; state.player.y = 400; state.player.vx = 0; state.player.vy = 0; + runTicks(state, 1); + check('already-carrying player cannot pick up a second humanoid', state.player.carrying === h1.id && h2.status === 'falling'); + } +} + +// --------------------------------------------------------------------------- +console.log('4. Overdrive meter & combo multiplier'); +{ + const state = createGame({ seed: 11 }); + state.phase = 'wave'; state.spawnQueue = []; + let readyFired = 0; let startFired = 0; let endFired = 0; + let prevMeter = 0; let monotonicOnFill = true; + let killsToReady = 0; + while (state.overdriveMeter < 1 && killsToReady < 60) { + killsToReady += 1; + state.enemies = [{ id: 1000 + killsToReady, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state.player.x, y: state.player.y, vx: 0, vy: 0 }]; + state.shots = [{ x: state.player.x, y: state.player.y, vx: 0, vy: 0, ttlMs: 500 }]; + prevMeter = state.overdriveMeter; + const ev = runTicks(state, 1); + if (state.overdriveMeter < prevMeter) monotonicOnFill = false; + readyFired += ev.filter((e) => e.type === 'overdriveReady').length; + } + check('overdrive meter fills monotonically with kills', monotonicOnFill); + check('expected number of kills to fill the meter', killsToReady === Math.ceil(1 / TUNE.OVERDRIVE_FILL_PER_KILL), + `took ${killsToReady} kills`); + check('overdriveReady fires exactly once at the 1.0 crossing', readyFired === 1, `fired ${readyFired} times`); + check('multiplier escalated across the rapid kill chain', state.multiplier > 1, `multiplier=${state.multiplier}`); + + const startEv = runTicks(state, 1, { overdrive: true }); + startFired = startEv.filter((e) => e.type === 'overdriveStart').length; + check('overdriveStart fires exactly once on trigger', startFired === 1 && state.overdriveActive === true); + + const ticksForFullDrain = Math.ceil(TUNE.OVERDRIVE_DURATION_MS / (STEP_MS * TUNE.OVERDRIVE_TIMESCALE)) + 5; + const endEv = runTicks(state, ticksForFullDrain, { overdrive: false }); + endFired = endEv.filter((e) => e.type === 'overdriveEnd').length; + check('overdriveEnd fires exactly once after duration elapses', endFired === 1 && state.overdriveActive === false, + `fired ${endFired} times, active=${state.overdriveActive}`); + + // combo reset after a gap + const state2 = createGame({ seed: 12 }); + state2.phase = 'wave'; state2.spawnQueue = []; + state2.enemies = [{ id: 2001, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state2.player.x, y: state2.player.y, vx: 0, vy: 0 }]; + state2.shots = [{ x: state2.player.x, y: state2.player.y, vx: 0, vy: 0, ttlMs: 500 }]; + runTicks(state2, 1); + const multAfterOneKill = state2.multiplier; + runTicks(state2, Math.ceil((TUNE.COMBO_WINDOW_MS + 200) / STEP_MS)); + check('combo multiplier resets after the combo window elapses', multAfterOneKill >= 1 && state2.multiplier === 1, + `after=${state2.multiplier}`); +} + +// --------------------------------------------------------------------------- +console.log('5. No entity leaks / bounded arrays across a soak'); +{ + const state = createGame({ seed: 13 }); + let maxEnemies = 0; let maxShots = 0; let maxEnemyShots = 0; let maxHumanoids = 0; + for (let i = 0; i < 6000; i += 1) { + setInput(state, { right: i % 120 < 60, fire: true, overdrive: state.overdriveMeter >= 1 }); + step(state, STEP_MS); + maxEnemies = Math.max(maxEnemies, state.enemies.length); + maxShots = Math.max(maxShots, state.shots.length); + maxEnemyShots = Math.max(maxEnemyShots, state.enemyShots.length); + maxHumanoids = Math.max(maxHumanoids, state.humanoids.length); + if (state.over) break; + } + check('enemy count stays bounded', maxEnemies < 200, `max ${maxEnemies}`); + check('player shot count stays bounded', maxShots < 500, `max ${maxShots}`); + check('enemy shot count stays bounded', maxEnemyShots < 500, `max ${maxEnemyShots}`); + check('humanoid count stays bounded near per-level count', maxHumanoids <= TUNE.HUMANOIDS_PER_LEVEL + 1, `max ${maxHumanoids}`); +} + +// --------------------------------------------------------------------------- +console.log('6. Boss defeat -> exactly one levelComplete, tally correct'); +{ + const state = createGame({ seed: 14 }); + state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = []; + let ev = runTicks(state, 1); // spawns the boss + check('boss spawns from bossIntro', state.boss != null && ev.some((e) => e.type === 'bossSpawn')); + state.boss.hp = 1; + state.shots = [{ x: state.boss.x, y: state.boss.y, vx: 0, vy: 0, ttlMs: 500 }]; + ev = runTicks(state, 1); + const defeatIdx = ev.findIndex((e) => e.type === 'bossDefeated'); + const completeCount = ev.filter((e) => e.type === 'levelComplete').length; + check('bossDefeated fires', defeatIdx >= 0); + check('exactly one levelComplete follows boss defeat', completeCount === 1, `got ${completeCount}`); + const complete = ev.find((e) => e.type === 'levelComplete'); + check('levelComplete tally matches rescued/lost counts', complete + && complete.rescued === state.rescuedThisLevel && complete.lost === state.lostThisLevel); +} + +// --------------------------------------------------------------------------- +console.log('7. Spiral-of-death guard'); +{ + const state = createGame({ seed: 15 }); + const before = state.timeMs; + step(state, 5000); // a huge delta, e.g. a backgrounded tab waking up + const advanced = state.timeMs - before; + check('a huge delta only advances MAX_STEPS worth of sim time', + advanced <= MAX_STEPS * STEP_MS + 1e-6, `advanced ${advanced}ms`); + check('leftover accumulator is discarded rather than replayed', state.accumulatorMs === 0, + `accumulatorMs=${state.accumulatorMs}`); +} + +// --------------------------------------------------------------------------- +console.log('8. Determinism'); +{ + function scriptedInputAt(i) { + const p = i % 240; + return { + left: p < 40, right: p >= 40 && p < 90, up: p >= 90 && p < 110, down: p >= 150 && p < 170, + fire: true, overdrive: p === 200, + }; + } + function replay(seed, ticks) { + const s = createGame({ seed }); + const log = []; + for (let i = 0; i < ticks; i += 1) { + setInput(s, scriptedInputAt(i)); + log.push(...step(s, STEP_MS)); + } + return { s, log }; + } + const a = replay(42, 3000); + const b = replay(42, 3000); + const same = JSON.stringify(a.log) === JSON.stringify(b.log); + check('same seed + same inputs produce identical event streams', same); + check('same seed + same inputs produce identical final score', a.s.score === b.s.score, `${a.s.score} vs ${b.s.score}`); + check('same seed + same inputs produce identical final state shape', + JSON.stringify(a.s) === JSON.stringify(b.s)); +} + +// --------------------------------------------------------------------------- +console.log('9. Monte-carlo bot soak (all 5 levels)'); +{ + function nearestEnemyX(state) { + let best = null; let bestD = Infinity; + for (const e of state.enemies) { + const d = Math.abs(tdelta(state.player.x, e.x)); + if (d < bestD) { bestD = d; best = e; } + } + return best; + } + + function botInput(state) { + const p = state.player; + let targetX = p.x; let targetY = (Y_GROUND + 400) / 2; + if (p.carrying != null) { + const zone = state.extractionZones[0]; + targetX = zone.x; targetY = 400; + } else { + const falling = state.humanoids.find((h) => h.status === 'falling'); + if (falling) { targetX = falling.x; targetY = falling.y; } + else { + const e = nearestEnemyX(state); + if (e) { targetX = e.x; targetY = e.y; } + } + } + const dx = tdelta(p.x, targetX); + const dy = targetY - p.y; + return { + left: dx < -8, right: dx > 8, up: dy < -8, down: dy > 8, + fire: true, overdrive: state.overdriveMeter >= 1, + }; + } + + let seedsRun = 0; let victories = 0; let gameOvers = 0; + const SEEDS = 8; + const MAX_TICKS = 200000; // generous safety valve; a healthy sim finishes well inside this + for (let seed = 1; seed <= SEEDS; seed += 1) { + const state = createGame({ seed: seed * 1000 + 7 }); + let ticks = 0; + let invariantsOk = true; + while (!state.over && ticks < MAX_TICKS) { + setInput(state, botInput(state)); + step(state, STEP_MS); + ticks += 1; + if (!Number.isFinite(state.player.x) || !Number.isFinite(state.player.y)) invariantsOk = false; + if (state.level < 1 || state.level > TUNE.LEVEL_COUNT) invariantsOk = false; + if (state.multiplier < 1 || state.multiplier > TUNE.MULT_MAX) invariantsOk = false; + if (state.overdriveMeter < 0 || state.overdriveMeter > 1 + 1e-9) invariantsOk = false; + if (!invariantsOk) break; + } + seedsRun += 1; + check(`seed ${seed}: invariants held every tick`, invariantsOk); + check(`seed ${seed}: run terminated (won or lost) within budget`, state.over, `stopped at ${ticks} ticks, phase=${state.phase}`); + if (state.victory) victories += 1; + if (state.over && !state.victory) gameOvers += 1; + } + check('every seed in the soak terminated', seedsRun === SEEDS); + console.log(` info: ${victories}/${SEEDS} bot runs reached victory, ${gameOvers}/${SEEDS} ended in game over`); +} + +// --------------------------------------------------------------------------- +if (failures > 0) { + console.error(`\n${failures} check(s) FAILED`); + process.exit(1); +} else { + console.log('\nAll checks passed.'); +} From 03d35b0b0044e180582d1f0bcf42dd18a6a83fdb Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 6 Sep 2026 15:54:20 -0600 Subject: [PATCH 2/3] Overhaul boss encounters into distinct escalating profiles per level - Replace flat kind/secondaryKind model with named BOSS_PROFILES that each carry their own move list, speed multiplier, and attack cooldown, so later fights are harder along every axis at once. - Add six new attack patterns (ring, spread, spiral, aimed, wall, reinforce) dispatched via round-robin; bosses under half health can swap to a harder phase2Moves set. - Surface the boss name in the WARNING banner and extend its display time. - Extend verifyDefender.js with check 10: asserts HP/cooldown monotonicity, valid move names, dual-phase presence, and live-simulates each level's boss to confirm declared move ordering and phase-2 takeover. --- src/games/defender/DefenderGame.js | 2 +- src/games/defender/DefenderLogic.js | 125 +++++++++++++++++++++------- tools/verifyDefender.js | 61 ++++++++++++++ 3 files changed, 159 insertions(+), 29 deletions(-) diff --git a/src/games/defender/DefenderGame.js b/src/games/defender/DefenderGame.js index 4b75ba4..a73f857 100644 --- a/src/games/defender/DefenderGame.js +++ b/src/games/defender/DefenderGame.js @@ -289,7 +289,7 @@ export default class DefenderGame extends Phaser.Scene { break; case 'bossSpawn': playSound(this, SFX.SCIFI_REVEAL); - this.showBanner('WARNING', 0xff4040, 1800); + this.showBanner(`WARNING: ${e.kind}`, 0xff4040, 2000); break; case 'bossPhaseChange': playSound(this, SFX.EIGHTBIT_EXPLODE_2); diff --git a/src/games/defender/DefenderLogic.js b/src/games/defender/DefenderLogic.js index 250cb13..59094b7 100644 --- a/src/games/defender/DefenderLogic.js +++ b/src/games/defender/DefenderLogic.js @@ -168,13 +168,40 @@ export function waveSpec(level, wave) { }; } -const BOSS_KINDS = ['sentinel', 'crusher', 'swarmlord']; +// One distinct, escalating boss encounter per level — not just more HP, but a +// genuinely different attack repertoire each time. `moves` is round-robined +// every attack (so a fight never just repeats one pattern), `phase2Moves` +// (if present) takes over once the boss drops below half health, and +// `speedMult`/`cooldownMs` layer movement and attack-rate pressure on top so +// later fights are harder along every axis at once, not just bullet variety. +export const BOSS_PROFILES = [ + { // Level 1 — Sentinel: a single steady ring burst. The introduction. + name: 'SENTINEL', moves: ['ring'], speedMult: 1, cooldownMs: 1700, + }, + { // Level 2 — Ravager: rams hard and sprays a forward shotgun spread. + name: 'RAVAGER', moves: ['spread'], speedMult: 1.8, cooldownMs: 1450, + }, + { // Level 3 — Swarmlord: a rotating bullet spiral plus reinforcement waves — + // now you're managing adds and dodging a moving pattern at the same time. + name: 'SWARMLORD', moves: ['spiral', 'reinforce'], speedMult: 1.2, cooldownMs: 1300, + }, + { // Level 4 — Warden: alternates area-denial and player-tracking fire; past + // half health it adds the spiral and reinforcements too. First two-phase fight. + name: 'WARDEN', moves: ['ring', 'aimed'], phase2Moves: ['spiral', 'aimed', 'reinforce'], + speedMult: 1.4, cooldownMs: 1150, + }, + { // Level 5 — Overlord: the full arsenal from every earlier fight, fastest + // base cooldown, and a dense bullet-wall move once it's wounded. + name: 'OVERLORD', moves: ['ring', 'spread', 'aimed'], phase2Moves: ['spiral', 'wall', 'aimed', 'reinforce'], + speedMult: 1.6, cooldownMs: 950, + }, +]; + +export function bossProfileFor(level) { return BOSS_PROFILES[(level - 1) % BOSS_PROFILES.length]; } + export function bossSpec(level) { - const kind = BOSS_KINDS[(level - 1) % BOSS_KINDS.length]; - const dual = level >= 4; - const secondaryKind = dual ? BOSS_KINDS[level % BOSS_KINDS.length] : null; return { - kind, secondaryKind, + name: bossProfileFor(level).name, hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP, }; } @@ -623,50 +650,92 @@ function handleCollisions(state, events) { function spawnBoss(state, events) { const spec = bossSpec(state.level); state.boss = { - kind: spec.kind, secondaryKind: spec.secondaryKind, - hp: spec.hp, maxHp: spec.hp, + name: spec.name, hp: spec.hp, maxHp: spec.hp, x: wrap(state.player.x + WORLD_W / 2), y: (Y_MIN + Y_GROUND) / 2, - dir: 1, attackCooldownMs: TUNE.BOSS_ATTACK_COOLDOWN_MS, phase: 1, + dir: 1, attackCooldownMs: bossProfileFor(state.level).cooldownMs, phase: 1, + moveIdx: 0, spiralAngle: 0, }; - events.push({ type: 'bossSpawn', kind: spec.kind }); + events.push({ type: 'bossSpawn', kind: spec.name }); } -function activeBossKind(boss) { - if (!boss.secondaryKind) return boss.kind; - return boss.hp > boss.maxHp / 2 ? boss.kind : boss.secondaryKind; +function fireBossShot(state, boss, angle, speed) { + state.enemyShots.push({ + x: boss.x, y: boss.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, + radius: 8, ttlMs: 2600, + }); +} + +// Every attack pattern a boss can draw from. Each is a genuinely different +// shape/behavior (not a recolor of another), so "harder boss" means "new +// things to read and dodge", not just "more of the same bullets". +function performBossMove(state, boss, move, events) { + const p = state.player; + switch (move) { + case 'ring': // static radial burst — the baseline area-denial pattern + for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) { + fireBossShot(state, boss, (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED); + } + events.push({ type: 'shotFired', enemy: true, boss: true }); + break; + case 'spread': { // a forward shotgun cone toward the player's general side + const base = tdelta(boss.x, p.x) >= 0 ? 0 : Math.PI; + for (const off of [-0.5, -0.25, 0, 0.25, 0.5]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.1); + events.push({ type: 'shotFired', enemy: true, boss: true }); + break; + } + case 'spiral': // three arms that rotate a bit further each time this move fires + for (const off of [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]) { + fireBossShot(state, boss, boss.spiralAngle + off, TUNE.BOSS_SHOT_SPEED * 0.85); + } + boss.spiralAngle += 0.5; + events.push({ type: 'shotFired', enemy: true, boss: true }); + break; + case 'aimed': { // tracks the player directly — punishes standing still + const base = Math.atan2(p.y - boss.y, tdelta(boss.x, p.x)); + for (const off of [-0.12, 0, 0.12]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.3); + events.push({ type: 'shotFired', enemy: true, boss: true }); + break; + } + case 'wall': { // a dense ring, twice the density of 'ring' — find the gap + const count = TUNE.BOSS_SPOKE_COUNT * 2; + for (let i = 0; i < count; i += 1) fireBossShot(state, boss, (i / count) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED * 0.9); + events.push({ type: 'shotFired', enemy: true, boss: true }); + break; + } + case 'reinforce': // calls in a swarmer pack — now you're managing adds too + spawnSwarmerPack(state, 6); + break; + default: break; + } } function updateBoss(state, dt, events) { const boss = state.boss; if (!boss) return; const dtS = dt / 1000; + const profile = bossProfileFor(state.level); const wasPhase = boss.phase; boss.phase = boss.hp > boss.maxHp / 2 ? 1 : 2; - if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: activeBossKind(boss) }); + if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: boss.name }); - const kind = activeBossKind(boss); - boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * dtS * (kind === 'crusher' ? 2.4 : 1)); + const moves = (boss.phase === 2 && profile.phase2Moves) ? profile.phase2Moves : profile.moves; + + boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * profile.speedMult * dtS); const dHome = tdelta(state.player.x - WORLD_W / 2, boss.x); // roam the far side of the ring if (Math.abs(dHome) > WORLD_W * 0.3) boss.dir *= -1; boss.attackCooldownMs -= dt; if (boss.attackCooldownMs <= 0 && state.player.alive) { - boss.attackCooldownMs = TUNE.BOSS_ATTACK_COOLDOWN_MS; - if (kind === 'sentinel' || kind === 'swarmlord') { - for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) { - const a = (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2; - state.enemyShots.push({ - x: boss.x, y: boss.y, vx: Math.cos(a) * TUNE.BOSS_SHOT_SPEED, vy: Math.sin(a) * TUNE.BOSS_SHOT_SPEED, - radius: 8, ttlMs: 2600, - }); - } - events.push({ type: 'shotFired', enemy: true, boss: true }); - } - if (kind === 'swarmlord') spawnSwarmerPack(state, 6); + // Phase 2 also attacks a little faster, on top of whatever new moves it unlocked. + boss.attackCooldownMs = profile.cooldownMs * (boss.phase === 2 ? 0.8 : 1); + const move = moves[boss.moveIdx % moves.length]; + boss.moveIdx += 1; + events.push({ type: 'bossMove', move, phase: boss.phase }); + performBossMove(state, boss, move, events); } if (boss.hp <= 0) { - events.push({ type: 'bossDefeated', kind: boss.kind }); + events.push({ type: 'bossDefeated', kind: boss.name }); state.score += TUNE.BOSS_KILL_SCORE; state.boss = null; } diff --git a/tools/verifyDefender.js b/tools/verifyDefender.js index edf5be9..3f02480 100644 --- a/tools/verifyDefender.js +++ b/tools/verifyDefender.js @@ -12,10 +12,12 @@ // 7. Spiral-of-death guard on a huge injected deltaMs. // 8. Determinism — same seed + same input sequence replayed twice. // 9. Monte-carlo bot soak across many seeds through all 5 levels. +// 10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety. import { WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE, wrap, tdelta, tdist, createGame, setInput, step, + BOSS_PROFILES, bossSpec, } from '../src/games/defender/DefenderLogic.js'; let failures = 0; @@ -367,6 +369,65 @@ console.log('9. Monte-carlo bot soak (all 5 levels)'); console.log(` info: ${victories}/${SEEDS} bot runs reached victory, ${gameOvers}/${SEEDS} ended in game over`); } +// --------------------------------------------------------------------------- +console.log('10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety'); +{ + const VALID_MOVES = new Set(['ring', 'spread', 'spiral', 'aimed', 'wall', 'reinforce']); + check('one boss profile authored per level', BOSS_PROFILES.length === TUNE.LEVEL_COUNT, + `${BOSS_PROFILES.length} profiles vs ${TUNE.LEVEL_COUNT} levels`); + + let prevHp = -Infinity; let prevCooldown = Infinity; + let hpMonotonic = true; let cooldownMonotonic = true; let allMovesValid = true; let anyDualPhase = false; + for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) { + const spec = bossSpec(level); + const profile = BOSS_PROFILES[level - 1]; + if (spec.hp <= prevHp) hpMonotonic = false; + prevHp = spec.hp; + if (profile.cooldownMs >= prevCooldown) cooldownMonotonic = false; + prevCooldown = profile.cooldownMs; + for (const m of profile.moves) if (!VALID_MOVES.has(m)) allMovesValid = false; + if (profile.phase2Moves) { + anyDualPhase = true; + for (const m of profile.phase2Moves) if (!VALID_MOVES.has(m)) allMovesValid = false; + } + } + check('boss HP strictly increases level over level', hpMonotonic); + check('boss attack cooldown strictly shortens level over level (attacks come faster)', cooldownMonotonic); + check('every authored move name is a recognized attack pattern', allMovesValid); + check('later levels introduce a second, harder attack phase', anyDualPhase); + + // Live-sim: spawn each level's boss directly and confirm its phase-1 moves cycle + // in the declared round-robin order, and phase-2 moves take over once wounded. + for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) { + const profile = BOSS_PROFILES[level - 1]; + const state = createGame({ seed: 900 + level, startLevel: level }); + state.player.invulnMs = 999999; // isolate attack-pattern dispatch from collision outcomes + state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = []; + runTicks(state, 1); // spawns the boss + + const ticksPerCycle = Math.ceil(profile.cooldownMs / STEP_MS) + 2; + const observed = []; + for (let i = 0; i < profile.moves.length; i += 1) { + const ev = runTicks(state, ticksPerCycle); + const mv = ev.find((e) => e.type === 'bossMove'); + if (mv) observed.push(mv.move); + } + const matches = observed.length === profile.moves.length && observed.every((m, i) => m === profile.moves[i]); + check(`level ${level} (${profile.name}) phase-1 moves cycle in declared order`, + matches, `expected ${JSON.stringify(profile.moves)}, observed ${JSON.stringify(observed)}`); + + if (profile.phase2Moves) { + state.boss.hp = 1; // force the phase-2 threshold on the next tick + const ev2 = runTicks(state, ticksPerCycle); + const phaseEv = ev2.find((e) => e.type === 'bossPhaseChange'); + const mv2 = ev2.find((e) => e.type === 'bossMove'); + check(`level ${level} (${profile.name}) drops into phase 2 when wounded`, !!phaseEv && phaseEv.phase === 2); + check(`level ${level} (${profile.name}) phase-2 move comes from its harder move set`, + !!mv2 && profile.phase2Moves.includes(mv2.move), `got ${mv2 && mv2.move}`); + } + } +} + // --------------------------------------------------------------------------- if (failures > 0) { console.error(`\n${failures} check(s) FAILED`); From ed7620ee7be402e996dfc47b384213967a333089 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 6 Sep 2026 17:44:19 -0600 Subject: [PATCH 3/3] Replace chevron player ship with detailed asymmetric interceptor silhoue - Introduce SHIP_HULL polygon and ENGINE_COLOR for a sharper, cyberpunk-style ship - Swap strokeChevron for drawShip, adding fuselage spine, canted canopy, dorsal-fin greebles, and a speed-reactive neon engine flare - Update lives readout to render mini ship glyphs instead of chevrons --- src/games/defender/DefenderGame.js | 75 ++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 9 deletions(-) diff --git a/src/games/defender/DefenderGame.js b/src/games/defender/DefenderGame.js index a73f857..c9989d3 100644 --- a/src/games/defender/DefenderGame.js +++ b/src/games/defender/DefenderGame.js @@ -44,6 +44,20 @@ const ALERT_PINK = 0xff17c4; // the red enemy shots, so incoming vs. outgoing fire is unmistakable at a glance. const PLAYER_SHOT_COLOR = 0xffe135; +// Fixed neon engine-glow accent for the player ship — independent of the level +// palette, like PLAYER_SHOT_COLOR, so the thruster always reads as "engine". +const ENGINE_COLOR = 0x00e5ff; + +// Player ship silhouette, in unit space (nose at +x; mirrored by `facing`, +// scaled by radius) — an asymmetric interceptor: a long swept dorsal fin +// against a short ventral one, with concave "blade" notches at both wing +// roots and a spiked tail, for a sharper silhouette than a plain dart. +const SHIP_HULL = [ + [1.35, 0], [0.75, -0.16], [0.30, -0.40], [-0.10, -0.30], [-1.15, -0.92], + [-0.50, -0.26], [-0.80, -0.14], [-1.05, 0], [-0.80, 0.14], [-0.50, 0.26], + [-0.95, 0.68], [-0.10, 0.30], [0.30, 0.40], [0.75, 0.16], +]; + // Mini-map panel — a squashed side-view of the whole wrapped world, tucked // under MusicPlayer's buttons + track-name readout (src/ui/MusicPlayer.js: // PAD=12, BTN=32 buttons at y=12-44, track info text at y=58, so the music @@ -213,10 +227,10 @@ export default class DefenderGame extends Phaser.Scene { g.clear(); const pal = paletteFor(sim.level); - // Lives as little chevron glyphs under the score. + // Lives as tiny ship glyphs under the score, matching the real thing. for (let i = 0; i < Math.min(sim.lives, 8); i += 1) { - const x = 46 + i * 34; const y = 108; - this.strokeChevron(g, x, y, 12, 1, pal.accent, [[5, 0.18], [2, 1]]); + const x = 50 + i * 38; const y = 108; + this.drawShip(g, x, y, 12, 1, pal.accent, ENGINE_COLOR, 0, [[5, 0.18], [2, 1]]); } // Overdrive meter bar. @@ -339,17 +353,58 @@ export default class DefenderGame extends Phaser.Scene { this.camX = wrap(this.camX + d * 0.14); } - strokeChevron(g, x, y, r, facing, color, passes) { + // A sleek, asymmetric interceptor silhouette in place of the old plain dart: + // a long swept dorsal fin against a shorter ventral one, concave "blade" cuts + // at each wing root, a canted canopy, and a speed-reactive engine flare — + // aiming for sharp/edgy cyberpunk rather than a generic arcade ship. + drawShip(g, x, y, r, facing, color, engineColor, thrust, passes) { + const P = (ux, uy) => ({ x: x + facing * ux * r, y: y + uy * r }); + const hull = SHIP_HULL.map(([ux, uy]) => P(ux, uy)); + for (const [lw, a] of passes) { g.lineStyle(lw, color, a); g.beginPath(); - g.moveTo(x - facing * r * 0.8, y - r * 0.7); - g.lineTo(x + facing * r, y); - g.lineTo(x - facing * r * 0.8, y + r * 0.7); - g.lineTo(x - facing * r * 0.3, y); + hull.forEach((p, i) => { if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); }); g.closePath(); g.strokePath(); } + + // Fuselage spine. + const spineA = P(0.9, 0); const spineB = P(-0.8, 0); + g.lineStyle(passes[passes.length - 1][0] * 0.7, color, 0.3); + g.lineBetween(spineA.x, spineA.y, spineB.x, spineB.y); + + // Canted canopy — a filled, glassy diamond just aft of the nose. + const canopy = [[0.63, 0], [0.45, -0.11], [0.27, 0], [0.45, 0.11]].map(([ux, uy]) => P(ux, uy)); + g.beginPath(); + canopy.forEach((p, i) => { if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); }); + g.closePath(); + g.fillStyle(color, 0.3); + g.fillPath(); + g.lineStyle(1.6, 0xffffff, 0.85); + g.strokePath(); + + // Two greeble ticks along the long dorsal fin for mechanical detail. + const wingA = P(-0.1, -0.3); const wingB = P(-1.15, -0.92); + const wdx = wingB.x - wingA.x; const wdy = wingB.y - wingA.y; + const wlen = Math.hypot(wdx, wdy) || 1; + const nx = -wdy / wlen; const ny = wdx / wlen; + for (const t of [0.35, 0.7]) { + const mx = wingA.x + wdx * t; const my = wingA.y + wdy * t; + g.lineStyle(1.4, color, 0.6); + g.lineBetween(mx - nx * r * 0.12, my - ny * r * 0.12, mx + nx * r * 0.12, my + ny * r * 0.12); + } + + // Engine flare — a fixed neon accent (not the level palette) so the + // thruster always reads as "engine", and it stretches with current speed. + const tail = P(-1.05, 0); + const flareLen = r * (0.5 + thrust * 1.5); + g.lineStyle(7, engineColor, 0.16); + g.lineBetween(tail.x, tail.y, tail.x - facing * flareLen, tail.y); + g.lineStyle(2.4, engineColor, 0.9); + g.lineBetween(tail.x, tail.y, tail.x - facing * flareLen * 0.65, tail.y); + g.fillStyle(engineColor, 0.95); + g.fillCircle(tail.x, tail.y, r * 0.14); } strokeNgon(g, x, y, r, sides, rot = 0) { @@ -509,7 +564,9 @@ export default class DefenderGame extends Phaser.Scene { if (p.alive && (Math.floor(p.invulnMs / 90) % 2 === 0 || p.invulnMs <= 0)) { const sx = this.screenX(p.x); const color = this.sim.overdriveActive ? 0xffffff : pal.accent; - this.strokeChevron(g, sx, p.y, TUNE.PLAYER_RADIUS, p.facing, color, [[6, 0.2], [2.2, 1]]); + const engineColor = this.sim.overdriveActive ? 0xffffff : ENGINE_COLOR; + const thrust = Math.min(1, Math.hypot(p.vx, p.vy) / TUNE.PLAYER_MAX_SPEED_X); + this.drawShip(g, sx, p.y, TUNE.PLAYER_RADIUS, p.facing, color, engineColor, thrust, [[6, 0.2], [2.2, 1]]); if (p.carrying != null) { // (drawn in drawHumanoids since the humanoid entity itself already // tracks the carry offset each tick)