Compare commits
38 Commits
Master-of-
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
068da0a8ce | |
|
|
ed7620ee7b | |
|
|
03d35b0b00 | |
|
|
79fb74d9b2 | |
|
|
71a919e0cb | |
|
|
5a5f30bca7 | |
|
|
858dc9535d | |
|
|
52eaff8b96 | |
|
|
44de2fb049 | |
|
|
e3d2fdee34 | |
|
|
3c2cf462ed | |
|
|
93db9ef9dc | |
|
|
ae30c66a3a | |
|
|
429102105b | |
|
|
1298648e97 | |
|
|
74cd4df85b | |
|
|
cbec3b3a44 | |
|
|
f2108e6258 | |
|
|
4ecfcaee50 | |
|
|
e319f41ce8 | |
|
|
97adfa642d | |
|
|
18d89d4553 | |
|
|
0c59a292a3 | |
|
|
243d2e143c | |
|
|
c29f33e2a3 | |
|
|
a7ebb67413 | |
|
|
25f5e05a96 | |
|
|
4f2d29942d | |
|
|
0740c12ffa | |
|
|
19dff633ce | |
|
|
8dee798ae2 | |
|
|
85fc7c16bf | |
|
|
b6299234cf | |
|
|
dca1cbef4d | |
|
|
22e6b68cef | |
|
|
a8b4d15e8c | |
|
|
cb4ac7bd85 | |
|
|
d19fd1681b |
|
|
@ -0,0 +1,5 @@
|
|||
# Agent notes
|
||||
|
||||
## Git
|
||||
- **The user makes their own commits.** Leave changes in the working tree; do not
|
||||
run `git commit` (or `git push`) unless explicitly asked.
|
||||
|
|
@ -88,7 +88,7 @@ menu categories:
|
|||
| **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi |
|
||||
| **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle |
|
||||
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
|
||||
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble |
|
||||
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble |
|
||||
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
|
||||
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
|
||||
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
|
||||
</head><body>
|
||||
<div id="log"></div>
|
||||
<script type="module">
|
||||
import * as Phaser from 'phaser';
|
||||
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
|
||||
|
||||
const log = (m) => { document.getElementById('log').textContent += m + '\n'; console.log('[t]', m); };
|
||||
let failures = 0;
|
||||
const check = (ok, msg) => { if (!ok) { failures++; log('FAIL: ' + msg); } else log('ok: ' + msg); };
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const ART = [
|
||||
{ name: 'Alien World', path: 'assets/images/shift/alien-world.png' },
|
||||
{ name: 'Aquaroom', path: 'assets/images/shift/aquaroom.png' },
|
||||
{ name: 'Aztec Warrior', path: 'assets/images/shift/aztec-warrior.png' },
|
||||
{ name: 'Cat On Tiger', path: 'assets/images/shift/cat-on-tiger.png' },
|
||||
{ name: 'Cockpit', path: 'assets/images/shift/cockpit.png' },
|
||||
];
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
width: 1920, height: 1080,
|
||||
parent: document.body,
|
||||
backgroundColor: '#000',
|
||||
scene: [ { key: 'boot', create() {
|
||||
this.cache.json.add('shift-artwork', { artwork: ART });
|
||||
this.cache.json.add('music', { tracks: [] });
|
||||
this.scene.start('jigsaw-game');
|
||||
} }, JigsawGame ],
|
||||
};
|
||||
const game = new Phaser.Game(config);
|
||||
const s = () => game.scene.getScene('jigsaw-game');
|
||||
|
||||
(async () => {
|
||||
for (let i = 0; i < 400 && !s(); i++) await wait(50); // boot → jigsaw start is async
|
||||
if (!s()) throw new Error('jigsaw scene never started');
|
||||
for (let i = 0; i < 400 && !s().menu; i++) await wait(50);
|
||||
check(!!s().menu, 'menu built');
|
||||
|
||||
// Menu is back to the original shape (random-start button reverted).
|
||||
check(s().randomStartButton === undefined, 'randomStartButton is gone (menu as before)');
|
||||
|
||||
// The initial image is random but always a valid artwork entry, and the
|
||||
// preview shown in the menu matches it.
|
||||
const n = s().artwork.length;
|
||||
const i = s().imageIndex;
|
||||
check(Number.isInteger(i) && i >= 0 && i < n, `initial imageIndex ${i} is a valid index (0..${n - 1})`);
|
||||
check(!!ART.find((a) => a.name === s().currentImage().name), 'initial currentImage() is a known artwork entry');
|
||||
|
||||
// Preview must be built for the initial image (async image load).
|
||||
for (let k = 0; k < 200 && !s().previewImg; k++) await wait(25);
|
||||
check(!!s().previewImg, 'initial preview image is displayed');
|
||||
check(s().thumbName && s().thumbName.text === s().currentImage().name, `thumb name matches initial image (${s().currentImage().name})`);
|
||||
|
||||
// Start Puzzle must still start the initial (random) image.
|
||||
s().selectedDiff = 'easy';
|
||||
s().startPuzzle();
|
||||
let playing = false;
|
||||
for (let k = 0; k < 400 && !playing; k++) { playing = s().state === 'playing'; await wait(25); }
|
||||
check(playing, 'Start Puzzle reaches playing state');
|
||||
check(s().pieces.length === 25, '25 pieces built');
|
||||
check(s().imageName === ART[i].name, `playing image matches the initial pick (${ART[i].name})`);
|
||||
|
||||
// The original 🎲 Random button still randomises the preview in the menu.
|
||||
s().toMenu();
|
||||
const before = s().imageIndex;
|
||||
const seen = new Set([before]);
|
||||
for (let k = 0; k < 10; k++) { s().randomImage(); seen.add(s().imageIndex); await wait(10); }
|
||||
check(seen.size >= 2, '🎲 Random button still changes the selected image');
|
||||
|
||||
document.__initIndex = i;
|
||||
log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
|
||||
document.title = failures === 0 ? 'PASS' : 'FAIL:' + failures;
|
||||
})().catch((e) => {
|
||||
log('ERROR: ' + ((e && e.stack) || e));
|
||||
document.title = 'FAIL:error';
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
|
||||
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
|
||||
</head><body>
|
||||
<div id="log"></div>
|
||||
<script type="module">
|
||||
import * as Phaser from 'phaser';
|
||||
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
|
||||
|
||||
const log = (m) => { const el = document.getElementById('log'); el.textContent += m + '\n'; console.log('[harness]', m); };
|
||||
|
||||
const config = {
|
||||
type: Phaser.WEBGL,
|
||||
width: 1920, height: 1080,
|
||||
parent: document.body,
|
||||
backgroundColor: '#000',
|
||||
scene: [ { key:'boot', create(){
|
||||
// Provide the artwork cache so loadArtwork() has a real list.
|
||||
this.cache.json.add('shift-artwork', { artwork: [ { name:'Alien World', path:'assets/images/shift/alien-world.png' } ] });
|
||||
this.cache.json.add('music', { tracks: [] });
|
||||
this.scene.start('jigsaw-game');
|
||||
} }, JigsawGame ],
|
||||
};
|
||||
const game = new Phaser.Game(config);
|
||||
window.__game = game;
|
||||
|
||||
// Helpers exposed for the driver.
|
||||
window.__log = log;
|
||||
window.__scene = () => game.scene.getScene('jigsaw-game');
|
||||
window.__worldToScreen = (wx, wy) => {
|
||||
const cam = window.__scene().cameras.main;
|
||||
return { x: (wx - cam.scrollX) * cam.zoom + cam.x, y: (wy - cam.scrollY) * cam.zoom + cam.y };
|
||||
};
|
||||
window.__pieceAt = (i) => { const s = window.__scene(); const p = s.pieces[i]; return { i, x:p.img.x, y:p.img.y, placed:p.placed, groupLeader: p.group===p, depth:p.img.depth, hasInput: !!(p.img.input&&p.img.input.enabled) }; };
|
||||
window.__pieces = () => window.__scene().pieces.map((p,i)=>({i, x:Math.round(p.img.x), y:Math.round(p.img.y), placed:p.placed, depth:p.img.depth}));
|
||||
window.__startPuzzle = () => { const s = window.__scene(); s.selectedDiff='easy'; s.startPuzzle(); };
|
||||
window.__grabAndDrop = async (pieceIdx, toWorld, steps=12, holdMs=40) => {
|
||||
const s = window.__scene();
|
||||
const p = s.pieces[pieceIdx];
|
||||
const from = { x: p.img.x, y: p.img.y };
|
||||
// mousedown at from
|
||||
const f2s = window.__worldToScreen(from.x, from.y);
|
||||
await __mouseDown(f2s.x, f2s.y);
|
||||
await new Promise(r=>setTimeout(r,holdMs));
|
||||
for (let k=1;k<=steps;k++){
|
||||
const x = from.x + (toWorld.x-from.x)*k/steps, y = from.y + (toWorld.y-from.y)*k/steps;
|
||||
const c = window.__worldToScreen(x,y);
|
||||
await __mouseMove(c.x,c.y);
|
||||
await new Promise(r=>setTimeout(r,8));
|
||||
}
|
||||
await __mouseUp();
|
||||
return { from, to:{x:p.img.x,y:p.img.y}, moved: Math.hypot(p.img.x-from.x,p.img.y-from.y) };
|
||||
};
|
||||
// Low-level mouse dispatch in canvas (client) coordinates.
|
||||
window.__mouseDown = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousedown',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
|
||||
window.__mouseMove = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousemove',{clientX:cx,clientY:cy,bubbles:true})); };
|
||||
window.__mouseUp = (cx=0,cy=0) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mouseup',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
|
||||
|
||||
log('harness ready');
|
||||
</script>
|
||||
</body></html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 341 KiB |
Binary file not shown.
|
|
@ -1,5 +1,18 @@
|
|||
{
|
||||
"playerBaseHp": 100,
|
||||
"steer": {
|
||||
"wordBoostK": 1.0,
|
||||
"classThreshold": 2,
|
||||
"classDamp": 0.35,
|
||||
"classBoost": 1.8,
|
||||
"vowelBand": [0.30, 0.45]
|
||||
},
|
||||
"specialTiles": {
|
||||
"startLevel": 1,
|
||||
"endLevel": 10,
|
||||
"start": { "gold": 0.10, "diamond": 0.06 },
|
||||
"end": { "gold": 0.03, "diamond": 0.02 }
|
||||
},
|
||||
"milestones": [
|
||||
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
|
||||
{ "afterLevel": 10, "maxHpBonus": 10 },
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"default": "back-0",
|
||||
"cardBacks": [
|
||||
{ "id": "back-0", "name": "Card Back 1", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-1", "name": "Card Back 2", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-2", "name": "Card Back 3", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-3", "name": "Card Back 4", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-4", "name": "Card Back 5", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-5", "name": "Card Back 6", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-6", "name": "Card Back 7", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-7", "name": "Card Back 8", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
|
||||
{ "id": "back-0", "name": "Cthulhu", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-1", "name": "Cyberpunk", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-2", "name": "Nautical", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-3", "name": "Steampunk", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-4", "name": "Jungle", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-5", "name": "Hi Tech", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-6", "name": "Fall Mountain", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-7", "name": "Neon Miami", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,5 +9,8 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -256,6 +256,9 @@ export const MANIFEST = {
|
|||
image('bg-jewelquest-menu', 'assets/images/background-jewelquest-menu.png'),
|
||||
],
|
||||
bejeweled: [image('bg-bejeweled', 'assets/images/background-bejeweledblitz.png')],
|
||||
// Still gameplay background for Spelling Bee — the intro clip is swapped for
|
||||
// this once a puzzle starts (SpellingBeeGame.showImageBackground).
|
||||
spellingbee: [image('spellingbee-bg', 'assets/images/background-spellingbee.png')],
|
||||
rushhour: [
|
||||
// Carries its own title, so the level-select screen draws no heading.
|
||||
// In-play art is procedural (see games/rushhour/RushHourArt.js).
|
||||
|
|
@ -480,6 +483,11 @@ export const MANIFEST = {
|
|||
// so its audio only downloads once Tempest is actually entered.
|
||||
(scene) => musicFrom(scene, 'arcadedark-music'),
|
||||
],
|
||||
defender: [
|
||||
// arcadedark soundtrack (see services/soundtrack.js) — lazy-loaded here
|
||||
// so its audio only downloads once Defender is actually entered.
|
||||
(scene) => musicFrom(scene, 'arcadedark-music'),
|
||||
],
|
||||
mastermind: [
|
||||
// hacker soundtrack (see services/soundtrack.js) — lazy-loaded here so
|
||||
// its audio only downloads once Mastermind is actually entered.
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame:
|
|||
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' });
|
||||
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
|
||||
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
|
||||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });
|
||||
|
|
@ -123,3 +123,5 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
|
|||
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
||||
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
|
||||
registerGame({ slug: 'defender', name: 'Defender', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 97 });
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import {
|
|||
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
|
||||
wordFromCells, computeDamage, computeSelfDamage,
|
||||
clearAndRefill, dropSpecialTile, countPoisonTiles,
|
||||
computeMaxHp, isPotionUnlocked,
|
||||
computeMaxHp, isPotionUnlocked, specialTileChances,
|
||||
} from './BookworkLogic.js';
|
||||
import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
|
||||
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
|
||||
|
||||
const CELL = 96;
|
||||
const GRID_W = CELL * GRID_SIZE;
|
||||
|
|
@ -39,7 +40,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
constructor() { super('BookworkGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' };
|
||||
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
|
||||
this.config = { playerBaseHp: 100, milestones: [], levels: [] };
|
||||
this.bank = [];
|
||||
this.roster = [];
|
||||
|
|
@ -56,6 +57,9 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.bgTex = null;
|
||||
// Battle state
|
||||
this.grid = null;
|
||||
this.wordSet = null; // steering dictionary (ENABLE words 3–15); null → unsteered
|
||||
this.steerOpts = {};
|
||||
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
|
||||
this.tileObjs = null;
|
||||
this.selection = [];
|
||||
this.selGraphics = null;
|
||||
|
|
@ -85,8 +89,16 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
|
||||
const raw = this.cache.json.get('bookwork');
|
||||
if (raw) this.config = raw;
|
||||
this.steerOpts = this.config.steer ?? {};
|
||||
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
// Load the steering dictionary (same ENABLE list the word validator uses).
|
||||
// Non-fatal: if it fails, boards fall back to plain weighted-random.
|
||||
try {
|
||||
const res = await fetch('data/wordlists/enable1.txt');
|
||||
if (res.ok) this.wordSet = parseWordList(await res.text());
|
||||
} catch (_) { this.wordSet = null; }
|
||||
|
||||
try {
|
||||
const res = await fetch('data/opponents.json');
|
||||
const json = await res.json();
|
||||
|
|
@ -279,7 +291,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'BOOKWORK', {
|
||||
const title = this.add.text(cx, 84, 'BOOKWORM', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
|
||||
|
|
@ -514,6 +526,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.level = level;
|
||||
this.levelDef = lv;
|
||||
this.opponent = this.opponentFor(lv);
|
||||
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
|
||||
this.clearLayer();
|
||||
|
||||
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
|
||||
|
|
@ -523,7 +536,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
|
||||
this.potionUsed = false;
|
||||
this.turnPhase = 'player';
|
||||
this.grid = makeGrid();
|
||||
this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
|
||||
this.selection = [];
|
||||
|
||||
this.drawBattleUI();
|
||||
|
|
@ -832,7 +845,9 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Refill grid
|
||||
this.grid = clearAndRefill(this.grid, cells);
|
||||
this.grid = this.wordSet
|
||||
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
|
||||
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
|
||||
await this.animateRefill(cells);
|
||||
this.redrawAllTiles();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const GRID_SIZE = 5;
|
||||
|
||||
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
|
||||
const LETTER_WEIGHTS = {
|
||||
export const LETTER_WEIGHTS = {
|
||||
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
|
||||
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
|
||||
X:1, Y:3, Z:1,
|
||||
|
|
@ -16,6 +16,29 @@ export function randomLetter(rng = Math.random) {
|
|||
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
|
||||
}
|
||||
|
||||
// Default spawn chances for multiplier tiles on refills (late-game floor).
|
||||
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
|
||||
|
||||
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
|
||||
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
|
||||
// holds cfg.end. Returns { goldChance, diamondChance }.
|
||||
// cfg = { startLevel: 1, endLevel: 10,
|
||||
// start: { gold: 0.10, diamond: 0.06 },
|
||||
// end: { gold: 0.03, diamond: 0.02 } }
|
||||
export function specialTileChances(level, cfg = {}) {
|
||||
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
|
||||
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
|
||||
const from = cfg.startLevel ?? 1;
|
||||
const to = cfg.endLevel ?? from;
|
||||
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
|
||||
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
|
||||
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
|
||||
return {
|
||||
goldChance: start.gold + (end.gold - start.gold) * t,
|
||||
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeGrid(rng = Math.random) {
|
||||
const grid = [];
|
||||
for (let r = 0; r < GRID_SIZE; r++) {
|
||||
|
|
@ -69,9 +92,12 @@ export function computeSelfDamage(cells, grid) {
|
|||
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
|
||||
}
|
||||
|
||||
// Cascade tiles down in each column, fill top with new random tiles
|
||||
export function clearAndRefill(grid, usedCells, rng = Math.random) {
|
||||
// Cascade tiles down in each column, fill top with new random tiles.
|
||||
// opts may override spawn chances: { goldChance, diamondChance }.
|
||||
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
|
||||
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
|
||||
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
|
||||
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
|
||||
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
|
||||
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
|
|
@ -82,8 +108,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random) {
|
|||
}
|
||||
// Fill remainder with new normal tiles
|
||||
while (survive.length < GRID_SIZE) {
|
||||
const gold = rng() < 0.03;
|
||||
const diamond = !gold && rng() < 0.02;
|
||||
const gold = rng() < goldChance;
|
||||
const diamond = !gold && rng() < diamondChance;
|
||||
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
|
||||
survive.push({ letter: randomLetter(rng), type });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,782 @@
|
|||
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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,882 @@
|
|||
// 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// 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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,292 @@
|
|||
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
|
||||
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
|
||||
//
|
||||
// Model
|
||||
// -----
|
||||
// A cols×rows grid. Every internal edge between two cells carries exactly one
|
||||
// knob (a protruding "tab") that belongs to one of the two cells; the other
|
||||
// cell gets the matching "blank" (indent). Boundary edges are flat.
|
||||
//
|
||||
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
|
||||
// (r,c+1) [right]. 'L' → left cell owns the tab.
|
||||
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
|
||||
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
|
||||
//
|
||||
// Because both neighbours compute the SAME knob (same line segment, same side)
|
||||
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
|
||||
|
||||
export const DIFFICULTIES = {
|
||||
// Piece counts roughly double per tier. Square grids so the (square) source
|
||||
// image fills the board edge-to-edge with no letterboxing.
|
||||
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
|
||||
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
|
||||
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
|
||||
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
|
||||
};
|
||||
|
||||
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
|
||||
|
||||
// Knob shape as fractions of the edge length (see edgeFragment below).
|
||||
// neckFrac: how far in from each end the neck (narrow waist) sits.
|
||||
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
|
||||
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
|
||||
|
||||
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function rng() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const rand = Math.random;
|
||||
|
||||
// Build the knob assignment for every internal edge.
|
||||
export function makeJigsaw(cols, rows, seed = null) {
|
||||
const g = seed == null ? rand : mulberry32(seed);
|
||||
const H = [];
|
||||
const V = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
|
||||
H.push(row);
|
||||
}
|
||||
for (let r = 0; r < rows - 1; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
|
||||
V.push(row);
|
||||
}
|
||||
return { cols, rows, H, V };
|
||||
}
|
||||
|
||||
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
|
||||
// `normal` is the direction the knob bulges (null for flat edges). This is the
|
||||
// side the shared curve lies on, so it is identical for both adjacent cells.
|
||||
export function cellEdgeSpec(jig, r, c) {
|
||||
const { cols, rows, H, V } = jig;
|
||||
const out = {};
|
||||
|
||||
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
|
||||
if (r === 0) out.top = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = V[r - 1][c];
|
||||
const tab = owner === 'D'; // bottom cell owns the tab
|
||||
out.top = tab
|
||||
? { kind: 'tab', normal: { x: 0, y: -1 } }
|
||||
: { kind: 'blank', normal: { x: 0, y: 1 } };
|
||||
}
|
||||
|
||||
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
|
||||
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = H[r][c];
|
||||
const tab = owner === 'L'; // left cell owns the tab
|
||||
out.right = tab
|
||||
? { kind: 'tab', normal: { x: 1, y: 0 } }
|
||||
: { kind: 'blank', normal: { x: -1, y: 0 } };
|
||||
}
|
||||
|
||||
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
|
||||
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = V[r][c];
|
||||
const tab = owner === 'U'; // top cell owns the tab
|
||||
out.bottom = tab
|
||||
? { kind: 'tab', normal: { x: 0, y: 1 } }
|
||||
: { kind: 'blank', normal: { x: 0, y: -1 } };
|
||||
}
|
||||
|
||||
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
|
||||
if (c === 0) out.left = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = H[r][c - 1];
|
||||
const tab = owner === 'R'; // right cell owns the tab
|
||||
out.left = tab
|
||||
? { kind: 'tab', normal: { x: -1, y: 0 } }
|
||||
: { kind: 'blank', normal: { x: 1, y: 0 } };
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// The 4-neighbour cells of (r,c) inside the grid. By construction every such
|
||||
// pair shares an internal edge, and both pieces trace the *same* shared curve
|
||||
// for it — so these are exactly the pieces that mesh with (r,c) when placed in
|
||||
// their correct board slots. That is the legal set of pieces that may join
|
||||
// (r,c) anywhere on the table; no other pair can ever fit together.
|
||||
export function cellNeighbours(jig, r, c) {
|
||||
const { cols, rows } = jig;
|
||||
const out = [];
|
||||
if (c > 0) out.push([r, c - 1]);
|
||||
if (c < cols - 1) out.push([r, c + 1]);
|
||||
if (r > 0) out.push([r - 1, c]);
|
||||
if (r < rows - 1) out.push([r + 1, c]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Table assembly: joining pieces & locking groups (pure, Phaser-free) ─────
|
||||
// Model the scene feeds in (plain data only — the math never touches Phaser):
|
||||
// piece: { r, c, home:{x,y}, pos:{x,y}, placed, group }
|
||||
// group: { pieces: [...] } (piece.group points back)
|
||||
// cellAt(r, c) -> piece|null (the grid lookup)
|
||||
//
|
||||
// Group invariant: every member of a group sits at `anchor.pos + (member.home -
|
||||
// anchor.home)` for any member `anchor` — i.e. the exact board-relative offset
|
||||
// — so members always mesh while the group moves. resolveDrop preserves it.
|
||||
//
|
||||
// resolveDrop decides what happens when `group` (all members unplaced) is
|
||||
// released on the table, using the same snap radius for both outcomes:
|
||||
// 1. BOARD LOCK — if any member is within `snapR` of its home slot, the
|
||||
// whole group locks onto the board. Only grid-adjacent pieces can share a
|
||||
// group, and grid-adjacent pieces mesh exactly on the board, so the group
|
||||
// always lands as a correctly assembled block (every member is then
|
||||
// aligned too, by the invariant).
|
||||
// 2. JOIN — otherwise, any unplaced grid-neighbour of any member sitting
|
||||
// within `snapR` of its correct relative position is absorbed together
|
||||
// with its WHOLE group; repeated to a fixpoint so a chain of correctly
|
||||
// placed pieces latches on in a single drop. Non-adjacent pieces can
|
||||
// never join, no matter where they sit — they wouldn't mesh on the board.
|
||||
// 3. REST — otherwise the group just rests where it was dropped.
|
||||
//
|
||||
// Pure: no mutation. Returns { outcome, placements, absorbedGroups } where
|
||||
// placements are the target positions the caller must apply and absorbedGroups
|
||||
// are the (other) groups that merged into `group`.
|
||||
export function resolveDrop(jig, group, cellAt, snapR) {
|
||||
// 1) Board lock takes precedence: any member aligned ⇒ the group is placed.
|
||||
for (const m of group.pieces) {
|
||||
if (Math.hypot(m.pos.x - m.home.x, m.pos.y - m.home.y) < snapR) {
|
||||
return {
|
||||
outcome: 'locked',
|
||||
placements: group.pieces.map((m) => ({ piece: m, x: m.home.x, y: m.home.y })),
|
||||
absorbedGroups: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Join: absorb unplaced grid-neighbours at their correct relative spot.
|
||||
// `frame` is the group's consistent position frame: the dropped group is
|
||||
// already home-exact, and every absorbed piece is snapped INTO the frame,
|
||||
// so a chain that latches on ends up fully consistent (invariant holds).
|
||||
const frame = new Map();
|
||||
for (const m of group.pieces) frame.set(m, m.pos);
|
||||
const members = [...group.pieces]; // working set — `group` is not mutated
|
||||
const inGroup = new Set(members);
|
||||
const placements = [];
|
||||
const absorbedGroups = new Set();
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const m of [...members]) {
|
||||
for (const [nr, nc] of cellNeighbours(jig, m.r, m.c)) {
|
||||
const q = cellAt(nr, nc);
|
||||
if (!q || q.placed || inGroup.has(q)) continue;
|
||||
// Where q belongs in the assembled group relative to m's frame position.
|
||||
const fm = frame.get(m);
|
||||
const ex = fm.x + (q.home.x - m.home.x);
|
||||
const ey = fm.y + (q.home.y - m.home.y);
|
||||
if (Math.hypot(q.pos.x - ex, q.pos.y - ey) >= snapR) continue;
|
||||
absorbedGroups.add(q.group);
|
||||
for (const x of q.group.pieces) {
|
||||
if (inGroup.has(x)) continue;
|
||||
// Snap x into the frame (offsets from q are exact).
|
||||
const px = ex + (x.home.x - q.home.x);
|
||||
const py = ey + (x.home.y - q.home.y);
|
||||
frame.set(x, { x: px, y: py });
|
||||
placements.push({ piece: x, x: px, y: py });
|
||||
members.push(x);
|
||||
inGroup.add(x);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!placements.length) return { outcome: 'rested', placements: [], absorbedGroups: [] };
|
||||
return { outcome: 'joined', placements, absorbedGroups: [...absorbedGroups].filter((g) => g !== group) };
|
||||
}
|
||||
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
|
||||
// Path commands for one edge, assuming the current point is `p0`.
|
||||
// Flat → a single line. Knob → line to the near neck, one cubic through the
|
||||
// bulb to the far neck, line to `p1`. The curve is direction-independent:
|
||||
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
|
||||
// which is what makes neighbouring pieces mesh.
|
||||
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
|
||||
if (!edge || edge.kind === 'flat') {
|
||||
return [{ t: 'line', x: p1.x, y: p1.y }];
|
||||
}
|
||||
const { neckFrac, ctrlFrac } = knob;
|
||||
const nA = lerp(p0, p1, neckFrac);
|
||||
const nB = lerp(p0, p1, 1 - neckFrac);
|
||||
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
||||
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
|
||||
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
|
||||
return [
|
||||
{ t: 'line', x: nA.x, y: nA.y },
|
||||
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
|
||||
{ t: 'line', x: p1.x, y: p1.y },
|
||||
];
|
||||
}
|
||||
|
||||
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
|
||||
// units; `ox`,`oy` the cell's top-left in local units. Returns
|
||||
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
|
||||
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
|
||||
const x0 = ox + c * W;
|
||||
const y0 = oy + r * H;
|
||||
const TL = { x: x0, y: y0 };
|
||||
const TR = { x: x0 + W, y: y0 };
|
||||
const BR = { x: x0 + W, y: y0 + H };
|
||||
const BL = { x: x0, y: y0 + H };
|
||||
const spec = cellEdgeSpec(jig, r, c);
|
||||
|
||||
const cmds = [
|
||||
...edgeFragment(TL, TR, spec.top, knob),
|
||||
...edgeFragment(TR, BR, spec.right, knob),
|
||||
...edgeFragment(BR, BL, spec.bottom, knob),
|
||||
...edgeFragment(BL, TL, spec.left, knob),
|
||||
];
|
||||
return { start: TL, cmds };
|
||||
}
|
||||
|
||||
// Apply path commands to a 2D canvas context (builds the current path).
|
||||
export function tracePath(ctx, outline) {
|
||||
const { start, cmds } = outline;
|
||||
ctx.moveTo(start.x, start.y);
|
||||
for (const c of cmds) {
|
||||
if (c.t === 'line') ctx.lineTo(c.x, c.y);
|
||||
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// ── Scramble: assign each piece a start position in the "tray" region ─────────
|
||||
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
|
||||
// pieces are scattered. Returns an array aligned to the piece index
|
||||
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
|
||||
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
|
||||
const g = seed == null ? rand : mulberry32(seed);
|
||||
const { cols, rows } = jig;
|
||||
const N = cols * rows;
|
||||
const placed = [];
|
||||
const margin = Math.max(tray.w, tray.h) * 0.06;
|
||||
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
|
||||
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
|
||||
for (let i = 0; i < N; i++) {
|
||||
// Rejection-sample a few tries so pieces don't pile in a single spot.
|
||||
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
|
||||
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
|
||||
let tries = 0;
|
||||
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
|
||||
px = x0 + (x1 - x0) * g();
|
||||
py = y0 + (y1 - y0) * g();
|
||||
tries++;
|
||||
}
|
||||
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
|
@ -81,7 +81,17 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) new MusicPlayer(this, tracks, volume);
|
||||
} catch { /* optional */ }
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg);
|
||||
// Backdrop: the playfield chosen on the opponent-select screen (image
|
||||
// texture, or the generated canvas texture for the "colored" playfields),
|
||||
// falling back to its flat color and finally to the classic deep green.
|
||||
const pf = this.playfield;
|
||||
if (pf?.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
} else {
|
||||
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : FELT;
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color).setDepth(DEPTH.bg);
|
||||
}
|
||||
|
||||
const names = [auth.user?.username ?? 'You'];
|
||||
const skills = { 0: 5 };
|
||||
|
|
@ -130,11 +140,16 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildPortraits() {
|
||||
// Each portrait sits just to the left of its own tiles, at the vertical
|
||||
// centre of that area (rows/columns are all centred on their midpoints),
|
||||
// so it reads as belonging to that player without ever covering a tile.
|
||||
// Keep ~18-20px clearance: human hand left edge ≥ 391 (14 tiles), left
|
||||
// column left edge 133, right column left edge 1836, top row ≥ 616.
|
||||
const spots = [
|
||||
{ x: 90, y: 870, r: 46 }, // human — bottom left
|
||||
{ x: 1830, y: 170, r: 40 }, // seat 1 — right
|
||||
{ x: 560, y: 95, r: 40 }, // seat 2 — top
|
||||
{ x: 90, y: 310, r: 40 }, // seat 3 — left
|
||||
{ x: 325, y: 975, r: 46 }, // human — left of the bottom hand row
|
||||
{ x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column
|
||||
{ x: 558, y: 70, r: 40 }, // seat 2 — left of the top row
|
||||
{ x: 75, y: 580, r: 40 }, // seat 3 — left of the left column
|
||||
];
|
||||
for (let seat = 0; seat < 4; seat++) {
|
||||
const { x, y, r } = spots[seat];
|
||||
|
|
@ -219,7 +234,10 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5));
|
||||
this.refPanel = panel;
|
||||
|
||||
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), {
|
||||
// Parked just left of the reference panel's open position (1490) so it
|
||||
// never ends up under it; y=210 clears seat 2's melds (y≤98) and bonus
|
||||
// tiles (y≤157), which sit at x 1330+.
|
||||
this.refBtn = new Button(this, 1399, 210, 'Hands', () => this.toggleReference(), {
|
||||
width: 150, height: 52, fontSize: 22, variant: 'ghost',
|
||||
}).setDepth(DEPTH.ref + 1);
|
||||
}
|
||||
|
|
@ -233,6 +251,15 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
ease: 'Quad.Out',
|
||||
});
|
||||
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
|
||||
// Seat 1's portrait sits under the open panel. Its <video> is a DOM node
|
||||
// that always renders above canvas content, so hide it while covered.
|
||||
this.portraitCtrls[1]?.controller?.setVideoVisible?.(!this.refOpen);
|
||||
}
|
||||
|
||||
// The claim row and the self win/kong buttons sit under the open panel's
|
||||
// footprint — close it whenever they appear so nothing clickable is covered.
|
||||
closeReferenceIfOpen() {
|
||||
if (this.refOpen) this.toggleReference();
|
||||
}
|
||||
|
||||
// ── tile drawing ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -382,7 +409,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
bx += 40;
|
||||
}
|
||||
} else { // sides — vertical column of rotated backs
|
||||
const x = seat === 1 ? 1858 : 62;
|
||||
const x = seat === 1 ? 1858 : 155; // columns stay right of their portraits (seat 1: 1778+40, seat 3: 75+40)
|
||||
let y = 580 - ((n - 1) * step) / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const back = this.makeTileBack(SM_W, SM_H);
|
||||
|
|
@ -390,7 +417,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
this.dyn.add(back);
|
||||
y += step;
|
||||
}
|
||||
const mx = seat === 1 ? 1745 : 175;
|
||||
const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column
|
||||
let my = 330;
|
||||
for (const m of p.melds) {
|
||||
const row = this.makeMeldRow(m, 38, 52, false);
|
||||
|
|
@ -481,6 +508,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
btn.setVisible(!!spec);
|
||||
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
|
||||
});
|
||||
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
|
||||
}
|
||||
|
||||
// ── human input ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -521,6 +549,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
return new Promise((resolve) => {
|
||||
this.claimResolve = resolve;
|
||||
this.chowOptions = opts.chows;
|
||||
this.closeReferenceIfOpen();
|
||||
const visible = [];
|
||||
if (opts.win) visible.push(this.claimBtns.win);
|
||||
if (opts.kong) visible.push(this.claimBtns.kong);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,28 @@ const INK_DARK = '#1a1208'; // dark letter on the honey center hex
|
|||
const TITLE_GOLD = '#f2c14e';
|
||||
const PAPER = 0x1e1a12; // panels
|
||||
|
||||
// ── Background video ─────────────────────────────────────────────────────────
|
||||
// Animated bee-on-a-flower clip used as a live backdrop behind the board.
|
||||
// Loaded on demand (not in the asset manifest) and muted — the game has its
|
||||
// own soundtrack and SFX, so the clip is purely visual. Source resolution is
|
||||
// used to scale until the real texture decodes (a Video's own width/height
|
||||
// report a square placeholder before its first frame).
|
||||
const BG_VIDEO_KEY = 'spellingbee-bg-video';
|
||||
const BG_VIDEO_PATH = 'assets/videos/games/spellingBee.mp4';
|
||||
const BG_VIDEO_SRC_W = 864;
|
||||
const BG_VIDEO_SRC_H = 480;
|
||||
// The clip is bright, so dim it a touch to keep the title and board readable
|
||||
// without killing the color.
|
||||
const BG_DIM_ALPHA = 0.38;
|
||||
// Fallback for revealing the start panel: if the intro clip never finishes its
|
||||
// first loop (stalled download, unsupported codec, ...), bring the panel up
|
||||
// anyway. Just past the clip's ~15 s runtime.
|
||||
const INTRO_REVEAL_FALLBACK_MS = 20000;
|
||||
// Still gameplay backdrop, preloaded by GameRoomScene via data/assetManifest.js.
|
||||
// Replaces the intro clip the moment a puzzle actually starts.
|
||||
const BG_IMAGE_KEY = 'spellingbee-bg';
|
||||
const BG_IMAGE_PATH = 'assets/images/background-spellingbee.png';
|
||||
|
||||
const DEPTH = { bg: 0, panel: 2, comb: 8, combTxt: 10, word: 12, ui: 20, overlay: 40, overlayUI: 42 };
|
||||
|
||||
// ── Honeycomb geometry ─────────────────────────────────────────────────────────
|
||||
|
|
@ -48,6 +70,9 @@ export default class SpellingBeeGame extends Phaser.Scene {
|
|||
this.curLetters = []; // per-char text objects for the current word
|
||||
this.foundTexts = [];
|
||||
this.hexes = {}; // letter -> { container, gfx, isCenter }
|
||||
this.bgVideo = null;
|
||||
this.bgImage = null; // still gameplay background (replaces the video)
|
||||
this.panelRevealed = false; // difficulty panel shown (intro finished)
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -57,6 +82,8 @@ export default class SpellingBeeGame extends Phaser.Scene {
|
|||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT)
|
||||
.setDepth(DEPTH.bg);
|
||||
|
||||
this.buildVideoBackground();
|
||||
|
||||
this.add.text(40, 36, 'SPELLING BEE', {
|
||||
fontFamily: 'Righteous', fontSize: '46px', color: TITLE_GOLD,
|
||||
}).setDepth(DEPTH.ui);
|
||||
|
|
@ -70,6 +97,89 @@ export default class SpellingBeeGame extends Phaser.Scene {
|
|||
this.input.keyboard.off('keydown', this.keyHandler);
|
||||
this.keyHandler = null;
|
||||
}
|
||||
// The video may already be gone (e.g. the error handler destroyed it).
|
||||
if (this.bgVideo?.scene) this.bgVideo.destroy();
|
||||
this.bgVideo = null;
|
||||
if (this.bgImage?.scene) this.bgImage.destroy();
|
||||
this.bgImage = null;
|
||||
}
|
||||
|
||||
// ── Background video ───────────────────────────────────────────────────────
|
||||
|
||||
// Bring up the looping background clip on top of the felt base. In this
|
||||
// Phaser version a VideoFile "load" is a synchronous no-op — it only records
|
||||
// the URL in the video cache (the real fetch happens when the Video object
|
||||
// is created, in showBackgroundVideo) — so there is no async window to wait
|
||||
// out: queue it if needed and show it immediately. A fetch/playback failure
|
||||
// is handled by the video's 'error' event, leaving the felt base visible.
|
||||
buildVideoBackground() {
|
||||
if (!this.cache.video?.exists(BG_VIDEO_KEY)) {
|
||||
this.load.video(BG_VIDEO_KEY, BG_VIDEO_PATH, true); // noAudio — visual only
|
||||
if (!this.load.isLoading()) this.load.start();
|
||||
}
|
||||
this.showBackgroundVideo();
|
||||
}
|
||||
|
||||
showBackgroundVideo() {
|
||||
const v = this.add.video(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_VIDEO_KEY)
|
||||
.setDepth(DEPTH.bg);
|
||||
v.setLoop(true);
|
||||
v.setMute(true);
|
||||
|
||||
// Cover, not contain: fill edge-to-edge. The clip's aspect (864x480) is
|
||||
// within a hair of the game's (1920x1080), so the crop is imperceptible.
|
||||
const fit = () => {
|
||||
const w = v.videoTexture ? v.width : BG_VIDEO_SRC_W;
|
||||
const h = v.videoTexture ? v.height : BG_VIDEO_SRC_H;
|
||||
v.setScale(Math.max(GAME_WIDTH / w, GAME_HEIGHT / h));
|
||||
};
|
||||
fit();
|
||||
v.on('created', fit);
|
||||
v.on('playing', fit);
|
||||
|
||||
// A load failure mid-playback (or a browser refusing the element) falls
|
||||
// back to the felt rectangle already underneath — and reveals the start
|
||||
// panel, which otherwise waits for the intro loop.
|
||||
v.once('error', () => {
|
||||
this.revealStartPanel();
|
||||
if (v.scene) v.destroy();
|
||||
});
|
||||
|
||||
this.bgVideo = v;
|
||||
v.setAlpha(0);
|
||||
v.play(true);
|
||||
this.tweens.add({ targets: v, alpha: 1, duration: 700, ease: 'Sine.easeOut' });
|
||||
|
||||
// Dim layer on top of the footage so the title and board stay readable.
|
||||
// Fixed alpha: it only darkens the felt base while the video fades in, so
|
||||
// the footage never flashes bright before settling. (It also dims the
|
||||
// still gameplay backdrop once the video is swapped out — see
|
||||
// showImageBackground.)
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT,
|
||||
0x000000, BG_DIM_ALPHA).setDepth(DEPTH.bg + 1);
|
||||
}
|
||||
|
||||
// Swap the intro clip for the still gameplay backdrop (honeycomb PNG).
|
||||
// Preloaded via the asset manifest, so the texture is almost always ready;
|
||||
// the else branch covers a scene started without that preload. The felt
|
||||
// rectangle stays underneath as the ultimate fallback.
|
||||
showImageBackground() {
|
||||
if (this.bgImage) return;
|
||||
if (!this.textures.exists(BG_IMAGE_KEY)) {
|
||||
this.load.image(BG_IMAGE_KEY, BG_IMAGE_PATH);
|
||||
if (!this.load.isLoading()) this.load.start();
|
||||
}
|
||||
if (this.textures.exists(BG_IMAGE_KEY)) {
|
||||
this.bgImage = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_IMAGE_KEY)
|
||||
.setOrigin(0.5).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
} else {
|
||||
this.load.once('filecomplete', (type, key) => {
|
||||
if (type !== 'texture' || key !== BG_IMAGE_KEY) return;
|
||||
if (!this.sys.isActive() || this.bgImage) return;
|
||||
this.bgImage = this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, BG_IMAGE_KEY)
|
||||
.setOrigin(0.5).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Start panel (opponent-select is skipped for this solo game) ─────────────
|
||||
|
|
@ -107,6 +217,31 @@ export default class SpellingBeeGame extends Phaser.Scene {
|
|||
b.setDepth(DEPTH.ui);
|
||||
this.startObjs.push(b);
|
||||
});
|
||||
|
||||
// Hidden until the intro clip has played a full loop (see revealStartPanel).
|
||||
this.startObjs.forEach((o) => o.setAlpha(0));
|
||||
|
||||
// The first 'loop' event of the background video means the clip has just
|
||||
// played through once — the natural moment to surface the difficulty
|
||||
// choice. The safety timer covers a clip that never gets that far; the
|
||||
// video's 'error' handler (showBackgroundVideo) does the same immediately.
|
||||
const reveal = () => this.revealStartPanel();
|
||||
this.bgVideo?.once(Phaser.GameObjects.Events.VIDEO_LOOP, reveal);
|
||||
this.time.delayedCall(INTRO_REVEAL_FALLBACK_MS, reveal);
|
||||
}
|
||||
|
||||
// Fade the difficulty panel in. Whichever trigger fires first (intro loop,
|
||||
// video error, fallback timer) wins; the rest are no-ops.
|
||||
revealStartPanel() {
|
||||
if (this.panelRevealed) return;
|
||||
// Panel not built yet — bail without consuming the reveal; a later trigger
|
||||
// (loop timer / fallback) will fire once it exists.
|
||||
if (!this.startObjs.length) return;
|
||||
this.panelRevealed = true;
|
||||
this.startObjs.forEach((o) => {
|
||||
if (!o.scene) return;
|
||||
this.tweens.add({ targets: o, alpha: 1, duration: 700, ease: 'Sine.easeOut' });
|
||||
});
|
||||
}
|
||||
|
||||
async startPuzzle(difficulty) {
|
||||
|
|
@ -130,6 +265,12 @@ export default class SpellingBeeGame extends Phaser.Scene {
|
|||
this.maxScore = data.maxScore ?? 0;
|
||||
this.tiers = buildTiers(this.maxScore, difficulty);
|
||||
|
||||
// Difficulty screen is over: retire the intro clip and bring up the still
|
||||
// honeycomb backdrop. (If the fetch above failed we never get here, and
|
||||
// the player stays on the difficulty screen with the clip still playing.)
|
||||
if (this.bgVideo?.scene) { this.bgVideo.destroy(); this.bgVideo = null; }
|
||||
this.showImageBackground();
|
||||
|
||||
this.buildBoard();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
|
|||
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
||||
import ShiftGame from './games/shift/ShiftGame.js';
|
||||
import JigsawGame from './games/jigsaw/JigsawGame.js';
|
||||
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
||||
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
||||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||
|
|
@ -109,6 +110,7 @@ import WolfensteinGame from './games/wolfenstein/WolfensteinGame.js';
|
|||
import WolfensteinEditor from './games/wolfenstein/WolfensteinEditor.js';
|
||||
import PipePuzzleGame from './games/pipepuzzle/PipePuzzleGame.js';
|
||||
import TentsGame from './games/tents/TentsGame.js';
|
||||
import DefenderGame from './games/defender/DefenderGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -184,6 +186,7 @@ const config = {
|
|||
HexsweeperGame,
|
||||
PuddingMonstersGame,
|
||||
ShiftGame,
|
||||
JigsawGame,
|
||||
BlockFighterGame,
|
||||
MahjongMatchGame,
|
||||
MahjongGame,
|
||||
|
|
@ -231,6 +234,7 @@ const config = {
|
|||
WolfensteinEditor,
|
||||
PipePuzzleGame,
|
||||
TentsGame,
|
||||
DefenderGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
||||
import { TutorialModal } from '../ui/TutorialModal.js';
|
||||
|
||||
let _lastCategory = null;
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'tabletop', label: 'Tabletop' },
|
||||
{ key: 'cards', label: 'Cards & Dice' },
|
||||
|
|
@ -22,10 +21,29 @@ const ICON_INACTIVE = 56;
|
|||
const ICON_ACTIVE = 72;
|
||||
const ICON_OVERSHOOT = 86;
|
||||
const ICON_X_OFFSET = -145;
|
||||
// How far (px) a grid travels off screen when switching categories.
|
||||
const GRID_TRAVEL = GAME_WIDTH + 800;
|
||||
|
||||
// The most recently selected category. Module scope so it survives scene
|
||||
// restarts (scene.start() reuses the instance) and also covers paths that
|
||||
// start the menu without category data — the menu always returns to where
|
||||
// the user last was.
|
||||
let lastCategory = null;
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
constructor() { super('GameMenu'); }
|
||||
|
||||
init(data) {
|
||||
// Category to restore on entry: the explicit one (coming back from the
|
||||
// opponent picker) or the most recently selected one.
|
||||
this._initialCategory = (data && data.category) || lastCategory;
|
||||
// scene.start() reuses the existing scene instance, so instance props from
|
||||
// the previous visit survive — reset them so this create() starts clean
|
||||
// (otherwise showCategory()'s "same category" guard swallows the restore).
|
||||
this._currentCategory = null;
|
||||
this._gridAnim = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
playMenuMusic();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
|
@ -33,12 +51,18 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
addFullscreenButton(this);
|
||||
|
||||
const titleText = this.add.text(cx, 60, 'Choose a game', {
|
||||
const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(1);
|
||||
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
|
||||
titleText.setLetterSpacing(2);
|
||||
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
|
||||
this._titleText = titleText;
|
||||
const plateW = titleText.width + 64;
|
||||
const plateH = titleText.height + 28;
|
||||
this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5);
|
||||
this._titlePlateW = plateW;
|
||||
|
||||
const loadingText = this.add.text(cx, 540, 'Loading game list…', {
|
||||
fontSize: '24px', color: COLORS.mutedHex,
|
||||
|
|
@ -107,14 +131,49 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._tabIcons[key] = icon;
|
||||
});
|
||||
|
||||
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key;
|
||||
this.showCategory(startKey);
|
||||
// No category is selected by default — the user picks one to see games.
|
||||
this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', {
|
||||
fontSize: '26px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
|
||||
// Returning from the opponent picker: restore the category that was
|
||||
// active when the game was chosen — its grid flies back in from the right.
|
||||
if (this._initialCategory) {
|
||||
this.showCategory(this._initialCategory);
|
||||
}
|
||||
|
||||
// The landing scene zooms the logo out before starting us, so fade the
|
||||
// menu controls in to complete the handoff.
|
||||
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn].filter(Boolean);
|
||||
for (const obj of fadeIn) obj.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: fadeIn,
|
||||
alpha: 1,
|
||||
duration: 450,
|
||||
ease: 'Power2.easeOut',
|
||||
});
|
||||
}
|
||||
|
||||
showCategory(key) {
|
||||
_lastCategory = key;
|
||||
if (key === this._currentCategory) return;
|
||||
this._currentCategory = key;
|
||||
lastCategory = key;
|
||||
|
||||
if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
|
||||
if (this._titleText) {
|
||||
this._titleText.setText('Choose a game');
|
||||
const newW = this._titleText.width + 64;
|
||||
const newH = this._titleText.height + 28;
|
||||
if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) {
|
||||
this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut');
|
||||
this._titlePlateW = newW;
|
||||
}
|
||||
// Little pop so the label swap reads as intentional.
|
||||
this._titleText.setScale(0.9);
|
||||
this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' });
|
||||
}
|
||||
for (const [k, btn] of Object.entries(this._tabs)) {
|
||||
btn.setActive(k === key);
|
||||
}
|
||||
|
|
@ -162,13 +221,41 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
for (const obj of this._gameObjects) obj.destroy();
|
||||
// Interrupt any grid animation still in flight (user changed their mind mid-flight).
|
||||
if (this._gridAnim) {
|
||||
for (const obj of this._gridAnim.objects) obj.destroy();
|
||||
this._gridAnim = null;
|
||||
this._gameObjects = [];
|
||||
}
|
||||
if (this._ptrMoveHandler) {
|
||||
this.input.off('pointermove', this._ptrMoveHandler, this);
|
||||
this._ptrMoveHandler = null;
|
||||
}
|
||||
|
||||
const oldObjects = this._gameObjects;
|
||||
this._gameObjects = [];
|
||||
if (oldObjects.length === 0) {
|
||||
this.buildCategoryGrid(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
|
||||
for (const obj of oldObjects) obj.disableInteractive();
|
||||
this._gridAnim = { objects: oldObjects };
|
||||
this.tweens.add({
|
||||
targets: oldObjects,
|
||||
x: `-=${GRID_TRAVEL}`,
|
||||
duration: 380,
|
||||
ease: 'Power2.easeIn',
|
||||
onComplete: () => {
|
||||
this._gridAnim = null;
|
||||
for (const obj of oldObjects) obj.destroy();
|
||||
this.buildCategoryGrid(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
buildCategoryGrid(key) {
|
||||
const games = this._gamesByCategory[key];
|
||||
if (!games || games.length === 0) return;
|
||||
|
||||
|
|
@ -267,6 +354,18 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._gameObjects.push(qg, qLabel);
|
||||
}
|
||||
});
|
||||
|
||||
// Fly the grid in from off screen right.
|
||||
const objs = this._gameObjects;
|
||||
for (const obj of objs) obj.x += GRID_TRAVEL;
|
||||
this._gridAnim = { objects: objs };
|
||||
this.tweens.add({
|
||||
targets: objs,
|
||||
x: `-=${GRID_TRAVEL}`,
|
||||
duration: 420,
|
||||
ease: 'Power2.easeOut',
|
||||
onComplete: () => { this._gridAnim = null; },
|
||||
});
|
||||
}
|
||||
|
||||
openGame(game) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
|
||||
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' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
|
||||
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
|
||||
this._logo = logo;
|
||||
this._transitioning = false;
|
||||
|
||||
this.tweens.add({
|
||||
targets: logo,
|
||||
|
|
@ -53,6 +56,7 @@ export default class LandingScene extends Phaser.Scene {
|
|||
renderButtons() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const user = auth.user;
|
||||
this._avatar = null;
|
||||
|
||||
const avatarR = 28;
|
||||
const avatarGap = 18;
|
||||
|
|
@ -65,6 +69,8 @@ export default class LandingScene extends Phaser.Scene {
|
|||
fontSize: '36px',
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(1);
|
||||
this._welcomeText = welcomeText;
|
||||
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||
|
||||
const hasAvatar = !!user?.avatarPath;
|
||||
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
|
||||
|
|
@ -72,7 +78,8 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
|
||||
|
||||
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45);
|
||||
this._welcomeBg = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
|
||||
.setPosition(cx, y);
|
||||
|
||||
if (hasAvatar) {
|
||||
const avatarCx = groupLeft + avatarR;
|
||||
|
|
@ -86,11 +93,11 @@ export default class LandingScene extends Phaser.Scene {
|
|||
this.load.start();
|
||||
});
|
||||
}
|
||||
if (!this.scene.isActive('Landing')) return;
|
||||
if (this._transitioning || !this.scene.isActive('Landing')) return;
|
||||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff);
|
||||
maskG.fillCircle(avatarCx, y, avatarR);
|
||||
this.add.image(avatarCx, y, key)
|
||||
this._avatar = this.add.image(avatarCx, y, key)
|
||||
.setDisplaySize(avatarR * 2, avatarR * 2)
|
||||
.setMask(maskG.createGeometryMask())
|
||||
.setDepth(1);
|
||||
|
|
@ -98,7 +105,39 @@ export default class LandingScene extends Phaser.Scene {
|
|||
})();
|
||||
}
|
||||
|
||||
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
|
||||
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
|
||||
this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
}
|
||||
|
||||
goToGameMenu() {
|
||||
if (this._transitioning) return;
|
||||
this._transitioning = true;
|
||||
|
||||
this.playBtn.disableInteractive();
|
||||
this.profileBtn.disableInteractive();
|
||||
|
||||
// Fade the landing buttons out while the logo zooms past the camera.
|
||||
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
|
||||
if (this._avatar) fadeOut.push(this._avatar);
|
||||
this.tweens.add({
|
||||
targets: fadeOut,
|
||||
alpha: 0,
|
||||
duration: 350,
|
||||
ease: 'Power2.easeOut',
|
||||
});
|
||||
|
||||
if (!this._logo) { this.scene.start('GameMenu'); return; }
|
||||
this.tweens.killTweensOf(this._logo);
|
||||
this.tweens.add({
|
||||
targets: this._logo,
|
||||
x: GAME_WIDTH / 2,
|
||||
y: GAME_HEIGHT / 2,
|
||||
scaleX: 2.4,
|
||||
scaleY: 2.4,
|
||||
alpha: 0,
|
||||
duration: 700,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => this.scene.start('GameMenu'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
|
||||
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
|
||||
|
||||
|
|
@ -17,8 +18,8 @@ const CARD_TILE_GAP = 14;
|
|||
|
||||
// Opponent grid scroll area
|
||||
const OPP_SCROLL_W = 1780;
|
||||
const OPP_SCROLL_H = 440;
|
||||
const OPP_SCROLL_TOP = 155; // top edge of scroll area
|
||||
const OPP_SCROLL_H = 415;
|
||||
const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
|
||||
|
||||
export default class OpponentSelectScene extends Phaser.Scene {
|
||||
constructor() { super('OpponentSelect'); }
|
||||
|
|
@ -58,21 +59,32 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
|
||||
|
||||
const titleText = this.add.text(cx, 60, this.gameDef.name, {
|
||||
// Brass-plaque headers, same treatment as the game menu headings. The
|
||||
// subtitle plaque sits in the band between the title plaque and the
|
||||
// opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either.
|
||||
const TITLE_Y = 55;
|
||||
const PLATE_GAP = 10;
|
||||
const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
|
||||
this.children.moveBelow(titlePill, titleText);
|
||||
titleText.setLetterSpacing(2);
|
||||
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
|
||||
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
|
||||
this.children.moveBelow(titlePlate, titleText);
|
||||
|
||||
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
|
||||
const subtitleText = this.add.text(cx, 0, 'Choose your opponent', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '36px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
|
||||
this.children.moveBelow(subtitlePill, subtitleText);
|
||||
subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||
const subPlateH = subtitleText.height + 28;
|
||||
const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2;
|
||||
subtitleText.setY(subtitleY);
|
||||
const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY);
|
||||
this.children.moveBelow(subtitlePlate, subtitleText);
|
||||
|
||||
let opponents = [];
|
||||
try {
|
||||
|
|
@ -87,7 +99,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
const min = this.gameDef.minOpponents ?? 1;
|
||||
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
|
||||
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), {
|
||||
variant: 'ghost',
|
||||
width: 280,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const GAME_SOUNDTRACK_OVERRIDES = {
|
|||
totalannihilation: 'hacker',
|
||||
coloradodefense: 'arcadedark',
|
||||
tempest: 'arcadedark',
|
||||
defender: 'arcadedark',
|
||||
mastermind: 'hacker',
|
||||
balatro: 'hacker',
|
||||
hexsweeper: 'hacker',
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
|
|||
if (this._active) return;
|
||||
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
|
||||
if (v === 'ghost') {
|
||||
this._drawBg(bgh, 0.18);
|
||||
this.text.setColor(COLORS.goldHex);
|
||||
this._drawBg(bgh, 0.9);
|
||||
this.text.setColor(thc);
|
||||
} else {
|
||||
this._drawBg(bgh, 1);
|
||||
this.text.setColor(thc);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// Headless-chromium CDP driver for the jigsaw initial-image test page.
|
||||
// Loads the page repeatedly; each load must end PASS, and the initial image
|
||||
// index (document.__initIndex) must vary across fresh loads.
|
||||
// Usage: node tools/__jig_init_driver.mjs <url> [loads=8]
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
const url = process.argv[2];
|
||||
const loads = Math.max(2, parseInt(process.argv[3] || '8', 10));
|
||||
if (!url) { console.error('usage: driver <url> [loads]'); process.exit(2); }
|
||||
|
||||
const BIN = process.env.HOME + '/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell';
|
||||
const PORT = 9333;
|
||||
const chrome = spawn(BIN, [
|
||||
'--headless', '--no-sandbox', '--disable-gpu',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
'about:blank',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
chrome.stderr.on('data', (d) => { const s = d.toString(); if (!/Fontconfig|dbus|DBus|ozone/i.test(s)) process.stderr.write(s); });
|
||||
|
||||
async function httpJson(path) {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}${path}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
let ws, idc = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++idc;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
|
||||
try {
|
||||
let targets = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try { targets = await httpJson('/json/list'); break; } catch { await sleep(250); }
|
||||
}
|
||||
if (!targets) throw new Error('chrome devtools endpoint never came up');
|
||||
const page = targets.find((t) => t.type === 'page');
|
||||
if (!page) throw new Error('no page target');
|
||||
ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
|
||||
}
|
||||
};
|
||||
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
|
||||
const ev = (expression) => send('Runtime.evaluate', { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
|
||||
const indexes = [];
|
||||
for (let k = 0; k < loads; k++) {
|
||||
await send('Page.navigate', { url });
|
||||
const t0 = Date.now();
|
||||
let title = '';
|
||||
while (Date.now() - t0 < 60000) {
|
||||
title = (await ev('document.title')) || '';
|
||||
if (title === 'PASS' || title.startsWith('FAIL')) break;
|
||||
await sleep(500);
|
||||
}
|
||||
if (title !== 'PASS') throw new Error(`load #${k}: ${title || 'TIMEOUT'}`);
|
||||
indexes.push(await ev('document.__initIndex'));
|
||||
}
|
||||
|
||||
const valid = indexes.every((i) => Number.isInteger(i) && i >= 0 && i < 5);
|
||||
const distinct = new Set(indexes).size;
|
||||
console.log('initial indexes across fresh loads:', indexes.join(', '));
|
||||
console.log(`all valid: ${valid}, distinct images: ${distinct}/${loads}`);
|
||||
if (!valid || distinct < 2) { console.log('FAIL'); process.exitCode = 1; }
|
||||
else console.log('ALL PASS');
|
||||
} finally {
|
||||
try { ws && ws.close(); } catch {}
|
||||
chrome.kill('SIGKILL');
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
|
||||
//
|
||||
// Usage:
|
||||
// npx playwright install chromium # one-time
|
||||
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
|
||||
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
|
||||
// node tools/smokeJigsaw.mjs http://localhost:9000
|
||||
//
|
||||
// What it checks:
|
||||
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
|
||||
// - forced win (placed = total; onWin()) runs the full showcase:
|
||||
// * camera sweep lands on WIN_ZOOM framing the board
|
||||
// * stats counter animates up to the total
|
||||
// * celebration emitters fire (burst + curtain) and confetti is alive
|
||||
// * gold frame + shine sweep exist at the right depths
|
||||
// - "Play Again" restarts a fresh board and tears the win layer down
|
||||
// - "Menu" returns to the menu and destroys the win layer
|
||||
// - menu → Start Puzzle works again
|
||||
// - zero JS console/page errors across the whole flow
|
||||
//
|
||||
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
|
||||
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
|
||||
// waits here are condition-based polling (generous timeouts), never fixed
|
||||
// sleeps. A full run takes roughly 1–3 minutes of wall time.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
|
||||
|
||||
let server = null;
|
||||
if (!process.argv[2]) {
|
||||
try {
|
||||
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
|
||||
} catch {
|
||||
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('started local static server on :8000');
|
||||
}
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
let failed = false;
|
||||
const ok = (m) => console.log(' ok ' + m);
|
||||
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
||||
|
||||
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
|
||||
const waitFor = async (name, cond, timeoutMs) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (await evalS(cond)) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
bad(`timeout waiting for: ${name}`);
|
||||
return false;
|
||||
};
|
||||
const snap = async (name) => {
|
||||
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
ok(`screenshot ${dest}`);
|
||||
};
|
||||
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
|
||||
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
|
||||
|
||||
try {
|
||||
console.log('— load —');
|
||||
await page.goto(BASE, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
|
||||
ok('harness ready');
|
||||
|
||||
console.log('— start puzzle —');
|
||||
await evalS(() => window.__startPuzzle());
|
||||
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
|
||||
ok('state = playing');
|
||||
const pieces = await evalS(() => window.__pieces());
|
||||
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
|
||||
if (pieces.length === 0) bad('no pieces spawned');
|
||||
await snap('1_playing');
|
||||
|
||||
console.log('— force win —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
|
||||
|
||||
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
|
||||
if (await waitFor('camera settled + counter=total', () => {
|
||||
const s = window.__scene();
|
||||
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
|
||||
&& s.piecesBig && s.piecesBig.text === String(s.total);
|
||||
}, settleTimeout)) {
|
||||
const wi = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
|
||||
});
|
||||
ok('settled: ' + JSON.stringify(wi));
|
||||
await snap('2_win_panel');
|
||||
}
|
||||
|
||||
console.log('— wait for celebration (emitters created + particles alive) —');
|
||||
if (await waitFor('emitters alive', () => {
|
||||
const s = window.__scene();
|
||||
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
|
||||
}, settleTimeout)) {
|
||||
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
|
||||
ok('particles alive: ' + JSON.stringify(alive));
|
||||
await snap('3_confetti');
|
||||
const fx = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
|
||||
});
|
||||
ok('frame/shine: ' + JSON.stringify(fx));
|
||||
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
|
||||
}
|
||||
|
||||
console.log('— Play Again button —');
|
||||
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
|
||||
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
|
||||
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
|
||||
if (placed !== 0) bad('pieces not reset after Play Again');
|
||||
if (!winGone) bad('winLayer not destroyed after Play Again');
|
||||
await snap('4_restarted');
|
||||
}
|
||||
|
||||
console.log('— force win again, then Menu button —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
|
||||
ok('second win showcase running');
|
||||
await click(400, 720); // inside "Menu"
|
||||
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
|
||||
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
|
||||
if (!menuVisible) bad('menu not visible');
|
||||
if (!winGone) bad('winLayer not destroyed');
|
||||
await snap('5_menu');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('— Start Puzzle from menu works —');
|
||||
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
|
||||
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
|
||||
}
|
||||
|
||||
console.log('\n— JS errors (' + errors.length + ') —');
|
||||
errors.forEach((e) => console.log(' ' + e));
|
||||
if (errors.length) failed = true;
|
||||
|
||||
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
// verifyBookwork.js — engine tests for Bookwork
|
||||
// verifyBookwork.js — engine tests for Bookworm
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
|
||||
wordFromCells, computeDamage, computeSelfDamage,
|
||||
clearAndRefill, dropSpecialTile, countPoisonTiles,
|
||||
computeMaxHp, isPotionUnlocked,
|
||||
computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
|
||||
} from '../src/games/bookwork/BookworkLogic.js';
|
||||
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
|
||||
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
|
@ -92,6 +93,34 @@ const after = clearAndRefill(gridR, used);
|
|||
ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE));
|
||||
ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type));
|
||||
|
||||
// ── special tile schedule ────────────────────────────────────────────────────
|
||||
console.log('\nspecial tile schedule');
|
||||
const SCHED = {
|
||||
startLevel: 1, endLevel: 10,
|
||||
start: { gold: 0.10, diamond: 0.06 },
|
||||
end: { gold: 0.03, diamond: 0.02 },
|
||||
};
|
||||
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
|
||||
const at = (lv) => specialTileChances(lv, SCHED);
|
||||
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
|
||||
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
|
||||
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
|
||||
ok('clamps below startLevel', at(0).goldChance === 0.10);
|
||||
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
|
||||
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
|
||||
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
|
||||
|
||||
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
|
||||
const newCount = 3;
|
||||
ok('goldChance:1 → every fresh tile is gold',
|
||||
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
|
||||
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
|
||||
ok('diamondChance:1 → every fresh tile is diamond',
|
||||
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
|
||||
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
|
||||
ok('refillSteered honors goldChance:1',
|
||||
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
|
||||
|
||||
// ── dropSpecialTile ────────────────────────────────────────────────────────────
|
||||
console.log('\ndropSpecialTile');
|
||||
const gridS = makeGrid(() => 0.5);
|
||||
|
|
@ -175,6 +204,10 @@ if (bwData) {
|
|||
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
|
||||
ok('playerBaseHp present', bwData.playerBaseHp > 0);
|
||||
ok('milestones array present', Array.isArray(bwData.milestones));
|
||||
const st = bwData.specialTiles;
|
||||
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
|
||||
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
|
||||
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
#!/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);
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
// 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.');
|
||||
}
|
||||
|
|
@ -0,0 +1,354 @@
|
|||
// Headless verification for Jigsaw.
|
||||
// node tools/verifyJigsaw.js
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Grid model: seeded determinism, neighbour bounds/symmetry, tab/blank
|
||||
// complementarity on every internal edge.
|
||||
// 2. FIT guarantee: for every internal edge of every difficulty, the two
|
||||
// adjacent pieces trace the *same* shared curve — adjacent pieces physically
|
||||
// mesh when placed in their correct slots. This is the property the
|
||||
// table-join rule ("pieces may only join if they fit on the board") relies
|
||||
// on, so it is checked for ALL grids.
|
||||
// 3. Table assembly (the production resolveDrop): only grid-adjacent pieces
|
||||
// join, whole groups are absorbed, fixpoint chains, the rigid-drag
|
||||
// invariant, board lock (and its precedence over join), and win counting.
|
||||
|
||||
import {
|
||||
DIFFICULTIES, DIFFICULTY_ORDER,
|
||||
makeJigsaw, cellEdgeSpec, cellNeighbours, edgeFragment, resolveDrop,
|
||||
} from '../src/games/jigsaw/JigsawLogic.js';
|
||||
|
||||
let failures = 0;
|
||||
function check(ok, msg) {
|
||||
if (!ok) { failures++; console.error(` ✗ ${msg}`); }
|
||||
return ok;
|
||||
}
|
||||
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
||||
|
||||
// ── 1. Grid model ────────────────────────────────────────────────────────────
|
||||
console.log('Grid model:');
|
||||
{
|
||||
const a = makeJigsaw(6, 5, 1234), b = makeJigsaw(6, 5, 1234), c = makeJigsaw(6, 5, 4321);
|
||||
check(JSON.stringify(a.H) === JSON.stringify(b.H) && JSON.stringify(a.V) === JSON.stringify(b.V),
|
||||
'same seed must give the same knob layout');
|
||||
check(JSON.stringify(a.H) !== JSON.stringify(c.H) || JSON.stringify(a.V) !== JSON.stringify(c.V),
|
||||
'different seeds should give different layouts');
|
||||
|
||||
const jig = makeJigsaw(5, 5, 7);
|
||||
const inB = (r, cc) => r >= 0 && r < 5 && cc >= 0 && cc < 5;
|
||||
let allInRange = true, symmetric = true;
|
||||
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 5; cc++) {
|
||||
for (const [nr, nc] of cellNeighbours(jig, r, cc)) {
|
||||
if (!inB(nr, nc)) allInRange = false;
|
||||
if (!cellNeighbours(jig, nr, nc).some(([pr, pc]) => pr === r && pc === cc)) symmetric = false;
|
||||
}
|
||||
}
|
||||
check(allInRange, 'neighbours never leave the grid');
|
||||
check(symmetric, 'neighbourhood is symmetric');
|
||||
check(cellNeighbours(jig, 0, 0).length === 2, 'corner piece has 2 neighbours');
|
||||
check(cellNeighbours(jig, 0, 2).length === 3, 'edge piece has 3 neighbours');
|
||||
check(cellNeighbours(jig, 2, 2).length === 4, 'interior piece has 4 neighbours');
|
||||
|
||||
// Every internal edge is a tab on exactly one side, blank on the other, and
|
||||
// both sides agree on which way the shared curve bulges.
|
||||
let complement = true;
|
||||
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 4; cc++) {
|
||||
const aL = cellEdgeSpec(jig, r, cc).right, bL = cellEdgeSpec(jig, r, cc + 1).left;
|
||||
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
|
||||
}
|
||||
for (let r = 0; r < 4; r++) for (let cc = 0; cc < 5; cc++) {
|
||||
const aL = cellEdgeSpec(jig, r, cc).bottom, bL = cellEdgeSpec(jig, r + 1, cc).top;
|
||||
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
|
||||
}
|
||||
check(complement, 'every internal edge: tab/blank pair with a common curve side');
|
||||
console.log(' ok');
|
||||
}
|
||||
|
||||
// ── 2. Fit guarantee: adjacent pieces trace the identical shared curve ───────
|
||||
console.log('Fit guarantee (adjacent pieces mesh on the board):');
|
||||
{
|
||||
const sample = (p0, p1, edge, n = 128) => {
|
||||
const pts = [];
|
||||
let cur = { x: p0.x, y: p0.y };
|
||||
for (const c of edgeFragment(p0, p1, edge)) {
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n;
|
||||
let x, y;
|
||||
if (c.t === 'line') { x = cur.x + (c.x - cur.x) * t; y = cur.y + (c.y - cur.y) * t; }
|
||||
else {
|
||||
const m = 1 - t;
|
||||
x = m * m * m * cur.x + 3 * m * m * t * c.c1.x + 3 * m * t * t * c.c2.x + t * t * t * c.x;
|
||||
y = m * m * m * cur.y + 3 * m * m * t * c.c1.y + 3 * m * t * t * c.c2.y + t * t * t * c.y;
|
||||
}
|
||||
pts.push({ x, y });
|
||||
}
|
||||
cur = { x: c.x, y: c.y };
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
// Max distance from every sample of curve A to the closest sample of B (both
|
||||
// ways). Identical curves → near zero (sampling gap only).
|
||||
const maxGap = (A, B) => Math.max(
|
||||
...A.map((p) => Math.min(...B.map((q) => Math.hypot(p.x - q.x, p.y - q.y)))),
|
||||
...B.map((p) => Math.min(...A.map((q) => Math.hypot(p.x - q.x, p.y - q.y))))
|
||||
);
|
||||
|
||||
let edgesChecked = 0, ok = true, worst = 0;
|
||||
const W = 100, H = 100;
|
||||
for (const key of DIFFICULTY_ORDER) {
|
||||
const { cols, rows } = DIFFICULTIES[key];
|
||||
const jig = makeJigsaw(cols, rows, 42);
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols - 1; c++) {
|
||||
// Vertical internal edge between (r,c) [left] and (r,c+1) [right].
|
||||
const xA = c * W, yA = r * H;
|
||||
const p0A = { x: xA + W, y: yA }, p1A = { x: xA + W, y: yA + H };
|
||||
const p0B = { x: xA + W, y: yA + H }, p1B = { x: xA + W, y: yA };
|
||||
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).right);
|
||||
const B = sample(p0B, p1B, cellEdgeSpec(jig, r, c + 1).left);
|
||||
const gap = maxGap(A, B);
|
||||
edgesChecked++; worst = Math.max(worst, gap);
|
||||
if (gap > 2.5) ok = false;
|
||||
}
|
||||
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols; c++) {
|
||||
// Horizontal internal edge between (r,c) [top] and (r+1,c) [bottom].
|
||||
const xA = c * W, yA = r * H;
|
||||
const p0A = { x: xA + W, y: yA + H }, p1A = { x: xA, y: yA + H };
|
||||
const p0B = { x: xA, y: yA + H }, p1B = { x: xA + W, y: yA + H };
|
||||
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).bottom);
|
||||
const B = sample(p0B, p1B, cellEdgeSpec(jig, r + 1, c).top);
|
||||
const gap = maxGap(A, B);
|
||||
edgesChecked++; worst = Math.max(worst, gap);
|
||||
if (gap > 2.5) ok = false;
|
||||
}
|
||||
}
|
||||
check(ok, `all ${edgesChecked} internal edges on all difficulties mesh exactly (worst gap ${worst.toFixed(3)}px)`);
|
||||
console.log(` ok — ${edgesChecked} internal edges checked, worst deviation ${worst.toFixed(4)}px`);
|
||||
}
|
||||
|
||||
// ── 3. Table assembly (production resolveDrop) ───────────────────────────────
|
||||
console.log('Table assembly (piece joining / group lock):');
|
||||
{
|
||||
const SNAP = 0.42; // same SNAP_FRAC as the scene
|
||||
const boardOrigin = { x: 1000, y: 1000 };
|
||||
const cell = 100;
|
||||
|
||||
function makeBoard(cols = 5, rows = 5, seed = 7) {
|
||||
const jig = makeJigsaw(cols, rows, seed);
|
||||
const pieces = [];
|
||||
const grid = [];
|
||||
for (let r = 0; r < rows; r++) grid.push(new Array(cols));
|
||||
let i = 0;
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++, i++) {
|
||||
const p = {
|
||||
r, c,
|
||||
home: { x: boardOrigin.x + (c + 0.5) * cell, y: boardOrigin.y + (r + 0.5) * cell },
|
||||
// Scattered on the table, well clear of the board and of each other.
|
||||
pos: { x: 50 + i * 140, y: 5000 + (i % 6) * 120 },
|
||||
placed: false, group: null,
|
||||
};
|
||||
const g = { pieces: [p] };
|
||||
p.group = g;
|
||||
pieces.push(p);
|
||||
grid[r][c] = p;
|
||||
}
|
||||
return {
|
||||
jig, pieces, grid,
|
||||
groups: new Set(pieces.map((p) => p.group)),
|
||||
placed: 0, total: pieces.length,
|
||||
cellAt: (r, c) => (r >= 0 && r < rows && c >= 0 && c < cols) ? grid[r][c] : null,
|
||||
};
|
||||
}
|
||||
const at = (bd, r, c) => bd.grid[r][c];
|
||||
|
||||
// Mirror of JigsawGame.handleDrop: apply the decided positions, then do the
|
||||
// (sound/nudge-free) scene bookkeeping.
|
||||
function handleDrop(bd, group, anchor) {
|
||||
const res = resolveDrop(bd.jig, group, bd.cellAt, cell * SNAP);
|
||||
for (const pl of res.placements) pl.piece.pos = { x: pl.x, y: pl.y };
|
||||
if (res.outcome === 'locked') {
|
||||
const locked = group.pieces.filter((m) => !m.placed);
|
||||
locked.forEach((m) => { m.placed = true; });
|
||||
bd.groups.delete(group);
|
||||
bd.placed += locked.length;
|
||||
} else if (res.outcome === 'joined') {
|
||||
for (const g of res.absorbedGroups) {
|
||||
bd.groups.delete(g);
|
||||
for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); }
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Mirror of JigsawGame.onPointerMove: rigid group drag around the anchor.
|
||||
function dragGroup(bd, group, anchor, to) {
|
||||
for (const m of group.pieces) m.pos = { x: to.x + (m.home.x - anchor.home.x), y: to.y + (m.home.y - anchor.home.y) };
|
||||
}
|
||||
const invariantHolds = (group) => group.pieces.every((m) => group.pieces.every((a) =>
|
||||
approx(m.pos.x, a.pos.x + m.home.x - a.home.x) && approx(m.pos.y, a.pos.y + m.home.y - a.home.y)));
|
||||
|
||||
// 3a. resolveDrop is pure: no mutation before the scene applies the result.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1);
|
||||
A.pos = { x: 200, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 20, y: A.pos.y + (B.home.y - A.home.y) - 15 };
|
||||
const g0 = [...A.group.pieces];
|
||||
const res = resolveDrop(bd.jig, A.group, bd.cellAt, cell * SNAP);
|
||||
check(res.outcome === 'joined', '3a: adjacent pair within snap radius joins');
|
||||
check(res.placements.length === 1 && res.placements[0].piece === B, '3a: only the absorbed piece is placed');
|
||||
check(A.group.pieces.length === 1 && A.group.pieces[0] === A, '3a: group not mutated by resolveDrop');
|
||||
check(B.pos.x === 200 + (B.home.x - A.home.x) + 20, '3a: piece positions not mutated by resolveDrop');
|
||||
const m = res.placements[0];
|
||||
B.pos = { x: m.x, y: m.y }; // scene-side application
|
||||
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(B.pos.y, A.pos.y + (B.home.y - A.home.y)),
|
||||
'3a: absorbed piece snaps to the exact mesh offset');
|
||||
}
|
||||
|
||||
// 3b. Pieces that cannot both sit on the board never join — even when
|
||||
// dropped dead-on their (hypothetical) meshing offset.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), C = at(bd, 0, 2);
|
||||
A.pos = { x: 300, y: 500 };
|
||||
C.pos = { x: A.pos.x + 2 * cell, y: A.pos.y }; // exact 2-cell offset, zero error
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'rested', '3b: non-adjacent pieces never join (rests instead)');
|
||||
check(A.group.pieces.length === 1, '3b: group stays a singleton');
|
||||
}
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), D = at(bd, 1, 1);
|
||||
A.pos = { x: 300, y: 500 };
|
||||
D.pos = { x: A.pos.x + cell * 0.7, y: A.pos.y + cell * 0.7 }; // sitting on top, diagonally
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'rested', '3b: diagonal pieces never join even when overlapping');
|
||||
}
|
||||
|
||||
// 3c. A chain of correctly placed pieces latches on in ONE drop (fixpoint).
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1); // C neighbour of B only
|
||||
A.pos = { x: 200, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 10, y: A.pos.y + (B.home.y - A.home.y) };
|
||||
C.pos = { x: B.pos.x + (C.home.x - B.home.x) - 8, y: B.pos.y + (C.home.y - B.home.y) };
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'joined', '3c: chain joins in one drop');
|
||||
check(B.group === A.group && C.group === A.group && A.group.pieces.length === 3, '3c: all three in one group');
|
||||
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(C.pos.x, A.pos.x + (C.home.x - A.home.x)),
|
||||
'3c: every member snaps to the exact mesh offset');
|
||||
check(invariantHolds(A.group), '3c: group invariant holds after join');
|
||||
}
|
||||
|
||||
// 3d. A pre-joined group is absorbed WHOLE when a neighbour drops beside it.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1);
|
||||
// First: join B+C on the table.
|
||||
B.pos = { x: 400, y: 600 };
|
||||
C.pos = { x: B.pos.x + (C.home.x - B.home.x) + 5, y: B.pos.y + (C.home.y - B.home.y) };
|
||||
const r1 = handleDrop(bd, B.group, B);
|
||||
check(r1.outcome === 'joined' && B.group.pieces.length === 2, '3d: B+C joined first');
|
||||
// Then: drop A next to B → the whole B+C group comes along.
|
||||
const Bg = B.group;
|
||||
A.pos = { x: B.pos.x - (B.home.x - A.home.x) - 12, y: B.pos.y + 9 };
|
||||
const r2 = handleDrop(bd, A.group, A);
|
||||
check(r2.outcome === 'joined', '3d: A dropped beside the pair joins it');
|
||||
check(B.group === A.group && A.group.pieces.length === 3, '3d: the whole pre-joined group was absorbed');
|
||||
check(invariantHolds(A.group), '3d: group invariant holds after whole-group absorption');
|
||||
check(bd.groups.has(A.group) && !bd.groups.has(Bg), '3d: group registry stays consistent');
|
||||
}
|
||||
|
||||
// 3e. Groups drag rigidly: every member keeps its exact home offset.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 2, 2), B = at(bd, 2, 3), C = at(bd, 1, 2);
|
||||
A.pos = { x: 250, y: 520 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||
C.pos = { x: A.pos.x + (C.home.x - A.home.x), y: A.pos.y + (C.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A); // B and C both within snap → 3-piece group
|
||||
check(A.group.pieces.length === 3, '3e: three-piece group formed');
|
||||
dragGroup(bd, A.group, B, { x: 700, y: 900 }); // grab a non-first member
|
||||
check(approx(A.pos.x, 700 + (A.home.x - B.home.x)) && approx(C.pos.y, 900 + (C.home.y - B.home.y)),
|
||||
'3e: dragging any member moves the whole group rigidly');
|
||||
check(invariantHolds(A.group), '3e: invariant preserved by drag');
|
||||
}
|
||||
|
||||
// 3f. Board lock: any member aligned ⇒ the whole group lands on the board.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 2, 2), B = at(bd, 2, 3);
|
||||
A.pos = { x: 250, y: 520 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A);
|
||||
check(A.group.pieces.length === 2, '3f: pair formed');
|
||||
// Drag the pair (grabbing B, the "far" member) so B lands within snap of home.
|
||||
dragGroup(bd, A.group, B, { x: B.home.x - 14, y: B.home.y + 10 });
|
||||
const res = handleDrop(bd, A.group, B);
|
||||
check(res.outcome === 'locked', '3f: group locks when aligned with the board');
|
||||
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x) && approx(B.pos.y, B.home.y),
|
||||
'3f: every member lands exactly on its home slot');
|
||||
check(A.placed && B.placed && bd.placed === 2, '3f: both members count as placed');
|
||||
check(!bd.groups.has(A.group), '3f: locked group retired from the table');
|
||||
}
|
||||
|
||||
// 3g. Lock takes precedence over join.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 2, 2), B = at(bd, 2, 3), D = at(bd, 3, 2);
|
||||
A.pos = { x: 250, y: 520 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A);
|
||||
// D sits exactly where it would mesh under A (joinable)…
|
||||
D.pos = { x: A.pos.x + (D.home.x - A.home.x), y: A.pos.y + (D.home.y - A.home.y) };
|
||||
// …but the A+B pair is also aligned with the board.
|
||||
dragGroup(bd, A.group, A, { x: A.home.x + 8, y: A.home.y - 6 });
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'locked', '3g: board lock wins over a possible join');
|
||||
check(D.group.pieces.length === 1 && !D.placed, '3g: the joinable piece was NOT absorbed');
|
||||
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x), '3g: group landed on the board');
|
||||
}
|
||||
|
||||
// 3h. Outside the snap radius: nothing joins, positions untouched.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 1, 1), B = at(bd, 1, 2);
|
||||
A.pos = { x: 300, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 60, y: A.pos.y }; // 60 > 42 snap
|
||||
const Bdrop = { ...B.pos }; // where the drop LEFT it
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'rested', '3h: beyond snap radius the drop rests');
|
||||
check(approx(B.pos.x, Bdrop.x) && approx(B.pos.y, Bdrop.y), '3h: unjoined piece keeps its dropped position');
|
||||
}
|
||||
|
||||
// 3i. Placed pieces are ignored by joining.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 1, 1), B = at(bd, 1, 2), D = at(bd, 2, 1);
|
||||
D.pos = { x: D.home.x, y: D.home.y }; D.placed = true; // already on the board
|
||||
A.pos = { x: 300, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 4, y: A.pos.y + (B.home.y - A.home.y) };
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'joined' && A.group.pieces.length === 2, '3i: unplaced neighbour still joins');
|
||||
check(!A.group.pieces.includes(D) && D.placed, '3i: placed piece is never absorbed');
|
||||
}
|
||||
|
||||
// 3j. Win bookkeeping: locking the final pieces reaches the total.
|
||||
{
|
||||
const bd = makeBoard(2, 1, 11); // 2 pieces, one internal edge
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1);
|
||||
A.pos = { x: 200, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 6, y: A.pos.y + (B.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A);
|
||||
check(A.group.pieces.length === 2, '3j: pair formed');
|
||||
dragGroup(bd, A.group, A, { x: A.home.x, y: A.home.y });
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'locked' && bd.placed === bd.total, '3j: locking the pair completes the board');
|
||||
}
|
||||
|
||||
console.log(' ok');
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`\nFAILED: ${failures} check(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll Jigsaw checks passed.');
|
||||
Loading…
Reference in New Issue