import { el, clear, onTeardown } from '../lib/dom.js'; import { loadSnapshot } from '../data/snapshot.js'; import { Sprite } from '../components/Sprite.js'; import { whosThat, recordResult } from '../store/whosThat.js'; import { prefersReducedMotion } from '../store/settings.js'; import { closeEnough } from '../lib/fuzzy.js'; const loose = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const pretty = (name) => name.replace(/-/g, ' '); /** * Hidden mini-game — reachable by the Konami code (↑↑↓↓←→←→BA), seven quick * taps on the dex feed's game title, or the bare #/whos-that hash. Guess * the Pokémon from its silhouette; a light typo tolerance and a ghost-text * completion (once your prefix is unambiguous) keep the typing painless. */ export async function WhosThatView() { const view = el('section', { class: 'view wtp' }); const snap = await loadSnapshot(); const GENS = [...new Set(snap.species.map((s) => s.generation))] .filter(Boolean) .sort((a, b) => a - b); let genFilter = GENS.includes(whosThat.get().gen) ? whosThat.get().gen : 0; let pool = []; let NAMES = []; function rebuildPool() { pool = genFilter ? snap.species.filter((s) => s.generation === genFilter) : snap.species; NAMES = pool.map((s) => ({ loose: loose(s.name), display: pretty(s.name) })); } rebuildPool(); let current = null; let revealed = false; let wrong = 0; let streak = 0; const genSelect = el( 'select', { class: 'wtp__gen', 'aria-label': 'Which generations to pull from', onchange: (e) => { genFilter = Number(e.target.value) || 0; whosThat.set((s) => ({ ...s, gen: genFilter })); streak = 0; rebuildPool(); next(); }, }, el('option', { value: '0', selected: genFilter === 0 }, 'All generations'), ...GENS.map((g) => el('option', { value: String(g), selected: genFilter === g }, `Gen ${g}`)), ); const art = el('div', { class: 'wtp__art' }); const caption = el('div', { class: 'wtp__caption' }, 'Who’s that Pokémon?'); const hint = el('div', { class: 'wtp__hint' }); const feedback = el('div', { class: 'wtp__feedback' }); const scoreEl = el('div', { class: 'wtp__score' }); const ghostTyped = el('span', { class: 'wtp__ghost-typed' }); const ghostRest = el('span', { class: 'wtp__ghost-rest' }); const ghost = el( 'div', { class: 'wtp__ghost', 'aria-hidden': 'true', hidden: true }, ghostTyped, ghostRest, ); const input = el('input', { class: 'wtp__input', type: 'text', placeholder: 'Your guess…', autocomplete: 'off', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', oninput: updateGhost, onkeydown: (e) => { if (e.key === 'Enter') { submitGuess(); return; } const atEnd = input.selectionStart === input.value.length && input.selectionStart === input.selectionEnd; const wantsAccept = e.key === 'Tab' || e.key === 'End' || (e.key === 'ArrowRight' && atEnd); if (wantsAccept && !ghost.hidden && acceptGhost()) e.preventDefault(); }, }); const inputWrap = el('div', { class: 'wtp__inputwrap' }, ghost, input); const guessBtn = el( 'button', { class: 'button wtp__go', type: 'button', onclick: submitGuess }, 'Guess', ); const revealBtn = el( 'button', { class: 'button button--ghost', type: 'button', onclick: () => reveal(false) }, 'Reveal', ); const nextBtn = el('button', { class: 'button', type: 'button', onclick: next }, 'Next →'); /** The one display name whose loose form starts with what's typed, else ''. */ function currentCompletion() { const raw = input.value; if (!raw || revealed) return ''; const key = loose(raw); if (!key) return ''; const hits = NAMES.filter((n) => n.loose.startsWith(key)); if (hits.length !== 1) return ''; const full = hits[0].display; // Only when what's typed is a literal prefix of the pretty name — keeps // us from mangling the space in "mr mime" etc. if ( full.length <= raw.length || full.slice(0, raw.length).toLowerCase() !== raw.toLowerCase() ) { return ''; } return full; } function updateGhost() { const full = currentCompletion(); ghost.hidden = !full; if (!full) return; ghostTyped.textContent = input.value; ghostRest.textContent = full.slice(input.value.length); } function acceptGhost() { const full = currentCompletion(); if (!full) return false; input.value = full; updateGhost(); return true; } function renderScore() { const s = whosThat.get(); const pct = s.plays ? Math.round((s.wins / s.plays) * 100) : 0; clear(scoreEl).append( el('span', { class: 'wtp__pill' }, `Streak ${streak}`), el('span', { class: 'wtp__pill' }, `Best ${s.best}`), el('span', { class: 'wtp__pill wtp__pill--dim' }, `${s.wins}/${s.plays} · ${pct}%`), ); } function syncButtons() { inputWrap.hidden = revealed; guessBtn.hidden = revealed; revealBtn.hidden = revealed; nextBtn.hidden = !revealed; } function next() { current = pool[Math.floor(Math.random() * pool.length)]; revealed = false; wrong = 0; art.classList.remove('is-revealed'); clear(art).append(Sprite(current.id, { style: 'official', size: 260, alt: 'Mystery Pokémon' })); caption.textContent = 'Who’s that Pokémon?'; hint.textContent = ''; feedback.textContent = ''; feedback.className = 'wtp__feedback'; input.value = ''; updateGhost(); syncButtons(); input.focus(); } function reveal(win) { revealed = true; art.classList.add('is-revealed'); updateGhost(); caption.textContent = `It’s ${pretty(current.name)}! #${String(current.id).padStart(4, '0')}`; if (win) { streak += 1; feedback.textContent = wrong === 0 ? 'Nailed it!' : 'Got there!'; feedback.className = 'wtp__feedback is-good'; } else { streak = 0; feedback.textContent = 'That one got away.'; feedback.className = 'wtp__feedback is-bad'; } recordResult(win, streak); renderScore(); syncButtons(); if (win && !prefersReducedMotion()) { art.animate( [{ transform: 'scale(0.9)' }, { transform: 'scale(1.05)' }, { transform: 'scale(1)' }], { duration: 380, easing: 'cubic-bezier(.34,1.4,.64,1)' }, ); } nextBtn.focus(); } function submitGuess() { if (revealed || !input.value.trim()) return; if (closeEnough(loose(input.value), loose(current.name))) { reveal(true); return; } wrong += 1; input.value = ''; updateGhost(); feedback.className = 'wtp__feedback is-bad'; if (wrong === 1) { feedback.textContent = 'Nope — try again.'; hint.textContent = `${loose(current.name).length} letters`; } else if (wrong === 2) { feedback.textContent = 'Last clue…'; hint.textContent = `Starts with “${current.name[0].toUpperCase()}”, ${loose(current.name).length} letters`; } else { reveal(false); // three strikes return; } input.focus(); } view.append( el( 'nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/' }, '‹ Dex'), el('span', {}, ' · '), el('a', { class: 'link', href: '#/pinball' }, 'Pinball'), ), el( 'header', { class: 'view__header' }, el('h1', {}, 'Who’s that Pokémon?'), el('p', {}, 'Name it from the silhouette. Three misses and it’s revealed.'), ), el('div', { class: 'wtp__genrow' }, el('span', {}, 'Pull from'), genSelect), scoreEl, el('div', { class: 'wtp__stage' }, art), caption, hint, el('div', { class: 'wtp__controls' }, inputWrap, guessBtn, revealBtn, nextBtn), feedback, ); renderScore(); next(); const off = whosThat.subscribe(() => { if (revealed) renderScore(); }); onTeardown(view, off); return view; }