Compare commits

...

6 Commits

Author SHA1 Message Date
Brian Fertig a7ebb67413 Extend jigsaw initial-image test coverage
Test now also asserts the menu is back to its original shape (no random-start button), the initial preview and thumb name match the random pick, Start Puzzle begins exactly that image, and the original 🎲 Random button still randomises the menu preview.
2026-08-30 21:19:11 -06:00
Brian Fertig 25f5e05a96 Start the jigsaw puzzle picker on a random image
The menu always opened on the first artwork; now imageIndex is randomised right after loadArtwork(), so each visit to the picker shows a different initial puzzle. The existing Random/prev/next buttons are unchanged (previous random-start button reverted in 4f2d299). Test page + CDP driver verify 8 fresh page loads all land on a valid in-range image with its preview loaded, with variety across loads.
2026-08-30 21:14:37 -06:00
Brian Fertig 4f2d29942d Revert "Add one-click random start to the jigsaw menu"
This reverts commit 0740c12ffa.
2026-08-30 21:12:44 -06:00
Brian Fertig 0740c12ffa Add one-click random start to the jigsaw menu
New '🎲 Start Random' button picks a random picture (never the current one when there's a choice) and starts the puzzle immediately, keeping the selected difficulty. Menu panel grows 820→890 to fit it. Headless-chromium test page + CDP driver verify: button placement, 8/8 random starts reach playing state, image variety, single-image guard, and difficulty preservation.
2026-08-30 21:11:15 -06:00
Brian Fertig 19dff633ce Add jigsaw test harness and local phaser.esm.js bundle
- __jig_test.html boots a Phaser game that loads JigsawGame with stubbed artwork/music caches and exposes window.__* helpers for driving piece grab/drop, scene inspection, and coordinate conversion.
- phaser.esm.js is a vendored webpack build of Phaser used as the "phaser" import map target so the harness can run without a package manager.
2026-08-30 21:03:23 -06:00
Brian Fertig 8dee798ae2 Fix jigsaw piece grab when click lands inside custom hit area
- Add pointerdown handler on each piece to forward the event to the central onTableDown resolver; without it, Phaser's topOnly=true consumes the click and the background never sees it, leaving pieces un-grabbable.
- Anchor the hit-area rectangle to the image's displayOrigin instead of (0,0) so the custom hit box tracks the piece's actual rendered position.
2026-08-30 21:02:22 -06:00
5 changed files with 248540 additions and 4 deletions

83
__jig_init_test.html Normal file
View File

@ -0,0 +1,83 @@
<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
</head><body>
<div id="log"></div>
<script type="module">
import * as Phaser from 'phaser';
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
const log = (m) => { document.getElementById('log').textContent += m + '\n'; console.log('[t]', m); };
let failures = 0;
const check = (ok, msg) => { if (!ok) { failures++; log('FAIL: ' + msg); } else log('ok: ' + msg); };
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const ART = [
{ name: 'Alien World', path: 'assets/images/shift/alien-world.png' },
{ name: 'Aquaroom', path: 'assets/images/shift/aquaroom.png' },
{ name: 'Aztec Warrior', path: 'assets/images/shift/aztec-warrior.png' },
{ name: 'Cat On Tiger', path: 'assets/images/shift/cat-on-tiger.png' },
{ name: 'Cockpit', path: 'assets/images/shift/cockpit.png' },
];
const config = {
type: Phaser.AUTO,
width: 1920, height: 1080,
parent: document.body,
backgroundColor: '#000',
scene: [ { key: 'boot', create() {
this.cache.json.add('shift-artwork', { artwork: ART });
this.cache.json.add('music', { tracks: [] });
this.scene.start('jigsaw-game');
} }, JigsawGame ],
};
const game = new Phaser.Game(config);
const s = () => game.scene.getScene('jigsaw-game');
(async () => {
for (let i = 0; i < 400 && !s(); i++) await wait(50); // boot jigsaw start is async
if (!s()) throw new Error('jigsaw scene never started');
for (let i = 0; i < 400 && !s().menu; i++) await wait(50);
check(!!s().menu, 'menu built');
// Menu is back to the original shape (random-start button reverted).
check(s().randomStartButton === undefined, 'randomStartButton is gone (menu as before)');
// The initial image is random but always a valid artwork entry, and the
// preview shown in the menu matches it.
const n = s().artwork.length;
const i = s().imageIndex;
check(Number.isInteger(i) && i >= 0 && i < n, `initial imageIndex ${i} is a valid index (0..${n - 1})`);
check(!!ART.find((a) => a.name === s().currentImage().name), 'initial currentImage() is a known artwork entry');
// Preview must be built for the initial image (async image load).
for (let k = 0; k < 200 && !s().previewImg; k++) await wait(25);
check(!!s().previewImg, 'initial preview image is displayed');
check(s().thumbName && s().thumbName.text === s().currentImage().name, `thumb name matches initial image (${s().currentImage().name})`);
// Start Puzzle must still start the initial (random) image.
s().selectedDiff = 'easy';
s().startPuzzle();
let playing = false;
for (let k = 0; k < 400 && !playing; k++) { playing = s().state === 'playing'; await wait(25); }
check(playing, 'Start Puzzle reaches playing state');
check(s().pieces.length === 25, '25 pieces built');
check(s().imageName === ART[i].name, `playing image matches the initial pick (${ART[i].name})`);
// The original 🎲 Random button still randomises the preview in the menu.
s().toMenu();
const before = s().imageIndex;
const seen = new Set([before]);
for (let k = 0; k < 10; k++) { s().randomImage(); seen.add(s().imageIndex); await wait(10); }
check(seen.size >= 2, '🎲 Random button still changes the selected image');
document.__initIndex = i;
log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
document.title = failures === 0 ? 'PASS' : 'FAIL:' + failures;
})().catch((e) => {
log('ERROR: ' + ((e && e.stack) || e));
document.title = 'FAIL:error';
});
</script>
</body></html>

62
__jig_test.html Normal file
View File

@ -0,0 +1,62 @@
<!doctype html>
<html><head><meta charset="utf-8">
<script type="importmap">{"imports":{"phaser":"/phaser.esm.js"}}</script>
<style>html,body{margin:0;background:#111}canvas{display:block}</style>
</head><body>
<div id="log"></div>
<script type="module">
import * as Phaser from 'phaser';
import JigsawGame from './src/games/jigsaw/JigsawGame.js';
const log = (m) => { const el = document.getElementById('log'); el.textContent += m + '\n'; console.log('[harness]', m); };
const config = {
type: Phaser.WEBGL,
width: 1920, height: 1080,
parent: document.body,
backgroundColor: '#000',
scene: [ { key:'boot', create(){
// Provide the artwork cache so loadArtwork() has a real list.
this.cache.json.add('shift-artwork', { artwork: [ { name:'Alien World', path:'assets/images/shift/alien-world.png' } ] });
this.cache.json.add('music', { tracks: [] });
this.scene.start('jigsaw-game');
} }, JigsawGame ],
};
const game = new Phaser.Game(config);
window.__game = game;
// Helpers exposed for the driver.
window.__log = log;
window.__scene = () => game.scene.getScene('jigsaw-game');
window.__worldToScreen = (wx, wy) => {
const cam = window.__scene().cameras.main;
return { x: (wx - cam.scrollX) * cam.zoom + cam.x, y: (wy - cam.scrollY) * cam.zoom + cam.y };
};
window.__pieceAt = (i) => { const s = window.__scene(); const p = s.pieces[i]; return { i, x:p.img.x, y:p.img.y, placed:p.placed, groupLeader: p.group===p, depth:p.img.depth, hasInput: !!(p.img.input&&p.img.input.enabled) }; };
window.__pieces = () => window.__scene().pieces.map((p,i)=>({i, x:Math.round(p.img.x), y:Math.round(p.img.y), placed:p.placed, depth:p.img.depth}));
window.__startPuzzle = () => { const s = window.__scene(); s.selectedDiff='easy'; s.startPuzzle(); };
window.__grabAndDrop = async (pieceIdx, toWorld, steps=12, holdMs=40) => {
const s = window.__scene();
const p = s.pieces[pieceIdx];
const from = { x: p.img.x, y: p.img.y };
// mousedown at from
const f2s = window.__worldToScreen(from.x, from.y);
await __mouseDown(f2s.x, f2s.y);
await new Promise(r=>setTimeout(r,holdMs));
for (let k=1;k<=steps;k++){
const x = from.x + (toWorld.x-from.x)*k/steps, y = from.y + (toWorld.y-from.y)*k/steps;
const c = window.__worldToScreen(x,y);
await __mouseMove(c.x,c.y);
await new Promise(r=>setTimeout(r,8));
}
await __mouseUp();
return { from, to:{x:p.img.x,y:p.img.y}, moved: Math.hypot(p.img.x-from.x,p.img.y-from.y) };
};
// Low-level mouse dispatch in canvas (client) coordinates.
window.__mouseDown = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousedown',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
window.__mouseMove = (cx,cy) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mousemove',{clientX:cx,clientY:cy,bubbles:true})); };
window.__mouseUp = (cx=0,cy=0) => { const el=game.canvas; el.dispatchEvent(new MouseEvent('mouseup',{clientX:cx,clientY:cy,bubbles:true,button:0})); };
log('harness ready');
</script>
</body></html>

248303
phaser.esm.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -77,6 +77,10 @@ export default class JigsawGame extends Phaser.Scene {
this.selectedDiff = 'easy'; this.selectedDiff = 'easy';
this.loadArtwork(); this.loadArtwork();
// Open the menu on a random picture, not always the first one, so each
// visit to the puzzle picker starts on a different puzzle. (loadArtwork
// guarantees at least one entry, so this index is always valid.)
this.imageIndex = Math.floor(Math.random() * this.artwork.length);
this.buildBackground(); this.buildBackground();
this.buildHUD(); this.buildHUD();
this.buildMenu(); this.buildMenu();
@ -503,11 +507,14 @@ export default class JigsawGame extends Phaser.Scene {
// piece's hit area. (With the old ±cell box, ~35 neighbour pairs overlapped // piece's hit area. (With the old ±cell box, ~35 neighbour pairs overlapped
// and the click resolved to whichever had the highest index — the bottom- // and the click resolved to whichever had the highest index — the bottom-
// right piece — far away from the cursor.) // right piece — far away from the cursor.)
// Pieces stay interactive only for the hover cursor. The actual grab is // Pieces stay interactive for the hover cursor AND to consume the
// resolved centrally in onTableDown (nearest piece to the pointer) so a // pointerdown (forwarding it to the central grab resolver). Without the
// click can never resolve to a distant neighbour's overlapping hit box. // pointerdown handler here, a click that lands inside the piece's custom
// hit area is consumed by the piece (topOnly=true) and never reaches the
// background's onTableDown — leaving the piece un-grabbable.
const grab = this.cell * GRAB_FRAC; const grab = this.cell * GRAB_FRAC;
img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(-grab, -grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains }); img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(img.displayOriginX - grab, img.displayOriginY - grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
img.on('pointerdown', (pointer) => this.onTableDown(pointer));
this.pieces.push({ r, c, img, home, placed: false, key, depth: 10 }); this.pieces.push({ r, c, img, home, placed: false, key, depth: 10 });
} }
} }

