Add piece joining, board locking, and site soundtrack to jigsaw game
- Introduce group-based table assembly: grid-adjacent pieces snap together on the table, drag as one rigid unit, and lock onto the board as a block - Extract join/lock rules into pure `resolveDrop` in JigsawLogic.js with a headless verification suite (tools/verifyJigsaw.js) covering fit guarantee, joining, locking, and win counting - Replace the flat top-bar buttons with a "Menu ▾" dropdown panel to free the top-right corner for the shared site music HUD - Integrate MusicPlayer soundtrack via getGameSoundtrack() and pin it to the fixed HUD camera
This commit is contained in:
parent
b6299234cf
commit
85fc7c16bf
|
|
@ -1,10 +1,12 @@
|
||||||
import * as Phaser from 'phaser';
|
import * as Phaser from 'phaser';
|
||||||
import { COLORS, GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
import { COLORS, GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||||
import { Button } from '../../ui/Button.js';
|
import { Button } from '../../ui/Button.js';
|
||||||
|
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||||||
import { SFX, playSound } from '../../ui/Sounds.js';
|
import { SFX, playSound } from '../../ui/Sounds.js';
|
||||||
|
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||||||
import {
|
import {
|
||||||
DIFFICULTIES, DIFFICULTY_ORDER,
|
DIFFICULTIES, DIFFICULTY_ORDER,
|
||||||
makeJigsaw, cellOutline, tracePath, mulberry32,
|
makeJigsaw, cellOutline, tracePath, mulberry32, cellNeighbours, resolveDrop,
|
||||||
} from './JigsawLogic.js';
|
} from './JigsawLogic.js';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -22,7 +24,8 @@ const WORLD_H = 2160;
|
||||||
const BOARD = { x: (WORLD_W - BOARD_SIZE) / 2, y: (WORLD_H - BOARD_SIZE) / 2, size: BOARD_SIZE };
|
const BOARD = { x: (WORLD_W - BOARD_SIZE) / 2, y: (WORLD_H - BOARD_SIZE) / 2, size: BOARD_SIZE };
|
||||||
const MAX_ZOOM = 4;
|
const MAX_ZOOM = 4;
|
||||||
const MIN_ZOOM = 0.5; // zooms out far enough to see the entire field at once
|
const MIN_ZOOM = 0.5; // zooms out far enough to see the entire field at once
|
||||||
const SNAP_FRAC = 0.42; // snap radius as a fraction of the cell size
|
const SNAP_FRAC = 0.42; // snap radius (×cell) — used for both locking a piece
|
||||||
|
// 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
|
const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs
|
||||||
|
|
||||||
function offsetOutline(o, ox, oy) {
|
function offsetOutline(o, ox, oy) {
|
||||||
|
|
@ -54,6 +57,10 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
this._pt = new Phaser.Math.Vector2();
|
this._pt = new Phaser.Math.Vector2();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
init(data) {
|
||||||
|
this.gameDef = data?.game ?? { slug: 'jigsaw', name: 'Jigsaw' };
|
||||||
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
this.artwork = [];
|
this.artwork = [];
|
||||||
this.imageIndex = 0;
|
this.imageIndex = 0;
|
||||||
|
|
@ -96,9 +103,24 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
worldOnly(this.boardPanel);
|
worldOnly(this.boardPanel);
|
||||||
worldOnly(this.boardFrame);
|
worldOnly(this.boardFrame);
|
||||||
worldOnly(this.refImage);
|
worldOnly(this.refImage);
|
||||||
worldOnly(this.ghost);
|
|
||||||
screenOnly(this.hud);
|
screenOnly(this.hud);
|
||||||
screenOnly(this.menu);
|
screenOnly(this.menu);
|
||||||
|
screenOnly(this.ddPanel);
|
||||||
|
|
||||||
|
// ── Site soundtrack ────────────────────────────────────────────────────
|
||||||
|
// The shared default soundtrack (data/music.json, preloaded in PreloadScene);
|
||||||
|
// getGameSoundtrack() transparently resolves a per-game override if one is
|
||||||
|
// ever added for the 'jigsaw' slug. The music HUD (skip/mute buttons +
|
||||||
|
// track name) is shared MusicPlayer UI: pin it to the fixed HUD camera
|
||||||
|
// (it must not follow the panning world camera) and above the top bar so
|
||||||
|
// the bar's translucent fill doesn't dim it.
|
||||||
|
try {
|
||||||
|
const { tracks, volume } = getGameSoundtrack(this);
|
||||||
|
if (tracks.length) {
|
||||||
|
this.music = new MusicPlayer(this, tracks, volume);
|
||||||
|
for (const o of this.music._objs) screenOnly(o).setDepth(9100);
|
||||||
|
}
|
||||||
|
} catch (_) { /* music is optional */ }
|
||||||
|
|
||||||
this.state = 'menu';
|
this.state = 'menu';
|
||||||
}
|
}
|
||||||
|
|
@ -214,26 +236,77 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
}).setOrigin(0.5, 0.5);
|
}).setOrigin(0.5, 0.5);
|
||||||
this.hud.add(this.stats);
|
this.hud.add(this.stats);
|
||||||
|
|
||||||
const B = (label, x, w, onClick, o = {}) => this.mkButton(this.hud, label, x, HUD_H / 2, w, 54, onClick, o);
|
// The top bar keeps a single "Menu ▾" button: a dropdown that holds all
|
||||||
this.btnMenu = B('Menu', 1845, 118, () => this.toMenu());
|
// the other actions (Reset view, Zoom in/out, Hint, New puzzle, Main menu).
|
||||||
this.btnHome = B('⌂', 1735, 54, () => this.resetView(), { fontSize: 24 });
|
// It sits left of the top-right corner, which the site's shared music HUD
|
||||||
this.btnZoomIn = B('+', 1665, 54, () => this.zoomStep(1.2), { fontSize: 26 });
|
// (skip/mute + track name, see create()) reserves.
|
||||||
this.btnZoomOut= B('-', 1595, 54, () => this.zoomStep(1 / 1.2), { fontSize: 26 });
|
this.btnMenu = this.mkButton(this.hud, 'Menu ▾', 1480, HUD_H / 2, 150, 54, () => this.toggleMenu());
|
||||||
this.btnHint = B('Hint', 1490, 84, () => this.toggleHint(), { fontSize: 22 });
|
this.buildMenuPanel();
|
||||||
this.btnNew = B('New', 1365, 96, () => this.restart(), { fontSize: 22 });
|
|
||||||
this.btnHint.setActive(true);
|
|
||||||
|
|
||||||
this.hudVisible = () => this.hud.visible;
|
this.hudVisible = () => this.hud.visible;
|
||||||
this.setHUDForState('menu');
|
this.setHUDForState('menu');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Menu dropdown (screen space, under the top-bar trigger) ────────────────
|
||||||
|
// Container sits at (0,0) like the HUD so buttonHit() can use absolute
|
||||||
|
// coordinates for its rows. Toggled by btnMenu; dismissed by any outside
|
||||||
|
// click (handled in onTableDown) or by state changes (setHUDForState).
|
||||||
|
buildMenuPanel() {
|
||||||
|
const X0 = 1315, X1 = 1555; // right edge aligns with the trigger
|
||||||
|
const W = X1 - X0, cx = (X0 + X1) / 2;
|
||||||
|
const BH = 44, GAP = 8, PAD = 12, SEP = 26;
|
||||||
|
const top = HUD_H + 10;
|
||||||
|
const h = PAD + 5 * BH + 4 * GAP + SEP + BH + PAD; // 5 rows + separator + Main menu
|
||||||
|
const panel = this.add.container(0, 0).setDepth(9050);
|
||||||
|
panel.setVisible(false);
|
||||||
|
const bg = this.add.rectangle(cx, top + h / 2, W, h, 0x17130c, 0.98);
|
||||||
|
const frame = this.add.graphics();
|
||||||
|
frame.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12);
|
||||||
|
panel.add([bg, frame]);
|
||||||
|
|
||||||
|
const rowY = (i) => top + PAD + BH / 2 + i * (BH + GAP);
|
||||||
|
const row = (i, label, onClick) => this.mkButton(panel, label, cx, rowY(i), W - 24, BH, onClick, { fontSize: 20 });
|
||||||
|
this.btnHome = row(0, '⌂ Reset view', () => { this.resetView(); this.closeMenu(); });
|
||||||
|
this.btnZoomIn = row(1, '+ Zoom in', () => { this.zoomStep(1.2); this.closeMenu(); });
|
||||||
|
this.btnZoomOut = row(2, '- Zoom out', () => { this.zoomStep(1 / 1.2); this.closeMenu(); });
|
||||||
|
this.btnHint = row(3, 'Hint', () => this.toggleHint()); // stays open: it's a toggle
|
||||||
|
this.btnNew = row(4, 'New puzzle', () => { this.restart(); this.closeMenu(); });
|
||||||
|
|
||||||
|
const sepY = top + PAD + 5 * BH + 4 * GAP + SEP / 2;
|
||||||
|
panel.add(this.add.rectangle(cx, sepY, W - 36, 2, COLORS.accent, 0.5));
|
||||||
|
this.btnMain = this.mkButton(panel, 'Main menu', cx, sepY + SEP / 2 + BH / 2, W - 24, BH,
|
||||||
|
() => { this.toMenu(); this.closeMenu(); }, { fontSize: 20 });
|
||||||
|
|
||||||
|
this.btnHint.setActive(this.hintOn);
|
||||||
|
this.ddPanel = panel;
|
||||||
|
this.ddOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu() {
|
||||||
|
if (this.state !== 'playing' && this.state !== 'won') return;
|
||||||
|
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||||
|
if (this.ddOpen) this.closeMenu(); else this.openMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
openMenu() {
|
||||||
|
if (this.ddOpen) return;
|
||||||
|
this.ddOpen = true;
|
||||||
|
this.btnHint.setActive(this.hintOn); // keep the toggle row in sync
|
||||||
|
this.ddPanel.setVisible(true);
|
||||||
|
this.btnMenu.setLabel('Menu ▴');
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMenu() {
|
||||||
|
if (!this.ddPanel || !this.ddOpen) return;
|
||||||
|
this.ddOpen = false;
|
||||||
|
this.ddPanel.setVisible(false);
|
||||||
|
this.btnMenu.setLabel('Menu ▾');
|
||||||
|
}
|
||||||
|
|
||||||
setHUDForState(state) {
|
setHUDForState(state) {
|
||||||
const playing = state === 'playing' || state === 'won';
|
const playing = state === 'playing' || state === 'won';
|
||||||
[this.btnMenu, this.btnHome, this.btnZoomIn, this.btnZoomOut, this.btnHint, this.btnNew].forEach((b) => {
|
this.btnMenu.visible = playing;
|
||||||
const on = playing && (b !== this.btnNew || state === 'playing');
|
if (this.ddPanel) { this.ddOpen = false; this.ddPanel.setVisible(false); } // state changes collapse the dropdown
|
||||||
b.visible = on;
|
|
||||||
if (b === this.btnNew && state === 'won') b.visible = true;
|
|
||||||
});
|
|
||||||
this.diffBadge.visible = playing;
|
this.diffBadge.visible = playing;
|
||||||
this.stats.visible = playing;
|
this.stats.visible = playing;
|
||||||
this.title.setVisible(true);
|
this.title.setVisible(true);
|
||||||
|
|
@ -445,12 +518,25 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
const spots = this.scatterSpots(this.total);
|
const spots = this.scatterSpots(this.total);
|
||||||
this.pieces.forEach((p, i) => {
|
this.pieces.forEach((p, i) => {
|
||||||
const s = spots[i] || { x: BOARD.x + BOARD.size / 2, y: BOARD.y + BOARD.size / 2 };
|
const s = spots[i] || { x: BOARD.x + BOARD.size / 2, y: BOARD.y + BOARD.size / 2 };
|
||||||
p.img.setPosition(s.x, s.y);
|
this.setPiecePos(p, s.x, s.y);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Ghost target shown while dragging (kept above pieces so it stays visible)
|
// ── Piece groups ─────────────────────────────────────────────────────────
|
||||||
this.ghost = this.add.image(0, 0).setOrigin(0.5).setAlpha(0.22).setVisible(false).setDepth(9000);
|
// Each piece starts as a singleton group. When two grid-adjacent pieces are
|
||||||
if (this.hudCam) this.ghost.cameraFilter = this.hudCam.id; // world camera only
|
// dropped within snap radius of their correct relative offset they merge
|
||||||
|
// into one group and from then on drag, drop and lock as a single piece.
|
||||||
|
// Invariant: every group member sits at `anchorPos + (member.home - anchor.home)`
|
||||||
|
// for any member `anchor` — i.e. the exact board-relative offset — so the
|
||||||
|
// knob geometry always meshes while a group moves.
|
||||||
|
this.groups = new Set();
|
||||||
|
this.pieceGrid = [];
|
||||||
|
for (let r = 0; r < this.rows; r++) this.pieceGrid.push(new Array(this.cols));
|
||||||
|
for (const p of this.pieces) {
|
||||||
|
const g = { pieces: [p] };
|
||||||
|
p.group = g;
|
||||||
|
this.groups.add(g);
|
||||||
|
this.pieceGrid[p.r][p.c] = p;
|
||||||
|
}
|
||||||
|
|
||||||
this.updateStats();
|
this.updateStats();
|
||||||
this.setState('playing');
|
this.setState('playing');
|
||||||
|
|
@ -620,7 +706,7 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
let best = null, bestD = Infinity;
|
let best = null, bestD = Infinity;
|
||||||
for (const p of this.pieces) {
|
for (const p of this.pieces) {
|
||||||
if (p.placed) continue;
|
if (p.placed) continue;
|
||||||
const d = Math.hypot(p.img.x - w.x, p.img.y - w.y);
|
const d = Math.hypot(p.pos.x - w.x, p.pos.y - w.y);
|
||||||
if (d < thr && d < bestD) { bestD = d; best = p; }
|
if (d < thr && d < bestD) { bestD = d; best = p; }
|
||||||
}
|
}
|
||||||
return best;
|
return best;
|
||||||
|
|
@ -629,26 +715,32 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
onTableDown(pointer) {
|
onTableDown(pointer) {
|
||||||
if (this.state !== 'playing') return;
|
if (this.state !== 'playing') return;
|
||||||
const c = this.canvasPos(pointer);
|
const c = this.canvasPos(pointer);
|
||||||
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons work
|
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons (incl. dropdown rows) work
|
||||||
|
if (this.music && c.x > 1830 && c.y < 80) return; // site music HUD (top-right) — don't pan over it
|
||||||
|
if (this.ddOpen) { this.closeMenu(); return; } // outside click dismisses the dropdown
|
||||||
const piece = this.nearestPiece(this.worldOf(pointer));
|
const piece = this.nearestPiece(this.worldOf(pointer));
|
||||||
if (piece) { this.grabPiece(piece, pointer); return; }
|
if (piece) { this.grabPiece(piece, pointer); return; }
|
||||||
const cam = this.cameras.main; // empty table → pan
|
const cam = this.cameras.main; // empty table → pan
|
||||||
this.panning = { cx: c.x, cy: c.y, camX: cam.scrollX, camY: cam.scrollY };
|
this.panning = { cx: c.x, cy: c.y, camX: cam.scrollX, camY: cam.scrollY };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Single source of truth for a piece's table position: `p.pos`, mirrored
|
||||||
|
// onto the sprite. resolveDrop() (JigsawLogic.js) reasons about `pos` only.
|
||||||
|
setPiecePos(p, x, y) {
|
||||||
|
p.pos = { x, y };
|
||||||
|
p.img.setPosition(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
grabPiece(piece, pointer) {
|
grabPiece(piece, pointer) {
|
||||||
const w = this.worldOf(pointer);
|
const w = this.worldOf(pointer);
|
||||||
|
const group = piece.group;
|
||||||
|
// Lift the whole group above everything else so it reads as one lifted piece.
|
||||||
this.zTop += 1;
|
this.zTop += 1;
|
||||||
piece.depth = this.zTop;
|
for (const m of group.pieces) { m.depth = this.zTop; m.img.setDepth(this.zTop); }
|
||||||
piece.img.setDepth(this.zTop);
|
this.dragging = { group, anchor: piece, offX: piece.pos.x - w.x, offY: piece.pos.y - w.y };
|
||||||
this.dragging = { piece, offX: piece.img.x - w.x, offY: piece.img.y - w.y };
|
|
||||||
this.suppressNextButtonClick = true;
|
this.suppressNextButtonClick = true;
|
||||||
// Show the target ghost (brighter alpha preview of the home slot) on Easy only.
|
// Ghosts (brighter alpha preview of each member's home slot) on Easy only.
|
||||||
if (this.ghost && this.difficulty === 'easy') {
|
this.showGroupGhosts(group);
|
||||||
this.ghost.setTexture(piece.img.texture.key).setPosition(piece.home.x, piece.home.y).setVisible(true);
|
|
||||||
} else if (this.ghost) {
|
|
||||||
this.ghost.setVisible(false);
|
|
||||||
}
|
|
||||||
playSound(this, SFX.UI_PICK);
|
playSound(this, SFX.UI_PICK);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -656,8 +748,14 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
const c = this.canvasPos(pointer);
|
const c = this.canvasPos(pointer);
|
||||||
if (this.dragging) {
|
if (this.dragging) {
|
||||||
const w = this.worldOf(pointer);
|
const w = this.worldOf(pointer);
|
||||||
const { piece, offX, offY } = this.dragging;
|
const { group, anchor, offX, offY } = this.dragging;
|
||||||
piece.img.setPosition(w.x + offX, w.y + offY);
|
// Move the WHOLE group: every member keeps its exact home offset from the
|
||||||
|
// grabbed anchor, so the assembled shape rigidly translates and the knob
|
||||||
|
// geometry between members always meshes.
|
||||||
|
const bx = w.x + offX, by = w.y + offY;
|
||||||
|
for (const m of group.pieces) {
|
||||||
|
this.setPiecePos(m, bx + (m.home.x - anchor.home.x), by + (m.home.y - anchor.home.y));
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.panning) {
|
if (this.panning) {
|
||||||
|
|
@ -670,31 +768,83 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
|
|
||||||
onPointerUp() {
|
onPointerUp() {
|
||||||
if (this.dragging) {
|
if (this.dragging) {
|
||||||
const { piece } = this.dragging;
|
const { group, anchor } = this.dragging;
|
||||||
this.ghost.setVisible(false);
|
this.hideGroupGhosts(group);
|
||||||
this.dragging = null;
|
this.dragging = null;
|
||||||
this.trySnap(piece);
|
this.handleDrop(group, anchor);
|
||||||
}
|
}
|
||||||
this.panning = null;
|
this.panning = null;
|
||||||
this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; });
|
this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; });
|
||||||
}
|
}
|
||||||
|
|
||||||
trySnap(piece) {
|
// ── Drop resolution: lock onto the board, or join with table neighbours ───
|
||||||
const d = Math.hypot(piece.img.x - piece.home.x, piece.img.y - piece.home.y);
|
// The join/lock RULES live in resolveDrop() (JigsawLogic.js, unit-checked);
|
||||||
if (d < this.cell * SNAP_FRAC) {
|
// this method only applies the decided positions and does scene bookkeeping.
|
||||||
piece.img.setPosition(piece.home.x, piece.home.y);
|
handleDrop(group, anchor) {
|
||||||
piece.placed = true;
|
const res = resolveDrop(this.jig, group, (r, c) => this.pieceGrid[r][c], this.cell * SNAP_FRAC);
|
||||||
this.placed += 1;
|
for (const pl of res.placements) this.setPiecePos(pl.piece, pl.x, pl.y);
|
||||||
piece.img.setDepth(12);
|
|
||||||
this.nudge(piece.img);
|
if (res.outcome === 'locked') {
|
||||||
|
this.lockGroup(group);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.outcome === 'joined') {
|
||||||
|
// Merge the absorbed groups into the dropped one (pieces keep their exact
|
||||||
|
// mesh positions; membership is the only scene-side state change).
|
||||||
|
for (const g of res.absorbedGroups) {
|
||||||
|
this.groups.delete(g);
|
||||||
|
for (const x of g.pieces) {
|
||||||
|
if (x.group === group) continue;
|
||||||
|
x.group = group;
|
||||||
|
group.pieces.push(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.placements.forEach((pl) => this.nudge(pl.piece.img));
|
||||||
|
playSound(this, SFX.UI_CHIME);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// REST — the group just settles where it was dropped.
|
||||||
|
this.nudge(anchor.img);
|
||||||
|
playSound(this, SFX.PIECE_CLICK);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lock the group onto the board (positions already applied by handleDrop) and
|
||||||
|
// retire it from the table. `placed` advances by the group size.
|
||||||
|
lockGroup(group) {
|
||||||
|
const locked = [];
|
||||||
|
for (const m of group.pieces) {
|
||||||
|
if (m.placed) continue;
|
||||||
|
m.placed = true;
|
||||||
|
m.img.setDepth(12);
|
||||||
|
locked.push(m);
|
||||||
|
}
|
||||||
|
this.groups.delete(group);
|
||||||
|
this.placed += locked.length;
|
||||||
|
locked.forEach((m) => this.nudge(m.img));
|
||||||
playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE);
|
playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE);
|
||||||
this.updateStats();
|
this.updateStats();
|
||||||
if (this.placed === this.total) this.onWin();
|
if (this.placed === this.total) this.onWin();
|
||||||
} else {
|
|
||||||
// gentle settle
|
|
||||||
this.nudge(piece.img);
|
|
||||||
playSound(this, SFX.PIECE_CLICK);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Home-slot ghosts (Easy only) ───────────────────────────────────────────
|
||||||
|
// Faint preview of a piece's home slot, shown while its group is dragged.
|
||||||
|
// Created lazily per piece so a group of N pieces shows N ghosts.
|
||||||
|
ghostFor(p) {
|
||||||
|
if (!p.ghost) {
|
||||||
|
const g = this.add.image(0, 0, p.key).setOrigin(0.5).setAlpha(0.22).setDepth(9000);
|
||||||
|
if (this.hudCam) g.cameraFilter = this.hudCam.id; // world camera only
|
||||||
|
p.ghost = g;
|
||||||
|
}
|
||||||
|
return p.ghost;
|
||||||
|
}
|
||||||
|
|
||||||
|
showGroupGhosts(group) {
|
||||||
|
if (this.difficulty !== 'easy') return; // keep the hint to Easy, as before
|
||||||
|
for (const m of group.pieces) this.ghostFor(m).setPosition(m.home.x, m.home.y).setVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideGroupGhosts(group) {
|
||||||
|
for (const m of group.pieces) if (m.ghost) m.ghost.setVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
nudge(obj) {
|
nudge(obj) {
|
||||||
|
|
@ -838,15 +988,17 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
teardownPlay() {
|
teardownPlay() {
|
||||||
this.teardownWin();
|
this.teardownWin();
|
||||||
this.dragging = null; this.panning = null;
|
this.dragging = null; this.panning = null;
|
||||||
if (this.ghost) { this.ghost.destroy(); this.ghost = null; }
|
|
||||||
if (this.boardPanel) this.boardPanel.destroy();
|
if (this.boardPanel) this.boardPanel.destroy();
|
||||||
if (this.boardFrame) this.boardFrame.destroy();
|
if (this.boardFrame) this.boardFrame.destroy();
|
||||||
if (this.refImage) this.refImage.destroy();
|
if (this.refImage) this.refImage.destroy();
|
||||||
(this.pieces || []).forEach((p) => {
|
(this.pieces || []).forEach((p) => {
|
||||||
p.img.destroy();
|
p.img.destroy();
|
||||||
|
if (p.ghost) p.ghost.destroy();
|
||||||
if (this.textures.exists(p.key)) this.textures.remove(p.key);
|
if (this.textures.exists(p.key)) this.textures.remove(p.key);
|
||||||
});
|
});
|
||||||
this.pieces = [];
|
this.pieces = [];
|
||||||
|
this.groups = new Set();
|
||||||
|
this.pieceGrid = [];
|
||||||
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; }
|
||||||
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
|
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
|
||||||
|
|
@ -854,8 +1006,8 @@ export default class JigsawGame extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
setBoardVisible(v) {
|
setBoardVisible(v) {
|
||||||
[this.boardPanel, this.boardFrame, this.refImage, this.ghost].forEach((o) => o && o.setVisible(v));
|
[this.boardPanel, this.boardFrame, this.refImage].forEach((o) => o && o.setVisible(v));
|
||||||
(this.pieces || []).forEach((p) => p.img.setVisible(v));
|
(this.pieces || []).forEach((p) => { p.img.setVisible(v); if (p.ghost) p.ghost.setVisible(false); });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Loop ───────────────────────────────────────────────────────────────────
|
// ── Loop ───────────────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,102 @@ export function cellEdgeSpec(jig, r, c) {
|
||||||
return out;
|
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 });
|
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`.
|
// Path commands for one edge, assuming the current point is `p0`.
|
||||||
|
|
|
||||||
|
|
@ -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