diff --git a/src/lib/pinball.js b/src/lib/pinball.js index 6e070f2..76e056a 100644 --- a/src/lib/pinball.js +++ b/src/lib/pinball.js @@ -16,7 +16,7 @@ const H = 560; const PHYS = { gravity: 1050, // units/s² drag: 0.9998, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast - maxSpeed: 1000, // 5 substeps keep even this from tunnelling + maxSpeed: 1120, // 5 substeps keep even this from tunnelling substeps: 5, ballR: 7, wallE: 0.34, // wall restitution @@ -215,14 +215,21 @@ export function createPinball(canvas, cbs = {}) { b.vx *= 0.6; b.x += (TABLE.laneX - b.x) * 0.35; } - // Re-arm the plunger if a ball ends up sitting in the lane again. - if (!b.inLane && !b.launching && b.x > 292 && b.y > 430 && Math.hypot(b.vx, b.vy) < 60) { + // If a launch fizzled (never reached the gate) and the ball is dropping + // back down the lane, forget the launch so the plunger can re-arm. + if (b.launching && b.vy > 0 && b.y > 320) b.launching = false; + // Re-arm the plunger for any near-stationary ball back in the lane. + if (!b.inLane && !b.launching && b.x > 290 && b.y > 442 && Math.hypot(b.vx, b.vy) < 140) { b.inLane = true; + b.x = TABLE.laneX; + b.vx = 0; } - // Gate kick: once the launched ball clears the divider, fling it left - // into the playfield (a straight-up launch has nowhere else to go). - if (b.launching && b.y < 236) { - b.vx = -(150 + 90 * state.launchC); + // Gate kick: partway up the lane, fling the ball left into the + // playfield (a straight-up launch has nowhere else to go). Fired well + // below the divider top so even a soft launch clears it; the kick + // scales with the charge so a full plunge rips across the table. + if (b.launching && b.y < 300) { + b.vx = -(170 + 220 * state.launchC); b.launching = false; } @@ -595,6 +602,7 @@ export function createPinball(canvas, cbs = {}) { const sdt = fdt / PHYS.substeps; for (let i = 0; i < PHYS.substeps; i++) step(sdt); state.charge = state.chargeStart ? Math.min(1, (now - state.chargeStart) / PULL_MS) : 0; + cbs.onCharge?.(state.charge); } function loop(now) { @@ -640,16 +648,19 @@ export function createPinball(canvas, cbs = {}) { const b = state.ball; if (down && !state.plunge && b?.inLane) { state.chargeStart = performance.now(); - } else if (!down && state.plunge && b?.inLane && state.chargeStart) { + } else if (!down && state.plunge && state.chargeStart && b?.inLane) { const held = (performance.now() - state.chargeStart) / 1000; - const c = Math.min(1, Math.max(0.15, held / (PULL_MS / 1000))); - // Straight up the lane; step() kicks it left once it clears the divider. - b.vy = -(920 + 120 * c); + const c = Math.min(1, held / (PULL_MS / 1000)); // 0 = tap, 1 = full hold + // Straight up the lane; step() kicks it left once it clears the gate. + // Both the launch speed and that kick scale with the charge, so a + // full plunge rips the ball across the top and a tap just lobs it in. + b.vy = -(760 + 320 * c); b.vx = 0; b.inLane = false; // commit — don't let a graze in the lane cancel the launch b.launching = true; state.launchC = c; state.chargeStart = 0; + state.charge = 0; } state.plunge = down; }, @@ -659,6 +670,7 @@ export function createPinball(canvas, cbs = {}) { score: state.score, balls: state.balls, over: state.over, + charge: state.charge, ball: state.ball && { ...state.ball }, running: state.running, }), diff --git a/src/styles/layout.css b/src/styles/layout.css index 26e655b..2e6af8f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -3955,9 +3955,21 @@ margin-top: 12px; } .pin__launch { - min-width: 12rem; + position: relative; + overflow: hidden; + min-width: 13rem; touch-action: none; } +.pin__launch-fill { + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--charge, 0) * 100%); + background: var(--accent); + opacity: 0.85; +} +.pin__launch-label { + position: relative; +} .pin__hint { margin-top: 10px; font-size: 0.78rem; diff --git a/src/views/PinballView.js b/src/views/PinballView.js index 14004ba..3800513 100644 --- a/src/views/PinballView.js +++ b/src/views/PinballView.js @@ -77,6 +77,7 @@ export async function PinballView() { } let catchTimer = null; + let launchBtn; // created below (it needs `game`); onCharge only fires after start() const soundBtn = el('button', { class: 'pin__sound', type: 'button', @@ -94,6 +95,7 @@ export async function PinballView() { onBalls: renderBalls, onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()), onDrain: () => sfx.drain(), + onCharge: (c) => launchBtn.style.setProperty('--charge', c.toFixed(3)), pickMon: () => { const s = snap.species[(Math.random() * snap.species.length) | 0]; return { id: s.id, name: pretty(s.name) }; @@ -177,7 +179,7 @@ export async function PinballView() { for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture']) canvas.addEventListener(ev, drop); - const launchBtn = el('button', { + launchBtn = el('button', { class: 'button pin__launch', type: 'button', onpointerdown: (e) => { @@ -186,7 +188,10 @@ export async function PinballView() { game.setPlunge(true); }, }); - launchBtn.textContent = 'Launch (hold)'; + launchBtn.append( + el('span', { class: 'pin__launch-fill' }), + el('span', { class: 'pin__launch-label' }, 'Hold to launch'), + ); const releasePlunge = () => game.setPlunge(false); for (const ev of ['pointerup', 'pointerleave', 'pointercancel']) launchBtn.addEventListener(ev, releasePlunge);