Compare commits

..

No commits in common. "main" and "Master-of-Vega-Tutorial" have entirely different histories.

50 changed files with 58 additions and 257888 deletions

View File

@ -1,5 +0,0 @@
# Agent notes
## Git
- **The user makes their own commits.** Leave changes in the working tree; do not
run `git commit` (or `git push`) unless explicitly asked.

View File

@ -88,7 +88,7 @@ menu categories:
| **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi |
| **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle |
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble |
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble |
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |

View File

@ -1,83 +0,0 @@
<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
</head><body>
<div id="log"></div>
<script type="module">
import * as Phaser from 'phaser';
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
const log = (m) => { document.getElementById('log').textContent += m + '\n'; console.log('[t]', m); };
let failures = 0;
const check = (ok, msg) => { if (!ok) { failures++; log('FAIL: ' + msg); } else log('ok: ' + msg); };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const ART = [
{ name: 'Alien World', path: 'assets/images/shift/alien-world.png' },
{ name: 'Aquaroom', path: 'assets/images/shift/aquaroom.png' },
{ name: 'Aztec Warrior', path: 'assets/images/shift/aztec-warrior.png' },
{ name: 'Cat On Tiger', path: 'assets/images/shift/cat-on-tiger.png' },
{ name: 'Cockpit', path: 'assets/images/shift/cockpit.png' },
];
const config = {
type: Phaser.AUTO,
width: 1920, height: 1080,
parent: document.body,
backgroundColor: '#000',
scene: [ { key: 'boot', create() {
this.cache.json.add('shift-artwork', { artwork: ART });
this.cache.json.add('music', { tracks: [] });
this.scene.start('jigsaw-game');
} }, JigsawGame ],
};
const game = new Phaser.Game(config);
const s = () => game.scene.getScene('jigsaw-game');
(async () => {
for (let i = 0; i < 400 && !s(); i++) await wait(50); // boot → jigsaw start is async
if (!s()) throw new Error('jigsaw scene never started');
for (let i = 0; i < 400 && !s().menu; i++) await wait(50);
check(!!s().menu, 'menu built');
// Menu is back to the original shape (random-start button reverted).
check(s().randomStartButton === undefined, 'randomStartButton is gone (menu as before)');
// The initial image is random but always a valid artwork entry, and the
// preview shown in the menu matches it.
const n = s().artwork.length;
const i = s().imageIndex;
check(Number.isInteger(i) && i >= 0 && i < n, `initial imageIndex ${i} is a valid index (0..${n - 1})`);
check(!!ART.find((a) => a.name === s().currentImage().name), 'initial currentImage() is a known artwork entry');
// Preview must be built for the initial image (async image load).
for (let k = 0; k < 200 && !s().previewImg; k++) await wait(25);
check(!!s().previewImg, 'initial preview image is displayed');
check(s().thumbName && s().thumbName.text === s().currentImage().name, `thumb name matches initial image (${s().currentImage().name})`);
// Start Puzzle must still start the initial (random) image.
s().selectedDiff = 'easy';
s().startPuzzle();
let playing = false;
for (let k = 0; k < 400 && !playing; k++) { playing = s().state === 'playing'; await wait(25); }
check(playing, 'Start Puzzle reaches playing state');
check(s().pieces.length === 25, '25 pieces built');
check(s().imageName === ART[i].name, `playing image matches the initial pick (${ART[i].name})`);
// The original 🎲 Random button still randomises the preview in the menu.
s().toMenu();
const before = s().imageIndex;
const seen = new Set([before]);
for (let k = 0; k < 10; k++) { s().randomImage(); seen.add(s().imageIndex); await wait(10); }
check(seen.size >= 2, '🎲 Random button still changes the selected image');
document.__initIndex = i;
log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
document.title = failures === 0 ? 'PASS' : 'FAIL:' + failures;
})().catch((e) => {
log('ERROR: ' + ((e && e.stack) || e));
document.title = 'FAIL:error';
});
</script>
</body></html>

View File

