Pinball: Pokémon theming, real catches, sound; fix the plunger
Theming: - Bumpers are Poké Balls now; a lit one "opens" white with a halo. - A big faint Poké Ball watermark on the playfield. - Catching (light all 5) pops the wild Pokémon's official artwork with "Gotcha! <name> was caught!", logs it to a persistent "Caught" strip of sprites below the table (pdx.pinball.caught), and plays a 4-note jingle. - WebAudio blips for bumpers / posts / drain / launch (no assets), with a 🔊 mute toggle in the HUD (pdx.pinball.sound). Plunger: - Was drawn above the ball and travelled up while charging. Now it sits below the shooter-lane floor and is pulled DOWN to charge. - Stuck plunger fixed: a global window `pointerup` releases it, and `blur` / `visibilitychange` panic-release both the plunger and any held flippers. Launch button uses onpointerdown + release on up/leave/cancel. - Ignore auto-repeat keydown so a held key doesn't re-trigger. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
0c16c1c4a7
commit
f957a93c36
@ -2,7 +2,8 @@
|
|||||||
* A tiny single-table pinball engine. Canvas 2D, fixed-timestep physics
|
* A tiny single-table pinball engine. Canvas 2D, fixed-timestep physics
|
||||||
* with sub-stepping so the ball doesn't tunnel thin walls.
|
* with sub-stepping so the ball doesn't tunnel thin walls.
|
||||||
*
|
*
|
||||||
* createPinball(canvas, { onScore, onBalls, onGameOver, onCatch, pickName })
|
* createPinball(canvas, { onScore, onBalls, onGameOver, onCatch, onBump,
|
||||||
|
* onDrain, pickMon })
|
||||||
* → { start, stop, newGame, setFlipper('L'|'R', down), setPlunge(down) }
|
* → { start, stop, newGame, setFlipper('L'|'R', down), setPlunge(down) }
|
||||||
*
|
*
|
||||||
* All geometry is in logical units (a W×H table); the renderer scales to
|
* All geometry is in logical units (a W×H table); the renderer scales to
|
||||||
@ -118,7 +119,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
charge: 0, // 0..1, for the plunger visual only
|
charge: 0, // 0..1, for the plunger visual only
|
||||||
lit: TABLE.bumpers.map(() => false),
|
lit: TABLE.bumpers.map(() => false),
|
||||||
catchT: 0,
|
catchT: 0,
|
||||||
catchName: '',
|
catchMon: null,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -251,6 +252,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
b.x = p.x + nx * (PHYS.ballR + p.r + 0.5);
|
b.x = p.x + nx * (PHYS.ballR + p.r + 0.5);
|
||||||
b.y = p.y + ny * (PHYS.ballR + p.r + 0.5);
|
b.y = p.y + ny * (PHYS.ballR + p.r + 0.5);
|
||||||
addScore(25);
|
addScore(25);
|
||||||
|
cbs.onBump?.('post');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
||||||
@ -276,14 +278,16 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
b.x = bp.x + nx * (PHYS.ballR + bp.r + 0.5);
|
b.x = bp.x + nx * (PHYS.ballR + bp.r + 0.5);
|
||||||
b.y = bp.y + ny * (PHYS.ballR + bp.r + 0.5);
|
b.y = bp.y + ny * (PHYS.ballR + bp.r + 0.5);
|
||||||
addScore(100);
|
addScore(100);
|
||||||
|
cbs.onBump?.('bumper');
|
||||||
if (!state.lit[i]) {
|
if (!state.lit[i]) {
|
||||||
state.lit[i] = true;
|
state.lit[i] = true;
|
||||||
if (state.lit.every(Boolean)) {
|
if (state.lit.every(Boolean)) {
|
||||||
state.lit = TABLE.bumpers.map(() => false);
|
state.lit = TABLE.bumpers.map(() => false);
|
||||||
addScore(5000);
|
addScore(5000);
|
||||||
state.catchName = (cbs.pickName?.() || 'Something').toUpperCase();
|
const mon = cbs.pickMon?.() || { id: 0, name: 'Something' };
|
||||||
state.catchT = 1.6;
|
state.catchMon = mon;
|
||||||
cbs.onCatch?.(state.catchName);
|
state.catchT = 2.2;
|
||||||
|
cbs.onCatch?.(mon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -344,6 +348,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
function loseBall() {
|
function loseBall() {
|
||||||
state.balls -= 1;
|
state.balls -= 1;
|
||||||
cbs.onBalls?.(state.balls);
|
cbs.onBalls?.(state.balls);
|
||||||
|
cbs.onDrain?.();
|
||||||
if (state.balls <= 0) {
|
if (state.balls <= 0) {
|
||||||
state.over = true;
|
state.over = true;
|
||||||
state.ball = null;
|
state.ball = null;
|
||||||
@ -374,6 +379,38 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A Poké Ball centred at (cx,cy), radius r, rotated by ang. */
|
||||||
|
function pokeball(cx, cy, r, ang, topColor) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(cx, cy);
|
||||||
|
ctx.rotate(ang);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, r, 0, Math.PI * 2);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.fillStyle = '#f4f4f6';
|
||||||
|
ctx.fillRect(-r, -r, r * 2, r * 2);
|
||||||
|
ctx.fillStyle = topColor;
|
||||||
|
ctx.fillRect(-r, -r, r * 2, r);
|
||||||
|
const band = Math.max(2.4, r * 0.3);
|
||||||
|
ctx.fillStyle = '#17181d';
|
||||||
|
ctx.fillRect(-r, -band / 2, r * 2, band);
|
||||||
|
const br = Math.max(2, r * 0.36);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, br, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#17181d';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(0, 0, br * 0.55, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = '#f4f4f6';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
||||||
|
ctx.lineWidth = Math.max(1, r * 0.14);
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.45)';
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
const accentRGB = (() => {
|
const accentRGB = (() => {
|
||||||
const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a';
|
const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a';
|
||||||
return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||||
@ -406,6 +443,23 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
for (let x = 40; x < W; x += 40) line(x, 0, x, H);
|
for (let x = 40; x < W; x += 40) line(x, 0, x, H);
|
||||||
for (let y = 40; y < H; y += 40) line(0, y, W, y);
|
for (let y = 40; y < H; y += 40) line(0, y, W, y);
|
||||||
|
|
||||||
|
// big Poké Ball watermark on the playfield
|
||||||
|
ctx.save();
|
||||||
|
ctx.globalAlpha = 0.05;
|
||||||
|
ctx.strokeStyle = '#fff';
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(W / 2, 262, 118, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(W / 2 - 118, 262);
|
||||||
|
ctx.lineTo(W / 2 + 118, 262);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(W / 2, 262, 34, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
@ -450,28 +504,22 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// bumpers — concentric, with a lit glow
|
// bumpers — Poké Balls; a lit one "opens" (white) with a halo
|
||||||
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
||||||
const bp = TABLE.bumpers[i];
|
const bp = TABLE.bumpers[i];
|
||||||
const lit = state.lit[i];
|
const lit = state.lit[i];
|
||||||
if (lit) {
|
if (lit) {
|
||||||
|
ctx.save();
|
||||||
ctx.shadowColor = accent;
|
ctx.shadowColor = accent;
|
||||||
ctx.shadowBlur = 22;
|
ctx.shadowBlur = 24;
|
||||||
}
|
}
|
||||||
|
pokeball(bp.x, bp.y, bp.r, 0, lit ? '#ffffff' : accent);
|
||||||
|
if (lit) ctx.restore();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
|
ctx.arc(bp.x, bp.y, bp.r + 2, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = lit ? accent : '#3a3e4b';
|
ctx.lineWidth = 2;
|
||||||
ctx.fill();
|
ctx.strokeStyle = lit ? '#fff' : accentA(0.5);
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.strokeStyle = lit ? '#fff' : accent;
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(bp.x, bp.y, bp.r * 0.5, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = lit ? '#fff' : mix(accent, [255, 255, 255], 0.15);
|
|
||||||
ctx.globalAlpha = lit ? 0.9 : 0.5;
|
|
||||||
ctx.fill();
|
|
||||||
ctx.globalAlpha = 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// flippers
|
// flippers
|
||||||
@ -489,68 +537,42 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// plunger
|
// plunger — sits below the shooter-lane floor and is pulled DOWN to charge
|
||||||
const py = 470 - state.charge * 18;
|
{
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
|
const top = 480 + state.charge * 22; // more charge → further down
|
||||||
ctx.lineWidth = 3;
|
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
|
||||||
line(297, py + 22, 297, 486);
|
ctx.lineWidth = 4;
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.75)';
|
line(297, 474, 297, top); // shaft
|
||||||
ctx.beginPath();
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
ctx.roundRect?.(289, py, 16, 22, 4);
|
ctx.beginPath();
|
||||||
ctx.fill();
|
if (ctx.roundRect) ctx.roundRect(288, top, 18, 12, 4);
|
||||||
if (!ctx.roundRect) ctx.fillRect(289, py, 16, 22);
|
else ctx.rect(288, top, 18, 12);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
// ball — a spinning Poké Ball
|
// ball — a spinning Poké Ball with a drop shadow
|
||||||
const b = state.ball;
|
const b = state.ball;
|
||||||
if (b) {
|
if (b) {
|
||||||
const r = PHYS.ballR;
|
const r = PHYS.ballR;
|
||||||
ctx.save();
|
|
||||||
ctx.translate(b.x, b.y + 1);
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(0, 1.5, r, 0, Math.PI * 2);
|
ctx.arc(b.x, b.y + 2.5, r, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
ctx.fillStyle = 'rgba(0,0,0,0.28)';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.restore();
|
pokeball(b.x, b.y, r, b.spin, accent);
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(b.x, b.y);
|
|
||||||
ctx.rotate(b.spin);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, r, 0, Math.PI * 2);
|
|
||||||
ctx.clip();
|
|
||||||
ctx.fillStyle = '#f4f4f6';
|
|
||||||
ctx.fillRect(-r, -r, r * 2, r * 2);
|
|
||||||
ctx.fillStyle = accent;
|
|
||||||
ctx.fillRect(-r, -r, r * 2, r);
|
|
||||||
ctx.fillStyle = '#1b1c22';
|
|
||||||
ctx.fillRect(-r, -1.6, r * 2, 3.2);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, 2.6, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = '#1b1c22';
|
|
||||||
ctx.fill();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(0, 0, 1.5, 0, Math.PI * 2);
|
|
||||||
ctx.fillStyle = '#f4f4f6';
|
|
||||||
ctx.fill();
|
|
||||||
ctx.restore();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(b.x, b.y, r, 0, Math.PI * 2);
|
|
||||||
ctx.lineWidth = 1;
|
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.catchT > 0) {
|
if (state.catchT > 0) {
|
||||||
ctx.globalAlpha = Math.min(1, state.catchT);
|
const name = (state.catchMon?.name || 'a Pokémon').toUpperCase();
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
ctx.globalAlpha = Math.min(1, state.catchT / 1.4);
|
||||||
ctx.fillRect(0, H / 2 - 66, W, 92);
|
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||||
|
ctx.fillRect(0, H / 2 - 60, W, 88);
|
||||||
ctx.fillStyle = '#fff';
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
ctx.font = 'bold 20px system-ui, sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 30);
|
ctx.fillText('Gotcha!', W / 2, H / 2 - 30);
|
||||||
ctx.font = 'bold 15px system-ui, sans-serif';
|
ctx.font = 'bold 17px system-ui, sans-serif';
|
||||||
ctx.fillStyle = accent;
|
ctx.fillStyle = accent;
|
||||||
ctx.fillText('+5000', W / 2, H / 2 - 6);
|
ctx.fillText(`${name} was caught!`, W / 2, H / 2 - 6);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,20 @@
|
|||||||
import { createStore } from './createStore.js';
|
import { createStore } from './createStore.js';
|
||||||
|
|
||||||
/** High score for the hidden pinball table (#/pinball). */
|
/** High score + the catch log for the hidden pinball table (#/pinball). */
|
||||||
export const pinball = createStore('pdx.pinball', { best: 0 });
|
export const pinball = createStore('pdx.pinball', { best: 0, caught: [], sound: true });
|
||||||
|
|
||||||
export function recordPinball(score) {
|
export function recordPinball(score) {
|
||||||
if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score }));
|
if (score > pinball.get().best) pinball.set((s) => ({ ...s, best: score }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Log a Pokémon "caught" on the table (most recent first, de-duped, capped). */
|
||||||
|
export function recordCatch(mon) {
|
||||||
|
pinball.set((s) => {
|
||||||
|
const caught = [mon, ...s.caught.filter((m) => m.id !== mon.id)].slice(0, 24);
|
||||||
|
return { ...s, caught };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePinballSound() {
|
||||||
|
pinball.set((s) => ({ ...s, sound: !s.sound }));
|
||||||
|
}
|
||||||
|
|||||||
@ -3964,3 +3964,77 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
.pin__sound {
|
||||||
|
margin-left: 4px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* catch pop-up over the table */
|
||||||
|
.pin__catch[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.pin__catch {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: radial-gradient(60% 50% at 50% 45%, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.55));
|
||||||
|
animation: pinCatch 0.3s ease;
|
||||||
|
}
|
||||||
|
@keyframes pinCatch {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pin__catch img {
|
||||||
|
filter: drop-shadow(0 6px 16px rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
.pin__catch-title {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.pin__catch-name {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "caught this run" strip */
|
||||||
|
.pin__caught {
|
||||||
|
min-height: 38px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.pin__caught-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.pin__caught-label {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.pin__caught-row img {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pin__caught-empty {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,39 +1,119 @@
|
|||||||
import { el, onTeardown } from '../lib/dom.js';
|
import { el, onTeardown } from '../lib/dom.js';
|
||||||
import { loadSnapshot } from '../data/snapshot.js';
|
import { loadSnapshot } from '../data/snapshot.js';
|
||||||
import { createPinball } from '../lib/pinball.js';
|
import { createPinball } from '../lib/pinball.js';
|
||||||
import { pinball, recordPinball } from '../store/pinball.js';
|
import { pinball, recordPinball, recordCatch, togglePinballSound } from '../store/pinball.js';
|
||||||
|
import { Sprite } from '../components/Sprite.js';
|
||||||
|
|
||||||
const pretty = (name) => name.replace(/-/g, ' ');
|
const pretty = (name) => name.replace(/-/g, ' ');
|
||||||
|
|
||||||
|
// --- tiny WebAudio blips (no assets) ---------------------------------
|
||||||
|
let actx = null;
|
||||||
|
function tone(freq, dur, type = 'square', vol = 0.05, slideTo) {
|
||||||
|
if (!pinball.get().sound) return;
|
||||||
|
try {
|
||||||
|
actx = actx || new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
if (actx.state === 'suspended') actx.resume();
|
||||||
|
const t = actx.currentTime;
|
||||||
|
const o = actx.createOscillator();
|
||||||
|
const g = actx.createGain();
|
||||||
|
o.type = type;
|
||||||
|
o.frequency.setValueAtTime(freq, t);
|
||||||
|
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur);
|
||||||
|
g.gain.setValueAtTime(vol, t);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
|
||||||
|
o.connect(g).connect(actx.destination);
|
||||||
|
o.start(t);
|
||||||
|
o.stop(t + dur + 0.02);
|
||||||
|
} catch {
|
||||||
|
/* audio not available — no-op */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sfx = {
|
||||||
|
bump: () => tone(320 + Math.random() * 120, 0.06),
|
||||||
|
post: () => tone(520, 0.04, 'triangle', 0.035),
|
||||||
|
drain: () => tone(300, 0.35, 'sawtooth', 0.05, 90),
|
||||||
|
launch: () => tone(180, 0.22, 'sawtooth', 0.05, 520),
|
||||||
|
catch: () => {
|
||||||
|
[523, 659, 784, 1047].forEach((f, i) =>
|
||||||
|
setTimeout(() => tone(f, 0.12, 'square', 0.05), i * 90),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hidden mini-game — a single pinball table. Reached from the "Who's that
|
* Hidden mini-game — a single Pokémon-flavoured pinball table. Reached from
|
||||||
* Pokémon?" screen's nav row, or the bare #/pinball hash.
|
* the "Who's that Pokémon?" screen's nav row, or the bare #/pinball hash.
|
||||||
*
|
*
|
||||||
* ← / → (or A / L) work the flippers; hold Space (or the Launch button) to
|
* ← / → (or A / L / Z) work the flippers; hold Space or the Launch button
|
||||||
* pull the plunger. On touch, tap the left or right half of the table.
|
* to pull the plunger. On touch, tap the table's left or right half.
|
||||||
*/
|
*/
|
||||||
export async function PinballView() {
|
export async function PinballView() {
|
||||||
const view = el('section', { class: 'view pin' });
|
const view = el('section', { class: 'view pin' });
|
||||||
const snap = await loadSnapshot();
|
const snap = await loadSnapshot();
|
||||||
|
|
||||||
const scoreEl = el('span', { class: 'pin__score' }, '0');
|
const scoreEl = el('span', { class: 'pin__score' }, '0');
|
||||||
const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best}`);
|
const bestEl = el('span', { class: 'pin__best' }, `Best ${pinball.get().best.toLocaleString()}`);
|
||||||
const ballsEl = el('span', { class: 'pin__balls' });
|
const ballsEl = el('span', { class: 'pin__balls' });
|
||||||
const overlay = el('div', { class: 'pin__over', hidden: true });
|
const overlay = el('div', { class: 'pin__over', hidden: true });
|
||||||
|
const catchPop = el('div', { class: 'pin__catch', hidden: true });
|
||||||
|
const caughtStrip = el('div', { class: 'pin__caught' });
|
||||||
const canvas = el('canvas', { class: 'pin__canvas', 'aria-label': 'Pinball table' });
|
const canvas = el('canvas', { class: 'pin__canvas', 'aria-label': 'Pinball table' });
|
||||||
|
|
||||||
function renderBalls(n) {
|
function renderBalls(n) {
|
||||||
ballsEl.textContent = '●'.repeat(Math.max(0, n)) || '—';
|
ballsEl.textContent = '●'.repeat(Math.max(0, n)) || '—';
|
||||||
}
|
}
|
||||||
|
function renderCaught() {
|
||||||
|
const list = pinball.get().caught;
|
||||||
|
caughtStrip.replaceChildren(
|
||||||
|
list.length
|
||||||
|
? el(
|
||||||
|
'div',
|
||||||
|
{ class: 'pin__caught-row' },
|
||||||
|
el('span', { class: 'pin__caught-label' }, 'Caught'),
|
||||||
|
...list.slice(0, 14).map((m) => Sprite(m.id, { size: 30, alt: m.name })),
|
||||||
|
)
|
||||||
|
: el('span', { class: 'pin__caught-empty' }, 'Light all 5 Poké Balls to catch one.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let catchTimer = null;
|
||||||
|
const soundBtn = el('button', {
|
||||||
|
class: 'pin__sound',
|
||||||
|
type: 'button',
|
||||||
|
title: 'Sound',
|
||||||
|
'aria-label': 'Toggle sound',
|
||||||
|
onclick: () => {
|
||||||
|
togglePinballSound();
|
||||||
|
soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
soundBtn.textContent = pinball.get().sound ? '🔊' : '🔇';
|
||||||
|
|
||||||
const game = createPinball(canvas, {
|
const game = createPinball(canvas, {
|
||||||
onScore: (s) => (scoreEl.textContent = s.toLocaleString()),
|
onScore: (s) => (scoreEl.textContent = s.toLocaleString()),
|
||||||
onBalls: renderBalls,
|
onBalls: renderBalls,
|
||||||
pickName: () => pretty(snap.species[(Math.random() * snap.species.length) | 0].name),
|
onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()),
|
||||||
|
onDrain: () => sfx.drain(),
|
||||||
|
pickMon: () => {
|
||||||
|
const s = snap.species[(Math.random() * snap.species.length) | 0];
|
||||||
|
return { id: s.id, name: pretty(s.name) };
|
||||||
|
},
|
||||||
|
onCatch: (mon) => {
|
||||||
|
sfx.catch();
|
||||||
|
recordCatch(mon);
|
||||||
|
renderCaught();
|
||||||
|
catchPop.replaceChildren(
|
||||||
|
Sprite(mon.id, { style: 'official', size: 120, alt: mon.name }),
|
||||||
|
el('p', { class: 'pin__catch-title' }, 'Gotcha!'),
|
||||||
|
el('p', { class: 'pin__catch-name' }, `${mon.name} was caught!`),
|
||||||
|
);
|
||||||
|
catchPop.hidden = false;
|
||||||
|
clearTimeout(catchTimer);
|
||||||
|
catchTimer = setTimeout(() => (catchPop.hidden = true), 2200);
|
||||||
|
},
|
||||||
onGameOver: (score) => {
|
onGameOver: (score) => {
|
||||||
recordPinball(score);
|
recordPinball(score);
|
||||||
bestEl.textContent = `Best ${pinball.get().best}`;
|
bestEl.textContent = `Best ${pinball.get().best.toLocaleString()}`;
|
||||||
overlay.replaceChildren(
|
overlay.replaceChildren(
|
||||||
el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`),
|
el('p', { class: 'pin__over-score' }, `${score.toLocaleString()} points`),
|
||||||
el(
|
el(
|
||||||
@ -54,21 +134,16 @@ export async function PinballView() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- input --------------------------------------------------------
|
// --- input --------------------------------------------------------
|
||||||
const KEYS = {
|
const KEYS = { ArrowLeft: 'L', ArrowRight: 'R', a: 'L', d: 'R', z: 'L', l: 'R' };
|
||||||
ArrowLeft: 'L',
|
|
||||||
ArrowRight: 'R',
|
|
||||||
a: 'L',
|
|
||||||
d: 'R',
|
|
||||||
z: 'L',
|
|
||||||
l: 'R',
|
|
||||||
};
|
|
||||||
function onKey(down) {
|
function onKey(down) {
|
||||||
return (e) => {
|
return (e) => {
|
||||||
|
if (e.repeat) return;
|
||||||
const f = KEYS[e.key];
|
const f = KEYS[e.key];
|
||||||
if (f) {
|
if (f) {
|
||||||
game.setFlipper(f, down);
|
game.setFlipper(f, down);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.key === ' ' || e.key === 'ArrowDown') {
|
} else if (e.key === ' ' || e.key === 'ArrowDown') {
|
||||||
|
if (down && !e.repeat) sfx.launch();
|
||||||
game.setPlunge(down);
|
game.setPlunge(down);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
@ -79,8 +154,8 @@ export async function PinballView() {
|
|||||||
window.addEventListener('keydown', kd);
|
window.addEventListener('keydown', kd);
|
||||||
window.addEventListener('keyup', ku);
|
window.addEventListener('keyup', ku);
|
||||||
|
|
||||||
// Per-pointer flipper tracking so two thumbs work and nothing sticks if a
|
// Per-pointer flipper tracking: two thumbs work, and a missed pointerup
|
||||||
// pointerup is missed. `resync` recomputes each flipper from live pointers.
|
// can never leave a flipper stuck on.
|
||||||
const pointers = new Map(); // pointerId -> 'L' | 'R'
|
const pointers = new Map(); // pointerId -> 'L' | 'R'
|
||||||
function resync() {
|
function resync() {
|
||||||
let L = false;
|
let L = false;
|
||||||
@ -101,20 +176,34 @@ export async function PinballView() {
|
|||||||
};
|
};
|
||||||
for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture'])
|
for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture'])
|
||||||
canvas.addEventListener(ev, drop);
|
canvas.addEventListener(ev, drop);
|
||||||
// Clear everything if focus/visibility is lost mid-press.
|
|
||||||
const clearAll = () => {
|
const launchBtn = el('button', {
|
||||||
|
class: 'button pin__launch',
|
||||||
|
type: 'button',
|
||||||
|
onpointerdown: (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
sfx.launch();
|
||||||
|
game.setPlunge(true);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
launchBtn.textContent = 'Launch (hold)';
|
||||||
|
const releasePlunge = () => game.setPlunge(false);
|
||||||
|
for (const ev of ['pointerup', 'pointerleave', 'pointercancel'])
|
||||||
|
launchBtn.addEventListener(ev, releasePlunge);
|
||||||
|
|
||||||
|
// Global safety net: any release / focus loss drops the plunger and flippers.
|
||||||
|
const panic = () => {
|
||||||
|
game.setPlunge(false);
|
||||||
if (pointers.size) {
|
if (pointers.size) {
|
||||||
pointers.clear();
|
pointers.clear();
|
||||||
resync();
|
resync();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('blur', clearAll);
|
const onVis = () => document.hidden && panic();
|
||||||
document.addEventListener('visibilitychange', clearAll);
|
window.addEventListener('pointerup', releasePlunge);
|
||||||
|
window.addEventListener('pointercancel', panic);
|
||||||
const launchBtn = el('button', { class: 'button pin__launch', type: 'button' }, 'Launch (hold)');
|
window.addEventListener('blur', panic);
|
||||||
for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true));
|
document.addEventListener('visibilitychange', onVis);
|
||||||
for (const ev of ['pointerup', 'pointerleave', 'pointercancel'])
|
|
||||||
launchBtn.addEventListener(ev, () => game.setPlunge(false));
|
|
||||||
|
|
||||||
view.append(
|
view.append(
|
||||||
el(
|
el(
|
||||||
@ -125,26 +214,33 @@ export async function PinballView() {
|
|||||||
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
|
el('a', { class: 'link', href: '#/whos-that' }, "Who's that Pokémon?"),
|
||||||
),
|
),
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')),
|
el('header', { class: 'view__header' }, el('h1', {}, 'Pinball')),
|
||||||
el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl),
|
el('div', { class: 'pin__hud' }, el('span', {}, 'Score ', scoreEl), ballsEl, bestEl, soundBtn),
|
||||||
el('div', { class: 'pin__stage' }, canvas, overlay),
|
el('div', { class: 'pin__stage' }, canvas, overlay, catchPop),
|
||||||
|
caughtStrip,
|
||||||
el('div', { class: 'pin__controls' }, launchBtn),
|
el('div', { class: 'pin__controls' }, launchBtn),
|
||||||
el(
|
el(
|
||||||
'p',
|
'p',
|
||||||
{ class: 'pin__hint' },
|
{ class: 'pin__hint' },
|
||||||
'← / → flippers · hold Space to launch · tap the table’s left / right half on touch',
|
'← / → flippers · hold Space to launch · on touch, tap the table’s left / right half',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
renderBalls(3);
|
renderBalls(3);
|
||||||
// Give the canvas a layout box before the engine measures it.
|
renderCaught();
|
||||||
requestAnimationFrame(() => game.start());
|
requestAnimationFrame(() => game.start());
|
||||||
|
|
||||||
|
const offStore = pinball.subscribe(() => renderCaught());
|
||||||
|
|
||||||
onTeardown(view, () => {
|
onTeardown(view, () => {
|
||||||
game.stop();
|
game.stop();
|
||||||
|
clearTimeout(catchTimer);
|
||||||
|
offStore();
|
||||||
window.removeEventListener('keydown', kd);
|
window.removeEventListener('keydown', kd);
|
||||||
window.removeEventListener('keyup', ku);
|
window.removeEventListener('keyup', ku);
|
||||||
window.removeEventListener('blur', clearAll);
|
window.removeEventListener('pointerup', releasePlunge);
|
||||||
document.removeEventListener('visibilitychange', clearAll);
|
window.removeEventListener('pointercancel', panic);
|
||||||
|
window.removeEventListener('blur', panic);
|
||||||
|
document.removeEventListener('visibilitychange', onVis);
|
||||||
});
|
});
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user