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

1459 lines
64 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
// Win view: the finished board is framed large on the RIGHT of the screen
// (near full-screen), with the completion info panel parked on the LEFT out
// of the way of the picture. The camera eases into the framing.
const WIN_ZOOM = 1.24; // board (700px) renders ≈868px tall
const WIN_PANEL = { x: 48, y: 110, w: 470, h: 860 };
// ─────────────────────────────────────────────────────────────────────────────
// 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;
}
// Confetti bit: a small rounded square (the emitter tints each particle).
function makeConfettiTexture() {
const S = 18;
const cv = document.createElement('canvas');
cv.width = S; cv.height = S;
const ctx = cv.getContext('2d');
roundRectPath(ctx, 2, 2, S - 4, S - 4, 4);
ctx.fillStyle = '#f2ead8';
ctx.fill();
return cv;
}
// Diagonal light band for the shine sweep across the finished puzzle.
function makeShineTexture() {
const W = 256, H = 256;
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const ctx = cv.getContext('2d');
const g = ctx.createLinearGradient(0, 0, W, 0);
g.addColorStop(0, 'rgba(255,255,255,0)');
g.addColorStop(0.42, 'rgba(255,246,214,0.55)');
g.addColorStop(0.5, 'rgba(255,250,228,0.9)');
g.addColorStop(0.58, 'rgba(255,246,214,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
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.winTransition = false;
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.parentContainer && !b.parentContainer.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,
// but at full zoom-out the camera is clamped to scrollY = HUD_H, so the
// fixed bar still covers world y ∈ [HUD_H, HUD_H + HUD_H/MIN_ZOOM]. Push
// the tray top down so the WHOLE piece — base cell + tabs + shadow
// headroom, not just its centre — stays below that band.
const pieceHalf = this.cell + Math.max(7, this.cell * 0.09); // matches buildPieceCanvas PAD
const trayTop = HUD_H + HUD_H / MIN_ZOOM + pieceHalf;
const tray = { x: 40, y: trayTop, w: WORLD_W - 80, h: WORLD_H - trayTop - 40 };
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() {
// NOTE: the HUD bar, the Menu ▾ dropdown and the win overlay are all
// rendered by the fixed hudCam (see create()), which already places them
// in screen space — no pinning required. Kept as a no-op guard so nothing
// accidentally re-positions them. (buttonHit() relies on the same
// screen-space assumption.)
}
// ── 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;
// Pull the camera back to the start framing first, then rebuild the board.
const startCamX = BOARD.x + BOARD.size / 2 - GAME_WIDTH / 2; // matches setZoomTo(1)
const startCamY = BOARD.y + BOARD.size / 2 - GAME_HEIGHT / 2; // matches setZoomTo(1)
this.exitWin(() => {
this.beginPlay(this.currentImage());
playSound(this, SFX.EIGHTBIT_ACTIVATE);
}, startCamX, startCamY);
}
toMenu() {
playSound(this, SFX.EIGHTBIT_SELECT);
this.exitWin(() => {
this.teardownPlay();
this.setState('menu');
this.loadPreview(this.currentImage());
}, 0, HUD_H);
}
// Exit the win view: panel slides out, camera pulls back, then run `done()`.
exitWin(done, targetScrollX, targetScrollY) {
if (this.state !== 'won' || !this.winLayer) { done(); return; }
if (this.winTransition) return;
this.winTransition = true;
// Kill any in-flight showcase tweens (slide-ins, camera sweep, counter) so
// they don't fight the exit animation or fire the celebration late.
(this.winTweens || []).forEach((t) => t && t.stop());
this.winTweens = [];
playSound(this, SFX.VEGA_ZOOMOUT);
(this.winPanelGroups || []).forEach((g, i) => {
this.winTweens.push(this.tweens.add({ targets: g, x: g.x - 420, alpha: 0, delay: i * 45, duration: 300, ease: 'Cubic.easeIn' }));
});
if (this.winDim && this.winPanelBg) {
this.winTweens.push(this.tweens.add({ targets: [this.winDim, this.winPanelBg], alpha: 0, duration: 330, ease: 'Cubic.easeIn' }));
}
const cam = this.cameras.main;
this.winTweens.push(this.tweens.add({
targets: cam, scrollX: targetScrollX, scrollY: targetScrollY, zoom: 1,
duration: 460, ease: 'Cubic.easeInOut',
onComplete: () => {
this.winTransition = false;
this.teardownWin();
done();
},
}));
}
onWin() {
this.state = 'won';
this.setHUDForState('won');
playSound(this, SFX.VICTORY_SHORT);
this.showWinOverlay();
}
// ── Win showcase ────────────────────────────────────────────────────────
// The finished board eases into a near-full-screen frame on the RIGHT of the
// screen; the completion info + actions settle into a left-hand panel out of
// the way of the picture. Then it celebrates: confetti burst + falling
// curtain, a shine sweep across the picture, and a pulsing gold frame.
showWinOverlay() {
this.teardownWin();
this.winTweens = [];
this.winEmitters = [];
this.winPanelGroups = [];
if (!this.textures.exists('jigsaw-confetti')) this.textures.addCanvas('jigsaw-confetti', makeConfettiTexture());
if (!this.textures.exists('jigsaw-shine')) this.textures.addCanvas('jigsaw-shine', makeShineTexture());
// Win camera: board centred in the region right of the info panel. The
// board sits mid-world, so the camera must travel to its row. Vertically
// it is centred in the area BELOW the fixed HUD bar (not the whole screen),
// so the framed picture has comfortable breathing room under the bar.
const bcx = BOARD.x + BOARD.size / 2;
const bcy = BOARD.y + BOARD.size / 2;
const boardCx = (WIN_PANEL.x + WIN_PANEL.w + GAME_WIDTH) / 2;
const winScrollX = bcx - boardCx / WIN_ZOOM;
const winScrollY = bcy - ((HUD_H + GAME_HEIGHT) / 2) / WIN_ZOOM;
this.winLayer = this.add.container(0, 0).setDepth(9600);
if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only
const T = (target, x, y, s, color, o = {}) => {
const t = this.add.text(x, y, s, {
fontFamily: '"Julius Sans One"', fontSize: o.size || '20px', color, ...o,
}).setOrigin(o.align === 'left' ? 0 : 0.5);
target.add(t);
return t;
};
// Subtle dim — the picture stays the hero, the panel gets its contrast.
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.30);
dim.setAlpha(0);
this.winDim = dim;
const { x: PX, y: PY, w: PW } = WIN_PANEL;
const panelBg = this.add.graphics();
panelBg.fillStyle(0x17130c, 0.97).fillRoundedRect(PX, PY, PW, WIN_PANEL.h, 20);
panelBg.lineStyle(2, COLORS.accent, 0.55).strokeRoundedRect(PX, PY, PW, WIN_PANEL.h, 20);
panelBg.fillStyle(COLORS.gold, 0.95).fillRoundedRect(PX + 42, PY + 48, 64, 4, 2);
panelBg.setAlpha(0);
this.winPanelBg = panelBg;
this.winLayer.add([dim, panelBg]);
const LX = PX + 42; // left-aligned text edge
const BCX = PX + PW / 2, BW = PW - 84; // action-button geometry
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
// Row 1 — title + image name
const gTitle = this.add.container(0, 0);
this.winLayer.add(gTitle);
T(gTitle, LX, PY + 106, 'PUZZLE COMPLETE', COLORS.goldHex, { size: '34px', letterSpacing: 4, align: 'left' });
T(gTitle, LX, PY + 150, this.imageName || 'Puzzle', COLORS.textHex, { size: '20px', align: 'left' });
// Row 2 — solve time
const gTime = this.add.container(0, 0);
this.winLayer.add(gTime);
T(gTime, LX, PY + 232, 'SOLVED IN', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
T(gTime, LX, PY + 280, `${mm}:${ss}`, COLORS.textHex, { size: '56px', align: 'left' });
// Row 3 — pieces + difficulty
const gStats = this.add.container(0, 0);
this.winLayer.add(gStats);
const rule = this.add.graphics();
rule.lineStyle(1, 0x3a3226, 0.9);
rule.lineBetween(LX, PY + 344, LX + (PW - 84), PY + 344);
gStats.add(rule);
T(gStats, LX, PY + 384, 'PIECES', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
this.piecesBig = T(gStats, LX, PY + 424, '0', COLORS.textHex, { size: '30px', align: 'left' });
const dx = PX + PW / 2 + 20;
const diffName = (DIFFICULTIES[this.difficulty] && DIFFICULTIES[this.difficulty].label) || '—';
T(gStats, dx, PY + 384, 'DIFFICULTY', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
T(gStats, dx, PY + 424, diffName, COLORS.textHex, { size: '22px', align: 'left' });
// Row 4 — actions
const gBtns = this.add.container(0, 0);
this.winLayer.add(gBtns);
this.mkButton(gBtns, 'Play Again', BCX, PY + 512, BW, 62, () => this.restart(), { fontSize: 24, bg: COLORS.gold });
this.mkButton(gBtns, 'Menu', BCX, PY + 594, BW, 62, () => this.toMenu(), { fontSize: 24 });
this.mkButton(gBtns, '🎲 Surprise Me', BCX, PY + 676, BW, 62, () => { this.randomImage(); this.restart(); }, { fontSize: 22, variant: 'ghost' });
T(gBtns, BCX, PY + 782, 'The picture is complete — enjoy the view.', COLORS.mutedHex, { size: '16px' });
// ── Choreography ─────────────────────────────────────────────────────
const stagger = (g, delay) => {
g.setAlpha(0);
const fx = g.x;
g.x = fx - 46; // slide in from the left
this.winTweens.push(this.tweens.add({ targets: g, x: fx, alpha: 1, delay, duration: 480, ease: 'Cubic.easeOut' }));
this.winPanelGroups.push(g);
};
this.winTweens.push(this.tweens.add({ targets: [dim, panelBg], alpha: 1, delay: 350, duration: 550, ease: 'Cubic.easeOut' }));
stagger(gTitle, 500);
stagger(gTime, 620);
stagger(gStats, 740);
stagger(gBtns, 860);
// Piece counter counts up as the stats row lands.
const counter = { v: 0 };
this.winTweens.push(this.tweens.add({
targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut',
onUpdate: (tw) => { if (this.piecesBig) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); },
}));
// Camera sweep — the finale fires when it lands.
playSound(this, SFX.VEGA_ZOOMIN);
this.winTweens.push(this.tweens.add({
targets: this.cameras.main,
scrollX: winScrollX, scrollY: winScrollY, zoom: WIN_ZOOM,
duration: 1200, ease: 'Cubic.easeInOut',
onComplete: () => this.winCelebrate(),
}));
this.winObjects = [this.winLayer];
}
// Fired when the win camera lands: confetti, shine sweep, gold frame pulse.
winCelebrate() {
if (!this.winLayer || this.state !== 'won') return;
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
const TINTS = [0xd4a017, 0xf2ead8, 0xffffff, 0xc8a84b, 0xe06c75];
playSound(this, SFX.FIREWORK);
playSound(this, SFX.EIGHTBIT_WIN);
// Burst from the heart of the finished puzzle.
const burst = this.add.particles(bcx, bcy, 'jigsaw-confetti', {
radial: true,
lifespan: { min: 1200, max: 2400 },
speed: { min: 220, max: 760 },
angle: { min: 0, max: 360 },
gravityY: 950,
rotate: { start: -720, end: 720, random: true },
scaleX: { min: 0.45, max: 1.25 },
scaleY: { min: 0.45, max: 1.25 },
alpha: { start: 1, end: 0 },
tint: TINTS,
maxAliveParticles: 300,
});
if (this.hudCam) burst.cameraFilter = this.hudCam.id; // world camera only
burst.setDepth(50); // above the finished pieces (depth 10-12)
burst.explode(120);
this.winEmitters.push(burst);
// Curtain of confetti falling across the whole frame (screen-anchored).
const cam = this.cameras.main;
const band = new Phaser.Geom.Rectangle(cam.scrollX, cam.scrollY, GAME_WIDTH / cam.zoom, 120);
const curtain = this.add.particles(0, 0, 'jigsaw-confetti', {
lifespan: { min: 1700, max: 3200 },
speedX: { min: -70, max: 70 },
speedY: { min: 90, max: 260 },
gravityY: 430,
rotate: { start: -540, end: 540, random: true },
scaleX: { min: 0.4, max: 1.05 },
scaleY: { min: 0.4, max: 1.05 },
alpha: { start: 1, end: 0 },
tint: TINTS,
emitZone: { type: 'random', source: band },
maxAliveParticles: 220,
});
if (this.hudCam) curtain.cameraFilter = this.hudCam.id;
curtain.setDepth(50); // above the finished pieces (depth 10-12)
curtain.explode(90);
this.winEmitters.push(curtain);
// Gold trophy frame around the board, then a gentle pulse.
const frame = this.add.graphics().setDepth(4);
if (this.hudCam) frame.cameraFilter = this.hudCam.id; // world camera only
frame.lineStyle(3, COLORS.gold, 0.95);
frame.strokeRoundedRect(BOARD.x - 16, BOARD.y - 16, BOARD.size + 32, BOARD.size + 32, 18);
frame.lineStyle(1, 0xfff1cf, 0.5);
frame.strokeRoundedRect(BOARD.x - 7, BOARD.y - 7, BOARD.size + 14, BOARD.size + 14, 12);
frame.setAlpha(0);
this.winFrame = frame;
this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.95, duration: 600, ease: 'Cubic.easeOut' }));
this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.62, duration: 1500, delay: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }));
// Shine sweep across the completed picture.
const shine = this.add.image(bcx, bcy, 'jigsaw-shine')
.setDisplaySize(BOARD.size * 0.55, BOARD.size * 1.9)
.setRotation(-0.22)
.setAlpha(0)
.setDepth(40)
.setBlendMode(Phaser.BlendModes.ADD);
if (this.hudCam) shine.cameraFilter = this.hudCam.id; // world camera only
this.winShine = shine;
const x0 = bcx - BOARD.size * 1.05, x1 = bcx + BOARD.size * 1.05;
shine.x = x0; // start off the left edge of the board, sweep through to the right
this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.5, duration: 450, ease: 'Cubic.easeIn' }));
this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.55, duration: 350, delay: 450, ease: 'Sine.easeInOut' }));
this.winTweens.push(this.tweens.add({
targets: shine, x: x1, alpha: 0, duration: 520, delay: 800, ease: 'Cubic.easeOut',
onComplete: () => shine.setVisible(false),
}));
}
teardownWin() {
(this.winTweens || []).forEach((t) => t && t.stop());
this.winTweens = null;
(this.winEmitters || []).forEach((e) => e && e.destroy());
this.winEmitters = null;
this.winPanelGroups = null;
this.piecesBig = null;
if (this.winFrame) { this.winFrame.destroy(); this.winFrame = null; }
if (this.winShine) { this.winShine.destroy(); this.winShine = null; }
if (this.winObjects) {
this.winObjects.forEach((o) => o.destroy());
this.winObjects = null;
}
this.winLayer = null;
this.winDim = null;
this.winPanelBg = null;
if (this.textures.exists('jigsaw-confetti')) this.textures.remove('jigsaw-confetti');
if (this.textures.exists('jigsaw-shine')) this.textures.remove('jigsaw-shine');
}
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();
}
}
}