Compare commits

..

No commits in common. "bfa2359ea22f3fbb0bfe2c056eef477912777e46" and "dadf8c28dc52c4fe49839159d6be88b6934efc6f" have entirely different histories.

11 changed files with 110 additions and 1236 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -9,41 +9,16 @@ import {
DEFAULT_CITIES, GROUND_Y, PALETTES, createGame, step, pickFiringBase,
fireInterceptor, lerpPos, explosionRadius,
} from './ColoradoDefenseLogic.js';
import { drawVectorText } from './VectorFont.js';
const D = { bg: -2, mountains: -1, ground: 0, fx: 2, ui: 30, overlay: 60 };
const D = { bg: -2, 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];
}
@ -58,13 +33,10 @@ 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());
@ -102,34 +74,9 @@ 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;
@ -151,98 +98,42 @@ 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) {
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);
g.fillStyle(this.C.accent, 1);
g.fillTriangle(x - 34, y, x + 34, y, x, y - 60);
g.fillStyle(0x0c0f16, 1);
g.fillRect(x - 10, y - 22, 20, 22);
for (let i = 0; i < base.ammo; i += 1) {
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);
g.fillStyle(0xffffff, 0.85);
g.fillCircle(x - 24 + (i % 5) * 12, y - 74 - Math.floor(i / 5) * 12, 3);
}
} else {
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);
g.fillStyle(0x2a2a2a, 1);
g.fillTriangle(x - 34, y, x + 34, y, x, y - 18);
g.lineStyle(3, 0x000000, 0.6);
g.lineBetween(x - 18, y - 22, x + 18, y - 2);
g.lineBetween(x + 18, y - 22, x - 18, y - 2);
}
}
drawCity(g, city) {
const { x, y } = city;
if (city.alive) {
const 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);
}
const blocks = [{ w: 20, h: 36 }, { w: 16, h: 56 }, { w: 24, h: 28 }, { w: 18, h: 46 }];
let cx = x - 44;
for (const b of blocks) {
g.fillStyle(this.C.trail, 0.9);
g.fillRect(cx, y - b.h, b.w, b.h);
cx += b.w + 6;
});
}
} else {
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);
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);
}
}
@ -286,29 +177,14 @@ 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.playWaveRecap(e);
this.C = PALETTES[e.paletteIdx];
this.crt.setIntensity({ accentTint: this.C.accent, scanlineTint: this.C.trail });
this.bgRect.setFillStyle(this.C.bgTop);
this.drawGround();
playSound(this, SFX.SCIFI_REVEAL);
break;
case 'gameOver':
this.onGameOver(e);
@ -338,246 +214,17 @@ 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 : (e.owner === 'chain' ? this.C.chain : 0xff4433);
const alpha = e.owner === 'chain' ? 0.62 : 0.55;
g.fillStyle(color, alpha);
const color = e.owner === 'player' ? this.C.explosion : 0xff4433;
g.fillStyle(color, 0.55);
g.fillCircle(e.x, e.y, r);
g.lineStyle(2, 0xffffff, 0.8);
g.strokeCircle(e.x, e.y, r);
}
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,53 +55,14 @@ 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: 3,
MIRV_CHANCE_BASE: 0.05, MIRV_CHANCE_GROWTH: 0.025, MIRV_CHANCE_MAX: 0.4,
MIRV_WAVE: 6,
MIRV_CHANCE_BASE: 0.15, MIRV_CHANCE_GROWTH: 0.02, MIRV_CHANCE_MAX: 0.5,
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,
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,
SCORE_WAVE_BONUS: 100,
};
export function spawnInterval(wave) {
@ -121,11 +82,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, 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 },
{ bgTop: 0x0a0e1a, bgBottom: 0x1a1030, trail: 0x66ffea, explosion: 0xffdd55, accent: 0x00f0ff, ground: 0x141a2e },
{ bgTop: 0x1a0a0e, bgBottom: 0x300a1a, trail: 0xff6bcb, explosion: 0xffaa33, accent: 0xff2bd6, ground: 0x2a0f1a },
{ bgTop: 0x0a1a0e, bgBottom: 0x0a301a, trail: 0x8dff6b, explosion: 0xffee44, accent: 0x45d17a, ground: 0x0f2a18 },
{ bgTop: 0x1a140a, bgBottom: 0x301c0a, trail: 0xffb366, explosion: 0xff5555, accent: 0xff8a3c, ground: 0x2a1c0f },
{ bgTop: 0x120a1a, bgBottom: 0x260a30, trail: 0xc78dff, explosion: 0xff66c4, accent: 0xb84bff, ground: 0x1e0f2a },
];
export const PALETTE_WAVES = 4;
export function paletteIndexForWave(wave) {
@ -134,12 +95,6 @@ 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);
@ -147,9 +102,7 @@ export function lerpPos(entity) {
}
export function explosionRadius(e) {
const G = TUNE.BLAST_GROW_MS + (e.extraGrow || 0);
const H = TUNE.BLAST_HOLD_MS;
const F = TUNE.BLAST_FADE_MS;
const { BLAST_GROW_MS: G, BLAST_HOLD_MS: H, BLAST_FADE_MS: F } = TUNE;
const t = e.t;
if (t < G) return e.maxR * (t / G);
if (t < G + H) return e.maxR;
@ -178,22 +131,11 @@ 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 }); }
@ -210,13 +152,7 @@ export class Sim {
return weighted[Math.floor(this.rng() * weighted.length)];
}
// 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) {
spawnEnemyMissile(fromOverride) {
const target = this.pickTargetSlot();
if (!target) return null;
const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160);
@ -224,14 +160,11 @@ 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 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 kind = fromOverride ? 'single' : (this.rng() < mirvChance(this.wave) ? 'mirv' : 'single');
const missile = {
id: this.nextId++, fromX, fromY, toX: target.x, toY: target.y,
targetSlot: target.slot, targetKind: target.kind,
t: 0, dur, kind, splitsLeft,
splitY: splitsLeft > 0 ? pickSplitY(this.rng, TUNE.SPLIT_ZONE_1) : null,
t: 0, dur, kind, splitAt: 0.4 + this.rng() * 0.25, splitDone: false,
};
this.enemyMissiles.push(missile);
return missile;
@ -266,94 +199,22 @@ export class Sim {
this.spawnExplosion(m.toX, m.toY, 'enemy');
}
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,
});
}
spawnExplosion(x, y, owner) {
const totalMs = TUNE.BLAST_GROW_MS + TUNE.BLAST_HOLD_MS + TUNE.BLAST_FADE_MS;
this.explosions.push({ id: this.nextId++, x, y, t: 0, totalMs, maxR: TUNE.BLAST_R_BASE, owner });
this.emit('explosion', { x, y, owner });
}
completeWave() {
// 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 += cityBonus + ammoBonus;
this.score += TUNE.SCORE_WAVE_BONUS;
this.paletteIdx = paletteIndexForWave(this.wave);
this.emit('waveComplete', {
wave: this.wave, paletteIdx: this.paletteIdx,
preAmmo, aliveCityCount, cityBonus, totalAmmo, ammoBonus,
});
this.emit('waveComplete', { wave: this.wave, paletteIdx: this.paletteIdx });
}
step(dtMs) {
@ -372,18 +233,12 @@ 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.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 (m.kind === 'mirv' && !m.splitDone && m.t >= m.splitAt && m.t < 1) {
m.splitDone = true;
newChildren.push(...this.makeMirvChildren(m));
}
}
if (newChildren.length) this.enemyMissiles.push(...newChildren);
@ -405,94 +260,13 @@ export class Sim {
}
this.interceptors = stillInterceptors;
// 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.
// Advance explosions and resolve collisions against enemy missiles.
this.explosions = this.explosions.filter((e) => {
e.t += dtMs;
return e.t < e.totalMs;
});
for (const e of this.explosions) {
if (!e.lethal) continue;
if (e.owner !== 'player') continue;
const r = explosionRadius(e);
if (r <= 0) continue;
this.enemyMissiles = this.enemyMissiles.filter((m) => {
@ -501,33 +275,11 @@ export class Sim {
if (d <= r) {
const gained = TUNE.SCORE_PER_KILL * this.wave;
this.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 });
}
this.emit('missileDestroyed', { x: pos.x, y: pos.y, score: gained });
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

@ -1,57 +0,0 @@
// 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,9 +113,6 @@ 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,19 +1,15 @@
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 (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.
// Reusable "arcade cabinet" screen dressing: animated scanlines + a vector
// TV-style bevel/vignette. Drop this onto any arcade/console game scene.
//
// (src/games/balatro/BalatroCrtPipeline.js is a separate, single-consumer
// WebGL CRT effect — not reused here, but this module follows the same
// PostFXPipeline convention for its curvature half.)
// (src/games/balatro/BalatroCrtPipeline.js is a different, WebGL-shader-based
// CRT effect that's single-consumer and Canvas-incompatible — not reused here;
// this module is GameObject-based so it works on any renderer.)
//
// Usage:
// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a, curveAmount: 0.4 });
// this.crt = applyArcadeCRTOverlay(this, { accentTint: 0xff6b3a });
// this.events.once('shutdown', () => this.crt.destroy());
// this.crt.pulse(1.0, 400); // one-off impact flash
// this.crt.setIntensity({ accentTint: C.accent }); // re-skin on palette change
@ -36,13 +32,12 @@ 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 },
curveAmount: 0.35,
curveBezelColor: 0x05060a,
curveVignetteStrength: 0.35,
curveSheenStrength: 0.08,
curveAberration: 0.0035,
depth: { scan: 58, bevel: 59 },
...options,
};
@ -63,13 +58,43 @@ export function applyArcadeCRTOverlay(scene, options = {}) {
ease: 'Linear',
});
const curve = attachArcadeCurve(scene, {
amount: opts.curveAmount,
bezelColor: opts.curveBezelColor,
vignetteStrength: opts.curveVignetteStrength,
sheenStrength: opts.curveSheenStrength,
aberration: opts.curveAberration,
});
const bevel = scene.add.graphics().setDepth(opts.depth.bevel);
function drawVignette(g) {
const corners = [
[0, 0], [GAME_WIDTH, 0], [0, GAME_HEIGHT], [GAME_WIDTH, GAME_HEIGHT],
];
for (const [cx, cy] of corners) {
g.fillStyle(0x000000, opts.vignetteStrength * 0.42);
g.fillCircle(cx, cy, 480);
g.fillStyle(0x000000, opts.vignetteStrength * 0.3);
g.fillCircle(cx, cy, 320);
}
const rings = 7;
for (let i = 0; i < rings; i += 1) {
const t = i / (rings - 1);
const inset = opts.bevelThickness + t * 180;
const alpha = opts.vignetteStrength * (1 - t) * 0.16;
g.lineStyle(52, 0x000000, alpha);
g.strokeRoundedRect(inset, inset, GAME_WIDTH - inset * 2, GAME_HEIGHT - inset * 2, opts.bevelRadius + 8);
}
}
function drawBevel() {
bevel.clear();
drawVignette(bevel);
const inset = opts.bevelThickness / 2;
bevel.lineStyle(opts.bevelThickness, opts.bevelColor, 1);
bevel.strokeRoundedRect(inset, inset, GAME_WIDTH - opts.bevelThickness, GAME_HEIGHT - opts.bevelThickness, opts.bevelRadius);
bevel.lineStyle(9, opts.accentTint, 0.65);
bevel.strokeRoundedRect(
opts.bevelThickness, opts.bevelThickness,
GAME_WIDTH - opts.bevelThickness * 2, GAME_HEIGHT - opts.bevelThickness * 2,
Math.max(0, opts.bevelRadius - 8),
);
}
drawBevel();
let flashTween = null;
@ -81,24 +106,17 @@ 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);
curve.setUniforms({
amount: opts.curveAmount,
bezelColor: opts.curveBezelColor,
vignetteStrength: opts.curveVignetteStrength,
sheenStrength: opts.curveSheenStrength,
aberration: opts.curveAberration,
});
drawBevel();
},
destroy() {
scanTween.stop();
if (flashTween) flashTween.stop();
scan.destroy();
curve.destroy();
bevel.destroy();
},
};

