The right flipper's arm was computed with the same +cos(angle) direction as the left one, so it pointed into the right wall, off the 480px canvas - only the left paddle was ever visible/playable. - add physics.flipper_tip() which mirrors the right flipper (and use it in both collision and rendering) - derive the flipper's surface velocity from actual tip displacement (scaled by contact radius), instead of a hand-signed angular formula - advance flipper angles inside the collision substeps (6/frame while pressed) so a fast sweep can't jump past a resting ball; resting balls now get a real 'scoop' from either paddle - verify: pixel probe shows both paddles on screen (L=1007, R=999 blue px); physics probe confirms each paddle launches a resting ball
475 lines
16 KiB
Python
475 lines
16 KiB
Python
"""Pinball physics: pure logic, no rendering. Testable headless.
|
|
|
|
Coordinate system: y grows downward. 480x360 virtual canvas.
|
|
"""
|
|
|
|
import math
|
|
import random
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playfield geometry
|
|
# ---------------------------------------------------------------------------
|
|
CV_W, CV_H = 480, 360
|
|
PLAY_L, PLAY_R, PLAY_T, PLAY_B = 20.0, 460.0, 52.0, 346.0
|
|
|
|
GRAVITY = 0.24 # px/frame^2
|
|
MAX_SPEED = 13.0 # speed cap (prevents tunneling; ~300px of rise)
|
|
RESTITUTION = 0.42
|
|
BALL_R = 7.0
|
|
|
|
FLIP_LEN = 170.0
|
|
FLIP_REST = math.radians(6.0) # tips slightly lowered toward center drain
|
|
FLIP_UP = -math.radians(52.0) # fired position
|
|
FLIP_PIVOT_L = (62.0, 338.0)
|
|
FLIP_PIVOT_R = (418.0, 338.0)
|
|
FLIP_RADIUS = 5.5
|
|
FLIP_SNAP_UP = 0.36 # rad/frame while firing
|
|
FLIP_SNAP_DOWN = 0.05 # gentle return speed
|
|
FLIP_BOUNCE = 0.12
|
|
|
|
# elements
|
|
BUMPERS = [
|
|
dict(pos=(150.0, 180.0), r=23.0, color="pink"),
|
|
dict(pos=(330.0, 180.0), r=23.0, color="mint"),
|
|
dict(pos=(240.0, 240.0), r=23.0, color="lemon"),
|
|
]
|
|
SLING_KICK_L = (0.60, -0.80)
|
|
SLING_KICK_R = (-0.60, -0.80)
|
|
SLINGS = [
|
|
# upper slingshots by the lower bumpers - kick toward table center
|
|
dict(pos=(150.0, 282.0), r=11.0, kick=SLING_KICK_L),
|
|
dict(pos=(330.0, 282.0), r=11.0, kick=SLING_KICK_R),
|
|
# guard posts at the outlane mouths - protect the corners
|
|
dict(pos=(44.0, 314.0), r=12.0, kick=(0.80, -0.60)),
|
|
dict(pos=(436.0, 314.0), r=12.0, kick=(-0.80, -0.60)),
|
|
]
|
|
STARS = [(168.0, 92.0), (240.0, 92.0), (312.0, 92.0)]
|
|
|
|
CANNON = (440.0, 152.0)
|
|
CANNON_R = 17.0
|
|
LAUNCH_DIR = (-0.94, -0.34)
|
|
LAUNCH_MIN, LAUNCH_MAX = 7.0, 13.5
|
|
|
|
DRAIN_Y = 362.0
|
|
CENTER_HOLE = (200.0, 280.0)
|
|
OUTLANE_L = 46.0
|
|
OUTLANE_R = 434.0
|
|
|
|
|
|
def clamp(v, lo, hi):
|
|
return lo if v < lo else hi if v > hi else v
|
|
|
|
|
|
def seg_info(px, py, ax, ay, bx, by):
|
|
"""Closest point on segment ab to p; returns (t, cx, cy, dist2)."""
|
|
abx, aby = bx - ax, by - ay
|
|
l2 = abx * abx + aby * aby
|
|
if l2 == 0.0:
|
|
return 0.0, ax, ay, (px - ax) ** 2 + (py - ay) ** 2
|
|
t = clamp(((px - ax) * abx + (py - ay) * aby) / l2, 0.0, 1.0)
|
|
cx, cy = ax + abx * t, ay + aby * t
|
|
dx, dy = px - cx, py - cy
|
|
return t, cx, cy, dx * dx + dy * dy
|
|
|
|
|
|
def flipper_tip(f):
|
|
"""World-space position of the flipper tip.
|
|
|
|
Angles are expressed in *local* coordinates: a positive angle lowers the
|
|
tip toward the centre drain (screen-down is +y). The right flipper is the
|
|
mirror image of the left, so its horizontal component is negated.
|
|
"""
|
|
px, py = f["pivot"]
|
|
a = f["angle"]
|
|
if f["side"] == "L":
|
|
return (px + math.cos(a) * f["length"], py + math.sin(a) * f["length"])
|
|
return (px - math.cos(a) * f["length"], py + math.sin(a) * f["length"])
|
|
|
|
|
|
class Ball:
|
|
__slots__ = ("x", "y", "vx", "vy", "rad", "prev_x", "prev_y")
|
|
|
|
def __init__(self, x, y):
|
|
self.x, self.y = float(x), float(y)
|
|
self.vx = self.vy = 0.0
|
|
self.rad = BALL_R
|
|
self.prev_x, self.prev_y = x, y
|
|
|
|
@property
|
|
def speed(self):
|
|
return math.hypot(self.vx, self.vy)
|
|
|
|
|
|
class Event:
|
|
__slots__ = ("kind", "x", "y", "data")
|
|
|
|
def __init__(self, kind, x=0.0, y=0.0, data=None):
|
|
self.kind, self.x, self.y, self.data = kind, x, y, data
|
|
|
|
def __repr__(self):
|
|
return "Event(%s)" % self.kind
|
|
|
|
|
|
class Table:
|
|
def __init__(self, seed=1):
|
|
self.rng = random.Random(seed)
|
|
self.events = []
|
|
self.frame = 0
|
|
|
|
# static walls / floors: list of (a, b, kind) where kind in
|
|
# ("rail", "floor") - rails keep balls in, floors hold them up
|
|
self.walls = [
|
|
((PLAY_L, PLAY_T), (PLAY_L, PLAY_B), "rail"),
|
|
((PLAY_R, PLAY_T), (PLAY_R, PLAY_B), "rail"),
|
|
((PLAY_L, PLAY_T), (PLAY_R, PLAY_T), "rail"),
|
|
# floors tilt gently down toward the center so balls funnel inward
|
|
((OUTLANE_L, PLAY_B - 8), (CENTER_HOLE[0], PLAY_B), "floor"),
|
|
((CENTER_HOLE[1], PLAY_B), (OUTLANE_R, PLAY_B - 8), "floor"),
|
|
((CENTER_HOLE[0], PLAY_B), (CENTER_HOLE[0], DRAIN_Y), "hole"),
|
|
((CENTER_HOLE[1], PLAY_B), (CENTER_HOLE[1], DRAIN_Y), "hole"),
|
|
((OUTLANE_L, PLAY_B), (OUTLANE_L, DRAIN_Y), "hole"),
|
|
((OUTLANE_R, PLAY_B), (OUTLANE_R, DRAIN_Y), "hole"),
|
|
]
|
|
|
|
self.bumpers = [
|
|
dict(x=b["pos"][0], y=b["pos"][1], r=b["r"], color=b["color"],
|
|
cool=0, hit=0, squash=0, face_t=0.0, blink=0.0)
|
|
for b in BUMPERS]
|
|
self.slings = [
|
|
dict(x=s["pos"][0], y=s["pos"][1], r=s["r"], cool=0, flash=0,
|
|
kick=s["kick"], guard=(i >= 2))
|
|
for i, s in enumerate(SLINGS)]
|
|
self.stars = [dict(x=x, y=y, r=13.0, lit=False, cool=0, pulse=0)
|
|
for x, y in STARS]
|
|
self.flippers = [
|
|
dict(side="L", pivot=FLIP_PIVOT_L, angle=FLIP_REST, rest=FLIP_REST,
|
|
up=FLIP_UP, length=FLIP_LEN, pressed=False, w=0.0, contacting=False),
|
|
dict(side="R", pivot=FLIP_PIVOT_R, angle=FLIP_REST, rest=FLIP_REST,
|
|
up=FLIP_UP, length=FLIP_LEN, pressed=False, w=0.0, contacting=False),
|
|
]
|
|
for f in self.flippers:
|
|
f["prev_tip"] = flipper_tip(f)
|
|
f["tipvx"] = f["tipvy"] = 0.0
|
|
self.cannon = dict(x=CANNON[0], y=CANNON[1], r=CANNON_R)
|
|
|
|
self.ball = Ball(CANNON[0], CANNON[1])
|
|
self.ball_live = False
|
|
self.charge = 0.0
|
|
self.stuck_frames = 0
|
|
self.trail = []
|
|
|
|
# ------------------------------------------------------------- input
|
|
def press(self, side, down):
|
|
f = self.flippers[0] if side == "L" else self.flippers[1]
|
|
f["pressed"] = down
|
|
|
|
# ------------------------------------------------------------- setup
|
|
def reset_ball_in_cannon(self):
|
|
self.charge = 0.0
|
|
self.ball_live = False
|
|
self.ball.x, self.ball.y = CANNON[0] + 6, CANNON[1]
|
|
self.ball.vx = self.ball.vy = 0.0
|
|
self.ball.prev_x, self.ball.prev_y = self.ball.x, self.ball.y
|
|
self.stuck_frames = 0
|
|
self.trail = []
|
|
|
|
def launch(self, power):
|
|
self.ball_live = True
|
|
mx, my = CANNON
|
|
off = CANNON_R + self.ball.rad + 2.0
|
|
self.ball.x = mx + LAUNCH_DIR[0] * off
|
|
self.ball.y = my + LAUNCH_DIR[1] * off
|
|
self.ball.vx = LAUNCH_DIR[0] * power
|
|
self.ball.vy = LAUNCH_DIR[1] * power
|
|
self.ball.prev_x, self.ball.prev_y = self.ball.x, self.ball.y
|
|
self.trail = [(self.ball.x, self.ball.y)]
|
|
return Event("launch", self.ball.x, self.ball.y)
|
|
|
|
# ------------------------------------------------------------- frame
|
|
def update(self):
|
|
self.events = []
|
|
self.frame += 1
|
|
|
|
# decay timers
|
|
for b in self.bumpers:
|
|
if b["cool"] > 0:
|
|
b["cool"] -= 1
|
|
if b["squash"] > 0:
|
|
b["squash"] -= 1
|
|
b["face_t"] += 1
|
|
if b["blink"] > 0:
|
|
b["blink"] -= 1
|
|
elif self.rng.random() < 0.0025:
|
|
b["blink"] = 16
|
|
for s in self.slings:
|
|
if s["cool"] > 0:
|
|
s["cool"] -= 1
|
|
if s["flash"] > 0:
|
|
s["flash"] -= 1
|
|
for st in self.stars:
|
|
if st["cool"] > 0:
|
|
st["cool"] -= 1
|
|
if st["pulse"] > 0:
|
|
st["pulse"] -= 1
|
|
|
|
if not self.ball_live:
|
|
return self.events
|
|
if self.ball.y > DRAIN_Y + 30 and not self._in_drain(self.ball.x, self.ball.y):
|
|
# lost below the table somehow - force a drain
|
|
self.events.append(Event("drain", self.ball.x, self.ball.y))
|
|
return self.events
|
|
|
|
ball = self.ball
|
|
|
|
# flippers move inside substeps; sweep 6 steps/frame while pressed so
|
|
# the paddle never jumps over a resting ball
|
|
flips_moving = any(f["pressed"] for f in self.flippers)
|
|
speed = ball.speed
|
|
n_sub = 6 if flips_moving else 1
|
|
if speed > 0.0:
|
|
n_sub = max(n_sub, min(6, int(math.ceil(speed / 4.0))))
|
|
for _ in range(n_sub):
|
|
self._substep(ball, 1.0 / n_sub, n_sub)
|
|
|
|
self.trail.append((ball.x, ball.y))
|
|
if len(self.trail) > 9:
|
|
self.trail.pop(0)
|
|
|
|
if ball.speed < 0.55:
|
|
self.stuck_frames += 1
|
|
if self.stuck_frames > 60 and self.stuck_frames % 14 == 0:
|
|
ang = self.rng.uniform(0.0, math.tau)
|
|
ball.vx += math.cos(ang) * 1.3
|
|
ball.vy += math.sin(ang) * 1.3 - 1.1
|
|
self.events.append(Event("nudge", ball.x, ball.y))
|
|
else:
|
|
self.stuck_frames = 0
|
|
|
|
if self._in_drain(ball.x, ball.y):
|
|
self.events.append(Event("drain", ball.x, ball.y))
|
|
return self.events
|
|
|
|
# ------------------------------------------------------------- helpers
|
|
def _in_drain(self, x, y):
|
|
if y < DRAIN_Y - 2:
|
|
return False
|
|
if CENTER_HOLE[0] - 6 < x < CENTER_HOLE[1] + 6:
|
|
return True
|
|
if x < OUTLANE_L - 4 or x > OUTLANE_R + 4:
|
|
return True
|
|
if y > DRAIN_Y + 8:
|
|
return x < OUTLANE_L + 10 or x > OUTLANE_R - 10
|
|
return False
|
|
|
|
# ------------------------------------------------------------- substep
|
|
def _substep(self, ball, dt, n_sub=1):
|
|
# flippers: kinematic angles advance inside the substep so a fast sweep
|
|
# stays continuous w.r.t. the ball (no tunneling past a resting ball).
|
|
for f in self.flippers:
|
|
target = f["up"] if f["pressed"] else f["rest"]
|
|
rate = (FLIP_SNAP_UP if f["pressed"] else FLIP_SNAP_DOWN) * dt
|
|
d = target - f["angle"]
|
|
f["angle"] = target if abs(d) < rate else f["angle"] + (rate if d > 0 else -rate)
|
|
tip = flipper_tip(f)
|
|
fx, fy = f["prev_tip"]
|
|
# displacement this substep, scaled back to px/frame
|
|
f["tipvx"] = (tip[0] - fx) * n_sub
|
|
f["tipvy"] = (tip[1] - fy) * n_sub
|
|
f["prev_tip"] = tip
|
|
f["contacting"] = False
|
|
|
|
ball.vy += GRAVITY * dt
|
|
ball.vx *= (1.0 - 0.014 * dt)
|
|
ball.vy *= (1.0 - 0.005 * dt)
|
|
ball.prev_x, ball.prev_y = ball.x, ball.y
|
|
ball.x += ball.vx * dt
|
|
ball.y += ball.vy * dt
|
|
|
|
# static walls / floors
|
|
for (ax, ay), (bx, by), kind in self.walls:
|
|
t, cx, cy, d2 = seg_info(ball.x, ball.y, ax, ay, bx, by)
|
|
if d2 >= ball.rad * ball.rad:
|
|
continue
|
|
if t <= 0.0 or t >= 1.0:
|
|
continue
|
|
dist = math.sqrt(d2)
|
|
nx = (ball.x - cx) / dist
|
|
ny = (ball.y - cy) / dist
|
|
# one-way-ish: never fling a ball that has dropped past a floor plane
|
|
if kind == "floor" and ball.y > PLAY_B + ball.rad * 0.5 and ny < -0.3:
|
|
continue
|
|
ball.x = cx + nx * ball.rad
|
|
ball.y = cy + ny * ball.rad
|
|
vn = ball.vx * nx + ball.vy * ny
|
|
if vn < 0.0:
|
|
e = RESTITUTION if kind == "floor" else 0.5
|
|
ball.vx -= (1.0 + e) * vn * nx
|
|
ball.vy -= (1.0 + e) * vn * ny
|
|
ball.vx *= 0.985
|
|
if kind == "rail" and ball.speed > 8.0:
|
|
self.events.append(Event("wall_hit", ball.x, ball.y, ball.speed))
|
|
|
|
# bumpers
|
|
for b in self.bumpers:
|
|
self._bumper_collide(ball, b)
|
|
|
|
# slingshots
|
|
for s in self.slings:
|
|
self._sling_collide(ball, s)
|
|
|
|
# flippers
|
|
for f in self.flippers:
|
|
self._flipper_collide(ball, f)
|
|
|
|
# cannon deflector
|
|
self._circle_push(ball, self.cannon["x"], self.cannon["y"],
|
|
self.cannon["r"], 0.5)
|
|
|
|
# star lanes
|
|
for st in self.stars:
|
|
dx = ball.x - st["x"]
|
|
dy = ball.y - st["y"]
|
|
rr = st["r"] + ball.rad
|
|
if dx * dx + dy * dy < rr * rr and st["cool"] <= 0:
|
|
st["cool"] = 26
|
|
st["pulse"] = 14
|
|
if not st["lit"]:
|
|
st["lit"] = True
|
|
self.events.append(Event("star_lit", st["x"], st["y"], st))
|
|
else:
|
|
self.events.append(Event("star_again", st["x"], st["y"], st))
|
|
|
|
self._cap(ball)
|
|
|
|
@staticmethod
|
|
def _cap(ball):
|
|
sp = ball.speed
|
|
if sp > MAX_SPEED:
|
|
k = MAX_SPEED / sp
|
|
ball.vx *= k
|
|
ball.vy *= k
|
|
|
|
def _circle_push(self, ball, cx, cy, cr, e, impulse=None, cool_key=None):
|
|
"""Generic circle collider. impulse=(ix,iy,strength) overrides bounce."""
|
|
dx = ball.x - cx
|
|
dy = ball.y - cy
|
|
rr = cr + ball.rad
|
|
d2 = dx * dx + dy * dy
|
|
if d2 >= rr * rr or d2 < 0.0001:
|
|
return False
|
|
d = math.sqrt(d2)
|
|
nx, ny = dx / d, dy / d
|
|
ball.x = cx + nx * rr
|
|
ball.y = cy + ny * rr
|
|
if impulse is not None:
|
|
kx, ky, sp = impulse
|
|
ball.vx = kx * sp
|
|
ball.vy = ky * sp
|
|
return True
|
|
vn = ball.vx * nx + ball.vy * ny
|
|
if vn < 0.0:
|
|
ball.vx -= (1.0 + e) * vn * nx
|
|
ball.vy -= (1.0 + e) * vn * ny
|
|
return True
|
|
|
|
def _bumper_collide(self, ball, b):
|
|
dx = ball.x - b["x"]
|
|
dy = ball.y - b["y"]
|
|
rr = b["r"] + ball.rad
|
|
d2 = dx * dx + dy * dy
|
|
if d2 >= rr * rr or d2 < 0.0001:
|
|
return
|
|
d = math.sqrt(d2)
|
|
nx, ny = dx / d, dy / d
|
|
ball.x = b["x"] + nx * rr
|
|
ball.y = b["y"] + ny * rr
|
|
if b["cool"] > 0:
|
|
return
|
|
b["cool"] = 6
|
|
b["hit"] += 1
|
|
b["squash"] = 5
|
|
b["face_t"] = 0.0
|
|
kick = max(6.0, ball.speed * 0.72 + 4.2)
|
|
ball.vx = nx * kick
|
|
ball.vy = ny * kick
|
|
self.events.append(Event("bumper", b["x"] + nx * b["r"], b["y"] + ny * b["r"], b))
|
|
|
|
def _sling_collide(self, ball, s):
|
|
dx = ball.x - s["x"]
|
|
dy = ball.y - s["y"]
|
|
rr = s["r"] + ball.rad
|
|
d2 = dx * dx + dy * dy
|
|
if d2 >= rr * rr or d2 < 0.0001:
|
|
return
|
|
d = math.sqrt(d2)
|
|
nx, ny = dx / d, dy / d
|
|
ball.x = s["x"] + nx * rr
|
|
ball.y = s["y"] + ny * rr
|
|
if s["cool"] > 0:
|
|
return
|
|
s["cool"] = 4
|
|
s["flash"] = 7
|
|
kx, ky = s["kick"]
|
|
ball.vx = kx * 9.6
|
|
ball.vy = ky * 9.6
|
|
self.events.append(Event("sling", s["x"] + kx * 22, s["y"] + ky * 22, s))
|
|
|
|
def _flipper_collide(self, ball, f):
|
|
px, py = f["pivot"]
|
|
tipx, tipy = flipper_tip(f)
|
|
t, cx, cy, d2 = seg_info(ball.x, ball.y, px, py, tipx, tipy)
|
|
rad = FLIP_RADIUS + ball.rad
|
|
if d2 >= rad * rad:
|
|
return
|
|
# endpoint (pivot) -> small circle collider; tip end -> still a segment
|
|
if t <= 0.0 or t >= 1.0:
|
|
ex, ey = (px, py) if t <= 0.0 else (tipx, tipy)
|
|
exx, eyy = ball.x - ex, ball.y - ey
|
|
ed = math.hypot(exx, eyy)
|
|
if ed < rad:
|
|
nx, ny = exx / ed, eyy / ed
|
|
ball.x = ex + nx * rad
|
|
ball.y = ey + ny * rad
|
|
self._flipper_impulse(ball, f, nx, ny)
|
|
return
|
|
dist = math.sqrt(d2)
|
|
nx = (ball.x - cx) / dist
|
|
ny = (ball.y - cy) / dist
|
|
ball.x = cx + nx * rad
|
|
ball.y = cy + ny * rad
|
|
self._flipper_impulse(ball, f, nx, ny)
|
|
|
|
def _flipper_impulse(self, ball, f, nx, ny):
|
|
"""Reflect relative velocity across the moving flipper surface.
|
|
|
|
sv = the velocity of the flipper surface at the contact point. The tip
|
|
moves by (tipvx, tipvy) px/frame; points closer to the pivot move
|
|
proportionally less, so scale by the contact radius / length.
|
|
"""
|
|
px, py = f["pivot"]
|
|
rx, ry = ball.x - px, ball.y - py
|
|
rc = math.hypot(rx, ry)
|
|
scale = 1.0 if f["length"] <= 0 else min(1.0, max(0.2, rc / f["length"]))
|
|
svx = f["tipvx"] * scale
|
|
svy = f["tipvy"] * scale
|
|
rvx = ball.vx - svx
|
|
rvy = ball.vy - svy
|
|
vn = rvx * nx + rvy * ny
|
|
|
|
fresh = not f["contacting"]
|
|
f["contacting"] = True
|
|
if vn < -0.9:
|
|
if fresh:
|
|
j = -(1.0 + FLIP_BOUNCE) * vn
|
|
else:
|
|
j = -vn # inelastic follow-through while pressing
|
|
ball.vx += j * nx
|
|
ball.vy += j * ny
|
|
# light tangential traction so resting balls ride the swipe a little
|
|
tx, ty = -ny, nx
|
|
vt = rvx * tx + rvy * ty
|
|
traction = 0.18 if (abs(f["tipvx"]) + abs(f["tipvy"])) > 0.1 else 0.0
|
|
ball.vx -= traction * vt * tx
|
|
ball.vy -= traction * vt * ty
|
|
self._cap(ball)
|