feat(peggle): overhaul 7 powers with enhanced mechanics, visuals, and audio

Add character assets (sprites/videos) and sound effects for Aiko, Blackwind,
DV-8-2303, Kage, Nadia, Nicole, and Steve.

Power overhauls:
- Extreme Ball: lasts 2 shots, cascade splits into 4 balls on 2nd bounces
- Cannonball: adds Space Blast-style explosion on green peg trigger
- Beam Up: retargets to a clear-path orange peg on return
- Overclock: lasts 2 shots, grants free ball every 6 pegs, pitch-rising hum
- Laser Sweep: follow-up sweep on random orange peg after 1s delay
- Shadow Slash: follow-up slash on random orange peg after 1s delay
- Cosmic Bloom: adds small Space Blast around green peg
- Rewind: glitch effect, portal opens targeting clear orange peg, refunds on drain

Add visual effects (shockwave rings, portal animation, glitch bars, split
particles), happy reaction dedup per ball, and comprehensive verification
tests for all new mechanics.
This commit is contained in:
Brian Fertig 2026-07-13 17:50:24 -06:00
parent a897058b71
commit dadf8c28dc
15 changed files with 967 additions and 5 deletions

View File

@ -80,7 +80,7 @@ server-side, so hosting is entirely stateless.
## The game catalog
77 games are registered in `src/data/gamesRegistry.js` and grouped into five
82 games are registered in `src/data/gamesRegistry.js` and grouped into six
menu categories:
| Category | Count | Examples |
@ -90,6 +90,7 @@ menu categories:
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble |
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
| **Arcade, Console & PC** | 1 | Colorado Defense |
Each game is a self-contained `Phaser.Scene` (plus its own logic/AI/data helper
modules) under `src/games/<slug>/`. Games are single-player against 07
@ -223,7 +224,7 @@ drop-in spritesheets on top, so art can be added without refactoring scenes.
registerGame({
slug: 'cribbage',
name: 'Cribbage',
category: 'cards', // tabletop | cards | casino | word | logic
category: 'cards', // tabletop | cards | casino | word | logic | arcade-console-pc
cardGame: true, // uses the shared card-back picker
minPlayers: 2, maxPlayers: 2,
minOpponents: 1, maxOpponents: 1,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

View File

@ -0,0 +1,14 @@
{
"cities": [
"Denver",
"Pueblo",
"Colorado Springs",
"Arvada",
"Ft. Lupton",
"Ft. Collins",
"Limon",
"Glenwood Springs",
"Alamosa",
"Salida"
]
}

View File

@ -108,3 +108,4 @@ registerGame({ slug: 'dungeonboss', name: 'Dungeon Boss', category: 'cards', car
registerGame({ slug: 'swdbg', name: 'Star Wars', category: 'cards', cardGame: true, minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true, iconFrame: 78, defaultPlayfield: 'stars' });
registerGame({ slug: 'balatro', name: 'Balatro', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 79 });
registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 80 });
registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 });

View File

