Compare commits
No commits in common. "main" and "Tents" have entirely different histories.
|
|
@ -1,5 +0,0 @@
|
|||
# Agent notes
|
||||
|
||||
## Git
|
||||
- **The user makes their own commits.** Leave changes in the working tree; do not
|
||||
run `git commit` (or `git push`) unless explicitly asked.
|
||||
|
|
@ -88,7 +88,7 @@ menu categories:
|
|||
| **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi |
|
||||
| **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle |
|
||||
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
|
||||
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble |
|
||||
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble |
|
||||
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
|
||||
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,83 +0,0 @@
|
|||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
|
||||
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
|
||||
</head><body>
|
||||
<div id="log"></div>
|
||||
<script type="module">
|
||||
import * as Phaser from 'phaser';
|
||||
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
|
||||
|
||||
const log = (m) => { document.getElementById('log').textContent += m + '\n'; console.log('[t]', m); };
|
||||
let failures = 0;
|
||||
const check = (ok, msg) => { if (!ok) { failures++; log('FAIL: ' + msg); } else log('ok: ' + msg); };
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const ART = [
|
||||
{ name: 'Alien World', path: 'assets/images/shift/alien-world.png' },
|
||||
{ name: 'Aquaroom', path: 'assets/images/shift/aquaroom.png' },
|
||||
{ name: 'Aztec Warrior', path: 'assets/images/shift/aztec-warrior.png' },
|
||||
{ name: 'Cat On Tiger', path: 'assets/images/shift/cat-on-tiger.png' },
|
||||
{ name: 'Cockpit', path: 'assets/images/shift/cockpit.png' },
|
||||
];
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
width: 1920, height: 1080,
|
||||
parent: document.body,
|
||||
backgroundColor: '#000',
|
||||
scene: [ { key: 'boot', create() {
|
||||
this.cache.json.add('shift-artwork', { artwork: ART });
|
||||
this.cache.json.add('music', { tracks: [] });
|
||||
this.scene.start('jigsaw-game');
|
||||
} }, JigsawGame ],
|
||||
};
|
||||
const game = new Phaser.Game(config);
|
||||
const s = () => game.scene.getScene('jigsaw-game');
|
||||
|
||||
(async () => {
|
||||
for (let i = 0; i < 400 && !s(); i++) await wait(50); // boot → jigsaw start is async
|
||||
if (!s()) throw new Error('jigsaw scene never started');
|
||||
for (let i = 0; i < 400 && !s().menu; i++) await wait(50);
|
||||
check(!!s().menu, 'menu built');
|
||||
|
||||
// Menu is back to the original shape (random-start button reverted).
|
||||
check(s().randomStartButton === undefined, 'randomStartButton is gone (menu as before)');
|
||||
|
||||
// The initial image is random but always a valid artwork entry, and the
|
||||
// preview shown in the menu matches it.
|
||||
const n = s().artwork.length;
|
||||
const i = s().imageIndex;
|
||||
check(Number.isInteger(i) && i >= 0 && i < n, `initial imageIndex ${i} is a valid index (0..${n - 1})`);
|
||||
check(!!ART.find((a) => a.name === s().currentImage().name), 'initial currentImage() is a known artwork entry');
|
||||
|
||||
// Preview must be built for the initial image (async image load).
|
||||
for (let k = 0; k < 200 && !s().previewImg; k++) await wait(25);
|
||||
check(!!s().previewImg, 'initial preview image is displayed');
|
||||
check(s().thumbName && s().thumbName.text === s().currentImage().name, `thumb name matches initial image (${s().currentImage().name})`);
|
||||
|
||||
// Start Puzzle must still start the initial (random) image.
|
||||
s().selectedDiff = 'easy';
|
||||
s().startPuzzle();
|
||||
let playing = false;
|
||||
for (let k = 0; k < 400 && !playing; k++) { playing = s().state === 'playing'; await wait(25); }
|
||||
check(playing, 'Start Puzzle reaches playing state');
|
||||
check(s().pieces.length === 25, '25 pieces built');
|
||||
check(s().imageName === ART[i].name, `playing image matches the initial pick (${ART[i].name})`);
|
||||
|
||||
// The original 🎲 Random button still randomises the preview in the menu.
|
||||
s().toMenu();
|
||||
const before = s().imageIndex;
|
||||
const seen = new Set([before]);
|
||||
for (let k = 0; k < 10; k++) { s().randomImage(); seen.add(s().imageIndex); await wait(10); }
|
||||
check(seen.size >= 2, '🎲 Random button still changes the selected image');
|
||||
|
||||
document.__initIndex = i;
|
||||
log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
|
||||
document.title = failures === 0 ? 'PASS' : 'FAIL:' + failures;
|
||||
})().catch((e) => {
|
||||
log('ERROR: ' + ((e && e.stack) || e));
|
||||
document.title = 'FAIL:error';
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<!doctype html>
|
||||
<html><head><meta charset="utf-8">
|
||||
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
|
||||
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
|
||||
</head><body>
|
||||
<div id="log"></div>
|
||||
<script type="module">
|
||||
import * as Phaser from 'phaser';
|
||||
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
|
||||
|
||||
const log = (m) => { const el = document.getElementById('log'); el.textContent += m + '\n'; console.log('[harness]', m); };
|
||||
|
||||
const config = {
|
||||
type: Phaser.WEBGL,
|
||||
width: 1920, height: 1080,
|
||||
parent: document.body,
|
||||
backgroundColor: '#000',
|
||||
scene: [ { key:'boot', create(){
|
||||
// Provide the artwork cache so loadArtwork() has a real list.
|
||||
this.cache.json.add('shift-artwork', { artwork: [ { name:'Alien World', path:'assets/images/shift/alien-world.png' } ] });
|
||||
this.cache.json.add('music', { tracks: [] });
|
||||
this.scene.start('jigsaw-game');
|
||||
} }, JigsawGame ],
|
||||
};
|
||||
const game = new Phaser.Game(config);
|
||||
window.__game = game;
|
||||
|
||||
// Helpers exposed for the driver.
|
||||
window.__log = log;
|
||||
window.__scene = () => game.scene.getScene('jigsaw-game');
|
||||
window.__worldToScreen = (wx, wy) => {
|
||||
const cam = window.__scene().cameras.main;
|
||||
return { x: (wx - cam.scrollX) * cam.zoom + cam.x, y: (wy - cam.scrollY) * cam.zoom + cam.y };
|
||||
};
|
||||
window.__pieceAt = (i) => { const s = window.__scene(); const p = s.pieces[i]; return { i, x:p.img.x, y:p.img.y, placed:p.placed, groupLeader: p.group===p, depth:p.img.depth, hasInput: !!(p.img.input&&p.img.input.enabled) }; };
|
||||
window.__pieces = () => window.__scene().pieces.map((p,i)=>({i, x:Math.round(p.img.x), y:Math.round(p.img.y), placed:p.placed, depth:p.img.depth}));
|
||||
window.__startPuzzle = () => { const s = window.__scene(); s.selectedDiff='easy'; s.startPuzzle(); };
|
||||
window.__grabAndDrop = async (pieceIdx, toWorld, steps=12, holdMs=40) => {
|
||||
const s = window.__scene();
|
||||
const p = s.pieces[pieceIdx];
|
||||
const from = { x: p.img.x, y: p.img.y };
|
||||
// mousedown at from
|
||||
const f2s = window.__worldToScreen(from.x, from.y);
|
||||
await __mouseDown(f2s.x, f2s.y);
|
||||
await new Promise(r=>setTimeout(r,holdMs));
|
||||
for (let k=1;k<=steps;k++){
|
||||
const x = from.x + (toWorld.x-from.x)*k/steps, y = from.y + (toWorld.y-from.y)*k/steps;
|
||||
const c = window.__worldToScreen(x,y);
|
||||
await __mouseMove(c.x,c.y);
|
||||
await new Promise(r=>setTimeout(r,8));
|
||||
}
|
||||
await __mouseUp();
|
||||
return { from, to:{x:p.img.x,y:p.img.y}, moved: Math.hypot(p.img.x-from.x,p.img.y-from.y) };
|
||||
};
|
||||
// Low-level mouse dispatch in canvas (client) coordinates.
|
||||
window.__mouseDown = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousedown',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
|
||||
window.__mouseMove = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousemove',{clientX:cx,clientY:cy,bubbles:true})); };
|
||||
window.__mouseUp = (cx=0,cy=0) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mouseup',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
|
||||
|
||||
log('harness ready');
|
||||
</script>
|
||||
</body></html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 2.5 MiB |
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 341 KiB After Width: | Height: | Size: 339 KiB |
Binary file not shown.
|
|
@ -1,18 +1,5 @@
|
|||
{
|
||||
"playerBaseHp": 100,
|
||||
"steer": {
|
||||
"wordBoostK": 1.0,
|
||||
"classThreshold": 2,
|
||||
"classDamp": 0.35,
|
||||
"classBoost": 1.8,
|
||||
"vowelBand": [0.30, 0.45]
|
||||
},
|
||||
"specialTiles": {
|
||||
"startLevel": 1,
|
||||
"endLevel": 10,
|
||||
"start": { "gold": 0.10, "diamond": 0.06 },
|
||||
"end": { "gold": 0.03, "diamond": 0.02 }
|
||||
},
|
||||
"milestones": [
|
||||
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
|
||||
{ "afterLevel": 10, "maxHpBonus": 10 },
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"default": "back-0",
|
||||
"cardBacks": [
|
||||
{ "id": "back-0", "name": "Cthulhu", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-1", "name": "Cyberpunk", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-2", "name": "Nautical", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-3", "name": "Steampunk", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-4", "name": "Jungle", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-5", "name": "Hi Tech", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-6", "name": "Fall Mountain", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-7", "name": "Neon Miami", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
|
||||
{ "id": "back-0", "name": "Card Back 1", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-1", "name": "Card Back 2", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-2", "name": "Card Back 3", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-3", "name": "Card Back 4", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-4", "name": "Card Back 5", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-5", "name": "Card Back 6", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-6", "name": "Card Back 7", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-7", "name": "Card Back 8", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,343 +0,0 @@
|
|||
{
|
||||
"version": 1,
|
||||
"vars": ["species"],
|
||||
"confirmSkip": {
|
||||
"body": "Are you sure you want to skip the entire tutorial?",
|
||||
"confirmLabel": "Skip tutorial",
|
||||
"cancelLabel": "Keep learning"
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "intro",
|
||||
"kind": "modal",
|
||||
"voice": "vega/tutorial-intro-01",
|
||||
"body": "All alone. Your species, the {species}, has spent its entire existence on a single planet.... but no more. Recent advances have given you the ability to explore and colonize nearby stars. It is time for your species to begin your exploration of the universe. Soon, you will find you're not alone afterall. But for now, you have prepared a scout ship and a colony ship with a singular mission: Find a habitable planet nearby to begin your expansion.",
|
||||
"highlights": [],
|
||||
"buttons": [
|
||||
{ "action": "skip", "label": "Skip tutorial" },
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "your-fleet",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "homeFleet",
|
||||
"highlights": ["homeFleet", "homeStar"],
|
||||
"calloutText": "This is your fleet. A Scout Ship and Colony Ship. Click here to select them.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "selectHomeFleet",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "ship-profiles",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "fleetShipProfiles",
|
||||
"highlights": ["fleetShipProfiles"],
|
||||
"calloutText": "These are the ships in your fleet. Click on a ship's profile picture or video to view details of that ship.",
|
||||
"advanceOn": "shipDetailClosed",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "ship-counts",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "fleetCountControls",
|
||||
"highlights": ["fleetCountControls"],
|
||||
"calloutText": "You can remove or increase or decrease the number of ships in this fleet by clicking one of these buttons.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "nearby-stars",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "homeFleet",
|
||||
"highlights": ["homeFleet", "homeStar", "nearbyStars"],
|
||||
"calloutText": "Your fleet isn't limited to your home star — it can also travel to any of the other stars highlighted nearby.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "map-controls",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "homeFleet",
|
||||
"highlights": ["mapArea"],
|
||||
"freePan": true,
|
||||
"calloutText": "Before you send your fleet anywhere, here's how to get around the map. Click and drag anywhere to pan in any direction. Scroll your mouse wheel up to zoom in, or down to zoom out. Try it now, then click Next when you're ready.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "click-star",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "nearbyStars",
|
||||
"highlights": ["nearbyStars"],
|
||||
"starPick": "info",
|
||||
"advanceOn": "starPick",
|
||||
"fitReachableStars": true,
|
||||
"calloutText": "Now click on one of the highlighted stars to see what's there.",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "read-star-info",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "panel",
|
||||
"highlights": ["panel"],
|
||||
"calloutText": "This star hasn't been explored yet, so for now the panel only shows its class and a general description — different star classes support different amounts of population. Once a scout of yours actually reaches a system, this same panel will also reveal its worlds, how many of them are habitable for your species, and any colonies or fleets stationed there.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "move-fleet",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "panel",
|
||||
"highlights": ["nearbyStars", "panel"],
|
||||
"starPick": "order",
|
||||
"advanceOn": "fleetInFlight",
|
||||
"selectHomeFleet": true,
|
||||
"calloutText": "Let's send your fleet on its way. Click one of the highlighted stars again, then review the order in this panel and press Accept to launch your fleet.",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "fleet-moving",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "travelingFleet",
|
||||
"highlights": ["travelingFleet"],
|
||||
"calloutText": "Your fleet is now underway! The number above it shows how many turns remain until it arrives at its destination.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Got it!" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "home-star",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "homeStar",
|
||||
"highlights": ["homeStar"],
|
||||
"calloutText": "This is your home star. Click it to see your system's information.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "showHomeStar",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "home-star-info",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "panel",
|
||||
"highlights": ["panel", "homeStar"],
|
||||
"calloutText": "This panel shows your home system: how many worlds orbit it and how many are habitable, your colony's population, factories, output, and defences, and what it's currently building. Press View System at the bottom to see the whole system in detail.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "view-system",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "panel",
|
||||
"highlights": ["panel"],
|
||||
"calloutText": "Press View System to open a detailed view of your home system.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "openSystemView",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "system-view-planets",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"body": "This is your system view. Click on any of the worlds orbiting your star to inspect it — the panel on the right updates with its type, size, and richness.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "system-view-colony",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"advanceOn": "external",
|
||||
"body": "Click on your home world — the one flying a small colony flag above it — then press View Colony to open its colony screen.",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "colony-overview",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "colonyCenter",
|
||||
"body": "This is your colony screen — everything you need to run this world lives here. The panel on the right shows its population, factories, output, trade, and defences.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "colony-allocation",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "colonyCenter",
|
||||
"body": "Below that, the Allocation sliders split your production across five channels: Construction, Defence, Industry, Ecology, and Research. Drag a slider to change its share — the rest renormalise automatically. Click a channel's lock icon to hold its share fixed while you adjust the others. Ecology is always funded first, off the top.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "colony-focus",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "colonyCenter",
|
||||
"body": "Colony Focus automates your build queue toward a goal — Expansion, Colony Improvement, Research, Fleet Production, Population Growth, Trade, or Homeworld Defense — instead of you queuing every building by hand. Allocation Focus applies a one-time preset to the sliders above, tuned to the same kinds of strategies. Open either one and your advisors will point out their recommended pick with a pulsing arrow and a short reason.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "colony-close",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"advanceOn": "external",
|
||||
"body": "When you're ready, close this colony screen, then close the system view too, to get back to the star map.",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "end-turn",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "endTurnButton",
|
||||
"highlights": ["endTurnButton"],
|
||||
"calloutText": "You've given your fleet its orders and looked over your colony — press End Turn to advance to the next turn.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "endTurn",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "empire-menu",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "empireButton",
|
||||
"highlights": ["empireButton"],
|
||||
"calloutText": "Your turn is underway. Let's look at the rest of your empire — click the Empire menu to open it.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "openEmpireMenu",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "open-research",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "empireMenuResearch",
|
||||
"highlights": ["empireMenuResearch"],
|
||||
"calloutText": "Click Research to see what your empire is investigating.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "openResearch",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "research-info",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"advanceAction": "closeEmpireScreenReopenMenu",
|
||||
"body": "Each tile is a research field — its current level, and a slider (with the same lock icon your colony's allocation sliders have) for how much of your research output feeds it. Click a field to see what you're currently working toward there and what comes after it.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-diplomacy",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "empireMenuDiplomacy",
|
||||
"highlights": ["empireMenuDiplomacy"],
|
||||
"calloutText": "Click Diplomacy to see your relations with the species you've met.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "openDiplomacy",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "diplomacy-info",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"advanceAction": "closeEmpireScreenReopenMenu",
|
||||
"body": "You haven't met another species yet, so this window is empty for now. Once you do, it will list each one here — their portrait, treaty status and attitude toward you, how many colonies and how much power they hold, and a Seek Audience button to open negotiations.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-leaders",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "empireMenuLeaders",
|
||||
"highlights": ["empireMenuLeaders"],
|
||||
"calloutText": "Click Leaders to see who's available to hire.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "openLeaders",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "leaders-info",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"advanceAction": "closeEmpireScreenReopenMenu",
|
||||
"body": "Leaders available for hire are listed here, each with their specialty and a one-time cost. Hire one to post them to a fleet or colony for a standing bonus.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "open-colonies",
|
||||
"kind": "callout",
|
||||
"voice": null,
|
||||
"anchor": "empireMenuColonies",
|
||||
"highlights": ["empireMenuColonies"],
|
||||
"calloutText": "Click Colonies to see every world you own in one place.",
|
||||
"advanceOn": "hotspot",
|
||||
"hotspotAction": "openColonies",
|
||||
"buttons": []
|
||||
},
|
||||
{
|
||||
"id": "colonies-info",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"noDim": true,
|
||||
"windowPos": "topBanner",
|
||||
"advanceAction": "closeEmpireScreenAndMenu",
|
||||
"body": "This lists every colony you own — population, factories, what's building, and its Colony Focus and Allocation Focus — so you can check and adjust any of them from one screen instead of opening each individually.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Next" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "keep-exploring",
|
||||
"kind": "modal",
|
||||
"voice": null,
|
||||
"body": "That's the Empire menu. From here, keep exploring nearby star systems with your scouts — once you find a habitable planet, bring a colony ship and found a new colony there to keep growing your empire.",
|
||||
"buttons": [
|
||||
{ "action": "next", "label": "Understood" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1772,71 +1772,6 @@ Mirror-match-bias and species-spread assertions (verifier sections 5/11)
|
|||
both still passed at their existing tolerances — not re-measured as a
|
||||
standalone figure here.
|
||||
|
||||
## Guided tutorial (2026-08-28)
|
||||
|
||||
An in-game guided tutorial that opens on every brand-new game (never a
|
||||
resumed/loaded one) and is re-triggerable from the ☰ menu ("Replay tutorial",
|
||||
greyed once the starting fleet has moved/split). Phase 1 ships the framework
|
||||
plus four steps: a centred intro modal, a "select your fleet" callout, a
|
||||
"these are your ships" callout over the side panel's ship rows, and a
|
||||
"change ship counts" callout over the −/+/✕ cluster.
|
||||
|
||||
- **The script is data.** `data/mastervega-tutorial.json` — an ordered `steps[]`
|
||||
list, each with `kind` (`modal` | `callout`), `body`/`calloutText`, `voice`
|
||||
(path under `assets/speech/`, no `.mp3`, or `null`), `highlights[]` /
|
||||
`anchor` (string ids), `advanceOn` (`hotspot` = click the lit anchor;
|
||||
`shipDetailClosed` = the player opened and closed a ship detail window),
|
||||
and `buttons[]` (`{action, label}`, action ∈ next/back/skip/finish). A step
|
||||
with `buttons: []` is legal only when `advanceOn` is set. `{token}`
|
||||
placeholders are interpolated against a `vars` allow-list (only `species`
|
||||
so far → `rules.species[emp.speciesId].plural`). Adding later steps =
|
||||
editing this file; no code change unless a step needs a **new** highlight
|
||||
target or advance mode.
|
||||
- **Target ids** (`TUTORIAL_TARGET_IDS`): `homeStar` / `homeFleet` on the star
|
||||
map (accent ring), `fleetShipProfiles` / `fleetCountControls` on the side
|
||||
panel (yellow box). Panel regions resolve through a new
|
||||
`VegaSidePanel.tutorialRegion(name)` — screen-space union of per-stack rects
|
||||
recorded in `stackRow()` into `this._tutorRows` on every `rebuild()`, keyed
|
||||
off `this.x0/this.y0` (the panel's resting position, so it is right even
|
||||
while the panel is still sliding in).
|
||||
- **Two modules, split like `VegaGnn` / `VegaGnnScreen`.**
|
||||
`VegaTutorialData.js` is **Phaser-free** (schema validation, interpolation,
|
||||
`TUTORIAL_TARGET_IDS`) so `tools/verifyMasterOfVega.js` imports it (section
|
||||
12). `VegaTutorial.js` is the Phaser half (overlay, callout, hotspot,
|
||||
state machine). A highlightable thing needs an id in `TUTORIAL_TARGET_IDS`
|
||||
**and** a `_resolveTarget` case in `VegaTutorial.js`.
|
||||
- **Darken = four opaque strips framing one rectangular hole** (the union of
|
||||
the step's resolved highlight rects, padded), NOT a mask cutout — every
|
||||
target worth highlighting is rectangular. A pulsing accent ring is stroked
|
||||
around each target on top. Empty `highlights` → one full-screen dim rect.
|
||||
- **The map is frozen by `scene.modalOpen = true`** for the tutorial's whole
|
||||
life. That one flag blocks star-map pan/zoom (`blockPointer`/`blockWheel`)
|
||||
and every map/HUD handler — **but not the side panel's own controls**
|
||||
(`detailHit`, `tinyButton`, `tinyCircleButton`, `openShipDetail` — none
|
||||
check `modalOpen`), which is what lets the fleet-ship steps work: the hole
|
||||
over the panel exposes real, clickable panel widgets (`input.topOnly` is on,
|
||||
so the dark strips must genuinely not cover them — the four-strip hole does
|
||||
exactly that). `centerOn(emp.homeStar)` on start. `finish()` restores
|
||||
`modalOpen = false` + `refreshAll()`, mirroring `openModal`'s `done()`.
|
||||
- **`D.tutorial = 75`** — deliberately just *below* `D.detail` (76). The
|
||||
"these are your ships" step tells the player to click a ship profile, which
|
||||
opens the real `openShipDetail` window; sitting below `D.detail` lets it
|
||||
layer cleanly on top of the overlay. The step then advances when
|
||||
`panel.detailOpen` goes true-then-false (polled in `VegaTutorial.update()`).
|
||||
- The "click here to select them" hotspot is a transparent interactive rect
|
||||
over the fleet marker; its handler does the real low-level selection
|
||||
(`scene.selectedFleet = f; map.setSelectedFleet(f); panel.showFleet(f)`,
|
||||
bypassing `onFleetClick`'s `modalOpen` guard) then `advance()`.
|
||||
- The skip button opens a small confirm prompt drawn on the tutorial's own
|
||||
container (not `openModal`), so it never touches `modalOpen`. Skip is a
|
||||
per-step button in the JSON, present only on the intro step — once the
|
||||
player clicks Next it is gone. A step with an empty `buttons[]` is legal
|
||||
**only** when it has `advanceOn: "hotspot"` (the callout step advances by
|
||||
clicking the fleet, nothing else); `validateTutorialData` enforces that.
|
||||
- No `VegaLogic` change — runs every new game, so there is no "seen" flag to
|
||||
serialize. A malformed JSON file is validated in `create()` and disables the
|
||||
feature with a `console.warn` rather than crashing.
|
||||
|
||||
## Files touched to register the game
|
||||
|
||||
`src/data/gamesRegistry.js`, `src/main.js`, `src/scenes/GameRoomScene.js`
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
{
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,5 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
248303
phaser.esm.js
248303
phaser.esm.js
File diff suppressed because it is too large
Load Diff
|
|
@ -148,7 +148,6 @@ export const MANIFEST = {
|
|||
// only the JSON does.
|
||||
mastervega: [
|
||||
{ type: 'json', key: 'mastervega-rules', path: 'data/mastervega-rules.json' },
|
||||
{ type: 'json', key: 'mastervega-tutorial', path: 'data/mastervega-tutorial.json' },
|
||||
(scene) => sheetsFrom(scene, 'mastervega-artwork', ['sheets']),
|
||||
(scene) => videosFrom(scene, 'mastervega-artwork', 'portraitVideos'),
|
||||
(scene) => nestedVideosFrom(scene, 'mastervega-artwork', 'shipVideos'),
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame:
|
|||
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' });
|
||||
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
|
||||
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
|
||||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });
|
||||
|
|
@ -123,4 +123,3 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
|
|||
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
||||
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
|
||||
|
|
|
|||
|
|
@ -9,10 +9,9 @@ import {
|
|||
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
|
||||
wordFromCells, computeDamage, computeSelfDamage,
|
||||
clearAndRefill, dropSpecialTile, countPoisonTiles,
|
||||
computeMaxHp, isPotionUnlocked, specialTileChances,
|
||||
computeMaxHp, isPotionUnlocked,
|
||||
} from './BookworkLogic.js';
|
||||
import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
|
||||
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
|
||||
|
||||
const CELL = 96;
|
||||
const GRID_W = CELL * GRID_SIZE;
|
||||
|
|
@ -40,7 +39,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
constructor() { super('BookworkGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
|
||||
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' };
|
||||
this.config = { playerBaseHp: 100, milestones: [], levels: [] };
|
||||
this.bank = [];
|
||||
this.roster = [];
|
||||
|
|
@ -57,9 +56,6 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.bgTex = null;
|
||||
// Battle state
|
||||
this.grid = null;
|
||||
this.wordSet = null; // steering dictionary (ENABLE words 3–15); null → unsteered
|
||||
this.steerOpts = {};
|
||||
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
|
||||
this.tileObjs = null;
|
||||
this.selection = [];
|
||||
this.selGraphics = null;
|
||||
|
|
@ -89,16 +85,8 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
|
||||
const raw = this.cache.json.get('bookwork');
|
||||
if (raw) this.config = raw;
|
||||
this.steerOpts = this.config.steer ?? {};
|
||||
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
// Load the steering dictionary (same ENABLE list the word validator uses).
|
||||
// Non-fatal: if it fails, boards fall back to plain weighted-random.
|
||||
try {
|
||||
const res = await fetch('data/wordlists/enable1.txt');
|
||||
if (res.ok) this.wordSet = parseWordList(await res.text());
|
||||
} catch (_) { this.wordSet = null; }
|
||||
|
||||
try {
|
||||
const res = await fetch('data/opponents.json');
|
||||
const json = await res.json();
|
||||
|
|
@ -291,7 +279,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'BOOKWORM', {
|
||||
const title = this.add.text(cx, 84, 'BOOKWORK', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
|
||||
|
|
@ -526,7 +514,6 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.level = level;
|
||||
this.levelDef = lv;
|
||||
this.opponent = this.opponentFor(lv);
|
||||
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
|
||||
this.clearLayer();
|
||||
|
||||
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
|
||||
|
|
@ -536,7 +523,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
|
||||
this.potionUsed = false;
|
||||
this.turnPhase = 'player';
|
||||
this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
|
||||
this.grid = makeGrid();
|
||||
this.selection = [];
|
||||
|
||||
this.drawBattleUI();
|
||||
|
|
@ -845,9 +832,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Refill grid
|
||||
this.grid = this.wordSet
|
||||
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
|
||||
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
|
||||
this.grid = clearAndRefill(this.grid, cells);
|
||||
await this.animateRefill(cells);
|
||||
this.redrawAllTiles();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const GRID_SIZE = 5;
|
||||
|
||||
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
|
||||
export const LETTER_WEIGHTS = {
|
||||
const LETTER_WEIGHTS = {
|
||||
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
|
||||
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
|
||||
X:1, Y:3, Z:1,
|
||||
|
|
@ -16,29 +16,6 @@ export function randomLetter(rng = Math.random) {
|
|||
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
|
||||
}
|
||||
|
||||
// Default spawn chances for multiplier tiles on refills (late-game floor).
|
||||
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
|
||||
|
||||
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
|
||||
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
|
||||
// holds cfg.end. Returns { goldChance, diamondChance }.
|
||||
// cfg = { startLevel: 1, endLevel: 10,
|
||||
// start: { gold: 0.10, diamond: 0.06 },
|
||||
// end: { gold: 0.03, diamond: 0.02 } }
|
||||
export function specialTileChances(level, cfg = {}) {
|
||||
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
|
||||
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
|
||||
const from = cfg.startLevel ?? 1;
|
||||
const to = cfg.endLevel ?? from;
|
||||
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
|
||||
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
|
||||
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
|
||||
return {
|
||||
goldChance: start.gold + (end.gold - start.gold) * t,
|
||||
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeGrid(rng = Math.random) {
|
||||
const grid = [];
|
||||
for (let r = 0; r < GRID_SIZE; r++) {
|
||||
|
|
@ -92,12 +69,9 @@ export function computeSelfDamage(cells, grid) {
|
|||
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
|
||||
}
|
||||
|
||||
// Cascade tiles down in each column, fill top with new random tiles.
|
||||
// opts may override spawn chances: { goldChance, diamondChance }.
|
||||
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
|
||||
// Cascade tiles down in each column, fill top with new random tiles
|
||||
export function clearAndRefill(grid, usedCells, rng = Math.random) {
|
||||
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
|
||||
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
|
||||
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
|
||||
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
|
||||
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
|
|
@ -108,8 +82,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
|
|||
}
|
||||
// Fill remainder with new normal tiles
|
||||
while (survive.length < GRID_SIZE) {
|
||||
const gold = rng() < goldChance;
|
||||
const diamond = !gold && rng() < diamondChance;
|
||||
const gold = rng() < 0.03;
|
||||
const diamond = !gold && rng() < 0.02;
|
||||
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
|
||||
survive.push({ letter: randomLetter(rng), type });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,204 +0,0 @@
|
|||
// BookworkSteering.js — context-aware letter placement for Bookworm
|
||||
//
|
||||
// The base engine (BookworkLogic.js) draws every letter independently from a
|
||||
// weighted pool. That works, but it occasionally produces "dead" boards (no
|
||||
// 3+ letter word findable) and vowel/consonant clumps, and those clumps
|
||||
// persist through refills.
|
||||
//
|
||||
// This module layers a steering policy on the SAME pool: when a letter is
|
||||
// chosen for a specific cell, each candidate's weight is adjusted by:
|
||||
// 1. class steering — damp the class (vowel/consonant) already
|
||||
// over-represented among the cell's filled neighbours
|
||||
// 2. vowel band — keep the board-wide vowel share inside a target band
|
||||
// 3. word boost — boost letters that complete real 3-letter words given
|
||||
// the letters already placed around this cell
|
||||
//
|
||||
// Design notes:
|
||||
// • Sampling, never argmax — boards stay varied and the injected-rng
|
||||
// testability of BookworkLogic is preserved.
|
||||
// • No full words are pre-placed. Steering only biases a single letter by
|
||||
// what is already on the board, so the "word-friendly" property is
|
||||
// re-established inductively after every refill — the board stays good
|
||||
// as the game is played, not just at deal time.
|
||||
// • With `wordSet` null every helper degrades to the plain weighted pool.
|
||||
|
||||
import { GRID_SIZE, LETTER_WEIGHTS, randomLetter, SPECIAL_TILE_CHANCES, getAdjacent, isAdjacent } from './BookworkLogic.js';
|
||||
|
||||
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
|
||||
|
||||
export const DEFAULT_STEER = {
|
||||
wordBoostK: 1.0, // weight multiplier per word completed: w *= (1 + K * count)
|
||||
classThreshold: 2, // neighbour class imbalance (|vowels - consonants|) that triggers steering
|
||||
classDamp: 0.35, // multiplier applied to the over-represented class
|
||||
classBoost: 1.8, // multiplier applied to the under-represented class
|
||||
vowelBand: [0.30, 0.45], // target band for board-wide vowel share
|
||||
bandMinFilled: 10, // only apply the vowel band once this many cells are filled
|
||||
};
|
||||
|
||||
// Parse an ENABLE-style word list into a Set of A-Z words within [minLen, maxLen].
|
||||
// 3–15 matches the /words/scrabble/validate dictionary (ENABLE, 2–15 letters)
|
||||
// and the game's minimum playable word length.
|
||||
export function parseWordList(text, minLen = 3, maxLen = 15) {
|
||||
const set = new Set();
|
||||
for (const raw of text.split('\n')) {
|
||||
const w = raw.trim().toUpperCase();
|
||||
if (w.length >= minLen && w.length <= maxLen && /^[A-Z]+$/.test(w)) set.add(w);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// Context-adjusted weight for every candidate letter at grid[r][c].
|
||||
// `grid` may contain unfilled cells ({letter: null}) — they are ignored.
|
||||
export function letterWeights(grid, r, c, wordSet = null, opts = {}) {
|
||||
const o = { ...DEFAULT_STEER, ...opts };
|
||||
|
||||
// ── class steering: neighbour balance ───────────────────────────────
|
||||
let v = 0, k = 0;
|
||||
for (const nb of getAdjacent(r, c)) {
|
||||
const l = grid[nb.r][nb.c]?.letter;
|
||||
if (!l) continue;
|
||||
if (VOWELS.has(l)) v++; else k++;
|
||||
}
|
||||
let vowelMult = 1, consonantMult = 1;
|
||||
if (o.classSteer !== false) {
|
||||
if (v >= k + o.classThreshold) { vowelMult = o.classDamp; consonantMult = o.classBoost; }
|
||||
else if (k >= v + o.classThreshold) { consonantMult = o.classDamp; vowelMult = o.classBoost; }
|
||||
}
|
||||
|
||||
// ── vowel band: board-wide share ────────────────────────────────────
|
||||
if (o.vowelBand) {
|
||||
let filled = 0, vTotal = 0;
|
||||
for (let rr = 0; rr < GRID_SIZE; rr++) {
|
||||
for (let cc = 0; cc < GRID_SIZE; cc++) {
|
||||
if (rr === r && cc === c) continue;
|
||||
const l = grid[rr][cc]?.letter;
|
||||
if (!l) continue;
|
||||
filled++;
|
||||
if (VOWELS.has(l)) vTotal++;
|
||||
}
|
||||
}
|
||||
if (filled >= o.bandMinFilled) {
|
||||
const share = vTotal / filled;
|
||||
if (share > o.vowelBand[1]) { vowelMult *= o.classDamp; consonantMult *= o.classBoost; }
|
||||
else if (share < o.vowelBand[0]) { consonantMult *= o.classDamp; vowelMult *= o.classBoost; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── word boost: 3-letter words completed through this cell ─────────
|
||||
// For candidate L, count real words of the forms a-L-b (a,b filled
|
||||
// neighbours), L-a-b and a-b-L (a,b filled neighbours, b adjacent a).
|
||||
let counts = null;
|
||||
if (wordSet && o.wordBoostK > 0) {
|
||||
const N = [];
|
||||
for (const nb of getAdjacent(r, c)) {
|
||||
const l = grid[nb.r][nb.c]?.letter;
|
||||
if (l) N.push({ l, r: nb.r, c: nb.c });
|
||||
}
|
||||
if (N.length >= 2) {
|
||||
counts = new Array(26).fill(0);
|
||||
for (let li = 0; li < 26; li++) {
|
||||
const L = String.fromCharCode(65 + li);
|
||||
if (!LETTER_WEIGHTS[L]) continue;
|
||||
const seen = new Set();
|
||||
for (const a of N) {
|
||||
for (const b of N) {
|
||||
if (a === b) continue;
|
||||
const ab = isAdjacent(a, b);
|
||||
const w1 = a.l + L + b.l;
|
||||
if (wordSet.has(w1) && !seen.has(w1)) { seen.add(w1); counts[li]++; }
|
||||
if (ab) {
|
||||
const w2 = L + a.l + b.l;
|
||||
if (wordSet.has(w2) && !seen.has(w2)) { seen.add(w2); counts[li]++; }
|
||||
const w3 = a.l + b.l + L;
|
||||
if (wordSet.has(w3) && !seen.has(w3)) { seen.add(w3); counts[li]++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── final weights ───────────────────────────────────────────────────
|
||||
const weights = new Array(26).fill(0);
|
||||
for (let li = 0; li < 26; li++) {
|
||||
const L = String.fromCharCode(65 + li);
|
||||
const base = LETTER_WEIGHTS[L];
|
||||
if (!base) continue;
|
||||
let w = base * (VOWELS.has(L) ? vowelMult : consonantMult);
|
||||
if (counts) w *= 1 + o.wordBoostK * counts[li];
|
||||
weights[li] = w;
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
// Sample a letter from a 26-entry weight array (A..Z).
|
||||
export function pickWeighted(weights, rng) {
|
||||
let total = 0;
|
||||
for (const w of weights) total += w;
|
||||
if (total <= 0) return String.fromCharCode(65 + Math.floor(rng() * 26));
|
||||
let x = rng() * total;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
x -= weights[i];
|
||||
if (x <= 0) return String.fromCharCode(65 + i);
|
||||
}
|
||||
return 'Z';
|
||||
}
|
||||
|
||||
// Build a full 5×5 board with context steering (cells placed in random order,
|
||||
// each steered by the already-placed neighbours).
|
||||
export function makeSteeredGrid(rng = Math.random, wordSet = null, opts = {}) {
|
||||
const grid = Array.from({ length: GRID_SIZE }, () =>
|
||||
Array.from({ length: GRID_SIZE }, () => ({ letter: null, type: 'normal' })));
|
||||
if (!wordSet) {
|
||||
// Degrade to the plain weighted pool.
|
||||
for (const row of grid) for (const cell of row) cell.letter = randomLetter(rng);
|
||||
return grid;
|
||||
}
|
||||
const cells = [];
|
||||
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) cells.push({ r, c });
|
||||
for (let i = cells.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[cells[i], cells[j]] = [cells[j], cells[i]];
|
||||
}
|
||||
for (const { r, c } of cells) {
|
||||
grid[r][c].letter = pickWeighted(letterWeights(grid, r, c, wordSet, opts), rng);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// Same cascade semantics as BookworkLogic.clearAndRefill (survivors fall to
|
||||
// the bottom of each column, fresh tiles drop into the top rows), except the
|
||||
// fresh letters are chosen with context steering against the partially
|
||||
// rebuilt board. Fresh tiles land ABOVE their column's survivors, so this
|
||||
// also completes vertical word fragments the survivors left behind.
|
||||
export function refillSteered(grid, usedCells, rng = Math.random, wordSet = null, opts = {}) {
|
||||
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
|
||||
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
|
||||
|
||||
// Pass 1: cascade survivors down each column; mark fresh slots as empty.
|
||||
const newRows = new Array(GRID_SIZE).fill(0);
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
const survive = [];
|
||||
for (let r = GRID_SIZE - 1; r >= 0; r--) {
|
||||
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
|
||||
}
|
||||
newRows[c] = GRID_SIZE - survive.length;
|
||||
for (let i = 0; i < survive.length; i++) next[GRID_SIZE - 1 - i][c] = survive[i];
|
||||
for (let r = 0; r < newRows[c]; r++) next[r][c] = { letter: null, type: 'normal' };
|
||||
}
|
||||
|
||||
// Pass 2: place fresh tiles — per column bottom-up, left→right across
|
||||
// columns — so each new tile sees survivors + already-placed tiles.
|
||||
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
|
||||
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
for (let r = newRows[c] - 1; r >= 0; r--) {
|
||||
const weights = wordSet ? letterWeights(next, r, c, wordSet, opts) : null;
|
||||
const letter = weights ? pickWeighted(weights, rng) : randomLetter(rng);
|
||||
const gold = rng() < goldChance;
|
||||
const diamond = !gold && rng() < diamondChance;
|
||||
next[r][c] = { letter, type: gold ? 'gold' : diamond ? 'diamond' : 'normal' };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,292 +0,0 @@
|
|||
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
|
||||
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
|
||||
//
|
||||
// Model
|
||||
// -----
|
||||
// A cols×rows grid. Every internal edge between two cells carries exactly one
|
||||
// knob (a protruding "tab") that belongs to one of the two cells; the other
|
||||
// cell gets the matching "blank" (indent). Boundary edges are flat.
|
||||
//
|
||||
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
|
||||
// (r,c+1) [right]. 'L' → left cell owns the tab.
|
||||
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
|
||||
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
|
||||
//
|
||||
// Because both neighbours compute the SAME knob (same line segment, same side)
|
||||
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
|
||||
|
||||
export const DIFFICULTIES = {
|
||||
// Piece counts roughly double per tier. Square grids so the (square) source
|
||||
// image fills the board edge-to-edge with no letterboxing.
|
||||
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
|
||||
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
|
||||
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
|
||||
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
|
||||
};
|
||||
|
||||
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
|
||||
|
||||
// Knob shape as fractions of the edge length (see edgeFragment below).
|
||||
// neckFrac: how far in from each end the neck (narrow waist) sits.
|
||||
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
|
||||
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
|
||||
|
||||
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function rng() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const rand = Math.random;
|
||||
|
||||
// Build the knob assignment for every internal edge.
|
||||
export function makeJigsaw(cols, rows, seed = null) {
|
||||
const g = seed == null ? rand : mulberry32(seed);
|
||||
const H = [];
|
||||
const V = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
|
||||
H.push(row);
|
||||
}
|
||||
for (let r = 0; r < rows - 1; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
|
||||
V.push(row);
|
||||
}
|
||||
return { cols, rows, H, V };
|
||||
}
|
||||
|
||||
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
|
||||
// `normal` is the direction the knob bulges (null for flat edges). This is the
|
||||
// side the shared curve lies on, so it is identical for both adjacent cells.
|
||||
export function cellEdgeSpec(jig, r, c) {
|
||||
const { cols, rows, H, V } = jig;
|
||||
const out = {};
|
||||
|
||||
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
|
||||
if (r === 0) out.top = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = V[r - 1][c];
|
||||
const tab = owner === 'D'; // bottom cell owns the tab
|
||||
out.top = tab
|
||||
? { kind: 'tab', normal: { x: 0, y: -1 } }
|
||||
: { kind: 'blank', normal: { x: 0, y: 1 } };
|
||||
}
|
||||
|
||||
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
|
||||
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = H[r][c];
|
||||
const tab = owner === 'L'; // left cell owns the tab
|
||||
out.right = tab
|
||||
? { kind: 'tab', normal: { x: 1, y: 0 } }
|
||||
: { kind: 'blank', normal: { x: -1, y: 0 } };
|
||||
}
|
||||
|
||||
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
|
||||
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = V[r][c];
|
||||
const tab = owner === 'U'; // top cell owns the tab
|
||||
out.bottom = tab
|
||||
? { kind: 'tab', normal: { x: 0, y: 1 } }
|
||||
: { kind: 'blank', normal: { x: 0, y: -1 } };
|
||||
}
|
||||
|
||||
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
|
||||
if (c === 0) out.left = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = H[r][c - 1];
|
||||
const tab = owner === 'R'; // right cell owns the tab
|
||||
out.left = tab
|
||||
? { kind: 'tab', normal: { x: -1, y: 0 } }
|
||||
: { kind: 'blank', normal: { x: 1, y: 0 } };
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// The 4-neighbour cells of (r,c) inside the grid. By construction every such
|
||||
// pair shares an internal edge, and both pieces trace the *same* shared curve
|
||||
// for it — so these are exactly the pieces that mesh with (r,c) when placed in
|
||||
// their correct board slots. That is the legal set of pieces that may join
|
||||
// (r,c) anywhere on the table; no other pair can ever fit together.
|
||||
export function cellNeighbours(jig, r, c) {
|
||||
const { cols, rows } = jig;
|
||||
const out = [];
|
||||
if (c > 0) out.push([r, c - 1]);
|
||||
if (c < cols - 1) out.push([r, c + 1]);
|
||||
if (r > 0) out.push([r - 1, c]);
|
||||
if (r < rows - 1) out.push([r + 1, c]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Table assembly: joining pieces & locking groups (pure, Phaser-free) ─────
|
||||
// Model the scene feeds in (plain data only — the math never touches Phaser):
|
||||
// piece: { r, c, home:{x,y}, pos:{x,y}, placed, group }
|
||||
// group: { pieces: [...] } (piece.group points back)
|
||||
// cellAt(r, c) -> piece|null (the grid lookup)
|
||||
//
|
||||
// Group invariant: every member of a group sits at `anchor.pos + (member.home -
|
||||
// anchor.home)` for any member `anchor` — i.e. the exact board-relative offset
|
||||
// — so members always mesh while the group moves. resolveDrop preserves it.
|
||||
//
|
||||
// resolveDrop decides what happens when `group` (all members unplaced) is
|
||||
// released on the table, using the same snap radius for both outcomes:
|
||||
// 1. BOARD LOCK — if any member is within `snapR` of its home slot, the
|
||||
// whole group locks onto the board. Only grid-adjacent pieces can share a
|
||||
// group, and grid-adjacent pieces mesh exactly on the board, so the group
|
||||
// always lands as a correctly assembled block (every member is then
|
||||
// aligned too, by the invariant).
|
||||
// 2. JOIN — otherwise, any unplaced grid-neighbour of any member sitting
|
||||
// within `snapR` of its correct relative position is absorbed together
|
||||
// with its WHOLE group; repeated to a fixpoint so a chain of correctly
|
||||
// placed pieces latches on in a single drop. Non-adjacent pieces can
|
||||
// never join, no matter where they sit — they wouldn't mesh on the board.
|
||||
// 3. REST — otherwise the group just rests where it was dropped.
|
||||
//
|
||||
// Pure: no mutation. Returns { outcome, placements, absorbedGroups } where
|
||||
// placements are the target positions the caller must apply and absorbedGroups
|
||||
// are the (other) groups that merged into `group`.
|
||||
export function resolveDrop(jig, group, cellAt, snapR) {
|
||||
// 1) Board lock takes precedence: any member aligned ⇒ the group is placed.
|
||||
for (const m of group.pieces) {
|
||||
if (Math.hypot(m.pos.x - m.home.x, m.pos.y - m.home.y) < snapR) {
|
||||
return {
|
||||
outcome: 'locked',
|
||||
placements: group.pieces.map((m) => ({ piece: m, x: m.home.x, y: m.home.y })),
|
||||
absorbedGroups: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Join: absorb unplaced grid-neighbours at their correct relative spot.
|
||||
// `frame` is the group's consistent position frame: the dropped group is
|
||||
// already home-exact, and every absorbed piece is snapped INTO the frame,
|
||||
// so a chain that latches on ends up fully consistent (invariant holds).
|
||||
const frame = new Map();
|
||||
for (const m of group.pieces) frame.set(m, m.pos);
|
||||
const members = [...group.pieces]; // working set — `group` is not mutated
|
||||
const inGroup = new Set(members);
|
||||
const placements = [];
|
||||
const absorbedGroups = new Set();
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const m of [...members]) {
|
||||
for (const [nr, nc] of cellNeighbours(jig, m.r, m.c)) {
|
||||
const q = cellAt(nr, nc);
|
||||
if (!q || q.placed || inGroup.has(q)) continue;
|
||||
// Where q belongs in the assembled group relative to m's frame position.
|
||||
const fm = frame.get(m);
|
||||
const ex = fm.x + (q.home.x - m.home.x);
|
||||
const ey = fm.y + (q.home.y - m.home.y);
|
||||
if (Math.hypot(q.pos.x - ex, q.pos.y - ey) >= snapR) continue;
|
||||
absorbedGroups.add(q.group);
|
||||
for (const x of q.group.pieces) {
|
||||
if (inGroup.has(x)) continue;
|
||||
// Snap x into the frame (offsets from q are exact).
|
||||
const px = ex + (x.home.x - q.home.x);
|
||||
const py = ey + (x.home.y - q.home.y);
|
||||
frame.set(x, { x: px, y: py });
|
||||
placements.push({ piece: x, x: px, y: py });
|
||||
members.push(x);
|
||||
inGroup.add(x);
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!placements.length) return { outcome: 'rested', placements: [], absorbedGroups: [] };
|
||||
return { outcome: 'joined', placements, absorbedGroups: [...absorbedGroups].filter((g) => g !== group) };
|
||||
}
|
||||
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
|
||||
// Path commands for one edge, assuming the current point is `p0`.
|
||||
// Flat → a single line. Knob → line to the near neck, one cubic through the
|
||||
// bulb to the far neck, line to `p1`. The curve is direction-independent:
|
||||
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
|
||||
// which is what makes neighbouring pieces mesh.
|
||||
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
|
||||
if (!edge || edge.kind === 'flat') {
|
||||
return [{ t: 'line', x: p1.x, y: p1.y }];
|
||||
}
|
||||
const { neckFrac, ctrlFrac } = knob;
|
||||
const nA = lerp(p0, p1, neckFrac);
|
||||
const nB = lerp(p0, p1, 1 - neckFrac);
|
||||
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
||||
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
|
||||
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
|
||||
return [
|
||||
{ t: 'line', x: nA.x, y: nA.y },
|
||||
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
|
||||
{ t: 'line', x: p1.x, y: p1.y },
|
||||
];
|
||||
}
|
||||
|
||||
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
|
||||
// units; `ox`,`oy` the cell's top-left in local units. Returns
|
||||
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
|
||||
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
|
||||
const x0 = ox + c * W;
|
||||
const y0 = oy + r * H;
|
||||
const TL = { x: x0, y: y0 };
|
||||
const TR = { x: x0 + W, y: y0 };
|
||||
const BR = { x: x0 + W, y: y0 + H };
|
||||
const BL = { x: x0, y: y0 + H };
|
||||
const spec = cellEdgeSpec(jig, r, c);
|
||||
|
||||
const cmds = [
|
||||
...edgeFragment(TL, TR, spec.top, knob),
|
||||
...edgeFragment(TR, BR, spec.right, knob),
|
||||
...edgeFragment(BR, BL, spec.bottom, knob),
|
||||
...edgeFragment(BL, TL, spec.left, knob),
|
||||
];
|
||||
return { start: TL, cmds };
|
||||
}
|
||||
|
||||
// Apply path commands to a 2D canvas context (builds the current path).
|
||||
export function tracePath(ctx, outline) {
|
||||
const { start, cmds } = outline;
|
||||
ctx.moveTo(start.x, start.y);
|
||||
for (const c of cmds) {
|
||||
if (c.t === 'line') ctx.lineTo(c.x, c.y);
|
||||
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// ── Scramble: assign each piece a start position in the "tray" region ─────────
|
||||
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
|
||||
// pieces are scattered. Returns an array aligned to the piece index
|
||||
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
|
||||
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
|
||||
const g = seed == null ? rand : mulberry32(seed);
|
||||
const { cols, rows } = jig;
|
||||
const N = cols * rows;
|
||||
const placed = [];
|
||||
const margin = Math.max(tray.w, tray.h) * 0.06;
|
||||
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
|
||||
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
|
||||
for (let i = 0; i < N; i++) {
|
||||
// Rejection-sample a few tries so pieces don't pile in a single spot.
|
||||
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
|
||||
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
|
||||
let tries = 0;
|
||||
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
|
||||
px = x0 + (x1 - x0) * g();
|
||||
py = y0 + (y1 - y0) * g();
|
||||
tries++;
|
||||
}
|
||||
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
|
@ -81,17 +81,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) new MusicPlayer(this, tracks, volume);
|
||||
} catch { /* optional */ }
|
||||
// Backdrop: the playfield chosen on the opponent-select screen (image
|
||||
// texture, or the generated canvas texture for the "colored" playfields),
|
||||
// falling back to its flat color and finally to the classic deep green.
|
||||
const pf = this.playfield;
|
||||
if (pf?.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
} else {
|
||||
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : FELT;
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color).setDepth(DEPTH.bg);
|
||||
}
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg);
|
||||
|
||||
const names = [auth.user?.username ?? 'You'];
|
||||
const skills = { 0: 5 };
|
||||
|
|
@ -140,16 +130,11 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildPortraits() {
|
||||
// Each portrait sits just to the left of its own tiles, at the vertical
|
||||
// centre of that area (rows/columns are all centred on their midpoints),
|
||||
// so it reads as belonging to that player without ever covering a tile.
|
||||
// Keep ~18-20px clearance: human hand left edge ≥ 391 (14 tiles), left
|
||||
// column left edge 133, right column left edge 1836, top row ≥ 616.
|
||||
const spots = [
|
||||
{ x: 325, y: 975, r: 46 }, // human — left of the bottom hand row
|
||||
{ x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column
|
||||
{ x: 558, y: 70, r: 40 }, // seat 2 — left of the top row
|
||||
{ x: 75, y: 580, r: 40 }, // seat 3 — left of the left column
|
||||
{ x: 90, y: 870, r: 46 }, // human — bottom left
|
||||
{ x: 1830, y: 170, r: 40 }, // seat 1 — right
|
||||
{ x: 560, y: 95, r: 40 }, // seat 2 — top
|
||||
{ x: 90, y: 310, r: 40 }, // seat 3 — left
|
||||
];
|
||||
for (let seat = 0; seat < 4; seat++) {
|
||||
const { x, y, r } = spots[seat];
|
||||
|
|
@ -234,10 +219,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5));
|
||||
this.refPanel = panel;
|
||||
|
||||
// Parked just left of the reference panel's open position (1490) so it
|
||||
// never ends up under it; y=210 clears seat 2's melds (y≤98) and bonus
|
||||
// tiles (y≤157), which sit at x 1330+.
|
||||
this.refBtn = new Button(this, 1399, 210, 'Hands', () => this.toggleReference(), {
|
||||
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), {
|
||||
width: 150, height: 52, fontSize: 22, variant: 'ghost',
|
||||
}).setDepth(DEPTH.ref + 1);
|
||||
}
|
||||
|
|
@ -251,15 +233,6 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
ease: 'Quad.Out',
|
||||
});
|
||||
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
|
||||
// Seat 1's portrait sits under the open panel. Its <video> is a DOM node
|
||||
// that always renders above canvas content, so hide it while covered.
|
||||
this.portraitCtrls[1]?.controller?.setVideoVisible?.(!this.refOpen);
|
||||
}
|
||||
|
||||
// The claim row and the self win/kong buttons sit under the open panel's
|
||||
// footprint — close it whenever they appear so nothing clickable is covered.
|
||||
closeReferenceIfOpen() {
|
||||
if (this.refOpen) this.toggleReference();
|
||||
}
|
||||
|
||||
// ── tile drawing ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -409,7 +382,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
bx += 40;
|
||||
}
|
||||
} else { // sides — vertical column of rotated backs
|
||||
const x = seat === 1 ? 1858 : 155; // columns stay right of their portraits (seat 1: 1778+40, seat 3: 75+40)
|
||||
const x = seat === 1 ? 1858 : 62;
|
||||
let y = 580 - ((n - 1) * step) / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const back = this.makeTileBack(SM_W, SM_H);
|
||||
|
|
@ -417,7 +390,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
this.dyn.add(back);
|
||||
y += step;
|
||||
}
|
||||
const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column
|
||||
const mx = seat === 1 ? 1745 : 175;
|
||||
let my = 330;
|
||||
for (const m of p.melds) {
|
||||
const row = this.makeMeldRow(m, 38, 52, false);
|
||||
|
|
@ -508,7 +481,6 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
btn.setVisible(!!spec);
|
||||
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
|
||||
});
|
||||
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
|
||||
}
|
||||
|
||||
// ── human input ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -549,7 +521,6 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
return new Promise((resolve) => {
|
||||
this.claimResolve = resolve;
|
||||
this.chowOptions = opts.chows;
|
||||
this.closeReferenceIfOpen();
|
||||
const visible = [];
|
||||
if (opts.win) visible.push(this.claimBtns.win);
|
||||
if (opts.kong) visible.push(this.claimBtns.kong);
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ import { openCouncilSessionScreen } from './VegaCouncilSession.js';
|
|||
import { openAudienceScreen } from './VegaAudience.js';
|
||||
import { playIntroVideo } from './VegaIntroVideo.js';
|
||||
import { claimAudienceContacts, claimFleetComplaints, canNegotiate } from './VegaDiplomacy.js';
|
||||
import { VegaTutorial } from './VegaTutorial.js';
|
||||
import { validateTutorialData } from './VegaTutorialData.js';
|
||||
|
||||
const SAVE_KEY = 'mastervega-save';
|
||||
// 10 manual slots, independent of the single SAVE_KEY auto-save above (which
|
||||
|
|
@ -80,12 +78,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
// Arcade already relies on instead of a bespoke in-place teardown.
|
||||
this.pendingSavedState = data?.savedState ?? null;
|
||||
this.modalOpen = false;
|
||||
// Set true by VegaTutorial for the one step that teaches pan/zoom — lets
|
||||
// the star map's own drag/wheel handlers through despite modalOpen, while
|
||||
// onStarClick/onFleetClick (and every other modalOpen-gated control)
|
||||
// still check modalOpen directly and stay frozen. See blockWheel/
|
||||
// blockPointer below.
|
||||
this.tutorialFreePan = false;
|
||||
this.busy = false;
|
||||
// Empire indices with a freshly-claimed contact waiting for their
|
||||
// full-screen Audience — see runAudienceQueue().
|
||||
|
|
@ -103,17 +95,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
console.info(`[MasterOfVega] procedural art for: ${procedural.join(', ')}`);
|
||||
}
|
||||
|
||||
// Guided-tutorial script (data/mastervega-tutorial.json). A malformed file
|
||||
// must never take the game down with it — log and disable the feature.
|
||||
this.tutorialData = this.cache.json.get('mastervega-tutorial') ?? null;
|
||||
if (this.tutorialData) {
|
||||
const { ok, errors } = validateTutorialData(this.tutorialData);
|
||||
if (!ok) {
|
||||
console.warn('[MasterOfVega] tutorial disabled — invalid mastervega-tutorial.json:', errors);
|
||||
this.tutorialData = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.music = new VegaMusic(this, this.cache.json.get('masterofvega-music'));
|
||||
} catch (err) { /* music is optional */ }
|
||||
|
|
@ -148,8 +129,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
teardown() {
|
||||
this.tutorial?.destroy();
|
||||
this.tutorial = null;
|
||||
resetSpeechQueue();
|
||||
this.panel?.destroy();
|
||||
this.map?.destroy();
|
||||
|
|
@ -658,17 +637,15 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
onStarClick: (idx) => this.onStarClick(idx),
|
||||
onFleetClick: (fleet) => this.onFleetClick(fleet),
|
||||
onEmptyClick: () => this.clearSelection(),
|
||||
blockWheel: () => (this.modalOpen && !this.tutorialFreePan) || !!this.panel?.detailOpen,
|
||||
blockWheel: () => this.modalOpen || !!this.panel?.detailOpen,
|
||||
// A modal blocks the map outright; so does the panel's ship detail
|
||||
// pop-over, which veils the whole screen without being a modal.
|
||||
// Otherwise only the side panel's own footprint does (so dragging a
|
||||
// slider there doesn't pan the galaxy underneath it). The old
|
||||
// `!this.modalOpen && ...` form always short-circuited to false while a
|
||||
// modal was open, which let drags pan the star map right through the
|
||||
// System View window. `tutorialFreePan` punches a narrow exception
|
||||
// through modalOpen for pan/zoom only — onStarClick/onFleetClick are
|
||||
// untouched, so clicking a star or fleet is still fully frozen.
|
||||
blockPointer: (p) => (this.modalOpen && !this.tutorialFreePan) || !!this.panel?.detailOpen
|
||||
// System View window.
|
||||
blockPointer: (p) => this.modalOpen || !!this.panel?.detailOpen
|
||||
|| !!this.panel?.containsPoint(p.x, p.y),
|
||||
});
|
||||
|
||||
|
|
@ -686,38 +663,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
|
||||
this.buildHud();
|
||||
this.refreshHud();
|
||||
|
||||
// A brand-new game (never a resumed/loaded one) opens with the guided
|
||||
// tutorial. Runs every new game — there is no "seen" flag — and is also
|
||||
// re-triggerable from the ☰ menu.
|
||||
if (!savedState) this.startTutorial({ replay: false });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- tutorial
|
||||
|
||||
startTutorial({ replay = false } = {}) {
|
||||
if (this.tutorial || !this.tutorialData || !this.map || !this.panel) return;
|
||||
if (replay && !this.canReplayTutorial()) return;
|
||||
const emp = this.state.empires[this.state.humanIndex];
|
||||
const species = this.rules.species[emp?.speciesId]?.plural
|
||||
?? this.rules.species[emp?.speciesId]?.name ?? 'your people';
|
||||
this.tutorial = new VegaTutorial(this, {
|
||||
data: this.tutorialData,
|
||||
vars: { species },
|
||||
onFinish: () => { this.tutorial = null; },
|
||||
});
|
||||
this.tutorial.start();
|
||||
}
|
||||
|
||||
/** The tutorial's fleet callout needs the untouched starting fleet in orbit
|
||||
* at the homeworld — once it has moved or split there is nothing to point
|
||||
* at, so the ☰ replay entry greys out. */
|
||||
canReplayTutorial() {
|
||||
if (!this.tutorialData || !this.map || !this.panel || !this.state) return false;
|
||||
const emp = this.state.empires[this.state.humanIndex];
|
||||
if (!emp) return false;
|
||||
return this.state.fleets.some((f) => f.empireIdx === this.state.humanIndex
|
||||
&& f.starIdx === emp.homeStar && f.toStar < 0);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ HUD
|
||||
|
|
@ -823,7 +768,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
['Return to Main Menu', () => this.returnToMainMenu()],
|
||||
['Save', () => this.openSaveMenu()],
|
||||
['Load', () => this.openLoadMenu(), !this.hasAnySaveSlot()],
|
||||
['Replay tutorial', () => this.startTutorial({ replay: true }), !this.canReplayTutorial()],
|
||||
['Quit to Arcade', () => this.quitToArcade()],
|
||||
];
|
||||
|
||||
|
|
@ -1569,7 +1513,6 @@ export default class MasterOfVegaGame extends Phaser.Scene {
|
|||
|
||||
update(time, delta) {
|
||||
this.map?.update(time, delta);
|
||||
this.tutorial?.update?.(time, delta);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ save/load
|
||||
|
|
|
|||
|
|
@ -570,7 +570,6 @@ export const speciesSpeechClip = (speciesId) => `vega/char-${speciesId}`;
|
|||
/** Non-species speech clips, addressed the same way. */
|
||||
export const UI_SPEECH = {
|
||||
chooseSpecies: 'vega/ui-choose-start',
|
||||
tutorialIntro: 'vega/tutorial-intro-01',
|
||||
};
|
||||
|
||||
export function hasSpeciesVideo(scene, speciesId) {
|
||||
|
|
|
|||
|
|
@ -30,13 +30,9 @@ export const FONT = '"Julius Sans One"';
|
|||
// actually matters in practice; it sits next to gnn as the other full-screen
|
||||
// takeover. `intro` is the colony-founding vignette, which opens over the
|
||||
// system view it was triggered from and must cover everything except the
|
||||
// end-of-game overlay. `tutorial` is the guided-tutorial darken overlay — it
|
||||
// sits above the HUD, side panel and modals, but DELIBERATELY just below
|
||||
// `detail` so a ship-detail pop-over the tutorial itself invites the player
|
||||
// to open layers cleanly on top of it; it only runs on a fresh turn-0 game,
|
||||
// so nothing from `detail` up ever competes with it in practice.
|
||||
// end-of-game overlay.
|
||||
export const D = {
|
||||
map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, tutorial: 75, detail: 76, intro: 78, toast: 80,
|
||||
map: 1, hud: 30, modal: 60, colony: 70, gnn: 72, council: 73, detail: 76, intro: 78, toast: 80,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -323,9 +323,6 @@ export default class VegaSidePanel {
|
|||
// The pool survives the wipe; claim what this pass actually uses and let
|
||||
// endFrame() hide and pause the rest.
|
||||
this.pool.beginFrame();
|
||||
// Per-stack local-coord rects the guided tutorial points at (ship
|
||||
// profiles vs. the −/+/✕ cluster). Rebuilt every pass like the body.
|
||||
this._tutorRows = [];
|
||||
this.y = 90;
|
||||
if (this.mode === 'star') this.buildStar();
|
||||
else if (this.mode === 'fleet') this.buildFleet();
|
||||
|
|
@ -764,57 +761,9 @@ export default class VegaSidePanel {
|
|||
}).setOrigin(0.5);
|
||||
this.body.add(count);
|
||||
|
||||
// Tutorial anchors, in body-local coords (body sits at 0,0 in root).
|
||||
const ctrlLeft = right - boxSize * 4.3 - boxSize / 2;
|
||||
this._tutorRows.push({
|
||||
profiles: {
|
||||
x: PAD - 4,
|
||||
y: rowY - 4,
|
||||
w: Math.min(name.x + name.width, ctrlLeft - 6) - (PAD - 4),
|
||||
h: rowH + 8,
|
||||
},
|
||||
counts: {
|
||||
x: ctrlLeft - 4,
|
||||
y: ctrlY - boxSize / 2 - 5,
|
||||
w: (right + 4) - (ctrlLeft - 4),
|
||||
h: boxSize + 10,
|
||||
},
|
||||
});
|
||||
|
||||
this.y = rowY + rowH + 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen-space bounding box of a named region of the panel, for the guided
|
||||
* tutorial's spotlight. `name` is 'shipProfiles' or 'countControls' (union
|
||||
* across every task-force stack in the fleet view), or 'panel' (the whole
|
||||
* docked column, whatever mode — star/fleet/order — it is currently
|
||||
* showing). Uses the panel's resting position (this.x0/this.y0), not
|
||||
* this.root.x, so it is correct even while the panel is still sliding in.
|
||||
*/
|
||||
tutorialRegion(name) {
|
||||
if (!this.root?.visible) return null;
|
||||
if (name === 'panel') return { x: this.x0, y: this.y0, w: W, h: this.h };
|
||||
if (this.mode !== 'fleet') return null;
|
||||
const rows = this._tutorRows;
|
||||
if (!rows || !rows.length) return null;
|
||||
const key = name === 'shipProfiles' ? 'profiles'
|
||||
: name === 'countControls' ? 'counts' : null;
|
||||
if (!key) return null;
|
||||
let x0 = Infinity;
|
||||
let y0 = Infinity;
|
||||
let x1 = -Infinity;
|
||||
let y1 = -Infinity;
|
||||
for (const r of rows) {
|
||||
const b = r[key];
|
||||
x0 = Math.min(x0, b.x);
|
||||
y0 = Math.min(y0, b.y);
|
||||
x1 = Math.max(x1, b.x + b.w);
|
||||
y1 = Math.max(y1, b.y + b.h);
|
||||
}
|
||||
return { x: this.x0 + x0, y: this.y0 + y0, w: x1 - x0, h: y1 - y0 };
|
||||
}
|
||||
|
||||
selectionSummary() {
|
||||
const { rules, state } = this;
|
||||
const ships = this.selectedShips();
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
coloniesAt, empireColonies, fleetEta, habitableForEmpire,
|
||||
} from './VegaLogic.js';
|
||||
import { starFrame } from './VegaArt.js';
|
||||
import { buildZoomLadder, DEFAULT_ZOOM_INDEX, pickFitZoomIndex } from './VegaZoom.js';
|
||||
import { buildZoomLadder, DEFAULT_ZOOM_INDEX } from './VegaZoom.js';
|
||||
import { delaunayTriangulate, triangulationEdges } from './VegaDelaunay.js';
|
||||
import { describeStarTooltip } from './VegaTooltips.js';
|
||||
import { ORBIT } from './VegaScreens.js';
|
||||
|
|
@ -868,34 +868,6 @@ export default class VegaStarMap {
|
|||
this.clampPan();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the ladder rung that frames a world-space box (`minX/minY/maxX/
|
||||
* maxY`, plus `padding` on every side) without cropping it — via
|
||||
* VegaZoom.pickFitZoomIndex, same math VegaCombatCamera.js's "frame the
|
||||
* whole fleet" opening shot uses — then pan so the box's center lands at
|
||||
* `(focusX, focusY)` in screen space. `focusX`/`focusY` default to the
|
||||
* viewport's own center, same as `viewW`/`viewH` default to the full
|
||||
* screen; the guided tutorial narrows all four so the frame is biased left
|
||||
* of the docked command panel instead of packing stars in behind it.
|
||||
*/
|
||||
fitToWorldBounds(minX, minY, maxX, maxY, {
|
||||
padding = 0, viewW = GAME_WIDTH, viewH = GAME_HEIGHT,
|
||||
focusX = GAME_WIDTH / 2, focusY = GAME_HEIGHT / 2,
|
||||
} = {}) {
|
||||
const idx = pickFitZoomIndex(this.zooms, maxX - minX, maxY - minY, padding, { viewW, viewH });
|
||||
this.zoomIndex = idx;
|
||||
this.zoom = this.zooms[idx];
|
||||
this.root.setScale(this.zoom);
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
this.root.x = focusX - cx * this.zoom;
|
||||
this.root.y = focusY - cy * this.zoom;
|
||||
this.clampPan();
|
||||
this.refreshLabels();
|
||||
this.refreshStars();
|
||||
this.refreshColonyInfo();
|
||||
}
|
||||
|
||||
panToStar(starIdx, duration = 420) {
|
||||
const star = this.state.galaxy.stars[starIdx];
|
||||
if (!star) return;
|
||||
|
|
|
|||
|
|
@ -29,14 +29,7 @@ import {
|
|||
} from './VegaLogic.js';
|
||||
|
||||
export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
||||
const {
|
||||
viewerIdx = state.humanIndex, onChanged = null, onClose = null,
|
||||
// Fired right when the "View Colony" button opens the colony screen over
|
||||
// this one — nothing else in the game needs to know that
|
||||
// (MasterOfVegaGame.js's openSystemViewFor never passes it), but the
|
||||
// guided tutorial does, since it has no other hook into that click.
|
||||
onColonyOpen = null,
|
||||
} = opts;
|
||||
const { viewerIdx = state.humanIndex, onChanged = null, onClose = null } = opts;
|
||||
const star = state.galaxy.stars[starIdx];
|
||||
const shell = modalShell(scene, star.name, onClose, { width: 1620, height: 900 });
|
||||
|
||||
|
|
@ -364,7 +357,6 @@ export function openSystemView(scene, rules, state, starIdx, art, opts = {}) {
|
|||
rebuild();
|
||||
},
|
||||
});
|
||||
onColonyOpen?.(colony);
|
||||
}, { width: panelW, height: 50, fontSize: 21 }));
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,236 +0,0 @@
|
|||
// Master of Vega — tutorial data: schema validation, text interpolation, and
|
||||
// the highlight-target id registry. Headless, no Phaser imports, so it runs in
|
||||
// Node (tools/verifyMasterOfVega.js) exactly like VegaTurnReport.js.
|
||||
//
|
||||
// The tutorial SCRIPT lives in data/mastervega-tutorial.json — an ordered list
|
||||
// of steps, each with body/callout text, button labels, a voice clip, and a
|
||||
// list of highlight-target ids. This file knows nothing about Phaser or the
|
||||
// scene: it validates the JSON shape, substitutes {token} placeholders, and
|
||||
// pins the set of target ids the renderer (VegaTutorial.js) knows how to
|
||||
// resolve to on-screen positions. Anything the renderer can point at has to be
|
||||
// listed in TUTORIAL_TARGET_IDS here AND have a resolver case in VegaTutorial.
|
||||
|
||||
// Every id a step may name in `highlights` or `anchor`. VegaTutorial.js has a
|
||||
// _resolveTarget() case for each. Adding a new highlightable thing later means
|
||||
// adding its id here and a resolver there. `homeStar`/`homeFleet` are on the
|
||||
// star map; `fleetShipProfiles`/`fleetCountControls` are regions of the side
|
||||
// panel's fleet view, resolved through VegaSidePanel.tutorialRegion().
|
||||
// `nearbyStars` resolves to an ARRAY of targets (every star in fuel range of
|
||||
// home, one per star) rather than a single one — VegaTutorial.js flattens it
|
||||
// when building the highlight/hole list. `mapArea` is a big invisible hole
|
||||
// (no ring drawn) used only to uncover the map for the pan/zoom lesson.
|
||||
// `panel` is the docked command panel's whole footprint, regardless of what
|
||||
// mode (star/fleet/order) it is currently showing. `travelingFleet` is
|
||||
// whichever of the human player's fleets is currently under way.
|
||||
// `endTurnButton` is the fixed HUD button (MasterOfVegaGame's scene.endTurnBtn).
|
||||
// `empireButton` is the fixed HUD "Empire" dropdown toggle (scene.empireBtn).
|
||||
// The four `empireMenu*` ids are that dropdown's own item rows (Research/
|
||||
// Diplomacy/Leaders/Colonies, in that fixed order) — each resolves to null
|
||||
// unless the dropdown (scene.empireMenuLayer) is actually open.
|
||||
export const TUTORIAL_TARGET_IDS = Object.freeze([
|
||||
'homeStar', 'homeFleet', 'fleetShipProfiles', 'fleetCountControls',
|
||||
'nearbyStars', 'mapArea', 'panel', 'travelingFleet', 'endTurnButton',
|
||||
'empireButton', 'empireMenuResearch', 'empireMenuDiplomacy', 'empireMenuLeaders', 'empireMenuColonies',
|
||||
]);
|
||||
|
||||
const STEP_KINDS = Object.freeze(['modal', 'callout']);
|
||||
const BUTTON_ACTIONS = Object.freeze(['next', 'back', 'skip', 'finish']);
|
||||
// Ways a step can progress WITHOUT a button. `hotspot` = a click on the lit
|
||||
// anchor (what that click actually DOES is the step's own `hotspotAction`
|
||||
// field, not this one — see HOTSPOT_ACTIONS); `shipDetailClosed` = the player
|
||||
// opened and then closed a ship detail window; `starPick` = the player
|
||||
// clicked one of the `nearbyStars` hotspots (see STAR_PICK_MODES — the
|
||||
// step's own `starPick` field decides what that click does); `fleetInFlight`
|
||||
// = the player accepted a REAL fleet order through the side panel's own
|
||||
// Accept button (VegaTutorial polls state.fleets for one under way, since
|
||||
// that button is not part of the tutorial overlay); `external` = something
|
||||
// outside the polling/hotspot system calls advance() directly — a callback
|
||||
// VegaTutorial wired into a real screen it opened itself (System View /
|
||||
// Colony View), the same reason `starPick`/`hotspot` need no poll either. A
|
||||
// step with no buttons must declare one of these or it is a dead end.
|
||||
export const ADVANCE_MODES = Object.freeze([
|
||||
'hotspot', 'shipDetailClosed', 'starPick', 'fleetInFlight', 'external',
|
||||
]);
|
||||
// What clicking a `nearbyStars` hotspot does, independent of `advanceOn`:
|
||||
// `info` opens that star's read-only panel and immediately advances (paired
|
||||
// with advanceOn: 'starPick'); `order` quotes a move order for the already-
|
||||
// selected fleet to that star and does NOT advance — the player still has to
|
||||
// press the panel's real Accept button (paired with advanceOn: 'fleetInFlight').
|
||||
export const STAR_PICK_MODES = Object.freeze(['info', 'order']);
|
||||
// What clicking the single `advanceOn: 'hotspot'` target does — VegaTutorial's
|
||||
// _onHotspot() switches on this instead of pattern-matching the step's
|
||||
// `anchor` id, so more than one step can point at the same anchor (e.g. two
|
||||
// different steps both pointing at 'panel') without colliding.
|
||||
export const HOTSPOT_ACTIONS = Object.freeze([
|
||||
'selectHomeFleet', // ring+select the docked home fleet, open its panel
|
||||
'showHomeStar', // ring+select the home star, open its read-only panel
|
||||
'openSystemView', // open System View directly for the home star
|
||||
'endTurn', // unfreeze modalOpen just long enough to fire the real End Turn
|
||||
'openEmpireMenu', // open the Empire dropdown directly (bypasses its own modalOpen gate)
|
||||
'openResearch', 'openDiplomacy', 'openLeaders', 'openColonies', // close the dropdown, open that screen directly
|
||||
]);
|
||||
// What a `kind:'modal'` step's Next button does before advancing, in
|
||||
// addition to the button's own `action:'next'` — VegaTutorial's own state
|
||||
// (which real screen it opened directly, and whether the Empire dropdown
|
||||
// should reappear behind the next one), not anything the step's JSON needs
|
||||
// to know the mechanics of.
|
||||
export const ADVANCE_ACTIONS = Object.freeze([
|
||||
'closeEmpireScreenReopenMenu', // close whichever Empire-menu screen is open, reopen the dropdown
|
||||
'closeEmpireScreenAndMenu', // close it, and make sure the dropdown is closed too (the tour is over)
|
||||
]);
|
||||
|
||||
const PLACEHOLDER_RE = /\{([a-zA-Z0-9_]+)\}/g;
|
||||
|
||||
/**
|
||||
* Replace every `{token}` in `str` with `vars[token]`. Tokens with no matching
|
||||
* key are left untouched (the verifier separately flags any token not declared
|
||||
* in the file's top-level `vars` allow-list, so an unresolved placeholder is a
|
||||
* build error, not a silent runtime gap).
|
||||
*/
|
||||
export function interpolate(str, vars = {}) {
|
||||
if (typeof str !== 'string') return str;
|
||||
return str.replace(PLACEHOLDER_RE, (whole, token) =>
|
||||
(Object.prototype.hasOwnProperty.call(vars, token) ? String(vars[token]) : whole));
|
||||
}
|
||||
|
||||
/** Every distinct `{token}` appearing anywhere in `str`. */
|
||||
export function placeholdersIn(str) {
|
||||
if (typeof str !== 'string') return [];
|
||||
const out = new Set();
|
||||
let m;
|
||||
PLACEHOLDER_RE.lastIndex = 0;
|
||||
// eslint-disable-next-line no-cond-assign
|
||||
while ((m = PLACEHOLDER_RE.exec(str))) out.add(m[1]);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
const isNonEmptyString = (v) => typeof v === 'string' && v.trim().length > 0;
|
||||
|
||||
/**
|
||||
* Validate a parsed tutorial JSON. Returns `{ ok, errors }` — `errors` is a
|
||||
* list of human-readable strings, empty when `ok` is true. Deliberately
|
||||
* permissive about unknown extra fields (forward-compatible) but strict about
|
||||
* every field the renderer actually reads.
|
||||
*/
|
||||
export function validateTutorialData(json) {
|
||||
const errors = [];
|
||||
const fail = (msg) => errors.push(msg);
|
||||
|
||||
if (!json || typeof json !== 'object') {
|
||||
return { ok: false, errors: ['tutorial JSON is not an object'] };
|
||||
}
|
||||
|
||||
if (json.version !== 1) fail(`version must be 1 (got ${JSON.stringify(json.version)})`);
|
||||
|
||||
const vars = Array.isArray(json.vars) ? json.vars : [];
|
||||
if (!Array.isArray(json.vars)) fail('`vars` must be an array of allowed placeholder names');
|
||||
|
||||
const cs = json.confirmSkip;
|
||||
if (!cs || typeof cs !== 'object') {
|
||||
fail('`confirmSkip` must be an object');
|
||||
} else {
|
||||
if (!isNonEmptyString(cs.body)) fail('`confirmSkip.body` must be a non-empty string');
|
||||
if (!isNonEmptyString(cs.confirmLabel)) fail('`confirmSkip.confirmLabel` must be a non-empty string');
|
||||
if (!isNonEmptyString(cs.cancelLabel)) fail('`confirmSkip.cancelLabel` must be a non-empty string');
|
||||
}
|
||||
|
||||
if (!Array.isArray(json.steps) || json.steps.length === 0) {
|
||||
fail('`steps` must be a non-empty array');
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
const seenIds = new Set();
|
||||
json.steps.forEach((step, i) => {
|
||||
const at = `steps[${i}]`;
|
||||
if (!step || typeof step !== 'object') { fail(`${at} is not an object`); return; }
|
||||
|
||||
if (!isNonEmptyString(step.id)) fail(`${at}.id must be a non-empty string`);
|
||||
else if (seenIds.has(step.id)) fail(`${at}.id "${step.id}" is duplicated`);
|
||||
else seenIds.add(step.id);
|
||||
|
||||
if (!STEP_KINDS.includes(step.kind)) {
|
||||
fail(`${at}.kind must be one of ${STEP_KINDS.join('/')} (got ${JSON.stringify(step.kind)})`);
|
||||
}
|
||||
|
||||
if (step.voice !== null && step.voice !== undefined && !isNonEmptyString(step.voice)) {
|
||||
fail(`${at}.voice must be null or a non-empty string`);
|
||||
}
|
||||
|
||||
if (step.kind === 'modal' && !isNonEmptyString(step.body)) {
|
||||
fail(`${at} (modal) must have a non-empty body`);
|
||||
}
|
||||
if (step.kind === 'callout') {
|
||||
if (!isNonEmptyString(step.calloutText)) fail(`${at} (callout) must have a non-empty calloutText`);
|
||||
if (!TUTORIAL_TARGET_IDS.includes(step.anchor)) {
|
||||
fail(`${at} (callout) anchor must be one of ${TUTORIAL_TARGET_IDS.join('/')} (got ${JSON.stringify(step.anchor)})`);
|
||||
}
|
||||
}
|
||||
|
||||
const highlights = step.highlights ?? [];
|
||||
if (!Array.isArray(highlights)) {
|
||||
fail(`${at}.highlights must be an array`);
|
||||
} else {
|
||||
highlights.forEach((h) => {
|
||||
if (!TUTORIAL_TARGET_IDS.includes(h)) fail(`${at}.highlights has unknown target id ${JSON.stringify(h)}`);
|
||||
});
|
||||
}
|
||||
|
||||
if (step.advanceOn !== undefined && !ADVANCE_MODES.includes(step.advanceOn)) {
|
||||
fail(`${at}.advanceOn, when set, must be one of ${ADVANCE_MODES.join('/')} (got ${JSON.stringify(step.advanceOn)})`);
|
||||
}
|
||||
if (step.advanceOn === 'hotspot' && !TUTORIAL_TARGET_IDS.includes(step.anchor)) {
|
||||
fail(`${at}.advanceOn "hotspot" needs a valid anchor`);
|
||||
}
|
||||
if (step.advanceOn === 'hotspot' && !HOTSPOT_ACTIONS.includes(step.hotspotAction)) {
|
||||
fail(`${at}.advanceOn "hotspot" needs a valid hotspotAction (one of ${HOTSPOT_ACTIONS.join('/')}, got ${JSON.stringify(step.hotspotAction)})`);
|
||||
}
|
||||
|
||||
if (step.advanceAction !== undefined && !ADVANCE_ACTIONS.includes(step.advanceAction)) {
|
||||
fail(`${at}.advanceAction, when set, must be one of ${ADVANCE_ACTIONS.join('/')} (got ${JSON.stringify(step.advanceAction)})`);
|
||||
}
|
||||
|
||||
if (step.starPick !== undefined && !STAR_PICK_MODES.includes(step.starPick)) {
|
||||
fail(`${at}.starPick, when set, must be one of ${STAR_PICK_MODES.join('/')} (got ${JSON.stringify(step.starPick)})`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(step.buttons)) {
|
||||
fail(`${at}.buttons must be an array`);
|
||||
} else if (step.buttons.length === 0 && !ADVANCE_MODES.includes(step.advanceOn)) {
|
||||
// An empty button row is only legal when the step can be advanced some
|
||||
// other way — otherwise the player is stuck with nowhere to click.
|
||||
fail(`${at}.buttons is empty but the step has no advanceOn (${ADVANCE_MODES.join('/')}) to progress`);
|
||||
} else {
|
||||
step.buttons.forEach((b, bi) => {
|
||||
if (!b || typeof b !== 'object') { fail(`${at}.buttons[${bi}] is not an object`); return; }
|
||||
if (!BUTTON_ACTIONS.includes(b.action)) {
|
||||
fail(`${at}.buttons[${bi}].action must be one of ${BUTTON_ACTIONS.join('/')} (got ${JSON.stringify(b.action)})`);
|
||||
}
|
||||
if (!isNonEmptyString(b.label)) fail(`${at}.buttons[${bi}].label must be a non-empty string`);
|
||||
});
|
||||
}
|
||||
|
||||
// Every {token} used in visible text must be declared in `vars`.
|
||||
for (const field of ['title', 'body', 'calloutText']) {
|
||||
for (const token of placeholdersIn(step[field])) {
|
||||
if (!vars.includes(token)) fail(`${at}.${field} uses undeclared placeholder {${token}} (add it to top-level "vars")`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the runtime step list: a shallow copy of `json.steps` with `title`,
|
||||
* `body` and `calloutText` interpolated against `vars`, and `highlights`
|
||||
* defaulted to `[]`. Assumes the data already passed validateTutorialData.
|
||||
*/
|
||||
export function resolveSteps(json, vars = {}) {
|
||||
return (json.steps ?? []).map((step) => ({
|
||||
...step,
|
||||
title: interpolate(step.title, vars),
|
||||
body: interpolate(step.body, vars),
|
||||
calloutText: interpolate(step.calloutText, vars),
|
||||
highlights: step.highlights ?? [],
|
||||
}));
|
||||
}
|
||||
|
|
@ -47,14 +47,10 @@ export function buildZoomLadder(worldW, worldH, { steps = ZOOM_STEPS, maxZoom =
|
|||
* than the area needs — VegaCombatCamera.js's "frame the whole fleet"
|
||||
* feature is built on this.
|
||||
*/
|
||||
// `viewW`/`viewH` default to the full screen but can be narrowed to whatever
|
||||
// area is actually free of docked chrome (the guided tutorial's fit-the-
|
||||
// reachable-stars step passes the width left of the command panel, so the
|
||||
// picked rung doesn't pack stars in behind it).
|
||||
export function pickFitZoomIndex(zooms, boundsW, boundsH, padding = 0, { viewW = GAME_WIDTH, viewH = GAME_HEIGHT } = {}) {
|
||||
export function pickFitZoomIndex(zooms, boundsW, boundsH, padding = 0) {
|
||||
const w = Math.max(1, boundsW + padding * 2);
|
||||
const h = Math.max(1, boundsH + padding * 2);
|
||||
const ideal = Math.min(viewW / w, viewH / h);
|
||||
const ideal = Math.min(GAME_WIDTH / w, GAME_HEIGHT / h);
|
||||
let idx = 0;
|
||||
for (let i = 0; i < zooms.length; i += 1) {
|
||||
if (zooms[i] <= ideal) idx = i;
|
||||
|
|
|
|||
|
|
@ -62,7 +62,6 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
|
|||
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
||||
import ShiftGame from './games/shift/ShiftGame.js';
|
||||
import JigsawGame from './games/jigsaw/JigsawGame.js';
|
||||
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
||||
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
||||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||
|
|
@ -185,7 +184,6 @@ const config = {
|
|||
HexsweeperGame,
|
||||
PuddingMonstersGame,
|
||||
ShiftGame,
|
||||
JigsawGame,
|
||||
BlockFighterGame,
|
||||
MahjongMatchGame,
|
||||
MahjongGame,
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
||||
import { TutorialModal } from '../ui/TutorialModal.js';
|
||||
|
||||
let _lastCategory = null;
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'tabletop', label: 'Tabletop' },
|
||||
{ key: 'cards', label: 'Cards & Dice' },
|
||||
|
|
@ -21,29 +22,10 @@ const ICON_INACTIVE = 56;
|
|||
const ICON_ACTIVE = 72;
|
||||
const ICON_OVERSHOOT = 86;
|
||||
const ICON_X_OFFSET = -145;
|
||||
// How far (px) a grid travels off screen when switching categories.
|
||||
const GRID_TRAVEL = GAME_WIDTH + 800;
|
||||
|
||||
// The most recently selected category. Module scope so it survives scene
|
||||
// restarts (scene.start() reuses the instance) and also covers paths that
|
||||
// start the menu without category data — the menu always returns to where
|
||||
// the user last was.
|
||||
let lastCategory = null;
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
constructor() { super('GameMenu'); }
|
||||
|
||||
init(data) {
|
||||
// Category to restore on entry: the explicit one (coming back from the
|
||||
// opponent picker) or the most recently selected one.
|
||||
this._initialCategory = (data && data.category) || lastCategory;
|
||||
// scene.start() reuses the existing scene instance, so instance props from
|
||||
// the previous visit survive — reset them so this create() starts clean
|
||||
// (otherwise showCategory()'s "same category" guard swallows the restore).
|
||||
this._currentCategory = null;
|
||||
this._gridAnim = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
playMenuMusic();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
|
@ -51,18 +33,12 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
addFullscreenButton(this);
|
||||
|
||||
const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
|
||||
const titleText = this.add.text(cx, 60, 'Choose a game', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(1);
|
||||
titleText.setLetterSpacing(2);
|
||||
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
|
||||
this._titleText = titleText;
|
||||
const plateW = titleText.width + 64;
|
||||
const plateH = titleText.height + 28;
|
||||
this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5);
|
||||
this._titlePlateW = plateW;
|
||||
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
|
||||
|
||||
const loadingText = this.add.text(cx, 540, 'Loading game list…', {
|
||||
fontSize: '24px', color: COLORS.mutedHex,
|
||||
|
|
@ -131,49 +107,14 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._tabIcons[key] = icon;
|
||||
});
|
||||
|
||||
// No category is selected by default — the user picks one to see games.
|
||||
this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', {
|
||||
fontSize: '26px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key;
|
||||
this.showCategory(startKey);
|
||||
|
||||
this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
|
||||
// Returning from the opponent picker: restore the category that was
|
||||
// active when the game was chosen — its grid flies back in from the right.
|
||||
if (this._initialCategory) {
|
||||
this.showCategory(this._initialCategory);
|
||||
}
|
||||
|
||||
// The landing scene zooms the logo out before starting us, so fade the
|
||||
// menu controls in to complete the handoff.
|
||||
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn].filter(Boolean);
|
||||
for (const obj of fadeIn) obj.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: fadeIn,
|
||||
alpha: 1,
|
||||
duration: 450,
|
||||
ease: 'Power2.easeOut',
|
||||
});
|
||||
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}
|
||||
|
||||
showCategory(key) {
|
||||
if (key === this._currentCategory) return;
|
||||
this._currentCategory = key;
|
||||
lastCategory = key;
|
||||
|
||||
if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
|
||||
if (this._titleText) {
|
||||
this._titleText.setText('Choose a game');
|
||||
const newW = this._titleText.width + 64;
|
||||
const newH = this._titleText.height + 28;
|
||||
if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) {
|
||||
this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut');
|
||||
this._titlePlateW = newW;
|
||||
}
|
||||
// Little pop so the label swap reads as intentional.
|
||||
this._titleText.setScale(0.9);
|
||||
this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' });
|
||||
}
|
||||
_lastCategory = key;
|
||||
for (const [k, btn] of Object.entries(this._tabs)) {
|
||||
btn.setActive(k === key);
|
||||
}
|
||||
|
|
@ -221,41 +162,13 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
// Interrupt any grid animation still in flight (user changed their mind mid-flight).
|
||||
if (this._gridAnim) {
|
||||
for (const obj of this._gridAnim.objects) obj.destroy();
|
||||
this._gridAnim = null;
|
||||
this._gameObjects = [];
|
||||
}
|
||||
for (const obj of this._gameObjects) obj.destroy();
|
||||
this._gameObjects = [];
|
||||
if (this._ptrMoveHandler) {
|
||||
this.input.off('pointermove', this._ptrMoveHandler, this);
|
||||
this._ptrMoveHandler = null;
|
||||
}
|
||||
|
||||
const oldObjects = this._gameObjects;
|
||||
this._gameObjects = [];
|
||||
if (oldObjects.length === 0) {
|
||||
this.buildCategoryGrid(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
|
||||
for (const obj of oldObjects) obj.disableInteractive();
|
||||
this._gridAnim = { objects: oldObjects };
|
||||
this.tweens.add({
|
||||
targets: oldObjects,
|
||||
x: `-=${GRID_TRAVEL}`,
|
||||
duration: 380,
|
||||
ease: 'Power2.easeIn',
|
||||
onComplete: () => {
|
||||
this._gridAnim = null;
|
||||
for (const obj of oldObjects) obj.destroy();
|
||||
this.buildCategoryGrid(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
buildCategoryGrid(key) {
|
||||
const games = this._gamesByCategory[key];
|
||||
if (!games || games.length === 0) return;
|
||||
|
||||
|
|
@ -354,18 +267,6 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._gameObjects.push(qg, qLabel);
|
||||
}
|
||||
});
|
||||
|
||||
// Fly the grid in from off screen right.
|
||||
const objs = this._gameObjects;
|
||||
for (const obj of objs) obj.x += GRID_TRAVEL;
|
||||
this._gridAnim = { objects: objs };
|
||||
this.tweens.add({
|
||||
targets: objs,
|
||||
x: `-=${GRID_TRAVEL}`,
|
||||
duration: 420,
|
||||
ease: 'Power2.easeOut',
|
||||
onComplete: () => { this._gridAnim = null; },
|
||||
});
|
||||
}
|
||||
|
||||
openGame(game) {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
|
|
@ -17,8 +16,6 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
|
||||
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
|
||||
this._logo = logo;
|
||||
this._transitioning = false;
|
||||
|
||||
this.tweens.add({
|
||||
targets: logo,
|
||||
|
|
@ -56,7 +53,6 @@ export default class LandingScene extends Phaser.Scene {
|
|||
renderButtons() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const user = auth.user;
|
||||
this._avatar = null;
|
||||
|
||||
const avatarR = 28;
|
||||
const avatarGap = 18;
|
||||
|
|
@ -69,8 +65,6 @@ export default class LandingScene extends Phaser.Scene {
|
|||
fontSize: '36px',
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(1);
|
||||
this._welcomeText = welcomeText;
|
||||
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||
|
||||
const hasAvatar = !!user?.avatarPath;
|
||||
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
|
||||
|
|
@ -78,8 +72,7 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
|
||||
|
||||
this._welcomeBg = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
|
||||
.setPosition(cx, y);
|
||||
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45);
|
||||
|
||||
if (hasAvatar) {
|
||||
const avatarCx = groupLeft + avatarR;
|
||||
|
|
@ -93,11 +86,11 @@ export default class LandingScene extends Phaser.Scene {
|
|||
this.load.start();
|
||||
});
|
||||
}
|
||||
if (this._transitioning || !this.scene.isActive('Landing')) return;
|
||||
if (!this.scene.isActive('Landing')) return;
|
||||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff);
|
||||
maskG.fillCircle(avatarCx, y, avatarR);
|
||||
this._avatar = this.add.image(avatarCx, y, key)
|
||||
this.add.image(avatarCx, y, key)
|
||||
.setDisplaySize(avatarR * 2, avatarR * 2)
|
||||
.setMask(maskG.createGeometryMask())
|
||||
.setDepth(1);
|
||||
|
|
@ -105,39 +98,7 @@ export default class LandingScene extends Phaser.Scene {
|
|||
})();
|
||||
}
|
||||
|
||||
this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
|
||||
this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
}
|
||||
|
||||
goToGameMenu() {
|
||||
if (this._transitioning) return;
|
||||
this._transitioning = true;
|
||||
|
||||
this.playBtn.disableInteractive();
|
||||
this.profileBtn.disableInteractive();
|
||||
|
||||
// Fade the landing buttons out while the logo zooms past the camera.
|
||||
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
|
||||
if (this._avatar) fadeOut.push(this._avatar);
|
||||
this.tweens.add({
|
||||
targets: fadeOut,
|
||||
alpha: 0,
|
||||
duration: 350,
|
||||
ease: 'Power2.easeOut',
|
||||
});
|
||||
|
||||
if (!this._logo) { this.scene.start('GameMenu'); return; }
|
||||
this.tweens.killTweensOf(this._logo);
|
||||
this.tweens.add({
|
||||
targets: this._logo,
|
||||
x: GAME_WIDTH / 2,
|
||||
y: GAME_HEIGHT / 2,
|
||||
scaleX: 2.4,
|
||||
scaleY: 2.4,
|
||||
alpha: 0,
|
||||
duration: 700,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => this.scene.start('GameMenu'),
|
||||
});
|
||||
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
|
||||
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
|
||||
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
|
||||
|
||||
|
|
@ -18,8 +17,8 @@ const CARD_TILE_GAP = 14;
|
|||
|
||||
// Opponent grid scroll area
|
||||
const OPP_SCROLL_W = 1780;
|
||||
const OPP_SCROLL_H = 415;
|
||||
const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
|
||||
const OPP_SCROLL_H = 440;
|
||||
const OPP_SCROLL_TOP = 155; // top edge of scroll area
|
||||
|
||||
export default class OpponentSelectScene extends Phaser.Scene {
|
||||
constructor() { super('OpponentSelect'); }
|
||||
|
|
@ -59,32 +58,21 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
|
||||
|
||||
// Brass-plaque headers, same treatment as the game menu headings. The
|
||||
// subtitle plaque sits in the band between the title plaque and the
|
||||
// opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either.
|
||||
const TITLE_Y = 55;
|
||||
const PLATE_GAP = 10;
|
||||
const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, {
|
||||
const titleText = this.add.text(cx, 60, this.gameDef.name, {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
titleText.setLetterSpacing(2);
|
||||
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
|
||||
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
|
||||
this.children.moveBelow(titlePlate, titleText);
|
||||
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
|
||||
this.children.moveBelow(titlePill, titleText);
|
||||
|
||||
const subtitleText = this.add.text(cx, 0, 'Choose your opponent', {
|
||||
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '36px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||
const subPlateH = subtitleText.height + 28;
|
||||
const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2;
|
||||
subtitleText.setY(subtitleY);
|
||||
const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY);
|
||||
this.children.moveBelow(subtitlePlate, subtitleText);
|
||||
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
|
||||
this.children.moveBelow(subtitlePill, subtitleText);
|
||||
|
||||
let opponents = [];
|
||||
try {
|
||||
|
|
@ -99,7 +87,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
const min = this.gameDef.minOpponents ?? 1;
|
||||
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), {
|
||||
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
|
||||
variant: 'ghost',
|
||||
width: 280,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
|
|||
if (this._active) return;
|
||||
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
|
||||
if (v === 'ghost') {
|
||||
this._drawBg(bgh, 0.9);
|
||||
this.text.setColor(thc);
|
||||
this._drawBg(bgh, 0.18);
|
||||
this.text.setColor(COLORS.goldHex);
|
||||
} else {
|
||||
this._drawBg(bgh, 1);
|
||||
this.text.setColor(thc);
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { COLORS } from '../config.js';
|
||||
|
||||
// A rounded, brass-trimmed plaque in the same design language as the
|
||||
// category tabs. Use instead of a flat black rectangle behind title text.
|
||||
export class Plaque extends Phaser.GameObjects.Graphics {
|
||||
constructor(scene, width = 300, height = 90, options = {}) {
|
||||
super(scene, false);
|
||||
const {
|
||||
fill = 0x241c10,
|
||||
fillAlpha = 0.92,
|
||||
border = COLORS.accent,
|
||||
radius = 18,
|
||||
} = options;
|
||||
this._opts = { fill, fillAlpha, border, radius };
|
||||
this._w = width;
|
||||
this._h = height;
|
||||
this.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65);
|
||||
this.redraw(width, height);
|
||||
scene.add.existing(this);
|
||||
}
|
||||
|
||||
redraw(w, h) {
|
||||
this._w = w;
|
||||
this._h = h;
|
||||
const { fill, fillAlpha, border, radius } = this._opts;
|
||||
const r = Math.max(4, Math.min(radius, w / 2, h / 2));
|
||||
this.clear();
|
||||
this.fillStyle(fill, fillAlpha);
|
||||
this.fillRoundedRect(-w / 2, -h / 2, w, h, r);
|
||||
// Faint warm highlight just inside the top edge reads as a raised plate.
|
||||
this.lineStyle(2, 0xfff6dd, 0.16);
|
||||
this.lineBetween(-w / 2 + r + 4, -h / 2 + 4, w / 2 - r - 4, -h / 2 + 4);
|
||||
this.lineStyle(2, border, 1);
|
||||
this.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
|
||||
}
|
||||
|
||||
// Animate the plate to a new size. Graphics in this Phaser build exposes
|
||||
// no setScaleX/setScaleY (and scaling a redrawn shape is unreliable), so
|
||||
// we tween a size proxy and redraw every frame instead.
|
||||
animateSize(w, h, duration = 240, ease = 'Quad.easeOut', onComplete) {
|
||||
if (this._sizeTween) this._sizeTween.stop();
|
||||
const proxy = { w: this._w, h: this._h };
|
||||
this._sizeTween = this.scene.tweens.add({
|
||||
targets: proxy,
|
||||
w,
|
||||
h,
|
||||
duration,
|
||||
ease,
|
||||
onUpdate: () => {
|
||||
if (!this.scene) return; // destroyed mid-animation
|
||||
this.redraw(proxy.w, proxy.h);
|
||||
},
|
||||
onComplete: () => {
|
||||
this._sizeTween = null;
|
||||
if (!this.scene) return; // destroyed mid-animation
|
||||
this.redraw(w, h);
|
||||
if (onComplete) onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
// Headless-chromium CDP driver for the jigsaw initial-image test page.
|
||||
// Loads the page repeatedly; each load must end PASS, and the initial image
|
||||
// index (document.__initIndex) must vary across fresh loads.
|
||||
// Usage: node tools/__jig_init_driver.mjs <url> [loads=8]
|
||||
import { spawn } from 'node:child_process';
|
||||
import { setTimeout as sleep } from 'node:timers/promises';
|
||||
|
||||
const url = process.argv[2];
|
||||
const loads = Math.max(2, parseInt(process.argv[3] || '8', 10));
|
||||
if (!url) { console.error('usage: driver <url> [loads]'); process.exit(2); }
|
||||
|
||||
const BIN = process.env.HOME + '/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell';
|
||||
const PORT = 9333;
|
||||
const chrome = spawn(BIN, [
|
||||
'--headless', '--no-sandbox', '--disable-gpu',
|
||||
`--remote-debugging-port=${PORT}`,
|
||||
'about:blank',
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
chrome.stderr.on('data', (d) => { const s = d.toString(); if (!/Fontconfig|dbus|DBus|ozone/i.test(s)) process.stderr.write(s); });
|
||||
|
||||
async function httpJson(path) {
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}${path}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
let ws, idc = 0;
|
||||
const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
||||
const id = ++idc;
|
||||
pending.set(id, { resolve, reject });
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
|
||||
try {
|
||||
let targets = null;
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try { targets = await httpJson('/json/list'); break; } catch { await sleep(250); }
|
||||
}
|
||||
if (!targets) throw new Error('chrome devtools endpoint never came up');
|
||||
const page = targets.find((t) => t.type === 'page');
|
||||
if (!page) throw new Error('no page target');
|
||||
ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
|
||||
ws.onmessage = (m) => {
|
||||
const msg = JSON.parse(m.data);
|
||||
if (msg.id && pending.has(msg.id)) {
|
||||
const { resolve, reject } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
|
||||
}
|
||||
};
|
||||
|
||||
await send('Runtime.enable');
|
||||
await send('Page.enable');
|
||||
|
||||
const ev = (expression) => send('Runtime.evaluate', { expression, returnByValue: true }).then((r) => r.result.value);
|
||||
|
||||
const indexes = [];
|
||||
for (let k = 0; k < loads; k++) {
|
||||
await send('Page.navigate', { url });
|
||||
const t0 = Date.now();
|
||||
let title = '';
|
||||
while (Date.now() - t0 < 60000) {
|
||||
title = (await ev('document.title')) || '';
|
||||
if (title === 'PASS' || title.startsWith('FAIL')) break;
|
||||
await sleep(500);
|
||||
}
|
||||
if (title !== 'PASS') throw new Error(`load #${k}: ${title || 'TIMEOUT'}`);
|
||||
indexes.push(await ev('document.__initIndex'));
|
||||
}
|
||||
|
||||
const valid = indexes.every((i) => Number.isInteger(i) && i >= 0 && i < 5);
|
||||
const distinct = new Set(indexes).size;
|
||||
console.log('initial indexes across fresh loads:', indexes.join(', '));
|
||||
console.log(`all valid: ${valid}, distinct images: ${distinct}/${loads}`);
|
||||
if (!valid || distinct < 2) { console.log('FAIL'); process.exitCode = 1; }
|
||||
else console.log('ALL PASS');
|
||||
} finally {
|
||||
try { ws && ws.close(); } catch {}
|
||||
chrome.kill('SIGKILL');
|
||||
}
|
||||
|
|
@ -1,162 +0,0 @@
|
|||
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
|
||||
//
|
||||
// Usage:
|
||||
// npx playwright install chromium # one-time
|
||||
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
|
||||
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
|
||||
// node tools/smokeJigsaw.mjs http://localhost:9000
|
||||
//
|
||||
// What it checks:
|
||||
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
|
||||
// - forced win (placed = total; onWin()) runs the full showcase:
|
||||
// * camera sweep lands on WIN_ZOOM framing the board
|
||||
// * stats counter animates up to the total
|
||||
// * celebration emitters fire (burst + curtain) and confetti is alive
|
||||
// * gold frame + shine sweep exist at the right depths
|
||||
// - "Play Again" restarts a fresh board and tears the win layer down
|
||||
// - "Menu" returns to the menu and destroys the win layer
|
||||
// - menu → Start Puzzle works again
|
||||
// - zero JS console/page errors across the whole flow
|
||||
//
|
||||
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
|
||||
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
|
||||
// waits here are condition-based polling (generous timeouts), never fixed
|
||||
// sleeps. A full run takes roughly 1–3 minutes of wall time.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
|
||||
|
||||
let server = null;
|
||||
if (!process.argv[2]) {
|
||||
try {
|
||||
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
|
||||
} catch {
|
||||
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('started local static server on :8000');
|
||||
}
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
let failed = false;
|
||||
const ok = (m) => console.log(' ok ' + m);
|
||||
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
||||
|
||||
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
|
||||
const waitFor = async (name, cond, timeoutMs) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (await evalS(cond)) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
bad(`timeout waiting for: ${name}`);
|
||||
return false;
|
||||
};
|
||||
const snap = async (name) => {
|
||||
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
ok(`screenshot ${dest}`);
|
||||
};
|
||||
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
|
||||
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
|
||||
|
||||
try {
|
||||
console.log('— load —');
|
||||
await page.goto(BASE, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
|
||||
ok('harness ready');
|
||||
|
||||
console.log('— start puzzle —');
|
||||
await evalS(() => window.__startPuzzle());
|
||||
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
|
||||
ok('state = playing');
|
||||
const pieces = await evalS(() => window.__pieces());
|
||||
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
|
||||
if (pieces.length === 0) bad('no pieces spawned');
|
||||
await snap('1_playing');
|
||||
|
||||
console.log('— force win —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
|
||||
|
||||
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
|
||||
if (await waitFor('camera settled + counter=total', () => {
|
||||
const s = window.__scene();
|
||||
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
|
||||
&& s.piecesBig && s.piecesBig.text === String(s.total);
|
||||
}, settleTimeout)) {
|
||||
const wi = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
|
||||
});
|
||||
ok('settled: ' + JSON.stringify(wi));
|
||||
await snap('2_win_panel');
|
||||
}
|
||||
|
||||
console.log('— wait for celebration (emitters created + particles alive) —');
|
||||
if (await waitFor('emitters alive', () => {
|
||||
const s = window.__scene();
|
||||
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
|
||||
}, settleTimeout)) {
|
||||
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
|
||||
ok('particles alive: ' + JSON.stringify(alive));
|
||||
await snap('3_confetti');
|
||||
const fx = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
|
||||
});
|
||||
ok('frame/shine: ' + JSON.stringify(fx));
|
||||
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
|
||||
}
|
||||
|
||||
console.log('— Play Again button —');
|
||||
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
|
||||
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
|
||||
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
|
||||
if (placed !== 0) bad('pieces not reset after Play Again');
|
||||
if (!winGone) bad('winLayer not destroyed after Play Again');
|
||||
await snap('4_restarted');
|
||||
}
|
||||
|
||||
console.log('— force win again, then Menu button —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
|
||||
ok('second win showcase running');
|
||||
await click(400, 720); // inside "Menu"
|
||||
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
|
||||
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
|
||||
if (!menuVisible) bad('menu not visible');
|
||||
if (!winGone) bad('winLayer not destroyed');
|
||||
await snap('5_menu');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('— Start Puzzle from menu works —');
|
||||
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
|
||||
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
|
||||
}
|
||||
|
||||
console.log('\n— JS errors (' + errors.length + ') —');
|
||||
errors.forEach((e) => console.log(' ' + e));
|
||||
if (errors.length) failed = true;
|
||||
|
||||
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
#!/usr/bin/env node
|
||||
// verifyBookwork.js — engine tests for Bookworm
|
||||
// verifyBookwork.js — engine tests for Bookwork
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
|
||||
wordFromCells, computeDamage, computeSelfDamage,
|
||||
clearAndRefill, dropSpecialTile, countPoisonTiles,
|
||||
computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
|
||||
computeMaxHp, isPotionUnlocked,
|
||||
} from '../src/games/bookwork/BookworkLogic.js';
|
||||
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
|
||||
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
|
@ -93,34 +92,6 @@ const after = clearAndRefill(gridR, used);
|
|||
ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE));
|
||||
ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type));
|
||||
|
||||
// ── special tile schedule ────────────────────────────────────────────────────
|
||||
console.log('\nspecial tile schedule');
|
||||
const SCHED = {
|
||||
startLevel: 1, endLevel: 10,
|
||||
start: { gold: 0.10, diamond: 0.06 },
|
||||
end: { gold: 0.03, diamond: 0.02 },
|
||||
};
|
||||
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
|
||||
const at = (lv) => specialTileChances(lv, SCHED);
|
||||
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
|
||||
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
|
||||
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
|
||||
ok('clamps below startLevel', at(0).goldChance === 0.10);
|
||||
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
|
||||
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
|
||||
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
|
||||
|
||||
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
|
||||
const newCount = 3;
|
||||
ok('goldChance:1 → every fresh tile is gold',
|
||||
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
|
||||
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
|
||||
ok('diamondChance:1 → every fresh tile is diamond',
|
||||
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
|
||||
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
|
||||
ok('refillSteered honors goldChance:1',
|
||||
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
|
||||
|
||||
// ── dropSpecialTile ────────────────────────────────────────────────────────────
|
||||
console.log('\ndropSpecialTile');
|
||||
const gridS = makeGrid(() => 0.5);
|
||||
|
|
@ -204,10 +175,6 @@ if (bwData) {
|
|||
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
|
||||
ok('playerBaseHp present', bwData.playerBaseHp > 0);
|
||||
ok('milestones array present', Array.isArray(bwData.milestones));
|
||||
const st = bwData.specialTiles;
|
||||
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
|
||||
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
|
||||
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,210 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
// verifyBookwormBoard.js — Bookworm board-quality harness
|
||||
//
|
||||
// Compares the plain weighted-random board (baseline) against the steering
|
||||
// layer (class steering + vowel band + word-completion boost) on:
|
||||
// • static boards — words available, dead boards, vowel share
|
||||
// • simulated sessions — play the longest word each turn, refill, repeat:
|
||||
// words available per turn, dead turns (no word to play), turn survival
|
||||
//
|
||||
// Deterministic (seeded RNG). The dictionary is the same ENABLE list
|
||||
// (3–15 letters) that /words/scrabble/validate accepts, so "available word"
|
||||
// means "word the player can actually submit".
|
||||
//
|
||||
// Run: node tools/verifyBookwormBoard.js
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
GRID_SIZE, makeGrid, clearAndRefill, getAdjacent,
|
||||
} from '../src/games/bookwork/BookworkLogic.js';
|
||||
import {
|
||||
makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER,
|
||||
} from '../src/games/bookwork/BookworkSteering.js';
|
||||
|
||||
const N_BOARDS = 300;
|
||||
const N_SESSIONS = 60;
|
||||
const MAX_TURNS = 30;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(label, cond) {
|
||||
if (cond) { console.log(` ✓ ${label}`); pass++; }
|
||||
else { console.error(` ✗ ${label}`); fail++; }
|
||||
}
|
||||
|
||||
// ── deterministic rng ─────────────────────────────────────────────────────────
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ── word finding (boggle DFS with prefix pruning; one path per word) ─────────
|
||||
function buildPrefixSet(wordSet) {
|
||||
const pre = new Set();
|
||||
for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i));
|
||||
return pre;
|
||||
}
|
||||
|
||||
function findWords(grid, wordSet, prefixSet) {
|
||||
const found = new Map(); // word -> [cells]
|
||||
const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false));
|
||||
const dfs = (r, c, word, cells) => {
|
||||
const w = word + grid[r][c].letter;
|
||||
const isWord = w.length >= 3 && wordSet.has(w);
|
||||
if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]);
|
||||
if (!prefixSet.has(w)) return;
|
||||
if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return;
|
||||
for (const { r: nr, c: nc } of getAdjacent(r, c)) {
|
||||
if (visited[nr][nc]) continue;
|
||||
visited[nr][nc] = true;
|
||||
dfs(nr, nc, w, [...cells, { r, c }]);
|
||||
visited[nr][nc] = false;
|
||||
}
|
||||
};
|
||||
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) {
|
||||
visited[r][c] = true;
|
||||
dfs(r, c, '', []);
|
||||
visited[r][c] = false;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
|
||||
const vowelShare = (grid) =>
|
||||
grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE);
|
||||
|
||||
const quantile = (arr, q) => {
|
||||
const s = [...arr].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
||||
};
|
||||
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length);
|
||||
|
||||
// ── setup ─────────────────────────────────────────────────────────────────────
|
||||
console.log('Loading dictionary…');
|
||||
const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8'));
|
||||
const prefixSet = buildPrefixSet(wordSet);
|
||||
console.log(` ${wordSet.size} words (3–15 letters), ${prefixSet.size} prefixes\n`);
|
||||
|
||||
const variants = {
|
||||
baseline: {
|
||||
make: (rng) => makeGrid(rng),
|
||||
refill: (g, cells, rng) => clearAndRefill(g, cells, rng),
|
||||
},
|
||||
steered: {
|
||||
make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER),
|
||||
refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER),
|
||||
},
|
||||
};
|
||||
|
||||
// ── steering sanity ───────────────────────────────────────────────────────────
|
||||
console.log('Steering sanity');
|
||||
{
|
||||
const rng = mulberry32(1);
|
||||
const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER);
|
||||
ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE));
|
||||
ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter)));
|
||||
ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q'));
|
||||
const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER);
|
||||
ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0);
|
||||
ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0);
|
||||
const letter = pickWeighted(w, mulberry32(7));
|
||||
ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q');
|
||||
const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER);
|
||||
ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter)));
|
||||
}
|
||||
|
||||
// ── static board quality ──────────────────────────────────────────────────────
|
||||
console.log(`\nStatic boards (N=${N_BOARDS} each)`);
|
||||
const staticStats = {};
|
||||
for (const [name, v] of Object.entries(variants)) {
|
||||
const wordCounts = [], shares = [];
|
||||
for (let i = 0; i < N_BOARDS; i++) {
|
||||
const g = v.make(mulberry32(1000 + i));
|
||||
wordCounts.push(findWords(g, wordSet, prefixSet).size);
|
||||
shares.push(vowelShare(g));
|
||||
}
|
||||
staticStats[name] = {
|
||||
wordCounts,
|
||||
dead: wordCounts.filter((n) => n === 0).length,
|
||||
min: Math.min(...wordCounts),
|
||||
p5: quantile(wordCounts, 0.05),
|
||||
median: quantile(wordCounts, 0.5),
|
||||
mean: mean(wordCounts),
|
||||
max: Math.max(...wordCounts),
|
||||
vowelMin: Math.min(...shares),
|
||||
vowelMax: Math.max(...shares),
|
||||
vowelMean: mean(shares),
|
||||
};
|
||||
}
|
||||
|
||||
const row = (label, f) =>
|
||||
console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`);
|
||||
console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14));
|
||||
row('words/board (min)', (n) => staticStats[n].min);
|
||||
row('words/board (p5)', (n) => staticStats[n].p5);
|
||||
row('words/board (median)', (n) => staticStats[n].median);
|
||||
row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1));
|
||||
row('words/board (max)', (n) => staticStats[n].max);
|
||||
row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`);
|
||||
row('vowel share (min–max)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}–${(100 * staticStats[n].vowelMax).toFixed(0)}%`);
|
||||
row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`);
|
||||
|
||||
// ── simulated sessions ────────────────────────────────────────────────────────
|
||||
console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`);
|
||||
const sessionStats = {};
|
||||
for (const [name, v] of Object.entries(variants)) {
|
||||
const totals = [], wpt = [], dead = [], initial = [], longest = [];
|
||||
for (let i = 0; i < N_SESSIONS; i++) {
|
||||
const rng = mulberry32(90000 + i);
|
||||
let grid = v.make(rng);
|
||||
let deadTurn = false, total = 0;
|
||||
for (let t = 0; t < MAX_TURNS; t++) {
|
||||
const words = findWords(grid, wordSet, prefixSet);
|
||||
if (t === 0) initial.push(words.size);
|
||||
if (words.size === 0) { deadTurn = true; break; }
|
||||
total += words.size;
|
||||
wpt.push(words.size);
|
||||
let best = null;
|
||||
for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells };
|
||||
longest.push(best.w.length);
|
||||
grid = v.refill(grid, best.cells, rng);
|
||||
}
|
||||
totals.push(total);
|
||||
if (deadTurn) dead.push(1); else dead.push(0);
|
||||
}
|
||||
sessionStats[name] = {
|
||||
deadRate: mean(dead),
|
||||
totalMedian: quantile(totals, 0.5),
|
||||
totalMean: mean(totals),
|
||||
wptMin: Math.min(...wpt),
|
||||
wptP5: quantile(wpt, 0.05),
|
||||
wptMedian: quantile(wpt, 0.5),
|
||||
initialMedian: quantile(initial, 0.5),
|
||||
longestMax: Math.max(...longest),
|
||||
};
|
||||
}
|
||||
|
||||
row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`);
|
||||
row('initial words (median)', (n) => sessionStats[n].initialMedian);
|
||||
row('words/turn (min)', (n) => sessionStats[n].wptMin);
|
||||
row('words/turn (p5)', (n) => sessionStats[n].wptP5);
|
||||
row('words/turn (median)', (n) => sessionStats[n].wptMedian);
|
||||
row('total words (median)', (n) => sessionStats[n].totalMedian);
|
||||
row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0));
|
||||
row('longest word seen', (n) => sessionStats[n].longestMax);
|
||||
|
||||
// ── comparisons ───────────────────────────────────────────────────────────────
|
||||
console.log('\nComparison');
|
||||
ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median);
|
||||
ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead);
|
||||
ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5);
|
||||
ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate);
|
||||
ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian);
|
||||
ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian);
|
||||
|
||||
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
|
|
@ -1,354 +0,0 @@
|
|||
// Headless verification for Jigsaw.
|
||||
// node tools/verifyJigsaw.js
|
||||
// Exits non-zero on any failure.
|
||||
//
|
||||
// 1. Grid model: seeded determinism, neighbour bounds/symmetry, tab/blank
|
||||
// complementarity on every internal edge.
|
||||
// 2. FIT guarantee: for every internal edge of every difficulty, the two
|
||||
// adjacent pieces trace the *same* shared curve — adjacent pieces physically
|
||||
// mesh when placed in their correct slots. This is the property the
|
||||
// table-join rule ("pieces may only join if they fit on the board") relies
|
||||
// on, so it is checked for ALL grids.
|
||||
// 3. Table assembly (the production resolveDrop): only grid-adjacent pieces
|
||||
// join, whole groups are absorbed, fixpoint chains, the rigid-drag
|
||||
// invariant, board lock (and its precedence over join), and win counting.
|
||||
|
||||
import {
|
||||
DIFFICULTIES, DIFFICULTY_ORDER,
|
||||
makeJigsaw, cellEdgeSpec, cellNeighbours, edgeFragment, resolveDrop,
|
||||
} from '../src/games/jigsaw/JigsawLogic.js';
|
||||
|
||||
let failures = 0;
|
||||
function check(ok, msg) {
|
||||
if (!ok) { failures++; console.error(` ✗ ${msg}`); }
|
||||
return ok;
|
||||
}
|
||||
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
||||
|
||||
// ── 1. Grid model ────────────────────────────────────────────────────────────
|
||||
console.log('Grid model:');
|
||||
{
|
||||
const a = makeJigsaw(6, 5, 1234), b = makeJigsaw(6, 5, 1234), c = makeJigsaw(6, 5, 4321);
|
||||
check(JSON.stringify(a.H) === JSON.stringify(b.H) && JSON.stringify(a.V) === JSON.stringify(b.V),
|
||||
'same seed must give the same knob layout');
|
||||
check(JSON.stringify(a.H) !== JSON.stringify(c.H) || JSON.stringify(a.V) !== JSON.stringify(c.V),
|
||||
'different seeds should give different layouts');
|
||||
|
||||
const jig = makeJigsaw(5, 5, 7);
|
||||
const inB = (r, cc) => r >= 0 && r < 5 && cc >= 0 && cc < 5;
|
||||
let allInRange = true, symmetric = true;
|
||||
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 5; cc++) {
|
||||
for (const [nr, nc] of cellNeighbours(jig, r, cc)) {
|
||||
if (!inB(nr, nc)) allInRange = false;
|
||||
if (!cellNeighbours(jig, nr, nc).some(([pr, pc]) => pr === r && pc === cc)) symmetric = false;
|
||||
}
|
||||
}
|
||||
check(allInRange, 'neighbours never leave the grid');
|
||||
check(symmetric, 'neighbourhood is symmetric');
|
||||
check(cellNeighbours(jig, 0, 0).length === 2, 'corner piece has 2 neighbours');
|
||||
check(cellNeighbours(jig, 0, 2).length === 3, 'edge piece has 3 neighbours');
|
||||
check(cellNeighbours(jig, 2, 2).length === 4, 'interior piece has 4 neighbours');
|
||||
|
||||
// Every internal edge is a tab on exactly one side, blank on the other, and
|
||||
// both sides agree on which way the shared curve bulges.
|
||||
let complement = true;
|
||||
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 4; cc++) {
|
||||
const aL = cellEdgeSpec(jig, r, cc).right, bL = cellEdgeSpec(jig, r, cc + 1).left;
|
||||
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
|
||||
}
|
||||
for (let r = 0; r < 4; r++) for (let cc = 0; cc < 5; cc++) {
|
||||
const aL = cellEdgeSpec(jig, r, cc).bottom, bL = cellEdgeSpec(jig, r + 1, cc).top;
|
||||
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
|
||||
}
|
||||
check(complement, 'every internal edge: tab/blank pair with a common curve side');
|
||||
console.log(' ok');
|
||||
}
|
||||
|
||||
// ── 2. Fit guarantee: adjacent pieces trace the identical shared curve ───────
|
||||
console.log('Fit guarantee (adjacent pieces mesh on the board):');
|
||||
{
|
||||
const sample = (p0, p1, edge, n = 128) => {
|
||||
const pts = [];
|
||||
let cur = { x: p0.x, y: p0.y };
|
||||
for (const c of edgeFragment(p0, p1, edge)) {
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n;
|
||||
let x, y;
|
||||
if (c.t === 'line') { x = cur.x + (c.x - cur.x) * t; y = cur.y + (c.y - cur.y) * t; }
|
||||
else {
|
||||
const m = 1 - t;
|
||||
x = m * m * m * cur.x + 3 * m * m * t * c.c1.x + 3 * m * t * t * c.c2.x + t * t * t * c.x;
|
||||
y = m * m * m * cur.y + 3 * m * m * t * c.c1.y + 3 * m * t * t * c.c2.y + t * t * t * c.y;
|
||||
}
|
||||
pts.push({ x, y });
|
||||
}
|
||||
cur = { x: c.x, y: c.y };
|
||||
}
|
||||
return pts;
|
||||
};
|
||||
// Max distance from every sample of curve A to the closest sample of B (both
|
||||
// ways). Identical curves → near zero (sampling gap only).
|
||||
const maxGap = (A, B) => Math.max(
|
||||
...A.map((p) => Math.min(...B.map((q) => Math.hypot(p.x - q.x, p.y - q.y)))),
|
||||
...B.map((p) => Math.min(...A.map((q) => Math.hypot(p.x - q.x, p.y - q.y))))
|
||||
);
|
||||
|
||||
let edgesChecked = 0, ok = true, worst = 0;
|
||||
const W = 100, H = 100;
|
||||
for (const key of DIFFICULTY_ORDER) {
|
||||
const { cols, rows } = DIFFICULTIES[key];
|
||||
const jig = makeJigsaw(cols, rows, 42);
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols - 1; c++) {
|
||||
// Vertical internal edge between (r,c) [left] and (r,c+1) [right].
|
||||
const xA = c * W, yA = r * H;
|
||||
const p0A = { x: xA + W, y: yA }, p1A = { x: xA + W, y: yA + H };
|
||||
const p0B = { x: xA + W, y: yA + H }, p1B = { x: xA + W, y: yA };
|
||||
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).right);
|
||||
const B = sample(p0B, p1B, cellEdgeSpec(jig, r, c + 1).left);
|
||||
const gap = maxGap(A, B);
|
||||
edgesChecked++; worst = Math.max(worst, gap);
|
||||
if (gap > 2.5) ok = false;
|
||||
}
|
||||
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols; c++) {
|
||||
// Horizontal internal edge between (r,c) [top] and (r+1,c) [bottom].
|
||||
const xA = c * W, yA = r * H;
|
||||
const p0A = { x: xA + W, y: yA + H }, p1A = { x: xA, y: yA + H };
|
||||
const p0B = { x: xA, y: yA + H }, p1B = { x: xA + W, y: yA + H };
|
||||
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).bottom);
|
||||
const B = sample(p0B, p1B, cellEdgeSpec(jig, r + 1, c).top);
|
||||
const gap = maxGap(A, B);
|
||||
edgesChecked++; worst = Math.max(worst, gap);
|
||||
if (gap > 2.5) ok = false;
|
||||
}
|
||||
}
|
||||
check(ok, `all ${edgesChecked} internal edges on all difficulties mesh exactly (worst gap ${worst.toFixed(3)}px)`);
|
||||
console.log(` ok — ${edgesChecked} internal edges checked, worst deviation ${worst.toFixed(4)}px`);
|
||||
}
|
||||
|
||||
// ── 3. Table assembly (production resolveDrop) ───────────────────────────────
|
||||
console.log('Table assembly (piece joining / group lock):');
|
||||
{
|
||||
const SNAP = 0.42; // same SNAP_FRAC as the scene
|
||||
const boardOrigin = { x: 1000, y: 1000 };
|
||||
const cell = 100;
|
||||
|
||||
function makeBoard(cols = 5, rows = 5, seed = 7) {
|
||||
const jig = makeJigsaw(cols, rows, seed);
|
||||
const pieces = [];
|
||||
const grid = [];
|
||||
for (let r = 0; r < rows; r++) grid.push(new Array(cols));
|
||||
let i = 0;
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++, i++) {
|
||||
const p = {
|
||||
r, c,
|
||||
home: { x: boardOrigin.x + (c + 0.5) * cell, y: boardOrigin.y + (r + 0.5) * cell },
|
||||
// Scattered on the table, well clear of the board and of each other.
|
||||
pos: { x: 50 + i * 140, y: 5000 + (i % 6) * 120 },
|
||||
placed: false, group: null,
|
||||
};
|
||||
const g = { pieces: [p] };
|
||||
p.group = g;
|
||||
pieces.push(p);
|
||||
grid[r][c] = p;
|
||||
}
|
||||
return {
|
||||
jig, pieces, grid,
|
||||
groups: new Set(pieces.map((p) => p.group)),
|
||||
placed: 0, total: pieces.length,
|
||||
cellAt: (r, c) => (r >= 0 && r < rows && c >= 0 && c < cols) ? grid[r][c] : null,
|
||||
};
|
||||
}
|
||||
const at = (bd, r, c) => bd.grid[r][c];
|
||||
|
||||
// Mirror of JigsawGame.handleDrop: apply the decided positions, then do the
|
||||
// (sound/nudge-free) scene bookkeeping.
|
||||
function handleDrop(bd, group, anchor) {
|
||||
const res = resolveDrop(bd.jig, group, bd.cellAt, cell * SNAP);
|
||||
for (const pl of res.placements) pl.piece.pos = { x: pl.x, y: pl.y };
|
||||
if (res.outcome === 'locked') {
|
||||
const locked = group.pieces.filter((m) => !m.placed);
|
||||
locked.forEach((m) => { m.placed = true; });
|
||||
bd.groups.delete(group);
|
||||
bd.placed += locked.length;
|
||||
} else if (res.outcome === 'joined') {
|
||||
for (const g of res.absorbedGroups) {
|
||||
bd.groups.delete(g);
|
||||
for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); }
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Mirror of JigsawGame.onPointerMove: rigid group drag around the anchor.
|
||||
function dragGroup(bd, group, anchor, to) {
|
||||
for (const m of group.pieces) m.pos = { x: to.x + (m.home.x - anchor.home.x), y: to.y + (m.home.y - anchor.home.y) };
|
||||
}
|
||||
const invariantHolds = (group) => group.pieces.every((m) => group.pieces.every((a) =>
|
||||
approx(m.pos.x, a.pos.x + m.home.x - a.home.x) && approx(m.pos.y, a.pos.y + m.home.y - a.home.y)));
|
||||
|
||||
// 3a. resolveDrop is pure: no mutation before the scene applies the result.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1);
|
||||
A.pos = { x: 200, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 20, y: A.pos.y + (B.home.y - A.home.y) - 15 };
|
||||
const g0 = [...A.group.pieces];
|
||||
const res = resolveDrop(bd.jig, A.group, bd.cellAt, cell * SNAP);
|
||||
check(res.outcome === 'joined', '3a: adjacent pair within snap radius joins');
|
||||
check(res.placements.length === 1 && res.placements[0].piece === B, '3a: only the absorbed piece is placed');
|
||||
check(A.group.pieces.length === 1 && A.group.pieces[0] === A, '3a: group not mutated by resolveDrop');
|
||||
check(B.pos.x === 200 + (B.home.x - A.home.x) + 20, '3a: piece positions not mutated by resolveDrop');
|
||||
const m = res.placements[0];
|
||||
B.pos = { x: m.x, y: m.y }; // scene-side application
|
||||
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(B.pos.y, A.pos.y + (B.home.y - A.home.y)),
|
||||
'3a: absorbed piece snaps to the exact mesh offset');
|
||||
}
|
||||
|
||||
// 3b. Pieces that cannot both sit on the board never join — even when
|
||||
// dropped dead-on their (hypothetical) meshing offset.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), C = at(bd, 0, 2);
|
||||
A.pos = { x: 300, y: 500 };
|
||||
C.pos = { x: A.pos.x + 2 * cell, y: A.pos.y }; // exact 2-cell offset, zero error
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'rested', '3b: non-adjacent pieces never join (rests instead)');
|
||||
check(A.group.pieces.length === 1, '3b: group stays a singleton');
|
||||
}
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), D = at(bd, 1, 1);
|
||||
A.pos = { x: 300, y: 500 };
|
||||
D.pos = { x: A.pos.x + cell * 0.7, y: A.pos.y + cell * 0.7 }; // sitting on top, diagonally
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'rested', '3b: diagonal pieces never join even when overlapping');
|
||||
}
|
||||
|
||||
// 3c. A chain of correctly placed pieces latches on in ONE drop (fixpoint).
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1); // C neighbour of B only
|
||||
A.pos = { x: 200, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 10, y: A.pos.y + (B.home.y - A.home.y) };
|
||||
C.pos = { x: B.pos.x + (C.home.x - B.home.x) - 8, y: B.pos.y + (C.home.y - B.home.y) };
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'joined', '3c: chain joins in one drop');
|
||||
check(B.group === A.group && C.group === A.group && A.group.pieces.length === 3, '3c: all three in one group');
|
||||
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(C.pos.x, A.pos.x + (C.home.x - A.home.x)),
|
||||
'3c: every member snaps to the exact mesh offset');
|
||||
check(invariantHolds(A.group), '3c: group invariant holds after join');
|
||||
}
|
||||
|
||||
// 3d. A pre-joined group is absorbed WHOLE when a neighbour drops beside it.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1);
|
||||
// First: join B+C on the table.
|
||||
B.pos = { x: 400, y: 600 };
|
||||
C.pos = { x: B.pos.x + (C.home.x - B.home.x) + 5, y: B.pos.y + (C.home.y - B.home.y) };
|
||||
const r1 = handleDrop(bd, B.group, B);
|
||||
check(r1.outcome === 'joined' && B.group.pieces.length === 2, '3d: B+C joined first');
|
||||
// Then: drop A next to B → the whole B+C group comes along.
|
||||
const Bg = B.group;
|
||||
A.pos = { x: B.pos.x - (B.home.x - A.home.x) - 12, y: B.pos.y + 9 };
|
||||
const r2 = handleDrop(bd, A.group, A);
|
||||
check(r2.outcome === 'joined', '3d: A dropped beside the pair joins it');
|
||||
check(B.group === A.group && A.group.pieces.length === 3, '3d: the whole pre-joined group was absorbed');
|
||||
check(invariantHolds(A.group), '3d: group invariant holds after whole-group absorption');
|
||||
check(bd.groups.has(A.group) && !bd.groups.has(Bg), '3d: group registry stays consistent');
|
||||
}
|
||||
|
||||
// 3e. Groups drag rigidly: every member keeps its exact home offset.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 2, 2), B = at(bd, 2, 3), C = at(bd, 1, 2);
|
||||
A.pos = { x: 250, y: 520 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||
C.pos = { x: A.pos.x + (C.home.x - A.home.x), y: A.pos.y + (C.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A); // B and C both within snap → 3-piece group
|
||||
check(A.group.pieces.length === 3, '3e: three-piece group formed');
|
||||
dragGroup(bd, A.group, B, { x: 700, y: 900 }); // grab a non-first member
|
||||
check(approx(A.pos.x, 700 + (A.home.x - B.home.x)) && approx(C.pos.y, 900 + (C.home.y - B.home.y)),
|
||||
'3e: dragging any member moves the whole group rigidly');
|
||||
check(invariantHolds(A.group), '3e: invariant preserved by drag');
|
||||
}
|
||||
|
||||
// 3f. Board lock: any member aligned ⇒ the whole group lands on the board.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 2, 2), B = at(bd, 2, 3);
|
||||
A.pos = { x: 250, y: 520 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A);
|
||||
check(A.group.pieces.length === 2, '3f: pair formed');
|
||||
// Drag the pair (grabbing B, the "far" member) so B lands within snap of home.
|
||||
dragGroup(bd, A.group, B, { x: B.home.x - 14, y: B.home.y + 10 });
|
||||
const res = handleDrop(bd, A.group, B);
|
||||
check(res.outcome === 'locked', '3f: group locks when aligned with the board');
|
||||
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x) && approx(B.pos.y, B.home.y),
|
||||
'3f: every member lands exactly on its home slot');
|
||||
check(A.placed && B.placed && bd.placed === 2, '3f: both members count as placed');
|
||||
check(!bd.groups.has(A.group), '3f: locked group retired from the table');
|
||||
}
|
||||
|
||||
// 3g. Lock takes precedence over join.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 2, 2), B = at(bd, 2, 3), D = at(bd, 3, 2);
|
||||
A.pos = { x: 250, y: 520 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A);
|
||||
// D sits exactly where it would mesh under A (joinable)…
|
||||
D.pos = { x: A.pos.x + (D.home.x - A.home.x), y: A.pos.y + (D.home.y - A.home.y) };
|
||||
// …but the A+B pair is also aligned with the board.
|
||||
dragGroup(bd, A.group, A, { x: A.home.x + 8, y: A.home.y - 6 });
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'locked', '3g: board lock wins over a possible join');
|
||||
check(D.group.pieces.length === 1 && !D.placed, '3g: the joinable piece was NOT absorbed');
|
||||
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x), '3g: group landed on the board');
|
||||
}
|
||||
|
||||
// 3h. Outside the snap radius: nothing joins, positions untouched.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 1, 1), B = at(bd, 1, 2);
|
||||
A.pos = { x: 300, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 60, y: A.pos.y }; // 60 > 42 snap
|
||||
const Bdrop = { ...B.pos }; // where the drop LEFT it
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'rested', '3h: beyond snap radius the drop rests');
|
||||
check(approx(B.pos.x, Bdrop.x) && approx(B.pos.y, Bdrop.y), '3h: unjoined piece keeps its dropped position');
|
||||
}
|
||||
|
||||
// 3i. Placed pieces are ignored by joining.
|
||||
{
|
||||
const bd = makeBoard();
|
||||
const A = at(bd, 1, 1), B = at(bd, 1, 2), D = at(bd, 2, 1);
|
||||
D.pos = { x: D.home.x, y: D.home.y }; D.placed = true; // already on the board
|
||||
A.pos = { x: 300, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 4, y: A.pos.y + (B.home.y - A.home.y) };
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'joined' && A.group.pieces.length === 2, '3i: unplaced neighbour still joins');
|
||||
check(!A.group.pieces.includes(D) && D.placed, '3i: placed piece is never absorbed');
|
||||
}
|
||||
|
||||
// 3j. Win bookkeeping: locking the final pieces reaches the total.
|
||||
{
|
||||
const bd = makeBoard(2, 1, 11); // 2 pieces, one internal edge
|
||||
const A = at(bd, 0, 0), B = at(bd, 0, 1);
|
||||
A.pos = { x: 200, y: 500 };
|
||||
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 6, y: A.pos.y + (B.home.y - A.home.y) };
|
||||
handleDrop(bd, A.group, A);
|
||||
check(A.group.pieces.length === 2, '3j: pair formed');
|
||||
dragGroup(bd, A.group, A, { x: A.home.x, y: A.home.y });
|
||||
const res = handleDrop(bd, A.group, A);
|
||||
check(res.outcome === 'locked' && bd.placed === bd.total, '3j: locking the pair completes the board');
|
||||
}
|
||||
|
||||
console.log(' ok');
|
||||
}
|
||||
|
||||
if (failures) {
|
||||
console.error(`\nFAILED: ${failures} check(s).`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('\nAll Jigsaw checks passed.');
|
||||
|
|
@ -56,19 +56,12 @@ import * as Gnn from '../src/games/mastervega/VegaGnn.js';
|
|||
import { shipVideoKey, hasShipVideo } from '../src/games/mastervega/VegaShipMedia.js';
|
||||
// Dependency-free, so what the game room eagerly pulls is checkable here.
|
||||
import { resolveGameAssets } from '../src/data/assetManifest.js';
|
||||
// Guided-tutorial data: schema/interpolation/target-id registry are Phaser-free
|
||||
// (VegaTutorial.js is the Phaser half), so the script itself is checkable here.
|
||||
import {
|
||||
validateTutorialData, resolveSteps, interpolate, placeholdersIn,
|
||||
TUTORIAL_TARGET_IDS, ADVANCE_MODES,
|
||||
} from '../src/games/mastervega/VegaTutorialData.js';
|
||||
|
||||
const QUICK = process.argv.includes('--quick');
|
||||
const gamesArg = process.argv.find((a) => a.startsWith('--games='));
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const rulesJson = JSON.parse(readFileSync(join(root, 'data/mastervega-rules.json'), 'utf8'));
|
||||
const artJson = JSON.parse(readFileSync(join(root, 'data/mastervega-artwork.json'), 'utf8'));
|
||||
const tutorialJson = JSON.parse(readFileSync(join(root, 'data/mastervega-tutorial.json'), 'utf8'));
|
||||
|
||||
let failures = 0;
|
||||
let passes = 0;
|
||||
|
|
@ -5932,82 +5925,6 @@ section('11. Combat V2 (per-ship prototype)');
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
section('12. Tutorial data');
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const { ok, errors } = validateTutorialData(tutorialJson);
|
||||
check('mastervega-tutorial.json passes schema validation', ok, errors.join('; '));
|
||||
check('tutorial version is 1', tutorialJson.version === 1, `${tutorialJson.version}`);
|
||||
|
||||
const steps = Array.isArray(tutorialJson.steps) ? tutorialJson.steps : [];
|
||||
check('tutorial has at least one step', steps.length > 0);
|
||||
check('tutorial step ids are unique and non-empty',
|
||||
new Set(steps.map((s) => s.id)).size === steps.length && steps.every((s) => typeof s.id === 'string' && s.id.trim()));
|
||||
check('every tutorial step kind is modal or callout',
|
||||
steps.every((s) => s.kind === 'modal' || s.kind === 'callout'));
|
||||
// A modal can advance without a button of its own — 'external' steps (a
|
||||
// System View / Colony View the tutorial opened directly wires its own
|
||||
// onClose/onColonyOpen straight to advance()) have none by design; the
|
||||
// general "every buttonless step can still be advanced" check below still
|
||||
// catches a modal that has neither.
|
||||
check('every modal step has a non-empty body',
|
||||
steps.filter((s) => s.kind === 'modal').every((s) => typeof s.body === 'string' && s.body.trim()));
|
||||
check('every callout step has calloutText and a valid anchor',
|
||||
steps.filter((s) => s.kind === 'callout').every((s) =>
|
||||
typeof s.calloutText === 'string' && s.calloutText.trim() && TUTORIAL_TARGET_IDS.includes(s.anchor)));
|
||||
check('every highlight id is a known tutorial target',
|
||||
steps.every((s) => (s.highlights ?? []).every((h) => TUTORIAL_TARGET_IDS.includes(h))));
|
||||
check('every button action is next/back/skip/finish',
|
||||
steps.every((s) => (s.buttons ?? []).every((b) => ['next', 'back', 'skip', 'finish'].includes(b.action))));
|
||||
check('every advanceOn (when set) is a known mode',
|
||||
steps.every((s) => s.advanceOn === undefined || ADVANCE_MODES.includes(s.advanceOn)));
|
||||
check('every buttonless step can still be advanced (advanceOn set)',
|
||||
steps.every((s) => (s.buttons ?? []).length > 0 || ADVANCE_MODES.includes(s.advanceOn)),
|
||||
steps.filter((s) => !(s.buttons ?? []).length && !ADVANCE_MODES.includes(s.advanceOn)).map((s) => s.id).join(','));
|
||||
|
||||
// Every non-null voice clip resolves on disk (same streamed-from-assets/speech
|
||||
// contract as the species clips checked in section 2).
|
||||
for (const s of steps) {
|
||||
if (!s.voice) continue;
|
||||
check(`tutorial step "${s.id}" voice clip exists`,
|
||||
existsSync(join(root, 'assets/speech', `${s.voice}.mp3`)), `${s.voice}.mp3`);
|
||||
}
|
||||
check('a tutorial step uses the shipped intro clip vega/tutorial-intro-01',
|
||||
steps.some((s) => s.voice === 'vega/tutorial-intro-01'));
|
||||
|
||||
// Every {token} in visible text is declared in the top-level vars allow-list.
|
||||
const vars = tutorialJson.vars ?? [];
|
||||
for (const s of steps) {
|
||||
for (const field of ['title', 'body', 'calloutText']) {
|
||||
for (const tok of placeholdersIn(s[field])) {
|
||||
check(`tutorial step "${s.id}" ${field} placeholder {${tok}} is declared in vars`, vars.includes(tok));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// interpolate substitutes and leaves nothing behind.
|
||||
const sample = interpolate('the {species} thrive', { species: 'Humans' });
|
||||
check('interpolate substitutes a declared token', sample === 'the Humans thrive', sample);
|
||||
const resolved = resolveSteps(tutorialJson, { species: 'Humans' });
|
||||
check('resolveSteps leaves no {token} in any visible field',
|
||||
resolved.every((s) => !['title', 'body', 'calloutText']
|
||||
.some((f) => typeof s[f] === 'string' && /\{[a-zA-Z0-9_]+\}/.test(s[f]))));
|
||||
|
||||
// The lazy manifest exposes the file so scene.cache.json.get('mastervega-tutorial') works.
|
||||
{
|
||||
const stub = {
|
||||
cache: { json: { get: (k) => (k === 'mastervega-tutorial' ? tutorialJson : null) } },
|
||||
textures: { exists: () => false },
|
||||
};
|
||||
const eager = resolveGameAssets(stub, 'mastervega');
|
||||
check('mastervega-tutorial.json is in the lazy asset manifest',
|
||||
eager.some((d) => d.type === 'json' && d.key === 'mastervega-tutorial'));
|
||||
}
|
||||
|
||||
check('UI_SPEECH declares the tutorial intro clip', UI_SPEECH.tutorialIntro === 'vega/tutorial-intro-01');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
console.log(`\n${passes} passed, ${failures} failed`);
|
||||
if (failures > 0) process.exit(1);
|
||||
|
|
|
|||
Loading…
Reference in New Issue