from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Optional import pygame from src.assets import AssetLibrary @dataclass(frozen=True) class LayerSpec: path_parts: tuple[str, ...] speed_factor: float alpha: int = 255 colorkey: tuple[int, int, int] | None = None class ParallaxBackground: """ Parallax background using Foozle environment pack layers. Source images are very wide strips (e.g., 5760x360). We scale each layer to screen height and tile horizontally with a scrolling offset. """ def __init__(self, screen_size: tuple[int, int], assets: AssetLibrary): self.w, self.h = screen_size self.assets = assets self.base_speed = 80.0 # px/sec; tuned for current prototype self.offset = 0.0 self.layers: list[tuple[pygame.Surface, float]] = [] self._apply_layer_specs(list(self._default_layers())) self.planet: Optional[pygame.Surface] = self._load_planet() self.planet_pos = pygame.Vector2(self.w * 0.75, self.h * 0.25) self.planet_speed = 10.0 self.tint_mult = (255, 255, 255) self._tint_surface: Optional[pygame.Surface] = None self.nebula: Optional[pygame.Surface] = None self.nebula_factor: float = 0.10 def set_tint_mult(self, rgb: tuple[int, int, int]) -> None: self.tint_mult = (int(rgb[0]), int(rgb[1]), int(rgb[2])) if self.tint_mult == (255, 255, 255): self._tint_surface = None return self._tint_surface = pygame.Surface((self.w, self.h)) self._tint_surface.fill(self.tint_mult) def _default_layers(self) -> Iterable[LayerSpec]: # Prefer Split up layers for flexibility. base = ( "Foozle_2DS0015_Void_EnvironmentPack", "Backgrounds", "PNGs", "Split up", ) return [ LayerSpec(base + ("Starry background - Layer 01 - Solid colour.png",), 0.15), LayerSpec(base + ("Starry background - Layer 02 - Shadows.png",), 0.35), LayerSpec(base + ("Starry background - Layer 03 - Stars.png",), 0.70), ] def _apply_layer_specs(self, specs: list[LayerSpec]) -> None: self.layers = [] for spec in specs: surf = self._load_layer(spec.path_parts) if surf is None: continue if spec.colorkey is not None: surf.set_colorkey(spec.colorkey) if spec.alpha != 255: surf.set_alpha(spec.alpha) self.layers.append((surf, spec.speed_factor)) def set_layers(self, specs: Iterable[LayerSpec]) -> None: self._apply_layer_specs(list(specs)) def _load_layer(self, parts: tuple[str, ...]) -> Optional[pygame.Surface]: path = self.assets.resolve("environment_pack", *parts[1:]) if parts[0] == "Foozle_2DS0015_Void_EnvironmentPack" else self.assets.resolve(parts[0], *parts[1:]) if not path.exists(): return None img = self.assets.load_image(path) # Scale to screen height, preserve aspect ratio. scale = self.h / img.get_height() new_w = max(1, int(img.get_width() * scale)) scaled = pygame.transform.smoothscale(img, (new_w, self.h)) return scaled def _load_planet(self) -> Optional[pygame.Surface]: path = self.assets.resolve( "environment_pack", "Planets", "PNGs", "Earth-Like planet.png", ) if not path.exists(): return None img = self.assets.load_image(path) target = 140 scale = target / max(1, img.get_width()) w = max(1, int(img.get_width() * scale)) h = max(1, int(img.get_height() * scale)) return pygame.transform.smoothscale(img, (w, h)) def set_planet_variant(self, variant: str, *, scale: float = 1.0) -> None: if variant == "none": self.planet = None return planet_file = "Earth-Like planet.png" if variant == "glow" else "Earth-Like planet - Without back glow.png" path = self.assets.resolve("environment_pack", "Planets", "PNGs", planet_file) if not path.exists(): self.planet = None return img = self.assets.load_image(path) target = int(140 * max(0.5, float(scale))) s = target / max(1, img.get_width()) w = max(1, int(img.get_width() * s)) h = max(1, int(img.get_height() * s)) self.planet = pygame.transform.smoothscale(img, (w, h)) self.planet_pos = pygame.Vector2(self.w * 0.75, self.h * 0.25) def set_nebula( self, *, seed: int | None, colors: tuple[tuple[int, int, int], ...], alpha: int = 80, factor: float = 0.10, ) -> None: self.nebula_factor = float(factor) if seed is None or not colors: self.nebula = None return import random rng = random.Random(int(seed)) surf = pygame.Surface((self.w * 2, self.h), pygame.SRCALPHA) surf.fill((0, 0, 0, 0)) for _ in range(30): col = rng.choice(list(colors)) radius = rng.randint(90, 240) x = rng.randint(-radius, surf.get_width() + radius) y = rng.randint(-radius, self.h + radius) a = rng.randint(max(20, int(alpha) - 40), min(150, int(alpha) + 35)) pygame.draw.circle(surf, (int(col[0]), int(col[1]), int(col[2]), a), (x, y), radius) self.nebula = surf def set_planet_from_layer(self, path_parts: tuple[str, ...], *, scale: float = 1.0) -> None: try: path = self.assets.resolve(path_parts[0], *path_parts[1:]) except Exception: self.planet = None return if not path.exists(): self.planet = None return img = self.assets.load_image(path) target = int(140 * max(0.5, float(scale))) s = target / max(1, img.get_width()) w = max(1, int(img.get_width() * s)) h = max(1, int(img.get_height() * s)) self.planet = pygame.transform.smoothscale(img, (w, h)) self.planet_pos = pygame.Vector2(self.w * 0.75, self.h * 0.25) def configure_theme( self, *, shadows_variant: int = 0, stars_variant: int = 0, extras: tuple[str, ...] = (), tint_mult: tuple[int, int, int] = (255, 255, 255), planet_variant: str = "glow", # glow | noglow | none planet_scale: float = 1.0, nebula_seed: int | None = None, nebula_colors: tuple[tuple[int, int, int], ...] = (), nebula_alpha: int = 80, ) -> None: base = ("Foozle_2DS0015_Void_EnvironmentPack", "Backgrounds", "PNGs", "Split up") shadows_file = "Starry background - Layer 02 - Shadows.png" if int(shadows_variant) == 0 else "Starry background - Layer 02 - Shadows 2.png" stars_file = "Starry background - Layer 03 - Stars.png" if int(stars_variant) == 0 else "Starry background - Layer 03 - Stars 2.png" specs: list[LayerSpec] = [ LayerSpec(base + ("Starry background - Layer 01 - Solid colour.png",), 0.15), LayerSpec(base + (shadows_file,), 0.35), LayerSpec(base + (stars_file,), 0.70), ] extra_map = { "big_star": "Starry background - Layer X - Big Star.png", "big_star_2": "Starry background - Layer X - Big Star 2.png", "black_hole": "Starry background - Layer X -Black hole.png", "rotary_star": "Starry background - Layer X -Rotary Star.png", "rotary_star_2": "Starry background - Layer X -Rotary Star 2.png", } for key in extras: name = extra_map.get(str(key)) if name: # Extras should be subtle; keep low opacity to avoid dominating the scene. specs.append(LayerSpec(base + (name,), 0.10, alpha=120)) self._apply_layer_specs(specs) self.set_tint_mult(tint_mult) # Planet if planet_variant == "none": self.planet = None else: planet_file = "Earth-Like planet.png" if planet_variant == "glow" else "Earth-Like planet - Without back glow.png" path = self.assets.resolve("environment_pack", "Planets", "PNGs", planet_file) if path.exists(): img = self.assets.load_image(path) target = int(140 * max(0.5, float(planet_scale))) scale = target / max(1, img.get_width()) w = max(1, int(img.get_width() * scale)) h = max(1, int(img.get_height() * scale)) self.planet = pygame.transform.smoothscale(img, (w, h)) else: self.planet = None self.planet_pos = pygame.Vector2(self.w * 0.75, self.h * 0.25) # Nebula overlay (procedural), wide so it tiles. self.set_nebula(seed=nebula_seed, colors=nebula_colors, alpha=nebula_alpha, factor=self.nebula_factor) def update(self, dt: float): self.offset += self.base_speed * dt if self.offset > 1_000_000: self.offset = 0.0 if self.planet is not None: self.planet_pos.x -= self.planet_speed * dt if self.planet_pos.x < -200: self.planet_pos.x = self.w + 200 def draw(self, surface: pygame.Surface): for layer, factor in self.layers: self._draw_tiled(surface, layer, self.offset * factor) if self.planet is not None: rect = self.planet.get_rect(center=(int(self.planet_pos.x), int(self.planet_pos.y))) surface.blit(self.planet, rect) if self.nebula is not None: self._draw_tiled(surface, self.nebula, self.offset * self.nebula_factor) if self._tint_surface is not None: surface.blit(self._tint_surface, (0, 0), special_flags=pygame.BLEND_RGB_MULT) def _draw_tiled(self, surface: pygame.Surface, img: pygame.Surface, x_offset: float): w = img.get_width() # Scroll left-to-right: subtract offset for left movement. x = -int(x_offset) % w x -= w while x < self.w: surface.blit(img, (x, 0)) x += w