// 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; }