cybermax / kenwren /solo.js
CyberMax-tools's picture
Deploy kenwren to the hub
f6d583b verified
Raw History Blame Contribute Delete
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 };