Pinball: rebuild the table — flat stadium look, connected shooter lane
Reworked the table geometry and render toward a flat "Pokémon stadium"
look and fixed the launcher, which never delivered the ball into play.
Visual:
- Flat, bright arena style — solid fills, crisp dark outlines, no glow.
A rounded battle-arena floor with a Poké Ball painted on it, a crowd
strip across the top. Dropped the dark playfield and neon strokes.
- The whole table is a rounded rectangle now (was a stitched-looking
polygon); play boundary drawn as one smooth curve.
- Flippers are tapered bats; bumpers are dimensional pop bumpers with a
skirt (Poké Balls up top, Great Balls below); slingshots are solid
rubber wedges; a coil-spring plunger.
Launcher:
- The shooter lane is a real channel inside the right edge with a curled
hood at the top. Because that corner reliably ping-ponged a launched
ball straight back down the tube, step() now flies the ball out on a
scripted arc ("hood delivery") — collisions off — until it's clearly
among the bumpers. Every pull now launches; charge sets how far left
it carries.
- Geometry is symmetric about the playfield centre; outlane/inlane guides
run past the flipper pivots so a ball can't wedge in the nook beside
them. Ball-search tightened (1.1s window, 4 strikes).
Other:
- A catch now freezes the table while the celebration plays, then
resumes (frame() early-returns during catchT).
- Canvas height is capped to the viewport so the table never needs
scrolling on mobile; tightened the surrounding layout.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
This commit is contained in:
parent
8a784ec641
commit
d9ec697af8
@ -34,59 +34,113 @@ const PHYS = {
|
|||||||
spin: 0.07, // Poké Ball rotation per unit travelled
|
spin: 0.07, // Poké Ball rotation per unit travelled
|
||||||
};
|
};
|
||||||
|
|
||||||
// Named table geometry — segments are { a:[x,y], b:[x,y], e? }.
|
/** Points along an arc a0→a1 (radians, y-down), inclusive of both ends. */
|
||||||
// Concave corners are chamfered so a slow ball can't wedge in a vertex.
|
function arcPts(cx, cy, r, a0, a1, n) {
|
||||||
|
const p = [];
|
||||||
|
for (let i = 0; i <= n; i++) {
|
||||||
|
const a = a0 + ((a1 - a0) * i) / n;
|
||||||
|
p.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
/** Consecutive points → wall segments sharing `extra` (role, e, k…). */
|
||||||
|
function polySegs(pts, extra) {
|
||||||
|
const s = [];
|
||||||
|
for (let i = 0; i < pts.length - 1; i++) s.push({ a: pts[i], b: pts[i + 1], ...extra });
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PI = Math.PI;
|
||||||
|
const D = PI / 180;
|
||||||
|
|
||||||
|
// The playfield is a rounded box. The usable area runs x≈16..264; the
|
||||||
|
// shooter lane is the strip to its right (x≈264..304). Everything below
|
||||||
|
// is built symmetrically about the centre line so nothing looks stitched.
|
||||||
|
const CX = 140; // centre of the usable playfield
|
||||||
|
|
||||||
|
// Outer table outline — a rounded rectangle. Fills the stage as the arena
|
||||||
|
// floor and is the ball's wall everywhere except the drain gap and the
|
||||||
|
// lane mouth (handled by their own segments).
|
||||||
|
const OUTLINE = [
|
||||||
|
...arcPts(58, 62, 44, 180 * D, 270 * D, 8), // TL → (58,18)
|
||||||
|
...arcPts(262, 62, 44, 270 * D, 360 * D, 8), // TR → (306,62)
|
||||||
|
...arcPts(262, 498, 44, 0, 90 * D, 8), // BR → (262,542)
|
||||||
|
...arcPts(58, 498, 44, 90 * D, 180 * D, 8), // BL → (14,498)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Collision boundary: the outline's left + top + right, then straight
|
||||||
|
// down the shooter-lane outer wall. Open at the bottom (the drain).
|
||||||
|
const BOUND = [
|
||||||
|
[112, 508], // runs past the left flipper pivot so there's no nook beside it
|
||||||
|
[80, 496],
|
||||||
|
[54, 478],
|
||||||
|
[32, 452],
|
||||||
|
[18, 418],
|
||||||
|
[16, 388],
|
||||||
|
[16, 106],
|
||||||
|
...arcPts(60, 106, 44, 180 * D, 270 * D, 8), // TL corner → (60,62)
|
||||||
|
[252, 62],
|
||||||
|
...arcPts(252, 106, 44, 270 * D, 360 * D, 8), // TR corner → (296,106)
|
||||||
|
[304, 512], // shooter-lane outer wall
|
||||||
|
];
|
||||||
|
|
||||||
const TABLE = {
|
const TABLE = {
|
||||||
// Deliberately open at the bottom: below the side walls it's all drain
|
outline: OUTLINE,
|
||||||
// except the two flippers and slingshots. No enclosed basins to wedge in.
|
bound: BOUND,
|
||||||
walls: [
|
walls: [
|
||||||
{ a: [14, 60], b: [42, 20] }, // top-left corner
|
...polySegs(BOUND, { role: 'wall' }),
|
||||||
{ 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, 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, 448] }, // left wall
|
|
||||||
|
|
||||||
// left outlane is CLOSED: a solid deflector from the wall to the flipper
|
// shooter lane — a channel on the right. Its inner wall runs straight
|
||||||
// pivot, so a ball down the left is always fed onto the left flipper.
|
// up to hold the resting ball, then curls left into a hood that steers
|
||||||
{ a: [14, 448], b: [50, 470] },
|
// a launched ball down into the playfield (with step()'s scripted
|
||||||
{ a: [50, 470], b: [86, 504] },
|
// "hood delivery" doing the heavy lifting).
|
||||||
|
{ a: [304, 512], b: [268, 512], role: 'lane' }, // lane floor
|
||||||
|
...polySegs([[268, 512], [268, 150], ...arcPts(238, 150, 30, 0, -120 * D, 6), [196, 92]], {
|
||||||
|
role: 'lane',
|
||||||
|
}),
|
||||||
|
|
||||||
// right side stays open below the divider so a ball can trickle to the
|
// right inlane guide — mirrors the left outlane, runs past the right
|
||||||
// return; a short guide nudges it that way.
|
// flipper pivot so there's no nook beside it
|
||||||
{ a: [288, 474], b: [262, 494] },
|
...polySegs(
|
||||||
|
[
|
||||||
|
[250, 400],
|
||||||
|
[244, 436],
|
||||||
|
[226, 466],
|
||||||
|
[200, 486],
|
||||||
|
[168, 508],
|
||||||
|
],
|
||||||
|
{ role: 'guide' },
|
||||||
|
),
|
||||||
|
|
||||||
// slingshots (triangles above each flipper's outer half)
|
// slingshots — wedges above each flipper's outer half (symmetric),
|
||||||
{ a: [96, 434], b: [110, 476], e: PHYS.slingE, k: PHYS.slingKick }, // L kicking face
|
// pulled up and inboard so they don't pocket against the outlane
|
||||||
{ a: [96, 434], b: [76, 476] },
|
{ a: [104, 424], b: [118, 462], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' },
|
||||||
{ a: [76, 476], b: [110, 476] },
|
{ a: [104, 424], b: [96, 462], role: 'sling' },
|
||||||
{ a: [230, 434], b: [214, 476], e: PHYS.slingE, k: PHYS.slingKick }, // R kicking face
|
{ a: [96, 462], b: [118, 462], role: 'sling' },
|
||||||
{ a: [230, 434], b: [250, 476] },
|
{ a: [176, 424], b: [190, 462], role: 'sling' },
|
||||||
{ a: [214, 476], b: [250, 476] },
|
{ a: [176, 424], b: [162, 462], e: PHYS.slingE, k: PHYS.slingKick, role: 'sling' },
|
||||||
|
{ a: [162, 462], b: [190, 462], role: 'sling' },
|
||||||
],
|
],
|
||||||
bumpers: [
|
bumpers: [
|
||||||
{ x: 80, y: 132, r: 15 },
|
{ x: 94, y: 152, r: 15, kind: 'poke' },
|
||||||
{ x: 160, y: 100, r: 15 },
|
{ x: 140, y: 110, r: 16, kind: 'poke' },
|
||||||
{ x: 240, y: 132, r: 15 },
|
{ x: 186, y: 152, r: 15, kind: 'poke' },
|
||||||
{ x: 120, y: 214, r: 13 },
|
{ x: 116, y: 220, r: 12, kind: 'great' },
|
||||||
{ x: 200, y: 214, r: 13 },
|
{ x: 164, y: 220, r: 12, kind: 'great' },
|
||||||
],
|
],
|
||||||
// small round posts — extra things to carom off in the mid-field
|
|
||||||
posts: [
|
posts: [
|
||||||
{ x: 52, y: 300, r: 7 },
|
{ x: 44, y: 300, r: 7 },
|
||||||
{ x: 268, y: 300, r: 7 },
|
{ x: 236, y: 300, r: 7 },
|
||||||
{ x: 160, y: 300, r: 7 },
|
{ x: 140, y: 296, r: 7 },
|
||||||
],
|
],
|
||||||
flippers: {
|
flippers: {
|
||||||
L: { pivot: [86, 504], rest: 0.3 }, // radians, y-down; tip points down-right
|
L: { pivot: [96, 500], rest: 0.32 }, // radians, y-down; tip points down-right
|
||||||
R: { pivot: [234, 504], rest: Math.PI - 0.3 },
|
R: { pivot: [184, 500], rest: Math.PI - 0.32 },
|
||||||
},
|
},
|
||||||
laneX: 297, // ball rest x in the shooter lane
|
laneX: 286, // ball rest x in the shooter lane
|
||||||
laneRestY: 466,
|
laneRestY: 500,
|
||||||
drainY: 552,
|
drainY: 546,
|
||||||
returnMinX: 246, // a ball past drainY here came down the right side → back to the plunger, no life lost
|
returnMinX: 999, // no return chute — every side feeds a flipper
|
||||||
};
|
};
|
||||||
|
|
||||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||||
@ -224,31 +278,45 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
b.x = TABLE.laneX;
|
b.x = TABLE.laneX;
|
||||||
b.vx = 0;
|
b.vx = 0;
|
||||||
}
|
}
|
||||||
// Gate kick: partway up the lane, fling the ball left into the
|
// Hood delivery: the shooter lane and the playfield's top-right corner
|
||||||
// playfield (a straight-up launch has nowhere else to go). Fired well
|
// share a cramped space where wall bounces reliably ping a launched
|
||||||
// below the divider top so even a soft launch clears it; the kick
|
// ball straight back down the tube. So once the ball nears the top of
|
||||||
// scales with the charge so a full plunge rips across the table.
|
// the lane we fly it into the playfield on a scripted arc — collisions
|
||||||
if (b.launching && b.y < 300) {
|
// off — until it's clearly among the bumpers. The charge sets how hard
|
||||||
b.vx = -(170 + 220 * state.launchC);
|
// it cuts across.
|
||||||
|
if (b.launching && b.y < 104) {
|
||||||
|
b.vx = -(300 + 250 * state.launchC);
|
||||||
|
b.vy = 40;
|
||||||
b.launching = false;
|
b.launching = false;
|
||||||
}
|
}
|
||||||
|
// End the scripted glide once the kick has fired (b.launching cleared)
|
||||||
|
// and the ball is well left of the shooter lane — or, as a safety, has
|
||||||
|
// dropped past the bumpers.
|
||||||
|
if (b.hooding && !b.launching && (b.x < 212 || b.y > 236)) b.hooding = false;
|
||||||
|
|
||||||
b.vy += PHYS.gravity * dt;
|
b.vy += PHYS.gravity * dt;
|
||||||
b.x += b.vx * dt;
|
b.x += b.vx * dt;
|
||||||
b.y += b.vy * dt;
|
b.y += b.vy * dt;
|
||||||
|
|
||||||
for (const w of TABLE.walls) {
|
if (!b.hooding)
|
||||||
if (collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0) && w.k) {
|
for (const w of TABLE.walls) {
|
||||||
// slingshot face: shove the ball away from the segment + score
|
if (collideSeg(b, w.a[0], w.a[1], w.b[0], w.b[1], w.e ?? PHYS.wallE, 0) && w.k) {
|
||||||
const mx = (w.a[0] + w.b[0]) / 2;
|
// slingshot face: shove the ball away from the segment + score
|
||||||
const my = (w.a[1] + w.b[1]) / 2;
|
const mx = (w.a[0] + w.b[0]) / 2;
|
||||||
const nx = b.x - mx;
|
const my = (w.a[1] + w.b[1]) / 2;
|
||||||
const ny = b.y - my;
|
const nx = b.x - mx;
|
||||||
const nl = Math.hypot(nx, ny) || 1;
|
const ny = b.y - my;
|
||||||
b.vx += (nx / nl) * w.k;
|
const nl = Math.hypot(nx, ny) || 1;
|
||||||
b.vy += (ny / nl) * w.k;
|
b.vx += (nx / nl) * w.k;
|
||||||
addScore(50);
|
b.vy += (ny / nl) * w.k;
|
||||||
|
addScore(50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (b.hooding) {
|
||||||
|
// still gliding out of the hood — skip the rest of the collisions
|
||||||
|
b.vx *= PHYS.drag;
|
||||||
|
b.vy *= PHYS.drag;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
for (const p of TABLE.posts) {
|
for (const p of TABLE.posts) {
|
||||||
const dx = b.x - p.x;
|
const dx = b.x - p.x;
|
||||||
@ -325,27 +393,27 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
}
|
}
|
||||||
b.spin += b.vx * dt * PHYS.spin; // Poké Ball rolls with its horizontal motion
|
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 22 units in 1.1s (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
|
||||||
// a real table. Three strikes and it drains.
|
// a real table. Four strikes and it drains.
|
||||||
b.trackT = (b.trackT || 0) + dt;
|
b.trackT = (b.trackT || 0) + dt;
|
||||||
if (b.trackX == null) {
|
if (b.trackX == null) {
|
||||||
b.trackX = b.x;
|
b.trackX = b.x;
|
||||||
b.trackY = b.y;
|
b.trackY = b.y;
|
||||||
}
|
}
|
||||||
if (b.trackT > 1.4) {
|
if (b.trackT > 1.1) {
|
||||||
const moved = Math.hypot(b.x - b.trackX, b.y - b.trackY);
|
const moved = Math.hypot(b.x - b.trackX, b.y - b.trackY);
|
||||||
if (!b.inLane && moved < 24) {
|
if (!b.inLane && !b.hooding && moved < 22) {
|
||||||
if ((b.searches || 0) >= 2) {
|
if ((b.searches || 0) >= 3) {
|
||||||
loseBall();
|
loseBall();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
b.searches = (b.searches || 0) + 1;
|
b.searches = (b.searches || 0) + 1;
|
||||||
const kx = W / 2 - b.x;
|
const kx = CX - b.x;
|
||||||
const ky = 210 - b.y;
|
const ky = 200 - b.y;
|
||||||
const kl = Math.hypot(kx, ky) || 1;
|
const kl = Math.hypot(kx, ky) || 1;
|
||||||
b.vx = (kx / kl) * 360;
|
b.vx = (kx / kl) * 430;
|
||||||
b.vy = (ky / kl) * 360;
|
b.vy = (ky / kl) * 430;
|
||||||
} else {
|
} else {
|
||||||
b.searches = 0;
|
b.searches = 0;
|
||||||
}
|
}
|
||||||
@ -377,8 +445,15 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
// --- render ---------------------------------------------------------
|
// --- render ---------------------------------------------------------
|
||||||
function resize() {
|
function resize() {
|
||||||
const host = canvas.parentElement;
|
const host = canvas.parentElement;
|
||||||
const cw = Math.max(200, Math.min(host ? host.clientWidth : 320, 340));
|
let cw = Math.max(200, Math.min(host ? host.clientWidth : 320, 340));
|
||||||
const ch = cw * (H / W);
|
let ch = cw * (H / W);
|
||||||
|
// Keep the whole table on screen without scrolling: cap the height to
|
||||||
|
// what's left of the viewport after the nav, HUD, and controls.
|
||||||
|
const availH = (window.innerHeight || 800) - 250;
|
||||||
|
if (ch > availH) {
|
||||||
|
ch = Math.max(340, availH);
|
||||||
|
cw = ch * (W / H);
|
||||||
|
}
|
||||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
canvas.style.width = `${cw}px`;
|
canvas.style.width = `${cw}px`;
|
||||||
canvas.style.height = `${ch}px`;
|
canvas.style.height = `${ch}px`;
|
||||||
@ -388,13 +463,6 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
function line(ax, ay, bx, by) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(ax, ay);
|
|
||||||
ctx.lineTo(bx, by);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A Poké Ball centred at (cx,cy), radius r, rotated by ang. */
|
/** A Poké Ball centred at (cx,cy), radius r, rotated by ang. */
|
||||||
function pokeball(cx, cy, r, ang, topColor) {
|
function pokeball(cx, cy, r, ang, topColor) {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
@ -431,165 +499,293 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a';
|
const h = /^#([0-9a-f]{6})$/i.test(accent) ? accent : '#b3161a';
|
||||||
return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
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})`;
|
const accentA = (a) => `rgba(${accentRGB[0]},${accentRGB[1]},${accentRGB[2]},${a})`;
|
||||||
|
|
||||||
|
// Flat, bright "Pokémon stadium" palette — solid fills, crisp dark
|
||||||
|
// outlines, no glow. A battle-arena floor with a Poké Ball painted on
|
||||||
|
// it, ringed by stadium seating.
|
||||||
|
const C = {
|
||||||
|
arena: '#d8b878', // arena floor (sandy battle ground)
|
||||||
|
arenaAlt: '#cdaa66', // inner ring of the floor
|
||||||
|
apron: '#2b3040', // outside the arena bowl
|
||||||
|
seatA: '#454c63', // stadium seating bands
|
||||||
|
seatB: '#3a4056',
|
||||||
|
court: 'rgba(255,255,255,0.34)', // Poké Ball court art
|
||||||
|
courtRed: 'rgba(196,32,32,0.16)',
|
||||||
|
barrier: '#eef0f2', // stadium barrier fill
|
||||||
|
edge: '#1c2230', // universal dark outline
|
||||||
|
rubber: '#e5473a', // slingshots + rubber deflectors
|
||||||
|
rubberTop: '#ff6a5c',
|
||||||
|
flip: '#f7c948', // flippers (Pikachu yellow)
|
||||||
|
flipTop: '#ffe08a',
|
||||||
|
post: '#3d6bd6', // mid-field pegs (blue)
|
||||||
|
postTop: '#6f97ee',
|
||||||
|
metal: '#b8bdc7', // plunger spring
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Stroke the current path: dark outline, then a coloured core on top. */
|
||||||
|
function stroke2(core, coreW, edgeW = coreW + 3) {
|
||||||
|
ctx.lineWidth = edgeW;
|
||||||
|
ctx.strokeStyle = C.edge;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.lineWidth = coreW;
|
||||||
|
ctx.strokeStyle = core;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
/** Filled circle with a dark outline and a lighter top cap (a "puck"). */
|
||||||
|
function puck(x, y, r, fill, top) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fill();
|
||||||
|
if (top) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y - r * 0.32, r * 0.82, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = top;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = C.edge;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
const tri = (pts) => {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||||
|
ctx.lineTo(pts[1][0], pts[1][1]);
|
||||||
|
ctx.lineTo(pts[2][0], pts[2][1]);
|
||||||
|
ctx.closePath();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Trace a polyline as a smooth curve (quadratics through midpoints). */
|
||||||
|
function tracePath(pts, close) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(pts[0][0], pts[0][1]);
|
||||||
|
for (let i = 1; i < pts.length - 1; i++) {
|
||||||
|
const mx = (pts[i][0] + pts[i + 1][0]) / 2;
|
||||||
|
const my = (pts[i][1] + pts[i + 1][1]) / 2;
|
||||||
|
ctx.quadraticCurveTo(pts[i][0], pts[i][1], mx, my);
|
||||||
|
}
|
||||||
|
const last = pts[pts.length - 1];
|
||||||
|
ctx.lineTo(last[0], last[1]);
|
||||||
|
if (close) ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tapered flipper bat: wide at the pivot, rounded at the tip. */
|
||||||
|
function bat(ax, ay, bx, by, wr, wt, fill) {
|
||||||
|
const ang = Math.atan2(by - ay, bx - ax);
|
||||||
|
const px = -Math.sin(ang);
|
||||||
|
const py = Math.cos(ang);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(ax + px * wr, ay + py * wr);
|
||||||
|
ctx.lineTo(bx + px * wt, by + py * wt);
|
||||||
|
ctx.lineTo(bx - px * wt, by - py * wt);
|
||||||
|
ctx.lineTo(ax - px * wr, ay - py * wr);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = fill;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(ax, ay, wr, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(bx, by, wt, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pop bumper sitting on the table — skirt, cap, gloss. */
|
||||||
|
function bumper(x, y, r, kind, lit) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r + 5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = C.edge;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r + 5, 0, Math.PI * 2);
|
||||||
|
ctx.lineWidth = 2.5;
|
||||||
|
ctx.strokeStyle = lit ? '#ffd23f' : 'rgba(255,255,255,0.45)';
|
||||||
|
ctx.stroke();
|
||||||
|
pokeball(x, y, r, 0, lit ? '#ffd23f' : kind === 'great' ? '#3d7fd6' : accent);
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(x - r * 0.3, y - r * 0.42, r * 0.5, 0, Math.PI * 2);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||||
|
ctx.fill();
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
|
||||||
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, 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);
|
|
||||||
|
|
||||||
// big Poké Ball watermark on the playfield
|
|
||||||
ctx.save();
|
|
||||||
ctx.globalAlpha = 0.05;
|
|
||||||
ctx.strokeStyle = '#fff';
|
|
||||||
ctx.lineWidth = 3;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(W / 2, 262, 118, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(W / 2 - 118, 262);
|
|
||||||
ctx.lineTo(W / 2 + 118, 262);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(W / 2, 262, 34, 0, Math.PI * 2);
|
|
||||||
ctx.stroke();
|
|
||||||
ctx.restore();
|
|
||||||
|
|
||||||
ctx.lineCap = 'round';
|
ctx.lineCap = 'round';
|
||||||
ctx.lineJoin = 'round';
|
ctx.lineJoin = 'round';
|
||||||
|
|
||||||
// slingshot triangles (filled) — draw before the wall outline
|
// outside the bowl
|
||||||
const tri = (pts) => {
|
ctx.fillStyle = C.apron;
|
||||||
ctx.beginPath();
|
ctx.fillRect(0, 0, W, H);
|
||||||
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([
|
|
||||||
[96, 434],
|
|
||||||
[110, 476],
|
|
||||||
[76, 476],
|
|
||||||
]);
|
|
||||||
ctx.fill();
|
|
||||||
tri([
|
|
||||||
[230, 434],
|
|
||||||
[214, 476],
|
|
||||||
[250, 476],
|
|
||||||
]);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// wall outline
|
// stadium seating — a crowd strip across the top
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.7)';
|
for (let row = 0; row < 3; row++) {
|
||||||
ctx.lineWidth = 3;
|
const yy = 14 + row * 15;
|
||||||
for (const w of TABLE.walls) {
|
ctx.fillStyle = row % 2 ? C.seatA : C.seatB;
|
||||||
ctx.strokeStyle = w.k ? accent : 'rgba(255,255,255,0.7)';
|
ctx.fillRect(0, yy - 6, W, 13);
|
||||||
ctx.lineWidth = w.k ? 4 : 3;
|
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
||||||
line(w.a[0], w.a[1], w.b[0], w.b[1]);
|
for (let xx = 5; xx < W; xx += 9) ctx.fillRect(xx, yy - 5, 3, 11);
|
||||||
}
|
}
|
||||||
|
|
||||||
// posts
|
// arena floor — the rounded table outline, filling the stage
|
||||||
for (const p of TABLE.posts) {
|
tracePath(TABLE.outline, true);
|
||||||
ctx.beginPath();
|
ctx.fillStyle = C.arena;
|
||||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
ctx.fill();
|
||||||
ctx.fillStyle = '#5a5f70';
|
stroke2(C.edge, 2, 2);
|
||||||
|
|
||||||
|
// Poké Ball painted on the floor, centred on the usable playfield
|
||||||
|
ctx.save();
|
||||||
|
tracePath(TABLE.outline, true);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX, 262, 128, Math.PI, 0);
|
||||||
|
ctx.fillStyle = C.courtRed;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.strokeStyle = C.court;
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX, 262, 128, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(CX - 128, 262);
|
||||||
|
ctx.lineTo(CX + 128, 262);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(CX, 262, 34, 0, Math.PI * 2);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// slingshot wedges — solid rubber, lighter top face
|
||||||
|
for (const pts of [
|
||||||
|
[
|
||||||
|
[104, 424],
|
||||||
|
[118, 462],
|
||||||
|
[96, 462],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[176, 424],
|
||||||
|
[162, 462],
|
||||||
|
[190, 462],
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
tri(pts);
|
||||||
|
ctx.fillStyle = C.rubber;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeStyle = accent;
|
ctx.strokeStyle = C.edge;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
tri([pts[0], pts[1], [(pts[1][0] + pts[2][0]) / 2, (pts[1][1] + pts[2][1]) / 2]]);
|
||||||
|
ctx.fillStyle = C.rubberTop;
|
||||||
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// bumpers — Poké Balls; a lit one "opens" (white) with a halo
|
// lane + guide walls — straight white barriers
|
||||||
|
for (const w of TABLE.walls) {
|
||||||
|
if (w.role !== 'lane' && w.role !== 'guide') continue;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(w.a[0], w.a[1]);
|
||||||
|
ctx.lineTo(w.b[0], w.b[1]);
|
||||||
|
stroke2(C.barrier, 3, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the play boundary — one smooth white barrier
|
||||||
|
tracePath(TABLE.bound, false);
|
||||||
|
stroke2(C.barrier, 5, 9);
|
||||||
|
|
||||||
|
// mid-field pegs
|
||||||
|
for (const p of TABLE.posts) puck(p.x, p.y, p.r, C.post, C.postTop);
|
||||||
|
|
||||||
|
// bumpers
|
||||||
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];
|
bumper(bp.x, bp.y, bp.r, bp.kind, state.lit[i]);
|
||||||
if (lit) {
|
}
|
||||||
ctx.save();
|
|
||||||
ctx.shadowColor = accent;
|
// flippers — tapered yellow bats
|
||||||
ctx.shadowBlur = 24;
|
for (const which of ['L', 'R']) {
|
||||||
}
|
const s = flipperSeg(which);
|
||||||
pokeball(bp.x, bp.y, bp.r, 0, lit ? '#ffffff' : accent);
|
bat(s.ax, s.ay, s.bx, s.by, 9.5, 4.5, C.edge);
|
||||||
if (lit) ctx.restore();
|
bat(s.ax, s.ay, s.bx, s.by, 7.5, 3, C.flip);
|
||||||
|
bat(s.ax, s.ay, s.ax + (s.bx - s.ax) * 0.6, s.ay + (s.by - s.ay) * 0.6, 3.5, 2, C.flipTop);
|
||||||
|
}
|
||||||
|
|
||||||
|
// plunger — a coil spring below the shooter lane, compresses as it charges
|
||||||
|
{
|
||||||
|
const lx = TABLE.laneX;
|
||||||
|
const restY = 516;
|
||||||
|
const botY = 542;
|
||||||
|
const topY = restY + state.charge * (botY - restY - 6);
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(bp.x, bp.y, bp.r + 2, 0, Math.PI * 2);
|
ctx.moveTo(lx, botY);
|
||||||
|
const coils = 4;
|
||||||
|
for (let k = 0; k <= coils; k++) {
|
||||||
|
const y = botY - (k / coils) * (botY - topY);
|
||||||
|
ctx.lineTo(k % 2 ? lx + 9 : lx - 9, y);
|
||||||
|
}
|
||||||
|
ctx.lineTo(lx, topY);
|
||||||
|
ctx.strokeStyle = C.edge;
|
||||||
|
ctx.lineWidth = 4;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.strokeStyle = C.metal;
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.strokeStyle = lit ? '#fff' : accentA(0.5);
|
ctx.stroke();
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) ctx.roundRect(lx - 10, topY - 8, 20, 9, 3);
|
||||||
|
else ctx.rect(lx - 10, topY - 8, 20, 9);
|
||||||
|
ctx.fillStyle = C.metal;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.strokeStyle = C.edge;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
// flippers
|
// ball — a spinning Poké Ball
|
||||||
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 — sits below the shooter-lane floor and is pulled DOWN to charge
|
|
||||||
{
|
|
||||||
const top = 480 + state.charge * 22; // more charge → further down
|
|
||||||
ctx.strokeStyle = 'rgba(255,255,255,0.28)';
|
|
||||||
ctx.lineWidth = 4;
|
|
||||||
line(297, 474, 297, top); // shaft
|
|
||||||
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
|
||||||
ctx.beginPath();
|
|
||||||
if (ctx.roundRect) ctx.roundRect(288, top, 18, 12, 4);
|
|
||||||
else ctx.rect(288, top, 18, 12);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ball — a spinning Poké Ball with a drop shadow
|
|
||||||
const b = state.ball;
|
const b = state.ball;
|
||||||
if (b) {
|
if (b) {
|
||||||
const r = PHYS.ballR;
|
const r = PHYS.ballR;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(b.x, b.y + 2.5, r, 0, Math.PI * 2);
|
ctx.arc(b.x + 1.5, b.y + 2, r, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.28)';
|
ctx.fillStyle = 'rgba(0,0,0,0.22)';
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
pokeball(b.x, b.y, r, b.spin, accent);
|
pokeball(b.x, b.y, r, b.spin, accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// catch — the table is frozen; show a scoreboard card over a dim wash
|
||||||
if (state.catchT > 0) {
|
if (state.catchT > 0) {
|
||||||
const name = (state.catchMon?.name || 'a Pokémon').toUpperCase();
|
const name = (state.catchMon?.name || 'a Pokémon').toUpperCase();
|
||||||
ctx.globalAlpha = Math.min(1, state.catchT / 1.4);
|
ctx.fillStyle = 'rgba(10,12,18,0.55)';
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
ctx.fillRect(0, 0, W, H);
|
||||||
ctx.fillRect(0, H / 2 - 60, W, 88);
|
const cy = 292;
|
||||||
ctx.fillStyle = '#fff';
|
const cw = 244;
|
||||||
ctx.font = 'bold 20px system-ui, sans-serif';
|
const ch = 92;
|
||||||
|
ctx.beginPath();
|
||||||
|
if (ctx.roundRect) ctx.roundRect(W / 2 - cw / 2, cy - ch / 2, cw, ch, 14);
|
||||||
|
else ctx.rect(W / 2 - cw / 2, cy - ch / 2, cw, ch);
|
||||||
|
ctx.fillStyle = C.edge;
|
||||||
|
ctx.fill();
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.strokeStyle = '#ffd23f';
|
||||||
|
ctx.stroke();
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
ctx.fillText('Gotcha!', W / 2, H / 2 - 30);
|
ctx.fillStyle = '#fff';
|
||||||
ctx.font = 'bold 17px system-ui, sans-serif';
|
ctx.font = 'bold 21px system-ui, sans-serif';
|
||||||
ctx.fillStyle = accent;
|
ctx.fillText('Gotcha!', W / 2, cy - 6);
|
||||||
ctx.fillText(`${name} was caught!`, W / 2, H / 2 - 6);
|
ctx.fillStyle = '#ffd23f';
|
||||||
ctx.globalAlpha = 1;
|
ctx.font = 'bold 15px system-ui, sans-serif';
|
||||||
|
ctx.fillText(`${name} was caught!`, W / 2, cy + 18);
|
||||||
ctx.textAlign = 'left';
|
ctx.textAlign = 'left';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -598,7 +794,13 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
// drive the physics deterministically without requestAnimationFrame.
|
// drive the physics deterministically without requestAnimationFrame.
|
||||||
function frame(fdt, now) {
|
function frame(fdt, now) {
|
||||||
fdt = Math.min(fdt || 0, 1 / 30);
|
fdt = Math.min(fdt || 0, 1 / 30);
|
||||||
if (state.catchT > 0) state.catchT = Math.max(0, state.catchT - fdt);
|
// A catch freezes the table — the ball hangs where it was while the
|
||||||
|
// celebration plays out, then the game resumes.
|
||||||
|
if (state.catchT > 0) {
|
||||||
|
state.catchT = Math.max(0, state.catchT - fdt);
|
||||||
|
state.charge = state.chargeStart ? Math.min(1, (now - state.chargeStart) / PULL_MS) : 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
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;
|
||||||
@ -651,13 +853,14 @@ export function createPinball(canvas, cbs = {}) {
|
|||||||
} else if (!down && state.plunge && state.chargeStart && b?.inLane) {
|
} 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, held / (PULL_MS / 1000)); // 0 = tap, 1 = full hold
|
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.
|
// Straight up the lane fast enough to reach the hood; step() then
|
||||||
// Both the launch speed and that kick scale with the charge, so a
|
// flies it into the playfield (see "Hood delivery"). Every pull
|
||||||
// full plunge rips the ball across the top and a tap just lobs it in.
|
// launches; a hard pull carries it to the far bumpers.
|
||||||
b.vy = -(760 + 320 * c);
|
b.vy = -(940 + 180 * 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;
|
||||||
|
b.hooding = true;
|
||||||
state.launchC = c;
|
state.launchC = c;
|
||||||
state.chargeStart = 0;
|
state.chargeStart = 0;
|
||||||
state.charge = 0;
|
state.charge = 0;
|
||||||
|
|||||||
@ -3888,12 +3888,18 @@
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-touch-callout: none;
|
-webkit-touch-callout: none;
|
||||||
}
|
}
|
||||||
|
.pin .view__header {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
.pin .view__header h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
.pin__hud {
|
.pin__hud {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 10px 16px;
|
gap: 6px 16px;
|
||||||
margin: 2px 0 12px;
|
margin: 2px 0 8px;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
@ -3912,20 +3918,19 @@
|
|||||||
}
|
}
|
||||||
.pin__stage {
|
.pin__stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
padding: 8px;
|
padding: 6px;
|
||||||
border-radius: calc(var(--radius-lg) + 8px);
|
border-radius: calc(var(--radius-lg) + 6px);
|
||||||
background: linear-gradient(160deg, #2a2c36, #131419);
|
background: #1c2230;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.06),
|
inset 0 0 0 1px rgba(255, 255, 255, 0.06),
|
||||||
0 10px 30px -12px rgba(0, 0, 0, 0.6);
|
0 10px 30px -14px rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
.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);
|
||||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
background: #101116;
|
background: #2c6a34;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@ -3952,7 +3957,7 @@
|
|||||||
.pin__controls {
|
.pin__controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 12px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.pin__launch {
|
.pin__launch {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -3971,8 +3976,8 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.pin__hint {
|
.pin__hint {
|
||||||
margin-top: 10px;
|
margin-top: 7px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.72rem;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -4025,8 +4030,8 @@
|
|||||||
|
|
||||||
/* "caught this run" strip */
|
/* "caught this run" strip */
|
||||||
.pin__caught {
|
.pin__caught {
|
||||||
min-height: 38px;
|
min-height: 34px;
|
||||||
margin-top: 10px;
|
margin-top: 7px;
|
||||||
}
|
}
|
||||||
.pin__caught-row {
|
.pin__caught-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user