jigsaw-ui-improvements #7

Merged
brianfertig merged 3 commits from jigsaw-ui-improvements into main 2026-08-31 14:47:48 +00:00
4 changed files with 229 additions and 2 deletions
Showing only changes of commit 97adfa642d - Show all commits

62
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

View File

@ -9,5 +9,8 @@
},
"engines": {
"node": ">=20"
},
"devDependencies": {
"playwright": "^1.62.1"
}
}

View File

@ -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.

162
tools/smokeJigsaw.mjs Normal file
View File

@ -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 13 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);