latchkite / levels.js
CyberMax-tools's picture
Deploy latchkite
f12281a verified
Raw History Blame Contribute Delete
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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('');
}