@ -1,62 +0,0 @@
<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
</head><body>
<div id="log"></div>
<script type="module">
import * as Phaser from 'phaser';
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
const log = (m) => { const el = document.getElementById('log'); el.textContent += m + '\n'; console.log('[harness]', m); };
const config = {
type: Phaser.WEBGL,
width: 1920, height: 1080,
parent: document.body,
backgroundColor: '#000',
scene: [ { key:'boot', create(){
// Provide the artwork cache so loadArtwork() has a real list.
this.cache.json.add('shift-artwork', { artwork: [ { name:'Alien World', path:'assets/images/shift/alien-world.png' } ] });
this.cache.json.add('music', { tracks: [] });
this.scene.start('jigsaw-game');
} }, JigsawGame ],
};
const game = new Phaser.Game(config);
window.__game = game;
// Helpers exposed for the driver.
window.__log = log;
window.__scene = () => game.scene.getScene('jigsaw-game');
window.__worldToScreen = (wx, wy) => {
const cam = window.__scene().cameras.main;
return { x: (wx - cam.scrollX) * cam.zoom + cam.x, y: (wy - cam.scrollY) * cam.zoom + cam.y };
};
window.__pieceAt = (i) => { const s = window.__scene(); const p = s.pieces[i]; return { i, x:p.img.x, y:p.img.y, placed:p.placed, groupLeader: p.group===p, depth:p.img.depth, hasInput: !!(p.img.input&&p.img.input.enabled) }; };
window.__pieces = () => window.__scene().pieces.map((p,i)=>({i, x:Math.round(p.img.x), y:Math.round(p.img.y), placed:p.placed, depth:p.img.depth}));
window.__startPuzzle = () => { const s = window.__scene(); s.selectedDiff='easy'; s.startPuzzle(); };
window.__grabAndDrop = async (pieceIdx, toWorld, steps=12, holdMs=40) => {
const s = window.__scene();
const p = s.pieces[pieceIdx];
const from = { x: p.img.x, y: p.img.y };
// mousedown at from
const f2s = window.__worldToScreen(from.x, from.y);
await __mouseDown(f2s.x, f2s.y);
await new Promise(r=>setTimeout(r,holdMs));
for (let k=1;k<=steps;k++){
const x = from.x + (toWorld.x-from.x)*k/steps, y = from.y + (toWorld.y-from.y)*k/steps;
const c = window.__worldToScreen(x,y);
await __mouseMove(c.x,c.y);
await new Promise(r=>setTimeout(r,8));
}
await __mouseUp();
return { from, to:{x:p.img.x,y:p.img.y}, moved: Math.hypot(p.img.x-from.x,p.img.y-from.y) };
};
// Low-level mouse dispatch in canvas (client) coordinates.
window.__mouseDown = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousedown',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
window.__mouseMove = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousemove',{clientX:cx,clientY:cy,bubbles:true})); };
window.__mouseUp = (cx=0,cy=0) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mouseup',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
log('harness ready');
</script>
</body></html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,18 +1,5 @@
{
"playerBaseHp": 100,
"steer": {
"wordBoostK": 1.0,
"classThreshold": 2,
"classDamp": 0.35,
"classBoost": 1.8,
"vowelBand": [0.30, 0.45]
},
"specialTiles": {
"startLevel": 1,
"endLevel": 10,
"start": { "gold": 0.10, "diamond": 0.06 },
"end": { "gold": 0.03, "diamond": 0.02 }
},
"milestones": [
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
{ "afterLevel": 10, "maxHpBonus": 10 },

View File

@ -1,13 +1,13 @@
{
"default": "back-0",
"cardBacks": [
{ "id": "back-0", "name": "Cthulhu", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
{ "id": "back-1", "name": "Cyberpunk", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
{ "id": "back-2", "name": "Nautical", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
{ "id": "back-3", "name": "Steampunk", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
{ "id": "back-4", "name": "Jungle", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
{ "id": "back-5", "name": "Hi Tech", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
{ "id": "back-6", "name": "Fall Mountain", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
{ "id": "back-7", "name": "Neon Miami", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
{ "id": "back-0", "name": "Card Back 1", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
{ "id": "back-1", "name": "Card Back 2", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
{ "id": "back-2", "name": "Card Back 3", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
{ "id": "back-3", "name": "Card Back 4", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
{ "id": "back-4", "name": "Card Back 5", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
{ "id": "back-5", "name": "Card Back 6", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
{ "id": "back-6", "name": "Card Back 7", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
{ "id": "back-7", "name": "Card Back 8", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
]
}

62
package-lock.json generated
View File

@ -1,62 +0,0 @@
{
"name": "fertig-classic-games",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fertig-classic-games",
"version": "0.1.0",
"devDependencies": {
"playwright": "^1.62.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"dependencies": {
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

View File

@ -9,8 +9,5 @@
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"playwright": "^1.62.1"
}
}

248303
phaser.esm.js

File diff suppressed because it is too large Load Diff

View File

@ -256,9 +256,6 @@ export const MANIFEST = {
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
// Still gameplay background for Spelling Bee — the intro clip is swapped for
// this once a puzzle starts (SpellingBeeGame.showImageBackground).
spellingbee: [image('spellingbee-bg', 'assets/images/background-spellingbee.png')],
rushhour: [
// Carries its own title, so the level-select screen draws no heading.
// In-play art is procedural (see games/rushhour/RushHourArt.js).
@ -483,17 +480,6 @@ export const MANIFEST = {
// so its audio only downloads once Tempest is actually entered.
(scene) => musicFrom(scene, 'arcadedark-music'),
],
defender: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Defender is actually entered.
(scene) => musicFrom(scene, 'arcadedark-music'),
],
geometrywars: [
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
// so its audio only downloads once Geometry Wars is actually entered.
// Everything else in the game is drawn procedurally; no image assets.
(scene) => musicFrom(scene, 'arcadedark-music'),
],
mastermind: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mastermind is actually entered.

View File

@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame:
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' });
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });
@ -123,6 +123,3 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
registerGame({ slug: 'defender', name: 'Defender', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 97 });
registerGame({ slug: 'geometrywars', name: 'Geometry Wars', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 98 });

View File

@ -9,10 +9,9 @@ import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, specialTileChances,
computeMaxHp, isPotionUnlocked,
} from './BookworkLogic.js';
import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
const CELL = 96;
const GRID_W = CELL * GRID_SIZE;
@ -40,7 +39,7 @@ export default class BookworkGame extends Phaser.Scene {
constructor() { super('BookworkGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' };
this.config = { playerBaseHp: 100, milestones: [], levels: [] };
this.bank = [];
this.roster = [];
@ -57,9 +56,6 @@ export default class BookworkGame extends Phaser.Scene {
this.bgTex = null;
// Battle state
this.grid = null;
this.wordSet = null; // steering dictionary (ENABLE words 3–15); null → unsteered
this.steerOpts = {};
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
this.tileObjs = null;
this.selection = [];
this.selGraphics = null;
@ -89,16 +85,8 @@ export default class BookworkGame extends Phaser.Scene {
const raw = this.cache.json.get('bookwork');
if (raw) this.config = raw;
this.steerOpts = this.config.steer ?? {};
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
// Load the steering dictionary (same ENABLE list the word validator uses).
// Non-fatal: if it fails, boards fall back to plain weighted-random.
try {
const res = await fetch('data/wordlists/enable1.txt');
if (res.ok) this.wordSet = parseWordList(await res.text());
} catch (_) { this.wordSet = null; }
try {
const res = await fetch('data/opponents.json');
const json = await res.json();
@ -291,7 +279,7 @@ export default class BookworkGame extends Phaser.Scene {
this.clearLayer();
const cx = GAME_WIDTH / 2;
const title = this.add.text(cx, 84, 'BOOKWORM', {
const title = this.add.text(cx, 84, 'BOOKWORK', {
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
@ -526,7 +514,6 @@ export default class BookworkGame extends Phaser.Scene {
this.level = level;
this.levelDef = lv;
this.opponent = this.opponentFor(lv);
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
this.clearLayer();
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
@ -536,7 +523,7 @@ export default class BookworkGame extends Phaser.Scene {
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
this.potionUsed = false;
this.turnPhase = 'player';
this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
this.grid = makeGrid();
this.selection = [];
this.drawBattleUI();
@ -845,9 +832,7 @@ export default class BookworkGame extends Phaser.Scene {
}
// Refill grid
this.grid = this.wordSet
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
this.grid = clearAndRefill(this.grid, cells);
await this.animateRefill(cells);
this.redrawAllTiles();

View File

@ -1,7 +1,7 @@
export const GRID_SIZE = 5;
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
export const LETTER_WEIGHTS = {
const LETTER_WEIGHTS = {
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
X:1, Y:3, Z:1,
@ -16,29 +16,6 @@ export function randomLetter(rng = Math.random) {
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
}
// Default spawn chances for multiplier tiles on refills (late-game floor).
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
// holds cfg.end. Returns { goldChance, diamondChance }.
// cfg = { startLevel: 1, endLevel: 10,
// start: { gold: 0.10, diamond: 0.06 },
// end: { gold: 0.03, diamond: 0.02 } }
export function specialTileChances(level, cfg = {}) {
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
const from = cfg.startLevel ?? 1;
const to = cfg.endLevel ?? from;
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
return {
goldChance: start.gold + (end.gold - start.gold) * t,
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
};
}
export function makeGrid(rng = Math.random) {
const grid = [];
for (let r = 0; r < GRID_SIZE; r++) {
@ -92,12 +69,9 @@ export function computeSelfDamage(cells, grid) {
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
}
// Cascade tiles down in each column, fill top with new random tiles.
// opts may override spawn chances: { goldChance, diamondChance }.
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
// Cascade tiles down in each column, fill top with new random tiles
export function clearAndRefill(grid, usedCells, rng = Math.random) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let c = 0; c < GRID_SIZE; c++) {
@ -108,8 +82,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
}
// Fill remainder with new normal tiles
while (survive.length < GRID_SIZE) {
const gold = rng() < goldChance;
const diamond = !gold && rng() < diamondChance;
const gold = rng() < 0.03;
const diamond = !gold && rng() < 0.02;
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
survive.push({ letter: randomLetter(rng), type });
}

View File

@ -1,204 +0,0 @@
// BookworkSteering.js — context-aware letter placement for Bookworm
//
// The base engine (BookworkLogic.js) draws every letter independently from a
// weighted pool. That works, but it occasionally produces "dead" boards (no
// 3+ letter word findable) and vowel/consonant clumps, and those clumps
// persist through refills.
//
// This module layers a steering policy on the SAME pool: when a letter is
// chosen for a specific cell, each candidate's weight is adjusted by:
// 1. class steering — damp the class (vowel/consonant) already
// over-represented among the cell's filled neighbours
// 2. vowel band — keep the board-wide vowel share inside a target band
// 3. word boost — boost letters that complete real 3-letter words given
// the letters already placed around this cell
//
// Design notes:
// • Sampling, never argmax — boards stay varied and the injected-rng
// testability of BookworkLogic is preserved.
// • No full words are pre-placed. Steering only biases a single letter by
// what is already on the board, so the "word-friendly" property is
// re-established inductively after every refill — the board stays good
// as the game is played, not just at deal time.
// • With `wordSet` null every helper degrades to the plain weighted pool.
import { GRID_SIZE, LETTER_WEIGHTS, randomLetter, SPECIAL_TILE_CHANCES, getAdjacent, isAdjacent } from './BookworkLogic.js';
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
export const DEFAULT_STEER = {
wordBoostK: 1.0, // weight multiplier per word completed: w *= (1 + K * count)
classThreshold: 2, // neighbour class imbalance (|vowels - consonants|) that triggers steering
classDamp: 0.35, // multiplier applied to the over-represented class
classBoost: 1.8, // multiplier applied to the under-represented class
vowelBand: [0.30, 0.45], // target band for board-wide vowel share
bandMinFilled: 10, // only apply the vowel band once this many cells are filled
};
// Parse an ENABLE-style word list into a Set of A-Z words within [minLen, maxLen].
// 3–15 matches the /words/scrabble/validate dictionary (ENABLE, 2–15 letters)
// and the game's minimum playable word length.
export function parseWordList(text, minLen = 3, maxLen = 15) {
const set = new Set();
for (const raw of text.split('\n')) {
const w = raw.trim().toUpperCase();
if (w.length >= minLen && w.length <= maxLen && /^[A-Z]+$/.test(w)) set.add(w);
}
return set;
}
// Context-adjusted weight for every candidate letter at grid[r][c].
// `grid` may contain unfilled cells ({letter: null}) — they are ignored.
export function letterWeights(grid, r, c, wordSet = null, opts = {}) {
const o = { ...DEFAULT_STEER, ...opts };
// ── class steering: neighbour balance ───────────────────────────────
let v = 0, k = 0;
for (const nb of getAdjacent(r, c)) {
const l = grid[nb.r][nb.c]?.letter;
if (!l) continue;
if (VOWELS.has(l)) v++; else k++;
}
let vowelMult = 1, consonantMult = 1;
if (o.classSteer !== false) {
if (v >= k + o.classThreshold) { vowelMult = o.classDamp; consonantMult = o.classBoost; }
else if (k >= v + o.classThreshold) { consonantMult = o.classDamp; vowelMult = o.classBoost; }
}
// ── vowel band: board-wide share ────────────────────────────────────
if (o.vowelBand) {
let filled = 0, vTotal = 0;
for (let rr = 0; rr < GRID_SIZE; rr++) {
for (let cc = 0; cc < GRID_SIZE; cc++) {
if (rr === r && cc === c) continue;
const l = grid[rr][cc]?.letter;
if (!l) continue;
filled++;
if (VOWELS.has(l)) vTotal++;
}
}
if (filled >= o.bandMinFilled) {
const share = vTotal / filled;
if (share > o.vowelBand[1]) { vowelMult *= o.classDamp; consonantMult *= o.classBoost; }
else if (share < o.vowelBand[0]) { consonantMult *= o.classDamp; vowelMult *= o.classBoost; }
}
}
// ── word boost: 3-letter words completed through this cell ─────────
// For candidate L, count real words of the forms a-L-b (a,b filled
// neighbours), L-a-b and a-b-L (a,b filled neighbours, b adjacent a).
let counts = null;
if (wordSet && o.wordBoostK > 0) {
const N = [];
for (const nb of getAdjacent(r, c)) {
const l = grid[nb.r][nb.c]?.letter;
if (l) N.push({ l, r: nb.r, c: nb.c });
}
if (N.length >= 2) {
counts = new Array(26).fill(0);
for (let li = 0; li < 26; li++) {
const L = String.fromCharCode(65 + li);
if (!LETTER_WEIGHTS[L]) continue;
const seen = new Set();
for (const a of N) {
for (const b of N) {
if (a === b) continue;
const ab = isAdjacent(a, b);
const w1 = a.l + L + b.l;
if (wordSet.has(w1) && !seen.has(w1)) { seen.add(w1); counts[li]++; }
if (ab) {
const w2 = L + a.l + b.l;
if (wordSet.has(w2) && !seen.has(w2)) { seen.add(w2); counts[li]++; }
const w3 = a.l + b.l + L;
if (wordSet.has(w3) && !seen.has(w3)) { seen.add(w3); counts[li]++; }
}
}
}
}
}
}
// ── final weights ───────────────────────────────────────────────────
const weights = new Array(26).fill(0);
for (let li = 0; li < 26; li++) {
const L = String.fromCharCode(65 + li);
const base = LETTER_WEIGHTS[L];
if (!base) continue;
let w = base * (VOWELS.has(L) ? vowelMult : consonantMult);
if (counts) w *= 1 + o.wordBoostK * counts[li];
weights[li] = w;
}
return weights;
}
// Sample a letter from a 26-entry weight array (A..Z).
export function pickWeighted(weights, rng) {
let total = 0;
for (const w of weights) total += w;
if (total <= 0) return String.fromCharCode(65 + Math.floor(rng() * 26));
let x = rng() * total;
for (let i = 0; i < 26; i++) {
x -= weights[i];
if (x <= 0) return String.fromCharCode(65 + i);
}
return 'Z';
}
// Build a full 5×5 board with context steering (cells placed in random order,
// each steered by the already-placed neighbours).
export function makeSteeredGrid(rng = Math.random, wordSet = null, opts = {}) {
const grid = Array.from({ length: GRID_SIZE }, () =>
Array.from({ length: GRID_SIZE }, () => ({ letter: null, type: 'normal' })));
if (!wordSet) {
// Degrade to the plain weighted pool.
for (const row of grid) for (const cell of row) cell.letter = randomLetter(rng);
return grid;
}
const cells = [];
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) cells.push({ r, c });
for (let i = cells.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[cells[i], cells[j]] = [cells[j], cells[i]];
}
for (const { r, c } of cells) {
grid[r][c].letter = pickWeighted(letterWeights(grid, r, c, wordSet, opts), rng);
}
return grid;
}
// Same cascade semantics as BookworkLogic.clearAndRefill (survivors fall to
// the bottom of each column, fresh tiles drop into the top rows), except the
// fresh letters are chosen with context steering against the partially
// rebuilt board. Fresh tiles land ABOVE their column's survivors, so this
// also completes vertical word fragments the survivors left behind.
export function refillSteered(grid, usedCells, rng = Math.random, wordSet = null, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
// Pass 1: cascade survivors down each column; mark fresh slots as empty.
const newRows = new Array(GRID_SIZE).fill(0);
for (let c = 0; c < GRID_SIZE; c++) {
const survive = [];
for (let r = GRID_SIZE - 1; r >= 0; r--) {
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
}
newRows[c] = GRID_SIZE - survive.length;
for (let i = 0; i < survive.length; i++) next[GRID_SIZE - 1 - i][c] = survive[i];
for (let r = 0; r < newRows[c]; r++) next[r][c] = { letter: null, type: 'normal' };
}
// Pass 2: place fresh tiles — per column bottom-up, left→right across
// columns — so each new tile sees survivors + already-placed tiles.
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
for (let c = 0; c < GRID_SIZE; c++) {
for (let r = newRows[c] - 1; r >= 0; r--) {
const weights = wordSet ? letterWeights(next, r, c, wordSet, opts) : null;
const letter = weights ? pickWeighted(weights, rng) : randomLetter(rng);
const gold = rng() < goldChance;
const diamond = !gold && rng() < diamondChance;
next[r][c] = { letter, type: gold ? 'gold' : diamond ? 'diamond' : 'normal' };
}
}
return next;
}

View File

@ -1,125 +0,0 @@
// Defender — procedural wireframe combat effects.
//
// Every bullet and explosion is a Graphics stroke, not a sprite: a capped TTL
// list is fed from sim events and redrawn each frame using the repo's
// glow-stroke pair (one wide low-alpha pass, then a thin opaque one on top) —
// the same idiom as Total Annihilation's TAFx.js and Star Control's fxList.
// Explosions render as radiating line-segment "shatter" bursts rather than
// filled particles, to match the wireframe aesthetic.
const MAX_FX = 420;
// Every explosion — enemy kills, the boss dying, the player dying — uses this
// one bright, fixed orange rather than the target's own color, so a kill
// always reads as a kill: a consistent, high-contrast burst against every
// level's cyan/magenta/green/yellow/red wireframe palette.
export const EXPLOSION_COLOR = 0xff7a1a;
export default class DefenderFx {
constructor(scene, depths) {
this.scene = scene;
this.gUnder = scene.add.graphics().setDepth(depths.fxUnder);
this.gOver = scene.add.graphics().setDepth(depths.fxOver);
this.list = [];
}
destroy() {
this.gUnder.destroy();
this.gOver.destroy();
this.list.length = 0;
}
push(fx) {
if (this.list.length >= MAX_FX) this.list.shift();
fx.age = 0;
this.list.push(fx);
}
shatter(x, y, color, count = 8, speed = 220) {
for (let i = 0; i < count; i += 1) {
const a = (i / count) * Math.PI * 2 + Math.random() * 0.4;
const len = 10 + Math.random() * 14;
const spd = speed * (0.6 + Math.random() * 0.6);
this.push({
kind: 'shard', x, y, ang: a, len, color,
dx: Math.cos(a) * spd, dy: Math.sin(a) * spd, spin: (Math.random() - 0.5) * 6,
ttl: 360 + Math.random() * 220,
});
}
}
ring(x, y, color, r = 40, ttl = 320) {
this.push({ kind: 'ring', x, y, r, color, ttl });
}
tracer(x1, y1, x2, y2, color, width = 2, ttl = 90) {
this.push({ kind: 'tracer', x1, y1, x2, y2, color, width, ttl });
}
spark(x, y, color, ttl = 220) {
this.push({ kind: 'spark', x, y, color, ttl });
}
onEvent(ev) {
switch (ev.type) {
case 'enemyKilled':
this.shatter(ev.x, ev.y, EXPLOSION_COLOR, ev.enemyType === 'walker' ? 12 : 8,
ev.enemyType === 'walker' ? 260 : 200);
this.ring(ev.x, ev.y, EXPLOSION_COLOR);
break;
case 'humanoidLost':
this.spark(ev.x ?? 0, ev.y ?? 0, 0xff5566, 300);
break;
default: break;
}
}
draw(delta) {
const gu = this.gUnder; const go = this.gOver;
gu.clear(); go.clear();
const keep = [];
for (const fx of this.list) {
fx.age += delta;
if (fx.age >= fx.ttl) continue;
keep.push(fx);
const t = fx.age / fx.ttl;
const a = 1 - t;
switch (fx.kind) {
case 'shard': {
const cx = fx.x + fx.dx * (fx.age / 1000);
const cy = fx.y + fx.dy * (fx.age / 1000);
const ang = fx.ang + fx.spin * (fx.age / 1000);
const hx = Math.cos(ang) * fx.len * 0.5;
const hy = Math.sin(ang) * fx.len * 0.5;
go.lineStyle(4, fx.color, 0.18 * a);
go.lineBetween(cx - hx, cy - hy, cx + hx, cy + hy);
go.lineStyle(1.6, fx.color, a);
go.lineBetween(cx - hx, cy - hy, cx + hx, cy + hy);
break;
}
case 'ring': {
const r = fx.r * (0.3 + t * 1.1);
go.lineStyle(5 * a + 1, fx.color, 0.6 * a);
go.strokeCircle(fx.x, fx.y, r);
break;
}
case 'tracer': {
go.lineStyle(fx.width * 3, fx.color, 0.16 * a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
go.lineStyle(fx.width, fx.color, a);
go.lineBetween(fx.x1, fx.y1, fx.x2, fx.y2);
break;
}
case 'spark': {
go.fillStyle(fx.color, a);
go.fillCircle(fx.x, fx.y, 3 * a + 1);
break;
}
default: break;
}
}
this.list = keep;
}
}

View File

@ -1,782 +0,0 @@
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 { getGameSoundtrack } from '../../services/soundtrack.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { api } from '../../services/api.js';
import { applyArcadeCRTOverlay } from '../../ui/ArcadeCRTOverlay.js';
import {
WORLD_W, Y_MIN, Y_GROUND, Y_MAX, TUNE,
wrap, tdelta, createGame, setInput, step,
} from './DefenderLogic.js';
import DefenderFx, { EXPLOSION_COLOR } from './DefenderFx.js';
import { drawVectorText } from './DefenderVectorFont.js';
// Depth layers — one Graphics object per layer, cleared and redrawn every
// frame from current sim state (never persistent GameObjects), same idiom as
// Tempest/Total Annihilation.
const D = {
bgFar: -6, bgNear: -5, groundLine: -4, extraction: -3,
fxUnder: -2, humanoids: -1, tractorBeams: -0.5, enemies: 0,
player: 1, shots: 1.5, fxOver: 2, banner: 5, ui: 30, overlay: 61,
};
const BEST_KEY = 'defender-best';
// One accent palette per level (1-indexed) — cycles if a level ever exceeds
// the authored count. Chosen to read clearly as wireframe strokes on black.
const PALETTES = [
{ accent: 0x33e6ff, swarmer: 0x33e6ff, walker: 0xff8a3c, abductor: 0xff4fd8, humanoid: 0xffffff, boss: 0xff4fd8, ground: 0x2a3a4a },
{ accent: 0xff4fd8, swarmer: 0xff4fd8, walker: 0xffd23c, abductor: 0x33e6ff, humanoid: 0xffffff, boss: 0x33e6ff, ground: 0x3a2a4a },
{ accent: 0x7cff5a, swarmer: 0x7cff5a, walker: 0xff8a3c, abductor: 0xffd23c, humanoid: 0xffffff, boss: 0xffd23c, ground: 0x1a3a2a },
{ accent: 0xffd23c, swarmer: 0xffd23c, walker: 0xff4fd8, abductor: 0x7cff5a, humanoid: 0xffffff, boss: 0x7cff5a, ground: 0x3a3a1a },
{ accent: 0xff5a5a, swarmer: 0xff5a5a, walker: 0x33e6ff, abductor: 0xffd23c, humanoid: 0xffffff, boss: 0xffffff, ground: 0x3a1a1a },
];
function paletteFor(level) { return PALETTES[(level - 1) % PALETTES.length]; }
// Vivid, deliberately off-palette pink — every level accent above is a cyan/
// magenta/green/yellow/red, so this reads as "alert" rather than blending in
// as just another enemy color.
const ALERT_PINK = 0xff17c4;
// The player's own bolts, distinct from every level's accent color and from
// the red enemy shots, so incoming vs. outgoing fire is unmistakable at a glance.
const PLAYER_SHOT_COLOR = 0xffe135;
// Fixed neon engine-glow accent for the player ship — independent of the level
// palette, like PLAYER_SHOT_COLOR, so the thruster always reads as "engine".
const ENGINE_COLOR = 0x00e5ff;
// Player ship silhouette, in unit space (nose at +x; mirrored by `facing`,
// scaled by radius) — an asymmetric interceptor: a long swept dorsal fin
// against a short ventral one, with concave "blade" notches at both wing
// roots and a spiked tail, for a sharper silhouette than a plain dart.
const SHIP_HULL = [
[1.35, 0], [0.75, -0.16], [0.30, -0.40], [-0.10, -0.30], [-1.15, -0.92],
[-0.50, -0.26], [-0.80, -0.14], [-1.05, 0], [-0.80, 0.14], [-0.50, 0.26],
[-0.95, 0.68], [-0.10, 0.30], [0.30, 0.40], [0.75, 0.16],
];
// Mini-map panel — a squashed side-view of the whole wrapped world, tucked
// under MusicPlayer's buttons + track-name readout (src/ui/MusicPlayer.js:
// PAD=12, BTN=32 buttons at y=12-44, track info text at y=58, so the music
// block's footprint ends around y=74-80).
const MINI_W = 220;
const MINI_H = 64;
const MINI_X = GAME_WIDTH - 20 - MINI_W;
const MINI_Y = 92;
export default class DefenderGame extends Phaser.Scene {
constructor() { super('DefenderGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'defender', name: 'Defender' };
this.mode = 'title'; // 'title' | 'playing' | 'gameover' | 'victory'
this.sim = null;
this.camX = WORLD_W / 2;
this.spin = 0;
this.banner = null;
this.multPulseMs = 0;
}
create() {
try {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) this.music = new MusicPlayer(this, tracks, volume);
} catch (_) { /* optional */ }
this.bgRect = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x05060c)
.setDepth(D.bgFar - 1);
this.bgFarG = this.add.graphics().setDepth(D.bgFar);
this.bgNearG = this.add.graphics().setDepth(D.bgNear);
this.groundG = this.add.graphics().setDepth(D.groundLine);
this.extractG = this.add.graphics().setDepth(D.extraction);
this.humanoidG = this.add.graphics().setDepth(D.humanoids);
this.beamG = this.add.graphics().setDepth(D.tractorBeams);
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.bannerG = this.add.graphics().setDepth(D.banner);
this.hudG = this.add.graphics().setDepth(D.ui);
this.minimapG = this.add.graphics().setDepth(D.ui);
this.minimapLabel = this.add.text(MINI_X + MINI_W / 2, MINI_Y - 16, 'MAP', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1).setDepth(D.ui);
this.fx = new DefenderFx(this, { fxUnder: D.fxUnder, fxOver: D.fxOver });
this.crt = applyArcadeCRTOverlay(this, {
accentTint: paletteFor(1).accent, scanlineTint: paletteFor(1).accent,
});
this.events.once('shutdown', () => { this.crt.destroy(); this.fx.destroy(); });
this.buildHud();
this.bindInput();
this.showTitle();
}
// ── Input ─────────────────────────────────────────────────────────────────
bindInput() {
this.cursors = this.input.keyboard.createCursorKeys();
this.wasd = this.input.keyboard.addKeys('W,A,S,D,SPACE,SHIFT');
}
readInput() {
return {
left: this.cursors.left.isDown || this.wasd.A.isDown,
right: this.cursors.right.isDown || this.wasd.D.isDown,
up: this.cursors.up.isDown || this.wasd.W.isDown,
down: this.cursors.down.isDown || this.wasd.S.isDown,
fire: this.cursors.space?.isDown || this.wasd.SPACE.isDown,
overdrive: this.wasd.SHIFT.isDown,
};
}
// ── Title screen ─────────────────────────────────────────────────────────
showTitle() {
this.mode = 'title';
this.titleUi = this.add.container(0, 0).setDepth(D.ui + 1);
this.titleG = this.add.graphics().setDepth(D.ui);
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
const sub = this.add.text(GAME_WIDTH / 2, 470,
'ARROWS/WASD: FLY • SPACE: FIRE • SHIFT: OVERDRIVE (when charged)', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const sub2 = this.add.text(GAME_WIDTH / 2, 508,
'Free the humanoids from abductors, catch them before they fall, and fly them to a beacon.', {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.titleUi.add([sub, sub2]);
if (best > 0) {
this.titleUi.add(this.add.text(GAME_WIDTH / 2, 546, `BEST ${best}`, {
fontFamily: 'm6x11, "Julius Sans One"', fontSize: '20px', color: COLORS.goldHex,
}).setOrigin(0.5));
}
const play = new Button(this, GAME_WIDTH / 2, 640, 'Play', () => this.startRun(),
{ width: 280, height: 66, fontSize: 28 });
this.titleUi.add(play);
}
drawTitle(delta) {
const g = this.titleG;
g.clear();
this.spin += delta * 0.0002;
const pal = paletteFor(1);
for (let i = 0; i < 40; i += 1) {
const a = this.spin + (i / 40) * Math.PI * 2;
const r = 260 + Math.sin(a * 3) * 20;
const x = GAME_WIDTH / 2 + Math.cos(a) * r;
const y = 300 + Math.sin(a) * r * 0.32;
g.lineStyle(1.4, pal.accent, 0.12);
g.lineBetween(GAME_WIDTH / 2, 300, x, y);
}
drawVectorText(g, 'DEFENDER', GAME_WIDTH / 2, 300, 24, pal.accent,
{ lineWidth: 5, glowWidth: 18, glowAlpha: 0.22 });
}
startRun() {
playSound(this, SFX.EIGHTBIT_ACTIVATE);
this.titleUi.destroy(true);
this.titleG.destroy();
this.titleUi = null;
this.titleG = null;
this.sim = createGame({ seed: (Date.now() ^ (Math.random() * 0xffffffff)) >>> 0 });
this.camX = this.sim.player.x;
this.mode = 'playing';
this.applyPalette();
this.showBanner('LEVEL 1', paletteFor(1).accent, 1600);
playSound(this, SFX.COUNTDOWN_GO);
}
applyPalette() {
const pal = paletteFor(this.sim.level);
this.crt.setIntensity({ accentTint: pal.accent, scanlineTint: pal.accent });
}
// ── 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(GAME_WIDTH / 2, 30, '', { ...font, fontSize: '28px', 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: '22px', color: COLORS.mutedHex }).setOrigin(1, 0).setDepth(D.ui);
this.multText = this.add.text(GAME_WIDTH - 40, 66, '', { ...font, fontSize: '26px', color: COLORS.goldHex })
.setOrigin(1, 0).setDepth(D.ui);
this.overdriveLabel = this.add.text(40, 150, 'OVERDRIVE', { ...font, fontSize: '16px', color: COLORS.mutedHex }).setDepth(D.ui);
}
updateHud() {
const sim = this.sim;
this.scoreText.setText(String(sim.score));
this.levelText.setText(`LEVEL ${sim.level} • WAVE ${Math.min(sim.wave, TUNE.WAVES_PER_LEVEL)}/${TUNE.WAVES_PER_LEVEL}`);
if (sim.multiplier > 1) {
this.multText.setText(`×${sim.multiplier}`);
this.multText.setVisible(true);
} else {
this.multText.setVisible(false);
}
const g = this.hudG;
g.clear();
const pal = paletteFor(sim.level);
// Lives as tiny ship glyphs under the score, matching the real thing.
for (let i = 0; i < Math.min(sim.lives, 8); i += 1) {
const x = 50 + i * 38; const y = 108;
this.drawShip(g, x, y, 12, 1, pal.accent, ENGINE_COLOR, 0, [[5, 0.18], [2, 1]]);
}
// Overdrive meter bar.
const bx = 40; const by = 172; const bw = 260; const bh = 18;
g.lineStyle(2, COLORS.muted, 0.6);
g.strokeRect(bx, by, bw, bh);
const fillColor = sim.overdriveActive ? 0xffffff : pal.accent;
g.fillStyle(fillColor, sim.overdriveMeter >= 1 && !sim.overdriveActive ? 0.5 + 0.5 * Math.sin(this.time.now * 0.01) : 0.8);
g.fillRect(bx + 2, by + 2, Math.max(0, (bw - 4) * sim.overdriveMeter), bh - 4);
}
// ── Frame loop ────────────────────────────────────────────────────────────
update(time, delta) {
if (this.mode === 'title') { this.drawTitle(delta); return; }
if (!this.sim) return;
if (this.mode === 'playing') {
setInput(this.sim, this.readInput());
const events = step(this.sim, delta);
for (const e of events) this.handleEvent(e);
}
this.syncGraphics(delta);
if (this.mode === 'playing') this.updateHud();
}
handleEvent(e) {
const pal = paletteFor(this.sim.level);
switch (e.type) {
case 'shotFired':
if (!e.enemy) playSound(this, SFX.LASER_ZAP);
break;
case 'enemyKilled':
playSound(this, e.enemyType === 'walker' ? SFX.EIGHTBIT_EXPLODE_2 : SFX.EIGHTBIT_EXPLODE);
// Sim events carry world-space x/y; DefenderFx draws in screen space, so the x has
// to go through screenX() here or the burst lands wherever the world coordinate
// happens to fall on screen instead of where the kill actually happened.
this.fx.onEvent({ ...e, x: this.screenX(e.x) });
break;
case 'humanoidGrabbed':
playSound(this, SFX.CASINO_LOSE);
this.showBanner('HUMAN CAPTURED!', ALERT_PINK, 1500);
this.crt.pulse(0.5, 220);
break;
case 'humanoidPickedUp':
playSound(this, SFX.UI_CHIME);
break;
case 'humanoidRescued':
playSound(this, SFX.VICTORY_SHORT);
break;
case 'humanoidLost':
playSound(this, SFX.CASINO_LOSE);
this.fx.onEvent({ ...e, x: this.screenX(e.x) });
break;
case 'overdriveReady':
playSound(this, SFX.EIGHTBIT_COUNT);
break;
case 'overdriveStart':
playSound(this, SFX.ENERGY_HUM);
this.crt.pulse(0.6, 260);
break;
case 'overdriveEnd':
playSound(this, SFX.SCIFI_WOOSH);
break;
case 'waveStart':
this.showBanner(`WAVE ${e.wave}`, pal.accent, 1200);
break;
case 'waveClear':
playSound(this, SFX.UI_ACTIVATE);
break;
case 'bossSpawn':
playSound(this, SFX.SCIFI_REVEAL);
this.showBanner(`WARNING: ${e.kind}`, 0xff4040, 2000);
break;
case 'bossPhaseChange':
playSound(this, SFX.EIGHTBIT_EXPLODE_2);
this.crt.pulse(0.7, 300);
break;
case 'bossDefeated':
playSound(this, SFX.SCIFI_EXPLODE);
this.fx.shatter(this.screenX(this.sim.boss?.x ?? this.camX), (Y_MIN + Y_GROUND) / 2, EXPLOSION_COLOR, 24, 320);
this.crt.pulse(1, 400);
break;
case 'levelComplete':
this.showBanner(e.fullRescue ? 'LEVEL CLEAR — ALL RESCUED!' : 'LEVEL CLEAR', COLORS.gold, 2000);
playSound(this, SFX.EIGHTBIT_WIN);
break;
case 'playerDied':
playSound(this, SFX.EIGHTBIT_EXPLODE_2);
// Player position is world-space; convert through the camera or the burst
// renders at whatever raw world-x maps to in screen pixels, not the ship.
this.fx.shatter(this.screenX(this.sim.player.x), this.sim.player.y, EXPLOSION_COLOR, 14, 260);
this.crt.pulse(0.8, 320);
break;
case 'playerRespawned':
playSound(this, SFX.COUNTDOWN_GO);
break;
case 'victory':
this.onVictory(e);
break;
case 'gameOver':
this.onGameOver(e);
break;
default: break;
}
if (this.sim.level && (e.type === 'levelComplete')) {
this.time.delayedCall(50, () => this.applyPalette());
}
}
// ── World → screen mapping & camera ─────────────────────────────────────
screenX(worldX) {
return GAME_WIDTH / 2 + tdelta(this.camX, worldX);
}
updateCamera() {
const target = this.sim.player.x;
const d = tdelta(this.camX, target);
this.camX = wrap(this.camX + d * 0.14);
}
// A sleek, asymmetric interceptor silhouette in place of the old plain dart:
// a long swept dorsal fin against a shorter ventral one, concave "blade" cuts
// at each wing root, a canted canopy, and a speed-reactive engine flare —
// aiming for sharp/edgy cyberpunk rather than a generic arcade ship.
drawShip(g, x, y, r, facing, color, engineColor, thrust, passes) {
const P = (ux, uy) => ({ x: x + facing * ux * r, y: y + uy * r });
const hull = SHIP_HULL.map(([ux, uy]) => P(ux, uy));
for (const [lw, a] of passes) {
g.lineStyle(lw, color, a);
g.beginPath();
hull.forEach((p, i) => { if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); });
g.closePath();
g.strokePath();
}
// Fuselage spine.
const spineA = P(0.9, 0); const spineB = P(-0.8, 0);
g.lineStyle(passes[passes.length - 1][0] * 0.7, color, 0.3);
g.lineBetween(spineA.x, spineA.y, spineB.x, spineB.y);
// Canted canopy — a filled, glassy diamond just aft of the nose.
const canopy = [[0.63, 0], [0.45, -0.11], [0.27, 0], [0.45, 0.11]].map(([ux, uy]) => P(ux, uy));
g.beginPath();
canopy.forEach((p, i) => { if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y); });
g.closePath();
g.fillStyle(color, 0.3);
g.fillPath();
g.lineStyle(1.6, 0xffffff, 0.85);
g.strokePath();
// Two greeble ticks along the long dorsal fin for mechanical detail.
const wingA = P(-0.1, -0.3); const wingB = P(-1.15, -0.92);
const wdx = wingB.x - wingA.x; const wdy = wingB.y - wingA.y;
const wlen = Math.hypot(wdx, wdy) || 1;
const nx = -wdy / wlen; const ny = wdx / wlen;
for (const t of [0.35, 0.7]) {
const mx = wingA.x + wdx * t; const my = wingA.y + wdy * t;
g.lineStyle(1.4, color, 0.6);
g.lineBetween(mx - nx * r * 0.12, my - ny * r * 0.12, mx + nx * r * 0.12, my + ny * r * 0.12);
}
// Engine flare — a fixed neon accent (not the level palette) so the
// thruster always reads as "engine", and it stretches with current speed.
const tail = P(-1.05, 0);
const flareLen = r * (0.5 + thrust * 1.5);
g.lineStyle(7, engineColor, 0.16);
g.lineBetween(tail.x, tail.y, tail.x - facing * flareLen, tail.y);
g.lineStyle(2.4, engineColor, 0.9);
g.lineBetween(tail.x, tail.y, tail.x - facing * flareLen * 0.65, tail.y);
g.fillStyle(engineColor, 0.95);
g.fillCircle(tail.x, tail.y, r * 0.14);
}
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();
}
strokeStick(g, x, y, r, color) {
for (const [lw, a] of [[4, 0.16], [1.6, 0.9]]) {
g.lineStyle(lw, color, a);
g.strokeCircle(x, y - r * 1.3, r * 0.45);
g.beginPath();
g.moveTo(x, y - r * 0.85); g.lineTo(x, y + r * 0.3);
g.moveTo(x - r * 0.6, y - r * 0.4); g.lineTo(x + r * 0.6, y - r * 0.4);
g.moveTo(x, y + r * 0.3); g.lineTo(x - r * 0.4, y + r);
g.moveTo(x, y + r * 0.3); g.lineTo(x + r * 0.4, y + r);
g.strokePath();
}
}
// ── Draw ──────────────────────────────────────────────────────────────────
drawBackground(delta) {
this.spin += delta * 0.00006;
const pal = paletteFor(this.sim.level);
this.drawSkyline(this.bgFarG, this.camX * 0.35, 30, 0.0014, pal.ground, 0.5);
this.drawSkyline(this.bgNearG, this.camX * 0.7, 60, 0.0026, pal.ground, 0.85);
const gg = this.groundG;
gg.clear();
gg.lineStyle(3, paletteFor(this.sim.level).ground, 0.9);
gg.lineBetween(0, Y_GROUND, GAME_WIDTH, Y_GROUND);
gg.lineStyle(1, paletteFor(this.sim.level).ground, 0.35);
gg.lineBetween(0, Y_MAX, GAME_WIDTH, Y_MAX);
}
drawSkyline(g, camOffset, amp, freq, color, alpha) {
g.clear();
g.lineStyle(2, color, alpha);
g.beginPath();
const baseY = Y_GROUND - 40;
for (let sx = 0; sx <= GAME_WIDTH; sx += 24) {
const wx = camOffset + sx;
const h = amp * (Math.sin(wx * freq) + 0.5 * Math.sin(wx * freq * 2.7 + 1.3));
const y = baseY - Math.abs(h) - amp * 0.4;
if (sx === 0) g.moveTo(sx, y); else g.lineTo(sx, y);
}
g.strokePath();
}
drawExtractionZones() {
const g = this.extractG;
g.clear();
const pal = paletteFor(this.sim.level);
const pulse = 0.5 + 0.5 * Math.sin(this.time.now * 0.006);
for (const z of this.sim.extractionZones) {
const sx = this.screenX(z.x);
if (sx < -80 || sx > GAME_WIDTH + 80) continue;
g.lineStyle(2, pal.accent, 0.3 + 0.3 * pulse);
g.lineBetween(sx, Y_MIN - 30, sx, Y_GROUND);
g.lineStyle(3, pal.accent, 0.7 + 0.3 * pulse);
this.strokeNgon(g, sx, Y_MIN - 30, 14, 4, Math.PI / 4);
drawVectorText(g, 'BEACON', sx, Y_MIN - 60, 1.6, pal.accent, { lineWidth: 1.4, glowWidth: 4, alpha: 0.85 });
}
}
drawHumanoids() {
const g = this.humanoidG; const bg = this.beamG;
g.clear(); bg.clear();
const pal = paletteFor(this.sim.level);
for (const h of this.sim.humanoids) {
if (h.status === 'rescued' || h.status === 'lost') continue;
const sx = this.screenX(h.x);
if (sx < -60 || sx > GAME_WIDTH + 60) continue;
this.strokeStick(g, sx, h.y, TUNE.HUMANOID_RADIUS, h.status === 'idle' ? COLORS.muted : pal.humanoid);
if (h.status === 'grabbed') {
const e = this.sim.enemies.find((ee) => ee.id === h.grabberId);
if (e) {
const ex = this.screenX(e.x);
bg.lineStyle(5, pal.abductor, 0.18);
bg.lineBetween(ex, e.y, sx, h.y);
bg.lineStyle(2, pal.abductor, 0.8);
bg.lineBetween(ex, e.y, sx, h.y);
}
}
}
}
drawEnemies() {
const g = this.enemyG;
g.clear();
const pal = paletteFor(this.sim.level);
const t = this.time.now;
for (const e of this.sim.enemies) {
const sx = this.screenX(e.x);
if (sx < -80 || sx > GAME_WIDTH + 80) continue;
if (e.type === 'swarmer') {
const rot = Math.atan2(e.vy, e.vx || 0.001);
g.lineStyle(4, pal.swarmer, 0.16);
this.strokeNgon(g, sx, e.y, TUNE.SWARMER_RADIUS, 3, rot);
g.lineStyle(1.6, pal.swarmer, 1);
this.strokeNgon(g, sx, e.y, TUNE.SWARMER_RADIUS, 3, rot);
} else if (e.type === 'walker') {
const r = TUNE.WALKER_RADIUS;
for (const [lw, a] of [[5, 0.16], [1.8, 0.95]]) {
g.lineStyle(lw, pal.walker, a);
g.beginPath();
g.moveTo(sx - r, e.y + r * 0.6); g.lineTo(sx - r * 0.6, e.y - r * 0.6);
g.lineTo(sx + r * 0.6, e.y - r * 0.6); g.lineTo(sx + r, e.y + r * 0.6);
g.closePath();
g.strokePath();
const aim = Math.atan2(0, tdelta(e.x, this.sim.player.x) || 1) + (tdelta(e.x, this.sim.player.x) < 0 ? Math.PI : 0);
g.beginPath();
g.moveTo(sx, e.y - r * 0.3);
g.lineTo(sx + Math.cos(aim) * r * 1.2, e.y - r * 0.3 + Math.sin(aim) * r * 0.3);
g.strokePath();
}
} else if (e.type === 'abductor') {
const spin = t * 0.003;
g.lineStyle(4, pal.abductor, 0.18);
this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS, 8, spin);
g.lineStyle(1.8, pal.abductor, 1);
this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS, 8, spin);
this.strokeNgon(g, sx, e.y, TUNE.ABDUCTOR_RADIUS * 0.5, 8, -spin);
}
}
if (this.sim.boss) {
const b = this.sim.boss;
const sx = this.screenX(b.x);
const spin = t * 0.0015;
const hpFrac = Math.max(0, b.hp / b.maxHp);
for (const [lw, a] of [[6, 0.18], [2.2, 1]]) {
g.lineStyle(lw, pal.boss, a);
this.strokeNgon(g, sx, b.y, TUNE.BOSS_RADIUS, 5, spin);
this.strokeNgon(g, sx, b.y, TUNE.BOSS_RADIUS * 0.6, 5, -spin * 1.6);
}
// boss health bar
const bw = 300; const bx = sx - bw / 2;
g.lineStyle(2, COLORS.muted, 0.7);
g.strokeRect(bx, b.y - TUNE.BOSS_RADIUS - 40, bw, 14);
g.fillStyle(pal.boss, 0.85);
g.fillRect(bx + 2, b.y - TUNE.BOSS_RADIUS - 38, Math.max(0, (bw - 4) * hpFrac), 10);
}
}
drawPlayerAndShots() {
const g = this.playerG; const sg = this.shotG;
g.clear(); sg.clear();
const pal = paletteFor(this.sim.level);
const p = this.sim.player;
if (p.alive && (Math.floor(p.invulnMs / 90) % 2 === 0 || p.invulnMs <= 0)) {
const sx = this.screenX(p.x);
const color = this.sim.overdriveActive ? 0xffffff : pal.accent;
const engineColor = this.sim.overdriveActive ? 0xffffff : ENGINE_COLOR;
const thrust = Math.min(1, Math.hypot(p.vx, p.vy) / TUNE.PLAYER_MAX_SPEED_X);
this.drawShip(g, sx, p.y, TUNE.PLAYER_RADIUS, p.facing, color, engineColor, thrust, [[6, 0.2], [2.2, 1]]);
if (p.carrying != null) {
// (drawn in drawHumanoids since the humanoid entity itself already
// tracks the carry offset each tick)
}
}
for (const s of this.sim.shots) {
const sx = this.screenX(s.x);
sg.lineStyle(4, PLAYER_SHOT_COLOR, 0.22);
sg.lineBetween(sx - 10 * Math.sign(s.vx || 1), s.y, sx + 10 * Math.sign(s.vx || 1), s.y);
sg.lineStyle(1.8, PLAYER_SHOT_COLOR, 1);
sg.lineBetween(sx - 10 * Math.sign(s.vx || 1), s.y, sx + 10 * Math.sign(s.vx || 1), s.y);
}
for (const s of this.sim.enemyShots) {
const sx = this.screenX(s.x);
sg.lineStyle(4, 0xff5050, 0.18);
sg.fillStyle(0xff5050, 1);
sg.fillCircle(sx, s.y, 4);
sg.lineStyle(1.6, 0xffb0b0, 1);
sg.strokeCircle(sx, s.y, 4);
}
}
// A squashed side-view of the entire wrapped world: the player sits fixed
// at the horizontal center, and every other entity is placed left/right of
// it by tdelta() scaled against half the world's width — so an entity's
// offset from center IS its shortest-path direction and distance, with no
// separate case needed for "which way around the ring is closer". Vertical
// position mirrors world Y, so a climbing abductor visibly rises toward the
// top of the panel — a glance tells you both which way to fly and how much
// time is left before an escape.
minimapX(worldX) {
const half = MINI_W / 2 - 12;
return MINI_X + MINI_W / 2 + (tdelta(this.sim.player.x, worldX) / (WORLD_W / 2)) * half;
}
minimapY(worldY) {
const top = MINI_Y + 8; const h = MINI_H - 16;
const f = Math.max(0, Math.min(1, (worldY - Y_MIN) / (Y_GROUND - Y_MIN)));
return top + f * h;
}
drawMinimap() {
const g = this.minimapG;
g.clear();
if (!this.sim) return;
const pal = paletteFor(this.sim.level);
const dangerHumanoids = this.sim.humanoids.filter((h) => h.status === 'grabbed' || h.status === 'falling');
const alerting = dangerHumanoids.length > 0;
const pulse = 0.5 + 0.5 * Math.sin(this.time.now * 0.014);
const frameColor = alerting ? ALERT_PINK : pal.accent;
g.fillStyle(0x000000, 0.5);
g.fillRoundedRect(MINI_X, MINI_Y, MINI_W, MINI_H, 8);
g.lineStyle(alerting ? 3 : 2, frameColor, alerting ? 0.6 + 0.4 * pulse : 0.7);
g.strokeRoundedRect(MINI_X, MINI_Y, MINI_W, MINI_H, 8);
// Extraction beacons — always visible, so "where do I take them" never gets lost.
for (const z of this.sim.extractionZones) {
const x = this.minimapX(z.x);
if (x < MINI_X || x > MINI_X + MINI_W) continue;
g.lineStyle(2, pal.accent, 0.9);
this.strokeNgon(g, x, MINI_Y + MINI_H - 8, 4, 4, Math.PI / 4);
}
// Enemies — small dots tinted by type, same palette colors as the world view.
for (const en of this.sim.enemies) {
const x = this.minimapX(en.x);
if (x < MINI_X - 4 || x > MINI_X + MINI_W + 4) continue;
const y = this.minimapY(en.y);
const color = pal[en.type] ?? pal.accent;
g.fillStyle(color, 0.8);
g.fillCircle(x, y, en.type === 'walker' ? 3 : 2);
}
// Boss, if active.
if (this.sim.boss) {
const x = this.minimapX(this.sim.boss.x);
if (x >= MINI_X && x <= MINI_X + MINI_W) {
g.fillStyle(pal.boss, 0.6 + 0.4 * pulse);
g.fillCircle(x, this.minimapY(this.sim.boss.y), 5);
}
}
// Humanoids — idle dim, carried safe-white, grabbed/falling alert-pink and pulsing.
for (const h of this.sim.humanoids) {
if (h.status === 'rescued' || h.status === 'lost') continue;
const x = this.minimapX(h.x);
if (x < MINI_X - 6 || x > MINI_X + MINI_W + 6) continue;
const y = this.minimapY(h.y);
if (h.status === 'grabbed' || h.status === 'falling') {
g.fillStyle(ALERT_PINK, 0.3 + 0.35 * pulse);
g.fillCircle(x, y, 7 + 3 * pulse);
g.fillStyle(0xffffff, 1);
g.fillCircle(x, y, 2.6);
} else {
g.fillStyle(h.status === 'carried' ? 0xffffff : COLORS.muted, h.status === 'carried' ? 1 : 0.75);
g.fillCircle(x, y, h.status === 'carried' ? 3.4 : 2.6);
}
}
// Player — fixed at horizontal center; vertical position still tracks real altitude.
const px = MINI_X + MINI_W / 2; const py = this.minimapY(this.sim.player.y);
g.fillStyle(this.sim.overdriveActive ? 0xffffff : pal.accent, 1);
g.beginPath();
g.moveTo(px, py - 5); g.lineTo(px - 4, py + 4); g.lineTo(px + 4, py + 4);
g.closePath();
g.fillPath();
// Directional alert arrow, just outside the panel, toward the nearest capture.
if (alerting) {
dangerHumanoids.sort((a, b) => Math.abs(tdelta(this.sim.player.x, a.x)) - Math.abs(tdelta(this.sim.player.x, b.x)));
const nearest = dangerHumanoids[0];
const dir = tdelta(this.sim.player.x, nearest.x) >= 0 ? 1 : -1;
const ax = dir > 0 ? MINI_X + MINI_W + 16 : MINI_X - 16;
const ay = MINI_Y + MINI_H / 2;
g.fillStyle(ALERT_PINK, 0.6 + 0.4 * pulse);
g.beginPath();
g.moveTo(ax + dir * 8, ay); g.lineTo(ax - dir * 6, ay - 8); g.lineTo(ax - dir * 6, ay + 8);
g.closePath();
g.fillPath();
}
}
syncGraphics(delta) {
this.updateCamera();
this.drawBackground(delta);
this.drawExtractionZones();
this.drawHumanoids();
this.drawEnemies();
this.drawPlayerAndShots();
this.fx.draw(delta);
this.drawMinimap();
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, GAME_WIDTH / 2, 200, 6, this.banner.color,
{ lineWidth: 3, glowWidth: 12, glowAlpha: 0.22, alpha });
}
}
}
showBanner(text, color, lifeMs) {
this.banner = { text, color, lifeMs, ageMs: 0 };
}
// ── Game over / victory ───────────────────────────────────────────────────
recordScore(score, result) {
const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0);
const newBest = score > prevBest;
if (newBest) localStorage.setItem(BEST_KEY, String(score));
api.post('/history/single-player', {
slug: 'defender', score, opponentScores: [], result,
}).catch(() => { /* best effort */ });
return { prevBest, newBest };
}
onGameOver(e) {
this.mode = 'gameover';
this.crt.pulse(1, 400);
const { prevBest, newBest } = this.recordScore(e.score, 'loss');
this.time.delayedCall(600, () => this.showEndPanel('GAME OVER', COLORS.dangerHex, e, prevBest, newBest, `You reached level ${e.level}.`));
}
onVictory(e) {
this.mode = 'victory';
this.crt.pulse(1, 500);
const { prevBest, newBest } = this.recordScore(e.score, 'win');
playSound(this, SFX.EIGHTBIT_WIN);
this.time.delayedCall(600, () => this.showEndPanel('VICTORY!', COLORS.goldHex, e, prevBest, newBest, 'All 5 levels defended.'));
}
showEndPanel(title, titleColor, e, prevBest, newBest, subtitle) {
const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2;
const root = this.add.container(0, 0).setDepth(D.overlay);
root.add(this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.65).setInteractive());
const panel = this.add.graphics();
panel.fillStyle(COLORS.panel, 0.98);
panel.fillRoundedRect(cx - 380, cy - 260, 760, 520, 22);
panel.lineStyle(3, paletteFor(this.sim.level).accent, 1);
panel.strokeRoundedRect(cx - 380, cy - 260, 760, 520, 22);
root.add(panel);
root.add(this.add.text(cx, cy - 192, title, { fontFamily: 'm6x11, "Julius Sans One"', fontSize: '52px', color: titleColor }).setOrigin(0.5));
root.add(this.add.text(cx, cy - 130, subtitle, { 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]);
}
}

View File

@ -1,882 +0,0 @@
// Pure simulation for Defender (Resogun-style wireframe swarm shooter). No
// Phaser dependency — fully unit-testable headlessly via tools/verifyDefender.js.
//
// World model: a single 2D plane that WRAPS horizontally (a "ring", like a
// side view of a cylinder) and is bounded vertically. Every position/velocity
// update and every AI distance/heading calc that touches X must go through
// wrap()/tdelta() below — a naive `dx = a.x - b.x` will make swarms visibly
// split at the wrap seam. Same toroidal-math idiom as Star Control
// (src/games/starcontrol/StarControlLogic.js), reduced to one wrapped axis.
//
// Fixed-tick loop: same accumulator/spiral-of-death-guard pattern as Total
// Annihilation (src/games/totalannihilation/TALogic.js) rather than a raw
// per-frame delta — swarm flocking and rescue timers behave identically
// regardless of render framerate. state.alpha is the leftover fraction the
// view uses to interpolate between the last two ticks.
// ---------------------------------------------------------------------------
// Seeded RNG (same generator every other game in this repo uses).
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;
};
}
// ---------------------------------------------------------------------------
// World / wrap math
export const WORLD_W = 6000;
export const Y_SKY = 60; // an abductor carrying a humanoid past this height = escaped
export const Y_MIN = 90; // top of the flight band
export const Y_GROUND = 900; // ground band: walkers, idle humanoids, falling humanoids land here
export const Y_MAX = 940; // bottom clamp for the player
export const STEP_MS = 1000 / 60;
export const MAX_STEPS = 4;
export function wrap(v, size = WORLD_W) {
return ((v % size) + size) % size;
}
// Shortest signed delta from a to b on the wrapped X axis.
export function tdelta(a, b, size = WORLD_W) {
let d = (b - a) % size;
if (d > size / 2) d -= size;
else if (d < -size / 2) d += size;
return d;
}
export function tdist(ax, ay, bx, by) {
const dx = tdelta(ax, bx);
const dy = ay - by;
return Math.hypot(dx, dy);
}
// ---------------------------------------------------------------------------
// Tuning — every gameplay constant lives here, named, so feel-tuning never
// touches game logic (same convention as Tempest's TUNE table).
export const TUNE = {
// Horizontal flight is deliberately much quicker than vertical — this is a
// side-view wraparound shooter, so covering ground left/right is the ship's
// primary job and should feel snappy; vertical is fine-grained dodging.
PLAYER_ACCEL_X: 3600,
PLAYER_MAX_SPEED_X: 1150,
PLAYER_ACCEL_Y: 1400,
PLAYER_MAX_SPEED_Y: 460,
PLAYER_DRAG: 5.2,
PLAYER_RADIUS: 18,
PLAYER_FIRE_COOLDOWN_MS: 130,
// Fast + long-lived enough to cross from the player (screen-centered by the
// camera) all the way past either screen edge before expiring. TTL carries
// extra margin over the bare half-screen distance because at top horizontal
// speed the camera's smoothed follow lags the ship by ~100+px.
PLAYER_SHOT_SPEED: 1100,
PLAYER_SHOT_TTL_MS: 1100,
PLAYER_SHOT_RADIUS: 6,
RESPAWN_DELAY_MS: 1500,
RESPAWN_INVULN_MS: 1200,
PLAYER_LIVES_START: 3,
SWARMER_RADIUS: 14,
SWARMER_SPEED: 220,
SWARMER_HP: 1,
SEPARATION_RADIUS: 30,
COHESION_RADIUS: 130,
ALIGNMENT_RADIUS: 100,
SEPARATION_W: 1.5,
COHESION_W: 0.45,
ALIGNMENT_W: 0.6,
SEEK_PLAYER_W: 0.55,
SEEK_RADIUS: 520,
SWARM_PACK_SIZE_MIN: 10,
SWARM_PACK_SIZE_MAX: 16,
SWARM_MAX_CONCURRENT: 48,
WALKER_RADIUS: 24,
WALKER_HP: 3,
WALKER_SPEED: 70,
WALKER_PATROL_RANGE: 260,
WALKER_FIRE_COOLDOWN_MS: 1500,
WALKER_SHOT_SPEED: 360,
WALKER_SHOT_RADIUS: 7,
WALKER_AIM_RANGE: 640,
ABDUCTOR_RADIUS: 26,
ABDUCTOR_HP: 2,
ABDUCTOR_SPEED: 160,
ABDUCTOR_RISE_SPEED: 65,
ABDUCTOR_GRAB_RADIUS: 46,
HUMANOID_RADIUS: 14,
PICKUP_RADIUS: 46,
// A freed humanoid falls from roughly Y_SKY..Y_MIN down to Y_GROUND (~790-840px)
// at this constant speed, so a typical fall takes ~5.5-6s; GRAB_WINDOW_MS sits
// comfortably above that so hitting the ground — not the window — is normally
// what ends an uncaught fall, with the window only as a backstop.
FALL_SPEED: 140,
GRAB_WINDOW_MS: 8000,
CARRY_TIMEOUT_MS: 12000,
EXTRACT_RADIUS: 80,
EXTRACTION_ZONES_PER_LEVEL: 2,
HUMANOIDS_PER_LEVEL: 6,
COMBO_WINDOW_MS: 1800,
MULT_MAX: 8,
OVERDRIVE_FILL_PER_KILL: 0.04,
OVERDRIVE_DURATION_MS: 8000,
OVERDRIVE_TIMESCALE: 0.35,
OVERDRIVE_SCORE_MULT: 5,
LEVEL_COUNT: 5,
WAVES_PER_LEVEL: 4,
WAVE_BREATHER_MS: 2200,
BOSS_INTRO_MS: 2600,
BOSS_OUTRO_MS: 1800,
BOSS_HP_BASE: 60,
BOSS_HP_STEP: 30,
BOSS_RADIUS: 70,
BOSS_SPEED: 90,
BOSS_SHOT_SPEED: 300,
BOSS_ATTACK_COOLDOWN_MS: 1600,
BOSS_SPOKE_COUNT: 10,
FULL_RESCUE_BONUS: 5000,
ENEMY_KILL_SCORE: { swarmer: 50, walker: 150, abductor: 120 },
BOSS_KILL_SCORE: 3000,
HUMANOID_RESCUE_SCORE: 400,
};
// Entity ids are assigned from a per-state counter (not a module-level one) so
// that replaying the same seed from a fresh createGame() is fully
// reproducible regardless of how many other games ran earlier in the process.
function nid(state) { return state.nextId += 1; }
// ---------------------------------------------------------------------------
// Wave / boss authoring — formula-based per (level, wave), same idiom as
// Tempest's per-level tuning functions (flipperSpeed(level), spawnInterval(level), …).
export function waveSpec(level, wave) {
const p = (level - 1) * TUNE.WAVES_PER_LEVEL + (wave - 1); // 0..19 overall progress index
return {
swarmerCount: 8 + p * 3,
packSize: Math.min(TUNE.SWARM_PACK_SIZE_MAX, TUNE.SWARM_PACK_SIZE_MIN + Math.floor(p / 2)),
walkerCount: Math.max(0, Math.floor((p - 1) / 3)),
abductorCount: Math.max(1, Math.floor(p / 3) + 1),
spawnIntervalMs: Math.max(260, 900 - p * 26),
};
}
// One distinct, escalating boss encounter per level — not just more HP, but a
// genuinely different attack repertoire each time. `moves` is round-robined
// every attack (so a fight never just repeats one pattern), `phase2Moves`
// (if present) takes over once the boss drops below half health, and
// `speedMult`/`cooldownMs` layer movement and attack-rate pressure on top so
// later fights are harder along every axis at once, not just bullet variety.
export const BOSS_PROFILES = [
{ // Level 1 — Sentinel: a single steady ring burst. The introduction.
name: 'SENTINEL', moves: ['ring'], speedMult: 1, cooldownMs: 1700,
},
{ // Level 2 — Ravager: rams hard and sprays a forward shotgun spread.
name: 'RAVAGER', moves: ['spread'], speedMult: 1.8, cooldownMs: 1450,
},
{ // Level 3 — Swarmlord: a rotating bullet spiral plus reinforcement waves —
// now you're managing adds and dodging a moving pattern at the same time.
name: 'SWARMLORD', moves: ['spiral', 'reinforce'], speedMult: 1.2, cooldownMs: 1300,
},
{ // Level 4 — Warden: alternates area-denial and player-tracking fire; past
// half health it adds the spiral and reinforcements too. First two-phase fight.
name: 'WARDEN', moves: ['ring', 'aimed'], phase2Moves: ['spiral', 'aimed', 'reinforce'],
speedMult: 1.4, cooldownMs: 1150,
},
{ // Level 5 — Overlord: the full arsenal from every earlier fight, fastest
// base cooldown, and a dense bullet-wall move once it's wounded.
name: 'OVERLORD', moves: ['ring', 'spread', 'aimed'], phase2Moves: ['spiral', 'wall', 'aimed', 'reinforce'],
speedMult: 1.6, cooldownMs: 950,
},
];
export function bossProfileFor(level) { return BOSS_PROFILES[(level - 1) % BOSS_PROFILES.length]; }
export function bossSpec(level) {
return {
name: bossProfileFor(level).name,
hp: TUNE.BOSS_HP_BASE + (level - 1) * TUNE.BOSS_HP_STEP,
};
}
// ---------------------------------------------------------------------------
// Entity factories
function makePlayer() {
return {
x: WORLD_W / 2, y: (Y_MIN + Y_GROUND) / 2, vx: 0, vy: 0, facing: 1,
alive: true, invulnMs: 0, respawnMs: 0,
fireCooldownMs: 0, carrying: null,
};
}
function makeExtractionZones(rng) {
const zones = [];
const spacing = WORLD_W / TUNE.EXTRACTION_ZONES_PER_LEVEL;
for (let i = 0; i < TUNE.EXTRACTION_ZONES_PER_LEVEL; i += 1) {
zones.push({ x: wrap(spacing * i + spacing * 0.5 + (rng() - 0.5) * spacing * 0.3) });
}
return zones;
}
function spawnSwarmerPack(state, count) {
const cx = wrap(state.rng() * WORLD_W);
const cy = Y_MIN + state.rng() * (Y_GROUND - Y_MIN - 200);
for (let i = 0; i < count && state.enemies.filter((e) => e.type === 'swarmer').length < TUNE.SWARM_MAX_CONCURRENT; i += 1) {
state.enemies.push({
id: nid(state), type: 'swarmer', hp: TUNE.SWARMER_HP, radius: TUNE.SWARMER_RADIUS,
x: wrap(cx + (state.rng() - 0.5) * 80),
y: cy + (state.rng() - 0.5) * 80,
vx: (state.rng() - 0.5) * 40, vy: (state.rng() - 0.5) * 40,
});
}
}
function spawnWalker(state) {
const x = wrap(state.rng() * WORLD_W);
state.enemies.push({
id: nid(state), type: 'walker', hp: TUNE.WALKER_HP, radius: TUNE.WALKER_RADIUS,
x, y: Y_GROUND, vx: 0, vy: 0,
homeX: x, dir: state.rng() < 0.5 ? -1 : 1, fireCooldownMs: TUNE.WALKER_FIRE_COOLDOWN_MS * state.rng(),
});
}
function spawnAbductor(state) {
state.enemies.push({
id: nid(state), type: 'abductor', hp: TUNE.ABDUCTOR_HP, radius: TUNE.ABDUCTOR_RADIUS,
x: wrap(state.rng() * WORLD_W), y: Y_MIN + 20, vx: 0, vy: 0,
targetHumanoidId: null, carryingId: null,
});
}
function spawnHumanoids(state, count) {
const used = new Set();
for (let i = 0; i < count; i += 1) {
let x;
do { x = wrap(state.rng() * WORLD_W); } while (used.has(Math.floor(x / 120)));
used.add(Math.floor(x / 120));
state.humanoids.push({
id: nid(state), status: 'idle', x, y: Y_GROUND, vx: 0, vy: 0,
grabberId: null, carrierIsPlayer: false, timerMs: 0,
});
}
}
function startWave(state, wave) {
state.wave = wave;
state.phase = 'waveIntro';
state.phaseMs = 0;
const spec = waveSpec(state.level, wave);
const queue = [];
for (let i = 0; i < Math.ceil(spec.swarmerCount / spec.packSize); i += 1) {
queue.push({ kind: 'swarmerPack', count: Math.min(spec.packSize, spec.swarmerCount - i * spec.packSize) });
}
for (let i = 0; i < spec.walkerCount; i += 1) queue.push({ kind: 'walker' });
for (let i = 0; i < spec.abductorCount; i += 1) queue.push({ kind: 'abductor' });
state.spawnQueue = queue;
state.spawnTimerMs = 0;
state.spawnIntervalMs = spec.spawnIntervalMs;
}
export function createGame(opts = {}) {
const seed = opts.seed ?? 1;
const rng = mulberry32(seed);
const state = {
seed, rng, nextId: 1,
level: opts.startLevel ?? 1,
wave: 1, phase: 'waveIntro', phaseMs: 0,
accumulatorMs: 0, alpha: 0, timeMs: 0,
player: makePlayer(),
enemies: [], humanoids: [], shots: [], enemyShots: [], boss: null,
score: 0, lives: TUNE.PLAYER_LIVES_START,
multiplier: 1, lastKillMs: -Infinity,
overdriveMeter: 0, overdriveActive: false, overdriveMsLeft: 0,
rescuedThisLevel: 0, lostThisLevel: 0,
extractionZones: makeExtractionZones(rng),
spawnQueue: [], spawnTimerMs: 0, spawnIntervalMs: 800,
input: { up: false, down: false, left: false, right: false, fire: false, overdrive: false },
over: false, victory: false,
};
spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL);
startWave(state, 1);
return state;
}
export function setInput(state, patch) {
Object.assign(state.input, patch);
}
// ---------------------------------------------------------------------------
// Per-tick subsystems
function timescale(state) {
return state.overdriveActive ? TUNE.OVERDRIVE_TIMESCALE : 1;
}
function updatePlayer(state, dt, events) {
const p = state.player;
if (!p.alive) {
p.respawnMs -= dt;
if (p.respawnMs <= 0) {
p.alive = true;
p.x = wrap(p.x);
p.y = (Y_MIN + Y_GROUND) / 2;
p.vx = 0; p.vy = 0;
p.invulnMs = TUNE.RESPAWN_INVULN_MS;
p.carrying = null;
events.push({ type: 'playerRespawned' });
}
return;
}
if (p.invulnMs > 0) p.invulnMs -= dt;
const { input } = state;
const ax = (input.right ? 1 : 0) - (input.left ? 1 : 0);
const ay = (input.down ? 1 : 0) - (input.up ? 1 : 0);
const dtS = dt / 1000;
p.vx += ax * TUNE.PLAYER_ACCEL_X * dtS;
p.vy += ay * TUNE.PLAYER_ACCEL_Y * dtS;
const drag = 1 / (1 + TUNE.PLAYER_DRAG * dtS);
p.vx *= drag; p.vy *= drag;
// Independent per-axis clamps (not a combined-magnitude clamp) so the much
// higher horizontal cap isn't diluted whenever the player is also holding
// a vertical direction.
p.vx = Math.max(-TUNE.PLAYER_MAX_SPEED_X, Math.min(TUNE.PLAYER_MAX_SPEED_X, p.vx));
p.vy = Math.max(-TUNE.PLAYER_MAX_SPEED_Y, Math.min(TUNE.PLAYER_MAX_SPEED_Y, p.vy));
p.x = wrap(p.x + p.vx * dtS);
p.y = Math.min(Y_MAX, Math.max(Y_MIN, p.y + p.vy * dtS));
if (ax > 0) p.facing = 1; else if (ax < 0) p.facing = -1;
p.fireCooldownMs -= dt;
if (input.fire && p.fireCooldownMs <= 0) {
p.fireCooldownMs = TUNE.PLAYER_FIRE_COOLDOWN_MS;
state.shots.push({
x: p.x, y: p.y, vx: TUNE.PLAYER_SHOT_SPEED * p.facing, vy: 0, ttlMs: TUNE.PLAYER_SHOT_TTL_MS,
});
events.push({ type: 'shotFired' });
}
// Overdrive trigger
if (input.overdrive && !state.overdriveActive && state.overdriveMeter >= 1) {
state.overdriveActive = true;
state.overdriveMsLeft = TUNE.OVERDRIVE_DURATION_MS;
events.push({ type: 'overdriveStart' });
}
// Carried humanoid follows the player, and can be dropped at an extraction zone.
if (p.carrying != null) {
const h = state.humanoids.find((hh) => hh.id === p.carrying);
if (h) {
h.x = wrap(p.x - p.facing * 24);
h.y = p.y + 24;
h.timerMs += dt;
const nearZone = state.extractionZones.some((z) => Math.abs(tdelta(p.x, z.x)) < TUNE.EXTRACT_RADIUS);
if (nearZone) {
h.status = 'rescued';
p.carrying = null;
state.rescuedThisLevel += 1;
state.score += TUNE.HUMANOID_RESCUE_SCORE;
events.push({ type: 'humanoidRescued', id: h.id });
} else if (h.timerMs >= TUNE.CARRY_TIMEOUT_MS) {
h.status = 'lost';
p.carrying = null;
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'carryTimeout' });
}
} else {
p.carrying = null;
}
} else {
// Auto-pickup: a falling humanoid within pickup radius, if not already carried.
for (const h of state.humanoids) {
if (h.status !== 'falling') continue;
if (tdist(p.x, p.y, h.x, h.y) <= TUNE.PICKUP_RADIUS) {
h.status = 'carried';
h.timerMs = 0;
p.carrying = h.id;
events.push({ type: 'humanoidPickedUp', id: h.id });
break;
}
}
}
}
function killPlayer(state, events) {
const p = state.player;
if (!p.alive || p.invulnMs > 0) return;
p.alive = false;
p.respawnMs = TUNE.RESPAWN_DELAY_MS;
if (p.carrying != null) {
const h = state.humanoids.find((hh) => hh.id === p.carrying);
if (h) {
h.status = 'lost';
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'playerDied' });
}
p.carrying = null;
}
state.lives -= 1;
events.push({ type: 'playerDied' });
if (state.lives < 0) {
state.over = true;
state.phase = 'gameOver';
events.push({ type: 'gameOver', score: state.score, level: state.level });
}
}
function neighborForces(e, list, dt) {
let sepX = 0; let sepY = 0; let cohX = 0; let cohY = 0; let aliX = 0; let aliY = 0; let n = 0;
for (const o of list) {
if (o === e) continue;
const dx = tdelta(e.x, o.x);
const dy = o.y - e.y;
const d = Math.hypot(dx, dy) || 0.001;
if (d < TUNE.SEPARATION_RADIUS) { sepX -= dx / d; sepY -= dy / d; }
if (d < TUNE.COHESION_RADIUS) { cohX += dx; cohY += dy; n += 1; }
if (d < TUNE.ALIGNMENT_RADIUS) { aliX += o.vx; aliY += o.vy; }
}
if (n > 0) { cohX /= n; cohY /= n; aliX /= n; aliY /= n; }
return {
fx: sepX * TUNE.SEPARATION_W + cohX * TUNE.COHESION_W * 0.02 + aliX * TUNE.ALIGNMENT_W * 0.02,
fy: sepY * TUNE.SEPARATION_W + cohY * TUNE.COHESION_W * 0.02 + aliY * TUNE.ALIGNMENT_W * 0.02,
};
}
function updateSwarmers(state, dt) {
const dtS = dt / 1000;
const swarmers = state.enemies.filter((e) => e.type === 'swarmer');
const p = state.player;
for (const e of swarmers) {
const { fx, fy } = neighborForces(e, swarmers, dt);
e.vx += fx * dt; e.vy += fy * dt;
const dToPlayer = tdist(e.x, e.y, p.x, p.y);
if (dToPlayer < TUNE.SEEK_RADIUS && p.alive) {
const dx = tdelta(e.x, p.x); const dy = p.y - e.y;
const d = Math.hypot(dx, dy) || 1;
e.vx += (dx / d) * TUNE.SEEK_PLAYER_W * dt;
e.vy += (dy / d) * TUNE.SEEK_PLAYER_W * dt;
}
const sp = Math.hypot(e.vx, e.vy);
if (sp > TUNE.SWARMER_SPEED) { const k = TUNE.SWARMER_SPEED / sp; e.vx *= k; e.vy *= k; }
e.x = wrap(e.x + e.vx * dtS);
e.y = Math.min(Y_GROUND - 40, Math.max(Y_MIN, e.y + e.vy * dtS));
}
}
function updateWalkers(state, dt, events) {
const dtS = dt / 1000;
for (const e of state.enemies) {
if (e.type !== 'walker') continue;
const dHome = tdelta(e.homeX, e.x);
if (Math.abs(dHome) > TUNE.WALKER_PATROL_RANGE) e.dir = dHome > 0 ? -1 : 1;
e.x = wrap(e.x + e.dir * TUNE.WALKER_SPEED * dtS);
e.fireCooldownMs -= dt;
const dToPlayer = tdist(e.x, e.y, state.player.x, state.player.y);
if (state.player.alive && dToPlayer < TUNE.WALKER_AIM_RANGE && e.fireCooldownMs <= 0) {
e.fireCooldownMs = TUNE.WALKER_FIRE_COOLDOWN_MS;
const dx = tdelta(e.x, state.player.x); const dy = state.player.y - e.y;
const d = Math.hypot(dx, dy) || 1;
state.enemyShots.push({
x: e.x, y: e.y, vx: (dx / d) * TUNE.WALKER_SHOT_SPEED, vy: (dy / d) * TUNE.WALKER_SHOT_SPEED,
radius: TUNE.WALKER_SHOT_RADIUS, ttlMs: 2200,
});
events.push({ type: 'shotFired', enemy: true });
}
}
}
function updateAbductors(state, dt, events) {
const dtS = dt / 1000;
for (const e of state.enemies) {
if (e.type !== 'abductor') continue;
if (e.carryingId == null) {
// seek an idle humanoid
if (e.targetHumanoidId == null) {
const idle = state.humanoids.filter((h) => h.status === 'idle');
if (idle.length) {
idle.sort((a, b) => Math.abs(tdelta(e.x, a.x)) - Math.abs(tdelta(e.x, b.x)));
e.targetHumanoidId = idle[0].id;
}
}
const target = state.humanoids.find((h) => h.id === e.targetHumanoidId && h.status === 'idle');
if (target) {
const dx = tdelta(e.x, target.x); const dy = target.y - e.y;
const d = Math.hypot(dx, dy) || 1;
e.x = wrap(e.x + (dx / d) * TUNE.ABDUCTOR_SPEED * dtS);
e.y += (dy / d) * TUNE.ABDUCTOR_SPEED * dtS;
if (d < TUNE.ABDUCTOR_GRAB_RADIUS) {
target.status = 'grabbed';
target.grabberId = e.id;
e.carryingId = target.id;
events.push({ type: 'humanoidGrabbed', id: target.id });
}
} else {
e.targetHumanoidId = null;
}
} else {
const h = state.humanoids.find((hh) => hh.id === e.carryingId);
if (!h || h.status !== 'grabbed') { e.carryingId = null; continue; }
e.y -= TUNE.ABDUCTOR_RISE_SPEED * dtS;
h.x = e.x; h.y = e.y + 20;
if (e.y <= Y_SKY) {
h.status = 'lost';
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: 'escaped' });
e.carryingId = null;
e.targetHumanoidId = null;
// The abductor escapes with its prize — remove it from play.
e.dead = true;
}
}
}
}
function updateHumanoids(state, dt, events) {
const dtS = dt / 1000;
for (const h of state.humanoids) {
if (h.status === 'falling') {
h.timerMs += dt;
// Constant, gentle descent — no acceleration, so a catch attempt is just as
// makeable in the last moment as it was at the start of the fall.
h.y += TUNE.FALL_SPEED * dtS;
if (h.y >= Y_GROUND || h.timerMs >= TUNE.GRAB_WINDOW_MS) {
h.status = 'lost';
state.lostThisLevel += 1;
events.push({ type: 'humanoidLost', id: h.id, x: h.x, y: h.y, reason: h.y >= Y_GROUND ? 'hitGround' : 'grabWindow' });
}
}
}
}
// A grabbed humanoid's carrying abductor may die mid-carry — releases it to fall.
function releaseGrabbedHumanoids(state, deadAbductorIds, events) {
if (!deadAbductorIds.size) return;
for (const h of state.humanoids) {
if (h.status === 'grabbed' && deadAbductorIds.has(h.grabberId)) {
h.status = 'falling';
h.vy = TUNE.FALL_SPEED;
h.timerMs = 0;
events.push({ type: 'humanoidFreed', id: h.id });
}
}
}
function updateShots(state, dt) {
const dtS = dt / 1000;
for (const s of state.shots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; }
state.shots = state.shots.filter((s) => s.ttlMs > 0);
for (const s of state.enemyShots) { s.x = wrap(s.x + s.vx * dtS); s.y += s.vy * dtS; s.ttlMs -= dt; }
state.enemyShots = state.enemyShots.filter((s) => s.ttlMs > 0);
}
function circleHit(ax, ay, ar, bx, by, br) {
const dx = tdelta(ax, bx); const dy = ay - by;
const r = ar + br;
return dx * dx + dy * dy <= r * r;
}
function registerKill(state, enemy, events) {
const now = state.timeMs;
if (now - state.lastKillMs <= TUNE.COMBO_WINDOW_MS) {
state.multiplier = Math.min(TUNE.MULT_MAX, state.multiplier + 1);
} else {
state.multiplier = 1;
}
state.lastKillMs = now;
state.overdriveMeter = Math.min(1, state.overdriveMeter + TUNE.OVERDRIVE_FILL_PER_KILL);
if (state.overdriveMeter >= 1) events.push({ type: 'overdriveReady' });
const base = TUNE.ENEMY_KILL_SCORE[enemy.type] ?? 50;
const mult = state.overdriveActive ? TUNE.OVERDRIVE_SCORE_MULT : state.multiplier;
state.score += base * mult;
events.push({ type: 'enemyKilled', enemyType: enemy.type, x: enemy.x, y: enemy.y, multiplier: state.multiplier });
}
function handleCollisions(state, events) {
const p = state.player;
const deadAbductorIds = new Set();
// player shots vs enemies
for (const s of state.shots) {
for (const e of state.enemies) {
if (e.dead || s.dead) continue;
if (circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, e.x, e.y, e.radius)) {
s.dead = true;
e.hp -= 1;
events.push({ type: 'enemyHit', id: e.id });
if (e.hp <= 0) {
e.dead = true;
if (e.type === 'abductor') deadAbductorIds.add(e.id);
registerKill(state, e, events);
}
}
}
// player shots vs boss
if (state.boss && !s.dead && circleHit(s.x, s.y, TUNE.PLAYER_SHOT_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) {
s.dead = true;
state.boss.hp -= 1;
events.push({ type: 'enemyHit', boss: true });
}
}
state.shots = state.shots.filter((s) => !s.dead);
releaseGrabbedHumanoids(state, deadAbductorIds, events);
state.enemies = state.enemies.filter((e) => !e.dead);
// enemy bodies / enemy shots vs player
if (p.alive && p.invulnMs <= 0 && !state.overdriveActive) {
for (const e of state.enemies) {
if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, e.x, e.y, e.radius)) { killPlayer(state, events); break; }
}
if (p.alive) {
for (const s of state.enemyShots) {
if (circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, s.x, s.y, s.radius)) { s.dead = true; killPlayer(state, events); break; }
}
}
if (p.alive && state.boss && circleHit(p.x, p.y, TUNE.PLAYER_RADIUS, state.boss.x, state.boss.y, TUNE.BOSS_RADIUS)) {
killPlayer(state, events);
}
}
state.enemyShots = state.enemyShots.filter((s) => !s.dead);
}
// ---------------------------------------------------------------------------
// Boss
function spawnBoss(state, events) {
const spec = bossSpec(state.level);
state.boss = {
name: spec.name, hp: spec.hp, maxHp: spec.hp,
x: wrap(state.player.x + WORLD_W / 2), y: (Y_MIN + Y_GROUND) / 2,
dir: 1, attackCooldownMs: bossProfileFor(state.level).cooldownMs, phase: 1,
moveIdx: 0, spiralAngle: 0,
};
events.push({ type: 'bossSpawn', kind: spec.name });
}
function fireBossShot(state, boss, angle, speed) {
state.enemyShots.push({
x: boss.x, y: boss.y, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed,
radius: 8, ttlMs: 2600,
});
}
// Every attack pattern a boss can draw from. Each is a genuinely different
// shape/behavior (not a recolor of another), so "harder boss" means "new
// things to read and dodge", not just "more of the same bullets".
function performBossMove(state, boss, move, events) {
const p = state.player;
switch (move) {
case 'ring': // static radial burst — the baseline area-denial pattern
for (let i = 0; i < TUNE.BOSS_SPOKE_COUNT; i += 1) {
fireBossShot(state, boss, (i / TUNE.BOSS_SPOKE_COUNT) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED);
}
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
case 'spread': { // a forward shotgun cone toward the player's general side
const base = tdelta(boss.x, p.x) >= 0 ? 0 : Math.PI;
for (const off of [-0.5, -0.25, 0, 0.25, 0.5]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.1);
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
}
case 'spiral': // three arms that rotate a bit further each time this move fires
for (const off of [0, (Math.PI * 2) / 3, (Math.PI * 4) / 3]) {
fireBossShot(state, boss, boss.spiralAngle + off, TUNE.BOSS_SHOT_SPEED * 0.85);
}
boss.spiralAngle += 0.5;
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
case 'aimed': { // tracks the player directly — punishes standing still
const base = Math.atan2(p.y - boss.y, tdelta(boss.x, p.x));
for (const off of [-0.12, 0, 0.12]) fireBossShot(state, boss, base + off, TUNE.BOSS_SHOT_SPEED * 1.3);
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
}
case 'wall': { // a dense ring, twice the density of 'ring' — find the gap
const count = TUNE.BOSS_SPOKE_COUNT * 2;
for (let i = 0; i < count; i += 1) fireBossShot(state, boss, (i / count) * Math.PI * 2, TUNE.BOSS_SHOT_SPEED * 0.9);
events.push({ type: 'shotFired', enemy: true, boss: true });
break;
}
case 'reinforce': // calls in a swarmer pack — now you're managing adds too
spawnSwarmerPack(state, 6);
break;
default: break;
}
}
function updateBoss(state, dt, events) {
const boss = state.boss;
if (!boss) return;
const dtS = dt / 1000;
const profile = bossProfileFor(state.level);
const wasPhase = boss.phase;
boss.phase = boss.hp > boss.maxHp / 2 ? 1 : 2;
if (boss.phase !== wasPhase) events.push({ type: 'bossPhaseChange', phase: boss.phase, kind: boss.name });
const moves = (boss.phase === 2 && profile.phase2Moves) ? profile.phase2Moves : profile.moves;
boss.x = wrap(boss.x + boss.dir * TUNE.BOSS_SPEED * profile.speedMult * dtS);
const dHome = tdelta(state.player.x - WORLD_W / 2, boss.x); // roam the far side of the ring
if (Math.abs(dHome) > WORLD_W * 0.3) boss.dir *= -1;
boss.attackCooldownMs -= dt;
if (boss.attackCooldownMs <= 0 && state.player.alive) {
// Phase 2 also attacks a little faster, on top of whatever new moves it unlocked.
boss.attackCooldownMs = profile.cooldownMs * (boss.phase === 2 ? 0.8 : 1);
const move = moves[boss.moveIdx % moves.length];
boss.moveIdx += 1;
events.push({ type: 'bossMove', move, phase: boss.phase });
performBossMove(state, boss, move, events);
}
if (boss.hp <= 0) {
events.push({ type: 'bossDefeated', kind: boss.name });
state.score += TUNE.BOSS_KILL_SCORE;
state.boss = null;
}
}
// ---------------------------------------------------------------------------
// Wave / level progression
function trySpawnFromQueue(state, dt) {
if (!state.spawnQueue.length) return;
state.spawnTimerMs -= dt;
if (state.spawnTimerMs > 0) return;
state.spawnTimerMs = state.spawnIntervalMs;
const next = state.spawnQueue.shift();
if (next.kind === 'swarmerPack') spawnSwarmerPack(state, next.count);
else if (next.kind === 'walker') spawnWalker(state);
else if (next.kind === 'abductor') spawnAbductor(state);
}
function updatePhase(state, dt, events) {
state.phaseMs += dt;
switch (state.phase) {
case 'waveIntro':
if (state.phaseMs >= TUNE.WAVE_BREATHER_MS * 0.4) {
state.phase = 'wave';
state.phaseMs = 0;
events.push({ type: 'waveStart', level: state.level, wave: state.wave });
}
break;
case 'wave':
trySpawnFromQueue(state, dt);
if (!state.spawnQueue.length && state.enemies.length === 0) {
state.phase = 'waveClear';
state.phaseMs = 0;
events.push({ type: 'waveClear', level: state.level, wave: state.wave });
}
break;
case 'waveClear':
if (state.phaseMs >= TUNE.WAVE_BREATHER_MS) {
if (state.wave < TUNE.WAVES_PER_LEVEL) {
startWave(state, state.wave + 1);
} else {
state.phase = 'bossIntro';
state.phaseMs = 0;
}
}
break;
case 'bossIntro':
if (state.phaseMs >= TUNE.BOSS_INTRO_MS) {
spawnBoss(state, events);
state.phase = 'boss';
state.phaseMs = 0;
}
break;
case 'boss':
updateBoss(state, dt, events);
if (!state.boss) {
state.phase = 'levelComplete';
state.phaseMs = 0;
const fullRescue = state.lostThisLevel === 0 && state.rescuedThisLevel === TUNE.HUMANOIDS_PER_LEVEL;
if (fullRescue) {
state.score += TUNE.FULL_RESCUE_BONUS;
state.lives += 1;
}
events.push({
type: 'levelComplete', level: state.level,
rescued: state.rescuedThisLevel, lost: state.lostThisLevel, fullRescue,
});
}
break;
case 'levelComplete':
if (state.phaseMs >= TUNE.BOSS_OUTRO_MS) {
if (state.level >= TUNE.LEVEL_COUNT) {
state.victory = true;
state.over = true;
state.phase = 'victory';
events.push({ type: 'victory', score: state.score });
} else {
state.level += 1;
state.rescuedThisLevel = 0;
state.lostThisLevel = 0;
state.humanoids = state.humanoids.filter((h) => h.status !== 'rescued' && h.status !== 'lost');
spawnHumanoids(state, TUNE.HUMANOIDS_PER_LEVEL);
state.extractionZones = makeExtractionZones(state.rng);
startWave(state, 1);
}
}
break;
default: break;
}
}
function updateOverdrive(state, dt, events) {
if (!state.overdriveActive) return;
state.overdriveMsLeft -= dt;
state.overdriveMeter = Math.max(0, state.overdriveMeter - dt / TUNE.OVERDRIVE_DURATION_MS);
if (state.overdriveMsLeft <= 0 || state.overdriveMeter <= 0) {
state.overdriveActive = false;
state.overdriveMeter = 0;
events.push({ type: 'overdriveEnd' });
}
}
function updateCombo(state) {
if (state.timeMs - state.lastKillMs > TUNE.COMBO_WINDOW_MS) state.multiplier = 1;
}
// ---------------------------------------------------------------------------
// Master tick — one fixed STEP_MS of simulation.
function tick(state) {
const events = [];
if (state.over) return events;
const dt = STEP_MS * timescale(state);
state.timeMs += dt;
updatePlayer(state, state.player.alive ? dt : STEP_MS, events);
updateSwarmers(state, dt);
updateWalkers(state, dt, events);
updateAbductors(state, dt, events);
updateHumanoids(state, dt, events);
updateShots(state, dt);
handleCollisions(state, events);
updatePhase(state, dt, events);
updateOverdrive(state, dt, events);
updateCombo(state);
return events;
}
export function step(state, deltaMs) {
const out = [];
state.accumulatorMs += deltaMs;
let n = 0;
while (state.accumulatorMs >= STEP_MS && n < MAX_STEPS) {
state.accumulatorMs -= STEP_MS;
const ev = tick(state);
for (let i = 0; i < ev.length; i += 1) out.push(ev[i]);
n += 1;
if (state.over) break;
}
if (n === MAX_STEPS && state.accumulatorMs >= STEP_MS) state.accumulatorMs = 0; // spiral-of-death guard
state.alpha = Math.min(1, state.accumulatorMs / STEP_MS);
return out;
}

View File

@ -1,90 +0,0 @@
// A stroked vector font for Defender — letters and digits as line segments in
// a 4x6 unit cell, drawn as a glow-stroke pair to match the game's wireframe
// look (same convention as Tempest's TempestVectorFont.js, copied verbatim
// since the glyph set itself is generic).
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]]],
'×': [[[0, 0], [4, 6]], [[4, 0], [0, 6]]],
};
function strokePoly(g, pts) {
g.beginPath();
g.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) g.lineTo(pts[i][0], pts[i][1]);
g.strokePath();
}
export function measureVectorText(text, scale) {
let w = 0;
for (const ch of text.toUpperCase()) {
w += (ch === ' ' ? SPACE_W : GLYPH_W + GLYPH_GAP) * scale;
}
return w - GLYPH_GAP * scale;
}
export function drawVectorText(g, text, cx, cy, scale, color, options = {}) {
const { lineWidth = 3, glowWidth = 9, glowAlpha = 0.18, alpha = 1 } = options;
const totalW = measureVectorText(text, scale);
let x = cx - totalW / 2;
const y = cy - (GLYPH_H * scale) / 2;
for (const ch of text.toUpperCase()) {
if (ch === ' ') { x += SPACE_W * scale; continue; }
const strokes = GLYPHS[ch];
if (strokes) {
for (const poly of strokes) {
const pts = poly.map(([ux, uy]) => [x + ux * scale, y + uy * scale]);
g.lineStyle(glowWidth, color, glowAlpha * alpha);
strokePoly(g, pts);
g.lineStyle(lineWidth, color, alpha);
strokePoly(g, pts);
}
}
x += (GLYPH_W + GLYPH_GAP) * scale;
}
}

View File

@ -1,91 +0,0 @@
// Additive neon bloom for Geometry Wars' world camera.
//
// Phaser's built-in Bloom FX finishes with a 50/50 linear mix of the sharp
// frame and the blurred one, which halves the brightness of every thin line
// — exactly the wrong trade for a game made of 2px neon strokes. This is the
// same ping-pong Gaussian blur (same onDraw shape as Phaser's
// BloomFXPipeline) but composited with blendFramesAdditive, so cores stay at
// full brightness and the glow is added on top.
//
// Canvas renderer gets a no-op stub. PostFXPipeline convention follows
// src/ui/ArcadeCurvePipeline.js.
import * as Phaser from 'phaser';
const FRAG = `
precision mediump float;
uniform sampler2D uMainSampler;
uniform vec2 offset;
varying vec2 outTexCoord;
void main(void) {
vec4 sum = texture2D(uMainSampler, outTexCoord) * 0.2270270270;
sum += texture2D(uMainSampler, outTexCoord + offset * 1.3846153846) * 0.3162162162;
sum += texture2D(uMainSampler, outTexCoord - offset * 1.3846153846) * 0.3162162162;
sum += texture2D(uMainSampler, outTexCoord + offset * 3.2307692308) * 0.0702702703;
sum += texture2D(uMainSampler, outTexCoord - offset * 3.2307692308) * 0.0702702703;
gl_FragColor = sum;
}
`;
// Tuning knobs — radius is in ~2px units per blur tap, strength is how much
// of the blurred frame gets added back on top.
export const GLOW_LEVELS = {
off: null,
low: { radius: 1.2, strength: 0.75, steps: 3 },
high: { radius: 1.8, strength: 1.2, steps: 4 },
};
export class NeonBloomPipeline extends Phaser.Renderer.WebGL.Pipelines.PostFXPipeline {
constructor(game) {
super({ game, name: 'GWNeonBloom', fragShader: FRAG });
this.radius = GLOW_LEVELS.high.radius;
this.strength = GLOW_LEVELS.high.strength;
this.steps = GLOW_LEVELS.high.steps;
}
onDraw(target1) {
const target2 = this.fullFrame1;
const target3 = this.fullFrame2;
this.copyFrame(target1, target3);
const x = (2 / target1.width) * this.radius;
const y = (2 / target1.height) * this.radius;
// set2f only rebinds this pipeline's program when the value changes;
// alternating (x,0)/(0,y) guarantees it does after copyFrame bound the
// utility shader (same reliance as Phaser's own BloomFXPipeline).
for (let i = 0; i < this.steps; i += 1) {
this.set2f('offset', x, 0);
this.copySprite(target1, target2);
this.set2f('offset', 0, y);
this.copySprite(target2, target1);
}
this.blendFramesAdditive(target3, target1, target2, this.strength);
this.copyToGame(target2);
}
}
export function attachNeonBloom(scene, camera, level = 'high') {
if (!scene.renderer || scene.renderer.type !== Phaser.WEBGL) {
return { setLevel() {}, destroy() {} };
}
scene.renderer.pipelines.addPostPipeline('GWNeonBloom', NeonBloomPipeline);
let attached = false;
const setLevel = (lvl) => {
const cfg = GLOW_LEVELS[lvl] ?? null;
if (!cfg) {
if (attached) { camera.removePostPipeline(NeonBloomPipeline); attached = false; }
return;
}
if (!attached) { camera.setPostPipeline(NeonBloomPipeline); attached = true; }
const got = camera.getPostPipeline(NeonBloomPipeline);
const inst = Array.isArray(got) ? got[0] : got;
if (inst) Object.assign(inst, cfg);
};
setLevel(level);
return {
setLevel,
destroy() {
if (attached) { try { camera.removePostPipeline(NeonBloomPipeline); } catch (_) { /* camera gone */ } }
attached = false;
},
};
}

View File

@ -1,182 +0,0 @@
// Particle field + shockwave rings for Geometry Wars.
//
// Particles are a fixed-size struct-of-arrays pool (oldest slot reused when
// full) drawn as velocity-aligned streaks — the look of a Geometry Wars
// explosion is thousands of short neon dashes slowing down, bouncing off the
// arena walls and getting sucked into black holes. Drawing is batched by
// (colour, alpha band, width) so a full screen costs a few dozen
// lineStyle switches rather than one per particle.
//
// Phaser-free: draw() only calls Graphics methods on the object it is given.
export const FX_MAX = 2800;
const ALPHA_BANDS = 4;
const WIDTHS = [2, 3.4];
export default class ParticleField {
constructor(bounds) {
this.bounds = bounds; // { w, h }
const n = FX_MAX;
this.x = new Float32Array(n); this.y = new Float32Array(n);
this.vx = new Float32Array(n); this.vy = new Float32Array(n);
this.life = new Float32Array(n); this.maxLife = new Float32Array(n);
this.len = new Float32Array(n); this.damp = new Float32Array(n);
this.col = new Uint8Array(n); this.wid = new Uint8Array(n);
this.cursor = 0;
this.palette = [];
this.paletteIndex = new Map();
this.rings = [];
this.buckets = new Map();
}
colorId(color) {
let id = this.paletteIndex.get(color);
if (id === undefined) {
id = this.palette.length;
if (id >= 255) return 0;
this.palette.push(color);
this.paletteIndex.set(color, id);
}
return id;
}
liveCount() {
let n = 0;
for (let i = 0; i < FX_MAX; i += 1) if (this.life[i] > 0) n += 1;
return n;
}
clear() {
this.life.fill(0);
this.rings.length = 0;
}
emit(x, y, vx, vy, lifeMs, color, opts = {}) {
const i = this.cursor;
this.cursor = (i + 1) % FX_MAX;
this.x[i] = x; this.y[i] = y;
this.vx[i] = vx; this.vy[i] = vy;
this.life[i] = lifeMs; this.maxLife[i] = lifeMs;
this.len[i] = opts.len ?? 1;
this.damp[i] = opts.damp ?? 0.955;
this.col[i] = this.colorId(color);
this.wid[i] = opts.wide ? 1 : 0;
}
// Classic radial burst. Speeds follow 1 - 1/rand so most sparks are fast
// and a few linger near the centre, which reads as a "hot core".
explosion(x, y, color, opts = {}) {
const count = opts.count ?? 60;
const speed = opts.speed ?? 1000;
const life = opts.life ?? 1100;
const color2 = opts.color2 ?? color;
for (let k = 0; k < count; k += 1) {
const a = Math.random() * Math.PI * 2;
const sp = speed * (1 - 1 / (1 + Math.random() * 9));
const c = Math.random() < 0.5 ? color : color2;
this.emit(x, y, Math.cos(a) * sp, Math.sin(a) * sp, life * (0.55 + Math.random() * 0.6), c,
{ len: opts.len ?? 1, wide: opts.wide && Math.random() < 0.5 });
}
}
// Sparks thrown back off a wall hit, reflected about the wall normal.
wallSparks(x, y, angle, color, count = 18) {
const { w, h } = this.bounds;
let nx = 0; let ny = 0;
if (x <= 1) nx = 1; else if (x >= w - 1) nx = -1;
if (y <= 1) ny = 1; else if (y >= h - 1) ny = -1;
let dx = Math.cos(angle); let dy = Math.sin(angle);
if (nx) dx = -dx;
if (ny) dy = -dy;
const base = Math.atan2(dy, dx);
for (let k = 0; k < count; k += 1) {
const a = base + (Math.random() - 0.5) * 1.6;
const sp = 200 + Math.random() * 700;
this.emit(x + nx * 2, y + ny * 2, Math.cos(a) * sp, Math.sin(a) * sp, 380 + Math.random() * 380, color);
}
}
ring(x, y, maxR, lifeMs, color, width = 3, startR = 0) {
if (this.rings.length > 60) this.rings.shift();
this.rings.push({ x, y, r: startR, startR, maxR, life: lifeMs, maxLife: lifeMs, color, width });
}
// holes: [{x, y, r}] — active black holes pull sparks in and spin them.
update(dtMs, holes = []) {
const dt = dtMs / 1000;
if (dt <= 0) return;
const { w, h } = this.bounds;
const f60 = dt * 60;
for (let i = 0; i < FX_MAX; i += 1) {
if (this.life[i] <= 0) continue;
this.life[i] -= dtMs;
if (this.life[i] <= 0) continue;
let vx = this.vx[i]; let vy = this.vy[i];
for (let k = 0; k < holes.length; k += 1) {
const hole = holes[k];
const dx = hole.x - this.x[i]; const dy = hole.y - this.y[i];
const d2 = dx * dx + dy * dy;
if (d2 > 160000) continue;
const d = Math.sqrt(d2) || 1;
if (d < hole.r * 0.6) { this.life[i] = 0; break; }
const pull = 2600 * (1 - d / 400) * dt;
vx += (dx / d) * pull - (dy / d) * pull * 0.9;
vy += (dy / d) * pull + (dx / d) * pull * 0.9;
}
const damp = Math.pow(this.damp[i], f60);
vx *= damp; vy *= damp;
let x = this.x[i] + vx * dt; let y = this.y[i] + vy * dt;
if (x < 0) { x = -x; vx = -vx * 0.6; } else if (x > w) { x = 2 * w - x; vx = -vx * 0.6; }
if (y < 0) { y = -y; vy = -vy * 0.6; } else if (y > h) { y = 2 * h - y; vy = -vy * 0.6; }
this.x[i] = x; this.y[i] = y; this.vx[i] = vx; this.vy[i] = vy;
}
for (const r of this.rings) {
r.life -= dtMs;
const t = 1 - Math.max(0, r.life) / r.maxLife;
r.r = r.startR + (r.maxR - r.startR) * (1 - (1 - t) * (1 - t));
}
if (this.rings.length && this.rings[0].life <= 0) this.rings = this.rings.filter((r) => r.life > 0);
}
draw(g, view) {
const x0 = view.x - 40; const x1 = view.x + view.w + 40;
const y0 = view.y - 40; const y1 = view.y + view.h + 40;
for (const list of this.buckets.values()) list.length = 0;
for (let i = 0; i < FX_MAX; i += 1) {
if (this.life[i] <= 0) continue;
const x = this.x[i]; const y = this.y[i];
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
const f = this.life[i] / this.maxLife[i];
const band = Math.min(ALPHA_BANDS - 1, Math.floor(Math.min(1, f * 1.6) * ALPHA_BANDS));
const key = (this.col[i] * ALPHA_BANDS + band) * 2 + this.wid[i];
let list = this.buckets.get(key);
if (!list) { list = []; this.buckets.set(key, list); }
list.push(i);
}
for (const [key, list] of this.buckets) {
if (list.length === 0) continue;
const wid = key % 2;
const band = Math.floor(key / 2) % ALPHA_BANDS;
const col = Math.floor(key / 2 / ALPHA_BANDS);
g.lineStyle(WIDTHS[wid], this.palette[col], (band + 1) / ALPHA_BANDS);
g.beginPath();
for (const i of list) {
const vx = this.vx[i]; const vy = this.vy[i];
const sp = Math.sqrt(vx * vx + vy * vy);
const L = Math.min(28, Math.max(2.5, sp * 0.02)) * this.len[i];
const ux = sp > 1 ? vx / sp : 1; const uy = sp > 1 ? vy / sp : 0;
g.moveTo(this.x[i] - ux * L, this.y[i] - uy * L);
g.lineTo(this.x[i], this.y[i]);
}
g.strokePath();
}
for (const r of this.rings) {
if (r.life <= 0) continue;
const f = Math.max(0, r.life) / r.maxLife;
g.lineStyle(r.width * 3, r.color, 0.18 * f);
g.strokeCircle(r.x, r.y, r.r);
g.lineStyle(r.width, r.color, 0.85 * f);
g.strokeCircle(r.x, r.y, r.r);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,249 +0,0 @@
// The signature warping background grid: a lattice of point masses joined by
// springs, pushed around by bullets, explosions and black holes, and drawn
// with a slight perspective projection so pushes along z read as bulges.
//
// Phaser-free (it only calls Graphics methods on the object passed to
// draw()), so tools/verifyGeometryWars.js can exercise the physics in Node.
//
// Model after Michael Hoffman's well-known Geometry Wars grid write-up:
// springs only pull (target length is 95% of the rest length, which keeps
// the lattice taut), each mass's damping resets to 0.98 every update, and
// force calls temporarily raise damping so a blast punches in and then
// settles instead of ringing forever. Border masses are pinned outright
// (Hoffman anchors them with springs) so the lattice starts at equilibrium.
// Forces are in px/step units at the fixed 60Hz update rate.
const SPRING_STIFF = 0.28;
const SPRING_DAMP = 0.06;
const ANCHOR_STIFF = 0.002;
const ANCHOR_DAMP = 0.02;
const ANCHOR_EVERY = 3;
const BASE_DAMPING = 0.98;
const PERSPECTIVE = 2000;
export default class WarpGrid {
constructor(width, height, spacing = 40) {
this.width = width;
this.height = height;
this.spacing = spacing;
this.cols = Math.floor(width / spacing) + 1;
this.rows = Math.floor(height / spacing) + 1;
const n = this.cols * this.rows;
this.n = n;
this.px = new Float32Array(n); this.py = new Float32Array(n); this.pz = new Float32Array(n);
this.vx = new Float32Array(n); this.vy = new Float32Array(n); this.vz = new Float32Array(n);
this.ax = new Float32Array(n); this.ay = new Float32Array(n); this.az = new Float32Array(n);
this.ox = new Float32Array(n); this.oy = new Float32Array(n);
this.inv = new Float32Array(n);
this.damp = new Float32Array(n).fill(BASE_DAMPING);
// Projected screen positions, refreshed by draw().
this.sx = new Float32Array(n); this.sy = new Float32Array(n);
for (let r = 0; r < this.rows; r += 1) {
for (let c = 0; c < this.cols; c += 1) {
const i = r * this.cols + c;
const x = c * spacing; const y = r * spacing;
this.px[i] = x; this.py[i] = y;
this.ox[i] = x; this.oy[i] = y;
const border = r === 0 || c === 0 || r === this.rows - 1 || c === this.cols - 1;
this.inv[i] = border ? 0 : 1;
}
}
// Springs: neighbour links (b >= 0) and interior anchors (b === -1,
// pulling toward the mass's own rest position).
const sa = []; const sb = []; const rest = []; const k = []; const damp = [];
for (let r = 0; r < this.rows; r += 1) {
for (let c = 0; c < this.cols; c += 1) {
const i = r * this.cols + c;
if (c < this.cols - 1) { sa.push(i); sb.push(i + 1); rest.push(spacing * 0.95); k.push(SPRING_STIFF); damp.push(SPRING_DAMP); }
if (r < this.rows - 1) { sa.push(i); sb.push(i + this.cols); rest.push(spacing * 0.95); k.push(SPRING_STIFF); damp.push(SPRING_DAMP); }
if (this.inv[i] > 0 && r % ANCHOR_EVERY === 0 && c % ANCHOR_EVERY === 0) {
sa.push(i); sb.push(-1); rest.push(0); k.push(ANCHOR_STIFF); damp.push(ANCHOR_DAMP);
}
}
}
this.sa = Int32Array.from(sa);
this.sb = Int32Array.from(sb);
this.rest = Float32Array.from(rest);
this.k = Float32Array.from(k);
this.sdamp = Float32Array.from(damp);
}
// Index range of lattice cells that could lie within `radius` of (x, y).
// Masses drift from their rest spots, so pad by a couple of cells.
range(x, y, radius) {
const pad = this.spacing * 2;
const s = this.spacing;
return [
Math.max(1, Math.floor((x - radius - pad) / s)),
Math.min(this.cols - 2, Math.ceil((x + radius + pad) / s)),
Math.max(1, Math.floor((y - radius - pad) / s)),
Math.min(this.rows - 2, Math.ceil((y + radius + pad) / s)),
];
}
applyDirectedForce(fx, fy, fz, x, y, radius) {
const [c0, c1, r0, r1] = this.range(x, y, radius);
const r2 = radius * radius;
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * this.cols + c;
const dx = x - this.px[i]; const dy = y - this.py[i]; const dz = this.pz[i];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= r2) continue;
const f = (10 / (10 + Math.sqrt(d2))) * this.inv[i];
this.ax[i] += fx * f; this.ay[i] += fy * f; this.az[i] += fz * f;
}
}
}
applyImplosiveForce(force, x, y, radius) {
const [c0, c1, r0, r1] = this.range(x, y, radius);
const r2 = radius * radius;
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * this.cols + c;
const dx = x - this.px[i]; const dy = y - this.py[i]; const dz = -this.pz[i];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= r2) continue;
const f = ((10 * force) / (100 + d2)) * this.inv[i];
this.ax[i] += dx * f; this.ay[i] += dy * f; this.az[i] += dz * f;
this.damp[i] *= 0.6;
}
}
}
applyExplosiveForce(force, x, y, radius) {
const [c0, c1, r0, r1] = this.range(x, y, radius);
const r2 = radius * radius;
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * this.cols + c;
const dx = this.px[i] - x; const dy = this.py[i] - y; const dz = this.pz[i];
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= r2) continue;
const f = ((100 * force) / (10000 + d2)) * this.inv[i];
this.ax[i] += dx * f; this.ay[i] += dy * f; this.az[i] += dz * f;
this.damp[i] *= 0.6;
}
}
}
// One fixed 60Hz physics step.
update() {
const { px, py, pz, vx, vy, vz, ax, ay, az, inv, sa, sb, rest, k, sdamp } = this;
const m = sa.length;
for (let s = 0; s < m; s += 1) {
const a = sa[s]; const b = sb[s];
let bxp; let byp; let bzp; let bvx; let bvy; let bvz;
if (b >= 0) { bxp = px[b]; byp = py[b]; bzp = pz[b]; bvx = vx[b]; bvy = vy[b]; bvz = vz[b]; }
else { bxp = this.ox[a]; byp = this.oy[a]; bzp = 0; bvx = 0; bvy = 0; bvz = 0; }
let dx = px[a] - bxp; let dy = py[a] - byp; let dz = pz[a] - bzp;
const len = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (len <= rest[s]) continue;
const stretch = (len - rest[s]) / len;
dx *= stretch; dy *= stretch; dz *= stretch;
const kk = k[s]; const cc = sdamp[s];
const fx = kk * dx - (bvx - vx[a]) * cc;
const fy = kk * dy - (bvy - vy[a]) * cc;
const fz = kk * dz - (bvz - vz[a]) * cc;
ax[a] -= fx * inv[a]; ay[a] -= fy * inv[a]; az[a] -= fz * inv[a];
if (b >= 0) { ax[b] += fx * inv[b]; ay[b] += fy * inv[b]; az[b] += fz * inv[b]; }
}
const damp = this.damp;
for (let i = 0; i < this.n; i += 1) {
if (inv[i] === 0) { ax[i] = 0; ay[i] = 0; az[i] = 0; continue; }
vx[i] += ax[i]; vy[i] += ay[i]; vz[i] += az[i];
px[i] += vx[i]; py[i] += vy[i]; pz[i] += vz[i];
ax[i] = 0; ay[i] = 0; az[i] = 0;
if (vx[i] * vx[i] + vy[i] * vy[i] + vz[i] * vz[i] < 1e-6) { vx[i] = 0; vy[i] = 0; vz[i] = 0; }
const d = damp[i];
vx[i] *= d; vy[i] *= d; vz[i] *= d;
damp[i] = BASE_DAMPING;
}
}
// Largest displacement from rest anywhere on the lattice (verification aid).
maxDisplacement() {
let m = 0;
for (let i = 0; i < this.n; i += 1) {
const dx = this.px[i] - this.ox[i]; const dy = this.py[i] - this.oy[i]; const dz = this.pz[i];
const d = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (d > m) m = d;
}
return m;
}
// Draw into a Graphics-like object. `view` = {x, y, w, h} world rect.
// `style` = { minor, minorAlpha, minorWidth, major, majorAlpha, majorWidth,
// hot, hotAlpha, hotter, hotterAlpha, majorEvery }.
draw(g, view, style) {
const { cols, rows, spacing, px, py, pz, sx, sy } = this;
const cx = view.x + view.w / 2; const cy = view.y + view.h / 2;
const c0 = Math.max(0, Math.floor(view.x / spacing) - 1);
const c1 = Math.min(cols - 1, Math.ceil((view.x + view.w) / spacing) + 1);
const r0 = Math.max(0, Math.floor(view.y / spacing) - 1);
const r1 = Math.min(rows - 1, Math.ceil((view.y + view.h) / spacing) + 1);
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * cols + c;
const f = (pz[i] + PERSPECTIVE) / PERSPECTIVE;
sx[i] = (px[i] - cx) * f + cx;
sy[i] = (py[i] - cy) * f + cy;
}
}
const every = style.majorEvery ?? 4;
const pass = (major) => {
g.beginPath();
for (let r = r0; r <= r1; r += 1) {
if ((r % every === 0) !== major) continue;
let i = r * cols + c0;
g.moveTo(sx[i], sy[i]);
for (let c = c0 + 1; c <= c1; c += 1) { i += 1; g.lineTo(sx[i], sy[i]); }
}
for (let c = c0; c <= c1; c += 1) {
if ((c % every === 0) !== major) continue;
let i = r0 * cols + c;
g.moveTo(sx[i], sy[i]);
for (let r = r0 + 1; r <= r1; r += 1) { i += cols; g.lineTo(sx[i], sy[i]); }
}
g.strokePath();
};
g.lineStyle(style.minorWidth, style.minor, style.minorAlpha);
pass(false);
g.lineStyle(style.majorWidth, style.major, style.majorAlpha);
pass(true);
// Hot overlay: segments around strongly displaced masses glow brighter,
// so ripples and black-hole funnels light up instead of just bending.
const hot = []; const hotter = [];
for (let r = r0; r <= r1; r += 1) {
for (let c = c0; c <= c1; c += 1) {
const i = r * cols + c;
const dx = px[i] - this.ox[i]; const dy = py[i] - this.oy[i];
const d2 = dx * dx + dy * dy + pz[i] * pz[i] * 0.0025;
if (d2 > 144) hotter.push(i);
else if (d2 > 25) hot.push(i);
}
}
// Only link to neighbours inside the projected window — anything outside
// it holds a stale (or never-written) screen position.
const segs = (list) => {
g.beginPath();
for (const i of list) {
const c = i % cols; const r = (i - c) / cols;
if (c < c1) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i + 1], sy[i + 1]); }
if (r < r1) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i + cols], sy[i + cols]); }
if (c > c0) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i - 1], sy[i - 1]); }
if (r > r0) { g.moveTo(sx[i], sy[i]); g.lineTo(sx[i - cols], sy[i - cols]); }
}
g.strokePath();
};
if (hot.length) { g.lineStyle(style.majorWidth, style.hot, style.hotAlpha); segs(hot); }
if (hotter.length) { g.lineStyle(style.majorWidth + 0.5, style.hotter, style.hotterAlpha); segs(hotter); }
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,117 +0,0 @@
// Stroked vector font for Geometry Wars — the glyph table is the generic
// 4x6-cell set from TempestVectorFont.js / DefenderVectorFont.js (copied, per
// this repo's convention), extended with a few symbols, plus italic skew,
// left/right alignment, letter spacing and a separate glow colour so titles
// and HUD numbers can read as neon tubes.
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]]],
'+': [[[0.6, 3], [3.4, 3]], [[2, 1.6], [2, 4.4]]],
'.': [[[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]]],
'?': [[[0, 1], [1, 0], [3, 0], [4, 1], [4, 2], [2, 3.4], [2, 4.2]], [[1.8, 5.4], [2.2, 5.4], [2.2, 6], [1.8, 6], [1.8, 5.4]]],
'/': [[[3.6, 0], [0.4, 6]]],
"'": [[[2, 0], [2, 1.6]]],
'%': [[[3.6, 0], [0.4, 6]], [[0.4, 0.4], [1.4, 0.4], [1.4, 1.6], [0.4, 1.6], [0.4, 0.4]], [[2.6, 4.4], [3.6, 4.4], [3.6, 5.6], [2.6, 5.6], [2.6, 4.4]]],
'×': [[[0.6, 1.6], [3.4, 4.6]], [[3.4, 1.6], [0.6, 4.6]]],
};
function advance(ch, spacing) {
return ch === ' ' ? SPACE_W + spacing : GLYPH_W + GLYPH_GAP + spacing;
}
export function measureVectorText(text, scale, spacing = 0) {
let w = 0;
for (const ch of String(text).toUpperCase()) w += advance(ch, spacing) * scale;
return Math.max(0, w - (GLYPH_GAP + spacing) * scale);
}
export function vectorTextHeight(scale) { return GLYPH_H * scale; }
function strokeGlyphs(g, text, x0, y0, scale, skew, spacing) {
let x = x0;
for (const ch of text) {
const strokes = GLYPHS[ch];
if (strokes) {
for (const poly of strokes) {
g.beginPath();
for (let i = 0; i < poly.length; i += 1) {
const [ux, uy] = poly[i];
const px = x + (ux + (GLYPH_H - uy) * skew) * scale;
const py = y0 + uy * scale;
if (i === 0) g.moveTo(px, py); else g.lineTo(px, py);
}
g.strokePath();
}
}
x += advance(ch, spacing) * scale;
}
}
// (x, y) is the anchor: centre/left/right edge per `align`, vertical centre.
export function drawVectorText(g, text, x, y, scale, color, options = {}) {
const {
lineWidth = 3, glowWidth = 9, glowAlpha = 0.2, alpha = 1,
align = 'center', skew = 0, spacing = 0, glowColor = color, glow = true,
} = options;
const str = String(text).toUpperCase();
const w = measureVectorText(str, scale, spacing);
let x0 = x - w / 2;
if (align === 'left') x0 = x;
else if (align === 'right') x0 = x - w;
const y0 = y - (GLYPH_H * scale) / 2;
if (glow && glowWidth > 0 && glowAlpha > 0) {
g.lineStyle(glowWidth, glowColor, glowAlpha * alpha);
strokeGlyphs(g, str, x0, y0, scale, skew, spacing);
}
g.lineStyle(lineWidth, color, alpha);
strokeGlyphs(g, str, x0, y0, scale, skew, spacing);
return w;
}
export function formatScore(n) {
return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,292 +0,0 @@
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
//
// Model
// -----
// A cols×rows grid. Every internal edge between two cells carries exactly one
// knob (a protruding "tab") that belongs to one of the two cells; the other
// cell gets the matching "blank" (indent). Boundary edges are flat.
//
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
// (r,c+1) [right]. 'L' → left cell owns the tab.
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
//
// Because both neighbours compute the SAME knob (same line segment, same side)
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
export const DIFFICULTIES = {
// Piece counts roughly double per tier. Square grids so the (square) source
// image fills the board edge-to-edge with no letterboxing.
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
};
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
// Knob shape as fractions of the edge length (see edgeFragment below).
// neckFrac: how far in from each end the neck (narrow waist) sits.
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
export function mulberry32(seed) {
let a = seed >>> 0;
return function rng() {
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;
};
}
const rand = Math.random;
// Build the knob assignment for every internal edge.
export function makeJigsaw(cols, rows, seed = null) {
const g = seed == null ? rand : mulberry32(seed);
const H = [];
const V = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
H.push(row);
}
for (let r = 0; r < rows - 1; r++) {
const row = [];
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
V.push(row);
}
return { cols, rows, H, V };
}
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
// `normal` is the direction the knob bulges (null for flat edges). This is the
// side the shared curve lies on, so it is identical for both adjacent cells.
export function cellEdgeSpec(jig, r, c) {
const { cols, rows, H, V } = jig;
const out = {};
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
if (r === 0) out.top = { kind: 'flat', normal: null };
else {
const owner = V[r - 1][c];
const tab = owner === 'D'; // bottom cell owns the tab
out.top = tab
? { kind: 'tab', normal: { x: 0, y: -1 } }
: { kind: 'blank', normal: { x: 0, y: 1 } };
}
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
else {
const owner = H[r][c];
const tab = owner === 'L'; // left cell owns the tab
out.right = tab
? { kind: 'tab', normal: { x: 1, y: 0 } }
: { kind: 'blank', normal: { x: -1, y: 0 } };
}
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
else {
const owner = V[r][c];
const tab = owner === 'U'; // top cell owns the tab
out.bottom = tab
? { kind: 'tab', normal: { x: 0, y: 1 } }
: { kind: 'blank', normal: { x: 0, y: -1 } };
}
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
if (c === 0) out.left = { kind: 'flat', normal: null };
else {
const owner = H[r][c - 1];
const tab = owner === 'R'; // right cell owns the tab
out.left = tab
? { kind: 'tab', normal: { x: -1, y: 0 } }
: { kind: 'blank', normal: { x: 1, y: 0 } };
}
return out;
}
// The 4-neighbour cells of (r,c) inside the grid. By construction every such
// pair shares an internal edge, and both pieces trace the *same* shared curve
// for it — so these are exactly the pieces that mesh with (r,c) when placed in
// their correct board slots. That is the legal set of pieces that may join
// (r,c) anywhere on the table; no other pair can ever fit together.
export function cellNeighbours(jig, r, c) {
const { cols, rows } = jig;
const out = [];
if (c > 0) out.push([r, c - 1]);
if (c < cols - 1) out.push([r, c + 1]);
if (r > 0) out.push([r - 1, c]);
if (r < rows - 1) out.push([r + 1, c]);
return out;
}
// ── Table assembly: joining pieces & locking groups (pure, Phaser-free) ─────
// Model the scene feeds in (plain data only — the math never touches Phaser):
// piece: { r, c, home:{x,y}, pos:{x,y}, placed, group }
// group: { pieces: [...] } (piece.group points back)
// cellAt(r, c) -> piece|null (the grid lookup)
//
// Group invariant: every member of a group sits at `anchor.pos + (member.home -
// anchor.home)` for any member `anchor` — i.e. the exact board-relative offset
// — so members always mesh while the group moves. resolveDrop preserves it.
//
// resolveDrop decides what happens when `group` (all members unplaced) is
// released on the table, using the same snap radius for both outcomes:
// 1. BOARD LOCK — if any member is within `snapR` of its home slot, the
// whole group locks onto the board. Only grid-adjacent pieces can share a
// group, and grid-adjacent pieces mesh exactly on the board, so the group
// always lands as a correctly assembled block (every member is then
// aligned too, by the invariant).
// 2. JOIN — otherwise, any unplaced grid-neighbour of any member sitting
// within `snapR` of its correct relative position is absorbed together
// with its WHOLE group; repeated to a fixpoint so a chain of correctly
// placed pieces latches on in a single drop. Non-adjacent pieces can
// never join, no matter where they sit — they wouldn't mesh on the board.
// 3. REST — otherwise the group just rests where it was dropped.
//
// Pure: no mutation. Returns { outcome, placements, absorbedGroups } where
// placements are the target positions the caller must apply and absorbedGroups
// are the (other) groups that merged into `group`.
export function resolveDrop(jig, group, cellAt, snapR) {
// 1) Board lock takes precedence: any member aligned ⇒ the group is placed.
for (const m of group.pieces) {
if (Math.hypot(m.pos.x - m.home.x, m.pos.y - m.home.y) < snapR) {
return {
outcome: 'locked',
placements: group.pieces.map((m) => ({ piece: m, x: m.home.x, y: m.home.y })),
absorbedGroups: [],
};
}
}
// 2) Join: absorb unplaced grid-neighbours at their correct relative spot.
// `frame` is the group's consistent position frame: the dropped group is
// already home-exact, and every absorbed piece is snapped INTO the frame,
// so a chain that latches on ends up fully consistent (invariant holds).
const frame = new Map();
for (const m of group.pieces) frame.set(m, m.pos);
const members = [...group.pieces]; // working set — `group` is not mutated
const inGroup = new Set(members);
const placements = [];
const absorbedGroups = new Set();
let changed = true;
while (changed) {
changed = false;
for (const m of [...members]) {
for (const [nr, nc] of cellNeighbours(jig, m.r, m.c)) {
const q = cellAt(nr, nc);
if (!q || q.placed || inGroup.has(q)) continue;
// Where q belongs in the assembled group relative to m's frame position.
const fm = frame.get(m);
const ex = fm.x + (q.home.x - m.home.x);
const ey = fm.y + (q.home.y - m.home.y);
if (Math.hypot(q.pos.x - ex, q.pos.y - ey) >= snapR) continue;
absorbedGroups.add(q.group);
for (const x of q.group.pieces) {
if (inGroup.has(x)) continue;
// Snap x into the frame (offsets from q are exact).
const px = ex + (x.home.x - q.home.x);
const py = ey + (x.home.y - q.home.y);
frame.set(x, { x: px, y: py });
placements.push({ piece: x, x: px, y: py });
members.push(x);
inGroup.add(x);
}
changed = true;
}
}
}
if (!placements.length) return { outcome: 'rested', placements: [], absorbedGroups: [] };
return { outcome: 'joined', placements, absorbedGroups: [...absorbedGroups].filter((g) => g !== group) };
}
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
// Path commands for one edge, assuming the current point is `p0`.
// Flat → a single line. Knob → line to the near neck, one cubic through the
// bulb to the far neck, line to `p1`. The curve is direction-independent:
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
// which is what makes neighbouring pieces mesh.
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
if (!edge || edge.kind === 'flat') {
return [{ t: 'line', x: p1.x, y: p1.y }];
}
const { neckFrac, ctrlFrac } = knob;
const nA = lerp(p0, p1, neckFrac);
const nB = lerp(p0, p1, 1 - neckFrac);
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
return [
{ t: 'line', x: nA.x, y: nA.y },
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
{ t: 'line', x: p1.x, y: p1.y },
];
}
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
// units; `ox`,`oy` the cell's top-left in local units. Returns
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
const x0 = ox + c * W;
const y0 = oy + r * H;
const TL = { x: x0, y: y0 };
const TR = { x: x0 + W, y: y0 };
const BR = { x: x0 + W, y: y0 + H };
const BL = { x: x0, y: y0 + H };
const spec = cellEdgeSpec(jig, r, c);
const cmds = [
...edgeFragment(TL, TR, spec.top, knob),
...edgeFragment(TR, BR, spec.right, knob),
...edgeFragment(BR, BL, spec.bottom, knob),
...edgeFragment(BL, TL, spec.left, knob),
];
return { start: TL, cmds };
}
// Apply path commands to a 2D canvas context (builds the current path).
export function tracePath(ctx, outline) {
const { start, cmds } = outline;
ctx.moveTo(start.x, start.y);
for (const c of cmds) {
if (c.t === 'line') ctx.lineTo(c.x, c.y);
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
}
ctx.closePath();
}
// ── Scramble: assign each piece a start position in the "tray" region ─────────
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
// pieces are scattered. Returns an array aligned to the piece index
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
const g = seed == null ? rand : mulberry32(seed);
const { cols, rows } = jig;
const N = cols * rows;
const placed = [];
const margin = Math.max(tray.w, tray.h) * 0.06;
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
for (let i = 0; i < N; i++) {
// Rejection-sample a few tries so pieces don't pile in a single spot.
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
let tries = 0;
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
px = x0 + (x1 - x0) * g();
py = y0 + (y1 - y0) * g();
tries++;
}
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
}
return placed;
}

