378 lines
15 KiB
JavaScript
378 lines
15 KiB
JavaScript
// 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;
|
|
}
|