diff --git a/assets/images/civilization/civilization-units.png b/assets/images/civilization/civilization-units.png index efcab07..5e16b00 100644 Binary files a/assets/images/civilization/civilization-units.png and b/assets/images/civilization/civilization-units.png differ diff --git a/assets/images/civilization/civilization-units.psd b/assets/images/civilization/civilization-units.psd index e553481..64dd27e 100644 Binary files a/assets/images/civilization/civilization-units.psd and b/assets/images/civilization/civilization-units.psd differ diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index 6bda30e..4bbed96 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/assets/images/game-icons.psd b/assets/images/game-icons.psd index 30c662e..c96666a 100644 Binary files a/assets/images/game-icons.psd and b/assets/images/game-icons.psd differ diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index 315fb12..4c2140a 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -111,3 +111,4 @@ registerGame({ slug: 'peggle', name: 'Peggle', category: 'logic', minPlayers: 1, registerGame({ slug: 'coloradodefense', name: 'Colorado Defense', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 81 }); registerGame({ slug: 'starcontrol', name: 'Star Control', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 0, iconFrame: 82 }); registerGame({ slug: 'civilization', name: 'Civilization', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 83 }); +registerGame({ slug: 'tempest', name: 'Tempest', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 84 }); diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js index 0438baf..69a0d80 100644 --- a/src/games/civilization/CivilizationCityScreen.js +++ b/src/games/civilization/CivilizationCityScreen.js @@ -3,7 +3,9 @@ import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; +import { Tooltip } from '../../ui/Tooltip.js'; import * as Logic from './CivilizationLogic.js'; +import { describeUnitTooltip, describeBuildingTooltip } from './CivilizationTooltips.js'; const FONT = '"Julius Sans One"'; @@ -22,13 +24,16 @@ export function openCityScreen(scene, rules, state, city, onClose) { let dynamic = scene.add.container(0, 0); root.add(dynamic); - const close = () => { root.destroy(true); onClose(); }; + const tooltip = new Tooltip(scene, { depth: 70 }); + + const close = () => { tooltip.destroy(); root.destroy(true); onClose(); }; const closeBtn = new Button(scene, px + W / 2 - 80, py - H / 2 + 40, '✕', close, { width: 60, height: 44, fontSize: 22, variant: 'ghost' }); root.add(closeBtn); function redraw() { dynamic.destroy(true); + tooltip.hide(); dynamic = scene.add.container(0, 0); root.add(dynamic); const civ = state.civs[city.civ]; @@ -131,9 +136,9 @@ export function openCityScreen(scene, rules, state, city, onClose) { // Choices: units then buildings, two columns of rows. const unitChoices = Logic.availableUnits(rules, state, civ, city) - .map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, tip: `A${u.attack} D${u.defense} M${u.move}` })); + .map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, ruleObj: u })); const bldChoices = Logic.availableBuildings(rules, state, civ, city) - .map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, tip: `upkeep ${b.upkeep}` })); + .map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, ruleObj: b })); const choices = [...unitChoices, ...bldChoices]; const colW = 330; const rowH = 36; @@ -154,6 +159,9 @@ export function openCityScreen(scene, rules, state, city, onClose) { Logic.setBuild(rules, state, city, ch.type, ch.id); redraw(); }); + tooltip.attachTo(rect, () => (ch.type === 'unit' + ? describeUnitTooltip(rules, ch.ruleObj) + : describeBuildingTooltip(rules, ch.ruleObj))); dynamic.add(rect); dynamic.add(txt); }); diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 8fe6740..1308da8 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -334,7 +334,7 @@ export default class CivilizationGame extends Phaser.Scene { this.phase = 'playing'; this.setupRoot?.destroy(true); this.setupRoot = null; - this.view = new CivilizationMapView(this, this.rules, this.state, { + this.view = new CivilizationMapView(this, this.rules, this.state, this.opponentsData, { onCityClick: (city) => this.onCityClick(city), onUnitClick: (unit) => this.onUnitClick(unit), }); @@ -593,8 +593,11 @@ export default class CivilizationGame extends Phaser.Scene { if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return; if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands // Clicks that landed on any interactive object (buttons, minimap, city - // banners) are theirs, not the map's. - if (this.input.hitTestPointer(pointer).length > 0) return; + // banners) are theirs, not the map's. Non-selected unit markers are + // interactive too now (for hover tooltips) but are tagged 'hoverOnly' + // (see CivilizationMapView.drawUnit) since they aren't a real click + // target — a click there should still fall through to onTileClick. + if (this.input.hitTestPointer(pointer).some((o) => !o.getData('hoverOnly'))) return; const tile = this.view.screenToTile(pointer.x, pointer.y); if (tile) this.onTileClick(tile[0], tile[1]); }); diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index bb297a2..fc8a2b6 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -356,6 +356,10 @@ export function cityYields(rules, state, city) { if (b.effect === 'shields') shieldMult += b.value; if (b.effect === 'power' && city.buildings.factory) shieldMult += b.value; } + // Difficulty production handicap — AI civs only (see rules.difficulties' + // aiProdBonus; humanResearchFactor/aiScienceBonus handle research speed, + // aiAggression handles war odds, this is the shield-output lever). + if (!civ.human) shieldMult *= rules.difficulties[state.difficultyId].aiProdBonus; // Unit support: shields per supported unit beyond the free allowance // (Democracy pays gold instead), settlers also eat food. diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 0507709..1bea2d0 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -16,6 +16,8 @@ import { IMP, tileIndex, inBounds, cityAt, unitsAt, civUnits, civCities, computeVisible, isUnitVisibleTo, shieldGrassAt, } from './CivilizationLogic.js'; +import { Tooltip } from '../../ui/Tooltip.js'; +import { describeUnitStackTooltip } from './CivilizationTooltips.js'; export const TILE_W = 128; export const TILE_H = 64; @@ -33,16 +35,21 @@ const SEL_RING_W = SEL_RING_H * (TILE_W / TILE_H); const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0]; export class CivilizationMapView { - constructor(scene, rules, state, callbacks = {}) { + constructor(scene, rules, state, opponentsData, callbacks = {}) { this.scene = scene; this.rules = rules; this.state = state; + this.opponentsData = opponentsData ?? []; this.cb = callbacks; this.humanIdx = state.humanIndex; this.zoomIdx = 1; this.selectedUnitId = null; this.exploredDrawn = null; this.unitContainers = new Map(); + // Depth 40 sits above the HUD (30) but below any modal (60+), so a + // hovered-unit tooltip left visible when a screen opens on top just + // renders underneath it instead of floating over the modal. + this.tooltip = new Tooltip(scene, { depth: 40 }); const { world } = state; this.originX = world.rows * (TILE_W / 2); // keeps iso x positive @@ -108,6 +115,7 @@ export class CivilizationMapView { } destroy() { + this.tooltip.destroy(); this.root.destroy(true); this.stamp.destroy(); this.fogStamp?.destroy(); @@ -498,6 +506,7 @@ export class CivilizationMapView { refresh() { this.updateFog(); + this.tooltip.hide(); this.dynamic.removeAll(true); this.unitContainers.clear(); const { state, rules } = this; @@ -532,7 +541,7 @@ export class CivilizationMapView { const r = (idx / state.world.cols) | 0; const selected = units.find((u) => u.id === this.selectedUnitId); const top = selected ?? units[0]; - this.drawUnit(top, units.length, c, r); + this.drawUnit(top, units, c, r); } this.drawSelection(); this.refreshMinimap(); @@ -597,7 +606,8 @@ export class CivilizationMapView { this.dynamic.add(container); } - drawUnit(unit, stackCount, c, r) { + drawUnit(unit, units, c, r) { + const stackCount = units.length; const { scene, rules } = this; const def = rules.units[unit.type]; const civ = this.state.civs[unit.civ]; @@ -644,15 +654,23 @@ export class CivilizationMapView { } // Only the currently-selected unit is clickable — clicking any other // unit's tile still goes through the normal tile-click select/move flow - // in CivilizationGame (see bindPointer/onTileClick). - if (unit.id === this.selectedUnitId) { - container.setSize(64, 64); - container.setInteractive({ useHandCursor: true }); + // in CivilizationGame (see bindPointer/onTileClick). Every unit is still + // made interactive (for hover tooltips); non-selected ones are tagged + // 'hoverOnly' so CivilizationGame's click-vs-map hitTestPointer gate + // knows to let the click fall through to onTileClick instead of treating + // it as "handled" the way a real click target (button, selected unit) is. + const isSelected = unit.id === this.selectedUnitId; + container.setSize(64, 64); + container.setInteractive({ useHandCursor: isSelected }); + if (isSelected) { container.on('pointerdown', (pointer, lx, ly, event) => { event.stopPropagation(); this.cb.onUnitClick?.(unit); }); + } else { + container.setData('hoverOnly', true); } + this.tooltip.attachTo(container, () => describeUnitStackTooltip(rules, civ, this.opponentsData, units)); container.setDepth(y + 2); this.dynamic.add(container); this.unitContainers.set(unit.id, container); diff --git a/src/games/civilization/CivilizationTooltips.js b/src/games/civilization/CivilizationTooltips.js new file mode 100644 index 0000000..8b6a097 --- /dev/null +++ b/src/games/civilization/CivilizationTooltips.js @@ -0,0 +1,115 @@ +import { COLORS } from '../../config.js'; + +// Flavor/ability text for unit flags — data/civilization-rules.json has no +// description field on units, so this is authored here. A few flags +// (aegis, paradrop, amphibious, fighter) aren't mechanically wired in +// CivilizationLogic.js/CivilizationAI.js yet; their text is standard +// Civilization-series flavor phrasing, harmless either way. +const FLAG_TEXT = { + settler: 'Can found new cities and build terrain improvements (irrigation, roads, mines).', + coastal: 'Coast-hugging vessel — cannot cross open ocean away from land.', + mounted: 'Fast cavalry — takes double damage from anti-mounted defenders.', + paradrop: 'Can drop directly onto a target tile within range, bypassing the terrain in between.', + ignoreterrain: 'Ignores terrain movement penalties — always costs 1 movement point per tile.', + antimounted: 'Doubles its defense strength against mounted attackers.', + aegis: 'Advanced systems grant bonus defense against air and missile attacks.', + spaceship: 'A spaceship component — built toward the space-race victory instead of combat.', + amphibious: 'No attack penalty when assaulting land units directly from a ship.', + engineer: 'Works twice as fast on terrain improvements and can perform advanced terraforming.', + missile: 'One-shot weapon — consumed whether or not the attack succeeds.', + nuke: 'Devastating strike that levels the target; can be intercepted by SDI Defense.', + fighter: 'Limited range — must return to a city or carrier to refuel.', + submarine: 'Stealthed — invisible to enemies unless one of their units or cities is adjacent.', + noncombat: 'Cannot attack or be used to initiate combat.', + ignorewalls: 'Attack ignores the defensive bonus from City Walls.', + caravan: 'Can be sent to another city to establish a trade route or help rush a Wonder.', +}; + +// Benefit text for building effects — again authored here since the rules +// JSON has no description field. Each entry is a function of the building +// so magnitude (building.value) can be interpolated where the game engine +// actually uses that value (see CivilizationLogic.js); a couple of effects +// (sizecap, palace, granary, veterans, airport, sdi) are described by what +// the engine actually does rather than by the value field, since that field +// isn't consumed literally for them. +const EFFECT_TEXT = { + roadtrade: (b) => `Boosts trade from roaded tiles by ${Math.round(b.value * 100)}%.`, + science: (b) => `+${Math.round(b.value * 100)}% science output from this city.`, + airport: () => 'Air units based here can rebase instantly and heal fully each turn.', + granary: () => 'Keeps half the food box on hand when the city grows, speeding regrowth.', + walls: (b) => `Multiplies this city's defense against land attacks by ${b.value} (unless the attacker ignores walls).`, + sdi: () => 'Automatically intercepts incoming nuclear missiles aimed at this city.', + farmfood: (b) => `+${Math.round(b.value * 100)}% food from farmland-improved tiles worked by this city.`, + defenseair: (b) => `Multiplies this city's defense against air attacks by ${b.value}.`, + power: (b) => `+${Math.round(b.value * 100)}% shield output from the city's Factory (requires a Factory).`, + corruption: (b) => `Cuts this city's corruption by ${Math.round(b.value * 100)}%.`, + gold: (b) => `+${Math.round(b.value * 100)}% gold income from this city's trade.`, + veterans: () => 'Units built here start as veterans, with a combat bonus.', + sizecap: (b) => (b.id === 'sewersystem' + ? 'Removes this city\'s population growth cap entirely.' + : 'Raises this city\'s population cap so it can keep growing.'), + oceanfood: (b) => `+${b.value} food from every ocean tile this city works.`, + shields: (b) => `+${Math.round(b.value * 100)}% shield (production) output from this city.`, + oceanshield: (b) => `+${b.value} shield from every ocean tile this city works.`, + palace: () => 'Makes this your capital — sharply reduces corruption and stores the seat of government.', + defensesea: (b) => `Multiplies this city's defense against naval attacks by ${b.value}.`, +}; + +export function describeUnitTooltip(rules, unit) { + const lines = [ + { text: `Attack ${unit.attack} · Defense ${unit.defense} · Move ${unit.move}`, color: COLORS.goldHex }, + ]; + for (const flag of unit.flags ?? []) { + const text = FLAG_TEXT[flag]; + if (text) lines.push({ text: `• ${text}` }); + } + return { title: unit.name, lines }; +} + +export function describeBuildingTooltip(rules, building) { + const lines = [ + { text: `Cost ${building.cost} shields · Upkeep ${building.upkeep}/turn`, color: COLORS.goldHex }, + ]; + const describe = EFFECT_TEXT[building.effect]; + if (describe) lines.push({ text: `• ${describe(building)}` }); + return { title: building.name, lines }; +} + +// Hover tooltip for a map unit/stack: which leader controls it plus a +// per-unit-type breakdown. `units` are live game-state unit instances (all +// sharing `civ`, since only one civ's units normally occupy a tile), not +// rule defs — look each one's def up via rules.units[u.type]. +export function describeUnitStackTooltip(rules, civ, opponentsData, units) { + const opData = opponentsData?.find((o) => o.id === civ.leaderId); + const lines = []; + if (units.length === 1) { + const [u] = units; + const def = rules.units[u.type]; + lines.push({ text: def.name, color: COLORS.goldHex }); + lines.push({ text: `Attack ${def.attack} · Defense ${def.defense} · Move ${def.move}` }); + if (u.vet) lines.push({ text: '• Veteran' }); + if (u.fortified) lines.push({ text: '• Fortified' }); + for (const flag of def.flags ?? []) { + const text = FLAG_TEXT[flag]; + if (text) lines.push({ text: `• ${text}` }); + } + } else { + const byName = new Map(); + for (const u of units) { + const def = rules.units[u.type]; + const entry = byName.get(def.name) ?? { count: 0, def }; + entry.count += 1; + byName.set(def.name, entry); + } + lines.push({ text: `${units.length} units`, color: COLORS.goldHex }); + for (const [name, { count, def }] of byName) { + lines.push({ text: `${count}× ${name} (A${def.attack} D${def.defense} M${def.move})` }); + } + } + return { + title: civ.name, + titleColor: civ.color, + lines, + icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name }, + }; +} diff --git a/src/games/tempest/TempestGame.js b/src/games/tempest/TempestGame.js new file mode 100644 index 0000000..2878b39 --- /dev/null +++ b/src/games/tempest/TempestGame.js @@ -0,0 +1,867 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js'; +import { + WEBS, BANDS, ENEMY_COLORS, TUNE, FAR_SCALE, + laneCount, rimPoint, makeProjector, bandIndexForLevel, + startBonus, startLevelOptions, webForLevel, + createGame, step, setAim, setFiring, superzap, +} from './TempestLogic.js'; +import { drawVectorText } from './TempestVectorFont.js'; + +const D = { + bg: -2, stars: -1, web: 0, spikes: 1, enemies: 2, player: 3, shots: 4, + fx: 5, banner: 6, ui: 30, overlay: 61, +}; +const BEST_KEY = 'tempest-best'; +const MAX_KEY = 'tempest-max-level'; + +// Where the web sits on screen and how big it renders. +const WEB_CX = GAME_WIDTH / 2; +const WEB_CY = GAME_HEIGHT / 2 + 30; +const RIM_SCALE = 400; + +export default class TempestGame extends Phaser.Scene { + constructor() { super('TempestGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'tempest', name: 'Tempest' }; + this.mode = 'skillstep'; // 'skillstep' | 'playing' | 'gameover' + this.sim = null; + this.band = BANDS[0]; + this.particles = []; + this.popups = []; + this.banner = null; + this.flashMs = 0; + this.spin = 0; + this.leftHeld = false; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) this.music = new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + this.input.mouse?.disableContextMenu(); + + this.bgRect = this.add + .rectangle(WEB_CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, this.band.bg) + .setDepth(D.bg); + this.starG = this.add.graphics().setDepth(D.stars); + this.webG = this.add.graphics().setDepth(D.web); + this.spikeG = this.add.graphics().setDepth(D.spikes); + this.enemyG = this.add.graphics().setDepth(D.enemies); + this.playerG = this.add.graphics().setDepth(D.player); + this.shotG = this.add.graphics().setDepth(D.shots); + this.fxG = this.add.graphics().setDepth(D.fx); + this.bannerG = this.add.graphics().setDepth(D.banner); + this.hudG = this.add.graphics().setDepth(D.ui); + + this.drawStarfield(); + + this.crt = applyArcadeCRTOverlay(this, { + accentTint: this.band.lane, scanlineTint: this.band.web, + }); + this.events.once('shutdown', () => this.crt.destroy()); + + this.buildHud(); + this.bindInput(); + this.showSkillStep(); + } + + // ── Input ───────────────────────────────────────────────────────────────── + + bindInput() { + this.input.on('pointerdown', (p) => { + if (this.mode !== 'playing' || !this.sim) return; + if (p.rightButtonDown()) { + superzap(this.sim); + return; + } + if (p.leftButtonDown()) { + this.leftHeld = true; + setFiring(this.sim, true); + } + }); + this.input.on('pointerup', (p) => { + if (!p.leftButtonDown()) { + this.leftHeld = false; + if (this.sim) setFiring(this.sim, false); + } + }); + } + + aimFromPointer() { + const p = this.input.activePointer; + const { vp } = this.proj; + const dx = p.worldX - vp.x; + const dy = p.worldY - vp.y; + if (dx * dx + dy * dy < 16) return; // dead zone right on the vanishing point + setAim(this.sim, Math.atan2(dy, dx)); + } + + // ── Projection (warp-aware) ────────────────────────────────────────────── + // The sim's makeProjector covers normal play; during the warp the whole + // depth axis slides toward the camera, and points behind it (tt < 0) blow + // up past the screen edges, which is exactly the fly-through look. + + projectAt(rimCoord, t) { + const sim = this.sim; + let tt = t; + if (sim && sim.phase === 'warp') { + tt = (t - sim.warpT) / Math.max(1e-6, 1 - sim.warpT); + } + tt = Math.max(-0.12, tt); + const s = 1 / (1 + tt * (1 / FAR_SCALE - 1)); + const [rx, ry] = rimPoint(this.web, rimCoord); + const { vp } = this.proj; + return { + x: vp.x + (rx - this.web.pit[0]) * RIM_SCALE * s, + y: vp.y + (ry - this.web.pit[1]) * RIM_SCALE * s, + s, + }; + } + + useWeb(web) { + this.web = web; + this.proj = makeProjector(web, WEB_CX, WEB_CY, RIM_SCALE); + } + + // ── Skill Step (start screen) ──────────────────────────────────────────── + + maxReached() { + return Math.max(1, Number(localStorage.getItem(MAX_KEY) ?? 1)); + } + + showSkillStep() { + this.mode = 'skillstep'; + this.useWeb(WEBS[0]); + this.skillUi = this.add.container(0, 0).setDepth(D.ui + 1); + this.skillG = this.add.graphics().setDepth(D.ui); + this.hoverOption = -1; + + const options = startLevelOptions(this.maxReached()); + const best = Number(localStorage.getItem(BEST_KEY) ?? 0); + + const sub = this.add.text(WEB_CX, 318, + 'STEER WITH THE MOUSE • HOLD LEFT BUTTON TO FIRE • RIGHT BUTTON: SUPERZAPPER', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.skillUi.add(sub); + if (best > 0) { + this.skillUi.add(this.add.text(WEB_CX, 354, `BEST ${best}`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5)); + } + this.skillUi.add(this.add.text(WEB_CX, 412, options.length > 1 ? 'CHOOSE YOUR STARTING LEVEL' : 'CLICK TO PLAY', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: COLORS.textHex, + }).setOrigin(0.5)); + + // Layout the level choices as a centered grid of live web thumbnails. + const cols = Math.min(options.length, 8); + const cellW = 190; const cellH = 210; + this.skillCells = options.map((level, i) => { + const row = Math.floor(i / cols); + const rowCount = Math.min(cols, options.length - row * cols); + const x = WEB_CX + (i % cols - (rowCount - 1) / 2) * cellW; + const y = 560 + row * cellH; + const zone = this.add.rectangle(x, y, cellW - 16, cellH - 16, 0xffffff, 0.001) + .setInteractive({ useHandCursor: true }); + zone.on('pointerover', () => { this.hoverOption = i; }); + zone.on('pointerout', () => { if (this.hoverOption === i) this.hoverOption = -1; }); + zone.on('pointerdown', () => this.startRun(level)); + this.skillUi.add(zone); + return { level, x, y }; + }); + } + + drawSkillStep(delta) { + const g = this.skillG; + g.clear(); + + // Slowly spinning, receding circle web behind the title. + this.spin += delta * 0.00035; + const bandBlue = BANDS[0]; + for (const ringT of [0, 0.3, 0.55, 0.75, 0.9]) { + const alpha = ringT === 0 ? 0.5 : 0.22 * (1 - ringT); + g.lineStyle(ringT === 0 ? 2.5 : 1.5, bandBlue.web, alpha); + g.beginPath(); + for (let i = 0; i <= 16; i += 1) { + const p = this.projectAt(i + this.spin * 3, ringT); + if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); + } + g.strokePath(); + } + for (let i = 0; i < 16; i += 1) { + const a = this.projectAt(i + this.spin * 3, 0); + const b = this.projectAt(i + this.spin * 3, 0.92); + g.lineStyle(1.5, bandBlue.web, 0.18); + g.lineBetween(a.x, a.y, b.x, b.y); + } + + drawVectorText(g, 'TEMPEST', WEB_CX, 200, 22, bandBlue.lane, + { lineWidth: 5, glowWidth: 18, glowAlpha: 0.22 }); + + // Web thumbnails for each starting level. + for (let i = 0; i < this.skillCells.length; i += 1) { + const { level, x, y } = this.skillCells[i]; + const web = webForLevel(level); + const band = BANDS[bandIndexForLevel(level)]; + const hot = this.hoverOption === i; + const scale = hot ? 60 : 52; + const color = hot ? band.lane : band.web; + + g.lineStyle(hot ? 6 : 4, color, hot ? 0.3 : 0.14); + this.strokeMiniWeb(g, web, x, y - 20, scale); + g.lineStyle(hot ? 2.5 : 1.8, color, hot ? 1 : 0.8); + this.strokeMiniWeb(g, web, x, y - 20, scale); + + drawVectorText(g, `LEVEL ${level}`, x, y + 62, 2.6, hot ? band.lane : band.web, + { lineWidth: 2, glowWidth: 6, alpha: hot ? 1 : 0.85 }); + const bonus = startBonus(level); + if (bonus > 0) { + drawVectorText(g, `BONUS ${bonus}`, x, y + 88, 1.7, COLORS.gold, + { lineWidth: 1.5, glowWidth: 5, alpha: hot ? 1 : 0.7 }); + } + } + } + + strokeMiniWeb(g, web, cx, cy, scale) { + g.beginPath(); + const n = web.verts.length; + for (let i = 0; i < n; i += 1) { + const [x, y] = web.verts[i]; + if (i === 0) g.moveTo(cx + x * scale, cy + y * scale); + else g.lineTo(cx + x * scale, cy + y * scale); + } + if (web.closed) g.closePath(); + g.strokePath(); + } + + startRun(level) { + playSound(this, SFX.EIGHTBIT_ACTIVATE); + this.skillUi.destroy(true); + this.skillG.destroy(); + this.skillUi = null; + this.skillG = null; + this.sim = createGame({ startLevel: level }); + this.useWeb(this.sim.web); + this.applyBand(); + this.mode = 'playing'; + this.showBanner(`LEVEL ${level}`, this.band.lane, 1400); + playSound(this, SFX.COUNTDOWN_GO); + } + + applyBand() { + this.band = BANDS[bandIndexForLevel(this.sim.level)]; + this.bgRect.setFillStyle(this.band.bg); + this.crt.setIntensity({ accentTint: this.band.lane, scanlineTint: this.band.web }); + } + + // ── HUD ─────────────────────────────────────────────────────────────────── + + buildHud() { + const font = { fontFamily: 'm6x11, "Julius Sans One"' }; + this.scoreLabel = this.add.text(40, 26, 'SCORE', { + ...font, fontSize: '18px', color: COLORS.mutedHex, + }).setDepth(D.ui); + this.scoreText = this.add.text(40, 46, '0', { + ...font, fontSize: '40px', color: COLORS.textHex, + }).setDepth(D.ui); + this.levelText = this.add.text(WEB_CX, 30, '', { + ...font, fontSize: '30px', color: COLORS.goldHex, + }).setOrigin(0.5, 0).setDepth(D.ui); + this.bestText = this.add.text(GAME_WIDTH - 40, 30, `BEST ${Number(localStorage.getItem(BEST_KEY) ?? 0)}`, { + ...font, fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(1, 0).setDepth(D.ui); + this.zapText = this.add.text(GAME_WIDTH - 40, 66, '', { + ...font, fontSize: '20px', color: COLORS.mutedHex, + }).setOrigin(1, 0).setDepth(D.ui); + } + + updateHud() { + const sim = this.sim; + this.scoreText.setText(String(sim.score)); + this.levelText.setText(`LEVEL ${sim.level} • ${this.web.name}`); + this.zapText.setText(`SUPERZAPPER x${sim.zapper.uses}`); + this.zapText.setColor(sim.zapper.uses > 0 ? COLORS.goldHex : COLORS.mutedHex); + + // Lives as little claw glyphs under the score. + const g = this.hudG; + g.clear(); + const colr = this.band.player; + for (let i = 0; i < Math.min(sim.lives, 8); i += 1) { + const x = 46 + i * 40; const y = 118; + for (const [w, a] of [[5, 0.18], [2, 1]]) { + g.lineStyle(w, colr, a); + g.beginPath(); + g.moveTo(x - 12, y + 8); + g.lineTo(x - 8, y - 4); + g.lineTo(x, y + 2); + g.lineTo(x + 8, y - 4); + g.lineTo(x + 12, y + 8); + g.strokePath(); + } + } + } + + // ── Frame loop ──────────────────────────────────────────────────────────── + + update(time, delta) { + if (this.mode === 'skillstep') { + this.drawSkillStep(delta); + return; + } + if (!this.sim) return; + + if (this.mode === 'playing') { + this.aimFromPointer(); + const events = step(this.sim, delta); + for (const e of events) this.handleEvent(e); + } + this.syncGraphics(delta); + this.updateHud(); + } + + handleEvent(e) { + switch (e.type) { + case 'shotFired': + playSound(this, SFX.LASER_ZAP); + break; + case 'enemySpawned': + break; + case 'enemyKilled': { + playSound(this, e.zap ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE); + const lane = e.enemyType === 'fuseball' ? e.edge : e.lane + 0.5; + const p = this.projectAt(lane, e.t); + this.burst(p.x, p.y, this.enemyColor(e.enemyType), 10, p.s); + if (e.points > 0) this.popups.push({ value: e.points, x: p.x, y: p.y, ageMs: 0 }); + break; + } + case 'enemyShotDestroyed': { + const p = this.projectAt(e.lane + 0.5, e.t); + this.burst(p.x, p.y, 0xffffff, 5, p.s); + break; + } + case 'enemyReachedRim': + this.crt.pulse(0.3, 120); + break; + case 'spikeTrimmed': + playSound(this, SFX.EIGHTBIT_ACTION); + break; + case 'superzapper': + playSound(this, SFX.ENERGY_HUM); + this.flashMs = e.mode === 'all' ? 420 : 160; + this.crt.pulse(e.mode === 'all' ? 0.85 : 0.4, e.mode === 'all' ? 320 : 140); + break; + case 'playerHit': { + playSound(this, SFX.SCIFI_EXPLODE); + this.crt.pulse(0.9, 380); + const p = this.projectAt(this.sim.player.pos + 0.5, 0); + this.burst(p.x, p.y, this.band.player, 16, 1); + if (this.sim.lives > 0) this.showBanner(`${this.sim.lives} SHIP${this.sim.lives === 1 ? '' : 'S'} LEFT`, COLORS.danger, 1200); + break; + } + case 'respiteStart': + this.showBanner('GET READY', this.band.lane, TUNE.RESPITE_MS); + playSound(this, SFX.EIGHTBIT_COUNT); + break; + case 'spikeHit': + playSound(this, SFX.SWORD_HIT); + break; + case 'extraLife': + playSound(this, SFX.VICTORY_SHORT); + this.showBanner('EXTRA SHIP', this.band.lane, 1600); + break; + case 'levelCleared': + break; + case 'warpStart': { + playSound(this, SFX.SCIFI_RISER); + const spiked = this.sim.spikes.some((tip) => tip < 1); + this.showBanner(spiked ? 'AVOID SPIKES' : 'SUPERZAPPER RECHARGE', this.band.lane, 1500); + break; + } + case 'warpDone': { + playSound(this, SFX.SCIFI_WOOSH); + this.useWeb(this.sim.web); + this.applyBand(); + this.showBanner(`LEVEL ${e.level}`, this.band.lane, 1400); + const max = this.maxReached(); + if (e.level > max) localStorage.setItem(MAX_KEY, String(e.level)); + break; + } + case 'gameOver': + this.onGameOver(e); + break; + default: + break; + } + } + + enemyColor(type) { + if (type === 'flipper') return this.band.flipper; + return ENEMY_COLORS[type] ?? 0xffffff; + } + + // ── Rendering ───────────────────────────────────────────────────────────── + + syncGraphics(delta) { + this.drawWeb(); + this.drawSpikes(); + this.drawEnemies(); + this.drawPlayer(); + this.drawShots(); + this.drawFx(delta); + } + + drawStarfield() { + const g = this.starG; + g.clear(); + const rnd = Phaser.Math.RND; + for (let i = 0; i < 110; i += 1) { + const x = rnd.between(0, GAME_WIDTH); + const y = rnd.between(0, GAME_HEIGHT); + const a = rnd.frac() * 0.28 + 0.05; + g.fillStyle(0xffffff, a); + g.fillRect(x, y, 2, 2); + } + } + + // The wireframe well: lane rails from rim to pit, the bright rim, and a + // few receding depth rings. The player's lane gets the highlight color. + drawWeb() { + const g = this.webG; + g.clear(); + const sim = this.sim; + const web = this.web; + const n = laneCount(web); + const warp = sim.phase === 'warp'; + + // Lane rails (one per vertex ray). + const rails = web.closed ? n : n + 1; + for (let i = 0; i < rails; i += 1) { + const a = this.projectAt(i, 0); + const b = this.projectAt(i, 1); + g.lineStyle(4, this.band.web, 0.10); + g.lineBetween(a.x, a.y, b.x, b.y); + g.lineStyle(1.6, this.band.web, 0.85); + g.lineBetween(a.x, a.y, b.x, b.y); + } + + // Depth rings, nearest brightest. During the warp these stream outward. + for (const [ringT, width, alpha] of [[0, 3, 1], [0.42, 1.4, 0.3], [0.72, 1.4, 0.35], [1, 2, 0.6]]) { + for (const [w2, a2] of [[width + 5, 0.14 * alpha], [width, alpha]]) { + g.lineStyle(w2, this.band.web, a2); + g.beginPath(); + let started = false; + for (let i = 0; i <= rails - (web.closed ? 0 : 1); i += 1) { + const p = this.projectAt(i, ringT); + if (!started) { g.moveTo(p.x, p.y); started = true; } else g.lineTo(p.x, p.y); + } + if (web.closed) g.closePath(); + g.strokePath(); + } + } + + // Player lane highlight (skip while the ship is gone). + if (sim.player.alive && !warp) { + const lane = sim.playerLane(); + const pts = [this.projectAt(lane, 0), this.projectAt(lane, 1), + this.projectAt(lane + 1, 1), this.projectAt(lane + 1, 0)]; + g.lineStyle(7, this.band.lane, 0.16); + g.strokePoints(pts.map((p) => new Phaser.Geom.Point(p.x, p.y)), true); + g.lineStyle(2.2, this.band.lane, 0.95); + g.strokePoints(pts.map((p) => new Phaser.Geom.Point(p.x, p.y)), true); + } + + // Warp star-streaks: radial lines rushing past the vanishing point. + if (warp) { + const { vp } = this.proj; + g.lineStyle(2, 0xffffff, 0.5); + for (let i = 0; i < 14; i += 1) { + const a = (i / 14) * Math.PI * 2 + sim.warpT * 2.2; + const r0 = 60 + ((sim.warpT * 900 + i * 130) % 700); + g.lineBetween( + vp.x + Math.cos(a) * r0, vp.y + Math.sin(a) * r0, + vp.x + Math.cos(a) * (r0 + 46 + sim.warpT * 90), vp.y + Math.sin(a) * (r0 + 46 + sim.warpT * 90), + ); + } + } + } + + drawSpikes() { + const g = this.spikeG; + g.clear(); + const sim = this.sim; + for (let lane = 0; lane < sim.spikes.length; lane += 1) { + const tip = sim.spikes[lane]; + if (tip >= 1) continue; + const a = this.projectAt(lane + 0.5, 1); + const b = this.projectAt(lane + 0.5, tip); + g.lineStyle(5, ENEMY_COLORS.spiker, 0.16); + g.lineBetween(a.x, a.y, b.x, b.y); + g.lineStyle(1.8, ENEMY_COLORS.spiker, 0.95); + g.lineBetween(a.x, a.y, b.x, b.y); + g.fillStyle(0xffffff, 0.9); + g.fillCircle(b.x, b.y, Math.max(1.6, 3.2 * b.s)); + } + } + + drawEnemies() { + const g = this.enemyG; + g.clear(); + const sim = this.sim; + for (const e of sim.enemies) { + if (e.type === 'flipper') this.drawFlipper(g, e); + else if (e.type === 'tanker') this.drawTanker(g, e); + else if (e.type === 'spiker') this.drawSpiker(g, e); + else if (e.type === 'fuseball') this.drawFuseball(g, e); + else if (e.type === 'pulsar') this.drawPulsar(g, e); + } + for (const es of sim.enemyShots) { + const p = this.projectAt(es.lane + 0.5, es.t); + const r = Math.max(2.5, 7 * p.s); + const spin = (this.time.now * 0.01) % (Math.PI * 2); + g.lineStyle(4, 0xffffff, 0.2); + this.strokeNgon(g, p.x, p.y, r, 4, spin); + g.lineStyle(1.6, 0xffffff, 1); + this.strokeNgon(g, p.x, p.y, r, 4, spin); + } + } + + strokeNgon(g, x, y, r, sides, rot = 0) { + g.beginPath(); + for (let i = 0; i <= sides; i += 1) { + const a = rot + (i / sides) * Math.PI * 2; + const px = x + Math.cos(a) * r; + const py = y + Math.sin(a) * r; + if (i === 0) g.moveTo(px, py); else g.lineTo(px, py); + } + g.strokePath(); + } + + // Flipper: the classic bowtie, sliding between lanes as it flips. + drawFlipper(g, e) { + let laneCoord = e.lane + 0.5; + let rot = 0; + if (e.state === 'flip') { + const f = Math.min(1, e.flipMs / TUNE.FLIP_DUR_MS); + const n = laneCount(this.web); + let d = e.flipTo - e.flipFrom; + // Hop the short way across the wrap seam, not around the whole web. + if (this.web.closed && Math.abs(d) > n / 2) d -= Math.sign(d) * n; + laneCoord = e.flipFrom + 0.5 + d * f; + rot = f * Math.PI; + } + const p = this.projectAt(laneCoord, e.t); + const a2 = this.projectAt(laneCoord + 0.5, e.t); + const ang = Math.atan2(a2.y - p.y, a2.x - p.x) + rot; + const w = Math.max(6, 26 * p.s); + const h = Math.max(3, 10 * p.s); + const cos = Math.cos(ang); const sin = Math.sin(ang); + const pt = (lx, ly) => [p.x + lx * cos - ly * sin, p.y + lx * sin + ly * cos]; + const [x1, y1] = pt(-w, -h); const [x2, y2] = pt(-w, h); + const [x3, y3] = pt(w, -h); const [x4, y4] = pt(w, h); + const color = this.band.flipper; + for (const [lw, la] of [[5, 0.18], [1.8, 1]]) { + g.lineStyle(lw, color, la); + g.strokeTriangle(x1, y1, x2, y2, p.x, p.y); + g.strokeTriangle(x3, y3, x4, y4, p.x, p.y); + } + } + + // Tanker: nested diamonds with its flipper cargo hinted inside. + drawTanker(g, e) { + const p = this.projectAt(e.lane + 0.5, e.t); + const r = Math.max(5, 24 * p.s); + const color = ENEMY_COLORS.tanker; + for (const [lw, la] of [[5, 0.18], [1.8, 1]]) { + g.lineStyle(lw, color, la); + this.strokeNgon(g, p.x, p.y, r, 4, Math.PI / 4); + } + g.lineStyle(1.4, this.band.flipper, 0.9); + this.strokeNgon(g, p.x, p.y, r * 0.5, 4, Math.PI / 4); + g.lineBetween(p.x - r * 0.5, p.y, p.x + r * 0.5, p.y); + } + + // Spiker: a little spiral corkscrew. + drawSpiker(g, e) { + const p = this.projectAt(e.lane + 0.5, e.t); + const rMax = Math.max(4, 18 * p.s); + const color = ENEMY_COLORS.spiker; + for (const [lw, la] of [[4, 0.2], [1.6, 1]]) { + g.lineStyle(lw, color, la); + g.beginPath(); + const turns = 2.6; + for (let i = 0; i <= 22; i += 1) { + const f = i / 22; + const a = f * turns * Math.PI * 2 + this.time.now * 0.004; + const r = rMax * f; + const x = p.x + Math.cos(a) * r; + const y = p.y + Math.sin(a) * r; + if (i === 0) g.moveTo(x, y); else g.lineTo(x, y); + } + g.strokePath(); + } + } + + // Fuseball: a jittering multi-point spark riding a lane boundary. + drawFuseball(g, e) { + const p = this.projectAt(e.edge, e.t); + const r = Math.max(5, 20 * p.s); + const sparking = e.state === 'pause'; + const spokes = 6; + for (let i = 0; i < spokes; i += 1) { + const a = (i / spokes) * Math.PI * 2 + Math.sin(this.time.now * 0.02 + i * 7) * 0.8; + const rr = r * (0.55 + 0.45 * Math.abs(Math.sin(this.time.now * 0.013 + i * 3))); + const hue = (this.time.now * 0.35 + i * 60) % 360; + const c = Phaser.Display.Color.HSLToColor(hue / 360, 1, sparking ? 0.75 : 0.6).color; + g.lineStyle(4, c, 0.2); + g.lineBetween(p.x, p.y, p.x + Math.cos(a) * rr, p.y + Math.sin(a) * rr); + g.lineStyle(1.6, c, 1); + g.lineBetween(p.x, p.y, p.x + Math.cos(a) * rr, p.y + Math.sin(a) * rr); + } + } + + // Pulsar: a zigzag that flattens and blazes while it pulses; a pulsing + // lane near the rim is electrified along its whole length. + drawPulsar(g, e) { + const sim = this.sim; + const pulsing = sim.pulsarPulsing(); + const a = this.projectAt(e.lane, e.t); + const b = this.projectAt(e.lane + 1, e.t); + const segs = 6; + const amp = (pulsing ? 3 : 9) * Math.max(0.25, a.s); + const nx = -(b.y - a.y); const ny = b.x - a.x; + const nl = Math.hypot(nx, ny) || 1; + const color = ENEMY_COLORS.pulsar; + for (const [lw, la] of [[4, 0.22], [1.7, 1]]) { + g.lineStyle(lw, color, pulsing ? la : la * 0.85); + g.beginPath(); + for (let i = 0; i <= segs; i += 1) { + const f = i / segs; + const sway = (i % 2 === 0 ? 1 : -1) * amp; + const x = a.x + (b.x - a.x) * f + (nx / nl) * sway; + const y = a.y + (b.y - a.y) * f + (ny / nl) * sway; + if (i === 0) g.moveTo(x, y); else g.lineTo(x, y); + } + g.strokePath(); + } + if (pulsing && e.t <= TUNE.PULSAR_LETHAL_T + 0.05) { + // Electrify the lane from the pulsar up to the rim. + const flick = 0.35 + 0.4 * Math.abs(Math.sin(this.time.now * 0.05)); + g.lineStyle(2, color, flick); + for (const rc of [e.lane + 0.25, e.lane + 0.5, e.lane + 0.75]) { + const top = this.projectAt(rc, 0); + const bot = this.projectAt(rc, e.t); + g.lineBetween(top.x, top.y, bot.x, bot.y); + } + } + } + + // The player's claw: two prongs gripping the rim of its lane, a crown in + // the middle, drawn in rim-tangent space so it hugs any web shape. + drawPlayer() { + const g = this.playerG; + g.clear(); + const sim = this.sim; + if (!sim.player.alive || sim.phase === 'gameover') return; + + // During the warp the ship rides the camera: its sim depth is warpT, + // which the warp-aware projector maps right back to the rim ring. + const depth = sim.phase === 'warp' ? sim.warpT : 0; + const pos = sim.player.pos; + const a = this.projectAt(pos, depth); + const b = this.projectAt(pos + 1, depth); + const mx = (a.x + b.x) / 2; const my = (a.y + b.y) / 2; + const { vp } = this.proj; + let nx = mx - vp.x; let ny = my - vp.y; + const nl = Math.hypot(nx, ny) || 1; + nx /= nl; ny /= nl; + let tx = b.x - a.x; let ty = b.y - a.y; + const tl = Math.hypot(tx, ty) || 1; + tx /= tl; ty /= tl; + const w = Math.min(46, Math.max(16, tl * 0.5)); + + const P = (bx, by, outward, along) => [bx + nx * outward + tx * along, by + ny * outward + ty * along]; + const [s1x, s1y] = P(a.x, a.y, w * 0.9, w * 0.18); + const [s2x, s2y] = P(b.x, b.y, w * 0.9, -w * 0.18); + const [c1x, c1y] = P(mx, my, w * 0.42, -w * 0.3); + const [c2x, c2y] = P(mx, my, w * 0.42, w * 0.3); + const [tipx, tipy] = P(mx, my, w * 1.25, 0); + + const color = this.band.player; + for (const [lw, la] of [[6, 0.2], [2.2, 1]]) { + g.lineStyle(lw, color, la); + g.beginPath(); + g.moveTo(a.x, a.y); + g.lineTo(s1x, s1y); + g.lineTo(c1x, c1y); + g.lineTo(tipx, tipy); + g.lineTo(c2x, c2y); + g.lineTo(s2x, s2y); + g.lineTo(b.x, b.y); + g.strokePath(); + } + // Idle shimmer at the claw tip. + const tw = 0.5 + 0.5 * Math.sin(this.time.now * 0.008); + g.fillStyle(color, 0.35 + 0.4 * tw); + g.fillCircle(tipx, tipy, 3 + 2 * tw); + } + + drawShots() { + const g = this.shotG; + g.clear(); + const sim = this.sim; + for (const s of sim.shots) { + const head = this.projectAt(s.lane + 0.5, s.t); + const tail = this.projectAt(s.lane + 0.5, Math.max(0, s.t - 0.05)); + const color = this.band.player; + g.lineStyle(4, color, 0.25); + g.lineBetween(tail.x, tail.y, head.x, head.y); + g.lineStyle(1.8, color, 1); + g.lineBetween(tail.x, tail.y, head.x, head.y); + const r = Math.max(2, 6 * head.s); + g.lineStyle(1.6, 0xffffff, 1); + this.strokeNgon(g, head.x, head.y, r, 4, Math.PI / 4); + } + } + + // ── FX: particles, popups, banner, zap flash ───────────────────────────── + + burst(x, y, color, count, scale) { + for (let i = 0; i < count; i += 1) { + const a = Math.random() * Math.PI * 2; + const speed = (60 + Math.random() * 240) * Math.max(0.35, scale); + this.particles.push({ + x, y, + dx: Math.cos(a) * speed, dy: Math.sin(a) * speed, + len: (6 + Math.random() * 14) * Math.max(0.35, scale), + ang: a, spin: (Math.random() - 0.5) * 8, + ageMs: 0, lifeMs: 380 + Math.random() * 320, color, + }); + } + } + + drawFx(delta) { + const g = this.fxG; + g.clear(); + + // Line-segment explosion debris. + this.particles = this.particles.filter((p) => (p.ageMs += delta) < p.lifeMs); + for (const p of this.particles) { + p.x += p.dx * (delta / 1000); + p.y += p.dy * (delta / 1000); + p.ang += p.spin * (delta / 1000); + const f = 1 - p.ageMs / p.lifeMs; + const hx = Math.cos(p.ang) * p.len * 0.5; + const hy = Math.sin(p.ang) * p.len * 0.5; + g.lineStyle(2, p.color, f); + g.lineBetween(p.x - hx, p.y - hy, p.x + hx, p.y + hy); + } + + // Floating score popups (vector digits). + this.popups = this.popups.filter((p) => (p.ageMs += delta) < 900); + for (const p of this.popups) { + const f = p.ageMs / 900; + drawVectorText(g, String(p.value), p.x, p.y - f * 44, 2.2, 0xffffff, + { lineWidth: 1.6, glowWidth: 5, alpha: 1 - f * f }); + } + + // Superzapper full-screen flash. + if (this.flashMs > 0) { + this.flashMs -= delta; + const f = Math.max(0, this.flashMs / 420); + g.fillStyle(0xffffff, 0.55 * f); + g.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + } + + // Banner text. + const bg = this.bannerG; + bg.clear(); + if (this.banner) { + this.banner.ageMs += delta; + if (this.banner.ageMs >= this.banner.lifeMs) { + this.banner = null; + } else { + const f = this.banner.ageMs / this.banner.lifeMs; + const alpha = f < 0.15 ? f / 0.15 : (f > 0.75 ? (1 - f) / 0.25 : 1); + drawVectorText(bg, this.banner.text, WEB_CX, 300, 7, this.banner.color, + { lineWidth: 3, glowWidth: 12, glowAlpha: 0.22, alpha }); + } + } + } + + showBanner(text, color, lifeMs) { + this.banner = { text, color, lifeMs, ageMs: 0 }; + } + + // ── Game over ───────────────────────────────────────────────────────────── + + onGameOver(e) { + this.mode = 'gameover'; + this.crt.pulse(1.0, 400); + + const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0); + const newBest = e.score > prevBest; + if (newBest) localStorage.setItem(BEST_KEY, String(e.score)); + const max = this.maxReached(); + if (e.level > max) localStorage.setItem(MAX_KEY, String(e.level)); + + api.post('/history/single-player', { + slug: 'tempest', score: e.score, opponentScores: [], result: 'loss', + }).catch(() => { /* best effort */ }); + + this.time.delayedCall(600, () => this.showGameOverPanel(e, prevBest, newBest)); + } + + showGameOverPanel(e, prevBest, newBest) { + const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; + const root = this.add.container(0, 0).setDepth(D.overlay); + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive(); + root.add(dim); + + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 380, cy - 260, 760, 520, 22); + panel.lineStyle(3, this.band.web, 1); + panel.strokeRoundedRect(cx - 380, cy - 260, 760, 520, 22); + root.add(panel); + + root.add(this.add.text(cx, cy - 192, 'GAME OVER', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '52px', color: COLORS.dangerHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy - 130, `You reached level ${e.level}.`, { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + + const scoreText = this.add.text(cx, cy - 30, '0', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '88px', color: COLORS.goldHex, + }).setOrigin(0.5); + root.add(scoreText); + const counter = { v: 0 }; + this.tweens.add({ + targets: counter, v: e.score, duration: 900, ease: 'Cubic.easeOut', + onUpdate: () => scoreText.setText(String(Math.round(counter.v))), + }); + root.add(this.add.text(cx, cy + 30, 'SCORE', { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex, + }).setOrigin(0.5)); + root.add(this.add.text(cx, cy + 74, newBest ? '★ NEW BEST ★' : (prevBest > 0 ? `Best: ${prevBest}` : ''), { + fontFamily: 'm6x11, "Julius Sans One"', fontSize: '24px', color: newBest ? COLORS.goldHex : COLORS.mutedHex, + }).setOrigin(0.5)); + + const again = new Button(this, cx - 170, cy + 190, 'Play Again', + () => this.scene.restart({ game: this.gameDef }), + { width: 280, height: 62, fontSize: 26 }); + const menu = new Button(this, cx + 170, cy + 190, 'Menu', + () => this.scene.start('GameMenu'), + { width: 280, height: 62, fontSize: 26, variant: 'ghost' }); + root.add([again, menu]); + } +} diff --git a/src/games/tempest/TempestLogic.js b/src/games/tempest/TempestLogic.js new file mode 100644 index 0000000..5525120 --- /dev/null +++ b/src/games/tempest/TempestLogic.js @@ -0,0 +1,967 @@ +// Pure simulation for Tempest (Atari 1981 vector arcade). No Phaser +// dependency — fully unit-testable headlessly via tools/verifyTempest.js. +// +// Coordinate model: every gameplay position is (lane, t) where t = 0 is the +// near rim the player rides and t = 1 is the pit (the vanishing point down +// the tube). The scene projects (rim coordinate, t) to pixels with +// makeProjector; the sim itself never touches screen space. + +export const LANES = 16; + +export function mulberry32(seed) { + let a = seed >>> 0; + return () => { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// --------------------------------------------------------------------------- +// Web geometry — the 16 classic playfield shapes, in arcade order. Closed +// webs have LANES verts (lane i spans verts[i]..verts[i+1 mod N]); open webs +// have LANES+1 verts and no wraparound. Every def carries `pit`, the +// vanishing-point offset in the same normalized space; for open webs the pit +// sits on the concave side so lanes radiate from it like the arcade's tubes. +// --------------------------------------------------------------------------- + +function polarShape(rFn, phase = 0) { + const verts = []; + for (let i = 0; i < LANES; i += 1) { + const a = phase + (i / LANES) * Math.PI * 2; + const r = rFn(a); + verts.push([Math.cos(a) * r, Math.sin(a) * r]); + } + return verts; +} + +// Subdivide a corner path into lanes: counts[i] lanes along edge i. +function subdivideEdges(corners, counts, closed) { + const verts = []; + const n = corners.length; + const edges = closed ? n : n - 1; + for (let e = 0; e < edges; e += 1) { + const [ax, ay] = corners[e]; + const [bx, by] = corners[(e + 1) % n]; + for (let s = 0; s < counts[e]; s += 1) { + const f = s / counts[e]; + verts.push([ax + (bx - ax) * f, ay + (by - ay) * f]); + } + } + if (!closed) verts.push(corners[n - 1]); + return verts; +} + +// Center on the bounding box and scale to a max half-extent of 1 so every +// web renders at a consistent size regardless of how it was authored. +function normalizeVerts(verts) { + let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; + for (const [x, y] of verts) { + minX = Math.min(minX, x); maxX = Math.max(maxX, x); + minY = Math.min(minY, y); maxY = Math.max(maxY, y); + } + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + const scale = 1 / Math.max((maxX - minX) / 2, (maxY - minY) / 2); + return verts.map(([x, y]) => [(x - cx) * scale, (y - cy) * scale]); +} + +function heartVerts() { + const verts = []; + for (let i = 0; i < LANES; i += 1) { + const a = (i / LANES) * Math.PI * 2; + const x = 16 * Math.sin(a) ** 3; + const y = -(13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a)); + verts.push([x / 16, y / 16]); + } + return verts; +} + +function starVerts() { + const verts = []; + for (let i = 0; i < LANES; i += 1) { + const a = -Math.PI / 2 + (i / LANES) * Math.PI * 2; + const r = i % 2 === 0 ? 1 : 0.55; + verts.push([Math.cos(a) * r, Math.sin(a) * r]); + } + return verts; +} + +function buildWeb(name, closed, rawVerts, pit) { + const verts = normalizeVerts(rawVerts); + // Rim-midpoint angle of each lane as seen from the pit — the basis for + // mapping the mouse angle to a target lane (see aimLaneForAngle). + const laneAngles = []; + const laneCountN = closed ? verts.length : verts.length - 1; + for (let i = 0; i < laneCountN; i += 1) { + const [ax, ay] = verts[i]; + const [bx, by] = verts[(i + 1) % verts.length]; + laneAngles.push(Math.atan2((ay + by) / 2 - pit[1], (ax + bx) / 2 - pit[0])); + } + return { name, closed, verts, pit, laneAngles }; +} + +const PLUS_CORNERS = [ + [-0.4, -1], [0.4, -1], [0.4, -0.4], [1, -0.4], [1, 0.4], [0.4, 0.4], + [0.4, 1], [-0.4, 1], [-0.4, 0.4], [-1, 0.4], [-1, -0.4], [-0.4, -0.4], +]; +const PLUS_COUNTS = [2, 1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1]; + +function rotate45(pts) { + const s = Math.SQRT1_2; + return pts.map(([x, y]) => [(x - y) * s, (x + y) * s]); +} + +export const WEBS = [ + buildWeb('CIRCLE', true, polarShape(() => 1, -Math.PI / 2), [0.12, -0.1]), + buildWeb('SQUARE', true, subdivideEdges( + [[-1, -1], [1, -1], [1, 1], [-1, 1]], [4, 4, 4, 4], true, + ), [-0.12, 0.1]), + buildWeb('PLUS', true, subdivideEdges(PLUS_CORNERS, PLUS_COUNTS, true), [0.1, 0.12]), + buildWeb('PEANUT', true, polarShape((a) => 0.55 + 0.45 * Math.abs(Math.cos(a))), [0, -0.15]), + // The X web is the plus rotated 45 degrees. + buildWeb('CROSS', true, subdivideEdges(rotate45(PLUS_CORNERS), PLUS_COUNTS, true), [-0.1, -0.12]), + buildWeb('TRIANGLE', true, subdivideEdges( + [[0, -1], [1, 0.85], [-1, 0.85]], [6, 5, 5], true, + ), [0, 0.15]), + buildWeb('CLOVER', true, polarShape((a) => 0.55 + 0.45 * Math.abs(Math.sin(2 * a))), [0.14, 0]), + buildWeb('VEE', false, subdivideEdges( + [[-1, -0.55], [0, 0.75], [1, -0.55]], [8, 8], false, + ), [0, 0.05]), + buildWeb('STEPS', false, subdivideEdges( + [ + [-1, 0.6], [-0.75, 0.6], [-0.75, 0.2], [-0.5, 0.2], [-0.5, -0.2], + [-0.25, -0.2], [-0.25, -0.6], [0.25, -0.6], [0.25, -0.2], [0.5, -0.2], + [0.5, 0.2], [0.75, 0.2], [0.75, 0.6], [1, 0.6], + ], + [2, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 2], false, + // The pit must sit well below the staircase or the vertical risers break + // the star-shape property the mouse-aim mapping relies on. + ), [0, 0.75]), + buildWeb('UBEND', false, subdivideEdges( + [[-1, -0.9], [-0.85, 0.55], [-0.3, 0.9], [0.3, 0.9], [0.85, 0.55], [1, -0.9]], + [4, 3, 2, 3, 4], false, + ), [0, -0.05]), + buildWeb('FLAT', false, subdivideEdges( + [[-1, 0], [1, 0]], [16], false, + ), [0, -0.55]), + buildWeb('HEART', true, heartVerts(), [0, 0.08]), + buildWeb('STAR', true, starVerts(), [0.1, -0.08]), + buildWeb('DUBYA', false, subdivideEdges( + [[-1, -0.5], [-0.5, 0.5], [0, -0.15], [0.5, 0.5], [1, -0.5]], + [4, 4, 4, 4], false, + ), [0, -0.6]), + buildWeb('WIDE VEE', false, subdivideEdges( + [[-1, -0.3], [0, 0.42], [1, -0.3]], [8, 8], false, + ), [0, -0.15]), + buildWeb('OVAL', true, polarShape(() => 1, -Math.PI / 2).map(([x, y]) => [x, y * 0.62]), [-0.12, 0.06]), +]; + +export function laneCount(web) { + return web.closed ? web.verts.length : web.verts.length - 1; +} + +export function webForLevel(level) { + return WEBS[(level - 1) % WEBS.length]; +} + +// Point on the rim polyline at a continuous rim coordinate in lane units +// (0..N for closed with wrap, 0..N for open where N maps to the last vert). +export function rimPoint(web, rimCoord) { + const n = web.verts.length; + let rc = rimCoord; + if (web.closed) { + rc = ((rc % n) + n) % n; + } else { + rc = Math.max(0, Math.min(n - 1, rc)); + } + const i = Math.min(Math.floor(rc), n - (web.closed ? 1 : 2)); + const f = rc - i; + const [ax, ay] = web.verts[i]; + const [bx, by] = web.verts[(i + 1) % n]; + return [ax + (bx - ax) * f, ay + (by - ay) * f]; +} + +// True perspective down the tube: screen scale is 1/z with the far end at +// FAR_SCALE of the rim, so enemies stay small deep in the well and loom +// quickly near the top — the classic Tempest depth feel. +export const FAR_SCALE = 0.135; + +export function depthScale(t) { + const zFar = 1 / FAR_SCALE; + return 1 / (1 + Math.max(0, Math.min(1, t)) * (zFar - 1)); +} + +// Projector shared by the sim's tests and the scene's rendering. Returns +// project(rimCoord, t) -> {x, y} plus the vanishing point in screen space +// (also the origin the scene measures the mouse angle from). +export function makeProjector(web, cx, cy, scale) { + const vpX = cx + web.pit[0] * scale; + const vpY = cy + web.pit[1] * scale; + const project = (rimCoord, t) => { + const [rx, ry] = rimPoint(web, rimCoord); + const s = depthScale(t); + return { + x: vpX + (rx - web.pit[0]) * scale * s, + y: vpY + (ry - web.pit[1]) * scale * s, + }; + }; + return { project, vp: { x: vpX, y: vpY } }; +} + +function angleDiff(a, b) { + let d = a - b; + while (d > Math.PI) d -= Math.PI * 2; + while (d < -Math.PI) d += Math.PI * 2; + return d; +} + +// Mouse angle (about the vanishing point) -> the lane whose rim midpoint +// sits closest to that bearing. Every web is star-shaped about its pit, so +// the mapping is unambiguous; verifyTempest.js asserts that property. +export function aimLaneForAngle(web, angle) { + let best = 0; + let bestD = Infinity; + for (let i = 0; i < web.laneAngles.length; i += 1) { + const d = Math.abs(angleDiff(angle, web.laneAngles[i])); + if (d < bestD) { bestD = d; best = i; } + } + return best; +} + +// --------------------------------------------------------------------------- +// Tuning +// --------------------------------------------------------------------------- + +export const TUNE = { + LIVES: 3, + EXTRA_LIFE_EVERY: 20000, + + MOVE_LANES_PER_SEC: 14, + FIRE_COOLDOWN_MS: 110, + MAX_SHOTS: 8, // arcade cap on live player shots + SHOT_SPEED: 2.3, // t units per second, rim -> pit + + // Enemy climb speeds in t/sec (positive = toward the rim) and growth. + FLIPPER_SPEED: 0.16, FLIPPER_SPEED_GROWTH: 0.006, FLIPPER_SPEED_MAX: 0.42, + TANKER_SPEED: 0.10, + SPIKER_SPEED: 0.22, + FUSEBALL_SPEED: 0.30, + PULSAR_SPEED: 0.14, + + // Rim flippers hunt the player: rest between hops, then a hop that can be + // shot mid-flight. Ascending flippers also hop lanes from FLIP_CLIMB_LEVEL. + FLIP_DUR_MS: 260, + FLIP_REST_MS: 520, FLIP_REST_DECAY: 8, FLIP_REST_MIN_MS: 220, + FLIP_CLIMB_LEVEL: 5, FLIP_CLIMB_CHANCE: 0.25, FLIP_CLIMB_COOLDOWN_MS: 1400, + + SPIKE_MIN_T: 0.22, // how close to the rim a spike can grow + SPIKE_TRIM: 0.06, // how much one shot shaves off a spike tip + SPIKE_POINTS: 1, + + FUSEBALL_MOVE_MS: [500, 1100], + FUSEBALL_PAUSE_MS: [350, 900], + FUSEBALL_EDGE_HOP_CHANCE: 0.35, + FUSEBALL_CONTACT_T: 0.05, + + PULSAR_PERIOD_MS: 3200, PULSAR_ACTIVE_MS: 700, + PULSAR_LETHAL_T: 0.18, // a pulse only kills when the pulsar is near the rim + PULSAR_TURN_T: 0.10, // patrol: climb to here, retreat, climb again + PULSAR_RETREAT_T: 0.72, + + ENEMY_SHOT_SPEED: 0.34, ENEMY_SHOT_SPEED_GROWTH: 0.012, ENEMY_SHOT_SPEED_MAX: 0.62, + ENEMY_FIRE_PER_SEC: 0.16, ENEMY_FIRE_GROWTH: 0.012, ENEMY_FIRE_MAX: 0.55, + MAX_ENEMY_SHOTS: 8, + + // Spawn scheduling: per-level budget, concurrency cap, and cadence. + BUDGET_BASE: 10, BUDGET_GROWTH: 2.0, BUDGET_MAX: 40, + CONCURRENT_BASE: 4, CONCURRENT_MAX: 10, + SPAWN_MS_BASE: 1500, SPAWN_MS_DECAY: 0.97, SPAWN_MS_MIN: 480, + + // Type unlock levels and relative spawn weights once unlocked. + UNLOCK: { flipper: 1, tanker: 3, spiker: 4, fuseball: 11, pulsar: 17 }, + WEIGHT: { flipper: 1.0, tanker: 0.4, spiker: 0.35, fuseball: 0.3, pulsar: 0.35 }, + + POINTS: { flipper: 150, tanker: 100, spiker: 50, pulsar: 200 }, + FUSEBALL_POINTS: [250, 500, 750], // by proximity to the rim when killed + + ZAPPER_USES: 2, + ZAP_CASCADE_MS: 40, + + DEATH_MS: 1300, + RESPITE_MS: 900, + WARP_MS: 2600, + + START_BONUS_SCALE: 700, // start bonus ~ (level-1)^2 * this, rounded +}; + +export function flipperSpeed(level) { + return Math.min(TUNE.FLIPPER_SPEED_MAX, TUNE.FLIPPER_SPEED + TUNE.FLIPPER_SPEED_GROWTH * (level - 1)); +} +export function enemyShotSpeed(level) { + return Math.min(TUNE.ENEMY_SHOT_SPEED_MAX, TUNE.ENEMY_SHOT_SPEED + TUNE.ENEMY_SHOT_SPEED_GROWTH * (level - 1)); +} +export function enemyFireRate(level) { + return Math.min(TUNE.ENEMY_FIRE_MAX, TUNE.ENEMY_FIRE_PER_SEC + TUNE.ENEMY_FIRE_GROWTH * (level - 1)); +} +export function levelBudget(level) { + return Math.min(TUNE.BUDGET_MAX, Math.round(TUNE.BUDGET_BASE + TUNE.BUDGET_GROWTH * (level - 1))); +} +export function maxConcurrent(level) { + return Math.min(TUNE.CONCURRENT_MAX, TUNE.CONCURRENT_BASE + Math.floor(level / 2)); +} +export function spawnInterval(level) { + return Math.max(TUNE.SPAWN_MS_MIN, TUNE.SPAWN_MS_BASE * TUNE.SPAWN_MS_DECAY ** (level - 1)); +} +export function flipRest(level) { + return Math.max(TUNE.FLIP_REST_MIN_MS, TUNE.FLIP_REST_MS - TUNE.FLIP_REST_DECAY * (level - 1)); +} + +// --------------------------------------------------------------------------- +// Level color bands — the arcade cycles its palette every 16 levels +// (blue, red, yellow, cyan, green, then around again). Enemies keep their +// canonical identity colors except the flipper, which the arcade re-inks +// per band so it never blends into the web. +// --------------------------------------------------------------------------- + +export const BANDS = [ + { web: 0x2b3cff, lane: 0xffe14d, player: 0xffe14d, flipper: 0xff4040, text: 0x7d9bff, bg: 0x03040c }, + { web: 0xff3b30, lane: 0x7dff6b, player: 0x7dff6b, flipper: 0x3cff88, text: 0xff9d94, bg: 0x0c0304 }, + { web: 0xffd23c, lane: 0x3cd2ff, player: 0x3cd2ff, flipper: 0xff5540, text: 0xffe694, bg: 0x0b0903 }, + { web: 0x35e0ff, lane: 0xff6bd4, player: 0xff6bd4, flipper: 0xff8040, text: 0x9deeff, bg: 0x030a0c }, + { web: 0x46ff7c, lane: 0xffffff, player: 0xffffff, flipper: 0xff5050, text: 0xa8ffc4, bg: 0x040c05 }, +]; + +export const ENEMY_COLORS = { + tanker: 0xc46bff, spiker: 0x5cff9a, fuseball: 0xffffff, pulsar: 0x6bf6ff, +}; + +export function bandIndexForLevel(level) { + return Math.floor((level - 1) / WEBS.length) % BANDS.length; +} + +// --------------------------------------------------------------------------- +// Skill Step — the arcade's starting-level select with a deeper-start bonus. +// --------------------------------------------------------------------------- + +export function startBonus(level) { + if (level <= 1) return 0; + return Math.round(((level - 1) ** 2 * TUNE.START_BONUS_SCALE) / 100) * 100; +} + +export function startLevelOptions(maxReached) { + const cap = Math.max(1, maxReached); + const options = []; + for (let l = 1; l <= Math.min(cap, 15); l += 2) options.push(l); + for (let l = 17; l <= Math.min(cap, 65); l += 3) options.push(l); + return options; +} + +// --------------------------------------------------------------------------- +// Simulation +// --------------------------------------------------------------------------- + +let NEXT_ID = 1; + +export class Sim { + constructor(opts = {}) { + this.rng = mulberry32((opts.seed ?? Date.now()) >>> 0); + this.level = Math.max(1, opts.startLevel ?? 1); + this.score = startBonus(this.level); + this.lives = TUNE.LIVES; + this.nextExtraLifeAt = TUNE.EXTRA_LIFE_EVERY; + // A deep start's bonus counts toward extra lives, like the arcade. + while (this.score >= this.nextExtraLifeAt) this.nextExtraLifeAt += TUNE.EXTRA_LIFE_EVERY; + this.phase = 'playing'; // 'playing'|'death'|'respite'|'warp'|'gameover' + this.events = []; + this.pulsarClockMs = 0; + this.warpT = 0; + this.deathMs = 0; + this.respiteMs = 0; + this.pendingWarp = false; // re-fly the warp after a spike death + this.enterLevel(this.level); + } + + emit(type, data = {}) { this.events.push({ type, ...data }); } + + enterLevel(level) { + this.level = level; + this.web = webForLevel(level); + const n = laneCount(this.web); + // Start at the bottom of a closed web (the arcade's home position) or + // the middle of an open one. + const startLane = this.web.closed + ? aimLaneForAngle(this.web, Math.PI / 2) + : Math.floor((n - 1) / 2); + // Control state (a held fire button) survives level transitions. + this.player = { + pos: startLane, targetLane: startLane, coolMs: 0, alive: true, + firing: this.player ? this.player.firing : false, + }; + this.shots = []; + this.enemyShots = []; + this.enemies = []; + this.spikes = new Array(n).fill(1); // spike tip t per lane; 1 = no spike + this.zapper = { uses: TUNE.ZAPPER_USES, cascade: [], cascadeMs: 0 }; + this.spawn = { remaining: this.buildBudget(level), timerMs: 600 }; + this.warpT = 0; + } + + buildBudget(level) { + const remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const types = Object.keys(TUNE.UNLOCK).filter((k) => level >= TUNE.UNLOCK[k]); + const weights = types.map((k) => TUNE.WEIGHT[k]); + const total = weights.reduce((a, b) => a + b, 0); + let budget = levelBudget(level); + // Always seed at least one of each unlocked support type so new + // mechanics reliably show up the level they're introduced. + for (const k of types) { + if (k !== 'flipper' && budget > 0) { remaining[k] += 1; budget -= 1; } + } + for (let i = 0; i < budget; i += 1) { + let roll = this.rng() * total; + let pick = types[0]; + for (let j = 0; j < types.length; j += 1) { + roll -= weights[j]; + if (roll <= 0) { pick = types[j]; break; } + } + remaining[pick] += 1; + } + return remaining; + } + + budgetTotal() { + const r = this.spawn.remaining; + return r.flipper + r.tanker + r.spiker + r.fuseball + r.pulsar; + } + + playerLane() { + const n = laneCount(this.web); + let lane = Math.round(this.player.pos); + if (this.web.closed) lane = ((lane % n) + n) % n; + else lane = Math.max(0, Math.min(n - 1, lane)); + return lane; + } + + // -- public control surface (via the exported wrappers) ------------------ + + setAim(angle) { + if (this.phase === 'gameover') return; + this.player.targetLane = aimLaneForAngle(this.web, angle); + } + + setFiring(on) { this.player.firing = !!on; } + + superzap() { + if (this.phase !== 'playing' || this.zapper.uses <= 0) return; + this.zapper.uses -= 1; + if (this.zapper.uses === 1) { + // Full charge: every enemy on the web dies in a rippling cascade. + this.zapper.cascade = this.enemies.map((e) => e.id); + this.zapper.cascadeMs = 0; + this.emit('superzapper', { mode: 'all' }); + } else { + const live = this.enemies; + if (live.length) { + const victim = live[Math.floor(this.rng() * live.length)]; + this.killEnemy(victim, { zap: true, points: 0 }); + } + this.emit('superzapper', { mode: 'one' }); + } + } + + // -- scoring / kills ------------------------------------------------------ + + addScore(points) { + if (points <= 0) return; + this.score += points; + while (this.score >= this.nextExtraLifeAt) { + this.lives += 1; + this.nextExtraLifeAt += TUNE.EXTRA_LIFE_EVERY; + this.emit('extraLife', { lives: this.lives }); + } + } + + pointsFor(e) { + if (e.type === 'fuseball') { + const p = TUNE.FUSEBALL_POINTS; + if (e.t < 0.33) return p[2]; + if (e.t < 0.66) return p[1]; + return p[0]; + } + return TUNE.POINTS[e.type] ?? 0; + } + + killEnemy(e, { zap = false, points = null } = {}) { + const idx = this.enemies.indexOf(e); + if (idx === -1) return; + this.enemies.splice(idx, 1); + const pts = points ?? this.pointsFor(e); + this.addScore(pts); + if (e.type === 'tanker' && !zap) this.releaseFlippers(e); + this.emit('enemyKilled', { + enemyType: e.type, points: pts, lane: e.lane, edge: e.edge, t: e.t, zap, + }); + } + + releaseFlippers(tanker) { + const n = laneCount(this.web); + for (const dir of [-1, 1]) { + let lane = tanker.lane + dir; + if (this.web.closed) lane = ((lane % n) + n) % n; + else lane = Math.max(0, Math.min(n - 1, lane)); + this.enemies.push(this.makeEnemy('flipper', lane, Math.min(0.95, tanker.t + 0.02))); + } + } + + // -- spawning ------------------------------------------------------------- + + makeEnemy(type, lane, t) { + const e = { id: NEXT_ID++, type, lane, t, prevT: t }; + if (type === 'flipper') { + e.state = 'climb'; e.restMs = flipRest(this.level); e.flipMs = 0; + e.flipFrom = lane; e.flipTo = lane; e.climbFlipCd = TUNE.FLIP_CLIMB_COOLDOWN_MS; + } else if (type === 'spiker') { + e.dir = -1; // toward the rim + } else if (type === 'fuseball') { + e.edge = lane; e.state = 'move'; e.dir = -1; + e.phaseMs = this.randRange(TUNE.FUSEBALL_MOVE_MS); + } else if (type === 'pulsar') { + e.dir = -1; + } + return e; + } + + randRange([lo, hi]) { return lo + this.rng() * (hi - lo); } + + spawnTick(dt) { + if (this.budgetTotal() <= 0) return; + if (this.enemies.length >= maxConcurrent(this.level)) return; + this.spawn.timerMs -= dt; + if (this.spawn.timerMs > 0) return; + this.spawn.timerMs = spawnInterval(this.level) * (0.7 + this.rng() * 0.6); + const r = this.spawn.remaining; + const types = Object.keys(r).filter((k) => r[k] > 0); + const pick = types[Math.floor(this.rng() * types.length)]; + r[pick] -= 1; + const n = laneCount(this.web); + const lane = Math.floor(this.rng() * (pick === 'fuseball' ? this.edgeCount() : n)); + this.enemies.push(this.makeEnemy(pick, lane, 1)); + this.emit('enemySpawned', { enemyType: pick, lane }); + } + + // Lane-boundary count: vertex i is the edge between lanes i-1 and i, so + // both closed and open webs have exactly verts.length edges. + edgeCount() { + return this.web.verts.length; + } + + // -- player --------------------------------------------------------------- + + movePlayer(dt) { + const n = laneCount(this.web); + const target = this.player.targetLane; + if (target == null) return; + let diff; + if (this.web.closed) { + diff = ((target - this.player.pos) % n + n) % n; + if (diff > n / 2) diff -= n; // shorter way around the rim + } else { + diff = Math.max(0, Math.min(n - 1, target)) - this.player.pos; + } + const maxStep = TUNE.MOVE_LANES_PER_SEC * (dt / 1000); + const step = Math.max(-maxStep, Math.min(maxStep, diff)); + this.player.pos += step; + if (this.web.closed) this.player.pos = ((this.player.pos % n) + n) % n; + else this.player.pos = Math.max(0, Math.min(n - 1, this.player.pos)); + } + + fireTick(dt) { + this.player.coolMs = Math.max(0, this.player.coolMs - dt); + if (!this.player.firing || this.player.coolMs > 0) return; + if (this.shots.length >= TUNE.MAX_SHOTS) return; + const depth = this.phase === 'warp' ? this.warpT : 0; + this.shots.push({ lane: this.playerLane(), t: depth, prevT: depth }); + this.player.coolMs = TUNE.FIRE_COOLDOWN_MS; + this.emit('shotFired', { lane: this.playerLane() }); + } + + // -- enemies -------------------------------------------------------------- + + visualLane(e) { + if (e.type === 'flipper' && e.state === 'flip') { + return e.flipMs / TUNE.FLIP_DUR_MS < 0.5 ? e.flipFrom : e.flipTo; + } + return e.lane; + } + + adjacentLaneToward(fromLane, targetLane) { + const n = laneCount(this.web); + if (this.web.closed) { + let d = ((targetLane - fromLane) % n + n) % n; + if (d === 0) return fromLane; + if (d > n / 2) d -= n; + return ((fromLane + Math.sign(d)) % n + n) % n; + } + if (targetLane === fromLane) return fromLane; + return Math.max(0, Math.min(n - 1, fromLane + Math.sign(targetLane - fromLane))); + } + + updateFlipper(e, dt) { + if (e.state === 'climb') { + e.t -= flipperSpeed(this.level) * (dt / 1000); + // High-level flippers hop lanes on the way up, too. + e.climbFlipCd -= dt; + if (this.level >= TUNE.FLIP_CLIMB_LEVEL && e.climbFlipCd <= 0 && e.t > 0.15) { + e.climbFlipCd = TUNE.FLIP_CLIMB_COOLDOWN_MS * (0.7 + this.rng() * 0.6); + if (this.rng() < TUNE.FLIP_CLIMB_CHANCE) { + e.lane = this.adjacentLaneToward(e.lane, this.playerLane()); + } + } + if (e.t <= 0) { + e.t = 0; + e.state = 'rest'; + e.restMs = flipRest(this.level); + } + } else if (e.state === 'rest') { + e.restMs -= dt; + if (e.restMs <= 0) { + const to = this.adjacentLaneToward(e.lane, this.playerLane()); + if (to !== e.lane) { + e.state = 'flip'; e.flipFrom = e.lane; e.flipTo = to; e.flipMs = 0; + } else { + e.restMs = flipRest(this.level); + } + } + } else if (e.state === 'flip') { + e.flipMs += dt; + if (e.flipMs >= TUNE.FLIP_DUR_MS) { + e.lane = e.flipTo; + e.state = 'rest'; + e.restMs = flipRest(this.level); + } + } + // A rim flipper sharing the player's lane is a grab — instant death. + if (e.t <= 0.02 && e.state !== 'climb' && this.visualLane(e) === this.playerLane() && e.state !== 'flip') { + this.killPlayer('grabbed'); + } + } + + updateTanker(e, dt) { + e.t -= TUNE.TANKER_SPEED * (dt / 1000); + if (e.t <= 0.05) { + // Reached the top: pops open and its two flippers spill out. + const idx = this.enemies.indexOf(e); + if (idx !== -1) this.enemies.splice(idx, 1); + this.releaseFlippers(e); + this.emit('enemyReachedRim', { enemyType: 'tanker', lane: e.lane }); + } + } + + updateSpiker(e, dt) { + e.t += e.dir * TUNE.SPIKER_SPEED * (dt / 1000); + if (e.dir === -1) { + this.spikes[e.lane] = Math.min(this.spikes[e.lane], Math.max(e.t, TUNE.SPIKE_MIN_T)); + if (e.t <= TUNE.SPIKE_MIN_T) { e.t = TUNE.SPIKE_MIN_T; e.dir = 1; } + } else if (e.t >= 0.98) { + e.t = 0.98; e.dir = -1; + // Each new pass digs from wherever the spike already ends. + } + } + + updateFuseball(e, dt) { + e.phaseMs -= dt; + if (e.state === 'move') { + e.t += e.dir * TUNE.FUSEBALL_SPEED * (dt / 1000); + if (e.t <= TUNE.FUSEBALL_CONTACT_T) e.t = TUNE.FUSEBALL_CONTACT_T; + if (e.t >= 0.97) { e.t = 0.97; e.dir = -1; } + if (e.phaseMs <= 0) { + e.state = 'pause'; + e.phaseMs = this.randRange(TUNE.FUSEBALL_PAUSE_MS); + } + } else if (e.phaseMs <= 0) { + e.state = 'move'; + e.phaseMs = this.randRange(TUNE.FUSEBALL_MOVE_MS); + // Mostly climbs, sometimes dives back down, sometimes hops an edge. + e.dir = this.rng() < 0.72 ? -1 : 1; + if (this.rng() < TUNE.FUSEBALL_EDGE_HOP_CHANCE) { + const edges = this.edgeCount(); + let edge = e.edge + (this.rng() < 0.5 ? -1 : 1); + if (this.web.closed) edge = ((edge % edges) + edges) % edges; + else edge = Math.max(0, Math.min(edges - 1, edge)); + e.edge = edge; + } + } + // Lethal at the rim when touching either edge of the player's lane. + if (e.t <= TUNE.FUSEBALL_CONTACT_T + 0.01) { + const n = laneCount(this.web); + const lane = this.playerLane(); + const rightEdge = this.web.closed ? (lane + 1) % this.web.verts.length : lane + 1; + if (e.edge === lane || e.edge === rightEdge) this.killPlayer('fuseball'); + } + } + + pulsarPulsing() { + return this.pulsarClockMs % TUNE.PULSAR_PERIOD_MS < TUNE.PULSAR_ACTIVE_MS; + } + + updatePulsar(e, dt) { + e.t += e.dir * TUNE.PULSAR_SPEED * (dt / 1000); + if (e.t <= TUNE.PULSAR_TURN_T) { e.t = TUNE.PULSAR_TURN_T; e.dir = 1; } + if (e.t >= TUNE.PULSAR_RETREAT_T && e.dir === 1) { e.dir = -1; } + if (this.pulsarPulsing() && e.t <= TUNE.PULSAR_LETHAL_T && e.lane === this.playerLane()) { + this.killPlayer('pulsar'); + } + } + + enemyFireTick(e, dt) { + if (e.type !== 'flipper' && e.type !== 'tanker') return; + if (e.t < 0.12 || e.t > 0.92) return; // only while riding the well + if (this.enemyShots.length >= TUNE.MAX_ENEMY_SHOTS) return; + if (this.rng() < enemyFireRate(this.level) * (dt / 1000)) { + this.enemyShots.push({ lane: this.visualLane(e), t: e.t, prevT: e.t }); + this.emit('enemyShotFired', { lane: this.visualLane(e) }); + } + } + + // -- collisions ----------------------------------------------------------- + + crossed(a, b) { + return (a.prevT - b.prevT) * (a.t - b.t) <= 0; + } + + resolveShots() { + for (const shot of this.shots) { + if (shot.dead) continue; + const tip = this.spikes[shot.lane]; + + // Enemy shots die head-on. + for (const es of this.enemyShots) { + if (es.dead || es.lane !== shot.lane) continue; + if (this.crossed(shot, es)) { + shot.dead = true; es.dead = true; + this.emit('enemyShotDestroyed', { lane: shot.lane, t: shot.t }); + break; + } + } + if (shot.dead) continue; + + // Enemies in front of the lane's spike (closer to the rim) are fair + // game; anything hiding behind the spike is protected until it's + // shot down segment by segment. + let hit = null; + for (const e of this.enemies) { + if (e.dead) continue; + const lane = this.visualLane(e); + const inLane = e.type === 'fuseball' + ? this.fuseballBlocksLane(e, shot.lane) + : lane === shot.lane; + if (!inLane) continue; + if (e.type === 'fuseball' && e.state === 'pause') continue; // sparking = intangible + if (e.t > tip + 0.01) continue; + if (this.crossed(shot, e) && (!hit || e.t < hit.t)) hit = e; + } + if (hit) { + shot.dead = true; + hit.dead = true; + this.killEnemy(hit); + continue; + } + + if (tip < 1 && shot.t >= tip) { + shot.dead = true; + this.spikes[shot.lane] = Math.min(1, tip + TUNE.SPIKE_TRIM); + this.addScore(TUNE.SPIKE_POINTS); + this.emit('spikeTrimmed', { lane: shot.lane, t: tip }); + } + } + this.shots = this.shots.filter((s) => !s.dead && s.t < 1); + this.enemyShots = this.enemyShots.filter((s) => !s.dead); + } + + fuseballBlocksLane(e, lane) { + // A fuseball rides an edge; a shot in either adjacent lane can hit it. + const right = this.web.closed ? (lane + 1) % this.web.verts.length : lane + 1; + return e.edge === lane || e.edge === right; + } + + // -- player death / respite ---------------------------------------------- + + killPlayer(cause) { + if (!this.player.alive || this.phase !== 'playing') return; + this.player.alive = false; + this.lives -= 1; + this.phase = 'death'; + this.deathMs = TUNE.DEATH_MS; + this.emit('playerHit', { cause, lives: this.lives }); + } + + finishDeath() { + if (this.lives <= 0) { + this.phase = 'gameover'; + this.emit('gameOver', { score: this.score, level: this.level }); + return; + } + if (this.pendingWarp) { + // Spiked during the fly-through: re-fly the same warp. + this.pendingWarp = false; + this.player.alive = true; + this.warpT = 0; + this.phase = 'warp'; + this.emit('warpStart', { level: this.level, refly: true }); + return; + } + // Survivors sink back into the pit and rejoin the spawn queue; the + // fight resumes where it left off (zapper charges are NOT refilled). + for (const e of this.enemies) { + const key = e.type; + this.spawn.remaining[key] += 1; + } + this.enemies = []; + this.enemyShots = []; + this.shots = []; + this.phase = 'respite'; + this.respiteMs = TUNE.RESPITE_MS; + this.emit('respiteStart', {}); + } + + // -- level flow ----------------------------------------------------------- + + maybeClearLevel() { + if (this.budgetTotal() > 0 || this.enemies.length > 0) return; + this.shots = []; + this.enemyShots = []; + this.phase = 'warp'; + this.warpT = 0; + this.emit('levelCleared', { level: this.level }); + this.emit('warpStart', { level: this.level, refly: false }); + } + + stepWarp(dt) { + const prev = this.warpT; + this.warpT = Math.min(1, this.warpT + dt / TUNE.WARP_MS); + this.movePlayer(dt); + this.fireTick(dt); + for (const shot of this.shots) { shot.prevT = shot.t; shot.t += TUNE.SHOT_SPEED * (dt / 1000); } + // Shots ahead of the diving player shave spikes down. + for (const shot of this.shots) { + const tip = this.spikes[shot.lane]; + if (tip < 1 && shot.t >= tip) { + shot.dead = true; + this.spikes[shot.lane] = Math.min(1, tip + TUNE.SPIKE_TRIM); + this.addScore(TUNE.SPIKE_POINTS); + this.emit('spikeTrimmed', { lane: shot.lane, t: tip }); + } + } + this.shots = this.shots.filter((s) => !s.dead && s.t < 1); + // Flying into a spike costs a life and the warp restarts. + const tip = this.spikes[this.playerLane()]; + if (tip < 1 && prev < tip && this.warpT >= tip) { + this.lives -= 1; + this.emit('spikeHit', { lane: this.playerLane(), lives: this.lives }); + this.player.alive = false; + this.pendingWarp = true; + this.phase = 'death'; + this.deathMs = TUNE.DEATH_MS; + this.emit('playerHit', { cause: 'spike', lives: this.lives }); + return; + } + if (this.warpT >= 1) { + this.enterLevel(this.level + 1); + this.phase = 'playing'; + this.emit('warpDone', { level: this.level }); + } + } + + // -- main tick ------------------------------------------------------------- + + step(dtMs) { + this.events = []; + const dt = Math.min(50, Math.max(0, dtMs)); + this.pulsarClockMs += dt; + + if (this.phase === 'gameover') return this.events; + + if (this.phase === 'death') { + this.deathMs -= dt; + if (this.deathMs <= 0) this.finishDeath(); + return this.events; + } + + if (this.phase === 'respite') { + this.respiteMs -= dt; + if (this.respiteMs <= 0) { + this.phase = 'playing'; + this.player.alive = true; + this.spawn.timerMs = 600; + } + return this.events; + } + + if (this.phase === 'warp') { + this.stepWarp(dt); + return this.events; + } + + // -- phase 'playing' -- + this.movePlayer(dt); + this.fireTick(dt); + this.spawnTick(dt); + + // Superzapper cascade: one victim every ZAP_CASCADE_MS. + if (this.zapper.cascade.length) { + this.zapper.cascadeMs -= dt; + while (this.zapper.cascade.length && this.zapper.cascadeMs <= 0) { + const id = this.zapper.cascade.shift(); + const victim = this.enemies.find((e) => e.id === id); + if (victim) this.killEnemy(victim, { zap: true, points: 0 }); + this.zapper.cascadeMs += TUNE.ZAP_CASCADE_MS; + } + } + + for (const shot of this.shots) { shot.prevT = shot.t; shot.t += TUNE.SHOT_SPEED * (dt / 1000); } + for (const es of this.enemyShots) { es.prevT = es.t; es.t -= enemyShotSpeed(this.level) * (dt / 1000); } + + for (const e of this.enemies.slice()) { + if (e.dead) continue; + e.prevT = e.t; + if (e.type === 'flipper') this.updateFlipper(e, dt); + else if (e.type === 'tanker') this.updateTanker(e, dt); + else if (e.type === 'spiker') this.updateSpiker(e, dt); + else if (e.type === 'fuseball') this.updateFuseball(e, dt); + else if (e.type === 'pulsar') this.updatePulsar(e, dt); + if (this.phase !== 'playing') return this.events; // died mid-loop + this.enemyFireTick(e, dt); + } + + this.resolveShots(); + if (this.phase !== 'playing') return this.events; + + // Enemy shots that reach the rim in the player's lane connect. + for (const es of this.enemyShots) { + if (es.t <= 0) { + if (es.lane === this.playerLane()) { + this.killPlayer('shot'); + break; + } + es.dead = true; + } + } + this.enemyShots = this.enemyShots.filter((s) => !s.dead && s.t > -0.02); + if (this.phase !== 'playing') return this.events; + + this.maybeClearLevel(); + return this.events; + } +} + +// --------------------------------------------------------------------------- +// Function-style API, matching the other arcade games' logic modules. +// --------------------------------------------------------------------------- + +export function createGame(opts = {}) { return new Sim(opts); } +export function step(sim, dtMs) { return sim.step(dtMs); } +export function setAim(sim, angle) { sim.setAim(angle); } +export function setFiring(sim, on) { sim.setFiring(on); } +export function superzap(sim) { sim.superzap(); } diff --git a/src/games/tempest/TempestVectorFont.js b/src/games/tempest/TempestVectorFont.js new file mode 100644 index 0000000..2e323f0 --- /dev/null +++ b/src/games/tempest/TempestVectorFont.js @@ -0,0 +1,90 @@ +// A stroked vector font for Tempest — letters and digits as line segments in +// a 4x6 unit cell, drawn as a glow-stroke pair to match the game's phosphor +// look (same convention as Colorado Defense's VectorFont, extended to the +// full A-Z / 0-9 set this game's banners, score popups, and skill-step +// screen need). + +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]]], + B: [[[0, 0], [0, 6]], [[0, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [0, 2.8]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [0, 6]]], + 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]]], + G: [[[3.6, 1], [1.2, 0], [0, 1.6], [0, 4.4], [1.2, 6], [3, 6], [4, 4.6], [4, 3.2], [2.2, 3.2]]], + H: [[[0, 0], [0, 6]], [[4, 0], [4, 6]], [[0, 3], [4, 3]]], + I: [[[1, 0], [3, 0]], [[2, 0], [2, 6]], [[1, 6], [3, 6]]], + J: [[[4, 0], [4, 5], [3, 6], [1, 6], [0, 5]]], + K: [[[0, 0], [0, 6]], [[4, 0], [0, 3.2]], [[1.4, 2.2], [4, 6]]], + L: [[[0, 0], [0, 6], [3.4, 6]]], + M: [[[0, 6], [0, 0], [2, 2.6], [4, 0], [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]]], + P: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.6], [0, 2.6]]], + Q: [[[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]], [[2.5, 4.4], [4, 6]]], + R: [[[0, 6], [0, 0], [3, 0], [4, 1.2], [3, 2.4], [0, 2.4]], [[1.6, 2.4], [4, 6]]], + S: [[[4, 1], [3, 0], [1, 0], [0, 1], [0, 2], [1, 2.8], [3, 3.2], [4, 4], [4, 5], [3, 6], [1, 6], [0, 5]]], + T: [[[0, 0], [4, 0]], [[2, 0], [2, 6]]], + U: [[[0, 0], [0, 5], [1, 6], [3, 6], [4, 5], [4, 0]]], + V: [[[0, 0], [2, 6], [4, 0]]], + W: [[[0, 0], [0.8, 6], [2, 3.2], [3.2, 6], [4, 0]]], + X: [[[0, 0], [4, 6]], [[4, 0], [0, 6]]], + Y: [[[0, 0], [2, 2.8], [4, 0]], [[2, 2.8], [2, 6]]], + Z: [[[0, 0], [4, 0], [0, 6], [4, 6]]], + 0: [[[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]]], + 1: [[[1, 1], [2, 0], [2, 6]], [[1, 6], [3, 6]]], + 2: [[[0, 1], [1, 0], [3, 0], [4, 1], [4, 2.4], [0, 6], [4, 6]]], + 3: [[[0, 0], [4, 0], [2.4, 2.4], [4, 3.4], [4, 5], [3, 6], [1, 6], [0, 5]]], + 4: [[[3, 6], [3, 0], [0, 4], [4, 4]]], + 5: [[[4, 0], [0, 0], [0, 2.6], [3, 2.6], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5]]], + 6: [[[3.5, 0], [1, 0], [0, 1.5], [0, 5], [1, 6], [3, 6], [4, 5], [4, 3.6], [3, 2.6], [0, 2.6]]], + 7: [[[0, 0], [4, 0], [1.5, 6]]], + 8: [[[1, 0], [3, 0], [4, 1], [4, 2], [3, 2.8], [1, 2.8], [0, 2], [0, 1], [1, 0]], [[3, 2.8], [4, 3.6], [4, 5], [3, 6], [1, 6], [0, 5], [0, 3.6], [1, 2.8]]], + 9: [[[0.5, 6], [3, 6], [4, 4.5], [4, 1], [3, 0], [1, 0], [0, 1], [0, 2.4], [1, 3.4], [4, 3.4]]], + '-': [[[0.8, 3], [3.2, 3]]], + '.': [[[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]], + ',': [[[2.2, 5.2], [2.2, 6], [1.6, 7]]], + ':': [[[1.8, 1.4], [2.2, 1.4], [2.2, 2], [1.8, 2], [1.8, 1.4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]], + '!': [[[2, 0], [2, 4]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]], +}; + +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 * alpha); + strokePoly(g, pts); + g.lineStyle(lineWidth, color, alpha); + strokePoly(g, pts); + } + } + x += (GLYPH_W + GLYPH_GAP) * scale; + } +} diff --git a/src/main.js b/src/main.js index 4a9efb6..01d8fff 100644 --- a/src/main.js +++ b/src/main.js @@ -92,6 +92,7 @@ import PeggleEditor from './games/peggle/PeggleEditor.js'; import ColoradoDefenseGame from './games/coloradodefense/ColoradoDefenseGame.js'; import StarControlGame from './games/starcontrol/StarControlGame.js'; import CivilizationGame from './games/civilization/CivilizationGame.js'; +import TempestGame from './games/tempest/TempestGame.js'; const config = { type: Phaser.AUTO, @@ -197,6 +198,7 @@ const config = { ColoradoDefenseGame, StarControlGame, CivilizationGame, + TempestGame, ], }; diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index e57bb3a..fc46557 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene { } create() { - const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame' }; + const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame' }; if (slugDispatch[this.game.slug]) { const sceneKey = slugDispatch[this.game.slug]; const startData = { diff --git a/src/ui/Tooltip.js b/src/ui/Tooltip.js new file mode 100644 index 0000000..10d72cd --- /dev/null +++ b/src/ui/Tooltip.js @@ -0,0 +1,174 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../config.js'; + +const RADIUS = 10; +const PAD_X = 18; +const PAD_Y = 14; +const GAP = 8; +const WIDTH = 340; +const OFF = 22; +const MARGIN = 10; +const ICON_SIZE = 44; +const ICON_GAP = 12; + +export const TOOLTIP_DEPTH = 70; + +// Reusable floating tooltip that follows the live mouse position while any +// attached game object is hovered. One instance is meant to be shared by an +// entire screen/popup (attach many rows to it) rather than created per row — +// see src/games/civilization/CivilizationCityScreen.js for the reference +// usage. If attaching to a Container (e.g. a Button), give it an explicit +// `hitArea`/`hitAreaCallback` first per src/ui/Button.js's pattern — +// this component does not call setInteractive() for you. +export class Tooltip { + constructor(scene, options = {}) { + const { depth = TOOLTIP_DEPTH, hoverDelay = 0 } = options; + this.scene = scene; + this.hoverDelay = hoverDelay; + this._owner = null; + this._timer = null; + + this.bg = scene.add.graphics(); + this.title = scene.add.text(0, 0, '', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }); + this.lineTexts = []; + this.iconObjs = []; + this._iconMask = null; + this.container = scene.add.container(-9999, -9999, [this.bg, this.title]) + .setDepth(depth) + .setVisible(false); + + this._onMove = (ptr) => { + if (this.container.visible) this._reposition(ptr.x, ptr.y); + }; + scene.input.on('pointermove', this._onMove); + + this._shutdownHandler = () => this.destroy(); + scene.events.once('shutdown', this._shutdownHandler); + } + + setContent({ title, titleColor, lines = [], icon = null }) { + this.title.setText(title ?? ''); + this.title.setColor(titleColor ?? COLORS.goldHex); + + this.iconObjs.forEach((o) => o.destroy()); + this._iconMask?.graphics.destroy(); + this._iconMask = null; + this.iconObjs = icon ? this._buildIcon(icon) : []; + this.container.add(this.iconObjs); + const textX = icon ? PAD_X + ICON_SIZE + ICON_GAP : PAD_X; + + this.lineTexts.forEach((t) => t.destroy()); + this.lineTexts = lines.map((l) => this.scene.add.text(0, 0, l.text, { + fontFamily: '"Julius Sans One"', fontSize: '16px', color: l.color ?? COLORS.textHex, + wordWrap: { width: WIDTH - textX - PAD_X }, lineSpacing: 4, + })); + this.container.add(this.lineTexts); + + let y = PAD_Y + (title ? this.title.height + GAP : 0); + this.title.setPosition(textX, PAD_Y); + this.lineTexts.forEach((t) => { + t.setPosition(textX, y); + y += t.height; + }); + const totalH = Math.max(y + PAD_Y, icon ? PAD_Y * 2 + ICON_SIZE : 0); + + this.bg.clear(); + this.bg.fillStyle(COLORS.panel, 0.96); + this.bg.fillRoundedRect(0, 0, WIDTH, totalH, RADIUS); + this.bg.lineStyle(2, COLORS.accent, 1); + this.bg.strokeRoundedRect(0, 0, WIDTH, totalH, RADIUS); + this._w = WIDTH; + this._h = totalH; + } + + // Small circular portrait (sprite frame, or a colored fallback disc with + // an initial letter if the texture isn't loaded) ringed in `icon.color` — + // used to show which leader/civ a hovered thing belongs to. + _buildIcon({ texture, frame, color, label }) { + const colorInt = typeof color === 'string' ? Phaser.Display.Color.HexStringToColor(color).color : color; + const cx = PAD_X + ICON_SIZE / 2; + const cy = PAD_Y + ICON_SIZE / 2; + const ring = this.scene.add.graphics(); + ring.lineStyle(3, colorInt, 1); + ring.strokeCircle(cx, cy, ICON_SIZE / 2 + 3); + if (texture && this.scene.textures.exists(texture)) { + const img = this.scene.add.image(cx, cy, texture, frame).setDisplaySize(ICON_SIZE, ICON_SIZE); + // Crop the (likely square) sprite frame to a circle so it sits inside + // the ring instead of overlapping it. A GeometryMask reads the mask + // graphics' OWN transform each render, not the masked image's — since + // this tooltip's container moves with the mouse, the mask graphics is + // kept OUT of the container (scene.make.graphics(..., add:false), per + // src/ui/Portrait.js's convention) and its position is instead synced + // to the icon's on-screen position every _reposition() call below. + const maskG = this.scene.make.graphics({ x: 0, y: 0, add: false }); + maskG.fillStyle(0xffffff); + maskG.fillCircle(0, 0, ICON_SIZE / 2); + img.setMask(maskG.createGeometryMask()); + this._iconMask = { graphics: maskG, cx, cy }; + return [ring, img]; + } + const disc = this.scene.add.circle(cx, cy, ICON_SIZE / 2, colorInt, 0.3); + const letter = this.scene.add.text(cx, cy, (label ?? '?').charAt(0).toUpperCase(), { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#ffffff', fontStyle: 'bold', + }).setOrigin(0.5); + return [ring, disc, letter]; + } + + _reposition(px, py) { + let tx = px + OFF; + let ty = py + OFF; + if (tx + this._w > GAME_WIDTH - MARGIN) tx = px - this._w - OFF; + if (ty + this._h > GAME_HEIGHT - MARGIN) ty = py - this._h - OFF; + if (tx < MARGIN) tx = MARGIN; + if (ty < MARGIN) ty = MARGIN; + this.container.setPosition(tx, ty); + if (this._iconMask) { + this._iconMask.graphics.setPosition(tx + this._iconMask.cx, ty + this._iconMask.cy); + } + } + + hide() { + this._clearTimer(); + this._owner = null; + this.container.setVisible(false); + } + + _clearTimer() { + if (this._timer) { + this._timer.remove(); + this._timer = null; + } + } + + // Wires pointerover/pointerout on gameObject so hovering it shows this + // shared tooltip with content from contentFn() (called lazily, on hover). + attachTo(gameObject, contentFn, opts = {}) { + const delay = opts.delay ?? this.hoverDelay; + const show = () => { + this.setContent(contentFn()); + this._owner = gameObject; + const ptr = this.scene.input.activePointer; + this._reposition(ptr.x, ptr.y); + this.container.setVisible(true); + }; + gameObject.on('pointerover', () => { + this._clearTimer(); + if (delay > 0) this._timer = this.scene.time.delayedCall(delay, show); + else show(); + }); + gameObject.on('pointerout', () => { + this._clearTimer(); + if (this._owner === gameObject) this.hide(); + }); + } + + destroy() { + this._clearTimer(); + this.scene.input.off('pointermove', this._onMove); + this.scene.events.off('shutdown', this._shutdownHandler); + this._iconMask?.graphics.destroy(); + this.container.destroy(true); + } +} diff --git a/tools/verifyCivilization.js b/tools/verifyCivilization.js index 37b6c77..276ddc2 100644 --- a/tools/verifyCivilization.js +++ b/tools/verifyCivilization.js @@ -1192,7 +1192,12 @@ if (RULES) { return null; } - function runGame(gameIdx, { sizeId, numCivs, difficultyId, seed, turnCap = 600 }) { + // Raised from 600 (2026-07-16) after wiring aiProdBonus into AI shield + // output: Chieftain/Warlord's genuine <1.0 production handicap now slows + // self-play pacing enough that many mixed-difficulty games needed more + // runway to reach any victory within the cap (confirmed via a scratchpad + // diagnostic: 8/30 decided at 600 vs 24/30 at 1000). + function runGame(gameIdx, { sizeId, numCivs, difficultyId, seed, turnCap = 1000 }) { const leaders = LEADER_POOL.slice(0, numCivs); const st = Logic.createGame(RULES, { sizeId, seed, difficultyId, leaders, humanIndex: -1 }); let invariantErr = null; @@ -1232,9 +1237,12 @@ if (RULES) { }); } // Pinned seeds known to end in conquest (deterministic engine), so the - // victory-mix coverage below cannot flake on an all-peaceful draw. - configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'emperor', seed: 7077 }); - configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'king', seed: 8088 }); + // victory-mix coverage below cannot flake on an all-peaceful draw. Re-picked + // 2026-07-16 after wiring aiProdBonus into AI shield output (previously a + // dead field) changed game pacing enough to shift the old 7077/8088 seeds + // off a conquest outcome within turnCap. + configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'emperor', seed: 7017 }); + configs.push({ sizeId: 'small', numCivs: 4, difficultyId: 'king', seed: 8033 }); configs.forEach((cfg, g) => { const out = runGame(g, cfg); games.push({ cfg, ...out }); diff --git a/tools/verifyTempest.js b/tools/verifyTempest.js new file mode 100644 index 0000000..de84266 --- /dev/null +++ b/tools/verifyTempest.js @@ -0,0 +1,580 @@ +// Headless verification for Tempest. +// node tools/verifyTempest.js +// Exits non-zero on any failure. +// +// 1. Web geometry invariants (16 webs, 16 lanes each, star-shaped about pit). +// 2. Projection sanity (t=0 on rim, t=1 converges at the vanishing point). +// 3. Mouse-aim mapping (angle -> lane, unambiguous on every web). +// 4. Player movement (regulated speed, shorter-arc wrap, open-web clamping). +// 5. Firing (cooldown, 8-shot cap, fire state survives level transitions). +// 6. Enemy fixtures (flipper climb/flip/grab, tanker split, spiker/spikes, +// fuseball edges + pause intangibility, pulsar lane pulses). +// 7. Superzapper (full clear worth 0 pts, second use kills exactly one, +// recharges next level). +// 8. Scoring / extra lives / skill-step start bonus. +// 9. Level flow (clear -> warp -> next level; warp spike deaths re-fly). +// 10. Difficulty escalation monotonicity. +// 11. Monte-carlo bot soak (many seeds, invariant checks every step). + +import { + WEBS, LANES, TUNE, BANDS, ENEMY_COLORS, + laneCount, webForLevel, rimPoint, depthScale, makeProjector, aimLaneForAngle, + bandIndexForLevel, startBonus, startLevelOptions, + flipperSpeed, enemyShotSpeed, enemyFireRate, levelBudget, maxConcurrent, + spawnInterval, flipRest, createGame, step, setAim, setFiring, superzap, +} from '../src/games/tempest/TempestLogic.js'; + +let failures = 0; +function check(name, cond, detail = '') { + if (cond) { console.log(` ok ${name}`); return; } + failures += 1; + console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`); +} + +function angleNorm(d) { + let a = d; + while (a > Math.PI) a -= Math.PI * 2; + while (a < -Math.PI) a += Math.PI * 2; + return a; +} + +// Drives one sim frame at 60fps. +const DT = 1000 / 60; + +// ── 1. Web geometry ────────────────────────────────────────────────────────── + +console.log('Web geometry'); +{ + check('exactly 16 webs', WEBS.length === 16); + const names = new Set(WEBS.map((w) => w.name)); + check('web names unique', names.size === 16); + const closedCount = WEBS.filter((w) => w.closed).length; + check('mix of closed and open webs', closedCount >= 9 && closedCount <= 12, `closed=${closedCount}`); + + for (const web of WEBS) { + const n = laneCount(web); + check(`${web.name}: 16 lanes`, n === LANES, `got ${n}`); + check(`${web.name}: vert count matches ${web.closed ? 'closed' : 'open'}`, + web.verts.length === (web.closed ? LANES : LANES + 1)); + + let minLen = Infinity; + const edges = web.closed ? web.verts.length : web.verts.length - 1; + for (let i = 0; i < edges; i += 1) { + const [ax, ay] = web.verts[i]; + const [bx, by] = web.verts[(i + 1) % web.verts.length]; + minLen = Math.min(minLen, Math.hypot(bx - ax, by - ay)); + } + check(`${web.name}: no degenerate lane edges`, minLen > 0.02, `min=${minLen.toFixed(4)}`); + + let maxAbs = 0; + for (const [x, y] of web.verts) maxAbs = Math.max(maxAbs, Math.abs(x), Math.abs(y)); + check(`${web.name}: normalized to unit half-extent`, Math.abs(maxAbs - 1) < 1e-6, `max=${maxAbs}`); + + // Star-shape property: lane bearings from the pit sweep monotonically, + // so the mouse angle picks exactly one lane everywhere. + let inc = true; let dec = true; + for (let i = 1; i < web.laneAngles.length; i += 1) { + const d = angleNorm(web.laneAngles[i] - web.laneAngles[i - 1]); + if (d <= 0) inc = false; + if (d >= 0) dec = false; + } + check(`${web.name}: lane bearings monotonic about pit`, inc || dec); + } + + check('level 1 is the circle', webForLevel(1).name === 'CIRCLE'); + check('level 17 wraps back to the circle', webForLevel(17).name === 'CIRCLE'); + check('level 16 is the oval', webForLevel(16).name === 'OVAL'); +} + +// ── 2. Projection ──────────────────────────────────────────────────────────── + +console.log('Projection'); +{ + check('depthScale(0) is 1', Math.abs(depthScale(0) - 1) < 1e-9); + check('depthScale(1) matches FAR', Math.abs(depthScale(1) - 0.135) < 1e-3); + let monotonic = true; + for (let i = 1; i <= 100; i += 1) { + if (depthScale(i / 100) >= depthScale((i - 1) / 100)) monotonic = false; + } + check('depthScale strictly shrinks with depth', monotonic); + + for (const web of WEBS) { + const { project, vp } = makeProjector(web, 960, 540, 330); + let rimOk = true; let pitOk = true; + for (let lane = 0; lane < laneCount(web); lane += 1) { + const near = project(lane + 0.5, 0); + const [rx, ry] = rimPoint(web, lane + 0.5); + if (Math.hypot(near.x - (960 + rx * 330), near.y - (540 + ry * 330)) > 0.001) rimOk = false; + const far = project(lane + 0.5, 1); + if (Math.hypot(far.x - vp.x, far.y - vp.y) > 330 * 0.15 * 2.2) pitOk = false; + } + check(`${web.name}: t=0 lands on the rim`, rimOk); + check(`${web.name}: t=1 converges near the vanishing point`, pitOk); + } +} + +// ── 3. Mouse aim ───────────────────────────────────────────────────────────── + +console.log('Mouse aim'); +{ + const circle = WEBS[0]; + check('circle: aiming straight down picks the bottom lane', + Math.abs(angleNorm(circle.laneAngles[aimLaneForAngle(circle, Math.PI / 2)] - Math.PI / 2)) < 0.25); + check('circle: aiming right picks the right lane', + Math.abs(angleNorm(circle.laneAngles[aimLaneForAngle(circle, 0)])) < 0.25); + + // Feeding back each lane's own bearing must return that same lane. + for (const web of WEBS) { + let stable = true; + for (let lane = 0; lane < web.laneAngles.length; lane += 1) { + if (aimLaneForAngle(web, web.laneAngles[lane]) !== lane) stable = false; + } + check(`${web.name}: every lane reachable by its own bearing`, stable); + } +} + +// ── 4. Player movement ─────────────────────────────────────────────────────── + +console.log('Player movement'); +{ + const sim = createGame({ seed: 7, startLevel: 1 }); // circle, closed + const n = laneCount(sim.web); + const start = sim.player.pos; + sim.player.targetLane = (Math.round(start) + 3) % n; + const before = sim.player.pos; + step(sim, DT); + const moved = Math.abs(angleNorm(((sim.player.pos - before) / n) * Math.PI * 2)) * (n / (Math.PI * 2)); + check('movement per frame respects the speed cap', + moved <= TUNE.MOVE_LANES_PER_SEC * (DT / 1000) + 1e-6, `moved=${moved}`); + + // Wraparound: target 2 lanes "behind" should go the short way (negative). + sim.player.pos = 1; sim.player.targetLane = n - 1; + step(sim, DT); + check('closed web takes the shorter arc through the wrap', + sim.player.pos > 1 - 1 || sim.player.pos > n - 3, `pos=${sim.player.pos}`); + let posWrapped = sim.player.pos; + check('closed web position stays within [0, N)', posWrapped >= 0 && posWrapped < n); + + // Open web: clamps at the ends, never wraps. + const open = createGame({ seed: 7, startLevel: 8 }); // VEE + check('level 8 fixture is an open web', !open.web.closed); + open.player.pos = 0; open.player.targetLane = 0; + setAim(open, open.web.laneAngles[laneCount(open.web) - 1]); + for (let i = 0; i < 600; i += 1) step(open, DT); + check('open web reaches the far end', Math.round(open.player.pos) === laneCount(open.web) - 1, + `pos=${open.player.pos}`); + let inBounds = true; + setAim(open, open.web.laneAngles[0]); + for (let i = 0; i < 600; i += 1) { + step(open, DT); + if (open.player.pos < 0 || open.player.pos > laneCount(open.web) - 1) inBounds = false; + } + check('open web clamps at the ends', inBounds && Math.round(open.player.pos) === 0); +} + +// ── 5. Firing ──────────────────────────────────────────────────────────────── + +console.log('Firing'); +{ + const sim = createGame({ seed: 11 }); + sim.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + sim.enemies.push(sim.makeEnemy('spiker', 5, 0.9)); // keep the level from clearing + setFiring(sim, true); + let fired = 0; + for (let i = 0; i < 60; i += 1) { + for (const e of step(sim, DT)) if (e.type === 'shotFired') fired += 1; + } + const expected = Math.floor(1000 / TUNE.FIRE_COOLDOWN_MS) + 1; + check('fire rate honors the cooldown', Math.abs(fired - expected) <= 1, `fired=${fired}`); + check('live shots never exceed the arcade cap', sim.shots.length <= TUNE.MAX_SHOTS); + + // Held fire must survive a level transition (regression: enterLevel reset it). + const sim2 = createGame({ seed: 12 }); + setFiring(sim2, true); + sim2.enterLevel(2); + check('firing state survives enterLevel', sim2.player.firing === true); +} + +// ── 6. Enemy fixtures ──────────────────────────────────────────────────────── + +console.log('Enemy fixtures'); +{ + // Flipper: climbs, rests at the rim, hops toward the player, grabs on contact. + const sim = createGame({ seed: 21 }); + sim.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const playerLane = sim.playerLane(); + const flipLane = (playerLane + 3) % LANES; + const flipper = sim.makeEnemy('flipper', flipLane, 0.4); + sim.enemies.push(flipper); + setAim(sim, sim.web.laneAngles[playerLane]); + let grabbed = false; + for (let i = 0; i < 60 * 30 && !grabbed; i += 1) { + for (const e of step(sim, DT)) if (e.type === 'playerHit' && e.cause === 'grabbed') grabbed = true; + if (sim.phase === 'death') break; + } + check('flipper climbs, hunts, and grabs a stationary player', grabbed); + + // Shooting a flipper as it climbs. + const sim2 = createGame({ seed: 22 }); + sim2.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const lane2 = sim2.playerLane(); + sim2.enemies.push(sim2.makeEnemy('flipper', lane2, 0.8)); + sim2.enemies.push(sim2.makeEnemy('spiker', (lane2 + 8) % LANES, 0.95)); // hold level open + setFiring(sim2, true); + let killedPts = 0; + for (let i = 0; i < 60 * 5 && !killedPts; i += 1) { + for (const e of step(sim2, DT)) if (e.type === 'enemyKilled' && e.enemyType === 'flipper') killedPts = e.points; + } + check('shot flipper dies for 150', killedPts === TUNE.POINTS.flipper, `got ${killedPts}`); + + // Tanker: releases two flippers when shot. + const sim3 = createGame({ seed: 23 }); + sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const lane3 = sim3.playerLane(); + sim3.enemies.push(sim3.makeEnemy('tanker', lane3, 0.7)); + setFiring(sim3, true); + let tankerPts = 0; + for (let i = 0; i < 60 * 5 && !tankerPts; i += 1) { + for (const e of step(sim3, DT)) if (e.type === 'enemyKilled' && e.enemyType === 'tanker') tankerPts = e.points; + } + const flippersOut = sim3.enemies.filter((e) => e.type === 'flipper').length; + check('shot tanker dies for 100', tankerPts === TUNE.POINTS.tanker, `got ${tankerPts}`); + check('shot tanker releases two flippers', flippersOut === 2, `got ${flippersOut}`); + + // Tanker reaching the rim also splits (no points). + const sim4 = createGame({ seed: 24 }); + sim4.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const away = (sim4.playerLane() + 8) % LANES; + sim4.enemies.push(sim4.makeEnemy('tanker', away, 0.2)); + let rimSplit = false; + for (let i = 0; i < 60 * 6 && !rimSplit; i += 1) { + for (const e of step(sim4, DT)) if (e.type === 'enemyReachedRim' && e.enemyType === 'tanker') rimSplit = true; + if (sim4.phase !== 'playing') break; + } + check('tanker reaching the rim pops open', rimSplit); + check('rim split produced flippers', sim4.enemies.some((e) => e.type === 'flipper')); + + // Spiker: builds a spike, capped near the rim; shots trim it. + const sim5 = createGame({ seed: 25 }); + sim5.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const sLane = sim5.playerLane(); + sim5.enemies.push(sim5.makeEnemy('spiker', sLane, 0.9)); + for (let i = 0; i < 60 * 6; i += 1) { step(sim5, DT); if (sim5.phase !== 'playing') break; } + check('spiker grows a spike in its lane', sim5.spikes[sLane] < 1, `tip=${sim5.spikes[sLane]}`); + check('spike growth capped away from the rim', sim5.spikes[sLane] >= TUNE.SPIKE_MIN_T - 1e-9, + `tip=${sim5.spikes[sLane]}`); + const tipBefore = sim5.spikes[sLane]; + // Kill the spiker with the zapper so only the spike remains, then shoot it. + superzap(sim5); + for (let i = 0; i < 30; i += 1) step(sim5, DT); + setFiring(sim5, true); + let trimmed = false; + for (let i = 0; i < 60 * 2 && !trimmed; i += 1) { + for (const e of step(sim5, DT)) if (e.type === 'spikeTrimmed') trimmed = true; + if (sim5.phase !== 'playing') break; + } + check('shots trim the spike', trimmed && sim5.spikes[sLane] > tipBefore, + `before=${tipBefore} after=${sim5.spikes[sLane]}`); + + // Fuseball: rides edges, intangible while paused, lethal at the rim edge. + const sim6 = createGame({ seed: 26 }); + sim6.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const fLane = sim6.playerLane(); + const fuse = sim6.makeEnemy('fuseball', fLane, 0.5); + fuse.state = 'pause'; fuse.phaseMs = 1e9; // frozen mid-web, sparking + sim6.enemies.push(fuse); + sim6.enemies.push(sim6.makeEnemy('spiker', (fLane + 8) % LANES, 0.95)); + setFiring(sim6, true); + let fuseKilled = false; + for (let i = 0; i < 90; i += 1) { + for (const e of step(sim6, DT)) if (e.type === 'enemyKilled' && e.enemyType === 'fuseball') fuseKilled = true; + } + check('paused fuseball is intangible to shots', !fuseKilled); + fuse.state = 'move'; fuse.dir = -1; fuse.phaseMs = 1e9; + for (let i = 0; i < 60 * 4 && !fuseKilled; i += 1) { + for (const e of step(sim6, DT)) { + if (e.type === 'enemyKilled' && e.enemyType === 'fuseball') { + fuseKilled = true; + check('fuseball kill pays a proximity bonus', TUNE.FUSEBALL_POINTS.includes(e.points)); + } + } + if (sim6.phase !== 'playing') break; + } + check('moving fuseball can be shot', fuseKilled); + + // Fuseball contact at the rim kills the player. + const sim7 = createGame({ seed: 27 }); + sim7.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const contact = sim7.makeEnemy('fuseball', sim7.playerLane(), 0.08); + contact.state = 'move'; contact.dir = -1; contact.phaseMs = 1e9; + sim7.enemies.push(contact); + let fuseDeath = false; + for (let i = 0; i < 60 * 3 && !fuseDeath; i += 1) { + for (const e of step(sim7, DT)) if (e.type === 'playerHit' && e.cause === 'fuseball') fuseDeath = true; + if (sim7.phase !== 'playing') break; + } + check('fuseball at the rim edge kills the player', fuseDeath); + + // Pulsar: pulses on the shared clock; lethal only near the rim in-lane. + const sim8 = createGame({ seed: 28, startLevel: 17 }); + sim8.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const pLane = sim8.playerLane(); + const pulsar = sim8.makeEnemy('pulsar', pLane, TUNE.PULSAR_LETHAL_T - 0.02); + sim8.enemies.push(pulsar); + let pulsarDeath = false; + for (let i = 0; i < 60 * 8 && !pulsarDeath; i += 1) { + pulsar.t = TUNE.PULSAR_LETHAL_T - 0.02; pulsar.dir = 1; // pin it near the rim + for (const e of step(sim8, DT)) if (e.type === 'playerHit' && e.cause === 'pulsar') pulsarDeath = true; + if (sim8.phase !== 'playing') break; + } + check('pulsing pulsar near the rim kills in-lane player', pulsarDeath); + + const sim9 = createGame({ seed: 29, startLevel: 17 }); + sim9.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + const deep = sim9.makeEnemy('pulsar', sim9.playerLane(), 0.6); + sim9.enemies.push(deep); + let deepDeath = false; + for (let i = 0; i < 60 * 8; i += 1) { + deep.t = 0.6; deep.dir = 1; // pin it deep in the well + for (const e of step(sim9, DT)) if (e.type === 'playerHit') deepDeath = true; + if (sim9.phase !== 'playing') break; + } + check('pulsar deep in the well is harmless', !deepDeath); +} + +// ── 7. Superzapper ─────────────────────────────────────────────────────────── + +console.log('Superzapper'); +{ + const sim = createGame({ seed: 31 }); + // Keep one enemy in the budget (with spawns frozen) so clearing the web + // with the zapper doesn't end the level mid-fixture. + sim.spawn.remaining = { flipper: 1, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + sim.spawn.timerMs = 1e9; + for (let i = 0; i < 5; i += 1) sim.enemies.push(sim.makeEnemy('flipper', i * 3 % LANES, 0.5 + i * 0.08)); + sim.enemies.push(sim.makeEnemy('spiker', 1, 0.9)); + const scoreBefore = sim.score; + superzap(sim); + let zapKills = 0; + for (let i = 0; i < 60 * 2; i += 1) { + for (const e of step(sim, DT)) if (e.type === 'enemyKilled' && e.zap) zapKills += 1; + if (sim.phase !== 'playing') break; + } + check('full zap clears every enemy on the web', zapKills === 6, `kills=${zapKills}`); + check('zap kills score nothing', sim.score === scoreBefore, `delta=${sim.score - scoreBefore}`); + check('one charge consumed', sim.zapper.uses === 1); + + sim.enemies.push(sim.makeEnemy('flipper', 2, 0.5)); + sim.enemies.push(sim.makeEnemy('flipper', 9, 0.5)); + superzap(sim); + step(sim, DT); + check('second zap kills exactly one enemy', sim.enemies.length === 1, `left=${sim.enemies.length}`); + check('zapper now empty', sim.zapper.uses === 0); + superzap(sim); + step(sim, DT); + check('empty zapper does nothing', sim.enemies.length === 1); + + sim.enterLevel(sim.level + 1); + check('zapper recharges on a new level', sim.zapper.uses === TUNE.ZAPPER_USES); +} + +// ── 8. Scoring, extra lives, skill step ────────────────────────────────────── + +console.log('Scoring and skill step'); +{ + const sim = createGame({ seed: 41 }); + sim.addScore(TUNE.EXTRA_LIFE_EVERY - 50); + check('no early extra life', sim.lives === TUNE.LIVES); + let extra = false; + sim.events = []; + sim.addScore(100); + extra = sim.events.some((e) => e.type === 'extraLife'); + check('extra life at 20k', extra && sim.lives === TUNE.LIVES + 1); + sim.addScore(TUNE.EXTRA_LIFE_EVERY); + check('another at 40k', sim.lives === TUNE.LIVES + 2); + + check('start bonus is zero at level 1', startBonus(1) === 0); + let bonusMono = true; + for (let l = 2; l <= 40; l += 1) if (startBonus(l) <= startBonus(l - 1)) bonusMono = false; + check('start bonus grows with depth', bonusMono); + const deep = createGame({ seed: 42, startLevel: 9 }); + check('deep start seeds the bonus as score', deep.score === startBonus(9)); + check('deep-start bonus counts toward the next extra life', + deep.nextExtraLifeAt > startBonus(9)); + + check('skill-step options start at level 1', startLevelOptions(1).join(',') === '1'); + const opts = startLevelOptions(20); + check('skill-step options are odd steps then wider steps', + opts[0] === 1 && opts.includes(15) && opts.every((v, i) => i === 0 || v > opts[i - 1])); + check('options never exceed the reached level', opts.every((v) => v <= 20)); +} + +// ── 9. Level flow: clear, warp, spike death ────────────────────────────────── + +console.log('Level flow'); +{ + const sim = createGame({ seed: 51 }); + sim.spawn.remaining = { flipper: 1, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + setFiring(sim, true); + let clearedAt = null; let warped = null; + for (let i = 0; i < 60 * 60 && warped == null; i += 1) { + // chase the last flipper + const target = sim.enemies[0]; + if (target) setAim(sim, sim.web.laneAngles[sim.visualLane(target)]); + for (const e of step(sim, DT)) { + if (e.type === 'levelCleared') clearedAt = e.level; + if (e.type === 'warpDone') warped = e.level; + } + } + check('killing the whole budget clears the level', clearedAt === 1); + check('warp lands on the next level', warped === 2 && sim.level === 2); + check('new level rebuilds the web', sim.web === webForLevel(2)); + + // Warp spike death: costs a life and re-flies the same warp. + const sim2 = createGame({ seed: 52 }); + sim2.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + sim2.enemies = []; + const lane = sim2.playerLane(); + sim2.spikes.fill(0.5); // every lane spiked: no dodging + sim2.maybeClearLevel(); + check('empty web enters the warp', sim2.phase === 'warp'); + const livesBefore = sim2.lives; + let spiked = false; let reflew = false; let advanced = false; + for (let i = 0; i < 60 * 20; i += 1) { + for (const e of step(sim2, DT)) { + if (e.type === 'spikeHit') spiked = true; + if (e.type === 'warpStart' && e.refly) reflew = true; + if (e.type === 'warpDone') advanced = true; + } + if (spiked && sim2.phase === 'warp' && reflew) { + // dodging is impossible (all lanes spiked) — shoot the spike away instead + setFiring(sim2, true); + } + if (advanced || sim2.phase === 'gameover') break; + } + check('flying into a spike costs a life', spiked && sim2.lives < livesBefore); + check('spike death re-flies the warp', reflew); + + // Death and respite: survivors rejoin the spawn budget. + const sim3 = createGame({ seed: 53 }); + sim3.spawn.remaining = { flipper: 0, tanker: 0, spiker: 0, fuseball: 0, pulsar: 0 }; + sim3.enemies.push(sim3.makeEnemy('flipper', (sim3.playerLane() + 5) % LANES, 0.5)); + sim3.enemies.push(sim3.makeEnemy('spiker', (sim3.playerLane() + 8) % LANES, 0.9)); + sim3.killPlayer('shot'); + check('death costs a life', sim3.lives === TUNE.LIVES - 1); + let respited = false; + for (let i = 0; i < 60 * 5; i += 1) { + for (const e of step(sim3, DT)) if (e.type === 'respiteStart') respited = true; + if (sim3.phase === 'playing') break; + } + check('respite follows a death with lives left', respited && sim3.phase === 'playing'); + check('survivors rejoined the spawn budget', + sim3.spawn.remaining.flipper === 1 && sim3.spawn.remaining.spiker === 1 && sim3.enemies.length === 0); + + // Game over at zero lives. + const sim4 = createGame({ seed: 54 }); + sim4.lives = 1; + sim4.killPlayer('shot'); + let over = false; + for (let i = 0; i < 60 * 5 && !over; i += 1) { + for (const e of step(sim4, DT)) if (e.type === 'gameOver') over = true; + } + check('last life ends the game', over && sim4.phase === 'gameover'); +} + +// ── 10. Difficulty escalation ──────────────────────────────────────────────── + +console.log('Difficulty escalation (levels 1-60)'); +{ + let ok = true; + for (let l = 2; l <= 60; l += 1) { + if (flipperSpeed(l) < flipperSpeed(l - 1)) ok = false; + if (enemyShotSpeed(l) < enemyShotSpeed(l - 1)) ok = false; + if (enemyFireRate(l) < enemyFireRate(l - 1)) ok = false; + if (levelBudget(l) < levelBudget(l - 1)) ok = false; + if (maxConcurrent(l) < maxConcurrent(l - 1)) ok = false; + if (spawnInterval(l) > spawnInterval(l - 1)) ok = false; + if (flipRest(l) > flipRest(l - 1)) ok = false; + } + check('all difficulty curves monotonic', ok); + check('curves stay bounded', + flipperSpeed(999) <= TUNE.FLIPPER_SPEED_MAX + && levelBudget(999) <= TUNE.BUDGET_MAX + && spawnInterval(999) >= TUNE.SPAWN_MS_MIN + && flipRest(999) >= TUNE.FLIP_REST_MIN_MS); + check('unlock order matches the arcade schedule', + TUNE.UNLOCK.flipper < TUNE.UNLOCK.tanker + && TUNE.UNLOCK.tanker < TUNE.UNLOCK.spiker + && TUNE.UNLOCK.spiker < TUNE.UNLOCK.fuseball + && TUNE.UNLOCK.fuseball < TUNE.UNLOCK.pulsar); + check('five color bands defined', BANDS.length === 5 + && BANDS.every((b) => b.web != null && b.lane != null && b.player != null && b.flipper != null)); + check('band cycles every 16 levels', + bandIndexForLevel(1) === 0 && bandIndexForLevel(16) === 0 + && bandIndexForLevel(17) === 1 && bandIndexForLevel(81) === 0); + check('canonical enemy colors defined', + ENEMY_COLORS.tanker != null && ENEMY_COLORS.spiker != null + && ENEMY_COLORS.fuseball != null && ENEMY_COLORS.pulsar != null); +} + +// ── 11. Monte-carlo bot soak ───────────────────────────────────────────────── + +console.log('Monte-carlo bot soak (40 games)'); +{ + let cleared = 0; let deaths = 0; let maxLevel = 0; let bad = 0; let overs = 0; + const started = Date.now(); + for (let g = 0; g < 40; g += 1) { + const sim = createGame({ seed: 9000 + g, startLevel: 1 + (g % 4) * 4 }); + setFiring(sim, true); + let steps = 0; + const stepCap = 60 * 60 * 6; // six simulated minutes per game + while (sim.phase !== 'gameover' && steps < stepCap) { + steps += 1; + let target = null; + for (const e of sim.enemies) if (!target || e.t < target.t) target = e; + if (target) { + const lane = target.type === 'fuseball' ? target.edge : sim.visualLane(target); + const idx = Math.min(lane, sim.web.laneAngles.length - 1); + if (target.t < 0.05 && target.type === 'flipper') { + // sidestep rim flippers instead of walking into them + setAim(sim, sim.web.laneAngles[(idx + 4) % sim.web.laneAngles.length]); + } else { + setAim(sim, sim.web.laneAngles[idx]); + } + } + if (sim.enemies.length >= 6 && sim.zapper.uses === TUNE.ZAPPER_USES) superzap(sim); + for (const e of step(sim, DT)) { + if (e.type === 'levelCleared') cleared += 1; + if (e.type === 'playerHit') deaths += 1; + if (e.type === 'gameOver') overs += 1; + } + // Per-step invariants. + for (const e of sim.enemies) { + if (!(e.t >= -0.02 && e.t <= 1.02)) bad += 1; + if (e.type === 'fuseball') { + if (!(e.edge >= 0 && e.edge < sim.edgeCount())) bad += 1; + } else if (!(e.lane >= 0 && e.lane < laneCount(sim.web))) bad += 1; + } + for (const s of sim.shots) if (!(s.t >= -0.02 && s.t <= 1.02)) bad += 1; + for (const s of sim.enemyShots) if (!(s.t >= -0.05 && s.t <= 1.02)) bad += 1; + const n = laneCount(sim.web); + if (sim.web.closed ? (sim.player.pos < 0 || sim.player.pos >= n) + : (sim.player.pos < 0 || sim.player.pos > n - 1)) bad += 1; + for (const tip of sim.spikes) if (!(tip >= TUNE.SPIKE_MIN_T - 1e-6 && tip <= 1)) bad += 1; + if (sim.shots.length > TUNE.MAX_SHOTS) bad += 1; + } + maxLevel = Math.max(maxLevel, sim.level); + } + check('no invariant violations across the soak', bad === 0, `violations=${bad}`); + check('bot makes real progress', cleared >= 40 && maxLevel >= 5, `cleared=${cleared} maxLevel=${maxLevel}`); + check('deaths occur (the game bites back)', deaths > 0, `deaths=${deaths}`); + console.log(` info soak: cleared=${cleared} deaths=${deaths} gameovers=${overs} maxLevel=${maxLevel} in ${((Date.now() - started) / 1000).toFixed(1)}s`); +} + +// ── Result ─────────────────────────────────────────────────────────────────── + +if (failures) { + console.error(`\n${failures} FAILURE${failures === 1 ? '' : 'S'}`); + process.exit(1); +} +console.log('\nAll checks passed.');