@ -0,0 +1,288 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import {
DEFAULT_CITIES, GROUND_Y, PALETTES, createGame, step, pickFiringBase,
fireInterceptor, lerpPos, explosionRadius,
} from './ColoradoDefenseLogic.js';
const D = { bg: -2, ground: 0, fx: 2, ui: 30, overlay: 60 };
const BEST_KEY = 'coloradodefense-best';
export default class ColoradoDefenseGame extends Phaser.Scene {
constructor() { super('ColoradoDefenseGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'coloradodefense', name: 'Colorado Defense' };
this.overlayUp = false;
this.state = null;
this.C = PALETTES[0];
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) this.music = new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.input.mouse?.disableContextMenu();
this.bgRect = this.add
.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, this.C.bgTop)
.setDepth(D.bg);
this.groundG = this.add.graphics().setDepth(D.ground);
this.fxG = this.add.graphics().setDepth(D.fx);
this.reticleG = this.add.graphics().setDepth(D.fx + 1);
const pool = this.cache.json.get('colorado-defense-cities')?.cities ?? DEFAULT_CITIES;
this.state = createGame(pool, Date.now());
this.cityLabels = new Map();
for (const city of this.state.cities) {
const label = this.add.text(city.x, city.y + 26, city.name, {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5, 0).setDepth(D.ui);
this.cityLabels.set(city.slot, label);
}
this.crt = applyArcadeCRTOverlay(this, { accentTint: this.C.accent, scanlineTint: this.C.trail });
this.events.once('shutdown', () => this.crt.destroy());
this.drawGround();
this.buildHud();
this.bindInput();
}
// ── Input ─────────────────────────────────────────────────────────────────
bindInput() {
this.input.on('pointermove', (p) => {
this.reticleG.clear();
if (this.overlayUp || !this.state || this.state.status !== 'playing') return;
this.reticleG.lineStyle(2, this.C.accent, 0.7);
this.reticleG.strokeCircle(p.x, p.y, 14);
this.reticleG.lineBetween(p.x - 20, p.y, p.x + 20, p.y);
this.reticleG.lineBetween(p.x, p.y - 20, p.x, p.y + 20);
});
this.input.on('pointerdown', (p) => {
if (this.overlayUp || !this.state || this.state.status !== 'playing') return;
const x = Phaser.Math.Clamp(p.x, 0, GAME_WIDTH);
const y = Phaser.Math.Clamp(p.y, 0, GROUND_Y);
const base = pickFiringBase(this.state, x, y);
if (!base) return;
fireInterceptor(this.state, base, x, y);
playSound(this, SFX.SCIFI_LAUNCH);
});
}
// ── Ground (silos + cities) ──────────────────────────────────────────────────
drawGround() {
const g = this.groundG;
g.clear();
const C = this.C;
g.fillStyle(C.ground, 1);
g.fillRect(0, GROUND_Y, GAME_WIDTH, GAME_HEIGHT - GROUND_Y);
g.lineStyle(4, C.accent, 0.6);
g.lineBetween(0, GROUND_Y, GAME_WIDTH, GROUND_Y);
for (const base of this.state.bases) this.drawSilo(g, base);
for (const city of this.state.cities) {
this.drawCity(g, city);
const label = this.cityLabels.get(city.slot);
if (label) {
label.setAlpha(city.alive ? 1 : 0.35);
label.setColor(city.alive ? COLORS.textHex : COLORS.mutedHex);
}
}
}
drawSilo(g, base) {
const { x, y } = base;
if (base.alive) {
g.fillStyle(this.C.accent, 1);
g.fillTriangle(x - 34, y, x + 34, y, x, y - 60);
g.fillStyle(0x0c0f16, 1);
g.fillRect(x - 10, y - 22, 20, 22);
for (let i = 0; i < base.ammo; i += 1) {
g.fillStyle(0xffffff, 0.85);
g.fillCircle(x - 24 + (i % 5) * 12, y - 74 - Math.floor(i / 5) * 12, 3);
}
} else {
g.fillStyle(0x2a2a2a, 1);
g.fillTriangle(x - 34, y, x + 34, y, x, y - 18);
g.lineStyle(3, 0x000000, 0.6);
g.lineBetween(x - 18, y - 22, x + 18, y - 2);
g.lineBetween(x + 18, y - 22, x - 18, y - 2);
}
}
drawCity(g, city) {
const { x, y } = city;
if (city.alive) {
const blocks = [{ w: 20, h: 36 }, { w: 16, h: 56 }, { w: 24, h: 28 }, { w: 18, h: 46 }];
let cx = x - 44;
for (const b of blocks) {
g.fillStyle(this.C.trail, 0.9);
g.fillRect(cx, y - b.h, b.w, b.h);
cx += b.w + 6;
}
} else {
g.fillStyle(0x1a1a1a, 1);
g.fillRect(x - 40, y - 8, 80, 8);
g.fillStyle(0x0a0a0a, 1);
g.fillCircle(x - 20, y - 4, 6);
g.fillCircle(x + 15, y - 3, 5);
}
}
// ── HUD ───────────────────────────────────────────────────────────────────
buildHud() {
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
this.scoreText = this.add.text(40, 30, 'Score: 0', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
}).setDepth(D.ui);
this.waveText = this.add.text(GAME_WIDTH / 2, 30, 'Wave 1', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5, 0).setDepth(D.ui);
this.bestText = this.add.text(GAME_WIDTH - 40, 30, `Best: ${best}`, {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(1, 0).setDepth(D.ui);
this.citiesText = this.add.text(40, 70, 'Cities: 6/6', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setDepth(D.ui);
}
updateHud() {
this.scoreText.setText(`Score: ${this.state.score}`);
this.waveText.setText(`Wave ${this.state.wave}`);
this.citiesText.setText(`Cities: ${this.state.aliveCities().length}/6`);
}
// ── Frame loop ────────────────────────────────────────────────────────────
update(time, delta) {
if (!this.state || this.overlayUp || this.state.status !== 'playing') return;
const events = step(this.state, delta);
for (const e of events) this.handleEvent(e);
this.syncGraphics();
}
handleEvent(e) {
switch (e.type) {
case 'cityLost':
case 'baseLost':
playSound(this, SFX.SCIFI_EXPLODE);
this.crt.pulse(0.6, 220);
this.drawGround();
break;
case 'missileDestroyed':
playSound(this, SFX.LASER_ZAP);
break;
case 'waveComplete':
this.C = PALETTES[e.paletteIdx];
this.crt.setIntensity({ accentTint: this.C.accent, scanlineTint: this.C.trail });
this.bgRect.setFillStyle(this.C.bgTop);
this.drawGround();
playSound(this, SFX.SCIFI_REVEAL);
break;
case 'gameOver':
this.onGameOver(e);
break;
default:
break;
}
}
syncGraphics() {
const g = this.fxG;
g.clear();
for (const m of this.state.enemyMissiles) {
const pos = lerpPos(m);
g.lineStyle(3, this.C.explosion, 0.8);
g.lineBetween(m.fromX, m.fromY, pos.x, pos.y);
g.fillStyle(this.C.explosion, 1);
g.fillCircle(pos.x, pos.y, 5);
}
for (const p of this.state.interceptors) {
const pos = lerpPos(p);
g.lineStyle(3, this.C.accent, 0.9);
g.lineBetween(p.fromX, p.fromY, pos.x, pos.y);
g.fillStyle(0xffffff, 1);
g.fillCircle(pos.x, pos.y, 5);
}
for (const e of this.state.explosions) {
const r = explosionRadius(e);
if (r <= 0) continue;
const color = e.owner === 'player' ? this.C.explosion : 0xff4433;
g.fillStyle(color, 0.55);
g.fillCircle(e.x, e.y, r);
g.lineStyle(2, 0xffffff, 0.8);
g.strokeCircle(e.x, e.y, r);
}
this.updateHud();
}
// ── Game over ─────────────────────────────────────────────────────────────
onGameOver(e) {
this.overlayUp = true;
this.crt.pulse(1.0, 400);
playSound(this, SFX.SCIFI_EXPLODE);
const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0);
const newBest = e.score > prevBest;
if (newBest) localStorage.setItem(BEST_KEY, String(e.score));
api.post('/history/single-player', {
slug: 'coloradodefense', score: e.score, opponentScores: [], result: 'loss',
}).catch(() => { /* best effort */ });
this.time.delayedCall(500, () => this.showGameOverPanel(e, prevBest, newBest));
}
showGameOverPanel(e, prevBest, newBest) {
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
const root = this.add.container(0, 0).setDepth(D.overlay);
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive();
root.add(dim);
const panel = this.add.graphics();
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 380, cy - 260, 760, 520, 22);
panel.lineStyle(3, COLORS.danger, 1);
panel.strokeRoundedRect(cx - 380, cy - 260, 760, 520, 22);
root.add(panel);
root.add(this.add.text(cx, cy - 192, 'ALL CITIES LOST', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '52px', color: COLORS.dangerHex,
}).setOrigin(0.5));
root.add(this.add.text(cx, cy - 130, `You held out through wave ${e.wave}.`, {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5));
const scoreText = this.add.text(cx, cy - 30, '0', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '88px', color: COLORS.goldHex,
}).setOrigin(0.5);
root.add(scoreText);
const counter = { v: 0 };
this.tweens.add({
targets: counter, v: e.score, duration: 900, ease: 'Cubic.easeOut',
onUpdate: () => scoreText.setText(String(Math.round(counter.v))),
});
root.add(this.add.text(cx, cy + 30, 'SCORE', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5));
root.add(this.add.text(cx, cy + 74, newBest ? '★ NEW BEST ★' : (prevBest > 0 ? `Best: ${prevBest}` : ''), {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: newBest ? COLORS.goldHex : COLORS.mutedHex,
}).setOrigin(0.5));
const again = new Button(this, cx - 170, cy + 190, 'Play Again',
() => this.scene.restart({ game: this.gameDef }),
{ width: 280, height: 62, fontSize: 26 });
const menu = new Button(this, cx + 170, cy + 190, 'Menu',
() => this.scene.start('GameMenu'),
{ width: 280, height: 62, fontSize: 26, variant: 'ghost' });
root.add([again, menu]);
}
}

