Compare commits

..

No commits in common. "main" and "Game-Polish" have entirely different histories.

20 changed files with 2 additions and 6078 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

View File

@ -483,17 +483,6 @@ export const MANIFEST = {
// so its audio only downloads once Tempest is actually entered. // so its audio only downloads once Tempest is actually entered.
(scene) => musicFrom(scene, 'arcadedark-music'), (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'),
],
geometrywars: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Geometry Wars is actually entered.
// Everything else in the game is drawn procedurally; no image assets.
(scene) => musicFrom(scene, 'arcadedark-music'),
],
mastermind: [ mastermind: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mastermind is actually entered. // its audio only downloads once Mastermind is actually entered.

View File

@ -124,5 +124,3 @@ 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: '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: '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: '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 });
registerGame({ slug: 'geometrywars', name: 'Geometry Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 98 });

View File

@ -1,125 +0,0 @@
// 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

@ -1,782 +0,0 @@
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;
// 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
// 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 tiny ship glyphs under the score, matching the real thing.
for (let i = 0; i < Math.min(sim.lives, 8); i += 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.
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: ${e.kind}`, 0xff4040, 2000);
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);
}
// 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();
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) {
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;
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)
}
}
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

@ -1,882 +0,0 @@
// 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),
};
}
// 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) {
return {
name: bossProfileFor(level).name,
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 = {
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: bossProfileFor(state.level).cooldownMs, phase: 1,
moveIdx: 0, spiralAngle: 0,
};
events.push({ type: 'bossSpawn', kind: spec.name });
}
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: boss.name });
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) {
// 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.name });
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

@ -1,90 +0,0 @@
// 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

@ -1,91 +0,0 @@
// Additive neon bloom for Geometry Wars' world camera.
//
// Phaser's built-in Bloom FX finishes with a 50/50 linear mix of the sharp
// frame and the blurred one, which halves the brightness of every thin line
// — exactly the wrong trade for a game made of 2px neon strokes. This is the
// same ping-pong Gaussian blur (same onDraw shape as Phaser's
// BloomFXPipeline) but composited with blendFramesAdditive, so cores stay at
// full brightness and the glow is added on top.
//
// Canvas renderer gets a no-op stub. PostFXPipeline convention follows
// src/ui/ArcadeCurvePipeline.js.
import * as Phaser from 'phaser';
const FRAG = `
precision mediump float;
uniform sampler2D uMainSampler;
uniform vec2 offset;
varying vec2 outTexCoord;
void main(void) {
vec4 sum = texture2D(uMainSampler, outTexCoord) * 0.2270270270;
sum += texture2D(uMainSampler, outTexCoord + offset * 1.3846153846) * 0.3162162162;
sum += texture2D(uMainSampler, outTexCoord - offset * 1.3846153846) * 0.3162162162;
sum += texture2D(uMainSampler, outTexCoord + offset * 3.2307692308) * 0.0702702703;
sum += texture2D(uMainSampler, outTexCoord - offset * 3.2307692308) * 0.0702702703;
gl_FragColor = sum;
}
`;
// Tuning knobs — radius is in ~2px units per blur tap, strength is how much
// of the blurred frame gets added back on top.
export const GLOW_LEVELS = {
off: null,
low: { radius: 1.2, strength: 0.75, steps: 3 },
high: { radius: 1.8, strength: 1.2, steps: 4 },
};
export class NeonBloomPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
constructor(game) {
super({ game, name: 'GWNeonBloom', fragShader: FRAG });
this.radius = GLOW_LEVELS.high.radius;
this.strength = GLOW_LEVELS.high.strength;
this.steps = GLOW_LEVELS.high.steps;
}
onDraw(target1) {
const target2 = this.fullFrame1;
const target3 = this.fullFrame2;
this.copyFrame(target1, target3);
const x = (2 / target1.width) * this.radius;
const y = (2 / target1.height) * this.radius;
// set2f only rebinds this pipeline's program when the value changes;
// alternating (x,0)/(0,y) guarantees it does after copyFrame bound the
// utility shader (same reliance as Phaser's own BloomFXPipeline).
for (let i = 0; i < this.steps; i += 1) {
this.set2f('offset', x, 0);
this.copySprite(target1, target2);
this.set2f('offset', 0, y);
this.copySprite(target2, target1);
}
this.blendFramesAdditive(target3, target1, target2, this.strength);
this.copyToGame(target2);
}
}
export function attachNeonBloom(scene, camera, level = 'high') {
if (!scene.renderer || scene.renderer.type !== Phaser.WEBGL) {
return { setLevel() {}, destroy() {} };
}
scene.renderer.pipelines.addPostPipeline('GWNeonBloom', NeonBloomPipeline);
let attached = false;
const setLevel = (lvl) => {
const cfg = GLOW_LEVELS[lvl] ?? null;
if (!cfg) {
if (attached) { camera.removePostPipeline(NeonBloomPipeline); attached = false; }
return;
}
if (!attached) { camera.setPostPipeline(NeonBloomPipeline); attached = true; }
const got = camera.getPostPipeline(NeonBloomPipeline);
const inst = Array.isArray(got) ? got[0] : got;
if (inst) Object.assign(inst, cfg);
};
setLevel(level);
return {
setLevel,
destroy() {
if (attached) { try { camera.removePostPipeline(NeonBloomPipeline); } catch (_) { /* camera gone */ } }
attached = false;
},
};
}

View File

@ -1,182 +0,0 @@
// Particle field + shockwave rings for Geometry Wars.
//
// Particles are a fixed-size struct-of-arrays pool (oldest slot reused when
// full) drawn as velocity-aligned streaks — the look of a Geometry Wars
// explosion is thousands of short neon dashes slowing down, bouncing off the
// arena walls and getting sucked into black holes. Drawing is batched by
// (colour, alpha band, width) so a full screen costs a few dozen
// lineStyle switches rather than one per particle.
//
// Phaser-free: draw() only calls Graphics methods on the object it is given.
export const FX_MAX = 2800;
const ALPHA_BANDS = 4;
const WIDTHS = [2, 3.4];
export default class ParticleField {
constructor(bounds) {
this.bounds = bounds; // { w, h }
const n = FX_MAX;
this.x = new Float32Array(n); this.y = new Float32Array(n);
this.vx = new Float32Array(n); this.vy = new Float32Array(n);
this.life = new Float32Array(n); this.maxLife = new Float32Array(n);
this.len = new Float32Array(n); this.damp = new Float32Array(n);
this.col = new Uint8Array(n); this.wid = new Uint8Array(n);
this.cursor = 0;
this.palette = [];
this.paletteIndex = new Map();
this.rings = [];
this.buckets = new Map();
}
colorId(color) {
let id = this.paletteIndex.get(color);
if (id === undefined) {
id = this.palette.length;
if (id >= 255) return 0;
this.palette.push(color);
this.paletteIndex.set(color, id);
}
return id;
}
liveCount() {
let n = 0;
for (let i = 0; i < FX_MAX; i += 1) if (this.life[i] > 0) n += 1;
return n;
}
clear() {
this.life.fill(0);
this.rings.length = 0;
}
emit(x, y, vx, vy, lifeMs, color, opts = {}) {
const i = this.cursor;
this.cursor = (i + 1) % FX_MAX;
this.x[i] = x; this.y[i] = y;
this.vx[i] = vx; this.vy[i] = vy;
this.life[i] = lifeMs; this.maxLife[i] = lifeMs;
this.len[i] = opts.len ?? 1;
this.damp[i] = opts.damp ?? 0.955;
this.col[i] = this.colorId(color);
this.wid[i] = opts.wide ? 1 : 0;
}
// Classic radial burst. Speeds follow 1 - 1/rand so most sparks are fast
// and a few linger near the centre, which reads as a "hot core".
explosion(x, y, color, opts = {}) {
const count = opts.count ?? 60;
const speed = opts.speed ?? 1000;
const life = opts.life ?? 1100;
const color2 = opts.color2 ?? color;
for (let k = 0; k < count; k += 1) {
const a = Math.random() * Math.PI * 2;
const sp = speed * (1 - 1 / (1 + Math.random() * 9));
const c = Math.random() < 0.5 ? color : color2;
this.emit(x, y, Math.cos(a) * sp, Math.sin(a) * sp, life * (0.55 + Math.random() * 0.6), c,
{ len: opts.len ?? 1, wide: opts.wide && Math.random() < 0.5 });
}
}
// Sparks thrown back off a wall hit, reflected about the wall normal.
wallSparks(x, y, angle, color, count = 18) {
const { w, h } = this.bounds;
let nx = 0; let ny = 0;
if (x <= 1) nx = 1; else if (x >= w - 1) nx = -1;
if (y <= 1) ny = 1; else if (y >= h - 1) ny = -1;
let dx = Math.cos(angle); let dy = Math.sin(angle);
if (nx) dx = -dx;
if (ny) dy = -dy;
const base = Math.atan2(dy, dx);
for (let k = 0; k < count; k += 1) {
const a = base + (Math.random() - 0.5) * 1.6;
const sp = 200 + Math.random() * 700;
this.emit(x + nx * 2, y + ny * 2, Math.cos(a) * sp, Math.sin(a) * sp, 380 + Math.random() * 380, color);
}
}
ring(x, y, maxR, lifeMs, color, width = 3, startR = 0) {
if (this.rings.length > 60) this.rings.shift();
this.rings.push({ x, y, r: startR, startR, maxR, life: lifeMs, maxLife: lifeMs, color, width });
}
// holes: [{x, y, r}] — active black holes pull sparks in and spin them.
update(dtMs, holes = []) {
const dt = dtMs / 1000;
if (dt <= 0) return;
const { w, h } = this.bounds;
const f60 = dt * 60;
for (let i = 0; i < FX_MAX; i += 1) {
if (this.life[i] <= 0) continue;
this.life[i] -= dtMs;
if (this.life[i] <= 0) continue;
let vx = this.vx[i]; let vy = this.vy[i];
for (let k = 0; k < holes.length; k += 1) {
const hole = holes[k];
const dx = hole.x - this.x[i]; const dy = hole.y - this.y[i];
const d2 = dx * dx + dy * dy;
if (d2 > 160000) continue;
const d = Math.sqrt(d2) || 1;
if (d < hole.r * 0.6) { this.life[i] = 0; break; }
const pull = 2600 * (1 - d / 400) * dt;
vx += (dx / d) * pull - (dy / d) * pull * 0.9;
vy += (dy / d) * pull + (dx / d) * pull * 0.9;
}
const damp = Math.pow(this.damp[i], f60);
vx *= damp; vy *= damp;
let x = this.x[i] + vx * dt; let y = this.y[i] + vy * dt;
if (x < 0) { x = -x; vx = -vx * 0.6; } else if (x > w) { x = 2 * w - x; vx = -vx * 0.6; }
if (y < 0) { y = -y; vy = -vy * 0.6; } else if (y > h) { y = 2 * h - y; vy = -vy * 0.6; }
this.x[i] = x; this.y[i] = y; this.vx[i] = vx; this.vy[i] = vy;
}
for (const r of this.rings) {
r.life -= dtMs;
const t = 1 - Math.max(0, r.life) / r.maxLife;
r.r = r.startR + (r.maxR - r.startR) * (1 - (1 - t) * (1 - t));
}
if (this.rings.length && this.rings[0].life <= 0) this.rings = this.rings.filter((r) => r.life > 0);
}
draw(g, view) {
const x0 = view.x - 40; const x1 = view.x + view.w + 40;
const y0 = view.y - 40; const y1 = view.y + view.h + 40;
for (const list of this.buckets.values()) list.length = 0;
for (let i = 0; i < FX_MAX; i += 1) {
if (this.life[i] <= 0) continue;
const x = this.x[i]; const y = this.y[i];
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
const f = this.life[i] / this.maxLife[i];
const band = Math.min(ALPHA_BANDS - 1, Math.floor(Math.min(1, f * 1.6) * ALPHA_BANDS));
const key = (this.col[i] * ALPHA_BANDS + band) * 2 + this.wid[i];
let list = this.buckets.get(key);
if (!list) { list = []; this.buckets.set(key, list); }
list.push(i);
}
for (const [key, list] of this.buckets) {
if (list.length === 0) continue;
const wid = key % 2;
const band = Math.floor(key / 2) % ALPHA_BANDS;
const col = Math.floor(key / 2 / ALPHA_BANDS);
g.lineStyle(WIDTHS[wid], this.palette[col], (band + 1) / ALPHA_BANDS);
g.beginPath();
for (const i of list) {
const vx = this.vx[i]; const vy = this.vy[i];
const sp = Math.sqrt(vx * vx + vy * vy);
const L = Math.min(28, Math.max(2.5, sp * 0.02)) * this.len[i];
const ux = sp > 1 ? vx / sp : 1; const uy = sp > 1 ? vy / sp : 0;
g.moveTo(this.x[i] - ux * L, this.y[i] - uy * L);
g.lineTo(this.x[i], this.y[i]);
}
g.strokePath();
}
for (const r of this.rings) {
if (r.life <= 0) continue;
const f = Math.max(0, r.life) / r.maxLife;
g.lineStyle(r.width * 3, r.color, 0.18 * f);
g.strokeCircle(r.x, r.y, r.r);
g.lineStyle(r.width, r.color, 0.85 * f);
g.strokeCircle(r.x, r.y, r.r);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,249 +0,0 @@
// The signature warping background grid: a lattice of point masses joined by
// springs, pushed around by bullets, explosions and black holes, and drawn
// with a slight perspective projection so pushes along z read as bulges.
//
// Phaser-free (it only calls Graphics methods on the object passed to
// draw()), so tools/verifyGeometryWars.js can exercise the physics in Node.
//
// Model after Michael Hoffman's well-known Geometry Wars grid write-up:
// springs only pull (target length is 95% of the rest length, which keeps
// the lattice taut), each mass's damping resets to 0.98 every update, and
// force calls temporarily raise damping so a blast punches in and then
// settles instead of ringing forever. Border masses are pinned outright
// (Hoffman anchors them with springs) so the lattice starts at equilibrium.
// Forces are in px/step units at the fixed 60Hz update rate.
const SPRING_STIFF = 0.28;
const SPRING_DAMP = 0.06;
const ANCHOR_STIFF = 0.002;
const ANCHOR_DAMP = 0.02;
const ANCHOR_EVERY = 3;
const BASE_DAMPING = 0.98;
const PERSPECTIVE = 2000;
export default class WarpGrid {
constructor(width, height, spacing = 40) {
this.width = width;
this.height = height;
this.spacing = spacing;
this.cols = Math.floor(width / spacing) + 1;
this.rows = Math.floor(height / spacing) + 1;
const n = this.cols * this.rows;
this.n = n;
this.px = new Float32Array(n); this.py = new Float32Array(n); this.pz = new Float32Array(n);
this.vx = new Float32Array(n); this.vy = new Float32Array(n); this.vz = new Float32Array(n);
this.ax = new Float32Array(n); this.ay = new Float32Array(n); this.az = new Float32Array(n);
this.ox = new Float32Array(n); this.oy = new Float32Array(n);
this.inv = new Float32Array(n);
this.damp = new Float32Array(n).fill(BASE_DAMPING);
// Projected screen positions, refreshed by draw().
this.sx = new Float32Array(n); this.sy = new Float32Array(n);
for (let r = 0; r < this.rows; r += 1) {
for (let c = 0; c < this.cols; c += 1) {
const i = r * this.cols + c;
const x = c * spacing; const y = r * spacing;
this.px[i] = x; this.py[i] = y;
this.ox[i] = x; this.oy[i] = y;
const border = r === 0 || c === 0 || r === this.rows - 1 || c === this.cols - 1;
this.inv[i] = border ? 0 : 1;
}
}
// Springs: neighbour links (b >= 0) and interior anchors (b === -1,
// pulling toward the mass's own rest position).
const sa = []; const sb = []; const rest = []; const k = []; const damp = [];
for (let r = 0; r < this.rows; r += 1) {
for (let c = 0; c < this.cols; c += 1) {
const i = r * this.cols + c;
if (c < this.cols - 1) { sa.push(i); sb.push(i + 1); rest.push(spacing * 0.95); k.push(SPRING_STIFF); damp.push(SPRING_DAMP); }
if (r < this.rows - 1) { sa.push(i); sb.push(i + this.cols); rest.push(spacing * 0.95); k.push(SPRING_STIFF); damp.push(SPRING_DAMP); }
if (this.inv[i] > 0 && r % ANCHOR_EVERY === 0 && c % ANCHOR_EVERY === 0) {
sa.push(i); sb.push(-1); rest.push(0); k.push(ANCHOR_STIFF); damp.push(ANCHOR_DAMP);
}
}
}
this.sa = Int32Array.from(sa);
this.sb = Int32Array.from(sb);
this.rest = Float32Array.from(rest);
this.k = Float32Array.from(k);
this.sdamp = Float32Array.from(damp);
}
// Index range of lattice cells that could lie within `radius` of (x, y).
// Masses drift from their rest spots, so pad by a couple of cells.
range(x, y, radius) {
const pad = this.spacing * 2;
const s = this.spacing;
return [
Math.max(1, Math.floor((x - radius - pad) / s)),
Math.min(this.cols - 2, Math.ceil((x + radius + pad) / s)),
Math.max(1, Math.floor((y - radius - pad) / s)),
Math.min(this.rows - 2, Math.ceil((y + radius + pad) / s)),
];
}
applyDirectedForce(fx, fy, fz, x, y, radius) {
const [c0, c1, r0, r1] = this.range(x, y, radius);
const r2 = radius * radius;
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * this.cols + c;
const dx = x - this.px[i]; const dy = y - this.py[i]; const dz = this.pz[i];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= r2) continue;
const f = (10 / (10 + Math.sqrt(d2))) * this.inv[i];
this.ax[i] += fx * f; this.ay[i] += fy * f; this.az[i] += fz * f;
}
}
}
applyImplosiveForce(force, x, y, radius) {
const [c0, c1, r0, r1] = this.range(x, y, radius);
const r2 = radius * radius;
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * this.cols + c;
const dx = x - this.px[i]; const dy = y - this.py[i]; const dz = -this.pz[i];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= r2) continue;
const f = ((10 * force) / (100 + d2)) * this.inv[i];
this.ax[i] += dx * f; this.ay[i] += dy * f; this.az[i] += dz * f;
this.damp[i] *= 0.6;
}
}
}
applyExplosiveForce(force, x, y, radius) {
const [c0, c1, r0, r1] = this.range(x, y, radius);
const r2 = radius * radius;
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * this.cols + c;
const dx = this.px[i] - x; const dy = this.py[i] - y; const dz = this.pz[i];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= r2) continue;
const f = ((100 * force) / (10000 + d2)) * this.inv[i];
this.ax[i] += dx * f; this.ay[i] += dy * f; this.az[i] += dz * f;
this.damp[i] *= 0.6;
}
}
}
// One fixed 60Hz physics step.
update() {
const { px, py, pz, vx, vy, vz, ax, ay, az, inv, sa, sb, rest, k, sdamp } = this;
const m = sa.length;
for (let s = 0; s < m; s += 1) {
const a = sa[s]; const b = sb[s];
let bxp; let byp; let bzp; let bvx; let bvy; let bvz;
if (b >= 0) { bxp = px[b]; byp = py[b]; bzp = pz[b]; bvx = vx[b]; bvy = vy[b]; bvz = vz[b]; }
else { bxp = this.ox[a]; byp = this.oy[a]; bzp = 0; bvx = 0; bvy = 0; bvz = 0; }
let dx = px[a] - bxp; let dy = py[a] - byp; let dz = pz[a] - bzp;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len <= rest[s]) continue;
const stretch = (len - rest[s]) / len;
dx *= stretch; dy *= stretch; dz *= stretch;
const kk = k[s]; const cc = sdamp[s];
const fx = kk * dx - (bvx - vx[a]) * cc;
const fy = kk * dy - (bvy - vy[a]) * cc;
const fz = kk * dz - (bvz - vz[a]) * cc;
ax[a] -= fx * inv[a]; ay[a] -= fy * inv[a]; az[a] -= fz * inv[a];
if (b >= 0) { ax[b] += fx * inv[b]; ay[b] += fy * inv[b]; az[b] += fz * inv[b]; }
}
const damp = this.damp;
for (let i = 0; i < this.n; i += 1) {
if (inv[i] === 0) { ax[i] = 0; ay[i] = 0; az[i] = 0; continue; }
vx[i] += ax[i]; vy[i] += ay[i]; vz[i] += az[i];
px[i] += vx[i]; py[i] += vy[i]; pz[i] += vz[i];
ax[i] = 0; ay[i] = 0; az[i] = 0;
if (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i] < 1e-6) { vx[i] = 0; vy[i] = 0; vz[i] = 0; }
const d = damp[i];
vx[i] *= d; vy[i] *= d; vz[i] *= d;
damp[i] = BASE_DAMPING;
}
}
// Largest displacement from rest anywhere on the lattice (verification aid).
maxDisplacement() {
let m = 0;
for (let i = 0; i < this.n; i += 1) {
const dx = this.px[i] - this.ox[i]; const dy = this.py[i] - this.oy[i]; const dz = this.pz[i];
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (d > m) m = d;
}
return m;
}
// Draw into a Graphics-like object. `view` = {x, y, w, h} world rect.
// `style` = { minor, minorAlpha, minorWidth, major, majorAlpha, majorWidth,
// hot, hotAlpha, hotter, hotterAlpha, majorEvery }.
draw(g, view, style) {
const { cols, rows, spacing, px, py, pz, sx, sy } = this;
const cx = view.x + view.w / 2; const cy = view.y + view.h / 2;
const c0 = Math.max(0, Math.floor(view.x / spacing) - 1);
const c1 = Math.min(cols - 1, Math.ceil((view.x + view.w) / spacing) + 1);
const r0 = Math.max(0, Math.floor(view.y / spacing) - 1);
const r1 = Math.min(rows - 1, Math.ceil((view.y + view.h) / spacing) + 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * cols + c;
const f = (pz[i] + PERSPECTIVE) / PERSPECTIVE;
sx[i] = (px[i] - cx) * f + cx;
sy[i] = (py[i] - cy) * f + cy;
}
}
const every = style.majorEvery ?? 4;
const pass = (major) => {
g.beginPath();
for (let r = r0; r <= r1; r += 1) {
if ((r % every === 0) !== major) continue;
let i = r * cols + c0;
g.moveTo(sx[i], sy[i]);
for (let c = c0 + 1; c <= c1; c += 1) { i += 1; g.lineTo(sx[i], sy[i]); }
}
for (let c = c0; c <= c1; c += 1) {
if ((c % every === 0) !== major) continue;
let i = r0 * cols + c;
g.moveTo(sx[i], sy[i]);
for (let r = r0 + 1; r <= r1; r += 1) { i += cols; g.lineTo(sx[i], sy[i]); }
}
g.strokePath();
};
g.lineStyle(style.minorWidth, style.minor, style.minorAlpha);
pass(false);
g.lineStyle(style.majorWidth, style.major, style.majorAlpha);
pass(true);
// Hot overlay: segments around strongly displaced masses glow brighter,
// so ripples and black-hole funnels light up instead of just bending.
const hot = []; const hotter = [];
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * cols + c;
const dx = px[i] - this.ox[i]; const dy = py[i] - this.oy[i];
const d2 = dx * dx + dy * dy + pz[i] * pz[i] * 0.0025;
if (d2 > 144) hotter.push(i);
else if (d2 > 25) hot.push(i);
}
}
// Only link to neighbours inside the projected window — anything outside
// it holds a stale (or never-written) screen position.
const segs = (list) => {
g.beginPath();
for (const i of list) {
const c = i % cols; const r = (i - c) / cols;
if (c < c1) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i + 1], sy[i + 1]); }
if (r < r1) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i + cols], sy[i + cols]); }
if (c > c0) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i - 1], sy[i - 1]); }
if (r > r0) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i - cols], sy[i - cols]); }
}
g.strokePath();
};
if (hot.length) { g.lineStyle(style.majorWidth, style.hot, style.hotAlpha); segs(hot); }
if (hotter.length) { g.lineStyle(style.majorWidth + 0.5, style.hotter, style.hotterAlpha); segs(hotter); }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,117 +0,0 @@
// Stroked vector font for Geometry Wars — the glyph table is the generic
// 4x6-cell set from TempestVectorFont.js / DefenderVectorFont.js (copied, per
// this repo's convention), extended with a few symbols, plus italic skew,
// left/right alignment, letter spacing and a separate glow colour so titles
// and HUD numbers can read as neon tubes.
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]]],
'+': [[[0.6, 3], [3.4, 3]], [[2, 1.6], [2, 4.4]]],
'.': [[[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, 1], [1, 0], [3, 0], [4, 1], [4, 2], [2, 3.4], [2, 4.2]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
'/': [[[3.6, 0], [0.4, 6]]],
"'": [[[2, 0], [2, 1.6]]],
'%': [[[3.6, 0], [0.4, 6]], [[0.4, 0.4], [1.4, 0.4], [1.4, 1.6], [0.4, 1.6], [0.4, 0.4]], [[2.6, 4.4], [3.6, 4.4], [3.6, 5.6], [2.6, 5.6], [2.6, 4.4]]],
'×': [[[0.6, 1.6], [3.4, 4.6]], [[3.4, 1.6], [0.6, 4.6]]],
};
function advance(ch, spacing) {
return ch === ' ' ? SPACE_W + spacing : GLYPH_W + GLYPH_GAP + spacing;
}
export function measureVectorText(text, scale, spacing = 0) {
let w = 0;
for (const ch of String(text).toUpperCase()) w += advance(ch, spacing) * scale;
return Math.max(0, w - (GLYPH_GAP + spacing) * scale);
}
export function vectorTextHeight(scale) { return GLYPH_H * scale; }
function strokeGlyphs(g, text, x0, y0, scale, skew, spacing) {
let x = x0;
for (const ch of text) {
const strokes = GLYPHS[ch];
if (strokes) {
for (const poly of strokes) {
g.beginPath();
for (let i = 0; i < poly.length; i += 1) {
const [ux, uy] = poly[i];
const px = x + (ux + (GLYPH_H - uy) * skew) * scale;
const py = y0 + uy * scale;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.strokePath();
}
}
x += advance(ch, spacing) * scale;
}
}
// (x, y) is the anchor: centre/left/right edge per `align`, vertical centre.
export function drawVectorText(g, text, x, y, scale, color, options = {}) {
const {
lineWidth = 3, glowWidth = 9, glowAlpha = 0.2, alpha = 1,
align = 'center', skew = 0, spacing = 0, glowColor = color, glow = true,
} = options;
const str = String(text).toUpperCase();
const w = measureVectorText(str, scale, spacing);
let x0 = x - w / 2;
if (align === 'left') x0 = x;
else if (align === 'right') x0 = x - w;
const y0 = y - (GLYPH_H * scale) / 2;
if (glow && glowWidth > 0 && glowAlpha > 0) {
g.lineStyle(glowWidth, glowColor, glowAlpha * alpha);
strokeGlyphs(g, str, x0, y0, scale, skew, spacing);
}
g.lineStyle(lineWidth, color, alpha);
strokeGlyphs(g, str, x0, y0, scale, skew, spacing);
return w;
}
export function formatScore(n) {
return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

View File

@ -13,16 +13,6 @@ import {
const FONT_D = 'Righteous'; const FONT_D = 'Righteous';
const FONT_B = '"Julius Sans One"'; const FONT_B = '"Julius Sans One"';
const RULES_W = 420;
// The same four rules the engine enforces (TentsLogic.js rule comment) —
// kept here as plain strings so the panel can never drift from the model.
const RULES_TEXT = [
'Every tent must sit next to a tree — up, down, left, or right (no diagonals).',
"It's a one-to-one pairing: each tree gets exactly one tent, and each tent belongs to exactly one tree.",
'Tents can never touch another tent, not even corner to corner.',
'The numbers along the top and side are the exact tent count for that column and row — no more, no less.',
];
// ── Color helpers (day → night lerps drive the whole scene) ───────────────── // ── Color helpers (day → night lerps drive the whole scene) ─────────────────
@ -85,7 +75,6 @@ export default class TentsGame extends Phaser.Scene {
this.sky = { t: 0 }; // 0 = midday, 1 = starry night this.sky = { t: 0 }; // 0 = midday, 1 = starry night
this.won = false; this.won = false;
this.winPanelShown = false; this.winPanelShown = false;
this.rulesOpen = false;
this.difficulty = 'porch'; this.difficulty = 'porch';
this.hover = null; // {c, r} this.hover = null; // {c, r}
this.embers = []; this.embers = [];
@ -118,7 +107,6 @@ export default class TentsGame extends Phaser.Scene {
this.buildHud(); this.buildHud();
this.hudLayer.setVisible(false); this.hudLayer.setVisible(false);
this.buildRulesPanel();
this.buildSelectScreen(); this.buildSelectScreen();
this.events.on('shutdown', () => { this.events.on('shutdown', () => {
@ -221,14 +209,7 @@ export default class TentsGame extends Phaser.Scene {
this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost'); this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost');
this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost'); this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
// Parked at the same screen position the rules panel's Close button sits this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]);
// at once open (GAME_WIDTH - RULES_W/2, GAME_HEIGHT - 80), so opening the
// panel reads as this button "becoming" Close rather than two separate
// controls.
this.btnRules = new Button(this, GAME_WIDTH - RULES_W / 2, GAME_HEIGHT - 80, 'Rules',
() => this.toggleRules(), { width: 200, height: 56, variant: 'ghost' }).setDepth(31);
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu, this.btnRules]);
} }
makeText(str, x, y, font, size, color) { makeText(str, x, y, font, size, color) {
@ -238,68 +219,6 @@ export default class TentsGame extends Phaser.Scene {
}).setOrigin(0, 0).setDepth(30); }).setOrigin(0, 0).setDepth(30);
} }
// ── Rules panel (toggled in-play; parked off-screen until opened) ──────────
buildRulesPanel() {
const panel = this.add.container(GAME_WIDTH, 0).setDepth(35);
const bg = this.add.graphics();
bg.fillStyle(0x101024, 0.95);
bg.fillRect(0, 0, RULES_W, GAME_HEIGHT);
bg.lineStyle(3, 0xffc46a, 0.9);
bg.lineBetween(0, 0, 0, GAME_HEIGHT);
panel.add(bg);
// Pushed down ~100px so the title doesn't sit under the soundtrack
// controls (MusicPlayer's skip/mute buttons + track name) in the
// top-right corner, which render above this panel's depth.
panel.add(this.add.text(RULES_W / 2, 150, 'How to Play', {
fontFamily: FONT_D, fontSize: '36px', color: '#ffd76a',
stroke: '#3a2a14', strokeThickness: 6,
}).setOrigin(0.5));
let y = 218;
RULES_TEXT.forEach((line, i) => {
panel.add(this.add.text(24, y, `${i + 1}.`, {
fontFamily: FONT_D, fontSize: '22px', color: '#ffd76a',
}).setOrigin(0, 0));
const body = this.add.text(58, y, line, {
fontFamily: FONT_B, fontSize: '20px', color: '#fdf6e3',
wordWrap: { width: RULES_W - 82 }, lineSpacing: 4,
}).setOrigin(0, 0);
panel.add(body);
y += body.height + 34;
});
const closeBtn = new Button(this, RULES_W / 2, GAME_HEIGHT - 80, 'Close',
() => this.toggleRules(), { width: 200, height: 56, variant: 'ghost' });
panel.add(closeBtn);
this.rulesPanel = panel;
}
toggleRules() {
this.rulesOpen = !this.rulesOpen;
this.tweens.add({
targets: this.rulesPanel,
x: this.rulesOpen ? GAME_WIDTH - RULES_W : GAME_WIDTH,
duration: 220,
ease: 'Quad.Out',
});
// The panel's own Close button sits in this same screen spot once open,
// so hide (and un-click) the Rules button rather than stacking both.
this.btnRules.setVisible(!this.rulesOpen);
if (this.rulesOpen) {
this.btnRules.disableInteractive();
} else {
this.btnRules.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(0, 0, 200, 56),
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
});
}
}
// ── Select screen ────────────────────────────────────────────────────────── // ── Select screen ──────────────────────────────────────────────────────────
buildSelectScreen() { buildSelectScreen() {

View File

@ -110,8 +110,6 @@ import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js'; import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js'; import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
import TentsGame from './games/tents/TentsGame.js'; import TentsGame from './games/tents/TentsGame.js';
import DefenderGame from './games/defender/DefenderGame.js';
import GeometryWarsGame from './games/geometrywars/GeometryWarsGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -235,8 +233,6 @@ const config = {
WolfensteinEditor, WolfensteinEditor,
PipePuzzleGame, PipePuzzleGame,
TentsGame, TentsGame,
DefenderGame,
GeometryWarsGame,
], ],
}; };

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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', defender: 'DefenderGame', geometrywars: 'GeometryWarsGame' }; 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' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug]; const sceneKey = slugDispatch[this.game.slug];
const startData = { const startData = {

View File

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

View File

@ -1,437 +0,0 @@
// 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.
// 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;
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`);
}
// ---------------------------------------------------------------------------
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`);
process.exit(1);
} else {
console.log('\nAll checks passed.');
}

View File

@ -1,650 +0,0 @@
// Headless verification for Geometry Wars.
// node tools/verifyGeometryWars.js
// Exits non-zero on any failure.
//
// 1. Setup per mode (lives, bombs, gun).
// 2. Movement: speed cap, diagonal normalisation, arena clamp.
// 3. Firing: volley size/rate per weapon tier; pacifism can't shoot.
// 4. Kill → score × multiplier, geoms drop, pickup raises multiplier + gun.
// 5. Splitter splits into three minis; bombs/deaths never split.
// 6. Snake: tail eats bullets, head dies, tail kills the player.
// 7. Black hole: dormant until shot, pulls + absorbs, overloads into protons.
// 8. Bomb: kills everything for no points, edge-triggered, consumes a bomb.
// 9. Death: board wipe, multiplier/gun reset, respawn invulnerability,
// game over when the last ship goes.
// 10. Extra life / bomb thresholds.
// 11. Pacifism gates: crossing detonates, posts kill, blast radius.
// 12. Spawning: nothing within the safe radius; warming-up enemies are inert.
// 13. Spiral-of-death guard.
// 14. Determinism.
// 15. Warp grid: settles back to rest, never NaNs.
// 16. Particle field: pool cap, NaN-free, hole capture.
// 17. Bot soak: many seeds, both modes, invariants every tick.
import {
STEP_MS, MAX_STEPS, ARENA_W, ARENA_H, TUNE, ENEMY_TYPES, WEAPONS,
createGame, setInput, step, spawnEnemy, snakeSegments, gateEndpoints,
weaponTierFor, runPattern, _internal,
} from '../src/games/geometrywars/GeometryWarsLogic.js';
import WarpGrid from '../src/games/geometrywars/GeometryWarsGrid.js';
import ParticleField, { FX_MAX } from '../src/games/geometrywars/GeometryWarsFx.js';
let failures = 0;
let passes = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); return; }
failures += 1;
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
}
function runTicks(s, count, input = null) {
if (input) setInput(s, input);
const events = [];
for (let i = 0; i < count; i += 1) events.push(...step(s, STEP_MS));
return events;
}
// A quiet arena: director held off so tests control every spawn.
function quiet(mode = 'evolved', seed = 7) {
const s = createGame({ seed, mode });
s.director.spawnCd = 1e12;
s.director.gateCd = 1e12;
return s;
}
const ofType = (events, type) => events.filter((e) => e.type === type);
// ---------------------------------------------------------------------------
console.log('1. Setup per mode');
{
const ev = createGame({ seed: 1, mode: 'evolved' });
check('evolved starts with 3 ships', ev.lives === 3);
check('evolved starts with 3 bombs', ev.bombs === 3);
check('evolved starts on the twin stream', ev.weaponTier === 0 && WEAPONS[0].shots.length === 2);
check('multiplier starts at 1', ev.multiplier === 1);
const pc = createGame({ seed: 1, mode: 'pacifism' });
check('pacifism is one ship', pc.lives === 1);
check('pacifism has no bombs', pc.bombs === 0);
check('unknown mode falls back to evolved', createGame({ mode: 'bogus' }).mode === 'evolved');
check('player starts mid-arena', ev.player.x === ARENA_W / 2 && ev.player.y === ARENA_H / 2);
}
// ---------------------------------------------------------------------------
console.log('2. Movement');
{
const s = quiet();
runTicks(s, 90, { moveX: 1, moveY: 0 });
const sp = Math.hypot(s.player.vx, s.player.vy);
check('reaches but never exceeds top speed', sp <= TUNE.PLAYER_SPEED + 1e-6 && sp > TUNE.PLAYER_SPEED * 0.95, `speed ${sp.toFixed(1)}`);
const d = quiet();
runTicks(d, 90, { moveX: 1, moveY: 1 });
const dsp = Math.hypot(d.player.vx, d.player.vy);
check('diagonal is normalised (no √2 boost)', dsp <= TUNE.PLAYER_SPEED + 1e-6, `speed ${dsp.toFixed(1)}`);
const c = quiet();
runTicks(c, 400, { moveX: -1, moveY: -1 });
check('clamped inside arena (top-left)', c.player.x >= TUNE.PLAYER_RADIUS - 1e-9 && c.player.y >= TUNE.PLAYER_RADIUS - 1e-9);
runTicks(c, 600, { moveX: 1, moveY: 1 });
check('clamped inside arena (bottom-right)', c.player.x <= ARENA_W - TUNE.PLAYER_RADIUS + 1e-9 && c.player.y <= ARENA_H - TUNE.PLAYER_RADIUS + 1e-9);
const f = quiet();
runTicks(f, 60, { moveX: 0, moveY: 1 });
check('ship turns to face its travel direction', Math.abs(f.player.angle - Math.PI / 2) < 0.05, `angle ${f.player.angle.toFixed(3)}`);
}
// ---------------------------------------------------------------------------
console.log('3. Firing');
{
for (let tier = 0; tier < WEAPONS.length; tier += 1) {
const s = quiet();
s.weaponTier = tier;
const ev = runTicks(s, 1, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
check(`tier ${tier} (${WEAPONS[tier].name}) fires ${WEAPONS[tier].shots.length} bullets`, s.bullets.length === WEAPONS[tier].shots.length && ofType(ev, 'shotFired').length === 1,
`got ${s.bullets.length}`);
}
const s = quiet();
const ev = runTicks(s, 60, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
const volleys = ofType(ev, 'shotFired').length;
const expected = Math.floor(1000 / WEAPONS[0].intervalMs) + 1;
check('fire rate matches the weapon interval', Math.abs(volleys - expected) <= 1, `${volleys} volleys/s, expected ~${expected}`);
const forward = s.bullets.every((b) => b.vx > 0 && Math.abs(b.vy) < Math.abs(b.vx) * 0.1);
check('bullets travel toward the aim point', forward);
const pc = quiet('pacifism');
runTicks(pc, 60, { fire: true, aimX: 0, aimY: 0 });
check('pacifism cannot shoot', pc.bullets.length === 0);
const wall = quiet();
const wev = runTicks(wall, 120, { fire: true, aimX: wall.player.x + 5000, aimY: wall.player.y });
check('bullets that reach a wall emit bulletWall', ofType(wev, 'bulletWall').length > 0);
check('no bullet survives outside the arena', wall.bullets.every((b) => b.x >= 0 && b.x <= ARENA_W && b.y >= 0 && b.y <= ARENA_H));
check('weaponTierFor thresholds', weaponTierFor(1) === 0 && weaponTierFor(10) === 1 && weaponTierFor(40) === 2 && weaponTierFor(100) === 3 && weaponTierFor(99999) === 3);
}
// ---------------------------------------------------------------------------
console.log('4. Kills, geoms, multiplier');
{
const s = quiet();
s.multiplier = 3;
const g = spawnEnemy(s, 'grunt', s.player.x + 300, s.player.y, { spawnMs: 0 });
g.vx = 0;
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 300, aimY: s.player.y });
const kill = ofType(ev, 'enemyKilled')[0];
check('grunt killed by bullet', kill && kill.enemyType === 'grunt' && kill.cause === 'bullet');
check('points = base × multiplier', kill && kill.points === ENEMY_TYPES.grunt.score * 3, `points ${kill?.points}`);
check('score credited', s.score === ENEMY_TYPES.grunt.score * 3);
check('kill dropped geoms', s.geoms.length + s.stats.geoms >= ENEMY_TYPES.grunt.geoms);
const p = quiet();
for (let i = 0; i < 9; i += 1) p.geoms.push({ id: 1000 + i, x: p.player.x + 5, y: p.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
const pev = runTicks(p, 2);
check('touching geoms collects them', p.multiplier === 10 && p.stats.geoms === 9, `mult ${p.multiplier}`);
check('crossing ×10 upgrades the gun', p.weaponTier === 1 && ofType(pev, 'weaponUpgrade').length === 1);
check('milestone event at ×10', ofType(pev, 'multiplierMilestone').some((e) => e.multiplier === 10));
const m = quiet();
m.geoms.push({ id: 5, x: m.player.x + 120, y: m.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
runTicks(m, 40);
check('geoms within magnet range fly to the ship', m.stats.geoms === 1);
const e = quiet();
e.geoms.push({ id: 6, x: 100, y: 100, vx: 0, vy: 0, lifeMs: 500, spin: 0, dead: false });
runTicks(e, 40);
check('uncollected geoms expire', e.geoms.length === 0 && e.stats.geoms === 0);
const cap = quiet();
for (let i = 0; i < 200; i += 1) _internal.collectGeom(cap, { dead: false, x: 0, y: 0 });
check('peak multiplier tracked', cap.stats.peakMult === 201);
}
// ---------------------------------------------------------------------------
console.log('5. Splitter');
{
const s = quiet();
const sp = spawnEnemy(s, 'splitter', s.player.x + 400, s.player.y, { spawnMs: 0 });
sp.vx = 0;
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
check('splitter dies to a bullet', ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'splitter'));
const minis = ofType(ev, 'enemySpawned').filter((k) => k.enemyType === 'mini');
check('splits into exactly three minis', minis.length === 3, `got ${minis.length}`);
const b = quiet();
spawnEnemy(b, 'splitter', 300, 300, { spawnMs: 0 });
_internal.detonateBomb(b);
runTicks(b, 1);
check('bombed splitter does not split', b.enemies.length === 0);
}
// ---------------------------------------------------------------------------
console.log('6. Snake');
{
const s = quiet();
const sn = spawnEnemy(s, 'snake', s.player.x + 500, s.player.y, { spawnMs: 0, heading: 0 });
// Lay its tail out toward the player so shots hit tail before head.
sn.trail = sn.trail.map((_, i) => ({ x: sn.x - i * 6, y: sn.y }));
sn.heading = 0;
const segs = snakeSegments(sn);
check('snake exposes its segment count', segs.length === TUNE.SNAKE_SEGMENTS);
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 500, aimY: s.player.y });
check('tail absorbs bullets', ofType(ev, 'bulletDeflect').length > 0);
check('tail hits do not kill the snake', !ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
const h = quiet();
// Coming straight at the player, so the head leads and the tail trails away.
const head = spawnEnemy(h, 'snake', h.player.x + 300, h.player.y, { spawnMs: 0, heading: Math.PI });
head.trail = head.trail.map((_, i) => ({ x: head.x + i * 6, y: head.y }));
const hev = runTicks(h, 30, { fire: true, aimX: h.player.x + 300, aimY: h.player.y });
check('head shot kills the snake', ofType(hev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
const t = quiet();
const tk = spawnEnemy(t, 'snake', t.player.x + 600, t.player.y + 600, { spawnMs: 0 });
tk.trail = tk.trail.map(() => ({ x: t.player.x, y: t.player.y }));
tk.trail[0] = { x: tk.x, y: tk.y };
const save = TUNE.SNAKE_SEGMENTS;
_internal.tick(t);
check('touching the tail kills the player', !t.player.alive && t.stats.deaths === 1);
check('(segment count unchanged)', TUNE.SNAKE_SEGMENTS === save);
}
// ---------------------------------------------------------------------------
console.log('7. Black hole');
{
const s = quiet();
const hole = spawnEnemy(s, 'blackhole', s.player.x + 500, s.player.y, { spawnMs: 0 });
const g = spawnEnemy(s, 'wanderer', hole.x, hole.y - 200, { spawnMs: 0 });
const gy0 = g.y;
runTicks(s, 20, { fire: false });
check('dormant hole does not pull', Math.abs(g.y - gy0) > 0 && !hole.active); // wanderer moves on its own
const ev = runTicks(s, 30, { fire: true, aimX: hole.x, aimY: hole.y });
check('first hit activates the hole', hole.active && ofType(ev, 'blackHoleActivated').length === 1);
check('hits drain hp', hole.hp < TUNE.BLACKHOLE_HP);
const p = quiet();
const h2 = spawnEnemy(p, 'blackhole', 1000, 600, { spawnMs: 0 });
h2.active = true;
const gr = spawnEnemy(p, 'grunt', 1000, 420, { spawnMs: 0 });
p.player.x = 200; p.player.y = 1200; // far away, so the grunt chases elsewhere
const d0 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
runTicks(p, 10);
const d1 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
check('active hole pulls enemies in', d1 < d0 || gr.dead, `${d0.toFixed(1)} → ${d1.toFixed(1)}`);
runTicks(p, 240);
check('hole absorbs what it pulls in (no points)', h2.absorbed >= 1 && p.score === 0);
const pp = quiet();
const h3 = spawnEnemy(pp, 'blackhole', pp.player.x + 250, pp.player.y, { spawnMs: 0 });
h3.active = true;
const px0 = pp.player.x;
runTicks(pp, 10, { moveX: 0, moveY: 0 });
check('active hole drags the player', pp.player.x > px0);
const o = quiet();
const h4 = spawnEnemy(o, 'blackhole', 1000, 600, { spawnMs: 0 });
h4.active = true;
o.player.x = 200; o.player.y = 200;
for (let i = 0; i < TUNE.BLACKHOLE_OVERLOAD; i += 1) spawnEnemy(o, 'wanderer', 1000 + 5, 600, { spawnMs: 0 });
const oev = runTicks(o, 3);
check('overfed hole overloads', ofType(oev, 'blackHoleOverload').length === 1 && !o.enemies.some((e) => e.type === 'blackhole'));
check('overload releases protons', o.enemies.filter((e) => e.type === 'proton').length >= 6);
const k = quiet();
const h5 = spawnEnemy(k, 'blackhole', k.player.x + 300, k.player.y, { spawnMs: 0 });
h5.active = true; h5.hp = 1; h5.absorbed = 4;
k.multiplier = 2;
const kev = runTicks(k, 30, { fire: true, aimX: h5.x, aimY: h5.y });
const kk = ofType(kev, 'enemyKilled').find((e) => e.enemyType === 'blackhole');
check('shot-down hole scores base + 50/absorbed × mult', kk && kk.points === (ENEMY_TYPES.blackhole.score + 200) * 2, `points ${kk?.points}`);
}
// ---------------------------------------------------------------------------
console.log('8. Bomb');
{
const s = quiet();
for (let i = 0; i < 20; i += 1) spawnEnemy(s, 'grunt', 200 + i * 40, 200, { spawnMs: i % 2 ? 0 : 500 });
const ev = runTicks(s, 1, { bomb: true });
check('bomb clears every enemy (incl. warming up)', s.enemies.length === 0);
check('bomb kills score nothing', s.score === 0 && ofType(ev, 'enemyKilled').every((e) => e.points === 0));
check('bomb consumes one bomb', s.bombs === 2 && s.stats.bombsUsed === 1);
runTicks(s, 30, { bomb: true });
check('holding the button does not chain bombs', s.bombs === 2);
runTicks(s, 1, { bomb: false });
runTicks(s, 1, { bomb: true });
check('release + press fires the next bomb', s.bombs === 1);
check('bomb grants a spawn lull', s.director.spawnCd > 0);
const z = quiet();
z.bombs = 0;
spawnEnemy(z, 'grunt', 200, 200, { spawnMs: 0 });
runTicks(z, 1, { bomb: true });
check('no bombs → nothing happens', z.enemies.length === 1);
const pc = quiet('pacifism');
pc.bombs = 3;
spawnEnemy(pc, 'grunt', 200, 200, { spawnMs: 0 });
runTicks(pc, 1, { bomb: true });
check('pacifism never bombs', pc.enemies.length === 1 && pc.bombs === 3);
}
// ---------------------------------------------------------------------------
console.log('9. Death and respawn');
{
const s = quiet();
s.multiplier = 55; s.weaponTier = 2; s.director.clockMs = 100000;
for (let i = 0; i < 10; i += 1) spawnEnemy(s, 'wanderer', 300 + i * 50, 300, { spawnMs: 0 });
s.geoms.push({ id: 9, x: 50, y: 50, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
const ev = runTicks(s, 1);
check('touching an enemy kills the player', ofType(ev, 'playerDied').length === 1 && !s.player.alive);
check('a ship is lost', s.lives === 2);
check('board wiped for no points', s.enemies.length === 0 && s.score === 0);
check('geoms cleared', s.geoms.length === 0);
check('multiplier and gun reset', s.multiplier === 1 && s.weaponTier === 0);
check('difficulty rewinds', s.director.clockMs < 100000);
const rev = runTicks(s, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 1);
check('respawns after the delay', s.player.alive && ofType(rev, 'playerRespawned').length === 1);
check('respawn is invulnerable', s.player.invulnMs > 0);
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
runTicks(s, 2);
check('invulnerable ship survives contact', s.player.alive);
const warm = quiet();
spawnEnemy(warm, 'grunt', warm.player.x, warm.player.y, { spawnMs: 400 });
runTicks(warm, 3);
check('warming-up enemies are harmless', warm.player.alive);
const last = quiet();
last.lives = 1;
spawnEnemy(last, 'grunt', last.player.x, last.player.y, { spawnMs: 0 });
runTicks(last, 1);
check('last ship lost → no respawn', !last.player.alive && last.lives === 0);
const gev = runTicks(last, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 2);
const go = ofType(gev, 'gameOver');
check('game over emitted exactly once', go.length === 1 && last.phase === 'over');
check('game over carries score + stats', go[0] && go[0].score === last.score && go[0].stats.deaths === 1);
check('a finished game stays finished', runTicks(last, 60).length === 0 && last.phase === 'over');
}
// ---------------------------------------------------------------------------
console.log('10. Extra lives and bombs');
{
const s = quiet();
s.score = TUNE.FIRST_LIFE_AT;
const ev = runTicks(s, 1);
check('first life threshold awards a ship', s.lives === 4 && ofType(ev, 'extraLife').length === 1);
check('next life threshold grows', s.nextLifeAt === Math.round(TUNE.FIRST_LIFE_AT * TUNE.THRESHOLD_GROWTH));
s.score = TUNE.FIRST_BOMB_AT;
const bev = runTicks(s, 1);
check('bomb threshold awards a bomb', s.bombs === 4 && ofType(bev, 'extraBomb').length === 1);
const big = quiet();
big.score = 1e12;
runTicks(big, 1);
check('lives capped', big.lives === TUNE.MAX_LIVES);
check('bombs capped', big.bombs === TUNE.MAX_BOMBS);
check('thresholds advanced past the score', big.nextLifeAt > big.score && big.nextBombAt > big.score);
const pc = quiet('pacifism');
pc.score = 1e9;
runTicks(pc, 1);
check('pacifism awards no extra ships', pc.lives === 1);
}
// ---------------------------------------------------------------------------
console.log('11. Pacifism gates');
{
const s = quiet('pacifism');
const g = _internal.spawnGate(s);
check('gate spawns', !!g && s.gates.length === 1);
g.spawnMs = 0; g.vx = 0; g.vy = 0; g.spinRate = 0; g.angle = Math.PI / 2; // vertical
g.x = 1000; g.y = 600;
s.player.x = 900; s.player.y = 600;
s.multiplier = 2;
const near = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R - 40, { spawnMs: 0 });
const far = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R + 200, { spawnMs: 0 });
near.vx = 0; far.vx = 0;
const ev = runTicks(s, 30, { moveX: 1, moveY: 0 });
check('flying through the middle detonates the gate', ofType(ev, 'gateTriggered').length === 1 && s.gates.length === 0);
check('blast kills enemies inside its radius', near.dead && ofType(ev, 'enemyKilled').some((k) => k.id === near.id && k.cause === 'gate'));
check('blast spares enemies outside it', !far.dead);
check('gate kills score × multiplier', s.score === ENEMY_TYPES.grunt.score * 2, `score ${s.score}`);
check('player survives a clean pass', s.player.alive);
check('gates counted', s.stats.gates === 1);
const p = quiet('pacifism');
const g2 = _internal.spawnGate(p);
g2.spawnMs = 0; g2.vx = 0; g2.vy = 0; g2.spinRate = 0; g2.angle = 0;
g2.x = 1000; g2.y = 600;
const [a] = gateEndpoints(g2);
p.player.x = a.x; p.player.y = a.y - 4;
runTicks(p, 1);
check('touching a gate post kills', !p.player.alive);
const d = createGame({ seed: 3, mode: 'pacifism' });
d.player.invulnMs = 1e12;
runTicks(d, 60 * 20);
check('director keeps gates on the field', d.stats.gates > 0 || d.gates.length >= 3, `gates ${d.gates.length}`);
check('pacifism director spawns enemies', d.enemies.length > 0 || d.stats.deaths > 0);
}
// ---------------------------------------------------------------------------
console.log('12. Spawning');
{
let tooClose = 0; let total = 0;
for (const seed of [1, 2, 3, 4, 5, 6]) {
const s = createGame({ seed });
s.player.invulnMs = 1e12;
s.director.clockMs = 200000; // everything unlocked
for (let i = 0; i < 1500; i += 1) {
s.player.x = 300 + ((i * 37) % (ARENA_W - 600));
s.player.y = 300 + ((i * 53) % (ARENA_H - 600));
const ev = step(s, STEP_MS);
for (const e of ev) {
if (e.type !== 'enemySpawned' || e.enemyType === 'mini' || e.enemyType === 'proton') continue;
total += 1;
if (Math.hypot(e.x - s.player.x, e.y - s.player.y) < TUNE.SPAWN_SAFE_DIST - 1) tooClose += 1;
}
s.enemies.length = Math.min(s.enemies.length, 120);
}
}
check('director spawns plenty', total > 500, `spawned ${total}`);
check('nothing spawns inside the safe radius', tooClose === 0, `${tooClose}/${total} too close`);
for (const pattern of ['scatter', 'corners', 'swarm', 'ring', 'rocketwall', 'blackhole', 'snakes']) {
const s = quiet('evolved', 11);
s.director.clockMs = 200000;
runPattern(s, pattern);
const inside = s.enemies.every((e) => e.x >= e.radius - 1e-6 && e.x <= ARENA_W - e.radius + 1e-6 && e.y >= e.radius - 1e-6 && e.y <= ARENA_H - e.radius + 1e-6);
check(`pattern '${pattern}' spawns inside the arena`, s.enemies.length > 0 && inside, `count ${s.enemies.length}`);
}
const cap = quiet();
for (let i = 0; i < 400; i += 1) spawnEnemy(cap, 'grunt', 100, 100);
check('enemy cap respected', cap.enemies.length === TUNE.MAX_ENEMIES);
const bh = quiet();
bh.director.clockMs = 200000;
for (let i = 0; i < 10; i += 1) runPattern(bh, 'blackhole');
check('black hole count capped', bh.enemies.filter((e) => e.type === 'blackhole').length <= TUNE.BLACKHOLE_MAX);
}
// ---------------------------------------------------------------------------
console.log('13. Spiral-of-death guard');
{
const s = quiet();
const t0 = s.tick;
step(s, 10_000);
check('huge delta runs at most MAX_STEPS ticks', s.tick - t0 === MAX_STEPS, `ran ${s.tick - t0}`);
const u = quiet();
step(u, -50); step(u, NaN);
check('negative/NaN deltas are ignored', u.tick === 0 && u.acc === 0);
}
// ---------------------------------------------------------------------------
console.log('14. Determinism');
{
const run = () => {
const s = createGame({ seed: 4242 });
let sig = 0;
for (let i = 0; i < 60 * 90; i += 1) {
const a = i * 0.013;
setInput(s, {
moveX: Math.cos(a), moveY: Math.sin(a * 1.3),
aimX: s.player.x + Math.cos(a * 3) * 300, aimY: s.player.y + Math.sin(a * 3) * 300,
fire: true, bomb: i % 900 === 0,
});
const ev = step(s, STEP_MS * (1 + (i % 3) * 0.37));
sig = (sig * 31 + ev.length + s.enemies.length * 7 + s.score) % 1e15;
}
return { sig, score: s.score, id: s.nextId, x: s.player.x, mult: s.multiplier, deaths: s.stats.deaths };
};
const a = run(); const b = run();
check('same seed + inputs → identical run', JSON.stringify(a) === JSON.stringify(b), `${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
const c = createGame({ seed: 1 }); const d = createGame({ seed: 2 });
for (let i = 0; i < 600; i += 1) { step(c, STEP_MS); step(d, STEP_MS); }
check('different seeds diverge', JSON.stringify(c.enemies.map((e) => [e.x, e.y])) !== JSON.stringify(d.enemies.map((e) => [e.x, e.y])));
}
// ---------------------------------------------------------------------------
console.log('15. Warp grid');
{
const g = new WarpGrid(ARENA_W, ARENA_H, 40);
for (let i = 0; i < 30; i += 1) g.update();
check('grid starts at rest', g.maxDisplacement() < 1e-6);
g.applyExplosiveForce(40, 1000, 600, 900);
g.applyDirectedForce(0, 0, 150, 1000, 600, 300);
for (let i = 0; i < 300; i += 1) g.applyImplosiveForce(4, 1500, 800, 240), g.update();
const disp = g.maxDisplacement();
check('forces displace the grid', disp > 5, `disp ${disp.toFixed(2)}`);
let finite = true;
for (let i = 0; i < g.n; i += 1) if (!Number.isFinite(g.px[i]) || !Number.isFinite(g.py[i]) || !Number.isFinite(g.pz[i])) finite = false;
check('grid stays finite under load', finite);
check('grid stays bounded under a sustained hole', disp < 400, `disp ${disp.toFixed(1)}`);
for (let i = 0; i < 60 * 30; i += 1) g.update();
check('grid settles back to rest', g.maxDisplacement() < 1.5, `disp ${g.maxDisplacement().toFixed(3)}`);
let pinned = true;
for (let r = 0; r < g.rows; r += 1) for (let c = 0; c < g.cols; c += 1) {
if (r !== 0 && c !== 0 && r !== g.rows - 1 && c !== g.cols - 1) continue;
const i = r * g.cols + c;
if (g.px[i] !== g.ox[i] || g.py[i] !== g.oy[i]) pinned = false;
}
check('border masses stay pinned', pinned);
// Draw against a recording stub: every coordinate must be finite.
const calls = { n: 0, bad: 0 };
const stub = {
lineStyle() {}, beginPath() {}, strokePath() {},
moveTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
lineTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
};
g.applyExplosiveForce(30, 600, 400, 300);
g.update();
g.draw(stub, { x: 160, y: 100, w: 1920, h: 1080 }, { minor: 1, minorAlpha: 1, minorWidth: 1, major: 1, majorAlpha: 1, majorWidth: 2, hot: 1, hotAlpha: 1, hotter: 1, hotterAlpha: 1 });
check('grid draw emits finite coordinates', calls.n > 1000 && calls.bad === 0, `${calls.n} calls, ${calls.bad} bad`);
}
// ---------------------------------------------------------------------------
console.log('16. Particle field');
{
const fx = new ParticleField({ w: ARENA_W, h: ARENA_H });
for (let i = 0; i < 20; i += 1) fx.explosion(1000, 600, 0xff00ff, { count: 300 });
check('pool never exceeds FX_MAX', fx.liveCount() <= FX_MAX);
for (let i = 0; i < 30; i += 1) fx.update(16.7, [{ x: 1100, y: 600, r: 26 }]);
let ok = true;
for (let i = 0; i < FX_MAX; i += 1) {
if (fx.life[i] <= 0) continue;
if (!Number.isFinite(fx.x[i]) || !Number.isFinite(fx.y[i])) ok = false;
if (fx.x[i] < -1 || fx.x[i] > ARENA_W + 1 || fx.y[i] < -1 || fx.y[i] > ARENA_H + 1) ok = false;
}
check('particles stay finite and inside the arena', ok);
const before = fx.liveCount();
for (let i = 0; i < 200; i += 1) fx.update(16.7, [{ x: 1000, y: 600, r: 60 }]);
check('particles expire', fx.liveCount() < before);
fx.ring(0, 0, 100, 300, 0xffffff);
fx.update(400);
check('rings expire', fx.rings.length === 0);
fx.wallSparks(0, 500, Math.PI, 0xffff00, 10);
fx.update(16.7);
let rightward = 0;
for (let i = 0; i < FX_MAX; i += 1) if (fx.life[i] > 0 && fx.vx[i] > 0) rightward += 1;
check('wall sparks bounce back into the arena', rightward >= 8);
const calls = { bad: 0 };
const stub = {
lineStyle() {}, beginPath() {}, strokePath() {}, strokeCircle() {},
moveTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
lineTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
};
fx.explosion(500, 500, 0x00ffff, { count: 100 });
fx.draw(stub, { x: 0, y: 0, w: 1920, h: 1080 });
check('particle draw emits finite coordinates', calls.bad === 0);
}
// ---------------------------------------------------------------------------
console.log('17. Bot soak');
{
// A dodging bot with human-ish limits: it only re-decides every `latency`
// ticks, aims with deterministic jitter, and only reacts to enemies inside
// `react` px. It aims at the nearest enemy, strafes away from anything
// close and bombs when crowded. Two profiles bracket the difficulty curve;
// invariants are checked every tick of every run.
const PROFILES = {
sharp: { latency: 10, noise: 0.15, react: 260 },
sloppy: { latency: 18, noise: 0.2, react: 220 },
};
function makeBot(profile, seed) {
let r = seed * 7919 + 13;
const jitter = () => { r = (r * 1103515245 + 12345) % 2147483648; return r / 2147483648 - 0.5; };
let held = null; let tick = 0;
return (s) => {
tick += 1;
if (held && tick % profile.latency !== 0) return held;
const p = s.player;
let nearest = null; let nd = Infinity;
let ax = 0; let ay = 0;
for (const e of s.enemies) {
const dx = e.x - p.x; const dy = e.y - p.y;
const d = Math.hypot(dx, dy) || 1;
if (d < nd && e.spawnMs <= 0) { nd = d; nearest = e; }
if (d < profile.react) { ax -= dx / d * (profile.react - d); ay -= dy / d * (profile.react - d); }
}
for (const g of s.gates) {
const dx = g.x - p.x; const dy = g.y - p.y;
const d = Math.hypot(dx, dy) || 1;
ax += dx / d * 120; ay += dy / d * 120;
for (const end of gateEndpoints(g)) {
const ex = end.x - p.x; const ey = end.y - p.y;
const ed = Math.hypot(ex, ey) || 1;
if (ed < 90) { ax -= ex / ed * (90 - ed) * 12; ay -= ey / ed * (90 - ed) * 12; }
}
}
ax += (ARENA_W / 2 - p.x) * 0.08; ay += (ARENA_H / 2 - p.y) * 0.08;
const close = s.enemies.filter((e) => Math.hypot(e.x - p.x, e.y - p.y) < 140).length;
const aim = (nearest ? Math.atan2(nearest.y - p.y, nearest.x - p.x) : 0) + jitter() * 2 * profile.noise;
held = {
moveX: Math.sign(ax) * Math.min(1, Math.abs(ax) / 50),
moveY: Math.sign(ay) * Math.min(1, Math.abs(ay) / 50),
aimX: p.x + Math.cos(aim) * 300, aimY: p.y + Math.sin(aim) * 300,
fire: true,
bomb: close >= 4,
};
return held;
};
}
const runs = [
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sharp', seed })),
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sloppy', seed })),
...[11, 22, 33, 44].map((seed) => ({ mode: 'pacifism', profile: 'sharp', seed })),
];
const results = [];
let violations = [];
const seenTypes = new Set();
for (const { mode, profile, seed } of runs) {
const s = createGame({ seed, mode });
const bot = makeBot(PROFILES[profile], seed);
let lastScore = 0;
let firstDeathMs = null;
const limit = 60 * 60 * 8; // 8 simulated minutes
let ticks = 0;
const tag = `${mode}/${profile}/${seed}`;
while (s.phase !== 'over' && ticks < limit) {
setInput(s, bot(s));
const ev = step(s, STEP_MS);
ticks += 1;
if (firstDeathMs == null && ev.some((e) => e.type === 'playerDied')) firstDeathMs = s.timeMs;
if (s.score < lastScore) violations.push(`${tag}: score went down`);
lastScore = s.score;
if (s.enemies.length > TUNE.MAX_ENEMIES + 40) violations.push(`${tag}: enemy overflow ${s.enemies.length}`);
if (s.bullets.length > TUNE.MAX_BULLETS) violations.push(`${tag}: bullet overflow`);
if (s.geoms.length > TUNE.MAX_GEOMS) violations.push(`${tag}: geom overflow`);
if (s.multiplier < 1 || s.lives < 0 || s.bombs < 0) violations.push(`${tag}: negative counters`);
if (mode === 'pacifism' && s.bullets.length) violations.push(`${tag}: pacifism bullet`);
if (ticks % 30 === 0) {
for (const e of s.enemies) {
seenTypes.add(e.type);
if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) violations.push(`${tag}: NaN ${e.type}`);
if (e.x < -1 || e.x > ARENA_W + 1 || e.y < -1 || e.y > ARENA_H + 1) violations.push(`${tag}: ${e.type} escaped arena`);
}
const p = s.player;
if (!Number.isFinite(p.x) || p.x < 0 || p.x > ARENA_W || p.y < 0 || p.y > ARENA_H) violations.push(`${tag}: player escaped`);
if (s.enemies.some((e) => e.dead) || s.bullets.some((b) => b.dead)) violations.push(`${tag}: dead entity kept`);
}
if (violations.length > 20) break;
}
results.push({
mode, profile, seed, over: s.phase === 'over', mins: s.timeMs / 60000,
firstDeath: firstDeathMs == null ? null : firstDeathMs / 60000,
score: s.score, kills: s.stats.kills, peak: s.stats.peakMult, deaths: s.stats.deaths, gates: s.stats.gates,
});
}
violations = [...new Set(violations)];
for (const r of results) {
console.log(` ${r.mode.padEnd(8)} ${r.profile.padEnd(6)} seed ${String(r.seed).padStart(2)}: ${r.over ? 'over ' : 'alive'} at ${r.mins.toFixed(1)}m`
+ `, first death ${r.firstDeath == null ? '—' : `${r.firstDeath.toFixed(1)}m`}, deaths ${r.deaths}, score ${formatInt(r.score)}, peak ×${r.peak}`
+ `${r.mode === 'pacifism' ? `, gates ${r.gates}` : ''}`);
}
const median = (xs) => { const m = [...xs].sort((a, b) => a - b); return m[Math.floor(m.length / 2)]; };
const sharp = results.filter((r) => r.mode === 'evolved' && r.profile === 'sharp');
const sloppy = results.filter((r) => r.mode === 'evolved' && r.profile === 'sloppy');
check('no invariant violations across the soak', violations.length === 0, violations.slice(0, 6).join('; '));
check('every run terminates or hits the time limit', results.length === runs.length);
check('evolved bots score', [...sharp, ...sloppy].every((r) => r.score > 0));
check('pacifism bot detonates gates', results.filter((r) => r.mode === 'pacifism').some((r) => r.gates > 0));
const allTypes = ['wanderer', 'grunt', 'weaver', 'splitter', 'snake', 'blackhole', 'rocket'];
check('every evolved enemy type appears in play', allTypes.every((t) => seenTypes.has(t)), `missing: ${allTypes.filter((t) => !seenTypes.has(t)).join(',')}`);
check('not too hard: sharp bot median run ≥ 3 min', median(sharp.map((r) => r.mins)) >= 3, `median ${median(sharp.map((r) => r.mins)).toFixed(2)}m`);
check('not too easy: sloppy bot loses a ship in most runs', sloppy.filter((r) => r.deaths > 0).length >= 3);
check('not too easy: sloppy bot first death before 4 min (median)',
median(sloppy.map((r) => r.firstDeath ?? 99)) < 4, `median ${median(sloppy.map((r) => r.firstDeath ?? 99)).toFixed(2)}m`);
}
function formatInt(n) { return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
console.log(`\n${passes} passed, ${failures} failed`);
process.exit(failures ? 1 : 0);