Source code for simvx.core.nodes_2d.node2d

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