diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 7e3d4ce..1683f56 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 7e15cf4..d13b2dd 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index de0fa17..0741d09 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -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. diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 4cbe37a..d5174c6 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/geometrywars/GeometryWarsBloom.js b/src/games/geometrywars/GeometryWarsBloom.js new file mode 100644 index 0000000..6fa7a2d --- /dev/null +++ b/src/games/geometrywars/GeometryWarsBloom.js @@ -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; + }, + }; +} diff --git a/src/games/geometrywars/GeometryWarsFx.js b/src/games/geometrywars/GeometryWarsFx.js new file mode 100644 index 0000000..9135273 --- /dev/null +++ b/src/games/geometrywars/GeometryWarsFx.js @@ -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); + } + } +} diff --git a/src/games/geometrywars/GeometryWarsGame.js b/src/games/geometrywars/GeometryWarsGame.js new file mode 100644 index 0000000..6c35e30 --- /dev/null +++ b/src/games/geometrywars/GeometryWarsGame.js @@ -0,0 +1,1300 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { getGameSoundtrack } from '../../services/soundtrack.js'; +import { playSoundEx, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + STEP_MS, ARENA_W, ARENA_H, TUNE, ENEMY_TYPES, WEAPONS, + createGame, setInput, step, snakeSegments, gateEndpoints, +} from './GeometryWarsLogic.js'; +import WarpGrid from './GeometryWarsGrid.js'; +import ParticleField from './GeometryWarsFx.js'; +import { attachNeonBloom } from './GeometryWarsBloom.js'; +import { drawVectorText, formatScore } from './GeometryWarsVectorFont.js'; + +// Rendering model: the sim (GeometryWarsLogic) owns gameplay; this scene +// redraws a stack of Graphics layers from sim state every frame (no +// persistent per-entity GameObjects), same idiom as Tempest/Defender. +// +// Two cameras: the main camera scrolls over the arena (slightly larger than +// the screen) and carries the additive neon bloom; uiCam is fixed and +// bloom-free so HUD text, buttons and the music player stay crisp. +// routeCameras() assigns every display-list object to exactly one of them: +// anything registered in worldSet is world, everything else is UI. + +const C = { + bg: 0x02030a, + player: 0xffffff, + playerGlow: 0x8fe8ff, + bullet: 0xfff07a, + bulletGlow: 0xff8a1a, + geom: 0xc8ff3a, + border: 0x6f8dff, + hudLabel: 0x7f8fd8, + gold: 0xffd23a, + gate: 0xfff3b0, + gateEnd: 0xff9a2a, + danger: 0xff4f7a, +}; + +const ENEMY_COLOR = { + wanderer: 0xc65bff, + grunt: 0x38b8ff, + weaver: 0x46ff7a, + splitter: 0xff47d6, + mini: 0xff8ff0, + snake: 0xffd23a, + blackhole: 0xff3b3b, + proton: 0x9ff6ff, + rocket: 0xff7a1a, +}; +const SNAKE_TAIL = 0xff9f1c; + +const GRID_STYLE = { + minor: 0x1a2a8a, minorAlpha: 0.42, minorWidth: 1.2, + major: 0x2c45d8, majorAlpha: 0.5, majorWidth: 2, + hot: 0x5d7cff, hotAlpha: 0.5, + hotter: 0xb8c8ff, hotterAlpha: 0.7, + majorEvery: 4, +}; + +const MODE_INFO = { + evolved: { + title: 'EVOLVED', color: 0x5ff2ff, + desc: 'Shoot everything. Kills drop geoms; collect them to raise your multiplier and power up your gun. Three ships, three bombs.', + }, + pacifism: { + title: 'PACIFISM', color: 0xb6ff4a, + desc: 'No guns and one ship. Fly through the middle of a gate to detonate it and take every nearby enemy with the blast. The posts are deadly.', + }, +}; + +const GUIDE = ['wanderer', 'grunt', 'weaver', 'splitter', 'snake', 'rocket', 'blackhole']; + +const GLOW_KEY = 'geometrywars-glow'; +const GLOW_ORDER = ['high', 'low', 'off']; +const bestKey = (mode) => `geometrywars-best-${mode}`; + +const CAM_MARGIN = 90; +const TITLE_SCROLL_X = (ARENA_W - GAME_WIDTH) / 2; +const TITLE_SCROLL_Y = (ARENA_H - GAME_HEIGHT) / 2; +const SLOWMO_MS = 1300; +const UI_FONT = 'Righteous, "Julius Sans One", sans-serif'; + +// Player ship: a claw with its mouth open forward (nose = +x), unit space. +const SHIP = [1.05, -0.45, -0.1, -0.95, -0.8, -0.6, -0.45, 0, -0.8, 0.6, -0.1, 0.95, 1.05, 0.45, 0.25, 0.3, 0, 0, 0.25, -0.3, 1.05, -0.45]; +const SHIP_CORE = [-0.38, -0.26, 0.04, 0, -0.38, 0.26]; +const ROCKET = [1.25, 0, -0.9, -0.78, -0.4, 0, -0.9, 0.78, 1.25, 0]; +const SNAKE_HEAD = [1.25, 0, 0.3, -0.8, -0.85, -0.55, -0.85, 0.55, 0.3, 0.8, 1.25, 0]; + +function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } + +function lighten(color, f) { + const r = (color >> 16) & 255; const g = (color >> 8) & 255; const b = color & 255; + return (Math.round(r + (255 - r) * f) << 16) | (Math.round(g + (255 - g) * f) << 8) | Math.round(b + (255 - b) * f); +} + +function readStore(key, fallback) { + try { const v = localStorage.getItem(key); return v == null ? fallback : v; } catch (_) { return fallback; } +} +function writeStore(key, value) { + try { localStorage.setItem(key, String(value)); } catch (_) { /* private mode */ } +} + +// ── Shape builders: each pushes closed flat polylines [x0,y0,x1,y1,...] ───── + +function tpoly(out, local, x, y, ang, s) { + const c = Math.cos(ang) * s; const n = Math.sin(ang) * s; + const p = new Array(local.length); + for (let i = 0; i < local.length; i += 2) { + p[i] = x + local[i] * c - local[i + 1] * n; + p[i + 1] = y + local[i] * n + local[i + 1] * c; + } + out.push(p); +} + +function ngon(out, x, y, R, sides, rot) { + const p = []; + for (let i = 0; i <= sides; i += 1) { + const a = rot + (i / sides) * Math.PI * 2; + p.push(x + Math.cos(a) * R, y + Math.sin(a) * R); + } + out.push(p); +} + +function enemyShape(out, e, x, y, s, time) { + const r = (e.radius ?? ENEMY_TYPES[e.type].radius) * s; + switch (e.type) { + case 'wanderer': + for (let i = 0; i < 4; i += 1) { + const a = e.spin + i * Math.PI / 2; + out.push([x, y, x + Math.cos(a) * r, y + Math.sin(a) * r, + x + Math.cos(a + 0.85) * r * 0.62, y + Math.sin(a + 0.85) * r * 0.62, x, y]); + } + break; + case 'grunt': { + const k = 1 + 0.16 * Math.sin((e.age ?? time) * 0.009); + const a = r * 1.08 * k; const b = r * 1.08 / k; + out.push([x + a, y, x, y + b, x - a, y, x, y - b, x + a, y]); + out.push([x + a * 0.45, y, x, y + b * 0.45, x - a * 0.45, y, x, y - b * 0.45, x + a * 0.45, y]); + break; + } + case 'weaver': + ngon(out, x, y, r * 1.12, 4, e.spin + Math.PI / 4); + ngon(out, x, y, r * 1.12 * 0.7071, 4, e.spin); + break; + case 'splitter': + ngon(out, x, y, r * 1.15, 4, e.spin); + ngon(out, x, y, r * 0.8, 4, -e.spin * 0.6 + Math.PI / 4); + break; + case 'mini': + ngon(out, x, y, r * 1.2, 4, e.spin); + break; + case 'proton': + for (let i = 0; i < 3; i += 1) { + const a = e.spin + i * Math.PI / 3; + out.push([x - Math.cos(a) * r * 1.3, y - Math.sin(a) * r * 1.3, x + Math.cos(a) * r * 1.3, y + Math.sin(a) * r * 1.3]); + } + ngon(out, x, y, r * 0.55, 6, e.spin); + break; + case 'rocket': + tpoly(out, ROCKET, x, y, Math.atan2(e.vy || 0, e.vx || 1), r); + break; + case 'snake': + tpoly(out, SNAKE_HEAD, x, y, e.heading ?? 0, r); + break; + default: + ngon(out, x, y, r, 6, 0); + } +} + +export default class GeometryWarsGame extends Phaser.Scene { + constructor() { super('GeometryWarsGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'geometrywars', name: 'Geometry Wars' }; + this.startMode = data.startMode ?? null; + this.mode = 'title'; // 'title' | 'playing' | 'paused' | 'gameover' + this.sim = null; + this.playMode = 'evolved'; + this.selectedCard = 0; + this.timeScale = 1; + this.slowMoMs = 0; + this.gridAcc = 0; + this.exhaustAcc = 0; + this.banner = null; + this.popups = []; + this.borderHits = []; + this.sfxLast = {}; + this.multPulse = 0; + this.titleDeco = []; + this.titleHole = null; + this.fireworkMs = 600; + this.decoPopMs = 1500; + this.endPanel = null; + this.pauseUi = null; + this.titleUi = null; + } + + create() { + this.input.mouse?.disableContextMenu(); + const main = this.cameras.main; + main.setBackgroundColor(C.bg); + this.uiCam = this.cameras.add(0, 0, GAME_WIDTH, GAME_HEIGHT); + this.worldSet = new Set(); + + const world = (depth, additive = true) => { + const g = this.add.graphics().setDepth(depth); + if (additive) g.setBlendMode(Phaser.BlendModes.ADD); + this.worldSet.add(g); + return g; + }; + this.gridG = world(0); + this.borderG = world(1); + this.holeG = world(2, false); + this.fxG = world(3); + this.geomG = world(4); + this.gateG = world(5); + this.enemyG = world(6); + this.bulletG = world(7); + this.playerG = world(8); + this.popupG = world(9); + this.titleG = world(10); + + this.hudG = this.add.graphics().setDepth(50); + this.bannerG = this.add.graphics().setDepth(52); + this.crossG = this.add.graphics().setDepth(95); + this.flash = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0xffffff, 1) + .setDepth(80).setAlpha(0).setBlendMode(Phaser.BlendModes.ADD); + + try { + const { tracks, volume } = getGameSoundtrack(this); + if (tracks.length) this.music = new MusicPlayer(this, tracks, volume); + } catch (_) { /* optional */ } + + this.grid = new WarpGrid(ARENA_W, ARENA_H, 40); + this.fx = new ParticleField({ w: ARENA_W, h: ARENA_H }); + + this.glowLevel = readStore(GLOW_KEY, 'high'); + if (!GLOW_ORDER.includes(this.glowLevel)) this.glowLevel = 'high'; + this.bloom = attachNeonBloom(this, main, this.glowLevel); + + this.bindInput(); + this.game.events.on(Phaser.Core.Events.BLUR, this.onBlur, this); + this.events.once('shutdown', () => this.cleanup()); + + if (this.startMode) this.startRun(this.startMode); + else this.showTitle(); + this.routeCameras(); + } + + cleanup() { + this.input.setDefaultCursor('default'); + this.game.events.off(Phaser.Core.Events.BLUR, this.onBlur, this); + this.bloom?.destroy(); + } + + routeCameras() { + const main = this.cameras.main; + for (const obj of this.children.list) { + if (obj.__gwCam) continue; + obj.__gwCam = true; + if (this.worldSet.has(obj)) this.uiCam.ignore(obj); + else main.ignore(obj); + } + } + + // ── Input ───────────────────────────────────────────────────────────────── + + bindInput() { + this.keys = this.input.keyboard.addKeys('W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE,SHIFT,E,Q'); + this.input.keyboard.on('keydown', (ev) => { + const code = ev.code; + if (this.mode === 'title') { + if (code === 'ArrowLeft' || code === 'KeyA') this.selectCard(0); + else if (code === 'ArrowRight' || code === 'KeyD') this.selectCard(1); + else if (code === 'Enter' || code === 'Space') this.startRun(this.selectedCard === 0 ? 'evolved' : 'pacifism'); + } else if (this.mode === 'playing') { + if (code === 'Escape' || code === 'KeyP') this.pauseGame(); + } else if (this.mode === 'paused') { + if (code === 'Escape' || code === 'KeyP') this.resumeGame(); + } else if (this.mode === 'gameover' && this.endPanel) { + if (code === 'Enter') this.restartRun(); + } + }); + } + + readInput() { + const k = this.keys; + const ptr = this.input.activePointer; + const wp = this.cameras.main.getWorldPoint(ptr.x, ptr.y); + return { + moveX: ((k.D.isDown || k.RIGHT.isDown) ? 1 : 0) - ((k.A.isDown || k.LEFT.isDown) ? 1 : 0), + moveY: ((k.S.isDown || k.DOWN.isDown) ? 1 : 0) - ((k.W.isDown || k.UP.isDown) ? 1 : 0), + aimX: wp.x, + aimY: wp.y, + fire: ptr.leftButtonDown() || k.SPACE.isDown, + bomb: ptr.rightButtonDown() || k.SHIFT.isDown || k.E.isDown || k.Q.isDown, + }; + } + + onBlur() { + if (this.mode === 'playing') this.pauseGame(); + } + + sfx(key, { gap = 0, volume = 1, detune = 0 } = {}) { + const now = this.time.now; + if (gap > 0 && now - (this.sfxLast[key] ?? -1e9) < gap) return; + this.sfxLast[key] = now; + playSoundEx(this, key, { volume, detune: detune ? (Math.random() * 2 - 1) * detune : 0 }); + } + + // ── Frame loop ──────────────────────────────────────────────────────────── + + update(_time, delta) { + const dt = Math.min(delta, 50); + if (this.mode === 'title') this.updateTitle(dt); + else if (this.mode === 'playing' || this.mode === 'gameover') this.updatePlay(dt); + this.drawCrosshair(); + this.routeCameras(); + } + + viewRect() { + const cam = this.cameras.main; + return { x: cam.scrollX, y: cam.scrollY, w: GAME_WIDTH, h: GAME_HEIGHT }; + } + + stepGrid(dt, continuous) { + this.gridAcc = Math.min(this.gridAcc + dt, STEP_MS * 4); + while (this.gridAcc >= STEP_MS) { + this.gridAcc -= STEP_MS; + continuous(); + this.grid.update(); + } + } + + // ── Title screen ────────────────────────────────────────────────────────── + + showTitle() { + this.mode = 'title'; + this.input.setDefaultCursor('default'); + this.cameras.main.setScroll(TITLE_SCROLL_X, TITLE_SCROLL_Y); + + const view = this.viewRect(); + const decoTypes = ['wanderer', 'grunt', 'weaver', 'splitter', 'rocket', 'proton', 'mini', 'wanderer', 'grunt', 'weaver', 'splitter', 'rocket', 'wanderer', 'grunt']; + this.titleDeco = decoTypes.map((type) => this.makeDeco(type, view)); + this.titleHole = { a: Math.random() * Math.PI * 2, x: 0, y: 0, spin: 0 }; + + const ui = this.add.container(0, 0).setDepth(40); + this.titleUi = ui; + this.cardG = this.add.graphics(); + ui.add(this.cardG); + + const tag = this.add.text(GAME_WIDTH / 2, 420, 'A N E O N A R E N A S H O O T E R', { + fontFamily: UI_FONT, fontSize: '22px', color: '#8fa4ff', + }).setOrigin(0.5); + ui.add(tag); + + this.cards = []; + ['evolved', 'pacifism'].forEach((mode, idx) => { + const cx = GAME_WIDTH / 2 + (idx === 0 ? -300 : 300); + const cy = 590; + const desc = this.add.text(cx, cy + 4, MODE_INFO[mode].desc, { + fontFamily: UI_FONT, fontSize: '20px', color: '#c3cdf5', align: 'center', + wordWrap: { width: 470 }, lineSpacing: 4, + }).setOrigin(0.5); + const zone = this.add.zone(cx, cy, 540, 220).setInteractive({ useHandCursor: true }); + zone.on('pointerover', () => this.selectCard(idx)); + zone.on('pointerdown', () => this.startRun(mode)); + ui.add([desc, zone]); + this.cards.push({ mode, cx, cy, best: Number(readStore(bestKey(mode), 0)) || 0 }); + }); + + const guideY = 840; + GUIDE.forEach((type, i) => { + const x = GAME_WIDTH / 2 + (i - (GUIDE.length - 1) / 2) * 190; + const color = '#' + lighten(ENEMY_COLOR[type], 0.25).toString(16).padStart(6, '0'); + ui.add(this.add.text(x, guideY + 48, ENEMY_TYPES[type].name, { + fontFamily: UI_FONT, fontSize: '17px', color, + }).setOrigin(0.5)); + const note = type === 'snake' ? 'HEAD ONLY' : (type === 'blackhole' ? 'SHOOT TO WAKE' : `${ENEMY_TYPES[type].score} PTS`); + ui.add(this.add.text(x, guideY + 72, note, { + fontFamily: UI_FONT, fontSize: '14px', color: '#7f8fc8', + }).setOrigin(0.5)); + }); + + ui.add(this.add.text(GAME_WIDTH / 2, 972, + 'WASD / ARROWS move MOUSE aim LEFT CLICK / SPACE fire RIGHT CLICK / SHIFT bomb ESC pause', { + fontFamily: UI_FONT, fontSize: '19px', color: '#9aa6d8', + }).setOrigin(0.5)); + + const menu = new Button(this, 150, 1032, 'Menu', () => this.scene.start('GameMenu'), + { width: 200, height: 50, fontSize: 22, variant: 'ghost' }); + this.glowBtn = new Button(this, GAME_WIDTH - 170, 1032, this.glowLabel(), () => this.cycleGlow(), + { width: 240, height: 50, fontSize: 22, variant: 'ghost' }); + ui.add([menu, this.glowBtn]); + this.routeCameras(); + } + + glowLabel() { return `Glow: ${this.glowLevel[0].toUpperCase()}${this.glowLevel.slice(1)}`; } + + cycleGlow() { + const i = GLOW_ORDER.indexOf(this.glowLevel); + this.glowLevel = GLOW_ORDER[(i + 1) % GLOW_ORDER.length]; + writeStore(GLOW_KEY, this.glowLevel); + this.bloom.setLevel(this.glowLevel); + this.glowBtn?.text?.setText(this.glowLabel()); + this.pauseGlowBtn?.text?.setText(this.glowLabel()); + this.sfx(SFX.UI_PICK, { volume: 0.5 }); + } + + selectCard(idx) { + if (this.selectedCard === idx) return; + this.selectedCard = idx; + this.sfx(SFX.UI_PICK, { volume: 0.4, gap: 60 }); + } + + makeDeco(type, view) { + const a = Math.random() * Math.PI * 2; + const sp = 40 + Math.random() * 70; + return { + type, radius: ENEMY_TYPES[type].radius, + x: view.x + 80 + Math.random() * (view.w - 160), + y: view.y + 80 + Math.random() * (view.h - 160), + vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + spin: Math.random() * 6, heading: a, age: Math.random() * 5000, + spawnMs: 600, spawnTotal: 600, + }; + } + + destroyTitle() { + this.titleUi?.destroy(true); + this.titleUi = null; + this.glowBtn = null; + this.cards = []; + this.titleDeco = []; + this.titleHole = null; + this.titleG.clear(); + } + + updateTitle(dt) { + const view = this.viewRect(); + const t = this.time.now; + + // Drifting demo enemies; one pops every so often and warps back in. + for (const d of this.titleDeco) { + d.age += dt; + d.spin += dt * 0.003; + if (d.spawnMs > 0) d.spawnMs -= dt; + d.x += d.vx * dt / 1000; + d.y += d.vy * dt / 1000; + if (d.x < view.x + 40 || d.x > view.x + view.w - 40) d.vx = -d.vx; + if (d.y < view.y + 40 || d.y > view.y + view.h - 40) d.vy = -d.vy; + d.heading = Math.atan2(d.vy, d.vx); + } + this.decoPopMs -= dt; + if (this.decoPopMs <= 0 && this.titleDeco.length) { + this.decoPopMs = 1100 + Math.random() * 1300; + const i = Math.floor(Math.random() * this.titleDeco.length); + const d = this.titleDeco[i]; + this.burst(d.x, d.y, ENEMY_COLOR[d.type], 50); + this.grid.applyExplosiveForce(14, d.x, d.y, 160); + this.titleDeco[i] = this.makeDeco(d.type, view); + } + this.fireworkMs -= dt; + if (this.fireworkMs <= 0) { + this.fireworkMs = 700 + Math.random() * 900; + const colors = Object.values(ENEMY_COLOR); + const c = colors[Math.floor(Math.random() * colors.length)]; + const x = view.x + 100 + Math.random() * (view.w - 200); + const y = view.y + 100 + Math.random() * (view.h - 200); + this.fx.explosion(x, y, c, { count: 90, speed: 1100, life: 1300, color2: lighten(c, 0.6) }); + this.fx.ring(x, y, 160, 600, c, 2); + this.grid.applyExplosiveForce(22, x, y, 220); + } + + // A lone black hole circling the arena, warping the grid as it goes. + const hole = this.titleHole; + hole.a += dt * 0.00012; + hole.spin += dt * 0.0025; + hole.x = ARENA_W / 2 + Math.cos(hole.a) * 760; + hole.y = ARENA_H / 2 + Math.sin(hole.a * 1.7) * 330; + + this.stepGrid(dt, () => { + this.grid.applyImplosiveForce(3.6 + Math.sin(t * 0.004) * 1.2, hole.x, hole.y, 250); + }); + this.fx.update(dt, [{ x: hole.x, y: hole.y, r: 26 }]); + + this.gridG.clear(); + this.grid.draw(this.gridG, view, GRID_STYLE); + this.drawBorder(dt); + this.fxG.clear(); + this.fx.draw(this.fxG, view); + for (const g of [this.geomG, this.gateG, this.bulletG, this.playerG, this.popupG, this.hudG, this.bannerG]) g.clear(); + + // Demo enemies + the field guide icons share one batched draw per type. + const eg = this.enemyG; const hg = this.holeG; + eg.clear(); hg.clear(); + const batches = new Map(); + const push = (type, alpha, fn) => { + const key = `${type}|${alpha}`; + let b = batches.get(key); + if (!b) { b = { type, alpha, polys: [] }; batches.set(key, b); } + fn(b.polys); + }; + for (const d of this.titleDeco) { + const f = d.spawnMs > 0 ? 1 - d.spawnMs / d.spawnTotal : 1; + const s = 1 + (1 - f) * 1.4; + push(d.type, f < 1 ? 0.35 : 0.55, (out) => enemyShape(out, d, d.x, d.y, s, t)); + } + const guideY = 840 + TITLE_SCROLL_Y; + GUIDE.forEach((type, i) => { + const x = GAME_WIDTH / 2 + (i - (GUIDE.length - 1) / 2) * 190 + TITLE_SCROLL_X; + const fake = { type, radius: ENEMY_TYPES[type].radius, spin: t * 0.002 + i, age: t, heading: -Math.PI / 2 + Math.sin(t * 0.002 + i) * 0.4, vx: 0, vy: -1 }; + if (type === 'blackhole') { + this.drawBlackHole(hg, eg, { ...fake, active: true, absorbed: 0, radius: 20 }, x, guideY, t); + return; + } + if (type === 'snake') { + const segs = []; + for (let k = 1; k <= 5; k += 1) ngon(segs, x + Math.sin(t * 0.004 + k * 0.8) * 6, guideY + k * 9, 6 - k * 0.6, 4, Math.PI / 4); + this.strokeBatch(eg, segs, SNAKE_TAIL, 0.9, 6, 1.8); + } + push(type, 1, (out) => enemyShape(out, fake, x, guideY - (type === 'snake' ? 8 : 0), 1, t)); + }); + for (const b of batches.values()) this.strokeBatch(eg, b.polys, ENEMY_COLOR[b.type], b.alpha, 8, 2.2); + this.drawBlackHole(hg, eg, { type: 'blackhole', radius: 26, spin: hole.spin, active: true, absorbed: 0, age: t }, hole.x, hole.y, t); + + this.drawTitleText(t); + this.drawCards(t); + } + + drawTitleText(t) { + const g = this.titleG; + g.clear(); + const cx = GAME_WIDTH / 2 + TITLE_SCROLL_X; + const y1 = 190 + TITLE_SCROLL_Y; const y2 = 318 + TITLE_SCROLL_Y; + const skew = 0.16; + // Chromatic split that occasionally glitches wider. + const glitch = Math.sin(t * 0.0021) > 0.985 ? 10 : 0; + const off = 4 + Math.sin(t * 0.003) * 1.5 + glitch; + drawVectorText(g, 'GEOMETRY', cx - off, y1, 17, 0xff2fd0, { lineWidth: 3, glow: false, alpha: 0.6, skew, spacing: 0.5 }); + drawVectorText(g, 'GEOMETRY', cx + off, y1, 17, 0x29e7ff, { lineWidth: 3, glow: false, alpha: 0.6, skew, spacing: 0.5 }); + drawVectorText(g, 'GEOMETRY', cx, y1, 17, 0xffffff, { + lineWidth: 4.5, glowWidth: 18, glowColor: 0x6ff0ff, glowAlpha: 0.28, skew, spacing: 0.5, + }); + const pulse = 0.85 + 0.15 * Math.sin(t * 0.004); + drawVectorText(g, 'WARS', cx - off * 1.3, y2, 25, 0xff3b3b, { lineWidth: 4, glow: false, alpha: 0.55, skew, spacing: 0.7 }); + drawVectorText(g, 'WARS', cx + off * 1.3, y2, 25, 0xffd23a, { lineWidth: 4, glow: false, alpha: 0.55, skew, spacing: 0.7 }); + drawVectorText(g, 'WARS', cx, y2, 25, 0xfff2d0, { + lineWidth: 6, glowWidth: 26, glowColor: 0xff8a1a, glowAlpha: 0.3 * pulse, skew, spacing: 0.7, + }); + } + + drawCards(t) { + const g = this.cardG; + g.clear(); + if (!this.cards) return; + for (let i = 0; i < this.cards.length; i += 1) { + const card = this.cards[i]; + const info = MODE_INFO[card.mode]; + const sel = i === this.selectedCard; + const w = 540; const h = 220; + const x = card.cx - w / 2; const y = card.cy - h / 2; + const pulse = 0.5 + 0.5 * Math.sin(t * 0.006); + g.fillStyle(0x060a1e, sel ? 0.86 : 0.7); + g.fillRoundedRect(x, y, w, h, 16); + g.lineStyle(sel ? 12 : 8, info.color, sel ? 0.16 + 0.1 * pulse : 0.07); + g.strokeRoundedRect(x, y, w, h, 16); + g.lineStyle(sel ? 3 : 2, info.color, sel ? 1 : 0.45); + g.strokeRoundedRect(x, y, w, h, 16); + drawVectorText(g, info.title, card.cx, y + 42, 5, sel ? 0xffffff : lighten(info.color, 0.3), { + lineWidth: 3, glowWidth: 12, glowColor: info.color, glowAlpha: sel ? 0.35 : 0.15, skew: 0.12, spacing: 0.5, + }); + const best = card.best > 0 ? `BEST ${formatScore(card.best)}` : 'NO SCORE YET'; + drawVectorText(g, best, card.cx, y + h - 32, 2.2, card.best > 0 ? C.gold : 0x6c78b0, { + lineWidth: 1.8, glowWidth: 5, glowAlpha: 0.2, spacing: 0.4, + }); + if (sel) { + const ax = x - 24 - pulse * 6; + g.fillStyle(info.color, 0.9); + g.fillTriangle(ax, card.cy - 12, ax, card.cy + 12, ax + 14, card.cy); + const bx = x + w + 24 + pulse * 6; + g.fillTriangle(bx, card.cy - 12, bx, card.cy + 12, bx - 14, card.cy); + } + } + } + + // ── Running a game ──────────────────────────────────────────────────────── + + startRun(mode) { + if (this.mode === 'playing') return; + this.destroyTitle(); + this.playMode = mode; + this.sim = createGame({ seed: (Date.now() ^ Math.floor(Math.random() * 0xffffffff)) >>> 0, mode }); + this.best = Number(readStore(bestKey(mode), 0)) || 0; + this.mode = 'playing'; + this.popups = []; + this.borderHits = []; + this.fx.clear(); + this.input.setDefaultCursor('none'); + const p = this.sim.player; + this.cameras.main.setScroll( + clamp(p.x - GAME_WIDTH / 2, -CAM_MARGIN, ARENA_W - GAME_WIDTH + CAM_MARGIN), + clamp(p.y - GAME_HEIGHT / 2, -CAM_MARGIN, ARENA_H - GAME_HEIGHT + CAM_MARGIN), + ); + const info = MODE_INFO[mode]; + this.showBanner(info.title, info.color, 1900, mode === 'pacifism' ? 'FLY THROUGH THE GATES' : 'SURVIVE'); + this.fx.ring(p.x, p.y, 8, 700, C.playerGlow, 3, 260); + this.grid.applyImplosiveForce(8, p.x, p.y, 260); + this.sfx(SFX.COUNTDOWN_GO, { volume: 0.7 }); + this.routeCameras(); + } + + restartRun() { + this.scene.restart({ game: this.gameDef, startMode: this.playMode }); + } + + updatePlay(dt) { + const sim = this.sim; + if (!sim) return; + if (this.slowMoMs > 0) { + this.slowMoMs -= dt; + const f = 1 - Math.max(0, this.slowMoMs) / SLOWMO_MS; + this.timeScale = 0.25 + 0.75 * f * f; + } else { + this.timeScale = 1; + } + const sdt = dt * this.timeScale; + + if (this.mode === 'playing') { + setInput(sim, this.readInput()); + const events = step(sim, sdt); + for (const e of events) this.handleEvent(e); + } + + const t = this.time.now; + this.stepGrid(sdt, () => { + for (const b of sim.bullets) this.grid.applyExplosiveForce(2, b.x, b.y, 90); + for (const e of sim.enemies) { + if (e.type !== 'blackhole' || e.spawnMs > 0) continue; + if (e.active) this.grid.applyImplosiveForce(3.4 + Math.sin(t * 0.004 + e.id) * 1.1 + e.absorbed * 0.15, e.x, e.y, 240 + e.absorbed * 8); + else this.grid.applyImplosiveForce(0.5, e.x, e.y, 120); + } + const p = sim.player; + if (p.alive) this.grid.applyDirectedForce(p.vx * 0.0007, p.vy * 0.0007, 0, p.x, p.y, 70); + }); + this.emitExhaust(sdt); + const holes = []; + for (const e of sim.enemies) if (e.type === 'blackhole' && e.active && e.spawnMs <= 0) holes.push({ x: e.x, y: e.y, r: e.radius }); + this.fx.update(sdt, holes); + + if (sim.player.alive) this.updateCamera(dt); + this.drawWorld(dt); + this.drawHud(); + this.drawBanner(dt); + if (this.endPanel) this.drawEndPanel(dt); + } + + updateCamera(dt) { + const cam = this.cameras.main; + const p = this.sim.player; + const tx = clamp(p.x - GAME_WIDTH / 2, -CAM_MARGIN, ARENA_W - GAME_WIDTH + CAM_MARGIN); + const ty = clamp(p.y - GAME_HEIGHT / 2, -CAM_MARGIN, ARENA_H - GAME_HEIGHT + CAM_MARGIN); + const k = 1 - Math.pow(0.002, dt / 1000); + cam.setScroll(cam.scrollX + (tx - cam.scrollX) * k, cam.scrollY + (ty - cam.scrollY) * k); + } + + emitExhaust(dt) { + const p = this.sim.player; + if (!p.alive || dt <= 0) return; + const sp = Math.hypot(p.vx, p.vy); + if (sp < 60) { this.exhaustAcc = 0; return; } + this.exhaustAcc += dt; + const back = p.angle + Math.PI; + const ux = Math.cos(back); const uy = Math.sin(back); + const tx = p.x + ux * 15; const ty = p.y + uy * 15; + const thrust = Math.min(1, sp / TUNE.PLAYER_SPEED); + while (this.exhaustAcc >= 16) { + this.exhaustAcc -= 16; + const wob = Math.sin(this.time.now * 0.02) * 0.6; + const base = 240 * thrust; + const nx = -uy; const ny = ux; + for (const side of [-1, 1]) { + const vx = ux * base + nx * side * wob * 180 + (Math.random() - 0.5) * 30; + const vy = uy * base + ny * side * wob * 180 + (Math.random() - 0.5) * 30; + this.fx.emit(tx, ty, vx, vy, 380 + Math.random() * 120, 0xff4a10, { damp: 0.93 }); + } + this.fx.emit(tx, ty, ux * base * 1.2 + (Math.random() - 0.5) * 40, uy * base * 1.2 + (Math.random() - 0.5) * 40, + 420 + Math.random() * 120, 0xffbb1e, { damp: 0.93 }); + if (Math.random() < 0.5) { + this.fx.emit(tx, ty, ux * base, uy * base, 260, 0xfff2c0, { damp: 0.92, len: 0.6 }); + } + } + } + + burst(x, y, color, count, opts = {}) { + this.fx.explosion(x, y, color, { count, color2: lighten(color, 0.55), ...opts }); + } + + doFlash(alpha, ms) { + this.tweens.killTweensOf(this.flash); + this.flash.setAlpha(alpha); + this.tweens.add({ targets: this.flash, alpha: 0, duration: ms, ease: 'Quad.easeOut' }); + } + + addPopup(x, y, text, color) { + if (this.popups.length >= 45) this.popups.shift(); + this.popups.push({ x, y, text, color, life: 900, max: 900 }); + } + + handleEvent(e) { + const sim = this.sim; + switch (e.type) { + case 'shotFired': { + this.sfx(SFX.LASER_ZAP, { gap: 85, volume: 0.13, detune: 150 }); + const a = e.angle; + for (let i = 0; i < 2; i += 1) { + const da = (Math.random() - 0.5) * 0.6; + this.fx.emit(e.x + Math.cos(a) * 20, e.y + Math.sin(a) * 20, + Math.cos(a + da) * 500, Math.sin(a + da) * 500, 120, C.bullet, { len: 0.6 }); + } + break; + } + case 'enemySpawned': { + const color = ENEMY_COLOR[e.enemyType]; + this.fx.ring(e.x, e.y, 4, 650, color, 2, 48); + this.grid.applyImplosiveForce(1.5, e.x, e.y, 80); + this.sfx(SFX.SCIFI_PLONK, { gap: 180, volume: 0.1 }); + break; + } + case 'enemyKilled': { + const color = ENEMY_COLOR[e.enemyType]; + const mass = e.cause === 'bomb' || e.cause === 'death'; + if (e.cause === 'absorbed') { + this.burst(e.x, e.y, color, 14, { speed: 400, life: 500 }); + break; + } + if (e.enemyType === 'blackhole') { + this.burst(e.x, e.y, color, 180, { speed: 1400, life: 1500, wide: true }); + this.burst(e.x, e.y, 0xffffff, 60, { speed: 900, life: 900 }); + this.fx.ring(e.x, e.y, 320, 600, color, 4); + this.grid.applyExplosiveForce(40, e.x, e.y, 360); + this.cameras.main.shake(220, 0.006); + this.sfx(SFX.SCIFI_EXPLODE, { gap: 60, volume: 0.6 }); + } else { + this.burst(e.x, e.y, color, mass ? 16 : 55, mass ? { speed: 700, life: 800 } : {}); + this.grid.applyExplosiveForce(mass ? 6 : 12, e.x, e.y, mass ? 110 : 150); + if (!mass) this.sfx(SFX.EIGHTBIT_EXPLODE, { gap: 40, volume: 0.3, detune: 300 }); + } + if (e.points > 0) this.addPopup(e.x, e.y, formatScore(e.points), lighten(color, 0.35)); + break; + } + case 'enemyHit': + this.burst(e.x, e.y, 0xff7070, 10, { speed: 500, life: 380 }); + this.grid.applyExplosiveForce(4, e.x, e.y, 100); + this.sfx(SFX.SCIFI_PLINK, { gap: 60, volume: 0.14, detune: 200 }); + break; + case 'bulletWall': + this.fx.wallSparks(e.x, e.y, e.angle, C.bullet, 9); + if (this.borderHits.length > 40) this.borderHits.shift(); + this.borderHits.push({ x: e.x, y: e.y, life: 280 }); + this.grid.applyExplosiveForce(3, e.x, e.y, 80); + break; + case 'bulletDeflect': + this.burst(e.x, e.y, SNAKE_TAIL, 8, { speed: 500, life: 300 }); + this.sfx(SFX.SCIFI_PLINK, { gap: 90, volume: 0.1 }); + break; + case 'geomCollected': + this.multPulse = 1; + this.burst(e.x, e.y, C.geom, 5, { speed: 300, life: 300 }); + playSoundEx(this, SFX.SCIFI_PLINK, { volume: 0.12, detune: Math.min(1200, (e.multiplier % 40) * 30) }); + break; + case 'multiplierMilestone': + this.sfx(SFX.EIGHTBIT_COUNT, { volume: 0.5 }); + if (e.multiplier >= 25) this.showBanner(`×${e.multiplier}`, C.geom, 1100, 'MULTIPLIER'); + break; + case 'weaponUpgrade': + this.showBanner('WEAPON UPGRADE', C.bullet, 1700, e.name); + this.fx.ring(sim.player.x, sim.player.y, 180, 500, C.bullet, 3); + this.sfx(SFX.SCIFI_REVEAL, { volume: 0.55 }); + break; + case 'extraLife': + this.showBanner('EXTRA LIFE', 0x7dffb0, 1700); + this.sfx(SFX.VICTORY_SHORT, { volume: 0.6 }); + break; + case 'extraBomb': + this.showBanner('+1 BOMB', 0x9fd0ff, 1500); + this.sfx(SFX.UI_CHIME, { volume: 0.6 }); + break; + case 'bomb': + this.fx.ring(e.x, e.y, 1500, 900, 0xffffff, 6); + this.fx.ring(e.x, e.y, 1100, 700, 0x5fe8ff, 4); + this.fx.explosion(e.x, e.y, 0xffffff, { count: 260, speed: 1800, life: 1200, color2: 0x8ff0ff, wide: true }); + this.grid.applyExplosiveForce(40, e.x, e.y, 900); + this.grid.applyDirectedForce(0, 0, 150, e.x, e.y, 300); + this.doFlash(0.5, 380); + this.cameras.main.shake(380, 0.012); + this.sfx(SFX.SCIFI_EXPLODE, { volume: 0.8 }); + this.sfx(SFX.SCIFI_WOOSH, { volume: 0.6 }); + break; + case 'playerDied': + this.fx.explosion(e.x, e.y, 0xffffff, { count: 420, speed: 1700, life: 1700, color2: C.playerGlow, wide: true }); + this.fx.explosion(e.x, e.y, 0xffbb1e, { count: 160, speed: 1100, life: 1300, color2: 0xff4a10 }); + this.fx.ring(e.x, e.y, 700, 900, 0xffffff, 5); + this.fx.ring(e.x, e.y, 420, 650, C.playerGlow, 3); + this.grid.applyExplosiveForce(50, e.x, e.y, 700); + this.grid.applyDirectedForce(0, 0, 150, e.x, e.y, 260); + this.doFlash(0.6, 500); + this.cameras.main.shake(520, 0.018); + this.slowMoMs = SLOWMO_MS; + this.sfx(SFX.SCIFI_EXPLODE, { volume: 0.9 }); + this.sfx(SFX.EIGHTBIT_EXPLODE_2, { volume: 0.7 }); + if (e.lives > 0) this.showBanner(e.lives === 1 ? 'LAST SHIP' : `${e.lives} SHIPS LEFT`, C.danger, 1600); + break; + case 'playerRespawned': + this.fx.ring(e.x, e.y, 8, 700, C.playerGlow, 3, 240); + this.grid.applyImplosiveForce(8, e.x, e.y, 240); + this.sfx(SFX.SCIFI_WOOSH, { volume: 0.5 }); + break; + case 'blackHoleActivated': + this.fx.ring(e.x, e.y, 160, 500, ENEMY_COLOR.blackhole, 3); + this.sfx(SFX.ENERGY_HUM, { gap: 300, volume: 0.35 }); + break; + case 'blackHoleAbsorb': + this.grid.applyImplosiveForce(6, e.x, e.y, 200); + break; + case 'blackHoleOverload': + this.burst(e.x, e.y, ENEMY_COLOR.blackhole, 220, { speed: 1500, life: 1400, wide: true }); + this.burst(e.x, e.y, ENEMY_COLOR.proton, 80, { speed: 1000, life: 900 }); + this.fx.ring(e.x, e.y, 520, 700, 0xffffff, 4); + this.grid.applyExplosiveForce(45, e.x, e.y, 520); + this.cameras.main.shake(300, 0.009); + this.addPopup(e.x, e.y - 30, 'OVERLOAD!', ENEMY_COLOR.blackhole); + this.sfx(SFX.SCIFI_EXPLODE, { volume: 0.7 }); + break; + case 'rocketWall': + this.sfx(SFX.SCIFI_WOOSH, { gap: 400, volume: 0.3 }); + break; + case 'gateSpawned': + this.fx.ring(e.x, e.y, 6, 600, C.gate, 2, 110); + break; + case 'gateTriggered': + this.fx.ring(e.x, e.y, e.radius, 520, C.gold, 5); + this.fx.ring(e.x, e.y, e.radius * 0.6, 420, 0xffffff, 3); + this.burst(e.x, e.y, C.gold, 150, { speed: 1300, life: 1100, wide: true }); + this.burst(e.x, e.y, C.gateEnd, 60, { speed: 800, life: 800 }); + this.grid.applyExplosiveForce(45, e.x, e.y, 420); + this.cameras.main.shake(170, 0.005); + this.sfx(SFX.EIGHTBIT_EXPLODE_2, { volume: 0.55 }); + break; + case 'gameOver': + this.onGameOver(e); + break; + default: break; + } + } + + // ── World drawing ───────────────────────────────────────────────────────── + + strokeBatch(g, polys, color, alpha, glowW = 8, coreW = 2.2) { + if (!polys.length || alpha <= 0) return; + for (const [w, a] of [[glowW, 0.22 * alpha], [coreW, alpha]]) { + g.lineStyle(w, color, a); + g.beginPath(); + for (const p of polys) { + g.moveTo(p[0], p[1]); + for (let i = 2; i < p.length; i += 2) g.lineTo(p[i], p[i + 1]); + } + g.strokePath(); + } + } + + drawWorld(dt) { + const view = this.viewRect(); + const sim = this.sim; + const t = this.time.now; + + this.gridG.clear(); + this.grid.draw(this.gridG, view, GRID_STYLE); + this.drawBorder(dt); + this.fxG.clear(); + this.fx.draw(this.fxG, view); + + const vx0 = view.x - 80; const vx1 = view.x + view.w + 80; + const vy0 = view.y - 80; const vy1 = view.y + view.h + 80; + const inView = (x, y) => x > vx0 && x < vx1 && y > vy0 && y < vy1; + + // Geoms — blink out over their last 1.5s. + const gg = this.geomG; + gg.clear(); + const steady = []; const fading = []; + for (const g of sim.geoms) { + if (!inView(g.x, g.y)) continue; + if (g.lifeMs < 1500 && Math.floor(g.lifeMs / 120) % 2 === 0) continue; + ngon(g.lifeMs < 1500 ? fading : steady, g.x, g.y, 7, 4, g.spin); + } + this.strokeBatch(gg, steady, C.geom, 1, 6, 2); + this.strokeBatch(gg, fading, C.geom, 0.55, 6, 2); + + // Gates (pacifism). + const ga = this.gateG; + ga.clear(); + for (const g of sim.gates) { + const [a, b] = gateEndpoints(g); + const fade = g.spawnMs > 0 ? 0.3 + 0.4 * (Math.floor(t / 80) % 2) : 1; + const pulse = 0.75 + 0.25 * Math.sin(t * 0.008 + g.id); + ga.lineStyle(12, C.gold, 0.14 * fade * pulse); + ga.lineBetween(a.x, a.y, b.x, b.y); + ga.lineStyle(3, C.gate, 0.95 * fade * pulse); + ga.lineBetween(a.x, a.y, b.x, b.y); + for (const end of [a, b]) { + ga.lineStyle(7, C.gateEnd, 0.25 * fade); + ga.strokeCircle(end.x, end.y, TUNE.GATE_END_R); + ga.lineStyle(2.4, C.gateEnd, fade); + ga.strokeCircle(end.x, end.y, TUNE.GATE_END_R); + } + } + + // Enemies, batched per (type, alpha). + const eg = this.enemyG; const hg = this.holeG; + eg.clear(); hg.clear(); + const batches = new Map(); + const tail = []; + for (const e of sim.enemies) { + if (!inView(e.x, e.y) && e.type !== 'snake') continue; + if (e.type === 'blackhole') { this.drawBlackHole(hg, eg, e, e.x, e.y, t); continue; } + let s = 1; let alpha = 1; + if (e.spawnMs > 0 && e.spawnTotal > 0) { + const f = 1 - e.spawnMs / e.spawnTotal; + s = 1 + (1 - f) * 1.3; + alpha = Math.floor(t / 70) % 2 === 0 ? 0.65 : 0.3; + } + if (e.type === 'snake') { + const segs = snakeSegments(e); + let px = e.x; let py = e.y; + for (let i = 0; i < segs.length; i += 1) { + const sg = segs[i]; + if (inView(sg.x, sg.y)) ngon(tail, sg.x, sg.y, (11 - i * 0.45) * s, 4, Math.atan2(py - sg.y, px - sg.x) + Math.PI / 4); + px = sg.x; py = sg.y; + } + if (!inView(e.x, e.y)) continue; + } + const key = `${e.type}|${alpha}`; + let b = batches.get(key); + if (!b) { b = { type: e.type, alpha, polys: [] }; batches.set(key, b); } + enemyShape(b.polys, e, e.x, e.y, s, t); + } + this.strokeBatch(eg, tail, SNAKE_TAIL, 0.85, 6, 1.8); + for (const b of batches.values()) this.strokeBatch(eg, b.polys, ENEMY_COLOR[b.type], b.alpha, 8, 2.2); + + // Bullets: hot streaks. + const bg = this.bulletG; + bg.clear(); + if (sim.bullets.length) { + for (const [w, color, a] of [[7, C.bulletGlow, 0.3], [2.6, C.bullet, 1]]) { + bg.lineStyle(w, color, a); + bg.beginPath(); + for (const b of sim.bullets) { + if (!inView(b.x, b.y)) continue; + bg.moveTo(b.x - b.vx * 0.014, b.y - b.vy * 0.014); + bg.lineTo(b.x, b.y); + } + bg.strokePath(); + } + } + + // Player. + const pg = this.playerG; + pg.clear(); + const p = sim.player; + if (p.alive) { + const blink = p.invulnMs > 0 && Math.floor(p.invulnMs / 90) % 2 === 0; + this.drawShip(pg, p.x, p.y, p.angle, p.radius * 1.3, blink ? 0.35 : 1); + if (p.invulnMs > 0) { + pg.lineStyle(2, C.playerGlow, 0.25 + 0.2 * Math.sin(t * 0.02)); + pg.strokeCircle(p.x, p.y, 34); + } + } + + // Score popups. + const pp = this.popupG; + pp.clear(); + for (const pop of this.popups) pop.life -= dt * this.timeScale; + this.popups = this.popups.filter((pop) => pop.life > 0); + for (const pop of this.popups) { + const f = pop.life / pop.max; + drawVectorText(pp, pop.text, pop.x, pop.y - (1 - f) * 34, 1.7, pop.color, { + lineWidth: 1.6, glowWidth: 4, glowAlpha: 0.25, alpha: Math.min(1, f * 2), + }); + } + } + + drawShip(g, x, y, angle, scale, alpha) { + const polys = []; + tpoly(polys, SHIP, x, y, angle, scale); + this.strokeBatch(g, polys, C.player, alpha, 7, 2.4); + g.lineStyle(7, C.playerGlow, 0.3 * alpha); + g.beginPath(); + g.moveTo(polys[0][0], polys[0][1]); + for (let i = 2; i < polys[0].length; i += 2) g.lineTo(polys[0][i], polys[0][i + 1]); + g.strokePath(); + const core = []; + tpoly(core, SHIP_CORE, x, y, angle, scale); + this.strokeBatch(g, core, C.playerGlow, alpha, 5, 2); + } + + drawBlackHole(hg, eg, e, x, y, t) { + const r = e.radius; + const spawning = e.spawnMs > 0; + const color = ENEMY_COLOR.blackhole; + const alpha = spawning ? (Math.floor(t / 70) % 2 === 0 ? 0.6 : 0.3) : 1; + hg.fillStyle(0x000000, 0.9 * alpha); + hg.fillCircle(x, y, r * 1.05); + if (e.active && !spawning) { + const throb = 1 + 0.12 * Math.sin(t * 0.012 + (e.id ?? 0)); + eg.lineStyle(10, color, 0.22); + eg.strokeCircle(x, y, r * throb); + eg.lineStyle(3, color, 1); + eg.strokeCircle(x, y, r * throb); + eg.lineStyle(2, lighten(color, 0.4), 0.5); + eg.strokeCircle(x, y, r * 0.55); + for (let k = 0; k < 3; k += 1) { + const a0 = e.spin * 1.6 + (k / 3) * Math.PI * 2; + eg.lineStyle(6, color, 0.18); + eg.beginPath(); eg.arc(x, y, r * 1.5, a0, a0 + 1.2); eg.strokePath(); + eg.lineStyle(2, color, 0.85); + eg.beginPath(); eg.arc(x, y, r * 1.5, a0, a0 + 1.2); eg.strokePath(); + } + // A slow outward ripple so an active hole visibly "breathes". + const ph = ((t * 0.0012 + (e.id ?? 0) * 0.37) % 1); + eg.lineStyle(2, color, 0.5 * (1 - ph)); + eg.strokeCircle(x, y, r * (1.2 + ph * 1.8)); + } else { + for (let k = 0; k < 8; k += 1) { + const a0 = e.spin + (k / 8) * Math.PI * 2; + eg.lineStyle(5, color, 0.14 * alpha); + eg.beginPath(); eg.arc(x, y, r, a0, a0 + 0.45); eg.strokePath(); + eg.lineStyle(2, color, 0.75 * alpha); + eg.beginPath(); eg.arc(x, y, r, a0, a0 + 0.45); eg.strokePath(); + } + } + } + + drawBorder(dt) { + const g = this.borderG; + g.clear(); + g.lineStyle(12, C.border, 0.14); + g.strokeRect(0, 0, ARENA_W, ARENA_H); + g.lineStyle(3, C.border, 0.85); + g.strokeRect(0, 0, ARENA_W, ARENA_H); + for (const h of this.borderHits) h.life -= dt; + this.borderHits = this.borderHits.filter((h) => h.life > 0); + for (const h of this.borderHits) { + const a = h.life / 280; + const L = 70; + let x0; let y0; let x1; let y1; + if (h.x <= 1 || h.x >= ARENA_W - 1) { x0 = x1 = h.x; y0 = h.y - L; y1 = h.y + L; } + else { y0 = y1 = h.y; x0 = h.x - L; x1 = h.x + L; } + g.lineStyle(10, 0xffffff, 0.2 * a); + g.lineBetween(x0, y0, x1, y1); + g.lineStyle(3, 0xffffff, 0.9 * a); + g.lineBetween(x0, y0, x1, y1); + } + } + + // ── HUD (uiCam, screen space) ───────────────────────────────────────────── + + drawHud() { + const g = this.hudG; + g.clear(); + const s = this.sim; + if (!s || this.mode === 'gameover') return; + this.multPulse *= 0.88; + + drawVectorText(g, 'SCORE', 42, 30, 1.7, C.hudLabel, { align: 'left', lineWidth: 1.6, glowWidth: 4, spacing: 0.6 }); + drawVectorText(g, formatScore(s.score), 42, 66, 5, 0xffffff, { + align: 'left', lineWidth: 3, glowWidth: 11, glowColor: 0x5fe8ff, glowAlpha: 0.3, skew: 0.12, + }); + drawVectorText(g, `×${formatScore(s.multiplier)}`, 42, 114, 3.4 * (1 + 0.22 * this.multPulse), C.geom, { + align: 'left', lineWidth: 2.4, glowWidth: 9, glowAlpha: 0.3, skew: 0.12, + }); + + // Reserve ships (and bombs, in Evolved), centred along the top. `lives` + // counts the ship in play (or the one about to respawn), so reserve = -1. + const lives = Math.max(0, s.lives - 1); + const lifeW = 36; + const lx = GAME_WIDTH / 2 - ((lives - 1) * lifeW) / 2; + for (let i = 0; i < lives; i += 1) this.drawShip(g, lx + i * lifeW, 36, -Math.PI / 2, 12, 0.95); + if (s.mode === 'evolved') { + const bw = 30; + const bx = GAME_WIDTH / 2 - ((s.bombs - 1) * bw) / 2; + for (let i = 0; i < s.bombs; i += 1) { + g.lineStyle(6, 0x5fe8ff, 0.2); g.strokeCircle(bx + i * bw, 76, 9); + g.lineStyle(2, 0x9ff6ff, 1); g.strokeCircle(bx + i * bw, 76, 9); + g.fillStyle(0x9ff6ff, 0.8); g.fillCircle(bx + i * bw, 76, 3); + } + drawVectorText(g, WEAPONS[s.weaponTier].name, 42, 1046, 1.8, C.bullet, { + align: 'left', lineWidth: 1.6, glowWidth: 5, glowAlpha: 0.25, spacing: 0.5, alpha: 0.85, + }); + } else { + drawVectorText(g, `GATES ${s.stats.gates}`, 42, 1046, 1.8, C.gold, { + align: 'left', lineWidth: 1.6, glowWidth: 5, glowAlpha: 0.25, spacing: 0.5, alpha: 0.85, + }); + } + + const best = Math.max(this.best, s.score); + drawVectorText(g, 'HIGH SCORE', GAME_WIDTH - 42, 104, 1.7, C.hudLabel, { align: 'right', lineWidth: 1.6, glowWidth: 4, spacing: 0.6 }); + drawVectorText(g, formatScore(best), GAME_WIDTH - 42, 134, 3, s.score > this.best && this.best > 0 ? C.gold : 0xdfe6ff, { + align: 'right', lineWidth: 2.2, glowWidth: 7, glowAlpha: 0.25, skew: 0.12, + }); + const secs = Math.floor(s.timeMs / 1000); + drawVectorText(g, `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`, GAME_WIDTH - 42, 1046, 1.8, C.hudLabel, { + align: 'right', lineWidth: 1.6, glowWidth: 4, spacing: 0.5, + }); + } + + showBanner(text, color, ms, sub = null) { + this.banner = { text, sub, color, ms, age: 0 }; + } + + drawBanner(dt) { + const g = this.bannerG; + g.clear(); + const b = this.banner; + if (!b || this.mode !== 'playing') return; + b.age += dt; + if (b.age >= b.ms) { this.banner = null; return; } + const f = b.age / b.ms; + const alpha = f < 0.1 ? f / 0.1 : (f > 0.72 ? (1 - f) / 0.28 : 1); + const pop = 1 + 0.3 * Math.max(0, 1 - b.age / 160); + drawVectorText(g, b.text, GAME_WIDTH / 2, 290, 7 * pop, b.color, { + lineWidth: 3.5, glowWidth: 16, glowAlpha: 0.3, alpha, skew: 0.12, spacing: 0.4, + }); + if (b.sub) { + drawVectorText(g, b.sub, GAME_WIDTH / 2, 360, 2.6, 0xdfe6ff, { + lineWidth: 2, glowWidth: 6, glowAlpha: 0.2, alpha: alpha * 0.9, spacing: 0.6, + }); + } + } + + drawCrosshair() { + const g = this.crossG; + g.clear(); + if (this.mode !== 'playing') return; + const ptr = this.input.activePointer; + const t = this.time.now * 0.003; + const x = ptr.x; const y = ptr.y; + for (const [w, a] of [[6, 0.2], [2, 0.95]]) { + g.lineStyle(w, 0x9ff6ff, a); + g.strokeCircle(x, y, 12); + g.beginPath(); + for (let k = 0; k < 4; k += 1) { + const ang = t + k * Math.PI / 2; + g.moveTo(x + Math.cos(ang) * 17, y + Math.sin(ang) * 17); + g.lineTo(x + Math.cos(ang) * 25, y + Math.sin(ang) * 25); + } + g.strokePath(); + } + g.fillStyle(0xffffff, 1); + g.fillCircle(x, y, 2); + } + + // ── Pause ───────────────────────────────────────────────────────────────── + + pauseGame() { + if (this.mode !== 'playing') return; + this.mode = 'paused'; + this.input.setDefaultCursor('default'); + this.crossG.clear(); + this.bannerG.clear(); + this.sfx(SFX.UI_FLIP, { volume: 0.5 }); + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(70); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive()); + const g = this.add.graphics(); + g.fillStyle(0x050816, 0.94); + g.fillRoundedRect(cx - 320, cy - 270, 640, 540, 20); + g.lineStyle(10, 0x5fe8ff, 0.14); + g.strokeRoundedRect(cx - 320, cy - 270, 640, 540, 20); + g.lineStyle(2.5, 0x5fe8ff, 0.9); + g.strokeRoundedRect(cx - 320, cy - 270, 640, 540, 20); + drawVectorText(g, 'PAUSED', cx, cy - 200, 7, 0xffffff, { + lineWidth: 3.5, glowWidth: 14, glowColor: 0x5fe8ff, glowAlpha: 0.3, skew: 0.12, spacing: 0.5, + }); + root.add(g); + const opts = { width: 340, height: 58, fontSize: 24 }; + const resume = new Button(this, cx, cy - 92, 'Resume', () => this.resumeGame(), opts); + const restart = new Button(this, cx, cy - 22, 'Restart', () => this.restartRun(), { ...opts, variant: 'ghost' }); + const title = new Button(this, cx, cy + 48, 'Title Screen', () => this.scene.restart({ game: this.gameDef }), { ...opts, variant: 'ghost' }); + this.pauseGlowBtn = new Button(this, cx, cy + 118, this.glowLabel(), () => this.cycleGlow(), { ...opts, variant: 'ghost' }); + const menu = new Button(this, cx, cy + 188, 'Quit to Menu', () => this.scene.start('GameMenu'), { ...opts, variant: 'ghost' }); + root.add([resume, restart, title, this.pauseGlowBtn, menu]); + this.pauseUi = root; + this.routeCameras(); + } + + resumeGame() { + if (this.mode !== 'paused') return; + this.pauseUi?.destroy(true); + this.pauseUi = null; + this.pauseGlowBtn = null; + this.mode = 'playing'; + this.input.setDefaultCursor('none'); + // Don't let a button click that resumed the game also fire a bomb. + if (this.sim) this.sim.bombLatch = true; + } + + // ── Game over ───────────────────────────────────────────────────────────── + + recordScore(score) { + const key = bestKey(this.playMode); + const prevBest = Number(readStore(key, 0)) || 0; + const newBest = score > prevBest; + if (newBest) writeStore(key, score); + api.post('/history/single-player', { + slug: 'geometrywars', score, opponentScores: [], result: 'loss', + }).catch(() => { /* best effort */ }); + return { prevBest, newBest }; + } + + onGameOver(e) { + this.mode = 'gameover'; + this.input.setDefaultCursor('default'); + this.banner = null; + const { prevBest, newBest } = this.recordScore(e.score); + this.time.delayedCall(700, () => this.showEndPanel(e, prevBest, newBest)); + } + + showEndPanel(e, prevBest, newBest) { + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(70); + root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.5).setInteractive()); + const frame = this.add.graphics(); + const accent = MODE_INFO[this.playMode].color; + frame.fillStyle(0x050816, 0.94); + frame.fillRoundedRect(cx - 430, cy - 320, 860, 640, 22); + frame.lineStyle(12, accent, 0.14); + frame.strokeRoundedRect(cx - 430, cy - 320, 860, 640, 22); + frame.lineStyle(2.5, accent, 0.95); + frame.strokeRoundedRect(cx - 430, cy - 320, 860, 640, 22); + root.add(frame); + const live = this.add.graphics(); + root.add(live); + + const st = e.stats; + const secs = Math.floor(st.timeMs / 1000); + const rows = [ + ['TIME', `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`], + ['KILLS', formatScore(st.kills)], + ['GEOMS', formatScore(st.geoms)], + ['PEAK MULTIPLIER', `×${formatScore(st.peakMult)}`], + this.playMode === 'evolved' ? ['BOMBS USED', String(st.bombsUsed)] : ['GATES', formatScore(st.gates)], + ['MODE', MODE_INFO[this.playMode].title], + ]; + rows.forEach(([label, value], i) => { + const col = i % 2; const row = Math.floor(i / 2); + const x0 = cx - 360 + col * 380; const y = cy + 40 + row * 46; + root.add(this.add.text(x0, y, label, { fontFamily: UI_FONT, fontSize: '20px', color: '#7f8fd8' }).setOrigin(0, 0.5)); + root.add(this.add.text(x0 + 340, y, value, { fontFamily: UI_FONT, fontSize: '24px', color: '#eef2ff' }).setOrigin(1, 0.5)); + }); + + const opts = { width: 240, height: 58, fontSize: 24 }; + const again = new Button(this, cx - 260, cy + 250, 'Play Again', () => this.restartRun(), opts); + const title = new Button(this, cx, cy + 250, 'Title Screen', () => this.scene.restart({ game: this.gameDef }), { ...opts, variant: 'ghost' }); + const menu = new Button(this, cx + 260, cy + 250, 'Menu', () => this.scene.start('GameMenu'), { ...opts, variant: 'ghost' }); + root.add([again, title, menu]); + + this.endPanel = { root, live, score: e.score, shown: 0, age: 0, newBest, prevBest }; + this.sfx(newBest ? SFX.EIGHTBIT_WIN : SFX.CASINO_LOSE, { volume: 0.55 }); + this.routeCameras(); + } + + drawEndPanel(dt) { + const p = this.endPanel; + const g = p.live; + g.clear(); + p.age += dt; + const f = Math.min(1, p.age / 1100); + p.shown = Math.round(p.score * (1 - Math.pow(1 - f, 3))); + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + drawVectorText(g, 'GAME OVER', cx, cy - 250, 8, C.danger, { + lineWidth: 3.5, glowWidth: 16, glowAlpha: 0.3, skew: 0.12, spacing: 0.5, + }); + drawVectorText(g, formatScore(p.shown), cx, cy - 130, 8, 0xffffff, { + lineWidth: 4, glowWidth: 18, glowColor: 0x5fe8ff, glowAlpha: 0.3, skew: 0.12, + }); + if (p.newBest) { + const blink = 0.6 + 0.4 * Math.sin(p.age * 0.01); + drawVectorText(g, 'NEW HIGH SCORE!', cx, cy - 50, 3, C.gold, { + lineWidth: 2.4, glowWidth: 9, glowAlpha: 0.35, alpha: blink, spacing: 0.5, + }); + } else if (p.prevBest > 0) { + drawVectorText(g, `BEST ${formatScore(p.prevBest)}`, cx, cy - 50, 2.4, 0x9aa6d8, { + lineWidth: 2, glowWidth: 6, glowAlpha: 0.2, spacing: 0.5, + }); + } + } +} diff --git a/src/games/geometrywars/GeometryWarsGrid.js b/src/games/geometrywars/GeometryWarsGrid.js new file mode 100644 index 0000000..9c36c0d --- /dev/null +++ b/src/games/geometrywars/GeometryWarsGrid.js @@ -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); } + } +} diff --git a/src/games/geometrywars/GeometryWarsLogic.js b/src/games/geometrywars/GeometryWarsLogic.js new file mode 100644 index 0000000..0c2c257 --- /dev/null +++ b/src/games/geometrywars/GeometryWarsLogic.js @@ -0,0 +1,1071 @@ +// Geometry Wars — headless simulation. Zero Phaser imports so +// tools/verifyGeometryWars.js can drive it in Node. +// +// Fixed-tick accumulator loop (60Hz, MAX_STEPS spiral-of-death guard), same +// pattern as Defender/Total Annihilation. All randomness goes through the +// per-state mulberry32 stream and entity ids come from a per-state counter, +// so a seed + input sequence always replays identically. +// +// Two modes: +// evolved — twin-stick shooting, bombs, 3 lives. Geoms dropped by kills +// raise the multiplier, and the multiplier powers up the gun. +// pacifism — no gun, no bombs, one life. Fly through gates to detonate +// them; the blast takes out every enemy nearby. +// +// The sim only produces state + events. Every visual (particles, the warping +// grid, glow, screen shake) is the scene's job, driven by those events. + +export const STEP_MS = 1000 / 60; +export const MAX_STEPS = 5; + +export const ARENA_W = 2240; +export const ARENA_H = 1280; + +export const MODES = ['evolved', 'pacifism']; + +export const TUNE = { + PLAYER_RADIUS: 16, + PLAYER_SPEED: 560, + PLAYER_ACCEL: 16, // velocity smoothing rate, 1/s + BULLET_SPEED: 1500, + BULLET_RADIUS: 6, + BULLET_LIFE_MS: 1400, + MAX_BULLETS: 260, + START_LIVES: 3, + START_BOMBS: 3, + MAX_LIVES: 9, + MAX_BOMBS: 9, + RESPAWN_MS: 1900, + INVULN_MS: 2000, + BOMB_LULL_MS: 1600, // spawn pause after a bomb + SPAWN_WARMUP_MS: 700, // enemies fade in harmlessly for this long + SPAWN_SAFE_DIST: 340, // nothing spawns closer than this to the player + HIT_SLACK: 0.8, // player-vs-enemy hitboxes shrunk to feel fair + GEOM_LIFE_MS: 7000, + GEOM_MAGNET_R: 170, + GEOM_PICKUP_R: 30, + GEOM_MAX_SPEED: 900, + MAX_ENEMIES: 170, + MAX_GEOMS: 240, + SNAKE_SEGMENTS: 12, + SNAKE_SPACING: 4, // ticks of trail between tail segments + SNAKE_SEG_RADIUS: 10, + WEAVER_DODGE_R: 170, + BLACKHOLE_HP: 14, + BLACKHOLE_PULL_R: 520, + BLACKHOLE_PULL_ENEMY: 300, // px/s at the centre, falling off to 0 at PULL_R + BLACKHOLE_PULL_PLAYER: 230, + BLACKHOLE_BEND: 2600, // px/s² applied to bullets at the centre + BLACKHOLE_OVERLOAD: 12, // absorbed enemies before it bursts into protons + BLACKHOLE_MAX: 3, + GATE_LEN: 170, + GATE_END_R: 9, + GATE_BLAST_R: 300, + DEATH_DIFFICULTY_REWIND: 0.75, + FIRST_LIFE_AT: 100000, + FIRST_BOMB_AT: 200000, + THRESHOLD_GROWTH: 3, +}; + +// Per-type base stats. Points are multiplied by the current multiplier. +export const ENEMY_TYPES = { + wanderer: { radius: 20, speed: 140, score: 25, geoms: 1, name: 'WANDERER' }, + grunt: { radius: 18, speed: 215, score: 50, geoms: 1, name: 'GRUNT' }, + weaver: { radius: 18, speed: 250, score: 100, geoms: 2, name: 'WEAVER' }, + splitter: { radius: 22, speed: 150, score: 100, geoms: 1, name: 'SPLITTER' }, + mini: { radius: 11, speed: 270, score: 50, geoms: 1, name: 'MINI' }, + snake: { radius: 17, speed: 235, score: 150, geoms: 3, name: 'SNAKE' }, + blackhole: { radius: 26, speed: 25, score: 150, geoms: 4, name: 'BLACK HOLE' }, + proton: { radius: 9, speed: 340, score: 10, geoms: 0, name: 'PROTON' }, + rocket: { radius: 15, speed: 380, score: 25, geoms: 1, name: 'ROCKET' }, +}; + +// Gun tiers, unlocked by multiplier (which resets on death, so does the gun). +// `shots` entries: da = angle offset from the aim, off = sideways offset. +export const WEAPONS = [ + { name: 'TWIN STREAM', minMult: 1, intervalMs: 95, + shots: [{ da: 0, off: -8 }, { da: 0, off: 8 }] }, + { name: 'TRIPLE SPREAD', minMult: 10, intervalMs: 100, + shots: [{ da: -0.09, off: 0 }, { da: 0, off: 0 }, { da: 0.09, off: 0 }] }, + { name: 'QUAD CANNON', minMult: 40, intervalMs: 95, + shots: [{ da: -0.16, off: 0 }, { da: -0.03, off: -7 }, { da: 0.03, off: 7 }, { da: 0.16, off: 0 }] }, + { name: 'STARBURST', minMult: 100, intervalMs: 85, + shots: [ + { da: -0.24, off: 0 }, { da: -0.12, off: 0 }, { da: 0, off: 0 }, { da: 0.12, off: 0 }, { da: 0.24, off: 0 }, + { da: Math.PI - 0.18, off: 0 }, { da: Math.PI + 0.18, off: 0 }, + ] }, +]; + +const MULT_MILESTONES = [10, 25, 50, 100, 250, 500, 1000, 2000, 5000]; + +export function weaponTierFor(mult) { + let tier = 0; + for (let i = 0; i < WEAPONS.length; i += 1) if (mult >= WEAPONS[i].minMult) tier = i; + return tier; +} + +// ── Small math helpers ─────────────────────────────────────────────────────── + +function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); } + +function wrapAngle(a) { + while (a > Math.PI) a -= Math.PI * 2; + while (a < -Math.PI) a += Math.PI * 2; + return a; +} + +function turnToward(current, target, maxStep) { + const d = wrapAngle(target - current); + if (Math.abs(d) <= maxStep) return target; + return current + Math.sign(d) * maxStep; +} + +// mulberry32 on the state's own stream. +export function rand(s) { + s.rng = (s.rng + 0x6D2B79F5) | 0; + let t = s.rng; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; +} + +function randRange(s, lo, hi) { return lo + (hi - lo) * rand(s); } + +function pickWeighted(s, entries) { + let total = 0; + for (const [, w] of entries) total += w; + let r = rand(s) * total; + for (const [k, w] of entries) { + r -= w; + if (r <= 0) return k; + } + return entries[entries.length - 1][0]; +} + +function emit(s, ev) { s.events.push(ev); } + +// ── State ──────────────────────────────────────────────────────────────────── + +export function createGame({ seed = 1, mode = 'evolved' } = {}) { + const pac = mode === 'pacifism'; + return { + mode: pac ? 'pacifism' : 'evolved', + rng: (seed >>> 0) || 1, + acc: 0, + tick: 0, + timeMs: 0, + phase: 'playing', // 'playing' | 'over' + player: { + x: ARENA_W / 2, y: ARENA_H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, + radius: TUNE.PLAYER_RADIUS, alive: true, invulnMs: 0, respawnMs: 0, fireCd: 0, + }, + input: { moveX: 0, moveY: 0, aimX: ARENA_W / 2, aimY: ARENA_H / 2 - 200, fire: false, bomb: false }, + bombLatch: false, + bullets: [], + enemies: [], + geoms: [], + gates: [], + score: 0, + multiplier: 1, + lives: pac ? 1 : TUNE.START_LIVES, + bombs: pac ? 0 : TUNE.START_BOMBS, + weaponTier: 0, + nextLifeAt: TUNE.FIRST_LIFE_AT, + nextBombAt: TUNE.FIRST_BOMB_AT, + nextMilestone: 0, + director: { clockMs: 0, spawnCd: 1200, gateCd: 400 }, + stats: { kills: 0, geoms: 0, peakMult: 1, bombsUsed: 0, deaths: 0, gates: 0, timeMs: 0, shots: 0 }, + events: [], + nextId: 1, + }; +} + +export function setInput(s, input) { + Object.assign(s.input, input); +} + +export function weaponOf(s) { return WEAPONS[s.weaponTier]; } + +export function difficultySeconds(s) { return s.director.clockMs / 1000; } + +// Speed multiplier ramps gently with the difficulty clock. +function speedMul(s, type) { + const t = difficultySeconds(s); + let m = 1 + Math.min(0.45, t / 450); + if (s.mode === 'pacifism' && (type === 'grunt' || type === 'wanderer')) m *= 0.72 + Math.min(0.28, t / 400); + return m; +} + +// ── Spawning ───────────────────────────────────────────────────────────────── + +export function spawnEnemy(s, type, x, y, opts = {}) { + if (!opts.force && s.enemies.length >= TUNE.MAX_ENEMIES) return null; + const def = ENEMY_TYPES[type]; + const r = def.radius; + const e = { + id: s.nextId++, + type, + x: clamp(x, r, ARENA_W - r), + y: clamp(y, r, ARENA_H - r), + vx: opts.vx ?? 0, + vy: opts.vy ?? 0, + radius: r, + spawnMs: opts.spawnMs ?? TUNE.SPAWN_WARMUP_MS, + spawnTotal: opts.spawnMs ?? TUNE.SPAWN_WARMUP_MS, + spin: rand(s) * Math.PI * 2, + heading: opts.heading ?? rand(s) * Math.PI * 2, + age: 0, + dead: false, + }; + if (type === 'wanderer') e.turn = 0; + if (type === 'mini') e.orbit = rand(s) * Math.PI * 2; + if (type === 'blackhole') { e.hp = TUNE.BLACKHOLE_HP; e.active = false; e.absorbed = 0; } + if (type === 'snake') { + e.phase = rand(s) * Math.PI * 2; + const len = TUNE.SNAKE_SEGMENTS * TUNE.SNAKE_SPACING + 1; + e.trail = []; + for (let i = 0; i < len; i += 1) e.trail.push({ x: e.x, y: e.y }); + } + if (type === 'rocket') { + const sp = def.speed * speedMul(s, type); + e.vx = Math.cos(e.heading) * sp; + e.vy = Math.sin(e.heading) * sp; + } + s.enemies.push(e); + emit(s, { type: 'enemySpawned', enemyType: type, id: e.id, x: e.x, y: e.y }); + return e; +} + +// A random point at least `minDist` from the player. If random tries keep +// landing close, fall back to the arena corner farthest from the player — +// at least half the arena diagonal away, so always clear of the safe radius. +function farPoint(s, minDist = TUNE.SPAWN_SAFE_DIST, margin = 60) { + const p = s.player; + for (let i = 0; i < 24; i += 1) { + const x = randRange(s, margin, ARENA_W - margin); + const y = randRange(s, margin, ARENA_H - margin); + if (Math.hypot(x - p.x, y - p.y) >= minDist) return { x, y }; + } + return { + x: p.x < ARENA_W / 2 ? ARENA_W - margin : margin, + y: p.y < ARENA_H / 2 ? ARENA_H - margin : margin, + }; +} + +function tooClose(s, x, y) { + return Math.hypot(x - s.player.x, y - s.player.y) < TUNE.SPAWN_SAFE_DIST; +} + +function scatterPool(t) { + const pool = [['wanderer', 3], ['grunt', 4]]; + if (t >= 15) pool.push(['weaver', 2.5]); + if (t >= 30) pool.push(['splitter', 2]); + if (t >= 45) pool.push(['rocket', 1]); + if (t >= 55) pool.push(['snake', 0.8]); + return pool; +} + +function countType(s, type) { + let n = 0; + for (const e of s.enemies) if (!e.dead && e.type === type) n += 1; + return n; +} + +const PATTERN_DELAY = { scatter: 1, corners: 1.4, swarm: 1.3, ring: 1.8, rocketwall: 1.3, blackhole: 0.6, snakes: 1.2 }; + +function pickPattern(s, t) { + const opts = [['scatter', 5]]; + if (t >= 18) opts.push(['corners', 2]); + if (t >= 28) opts.push(['swarm', 2]); + if (t >= 35 && countType(s, 'blackhole') < TUNE.BLACKHOLE_MAX) opts.push(['blackhole', 0.9]); + if (t >= 45) opts.push(['rocketwall', 1.3]); + if (t >= 55) opts.push(['snakes', 1]); + if (t >= 75) opts.push(['ring', 1]); + return pickWeighted(s, opts); +} + +export function spawnIntervalMs(t) { + return clamp(1700 - t * 10, 420, 1700); +} + +export function runPattern(s, pattern) { + const t = difficultySeconds(s); + const p = s.player; + switch (pattern) { + case 'scatter': { + const n = 1 + Math.min(5, Math.floor(t / 30)); + const pool = scatterPool(t); + for (let i = 0; i < n; i += 1) { + const type = pickWeighted(s, pool); + const pt = farPoint(s); + spawnEnemy(s, type, pt.x, pt.y, type === 'rocket' ? { heading: Math.atan2(p.y - pt.y, p.x - pt.x) } : {}); + } + break; + } + case 'corners': { + const types = [['grunt', 3], ['wanderer', 2]]; + if (t >= 15) types.push(['weaver', 1.5]); + const type = pickWeighted(s, types); + const per = 3 + Math.min(8, Math.floor(t / 30)); + const m = 70; + let corners = [[m, m], [ARENA_W - m, m], [m, ARENA_H - m], [ARENA_W - m, ARENA_H - m]]; + if (t < 45) { + const a = Math.floor(rand(s) * 4); + corners = [corners[a], corners[3 - a]]; + } + for (const [cx, cy] of corners) { + for (let i = 0; i < per; i += 1) { + const x = cx + randRange(s, -45, 45); const y = cy + randRange(s, -45, 45); + if (!tooClose(s, x, y)) spawnEnemy(s, type, x, y); + } + } + break; + } + case 'swarm': { + const type = pickWeighted(s, [['grunt', 3], ['weaver', 2], ['splitter', 1.5], ['wanderer', 1.5]]); + const n = 6 + Math.min(18, Math.floor(t / 12)); + const c = farPoint(s, 600, 140); + for (let i = 0; i < n; i += 1) { + const a = rand(s) * Math.PI * 2; const r = Math.sqrt(rand(s)) * 95; + const x = c.x + Math.cos(a) * r; const y = c.y + Math.sin(a) * r; + if (!tooClose(s, x, y)) spawnEnemy(s, type, x, y); + } + break; + } + case 'ring': { + const n = 10 + Math.min(14, Math.floor(t / 25)); + const R = 520; + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2; + const x = p.x + Math.cos(a) * R; const y = p.y + Math.sin(a) * R; + if (x < 30 || y < 30 || x > ARENA_W - 30 || y > ARENA_H - 30) continue; + const type = (t >= 120 && i % 3 === 0) ? 'weaver' : 'grunt'; + spawnEnemy(s, type, x, y, { spawnMs: 1100 }); + } + break; + } + case 'rocketwall': { + const n = 8 + Math.min(10, Math.floor(t / 30)); + // The wall farthest from the player, so the line has room to read. + const walls = [ + { d: p.x, wall: 'left' }, { d: ARENA_W - p.x, wall: 'right' }, + { d: p.y, wall: 'top' }, { d: ARENA_H - p.y, wall: 'bottom' }, + ].sort((a, b) => b.d - a.d); + const wall = walls[Math.floor(rand(s) * 2)].wall; + for (let i = 0; i < n; i += 1) { + const f = (i + 0.5) / n; + let x; let y; let heading; + if (wall === 'left') { x = 40; y = f * ARENA_H; heading = 0; } + else if (wall === 'right') { x = ARENA_W - 40; y = f * ARENA_H; heading = Math.PI; } + else if (wall === 'top') { x = f * ARENA_W; y = 40; heading = Math.PI / 2; } + else { x = f * ARENA_W; y = ARENA_H - 40; heading = -Math.PI / 2; } + if (tooClose(s, x, y)) continue; + spawnEnemy(s, 'rocket', x, y, { heading, spawnMs: 850 }); + } + emit(s, { type: 'rocketWall', wall }); + break; + } + case 'blackhole': { + const n = Math.min(t >= 120 ? 2 : 1, TUNE.BLACKHOLE_MAX - countType(s, 'blackhole')); + for (let i = 0; i < n; i += 1) { + let pt = null; + for (let k = 0; k < 10; k += 1) { + const c = farPoint(s, 500, 160); + const clear = s.enemies.every((e) => e.type !== 'blackhole' || Math.hypot(e.x - c.x, e.y - c.y) > 350); + if (clear) { pt = c; break; } + } + if (pt) spawnEnemy(s, 'blackhole', pt.x, pt.y, { spawnMs: 1000 }); + } + break; + } + case 'snakes': { + const n = 2 + Math.min(3, Math.floor(t / 90)); + for (let i = 0; i < n; i += 1) { + const pt = farPoint(s, 500, 80); + spawnEnemy(s, 'snake', pt.x, pt.y, { heading: Math.atan2(p.y - pt.y, p.x - pt.x) }); + } + break; + } + default: break; + } +} + +function spawnGate(s) { + const len = TUNE.GATE_LEN; + const m = len / 2 + 30; + let pt = null; + for (let i = 0; i < 12; i += 1) { + const c = farPoint(s, 380, m); + const clear = s.gates.every((g) => Math.hypot(g.x - c.x, g.y - c.y) > 260); + if (clear) { pt = c; break; } + } + if (!pt) return null; + const driftA = rand(s) * Math.PI * 2; + const g = { + id: s.nextId++, + x: pt.x, y: pt.y, + vx: Math.cos(driftA) * 45, vy: Math.sin(driftA) * 45, + angle: rand(s) * Math.PI, + spinRate: randRange(s, -0.7, 0.7), + len, + spawnMs: 600, + dead: false, + }; + s.gates.push(g); + emit(s, { type: 'gateSpawned', id: g.id, x: g.x, y: g.y }); + return g; +} + +function runDirector(s) { + const d = s.director; + d.spawnCd -= STEP_MS; + const t = difficultySeconds(s); + + if (s.mode === 'pacifism') { + d.gateCd -= STEP_MS; + const gateTarget = 4 + Math.min(5, Math.floor(t / 20)); + if (d.gateCd <= 0 && s.gates.length < gateTarget) { + spawnGate(s); + d.gateCd = 1400; + } + if (d.spawnCd <= 0) { + const n = 4 + Math.min(18, Math.floor(t / 10)); + const type = rand(s) < Math.max(0.08, 0.35 - t / 90) ? 'wanderer' : 'grunt'; + const m = 70; + const spots = [ + [m, m], [ARENA_W - m, m], [m, ARENA_H - m], [ARENA_W - m, ARENA_H - m], + [ARENA_W / 2, m], [ARENA_W / 2, ARENA_H - m], [m, ARENA_H / 2], [ARENA_W - m, ARENA_H / 2], + ].filter(([x, y]) => !tooClose(s, x, y)); + const [cx, cy] = spots[Math.floor(rand(s) * spots.length)] ?? [m, m]; + for (let i = 0; i < n; i += 1) { + const x = cx + randRange(s, -60, 60); const y = cy + randRange(s, -60, 60); + if (!tooClose(s, x, y)) spawnEnemy(s, type, x, y); + } + d.spawnCd = clamp(2800 - t * 12, 800, 2800); + } + return; + } + + if (d.spawnCd > 0) return; + const pattern = t < 4 ? 'scatter' : pickPattern(s, t); + runPattern(s, pattern); + d.spawnCd = spawnIntervalMs(t) * (PATTERN_DELAY[pattern] ?? 1); +} + +// ── Kills, geoms, scoring ──────────────────────────────────────────────────── + +function dropGeoms(s, x, y, n) { + for (let i = 0; i < n; i += 1) { + if (s.geoms.length >= TUNE.MAX_GEOMS) s.geoms.shift(); + const a = rand(s) * Math.PI * 2; const sp = randRange(s, 40, 160); + s.geoms.push({ + id: s.nextId++, x, y, vx: Math.cos(a) * sp, vy: Math.sin(a) * sp, + lifeMs: TUNE.GEOM_LIFE_MS, spin: rand(s) * Math.PI * 2, dead: false, + }); + } +} + +// cause: 'bullet' | 'gate' (rewarded), 'bomb' | 'death' | 'absorbed' (not). +export function killEnemy(s, e, cause) { + if (e.dead) return 0; + e.dead = true; + const def = ENEMY_TYPES[e.type]; + let points = 0; + if (cause === 'bullet' || cause === 'gate') { + let base = def.score; + let geoms = def.geoms; + if (e.type === 'blackhole') { base += 50 * e.absorbed; geoms += Math.floor(e.absorbed / 2); } + points = base * s.multiplier; + s.score += points; + s.stats.kills += 1; + dropGeoms(s, e.x, e.y, geoms); + if (e.type === 'splitter') { + for (let i = 0; i < 3; i += 1) { + const a = e.spin + (i / 3) * Math.PI * 2; + spawnEnemy(s, 'mini', e.x + Math.cos(a) * 14, e.y + Math.sin(a) * 14, { + spawnMs: 0, vx: Math.cos(a) * 260, vy: Math.sin(a) * 260, force: true, + }); + } + } + } + emit(s, { + type: 'enemyKilled', enemyType: e.type, id: e.id, x: e.x, y: e.y, + points, cause, radius: e.radius, absorbed: e.absorbed ?? 0, + }); + return points; +} + +function collectGeom(s, g) { + g.dead = true; + s.multiplier += 1; + s.stats.geoms += 1; + if (s.multiplier > s.stats.peakMult) s.stats.peakMult = s.multiplier; + emit(s, { type: 'geomCollected', x: g.x, y: g.y, multiplier: s.multiplier }); + while (s.nextMilestone < MULT_MILESTONES.length && s.multiplier >= MULT_MILESTONES[s.nextMilestone]) { + emit(s, { type: 'multiplierMilestone', multiplier: MULT_MILESTONES[s.nextMilestone] }); + s.nextMilestone += 1; + } + if (s.mode === 'evolved') { + const tier = weaponTierFor(s.multiplier); + if (tier > s.weaponTier) { + s.weaponTier = tier; + emit(s, { type: 'weaponUpgrade', tier, name: WEAPONS[tier].name }); + } + } +} + +function checkThresholds(s) { + if (s.mode !== 'evolved') return; + while (s.score >= s.nextLifeAt) { + s.nextLifeAt = Math.round(s.nextLifeAt * TUNE.THRESHOLD_GROWTH); + if (s.lives < TUNE.MAX_LIVES) { + s.lives += 1; + emit(s, { type: 'extraLife', lives: s.lives }); + } + } + while (s.score >= s.nextBombAt) { + s.nextBombAt = Math.round(s.nextBombAt * TUNE.THRESHOLD_GROWTH); + if (s.bombs < TUNE.MAX_BOMBS) { + s.bombs += 1; + emit(s, { type: 'extraBomb', bombs: s.bombs }); + } + } +} + +// ── Player ─────────────────────────────────────────────────────────────────── + +function fireVolley(s) { + const p = s.player; + const ax = s.input.aimX - p.x; const ay = s.input.aimY - p.y; + const base = (ax * ax + ay * ay > 16) ? Math.atan2(ay, ax) : p.angle; + const weapon = WEAPONS[s.weaponTier]; + const cos = Math.cos(base); const sin = Math.sin(base); + for (const shot of weapon.shots) { + const a = base + shot.da + (rand(s) - 0.5) * 0.03; + if (s.bullets.length >= TUNE.MAX_BULLETS) s.bullets.shift(); + s.bullets.push({ + id: s.nextId++, + x: p.x + cos * 18 - sin * shot.off, + y: p.y + sin * 18 + cos * shot.off, + vx: Math.cos(a) * TUNE.BULLET_SPEED, + vy: Math.sin(a) * TUNE.BULLET_SPEED, + lifeMs: TUNE.BULLET_LIFE_MS, + dead: false, + }); + } + s.stats.shots += 1; + emit(s, { type: 'shotFired', x: p.x, y: p.y, angle: base, tier: s.weaponTier }); +} + +function detonateBomb(s) { + if (s.mode !== 'evolved' || s.bombs <= 0 || !s.player.alive) return false; + s.bombs -= 1; + s.stats.bombsUsed += 1; + emit(s, { type: 'bomb', x: s.player.x, y: s.player.y, bombs: s.bombs }); + for (const e of s.enemies) killEnemy(s, e, 'bomb'); + s.director.spawnCd = Math.max(s.director.spawnCd, TUNE.BOMB_LULL_MS); + s.player.invulnMs = Math.max(s.player.invulnMs, 400); + return true; +} + +function killPlayer(s, cause) { + const p = s.player; + if (!p.alive) return; + p.alive = false; + p.vx = 0; p.vy = 0; + p.respawnMs = TUNE.RESPAWN_MS; + s.lives -= 1; + s.stats.deaths += 1; + emit(s, { type: 'playerDied', x: p.x, y: p.y, cause, lives: s.lives }); + // Classic rule: losing a ship wipes the board (no points), and the + // multiplier and gun reset. + for (const e of s.enemies) killEnemy(s, e, 'death'); + s.geoms = []; + s.bullets = []; + for (const g of s.gates) g.dead = true; + s.multiplier = 1; + s.weaponTier = 0; + s.nextMilestone = 0; + s.director.clockMs *= TUNE.DEATH_DIFFICULTY_REWIND; +} + +function updatePlayer(s, dt) { + const p = s.player; + if (!p.alive) { + p.respawnMs -= STEP_MS; + if (p.respawnMs <= 0) { + if (s.lives <= 0) { + s.phase = 'over'; + emit(s, { type: 'gameOver', score: s.score, mode: s.mode, stats: { ...s.stats } }); + } else { + p.alive = true; + p.x = ARENA_W / 2; p.y = ARENA_H / 2; + p.vx = 0; p.vy = 0; + p.invulnMs = TUNE.INVULN_MS; + p.fireCd = 0; + s.director.spawnCd = 1200; + s.director.gateCd = 300; + emit(s, { type: 'playerRespawned', x: p.x, y: p.y, lives: s.lives }); + } + } + return; + } + + let mx = s.input.moveX || 0; let my = s.input.moveY || 0; + const m = Math.hypot(mx, my); + if (m > 1) { mx /= m; my /= m; } + const k = Math.min(1, TUNE.PLAYER_ACCEL * dt); + p.vx += (mx * TUNE.PLAYER_SPEED - p.vx) * k; + p.vy += (my * TUNE.PLAYER_SPEED - p.vy) * k; + p.x += p.vx * dt; + p.y += p.vy * dt; + const r = p.radius; + if (p.x < r) { p.x = r; p.vx = 0; } + if (p.x > ARENA_W - r) { p.x = ARENA_W - r; p.vx = 0; } + if (p.y < r) { p.y = r; p.vy = 0; } + if (p.y > ARENA_H - r) { p.y = ARENA_H - r; p.vy = 0; } + if (Math.hypot(p.vx, p.vy) > 40) { + p.angle = turnToward(p.angle, Math.atan2(p.vy, p.vx), 14 * dt); + } + if (p.invulnMs > 0) p.invulnMs -= STEP_MS; + + if (p.fireCd > 0) p.fireCd -= STEP_MS; + if (s.mode === 'evolved' && s.input.fire) { + while (p.fireCd <= 0) { + fireVolley(s); + p.fireCd += WEAPONS[s.weaponTier].intervalMs; + } + } else if (p.fireCd < 0) { + p.fireCd = 0; + } + + const wantBomb = !!s.input.bomb; + if (wantBomb && !s.bombLatch) detonateBomb(s); + s.bombLatch = wantBomb; +} + +// ── Enemies ────────────────────────────────────────────────────────────────── + +export function snakeSegments(e) { + const out = []; + for (let i = 1; i <= TUNE.SNAKE_SEGMENTS; i += 1) { + const idx = Math.min(e.trail.length - 1, i * TUNE.SNAKE_SPACING); + out.push(e.trail[idx]); + } + return out; +} + +function updateEnemies(s, dt) { + const p = s.player; + for (const e of s.enemies) { + if (e.dead) continue; + e.age += STEP_MS; + if (e.spawnMs > 0) { e.spawnMs -= STEP_MS; continue; } + const spd = ENEMY_TYPES[e.type].speed * speedMul(s, e.type); + const dx = p.x - e.x; const dy = p.y - e.y; + const d = Math.hypot(dx, dy) || 1; + const chase = p.alive; + + switch (e.type) { + case 'wanderer': { + e.turn = clamp(e.turn + (rand(s) - 0.5) * 7 * dt, -1.6, 1.6); + e.heading += e.turn * dt; + e.vx = Math.cos(e.heading) * spd; + e.vy = Math.sin(e.heading) * spd; + e.spin += 4.5 * dt; + break; + } + case 'grunt': + case 'proton': { + const tx = chase ? (dx / d) * spd : 0; const ty = chase ? (dy / d) * spd : 0; + const k = Math.min(1, (e.type === 'proton' ? 4 : 2.6) * dt); + e.vx += (tx - e.vx) * k; + e.vy += (ty - e.vy) * k; + e.spin += (e.type === 'proton' ? 9 : 3) * dt; + break; + } + case 'weaver': { + let tx = chase ? (dx / d) * spd : 0; let ty = chase ? (dy / d) * spd : 0; + let best = null; let bestD = TUNE.WEAVER_DODGE_R * TUNE.WEAVER_DODGE_R; + for (const b of s.bullets) { + if (b.dead) continue; + const bx = e.x - b.x; const by = e.y - b.y; + const dd = bx * bx + by * by; + if (dd < bestD && (b.vx * bx + b.vy * by) > 0) { best = b; bestD = dd; } + } + if (best) { + const bl = Math.hypot(best.vx, best.vy) || 1; + const nx = -best.vy / bl; const ny = best.vx / bl; + const side = (nx * (e.x - best.x) + ny * (e.y - best.y)) >= 0 ? 1 : -1; + tx += nx * side * spd * 1.6; + ty += ny * side * spd * 1.6; + } + const k = Math.min(1, 7 * dt); + e.vx += (tx - e.vx) * k; + e.vy += (ty - e.vy) * k; + e.spin += 2 * dt; + break; + } + case 'splitter': { + const a = Math.atan2(dy, dx) + Math.sin(e.age * 0.002 + e.heading) * 0.8; + const tx = chase ? Math.cos(a) * spd : 0; const ty = chase ? Math.sin(a) * spd : 0; + const k = Math.min(1, 1.8 * dt); + e.vx += (tx - e.vx) * k; + e.vy += (ty - e.vy) * k; + e.spin += 2.2 * dt; + break; + } + case 'mini': { + e.orbit += 7 * dt; + const cx = chase ? (dx / d) * spd * 0.65 : 0; const cy = chase ? (dy / d) * spd * 0.65 : 0; + const tx = cx + Math.cos(e.orbit) * spd * 0.75; + const ty = cy + Math.sin(e.orbit) * spd * 0.75; + const k = Math.min(1, 5 * dt); + e.vx += (tx - e.vx) * k; + e.vy += (ty - e.vy) * k; + e.spin += 6 * dt; + break; + } + case 'snake': { + const want = (chase ? Math.atan2(dy, dx) : e.heading) + Math.sin(e.age * 0.003 + e.phase) * 0.9; + e.heading = turnToward(e.heading, want, 2.6 * dt); + e.vx = Math.cos(e.heading) * spd; + e.vy = Math.sin(e.heading) * spd; + break; + } + case 'blackhole': { + e.vx *= 0.98; e.vy *= 0.98; + e.spin += (e.active ? 2.4 : 0.6) * dt; + break; + } + case 'rocket': + default: + break; + } + + e.x += e.vx * dt; + e.y += e.vy * dt; + + const r = e.radius; + let bounced = false; + if (e.x < r) { e.x = r; e.vx = Math.abs(e.vx); bounced = true; } + if (e.x > ARENA_W - r) { e.x = ARENA_W - r; e.vx = -Math.abs(e.vx); bounced = true; } + if (e.y < r) { e.y = r; e.vy = Math.abs(e.vy); bounced = true; } + if (e.y > ARENA_H - r) { e.y = ARENA_H - r; e.vy = -Math.abs(e.vy); bounced = true; } + if (bounced && (e.type === 'wanderer' || e.type === 'snake' || e.type === 'rocket')) { + e.heading = Math.atan2(e.vy, e.vx); + } + + if (e.type === 'snake') { + e.trail.unshift({ x: e.x, y: e.y }); + e.trail.length = TUNE.SNAKE_SEGMENTS * TUNE.SNAKE_SPACING + 1; + } + } +} + +function activeHoles(s) { + const out = []; + for (const e of s.enemies) if (e.type === 'blackhole' && e.active && !e.dead && e.spawnMs <= 0) out.push(e); + return out; +} + +function updateBlackHoles(s, dt) { + const holes = activeHoles(s); + if (holes.length === 0) return; + const R = TUNE.BLACKHOLE_PULL_R; const R2 = R * R; + const p = s.player; + for (const h of holes) { + if (h.dead) continue; + for (const e of s.enemies) { + if (e === h || e.dead || e.type === 'blackhole' || e.spawnMs > 0) continue; + const dx = h.x - e.x; const dy = h.y - e.y; + const d2 = dx * dx + dy * dy; + if (d2 >= R2) continue; + const d = Math.sqrt(d2) || 1; + if (d < h.radius + e.radius * 0.4) { + killEnemy(s, e, 'absorbed'); + h.absorbed += 1; + h.radius = ENEMY_TYPES.blackhole.radius + h.absorbed * 1.6; + emit(s, { type: 'blackHoleAbsorb', id: h.id, x: h.x, y: h.y, absorbed: h.absorbed }); + continue; + } + const pull = (TUNE.BLACKHOLE_PULL_ENEMY * (1 - d / R) + 30) * dt; + e.x += (dx / d) * pull; + e.y += (dy / d) * pull; + } + if (p.alive) { + const dx = h.x - p.x; const dy = h.y - p.y; + const d2 = dx * dx + dy * dy; + if (d2 < R2) { + const d = Math.sqrt(d2) || 1; + const pull = TUNE.BLACKHOLE_PULL_PLAYER * (1 - d / R) * dt; + p.x += (dx / d) * pull; + p.y += (dy / d) * pull; + } + } + for (const g of s.geoms) { + if (g.dead) continue; + const dx = h.x - g.x; const dy = h.y - g.y; + const d2 = dx * dx + dy * dy; + if (d2 >= R2) continue; + const d = Math.sqrt(d2) || 1; + if (d < h.radius) { g.dead = true; continue; } + g.vx += (dx / d) * 900 * (1 - d / R) * dt; + g.vy += (dy / d) * 900 * (1 - d / R) * dt; + } + if (h.absorbed >= TUNE.BLACKHOLE_OVERLOAD) { + h.dead = true; + const n = 6 + Math.floor(h.absorbed / 2); + emit(s, { type: 'blackHoleOverload', id: h.id, x: h.x, y: h.y, protons: n }); + for (let i = 0; i < n; i += 1) { + const a = (i / n) * Math.PI * 2; + spawnEnemy(s, 'proton', h.x + Math.cos(a) * 20, h.y + Math.sin(a) * 20, { + spawnMs: 250, vx: Math.cos(a) * 420, vy: Math.sin(a) * 420, force: true, + }); + } + } + } +} + +function updateBullets(s, dt) { + const holes = activeHoles(s); + const BR = 300; const BR2 = BR * BR; + for (const b of s.bullets) { + if (b.dead) continue; + for (const h of holes) { + const dx = h.x - b.x; const dy = h.y - b.y; + const d2 = dx * dx + dy * dy; + if (d2 >= BR2) continue; + const d = Math.sqrt(d2) || 1; + const f = TUNE.BLACKHOLE_BEND * (1 - d / BR) * dt; + b.vx += (dx / d) * f; + b.vy += (dy / d) * f; + } + b.x += b.vx * dt; + b.y += b.vy * dt; + b.lifeMs -= STEP_MS; + if (b.x < 0 || b.x > ARENA_W || b.y < 0 || b.y > ARENA_H) { + b.dead = true; + emit(s, { + type: 'bulletWall', + x: clamp(b.x, 0, ARENA_W), y: clamp(b.y, 0, ARENA_H), + angle: Math.atan2(b.vy, b.vx), + }); + } else if (b.lifeMs <= 0) { + b.dead = true; + } + } +} + +function collideBullets(s) { + const rB = TUNE.BULLET_RADIUS; + for (const b of s.bullets) { + if (b.dead) continue; + for (const e of s.enemies) { + if (e.dead || e.spawnMs > 0) continue; + const dx = e.x - b.x; const dy = e.y - b.y; + const rr = e.radius + rB; + if (dx * dx + dy * dy < rr * rr) { + b.dead = true; + if (e.type === 'blackhole') { + if (!e.active) { + e.active = true; + emit(s, { type: 'blackHoleActivated', id: e.id, x: e.x, y: e.y }); + } + e.hp -= 1; + emit(s, { type: 'enemyHit', enemyType: e.type, id: e.id, x: b.x, y: b.y }); + if (e.hp <= 0) killEnemy(s, e, 'bullet'); + } else { + killEnemy(s, e, 'bullet'); + } + break; + } + if (e.type === 'snake') { + const sr = TUNE.SNAKE_SEG_RADIUS + rB; + let deflected = false; + for (const seg of snakeSegments(e)) { + const sx = seg.x - b.x; const sy = seg.y - b.y; + if (sx * sx + sy * sy < sr * sr) { deflected = true; break; } + } + if (deflected) { + b.dead = true; + emit(s, { type: 'bulletDeflect', x: b.x, y: b.y }); + break; + } + } + } + } +} + +const NO_SEPARATE = new Set(['blackhole', 'snake', 'rocket']); + +function separateEnemies(s) { + const list = []; + for (const e of s.enemies) if (!e.dead && e.spawnMs <= 0 && !NO_SEPARATE.has(e.type)) list.push(e); + for (let i = 0; i < list.length; i += 1) { + const a = list[i]; + for (let j = i + 1; j < list.length; j += 1) { + const b = list[j]; + const dx = b.x - a.x; const dy = b.y - a.y; + const rr = (a.radius + b.radius) * 0.85; + const d2 = dx * dx + dy * dy; + if (d2 >= rr * rr || d2 < 1e-6) continue; + const d = Math.sqrt(d2); + const push = (rr - d) * 0.25; + const nx = dx / d; const ny = dy / d; + a.x -= nx * push; a.y -= ny * push; + b.x += nx * push; b.y += ny * push; + } + } +} + +function updateGeoms(s, dt) { + const p = s.player; + const fr = Math.pow(0.94, dt * 60); + const magR2 = TUNE.GEOM_MAGNET_R * TUNE.GEOM_MAGNET_R; + const pick = TUNE.GEOM_PICKUP_R + p.radius; + for (const g of s.geoms) { + if (g.dead) continue; + g.lifeMs -= STEP_MS; + if (g.lifeMs <= 0) { g.dead = true; continue; } + g.spin += 3 * dt; + g.vx *= fr; g.vy *= fr; + if (p.alive) { + const dx = p.x - g.x; const dy = p.y - g.y; + const d2 = dx * dx + dy * dy; + if (d2 < pick * pick) { collectGeom(s, g); continue; } + if (d2 < magR2) { + const d = Math.sqrt(d2) || 1; + g.vx += (dx / d) * 4200 * dt; + g.vy += (dy / d) * 4200 * dt; + const sp = Math.hypot(g.vx, g.vy); + if (sp > TUNE.GEOM_MAX_SPEED) { g.vx *= TUNE.GEOM_MAX_SPEED / sp; g.vy *= TUNE.GEOM_MAX_SPEED / sp; } + } + } + g.x = clamp(g.x + g.vx * dt, 4, ARENA_W - 4); + g.y = clamp(g.y + g.vy * dt, 4, ARENA_H - 4); + } +} + +export function gateEndpoints(g) { + const hx = Math.cos(g.angle) * g.len / 2; const hy = Math.sin(g.angle) * g.len / 2; + return [{ x: g.x - hx, y: g.y - hy }, { x: g.x + hx, y: g.y + hy }]; +} + +function pointSegDist(px, py, ax, ay, bx, by) { + const vx = bx - ax; const vy = by - ay; + const L2 = vx * vx + vy * vy || 1; + const t = clamp(((px - ax) * vx + (py - ay) * vy) / L2, 0, 1); + return Math.hypot(px - (ax + vx * t), py - (ay + vy * t)); +} + +function triggerGate(s, g) { + g.dead = true; + s.stats.gates += 1; + emit(s, { type: 'gateTriggered', id: g.id, x: g.x, y: g.y, angle: g.angle, len: g.len, radius: TUNE.GATE_BLAST_R }); + const R2 = TUNE.GATE_BLAST_R * TUNE.GATE_BLAST_R; + for (const e of s.enemies) { + if (e.dead) continue; + const dx = e.x - g.x; const dy = e.y - g.y; + if (dx * dx + dy * dy < R2) killEnemy(s, e, 'gate'); + } +} + +function updateGates(s, dt) { + const p = s.player; + for (const g of s.gates) { + if (g.dead) continue; + g.angle += g.spinRate * dt; + g.x += g.vx * dt; + g.y += g.vy * dt; + const m = g.len / 2 + 12; + if (g.x < m) { g.x = m; g.vx = Math.abs(g.vx); } + if (g.x > ARENA_W - m) { g.x = ARENA_W - m; g.vx = -Math.abs(g.vx); } + if (g.y < m) { g.y = m; g.vy = Math.abs(g.vy); } + if (g.y > ARENA_H - m) { g.y = ARENA_H - m; g.vy = -Math.abs(g.vy); } + if (g.spawnMs > 0) { g.spawnMs -= STEP_MS; continue; } + if (!p.alive) continue; + // The live (middle) part of the gate is the segment minus its two posts. + const [a, b] = gateEndpoints(g); + const inset = (TUNE.GATE_END_R + 6) / g.len; + const ax = a.x + (b.x - a.x) * inset; const ay = a.y + (b.y - a.y) * inset; + const bx = b.x + (a.x - b.x) * inset; const by = b.y + (a.y - b.y) * inset; + if (pointSegDist(p.x, p.y, ax, ay, bx, by) < p.radius * 0.9) triggerGate(s, g); + } +} + +function collidePlayer(s) { + const p = s.player; + if (!p.alive || p.invulnMs > 0) return; + const slack = TUNE.HIT_SLACK; + for (const e of s.enemies) { + if (e.dead || e.spawnMs > 0) continue; + const rr = (e.radius + p.radius) * slack; + const dx = e.x - p.x; const dy = e.y - p.y; + if (dx * dx + dy * dy < rr * rr) { killPlayer(s, e.type); return; } + if (e.type === 'snake') { + const sr = (TUNE.SNAKE_SEG_RADIUS + p.radius) * slack; + for (const seg of snakeSegments(e)) { + const sx = seg.x - p.x; const sy = seg.y - p.y; + if (sx * sx + sy * sy < sr * sr) { killPlayer(s, 'snake'); return; } + } + } + } + for (const g of s.gates) { + if (g.dead || g.spawnMs > 0) continue; + const rr = TUNE.GATE_END_R + p.radius * 0.7; + for (const end of gateEndpoints(g)) { + const dx = end.x - p.x; const dy = end.y - p.y; + if (dx * dx + dy * dy < rr * rr) { killPlayer(s, 'gate'); return; } + } + } +} + +function compact(s) { + if (s.enemies.some((e) => e.dead)) s.enemies = s.enemies.filter((e) => !e.dead); + if (s.bullets.some((b) => b.dead)) s.bullets = s.bullets.filter((b) => !b.dead); + if (s.geoms.some((g) => g.dead)) s.geoms = s.geoms.filter((g) => !g.dead); + if (s.gates.some((g) => g.dead)) s.gates = s.gates.filter((g) => !g.dead); +} + +// ── Main loop ──────────────────────────────────────────────────────────────── + +function tick(s) { + if (s.phase === 'over') return; + const dt = STEP_MS / 1000; + s.tick += 1; + updatePlayer(s, dt); + if (s.phase === 'over') return; + if (s.player.alive) { + s.timeMs += STEP_MS; + s.stats.timeMs = s.timeMs; + s.director.clockMs += STEP_MS; + runDirector(s); + } + updateEnemies(s, dt); + updateBlackHoles(s, dt); + updateBullets(s, dt); + collideBullets(s); + separateEnemies(s); + updateGeoms(s, dt); + updateGates(s, dt); + collidePlayer(s); + checkThresholds(s); + compact(s); +} + +export function step(s, deltaMs) { + s.acc = Math.min(s.acc + Math.max(0, deltaMs || 0), STEP_MS * MAX_STEPS); + while (s.acc >= STEP_MS - 1e-9) { + s.acc -= STEP_MS; + tick(s); + if (s.phase === 'over') { s.acc = 0; break; } + } + if (s.acc < 0) s.acc = 0; + const events = s.events; + s.events = []; + return events; +} + +// Expose a few internals for tools/verifyGeometryWars.js. +export const _internal = { tick, killPlayer, detonateBomb, triggerGate, collectGeom, spawnGate, runDirector }; diff --git a/src/games/geometrywars/GeometryWarsVectorFont.js b/src/games/geometrywars/GeometryWarsVectorFont.js new file mode 100644 index 0000000..61e5de5 --- /dev/null +++ b/src/games/geometrywars/GeometryWarsVectorFont.js @@ -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, ','); +} diff --git a/src/main.js b/src/main.js index d7f98a5..e9fec4b 100644 --- a/src/main.js +++ b/src/main.js @@ -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, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index 0a95bc7..0d39715 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game', 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 = { diff --git a/src/services/soundtrack.js b/src/services/soundtrack.js index 17cc872..0baadba 100644 --- a/src/services/soundtrack.js +++ b/src/services/soundtrack.js @@ -25,6 +25,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = { coloradodefense: 'arcadedark', tempest: 'arcadedark', defender: 'arcadedark', + geometrywars: 'arcadedark', mastermind: 'hacker', balatro: 'hacker', hexsweeper: 'hacker', diff --git a/tools/verifyGeometryWars.js b/tools/verifyGeometryWars.js new file mode 100644 index 0000000..9956170 --- /dev/null +++ b/tools/verifyGeometryWars.js @@ -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);