View File

@ -81,17 +81,7 @@ export default class MahjongGame extends Phaser.Scene {
const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch { /* optional */ }
// Backdrop: the playfield chosen on the opponent-select screen (image
// texture, or the generated canvas texture for the "colored" playfields),
// falling back to its flat color and finally to the classic deep green.
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : FELT;
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color).setDepth(DEPTH.bg);
}
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg);
const names = [auth.user?.username ?? 'You'];
const skills = { 0: 5 };
@ -140,16 +130,11 @@ export default class MahjongGame extends Phaser.Scene {
}
buildPortraits() {
// Each portrait sits just to the left of its own tiles, at the vertical
// centre of that area (rows/columns are all centred on their midpoints),
// so it reads as belonging to that player without ever covering a tile.
// Keep ~18-20px clearance: human hand left edge ≥ 391 (14 tiles), left
// column left edge 133, right column left edge 1836, top row ≥ 616.
const spots = [
{ x: 325, y: 975, r: 46 }, // human — left of the bottom hand row
{ x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column
{ x: 558, y: 70, r: 40 }, // seat 2 — left of the top row
{ x: 75, y: 580, r: 40 }, // seat 3 — left of the left column
{ x: 90, y: 870, r: 46 }, // human — bottom left
{ x: 1830, y: 170, r: 40 }, // seat 1 — right
{ x: 560, y: 95, r: 40 }, // seat 2 — top
{ x: 90, y: 310, r: 40 }, // seat 3 — left
];
for (let seat = 0; seat < 4; seat++) {
const { x, y, r } = spots[seat];
@ -234,10 +219,7 @@ export default class MahjongGame extends Phaser.Scene {
}).setOrigin(0.5));
this.refPanel = panel;
// Parked just left of the reference panel's open position (1490) so it
// never ends up under it; y=210 clears seat 2's melds (y≤98) and bonus
// tiles (y≤157), which sit at x 1330+.
this.refBtn = new Button(this, 1399, 210, 'Hands', () => this.toggleReference(), {
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), {
width: 150, height: 52, fontSize: 22, variant: 'ghost',
}).setDepth(DEPTH.ref + 1);
}
@ -251,15 +233,6 @@ export default class MahjongGame extends Phaser.Scene {
ease: 'Quad.Out',
});
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
// Seat 1's portrait sits under the open panel. Its <video> is a DOM node
// that always renders above canvas content, so hide it while covered.
this.portraitCtrls[1]?.controller?.setVideoVisible?.(!this.refOpen);
}
// The claim row and the self win/kong buttons sit under the open panel's
// footprint — close it whenever they appear so nothing clickable is covered.
closeReferenceIfOpen() {
if (this.refOpen) this.toggleReference();
}
// ── tile drawing ──────────────────────────────────────────────────────────────
@ -409,7 +382,7 @@ export default class MahjongGame extends Phaser.Scene {
bx += 40;
}
} else { // sides — vertical column of rotated backs
const x = seat === 1 ? 1858 : 155; // columns stay right of their portraits (seat 1: 1778+40, seat 3: 75+40)
const x = seat === 1 ? 1858 : 62;
let y = 580 - ((n - 1) * step) / 2;
for (let i = 0; i < n; i++) {
const back = this.makeTileBack(SM_W, SM_H);
@ -417,7 +390,7 @@ export default class MahjongGame extends Phaser.Scene {
this.dyn.add(back);
y += step;
}
const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column
const mx = seat === 1 ? 1745 : 175;
let my = 330;
for (const m of p.melds) {
const row = this.makeMeldRow(m, 38, 52, false);
@ -508,7 +481,6 @@ export default class MahjongGame extends Phaser.Scene {
btn.setVisible(!!spec);
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
});
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
}
// ── human input ───────────────────────────────────────────────────────────────
@ -549,7 +521,6 @@ export default class MahjongGame extends Phaser.Scene {
return new Promise((resolve) => {
this.claimResolve = resolve;
this.chowOptions = opts.chows;
this.closeReferenceIfOpen();
const visible = [];
if (opts.win) visible.push(this.claimBtns.win);
if (opts.kong) visible.push(this.claimBtns.kong);

View File

@ -17,28 +17,6 @@ const INK_DARK = '#1a1208'; // dark letter on the honey center hex
const TITLE_GOLD = '#f2c14e';
const PAPER = 0x1e1a12; // panels
// ── Background video ─────────────────────────────────────────────────────────
// Animated bee-on-a-flower clip used as a live backdrop behind the board.
// Loaded on demand (not in the asset manifest) and muted — the game has its
// own soundtrack and SFX, so the clip is purely visual. Source resolution is
// used to scale until the real texture decodes (a Video's own width/height
// report a square placeholder before its first frame).
const BG_VIDEO_KEY = 'spellingbee-bg-video';
const BG_VIDEO_PATH = 'assets/videos/games/spellingBee.mp4';
const BG_VIDEO_SRC_W = 864;
const BG_VIDEO_SRC_H = 480;
// The clip is bright, so dim it a touch to keep the title and board readable
// without killing the color.
const BG_DIM_ALPHA = 0.38;
// Fallback for revealing the start panel: if the intro clip never finishes its
// first loop (stalled download, unsupported codec, ...), bring the panel up
// anyway. Just past the clip's ~15 s runtime.
const INTRO_REVEAL_FALLBACK_MS = 20000;
// Still gameplay backdrop, preloaded by GameRoomScene via data/assetManifest.js.
// Replaces the intro clip the moment a puzzle actually starts.
const BG_IMAGE_KEY = 'spellingbee-bg';
const BG_IMAGE_PATH = 'assets/images/background-spellingbee.png';
const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 };
// ── Honeycomb geometry ─────────────────────────────────────────────────────────
@ -70,9 +48,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.curLetters = []; // per-char text objects for the current word
this.foundTexts = [];
this.hexes = {}; // letter -> { container, gfx, isCenter }
this.bgVideo = null;
this.bgImage = null; // still gameplay background (replaces the video)
this.panelRevealed = false; // difficulty panel shown (intro finished)
}
create() {
@ -82,8 +57,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
.setDepth(DEPTH.bg);
this.buildVideoBackground();
this.add.text(40, 36, 'SPELLING BEE', {
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
}).setDepth(DEPTH.ui);
@ -97,89 +70,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.input.keyboard.off('keydown', this.keyHandler);
this.keyHandler = null;
}
// The video may already be gone (e.g. the error handler destroyed it).
if (this.bgVideo?.scene) this.bgVideo.destroy();
this.bgVideo = null;
if (this.bgImage?.scene) this.bgImage.destroy();
this.bgImage = null;
}
// ── Background video ───────────────────────────────────────────────────────
// Bring up the looping background clip on top of the felt base. In this
// Phaser version a VideoFile "load" is a synchronous no-op — it only records
// the URL in the video cache (the real fetch happens when the Video object
// is created, in showBackgroundVideo) — so there is no async window to wait
// out: queue it if needed and show it immediately. A fetch/playback failure
// is handled by the video's 'error' event, leaving the felt base visible.
buildVideoBackground() {
if (!this.cache.video?.exists(BG_VIDEO_KEY)) {
this.load.video(BG_VIDEO_KEY, BG_VIDEO_PATH, true); // noAudio — visual only
if (!this.load.isLoading()) this.load.start();
}
this.showBackgroundVideo();
}
showBackgroundVideo() {
const v = this.add.video(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_VIDEO_KEY)
.setDepth(DEPTH.bg);
v.setLoop(true);
v.setMute(true);
// Cover, not contain: fill edge-to-edge. The clip's aspect (864x480) is
// within a hair of the game's (1920x1080), so the crop is imperceptible.
const fit = () => {
const w = v.videoTexture ? v.width : BG_VIDEO_SRC_W;
const h = v.videoTexture ? v.height : BG_VIDEO_SRC_H;
v.setScale(Math.max(GAME_WIDTH / w, GAME_HEIGHT / h));
};
fit();
v.on('created', fit);
v.on('playing', fit);
// A load failure mid-playback (or a browser refusing the element) falls
// back to the felt rectangle already underneath — and reveals the start
// panel, which otherwise waits for the intro loop.
v.once('error', () => {
this.revealStartPanel();
if (v.scene) v.destroy();
});
this.bgVideo = v;
v.setAlpha(0);
v.play(true);
this.tweens.add({ targets: v, alpha: 1, duration: 700, ease: 'Sine.easeOut' });
// Dim layer on top of the footage so the title and board stay readable.
// Fixed alpha: it only darkens the felt base while the video fades in, so
// the footage never flashes bright before settling. (It also dims the
// still gameplay backdrop once the video is swapped out — see
// showImageBackground.)
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT,
0x000000, BG_DIM_ALPHA).setDepth(DEPTH.bg + 1);
}
// Swap the intro clip for the still gameplay backdrop (honeycomb PNG).
// Preloaded via the asset manifest, so the texture is almost always ready;
// the else branch covers a scene started without that preload. The felt
// rectangle stays underneath as the ultimate fallback.
showImageBackground() {
if (this.bgImage) return;
if (!this.textures.exists(BG_IMAGE_KEY)) {
this.load.image(BG_IMAGE_KEY, BG_IMAGE_PATH);
if (!this.load.isLoading()) this.load.start();
}
if (this.textures.exists(BG_IMAGE_KEY)) {
this.bgImage = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_IMAGE_KEY)
.setOrigin(0.5).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
} else {
this.load.once('filecomplete', (type, key) => {
if (type !== 'texture' || key !== BG_IMAGE_KEY) return;
if (!this.sys.isActive() || this.bgImage) return;
this.bgImage = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_IMAGE_KEY)
.setOrigin(0.5).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
});
}
}
// ── Start panel (opponent-select is skipped for this solo game) ─────────────
@ -217,31 +107,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
b.setDepth(DEPTH.ui);
this.startObjs.push(b);
});
// Hidden until the intro clip has played a full loop (see revealStartPanel).
this.startObjs.forEach((o) => o.setAlpha(0));
// The first 'loop' event of the background video means the clip has just
// played through once — the natural moment to surface the difficulty
// choice. The safety timer covers a clip that never gets that far; the
// video's 'error' handler (showBackgroundVideo) does the same immediately.
const reveal = () => this.revealStartPanel();
this.bgVideo?.once(Phaser.GameObjects.Events.VIDEO_LOOP, reveal);
this.time.delayedCall(INTRO_REVEAL_FALLBACK_MS, reveal);
}
// Fade the difficulty panel in. Whichever trigger fires first (intro loop,
// video error, fallback timer) wins; the rest are no-ops.
revealStartPanel() {
if (this.panelRevealed) return;
// Panel not built yet — bail without consuming the reveal; a later trigger
// (loop timer / fallback) will fire once it exists.
if (!this.startObjs.length) return;
this.panelRevealed = true;
this.startObjs.forEach((o) => {
if (!o.scene) return;
this.tweens.add({ targets: o, alpha: 1, duration: 700, ease: 'Sine.easeOut' });
});
}
async startPuzzle(difficulty) {
@ -265,12 +130,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.maxScore = data.maxScore ?? 0;
this.tiers = buildTiers(this.maxScore, difficulty);
// Difficulty screen is over: retire the intro clip and bring up the still
// honeycomb backdrop. (If the fetch above failed we never get here, and
// the player stays on the difficulty screen with the clip still playing.)
if (this.bgVideo?.scene) { this.bgVideo.destroy(); this.bgVideo = null; }
this.showImageBackground();
this.buildBoard();
}

