Adjustments to Colorado Defense
This commit is contained in:
parent
6cef254dde
commit
bfa2359ea2
|
|
@ -288,6 +288,25 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
case 'missileDestroyed':
|
||||
playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE);
|
||||
break;
|
||||
case 'planeSpawned':
|
||||
playSound(this, SFX.WOOSH);
|
||||
break;
|
||||
case 'planeDestroyed':
|
||||
playSound(this, SFX.EIGHTBIT_EXPLODE);
|
||||
this.crt.pulse(0.5, 180);
|
||||
this.showFloatingScore(e.x, e.y, e.score);
|
||||
break;
|
||||
case 'ufoSpawned':
|
||||
playSound(this, SFX.SCIFI_RISER);
|
||||
break;
|
||||
case 'ufoShot':
|
||||
playSound(this, SFX.LASER_ZAP);
|
||||
break;
|
||||
case 'ufoDestroyed':
|
||||
playSound(this, SFX.EIGHTBIT_EXPLODE);
|
||||
this.crt.pulse(0.8, 260);
|
||||
this.showFloatingScore(e.x, e.y, e.score);
|
||||
break;
|
||||
case 'waveComplete':
|
||||
this.playWaveRecap(e);
|
||||
break;
|
||||
|
|
@ -326,9 +345,88 @@ export default class ColoradoDefenseGame extends Phaser.Scene {
|
|||
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));
|
||||
|
|
|
|||
|
|
@ -74,6 +74,34 @@ export const TUNE = {
|
|||
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) {
|
||||
|
|
@ -156,6 +184,16 @@ export class Sim {
|
|||
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 }); }
|
||||
|
|
@ -172,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);
|
||||
|
|
@ -180,9 +224,7 @@ 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);
|
||||
// Only a freshly-spawned missile (not a MIRV child) can itself be a MIRV
|
||||
// "bus" — it keeps flying toward its own target after each split.
|
||||
const isMirv = !fromOverride && this.rng() < mirvChance(this.wave);
|
||||
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 = {
|
||||
|
|
@ -235,6 +277,61 @@ export class Sim {
|
|||
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
|
||||
|
|
@ -308,6 +405,70 @@ export class Sim {
|
|||
}
|
||||
this.interceptors = stillInterceptors;
|
||||
|
||||
// Bonus plane: ticks on its own timer once unlocked, independent of wave
|
||||
// spawning/quota, so it keeps showing up smoothly across wave transitions.
|
||||
if (this.wave >= TUNE.PLANE_WAVE) {
|
||||
this.planeSpawnT += dtMs;
|
||||
if (!this.plane && this.planeSpawnT >= this.nextPlaneAt) {
|
||||
this.spawnPlane();
|
||||
this.planeSpawnT = 0;
|
||||
this.nextPlaneAt = TUNE.PLANE_SPAWN_MIN_MS
|
||||
+ this.rng() * (TUNE.PLANE_SPAWN_MAX_MS - TUNE.PLANE_SPAWN_MIN_MS);
|
||||
}
|
||||
}
|
||||
if (this.plane) {
|
||||
this.plane.x += this.plane.vx * dtMs;
|
||||
if (this.plane.x < -100 || this.plane.x > WIDTH + 100) this.plane = null;
|
||||
}
|
||||
|
||||
// Bonus UFO: same unlock-timer pattern as the plane, but it weaves its
|
||||
// heading a few times per flight and fires its own MIRVs if left alive.
|
||||
if (this.wave >= TUNE.UFO_WAVE) {
|
||||
this.ufoSpawnT += dtMs;
|
||||
if (!this.ufo && this.ufoSpawnT >= this.nextUfoAt) {
|
||||
this.spawnUfo();
|
||||
this.ufoSpawnT = 0;
|
||||
this.nextUfoAt = TUNE.UFO_SPAWN_MIN_MS
|
||||
+ this.rng() * (TUNE.UFO_SPAWN_MAX_MS - TUNE.UFO_SPAWN_MIN_MS);
|
||||
}
|
||||
}
|
||||
if (this.ufo) {
|
||||
const u = this.ufo;
|
||||
u.elapsed += dtMs;
|
||||
if (u.turnIdx < u.turnSchedule.length && u.elapsed >= u.turnSchedule[u.turnIdx]) {
|
||||
u.turnIdx += 1;
|
||||
const angle = (this.rng() * 2 - 1) * TUNE.UFO_MAX_ANGLE_DEG * (Math.PI / 180);
|
||||
u.vx = Math.cos(angle) * u.speed * u.dir;
|
||||
u.vy = Math.sin(angle) * u.speed;
|
||||
this.emit('ufoTurn', { x: u.x, y: u.y });
|
||||
}
|
||||
u.x += u.vx * dtMs;
|
||||
u.y += u.vy * dtMs;
|
||||
const yMin = HEIGHT * TUNE.UFO_Y_MIN_FRAC;
|
||||
const yMax = HEIGHT * TUNE.UFO_Y_MAX_FRAC;
|
||||
if (u.y < yMin) { u.y = yMin; u.vy = 0; }
|
||||
if (u.y > yMax) { u.y = yMax; u.vy = 0; }
|
||||
|
||||
if (u.shotsLeft > 0) {
|
||||
u.nextShotAt -= dtMs;
|
||||
if (u.nextShotAt <= 0) {
|
||||
this.fireUfoShot(u);
|
||||
u.shotsLeft -= 1;
|
||||
u.nextShotAt = u.shotGap;
|
||||
}
|
||||
}
|
||||
|
||||
if (u.x < -100 || u.x > WIDTH + 100) this.ufo = null;
|
||||
}
|
||||
|
||||
this.debris = this.debris.filter((p) => {
|
||||
p.vy += p.gravity * dtMs;
|
||||
p.x += p.vx * dtMs;
|
||||
p.y += p.vy * dtMs;
|
||||
p.rot += p.rotSpeed * dtMs;
|
||||
return p.y < GROUND_Y + 40;
|
||||
});
|
||||
|
||||
// Detonate any missile whose delayed self-destruct has come due, so its
|
||||
// secondary blast joins this tick's collision pass below.
|
||||
const readyChains = [];
|
||||
|
|
@ -349,6 +510,24 @@ export class Sim {
|
|||
}
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@
|
|||
// 4. MIRV step-unlock threshold.
|
||||
// 4b. MIRV split-zone geometry (top-third once, top-third + halfway from wave 8).
|
||||
// 5. Scripted explosion/collision fixture.
|
||||
// 5c. Bonus plane fixture (unlock wave, crossing speed, hit/score/debris).
|
||||
// 5d. Bonus UFO fixture (unlock wave, weaving/shots, hit/score/debris).
|
||||
// 6. Palette-progression monotonicity.
|
||||
// 7. Wave-complete bonus breakdown (recap data) matches the score delta.
|
||||
// 8. Monte-carlo "always fire at nearest incoming missile" bot run.
|
||||
|
||||
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,
|
||||
PALETTES, paletteIndexForWave, createGame, step, pickFiringBase,
|
||||
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);
|
||||
}
|
||||
|
||||
// ── 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');
|
||||
|
|
@ -369,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