initial Game

This commit is contained in:
christopher
2026-09-12 01:33:07 -04:00
commit a740d2f141
15 changed files with 4275 additions and 0 deletions
+377
View File
@@ -0,0 +1,377 @@
// Table layout at real-world scale, in millimetres (+y points down the table).
//
// Reference dimensions (see README for sources):
// playfield 20.25" x 42" -> 514 x 1067 mm
// ball 1-1/16" -> 27 mm diameter
// flippers: pivots 7" apart (centre to centre), 3.25" overall with rubber,
// rest 31 deg below horizontal, 20 deg above when raised (Visual Pinball
// defaults of 121 / 70 deg measured clockwise from 12 o'clock).
import { Segment, Circle, Flipper, Track, makeMaterial } from './physics.js';
export const WIDTH = 514;
export const HEIGHT = 1067;
export const BALL_RADIUS = 13.5;
/** Once the ball's centre passes this line it has gone under the apron: drained. */
export const DRAIN_Y = 1000;
const DEG = Math.PI / 180;
export const MATERIALS = {
wall: makeMaterial(0.45, 0.25, 0.04),
rubber: makeMaterial(0.8, 0.3, 0.08),
flipper: makeMaterial(0.8, 0.43, 0.05),
target: makeMaterial(0.35, 0.2, 0.04),
plunger: makeMaterial(0.1, 0, 0),
};
// Side walls and shooter lane.
export const LEFT = 12;
export const RIGHT = 464; // playfield face of the shooter-lane divider
export const CX = (LEFT + RIGHT) / 2; // flipper centreline
export const LANE_LEFT = 470;
export const LANE_RIGHT = 502;
export const SHOOTER_X = (LANE_LEFT + LANE_RIGHT) / 2;
export const PLUNGER_REST_Y = 1000; // top of the plunger tip at rest
export const PLUNGER_TRAVEL = 60; // how far the plunger can be pulled back
export const LANE_TOP = 292; // top of the shooter-lane divider
export const ARCH = { x: 257, y: 257, r: 245 };
// Flippers.
export const FLIPPER_Y = 930;
export const FLIPPER_SPACING = 177.8; // 7" between pivots
export const FLIPPER_GEOMETRY = {
length: 64, // pivot-centre to tip-centre: 64 + 11.5 + 7 = 82.5 mm = 3.25" overall
baseRadius: 11.5, // VPX default 21.5 VP units ~ 11.6 mm
tipRadius: 7, // VPX default 13 VP units ~ 7.0 mm
restAngle: 31 * DEG,
upAngle: -20 * DEG,
};
const GUIDE_RADIUS = 3; // half-thickness of the metal lane guides
const SLING_RADIUS = 5; // half-thickness of the slingshot rubber
const LANE_CLEAR = 32; // clear lane width for the 27 mm ball
function mirrorX(x) {
return 2 * CX - x;
}
function pushPolyline(walls, points, options) {
for (let i = 0; i < points.length - 1; i++) {
const [ax, ay] = points[i];
const [bx, by] = points[i + 1];
walls.push(new Segment(ax, ay, bx, by, options));
}
}
function arcPoints(cx, cy, r, fromDeg, toDeg, steps) {
const pts = [];
for (let i = 0; i <= steps; i++) {
const a = (fromDeg + ((toDeg - fromDeg) * i) / steps) * DEG;
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
}
return pts;
}
/** Lower playfield for one side: flipper, inlane/outlane guide and slingshot. side = -1 (left) or +1 (right). */
function buildLowerSide(side) {
const mx = side < 0 ? (x) => x : mirrorX; // everything is designed on the left and mirrored
const pivotX = CX - FLIPPER_SPACING / 2;
const g = FLIPPER_GEOMETRY;
const d = { x: Math.cos(g.restAngle), y: Math.sin(g.restAngle) }; // along the resting flipper
const n = { x: d.y, y: -d.x }; // upper normal of the resting flipper
// Inlane guide: meets the top of the flipper's base circle so the ball rolls straight on to the flipper.
const endX = pivotX + n.x * (g.baseRadius - GUIDE_RADIUS);
const endY = FLIPPER_Y + n.y * (g.baseRadius - GUIDE_RADIUS);
const dividerX = LEFT + LANE_CLEAR - 1 + GUIDE_RADIUS; // outlane | inlane divider
const t = (endX - dividerX) / d.x;
const bendY = endY - d.y * t;
const dividerTop = 700;
const guide = [
[dividerX, dividerTop],
[dividerX, bendY],
[endX, endY],
];
// Slingshot: left edge parallel to the divider, bottom edge parallel to the inlane guide.
const offset = GUIDE_RADIUS + LANE_CLEAR + SLING_RADIUS;
const slingX = dividerX + offset;
const lineX = dividerX + n.x * offset;
const lineY = bendY + n.y * offset;
const blY = lineY + ((slingX - lineX) * d.y) / d.x;
const brX = 152;
const brY = lineY + ((brX - lineX) * d.y) / d.x;
const top = [slingX, 760];
const bl = [slingX, blY];
const br = [brX, brY];
const flipper = new Flipper({
x: mx(pivotX),
y: FLIPPER_Y,
length: g.length,
baseRadius: g.baseRadius,
tipRadius: g.tipRadius,
restAngle: side < 0 ? g.restAngle : Math.PI - g.restAngle,
upAngle: side < 0 ? g.upAngle : Math.PI - g.upAngle,
upSpeed: 38,
downSpeed: 16,
material: MATERIALS.flipper,
});
flipper.side = side < 0 ? 'left' : 'right';
const m = (p) => [mx(p[0]), p[1]];
const sling = {
side: flipper.side,
// Keep vertices in a consistent winding (top, bottom corner, flipper-side corner).
top: m(top),
bottom: m(bl),
tip: m(br),
flash: 0,
};
const inlaneX = (dividerX + GUIDE_RADIUS + slingX - SLING_RADIUS) / 2;
const outlaneX = (LEFT + dividerX - GUIDE_RADIUS) / 2;
return {
flipper,
guide: guide.map(m),
dividerPost: { x: mx(dividerX), y: dividerTop },
sling,
inlane: { x: mx(inlaneX), y: 800 },
outlane: { x: mx(outlaneX), y: 790 },
};
}
// Ramp/wireform crossover: the ball is flipped hard up the outer corridor (between the side wall and
// the slingshot), climbs a plastic ramp over the pop bumpers, and a wire habitrail carries it back down
// to the OPPOSITE inlane — the classic crossover layout used on real tables so a good ramp shot feeds the
// other flipper for a continuous rhythm (Mission Pinball Framework's playfield-layout notes: ball guides
// into an orbit/ramp should return the ball toward a flipper).
const RAMP_HEIGHT = 46; // mm above the playfield at the crest — a plastic ramp's typical rise
export const RAMP_ENTRY_SPEED = 1250; // mm/s of upward speed needed to climb in, below that it rolls back out
/**
* One ramp for side = -1 (left) or +1 (right); left is fed by the left flipper, right by the right.
*
* The entrance position below was not guessed: a hard flip off a cradled ball was simulated across a
* wide sweep of hold times and the full trajectory logged, then searched for a spot that only a specific,
* contiguous band of hold times actually passes through fast and upward. Putting the mouth right at the
* flipper's own tip (the obvious first guess) caught nearly every flip regardless of timing, because
* that's the one point every hard shot passes through on the way out — there's no aiming to it. A point
* well downrange, past where a "snap" flip has already peeled off in a different direction, is only
* reached by holding the flipper for roughly a beat before releasing — a real, learnable technique,
* not a hidden auto-capture.
*/
function buildRamp(side) {
const mx = side < 0 ? (x) => x : mirrorX;
const g = FLIPPER_GEOMETRY;
const pivotX = CX - FLIPPER_SPACING / 2;
const upAngle = g.upAngle;
const tipUpX = pivotX + Math.cos(upAngle) * g.length;
const tipUpY = FLIPPER_Y + Math.sin(upAngle) * g.length;
const clear = g.tipRadius + 15;
const returnX = tipUpX + Math.cos(upAngle) * clear;
const returnY = tipUpY + Math.sin(upAngle) * clear;
// Control points designed on the left and mirrored; (x, y, z) in mm, z = height above the playfield.
// Entrance first — well past the flipper, where the timing-dependent shot lands — then up and over
// the pop bumpers, then back down to the natural post-flip point above the SAME flipper (the return,
// which needs no special aim: any ball rolling off the ramp lands right where the flipper already is).
const raw = [
[350, 742, 12], // entrance: reached only by holding the flip for roughly a beat, not a snap flip
[415, 540, 30],
[400, 380, RAMP_HEIGHT], // crest, arcing up over the pop bumpers, clear of the kicker bulge
[330, 260, RAMP_HEIGHT],
[190, 195, RAMP_HEIGHT - 2], // wireform: the highest, flattest part of the loop
[80, 250, 36],
[40, 400, 18],
[32, 580, 5],
[42, 740, 0],
[120, 850, 0],
[returnX, returnY, 0], // return, feeding back to the same flipper right where it naturally throws the ball
];
const points = raw.map(([x, y, z]) => [mx(x), y, Math.max(0, z)]);
const track = new Track(points, { name: `${side < 0 ? 'left' : 'right'}Ramp`, kind: 'ramp', width: 40, steps: 14 });
track.side = side < 0 ? 'left' : 'right';
track.color = side < 0 ? '#ff3fa4' : '#34e7ff';
const p0 = track.sample(0);
return {
track,
entrance: { x: p0.x, y: p0.y, dirx: 0, diry: -1, radius: 22, minSpeed: RAMP_ENTRY_SPEED },
};
}
/** Center lock/spinner scoop, sitting just below the pop bumper triangle where a straight shot up the
* middle reaches it directly — it holds balls to build a multiball, then jackpots run on the ramps
* once multiball starts. */
function buildLockScoop() {
return { x: CX + 12, y: 375, radius: 19, captureSpeed: 500, cooldown: 0 };
}
/** Build every static and interactive element of the table. */
export function createTable() {
const walls = [];
const posts = [];
// Outer boundary: left wall with a rubber "kicker" bulge, the top arch, and the shooter lane's outer wall.
const bottom = HEIGHT + 60;
const leftWall = [
[LEFT, bottom],
[LEFT, 650],
[70, 480],
[LEFT, 380],
[LEFT, ARCH.y],
];
const arch = arcPoints(ARCH.x, ARCH.y, ARCH.r, 180, 360, 72);
const outline = [...leftWall, ...arch.slice(1), [LANE_RIGHT, bottom]];
pushPolyline(walls, leftWall.slice(0, 2), { material: MATERIALS.wall });
pushPolyline(walls, leftWall.slice(1, 3), { material: MATERIALS.wall });
pushPolyline(walls, leftWall.slice(2, 4), { material: MATERIALS.rubber, tag: 'kicker' });
pushPolyline(walls, leftWall.slice(3), { material: MATERIALS.wall });
pushPolyline(walls, [leftWall[4], ...arch.slice(1), [LANE_RIGHT, bottom]], { material: MATERIALS.wall });
// Shooter-lane divider (with a mirrored kicker bulge on its playfield side).
const divider = [
[RIGHT, LANE_TOP],
[RIGHT, 380],
[mirrorX(70), 480],
[RIGHT, 650],
[RIGHT, bottom],
[LANE_LEFT, bottom],
[LANE_LEFT, LANE_TOP],
];
pushPolyline(walls, divider.slice(0, 2), { material: MATERIALS.wall });
pushPolyline(walls, divider.slice(1, 3), { material: MATERIALS.rubber, tag: 'kicker' });
pushPolyline(walls, divider.slice(2, 5), { material: MATERIALS.wall });
pushPolyline(walls, divider.slice(5), { material: MATERIALS.wall });
posts.push(new Circle((RIGHT + LANE_LEFT) / 2, LANE_TOP, (LANE_LEFT - RIGHT) / 2, { material: MATERIALS.wall }));
posts.push(new Circle(70, 480, 6, { material: MATERIALS.rubber, tag: 'kickerPost' }));
posts.push(new Circle(mirrorX(70), 480, 6, { material: MATERIALS.rubber, tag: 'kickerPost' }));
// One-way gate at the top of the shooter lane: the ball can leave the lane but not fall back in.
const gate = new Segment(RIGHT + 3, LANE_TOP, LANE_RIGHT, ARCH.y, {
radius: 1.5,
material: MATERIALS.wall,
oneWay: true,
tag: 'gate',
});
gate.swing = 0; // visual only
walls.push(gate);
// Top rollover lanes.
const laneGuideXs = [178, 226, 274, 322];
const laneTop = 85;
const laneBottom = 140;
for (const x of laneGuideXs) {
walls.push(new Segment(x, laneTop, x, laneBottom, { radius: GUIDE_RADIUS, material: MATERIALS.wall }));
posts.push(new Circle(x, laneTop, 5, { material: MATERIALS.rubber }));
}
const rollovers = [];
for (let i = 0; i < 3; i++) {
const x = (laneGuideXs[i] + laneGuideXs[i + 1]) / 2;
rollovers.push({ kind: 'top', index: i, x, y: 118, radius: 14, inside: false, flash: 0 });
}
// Pop bumpers.
const bumpers = [
{ x: 195, y: 215 },
{ x: 305, y: 215 },
{ x: 250, y: 300 },
].map((b, i) => ({ ...b, index: i, radius: 24, flash: 0, cooldown: 0 }));
// Lower playfield, both sides.
const lowers = [buildLowerSide(-1), buildLowerSide(1)];
const flippers = lowers.map((l) => l.flipper);
const guides = lowers.map((l) => l.guide);
const slings = lowers.map((l) => l.sling);
for (const l of lowers) {
pushPolyline(walls, l.guide, { radius: GUIDE_RADIUS, material: MATERIALS.wall });
posts.push(new Circle(l.dividerPost.x, l.dividerPost.y, 6, { material: MATERIALS.rubber }));
rollovers.push({ kind: 'inlane', side: l.flipper.side, x: l.inlane.x, y: l.inlane.y, radius: 14, inside: false, flash: 0 });
rollovers.push({ kind: 'outlane', side: l.flipper.side, x: l.outlane.x, y: l.outlane.y, radius: 14, inside: false, flash: 0 });
}
for (const s of slings) {
const opts = { radius: SLING_RADIUS, material: MATERIALS.rubber };
s.segments = [
new Segment(...s.top, ...s.bottom, opts),
new Segment(...s.bottom, ...s.tip, opts),
new Segment(...s.tip, ...s.top, { ...opts, tag: 'sling' }),
];
s.face = s.segments[2];
// Outward normal of the kicking face (points towards the middle of the table).
const fx = s.tip[0] - s.top[0];
const fy = s.tip[1] - s.top[1];
const len = Math.hypot(fx, fy);
const nx = -fy / len;
const ny = fx / len;
const inward = (CX - s.top[0]) * nx > 0 ? 1 : -1;
s.nx = nx * inward;
s.ny = ny * inward;
}
// Flanking the pop bumpers on each side: a bank of three drop targets on the left, three stand-up
// targets on the right, both well clear of the ramps that arc above them.
const dropTargets = bankOnFace([25, 200], [75, 340], 'drop');
const standups = bankOnFace([mirrorX(25), 200], [mirrorX(75), 340], 'standup');
// Plunger tip (moves with the plunger).
const plunger = new Segment(LANE_LEFT, PLUNGER_REST_Y, LANE_RIGHT, PLUNGER_REST_Y, { material: MATERIALS.plunger, tag: 'plunger' });
// Ramps (with wireform returns) and the center lock scoop.
const ramps = [buildRamp(-1), buildRamp(1)];
const lock = buildLockScoop();
return {
walls,
posts,
gate,
bumpers,
slings,
flippers,
guides,
rollovers,
dropTargets,
standups,
plunger,
ramps,
lock,
outline,
divider,
laneGuideXs,
laneTop,
laneBottom,
};
}
/** Three targets evenly spaced along the wall face a→b, standing slightly proud of it. */
function bankOnFace(a, b, kind) {
const fx = b[0] - a[0];
const fy = b[1] - a[1];
const len = Math.hypot(fx, fy);
const ux = fx / len;
const uy = fy / len;
// Normal pointing into the playfield (towards the flipper centreline).
let nx = -uy;
let ny = ux;
if ((CX - a[0]) * nx < 0) {
nx = -nx;
ny = -ny;
}
const targets = [];
const midX = (a[0] + b[0]) / 2;
const midY = (a[1] + b[1]) / 2;
const halfWidth = 14;
for (let i = -1; i <= 1; i++) {
const cx = midX + ux * i * 38 + nx * 6;
const cy = midY + uy * i * 38 + ny * 6;
const seg = new Segment(cx - ux * halfWidth, cy - uy * halfWidth, cx + ux * halfWidth, cy + uy * halfWidth, {
radius: 3,
material: MATERIALS.target,
tag: kind,
});
targets.push({ kind, index: i + 1, x: cx, y: cy, ux, uy, nx, ny, halfWidth, segment: seg, down: false, lit: false, drop: 0, flash: 0 });
}
return targets;
}