"""``BackgroundBands`` / ``SkyHorizon``: 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 .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
if colour_bottom is None or not hasattr(renderer, "fill_rect_gradient"):
# Solid fill: Draw2D.draw_rect takes (pos, size, ..., filled=True).
renderer.draw_rect(
(wx0, y0),
(wx1 - wx0, y1 - y0),
colour=colour_top,
filled=True,
)
else:
renderer.fill_rect_gradient(
wx0, y0, wx1 - wx0, y1 - y0,
colour_top, colour_bottom,
)