Fix right flipper: mirror its geometry and sweep it per-substep

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
This commit is contained in:
georgios
2026-09-09 16:46:07 +02:00
parent db49b466c2
commit 973dd870fd
14 changed files with 61 additions and 35 deletions
+5 -5
View File
@@ -9,7 +9,7 @@ import pygame
from physics import (CV_W, CV_H, PLAY_L, PLAY_R, PLAY_T, PLAY_B, BALL_R,
CENTER_HOLE, OUTLANE_L, OUTLANE_R, DRAIN_Y, LAUNCH_DIR,
LAUNCH_MIN, LAUNCH_MAX, Table, FLIP_PIVOT_L, FLIP_PIVOT_R,
FLIP_RADIUS, FLIP_LEN)
FLIP_RADIUS, FLIP_LEN, flipper_tip)
import font as pix
# ---------------------------------------------------------------------------
@@ -195,9 +195,7 @@ def draw_sling(surf, s):
def draw_flipper(surf, f, side):
px, py = f["pivot"]
a = f["angle"]
tipx = px + math.cos(a) * f["length"]
tipy = py + math.sin(a) * f["length"]
tipx, tipy = flipper_tip(f)
p0 = (int(px), int(py))
p1 = (int(tipx), int(tipy))
# shadow
@@ -210,7 +208,9 @@ def draw_flipper(surf, f, side):
pygame.draw.circle(surf, C["flipper_d"], (ex, ey), 6)
pygame.draw.circle(surf, C["flipper"], (ex, ey), 4)
# cute stripe near the top edge
nx, ny = -math.sin(a), math.cos(a)
a = f["angle"]
side_sign = -1.0 if side == "R" else 1.0
nx, ny = -math.sin(a) * side_sign, math.cos(a)
hx, hy = int(px + nx * 3), int(py + ny * 3)
tx, ty = int(tipx + nx * 3), int(tipy + ny * 3)
pygame.draw.line(surf, C["flipper_l"], (hx, hy), (tx, ty), 2)
+56 -30
View File
@@ -23,7 +23,7 @@ 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_UP = 0.36 # rad/frame while firing
FLIP_SNAP_DOWN = 0.05 # gentle return speed
FLIP_BOUNCE = 0.12
@@ -72,6 +72,20 @@ def seg_info(px, py, ax, ay, bx, by):
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")
@@ -133,6 +147,9 @@ class Table:
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])
@@ -204,20 +221,15 @@ class Table:
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
# 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 = 1 if speed <= 0.0 else min(6, max(1, int(math.ceil(speed / 4.0))))
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)
self._substep(ball, 1.0 / n_sub, n_sub)
self.trail.append((ball.x, ball.y))
if len(self.trail) > 9:
@@ -250,7 +262,22 @@ class Table:
return False
# ------------------------------------------------------------- substep
def _substep(self, ball, dt):
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)
@@ -389,9 +416,7 @@ class Table:
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"]
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:
@@ -415,14 +440,18 @@ class Table:
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"]
"""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"]
# 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
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
@@ -436,13 +465,10 @@ class Table:
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
# light tangential traction so resting balls ride the swipe a little
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
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)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB