Spaces:
Running
Running
Download levels.js from CyberMax-tools/latchkite: direct link, hf CLI and curl.
- Browser
- Download file 19.8 kB
-
https://huggingface.co/spaces/CyberMax-tools/latchkite/resolve/main/levels.js
- Command line
-
hf download hf://spaces/CyberMax-tools/latchkite/levels.js
-
curl -L -o levels.js https://huggingface.co/spaces/CyberMax-tools/latchkite/resolve/main/levels.js
19.8 kB
| // CyberMax Fun kit: levels, daily challenge, streaks, "beat my score" links and game juice (copied into each game's | |
| // web/ folder by scripts/games/sync_kit.py when the game has "levels": true in products/fun-pass/games.json). | |
| // Everything is saved in this browser only (localStorage). No new assets: sounds are WebAudio blips, particles are | |
| // drawn on one overlay canvas. Levels 1..free are free; later levels need the CyberMax Fun Pass, but a level the player | |
| // has already opened is never locked again. (c) 2026 CyberMax. All rights reserved. | |
| import { SITE, today, dayNumber, dateOf, store, rr } from './cmx.js'; | |
| export const TOTAL = 30; | |
| export const FREE = 20; | |
| // ------------------------------------------------------------------ progress (pure logic, testable in node) | |
| /** | |
| * Level progress for one game. Higher score is better unless `lower` is set (e.g. taps or time). | |
| * opts: { total, free, isPro: () => bool, lower } | |
| */ | |
| export function progress(game, opts = {}) { | |
| const total = opts.total || TOTAL, free = opts.free ?? FREE, isPro = opts.isPro || (() => false), lower = !!opts.lower; | |
| const KEY = `${game}:levels`; | |
| const read = () => { | |
| const d = store(KEY) || {}; | |
| return { stars: d.stars || {}, best: d.best || {}, opened: d.opened || [] }; | |
| }; | |
| const api = { | |
| total, free, lower, | |
| data: read, | |
| stars: (n) => read().stars[n] || 0, | |
| best: (n) => read().best[n], | |
| totalStars: () => Object.values(read().stars).reduce((a, b) => a + b, 0), | |
| cleared: () => Object.keys(read().stars).filter((k) => read().stars[k] > 0).length, | |
| /** The furthest level the player may open: one past the highest cleared level. */ | |
| reached() { | |
| const s = read().stars; | |
| let hi = 0; | |
| for (const k of Object.keys(s)) if (s[k] > 0) hi = Math.max(hi, Number(k)); | |
| return Math.min(total, hi + 1); | |
| }, | |
| /** null when playable, else 'progress' (clear the level before) or 'pass' (Fun Pass level). */ | |
| lock(n, { challenge = false } = {}) { | |
| if (!(n >= 1 && n <= total)) return 'progress'; | |
| const d = read(); | |
| if (d.opened.includes(n) || n <= free && challenge) return null; | |
| if (n > api.reached() && !challenge) return 'progress'; | |
| if (n > free && !isPro()) return 'pass'; | |
| return null; | |
| }, | |
| /** Remember that a level was opened, so it stays open even if the pass ends later. */ | |
| open(n) { | |
| const d = read(); | |
| if (!d.opened.includes(n)) { d.opened.push(n); store(KEY, d); } | |
| }, | |
| /** Save a finished level. Returns { stars, best, improved, first, next } (next = the newly reachable level or 0). */ | |
| record(n, stars, score) { | |
| const d = read(); | |
| const before = api.reached(); | |
| const first = !(d.stars[n] > 0); | |
| const prev = d.best[n]; | |
| const improved = prev === undefined || (lower ? score < prev : score > prev); | |
| if (improved && stars > 0) d.best[n] = score; | |
| if (stars > (d.stars[n] || 0)) d.stars[n] = stars; | |
| store(KEY, d); | |
| const after = api.reached(); | |
| return { stars: d.stars[n] || 0, best: d.best[n], improved: improved && stars > 0, first: first && stars > 0, next: after > before ? after : 0 }; | |
| }, | |
| }; | |
| return api; | |
| } | |
| /** Stars from a score and three thresholds [one, two, three] (for `lower`, smaller scores are better). */ | |
| export function starsFor(score, t, lower = false) { | |
| let s = 0; | |
| for (const v of t) if (lower ? score <= v : score >= v) s++; | |
| return s; | |
| } | |
| export const starText = (n, of = 3) => 'β'.repeat(n) + 'β'.repeat(Math.max(0, of - n)); | |
| // ------------------------------------------------------------------ daily challenge + streak | |
| /** The daily challenge: one seed for everyone today. record() keeps the best score and the day streak. */ | |
| export function daily(game, { lower = false } = {}) { | |
| const KEY = `${game}:daily`; | |
| const iso = today(); | |
| const read = () => store(KEY) || { days: {}, streak: 0, bestStreak: 0 }; | |
| return { | |
| seed: iso, num: dayNumber(iso), date: iso, | |
| best: () => read().days[iso], | |
| streak() { | |
| const d = read(); | |
| return d.days[iso] !== undefined || d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? d.streak || 0 : 0; | |
| }, | |
| bestStreak: () => read().bestStreak || 0, | |
| played: () => Object.keys(read().days).length, | |
| record(score) { | |
| const d = read(); | |
| const prev = d.days[iso]; | |
| if (prev === undefined) { | |
| d.streak = d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? (d.streak || 0) + 1 : 1; | |
| d.bestStreak = Math.max(d.bestStreak || 0, d.streak); | |
| } | |
| const improved = prev === undefined || (lower ? score < prev : score > prev); | |
| if (improved) d.days[iso] = score; | |
| store(KEY, d); | |
| return { best: d.days[iso], improved, streak: d.streak }; | |
| }, | |
| }; | |
| } | |
| // ------------------------------------------------------------------ "beat my score" links | |
| /** Read ?seed=&score= from the page URL. seed is a YYYY-MM-DD daily or L<n> for level n. */ | |
| export function readChallenge(search = typeof location !== 'undefined' ? location.search : '') { | |
| const q = new URLSearchParams(search); | |
| const seed = (q.get('seed') || '').trim(); | |
| const score = Number(q.get('score')); | |
| if (!seed) return null; | |
| const out = { seed, score: Number.isFinite(score) && q.get('score') !== null && q.get('score') !== '' ? score : null }; | |
| const lv = /^L(\d{1,3})$/i.exec(seed); | |
| if (lv) out.level = Number(lv[1]); | |
| else if (/^\d{4}-\d{2}-\d{2}$/.test(seed) && dayNumber(seed) >= 1 && seed <= today()) out.date = seed; | |
| else return null; | |
| return out; | |
| } | |
| /** The link a player shares: opens the same level (or daily) with their score to beat. */ | |
| export function challengeLink(path, game, { level, date, score }) { | |
| const u = new URL(SITE + path); | |
| u.searchParams.set('seed', level ? `L${level}` : date); | |
| if (score !== undefined && score !== null) u.searchParams.set('score', String(score)); | |
| u.searchParams.set('utm_source', 'challenge'); | |
| u.searchParams.set('utm_medium', 'share'); | |
| u.searchParams.set('utm_campaign', `game-${game}`); | |
| return u.toString(); | |
| } | |
| /** Share text + link via the native sheet (with an optional PNG), else copy. Returns 'shared'|'copied'|'cancelled'. */ | |
| export async function shareChallenge(text, url, blob, filename) { | |
| const full = `${text}\n${url}`; | |
| if (typeof navigator !== 'undefined' && navigator.share) { | |
| try { | |
| const file = blob ? new File([blob], filename || 'score.png', { type: 'image/png' }) : null; | |
| await navigator.share(file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full }); | |
| return 'shared'; | |
| } catch (e) { if (e && e.name === 'AbortError') return 'cancelled'; } | |
| } | |
| try { await navigator.clipboard.writeText(full); } catch { prompt('Copy your challenge link:', full); } | |
| return 'copied'; | |
| } | |
| // ------------------------------------------------------------------ juice: sound, shake, particles, tutorial | |
| let ac = null; | |
| /** WebAudio blips. muted() is checked on every sound. */ | |
| export function sfx(muted = () => false) { | |
| const tone = (f, d = 0.07, type = 'sine', vol = 0.07, slide = 1.5, at = 0) => { | |
| if (muted()) return; | |
| try { | |
| ac = ac || new (window.AudioContext || window.webkitAudioContext)(); | |
| if (ac.state === 'suspended') ac.resume(); | |
| const t = ac.currentTime + at, o = ac.createOscillator(), v = ac.createGain(); | |
| o.type = type; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(Math.max(30, f * slide), t + d); | |
| v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0001, t + d); | |
| o.connect(v); v.connect(ac.destination); o.start(t); o.stop(t + d + 0.02); | |
| } catch { /* no audio */ } | |
| }; | |
| return { | |
| tone, | |
| tap: () => tone(660, 0.04, 'triangle', 0.05, 1.3), | |
| good: (k = 0) => tone(880 + Math.min(k, 30) * 22, 0.06, 'sine', 0.06, 1.4), | |
| hit: () => { tone(160, 0.3, 'sawtooth', 0.11, 0.3); tone(90, 0.25, 'square', 0.05, 0.5, 0.02); }, | |
| lose: () => { tone(330, 0.16, 'triangle', 0.08, 0.7); tone(220, 0.3, 'triangle', 0.08, 0.6, 0.15); }, | |
| star: (i = 0) => tone(990 * (1 + i * 0.26), 0.12, 'sine', 0.07, 1.25, i * 0.14), | |
| win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.16, 'triangle', 0.07, 1.02, i * 0.09)), | |
| boss: () => [196, 233, 262].forEach((f, i) => tone(f, 0.22, 'square', 0.04, 1, i * 0.12)), | |
| }; | |
| } | |
| const reduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| /** Shake an element for ms (skipped when the player prefers reduced motion). */ | |
| export function shake(el, mag = 8, ms = 260) { | |
| if (!el || !el.animate || reduced()) return; | |
| const k = []; | |
| 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)` }); } | |
| el.animate(k, { duration: ms, easing: 'ease-out' }); | |
| } | |
| let fx = null, parts = [], fxRaf = 0; | |
| /** Particle burst at a page point (clientX/clientY). colors = array of CSS colours. */ | |
| export function burst(x, y, { colors = ['#FFC857', '#FF6A3D', '#5CF2FF', '#FFFFFF'], n = 26, speed = 6, life = 700 } = {}) { | |
| if (typeof document === 'undefined' || reduced()) return; | |
| if (!fx) { | |
| fx = document.createElement('canvas'); | |
| fx.className = 'cmx-fx'; | |
| fx.setAttribute('aria-hidden', 'true'); | |
| document.body.appendChild(fx); | |
| } | |
| const dpr = Math.min(2, window.devicePixelRatio || 1); | |
| if (fx.width !== innerWidth * dpr || fx.height !== innerHeight * dpr) { fx.width = innerWidth * dpr; fx.height = innerHeight * dpr; } | |
| const t0 = performance.now(); | |
| for (let i = 0; i < n; i++) { | |
| const a = Math.random() * Math.PI * 2, v = speed * (0.4 + Math.random()); | |
| 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 }); | |
| } | |
| if (!fxRaf) fxRaf = requestAnimationFrame(fxStep); | |
| } | |
| function fxStep(now) { | |
| const c = fx.getContext('2d'); | |
| c.clearRect(0, 0, fx.width, fx.height); | |
| parts = parts.filter((p) => now - p.t0 < p.life); | |
| for (const p of parts) { | |
| p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.vx *= 0.98; | |
| c.globalAlpha = 1 - (now - p.t0) / p.life; | |
| c.fillStyle = p.c; c.fillRect(p.x - p.r / 2, p.y - p.r / 2, p.r, p.r); | |
| } | |
| c.globalAlpha = 1; | |
| fxRaf = parts.length ? requestAnimationFrame(fxStep) : 0; | |
| } | |
| /** Burst from the centre of an element (or a point inside it given as 0..1 fractions). */ | |
| export function burstAt(el, fx = 0.5, fy = 0.5, opts) { | |
| if (!el) return; | |
| const r = el.getBoundingClientRect(); | |
| burst(r.left + r.width * fx, r.top + r.height * fy, opts); | |
| } | |
| /** | |
| * The 2-second no-text tutorial: a pulsing hand over a spot of `el` (fractions fx, fy). gesture: 'tap' (default), | |
| * 'hold' or 'swipe'. Shown once per game (key `<game>:tut`) unless force is set; any tap hides it. | |
| */ | |
| export function tutorial(game, el, { fx = 0.5, fy = 0.6, gesture = 'tap', force = false, ms = 2200 } = {}) { | |
| if (!el || (!force && store(`${game}:tut`))) return; | |
| store(`${game}:tut`, 1); | |
| const host = el.parentElement; | |
| if (getComputedStyle(host).position === 'static') host.style.position = 'relative'; | |
| const h = document.createElement('div'); | |
| h.className = `cmx-tut cmx-tut-${gesture}`; | |
| h.setAttribute('aria-hidden', 'true'); | |
| h.innerHTML = '<i></i><b>π</b>'; | |
| const r = el.getBoundingClientRect(), hr = host.getBoundingClientRect(); | |
| h.style.left = `${r.left - hr.left + r.width * fx}px`; | |
| h.style.top = `${r.top - hr.top + r.height * fy}px`; | |
| host.appendChild(h); | |
| const off = () => { h.remove(); window.removeEventListener('pointerdown', off, true); }; | |
| setTimeout(off, ms); | |
| window.addEventListener('pointerdown', off, true); | |
| } | |
| // ------------------------------------------------------------------ level-select map | |
| /** | |
| * Render the level map into `host`. levels = [{ n, name, twist?, boss? }] (twist = short text for the first level | |
| * of a new mechanic). onPick(n) runs for a playable level; onLocked(n, reason) for a locked one. | |
| */ | |
| export function renderMap(host, prog, levels, { current = 0, onPick, onLocked, worlds = [] } = {}) { | |
| css(); | |
| const d = prog.data(); | |
| const reached = prog.reached(); | |
| let html = `<div class="lv-head"><b>${prog.cleared()}/${prog.total}</b> levels Β· <b>${prog.totalStars()}/${prog.total * 3}</b> β</div>`; | |
| for (let w = 0; w < Math.ceil(levels.length / 10); w++) { | |
| const set = levels.slice(w * 10, w * 10 + 10); | |
| const twists = set.filter((l) => l.twist).map((l) => `<span>Lv ${l.n}: ${esc(l.twist)}</span>`).join(''); | |
| html += `<section class="lv-world"><h3>${esc(worlds[w] || `World ${w + 1}`)}</h3>${twists ? `<p class="lv-twists">${twists}</p>` : ''}<ol class="lv-grid">`; | |
| set.forEach((l, i) => { | |
| const lock = prog.lock(l.n); | |
| const passLv = !!lock && prog.lock(l.n, { challenge: true }) === 'pass'; // Fun Pass level: show the lock even before it is reached | |
| const st = d.stars[l.n] || 0; | |
| 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(' '); | |
| const label = `Level ${l.n}${l.name ? `: ${l.name}` : ''}${l.boss ? ' (challenge level)' : ''}${passLv ? ', Fun Pass' : lock ? ', locked' : st ? `, ${st} of 3 stars` : ''}`; | |
| 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>`; | |
| }); | |
| html += '</ol></section>'; | |
| } | |
| 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>`; | |
| host.innerHTML = html; | |
| host.querySelectorAll('[data-lv]').forEach((b) => b.addEventListener('click', () => { | |
| const n = Number(b.dataset.lv), lock = prog.lock(n); | |
| if (lock) onLocked && onLocked(n, lock); else onPick && onPick(n); | |
| })); | |
| } | |
| const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); | |
| // ------------------------------------------------------------------ score card (1080Γ1350 PNG for stories) | |
| /** A share card: { title, sub, big, unit, line, stars, bg, fg, accent, foot } -> canvas. draw(ctx) adds game art. */ | |
| export function scoreCard(o, draw) { | |
| const c = document.createElement('canvas'); | |
| c.width = 1080; c.height = 1350; | |
| const x = c.getContext('2d'); | |
| x.fillStyle = o.bg || '#11141B'; x.fillRect(0, 0, 1080, 1350); | |
| if (draw) { x.save(); draw(x); x.restore(); } | |
| x.textAlign = 'left'; x.fillStyle = o.accent || '#FF6A3D'; x.font = '900 64px system-ui, sans-serif'; x.fillText(String(o.title || '').toUpperCase(), 80, 130); | |
| 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; | |
| x.fillStyle = 'rgba(0,0,0,.35)'; rr(x, 60, 930, 960, 300, 36); x.fill(); | |
| x.textAlign = 'center'; x.fillStyle = o.fg || '#fff'; | |
| x.font = '900 150px system-ui, sans-serif'; x.fillText(String(o.big ?? ''), 540, 1080); | |
| x.font = '700 44px system-ui, sans-serif'; x.fillText(o.unit || '', 540, 1140); | |
| if (o.stars !== undefined) { x.font = '64px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; x.fillText(starText(o.stars), 540, 1205); } | |
| x.fillStyle = o.accent || '#FF6A3D'; x.font = '800 46px system-ui, sans-serif'; x.fillText(o.line || 'Can you beat it?', 540, 880); | |
| 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); | |
| return c; | |
| } | |
| // ------------------------------------------------------------------ styles (injected once; no extra file) | |
| let cssDone = false; | |
| export function css() { | |
| if (cssDone || typeof document === 'undefined') return; | |
| cssDone = true; | |
| const s = document.createElement('style'); | |
| s.textContent = `.cmx-fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:60} | |
| .lv-head{font-size:15px;color:var(--muted);margin:4px 0 8px}.lv-head b{color:var(--ink)} | |
| .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} | |
| .lv-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:8px} | |
| .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} | |
| .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} | |
| .lv.done{border-color:var(--ok)}.lv.boss{border-radius:18px;border-width:3px}.lv.boss.done{border-color:#E0A400} | |
| .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} | |
| .lv.cur{outline:3px solid var(--ink);outline-offset:2px} | |
| .lv.locked.progress{opacity:.38;cursor:default}.lv.locked.pass{opacity:.75;border-style:dashed} | |
| .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} | |
| .lv-note{font-size:13px;color:var(--muted);margin:14px 0 0} | |
| @keyframes lvp{50%{transform:scale(1.07)}} | |
| .cmx-tut{position:absolute;width:0;height:0;pointer-events:none;z-index:20} | |
| .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} | |
| .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} | |
| .cmx-tut-hold b{animation:tuthold 1.1s ease-in-out infinite}.cmx-tut-swipe b{animation:tutswipe 1.1s ease-in-out infinite} | |
| @keyframes tutring{0%{transform:scale(.4);opacity:.9}100%{transform:scale(1.5);opacity:0}} | |
| @keyframes tuttap{0%,100%{transform:translateY(8px)}50%{transform:translateY(0) scale(.9)}} | |
| @keyframes tuthold{0%{transform:translateY(8px)}30%,80%{transform:translateY(0) scale(.85)}100%{transform:translateY(8px)}} | |
| @keyframes tutswipe{0%{transform:translateX(-40px)}100%{transform:translateX(40px)}} | |
| .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} | |
| .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} | |
| .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} | |
| @keyframes lvstar{0%{transform:scale(0) rotate(-40deg)}100%{transform:scale(1)}} | |
| @media (prefers-reduced-motion:reduce){.lv.next,.cmx-tut i,.cmx-tut b,.lv-stars span{animation:none}}`; | |
| document.head.appendChild(s); | |
| } | |
| /** Animated star row HTML for result panels. */ | |
| export function starsHtml(n) { | |
| return [0, 1, 2].map((i) => `<span style="animation-delay:${i * 0.14}s${i < n ? '' : ';opacity:.3'}">${i < n ? 'β' : 'β'}</span>`).join(''); | |
| } | |