/* ============================================================================ AI PUZZLE MAKER — hand-built canvas jigsaw engine. Vanilla JS, no frameworks. Flow: home (subject + pieces + rotation) -> /generate (FLUX art + MiniCPM theme pack; bundled fallbacks offline) -> image is CUT into bezier-tab jigsaw pieces -> scattered across the table -> drag / rotate / snap against the clock while a mascot hops around the board talking trash via /quip -> solve -> per-puzzle leaderboard + share to the community gallery. ========================================================================= */ "use strict"; /* ---------------- tiny helpers ---------------- */ const $ = (id) => document.getElementById(id); const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const lerp = (a, b, t) => a + (b - a) * t; const dist = (x1, y1, x2, y2) => Math.hypot(x2 - x1, y2 - y1); const rnd = (a, b) => a + Math.random() * (b - a); const pick = (arr) => arr[(Math.random() * arr.length) | 0]; const fmtMs = (ms) => { const t = Math.max(0, ms | 0), m = (t / 60000) | 0, s = ((t % 60000) / 1000) | 0, d = ((t % 1000) / 100) | 0; return `${m}:${String(s).padStart(2, "0")}.${d}`; }; function mulberry32(seed) { let a = seed >>> 0; return () => { 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 store = { get(k, d) { try { const v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch { return d; } }, set(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }, }; /* ---------------- canvas ---------------- */ const cv = $("cv"), ctx = cv.getContext("2d"); let CW = 0, CH = 0; function resize() { const dpr = Math.min(window.devicePixelRatio || 1, 2); CW = window.innerWidth; CH = window.innerHeight; cv.width = Math.round(CW * dpr); cv.height = Math.round(CH * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); if (game) relayout(); } window.addEventListener("resize", resize); /* ---------------- audio (lazy WebAudio synth) ---------------- */ let AC = null; let muted = store.get("pzMute", false); function ac() { if (!AC) { try { AC = new (window.AudioContext || window.webkitAudioContext)(); } catch {} } return AC; } function tone(freq, dur = 0.1, type = "sine", gain = 0.16, when = 0, slide = 0) { const a = ac(); if (!a || muted) return; const t0 = a.currentTime + when; const o = a.createOscillator(), g = a.createGain(); o.type = type; o.frequency.setValueAtTime(freq, t0); if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur); g.gain.setValueAtTime(gain, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur); o.connect(g).connect(a.destination); o.start(t0); o.stop(t0 + dur + 0.02); } const PENTA = [0, 2, 4, 7, 9, 12, 14, 16]; const sndSnap = (streak) => { const st = PENTA[clamp(streak - 1, 0, 7)]; tone(523 * Math.pow(2, st / 12), 0.09, "sine", 0.2); tone(1046 * Math.pow(2, st / 12), 0.07, "sine", 0.08, 0.03); }; const sndWrong = () => { tone(130, 0.12, "square", 0.1, 0, -40); tone(98, 0.16, "square", 0.08, 0.05, -30); }; const sndPick = () => tone(740, 0.04, "sine", 0.06); const sndRotate = () => tone(880, 0.05, "triangle", 0.08, 0, 120); const sndHint = () => { tone(988, 0.12, "sine", 0.1); tone(1318, 0.18, "sine", 0.1, 0.1); }; const sndWin = () => [0, 4, 7, 12, 16, 19, 24].forEach((st, i) => tone(523 * Math.pow(2, st / 12), 0.18, "triangle", 0.14, i * 0.09)); /* ---------------- canned content (offline fallbacks) ---------------- */ const TIPS = [ "Edge pieces first — every grandma is right.", "R / right-click / double-tap rotates a piece.", "Hints flash the right slot… and cost +15s.", "Snap streaks make the mascot lose its mind.", "Share your puzzle so friends can race your time.", "👁 toggles the ghost preview on the board.", ]; const CHIPS = ["a lighthouse in a storm", "a dragon's treasure cave", "street food market in Tokyo", "an astronaut's garden on Mars", "a fox in an autumn forest", "underwater ruins with whales"]; const CANNED = { generic: ["Edges first. Trust the process.", "That one? Sky. Probably sky.", "You're doing great. Ish.", "My grandma solves faster. She's a bird.", "Ooh, bold choice.", "The corners are RIGHT THERE."], streak: ["You're on FIRE!", "Okay okay okay, show-off!", "Combo! Keep it rolling!", "Unstoppable!! 🔥"], wrong: ["Nope. Not even close.", "Force it harder, that always works.", "That piece says ouch.", "Wrong hole, friend."], idle: ["You good over there?", "Blink twice if you're stuck.", "The pieces won't place themselves.", "I've seen glaciers move faster."], m25: ["A quarter down already!", "25% — warming up!"], m50: ["Halfway! The picture's coming alive!", "50%! Downhill from here!"], m75: ["75%! Sprint finish!", "Almost there — don't choke now!"], hint: ["Check the flashing spot. You're welcome.", "Right there. The glowy bit. Go."], victory: ["DONE! Frame it. Sell it. Retire.", "Puzzle destroyed. Magnificent."], }; const DEMOS = [ { pid: "demo-1", title: "Sunset Peaks", desc: "Starlit peaks over a blazing sunset.", subject: "a starry sunset over jagged mountain peaks and pine forest", voice: "a dramatic nature documentary narrator", mascot_name: "Sunny", image: "assets/fallback1.jpg", mascot: "assets/mascot.png", quips: ["Behold… the mountains.", "The pines all look alike. Hee hee.", "Majestic. Unlike your pace.", "That star piece? Could be anywhere.", "The sun sets on your indecision.", "Nature is patient. I am not."], victory: "And so, the sun sets on a flawless ascent." }, { pid: "demo-2", title: "Neon Tides", desc: "Synthwave waves under a city moon.", subject: "neon synthwave waves below a moonlit city skyline", voice: "a glitchy retro arcade announcer", mascot_name: "Volt", image: "assets/fallback2.jpg", mascot: "assets/mascot.png", quips: ["INSERT COIN. And skill.", "The waves are ALL the same. Enjoy.", "New high score? Doubt it.", "Neon never lies. You might.", "Moon piece. Easy. The rest? Ha.", "Lag detected. Oh wait, that's you."], victory: "HIGH SCORE! The grid bows to you." }, { pid: "demo-3", title: "Bloom Meadow", desc: "A sunny meadow drowning in flowers.", subject: "a bright flower meadow with butterflies under a summer sky", voice: "an overly cheerful garden gnome", mascot_name: "Petal", image: "assets/fallback3.jpg", mascot: "assets/mascot.png", quips: ["Every flower is a friend!", "The white ones are SNEAKY.", "Butterflies judge slow solvers.", "Smell that? Progress!", "A weed! Just kidding. Or am I?", "Sunshine and edge pieces!"], victory: "The meadow blooms — and so do you!" }, ]; const GRIDS = { 12: [4, 3], 24: [6, 4], 48: [8, 6], 96: [12, 8] }; /* ---------------- network ---------------- */ let quipDown = false; async function apiQuip(messages, maxTok = 60, temp = 1.0, timeout = 12000) { if (quipDown) return ""; try { const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), timeout); const r = await fetch("../quip", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ messages, max_tokens: maxTok, temperature: temp }), signal: ctrl.signal, }); clearTimeout(t); const j = await r.json(); if (j.error && !j.text) { quipDown = true; return ""; } return (j.text || "").trim().replace(/^"|"$/g, ""); } catch { return ""; } } async function apiJson(path, opts) { try { const r = await fetch(path, opts); return await r.json(); } catch (e) { return { error: String(e) }; } } /* --- ZeroGPU bridge: generation must run as a real gradio event on the outer page (see app.py). Detect the bridge with a ping; fall back to /generate. */ let bridgeOk = null; function detectBridge() { return new Promise((res) => { if (window.parent === window) return res(false); let done = false; const onMsg = (ev) => { if (ev.data && ev.data.type === "ajp-pong") { done = true; window.removeEventListener("message", onMsg); res(true); } }; window.addEventListener("message", onMsg); try { window.parent.postMessage({ type: "ajp-ping" }, "*"); } catch {} setTimeout(() => { if (!done) { window.removeEventListener("message", onMsg); res(false); } }, 1500); }); } function bridgeGenerate(subject) { return new Promise((res) => { const onMsg = (ev) => { const d = ev.data || {}; if (d.type !== "ajp-gen-result") return; window.removeEventListener("message", onMsg); res(d.data || { error: d.error || "generation failed" }); }; window.addEventListener("message", onMsg); try { window.parent.postMessage({ type: "ajp-gen", subject }, "*"); } catch { res({ error: "no bridge" }); } setTimeout(() => { window.removeEventListener("message", onMsg); res({ error: "generation timed out" }); }, 250000); }); } async function generatePuzzle(subject) { if (bridgeOk === null) bridgeOk = await detectBridge(); if (bridgeOk) { const r = await bridgeGenerate(subject); if (!r.error || r.data) return r; } return apiJson("../generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subject }), }); } /* ---------------- screens ---------------- */ const SCREENS = ["home", "gallery", "loading", "complete"]; function show(id) { SCREENS.forEach((s) => $(s).classList.toggle("hidden", s !== id)); $("hud").classList.toggle("hidden", id !== null); } function playMode() { SCREENS.forEach((s) => $(s).classList.add("hidden")); $("hud").classList.remove("hidden"); } /* ============================================================================ JIGSAW CUTTING — every interior edge gets a randomized bezier tab; the two neighbouring pieces share the exact same world-space curve, so they mate. ========================================================================= */ function edgeSegs(x0, y0, x1, y1, rng) { const dx = x1 - x0, dy = y1 - y0; // along the edge const nx = -dy, ny = dx; // perpendicular (tab direction for s=+1) const s = rng() < 0.5 ? 1 : -1; const j = () => (rng() * 2 - 1) * 0.018; const P = (u, v) => [x0 + u * dx + v * s * nx, y0 + u * dy + v * s * ny]; // endpoints of the 6 cubic segments (shared so the chain stays continuous) const e1 = [0.40 + j(), -0.06 + j()], e2 = [0.43 + j(), 0.12 + j()], e3 = [0.50 + j() * 2, 0.29 + j()]; const e4 = [0.57 + j(), 0.12 + j()], e5 = [0.60 + j(), -0.06 + j()]; const segs = [ [P(0.18, 0.01), P(0.30, e1[1]), P(e1[0], e1[1])], // run-in, slight dip [P(e1[0] + 0.06, e1[1]), P(e2[0] - 0.05, 0.05), P(e2[0], e2[1])], // pinch into the neck [P(e2[0] - 0.07, 0.21), P(e3[0] - 0.09, e3[1] + 0.02), P(e3[0], e3[1])],// around the bump (left) [P(e4[0] + 0.02, e3[1] + 0.02), P(e4[0] + 0.07, 0.21), P(e4[0], e4[1])],// around the bump (right) [P(e4[0] + 0.05, 0.05), P(e5[0] - 0.06, e5[1]), P(e5[0], e5[1])], // out of the neck [P(0.70, e5[1]), P(0.82, 0.01), P(1, 0)], // run-out ]; return { p0: [x0, y0], segs }; } function appendEdge(path, edge, reverse, ox, oy) { if (!reverse) { for (const [c1, c2, e] of edge.segs) path.bezierCurveTo(c1[0] + ox, c1[1] + oy, c2[0] + ox, c2[1] + oy, e[0] + ox, e[1] + oy); } else { const pts = [edge.p0, ...edge.segs.map((s) => s[2])]; for (let i = edge.segs.length - 1; i >= 0; i--) { const [c1, c2] = edge.segs[i]; path.bezierCurveTo(c2[0] + ox, c2[1] + oy, c1[0] + ox, c1[1] + oy, pts[i][0] + ox, pts[i][1] + oy); } } } function cutImage(img, rows, cols) { const iw = img.naturalWidth || img.width, ih = img.naturalHeight || img.height; const cw = iw / cols, ch = ih / rows; const pad = Math.ceil(Math.max(cw, ch) * 0.36); const rng = mulberry32((Math.random() * 1e9) | 0); const hE = [], vE = []; for (let r = 1; r < rows; r++) { hE[r] = []; for (let c = 0; c < cols; c++) hE[r][c] = edgeSegs(c * cw, r * ch, (c + 1) * cw, r * ch, rng); } for (let c = 1; c < cols; c++) { vE[c] = []; for (let r = 0; r < rows; r++) vE[c][r] = edgeSegs(c * cw, r * ch, c * cw, (r + 1) * ch, rng); } const pieces = []; for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) { const ox = pad - c * cw, oy = pad - r * ch; // world -> piece-local offset const x0 = c * cw, y0 = r * ch, x1 = x0 + cw, y1 = y0 + ch; const path = new Path2D(); path.moveTo(x0 + ox, y0 + oy); r === 0 ? path.lineTo(x1 + ox, y0 + oy) : appendEdge(path, hE[r][c], false, ox, oy); c === cols - 1 ? path.lineTo(x1 + ox, y1 + oy) : appendEdge(path, vE[c + 1][r], false, ox, oy); r === rows - 1 ? path.lineTo(x0 + ox, y1 + oy) : appendEdge(path, hE[r + 1][c], true, ox, oy); c === 0 ? path.closePath() : appendEdge(path, vE[c][r], true, ox, oy); path.closePath(); const can = document.createElement("canvas"); can.width = Math.ceil(cw + 2 * pad); can.height = Math.ceil(ch + 2 * pad); const pc = can.getContext("2d"); pc.save(); pc.clip(path); pc.drawImage(img, ox, oy); pc.restore(); pc.lineJoin = "round"; pc.strokeStyle = "rgba(255,255,255,0.22)"; pc.lineWidth = 3; pc.stroke(path); pc.strokeStyle = "rgba(0,0,0,0.45)"; pc.lineWidth = 1.6; pc.stroke(path); const piece = { r, c, canvas: can, pctx: pc, path, pad, cw, ch, x: 0, y: 0, rot: 0, dispRot: 0, z: 0, placed: false, wrongs: 0, tw: null, grp: null }; piece.grp = [piece]; pieces.push(piece); } return { pieces, iw, ih, cw, ch, pad }; } /* ============================================================================ GAME STATE ========================================================================= */ let pack = null; // current puzzle pack (title, voice, image, mascot, pid?, mine?) let game = null; // live board state let zTop = 1; let raf = 0, lastT = 0; let particles = []; let lastResult = null; function relayout() { if (!game) return; const availW = CW - 40, availH = CH - 120; let bh = Math.min(availH * 0.76, (availW * 0.58) * (game.ih / game.iw)); let bw = bh * (game.iw / game.ih); if (bw > availW * 0.9) { bw = availW * 0.9; bh = bw * (game.ih / game.iw); } const bx = (CW - bw) / 2, by = 78 + (availH - bh) / 2 * 0.7; game.board = { x: bx, y: by, w: bw, h: bh }; game.scale = bw / game.iw; const seen = new Set(); for (const p of game.pieces) { if (p.placed) { const s = slotCenter(p); p.x = s.x; p.y = s.y; continue; } if (seen.has(p.grp)) continue; seen.add(p.grp); realignGroup(p); // group offsets depend on scale const ax = clamp(p.x, 30, CW - 30) - p.x, ay = clamp(p.y, 80, CH - 30) - p.y; for (const m of p.grp) { m.x += ax; m.y += ay; } } mascotHome(); } function slotCenter(p) { const b = game.board; return { x: b.x + (p.c + 0.5) * game.cw * game.scale, y: b.y + (p.r + 0.5) * game.ch * game.scale }; } function startGame(newPack, pieces, rotMode) { pack = newPack; playMode(); $("loadTitle").textContent = "CUTTING THE PIECES…"; const img = new Image(); img.onload = () => { const [cols, rows] = GRIDS[pieces] || GRIDS[24]; const cut = cutImage(img, rows, cols); game = { img, rows, cols, total: rows * cols, pieces: cut.pieces, iw: cut.iw, ih: cut.ih, cw: cut.cw, ch: cut.ch, pad: cut.pad, rotMode, nPieces: pieces, placed: 0, moves: 0, hints: 0, streak: 0, lastSnap: 0, milestones: {}, t0: 0, penalty: 0, done: false, doneAt: 0, fade: 0, drag: null, hintFlash: null, lastAct: performance.now(), idleCount: 0, board: null, scale: 1, byRC: {}, }; for (const p of cut.pieces) game.byRC[p.r * cols + p.c] = p; zTop = 1; particles = []; relayout(); scatter(); $("rotBtn").classList.toggle("hidden", !rotMode); $("prog").textContent = `0 / ${game.total}`; $("timer").textContent = "0:00.0"; $("streak").classList.add("hidden"); mascotInit(); quips.reset(); setTimeout(() => say(`Let's go, ${playerName()}! 🧩`), 900); setTimeout(() => say(pack.quips && pack.quips[0] || "Edges first. Trust me."), 4200); if (!raf) { lastT = performance.now(); raf = requestAnimationFrame(loop); } }; img.onerror = () => { show("home"); $("homeMsg").textContent = "couldn't load the puzzle image — try again"; }; img.src = pack.image; } function scatter() { const b = game.board; const cx = b.x + b.w / 2, cy = b.y + b.h / 2; for (const p of game.pieces) { let x, y, tries = 0; do { x = rnd(50, CW - 50); y = rnd(95, CH - 45); tries++; } while (tries < 24 && Math.abs(x - cx) < b.w * 0.34 && Math.abs(y - cy) < b.h * 0.34); p.x = x; p.y = y; p.z = zTop++; p.rot = game.rotMode ? (Math.random() * 4) | 0 : 0; p.dispRot = p.rot * 90; p.placed = false; p.wrongs = 0; p.tw = null; p.grp = [p]; // every piece starts as its own cluster } } /* clusters: members are kept EXACTLY aligned relative to each other; drags move them by a common delta and rotations are rigid, so realigning from any anchor restores exactness after resizes or interrupted tweens. */ function realignGroup(anchor) { const th = (anchor.rot * Math.PI) / 2, cos = Math.cos(th), sin = Math.sin(th); for (const m of anchor.grp) { if (m === anchor) continue; const dx = (m.c - anchor.c) * game.cw * game.scale, dy = (m.r - anchor.r) * game.ch * game.scale; m.x = anchor.x + dx * cos - dy * sin; m.y = anchor.y + dx * sin + dy * cos; m.rot = anchor.rot; } } /* ---------------- input ---------------- */ let lastTapT = 0, lastTapP = null; function canvasPos(ev) { const r = cv.getBoundingClientRect(); return { x: ev.clientX - r.left, y: ev.clientY - r.top }; } function pieceAt(x, y) { const loose = game.pieces.filter((p) => !p.placed).sort((a, b) => b.z - a.z); for (const p of loose) { const ang = (-p.dispRot * Math.PI) / 180; const dx = x - p.x, dy = y - p.y; const rx = dx * Math.cos(ang) - dy * Math.sin(ang), ry = dx * Math.sin(ang) + dy * Math.cos(ang); const lx = rx / game.scale + p.pad + p.cw / 2, ly = ry / game.scale + p.pad + p.ch / 2; if (lx >= 0 && ly >= 0 && lx <= p.canvas.width && ly <= p.canvas.height && p.pctx.isPointInPath(p.path, lx, ly)) return p; } return null; } cv.addEventListener("pointerdown", (ev) => { if (!game || game.done) return; ac() && AC.state === "suspended" && AC.resume(); const { x, y } = canvasPos(ev); const p = pieceAt(x, y); game.lastAct = performance.now(); if (!p) return; if (ev.button === 2) { rotatePiece(p); return; } // double-tap rotate (touch) const now = performance.now(); if (ev.pointerType === "touch" && p === lastTapP && now - lastTapT < 320 && game.rotMode) { rotatePiece(p); lastTapT = 0; return; } lastTapT = now; lastTapP = p; if (!game.t0) game.t0 = now; // ⏱ the clock starts on first grab for (const m of p.grp) { m.tw = null; m.z = zTop++; } realignGroup(p); // in case a join tween was interrupted game.drag = { p, lastX: x, lastY: y, offs: p.grp.map((m) => ({ m, ox: m.x - x, oy: m.y - y })) }; sndPick(); cv.setPointerCapture(ev.pointerId); }); cv.addEventListener("pointermove", (ev) => { if (!game || !game.drag) return; const { x, y } = canvasPos(ev); for (const o of game.drag.offs) { o.m.x = x + o.ox; o.m.y = y + o.oy; } game.drag.lastX = x; game.drag.lastY = y; game.lastAct = performance.now(); }); cv.addEventListener("pointerup", (ev) => { if (!game || !game.drag) return; const p = game.drag.p; game.drag = null; game.moves++; tryPlace(p); }); cv.addEventListener("pointercancel", () => { if (game) game.drag = null; }); cv.addEventListener("contextmenu", (ev) => { ev.preventDefault(); if (!game || game.done || !game.rotMode) return; const { x, y } = canvasPos(ev); const p = (game.drag && game.drag.p) || pieceAt(x, y); if (p) rotatePiece(p); }); window.addEventListener("keydown", (ev) => { if (ev.key === "m" || ev.key === "M") return toggleMute(); if (!game || game.done) return; if ((ev.key === "r" || ev.key === "R") && game.rotMode) { const p = (game.drag && game.drag.p) || lastTapP; if (p && !p.placed) rotatePiece(p); } if (ev.key === "p" || ev.key === "P") toggleGhost(); }); function rotatePiece(p) { if (p.placed || !game.rotMode) return; for (const m of p.grp) { // rigid 90° turn of the whole cluster around p if (m !== p) { const vx = m.x - p.x, vy = m.y - p.y; m.x = p.x - vy; m.y = p.y + vx; } m.rot = (m.rot + 1) % 4; m.tw = null; } if (game.drag && game.drag.p.grp === p.grp) { // keep the grip point under the finger const { lastX, lastY } = game.drag; game.drag.offs = p.grp.map((m) => ({ m, ox: m.x - lastX, oy: m.y - lastY })); } sndRotate(); } function tryPlace(p) { const grp = p.grp; const tol = Math.max(22, game.cw * game.scale * 0.32); // 1) board snap — if any member of the cluster is over its own slot, the // whole cluster locks in at once if (p.rot === 0) { for (const m of grp) { const s = slotCenter(m); if (dist(m.x, m.y, s.x, s.y) < tol) { placeGroup(grp, m); return; } } } // 2) piece-to-piece — correct neighbours STICK together anywhere on the table // (same orientation, dropped at the right relative offset) const th = (p.rot * Math.PI) / 2, cos = Math.cos(th), sin = Math.sin(th); for (const m of grp) { for (const [dr, dc] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { const nr = m.r + dr, nc = m.c + dc; if (nr < 0 || nr >= game.rows || nc < 0 || nc >= game.cols) continue; const q = game.byRC[nr * game.cols + nc]; if (!q || q.placed || q.grp === grp || q.rot !== p.rot) continue; const dx = (m.c - q.c) * game.cw * game.scale, dy = (m.r - q.r) * game.ch * game.scale; const ex = q.x + dx * cos - dy * sin, ey = q.y + dx * sin + dy * cos; if (dist(m.x, m.y, ex, ey) < tol) { joinGroups(grp, q, m, ex, ey); return; } } } // 3) a "wrong fit" is a deliberate-looking attempt, not just sorting pieces on // the board: dead-on its own slot at the wrong angle, or someone else's slot const sOwn = slotCenter(p); if (dist(p.x, p.y, sOwn.x, sOwn.y) < tol && p.rot !== 0) { wrongDrop(p); return; } const b = game.board; if (grp.length === 1 && p.x > b.x && p.x < b.x + b.w && p.y > b.y && p.y < b.y + b.h) { const sw = game.cw * game.scale, sh = game.ch * game.scale; const nc = clamp(Math.round((p.x - b.x) / sw - 0.5), 0, game.cols - 1); const nr = clamp(Math.round((p.y - b.y) / sh - 0.5), 0, game.rows - 1); const ncx = b.x + (nc + 0.5) * sw, ncy = b.y + (nr + 0.5) * sh; if (dist(p.x, p.y, ncx, ncy) < tol * 0.45 && !(nr === p.r && nc === p.c)) wrongDrop(p); } } function joinGroups(moving, q, viaPiece, ex, ey) { // glide the moving cluster onto exact alignment with the stationary one const ddx = ex - viaPiece.x, ddy = ey - viaPiece.y; const now = performance.now(); for (const m of moving) { m.tw = { x0: m.x, y0: m.y, x1: m.x + ddx, y1: m.y + ddy, t0: now, d: 120 }; } const merged = q.grp; merged.push(...moving); for (const m of moving) m.grp = merged; game.streak = now - game.lastSnap < 7000 ? game.streak + 1 : 1; game.lastSnap = now; sndSnap(game.streak); burst(ex, ey, 12, ["#7ad7ff", "#c79bff", "#ffd76b"]); mascotHopTo(ex, ey - 10); const st = $("streak"); if (game.streak >= 3) { st.textContent = `🔥 x${game.streak}`; st.classList.remove("hidden"); } if (Math.random() < 0.18) quips.generic(); if (merged.length === game.total) { // built the whole thing on the table! for (const m of game.pieces) { m.rot = 0; m.placed = true; const s = slotCenter(m); m.tw = { x0: m.x, y0: m.y, x1: s.x, y1: s.y, t0: now, d: 460 }; m.x = s.x; m.y = s.y; } game.placed = game.total; $("prog").textContent = `${game.total} / ${game.total}`; finishGame(); } } function wrongDrop(p) { p.wrongs++; sndWrong(); mascotSad(); if (p.wrongs > 0 && p.wrongs % 3 === 0) { quips.event("wrong", `The player jammed the same wrong piece in for the ${p.wrongs}th time.`); } } function placeGroup(grp, via) { const now = performance.now(); for (const m of grp) { m.placed = true; const s = slotCenter(m); m.tw = { x0: m.x, y0: m.y, x1: s.x, y1: s.y, t0: now, d: 130 }; } game.placed += grp.length; $("prog").textContent = `${game.placed} / ${game.total}`; game.streak = now - game.lastSnap < 7000 ? game.streak + 1 : 1; game.lastSnap = now; sndSnap(game.streak); const sv = slotCenter(via); burst(sv.x, sv.y, 12 + grp.length * 2, ["#ffd76b", "#c79bff", "#7be0a2"]); mascotHopTo(sv.x, sv.y - 10); const st = $("streak"); if (game.streak >= 3) { st.textContent = `🔥 x${game.streak}`; st.classList.remove("hidden"); } else st.classList.add("hidden"); if (game.streak === 3 || game.streak === 5 || game.streak === 8 || game.streak === 12) { quips.canned("streak"); quips.event("streak", `The player just snapped ${game.streak} pieces in a row, fast. Hype them up.`); mascotCelebrate(1); } if (grp.length >= 4) { quips.event("cluster", `The player slammed a ${grp.length}-piece chunk into the board in one go. React in one line.`); } const pct = (game.placed / game.total) * 100; for (const m of [25, 50, 75]) { if (pct >= m && !game.milestones[m]) { game.milestones[m] = true; quips.canned("m" + m); quips.event("milestone", `The puzzle is now ${m}% complete. React in one line.`); burst(game.board.x + game.board.w / 2, game.board.y - 8, 22, ["#ffd76b", "#ff9d5c"]); break; } } if (game.streak < 3 && Math.random() < 0.14) quips.generic(); if (game.placed === game.total) finishGame(); } /* ---------------- hints (+15s on the clock) ---------------- */ const REGION_V = ["top", "middle", "bottom"], REGION_H = ["left", "center", "right"]; function regionName(p) { const v = REGION_V[Math.min(2, Math.floor((p.r / game.rows) * 3))]; const h = REGION_H[Math.min(2, Math.floor((p.c / game.cols) * 3))]; return v === "middle" && h === "center" ? "dead center" : `${v}-${h}`; } $("hintBtn").addEventListener("click", () => { if (!game || game.done) return; const loose = game.pieces.filter((p) => !p.placed); if (!loose.length) return; const p = loose.sort((a, b) => b.wrongs - a.wrongs)[0].wrongs > 0 ? loose.sort((a, b) => b.wrongs - a.wrongs)[0] : pick(loose); if (!game.t0) game.t0 = performance.now(); game.penalty += 15000; game.hints++; game.hintFlash = { p, until: performance.now() + 3600 }; sndHint(); quips.canned("hint"); quips.event("hint", `The player bought a hint (+15s penalty). The flashing piece belongs in the ${regionName(p)} of the picture ("${pack.subject}"). One line telling them where to look, in character.`); }); /* ---------------- ghost preview ---------------- */ let ghostOn = store.get("pzGhost", true); function toggleGhost() { ghostOn = !ghostOn; store.set("pzGhost", ghostOn); $("ghostBtn").classList.toggle("on", ghostOn); } $("ghostBtn").addEventListener("click", toggleGhost); $("ghostBtn").classList.toggle("on", ghostOn); $("rotBtn").addEventListener("click", () => { const p = (game && game.drag && game.drag.p) || lastTapP; if (p && !p.placed) rotatePiece(p); }); /* ============================================================================ MASCOT — a themed sprite that perches by the board, hops onto pieces you place, celebrates streaks, slumps at wrong fits and does all the talking. ========================================================================= */ const mascot = { img: null, x: 0, y: 0, hx: 0, hy: 0, h: 72, w: 60, flip: 1, jumps: [], jump: null, squash: 0, spin: 0, sad: 0, bobT: 0, wanderT: 4 }; function mascotInit() { mascot.img = new Image(); mascot.img.src = pack.mascot || "assets/mascot.png"; mascot.img.onload = () => { mascot.w = mascot.h * (mascot.img.width / mascot.img.height); }; mascotHome(); mascot.x = -60; mascot.y = mascot.hy; mascot.jumps = [{ tx: mascot.hx, ty: mascot.hy, T: 0.7 }]; mascot.jump = null; } function mascotHome() { if (!game) return; mascot.hx = clamp(game.board.x - 56, 40, CW - 40); mascot.hy = game.board.y + game.board.h + 4; } function mascotHopTo(x, y, back = true) { mascot.jumps = [{ tx: x, ty: y, T: 0.5 }]; if (back) mascot.jumps.push({ tx: mascot.hx, ty: mascot.hy, T: 0.55, delay: 1.1 }); } function mascotCelebrate(n = 2) { const js = []; for (let i = 0; i < n + 1; i++) js.push({ tx: mascot.x + rnd(-30, 30), ty: mascot.y, T: 0.34, spin: i % 2 ? 1 : 0 }); js.push({ tx: mascot.hx, ty: mascot.hy, T: 0.5 }); mascot.jumps = js; } function mascotSad() { mascot.sad = 1; } function mascotUpdate(dt) { mascot.bobT += dt; if (mascot.sad > 0) mascot.sad = Math.max(0, mascot.sad - dt * 1.5); if (!mascot.jump && mascot.jumps.length) { const j = mascot.jumps[0]; if (j.delay && j.delay > 0) { j.delay -= dt; } else { mascot.jumps.shift(); mascot.jump = { x0: mascot.x, y0: mascot.y, ...j, t: 0 }; mascot.flip = j.tx >= mascot.x ? 1 : -1; } } if (mascot.jump) { const j = mascot.jump; j.t += dt; const t = Math.min(1, j.t / j.T); mascot.x = lerp(j.x0, j.tx, t); mascot.y = lerp(j.y0, j.ty, t) - Math.sin(t * Math.PI) * 64; if (j.spin) mascot.spin = t * Math.PI * 2 * mascot.flip; if (t >= 1) { mascot.jump = null; mascot.squash = 0.32; mascot.spin = 0; } } else { mascot.squash = Math.max(0, mascot.squash - dt * 2.2); mascot.wanderT -= dt; if (mascot.wanderT <= 0 && !game.done) { // little idle hops near its perch mascot.wanderT = rnd(4, 9); mascot.jumps.push({ tx: clamp(mascot.hx + rnd(-46, 46), 30, CW - 30), ty: mascot.hy, T: 0.4 }); } } } function mascotDraw() { if (!mascot.img || !mascot.img.complete || !mascot.img.naturalWidth) return; const bob = mascot.jump ? 0 : Math.sin(mascot.bobT * 3.1) * 2.5; const sq = mascot.squash; const sx = 1 + sq * 0.55, sy = 1 - sq * 0.45; ctx.save(); ctx.translate(mascot.x, mascot.y + bob); if (mascot.sad > 0) ctx.rotate(0.25 * mascot.sad * mascot.flip); if (mascot.spin) ctx.rotate(mascot.spin); ctx.scale(mascot.flip * sx, sy); ctx.drawImage(mascot.img, -mascot.w / 2, -mascot.h, mascot.w, mascot.h); ctx.restore(); } /* ---------------- speech bubble + quip engine ---------------- */ const bubbleEl = $("bubble"); let bubbleToken = 0, bubbleTimer = 0; function say(text, dur = 3000) { if (!text) return; const tok = ++bubbleToken; bubbleEl.textContent = text; bubbleEl.classList.remove("hidden"); clearTimeout(bubbleTimer); bubbleTimer = setTimeout(() => { if (tok === bubbleToken) bubbleEl.classList.add("hidden"); }, dur); } function bubbleFollow() { if (bubbleEl.classList.contains("hidden")) return; bubbleEl.style.left = clamp(mascot.x, 120, CW - 120) + "px"; bubbleEl.style.top = clamp(mascot.y - mascot.h - 12, 60, CH) + "px"; } const quips = { buf: [], refilling: false, reset() { this.buf = (pack.quips || []).slice(1); }, generic() { say(this.buf.length ? this.buf.shift() : pick(CANNED.generic)); if (this.buf.length < 3) this.refill(); }, canned(kind) { say(pick(CANNED[kind] || CANNED.generic)); }, async refill() { if (this.refilling || quipDown || !pack) return; this.refilling = true; const txt = await apiQuip([{ role: "user", content: `You are ${pack.voice || "a cheeky mascot"} — ${pack.mascot_name || "the mascot"}, a tiny creature perched beside a jigsaw ` + `puzzle of "${pack.subject}". The player, "${playerName()}", is mid-solve. Write 10 SHORT punchy lines (3-8 words each): a mix of hype, ` + `playful roasts about their pace (use their name in a couple of lines), and teases about tricky parts of the picture. ` + `One per line. No numbering, no quotes, no emoji.` }], 170, 1.2); const lines = txt.split("\n").map((s) => s.replace(/^[\s\-\d\.\)]+/, "").trim()).filter((s) => s && s.length <= 42); this.buf.push(...lines.slice(0, 10)); this.refilling = false; }, async event(kind, eventText) { // live one-shot reaction (canned line already showing) const sentAt = performance.now(); const txt = await apiQuip([ { role: "system", content: `You are ${pack.voice || "a cheeky mascot"}, a tiny mascot beside a jigsaw puzzle of "${pack.subject}", commentating live on the player "${playerName()}". One punchy line, max 12 words, no quotes, no emoji.` }, { role: "user", content: eventText }], 46, 1.05); if (txt && performance.now() - sentAt < 9000) say(txt); }, }; /* ---------------- particles ---------------- */ function burst(x, y, n, colors) { for (let i = 0; i < n; i++) { const a = rnd(0, Math.PI * 2), v = rnd(60, 260); particles.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 60, life: rnd(0.5, 1), t: 0, c: pick(colors), s: rnd(3, 6), g: 420 }); } } function confetti() { for (let i = 0; i < 150; i++) { particles.push({ x: rnd(0, CW), y: rnd(-CH * 0.3, 0), vx: rnd(-40, 40), vy: rnd(40, 160), life: rnd(1.6, 3), t: 0, c: pick(["#ffd76b", "#c79bff", "#7be0a2", "#ff7da0", "#7ad7ff"]), s: rnd(4, 8), g: 60, flake: true }); } } /* ============================================================================ RENDER LOOP ========================================================================= */ function loop(t) { raf = requestAnimationFrame(loop); const dt = Math.min(0.05, (t - lastT) / 1000); lastT = t; if (!game) return; ctx.clearRect(0, 0, CW, CH); const b = game.board; // table mat + board well rounded(b.x - 14, b.y - 14, b.w + 28, b.h + 28, 18); ctx.fillStyle = "rgba(0,0,0,0.35)"; ctx.fill(); ctx.strokeStyle = "rgba(160,107,255,0.35)"; ctx.lineWidth = 2; ctx.stroke(); rounded(b.x, b.y, b.w, b.h, 6); ctx.fillStyle = "rgba(255,255,255,0.04)"; ctx.fill(); if (ghostOn && !game.done) { ctx.save(); ctx.globalAlpha = 0.14; ctx.drawImage(game.img, b.x, b.y, b.w, b.h); ctx.restore(); } // faint slot grid if (!game.done) { ctx.strokeStyle = "rgba(255,255,255,0.06)"; ctx.lineWidth = 1; ctx.beginPath(); for (let c = 1; c < game.cols; c++) { const x = b.x + c * game.cw * game.scale; ctx.moveTo(x, b.y); ctx.lineTo(x, b.y + b.h); } for (let r = 1; r < game.rows; r++) { const y = b.y + r * game.ch * game.scale; ctx.moveTo(b.x, y); ctx.lineTo(b.x + b.w, y); } ctx.stroke(); } // hint flash if (game.hintFlash) { const hf = game.hintFlash; if (t > hf.until) game.hintFlash = null; else { const pl = (Math.sin(t / 110) + 1) / 2; const p = hf.p, sw = game.cw * game.scale, sh = game.ch * game.scale; ctx.save(); ctx.strokeStyle = `rgba(255,215,107,${0.35 + pl * 0.6})`; ctx.lineWidth = 3 + pl * 2; ctx.strokeRect(b.x + p.c * sw + 2, b.y + p.r * sh + 2, sw - 4, sh - 4); ctx.beginPath(); ctx.arc(p.x, p.y, 34 + pl * 10, 0, Math.PI * 2); ctx.strokeStyle = `rgba(255,215,107,${0.25 + pl * 0.45})`; ctx.stroke(); ctx.restore(); } } // pieces — placed first, then loose by z, dragged on top for (const p of game.pieces) { if (p.tw) tween(p, t); } for (const p of game.pieces) if (p.placed) drawPiece(p, false); const loose = game.pieces.filter((p) => !p.placed).sort((a, b) => a.z - b.z); for (const p of loose) drawPiece(p, game.drag && game.drag.p === p); // completed: fade the seams away into the full artwork if (game.done) { game.fade = Math.min(1, game.fade + dt * 0.6); ctx.save(); ctx.globalAlpha = game.fade; ctx.drawImage(game.img, b.x, b.y, b.w, b.h); ctx.restore(); if (game.fade > 0.95) { ctx.save(); ctx.strokeStyle = "rgba(255,215,107,0.8)"; ctx.lineWidth = 4; rounded(b.x - 4, b.y - 4, b.w + 8, b.h + 8, 8); ctx.stroke(); ctx.restore(); } } // particles particles = particles.filter((pt) => (pt.t += dt) < pt.life); for (const pt of particles) { pt.vy += pt.g * dt; pt.x += pt.vx * dt; pt.y += pt.vy * dt; ctx.globalAlpha = 1 - pt.t / pt.life; ctx.fillStyle = pt.c; if (pt.flake) { ctx.save(); ctx.translate(pt.x, pt.y); ctx.rotate(pt.t * 6); ctx.fillRect(-pt.s / 2, -pt.s / 4, pt.s, pt.s / 2); ctx.restore(); } else ctx.fillRect(pt.x - pt.s / 2, pt.y - pt.s / 2, pt.s, pt.s); } ctx.globalAlpha = 1; mascotUpdate(dt); mascotDraw(); bubbleFollow(); // HUD timer + idle nudges if (game.t0 && !game.done) { $("timer").textContent = fmtMs(t - game.t0 + game.penalty); if (t - game.lastAct > 45000 && game.idleCount < 3) { game.idleCount++; game.lastAct = t; quips.canned("idle"); quips.event("idle", "The player hasn't touched a piece in 45 seconds. Tease them in one line."); } } } function tween(p, t) { const k = clamp((t - p.tw.t0) / p.tw.d, 0, 1); const e = 1 - (1 - k) * (1 - k); p.x = lerp(p.tw.x0, p.tw.x1, e); p.y = lerp(p.tw.y0, p.tw.y1, e); if (k >= 1) p.tw = null; } function drawPiece(p, dragged) { const target = p.rot * 90; if (p.dispRot !== target) { let d = target - p.dispRot; while (d > 180) d -= 360; while (d < -180) d += 360; p.dispRot += d * 0.35; if (Math.abs(d) < 1) p.dispRot = target; } const s = game.scale * (dragged ? 1.045 : 1); ctx.save(); ctx.translate(p.x, p.y + (dragged ? -3 : 0)); ctx.rotate((p.dispRot * Math.PI) / 180); ctx.scale(s, s); if (dragged) { ctx.shadowColor = "rgba(0,0,0,0.55)"; ctx.shadowBlur = 18; ctx.shadowOffsetY = 8; } else if (!p.placed) { ctx.shadowColor = "rgba(0,0,0,0.4)"; ctx.shadowBlur = 6; ctx.shadowOffsetY = 3; } ctx.drawImage(p.canvas, -(p.pad + p.cw / 2), -(p.pad + p.ch / 2)); ctx.restore(); } function rounded(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } /* ============================================================================ COMPLETION — celebration, live victory line, leaderboard, sharing ========================================================================= */ function finishGame() { game.done = true; game.doneAt = performance.now(); const ms = Math.round(game.doneAt - game.t0 + game.penalty); lastResult = { ms, moves: game.moves, hints: game.hints, pieces: game.nPieces, rot: game.rotMode }; $("timer").textContent = fmtMs(ms); sndWin(); confetti(); mascotCelebrate(3); say(pack.victory || pick(CANNED.victory), 4000); // best-time bookkeeping (local) const bk = `pzBest:${pack.pid || "local"}:${game.nPieces}`; const prev = store.get(bk, 0); const isBest = !prev || ms < prev; if (isBest) store.set(bk, ms); setTimeout(() => showComplete(ms, isBest), 2800); } async function showComplete(ms, isBest) { $("cTitle").textContent = "🧩 SOLVED!"; $("cQuip").textContent = pack.victory || pick(CANNED.victory); $("cStats").innerHTML = `