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.
This commit is contained in:
parent
19dff633ce
commit
0740c12ffa
|
|
@ -0,0 +1,104 @@
|
||||||
|
<!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));
|
||||||
|
window.addEventListener('error', (e) => log('PAGE ERROR: ' + e.message));
|
||||||
|
window.addEventListener('unhandledrejection', (e) => log('PAGE REJECTION: ' + (e.reason && e.reason.stack || e.reason)));
|
||||||
|
|
||||||
|
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 function waitForState(st, tries = 200) {
|
||||||
|
for (let i = 0; i < tries; i++) { if (s().state === st) return true; await wait(25); }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
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');
|
||||||
|
|
||||||
|
const btn = s().randomStartButton;
|
||||||
|
check(!!btn, 'randomStartButton exists');
|
||||||
|
check(btn.visible === true && s().menu.visible === true, 'randomStartButton visible inside menu');
|
||||||
|
const cy = 1080 / 2 + 12;
|
||||||
|
const panelBottom = cy + 445; // panel H = 890
|
||||||
|
check(btn.y + btn.options.height / 2 <= panelBottom, `button bottom (${(btn.y + btn.options.height / 2).toFixed(0)}) inside panel (${panelBottom})`);
|
||||||
|
check(btn.y - btn.options.height / 2 > s().startButton.y + s().startButton.options.height / 2 + 8, 'button sits below Start with a gap');
|
||||||
|
|
||||||
|
// 8 one-click random starts from the menu
|
||||||
|
s().selectedDiff = 'easy';
|
||||||
|
const names = new Set();
|
||||||
|
let allPlaying = true, allPieces = true, nameValid = true;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
s().toMenu();
|
||||||
|
await wait(30);
|
||||||
|
s().startRandomPuzzle();
|
||||||
|
const ok = await waitForState('playing');
|
||||||
|
if (i % 5 === 0) log(` (start #${i}: state=${s().state}, img=${s().imageName || '-'})`);
|
||||||
|
if (!ok) { allPlaying = false; log(` (start #${i}: state=${s().state})`); continue; }
|
||||||
|
if (s().pieces.length !== 25) allPieces = false;
|
||||||
|
names.add(s().imageName);
|
||||||
|
if (!ART.some((a) => a.name === s().imageName)) nameValid = false;
|
||||||
|
}
|
||||||
|
check(allPlaying, 'state=playing after every startRandomPuzzle call');
|
||||||
|
check(allPieces, '25 pieces built on every random start');
|
||||||
|
check(nameValid, 'started image is always a valid artwork entry');
|
||||||
|
check(names.size >= 2, `randomness: ${names.size}/5 distinct images in 8 starts (${[...names].join(', ')})`);
|
||||||
|
|
||||||
|
// Single-image guard: no infinite loop, still starts with the only image.
|
||||||
|
s().toMenu();
|
||||||
|
s().artwork = [ART[0]]; s().imageIndex = 0;
|
||||||
|
s().startRandomPuzzle();
|
||||||
|
await waitForState('playing');
|
||||||
|
check(s().state === 'playing' && s().imageName === 'Alien World', 'single-image artwork: starts with the only image');
|
||||||
|
|
||||||
|
// Difficulty must be preserved by a random start.
|
||||||
|
s().toMenu();
|
||||||
|
s().artwork = ART; s().imageIndex = 0;
|
||||||
|
s().selectDifficulty('medium');
|
||||||
|
s().startRandomPuzzle();
|
||||||
|
await waitForState('playing');
|
||||||
|
check(s().state === 'playing' && s().total === 36 && s().difficulty === 'medium', 'random start keeps the selected difficulty (36 pieces)');
|
||||||
|
|
||||||
|
log(failures === 0 ? 'ALL PASS' : failures + ' FAILURES');
|
||||||
|
document.title = failures === 0 ? 'PASS' : 'FAIL:' + failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
log('ERROR: ' + ((e && e.stack) || e));
|
||||||
|
failures++;
|
||||||
|
document.title = 'FAIL:error';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
|
|
@ -153,6 +153,18 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
this.loadPreview(this.currentImage());
|
this.loadPreview(this.currentImage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One-click "random puzzle": pick a random image (never the current one when
|
||||||
|
// there's a choice) and start the game with it. The chosen difficulty stays.
|
||||||
|
startRandomPuzzle() {
|
||||||
|
if (this.artwork.length > 1) {
|
||||||
|
let i = this.imageIndex;
|
||||||
|
while (i === this.imageIndex) i = Math.floor(Math.random() * this.artwork.length);
|
||||||
|
this.imageIndex = i;
|
||||||
|
this.loadPreview(this.currentImage());
|
||||||
|
}
|
||||||
|
this.startPuzzle();
|
||||||
|
}
|
||||||
|
|
||||||
loadPreview(item) {
|
loadPreview(item) {
|
||||||
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
|
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
|
||||||
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
|
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
|
||||||
|
|
@ -344,7 +356,7 @@ 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;
|
const W = 1160, H = 890, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2 + 12;
|
||||||
const panel = this.add.rectangle(cx, cy, W, H, 0x17130c, 0.96);
|
const panel = this.add.rectangle(cx, cy, W, H, 0x17130c, 0.96);
|
||||||
const frame = this.add.graphics();
|
const frame = this.add.graphics();
|
||||||
frame.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 20);
|
frame.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 20);
|
||||||
|
|
@ -383,6 +395,8 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
// Start
|
// Start
|
||||||
const start = this.mkButton(this.menu, 'Start Puzzle ▸', cx, cy + 315, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
|
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;
|
||||||
|
// One-click random: pick a random picture and start immediately.
|
||||||
|
this.randomStartButton = this.mkButton(this.menu, '🎲 Start Random', cx, cy + 404, 300, 58, () => this.startRandomPuzzle(), { fontSize: 24 });
|
||||||
|
|
||||||
// Load the first preview
|
// Load the first preview
|
||||||
this.selectDifficulty('easy');
|
this.selectDifficulty('easy');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Headless-chromium CDP driver for the jigsaw random-start test page.
|
||||||
|
// Usage: node tools/__jig_random_driver.mjs <url>
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { setTimeout as sleep } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const url = process.argv[2];
|
||||||
|
if (!url) { console.error('usage: driver <url>'); 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 {
|
||||||
|
// Wait for the debug endpoint.
|
||||||
|
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');
|
||||||
|
await send('Page.navigate', { url });
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
let title = '';
|
||||||
|
while (Date.now() - t0 < 240000) {
|
||||||
|
const { result: { value } } = await send('Runtime.evaluate', { expression: 'document.title', returnByValue: true });
|
||||||
|
title = value || '';
|
||||||
|
if (title === 'PASS' || title.startsWith('FAIL')) break;
|
||||||
|
await sleep(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { result: { value: logText } } = await send('Runtime.evaluate', { expression: 'document.getElementById("log").textContent', returnByValue: true });
|
||||||
|
console.log('===== page log =====');
|
||||||
|
console.log(logText.trimEnd());
|
||||||
|
console.log('===== result: ' + (title || 'TIMEOUT') + ' =====');
|
||||||
|
process.exitCode = title === 'PASS' ? 0 : 1;
|
||||||
|
} finally {
|
||||||
|
try { ws && ws.close(); } catch {}
|
||||||
|
chrome.kill('SIGKILL');
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue