Rework jigsaw UI with stitched mat playfield and two-column menu #6
|
|
@ -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];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue