Spaces:
Running
Running
Download kenwren/solo.js from CyberMax-tools/cybermax: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/kenwren/solo.js
- Command line
-
hf download hf://spaces/CyberMax-tools/cybermax/kenwren/solo.js
-
curl -L -o solo.js https://huggingface.co/spaces/CyberMax-tools/cybermax/resolve/main/kenwren/solo.js
15.4 kB
| // Kenwren solo screens: level map, "read a friend" rounds, daily read, streak, share and beat-my-score links. | |
| // Logic lives in levels.js; sounds/particles in juice.js. (c) 2026 CyberMax. | |
| import { BY_ID } from './core.js'; | |
| import { count, store, toast, share, today } from './cmx.js'; | |
| import * as pass from './funpass.js'; | |
| import { LEVELS, SOLO_PACKS, FREE_MAX, buildLevel, buildRead, dailySeed, starsFor, cleanProgress, access, start, record, totalStars, nextLevel, streak, streakNow, parseLink, linkQuery, grid } from './levels.js'; | |
| import { sfx, shake, burst, isMuted, setMuted } from './juice.js'; | |
| const GAME = 'kenwren'; | |
| const $ = (id) => document.getElementById(id); | |
| const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); | |
| let show = () => {}; | |
| let PATH = '/games/kenwren/'; | |
| let prog = cleanProgress(store('kenwren:levels')); | |
| let R = null; // current round: { round, k, marks, beat, busy, timer } | |
| const saveProg = (p) => { prog = cleanProgress(p); store('kenwren:levels', prog); }; | |
| const todayIso = () => today(); | |
| const pro = () => pass.isPro(); | |
| // ------------------------------------------------------------------ home strip (daily + levels + streak) | |
| export function renderHome() { | |
| const iso = todayIso(); const n = dailySeed(iso); | |
| $('daily-n').textContent = n; | |
| const st = streakNow(store('kenwren:streak'), iso); | |
| $('streak').hidden = !st; $('streak').querySelector('b').textContent = st; | |
| $('streak').title = `${st}-day daily streak`; | |
| const d = store('kenwren:daily'); | |
| $('daily-done').hidden = !(d && d.day === n); | |
| $('levels-stars').textContent = `β ${totalStars(prog)}/${LEVELS.length * 3}`; | |
| } | |
| // ------------------------------------------------------------------ level map | |
| function renderMap() { | |
| $('map-stars').textContent = `β ${totalStars(prog)}/${LEVELS.length * 3}`; | |
| const host = $('map'); host.innerHTML = ''; | |
| for (const P of SOLO_PACKS) { | |
| const sec = document.createElement('section'); sec.className = 'pack'; | |
| const paid = P.from > FREE_MAX; | |
| sec.innerHTML = `<h3>${esc(P.name)} <small>${P.from}β${P.to}${paid ? ' Β· <span class="passtag">Fun Pass</span>' : ' Β· free'}</small></h3><p class="muted small">${esc(P.twist)}</p><div class="nodes"></div>`; | |
| const nodes = sec.querySelector('.nodes'); | |
| for (let n = P.from; n <= P.to; n++) { | |
| const L = LEVELS[n - 1]; const a = access(n, prog, pro()); const s = prog.stars[n] || 0; | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = `node ${a}${s ? ' done' : ''}${L.challenge ? ' boss' : ''}`; b.dataset.lvl = n; | |
| b.setAttribute('aria-label', `Level ${n}${L.challenge ? ' (challenge)' : ''}: ${a === 'ok' ? `${s} of 3 stars` : a === 'pass' ? 'needs the Fun Pass' : 'locked'}`); | |
| b.innerHTML = `<span class="nn">${n}</span><span class="em">${a === 'ok' ? L.emoji : a === 'pass' ? 'π' : 'Β·'}</span><span class="st">${'β '.repeat(s)}${'β'.repeat(3 - s)}</span>`; | |
| b.onclick = () => openLevel(n, null, b); | |
| nodes.appendChild(b); | |
| } | |
| host.appendChild(sec); | |
| } | |
| } | |
| export function showMap() { | |
| renderMap(); show('s-map'); | |
| const cur = $('map').querySelector(`[data-lvl="${nextLevel(prog, pro())}"]`); | |
| if (cur) { cur.classList.add('cur'); } | |
| } | |
| function openLevel(n, beat = null, from = null, viaLink = false) { | |
| const a = access(n, prog, pro()); | |
| const free = n <= FREE_MAX || pro(); | |
| if (a === 'pass' || (viaLink && !free && a !== 'ok')) { showMap(); pass.open(`level-${n}`); return; } | |
| if (a === 'progress' && !viaLink) { sfx.bad(); shake(from); toast(`Clear level ${n - 1} first.`); return; } | |
| saveProg(start(prog, n)); | |
| play(buildLevel(n), beat); | |
| } | |
| function openRead(seed, beat = null) { play(buildRead(seed), beat); } | |
| // ------------------------------------------------------------------ a round | |
| function play(round, beat) { | |
| clearTimers(); | |
| R = { round, k: 0, marks: [], beat, busy: false, peekT: 0, qT: 0 }; | |
| const { who, emoji, qn, kind, n } = round; | |
| const pack = kind === 'level' ? SOLO_PACKS.find((p) => p.id === round.pack).name : (n === dailySeed(todayIso()) ? 'Daily read' : 'Challenge read'); | |
| $('so-title').textContent = kind === 'level' ? `Level ${n}${round.challenge ? ' β challenge' : ''} Β· ${pack}` : `${pack} #${n}`; | |
| const need = Math.ceil(qn * 0.6); | |
| $('so-goal').innerHTML = `Goal: ${round.peek ? 'memorise the card, then guess' : 'guess'} how <b>${esc(who)}</b> answers${round.peek ? '' : ', using the clue card'}. ${need}/${qn} to clear${beat !== null ? ` Β· <b class="beat">Beat ${beat}</b>` : ''}`; | |
| $('so-emoji').textContent = emoji; $('so-who').textContent = who; | |
| $('so-clues').innerHTML = round.card.map((c, k) => `<li data-k="${k}">${esc(c.text)}</li>`).join(''); | |
| $('so-card').classList.remove('hidden'); | |
| $('so-pips').innerHTML = Array.from({ length: qn }, () => '<i></i>').join(''); | |
| count(GAME, 'play', kind === 'level' ? 'level' : 'daily'); | |
| show('s-solo'); | |
| if (round.peek) { | |
| $('so-q-wrap').hidden = true; $('so-ready').hidden = false; $('so-peek').hidden = false; | |
| const bar = $('so-peek').querySelector('i'); | |
| bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth; | |
| bar.style.transition = `width ${round.peek}s linear`; bar.style.width = '0%'; | |
| R.peekT = setTimeout(hideCard, round.peek * 1000); | |
| } else { | |
| $('so-ready').hidden = true; $('so-peek').hidden = true; $('so-q-wrap').hidden = false; ask(); | |
| } | |
| } | |
| function hideCard() { | |
| if (!R) return; | |
| clearTimeout(R.peekT); | |
| $('so-card').classList.add('hidden'); $('so-ready').hidden = true; $('so-peek').hidden = true; $('so-q-wrap').hidden = false; | |
| sfx.tap(); ask(); | |
| } | |
| function clearTimers() { if (R) { clearTimeout(R.peekT); clearTimeout(R.qT); clearTimeout(R.nextT); } $('so-hand').hidden = true; } | |
| function ask() { | |
| const { round, k } = R; const it = round.items[k]; const q = BY_ID[it.q]; | |
| R.busy = false; | |
| $('so-q').textContent = q.you.replaceAll('{n}', round.who); | |
| const box = $('so-opts'); box.innerHTML = ''; | |
| q.opts.forEach((o, j) => { | |
| const b = document.createElement('button'); | |
| b.type = 'button'; b.className = 'opt'; b.textContent = o; | |
| b.onclick = () => pick(j, b); | |
| box.appendChild(b); | |
| }); | |
| // easy levels (clues in question order, always visible): light up the clue that belongs to this question | |
| $('so-clues').querySelectorAll('li').forEach((li) => li.classList.remove('hint')); | |
| $('so-clues').querySelectorAll('li').forEach((li) => li.classList.toggle('now', round.order === 'in' && !round.peek && Number(li.dataset.k) === k)); | |
| if (round.kind === 'level' && round.n === 1 && !(prog.stars[1] > 0)) hint(k, it.a); | |
| if (round.timer) { | |
| const t = $('so-timer'); const bar = t.querySelector('i'); t.hidden = false; | |
| bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth; | |
| bar.style.transition = `width ${round.timer}s linear`; bar.style.width = '0%'; | |
| R.qT = setTimeout(() => pick(-1, null), round.timer * 1000); | |
| } else $('so-timer').hidden = true; | |
| } | |
| // No-text tutorial on level 1: a hand slides from the matching clue to the matching answer. | |
| function hint(k, a) { | |
| const hand = $('so-hand'); const stage = $('stage'); | |
| const clue = $('so-clues').querySelector(`li[data-k="${k}"]`); const opt = $('so-opts').children[a]; | |
| if (!clue || !opt) return; | |
| const sr = stage.getBoundingClientRect(); | |
| const pos = (el) => { const r = el.getBoundingClientRect(); return [r.left - sr.left + Math.min(r.width - 30, 60), r.top - sr.top + r.height / 2]; }; | |
| const [x1, y1] = pos(clue); const [x2, y2] = pos(opt); | |
| hand.hidden = false; opt.classList.add('hint'); clue.classList.add('hint'); | |
| try { | |
| hand.getAnimations().forEach((an) => an.cancel()); | |
| hand.animate([{ transform: `translate(${x1}px,${y1}px) scale(1)`, opacity: 0 }, { transform: `translate(${x1}px,${y1}px) scale(1)`, opacity: 1, offset: 0.15 }, | |
| { transform: `translate(${x2}px,${y2}px) scale(1)`, opacity: 1, offset: 0.7 }, { transform: `translate(${x2}px,${y2}px) scale(.85)`, opacity: 1, offset: 0.8 }, { transform: `translate(${x2}px,${y2}px) scale(1)`, opacity: 0 }], | |
| { duration: 1800, iterations: Infinity, easing: 'ease-in-out' }); | |
| } catch { hand.style.transform = `translate(${x2}px,${y2}px)`; } | |
| } | |
| function pick(j, btn) { | |
| if (!R || R.busy) return; | |
| R.busy = true; clearTimeout(R.qT); $('so-hand').hidden = true; | |
| const { round, k } = R; const it = round.items[k]; | |
| const ok = j === it.a; | |
| const opts = $('so-opts').children; | |
| if (navigator.vibrate) try { navigator.vibrate(ok ? 10 : [20, 40, 20]); } catch { /* */ } | |
| [...opts].forEach((b, m) => { b.disabled = true; if (m === it.a) b.classList.add('right'); }); | |
| if (btn && !ok) btn.classList.add('wrong'); | |
| R.marks.push(ok); | |
| const pip = $('so-pips').children[k]; pip.className = ok ? 'ok' : 'no'; | |
| if (ok) { sfx.ok(); burst(btn || opts[it.a], 22); } else { sfx.bad(); shake($('stage')); burst(btn || opts[it.a], 10, true); if (j < 0) toast('Time!', 900); } | |
| R.nextT = setTimeout(() => { if (R.k + 1 < round.qn) { R.k++; ask(); } else done(); }, ok ? 480 : 950); | |
| } | |
| // ------------------------------------------------------------------ result | |
| function done() { | |
| const { round, marks, beat } = R; const right = marks.filter(Boolean).length; const qn = round.qn; | |
| $('so-timer').hidden = true; | |
| const st = starsFor(right, qn); | |
| let note = ''; let got = st; | |
| const iso = todayIso(); const isDaily = round.kind === 'read' && round.n === dailySeed(iso); | |
| if (round.kind === 'level') { | |
| const before = prog.stars[round.n] || 0; | |
| const r = record(prog, round.n, right, qn); got = r.got; saveProg(r); | |
| if (got) count(GAME, 'win', `level-${round.n}`); | |
| note = got ? (got > before && before ? 'New best!' : '') : `You need ${Math.ceil(qn * 0.6)} right to clear it. One tap to try again.`; | |
| } else { | |
| if (isDaily) { | |
| const d = store('kenwren:daily'); | |
| if (!(d && d.day === round.n)) { store('kenwren:daily', { day: round.n, right, marks }); store('kenwren:streak', streak(store('kenwren:streak'), iso)); count(GAME, 'win', 'daily'); } | |
| const s = streakNow(store('kenwren:streak'), iso); | |
| note = `π₯ ${s}-day streak. A new friend to read tomorrow.`; | |
| } else note = 'Challenge read: same friend, same clues for everyone with this link.'; | |
| } | |
| if (beat !== null) note = `${right > beat ? `You beat ${beat}! π` : right === beat ? `Tied at ${beat}. So close.` : `${beat} still wins. Try again?`} ${note}`; | |
| $('dn-kick').textContent = round.kind === 'level' ? `Level ${round.n}${round.challenge ? ' Β· challenge' : ''}` : (isDaily ? `Daily read #${round.n}` : `Challenge read #${round.n}`); | |
| $('dn-title').textContent = got || round.kind !== 'level' ? `You read ${round.who} ${right === qn ? 'perfectly' : 'well'}!` : `${round.who} stumped you`; | |
| if (round.kind !== 'level' && right < Math.ceil(qn * 0.6)) $('dn-title').textContent = `${round.who} stumped you`; | |
| $('dn-score').textContent = `${right}/${qn}`; | |
| $('dn-grid').textContent = grid(marks); | |
| $('dn-note').textContent = note; | |
| const stars = $('dn-stars'); | |
| stars.innerHTML = round.kind === 'level' ? [0, 1, 2].map((k) => `<span class="${k < got ? 'on' : ''}">β </span>`).join('') : ''; | |
| // next button | |
| const nb = $('dn-next'); | |
| if (round.kind === 'level') { | |
| const nx = round.n + 1; | |
| if (!got) { nb.textContent = 'β» Try again'; nb.onclick = () => openLevel(round.n, beat); } | |
| else if (nx > LEVELS.length) { nb.textContent = 'πΊ All levels'; nb.onclick = showMap; } | |
| else if (access(nx, prog, pro()) === 'pass') { nb.textContent = `Level ${nx} π Fun Pass`; nb.onclick = () => pass.open(`level-${nx}`); } | |
| else { nb.textContent = `Level ${nx} βΆ`; nb.onclick = () => openLevel(nx); } | |
| } else { nb.textContent = 'βΆ Solo levels'; nb.onclick = () => openLevel(nextLevel(prog, pro())); } | |
| $('dn-retry').onclick = () => (round.kind === 'level' ? openLevel(round.n, beat) : openRead(round.n, beat)); | |
| $('dn-share').onclick = () => shareRound(round, right, marks); | |
| show('s-done'); | |
| if (got || (round.kind !== 'level' && right >= Math.ceil(qn * 0.6))) { | |
| sfx.win(); burst($('dn-score'), 60); | |
| [...stars.children].forEach((s, k) => { if (k < got) setTimeout(() => { s.classList.add('pop'); sfx.star(k); }, 250 + k * 220); }); | |
| } else { sfx.lose(); shake($('stage')); } | |
| if (isDaily) pass.afterWin($('dn-after'), 'daily'); else $('dn-after').innerHTML = ''; | |
| renderHome(); | |
| } | |
| export function shareText(round, right, marks, streakN = 0) { | |
| if (round.kind === 'level') { | |
| const s = starsFor(right, round.qn); | |
| return `Kenwren level ${round.n} ${'β'.repeat(s) || 'π₯'} ${right}/${round.qn}\n${grid(marks)}\nCan you read ${round.who} better? Beat ${right}:`; | |
| } | |
| return `Kenwren Daily read #${round.n} ${right}/${round.qn}${streakN ? ` π₯${streakN}` : ''}\n${grid(marks)}\nHow well can you read a stranger? Beat ${right}:`; | |
| } | |
| function shareRound(round, right, marks) { | |
| const q = linkQuery(round.kind === 'level' ? { lvl: round.n, score: right } : { seed: round.n, score: right }); | |
| share(GAME, shareText(round, right, marks, streakNow(store('kenwren:streak'), todayIso())), `${PATH}${q}`); | |
| } | |
| // ------------------------------------------------------------------ wiring | |
| export function initSolo(opts) { | |
| show = opts.show; PATH = opts.path || PATH; | |
| $('daily-btn').onclick = () => { | |
| const n = dailySeed(todayIso()); const d = store('kenwren:daily'); | |
| if (d && d.day === n && Array.isArray(d.marks)) { | |
| // already played today: show today's result (first try counts), with share + practice | |
| R = { round: buildRead(n), marks: d.marks.map(Boolean), beat: null }; R.k = R.marks.length; done(); | |
| toast('You played today. Replays are practice; your first score stands.', 2600); | |
| return; | |
| } | |
| openRead(n); | |
| }; | |
| $('levels-btn').onclick = () => { sfx.tap(); showMap(); }; | |
| $('so-map').onclick = () => { clearTimers(); R = null; showMap(); }; | |
| $('dn-map').onclick = showMap; | |
| $('map-home').onclick = () => { renderHome(); show('s-home'); }; | |
| $('so-retry').onclick = () => { if (!R) return; const r = R.round; r.kind === 'level' ? openLevel(r.n, R.beat) : openRead(r.n, R.beat); }; | |
| $('so-ready').onclick = hideCard; | |
| const mute = $('mute'); | |
| const paint = () => { mute.textContent = isMuted() ? 'π' : 'π'; mute.setAttribute('aria-pressed', String(isMuted())); }; | |
| mute.onclick = () => { setMuted(!isMuted()); paint(); if (!isMuted()) sfx.ok(); }; | |
| paint(); | |
| document.addEventListener('keydown', (e) => { | |
| if ($('s-solo').hidden) return; | |
| if (/^[1-4]$/.test(e.key) && !$('so-q-wrap').hidden && R && !R.busy) { const b = $('so-opts').children[Number(e.key) - 1]; if (b) pick(Number(e.key) - 1, b); } | |
| else if (e.key === 'r' || e.key === 'R') $('so-retry').click(); | |
| else if ((e.key === ' ' || e.key === 'Enter') && !$('so-ready').hidden) { e.preventDefault(); hideCard(); } | |
| }); | |
| pass.onChange(() => { if (!$('s-map').hidden) renderMap(); }); | |
| renderHome(); | |
| } | |
| /** Handle ?lvl= / ?seed= beat-my-score links. Returns true when it opened something. */ | |
| export function route(search) { | |
| const L = parseLink(search); | |
| if (!L) return false; | |
| if (L.lvl) openLevel(L.lvl, L.score, null, true); else openRead(L.seed, L.score); | |
| return true; | |
| } | |
| // test/screenshot hook | |
| window.__kenwrenSolo = { get prog() { return prog; }, get round() { return R && R.round; }, answer: () => R && R.round.items[R.k].a }; | |