From a8b4d15e8cabf11691505c11ba3cc4b450eb5e50 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 30 Aug 2026 17:33:31 -0600 Subject: [PATCH] Add Jigsaw puzzle game with drag-and-drop pieces and pan/zoom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- assets/images/game-icons.png | Bin 347453 -> 349009 bytes src/data/gamesRegistry.js | 1 + src/games/jigsaw/JigsawGame.js | 801 ++++++++++++++++++++++++++++++++ src/games/jigsaw/JigsawLogic.js | 196 ++++++++ src/main.js | 2 + src/scenes/GameRoomScene.js | 2 +- 6 files changed, 1001 insertions(+), 1 deletion(-) create mode 100644 src/games/jigsaw/JigsawGame.js create mode 100644 src/games/jigsaw/JigsawLogic.js diff --git a/assets/images/game-icons.png b/assets/images/game-icons.png index f1d5f99477085d53133a0d4522c363d971e24db0..022186673d7a6f3055248672fe477fc747523d9b 100644 GIT binary patch delta 21740 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 McE!qs07E|b58N(|@Bjb+ delta 20186 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 Y>r;b+Z*q@q7ZZj38AqFHYwsuj0tw~?K>z>% 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 = {