From 18d89d4553198468159737bf119f5f271addcd78 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 31 Aug 2026 08:11:27 -0600 Subject: [PATCH 1/3] Redesign jigsaw win screen with zoomed board view and celebration effect - Frame the completed puzzle large on the right while parking stats/actions in a left-hand panel, easing the camera into place - Add staggered slide-in choreography for title, time, stats (with piece counter), and action buttons - Fire confetti burst + falling curtain, a shine sweep, and a pulsing gold frame when the win camera lands - Introduce exitWin() so restart/menu pull the camera back and slide the panel out before rebuilding state - Simplify pinHUD() now that HUD/win overlay render through the fixed hudCam --- src/games/jigsaw/JigsawGame.js | 318 +++++++++++++++++++++++++++++---- 1 file changed, 284 insertions(+), 34 deletions(-) diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index 29b3863..fda1a40 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -28,6 +28,12 @@ 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 +// Win view: the finished board is framed large on the RIGHT of the screen +// (near full-screen), with the completion info panel parked on the LEFT out +// of the way of the picture. The camera eases into the framing. +const WIN_ZOOM = 1.24; // board (700px) renders ≈868px tall +const WIN_PANEL = { x: 48, y: 110, w: 470, h: 860 }; + // ───────────────────────────────────────────────────────────────────────────── // Playfield: a stitched beige mat. The field renders as one piece of fabric // (border band + dashed stitching + subtle weave) and the board target sits in @@ -166,6 +172,35 @@ function makePocketTexture(size) { return cv; } +// Confetti bit: a small rounded square (the emitter tints each particle). +function makeConfettiTexture() { + const S = 18; + const cv = document.createElement('canvas'); + cv.width = S; cv.height = S; + const ctx = cv.getContext('2d'); + roundRectPath(ctx, 2, 2, S - 4, S - 4, 4); + ctx.fillStyle = '#f2ead8'; + ctx.fill(); + return cv; +} + +// Diagonal light band for the shine sweep across the finished puzzle. +function makeShineTexture() { + const W = 256, H = 256; + const cv = document.createElement('canvas'); + cv.width = W; cv.height = H; + const ctx = cv.getContext('2d'); + const g = ctx.createLinearGradient(0, 0, W, 0); + g.addColorStop(0, 'rgba(255,255,255,0)'); + g.addColorStop(0.42, 'rgba(255,246,214,0.55)'); + g.addColorStop(0.5, 'rgba(255,250,228,0.9)'); + g.addColorStop(0.58, 'rgba(255,246,214,0.55)'); + g.addColorStop(1, 'rgba(255,255,255,0)'); + ctx.fillStyle = g; + ctx.fillRect(0, 0, W, H); + return cv; +} + function offsetOutline(o, ox, oy) { return { start: { x: o.start.x + ox, y: o.start.y + oy }, @@ -213,6 +248,7 @@ export default class JigsawGame extends Phaser.Scene { this.elapsed = 0; this.hintOn = true; this.selectedDiff = 'easy'; + this.winTransition = false; this.loadArtwork(); // Open the menu on a random picture, not always the first one, so each @@ -1089,18 +1125,11 @@ export default class JigsawGame extends Phaser.Scene { } 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); - } + // NOTE: the HUD bar, the Menu ▾ dropdown and the win overlay are all + // rendered by the fixed hudCam (see create()), which already places them + // in screen space — no pinning required. Kept as a no-op guard so nothing + // accidentally re-positions them. (buttonHit() relies on the same + // screen-space assumption.) } // ── State flow ───────────────────────────────────────────────────────────── @@ -1113,15 +1142,51 @@ export default class JigsawGame extends Phaser.Scene { restart() { if (this.state === 'menu') return; - this.beginPlay(this.currentImage()); - playSound(this, SFX.EIGHTBIT_ACTIVATE); + // Pull the camera back to the start framing first, then rebuild the board. + const startCamX = BOARD.x + BOARD.size / 2 - GAME_WIDTH / 2; // matches setZoomTo(1) + const startCamY = BOARD.y + BOARD.size / 2 - GAME_HEIGHT / 2; // matches setZoomTo(1) + this.exitWin(() => { + this.beginPlay(this.currentImage()); + playSound(this, SFX.EIGHTBIT_ACTIVATE); + }, startCamX, startCamY); } toMenu() { playSound(this, SFX.EIGHTBIT_SELECT); - this.teardownPlay(); - this.setState('menu'); - this.loadPreview(this.currentImage()); + this.exitWin(() => { + this.teardownPlay(); + this.setState('menu'); + this.loadPreview(this.currentImage()); + }, 0, HUD_H); + } + + // Exit the win view: panel slides out, camera pulls back, then run `done()`. + exitWin(done, targetScrollX, targetScrollY) { + if (this.state !== 'won' || !this.winLayer) { done(); return; } + if (this.winTransition) return; + this.winTransition = true; + // Kill any in-flight showcase tweens (slide-ins, camera sweep, counter) so + // they don't fight the exit animation or fire the celebration late. + (this.winTweens || []).forEach((t) => t && t.stop()); + this.winTweens = []; + + playSound(this, SFX.VEGA_ZOOMOUT); + (this.winPanelGroups || []).forEach((g, i) => { + this.winTweens.push(this.tweens.add({ targets: g, x: g.x - 420, alpha: 0, delay: i * 45, duration: 300, ease: 'Cubic.easeIn' })); + }); + if (this.winDim && this.winPanelBg) { + this.winTweens.push(this.tweens.add({ targets: [this.winDim, this.winPanelBg], alpha: 0, duration: 330, ease: 'Cubic.easeIn' })); + } + const cam = this.cameras.main; + this.winTweens.push(this.tweens.add({ + targets: cam, scrollX: targetScrollX, scrollY: targetScrollY, zoom: 1, + duration: 460, ease: 'Cubic.easeInOut', + onComplete: () => { + this.winTransition = false; + this.teardownWin(); + done(); + }, + })); } onWin() { @@ -1131,39 +1196,224 @@ export default class JigsawGame extends Phaser.Scene { this.showWinOverlay(); } + // ── Win showcase ──────────────────────────────────────────────────────── + // The finished board eases into a near-full-screen frame on the RIGHT of the + // screen; the completion info + actions settle into a left-hand panel out of + // the way of the picture. Then it celebrates: confetti burst + falling + // curtain, a shine sweep across the picture, and a pulsing gold frame. showWinOverlay() { this.teardownWin(); - const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + this.winTweens = []; + this.winEmitters = []; + this.winPanelGroups = []; + + if (!this.textures.exists('jigsaw-confetti')) this.textures.addCanvas('jigsaw-confetti', makeConfettiTexture()); + if (!this.textures.exists('jigsaw-shine')) this.textures.addCanvas('jigsaw-shine', makeShineTexture()); + + // Win camera: board centred in the region right of the info panel. The + // board sits mid-world, so the camera must travel to its row — this lands + // the board vertically centred on screen, its top just under the HUD bar. + const bcx = BOARD.x + BOARD.size / 2; + const bcy = BOARD.y + BOARD.size / 2; + const boardCx = (WIN_PANEL.x + WIN_PANEL.w + GAME_WIDTH) / 2; + const winScrollX = bcx - boardCx / WIN_ZOOM; + const winScrollY = bcy - (GAME_HEIGHT / 2) / WIN_ZOOM; + this.winLayer = this.add.container(0, 0).setDepth(9600); if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only - const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55); - 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); + + const T = (target, x, y, s, color, o = {}) => { + const t = this.add.text(x, y, s, { + fontFamily: '"Julius Sans One"', fontSize: o.size || '20px', color, ...o, + }).setOrigin(o.align === 'left' ? 0 : 0.5); + target.add(t); return t; }; - this.winLayer.add([dim, panel]); - T(cy - 138, 'PUZZLE COMPLETE', COLORS.goldHex, '44px'); + + // Subtle dim — the picture stays the hero, the panel gets its contrast. + const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.30); + dim.setAlpha(0); + this.winDim = dim; + + const { x: PX, y: PY, w: PW } = WIN_PANEL; + const panelBg = this.add.graphics(); + panelBg.fillStyle(0x17130c, 0.97).fillRoundedRect(PX, PY, PW, WIN_PANEL.h, 20); + panelBg.lineStyle(2, COLORS.accent, 0.55).strokeRoundedRect(PX, PY, PW, WIN_PANEL.h, 20); + panelBg.fillStyle(COLORS.gold, 0.95).fillRoundedRect(PX + 42, PY + 48, 64, 4, 2); + panelBg.setAlpha(0); + this.winPanelBg = panelBg; + this.winLayer.add([dim, panelBg]); + + const LX = PX + 42; // left-aligned text edge + const BCX = PX + PW / 2, BW = PW - 84; // action-button geometry const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0'); const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0'); - 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' }); + + // Row 1 — title + image name + const gTitle = this.add.container(0, 0); + this.winLayer.add(gTitle); + T(gTitle, LX, PY + 106, 'PUZZLE COMPLETE', COLORS.goldHex, { size: '34px', letterSpacing: 4, align: 'left' }); + T(gTitle, LX, PY + 150, this.imageName || 'Puzzle', COLORS.textHex, { size: '20px', align: 'left' }); + + // Row 2 — solve time + const gTime = this.add.container(0, 0); + this.winLayer.add(gTime); + T(gTime, LX, PY + 232, 'SOLVED IN', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' }); + T(gTime, LX, PY + 280, `${mm}:${ss}`, COLORS.textHex, { size: '56px', align: 'left' }); + + // Row 3 — pieces + difficulty + const gStats = this.add.container(0, 0); + this.winLayer.add(gStats); + const rule = this.add.graphics(); + rule.lineStyle(1, 0x3a3226, 0.9); + rule.lineBetween(LX, PY + 344, LX + (PW - 84), PY + 344); + gStats.add(rule); + T(gStats, LX, PY + 384, 'PIECES', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' }); + this.piecesBig = T(gStats, LX, PY + 424, '0', COLORS.textHex, { size: '30px', align: 'left' }); + const dx = PX + PW / 2 + 20; + const diffName = (DIFFICULTIES[this.difficulty] && DIFFICULTIES[this.difficulty].label) || '—'; + T(gStats, dx, PY + 384, 'DIFFICULTY', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' }); + T(gStats, dx, PY + 424, diffName, COLORS.textHex, { size: '22px', align: 'left' }); + + // Row 4 — actions + const gBtns = this.add.container(0, 0); + this.winLayer.add(gBtns); + this.mkButton(gBtns, 'Play Again', BCX, PY + 512, BW, 62, () => this.restart(), { fontSize: 24, bg: COLORS.gold }); + this.mkButton(gBtns, 'Menu', BCX, PY + 594, BW, 62, () => this.toMenu(), { fontSize: 24 }); + this.mkButton(gBtns, '🎲 Surprise Me', BCX, PY + 676, BW, 62, () => { this.randomImage(); this.restart(); }, { fontSize: 22, variant: 'ghost' }); + T(gBtns, BCX, PY + 782, 'The picture is complete — enjoy the view.', COLORS.mutedHex, { size: '16px' }); + + // ── Choreography ───────────────────────────────────────────────────── + const stagger = (g, delay) => { + g.setAlpha(0); + const fx = g.x; + g.x = fx - 46; // slide in from the left + this.winTweens.push(this.tweens.add({ targets: g, x: fx, alpha: 1, delay, duration: 480, ease: 'Cubic.easeOut' })); + this.winPanelGroups.push(g); + }; + + this.winTweens.push(this.tweens.add({ targets: [dim, panelBg], alpha: 1, delay: 350, duration: 550, ease: 'Cubic.easeOut' })); + stagger(gTitle, 500); + stagger(gTime, 620); + stagger(gStats, 740); + stagger(gBtns, 860); + + // Piece counter counts up as the stats row lands. + const counter = { v: 0 }; + this.winTweens.push(this.tweens.add({ + targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut', + onUpdate: (tw) => { if (this.piecesBig && this.piecesBig.parent) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); }, + })); + + // Camera sweep — the finale fires when it lands. + playSound(this, SFX.VEGA_ZOOMIN); + this.winTweens.push(this.tweens.add({ + targets: this.cameras.main, + scrollX: winScrollX, scrollY: winScrollY, zoom: WIN_ZOOM, + duration: 1200, ease: 'Cubic.easeInOut', + onComplete: () => this.winCelebrate(), + })); this.winObjects = [this.winLayer]; } + // Fired when the win camera lands: confetti, shine sweep, gold frame pulse. + winCelebrate() { + if (!this.winLayer || this.state !== 'won') return; + const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2; + const TINTS = [0xd4a017, 0xf2ead8, 0xffffff, 0xc8a84b, 0xe06c75]; + + playSound(this, SFX.FIREWORK); + playSound(this, SFX.EIGHTBIT_WIN); + + // Burst from the heart of the finished puzzle. + const burst = this.add.particles(bcx, bcy, 'jigsaw-confetti', { + radial: true, + lifespan: { min: 1200, max: 2400 }, + speed: { min: 220, max: 760 }, + angle: { min: 0, max: 360 }, + gravityY: 950, + rotate: { start: -720, end: 720, random: true }, + scaleX: { min: 0.45, max: 1.25 }, + scaleY: { min: 0.45, max: 1.25 }, + alpha: { start: 1, end: 0 }, + tint: TINTS, + maxAliveParticles: 300, + }); + if (this.hudCam) burst.cameraFilter = this.hudCam.id; // world camera only + burst.setDepth(50); // above the finished pieces (depth 10-12) + burst.explode(120); + this.winEmitters.push(burst); + + // Curtain of confetti falling across the whole frame (screen-anchored). + const cam = this.cameras.main; + const band = new Phaser.Geom.Rectangle(cam.scrollX, cam.scrollY, GAME_WIDTH / cam.zoom, 120); + const curtain = this.add.particles(0, 0, 'jigsaw-confetti', { + lifespan: { min: 1700, max: 3200 }, + speedX: { min: -70, max: 70 }, + speedY: { min: 90, max: 260 }, + gravityY: 430, + rotate: { start: -540, end: 540, random: true }, + scaleX: { min: 0.4, max: 1.05 }, + scaleY: { min: 0.4, max: 1.05 }, + alpha: { start: 1, end: 0 }, + tint: TINTS, + emitZone: { type: 'random', source: band }, + maxAliveParticles: 220, + }); + if (this.hudCam) curtain.cameraFilter = this.hudCam.id; + curtain.setDepth(50); // above the finished pieces (depth 10-12) + curtain.explode(90); + this.winEmitters.push(curtain); + + // Gold trophy frame around the board, then a gentle pulse. + const frame = this.add.graphics().setDepth(4); + if (this.hudCam) frame.cameraFilter = this.hudCam.id; // world camera only + frame.lineStyle(3, COLORS.gold, 0.95); + frame.strokeRoundedRect(BOARD.x - 16, BOARD.y - 16, BOARD.size + 32, BOARD.size + 32, 18); + frame.lineStyle(1, 0xfff1cf, 0.5); + frame.strokeRoundedRect(BOARD.x - 7, BOARD.y - 7, BOARD.size + 14, BOARD.size + 14, 12); + frame.setAlpha(0); + this.winFrame = frame; + this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.95, duration: 600, ease: 'Cubic.easeOut' })); + this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.62, duration: 1500, delay: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' })); + + // Shine sweep across the completed picture. + const shine = this.add.image(bcx, bcy, 'jigsaw-shine') + .setDisplaySize(BOARD.size * 0.55, BOARD.size * 1.9) + .setRotation(-0.22) + .setAlpha(0) + .setDepth(40) + .setBlendMode(Phaser.BlendModes.ADD); + if (this.hudCam) shine.cameraFilter = this.hudCam.id; // world camera only + this.winShine = shine; + const x0 = bcx - BOARD.size * 1.05, x1 = bcx + BOARD.size * 1.05; + shine.x = x0; // start off the left edge of the board, sweep through to the right + this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.5, duration: 450, ease: 'Cubic.easeIn' })); + this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.55, duration: 350, delay: 450, ease: 'Sine.easeInOut' })); + this.winTweens.push(this.tweens.add({ + targets: shine, x: x1, alpha: 0, duration: 520, delay: 800, ease: 'Cubic.easeOut', + onComplete: () => shine.setVisible(false), + })); + } + teardownWin() { + (this.winTweens || []).forEach((t) => t && t.stop()); + this.winTweens = null; + (this.winEmitters || []).forEach((e) => e && e.destroy()); + this.winEmitters = null; + this.winPanelGroups = null; + this.piecesBig = null; + if (this.winFrame) { this.winFrame.destroy(); this.winFrame = null; } + if (this.winShine) { this.winShine.destroy(); this.winShine = null; } if (this.winObjects) { this.winObjects.forEach((o) => o.destroy()); this.winObjects = null; - this.winLayer = null; } + this.winLayer = null; + this.winDim = null; + this.winPanelBg = null; + if (this.textures.exists('jigsaw-confetti')) this.textures.remove('jigsaw-confetti'); + if (this.textures.exists('jigsaw-shine')) this.textures.remove('jigsaw-shine'); } teardownPlay() { -- 2.40.1 From 97adfa642dd3f8abe5339c36adbb4c69b1b3f22c Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 31 Aug 2026 08:26:32 -0600 Subject: [PATCH 2/3] Fix win-screen counter guard and add Playwright browser smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - piecesBig counter guard used .parent (always undefined on container children in this Phaser build — they get .parentContainer), so the 'PIECES' count-up never rendered; drop the dead guard. - buttonHit() had the same .parent assumption; use .parentContainer. - Add tools/smokeJigsaw.mjs: headless-Chromium smoke test driving menu -> play -> win showcase (camera settle, counter, confetti, frame/shine) -> Play Again -> Menu -> play again, asserting zero JS errors. Verified pass in browser. --- package-lock.json | 62 +++++++++++++ package.json | 3 + src/games/jigsaw/JigsawGame.js | 4 +- tools/smokeJigsaw.mjs | 162 +++++++++++++++++++++++++++++++++ 4 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 package-lock.json create mode 100644 tools/smokeJigsaw.mjs diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0c896dc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,62 @@ +{ + "name": "fertig-classic-games", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fertig-classic-games", + "version": "0.1.0", + "devDependencies": { + "playwright": "^1.62.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/package.json b/package.json index 222c5f9..5560fde 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,8 @@ }, "engines": { "node": ">=20" + }, + "devDependencies": { + "playwright": "^1.62.1" } } diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index fda1a40..62acd4f 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -514,7 +514,7 @@ export default class JigsawGame extends Phaser.Scene { buttonHit(cx, cy) { for (const b of this.buttons) { - if (!b.visible || !b.parent || !b.parent.visible) continue; + if (!b.visible || (b.parentContainer && !b.parentContainer.visible)) continue; const w = b.options.width, h = b.options.height; if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b; } @@ -1302,7 +1302,7 @@ export default class JigsawGame extends Phaser.Scene { const counter = { v: 0 }; this.winTweens.push(this.tweens.add({ targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut', - onUpdate: (tw) => { if (this.piecesBig && this.piecesBig.parent) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); }, + onUpdate: (tw) => { if (this.piecesBig) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); }, })); // Camera sweep — the finale fires when it lands. diff --git a/tools/smokeJigsaw.mjs b/tools/smokeJigsaw.mjs new file mode 100644 index 0000000..66fd2ec --- /dev/null +++ b/tools/smokeJigsaw.mjs @@ -0,0 +1,162 @@ +// Browser smoke test for the Jigsaw game (Playwright + headless Chromium). +// +// Usage: +// npx playwright install chromium # one-time +// python3 -m http.server 8000 # serve the repo root (or pass a base URL) +// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000 +// node tools/smokeJigsaw.mjs http://localhost:9000 +// +// What it checks: +// - game loads, menu → Start Puzzle → playing, pieces spawn on the table +// - forced win (placed = total; onWin()) runs the full showcase: +// * camera sweep lands on WIN_ZOOM framing the board +// * stats counter animates up to the total +// * celebration emitters fire (burst + curtain) and confetti is alive +// * gold frame + shine sweep exist at the right depths +// - "Play Again" restarts a fresh board and tears the win layer down +// - "Menu" returns to the menu and destroys the win layer +// - menu → Start Puzzle works again +// - zero JS console/page errors across the whole flow +// +// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few +// FPS (SwiftShader), so the scene clock advances slowly in real time. All +// waits here are condition-based polling (generous timeouts), never fixed +// sleeps. A full run takes roughly 1–3 minutes of wall time. + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import os from 'node:os'; +import path from 'node:path'; +import { chromium } from 'playwright'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html'; + +let server = null; +if (!process.argv[2]) { + try { + await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) }); + } catch { + server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true }); + await new Promise((r) => setTimeout(r, 1200)); + console.log('started local static server on :8000'); + } +} + +const errors = []; +let failed = false; +const ok = (m) => console.log(' ok ' + m); +const bad = (m) => { failed = true; console.log(' FAIL ' + m); }; + +const browser = await chromium.launch({ args: ['--no-sandbox'] }); +const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } }); +page.on('pageerror', (e) => errors.push('pageerror: ' + e.message)); +page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); + +const evalS = (fn, ...a) => page.evaluate(fn, ...a); +const waitFor = async (name, cond, timeoutMs) => { + const t0 = Date.now(); + while (Date.now() - t0 < timeoutMs) { + if (await evalS(cond)) return true; + await new Promise((r) => setTimeout(r, 500)); + } + bad(`timeout waiting for: ${name}`); + return false; +}; +const snap = async (name) => { + const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`); + await page.screenshot({ path: dest, fullPage: false }); + ok(`screenshot ${dest}`); +}; +const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]); +const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000); + +try { + console.log('— load —'); + await page.goto(BASE, { waitUntil: 'load' }); + await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 }); + ok('harness ready'); + + console.log('— start puzzle —'); + await evalS(() => window.__startPuzzle()); + if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing'); + ok('state = playing'); + const pieces = await evalS(() => window.__pieces()); + ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`); + if (pieces.length === 0) bad('no pieces spawned'); + await snap('1_playing'); + + console.log('— force win —'); + await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); }); + ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')'); + + console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —'); + if (await waitFor('camera settled + counter=total', () => { + const s = window.__scene(); + return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01 + && s.piecesBig && s.piecesBig.text === String(s.total); + }, settleTimeout)) { + const wi = await evalS(() => { + const s = window.__scene(); + return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text }; + }); + ok('settled: ' + JSON.stringify(wi)); + await snap('2_win_panel'); + } + + console.log('— wait for celebration (emitters created + particles alive) —'); + if (await waitFor('emitters alive', () => { + const s = window.__scene(); + return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0); + }, settleTimeout)) { + const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount())); + ok('particles alive: ' + JSON.stringify(alive)); + await snap('3_confetti'); + const fx = await evalS(() => { + const s = window.__scene(); + return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth }; + }); + ok('frame/shine: ' + JSON.stringify(fx)); + if (!fx.frame || !fx.shine) bad('winFrame or winShine missing'); + } + + console.log('— Play Again button —'); + await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684) + if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) { + const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length); + const winGone = await evalS(() => !window.__scene().winLayer); + ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`); + if (placed !== 0) bad('pieces not reset after Play Again'); + if (!winGone) bad('winLayer not destroyed after Play Again'); + await snap('4_restarted'); + } + + console.log('— force win again, then Menu button —'); + await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); }); + if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) { + ok('second win showcase running'); + await click(400, 720); // inside "Menu" + if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) { + const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible); + const winGone = await evalS(() => !window.__scene().winLayer); + ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`); + if (!menuVisible) bad('menu not visible'); + if (!winGone) bad('winLayer not destroyed'); + await snap('5_menu'); + } + } + + console.log('— Start Puzzle from menu works —'); + await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); }); + if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK'); +} finally { + await browser.close(); + if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } } +} + +console.log('\n— JS errors (' + errors.length + ') —'); +errors.forEach((e) => console.log(' ' + e)); +if (errors.length) failed = true; + +console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS'); +process.exit(failed ? 1 : 0); -- 2.40.1 From e319f41ce863cce7f026cd5dd34b2837d28243bd Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Mon, 31 Aug 2026 08:44:51 -0600 Subject: [PATCH 3/3] Fix jigsaw tray and win-camera to account for the fixed HUD band - Tray top now clears the HUD at full zoom-out so an entire piece (base cell + tabs + shadow headroom) stays below the bar. - Win camera centers the board in the area below the HUD rather than the whole screen, giving the framed picture room under the bar. - Add AGENTS.md noting the user handles their own commits/pushes. --- AGENTS.md | 5 +++++ src/games/jigsaw/JigsawGame.js | 16 +++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..68aaf45 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Agent notes + +## Git +- **The user makes their own commits.** Leave changes in the working tree; do not + run `git commit` (or `git push`) unless explicitly asked. diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index 62acd4f..4b33978 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -805,8 +805,13 @@ export default class JigsawGame extends Phaser.Scene { // 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: HUD_H + 40, w: WORLD_W - 80, h: WORLD_H - HUD_H - 80 }; + // but at full zoom-out the camera is clamped to scrollY = HUD_H, so the + // fixed bar still covers world y ∈ [HUD_H, HUD_H + HUD_H/MIN_ZOOM]. Push + // the tray top down so the WHOLE piece — base cell + tabs + shadow + // headroom, not just its centre — stays below that band. + const pieceHalf = this.cell + Math.max(7, this.cell * 0.09); // matches buildPieceCanvas PAD + const trayTop = HUD_H + HUD_H / MIN_ZOOM + pieceHalf; + const tray = { x: 40, y: trayTop, w: WORLD_W - 80, h: WORLD_H - trayTop - 40 }; const boardBox = { x0: BOARD.x - 6, y0: BOARD.y - 6, x1: BOARD.x + BOARD.size + 6, y1: BOARD.y + BOARD.size + 6 }; const grabR = this.cell * GRAB_FRAC; // We guarantee a comfortable minimum centre-gap so no two pieces steal each @@ -1211,13 +1216,14 @@ export default class JigsawGame extends Phaser.Scene { if (!this.textures.exists('jigsaw-shine')) this.textures.addCanvas('jigsaw-shine', makeShineTexture()); // Win camera: board centred in the region right of the info panel. The - // board sits mid-world, so the camera must travel to its row — this lands - // the board vertically centred on screen, its top just under the HUD bar. + // board sits mid-world, so the camera must travel to its row. Vertically + // it is centred in the area BELOW the fixed HUD bar (not the whole screen), + // so the framed picture has comfortable breathing room under the bar. const bcx = BOARD.x + BOARD.size / 2; const bcy = BOARD.y + BOARD.size / 2; const boardCx = (WIN_PANEL.x + WIN_PANEL.w + GAME_WIDTH) / 2; const winScrollX = bcx - boardCx / WIN_ZOOM; - const winScrollY = bcy - (GAME_HEIGHT / 2) / WIN_ZOOM; + const winScrollY = bcy - ((HUD_H + GAME_HEIGHT) / 2) / WIN_ZOOM; this.winLayer = this.add.container(0, 0).setDepth(9600); if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only -- 2.40.1