Add Geometry Wars arcade shooter with warp grid and neon bloom
- Headless sim (GeometryWarsLogic) with evolved and pacifism modes, seeded RNG for deterministic replays, and a fixed-tick accumulator loop - Spring-lattice warp grid that reacts to bullets, explosions and black holes - Additive neon bloom post pipeline plus a particle field for streaks/rings - Vector font, title screen with mode cards, HUD, pause and game-over panels - Register the game in the registry, scene dispatch, soundtrack and assets - tools/verifyGeometryWars.js: headless checks incl. a bot soak across seeds
This commit is contained in:
parent
e3c764a981
commit
d0d412cea9
Binary file not shown.
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
|
@ -488,6 +488,12 @@ 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.
|
||||
|
|
|
|||
|
|
@ -125,3 +125,4 @@ 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 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
// 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
|
|
@ -0,0 +1,249 @@
|
|||
// 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
|
|
@ -0,0 +1,117 @@
|
|||
// 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, ',');
|
||||
}
|
||||
|
|
@ -111,6 +111,7 @@ 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,
|
||||
|
|
@ -235,6 +236,7 @@ const config = {
|
|||
PipePuzzleGame,
|
||||
TentsGame,
|
||||
DefenderGame,
|
||||
GeometryWarsGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
|||
coloradodefense: 'arcadedark',
|
||||
tempest: 'arcadedark',
|
||||
defender: 'arcadedark',
|
||||
geometrywars: 'arcadedark',
|
||||
mastermind: 'hacker',
|
||||
balatro: 'hacker',
|
||||
hexsweeper: 'hacker',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,650 @@
|
|||
// 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);
|
||||
Loading…
Reference in New Issue