// 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; }