Files
ball_game/physics.py
T
2026-10-31 23:04:47 +03:30

748 lines
22 KiB
Python

# physics.py
#
# Pure simulation - no pygame, no OpenCV, no drawing. Everything the
# viewer sees (trails, glow, sparks, squash) is derived from this
# module's state and the events it emits, in effects.py.
#
# What is simulated:
# - balls falling under gravity, with slightly different sizes
# (bigger = heavier),
# - bouncing off detected stickers, which are ROTATED rectangles:
# the bounce uses the real surface normal (flat edge or corner)
# of the tilted sticker, with restitution (how bouncy) and
# friction (so balls roll along slopes instead of skating),
# - balls bouncing off each other,
# - ALL FOUR screen edges as solid walls - a ball never leaves the
# screen, it bounces off the top/bottom/left/right forever,
# - by default none of the above bounces lose any energy
# (BALL_RESTITUTION = BALL_BALL_RESTITUTION = 1.0, BALL_FRICTION
# = 0 in config.py), so with gravity a ball just keeps bouncing
# indefinitely like an ideal rubber ball, instead of gradually
# settling - lower these if a more "real rubber ball" feel that
# loses a little height each bounce is wanted instead,
# - a ball is only ever removed in one of two ways, each
# independently configurable in config.py and each with its own
# little pop effect: it reaches BALL_MAX_LIFETIME_SECONDS after
# being spawned, or it is "stuck" (parked in one spot, usually a
# pocket between stickers) for BALL_STUCK_TIMEOUT_SECONDS.
#
# Performance notes (this runs 144 times a second):
# - obstacle dicts are converted ONCE per detection update into
# lightweight _Solid records with cos/sin already computed,
# - each ball first does a cheap circle-vs-circle reject against
# each solid's bounding circle before any real collision math,
# - time is split into equal slices no longer than
# PHYSICS_MAX_STEP_SECONDS (and short enough that a fast ball
# can't skip over a sticker), so motion stays smooth and
# proportional to real elapsed time.
import math
import random
from geometry import extract_obb
from launcher import Launcher
from config import (
BALL_RADIUS,
BALL_RADIUS_VARIATION,
BALL_GRAVITY,
BALL_RESTITUTION,
BALL_REST_SPEED,
BALL_FRICTION,
BALL_MAX_SPEED,
BALL_BALL_COLLISION_ENABLED,
BALL_BALL_RESTITUTION,
PHYSICS_MAX_STEP_SECONDS,
MAX_SIMULTANEOUS_BALLS,
BALL_STUCK_DISTANCE,
BALL_STUCK_TIMEOUT_SECONDS,
BALL_MAX_LIFETIME_SECONDS,
BALL_HUE_MIN_DEGREES,
BALL_HUE_MAX_DEGREES,
IMPACT_EFFECT_MIN_SPEED,
)
# Continuous slow drag (per second) applied to the sliding speed of
# a ball that is resting/rolling ON a surface, so it doesn't glide
# forever on a slope. Impact friction (BALL_FRICTION) is separate.
_ROLLING_DRAG = 0.6
# How long a removed ("popped") ball takes to shrink away.
_DYING_SECONDS = 0.45
_MAX_EVENTS_PER_FRAME = 64
class Ball:
def __init__(
self,
x,
y,
radius=15,
velocity_x=0.0,
velocity_y=0.0,
gravity=900.0,
hue=200.0,
):
self.x = float(x)
self.y = float(y)
self.radius = float(radius)
# Mass grows with area, so a big ball shoves a small one
# around instead of bouncing off it like an equal.
self.mass = self.radius * self.radius
self.velocity_x = float(velocity_x)
self.velocity_y = float(velocity_y)
self.gravity = float(gravity)
self.alive = True
# True once the ball has fallen down far enough to actually
# be on screen (y - radius >= 0). Until then the top wall is
# not enforced, so a ball can still drop in from just above
# the visible screen the way it always has; from then on,
# the top wall is a solid ceiling like every other edge.
self.entered = False
# Dying balls are on their way out (shrinking away): they
# no longer collide with anything.
self.dying = False
self.fade = 1.0
self.age = 0.0
# Purely cosmetic state, owned/updated by effects.py.
self.hue = float(hue)
self.trail = []
self.trail_timer = 0.0
self.squash = 0.0
self.squash_nx = 0.0
self.squash_ny = -1.0
# Stuck detection.
self._anchor_x = self.x
self._anchor_y = self.y
self._stuck_time = 0.0
@property
def speed(self):
return math.hypot(self.velocity_x, self.velocity_y)
class _Solid:
"""One detected sticker, pre-digested for fast collision tests."""
__slots__ = (
"cx",
"cy",
"half_long",
"half_short",
"cos",
"sin",
"bound",
)
def __init__(self, cx, cy, rect_long, rect_short, angle):
theta = math.radians(angle)
self.cx = cx
self.cy = cy
self.half_long = rect_long / 2.0
self.half_short = rect_short / 2.0
self.cos = math.cos(theta)
self.sin = math.sin(theta)
# Radius of the smallest circle containing the whole
# rectangle: anything farther away than this (plus a
# ball's radius) can't possibly touch it.
self.bound = math.hypot(
self.half_long,
self.half_short,
)
class PhysicsWorld:
def __init__(self, width, height):
self.width = float(width)
self.height = float(height)
self.launcher = Launcher(width, height)
self.balls = []
# Impact/pop/launch events produced by the last update(), consumed
# by effects.py: (kind, x, y, normal_x, normal_y, speed,
# ball). kind is "obstacle", "ball", "pop" or "launch".
self.events = []
self._solids = []
self._sticker_solids = []
self._solid_source = None
self._random = random.Random()
self._hue_cursor = self._random.uniform(
BALL_HUE_MIN_DEGREES,
BALL_HUE_MAX_DEGREES,
)
# ========================================================
# Balls
# ========================================================
def add_ball(self, ball):
if ball is not None:
self.balls.append(ball)
def live_ball_count(self):
return sum(
1
for ball in self.balls
if ball.alive and not ball.dying
)
def _next_hue(self):
# Golden-ratio hop around the allowed hue range: successive
# balls always get clearly different colors.
span = BALL_HUE_MAX_DEGREES - BALL_HUE_MIN_DEGREES
self._hue_cursor = (
BALL_HUE_MIN_DEGREES
+ (
self._hue_cursor
- BALL_HUE_MIN_DEGREES
+ span * 0.618034
)
% span
)
return self._hue_cursor
def spawn_ball(self):
"""
Spawns a new ball emerging from the launcher nozzle.
Returns the ball, or None if blocked at nozzle exit or max balls reached.
"""
if self.live_ball_count() >= MAX_SIMULTANEOUS_BALLS:
return None
spawn_x, spawn_y, dir_x, dir_y = self.launcher.get_spawn_transform()
rng = self._random
radius = BALL_RADIUS * (
1.0
+ rng.uniform(
-BALL_RADIUS_VARIATION,
BALL_RADIUS_VARIATION,
)
)
min_distance = (radius + BALL_RADIUS * (1.0 + BALL_RADIUS_VARIATION)) * 1.05
for other in self.balls:
if not other.alive or other.dying:
continue
if math.hypot(spawn_x - other.x, spawn_y - other.y) < min_distance:
return None
vel_x = dir_x * self.launcher.spawn_speed
vel_y = dir_y * self.launcher.spawn_speed
ball = Ball(
x=spawn_x,
y=spawn_y,
radius=radius,
velocity_x=vel_x,
velocity_y=vel_y,
gravity=BALL_GRAVITY,
hue=self._next_hue(),
)
ball.entered = True
self.balls.append(ball)
if len(self.events) < _MAX_EVENTS_PER_FRAME:
self.events.append(
(
"launch",
spawn_x,
spawn_y,
dir_x,
dir_y,
self.launcher.spawn_speed,
ball,
)
)
return ball
# ========================================================
# Obstacles
# ========================================================
def set_obstacles(self, obstacles):
# The vision thread publishes a NEW list object whenever
# something changed; rebuild sticker solids only then.
if obstacles is not self._solid_source:
self._solid_source = obstacles
solids = []
for obstacle in obstacles or ():
obb = extract_obb(obstacle)
if obb is None:
continue
solids.append(_Solid(*obb))
self._sticker_solids = solids
# Combine sticker solids + launcher tube solid
self._solids = list(self._sticker_solids)
if self.launcher is not None:
self._solids.append(_Solid(*self.launcher.get_solid_params()))
# ========================================================
# Update
# ========================================================
def update(self, delta_time, obstacles=None):
self.events = []
if delta_time <= 0:
return
delta_time = min(delta_time, 0.05)
if self.launcher is not None:
self.launcher.update(delta_time)
self.set_obstacles(obstacles)
movers = [
ball
for ball in self.balls
if ball.alive and not ball.dying
]
if movers:
fastest = max(ball.speed for ball in movers)
smallest = min(ball.radius for ball in movers)
# Enough slices that (a) none is longer than the
# configured maximum and (b) no ball travels more than
# half its own radius in one slice - which is what
# guarantees it can't skip through a thin sticker.
steps = max(
math.ceil(
delta_time / PHYSICS_MAX_STEP_SECONDS
),
math.ceil(
fastest * delta_time
/ (0.5 * smallest)
),
1,
)
steps = min(steps, 10)
step = delta_time / steps
for _ in range(steps):
self._step(movers, step)
self._housekeeping(delta_time)
def _step(self, movers, h):
for ball in movers:
ball.velocity_y += ball.gravity * h
speed = ball.speed
if speed > BALL_MAX_SPEED:
scale = BALL_MAX_SPEED / speed
ball.velocity_x *= scale
ball.velocity_y *= scale
ball.x += ball.velocity_x * h
ball.y += ball.velocity_y * h
if BALL_BALL_COLLISION_ENABLED and len(movers) > 1:
self._collide_balls(movers)
for ball in movers:
# Up to 3 passes so a ball squeezed between two
# stickers (or a sticker and another ball's push) ends
# up somewhere consistent instead of half inside one.
for _ in range(3):
touched = False
for solid in self._solids:
if self._collide_solid(ball, solid, h):
touched = True
if not touched:
break
self._keep_inside_walls(ball)
# ========================================================
# Ball vs. sticker (oriented rectangle)
# ========================================================
def _collide_solid(self, ball, solid, h):
radius = ball.radius
dx = ball.x - solid.cx
dy = ball.y - solid.cy
reach = solid.bound + radius
# Cheap reject: outside the sticker's bounding circle.
if dx * dx + dy * dy > reach * reach:
return False
cos_t = solid.cos
sin_t = solid.sin
# Ball center in the sticker's own un-rotated frame, where
# this is plain circle-vs-axis-aligned-box.
local_x = dx * cos_t + dy * sin_t
local_y = -dx * sin_t + dy * cos_t
half_long = solid.half_long
half_short = solid.half_short
closest_x = max(-half_long, min(local_x, half_long))
closest_y = max(-half_short, min(local_y, half_short))
diff_x = local_x - closest_x
diff_y = local_y - closest_y
distance_sq = diff_x * diff_x + diff_y * diff_y
if distance_sq > radius * radius:
return False
if distance_sq > 1e-9:
# Center outside the box: (diff) already points from the
# nearest surface point to the ball - the true surface
# normal for both a flat face hit and a corner hit.
distance = math.sqrt(distance_sq)
normal_x = diff_x / distance
normal_y = diff_y / distance
penetration = radius - distance
local_x += normal_x * penetration
local_y += normal_y * penetration
else:
# Center already inside the box (very fast ball / thin
# sticker): leave through whichever side is nearest.
overlap_x = half_long + radius - abs(local_x)
overlap_y = half_short + radius - abs(local_y)
if overlap_x < overlap_y:
normal_x = 1.0 if local_x >= 0 else -1.0
normal_y = 0.0
local_x = normal_x * (half_long + radius)
else:
normal_x = 0.0
normal_y = 1.0 if local_y >= 0 else -1.0
local_y = normal_y * (half_short + radius)
ball.x = solid.cx + local_x * cos_t - local_y * sin_t
ball.y = solid.cy + local_x * sin_t + local_y * cos_t
local_vx = (
ball.velocity_x * cos_t
+ ball.velocity_y * sin_t
)
local_vy = (
-ball.velocity_x * sin_t
+ ball.velocity_y * cos_t
)
normal_speed = local_vx * normal_x + local_vy * normal_y
# Only react if the ball is actually moving INTO the
# surface. (Reflecting a ball that is already leaving is
# what makes bounces flip-flop and jitter.)
if normal_speed < 0:
impact = -normal_speed
tangent_x = -normal_y
tangent_y = normal_x
tangent_speed = (
local_vx * tangent_x + local_vy * tangent_y
)
if impact >= BALL_REST_SPEED:
bounce = BALL_RESTITUTION
tangent_speed *= 1.0 - BALL_FRICTION
else:
# Gentle contact = resting/rolling: no bounce, just
# a little rolling resistance.
bounce = 0.0
tangent_speed *= max(
0.0,
1.0 - _ROLLING_DRAG * h,
)
new_normal_speed = -normal_speed * bounce
local_vx = (
new_normal_speed * normal_x
+ tangent_speed * tangent_x
)
local_vy = (
new_normal_speed * normal_y
+ tangent_speed * tangent_y
)
ball.velocity_x = local_vx * cos_t - local_vy * sin_t
ball.velocity_y = local_vx * sin_t + local_vy * cos_t
if (
impact >= IMPACT_EFFECT_MIN_SPEED
and len(self.events) < _MAX_EVENTS_PER_FRAME
):
world_nx = normal_x * cos_t - normal_y * sin_t
world_ny = normal_x * sin_t + normal_y * cos_t
self.events.append(
(
"obstacle",
ball.x - world_nx * ball.radius,
ball.y - world_ny * ball.radius,
world_nx,
world_ny,
impact,
ball,
)
)
return True
# ========================================================
# Ball vs. ball
# ========================================================
def _collide_balls(self, movers):
count = len(movers)
for i in range(count - 1):
first = movers[i]
for j in range(i + 1, count):
second = movers[j]
reach = first.radius + second.radius
dx = second.x - first.x
dy = second.y - first.y
if abs(dx) >= reach or abs(dy) >= reach:
continue
distance_sq = dx * dx + dy * dy
if distance_sq >= reach * reach:
continue
distance = math.sqrt(distance_sq)
if distance > 1e-6:
normal_x = dx / distance
normal_y = dy / distance
else:
normal_x = 0.0
normal_y = 1.0
total_mass = first.mass + second.mass
# Push apart, the lighter ball moving more.
penetration = reach - distance
first_share = penetration * (second.mass / total_mass)
second_share = penetration * (first.mass / total_mass)
first.x -= normal_x * first_share
first.y -= normal_y * first_share
second.x += normal_x * second_share
second.y += normal_y * second_share
closing = (
(second.velocity_x - first.velocity_x) * normal_x
+ (second.velocity_y - first.velocity_y) * normal_y
)
if closing >= 0:
continue
impact = -closing
bounce = (
BALL_BALL_RESTITUTION
if impact >= BALL_REST_SPEED
else 0.0
)
impulse = (
-(1.0 + bounce)
* closing
/ (1.0 / first.mass + 1.0 / second.mass)
)
first.velocity_x -= impulse / first.mass * normal_x
first.velocity_y -= impulse / first.mass * normal_y
second.velocity_x += impulse / second.mass * normal_x
second.velocity_y += impulse / second.mass * normal_y
if (
impact >= IMPACT_EFFECT_MIN_SPEED
and len(self.events) < _MAX_EVENTS_PER_FRAME
):
self.events.append(
(
"ball",
first.x + normal_x * first.radius,
first.y + normal_y * first.radius,
normal_x,
normal_y,
impact,
first,
)
)
# ========================================================
# Walls
# ========================================================
#
# All four screen edges are solid: a ball bounces off every one
# of them forever and never leaves the screen (the only ways a
# ball ever disappears are the lifetime/stuck-timeout removal in
# _housekeeping below, each with its own little pop effect).
# With BALL_RESTITUTION left at its lossless default (1.0) these
# bounces never bleed off energy, so together with gravity a
# ball just keeps bouncing indefinitely, exactly like a perfect
# rubber ball in a box, until its time is up.
def _keep_inside_walls(self, ball):
radius = ball.radius
if ball.x - radius < 0:
ball.x = radius
if ball.velocity_x < 0:
ball.velocity_x *= -BALL_RESTITUTION
elif ball.x + radius > self.width:
ball.x = self.width - radius
if ball.velocity_x > 0:
ball.velocity_x *= -BALL_RESTITUTION
if not ball.entered:
if ball.y - radius < 0:
# Still on its way in from above the visible
# screen - leave the top wall unenforced for now.
return
ball.entered = True
if ball.y - radius < 0:
ball.y = radius
if ball.velocity_y < 0:
ball.velocity_y *= -BALL_RESTITUTION
elif ball.y + radius > self.height:
ball.y = self.height - radius
if ball.velocity_y > 0:
ball.velocity_y *= -BALL_RESTITUTION
# ========================================================
# Lifetime, stuck balls, removal
# ========================================================
def _housekeeping(self, delta_time):
for ball in self.balls:
if not ball.alive:
continue
if ball.dying:
ball.fade -= delta_time / _DYING_SECONDS
if ball.fade <= 0.0:
ball.alive = False
continue
ball.age += delta_time
if BALL_STUCK_TIMEOUT_SECONDS > 0:
moved = math.hypot(
ball.x - ball._anchor_x,
ball.y - ball._anchor_y,
)
if moved > BALL_STUCK_DISTANCE:
ball._anchor_x = ball.x
ball._anchor_y = ball.y
ball._stuck_time = 0.0
else:
ball._stuck_time += delta_time
if ball._stuck_time >= BALL_STUCK_TIMEOUT_SECONDS:
self._start_dying(ball)
continue
if (
BALL_MAX_LIFETIME_SECONDS > 0
and ball.age >= BALL_MAX_LIFETIME_SECONDS
):
self._start_dying(ball)
self.balls = [
ball for ball in self.balls if ball.alive
]
def _start_dying(self, ball):
ball.dying = True
ball.fade = 1.0
if len(self.events) < _MAX_EVENTS_PER_FRAME:
self.events.append(
(
"pop",
ball.x,
ball.y,
0.0,
-1.0,
0.0,
ball,
)
)
# ========================================================
# Reset
# ========================================================
def clear(self):
self.balls = []
self.events = []
def reset(self):
self.clear()