Source code for simvx.core.nodes_2d.background_bands

"""``BackgroundBands``: horizontal colour bands relative to the camera.

Renders a stack of full-width horizontal stripes that fill the visible
viewport. Useful for sky / sea / cave-tier backdrops where you want a few
solid bands (or gradient bands) without authoring a backdrop texture.

Two scroll modes:

* ``world_space=True`` (default): bands are anchored at fixed world-Y
  values: the camera can scroll past them, so a "horizon" stays put.
* ``world_space=False``: bands lock to the screen: the sky always stays
  at the top of the viewport, irrespective of camera Y. Equivalent to
  parenting a ``Node2D`` under a ``CanvasLayer``.

A band entry is ``(top_y, colour, [bottom_colour])``:

* ``top_y``: Y in *the chosen space* (world or screen) for the top of
  this band. Bands are sorted ascending; each band extends down to the
  next band's ``top_y`` (or the viewport bottom for the last band).
* ``colour``: solid RGBA fill, or top colour for a vertical gradient.
* ``bottom_colour``: optional; if given, the band is a vertical gradient
  from ``colour`` (top) to ``bottom_colour`` (bottom).
"""

from __future__ import annotations

from collections.abc import Sequence

from ..descriptors import Property
from ..gradient import Gradient
from .node2d import Node2D

Band = tuple[float, tuple[float, ...], tuple[float, ...]] | tuple[float, tuple[float, ...]]


[docs] class BackgroundBands(Node2D): """Horizontal colour bands that follow the camera. Example: sky / sea horizon at world-Y = 0:: bg = root.add_child(BackgroundBands()) bg.bands = [ (-1e9, (0.4, 0.7, 1.0, 1.0), (0.7, 0.85, 1.0, 1.0)), # sky (0.0, (0.1, 0.3, 0.6, 1.0), (0.05, 0.15, 0.4, 1.0)), # sea ] """ world_space = Property(True, hint="If True, bands anchor to world Y; else to screen Y") def __init__(self, bands: Sequence[Band] | None = None, world_space: bool = True, **kwargs): super().__init__(**kwargs) self._bands: list[Band] = list(bands) if bands else [] self.world_space = world_space @property def bands(self) -> list[Band]: return list(self._bands)
[docs] @bands.setter def bands(self, value: Sequence[Band]) -> None: self._bands = list(value)
[docs] def add_band(self, top_y: float, colour: tuple[float, ...], bottom_colour: tuple[float, ...] | None = None) -> None: """Append a band entry. Bands are sorted by ``top_y`` at draw time.""" if bottom_colour is None: self._bands.append((top_y, colour)) else: self._bands.append((top_y, colour, bottom_colour))
[docs] def clear_bands(self) -> None: self._bands.clear()
def _viewport_world_rect(self) -> tuple[float, float, float, float] | None: """Return (min_x, min_y, max_x, max_y) of the visible area in world space.""" if self._tree is None: return None sw, sh = self._tree._screen_size cam = self._tree._current_camera_2d if cam is not None: zoom = cam.zoom if cam.zoom > 0 else 1.0 cx, cy = cam.current.x, cam.current.y half_w, half_h = (sw / zoom) * 0.5, (sh / zoom) * 0.5 return cx - half_w, cy - half_h, cx + half_w, cy + half_h return 0.0, 0.0, float(sw), float(sh)
[docs] def on_draw(self, renderer) -> None: """Emit the band rects covering the visible viewport.""" if not self._bands or not self.visible: return rect = self._viewport_world_rect() if rect is None: return wx0, wy0, wx1, wy1 = rect # Sort bands ascending by their top-Y. sorted_bands = sorted(self._bands, key=lambda b: b[0]) # In screen-locked mode, transform band Y values from screen space to # the *current* world top by adding the world-space view top. if not self.world_space: offset = wy0 sorted_bands = [(b[0] + offset, *b[1:]) for b in sorted_bands] # Clip band stack to the viewport range. view_top, view_bottom = wy0, wy1 # Compute each band's [top, bottom] interval inside the viewport. n = len(sorted_bands) for i, band in enumerate(sorted_bands): top_y = band[0] bottom_y = sorted_bands[i + 1][0] if i + 1 < n else view_bottom # Clip the band's Y range to the viewport. y0 = max(top_y, view_top) y1 = min(bottom_y, view_bottom) if y1 <= y0: continue colour_top = band[1] colour_bottom = band[2] if len(band) > 2 else None self._emit_band(renderer, wx0, y0, wx1, y1, colour_top, colour_bottom)
def _emit_band(self, renderer, x0, y0, x1, y1, colour_top, colour_bottom) -> None: """Fill one band given in world space, mapped into this node's space. The renderer maps the node's space back to the world, so the band lands on the viewport wherever the node sits. Under a rotated ancestor the band is no longer axis-aligned here, so it is filled as a quad in its top colour. """ _a, b, c, _d, _tx, _ty = self._draw_affine() if b or c: corners = [self.to_local((x, y)) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] renderer.polygon([(float(p.x), float(p.y)) for p in corners], fill=colour_top) return p0, p1 = self.to_local((x0, y0)), self.to_local((x1, y1)) lx, ly = float(min(p0.x, p1.x)), float(min(p0.y, p1.y)) w, h = float(abs(p1.x - p0.x)), float(abs(p1.y - p0.y)) if colour_bottom is None: renderer.rect((lx, ly), (w, h), fill=colour_top) else: renderer.rect((lx, ly), (w, h), fill=Gradient(colour_top, colour_bottom))