Spaces:
Running
Running
Deploy pathmoor
Browse files- README.md +12 -2
- cmx.js +3 -1
- core.js +138 -0
- funpass.js +2 -2
- game.js +198 -46
- index.html +40 -23
- levels.js +341 -0
- style.css +2 -0
- sw.js +2 -2
README.md
CHANGED
|
@@ -6,6 +6,7 @@ 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
|
|
@@ -16,12 +17,21 @@ tags:
|
|
| 16 |
- browser-game
|
| 17 |
- pwa
|
| 18 |
- cybermax
|
| 19 |
-
thumbnail: https://huggingface.co/spaces/CyberMax-tools/pathmoor/resolve/main/thumbnail.png
|
| 20 |
---
|
| 21 |
**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).
|
| 22 |
|
| 23 |
Play it full screen: https://cybermaxtools.com/games/pathmoor/?utm_source=huggingface
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
More free games: https://cybermaxtools.com/games/ ยท ยฉ 2026 CyberMax. All rights reserved.
|
|
|
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
license: other
|
| 9 |
+
thumbnail: https://cybermaxtools.com/games/pathmoor/og.png
|
| 10 |
short_description: "Daily path puzzle: turn stones until every path glows"
|
| 11 |
tags:
|
| 12 |
- game
|
|
|
|
| 17 |
- browser-game
|
| 18 |
- pwa
|
| 19 |
- cybermax
|
|
|
|
| 20 |
---
|
| 21 |
**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).
|
| 22 |
|
| 23 |
Play it full screen: https://cybermaxtools.com/games/pathmoor/?utm_source=huggingface
|
| 24 |
|
| 25 |
+
**Watch the real gameplay demo:** https://cybermaxtools.com/media/demos/pathmoor/demo-16x9.mp4
|
| 26 |
+
|
| 27 |
+

