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
254 lines
7.9 KiB
JavaScript
254 lines
7.9 KiB
JavaScript
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;
|
||
}
|