Files
2026-10-31 23:04:47 +03:30

272 lines
7.3 KiB
Python

# effects.py
#
# Everything cosmetic that reacts to the simulation: comet tails,
# colour changes on bounce, squash-and-stretch, sparks and rings.
# Pure state + math (no pygame, no drawing) - rendering.py draws it.
# None of it ever feeds back into physics.
#
# Driven by two things only: each ball's own state (position,
# velocity) and the events physics.py emits (kind, x, y, normal_x,
# normal_y, speed, ball) with kind "obstacle", "ball" or "pop".
#
# Camera safety: rendering only ever draws these with saturated
# colours in small dim glows, so the camera looking at this same
# monitor never mistakes an effect for a white sticker. Set
# VISUAL_EFFECTS_ENABLED = False in config.py to switch it all off.
import math
import random
from config import (
VISUAL_EFFECTS_ENABLED,
BALL_HUE_MIN_DEGREES,
BALL_HUE_MAX_DEGREES,
BALL_HUE_SHIFT_ON_BOUNCE,
BALL_TRAIL_LENGTH,
BALL_TRAIL_SAMPLE_SECONDS,
BALL_SQUASH_DURATION_SECONDS,
IMPACT_EFFECT_MIN_SPEED,
MAX_PARTICLES,
)
_PARTICLE_GRAVITY = 520.0
_PARTICLE_DRAG = 2.2
_MAX_RINGS = 24
# Impact speed (px/s) at which effects reach full strength.
_FULL_STRENGTH_SPEED = 1400.0
class Effects:
def __init__(self):
# Sparks: [x, y, vx, vy, age, life, hue, size]
self.particles = []
# Expanding rings: [x, y, age, life, hue, max_radius]
self.rings = []
self._random = random.Random()
def clear(self):
self.particles = []
self.rings = []
# ========================================================
# Update
# ========================================================
def update(self, delta_time, balls, events):
if not VISUAL_EFFECTS_ENABLED:
return
delta_time = max(0.0, min(delta_time, 0.05))
for event in events:
self._on_event(event)
for ball in balls:
self._update_ball(ball, delta_time)
self._update_particles(delta_time)
self._update_rings(delta_time)
# ========================================================
# Per-ball state
# ========================================================
def _update_ball(self, ball, dt):
if ball.squash > 0.0:
ball.squash = max(
0.0,
ball.squash
- dt / max(BALL_SQUASH_DURATION_SECONDS, 1e-3),
)
if BALL_TRAIL_LENGTH <= 0:
ball.trail = []
return
ball.trail_timer += dt
# More than one sample can be due on a slow frame; keep the
# tail evenly spaced in TIME regardless of frame rate.
while ball.trail_timer >= BALL_TRAIL_SAMPLE_SECONDS:
ball.trail_timer -= BALL_TRAIL_SAMPLE_SECONDS
if ball.dying:
# A popping ball's tail drains away.
if ball.trail:
ball.trail.pop(0)
else:
ball.trail.append((ball.x, ball.y))
excess = len(ball.trail) - BALL_TRAIL_LENGTH
if excess > 0:
del ball.trail[:excess]
@staticmethod
def _shift_hue(hue, amount):
span = BALL_HUE_MAX_DEGREES - BALL_HUE_MIN_DEGREES
return (
BALL_HUE_MIN_DEGREES
+ (hue - BALL_HUE_MIN_DEGREES + amount) % span
)
# ========================================================
# Events -> sparks, rings, squash, colour change
# ========================================================
def _on_event(self, event):
kind, x, y, nx, ny, speed, ball = event
if kind == "pop":
self._burst(x, y, ball.hue, 16, 80.0, 260.0)
self._ring(x, y, ball.hue, ball.radius * 3.0, 0.45)
return
if kind == "launch":
self._sparks(x, y, nx, ny, ball.hue, 12, 0.8)
self._ring(x, y, ball.hue, ball.radius * 2.2, 0.25)
return
strength = min(1.0, speed / _FULL_STRENGTH_SPEED)
ball.squash = 1.0
ball.squash_nx = nx
ball.squash_ny = ny
if kind == "obstacle":
ball.hue = self._shift_hue(
ball.hue, BALL_HUE_SHIFT_ON_BOUNCE
)
self._sparks(
x,
y,
nx,
ny,
ball.hue,
int(4 + 10 * strength),
strength,
)
self._ring(
x,
y,
ball.hue,
ball.radius * (1.6 + 1.6 * strength),
0.32,
)
else:
self._burst(
x,
y,
ball.hue,
int(3 + 6 * strength),
60.0,
140.0 + 200.0 * strength,
)
self._ring(
x,
y,
ball.hue,
ball.radius * (1.3 + 1.0 * strength),
0.25,
)
def _sparks(self, x, y, nx, ny, hue, count, strength):
rng = self._random
base = math.atan2(ny, nx)
for _ in range(count):
angle = base + rng.uniform(-1.25, 1.25)
speed = rng.uniform(
140.0,
260.0 + 420.0 * strength,
)
self._add_particle(
x,
y,
math.cos(angle) * speed,
math.sin(angle) * speed,
rng.uniform(0.35, 0.8),
hue + rng.uniform(-14.0, 14.0),
rng.uniform(2.5, 5.5),
)
def _burst(self, x, y, hue, count, speed_min, speed_max):
rng = self._random
for _ in range(count):
angle = rng.uniform(0.0, 2.0 * math.pi)
speed = rng.uniform(speed_min, speed_max)
self._add_particle(
x,
y,
math.cos(angle) * speed,
math.sin(angle) * speed,
rng.uniform(0.3, 0.7),
hue + rng.uniform(-18.0, 18.0),
rng.uniform(2.5, 5.0),
)
def _add_particle(self, x, y, vx, vy, life, hue, size):
if len(self.particles) >= MAX_PARTICLES:
# Drop the oldest so a busy moment never grows without
# bound.
del self.particles[0]
self.particles.append(
[x, y, vx, vy, 0.0, life, hue, size]
)
def _ring(self, x, y, hue, max_radius, life):
if len(self.rings) >= _MAX_RINGS:
del self.rings[0]
self.rings.append([x, y, 0.0, life, hue, max_radius])
# ========================================================
# Ageing
# ========================================================
def _update_particles(self, dt):
alive = []
drag = max(0.0, 1.0 - _PARTICLE_DRAG * dt)
for particle in self.particles:
particle[4] += dt
if particle[4] >= particle[5]:
continue
particle[3] += _PARTICLE_GRAVITY * dt
particle[2] *= drag
particle[3] *= drag
particle[0] += particle[2] * dt
particle[1] += particle[3] * dt
alive.append(particle)
self.particles = alive
def _update_rings(self, dt):
alive = []
for ring in self.rings:
ring[2] += dt
if ring[2] < ring[3]:
alive.append(ring)
self.rings = alive