Source code for simvx.core.nodes_2d.node2d

"""Node2D -- 2D spatial node with position, rotation, and scale."""

import math

import numpy as np

from .._spatial_property import _SpatialVecProperty
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) """ position = _SpatialVecProperty(2, hint="Local position") rotation = Property(0.0, on_change="_invalidate_transform", persist=True, hint="Local rotation (radians)") scale = _SpatialVecProperty(2, default=(1.0, 1.0), hint="Local scale") 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))
[docs] @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 children = list(self.children.safe_iter()) # Fast path: no CanvasLayer children (via the maintained counter) and # no Node2D child with non-zero z_index → tree order, no sort. needs_sort = self._canvas_layer_child_count > 0 if not needs_sort: for c in children: if isinstance(c, Node2D) and c.z_index != 0: needs_sort = True break if not needs_sort: self._draw_dispatch(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 c._is_canvas_layer: 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._draw_dispatch(renderer) for child in above: child._draw_recursive(renderer) for child in canvas_layers: child._draw_recursive(renderer) def __init__(self, **kwargs): # State that ``_invalidate_transform`` and the spatial Property setters # touch must exist before ``super().__init__()`` walks Property kwargs. 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 super().__init__(**kwargs) @property def rotation_degrees(self) -> float: """Local rotation in degrees (convenience for editor display).""" return math.degrees(self.rotation)
[docs] @rotation_degrees.setter def rotation_degrees(self, deg: float): self.rotation = math.radians(float(deg))
# -- 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): # Pull parent triple in one dirty-flag check instead of three. pp, ps, pr = self.parent.world_transform c, s = math.cos(pr), math.sin(pr) local = self.position * ps rotated = Vec2(local.x * c - local.y * s, local.x * s + local.y * c) self._cached_world_position = pp + rotated self._cached_world_rotation = pr + self.rotation self._cached_world_scale = ps * 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
[docs] @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)
[docs] @property def world_rotation(self) -> float: """World rotation in radians.""" if self._transform_dirty: self._recompute_global_transform() return self._cached_world_rotation
[docs] @property def world_scale(self) -> Vec2: if self._transform_dirty: self._recompute_global_transform() return self._cached_world_scale
[docs] @property def world_transform(self) -> tuple[Vec2, Vec2, float]: """All three world-transform components in one call: ``(world_position, world_scale, world_rotation)``. Hot-path callers (sprite ``on_draw``, scene-adapter walks, anything that today reads the three component properties back-to-back) should prefer this: it pays the dirty-flag check + cache materialisation exactly once instead of three times. """ if self._transform_dirty: self._recompute_global_transform() return ( self._cached_world_position, self._cached_world_scale, self._cached_world_rotation, )
[docs] @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))
[docs] @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 transform_points(self, points: list[Vec2]) -> list[Vec2]: """Transform local points by this node's position and rotation.""" c, s = math.cos(self.rotation), math.sin(self.rotation) pos = self.position return [Vec2(p.x * c - p.y * s + pos.x, p.x * s + p.y * c + pos.y) for p in points]
[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, )