"""Node2D -- 2D spatial node with position, rotation, and scale."""
from __future__ import annotations
import math
import numpy as np
from ..descriptors import Property
from ..math.types import Vec2
from ..node import Node
class _ObservedVec2(Vec2):
"""Vec2 subclass that calls ``_notify`` on in-place mutation."""
def __new__(cls, *args, _notify=None, **kwargs):
obj = super().__new__(cls, *args, **kwargs)
obj._notify = _notify
return obj
def __array_finalize__(self, obj):
self._notify = getattr(obj, "_notify", None)
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
args = [np.asarray(i) if isinstance(i, Vec2) else i for i in inputs]
out = kwargs.pop("out", None)
if out is not None:
kwargs["out"] = tuple(np.asarray(o) if isinstance(o, Vec2) else o for o in out)
result = getattr(ufunc, method)(*args, **kwargs)
if isinstance(result, np.ndarray) and result.shape == (2,):
return result.view(Vec2)
return result
def __setitem__(self, key, value):
super().__setitem__(key, value)
if self._notify:
self._notify()
@Vec2.x.setter
def x(self, val):
self[0] = val
@Vec2.y.setter
def y(self, val):
self[1] = val
[docs]
class Node2D(Node):
"""2D spatial node with position, rotation, and scale.
Extends ``Node`` with a 2D transform (position, rotation, scale) and
cached world-transform propagation. All spatial 2D nodes -- sprites,
cameras, collision shapes -- inherit from this.
Attributes:
position: Local position as ``Vec2`` (pixels).
rotation: Local rotation in radians (float).
scale: Local scale as ``Vec2`` (default ``(1, 1)``).
z_index: Draw order relative to siblings (higher = on top).
z_as_relative: When ``True`` (default), ``z_index`` is added to the
parent's absolute z-index.
Example::
player = Node2D(position=(100, 200), rotation=0.0, name="Player")
player.position += Vec2(10, 0)
print(player.world_position)
"""
z_index = Property(0, range=(-4096, 4096), hint="Draw order (higher = on top)", on_change="_invalidate_z_cache")
z_as_relative = Property(True, hint="z_index relative to parent", on_change="_invalidate_z_cache")
render_layer = Property(1, range=(0, 0xFFFFFFFF), hint="Render layer bitmask (32 layers)")
[docs]
def set_render_layer(self, index: int, enabled: bool = True) -> None:
"""Enable or disable a specific render layer (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Render layer index must be 0-31, got {index}")
if enabled:
self.render_layer = self.render_layer | (1 << index)
else:
self.render_layer = self.render_layer & ~(1 << index)
[docs]
def is_on_render_layer(self, index: int) -> bool:
"""Check if this node is on a specific render layer (0-31)."""
if not 0 <= index < 32:
raise ValueError(f"Render layer index must be 0-31, got {index}")
return bool(self.render_layer & (1 << index))
@property
def absolute_z_index(self) -> int:
"""Compute absolute z-index walking up the tree (cached)."""
if self._z_cache is not None:
return self._z_cache
z = self.z_index
if self.z_as_relative and self.parent and isinstance(self.parent, Node2D):
z += self.parent.absolute_z_index
self._z_cache = z
return z
def _invalidate_z_cache(self):
"""Invalidate z_index cache on self and all Node2D descendants."""
self._z_cache = None
for child in self.children:
if isinstance(child, Node2D):
child._invalidate_z_cache()
def _draw_recursive(self, renderer):
"""Override: sort children by absolute_z_index for correct draw order.
CanvasLayer children always draw after all world-space content (sorted
by their ``layer`` property). Node2D children with negative z_index
draw before the parent; zero/positive draw after (ascending).
Fast path when no child needs reordering (all z_index == 0, no CanvasLayers).
"""
if not self.visible:
return
if self._script_error:
for child in self.children.safe_iter():
child._draw_recursive(renderer)
return
from .canvas import CanvasLayer
children = list(self.children.safe_iter())
# Fast path: no child has non-zero z_index and no CanvasLayers → tree order
needs_sort = False
for c in children:
if isinstance(c, CanvasLayer):
needs_sort = True
break
if isinstance(c, Node2D) and c.z_index != 0:
needs_sort = True
break
if not needs_sort:
self._safe_call(self.draw, renderer)
for child in children:
child._draw_recursive(renderer)
return
# Separate CanvasLayers from world-space children
canvas_layers = []
below = [] # negative z_index — draw before parent
above = [] # zero/positive z_index — draw after parent
for c in children:
if isinstance(c, CanvasLayer):
canvas_layers.append(c)
elif isinstance(c, Node2D) and c.absolute_z_index < 0:
below.append(c)
else:
above.append(c)
def _z_key(c):
return c.absolute_z_index if isinstance(c, Node2D) else 0
below.sort(key=_z_key)
above.sort(key=_z_key)
canvas_layers.sort(key=lambda c: c.layer)
for child in below:
child._draw_recursive(renderer)
self._safe_call(self.draw, renderer)
for child in above:
child._draw_recursive(renderer)
for child in canvas_layers:
child._draw_recursive(renderer)
def __init__(self, position=None, rotation: float = 0.0, scale=None, **kwargs):
if position is not None and not isinstance(position, (Vec2, tuple, list, np.ndarray)):
raise TypeError(f"Node2D position must be Vec2, tuple, list, or ndarray, got {type(position).__name__}")
if not isinstance(rotation, (int, float)):
raise TypeError(f"Node2D rotation must be a number (radians), got {type(rotation).__name__}")
if scale is not None and not isinstance(scale, (Vec2, tuple, list, np.ndarray)):
raise TypeError(f"Node2D scale must be Vec2, tuple, list, or ndarray, got {type(scale).__name__}")
super().__init__(**kwargs)
self._position = _ObservedVec2(position if position else (0, 0), _notify=self._invalidate_transform)
self._rotation: float = float(rotation) # radians
self._scale = _ObservedVec2(scale if scale else (1, 1), _notify=self._invalidate_transform)
self._transform_dirty: bool = True
self._cached_world_position: Vec2 | None = None
self._cached_world_rotation: float | None = None
self._cached_world_scale: Vec2 | None = None
self._z_cache: int | None = None
# -- Local transform properties with dirty invalidation --
@property
def position(self) -> Vec2:
return self._position
@position.setter
def position(self, v):
self._position = _ObservedVec2(v, _notify=self._invalidate_transform)
self._invalidate_transform()
@property
def rotation(self) -> float:
"""Local rotation in radians."""
return self._rotation
@rotation.setter
def rotation(self, v: float):
self._rotation = float(v)
self._invalidate_transform()
@property
def rotation_degrees(self) -> float:
"""Local rotation in degrees (convenience for editor display)."""
return math.degrees(self._rotation)
@rotation_degrees.setter
def rotation_degrees(self, deg: float):
self._rotation = math.radians(float(deg))
self._invalidate_transform()
@property
def scale(self) -> Vec2:
return self._scale
@scale.setter
def scale(self, v):
self._scale = _ObservedVec2(v, _notify=self._invalidate_transform)
self._invalidate_transform()
# -- Transform cache invalidation --
def _enter_tree(self, tree):
super()._enter_tree(tree)
self._invalidate_z_cache()
def _invalidate_transform(self):
"""Mark this node and all descendants as needing global transform recomputation."""
if not self._transform_dirty:
self._transform_dirty = True
self._z_cache = None # Also clear z_index cache on reparenting/transform changes
for child in self.children:
if isinstance(child, Node2D) and not child._transform_dirty:
child._invalidate_transform()
def _recompute_global_transform(self):
"""Recompute and cache all world transform components."""
if self.parent and isinstance(self.parent, Node2D):
p = self.parent
pg_rot = p.world_rotation
c, s = math.cos(pg_rot), math.sin(pg_rot)
pg_scale = p.world_scale
local = self._position * pg_scale
rotated = Vec2(local.x * c - local.y * s, local.x * s + local.y * c)
self._cached_world_position = p.world_position + rotated
self._cached_world_rotation = pg_rot + self._rotation
self._cached_world_scale = pg_scale * self._scale
else:
self._cached_world_position = Vec2(self._position)
self._cached_world_rotation = self._rotation
self._cached_world_scale = Vec2(self._scale)
self._transform_dirty = False
# -- World transform properties (cached) --
@property
def world_position(self) -> Vec2:
if self._transform_dirty:
self._recompute_global_transform()
return self._cached_world_position
@world_position.setter
def world_position(self, v: tuple[float, float] | np.ndarray):
if self.parent and isinstance(self.parent, Node2D):
p = self.parent
diff = v - p.world_position
angle = -p.world_rotation
c, s = math.cos(angle), math.sin(angle)
unrotated = Vec2(diff.x * c - diff.y * s, diff.x * s + diff.y * c)
self.position = unrotated / p.world_scale
else:
self.position = Vec2(v)
@property
def world_rotation(self) -> float:
"""World rotation in radians."""
if self._transform_dirty:
self._recompute_global_transform()
return self._cached_world_rotation
@property
def world_scale(self) -> Vec2:
if self._transform_dirty:
self._recompute_global_transform()
return self._cached_world_scale
@property
def forward(self) -> Vec2:
"""Unit vector pointing in the direction of rotation (up = -Y in screen coords)."""
angle = self.world_rotation
return Vec2(math.sin(angle), -math.cos(angle))
@property
def right(self) -> Vec2:
angle = self.world_rotation
return Vec2(math.cos(angle), math.sin(angle))
[docs]
def translate(self, offset: tuple[float, float] | np.ndarray):
self.position = self._position + offset
[docs]
def rotate(self, radians: float):
"""Rotate by the given number of radians."""
self.rotation = self._rotation + radians
[docs]
def rotate_deg(self, degrees: float):
"""Rotate by the given number of degrees."""
self.rotation = self._rotation + math.radians(degrees)
[docs]
def look_at(self, target: tuple[float, float] | np.ndarray):
diff = target - self.world_position
self.rotation = math.atan2(diff.x, -diff.y)
# --- Drawing helpers ---
[docs]
def draw_polygon(self, renderer, points: list[Vec2], closed=True, colour=None):
"""Draw a polygon transformed by this node's position/rotation."""
renderer.draw_lines(self.transform_points(points), closed=closed, colour=colour)
# --- Screen wrapping ---
[docs]
def wrap_screen(self, margin: float = 20):
"""Wrap position around screen edges."""
if not self._tree:
return
sw, sh = self._tree.screen_size
px = self._position.x
py = self._position.y
self.position = Vec2(
(px + margin) % (sw + margin * 2) - margin,
(py + margin) % (sh + margin * 2) - margin,
)