293 lines
12 KiB
JavaScript
293 lines
12 KiB
JavaScript
// 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;
|
||
}
|