"""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)