748 lines
22 KiB
Python
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()
|