import * as Phaser from 'phaser'; import { COLORS, GAME_WIDTH, GAME_HEIGHT } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { SFX, playSound } from '../../ui/Sounds.js'; import { getGameSoundtrack } from '../../services/soundtrack.js'; import { DIFFICULTIES, DIFFICULTY_ORDER, makeJigsaw, cellOutline, tracePath, mulberry32, cellNeighbours, resolveDrop, } 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 (×cell) — used for both locking a piece // into the board AND joining two pieces on the table const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs // ───────────────────────────────────────────────────────────────────────────── // Playfield: a stitched beige mat. The field renders as one piece of fabric // (border band + dashed stitching + subtle weave) and the board target sits in // a slightly deeper stitched "pocket", like a puzzle mat laid out for a jigsaw. // ───────────────────────────────────────────────────────────────────────────── const MAT = { base: '#d7ceb9', // main surface — warm, neutral beige band: '#b4a487', // border band around the field edge bandEdge: '#998867', // outermost rim of the mat stitch: '#8a7857', // stitching thread pocket: '#c7bca6', // board pocket (a shade deeper than the surface) }; const BAND = 26; // border band width const MAT_OVERSCAN = 140; // mat drawn past the world's bottom edge (covers the // camera dead-zone below the world at minimum zoom; // see clampCamera: the world top is reserved for HUD) const POCKET_MARGIN = 28; // pocket extends this far past the board edge function roundRectPath(ctx, x, y, w, h, r) { r = Math.min(r, w / 2, h / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } // Dashed "stitching" around a rounded rect. Drawn twice — a soft shadow pass // under the thread — so it reads as sewn into the fabric. function stitchRect(ctx, x, y, w, h, r, inset, dash, width) { const sx = x + inset, sy = y + inset, sw = w - 2 * inset, sh = h - 2 * inset; const rr = Math.max(3, r - inset); ctx.save(); ctx.lineJoin = 'round'; ctx.lineWidth = width; ctx.setLineDash(dash); ctx.strokeStyle = 'rgba(66,54,34,0.35)'; roundRectPath(ctx, sx, sy + 1.4, sw, sh, rr); ctx.stroke(); ctx.strokeStyle = MAT.stitch; roundRectPath(ctx, sx, sy, sw, sh, rr); ctx.stroke(); ctx.restore(); } // Subtle fabric-weave tile: fine thread lines + a sprinkle of speckle. function makeWeaveTile(seed) { const S = 128; const cv = document.createElement('canvas'); cv.width = cv.height = S; const ctx = cv.getContext('2d'); const g = mulberry32(seed); ctx.fillStyle = 'rgba(94,80,56,0.03)'; for (let y = 0; y < S; y += 4) ctx.fillRect(0, y, S, 1); ctx.fillStyle = 'rgba(255,255,250,0.03)'; for (let x = 0; x < S; x += 4) ctx.fillRect(x, 0, 1, S); for (let i = 0; i < 700; i++) { ctx.fillStyle = g() > 0.5 ? 'rgba(94,80,56,0.06)' : 'rgba(255,255,250,0.07)'; ctx.fillRect(g() * S, g() * S, 1 + g() * 1.5, 1 + g() * 1.5); } return cv; } // Full-field mat: border band, stitching, weave and a soft vignette. function makeMatTexture() { const W = WORLD_W, H = WORLD_H + MAT_OVERSCAN; const cv = document.createElement('canvas'); cv.width = W; cv.height = H; const ctx = cv.getContext('2d'); ctx.fillStyle = MAT.band; ctx.fillRect(0, 0, W, H); ctx.save(); roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22); ctx.fillStyle = MAT.base; ctx.fill(); ctx.clip(); ctx.fillStyle = ctx.createPattern(makeWeaveTile(1234), 'repeat'); ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND); // Soft vignette centred on the visible field (adds gentle depth). const vg = ctx.createRadialGradient(W / 2, WORLD_H / 2, Math.min(W, WORLD_H) * 0.30, W / 2, WORLD_H / 2, Math.max(W, WORLD_H) * 0.72); vg.addColorStop(0, 'rgba(118,102,72,0)'); vg.addColorStop(1, 'rgba(118,102,72,0.14)'); ctx.fillStyle = vg; ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND); // Seam where the surface meets the band. ctx.strokeStyle = 'rgba(96,82,56,0.28)'; ctx.lineWidth = 2; roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22); ctx.stroke(); ctx.restore(); // Outer rim of the mat. ctx.strokeStyle = MAT.bandEdge; ctx.lineWidth = 5; roundRectPath(ctx, 2.5, 2.5, W - 5, H - 5, 26); ctx.stroke(); // Stitching, centred in the border band. stitchRect(ctx, 0, 0, W, H, 26, BAND / 2, [18, 12], 3.5); return cv; } // Board pocket: a slightly deeper stitched panel that the reference picture // (and the locked pieces) sit inside. function makePocketTexture(size) { const M = POCKET_MARGIN; const S = size + 2 * M; const cv = document.createElement('canvas'); cv.width = cv.height = S; const ctx = cv.getContext('2d'); ctx.save(); roundRectPath(ctx, M, M, size, size, 16); ctx.shadowColor = 'rgba(64,52,32,0.35)'; ctx.shadowBlur = 18; ctx.shadowOffsetY = 6; ctx.fillStyle = MAT.pocket; ctx.fill(); ctx.restore(); ctx.save(); roundRectPath(ctx, M, M, size, size, 16); ctx.clip(); ctx.fillStyle = ctx.createPattern(makeWeaveTile(4242), 'repeat'); ctx.fillRect(0, 0, S, S); // Recessed feel: darker toward the top, a faint lift at the bottom lip. const g = ctx.createLinearGradient(0, M, 0, M + size); g.addColorStop(0, 'rgba(84,70,44,0.14)'); g.addColorStop(0.14, 'rgba(84,70,44,0.03)'); g.addColorStop(0.86, 'rgba(255,255,246,0.04)'); g.addColorStop(1, 'rgba(255,255,246,0.09)'); ctx.fillStyle = g; ctx.fillRect(M, M, size, size); ctx.restore(); ctx.strokeStyle = 'rgba(96,82,56,0.4)'; ctx.lineWidth = 2; roundRectPath(ctx, M + 1, M + 1, size - 2, size - 2, 16); ctx.stroke(); stitchRect(ctx, M, M, size, size, 16, 11, [14, 10], 3); return cv; } 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(); } init(data) { this.gameDef = data?.game ?? { slug: 'jigsaw', name: 'Jigsaw' }; } 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(); // Open the menu on a random picture, not always the first one, so each // visit to the puzzle picker starts on a different puzzle. (loadArtwork // guarantees at least one entry, so this index is always valid.) this.imageIndex = Math.floor(Math.random() * this.artwork.length); this.buildBackground(); this.buildHUD(); this.buildMenu(); this.bindInput(); this.cameras.main.setBounds(0, HUD_H, WORLD_W, WORLD_H - HUD_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); screenOnly(this.hud); screenOnly(this.menu); screenOnly(this.ddPanel); // ── Site soundtrack ──────────────────────────────────────────────────── // The shared default soundtrack (data/music.json, preloaded in PreloadScene); // getGameSoundtrack() transparently resolves a per-game override if one is // ever added for the 'jigsaw' slug. The music HUD (skip/mute buttons + // track name) is shared MusicPlayer UI: pin it to the fixed HUD camera // (it must not follow the panning world camera) and above the top bar so // the bar's translucent fill doesn't dim it. try { const { tracks, volume } = getGameSoundtrack(this); if (tracks.length) { this.music = new MusicPlayer(this, tracks, volume); for (const o of this.music._objs) screenOnly(o).setDepth(9100); } } catch (_) { /* music is optional */ } 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 = this.thumbBox || 280, cx = this.thumbBoxX, cy = this.thumbBoxY; const S = box; const cv = document.createElement('canvas'); cv.width = cv.height = S; const ctx = cv.getContext('2d'); 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(); const h = (this.thumbBox || 280) / 2 - 10; g.lineStyle(3, COLORS.accent, 0.9); g.strokeRoundedRect(-h, -h, h * 2, h * 2, 14); this.thumbBorder = g; const parent = this.menu || this; if (parent.add) parent.add(g); g.setPosition(this.thumbBoxX, this.thumbBoxY); } // ── Background / table ───────────────────────────────────────────────────── buildBackground() { // The playfield is one stitched beige mat (band + stitching + weave). if (!this.textures.exists('jigsaw-mat')) { this.textures.addCanvas('jigsaw-mat', makeMatTexture()); } this.bg = this.add.image(WORLD_W / 2, (WORLD_H + MAT_OVERSCAN) / 2, 'jigsaw-mat').setDepth(0); // Invisible hit zone for the table (pan on empty space). Kept separate // from the mat image so the background can be swapped without dropping // the input. Zones are center-origin, so position at the world midpoint. const hit = this.add.zone(WORLD_W / 2, WORLD_H / 2, WORLD_W, WORLD_H).setDepth(0); hit.setInteractive(); hit.on('pointerdown', (pointer) => this.onTableDown(pointer)); this.bgHit = hit; } // ── HUD (pinned to the top of the screen regardless of pan/zoom) ────────── 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(250, HUD_H / 2, 'Easy · 25', { fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex, }).setOrigin(0.5, 0.5); this.hud.add(this.diffBadge); 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); // The top bar keeps a single "Menu ▾" button: a dropdown that holds all // the other actions (Reset view, Zoom in/out, Hint, New puzzle, Main menu). // It sits left of the top-right corner, which the site's shared music HUD // (skip/mute + track name, see create()) reserves. this.btnMenu = this.mkButton(this.hud, 'Menu ▾', 1480, HUD_H / 2, 150, 54, () => this.toggleMenu()); this.buildMenuPanel(); this.hudVisible = () => this.hud.visible; this.setHUDForState('menu'); } // ── Menu dropdown (screen space, under the top-bar trigger) ──────────────── // Container sits at (0,0) like the HUD so buttonHit() can use absolute // coordinates for its rows. Toggled by btnMenu; dismissed by any outside // click (handled in onTableDown) or by state changes (setHUDForState). buildMenuPanel() { const X0 = 1315, X1 = 1555; // right edge aligns with the trigger const W = X1 - X0, cx = (X0 + X1) / 2; const BH = 44, GAP = 8, PAD = 12, SEP = 26; const top = HUD_H + 10; const h = PAD + 5 * BH + 4 * GAP + SEP + BH + PAD; // 5 rows + separator + Main menu const panel = this.add.container(0, 0).setDepth(9050); panel.setVisible(false); const bg = this.add.graphics(); bg.fillStyle(0x17130c, 0.98).fillRoundedRect(X0, top, W, h, 12); bg.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12); panel.add(bg); const rowY = (i) => top + PAD + BH / 2 + i * (BH + GAP); const row = (i, label, onClick) => this.mkButton(panel, label, cx, rowY(i), W - 24, BH, onClick, { fontSize: 20 }); this.btnHome = row(0, '⌂ Reset view', () => { this.resetView(); this.closeMenu(); }); this.btnZoomIn = row(1, '+ Zoom in', () => { this.zoomStep(1.2); this.closeMenu(); }); this.btnZoomOut = row(2, '- Zoom out', () => { this.zoomStep(1 / 1.2); this.closeMenu(); }); this.btnHint = row(3, 'Hint', () => this.toggleHint()); // stays open: it's a toggle this.btnNew = row(4, 'New puzzle', () => { this.restart(); this.closeMenu(); }); const sepY = top + PAD + 5 * BH + 4 * GAP + SEP / 2; panel.add(this.add.rectangle(cx, sepY, W - 36, 2, COLORS.accent, 0.5)); this.btnMain = this.mkButton(panel, 'Main menu', cx, sepY + SEP / 2 + BH / 2, W - 24, BH, () => { this.toMenu(); this.closeMenu(); }, { fontSize: 20 }); this.btnHint.setActive(this.hintOn); this.ddPanel = panel; this.ddOpen = false; } toggleMenu() { if (this.state !== 'playing' && this.state !== 'won') return; playSound(this, SFX.EIGHTBIT_SELECT); if (this.ddOpen) this.closeMenu(); else this.openMenu(); } openMenu() { if (this.ddOpen) return; this.ddOpen = true; this.btnHint.setActive(this.hintOn); // keep the toggle row in sync this.ddPanel.setVisible(true); this.btnMenu.setLabel('Menu ▴'); } closeMenu() { if (!this.ddPanel || !this.ddOpen) return; this.ddOpen = false; this.ddPanel.setVisible(false); this.btnMenu.setLabel('Menu ▾'); } setHUDForState(state) { const playing = state === 'playing' || state === 'won'; this.btnMenu.visible = playing; if (this.ddPanel) { this.ddOpen = false; this.ddPanel.setVisible(false); } // state changes collapse the dropdown 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); // Two comfortably-spaced columns — picture picker left, difficulty + // start right — with a consistent vertical rhythm (26px label→control, // ≥50px between sections) instead of one tall, cramped stack. const W = 1180, H = 660; const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; const top = cy - H / 2; const bg = this.add.graphics(); bg.fillStyle(0x17130c, 0.96).fillRoundedRect(cx - W / 2, top, W, H, 20); bg.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, top, W, H, 20); this.menu.add(bg); const addT = (x, y, s, color, o = {}) => { const t = this.add.text(x, y, s, { fontFamily: '"Julius Sans One"', fontSize: o.size, color: color, ...o }).setOrigin(0.5); this.menu.add(t); return t; }; addT(cx, top + 72, 'JIGSAW', COLORS.textHex, { size: '48px', letterSpacing: 8 }); addT(cx, top + 122, 'Assemble the picture.', COLORS.mutedHex, { size: '20px' }); const labelY = top + 202; const cxL = cx - 292, cxR = cx + 286; // right column kept inboard so the // difficulty pair stays inside the panel addT(cxL, labelY, 'Image', COLORS.goldHex, { size: '22px', letterSpacing: 3 }); addT(cxR, labelY, 'Difficulty', COLORS.goldHex, { size: '22px', letterSpacing: 3 }); // ── Left column: picture picker ─────────────────────────────────────── this.thumbBox = 280; const thumbCy = labelY + 11 + 26 + this.thumbBox / 2; this.thumbBoxX = cxL; this.thumbBoxY = thumbCy; this.mkButton(this.menu, '‹', cxL - 190, thumbCy, 64, 64, () => this.previewImage(-1), { fontSize: 34 }); this.mkButton(this.menu, '›', cxL + 190, thumbCy, 64, 64, () => this.previewImage(1), { fontSize: 34 }); const nameY = thumbCy + this.thumbBox / 2 + 26; this.thumbName = addT(cxL, nameY, '', COLORS.textHex, { size: '20px' }); this.mkButton(this.menu, '🎲 Random', cxL, nameY + 10 + 22 + 26, 190, 52, () => this.randomImage(), { fontSize: 22 }); // ── Right column: difficulty + start ────────────────────────────────── const bw = 190, bh = 58, gap = 18; const row1 = labelY + 11 + 26 + bh / 2; const row2 = row1 + bh + gap; const bxL = cxR - (bw + gap) / 2 - bw / 2; const bxR = cxR + (bw + gap) / 2 + bw / 2; this.diffButtons = []; const bx = [bxL, bxR], by = [row1, row2]; DIFFICULTY_ORDER.forEach((k, i) => { const cfg = DIFFICULTIES[k]; const b = this.mkButton(this.menu, `${cfg.label} · ${cfg.cols * cfg.rows}`, bx[i % 2], by[Math.floor(i / 2)], bw, bh, () => this.selectDifficulty(k), { fontSize: 21 }); this.diffButtons.push({ key: k, btn: b }); }); const startY = row2 + bh / 2 + 52 + 37; const start = this.mkButton(this.menu, 'Start Puzzle ▸', cxR, startY, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold }); this.startButton = start; addT(cxR, startY + 37 + 40, 'Drag pieces onto the mat · scroll to zoom · drag to pan', COLORS.mutedHex, { size: '17px' }); // Load the first preview this.selectDifficulty('easy'); 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.difficulty = this.selectedDiff; this.diffLabel = `${cfg.label} · ${this.total} pieces`; this.imageName = item.name || 'Image'; // Board: a stitched pocket set a shade deeper than the field, with the // faint reference picture laid inside it. const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2; if (!this.textures.exists('jigsaw-pocket')) { this.textures.addCanvas('jigsaw-pocket', makePocketTexture(BOARD.size)); } const panel = this.add.image(bcx, bcy, 'jigsaw-pocket') .setDisplaySize(BOARD.size + 2 * POCKET_MARGIN, BOARD.size + 2 * POCKET_MARGIN) .setDepth(1); const boardFrame = this.add.graphics().setDepth(2); boardFrame.lineStyle(2, 0x5a4c32, 0.25); boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size); this.boardPanel = panel; this.boardFrame = boardFrame; // Keep board on the main camera only (the fixed HUD camera would draw its // top-left corner into the lower-right of the screen). if (this.hudCam) { panel.cameraFilter = this.hudCam.id; boardFrame.cameraFilter = this.hudCam.id; } 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.2).setDepth(3); if (this.hudCam) this.refImage.cameraFilter = this.hudCam.id; // main camera only // Faint full-picture reference: keep on Easy/Medium, hide on Hard/Legendary. this.refImage.setVisible(this.difficulty !== 'hard' && this.difficulty !== 'legendary'); // 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 for the hover cursor AND to consume the // pointerdown (forwarding it to the central grab resolver). Without the // pointerdown handler here, a click that lands inside the piece's custom // hit area is consumed by the piece (topOnly=true) and never reaches the // background's onTableDown — leaving the piece un-grabbable. const grab = this.cell * GRAB_FRAC; img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(img.displayOriginX - grab, img.displayOriginY - grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains }); img.on('pointerdown', (pointer) => this.onTableDown(pointer)); 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 }; this.setPiecePos(p, s.x, s.y); }); // ── Piece groups ───────────────────────────────────────────────────────── // Each piece starts as a singleton group. When two grid-adjacent pieces are // dropped within snap radius of their correct relative offset they merge // into one group and from then on drag, drop and lock as a single piece. // Invariant: every group member sits at `anchorPos + (member.home - anchor.home)` // for any member `anchor` — i.e. the exact board-relative offset — so the // knob geometry always meshes while a group moves. this.groups = new Set(); this.pieceGrid = []; for (let r = 0; r < this.rows; r++) this.pieceGrid.push(new Array(this.cols)); for (const p of this.pieces) { const g = { pieces: [p] }; p.group = g; this.groups.add(g); this.pieceGrid[p.r][p.c] = p; } this.updateStats(); this.setState('playing'); this.resetView(); } buildPieceCanvas(r, c, src, boardW, boardH) { const cell = this.cell; const PAD = Math.max(7, cell * 0.09); // headroom for the drop shadow const S = Math.ceil(cell * 2) + Math.ceil(PAD * 2); const cv = document.createElement('canvas'); cv.width = cv.height = S; const ctx = cv.getContext('2d'); 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); // Soft drop shadow so pieces read as resting on the mat. The fill colour // is arbitrary — the opaque art pass below repaints the piece exactly; // only the shadow escaping the outline is visible. ctx.save(); tracePath(ctx, outline); ctx.shadowColor = 'rgba(58,46,26,0.30)'; ctx.shadowBlur = Math.max(4, cell * 0.07); ctx.shadowOffsetY = Math.max(3, cell * 0.05); ctx.fillStyle = '#3a2f1a'; ctx.fill(); ctx.restore(); ctx.save(); tracePath(ctx, outline); ctx.clip(); 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: HUD_H + 40, w: WORLD_W - 80, h: WORLD_H - HUD_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.pos.x - w.x, p.pos.y - w.y); if (d < thr && d < bestD) { bestD = d; best = p; } } return best; } onTableDown(pointer) { if (this.state !== 'playing') return; if (this.dragging) return; // a grab is already in progress (both the piece and the table zone forward their pointerdown here — resolve only once) const c = this.canvasPos(pointer); if (this.buttonHit(c.x, c.y)) return; // let HUD buttons (incl. dropdown rows) work if (this.music && c.x > 1830 && c.y < 80) return; // site music HUD (top-right) — don't pan over it if (this.ddOpen) { this.closeMenu(); return; } // outside click dismisses the dropdown 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 }; } // Single source of truth for a piece's table position: `p.pos`, mirrored // onto the sprite. resolveDrop() (JigsawLogic.js) reasons about `pos` only. setPiecePos(p, x, y) { p.pos = { x, y }; p.img.setPosition(x, y); } grabPiece(piece, pointer) { const w = this.worldOf(pointer); const group = piece.group; // Lift the whole group above everything else so it reads as one lifted piece. this.zTop += 1; for (const m of group.pieces) { m.depth = this.zTop; m.img.setDepth(this.zTop); } this.dragging = { group, anchor: piece, offX: piece.pos.x - w.x, offY: piece.pos.y - w.y }; this.suppressNextButtonClick = true; // Ghosts (brighter alpha preview of each member's home slot) on Easy only. this.showGroupGhosts(group); playSound(this, SFX.UI_PICK); } onPointerMove(pointer) { const c = this.canvasPos(pointer); if (this.dragging) { const w = this.worldOf(pointer); const { group, anchor, offX, offY } = this.dragging; // Move the WHOLE group: every member keeps its exact home offset from the // grabbed anchor, so the assembled shape rigidly translates and the knob // geometry between members always meshes. const bx = w.x + offX, by = w.y + offY; for (const m of group.pieces) { this.setPiecePos(m, bx + (m.home.x - anchor.home.x), by + (m.home.y - anchor.home.y)); } 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 { group, anchor } = this.dragging; this.hideGroupGhosts(group); this.dragging = null; this.handleDrop(group, anchor); } this.panning = null; this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; }); } // ── Drop resolution: lock onto the board, or join with table neighbours ─── // The join/lock RULES live in resolveDrop() (JigsawLogic.js, unit-checked); // this method only applies the decided positions and does scene bookkeeping. handleDrop(group, anchor) { const res = resolveDrop(this.jig, group, (r, c) => this.pieceGrid[r][c], this.cell * SNAP_FRAC); for (const pl of res.placements) this.setPiecePos(pl.piece, pl.x, pl.y); if (res.outcome === 'locked') { this.lockGroup(group); return; } if (res.outcome === 'joined') { // Merge the absorbed groups into the dropped one (pieces keep their exact // mesh positions; membership is the only scene-side state change). for (const g of res.absorbedGroups) { this.groups.delete(g); for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); } } res.placements.forEach((pl) => this.nudge(pl.piece.img)); playSound(this, SFX.UI_CHIME); return; } // REST — the group just settles where it was dropped. this.nudge(anchor.img); playSound(this, SFX.PIECE_CLICK); } // Lock the group onto the board (positions already applied by handleDrop) and // retire it from the table. `placed` advances by the group size. lockGroup(group) { const locked = []; for (const m of group.pieces) { if (m.placed) continue; m.placed = true; m.img.setDepth(12); locked.push(m); } this.groups.delete(group); this.placed += locked.length; locked.forEach((m) => this.nudge(m.img)); playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE); this.updateStats(); if (this.placed === this.total) this.onWin(); } // ── Home-slot ghosts (Easy only) ─────────────────────────────────────────── // Faint preview of a piece's home slot, shown while its group is dragged. // Created lazily per piece so a group of N pieces shows N ghosts. ghostFor(p) { if (!p.ghost) { const g = this.add.image(0, 0, p.key).setOrigin(0.5).setAlpha(0.22).setDepth(9000); if (this.hudCam) g.cameraFilter = this.hudCam.id; // world camera only p.ghost = g; } return p.ghost; } showGroupGhosts(group) { if (this.difficulty !== 'easy') return; // keep the hint to Easy, as before for (const m of group.pieces) this.ghostFor(m).setPosition(m.home.x, m.home.y).setVisible(true); } hideGroupGhosts(group) { for (const m of group.pieces) if (m.ghost) m.ghost.setVisible(false); } 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)); // Top bound = HUD_H so the play field never slides under the fixed top bar. cam.scrollY = Phaser.Math.Clamp(cam.scrollY, HUD_H, Math.max(HUD_H, 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, HUD_H); // top bound so the field never sits under the HUD } 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.2 : 0); this.btnHint.setActive(this.hintOn); playSound(this, SFX.EIGHTBIT_SELECT); } restart() { if (this.state === 'menu') return; this.beginPlay(this.currentImage()); playSound(this, SFX.EIGHTBIT_ACTIVATE); } 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.graphics(); panel.fillStyle(0x17130c, 0.98).fillRoundedRect(cx - 310, cy - 210, 620, 420, 20); panel.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]); T(cy - 138, '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, `Solved in ${mm}:${ss}`, COLORS.textHex, '28px'); T(cy - 24, `${this.diffLabel || ''} · ${this.imageName || ''}`, COLORS.mutedHex, '20px'); const again = this.mkButton(this.winLayer, 'Play Again', cx - 120, cy + 100, 210, 64, () => this.restart(), { fontSize: 26, bg: COLORS.gold }); this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 100, 210, 64, () => this.toMenu(), { fontSize: 26 }); panel.setScale(0.85); this.tweens.add({ targets: panel, 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.boardPanel) this.boardPanel.destroy(); if (this.boardFrame) this.boardFrame.destroy(); if (this.refImage) this.refImage.destroy(); (this.pieces || []).forEach((p) => { p.img.destroy(); if (p.ghost) p.ghost.destroy(); if (this.textures.exists(p.key)) this.textures.remove(p.key); }); this.pieces = []; this.groups = new Set(); this.pieceGrid = []; 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].forEach((o) => o && o.setVisible(v)); (this.pieces || []).forEach((p) => { p.img.setVisible(v); if (p.ghost) p.ghost.setVisible(false); }); } // ── Loop ─────────────────────────────────────────────────────────────────── update(time) { if (this.state === 'playing') { if (!this.startTime) this.startTime = time; this.elapsed = Math.max(0, (time - this.startTime) / 1000); this.updateStats(); } } }