feat: add Tempest arcade game and Civilization tooltips
- Add Tempest (Atari 1981 vector arcade clone) with full simulation logic, Phaser rendering scene, vector font, and headless verification suite - Register Tempest in games registry, main scene list, and game room dispatch - Add reusable Tooltip UI component for hover-based information display - Add Civilization unit/building tooltips in city screen and map hover (CivilizationTooltips.js with flag and effect descriptions) - Update Civilization click handling to distinguish hover-only from real click targets via hoverOnly data flag - Update civilization-units and game-icons assets
This commit is contained in:
parent
2a08df9cd8
commit
0f2fc829c8
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 438 KiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 319 KiB |
Binary file not shown.
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(); }
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
Loading…
Reference in New Issue