dex/src/views/WhosThatView.js
chris 9fa46493a8 Hidden mini-game: single-table pinball (#/pinball)
A small canvas pinball engine (src/lib/pinball.js): fixed-timestep physics
with 5 substeps, circle-vs-segment collision, two rotating flippers whose
angular velocity drives the ball, 3 pop bumpers, 2 slingshots, a charged
plunger, 3 balls. Lighting all 3 bumpers "catches" a random Pokémon for a
bonus. Ball-search kicks a wedged ball toward centre; three strikes and it
drains. High score in pdx.pinball.

Hidden like "Who's that Pokémon?" — no nav entry; the two games
cross-link from each other's back-nav, or reach it at #/pinball.

Controls: ← / → (also A/L, Z) flippers, hold Space / the Launch button for
the plunger; on touch, tap the table's left or right half.

Rendering: the canvas is sized from its container with a DPR-capped
backing store (crisp on any display). `_frame()` is exposed so the physics
can be driven headlessly. 3 unit tests for the geometry helpers.

The physics are stable and the game loop is complete; feel (flipper
strength, ball speed, table balance) still wants hands-on tuning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 14:18:11 -04:00

254 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' }, 'Whos 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 = 'Whos 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 = `Its ${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', {}, 'Whos that Pokémon?'),
el('p', {}, 'Name it from the silhouette. Three misses and its 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;
}