diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index f1d5f99..0221866 100644 Binary files a/assets/images/game-icons.png and b/assets/images/game-icons.png differ diff --git a/src/data/gamesRegistry.js b/src/data/gamesRegistry.js index f580b09..76805c8 100644 --- a/src/data/gamesRegistry.js +++ b/src/data/gamesRegistry.js @@ -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 }); diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js new file mode 100644 index 0000000..84edd7c --- /dev/null +++ b/src/games/jigsaw/JigsawGame.js @@ -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(); + } + } +} diff --git a/src/games/jigsaw/JigsawLogic.js b/src/games/jigsaw/JigsawLogic.js new file mode 100644 index 0000000..7813697 --- /dev/null +++ b/src/games/jigsaw/JigsawLogic.js @@ -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; +} diff --git a/src/main.js b/src/main.js index 9f4872d..10d6f01 100644 --- a/src/main.js +++ b/src/main.js @@ -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, diff --git a/src/scenes/GameRoomScene.js b/src/scenes/GameRoomScene.js index c9deddf..8dc0478 100644 --- a/src/scenes/GameRoomScene.js +++ b/src/scenes/GameRoomScene.js @@ -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 = {