Spaces:
Running
Running
| from __future__ import annotations | |
| from dataclasses import dataclass | |
| from typing import Iterable, Optional | |
| import pygame | |
| from src.assets import AssetLibrary | |
| 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 | |