Pinball: rework the table, fix mobile input, polish the render
Playtest feedback: - Ball wedged in the bottom corners → the lower table is now deliberately open: below the side walls it's all drain except the two flippers and slingshots, so there are no enclosed basins. A displacement-based ball-search still nudges (then, after 3 strikes, drains) anything that does get pinned. - "Needs more things to bounce off" → 5 bumpers (was 3) spread across the mid-field + 3 small posts. - "Gutter rails too large" → the long inlane rails are gone; short corner guides only. - "Can the ball go back to the launcher?" → yes: a ball that drains down the right side returns to the shooter lane, no life lost. Left/centre drains still cost a ball. - Poké Ball now spins with its motion. - Flippers stuck on mobile → per-pointer tracking (Map of pointerId→side), drop on pointerup/cancel/leave/lostpointercapture, clear on blur/hide. Two thumbs work; a missed pointerup can't leave a flipper held. - Text selection on touch → user-select:none on the game, preventDefault on pointerdown. - "Looks rudimentary" → framed panel, playfield grid + accent glow, concentric bumpers with a lit halo, slingshot triangles, flippers with a highlight core and pivot caps, a drawn plunger, a shaded Poké Ball. Also: canvas backing store is sized from the container × capped DPR (crisp on any display) and `.pin__over` no longer sits blurred over the table when hidden. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
9fa46493a8
commit
0c16c1c4a7
@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const W = 320;
|
const W = 320;
|
||||||
const H = 540;
|
const H = 560;
|
||||||
|
|
||||||
const PHYS = {
|
const PHYS = {
|
||||||
gravity: 1050, // units/s²
|
gravity: 1050, // units/s²
|
||||||
@ -18,51 +18,69 @@ const PHYS = {
|
|||||||
maxSpeed: 1000, // 5 substeps keep even this from tunnelling
|
maxSpeed: 1000, // 5 substeps keep even this from tunnelling
|
||||||
substeps: 5,
|
substeps: 5,
|
||||||
ballR: 7,
|
ballR: 7,
|
||||||
wallE: 0.32, // wall restitution
|
wallE: 0.34, // wall restitution
|
||||||
flipE: 0.28,
|
flipE: 0.28,
|
||||||
flipThick: 6,
|
flipThick: 6,
|
||||||
flipLen: 56,
|
flipLen: 56,
|
||||||
flipSwing: 1.02, // radians
|
flipSwing: 1.02, // radians
|
||||||
flipSpeed: 34, // ease rate toward target angle
|
flipSpeed: 34, // ease rate toward target angle
|
||||||
bumperE: 0.5,
|
bumperE: 0.55,
|
||||||
bumperKick: 108,
|
bumperKick: 116,
|
||||||
slingE: 1.06,
|
postE: 0.75,
|
||||||
slingKick: 78,
|
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? }.
|
// 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 = {
|
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: [
|
walls: [
|
||||||
{ a: [14, 58], b: [40, 20] }, // top-left corner
|
{ a: [14, 60], b: [42, 20] }, // top-left corner
|
||||||
{ a: [40, 20], b: [252, 20] }, // top
|
{ a: [42, 20], b: [250, 20] }, // top
|
||||||
{ a: [252, 20], b: [300, 56] }, // top-right corner
|
{ a: [250, 20], b: [300, 56] }, // top-right corner
|
||||||
{ a: [300, 56], b: [306, 96] },
|
{ a: [300, 56], b: [306, 96] },
|
||||||
{ a: [306, 96], b: [306, 446] }, // right wall / shooter-lane outer
|
{ a: [306, 96], b: [306, 474] }, // right wall / shooter-lane outer
|
||||||
{ a: [288, 446], b: [288, 230] }, // shooter-lane divider
|
{ a: [288, 474], b: [288, 236] }, // shooter-lane divider
|
||||||
{ a: [288, 446], b: [306, 446] }, // shooter-lane floor
|
{ a: [288, 474], b: [306, 474] }, // shooter-lane floor
|
||||||
{ a: [14, 58], b: [14, 452] }, // left wall
|
{ a: [14, 60], b: [14, 452] }, // left wall
|
||||||
{ a: [14, 452], b: [34, 476] }, // left chamfer
|
{ a: [14, 452], b: [40, 478] }, // left lower guide (rounds toward the flipper)
|
||||||
{ a: [34, 476], b: [86, 506], e: 0.12 }, // left inlane → left flipper
|
{ a: [288, 474], b: [264, 492] }, // right lower guide
|
||||||
{ a: [288, 446], b: [268, 470] }, // right chamfer off the divider
|
|
||||||
{ a: [268, 470], b: [214, 504], e: 0.12 }, // right inlane → right flipper
|
// left slingshot — triangle above the flipper's outer half
|
||||||
],
|
{ a: [88, 442], b: [104, 486], e: PHYS.slingE, k: PHYS.slingKick }, // kicking face
|
||||||
// slingshots — bouncy, kink up off the inlane end (a convex corner)
|
{ a: [88, 442], b: [58, 486] }, // outer face
|
||||||
slings: [
|
{ a: [58, 486], b: [104, 486] }, // bottom
|
||||||
{ a: [86, 506], b: [114, 452] },
|
|
||||||
{ a: [214, 504], b: [188, 452] },
|
// 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: [
|
bumpers: [
|
||||||
{ x: 92, y: 150, r: 15 },
|
{ x: 80, y: 132, r: 15 },
|
||||||
{ x: 168, y: 114, r: 15 },
|
{ x: 160, y: 100, r: 15 },
|
||||||
{ x: 226, y: 150, 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: {
|
flippers: {
|
||||||
L: { pivot: [88, 500], rest: 0.36 }, // radians, y-down; tip points down-right
|
L: { pivot: [86, 504], rest: 0.3 }, // radians, y-down; tip points down-right
|
||||||
R: { pivot: [214, 500], rest: Math.PI - 0.36 },
|
R: { pivot: [234, 504], rest: Math.PI - 0.3 },
|
||||||
},
|
},
|
||||||
laneX: 297, // ball rest x in the shooter lane
|
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));
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
@ -98,14 +116,21 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
plunge: false,
|
plunge: false,
|
||||||
chargeStart: 0, // performance.now() when the plunger was pressed
|
chargeStart: 0, // performance.now() when the plunger was pressed
|
||||||
charge: 0, // 0..1, for the plunger visual only
|
charge: 0, // 0..1, for the plunger visual only
|
||||||
lit: [false, false, false],
|
lit: TABLE.bumpers.map(() => false),
|
||||||
catchT: 0,
|
catchT: 0,
|
||||||
catchName: '',
|
catchName: '',
|
||||||
scale: 1,
|
scale: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function spawnBall() {
|
function spawnBall(toLane = true) {
|
||||||
state.ball = { x: TABLE.laneX, y: 436, vx: 0, vy: 0, inLane: true };
|
state.ball = {
|
||||||
|
x: TABLE.laneX,
|
||||||
|
y: TABLE.laneRestY - 4,
|
||||||
|
vx: 0,
|
||||||
|
vy: 0,
|
||||||
|
spin: 0,
|
||||||
|
inLane: toLane,
|
||||||
|
};
|
||||||
state.charge = 0;
|
state.charge = 0;
|
||||||
state.chargeStart = 0;
|
state.chargeStart = 0;
|
||||||
}
|
}
|
||||||
@ -116,7 +141,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
state.score = 0;
|
state.score = 0;
|
||||||
state.balls = 3;
|
state.balls = 3;
|
||||||
state.over = false;
|
state.over = false;
|
||||||
state.lit = [false, false, false];
|
state.lit = TABLE.bumpers.map(() => false);
|
||||||
spawnBall();
|
spawnBall();
|
||||||
cbs.onScore?.(0);
|
cbs.onScore?.(0);
|
||||||
cbs.onBalls?.(3);
|
cbs.onBalls?.(3);
|
||||||
@ -196,20 +221,38 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
b.y += b.vy * dt;
|
b.y += b.vy * dt;
|
||||||
|
|
||||||
for (const w of TABLE.walls) {
|
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);
|
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
|
||||||
for (const s of TABLE.slings) {
|
const mx = (w.a[0] + w.b[0]) / 2;
|
||||||
if (collideSeg(b, s.a[0], s.a[1], s.b[0], s.b[1], PHYS.slingE, 2)) {
|
const my = (w.a[1] + w.b[1]) / 2;
|
||||||
const mx = (s.a[0] + s.b[0]) / 2;
|
|
||||||
const my = (s.a[1] + s.b[1]) / 2;
|
|
||||||
const nx = b.x - mx;
|
const nx = b.x - mx;
|
||||||
const ny = b.y - my;
|
const ny = b.y - my;
|
||||||
const nl = Math.hypot(nx, ny) || 1;
|
const nl = Math.hypot(nx, ny) || 1;
|
||||||
b.vx += (nx / nl) * PHYS.slingKick;
|
b.vx += (nx / nl) * w.k;
|
||||||
b.vy += (ny / nl) * PHYS.slingKick;
|
b.vy += (ny / nl) * w.k;
|
||||||
addScore(50);
|
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++) {
|
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
||||||
const bp = TABLE.bumpers[i];
|
const bp = TABLE.bumpers[i];
|
||||||
let dx = b.x - bp.x;
|
let dx = b.x - bp.x;
|
||||||
@ -236,7 +279,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
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 = [false, false, false];
|
state.lit = TABLE.bumpers.map(() => false);
|
||||||
addScore(5000);
|
addScore(5000);
|
||||||
state.catchName = (cbs.pickName?.() || 'Something').toUpperCase();
|
state.catchName = (cbs.pickName?.() || 'Something').toUpperCase();
|
||||||
state.catchT = 1.6;
|
state.catchT = 1.6;
|
||||||
@ -260,6 +303,7 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
b.vx *= PHYS.maxSpeed / sp;
|
b.vx *= PHYS.maxSpeed / sp;
|
||||||
b.vy *= 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
|
// 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
|
// 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;
|
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() {
|
function loseBall() {
|
||||||
@ -326,95 +374,183 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
ctx.stroke();
|
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() {
|
function render() {
|
||||||
ctx.setTransform(state.scale, 0, 0, state.scale, 0, 0);
|
ctx.setTransform(state.scale, 0, 0, state.scale, 0, 0);
|
||||||
|
|
||||||
|
// playfield
|
||||||
const g = ctx.createLinearGradient(0, 0, 0, H);
|
const g = ctx.createLinearGradient(0, 0, 0, H);
|
||||||
g.addColorStop(0, '#282a34');
|
g.addColorStop(0, '#2b2d38');
|
||||||
g.addColorStop(1, '#101116');
|
g.addColorStop(0.55, '#1a1b22');
|
||||||
|
g.addColorStop(1, '#0d0e12');
|
||||||
ctx.fillStyle = g;
|
ctx.fillStyle = g;
|
||||||
ctx.fillRect(0, 0, W, H);
|
ctx.fillRect(0, 0, W, H);
|
||||||
const glow = ctx.createRadialGradient(W / 2, 150, 20, W / 2, 150, 220);
|
const glow = ctx.createRadialGradient(W / 2, 150, 10, W / 2, 150, 240);
|
||||||
glow.addColorStop(0, `${accent}22`);
|
glow.addColorStop(0, accentA(0.18));
|
||||||
glow.addColorStop(1, 'transparent');
|
glow.addColorStop(1, 'transparent');
|
||||||
ctx.fillStyle = glow;
|
ctx.fillStyle = glow;
|
||||||
ctx.fillRect(0, 0, W, H);
|
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.lineCap = 'round';
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.55)';
|
// slingshot triangles (filled) — draw before the wall outline
|
||||||
ctx.lineWidth = 2.5;
|
const tri = (pts) => {
|
||||||
for (const w of TABLE.walls) line(w.a[0], w.a[1], w.b[0], w.b[1]);
|
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;
|
// wall outline
|
||||||
ctx.lineWidth = 5;
|
ctx.strokeStyle = 'rgba(255,255,255,0.7)';
|
||||||
for (const s of TABLE.slings) line(s.a[0], s.a[1], s.b[0], s.b[1]);
|
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++) {
|
for (let i = 0; i < TABLE.bumpers.length; i++) {
|
||||||
const bp = TABLE.bumpers[i];
|
const bp = TABLE.bumpers[i];
|
||||||
|
const lit = state.lit[i];
|
||||||
|
if (lit) {
|
||||||
|
ctx.shadowColor = accent;
|
||||||
|
ctx.shadowBlur = 22;
|
||||||
|
}
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
|
ctx.arc(bp.x, bp.y, bp.r, 0, Math.PI * 2);
|
||||||
if (state.lit[i]) {
|
ctx.fillStyle = lit ? accent : '#3a3e4b';
|
||||||
ctx.fillStyle = accent;
|
|
||||||
ctx.shadowColor = accent;
|
|
||||||
ctx.shadowBlur = 18;
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = '#4a4e5c';
|
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
}
|
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.shadowBlur = 0;
|
ctx.shadowBlur = 0;
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.strokeStyle = state.lit[i] ? '#fff' : accent;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.strokeStyle = accent;
|
// flippers
|
||||||
ctx.lineWidth = PHYS.flipThick * 2;
|
|
||||||
for (const which of ['L', 'R']) {
|
for (const which of ['L', 'R']) {
|
||||||
const s = flipperSeg(which);
|
const s = flipperSeg(which);
|
||||||
|
ctx.strokeStyle = accent;
|
||||||
|
ctx.lineWidth = PHYS.flipThick * 2 + 2;
|
||||||
line(s.ax, s.ay, s.bx, s.by);
|
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
|
// plunger
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.55)';
|
const py = 470 - state.charge * 18;
|
||||||
ctx.fillRect(289, 452 + state.charge * 14, 16, 20);
|
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;
|
const b = state.ball;
|
||||||
if (b) {
|
if (b) {
|
||||||
|
const r = PHYS.ballR;
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
ctx.translate(b.x, b.y + 1);
|
||||||
ctx.beginPath();
|
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.clip();
|
||||||
ctx.fillStyle = '#f2f2f5';
|
ctx.fillStyle = '#f4f4f6';
|
||||||
ctx.fillRect(b.x - 9, b.y - 9, 18, 18);
|
ctx.fillRect(-r, -r, r * 2, r * 2);
|
||||||
ctx.fillStyle = accent;
|
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.restore();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(b.x, b.y, PHYS.ballR, 0, Math.PI * 2);
|
ctx.arc(b.x, b.y, r, 0, Math.PI * 2);
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
|
ctx.strokeStyle = 'rgba(0,0,0,0.4)';
|
||||||
ctx.stroke();
|
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) {
|
if (state.catchT > 0) {
|
||||||
ctx.globalAlpha = Math.min(1, state.catchT);
|
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.fillStyle = '#fff';
|
||||||
ctx.font = 'bold 22px system-ui, sans-serif';
|
ctx.font = 'bold 22px system-ui, sans-serif';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 40);
|
ctx.fillText(`Caught ${state.catchName}!`, W / 2, H / 2 - 30);
|
||||||
ctx.font = 'bold 14px system-ui, sans-serif';
|
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||||
ctx.fillStyle = accent;
|
ctx.fillStyle = accent;
|
||||||
ctx.fillText('+5000', W / 2, H / 2 - 18);
|
ctx.fillText('+5000', W / 2, H / 2 - 6);
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3882,8 +3882,11 @@
|
|||||||
|
|
||||||
/* ---- Hidden mini-game: Pinball (#/pinball) ----------------------- */
|
/* ---- Hidden mini-game: Pinball (#/pinball) ----------------------- */
|
||||||
.pin {
|
.pin {
|
||||||
max-width: 26rem;
|
max-width: 24rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
.pin__hud {
|
.pin__hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -3909,14 +3912,22 @@
|
|||||||
}
|
}
|
||||||
.pin__stage {
|
.pin__stage {
|
||||||
position: relative;
|
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 {
|
.pin__canvas {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto; /* the engine sets style.width/height from the container */
|
margin: 0 auto; /* the engine sets style.width/height from the container */
|
||||||
border-radius: var(--radius-lg);
|
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;
|
touch-action: none;
|
||||||
background: #101116;
|
background: #101116;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
.pin__over[hidden] {
|
.pin__over[hidden] {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@ -79,18 +79,37 @@ 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
|
||||||
|
// 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) => {
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
canvas.setPointerCapture?.(e.pointerId);
|
canvas.setPointerCapture?.(e.pointerId);
|
||||||
const left = e.offsetX < canvas.clientWidth / 2;
|
const rect = canvas.getBoundingClientRect();
|
||||||
game.setFlipper(left ? 'L' : 'R', true);
|
pointers.set(e.pointerId, e.clientX - rect.left < rect.width / 2 ? 'L' : 'R');
|
||||||
canvas._side = left ? 'L' : 'R';
|
resync();
|
||||||
});
|
});
|
||||||
const releaseFlippers = (e) => {
|
const drop = (e) => {
|
||||||
if (canvas._side) game.setFlipper(canvas._side, false);
|
if (pointers.delete(e.pointerId)) resync();
|
||||||
canvas._side = null;
|
|
||||||
};
|
};
|
||||||
canvas.addEventListener('pointerup', releaseFlippers);
|
for (const ev of ['pointerup', 'pointercancel', 'pointerleave', 'lostpointercapture'])
|
||||||
canvas.addEventListener('pointercancel', releaseFlippers);
|
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)');
|
const launchBtn = el('button', { class: 'button pin__launch', type: 'button' }, 'Launch (hold)');
|
||||||
for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true));
|
for (const ev of ['pointerdown']) launchBtn.addEventListener(ev, () => game.setPlunge(true));
|
||||||
@ -124,6 +143,8 @@ export async function PinballView() {
|
|||||||
game.stop();
|
game.stop();
|
||||||
window.removeEventListener('keydown', kd);
|
window.removeEventListener('keydown', kd);
|
||||||
window.removeEventListener('keyup', ku);
|
window.removeEventListener('keyup', ku);
|
||||||
|
window.removeEventListener('blur', clearAll);
|
||||||
|
document.removeEventListener('visibilitychange', clearAll);
|
||||||
});
|
});
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user