cybermax / pathmoor /game.js
CyberMax-tools's picture
Deploy pathmoor to the hub
218cc75 verified
Raw History Blame Contribute Delete
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();
};