Adjustments to Colorado Defense

This commit is contained in:
Brian Fertig 2026-07-14 08:17:57 -06:00
parent 6cef254dde
commit bfa2359ea2
3 changed files with 439 additions and 6 deletions

View File

@ -288,6 +288,25 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
case 'missileDestroyed': case 'missileDestroyed':
playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE); playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE);
break; 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': case 'waveComplete':
this.playWaveRecap(e); this.playWaveRecap(e);
break; break;
@ -326,9 +345,88 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
g.lineStyle(2, 0xffffff, 0.8); g.lineStyle(2, 0xffffff, 0.8);
g.strokeCircle(e.x, e.y, r); 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(); 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 ────────────────────────────────────────────────────── // ── Inter-wave recap ──────────────────────────────────────────────────────
wait(ms) { wait(ms) {
return new Promise((resolve) => this.time.delayedCall(ms, resolve)); return new Promise((resolve) => this.time.delayedCall(ms, resolve));

View File

@ -74,6 +74,34 @@ export const TUNE = {
CHAIN_DELAY_MS: 250, CHAIN_DELAY_MS: 250,
CHAIN_EXTRA_GROW_MS: 750, CHAIN_EXTRA_GROW_MS: 750,
CHAIN_R_MULT: 1.5, 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) { export function spawnInterval(wave) {
@ -156,6 +184,16 @@ export class Sim {
this.waveSpawned = 0; this.waveSpawned = 0;
this.waveQuota = waveQuota(this.wave); this.waveQuota = waveQuota(this.wave);
this.spawnT = 0; 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 }); } emit(type, data = {}) { this.events.push({ type, ...data }); }
@ -172,7 +210,13 @@ export class Sim {
return weighted[Math.floor(this.rng() * weighted.length)]; 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(); const target = this.pickTargetSlot();
if (!target) return null; if (!target) return null;
const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160); const fromX = fromOverride ? fromOverride.x : 80 + this.rng() * (WIDTH - 160);
@ -180,9 +224,7 @@ export class Sim {
const dist = Math.hypot(target.x - fromX, target.y - fromY); const dist = Math.hypot(target.x - fromX, target.y - fromY);
const speed = fallSpeed(this.wave); const speed = fallSpeed(this.wave);
const dur = Math.max(200, (dist / speed) * 1000); const dur = Math.max(200, (dist / speed) * 1000);
// Only a freshly-spawned missile (not a MIRV child) can itself be a MIRV const isMirv = forceMirv || (!fromOverride && this.rng() < mirvChance(this.wave));
// "bus" — it keeps flying toward its own target after each split.
const isMirv = !fromOverride && this.rng() < mirvChance(this.wave);
const kind = isMirv ? 'mirv' : 'single'; const kind = isMirv ? 'mirv' : 'single';
const splitsLeft = isMirv ? (this.wave >= TUNE.MIRV_RESPLIT_WAVE ? 2 : 1) : 0; const splitsLeft = isMirv ? (this.wave >= TUNE.MIRV_RESPLIT_WAVE ? 2 : 1) : 0;
const missile = { const missile = {
@ -235,6 +277,61 @@ export class Sim {
this.emit('explosion', { x, y, owner, lethal }); 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() { completeWave() {
// Snapshot pre-refill ammo and the survivor bonus breakdown so a // Snapshot pre-refill ammo and the survivor bonus breakdown so a
// consumer (e.g. an inter-wave recap animation) can show exactly what // consumer (e.g. an inter-wave recap animation) can show exactly what
@ -308,6 +405,70 @@ export class Sim {
} }
this.interceptors = stillInterceptors; 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 // Detonate any missile whose delayed self-destruct has come due, so its
// secondary blast joins this tick's collision pass below. // secondary blast joins this tick's collision pass below.
const readyChains = []; const readyChains = [];
@ -349,6 +510,24 @@ export class Sim {
} }
return true; 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) { if (this.waveSpawned >= this.waveQuota && this.enemyMissiles.length === 0 && this.interceptors.length === 0) {

View File

@ -8,12 +8,14 @@
// 4. MIRV step-unlock threshold. // 4. MIRV step-unlock threshold.
// 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8). // 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8).
// 5. Scripted explosion/collision fixture. // 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. // 6. Palette-progression monotonicity.
// 7. Wave-complete bonus breakdown (recap data) matches the score delta. // 7. Wave-complete bonus breakdown (recap data) matches the score delta.
// 8. Monte-carlo "always fire at nearest incoming missile" bot run. // 8. Monte-carlo "always fire at nearest incoming missile" bot run.
import { import {
BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, slotX, mulberry32, pickCities, BASE_SLOTS, CITY_SLOTS, GROUND_SLOTS, HEIGHT, WIDTH, GROUND_Y, slotX, mulberry32, pickCities,
DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance, DEFAULT_CITIES, TUNE, spawnInterval, fallSpeed, waveQuota, mirvChance,
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase, PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
fireInterceptor, lerpPos, explosionRadius, fireInterceptor, lerpPos, explosionRadius,
@ -289,6 +291,159 @@ console.log('Chain-reaction fixture');
check('chain reaction bounded to one extra tier (no dangling pending chains)', sim.pendingChains.length === 0); 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 ──────────────────────────────────────────────────── // ── 6. Palette progression ────────────────────────────────────────────────────
console.log('Palette progression'); console.log('Palette progression');
@ -369,7 +524,8 @@ console.log('Monte-carlo bot run');
if (aliveCities > prevCities) cityCountIncreased = true; if (aliveCities > prevCities) cityCountIncreased = true;
prevCities = aliveCities; 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) { for (const e of list) {
if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true; if (Number.isNaN(e.x ?? e.fromX) || Number.isNaN(e.y ?? e.fromY)) nanFound = true;
} }