View File

@ -0,0 +1,81 @@
// Headless-chromium CDP driver for the jigsaw initial-image test page.
// Loads the page repeatedly; each load must end PASS, and the initial image
// index (document.__initIndex) must vary across fresh loads.
// Usage: node tools/__jig_init_driver.mjs <url> [loads=8]
import { spawn } from 'node:child_process';
import { setTimeout as sleep } from 'node:timers/promises';
const url = process.argv[2];
const loads = Math.max(2, parseInt(process.argv[3] || '8', 10));
if (!url) { console.error('usage: driver <url> [loads]'); process.exit(2); }
const BIN = process.env.HOME + '/.cache/ms-playwright/chromium_headless_shell-1234/chrome-headless-shell-linux64/chrome-headless-shell';
const PORT = 9333;
const chrome = spawn(BIN, [
'--headless', '--no-sandbox', '--disable-gpu',
`--remote-debugging-port=${PORT}`,
'about:blank',
], { stdio: ['ignore', 'pipe', 'pipe'] });
chrome.stderr.on('data', (d) => { const s = d.toString(); if (!/Fontconfig|dbus|DBus|ozone/i.test(s)) process.stderr.write(s); });
async function httpJson(path) {
const r = await fetch(`http://127.0.0.1:${PORT}${path}`);
return r.json();
}
let ws, idc = 0;
const pending = new Map();
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++idc;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
try {
let targets = null;
for (let i = 0; i < 60; i++) {
try { targets = await httpJson('/json/list'); break; } catch { await sleep(250); }
}
if (!targets) throw new Error('chrome devtools endpoint never came up');
const page = targets.find((t) => t.type === 'page');
if (!page) throw new Error('no page target');
ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; });
ws.onmessage = (m) => {
const msg = JSON.parse(m.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(new Error(msg.error.message)); else resolve(msg.result);
}
};
await send('Runtime.enable');
await send('Page.enable');
const ev = (expression) => send('Runtime.evaluate', { expression, returnByValue: true }).then((r) => r.result.value);
const indexes = [];
for (let k = 0; k < loads; k++) {
await send('Page.navigate', { url });
const t0 = Date.now();
let title = '';
while (Date.now() - t0 < 60000) {
title = (await ev('document.title')) || '';
if (title === 'PASS' || title.startsWith('FAIL')) break;
await sleep(500);
}
if (title !== 'PASS') throw new Error(`load #${k}: ${title || 'TIMEOUT'}`);
indexes.push(await ev('document.__initIndex'));
}
const valid = indexes.every((i) => Number.isInteger(i) && i >= 0 && i < 5);
const distinct = new Set(indexes).size;
console.log('initial indexes across fresh loads:', indexes.join(', '));
console.log(`all valid: ${valid}, distinct images: ${distinct}/${loads}`);
if (!valid || distinct < 2) { console.log('FAIL'); process.exitCode = 1; }
else console.log('ALL PASS');
} finally {
try { ws && ws.close(); } catch {}
chrome.kill('SIGKILL');
}