View File

@ -0,0 +1,325 @@
// Pure simulation for Colorado Defense (Missile Command clone). No Phaser
// dependency — fully unit-testable headlessly via tools/verifyColoradoDefense.js.
export const WIDTH = 1920;
export const HEIGHT = 1080;
export const DEFAULT_CITIES = [
'Denver', 'Pueblo', 'Colorado Springs', 'Arvada', 'Ft. Lupton',
'Ft. Collins', 'Limon', 'Glenwood Springs', 'Alamosa', 'Salida',
];
// Authentic classic-arcade ground layout: 3 bases + 6 cities across 9 slots,
// symmetric: Base, City, City, City, Base, City, City, City, Base.
export const GROUND_SLOTS = 9;
export const BASE_SLOTS = [0, 4, 8];
export const CITY_SLOTS = [1, 2, 3, 5, 6, 7];
export const GROUND_Y = HEIGHT - 90;
const SLOT_MARGIN = 120;
export function slotX(slot) {
return SLOT_MARGIN + slot * ((WIDTH - 2 * SLOT_MARGIN) / (GROUND_SLOTS - 1));
}
export function mulberry32(seed) {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function fisherYates(arr, rng) {
for (let i = arr.length - 1; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// Draws 6 town names from the pool (with replacement-by-cycling if the pool
// has fewer than 6 entries, so a hand-edited JSON can never crash the game).
export function pickCities(pool, rng) {
const names = Array.isArray(pool) && pool.length ? pool.slice() : DEFAULT_CITIES.slice();
const shuffled = fisherYates(names, rng);
const picked = [];
for (let i = 0; picked.length < CITY_SLOTS.length; i += 1) {
picked.push(shuffled[i % shuffled.length]);
}
return picked;
}
export const TUNE = {
SPAWN_MS_BASE: 1800, SPAWN_MS_DECAY: 0.92, SPAWN_MS_MIN: 350,
FALL_SPEED_BASE: 90, FALL_SPEED_GROWTH: 1.06, FALL_SPEED_MAX: 340,
WAVE_QUOTA_BASE: 8, WAVE_QUOTA_GROWTH: 1.15, WAVE_QUOTA_MAX: 40,
MIRV_WAVE: 6,
MIRV_CHANCE_BASE: 0.15, MIRV_CHANCE_GROWTH: 0.02, MIRV_CHANCE_MAX: 0.5,
MIRV_CHILDREN: [2, 3],
BASE_AMMO: 10,
BLAST_R_BASE: 90, BLAST_GROW_MS: 350, BLAST_HOLD_MS: 120, BLAST_FADE_MS: 200,
INTERCEPTOR_SPEED: 900,
SCORE_PER_KILL: 25,
SCORE_WAVE_BONUS: 100,
};
export function spawnInterval(wave) {
return Math.max(TUNE.SPAWN_MS_MIN, TUNE.SPAWN_MS_BASE * TUNE.SPAWN_MS_DECAY ** (wave - 1));
}
export function fallSpeed(wave) {
return Math.min(TUNE.FALL_SPEED_MAX, TUNE.FALL_SPEED_BASE * TUNE.FALL_SPEED_GROWTH ** (wave - 1));
}
export function waveQuota(wave) {
return Math.min(TUNE.WAVE_QUOTA_MAX, Math.round(TUNE.WAVE_QUOTA_BASE * TUNE.WAVE_QUOTA_GROWTH ** (wave - 1)));
}
export function mirvChance(wave) {
if (wave < TUNE.MIRV_WAVE) return 0;
return Math.min(TUNE.MIRV_CHANCE_MAX, TUNE.MIRV_CHANCE_BASE + TUNE.MIRV_CHANCE_GROWTH * (wave - TUNE.MIRV_WAVE));
}
// Automatic, level-driven palette progression (distinct from user-facing
// pickers like 2048's) — advances every PALETTE_WAVES waves, clamped at the end.
export const PALETTES = [
{ bgTop: 0x0a0e1a, bgBottom: 0x1a1030, trail: 0x66ffea, explosion: 0xffdd55, accent: 0x00f0ff, ground: 0x141a2e },
{ bgTop: 0x1a0a0e, bgBottom: 0x300a1a, trail: 0xff6bcb, explosion: 0xffaa33, accent: 0xff2bd6, ground: 0x2a0f1a },
{ bgTop: 0x0a1a0e, bgBottom: 0x0a301a, trail: 0x8dff6b, explosion: 0xffee44, accent: 0x45d17a, ground: 0x0f2a18 },
{ bgTop: 0x1a140a, bgBottom: 0x301c0a, trail: 0xffb366, explosion: 0xff5555, accent: 0xff8a3c, ground: 0x2a1c0f },
{ bgTop: 0x120a1a, bgBottom: 0x260a30, trail: 0xc78dff, explosion: 0xff66c4, accent: 0xb84bff, ground: 0x1e0f2a },
];
export const PALETTE_WAVES = 4;
export function paletteIndexForWave(wave) {
return Math.min(PALETTES.length - 1, Math.floor((wave - 1) / PALETTE_WAVES));
}
function lerp(a, b, t) { return a + (b - a) * t; }
// Current x/y for any in-flight entity with fromX/fromY/toX/toY/t fields.
export function lerpPos(entity) {
const t = Math.min(1, entity.t);
return { x: lerp(entity.fromX, entity.toX, t), y: lerp(entity.fromY, entity.toY, t) };
}
export function explosionRadius(e) {
const { BLAST_GROW_MS: G, BLAST_HOLD_MS: H, BLAST_FADE_MS: F } = TUNE;
const t = e.t;
if (t < G) return e.maxR * (t / G);
if (t < G + H) return e.maxR;
if (t < G + H + F) return e.maxR * Math.max(0, 1 - (t - G - H) / F);
return 0;
}
export class Sim {
constructor(cityPool, seed) {
this.rng = mulberry32(seed >>> 0);
this.wave = 1;
this.score = 0;
this.status = 'playing'; // 'playing' | 'gameover'
this.paletteIdx = 0;
this.nextId = 1;
this.events = [];
const names = pickCities(cityPool, this.rng);
this.bases = BASE_SLOTS.map((slot) => (
{ slot, x: slotX(slot), y: GROUND_Y, alive: true, ammo: TUNE.BASE_AMMO }
));
this.cities = CITY_SLOTS.map((slot, i) => (
{ slot, x: slotX(slot), y: GROUND_Y, name: names[i], alive: true }
));
this.enemyMissiles = [];
this.interceptors = [];
this.explosions = [];
this.waveT = 0;
this.waveSpawned = 0;
this.waveQuota = waveQuota(this.wave);
this.spawnT = 0;
}
emit(type, data = {}) { this.events.push({ type, ...data }); }
aliveCities() { return this.cities.filter((c) => c.alive); }
aliveBases() { return this.bases.filter((b) => b.alive); }
// Picks a currently-alive ground slot to target, weighted 2x toward cities.
pickTargetSlot() {
const weighted = [];
for (const c of this.aliveCities()) weighted.push({ kind: 'city', x: c.x, y: c.y, slot: c.slot }, { kind: 'city', x: c.x, y: c.y, slot: c.slot });
for (const b of this.aliveBases()) weighted.push({ kind: 'base', x: b.x, y: b.y, slot: b.slot });
if (!weighted.length) return null;
return weighted[Math.floor(this.rng() * weighted.length)];
}
spawnEnemyMissile(fromOverride) {
const target = this.pickTargetSlot();
if (!target) return null;
const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160);
const fromY = fromOverride ? fromOverride.y : 0;
const dist = Math.hypot(target.x - fromX, target.y - fromY);
const speed = fallSpeed(this.wave);
const dur = Math.max(200, (dist / speed) * 1000);
const kind = fromOverride ? 'single' : (this.rng() < mirvChance(this.wave) ? 'mirv' : 'single');
const missile = {
id: this.nextId++, fromX, fromY, toX: target.x, toY: target.y,
targetSlot: target.slot, targetKind: target.kind,
t: 0, dur, kind, splitAt: 0.4 + this.rng() * 0.25, splitDone: false,
};
this.enemyMissiles.push(missile);
return missile;
}
makeMirvChildren(parent) {
const options = TUNE.MIRV_CHILDREN;
const n = options[Math.floor(this.rng() * options.length)];
const cur = lerpPos(parent);
const children = [];
for (let i = 0; i < n; i += 1) {
const m = this.spawnEnemyMissile(cur);
if (m) children.push(m);
}
return children;
}
resolveImpact(m) {
if (m.targetKind === 'city') {
const city = this.cities.find((c) => c.slot === m.targetSlot);
if (city && city.alive) {
city.alive = false;
this.emit('cityLost', { slot: city.slot, name: city.name });
}
} else {
const base = this.bases.find((b) => b.slot === m.targetSlot);
if (base && base.alive) {
base.alive = false;
this.emit('baseLost', { slot: base.slot });
}
}
this.spawnExplosion(m.toX, m.toY, 'enemy');
}
spawnExplosion(x, y, owner) {
const totalMs = TUNE.BLAST_GROW_MS + TUNE.BLAST_HOLD_MS + TUNE.BLAST_FADE_MS;
this.explosions.push({ id: this.nextId++, x, y, t: 0, totalMs, maxR: TUNE.BLAST_R_BASE, owner });
this.emit('explosion', { x, y, owner });
}
completeWave() {
this.wave += 1;
for (const b of this.bases) if (b.alive) b.ammo = TUNE.BASE_AMMO;
this.waveQuota = waveQuota(this.wave);
this.waveSpawned = 0;
this.spawnT = 0;
this.waveT = 0;
this.score += TUNE.SCORE_WAVE_BONUS;
this.paletteIdx = paletteIndexForWave(this.wave);
this.emit('waveComplete', { wave: this.wave, paletteIdx: this.paletteIdx });
}
step(dtMs) {
this.events = [];
if (this.status !== 'playing') return this.events;
this.waveT += dtMs;
// Spawn new enemy missiles up to this wave's quota.
if (this.waveSpawned < this.waveQuota) {
this.spawnT += dtMs;
const interval = spawnInterval(this.wave);
while (this.spawnT >= interval && this.waveSpawned < this.waveQuota) {
this.spawnT -= interval;
if (this.spawnEnemyMissile()) this.waveSpawned += 1;
}
}
// Advance enemy missiles; collect MIRV splits without mutating mid-loop.
const newChildren = [];
for (const m of this.enemyMissiles) {
m.t += dtMs / m.dur;
if (m.kind === 'mirv' && !m.splitDone && m.t >= m.splitAt && m.t < 1) {
m.splitDone = true;
newChildren.push(...this.makeMirvChildren(m));
}
}
if (newChildren.length) this.enemyMissiles.push(...newChildren);
const stillFlying = [];
for (const m of this.enemyMissiles) {
if (m.t >= 1) this.resolveImpact(m);
else stillFlying.push(m);
}
this.enemyMissiles = stillFlying;
// Advance interceptors; on arrival they become a player explosion (blast
// radius kills, not point hits — the authentic Missile Command mechanic).
const stillInterceptors = [];
for (const p of this.interceptors) {
p.t += dtMs / p.dur;
if (p.t >= 1) this.spawnExplosion(p.toX, p.toY, 'player');
else stillInterceptors.push(p);
}
this.interceptors = stillInterceptors;
// Advance explosions and resolve collisions against enemy missiles.
this.explosions = this.explosions.filter((e) => {
e.t += dtMs;
return e.t < e.totalMs;
});
for (const e of this.explosions) {
if (e.owner !== 'player') continue;
const r = explosionRadius(e);
if (r <= 0) continue;
this.enemyMissiles = this.enemyMissiles.filter((m) => {
const pos = lerpPos(m);
const d = Math.hypot(pos.x - e.x, pos.y - e.y);
if (d <= r) {
const gained = TUNE.SCORE_PER_KILL * this.wave;
this.score += gained;
this.emit('missileDestroyed', { x: pos.x, y: pos.y, score: gained });
return false;
}
return true;
});
}
if (this.waveSpawned >= this.waveQuota && this.enemyMissiles.length === 0 && this.interceptors.length === 0) {
this.completeWave();
}
if (this.status === 'playing' && this.aliveCities().length === 0) {
this.status = 'gameover';
this.emit('gameOver', { score: this.score, wave: this.wave });
}
return this.events;
}
}
export function createGame(cityPool, seed) {
return new Sim(cityPool, seed);
}
export function step(sim, dtMs) {
return sim.step(dtMs);
}
// Nearest surviving, ammo-having base to the click point; null if none ready.
export function pickFiringBase(sim, x, y) {
const candidates = sim.bases.filter((b) => b.alive && b.ammo > 0);
if (!candidates.length) return null;
candidates.sort((a, b) => Math.hypot(a.x - x, a.y - y) - Math.hypot(b.x - x, b.y - y));
return candidates[0];
}
export function fireInterceptor(sim, base, x, y) {
if (!base || !base.alive || base.ammo <= 0) return null;
base.ammo -= 1;
const dist = Math.hypot(x - base.x, y - base.y);
const dur = Math.max(120, (dist / TUNE.INTERCEPTOR_SPEED) * 1000);
const interceptor = {
id: sim.nextId++, fromX: base.x, fromY: base.y, toX: x, toY: y, t: 0, dur, baseSlot: base.slot,
};
sim.interceptors.push(interceptor);
sim.emit('interceptorFired', { x: base.x, y: base.y });
return interceptor;
}

View File

@ -89,6 +89,7 @@ import SWDBGGame from './games/swdbg/SWDBGGame.js';
import BalatroGame from './games/balatro/BalatroGame.js';
import PeggleGame from './games/peggle/PeggleGame.js';
import PeggleEditor from './games/peggle/PeggleEditor.js';
import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js';
const config = {
type: Phaser.AUTO,
@ -191,6 +192,7 @@ const config = {
BalatroGame,
PeggleGame,
PeggleEditor,
ColoradoDefenseGame,
],
};

View File

@ -14,9 +14,10 @@ const CATEGORIES = [
{ key: 'casino', label: 'Casino' },
{ key: 'word', label: 'Words & Numbers' },
{ key: 'logic', label: 'Logic & Puzzle' },
{ key: 'arcade-console-pc', label: 'Arcade, Console & PC' },
];
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4 };
const TAB_ICON_FRAMES = { tabletop: 0, cards: 1, casino: 2, word: 3, logic: 4, 'arcade-console-pc': 5 };
const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86;
@ -63,7 +64,7 @@ export default class GameMenuScene extends Phaser.Scene {
// Two-row tab layout: row 1 has 4 categories, row 2 has the rest
const ROW1_KEYS = ['tabletop', 'cards', 'casino', 'word'];
const ROW2_KEYS = ['logic'];
const ROW2_KEYS = ['logic', 'arcade-console-pc'];
const ROW1 = CATEGORIES.filter(c => ROW1_KEYS.includes(c.key));
const ROW2 = CATEGORIES.filter(c => ROW2_KEYS.includes(c.key));
const activeRow1 = ROW1.filter(({ key }) => this._gamesByCategory[key].length > 0);

View File

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

View File

@ -60,6 +60,7 @@ export default class PreloadScene extends Phaser.Scene {
this.load.json('jumble', 'data/jumble.json');
this.load.json('balatro-artwork', 'data/balatro-artwork.json');
this.load.json('peggle-levels', 'assets/gamedata/peggle/levels.json');
this.load.json('colorado-defense-cities', 'data/colorado-defense-cities.json');
this.load.audio('sfx-water-splash', 'assets/fx/water-splash.mp3');
this.load.audio('sfx-water-sink', 'assets/fx/water-sink.mp3');

124
src/ui/ArcadeCRTOverlay.js Normal file
View File

@ -0,0 +1,124 @@
import * as Phaser from 'phaser';
import { GAME_WIDTH, GAME_HEIGHT } from '../config.js';
// Reusable "arcade cabinet" screen dressing: animated scanlines + a vector
// TV-style bevel/vignette. Drop this onto any arcade/console game scene.
//
// (src/games/balatro/BalatroCrtPipeline.js is a different, WebGL-shader-based
// CRT effect that's single-consumer and Canvas-incompatible — not reused here;
// this module is GameObject-based so it works on any renderer.)
//
// Usage:
// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a });
// this.events.once('shutdown', () => this.crt.destroy());
// this.crt.pulse(1.0, 400); // one-off impact flash
// this.crt.setIntensity({ accentTint: C.accent }); // re-skin on palette change
const SCAN_TEXTURE_KEY = 'arcade-crt-scan';
const SCAN_STRIP_HEIGHT = 12;
const SCAN_LINE_HEIGHT = 6;
function ensureScanTexture(scene) {
if (scene.textures.exists(SCAN_TEXTURE_KEY)) return;
const g = scene.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 0.18);
g.fillRect(0, 0, GAME_WIDTH, SCAN_LINE_HEIGHT);
g.generateTexture(SCAN_TEXTURE_KEY, GAME_WIDTH, SCAN_STRIP_HEIGHT);
g.destroy();
}
export function applyArcadeCRTOverlay(scene, options = {}) {
const opts = {
scanlineAlpha: 0.65,
scanlineTint: 0x00f0ff,
scanlineSpeedMs: 9000,
bevelThickness: 78,
bevelColor: 0x05060a,
bevelRadius: 32,
vignetteStrength: 0.78,
accentTint: 0xc8a84b,
depth: { scan: 58, bevel: 59 },
...options,
};
ensureScanTexture(scene);
const scan = scene.add
.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, SCAN_TEXTURE_KEY)
.setDepth(opts.depth.scan)
.setAlpha(opts.scanlineAlpha)
.setTint(opts.scanlineTint);
scan.setBlendMode(Phaser.BlendModes.ADD);
const scanTween = scene.tweens.add({
targets: scan,
tilePositionY: GAME_HEIGHT,
duration: opts.scanlineSpeedMs,
repeat: -1,
ease: 'Linear',
});
const bevel = scene.add.graphics().setDepth(opts.depth.bevel);
function drawVignette(g) {
const corners = [
[0, 0], [GAME_WIDTH, 0], [0, GAME_HEIGHT], [GAME_WIDTH, GAME_HEIGHT],
];
for (const [cx, cy] of corners) {
g.fillStyle(0x000000, opts.vignetteStrength * 0.42);
g.fillCircle(cx, cy, 480);
g.fillStyle(0x000000, opts.vignetteStrength * 0.3);
g.fillCircle(cx, cy, 320);
}
const rings = 7;
for (let i = 0; i < rings; i += 1) {
const t = i / (rings - 1);
const inset = opts.bevelThickness + t * 180;
const alpha = opts.vignetteStrength * (1 - t) * 0.16;
g.lineStyle(52, 0x000000, alpha);
g.strokeRoundedRect(inset, inset, GAME_WIDTH - inset * 2, GAME_HEIGHT - inset * 2, opts.bevelRadius + 8);
}
}
function drawBevel() {
bevel.clear();
drawVignette(bevel);
const inset = opts.bevelThickness / 2;
bevel.lineStyle(opts.bevelThickness, opts.bevelColor, 1);
bevel.strokeRoundedRect(inset, inset, GAME_WIDTH - opts.bevelThickness, GAME_HEIGHT - opts.bevelThickness, opts.bevelRadius);
bevel.lineStyle(9, opts.accentTint, 0.65);
bevel.strokeRoundedRect(
opts.bevelThickness, opts.bevelThickness,
GAME_WIDTH - opts.bevelThickness * 2, GAME_HEIGHT - opts.bevelThickness * 2,
Math.max(0, opts.bevelRadius - 8),
);
}
drawBevel();
let flashTween = null;
const controller = {
pulse(strength = 0.5, durationMs = 150) {
scene.cameras.main.shake(durationMs, 0.004 * strength);
if (flashTween) flashTween.stop();
scan.setAlpha(Math.min(1, opts.scanlineAlpha + 0.5 * strength));
flashTween = scene.tweens.add({
targets: scan, alpha: opts.scanlineAlpha, duration: durationMs * 2,
});
},
setIntensity(patch = {}) {
Object.assign(opts, patch);
scan.setAlpha(opts.scanlineAlpha).setTint(opts.scanlineTint);
drawBevel();
},
destroy() {
scanTween.stop();
if (flashTween) flashTween.stop();
scan.destroy();
bevel.destroy();
},
};
return controller;
}

View File

@ -0,0 +1,205 @@
// Headless verification for Colorado Defense.
// node tools/verifyColoradoDefense.js
// Exits non-zero on any failure.
//
// 1. Ground-layout invariants (9 slots, 3 bases + 6 cities, symmetric spacing).
// 2. City-pool draw correctness + malformed-pool fallback.
// 3. Difficulty escalation monotonicity across waves 1-40 (bounded).
// 4. MIRV step-unlock threshold.
// 5. Scripted explosion/collision fixture.
// 6. Palette-progression monotonicity.
// 7. Monte-carlo "always fire at nearest incoming missile" bot run.
import {
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities,
DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance,
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
fireInterceptor, lerpPos, explosionRadius,
} from '../src/games/coloradodefense/ColoradoDefenseLogic.js';
let failures = 0;
function check(name, cond, detail = '') {
if (cond) { console.log(` ok ${name}`); return; }
failures += 1;
console.error(` FAIL ${name}${detail ? `${detail}` : ''}`);
}
// ── 1. Ground layout ─────────────────────────────────────────────────────────
console.log('Ground layout');
{
check('9 slots total', GROUND_SLOTS === 9);
check('3 base slots', BASE_SLOTS.length === 3);
check('6 city slots', CITY_SLOTS.length === 6);
const allSlots = [...BASE_SLOTS, ...CITY_SLOTS].sort((a, b) => a - b);
check('slots partition 0..8 exactly', allSlots.join(',') === '0,1,2,3,4,5,6,7,8');
check('classic arrangement Base,City,City,City,Base,City,City,City,Base',
BASE_SLOTS.join(',') === '0,4,8' && CITY_SLOTS.join(',') === '1,2,3,5,6,7');
const xs = Array.from({ length: GROUND_SLOTS }, (_, i) => slotX(i));
let increasing = true;
for (let i = 1; i < xs.length; i += 1) if (xs[i] <= xs[i - 1]) increasing = false;
check('slot positions strictly increasing left to right', increasing);
const mid = (xs[0] + xs[8]) / 2;
check('layout symmetric around center', Math.abs((xs[4]) - mid) < 1, `slot4=${xs[4]} mid=${mid}`);
}
// ── 2. City pool ──────────────────────────────────────────────────────────────
console.log('City pool');
{
const rng = mulberry32(42);
for (const seed of [1, 2, 3, 999, 123456]) {
const r = mulberry32(seed);
const picked = pickCities(DEFAULT_CITIES, r);
check(`seed ${seed}: exactly 6 cities`, picked.length === 6);
check(`seed ${seed}: unique cities`, new Set(picked).size === 6, picked.join(','));
check(`seed ${seed}: all from pool`, picked.every((n) => DEFAULT_CITIES.includes(n)));
}
const empty = pickCities([], rng);
check('empty pool falls back safely (no throw, bounded length)', empty.length === 6);
const small = pickCities(['A', 'B'], rng);
check('pool smaller than 6 cycles without throwing', small.length === 6);
}
// ── 3. Escalation monotonicity ────────────────────────────────────────────────
console.log('Escalation monotonicity (waves 1-40)');
{
let spawnOk = true; let fallOk = true; let quotaOk = true;
let spawnBounded = true; let fallBounded = true; let quotaBounded = true;
for (let w = 2; w <= 40; w += 1) {
if (spawnInterval(w) > spawnInterval(w - 1)) spawnOk = false;
if (fallSpeed(w) < fallSpeed(w - 1)) fallOk = false;
if (waveQuota(w) < waveQuota(w - 1)) quotaOk = false;
}
for (let w = 1; w <= 40; w += 1) {
if (spawnInterval(w) < TUNE.SPAWN_MS_MIN) spawnBounded = false;
if (fallSpeed(w) > TUNE.FALL_SPEED_MAX) fallBounded = false;
if (waveQuota(w) > TUNE.WAVE_QUOTA_MAX) quotaBounded = false;
}
check('spawn interval never increases with wave', spawnOk);
check('fall speed never decreases with wave', fallOk);
check('wave quota never decreases with wave', quotaOk);
check('spawn interval bounded at floor', spawnBounded);
check('fall speed bounded at cap', fallBounded);
check('wave quota bounded at cap', quotaBounded);
}
// ── 4. MIRV step-unlock ────────────────────────────────────────────────────────
console.log('MIRV step-unlock');
{
let belowZero = true;
for (let w = 1; w < TUNE.MIRV_WAVE; w += 1) if (mirvChance(w) !== 0) belowZero = false;
check(`mirvChance is 0 before wave ${TUNE.MIRV_WAVE}`, belowZero);
check(`mirvChance > 0 at wave ${TUNE.MIRV_WAVE}`, mirvChance(TUNE.MIRV_WAVE) > 0);
check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX);
}
// ── 5. Explosion/collision fixture ────────────────────────────────────────────
console.log('Explosion/collision fixture');
{
const sim = createGame(DEFAULT_CITIES, 7);
const target = sim.cities[0];
// A slow-descending missile (long dur) barely drifts during the
// interceptor's flight time, so a shot at its current position still lands.
sim.enemyMissiles.push({
id: sim.nextId++, fromX: target.x, fromY: 0, toX: target.x, toY: target.y,
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
});
const missilePos = lerpPos(sim.enemyMissiles[0]);
const base = pickFiringBase(sim, missilePos.x, missilePos.y);
fireInterceptor(sim, base, missilePos.x, missilePos.y);
let destroyed = false;
let steps = 0;
while (!destroyed && steps < 200) {
const events = step(sim, 16);
if (events.some((e) => e.type === 'missileDestroyed')) destroyed = true;
steps += 1;
}
check('interceptor blast destroys missile within radius', destroyed, `steps=${steps}`);
check('score increased on kill', sim.score > 0, `score=${sim.score}`);
const sim2 = createGame(DEFAULT_CITIES, 8);
const target2 = sim2.cities[0];
sim2.enemyMissiles.push({
id: sim2.nextId++, fromX: target2.x, fromY: 0, toX: target2.x, toY: target2.y,
targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitAt: 2, splitDone: true,
});
const far = lerpPos(sim2.enemyMissiles[0]);
fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y);
for (let i = 0; i < 60; i += 1) step(sim2, 16);
check('missile far outside blast radius survives', sim2.enemyMissiles.length === 1);
}
// ── 6. Palette progression ────────────────────────────────────────────────────
console.log('Palette progression');
{
let monotonic = true;
for (let w = 2; w <= 60; w += 1) {
if (paletteIndexForWave(w) < paletteIndexForWave(w - 1)) monotonic = false;
}
check('palette index monotonic non-decreasing', monotonic);
check('palette index clamps at array end', paletteIndexForWave(9999) === PALETTES.length - 1);
}
// ── 7. Monte-carlo bot run ────────────────────────────────────────────────────
console.log('Monte-carlo bot run');
{
for (const seed of [1, 2, 3, 4, 5]) {
const sim = createGame(DEFAULT_CITIES, seed);
let steps = 0;
let scoreRegressed = false;
let cityCountIncreased = false;
let nanFound = false;
let prevScore = 0;
let prevCities = sim.aliveCities().length;
const MAX_STEPS = 20000; // ~320s of sim time at 16ms/step
while (sim.status === 'playing' && steps < MAX_STEPS) {
// Bot: fire at whichever enemy missile is closest to impact (highest t),
// but only one interceptor in flight at a time so ammo isn't wasted on
// redundant shots at the same target.
if (sim.enemyMissiles.length && sim.interceptors.length === 0) {
let soonest = null; let soonestT = -1;
for (const m of sim.enemyMissiles) {
if (m.t > soonestT) { soonestT = m.t; soonest = m; }
}
if (soonest) {
const pos = lerpPos(soonest);
const base = pickFiringBase(sim, pos.x, pos.y);
if (base) fireInterceptor(sim, base, pos.x, pos.y);
}
}
step(sim, 16);
steps += 1;
if (sim.score < prevScore) scoreRegressed = true;
prevScore = sim.score;
const aliveCities = sim.aliveCities().length;
if (aliveCities > prevCities) cityCountIncreased = true;
prevCities = aliveCities;
for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions]) {
for (const e of list) {
if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true;
}
}
}
check(`seed ${seed}: no NaN values`, !nanFound);
check(`seed ${seed}: score never regressed`, !scoreRegressed);
check(`seed ${seed}: city count never increased`, !cityCountIncreased);
check(`seed ${seed}: game over exactly when all cities dead`,
sim.status === 'gameover' ? sim.aliveCities().length === 0 : true);
check(`seed ${seed}: terminates within step bound`, steps < MAX_STEPS, `steps=${steps}`);
console.log(` seed ${seed}: status=${sim.status} wave=${sim.wave} score=${sim.score} steps=${steps}`);
}
}
console.log(failures ? `\n${failures} FAILURE(S)` : '\nAll checks passed.');
process.exit(failures ? 1 : 0);