Add Jigsaw puzzle game with drag-and-drop pieces and pan/zoom

- New Phaser scene (JigsawGame) supporting 4 difficulty tiers (25–144 pieces),
  image selection, timer, hint overlay, and win screen.
- Pure geometry module (JigsawLogic) generates seeded tab/blank outlines so
  adjacent pieces mesh exactly; also provides deterministic scatter positions.
- Pieces are drawn to canvas textures with depth shading; drag-to-snap with a
  ghost target, camera pan on empty table, and wheel/button zoom (1x–4x).
- Registered in gamesRegistry, main.js scene list, and GameRoomScene slug map;
  game-icons.png updated for the new icon frame.
This commit is contained in:
Brian Fertig 2026-08-30 17:33:31 -06:00
parent cb4ac7bd85
commit a8b4d15e8c
6 changed files with 1001 additions and 1 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

After

Width:  |  Height:  |  Size: 341 KiB

View File

@ -123,3 +123,4 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });

View File

@ -0,0 +1,801 @@
import * as Phaser from 'phaser';
import { COLORS, GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
import { Button } from '../../ui/Button.js';
import { SFX, playSound } from '../../ui/Sounds.js';
import {
DIFFICULTIES, DIFFICULTY_ORDER,
makeJigsaw, cellOutline, tracePath, mulberry32,
} from './JigsawLogic.js';
// ─────────────────────────────────────────────────────────────────────────────
// Layout (world == the 1920×1080 canvas). Pieces live on a big "table"; the
// board target is a fixed square. Drag pieces to their slots; drag empty space
// to pan; scroll / / to zoom.
// ─────────────────────────────────────────────────────────────────────────────
const HUD_H = 88;
const BOARD_SIZE = 700;
const BOARD = { x: (GAME_WIDTH - BOARD_SIZE) / 2, y: 150, size: BOARD_SIZE };
const MAX_ZOOM = 4;
const SNAP_FRAC = 0.42; // snap radius as a fraction of the cell size
const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs
function offsetOutline(o, ox, oy) {
return {
start: { x: o.start.x + ox, y: o.start.y + oy },
cmds: o.cmds.map((c) => c.t === 'line'
? { t: 'line', x: c.x + ox, y: c.y + oy }
: { t: 'bezier', c1: { x: c.c1.x + ox, y: c.c1.y + oy }, c2: { x: c.c2.x + ox, y: c.c2.y + oy }, x: c.x + ox, y: c.y + oy }),
};
}
// Load a picture as a plain HTMLImageElement (cached by URL). We draw pieces
// and thumbnails straight from this element rather than relying on Phaser's
// texture-source API, which differs across Phaser 3 versions.
const _imgCache = {};
function loadImage(url) {
if (_imgCache[url]) return Promise.resolve(_imgCache[url]);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => { _imgCache[url] = img; resolve(img); };
img.onerror = () => reject(new Error('image load failed: ' + url));
img.src = url;
});
}
export default class JigsawGame extends Phaser.Scene {
constructor() {
super('jigsaw-game');
this._pt = new Phaser.Math.Vector2();
}
create() {
this.artwork = [];
this.imageIndex = 0;
this.buttons = [];
this.pieces = [];
this.state = 'menu';
this.dragging = null;
this.panning = null;
this.placed = 0;
this.zTop = 1000;
this.startTime = 0;
this.elapsed = 0;
this.hintOn = true;
this.selectedDiff = 'easy';
this.loadArtwork();
this.buildBackground();
this.buildHUD();
this.buildMenu();
this.bindInput();
this.cameras.main.setBounds(0, 0, GAME_WIDTH, GAME_HEIGHT);
this.cameras.main.setZoom(1);
this.cameras.main.setScroll(0, 0);
this.state = 'menu';
}
// ── Artwork (Shift images, lazy-loaded) ────────────────────────────────────
loadArtwork() {
const data = this.cache.json.get('shift-artwork');
const list = (data && data.artwork) || (Array.isArray(data) ? data : []);
this.artwork = list.filter((a) => a && a.path);
if (!this.artwork.length) this.artwork = [{ name: 'Puzzle', path: 'assets/images/shift/alien-world.png' }];
}
currentImage() { return this.artwork[this.imageIndex % this.artwork.length]; }
previewImage(dir) {
this.imageIndex = (this.imageIndex + dir + this.artwork.length) % this.artwork.length;
const item = this.currentImage();
playSound(this, SFX.UI_FLIP);
this.loadPreview(item);
}
randomImage() {
let i = this.imageIndex;
while (i === this.imageIndex && this.artwork.length > 1) i = Math.floor(Math.random() * this.artwork.length);
this.imageIndex = i;
playSound(this, SFX.UI_ACTIVATE);
this.loadPreview(this.currentImage());
}
loadPreview(item) {
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
const key = item.path;
this._loadToken = (this._loadToken || 0) + 1;
const token = this._loadToken;
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 cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
ctx.save();
const rr = 16;
ctx.beginPath();
ctx.moveTo(rr, 0); ctx.arcTo(S, 0, S, S, rr); ctx.arcTo(S, S, 0, S, rr);
ctx.arcTo(0, S, 0, 0, rr); ctx.arcTo(0, 0, S, 0, rr); ctx.closePath();
ctx.clip();
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
const sc = Math.max(S / iw, S / ih);
ctx.drawImage(srcImg, (S - iw * sc) / 2, (S - ih * sc) / 2, iw * sc, ih * sc);
ctx.restore();
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
this.textures.addCanvas('jigsaw-thumb', cv);
const img = this.add.image(cx, cy, 'jigsaw-thumb').setDisplaySize(box, box);
this.menu.add(img);
img.setPosition(cx, cy);
this.previewImg = img;
this.addThumbBorder();
if (this.thumbName) this.thumbName.setText(item.name || 'Image');
}).catch((e) => { console.warn('jigsaw preview:', e.message); });
}
addThumbBorder() {
if (this.thumbBorder) this.thumbBorder.destroy();
const g = this.add.graphics();
g.lineStyle(3, COLORS.accent, 0.9);
g.strokeRoundedRect(-150, -150, 300, 300, 16);
this.thumbBorder = g;
const parent = this.menu || this;
if (parent.add) parent.add(g);
g.setPosition(this.thumbBoxX, this.thumbBoxY);
}
// ── Background / table ─────────────────────────────────────────────────────
buildBackground() {
const bg = this.add.graphics().setDepth(0);
// felt-like table
bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
// subtle framed panel to read as a table
bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, GAME_WIDTH - 56, GAME_HEIGHT - 56, 26);
bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, GAME_WIDTH - 68, GAME_HEIGHT - 68, 22);
bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, GAME_WIDTH - 80, GAME_HEIGHT - 80, 18);
this.bg = bg;
bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT), Phaser.Geom.Rectangle.Contains);
bg.on('pointerdown', (pointer) => this.onTableDown(pointer));
}
// ── HUD (pinned to the top of the screen regardless of pan/zoom) ──────────
buildHUD() {
this.hud = this.add.container(0, 0).setDepth(9000);
const bar = this.add.rectangle(GAME_WIDTH / 2, HUD_H / 2, GAME_WIDTH, HUD_H, 0x000000, 0.32);
const line = this.add.rectangle(GAME_WIDTH / 2, HUD_H, GAME_WIDTH, 3, COLORS.accent, 0.7);
this.hud.add([bar, line]);
this.title = this.add.text(72, HUD_H / 2, 'JIGSAW', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
letterSpacing: 4,
}).setOrigin(0.5, 0.5);
this.hud.add(this.title);
this.diffBadge = this.add.text(195, HUD_H / 2, 'Easy · 25', {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex,
}).setOrigin(0.5, 0.5);
this.hud.add(this.diffBadge);
this.stats = this.add.text(GAME_WIDTH / 2, HUD_H / 2, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5, 0.5);
this.hud.add(this.stats);
const B = (label, x, w, onClick, o = {}) => this.mkButton(this.hud, label, x, HUD_H / 2, w, 54, onClick, o);
this.btnMenu = B('Menu', 1845, 118, () => this.toMenu());
this.btnHome = B('⌂', 1735, 54, () => this.resetView(), { fontSize: 24 });
this.btnZoomIn = B('', 1665, 54, () => this.zoomStep(1.2), { fontSize: 26 });
this.btnZoomOut= B('', 1595, 54, () => this.zoomStep(1 / 1.2), { fontSize: 26 });
this.btnHint = B('Hint', 1490, 84, () => this.toggleHint(), { fontSize: 22 });
this.btnNew = B('New', 1365, 96, () => this.restart(), { fontSize: 22 });
this.btnHint.setActive(true);
this.hudVisible = () => this.hud.visible;
this.setHUDForState('menu');
}
setHUDForState(state) {
const playing = state === 'playing' || state === 'won';
[this.btnMenu, this.btnHome, this.btnZoomIn, this.btnZoomOut, this.btnHint, this.btnNew].forEach((b) => {
const on = playing && (b !== this.btnNew || state === 'playing');
b.visible = on;
if (b === this.btnNew && state === 'won') b.visible = true;
});
this.diffBadge.visible = playing;
this.stats.visible = playing;
this.title.setVisible(true);
}
// ── Button factory (registered for hit-testing + cleanup) ──────────────────
mkButton(parent, label, x, y, w, h, onClick, o = {}) {
// Swallow a click that is really the tail-end of a piece drag (Phaser fires
// pointerup on whatever is under the cursor at release, even mid-drag).
const safe = () => {
if (this.suppressNextButtonClick) { this.suppressNextButtonClick = false; return; }
if (onClick) onClick();
};
const b = new Button(this, x, y, label, safe, {
width: w, height: h,
fontSize: o.fontSize || 24,
variant: o.variant || 'solid',
bg: o.bg || COLORS.panel,
});
parent.add(b);
this.buttons.push(b);
return b;
}
buttonHit(cx, cy) {
for (const b of this.buttons) {
if (!b.visible || !b.parent || !b.parent.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;
}
return null;
}
// ── Menu ───────────────────────────────────────────────────────────────────
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]);
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;
this.diffButtons = [];
const bw = 210, gap = 22, totalW = diffLabels.length * bw + (diffLabels.length - 1) * gap;
let bx = cx - totalW / 2 + bw / 2;
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 });
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 });
this.startButton = start;
// Load the first preview
this.selectDifficulty('easy');
this.loadPreview(this.currentImage());
}
selectDifficulty(key) {
this.selectedDiff = key;
playSound(this, SFX.EIGHTBIT_SELECT);
this.diffButtons.forEach((d) => d.btn.setActive(d.key === key));
}
// ── Puzzle start ───────────────────────────────────────────────────────────
startPuzzle() {
const item = this.currentImage();
playSound(this, SFX.EIGHTBIT_ACTIVATE);
this.menu.setVisible(false);
this.setState('loading');
this.showLoading(true);
loadImage(item.path).then(() => {
this.showLoading(false);
this.beginPlay(item);
}).catch((e) => {
this.showLoading(false);
console.error('jigsaw image load failed:', e.message);
this.setState('menu');
this.menu.setVisible(true);
});
}
showLoading(on) {
if (on && !this.loadingLabel) {
this.loadingLabel = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'Loading…', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(9500);
} else if (this.loadingLabel) this.loadingLabel.setVisible(on);
}
setState(s) {
this.state = s;
this.setHUDForState(s);
if (s === 'menu') {
this.menu.setVisible(true);
this.setBoardVisible(false);
this.setZoomTo(1);
} else {
this.menu.setVisible(false);
}
}
// ── Build board + pieces ───────────────────────────────────────────────────
beginPlay(item) {
this.teardownPlay();
const cfg = DIFFICULTIES[this.selectedDiff];
this.cols = cfg.cols; this.rows = cfg.rows;
this.total = cfg.cols * cfg.rows;
this.cell = BOARD_SIZE / cfg.cols;
this.placed = 0;
this.startTime = 0; this.elapsed = 0;
this.seed = (Math.random() * 1e9) | 0;
this.jig = makeJigsaw(this.cols, this.rows, this.seed);
this.diffLabel = `${cfg.label} · ${this.total} pieces`;
this.imageName = item.name || 'Image';
// Board panel + reference picture
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);
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.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size);
this.boardPanel = panel;
this.boardFrame = boardFrame;
const srcImg = _imgCache[item.path];
// Build the faint reference into a canvas texture (cover-cropped square),
// then place it. Texture must exist before the image references it.
const R = 640;
const rcv = document.createElement('canvas');
rcv.width = rcv.height = R;
const rctx = rcv.getContext('2d');
if (srcImg) {
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
const sc = Math.max(R / iw, R / ih);
rctx.drawImage(srcImg, (R - iw * sc) / 2, (R - ih * sc) / 2, iw * sc, ih * sc);
} else {
rctx.fillStyle = '#5a7b8a'; rctx.fillRect(0, 0, R, R);
}
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);
// Build piece textures + sprites
const texKey = (r, c) => `jg-${this.seed}-${r}-${c}`;
const boardW = this.cols * this.cell, boardH = this.rows * this.cell;
this.pieces = [];
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const key = texKey(r, c);
const cv = this.buildPieceCanvas(r, c, srcImg, boardW, boardH);
if (this.textures.exists(key)) this.textures.remove(key);
this.textures.addCanvas(key, cv);
const home = { x: BOARD.x + (c + 0.5) * this.cell, y: BOARD.y + (r + 0.5) * this.cell };
const img = this.add.image(0, 0, key).setOrigin(0.5).setDepth(10);
// Hit area = the piece's own footprint (base cell + inner knob), NOT 2×cell.
// Pieces scatter with a minimum centre-gap of ~1.45×cell, so keeping the
// radius at 0.6×cell guarantees a pointer can only ever fall inside ONE
// piece's hit area. (With the old ±cell box, ~35 neighbour pairs overlapped
// and the click resolved to whichever had the highest index — the bottom-
// right piece — far away from the cursor.)
// Pieces stay interactive only for the hover cursor. The actual grab is
// resolved centrally in onTableDown (nearest piece to the pointer) so a
// click can never resolve to a distant neighbour's overlapping hit box.
const grab = this.cell * GRAB_FRAC;
img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(-grab, -grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
this.pieces.push({ r, c, img, home, placed: false, key, depth: 10 });
}
}
// Scatter pieces around (but off) the board. scatterSpots always returns
// exactly `total` spots; the `|| { x: BOARD.x, y: BOARD.y }` is just a
// last-resort guard so a layout change can never crash the game.
const spots = this.scatterSpots(this.total);
this.pieces.forEach((p, i) => {
const s = spots[i] || { x: BOARD.x + BOARD.size / 2, y: BOARD.y + BOARD.size / 2 };
p.img.setPosition(s.x, s.y);
});
// Ghost target shown while dragging (kept above pieces so it stays visible)
this.ghost = this.add.image(0, 0).setOrigin(0.5).setAlpha(0.22).setVisible(false).setDepth(9000);
this.updateStats();
this.setState('playing');
this.resetView();
}
buildPieceCanvas(r, c, src, boardW, boardH) {
const cell = this.cell;
const S = Math.ceil(cell * 2) + 2;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
const ox = S / 2 - (c + 0.5) * cell;
const oy = S / 2 - (r + 0.5) * cell;
const outline = offsetOutline(cellOutline(this.jig, r, c, cell, cell), ox, oy);
ctx.save();
tracePath(ctx, outline);
ctx.clip();
if (src) ctx.drawImage(src, ox, oy, boardW, boardH);
else {
// Fallback swatch so a piece is still visible if the image is missing.
const hue = ((r * this.cols + c) * 47) % 360;
ctx.fillStyle = `hsl(${hue} 45% 60%)`;
ctx.fillRect(0, 0, S, S);
}
// depth shading
const g = ctx.createLinearGradient(0, 0, 0, S);
g.addColorStop(0, 'rgba(255,255,255,0.10)');
g.addColorStop(0.5, 'rgba(255,255,255,0)');
g.addColorStop(1, 'rgba(0,0,0,0.20)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, S, S);
ctx.restore();
tracePath(ctx, outline);
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(1.5, cell * 0.028);
ctx.strokeStyle = 'rgba(12,9,6,0.6)';
ctx.stroke();
tracePath(ctx, outline);
ctx.lineWidth = Math.max(1, cell * 0.014);
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.stroke();
return cv;
}
scatterSpots(n) {
const g = mulberry32(this.seed ^ 0x9e3779b9);
// Tray = the whole table 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).
const tray = { x: 40, y: HUD_H + 16, w: GAME_WIDTH - 80, h: GAME_HEIGHT - (HUD_H + 16) - 20 };
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
// other's grab region (the old rejection-scatter over-packed the table and
// left some pieces ungrabbable until their neighbours were placed).
//
// KEY: candidate CENTRES are computed without jitter, so their COUNT is
// deterministic for a given spacing (the old code jittered first and then
// dropped points that wandered off-table / under the board, so the count was
// seed-dependent and sometimes came up short — crashing beginPlay with
// `undefined.x`). We start roomy and tighten the grid until it holds >= n
// centres. The floor is small enough that a fit always exists, so we never
// come up short and never emit an off-canvas spot.
const floor = this.cell; // densest we'll allow — one base-cell apart
let spacing = this.cell * 1.7;
let cells = [];
for (let i = 0; i < 40; i++) {
cells = this.gridCells(tray, boardBox, spacing, grabR);
if (cells.length >= n) break;
spacing = Math.max(floor, spacing * 0.95);
}
// Cosmetic jitter for a natural look. Only jitter cells that sit safely away
// from the board and the tray edges so the jitter can never push a piece
// under the board or off the table. When the grid is tight (spacing <= 2x
// grab) we skip jitter so the centre-gap never shrinks below the fit.
const J = Math.max(0, spacing - 2 * grabR) * 0.2;
const safe = (p) => {
const bx0 = boardBox.x0 - J, by0 = boardBox.y0 - J, bx1 = boardBox.x1 + J, by1 = boardBox.y1 + J;
const tx0 = tray.x + grabR + J, tx1 = tray.x + tray.w - grabR - J;
const ty0 = tray.y + grabR + J, ty1 = tray.y + tray.h - grabR - J;
return !(p.x > bx0 && p.x < bx1 && p.y > by0 && p.y < by1) &&
(p.x > tx0 && p.x < tx1 && p.y > ty0 && p.y < ty1);
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
const pts = cells.map((p) => safe(p) && J > 0
? { x: clamp(p.x + (g() - 0.5) * 2 * J, x0, x1), y: clamp(p.y + (g() - 0.5) * 2 * J, y0, y1) }
: { x: clamp(p.x, x0, x1), y: clamp(p.y, y0, y1) });
// Defensive backfill (should never be needed with the floor above): add spots
// in a guaranteed-clear left strip, staying strictly inside the tray so we
// can never emit an off-canvas position.
if (pts.length < n) {
let k = pts.length;
while (pts.length < n) {
const col = k % 3, row = Math.floor(k / 3);
pts.push({
x: clamp(tray.x + grabR + col * 2 * grabR, x0, x1),
y: clamp(tray.y + grabR + row * 2 * grabR, y0, y1),
});
k++;
}
}
// shuffle so pieces aren't in grid order, then take n
for (let i = pts.length - 1; i > 0; i--) { const j = (g() * (i + 1)) | 0; const t = pts[i]; pts[i] = pts[j]; pts[j] = t; }
return pts.slice(0, n);
}
// Un-jittered grid CENTRES inside the tray, clear of the board. Count is
// deterministic in `spacing` (no RNG), so the caller can rely on it to
// guarantee a fit. Centres are kept a full grab-radius away from the board.
gridCells(tray, boardBox, spacing, grabR) {
const out = [];
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
const cs = Math.round(x0 / spacing), ce = Math.floor(x1 / spacing);
const rs = Math.round(y0 / spacing), re = Math.floor(y1 / spacing);
for (let r = rs; r <= re; r++) {
for (let c = cs; c <= ce; c++) {
const x = c * spacing, y = r * spacing;
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
if (x > boardBox.x0 && x < boardBox.x1 && y > boardBox.y0 && y < boardBox.y1) continue;
out.push({ x, y });
}
}
return out;
}
// ── Interaction ────────────────────────────────────────────────────────────
bindInput() {
this.input.on('pointermove', (pointer) => this.onPointerMove(pointer));
this.input.on('pointerup', () => this.onPointerUp());
this.input.on('pointerupoutside', () => this.onPointerUp());
this.input.on('wheel', (pointer, _o, _dx, dy) => {
if (this.state !== 'playing') return;
const c = this.canvasPos(pointer);
this.zoomAt(c.x, c.y, dy < 0 ? 1.15 : 1 / 1.15);
});
}
canvasPos(pointer) {
// pointer.x/y are already in screen (game-canvas) space per the Phaser 3.90
// Pointer API ("The value is in screen space"). Phaser 3.90 has no
// ScaleManager.getPointerPosition, so we read the pointer directly.
return { x: pointer.x, y: pointer.y };
}
worldOf(pointer) {
// Convert screen (canvas) space -> world space through the active camera
// matrix (accounts for scroll + zoom). Deterministic: reads the current
// pointer position and the current camera matrix.
const c = this.canvasPos(pointer);
const w = this.cameras.main.getWorldPoint(c.x, c.y);
return { x: w.x, y: w.y };
}
nearestPiece(w) {
if (this.state !== 'playing') return null;
const thr = this.cell * GRAB_FRAC; // grab radius: covers the piece body incl. its knobs
let best = null, bestD = Infinity;
for (const p of this.pieces) {
if (p.placed) continue;
const d = Math.hypot(p.img.x - w.x, p.img.y - w.y);
if (d < thr && d < bestD) { bestD = d; best = p; }
}
return best;
}
onTableDown(pointer) {
if (this.state !== 'playing') return;
const c = this.canvasPos(pointer);
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons work
const piece = this.nearestPiece(this.worldOf(pointer));
if (piece) { this.grabPiece(piece, pointer); return; }
const cam = this.cameras.main; // empty table → pan
this.panning = { cx: c.x, cy: c.y, camX: cam.scrollX, camY: cam.scrollY };
}
grabPiece(piece, pointer) {
const w = this.worldOf(pointer);
this.zTop += 1;
piece.depth = this.zTop;
piece.img.setDepth(this.zTop);
this.dragging = { piece, offX: piece.img.x - w.x, offY: piece.img.y - w.y };
this.suppressNextButtonClick = true;
this.ghost.setTexture(piece.img.texture.key).setPosition(piece.home.x, piece.home.y).setVisible(true);
playSound(this, SFX.UI_PICK);
}
onPointerMove(pointer) {
const c = this.canvasPos(pointer);
if (this.dragging) {
const w = this.worldOf(pointer);
const { piece, offX, offY } = this.dragging;
piece.img.setPosition(w.x + offX, w.y + offY);
return;
}
if (this.panning) {
const cam = this.cameras.main;
cam.scrollX = this.panning.camX - (c.x - this.panning.cx) / cam.zoom;
cam.scrollY = this.panning.camY - (c.y - this.panning.cy) / cam.zoom;
this.clampCamera();
}
}
onPointerUp() {
if (this.dragging) {
const { piece } = this.dragging;
this.ghost.setVisible(false);
this.dragging = null;
this.trySnap(piece);
}
this.panning = null;
this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; });
}
trySnap(piece) {
const d = Math.hypot(piece.img.x - piece.home.x, piece.img.y - piece.home.y);
if (d < this.cell * SNAP_FRAC) {
piece.img.setPosition(piece.home.x, piece.home.y);
piece.placed = true;
this.placed += 1;
piece.img.setDepth(12);
this.nudge(piece.img);
playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE);
this.updateStats();
if (this.placed === this.total) this.onWin();
} else {
// gentle settle
this.nudge(piece.img);
playSound(this, SFX.PIECE_CLICK);
}
}
nudge(obj) {
const t = obj.scale;
obj.setScale(obj.scale * 1.12);
this.tweens.add({ targets: obj, scale: t, duration: 150, ease: 'Quad.easeOut' });
}
updateStats() {
if (!this.stats) return;
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
this.stats.setText(`${this.placed} / ${this.total} · ${mm}:${ss}`);
if (this.diffBadge) this.diffBadge.setText(`${this.diffLabel || 'Easy'}`);
}
// ── Camera: pan / zoom ─────────────────────────────────────────────────────
clampCamera() {
const cam = this.cameras.main;
const vw = GAME_WIDTH / cam.zoom, vh = GAME_HEIGHT / cam.zoom;
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, Math.max(0, GAME_WIDTH - vw));
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, Math.max(0, GAME_HEIGHT - vh));
}
zoomAt(cx, cy, factor) {
const cam = this.cameras.main;
const oldZ = cam.zoom;
const newZ = Phaser.Math.Clamp(oldZ * factor, 1, MAX_ZOOM);
if (Math.abs(newZ - oldZ) < 0.0001) return;
const wx = cam.scrollX + cx / oldZ;
const wy = cam.scrollY + cy / oldZ;
cam.setZoom(newZ);
cam.scrollX = wx - cx / newZ;
cam.scrollY = wy - cy / newZ;
this.clampCamera();
}
zoomStep(factor) {
this.zoomAt(GAME_WIDTH / 2, GAME_HEIGHT / 2, factor);
}
resetView() { this.setZoomTo(1); }
setZoomTo(z) {
const cam = this.cameras.main;
cam.setZoom(Phaser.Math.Clamp(z, 1, MAX_ZOOM));
cam.setScroll(0, 0);
}
pinHUD() {
if (!this.hud) return;
const cam = this.cameras.main;
this.hud.setPosition(cam.scrollX, cam.scrollY);
this.hud.setScale(1 / cam.zoom);
}
// ── State flow ─────────────────────────────────────────────────────────────
toggleHint() {
this.hintOn = !this.hintOn;
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.16 : 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);
}
toMenu() {
playSound(this, SFX.EIGHTBIT_SELECT);
this.teardownPlay();
this.setState('menu');
this.loadPreview(this.currentImage());
}
onWin() {
this.state = 'won';
this.setHUDForState('won');
playSound(this, SFX.VICTORY_SHORT);
this.showWinOverlay();
}
showWinOverlay() {
this.teardownWin();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
this.winLayer = this.add.container(0, 0).setDepth(9600);
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);
return t;
};
this.winLayer.add([dim, panel, frame]);
T(cy - 140, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
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' }); });
this.winObjects = [this.winLayer];
}
teardownWin() {
if (this.winObjects) {
this.winObjects.forEach((o) => o.destroy());
this.winObjects = null;
this.winLayer = null;
}
}
teardownPlay() {
this.teardownWin();
this.dragging = null; this.panning = null;
if (this.ghost) { this.ghost.destroy(); this.ghost = null; }
if (this.boardPanel) this.boardPanel.destroy();
if (this.boardFrame) this.boardFrame.destroy();
if (this.refImage) this.refImage.destroy();
(this.pieces || []).forEach((p) => {
p.img.destroy();
if (this.textures.exists(p.key)) this.textures.remove(p.key);
});
this.pieces = [];
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
}
setBoardVisible(v) {
[this.boardPanel, this.boardFrame, this.refImage, this.ghost].forEach((o) => o && o.setVisible(v));
(this.pieces || []).forEach((p) => p.img.setVisible(v));
}
// ── Loop ───────────────────────────────────────────────────────────────────
update(time) {
this.pinHUD();
if (this.state === 'playing') {
if (!this.startTime) this.startTime = time;
this.elapsed = Math.max(0, (time - this.startTime) / 1000);
this.updateStats();
}
}
}

