"""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 = 154.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 just below the two outer bumpers - kick toward centre 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: wall-huggers get bounced back into # play instead of silently draining - the centre gap is the real risk 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"]) 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)