View File

@ -13,16 +13,6 @@ import {
const FONT_D = 'Righteous';
const FONT_B = '"Julius Sans One"';
const RULES_W = 420;
// The same four rules the engine enforces (TentsLogic.js rule comment) —
// kept here as plain strings so the panel can never drift from the model.
const RULES_TEXT = [
'Every tent must sit next to a tree — up, down, left, or right (no diagonals).',
"It's a one-to-one pairing: each tree gets exactly one tent, and each tent belongs to exactly one tree.",
'Tents can never touch another tent, not even corner to corner.',
'The numbers along the top and side are the exact tent count for that column and row — no more, no less.',
];
// ── Color helpers (day → night lerps drive the whole scene) ─────────────────
@ -85,7 +75,6 @@ export default class TentsGame extends Phaser.Scene {
this.sky = { t: 0 }; // 0 = midday, 1 = starry night
this.won = false;
this.winPanelShown = false;
this.rulesOpen = false;
this.difficulty = 'porch';
this.hover = null; // {c, r}
this.embers = [];
@ -118,7 +107,6 @@ export default class TentsGame extends Phaser.Scene {
this.buildHud();
this.hudLayer.setVisible(false);
this.buildRulesPanel();
this.buildSelectScreen();
this.events.on('shutdown', () => {
@ -221,14 +209,7 @@ export default class TentsGame extends Phaser.Scene {
this.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost');
this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
// Parked at the same screen position the rules panel's Close button sits
// at once open (GAME_WIDTH - RULES_W/2, GAME_HEIGHT - 80), so opening the
// panel reads as this button "becoming" Close rather than two separate
// controls.
this.btnRules = new Button(this, GAME_WIDTH - RULES_W / 2, GAME_HEIGHT - 80, 'Rules',
() => this.toggleRules(), { width: 200, height: 56, variant: 'ghost' }).setDepth(31);
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu, this.btnRules]);
this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]);
}
makeText(str, x, y, font, size, color) {
@ -238,68 +219,6 @@ export default class TentsGame extends Phaser.Scene {
}).setOrigin(0, 0).setDepth(30);
}
// ── Rules panel (toggled in-play; parked off-screen until opened) ──────────
buildRulesPanel() {
const panel = this.add.container(GAME_WIDTH, 0).setDepth(35);
const bg = this.add.graphics();
bg.fillStyle(0x101024, 0.95);
bg.fillRect(0, 0, RULES_W, GAME_HEIGHT);
bg.lineStyle(3, 0xffc46a, 0.9);
bg.lineBetween(0, 0, 0, GAME_HEIGHT);
panel.add(bg);
// Pushed down ~100px so the title doesn't sit under the soundtrack
// controls (MusicPlayer's skip/mute buttons + track name) in the
// top-right corner, which render above this panel's depth.
panel.add(this.add.text(RULES_W / 2, 150, 'How to Play', {
fontFamily: FONT_D, fontSize: '36px', color: '#ffd76a',
stroke: '#3a2a14', strokeThickness: 6,
}).setOrigin(0.5));
let y = 218;
RULES_TEXT.forEach((line, i) => {
panel.add(this.add.text(24, y, `${i + 1}.`, {
fontFamily: FONT_D, fontSize: '22px', color: '#ffd76a',
}).setOrigin(0, 0));
const body = this.add.text(58, y, line, {
fontFamily: FONT_B, fontSize: '20px', color: '#fdf6e3',
wordWrap: { width: RULES_W - 82 }, lineSpacing: 4,
}).setOrigin(0, 0);
panel.add(body);
y += body.height + 34;
});
const closeBtn = new Button(this, RULES_W / 2, GAME_HEIGHT - 80, 'Close',
() => this.toggleRules(), { width: 200, height: 56, variant: 'ghost' });
panel.add(closeBtn);
this.rulesPanel = panel;
}
toggleRules() {
this.rulesOpen = !this.rulesOpen;
this.tweens.add({
targets: this.rulesPanel,
x: this.rulesOpen ? GAME_WIDTH - RULES_W : GAME_WIDTH,
duration: 220,
ease: 'Quad.Out',
});
// The panel's own Close button sits in this same screen spot once open,
// so hide (and un-click) the Rules button rather than stacking both.
this.btnRules.setVisible(!this.rulesOpen);
if (this.rulesOpen) {
this.btnRules.disableInteractive();
} else {
this.btnRules.setInteractive({
useHandCursor: true,
hitArea: new Phaser.Geom.Rectangle(0, 0, 200, 56),
hitAreaCallback: Phaser.Geom.Rectangle.Contains,
});
}
}
// ── Select screen ──────────────────────────────────────────────────────────
buildSelectScreen() {

View File

@ -62,7 +62,6 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
import ShiftGame from './games/shift/ShiftGame.js';
import JigsawGame from './games/jigsaw/JigsawGame.js';
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
import MahjongGame from './games/mahjong/MahjongGame.js';
@ -110,8 +109,6 @@ import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
import TentsGame from './games/tents/TentsGame.js';
import DefenderGame from './games/defender/DefenderGame.js';
import GeometryWarsGame from './games/geometrywars/GeometryWarsGame.js';
const config = {
type: Phaser.AUTO,
@ -187,7 +184,6 @@ const config = {
HexsweeperGame,
PuddingMonstersGame,
ShiftGame,
JigsawGame,
BlockFighterGame,
MahjongMatchGame,
MahjongGame,
@ -235,8 +231,6 @@ const config = {
WolfensteinEditor,
PipePuzzleGame,
TentsGame,
DefenderGame,
GeometryWarsGame,
],
};

View File

@ -2,11 +2,12 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js';
import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
import { TutorialModal } from '../ui/TutorialModal.js';
let _lastCategory = null;
const CATEGORIES = [
{ key: 'tabletop', label: 'Tabletop' },
{ key: 'cards', label: 'Cards & Dice' },
@ -21,29 +22,10 @@ const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -145;
// How far (px) a grid travels off screen when switching categories.
const GRID_TRAVEL = GAME_WIDTH + 800;
// The most recently selected category. Module scope so it survives scene
// restarts (scene.start() reuses the instance) and also covers paths that
// start the menu without category data — the menu always returns to where
// the user last was.
let lastCategory = null;
export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); }
init(data) {
// Category to restore on entry: the explicit one (coming back from the
// opponent picker) or the most recently selected one.
this._initialCategory = (data && data.category) || lastCategory;
// scene.start() reuses the existing scene instance, so instance props from
// the previous visit survive — reset them so this create() starts clean
// (otherwise showCategory()'s "same category" guard swallows the restore).
this._currentCategory = null;
this._gridAnim = null;
}
async create() {
playMenuMusic();
const cx = GAME_WIDTH / 2;
@ -51,18 +33,12 @@ export default class GameMenuScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
addFullscreenButton(this);
const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
const titleText = this.add.text(cx, 60, 'Choose a game', {
fontFamily: 'Righteous',
fontSize: '64px',
color: COLORS.textHex,
}).setOrigin(0.5).setDepth(1);
titleText.setLetterSpacing(2);
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
this._titleText = titleText;
const plateW = titleText.width + 64;
const plateH = titleText.height + 28;
this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5);
this._titlePlateW = plateW;
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
const loadingText = this.add.text(cx, 540, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex,
@ -131,49 +107,14 @@ export default class GameMenuScene extends Phaser.Scene {
this._tabIcons[key] = icon;
});
// No category is selected by default — the user picks one to see games.
this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', {
fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key;
this.showCategory(startKey);
this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
// Returning from the opponent picker: restore the category that was
// active when the game was chosen — its grid flies back in from the right.
if (this._initialCategory) {
this.showCategory(this._initialCategory);
}
// The landing scene zooms the logo out before starting us, so fade the
// menu controls in to complete the handoff.
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn].filter(Boolean);
for (const obj of fadeIn) obj.setAlpha(0);
this.tweens.add({
targets: fadeIn,
alpha: 1,
duration: 450,
ease: 'Power2.easeOut',
});
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
}
showCategory(key) {
if (key === this._currentCategory) return;
this._currentCategory = key;
lastCategory = key;
if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
if (this._titleText) {
this._titleText.setText('Choose a game');
const newW = this._titleText.width + 64;
const newH = this._titleText.height + 28;
if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) {
this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut');
this._titlePlateW = newW;
}
// Little pop so the label swap reads as intentional.
this._titleText.setScale(0.9);
this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' });
}
_lastCategory = key;
for (const [k, btn] of Object.entries(this._tabs)) {
btn.setActive(k === key);
}
@ -221,41 +162,13 @@ export default class GameMenuScene extends Phaser.Scene {
}
}
// Interrupt any grid animation still in flight (user changed their mind mid-flight).
if (this._gridAnim) {
for (const obj of this._gridAnim.objects) obj.destroy();
this._gridAnim = null;
this._gameObjects = [];
}
for (const obj of this._gameObjects) obj.destroy();
this._gameObjects = [];
if (this._ptrMoveHandler) {
this.input.off('pointermove', this._ptrMoveHandler, this);
this._ptrMoveHandler = null;
}
const oldObjects = this._gameObjects;
this._gameObjects = [];
if (oldObjects.length === 0) {
this.buildCategoryGrid(key);
return;
}
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
for (const obj of oldObjects) obj.disableInteractive();
this._gridAnim = { objects: oldObjects };
this.tweens.add({
targets: oldObjects,
x: `-=${GRID_TRAVEL}`,
duration: 380,
ease: 'Power2.easeIn',
onComplete: () => {
this._gridAnim = null;
for (const obj of oldObjects) obj.destroy();
this.buildCategoryGrid(key);
},
});
}
buildCategoryGrid(key) {
const games = this._gamesByCategory[key];
if (!games || games.length === 0) return;
@ -354,18 +267,6 @@ export default class GameMenuScene extends Phaser.Scene {
this._gameObjects.push(qg, qLabel);
}
});
// Fly the grid in from off screen right.
const objs = this._gameObjects;
for (const obj of objs) obj.x += GRID_TRAVEL;
this._gridAnim = { objects: objs };
this.tweens.add({
targets: objs,
x: `-=${GRID_TRAVEL}`,
duration: 420,
ease: 'Power2.easeOut',
onComplete: () => { this._gridAnim = null; },
});
}
openGame(game) {

View File

@ -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', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game', defender: 'DefenderGame', geometrywars: 'GeometryWarsGame' };
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', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {

