Spaces:
Running
Running
Download game.js from CyberMax-tools/pathmoor: direct link, hf CLI and curl.
- Browser
- Download file 17.4 kB
-
https://huggingface.co/spaces/CyberMax-tools/pathmoor/resolve/main/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/pathmoor/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/pathmoor/resolve/main/game.js
17.4 kB
| // 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 ? ' <span class="lock">🔒</span>' : ''}`; | |
| 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(); | |
| }; | |