fertig-classic-games/src/games/jigsaw/JigsawGame.js

1203 lines
52 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}
}