// 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 = `

${esc(P.name)} ${P.from}–${P.to}${paid ? ' · Fun Pass' : ' · free'}

${esc(P.twist)}

`; 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 = `${n}${a === 'ok' ? L.emoji : a === 'pass' ? '🔒' : '·'}${'★'.repeat(s)}${'☆'.repeat(3 - s)}`; 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 ${esc(who)} answers${round.peek ? '' : ', using the clue card'}. ${need}/${qn} to clear${beat !== null ? ` · Beat ${beat}` : ''}`; $('so-emoji').textContent = emoji; $('so-who').textContent = who; $('so-clues').innerHTML = round.card.map((c, k) => `
  • ${esc(c.text)}
  • `).join(''); $('so-card').classList.remove('hidden'); $('so-pips').innerHTML = Array.from({ length: qn }, () => '').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) => `★`).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 };