diff --git a/assets/fx/8bit-count.mp3 b/assets/fx/8bit-count.mp3 new file mode 100644 index 0000000..4a3f67f Binary files /dev/null and b/assets/fx/8bit-count.mp3 differ diff --git a/assets/fx/8bit-explode.mp3 b/assets/fx/8bit-explode.mp3 new file mode 100644 index 0000000..60b0aaf Binary files /dev/null and b/assets/fx/8bit-explode.mp3 differ diff --git a/assets/fx/8bit-explode2.mp3 b/assets/fx/8bit-explode2.mp3 new file mode 100644 index 0000000..0b94efe Binary files /dev/null and b/assets/fx/8bit-explode2.mp3 differ diff --git a/src/games/coloradodefense/ColoradoDefenseGame.js b/src/games/coloradodefense/ColoradoDefenseGame.js index ef7f51c..1fcb919 100644 --- a/src/games/coloradodefense/ColoradoDefenseGame.js +++ b/src/games/coloradodefense/ColoradoDefenseGame.js @@ -9,16 +9,41 @@ import { DEFAULT_CITIES, GROUND_Y, PALETTES, createGame, step, pickFiringBase, fireInterceptor, lerpPos, explosionRadius, } from './ColoradoDefenseLogic.js'; +import { drawVectorText } from './VectorFont.js'; -const D = { bg: -2, ground: 0, fx: 2, ui: 30, overlay: 60 }; +const D = { bg: -2, mountains: -1, ground: 0, fx: 2, ui: 30, overlay: 60 }; const BEST_KEY = 'coloradodefense-best'; +// A couple of distinct skylines so the 6 cities don't look identical, picked +// deterministically by ground slot. +const BUILDING_PRESETS = [ + [{ w: 20, h: 34 }, { w: 16, h: 58 }, { w: 26, h: 26 }, { w: 18, h: 46 }], + [{ w: 18, h: 48 }, { w: 24, h: 30 }, { w: 16, h: 62 }, { w: 22, h: 40 }], + [{ w: 22, h: 40 }, { w: 18, h: 30 }, { w: 20, h: 56 }, { w: 16, h: 44 }], +]; + +// Fixed, hand-authored Rocky-Mountain ridgelines — line art only, drawn once +// and independent of the wave palette so it reads as constant scenery. +const BACK_RIDGE = [ + [0, 620], [140, 560], [260, 600], [380, 520], [520, 580], + [660, 500], [800, 560], [960, 480], [1100, 540], [1240, 500], + [1380, 570], [1520, 510], [1660, 560], [1800, 520], [1920, 580], +]; +const FRONT_RIDGE = [ + [0, 760], [120, 680], [280, 730], [420, 640], [560, 720], + [700, 650], [860, 710], [1020, 630], [1180, 700], [1340, 640], + [1500, 720], [1660, 660], [1820, 720], [1920, 700], +]; +const SNOW_CAP_PEAKS = [3, 7, 11]; // BACK_RIDGE indices tall enough to catch snow + 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._recapActive = false; + this._pendingGameOver = null; this.state = null; this.C = PALETTES[0]; } @@ -33,10 +58,13 @@ export default class ColoradoDefenseGame extends Phaser.Scene { this.bgRect = this.add .rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, this.C.bgTop) .setDepth(D.bg); + this.skylineG = this.add.graphics().setDepth(D.mountains); 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); + this.drawSkyline(); + const pool = this.cache.json.get('colorado-defense-cities')?.cities ?? DEFAULT_CITIES; this.state = createGame(pool, Date.now()); @@ -74,9 +102,34 @@ export default class ColoradoDefenseGame extends Phaser.Scene { if (!base) return; fireInterceptor(this.state, base, x, y); playSound(this, SFX.SCIFI_LAUNCH); + this.drawGround(); }); } + // ── Skyline (background mountains) ─────────────────────────────────────── + drawSkyline() { + const g = this.skylineG; + g.clear(); + + const drawRidge = (points, color, alpha, width) => { + g.lineStyle(width, color, alpha); + g.beginPath(); + g.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i += 1) g.lineTo(points[i][0], points[i][1]); + g.strokePath(); + }; + + drawRidge(BACK_RIDGE, 0x2b3a4a, 0.35, 2); + drawRidge(FRONT_RIDGE, 0x3d5266, 0.45, 2); + + g.lineStyle(1.5, 0xdce8f0, 0.4); + for (const idx of SNOW_CAP_PEAKS) { + const [px, py] = BACK_RIDGE[idx]; + g.lineBetween(px - 10, py + 8, px, py); + g.lineBetween(px, py, px + 10, py + 8); + } + } + // ── Ground (silos + cities) ────────────────────────────────────────────────── drawGround() { const g = this.groundG; @@ -98,42 +151,98 @@ export default class ColoradoDefenseGame extends Phaser.Scene { } } + // Glowing wireframe launcher — a hollow outlined silhouette (double-stroke + // for a soft phosphor halo) rather than a filled block, matching the look + // of a real XY vector-monitor arcade cabinet. 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); + const top = y - 62; + g.lineStyle(6, this.C.accent, 0.18); + g.strokeTriangle(x - 34, y, x + 34, y, x, top); + g.lineStyle(2, this.C.accent, 1); + g.strokeTriangle(x - 34, y, x + 34, y, x, top); + + g.lineStyle(1.5, this.C.accent, 0.6); + g.lineBetween(x - 18, y, x - 6, top + 14); + g.lineBetween(x + 18, y, x + 6, top + 14); + + g.lineStyle(1.5, 0xffffff, 0.7); + g.strokeRect(x - 10, y - 22, 20, 22); + + g.lineStyle(1.5, this.C.accent, 0.9); + g.lineBetween(x, top, x, top - 16); + g.strokeCircle(x, top - 20, 4); + 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); + const col = i % 5; + const row = Math.floor(i / 5); + const px = x - 26 + col * 13; + const py = y - 8 - row * 10; + g.lineStyle(1.5, 0xffffff, 0.9); + g.strokeRect(px - 3, py - 3, 6, 6); } } 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); + g.lineStyle(2, 0x55524a, 0.85); + g.strokeCircle(x, y - 6, 18); + g.lineStyle(1.5, 0x3a3830, 0.8); + g.lineBetween(x - 14, y - 18, x + 12, y + 4); + g.lineBetween(x + 14, y - 18, x - 12, y + 4); } } 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); + const preset = BUILDING_PRESETS[city.slot % BUILDING_PRESETS.length]; + let tallestIdx = 0; let tallestH = 0; + preset.forEach((b, i) => { if (b.h > tallestH) { tallestH = b.h; tallestIdx = i; } }); + + let cx = x - 46; + preset.forEach((b, i) => { + const bx = cx; const by = y - b.h; + + g.lineStyle(5, this.C.trail, 0.16); + g.strokeRect(bx, by, b.w, b.h); + g.lineStyle(2, this.C.trail, 1); + g.strokeRect(bx, by, b.w, b.h); + + g.lineStyle(1, this.C.trail, 0.4); + const floors = Math.max(1, Math.floor(b.h / 14)); + for (let f = 1; f < floors; f += 1) { + const fy = by + (b.h / floors) * f; + g.lineBetween(bx + 2, fy, bx + b.w - 2, fy); + } + + g.fillStyle(this.C.explosion, 0.5); + g.fillRect(bx + 4, by + 6, 3, 3); + if (b.w > 18) g.fillRect(bx + b.w - 8, by + b.h - 10, 3, 3); + + if (i === tallestIdx) { + g.lineStyle(1.5, this.C.trail, 0.9); + g.lineBetween(bx + b.w / 2, by, bx + b.w / 2, by - 14); + g.fillStyle(this.C.trail, 1); + g.fillCircle(bx + b.w / 2, by - 14, 2); + } + 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); + const baseY = y - 4; + g.lineStyle(2, 0x55524a, 0.85); + g.beginPath(); + g.moveTo(x - 42, baseY); + g.lineTo(x - 30, baseY - 10); + g.lineTo(x - 16, baseY - 4); + g.lineTo(x - 2, baseY - 14); + g.lineTo(x + 12, baseY - 3); + g.lineTo(x + 26, baseY - 11); + g.lineTo(x + 40, baseY); + g.strokePath(); + + g.lineStyle(1.5, 0x3a3830, 0.7); + g.strokeTriangle(x - 20, baseY, x - 10, baseY - 16, x, baseY); + g.strokeTriangle(x + 6, baseY, x + 16, baseY - 12, x + 26, baseY); } } @@ -177,14 +286,10 @@ export default class ColoradoDefenseGame extends Phaser.Scene { this.drawGround(); break; case 'missileDestroyed': - playSound(this, SFX.LASER_ZAP); + playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE); 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); + this.playWaveRecap(e); break; case 'gameOver': this.onGameOver(e); @@ -214,8 +319,9 @@ export default class ColoradoDefenseGame extends Phaser.Scene { 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); + const color = e.owner === 'player' ? this.C.explosion : (e.owner === 'chain' ? this.C.chain : 0xff4433); + const alpha = e.owner === 'chain' ? 0.62 : 0.55; + g.fillStyle(color, alpha); g.fillCircle(e.x, e.y, r); g.lineStyle(2, 0xffffff, 0.8); g.strokeCircle(e.x, e.y, r); @@ -223,8 +329,157 @@ export default class ColoradoDefenseGame extends Phaser.Scene { this.updateHud(); } + // ── Inter-wave recap ────────────────────────────────────────────────────── + wait(ms) { + return new Promise((resolve) => this.time.delayedCall(ms, resolve)); + } + + rowPositions(count, centerX, spacing) { + const totalW = (count - 1) * spacing; + const startX = centerX - totalW / 2; + return Array.from({ length: count }, (_, i) => startX + i * spacing); + } + + makeCityIcon(x, y) { + const g = this.add.graphics().setPosition(x, y).setDepth(D.overlay + 1); + const bars = [{ dx: -12, w: 8, h: 16 }, { dx: -2, w: 8, h: 24 }, { dx: 10, w: 8, h: 12 }]; + for (const b of bars) { + g.lineStyle(4, this.C.trail, 0.18); + g.strokeRect(b.dx, -b.h, b.w, b.h); + g.lineStyle(1.5, this.C.trail, 1); + g.strokeRect(b.dx, -b.h, b.w, b.h); + } + return g; + } + + makeMissileIcon(x, y) { + const g = this.add.graphics().setPosition(x, y).setDepth(D.overlay + 1); + g.lineStyle(5, this.C.accent, 0.18); + g.strokeTriangle(-6, 14, 6, 14, 0, -14); + g.lineStyle(1.5, this.C.accent, 1); + g.strokeTriangle(-6, 14, 6, 14, 0, -14); + return g; + } + + animateCityIntoRow(city, destX, destY, root) { + const icon = this.makeCityIcon(city.x, city.y); + root.add(icon); + return new Promise((resolve) => { + this.tweens.add({ + targets: icon, x: destX, y: destY, duration: 350, ease: 'Cubic.easeOut', + onComplete: () => { + const label = this.add.text(destX, destY + 22, '+500', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlay + 1); + root.add(label); + this._recapScore += 500; + this.scoreText.setText(`Score: ${this._recapScore}`); + playSound(this, SFX.EIGHTBIT_COUNT); + resolve(); + }, + }); + }); + } + + animateMissileIntoRow(base, destX, destY, root) { + const icon = this.makeMissileIcon(base.x, base.y); + root.add(icon); + playSound(this, SFX.EIGHTBIT_CARD); + return new Promise((resolve) => { + this.tweens.add({ + targets: icon, x: destX, y: destY, duration: 220, ease: 'Cubic.easeOut', onComplete: resolve, + }); + }); + } + + // Runs the between-waves "COLORADO DEFENDED" bonus recap: title, then each + // surviving city tallies +500 into a row, then each base's leftover ammo + // tallies into a row for a lump +25/missile summary, then everything + // resets for the next wave. Logic already applied the full bonus + // atomically in completeWave() — this only replays it visually. + async playWaveRecap(e) { + this.overlayUp = true; + this._recapActive = true; + this._recapScore = this.state.score - e.cityBonus - e.ammoBonus; + + this.C = PALETTES[e.paletteIdx]; + this.crt.setIntensity({ accentTint: this.C.accent, scanlineTint: this.C.trail }); + this.bgRect.setFillStyle(this.C.bgTop); + + const cx = GAME_WIDTH / 2; + const bannerY = GAME_HEIGHT / 3; + const cityRowY = bannerY + 130; + const missileRowY = cityRowY + 120; + const summaryY = missileRowY + 70; + + const root = this.add.container(0, 0).setDepth(D.overlay); + root.add(this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55)); + + const bannerG = this.add.graphics().setDepth(D.overlay + 1); + drawVectorText(bannerG, 'COLORADO DEFENDED', cx, bannerY, 13, this.C.accent); + root.add(bannerG); + + await this.wait(1000); + + const aliveCities = this.state.cities.filter((c) => c.alive); + if (aliveCities.length) { + const cityX = this.rowPositions(aliveCities.length, cx, 110); + let lastTween = null; + for (let i = 0; i < aliveCities.length; i += 1) { + lastTween = this.animateCityIntoRow(aliveCities[i], cityX[i], cityRowY, root); + if (i < aliveCities.length - 1) await this.wait(400); + } + await lastTween; + } + + await this.wait(200); + + if (e.totalAmmo > 0) { + const missileX = this.rowPositions(e.totalAmmo, cx, 34); + let mi = 0; + let lastTween = null; + for (let bi = 0; bi < this.state.bases.length; bi += 1) { + const count = e.preAmmo[bi] ?? 0; + for (let k = 0; k < count; k += 1) { + lastTween = this.animateMissileIntoRow(this.state.bases[bi], missileX[mi], missileRowY, root); + mi += 1; + if (mi < e.totalAmmo) await this.wait(100); + } + } + await lastTween; + + const bigText = this.add.text(cx, summaryY, `+${e.ammoBonus}`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '44px', color: COLORS.goldHex, + }).setOrigin(0.5).setDepth(D.overlay + 1); + const capText = this.add.text(cx, summaryY + 34, `${e.totalAmmo} MISSILES REMAINING`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.overlay + 1); + root.add([bigText, capText]); + this._recapScore = this.state.score; + this.scoreText.setText(`Score: ${this._recapScore}`); + playSound(this, SFX.EIGHTBIT_ACTION); + } + + await this.wait(1500); + + root.destroy(true); + this.drawGround(); + this._recapActive = false; + this.overlayUp = false; + + if (this._pendingGameOver) { + const pending = this._pendingGameOver; + this._pendingGameOver = null; + this.onGameOver(pending); + } + } + // ── Game over ───────────────────────────────────────────────────────────── onGameOver(e) { + // All cities can die in the same tick a wave completes; let the recap + // finish (it already reflects the correct pre-game-over bonus) before + // showing the game-over panel on top of it. + if (this._recapActive) { this._pendingGameOver = e; return; } this.overlayUp = true; this.crt.pulse(1.0, 400); playSound(this, SFX.SCIFI_EXPLODE); diff --git a/src/games/coloradodefense/ColoradoDefenseLogic.js b/src/games/coloradodefense/ColoradoDefenseLogic.js index b1e3866..30dc0b9 100644 --- a/src/games/coloradodefense/ColoradoDefenseLogic.js +++ b/src/games/coloradodefense/ColoradoDefenseLogic.js @@ -62,7 +62,12 @@ export const TUNE = { 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, + CITY_BONUS: 500, + // A missile a player explosion kills detonates itself shortly after, with + // a slower-blooming, bigger blast that can chain into others. + CHAIN_DELAY_MS: 250, + CHAIN_EXTRA_GROW_MS: 750, + CHAIN_R_MULT: 1.5, }; export function spawnInterval(wave) { @@ -82,11 +87,11 @@ export function mirvChance(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 }, + { bgTop: 0x0a0e1a, bgBottom: 0x1a1030, trail: 0x66ffea, explosion: 0xffdd55, accent: 0x00f0ff, ground: 0x141a2e, chain: 0xff6688 }, + { bgTop: 0x1a0a0e, bgBottom: 0x300a1a, trail: 0xff6bcb, explosion: 0xffaa33, accent: 0xff2bd6, ground: 0x2a0f1a, chain: 0xffee66 }, + { bgTop: 0x0a1a0e, bgBottom: 0x0a301a, trail: 0x8dff6b, explosion: 0xffee44, accent: 0x45d17a, ground: 0x0f2a18, chain: 0xff7744 }, + { bgTop: 0x1a140a, bgBottom: 0x301c0a, trail: 0xffb366, explosion: 0xff5555, accent: 0xff8a3c, ground: 0x2a1c0f, chain: 0xffee88 }, + { bgTop: 0x120a1a, bgBottom: 0x260a30, trail: 0xc78dff, explosion: 0xff66c4, accent: 0xb84bff, ground: 0x1e0f2a, chain: 0x66ffcc }, ]; export const PALETTE_WAVES = 4; export function paletteIndexForWave(wave) { @@ -102,7 +107,9 @@ export function lerpPos(entity) { } export function explosionRadius(e) { - const { BLAST_GROW_MS: G, BLAST_HOLD_MS: H, BLAST_FADE_MS: F } = TUNE; + const G = TUNE.BLAST_GROW_MS + (e.extraGrow || 0); + const H = TUNE.BLAST_HOLD_MS; + const F = TUNE.BLAST_FADE_MS; const t = e.t; if (t < G) return e.maxR * (t / G); if (t < G + H) return e.maxR; @@ -131,6 +138,7 @@ export class Sim { this.enemyMissiles = []; this.interceptors = []; this.explosions = []; + this.pendingChains = []; // missiles awaiting their delayed self-detonation this.waveT = 0; this.waveSpawned = 0; @@ -199,22 +207,39 @@ export class Sim { 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 }); + spawnExplosion(x, y, owner, opts = {}) { + const extraGrow = opts.extraGrow || 0; + const lethal = opts.lethal ?? (owner === 'player'); + const maxR = opts.maxR ?? TUNE.BLAST_R_BASE; + const totalMs = TUNE.BLAST_GROW_MS + extraGrow + TUNE.BLAST_HOLD_MS + TUNE.BLAST_FADE_MS; + this.explosions.push({ + id: this.nextId++, x, y, t: 0, totalMs, maxR, owner, lethal, extraGrow, + }); + this.emit('explosion', { x, y, owner, lethal }); } completeWave() { + // Snapshot pre-refill ammo and the survivor bonus breakdown so a + // consumer (e.g. an inter-wave recap animation) can show exactly what + // was scored, even though the transition below applies it atomically. + const preAmmo = this.bases.map((b) => (b.alive ? b.ammo : 0)); + const aliveCityCount = this.aliveCities().length; + const totalAmmo = preAmmo.reduce((a, b) => a + b, 0); + const cityBonus = aliveCityCount * TUNE.CITY_BONUS; + const ammoBonus = totalAmmo * TUNE.SCORE_PER_KILL; + 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.score += cityBonus + ammoBonus; this.paletteIdx = paletteIndexForWave(this.wave); - this.emit('waveComplete', { wave: this.wave, paletteIdx: this.paletteIdx }); + this.emit('waveComplete', { + wave: this.wave, paletteIdx: this.paletteIdx, + preAmmo, aliveCityCount, cityBonus, totalAmmo, ammoBonus, + }); } step(dtMs) { @@ -260,13 +285,30 @@ export class Sim { } this.interceptors = stillInterceptors; - // Advance explosions and resolve collisions against enemy missiles. + // Detonate any missile whose delayed self-destruct has come due, so its + // secondary blast joins this tick's collision pass below. + const readyChains = []; + this.pendingChains = this.pendingChains.filter((c) => { + c.delayMs -= dtMs; + if (c.delayMs > 0) return true; + readyChains.push(c); + return false; + }); + for (const c of readyChains) { + this.spawnExplosion(c.x, c.y, 'chain', { + lethal: true, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS, maxR: TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT, + }); + } + + // Advance explosions and resolve collisions against enemy missiles. Only + // a missile killed by the player's own blast (not a chain reaction) gets + // a delayed secondary detonation — this bounds the chain to one extra tier. 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; + if (!e.lethal) continue; const r = explosionRadius(e); if (r <= 0) continue; this.enemyMissiles = this.enemyMissiles.filter((m) => { @@ -275,7 +317,11 @@ export class Sim { 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 }); + const chained = e.owner === 'chain'; + this.emit('missileDestroyed', { x: pos.x, y: pos.y, score: gained, chained }); + if (!chained) { + this.pendingChains.push({ x: pos.x, y: pos.y, delayMs: TUNE.CHAIN_DELAY_MS }); + } return false; } return true; diff --git a/src/games/coloradodefense/VectorFont.js b/src/games/coloradodefense/VectorFont.js new file mode 100644 index 0000000..356910a --- /dev/null +++ b/src/games/coloradodefense/VectorFont.js @@ -0,0 +1,57 @@ +// A minimal stroked vector font — letters as line segments in a 4x6 unit +// cell, drawn as a glow-stroke pair (matching this game's silo/city look) +// rather than a bitmap font. Covers only the letters this game currently +// needs for its inter-wave banner; extend GLYPHS if more text is needed. + +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]]], + 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]]], + L: [[[0, 0], [0, 6], [3.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]]], + R: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.4], [0, 2.4]], [[1.6, 2.4], [4, 6]]], +}; + +function strokePoly(g, pts) { + g.beginPath(); + g.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i][0], pts[i][1]); + g.strokePath(); +} + +export function measureVectorText(text, scale) { + let w = 0; + for (const ch of text.toUpperCase()) { + w += (ch === ' ' ? SPACE_W : GLYPH_W + GLYPH_GAP) * scale; + } + return w - GLYPH_GAP * scale; +} + +export function drawVectorText(g, text, cx, cy, scale, color, options = {}) { + const { lineWidth = 3, glowWidth = 9, glowAlpha = 0.18, alpha = 1 } = options; + const totalW = measureVectorText(text, scale); + let x = cx - totalW / 2; + const y = cy - (GLYPH_H * scale) / 2; + for (const ch of text.toUpperCase()) { + if (ch === ' ') { x += SPACE_W * scale; continue; } + const strokes = GLYPHS[ch]; + if (strokes) { + for (const poly of strokes) { + const pts = poly.map(([ux, uy]) => [x + ux * scale, y + uy * scale]); + g.lineStyle(glowWidth, color, glowAlpha); + strokePoly(g, pts); + g.lineStyle(lineWidth, color, alpha); + strokePoly(g, pts); + } + } + x += (GLYPH_W + GLYPH_GAP) * scale; + } +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index e69a96e..7546030 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -113,6 +113,9 @@ export default class PreloadScene extends Phaser.Scene { this.load.audio('sfx-8bit-activate', 'assets/fx/8bit-activate.mp3'); this.load.audio('sfx-8bit-action', 'assets/fx/8bit-action.mp3'); this.load.audio('sfx-8bit-move', 'assets/fx/8bit-move.mp3'); + this.load.audio('sfx-8bit-explode', 'assets/fx/8bit-explode.mp3'); + this.load.audio('sfx-8bit-explode2', 'assets/fx/8bit-explode2.mp3'); + this.load.audio('sfx-8bit-count', 'assets/fx/8bit-count.mp3'); this.load.audio('sfx-squish', 'assets/fx/squish.mp3'); this.load.audio('sfx-squash', 'assets/fx/squash.mp3'); this.load.audio('sfx-woosh', 'assets/fx/woosh.mp3'); diff --git a/src/ui/ArcadeCRTOverlay.js b/src/ui/ArcadeCRTOverlay.js index c800753..dd032e1 100644 --- a/src/ui/ArcadeCRTOverlay.js +++ b/src/ui/ArcadeCRTOverlay.js @@ -1,15 +1,19 @@ import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT } from '../config.js'; +import { attachArcadeCurve } from './ArcadeCurvePipeline.js'; -// Reusable "arcade cabinet" screen dressing: animated scanlines + a vector -// TV-style bevel/vignette. Drop this onto any arcade/console game scene. +// Reusable "arcade cabinet" screen dressing: animated scanlines (GameObject- +// based, works on any renderer) plus a real barrel-distorted curved-CRT look +// (WebGL post-FX pipeline, see ArcadeCurvePipeline.js — Canvas gets a no-op +// stub for that half, scanlines still show). 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.) +// (src/games/balatro/BalatroCrtPipeline.js is a separate, single-consumer +// WebGL CRT effect — not reused here, but this module follows the same +// PostFXPipeline convention for its curvature half.) // // Usage: -// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a }); +// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a, curveAmount: 0.4 }); // 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 @@ -32,12 +36,13 @@ export function applyArcadeCRTOverlay(scene, options = {}) { scanlineAlpha: 0.65, scanlineTint: 0x00f0ff, scanlineSpeedMs: 9000, - bevelThickness: 78, - bevelColor: 0x05060a, - bevelRadius: 32, - vignetteStrength: 0.78, accentTint: 0xc8a84b, - depth: { scan: 58, bevel: 59 }, + depth: { scan: 58 }, + curveAmount: 0.35, + curveBezelColor: 0x05060a, + curveVignetteStrength: 0.35, + curveSheenStrength: 0.08, + curveAberration: 0.0035, ...options, }; @@ -58,43 +63,13 @@ export function applyArcadeCRTOverlay(scene, options = {}) { 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(); + const curve = attachArcadeCurve(scene, { + amount: opts.curveAmount, + bezelColor: opts.curveBezelColor, + vignetteStrength: opts.curveVignetteStrength, + sheenStrength: opts.curveSheenStrength, + aberration: opts.curveAberration, + }); let flashTween = null; @@ -106,17 +81,24 @@ export function applyArcadeCRTOverlay(scene, options = {}) { flashTween = scene.tweens.add({ targets: scan, alpha: opts.scanlineAlpha, duration: durationMs * 2, }); + curve.pulse(strength); }, setIntensity(patch = {}) { Object.assign(opts, patch); scan.setAlpha(opts.scanlineAlpha).setTint(opts.scanlineTint); - drawBevel(); + curve.setUniforms({ + amount: opts.curveAmount, + bezelColor: opts.curveBezelColor, + vignetteStrength: opts.curveVignetteStrength, + sheenStrength: opts.curveSheenStrength, + aberration: opts.curveAberration, + }); }, destroy() { scanTween.stop(); if (flashTween) flashTween.stop(); scan.destroy(); - bevel.destroy(); + curve.destroy(); }, }; diff --git a/src/ui/ArcadeCurvePipeline.js b/src/ui/ArcadeCurvePipeline.js new file mode 100644 index 0000000..59cfed4 --- /dev/null +++ b/src/ui/ArcadeCurvePipeline.js @@ -0,0 +1,138 @@ +// ArcadeCurvePipeline.js +// A WebGL camera post-FX pipeline that simulates an 80s curved CRT monitor: +// real barrel-distortion of the rendered scene (not just a painted vignette), +// a dark bezel where the curved glass falls outside the screen rect, a soft +// glass highlight, and a touch of edge chromatic aberration. Everything on +// the camera — gameplay and HUD alike — gets warped together, same as a real +// curved tube would show it. +// +// Modeled on src/games/balatro/BalatroCrtPipeline.js's PostFXPipeline +// convention. Canvas renderer (no WebGL) gets a no-op stub. +// +// This is an internal helper for src/ui/ArcadeCRTOverlay.js — games should +// go through applyArcadeCRTOverlay() rather than calling attachArcadeCurve() +// directly, so scanlines + curvature stay one bundled effect. + +import * as Phaser from 'phaser'; + +const FRAG = ` +precision mediump float; + +uniform sampler2D uMainSampler; +uniform vec2 uCurvature; +uniform float uVignette; +uniform vec3 uBezelColor; +uniform float uSheen; +uniform float uAberration; +uniform float uPulse; + +varying vec2 outTexCoord; + +vec2 curveUV(vec2 uv) { + uv = uv * 2.0 - 1.0; + vec2 offset = abs(uv.yx) / uCurvature; + uv = uv + uv * offset * offset; + uv = uv * 0.5 + 0.5; + return uv; +} + +void main(void) { + vec2 uv = curveUV(outTexCoord); + + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { + gl_FragColor = vec4(uBezelColor, 1.0); + return; + } + + float ca = uAberration * (0.4 + uPulse); + vec3 col; + col.r = texture2D(uMainSampler, clamp(uv + vec2(ca, 0.0), 0.0, 1.0)).r; + col.g = texture2D(uMainSampler, uv).g; + col.b = texture2D(uMainSampler, clamp(uv - vec2(ca, 0.0), 0.0, 1.0)).b; + + // Extra darkening toward the curved edge, on top of the natural falloff + // barrel distortion already gives foreshortened corner pixels. + vec2 vc = uv - 0.5; + float vig = 1.0 - uVignette * dot(vc, vc) * 2.2; + col *= clamp(vig, 0.0, 1.0); + + // Soft fixed highlight, like a light catching the top-left of convex glass. + float sheen = smoothstep(0.6, 0.0, length(uv - vec2(0.30, 0.20))) * uSheen; + col += sheen; + + gl_FragColor = vec4(col, 1.0); +} +`; + +const DEFAULTS = { + amount: 0.35, // 0 = nearly flat, 1 = strongly curved + vignetteStrength: 0.35, + bezelColor: 0x05060a, + sheenStrength: 0.08, + aberration: 0.0035, +}; + +function hexToRgb01(hex) { + return [((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255]; +} + +export class ArcadeCurvePipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline { + constructor(game) { + super({ game, name: 'ArcadeCurve', fragShader: FRAG }); + this.amount = DEFAULTS.amount; + this.vignetteStrength = DEFAULTS.vignetteStrength; + this.bezelRgb = hexToRgb01(DEFAULTS.bezelColor); + this.sheenStrength = DEFAULTS.sheenStrength; + this.aberration = DEFAULTS.aberration; + this._pulse = 0; + } + + // Bigger amount -> smaller divisors -> more pronounced barrel bulge. + curvatureVec() { + return [10.0 - this.amount * 7.0, 8.0 - this.amount * 5.5]; + } + + onPreRender() { + const [cx, cy] = this.curvatureVec(); + this.set2f('uCurvature', cx, cy); + this.set1f('uVignette', this.vignetteStrength); + this.set3f('uBezelColor', this.bezelRgb[0], this.bezelRgb[1], this.bezelRgb[2]); + this.set1f('uSheen', this.sheenStrength); + this.set1f('uAberration', this.aberration); + this.set1f('uPulse', this._pulse); + this._pulse *= 0.88; + if (this._pulse < 0.005) this._pulse = 0; + } + + // Event hook: momentarily intensify the edge aberration (0..1). + pulse(strength = 0.5) { + this._pulse = Math.min(1, Math.max(this._pulse, strength)); + } + + setUniforms(patch = {}) { + if (patch.amount != null) this.amount = patch.amount; + if (patch.vignetteStrength != null) this.vignetteStrength = patch.vignetteStrength; + if (patch.bezelColor != null) this.bezelRgb = hexToRgb01(patch.bezelColor); + if (patch.sheenStrength != null) this.sheenStrength = patch.sheenStrength; + if (patch.aberration != null) this.aberration = patch.aberration; + } +} + +// Attach the curvature post-FX to the scene's main camera. Returns a control +// object; on Canvas it is a stub so callers never need to branch on renderer. +export function attachArcadeCurve(scene, options = {}) { + if (!scene.renderer || scene.renderer.type !== Phaser.WEBGL) { + return { setUniforms() {}, pulse() {}, destroy() {} }; + } + scene.renderer.pipelines.addPostPipeline('ArcadeCurve', ArcadeCurvePipeline); + const cam = scene.cameras.main; + cam.setPostPipeline(ArcadeCurvePipeline); + const got = cam.getPostPipeline(ArcadeCurvePipeline); + const inst = Array.isArray(got) ? got[0] : got; + if (inst) inst.setUniforms({ ...DEFAULTS, ...options }); + return { + setUniforms(patch) { if (inst) inst.setUniforms(patch); }, + pulse(strength) { if (inst) inst.pulse(strength); }, + destroy() { cam.removePostPipeline(ArcadeCurvePipeline); }, + }; +} diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 3c78e37..99eefba 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -44,6 +44,10 @@ export const SFX = { EIGHTBIT_ACTIVATE: 'sfx-8bit-activate', EIGHTBIT_ACTION: 'sfx-8bit-action', EIGHTBIT_MOVE: 'sfx-8bit-move', + EIGHTBIT_CARD: 'sfx-card-deal-8bit', + EIGHTBIT_EXPLODE: 'sfx-8bit-explode', + EIGHTBIT_EXPLODE_2: 'sfx-8bit-explode2', + EIGHTBIT_COUNT: 'sfx-8bit-count', SQUISH: 'sfx-squish', SQUASH: 'sfx-squash', WOOSH: 'sfx-woosh', diff --git a/tools/verifyColoradoDefense.js b/tools/verifyColoradoDefense.js index 3b215cf..4d95d30 100644 --- a/tools/verifyColoradoDefense.js +++ b/tools/verifyColoradoDefense.js @@ -8,7 +8,8 @@ // 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. +// 7. Wave-complete bonus breakdown (recap data) matches the score delta. +// 8. Monte-carlo "always fire at nearest incoming missile" bot run. import { BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities, @@ -134,6 +135,77 @@ console.log('Explosion/collision fixture'); check('missile far outside blast radius survives', sim2.enemyMissiles.length === 1); } +// ── 5b. Chain-reaction fixture ───────────────────────────────────────────────── + +console.log('Chain-reaction fixture'); +{ + const durSim = createGame(DEFAULT_CITIES, 1); + durSim.spawnExplosion(0, 0, 'player'); + const normalTotal = durSim.explosions[0].totalMs; + durSim.spawnExplosion(0, 0, 'chain', { + lethal: true, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS, maxR: TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT, + }); + const chainExp = durSim.explosions[1]; + check('chain explosion lasts exactly CHAIN_EXTRA_GROW_MS longer than a normal one', + chainExp.totalMs - normalTotal === TUNE.CHAIN_EXTRA_GROW_MS, `normal=${normalTotal} chain=${chainExp.totalMs}`); + check('the extra time is spent growing to full size, not lingering at it', + explosionRadius({ t: TUNE.BLAST_GROW_MS + 1, maxR: 90, extraGrow: TUNE.CHAIN_EXTRA_GROW_MS }) < 90, + 'radius already maxed before the slower grow phase finished'); + check('chain explosion max radius is CHAIN_R_MULT times a normal one', + chainExp.maxR === TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT, + `expected ${TUNE.BLAST_R_BASE * TUNE.CHAIN_R_MULT} got ${chainExp.maxR}`); + + const sim = createGame(DEFAULT_CITIES, 21); + const target = sim.cities[0]; + 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 primaryPos = lerpPos(sim.enemyMissiles[0]); + const base = pickFiringBase(sim, primaryPos.x, primaryPos.y); + fireInterceptor(sim, base, primaryPos.x, primaryPos.y); + // Note: the interceptor itself takes real flight time to arrive (the + // original blast doesn't start at t=0), so a fixed-schedule secondary + // missile can't be pre-timed reliably. Instead, inject a near-stationary + // missile inside the chain's radius only once the chain blast is confirmed + // under way (well after the original — which shares the same radius — + // would have already faded), so the chain is unambiguously the killer. + + let primaryKillElapsed = null; + let chainExplosionElapsed = null; + let secondaryInjected = false; + let secondaryKilledByChain = false; + let elapsed = 0; + for (let steps = 0; steps < 300; steps += 1) { + const events = step(sim, 16); + elapsed += 16; + for (const ev of events) { + if (ev.type === 'missileDestroyed' && !ev.chained && primaryKillElapsed === null) primaryKillElapsed = elapsed; + if (ev.type === 'explosion' && ev.owner === 'chain' && chainExplosionElapsed === null) chainExplosionElapsed = elapsed; + if (ev.type === 'missileDestroyed' && ev.chained) secondaryKilledByChain = true; + } + // The original blast is only guaranteed faded 670ms after IT spawns + // (~primaryKillElapsed). The chain (spawned ~250ms after the kill) grows + // to a 135px max over 1100ms, passing the 50px test distance ~407ms into + // its own life (250+407=657ms after the kill) — wait past both before + // injecting, well inside its 1670ms total lethal window. + if (!secondaryInjected && primaryKillElapsed !== null && elapsed >= primaryKillElapsed + 1000) { + sim.enemyMissiles.push({ + id: sim.nextId++, fromX: primaryPos.x - 50, fromY: primaryPos.y, toX: primaryPos.x - 50, toY: primaryPos.y, + targetSlot: sim.cities[1].slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true, + }); + secondaryInjected = true; + } + } + check('primary missile destroyed by direct hit', primaryKillElapsed !== null); + check('a chain explosion follows ~CHAIN_DELAY_MS later', + chainExplosionElapsed !== null + && Math.abs((chainExplosionElapsed - primaryKillElapsed) - TUNE.CHAIN_DELAY_MS) <= 16, + `primary=${primaryKillElapsed} chain=${chainExplosionElapsed}`); + check('missile dropped inside the chain blast is destroyed by it', secondaryKilledByChain); + check('chain reaction bounded to one extra tier (no dangling pending chains)', sim.pendingChains.length === 0); +} + // ── 6. Palette progression ──────────────────────────────────────────────────── console.log('Palette progression'); @@ -146,7 +218,37 @@ console.log('Palette progression'); check('palette index clamps at array end', paletteIndexForWave(9999) === PALETTES.length - 1); } -// ── 7. Monte-carlo bot run ──────────────────────────────────────────────────── +// ── 7. Wave-complete bonus breakdown ────────────────────────────────────────── + +console.log('Wave-complete bonus breakdown'); +{ + const sim = createGame(DEFAULT_CITIES, 99); + sim.cities[0].alive = false; // 5 cities survive + sim.bases[0].ammo = 4; + sim.bases[1].ammo = 0; + sim.bases[2].ammo = 7; + const scoreBefore = sim.score; + + const events = []; + const origEmit = sim.emit.bind(sim); + sim.emit = (type, data) => { events.push({ type, ...data }); origEmit(type, data); }; + sim.completeWave(); + + const e = events.find((ev) => ev.type === 'waveComplete'); + check('waveComplete event emitted', !!e); + check('aliveCityCount matches surviving cities', e.aliveCityCount === 5, `got ${e.aliveCityCount}`); + check('cityBonus = aliveCityCount * CITY_BONUS', e.cityBonus === 5 * TUNE.CITY_BONUS); + check('preAmmo snapshot matches pre-refill ammo', e.preAmmo.join(',') === '4,0,7', e.preAmmo.join(',')); + check('totalAmmo sums preAmmo', e.totalAmmo === 11, `got ${e.totalAmmo}`); + check('ammoBonus = totalAmmo * SCORE_PER_KILL', e.ammoBonus === 11 * TUNE.SCORE_PER_KILL); + check('score increased by exactly cityBonus + ammoBonus', + sim.score === scoreBefore + e.cityBonus + e.ammoBonus, + `before=${scoreBefore} after=${sim.score}`); + check('surviving bases refilled to full ammo after transition', + sim.bases[0].ammo === TUNE.BASE_AMMO && sim.bases[2].ammo === TUNE.BASE_AMMO); +} + +// ── 8. Monte-carlo bot run ──────────────────────────────────────────────────── console.log('Monte-carlo bot run'); {