feat: clock + place games

This commit is contained in:
2026-09-17 01:33:54 +02:00
parent d104480188
commit 6866b45f66
46 changed files with 2043 additions and 10 deletions
+75
View File
@@ -0,0 +1,75 @@
"""Replays one event's pixel placement history into an animated GIF.
Unlike canvas.py (which only ever reflects the current state), this reads
the durable PixelPlacement history and reconstructs every intermediate
state of the grid, one frame per placement (or sampled down if there are
more placements than MAX_FRAMES), so an admin can watch that event's board
evolve.
"""
from io import BytesIO
from PIL import Image
from events.models import Event
from .models import PALETTE, PixelPlacement, PlaceSettings
SCALE = 8
MAX_FRAMES = 300
FRAME_DURATION_MS = 80
FINAL_FRAME_HOLD_MS = 3000
_PALETTE_RGB = [
tuple(int(hex_color[i : i + 2], 16) for i in (1, 3, 5)) for hex_color in PALETTE
]
def build_evolution_gif(event: Event) -> bytes:
"""Renders `event`'s full placement history as an animated GIF, upscaled
for visibility. Returns GIF bytes, even if there are zero placements.
Always replays at the *current* grid dimensions - a placement made
before a resize that now falls outside them is skipped rather than
raising."""
place_settings = PlaceSettings.current(event)
width, height = place_settings.width, place_settings.height
placements = PixelPlacement.objects.filter(event=event).order_by(
"placed_at"
).values_list("x", "y", "color_index")
placements = list(placements)
grid = bytearray(width * height)
frames: list[Image.Image] = []
step = max(1, len(placements) // MAX_FRAMES)
for i, (x, y, color_index) in enumerate(placements):
if x >= width or y >= height:
continue
grid[y * width + x] = color_index
if i % step == 0 or i == len(placements) - 1:
frames.append(_render_frame(grid, width, height))
if not frames:
frames.append(_render_frame(grid, width, height))
buffer = BytesIO()
durations = [FRAME_DURATION_MS] * len(frames)
durations[-1] = FINAL_FRAME_HOLD_MS
frames[0].save(
buffer,
format="GIF",
save_all=True,
append_images=frames[1:],
duration=durations,
loop=0,
disposal=2,
)
return buffer.getvalue()
def _render_frame(grid: bytearray, width: int, height: int) -> Image.Image:
image = Image.new("RGB", (width, height))
image.putdata([_PALETTE_RGB[c] for c in grid])
return image.resize((width * SCALE, height * SCALE), Image.NEAREST)