// Pathmoor: the game screen (canvas board, timer, packs, archive, share, card, themes). (c) 2026 CyberMax. import { generate, isSolved, lit, turn, turnsTo, par, shareGrid, fmtTime, PACKS, bits } from './core.js'; import { today, dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js'; import * as pass from './funpass.js'; const GAME = 'pathmoor'; const PATH = '/games/pathmoor/'; const $ = (id) => document.getElementById(id); const canvas = $('board'); const ctx = canvas.getContext('2d'); const SKINS = { moor: { name: 'Moor', bg: '#16241E', stone: '#22362D', off: '#3E5A4C', on: '#FFC857', glow: 'rgba(255,200,87,.55)', lamp: '#FF8A3D', free: true }, ember: { name: 'Ember', bg: '#1E1412', stone: '#33201C', off: '#5B3A33', on: '#FF7A45', glow: 'rgba(255,122,69,.55)', lamp: '#FFD166' }, tide: { name: 'Tide', bg: '#0E1E2B', stone: '#173246', off: '#2F5470', on: '#6FF2E0', glow: 'rgba(111,242,224,.5)', lamp: '#FFFFFF' }, dusk: { name: 'Dusk', bg: '#1B1530', stone: '#2A2147', off: '#4A3D78', on: '#FF8FD8', glow: 'rgba(255,143,216,.5)', lamp: '#FFE08A' }, paper: { name: 'Paper', bg: '#E9DFCB', stone: '#FBF7EE', off: '#CDBFA4', on: '#E2581F', glow: 'rgba(226,88,31,.35)', lamp: '#11141B' }, }; const todayIso = today(); const todayNum = dayNumber(todayIso); let skin = SKINS[store('pathmoor:skin')] ? store('pathmoor:skin') : 'moor'; let mode; // { kind: 'daily', date } | { kind: 'pack', pack, level } let board, cur, taps, undoStack, ms, started, done, key, focus = -1, anim = {}, winAt = 0, timer; pass.init({ game: GAME, name: 'Pathmoor', perks: ['250 bigger boards: Moorland 7×7, Highland 8×8, Summit 10×10', 'Every past daily in the archive', 'Ember, Tide, Dusk and Paper board themes'] }); const install = pwa(GAME, $('install')); themeToggle($('theme')); // ------------------------------------------------------------------ modes function packOf(id) { return PACKS.find((p) => p.id === id); } function locked(m) { if (pass.isPro()) return false; if (m.kind === 'daily') return m.date !== todayIso; const p = packOf(m.pack); return !(p.freeLevels && m.level <= p.freeLevels); } function keyOf(m) { return m.kind === 'daily' ? `d:${m.date}` : `p:${m.pack}:${m.level}`; } function label(m) { return m.kind === 'daily' ? `#${dayNumber(m.date)}` : `${packOf(m.pack).name} · ${m.level}`; } function fromUrl() { const q = new URLSearchParams(location.search); const d = q.get('d'); if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso) return { kind: 'daily', date: d }; const p = packOf(q.get('p') || ''); const l = Number(q.get('l')); if (p && p.id !== 'daily' && l >= 1 && l <= p.levels) return { kind: 'pack', pack: p.id, level: l }; return { kind: 'daily', date: todayIso }; } function load(m, push = false) { if (locked(m)) { pass.open(m.kind === 'daily' ? 'archive' : m.pack); return false; } mode = m; key = keyOf(m); const n = m.kind === 'daily' ? 6 : packOf(m.pack).n; board = generate(m.kind === 'daily' ? m.date : `${m.pack}-${m.level}`, n); const saved = store(`pathmoor:b:${key}`); if (saved && saved.cur && saved.cur.length === n * n) { cur = saved.cur; taps = saved.taps || {}; ms = saved.ms || 0; done = !!saved.done; undoStack = []; } else { cur = board.start.slice(); taps = {}; ms = 0; done = false; undoStack = []; } started = 0; anim = {}; focus = -1; $('num').textContent = label(m); $('par').textContent = par(board); if (push) { const u = new URL(location.href); u.search = m.kind === 'daily' ? (m.date === todayIso ? '' : `?d=${m.date}`) : `?p=${m.pack}&l=${m.level}`; window.history.replaceState(null, '', u.pathname + u.search); } renderModes(); $('win').hidden = true; if (done) showWin(false); hud(); draw(); return true; } function renderModes() { const el = $('modes'); const chips = [{ m: { kind: 'daily', date: todayIso }, t: `Today #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso }]; for (const p of PACKS.filter((x) => x.id !== 'daily')) { const lvl = Math.min(p.levels, (store(`pathmoor:pack:${p.id}`) || 0) + 1); const m = { kind: 'pack', pack: p.id, level: lvl }; chips.push({ m, t: p.name, on: mode.kind === 'pack' && mode.pack === p.id, lock: locked(m) }); } chips.push({ archive: true, t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro() }); el.innerHTML = ''; chips.forEach((c) => { const b = document.createElement('button'); b.className = 'chip'; b.type = 'button'; b.setAttribute('aria-pressed', String(!!c.on)); b.innerHTML = `${c.t}${c.lock ? ' 🔒' : ''}`; b.onclick = () => (c.archive ? archive() : load(c.m, true)); el.appendChild(b); }); } function archive() { if (!pass.isPro()) { pass.open('archive'); return; } const n = todayNum - 1; if (n < 1) { toast('The archive fills up day by day. Today is #1, so come back tomorrow for #1 in the archive.', 4000); return; } const pick = prompt(`Play a past daily: enter a number from 1 to ${n}`, String(n)); const k = Number(pick); if (k >= 1 && k <= n) load({ kind: 'daily', date: dateOf(k) }, true); } // ------------------------------------------------------------------ play function save() { store(`pathmoor:b:${key}`, { cur, taps, ms: elapsed(), done }); } function elapsed() { return ms + (started ? performance.now() - started : 0); } function tap(i, k = 1) { if (done || i < 0) return; if (!started) { started = performance.now(); if (!Object.keys(taps).length && !ms) count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack); } if (bits(cur[i]) === 4) return; // a cross never changes undoStack.push([i, cur[i]]); cur[i] = turn(cur[i], k); taps[i] = (taps[i] || 0) + 1; anim[i] = { from: k === 1 ? -90 : 90, t0: performance.now() }; if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ } if (isSolved(board.n, board.src, cur)) win(); save(); hud(); loop(); } function undo() { if (done || !undoStack.length) return; const [i, m] = undoStack.pop(); cur[i] = m; anim[i] = { from: 90, t0: performance.now() }; save(); hud(); loop(); } function restart() { if (done && !confirm('Start this board again? Your result stays saved.')) return; cur = board.start.slice(); taps = {}; ms = 0; started = 0; done = false; undoStack = []; store(`pathmoor:b:${key}`, null); $('win').hidden = true; hud(); draw(); } function totalTaps() { return Object.values(taps).reduce((a, b) => a + b, 0); } function hud() { $('time').textContent = fmtTime(done ? ms : elapsed()); $('taps').textContent = totalTaps(); const on = lit(board.n, board.src, cur); $('lit').textContent = `${Math.round((on.reduce((a, b) => a + b, 0) / on.length) * 100)}%`; } function win() { ms = elapsed(); started = 0; done = true; winAt = performance.now(); count(GAME, 'win', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack); if (mode.kind === 'daily') { const st = store('pathmoor:stats') || { days: {}, streak: 0, best: 0 }; if (!st.days[mode.date]) { st.days[mode.date] = { ms: Math.round(ms), taps: totalTaps(), par: par(board) }; if (mode.date === todayIso) { const y = dateOf(todayNum - 1); st.streak = st.days[y] ? (st.streak || 0) + 1 : 1; st.best = Math.max(st.best || 0, st.streak); } store('pathmoor:stats', st); } } else { store(`pathmoor:pack:${mode.pack}`, Math.max(store(`pathmoor:pack:${mode.pack}`) || 0, mode.level)); } save(); setTimeout(() => showWin(true), 650); install.offer(); } function shareText() { const t = totalTaps(), p = par(board); const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} 🏮` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} 🏮`; return `${head} ${fmtTime(ms)} · ${t} taps (par ${p})${t <= p ? ' ⭐ perfect' : ''}\n${shareGrid(board, taps)}`; } function showWin(scroll) { const st = store('pathmoor:stats') || {}; const t = totalTaps(), p = par(board); $('win-title').textContent = t <= p ? 'Perfect! Every stone lit in par.' : 'Every stone is lit!'; $('win-line').textContent = `${fmtTime(ms)} · ${t} taps (par ${p})` + (mode.kind === 'daily' && mode.date === todayIso && st.streak ? ` · 🔥 ${st.streak}-day streak` : ''); $('win-grid').textContent = shareGrid(board, taps); const text = shareText(); const it = intents(GAME, text, PATH); $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp; const pr = $('practice'); if (mode.kind === 'pack') { const pk = packOf(mode.pack); pr.textContent = mode.level < pk.levels ? `Next board: ${pk.name} #${mode.level + 1}` : 'Pack complete! Try another pack'; } else pr.textContent = 'Play a practice board'; $('win').hidden = false; pass.afterWin($('win'), 'win'); tick(); if (scroll) $('win').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function tick() { const now = new Date(); const mid = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); const s = Math.round((mid - now) / 1000); $('next').textContent = `Next daily board in ${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m.`; } // ------------------------------------------------------------------ drawing function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) { const S = SKINS[skin]; const n = board.n, cs = size / n, pad = cs * 0.06, w = cs * 0.2; c.save(); c.fillStyle = S.bg; rr(c, x0, y0, size, size, size * 0.04); c.fill(); for (let i = 0; i < n * n; i++) { const x = x0 + (i % n) * cs, y = y0 + ((i / n) | 0) * cs, cx = x + cs / 2, cy = y + cs / 2; c.fillStyle = S.stone; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill(); if (i === focusIdx) { c.strokeStyle = S.on; c.lineWidth = 3; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.stroke(); } let ang = 0; const a = anim[i]; if (a) { const p = Math.min(1, (t - a.t0) / 140); ang = a.from * (1 - (1 - (1 - p) ** 3)); if (p >= 1) delete anim[i]; } c.save(); c.translate(cx, cy); c.rotate((ang * Math.PI) / 180); const m = cells[i], lightOn = on[i]; c.strokeStyle = lightOn ? S.on : S.off; c.lineWidth = w; c.lineCap = 'round'; if (lightOn) { c.shadowColor = S.glow; c.shadowBlur = cs * 0.25; } c.beginPath(); const r = cs / 2; if (m & 1) { c.moveTo(0, 0); c.lineTo(0, -r); } if (m & 2) { c.moveTo(0, 0); c.lineTo(r, 0); } if (m & 4) { c.moveTo(0, 0); c.lineTo(0, r); } if (m & 8) { c.moveTo(0, 0); c.lineTo(-r, 0); } c.stroke(); if (bits(m) === 1 && i !== board.src) { c.fillStyle = lightOn ? S.on : S.off; c.beginPath(); c.arc(0, 0, w * 0.95, 0, Math.PI * 2); c.fill(); } c.restore(); if (i === board.src) { const pulse = 1 + 0.06 * Math.sin(t / 300); c.save(); c.shadowColor = S.glow; c.shadowBlur = cs * 0.5; c.fillStyle = S.lamp; c.beginPath(); c.arc(cx, cy, cs * 0.2 * pulse, 0, Math.PI * 2); c.fill(); c.restore(); c.fillStyle = S.bg; c.beginPath(); c.arc(cx, cy, cs * 0.08, 0, Math.PI * 2); c.fill(); } } c.restore(); } let raf = 0; function draw() { const t = performance.now(); ctx.clearRect(0, 0, canvas.width, canvas.height); const on = done ? new Uint8Array(cur.length).fill(1) : lit(board.n, board.src, cur); drawBoard(ctx, 0, 0, canvas.width, cur, on, t, document.activeElement === canvas ? focus : -1); if (done && t - winAt < 1200) { ctx.fillStyle = `rgba(255,255,255,${0.18 * (1 - (t - winAt) / 1200)})`; ctx.fillRect(0, 0, canvas.width, canvas.height); } return Object.keys(anim).length || (done && t - winAt < 1200); } function loop() { if (raf) return; const step = () => { raf = 0; if (draw()) raf = requestAnimationFrame(step); }; raf = requestAnimationFrame(step); } // the lantern breathes slowly; repaint a few times a second only while visible setInterval(() => { if (!document.hidden) { draw(); if (!done && started) hud(); } }, 250); // ------------------------------------------------------------------ result card (1080×1350 PNG) function card() { const c = document.createElement('canvas'); c.width = 1080; c.height = 1350; const g = c.getContext('2d'); const S = SKINS[skin]; g.fillStyle = S.bg; g.fillRect(0, 0, 1080, 1350); g.fillStyle = S.on; g.font = '900 64px system-ui, sans-serif'; g.fillText('PATHMOOR', 80, 130); g.fillStyle = '#ffffffcc'; g.font = '700 40px system-ui, sans-serif'; g.fillText(mode.kind === 'daily' ? `Daily #${dayNumber(mode.date)} · ${mode.date}` : `${packOf(mode.pack).name} · board ${mode.level}`, 80, 190); drawBoard(g, 140, 250, 800, cur, new Uint8Array(cur.length).fill(1), 0); g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; g.fillText(fmtTime(ms), 80, 1170); g.font = '700 44px system-ui, sans-serif'; g.fillStyle = S.on; g.fillText(`${totalTaps()} taps · par ${par(board)}`, 80, 1235); g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif'; g.fillText('Play free: cybermaxtools.com/games/pathmoor', 80, 1300); return c; } // ------------------------------------------------------------------ input function cellAt(ev) { const r = canvas.getBoundingClientRect(); const x = Math.floor(((ev.clientX - r.left) / r.width) * board.n), y = Math.floor(((ev.clientY - r.top) / r.height) * board.n); return x >= 0 && y >= 0 && x < board.n && y < board.n ? y * board.n + x : -1; } canvas.addEventListener('pointerdown', (ev) => { ev.preventDefault(); tap(cellAt(ev), ev.button === 2 || ev.shiftKey ? 3 : 1); }); canvas.addEventListener('contextmenu', (ev) => ev.preventDefault()); canvas.addEventListener('keydown', (ev) => { const n = board.n; if (focus < 0) focus = board.src; const x = focus % n, y = (focus / n) | 0; const mv = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[ev.key]; if (mv) { ev.preventDefault(); focus = Math.min(n - 1, Math.max(0, y + mv[1])) * n + Math.min(n - 1, Math.max(0, x + mv[0])); draw(); } else if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); tap(focus, ev.shiftKey ? 3 : 1); } else if (ev.key === 'z' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); undo(); } }); canvas.addEventListener('focus', draw); canvas.addEventListener('blur', draw); document.addEventListener('visibilitychange', () => { if (document.hidden && started && !done) { ms = elapsed(); started = performance.now(); save(); } }); $('undo').onclick = undo; $('restart').onclick = restart; $('pass-btn').onclick = () => pass.open('header'); document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); }); $('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id]))); $('card').onclick = async () => { download(await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); count(GAME, 'share', 'png'); }; $('practice').onclick = () => { if (mode.kind === 'pack') { const pk = packOf(mode.pack); if (mode.level < pk.levels) load({ kind: 'pack', pack: pk.id, level: mode.level + 1 }, true); else load({ kind: 'pack', pack: 'sprout', level: 1 }, true); } else { const lvl = Math.min(30, (store('pathmoor:pack:sprout') || 0) + 1); load({ kind: 'pack', pack: 'sprout', level: lvl }, true); } }; $('stats-btn').onclick = () => { const st = store('pathmoor:stats') || { days: {} }; const d = Object.values(st.days || {}); const best = d.length ? fmtTime(Math.min(...d.map((x) => x.ms))) : '–'; toast(`Dailies solved: ${d.length} · streak ${st.streak || 0} (best ${st.best || 0}) · fastest ${best}`, 4500); }; function renderSkins() { const el = $('skins'); el.innerHTML = ''; for (const [id, s] of Object.entries(SKINS)) { const b = document.createElement('button'); b.type = 'button'; b.title = `${s.name} theme${s.free ? '' : ' (Fun Pass)'}`; b.setAttribute('aria-label', b.title); b.style.background = `linear-gradient(135deg, ${s.bg} 50%, ${s.on} 50%)`; b.setAttribute('aria-pressed', String(id === skin)); if (!s.free && !pass.isPro()) b.classList.add('locked'); b.onclick = () => { if (!s.free && !pass.isPro()) { pass.open('theme'); return; } skin = id; store('pathmoor:skin', id); renderSkins(); draw(); }; el.appendChild(b); } } pass.onChange((pro) => { if (!pro && !SKINS[skin].free) { skin = 'moor'; store('pathmoor:skin', 'moor'); } renderSkins(); if (mode) renderModes(); }); load(fromUrl()); // Screenshot helper for scripts/games/render_assets.mjs: shows a board mid-solve (no effect on normal play). window.__cmxShot = async (stage = 1) => { if (stage === 2) { cur = board.sol.slice(); taps = {}; board.start.forEach((m, i) => { const k = turnsTo(m, board.sol[i]); if (k) taps[i] = k; }); const extra = Object.keys(taps)[3]; taps[extra] += 2; started = performance.now() - 83000; win(); return; } for (let i = 0; i < cur.length; i++) if (i % 7 !== 3) cur[i] = board.sol[i]; taps = { 1: 2, 4: 1, 9: 3 }; hud(); draw(); };