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; // The puzzle plays on a field larger than the on-screen canvas. The camera pans // over this whole field (and the player can zoom out until it all fits), so // there's lots of room to spread pieces and explore. const WORLD_W = 3840; const WORLD_H = 2160; const BOARD = { x: (WORLD_W - BOARD_SIZE) / 2, y: (WORLD_H - BOARD_SIZE) / 2, size: BOARD_SIZE }; const MAX_ZOOM = 4; const MIN_ZOOM = 0.5; // zooms out far enough to see the entire field at once 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, WORLD_W, WORLD_H); this.cameras.main.setZoom(1); this.cameras.main.setScroll(0, 0); // ── Fixed HUD camera ───────────────────────────────────────────────── // A second camera locked at (0,0) zoom 1 renders the HUD + win overlay // in screen space, so they never move or scale with the board camera. this.mainCam = this.cameras.main; this.hudCam = this.cameras.add(0, 0, GAME_WIDTH, GAME_HEIGHT); this.hudCam.scrollX = 0; this.hudCam.scrollY = 0; this.hudCam.setZoom(1); // Route world objects to the main camera only, and UI (HUD/menu) to the // fixed hudCam only, so the two never overlap on the same camera. const worldOnly = (o) => { if (o) o.cameraFilter = this.hudCam.id; return o; }; const screenOnly = (o) => { if (o) o.cameraFilter = this.mainCam.id; return o; }; worldOnly(this.bg); worldOnly(this.boardPanel); worldOnly(this.boardFrame); worldOnly(this.refImage); worldOnly(this.ghost); screenOnly(this.hud); screenOnly(this.menu); this.state = 'menu'; } // Route a game object so it renders on all cameras except `cam`. excludeFrom(obj, cam) { if (obj && cam) obj.cameraFilter = cam.id; return obj; } // ── 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 covering the whole (bigger) field bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, WORLD_W, WORLD_H); // subtle framed panel to read as a table bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, WORLD_W - 56, WORLD_H - 56, 26); bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, WORLD_W - 68, WORLD_H - 68, 22); bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, WORLD_W - 80, WORLD_H - 80, 18); this.bg = bg; bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, WORLD_W, WORLD_H), Phaser.Geom.Rectangle.Contains); bg.on('pointerdown', (pointer) => this.onTableDown(pointer)); } // ── 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); if (this.mainCam) this.loadingLabel.cameraFilter = this.mainCam.id; } 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.resetToCanvas(); } 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); if (this.hudCam) img.cameraFilter = this.hudCam.id; // world camera only // 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); if (this.hudCam) this.ghost.cameraFilter = this.hudCam.id; // world camera only 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 (bigger) field minus a small margin, EXCLUDING the board // (pieces must not scatter on top of the target image or they'd be // indistinguishable from placed pieces). The HUD lives in viewport space, // so it doesn't reserve any of the world. const tray = { x: 40, y: 40, w: WORLD_W - 80, h: WORLD_H - 80 }; 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, WORLD_W - vw)); cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, Math.max(0, WORLD_H - vh)); } // Zoom about the point (cx, cy) in canvas space: the world point under that // screen position stays fixed, so zooming in/out is anchored to the cursor // (relative to the board) rather than snapping to the canvas centre. zoomAt(cx, cy, factor) { const cam = this.cameras.main; const oldZ = cam.zoom; const newZ = Phaser.Math.Clamp(oldZ * factor, MIN_ZOOM, 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); } // Menu/loading views live in canvas space (top-left of the world). resetToCanvas() { const cam = this.cameras.main; cam.setZoom(1); cam.setScroll(0, 0); } setZoomTo(z) { const cam = this.cameras.main; cam.setZoom(Phaser.Math.Clamp(z, MIN_ZOOM, MAX_ZOOM)); // Centre the field on the board so the start view frames the puzzle. cam.centerOn(BOARD.x + BOARD.size / 2, BOARD.y + BOARD.size / 2); this.clampCamera(); } pinHUD() { const cam = this.cameras.main; // The HUD and the win overlay are both pinned to the top-left of the // viewport (scaled to counter the zoom) so they sit in screen space no // matter where the camera is panned/zoomed within the (bigger) field. if (this.hud) { this.hud.setPosition(cam.scrollX, cam.scrollY); this.hud.setScale(1 / cam.zoom); } if (this.winLayer) { this.winLayer.setPosition(cam.scrollX, cam.scrollY); this.winLayer.setScale(1 / cam.zoom); } } // ── 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); if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55); const panel = this.add.rectangle(cx, cy, 620, 420, 0x17130c, 0.98); const frame = this.add.graphics(); frame.lineStyle(3, COLORS.gold, 0.9).strokeRoundedRect(cx - 310, cy - 210, 620, 420, 20); const T = (y, s, color, size) => { const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5); this.winLayer.add(t); 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) { if (this.state === 'playing') { if (!this.startTime) this.startTime = time; this.elapsed = Math.max(0, (time - this.startTime) / 1000); this.updateStats(); } } }