Compare commits

...

2 Commits

Author SHA1 Message Date
8a784ec641 Pinball: charge-scaled plunger power, fizzle recovery, charge bar
The plunger held no useful power range — any press launched at nearly
full strength and a missed pointerup could leave it stuck.

- Launch speed now scales with hold time end to end: `-(760 + 320*c)`
  with no minimum floor, so a tap lobs the ball in and a full ~900ms
  hold rips it across the top.
- The gate kick that turns a straight-up launch into the playfield now
  fires lower in the lane (y < 300) and scales too, `-(170 + 220*c)`,
  so even a soft launch clears the divider instead of rattling back down.
- A launch that never reaches the gate now clears `b.launching` on the
  way down, and the lane re-arms for any slow ball sitting in it, so a
  ball that dribbles back into the tube can be re-launched.
- Launch button fills with an accent bar as it charges (`--charge`),
  driven by a new `onCharge` callback; `peek()` reports `charge`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 15:04:52 -04:00
9981c48bf6 Pinball: close the left outlane, seal the shooter lane after launch
- The ball kept escaping unsaveably down the far left. The left outlane
  is now closed by a solid deflector from the wall to the left flipper
  pivot, so a ball down that side is always fed onto the flipper. Only
  the centre gap between the flipper tips is a free drain now.
- The shooter-lane divider runs the full height (y 120–474) so a ball in
  play can't fall back into the lane after launch ("close it after
  launch"). Plus a safety: a slow ball that does end up deep in the lane
  re-arms the plunger. Right wall extends down as the return chute.

Sim: ball stays within x≥60, drains cleanly through the centre, game
completes; no NaN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-10 14:52:02 -04:00
3 changed files with 69 additions and 31 deletions

View File

@ -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
@ -44,22 +44,27 @@ const TABLE = {
{ 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, 474] }, // right wall / shooter-lane outer
{ a: [288, 474], b: [288, 236] }, // shooter-lane divider
{ a: [306, 96], b: [306, 548] }, // right wall / shooter-lane outer (continues down as the return chute)
{ a: [288, 474], b: [288, 120] }, // shooter-lane divider (tall — the ball can't fall back in after launch)
{ 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
{ a: [14, 60], b: [14, 448] }, // left wall
// 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
// left outlane is CLOSED: a solid deflector from the wall to the flipper
// pivot, so a ball down the left is always fed onto the left flipper.
{ a: [14, 448], b: [50, 470] },
{ a: [50, 470], b: [86, 504] },
// 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] },
// right side stays open below the divider so a ball can trickle to the
// return; a short guide nudges it that way.
{ a: [288, 474], b: [262, 494] },
// slingshots (triangles above each flipper's outer half)
{ a: [96, 434], b: [110, 476], e: PHYS.slingE, k: PHYS.slingKick }, // L kicking face
{ a: [96, 434], b: [76, 476] },
{ a: [76, 476], b: [110, 476] },
{ a: [230, 434], b: [214, 476], e: PHYS.slingE, k: PHYS.slingKick }, // R kicking face
{ a: [230, 434], b: [250, 476] },
{ a: [214, 476], b: [250, 476] },
],
bumpers: [
{ x: 80, y: 132, r: 15 },
@ -210,10 +215,21 @@ export function createPinball(canvas, cbs = {}) {
b.vx *= 0.6;
b.x += (TABLE.laneX - b.x) * 0.35;
}
// 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);
// 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: 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;
}
@ -472,15 +488,15 @@ export function createPinball(canvas, cbs = {}) {
};
ctx.fillStyle = accentA(0.22);
tri([
[88, 442],
[104, 486],
[58, 486],
[96, 434],
[110, 476],
[76, 476],
]);
ctx.fill();
tri([
[232, 442],
[216, 486],
[262, 486],
[230, 434],
[214, 476],
[250, 476],
]);
ctx.fill();
@ -586,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) {
@ -631,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;
},
@ -650,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,
}),

View File

@ -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;

View File

@ -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);