diff --git a/src/lib/pinball.js b/src/lib/pinball.js index 911eb12..7715925 100644 --- a/src/lib/pinball.js +++ b/src/lib/pinball.js @@ -10,7 +10,7 @@ */ const W = 320; -const H = 540; +const H = 560; const PHYS = { gravity: 1050, // units/s² @@ -18,51 +18,69 @@ const PHYS = { maxSpeed: 1000, // 5 substeps keep even this from tunnelling substeps: 5, ballR: 7, - wallE: 0.32, // wall restitution + wallE: 0.34, // wall restitution flipE: 0.28, flipThick: 6, flipLen: 56, flipSwing: 1.02, // radians flipSpeed: 34, // ease rate toward target angle - bumperE: 0.5, - bumperKick: 108, - slingE: 1.06, - slingKick: 78, + bumperE: 0.55, + bumperKick: 116, + postE: 0.75, + postKick: 70, + slingE: 1.1, + slingKick: 90, + spin: 0.07, // Poké Ball rotation per unit travelled }; // Named table geometry — segments are { a:[x,y], b:[x,y], e? }. +// Concave corners are chamfered so a slow ball can't wedge in a vertex. const TABLE = { - // Concave corners are chamfered so a slow ball can't wedge in a vertex. + // Deliberately open at the bottom: below the side walls it's all drain + // except the two flippers and slingshots. No enclosed basins to wedge in. walls: [ - { a: [14, 58], b: [40, 20] }, // top-left corner - { a: [40, 20], b: [252, 20] }, // top - { a: [252, 20], b: [300, 56] }, // top-right corner + { a: [14, 60], b: [42, 20] }, // top-left corner + { a: [42, 20], b: [250, 20] }, // top + { a: [250, 20], b: [300, 56] }, // top-right corner { a: [300, 56], b: [306, 96] }, - { a: [306, 96], b: [306, 446] }, // right wall / shooter-lane outer - { a: [288, 446], b: [288, 230] }, // shooter-lane divider - { a: [288, 446], b: [306, 446] }, // shooter-lane floor - { a: [14, 58], b: [14, 452] }, // left wall - { a: [14, 452], b: [34, 476] }, // left chamfer - { a: [34, 476], b: [86, 506], e: 0.12 }, // left inlane → left flipper - { a: [288, 446], b: [268, 470] }, // right chamfer off the divider - { a: [268, 470], b: [214, 504], e: 0.12 }, // right inlane → right flipper - ], - // slingshots — bouncy, kink up off the inlane end (a convex corner) - slings: [ - { a: [86, 506], b: [114, 452] }, - { a: [214, 504], b: [188, 452] }, + { a: [306, 96], b: [306, 474] }, // right wall / shooter-lane outer + { a: [288, 474], b: [288, 236] }, // shooter-lane divider + { a: [288, 474], b: [306, 474] }, // shooter-lane floor + { a: [14, 60], b: [14, 452] }, // left wall + { a: [14, 452], b: [40, 478] }, // left lower guide (rounds toward the flipper) + { a: [288, 474], b: [264, 492] }, // right lower guide + + // left slingshot — triangle above the flipper's outer half + { a: [88, 442], b: [104, 486], e: PHYS.slingE, k: PHYS.slingKick }, // kicking face + { a: [88, 442], b: [58, 486] }, // outer face + { a: [58, 486], b: [104, 486] }, // bottom + + // right slingshot + { a: [232, 442], b: [216, 486], e: PHYS.slingE, k: PHYS.slingKick }, + { a: [232, 442], b: [262, 486] }, + { a: [216, 486], b: [262, 486] }, ], bumpers: [ - { x: 92, y: 150, r: 15 }, - { x: 168, y: 114, r: 15 }, - { x: 226, y: 150, r: 15 }, + { x: 80, y: 132, r: 15 }, + { x: 160, y: 100, r: 15 }, + { x: 240, y: 132, r: 15 }, + { x: 120, y: 214, r: 13 }, + { x: 200, y: 214, r: 13 }, + ], + // small round posts — extra things to carom off in the mid-field + posts: [ + { x: 52, y: 300, r: 7 }, + { x: 268, y: 300, r: 7 }, + { x: 160, y: 300, r: 7 }, ], flippers: { - L: { pivot: [88, 500], rest: 0.36 }, // radians, y-down; tip points down-right - R: { pivot: [214, 500], rest: Math.PI - 0.36 }, + L: { pivot: [86, 504], rest: 0.3 }, // radians, y-down; tip points down-right + R: { pivot: [234, 504], rest: Math.PI - 0.3 }, }, laneX: 297, // ball rest x in the shooter lane - drainY: 536, + laneRestY: 466, + drainY: 552, + returnMinX: 246, // a ball past drainY here came down the right side → back to the plunger, no life lost }; const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); @@ -98,14 +116,21 @@ export function createPinball(canvas, cbs = {}) { plunge: false, chargeStart: 0, // performance.now() when the plunger was pressed charge: 0, // 0..1, for the plunger visual only - lit: [false, false, false], + lit: TABLE.bumpers.map(() => false), catchT: 0, catchName: '', scale: 1, }; - function spawnBall() { - state.ball = { x: TABLE.laneX, y: 436, vx: 0, vy: 0, inLane: true }; + function spawnBall(toLane = true) { + state.ball = { + x: TABLE.laneX, + y: TABLE.laneRestY - 4, + vx: 0, + vy: 0, + spin: 0, + inLane: toLane, + }; state.charge = 0; state.chargeStart = 0; } @@ -116,7 +141,7 @@ export function createPinball(canvas, cbs = {}) { state.score = 0; state.balls = 3; state.over = false; - state.lit = [false, false, false]; + state.lit = TABLE.bumpers.map(() => false); spawnBall(); cbs.onScore?.(0); cbs.onBalls?.(3); @@ -196,20 +221,38 @@ export function createPinball(canvas, cbs = {}) { b.y += b.vy * dt; for (const w of TABLE.walls) { - collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0); - } - for (const s of TABLE.slings) { - if (collideSeg(b, s.a[0], s.a[1], s.b[0], s.b[1], PHYS.slingE, 2)) { - const mx = (s.a[0] + s.b[0]) / 2; - const my = (s.a[1] + s.b[1]) / 2; + if (collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0) && w.k) { + // slingshot face: shove the ball away from the segment + score + const mx = (w.a[0] + w.b[0]) / 2; + const my = (w.a[1] + w.b[1]) / 2; const nx = b.x - mx; const ny = b.y - my; const nl = Math.hypot(nx, ny) || 1; - b.vx += (nx / nl) * PHYS.slingKick; - b.vy += (ny / nl) * PHYS.slingKick; + b.vx += (nx / nl) * w.k; + b.vy += (ny / nl) * w.k; addScore(50); } } + for (const p of TABLE.posts) { + const dx = b.x - p.x; + const dy = b.y - p.y; + const d = Math.hypot(dx, dy) || 1; + if (d < PHYS.ballR + p.r) { + const nx = dx / d; + const ny = dy / d; + const vn = b.vx * nx + b.vy * ny; + if (vn < 0) { + const j = -(1 + PHYS.postE) * vn; + b.vx += j * nx; + b.vy += j * ny; + } + b.vx += nx * PHYS.postKick; + b.vy += ny * PHYS.postKick; + b.x = p.x + nx * (PHYS.ballR + p.r + 0.5); + b.y = p.y + ny * (PHYS.ballR + p.r + 0.5); + addScore(25); + } + } for (let i = 0; i < TABLE.bumpers.length; i++) { const bp = TABLE.bumpers[i]; let dx = b.x - bp.x; @@ -236,7 +279,7 @@ export function createPinball(canvas, cbs = {}) { if (!state.lit[i]) { state.lit[i] = true; if (state.lit.every(Boolean)) { - state.lit = [false, false, false]; + state.lit = TABLE.bumpers.map(() => false); addScore(5000); state.catchName = (cbs.pickName?.() || 'Something').toUpperCase(); state.catchT = 1.6; @@ -260,6 +303,7 @@ export function createPinball(canvas, cbs = {}) { b.vx *= PHYS.maxSpeed / sp; b.vy *= PHYS.maxSpeed / sp; } + b.spin += b.vx * dt * PHYS.spin; // Poké Ball rolls with its horizontal motion // Ball-search: if the ball hasn't travelled 24 units in 1.4s (and isn't // waiting in the shooter lane) it's wedged — kick it toward centre, like @@ -290,7 +334,11 @@ export function createPinball(canvas, cbs = {}) { b.trackY = b.y; } - if (b.y > TABLE.drainY) loseBall(); + if (b.y > TABLE.drainY) { + if (b.x >= TABLE.returnMinX) + spawnBall(true); // right outlane — back to the plunger, no life lost + else loseBall(); + } } function loseBall() { @@ -326,95 +374,183 @@ export function createPinball(canvas, cbs = {}) { ctx.stroke(); } + const accentRGB = (() => { + const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a'; + return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16)); + })(); + /** Blend the accent toward `to` ([r,g,b]) by `t`. */ + const mix = (_ignored, to, t) => { + const c = (a, b) => Math.round(a + (b - a) * t); + return `rgb(${c(accentRGB[0], to[0])},${c(accentRGB[1], to[1])},${c(accentRGB[2], to[2])})`; + }; + const accentA = (a) => `rgba(${accentRGB[0]},${accentRGB[1]},${accentRGB[2]},${a})`; + function render() { ctx.setTransform(state.scale, 0, 0, state.scale, 0, 0); + + // playfield const g = ctx.createLinearGradient(0, 0, 0, H); - g.addColorStop(0, '#282a34'); - g.addColorStop(1, '#101116'); + g.addColorStop(0, '#2b2d38'); + g.addColorStop(0.55, '#1a1b22'); + g.addColorStop(1, '#0d0e12'); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); - const glow = ctx.createRadialGradient(W / 2, 150, 20, W / 2, 150, 220); - glow.addColorStop(0, `${accent}22`); + const glow = ctx.createRadialGradient(W / 2, 150, 10, W / 2, 150, 240); + glow.addColorStop(0, accentA(0.18)); glow.addColorStop(1, 'transparent'); ctx.fillStyle = glow; ctx.fillRect(0, 0, W, H); + // faint grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + 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); ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - ctx.strokeStyle = 'rgba(255,255,255,0.55)'; - ctx.lineWidth = 2.5; - for (const w of TABLE.walls) line(w.a[0], w.a[1], w.b[0], w.b[1]); + // slingshot triangles (filled) — draw before the wall outline + const tri = (pts) => { + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (const p of pts.slice(1)) ctx.lineTo(p[0], p[1]); + ctx.closePath(); + }; + ctx.fillStyle = accentA(0.22); + tri([ + [88, 442], + [104, 486], + [58, 486], + ]); + ctx.fill(); + tri([ + [232, 442], + [216, 486], + [262, 486], + ]); + ctx.fill(); - ctx.strokeStyle = accent; - ctx.lineWidth = 5; - for (const s of TABLE.slings) line(s.a[0], s.a[1], s.b[0], s.b[1]); + // wall outline + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.lineWidth = 3; + for (const w of TABLE.walls) { + ctx.strokeStyle = w.k ? accent : 'rgba(255,255,255,0.7)'; + ctx.lineWidth = w.k ? 4 : 3; + line(w.a[0], w.a[1], w.b[0], w.b[1]); + } + // posts + for (const p of TABLE.posts) { + ctx.beginPath(); + ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2); + ctx.fillStyle = '#5a5f70'; + ctx.fill(); + ctx.lineWidth = 2; + ctx.strokeStyle = accent; + ctx.stroke(); + } + + // bumpers — concentric, with a lit glow for (let i = 0; i < TABLE.bumpers.length; i++) { const bp = TABLE.bumpers[i]; + const lit = state.lit[i]; + if (lit) { + ctx.shadowColor = accent; + ctx.shadowBlur = 22; + } ctx.beginPath(); ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2); - if (state.lit[i]) { - ctx.fillStyle = accent; - ctx.shadowColor = accent; - ctx.shadowBlur = 18; - } else { - ctx.fillStyle = '#4a4e5c'; - ctx.shadowBlur = 0; - } + ctx.fillStyle = lit ? accent : '#3a3e4b'; ctx.fill(); ctx.shadowBlur = 0; ctx.lineWidth = 3; - ctx.strokeStyle = state.lit[i] ? '#fff' : accent; + ctx.strokeStyle = lit ? '#fff' : accent; 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; } - ctx.strokeStyle = accent; - ctx.lineWidth = PHYS.flipThick * 2; + // flippers for (const which of ['L', 'R']) { const s = flipperSeg(which); + ctx.strokeStyle = accent; + ctx.lineWidth = PHYS.flipThick * 2 + 2; line(s.ax, s.ay, s.bx, s.by); + ctx.strokeStyle = mix(accent, [255, 255, 255], 0.35); + ctx.lineWidth = PHYS.flipThick; + line(s.ax, s.ay, s.bx, s.by); + ctx.beginPath(); + ctx.arc(s.ax, s.ay, PHYS.flipThick + 1, 0, Math.PI * 2); + ctx.fillStyle = '#e8e8ec'; + ctx.fill(); } // plunger - ctx.fillStyle = 'rgba(255,255,255,0.55)'; - ctx.fillRect(289, 452 + state.charge * 14, 16, 20); + const py = 470 - state.charge * 18; + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 3; + line(297, py + 22, 297, 486); + ctx.fillStyle = 'rgba(255,255,255,0.75)'; + ctx.beginPath(); + ctx.roundRect?.(289, py, 16, 22, 4); + ctx.fill(); + if (!ctx.roundRect) ctx.fillRect(289, py, 16, 22); + // ball — a spinning Poké Ball const b = state.ball; if (b) { + const r = PHYS.ballR; ctx.save(); + ctx.translate(b.x, b.y + 1); ctx.beginPath(); - ctx.arc(b.x, b.y, PHYS.ballR, 0, Math.PI * 2); + ctx.arc(0, 1.5, r, 0, Math.PI * 2); + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.fill(); + ctx.restore(); + + 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 = '#f2f2f5'; - ctx.fillRect(b.x - 9, b.y - 9, 18, 18); + ctx.fillStyle = '#f4f4f6'; + ctx.fillRect(-r, -r, r * 2, r * 2); ctx.fillStyle = accent; - ctx.fillRect(b.x - 9, b.y - 9, 18, 9); + 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, PHYS.ballR, 0, Math.PI * 2); - ctx.lineWidth = 1.5; - ctx.strokeStyle = 'rgba(0,0,0,0.55)'; + ctx.arc(b.x, b.y, r, 0, Math.PI * 2); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.stroke(); - ctx.beginPath(); - ctx.moveTo(b.x - PHYS.ballR, b.y); - ctx.lineTo(b.x + PHYS.ballR, b.y); - ctx.stroke(); - ctx.beginPath(); - ctx.arc(b.x, b.y, 2.4, 0, Math.PI * 2); - ctx.fillStyle = '#fff'; - ctx.fill(); } if (state.catchT > 0) { ctx.globalAlpha = Math.min(1, state.catchT); + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, H / 2 - 66, W, 92); ctx.fillStyle = '#fff'; ctx.font = 'bold 22px system-ui, sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 40); - ctx.font = 'bold 14px system-ui, sans-serif'; + ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 30); + ctx.font = 'bold 15px system-ui, sans-serif'; ctx.fillStyle = accent; - ctx.fillText('+5000', W / 2, H / 2 - 18); + ctx.fillText('+5000', W / 2, H / 2 - 6); ctx.globalAlpha = 1; ctx.textAlign = 'left'; } diff --git a/src/styles/layout.css b/src/styles/layout.css index 5455634..c94fced 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3882,8 +3882,11 @@ /* ---- Hidden mini-game: Pinball (#/pinball) ----------------------- */ .pin { - max-width: 26rem; + max-width: 24rem; margin: 0 auto; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; } .pin__hud { display: flex; @@ -3909,14 +3912,22 @@ } .pin__stage { position: relative; + padding: 8px; + border-radius: calc(var(--radius-lg) + 8px); + background: linear-gradient(160deg, #2a2c36, #131419); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.06), + 0 10px 30px -12px rgba(0, 0, 0, 0.6); } .pin__canvas { display: block; margin: 0 auto; /* the engine sets style.width/height from the container */ border-radius: var(--radius-lg); - border: 1px solid var(--border); + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08); touch-action: none; background: #101116; + -webkit-user-select: none; + user-select: none; } .pin__over[hidden] { display: none; diff --git a/src/views/PinballView.js b/src/views/PinballView.js index fb530af..6fd719c 100644 --- a/src/views/PinballView.js +++ b/src/views/PinballView.js @@ -79,18 +79,37 @@ export async function PinballView() { window.addEventListener('keydown', kd); window.addEventListener('keyup', ku); + // Per-pointer flipper tracking so two thumbs work and nothing sticks if a + // pointerup is missed. `resync` recomputes each flipper from live pointers. + const pointers = new Map(); // pointerId -> 'L' | 'R' + function resync() { + let L = false; + let R = false; + for (const side of pointers.values()) side === 'L' ? (L = true) : (R = true); + game.setFlipper('L', L); + game.setFlipper('R', R); + } canvas.addEventListener('pointerdown', (e) => { + e.preventDefault(); canvas.setPointerCapture?.(e.pointerId); - const left = e.offsetX < canvas.clientWidth / 2; - game.setFlipper(left ? 'L' : 'R', true); - canvas._side = left ? 'L' : 'R'; + const rect = canvas.getBoundingClientRect(); + pointers.set(e.pointerId, e.clientX - rect.left < rect.width / 2 ? 'L' : 'R'); + resync(); }); - const releaseFlippers = (e) => { - if (canvas._side) game.setFlipper(canvas._side, false); - canvas._side = null; + const drop = (e) => { + if (pointers.delete(e.pointerId)) resync(); }; - canvas.addEventListener('pointerup', releaseFlippers); - canvas.addEventListener('pointercancel', releaseFlippers); + for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture']) + canvas.addEventListener(ev, drop); + // Clear everything if focus/visibility is lost mid-press. + const clearAll = () => { + if (pointers.size) { + pointers.clear(); + resync(); + } + }; + window.addEventListener('blur', clearAll); + document.addEventListener('visibilitychange', clearAll); const launchBtn = el('button', { class: 'button pin__launch', type: 'button' }, 'Launch (hold)'); for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true)); @@ -124,6 +143,8 @@ export async function PinballView() { game.stop(); window.removeEventListener('keydown', kd); window.removeEventListener('keyup', ku); + window.removeEventListener('blur', clearAll); + document.removeEventListener('visibilitychange', clearAll); }); return view; }