Spaces:
Running
Running
Download pathmoor/game.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 27.9 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/pathmoor/game.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/pathmoor/game.js
-
curl -L -o game.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/pathmoor/game.js
27.9 kB
| // 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 <b>${board.par}</b> taps for ⭐⭐⭐`); | |
| if (board.budget) bits2.push(`run out of <b>${board.budget}</b> taps and it resets`); | |
| if (L && L.twist) bits2.push(`<span class="new-twist">NEW: ${L.twist}</span>`); | |
| $('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 = `<span>🎯 A friend solved ${mode.kind === 'level' ? `level ${chal.level}` : `daily #${dayNumber(chal.date)}`} in <b>${chal.score}</b> taps. Beat it!</span>`; | |
| } | |
| 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 ? ' <span class="lock">🔒</span>' : ''}`; | |
| 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(); | |
| }; | |