Spaces:
Running
Running
Deploy pathmoor
Browse files- .gitattributes +2 -0
- README.md +21 -5
- cmx.js +179 -0
- core.js +143 -0
- fun.css +51 -0
- funpass.js +117 -0
- game.js +354 -0
- icon-192.png +0 -0
- icon-512.png +0 -0
- icon-maskable.png +0 -0
- icon.svg +1 -0
- index.html +60 -18
- manifest.webmanifest +47 -0
- og.png +3 -0
- shot-phone.png +3 -0
- style.css +14 -28
- sw.js +25 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
og.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
shot-phone.png filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,10 +1,26 @@
|
|
| 1 |
---
|
| 2 |
-
title: Pathmoor
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
|
|
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: "Pathmoor: Daily logic puzzle"
|
| 3 |
+
emoji: 🏮
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
license: other
|
| 9 |
+
short_description: "Daily path puzzle: turn stones until every path glows"
|
| 10 |
+
tags:
|
| 11 |
+
- game
|
| 12 |
+
- puzzle
|
| 13 |
+
- daily-puzzle
|
| 14 |
+
- logic
|
| 15 |
+
- html5
|
| 16 |
+
- browser-game
|
| 17 |
+
- pwa
|
| 18 |
+
- cybermax
|
| 19 |
---
|
| 20 |
+
**Pathmoor**: Daily path puzzle: turn stones until every path glows. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
|
| 21 |
|
| 22 |
+
Play it full screen: https://cybermax.cybermax-tools.workers.dev/games/pathmoor/?utm_source=huggingface
|
| 23 |
+
|
| 24 |
+
The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $4.99 once) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermax.cybermax-tools.workers.dev/games/?utm_source=huggingface
|
| 25 |
+
|
| 26 |
+
More free games: https://cybermax.cybermax-tools.workers.dev/games/ · © 2026 CyberMax. All rights reserved.
|
cmx.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by
|
| 2 |
+
// scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own).
|
| 3 |
+
// Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install.
|
| 4 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 5 |
+
|
| 6 |
+
export const SITE = 'https://cybermax.cybermax-tools.workers.dev';
|
| 7 |
+
export const API = 'https://toys.cybermax-tools.workers.dev';
|
| 8 |
+
export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game
|
| 9 |
+
|
| 10 |
+
/** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */
|
| 11 |
+
export function today(d = new Date()) {
|
| 12 |
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
| 13 |
+
}
|
| 14 |
+
/** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */
|
| 15 |
+
export function dayNumber(iso) {
|
| 16 |
+
const [y, m, d] = iso.split('-').map(Number);
|
| 17 |
+
return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1;
|
| 18 |
+
}
|
| 19 |
+
export function dateOf(n) {
|
| 20 |
+
const t = new Date(EPOCH + (n - 1) * 864e5);
|
| 21 |
+
return t.toISOString().slice(0, 10);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */
|
| 25 |
+
export function hash(str) {
|
| 26 |
+
let h = 0x811c9dc5;
|
| 27 |
+
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
| 28 |
+
return h >>> 0;
|
| 29 |
+
}
|
| 30 |
+
export function rng(seed) {
|
| 31 |
+
let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
|
| 32 |
+
return function () {
|
| 33 |
+
a = (a + 0x6d2b79f5) >>> 0;
|
| 34 |
+
let t = a;
|
| 35 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 36 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 37 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */
|
| 42 |
+
export function count(game, ev, ch = '') {
|
| 43 |
+
try {
|
| 44 |
+
if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return;
|
| 45 |
+
const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`;
|
| 46 |
+
if (navigator.sendBeacon && navigator.sendBeacon(u)) return;
|
| 47 |
+
fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {});
|
| 48 |
+
} catch { /* never break the game */ }
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function store(key, val) {
|
| 52 |
+
try {
|
| 53 |
+
if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
|
| 54 |
+
localStorage.setItem(key, JSON.stringify(val));
|
| 55 |
+
} catch { /* private mode */ }
|
| 56 |
+
return val;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export function toast(msg, ms = 2400) {
|
| 60 |
+
let t = document.getElementById('toast');
|
| 61 |
+
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); }
|
| 62 |
+
t.textContent = msg;
|
| 63 |
+
t.classList.add('on');
|
| 64 |
+
clearTimeout(t._h);
|
| 65 |
+
t._h = setTimeout(() => t.classList.remove('on'), ms);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/** The link people share: the canonical game page with UTM tags the site view counter reads. */
|
| 69 |
+
export function shareLink(path, channel, game) {
|
| 70 |
+
const u = new URL(SITE + path);
|
| 71 |
+
u.searchParams.set('utm_source', channel);
|
| 72 |
+
u.searchParams.set('utm_medium', 'share');
|
| 73 |
+
u.searchParams.set('utm_campaign', `game-${game}`);
|
| 74 |
+
return u.toString();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard.
|
| 79 |
+
* text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card.
|
| 80 |
+
*/
|
| 81 |
+
export async function share(game, text, path, blob, filename) {
|
| 82 |
+
const url = shareLink(path, 'share', game);
|
| 83 |
+
const full = `${text}\n${url}`;
|
| 84 |
+
if (navigator.share) {
|
| 85 |
+
try {
|
| 86 |
+
const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null;
|
| 87 |
+
const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full };
|
| 88 |
+
await navigator.share(data);
|
| 89 |
+
count(game, 'share', 'native');
|
| 90 |
+
return 'shared';
|
| 91 |
+
} catch (e) {
|
| 92 |
+
if (e && e.name === 'AbortError') return 'cancelled';
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); }
|
| 96 |
+
count(game, 'share', 'copy');
|
| 97 |
+
return 'copied';
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export function intents(game, text, path) {
|
| 101 |
+
const u = (ch) => shareLink(path, ch, game);
|
| 102 |
+
return {
|
| 103 |
+
x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`,
|
| 104 |
+
bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`,
|
| 105 |
+
whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`,
|
| 106 |
+
};
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function download(blob, filename) {
|
| 110 |
+
const a = document.createElement('a');
|
| 111 |
+
a.href = URL.createObjectURL(blob);
|
| 112 |
+
a.download = filename;
|
| 113 |
+
document.body.appendChild(a);
|
| 114 |
+
a.click();
|
| 115 |
+
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button.
|
| 120 |
+
* Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint.
|
| 121 |
+
* The button only appears after the first finished round (offer(): never on first load) and never once installed.
|
| 122 |
+
*/
|
| 123 |
+
export function pwa(game, btn) {
|
| 124 |
+
if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
| 125 |
+
navigator.serviceWorker.register('sw.js').catch(() => {});
|
| 126 |
+
}
|
| 127 |
+
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
|
| 128 |
+
if (standalone) { count(game, 'play', 'app'); return { offer() {} }; }
|
| 129 |
+
let deferred = null;
|
| 130 |
+
let wanted = false;
|
| 131 |
+
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
|
| 132 |
+
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn) btn.hidden = false; });
|
| 133 |
+
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
+
if (btn) {
|
| 135 |
+
btn.addEventListener('click', async () => {
|
| 136 |
+
if (deferred) {
|
| 137 |
+
deferred.prompt();
|
| 138 |
+
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
+
if (r && r.outcome === 'accepted') btn.hidden = true;
|
| 140 |
+
deferred = null;
|
| 141 |
+
} else if (ios) {
|
| 142 |
+
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 143 |
+
count(game, 'install', 'ios-hint');
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
}
|
| 147 |
+
return {
|
| 148 |
+
offer() {
|
| 149 |
+
wanted = true;
|
| 150 |
+
if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
|
| 151 |
+
},
|
| 152 |
+
};
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/** Rounded rectangle path for canvas cards. */
|
| 156 |
+
export function rr(ctx, x, y, w, h, r) {
|
| 157 |
+
ctx.beginPath();
|
| 158 |
+
ctx.moveTo(x + r, y);
|
| 159 |
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
| 160 |
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
| 161 |
+
ctx.arcTo(x, y + h, x, y, r);
|
| 162 |
+
ctx.arcTo(x, y, x + w, y, r);
|
| 163 |
+
ctx.closePath();
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
|
| 167 |
+
|
| 168 |
+
/** Theme toggle shared by every page (light/dark, remembered). */
|
| 169 |
+
export function themeToggle(btn) {
|
| 170 |
+
if (!btn) return;
|
| 171 |
+
btn.addEventListener('click', () => {
|
| 172 |
+
const r = document.documentElement;
|
| 173 |
+
const c = r.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 174 |
+
const n = c === 'dark' ? 'light' : 'dark';
|
| 175 |
+
r.dataset.theme = n;
|
| 176 |
+
store('cm-theme-v', n);
|
| 177 |
+
try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
|
| 178 |
+
});
|
| 179 |
+
}
|
core.js
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Pathmoor core: pure puzzle logic (no DOM), shared by the game and the tests. (c) 2026 CyberMax. All rights reserved.
|
| 2 |
+
// A board is an n×n grid of path stones. Each stone is a 4-bit mask of its openings: N=1, E=2, S=4, W=8.
|
| 3 |
+
// The solution is a random spanning tree grown from the lantern stone, so every stone joins the lantern by exactly one
|
| 4 |
+
// route. The board is shuffled by turning stones; the player turns them back. Solved = every stone lit by the lantern
|
| 5 |
+
// and no opening points at the edge or at a closed side (any arrangement that does that counts, not only ours).
|
| 6 |
+
import { rng } from './cmx.js';
|
| 7 |
+
|
| 8 |
+
export const N = 1, E = 2, S = 4, W = 8;
|
| 9 |
+
const DIRS = [[N, 0, -1, S], [E, 1, 0, W], [S, 0, 1, N], [W, -1, 0, E]];
|
| 10 |
+
|
| 11 |
+
/** Turn a stone a quarter clockwise, k times. */
|
| 12 |
+
export function turn(m, k = 1) {
|
| 13 |
+
let r = m;
|
| 14 |
+
for (let i = 0; i < ((k % 4) + 4) % 4; i++) r = ((r << 1) | (r >> 3)) & 15;
|
| 15 |
+
return r;
|
| 16 |
+
}
|
| 17 |
+
export const bits = (m) => (m & 1) + ((m >> 1) & 1) + ((m >> 2) & 1) + ((m >> 3) & 1);
|
| 18 |
+
|
| 19 |
+
/** Fewest clockwise turns that take stone `from` to `to` (Infinity if impossible). */
|
| 20 |
+
export function turnsTo(from, to) {
|
| 21 |
+
for (let k = 0; k < 4; k++) if (turn(from, k) === to) return k;
|
| 22 |
+
return Infinity;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Build a puzzle from a seed string. Returns { n, src, sol, start } where sol and start are arrays of n*n masks.
|
| 27 |
+
* Grown with a randomised Prim tree; boards with too many straight runs or too few branches are re-rolled so every
|
| 28 |
+
* board has real choices. The shuffle turns each stone 0-3 times and is never already solved.
|
| 29 |
+
*/
|
| 30 |
+
export function generate(seed, n = 6) {
|
| 31 |
+
const rand = rng(`pathmoor:${n}:${seed}`);
|
| 32 |
+
for (let attempt = 0; attempt < 40; attempt++) {
|
| 33 |
+
const sol = new Array(n * n).fill(0);
|
| 34 |
+
const c = Math.floor((n - 1) / 2);
|
| 35 |
+
const src = (c + Math.floor(rand() * 2)) * n + c + Math.floor(rand() * 2); // one of the 4 middle stones
|
| 36 |
+
const inTree = new Array(n * n).fill(false);
|
| 37 |
+
inTree[src] = true;
|
| 38 |
+
let frontier = [];
|
| 39 |
+
const addEdges = (i) => {
|
| 40 |
+
const x = i % n, y = (i / n) | 0;
|
| 41 |
+
for (const [b, dx, dy, back] of DIRS) {
|
| 42 |
+
const nx = x + dx, ny = y + dy;
|
| 43 |
+
if (nx >= 0 && ny >= 0 && nx < n && ny < n && !inTree[ny * n + nx]) frontier.push([i, ny * n + nx, b, back]);
|
| 44 |
+
}
|
| 45 |
+
};
|
| 46 |
+
addEdges(src);
|
| 47 |
+
let count = 1;
|
| 48 |
+
while (count < n * n) {
|
| 49 |
+
frontier = frontier.filter(([, j]) => !inTree[j]);
|
| 50 |
+
const [i, j, b, back] = frontier[Math.floor(rand() * frontier.length)];
|
| 51 |
+
if (inTree[j]) continue;
|
| 52 |
+
sol[i] |= b; sol[j] |= back; inTree[j] = true; count++;
|
| 53 |
+
addEdges(j);
|
| 54 |
+
}
|
| 55 |
+
const straights = sol.filter((m) => m === (N | S) || m === (E | W)).length;
|
| 56 |
+
const branches = sol.filter((m) => bits(m) >= 3).length;
|
| 57 |
+
if (straights > n * n * 0.34 || branches < Math.max(3, Math.floor(n * n / 9))) continue;
|
| 58 |
+
const start = sol.map((m) => turn(m, Math.floor(rand() * 4)));
|
| 59 |
+
if (isSolved(n, src, start)) continue;
|
| 60 |
+
return { n, src, sol, start };
|
| 61 |
+
}
|
| 62 |
+
// extremely unlikely fallback: keep the last rules but accept the board
|
| 63 |
+
const b = generate(`${seed}~`, n);
|
| 64 |
+
return b;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/** Stones reachable from the lantern through matching openings. Returns a Uint8Array (1 = lit). */
|
| 68 |
+
export function lit(n, src, cur) {
|
| 69 |
+
const on = new Uint8Array(n * n);
|
| 70 |
+
const q = [src];
|
| 71 |
+
on[src] = 1;
|
| 72 |
+
while (q.length) {
|
| 73 |
+
const i = q.pop();
|
| 74 |
+
const x = i % n, y = (i / n) | 0;
|
| 75 |
+
for (const [b, dx, dy, back] of DIRS) {
|
| 76 |
+
if (!(cur[i] & b)) continue;
|
| 77 |
+
const nx = x + dx, ny = y + dy;
|
| 78 |
+
if (nx < 0 || ny < 0 || nx >= n || ny >= n) continue;
|
| 79 |
+
const j = ny * n + nx;
|
| 80 |
+
if (!on[j] && (cur[j] & back)) { on[j] = 1; q.push(j); }
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
return on;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** Openings that lead nowhere (edge, or a neighbour without the matching opening). */
|
| 87 |
+
export function looseEnds(n, cur) {
|
| 88 |
+
let loose = 0;
|
| 89 |
+
for (let i = 0; i < n * n; i++) {
|
| 90 |
+
const x = i % n, y = (i / n) | 0;
|
| 91 |
+
for (const [b, dx, dy, back] of DIRS) {
|
| 92 |
+
if (!(cur[i] & b)) continue;
|
| 93 |
+
const nx = x + dx, ny = y + dy;
|
| 94 |
+
if (nx < 0 || ny < 0 || nx >= n || ny >= n || !(cur[ny * n + nx] & back)) loose++;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
return loose;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export function isSolved(n, src, cur) {
|
| 101 |
+
if (looseEnds(n, cur)) return false;
|
| 102 |
+
const on = lit(n, src, cur);
|
| 103 |
+
for (let i = 0; i < on.length; i++) if (!on[i]) return false;
|
| 104 |
+
return true;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/** Par = the fewest taps that solve the board from its shuffle (each tap turns one stone a quarter clockwise). */
|
| 108 |
+
export function par(board) {
|
| 109 |
+
return board.start.reduce((s, m, i) => s + turnsTo(m, board.sol[i]), 0);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/**
|
| 113 |
+
* Share grid (no spoilers): one square per stone. 🟩 stone ended in par turns or fewer, 🟨 a few extra turns,
|
| 114 |
+
* 🟧 many extra turns, ⬜ stone you never needed to touch.
|
| 115 |
+
*/
|
| 116 |
+
export function shareGrid(board, taps) {
|
| 117 |
+
const rows = [];
|
| 118 |
+
for (let y = 0; y < board.n; y++) {
|
| 119 |
+
let r = '';
|
| 120 |
+
for (let x = 0; x < board.n; x++) {
|
| 121 |
+
const i = y * board.n + x;
|
| 122 |
+
const need = turnsTo(board.start[i], board.sol[i]);
|
| 123 |
+
const used = taps[i] || 0;
|
| 124 |
+
r += need === 0 && used === 0 ? '⬜' : used <= Math.max(need, 1) ? '🟩' : used <= need + 4 ? '🟨' : '🟧';
|
| 125 |
+
}
|
| 126 |
+
rows.push(r);
|
| 127 |
+
}
|
| 128 |
+
return rows.join('\n');
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
export function fmtTime(ms) {
|
| 132 |
+
const s = Math.max(0, Math.round(ms / 1000));
|
| 133 |
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/** Packs: the free daily is 6×6; the Fun Pass adds numbered packs. */
|
| 137 |
+
export const PACKS = [
|
| 138 |
+
{ id: 'daily', name: 'Daily', n: 6, free: true },
|
| 139 |
+
{ id: 'sprout', name: 'Sprout 5×5', n: 5, levels: 30, freeLevels: 3 },
|
| 140 |
+
{ id: 'moorland', name: 'Moorland 7×7', n: 7, levels: 100 },
|
| 141 |
+
{ id: 'highland', name: 'Highland 8×8', n: 8, levels: 100 },
|
| 142 |
+
{ id: 'summit', name: 'Summit 10×10', n: 10, levels: 50 },
|
| 143 |
+
];
|
fun.css
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* CyberMax Fun kit: shared look for CyberMax games (copied into each game). Mobile first, light + dark. (c) 2026 CyberMax. */
|
| 2 |
+
:root{--bg:#F6F3EC;--card:#fff;--ink:#11141B;--muted:#565C6B;--line:#E2DCD0;--chip:#ECE6DA;--acc:#FF6A3D;--acc-ink:#11141B;--ok:#0B7F5E;--bad:#C2361B;--shadow:0 1px 2px rgba(17,20,27,.06),0 10px 30px rgba(17,20,27,.08);color-scheme:light}
|
| 3 |
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}}
|
| 4 |
+
:root[data-theme=dark]{--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}
|
| 5 |
+
*{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}
|
| 6 |
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
|
| 7 |
+
a{color:inherit}img,canvas{max-width:100%}
|
| 8 |
+
.wrap{max-width:760px;margin:0 auto;padding:0 16px}
|
| 9 |
+
.top{display:flex;align-items:center;gap:6px;padding:12px 0}
|
| 10 |
+
.top .brand{display:flex;align-items:center;gap:8px;font-weight:800;text-decoration:none;font-size:18px;margin-right:auto}.top .brand svg{width:28px;height:28px}
|
| 11 |
+
.top nav a,.top .icon{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:10px;font-size:15px;background:none;border:0;font-family:inherit;cursor:pointer;min-height:40px}
|
| 12 |
+
.top nav a:hover,.top .icon:hover{color:var(--ink);background:var(--chip)}
|
| 13 |
+
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:10px 18px;border-radius:12px;border:0;background:var(--acc);color:var(--acc-ink);font:inherit;font-weight:800;font-size:16px;cursor:pointer;text-decoration:none;touch-action:manipulation}
|
| 14 |
+
.btn:hover{filter:brightness(1.06)}.btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)}.btn.sm{min-height:40px;padding:6px 14px;font-size:15px}
|
| 15 |
+
.btn.pass{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B}
|
| 16 |
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 17 |
+
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
| 18 |
+
.chip{border:1.5px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:6px 12px;font:inherit;font-size:14px;font-weight:700;cursor:pointer;min-height:36px}
|
| 19 |
+
.chip[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}.chip .lock{opacity:.7;font-size:12px}
|
| 20 |
+
.panel{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:var(--shadow)}
|
| 21 |
+
.muted{color:var(--muted)}.small{font-size:14px}
|
| 22 |
+
h1{font-size:clamp(26px,6vw,40px);line-height:1.08;letter-spacing:-.02em;margin:0 0 6px;text-wrap:balance}
|
| 23 |
+
h2{font-size:21px;letter-spacing:-.01em;margin:30px 0 8px}
|
| 24 |
+
.seo h3{font-size:17px;margin:18px 0 4px}.seo p,.seo li{color:var(--muted)}
|
| 25 |
+
.more{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:12px 0}
|
| 26 |
+
.more a{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:var(--card);text-decoration:none}.more a b{display:block}.more a span{color:var(--muted);font-size:14px}
|
| 27 |
+
footer{color:var(--muted);font-size:14px;padding:26px 0 40px;border-top:1px solid var(--line);margin-top:36px}
|
| 28 |
+
#toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,40px);opacity:0;background:var(--ink);color:var(--bg);padding:12px 16px;border-radius:12px;font-weight:700;transition:.25s;z-index:99;max-width:calc(100% - 32px);text-align:center;pointer-events:none}
|
| 29 |
+
#toast.on{transform:translate(-50%,0);opacity:1}
|
| 30 |
+
.nudge{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;border:1.5px dashed var(--line);border-radius:14px;padding:10px 12px;margin-top:12px;font-size:15px}
|
| 31 |
+
html.cmx-pro .nudge{display:none}
|
| 32 |
+
.pro-badge{display:none;font-size:12px;font-weight:800;background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:2px 8px;vertical-align:middle}
|
| 33 |
+
html.cmx-pro .pro-badge{display:inline-block}
|
| 34 |
+
dialog.funpass{border:0;padding:0;border-radius:20px;max-width:min(520px,calc(100% - 24px));width:100%;background:var(--card);color:var(--ink);box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
| 35 |
+
dialog.funpass::backdrop{background:rgba(10,12,18,.55)}
|
| 36 |
+
.fp{padding:22px 20px 18px;position:relative}.fp h2{margin:2px 0 10px;font-size:24px}
|
| 37 |
+
.fp-x{position:absolute;right:10px;top:8px;border:0;background:none;color:var(--muted);font-size:28px;cursor:pointer;width:44px;height:44px}
|
| 38 |
+
.fp-kick{margin:0;color:var(--acc);font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
|
| 39 |
+
.fp-perks{margin:0 0 14px;padding-left:20px}.fp-perks li{margin:2px 0}
|
| 40 |
+
.fp-plans{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
| 41 |
+
.fp-plan{position:relative;display:flex;flex-direction:column;gap:2px;padding:14px 10px 12px;border:1.5px solid var(--line);border-radius:14px;text-decoration:none;text-align:center;background:var(--bg)}
|
| 42 |
+
.fp-plan:hover{border-color:var(--acc)}.fp-plan.best{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
|
| 43 |
+
.fp-plan i{position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:var(--acc);color:#11141B;font-style:normal;font-size:11px;font-weight:800;padding:2px 8px;border-radius:999px;white-space:nowrap}
|
| 44 |
+
.fp-plan b{font-size:14px}.fp-price{font-size:22px;font-weight:900}.fp-price small{font-size:12px;font-weight:600;color:var(--muted)}.fp-note{font-size:12px;color:var(--muted)}
|
| 45 |
+
.fp-small{font-size:13px;color:var(--muted);margin:10px 0 4px}
|
| 46 |
+
.fp-code{display:block;user-select:all;word-break:break-all;background:var(--chip);padding:8px;border-radius:8px;font-size:12px}
|
| 47 |
+
.fp details{margin-top:6px;font-size:14px}.fp-form{display:flex;gap:6px;margin-top:8px}.fp-form input{flex:1;min-width:0;font:inherit;font-size:14px;padding:8px 10px;border-radius:10px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink)}
|
| 48 |
+
@media (max-width:420px){.fp-plans{grid-template-columns:1fr}.fp-plan{flex-direction:row;align-items:center;justify-content:space-between;text-align:left;padding:12px}.fp-plan i{left:auto;right:10px;transform:none}}
|
| 49 |
+
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
| 50 |
+
@media (max-width:560px){.chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -16px;padding:2px 16px 4px}.chips::-webkit-scrollbar{display:none}.chip{flex:0 0 auto}}
|
| 51 |
+
@media (min-width:760px){dialog.funpass{max-width:720px}.fp-perks{columns:2;column-gap:28px}}
|
funpass.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
|
| 2 |
+
// Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
|
| 3 |
+
// Monthly $2.99 · Yearly $19 · Unlock all $4.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
|
| 4 |
+
// Buy: counted redirect https://data.cybermax-tools.workers.dev/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
+
// After checkout Stripe sends the buyer to data.cybermax-tools.workers.dev/unlock/fun-pass?session_id=..., which checks
|
| 6 |
+
// the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
|
| 7 |
+
// Every game on the same site reads the same browser key, so one pass covers them all. The code on the receipt page
|
| 8 |
+
// unlocks any other device or browser. No account, no cookies, nothing personal stored. (c) 2026 CyberMax.
|
| 9 |
+
import { count, store, toast } from './cmx.js';
|
| 10 |
+
|
| 11 |
+
const DATA = 'https://data.cybermax-tools.workers.dev';
|
| 12 |
+
const KEY = 'cmx-pro:fun-pass';
|
| 13 |
+
const SID = /^cs_(live|test)_[A-Za-z0-9]{10,200}$/;
|
| 14 |
+
const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
|
| 15 |
+
export const PLANS = [
|
| 16 |
+
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 17 |
+
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 18 |
+
{ id: 'unlock', label: 'Unlock all', price: '$4.99', per: ' once', note: 'Pay once, keep forever' },
|
| 19 |
+
];
|
| 20 |
+
export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
|
| 21 |
+
|
| 22 |
+
let state = store(KEY);
|
| 23 |
+
const listeners = [];
|
| 24 |
+
let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
|
| 25 |
+
|
| 26 |
+
export const isPro = () => !!(state && state.code);
|
| 27 |
+
export function onChange(fn) { listeners.push(fn); fn(isPro()); }
|
| 28 |
+
function emit() {
|
| 29 |
+
document.documentElement.classList.toggle('cmx-pro', isPro());
|
| 30 |
+
listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
async function check(code) {
|
| 34 |
+
try {
|
| 35 |
+
const r = await fetch(`${DATA}/unlock/fun-pass?json=1&session_id=${encodeURIComponent(code)}`);
|
| 36 |
+
const j = await r.json();
|
| 37 |
+
return j && typeof j.pro === 'boolean' ? j.pro : null;
|
| 38 |
+
} catch { return null; }
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export async function unlock(code) {
|
| 42 |
+
const ok = await check(code);
|
| 43 |
+
if (ok) {
|
| 44 |
+
state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
|
| 45 |
+
count(cfg.game, 'pro', 'unlocked');
|
| 46 |
+
toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
|
| 47 |
+
emit();
|
| 48 |
+
render();
|
| 49 |
+
} else if (ok === false) toast('That code is not an active Fun Pass.', 3500);
|
| 50 |
+
else toast('Could not reach the pass check. Try again in a minute.', 3500);
|
| 51 |
+
return ok;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
async function recheck() {
|
| 55 |
+
if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
|
| 56 |
+
const ok = await check(state.code);
|
| 57 |
+
if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
|
| 58 |
+
else if (ok) state = store(KEY, { ...state, checked: Date.now() });
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 62 |
+
const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
|
| 63 |
+
|
| 64 |
+
function dialog() {
|
| 65 |
+
let d = document.getElementById('funpass');
|
| 66 |
+
if (!d) {
|
| 67 |
+
d = document.createElement('dialog');
|
| 68 |
+
d.id = 'funpass';
|
| 69 |
+
d.className = 'funpass';
|
| 70 |
+
document.body.appendChild(d);
|
| 71 |
+
d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
|
| 72 |
+
}
|
| 73 |
+
return d;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function render() {
|
| 77 |
+
const d = dialog();
|
| 78 |
+
const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
|
| 79 |
+
if (isPro()) {
|
| 80 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p><h2>You're a supporter. Thank you!</h2>
|
| 81 |
+
<p>The pass is on in this browser for every CyberMax game: ${perks.map(esc).join(' · ')}.</p>
|
| 82 |
+
<p class="fp-small">Your code (paste it on another device to turn the pass on there):</p><code class="fp-code">${esc(state.code)}</code>
|
| 83 |
+
<p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p></div>`;
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
|
| 87 |
+
<h2>One pass. Every game. More to play.</h2>
|
| 88 |
+
<ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 89 |
+
<div class="fp-plans">${PLANS.map((p) => `<a class="fp-plan${p.best ? ' best' : ''}" href="${buyUrl(p.id)}" rel="nofollow" data-plan="${p.id}">${p.best ? '<i>Best value</i>' : ''}<b>${p.label}</b><span class="fp-price">${p.price}<small>${p.per}</small></span><span class="fp-note">${p.note}</span></a>`).join('')}</div>
|
| 90 |
+
<p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The free daily games stay free for everyone.</p>
|
| 91 |
+
<details><summary>Already have a pass?</summary><form class="fp-form"><input aria-label="Pass code" autocomplete="off" placeholder="Code from your receipt page (cs_live_…)"><button class="btn sm" type="submit">Turn on</button></form></details></div>`;
|
| 92 |
+
d.querySelector('.fp-form').addEventListener('submit', (e) => {
|
| 93 |
+
e.preventDefault();
|
| 94 |
+
const c = e.target.querySelector('input').value.trim();
|
| 95 |
+
if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page. It starts with cs_live_.', 3500);
|
| 96 |
+
});
|
| 97 |
+
d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
/** Open the pass sheet; `from` names the button that opened it (counted as cta). */
|
| 101 |
+
export function open(from = 'button') {
|
| 102 |
+
render();
|
| 103 |
+
const d = dialog();
|
| 104 |
+
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
| 105 |
+
count(cfg.game, 'cta', `pass-open-${from}`);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/** Call once per page: game id, display name and the game's own Pro perks (listed first). */
|
| 109 |
+
export function init(options) {
|
| 110 |
+
cfg = { ...cfg, ...options };
|
| 111 |
+
const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
|
| 112 |
+
if (m && SID.test(decodeURIComponent(m[1]))) {
|
| 113 |
+
history.replaceState(null, '', location.pathname + location.search);
|
| 114 |
+
unlock(decodeURIComponent(m[1]));
|
| 115 |
+
} else recheck();
|
| 116 |
+
emit();
|
| 117 |
+
}
|
game.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Pathmoor: the game screen (canvas board, timer, packs, archive, share, card, themes). (c) 2026 CyberMax.
|
| 2 |
+
import { generate, isSolved, lit, turn, turnsTo, par, shareGrid, fmtTime, PACKS, bits } from './core.js';
|
| 3 |
+
import { today, dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
|
| 4 |
+
import * as pass from './funpass.js';
|
| 5 |
+
|
| 6 |
+
const GAME = 'pathmoor';
|
| 7 |
+
const PATH = '/games/pathmoor/';
|
| 8 |
+
const $ = (id) => document.getElementById(id);
|
| 9 |
+
const canvas = $('board');
|
| 10 |
+
const ctx = canvas.getContext('2d');
|
| 11 |
+
const SKINS = {
|
| 12 |
+
moor: { name: 'Moor', bg: '#16241E', stone: '#22362D', off: '#3E5A4C', on: '#FFC857', glow: 'rgba(255,200,87,.55)', lamp: '#FF8A3D', free: true },
|
| 13 |
+
ember: { name: 'Ember', bg: '#1E1412', stone: '#33201C', off: '#5B3A33', on: '#FF7A45', glow: 'rgba(255,122,69,.55)', lamp: '#FFD166' },
|
| 14 |
+
tide: { name: 'Tide', bg: '#0E1E2B', stone: '#173246', off: '#2F5470', on: '#6FF2E0', glow: 'rgba(111,242,224,.5)', lamp: '#FFFFFF' },
|
| 15 |
+
dusk: { name: 'Dusk', bg: '#1B1530', stone: '#2A2147', off: '#4A3D78', on: '#FF8FD8', glow: 'rgba(255,143,216,.5)', lamp: '#FFE08A' },
|
| 16 |
+
paper: { name: 'Paper', bg: '#E9DFCB', stone: '#FBF7EE', off: '#CDBFA4', on: '#E2581F', glow: 'rgba(226,88,31,.35)', lamp: '#11141B' },
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const todayIso = today();
|
| 20 |
+
const todayNum = dayNumber(todayIso);
|
| 21 |
+
let skin = SKINS[store('pathmoor:skin')] ? store('pathmoor:skin') : 'moor';
|
| 22 |
+
let mode; // { kind: 'daily', date } | { kind: 'pack', pack, level }
|
| 23 |
+
let board, cur, taps, undoStack, ms, started, done, key, focus = -1, anim = {}, winAt = 0, timer;
|
| 24 |
+
|
| 25 |
+
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'] });
|
| 26 |
+
const install = pwa(GAME, $('install'));
|
| 27 |
+
themeToggle($('theme'));
|
| 28 |
+
|
| 29 |
+
// ------------------------------------------------------------------ modes
|
| 30 |
+
|
| 31 |
+
function packOf(id) { return PACKS.find((p) => p.id === id); }
|
| 32 |
+
function locked(m) {
|
| 33 |
+
if (pass.isPro()) return false;
|
| 34 |
+
if (m.kind === 'daily') return m.date !== todayIso;
|
| 35 |
+
const p = packOf(m.pack);
|
| 36 |
+
return !(p.freeLevels && m.level <= p.freeLevels);
|
| 37 |
+
}
|
| 38 |
+
function keyOf(m) { return m.kind === 'daily' ? `d:${m.date}` : `p:${m.pack}:${m.level}`; }
|
| 39 |
+
function label(m) { return m.kind === 'daily' ? `#${dayNumber(m.date)}` : `${packOf(m.pack).name} · ${m.level}`; }
|
| 40 |
+
|
| 41 |
+
function fromUrl() {
|
| 42 |
+
const q = new URLSearchParams(location.search);
|
| 43 |
+
const d = q.get('d');
|
| 44 |
+
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso) return { kind: 'daily', date: d };
|
| 45 |
+
const p = packOf(q.get('p') || '');
|
| 46 |
+
const l = Number(q.get('l'));
|
| 47 |
+
if (p && p.id !== 'daily' && l >= 1 && l <= p.levels) return { kind: 'pack', pack: p.id, level: l };
|
| 48 |
+
return { kind: 'daily', date: todayIso };
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
function load(m, push = false) {
|
| 52 |
+
if (locked(m)) { pass.open(m.kind === 'daily' ? 'archive' : m.pack); return false; }
|
| 53 |
+
mode = m;
|
| 54 |
+
key = keyOf(m);
|
| 55 |
+
const n = m.kind === 'daily' ? 6 : packOf(m.pack).n;
|
| 56 |
+
board = generate(m.kind === 'daily' ? m.date : `${m.pack}-${m.level}`, n);
|
| 57 |
+
const saved = store(`pathmoor:b:${key}`);
|
| 58 |
+
if (saved && saved.cur && saved.cur.length === n * n) {
|
| 59 |
+
cur = saved.cur; taps = saved.taps || {}; ms = saved.ms || 0; done = !!saved.done; undoStack = [];
|
| 60 |
+
} else { cur = board.start.slice(); taps = {}; ms = 0; done = false; undoStack = []; }
|
| 61 |
+
started = 0; anim = {}; focus = -1;
|
| 62 |
+
$('num').textContent = label(m);
|
| 63 |
+
$('par').textContent = par(board);
|
| 64 |
+
if (push) {
|
| 65 |
+
const u = new URL(location.href);
|
| 66 |
+
u.search = m.kind === 'daily' ? (m.date === todayIso ? '' : `?d=${m.date}`) : `?p=${m.pack}&l=${m.level}`;
|
| 67 |
+
window.history.replaceState(null, '', u.pathname + u.search);
|
| 68 |
+
}
|
| 69 |
+
renderModes();
|
| 70 |
+
$('win').hidden = true;
|
| 71 |
+
if (done) showWin(false);
|
| 72 |
+
hud(); draw();
|
| 73 |
+
return true;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function renderModes() {
|
| 77 |
+
const el = $('modes');
|
| 78 |
+
const chips = [{ m: { kind: 'daily', date: todayIso }, t: `Today #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso }];
|
| 79 |
+
for (const p of PACKS.filter((x) => x.id !== 'daily')) {
|
| 80 |
+
const lvl = Math.min(p.levels, (store(`pathmoor:pack:${p.id}`) || 0) + 1);
|
| 81 |
+
const m = { kind: 'pack', pack: p.id, level: lvl };
|
| 82 |
+
chips.push({ m, t: p.name, on: mode.kind === 'pack' && mode.pack === p.id, lock: locked(m) });
|
| 83 |
+
}
|
| 84 |
+
chips.push({ archive: true, t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro() });
|
| 85 |
+
el.innerHTML = '';
|
| 86 |
+
chips.forEach((c) => {
|
| 87 |
+
const b = document.createElement('button');
|
| 88 |
+
b.className = 'chip'; b.type = 'button';
|
| 89 |
+
b.setAttribute('aria-pressed', String(!!c.on));
|
| 90 |
+
b.innerHTML = `${c.t}${c.lock ? ' <span class="lock">🔒</span>' : ''}`;
|
| 91 |
+
b.onclick = () => (c.archive ? archive() : load(c.m, true));
|
| 92 |
+
el.appendChild(b);
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function archive() {
|
| 97 |
+
if (!pass.isPro()) { pass.open('archive'); return; }
|
| 98 |
+
const n = todayNum - 1;
|
| 99 |
+
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; }
|
| 100 |
+
const pick = prompt(`Play a past daily: enter a number from 1 to ${n}`, String(n));
|
| 101 |
+
const k = Number(pick);
|
| 102 |
+
if (k >= 1 && k <= n) load({ kind: 'daily', date: dateOf(k) }, true);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// ------------------------------------------------------------------ play
|
| 106 |
+
|
| 107 |
+
function save() { store(`pathmoor:b:${key}`, { cur, taps, ms: elapsed(), done }); }
|
| 108 |
+
function elapsed() { return ms + (started ? performance.now() - started : 0); }
|
| 109 |
+
|
| 110 |
+
function tap(i, k = 1) {
|
| 111 |
+
if (done || i < 0) return;
|
| 112 |
+
if (!started) {
|
| 113 |
+
started = performance.now();
|
| 114 |
+
if (!Object.keys(taps).length && !ms) count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack);
|
| 115 |
+
}
|
| 116 |
+
if (bits(cur[i]) === 4) return; // a cross never changes
|
| 117 |
+
undoStack.push([i, cur[i]]);
|
| 118 |
+
cur[i] = turn(cur[i], k);
|
| 119 |
+
taps[i] = (taps[i] || 0) + 1;
|
| 120 |
+
anim[i] = { from: k === 1 ? -90 : 90, t0: performance.now() };
|
| 121 |
+
if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ }
|
| 122 |
+
if (isSolved(board.n, board.src, cur)) win();
|
| 123 |
+
save(); hud(); loop();
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function undo() {
|
| 127 |
+
if (done || !undoStack.length) return;
|
| 128 |
+
const [i, m] = undoStack.pop();
|
| 129 |
+
cur[i] = m;
|
| 130 |
+
anim[i] = { from: 90, t0: performance.now() };
|
| 131 |
+
save(); hud(); loop();
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function restart() {
|
| 135 |
+
if (done && !confirm('Start this board again? Your result stays saved.')) return;
|
| 136 |
+
cur = board.start.slice(); taps = {}; ms = 0; started = 0; done = false; undoStack = [];
|
| 137 |
+
store(`pathmoor:b:${key}`, null);
|
| 138 |
+
$('win').hidden = true;
|
| 139 |
+
hud(); draw();
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function totalTaps() { return Object.values(taps).reduce((a, b) => a + b, 0); }
|
| 143 |
+
|
| 144 |
+
function hud() {
|
| 145 |
+
$('time').textContent = fmtTime(done ? ms : elapsed());
|
| 146 |
+
$('taps').textContent = totalTaps();
|
| 147 |
+
const on = lit(board.n, board.src, cur);
|
| 148 |
+
$('lit').textContent = `${Math.round((on.reduce((a, b) => a + b, 0) / on.length) * 100)}%`;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function win() {
|
| 152 |
+
ms = elapsed(); started = 0; done = true; winAt = performance.now();
|
| 153 |
+
count(GAME, 'win', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack);
|
| 154 |
+
if (mode.kind === 'daily') {
|
| 155 |
+
const st = store('pathmoor:stats') || { days: {}, streak: 0, best: 0 };
|
| 156 |
+
if (!st.days[mode.date]) {
|
| 157 |
+
st.days[mode.date] = { ms: Math.round(ms), taps: totalTaps(), par: par(board) };
|
| 158 |
+
if (mode.date === todayIso) {
|
| 159 |
+
const y = dateOf(todayNum - 1);
|
| 160 |
+
st.streak = st.days[y] ? (st.streak || 0) + 1 : 1;
|
| 161 |
+
st.best = Math.max(st.best || 0, st.streak);
|
| 162 |
+
}
|
| 163 |
+
store('pathmoor:stats', st);
|
| 164 |
+
}
|
| 165 |
+
} else {
|
| 166 |
+
store(`pathmoor:pack:${mode.pack}`, Math.max(store(`pathmoor:pack:${mode.pack}`) || 0, mode.level));
|
| 167 |
+
}
|
| 168 |
+
save();
|
| 169 |
+
setTimeout(() => showWin(true), 650);
|
| 170 |
+
install.offer();
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function shareText() {
|
| 174 |
+
const t = totalTaps(), p = par(board);
|
| 175 |
+
const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} 🏮` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} 🏮`;
|
| 176 |
+
return `${head} ${fmtTime(ms)} · ${t} taps (par ${p})${t <= p ? ' ⭐ perfect' : ''}\n${shareGrid(board, taps)}`;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function showWin(scroll) {
|
| 180 |
+
const st = store('pathmoor:stats') || {};
|
| 181 |
+
const t = totalTaps(), p = par(board);
|
| 182 |
+
$('win-title').textContent = t <= p ? 'Perfect! Every stone lit in par.' : 'Every stone is lit!';
|
| 183 |
+
$('win-line').textContent = `${fmtTime(ms)} · ${t} taps (par ${p})` + (mode.kind === 'daily' && mode.date === todayIso && st.streak ? ` · 🔥 ${st.streak}-day streak` : '');
|
| 184 |
+
$('win-grid').textContent = shareGrid(board, taps);
|
| 185 |
+
const text = shareText();
|
| 186 |
+
const it = intents(GAME, text, PATH);
|
| 187 |
+
$('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
|
| 188 |
+
const pr = $('practice');
|
| 189 |
+
if (mode.kind === 'pack') {
|
| 190 |
+
const pk = packOf(mode.pack);
|
| 191 |
+
pr.textContent = mode.level < pk.levels ? `Next board: ${pk.name} #${mode.level + 1}` : 'Pack complete! Try another pack';
|
| 192 |
+
} else pr.textContent = 'Play a practice board';
|
| 193 |
+
$('win').hidden = false;
|
| 194 |
+
tick();
|
| 195 |
+
if (scroll) $('win').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function tick() {
|
| 199 |
+
const now = new Date();
|
| 200 |
+
const mid = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
| 201 |
+
const s = Math.round((mid - now) / 1000);
|
| 202 |
+
$('next').textContent = `Next daily board in ${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m.`;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// ------------------------------------------------------------------ drawing
|
| 206 |
+
|
| 207 |
+
function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) {
|
| 208 |
+
const S = SKINS[skin];
|
| 209 |
+
const n = board.n, cs = size / n, pad = cs * 0.06, w = cs * 0.2;
|
| 210 |
+
c.save();
|
| 211 |
+
c.fillStyle = S.bg; rr(c, x0, y0, size, size, size * 0.04); c.fill();
|
| 212 |
+
for (let i = 0; i < n * n; i++) {
|
| 213 |
+
const x = x0 + (i % n) * cs, y = y0 + ((i / n) | 0) * cs, cx = x + cs / 2, cy = y + cs / 2;
|
| 214 |
+
c.fillStyle = S.stone; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill();
|
| 215 |
+
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(); }
|
| 216 |
+
let ang = 0;
|
| 217 |
+
const a = anim[i];
|
| 218 |
+
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]; }
|
| 219 |
+
c.save(); c.translate(cx, cy); c.rotate((ang * Math.PI) / 180);
|
| 220 |
+
const m = cells[i], lightOn = on[i];
|
| 221 |
+
c.strokeStyle = lightOn ? S.on : S.off; c.lineWidth = w; c.lineCap = 'round';
|
| 222 |
+
if (lightOn) { c.shadowColor = S.glow; c.shadowBlur = cs * 0.25; }
|
| 223 |
+
c.beginPath();
|
| 224 |
+
const r = cs / 2;
|
| 225 |
+
if (m & 1) { c.moveTo(0, 0); c.lineTo(0, -r); }
|
| 226 |
+
if (m & 2) { c.moveTo(0, 0); c.lineTo(r, 0); }
|
| 227 |
+
if (m & 4) { c.moveTo(0, 0); c.lineTo(0, r); }
|
| 228 |
+
if (m & 8) { c.moveTo(0, 0); c.lineTo(-r, 0); }
|
| 229 |
+
c.stroke();
|
| 230 |
+
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(); }
|
| 231 |
+
c.restore();
|
| 232 |
+
if (i === board.src) {
|
| 233 |
+
const pulse = 1 + 0.06 * Math.sin(t / 300);
|
| 234 |
+
c.save(); c.shadowColor = S.glow; c.shadowBlur = cs * 0.5; c.fillStyle = S.lamp;
|
| 235 |
+
c.beginPath(); c.arc(cx, cy, cs * 0.2 * pulse, 0, Math.PI * 2); c.fill(); c.restore();
|
| 236 |
+
c.fillStyle = S.bg; c.beginPath(); c.arc(cx, cy, cs * 0.08, 0, Math.PI * 2); c.fill();
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
c.restore();
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
let raf = 0;
|
| 243 |
+
function draw() {
|
| 244 |
+
const t = performance.now();
|
| 245 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 246 |
+
const on = done ? new Uint8Array(cur.length).fill(1) : lit(board.n, board.src, cur);
|
| 247 |
+
drawBoard(ctx, 0, 0, canvas.width, cur, on, t, document.activeElement === canvas ? focus : -1);
|
| 248 |
+
if (done && t - winAt < 1200) {
|
| 249 |
+
ctx.fillStyle = `rgba(255,255,255,${0.18 * (1 - (t - winAt) / 1200)})`; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
| 250 |
+
}
|
| 251 |
+
return Object.keys(anim).length || (done && t - winAt < 1200);
|
| 252 |
+
}
|
| 253 |
+
function loop() {
|
| 254 |
+
if (raf) return;
|
| 255 |
+
const step = () => { raf = 0; if (draw()) raf = requestAnimationFrame(step); };
|
| 256 |
+
raf = requestAnimationFrame(step);
|
| 257 |
+
}
|
| 258 |
+
// the lantern breathes slowly; repaint a few times a second only while visible
|
| 259 |
+
setInterval(() => { if (!document.hidden) { draw(); if (!done && started) hud(); } }, 250);
|
| 260 |
+
|
| 261 |
+
// ------------------------------------------------------------------ result card (1080×1350 PNG)
|
| 262 |
+
|
| 263 |
+
function card() {
|
| 264 |
+
const c = document.createElement('canvas');
|
| 265 |
+
c.width = 1080; c.height = 1350;
|
| 266 |
+
const g = c.getContext('2d');
|
| 267 |
+
const S = SKINS[skin];
|
| 268 |
+
g.fillStyle = S.bg; g.fillRect(0, 0, 1080, 1350);
|
| 269 |
+
g.fillStyle = S.on; g.font = '900 64px system-ui, sans-serif'; g.fillText('PATHMOOR', 80, 130);
|
| 270 |
+
g.fillStyle = '#ffffffcc'; g.font = '700 40px system-ui, sans-serif';
|
| 271 |
+
g.fillText(mode.kind === 'daily' ? `Daily #${dayNumber(mode.date)} · ${mode.date}` : `${packOf(mode.pack).name} · board ${mode.level}`, 80, 190);
|
| 272 |
+
drawBoard(g, 140, 250, 800, cur, new Uint8Array(cur.length).fill(1), 0);
|
| 273 |
+
g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; g.fillText(fmtTime(ms), 80, 1170);
|
| 274 |
+
g.font = '700 44px system-ui, sans-serif'; g.fillStyle = S.on;
|
| 275 |
+
g.fillText(`${totalTaps()} taps · par ${par(board)}`, 80, 1235);
|
| 276 |
+
g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif';
|
| 277 |
+
g.fillText('Play free: cybermax.cybermax-tools.workers.dev/games/pathmoor', 80, 1300);
|
| 278 |
+
return c;
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// ------------------------------------------------------------------ input
|
| 282 |
+
|
| 283 |
+
function cellAt(ev) {
|
| 284 |
+
const r = canvas.getBoundingClientRect();
|
| 285 |
+
const x = Math.floor(((ev.clientX - r.left) / r.width) * board.n), y = Math.floor(((ev.clientY - r.top) / r.height) * board.n);
|
| 286 |
+
return x >= 0 && y >= 0 && x < board.n && y < board.n ? y * board.n + x : -1;
|
| 287 |
+
}
|
| 288 |
+
canvas.addEventListener('pointerdown', (ev) => { ev.preventDefault(); tap(cellAt(ev), ev.button === 2 || ev.shiftKey ? 3 : 1); });
|
| 289 |
+
canvas.addEventListener('contextmenu', (ev) => ev.preventDefault());
|
| 290 |
+
canvas.addEventListener('keydown', (ev) => {
|
| 291 |
+
const n = board.n;
|
| 292 |
+
if (focus < 0) focus = board.src;
|
| 293 |
+
const x = focus % n, y = (focus / n) | 0;
|
| 294 |
+
const mv = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[ev.key];
|
| 295 |
+
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(); }
|
| 296 |
+
else if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); tap(focus, ev.shiftKey ? 3 : 1); }
|
| 297 |
+
else if (ev.key === 'z' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); undo(); }
|
| 298 |
+
});
|
| 299 |
+
canvas.addEventListener('focus', draw); canvas.addEventListener('blur', draw);
|
| 300 |
+
document.addEventListener('visibilitychange', () => { if (document.hidden && started && !done) { ms = elapsed(); started = performance.now(); save(); } });
|
| 301 |
+
|
| 302 |
+
$('undo').onclick = undo;
|
| 303 |
+
$('restart').onclick = restart;
|
| 304 |
+
$('pass-btn').onclick = () => pass.open('header');
|
| 305 |
+
document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); });
|
| 306 |
+
$('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`);
|
| 307 |
+
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
|
| 308 |
+
$('card').onclick = async () => { download(await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); count(GAME, 'share', 'png'); };
|
| 309 |
+
$('practice').onclick = () => {
|
| 310 |
+
if (mode.kind === 'pack') {
|
| 311 |
+
const pk = packOf(mode.pack);
|
| 312 |
+
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);
|
| 313 |
+
} else {
|
| 314 |
+
const lvl = Math.min(30, (store('pathmoor:pack:sprout') || 0) + 1);
|
| 315 |
+
load({ kind: 'pack', pack: 'sprout', level: lvl }, true);
|
| 316 |
+
}
|
| 317 |
+
};
|
| 318 |
+
$('stats-btn').onclick = () => {
|
| 319 |
+
const st = store('pathmoor:stats') || { days: {} };
|
| 320 |
+
const d = Object.values(st.days || {});
|
| 321 |
+
const best = d.length ? fmtTime(Math.min(...d.map((x) => x.ms))) : '–';
|
| 322 |
+
toast(`Dailies solved: ${d.length} · streak ${st.streak || 0} (best ${st.best || 0}) · fastest ${best}`, 4500);
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
function renderSkins() {
|
| 326 |
+
const el = $('skins');
|
| 327 |
+
el.innerHTML = '';
|
| 328 |
+
for (const [id, s] of Object.entries(SKINS)) {
|
| 329 |
+
const b = document.createElement('button');
|
| 330 |
+
b.type = 'button'; b.title = `${s.name} theme${s.free ? '' : ' (Fun Pass)'}`; b.setAttribute('aria-label', b.title);
|
| 331 |
+
b.style.background = `linear-gradient(135deg, ${s.bg} 50%, ${s.on} 50%)`;
|
| 332 |
+
b.setAttribute('aria-pressed', String(id === skin));
|
| 333 |
+
if (!s.free && !pass.isPro()) b.classList.add('locked');
|
| 334 |
+
b.onclick = () => {
|
| 335 |
+
if (!s.free && !pass.isPro()) { pass.open('theme'); return; }
|
| 336 |
+
skin = id; store('pathmoor:skin', id); renderSkins(); draw();
|
| 337 |
+
};
|
| 338 |
+
el.appendChild(b);
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
pass.onChange((pro) => {
|
| 343 |
+
if (!pro && !SKINS[skin].free) { skin = 'moor'; store('pathmoor:skin', 'moor'); }
|
| 344 |
+
renderSkins();
|
| 345 |
+
if (mode) renderModes();
|
| 346 |
+
});
|
| 347 |
+
load(fromUrl());
|
| 348 |
+
|
| 349 |
+
// Screenshot helper for scripts/games/render_assets.mjs: shows a board mid-solve (no effect on normal play).
|
| 350 |
+
window.__cmxShot = async (stage = 1) => {
|
| 351 |
+
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; }
|
| 352 |
+
for (let i = 0; i < cur.length; i++) if (i % 7 !== 3) cur[i] = board.sol[i];
|
| 353 |
+
taps = { 1: 2, 4: 1, 9: 3 }; hud(); draw();
|
| 354 |
+
};
|
icon-192.png
ADDED
|
|
icon-512.png
ADDED
|
|
icon-maskable.png
ADDED
|
|
icon.svg
ADDED
|
|
index.html
CHANGED
|
@@ -1,19 +1,61 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 3 |
+
<title>Pathmoor: free daily path puzzle. Light every stone | CyberMax</title>
|
| 4 |
+
<meta name="description" content="Pathmoor is a free daily path puzzle: tap stones to turn them until every path glows from the lantern. A new board every day, about 2 minutes, share your time like Wordle. Works offline, installs on your phone.">
|
| 5 |
+
<link rel="canonical" href="https://cybermax.cybermax-tools.workers.dev/games/pathmoor/">
|
| 6 |
+
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
+
<meta property="og:title" content="Pathmoor: light every stone">
|
| 8 |
+
<meta property="og:description" content="A new path puzzle every day. Tap to turn stones until every path glows. Share your time.">
|
| 9 |
+
<meta property="og:url" content="https://cybermax.cybermax-tools.workers.dev/games/pathmoor/">
|
| 10 |
+
<meta property="og:image" content="https://cybermax.cybermax-tools.workers.dev/games/pathmoor/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
|
| 11 |
+
<meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermax.cybermax-tools.workers.dev/games/pathmoor/og.png">
|
| 12 |
+
<meta name="theme-color" content="#16241E">
|
| 13 |
+
<link rel="manifest" href="manifest.webmanifest">
|
| 14 |
+
<link rel="icon" href="icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icon-192.png">
|
| 15 |
+
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Pathmoor">
|
| 16 |
+
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 17 |
+
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('pathmoor:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
|
| 18 |
+
<script type="application/ld+json">{"@context":"https://schema.org","@type":"VideoGame","name":"Pathmoor","url":"https://cybermax.cybermax-tools.workers.dev/games/pathmoor/","image":"https://cybermax.cybermax-tools.workers.dev/games/pathmoor/og.png","description":"A free daily path puzzle: tap stones to turn them until every path glows from the lantern.","genre":["Puzzle","Logic"],"gamePlatform":["Web browser","Android","iOS"],"applicationCategory":"Game","operatingSystem":"Any","playMode":"SinglePlayer","publisher":{"@type":"Organization","name":"CyberMax","url":"https://cybermax.cybermax-tools.workers.dev/"},"offers":[{"@type":"Offer","price":"0","priceCurrency":"USD","description":"Daily puzzle, free"},{"@type":"Offer","price":"2.99","priceCurrency":"USD","description":"CyberMax Fun Pass, monthly"}]}</script>
|
| 19 |
+
</head><body>
|
| 20 |
+
<div class="wrap">
|
| 21 |
+
<header class="top"><a class="brand" href="https://cybermax.cybermax-tools.workers.dev/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 22 |
+
<nav><a href="https://cybermax.cybermax-tools.workers.dev/games/">Games</a></nav><button class="icon" id="stats-btn" type="button" aria-label="Your stats">📊</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 23 |
+
<main>
|
| 24 |
+
<section class="game">
|
| 25 |
+
<div class="headline"><h1>Pathmoor <span id="num" class="num"></span> <span class="pro-badge">PASS</span></h1>
|
| 26 |
+
<p class="muted tag">Tap stones to turn them. Light every path from the lantern.</p></div>
|
| 27 |
+
<div class="chips" id="modes" role="group" aria-label="Board"></div>
|
| 28 |
+
<div class="hud"><span>⏱ <b id="time">0:00</b></span><span>Taps <b id="taps">0</b></span><span>Par <b id="par">0</b></span><span>Lit <b id="lit">0%</b></span></div>
|
| 29 |
+
<div class="board-wrap"><canvas id="board" width="720" height="720" aria-label="Pathmoor board. Use arrow keys to move and Space to turn a stone." tabindex="0"></canvas></div>
|
| 30 |
+
<div class="row under"><button class="btn ghost sm" id="undo" type="button">↶ Undo</button><button class="btn ghost sm" id="restart" type="button">Restart</button><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
|
| 31 |
+
<section class="panel win" id="win" hidden aria-live="polite">
|
| 32 |
+
<h2 id="win-title">Every stone is lit!</h2>
|
| 33 |
+
<p id="win-line" class="muted"></p>
|
| 34 |
+
<pre id="win-grid" class="grid"></pre>
|
| 35 |
+
<div class="row"><button class="btn" id="share" type="button">Share result</button><a class="btn ghost sm" id="sx" target="_blank" rel="noopener">X</a><a class="btn ghost sm" id="sb" target="_blank" rel="noopener">Bluesky</a><a class="btn ghost sm" id="sw" target="_blank" rel="noopener">WhatsApp</a><button class="btn ghost sm" id="card" type="button">Save card</button></div>
|
| 36 |
+
<p class="muted small" id="next"></p>
|
| 37 |
+
<div class="row"><button class="btn ghost sm" id="practice" type="button">Play a practice board</button></div>
|
| 38 |
+
<div class="nudge"><span>Loved it? The <b>Fun Pass</b> adds 250 bigger boards, every past daily and colour themes.</span><button class="btn pass sm" data-pass="win" type="button">See the pass</button></div>
|
| 39 |
+
</section>
|
| 40 |
+
<div class="row skins" id="skins" aria-label="Board theme"></div>
|
| 41 |
+
</section>
|
| 42 |
+
|
| 43 |
+
<section class="seo">
|
| 44 |
+
<h2>How to play Pathmoor</h2>
|
| 45 |
+
<p>The board is a square of path stones. One stone holds the lantern. Tap any stone to turn it a quarter turn. When a path joins the lantern it glows. The puzzle is solved when <b>every stone glows and no path points into a wall or the edge</b>. Fewer taps is better: par is the fewest taps that can solve today's board.</p>
|
| 46 |
+
<h3>Tips</h3>
|
| 47 |
+
<ul><li>Start at the edges: a path can never point off the board, so corner stones often have only one way to sit.</li><li>Dead-end stones (one opening) must point into a neighbour that opens back.</li><li>Straight stones on an edge run along it, never into it.</li></ul>
|
| 48 |
+
<h3>A new board every day</h3>
|
| 49 |
+
<p>Everyone gets the same daily board, and it changes at your midnight. Share your time and taps with the grid: 🟩 a stone you set in par, 🟨 a few extra turns, 🟧 a lot, ⬜ a stone that was already right. Keep your streak going. Pathmoor works offline and installs on your phone like an app (no app store, no sign-up).</p>
|
| 50 |
+
<h3>Free, with an optional Fun Pass</h3>
|
| 51 |
+
<p>The daily board and three practice boards are free for everyone. The optional <b>CyberMax Fun Pass</b> ($2.99/month, $19/year, or $4.99 once) adds the Moorland 7×7, Highland 8×8 and Summit 10×10 packs (250 boards), the full archive of past dailies and colour themes, removes the supporter nudges, and works in every CyberMax game.</p>
|
| 52 |
+
<h3>FAQ</h3>
|
| 53 |
+
<p><b>Is there only one solution?</b> Any arrangement that lights every stone with no loose ends counts. <b>Does it track me?</b> No accounts, no cookies; only anonymous counts (a game was played or shared). <b>Why not just play another daily puzzle?</b> Pathmoor is a turn-the-tiles puzzle you can finish on a coffee break, with a fresh board daily and a par score to beat.</p>
|
| 54 |
+
<h2>More free games from CyberMax</h2>
|
| 55 |
+
<div class="more" id="more"><a href="https://cybermax.cybermax-tools.workers.dev/games/whirlane/"><b>Whirlane</b><span>One-tap orbit dodger with a daily leaderboard</span></a><a href="https://cybermax.cybermax-tools.workers.dev/games/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermax.cybermax-tools.workers.dev/toys/"><b>Web toys</b><span>Roast your website, vibe-check a name</span></a></div>
|
| 56 |
+
</section>
|
| 57 |
+
</main>
|
| 58 |
+
<footer>© 2026 CyberMax · <a href="https://cybermax.cybermax-tools.workers.dev/games/">Games</a> · <a href="https://cybermax.cybermax-tools.workers.dev/toys/">Toys</a> · <a href="https://cybermax.cybermax-tools.workers.dev/store/">Store</a> · <a href="https://cybermax.cybermax-tools.workers.dev/privacy">Privacy</a> · <a href="https://cybermax.cybermax-tools.workers.dev/terms">Terms</a></footer>
|
| 59 |
+
</div>
|
| 60 |
+
<script type="module" src="game.js"></script>
|
| 61 |
+
</body></html>
|
manifest.webmanifest
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Pathmoor",
|
| 3 |
+
"short_name": "Pathmoor",
|
| 4 |
+
"description": "Daily path puzzle: turn stones until every path glows. Free daily game by CyberMax, works offline.",
|
| 5 |
+
"id": "./",
|
| 6 |
+
"start_url": "./?utm_source=pwa",
|
| 7 |
+
"scope": "./",
|
| 8 |
+
"display": "standalone",
|
| 9 |
+
"orientation": "portrait",
|
| 10 |
+
"background_color": "#16241E",
|
| 11 |
+
"theme_color": "#16241E",
|
| 12 |
+
"categories": [
|
| 13 |
+
"games",
|
| 14 |
+
"entertainment"
|
| 15 |
+
],
|
| 16 |
+
"icons": [
|
| 17 |
+
{
|
| 18 |
+
"src": "icon-192.png",
|
| 19 |
+
"sizes": "192x192",
|
| 20 |
+
"type": "image/png"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"src": "icon-512.png",
|
| 24 |
+
"sizes": "512x512",
|
| 25 |
+
"type": "image/png"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"src": "icon-maskable.png",
|
| 29 |
+
"sizes": "512x512",
|
| 30 |
+
"type": "image/png",
|
| 31 |
+
"purpose": "maskable"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"src": "icon.svg",
|
| 35 |
+
"sizes": "any",
|
| 36 |
+
"type": "image/svg+xml"
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"screenshots": [
|
| 40 |
+
{
|
| 41 |
+
"src": "shot-phone.png",
|
| 42 |
+
"sizes": "780x1688",
|
| 43 |
+
"type": "image/png",
|
| 44 |
+
"form_factor": "narrow"
|
| 45 |
+
}
|
| 46 |
+
]
|
| 47 |
+
}
|
og.png
ADDED
|
Git LFS Details
|
shot-phone.png
ADDED
|
Git LFS Details
|
style.css
CHANGED
|
@@ -1,28 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
margin-top: 5px;
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
| 1 |
+
/* Pathmoor layout. Board colours live in game.js (SKINS). (c) 2026 CyberMax. */
|
| 2 |
+
.game{padding:4px 0 0}
|
| 3 |
+
.headline h1 .num{color:var(--muted);font-weight:700;font-size:.62em}
|
| 4 |
+
.tag{margin:0 0 12px}
|
| 5 |
+
.hud{display:flex;gap:14px;flex-wrap:wrap;margin:12px 0 8px;font-size:15px;color:var(--muted)}.hud b{color:var(--ink);font-variant-numeric:tabular-nums}
|
| 6 |
+
.board-wrap{display:flex;justify-content:center}
|
| 7 |
+
#board{width:100%;max-width:min(560px,calc(100vh - 230px));min-width:280px;aspect-ratio:1;border-radius:22px;touch-action:manipulation;cursor:pointer;box-shadow:var(--shadow);outline-offset:4px}
|
| 8 |
+
.under{justify-content:center;margin:12px 0}
|
| 9 |
+
.win{margin:14px 0}.win h2{margin:0 0 4px}
|
| 10 |
+
.grid{font:22px/1.15 system-ui,"Apple Color Emoji","Segoe UI Emoji",sans-serif;letter-spacing:2px;margin:10px 0;white-space:pre}
|
| 11 |
+
.skins{justify-content:center;margin:8px 0}
|
| 12 |
+
.skins button{width:36px;height:36px;border-radius:50%;border:3px solid var(--line);cursor:pointer;position:relative}
|
| 13 |
+
.skins button[aria-pressed=true]{border-color:var(--ink)}
|
| 14 |
+
.skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
sw.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
|
| 2 |
+
// network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
|
| 3 |
+
const GAME = 'pathmoor';
|
| 4 |
+
const CACHE = `cmx-${GAME}-ddcdf874da`;
|
| 5 |
+
const FILES = ["./", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "style.css"];
|
| 6 |
+
|
| 7 |
+
self.addEventListener('install', (e) => {
|
| 8 |
+
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|
| 9 |
+
});
|
| 10 |
+
self.addEventListener('activate', (e) => {
|
| 11 |
+
e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(`cmx-${GAME}-`) && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
| 12 |
+
});
|
| 13 |
+
self.addEventListener('fetch', (e) => {
|
| 14 |
+
const u = new URL(e.request.url);
|
| 15 |
+
if (e.request.method !== 'GET' || u.origin !== location.origin || !u.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
|
| 16 |
+
if (e.request.mode === 'navigate') {
|
| 17 |
+
e.respondWith(fetch(e.request).then((r) => { const c = r.clone(); caches.open(CACHE).then((x) => x.put(u.pathname, c)); return r; })
|
| 18 |
+
.catch(() => caches.match(u.pathname).then((r) => r || caches.match('./'))));
|
| 19 |
+
return;
|
| 20 |
+
}
|
| 21 |
+
e.respondWith(caches.match(e.request, { ignoreSearch: true }).then((r) => r || fetch(e.request).then((res) => {
|
| 22 |
+
if (res.ok) { const c = res.clone(); caches.open(CACHE).then((x) => x.put(e.request, c)); }
|
| 23 |
+
return res;
|
| 24 |
+
})));
|
| 25 |
+
});
|