Compare commits

...

3 Commits

Author SHA1 Message Date
Brian Fertig bfa2359ea2 Adjustments to Colorado Defense 2026-07-14 08:17:57 -06:00
Brian Fertig 6cef254dde refactor(coloradodefense): replace MIRV splitAt/splitDone with split-zone geometry
- Replace time-based splitAt/splitDone with screen-space Y zones (SPLIT_ZONE_1
  at top third, SPLIT_ZONE_2 at halfway) for more predictable MIRV behavior
- Add MIRV_RESPLIT_WAVE (wave 8): from wave 8 onward, MIRV buses split twice
  instead of once, creating more late-game pressure
- Lower early-game difficulty: MIRV starts at wave 3 (was 6), reduced base
  chance (0.05 vs 0.15), increased growth rate, lowered max (0.4 vs 0.5)
- MIRV trunk continues flying toward its target after each split
- Updated verification tests for split-zone geometry and new missile fields
- Cleaned up obsolete splitAt/splitDone fields across test fixtures
2026-07-13 19:53:41 -06:00
Brian Fertig db1e36a42c 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
2026-07-13 19:52:55 -06:00
11 changed files with 1236 additions and 110 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,29 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
this.drawGround();
break;
case 'missileDestroyed':
playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE);
break;
case 'planeSpawned':
playSound(this, SFX.WOOSH);
break;
case 'planeDestroyed':
playSound(this, SFX.EIGHTBIT_EXPLODE);
this.crt.pulse(0.5, 180);
this.showFloatingScore(e.x, e.y, e.score);
break;
case 'ufoSpawned':
playSound(this, SFX.SCIFI_RISER);
break;
case 'ufoShot':
playSound(this, SFX.LASER_ZAP);
break;
case 'ufoDestroyed':
playSound(this, SFX.EIGHTBIT_EXPLODE);
this.crt.pulse(0.8, 260);
this.showFloatingScore(e.x, e.y, e.score);
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,17 +338,246 @@ 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);
}
if (this.state.plane) this.drawPlane(g, this.state.plane);
if (this.state.ufo) this.drawUfo(g, this.state.ufo);
for (const p of this.state.debris) this.drawDebris(g, p);
this.updateHud();
}
// Slow bonus plane — a simple swept-wing vector silhouette, mirrored by
// flight direction, with the same glow-stroke treatment as everything else.
drawPlane(g, plane) {
const { x, y, dir } = plane;
const fx = (v) => x + v * dir;
const draw = () => {
g.beginPath(); g.moveTo(fx(30), y); g.lineTo(fx(-26), y); g.strokePath();
g.beginPath(); g.moveTo(fx(4), y); g.lineTo(fx(-12), y - 26); g.strokePath();
g.beginPath(); g.moveTo(fx(4), y); g.lineTo(fx(-12), y + 26); g.strokePath();
g.beginPath(); g.moveTo(fx(-22), y); g.lineTo(fx(-30), y - 9); g.strokePath();
g.beginPath(); g.moveTo(fx(-22), y); g.lineTo(fx(-30), y + 9); g.strokePath();
};
g.lineStyle(5, this.C.accent, 0.16);
draw();
g.lineStyle(2, this.C.accent, 1);
draw();
g.fillStyle(0xffffff, 0.9);
g.fillCircle(fx(30), y, 2);
}
// Bonus UFO — a classic saucer + dome silhouette (glow-stroke, like
// everything else) with a tractor-beam glow underneath and a ring of
// rim lights that blink in an alternating pattern.
drawUfo(g, ufo) {
const { x, y } = ufo;
const rimW = 76; const rimH = 20;
const domeW = 38; const domeH = 24;
const draw = () => {
g.strokeEllipse(x, y, rimW, rimH);
g.strokeEllipse(x, y - 10, domeW, domeH);
g.beginPath();
g.moveTo(x - 14, y + 6);
g.lineTo(x, y + 28);
g.lineTo(x + 14, y + 6);
g.strokePath();
};
g.lineStyle(6, this.C.accent, 0.16);
draw();
g.lineStyle(2, this.C.accent, 1);
draw();
g.fillStyle(this.C.chain, 0.12);
g.fillEllipse(x, y, rimW - 4, rimH - 4);
const blink = Math.floor(this.time.now / 200) % 2 === 0;
const lightCount = 7;
for (let i = 0; i < lightCount; i += 1) {
const theta = (i / lightCount) * Math.PI * 2;
const lx = x + Math.cos(theta) * (rimW / 2 - 4);
const ly = y + Math.sin(theta) * (rimH / 2 - 2);
const on = (i % 2 === 0) === blink;
g.fillStyle(on ? 0xffffff : this.C.accent, on ? 1 : 0.4);
g.fillCircle(lx, ly, 2.4);
}
}
// A small rotating shard for each piece of wreckage (plane or UFO).
drawDebris(g, p) {
const len = 10;
const dx = Math.cos(p.rot) * len;
const dy = Math.sin(p.rot) * len;
g.lineStyle(3, 0xffaa66, 0.9);
g.lineBetween(p.x - dx, p.y - dy, p.x + dx, p.y + dy);
}
showFloatingScore(x, y, amount) {
const text = this.add.text(x, y, `+${amount}`, {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '26px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.ui);
this.tweens.add({
targets: text, y: y - 60, alpha: 0, duration: 900, ease: 'Cubic.easeOut',
onComplete: () => text.destroy(),
});
}
// ── 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

@ -55,14 +55,53 @@ 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_WAVE: 3,
MIRV_CHANCE_BASE: 0.05, MIRV_CHANCE_GROWTH: 0.025, MIRV_CHANCE_MAX: 0.4,
MIRV_CHILDREN: [2, 3],
// Screen-space bands (fraction of GAME_HEIGHT) a MIRV missile splits
// within. Through wave 7 it splits once, in the top third; from wave 8 on
// the same missile splits again lower down, around the halfway mark.
MIRV_RESPLIT_WAVE: 8,
SPLIT_ZONE_1: [0.20, 0.333],
SPLIT_ZONE_2: [0.45, 0.55],
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,
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,
// A slow bonus plane that crosses the screen every so often from wave 2 on.
PLANE_WAVE: 2,
PLANE_CROSS_MS: 8000,
PLANE_SPAWN_MIN_MS: 15000,
PLANE_SPAWN_MAX_MS: 25000,
PLANE_Y_FRAC: 0.12,
PLANE_SCORE: 150,
PLANE_DEBRIS_COUNT: [4, 6],
PLANE_DEBRIS_H_DAMPING: 0.6,
PLANE_DEBRIS_GRAVITY: 0.0003,
// A tougher bonus UFO from wave 4 on: weaves up/down and fires its own
// MIRVs twice if left alive long enough to cross the screen.
UFO_WAVE: 4,
UFO_CROSS_MS: 10000,
UFO_SPAWN_MIN_MS: 20000,
UFO_SPAWN_MAX_MS: 32000,
UFO_Y_MIN_FRAC: 0.06,
UFO_Y_MAX_FRAC: 0.28,
UFO_MAX_ANGLE_DEG: 25,
UFO_TURNS: [2, 3],
UFO_SHOTS: 2,
UFO_SHOT_GAP_MIN_MS: 3000,
UFO_SHOT_GAP_MAX_MS: 4000,
UFO_FIRST_SHOT_MS: 2500,
UFO_SCORE: 1000,
UFO_DEBRIS_COUNT: [7, 10],
UFO_DEBRIS_H_DAMPING: 0.5,
UFO_DEBRIS_GRAVITY: 0.00025,
};
export function spawnInterval(wave) {
@ -82,11 +121,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) {
@ -95,6 +134,12 @@ export function paletteIndexForWave(wave) {
function lerp(a, b, t) { return a + (b - a) * t; }
// A random screen-space Y within a [loFrac, hiFrac] band of GAME_HEIGHT.
function pickSplitY(rng, zone) {
const [lo, hi] = zone;
return HEIGHT * (lo + rng() * (hi - lo));
}
// 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);
@ -102,7 +147,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,11 +178,22 @@ export class Sim {
this.enemyMissiles = [];
this.interceptors = [];
this.explosions = [];
this.pendingChains = []; // missiles awaiting their delayed self-detonation
this.waveT = 0;
this.waveSpawned = 0;
this.waveQuota = waveQuota(this.wave);
this.spawnT = 0;
this.plane = null; // the single bonus plane currently in flight, if any
this.ufo = null; // the single bonus UFO currently in flight, if any
this.debris = []; // shared falling wreckage for a destroyed plane or UFO
this.planeSpawnT = 0;
this.nextPlaneAt = TUNE.PLANE_SPAWN_MIN_MS
+ this.rng() * (TUNE.PLANE_SPAWN_MAX_MS - TUNE.PLANE_SPAWN_MIN_MS);
this.ufoSpawnT = 0;
this.nextUfoAt = TUNE.UFO_SPAWN_MIN_MS
+ this.rng() * (TUNE.UFO_SPAWN_MAX_MS - TUNE.UFO_SPAWN_MIN_MS);
}
emit(type, data = {}) { this.events.push({ type, ...data }); }
@ -152,7 +210,13 @@ export class Sim {
return weighted[Math.floor(this.rng() * weighted.length)];
}
spawnEnemyMissile(fromOverride) {
// fromOverride lets a caller launch from an arbitrary in-air position (a
// MIRV child bursting off its parent, or a UFO's own shot) instead of the
// top edge. forceMirv is for a UFO's guaranteed MIRV payload — otherwise
// only a freshly-spawned, top-edge missile can randomly become one; a
// MIRV's own children (fromOverride, no force) are always simple 'single's
// so the split tree stays bounded.
spawnEnemyMissile(fromOverride, forceMirv = false) {
const target = this.pickTargetSlot();
if (!target) return null;
const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160);
@ -160,11 +224,14 @@ export class Sim {
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 isMirv = forceMirv || (!fromOverride && this.rng() < mirvChance(this.wave));
const kind = isMirv ? 'mirv' : 'single';
const splitsLeft = isMirv ? (this.wave >= TUNE.MIRV_RESPLIT_WAVE ? 2 : 1) : 0;
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,
t: 0, dur, kind, splitsLeft,
splitY: splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_1) : null,
};
this.enemyMissiles.push(missile);
return missile;
@ -199,22 +266,94 @@ 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 });
}
spawnPlane() {
const dir = this.rng() < 0.5 ? 1 : -1;
const y = HEIGHT * TUNE.PLANE_Y_FRAC;
const speed = WIDTH / TUNE.PLANE_CROSS_MS; // px/ms — crosses the full width in PLANE_CROSS_MS
const x = dir === 1 ? -60 : WIDTH + 60;
this.plane = { id: this.nextId++, x, y, dir, speed, vx: dir * speed, vy: 0 };
this.emit('planeSpawned', { x, y, dir });
}
spawnUfo() {
const dir = this.rng() < 0.5 ? 1 : -1;
const y = HEIGHT * (TUNE.UFO_Y_MIN_FRAC + this.rng() * (TUNE.UFO_Y_MAX_FRAC - TUNE.UFO_Y_MIN_FRAC));
const speed = WIDTH / TUNE.UFO_CROSS_MS; // px/ms — crosses the full width in UFO_CROSS_MS
const x = dir === 1 ? -70 : WIDTH + 70;
const [tLo, tHi] = TUNE.UFO_TURNS;
const turns = tLo + Math.floor(this.rng() * (tHi - tLo + 1));
const turnSchedule = [];
for (let i = 0; i < turns; i += 1) {
const frac = (i + 1) / (turns + 1);
turnSchedule.push(Math.max(200, TUNE.UFO_CROSS_MS * frac + (this.rng() - 0.5) * 800));
}
turnSchedule.sort((a, b) => a - b);
const shotGap = TUNE.UFO_SHOT_GAP_MIN_MS + this.rng() * (TUNE.UFO_SHOT_GAP_MAX_MS - TUNE.UFO_SHOT_GAP_MIN_MS);
this.ufo = {
id: this.nextId++, x, y, dir, speed, vx: dir * speed, vy: 0,
elapsed: 0, turnSchedule, turnIdx: 0,
shotsLeft: TUNE.UFO_SHOTS, nextShotAt: TUNE.UFO_FIRST_SHOT_MS + (this.rng() - 0.5) * 500, shotGap,
};
this.emit('ufoSpawned', { x, y, dir });
}
fireUfoShot(ufo) {
const missile = this.spawnEnemyMissile({ x: ufo.x, y: ufo.y }, true);
if (missile) this.emit('ufoShot', { x: ufo.x, y: ufo.y });
}
spawnDebris(source, opts) {
const [lo, hi] = opts.count;
const n = lo + Math.floor(this.rng() * (hi - lo + 1));
for (let i = 0; i < n; i += 1) {
this.debris.push({
id: this.nextId++,
x: source.x + (this.rng() - 0.5) * 60,
y: source.y,
vx: source.vx * opts.hDamping,
vy: source.vy * opts.hDamping - 0.05 - this.rng() * 0.04,
rot: this.rng() * Math.PI * 2,
rotSpeed: (this.rng() - 0.5) * 0.01,
gravity: opts.gravity,
});
}
}
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) {
@ -233,12 +372,18 @@ export class Sim {
}
// Advance enemy missiles; collect MIRV splits without mutating mid-loop.
// A MIRV keeps flying toward its own target after each split; once
// MIRV_RESPLIT_WAVE is reached it gets a second split lower down.
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 (m.kind === 'mirv' && m.splitsLeft > 0 && m.t < 1) {
const pos = lerpPos(m);
if (pos.y >= m.splitY) {
m.splitsLeft -= 1;
newChildren.push(...this.makeMirvChildren(m));
m.splitY = m.splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_2) : null;
}
}
}
if (newChildren.length) this.enemyMissiles.push(...newChildren);
@ -260,13 +405,94 @@ export class Sim {
}
this.interceptors = stillInterceptors;
// Advance explosions and resolve collisions against enemy missiles.
// Bonus plane: ticks on its own timer once unlocked, independent of wave
// spawning/quota, so it keeps showing up smoothly across wave transitions.
if (this.wave >= TUNE.PLANE_WAVE) {
this.planeSpawnT += dtMs;
if (!this.plane && this.planeSpawnT >= this.nextPlaneAt) {
this.spawnPlane();
this.planeSpawnT = 0;
this.nextPlaneAt = TUNE.PLANE_SPAWN_MIN_MS
+ this.rng() * (TUNE.PLANE_SPAWN_MAX_MS - TUNE.PLANE_SPAWN_MIN_MS);
}
}
if (this.plane) {
this.plane.x += this.plane.vx * dtMs;
if (this.plane.x < -100 || this.plane.x > WIDTH + 100) this.plane = null;
}
// Bonus UFO: same unlock-timer pattern as the plane, but it weaves its
// heading a few times per flight and fires its own MIRVs if left alive.
if (this.wave >= TUNE.UFO_WAVE) {
this.ufoSpawnT += dtMs;
if (!this.ufo && this.ufoSpawnT >= this.nextUfoAt) {
this.spawnUfo();
this.ufoSpawnT = 0;
this.nextUfoAt = TUNE.UFO_SPAWN_MIN_MS
+ this.rng() * (TUNE.UFO_SPAWN_MAX_MS - TUNE.UFO_SPAWN_MIN_MS);
}
}
if (this.ufo) {
const u = this.ufo;
u.elapsed += dtMs;
if (u.turnIdx < u.turnSchedule.length && u.elapsed >= u.turnSchedule[u.turnIdx]) {
u.turnIdx += 1;
const angle = (this.rng() * 2 - 1) * TUNE.UFO_MAX_ANGLE_DEG * (Math.PI / 180);
u.vx = Math.cos(angle) * u.speed * u.dir;
u.vy = Math.sin(angle) * u.speed;
this.emit('ufoTurn', { x: u.x, y: u.y });
}
u.x += u.vx * dtMs;
u.y += u.vy * dtMs;
const yMin = HEIGHT * TUNE.UFO_Y_MIN_FRAC;
const yMax = HEIGHT * TUNE.UFO_Y_MAX_FRAC;
if (u.y < yMin) { u.y = yMin; u.vy = 0; }
if (u.y > yMax) { u.y = yMax; u.vy = 0; }
if (u.shotsLeft > 0) {
u.nextShotAt -= dtMs;
if (u.nextShotAt <= 0) {
this.fireUfoShot(u);
u.shotsLeft -= 1;
u.nextShotAt = u.shotGap;
}
}
if (u.x < -100 || u.x > WIDTH + 100) this.ufo = null;
}
this.debris = this.debris.filter((p) => {
p.vy += p.gravity * dtMs;
p.x += p.vx * dtMs;
p.y += p.vy * dtMs;
p.rot += p.rotSpeed * dtMs;
return p.y < GROUND_Y + 40;
});
// 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,11 +501,33 @@ 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;
});
if (this.plane && Math.hypot(this.plane.x - e.x, this.plane.y - e.y) <= r) {
this.score += TUNE.PLANE_SCORE;
this.emit('planeDestroyed', { x: this.plane.x, y: this.plane.y, score: TUNE.PLANE_SCORE });
this.spawnDebris(this.plane, {
count: TUNE.PLANE_DEBRIS_COUNT, hDamping: TUNE.PLANE_DEBRIS_H_DAMPING, gravity: TUNE.PLANE_DEBRIS_GRAVITY,
});
this.plane = null;
}
if (this.ufo && Math.hypot(this.ufo.x - e.x, this.ufo.y - e.y) <= r) {
this.score += TUNE.UFO_SCORE;
this.emit('ufoDestroyed', { x: this.ufo.x, y: this.ufo.y, score: TUNE.UFO_SCORE });
this.spawnDebris(this.ufo, {
count: TUNE.UFO_DEBRIS_COUNT, hDamping: TUNE.UFO_DEBRIS_H_DAMPING, gravity: TUNE.UFO_DEBRIS_GRAVITY,
});
this.ufo = null;
}
}
if (this.waveSpawned >= this.waveQuota && this.enemyMissiles.length === 0 && this.interceptors.length === 0) {

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

@ -6,12 +6,16 @@
// 2. City-pool draw correctness + malformed-pool fallback.
// 3. Difficulty escalation monotonicity across waves 1-40 (bounded).
// 4. MIRV step-unlock threshold.
// 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8).
// 5. Scripted explosion/collision fixture.
// 5c. Bonus plane fixture (unlock wave, crossing speed, hit/score/debris).
// 5d. Bonus UFO fixture (unlock wave, weaving/shots, hit/score/debris).
// 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,
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, WIDTH, GROUND_Y, slotX, mulberry32, pickCities,
DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance,
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
fireInterceptor, lerpPos, explosionRadius,
@ -97,6 +101,88 @@ console.log('MIRV step-unlock');
check('mirvChance bounded at max', mirvChance(999) <= TUNE.MIRV_CHANCE_MAX);
}
// ── 4b. MIRV split-zone geometry ────────────────────────────────────────────────
console.log('MIRV split-zone geometry');
{
// spawnEnemyMissile() itself: force isMirv=true (rng()=0 < any nonzero
// mirvChance) and check the resulting splitsLeft/splitY at both tiers.
const spawnLow = createGame(DEFAULT_CITIES, 3);
spawnLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1;
spawnLow.rng = () => 0;
const missileLow = spawnLow.spawnEnemyMissile();
check('low-wave spawn becomes a MIRV with splitsLeft=1',
missileLow.kind === 'mirv' && missileLow.splitsLeft === 1);
check('low-wave MIRV splitY lands in SPLIT_ZONE_1',
missileLow.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileLow.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1],
`splitY=${missileLow.splitY}`);
const spawnHigh = createGame(DEFAULT_CITIES, 3);
spawnHigh.wave = TUNE.MIRV_RESPLIT_WAVE;
spawnHigh.rng = () => 0;
const missileHigh = spawnHigh.spawnEnemyMissile();
check('wave>=MIRV_RESPLIT_WAVE spawn becomes a MIRV with splitsLeft=2',
missileHigh.kind === 'mirv' && missileHigh.splitsLeft === 2);
check('its first splitY still lands in SPLIT_ZONE_1',
missileHigh.splitY >= HEIGHT * TUNE.SPLIT_ZONE_1[0] && missileHigh.splitY <= HEIGHT * TUNE.SPLIT_ZONE_1[1],
`splitY=${missileHigh.splitY}`);
// Full flight, low wave: exactly one split, and the children never re-split.
// (waveSpawned pinned to waveQuota so the sim's own spawn timer can't add
// unrelated missiles that would pollute the length-based split count.)
const simLow = createGame(DEFAULT_CITIES, 5);
simLow.wave = TUNE.MIRV_RESPLIT_WAVE - 1;
simLow.waveSpawned = simLow.waveQuota;
const t1 = simLow.cities[0];
simLow.enemyMissiles.push({
id: simLow.nextId++, fromX: t1.x, fromY: 0, toX: t1.x, toY: t1.y,
targetSlot: t1.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv',
splitsLeft: 1, splitY: HEIGHT * 0.25,
});
let splitCountLow = 0;
let childKinds = [];
for (let i = 0; i < 250; i += 1) {
const before = simLow.enemyMissiles.length;
step(simLow, 16);
if (simLow.enemyMissiles.length > before) {
splitCountLow += 1;
childKinds = simLow.enemyMissiles.filter((m) => m.kind !== 'mirv').map((m) => m.kind);
}
}
check('low-wave MIRV splits exactly once over its full flight', splitCountLow === 1, `splitCount=${splitCountLow}`);
check('its children never re-split', childKinds.length > 0 && childKinds.every((k) => k === 'single'));
// Full flight, wave >= MIRV_RESPLIT_WAVE: splits twice, second time in zone 2.
const simHigh = createGame(DEFAULT_CITIES, 6);
simHigh.wave = TUNE.MIRV_RESPLIT_WAVE;
simHigh.waveSpawned = simHigh.waveQuota;
const t2 = simHigh.cities[0];
simHigh.enemyMissiles.push({
id: simHigh.nextId++, fromX: t2.x, fromY: 0, toX: t2.x, toY: t2.y,
targetSlot: t2.slot, targetKind: 'city', t: 0, dur: 5000, kind: 'mirv',
splitsLeft: 2, splitY: HEIGHT * 0.25,
});
let splitEvents = 0;
let sawZone2SplitY = false;
// Stops well before the trunk's own ~5000ms impact — once it lands and
// completeWave() fires, waveSpawned resets and the spawn timer could add
// unrelated missiles again, polluting the length-based split count.
for (let i = 0; i < 280; i += 1) {
const before = simHigh.enemyMissiles.length;
step(simHigh, 16);
if (simHigh.enemyMissiles.length > before) {
splitEvents += 1;
const trunk = simHigh.enemyMissiles.find((m) => m.kind === 'mirv');
if (trunk && trunk.splitY !== null) {
const frac = trunk.splitY / HEIGHT;
if (frac >= TUNE.SPLIT_ZONE_2[0] && frac <= TUNE.SPLIT_ZONE_2[1]) sawZone2SplitY = true;
}
}
}
check('wave>=MIRV_RESPLIT_WAVE MIRV splits exactly twice', splitEvents === 2, `splitEvents=${splitEvents}`);
check('its second split is scheduled in SPLIT_ZONE_2', sawZone2SplitY);
}
// ── 5. Explosion/collision fixture ────────────────────────────────────────────
console.log('Explosion/collision fixture');
@ -107,7 +193,7 @@ console.log('Explosion/collision fixture');
// 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,
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitsLeft: 0,
});
const missilePos = lerpPos(sim.enemyMissiles[0]);
const base = pickFiringBase(sim, missilePos.x, missilePos.y);
@ -126,7 +212,7 @@ console.log('Explosion/collision fixture');
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,
targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitsLeft: 0,
});
const far = lerpPos(sim2.enemyMissiles[0]);
fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y);
@ -134,6 +220,230 @@ 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', splitsLeft: 0,
});
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', splitsLeft: 0,
});
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);
}
// ── 5c. Bonus plane fixture ────────────────────────────────────────────────────
console.log('Bonus plane fixture');
{
// Locked before PLANE_WAVE, even given plenty of time. waveQuota pinned to
// Infinity so a real wave can never complete mid-test and silently bump
// `wave` past the threshold being tested (ordinary missile spawning is
// left running — it's irrelevant noise for these plane-only assertions).
const simLocked = createGame(DEFAULT_CITIES, 40);
simLocked.wave = TUNE.PLANE_WAVE - 1;
simLocked.waveQuota = Infinity;
for (let i = 0; i < Math.ceil((TUNE.PLANE_SPAWN_MAX_MS + 2000) / 16); i += 1) step(simLocked, 16);
check('no plane spawns before PLANE_WAVE', simLocked.plane === null);
// Unlocked: spawns once its timer elapses.
const simSpawn = createGame(DEFAULT_CITIES, 41);
simSpawn.wave = TUNE.PLANE_WAVE;
simSpawn.nextPlaneAt = 100; // force a near-immediate spawn, deterministically
for (let i = 0; i < 20 && !simSpawn.plane; i += 1) step(simSpawn, 16);
check('plane spawns once its timer elapses at/after PLANE_WAVE', simSpawn.plane !== null);
if (simSpawn.plane) {
const { x, dir } = simSpawn.plane;
check('plane starts just off the correct edge for its direction',
(dir === 1 && x < 0) || (dir === -1 && x > WIDTH), `dir=${dir} x=${x}`);
check('plane speed crosses the full width in exactly PLANE_CROSS_MS',
Math.abs(simSpawn.plane.speed * TUNE.PLANE_CROSS_MS - WIDTH) < 1e-6);
}
// Hit: a lethal explosion at the plane's position destroys it, scores
// PLANE_SCORE exactly, and spawns debris that keeps the plane's heading.
const simHit = createGame(DEFAULT_CITIES, 42);
simHit.wave = TUNE.PLANE_WAVE;
simHit.waveQuota = Infinity;
const planeY = HEIGHT * TUNE.PLANE_Y_FRAC;
const planeSpeed = WIDTH / TUNE.PLANE_CROSS_MS;
simHit.plane = { id: simHit.nextId++, x: 500, y: planeY, dir: 1, speed: planeSpeed, vx: planeSpeed, vy: 0 };
const scoreBefore = simHit.score;
simHit.spawnExplosion(500, planeY, 'player');
let planeDestroyedEvent = null;
for (let i = 0; i < 50 && !planeDestroyedEvent; i += 1) {
const events = step(simHit, 16);
planeDestroyedEvent = events.find((e) => e.type === 'planeDestroyed') || planeDestroyedEvent;
}
check('a lethal explosion at the plane destroys it', planeDestroyedEvent !== null);
check('plane kill awards exactly PLANE_SCORE', simHit.score === scoreBefore + TUNE.PLANE_SCORE,
`before=${scoreBefore} after=${simHit.score}`);
check('plane is cleared after being destroyed', simHit.plane === null);
const [dLo, dHi] = TUNE.PLANE_DEBRIS_COUNT;
check('debris count is within PLANE_DEBRIS_COUNT range',
simHit.debris.length >= dLo && simHit.debris.length <= dHi, `count=${simHit.debris.length}`);
check("debris keeps the plane's horizontal momentum (rightward here)",
simHit.debris.length > 0 && simHit.debris.every((p) => p.vx > 0));
// Debris eventually falls and clears, without exceeding the ground band.
let debrisSteps = 0;
let maxY = 0;
while (simHit.debris.length > 0 && debrisSteps < 3000) {
for (const p of simHit.debris) maxY = Math.max(maxY, p.y);
step(simHit, 16);
debrisSteps += 1;
}
check('debris eventually clears (lands) within a bounded number of steps',
simHit.debris.length === 0, `steps=${debrisSteps}`);
check('debris never falls past the ground band', maxY <= GROUND_Y + 40, `maxY=${maxY}`);
}
// ── 5d. Bonus UFO fixture ───────────────────────────────────────────────────────
console.log('Bonus UFO fixture');
{
// Locked before UFO_WAVE, even given plenty of time.
const simLocked = createGame(DEFAULT_CITIES, 50);
simLocked.wave = TUNE.UFO_WAVE - 1;
simLocked.waveQuota = Infinity;
for (let i = 0; i < Math.ceil((TUNE.UFO_SPAWN_MAX_MS + 2000) / 16); i += 1) step(simLocked, 16);
check('no UFO spawns before UFO_WAVE', simLocked.ufo === null);
// Unlocked: spawns with the right edge/speed, weaves within ±UFO_MAX_ANGLE_DEG,
// and fires exactly UFO_SHOTS MIRVs (3-4s apart) if left alive.
const simUfo = createGame(DEFAULT_CITIES, 51);
simUfo.wave = TUNE.UFO_WAVE;
simUfo.waveQuota = Infinity;
simUfo.nextUfoAt = 100;
for (let i = 0; i < 20 && !simUfo.ufo; i += 1) step(simUfo, 16);
check('UFO spawns once its timer elapses at/after UFO_WAVE', simUfo.ufo !== null);
if (simUfo.ufo) {
const { x, dir, speed } = simUfo.ufo;
check('UFO starts just off the correct edge for its direction',
(dir === 1 && x < 0) || (dir === -1 && x > WIDTH), `dir=${dir} x=${x}`);
check('UFO speed crosses the full width in exactly UFO_CROSS_MS',
Math.abs(speed * TUNE.UFO_CROSS_MS - WIDTH) < 1e-6);
check('UFO turn count is within UFO_TURNS range',
simUfo.ufo.turnSchedule.length >= TUNE.UFO_TURNS[0] && simUfo.ufo.turnSchedule.length <= TUNE.UFO_TURNS[1],
`turns=${simUfo.ufo.turnSchedule.length}`);
const maxVyAllowed = speed * Math.sin(TUNE.UFO_MAX_ANGLE_DEG * Math.PI / 180) + 1e-9;
let turnsSeen = 0;
let shots = 0;
const shotTimes = [];
let elapsed = 0;
let vyMaxSeen = 0;
for (let i = 0; i < 700 && simUfo.ufo; i += 1) {
const events = step(simUfo, 16);
elapsed += 16;
for (const ev of events) {
if (ev.type === 'ufoTurn') turnsSeen += 1;
if (ev.type === 'ufoShot') { shots += 1; shotTimes.push(elapsed); }
}
if (simUfo.ufo) vyMaxSeen = Math.max(vyMaxSeen, Math.abs(simUfo.ufo.vy));
}
check('UFO performs its scheduled direction changes',
turnsSeen >= TUNE.UFO_TURNS[0], `turnsSeen=${turnsSeen}`);
check('UFO never exceeds the ±UFO_MAX_ANGLE_DEG heading',
vyMaxSeen <= maxVyAllowed, `vyMaxSeen=${vyMaxSeen} allowed=${maxVyAllowed}`);
check('UFO fires exactly UFO_SHOTS MIRV shots if left alive', shots === TUNE.UFO_SHOTS, `shots=${shots}`);
if (shotTimes.length === 2) {
const gap = shotTimes[1] - shotTimes[0];
check('gap between the two shots is within the configured 3-4s window',
gap >= TUNE.UFO_SHOT_GAP_MIN_MS - 16 && gap <= TUNE.UFO_SHOT_GAP_MAX_MS + 16, `gap=${gap}`);
}
}
// Hit: any lethal explosion at the UFO's position destroys it, scores
// UFO_SCORE exactly, and spawns debris that keeps its current heading.
const simHit = createGame(DEFAULT_CITIES, 52);
simHit.wave = TUNE.UFO_WAVE;
simHit.waveQuota = Infinity;
const ufoY = HEIGHT * 0.15;
const ufoSpeed = WIDTH / TUNE.UFO_CROSS_MS;
simHit.ufo = {
id: simHit.nextId++, x: 600, y: ufoY, dir: 1, speed: ufoSpeed,
vx: ufoSpeed * 0.9, vy: ufoSpeed * 0.3, // mid-weave heading
elapsed: 0, turnSchedule: [], turnIdx: 0, shotsLeft: 0, nextShotAt: 999999, shotGap: 3500,
};
const ufoScoreBefore = simHit.score;
simHit.spawnExplosion(600, ufoY, 'player');
let ufoDestroyedEvent = null;
for (let i = 0; i < 50 && !ufoDestroyedEvent; i += 1) {
const events = step(simHit, 16);
ufoDestroyedEvent = events.find((e) => e.type === 'ufoDestroyed') || ufoDestroyedEvent;
}
check('a lethal explosion at the UFO destroys it', ufoDestroyedEvent !== null);
check('UFO kill awards exactly UFO_SCORE', simHit.score === ufoScoreBefore + TUNE.UFO_SCORE,
`before=${ufoScoreBefore} after=${simHit.score}`);
check('UFO is cleared after being destroyed', simHit.ufo === null);
const [uLo, uHi] = TUNE.UFO_DEBRIS_COUNT;
check('UFO debris count is within UFO_DEBRIS_COUNT range',
simHit.debris.length >= uLo && simHit.debris.length <= uHi, `count=${simHit.debris.length}`);
check("UFO debris keeps its current heading's momentum",
simHit.debris.length > 0 && simHit.debris.every((p) => p.vx > 0));
}
// ── 6. Palette progression ────────────────────────────────────────────────────
console.log('Palette progression');
@ -146,7 +456,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');
{
@ -184,7 +524,8 @@ console.log('Monte-carlo bot run');
if (aliveCities > prevCities) cityCountIncreased = true;
prevCities = aliveCities;
for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions]) {
const planeList = sim.plane ? [sim.plane] : [];
for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions, planeList, sim.debris]) {
for (const e of list) {
if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true;
}