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
This commit is contained in:
Brian Fertig 2026-09-06 15:14:42 -06:00
parent 71a919e0cb
commit 79fb74d9b2
10 changed files with 2139 additions and 1 deletions

View File

@ -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.

View File

@ -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 });

View File

@ -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;
}
}

View File

@ -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]);
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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,
],
};

View File

@ -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 = {

View File

@ -24,6 +24,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
totalannihilation: 'hacker',
coloradodefense: 'arcadedark',
tempest: 'arcadedark',
defender: 'arcadedark',
mastermind: 'hacker',
balatro: 'hacker',
hexsweeper: 'hacker',

376
tools/verifyDefender.js Normal file
View File

@ -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.');
}