From db1e36a42c8bb10407bba09f4c33633fca0d412b Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 13 Jul 2026 19:52:55 -0600 Subject: [PATCH] feat(coloradodefense): add chain reactions, inter-wave recap, vector skyline, and curved CRT - Chain reaction explosions: player kills trigger delayed secondary blasts that can destroy nearby missiles (bounded to one extra tier) - Inter-wave "COLORADO DEFENDED" recap animation showing surviving cities (+500 each) and leftover missiles (+25 each) with tallying sounds - Dynamic wave bonus: replaced flat SCORE_WAVE_BONUS with per-surviving-city and per-remaining-missile bonuses, emitted in waveComplete event data - Vector-style skyline: hand-authored Rocky Mountain ridgelines with snow caps, building presets for varied city skylines, wireframe silos/cities - Minimal VectorFont module for the recap banner text - Curved CRT overlay: WebGL barrel-distortion pipeline with chromatic aberration, sheen highlight, vignette, and pulse on impacts - Three new 8-bit SFX: explode, explode2 (chain), count (tally) - Updated verification tests for chain reactions, wave bonus breakdown --- assets/fx/8bit-count.mp3 | Bin 0 -> 3287 bytes assets/fx/8bit-explode.mp3 | Bin 0 -> 29257 bytes assets/fx/8bit-explode2.mp3 | Bin 0 -> 15046 bytes .../coloradodefense/ColoradoDefenseGame.js | 317 ++++++++++++++++-- .../coloradodefense/ColoradoDefenseLogic.js | 78 ++++- src/games/coloradodefense/VectorFont.js | 57 ++++ src/scenes/PreloadScene.js | 3 + src/ui/ArcadeCRTOverlay.js | 82 ++--- src/ui/ArcadeCurvePipeline.js | 138 ++++++++ src/ui/Sounds.js | 4 + tools/verifyColoradoDefense.js | 106 +++++- 11 files changed, 686 insertions(+), 99 deletions(-) create mode 100644 assets/fx/8bit-count.mp3 create mode 100644 assets/fx/8bit-explode.mp3 create mode 100644 assets/fx/8bit-explode2.mp3 create mode 100644 src/games/coloradodefense/VectorFont.js create mode 100644 src/ui/ArcadeCurvePipeline.js diff --git a/assets/fx/8bit-count.mp3 b/assets/fx/8bit-count.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..4a3f67f7cd06dc9c5947a8bb408dc07b14987b25 GIT binary patch literal 3287 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 F{{UwiM}YtU literal 0 HcmV?d00001 diff --git a/assets/fx/8bit-explode.mp3 b/assets/fx/8bit-explode.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..60b0aaf310570b9fc9525df44c7a65ab106e7955 GIT binary patch literal 29257 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 T;D3eu|M}~G<*xtV-u(XnmKmXI literal 0 HcmV?d00001 diff --git a/assets/fx/8bit-explode2.mp3 b/assets/fx/8bit-explode2.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0b94efec3a1d11cb7a76675a19703a147682ca72 GIT binary patch literal 15046 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 NTHF4w{eP&y{{eHrhkyV8 literal 0 HcmV?d00001 diff --git a/src/games/coloradodefense/ColoradoDefenseGame.js b/src/games/coloradodefense/ColoradoDefenseGame.js index ef7f51c..1fcb919 100644 --- a/src/games/coloradodefense/ColoradoDefenseGame.js +++ b/src/games/coloradodefense/ColoradoDefenseGame.js @@ -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,10 @@ export default class ColoradoDefenseGame extends Phaser.Scene { this.drawGround(); break; case 'missileDestroyed': - playSound(this, SFX.LASER_ZAP); + playSound(this, e.chained ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE); 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,8 +319,9 @@ 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); @@ -223,8 +329,157 @@ export default class ColoradoDefenseGame extends Phaser.Scene { this.updateHud(); } + // ── 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); diff --git a/src/games/coloradodefense/ColoradoDefenseLogic.js b/src/games/coloradodefense/ColoradoDefenseLogic.js index b1e3866..30dc0b9 100644 --- a/src/games/coloradodefense/ColoradoDefenseLogic.js +++ b/src/games/coloradodefense/ColoradoDefenseLogic.js @@ -62,7 +62,12 @@ export const TUNE = { 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, }; export function spawnInterval(wave) { @@ -82,11 +87,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) { @@ -102,7 +107,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,6 +138,7 @@ export class Sim { this.enemyMissiles = []; this.interceptors = []; this.explosions = []; + this.pendingChains = []; // missiles awaiting their delayed self-detonation this.waveT = 0; this.waveSpawned = 0; @@ -199,22 +207,39 @@ 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 }); } 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) { @@ -260,13 +285,30 @@ export class Sim { } this.interceptors = stillInterceptors; - // Advance explosions and resolve collisions against enemy missiles. + // 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,7 +317,11 @@ 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; diff --git a/src/games/coloradodefense/VectorFont.js b/src/games/coloradodefense/VectorFont.js new file mode 100644 index 0000000..356910a --- /dev/null +++ b/src/games/coloradodefense/VectorFont.js @@ -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; + } +} diff --git a/src/scenes/PreloadScene.js b/src/scenes/PreloadScene.js index e69a96e..7546030 100644 --- a/src/scenes/PreloadScene.js +++ b/src/scenes/PreloadScene.js @@ -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'); diff --git a/src/ui/ArcadeCRTOverlay.js b/src/ui/ArcadeCRTOverlay.js index c800753..dd032e1 100644 --- a/src/ui/ArcadeCRTOverlay.js +++ b/src/ui/ArcadeCRTOverlay.js @@ -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(); }, }; diff --git a/src/ui/ArcadeCurvePipeline.js b/src/ui/ArcadeCurvePipeline.js new file mode 100644 index 0000000..59cfed4 --- /dev/null +++ b/src/ui/ArcadeCurvePipeline.js @@ -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); }, + }; +} diff --git a/src/ui/Sounds.js b/src/ui/Sounds.js index 3c78e37..99eefba 100644 --- a/src/ui/Sounds.js +++ b/src/ui/Sounds.js @@ -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', diff --git a/tools/verifyColoradoDefense.js b/tools/verifyColoradoDefense.js index 3b215cf..4d95d30 100644 --- a/tools/verifyColoradoDefense.js +++ b/tools/verifyColoradoDefense.js @@ -8,7 +8,8 @@ // 4. MIRV step-unlock threshold. // 5. Scripted explosion/collision fixture. // 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, @@ -134,6 +135,77 @@ 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', splitAt: 2, splitDone: true, + }); + 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', splitAt: 2, splitDone: true, + }); + 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); +} + // ── 6. Palette progression ──────────────────────────────────────────────────── console.log('Palette progression'); @@ -146,7 +218,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'); {