Compare commits

..

No commits in common. "main" and "jigsaw-ui-improvements" have entirely different histories.

39 changed files with 58 additions and 7020 deletions

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 | | **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 | | **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 | | **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 | | **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 | | **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |

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: 341 KiB

Binary file not shown.

Binary file not shown.

View File

@ -1,18 +1,5 @@
{ {
"playerBaseHp": 100, "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": [ "milestones": [
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" }, { "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
{ "afterLevel": 10, "maxHpBonus": 10 }, { "afterLevel": 10, "maxHpBonus": 10 },

View File

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

View File

@ -256,9 +256,6 @@ export const MANIFEST = {
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'), image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
], ],
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.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: [ rushhour: [
// Carries its own title, so the level-select screen draws no heading. // Carries its own title, so the level-select screen draws no heading.
// In-play art is procedural (see games/rushhour/RushHourArt.js). // 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. // so its audio only downloads once Tempest is actually entered.
(scene) => musicFrom(scene, 'arcadedark-music'), (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: [ mastermind: [
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so // hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
// its audio only downloads once Mastermind is actually entered. // 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: '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: '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: '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: '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: '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' }); registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });
@ -124,5 +124,3 @@ registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-co
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 }); 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: '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: '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, GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage, wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles, clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, specialTileChances, computeMaxHp, isPotionUnlocked,
} from './BookworkLogic.js'; } from './BookworkLogic.js';
import { getAttackDamage, getSpecialTile } from './BookworkAI.js'; import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
const CELL = 96; const CELL = 96;
const GRID_W = CELL * GRID_SIZE; const GRID_W = CELL * GRID_SIZE;
@ -40,7 +39,7 @@ export default class BookworkGame extends Phaser.Scene {
constructor() { super('BookworkGame'); } constructor() { super('BookworkGame'); }
init(data) { 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.config = { playerBaseHp: 100, milestones: [], levels: [] };
this.bank = []; this.bank = [];
this.roster = []; this.roster = [];
@ -57,9 +56,6 @@ export default class BookworkGame extends Phaser.Scene {
this.bgTex = null; this.bgTex = null;
// Battle state // Battle state
this.grid = null; 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.tileObjs = null;
this.selection = []; this.selection = [];
this.selGraphics = null; this.selGraphics = null;
@ -89,16 +85,8 @@ export default class BookworkGame extends Phaser.Scene {
const raw = this.cache.json.get('bookwork'); const raw = this.cache.json.get('bookwork');
if (raw) this.config = raw; if (raw) this.config = raw;
this.steerOpts = this.config.steer ?? {};
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level); 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 { try {
const res = await fetch('data/opponents.json'); const res = await fetch('data/opponents.json');
const json = await res.json(); const json = await res.json();
@ -291,7 +279,7 @@ export default class BookworkGame extends Phaser.Scene {
this.clearLayer(); this.clearLayer();
const cx = GAME_WIDTH / 2; 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, fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
}).setOrigin(0.5); }).setOrigin(0.5);
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', { 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.level = level;
this.levelDef = lv; this.levelDef = lv;
this.opponent = this.opponentFor(lv); this.opponent = this.opponentFor(lv);
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
this.clearLayer(); this.clearLayer();
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted); 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.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
this.potionUsed = false; this.potionUsed = false;
this.turnPhase = 'player'; this.turnPhase = 'player';
this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid(); this.grid = makeGrid();
this.selection = []; this.selection = [];
this.drawBattleUI(); this.drawBattleUI();
@ -845,9 +832,7 @@ export default class BookworkGame extends Phaser.Scene {
} }
// Refill grid // Refill grid
this.grid = this.wordSet this.grid = clearAndRefill(this.grid, cells);
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
await this.animateRefill(cells); await this.animateRefill(cells);
this.redrawAllTiles(); this.redrawAllTiles();

View File

@ -1,7 +1,7 @@
export const GRID_SIZE = 5; export const GRID_SIZE = 5;
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed) // 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, 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, 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, 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)]; 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) { export function makeGrid(rng = Math.random) {
const grid = []; const grid = [];
for (let r = 0; r < GRID_SIZE; r++) { 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; return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
} }
// Cascade tiles down in each column, fill top with new random tiles. // 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) {
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`)); 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 }))); const next = grid.map((row) => row.map((cell) => ({ ...cell })));
for (let c = 0; c < GRID_SIZE; c++) { 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 // Fill remainder with new normal tiles
while (survive.length < GRID_SIZE) { while (survive.length < GRID_SIZE) {
const gold = rng() < goldChance; const gold = rng() < 0.03;
const diamond = !gold && rng() < diamondChance; const diamond = !gold && rng() < 0.02;
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal'; const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
survive.push({ letter: randomLetter(rng), type }); 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, ',');
}

View File

@ -81,17 +81,7 @@ export default class MahjongGame extends Phaser.Scene {
const { tracks, volume } = getGameSoundtrack(this); const { tracks, volume } = getGameSoundtrack(this);
if (tracks.length) new MusicPlayer(this, tracks, volume); if (tracks.length) new MusicPlayer(this, tracks, volume);
} catch { /* optional */ } } catch { /* optional */ }
// Backdrop: the playfield chosen on the opponent-select screen (image this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg);
// 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);
}
const names = [auth.user?.username ?? 'You']; const names = [auth.user?.username ?? 'You'];
const skills = { 0: 5 }; const skills = { 0: 5 };
@ -140,16 +130,11 @@ export default class MahjongGame extends Phaser.Scene {
} }
buildPortraits() { 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 = [ const spots = [
{ x: 325, y: 975, r: 46 }, // human — left of the bottom hand row { x: 90, y: 870, r: 46 }, // human — bottom left
{ x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column { x: 1830, y: 170, r: 40 }, // seat 1 — right
{ x: 558, y: 70, r: 40 }, // seat 2 — left of the top row { x: 560, y: 95, r: 40 }, // seat 2 — top
{ x: 75, y: 580, r: 40 }, // seat 3 — left of the left column { x: 90, y: 310, r: 40 }, // seat 3 — left
]; ];
for (let seat = 0; seat < 4; seat++) { for (let seat = 0; seat < 4; seat++) {
const { x, y, r } = spots[seat]; const { x, y, r } = spots[seat];
@ -234,10 +219,7 @@ export default class MahjongGame extends Phaser.Scene {
}).setOrigin(0.5)); }).setOrigin(0.5));
this.refPanel = panel; this.refPanel = panel;
// Parked just left of the reference panel's open position (1490) so it this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), {
// 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(), {
width: 150, height: 52, fontSize: 22, variant: 'ghost', width: 150, height: 52, fontSize: 22, variant: 'ghost',
}).setDepth(DEPTH.ref + 1); }).setDepth(DEPTH.ref + 1);
} }
@ -251,15 +233,6 @@ export default class MahjongGame extends Phaser.Scene {
ease: 'Quad.Out', ease: 'Quad.Out',
}); });
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands'); 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 ────────────────────────────────────────────────────────────── // ── tile drawing ──────────────────────────────────────────────────────────────
@ -409,7 +382,7 @@ export default class MahjongGame extends Phaser.Scene {
bx += 40; bx += 40;
} }
} else { // sides — vertical column of rotated backs } 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; let y = 580 - ((n - 1) * step) / 2;
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const back = this.makeTileBack(SM_W, SM_H); const back = this.makeTileBack(SM_W, SM_H);
@ -417,7 +390,7 @@ export default class MahjongGame extends Phaser.Scene {
this.dyn.add(back); this.dyn.add(back);
y += step; y += step;
} }
const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column const mx = seat === 1 ? 1745 : 175;
let my = 330; let my = 330;
for (const m of p.melds) { for (const m of p.melds) {
const row = this.makeMeldRow(m, 38, 52, false); const row = this.makeMeldRow(m, 38, 52, false);
@ -508,7 +481,6 @@ export default class MahjongGame extends Phaser.Scene {
btn.setVisible(!!spec); btn.setVisible(!!spec);
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`); if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
}); });
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
} }
// ── human input ─────────────────────────────────────────────────────────────── // ── human input ───────────────────────────────────────────────────────────────
@ -549,7 +521,6 @@ export default class MahjongGame extends Phaser.Scene {
return new Promise((resolve) => { return new Promise((resolve) => {
this.claimResolve = resolve; this.claimResolve = resolve;
this.chowOptions = opts.chows; this.chowOptions = opts.chows;
this.closeReferenceIfOpen();
const visible = []; const visible = [];
if (opts.win) visible.push(this.claimBtns.win); if (opts.win) visible.push(this.claimBtns.win);
if (opts.kong) visible.push(this.claimBtns.kong); 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 TITLE_GOLD = '#f2c14e';
const PAPER = 0x1e1a12; // panels 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 }; const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 };
// ── Honeycomb geometry ───────────────────────────────────────────────────────── // ── Honeycomb geometry ─────────────────────────────────────────────────────────
@ -70,9 +48,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.curLetters = []; // per-char text objects for the current word this.curLetters = []; // per-char text objects for the current word
this.foundTexts = []; this.foundTexts = [];
this.hexes = {}; // letter -> { container, gfx, isCenter } 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() { 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) this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
.setDepth(DEPTH.bg); .setDepth(DEPTH.bg);
this.buildVideoBackground();
this.add.text(40, 36, 'SPELLING BEE', { this.add.text(40, 36, 'SPELLING BEE', {
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD, fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
}).setDepth(DEPTH.ui); }).setDepth(DEPTH.ui);
@ -97,89 +70,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.input.keyboard.off('keydown', this.keyHandler); this.input.keyboard.off('keydown', this.keyHandler);
this.keyHandler = null; 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) ───────────── // ── 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); b.setDepth(DEPTH.ui);
this.startObjs.push(b); 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) { async startPuzzle(difficulty) {
@ -265,12 +130,6 @@ export default class SpellingBeeGame extends Phaser.Scene {
this.maxScore = data.maxScore ?? 0; this.maxScore = data.maxScore ?? 0;
this.tiers = buildTiers(this.maxScore, difficulty); 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(); this.buildBoard();
} }

View File

@ -13,16 +13,6 @@ import {
const FONT_D = 'Righteous'; const FONT_D = 'Righteous';
const FONT_B = '"Julius Sans One"'; 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) ───────────────── // ── 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.sky = { t: 0 }; // 0 = midday, 1 = starry night
this.won = false; this.won = false;
this.winPanelShown = false; this.winPanelShown = false;
this.rulesOpen = false;
this.difficulty = 'porch'; this.difficulty = 'porch';
this.hover = null; // {c, r} this.hover = null; // {c, r}
this.embers = []; this.embers = [];
@ -118,7 +107,6 @@ export default class TentsGame extends Phaser.Scene {
this.buildHud(); this.buildHud();
this.hudLayer.setVisible(false); this.hudLayer.setVisible(false);
this.buildRulesPanel();
this.buildSelectScreen(); this.buildSelectScreen();
this.events.on('shutdown', () => { 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.btnReset = mk('Reset Tents', 700, () => this.resetTents(), 'ghost');
this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost'); this.btnMenu = mk('Menu', 780, () => this.scene.start('GameMenu'), 'ghost');
// Parked at the same screen position the rules panel's Close button sits this.hudLayer.add([this.btnNew, this.btnAnswer, this.btnReset, this.btnMenu]);
// 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]);
} }
makeText(str, x, y, font, size, color) { makeText(str, x, y, font, size, color) {
@ -238,68 +219,6 @@ export default class TentsGame extends Phaser.Scene {
}).setOrigin(0, 0).setDepth(30); }).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 ────────────────────────────────────────────────────────── // ── Select screen ──────────────────────────────────────────────────────────
buildSelectScreen() { buildSelectScreen() {

View File

@ -110,8 +110,6 @@ import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js'; import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js'; import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
import TentsGame from './games/tents/TentsGame.js'; import TentsGame from './games/tents/TentsGame.js';
import DefenderGame from './games/defender/DefenderGame.js';
import GeometryWarsGame from './games/geometrywars/GeometryWarsGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -235,8 +233,6 @@ const config = {
WolfensteinEditor, WolfensteinEditor,
PipePuzzleGame, PipePuzzleGame,
TentsGame, TentsGame,
DefenderGame,
GeometryWarsGame,
], ],
}; };

View File

@ -2,11 +2,12 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { api } from '../services/api.js'; import { api } from '../services/api.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
import { TutorialModal } from '../ui/TutorialModal.js'; import { TutorialModal } from '../ui/TutorialModal.js';
let _lastCategory = null;
const CATEGORIES = [ const CATEGORIES = [
{ key: 'tabletop', label: 'Tabletop' }, { key: 'tabletop', label: 'Tabletop' },
{ key: 'cards', label: 'Cards & Dice' }, { key: 'cards', label: 'Cards & Dice' },
@ -21,29 +22,10 @@ const ICON_INACTIVE = 56;
const ICON_ACTIVE = 72; const ICON_ACTIVE = 72;
const ICON_OVERSHOOT = 86; const ICON_OVERSHOOT = 86;
const ICON_X_OFFSET = -145; 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 { export default class GameMenuScene extends Phaser.Scene {
constructor() { super('GameMenu'); } 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() { async create() {
playMenuMusic(); playMenuMusic();
const cx = GAME_WIDTH / 2; 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); this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
addFullscreenButton(this); 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', fontFamily: 'Righteous',
fontSize: '64px', fontSize: '64px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
titleText.setLetterSpacing(2); this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
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;
const loadingText = this.add.text(cx, 540, 'Loading game list…', { const loadingText = this.add.text(cx, 540, 'Loading game list…', {
fontSize: '24px', color: COLORS.mutedHex, fontSize: '24px', color: COLORS.mutedHex,
@ -131,49 +107,14 @@ export default class GameMenuScene extends Phaser.Scene {
this._tabIcons[key] = icon; this._tabIcons[key] = icon;
}); });
// No category is selected by default — the user picks one to see games. const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key;
this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', { this.showCategory(startKey);
fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' }); 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',
});
} }
showCategory(key) { showCategory(key) {
if (key === this._currentCategory) return; _lastCategory = key;
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' });
}
for (const [k, btn] of Object.entries(this._tabs)) { for (const [k, btn] of Object.entries(this._tabs)) {
btn.setActive(k === key); 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). for (const obj of this._gameObjects) obj.destroy();
if (this._gridAnim) { this._gameObjects = [];
for (const obj of this._gridAnim.objects) obj.destroy();
this._gridAnim = null;
this._gameObjects = [];
}
if (this._ptrMoveHandler) { if (this._ptrMoveHandler) {
this.input.off('pointermove', this._ptrMoveHandler, this); this.input.off('pointermove', this._ptrMoveHandler, this);
this._ptrMoveHandler = null; 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]; const games = this._gamesByCategory[key];
if (!games || games.length === 0) return; if (!games || games.length === 0) return;
@ -354,18 +267,6 @@ export default class GameMenuScene extends Phaser.Scene {
this._gameObjects.push(qg, qLabel); 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) { openGame(game) {

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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', jigsaw: 'jigsaw-game' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug]; const sceneKey = slugDispatch[this.game.slug];
const startData = { const startData = {

View File

@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { auth } from '../services/auth.js'; import { auth } from '../services/auth.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { addFullscreenButton } from '../ui/FullscreenButton.js'; import { addFullscreenButton } from '../ui/FullscreenButton.js';
import { playMenuMusic } from '../ui/MenuMusic.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); 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); logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
this._logo = logo;
this._transitioning = false;
this.tweens.add({ this.tweens.add({
targets: logo, targets: logo,
@ -56,7 +53,6 @@ export default class LandingScene extends Phaser.Scene {
renderButtons() { renderButtons() {
const cx = GAME_WIDTH / 2; const cx = GAME_WIDTH / 2;
const user = auth.user; const user = auth.user;
this._avatar = null;
const avatarR = 28; const avatarR = 28;
const avatarGap = 18; const avatarGap = 18;
@ -69,8 +65,6 @@ export default class LandingScene extends Phaser.Scene {
fontSize: '36px', fontSize: '36px',
color: COLORS.accentHex, color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(1); }).setOrigin(0.5).setDepth(1);
this._welcomeText = welcomeText;
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
const hasAvatar = !!user?.avatarPath; const hasAvatar = !!user?.avatarPath;
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width; 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); 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 }) this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45);
.setPosition(cx, y);
if (hasAvatar) { if (hasAvatar) {
const avatarCx = groupLeft + avatarR; const avatarCx = groupLeft + avatarR;
@ -93,11 +86,11 @@ export default class LandingScene extends Phaser.Scene {
this.load.start(); 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 }); const maskG = this.make.graphics({ x: 0, y: 0, add: false });
maskG.fillStyle(0xffffff); maskG.fillStyle(0xffffff);
maskG.fillCircle(avatarCx, y, avatarR); maskG.fillCircle(avatarCx, y, avatarR);
this._avatar = this.add.image(avatarCx, y, key) this.add.image(avatarCx, y, key)
.setDisplaySize(avatarR * 2, avatarR * 2) .setDisplaySize(avatarR * 2, avatarR * 2)
.setMask(maskG.createGeometryMask()) .setMask(maskG.createGeometryMask())
.setDepth(1); .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 }); new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 }); 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'),
});
} }
} }

View File

@ -1,7 +1,6 @@
import * as Phaser from 'phaser'; import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js'; import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js'; import { Button } from '../ui/Button.js';
import { Plaque } from '../ui/Plaque.js';
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js'; import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.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 // Opponent grid scroll area
const OPP_SCROLL_W = 1780; const OPP_SCROLL_W = 1780;
const OPP_SCROLL_H = 415; const OPP_SCROLL_H = 440;
const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque) const OPP_SCROLL_TOP = 155; // top edge of scroll area
export default class OpponentSelectScene extends Phaser.Scene { export default class OpponentSelectScene extends Phaser.Scene {
constructor() { super('OpponentSelect'); } 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.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); 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 const titleText = this.add.text(cx, 60, this.gameDef.name, {
// 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, {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '52px', fontSize: '52px',
color: COLORS.textHex, color: COLORS.textHex,
}).setOrigin(0.5); }).setOrigin(0.5);
titleText.setLetterSpacing(2); const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8); this.children.moveBelow(titlePill, titleText);
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
this.children.moveBelow(titlePlate, titleText);
const subtitleText = this.add.text(cx, 0, 'Choose your opponent', { const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
fontFamily: 'Righteous', fontFamily: 'Righteous',
fontSize: '36px', fontSize: '36px',
color: COLORS.mutedHex, color: COLORS.mutedHex,
}).setOrigin(0.5); }).setOrigin(0.5);
subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5); const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
const subPlateH = subtitleText.height + 28; this.children.moveBelow(subtitlePill, subtitleText);
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);
let opponents = []; let opponents = [];
try { try {
@ -99,7 +87,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
} }
const min = this.gameDef.minOpponents ?? 1; 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', variant: 'ghost',
width: 280, width: 280,
}); });