View File

@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.js';
@ -17,8 +16,6 @@ export default class LandingScene extends Phaser.Scene {
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
this._logo = logo;
this._transitioning = false;
this.tweens.add({
targets: logo,
@ -56,7 +53,6 @@ export default class LandingScene extends Phaser.Scene {
renderButtons() {
const cx = GAME_WIDTH / 2;
const user = auth.user;
this._avatar = null;
const avatarR = 28;
const avatarGap = 18;
@ -69,8 +65,6 @@ export default class LandingScene extends Phaser.Scene {
fontSize: '36px',
color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(1);
this._welcomeText = welcomeText;
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
const hasAvatar = !!user?.avatarPath;
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
@ -78,8 +72,7 @@ export default class LandingScene extends Phaser.Scene {
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
this._welcomeBg = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
.setPosition(cx, y);
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45);
if (hasAvatar) {
const avatarCx = groupLeft + avatarR;
@ -93,11 +86,11 @@ export default class LandingScene extends Phaser.Scene {
this.load.start();
});
}
if (this._transitioning || !this.scene.isActive('Landing')) return;
if (!this.scene.isActive('Landing')) return;
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff);
maskG.fillCircle(avatarCx, y, avatarR);
this._avatar = this.add.image(avatarCx, y, key)
this.add.image(avatarCx, y, key)
.setDisplaySize(avatarR * 2, avatarR * 2)
.setMask(maskG.createGeometryMask())
.setDepth(1);
@ -105,39 +98,7 @@ export default class LandingScene extends Phaser.Scene {
})();
}
this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
}
goToGameMenu() {
if (this._transitioning) return;
this._transitioning = true;
this.playBtn.disableInteractive();
this.profileBtn.disableInteractive();
// Fade the landing buttons out while the logo zooms past the camera.
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
if (this._avatar) fadeOut.push(this._avatar);
this.tweens.add({
targets: fadeOut,
alpha: 0,
duration: 350,
ease: 'Power2.easeOut',
});
if (!this._logo) { this.scene.start('GameMenu'); return; }
this.tweens.killTweensOf(this._logo);
this.tweens.add({
targets: this._logo,
x: GAME_WIDTH / 2,
y: GAME_HEIGHT / 2,
scaleX: 2.4,
scaleY: 2.4,
alpha: 0,
duration: 700,
ease: 'Cubic.easeIn',
onComplete: () => this.scene.start('GameMenu'),
});
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
}
}

