Merge pull request 'Rework jigsaw UI with stitched mat playfield and two-column menu' (#6) from jigsaw-ui-improvements into main

Reviewed-on: #6
This commit is contained in:
brianfertig 2026-08-31 05:30:00 +00:00
commit 0c59a292a3
1 changed files with 236 additions and 62 deletions

View File

@ -28,6 +28,144 @@ const SNAP_FRAC = 0.42; // snap radius (×cell) — used for both locking a
// into the board AND joining two pieces on the table // 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 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) { function offsetOutline(o, ox, oy) {
return { return {
start: { x: o.start.x + ox, y: o.start.y + oy }, start: { x: o.start.x + ox, y: o.start.y + oy },
@ -166,8 +304,8 @@ export default class JigsawGame extends Phaser.Scene {
loadImage(key).then((srcImg) => { loadImage(key).then((srcImg) => {
if (token !== this._loadToken) return; // superseded by a newer pick if (token !== this._loadToken) return; // superseded by a newer pick
if (!this.menu || !this.menu.visible) return; if (!this.menu || !this.menu.visible) return;
const box = 300, cx = this.thumbBoxX, cy = this.thumbBoxY; const box = this.thumbBox || 280, cx = this.thumbBoxX, cy = this.thumbBoxY;
const S = 220; const S = box;
const cv = document.createElement('canvas'); const cv = document.createElement('canvas');
cv.width = cv.height = S; cv.width = cv.height = S;
const ctx = cv.getContext('2d'); const ctx = cv.getContext('2d');
@ -195,8 +333,9 @@ export default class JigsawGame extends Phaser.Scene {
addThumbBorder() { addThumbBorder() {
if (this.thumbBorder) this.thumbBorder.destroy(); if (this.thumbBorder) this.thumbBorder.destroy();
const g = this.add.graphics(); const g = this.add.graphics();
const h = (this.thumbBox || 280) / 2 - 10;
g.lineStyle(3, COLORS.accent, 0.9); g.lineStyle(3, COLORS.accent, 0.9);
g.strokeRoundedRect(-150, -150, 300, 300, 16); g.strokeRoundedRect(-h, -h, h * 2, h * 2, 14);
this.thumbBorder = g; this.thumbBorder = g;
const parent = this.menu || this; const parent = this.menu || this;
if (parent.add) parent.add(g); if (parent.add) parent.add(g);
@ -205,16 +344,18 @@ export default class JigsawGame extends Phaser.Scene {
// ── Background / table ───────────────────────────────────────────────────── // ── Background / table ─────────────────────────────────────────────────────
buildBackground() { buildBackground() {
const bg = this.add.graphics().setDepth(0); // The playfield is one stitched beige mat (band + stitching + weave).
// felt-like table covering the whole (bigger) field if (!this.textures.exists('jigsaw-mat')) {
bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, WORLD_W, WORLD_H); this.textures.addCanvas('jigsaw-mat', makeMatTexture());
// subtle framed panel to read as a table }
bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, WORLD_W - 56, WORLD_H - 56, 26); this.bg = this.add.image(WORLD_W / 2, (WORLD_H + MAT_OVERSCAN) / 2, 'jigsaw-mat').setDepth(0);
bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, WORLD_W - 68, WORLD_H - 68, 22); // Invisible hit zone for the table (pan on empty space). Kept separate
bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, WORLD_W - 80, WORLD_H - 80, 18); // from the mat image so the background can be swapped without dropping
this.bg = bg; // the input. Zones are center-origin, so position at the world midpoint.
bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, WORLD_W, WORLD_H), Phaser.Geom.Rectangle.Contains); const hit = this.add.zone(WORLD_W / 2, WORLD_H / 2, WORLD_W, WORLD_H).setDepth(0);
bg.on('pointerdown', (pointer) => this.onTableDown(pointer)); hit.setInteractive();
hit.on('pointerdown', (pointer) => this.onTableDown(pointer));
this.bgHit = hit;
} }
// ── HUD (pinned to the top of the screen regardless of pan/zoom) ────────── // ── HUD (pinned to the top of the screen regardless of pan/zoom) ──────────
@ -230,7 +371,7 @@ export default class JigsawGame extends Phaser.Scene {
}).setOrigin(0.5, 0.5); }).setOrigin(0.5, 0.5);
this.hud.add(this.title); this.hud.add(this.title);
this.diffBadge = this.add.text(195, HUD_H / 2, 'Easy · 25', { this.diffBadge = this.add.text(250, HUD_H / 2, 'Easy · 25', {
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex, fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex,
}).setOrigin(0.5, 0.5); }).setOrigin(0.5, 0.5);
this.hud.add(this.diffBadge); this.hud.add(this.diffBadge);
@ -263,10 +404,10 @@ export default class JigsawGame extends Phaser.Scene {
const h = PAD + 5 * BH + 4 * GAP + SEP + BH + PAD; // 5 rows + separator + Main menu const h = PAD + 5 * BH + 4 * GAP + SEP + BH + PAD; // 5 rows + separator + Main menu
const panel = this.add.container(0, 0).setDepth(9050); const panel = this.add.container(0, 0).setDepth(9050);
panel.setVisible(false); panel.setVisible(false);
const bg = this.add.rectangle(cx, top + h / 2, W, h, 0x17130c, 0.98); const bg = this.add.graphics();
const frame = this.add.graphics(); bg.fillStyle(0x17130c, 0.98).fillRoundedRect(X0, top, W, h, 12);
frame.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12); bg.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12);
panel.add([bg, frame]); panel.add(bg);
const rowY = (i) => top + PAD + BH / 2 + i * (BH + GAP); 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 }); const row = (i, label, onClick) => this.mkButton(panel, label, cx, rowY(i), W - 24, BH, onClick, { fontSize: 20 });
@ -348,45 +489,59 @@ export default class JigsawGame extends Phaser.Scene {
buildMenu() { buildMenu() {
this.menu = this.add.container(0, 0).setDepth(8000); this.menu = this.add.container(0, 0).setDepth(8000);
const W = 1160, H = 820, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2 + 12; // Two comfortably-spaced columns — picture picker left, difficulty +
const panel = this.add.rectangle(cx, cy, W, H, 0x17130c, 0.96); // start right — with a consistent vertical rhythm (26px label→control,
const frame = this.add.graphics(); // ≥50px between sections) instead of one tall, cramped stack.
frame.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 20); const W = 1180, H = 660;
this.menu.add([panel, frame]); 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 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); 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; 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, top + 72, 'JIGSAW', COLORS.textHex, { size: '48px', letterSpacing: 8 });
addT(cx, cy - 250, 'Difficulty', COLORS.goldHex, { size: '24px', letterSpacing: 3 }); addT(cx, top + 122, 'Assemble the picture.', COLORS.mutedHex, { size: '20px' });
const diffLabels = DIFFICULTY_ORDER.map((k) => DIFFICULTIES[k]);
const diffY = cy - 205; 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 = []; this.diffButtons = [];
const bw = 210, gap = 22, totalW = diffLabels.length * bw + (diffLabels.length - 1) * gap; const bx = [bxL, bxR], by = [row1, row2];
let bx = cx - totalW / 2 + bw / 2;
DIFFICULTY_ORDER.forEach((k, i) => { DIFFICULTY_ORDER.forEach((k, i) => {
const cfg = DIFFICULTIES[k]; 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 }); 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 }); this.diffButtons.push({ key: k, btn: b });
bx += bw + gap;
}); });
// Image row const startY = row2 + bh / 2 + 52 + 37;
addT(cx, cy - 110, 'Image', COLORS.goldHex, { size: '24px', letterSpacing: 3 }); const start = this.mkButton(this.menu, 'Start Puzzle ▸', cxR, startY, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
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; 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 // Load the first preview
this.selectDifficulty('easy'); this.selectDifficulty('easy');
@ -453,12 +608,17 @@ export default class JigsawGame extends Phaser.Scene {
this.diffLabel = `${cfg.label} · ${this.total} pieces`; this.diffLabel = `${cfg.label} · ${this.total} pieces`;
this.imageName = item.name || 'Image'; this.imageName = item.name || 'Image';
// Board panel + reference picture // 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; 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); 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); 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, 0x5a4c32, 0.25);
boardFrame.lineStyle(2, 0xffffff, 0.15);
boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size); boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size);
this.boardPanel = panel; this.boardPanel = panel;
this.boardFrame = boardFrame; this.boardFrame = boardFrame;
@ -482,7 +642,7 @@ export default class JigsawGame extends Phaser.Scene {
} }
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref'); if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
this.textures.addCanvas('jigsaw-ref', rcv); 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); 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 if (this.hudCam) this.refImage.cameraFilter = this.hudCam.id; // main camera only
// Faint full-picture reference: keep on Easy/Medium, hide on Hard/Legendary. // Faint full-picture reference: keep on Easy/Medium, hide on Hard/Legendary.
this.refImage.setVisible(this.difficulty !== 'hard' && this.difficulty !== 'legendary'); this.refImage.setVisible(this.difficulty !== 'hard' && this.difficulty !== 'legendary');
@ -552,7 +712,8 @@ export default class JigsawGame extends Phaser.Scene {
buildPieceCanvas(r, c, src, boardW, boardH) { buildPieceCanvas(r, c, src, boardW, boardH) {
const cell = this.cell; const cell = this.cell;
const S = Math.ceil(cell * 2) + 2; 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'); const cv = document.createElement('canvas');
cv.width = cv.height = S; cv.width = cv.height = S;
const ctx = cv.getContext('2d'); const ctx = cv.getContext('2d');
@ -560,6 +721,18 @@ export default class JigsawGame extends Phaser.Scene {
const oy = S / 2 - (r + 0.5) * cell; const oy = S / 2 - (r + 0.5) * cell;
const outline = offsetOutline(cellOutline(this.jig, r, c, cell, cell), ox, oy); 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(); ctx.save();
tracePath(ctx, outline); tracePath(ctx, outline);
ctx.clip(); ctx.clip();
@ -721,6 +894,7 @@ export default class JigsawGame extends Phaser.Scene {
onTableDown(pointer) { onTableDown(pointer) {
if (this.state !== 'playing') return; 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); const c = this.canvasPos(pointer);
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons (incl. dropdown rows) work 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.music && c.x > 1830 && c.y < 80) return; // site music HUD (top-right) — don't pan over it
@ -932,7 +1106,7 @@ export default class JigsawGame extends Phaser.Scene {
// ── State flow ───────────────────────────────────────────────────────────── // ── State flow ─────────────────────────────────────────────────────────────
toggleHint() { toggleHint() {
this.hintOn = !this.hintOn; this.hintOn = !this.hintOn;
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.16 : 0); if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.2 : 0);
this.btnHint.setActive(this.hintOn); this.btnHint.setActive(this.hintOn);
playSound(this, SFX.EIGHTBIT_SELECT); playSound(this, SFX.EIGHTBIT_SELECT);
} }
@ -963,24 +1137,24 @@ export default class JigsawGame extends Phaser.Scene {
this.winLayer = this.add.container(0, 0).setDepth(9600); this.winLayer = this.add.container(0, 0).setDepth(9600);
if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only 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 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 panel = this.add.graphics();
const frame = this.add.graphics(); panel.fillStyle(0x17130c, 0.98).fillRoundedRect(cx - 310, cy - 210, 620, 420, 20);
frame.lineStyle(3, COLORS.gold, 0.9).strokeRoundedRect(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 = (y, s, color, size) => {
const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5); const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5);
this.winLayer.add(t); this.winLayer.add(t);
return t; return t;
}; };
this.winLayer.add([dim, panel, frame]); this.winLayer.add([dim, panel]);
T(cy - 140, 'PUZZLE COMPLETE', COLORS.goldHex, '44px'); T(cy - 138, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0'); const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
const ss = 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 - 70, `Solved in ${mm}:${ss}`, COLORS.textHex, '28px');
T(cy - 28, `Solved in ${mm}:${ss}`, COLORS.textHex, '24px'); T(cy - 24, `${this.diffLabel || ''} · ${this.imageName || ''}`, COLORS.mutedHex, '20px');
T(cy + 8, 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 });
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 + 100, 210, 64, () => this.toMenu(), { fontSize: 26 });
this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 110, 210, 64, () => this.toMenu(), { fontSize: 26 }); panel.setScale(0.85);
[panel, frame].forEach((o) => { o.setScale(0.85); this.tweens.add({ targets: o, scale: 1, duration: 280, ease: 'Back.easeOut' }); }); this.tweens.add({ targets: panel, scale: 1, duration: 280, ease: 'Back.easeOut' });
this.winObjects = [this.winLayer]; this.winObjects = [this.winLayer];
} }