Files
nissaphotobooth/falling_photos.py
2026-07-18 15:17:37 -04:00

112 lines
3.8 KiB
Python

"""Falling photos backdrop for the idle/title screen.
Saved photos from the photos/ folder drift down from the top of the
screen with a small white border, looping forever. They are drawn behind
the title text. Thumbnails are loaded once and cached by file path; the
cache is pruned when photos are deleted (after a print cycle).
"""
import math
import random
import pygame
THUMB_H = 140
BORDER = 6
NUM_PHOTOS = 10
MIN_SPEED, MAX_SPEED = 30, 90 # px/sec downward
DRIFT_AMP = 40
ROT_MIN, ROT_MAX = -12.5, 12.5 # degrees (halved)
SPIN_MIN, SPIN_MAX = -10, 10 # deg/sec (halved)
def _color_with_border(src):
"""Scale src to THUMB_H tall, add a white BORDER, return (surf, w, h)."""
w0, h0 = src.get_size()
new_w = max(1, int(w0 * THUMB_H / h0))
scaled = pygame.transform.smoothscale(src, (new_w, THUMB_H))
bw, bh = new_w + 2 * BORDER, THUMB_H + 2 * BORDER
surf = pygame.Surface((bw, bh), pygame.SRCALPHA)
surf.fill((255, 255, 255, 255))
surf.blit(scaled, (BORDER, BORDER))
return surf, bw, bh
class _Sprite:
def __init__(self, screen_size, picker):
self.screen_w, self.screen_h = screen_size
self.picker = picker
self._respawn(top=True)
def _respawn(self, top=False):
self.image, self.w, self.h = self.picker()
self.angle = random.uniform(ROT_MIN, ROT_MAX)
self.spin = random.uniform(SPIN_MIN, SPIN_MAX)
self.speed = random.uniform(MIN_SPEED, MAX_SPEED)
self.drift_amp = random.uniform(0, DRIFT_AMP)
self.drift_phase = random.uniform(0, math.tau)
self.drift_speed = random.uniform(0.4, 1.2)
if top:
self.x = random.uniform(-self.w, self.screen_w)
self.y = random.uniform(-self.h * 3, -self.h)
else:
self.x = random.uniform(0, self.screen_w - self.w)
self.y = -self.h
def update(self, dt):
self.y += self.speed * dt
self.drift_phase += self.drift_speed * dt
self.angle += self.spin * dt
if self.y > self.screen_h + self.h:
self._respawn()
def draw(self, screen):
ox = math.sin(self.drift_phase) * self.drift_amp
rotated = pygame.transform.rotate(self.image, self.angle)
rect = rotated.get_rect(
center=(self.x + self.w / 2 + ox, self.y + self.h / 2))
screen.blit(rotated, rect)
class FallingPhotos:
def __init__(self, screen_size, photos_dir):
self.screen_size = screen_size
self.photos_dir = photos_dir
self._cache = {} # path -> (bordered_surf, bw, bh)
self._paths = []
self._scan()
self.sprites = [_Sprite(screen_size, self._pick) for _ in range(NUM_PHOTOS)]
def _scan(self):
self._paths = (sorted(self.photos_dir.glob("*.jpg"))
if self.photos_dir.exists() else [])
self._cache = {p: v for p, v in self._cache.items() if p in self._paths}
def reload(self):
"""Re-scan the photos folder (call after photos are deleted)."""
self._scan()
def _pick(self):
if not self._paths:
surf = pygame.Surface((THUMB_H, THUMB_H), pygame.SRCALPHA)
surf.fill((0, 0, 0, 0))
return surf, THUMB_H, THUMB_H
path = random.choice(self._paths)
if path not in self._cache:
try:
src = pygame.image.load(str(path)).convert_alpha()
except (pygame.error, OSError):
self._paths = [p for p in self._paths if p != path]
return self._pick()
self._cache[path] = _color_with_border(src)
return self._cache[path]
def update(self, dt):
if not self._paths:
self._scan()
for s in self.sprites:
s.update(dt)
def draw(self, screen):
for s in self.sprites:
s.draw(screen)