// Pathmoor: the game screen (canvas board, timer, 30 levels, daily, packs, archive, share, card, themes). (c) 2026 CyberMax. import { generate, turn, turnsTo, par, shareGrid, fmtTime, PACKS, bits, LEVELS, generateLevel, isSolvedB, litB, tapB, groupOf, levelStars } from './core.js'; import * as lv from './levels.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 } | { kind: 'level', level } let board, cur, taps, undoStack, ms, started, done, key, focus = -1, anim = {}, winAt = 0, failed = false; let muted = !!store('pathmoor:mute'); const sfx = lv.sfx(() => muted); const prog = lv.progress(GAME, { total: LEVELS.length, free: 20, isPro: () => pass.isPro() }); let chal = lv.readChallenge(); // ?seed=L7&score=12 or ?seed=2026-10-08&score=30 ("beat my score") const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss })); const WORLDS = ['Sprout Moor', 'Twin Fields', 'Highland (Fun Pass)']; pass.init({ game: GAME, name: 'Pathmoor', perks: ['Levels 21-30: reverse stones and 9×9 to 10×10 boards', '250 bonus 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 (m.kind === 'level') return prog.lock(m.level, { challenge: !!m.challenge }); 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}` : m.kind === 'level' ? `l:${m.level}` : `p:${m.pack}:${m.level}`; } function label(m) { return m.kind === 'daily' ? `#${dayNumber(m.date)}` : m.kind === 'level' ? `Level ${m.level}` : `${packOf(m.pack).name} · ${m.level}`; } function fromUrl() { const q = new URLSearchParams(location.search); if (chal && chal.level && chal.level <= LEVELS.length) return { kind: 'level', level: chal.level, challenge: true }; if (chal && chal.date) return { kind: 'daily', date: pass.isPro() || chal.date === todayIso ? chal.date : todayIso }; const lq = Number(q.get('lv')); if (lq >= 1 && lq <= LEVELS.length) return { kind: 'level', level: lq }; 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 }; // first visit: start on level 1 (2-second tutorial); returning players land on today's daily board if (!store('pathmoor:stats') && !prog.cleared()) return { kind: 'level', level: 1 }; return { kind: 'daily', date: todayIso }; } /** Level and daily boards share one shape: holes, twins, reverse stones and fog are empty on plain boards. */ /** The daily streak (days in a row with a solved daily), 0 once a day is missed. */ function streakNow() { const st = store('pathmoor:stats') || { days: {} }; return st.days[todayIso] || st.days[dateOf(todayNum - 1)] ? st.streak || 0 : 0; } function plain(b) { const z = b.n * b.n; return { ...b, hole: new Uint8Array(z), twin: new Int16Array(z).fill(-1), rev: new Uint8Array(z), fog: false, par: par(b), budget: 0 }; } function load(m, push = false) { const lk = locked(m); if (lk) { if (m.kind === 'level' && lk === 'progress') { toast(`Clear level ${prog.reached()} first.`); return false; } pass.open(m.kind === 'daily' ? 'archive' : m.kind === 'level' ? 'level' : m.pack); return false; } mode = m; key = keyOf(m); if (m.kind === 'level') { prog.open(m.level); board = generateLevel(m.level); } else board = plain(generate(m.kind === 'daily' ? m.date : `${m.pack}-${m.level}`, m.kind === 'daily' ? 6 : packOf(m.pack).n)); const n = board.n; failed = false; $('levels').hidden = true; 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 = board.par; goal(); banner(); if (push) { const u = new URL(location.href); u.search = m.kind === 'daily' ? (m.date === todayIso ? '' : `?d=${m.date}`) : m.kind === 'level' ? `?lv=${m.level}` : `?p=${m.pack}&l=${m.level}`; window.history.replaceState(null, '', u.pathname + u.search); } renderModes(); $('win').hidden = true; if (done) showWin(false); hud(); draw(); if (m.kind === 'level' && m.level === 1 && !done) setTimeout(() => { const i = board.start.findIndex((v, j) => v !== board.sol[j]); lv.tutorial(GAME, canvas, { fx: ((i % n) + 0.5) / n, fy: (((i / n) | 0) + 0.5) / n }); }, 300); if (m.kind === 'level' && board.budget && !done) sfx.boss(); return true; } /** The goal line under the modes: what to do on this board, in one glance. */ function goal() { const L = mode.kind === 'level' ? LEVELS[mode.level - 1] : null; const bits2 = []; if (L && L.boss) bits2.push('👑 Challenge level'); bits2.push(`🎯 Light every stone in ${board.par} taps for ⭐⭐⭐`); if (board.budget) bits2.push(`run out of ${board.budget} taps and it resets`); if (L && L.twist) bits2.push(`NEW: ${L.twist}`); $('goal').innerHTML = bits2.join(' · '); $('left-wrap').hidden = !board.budget; } /** "Beat my score" banner when the page was opened from a friend's link for this same board. */ function banner() { const el = $('challenge'); const same = chal && chal.score !== null && ((chal.level && mode.kind === 'level' && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date)); el.hidden = !same; if (same) el.innerHTML = `🎯 A friend solved ${mode.kind === 'level' ? `level ${chal.level}` : `daily #${dayNumber(chal.date)}`} in ${chal.score} taps. Beat it!`; } function showMap() { const el = $('levels'); if (!el.hidden) { el.hidden = true; return; } lv.renderMap($('lvmap'), prog, MAP, { current: mode.kind === 'level' ? mode.level : 0, worlds: WORLDS, onPick: (k) => load({ kind: 'level', level: k }, true), onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)), }); el.hidden = false; el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function renderModes() { const el = $('modes'); const chips = [{ m: { kind: 'daily', date: todayIso }, t: `Today #${todayNum}${streakNow() ? ` 🔥${streakNow()}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso }, { map: true, t: `Levels ${prog.cleared()}/${LEVELS.length} ⭐${prog.totalStars()}`, on: mode.kind === 'level' }]; 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() : c.map ? showMap() : 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 || failed || i < 0 || board.hole[i]) 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 && board.twin[i] < 0) return; // a cross never changes const before = litCount(); undoStack.push(groupOf(board, i).map((j) => [j, cur[j]])); tapB(board, cur, i, k); taps[i] = (taps[i] || 0) + 1; for (const j of groupOf(board, i)) anim[j] = { from: (board.rev[j] ? -1 : 1) * (k === 1 ? -90 : 90), t0: performance.now() }; if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ } const gain = litCount() - before; if (gain > 0) sfx.good(Math.min(20, gain)); else sfx.tap(); if (isSolvedB(board, cur)) win(); else if (board.budget && totalTaps() >= board.budget) fail(); save(); hud(); loop(); } function litCount() { return litB(board, cur).reduce((a, b) => a + b, 0); } /** Challenge level: out of taps. Shake, buzz, and the board resets by itself (one tap to try again). */ function fail() { failed = true; sfx.hit(); lv.shake(canvas, 10, 320); if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ } toast(`Out of taps! The board resets. Par is ${board.par}.`, 1800); setTimeout(() => { failed = false; restart(true); }, 650); } function undo() { if (done || !undoStack.length) return; for (const [i, m] of undoStack.pop()) { cur[i] = m; anim[i] = { from: 90, t0: performance.now() }; } save(); hud(); loop(); } /** Instant restart: one tap, no question (a finished result stays saved in your stats and stars). */ function restart(quiet = false) { if (!quiet) sfx.tap(); 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 = litB(board, cur); const cells = on.length - board.hole.reduce((a, b) => a + b, 0); $('lit').textContent = `${Math.round((on.reduce((a, b) => a + b, 0) / cells) * 100)}%`; if (board.budget) $('left').textContent = Math.max(0, board.budget - totalTaps()); } function win() { ms = elapsed(); started = 0; done = true; winAt = performance.now(); count(GAME, 'win', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.kind === 'level' ? `level-${mode.level}` : mode.pack); sfx.win(); lv.burstAt(canvas, 0.5, 0.5, { n: 60, speed: 9, colors: [SKINS[skin].on, SKINS[skin].lamp, '#FFFFFF'] }); if (mode.kind === 'level') { const st = levelStars(totalTaps(), board.par); mode.result = prog.record(mode.level, st, totalTaps()); mode.result.got = st; for (let k = 0; k < st; k++) sfx.star(k); } else 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: board.par }; 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)); } renderModes(); save(); setTimeout(() => showWin(true), 650); install.offer(); } function shareText() { const t = totalTaps(), p = board.par; if (mode.kind === 'level') return `Pathmoor level ${mode.level} 🏮 ${lv.starText(prog.stars(mode.level))}\n${t} taps (par ${p}) · ${fmtTime(ms)}\nCan you beat it?`; const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} 🏮` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} 🏮`; const streak = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` · 🔥${streakNow()}` : ''; return `${head} ${fmtTime(ms)} · ${t} taps (par ${p})${t <= p ? ' ⭐ perfect' : ''}${streak}\n${shareGrid(board, taps)}`; } /** The "beat my score" link: the same board, with my taps to beat. */ function beatLink() { if (mode.kind === 'level') return lv.challengeLink(PATH, GAME, { level: mode.level, score: totalTaps() }); if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: totalTaps() }); return `https://cybermaxtools.com${PATH}?p=${mode.pack}&l=${mode.level}`; } function showWin(scroll) { const st = store('pathmoor:stats') || {}; const t = totalTaps(), p = board.par; $('win-title').textContent = t <= p ? 'Perfect! Every stone lit in par.' : 'Every stone is lit!'; const isLv = mode.kind === 'level'; $('win-stars').hidden = !isLv; if (isLv) { const st = prog.stars(mode.level); $('win-stars').innerHTML = lv.starsHtml(st); $('win-title').textContent = st === 3 ? `Level ${mode.level}: perfect!` : `Level ${mode.level} cleared!`; } $('next-lv').hidden = !isLv || mode.level >= LEVELS.length; $('retry').hidden = !isLv; if (isLv && mode.level < LEVELS.length) $('next-lv').textContent = prog.lock(mode.level + 1) === 'pass' ? `Level ${mode.level + 1} 🔒 Fun Pass` : `Next: level ${mode.level + 1} ▶`; $('win-line').textContent = `${fmtTime(ms)} · ${t} taps (par ${p})` + (mode.kind === 'daily' && mode.date === todayIso && st.streak ? ` · 🔥 ${st.streak}-day streak` : ''); $('win-grid').textContent = isLv ? '' : shareGrid(board, taps); $('win-grid').hidden = isLv; if (chal && chal.score !== null && !$('challenge').hidden) { $('win-line').textContent += t < chal.score ? ` · 🎯 You beat your friend's ${chal.score}!` : t === chal.score ? ` · 🎯 Tied with your friend (${chal.score})` : ` · 🎯 ${t - chal.score} more than your friend. Retry?`; } const text = shareText(); const it = intents(GAME, `${text}\n🎯 ${beatLink()}`, PATH); $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp; const pr = $('practice'); if (isLv) pr.textContent = 'Play today\'s daily board'; else 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 level ${prog.reached()}`; $('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 const TWIN = ['#5CF2FF', '#FF8FD8', '#9BE15D', '#FFD166', '#FF7A45', '#B79CFF']; function mix(a, b, k) { const p = (h) => [1, 3, 5].map((o) => parseInt(h.slice(o, o + 2), 16)); const A = p(a), B = p(b); return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * k)).join(',')})`; } /** Fog: a stone shows only when it is lit or next to a lit stone. */ function fogMask(on) { if (!board.fog || done) return null; const n = board.n, f = new Uint8Array(n * n).fill(1); for (let i = 0; i < n * n; i++) { if (!on[i]) continue; const x = i % n, y = (i / n) | 0; f[i] = 0; for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const X = x + dx, Y = y + dy; if (X >= 0 && Y >= 0 && X < n && Y < n) f[Y * n + X] = 0; } } return f; } function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) { const S = SKINS[skin]; const fogged = fogMask(on); 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; if (board.hole[i]) continue; // a hole in the moor: nothing to draw if (fogged && fogged[i]) { c.fillStyle = S.off; c.globalAlpha = 0.35; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill(); c.globalAlpha = 1; continue; } c.fillStyle = board.rev[i] ? mix(S.stone, '#8A5CF6', 0.35) : S.stone; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill(); if (board.twin[i] >= 0) { c.fillStyle = TWIN[Math.min(i, board.twin[i]) % TWIN.length]; c.beginPath(); c.arc(x + cs * 0.2, y + cs * 0.2, cs * 0.075, 0, Math.PI * 2); c.fill(); } if (board.rev[i]) { c.strokeStyle = '#B79CFF'; c.lineWidth = Math.max(2, cs * 0.035); c.beginPath(); c.arc(x + cs * 0.8, y + cs * 0.2, cs * 0.07, Math.PI * 0.2, Math.PI * 1.7); c.stroke(); } 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) : litB(board, 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}` : mode.kind === 'level' ? `Level ${mode.level} · ${LEVELS[mode.level - 1].name} ${lv.starText(prog.stars(mode.level))}` : `${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 ${board.par} · can you beat it?`, 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(); } else if (ev.key === 'r' || ev.key === 'R') { ev.preventDefault(); restart(); } }); 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(); $('retry').onclick = () => restart(); $('next-lv').onclick = () => load({ kind: 'level', level: mode.level + 1 }, true) && canvas.scrollIntoView({ behavior: 'smooth', block: 'center' }); $('beat').onclick = async () => { const r = await lv.shareChallenge(`I solved Pathmoor ${mode.kind === 'level' ? `level ${mode.level}` : mode.kind === 'daily' ? `#${dayNumber(mode.date)}` : 'this board'} in ${totalTaps()} taps. Can you beat me? 🏮`, beatLink()); if (r === 'copied') toast('Challenge link copied. Send it to a friend!'); if (r !== 'cancelled') count(GAME, 'share', 'challenge'); }; $('mute').textContent = muted ? '🔇' : '🔊'; $('mute').onclick = () => { muted = !muted; store('pathmoor:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; }; $('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()}\n🎯 Beat me: ${beatLink()}`, 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 === 'level') load({ kind: 'daily', date: todayIso }, true); else if (mode.kind === 'daily') load({ kind: 'level', level: prog.reached() }, true) || load({ kind: 'level', level: Math.min(prog.reached(), prog.free) }, true); else 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} · levels ${prog.cleared()}/${LEVELS.length}, ${prog.totalStars()} ⭐`, 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(); }); // a locked link (pass level, level not reached yet) falls back to the next open level, then today's daily load(fromUrl()) || (!prog.lock(prog.reached()) && load({ kind: 'level', level: prog.reached() })) || load({ kind: 'daily', date: todayIso }); // 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(); };