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

966 lines
26 KiB
Python

# rendering.py
#
# Draws the game onto the monitor.
#
# Speed: with USE_DIRTY_RECTS the screen is split into a grid of
# small cells. Only cells where something was drawn last frame or is
# drawn now get repainted (background, then the sticker patches in
# them, then the effects in them) and only those cells are pushed to
# the display. A few balls touch a few dozen cells instead of all
# 2 million pixels. The calibration border and black interior are
# pre-rendered ONCE into a background surface and just copied in.
#
# Camera safety: the camera watches this very monitor for WHITE
# stickers. So everything here except the required sticker patch is
# drawn in saturated colour, with dim additive glows (never near
# white) and only tiny pastel highlights. The sticker patch is drawn
# slightly smaller than the sticker (see geometry.paint_dimensions)
# and the detector blanks it out, so a removed sticker can't be
# "seen" in its own leftover paint.
import colorsys
import math
import os
import time
import pygame
from geometry import (
obb_corners,
extract_obb,
paint_dimensions,
)
from config import (
SCREEN_WIDTH,
SCREEN_HEIGHT,
BACKGROUND_COLOR,
CALIBRATION_PATTERN_OUTER_COLOR,
CALIBRATION_PATTERN_INNER_COLOR,
CALIBRATION_OUTER_BORDER_SIZE,
CALIBRATION_INNER_BORDER_SIZE,
TARGET_FPS,
USE_DIRTY_RECTS,
FULL_REPAINT_SECONDS,
VISUAL_EFFECTS_ENABLED,
BALL_GLOW_ENABLED,
BALL_SQUASH_STRETCH_ENABLED,
SHOW_DETECTED_OBSTACLES,
DETECTED_OBSTACLE_COLOR,
DETECTED_OBSTACLE_BORDER_WIDTH,
DETECTED_OBSTACLE_PAINT_INSET_RATIO,
)
# Side length (px) of one dirty-repaint cell.
_CELL = 96
# Additive glow strengths (fraction of the full hue colour at the
# glow's centre). Deliberately dim: see "camera safety" above.
_GLOW_LEVELS = (0.16, 0.30, 0.48, 0.66)
# Layers, drawn in this order inside every repainted cell.
_LAYER_GLOW = 0
_LAYER_RING = 1
_LAYER_BODY = 2
class Renderer:
# ========================================================
# Initialization
# ========================================================
def __init__(self):
# Ask SDL to place the (borderless) window at the top-left
# corner so it lines up exactly with the screen.
os.environ.setdefault("SDL_VIDEO_WINDOW_POS", "0,0")
pygame.init()
pygame.display.set_caption("Monitor Ball")
# A borderless window sized to the full screen ("fake
# fullscreen"/kiosk mode) instead of pygame.FULLSCREEN.
# Exclusive SDL fullscreen needs a real videofullscreen-mode switch,
# which is unreliable on several X11/Wayland/XWayland driver
# combinations (blank frames, crashes on exit).
self.screen = pygame.display.set_mode(
(SCREEN_WIDTH, SCREEN_HEIGHT),
pygame.NOFRAME,
)
# pygame.mouse.set_visible(False)
# try:
# pygame.event.set_grab(True)
# except Exception:
# pass
# SDL2 turns "text input mode" on by default, which on
# systems with an active IME can swallow certain keys
# before they reach us as a normal KEYDOWN.
try:
pygame.key.stop_text_input()
except Exception:
pass
self.clock = pygame.time.Clock()
self._screen_rect = pygame.Rect(
0, 0, SCREEN_WIDTH, SCREEN_HEIGHT
)
self._cols = (SCREEN_WIDTH + _CELL - 1) // _CELL
self._rows = (SCREEN_HEIGHT + _CELL - 1) // _CELL
self._background = self._build_background()
self._mode = None
self._need_full = True
self._last_full = 0.0
# Sticker patches, rebuilt only when the detector publishes
# a new list.
self._obstacle_source = None
self._obstacle_polys = []
self._obstacle_cells = {}
self._obstacle_dirty = set()
self._prev_cells = set()
self._glow_cache = {}
self._color_cache = {}
def _build_background(self):
surface = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT))
surface.fill(BACKGROUND_COLOR)
return surface.convert()
# ========================================================
# Calibration screens
# ========================================================
def render_calibration(self):
"""
Solid pink over the whole screen: the simplest possible
target for the camera's first lock-on. It never changes, so
it is painted once (and again every FULL_REPAINT_SECONDS as
a safety net) instead of 144 times a second.
"""
now = time.monotonic()
if (
self._mode != "calibration"
or now - self._last_full >= FULL_REPAINT_SECONDS
):
self.draw_full_calibration_screen()
pygame.display.flip()
self._mode = "calibration"
self._last_full = now
self._need_full = True
self._prev_cells = set()
def draw_full_calibration_screen(self):
self.screen.set_clip(None)
self.screen.fill(CALIBRATION_PATTERN_OUTER_COLOR)
def draw_calibration_border(self):
# Border ring + black interior (pre-rendered).
self.screen.set_clip(None)
self.screen.blit(self._background, (0, 0))
def force_full_repaint(self):
self._need_full = True
# ========================================================
# Main rendering
# ========================================================
def render(self, balls, obstacles, effects=None, launcher=None):
now = time.monotonic()
self._sync_obstacles(obstacles)
items = self._collect_items(balls, effects, launcher)
cell_items = {}
for layer, bounds, draw in items:
self._add_to_cells(
cell_items,
bounds,
(layer, draw),
)
full = (
not USE_DIRTY_RECTS
or self._need_full
or self._mode != "game"
or now - self._last_full >= FULL_REPAINT_SECONDS
)
if full:
self._paint_full(cell_items, now)
else:
self._paint_dirty(cell_items)
self._prev_cells = set(cell_items)
def _paint_full(self, cell_items, now):
screen = self.screen
screen.set_clip(None)
screen.blit(self._background, (0, 0))
for poly in self._obstacle_polys:
self._draw_obstacle(poly)
everything = []
for entries in cell_items.values():
everything.extend(entries)
# An item spanning several cells appears once per cell;
# drawing it once per cell is wasteful when nothing is
# clipped, so de-duplicate by identity.
seen = set()
ordered = []
for entry in everything:
key = id(entry[1])
if key in seen:
continue
seen.add(key)
ordered.append(entry)
ordered.sort(key=lambda entry: entry[0])
for _layer, draw in ordered:
self._draw_item(draw)
pygame.display.flip()
self._need_full = False
self._mode = "game"
self._last_full = now
self._obstacle_dirty = set()
def _paint_dirty(self, cell_items):
screen = self.screen
background = self._background
dirty = (
set(cell_items)
| self._prev_cells
| self._obstacle_dirty
)
self._obstacle_dirty = set()
rects = []
for cell in dirty:
rect = self._cell_rect(cell)
if rect.width <= 0 or rect.height <= 0:
continue
rects.append(rect)
screen.set_clip(rect)
screen.blit(background, rect.topleft, rect)
for index in self._obstacle_cells.get(cell, ()):
self._draw_obstacle(self._obstacle_polys[index])
entries = cell_items.get(cell)
if entries:
entries.sort(key=lambda entry: entry[0])
for _layer, draw in entries:
self._draw_item(draw)
screen.set_clip(None)
if rects:
pygame.display.update(rects)
# ========================================================
# Grid helpers
# ========================================================
def _cell_rect(self, cell):
column, row = cell
return pygame.Rect(
column * _CELL,
row * _CELL,
_CELL,
_CELL,
).clip(self._screen_rect)
def _cells_for(self, rect):
left = max(0, rect.left // _CELL)
top = max(0, rect.top // _CELL)
right = min(self._cols - 1, (rect.right - 1) // _CELL)
bottom = min(self._rows - 1, (rect.bottom - 1) // _CELL)
if rect.right <= 0 or rect.bottom <= 0:
return ()
if rect.left >= SCREEN_WIDTH or rect.top >= SCREEN_HEIGHT:
return ()
return [
(column, row)
for row in range(top, bottom + 1)
for column in range(left, right + 1)
]
def _add_to_cells(self, cell_items, bounds, entry):
for cell in self._cells_for(bounds):
cell_items.setdefault(cell, []).append(entry)
# ========================================================
# Sticker patches
# ========================================================
def _sync_obstacles(self, obstacles):
if obstacles is self._obstacle_source:
return
self._obstacle_source = obstacles
# Cells that used to show a patch must be repainted, as
# must cells that now do.
self._obstacle_dirty |= set(self._obstacle_cells)
polys = []
cells = {}
if SHOW_DETECTED_OBSTACLES:
for obstacle in obstacles or ():
if not obstacle.get("painted", True):
continue
obb = extract_obb(obstacle)
if obb is None:
continue
cx, cy, long_side, short_side, angle = obb
long_side, short_side = paint_dimensions(
long_side,
short_side,
DETECTED_OBSTACLE_PAINT_INSET_RATIO,
)
if long_side <= 0 or short_side <= 0:
continue
corners = obb_corners(
cx,
cy,
long_side,
short_side,
angle,
)
points = [
(int(round(px)), int(round(py)))
for px, py in corners
]
xs = [p[0] for p in points]
ys = [p[1] for p in points]
bounds = pygame.Rect(
min(xs) - 2,
min(ys) - 2,
max(xs) - min(xs) + 4,
max(ys) - min(ys) + 4,
)
index = len(polys)
polys.append(points)
for cell in self._cells_for(bounds):
cells.setdefault(cell, []).append(index)
self._obstacle_polys = polys
self._obstacle_cells = cells
self._obstacle_dirty |= set(cells)
def _draw_obstacle(self, points):
if DETECTED_OBSTACLE_BORDER_WIDTH <= 0:
pygame.draw.polygon(
self.screen,
DETECTED_OBSTACLE_COLOR,
points,
)
else:
pygame.draw.polygon(
self.screen,
DETECTED_OBSTACLE_COLOR,
points,
int(DETECTED_OBSTACLE_BORDER_WIDTH),
)
# ========================================================
# Collecting what to draw this frame
# ========================================================
#
# Everything is turned into a flat list of
# (layer, bounding_rect, draw_tuple) ONCE per frame; the
# per-cell painting then only replays those tuples.
def _collect_items(self, balls, effects, launcher=None):
items = []
if launcher is not None:
self._collect_launcher(items, launcher)
effects_on = (
VISUAL_EFFECTS_ENABLED and effects is not None
)
if effects_on:
for particle in effects.particles:
self._collect_particle(items, particle)
for ring in effects.rings:
self._collect_ring(items, ring)
for ball in balls or ():
if ball.alive:
self._collect_ball(items, ball, effects_on)
return items
def _collect_launcher(self, items, launcher):
points = launcher.get_points()
xs = [p[0] for p in points]
ys = [p[1] for p in points]
bounds = pygame.Rect(
int(min(xs)) - 3,
int(min(ys)) - 3,
int(max(xs) - min(xs)) + 6,
int(max(ys) - min(ys)) + 6,
)
items.append((_LAYER_BODY, bounds, ("launcher", launcher)))
# ---- particles / rings -------------------------------------
def _collect_particle(self, items, particle):
x, y, _vx, _vy, age, life, hue, size = particle
fraction = 1.0 - age / life
level = min(
len(_GLOW_LEVELS) - 1,
int(fraction * len(_GLOW_LEVELS)),
)
radius = max(2, int(round(size * (0.6 + 0.6 * fraction))) + 2)
sprite = self._glow(hue, radius, level)
position = (int(x) - radius, int(y) - radius)
bounds = pygame.Rect(
position[0],
position[1],
radius * 2,
radius * 2,
)
items.append(
(
_LAYER_GLOW,
bounds,
("sprite", sprite, position),
)
)
def _collect_ring(self, items, ring):
x, y, age, life, hue, max_radius = ring
fraction = age / life
radius = max(2, int(max_radius * (0.25 + 0.75 * fraction)))
fade = (1.0 - fraction) ** 1.5
color = self._hue_rgb(hue, 0.85, 0.9 * fade)
width = max(1, int(3 * (1.0 - fraction)) + 1)
bounds = pygame.Rect(
int(x) - radius - 2,
int(y) - radius - 2,
radius * 2 + 4,
radius * 2 + 4,
)
items.append(
(
_LAYER_RING,
bounds,
(
"ring",
color,
(int(x), int(y)),
radius,
width,
),
)
)
# ---- balls --------------------------------------------------
def _collect_ball(self, items, ball, effects_on):
fade = max(0.0, ball.fade)
radius = ball.radius * fade
if radius < 1.0:
return
if effects_on:
self._collect_trail(items, ball, radius)
if BALL_GLOW_ENABLED:
self._collect_glow(items, ball, radius, fade)
points, angle_bounds = self._ball_polygon(
ball,
radius,
effects_on,
)
body = self._hue_rgb(ball.hue, 0.85, 1.0)
rim = self._hue_rgb(ball.hue, 0.95, 0.72)
# A small pastel highlight (saturated enough that the
# camera can never count it as "white").
highlight_color = self._hue_rgb(ball.hue, 0.42, 1.0)
highlight_radius = max(1, int(round(radius * 0.24)))
highlight_position = (
int(round(ball.x - radius * 0.32)),
int(round(ball.y - radius * 0.34)),
)
items.append(
(
_LAYER_BODY,
angle_bounds,
(
"body",
points,
body,
rim,
highlight_position,
highlight_radius,
highlight_color,
),
)
)
def _ball_polygon(self, ball, radius, effects_on):
# Ellipse approximated by a polygon: stretched along the
# direction of travel with speed, flattened against the
# surface for a moment after a hit.
semi_a = radius
semi_b = radius
angle = 0.0
if effects_on and BALL_SQUASH_STRETCH_ENABLED:
speed = ball.speed
if speed > 30.0:
stretch = min(0.35, speed / 4000.0)
semi_a = radius * (1.0 + stretch)
semi_b = radius / (1.0 + stretch * 0.9)
angle = math.atan2(
ball.velocity_y,
ball.velocity_x,
)
weight = ball.squash * ball.squash
if weight > 0.12:
k = weight * 0.38
# First axis lies along the surface normal.
semi_a = radius * (1.0 - k)
semi_b = radius * (1.0 + k * 0.7)
angle = math.atan2(
ball.squash_ny,
ball.squash_nx,
)
count = 18 if radius < 20 else 26
cos_a = math.cos(angle)
sin_a = math.sin(angle)
points = []
for i in range(count):
theta = 2.0 * math.pi * i / count
ex = semi_a * math.cos(theta)
ey = semi_b * math.sin(theta)
points.append(
(
ball.x + ex * cos_a - ey * sin_a,
ball.y + ex * sin_a + ey * cos_a,
)
)
xs = [p[0] for p in points]
ys = [p[1] for p in points]
bounds = pygame.Rect(
int(min(xs)) - 2,
int(min(ys)) - 2,
int(max(xs) - min(xs)) + 5,
int(max(ys) - min(ys)) + 5,
)
return points, bounds
def _collect_glow(self, items, ball, radius, fade):
glow_radius = int(radius * 2.4)
level = min(
len(_GLOW_LEVELS) - 1,
int(ball.speed / 450.0),
)
if fade < 0.6:
level = max(0, level - 1)
sprite = self._glow(ball.hue, glow_radius, level)
position = (
int(round(ball.x)) - glow_radius,
int(round(ball.y)) - glow_radius,
)
bounds = pygame.Rect(
position[0],
position[1],
glow_radius * 2,
glow_radius * 2,
)
items.append(
(
_LAYER_GLOW,
bounds,
("sprite", sprite, position),
)
)
def _collect_trail(self, items, ball, radius):
trail = ball.trail
count = len(trail)
if count < 2:
return
sprites = []
min_x = min_y = 10 ** 9
max_x = max_y = -(10 ** 9)
biggest = 0
for index, (tx, ty) in enumerate(trail):
fraction = (index + 1) / count
sprite_radius = max(
2,
int(radius * (0.35 + 0.6 * fraction)),
)
level = min(
len(_GLOW_LEVELS) - 1,
int(fraction * len(_GLOW_LEVELS)),
)
position = (
int(round(tx)) - sprite_radius,
int(round(ty)) - sprite_radius,
)
sprites.append(
(
self._glow(ball.hue, sprite_radius, level),
position,
)
)
biggest = max(biggest, sprite_radius)
min_x = min(min_x, tx)
max_x = max(max_x, tx)
min_y = min(min_y, ty)
max_y = max(max_y, ty)
bounds = pygame.Rect(
int(min_x) - biggest - 1,
int(min_y) - biggest - 1,
int(max_x - min_x) + biggest * 2 + 3,
int(max_y - min_y) + biggest * 2 + 3,
)
items.append(
(
_LAYER_GLOW,
bounds,
("trail", sprites),
)
)
# ========================================================
# Drawing one collected item
# ========================================================
def _draw_item(self, draw):
kind = draw[0]
screen = self.screen
if kind == "sprite":
screen.blit(
draw[1],
draw[2],
special_flags=pygame.BLEND_RGB_ADD,
)
elif kind == "trail":
for sprite, position in draw[1]:
screen.blit(
sprite,
position,
special_flags=pygame.BLEND_RGB_ADD,
)
elif kind == "ring":
_kind, color, center, radius, width = draw
pygame.draw.circle(
screen,
color,
center,
radius,
width,
)
elif kind == "body":
(
_kind,
points,
body,
rim,
highlight_position,
highlight_radius,
highlight_color,
) = draw
pygame.draw.polygon(screen, body, points)
# Anti-aliased outline in the rim colour smooths the
# jagged polygon fill edge.
pygame.draw.aalines(screen, rim, True, points)
pygame.draw.circle(
screen,
highlight_color,
highlight_position,
highlight_radius,
)
elif kind == "launcher":
launcher = draw[1]
points = launcher.get_points()
# Solid metallic tube fill
pygame.draw.polygon(screen, (48, 55, 66), points)
# Highlight & Shadow lines along the tube
rad = math.radians(launcher.angle)
u_x = math.sin(rad)
u_y = math.cos(rad)
v_x = -u_y
v_y = u_x
half_w = launcher.tube_width / 2.0
length = launcher.length
# Highlight line along left inner edge
hl_start = (
launcher.pivot_x - v_x * (half_w * 0.4),
launcher.pivot_y - v_y * (half_w * 0.4),
)
hl_end = (
launcher.pivot_x + u_x * length - v_x * (half_w * 0.4),
launcher.pivot_y + u_y * length - v_y * (half_w * 0.4),
)
pygame.draw.line(
screen,
(115, 126, 142),
hl_start,
hl_end,
width=max(2, int(half_w * 0.45)),
)
# Shadow line along right inner edge
sh_start = (
launcher.pivot_x + v_x * (half_w * 0.5),
launcher.pivot_y + v_y * (half_w * 0.5),
)
sh_end = (
launcher.pivot_x + u_x * length + v_x * (half_w * 0.5),
launcher.pivot_y + u_y * length + v_y * (half_w * 0.5),
)
pygame.draw.line(
screen,
(26, 30, 38),
sh_start,
sh_end,
width=max(2, int(half_w * 0.35)),
)
# Dark nozzle rim at tip
p_bot_right = points[2]
p_bot_left = points[3]
rim_points = [
(
launcher.pivot_x + u_x * (length - 6.0) - v_x * half_w,
launcher.pivot_y + u_y * (length - 6.0) - v_y * half_w,
),
(
launcher.pivot_x + u_x * (length - 6.0) + v_x * half_w,
launcher.pivot_y + u_y * (length - 6.0) + v_y * half_w,
),
p_bot_right,
p_bot_left,
]
pygame.draw.polygon(screen, (32, 36, 44), rim_points)
# Crisp metallic outline
pygame.draw.polygon(screen, (78, 88, 102), points, width=2)
pygame.draw.aalines(screen, (95, 108, 124), True, points)
# ========================================================
# Colours and glow sprites
# ========================================================
def _hue_rgb(self, hue, saturation, value):
key = (
int(hue) % 360,
int(saturation * 50),
int(value * 50),
)
color = self._color_cache.get(key)
if color is None:
r, g, b = colorsys.hsv_to_rgb(
(key[0] % 360) / 360.0,
saturation,
max(0.0, min(1.0, value)),
)
color = (
int(r * 255),
int(g * 255),
int(b * 255),
)
self._color_cache[key] = color
return color
def _glow(self, hue, radius, level):
# Soft radial gradient, cached by (hue bucket, size, level).
radius = max(2, min(int(radius), 96))
bucket = int(hue // 6) % 60
key = (bucket, radius, level)
sprite = self._glow_cache.get(key)
if sprite is None:
color = self._hue_rgb(bucket * 6 + 3, 0.9, 1.0)
gain = _GLOW_LEVELS[level]
sprite = pygame.Surface((radius * 2, radius * 2))
sprite.fill((0, 0, 0))
for i in range(radius, 0, -1):
t = 1.0 - i / radius
k = (t ** 1.8) * gain
pygame.draw.circle(
sprite,
(
int(color[0] * k),
int(color[1] * k),
int(color[2] * k),
),
(radius, radius),
i,
)
sprite = sprite.convert()
self._glow_cache[key] = sprite
return sprite
# ========================================================
# Timing and shutdown
# ========================================================
def get_delta_time(self):
delta_time = self.clock.tick(TARGET_FPS) / 1000.0
return max(0.0, min(delta_time, 0.05))
def close(self):
pygame.mouse.set_visible(True)
pygame.quit()