Puzzle #5
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 341 KiB |
File diff suppressed because it is too large
Load Diff
|
|
@ -123,3 +123,4 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
|
||||||
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||||
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
||||||
|
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,292 @@
|
||||||
|
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
|
||||||
|
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
|
||||||
|
//
|
||||||
|
// Model
|
||||||
|
// -----
|
||||||
|
// A cols×rows grid. Every internal edge between two cells carries exactly one
|
||||||
|
// knob (a protruding "tab") that belongs to one of the two cells; the other
|
||||||
|
// cell gets the matching "blank" (indent). Boundary edges are flat.
|
||||||
|
//
|
||||||
|
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
|
||||||
|
// (r,c+1) [right]. 'L' → left cell owns the tab.
|
||||||
|
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
|
||||||
|
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
|
||||||
|
//
|
||||||
|
// Because both neighbours compute the SAME knob (same line segment, same side)
|
||||||
|
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
|
||||||
|
|
||||||
|
export const DIFFICULTIES = {
|
||||||
|
// Piece counts roughly double per tier. Square grids so the (square) source
|
||||||
|
// image fills the board edge-to-edge with no letterboxing.
|
||||||
|
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
|
||||||
|
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
|
||||||
|
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
|
||||||
|
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
|
||||||
|
|
||||||
|
// Knob shape as fractions of the edge length (see edgeFragment below).
|
||||||
|
// neckFrac: how far in from each end the neck (narrow waist) sits.
|
||||||
|
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
|
||||||
|
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
|
||||||
|
|
||||||
|
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
|
||||||
|
export function mulberry32(seed) {
|
||||||
|
let a = seed >>> 0;
|
||||||
|
return function rng() {
|
||||||
|
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||||
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||||
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rand = Math.random;
|
||||||
|
|
||||||
|
// Build the knob assignment for every internal edge.
|
||||||
|
export function makeJigsaw(cols, rows, seed = null) {
|
||||||
|
const g = seed == null ? rand : mulberry32(seed);
|
||||||
|
const H = [];
|
||||||
|
const V = [];
|
||||||
|
for (let r = 0; r < rows; r++) {
|
||||||
|
const row = [];
|
||||||
|
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
|
||||||
|
H.push(row);
|
||||||
|
}
|
||||||
|
for (let r = 0; r < rows - 1; r++) {
|
||||||
|
const row = [];
|
||||||
|
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
|
||||||
|
V.push(row);
|
||||||
|
}
|
||||||
|
return { cols, rows, H, V };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
|
||||||
|
// `normal` is the direction the knob bulges (null for flat edges). This is the
|
||||||
|
// side the shared curve lies on, so it is identical for both adjacent cells.
|
||||||
|
export function cellEdgeSpec(jig, r, c) {
|
||||||
|
const { cols, rows, H, V } = jig;
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
|
||||||
|
if (r === 0) out.top = { kind: 'flat', normal: null };
|
||||||
|
else {
|
||||||
|
const owner = V[r - 1][c];
|
||||||
|
const tab = owner === 'D'; // bottom cell owns the tab
|
||||||
|
out.top = tab
|
||||||
|
? { kind: 'tab', normal: { x: 0, y: -1 } }
|
||||||
|
: { kind: 'blank', normal: { x: 0, y: 1 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
|
||||||
|
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
|
||||||
|
else {
|
||||||
|
const owner = H[r][c];
|
||||||
|
const tab = owner === 'L'; // left cell owns the tab
|
||||||
|
out.right = tab
|
||||||
|
? { kind: 'tab', normal: { x: 1, y: 0 } }
|
||||||
|
: { kind: 'blank', normal: { x: -1, y: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
|
||||||
|
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
|
||||||
|
else {
|
||||||
|
const owner = V[r][c];
|
||||||
|
const tab = owner === 'U'; // top cell owns the tab
|
||||||
|
out.bottom = tab
|
||||||
|
? { kind: 'tab', normal: { x: 0, y: 1 } }
|
||||||
|
: { kind: 'blank', normal: { x: 0, y: -1 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
|
||||||
|
if (c === 0) out.left = { kind: 'flat', normal: null };
|
||||||
|
else {
|
||||||
|
const owner = H[r][c - 1];
|
||||||
|
const tab = owner === 'R'; // right cell owns the tab
|
||||||
|
out.left = tab
|
||||||
|
? { kind: 'tab', normal: { x: -1, y: 0 } }
|
||||||
|
: { kind: 'blank', normal: { x: 1, y: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 4-neighbour cells of (r,c) inside the grid. By construction every such
|
||||||
|
// pair shares an internal edge, and both pieces trace the *same* shared curve
|
||||||
|
// for it — so these are exactly the pieces that mesh with (r,c) when placed in
|
||||||
|
// their correct board slots. That is the legal set of pieces that may join
|
||||||
|
// (r,c) anywhere on the table; no other pair can ever fit together.
|
||||||
|
export function cellNeighbours(jig, r, c) {
|
||||||
|
const { cols, rows } = jig;
|
||||||
|
const out = [];
|
||||||
|
if (c > 0) out.push([r, c - 1]);
|
||||||
|
if (c < cols - 1) out.push([r, c + 1]);
|
||||||
|
if (r > 0) out.push([r - 1, c]);
|
||||||
|
if (r < rows - 1) out.push([r + 1, c]);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Table assembly: joining pieces & locking groups (pure, Phaser-free) ─────
|
||||||
|
// Model the scene feeds in (plain data only — the math never touches Phaser):
|
||||||
|
// piece: { r, c, home:{x,y}, pos:{x,y}, placed, group }
|
||||||
|
// group: { pieces: [...] } (piece.group points back)
|
||||||
|
// cellAt(r, c) -> piece|null (the grid lookup)
|
||||||
|
//
|
||||||
|
// Group invariant: every member of a group sits at `anchor.pos + (member.home -
|
||||||
|
// anchor.home)` for any member `anchor` — i.e. the exact board-relative offset
|
||||||
|
// — so members always mesh while the group moves. resolveDrop preserves it.
|
||||||
|
//
|
||||||
|
// resolveDrop decides what happens when `group` (all members unplaced) is
|
||||||
|
// released on the table, using the same snap radius for both outcomes:
|
||||||
|
// 1. BOARD LOCK — if any member is within `snapR` of its home slot, the
|
||||||
|
// whole group locks onto the board. Only grid-adjacent pieces can share a
|
||||||
|
// group, and grid-adjacent pieces mesh exactly on the board, so the group
|
||||||
|
// always lands as a correctly assembled block (every member is then
|
||||||
|
// aligned too, by the invariant).
|
||||||
|
// 2. JOIN — otherwise, any unplaced grid-neighbour of any member sitting
|
||||||
|
// within `snapR` of its correct relative position is absorbed together
|
||||||
|
// with its WHOLE group; repeated to a fixpoint so a chain of correctly
|
||||||
|
// placed pieces latches on in a single drop. Non-adjacent pieces can
|
||||||
|
// never join, no matter where they sit — they wouldn't mesh on the board.
|
||||||
|
// 3. REST — otherwise the group just rests where it was dropped.
|
||||||
|
//
|
||||||
|
// Pure: no mutation. Returns { outcome, placements, absorbedGroups } where
|
||||||
|
// placements are the target positions the caller must apply and absorbedGroups
|
||||||
|
// are the (other) groups that merged into `group`.
|
||||||
|
export function resolveDrop(jig, group, cellAt, snapR) {
|
||||||
|
// 1) Board lock takes precedence: any member aligned ⇒ the group is placed.
|
||||||
|
for (const m of group.pieces) {
|
||||||
|
if (Math.hypot(m.pos.x - m.home.x, m.pos.y - m.home.y) < snapR) {
|
||||||
|
return {
|
||||||
|
outcome: 'locked',
|
||||||
|
placements: group.pieces.map((m) => ({ piece: m, x: m.home.x, y: m.home.y })),
|
||||||
|
absorbedGroups: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Join: absorb unplaced grid-neighbours at their correct relative spot.
|
||||||
|
// `frame` is the group's consistent position frame: the dropped group is
|
||||||
|
// already home-exact, and every absorbed piece is snapped INTO the frame,
|
||||||
|
// so a chain that latches on ends up fully consistent (invariant holds).
|
||||||
|
const frame = new Map();
|
||||||
|
for (const m of group.pieces) frame.set(m, m.pos);
|
||||||
|
const members = [...group.pieces]; // working set — `group` is not mutated
|
||||||
|
const inGroup = new Set(members);
|
||||||
|
const placements = [];
|
||||||
|
const absorbedGroups = new Set();
|
||||||
|
let changed = true;
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
for (const m of [...members]) {
|
||||||
|
for (const [nr, nc] of cellNeighbours(jig, m.r, m.c)) {
|
||||||
|
const q = cellAt(nr, nc);
|
||||||
|
if (!q || q.placed || inGroup.has(q)) continue;
|
||||||
|
// Where q belongs in the assembled group relative to m's frame position.
|
||||||
|
const fm = frame.get(m);
|
||||||
|
const ex = fm.x + (q.home.x - m.home.x);
|
||||||
|
const ey = fm.y + (q.home.y - m.home.y);
|
||||||
|
if (Math.hypot(q.pos.x - ex, q.pos.y - ey) >= snapR) continue;
|
||||||
|
absorbedGroups.add(q.group);
|
||||||
|
for (const x of q.group.pieces) {
|
||||||
|
if (inGroup.has(x)) continue;
|
||||||
|
// Snap x into the frame (offsets from q are exact).
|
||||||
|
const px = ex + (x.home.x - q.home.x);
|
||||||
|
const py = ey + (x.home.y - q.home.y);
|
||||||
|
frame.set(x, { x: px, y: py });
|
||||||
|
placements.push({ piece: x, x: px, y: py });
|
||||||
|
members.push(x);
|
||||||
|
inGroup.add(x);
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!placements.length) return { outcome: 'rested', placements: [], absorbedGroups: [] };
|
||||||
|
return { outcome: 'joined', placements, absorbedGroups: [...absorbedGroups].filter((g) => g !== group) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||||
|
|
||||||
|
// Path commands for one edge, assuming the current point is `p0`.
|
||||||
|
// Flat → a single line. Knob → line to the near neck, one cubic through the
|
||||||
|
// bulb to the far neck, line to `p1`. The curve is direction-independent:
|
||||||
|
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
|
||||||
|
// which is what makes neighbouring pieces mesh.
|
||||||
|
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
|
||||||
|
if (!edge || edge.kind === 'flat') {
|
||||||
|
return [{ t: 'line', x: p1.x, y: p1.y }];
|
||||||
|
}
|
||||||
|
const { neckFrac, ctrlFrac } = knob;
|
||||||
|
const nA = lerp(p0, p1, neckFrac);
|
||||||
|
const nB = lerp(p0, p1, 1 - neckFrac);
|
||||||
|
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
||||||
|
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
|
||||||
|
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
|
||||||
|
return [
|
||||||
|
{ t: 'line', x: nA.x, y: nA.y },
|
||||||
|
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
|
||||||
|
{ t: 'line', x: p1.x, y: p1.y },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
|
||||||
|
// units; `ox`,`oy` the cell's top-left in local units. Returns
|
||||||
|
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
|
||||||
|
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
|
||||||
|
const x0 = ox + c * W;
|
||||||
|
const y0 = oy + r * H;
|
||||||
|
const TL = { x: x0, y: y0 };
|
||||||
|
const TR = { x: x0 + W, y: y0 };
|
||||||
|
const BR = { x: x0 + W, y: y0 + H };
|
||||||
|
const BL = { x: x0, y: y0 + H };
|
||||||
|
const spec = cellEdgeSpec(jig, r, c);
|
||||||
|
|
||||||
|
const cmds = [
|
||||||
|
...edgeFragment(TL, TR, spec.top, knob),
|
||||||
|
...edgeFragment(TR, BR, spec.right, knob),
|
||||||
|
...edgeFragment(BR, BL, spec.bottom, knob),
|
||||||
|
...edgeFragment(BL, TL, spec.left, knob),
|
||||||
|
];
|
||||||
|
return { start: TL, cmds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply path commands to a 2D canvas context (builds the current path).
|
||||||
|
export function tracePath(ctx, outline) {
|
||||||
|
const { start, cmds } = outline;
|
||||||
|
ctx.moveTo(start.x, start.y);
|
||||||
|
for (const c of cmds) {
|
||||||
|
if (c.t === 'line') ctx.lineTo(c.x, c.y);
|
||||||
|
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
|
||||||
|
}
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scramble: assign each piece a start position in the "tray" region ─────────
|
||||||
|
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
|
||||||
|
// pieces are scattered. Returns an array aligned to the piece index
|
||||||
|
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
|
||||||
|
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
|
||||||
|
const g = seed == null ? rand : mulberry32(seed);
|
||||||
|
const { cols, rows } = jig;
|
||||||
|
const N = cols * rows;
|
||||||
|
const placed = [];
|
||||||
|
const margin = Math.max(tray.w, tray.h) * 0.06;
|
||||||
|
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
|
||||||
|
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
// Rejection-sample a few tries so pieces don't pile in a single spot.
|
||||||
|
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
|
||||||
|
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
|
||||||
|
let tries = 0;
|
||||||
|
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
|
||||||
|
px = x0 + (x1 - x0) * g();
|
||||||
|
py = y0 + (y1 - y0) * g();
|
||||||
|
tries++;
|
||||||
|
}
|
||||||
|
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
|
||||||
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||||
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
||||||
import ShiftGame from './games/shift/ShiftGame.js';
|
import ShiftGame from './games/shift/ShiftGame.js';
|
||||||
|
import JigsawGame from './games/jigsaw/JigsawGame.js';
|
||||||
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
||||||
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
||||||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||||
|
|
@ -184,6 +185,7 @@ const config = {
|
||||||
HexsweeperGame,
|
HexsweeperGame,
|
||||||
PuddingMonstersGame,
|
PuddingMonstersGame,
|
||||||
ShiftGame,
|
ShiftGame,
|
||||||
|
JigsawGame,
|
||||||
BlockFighterGame,
|
BlockFighterGame,
|
||||||
MahjongMatchGame,
|
MahjongMatchGame,
|
||||||
MahjongGame,
|
MahjongGame,
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
const sceneKey = slugDispatch[this.game.slug];
|
const sceneKey = slugDispatch[this.game.slug];
|
||||||
const startData = {
|
const startData = {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,354 @@
|
||||||
|
// Headless verification for Jigsaw.
|
||||||
|
// node tools/verifyJigsaw.js
|
||||||
|
// Exits non-zero on any failure.
|
||||||
|
//
|
||||||
|
// 1. Grid model: seeded determinism, neighbour bounds/symmetry, tab/blank
|
||||||
|
// complementarity on every internal edge.
|
||||||
|
// 2. FIT guarantee: for every internal edge of every difficulty, the two
|
||||||
|
// adjacent pieces trace the *same* shared curve — adjacent pieces physically
|
||||||
|
// mesh when placed in their correct slots. This is the property the
|
||||||
|
// table-join rule ("pieces may only join if they fit on the board") relies
|
||||||
|
// on, so it is checked for ALL grids.
|
||||||
|
// 3. Table assembly (the production resolveDrop): only grid-adjacent pieces
|
||||||
|
// join, whole groups are absorbed, fixpoint chains, the rigid-drag
|
||||||
|
// invariant, board lock (and its precedence over join), and win counting.
|
||||||
|
|
||||||
|
import {
|
||||||
|
DIFFICULTIES, DIFFICULTY_ORDER,
|
||||||
|
makeJigsaw, cellEdgeSpec, cellNeighbours, edgeFragment, resolveDrop,
|
||||||
|
} from '../src/games/jigsaw/JigsawLogic.js';
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
function check(ok, msg) {
|
||||||
|
if (!ok) { failures++; console.error(` ✗ ${msg}`); }
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
const approx = (a, b, eps = 1e-9) => Math.abs(a - b) <= eps;
|
||||||
|
|
||||||
|
// ── 1. Grid model ────────────────────────────────────────────────────────────
|
||||||
|
console.log('Grid model:');
|
||||||
|
{
|
||||||
|
const a = makeJigsaw(6, 5, 1234), b = makeJigsaw(6, 5, 1234), c = makeJigsaw(6, 5, 4321);
|
||||||
|
check(JSON.stringify(a.H) === JSON.stringify(b.H) && JSON.stringify(a.V) === JSON.stringify(b.V),
|
||||||
|
'same seed must give the same knob layout');
|
||||||
|
check(JSON.stringify(a.H) !== JSON.stringify(c.H) || JSON.stringify(a.V) !== JSON.stringify(c.V),
|
||||||
|
'different seeds should give different layouts');
|
||||||
|
|
||||||
|
const jig = makeJigsaw(5, 5, 7);
|
||||||
|
const inB = (r, cc) => r >= 0 && r < 5 && cc >= 0 && cc < 5;
|
||||||
|
let allInRange = true, symmetric = true;
|
||||||
|
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 5; cc++) {
|
||||||
|
for (const [nr, nc] of cellNeighbours(jig, r, cc)) {
|
||||||
|
if (!inB(nr, nc)) allInRange = false;
|
||||||
|
if (!cellNeighbours(jig, nr, nc).some(([pr, pc]) => pr === r && pc === cc)) symmetric = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(allInRange, 'neighbours never leave the grid');
|
||||||
|
check(symmetric, 'neighbourhood is symmetric');
|
||||||
|
check(cellNeighbours(jig, 0, 0).length === 2, 'corner piece has 2 neighbours');
|
||||||
|
check(cellNeighbours(jig, 0, 2).length === 3, 'edge piece has 3 neighbours');
|
||||||
|
check(cellNeighbours(jig, 2, 2).length === 4, 'interior piece has 4 neighbours');
|
||||||
|
|
||||||
|
// Every internal edge is a tab on exactly one side, blank on the other, and
|
||||||
|
// both sides agree on which way the shared curve bulges.
|
||||||
|
let complement = true;
|
||||||
|
for (let r = 0; r < 5; r++) for (let cc = 0; cc < 4; cc++) {
|
||||||
|
const aL = cellEdgeSpec(jig, r, cc).right, bL = cellEdgeSpec(jig, r, cc + 1).left;
|
||||||
|
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
|
||||||
|
}
|
||||||
|
for (let r = 0; r < 4; r++) for (let cc = 0; cc < 5; cc++) {
|
||||||
|
const aL = cellEdgeSpec(jig, r, cc).bottom, bL = cellEdgeSpec(jig, r + 1, cc).top;
|
||||||
|
if (!(aL.kind !== bL.kind && aL.normal.x === bL.normal.x && aL.normal.y === bL.normal.y)) complement = false;
|
||||||
|
}
|
||||||
|
check(complement, 'every internal edge: tab/blank pair with a common curve side');
|
||||||
|
console.log(' ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. Fit guarantee: adjacent pieces trace the identical shared curve ───────
|
||||||
|
console.log('Fit guarantee (adjacent pieces mesh on the board):');
|
||||||
|
{
|
||||||
|
const sample = (p0, p1, edge, n = 128) => {
|
||||||
|
const pts = [];
|
||||||
|
let cur = { x: p0.x, y: p0.y };
|
||||||
|
for (const c of edgeFragment(p0, p1, edge)) {
|
||||||
|
for (let i = 1; i <= n; i++) {
|
||||||
|
const t = i / n;
|
||||||
|
let x, y;
|
||||||
|
if (c.t === 'line') { x = cur.x + (c.x - cur.x) * t; y = cur.y + (c.y - cur.y) * t; }
|
||||||
|
else {
|
||||||
|
const m = 1 - t;
|
||||||
|
x = m * m * m * cur.x + 3 * m * m * t * c.c1.x + 3 * m * t * t * c.c2.x + t * t * t * c.x;
|
||||||
|
y = m * m * m * cur.y + 3 * m * m * t * c.c1.y + 3 * m * t * t * c.c2.y + t * t * t * c.y;
|
||||||
|
}
|
||||||
|
pts.push({ x, y });
|
||||||
|
}
|
||||||
|
cur = { x: c.x, y: c.y };
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
};
|
||||||
|
// Max distance from every sample of curve A to the closest sample of B (both
|
||||||
|
// ways). Identical curves → near zero (sampling gap only).
|
||||||
|
const maxGap = (A, B) => Math.max(
|
||||||
|
...A.map((p) => Math.min(...B.map((q) => Math.hypot(p.x - q.x, p.y - q.y)))),
|
||||||
|
...B.map((p) => Math.min(...A.map((q) => Math.hypot(p.x - q.x, p.y - q.y))))
|
||||||
|
);
|
||||||
|
|
||||||
|
let edgesChecked = 0, ok = true, worst = 0;
|
||||||
|
const W = 100, H = 100;
|
||||||
|
for (const key of DIFFICULTY_ORDER) {
|
||||||
|
const { cols, rows } = DIFFICULTIES[key];
|
||||||
|
const jig = makeJigsaw(cols, rows, 42);
|
||||||
|
for (let r = 0; r < rows; r++) for (let c = 0; c < cols - 1; c++) {
|
||||||
|
// Vertical internal edge between (r,c) [left] and (r,c+1) [right].
|
||||||
|
const xA = c * W, yA = r * H;
|
||||||
|
const p0A = { x: xA + W, y: yA }, p1A = { x: xA + W, y: yA + H };
|
||||||
|
const p0B = { x: xA + W, y: yA + H }, p1B = { x: xA + W, y: yA };
|
||||||
|
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).right);
|
||||||
|
const B = sample(p0B, p1B, cellEdgeSpec(jig, r, c + 1).left);
|
||||||
|
const gap = maxGap(A, B);
|
||||||
|
edgesChecked++; worst = Math.max(worst, gap);
|
||||||
|
if (gap > 2.5) ok = false;
|
||||||
|
}
|
||||||
|
for (let r = 0; r < rows - 1; r++) for (let c = 0; c < cols; c++) {
|
||||||
|
// Horizontal internal edge between (r,c) [top] and (r+1,c) [bottom].
|
||||||
|
const xA = c * W, yA = r * H;
|
||||||
|
const p0A = { x: xA + W, y: yA + H }, p1A = { x: xA, y: yA + H };
|
||||||
|
const p0B = { x: xA, y: yA + H }, p1B = { x: xA + W, y: yA + H };
|
||||||
|
const A = sample(p0A, p1A, cellEdgeSpec(jig, r, c).bottom);
|
||||||
|
const B = sample(p0B, p1B, cellEdgeSpec(jig, r + 1, c).top);
|
||||||
|
const gap = maxGap(A, B);
|
||||||
|
edgesChecked++; worst = Math.max(worst, gap);
|
||||||
|
if (gap > 2.5) ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(ok, `all ${edgesChecked} internal edges on all difficulties mesh exactly (worst gap ${worst.toFixed(3)}px)`);
|
||||||
|
console.log(` ok — ${edgesChecked} internal edges checked, worst deviation ${worst.toFixed(4)}px`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. Table assembly (production resolveDrop) ───────────────────────────────
|
||||||
|
console.log('Table assembly (piece joining / group lock):');
|
||||||
|
{
|
||||||
|
const SNAP = 0.42; // same SNAP_FRAC as the scene
|
||||||
|
const boardOrigin = { x: 1000, y: 1000 };
|
||||||
|
const cell = 100;
|
||||||
|
|
||||||
|
function makeBoard(cols = 5, rows = 5, seed = 7) {
|
||||||
|
const jig = makeJigsaw(cols, rows, seed);
|
||||||
|
const pieces = [];
|
||||||
|
const grid = [];
|
||||||
|
for (let r = 0; r < rows; r++) grid.push(new Array(cols));
|
||||||
|
let i = 0;
|
||||||
|
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++, i++) {
|
||||||
|
const p = {
|
||||||
|
r, c,
|
||||||
|
home: { x: boardOrigin.x + (c + 0.5) * cell, y: boardOrigin.y + (r + 0.5) * cell },
|
||||||
|
// Scattered on the table, well clear of the board and of each other.
|
||||||
|
pos: { x: 50 + i * 140, y: 5000 + (i % 6) * 120 },
|
||||||
|
placed: false, group: null,
|
||||||
|
};
|
||||||
|
const g = { pieces: [p] };
|
||||||
|
p.group = g;
|
||||||
|
pieces.push(p);
|
||||||
|
grid[r][c] = p;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
jig, pieces, grid,
|
||||||
|
groups: new Set(pieces.map((p) => p.group)),
|
||||||
|
placed: 0, total: pieces.length,
|
||||||
|
cellAt: (r, c) => (r >= 0 && r < rows && c >= 0 && c < cols) ? grid[r][c] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const at = (bd, r, c) => bd.grid[r][c];
|
||||||
|
|
||||||
|
// Mirror of JigsawGame.handleDrop: apply the decided positions, then do the
|
||||||
|
// (sound/nudge-free) scene bookkeeping.
|
||||||
|
function handleDrop(bd, group, anchor) {
|
||||||
|
const res = resolveDrop(bd.jig, group, bd.cellAt, cell * SNAP);
|
||||||
|
for (const pl of res.placements) pl.piece.pos = { x: pl.x, y: pl.y };
|
||||||
|
if (res.outcome === 'locked') {
|
||||||
|
const locked = group.pieces.filter((m) => !m.placed);
|
||||||
|
locked.forEach((m) => { m.placed = true; });
|
||||||
|
bd.groups.delete(group);
|
||||||
|
bd.placed += locked.length;
|
||||||
|
} else if (res.outcome === 'joined') {
|
||||||
|
for (const g of res.absorbedGroups) {
|
||||||
|
bd.groups.delete(g);
|
||||||
|
for (const x of g.pieces) { if (x.group === group) continue; x.group = group; group.pieces.push(x); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
// Mirror of JigsawGame.onPointerMove: rigid group drag around the anchor.
|
||||||
|
function dragGroup(bd, group, anchor, to) {
|
||||||
|
for (const m of group.pieces) m.pos = { x: to.x + (m.home.x - anchor.home.x), y: to.y + (m.home.y - anchor.home.y) };
|
||||||
|
}
|
||||||
|
const invariantHolds = (group) => group.pieces.every((m) => group.pieces.every((a) =>
|
||||||
|
approx(m.pos.x, a.pos.x + m.home.x - a.home.x) && approx(m.pos.y, a.pos.y + m.home.y - a.home.y)));
|
||||||
|
|
||||||
|
// 3a. resolveDrop is pure: no mutation before the scene applies the result.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 0, 0), B = at(bd, 0, 1);
|
||||||
|
A.pos = { x: 200, y: 500 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 20, y: A.pos.y + (B.home.y - A.home.y) - 15 };
|
||||||
|
const g0 = [...A.group.pieces];
|
||||||
|
const res = resolveDrop(bd.jig, A.group, bd.cellAt, cell * SNAP);
|
||||||
|
check(res.outcome === 'joined', '3a: adjacent pair within snap radius joins');
|
||||||
|
check(res.placements.length === 1 && res.placements[0].piece === B, '3a: only the absorbed piece is placed');
|
||||||
|
check(A.group.pieces.length === 1 && A.group.pieces[0] === A, '3a: group not mutated by resolveDrop');
|
||||||
|
check(B.pos.x === 200 + (B.home.x - A.home.x) + 20, '3a: piece positions not mutated by resolveDrop');
|
||||||
|
const m = res.placements[0];
|
||||||
|
B.pos = { x: m.x, y: m.y }; // scene-side application
|
||||||
|
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(B.pos.y, A.pos.y + (B.home.y - A.home.y)),
|
||||||
|
'3a: absorbed piece snaps to the exact mesh offset');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3b. Pieces that cannot both sit on the board never join — even when
|
||||||
|
// dropped dead-on their (hypothetical) meshing offset.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 0, 0), C = at(bd, 0, 2);
|
||||||
|
A.pos = { x: 300, y: 500 };
|
||||||
|
C.pos = { x: A.pos.x + 2 * cell, y: A.pos.y }; // exact 2-cell offset, zero error
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'rested', '3b: non-adjacent pieces never join (rests instead)');
|
||||||
|
check(A.group.pieces.length === 1, '3b: group stays a singleton');
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 0, 0), D = at(bd, 1, 1);
|
||||||
|
A.pos = { x: 300, y: 500 };
|
||||||
|
D.pos = { x: A.pos.x + cell * 0.7, y: A.pos.y + cell * 0.7 }; // sitting on top, diagonally
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'rested', '3b: diagonal pieces never join even when overlapping');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3c. A chain of correctly placed pieces latches on in ONE drop (fixpoint).
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1); // C neighbour of B only
|
||||||
|
A.pos = { x: 200, y: 500 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 10, y: A.pos.y + (B.home.y - A.home.y) };
|
||||||
|
C.pos = { x: B.pos.x + (C.home.x - B.home.x) - 8, y: B.pos.y + (C.home.y - B.home.y) };
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'joined', '3c: chain joins in one drop');
|
||||||
|
check(B.group === A.group && C.group === A.group && A.group.pieces.length === 3, '3c: all three in one group');
|
||||||
|
check(approx(B.pos.x, A.pos.x + (B.home.x - A.home.x)) && approx(C.pos.x, A.pos.x + (C.home.x - A.home.x)),
|
||||||
|
'3c: every member snaps to the exact mesh offset');
|
||||||
|
check(invariantHolds(A.group), '3c: group invariant holds after join');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3d. A pre-joined group is absorbed WHOLE when a neighbour drops beside it.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 0, 0), B = at(bd, 0, 1), C = at(bd, 1, 1);
|
||||||
|
// First: join B+C on the table.
|
||||||
|
B.pos = { x: 400, y: 600 };
|
||||||
|
C.pos = { x: B.pos.x + (C.home.x - B.home.x) + 5, y: B.pos.y + (C.home.y - B.home.y) };
|
||||||
|
const r1 = handleDrop(bd, B.group, B);
|
||||||
|
check(r1.outcome === 'joined' && B.group.pieces.length === 2, '3d: B+C joined first');
|
||||||
|
// Then: drop A next to B → the whole B+C group comes along.
|
||||||
|
const Bg = B.group;
|
||||||
|
A.pos = { x: B.pos.x - (B.home.x - A.home.x) - 12, y: B.pos.y + 9 };
|
||||||
|
const r2 = handleDrop(bd, A.group, A);
|
||||||
|
check(r2.outcome === 'joined', '3d: A dropped beside the pair joins it');
|
||||||
|
check(B.group === A.group && A.group.pieces.length === 3, '3d: the whole pre-joined group was absorbed');
|
||||||
|
check(invariantHolds(A.group), '3d: group invariant holds after whole-group absorption');
|
||||||
|
check(bd.groups.has(A.group) && !bd.groups.has(Bg), '3d: group registry stays consistent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3e. Groups drag rigidly: every member keeps its exact home offset.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 2, 2), B = at(bd, 2, 3), C = at(bd, 1, 2);
|
||||||
|
A.pos = { x: 250, y: 520 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||||
|
C.pos = { x: A.pos.x + (C.home.x - A.home.x), y: A.pos.y + (C.home.y - A.home.y) };
|
||||||
|
handleDrop(bd, A.group, A); // B and C both within snap → 3-piece group
|
||||||
|
check(A.group.pieces.length === 3, '3e: three-piece group formed');
|
||||||
|
dragGroup(bd, A.group, B, { x: 700, y: 900 }); // grab a non-first member
|
||||||
|
check(approx(A.pos.x, 700 + (A.home.x - B.home.x)) && approx(C.pos.y, 900 + (C.home.y - B.home.y)),
|
||||||
|
'3e: dragging any member moves the whole group rigidly');
|
||||||
|
check(invariantHolds(A.group), '3e: invariant preserved by drag');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3f. Board lock: any member aligned ⇒ the whole group lands on the board.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 2, 2), B = at(bd, 2, 3);
|
||||||
|
A.pos = { x: 250, y: 520 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||||
|
handleDrop(bd, A.group, A);
|
||||||
|
check(A.group.pieces.length === 2, '3f: pair formed');
|
||||||
|
// Drag the pair (grabbing B, the "far" member) so B lands within snap of home.
|
||||||
|
dragGroup(bd, A.group, B, { x: B.home.x - 14, y: B.home.y + 10 });
|
||||||
|
const res = handleDrop(bd, A.group, B);
|
||||||
|
check(res.outcome === 'locked', '3f: group locks when aligned with the board');
|
||||||
|
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x) && approx(B.pos.y, B.home.y),
|
||||||
|
'3f: every member lands exactly on its home slot');
|
||||||
|
check(A.placed && B.placed && bd.placed === 2, '3f: both members count as placed');
|
||||||
|
check(!bd.groups.has(A.group), '3f: locked group retired from the table');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3g. Lock takes precedence over join.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 2, 2), B = at(bd, 2, 3), D = at(bd, 3, 2);
|
||||||
|
A.pos = { x: 250, y: 520 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x), y: A.pos.y + (B.home.y - A.home.y) };
|
||||||
|
handleDrop(bd, A.group, A);
|
||||||
|
// D sits exactly where it would mesh under A (joinable)…
|
||||||
|
D.pos = { x: A.pos.x + (D.home.x - A.home.x), y: A.pos.y + (D.home.y - A.home.y) };
|
||||||
|
// …but the A+B pair is also aligned with the board.
|
||||||
|
dragGroup(bd, A.group, A, { x: A.home.x + 8, y: A.home.y - 6 });
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'locked', '3g: board lock wins over a possible join');
|
||||||
|
check(D.group.pieces.length === 1 && !D.placed, '3g: the joinable piece was NOT absorbed');
|
||||||
|
check(approx(A.pos.x, A.home.x) && approx(B.pos.x, B.home.x), '3g: group landed on the board');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3h. Outside the snap radius: nothing joins, positions untouched.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 1, 1), B = at(bd, 1, 2);
|
||||||
|
A.pos = { x: 300, y: 500 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 60, y: A.pos.y }; // 60 > 42 snap
|
||||||
|
const Bdrop = { ...B.pos }; // where the drop LEFT it
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'rested', '3h: beyond snap radius the drop rests');
|
||||||
|
check(approx(B.pos.x, Bdrop.x) && approx(B.pos.y, Bdrop.y), '3h: unjoined piece keeps its dropped position');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3i. Placed pieces are ignored by joining.
|
||||||
|
{
|
||||||
|
const bd = makeBoard();
|
||||||
|
const A = at(bd, 1, 1), B = at(bd, 1, 2), D = at(bd, 2, 1);
|
||||||
|
D.pos = { x: D.home.x, y: D.home.y }; D.placed = true; // already on the board
|
||||||
|
A.pos = { x: 300, y: 500 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 4, y: A.pos.y + (B.home.y - A.home.y) };
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'joined' && A.group.pieces.length === 2, '3i: unplaced neighbour still joins');
|
||||||
|
check(!A.group.pieces.includes(D) && D.placed, '3i: placed piece is never absorbed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3j. Win bookkeeping: locking the final pieces reaches the total.
|
||||||
|
{
|
||||||
|
const bd = makeBoard(2, 1, 11); // 2 pieces, one internal edge
|
||||||
|
const A = at(bd, 0, 0), B = at(bd, 0, 1);
|
||||||
|
A.pos = { x: 200, y: 500 };
|
||||||
|
B.pos = { x: A.pos.x + (B.home.x - A.home.x) + 6, y: A.pos.y + (B.home.y - A.home.y) };
|
||||||
|
handleDrop(bd, A.group, A);
|
||||||
|
check(A.group.pieces.length === 2, '3j: pair formed');
|
||||||
|
dragGroup(bd, A.group, A, { x: A.home.x, y: A.home.y });
|
||||||
|
const res = handleDrop(bd, A.group, A);
|
||||||
|
check(res.outcome === 'locked' && bd.placed === bd.total, '3j: locking the pair completes the board');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures) {
|
||||||
|
console.error(`\nFAILED: ${failures} check(s).`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log('\nAll Jigsaw checks passed.');
|
||||||
Loading…
Reference in New Issue