View File

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

View File

@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
if (this._active) return; if (this._active) return;
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options; const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
if (v === 'ghost') { if (v === 'ghost') {
this._drawBg(bgh, 0.9); this._drawBg(bgh, 0.18);
this.text.setColor(thc); this.text.setColor(COLORS.goldHex);
} else { } else {
this._drawBg(bgh, 1); this._drawBg(bgh, 1);
this.text.setColor(thc); 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,14 +1,13 @@
#!/usr/bin/env node #!/usr/bin/env node
// verifyBookwork.js — engine tests for Bookworm // verifyBookwork.js — engine tests for Bookwork
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { import {
GRID_SIZE, makeGrid, getAdjacent, isAdjacent, GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
wordFromCells, computeDamage, computeSelfDamage, wordFromCells, computeDamage, computeSelfDamage,
clearAndRefill, dropSpecialTile, countPoisonTiles, clearAndRefill, dropSpecialTile, countPoisonTiles,
computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES, computeMaxHp, isPotionUnlocked,
} from '../src/games/bookwork/BookworkLogic.js'; } from '../src/games/bookwork/BookworkLogic.js';
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js'; import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
let pass = 0, fail = 0; 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('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)); 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 ──────────────────────────────────────────────────────────── // ── dropSpecialTile ────────────────────────────────────────────────────────────
console.log('\ndropSpecialTile'); console.log('\ndropSpecialTile');
const gridS = makeGrid(() => 0.5); 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('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
ok('playerBaseHp present', bwData.playerBaseHp > 0); ok('playerBaseHp present', bwData.playerBaseHp > 0);
ok('milestones array present', Array.isArray(bwData.milestones)); 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 ─────────────────────────────────────────────────────────────────── // ── 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);