View File

@ -1,7 +1,6 @@
import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
@ -18,8 +17,8 @@ const CARD_TILE_GAP = 14;
// Opponent grid scroll area
const OPP_SCROLL_W = 1780;
const OPP_SCROLL_H = 415;
const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
const OPP_SCROLL_H = 440;
const OPP_SCROLL_TOP = 155; // top edge of scroll area
export default class OpponentSelectScene extends Phaser.Scene {
constructor() { super('OpponentSelect'); }
@ -59,32 +58,21 @@ export default class OpponentSelectScene extends Phaser.Scene {
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
// Brass-plaque headers, same treatment as the game menu headings. The
// subtitle plaque sits in the band between the title plaque and the
// opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either.
const TITLE_Y = 55;
const PLATE_GAP = 10;
const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, {
const titleText = this.add.text(cx, 60, this.gameDef.name, {
fontFamily: 'Righteous',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
titleText.setLetterSpacing(2);
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
this.children.moveBelow(titlePlate, titleText);
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
this.children.moveBelow(titlePill, titleText);
const subtitleText = this.add.text(cx, 0, 'Choose your opponent', {
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
fontFamily: 'Righteous',
fontSize: '36px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
const subPlateH = subtitleText.height + 28;
const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2;
subtitleText.setY(subtitleY);
const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY);
this.children.moveBelow(subtitlePlate, subtitleText);
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
this.children.moveBelow(subtitlePill, subtitleText);
let opponents = [];
try {
@ -99,7 +87,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
}
const min = this.gameDef.minOpponents ?? 1;
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), {
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
variant: 'ghost',
width: 280,
});

View File

@ -24,8 +24,6 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
totalannihilation: 'hacker',
coloradodefense: 'arcadedark',
tempest: 'arcadedark',
defender: 'arcadedark',
geometrywars: 'arcadedark',
mastermind: 'hacker',
balatro: 'hacker',
hexsweeper: 'hacker',

View File

@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
if (this._active) return;
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
if (v === 'ghost') {
this._drawBg(bgh, 0.9);
this.text.setColor(thc);
this._drawBg(bgh, 0.18);
this.text.setColor(COLORS.goldHex);
} else {
this._drawBg(bgh, 1);
this.text.setColor(thc);

View File

@ -1,62 +0,0 @@
import * as Phaser from 'phaser';
import { COLORS } from '../config.js';
// A rounded, brass-trimmed plaque in the same design language as the
// category tabs. Use instead of a flat black rectangle behind title text.
export class Plaque extends Phaser.GameObjects.Graphics {
constructor(scene, width = 300, height = 90, options = {}) {
super(scene, false);
const {
fill = 0x241c10,
fillAlpha = 0.92,
border = COLORS.accent,
radius = 18,
} = options;
this._opts = { fill, fillAlpha, border, radius };
this._w = width;
this._h = height;
this.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65);
this.redraw(width, height);
scene.add.existing(this);
}
redraw(w, h) {
this._w = w;
this._h = h;
const { fill, fillAlpha, border, radius } = this._opts;
const r = Math.max(4, Math.min(radius, w / 2, h / 2));
this.clear();
this.fillStyle(fill, fillAlpha);
this.fillRoundedRect(-w / 2, -h / 2, w, h, r);
// Faint warm highlight just inside the top edge reads as a raised plate.
this.lineStyle(2, 0xfff6dd, 0.16);
this.lineBetween(-w / 2 + r + 4, -h / 2 + 4, w / 2 - r - 4, -h / 2 + 4);
this.lineStyle(2, border, 1);
this.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
}
// Animate the plate to a new size. Graphics in this Phaser build exposes
// no setScaleX/setScaleY (and scaling a redrawn shape is unreliable), so
// we tween a size proxy and redraw every frame instead.
animateSize(w, h, duration = 240, ease = 'Quad.easeOut', onComplete) {
if (this._sizeTween) this._sizeTween.stop();
const proxy = { w: this._w, h: this._h };
this._sizeTween = this.scene.tweens.add({
targets: proxy,
w,
h,
duration,
ease,
onUpdate: () => {
if (!this.scene) return; // destroyed mid-animation
this.redraw(proxy.w, proxy.h);
},
onComplete: () => {
this._sizeTween = null;
if (!this.scene) return; // destroyed mid-animation
this.redraw(w, h);
if (onComplete) onComplete();
},
});
}
}

View File

@ -1,81 +0,0 @@
// Headless-chromium CDP driver for the jigsaw initial-image test page.
// Loads the page repeatedly; each load must end PASS, and the initial image
// index (document.__initIndex) must vary across fresh loads.
// Usage: node tools/__jig_init_driver.mjs <url> [loads=8]
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const url = process.argv[2];
const loads = Math.max(2, parseInt(process.argv[3] || '8', 10));
if (!url) { console.error('usage: driver <url> [loads]'); process.exit(2); }
const BIN = process.env.HOME + '/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell';
const PORT = 9333;
const chrome = spawn(BIN, [
'--headless', '--no-sandbox', '--disable-gpu',
`--remote-debugging-port=${PORT}`,
'about:blank',
], { stdio: ['ignore', 'pipe', 'pipe'] });
chrome.stderr.on('data', (d) => { const s = d.toString(); if (!/Fontconfig|dbus|DBus|ozone/i.test(s)) process.stderr.write(s); });
async function httpJson(path) {
const r = await fetch(`http://127.0.0.1:${PORT}${path}`);
return r.json();
}
let ws, idc = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++idc;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
try {
let targets = null;
for (let i = 0; i < 60; i++) {
try { targets = await httpJson('/json/list'); break; } catch { await sleep(250); }
}
if (!targets) throw new Error('chrome devtools endpoint never came up');
const page = targets.find((t) => t.type === 'page');
if (!page) throw new Error('no page target');
ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
}
};
await send('Runtime.enable');
await send('Page.enable');
const ev = (expression) => send('Runtime.evaluate', { expression, returnByValue: true }).then((r) => r.result.value);
const indexes = [];
for (let k = 0; k < loads; k++) {
await send('Page.navigate', { url });
const t0 = Date.now();
let title = '';
while (Date.now() - t0 < 60000) {
title = (await ev('document.title')) || '';
if (title === 'PASS' || title.startsWith('FAIL')) break;
await sleep(500);
}
if (title !== 'PASS') throw new Error(`load #${k}: ${title || 'TIMEOUT'}`);
indexes.push(await ev('document.__initIndex'));
}
const valid = indexes.every((i) => Number.isInteger(i) && i >= 0 && i < 5);
const distinct = new Set(indexes).size;
console.log('initial indexes across fresh loads:', indexes.join(', '));
console.log(`all valid: ${valid}, distinct images: ${distinct}/${loads}`);
if (!valid || distinct < 2) { console.log('FAIL'); process.exitCode = 1; }
else console.log('ALL PASS');
} finally {
try { ws && ws.close(); } catch {}
chrome.kill('SIGKILL');
}

View File

@ -1,162 +0,0 @@
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
//
// Usage:
// npx playwright install chromium # one-time
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
// node tools/smokeJigsaw.mjs http://localhost:9000
//
// What it checks:
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
// - forced win (placed = total; onWin()) runs the full showcase:
// * camera sweep lands on WIN_ZOOM framing the board
// * stats counter animates up to the total
// * celebration emitters fire (burst + curtain) and confetti is alive
// * gold frame + shine sweep exist at the right depths
// - "Play Again" restarts a fresh board and tears the win layer down
// - "Menu" returns to the menu and destroys the win layer
// - menu → Start Puzzle works again
// - zero JS console/page errors across the whole flow
//
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
// waits here are condition-based polling (generous timeouts), never fixed
// sleeps. A full run takes roughly 1–3 minutes of wall time.
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import os from 'node:os';
import path from 'node:path';
import { chromium } from 'playwright';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
let server = null;
if (!process.argv[2]) {
try {
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
} catch {
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
await new Promise((r) => setTimeout(r, 1200));
console.log('started local static server on :8000');
}
}
const errors = [];
let failed = false;
const ok = (m) => console.log(' ok ' + m);
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
const browser = await chromium.launch({ args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
const waitFor = async (name, cond, timeoutMs) => {
const t0 = Date.now();
while (Date.now() - t0 < timeoutMs) {
if (await evalS(cond)) return true;
await new Promise((r) => setTimeout(r, 500));
}
bad(`timeout waiting for: ${name}`);
return false;
};
const snap = async (name) => {
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
await page.screenshot({ path: dest, fullPage: false });
ok(`screenshot ${dest}`);
};
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
try {
console.log('— load —');
await page.goto(BASE, { waitUntil: 'load' });
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
ok('harness ready');
console.log('— start puzzle —');
await evalS(() => window.__startPuzzle());
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
ok('state = playing');
const pieces = await evalS(() => window.__pieces());
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
if (pieces.length === 0) bad('no pieces spawned');
await snap('1_playing');
console.log('— force win —');
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
if (await waitFor('camera settled + counter=total', () => {
const s = window.__scene();
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
&& s.piecesBig && s.piecesBig.text === String(s.total);
}, settleTimeout)) {
const wi = await evalS(() => {
const s = window.__scene();
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
});
ok('settled: ' + JSON.stringify(wi));
await snap('2_win_panel');
}
console.log('— wait for celebration (emitters created + particles alive) —');
if (await waitFor('emitters alive', () => {
const s = window.__scene();
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
}, settleTimeout)) {
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
ok('particles alive: ' + JSON.stringify(alive));
await snap('3_confetti');
const fx = await evalS(() => {
const s = window.__scene();
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
});
ok('frame/shine: ' + JSON.stringify(fx));
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
}
console.log('— Play Again button —');
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
const winGone = await evalS(() => !window.__scene().winLayer);
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
if (placed !== 0) bad('pieces not reset after Play Again');
if (!winGone) bad('winLayer not destroyed after Play Again');
await snap('4_restarted');
}
console.log('— force win again, then Menu button —');
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
ok('second win showcase running');
await click(400, 720); // inside "Menu"
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
const winGone = await evalS(() => !window.__scene().winLayer);
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
if (!menuVisible) bad('menu not visible');
if (!winGone) bad('winLayer not destroyed');
await snap('5_menu');
}
}
console.log('— Start Puzzle from menu works —');
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
} finally {
await browser.close();
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
}
console.log('\n— JS errors (' + errors.length + ') —');
errors.forEach((e) => console.log(' ' + e));
if (errors.length) failed = true;
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
process.exit(failed ? 1 : 0);

View File

@ -1,14 +1,13 @@
#!/usr/bin/env node
// verifyBookwork.js — engine tests for Bookworm
// verifyBookwork.js — engine tests for Bookwork
import { readFileSync } from 'node:fs';
import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
computeMaxHp, isPotionUnlocked,
} from '../src/games/bookwork/BookworkLogic.js';
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
let pass = 0, fail = 0;
@ -93,34 +92,6 @@ const after = clearAndRefill(gridR, used);
ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE));
ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type));
// ── special tile schedule ────────────────────────────────────────────────────
console.log('\nspecial tile schedule');
const SCHED = {
startLevel: 1, endLevel: 10,
start: { gold: 0.10, diamond: 0.06 },
end: { gold: 0.03, diamond: 0.02 },
};
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
const at = (lv) => specialTileChances(lv, SCHED);
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
ok('clamps below startLevel', at(0).goldChance === 0.10);
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
const newCount = 3;
ok('goldChance:1 → every fresh tile is gold',
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
ok('diamondChance:1 → every fresh tile is diamond',
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
ok('refillSteered honors goldChance:1',
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
// ── dropSpecialTile ────────────────────────────────────────────────────────────
console.log('\ndropSpecialTile');
const gridS = makeGrid(() => 0.5);
@ -204,10 +175,6 @@ if (bwData) {
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
ok('playerBaseHp present', bwData.playerBaseHp > 0);
ok('milestones array present', Array.isArray(bwData.milestones));
const st = bwData.specialTiles;
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
}
// ── Summary ───────────────────────────────────────────────────────────────────

View File

@ -1,210 +0,0 @@
#!/usr/bin/env node
// verifyBookwormBoard.js — Bookworm board-quality harness
//
// Compares the plain weighted-random board (baseline) against the steering
// layer (class steering + vowel band + word-completion boost) on:
// • static boards — words available, dead boards, vowel share
// • simulated sessions — play the longest word each turn, refill, repeat:
// words available per turn, dead turns (no word to play), turn survival
//
// Deterministic (seeded RNG). The dictionary is the same ENABLE list
// (3–15 letters) that /words/scrabble/validate accepts, so "available word"
// means "word the player can actually submit".
//
// Run: node tools/verifyBookwormBoard.js
import { readFileSync } from 'node:fs';
import {
GRID_SIZE, makeGrid, clearAndRefill, getAdjacent,
} from '../src/games/bookwork/BookworkLogic.js';
import {
makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER,
} from '../src/games/bookwork/BookworkSteering.js';
const N_BOARDS = 300;
const N_SESSIONS = 60;
const MAX_TURNS = 30;
let pass = 0, fail = 0;
function ok(label, cond) {
if (cond) { console.log(` ✓ ${label}`); pass++; }
else { console.error(` ✗ ${label}`); fail++; }
}
// ── deterministic rng ─────────────────────────────────────────────────────────
function mulberry32(seed) {
let a = seed >>> 0;
return function () {
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;
};
}
// ── word finding (boggle DFS with prefix pruning; one path per word) ─────────
function buildPrefixSet(wordSet) {
const pre = new Set();
for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i));
return pre;
}
function findWords(grid, wordSet, prefixSet) {
const found = new Map(); // word -> [cells]
const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false));
const dfs = (r, c, word, cells) => {
const w = word + grid[r][c].letter;
const isWord = w.length >= 3 && wordSet.has(w);
if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]);
if (!prefixSet.has(w)) return;
if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return;
for (const { r: nr, c: nc } of getAdjacent(r, c)) {
if (visited[nr][nc]) continue;
visited[nr][nc] = true;
dfs(nr, nc, w, [...cells, { r, c }]);
visited[nr][nc] = false;
}
};
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) {
visited[r][c] = true;
dfs(r, c, '', []);
visited[r][c] = false;
}
return found;
}
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
const vowelShare = (grid) =>
grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE);
const quantile = (arr, q) => {
const s = [...arr].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
};
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length);
// ── setup ─────────────────────────────────────────────────────────────────────
console.log('Loading dictionary…');
const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8'));
const prefixSet = buildPrefixSet(wordSet);
console.log(` ${wordSet.size} words (3–15 letters), ${prefixSet.size} prefixes\n`);
const variants = {
baseline: {
make: (rng) => makeGrid(rng),
refill: (g, cells, rng) => clearAndRefill(g, cells, rng),
},
steered: {
make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER),
refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER),
},
};
// ── steering sanity ───────────────────────────────────────────────────────────
console.log('Steering sanity');
{
const rng = mulberry32(1);
const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER);
ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE));
ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter)));
ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q'));
const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER);
ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0);
ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0);
const letter = pickWeighted(w, mulberry32(7));
ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q');
const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER);
ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter)));
}
// ── static board quality ──────────────────────────────────────────────────────
console.log(`\nStatic boards (N=${N_BOARDS} each)`);
const staticStats = {};
for (const [name, v] of Object.entries(variants)) {
const wordCounts = [], shares = [];
for (let i = 0; i < N_BOARDS; i++) {
const g = v.make(mulberry32(1000 + i));
wordCounts.push(findWords(g, wordSet, prefixSet).size);
shares.push(vowelShare(g));
}
staticStats[name] = {
wordCounts,
dead: wordCounts.filter((n) => n === 0).length,
min: Math.min(...wordCounts),
p5: quantile(wordCounts, 0.05),
median: quantile(wordCounts, 0.5),
mean: mean(wordCounts),
max: Math.max(...wordCounts),
vowelMin: Math.min(...shares),
vowelMax: Math.max(...shares),
vowelMean: mean(shares),
};
}
const row = (label, f) =>
console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`);
console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14));
row('words/board (min)', (n) => staticStats[n].min);
row('words/board (p5)', (n) => staticStats[n].p5);
row('words/board (median)', (n) => staticStats[n].median);
row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1));
row('words/board (max)', (n) => staticStats[n].max);
row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`);
row('vowel share (min–max)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}–${(100 * staticStats[n].vowelMax).toFixed(0)}%`);
row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`);
// ── simulated sessions ────────────────────────────────────────────────────────
console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`);
const sessionStats = {};
for (const [name, v] of Object.entries(variants)) {
const totals = [], wpt = [], dead = [], initial = [], longest = [];
for (let i = 0; i < N_SESSIONS; i++) {
const rng = mulberry32(90000 + i);
let grid = v.make(rng);
let deadTurn = false, total = 0;
for (let t = 0; t < MAX_TURNS; t++) {
const words = findWords(grid, wordSet, prefixSet);
if (t === 0) initial.push(words.size);
if (words.size === 0) { deadTurn = true; break; }
total += words.size;
wpt.push(words.size);
let best = null;
for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells };
longest.push(best.w.length);
grid = v.refill(grid, best.cells, rng);
}
totals.push(total);
if (deadTurn) dead.push(1); else dead.push(0);
}
sessionStats[name] = {
deadRate: mean(dead),
totalMedian: quantile(totals, 0.5),
totalMean: mean(totals),
wptMin: Math.min(...wpt),
wptP5: quantile(wpt, 0.05),
wptMedian: quantile(wpt, 0.5),
initialMedian: quantile(initial, 0.5),
longestMax: Math.max(...longest),
};
}
row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`);
row('initial words (median)', (n) => sessionStats[n].initialMedian);
row('words/turn (min)', (n) => sessionStats[n].wptMin);
row('words/turn (p5)', (n) => sessionStats[n].wptP5);
row('words/turn (median)', (n) => sessionStats[n].wptMedian);
row('total words (median)', (n) => sessionStats[n].totalMedian);
row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0));
row('longest word seen', (n) => sessionStats[n].longestMax);
// ── comparisons ───────────────────────────────────────────────────────────────
console.log('\nComparison');
ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median);
ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead);
ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5);
ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate);
ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian);
ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian);
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`);
process.exit(fail > 0 ? 1 : 0);

View File

