feat(coloradodefense): add chain reactions, inter-wave recap, vector skyline, and curved CRT

- Chain reaction explosions: player kills trigger delayed secondary blasts
  that can destroy nearby missiles (bounded to one extra tier)
- Inter-wave "COLORADO DEFENDED" recap animation showing surviving cities
  (+500 each) and leftover missiles (+25 each) with tallying sounds
- Dynamic wave bonus: replaced flat SCORE_WAVE_BONUS with per-surviving-city
  and per-remaining-missile bonuses, emitted in waveComplete event data
- Vector-style skyline: hand-authored Rocky Mountain ridgelines with snow
  caps, building presets for varied city skylines, wireframe silos/cities
- Minimal VectorFont module for the recap banner text
- Curved CRT overlay: WebGL barrel-distortion pipeline with chromatic
  aberration, sheen highlight, vignette, and pulse on impacts
- Three new 8-bit SFX: explode, explode2 (chain), count (tally)
- Updated verification tests for chain reactions, wave bonus breakdown
This commit is contained in:
Brian Fertig 2026-07-13 19:52:55 -06:00
parent dadf8c28dc
commit db1e36a42c
11 changed files with 686 additions and 99 deletions

BIN
assets/fx/8bit-count.mp3 Normal file

Binary file not shown.

BIN
assets/fx/8bit-explode.mp3 Normal file

Binary file not shown.

BIN
assets/fx/8bit-explode2.mp3 Normal file

Binary file not shown.

View File

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

View File

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

View File

@ -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;
}
}

View File

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

View File

@ -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();
},
};

View File

@ -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); },
};
}

View File

@ -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',

View File

@ -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');
{