View File

@ -1,138 +0,0 @@
// 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,10 +44,6 @@ 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,16 +6,12 @@
// 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. Wave-complete bonus breakdown (recap data) matches the score delta.
// 8. Monte-carlo "always fire at nearest incoming missile" bot run.
// 7. Monte-carlo "always fire at nearest incoming missile" bot run.
import {
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, WIDTH, GROUND_Y, slotX, mulberry32, pickCities,
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, slotX, mulberry32, pickCities,
DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance,
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
fireInterceptor, lerpPos, explosionRadius,
@ -101,88 +97,6 @@ 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');
@ -193,7 +107,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', splitsLeft: 0,
targetSlot: target.slot, targetKind: 'city', t: 0.5, dur: 60000, kind: 'single', splitAt: 2, splitDone: true,
});
const missilePos = lerpPos(sim.enemyMissiles[0]);
const base = pickFiringBase(sim, missilePos.x, missilePos.y);
@ -212,7 +126,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', splitsLeft: 0,
targetSlot: target2.slot, targetKind: 'city', t: 0.5, dur: 4000, kind: 'single', splitAt: 2, splitDone: true,
});
const far = lerpPos(sim2.enemyMissiles[0]);
fireInterceptor(sim2, sim2.bases[1], far.x + 5000, far.y);
@ -220,230 +134,6 @@ 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');
@ -456,37 +146,7 @@ console.log('Palette progression');
check('palette index clamps at array end', paletteIndexForWave(9999) === PALETTES.length - 1);
}
// ── 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 ────────────────────────────────────────────────────
// ── 7. Monte-carlo bot run ────────────────────────────────────────────────────
console.log('Monte-carlo bot run');
{
@ -524,8 +184,7 @@ console.log('Monte-carlo bot run');
if (aliveCities > prevCities) cityCountIncreased = true;
prevCities = aliveCities;
const planeList = sim.plane ? [sim.plane] : [];
for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions, planeList, sim.debris]) {
for (const list of [sim.enemyMissiles, sim.interceptors, sim.explosions]) {
for (const e of list) {
if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true;
}