@ -1,437 +0,0 @@
// Headless verification for Defender.
// node tools/verifyDefender.js
// Exits non-zero on any failure.
//
// 1. Wraparound math (wrap/tdelta) round-trip and shortest-path correctness.
// 2. Boids seam correctness (neighbors across the wrap seam attract/repel
// as if adjacent, not as if worlds apart).
// 3. Rescue state machine — every transition in the humanoid lifecycle.
// 4. Overdrive meter thresholds and combo multiplier behavior.
// 5. No entity leaks across a long soak.
// 6. Boss defeat always precedes exactly one levelComplete, tally correct.
// 7. Spiral-of-death guard on a huge injected deltaMs.
// 8. Determinism — same seed + same input sequence replayed twice.
// 9. Monte-carlo bot soak across many seeds through all 5 levels.
// 10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety.
import {
WORLD_W, Y_SKY, Y_GROUND, STEP_MS, MAX_STEPS, TUNE,
wrap, tdelta, tdist, createGame, setInput, step,
BOSS_PROFILES, bossSpec,
} from '../src/games/defender/DefenderLogic.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 runTicks(state, count, input = {}) {
setInput(state, input);
const events = [];
for (let i = 0; i < count; i += 1) events.push(...step(state, STEP_MS));
return events;
}
// ---------------------------------------------------------------------------
console.log('1. Wraparound math');
{
check('wrap() folds negative into range', wrap(-10) === WORLD_W - 10);
check('wrap() folds overflow into range', wrap(WORLD_W + 25) === 25);
check('wrap() is identity inside range', wrap(1234) === 1234);
check('tdelta shortest path across seam is small', Math.abs(tdelta(5, WORLD_W - 5)) === 10,
`got ${tdelta(5, WORLD_W - 5)}`);
check('tdelta sign points the short way', tdelta(5, WORLD_W - 5) < 0);
check('tdelta of equal points is 0', tdelta(500, 500) === 0);
check('tdelta magnitude never exceeds half the world', Math.abs(tdelta(0, WORLD_W / 2)) <= WORLD_W / 2 + 1e-9);
}
// ---------------------------------------------------------------------------
console.log('2. Boids seam correctness');
{
const state = createGame({ seed: 1 });
state.phase = 'wave';
state.spawnQueue = [];
state.enemies = [
{ id: 901, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: 5, y: 400, vx: 0, vy: 0 },
{ id: 902, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: WORLD_W - 5, y: 400, vx: 0, vy: 0 },
];
state.player.x = 3000; // far from both, out of seek range
const before = tdist(state.enemies[0].x, state.enemies[0].y, state.enemies[1].x, state.enemies[1].y);
runTicks(state, 30);
const [a, b] = state.enemies;
const after = tdist(a.x, a.y, b.x, b.y);
check('seam-adjacent swarmers perceive each other as close', before < 20, `raw seam gap ${before}`);
check('seam-adjacent swarmers stay bounded, not flung apart', after < 400,
`wrapped distance grew to ${after}`);
check('no NaN positions after seam interaction', Number.isFinite(a.x) && Number.isFinite(b.x));
}
// ---------------------------------------------------------------------------
console.log('3. Rescue state machine');
{
// grabbed -> lost (escaped past Y_SKY)
{
const state = createGame({ seed: 2 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
h.status = 'grabbed'; h.grabberId = 777;
state.enemies = [{ id: 777, type: 'abductor', hp: 2, radius: TUNE.ABDUCTOR_RADIUS, x: h.x, y: Y_SKY + 2, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }];
const ev = runTicks(state, 5);
check('grabbed humanoid lost on reaching Y_SKY', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'escaped'));
}
// grabbed -> falling (carrying abductor dies)
{
const state = createGame({ seed: 3 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
h.status = 'grabbed'; h.grabberId = 778; h.x = 3000; h.y = 400;
state.enemies = [{ id: 778, type: 'abductor', hp: 1, radius: TUNE.ABDUCTOR_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, carryingId: h.id, targetHumanoidId: null }];
state.shots = [{ x: 3000, y: 400, vx: 0, vy: 0, ttlMs: 500 }];
const ev = runTicks(state, 1);
check('humanoid freed when its abductor dies', h.status === 'falling' && ev.some((e) => e.type === 'humanoidFreed'));
}
// falling -> lost (hits ground)
{
const state = createGame({ seed: 4 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
h.status = 'falling'; h.y = Y_GROUND - 2; h.vy = TUNE.FALL_SPEED; h.timerMs = 0;
state.player.x = wrap(h.x + 3000); // keep the player far away so it can't intercept
const ev = runTicks(state, 3);
check('falling humanoid lost on hitting ground', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'hitGround'));
}
// falling -> lost (grab window elapses, never reaches ground)
{
const state = createGame({ seed: 5 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
// Fall distance over a full GRAB_WINDOW_MS at the constant FALL_SPEED is
// ~1120px; start well above that so "hits ground" can't pre-empt this
// test of the "grab window elapses" path.
h.status = 'falling'; h.y = Y_GROUND - 1400; h.vy = TUNE.FALL_SPEED; h.timerMs = 0;
state.player.x = wrap(h.x + 3000);
const justBefore = Math.floor((TUNE.GRAB_WINDOW_MS - 3 * STEP_MS) / STEP_MS);
runTicks(state, justBefore);
const stillFalling = h.status === 'falling';
const ev = runTicks(state, 10);
check('grab window not triggered early', stillFalling);
check('falling humanoid lost when grab window elapses', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'grabWindow'));
}
// falling -> carried (player proximity)
{
const state = createGame({ seed: 6 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
h.status = 'falling'; h.y = 400; h.vy = 0; h.timerMs = 0;
state.player.x = h.x; state.player.y = h.y; state.player.vx = 0; state.player.vy = 0;
const ev = runTicks(state, 1);
check('nearby falling humanoid is auto-picked-up', h.status === 'carried' && state.player.carrying === h.id
&& ev.some((e) => e.type === 'humanoidPickedUp'));
}
// carried -> rescued (extraction zone)
{
const state = createGame({ seed: 7 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
const zoneX = state.extractionZones[0].x;
h.status = 'carried'; h.timerMs = 0;
state.player.carrying = h.id; state.player.x = zoneX; state.player.y = 400; state.player.vx = 0; state.player.vy = 0;
const ev = runTicks(state, 1);
check('carried humanoid rescued at extraction zone', h.status === 'rescued' && state.player.carrying === null
&& state.rescuedThisLevel === 1 && ev.some((e) => e.type === 'humanoidRescued'));
}
// carried -> lost (timeout)
{
const state = createGame({ seed: 8 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
h.status = 'carried'; h.timerMs = 0;
state.player.carrying = h.id;
state.player.x = wrap(state.extractionZones[0].x + WORLD_W / 2); // far from every zone
state.player.y = 400; state.player.vx = 0; state.player.vy = 0;
const ticks = Math.ceil(TUNE.CARRY_TIMEOUT_MS / STEP_MS) + 2;
const ev = runTicks(state, ticks);
check('carried humanoid lost after carry timeout', h.status === 'lost' && state.player.carrying === null
&& ev.some((e) => e.type === 'humanoidLost' && e.reason === 'carryTimeout'));
}
// carried -> lost (player dies)
{
const state = createGame({ seed: 9 });
state.phase = 'wave'; state.spawnQueue = [];
const h = state.humanoids[0];
h.status = 'carried'; h.timerMs = 0;
state.player.carrying = h.id; state.player.invulnMs = 0;
state.player.x = 3000; state.player.y = 400;
state.enemies = [{ id: 555, type: 'walker', hp: 3, radius: TUNE.WALKER_RADIUS, x: 3000, y: 400, vx: 0, vy: 0, homeX: 3000, dir: 1, fireCooldownMs: 9999 }];
const ev = runTicks(state, 1);
check('carried humanoid lost when player dies', h.status === 'lost' && ev.some((e) => e.type === 'humanoidLost' && e.reason === 'playerDied'));
check('player death event also fired', ev.some((e) => e.type === 'playerDied'));
}
// illegal transition: can't pick up a second humanoid while already carrying one
{
const state = createGame({ seed: 10 });
state.phase = 'wave'; state.spawnQueue = [];
const [h1, h2] = state.humanoids;
h1.status = 'carried'; h1.timerMs = 0;
h2.status = 'falling'; h2.y = 400; h2.vy = 0; h2.timerMs = 0; h2.x = wrap(h1.x);
state.player.carrying = h1.id;
state.player.x = h2.x; state.player.y = 400; state.player.vx = 0; state.player.vy = 0;
runTicks(state, 1);
check('already-carrying player cannot pick up a second humanoid', state.player.carrying === h1.id && h2.status === 'falling');
}
}
// ---------------------------------------------------------------------------
console.log('4. Overdrive meter & combo multiplier');
{
const state = createGame({ seed: 11 });
state.phase = 'wave'; state.spawnQueue = [];
let readyFired = 0; let startFired = 0; let endFired = 0;
let prevMeter = 0; let monotonicOnFill = true;
let killsToReady = 0;
while (state.overdriveMeter < 1 && killsToReady < 60) {
killsToReady += 1;
state.enemies = [{ id: 1000 + killsToReady, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state.player.x, y: state.player.y, vx: 0, vy: 0 }];
state.shots = [{ x: state.player.x, y: state.player.y, vx: 0, vy: 0, ttlMs: 500 }];
prevMeter = state.overdriveMeter;
const ev = runTicks(state, 1);
if (state.overdriveMeter < prevMeter) monotonicOnFill = false;
readyFired += ev.filter((e) => e.type === 'overdriveReady').length;
}
check('overdrive meter fills monotonically with kills', monotonicOnFill);
check('expected number of kills to fill the meter', killsToReady === Math.ceil(1 / TUNE.OVERDRIVE_FILL_PER_KILL),
`took ${killsToReady} kills`);
check('overdriveReady fires exactly once at the 1.0 crossing', readyFired === 1, `fired ${readyFired} times`);
check('multiplier escalated across the rapid kill chain', state.multiplier > 1, `multiplier=${state.multiplier}`);
const startEv = runTicks(state, 1, { overdrive: true });
startFired = startEv.filter((e) => e.type === 'overdriveStart').length;
check('overdriveStart fires exactly once on trigger', startFired === 1 && state.overdriveActive === true);
const ticksForFullDrain = Math.ceil(TUNE.OVERDRIVE_DURATION_MS / (STEP_MS * TUNE.OVERDRIVE_TIMESCALE)) + 5;
const endEv = runTicks(state, ticksForFullDrain, { overdrive: false });
endFired = endEv.filter((e) => e.type === 'overdriveEnd').length;
check('overdriveEnd fires exactly once after duration elapses', endFired === 1 && state.overdriveActive === false,
`fired ${endFired} times, active=${state.overdriveActive}`);
// combo reset after a gap
const state2 = createGame({ seed: 12 });
state2.phase = 'wave'; state2.spawnQueue = [];
state2.enemies = [{ id: 2001, type: 'swarmer', hp: 1, radius: TUNE.SWARMER_RADIUS, x: state2.player.x, y: state2.player.y, vx: 0, vy: 0 }];
state2.shots = [{ x: state2.player.x, y: state2.player.y, vx: 0, vy: 0, ttlMs: 500 }];
runTicks(state2, 1);
const multAfterOneKill = state2.multiplier;
runTicks(state2, Math.ceil((TUNE.COMBO_WINDOW_MS + 200) / STEP_MS));
check('combo multiplier resets after the combo window elapses', multAfterOneKill >= 1 && state2.multiplier === 1,
`after=${state2.multiplier}`);
}
// ---------------------------------------------------------------------------
console.log('5. No entity leaks / bounded arrays across a soak');
{
const state = createGame({ seed: 13 });
let maxEnemies = 0; let maxShots = 0; let maxEnemyShots = 0; let maxHumanoids = 0;
for (let i = 0; i < 6000; i += 1) {
setInput(state, { right: i % 120 < 60, fire: true, overdrive: state.overdriveMeter >= 1 });
step(state, STEP_MS);
maxEnemies = Math.max(maxEnemies, state.enemies.length);
maxShots = Math.max(maxShots, state.shots.length);
maxEnemyShots = Math.max(maxEnemyShots, state.enemyShots.length);
maxHumanoids = Math.max(maxHumanoids, state.humanoids.length);
if (state.over) break;
}
check('enemy count stays bounded', maxEnemies < 200, `max ${maxEnemies}`);
check('player shot count stays bounded', maxShots < 500, `max ${maxShots}`);
check('enemy shot count stays bounded', maxEnemyShots < 500, `max ${maxEnemyShots}`);
check('humanoid count stays bounded near per-level count', maxHumanoids <= TUNE.HUMANOIDS_PER_LEVEL + 1, `max ${maxHumanoids}`);
}
// ---------------------------------------------------------------------------
console.log('6. Boss defeat -> exactly one levelComplete, tally correct');
{
const state = createGame({ seed: 14 });
state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = [];
let ev = runTicks(state, 1); // spawns the boss
check('boss spawns from bossIntro', state.boss != null && ev.some((e) => e.type === 'bossSpawn'));
state.boss.hp = 1;
state.shots = [{ x: state.boss.x, y: state.boss.y, vx: 0, vy: 0, ttlMs: 500 }];
ev = runTicks(state, 1);
const defeatIdx = ev.findIndex((e) => e.type === 'bossDefeated');
const completeCount = ev.filter((e) => e.type === 'levelComplete').length;
check('bossDefeated fires', defeatIdx >= 0);
check('exactly one levelComplete follows boss defeat', completeCount === 1, `got ${completeCount}`);
const complete = ev.find((e) => e.type === 'levelComplete');
check('levelComplete tally matches rescued/lost counts', complete
&& complete.rescued === state.rescuedThisLevel && complete.lost === state.lostThisLevel);
}
// ---------------------------------------------------------------------------
console.log('7. Spiral-of-death guard');
{
const state = createGame({ seed: 15 });
const before = state.timeMs;
step(state, 5000); // a huge delta, e.g. a backgrounded tab waking up
const advanced = state.timeMs - before;
check('a huge delta only advances MAX_STEPS worth of sim time',
advanced <= MAX_STEPS * STEP_MS + 1e-6, `advanced ${advanced}ms`);
check('leftover accumulator is discarded rather than replayed', state.accumulatorMs === 0,
`accumulatorMs=${state.accumulatorMs}`);
}
// ---------------------------------------------------------------------------
console.log('8. Determinism');
{
function scriptedInputAt(i) {
const p = i % 240;
return {
left: p < 40, right: p >= 40 && p < 90, up: p >= 90 && p < 110, down: p >= 150 && p < 170,
fire: true, overdrive: p === 200,
};
}
function replay(seed, ticks) {
const s = createGame({ seed });
const log = [];
for (let i = 0; i < ticks; i += 1) {
setInput(s, scriptedInputAt(i));
log.push(...step(s, STEP_MS));
}
return { s, log };
}
const a = replay(42, 3000);
const b = replay(42, 3000);
const same = JSON.stringify(a.log) === JSON.stringify(b.log);
check('same seed + same inputs produce identical event streams', same);
check('same seed + same inputs produce identical final score', a.s.score === b.s.score, `${a.s.score} vs ${b.s.score}`);
check('same seed + same inputs produce identical final state shape',
JSON.stringify(a.s) === JSON.stringify(b.s));
}
// ---------------------------------------------------------------------------
console.log('9. Monte-carlo bot soak (all 5 levels)');
{
function nearestEnemyX(state) {
let best = null; let bestD = Infinity;
for (const e of state.enemies) {
const d = Math.abs(tdelta(state.player.x, e.x));
if (d < bestD) { bestD = d; best = e; }
}
return best;
}
function botInput(state) {
const p = state.player;
let targetX = p.x; let targetY = (Y_GROUND + 400) / 2;
if (p.carrying != null) {
const zone = state.extractionZones[0];
targetX = zone.x; targetY = 400;
} else {
const falling = state.humanoids.find((h) => h.status === 'falling');
if (falling) { targetX = falling.x; targetY = falling.y; }
else {
const e = nearestEnemyX(state);
if (e) { targetX = e.x; targetY = e.y; }
}
}
const dx = tdelta(p.x, targetX);
const dy = targetY - p.y;
return {
left: dx < -8, right: dx > 8, up: dy < -8, down: dy > 8,
fire: true, overdrive: state.overdriveMeter >= 1,
};
}
let seedsRun = 0; let victories = 0; let gameOvers = 0;
const SEEDS = 8;
const MAX_TICKS = 200000; // generous safety valve; a healthy sim finishes well inside this
for (let seed = 1; seed <= SEEDS; seed += 1) {
const state = createGame({ seed: seed * 1000 + 7 });
let ticks = 0;
let invariantsOk = true;
while (!state.over && ticks < MAX_TICKS) {
setInput(state, botInput(state));
step(state, STEP_MS);
ticks += 1;
if (!Number.isFinite(state.player.x) || !Number.isFinite(state.player.y)) invariantsOk = false;
if (state.level < 1 || state.level > TUNE.LEVEL_COUNT) invariantsOk = false;
if (state.multiplier < 1 || state.multiplier > TUNE.MULT_MAX) invariantsOk = false;
if (state.overdriveMeter < 0 || state.overdriveMeter > 1 + 1e-9) invariantsOk = false;
if (!invariantsOk) break;
}
seedsRun += 1;
check(`seed ${seed}: invariants held every tick`, invariantsOk);
check(`seed ${seed}: run terminated (won or lost) within budget`, state.over, `stopped at ${ticks} ticks, phase=${state.phase}`);
if (state.victory) victories += 1;
if (state.over && !state.victory) gameOvers += 1;
}
check('every seed in the soak terminated', seedsRun === SEEDS);
console.log(` info: ${victories}/${SEEDS} bot runs reached victory, ${gameOvers}/${SEEDS} ended in game over`);
}
// ---------------------------------------------------------------------------
console.log('10. Boss difficulty escalation — HP/cooldown trend and per-level attack variety');
{
const VALID_MOVES = new Set(['ring', 'spread', 'spiral', 'aimed', 'wall', 'reinforce']);
check('one boss profile authored per level', BOSS_PROFILES.length === TUNE.LEVEL_COUNT,
`${BOSS_PROFILES.length} profiles vs ${TUNE.LEVEL_COUNT} levels`);
let prevHp = -Infinity; let prevCooldown = Infinity;
let hpMonotonic = true; let cooldownMonotonic = true; let allMovesValid = true; let anyDualPhase = false;
for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) {
const spec = bossSpec(level);
const profile = BOSS_PROFILES[level - 1];
if (spec.hp <= prevHp) hpMonotonic = false;
prevHp = spec.hp;
if (profile.cooldownMs >= prevCooldown) cooldownMonotonic = false;
prevCooldown = profile.cooldownMs;
for (const m of profile.moves) if (!VALID_MOVES.has(m)) allMovesValid = false;
if (profile.phase2Moves) {
anyDualPhase = true;
for (const m of profile.phase2Moves) if (!VALID_MOVES.has(m)) allMovesValid = false;
}
}
check('boss HP strictly increases level over level', hpMonotonic);
check('boss attack cooldown strictly shortens level over level (attacks come faster)', cooldownMonotonic);
check('every authored move name is a recognized attack pattern', allMovesValid);
check('later levels introduce a second, harder attack phase', anyDualPhase);
// Live-sim: spawn each level's boss directly and confirm its phase-1 moves cycle
// in the declared round-robin order, and phase-2 moves take over once wounded.
for (let level = 1; level <= TUNE.LEVEL_COUNT; level += 1) {
const profile = BOSS_PROFILES[level - 1];
const state = createGame({ seed: 900 + level, startLevel: level });
state.player.invulnMs = 999999; // isolate attack-pattern dispatch from collision outcomes
state.phase = 'bossIntro'; state.phaseMs = TUNE.BOSS_INTRO_MS; state.spawnQueue = [];
runTicks(state, 1); // spawns the boss
const ticksPerCycle = Math.ceil(profile.cooldownMs / STEP_MS) + 2;
const observed = [];
for (let i = 0; i < profile.moves.length; i += 1) {
const ev = runTicks(state, ticksPerCycle);
const mv = ev.find((e) => e.type === 'bossMove');
if (mv) observed.push(mv.move);
}
const matches = observed.length === profile.moves.length && observed.every((m, i) => m === profile.moves[i]);
check(`level ${level} (${profile.name}) phase-1 moves cycle in declared order`,
matches, `expected ${JSON.stringify(profile.moves)}, observed ${JSON.stringify(observed)}`);
if (profile.phase2Moves) {
state.boss.hp = 1; // force the phase-2 threshold on the next tick
const ev2 = runTicks(state, ticksPerCycle);
const phaseEv = ev2.find((e) => e.type === 'bossPhaseChange');
const mv2 = ev2.find((e) => e.type === 'bossMove');
check(`level ${level} (${profile.name}) drops into phase 2 when wounded`, !!phaseEv && phaseEv.phase === 2);
check(`level ${level} (${profile.name}) phase-2 move comes from its harder move set`,
!!mv2 && profile.phase2Moves.includes(mv2.move), `got ${mv2 && mv2.move}`);
}
}
}
// ---------------------------------------------------------------------------
if (failures > 0) {
console.error(`\n${failures} check(s) FAILED`);
process.exit(1);
} else {
console.log('\nAll checks passed.');
}

View File

@ -1,650 +0,0 @@
// Headless verification for Geometry Wars.
// node tools/verifyGeometryWars.js
// Exits non-zero on any failure.
//
// 1. Setup per mode (lives, bombs, gun).
// 2. Movement: speed cap, diagonal normalisation, arena clamp.
// 3. Firing: volley size/rate per weapon tier; pacifism can't shoot.
// 4. Kill → score × multiplier, geoms drop, pickup raises multiplier + gun.
// 5. Splitter splits into three minis; bombs/deaths never split.
// 6. Snake: tail eats bullets, head dies, tail kills the player.
// 7. Black hole: dormant until shot, pulls + absorbs, overloads into protons.
// 8. Bomb: kills everything for no points, edge-triggered, consumes a bomb.
// 9. Death: board wipe, multiplier/gun reset, respawn invulnerability,
// game over when the last ship goes.
// 10. Extra life / bomb thresholds.
// 11. Pacifism gates: crossing detonates, posts kill, blast radius.
// 12. Spawning: nothing within the safe radius; warming-up enemies are inert.
// 13. Spiral-of-death guard.
// 14. Determinism.
// 15. Warp grid: settles back to rest, never NaNs.
// 16. Particle field: pool cap, NaN-free, hole capture.
// 17. Bot soak: many seeds, both modes, invariants every tick.
import {
STEP_MS, MAX_STEPS, ARENA_W, ARENA_H, TUNE, ENEMY_TYPES, WEAPONS,
createGame, setInput, step, spawnEnemy, snakeSegments, gateEndpoints,
weaponTierFor, runPattern, _internal,
} from '../src/games/geometrywars/GeometryWarsLogic.js';
import WarpGrid from '../src/games/geometrywars/GeometryWarsGrid.js';
import ParticleField, { FX_MAX } from '../src/games/geometrywars/GeometryWarsFx.js';
let failures = 0;
let passes = 0;
function check(name, cond, detail = '') {
if (cond) { passes += 1; console.log(` ok ${name}`); return; }
failures += 1;
console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
}
function runTicks(s, count, input = null) {
if (input) setInput(s, input);
const events = [];
for (let i = 0; i < count; i += 1) events.push(...step(s, STEP_MS));
return events;
}
// A quiet arena: director held off so tests control every spawn.
function quiet(mode = 'evolved', seed = 7) {
const s = createGame({ seed, mode });
s.director.spawnCd = 1e12;
s.director.gateCd = 1e12;
return s;
}
const ofType = (events, type) => events.filter((e) => e.type === type);
// ---------------------------------------------------------------------------
console.log('1. Setup per mode');
{
const ev = createGame({ seed: 1, mode: 'evolved' });
check('evolved starts with 3 ships', ev.lives === 3);
check('evolved starts with 3 bombs', ev.bombs === 3);
check('evolved starts on the twin stream', ev.weaponTier === 0 && WEAPONS[0].shots.length === 2);
check('multiplier starts at 1', ev.multiplier === 1);
const pc = createGame({ seed: 1, mode: 'pacifism' });
check('pacifism is one ship', pc.lives === 1);
check('pacifism has no bombs', pc.bombs === 0);
check('unknown mode falls back to evolved', createGame({ mode: 'bogus' }).mode === 'evolved');
check('player starts mid-arena', ev.player.x === ARENA_W / 2 && ev.player.y === ARENA_H / 2);
}
// ---------------------------------------------------------------------------
console.log('2. Movement');
{
const s = quiet();
runTicks(s, 90, { moveX: 1, moveY: 0 });
const sp = Math.hypot(s.player.vx, s.player.vy);
check('reaches but never exceeds top speed', sp <= TUNE.PLAYER_SPEED + 1e-6 && sp > TUNE.PLAYER_SPEED * 0.95, `speed ${sp.toFixed(1)}`);
const d = quiet();
runTicks(d, 90, { moveX: 1, moveY: 1 });
const dsp = Math.hypot(d.player.vx, d.player.vy);
check('diagonal is normalised (no √2 boost)', dsp <= TUNE.PLAYER_SPEED + 1e-6, `speed ${dsp.toFixed(1)}`);
const c = quiet();
runTicks(c, 400, { moveX: -1, moveY: -1 });
check('clamped inside arena (top-left)', c.player.x >= TUNE.PLAYER_RADIUS - 1e-9 && c.player.y >= TUNE.PLAYER_RADIUS - 1e-9);
runTicks(c, 600, { moveX: 1, moveY: 1 });
check('clamped inside arena (bottom-right)', c.player.x <= ARENA_W - TUNE.PLAYER_RADIUS + 1e-9 && c.player.y <= ARENA_H - TUNE.PLAYER_RADIUS + 1e-9);
const f = quiet();
runTicks(f, 60, { moveX: 0, moveY: 1 });
check('ship turns to face its travel direction', Math.abs(f.player.angle - Math.PI / 2) < 0.05, `angle ${f.player.angle.toFixed(3)}`);
}
// ---------------------------------------------------------------------------
console.log('3. Firing');
{
for (let tier = 0; tier < WEAPONS.length; tier += 1) {
const s = quiet();
s.weaponTier = tier;
const ev = runTicks(s, 1, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
check(`tier ${tier} (${WEAPONS[tier].name}) fires ${WEAPONS[tier].shots.length} bullets`, s.bullets.length === WEAPONS[tier].shots.length && ofType(ev, 'shotFired').length === 1,
`got ${s.bullets.length}`);
}
const s = quiet();
const ev = runTicks(s, 60, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
const volleys = ofType(ev, 'shotFired').length;
const expected = Math.floor(1000 / WEAPONS[0].intervalMs) + 1;
check('fire rate matches the weapon interval', Math.abs(volleys - expected) <= 1, `${volleys} volleys/s, expected ~${expected}`);
const forward = s.bullets.every((b) => b.vx > 0 && Math.abs(b.vy) < Math.abs(b.vx) * 0.1);
check('bullets travel toward the aim point', forward);
const pc = quiet('pacifism');
runTicks(pc, 60, { fire: true, aimX: 0, aimY: 0 });
check('pacifism cannot shoot', pc.bullets.length === 0);
const wall = quiet();
const wev = runTicks(wall, 120, { fire: true, aimX: wall.player.x + 5000, aimY: wall.player.y });
check('bullets that reach a wall emit bulletWall', ofType(wev, 'bulletWall').length > 0);
check('no bullet survives outside the arena', wall.bullets.every((b) => b.x >= 0 && b.x <= ARENA_W && b.y >= 0 && b.y <= ARENA_H));
check('weaponTierFor thresholds', weaponTierFor(1) === 0 && weaponTierFor(10) === 1 && weaponTierFor(40) === 2 && weaponTierFor(100) === 3 && weaponTierFor(99999) === 3);
}
// ---------------------------------------------------------------------------
console.log('4. Kills, geoms, multiplier');
{
const s = quiet();
s.multiplier = 3;
const g = spawnEnemy(s, 'grunt', s.player.x + 300, s.player.y, { spawnMs: 0 });
g.vx = 0;
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 300, aimY: s.player.y });
const kill = ofType(ev, 'enemyKilled')[0];
check('grunt killed by bullet', kill && kill.enemyType === 'grunt' && kill.cause === 'bullet');
check('points = base × multiplier', kill && kill.points === ENEMY_TYPES.grunt.score * 3, `points ${kill?.points}`);
check('score credited', s.score === ENEMY_TYPES.grunt.score * 3);
check('kill dropped geoms', s.geoms.length + s.stats.geoms >= ENEMY_TYPES.grunt.geoms);
const p = quiet();
for (let i = 0; i < 9; i += 1) p.geoms.push({ id: 1000 + i, x: p.player.x + 5, y: p.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
const pev = runTicks(p, 2);
check('touching geoms collects them', p.multiplier === 10 && p.stats.geoms === 9, `mult ${p.multiplier}`);
check('crossing ×10 upgrades the gun', p.weaponTier === 1 && ofType(pev, 'weaponUpgrade').length === 1);
check('milestone event at ×10', ofType(pev, 'multiplierMilestone').some((e) => e.multiplier === 10));
const m = quiet();
m.geoms.push({ id: 5, x: m.player.x + 120, y: m.player.y, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
runTicks(m, 40);
check('geoms within magnet range fly to the ship', m.stats.geoms === 1);
const e = quiet();
e.geoms.push({ id: 6, x: 100, y: 100, vx: 0, vy: 0, lifeMs: 500, spin: 0, dead: false });
runTicks(e, 40);
check('uncollected geoms expire', e.geoms.length === 0 && e.stats.geoms === 0);
const cap = quiet();
for (let i = 0; i < 200; i += 1) _internal.collectGeom(cap, { dead: false, x: 0, y: 0 });
check('peak multiplier tracked', cap.stats.peakMult === 201);
}
// ---------------------------------------------------------------------------
console.log('5. Splitter');
{
const s = quiet();
const sp = spawnEnemy(s, 'splitter', s.player.x + 400, s.player.y, { spawnMs: 0 });
sp.vx = 0;
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 400, aimY: s.player.y });
check('splitter dies to a bullet', ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'splitter'));
const minis = ofType(ev, 'enemySpawned').filter((k) => k.enemyType === 'mini');
check('splits into exactly three minis', minis.length === 3, `got ${minis.length}`);
const b = quiet();
spawnEnemy(b, 'splitter', 300, 300, { spawnMs: 0 });
_internal.detonateBomb(b);
runTicks(b, 1);
check('bombed splitter does not split', b.enemies.length === 0);
}
// ---------------------------------------------------------------------------
console.log('6. Snake');
{
const s = quiet();
const sn = spawnEnemy(s, 'snake', s.player.x + 500, s.player.y, { spawnMs: 0, heading: 0 });
// Lay its tail out toward the player so shots hit tail before head.
sn.trail = sn.trail.map((_, i) => ({ x: sn.x - i * 6, y: sn.y }));
sn.heading = 0;
const segs = snakeSegments(sn);
check('snake exposes its segment count', segs.length === TUNE.SNAKE_SEGMENTS);
const ev = runTicks(s, 30, { fire: true, aimX: s.player.x + 500, aimY: s.player.y });
check('tail absorbs bullets', ofType(ev, 'bulletDeflect').length > 0);
check('tail hits do not kill the snake', !ofType(ev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
const h = quiet();
// Coming straight at the player, so the head leads and the tail trails away.
const head = spawnEnemy(h, 'snake', h.player.x + 300, h.player.y, { spawnMs: 0, heading: Math.PI });
head.trail = head.trail.map((_, i) => ({ x: head.x + i * 6, y: head.y }));
const hev = runTicks(h, 30, { fire: true, aimX: h.player.x + 300, aimY: h.player.y });
check('head shot kills the snake', ofType(hev, 'enemyKilled').some((k) => k.enemyType === 'snake'));
const t = quiet();
const tk = spawnEnemy(t, 'snake', t.player.x + 600, t.player.y + 600, { spawnMs: 0 });
tk.trail = tk.trail.map(() => ({ x: t.player.x, y: t.player.y }));
tk.trail[0] = { x: tk.x, y: tk.y };
const save = TUNE.SNAKE_SEGMENTS;
_internal.tick(t);
check('touching the tail kills the player', !t.player.alive && t.stats.deaths === 1);
check('(segment count unchanged)', TUNE.SNAKE_SEGMENTS === save);
}
// ---------------------------------------------------------------------------
console.log('7. Black hole');
{
const s = quiet();
const hole = spawnEnemy(s, 'blackhole', s.player.x + 500, s.player.y, { spawnMs: 0 });
const g = spawnEnemy(s, 'wanderer', hole.x, hole.y - 200, { spawnMs: 0 });
const gy0 = g.y;
runTicks(s, 20, { fire: false });
check('dormant hole does not pull', Math.abs(g.y - gy0) > 0 && !hole.active); // wanderer moves on its own
const ev = runTicks(s, 30, { fire: true, aimX: hole.x, aimY: hole.y });
check('first hit activates the hole', hole.active && ofType(ev, 'blackHoleActivated').length === 1);
check('hits drain hp', hole.hp < TUNE.BLACKHOLE_HP);
const p = quiet();
const h2 = spawnEnemy(p, 'blackhole', 1000, 600, { spawnMs: 0 });
h2.active = true;
const gr = spawnEnemy(p, 'grunt', 1000, 420, { spawnMs: 0 });
p.player.x = 200; p.player.y = 1200; // far away, so the grunt chases elsewhere
const d0 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
runTicks(p, 10);
const d1 = Math.hypot(gr.x - h2.x, gr.y - h2.y);
check('active hole pulls enemies in', d1 < d0 || gr.dead, `${d0.toFixed(1)} → ${d1.toFixed(1)}`);
runTicks(p, 240);
check('hole absorbs what it pulls in (no points)', h2.absorbed >= 1 && p.score === 0);
const pp = quiet();
const h3 = spawnEnemy(pp, 'blackhole', pp.player.x + 250, pp.player.y, { spawnMs: 0 });
h3.active = true;
const px0 = pp.player.x;
runTicks(pp, 10, { moveX: 0, moveY: 0 });
check('active hole drags the player', pp.player.x > px0);
const o = quiet();
const h4 = spawnEnemy(o, 'blackhole', 1000, 600, { spawnMs: 0 });
h4.active = true;
o.player.x = 200; o.player.y = 200;
for (let i = 0; i < TUNE.BLACKHOLE_OVERLOAD; i += 1) spawnEnemy(o, 'wanderer', 1000 + 5, 600, { spawnMs: 0 });
const oev = runTicks(o, 3);
check('overfed hole overloads', ofType(oev, 'blackHoleOverload').length === 1 && !o.enemies.some((e) => e.type === 'blackhole'));
check('overload releases protons', o.enemies.filter((e) => e.type === 'proton').length >= 6);
const k = quiet();
const h5 = spawnEnemy(k, 'blackhole', k.player.x + 300, k.player.y, { spawnMs: 0 });
h5.active = true; h5.hp = 1; h5.absorbed = 4;
k.multiplier = 2;
const kev = runTicks(k, 30, { fire: true, aimX: h5.x, aimY: h5.y });
const kk = ofType(kev, 'enemyKilled').find((e) => e.enemyType === 'blackhole');
check('shot-down hole scores base + 50/absorbed × mult', kk && kk.points === (ENEMY_TYPES.blackhole.score + 200) * 2, `points ${kk?.points}`);
}
// ---------------------------------------------------------------------------
console.log('8. Bomb');
{
const s = quiet();
for (let i = 0; i < 20; i += 1) spawnEnemy(s, 'grunt', 200 + i * 40, 200, { spawnMs: i % 2 ? 0 : 500 });
const ev = runTicks(s, 1, { bomb: true });
check('bomb clears every enemy (incl. warming up)', s.enemies.length === 0);
check('bomb kills score nothing', s.score === 0 && ofType(ev, 'enemyKilled').every((e) => e.points === 0));
check('bomb consumes one bomb', s.bombs === 2 && s.stats.bombsUsed === 1);
runTicks(s, 30, { bomb: true });
check('holding the button does not chain bombs', s.bombs === 2);
runTicks(s, 1, { bomb: false });
runTicks(s, 1, { bomb: true });
check('release + press fires the next bomb', s.bombs === 1);
check('bomb grants a spawn lull', s.director.spawnCd > 0);
const z = quiet();
z.bombs = 0;
spawnEnemy(z, 'grunt', 200, 200, { spawnMs: 0 });
runTicks(z, 1, { bomb: true });
check('no bombs → nothing happens', z.enemies.length === 1);
const pc = quiet('pacifism');
pc.bombs = 3;
spawnEnemy(pc, 'grunt', 200, 200, { spawnMs: 0 });
runTicks(pc, 1, { bomb: true });
check('pacifism never bombs', pc.enemies.length === 1 && pc.bombs === 3);
}
// ---------------------------------------------------------------------------
console.log('9. Death and respawn');
{
const s = quiet();
s.multiplier = 55; s.weaponTier = 2; s.director.clockMs = 100000;
for (let i = 0; i < 10; i += 1) spawnEnemy(s, 'wanderer', 300 + i * 50, 300, { spawnMs: 0 });
s.geoms.push({ id: 9, x: 50, y: 50, vx: 0, vy: 0, lifeMs: 5000, spin: 0, dead: false });
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
const ev = runTicks(s, 1);
check('touching an enemy kills the player', ofType(ev, 'playerDied').length === 1 && !s.player.alive);
check('a ship is lost', s.lives === 2);
check('board wiped for no points', s.enemies.length === 0 && s.score === 0);
check('geoms cleared', s.geoms.length === 0);
check('multiplier and gun reset', s.multiplier === 1 && s.weaponTier === 0);
check('difficulty rewinds', s.director.clockMs < 100000);
const rev = runTicks(s, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 1);
check('respawns after the delay', s.player.alive && ofType(rev, 'playerRespawned').length === 1);
check('respawn is invulnerable', s.player.invulnMs > 0);
spawnEnemy(s, 'grunt', s.player.x, s.player.y, { spawnMs: 0 });
runTicks(s, 2);
check('invulnerable ship survives contact', s.player.alive);
const warm = quiet();
spawnEnemy(warm, 'grunt', warm.player.x, warm.player.y, { spawnMs: 400 });
runTicks(warm, 3);
check('warming-up enemies are harmless', warm.player.alive);
const last = quiet();
last.lives = 1;
spawnEnemy(last, 'grunt', last.player.x, last.player.y, { spawnMs: 0 });
runTicks(last, 1);
check('last ship lost → no respawn', !last.player.alive && last.lives === 0);
const gev = runTicks(last, Math.ceil(TUNE.RESPAWN_MS / STEP_MS) + 2);
const go = ofType(gev, 'gameOver');
check('game over emitted exactly once', go.length === 1 && last.phase === 'over');
check('game over carries score + stats', go[0] && go[0].score === last.score && go[0].stats.deaths === 1);
check('a finished game stays finished', runTicks(last, 60).length === 0 && last.phase === 'over');
}
// ---------------------------------------------------------------------------
console.log('10. Extra lives and bombs');
{
const s = quiet();
s.score = TUNE.FIRST_LIFE_AT;
const ev = runTicks(s, 1);
check('first life threshold awards a ship', s.lives === 4 && ofType(ev, 'extraLife').length === 1);
check('next life threshold grows', s.nextLifeAt === Math.round(TUNE.FIRST_LIFE_AT * TUNE.THRESHOLD_GROWTH));
s.score = TUNE.FIRST_BOMB_AT;
const bev = runTicks(s, 1);
check('bomb threshold awards a bomb', s.bombs === 4 && ofType(bev, 'extraBomb').length === 1);
const big = quiet();
big.score = 1e12;
runTicks(big, 1);
check('lives capped', big.lives === TUNE.MAX_LIVES);
check('bombs capped', big.bombs === TUNE.MAX_BOMBS);
check('thresholds advanced past the score', big.nextLifeAt > big.score && big.nextBombAt > big.score);
const pc = quiet('pacifism');
pc.score = 1e9;
runTicks(pc, 1);
check('pacifism awards no extra ships', pc.lives === 1);
}
// ---------------------------------------------------------------------------
console.log('11. Pacifism gates');
{
const s = quiet('pacifism');
const g = _internal.spawnGate(s);
check('gate spawns', !!g && s.gates.length === 1);
g.spawnMs = 0; g.vx = 0; g.vy = 0; g.spinRate = 0; g.angle = Math.PI / 2; // vertical
g.x = 1000; g.y = 600;
s.player.x = 900; s.player.y = 600;
s.multiplier = 2;
const near = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R - 40, { spawnMs: 0 });
const far = spawnEnemy(s, 'grunt', 1000, 600 + TUNE.GATE_BLAST_R + 200, { spawnMs: 0 });
near.vx = 0; far.vx = 0;
const ev = runTicks(s, 30, { moveX: 1, moveY: 0 });
check('flying through the middle detonates the gate', ofType(ev, 'gateTriggered').length === 1 && s.gates.length === 0);
check('blast kills enemies inside its radius', near.dead && ofType(ev, 'enemyKilled').some((k) => k.id === near.id && k.cause === 'gate'));
check('blast spares enemies outside it', !far.dead);
check('gate kills score × multiplier', s.score === ENEMY_TYPES.grunt.score * 2, `score ${s.score}`);
check('player survives a clean pass', s.player.alive);
check('gates counted', s.stats.gates === 1);
const p = quiet('pacifism');
const g2 = _internal.spawnGate(p);
g2.spawnMs = 0; g2.vx = 0; g2.vy = 0; g2.spinRate = 0; g2.angle = 0;
g2.x = 1000; g2.y = 600;
const [a] = gateEndpoints(g2);
p.player.x = a.x; p.player.y = a.y - 4;
runTicks(p, 1);
check('touching a gate post kills', !p.player.alive);
const d = createGame({ seed: 3, mode: 'pacifism' });
d.player.invulnMs = 1e12;
runTicks(d, 60 * 20);
check('director keeps gates on the field', d.stats.gates > 0 || d.gates.length >= 3, `gates ${d.gates.length}`);
check('pacifism director spawns enemies', d.enemies.length > 0 || d.stats.deaths > 0);
}
// ---------------------------------------------------------------------------
console.log('12. Spawning');
{
let tooClose = 0; let total = 0;
for (const seed of [1, 2, 3, 4, 5, 6]) {
const s = createGame({ seed });
s.player.invulnMs = 1e12;
s.director.clockMs = 200000; // everything unlocked
for (let i = 0; i < 1500; i += 1) {
s.player.x = 300 + ((i * 37) % (ARENA_W - 600));
s.player.y = 300 + ((i * 53) % (ARENA_H - 600));
const ev = step(s, STEP_MS);
for (const e of ev) {
if (e.type !== 'enemySpawned' || e.enemyType === 'mini' || e.enemyType === 'proton') continue;
total += 1;
if (Math.hypot(e.x - s.player.x, e.y - s.player.y) < TUNE.SPAWN_SAFE_DIST - 1) tooClose += 1;
}
s.enemies.length = Math.min(s.enemies.length, 120);
}
}
check('director spawns plenty', total > 500, `spawned ${total}`);
check('nothing spawns inside the safe radius', tooClose === 0, `${tooClose}/${total} too close`);
for (const pattern of ['scatter', 'corners', 'swarm', 'ring', 'rocketwall', 'blackhole', 'snakes']) {
const s = quiet('evolved', 11);
s.director.clockMs = 200000;
runPattern(s, pattern);
const inside = s.enemies.every((e) => e.x >= e.radius - 1e-6 && e.x <= ARENA_W - e.radius + 1e-6 && e.y >= e.radius - 1e-6 && e.y <= ARENA_H - e.radius + 1e-6);
check(`pattern '${pattern}' spawns inside the arena`, s.enemies.length > 0 && inside, `count ${s.enemies.length}`);
}
const cap = quiet();
for (let i = 0; i < 400; i += 1) spawnEnemy(cap, 'grunt', 100, 100);
check('enemy cap respected', cap.enemies.length === TUNE.MAX_ENEMIES);
const bh = quiet();
bh.director.clockMs = 200000;
for (let i = 0; i < 10; i += 1) runPattern(bh, 'blackhole');
check('black hole count capped', bh.enemies.filter((e) => e.type === 'blackhole').length <= TUNE.BLACKHOLE_MAX);
}
// ---------------------------------------------------------------------------
console.log('13. Spiral-of-death guard');
{
const s = quiet();
const t0 = s.tick;
step(s, 10_000);
check('huge delta runs at most MAX_STEPS ticks', s.tick - t0 === MAX_STEPS, `ran ${s.tick - t0}`);
const u = quiet();
step(u, -50); step(u, NaN);
check('negative/NaN deltas are ignored', u.tick === 0 && u.acc === 0);
}
// ---------------------------------------------------------------------------
console.log('14. Determinism');
{
const run = () => {
const s = createGame({ seed: 4242 });
let sig = 0;
for (let i = 0; i < 60 * 90; i += 1) {
const a = i * 0.013;
setInput(s, {
moveX: Math.cos(a), moveY: Math.sin(a * 1.3),
aimX: s.player.x + Math.cos(a * 3) * 300, aimY: s.player.y + Math.sin(a * 3) * 300,
fire: true, bomb: i % 900 === 0,
});
const ev = step(s, STEP_MS * (1 + (i % 3) * 0.37));
sig = (sig * 31 + ev.length + s.enemies.length * 7 + s.score) % 1e15;
}
return { sig, score: s.score, id: s.nextId, x: s.player.x, mult: s.multiplier, deaths: s.stats.deaths };
};
const a = run(); const b = run();
check('same seed + inputs → identical run', JSON.stringify(a) === JSON.stringify(b), `${JSON.stringify(a)} vs ${JSON.stringify(b)}`);
const c = createGame({ seed: 1 }); const d = createGame({ seed: 2 });
for (let i = 0; i < 600; i += 1) { step(c, STEP_MS); step(d, STEP_MS); }
check('different seeds diverge', JSON.stringify(c.enemies.map((e) => [e.x, e.y])) !== JSON.stringify(d.enemies.map((e) => [e.x, e.y])));
}
// ---------------------------------------------------------------------------
console.log('15. Warp grid');
{
const g = new WarpGrid(ARENA_W, ARENA_H, 40);
for (let i = 0; i < 30; i += 1) g.update();
check('grid starts at rest', g.maxDisplacement() < 1e-6);
g.applyExplosiveForce(40, 1000, 600, 900);
g.applyDirectedForce(0, 0, 150, 1000, 600, 300);
for (let i = 0; i < 300; i += 1) g.applyImplosiveForce(4, 1500, 800, 240), g.update();
const disp = g.maxDisplacement();
check('forces displace the grid', disp > 5, `disp ${disp.toFixed(2)}`);
let finite = true;
for (let i = 0; i < g.n; i += 1) if (!Number.isFinite(g.px[i]) || !Number.isFinite(g.py[i]) || !Number.isFinite(g.pz[i])) finite = false;
check('grid stays finite under load', finite);
check('grid stays bounded under a sustained hole', disp < 400, `disp ${disp.toFixed(1)}`);
for (let i = 0; i < 60 * 30; i += 1) g.update();
check('grid settles back to rest', g.maxDisplacement() < 1.5, `disp ${g.maxDisplacement().toFixed(3)}`);
let pinned = true;
for (let r = 0; r < g.rows; r += 1) for (let c = 0; c < g.cols; c += 1) {
if (r !== 0 && c !== 0 && r !== g.rows - 1 && c !== g.cols - 1) continue;
const i = r * g.cols + c;
if (g.px[i] !== g.ox[i] || g.py[i] !== g.oy[i]) pinned = false;
}
check('border masses stay pinned', pinned);
// Draw against a recording stub: every coordinate must be finite.
const calls = { n: 0, bad: 0 };
const stub = {
lineStyle() {}, beginPath() {}, strokePath() {},
moveTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
lineTo(x, y) { calls.n += 1; if (!Number.isFinite(x) || !Number.isFinite(y)) calls.bad += 1; },
};
g.applyExplosiveForce(30, 600, 400, 300);
g.update();
g.draw(stub, { x: 160, y: 100, w: 1920, h: 1080 }, { minor: 1, minorAlpha: 1, minorWidth: 1, major: 1, majorAlpha: 1, majorWidth: 2, hot: 1, hotAlpha: 1, hotter: 1, hotterAlpha: 1 });
check('grid draw emits finite coordinates', calls.n > 1000 && calls.bad === 0, `${calls.n} calls, ${calls.bad} bad`);
}
// ---------------------------------------------------------------------------
console.log('16. Particle field');
{
const fx = new ParticleField({ w: ARENA_W, h: ARENA_H });
for (let i = 0; i < 20; i += 1) fx.explosion(1000, 600, 0xff00ff, { count: 300 });
check('pool never exceeds FX_MAX', fx.liveCount() <= FX_MAX);
for (let i = 0; i < 30; i += 1) fx.update(16.7, [{ x: 1100, y: 600, r: 26 }]);
let ok = true;
for (let i = 0; i < FX_MAX; i += 1) {
if (fx.life[i] <= 0) continue;
if (!Number.isFinite(fx.x[i]) || !Number.isFinite(fx.y[i])) ok = false;
if (fx.x[i] < -1 || fx.x[i] > ARENA_W + 1 || fx.y[i] < -1 || fx.y[i] > ARENA_H + 1) ok = false;
}
check('particles stay finite and inside the arena', ok);
const before = fx.liveCount();
for (let i = 0; i < 200; i += 1) fx.update(16.7, [{ x: 1000, y: 600, r: 60 }]);
check('particles expire', fx.liveCount() < before);
fx.ring(0, 0, 100, 300, 0xffffff);
fx.update(400);
check('rings expire', fx.rings.length === 0);
fx.wallSparks(0, 500, Math.PI, 0xffff00, 10);
fx.update(16.7);
let rightward = 0;
for (let i = 0; i < FX_MAX; i += 1) if (fx.life[i] > 0 && fx.vx[i] > 0) rightward += 1;
check('wall sparks bounce back into the arena', rightward >= 8);
const calls = { bad: 0 };
const stub = {
lineStyle() {}, beginPath() {}, strokePath() {}, strokeCircle() {},
moveTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
lineTo(x, y) { if (!Number.isFinite(x + y)) calls.bad += 1; },
};
fx.explosion(500, 500, 0x00ffff, { count: 100 });
fx.draw(stub, { x: 0, y: 0, w: 1920, h: 1080 });
check('particle draw emits finite coordinates', calls.bad === 0);
}
// ---------------------------------------------------------------------------
console.log('17. Bot soak');
{
// A dodging bot with human-ish limits: it only re-decides every `latency`
// ticks, aims with deterministic jitter, and only reacts to enemies inside
// `react` px. It aims at the nearest enemy, strafes away from anything
// close and bombs when crowded. Two profiles bracket the difficulty curve;
// invariants are checked every tick of every run.
const PROFILES = {
sharp: { latency: 10, noise: 0.15, react: 260 },
sloppy: { latency: 18, noise: 0.2, react: 220 },
};
function makeBot(profile, seed) {
let r = seed * 7919 + 13;
const jitter = () => { r = (r * 1103515245 + 12345) % 2147483648; return r / 2147483648 - 0.5; };
let held = null; let tick = 0;
return (s) => {
tick += 1;
if (held && tick % profile.latency !== 0) return held;
const p = s.player;
let nearest = null; let nd = Infinity;
let ax = 0; let ay = 0;
for (const e of s.enemies) {
const dx = e.x - p.x; const dy = e.y - p.y;
const d = Math.hypot(dx, dy) || 1;
if (d < nd && e.spawnMs <= 0) { nd = d; nearest = e; }
if (d < profile.react) { ax -= dx / d * (profile.react - d); ay -= dy / d * (profile.react - d); }
}
for (const g of s.gates) {
const dx = g.x - p.x; const dy = g.y - p.y;
const d = Math.hypot(dx, dy) || 1;
ax += dx / d * 120; ay += dy / d * 120;
for (const end of gateEndpoints(g)) {
const ex = end.x - p.x; const ey = end.y - p.y;
const ed = Math.hypot(ex, ey) || 1;
if (ed < 90) { ax -= ex / ed * (90 - ed) * 12; ay -= ey / ed * (90 - ed) * 12; }
}
}
ax += (ARENA_W / 2 - p.x) * 0.08; ay += (ARENA_H / 2 - p.y) * 0.08;
const close = s.enemies.filter((e) => Math.hypot(e.x - p.x, e.y - p.y) < 140).length;
const aim = (nearest ? Math.atan2(nearest.y - p.y, nearest.x - p.x) : 0) + jitter() * 2 * profile.noise;
held = {
moveX: Math.sign(ax) * Math.min(1, Math.abs(ax) / 50),
moveY: Math.sign(ay) * Math.min(1, Math.abs(ay) / 50),
aimX: p.x + Math.cos(aim) * 300, aimY: p.y + Math.sin(aim) * 300,
fire: true,
bomb: close >= 4,
};
return held;
};
}
const runs = [
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sharp', seed })),
...[11, 22, 33, 44].map((seed) => ({ mode: 'evolved', profile: 'sloppy', seed })),
...[11, 22, 33, 44].map((seed) => ({ mode: 'pacifism', profile: 'sharp', seed })),
];
const results = [];
let violations = [];
const seenTypes = new Set();
for (const { mode, profile, seed } of runs) {
const s = createGame({ seed, mode });
const bot = makeBot(PROFILES[profile], seed);
let lastScore = 0;
let firstDeathMs = null;
const limit = 60 * 60 * 8; // 8 simulated minutes
let ticks = 0;
const tag = `${mode}/${profile}/${seed}`;
while (s.phase !== 'over' && ticks < limit) {
setInput(s, bot(s));
const ev = step(s, STEP_MS);
ticks += 1;
if (firstDeathMs == null && ev.some((e) => e.type === 'playerDied')) firstDeathMs = s.timeMs;
if (s.score < lastScore) violations.push(`${tag}: score went down`);
lastScore = s.score;
if (s.enemies.length > TUNE.MAX_ENEMIES + 40) violations.push(`${tag}: enemy overflow ${s.enemies.length}`);
if (s.bullets.length > TUNE.MAX_BULLETS) violations.push(`${tag}: bullet overflow`);
if (s.geoms.length > TUNE.MAX_GEOMS) violations.push(`${tag}: geom overflow`);
if (s.multiplier < 1 || s.lives < 0 || s.bombs < 0) violations.push(`${tag}: negative counters`);
if (mode === 'pacifism' && s.bullets.length) violations.push(`${tag}: pacifism bullet`);
if (ticks % 30 === 0) {
for (const e of s.enemies) {
seenTypes.add(e.type);
if (!Number.isFinite(e.x) || !Number.isFinite(e.y)) violations.push(`${tag}: NaN ${e.type}`);
if (e.x < -1 || e.x > ARENA_W + 1 || e.y < -1 || e.y > ARENA_H + 1) violations.push(`${tag}: ${e.type} escaped arena`);
}
const p = s.player;
if (!Number.isFinite(p.x) || p.x < 0 || p.x > ARENA_W || p.y < 0 || p.y > ARENA_H) violations.push(`${tag}: player escaped`);
if (s.enemies.some((e) => e.dead) || s.bullets.some((b) => b.dead)) violations.push(`${tag}: dead entity kept`);
}
if (violations.length > 20) break;
}
results.push({
mode, profile, seed, over: s.phase === 'over', mins: s.timeMs / 60000,
firstDeath: firstDeathMs == null ? null : firstDeathMs / 60000,
score: s.score, kills: s.stats.kills, peak: s.stats.peakMult, deaths: s.stats.deaths, gates: s.stats.gates,
});
}
violations = [...new Set(violations)];
for (const r of results) {
console.log(` ${r.mode.padEnd(8)} ${r.profile.padEnd(6)} seed ${String(r.seed).padStart(2)}: ${r.over ? 'over ' : 'alive'} at ${r.mins.toFixed(1)}m`
+ `, first death ${r.firstDeath == null ? '—' : `${r.firstDeath.toFixed(1)}m`}, deaths ${r.deaths}, score ${formatInt(r.score)}, peak ×${r.peak}`
+ `${r.mode === 'pacifism' ? `, gates ${r.gates}` : ''}`);
}
const median = (xs) => { const m = [...xs].sort((a, b) => a - b); return m[Math.floor(m.length / 2)]; };
const sharp = results.filter((r) => r.mode === 'evolved' && r.profile === 'sharp');
const sloppy = results.filter((r) => r.mode === 'evolved' && r.profile === 'sloppy');
check('no invariant violations across the soak', violations.length === 0, violations.slice(0, 6).join('; '));
check('every run terminates or hits the time limit', results.length === runs.length);
check('evolved bots score', [...sharp, ...sloppy].every((r) => r.score > 0));
check('pacifism bot detonates gates', results.filter((r) => r.mode === 'pacifism').some((r) => r.gates > 0));
const allTypes = ['wanderer', 'grunt', 'weaver', 'splitter', 'snake', 'blackhole', 'rocket'];
check('every evolved enemy type appears in play', allTypes.every((t) => seenTypes.has(t)), `missing: ${allTypes.filter((t) => !seenTypes.has(t)).join(',')}`);
check('not too hard: sharp bot median run ≥ 3 min', median(sharp.map((r) => r.mins)) >= 3, `median ${median(sharp.map((r) => r.mins)).toFixed(2)}m`);
check('not too easy: sloppy bot loses a ship in most runs', sloppy.filter((r) => r.deaths > 0).length >= 3);
check('not too easy: sloppy bot first death before 4 min (median)',
median(sloppy.map((r) => r.firstDeath ?? 99)) < 4, `median ${median(sloppy.map((r) => r.firstDeath ?? 99)).toFixed(2)}m`);
}
function formatInt(n) { return Math.floor(n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ','); }
console.log(`\n${passes} passed, ${failures} failed`);
process.exit(failures ? 1 : 0);

View File

@ -1,354 +0,0 @@
// Headless verification for Jigsaw.
// node tools/verifyJigsaw.js
// Exits non-zero on any failure.
//
// 1. Grid model: seeded determinism, neighbour bounds/symmetry, tab/blank
// complementarity on every internal edge.
// 2. FIT guarantee: for every internal edge of every difficulty, the two
// adjacent pieces trace the *same* shared curve — adjacent pieces physically
// mesh when placed in their correct slots. This is the property the
// table-join rule ("pieces may only join if they fit on the board") relies
// on, so it is checked for ALL grids.
// 3. Table assembly (the production resolveDrop): only grid-adjacent pieces
// join, whole groups are absorbed, fixpoint chains, the rigid-drag
// invariant, board lock (and its precedence over join), and win counting.
import {
DIFFICULTIES, DIFFICULTY_ORDER,
makeJigsaw, cellEdgeSpec, cellNeighbours, edgeFragment, resolveDrop,
} from '../src/games/jigsaw/JigsawLogic.js';
let failures = 0;
function check(ok, msg) {
if (!ok) { failures++; console.error(` ✗ ${msg}`); }
return ok;
}
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
// ── 1. Grid model ────────────────────────────────────────────────────────────
console.log('Grid model:');
{
const a = makeJigsaw(6, 5, 1234), b = makeJigsaw(6, 5, 1234), c = makeJigsaw(6, 5, 4321);
check(JSON.stringify(a.H) === JSON.stringify(b.H) && JSON.stringify(a.V) === JSON.stringify(b.V),
'same seed must give the same knob layout');
check(JSON.stringify(a.H) !== JSON.stringify(c.H) || JSON.stringify(a.V) !== JSON.stringify(c.V),
'different seeds should give different layouts');
const jig = makeJigsaw(5, 5, 7);
const inB = (r, cc) => r >= 0 && r < 5 && cc >= 0 && cc < 5;
let allInRange = true, symmetric = true;
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 5; cc++) {
for (const [nr, nc] of cellNeighbours(jig, r, cc)) {
if (!inB(nr, nc)) allInRange = false;
if (!cellNeighbours(jig, nr, nc).some(([pr, pc]) => pr === r && pc === cc)) symmetric = false;
}
}
check(allInRange, 'neighbours never leave the grid');
check(symmetric, 'neighbourhood is symmetric');
check(cellNeighbours(jig, 0, 0).length === 2, 'corner piece has 2 neighbours');
check(cellNeighbours(jig, 0, 2).length === 3, 'edge piece has 3 neighbours');
check(cellNeighbours(jig, 2, 2).length === 4, 'interior piece has 4 neighbours');
// Every internal edge is a tab on exactly one side, blank on the other, and
// both sides agree on which way the shared curve bulges.
let complement = true;
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 4; cc++) {
const aL = cellEdgeSpec(jig, r, cc).right, bL = cellEdgeSpec(jig, r, cc + 1).left;
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
}
for (let r = 0; r < 4; r++) for (let cc = 0; cc < 5; cc++) {
const aL = cellEdgeSpec(jig, r, cc).bottom, bL = cellEdgeSpec(jig, r + 1, cc).top;
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
}
check(complement, 'every internal edge: tab/blank pair with a common curve side');
console.log(' ok');
}
// ── 2. Fit guarantee: adjacent pieces trace the identical shared curve ───────
console.log('Fit guarantee (adjacent pieces mesh on the board):');
{
const sample = (p0, p1, edge, n = 128) => {
const pts = [];
let cur = { x: p0.x, y: p0.y };
for (const c of edgeFragment(p0, p1, edge)) {
for (let i = 1; i <= n; i++) {
const t = i / n;
let x, y;
if (c.t === 'line') { x = cur.x + (c.x - cur.x) * t; y = cur.y + (c.y - cur.y) * t; }
else {
const m = 1 - t;
x = m * m * m * cur.x + 3 * m * m * t * c.c1.x + 3 * m * t * t * c.c2.x + t * t * t * c.x;
y = m * m * m * cur.y + 3 * m * m * t * c.c1.y + 3 * m * t * t * c.c2.y + t * t * t * c.y;
}
pts.push({ x, y });
}
cur = { x: c.x, y: c.y };
}
return pts;
};
// Max distance from every sample of curve A to the closest sample of B (both
// ways). Identical curves → near zero (sampling gap only).
const maxGap = (A, B) => Math.max(
...A.map((p) => Math.min(...B.map((q) => Math.hypot(p.x - q.x, p.y - q.y)))),
...B.map((p) => Math.min(...A.map((q) => Math.hypot(p.x - q.x, p.y - q.y))))
);
let edgesChecked = 0, ok = true, worst = 0;
const W = 100, H = 100;
for (const key of DIFFICULTY_ORDER) {
const { cols, rows } = DIFFICULTIES[key];
const jig = makeJigsaw(cols, rows, 42);
for (let r = 0; r < rows; r++) for (let c = 0; c < cols - 1; c++) {
// Vertical internal edge between (r,c) [left] and (r,c+1) [right].
const xA = c * W, yA = r * H;
const p0A = { x: xA + W, y: yA }, p1A = { x: xA + W, y: yA + H };
const p0B = { x: xA + W, y: yA + H }, p1B = { x: xA + W, y: yA };
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).right);
const B = sample(p0B, p1B, cellEdgeSpec(jig, r, c + 1).left);
const gap = maxGap(A, B);
edgesChecked++; worst = Math.max(worst, gap);
if (gap > 2.5) ok = false;
}
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols; c++) {
// Horizontal internal edge between (r,c) [top] and (r+1,c) [bottom].
const xA = c * W, yA = r * H;
const p0A = { x: xA + W, y: yA + H }, p1A = { x: xA, y: yA + H };
const p0B = { x: xA, y: yA + H }, p1B = { x: xA + W, y: yA + H };
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).bottom);
const B = sample(p0B, p1B, cellEdgeSpec(jig, r + 1, c).top);
const gap = maxGap(A, B);
edgesChecked++; worst = Math.max(worst, gap);
if (gap > 2.5) ok = false;
}
}
check(ok, `all ${edgesChecked} internal edges on all difficulties mesh exactly (worst gap ${worst.toFixed(3)}px)`);
console.log(` ok — ${edgesChecked} internal edges checked, worst deviation ${worst.toFixed(4)}px`);
}
// ── 3. Table assembly (production resolveDrop) ───────────────────────────────
console.log('Table assembly (piece joining / group lock):');
{
const SNAP = 0.42; // same SNAP_FRAC as the scene
const boardOrigin = { x: 1000, y: 1000 };
const cell = 100;
function makeBoard(cols = 5, rows = 5, seed = 7) {
const jig = makeJigsaw(cols, rows, seed);
const pieces = [];
const grid = [];
for (let r = 0; r < rows; r++) grid.push(new Array(cols));
let i = 0;
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++, i++) {
const p = {
r, c,
home: { x: boardOrigin.x + (c + 0.5) * cell, y: boardOrigin.y + (r + 0.5) * cell },
// Scattered on the table, well clear of the board and of each other.
pos: { x: 50 + i * 140, y: 5000 + (i % 6) * 120 },
placed: false, group: null,
};
const g = { pieces: [p] };
p.group = g;
pieces.push(p);
grid[r][c] = p;
}
return {
jig, pieces, grid,
groups: new Set(pieces.map((p) => p.group)),
placed: 0, total: pieces.length,
cellAt: (r, c) => (r >= 0 && r < rows && c >= 0 && c < cols) ? grid[r][c] : null,
};
}
const at = (bd, r, c) => bd.grid[r][c];
// Mirror of JigsawGame.handleDrop: apply the decided positions, then do the
// (sound/nudge-free) scene bookkeeping.
function handleDrop(bd, group, anchor) {
const res = resolveDrop(bd.jig, group, bd.cellAt, cell * SNAP);
for (const pl of res.placements) pl.piece.pos = { x: pl.x, y: pl.y };
if (res.outcome === 'locked') {
const locked = group.pieces.filter((m) => !m.placed);
locked.forEach((m) => { m.placed = true; });
bd.groups.delete(group);
bd.placed += locked.length;
} else if (res.outcome === 'joined') {
for (const g of res.absorbedGroups) {
bd.groups.delete(g);
for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); }
}
}
return res;
}
// Mirror of JigsawGame.onPointerMove: rigid group drag around the anchor.
function dragGroup(bd, group, anchor, to) {
for (const m of group.pieces) m.pos = { x: to.x + (m.home.x - anchor.home.x), y: to.y + (m.home.y - anchor.home.y) };
}
const invariantHolds = (group) => group.pieces.every((m) => group.pieces.every((a) =>
approx(m.pos.x, a.pos.x + m.home.x - a.home.x) && approx(m.pos.y, a.pos.y + m.home.y - a.home.y)));
// 3a. resolveDrop is pure: no mutation before the scene applies the result.
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1);
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 20, y: A.pos.y + (B.home.y - A.home.y) - 15 };
const g0 = [...A.group.pieces];
const res = resolveDrop(bd.jig, A.group, bd.cellAt, cell * SNAP);
check(res.outcome === 'joined', '3a: adjacent pair within snap radius joins');
check(res.placements.length === 1 && res.placements[0].piece === B, '3a: only the absorbed piece is placed');
check(A.group.pieces.length === 1 && A.group.pieces[0] === A, '3a: group not mutated by resolveDrop');
check(B.pos.x === 200 + (B.home.x - A.home.x) + 20, '3a: piece positions not mutated by resolveDrop');
const m = res.placements[0];
B.pos = { x: m.x, y: m.y }; // scene-side application
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(B.pos.y, A.pos.y + (B.home.y - A.home.y)),
'3a: absorbed piece snaps to the exact mesh offset');
}
// 3b. Pieces that cannot both sit on the board never join — even when
// dropped dead-on their (hypothetical) meshing offset.
{
const bd = makeBoard();
const A = at(bd, 0, 0), C = at(bd, 0, 2);
A.pos = { x: 300, y: 500 };
C.pos = { x: A.pos.x + 2 * cell, y: A.pos.y }; // exact 2-cell offset, zero error
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3b: non-adjacent pieces never join (rests instead)');
check(A.group.pieces.length === 1, '3b: group stays a singleton');
}
{
const bd = makeBoard();
const A = at(bd, 0, 0), D = at(bd, 1, 1);
A.pos = { x: 300, y: 500 };
D.pos = { x: A.pos.x + cell * 0.7, y: A.pos.y + cell * 0.7 }; // sitting on top, diagonally
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3b: diagonal pieces never join even when overlapping');
}
// 3c. A chain of correctly placed pieces latches on in ONE drop (fixpoint).
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1); // C neighbour of B only
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 10, y: A.pos.y + (B.home.y - A.home.y) };
C.pos = { x: B.pos.x + (C.home.x - B.home.x) - 8, y: B.pos.y + (C.home.y - B.home.y) };
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'joined', '3c: chain joins in one drop');
check(B.group === A.group && C.group === A.group && A.group.pieces.length === 3, '3c: all three in one group');
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(C.pos.x, A.pos.x + (C.home.x - A.home.x)),
'3c: every member snaps to the exact mesh offset');
check(invariantHolds(A.group), '3c: group invariant holds after join');
}
// 3d. A pre-joined group is absorbed WHOLE when a neighbour drops beside it.
{
const bd = makeBoard();
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1);
// First: join B+C on the table.
B.pos = { x: 400, y: 600 };
C.pos = { x: B.pos.x + (C.home.x - B.home.x) + 5, y: B.pos.y + (C.home.y - B.home.y) };
const r1 = handleDrop(bd, B.group, B);
check(r1.outcome === 'joined' && B.group.pieces.length === 2, '3d: B+C joined first');
// Then: drop A next to B → the whole B+C group comes along.
const Bg = B.group;
A.pos = { x: B.pos.x - (B.home.x - A.home.x) - 12, y: B.pos.y + 9 };
const r2 = handleDrop(bd, A.group, A);
check(r2.outcome === 'joined', '3d: A dropped beside the pair joins it');
check(B.group === A.group && A.group.pieces.length === 3, '3d: the whole pre-joined group was absorbed');
check(invariantHolds(A.group), '3d: group invariant holds after whole-group absorption');
check(bd.groups.has(A.group) && !bd.groups.has(Bg), '3d: group registry stays consistent');
}
// 3e. Groups drag rigidly: every member keeps its exact home offset.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3), C = at(bd, 1, 2);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
C.pos = { x: A.pos.x + (C.home.x - A.home.x), y: A.pos.y + (C.home.y - A.home.y) };
handleDrop(bd, A.group, A); // B and C both within snap → 3-piece group
check(A.group.pieces.length === 3, '3e: three-piece group formed');
dragGroup(bd, A.group, B, { x: 700, y: 900 }); // grab a non-first member
check(approx(A.pos.x, 700 + (A.home.x - B.home.x)) && approx(C.pos.y, 900 + (C.home.y - B.home.y)),
'3e: dragging any member moves the whole group rigidly');
check(invariantHolds(A.group), '3e: invariant preserved by drag');
}
// 3f. Board lock: any member aligned ⇒ the whole group lands on the board.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
check(A.group.pieces.length === 2, '3f: pair formed');
// Drag the pair (grabbing B, the "far" member) so B lands within snap of home.
dragGroup(bd, A.group, B, { x: B.home.x - 14, y: B.home.y + 10 });
const res = handleDrop(bd, A.group, B);
check(res.outcome === 'locked', '3f: group locks when aligned with the board');
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x) && approx(B.pos.y, B.home.y),
'3f: every member lands exactly on its home slot');
check(A.placed && B.placed && bd.placed === 2, '3f: both members count as placed');
check(!bd.groups.has(A.group), '3f: locked group retired from the table');
}
// 3g. Lock takes precedence over join.
{
const bd = makeBoard();
const A = at(bd, 2, 2), B = at(bd, 2, 3), D = at(bd, 3, 2);
A.pos = { x: 250, y: 520 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
// D sits exactly where it would mesh under A (joinable)…
D.pos = { x: A.pos.x + (D.home.x - A.home.x), y: A.pos.y + (D.home.y - A.home.y) };
// …but the A+B pair is also aligned with the board.
dragGroup(bd, A.group, A, { x: A.home.x + 8, y: A.home.y - 6 });
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'locked', '3g: board lock wins over a possible join');
check(D.group.pieces.length === 1 && !D.placed, '3g: the joinable piece was NOT absorbed');
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x), '3g: group landed on the board');
}
// 3h. Outside the snap radius: nothing joins, positions untouched.
{
const bd = makeBoard();
const A = at(bd, 1, 1), B = at(bd, 1, 2);
A.pos = { x: 300, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 60, y: A.pos.y }; // 60 > 42 snap
const Bdrop = { ...B.pos }; // where the drop LEFT it
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'rested', '3h: beyond snap radius the drop rests');
check(approx(B.pos.x, Bdrop.x) && approx(B.pos.y, Bdrop.y), '3h: unjoined piece keeps its dropped position');
}
// 3i. Placed pieces are ignored by joining.
{
const bd = makeBoard();
const A = at(bd, 1, 1), B = at(bd, 1, 2), D = at(bd, 2, 1);
D.pos = { x: D.home.x, y: D.home.y }; D.placed = true; // already on the board
A.pos = { x: 300, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 4, y: A.pos.y + (B.home.y - A.home.y) };
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'joined' && A.group.pieces.length === 2, '3i: unplaced neighbour still joins');
check(!A.group.pieces.includes(D) && D.placed, '3i: placed piece is never absorbed');
}
// 3j. Win bookkeeping: locking the final pieces reaches the total.
{
const bd = makeBoard(2, 1, 11); // 2 pieces, one internal edge
const A = at(bd, 0, 0), B = at(bd, 0, 1);
A.pos = { x: 200, y: 500 };
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 6, y: A.pos.y + (B.home.y - A.home.y) };
handleDrop(bd, A.group, A);
check(A.group.pieces.length === 2, '3j: pair formed');
dragGroup(bd, A.group, A, { x: A.home.x, y: A.home.y });
const res = handleDrop(bd, A.group, A);
check(res.outcome === 'locked' && bd.placed === bd.total, '3j: locking the pair completes the board');
}
console.log(' ok');
}
if (failures) {
console.error(`\nFAILED: ${failures} check(s).`);
process.exit(1);
}
console.log('\nAll Jigsaw checks passed.');