View File

@ -0,0 +1,196 @@
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
//
// Model
// -----
// A cols×rows grid. Every internal edge between two cells carries exactly one
// knob (a protruding "tab") that belongs to one of the two cells; the other
// cell gets the matching "blank" (indent). Boundary edges are flat.
//
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
// (r,c+1) [right]. 'L' → left cell owns the tab.
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
//
// Because both neighbours compute the SAME knob (same line segment, same side)
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
export const DIFFICULTIES = {
// Piece counts roughly double per tier. Square grids so the (square) source
// image fills the board edge-to-edge with no letterboxing.
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
};
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
// Knob shape as fractions of the edge length (see edgeFragment below).
// neckFrac: how far in from each end the neck (narrow waist) sits.
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
export function mulberry32(seed) {
let a = seed >>> 0;
return function rng() {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = Math.random;
// Build the knob assignment for every internal edge.
export function makeJigsaw(cols, rows, seed = null) {
const g = seed == null ? rand : mulberry32(seed);
const H = [];
const V = [];
for (let r = 0; r < rows; r++) {
const row = [];
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
H.push(row);
}
for (let r = 0; r < rows - 1; r++) {
const row = [];
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
V.push(row);
}
return { cols, rows, H, V };
}
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
// `normal` is the direction the knob bulges (null for flat edges). This is the
// side the shared curve lies on, so it is identical for both adjacent cells.
export function cellEdgeSpec(jig, r, c) {
const { cols, rows, H, V } = jig;
const out = {};
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
if (r === 0) out.top = { kind: 'flat', normal: null };
else {
const owner = V[r - 1][c];
const tab = owner === 'D'; // bottom cell owns the tab
out.top = tab
? { kind: 'tab', normal: { x: 0, y: -1 } }
: { kind: 'blank', normal: { x: 0, y: 1 } };
}
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
else {
const owner = H[r][c];
const tab = owner === 'L'; // left cell owns the tab
out.right = tab
? { kind: 'tab', normal: { x: 1, y: 0 } }
: { kind: 'blank', normal: { x: -1, y: 0 } };
}
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
else {
const owner = V[r][c];
const tab = owner === 'U'; // top cell owns the tab
out.bottom = tab
? { kind: 'tab', normal: { x: 0, y: 1 } }
: { kind: 'blank', normal: { x: 0, y: -1 } };
}
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
if (c === 0) out.left = { kind: 'flat', normal: null };
else {
const owner = H[r][c - 1];
const tab = owner === 'R'; // right cell owns the tab
out.left = tab
? { kind: 'tab', normal: { x: -1, y: 0 } }
: { kind: 'blank', normal: { x: 1, y: 0 } };
}
return out;
}
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
// Path commands for one edge, assuming the current point is `p0`.
// Flat → a single line. Knob → line to the near neck, one cubic through the
// bulb to the far neck, line to `p1`. The curve is direction-independent:
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
// which is what makes neighbouring pieces mesh.
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
if (!edge || edge.kind === 'flat') {
return [{ t: 'line', x: p1.x, y: p1.y }];
}
const { neckFrac, ctrlFrac } = knob;
const nA = lerp(p0, p1, neckFrac);
const nB = lerp(p0, p1, 1 - neckFrac);
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
return [
{ t: 'line', x: nA.x, y: nA.y },
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
{ t: 'line', x: p1.x, y: p1.y },
];
}
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
// units; `ox`,`oy` the cell's top-left in local units. Returns
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
const x0 = ox + c * W;
const y0 = oy + r * H;
const TL = { x: x0, y: y0 };
const TR = { x: x0 + W, y: y0 };
const BR = { x: x0 + W, y: y0 + H };
const BL = { x: x0, y: y0 + H };
const spec = cellEdgeSpec(jig, r, c);
const cmds = [
...edgeFragment(TL, TR, spec.top, knob),
...edgeFragment(TR, BR, spec.right, knob),
...edgeFragment(BR, BL, spec.bottom, knob),
...edgeFragment(BL, TL, spec.left, knob),
];
return { start: TL, cmds };
}
// Apply path commands to a 2D canvas context (builds the current path).
export function tracePath(ctx, outline) {
const { start, cmds } = outline;
ctx.moveTo(start.x, start.y);
for (const c of cmds) {
if (c.t === 'line') ctx.lineTo(c.x, c.y);
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
}
ctx.closePath();
}
// ── Scramble: assign each piece a start position in the "tray" region ─────────
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
// pieces are scattered. Returns an array aligned to the piece index
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
const g = seed == null ? rand : mulberry32(seed);
const { cols, rows } = jig;
const N = cols * rows;
const placed = [];
const margin = Math.max(tray.w, tray.h) * 0.06;
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
for (let i = 0; i < N; i++) {
// Rejection-sample a few tries so pieces don't pile in a single spot.
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
let tries = 0;
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
px = x0 + (x1 - x0) * g();
py = y0 + (y1 - y0) * g();
tries++;
}
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
}
return placed;
}

View File

@ -62,6 +62,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
import ShiftGame from './games/shift/ShiftGame.js';
import JigsawGame from './games/jigsaw/JigsawGame.js';
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
import MahjongGame from './games/mahjong/MahjongGame.js';
@ -184,6 +185,7 @@ const config = {
HexsweeperGame,
PuddingMonstersGame,
ShiftGame,
JigsawGame,
BlockFighterGame,
MahjongMatchGame,
MahjongGame,

View File

@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
}
create() {
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' };
if (slugDispatch[this.game.slug]) {
const sceneKey = slugDispatch[this.game.slug];
const startData = {