fertig-classic-games/src/games/jigsaw/JigsawLogic.js

293 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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