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