Compare commits

..

No commits in common. "main" and "Defender" have entirely different histories.

15 changed files with 2 additions and 3753 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

View File

@ -488,12 +488,6 @@ export const MANIFEST = {
// 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: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mastermind is actually entered.

View File

@ -125,4 +125,3 @@ registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPl
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
registerGame({ slug: 'defender', name: 'Defender', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 97 });
registerGame({ slug: 'geometrywars', name: 'Geometry Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 98 });

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_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) ─────────────────
@ -85,7 +75,6 @@ export default class TentsGame extends Phaser.Scene {
this.sky = { t: 0 }; // 0 = midday, 1 = starry night
this.won = false;
this.winPanelShown = false;
this.rulesOpen = false;
this.difficulty = 'porch';
this.hover = null; // {c, r}
this.embers = [];
@ -118,7 +107,6 @@ export default class TentsGame extends Phaser.Scene {
this.buildHud();
this.hudLayer.setVisible(false);
this.buildRulesPanel();
this.buildSelectScreen();
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.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
// Parked at the same screen position the rules panel's Close button sits
// 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]);
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]);
}
makeText(str, x, y, font, size, color) {
@ -238,68 +219,6 @@ export default class TentsGame extends Phaser.Scene {
}).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 ──────────────────────────────────────────────────────────
buildSelectScreen() {

View File

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

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game', 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', defender: 'DefenderGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

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

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