Compare commits
19 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
858dc9535d | |
|
|
52eaff8b96 | |
|
|
44de2fb049 | |
|
|
e3d2fdee34 | |
|
|
3c2cf462ed | |
|
|
93db9ef9dc | |
|
|
ae30c66a3a | |
|
|
429102105b | |
|
|
1298648e97 | |
|
|
74cd4df85b | |
|
|
cbec3b3a44 | |
|
|
f2108e6258 | |
|
|
4ecfcaee50 | |
|
|
e319f41ce8 | |
|
|
97adfa642d | |
|
|
18d89d4553 | |
|
|
0c59a292a3 | |
|
|
243d2e143c | |
|
|
c29f33e2a3 |
|
|
@ -0,0 +1,5 @@
|
|||
# Agent notes
|
||||
|
||||
## Git
|
||||
- **The user makes their own commits.** Leave changes in the working tree; do not
|
||||
run `git commit` (or `git push`) unless explicitly asked.
|
||||
|
|
@ -88,7 +88,7 @@ menu categories:
|
|||
| **Tabletop** | 21 | Backgammon, Chess, Checkers, Go, Othello, Settlers of Catan, Ticket to Ride, Risk, Monopoly, Blokus, Labyrinth, Mahjong, Stratego, Battleship, Mastermind, Connect 4, Forbidden Island, Azul, Chinese Checkers, Mexican Train, Parchisi |
|
||||
| **Cards** | 18 | Cribbage, Gin Rummy, Rummikub, Canasta, Hearts, Uno, Phase 10, Skip-Bo, Go Fish, Old Maid, Nerts, Dominion, Splendor, Freecell, Solitaire Tour, Spire Climb, Zahtzee, Farkle |
|
||||
| **Casino** | 9 | Blackjack, Texas Hold 'Em, Baccarat, Pai Gow Poker, Video Poker, Craps, Roulette, Bingo, Slot Machines |
|
||||
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookwork, Kiitos, Tri-Ominoes, Jumble |
|
||||
| **Word** | 15 | Wordle Race, Scrabble, Boggle, Ghost, Word Ladder, Word Search, Hangman, Spelling Bee, Sudoku, Mini Crossword, Tectonic, Bookworm, Kiitos, Tri-Ominoes, Jumble |
|
||||
| **Logic & Puzzle** | 14 | 2048, Rush Hour, Hexsweeper, Jell-o Monsters, Shift, Mahjong Match, Jewel Quest, Zuma, Bejeweled Blitz, Mini Motorways, Dot Link, Katamino, Genius Square, Block Fighter |
|
||||
| **Arcade, Console & PC** | 2 | Colorado Defense, Star Control |
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.3 MiB |
Binary file not shown.
|
|
@ -1,5 +1,18 @@
|
|||
{
|
||||
"playerBaseHp": 100,
|
||||
"steer": {
|
||||
"wordBoostK": 1.0,
|
||||
"classThreshold": 2,
|
||||
"classDamp": 0.35,
|
||||
"classBoost": 1.8,
|
||||
"vowelBand": [0.30, 0.45]
|
||||
},
|
||||
"specialTiles": {
|
||||
"startLevel": 1,
|
||||
"endLevel": 10,
|
||||
"start": { "gold": 0.10, "diamond": 0.06 },
|
||||
"end": { "gold": 0.03, "diamond": 0.02 }
|
||||
},
|
||||
"milestones": [
|
||||
{ "afterLevel": 5, "maxHpBonus": 10, "unlock": "potion" },
|
||||
{ "afterLevel": 10, "maxHpBonus": 10 },
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"default": "back-0",
|
||||
"cardBacks": [
|
||||
{ "id": "back-0", "name": "Card Back 1", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-1", "name": "Card Back 2", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-2", "name": "Card Back 3", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-3", "name": "Card Back 4", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-4", "name": "Card Back 5", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-5", "name": "Card Back 6", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-6", "name": "Card Back 7", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-7", "name": "Card Back 8", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
|
||||
{ "id": "back-0", "name": "Cthulhu", "key": "cardbacks", "spriteIndex": 0, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-1", "name": "Cyberpunk", "key": "cardbacks", "spriteIndex": 1, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-2", "name": "Nautical", "key": "cardbacks", "spriteIndex": 2, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-3", "name": "Steampunk", "key": "cardbacks", "spriteIndex": 3, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-4", "name": "Jungle", "key": "cardbacks", "spriteIndex": 4, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-5", "name": "Hi Tech", "key": "cardbacks", "spriteIndex": 5, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-6", "name": "Fall Mountain", "key": "cardbacks", "spriteIndex": 6, "fallbackColor": "#1a3a6b" },
|
||||
{ "id": "back-7", "name": "Neon Miami", "key": "cardbacks", "spriteIndex": 7, "fallbackColor": "#1a3a6b" }
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,5 +9,8 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ registerGame({ slug: 'ginrummy', name: 'Gin Rummy', category: 'cards', cardGame:
|
|||
registerGame({ slug: 'risk', name: 'Risk', category: 'tabletop', minPlayers: 2, maxPlayers: 6, minOpponents: 1, maxOpponents: 5, defaultOpponents: 3, hasTutorial: true, iconFrame: 54, defaultPlayfield: 'combat' });
|
||||
registerGame({ slug: 'geniussquare', name: 'Genius Square', category: 'logic', minPlayers: 1, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 70 });
|
||||
registerGame({ slug: 'katamino', name: 'Katamino', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 71 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookwork', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'bookwork', name: 'Bookworm', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 72 });
|
||||
registerGame({ slug: 'paigow', name: 'Pai Gow Poker', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, defaultOpponents: 4, iconFrame: 73 });
|
||||
registerGame({ slug: 'spireclimb', name: 'Spire Climb', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 74 });
|
||||
registerGame({ slug: 'azul', name: 'Azul', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, defaultOpponents: 3, hasTutorial: true, iconFrame: 75, defaultPlayfield: 'cherry' });
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import {
|
|||
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
|
||||
wordFromCells, computeDamage, computeSelfDamage,
|
||||
clearAndRefill, dropSpecialTile, countPoisonTiles,
|
||||
computeMaxHp, isPotionUnlocked,
|
||||
computeMaxHp, isPotionUnlocked, specialTileChances,
|
||||
} from './BookworkLogic.js';
|
||||
import { getAttackDamage, getSpecialTile } from './BookworkAI.js';
|
||||
import { makeSteeredGrid, refillSteered, parseWordList } from './BookworkSteering.js';
|
||||
|
||||
const CELL = 96;
|
||||
const GRID_W = CELL * GRID_SIZE;
|
||||
|
|
@ -39,7 +40,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
constructor() { super('BookworkGame'); }
|
||||
|
||||
init(data) {
|
||||
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookwork' };
|
||||
this.gameDef = data.game ?? { slug: 'bookwork', name: 'Bookworm' };
|
||||
this.config = { playerBaseHp: 100, milestones: [], levels: [] };
|
||||
this.bank = [];
|
||||
this.roster = [];
|
||||
|
|
@ -56,6 +57,9 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.bgTex = null;
|
||||
// Battle state
|
||||
this.grid = null;
|
||||
this.wordSet = null; // steering dictionary (ENABLE words 3–15); null → unsteered
|
||||
this.steerOpts = {};
|
||||
this.specialSpawn = { goldChance: 0.03, diamondChance: 0.02 };
|
||||
this.tileObjs = null;
|
||||
this.selection = [];
|
||||
this.selGraphics = null;
|
||||
|
|
@ -85,8 +89,16 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
|
||||
const raw = this.cache.json.get('bookwork');
|
||||
if (raw) this.config = raw;
|
||||
this.steerOpts = this.config.steer ?? {};
|
||||
this.bank = (this.config.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||||
|
||||
// Load the steering dictionary (same ENABLE list the word validator uses).
|
||||
// Non-fatal: if it fails, boards fall back to plain weighted-random.
|
||||
try {
|
||||
const res = await fetch('data/wordlists/enable1.txt');
|
||||
if (res.ok) this.wordSet = parseWordList(await res.text());
|
||||
} catch (_) { this.wordSet = null; }
|
||||
|
||||
try {
|
||||
const res = await fetch('data/opponents.json');
|
||||
const json = await res.json();
|
||||
|
|
@ -279,7 +291,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.clearLayer();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
const title = this.add.text(cx, 84, 'BOOKWORK', {
|
||||
const title = this.add.text(cx, 84, 'BOOKWORM', {
|
||||
fontFamily: 'Righteous', fontSize: '64px', color: COLORS.goldHex,
|
||||
}).setOrigin(0.5);
|
||||
const sub = this.add.text(cx, 138, 'Spell words from the letter grid to battle your way through 20 opponents.', {
|
||||
|
|
@ -514,6 +526,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.level = level;
|
||||
this.levelDef = lv;
|
||||
this.opponent = this.opponentFor(lv);
|
||||
this.specialSpawn = specialTileChances(level, this.config.specialTiles ?? {});
|
||||
this.clearLayer();
|
||||
|
||||
this.playerMaxHp = computeMaxHp(this.config, this.levelsCompleted);
|
||||
|
|
@ -523,7 +536,7 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
this.potionUnlocked = isPotionUnlocked(this.config, this.levelsCompleted);
|
||||
this.potionUsed = false;
|
||||
this.turnPhase = 'player';
|
||||
this.grid = makeGrid();
|
||||
this.grid = this.wordSet ? makeSteeredGrid(Math.random, this.wordSet, this.steerOpts) : makeGrid();
|
||||
this.selection = [];
|
||||
|
||||
this.drawBattleUI();
|
||||
|
|
@ -832,7 +845,9 @@ export default class BookworkGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
// Refill grid
|
||||
this.grid = clearAndRefill(this.grid, cells);
|
||||
this.grid = this.wordSet
|
||||
? refillSteered(this.grid, cells, Math.random, this.wordSet, { ...this.steerOpts, ...this.specialSpawn })
|
||||
: clearAndRefill(this.grid, cells, Math.random, this.specialSpawn);
|
||||
await this.animateRefill(cells);
|
||||
this.redrawAllTiles();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
export const GRID_SIZE = 5;
|
||||
|
||||
// Weighted letter pool tuned for playability (vowel-rich, rare letters suppressed)
|
||||
const LETTER_WEIGHTS = {
|
||||
export const LETTER_WEIGHTS = {
|
||||
A:9, B:2, C:3, D:4, E:13, F:2, G:2, H:3, I:8, J:1, K:2,
|
||||
L:4, M:3, N:6, O:7, P:2, R:6, S:5, T:7, U:4, V:2, W:2,
|
||||
X:1, Y:3, Z:1,
|
||||
|
|
@ -16,6 +16,29 @@ export function randomLetter(rng = Math.random) {
|
|||
return LETTER_POOL[Math.floor(rng() * LETTER_POOL.length)];
|
||||
}
|
||||
|
||||
// Default spawn chances for multiplier tiles on refills (late-game floor).
|
||||
export const SPECIAL_TILE_CHANCES = { gold: 0.03, diamond: 0.02 };
|
||||
|
||||
// Level schedule for multiplier-tile spawn chances. Interpolates linearly
|
||||
// from cfg.start at cfg.startLevel down to cfg.end at cfg.endLevel, then
|
||||
// holds cfg.end. Returns { goldChance, diamondChance }.
|
||||
// cfg = { startLevel: 1, endLevel: 10,
|
||||
// start: { gold: 0.10, diamond: 0.06 },
|
||||
// end: { gold: 0.03, diamond: 0.02 } }
|
||||
export function specialTileChances(level, cfg = {}) {
|
||||
const start = cfg.start ?? SPECIAL_TILE_CHANCES;
|
||||
const end = cfg.end ?? SPECIAL_TILE_CHANCES;
|
||||
const from = cfg.startLevel ?? 1;
|
||||
const to = cfg.endLevel ?? from;
|
||||
const t = to > from ? Math.max(0, Math.min(1, (level - from) / (to - from))) : 0;
|
||||
if (t <= 0) return { goldChance: start.gold, diamondChance: start.diamond };
|
||||
if (t >= 1) return { goldChance: end.gold, diamondChance: end.diamond };
|
||||
return {
|
||||
goldChance: start.gold + (end.gold - start.gold) * t,
|
||||
diamondChance: start.diamond + (end.diamond - start.diamond) * t,
|
||||
};
|
||||
}
|
||||
|
||||
export function makeGrid(rng = Math.random) {
|
||||
const grid = [];
|
||||
for (let r = 0; r < GRID_SIZE; r++) {
|
||||
|
|
@ -69,9 +92,12 @@ export function computeSelfDamage(cells, grid) {
|
|||
return cells.filter(({ r, c }) => grid[r][c].type === 'fire').length * 5;
|
||||
}
|
||||
|
||||
// Cascade tiles down in each column, fill top with new random tiles
|
||||
export function clearAndRefill(grid, usedCells, rng = Math.random) {
|
||||
// Cascade tiles down in each column, fill top with new random tiles.
|
||||
// opts may override spawn chances: { goldChance, diamondChance }.
|
||||
export function clearAndRefill(grid, usedCells, rng = Math.random, opts = {}) {
|
||||
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
|
||||
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
|
||||
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
|
||||
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
|
||||
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
|
|
@ -82,8 +108,8 @@ export function clearAndRefill(grid, usedCells, rng = Math.random) {
|
|||
}
|
||||
// Fill remainder with new normal tiles
|
||||
while (survive.length < GRID_SIZE) {
|
||||
const gold = rng() < 0.03;
|
||||
const diamond = !gold && rng() < 0.02;
|
||||
const gold = rng() < goldChance;
|
||||
const diamond = !gold && rng() < diamondChance;
|
||||
const type = gold ? 'gold' : diamond ? 'diamond' : 'normal';
|
||||
survive.push({ letter: randomLetter(rng), type });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
// BookworkSteering.js — context-aware letter placement for Bookworm
|
||||
//
|
||||
// The base engine (BookworkLogic.js) draws every letter independently from a
|
||||
// weighted pool. That works, but it occasionally produces "dead" boards (no
|
||||
// 3+ letter word findable) and vowel/consonant clumps, and those clumps
|
||||
// persist through refills.
|
||||
//
|
||||
// This module layers a steering policy on the SAME pool: when a letter is
|
||||
// chosen for a specific cell, each candidate's weight is adjusted by:
|
||||
// 1. class steering — damp the class (vowel/consonant) already
|
||||
// over-represented among the cell's filled neighbours
|
||||
// 2. vowel band — keep the board-wide vowel share inside a target band
|
||||
// 3. word boost — boost letters that complete real 3-letter words given
|
||||
// the letters already placed around this cell
|
||||
//
|
||||
// Design notes:
|
||||
// • Sampling, never argmax — boards stay varied and the injected-rng
|
||||
// testability of BookworkLogic is preserved.
|
||||
// • No full words are pre-placed. Steering only biases a single letter by
|
||||
// what is already on the board, so the "word-friendly" property is
|
||||
// re-established inductively after every refill — the board stays good
|
||||
// as the game is played, not just at deal time.
|
||||
// • With `wordSet` null every helper degrades to the plain weighted pool.
|
||||
|
||||
import { GRID_SIZE, LETTER_WEIGHTS, randomLetter, SPECIAL_TILE_CHANCES, getAdjacent, isAdjacent } from './BookworkLogic.js';
|
||||
|
||||
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
|
||||
|
||||
export const DEFAULT_STEER = {
|
||||
wordBoostK: 1.0, // weight multiplier per word completed: w *= (1 + K * count)
|
||||
classThreshold: 2, // neighbour class imbalance (|vowels - consonants|) that triggers steering
|
||||
classDamp: 0.35, // multiplier applied to the over-represented class
|
||||
classBoost: 1.8, // multiplier applied to the under-represented class
|
||||
vowelBand: [0.30, 0.45], // target band for board-wide vowel share
|
||||
bandMinFilled: 10, // only apply the vowel band once this many cells are filled
|
||||
};
|
||||
|
||||
// Parse an ENABLE-style word list into a Set of A-Z words within [minLen, maxLen].
|
||||
// 3–15 matches the /words/scrabble/validate dictionary (ENABLE, 2–15 letters)
|
||||
// and the game's minimum playable word length.
|
||||
export function parseWordList(text, minLen = 3, maxLen = 15) {
|
||||
const set = new Set();
|
||||
for (const raw of text.split('\n')) {
|
||||
const w = raw.trim().toUpperCase();
|
||||
if (w.length >= minLen && w.length <= maxLen && /^[A-Z]+$/.test(w)) set.add(w);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
// Context-adjusted weight for every candidate letter at grid[r][c].
|
||||
// `grid` may contain unfilled cells ({letter: null}) — they are ignored.
|
||||
export function letterWeights(grid, r, c, wordSet = null, opts = {}) {
|
||||
const o = { ...DEFAULT_STEER, ...opts };
|
||||
|
||||
// ── class steering: neighbour balance ───────────────────────────────
|
||||
let v = 0, k = 0;
|
||||
for (const nb of getAdjacent(r, c)) {
|
||||
const l = grid[nb.r][nb.c]?.letter;
|
||||
if (!l) continue;
|
||||
if (VOWELS.has(l)) v++; else k++;
|
||||
}
|
||||
let vowelMult = 1, consonantMult = 1;
|
||||
if (o.classSteer !== false) {
|
||||
if (v >= k + o.classThreshold) { vowelMult = o.classDamp; consonantMult = o.classBoost; }
|
||||
else if (k >= v + o.classThreshold) { consonantMult = o.classDamp; vowelMult = o.classBoost; }
|
||||
}
|
||||
|
||||
// ── vowel band: board-wide share ────────────────────────────────────
|
||||
if (o.vowelBand) {
|
||||
let filled = 0, vTotal = 0;
|
||||
for (let rr = 0; rr < GRID_SIZE; rr++) {
|
||||
for (let cc = 0; cc < GRID_SIZE; cc++) {
|
||||
if (rr === r && cc === c) continue;
|
||||
const l = grid[rr][cc]?.letter;
|
||||
if (!l) continue;
|
||||
filled++;
|
||||
if (VOWELS.has(l)) vTotal++;
|
||||
}
|
||||
}
|
||||
if (filled >= o.bandMinFilled) {
|
||||
const share = vTotal / filled;
|
||||
if (share > o.vowelBand[1]) { vowelMult *= o.classDamp; consonantMult *= o.classBoost; }
|
||||
else if (share < o.vowelBand[0]) { consonantMult *= o.classDamp; vowelMult *= o.classBoost; }
|
||||
}
|
||||
}
|
||||
|
||||
// ── word boost: 3-letter words completed through this cell ─────────
|
||||
// For candidate L, count real words of the forms a-L-b (a,b filled
|
||||
// neighbours), L-a-b and a-b-L (a,b filled neighbours, b adjacent a).
|
||||
let counts = null;
|
||||
if (wordSet && o.wordBoostK > 0) {
|
||||
const N = [];
|
||||
for (const nb of getAdjacent(r, c)) {
|
||||
const l = grid[nb.r][nb.c]?.letter;
|
||||
if (l) N.push({ l, r: nb.r, c: nb.c });
|
||||
}
|
||||
if (N.length >= 2) {
|
||||
counts = new Array(26).fill(0);
|
||||
for (let li = 0; li < 26; li++) {
|
||||
const L = String.fromCharCode(65 + li);
|
||||
if (!LETTER_WEIGHTS[L]) continue;
|
||||
const seen = new Set();
|
||||
for (const a of N) {
|
||||
for (const b of N) {
|
||||
if (a === b) continue;
|
||||
const ab = isAdjacent(a, b);
|
||||
const w1 = a.l + L + b.l;
|
||||
if (wordSet.has(w1) && !seen.has(w1)) { seen.add(w1); counts[li]++; }
|
||||
if (ab) {
|
||||
const w2 = L + a.l + b.l;
|
||||
if (wordSet.has(w2) && !seen.has(w2)) { seen.add(w2); counts[li]++; }
|
||||
const w3 = a.l + b.l + L;
|
||||
if (wordSet.has(w3) && !seen.has(w3)) { seen.add(w3); counts[li]++; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── final weights ───────────────────────────────────────────────────
|
||||
const weights = new Array(26).fill(0);
|
||||
for (let li = 0; li < 26; li++) {
|
||||
const L = String.fromCharCode(65 + li);
|
||||
const base = LETTER_WEIGHTS[L];
|
||||
if (!base) continue;
|
||||
let w = base * (VOWELS.has(L) ? vowelMult : consonantMult);
|
||||
if (counts) w *= 1 + o.wordBoostK * counts[li];
|
||||
weights[li] = w;
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
// Sample a letter from a 26-entry weight array (A..Z).
|
||||
export function pickWeighted(weights, rng) {
|
||||
let total = 0;
|
||||
for (const w of weights) total += w;
|
||||
if (total <= 0) return String.fromCharCode(65 + Math.floor(rng() * 26));
|
||||
let x = rng() * total;
|
||||
for (let i = 0; i < 26; i++) {
|
||||
x -= weights[i];
|
||||
if (x <= 0) return String.fromCharCode(65 + i);
|
||||
}
|
||||
return 'Z';
|
||||
}
|
||||
|
||||
// Build a full 5×5 board with context steering (cells placed in random order,
|
||||
// each steered by the already-placed neighbours).
|
||||
export function makeSteeredGrid(rng = Math.random, wordSet = null, opts = {}) {
|
||||
const grid = Array.from({ length: GRID_SIZE }, () =>
|
||||
Array.from({ length: GRID_SIZE }, () => ({ letter: null, type: 'normal' })));
|
||||
if (!wordSet) {
|
||||
// Degrade to the plain weighted pool.
|
||||
for (const row of grid) for (const cell of row) cell.letter = randomLetter(rng);
|
||||
return grid;
|
||||
}
|
||||
const cells = [];
|
||||
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) cells.push({ r, c });
|
||||
for (let i = cells.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[cells[i], cells[j]] = [cells[j], cells[i]];
|
||||
}
|
||||
for (const { r, c } of cells) {
|
||||
grid[r][c].letter = pickWeighted(letterWeights(grid, r, c, wordSet, opts), rng);
|
||||
}
|
||||
return grid;
|
||||
}
|
||||
|
||||
// Same cascade semantics as BookworkLogic.clearAndRefill (survivors fall to
|
||||
// the bottom of each column, fresh tiles drop into the top rows), except the
|
||||
// fresh letters are chosen with context steering against the partially
|
||||
// rebuilt board. Fresh tiles land ABOVE their column's survivors, so this
|
||||
// also completes vertical word fragments the survivors left behind.
|
||||
export function refillSteered(grid, usedCells, rng = Math.random, wordSet = null, opts = {}) {
|
||||
const used = new Set(usedCells.map(({ r, c }) => `${r},${c}`));
|
||||
const next = grid.map((row) => row.map((cell) => ({ ...cell })));
|
||||
|
||||
// Pass 1: cascade survivors down each column; mark fresh slots as empty.
|
||||
const newRows = new Array(GRID_SIZE).fill(0);
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
const survive = [];
|
||||
for (let r = GRID_SIZE - 1; r >= 0; r--) {
|
||||
if (!used.has(`${r},${c}`)) survive.push({ ...next[r][c] });
|
||||
}
|
||||
newRows[c] = GRID_SIZE - survive.length;
|
||||
for (let i = 0; i < survive.length; i++) next[GRID_SIZE - 1 - i][c] = survive[i];
|
||||
for (let r = 0; r < newRows[c]; r++) next[r][c] = { letter: null, type: 'normal' };
|
||||
}
|
||||
|
||||
// Pass 2: place fresh tiles — per column bottom-up, left→right across
|
||||
// columns — so each new tile sees survivors + already-placed tiles.
|
||||
const goldChance = opts.goldChance ?? SPECIAL_TILE_CHANCES.gold;
|
||||
const diamondChance = opts.diamondChance ?? SPECIAL_TILE_CHANCES.diamond;
|
||||
for (let c = 0; c < GRID_SIZE; c++) {
|
||||
for (let r = newRows[c] - 1; r >= 0; r--) {
|
||||
const weights = wordSet ? letterWeights(next, r, c, wordSet, opts) : null;
|
||||
const letter = weights ? pickWeighted(weights, rng) : randomLetter(rng);
|
||||
const gold = rng() < goldChance;
|
||||
const diamond = !gold && rng() < diamondChance;
|
||||
next[r][c] = { letter, type: gold ? 'gold' : diamond ? 'diamond' : 'normal' };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -28,6 +28,179 @@ const SNAP_FRAC = 0.42; // snap radius (×cell) — used for both locking a
|
|||
// into the board AND joining two pieces on the table
|
||||
const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs
|
||||
|
||||
// Win view: the finished board is framed large on the RIGHT of the screen
|
||||
// (near full-screen), with the completion info panel parked on the LEFT out
|
||||
// of the way of the picture. The camera eases into the framing.
|
||||
const WIN_ZOOM = 1.24; // board (700px) renders ≈868px tall
|
||||
const WIN_PANEL = { x: 48, y: 110, w: 470, h: 860 };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Playfield: a stitched beige mat. The field renders as one piece of fabric
|
||||
// (border band + dashed stitching + subtle weave) and the board target sits in
|
||||
// a slightly deeper stitched "pocket", like a puzzle mat laid out for a jigsaw.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const MAT = {
|
||||
base: '#d7ceb9', // main surface — warm, neutral beige
|
||||
band: '#b4a487', // border band around the field edge
|
||||
bandEdge: '#998867', // outermost rim of the mat
|
||||
stitch: '#8a7857', // stitching thread
|
||||
pocket: '#c7bca6', // board pocket (a shade deeper than the surface)
|
||||
};
|
||||
const BAND = 26; // border band width
|
||||
const MAT_OVERSCAN = 140; // mat drawn past the world's bottom edge (covers the
|
||||
// camera dead-zone below the world at minimum zoom;
|
||||
// see clampCamera: the world top is reserved for HUD)
|
||||
const POCKET_MARGIN = 28; // pocket extends this far past the board edge
|
||||
|
||||
function roundRectPath(ctx, x, y, w, h, r) {
|
||||
r = Math.min(r, w / 2, h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + r, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||
ctx.arcTo(x, y + h, x, y, r);
|
||||
ctx.arcTo(x, y, x + w, y, r);
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// Dashed "stitching" around a rounded rect. Drawn twice — a soft shadow pass
|
||||
// under the thread — so it reads as sewn into the fabric.
|
||||
function stitchRect(ctx, x, y, w, h, r, inset, dash, width) {
|
||||
const sx = x + inset, sy = y + inset, sw = w - 2 * inset, sh = h - 2 * inset;
|
||||
const rr = Math.max(3, r - inset);
|
||||
ctx.save();
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = width;
|
||||
ctx.setLineDash(dash);
|
||||
ctx.strokeStyle = 'rgba(66,54,34,0.35)';
|
||||
roundRectPath(ctx, sx, sy + 1.4, sw, sh, rr); ctx.stroke();
|
||||
ctx.strokeStyle = MAT.stitch;
|
||||
roundRectPath(ctx, sx, sy, sw, sh, rr); ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Subtle fabric-weave tile: fine thread lines + a sprinkle of speckle.
|
||||
function makeWeaveTile(seed) {
|
||||
const S = 128;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
const g = mulberry32(seed);
|
||||
ctx.fillStyle = 'rgba(94,80,56,0.03)';
|
||||
for (let y = 0; y < S; y += 4) ctx.fillRect(0, y, S, 1);
|
||||
ctx.fillStyle = 'rgba(255,255,250,0.03)';
|
||||
for (let x = 0; x < S; x += 4) ctx.fillRect(x, 0, 1, S);
|
||||
for (let i = 0; i < 700; i++) {
|
||||
ctx.fillStyle = g() > 0.5 ? 'rgba(94,80,56,0.06)' : 'rgba(255,255,250,0.07)';
|
||||
ctx.fillRect(g() * S, g() * S, 1 + g() * 1.5, 1 + g() * 1.5);
|
||||
}
|
||||
return cv;
|
||||
}
|
||||
|
||||
// Full-field mat: border band, stitching, weave and a soft vignette.
|
||||
function makeMatTexture() {
|
||||
const W = WORLD_W, H = WORLD_H + MAT_OVERSCAN;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.fillStyle = MAT.band;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.save();
|
||||
roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22);
|
||||
ctx.fillStyle = MAT.base;
|
||||
ctx.fill();
|
||||
ctx.clip();
|
||||
ctx.fillStyle = ctx.createPattern(makeWeaveTile(1234), 'repeat');
|
||||
ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND);
|
||||
// Soft vignette centred on the visible field (adds gentle depth).
|
||||
const vg = ctx.createRadialGradient(W / 2, WORLD_H / 2, Math.min(W, WORLD_H) * 0.30, W / 2, WORLD_H / 2, Math.max(W, WORLD_H) * 0.72);
|
||||
vg.addColorStop(0, 'rgba(118,102,72,0)');
|
||||
vg.addColorStop(1, 'rgba(118,102,72,0.14)');
|
||||
ctx.fillStyle = vg;
|
||||
ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND);
|
||||
// Seam where the surface meets the band.
|
||||
ctx.strokeStyle = 'rgba(96,82,56,0.28)';
|
||||
ctx.lineWidth = 2;
|
||||
roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
// Outer rim of the mat.
|
||||
ctx.strokeStyle = MAT.bandEdge;
|
||||
ctx.lineWidth = 5;
|
||||
roundRectPath(ctx, 2.5, 2.5, W - 5, H - 5, 26);
|
||||
ctx.stroke();
|
||||
// Stitching, centred in the border band.
|
||||
stitchRect(ctx, 0, 0, W, H, 26, BAND / 2, [18, 12], 3.5);
|
||||
return cv;
|
||||
}
|
||||
|
||||
// Board pocket: a slightly deeper stitched panel that the reference picture
|
||||
// (and the locked pieces) sit inside.
|
||||
function makePocketTexture(size) {
|
||||
const M = POCKET_MARGIN;
|
||||
const S = size + 2 * M;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.save();
|
||||
roundRectPath(ctx, M, M, size, size, 16);
|
||||
ctx.shadowColor = 'rgba(64,52,32,0.35)';
|
||||
ctx.shadowBlur = 18;
|
||||
ctx.shadowOffsetY = 6;
|
||||
ctx.fillStyle = MAT.pocket;
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
ctx.save();
|
||||
roundRectPath(ctx, M, M, size, size, 16);
|
||||
ctx.clip();
|
||||
ctx.fillStyle = ctx.createPattern(makeWeaveTile(4242), 'repeat');
|
||||
ctx.fillRect(0, 0, S, S);
|
||||
// Recessed feel: darker toward the top, a faint lift at the bottom lip.
|
||||
const g = ctx.createLinearGradient(0, M, 0, M + size);
|
||||
g.addColorStop(0, 'rgba(84,70,44,0.14)');
|
||||
g.addColorStop(0.14, 'rgba(84,70,44,0.03)');
|
||||
g.addColorStop(0.86, 'rgba(255,255,246,0.04)');
|
||||
g.addColorStop(1, 'rgba(255,255,246,0.09)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(M, M, size, size);
|
||||
ctx.restore();
|
||||
ctx.strokeStyle = 'rgba(96,82,56,0.4)';
|
||||
ctx.lineWidth = 2;
|
||||
roundRectPath(ctx, M + 1, M + 1, size - 2, size - 2, 16);
|
||||
ctx.stroke();
|
||||
stitchRect(ctx, M, M, size, size, 16, 11, [14, 10], 3);
|
||||
return cv;
|
||||
}
|
||||
|
||||
// Confetti bit: a small rounded square (the emitter tints each particle).
|
||||
function makeConfettiTexture() {
|
||||
const S = 18;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = S; cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
roundRectPath(ctx, 2, 2, S - 4, S - 4, 4);
|
||||
ctx.fillStyle = '#f2ead8';
|
||||
ctx.fill();
|
||||
return cv;
|
||||
}
|
||||
|
||||
// Diagonal light band for the shine sweep across the finished puzzle.
|
||||
function makeShineTexture() {
|
||||
const W = 256, H = 256;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d');
|
||||
const g = ctx.createLinearGradient(0, 0, W, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)');
|
||||
g.addColorStop(0.42, 'rgba(255,246,214,0.55)');
|
||||
g.addColorStop(0.5, 'rgba(255,250,228,0.9)');
|
||||
g.addColorStop(0.58, 'rgba(255,246,214,0.55)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
return cv;
|
||||
}
|
||||
|
||||
function offsetOutline(o, ox, oy) {
|
||||
return {
|
||||
start: { x: o.start.x + ox, y: o.start.y + oy },
|
||||
|
|
@ -75,6 +248,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
this.elapsed = 0;
|
||||
this.hintOn = true;
|
||||
this.selectedDiff = 'easy';
|
||||
this.winTransition = false;
|
||||
|
||||
this.loadArtwork();
|
||||
// Open the menu on a random picture, not always the first one, so each
|
||||
|
|
@ -166,8 +340,8 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
loadImage(key).then((srcImg) => {
|
||||
if (token !== this._loadToken) return; // superseded by a newer pick
|
||||
if (!this.menu || !this.menu.visible) return;
|
||||
const box = 300, cx = this.thumbBoxX, cy = this.thumbBoxY;
|
||||
const S = 220;
|
||||
const box = this.thumbBox || 280, cx = this.thumbBoxX, cy = this.thumbBoxY;
|
||||
const S = box;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
|
|
@ -195,8 +369,9 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
addThumbBorder() {
|
||||
if (this.thumbBorder) this.thumbBorder.destroy();
|
||||
const g = this.add.graphics();
|
||||
const h = (this.thumbBox || 280) / 2 - 10;
|
||||
g.lineStyle(3, COLORS.accent, 0.9);
|
||||
g.strokeRoundedRect(-150, -150, 300, 300, 16);
|
||||
g.strokeRoundedRect(-h, -h, h * 2, h * 2, 14);
|
||||
this.thumbBorder = g;
|
||||
const parent = this.menu || this;
|
||||
if (parent.add) parent.add(g);
|
||||
|
|
@ -205,16 +380,18 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
// ── Background / table ─────────────────────────────────────────────────────
|
||||
buildBackground() {
|
||||
const bg = this.add.graphics().setDepth(0);
|
||||
// felt-like table covering the whole (bigger) field
|
||||
bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, WORLD_W, WORLD_H);
|
||||
// subtle framed panel to read as a table
|
||||
bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, WORLD_W - 56, WORLD_H - 56, 26);
|
||||
bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, WORLD_W - 68, WORLD_H - 68, 22);
|
||||
bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, WORLD_W - 80, WORLD_H - 80, 18);
|
||||
this.bg = bg;
|
||||
bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, WORLD_W, WORLD_H), Phaser.Geom.Rectangle.Contains);
|
||||
bg.on('pointerdown', (pointer) => this.onTableDown(pointer));
|
||||
// The playfield is one stitched beige mat (band + stitching + weave).
|
||||
if (!this.textures.exists('jigsaw-mat')) {
|
||||
this.textures.addCanvas('jigsaw-mat', makeMatTexture());
|
||||
}
|
||||
this.bg = this.add.image(WORLD_W / 2, (WORLD_H + MAT_OVERSCAN) / 2, 'jigsaw-mat').setDepth(0);
|
||||
// Invisible hit zone for the table (pan on empty space). Kept separate
|
||||
// from the mat image so the background can be swapped without dropping
|
||||
// the input. Zones are center-origin, so position at the world midpoint.
|
||||
const hit = this.add.zone(WORLD_W / 2, WORLD_H / 2, WORLD_W, WORLD_H).setDepth(0);
|
||||
hit.setInteractive();
|
||||
hit.on('pointerdown', (pointer) => this.onTableDown(pointer));
|
||||
this.bgHit = hit;
|
||||
}
|
||||
|
||||
// ── HUD (pinned to the top of the screen regardless of pan/zoom) ──────────
|
||||
|
|
@ -230,7 +407,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5, 0.5);
|
||||
this.hud.add(this.title);
|
||||
|
||||
this.diffBadge = this.add.text(195, HUD_H / 2, 'Easy · 25', {
|
||||
this.diffBadge = this.add.text(250, HUD_H / 2, 'Easy · 25', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5, 0.5);
|
||||
this.hud.add(this.diffBadge);
|
||||
|
|
@ -263,10 +440,10 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
const h = PAD + 5 * BH + 4 * GAP + SEP + BH + PAD; // 5 rows + separator + Main menu
|
||||
const panel = this.add.container(0, 0).setDepth(9050);
|
||||
panel.setVisible(false);
|
||||
const bg = this.add.rectangle(cx, top + h / 2, W, h, 0x17130c, 0.98);
|
||||
const frame = this.add.graphics();
|
||||
frame.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12);
|
||||
panel.add([bg, frame]);
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x17130c, 0.98).fillRoundedRect(X0, top, W, h, 12);
|
||||
bg.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12);
|
||||
panel.add(bg);
|
||||
|
||||
const rowY = (i) => top + PAD + BH / 2 + i * (BH + GAP);
|
||||
const row = (i, label, onClick) => this.mkButton(panel, label, cx, rowY(i), W - 24, BH, onClick, { fontSize: 20 });
|
||||
|
|
@ -337,7 +514,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
buttonHit(cx, cy) {
|
||||
for (const b of this.buttons) {
|
||||
if (!b.visible || !b.parent || !b.parent.visible) continue;
|
||||
if (!b.visible || (b.parentContainer && !b.parentContainer.visible)) continue;
|
||||
const w = b.options.width, h = b.options.height;
|
||||
if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b;
|
||||
}
|
||||
|
|
@ -348,45 +525,59 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
buildMenu() {
|
||||
this.menu = this.add.container(0, 0).setDepth(8000);
|
||||
|
||||
const W = 1160, H = 820, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2 + 12;
|
||||
const panel = this.add.rectangle(cx, cy, W, H, 0x17130c, 0.96);
|
||||
const frame = this.add.graphics();
|
||||
frame.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 20);
|
||||
this.menu.add([panel, frame]);
|
||||
// Two comfortably-spaced columns — picture picker left, difficulty +
|
||||
// start right — with a consistent vertical rhythm (26px label→control,
|
||||
// ≥50px between sections) instead of one tall, cramped stack.
|
||||
const W = 1180, H = 660;
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
const top = cy - H / 2;
|
||||
const bg = this.add.graphics();
|
||||
bg.fillStyle(0x17130c, 0.96).fillRoundedRect(cx - W / 2, top, W, H, 20);
|
||||
bg.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, top, W, H, 20);
|
||||
this.menu.add(bg);
|
||||
|
||||
const addT = (x, y, s, color, o = {}) => {
|
||||
const t = this.add.text(x, y, s, { fontFamily: '"Julius Sans One"', fontSize: o.size, color: color, ...o }).setOrigin(0.5);
|
||||
this.menu.add(t); return t;
|
||||
};
|
||||
addT(cx, cy - 350, 'JIGSAW', COLORS.textHex, { size: '58px', letterSpacing: 10 });
|
||||
addT(cx, cy - 308, 'Assemble the picture. Drag pieces, pan the table, zoom to fit.', COLORS.mutedHex, { size: '20px' });
|
||||
|
||||
// Difficulty
|
||||
addT(cx, cy - 250, 'Difficulty', COLORS.goldHex, { size: '24px', letterSpacing: 3 });
|
||||
const diffLabels = DIFFICULTY_ORDER.map((k) => DIFFICULTIES[k]);
|
||||
const diffY = cy - 205;
|
||||
addT(cx, top + 72, 'JIGSAW', COLORS.textHex, { size: '48px', letterSpacing: 8 });
|
||||
addT(cx, top + 122, 'Assemble the picture.', COLORS.mutedHex, { size: '20px' });
|
||||
|
||||
const labelY = top + 202;
|
||||
const cxL = cx - 292, cxR = cx + 286; // right column kept inboard so the
|
||||
// difficulty pair stays inside the panel
|
||||
addT(cxL, labelY, 'Image', COLORS.goldHex, { size: '22px', letterSpacing: 3 });
|
||||
addT(cxR, labelY, 'Difficulty', COLORS.goldHex, { size: '22px', letterSpacing: 3 });
|
||||
|
||||
// ── Left column: picture picker ───────────────────────────────────────
|
||||
this.thumbBox = 280;
|
||||
const thumbCy = labelY + 11 + 26 + this.thumbBox / 2;
|
||||
this.thumbBoxX = cxL; this.thumbBoxY = thumbCy;
|
||||
this.mkButton(this.menu, '‹', cxL - 190, thumbCy, 64, 64, () => this.previewImage(-1), { fontSize: 34 });
|
||||
this.mkButton(this.menu, '›', cxL + 190, thumbCy, 64, 64, () => this.previewImage(1), { fontSize: 34 });
|
||||
const nameY = thumbCy + this.thumbBox / 2 + 26;
|
||||
this.thumbName = addT(cxL, nameY, '', COLORS.textHex, { size: '20px' });
|
||||
this.mkButton(this.menu, '🎲 Random', cxL, nameY + 10 + 22 + 26, 190, 52, () => this.randomImage(), { fontSize: 22 });
|
||||
|
||||
// ── Right column: difficulty + start ──────────────────────────────────
|
||||
const bw = 190, bh = 58, gap = 18;
|
||||
const row1 = labelY + 11 + 26 + bh / 2;
|
||||
const row2 = row1 + bh + gap;
|
||||
const bxL = cxR - (bw + gap) / 2 - bw / 2;
|
||||
const bxR = cxR + (bw + gap) / 2 + bw / 2;
|
||||
this.diffButtons = [];
|
||||
const bw = 210, gap = 22, totalW = diffLabels.length * bw + (diffLabels.length - 1) * gap;
|
||||
let bx = cx - totalW / 2 + bw / 2;
|
||||
const bx = [bxL, bxR], by = [row1, row2];
|
||||
DIFFICULTY_ORDER.forEach((k, i) => {
|
||||
const cfg = DIFFICULTIES[k];
|
||||
const b = this.mkButton(this.menu, `${cfg.label} · ${cfg.cols * cfg.rows}`, bx, diffY, bw, 64, () => this.selectDifficulty(k), { fontSize: 22 });
|
||||
const b = this.mkButton(this.menu, `${cfg.label} · ${cfg.cols * cfg.rows}`, bx[i % 2], by[Math.floor(i / 2)], bw, bh, () => this.selectDifficulty(k), { fontSize: 21 });
|
||||
this.diffButtons.push({ key: k, btn: b });
|
||||
bx += bw + gap;
|
||||
});
|
||||
|
||||
// Image row
|
||||
addT(cx, cy - 110, 'Image', COLORS.goldHex, { size: '24px', letterSpacing: 3 });
|
||||
this.thumbBoxX = cx; this.thumbBoxY = cy + 20;
|
||||
this.thumbName = addT(cx, cy + 175, '', COLORS.textHex, { size: '20px' });
|
||||
const prev = this.mkButton(this.menu, '‹', cx - 205, cy + 20, 64, 64, () => this.previewImage(-1), { fontSize: 34 });
|
||||
const next = this.mkButton(this.menu, '›', cx + 205, cy + 20, 64, 64, () => this.previewImage(1), { fontSize: 34 });
|
||||
const rnd = this.mkButton(this.menu, '🎲 Random', cx, cy + 215, 190, 52, () => this.randomImage(), { fontSize: 22 });
|
||||
this.thumbName.y = cy + 160;
|
||||
|
||||
// Start
|
||||
const start = this.mkButton(this.menu, 'Start Puzzle ▸', cx, cy + 315, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
|
||||
const startY = row2 + bh / 2 + 52 + 37;
|
||||
const start = this.mkButton(this.menu, 'Start Puzzle ▸', cxR, startY, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
|
||||
this.startButton = start;
|
||||
addT(cxR, startY + 37 + 40, 'Drag pieces onto the mat · scroll to zoom · drag to pan', COLORS.mutedHex, { size: '17px' });
|
||||
|
||||
// Load the first preview
|
||||
this.selectDifficulty('easy');
|
||||
|
|
@ -453,12 +644,17 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
this.diffLabel = `${cfg.label} · ${this.total} pieces`;
|
||||
this.imageName = item.name || 'Image';
|
||||
|
||||
// Board panel + reference picture
|
||||
// Board: a stitched pocket set a shade deeper than the field, with the
|
||||
// faint reference picture laid inside it.
|
||||
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
|
||||
const panel = this.add.rectangle(bcx, bcy, BOARD.size + 28, BOARD.size + 28, 0x000000, 0.28).setDepth(1);
|
||||
if (!this.textures.exists('jigsaw-pocket')) {
|
||||
this.textures.addCanvas('jigsaw-pocket', makePocketTexture(BOARD.size));
|
||||
}
|
||||
const panel = this.add.image(bcx, bcy, 'jigsaw-pocket')
|
||||
.setDisplaySize(BOARD.size + 2 * POCKET_MARGIN, BOARD.size + 2 * POCKET_MARGIN)
|
||||
.setDepth(1);
|
||||
const boardFrame = this.add.graphics().setDepth(2);
|
||||
boardFrame.lineStyle(3, COLORS.accent, 0.8).strokeRoundedRect(BOARD.x - 14, BOARD.y - 14, BOARD.size + 28, BOARD.size + 28, 12);
|
||||
boardFrame.lineStyle(2, 0xffffff, 0.15);
|
||||
boardFrame.lineStyle(2, 0x5a4c32, 0.25);
|
||||
boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size);
|
||||
this.boardPanel = panel;
|
||||
this.boardFrame = boardFrame;
|
||||
|
|
@ -482,7 +678,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
}
|
||||
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
|
||||
this.textures.addCanvas('jigsaw-ref', rcv);
|
||||
this.refImage = this.add.image(bcx, bcy, 'jigsaw-ref').setDisplaySize(BOARD.size, BOARD.size).setAlpha(0.16).setDepth(3);
|
||||
this.refImage = this.add.image(bcx, bcy, 'jigsaw-ref').setDisplaySize(BOARD.size, BOARD.size).setAlpha(0.2).setDepth(3);
|
||||
if (this.hudCam) this.refImage.cameraFilter = this.hudCam.id; // main camera only
|
||||
// Faint full-picture reference: keep on Easy/Medium, hide on Hard/Legendary.
|
||||
this.refImage.setVisible(this.difficulty !== 'hard' && this.difficulty !== 'legendary');
|
||||
|
|
@ -552,7 +748,8 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
buildPieceCanvas(r, c, src, boardW, boardH) {
|
||||
const cell = this.cell;
|
||||
const S = Math.ceil(cell * 2) + 2;
|
||||
const PAD = Math.max(7, cell * 0.09); // headroom for the drop shadow
|
||||
const S = Math.ceil(cell * 2) + Math.ceil(PAD * 2);
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
|
|
@ -560,6 +757,18 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
const oy = S / 2 - (r + 0.5) * cell;
|
||||
const outline = offsetOutline(cellOutline(this.jig, r, c, cell, cell), ox, oy);
|
||||
|
||||
// Soft drop shadow so pieces read as resting on the mat. The fill colour
|
||||
// is arbitrary — the opaque art pass below repaints the piece exactly;
|
||||
// only the shadow escaping the outline is visible.
|
||||
ctx.save();
|
||||
tracePath(ctx, outline);
|
||||
ctx.shadowColor = 'rgba(58,46,26,0.30)';
|
||||
ctx.shadowBlur = Math.max(4, cell * 0.07);
|
||||
ctx.shadowOffsetY = Math.max(3, cell * 0.05);
|
||||
ctx.fillStyle = '#3a2f1a';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
tracePath(ctx, outline);
|
||||
ctx.clip();
|
||||
|
|
@ -596,8 +805,13 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
// Tray = the whole (bigger) field minus a small margin, EXCLUDING the board
|
||||
// (pieces must not scatter on top of the target image or they'd be
|
||||
// indistinguishable from placed pieces). The HUD lives in viewport space,
|
||||
// so it doesn't reserve any of the world.
|
||||
const tray = { x: 40, y: HUD_H + 40, w: WORLD_W - 80, h: WORLD_H - HUD_H - 80 };
|
||||
// but at full zoom-out the camera is clamped to scrollY = HUD_H, so the
|
||||
// fixed bar still covers world y ∈ [HUD_H, HUD_H + HUD_H/MIN_ZOOM]. Push
|
||||
// the tray top down so the WHOLE piece — base cell + tabs + shadow
|
||||
// headroom, not just its centre — stays below that band.
|
||||
const pieceHalf = this.cell + Math.max(7, this.cell * 0.09); // matches buildPieceCanvas PAD
|
||||
const trayTop = HUD_H + HUD_H / MIN_ZOOM + pieceHalf;
|
||||
const tray = { x: 40, y: trayTop, w: WORLD_W - 80, h: WORLD_H - trayTop - 40 };
|
||||
const boardBox = { x0: BOARD.x - 6, y0: BOARD.y - 6, x1: BOARD.x + BOARD.size + 6, y1: BOARD.y + BOARD.size + 6 };
|
||||
const grabR = this.cell * GRAB_FRAC;
|
||||
// We guarantee a comfortable minimum centre-gap so no two pieces steal each
|
||||
|
|
@ -721,6 +935,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
onTableDown(pointer) {
|
||||
if (this.state !== 'playing') return;
|
||||
if (this.dragging) return; // a grab is already in progress (both the piece and the table zone forward their pointerdown here — resolve only once)
|
||||
const c = this.canvasPos(pointer);
|
||||
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons (incl. dropdown rows) work
|
||||
if (this.music && c.x > 1830 && c.y < 80) return; // site music HUD (top-right) — don't pan over it
|
||||
|
|
@ -915,39 +1130,68 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
pinHUD() {
|
||||
const cam = this.cameras.main;
|
||||
// The HUD and the win overlay are both pinned to the top-left of the
|
||||
// viewport (scaled to counter the zoom) so they sit in screen space no
|
||||
// matter where the camera is panned/zoomed within the (bigger) field.
|
||||
if (this.hud) {
|
||||
this.hud.setPosition(cam.scrollX, cam.scrollY);
|
||||
this.hud.setScale(1 / cam.zoom);
|
||||
}
|
||||
if (this.winLayer) {
|
||||
this.winLayer.setPosition(cam.scrollX, cam.scrollY);
|
||||
this.winLayer.setScale(1 / cam.zoom);
|
||||
}
|
||||
// NOTE: the HUD bar, the Menu ▾ dropdown and the win overlay are all
|
||||
// rendered by the fixed hudCam (see create()), which already places them
|
||||
// in screen space — no pinning required. Kept as a no-op guard so nothing
|
||||
// accidentally re-positions them. (buttonHit() relies on the same
|
||||
// screen-space assumption.)
|
||||
}
|
||||
|
||||
// ── State flow ─────────────────────────────────────────────────────────────
|
||||
toggleHint() {
|
||||
this.hintOn = !this.hintOn;
|
||||
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.16 : 0);
|
||||
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.2 : 0);
|
||||
this.btnHint.setActive(this.hintOn);
|
||||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||
}
|
||||
|
||||
restart() {
|
||||
if (this.state === 'menu') return;
|
||||
this.beginPlay(this.currentImage());
|
||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||
// Pull the camera back to the start framing first, then rebuild the board.
|
||||
const startCamX = BOARD.x + BOARD.size / 2 - GAME_WIDTH / 2; // matches setZoomTo(1)
|
||||
const startCamY = BOARD.y + BOARD.size / 2 - GAME_HEIGHT / 2; // matches setZoomTo(1)
|
||||
this.exitWin(() => {
|
||||
this.beginPlay(this.currentImage());
|
||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||
}, startCamX, startCamY);
|
||||
}
|
||||
|
||||
toMenu() {
|
||||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||
this.teardownPlay();
|
||||
this.setState('menu');
|
||||
this.loadPreview(this.currentImage());
|
||||
this.exitWin(() => {
|
||||
this.teardownPlay();
|
||||
this.setState('menu');
|
||||
this.loadPreview(this.currentImage());
|
||||
}, 0, HUD_H);
|
||||
}
|
||||
|
||||
// Exit the win view: panel slides out, camera pulls back, then run `done()`.
|
||||
exitWin(done, targetScrollX, targetScrollY) {
|
||||
if (this.state !== 'won' || !this.winLayer) { done(); return; }
|
||||
if (this.winTransition) return;
|
||||
this.winTransition = true;
|
||||
// Kill any in-flight showcase tweens (slide-ins, camera sweep, counter) so
|
||||
// they don't fight the exit animation or fire the celebration late.
|
||||
(this.winTweens || []).forEach((t) => t && t.stop());
|
||||
this.winTweens = [];
|
||||
|
||||
playSound(this, SFX.VEGA_ZOOMOUT);
|
||||
(this.winPanelGroups || []).forEach((g, i) => {
|
||||
this.winTweens.push(this.tweens.add({ targets: g, x: g.x - 420, alpha: 0, delay: i * 45, duration: 300, ease: 'Cubic.easeIn' }));
|
||||
});
|
||||
if (this.winDim && this.winPanelBg) {
|
||||
this.winTweens.push(this.tweens.add({ targets: [this.winDim, this.winPanelBg], alpha: 0, duration: 330, ease: 'Cubic.easeIn' }));
|
||||
}
|
||||
const cam = this.cameras.main;
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: cam, scrollX: targetScrollX, scrollY: targetScrollY, zoom: 1,
|
||||
duration: 460, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => {
|
||||
this.winTransition = false;
|
||||
this.teardownWin();
|
||||
done();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
onWin() {
|
||||
|
|
@ -957,39 +1201,225 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
this.showWinOverlay();
|
||||
}
|
||||
|
||||
// ── Win showcase ────────────────────────────────────────────────────────
|
||||
// The finished board eases into a near-full-screen frame on the RIGHT of the
|
||||
// screen; the completion info + actions settle into a left-hand panel out of
|
||||
// the way of the picture. Then it celebrates: confetti burst + falling
|
||||
// curtain, a shine sweep across the picture, and a pulsing gold frame.
|
||||
showWinOverlay() {
|
||||
this.teardownWin();
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
this.winTweens = [];
|
||||
this.winEmitters = [];
|
||||
this.winPanelGroups = [];
|
||||
|
||||
if (!this.textures.exists('jigsaw-confetti')) this.textures.addCanvas('jigsaw-confetti', makeConfettiTexture());
|
||||
if (!this.textures.exists('jigsaw-shine')) this.textures.addCanvas('jigsaw-shine', makeShineTexture());
|
||||
|
||||
// Win camera: board centred in the region right of the info panel. The
|
||||
// board sits mid-world, so the camera must travel to its row. Vertically
|
||||
// it is centred in the area BELOW the fixed HUD bar (not the whole screen),
|
||||
// so the framed picture has comfortable breathing room under the bar.
|
||||
const bcx = BOARD.x + BOARD.size / 2;
|
||||
const bcy = BOARD.y + BOARD.size / 2;
|
||||
const boardCx = (WIN_PANEL.x + WIN_PANEL.w + GAME_WIDTH) / 2;
|
||||
const winScrollX = bcx - boardCx / WIN_ZOOM;
|
||||
const winScrollY = bcy - ((HUD_H + GAME_HEIGHT) / 2) / WIN_ZOOM;
|
||||
|
||||
this.winLayer = this.add.container(0, 0).setDepth(9600);
|
||||
if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only
|
||||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55);
|
||||
const panel = this.add.rectangle(cx, cy, 620, 420, 0x17130c, 0.98);
|
||||
const frame = this.add.graphics();
|
||||
frame.lineStyle(3, COLORS.gold, 0.9).strokeRoundedRect(cx - 310, cy - 210, 620, 420, 20);
|
||||
const T = (y, s, color, size) => {
|
||||
const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5);
|
||||
this.winLayer.add(t);
|
||||
|
||||
const T = (target, x, y, s, color, o = {}) => {
|
||||
const t = this.add.text(x, y, s, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: o.size || '20px', color, ...o,
|
||||
}).setOrigin(o.align === 'left' ? 0 : 0.5);
|
||||
target.add(t);
|
||||
return t;
|
||||
};
|
||||
this.winLayer.add([dim, panel, frame]);
|
||||
T(cy - 140, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
|
||||
|
||||
// Subtle dim — the picture stays the hero, the panel gets its contrast.
|
||||
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.30);
|
||||
dim.setAlpha(0);
|
||||
this.winDim = dim;
|
||||
|
||||
const { x: PX, y: PY, w: PW } = WIN_PANEL;
|
||||
const panelBg = this.add.graphics();
|
||||
panelBg.fillStyle(0x17130c, 0.97).fillRoundedRect(PX, PY, PW, WIN_PANEL.h, 20);
|
||||
panelBg.lineStyle(2, COLORS.accent, 0.55).strokeRoundedRect(PX, PY, PW, WIN_PANEL.h, 20);
|
||||
panelBg.fillStyle(COLORS.gold, 0.95).fillRoundedRect(PX + 42, PY + 48, 64, 4, 2);
|
||||
panelBg.setAlpha(0);
|
||||
this.winPanelBg = panelBg;
|
||||
this.winLayer.add([dim, panelBg]);
|
||||
|
||||
const LX = PX + 42; // left-aligned text edge
|
||||
const BCX = PX + PW / 2, BW = PW - 84; // action-button geometry
|
||||
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
|
||||
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
|
||||
T(cy - 70, this.diffLabel || '', COLORS.textHex, '26px');
|
||||
T(cy - 28, `Solved in ${mm}:${ss}`, COLORS.textHex, '24px');
|
||||
T(cy + 8, this.imageName || '', COLORS.mutedHex, '20px');
|
||||
const again = this.mkButton(this.winLayer, 'Play Again', cx - 120, cy + 110, 210, 64, () => this.restart(), { fontSize: 26, bg: COLORS.gold });
|
||||
this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 110, 210, 64, () => this.toMenu(), { fontSize: 26 });
|
||||
[panel, frame].forEach((o) => { o.setScale(0.85); this.tweens.add({ targets: o, scale: 1, duration: 280, ease: 'Back.easeOut' }); });
|
||||
|
||||
// Row 1 — title + image name
|
||||
const gTitle = this.add.container(0, 0);
|
||||
this.winLayer.add(gTitle);
|
||||
T(gTitle, LX, PY + 106, 'PUZZLE COMPLETE', COLORS.goldHex, { size: '34px', letterSpacing: 4, align: 'left' });
|
||||
T(gTitle, LX, PY + 150, this.imageName || 'Puzzle', COLORS.textHex, { size: '20px', align: 'left' });
|
||||
|
||||
// Row 2 — solve time
|
||||
const gTime = this.add.container(0, 0);
|
||||
this.winLayer.add(gTime);
|
||||
T(gTime, LX, PY + 232, 'SOLVED IN', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
|
||||
T(gTime, LX, PY + 280, `${mm}:${ss}`, COLORS.textHex, { size: '56px', align: 'left' });
|
||||
|
||||
// Row 3 — pieces + difficulty
|
||||
const gStats = this.add.container(0, 0);
|
||||
this.winLayer.add(gStats);
|
||||
const rule = this.add.graphics();
|
||||
rule.lineStyle(1, 0x3a3226, 0.9);
|
||||
rule.lineBetween(LX, PY + 344, LX + (PW - 84), PY + 344);
|
||||
gStats.add(rule);
|
||||
T(gStats, LX, PY + 384, 'PIECES', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
|
||||
this.piecesBig = T(gStats, LX, PY + 424, '0', COLORS.textHex, { size: '30px', align: 'left' });
|
||||
const dx = PX + PW / 2 + 20;
|
||||
const diffName = (DIFFICULTIES[this.difficulty] && DIFFICULTIES[this.difficulty].label) || '—';
|
||||
T(gStats, dx, PY + 384, 'DIFFICULTY', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
|
||||
T(gStats, dx, PY + 424, diffName, COLORS.textHex, { size: '22px', align: 'left' });
|
||||
|
||||
// Row 4 — actions
|
||||
const gBtns = this.add.container(0, 0);
|
||||
this.winLayer.add(gBtns);
|
||||
this.mkButton(gBtns, 'Play Again', BCX, PY + 512, BW, 62, () => this.restart(), { fontSize: 24, bg: COLORS.gold });
|
||||
this.mkButton(gBtns, 'Menu', BCX, PY + 594, BW, 62, () => this.toMenu(), { fontSize: 24 });
|
||||
this.mkButton(gBtns, '🎲 Surprise Me', BCX, PY + 676, BW, 62, () => { this.randomImage(); this.restart(); }, { fontSize: 22, variant: 'ghost' });
|
||||
T(gBtns, BCX, PY + 782, 'The picture is complete — enjoy the view.', COLORS.mutedHex, { size: '16px' });
|
||||
|
||||
// ── Choreography ─────────────────────────────────────────────────────
|
||||
const stagger = (g, delay) => {
|
||||
g.setAlpha(0);
|
||||
const fx = g.x;
|
||||
g.x = fx - 46; // slide in from the left
|
||||
this.winTweens.push(this.tweens.add({ targets: g, x: fx, alpha: 1, delay, duration: 480, ease: 'Cubic.easeOut' }));
|
||||
this.winPanelGroups.push(g);
|
||||
};
|
||||
|
||||
this.winTweens.push(this.tweens.add({ targets: [dim, panelBg], alpha: 1, delay: 350, duration: 550, ease: 'Cubic.easeOut' }));
|
||||
stagger(gTitle, 500);
|
||||
stagger(gTime, 620);
|
||||
stagger(gStats, 740);
|
||||
stagger(gBtns, 860);
|
||||
|
||||
// Piece counter counts up as the stats row lands.
|
||||
const counter = { v: 0 };
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut',
|
||||
onUpdate: (tw) => { if (this.piecesBig) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); },
|
||||
}));
|
||||
|
||||
// Camera sweep — the finale fires when it lands.
|
||||
playSound(this, SFX.VEGA_ZOOMIN);
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: this.cameras.main,
|
||||
scrollX: winScrollX, scrollY: winScrollY, zoom: WIN_ZOOM,
|
||||
duration: 1200, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => this.winCelebrate(),
|
||||
}));
|
||||
this.winObjects = [this.winLayer];
|
||||
}
|
||||
|
||||
// Fired when the win camera lands: confetti, shine sweep, gold frame pulse.
|
||||
winCelebrate() {
|
||||
if (!this.winLayer || this.state !== 'won') return;
|
||||
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
|
||||
const TINTS = [0xd4a017, 0xf2ead8, 0xffffff, 0xc8a84b, 0xe06c75];
|
||||
|
||||
playSound(this, SFX.FIREWORK);
|
||||
playSound(this, SFX.EIGHTBIT_WIN);
|
||||
|
||||
// Burst from the heart of the finished puzzle.
|
||||
const burst = this.add.particles(bcx, bcy, 'jigsaw-confetti', {
|
||||
radial: true,
|
||||
lifespan: { min: 1200, max: 2400 },
|
||||
speed: { min: 220, max: 760 },
|
||||
angle: { min: 0, max: 360 },
|
||||
gravityY: 950,
|
||||
rotate: { start: -720, end: 720, random: true },
|
||||
scaleX: { min: 0.45, max: 1.25 },
|
||||
scaleY: { min: 0.45, max: 1.25 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
tint: TINTS,
|
||||
maxAliveParticles: 300,
|
||||
});
|
||||
if (this.hudCam) burst.cameraFilter = this.hudCam.id; // world camera only
|
||||
burst.setDepth(50); // above the finished pieces (depth 10-12)
|
||||
burst.explode(120);
|
||||
this.winEmitters.push(burst);
|
||||
|
||||
// Curtain of confetti falling across the whole frame (screen-anchored).
|
||||
const cam = this.cameras.main;
|
||||
const band = new Phaser.Geom.Rectangle(cam.scrollX, cam.scrollY, GAME_WIDTH / cam.zoom, 120);
|
||||
const curtain = this.add.particles(0, 0, 'jigsaw-confetti', {
|
||||
lifespan: { min: 1700, max: 3200 },
|
||||
speedX: { min: -70, max: 70 },
|
||||
speedY: { min: 90, max: 260 },
|
||||
gravityY: 430,
|
||||
rotate: { start: -540, end: 540, random: true },
|
||||
scaleX: { min: 0.4, max: 1.05 },
|
||||
scaleY: { min: 0.4, max: 1.05 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
tint: TINTS,
|
||||
emitZone: { type: 'random', source: band },
|
||||
maxAliveParticles: 220,
|
||||
});
|
||||
if (this.hudCam) curtain.cameraFilter = this.hudCam.id;
|
||||
curtain.setDepth(50); // above the finished pieces (depth 10-12)
|
||||
curtain.explode(90);
|
||||
this.winEmitters.push(curtain);
|
||||
|
||||
// Gold trophy frame around the board, then a gentle pulse.
|
||||
const frame = this.add.graphics().setDepth(4);
|
||||
if (this.hudCam) frame.cameraFilter = this.hudCam.id; // world camera only
|
||||
frame.lineStyle(3, COLORS.gold, 0.95);
|
||||
frame.strokeRoundedRect(BOARD.x - 16, BOARD.y - 16, BOARD.size + 32, BOARD.size + 32, 18);
|
||||
frame.lineStyle(1, 0xfff1cf, 0.5);
|
||||
frame.strokeRoundedRect(BOARD.x - 7, BOARD.y - 7, BOARD.size + 14, BOARD.size + 14, 12);
|
||||
frame.setAlpha(0);
|
||||
this.winFrame = frame;
|
||||
this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.95, duration: 600, ease: 'Cubic.easeOut' }));
|
||||
this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.62, duration: 1500, delay: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }));
|
||||
|
||||
// Shine sweep across the completed picture.
|
||||
const shine = this.add.image(bcx, bcy, 'jigsaw-shine')
|
||||
.setDisplaySize(BOARD.size * 0.55, BOARD.size * 1.9)
|
||||
.setRotation(-0.22)
|
||||
.setAlpha(0)
|
||||
.setDepth(40)
|
||||
.setBlendMode(Phaser.BlendModes.ADD);
|
||||
if (this.hudCam) shine.cameraFilter = this.hudCam.id; // world camera only
|
||||
this.winShine = shine;
|
||||
const x0 = bcx - BOARD.size * 1.05, x1 = bcx + BOARD.size * 1.05;
|
||||
shine.x = x0; // start off the left edge of the board, sweep through to the right
|
||||
this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.5, duration: 450, ease: 'Cubic.easeIn' }));
|
||||
this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.55, duration: 350, delay: 450, ease: 'Sine.easeInOut' }));
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: shine, x: x1, alpha: 0, duration: 520, delay: 800, ease: 'Cubic.easeOut',
|
||||
onComplete: () => shine.setVisible(false),
|
||||
}));
|
||||
}
|
||||
|
||||
teardownWin() {
|
||||
(this.winTweens || []).forEach((t) => t && t.stop());
|
||||
this.winTweens = null;
|
||||
(this.winEmitters || []).forEach((e) => e && e.destroy());
|
||||
this.winEmitters = null;
|
||||
this.winPanelGroups = null;
|
||||
this.piecesBig = null;
|
||||
if (this.winFrame) { this.winFrame.destroy(); this.winFrame = null; }
|
||||
if (this.winShine) { this.winShine.destroy(); this.winShine = null; }
|
||||
if (this.winObjects) {
|
||||
this.winObjects.forEach((o) => o.destroy());
|
||||
this.winObjects = null;
|
||||
this.winLayer = null;
|
||||
}
|
||||
this.winLayer = null;
|
||||
this.winDim = null;
|
||||
this.winPanelBg = null;
|
||||
if (this.textures.exists('jigsaw-confetti')) this.textures.remove('jigsaw-confetti');
|
||||
if (this.textures.exists('jigsaw-shine')) this.textures.remove('jigsaw-shine');
|
||||
}
|
||||
|
||||
teardownPlay() {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,17 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
const { tracks, volume } = getGameSoundtrack(this);
|
||||
if (tracks.length) new MusicPlayer(this, tracks, volume);
|
||||
} catch { /* optional */ }
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(DEPTH.bg);
|
||||
// Backdrop: the playfield chosen on the opponent-select screen (image
|
||||
// texture, or the generated canvas texture for the "colored" playfields),
|
||||
// falling back to its flat color and finally to the classic deep green.
|
||||
const pf = this.playfield;
|
||||
if (pf?.key && this.textures.exists(pf.key)) {
|
||||
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
|
||||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
|
||||
} else {
|
||||
const color = pf?.fallbackColor ? parseInt(pf.fallbackColor.replace('#', ''), 16) : FELT;
|
||||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color).setDepth(DEPTH.bg);
|
||||
}
|
||||
|
||||
const names = [auth.user?.username ?? 'You'];
|
||||
const skills = { 0: 5 };
|
||||
|
|
@ -130,11 +140,16 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
buildPortraits() {
|
||||
// Each portrait sits just to the left of its own tiles, at the vertical
|
||||
// centre of that area (rows/columns are all centred on their midpoints),
|
||||
// so it reads as belonging to that player without ever covering a tile.
|
||||
// Keep ~18-20px clearance: human hand left edge ≥ 391 (14 tiles), left
|
||||
// column left edge 133, right column left edge 1836, top row ≥ 616.
|
||||
const spots = [
|
||||
{ x: 90, y: 870, r: 46 }, // human — bottom left
|
||||
{ x: 1830, y: 170, r: 40 }, // seat 1 — right
|
||||
{ x: 560, y: 95, r: 40 }, // seat 2 — top
|
||||
{ x: 90, y: 310, r: 40 }, // seat 3 — left
|
||||
{ x: 325, y: 975, r: 46 }, // human — left of the bottom hand row
|
||||
{ x: 1778, y: 580, r: 40 }, // seat 1 — left of the right column
|
||||
{ x: 558, y: 70, r: 40 }, // seat 2 — left of the top row
|
||||
{ x: 75, y: 580, r: 40 }, // seat 3 — left of the left column
|
||||
];
|
||||
for (let seat = 0; seat < 4; seat++) {
|
||||
const { x, y, r } = spots[seat];
|
||||
|
|
@ -219,7 +234,10 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
}).setOrigin(0.5));
|
||||
this.refPanel = panel;
|
||||
|
||||
this.refBtn = new Button(this, 1810, 44, 'Hands', () => this.toggleReference(), {
|
||||
// Parked just left of the reference panel's open position (1490) so it
|
||||
// never ends up under it; y=210 clears seat 2's melds (y≤98) and bonus
|
||||
// tiles (y≤157), which sit at x 1330+.
|
||||
this.refBtn = new Button(this, 1399, 210, 'Hands', () => this.toggleReference(), {
|
||||
width: 150, height: 52, fontSize: 22, variant: 'ghost',
|
||||
}).setDepth(DEPTH.ref + 1);
|
||||
}
|
||||
|
|
@ -233,6 +251,15 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
ease: 'Quad.Out',
|
||||
});
|
||||
this.refBtn.setLabel(this.refOpen ? 'Close' : 'Hands');
|
||||
// Seat 1's portrait sits under the open panel. Its <video> is a DOM node
|
||||
// that always renders above canvas content, so hide it while covered.
|
||||
this.portraitCtrls[1]?.controller?.setVideoVisible?.(!this.refOpen);
|
||||
}
|
||||
|
||||
// The claim row and the self win/kong buttons sit under the open panel's
|
||||
// footprint — close it whenever they appear so nothing clickable is covered.
|
||||
closeReferenceIfOpen() {
|
||||
if (this.refOpen) this.toggleReference();
|
||||
}
|
||||
|
||||
// ── tile drawing ──────────────────────────────────────────────────────────────
|
||||
|
|
@ -382,7 +409,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
bx += 40;
|
||||
}
|
||||
} else { // sides — vertical column of rotated backs
|
||||
const x = seat === 1 ? 1858 : 62;
|
||||
const x = seat === 1 ? 1858 : 155; // columns stay right of their portraits (seat 1: 1778+40, seat 3: 75+40)
|
||||
let y = 580 - ((n - 1) * step) / 2;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const back = this.makeTileBack(SM_W, SM_H);
|
||||
|
|
@ -390,7 +417,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
this.dyn.add(back);
|
||||
y += step;
|
||||
}
|
||||
const mx = seat === 1 ? 1745 : 175;
|
||||
const mx = seat === 1 ? 1745 : 270; // melds/bonus offset from their column
|
||||
let my = 330;
|
||||
for (const m of p.melds) {
|
||||
const row = this.makeMeldRow(m, 38, 52, false);
|
||||
|
|
@ -481,6 +508,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
btn.setVisible(!!spec);
|
||||
if (spec) btn.setLabel(`Kong ${shortName(spec.kind)}`);
|
||||
});
|
||||
if (show && (acts?.canWin || this.kongSpecs.length > 0)) this.closeReferenceIfOpen();
|
||||
}
|
||||
|
||||
// ── human input ───────────────────────────────────────────────────────────────
|
||||
|
|
@ -521,6 +549,7 @@ export default class MahjongGame extends Phaser.Scene {
|
|||
return new Promise((resolve) => {
|
||||
this.claimResolve = resolve;
|
||||
this.chowOptions = opts.chows;
|
||||
this.closeReferenceIfOpen();
|
||||
const visible = [];
|
||||
if (opts.win) visible.push(this.claimBtns.win);
|
||||
if (opts.kong) visible.push(this.claimBtns.kong);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic, stopMenuMusic } from '../ui/MenuMusic.js';
|
||||
import { TutorialModal } from '../ui/TutorialModal.js';
|
||||
|
||||
let _lastCategory = null;
|
||||
|
||||
const CATEGORIES = [
|
||||
{ key: 'tabletop', label: 'Tabletop' },
|
||||
{ key: 'cards', label: 'Cards & Dice' },
|
||||
|
|
@ -22,10 +21,29 @@ const ICON_INACTIVE = 56;
|
|||
const ICON_ACTIVE = 72;
|
||||
const ICON_OVERSHOOT = 86;
|
||||
const ICON_X_OFFSET = -145;
|
||||
// How far (px) a grid travels off screen when switching categories.
|
||||
const GRID_TRAVEL = GAME_WIDTH + 800;
|
||||
|
||||
// The most recently selected category. Module scope so it survives scene
|
||||
// restarts (scene.start() reuses the instance) and also covers paths that
|
||||
// start the menu without category data — the menu always returns to where
|
||||
// the user last was.
|
||||
let lastCategory = null;
|
||||
|
||||
export default class GameMenuScene extends Phaser.Scene {
|
||||
constructor() { super('GameMenu'); }
|
||||
|
||||
init(data) {
|
||||
// Category to restore on entry: the explicit one (coming back from the
|
||||
// opponent picker) or the most recently selected one.
|
||||
this._initialCategory = (data && data.category) || lastCategory;
|
||||
// scene.start() reuses the existing scene instance, so instance props from
|
||||
// the previous visit survive — reset them so this create() starts clean
|
||||
// (otherwise showCategory()'s "same category" guard swallows the restore).
|
||||
this._currentCategory = null;
|
||||
this._gridAnim = null;
|
||||
}
|
||||
|
||||
async create() {
|
||||
playMenuMusic();
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
|
@ -33,12 +51,18 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this.add.image(cx, GAME_HEIGHT / 2, 'bg-menu').setDisplaySize(GAME_WIDTH, GAME_HEIGHT);
|
||||
addFullscreenButton(this);
|
||||
|
||||
const titleText = this.add.text(cx, 60, 'Choose a game', {
|
||||
const titleText = this.add.text(cx, 60, 'Choose a Game Category', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '64px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5).setDepth(1);
|
||||
this.add.rectangle(cx, 60, titleText.width + 64, titleText.height + 28, 0x000000, 0.7);
|
||||
titleText.setLetterSpacing(2);
|
||||
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
|
||||
this._titleText = titleText;
|
||||
const plateW = titleText.width + 64;
|
||||
const plateH = titleText.height + 28;
|
||||
this._titleBg = new Plaque(this, plateW, plateH).setPosition(cx, 60).setDepth(0.5);
|
||||
this._titlePlateW = plateW;
|
||||
|
||||
const loadingText = this.add.text(cx, 540, 'Loading game list…', {
|
||||
fontSize: '24px', color: COLORS.mutedHex,
|
||||
|
|
@ -107,14 +131,49 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._tabIcons[key] = icon;
|
||||
});
|
||||
|
||||
const startKey = allActiveCats.find(c => c.key === _lastCategory) ? _lastCategory : allActiveCats[0].key;
|
||||
this.showCategory(startKey);
|
||||
// No category is selected by default — the user picks one to see games.
|
||||
this._hintText = this.add.text(cx, 540, 'Select a category above to see its games', {
|
||||
fontSize: '26px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
this._backBtn = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
|
||||
// Returning from the opponent picker: restore the category that was
|
||||
// active when the game was chosen — its grid flies back in from the right.
|
||||
if (this._initialCategory) {
|
||||
this.showCategory(this._initialCategory);
|
||||
}
|
||||
|
||||
// The landing scene zooms the logo out before starting us, so fade the
|
||||
// menu controls in to complete the handoff.
|
||||
const fadeIn = [...Object.values(this._tabs), ...Object.values(this._tabIcons), this._hintText, this._backBtn].filter(Boolean);
|
||||
for (const obj of fadeIn) obj.setAlpha(0);
|
||||
this.tweens.add({
|
||||
targets: fadeIn,
|
||||
alpha: 1,
|
||||
duration: 450,
|
||||
ease: 'Power2.easeOut',
|
||||
});
|
||||
}
|
||||
|
||||
showCategory(key) {
|
||||
_lastCategory = key;
|
||||
if (key === this._currentCategory) return;
|
||||
this._currentCategory = key;
|
||||
lastCategory = key;
|
||||
|
||||
if (this._hintText) { this._hintText.destroy(); this._hintText = null; }
|
||||
if (this._titleText) {
|
||||
this._titleText.setText('Choose a game');
|
||||
const newW = this._titleText.width + 64;
|
||||
const newH = this._titleText.height + 28;
|
||||
if (this._titlePlateW && Math.abs(this._titlePlateW - newW) > 1) {
|
||||
this._titleBg.animateSize(newW, newH, 240, 'Quad.easeOut');
|
||||
this._titlePlateW = newW;
|
||||
}
|
||||
// Little pop so the label swap reads as intentional.
|
||||
this._titleText.setScale(0.9);
|
||||
this.tweens.add({ targets: this._titleText, scaleX: 1, scaleY: 1, duration: 240, ease: 'Back.easeOut' });
|
||||
}
|
||||
for (const [k, btn] of Object.entries(this._tabs)) {
|
||||
btn.setActive(k === key);
|
||||
}
|
||||
|
|
@ -162,13 +221,41 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
}
|
||||
}
|
||||
|
||||
for (const obj of this._gameObjects) obj.destroy();
|
||||
this._gameObjects = [];
|
||||
// Interrupt any grid animation still in flight (user changed their mind mid-flight).
|
||||
if (this._gridAnim) {
|
||||
for (const obj of this._gridAnim.objects) obj.destroy();
|
||||
this._gridAnim = null;
|
||||
this._gameObjects = [];
|
||||
}
|
||||
if (this._ptrMoveHandler) {
|
||||
this.input.off('pointermove', this._ptrMoveHandler, this);
|
||||
this._ptrMoveHandler = null;
|
||||
}
|
||||
|
||||
const oldObjects = this._gameObjects;
|
||||
this._gameObjects = [];
|
||||
if (oldObjects.length === 0) {
|
||||
this.buildCategoryGrid(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fly the outgoing grid off the left edge, then bring the new one in from the right.
|
||||
for (const obj of oldObjects) obj.disableInteractive();
|
||||
this._gridAnim = { objects: oldObjects };
|
||||
this.tweens.add({
|
||||
targets: oldObjects,
|
||||
x: `-=${GRID_TRAVEL}`,
|
||||
duration: 380,
|
||||
ease: 'Power2.easeIn',
|
||||
onComplete: () => {
|
||||
this._gridAnim = null;
|
||||
for (const obj of oldObjects) obj.destroy();
|
||||
this.buildCategoryGrid(key);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
buildCategoryGrid(key) {
|
||||
const games = this._gamesByCategory[key];
|
||||
if (!games || games.length === 0) return;
|
||||
|
||||
|
|
@ -267,6 +354,18 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
this._gameObjects.push(qg, qLabel);
|
||||
}
|
||||
});
|
||||
|
||||
// Fly the grid in from off screen right.
|
||||
const objs = this._gameObjects;
|
||||
for (const obj of objs) obj.x += GRID_TRAVEL;
|
||||
this._gridAnim = { objects: objs };
|
||||
this.tweens.add({
|
||||
targets: objs,
|
||||
x: `-=${GRID_TRAVEL}`,
|
||||
duration: 420,
|
||||
ease: 'Power2.easeOut',
|
||||
onComplete: () => { this._gridAnim = null; },
|
||||
});
|
||||
}
|
||||
|
||||
openGame(game) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as Phaser from 'phaser';
|
|||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { addFullscreenButton } from '../ui/FullscreenButton.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
|
|
@ -16,6 +17,8 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
const logo = this.add.image(cx, 290, 'main-title').setOrigin(0.5, 0.5).setAlpha(0);
|
||||
logo.postFX.addShadow(3, 6, 0.005, 2, 0x000000, 10, 0.75);
|
||||
this._logo = logo;
|
||||
this._transitioning = false;
|
||||
|
||||
this.tweens.add({
|
||||
targets: logo,
|
||||
|
|
@ -53,6 +56,7 @@ export default class LandingScene extends Phaser.Scene {
|
|||
renderButtons() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
const user = auth.user;
|
||||
this._avatar = null;
|
||||
|
||||
const avatarR = 28;
|
||||
const avatarGap = 18;
|
||||
|
|
@ -65,6 +69,8 @@ export default class LandingScene extends Phaser.Scene {
|
|||
fontSize: '36px',
|
||||
color: COLORS.accentHex,
|
||||
}).setOrigin(0.5).setDepth(1);
|
||||
this._welcomeText = welcomeText;
|
||||
welcomeText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||
|
||||
const hasAvatar = !!user?.avatarPath;
|
||||
const totalW = hasAvatar ? avatarR * 2 + avatarGap + welcomeText.width : welcomeText.width;
|
||||
|
|
@ -72,7 +78,8 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
welcomeText.setX(hasAvatar ? groupLeft + avatarR * 2 + avatarGap + welcomeText.width / 2 : cx);
|
||||
|
||||
this.add.rectangle(cx, y, totalW + pad.x * 2, welcomeText.height + pad.y * 2, 0x000000, 0.45);
|
||||
this._welcomeBg = new Plaque(this, totalW + pad.x * 2, welcomeText.height + pad.y * 2, { radius: 16 })
|
||||
.setPosition(cx, y);
|
||||
|
||||
if (hasAvatar) {
|
||||
const avatarCx = groupLeft + avatarR;
|
||||
|
|
@ -86,11 +93,11 @@ export default class LandingScene extends Phaser.Scene {
|
|||
this.load.start();
|
||||
});
|
||||
}
|
||||
if (!this.scene.isActive('Landing')) return;
|
||||
if (this._transitioning || !this.scene.isActive('Landing')) return;
|
||||
const maskG = this.make.graphics({ x: 0, y: 0, add: false });
|
||||
maskG.fillStyle(0xffffff);
|
||||
maskG.fillCircle(avatarCx, y, avatarR);
|
||||
this.add.image(avatarCx, y, key)
|
||||
this._avatar = this.add.image(avatarCx, y, key)
|
||||
.setDisplaySize(avatarR * 2, avatarR * 2)
|
||||
.setMask(maskG.createGeometryMask())
|
||||
.setDepth(1);
|
||||
|
|
@ -98,7 +105,39 @@ export default class LandingScene extends Phaser.Scene {
|
|||
})();
|
||||
}
|
||||
|
||||
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
|
||||
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
this.playBtn = new Button(this, cx, 810, 'Play', () => this.goToGameMenu(), { width: 480 });
|
||||
this.profileBtn = new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
}
|
||||
|
||||
goToGameMenu() {
|
||||
if (this._transitioning) return;
|
||||
this._transitioning = true;
|
||||
|
||||
this.playBtn.disableInteractive();
|
||||
this.profileBtn.disableInteractive();
|
||||
|
||||
// Fade the landing buttons out while the logo zooms past the camera.
|
||||
const fadeOut = [this.playBtn, this.profileBtn, this._welcomeText, this._welcomeBg].filter(Boolean);
|
||||
if (this._avatar) fadeOut.push(this._avatar);
|
||||
this.tweens.add({
|
||||
targets: fadeOut,
|
||||
alpha: 0,
|
||||
duration: 350,
|
||||
ease: 'Power2.easeOut',
|
||||
});
|
||||
|
||||
if (!this._logo) { this.scene.start('GameMenu'); return; }
|
||||
this.tweens.killTweensOf(this._logo);
|
||||
this.tweens.add({
|
||||
targets: this._logo,
|
||||
x: GAME_WIDTH / 2,
|
||||
y: GAME_HEIGHT / 2,
|
||||
scaleX: 2.4,
|
||||
scaleY: 2.4,
|
||||
alpha: 0,
|
||||
duration: 700,
|
||||
ease: 'Cubic.easeIn',
|
||||
onComplete: () => this.scene.start('GameMenu'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Plaque } from '../ui/Plaque.js';
|
||||
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
|
||||
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
|
||||
|
||||
|
|
@ -17,8 +18,8 @@ const CARD_TILE_GAP = 14;
|
|||
|
||||
// Opponent grid scroll area
|
||||
const OPP_SCROLL_W = 1780;
|
||||
const OPP_SCROLL_H = 440;
|
||||
const OPP_SCROLL_TOP = 155; // top edge of scroll area
|
||||
const OPP_SCROLL_H = 415;
|
||||
const OPP_SCROLL_TOP = 180; // top edge of scroll area (below the subtitle plaque)
|
||||
|
||||
export default class OpponentSelectScene extends Phaser.Scene {
|
||||
constructor() { super('OpponentSelect'); }
|
||||
|
|
@ -58,21 +59,32 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
|
||||
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
|
||||
|
||||
const titleText = this.add.text(cx, 60, this.gameDef.name, {
|
||||
// Brass-plaque headers, same treatment as the game menu headings. The
|
||||
// subtitle plaque sits in the band between the title plaque and the
|
||||
// opponent list (OPP_SCROLL_TOP) — keep those in sync if you move either.
|
||||
const TITLE_Y = 55;
|
||||
const PLATE_GAP = 10;
|
||||
const titleText = this.add.text(cx, TITLE_Y, this.gameDef.name, {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
|
||||
this.children.moveBelow(titlePill, titleText);
|
||||
titleText.setLetterSpacing(2);
|
||||
titleText.setShadow(0, 4, 'rgba(0,0,0,0.9)', 8);
|
||||
const titlePlate = new Plaque(this, titleText.width + 64, titleText.height + 28).setPosition(cx, TITLE_Y);
|
||||
this.children.moveBelow(titlePlate, titleText);
|
||||
|
||||
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
|
||||
const subtitleText = this.add.text(cx, 0, 'Choose your opponent', {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '36px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
|
||||
this.children.moveBelow(subtitlePill, subtitleText);
|
||||
subtitleText.setShadow(0, 3, 'rgba(0,0,0,0.8)', 5);
|
||||
const subPlateH = subtitleText.height + 28;
|
||||
const subtitleY = TITLE_Y + (titleText.height + 28) / 2 + PLATE_GAP + subPlateH / 2;
|
||||
subtitleText.setY(subtitleY);
|
||||
const subtitlePlate = new Plaque(this, subtitleText.width + 64, subPlateH, { radius: 16 }).setPosition(cx, subtitleY);
|
||||
this.children.moveBelow(subtitlePlate, subtitleText);
|
||||
|
||||
let opponents = [];
|
||||
try {
|
||||
|
|
@ -87,7 +99,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
const min = this.gameDef.minOpponents ?? 1;
|
||||
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
|
||||
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu', { category: this.gameDef.category }), {
|
||||
variant: 'ghost',
|
||||
width: 280,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ export class Button extends Phaser.GameObjects.Container {
|
|||
if (this._active) return;
|
||||
const { bgHover: bgh, textHoverColor: thc, variant: v } = this.options;
|
||||
if (v === 'ghost') {
|
||||
this._drawBg(bgh, 0.18);
|
||||
this.text.setColor(COLORS.goldHex);
|
||||
this._drawBg(bgh, 0.9);
|
||||
this.text.setColor(thc);
|
||||
} else {
|
||||
this._drawBg(bgh, 1);
|
||||
this.text.setColor(thc);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { COLORS } from '../config.js';
|
||||
|
||||
// A rounded, brass-trimmed plaque in the same design language as the
|
||||
// category tabs. Use instead of a flat black rectangle behind title text.
|
||||
export class Plaque extends Phaser.GameObjects.Graphics {
|
||||
constructor(scene, width = 300, height = 90, options = {}) {
|
||||
super(scene, false);
|
||||
const {
|
||||
fill = 0x241c10,
|
||||
fillAlpha = 0.92,
|
||||
border = COLORS.accent,
|
||||
radius = 18,
|
||||
} = options;
|
||||
this._opts = { fill, fillAlpha, border, radius };
|
||||
this._w = width;
|
||||
this._h = height;
|
||||
this.postFX.addShadow(0, 3, 0.004, 1.5, 0x000000, 8, 0.65);
|
||||
this.redraw(width, height);
|
||||
scene.add.existing(this);
|
||||
}
|
||||
|
||||
redraw(w, h) {
|
||||
this._w = w;
|
||||
this._h = h;
|
||||
const { fill, fillAlpha, border, radius } = this._opts;
|
||||
const r = Math.max(4, Math.min(radius, w / 2, h / 2));
|
||||
this.clear();
|
||||
this.fillStyle(fill, fillAlpha);
|
||||
this.fillRoundedRect(-w / 2, -h / 2, w, h, r);
|
||||
// Faint warm highlight just inside the top edge reads as a raised plate.
|
||||
this.lineStyle(2, 0xfff6dd, 0.16);
|
||||
this.lineBetween(-w / 2 + r + 4, -h / 2 + 4, w / 2 - r - 4, -h / 2 + 4);
|
||||
this.lineStyle(2, border, 1);
|
||||
this.strokeRoundedRect(-w / 2, -h / 2, w, h, r);
|
||||
}
|
||||
|
||||
// Animate the plate to a new size. Graphics in this Phaser build exposes
|
||||
// no setScaleX/setScaleY (and scaling a redrawn shape is unreliable), so
|
||||
// we tween a size proxy and redraw every frame instead.
|
||||
animateSize(w, h, duration = 240, ease = 'Quad.easeOut', onComplete) {
|
||||
if (this._sizeTween) this._sizeTween.stop();
|
||||
const proxy = { w: this._w, h: this._h };
|
||||
this._sizeTween = this.scene.tweens.add({
|
||||
targets: proxy,
|
||||
w,
|
||||
h,
|
||||
duration,
|
||||
ease,
|
||||
onUpdate: () => {
|
||||
if (!this.scene) return; // destroyed mid-animation
|
||||
this.redraw(proxy.w, proxy.h);
|
||||
},
|
||||
onComplete: () => {
|
||||
this._sizeTween = null;
|
||||
if (!this.scene) return; // destroyed mid-animation
|
||||
this.redraw(w, h);
|
||||
if (onComplete) onComplete();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
|
||||
//
|
||||
// Usage:
|
||||
// npx playwright install chromium # one-time
|
||||
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
|
||||
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
|
||||
// node tools/smokeJigsaw.mjs http://localhost:9000
|
||||
//
|
||||
// What it checks:
|
||||
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
|
||||
// - forced win (placed = total; onWin()) runs the full showcase:
|
||||
// * camera sweep lands on WIN_ZOOM framing the board
|
||||
// * stats counter animates up to the total
|
||||
// * celebration emitters fire (burst + curtain) and confetti is alive
|
||||
// * gold frame + shine sweep exist at the right depths
|
||||
// - "Play Again" restarts a fresh board and tears the win layer down
|
||||
// - "Menu" returns to the menu and destroys the win layer
|
||||
// - menu → Start Puzzle works again
|
||||
// - zero JS console/page errors across the whole flow
|
||||
//
|
||||
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
|
||||
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
|
||||
// waits here are condition-based polling (generous timeouts), never fixed
|
||||
// sleeps. A full run takes roughly 1–3 minutes of wall time.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
|
||||
|
||||
let server = null;
|
||||
if (!process.argv[2]) {
|
||||
try {
|
||||
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
|
||||
} catch {
|
||||
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('started local static server on :8000');
|
||||
}
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
let failed = false;
|
||||
const ok = (m) => console.log(' ok ' + m);
|
||||
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
||||
|
||||
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
|
||||
const waitFor = async (name, cond, timeoutMs) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (await evalS(cond)) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
bad(`timeout waiting for: ${name}`);
|
||||
return false;
|
||||
};
|
||||
const snap = async (name) => {
|
||||
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
ok(`screenshot ${dest}`);
|
||||
};
|
||||
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
|
||||
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
|
||||
|
||||
try {
|
||||
console.log('— load —');
|
||||
await page.goto(BASE, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
|
||||
ok('harness ready');
|
||||
|
||||
console.log('— start puzzle —');
|
||||
await evalS(() => window.__startPuzzle());
|
||||
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
|
||||
ok('state = playing');
|
||||
const pieces = await evalS(() => window.__pieces());
|
||||
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
|
||||
if (pieces.length === 0) bad('no pieces spawned');
|
||||
await snap('1_playing');
|
||||
|
||||
console.log('— force win —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
|
||||
|
||||
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
|
||||
if (await waitFor('camera settled + counter=total', () => {
|
||||
const s = window.__scene();
|
||||
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
|
||||
&& s.piecesBig && s.piecesBig.text === String(s.total);
|
||||
}, settleTimeout)) {
|
||||
const wi = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
|
||||
});
|
||||
ok('settled: ' + JSON.stringify(wi));
|
||||
await snap('2_win_panel');
|
||||
}
|
||||
|
||||
console.log('— wait for celebration (emitters created + particles alive) —');
|
||||
if (await waitFor('emitters alive', () => {
|
||||
const s = window.__scene();
|
||||
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
|
||||
}, settleTimeout)) {
|
||||
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
|
||||
ok('particles alive: ' + JSON.stringify(alive));
|
||||
await snap('3_confetti');
|
||||
const fx = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
|
||||
});
|
||||
ok('frame/shine: ' + JSON.stringify(fx));
|
||||
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
|
||||
}
|
||||
|
||||
console.log('— Play Again button —');
|
||||
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
|
||||
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
|
||||
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
|
||||
if (placed !== 0) bad('pieces not reset after Play Again');
|
||||
if (!winGone) bad('winLayer not destroyed after Play Again');
|
||||
await snap('4_restarted');
|
||||
}
|
||||
|
||||
console.log('— force win again, then Menu button —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
|
||||
ok('second win showcase running');
|
||||
await click(400, 720); // inside "Menu"
|
||||
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
|
||||
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
|
||||
if (!menuVisible) bad('menu not visible');
|
||||
if (!winGone) bad('winLayer not destroyed');
|
||||
await snap('5_menu');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('— Start Puzzle from menu works —');
|
||||
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
|
||||
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
|
||||
}
|
||||
|
||||
console.log('\n— JS errors (' + errors.length + ') —');
|
||||
errors.forEach((e) => console.log(' ' + e));
|
||||
if (errors.length) failed = true;
|
||||
|
||||
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
// verifyBookwork.js — engine tests for Bookwork
|
||||
// verifyBookwork.js — engine tests for Bookworm
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
GRID_SIZE, makeGrid, getAdjacent, isAdjacent,
|
||||
wordFromCells, computeDamage, computeSelfDamage,
|
||||
clearAndRefill, dropSpecialTile, countPoisonTiles,
|
||||
computeMaxHp, isPotionUnlocked,
|
||||
computeMaxHp, isPotionUnlocked, specialTileChances, SPECIAL_TILE_CHANCES,
|
||||
} from '../src/games/bookwork/BookworkLogic.js';
|
||||
import { refillSteered } from '../src/games/bookwork/BookworkSteering.js';
|
||||
import { getAttackDamage, getSpecialTile } from '../src/games/bookwork/BookworkAI.js';
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
|
|
@ -92,6 +93,34 @@ const after = clearAndRefill(gridR, used);
|
|||
ok('grid still 5×5 after refill', after.length === GRID_SIZE && after.every((r) => r.length === GRID_SIZE));
|
||||
ok('all cells have letter+type after refill', after.flat().every((c) => c.letter && c.type));
|
||||
|
||||
// ── special tile schedule ────────────────────────────────────────────────────
|
||||
console.log('\nspecial tile schedule');
|
||||
const SCHED = {
|
||||
startLevel: 1, endLevel: 10,
|
||||
start: { gold: 0.10, diamond: 0.06 },
|
||||
end: { gold: 0.03, diamond: 0.02 },
|
||||
};
|
||||
ok('no cfg → current defaults', specialTileChances(1) .goldChance === 0.03 && specialTileChances(1).diamondChance === 0.02);
|
||||
const at = (lv) => specialTileChances(lv, SCHED);
|
||||
ok('level 1 = boosted start', at(1).goldChance === 0.10 && at(1).diamondChance === 0.06);
|
||||
ok('level 10 = current rates', at(10).goldChance === 0.03 && at(10).diamondChance === 0.02);
|
||||
ok('level 20 holds at current rates', at(20).goldChance === 0.03 && at(20).diamondChance === 0.02);
|
||||
ok('clamps below startLevel', at(0).goldChance === 0.10);
|
||||
const ramp = Array.from({ length: 10 }, (_, i) => specialTileChances(i + 1, SCHED).goldChance);
|
||||
ok('gold chance monotonically decreases L1→L10', ramp.every((v, i) => i === 0 || v <= ramp[i - 1]));
|
||||
ok('ramp stays within [end, start]', ramp.every((v) => v >= 0.03 && v <= 0.10));
|
||||
|
||||
const forcedGold = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 1 });
|
||||
const newCount = 3;
|
||||
ok('goldChance:1 → every fresh tile is gold',
|
||||
forcedGold.flat().filter((c) => c.type === 'gold').length === newCount);
|
||||
const forcedDiamond = clearAndRefill(makeGrid(), used, Math.random, { goldChance: 0, diamondChance: 1 });
|
||||
ok('diamondChance:1 → every fresh tile is diamond',
|
||||
forcedDiamond.flat().filter((c) => c.type === 'diamond').length === newCount);
|
||||
const steeredGold = refillSteered(makeGrid(), used, Math.random, null, { goldChance: 1 });
|
||||
ok('refillSteered honors goldChance:1',
|
||||
steeredGold.flat().filter((c) => c.type === 'gold').length === newCount);
|
||||
|
||||
// ── dropSpecialTile ────────────────────────────────────────────────────────────
|
||||
console.log('\ndropSpecialTile');
|
||||
const gridS = makeGrid(() => 0.5);
|
||||
|
|
@ -175,6 +204,10 @@ if (bwData) {
|
|||
ok('hp increases over levels', bwData.levels[19].hp > bwData.levels[0].hp);
|
||||
ok('playerBaseHp present', bwData.playerBaseHp > 0);
|
||||
ok('milestones array present', Array.isArray(bwData.milestones));
|
||||
const st = bwData.specialTiles;
|
||||
ok('specialTiles schedule well-formed', !!st && st.startLevel < st.endLevel
|
||||
&& st.start.gold > st.end.gold > 0 && st.start.diamond > st.end.diamond > 0
|
||||
&& st.end.gold === SPECIAL_TILE_CHANCES.gold && st.end.diamond === SPECIAL_TILE_CHANCES.diamond);
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
#!/usr/bin/env node
|
||||
// verifyBookwormBoard.js — Bookworm board-quality harness
|
||||
//
|
||||
// Compares the plain weighted-random board (baseline) against the steering
|
||||
// layer (class steering + vowel band + word-completion boost) on:
|
||||
// • static boards — words available, dead boards, vowel share
|
||||
// • simulated sessions — play the longest word each turn, refill, repeat:
|
||||
// words available per turn, dead turns (no word to play), turn survival
|
||||
//
|
||||
// Deterministic (seeded RNG). The dictionary is the same ENABLE list
|
||||
// (3–15 letters) that /words/scrabble/validate accepts, so "available word"
|
||||
// means "word the player can actually submit".
|
||||
//
|
||||
// Run: node tools/verifyBookwormBoard.js
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
GRID_SIZE, makeGrid, clearAndRefill, getAdjacent,
|
||||
} from '../src/games/bookwork/BookworkLogic.js';
|
||||
import {
|
||||
makeSteeredGrid, refillSteered, parseWordList, letterWeights, pickWeighted, DEFAULT_STEER,
|
||||
} from '../src/games/bookwork/BookworkSteering.js';
|
||||
|
||||
const N_BOARDS = 300;
|
||||
const N_SESSIONS = 60;
|
||||
const MAX_TURNS = 30;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
function ok(label, cond) {
|
||||
if (cond) { console.log(` ✓ ${label}`); pass++; }
|
||||
else { console.error(` ✗ ${label}`); fail++; }
|
||||
}
|
||||
|
||||
// ── deterministic rng ─────────────────────────────────────────────────────────
|
||||
function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ── word finding (boggle DFS with prefix pruning; one path per word) ─────────
|
||||
function buildPrefixSet(wordSet) {
|
||||
const pre = new Set();
|
||||
for (const w of wordSet) for (let i = 1; i <= w.length; i++) pre.add(w.slice(0, i));
|
||||
return pre;
|
||||
}
|
||||
|
||||
function findWords(grid, wordSet, prefixSet) {
|
||||
const found = new Map(); // word -> [cells]
|
||||
const visited = Array.from({ length: GRID_SIZE }, () => new Array(GRID_SIZE).fill(false));
|
||||
const dfs = (r, c, word, cells) => {
|
||||
const w = word + grid[r][c].letter;
|
||||
const isWord = w.length >= 3 && wordSet.has(w);
|
||||
if (isWord && !found.has(w)) found.set(w, [{ r, c }, ...cells]);
|
||||
if (!prefixSet.has(w)) return;
|
||||
if (cells.length + 1 >= GRID_SIZE * GRID_SIZE) return;
|
||||
for (const { r: nr, c: nc } of getAdjacent(r, c)) {
|
||||
if (visited[nr][nc]) continue;
|
||||
visited[nr][nc] = true;
|
||||
dfs(nr, nc, w, [...cells, { r, c }]);
|
||||
visited[nr][nc] = false;
|
||||
}
|
||||
};
|
||||
for (let r = 0; r < GRID_SIZE; r++) for (let c = 0; c < GRID_SIZE; c++) {
|
||||
visited[r][c] = true;
|
||||
dfs(r, c, '', []);
|
||||
visited[r][c] = false;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const VOWELS = new Set(['A', 'E', 'I', 'O', 'U']);
|
||||
const vowelShare = (grid) =>
|
||||
grid.flat().filter((c) => c.letter && VOWELS.has(c.letter)).length / (GRID_SIZE * GRID_SIZE);
|
||||
|
||||
const quantile = (arr, q) => {
|
||||
const s = [...arr].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor(q * s.length))];
|
||||
};
|
||||
const mean = (arr) => arr.reduce((a, b) => a + b, 0) / Math.max(1, arr.length);
|
||||
|
||||
// ── setup ─────────────────────────────────────────────────────────────────────
|
||||
console.log('Loading dictionary…');
|
||||
const wordSet = parseWordList(readFileSync('./data/wordlists/enable1.txt', 'utf8'));
|
||||
const prefixSet = buildPrefixSet(wordSet);
|
||||
console.log(` ${wordSet.size} words (3–15 letters), ${prefixSet.size} prefixes\n`);
|
||||
|
||||
const variants = {
|
||||
baseline: {
|
||||
make: (rng) => makeGrid(rng),
|
||||
refill: (g, cells, rng) => clearAndRefill(g, cells, rng),
|
||||
},
|
||||
steered: {
|
||||
make: (rng) => makeSteeredGrid(rng, wordSet, DEFAULT_STEER),
|
||||
refill: (g, cells, rng) => refillSteered(g, cells, rng, wordSet, DEFAULT_STEER),
|
||||
},
|
||||
};
|
||||
|
||||
// ── steering sanity ───────────────────────────────────────────────────────────
|
||||
console.log('Steering sanity');
|
||||
{
|
||||
const rng = mulberry32(1);
|
||||
const g = makeSteeredGrid(rng, wordSet, DEFAULT_STEER);
|
||||
ok('steered grid is 5×5', g.length === GRID_SIZE && g.every((r) => r.length === GRID_SIZE));
|
||||
ok('all cells filled with A-Z', g.flat().every((c) => /^[A-Z]$/.test(c.letter)));
|
||||
ok('no Q (absent from base pool)', !g.flat().some((c) => c.letter === 'Q'));
|
||||
const w = letterWeights(g, 2, 2, wordSet, DEFAULT_STEER);
|
||||
ok('letterWeights: Q weight is 0', w['Q'.charCodeAt(0) - 65] === 0);
|
||||
ok('letterWeights: total weight > 0', w.reduce((a, b) => a + b, 0) > 0);
|
||||
const letter = pickWeighted(w, mulberry32(7));
|
||||
ok('pickWeighted returns a letter in the pool', /^[A-Z]$/.test(letter) && letter !== 'Q');
|
||||
const ref = refillSteered(g, [{ r: 0, c: 0 }, { r: 1, c: 0 }, { r: 2, c: 0 }], mulberry32(3), wordSet, DEFAULT_STEER);
|
||||
ok('steered refill keeps 5×5', ref.length === GRID_SIZE && ref.flat().every((c) => /^[A-Z]$/.test(c.letter)));
|
||||
}
|
||||
|
||||
// ── static board quality ──────────────────────────────────────────────────────
|
||||
console.log(`\nStatic boards (N=${N_BOARDS} each)`);
|
||||
const staticStats = {};
|
||||
for (const [name, v] of Object.entries(variants)) {
|
||||
const wordCounts = [], shares = [];
|
||||
for (let i = 0; i < N_BOARDS; i++) {
|
||||
const g = v.make(mulberry32(1000 + i));
|
||||
wordCounts.push(findWords(g, wordSet, prefixSet).size);
|
||||
shares.push(vowelShare(g));
|
||||
}
|
||||
staticStats[name] = {
|
||||
wordCounts,
|
||||
dead: wordCounts.filter((n) => n === 0).length,
|
||||
min: Math.min(...wordCounts),
|
||||
p5: quantile(wordCounts, 0.05),
|
||||
median: quantile(wordCounts, 0.5),
|
||||
mean: mean(wordCounts),
|
||||
max: Math.max(...wordCounts),
|
||||
vowelMin: Math.min(...shares),
|
||||
vowelMax: Math.max(...shares),
|
||||
vowelMean: mean(shares),
|
||||
};
|
||||
}
|
||||
|
||||
const row = (label, f) =>
|
||||
console.log(` ${label.padEnd(28)} ${String(f('baseline')).padStart(14)} ${String(f('steered')).padStart(14)}`);
|
||||
console.log(' ' + 'metric'.padEnd(28) + 'baseline'.padStart(14) + 'steered'.padStart(14));
|
||||
row('words/board (min)', (n) => staticStats[n].min);
|
||||
row('words/board (p5)', (n) => staticStats[n].p5);
|
||||
row('words/board (median)', (n) => staticStats[n].median);
|
||||
row('words/board (mean)', (n) => staticStats[n].mean.toFixed(1));
|
||||
row('words/board (max)', (n) => staticStats[n].max);
|
||||
row('dead boards (0 words)', (n) => `${staticStats[n].dead} (${(100 * staticStats[n].dead / N_BOARDS).toFixed(1)}%)`);
|
||||
row('vowel share (min–max)', (n) => `${(100 * staticStats[n].vowelMin).toFixed(0)}–${(100 * staticStats[n].vowelMax).toFixed(0)}%`);
|
||||
row('vowel share (mean)', (n) => `${(100 * staticStats[n].vowelMean).toFixed(1)}%`);
|
||||
|
||||
// ── simulated sessions ────────────────────────────────────────────────────────
|
||||
console.log(`\nSimulated sessions (N=${N_SESSIONS}, max ${MAX_TURNS} turns, greedy longest-word play)`);
|
||||
const sessionStats = {};
|
||||
for (const [name, v] of Object.entries(variants)) {
|
||||
const totals = [], wpt = [], dead = [], initial = [], longest = [];
|
||||
for (let i = 0; i < N_SESSIONS; i++) {
|
||||
const rng = mulberry32(90000 + i);
|
||||
let grid = v.make(rng);
|
||||
let deadTurn = false, total = 0;
|
||||
for (let t = 0; t < MAX_TURNS; t++) {
|
||||
const words = findWords(grid, wordSet, prefixSet);
|
||||
if (t === 0) initial.push(words.size);
|
||||
if (words.size === 0) { deadTurn = true; break; }
|
||||
total += words.size;
|
||||
wpt.push(words.size);
|
||||
let best = null;
|
||||
for (const [w, cells] of words) if (!best || w.length > best.length) best = { w, cells };
|
||||
longest.push(best.w.length);
|
||||
grid = v.refill(grid, best.cells, rng);
|
||||
}
|
||||
totals.push(total);
|
||||
if (deadTurn) dead.push(1); else dead.push(0);
|
||||
}
|
||||
sessionStats[name] = {
|
||||
deadRate: mean(dead),
|
||||
totalMedian: quantile(totals, 0.5),
|
||||
totalMean: mean(totals),
|
||||
wptMin: Math.min(...wpt),
|
||||
wptP5: quantile(wpt, 0.05),
|
||||
wptMedian: quantile(wpt, 0.5),
|
||||
initialMedian: quantile(initial, 0.5),
|
||||
longestMax: Math.max(...longest),
|
||||
};
|
||||
}
|
||||
|
||||
row('dead sessions (stuck)', (n) => `${Math.round(sessionStats[n].deadRate * N_SESSIONS)}/${N_SESSIONS} (${(100 * sessionStats[n].deadRate).toFixed(0)}%)`);
|
||||
row('initial words (median)', (n) => sessionStats[n].initialMedian);
|
||||
row('words/turn (min)', (n) => sessionStats[n].wptMin);
|
||||
row('words/turn (p5)', (n) => sessionStats[n].wptP5);
|
||||
row('words/turn (median)', (n) => sessionStats[n].wptMedian);
|
||||
row('total words (median)', (n) => sessionStats[n].totalMedian);
|
||||
row('total words (mean)', (n) => sessionStats[n].totalMean.toFixed(0));
|
||||
row('longest word seen', (n) => sessionStats[n].longestMax);
|
||||
|
||||
// ── comparisons ───────────────────────────────────────────────────────────────
|
||||
console.log('\nComparison');
|
||||
ok('steered median words/board ≥ baseline', staticStats.steered.median >= staticStats.baseline.median);
|
||||
ok('steered dead boards ≤ baseline', staticStats.steered.dead <= staticStats.baseline.dead);
|
||||
ok('steered p5 words/board ≥ baseline', staticStats.steered.p5 >= staticStats.baseline.p5);
|
||||
ok('steered dead-session rate ≤ baseline', sessionStats.steered.deadRate <= sessionStats.baseline.deadRate);
|
||||
ok('steered median words/turn ≥ baseline', sessionStats.steered.wptMedian >= sessionStats.baseline.wptMedian);
|
||||
ok('steered median total words ≥ baseline', sessionStats.steered.totalMedian >= sessionStats.baseline.totalMedian);
|
||||
|
||||
console.log(`\n${pass + fail} checks: ${pass} passed, ${fail} failed\n`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
Loading…
Reference in New Issue