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

857 lines
36 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 { SFX, playSound } from '../../ui/Sounds.js';
import {
DIFFICULTIES, DIFFICULTY_ORDER,
makeJigsaw, cellOutline, tracePath, mulberry32,
} from './JigsawLogic.js';
// ─────────────────────────────────────────────────────────────────────────────
// Layout (world == the 1920×1080 canvas). Pieces live on a big "table"; the
// board target is a fixed square. Drag pieces to their slots; drag empty space
// to pan; scroll / / to zoom.
// ─────────────────────────────────────────────────────────────────────────────
const HUD_H = 88;
const BOARD_SIZE = 700;
// The puzzle plays on a field larger than the on-screen canvas. The camera pans
// over this whole field (and the player can zoom out until it all fits), so
// there's lots of room to spread pieces and explore.
const WORLD_W = 3840;
const WORLD_H = 2160;
const BOARD = { x: (WORLD_W - BOARD_SIZE) / 2, y: (WORLD_H - BOARD_SIZE) / 2, size: BOARD_SIZE };
const MAX_ZOOM = 4;
const MIN_ZOOM = 0.5; // zooms out far enough to see the entire field at once
const SNAP_FRAC = 0.42; // snap radius as a fraction of the cell size
const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs
function offsetOutline(o, ox, oy) {
return {
start: { x: o.start.x + ox, y: o.start.y + oy },
cmds: o.cmds.map((c) => c.t === 'line'
? { t: 'line', x: c.x + ox, y: c.y + oy }
: { t: 'bezier', c1: { x: c.c1.x + ox, y: c.c1.y + oy }, c2: { x: c.c2.x + ox, y: c.c2.y + oy }, x: c.x + ox, y: c.y + oy }),
};
}
// Load a picture as a plain HTMLImageElement (cached by URL). We draw pieces
// and thumbnails straight from this element rather than relying on Phaser's
// texture-source API, which differs across Phaser 3 versions.
const _imgCache = {};
function loadImage(url) {
if (_imgCache[url]) return Promise.resolve(_imgCache[url]);
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => { _imgCache[url] = img; resolve(img); };
img.onerror = () => reject(new Error('image load failed: ' + url));
img.src = url;
});
}
export default class JigsawGame extends Phaser.Scene {
constructor() {
super('jigsaw-game');
this._pt = new Phaser.Math.Vector2();
}
create() {
this.artwork = [];
this.imageIndex = 0;
this.buttons = [];
this.pieces = [];
this.state = 'menu';
this.dragging = null;
this.panning = null;
this.placed = 0;
this.zTop = 1000;
this.startTime = 0;
this.elapsed = 0;
this.hintOn = true;
this.selectedDiff = 'easy';
this.loadArtwork();
this.buildBackground();
this.buildHUD();
this.buildMenu();
this.bindInput();
this.cameras.main.setBounds(0, 0, WORLD_W, WORLD_H);
this.cameras.main.setZoom(1);
this.cameras.main.setScroll(0, 0);
// ── Fixed HUD camera ─────────────────────────────────────────────────
// A second camera locked at (0,0) zoom 1 renders the HUD + win overlay
// in screen space, so they never move or scale with the board camera.
this.mainCam = this.cameras.main;
this.hudCam = this.cameras.add(0, 0, GAME_WIDTH, GAME_HEIGHT);
this.hudCam.scrollX = 0;
this.hudCam.scrollY = 0;
this.hudCam.setZoom(1);
// Route world objects to the main camera only, and UI (HUD/menu) to the
// fixed hudCam only, so the two never overlap on the same camera.
const worldOnly = (o) => { if (o) o.cameraFilter = this.hudCam.id; return o; };
const screenOnly = (o) => { if (o) o.cameraFilter = this.mainCam.id; return o; };
worldOnly(this.bg);
worldOnly(this.boardPanel);
worldOnly(this.boardFrame);
worldOnly(this.refImage);
worldOnly(this.ghost);
screenOnly(this.hud);
screenOnly(this.menu);
this.state = 'menu';
}
// Route a game object so it renders on all cameras except `cam`.
excludeFrom(obj, cam) { if (obj && cam) obj.cameraFilter = cam.id; return obj; }
// ── Artwork (Shift images, lazy-loaded) ────────────────────────────────────
loadArtwork() {
const data = this.cache.json.get('shift-artwork');
const list = (data && data.artwork) || (Array.isArray(data) ? data : []);
this.artwork = list.filter((a) => a && a.path);
if (!this.artwork.length) this.artwork = [{ name: 'Puzzle', path: 'assets/images/shift/alien-world.png' }];
}
currentImage() { return this.artwork[this.imageIndex % this.artwork.length]; }
previewImage(dir) {
this.imageIndex = (this.imageIndex + dir + this.artwork.length) % this.artwork.length;
const item = this.currentImage();
playSound(this, SFX.UI_FLIP);
this.loadPreview(item);
}
randomImage() {
let i = this.imageIndex;
while (i === this.imageIndex && this.artwork.length > 1) i = Math.floor(Math.random() * this.artwork.length);
this.imageIndex = i;
playSound(this, SFX.UI_ACTIVATE);
this.loadPreview(this.currentImage());
}
loadPreview(item) {
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
const key = item.path;
this._loadToken = (this._loadToken || 0) + 1;
const token = this._loadToken;
loadImage(key).then((srcImg) => {
if (token !== this._loadToken) return; // superseded by a newer pick
if (!this.menu || !this.menu.visible) return;
const box = 300, cx = this.thumbBoxX, cy = this.thumbBoxY;
const S = 220;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
ctx.save();
const rr = 16;
ctx.beginPath();
ctx.moveTo(rr, 0); ctx.arcTo(S, 0, S, S, rr); ctx.arcTo(S, S, 0, S, rr);
ctx.arcTo(0, S, 0, 0, rr); ctx.arcTo(0, 0, S, 0, rr); ctx.closePath();
ctx.clip();
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
const sc = Math.max(S / iw, S / ih);
ctx.drawImage(srcImg, (S - iw * sc) / 2, (S - ih * sc) / 2, iw * sc, ih * sc);
ctx.restore();
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
this.textures.addCanvas('jigsaw-thumb', cv);
const img = this.add.image(cx, cy, 'jigsaw-thumb').setDisplaySize(box, box);
this.menu.add(img);
img.setPosition(cx, cy);
this.previewImg = img;
this.addThumbBorder();
if (this.thumbName) this.thumbName.setText(item.name || 'Image');
}).catch((e) => { console.warn('jigsaw preview:', e.message); });
}
addThumbBorder() {
if (this.thumbBorder) this.thumbBorder.destroy();
const g = this.add.graphics();
g.lineStyle(3, COLORS.accent, 0.9);
g.strokeRoundedRect(-150, -150, 300, 300, 16);
this.thumbBorder = g;
const parent = this.menu || this;
if (parent.add) parent.add(g);
g.setPosition(this.thumbBoxX, this.thumbBoxY);
}
// ── Background / table ─────────────────────────────────────────────────────
buildBackground() {
const bg = this.add.graphics().setDepth(0);
// felt-like table covering the whole (bigger) field
bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, WORLD_W, WORLD_H);
// subtle framed panel to read as a table
bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, WORLD_W - 56, WORLD_H - 56, 26);
bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, WORLD_W - 68, WORLD_H - 68, 22);
bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, WORLD_W - 80, WORLD_H - 80, 18);
this.bg = bg;
bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, WORLD_W, WORLD_H), Phaser.Geom.Rectangle.Contains);
bg.on('pointerdown', (pointer) => this.onTableDown(pointer));
}
// ── HUD (pinned to the top of the screen regardless of pan/zoom) ──────────
buildHUD() {
this.hud = this.add.container(0, 0).setDepth(9000);
const bar = this.add.rectangle(GAME_WIDTH / 2, HUD_H / 2, GAME_WIDTH, HUD_H, 0x000000, 0.32);
const line = this.add.rectangle(GAME_WIDTH / 2, HUD_H, GAME_WIDTH, 3, COLORS.accent, 0.7);
this.hud.add([bar, line]);
this.title = this.add.text(72, HUD_H / 2, 'JIGSAW', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
letterSpacing: 4,
}).setOrigin(0.5, 0.5);
this.hud.add(this.title);
this.diffBadge = this.add.text(195, HUD_H / 2, 'Easy · 25', {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex,
}).setOrigin(0.5, 0.5);
this.hud.add(this.diffBadge);
this.stats = this.add.text(GAME_WIDTH / 2, HUD_H / 2, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5, 0.5);
this.hud.add(this.stats);
const B = (label, x, w, onClick, o = {}) => this.mkButton(this.hud, label, x, HUD_H / 2, w, 54, onClick, o);
this.btnMenu = B('Menu', 1845, 118, () => this.toMenu());
this.btnHome = B('⌂', 1735, 54, () => this.resetView(), { fontSize: 24 });
this.btnZoomIn = B('', 1665, 54, () => this.zoomStep(1.2), { fontSize: 26 });
this.btnZoomOut= B('', 1595, 54, () => this.zoomStep(1 / 1.2), { fontSize: 26 });
this.btnHint = B('Hint', 1490, 84, () => this.toggleHint(), { fontSize: 22 });
this.btnNew = B('New', 1365, 96, () => this.restart(), { fontSize: 22 });
this.btnHint.setActive(true);
this.hudVisible = () => this.hud.visible;
this.setHUDForState('menu');
}
setHUDForState(state) {
const playing = state === 'playing' || state === 'won';
[this.btnMenu, this.btnHome, this.btnZoomIn, this.btnZoomOut, this.btnHint, this.btnNew].forEach((b) => {
const on = playing && (b !== this.btnNew || state === 'playing');
b.visible = on;
if (b === this.btnNew && state === 'won') b.visible = true;
});
this.diffBadge.visible = playing;
this.stats.visible = playing;
this.title.setVisible(true);
}
// ── Button factory (registered for hit-testing + cleanup) ──────────────────
mkButton(parent, label, x, y, w, h, onClick, o = {}) {
// Swallow a click that is really the tail-end of a piece drag (Phaser fires
// pointerup on whatever is under the cursor at release, even mid-drag).
const safe = () => {
if (this.suppressNextButtonClick) { this.suppressNextButtonClick = false; return; }
if (onClick) onClick();
};
const b = new Button(this, x, y, label, safe, {
width: w, height: h,
fontSize: o.fontSize || 24,
variant: o.variant || 'solid',
bg: o.bg || COLORS.panel,
});
parent.add(b);
this.buttons.push(b);
return b;
}
buttonHit(cx, cy) {
for (const b of this.buttons) {
if (!b.visible || !b.parent || !b.parent.visible) continue;
const w = b.options.width, h = b.options.height;
if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b;
}
return null;
}
// ── Menu ───────────────────────────────────────────────────────────────────
buildMenu() {
this.menu = this.add.container(0, 0).setDepth(8000);
const W = 1160, H = 820, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2 + 12;
const panel = this.add.rectangle(cx, cy, W, H, 0x17130c, 0.96);
const frame = this.add.graphics();
frame.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 20);
this.menu.add([panel, frame]);
const addT = (x, y, s, color, o = {}) => {
const t = this.add.text(x, y, s, { fontFamily: '"Julius Sans One"', fontSize: o.size, color: color, ...o }).setOrigin(0.5);
this.menu.add(t); return t;
};
addT(cx, cy - 350, 'JIGSAW', COLORS.textHex, { size: '58px', letterSpacing: 10 });
addT(cx, cy - 308, 'Assemble the picture. Drag pieces, pan the table, zoom to fit.', COLORS.mutedHex, { size: '20px' });
// Difficulty
addT(cx, cy - 250, 'Difficulty', COLORS.goldHex, { size: '24px', letterSpacing: 3 });
const diffLabels = DIFFICULTY_ORDER.map((k) => DIFFICULTIES[k]);
const diffY = cy - 205;
this.diffButtons = [];
const bw = 210, gap = 22, totalW = diffLabels.length * bw + (diffLabels.length - 1) * gap;
let bx = cx - totalW / 2 + bw / 2;
DIFFICULTY_ORDER.forEach((k, i) => {
const cfg = DIFFICULTIES[k];
const b = this.mkButton(this.menu, `${cfg.label} · ${cfg.cols * cfg.rows}`, bx, diffY, bw, 64, () => this.selectDifficulty(k), { fontSize: 22 });
this.diffButtons.push({ key: k, btn: b });
bx += bw + gap;
});
// Image row
addT(cx, cy - 110, 'Image', COLORS.goldHex, { size: '24px', letterSpacing: 3 });
this.thumbBoxX = cx; this.thumbBoxY = cy + 20;
this.thumbName = addT(cx, cy + 175, '', COLORS.textHex, { size: '20px' });
const prev = this.mkButton(this.menu, '', cx - 205, cy + 20, 64, 64, () => this.previewImage(-1), { fontSize: 34 });
const next = this.mkButton(this.menu, '', cx + 205, cy + 20, 64, 64, () => this.previewImage(1), { fontSize: 34 });
const rnd = this.mkButton(this.menu, '🎲 Random', cx, cy + 215, 190, 52, () => this.randomImage(), { fontSize: 22 });
this.thumbName.y = cy + 160;
// Start
const start = this.mkButton(this.menu, 'Start Puzzle ▸', cx, cy + 315, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
this.startButton = start;
// Load the first preview
this.selectDifficulty('easy');
this.loadPreview(this.currentImage());
}
selectDifficulty(key) {
this.selectedDiff = key;
playSound(this, SFX.EIGHTBIT_SELECT);
this.diffButtons.forEach((d) => d.btn.setActive(d.key === key));
}
// ── Puzzle start ───────────────────────────────────────────────────────────
startPuzzle() {
const item = this.currentImage();
playSound(this, SFX.EIGHTBIT_ACTIVATE);
this.menu.setVisible(false);
this.setState('loading');
this.showLoading(true);
loadImage(item.path).then(() => {
this.showLoading(false);
this.beginPlay(item);
}).catch((e) => {
this.showLoading(false);
console.error('jigsaw image load failed:', e.message);
this.setState('menu');
this.menu.setVisible(true);
});
}
showLoading(on) {
if (on && !this.loadingLabel) {
this.loadingLabel = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'Loading…', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(9500);
if (this.mainCam) this.loadingLabel.cameraFilter = this.mainCam.id;
} else if (this.loadingLabel) this.loadingLabel.setVisible(on);
}
setState(s) {
this.state = s;
this.setHUDForState(s);
if (s === 'menu') {
this.menu.setVisible(true);
this.setBoardVisible(false);
this.resetToCanvas();
} else {
this.menu.setVisible(false);
}
}
// ── Build board + pieces ───────────────────────────────────────────────────
beginPlay(item) {
this.teardownPlay();
const cfg = DIFFICULTIES[this.selectedDiff];
this.cols = cfg.cols; this.rows = cfg.rows;
this.total = cfg.cols * cfg.rows;
this.cell = BOARD_SIZE / cfg.cols;
this.placed = 0;
this.startTime = 0; this.elapsed = 0;
this.seed = (Math.random() * 1e9) | 0;
this.jig = makeJigsaw(this.cols, this.rows, this.seed);
this.diffLabel = `${cfg.label} · ${this.total} pieces`;
this.imageName = item.name || 'Image';
// Board panel + reference picture
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
const panel = this.add.rectangle(bcx, bcy, BOARD.size + 28, BOARD.size + 28, 0x000000, 0.28).setDepth(1);
const boardFrame = this.add.graphics().setDepth(2);
boardFrame.lineStyle(3, COLORS.accent, 0.8).strokeRoundedRect(BOARD.x - 14, BOARD.y - 14, BOARD.size + 28, BOARD.size + 28, 12);
boardFrame.lineStyle(2, 0xffffff, 0.15);
boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size);
this.boardPanel = panel;
this.boardFrame = boardFrame;
const srcImg = _imgCache[item.path];
// Build the faint reference into a canvas texture (cover-cropped square),
// then place it. Texture must exist before the image references it.
const R = 640;
const rcv = document.createElement('canvas');
rcv.width = rcv.height = R;
const rctx = rcv.getContext('2d');
if (srcImg) {
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
const sc = Math.max(R / iw, R / ih);
rctx.drawImage(srcImg, (R - iw * sc) / 2, (R - ih * sc) / 2, iw * sc, ih * sc);
} else {
rctx.fillStyle = '#5a7b8a'; rctx.fillRect(0, 0, R, R);
}
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
this.textures.addCanvas('jigsaw-ref', rcv);
this.refImage = this.add.image(bcx, bcy, 'jigsaw-ref').setDisplaySize(BOARD.size, BOARD.size).setAlpha(0.16).setDepth(3);
// Build piece textures + sprites
const texKey = (r, c) => `jg-${this.seed}-${r}-${c}`;
const boardW = this.cols * this.cell, boardH = this.rows * this.cell;
this.pieces = [];
for (let r = 0; r < this.rows; r++) {
for (let c = 0; c < this.cols; c++) {
const key = texKey(r, c);
const cv = this.buildPieceCanvas(r, c, srcImg, boardW, boardH);
if (this.textures.exists(key)) this.textures.remove(key);
this.textures.addCanvas(key, cv);
const home = { x: BOARD.x + (c + 0.5) * this.cell, y: BOARD.y + (r + 0.5) * this.cell };
const img = this.add.image(0, 0, key).setOrigin(0.5).setDepth(10);
if (this.hudCam) img.cameraFilter = this.hudCam.id; // world camera only
// Hit area = the piece's own footprint (base cell + inner knob), NOT 2×cell.
// Pieces scatter with a minimum centre-gap of ~1.45×cell, so keeping the
// radius at 0.6×cell guarantees a pointer can only ever fall inside ONE
// piece's hit area. (With the old ±cell box, ~35 neighbour pairs overlapped
// and the click resolved to whichever had the highest index — the bottom-
// right piece — far away from the cursor.)
// Pieces stay interactive only for the hover cursor. The actual grab is
// resolved centrally in onTableDown (nearest piece to the pointer) so a
// click can never resolve to a distant neighbour's overlapping hit box.
const grab = this.cell * GRAB_FRAC;
img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(-grab, -grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
this.pieces.push({ r, c, img, home, placed: false, key, depth: 10 });
}
}
// Scatter pieces around (but off) the board. scatterSpots always returns
// exactly `total` spots; the `|| { x: BOARD.x, y: BOARD.y }` is just a
// last-resort guard so a layout change can never crash the game.
const spots = this.scatterSpots(this.total);
this.pieces.forEach((p, i) => {
const s = spots[i] || { x: BOARD.x + BOARD.size / 2, y: BOARD.y + BOARD.size / 2 };
p.img.setPosition(s.x, s.y);
});
// Ghost target shown while dragging (kept above pieces so it stays visible)
this.ghost = this.add.image(0, 0).setOrigin(0.5).setAlpha(0.22).setVisible(false).setDepth(9000);
if (this.hudCam) this.ghost.cameraFilter = this.hudCam.id; // world camera only
this.updateStats();
this.setState('playing');
this.resetView();
}
buildPieceCanvas(r, c, src, boardW, boardH) {
const cell = this.cell;
const S = Math.ceil(cell * 2) + 2;
const cv = document.createElement('canvas');
cv.width = cv.height = S;
const ctx = cv.getContext('2d');
const ox = S / 2 - (c + 0.5) * cell;
const oy = S / 2 - (r + 0.5) * cell;
const outline = offsetOutline(cellOutline(this.jig, r, c, cell, cell), ox, oy);
ctx.save();
tracePath(ctx, outline);
ctx.clip();
if (src) ctx.drawImage(src, ox, oy, boardW, boardH);
else {
// Fallback swatch so a piece is still visible if the image is missing.
const hue = ((r * this.cols + c) * 47) % 360;
ctx.fillStyle = `hsl(${hue} 45% 60%)`;
ctx.fillRect(0, 0, S, S);
}
// depth shading
const g = ctx.createLinearGradient(0, 0, 0, S);
g.addColorStop(0, 'rgba(255,255,255,0.10)');
g.addColorStop(0.5, 'rgba(255,255,255,0)');
g.addColorStop(1, 'rgba(0,0,0,0.20)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, S, S);
ctx.restore();
tracePath(ctx, outline);
ctx.lineJoin = 'round';
ctx.lineWidth = Math.max(1.5, cell * 0.028);
ctx.strokeStyle = 'rgba(12,9,6,0.6)';
ctx.stroke();
tracePath(ctx, outline);
ctx.lineWidth = Math.max(1, cell * 0.014);
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
ctx.stroke();
return cv;
}
scatterSpots(n) {
const g = mulberry32(this.seed ^ 0x9e3779b9);
// Tray = the whole (bigger) field minus a small margin, EXCLUDING the board
// (pieces must not scatter on top of the target image or they'd be
// indistinguishable from placed pieces). The HUD lives in viewport space,
// so it doesn't reserve any of the world.
const tray = { x: 40, y: 40, w: WORLD_W - 80, h: WORLD_H - 80 };
const boardBox = { x0: BOARD.x - 6, y0: BOARD.y - 6, x1: BOARD.x + BOARD.size + 6, y1: BOARD.y + BOARD.size + 6 };
const grabR = this.cell * GRAB_FRAC;
// We guarantee a comfortable minimum centre-gap so no two pieces steal each
// other's grab region (the old rejection-scatter over-packed the table and
// left some pieces ungrabbable until their neighbours were placed).
//
// KEY: candidate CENTRES are computed without jitter, so their COUNT is
// deterministic for a given spacing (the old code jittered first and then
// dropped points that wandered off-table / under the board, so the count was
// seed-dependent and sometimes came up short — crashing beginPlay with
// `undefined.x`). We start roomy and tighten the grid until it holds >= n
// centres. The floor is small enough that a fit always exists, so we never
// come up short and never emit an off-canvas spot.
const floor = this.cell; // densest we'll allow — one base-cell apart
let spacing = this.cell * 1.7;
let cells = [];
for (let i = 0; i < 40; i++) {
cells = this.gridCells(tray, boardBox, spacing, grabR);
if (cells.length >= n) break;
spacing = Math.max(floor, spacing * 0.95);
}
// Cosmetic jitter for a natural look. Only jitter cells that sit safely away
// from the board and the tray edges so the jitter can never push a piece
// under the board or off the table. When the grid is tight (spacing <= 2x
// grab) we skip jitter so the centre-gap never shrinks below the fit.
const J = Math.max(0, spacing - 2 * grabR) * 0.2;
const safe = (p) => {
const bx0 = boardBox.x0 - J, by0 = boardBox.y0 - J, bx1 = boardBox.x1 + J, by1 = boardBox.y1 + J;
const tx0 = tray.x + grabR + J, tx1 = tray.x + tray.w - grabR - J;
const ty0 = tray.y + grabR + J, ty1 = tray.y + tray.h - grabR - J;
return !(p.x > bx0 && p.x < bx1 && p.y > by0 && p.y < by1) &&
(p.x > tx0 && p.x < tx1 && p.y > ty0 && p.y < ty1);
};
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
const pts = cells.map((p) => safe(p) && J > 0
? { x: clamp(p.x + (g() - 0.5) * 2 * J, x0, x1), y: clamp(p.y + (g() - 0.5) * 2 * J, y0, y1) }
: { x: clamp(p.x, x0, x1), y: clamp(p.y, y0, y1) });
// Defensive backfill (should never be needed with the floor above): add spots
// in a guaranteed-clear left strip, staying strictly inside the tray so we
// can never emit an off-canvas position.
if (pts.length < n) {
let k = pts.length;
while (pts.length < n) {
const col = k % 3, row = Math.floor(k / 3);
pts.push({
x: clamp(tray.x + grabR + col * 2 * grabR, x0, x1),
y: clamp(tray.y + grabR + row * 2 * grabR, y0, y1),
});
k++;
}
}
// shuffle so pieces aren't in grid order, then take n
for (let i = pts.length - 1; i > 0; i--) { const j = (g() * (i + 1)) | 0; const t = pts[i]; pts[i] = pts[j]; pts[j] = t; }
return pts.slice(0, n);
}
// Un-jittered grid CENTRES inside the tray, clear of the board. Count is
// deterministic in `spacing` (no RNG), so the caller can rely on it to
// guarantee a fit. Centres are kept a full grab-radius away from the board.
gridCells(tray, boardBox, spacing, grabR) {
const out = [];
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
const cs = Math.round(x0 / spacing), ce = Math.floor(x1 / spacing);
const rs = Math.round(y0 / spacing), re = Math.floor(y1 / spacing);
for (let r = rs; r <= re; r++) {
for (let c = cs; c <= ce; c++) {
const x = c * spacing, y = r * spacing;
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
if (x > boardBox.x0 && x < boardBox.x1 && y > boardBox.y0 && y < boardBox.y1) continue;
out.push({ x, y });
}
}
return out;
}
// ── Interaction ────────────────────────────────────────────────────────────
bindInput() {
this.input.on('pointermove', (pointer) => this.onPointerMove(pointer));
this.input.on('pointerup', () => this.onPointerUp());
this.input.on('pointerupoutside', () => this.onPointerUp());
this.input.on('wheel', (pointer, _o, _dx, dy) => {
if (this.state !== 'playing') return;
const c = this.canvasPos(pointer);
this.zoomAt(c.x, c.y, dy < 0 ? 1.15 : 1 / 1.15);
});
}
canvasPos(pointer) {
// pointer.x/y are already in screen (game-canvas) space per the Phaser 3.90
// Pointer API ("The value is in screen space"). Phaser 3.90 has no
// ScaleManager.getPointerPosition, so we read the pointer directly.
return { x: pointer.x, y: pointer.y };
}
worldOf(pointer) {
// Convert screen (canvas) space -> world space through the active camera
// matrix (accounts for scroll + zoom). Deterministic: reads the current
// pointer position and the current camera matrix.
const c = this.canvasPos(pointer);
const w = this.cameras.main.getWorldPoint(c.x, c.y);
return { x: w.x, y: w.y };
}
nearestPiece(w) {
if (this.state !== 'playing') return null;
const thr = this.cell * GRAB_FRAC; // grab radius: covers the piece body incl. its knobs
let best = null, bestD = Infinity;
for (const p of this.pieces) {
if (p.placed) continue;
const d = Math.hypot(p.img.x - w.x, p.img.y - w.y);
if (d < thr && d < bestD) { bestD = d; best = p; }
}
return best;
}
onTableDown(pointer) {
if (this.state !== 'playing') return;
const c = this.canvasPos(pointer);
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons work
const piece = this.nearestPiece(this.worldOf(pointer));
if (piece) { this.grabPiece(piece, pointer); return; }
const cam = this.cameras.main; // empty table → pan
this.panning = { cx: c.x, cy: c.y, camX: cam.scrollX, camY: cam.scrollY };
}
grabPiece(piece, pointer) {
const w = this.worldOf(pointer);
this.zTop += 1;
piece.depth = this.zTop;
piece.img.setDepth(this.zTop);
this.dragging = { piece, offX: piece.img.x - w.x, offY: piece.img.y - w.y };
this.suppressNextButtonClick = true;
this.ghost.setTexture(piece.img.texture.key).setPosition(piece.home.x, piece.home.y).setVisible(true);
playSound(this, SFX.UI_PICK);
}
onPointerMove(pointer) {
const c = this.canvasPos(pointer);
if (this.dragging) {
const w = this.worldOf(pointer);
const { piece, offX, offY } = this.dragging;
piece.img.setPosition(w.x + offX, w.y + offY);
return;
}
if (this.panning) {
const cam = this.cameras.main;
cam.scrollX = this.panning.camX - (c.x - this.panning.cx) / cam.zoom;
cam.scrollY = this.panning.camY - (c.y - this.panning.cy) / cam.zoom;
this.clampCamera();
}
}
onPointerUp() {
if (this.dragging) {
const { piece } = this.dragging;
this.ghost.setVisible(false);
this.dragging = null;
this.trySnap(piece);
}
this.panning = null;
this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; });
}
trySnap(piece) {
const d = Math.hypot(piece.img.x - piece.home.x, piece.img.y - piece.home.y);
if (d < this.cell * SNAP_FRAC) {
piece.img.setPosition(piece.home.x, piece.home.y);
piece.placed = true;
this.placed += 1;
piece.img.setDepth(12);
this.nudge(piece.img);
playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE);
this.updateStats();
if (this.placed === this.total) this.onWin();
} else {
// gentle settle
this.nudge(piece.img);
playSound(this, SFX.PIECE_CLICK);
}
}
nudge(obj) {
const t = obj.scale;
obj.setScale(obj.scale * 1.12);
this.tweens.add({ targets: obj, scale: t, duration: 150, ease: 'Quad.easeOut' });
}
updateStats() {
if (!this.stats) return;
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
this.stats.setText(`${this.placed} / ${this.total} · ${mm}:${ss}`);
if (this.diffBadge) this.diffBadge.setText(`${this.diffLabel || 'Easy'}`);
}
// ── Camera: pan / zoom ─────────────────────────────────────────────────────
clampCamera() {
const cam = this.cameras.main;
const vw = GAME_WIDTH / cam.zoom, vh = GAME_HEIGHT / cam.zoom;
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, Math.max(0, WORLD_W - vw));
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, Math.max(0, WORLD_H - vh));
}
// Zoom about the point (cx, cy) in canvas space: the world point under that
// screen position stays fixed, so zooming in/out is anchored to the cursor
// (relative to the board) rather than snapping to the canvas centre.
zoomAt(cx, cy, factor) {
const cam = this.cameras.main;
const oldZ = cam.zoom;
const newZ = Phaser.Math.Clamp(oldZ * factor, MIN_ZOOM, MAX_ZOOM);
if (Math.abs(newZ - oldZ) < 0.0001) return;
const wx = cam.scrollX + cx / oldZ;
const wy = cam.scrollY + cy / oldZ;
cam.setZoom(newZ);
cam.scrollX = wx - cx / newZ;
cam.scrollY = wy - cy / newZ;
this.clampCamera();
}
zoomStep(factor) {
this.zoomAt(GAME_WIDTH / 2, GAME_HEIGHT / 2, factor);
}
resetView() { this.setZoomTo(1); }
// Menu/loading views live in canvas space (top-left of the world).
resetToCanvas() {
const cam = this.cameras.main;
cam.setZoom(1);
cam.setScroll(0, 0);
}
setZoomTo(z) {
const cam = this.cameras.main;
cam.setZoom(Phaser.Math.Clamp(z, MIN_ZOOM, MAX_ZOOM));
// Centre the field on the board so the start view frames the puzzle.
cam.centerOn(BOARD.x + BOARD.size / 2, BOARD.y + BOARD.size / 2);
this.clampCamera();
}
pinHUD() {
const cam = this.cameras.main;
// The HUD and the win overlay are both pinned to the top-left of the
// viewport (scaled to counter the zoom) so they sit in screen space no
// matter where the camera is panned/zoomed within the (bigger) field.
if (this.hud) {
this.hud.setPosition(cam.scrollX, cam.scrollY);
this.hud.setScale(1 / cam.zoom);
}
if (this.winLayer) {
this.winLayer.setPosition(cam.scrollX, cam.scrollY);
this.winLayer.setScale(1 / cam.zoom);
}
}
// ── State flow ─────────────────────────────────────────────────────────────
toggleHint() {
this.hintOn = !this.hintOn;
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.16 : 0);
this.btnHint.setActive(this.hintOn);
playSound(this, SFX.EIGHTBIT_SELECT);
}
restart() {
if (this.state === 'menu') return;
this.beginPlay(this.currentImage());
playSound(this, SFX.EIGHTBIT_ACTIVATE);
}
toMenu() {
playSound(this, SFX.EIGHTBIT_SELECT);
this.teardownPlay();
this.setState('menu');
this.loadPreview(this.currentImage());
}
onWin() {
this.state = 'won';
this.setHUDForState('won');
playSound(this, SFX.VICTORY_SHORT);
this.showWinOverlay();
}
showWinOverlay() {
this.teardownWin();
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
this.winLayer = this.add.container(0, 0).setDepth(9600);
if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55);
const panel = this.add.rectangle(cx, cy, 620, 420, 0x17130c, 0.98);
const frame = this.add.graphics();
frame.lineStyle(3, COLORS.gold, 0.9).strokeRoundedRect(cx - 310, cy - 210, 620, 420, 20);
const T = (y, s, color, size) => {
const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5);
this.winLayer.add(t);
return t;
};
this.winLayer.add([dim, panel, frame]);
T(cy - 140, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
T(cy - 70, this.diffLabel || '', COLORS.textHex, '26px');
T(cy - 28, `Solved in ${mm}:${ss}`, COLORS.textHex, '24px');
T(cy + 8, this.imageName || '', COLORS.mutedHex, '20px');
const again = this.mkButton(this.winLayer, 'Play Again', cx - 120, cy + 110, 210, 64, () => this.restart(), { fontSize: 26, bg: COLORS.gold });
this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 110, 210, 64, () => this.toMenu(), { fontSize: 26 });
[panel, frame].forEach((o) => { o.setScale(0.85); this.tweens.add({ targets: o, scale: 1, duration: 280, ease: 'Back.easeOut' }); });
this.winObjects = [this.winLayer];
}
teardownWin() {
if (this.winObjects) {
this.winObjects.forEach((o) => o.destroy());
this.winObjects = null;
this.winLayer = null;
}
}
teardownPlay() {
this.teardownWin();
this.dragging = null; this.panning = null;
if (this.ghost) { this.ghost.destroy(); this.ghost = null; }
if (this.boardPanel) this.boardPanel.destroy();
if (this.boardFrame) this.boardFrame.destroy();
if (this.refImage) this.refImage.destroy();
(this.pieces || []).forEach((p) => {
p.img.destroy();
if (this.textures.exists(p.key)) this.textures.remove(p.key);
});
this.pieces = [];
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
}
setBoardVisible(v) {
[this.boardPanel, this.boardFrame, this.refImage, this.ghost].forEach((o) => o && o.setVisible(v));
(this.pieces || []).forEach((p) => p.img.setVisible(v));
}
// ── Loop ───────────────────────────────────────────────────────────────────
update(time) {
if (this.state === 'playing') {
if (!this.startTime) this.startTime = time;
this.elapsed = Math.max(0, (time - this.startTime) / 1000);
this.updateStats();
}
}
}