Compare commits
3 Commits
dadf8c28dc
...
bfa2359ea2
| Author | SHA1 | Date |
|---|---|---|
|
|
bfa2359ea2 | |
|
|
6cef254dde | |
|
|
db1e36a42c |
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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); },
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue