966 lines
26 KiB
Python
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()
|