diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index 7fccb81..29b3863 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -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 const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs +// ───────────────────────────────────────────────────────────────────────────── +// Playfield: a stitched beige mat. The field renders as one piece of fabric +// (border band + dashed stitching + subtle weave) and the board target sits in +// a slightly deeper stitched "pocket", like a puzzle mat laid out for a jigsaw. +// ───────────────────────────────────────────────────────────────────────────── +const MAT = { + base: '#d7ceb9', // main surface — warm, neutral beige + band: '#b4a487', // border band around the field edge + bandEdge: '#998867', // outermost rim of the mat + stitch: '#8a7857', // stitching thread + pocket: '#c7bca6', // board pocket (a shade deeper than the surface) +}; +const BAND = 26; // border band width +const MAT_OVERSCAN = 140; // mat drawn past the world's bottom edge (covers the + // camera dead-zone below the world at minimum zoom; + // see clampCamera: the world top is reserved for HUD) +const POCKET_MARGIN = 28; // pocket extends this far past the board edge + +function roundRectPath(ctx, x, y, w, h, r) { + r = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +// Dashed "stitching" around a rounded rect. Drawn twice — a soft shadow pass +// under the thread — so it reads as sewn into the fabric. +function stitchRect(ctx, x, y, w, h, r, inset, dash, width) { + const sx = x + inset, sy = y + inset, sw = w - 2 * inset, sh = h - 2 * inset; + const rr = Math.max(3, r - inset); + ctx.save(); + ctx.lineJoin = 'round'; + ctx.lineWidth = width; + ctx.setLineDash(dash); + ctx.strokeStyle = 'rgba(66,54,34,0.35)'; + roundRectPath(ctx, sx, sy + 1.4, sw, sh, rr); ctx.stroke(); + ctx.strokeStyle = MAT.stitch; + roundRectPath(ctx, sx, sy, sw, sh, rr); ctx.stroke(); + ctx.restore(); +} + +// Subtle fabric-weave tile: fine thread lines + a sprinkle of speckle. +function makeWeaveTile(seed) { + const S = 128; + const cv = document.createElement('canvas'); + cv.width = cv.height = S; + const ctx = cv.getContext('2d'); + const g = mulberry32(seed); + ctx.fillStyle = 'rgba(94,80,56,0.03)'; + for (let y = 0; y < S; y += 4) ctx.fillRect(0, y, S, 1); + ctx.fillStyle = 'rgba(255,255,250,0.03)'; + for (let x = 0; x < S; x += 4) ctx.fillRect(x, 0, 1, S); + for (let i = 0; i < 700; i++) { + ctx.fillStyle = g() > 0.5 ? 'rgba(94,80,56,0.06)' : 'rgba(255,255,250,0.07)'; + ctx.fillRect(g() * S, g() * S, 1 + g() * 1.5, 1 + g() * 1.5); + } + return cv; +} + +// Full-field mat: border band, stitching, weave and a soft vignette. +function makeMatTexture() { + const W = WORLD_W, H = WORLD_H + MAT_OVERSCAN; + const cv = document.createElement('canvas'); + cv.width = W; cv.height = H; + const ctx = cv.getContext('2d'); + ctx.fillStyle = MAT.band; + ctx.fillRect(0, 0, W, H); + ctx.save(); + roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22); + ctx.fillStyle = MAT.base; + ctx.fill(); + ctx.clip(); + ctx.fillStyle = ctx.createPattern(makeWeaveTile(1234), 'repeat'); + ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND); + // Soft vignette centred on the visible field (adds gentle depth). + const vg = ctx.createRadialGradient(W / 2, WORLD_H / 2, Math.min(W, WORLD_H) * 0.30, W / 2, WORLD_H / 2, Math.max(W, WORLD_H) * 0.72); + vg.addColorStop(0, 'rgba(118,102,72,0)'); + vg.addColorStop(1, 'rgba(118,102,72,0.14)'); + ctx.fillStyle = vg; + ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND); + // Seam where the surface meets the band. + ctx.strokeStyle = 'rgba(96,82,56,0.28)'; + ctx.lineWidth = 2; + roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22); + ctx.stroke(); + ctx.restore(); + // Outer rim of the mat. + ctx.strokeStyle = MAT.bandEdge; + ctx.lineWidth = 5; + roundRectPath(ctx, 2.5, 2.5, W - 5, H - 5, 26); + ctx.stroke(); + // Stitching, centred in the border band. + stitchRect(ctx, 0, 0, W, H, 26, BAND / 2, [18, 12], 3.5); + return cv; +} + +// Board pocket: a slightly deeper stitched panel that the reference picture +// (and the locked pieces) sit inside. +function makePocketTexture(size) { + const M = POCKET_MARGIN; + const S = size + 2 * M; + const cv = document.createElement('canvas'); + cv.width = cv.height = S; + const ctx = cv.getContext('2d'); + ctx.save(); + roundRectPath(ctx, M, M, size, size, 16); + ctx.shadowColor = 'rgba(64,52,32,0.35)'; + ctx.shadowBlur = 18; + ctx.shadowOffsetY = 6; + ctx.fillStyle = MAT.pocket; + ctx.fill(); + ctx.restore(); + ctx.save(); + roundRectPath(ctx, M, M, size, size, 16); + ctx.clip(); + ctx.fillStyle = ctx.createPattern(makeWeaveTile(4242), 'repeat'); + ctx.fillRect(0, 0, S, S); + // Recessed feel: darker toward the top, a faint lift at the bottom lip. + const g = ctx.createLinearGradient(0, M, 0, M + size); + g.addColorStop(0, 'rgba(84,70,44,0.14)'); + g.addColorStop(0.14, 'rgba(84,70,44,0.03)'); + g.addColorStop(0.86, 'rgba(255,255,246,0.04)'); + g.addColorStop(1, 'rgba(255,255,246,0.09)'); + ctx.fillStyle = g; + ctx.fillRect(M, M, size, size); + ctx.restore(); + ctx.strokeStyle = 'rgba(96,82,56,0.4)'; + ctx.lineWidth = 2; + roundRectPath(ctx, M + 1, M + 1, size - 2, size - 2, 16); + ctx.stroke(); + stitchRect(ctx, M, M, size, size, 16, 11, [14, 10], 3); + return cv; +} + function offsetOutline(o, ox, oy) { return { start: { x: o.start.x + ox, y: o.start.y + oy }, @@ -166,8 +304,8 @@ export default class JigsawGame extends Phaser.Scene { 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 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'); @@ -195,8 +333,9 @@ export default class JigsawGame extends Phaser.Scene { 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(-150, -150, 300, 300, 16); + g.strokeRoundedRect(-h, -h, h * 2, h * 2, 14); this.thumbBorder = g; const parent = this.menu || this; if (parent.add) parent.add(g); @@ -205,16 +344,18 @@ export default class JigsawGame extends Phaser.Scene { // ── 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)); + // 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) ────────── @@ -230,7 +371,7 @@ export default class JigsawGame extends Phaser.Scene { }).setOrigin(0.5, 0.5); 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, }).setOrigin(0.5, 0.5); 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 panel = this.add.container(0, 0).setDepth(9050); panel.setVisible(false); - const bg = this.add.rectangle(cx, top + h / 2, W, h, 0x17130c, 0.98); - const frame = this.add.graphics(); - frame.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12); - panel.add([bg, frame]); + 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 }); @@ -348,45 +489,59 @@ export default class JigsawGame extends Phaser.Scene { 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]); + // 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, 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; + 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 bw = 210, gap = 22, totalW = diffLabels.length * bw + (diffLabels.length - 1) * gap; - let bx = cx - totalW / 2 + bw / 2; + 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, 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 }); - 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 }); + 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'); @@ -453,12 +608,17 @@ export default class JigsawGame extends Phaser.Scene { this.diffLabel = `${cfg.label} · ${this.total} pieces`; 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 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); - 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.lineStyle(2, 0x5a4c32, 0.25); boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size); this.boardPanel = panel; 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'); 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 // Faint full-picture reference: keep on Easy/Medium, hide on Hard/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) { 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'); cv.width = cv.height = S; 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 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(); @@ -721,6 +894,7 @@ export default class JigsawGame extends Phaser.Scene { 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 @@ -932,7 +1106,7 @@ export default class JigsawGame extends Phaser.Scene { // ── State flow ───────────────────────────────────────────────────────────── toggleHint() { 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); 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); 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 panel = this.add.graphics(); + panel.fillStyle(0x17130c, 0.98).fillRoundedRect(cx - 310, cy - 210, 620, 420, 20); + panel.lineStyle(3, COLORS.gold, 0.9).strokeRoundedRect(cx - 310, cy - 210, 620, 420, 20); const T = (y, s, color, size) => { const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5); this.winLayer.add(t); return t; }; - this.winLayer.add([dim, panel, frame]); - T(cy - 140, 'PUZZLE COMPLETE', COLORS.goldHex, '44px'); + this.winLayer.add([dim, panel]); + T(cy - 138, 'PUZZLE COMPLETE', COLORS.goldHex, '44px'); const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0'); const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0'); - T(cy - 70, 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' }); }); + T(cy - 70, `Solved in ${mm}:${ss}`, COLORS.textHex, '28px'); + T(cy - 24, `${this.diffLabel || ''} · ${this.imageName || ''}`, COLORS.mutedHex, '20px'); + const again = this.mkButton(this.winLayer, 'Play Again', cx - 120, cy + 100, 210, 64, () => this.restart(), { fontSize: 26, bg: COLORS.gold }); + this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 100, 210, 64, () => this.toMenu(), { fontSize: 26 }); + panel.setScale(0.85); + this.tweens.add({ targets: panel, scale: 1, duration: 280, ease: 'Back.easeOut' }); this.winObjects = [this.winLayer]; }