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
This commit is contained in:
parent
9981c48bf6
commit
8a784ec641
@ -16,7 +16,7 @@ const H = 560;
|
|||||||
const PHYS = {
|
const PHYS = {
|
||||||
gravity: 1050, // units/s²
|
gravity: 1050, // units/s²
|
||||||
drag: 0.9998, // per sub-step (×substeps×fps) — keep close to 1 or the ball dies fast
|
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,
|
substeps: 5,
|
||||||
ballR: 7,
|
ballR: 7,
|
||||||
wallE: 0.34, // wall restitution
|
wallE: 0.34, // wall restitution
|
||||||
@ -215,14 +215,21 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
b.vx *= 0.6;
|
b.vx *= 0.6;
|
||||||
b.x += (TABLE.laneX - b.x) * 0.35;
|
b.x += (TABLE.laneX - b.x) * 0.35;
|
||||||
}
|
}
|
||||||
// Re-arm the plunger if a ball ends up sitting in the lane again.
|
// If a launch fizzled (never reached the gate) and the ball is dropping
|
||||||
if (!b.inLane && !b.launching && b.x > 292 && b.y > 430 && Math.hypot(b.vx, b.vy) < 60) {
|
// 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.inLane = true;
|
||||||
|
b.x = TABLE.laneX;
|
||||||
|
b.vx = 0;
|
||||||
}
|
}
|
||||||
// Gate kick: once the launched ball clears the divider, fling it left
|
// Gate kick: partway up the lane, fling the ball left into the
|
||||||
// into the playfield (a straight-up launch has nowhere else to go).
|
// playfield (a straight-up launch has nowhere else to go). Fired well
|
||||||
if (b.launching && b.y < 236) {
|
// below the divider top so even a soft launch clears it; the kick
|
||||||
b.vx = -(150 + 90 * state.launchC);
|
// 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;
|
b.launching = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -595,6 +602,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
const sdt = fdt / PHYS.substeps;
|
const sdt = fdt / PHYS.substeps;
|
||||||
for (let i = 0; i < PHYS.substeps; i++) step(sdt);
|
for (let i = 0; i < PHYS.substeps; i++) step(sdt);
|
||||||
state.charge = state.chargeStart ? Math.min(1, (now - state.chargeStart) / PULL_MS) : 0;
|
state.charge = state.chargeStart ? Math.min(1, (now - state.chargeStart) / PULL_MS) : 0;
|
||||||
|
cbs.onCharge?.(state.charge);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loop(now) {
|
function loop(now) {
|
||||||
@ -640,16 +648,19 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
const b = state.ball;
|
const b = state.ball;
|
||||||
if (down && !state.plunge && b?.inLane) {
|
if (down && !state.plunge && b?.inLane) {
|
||||||
state.chargeStart = performance.now();
|
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 held = (performance.now() - state.chargeStart) / 1000;
|
||||||
const c = Math.min(1, Math.max(0.15, held / (PULL_MS / 1000)));
|
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 divider.
|
// Straight up the lane; step() kicks it left once it clears the gate.
|
||||||
b.vy = -(920 + 120 * c);
|
// 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.vx = 0;
|
||||||
b.inLane = false; // commit — don't let a graze in the lane cancel the launch
|
b.inLane = false; // commit — don't let a graze in the lane cancel the launch
|
||||||
b.launching = true;
|
b.launching = true;
|
||||||
state.launchC = c;
|
state.launchC = c;
|
||||||
state.chargeStart = 0;
|
state.chargeStart = 0;
|
||||||
|
state.charge = 0;
|
||||||
}
|
}
|
||||||
state.plunge = down;
|
state.plunge = down;
|
||||||
},
|
},
|
||||||
@ -659,6 +670,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
score: state.score,
|
score: state.score,
|
||||||
balls: state.balls,
|
balls: state.balls,
|
||||||
over: state.over,
|
over: state.over,
|
||||||
|
charge: state.charge,
|
||||||
ball: state.ball && { ...state.ball },
|
ball: state.ball && { ...state.ball },
|
||||||
running: state.running,
|
running: state.running,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -3955,9 +3955,21 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
.pin__launch {
|
.pin__launch {
|
||||||
min-width: 12rem;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 13rem;
|
||||||
touch-action: none;
|
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 {
|
.pin__hint {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
|
|||||||
@ -77,6 +77,7 @@ export async function PinballView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let catchTimer = null;
|
let catchTimer = null;
|
||||||
|
let launchBtn; // created below (it needs `game`); onCharge only fires after start()
|
||||||
const soundBtn = el('button', {
|
const soundBtn = el('button', {
|
||||||
class: 'pin__sound',
|
class: 'pin__sound',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
@ -94,6 +95,7 @@ export async function PinballView() {
|
|||||||
onBalls: renderBalls,
|
onBalls: renderBalls,
|
||||||
onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()),
|
onBump: (kind) => (kind === 'post' ? sfx.post() : sfx.bump()),
|
||||||
onDrain: () => sfx.drain(),
|
onDrain: () => sfx.drain(),
|
||||||
|
onCharge: (c) => launchBtn.style.setProperty('--charge', c.toFixed(3)),
|
||||||
pickMon: () => {
|
pickMon: () => {
|
||||||
const s = snap.species[(Math.random() * snap.species.length) | 0];
|
const s = snap.species[(Math.random() * snap.species.length) | 0];
|
||||||
return { id: s.id, name: pretty(s.name) };
|
return { id: s.id, name: pretty(s.name) };
|
||||||
@ -177,7 +179,7 @@ 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);
|
||||||
|
|
||||||
const launchBtn = el('button', {
|
launchBtn = el('button', {
|
||||||
class: 'button pin__launch',
|
class: 'button pin__launch',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
onpointerdown: (e) => {
|
onpointerdown: (e) => {
|
||||||
@ -186,7 +188,10 @@ export async function PinballView() {
|
|||||||
game.setPlunge(true);
|
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);
|
const releasePlunge = () => game.setPlunge(false);
|
||||||
for (const ev of ['pointerup', 'pointerleave', 'pointercancel'])
|
for (const ev of ['pointerup', 'pointerleave', 'pointercancel'])
|
||||||
launchBtn.addEventListener(ev, releasePlunge);
|
launchBtn.addEventListener(ev, releasePlunge);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user