"""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.40 # 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 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), ] 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 # flipper angle update (kinematic; per-frame, then substepped) 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 d = target - f["angle"] f["angle"] = target if abs(d) < rate else f["angle"] + (rate if d > 0 else -rate) f["w"] = f["angle"] - (f.get("prev_angle", f["angle"])) f["prev_angle"] = f["angle"] f["contacting"] = False speed = ball.speed n_sub = 1 if speed <= 0.0 else min(6, max(1, int(math.ceil(speed / 4.0)))) for _ in range(n_sub): self._substep(ball, 1.0 / 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): 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"] a = f["angle"] tipx = px + math.cos(a) * f["length"] tipy = py + math.sin(a) * f["length"] 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.""" w = f["w"] px, py = f["pivot"] # surface velocity of the flipper at the contact point, in px/frame # rotating by angle w (screen-clockwise positive) => v = w * (-ry, rx) rx, ry = ball.x - px, ball.y - py svx = -ry * w svy = rx * w 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 elif fresh and vn > 0.0: # tiny push to keep the ball from sinking into a resting flipper pass # tangential traction: transfer some of the flipper's swipe to the ball tx, ty = -ny, nx vt = rvx * tx + rvy * ty traction = 0.35 if abs(w) > 0.01 else 0.0 ball.vx += vt * tx * -traction + svx * 0.0 ball.vy += vt * ty * -traction self._cap(ball)