// 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 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 `: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 = '๐Ÿ‘†'; 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 = `
${prog.cleared()}/${prog.total} levels ยท ${prog.totalStars()}/${prog.total * 3} โญ
`; 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) => `Lv ${l.n}: ${esc(l.twist)}`).join(''); html += `

${esc(worlds[w] || `World ${w + 1}`)}

${twists ? `

${twists}

` : ''}
    `; 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 += `
  1. `; }); html += '
'; } if (prog.free < prog.total) html += `

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.

`; 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) => `${i < n ? 'โญ' : 'โ˜†'}`).join(''); }