|
| 28 |
+
|
| 29 |
+
**Embed it free on your site** (works on phones, keeps a small link to CyberMax):
|
| 30 |
+
|
| 31 |
+
```html
|
| 32 |
+
<iframe src="https://cybermaxtools.com/embed/pathmoor/" width="480" height="720" style="border:0;max-width:100%;aspect-ratio:2/3;height:auto" title="Pathmoor: free game by CyberMax" loading="lazy" allowfullscreen></iframe>
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $29.99 for life) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermaxtools.com/games/?utm_source=huggingface
|
| 36 |
|
| 37 |
More free games: https://cybermaxtools.com/games/ ยท ยฉ 2026 CyberMax. All rights reserved.
|
cmx.js
CHANGED
|
@@ -129,7 +129,7 @@ export function pwa(game, btn) {
|
|
| 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 () => {
|
|
@@ -137,10 +137,12 @@ export function pwa(game, btn) {
|
|
| 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 |
}
|
|
|
|
| 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 && !store(`cmx-install-no:${game}`)) btn.hidden = false; });
|
| 133 |
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
if (btn) {
|
| 135 |
btn.addEventListener('click', async () => {
|
|
|
|
| 137 |
deferred.prompt();
|
| 138 |
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
if (r && r.outcome === 'accepted') btn.hidden = true;
|
| 140 |
+
else if (r && r.outcome === 'dismissed') { store(`cmx-install-no:${game}`, 1); btn.hidden = true; } // "Not now" is remembered
|
| 141 |
deferred = null;
|
| 142 |
} else if (ios) {
|
| 143 |
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 144 |
count(game, 'install', 'ios-hint');
|
| 145 |
+
store(`cmx-install-no:${game}`, 1);
|
| 146 |
}
|
| 147 |
});
|
| 148 |
}
|
core.js
CHANGED
|
@@ -141,3 +141,141 @@ export const PACKS = [
|
|
| 141 |
{ id: 'highland', name: 'Highland 8ร8', n: 8, levels: 100 },
|
| 142 |
{ id: 'summit', name: 'Summit 10ร10', n: 10, levels: 50 },
|
| 143 |
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
{ id: 'highland', name: 'Highland 8ร8', n: 8, levels: 100 },
|
| 142 |
{ id: 'summit', name: 'Summit 10ร10', n: 10, levels: 50 },
|
| 143 |
];
|
| 144 |
+
|
| 145 |
+
// ------------------------------------------------------------------ levels (30 hand-tuned boards + twists)
|
| 146 |
+
// Twists, one new idea about every 5 levels: holes (shaped boards), twin stones (tap one, both turn), fog (you only
|
| 147 |
+
// see stones next to the light), reverse stones (turn anticlockwise) and bigger boards. Every 10th level is a
|
| 148 |
+
// challenge level with a tap budget: run out of taps and the board resets.
|
| 149 |
+
// Level boards use their own seed space, so the daily board above is unchanged.
|
| 150 |
+
|
| 151 |
+
/** n = size, mix = share of stones shuffled (early levels), holes = empty cells, twins = twin pairs, fog, rev = reverse stones, budget = par + slack (challenge). */
|
| 152 |
+
export const LEVELS = [
|
| 153 |
+
{ n: 3, mix: 0.35, name: 'First light' }, { n: 4, mix: 0.45, name: 'Crossroads' }, { n: 4, mix: 0.7, name: 'Moor gate' }, { n: 5, mix: 0.6, name: 'Bracken' }, { n: 5, mix: 0.7, name: 'Lantern walk' },
|
| 154 |
+
{ n: 5, holes: 3, name: 'Broken ground', twist: 'Holes in the moor' }, { n: 5, holes: 4, name: 'Bog pools' }, { n: 6, name: 'Long dusk' }, { n: 6, holes: 5, name: 'Peat cuts' },
|
| 155 |
+
{ n: 6, holes: 3, budget: 6, name: 'Lantern trial', boss: true },
|
| 156 |
+
{ n: 5, twins: 2, name: 'Twin stones', twist: 'Twin stones turn together' }, { n: 6, twins: 3, name: 'Paired path' }, { n: 6, twins: 3, holes: 3, name: 'Old wall' }, { n: 6, twins: 4, name: 'Mirror field' }, { n: 7, twins: 3, name: 'Wide moor' },
|
| 157 |
+
{ n: 6, fog: true, name: 'Moor fog', twist: 'Fog: only stones near the light show' }, { n: 6, fog: true, twins: 2, name: 'Grey morning' }, { n: 7, fog: true, holes: 4, name: 'Mist pools' }, { n: 7, twins: 4, holes: 4, name: 'Heather maze' },
|
| 158 |
+
{ n: 7, fog: true, twins: 3, budget: 10, name: 'Fog trial', boss: true },
|
| 159 |
+
{ n: 6, rev: 5, name: 'Backwards stones', twist: 'Purple stones turn the other way' }, { n: 7, rev: 7, name: 'Turnabout' }, { n: 7, rev: 6, twins: 3, name: 'Crossed wires' }, { n: 8, name: 'Highland' }, { n: 8, holes: 6, fog: true, name: 'Cloud tops' },
|
| 160 |
+
{ n: 9, name: 'Great moor', twist: 'Bigger boards' }, { n: 8, rev: 8, twins: 4, holes: 5, name: 'Tangle' }, { n: 9, fog: true, rev: 8, name: 'Night walk' }, { n: 9, rev: 9, twins: 5, holes: 6, name: 'Summit path' },
|
| 161 |
+
{ n: 10, rev: 10, twins: 5, holes: 6, fog: true, budget: 14, name: 'Summit trial', boss: true },
|
| 162 |
+
];
|
| 163 |
+
|
| 164 |
+
/** Stones reachable from the lantern (holes are walls). */
|
| 165 |
+
export function litB(b, cur) { return lit(b.n, b.src, cur); }
|
| 166 |
+
|
| 167 |
+
/** Solved: no loose ends and every stone that is not a hole is lit. */
|
| 168 |
+
export function isSolvedB(b, cur) {
|
| 169 |
+
if (looseEnds(b.n, cur)) return false;
|
| 170 |
+
const on = lit(b.n, b.src, cur);
|
| 171 |
+
for (let i = 0; i < on.length; i++) if (!on[i] && !b.hole[i]) return false;
|
| 172 |
+
return true;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/** The stones one tap on stone i turns (its twin too). */
|
| 176 |
+
export const groupOf = (b, i) => (b.twin[i] >= 0 ? [i, b.twin[i]] : [i]);
|
| 177 |
+
/** Turn direction of stone i: 1 clockwise, 3 (= one anticlockwise quarter) for reverse stones. */
|
| 178 |
+
export const dirOf = (b, i) => (b.rev[i] ? 3 : 1);
|
| 179 |
+
|
| 180 |
+
/** Apply one tap on stone i to cur (in place). k = 1 forward, 3 = back (undo / shift-tap). */
|
| 181 |
+
export function tapB(b, cur, i, k = 1) {
|
| 182 |
+
if (b.hole[i]) return false;
|
| 183 |
+
for (const j of groupOf(b, i)) cur[j] = turn(cur[j], dirOf(b, j) * k);
|
| 184 |
+
return true;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/** Fewest taps on the group of stone i that bring it from start back to the solution. */
|
| 188 |
+
function groupTaps(b, start, i) {
|
| 189 |
+
const g = groupOf(b, i);
|
| 190 |
+
for (let k = 0; k < 4; k++) if (g.every((j) => turn(start[j], dirOf(b, j) * k) === b.sol[j])) return k;
|
| 191 |
+
return 0;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
/** Par for a level board: the fewest taps from the shuffle to our solution. */
|
| 195 |
+
export function parB(b) {
|
| 196 |
+
let s = 0;
|
| 197 |
+
const seen = new Set();
|
| 198 |
+
for (let i = 0; i < b.n * b.n; i++) {
|
| 199 |
+
if (b.hole[i] || seen.has(i)) continue;
|
| 200 |
+
groupOf(b, i).forEach((j) => seen.add(j));
|
| 201 |
+
s += groupTaps(b, b.start, i);
|
| 202 |
+
}
|
| 203 |
+
return s;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
/** Stars: 3 = par or better, 2 = within about a third over par, 1 = solved. */
|
| 207 |
+
export function levelStars(taps, p) {
|
| 208 |
+
return taps <= p ? 3 : taps <= p + Math.ceil(p * 0.34) + 1 ? 2 : 1;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
/** Build level `num` (1-based) from LEVELS. Deterministic: the same level is the same board for everyone. */
|
| 212 |
+
export function generateLevel(num) {
|
| 213 |
+
const L = LEVELS[num - 1];
|
| 214 |
+
const n = L.n, rand = rng(`pathmoor:lv:${num}`);
|
| 215 |
+
for (let attempt = 0; attempt < 400; attempt++) {
|
| 216 |
+
const hole = new Uint8Array(n * n);
|
| 217 |
+
const c = Math.floor((n - 1) / 2);
|
| 218 |
+
const src = (c + Math.floor(rand() * 2)) * n + c + Math.floor(rand() * 2);
|
| 219 |
+
let holes = L.holes || 0;
|
| 220 |
+
for (let tries = 0; holes > 0 && tries < 200; tries++) {
|
| 221 |
+
const i = Math.floor(rand() * n * n);
|
| 222 |
+
if (i === src || hole[i]) continue;
|
| 223 |
+
hole[i] = 1; holes--;
|
| 224 |
+
}
|
| 225 |
+
const sol = new Array(n * n).fill(0);
|
| 226 |
+
const inTree = new Uint8Array(n * n);
|
| 227 |
+
inTree[src] = 1;
|
| 228 |
+
let frontier = [];
|
| 229 |
+
const addEdges = (i) => {
|
| 230 |
+
const x = i % n, y = (i / n) | 0;
|
| 231 |
+
for (const [bt, dx, dy, back] of DIRS) {
|
| 232 |
+
const nx = x + dx, ny = y + dy, j = ny * n + nx;
|
| 233 |
+
if (nx >= 0 && ny >= 0 && nx < n && ny < n && !inTree[j] && !hole[j]) frontier.push([i, j, bt, back]);
|
| 234 |
+
}
|
| 235 |
+
};
|
| 236 |
+
addEdges(src);
|
| 237 |
+
let count = 1;
|
| 238 |
+
const need = n * n - (L.holes || 0);
|
| 239 |
+
while (frontier.length) {
|
| 240 |
+
const k = Math.floor(rand() * frontier.length);
|
| 241 |
+
const [i, j, bt, back] = frontier[k];
|
| 242 |
+
frontier.splice(k, 1);
|
| 243 |
+
if (inTree[j]) continue;
|
| 244 |
+
sol[i] |= bt; sol[j] |= back; inTree[j] = 1; count++;
|
| 245 |
+
addEdges(j);
|
| 246 |
+
}
|
| 247 |
+
if (count < need) continue; // holes cut the moor in two: re-roll
|
| 248 |
+
const cells = [...Array(n * n).keys()].filter((i) => !hole[i]);
|
| 249 |
+
const straights = cells.filter((i) => sol[i] === (N | S) || sol[i] === (E | W)).length;
|
| 250 |
+
if (n >= 5 && straights > cells.length * 0.4) continue;
|
| 251 |
+
// twins: pairs of stones (not crosses, not the lantern) that turn together
|
| 252 |
+
const twin = new Int16Array(n * n).fill(-1);
|
| 253 |
+
const pool = cells.filter((i) => i !== src && bits(sol[i]) < 4);
|
| 254 |
+
for (let t = 0; t < (L.twins || 0) && pool.length > 1; t++) {
|
| 255 |
+
const a = pool.splice(Math.floor(rand() * pool.length), 1)[0];
|
| 256 |
+
const bb = pool.splice(Math.floor(rand() * pool.length), 1)[0];
|
| 257 |
+
twin[a] = bb; twin[bb] = a;
|
| 258 |
+
}
|
| 259 |
+
const rev = new Uint8Array(n * n);
|
| 260 |
+
const rpool = cells.filter((i) => twin[i] < 0 && bits(sol[i]) < 4 && !(sol[i] === (N | S) || sol[i] === (E | W)));
|
| 261 |
+
for (let t = 0; t < (L.rev || 0) && rpool.length; t++) rev[rpool.splice(Math.floor(rand() * rpool.length), 1)[0]] = 1;
|
| 262 |
+
const b = { n, src, sol, hole, twin, rev, fog: !!L.fog, level: num };
|
| 263 |
+
// shuffle by tapping: every group gets 0-3 backward taps, so the board is always solvable
|
| 264 |
+
const start = sol.slice();
|
| 265 |
+
const done = new Set();
|
| 266 |
+
for (const i of cells) {
|
| 267 |
+
if (done.has(i)) continue;
|
| 268 |
+
groupOf(b, i).forEach((j) => done.add(j));
|
| 269 |
+
const k = rand() < (L.mix || 0.75) ? 1 + Math.floor(rand() * 3) : 0;
|
| 270 |
+
for (const j of groupOf(b, i)) start[j] = turn(start[j], dirOf(b, j) * 3 * k);
|
| 271 |
+
}
|
| 272 |
+
b.start = start;
|
| 273 |
+
if (isSolvedB(b, start)) continue;
|
| 274 |
+
const p = parB(b);
|
| 275 |
+
if (p < Math.max(3, Math.floor(cells.length * 0.35 * (L.mix || 0.75)))) continue; // too few taps needed: not a real puzzle
|
| 276 |
+
b.par = p;
|
| 277 |
+
b.budget = L.budget ? p + L.budget : 0;
|
| 278 |
+
return b;
|
| 279 |
+
}
|
| 280 |
+
throw new Error(`level ${num}: no board`);
|
| 281 |
+
}
|
funpass.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 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 ยท
|
| 4 |
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
// After checkout Stripe sends the buyer to data.cybermaxtools.com/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>.
|
|
@@ -15,7 +15,7 @@ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cance
|
|
| 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: '
|
| 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 |
|
|
|
|
| 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.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
|
| 5 |
// After checkout Stripe sends the buyer to data.cybermaxtools.com/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>.
|
|
|
|
| 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 |
|
game.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
-
// Pathmoor: the game screen (canvas board, timer, packs, archive, share, card, themes). (c) 2026 CyberMax.
|
| 2 |
-
import { generate,
|
|
|
|
| 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 |
|
|
@@ -19,10 +20,16 @@ const SKINS = {
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
-
pass.init({ game: GAME, name: 'Pathmoor', perks: ['250
|
| 26 |
const install = pwa(GAME, $('install'));
|
| 27 |
themeToggle($('theme'));
|
| 28 |
|
|
@@ -30,52 +37,117 @@ themeToggle($('theme'));
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
mode = m;
|
| 54 |
key = keyOf(m);
|
| 55 |
-
|
| 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 =
|
|
|
|
|
|
|
| 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 };
|
|
@@ -88,7 +160,7 @@ function renderModes() {
|
|
| 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 |
}
|
|
@@ -108,31 +180,43 @@ 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 |
-
|
| 118 |
-
|
|
|
|
| 119 |
taps[i] = (taps[i] || 0) + 1;
|
| 120 |
-
anim[
|
| 121 |
if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ }
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
| 123 |
save(); hud(); loop();
|
| 124 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
function undo() {
|
| 127 |
if (done || !undoStack.length) return;
|
| 128 |
-
const [i, m]
|
| 129 |
-
cur[i] = m;
|
| 130 |
-
anim[i] = { from: 90, t0: performance.now() };
|
| 131 |
save(); hud(); loop();
|
| 132 |
}
|
| 133 |
|
| 134 |
-
|
| 135 |
-
|
|
|
|
| 136 |
cur = board.start.slice(); taps = {}; ms = 0; started = 0; done = false; undoStack = [];
|
| 137 |
store(`pathmoor:b:${key}`, null);
|
| 138 |
$('win').hidden = true;
|
|
@@ -144,17 +228,25 @@ function totalTaps() { return Object.values(taps).reduce((a, b) => a + b, 0); }
|
|
| 144 |
function hud() {
|
| 145 |
$('time').textContent = fmtTime(done ? ms : elapsed());
|
| 146 |
$('taps').textContent = totalTaps();
|
| 147 |
-
const on =
|
| 148 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 158 |
if (mode.date === todayIso) {
|
| 159 |
const y = dateOf(todayNum - 1);
|
| 160 |
st.streak = st.days[y] ? (st.streak || 0) + 1 : 1;
|
|
@@ -165,31 +257,55 @@ function win() {
|
|
| 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 =
|
|
|
|
| 175 |
const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} ๐ฎ` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} ๐ฎ`;
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
}
|
| 178 |
|
| 179 |
function showWin(scroll) {
|
| 180 |
const st = store('pathmoor:stats') || {};
|
| 181 |
-
const t = totalTaps(), p =
|
| 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 (
|
|
|
|
| 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 =
|
| 193 |
$('win').hidden = false;
|
| 194 |
pass.afterWin($('win'), 'win');
|
| 195 |
tick();
|
|
@@ -205,14 +321,37 @@ function tick() {
|
|
| 205 |
|
| 206 |
// ------------------------------------------------------------------ drawing
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) {
|
| 209 |
const S = SKINS[skin];
|
|
|
|
| 210 |
const n = board.n, cs = size / n, pad = cs * 0.06, w = cs * 0.2;
|
| 211 |
c.save();
|
| 212 |
c.fillStyle = S.bg; rr(c, x0, y0, size, size, size * 0.04); c.fill();
|
| 213 |
for (let i = 0; i < n * n; i++) {
|
| 214 |
const x = x0 + (i % n) * cs, y = y0 + ((i / n) | 0) * cs, cx = x + cs / 2, cy = y + cs / 2;
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
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(); }
|
| 217 |
let ang = 0;
|
| 218 |
const a = anim[i];
|
|
@@ -244,7 +383,7 @@ let raf = 0;
|
|
| 244 |
function draw() {
|
| 245 |
const t = performance.now();
|
| 246 |
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 247 |
-
const on = done ? new Uint8Array(cur.length).fill(1) :
|
| 248 |
drawBoard(ctx, 0, 0, canvas.width, cur, on, t, document.activeElement === canvas ? focus : -1);
|
| 249 |
if (done && t - winAt < 1200) {
|
| 250 |
ctx.fillStyle = `rgba(255,255,255,${0.18 * (1 - (t - winAt) / 1200)})`; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
@@ -269,11 +408,11 @@ function card() {
|
|
| 269 |
g.fillStyle = S.bg; g.fillRect(0, 0, 1080, 1350);
|
| 270 |
g.fillStyle = S.on; g.font = '900 64px system-ui, sans-serif'; g.fillText('PATHMOOR', 80, 130);
|
| 271 |
g.fillStyle = '#ffffffcc'; g.font = '700 40px system-ui, sans-serif';
|
| 272 |
-
g.fillText(mode.kind === 'daily' ? `Daily #${dayNumber(mode.date)} ยท ${mode.date}` : `${packOf(mode.pack).name} ยท board ${mode.level}`, 80, 190);
|
| 273 |
drawBoard(g, 140, 250, 800, cur, new Uint8Array(cur.length).fill(1), 0);
|
| 274 |
g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; g.fillText(fmtTime(ms), 80, 1170);
|
| 275 |
g.font = '700 44px system-ui, sans-serif'; g.fillStyle = S.on;
|
| 276 |
-
g.fillText(`${totalTaps()} taps ยท par ${
|
| 277 |
g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif';
|
| 278 |
g.fillText('Play free: cybermaxtools.com/games/pathmoor', 80, 1300);
|
| 279 |
return c;
|
|
@@ -296,19 +435,31 @@ canvas.addEventListener('keydown', (ev) => {
|
|
| 296 |
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(); }
|
| 297 |
else if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); tap(focus, ev.shiftKey ? 3 : 1); }
|
| 298 |
else if (ev.key === 'z' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); undo(); }
|
|
|
|
| 299 |
});
|
| 300 |
canvas.addEventListener('focus', draw); canvas.addEventListener('blur', draw);
|
| 301 |
document.addEventListener('visibilitychange', () => { if (document.hidden && started && !done) { ms = elapsed(); started = performance.now(); save(); } });
|
| 302 |
|
| 303 |
$('undo').onclick = undo;
|
| 304 |
-
$('restart').onclick = restart;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
$('pass-btn').onclick = () => pass.open('header');
|
| 306 |
document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); });
|
| 307 |
-
$('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`);
|
| 308 |
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
|
| 309 |
$('card').onclick = async () => { download(await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); count(GAME, 'share', 'png'); };
|
| 310 |
$('practice').onclick = () => {
|
| 311 |
-
if (mode.kind === '
|
|
|
|
|
|
|
| 312 |
const pk = packOf(mode.pack);
|
| 313 |
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);
|
| 314 |
} else {
|
|
@@ -320,7 +471,7 @@ $('stats-btn').onclick = () => {
|
|
| 320 |
const st = store('pathmoor:stats') || { days: {} };
|
| 321 |
const d = Object.values(st.days || {});
|
| 322 |
const best = d.length ? fmtTime(Math.min(...d.map((x) => x.ms))) : 'โ';
|
| 323 |
-
toast(`Dailies solved: ${d.length} ยท streak ${st.streak || 0} (best ${st.best || 0}) ยท fastest ${best}`, 4500);
|
| 324 |
};
|
| 325 |
|
| 326 |
function renderSkins() {
|
|
@@ -345,7 +496,8 @@ pass.onChange((pro) => {
|
|
| 345 |
renderSkins();
|
| 346 |
if (mode) renderModes();
|
| 347 |
});
|
| 348 |
-
|
|
|
|
| 349 |
|
| 350 |
// Screenshot helper for scripts/games/render_assets.mjs: shows a board mid-solve (no effect on normal play).
|
| 351 |
window.__cmxShot = async (stage = 1) => {
|
|
|
|
| 1 |
+
// Pathmoor: the game screen (canvas board, timer, 30 levels, daily, packs, archive, share, card, themes). (c) 2026 CyberMax.
|
| 2 |
+
import { generate, turn, turnsTo, par, shareGrid, fmtTime, PACKS, bits, LEVELS, generateLevel, isSolvedB, litB, tapB, groupOf, levelStars } from './core.js';
|
| 3 |
+
import * as lv from './levels.js';
|
| 4 |
import { today, dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
|
| 5 |
import * as pass from './funpass.js';
|
| 6 |
|
|
|
|
| 20 |
const todayIso = today();
|
| 21 |
const todayNum = dayNumber(todayIso);
|
| 22 |
let skin = SKINS[store('pathmoor:skin')] ? store('pathmoor:skin') : 'moor';
|
| 23 |
+
let mode; // { kind: 'daily', date } | { kind: 'pack', pack, level } | { kind: 'level', level }
|
| 24 |
+
let board, cur, taps, undoStack, ms, started, done, key, focus = -1, anim = {}, winAt = 0, failed = false;
|
| 25 |
+
let muted = !!store('pathmoor:mute');
|
| 26 |
+
const sfx = lv.sfx(() => muted);
|
| 27 |
+
const prog = lv.progress(GAME, { total: LEVELS.length, free: 20, isPro: () => pass.isPro() });
|
| 28 |
+
let chal = lv.readChallenge(); // ?seed=L7&score=12 or ?seed=2026-10-08&score=30 ("beat my score")
|
| 29 |
+
const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss }));
|
| 30 |
+
const WORLDS = ['Sprout Moor', 'Twin Fields', 'Highland (Fun Pass)'];
|
| 31 |
|
| 32 |
+
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'] });
|
| 33 |
const install = pwa(GAME, $('install'));
|
| 34 |
themeToggle($('theme'));
|
| 35 |
|
|
|
|
| 37 |
|
| 38 |
function packOf(id) { return PACKS.find((p) => p.id === id); }
|
| 39 |
function locked(m) {
|
| 40 |
+
if (m.kind === 'level') return prog.lock(m.level, { challenge: !!m.challenge });
|
| 41 |
if (pass.isPro()) return false;
|
| 42 |
if (m.kind === 'daily') return m.date !== todayIso;
|
| 43 |
const p = packOf(m.pack);
|
| 44 |
return !(p.freeLevels && m.level <= p.freeLevels);
|
| 45 |
}
|
| 46 |
+
function keyOf(m) { return m.kind === 'daily' ? `d:${m.date}` : m.kind === 'level' ? `l:${m.level}` : `p:${m.pack}:${m.level}`; }
|
| 47 |
+
function label(m) { return m.kind === 'daily' ? `#${dayNumber(m.date)}` : m.kind === 'level' ? `Level ${m.level}` : `${packOf(m.pack).name} ยท ${m.level}`; }
|
| 48 |
|
| 49 |
function fromUrl() {
|
| 50 |
const q = new URLSearchParams(location.search);
|
| 51 |
+
if (chal && chal.level && chal.level <= LEVELS.length) return { kind: 'level', level: chal.level, challenge: true };
|
| 52 |
+
if (chal && chal.date) return { kind: 'daily', date: pass.isPro() || chal.date === todayIso ? chal.date : todayIso };
|
| 53 |
+
const lq = Number(q.get('lv'));
|
| 54 |
+
if (lq >= 1 && lq <= LEVELS.length) return { kind: 'level', level: lq };
|
| 55 |
const d = q.get('d');
|
| 56 |
if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso) return { kind: 'daily', date: d };
|
| 57 |
const p = packOf(q.get('p') || '');
|
| 58 |
const l = Number(q.get('l'));
|
| 59 |
if (p && p.id !== 'daily' && l >= 1 && l <= p.levels) return { kind: 'pack', pack: p.id, level: l };
|
| 60 |
+
// first visit: start on level 1 (2-second tutorial); returning players land on today's daily board
|
| 61 |
+
if (!store('pathmoor:stats') && !prog.cleared()) return { kind: 'level', level: 1 };
|
| 62 |
return { kind: 'daily', date: todayIso };
|
| 63 |
}
|
| 64 |
|
| 65 |
+
/** Level and daily boards share one shape: holes, twins, reverse stones and fog are empty on plain boards. */
|
| 66 |
+
/** The daily streak (days in a row with a solved daily), 0 once a day is missed. */
|
| 67 |
+
function streakNow() {
|
| 68 |
+
const st = store('pathmoor:stats') || { days: {} };
|
| 69 |
+
return st.days[todayIso] || st.days[dateOf(todayNum - 1)] ? st.streak || 0 : 0;
|
| 70 |
+
}
|
| 71 |
+
function plain(b) {
|
| 72 |
+
const z = b.n * b.n;
|
| 73 |
+
return { ...b, hole: new Uint8Array(z), twin: new Int16Array(z).fill(-1), rev: new Uint8Array(z), fog: false, par: par(b), budget: 0 };
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
function load(m, push = false) {
|
| 77 |
+
const lk = locked(m);
|
| 78 |
+
if (lk) {
|
| 79 |
+
if (m.kind === 'level' && lk === 'progress') { toast(`Clear level ${prog.reached()} first.`); return false; }
|
| 80 |
+
pass.open(m.kind === 'daily' ? 'archive' : m.kind === 'level' ? 'level' : m.pack); return false;
|
| 81 |
+
}
|
| 82 |
mode = m;
|
| 83 |
key = keyOf(m);
|
| 84 |
+
if (m.kind === 'level') { prog.open(m.level); board = generateLevel(m.level); }
|
| 85 |
+
else board = plain(generate(m.kind === 'daily' ? m.date : `${m.pack}-${m.level}`, m.kind === 'daily' ? 6 : packOf(m.pack).n));
|
| 86 |
+
const n = board.n;
|
| 87 |
+
failed = false;
|
| 88 |
+
$('levels').hidden = true;
|
| 89 |
const saved = store(`pathmoor:b:${key}`);
|
| 90 |
if (saved && saved.cur && saved.cur.length === n * n) {
|
| 91 |
cur = saved.cur; taps = saved.taps || {}; ms = saved.ms || 0; done = !!saved.done; undoStack = [];
|
| 92 |
} else { cur = board.start.slice(); taps = {}; ms = 0; done = false; undoStack = []; }
|
| 93 |
started = 0; anim = {}; focus = -1;
|
| 94 |
$('num').textContent = label(m);
|
| 95 |
+
$('par').textContent = board.par;
|
| 96 |
+
goal();
|
| 97 |
+
banner();
|
| 98 |
if (push) {
|
| 99 |
const u = new URL(location.href);
|
| 100 |
+
u.search = m.kind === 'daily' ? (m.date === todayIso ? '' : `?d=${m.date}`) : m.kind === 'level' ? `?lv=${m.level}` : `?p=${m.pack}&l=${m.level}`;
|
| 101 |
window.history.replaceState(null, '', u.pathname + u.search);
|
| 102 |
}
|
| 103 |
renderModes();
|
| 104 |
$('win').hidden = true;
|
| 105 |
if (done) showWin(false);
|
| 106 |
hud(); draw();
|
| 107 |
+
if (m.kind === 'level' && m.level === 1 && !done) setTimeout(() => {
|
| 108 |
+
const i = board.start.findIndex((v, j) => v !== board.sol[j]);
|
| 109 |
+
lv.tutorial(GAME, canvas, { fx: ((i % n) + 0.5) / n, fy: (((i / n) | 0) + 0.5) / n });
|
| 110 |
+
}, 300);
|
| 111 |
+
if (m.kind === 'level' && board.budget && !done) sfx.boss();
|
| 112 |
return true;
|
| 113 |
}
|
| 114 |
|
| 115 |
+
/** The goal line under the modes: what to do on this board, in one glance. */
|
| 116 |
+
function goal() {
|
| 117 |
+
const L = mode.kind === 'level' ? LEVELS[mode.level - 1] : null;
|
| 118 |
+
const bits2 = [];
|
| 119 |
+
if (L && L.boss) bits2.push('๐ Challenge level');
|
| 120 |
+
bits2.push(`๐ฏ Light every stone in <b>${board.par}</b> taps for โญโญโญ`);
|
| 121 |
+
if (board.budget) bits2.push(`run out of <b>${board.budget}</b> taps and it resets`);
|
| 122 |
+
if (L && L.twist) bits2.push(`<span class="new-twist">NEW: ${L.twist}</span>`);
|
| 123 |
+
$('goal').innerHTML = bits2.join(' ยท ');
|
| 124 |
+
$('left-wrap').hidden = !board.budget;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/** "Beat my score" banner when the page was opened from a friend's link for this same board. */
|
| 128 |
+
function banner() {
|
| 129 |
+
const el = $('challenge');
|
| 130 |
+
const same = chal && chal.score !== null && ((chal.level && mode.kind === 'level' && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date));
|
| 131 |
+
el.hidden = !same;
|
| 132 |
+
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>`;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function showMap() {
|
| 136 |
+
const el = $('levels');
|
| 137 |
+
if (!el.hidden) { el.hidden = true; return; }
|
| 138 |
+
lv.renderMap($('lvmap'), prog, MAP, {
|
| 139 |
+
current: mode.kind === 'level' ? mode.level : 0, worlds: WORLDS,
|
| 140 |
+
onPick: (k) => load({ kind: 'level', level: k }, true),
|
| 141 |
+
onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)),
|
| 142 |
+
});
|
| 143 |
+
el.hidden = false;
|
| 144 |
+
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
function renderModes() {
|
| 148 |
const el = $('modes');
|
| 149 |
+
const chips = [{ m: { kind: 'daily', date: todayIso }, t: `Today #${todayNum}${streakNow() ? ` ๐ฅ${streakNow()}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso },
|
| 150 |
+
{ map: true, t: `Levels ${prog.cleared()}/${LEVELS.length} โญ${prog.totalStars()}`, on: mode.kind === 'level' }];
|
| 151 |
for (const p of PACKS.filter((x) => x.id !== 'daily')) {
|
| 152 |
const lvl = Math.min(p.levels, (store(`pathmoor:pack:${p.id}`) || 0) + 1);
|
| 153 |
const m = { kind: 'pack', pack: p.id, level: lvl };
|
|
|
|
| 160 |
b.className = 'chip'; b.type = 'button';
|
| 161 |
b.setAttribute('aria-pressed', String(!!c.on));
|
| 162 |
b.innerHTML = `${c.t}${c.lock ? ' <span class="lock">๐</span>' : ''}`;
|
| 163 |
+
b.onclick = () => (c.archive ? archive() : c.map ? showMap() : load(c.m, true));
|
| 164 |
el.appendChild(b);
|
| 165 |
});
|
| 166 |
}
|
|
|
|
| 180 |
function elapsed() { return ms + (started ? performance.now() - started : 0); }
|
| 181 |
|
| 182 |
function tap(i, k = 1) {
|
| 183 |
+
if (done || failed || i < 0 || board.hole[i]) return;
|
| 184 |
if (!started) {
|
| 185 |
started = performance.now();
|
| 186 |
if (!Object.keys(taps).length && !ms) count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack);
|
| 187 |
}
|
| 188 |
+
if (bits(cur[i]) === 4 && board.twin[i] < 0) return; // a cross never changes
|
| 189 |
+
const before = litCount();
|
| 190 |
+
undoStack.push(groupOf(board, i).map((j) => [j, cur[j]]));
|
| 191 |
+
tapB(board, cur, i, k);
|
| 192 |
taps[i] = (taps[i] || 0) + 1;
|
| 193 |
+
for (const j of groupOf(board, i)) anim[j] = { from: (board.rev[j] ? -1 : 1) * (k === 1 ? -90 : 90), t0: performance.now() };
|
| 194 |
if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ }
|
| 195 |
+
const gain = litCount() - before;
|
| 196 |
+
if (gain > 0) sfx.good(Math.min(20, gain)); else sfx.tap();
|
| 197 |
+
if (isSolvedB(board, cur)) win();
|
| 198 |
+
else if (board.budget && totalTaps() >= board.budget) fail();
|
| 199 |
save(); hud(); loop();
|
| 200 |
}
|
| 201 |
+
function litCount() { return litB(board, cur).reduce((a, b) => a + b, 0); }
|
| 202 |
+
|
| 203 |
+
/** Challenge level: out of taps. Shake, buzz, and the board resets by itself (one tap to try again). */
|
| 204 |
+
function fail() {
|
| 205 |
+
failed = true; sfx.hit(); lv.shake(canvas, 10, 320);
|
| 206 |
+
if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
|
| 207 |
+
toast(`Out of taps! The board resets. Par is ${board.par}.`, 1800);
|
| 208 |
+
setTimeout(() => { failed = false; restart(true); }, 650);
|
| 209 |
+
}
|
| 210 |
|
| 211 |
function undo() {
|
| 212 |
if (done || !undoStack.length) return;
|
| 213 |
+
for (const [i, m] of undoStack.pop()) { cur[i] = m; anim[i] = { from: 90, t0: performance.now() }; }
|
|
|
|
|
|
|
| 214 |
save(); hud(); loop();
|
| 215 |
}
|
| 216 |
|
| 217 |
+
/** Instant restart: one tap, no question (a finished result stays saved in your stats and stars). */
|
| 218 |
+
function restart(quiet = false) {
|
| 219 |
+
if (!quiet) sfx.tap();
|
| 220 |
cur = board.start.slice(); taps = {}; ms = 0; started = 0; done = false; undoStack = [];
|
| 221 |
store(`pathmoor:b:${key}`, null);
|
| 222 |
$('win').hidden = true;
|
|
|
|
| 228 |
function hud() {
|
| 229 |
$('time').textContent = fmtTime(done ? ms : elapsed());
|
| 230 |
$('taps').textContent = totalTaps();
|
| 231 |
+
const on = litB(board, cur);
|
| 232 |
+
const cells = on.length - board.hole.reduce((a, b) => a + b, 0);
|
| 233 |
+
$('lit').textContent = `${Math.round((on.reduce((a, b) => a + b, 0) / cells) * 100)}%`;
|
| 234 |
+
if (board.budget) $('left').textContent = Math.max(0, board.budget - totalTaps());
|
| 235 |
}
|
| 236 |
|
| 237 |
function win() {
|
| 238 |
ms = elapsed(); started = 0; done = true; winAt = performance.now();
|
| 239 |
+
count(GAME, 'win', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.kind === 'level' ? `level-${mode.level}` : mode.pack);
|
| 240 |
+
sfx.win(); lv.burstAt(canvas, 0.5, 0.5, { n: 60, speed: 9, colors: [SKINS[skin].on, SKINS[skin].lamp, '#FFFFFF'] });
|
| 241 |
+
if (mode.kind === 'level') {
|
| 242 |
+
const st = levelStars(totalTaps(), board.par);
|
| 243 |
+
mode.result = prog.record(mode.level, st, totalTaps());
|
| 244 |
+
mode.result.got = st;
|
| 245 |
+
for (let k = 0; k < st; k++) sfx.star(k);
|
| 246 |
+
} else if (mode.kind === 'daily') {
|
| 247 |
const st = store('pathmoor:stats') || { days: {}, streak: 0, best: 0 };
|
| 248 |
if (!st.days[mode.date]) {
|
| 249 |
+
st.days[mode.date] = { ms: Math.round(ms), taps: totalTaps(), par: board.par };
|
| 250 |
if (mode.date === todayIso) {
|
| 251 |
const y = dateOf(todayNum - 1);
|
| 252 |
st.streak = st.days[y] ? (st.streak || 0) + 1 : 1;
|
|
|
|
| 257 |
} else {
|
| 258 |
store(`pathmoor:pack:${mode.pack}`, Math.max(store(`pathmoor:pack:${mode.pack}`) || 0, mode.level));
|
| 259 |
}
|
| 260 |
+
renderModes();
|
| 261 |
save();
|
| 262 |
setTimeout(() => showWin(true), 650);
|
| 263 |
install.offer();
|
| 264 |
}
|
| 265 |
|
| 266 |
function shareText() {
|
| 267 |
+
const t = totalTaps(), p = board.par;
|
| 268 |
+
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?`;
|
| 269 |
const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} ๐ฎ` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} ๐ฎ`;
|
| 270 |
+
const streak = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` ยท ๐ฅ${streakNow()}` : '';
|
| 271 |
+
return `${head} ${fmtTime(ms)} ยท ${t} taps (par ${p})${t <= p ? ' โญ perfect' : ''}${streak}\n${shareGrid(board, taps)}`;
|
| 272 |
+
}
|
| 273 |
+
/** The "beat my score" link: the same board, with my taps to beat. */
|
| 274 |
+
function beatLink() {
|
| 275 |
+
if (mode.kind === 'level') return lv.challengeLink(PATH, GAME, { level: mode.level, score: totalTaps() });
|
| 276 |
+
if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: totalTaps() });
|
| 277 |
+
return `https://cybermaxtools.com${PATH}?p=${mode.pack}&l=${mode.level}`;
|
| 278 |
}
|
| 279 |
|
| 280 |
function showWin(scroll) {
|
| 281 |
const st = store('pathmoor:stats') || {};
|
| 282 |
+
const t = totalTaps(), p = board.par;
|
| 283 |
$('win-title').textContent = t <= p ? 'Perfect! Every stone lit in par.' : 'Every stone is lit!';
|
| 284 |
+
const isLv = mode.kind === 'level';
|
| 285 |
+
$('win-stars').hidden = !isLv;
|
| 286 |
+
if (isLv) {
|
| 287 |
+
const st = prog.stars(mode.level);
|
| 288 |
+
$('win-stars').innerHTML = lv.starsHtml(st);
|
| 289 |
+
$('win-title').textContent = st === 3 ? `Level ${mode.level}: perfect!` : `Level ${mode.level} cleared!`;
|
| 290 |
+
}
|
| 291 |
+
$('next-lv').hidden = !isLv || mode.level >= LEVELS.length;
|
| 292 |
+
$('retry').hidden = !isLv;
|
| 293 |
+
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} โถ`;
|
| 294 |
$('win-line').textContent = `${fmtTime(ms)} ยท ${t} taps (par ${p})` + (mode.kind === 'daily' && mode.date === todayIso && st.streak ? ` ยท ๐ฅ ${st.streak}-day streak` : '');
|
| 295 |
+
$('win-grid').textContent = isLv ? '' : shareGrid(board, taps);
|
| 296 |
+
$('win-grid').hidden = isLv;
|
| 297 |
+
if (chal && chal.score !== null && !$('challenge').hidden) {
|
| 298 |
+
$('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?`;
|
| 299 |
+
}
|
| 300 |
const text = shareText();
|
| 301 |
+
const it = intents(GAME, `${text}\n๐ฏ ${beatLink()}`, PATH);
|
| 302 |
$('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
|
| 303 |
const pr = $('practice');
|
| 304 |
+
if (isLv) pr.textContent = 'Play today\'s daily board';
|
| 305 |
+
else if (mode.kind === 'pack') {
|
| 306 |
const pk = packOf(mode.pack);
|
| 307 |
pr.textContent = mode.level < pk.levels ? `Next board: ${pk.name} #${mode.level + 1}` : 'Pack complete! Try another pack';
|
| 308 |
+
} else pr.textContent = `Play level ${prog.reached()}`;
|
| 309 |
$('win').hidden = false;
|
| 310 |
pass.afterWin($('win'), 'win');
|
| 311 |
tick();
|
|
|
|
| 321 |
|
| 322 |
// ------------------------------------------------------------------ drawing
|
| 323 |
|
| 324 |
+
const TWIN = ['#5CF2FF', '#FF8FD8', '#9BE15D', '#FFD166', '#FF7A45', '#B79CFF'];
|
| 325 |
+
function mix(a, b, k) {
|
| 326 |
+
const p = (h) => [1, 3, 5].map((o) => parseInt(h.slice(o, o + 2), 16));
|
| 327 |
+
const A = p(a), B = p(b);
|
| 328 |
+
return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * k)).join(',')})`;
|
| 329 |
+
}
|
| 330 |
+
/** Fog: a stone shows only when it is lit or next to a lit stone. */
|
| 331 |
+
function fogMask(on) {
|
| 332 |
+
if (!board.fog || done) return null;
|
| 333 |
+
const n = board.n, f = new Uint8Array(n * n).fill(1);
|
| 334 |
+
for (let i = 0; i < n * n; i++) {
|
| 335 |
+
if (!on[i]) continue;
|
| 336 |
+
const x = i % n, y = (i / n) | 0;
|
| 337 |
+
f[i] = 0;
|
| 338 |
+
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; }
|
| 339 |
+
}
|
| 340 |
+
return f;
|
| 341 |
+
}
|
| 342 |
function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) {
|
| 343 |
const S = SKINS[skin];
|
| 344 |
+
const fogged = fogMask(on);
|
| 345 |
const n = board.n, cs = size / n, pad = cs * 0.06, w = cs * 0.2;
|
| 346 |
c.save();
|
| 347 |
c.fillStyle = S.bg; rr(c, x0, y0, size, size, size * 0.04); c.fill();
|
| 348 |
for (let i = 0; i < n * n; i++) {
|
| 349 |
const x = x0 + (i % n) * cs, y = y0 + ((i / n) | 0) * cs, cx = x + cs / 2, cy = y + cs / 2;
|
| 350 |
+
if (board.hole[i]) continue; // a hole in the moor: nothing to draw
|
| 351 |
+
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; }
|
| 352 |
+
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();
|
| 353 |
+
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(); }
|
| 354 |
+
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(); }
|
| 355 |
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(); }
|
| 356 |
let ang = 0;
|
| 357 |
const a = anim[i];
|
|
|
|
| 383 |
function draw() {
|
| 384 |
const t = performance.now();
|
| 385 |
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 386 |
+
const on = done ? new Uint8Array(cur.length).fill(1) : litB(board, cur);
|
| 387 |
drawBoard(ctx, 0, 0, canvas.width, cur, on, t, document.activeElement === canvas ? focus : -1);
|
| 388 |
if (done && t - winAt < 1200) {
|
| 389 |
ctx.fillStyle = `rgba(255,255,255,${0.18 * (1 - (t - winAt) / 1200)})`; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
| 408 |
g.fillStyle = S.bg; g.fillRect(0, 0, 1080, 1350);
|
| 409 |
g.fillStyle = S.on; g.font = '900 64px system-ui, sans-serif'; g.fillText('PATHMOOR', 80, 130);
|
| 410 |
g.fillStyle = '#ffffffcc'; g.font = '700 40px system-ui, sans-serif';
|
| 411 |
+
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);
|
| 412 |
drawBoard(g, 140, 250, 800, cur, new Uint8Array(cur.length).fill(1), 0);
|
| 413 |
g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; g.fillText(fmtTime(ms), 80, 1170);
|
| 414 |
g.font = '700 44px system-ui, sans-serif'; g.fillStyle = S.on;
|
| 415 |
+
g.fillText(`${totalTaps()} taps ยท par ${board.par} ยท can you beat it?`, 80, 1235);
|
| 416 |
g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif';
|
| 417 |
g.fillText('Play free: cybermaxtools.com/games/pathmoor', 80, 1300);
|
| 418 |
return c;
|
|
|
|
| 435 |
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(); }
|
| 436 |
else if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); tap(focus, ev.shiftKey ? 3 : 1); }
|
| 437 |
else if (ev.key === 'z' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); undo(); }
|
| 438 |
+
else if (ev.key === 'r' || ev.key === 'R') { ev.preventDefault(); restart(); }
|
| 439 |
});
|
| 440 |
canvas.addEventListener('focus', draw); canvas.addEventListener('blur', draw);
|
| 441 |
document.addEventListener('visibilitychange', () => { if (document.hidden && started && !done) { ms = elapsed(); started = performance.now(); save(); } });
|
| 442 |
|
| 443 |
$('undo').onclick = undo;
|
| 444 |
+
$('restart').onclick = () => restart();
|
| 445 |
+
$('retry').onclick = () => restart();
|
| 446 |
+
$('next-lv').onclick = () => load({ kind: 'level', level: mode.level + 1 }, true) && canvas.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
| 447 |
+
$('beat').onclick = async () => {
|
| 448 |
+
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());
|
| 449 |
+
if (r === 'copied') toast('Challenge link copied. Send it to a friend!');
|
| 450 |
+
if (r !== 'cancelled') count(GAME, 'share', 'challenge');
|
| 451 |
+
};
|
| 452 |
+
$('mute').textContent = muted ? '๐' : '๐';
|
| 453 |
+
$('mute').onclick = () => { muted = !muted; store('pathmoor:mute', muted); $('mute').textContent = muted ? '๐' : '๐'; };
|
| 454 |
$('pass-btn').onclick = () => pass.open('header');
|
| 455 |
document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); });
|
| 456 |
+
$('share').onclick = async () => share(GAME, `${shareText()}\n๐ฏ Beat me: ${beatLink()}`, PATH, await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`);
|
| 457 |
['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
|
| 458 |
$('card').onclick = async () => { download(await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); count(GAME, 'share', 'png'); };
|
| 459 |
$('practice').onclick = () => {
|
| 460 |
+
if (mode.kind === 'level') load({ kind: 'daily', date: todayIso }, true);
|
| 461 |
+
else if (mode.kind === 'daily') load({ kind: 'level', level: prog.reached() }, true) || load({ kind: 'level', level: Math.min(prog.reached(), prog.free) }, true);
|
| 462 |
+
else if (mode.kind === 'pack') {
|
| 463 |
const pk = packOf(mode.pack);
|
| 464 |
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);
|
| 465 |
} else {
|
|
|
|
| 471 |
const st = store('pathmoor:stats') || { days: {} };
|
| 472 |
const d = Object.values(st.days || {});
|
| 473 |
const best = d.length ? fmtTime(Math.min(...d.map((x) => x.ms))) : 'โ';
|
| 474 |
+
toast(`Dailies solved: ${d.length} ยท streak ${st.streak || 0} (best ${st.best || 0}) ยท fastest ${best} ยท levels ${prog.cleared()}/${LEVELS.length}, ${prog.totalStars()} โญ`, 4500);
|
| 475 |
};
|
| 476 |
|
| 477 |
function renderSkins() {
|
|
|
|
| 496 |
renderSkins();
|
| 497 |
if (mode) renderModes();
|
| 498 |
});
|
| 499 |
+
// a locked link (pass level, level not reached yet) falls back to the next open level, then today's daily
|
| 500 |
+
load(fromUrl()) || (!prog.lock(prog.reached()) && load({ kind: 'level', level: prog.reached() })) || load({ kind: 'daily', date: todayIso });
|
| 501 |
|
| 502 |
// Screenshot helper for scripts/games/render_assets.mjs: shows a board mid-solve (no effect on normal play).
|
| 503 |
window.__cmxShot = async (stage = 1) => {
|
index.html
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 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:
|
| 4 |
-
<meta name="description" content="Pathmoor
|
| 5 |
<link rel="canonical" href="https://cybermaxtools.com/games/pathmoor/">
|
| 6 |
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
-
<meta property="og:title" content="Pathmoor:
|
| 8 |
-
<meta property="og:description" content="
|
| 9 |
<meta property="og:url" content="https://cybermaxtools.com/games/pathmoor/">
|
| 10 |
<meta property="og:image" content="https://cybermaxtools.com/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://cybermaxtools.com/games/pathmoor/og.png">
|
|
@@ -15,47 +15,64 @@
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 19 |
</head><body>
|
| 20 |
<div class="wrap">
|
| 21 |
<header class="top"><a class="brand" href="https://cybermaxtools.com/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://cybermaxtools.com/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="
|
|
|
|
|
|
|
|
|
|
| 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>
|
| 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 |
-
<
|
| 46 |
-
<h3>
|
| 47 |
-
<
|
| 48 |
-
<
|
| 49 |
-
<
|
| 50 |
-
<
|
| 51 |
-
<
|
| 52 |
-
<
|
| 53 |
-
<p>
|
| 54 |
-
<h2>
|
| 55 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
</section>
|
| 57 |
</main>
|
| 58 |
<footer>ยฉ 2026 CyberMax ยท <a href="https://cybermaxtools.com/games/">Games</a> ยท <a href="https://cybermaxtools.com/toys/">Toys</a> ยท <a href="https://cybermaxtools.com/store/">Store</a> ยท <a href="https://cybermaxtools.com/privacy">Privacy</a> ยท <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 59 |
</div>
|
| 60 |
<script type="module" src="game.js"></script>
|
| 61 |
-
<
|
|
|
|
| 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 Logic Puzzle Game Online, Rotate the Paths</title>
|
| 4 |
+
<meta name="description" content="Play Pathmoor free: a logic puzzle with 30 levels and a daily board. Turn stones until every path glows from the lantern, earn stars, beat a friend's score.">
|
| 5 |
<link rel="canonical" href="https://cybermaxtools.com/games/pathmoor/">
|
| 6 |
<meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
|
| 7 |
+
<meta property="og:title" content="Pathmoor: Free Daily Logic Puzzle Game Online">
|
| 8 |
+
<meta property="og:description" content="Play Pathmoor free: a logic puzzle with 30 levels and a daily board. Turn stones until every path glows from the lantern, earn stars, beat a friend's score.">
|
| 9 |
<meta property="og:url" content="https://cybermaxtools.com/games/pathmoor/">
|
| 10 |
<meta property="og:image" content="https://cybermaxtools.com/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://cybermaxtools.com/games/pathmoor/og.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 |
+
<meta name="twitter:title" content="Pathmoor: Free Daily Logic Puzzle Game Online">
|
| 19 |
+
<meta name="twitter:description" content="Play Pathmoor free: a logic puzzle with 30 levels and a daily board. Turn stones until every path glows from the lantern, earn stars, beat a friend's score.">
|
| 20 |
+
<style>.h1kw{display:block;font-size:.42em;font-weight:600;opacity:.75;letter-spacing:.01em;margin-top:.15em}.seo ol,.seo ul{padding-left:1.2em}.seo li{margin:.25em 0}</style>
|
| 21 |
+
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/pathmoor/#game", "name": "Pathmoor", "alternateName": "Daily logic puzzle game online free", "url": "https://cybermaxtools.com/games/pathmoor/", "image": "https://cybermaxtools.com/games/pathmoor/og.png", "description": "Pathmoor is a free logic puzzle for your browser. Turn stones a quarter turn at a time until every path connects back to the lantern and glows. 30 levels add holes, twin stones, fog and reverse stones, with a challenge level every 10. Earn up to 3 stars by matching par, play the same daily board as everyone else, keep a streak and send a friend a link to beat your score.", "genre": ["Puzzle", "Logic", "Daily puzzle", "Brain game"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-02", "keywords": "Daily logic puzzle game online free, free daily path puzzle, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/pathmoor/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/pathmoor/#faq", "mainEntity": [{"@type": "Question", "name": "Is Pathmoor free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. The daily board and levels 1-20 are free."}}, {"@type": "Question", "name": "How many levels are there?", "acceptedAnswer": {"@type": "Answer", "text": "30, with a new twist about every 5 levels and a challenge level every 10. Levels 21-30 come with the CyberMax Fun Pass."}}, {"@type": "Question", "name": "When does a new daily board come out?", "acceptedAnswer": {"@type": "Answer", "text": "Every day at your midnight."}}, {"@type": "Question", "name": "What is par?", "acceptedAnswer": {"@type": "Answer", "text": "The fewest taps that can solve the board. Match it for 3 stars."}}, {"@type": "Question", "name": "Can I challenge a friend?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. After a win, tap Challenge a friend to send a link to the same board with your score."}}, {"@type": "Question", "name": "Does it work offline?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, after your first visit, and it installs like an app."}}]}, {"@type": "VideoObject", "name": "Pathmoor demo: Daily path puzzle: light every stone", "description": "A 9-second screen recording of the live Pathmoor page: Today's board. Tap a stone to turn it; Every path must glow from the lantern; Solved: a shareable result. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/pathmoor/poster.jpg"], "uploadDate": "2026-10-02T18:13:52+00:00", "duration": "PT9S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/pathmoor/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/pathmoor/", "inLanguage": "en", "publisher": {"@type": "Organization", "name": "CyberMax", "logo": {"@type": "ImageObject", "url": "https://cybermax.cybermax-tools.workers.dev/store/img/favicon.webp"}}, "@id": "https://cybermaxtools.com/games/pathmoor/#video"}]}</script>
|
| 22 |
</head><body>
|
| 23 |
<div class="wrap">
|
| 24 |
<header class="top"><a class="brand" href="https://cybermaxtools.com/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>
|
| 25 |
+
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mute" type="button" aria-label="Sound on or off">๐</button><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>
|
| 26 |
<main>
|
| 27 |
<section class="game">
|
| 28 |
+
<div class="headline"><h1>Pathmoor <span id="num" class="num"></span> <span class="pro-badge">PASS</span> <small class="h1kw">Free daily path puzzle</small></h1>
|
| 29 |
+
<p class="muted tag">Tap stones to turn them. Light every path from the lantern. 30 levels, a daily board for everyone.</p></div>
|
| 30 |
<div class="chips" id="modes" role="group" aria-label="Board"></div>
|
| 31 |
+
<div class="lv-banner" id="challenge" hidden></div>
|
| 32 |
+
<section class="panel lvmap" id="levels" hidden aria-label="Levels"><div id="lvmap"></div></section>
|
| 33 |
+
<p class="lv-goal" id="goal"></p>
|
| 34 |
+
<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><span id="left-wrap" hidden>Taps left <b id="left">0</b></span></div>
|
| 35 |
<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>
|
| 36 |
<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>
|
| 37 |
<section class="panel win" id="win" hidden aria-live="polite">
|
| 38 |
<h2 id="win-title">Every stone is lit!</h2>
|
| 39 |
+
<div class="lv-stars" id="win-stars" hidden></div>
|
| 40 |
<p id="win-line" class="muted"></p>
|
| 41 |
<pre id="win-grid" class="grid"></pre>
|
| 42 |
+
<div class="row"><button class="btn" id="next-lv" type="button" hidden>Next level โถ</button><button class="btn ghost" id="retry" type="button" hidden>โป Retry</button><button class="btn ghost" id="beat" type="button">๐ฏ Challenge a friend</button></div>
|
| 43 |
<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>
|
| 44 |
<p class="muted small" id="next"></p>
|
| 45 |
<div class="row"><button class="btn ghost sm" id="practice" type="button">Play a practice board</button></div>
|
| 46 |
+
<div class="nudge"><span>Loved it? The <b>Fun Pass</b> opens levels 21-30, 250 bonus boards, every past daily and colour themes.</span><button class="btn pass sm" data-pass="win" type="button">See the pass</button></div>
|
| 47 |
</section>
|
| 48 |
<div class="row skins" id="skins" aria-label="Board theme"></div>
|
| 49 |
</section>
|
| 50 |
|
| 51 |
+
<section class="seo" id="guide">
|
| 52 |
+
<h2>Pathmoor: daily logic puzzle game online free</h2>
|
| 53 |
+
<p>Pathmoor is a free logic puzzle for your browser. Turn stones a quarter turn at a time until every path connects back to the lantern and glows. 30 levels add holes, twin stones, fog and reverse stones, with a challenge level every 10. Earn up to 3 stars by matching par, play the same daily board as everyone else, keep a streak and send a friend a link to beat your score.</p>
|
| 54 |
<h2>How to play Pathmoor</h2>
|
| 55 |
+
<ol><li>One stone holds the lantern.</li><li>Tap any stone to rotate it a quarter turn.</li><li>Paths connected to the lantern glow.</li><li>Solve the board when every stone glows and no path points into a wall.</li><li>Match par, the fewest possible taps, for 3 stars.</li></ol>
|
| 56 |
+
<h3>Controls</h3>
|
| 57 |
+
<p><b>Phone and tablet:</b> Tap a stone to turn it.</p>
|
| 58 |
+
<p><b>Desktop, laptop and Chromebook:</b> Click a stone to turn it.</p>
|
| 59 |
+
<h2>Pathmoor tips and strategy</h2>
|
| 60 |
+
<ul><li>Start with edge and corner stones: paths can never point off the board.</li><li>Dead-end stones must point into a neighbour that opens back.</li><li>Straight stones on an edge always run along it.</li><li>Work inward from what is forced before guessing.</li></ul>
|
| 61 |
+
<h2>Game modes</h2>
|
| 62 |
+
<ul><li>30 levels: holes, twin stones that turn together, fog and reverse stones, plus a tap-budget challenge level every 10. Levels 1-20 are free.</li><li>Daily board, the same for everyone, with a streak and a shareable grid.</li><li>Beat-my-score links: send a friend the same level with your tap count.</li><li>Fun Pass: levels 21-30, the full archive of past boards and 250 bonus boards.</li></ul>
|
| 63 |
+
<p>Free to play with no ads and no sign-up. The optional <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life) adds cosmetic extras and bonus modes in every CyberMax game.</p>
|
| 64 |
+
<h2>Pathmoor FAQ</h2>
|
| 65 |
+
<h3>Is Pathmoor free?</h3><p>Yes. The daily board and levels 1-20 are free.</p>
|
| 66 |
+
<h3>How many levels are there?</h3><p>30, with a new twist about every 5 levels and a challenge level every 10. Levels 21-30 come with the CyberMax Fun Pass.</p>
|
| 67 |
+
<h3>When does a new daily board come out?</h3><p>Every day at your midnight.</p>
|
| 68 |
+
<h3>What is par?</h3><p>The fewest taps that can solve the board. Match it for 3 stars.</p>
|
| 69 |
+
<h3>Can I challenge a friend?</h3><p>Yes. After a win, tap Challenge a friend to send a link to the same board with your score.</p>
|
| 70 |
+
<h3>Does it work offline?</h3><p>Yes, after your first visit, and it installs like an app.</p>
|
| 71 |
+
<h2>Similar free games</h2>
|
| 72 |
+
<div class="more"><a href="https://cybermaxtools.com/games/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/games/brimbloom/"><b>Brimbloom</b><span>Stop the bloom on the brim</span></a><a href="https://cybermaxtools.com/games/kenwren/"><b>Kenwren</b><span>How well do your friends know you?</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
|
| 73 |
</section>
|
| 74 |
</main>
|
| 75 |
<footer>ยฉ 2026 CyberMax ยท <a href="https://cybermaxtools.com/games/">Games</a> ยท <a href="https://cybermaxtools.com/toys/">Toys</a> ยท <a href="https://cybermaxtools.com/store/">Store</a> ยท <a href="https://cybermaxtools.com/privacy">Privacy</a> ยท <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 76 |
</div>
|
| 77 |
<script type="module" src="game.js"></script>
|
| 78 |
+
</body></html>
|
levels.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun kit: levels, daily challenge, streaks, "beat my score" links and game juice (copied into each game's
|
| 2 |
+
// web/ folder by scripts/games/sync_kit.py when the game has "levels": true in products/fun-pass/games.json).
|
| 3 |
+
// Everything is saved in this browser only (localStorage). No new assets: sounds are WebAudio blips, particles are
|
| 4 |
+
// drawn on one overlay canvas. Levels 1..free are free; later levels need the CyberMax Fun Pass, but a level the player
|
| 5 |
+
// has already opened is never locked again. (c) 2026 CyberMax. All rights reserved.
|
| 6 |
+
import { SITE, today, dayNumber, dateOf, store, rr } from './cmx.js';
|
| 7 |
+
|
| 8 |
+
export const TOTAL = 30;
|
| 9 |
+
export const FREE = 20;
|
| 10 |
+
|
| 11 |
+
// ------------------------------------------------------------------ progress (pure logic, testable in node)
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Level progress for one game. Higher score is better unless `lower` is set (e.g. taps or time).
|
| 15 |
+
* opts: { total, free, isPro: () => bool, lower }
|
| 16 |
+
*/
|
| 17 |
+
export function progress(game, opts = {}) {
|
| 18 |
+
const total = opts.total || TOTAL, free = opts.free ?? FREE, isPro = opts.isPro || (() => false), lower = !!opts.lower;
|
| 19 |
+
const KEY = `${game}:levels`;
|
| 20 |
+
const read = () => {
|
| 21 |
+
const d = store(KEY) || {};
|
| 22 |
+
return { stars: d.stars || {}, best: d.best || {}, opened: d.opened || [] };
|
| 23 |
+
};
|
| 24 |
+
const api = {
|
| 25 |
+
total, free, lower,
|
| 26 |
+
data: read,
|
| 27 |
+
stars: (n) => read().stars[n] || 0,
|
| 28 |
+
best: (n) => read().best[n],
|
| 29 |
+
totalStars: () => Object.values(read().stars).reduce((a, b) => a + b, 0),
|
| 30 |
+
cleared: () => Object.keys(read().stars).filter((k) => read().stars[k] > 0).length,
|
| 31 |
+
/** The furthest level the player may open: one past the highest cleared level. */
|
| 32 |
+
reached() {
|
| 33 |
+
const s = read().stars;
|
| 34 |
+
let hi = 0;
|
| 35 |
+
for (const k of Object.keys(s)) if (s[k] > 0) hi = Math.max(hi, Number(k));
|
| 36 |
+
return Math.min(total, hi + 1);
|
| 37 |
+
},
|
| 38 |
+
/** null when playable, else 'progress' (clear the level before) or 'pass' (Fun Pass level). */
|
| 39 |
+
lock(n, { challenge = false } = {}) {
|
| 40 |
+
if (!(n >= 1 && n <= total)) return 'progress';
|
| 41 |
+
const d = read();
|
| 42 |
+
if (d.opened.includes(n) || n <= free && challenge) return null;
|
| 43 |
+
if (n > api.reached() && !challenge) return 'progress';
|
| 44 |
+
if (n > free && !isPro()) return 'pass';
|
| 45 |
+
return null;
|
| 46 |
+
},
|
| 47 |
+
/** Remember that a level was opened, so it stays open even if the pass ends later. */
|
| 48 |
+
open(n) {
|
| 49 |
+
const d = read();
|
| 50 |
+
if (!d.opened.includes(n)) { d.opened.push(n); store(KEY, d); }
|
| 51 |
+
},
|
| 52 |
+
/** Save a finished level. Returns { stars, best, improved, first, next } (next = the newly reachable level or 0). */
|
| 53 |
+
record(n, stars, score) {
|
| 54 |
+
const d = read();
|
| 55 |
+
const before = api.reached();
|
| 56 |
+
const first = !(d.stars[n] > 0);
|
| 57 |
+
const prev = d.best[n];
|
| 58 |
+
const improved = prev === undefined || (lower ? score < prev : score > prev);
|
| 59 |
+
if (improved && stars > 0) d.best[n] = score;
|
| 60 |
+
if (stars > (d.stars[n] || 0)) d.stars[n] = stars;
|
| 61 |
+
store(KEY, d);
|
| 62 |
+
const after = api.reached();
|
| 63 |
+
return { stars: d.stars[n] || 0, best: d.best[n], improved: improved && stars > 0, first: first && stars > 0, next: after > before ? after : 0 };
|
| 64 |
+
},
|
| 65 |
+
};
|
| 66 |
+
return api;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/** Stars from a score and three thresholds [one, two, three] (for `lower`, smaller scores are better). */
|
| 70 |
+
export function starsFor(score, t, lower = false) {
|
| 71 |
+
let s = 0;
|
| 72 |
+
for (const v of t) if (lower ? score <= v : score >= v) s++;
|
| 73 |
+
return s;
|
| 74 |
+
}
|
| 75 |
+
export const starText = (n, of = 3) => 'โญ'.repeat(n) + 'โ'.repeat(Math.max(0, of - n));
|
| 76 |
+
|
| 77 |
+
// ------------------------------------------------------------------ daily challenge + streak
|
| 78 |
+
|
| 79 |
+
/** The daily challenge: one seed for everyone today. record() keeps the best score and the day streak. */
|
| 80 |
+
export function daily(game, { lower = false } = {}) {
|
| 81 |
+
const KEY = `${game}:daily`;
|
| 82 |
+
const iso = today();
|
| 83 |
+
const read = () => store(KEY) || { days: {}, streak: 0, bestStreak: 0 };
|
| 84 |
+
return {
|
| 85 |
+
seed: iso, num: dayNumber(iso), date: iso,
|
| 86 |
+
best: () => read().days[iso],
|
| 87 |
+
streak() {
|
| 88 |
+
const d = read();
|
| 89 |
+
return d.days[iso] !== undefined || d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? d.streak || 0 : 0;
|
| 90 |
+
},
|
| 91 |
+
bestStreak: () => read().bestStreak || 0,
|
| 92 |
+
played: () => Object.keys(read().days).length,
|
| 93 |
+
record(score) {
|
| 94 |
+
const d = read();
|
| 95 |
+
const prev = d.days[iso];
|
| 96 |
+
if (prev === undefined) {
|
| 97 |
+
d.streak = d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? (d.streak || 0) + 1 : 1;
|
| 98 |
+
d.bestStreak = Math.max(d.bestStreak || 0, d.streak);
|
| 99 |
+
}
|
| 100 |
+
const improved = prev === undefined || (lower ? score < prev : score > prev);
|
| 101 |
+
if (improved) d.days[iso] = score;
|
| 102 |
+
store(KEY, d);
|
| 103 |
+
return { best: d.days[iso], improved, streak: d.streak };
|
| 104 |
+
},
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ------------------------------------------------------------------ "beat my score" links
|
| 109 |
+
|
| 110 |
+
/** Read ?seed=&score= from the page URL. seed is a YYYY-MM-DD daily or L<n> for level n. */
|
| 111 |
+
export function readChallenge(search = typeof location !== 'undefined' ? location.search : '') {
|
| 112 |
+
const q = new URLSearchParams(search);
|
| 113 |
+
const seed = (q.get('seed') || '').trim();
|
| 114 |
+
const score = Number(q.get('score'));
|
| 115 |
+
if (!seed) return null;
|
| 116 |
+
const out = { seed, score: Number.isFinite(score) && q.get('score') !== null && q.get('score') !== '' ? score : null };
|
| 117 |
+
const lv = /^L(\d{1,3})$/i.exec(seed);
|
| 118 |
+
if (lv) out.level = Number(lv[1]);
|
| 119 |
+
else if (/^\d{4}-\d{2}-\d{2}$/.test(seed) && dayNumber(seed) >= 1 && seed <= today()) out.date = seed;
|
| 120 |
+
else return null;
|
| 121 |
+
return out;
|
| 122 |
+
}
|
| 123 |
+
/** The link a player shares: opens the same level (or daily) with their score to beat. */
|
| 124 |
+
export function challengeLink(path, game, { level, date, score }) {
|
| 125 |
+
const u = new URL(SITE + path);
|
| 126 |
+
u.searchParams.set('seed', level ? `L${level}` : date);
|
| 127 |
+
if (score !== undefined && score !== null) u.searchParams.set('score', String(score));
|
| 128 |
+
u.searchParams.set('utm_source', 'challenge');
|
| 129 |
+
u.searchParams.set('utm_medium', 'share');
|
| 130 |
+
u.searchParams.set('utm_campaign', `game-${game}`);
|
| 131 |
+
return u.toString();
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/** Share text + link via the native sheet (with an optional PNG), else copy. Returns 'shared'|'copied'|'cancelled'. */
|
| 135 |
+
export async function shareChallenge(text, url, blob, filename) {
|
| 136 |
+
const full = `${text}\n${url}`;
|
| 137 |
+
if (typeof navigator !== 'undefined' && navigator.share) {
|
| 138 |
+
try {
|
| 139 |
+
const file = blob ? new File([blob], filename || 'score.png', { type: 'image/png' }) : null;
|
| 140 |
+
await navigator.share(file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full });
|
| 141 |
+
return 'shared';
|
| 142 |
+
} catch (e) { if (e && e.name === 'AbortError') return 'cancelled'; }
|
| 143 |
+
}
|
| 144 |
+
try { await navigator.clipboard.writeText(full); } catch { prompt('Copy your challenge link:', full); }
|
| 145 |
+
return 'copied';
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ------------------------------------------------------------------ juice: sound, shake, particles, tutorial
|
| 149 |
+
|
| 150 |
+
let ac = null;
|
| 151 |
+
/** WebAudio blips. muted() is checked on every sound. */
|
| 152 |
+
export function sfx(muted = () => false) {
|
| 153 |
+
const tone = (f, d = 0.07, type = 'sine', vol = 0.07, slide = 1.5, at = 0) => {
|
| 154 |
+
if (muted()) return;
|
| 155 |
+
try {
|
| 156 |
+
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
|
| 157 |
+
if (ac.state === 'suspended') ac.resume();
|
| 158 |
+
const t = ac.currentTime + at, o = ac.createOscillator(), v = ac.createGain();
|
| 159 |
+
o.type = type; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(Math.max(30, f * slide), t + d);
|
| 160 |
+
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0001, t + d);
|
| 161 |
+
o.connect(v); v.connect(ac.destination); o.start(t); o.stop(t + d + 0.02);
|
| 162 |
+
} catch { /* no audio */ }
|
| 163 |
+
};
|
| 164 |
+
return {
|
| 165 |
+
tone,
|
| 166 |
+
tap: () => tone(660, 0.04, 'triangle', 0.05, 1.3),
|
| 167 |
+
good: (k = 0) => tone(880 + Math.min(k, 30) * 22, 0.06, 'sine', 0.06, 1.4),
|
| 168 |
+
hit: () => { tone(160, 0.3, 'sawtooth', 0.11, 0.3); tone(90, 0.25, 'square', 0.05, 0.5, 0.02); },
|
| 169 |
+
lose: () => { tone(330, 0.16, 'triangle', 0.08, 0.7); tone(220, 0.3, 'triangle', 0.08, 0.6, 0.15); },
|
| 170 |
+
star: (i = 0) => tone(990 * (1 + i * 0.26), 0.12, 'sine', 0.07, 1.25, i * 0.14),
|
| 171 |
+
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.16, 'triangle', 0.07, 1.02, i * 0.09)),
|
| 172 |
+
boss: () => [196, 233, 262].forEach((f, i) => tone(f, 0.22, 'square', 0.04, 1, i * 0.12)),
|
| 173 |
+
};
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const reduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
| 177 |
+
|
| 178 |
+
/** Shake an element for ms (skipped when the player prefers reduced motion). */
|
| 179 |
+
export function shake(el, mag = 8, ms = 260) {
|
| 180 |
+
if (!el || !el.animate || reduced()) return;
|
| 181 |
+
const k = [];
|
| 182 |
+
for (let i = 0; i <= 6; i++) { const m = mag * (1 - i / 6); k.push({ transform: `translate(${(i % 2 ? 1 : -1) * m}px, ${(i % 3 - 1) * m * 0.5}px)` }); }
|
| 183 |
+
el.animate(k, { duration: ms, easing: 'ease-out' });
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
let fx = null, parts = [], fxRaf = 0;
|
| 187 |
+
/** Particle burst at a page point (clientX/clientY). colors = array of CSS colours. */
|
| 188 |
+
export function burst(x, y, { colors = ['#FFC857', '#FF6A3D', '#5CF2FF', '#FFFFFF'], n = 26, speed = 6, life = 700 } = {}) {
|
| 189 |
+
if (typeof document === 'undefined' || reduced()) return;
|
| 190 |
+
if (!fx) {
|
| 191 |
+
fx = document.createElement('canvas');
|
| 192 |
+
fx.className = 'cmx-fx';
|
| 193 |
+
fx.setAttribute('aria-hidden', 'true');
|
| 194 |
+
document.body.appendChild(fx);
|
| 195 |
+
}
|
| 196 |
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
| 197 |
+
if (fx.width !== innerWidth * dpr || fx.height !== innerHeight * dpr) { fx.width = innerWidth * dpr; fx.height = innerHeight * dpr; }
|
| 198 |
+
const t0 = performance.now();
|
| 199 |
+
for (let i = 0; i < n; i++) {
|
| 200 |
+
const a = Math.random() * Math.PI * 2, v = speed * (0.4 + Math.random());
|
| 201 |
+
parts.push({ x: x * dpr, y: y * dpr, vx: Math.cos(a) * v * dpr, vy: Math.sin(a) * v * dpr - 2 * dpr, c: colors[i % colors.length], t0, life: life * (0.6 + Math.random() * 0.6), r: (2 + Math.random() * 3.5) * dpr });
|
| 202 |
+
}
|
| 203 |
+
if (!fxRaf) fxRaf = requestAnimationFrame(fxStep);
|
| 204 |
+
}
|
| 205 |
+
function fxStep(now) {
|
| 206 |
+
const c = fx.getContext('2d');
|
| 207 |
+
c.clearRect(0, 0, fx.width, fx.height);
|
| 208 |
+
parts = parts.filter((p) => now - p.t0 < p.life);
|
| 209 |
+
for (const p of parts) {
|
| 210 |
+
p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.vx *= 0.98;
|
| 211 |
+
c.globalAlpha = 1 - (now - p.t0) / p.life;
|
| 212 |
+
c.fillStyle = p.c; c.fillRect(p.x - p.r / 2, p.y - p.r / 2, p.r, p.r);
|
| 213 |
+
}
|
| 214 |
+
c.globalAlpha = 1;
|
| 215 |
+
fxRaf = parts.length ? requestAnimationFrame(fxStep) : 0;
|
| 216 |
+
}
|
| 217 |
+
/** Burst from the centre of an element (or a point inside it given as 0..1 fractions). */
|
| 218 |
+
export function burstAt(el, fx = 0.5, fy = 0.5, opts) {
|
| 219 |
+
if (!el) return;
|
| 220 |
+
const r = el.getBoundingClientRect();
|
| 221 |
+
burst(r.left + r.width * fx, r.top + r.height * fy, opts);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* The 2-second no-text tutorial: a pulsing hand over a spot of `el` (fractions fx, fy). gesture: 'tap' (default),
|
| 226 |
+
* 'hold' or 'swipe'. Shown once per game (key `<game>:tut`) unless force is set; any tap hides it.
|
| 227 |
+
*/
|
| 228 |
+
export function tutorial(game, el, { fx = 0.5, fy = 0.6, gesture = 'tap', force = false, ms = 2200 } = {}) {
|
| 229 |
+
if (!el || (!force && store(`${game}:tut`))) return;
|
| 230 |
+
store(`${game}:tut`, 1);
|
| 231 |
+
const host = el.parentElement;
|
| 232 |
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
| 233 |
+
const h = document.createElement('div');
|
| 234 |
+
h.className = `cmx-tut cmx-tut-${gesture}`;
|
| 235 |
+
h.setAttribute('aria-hidden', 'true');
|
| 236 |
+
h.innerHTML = '<i></i><b>๐</b>';
|
| 237 |
+
const r = el.getBoundingClientRect(), hr = host.getBoundingClientRect();
|
| 238 |
+
h.style.left = `${r.left - hr.left + r.width * fx}px`;
|
| 239 |
+
h.style.top = `${r.top - hr.top + r.height * fy}px`;
|
| 240 |
+
host.appendChild(h);
|
| 241 |
+
const off = () => { h.remove(); window.removeEventListener('pointerdown', off, true); };
|
| 242 |
+
setTimeout(off, ms);
|
| 243 |
+
window.addEventListener('pointerdown', off, true);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// ------------------------------------------------------------------ level-select map
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Render the level map into `host`. levels = [{ n, name, twist?, boss? }] (twist = short text for the first level
|
| 250 |
+
* of a new mechanic). onPick(n) runs for a playable level; onLocked(n, reason) for a locked one.
|
| 251 |
+
*/
|
| 252 |
+
export function renderMap(host, prog, levels, { current = 0, onPick, onLocked, worlds = [] } = {}) {
|
| 253 |
+
css();
|
| 254 |
+
const d = prog.data();
|
| 255 |
+
const reached = prog.reached();
|
| 256 |
+
let html = `<div class="lv-head"><b>${prog.cleared()}/${prog.total}</b> levels ยท <b>${prog.totalStars()}/${prog.total * 3}</b> โญ</div>`;
|
| 257 |
+
for (let w = 0; w < Math.ceil(levels.length / 10); w++) {
|
| 258 |
+
const set = levels.slice(w * 10, w * 10 + 10);
|
| 259 |
+
const twists = set.filter((l) => l.twist).map((l) => `<span>Lv ${l.n}: ${esc(l.twist)}</span>`).join('');
|
| 260 |
+
html += `<section class="lv-world"><h3>${esc(worlds[w] || `World ${w + 1}`)}</h3>${twists ? `<p class="lv-twists">${twists}</p>` : ''}<ol class="lv-grid">`;
|
| 261 |
+
set.forEach((l, i) => {
|
| 262 |
+
const lock = prog.lock(l.n);
|
| 263 |
+
const passLv = !!lock && prog.lock(l.n, { challenge: true }) === 'pass'; // Fun Pass level: show the lock even before it is reached
|
| 264 |
+
const st = d.stars[l.n] || 0;
|
| 265 |
+
const cls = ['lv', l.boss ? 'boss' : '', lock ? `locked ${lock}` : '', st ? 'done' : '', l.n === reached && !st ? 'next' : '', l.n === current ? 'cur' : '', Math.floor(i / 5) % 2 ? 'rev' : ''].filter(Boolean).join(' ');
|
| 266 |
+
const label = `Level ${l.n}${l.name ? `: ${l.name}` : ''}${l.boss ? ' (challenge level)' : ''}${passLv ? ', Fun Pass' : lock ? ', locked' : st ? `, ${st} of 3 stars` : ''}`;
|
| 267 |
+
html += `<li><button type="button" class="${cls}" data-lv="${l.n}" aria-label="${esc(label)}" title="${esc(label)}">${l.boss ? '<i class="crown">๐</i>' : ''}<span class="n">${passLv ? '๐' : l.n}</span><span class="st">${lock === 'progress' ? '' : starText(st).replace(/โ/g, '<s>โ</s>')}</span>${l.twist ? '<i class="new">NEW</i>' : ''}</button></li>`;
|
| 268 |
+
});
|
| 269 |
+
html += '</ol></section>';
|
| 270 |
+
}
|
| 271 |
+
if (prog.free < prog.total) html += `<p class="lv-note">Levels 1-${prog.free} are free. Levels ${prog.free + 1}-${prog.total} come with the CyberMax Fun Pass; a level you have opened always stays open.</p>`;
|
| 272 |
+
host.innerHTML = html;
|
| 273 |
+
host.querySelectorAll('[data-lv]').forEach((b) => b.addEventListener('click', () => {
|
| 274 |
+
const n = Number(b.dataset.lv), lock = prog.lock(n);
|
| 275 |
+
if (lock) onLocked && onLocked(n, lock); else onPick && onPick(n);
|
| 276 |
+
}));
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 280 |
+
|
| 281 |
+
// ------------------------------------------------------------------ score card (1080ร1350 PNG for stories)
|
| 282 |
+
|
| 283 |
+
/** A share card: { title, sub, big, unit, line, stars, bg, fg, accent, foot } -> canvas. draw(ctx) adds game art. */
|
| 284 |
+
export function scoreCard(o, draw) {
|
| 285 |
+
const c = document.createElement('canvas');
|
| 286 |
+
c.width = 1080; c.height = 1350;
|
| 287 |
+
const x = c.getContext('2d');
|
| 288 |
+
x.fillStyle = o.bg || '#11141B'; x.fillRect(0, 0, 1080, 1350);
|
| 289 |
+
if (draw) { x.save(); draw(x); x.restore(); }
|
| 290 |
+
x.textAlign = 'left'; x.fillStyle = o.accent || '#FF6A3D'; x.font = '900 64px system-ui, sans-serif'; x.fillText(String(o.title || '').toUpperCase(), 80, 130);
|
| 291 |
+
x.fillStyle = o.fg || '#fff'; x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif'; x.fillText(o.sub || '', 80, 190); x.globalAlpha = 1;
|
| 292 |
+
x.fillStyle = 'rgba(0,0,0,.35)'; rr(x, 60, 930, 960, 300, 36); x.fill();
|
| 293 |
+
x.textAlign = 'center'; x.fillStyle = o.fg || '#fff';
|
| 294 |
+
x.font = '900 150px system-ui, sans-serif'; x.fillText(String(o.big ?? ''), 540, 1080);
|
| 295 |
+
x.font = '700 44px system-ui, sans-serif'; x.fillText(o.unit || '', 540, 1140);
|
| 296 |
+
if (o.stars !== undefined) { x.font = '64px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; x.fillText(starText(o.stars), 540, 1205); }
|
| 297 |
+
x.fillStyle = o.accent || '#FF6A3D'; x.font = '800 46px system-ui, sans-serif'; x.fillText(o.line || 'Can you beat it?', 540, 880);
|
| 298 |
+
x.fillStyle = o.fg || '#fff'; x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText(o.foot || 'cybermaxtools.com/games', 540, 1300);
|
| 299 |
+
return c;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// ------------------------------------------------------------------ styles (injected once; no extra file)
|
| 303 |
+
|
| 304 |
+
let cssDone = false;
|
| 305 |
+
export function css() {
|
| 306 |
+
if (cssDone || typeof document === 'undefined') return;
|
| 307 |
+
cssDone = true;
|
| 308 |
+
const s = document.createElement('style');
|
| 309 |
+
s.textContent = `.cmx-fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:60}
|
| 310 |
+
.lv-head{font-size:15px;color:var(--muted);margin:4px 0 8px}.lv-head b{color:var(--ink)}
|
| 311 |
+
.lv-world h3{margin:14px 0 4px;font-size:16px}.lv-twists{margin:0 0 8px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 12px}
|
| 312 |
+
.lv-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
|
| 313 |
+
.lv{position:relative;width:100%;aspect-ratio:1;max-width:72px;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:50%;border:2.5px solid var(--line);background:var(--card);color:var(--ink);font:inherit;font-weight:900;font-size:18px;cursor:pointer;touch-action:manipulation;padding:0}
|
| 314 |
+
.lv .n{line-height:1}.lv .st{font-size:9px;letter-spacing:-1px;line-height:1.2;min-height:11px}.lv .st s{text-decoration:none;opacity:.35}
|
| 315 |
+
.lv.done{border-color:var(--ok)}.lv.boss{border-radius:18px;border-width:3px}.lv.boss.done{border-color:#E0A400}
|
| 316 |
+
.lv.next{border-color:var(--acc);box-shadow:0 0 0 4px color-mix(in srgb,var(--acc) 25%,transparent);animation:lvp 1.4s ease-in-out infinite}
|
| 317 |
+
.lv.cur{outline:3px solid var(--ink);outline-offset:2px}
|
| 318 |
+
.lv.locked.progress{opacity:.38;cursor:default}.lv.locked.pass{opacity:.75;border-style:dashed}
|
| 319 |
+
.lv .crown{position:absolute;top:-10px;font-style:normal;font-size:16px}.lv .new{position:absolute;bottom:-8px;font-style:normal;font-size:9px;background:var(--acc);color:var(--acc-ink);padding:1px 5px;border-radius:6px;font-weight:900}
|
| 320 |
+
.lv-note{font-size:13px;color:var(--muted);margin:14px 0 0}
|
| 321 |
+
@keyframes lvp{50%{transform:scale(1.07)}}
|
| 322 |
+
.cmx-tut{position:absolute;width:0;height:0;pointer-events:none;z-index:20}
|
| 323 |
+
.cmx-tut i{position:absolute;left:-28px;top:-28px;width:56px;height:56px;border-radius:50%;border:4px solid #fff;opacity:0;animation:tutring 1s ease-out infinite}
|
| 324 |
+
.cmx-tut b{position:absolute;left:-18px;top:-2px;font-size:44px;filter:drop-shadow(0 3px 6px rgba(0,0,0,.5));animation:tuttap 1s ease-in-out infinite}
|
| 325 |
+
.cmx-tut-hold b{animation:tuthold 1.1s ease-in-out infinite}.cmx-tut-swipe b{animation:tutswipe 1.1s ease-in-out infinite}
|
| 326 |
+
@keyframes tutring{0%{transform:scale(.4);opacity:.9}100%{transform:scale(1.5);opacity:0}}
|
| 327 |
+
@keyframes tuttap{0%,100%{transform:translateY(8px)}50%{transform:translateY(0) scale(.9)}}
|
| 328 |
+
@keyframes tuthold{0%{transform:translateY(8px)}30%,80%{transform:translateY(0) scale(.85)}100%{transform:translateY(8px)}}
|
| 329 |
+
@keyframes tutswipe{0%{transform:translateX(-40px)}100%{transform:translateX(40px)}}
|
| 330 |
+
.lv-banner{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;background:var(--card);border:1.5px solid var(--acc);border-radius:14px;padding:8px 12px;margin:8px 0;font-weight:700}
|
| 331 |
+
.lv-goal{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:15px;margin:8px 0}.lv-goal b{font-variant-numeric:tabular-nums}
|
| 332 |
+
.lv-stars{font-size:30px;letter-spacing:4px;margin:4px 0}.lv-stars span{display:inline-block;animation:lvstar .45s cubic-bezier(.2,1.6,.4,1) both}
|
| 333 |
+
@keyframes lvstar{0%{transform:scale(0) rotate(-40deg)}100%{transform:scale(1)}}
|
| 334 |
+
@media (prefers-reduced-motion:reduce){.lv.next,.cmx-tut i,.cmx-tut b,.lv-stars span{animation:none}}`;
|
| 335 |
+
document.head.appendChild(s);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/** Animated star row HTML for result panels. */
|
| 339 |
+
export function starsHtml(n) {
|
| 340 |
+
return [0, 1, 2].map((i) => `<span style="animation-delay:${i * 0.14}s${i < n ? '' : ';opacity:.3'}">${i < n ? 'โญ' : 'โ'}</span>`).join('');
|
| 341 |
+
}
|
style.css
CHANGED
|
@@ -12,3 +12,5 @@
|
|
| 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}
|
|
|
|
|
|
|
|
|
| 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}
|
| 15 |
+
.new-twist{background:var(--acc);color:var(--acc-ink);border-radius:8px;padding:1px 8px;font-weight:800;font-size:13px}
|
| 16 |
+
.lvmap{margin:10px 0}#goal{min-height:24px;color:var(--muted)}#goal b{color:var(--ink)}
|
sw.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 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}-
|
| 5 |
-
const FILES = ["index.html", "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()));
|
|
|
|
| 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}-0fe363ed32`;
|
| 5 |
+
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "levels.js", "manifest.webmanifest", "style.css"];
|
| 6 |
|
| 7 |
self.addEventListener('install', (e) => {
|
| 8 |
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|