"""Node2D -- 2D spatial node with position, rotation, and scale."""
import math
import numpy as np
from .._drawable2d import Drawable2D
from .._spatial_property import _SpatialVecProperty
from ..descriptors import Property
from ..math.types import Vec2
from ..node import Node
from ..properties import get_mask_bit, set_mask_bit
def _z_key(c):
"""Sort key for the Node2D z-band: absolute z (non-Node2D children rank 0)."""
return c.absolute_z_index if isinstance(c, Node2D) else 0
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(Drawable2D, 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)."""
self.render_layer = set_mask_bit(self.render_layer, index, enabled, label="Render layer")
[docs]
def is_on_render_layer(self, index: int) -> bool:
"""Check if this node is on a specific render layer (0-31)."""
return get_mask_bit(self.render_layer, index, label="Render layer")
[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.
A ``z_index``/``z_as_relative`` change is **structural** for the item
pipeline (design §2.2 / P0 gate 9: a z flip re-keys the changed node's
parent subtree -- ``seq`` must be renumbered, not re-sorted). ``z`` is
inherited, so the change also alters every descendant's ``absolute_z_index``.
We mark render-dirty on self + descendants so the ``RenderItemCache``
re-collects (re-walk + re-sort reproduces the correct band order). The
legacy path ignores the bit.
"""
self._z_cache = None
self.queue_redraw()
for child in self.children:
if isinstance(child, Node2D):
child._invalidate_z_cache()
def _ordered_children(self):
"""Ordering KEY = ``absolute_z_index`` with the below/self/above interleave.
Node2D children with negative absolute z draw before this node; zero or
positive draw after, both ascending by absolute z. CanvasLayer children
draw LAST in their own band.
Fast path: when no CanvasLayer children and no child sets a non-zero
``z_index``, return ``(None, None)`` -- byte-identical to tree order.
"""
children = list(self.children.safe_iter())
if self._canvas_layer_child_count == 0:
if not any(isinstance(c, Node2D) and c.z_index != 0 for c in children):
return None, None
return self._banded_children(children, self._canvas_layer_child_count, z_key=_z_key)
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
# Render-retention dirty bits (Drawable2D, design §2.7). New drawables
# are dirty so the item pipeline collects + uploads them once; the legacy
# path never reads these.
self._render_dirty: bool = True
self._transform_render_dirty: bool = True
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, _from_parent: bool = False):
"""Mark this node and all descendants as needing global transform recomputation.
Also drives the SEPARATE render transform-dirty bit (Drawable2D,
design §2.7). That bit is marked **unconditionally + before** the
``_transform_dirty`` short-circuit below, with its own no-short-circuit
descendant propagation, so a read-then-move sequence (which clears
``_transform_dirty`` mid-frame) still re-dirties the render bit on self
AND descendants (P0 gate 3b). The legacy path ignores it.
``_from_parent`` is True when the invalidation is propagated down from an
ancestor's transform change (vs a direct write to THIS node). Physics
nodes use it to distinguish a teleport (direct write) from being carried
by a parent; plain nodes ignore it.
"""
self._mark_transform_render_dirty()
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(_from_parent=True)
def _propagate_transform_render_dirty(self):
"""Set the render transform-dirty bit on every 2D descendant (Drawable2D).
Geometry follows ancestor transforms, so a move dirties the transform row
of every Node2D descendant. The bit is set DIRECTLY (not via each child's
``_mark_transform_render_dirty``) so one move is a single O(subtree) walk,
not O(subtree^2). Unlike ``_invalidate_transform`` there is NO
``_transform_dirty`` short-circuit: the render bit has its own lifetime
(cleared only at upload), so it always reaches descendants even after a
mid-frame ``world_position`` read cleared the transform cache (P0 gate 3b).
"""
stack = [c for c in self.children if isinstance(c, Node2D)]
while stack:
node = stack.pop()
node._transform_render_dirty = True
stack.extend(c for c in node.children if isinstance(c, Node2D))
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)
@property
def world_rotation(self) -> float:
"""World rotation in radians."""
if self._transform_dirty:
self._recompute_global_transform()
return self._cached_world_rotation
[docs]
@world_rotation.setter
def world_rotation(self, v: float):
"""Set the local ``rotation`` so this node's WORLD rotation equals ``v`` (radians).
Symmetric counterpart to the :attr:`world_position` setter (and parity
with ``Node3D.world_rotation``): under a ``Node2D`` parent the local
rotation is ``v - parent.world_rotation``; otherwise it is ``v`` directly.
"""
if self.parent and isinstance(self.parent, Node2D):
self.rotation = float(v) - self.parent.world_rotation
else:
self.rotation = float(v)
[docs]
@property
def world_scale(self) -> Vec2:
if self._transform_dirty:
self._recompute_global_transform()
return self._cached_world_scale
[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 draw_polygon(self, renderer, points: list[Vec2], closed=True, colour=None):
"""Draw a polygon transformed into this node's world space."""
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,
)