Source code for simvx.core.node

"""Node: Base node class with tree hierarchy, groups, and coroutine support."""

import ast
import difflib
import functools
import inspect
import logging
import sys
import textwrap
from collections.abc import Callable, Iterator, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, overload

from .decorators import collect_hooks
from .descriptors import Children, Coroutine, CoroutineHandle, NodeNotFound, Notification, Property, UpdateMode
from .events import InputEvent, TreeInputEvent
from .signals import Signal

log = logging.getLogger(__name__)

if TYPE_CHECKING:
    from .scene_tree import SceneTree

# Lazy-cached reference for circular import
_ui_Control: type | None = None


def _get_control() -> type:
    global _ui_Control
    if _ui_Control is None:
        from .ui import Control

        _ui_Control = Control
    return _ui_Control


def _where_declared(klass: type) -> str:
    """Where a class was declared, said the way a developer would look for it.

    A scene file's module name is derived from its path and reads as noise, so
    prefer the file itself; fall back to the module name for a class whose
    module is not (yet) importable.
    """
    module = sys.modules.get(klass.__module__)
    path = getattr(module, "__file__", None)
    return f"{path} ({klass.__qualname__})" if path else f"{klass.__module__}.{klass.__qualname__}"


def _init_calls_super(func) -> bool:
    """Check via AST whether *func* contains a super().__init__(...) call."""
    try:
        source = textwrap.dedent(inspect.getsource(func))
        tree = ast.parse(source)
    except (OSError, TypeError, IndentationError):
        return True  # Cannot inspect: assume user handles super
    for node in ast.walk(tree):
        if not isinstance(node, ast.Call):
            continue
        f = node.func
        # super().__init__(...) or super(Cls, self).__init__(...)
        if isinstance(f, ast.Attribute) and f.attr == "__init__" and isinstance(f.value, ast.Call):
            inner = f.value
            if isinstance(inner.func, ast.Name) and inner.func.id == "super":
                return True
    return False


#: The class-level namespaces a node's managed state is declared in.
#:
#: Managed state is state some machinery other than plain attribute lookup
#: maintains: a ``Property`` (validated, inspector-visible, scene-emitted), a
#: plain ``property`` (a computed accessor over other state), or one of the
#: three ``_ThemeAttr`` registries (a value read from the active theme unless
#: overridden). Each keeps the value somewhere other than the class attribute
#: the name is bound to, which is what makes a class-level rebinding of the
#: name silent rather than loud: see :func:`_managed_kind`.
_THEME_REGISTRIES: tuple[str, ...] = ("__theme_colours__", "__theme_sizes__", "__theme_styleboxes__")


def _managed_kind(owner: type, name: str) -> str | None:
    """How ``owner``'s own class body declares ``name``, or ``None`` if it does not.

    Answers for one class, not for its bases: the caller walks the MRO. Only
    the class's own ``__dict__`` is consulted, because an inherited declaration
    belongs to the class that made it and would otherwise be reported against
    every class below it.
    """
    declared = owner.__dict__.get(name)
    if isinstance(declared, Property):
        return "the Property"
    if isinstance(declared, property):
        return "the property"
    for registry in _THEME_REGISTRIES:
        if name in owner.__dict__.get(registry, ()):
            return "the theme attribute"
    return None


def _reject_shadowed_managed_names(cls: type) -> None:
    """Refuse a subclass that rebinds an inherited managed name to a plain value.

    ``class DemoRunner(Node): update_mode = UpdateMode.ALWAYS`` reads back as
    ``ALWAYS`` and changes nothing: the class attribute replaces the descriptor,
    while the engine keeps reading the storage behind it, which still holds the
    default. The mismatch is invisible at the assignment and shows up much later
    as behaviour that ignores a value the class plainly states, so it is refused
    where it is written.

    Only a plain value is refused. Rebinding the name to another descriptor is
    how a subclass legitimately redeclares managed state -- a ``Property`` with
    a different default, an overriding ``property``, a ``ThemeColour`` naming a
    different theme key -- so anything with a ``__get__`` is left alone, methods
    included.
    """
    for name, value in cls.__dict__.items():
        if name.startswith("__") or hasattr(type(value), "__get__"):
            continue
        for base in cls.__mro__[1:]:
            kind = _managed_kind(base, name)
            if kind is None:
                continue
            raise TypeError(
                f"{cls.__name__}.{name} = {value!r} shadows {kind} declared on "
                f"{base.__name__}: the class attribute replaces the descriptor, so reads "
                f"see this value while the engine goes on reading the storage behind it. "
                f"Assign it on the instance (self.{name} = ...), or redeclare it as a "
                f"descriptor of its own to change the default."
            )


T = TypeVar("T", bound="Node")
D = TypeVar("D")


class _NoDefault:
    """Marks "no default given" for :meth:`Node.node_at`, so ``None`` can be one."""

    def __repr__(self) -> str:
        return "<no default>"


_NO_DEFAULT = _NoDefault()


def _node_matcher(target: type[Node] | str | Callable[[Node], bool]) -> Callable[[Node], bool]:
    """Build a predicate from a :meth:`Node.find` target.

    A Node subclass matches by ``isinstance``, a ``str`` matches an exact
    ``name``, and a callable is used directly as the predicate.
    """
    if isinstance(target, type):
        return lambda n: isinstance(n, target)
    if isinstance(target, str):
        return lambda n: n.name == target
    if callable(target):
        return target
    raise TypeError(f"find() target must be a Node subclass, a name str, or a predicate; got {type(target).__name__}")


#: How many declared names to list when a misspelt kwarg has no near miss. A
#: ``Control`` subclass declares dozens, and a wall of them buries the error it
#: is attached to.
_KWARG_NAMES_SHOWN = 12


def _unknown_kwarg_message(cls: type, key: str, props: Any) -> str:
    """The ``TypeError`` text for a kwarg the class does not declare.

    A bare "unknown kwarg" names nothing to try, and the name a caller wants is
    usually one edit away: a typo, or a spelling the engine has since retired.
    Scenes are ``.py`` files that construct nodes by keyword, so this message is
    what a player sees when an older scene meets a newer engine, and pointing at
    the replacement is the difference between a rename and an investigation.

    A suggestion is only ever offered when one exists. Where a name was removed
    outright rather than renamed there is nothing close to it, and inventing a
    plausible-looking substitute would be worse than saying so, hence the
    fallback to listing what the class does declare.
    """
    declared = sorted(props)
    near = difflib.get_close_matches(key, declared, n=3)
    head = f"{cls.__name__}: unknown kwarg {key!r}"
    if near:
        options = " or ".join(repr(n) for n in near)
        return f"{head}. Did you mean {options}?"
    if not declared:
        return f"{head}, and it declares no Property at all."
    shown = ", ".join(declared[:_KWARG_NAMES_SHOWN])
    more = "" if len(declared) <= _KWARG_NAMES_SHOWN else f", and {len(declared) - _KWARG_NAMES_SHOWN} more"
    return f"{head}, and nothing it declares is close to it. {cls.__name__} declares: {shown}{more}."


[docs] class Node: """Base node with tree hierarchy, groups, and coroutine support. Attributes: name: Unique name within the parent's children. Defaults to the class name. parent: The parent ``Node``, or ``None`` if this is the root. children: Ordered collection of child nodes, accessible by name or index. visible: Whether this node (and its descendants) should be drawn. update_mode: Controls processing behaviour during pause (``INHERIT``, ``PAUSABLE``, ``PAUSED_ONLY``, ``ALWAYS``, ``DISABLED``). unique_name: When ``True``, the node is registered in the tree for fast lookup via ``SceneTree.get_unique_node()``. Example:: root = Node(name="Root") child = Node(name="Child") root.add_child(child) assert child.parent is root assert root.children["Child"] is child """ _registry: ClassVar[dict[str, type]] = {} #: Bumped by every subclass declaration, including one that rebinds a name #: already in the registry. A cache over the registry keys on this: the #: registry's own length does not move when a reload replaces a class, and #: the replacement may declare different properties. _registry_version: ClassVar[int] = 0 # Raise on script errors; set False for release. Governs EVERY exception class a # hook can raise, ``AssertionError`` included: an assertion inside a hook is fatal # in the default dev/test mode and contained per node when the flag is cleared. # A carve-out for ``assert`` would be a runtime contract the language does not # offer, since ``python -O`` removes the statement outright. strict_errors: ClassVar[bool] = True #: Whether to run the engine's own internal consistency checks: the ones that #: cost something on every frame and exist to catch an engine defect, not a #: game's. Distinct from ``strict_errors``, which is a policy about a user #: script's exceptions; the two were one flag and meant two unrelated things, #: so clearing it for a game's benefit silently disarmed engine diagnostics. #: #: Defaults to ``sys.flags.dev_mode`` (``python -X dev``), which is what #: asyncio keys its equivalent on and is False in an ordinary run. It is #: deliberately NOT ``__debug__``, which is True unless ``python -O`` is #: passed and would therefore leave these checks armed in most shipped games. dev_checks: ClassVar[bool] = sys.flags.dev_mode script_error_raised = Signal() # emits (node, method_name, traceback_str) # -- 2D render-retention contract ---------------------------------------- # ``on_draw`` lives on the base ``Node`` (any node -- plain HUD/menu, Node2D, # CanvasLayer, even a 3D billboard -- may define it), so the bits that let the # retained 2D item pipeline (``render2d.RenderItemCache``) know WHEN to re-run a # node's ``on_draw`` live here too. Without them a plain ``Node`` carrying an # ``on_draw`` could only refresh on a full re-collect (the plain-Node gap). # # ``dynamic`` -- PUBLIC, runtime-toggleable. ``True`` means "my ``on_draw`` reads # non-Property state (``tree.now`` animation, a frame counter, a live feed), so # re-collect me every frame." The self-documenting immediate-mode escape hatch. # (``_render_dynamic`` is the legacy internal name still honoured by the cache.) # ``_render_dirty`` -- appearance/geometry changed; re-capture this node's # ``on_draw`` next frame. Set by :meth:`queue_redraw`; drained by the cache. # ``_render_auto_dirty`` -- whether a ``Property`` write auto-dirties this node # (the blanket ``descriptors.py`` hook). ``False`` here so non-2D nodes (3D, # audio, camera) never pollute the 2D dirty scan; ``Drawable2D`` sets it ``True``. dynamic: bool = False _render_dirty: bool = False _render_auto_dirty: bool = False # -- Declared state every node has --------------------------------------- # Both are authoring state -- a user hides a node or takes it out of the # pause in the editor and expects the scene to save that way -- so both are # declared Properties rather than the plain ``@property`` pairs they used to # be, which no serialiser could see and no constructor would accept. # # Both stay storage-backed. Their setters do work beyond storing (a cache # invalidation, a subtree walk), but the value itself lives exactly where # ``Property`` would put it, at ``_update_mode`` and ``_visible``, so the # extra work belongs in an ``on_change`` hook and NOT in an overriding # ``__set__``. A derived Property (``storage_backed = False``, both # accessors overridden) is for a value with no slot of its own; declaring # one here would claim there is no storage when there is, and would cost a # subclass the ability to redeclare the default. # # ``on_change`` fires only on a real change, which is what makes the # re-assignment short-circuit these two have always had survive the move. visible = Property( True, coerce=bool, hint="Whether this node and its subtree are drawn and picked", on_change="_on_visible_changed", ) update_mode = Property( UpdateMode.INHERIT, hint="Processing behaviour while the tree is paused", on_change="_invalidate_update_mode_cache", ) # Engine kwargs consumed by Node.__init__, not forwarded to user __init__ unless explicitly accepted _NODE_INIT_KWARGS = frozenset({"name"}) # Stamped on by ``Property.__set_name__``: every Property declared on this # class, merged with the ones its bases declare. Declared without a value so # the descriptor's own "is it in this class's __dict__ yet" test still sees # a fresh subclass as fresh; :meth:`get_properties` is the read for anything # outside the constructor, which reads it directly to stay off a call. __properties__: ClassVar[dict[str, Property]] # Filled by __init_subclass__: hook name -> tuple of method names to invoke per dispatch. _simvx_hooks: ClassVar[dict[str, tuple[str, ...]]] = {} # Filled by __init_subclass__: ordered tuple of (method_name, filter_dict) for input handlers. _simvx_input_handlers: ClassVar[tuple[tuple[str, dict[str, Any]], ...]] = () # ``on_input`` is here so a plain override is dispatched like every other hook; # having no filters, it registers as the catch-all. That is why ``Node`` carries no # documentation-only ``on_input`` stub the way it does for the other hooks: a stub in # the base would put every node in the tree on the catch-all list. _PRIMARY_HOOK_METHODS: ClassVar[tuple[str, ...]] = ( "on_ready", "on_update", "on_fixed_update", "on_enter_tree", "on_exit_tree", "on_draw", "on_picked", "on_input", "on_unhandled_input", ) # Bare names commonly mistaken for SimVX hooks (the engine only dispatches the on_-prefixed forms). # ``update``/``fixed_update`` are deliberately NOT reserved: the on_ prefix already separates the # hooks from user code, so a plain ``def update()`` helper on a Node subclass is allowed. _BARE_HOOK_NAMES: ClassVar[tuple[str, ...]] = ( "ready", "draw", "input", )
[docs] def __init_subclass__(cls, **kwargs): super().__init_subclass__(**kwargs) previous = Node._registry.get(cls.__name__) if previous is not None and previous.__module__ != cls.__module__: # Two different modules, one name: the registry is keyed on the bare # name and keeps only the last, so a snapshot that names this class # without a module hint resolves to whichever was declared later. # Re-running ONE module is not this case and must stay quiet -- that # is hot reload, and it rebinds the name from the same module. log.warning( "Two node classes are named %r, in %s and %s. The later one wins wherever the " "name alone has to resolve it: the node listing, editor completions, and a " "snapshot that recorded no module.", cls.__name__, _where_declared(previous), _where_declared(cls), ) Node._registry[cls.__name__] = cls Node._registry_version += 1 # Lint: catch a class-level value that silently replaces an inherited descriptor. _reject_shadowed_managed_names(cls) # Lint: catch bare hook names (e.g. `def ready(self)`) that the engine silently never invokes. for name in cls._BARE_HOOK_NAMES: if name in cls.__dict__ and callable(cls.__dict__[name]): raise TypeError(f"{cls.__name__}.{name}: not a SimVX hook: did you mean 'on_{name}'?") # Collect lifecycle and input handlers (decorated + same-named overrides) # walking the MRO most-derived-last, mirroring Property.__set_name__. cls._simvx_hooks, cls._simvx_input_handlers = collect_hooks(cls, cls._PRIMARY_HOOK_METHODS) # Auto-super: wrap user __init__ that doesn't call super().__init__ if "__init__" not in cls.__dict__: return # No custom __init__: nothing to wrap if cls.__dict__.get("__auto_init__") is False: return # Opted out user_init = cls.__dict__["__init__"] if _init_calls_super(user_init): return # User handles super(): don't wrap user_sig = inspect.signature(user_init) user_params = user_sig.parameters # Determine which params (beyond self) the user accepts user_param_names = [n for n in user_params if n != "self"] has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in user_params.values()) has_var_positional = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in user_params.values()) # Positional param names (POSITIONAL_ONLY or POSITIONAL_OR_KEYWORD), in order positional_names = [ n for n, p in user_params.items() if n != "self" and p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) ] # Find the parent __init__ to call (the next in MRO that is not the user's) parent_init = None for base in cls.__mro__[1:]: if "__init__" in base.__dict__: parent_init = base.__dict__["__init__"] break if parent_init is None: parent_init = Node.__init__ # Inspect parent __init__ signature to know what it accepts. # follow_wrapped=False gets the actual wrapper signature (not the original # user function's signature that functools.wraps copies). try: parent_sig = inspect.signature(parent_init, follow_wrapped=False) parent_param_names = {n for n in parent_sig.parameters if n != "self"} parent_has_var_kw = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in parent_sig.parameters.values()) except (ValueError, TypeError): parent_param_names = set() parent_has_var_kw = True # Assume flexible has_on_change = getattr(cls, "_has_on_change_hooks", False) @functools.wraps(user_init) def _wrapped_init(self, *args, **all_kwargs): # Map positional args to their parameter names so we can split by name user_args = args if args and not has_var_positional: for i, val in enumerate(args): if i < len(positional_names): pname = positional_names[i] if pname in all_kwargs: raise TypeError(f"__init__() got multiple values for argument '{pname}'") all_kwargs[pname] = val user_args = () # All positional mapped to kwargs # Split kwargs: parent_kw goes to parent __init__, user_fwd goes to user's __init__ props = cls.get_properties() parent_kw = {} user_fwd = {} for k, v in all_kwargs.items(): is_user_param = k in user_param_names is_parent_named = k in parent_param_names # Explicitly named in parent sig is_prop = k in props is_engine = k in Node._NODE_INIT_KWARGS if is_user_param: user_fwd[k] = v if is_parent_named or is_prop or is_engine: parent_kw[k] = v # Also pass to parent (e.g. 'name', properties) elif is_parent_named or is_prop or is_engine: parent_kw[k] = v # Known parent/engine/property kwarg elif has_var_keyword: user_fwd[k] = v # Unknown kwarg, user accepts **kwargs elif parent_has_var_kw: parent_kw[k] = v # Fallback to parent **kwargs (will warn) else: raise TypeError(f"{cls.__name__}.__init__() got unexpected keyword argument {k!r}") we_set_init_scope = has_on_change and not getattr(self, "_on_change_init", False) if we_set_init_scope: self._on_change_init = True self._on_change_pending = [] try: # Initialise via parent chain (e.g. Node2D.__init__ -> Node.__init__) parent_init(self, **parent_kw) # Forward to user's __init__ if has_var_keyword or has_var_positional: user_init(self, *user_args, **user_fwd) elif user_param_names: forward = {k: v for k, v in user_fwd.items() if k in user_param_names} user_init(self, *user_args, **forward) else: user_init(self) finally: if we_set_init_scope: self._on_change_init = False self._flush_on_change_pending() cls.__init__ = _wrapped_init
# Class-level marker that lets ``_draw_recursive`` cheaply detect CanvasLayer # children without importing the subclass (which lives in a sibling module # and would otherwise force a per-frame ``from ... import CanvasLayer``). # ``CanvasLayer`` overrides this to ``True``. _is_canvas_layer: bool = False # Class-level marker for a SceneTree's own top node, the parent of every # singleton and of the current scene. Upward walks (``path``, ``node_at``) # stop below it, so a scene root still reports ``/Root`` and a save file # written before this node existed still resolves. ``SceneTree._TreeRoot`` # is the only class that sets it. _is_tree_root: bool = False #: What the engine worked out for this node, by property name #: (:meth:`_record_derived`). Shared and unwritable until a node has #: something to record, so the ordinary node -- which is nearly all of them #: -- allocates nothing and reads an empty mapping. _derived: Mapping[str, Any] = MappingProxyType({}) def _record_derived(self, name: str, value: Any) -> None: """Remember that the engine, not the author, put ``value`` in ``name``. A control measures its own size; a container places its children. Both write into the same properties an author writes into, and afterwards nothing tells the two apart -- which is how an editor save came to rewrite ``Label("Gold", size_x=200.0)`` inside a 300-wide container as ``Label("Gold", size_x=300.0, position=Vec2(0.0, 26.0))``, destroying the author's number and baking in an arithmetic result. A recorded value is not emitted while the property still holds it (:func:`~simvx.core.scene_io.emitter.iter_runtime_kwargs`). Any other write simply makes the two differ, so no write path is instrumented, nothing has to be cleared, and there is no per-frame cost. """ derived = self.__dict__.get("_derived") if derived is None: derived = {} self._derived = derived derived[name] = value def __init__(self, name: str = "", **kwargs): # The empty default is "no name given", and the node takes its type's # name. It is the only way to spell that: the setter refuses an empty # name, because a node that held one could be neither found nor saved. if name and not isinstance(name, str): raise TypeError(f"Node name must be a string, got {type(name).__name__}") self._name = name or type(self).__name__ self.parent: Node | None = None self.children = Children() # Count of direct children whose ``_is_canvas_layer`` flag is True. # Maintained by add_child / remove_child so ``_draw_recursive`` can # skip the sort/partition fast path when zero. self._canvas_layer_child_count: int = 0 self._tree: SceneTree | None = None self._coroutines: list[Coroutine] = [] self._groups: set[str] = set() self._scene_template_path: str | None = None # Prime the two declared Properties' storage from the DECLARED default # rather than from a literal, so a subclass that redeclares either one # (``visible = Property(False)`` on a dialog that opens hidden) starts # where it says it does. Priming at all is what lets the visibility walk # below read ``_visible`` directly rather than through the descriptor, # and it is what gives ``_visible_in_hierarchy`` its opening value. declared = type(self).__properties__ self._visible: bool = declared["visible"].default self._visible_in_hierarchy: bool = self._visible self._update_mode: UpdateMode = declared["update_mode"].default self._cached_update_mode: UpdateMode | None = None # cached resolved mode self.unique_name: bool = False self._script_error: bool = False self._destroying: bool = False # queued by destroy(), not yet flushed self._destroyed: bool = False # destroy() has run its course: this node is finished self._exiting: bool = False # inside this node's own _exit_tree self._ready_called: bool = False # on_ready has fired, once and for all self._outgoing_connections: list = [] # signals connected via this node's bound methods # Open the on_change deferral scope if this class has any on_change # hooks AND no enclosing init wrapper has already opened one. The flag # set here is cleared in the matching `finally` after kwargs are applied. we_set_init_scope = getattr(type(self), "_has_on_change_hooks", False) and not getattr( self, "_on_change_init", False ) if we_set_init_scope: self._on_change_init = True self._on_change_pending: list[tuple[str, str]] = [] try: # Apply Property values passed as kwargs props = self.get_properties() for key, val in kwargs.items(): if key in props: setattr(self, key, val) else: raise TypeError(_unknown_kwarg_message(type(self), key, props)) finally: if we_set_init_scope: self._on_change_init = False self._flush_on_change_pending() @property def name(self) -> str: """This node's name, which is never empty. The empty string is not a name a node can hold: it cannot be found by :meth:`find`, it cannot appear in a node path, and a scene that saved one would load back a node named after its type. Assigning it raises; pass no ``name`` to the constructor to get the type name instead. """ return self._name
[docs] @name.setter def name(self, value: str): if not isinstance(value, str): raise TypeError(f"Node name must be a string, got {type(value).__name__}") if not value: raise ValueError( f"{type(self).__name__}: a node name cannot be empty. " f"Construct without a name to be named after the type." ) old = self._name self._name = value parent = getattr(self, "parent", None) if parent is not None: names = parent.children._names if names.get(old) is self: del names[old] names[value] = self
def _on_visible_changed(self) -> None: """Carry a visibility flip down the subtree. Fired by the ``visible`` Property.""" parent_effective = True if self.parent is None else self.parent._visible_in_hierarchy self._propagate_visibility(parent_effective) self._notification(Notification.VISIBILITY_CHANGED)
[docs] @property def visible_in_tree(self) -> bool: """Whether this node is effectively visible: its own flag AND every ancestor's. ``visible`` answers only for this node, so a visible node under a hidden parent still reports ``True`` there. This is the answer the draw walk uses, and it is the one to test before skipping work for a hidden subtree. Maintained on every visibility change and every reparent, so reading it is a cached lookup rather than a walk up the parents. A node with no parent reports its own ``visible``. """ return self._visible_in_hierarchy
def _propagate_visibility(self, parent_effective: bool) -> None: """Update _visible_in_hierarchy for self and descendants. Propagates the effective visibility down the subtree; prunes branches whose effective state didn't change so toggles cost O(changed-subtree) rather than O(whole-subtree). """ new_effective = parent_effective and self._visible if self._visible_in_hierarchy == new_effective: return self._visible_in_hierarchy = new_effective # A flip in effective visibility changes what this node's ``on_draw`` # contributes to the retained 2D item set (it is drawn iff effectively # visible). ``visible`` is a plain ``@property``, so the blanket # ``Property.__set__ -> queue_redraw`` hook never sees it; mark render-dirty # here so the ``RenderItemCache`` re-collects and drops (or re-adds) this # node's slice instead of leaving its last frame painted. This walk visits # exactly the subtree whose effective visibility flipped, so one mark per # affected node is O(changed-subtree). self.queue_redraw() for child in self.children: child._propagate_visibility(new_effective) def _invalidate_update_mode_cache(self): """Clear cached process mode for this node and descendants that inherit.""" self._cached_update_mode = None for child in self.children: if child.update_mode == UpdateMode.INHERIT: child._invalidate_update_mode_cache() def _notification(self, what: Notification) -> None: """Called when a notification is dispatched. Override to handle."""
[docs] def reset_error(self) -> None: """Clear script error flag to re-enable processing.""" self._script_error = False
def _flush_on_change_pending(self) -> None: """Dispatch on_change hooks queued during ``__init__``. Hooks are deduplicated by ``(property_attr, method_name)`` so multiple sets of the same property during construction fire the hook once. Order of first occurrence is preserved. """ pending = getattr(self, "_on_change_pending", None) if not pending: return self._on_change_pending = [] seen: set[tuple[str, str]] = set() for prop_attr, method_name in pending: key = (prop_attr, method_name) if key in seen: continue seen.add(key) method = getattr(self, method_name, None) if method is not None: method() def _handle_script_error(self, method_name: str) -> None: """Disable this node after a script exception and surface the traceback. Only reached in non-strict mode: callers re-raise first when ``Node.strict_errors`` is set. Must be called from inside the active ``except`` block so ``traceback.format_exc()`` sees the exception. """ self._script_error = True import sys import traceback tb = traceback.format_exc() # The node's PATH, not its name: "which node?" is the whole diagnostic value of # a contained error, and a scene routinely holds several nodes of one name. path = self.path # Always print to stderr so errors are never invisible print(f"Script error in {path}.{method_name}: node disabled:\n{tb}", file=sys.stderr) log.error("Script error in %s.%s: node disabled", path, method_name) try: Node.script_error_raised.emit(self, method_name, tb) except Exception: # justified: signal-handler errors must not derail error recovery itself pass def _safe_call(self, method, *args: Any) -> None: """Call a lifecycle method with error recovery.""" if self._script_error: return try: method(*args) except Exception: if Node.strict_errors: raise self._handle_script_error(method.__name__)
[docs] def add_child(self, node: T) -> T: """Add a node as a child, reparenting it if already in a tree. Returns ``node`` itself, at the type it was passed, so the ``self.hero = self.add_child(Sprite2D(...))`` the tutorials teach keeps the child's own type rather than widening it to ``Node``. Args: node: The node to add. Removed from its current parent first. Raises: ValueError: ``node`` is ``self`` or one of ``self``'s ancestors -- either would create a cycle in the scene tree -- or :meth:`destroy` has been called on ``node``, which is final. """ self._attach_child(node) if self._tree: tree = self._tree tree._structure_version += 1 if tree._input_span_depth: # Already inside the tree's input span: the common case, a node spawned # from on_update, on_ready or an input handler. Entering another span # would only bump a depth counter, so skip it entirely and pay nothing. node._enter_tree(tree) node._ready_recursive() else: # Spawned from outside the tree's own work, so open a span: this node's # on_enter_tree and on_ready then see the tree they are joining rather # than whichever Input and InputMap happen to be active for the caller. with tree.activate_input(): node._enter_tree(tree) node._ready_recursive() return node
def _attach_child(self, node: Node) -> None: """Link ``node`` into this node's children WITHOUT running the entry path. The half of :meth:`add_child` that is pure structure, and the exact counterpart of :meth:`_detach_child`. Split out for ``SceneTree.root``'s setter, whose published contract is to re-link the current scene and run no lifecycle, so the two cannot drift apart. """ if node is self: raise ValueError(f"Cannot add node {node.name!r} as child of itself") if node._destroying or node._destroyed: raise ValueError( f"Cannot add destroyed node {node.name!r} as a child of {self.name!r}: " "destroy() is final, so build a fresh node instead of reviving this one" ) ancestor = self.parent while ancestor is not None: if ancestor is node: raise ValueError( f"Cannot reparent {node.name!r} under its descendant {self.name!r}: would create a cycle" ) ancestor = ancestor.parent if node.parent: node.parent.remove_child(node) node.parent = self self.children._add(node) if node._is_canvas_layer: self._canvas_layer_child_count += 1 node._notification(Notification.PARENTED) node._invalidate_update_mode_cache() node._propagate_visibility(self._visible_in_hierarchy) if hasattr(node, "_invalidate_transform"): node._invalidate_transform()
[docs] def remove_child(self, node: Node) -> None: """Remove a child node from this node's children. Immediate: ``node`` has left the tree by the time this returns, so its ``on_exit_tree`` has already run. Prefer :meth:`destroy` from inside a signal handler or a lifecycle hook, which runs the same teardown at the end of the frame instead of in the middle of a dispatch. """ if node not in self.children: return tree = self._tree if tree is None: node._exit_tree() self._detach_child(node) return tree._structure_version += 1 if tree._input_span_depth: # Already inside the tree's input span: the common case, a removal from # a signal handler or a lifecycle hook. Entering another span would only # bump a depth counter, so skip it entirely and pay nothing. node._exit_tree() else: # Removed from outside the tree's own work, so open a span: this node's # on_exit_tree then sees the tree it is leaving rather than whichever # Input and InputMap happen to be active for the caller. with tree.activate_input(): node._exit_tree() self._detach_child(node)
def _detach_child(self, node: Node) -> None: """Unlink ``node`` from this node's children WITHOUT running the exit path. The half of :meth:`remove_child` that is pure bookkeeping. Split out for the delete-queue drain, which must unlink a node that a destroyed ancestor already carried out of the tree: re-running ``_exit_tree`` there would fire ``on_exit_tree`` a second time on the same node. """ if node.parent is not self: # Already unlinked, which is what an ``on_exit_tree`` that removes # its own node leaves behind for the removal that is unwinding. return self.children._remove(node) if node._is_canvas_layer and self._canvas_layer_child_count > 0: self._canvas_layer_child_count -= 1 node._notification(Notification.UNPARENTED) node.parent = None node._invalidate_update_mode_cache() node._propagate_visibility(True)
[docs] def reparent(self, new_parent: Node): """Remove from current parent and add to new_parent.""" if self.parent: self.parent.remove_child(self) new_parent.add_child(self)
@overload def node_at(self, path: str) -> Node: ... @overload def node_at(self, path: str, default: D) -> Node | D: ...
[docs] def node_at(self, path, default=_NO_DEFAULT): """Navigate the tree by path: ``'Child/GrandChild'`` or ``'../Sibling'``. A leading ``/`` starts from the tree root, which the path may name as its first segment (``'/Root/Player'``). ``default`` works as it does on :func:`getattr`: without one, a path that names no such child raises :class:`NodeNotFound`; with one, that miss returns the default instead, so ``node_at("HUD/Minimap", None)`` is how an optional node is read. A malformed path -- ``'..'`` past the root -- is a bug in the caller rather than an absent node, and raises either way. """ current = self parts = [p for p in path.split("/") if p] if path.startswith("/"): # Stop below the tree's own top node: "the root" is the scene root, # never the internal parent that also owns the singletons. while current.parent is not None and not current.parent._is_tree_root: current = current.parent # An absolute path may optionally name the root as its first segment # (e.g. '/Root/Player'). Consume it once so it resolves to root. if parts and parts[0] == current.name: parts.pop(0) for part in parts: if part == "..": parent = current.parent if parent is None or parent._is_tree_root: raise ValueError("Already at root") current = parent else: try: current = current.children[part] except NodeNotFound: if default is _NO_DEFAULT: raise return default return current
@overload def find(self, target: type[T], *, direct: bool = False) -> T | None: ... @overload def find(self, target: str, *, direct: bool = False) -> Node | None: ... @overload def find(self, target: Callable[[Node], bool], *, direct: bool = False) -> Node | None: ...
[docs] def find(self, target, *, direct: bool = False): """First descendant matching ``target``, or ``None``. ``target`` may be: - a :class:`Node` subclass: matches the first ``isinstance`` descendant. The result is typed as that subclass (``find(Player) -> Player | None``), so no cast is needed. - a ``str``: matches the first descendant whose ``name`` equals it. - a predicate ``(Node) -> bool``: matches the first descendant it accepts. Search is depth-first, pre-order, and recursive by default. Pass ``direct=True`` to consider only this node's direct children. """ return self._find_first(_node_matcher(target), direct)
def _find_first(self, match: Callable[[Node], bool], direct: bool) -> Node | None: for child in self.children: if match(child): return child if not direct: found = child._find_first(match, False) if found is not None: return found return None @overload def find_all(self, target: type[T], *, direct: bool = False) -> list[T]: ... @overload def find_all(self, target: str, *, direct: bool = False) -> list[Node]: ... @overload def find_all(self, target: Callable[[Node], bool], *, direct: bool = False) -> list[Node]: ...
[docs] def find_all(self, target, *, direct: bool = False): """All descendants matching ``target`` (same matcher rules as :meth:`find`), in depth-first pre-order. Recursive by default; ``direct=True`` limits the search to direct children. Returns ``[]`` when nothing matches.""" out: list[Node] = [] self._find_all_into(_node_matcher(target), direct, out) return out
def _find_all_into(self, match: Callable[[Node], bool], direct: bool, out: list[Node]) -> None: for child in self.children: if match(child): out.append(child) if not direct: child._find_all_into(match, False, out) @overload def expect(self, target: type[T], *, direct: bool = False) -> T: ... @overload def expect(self, target: str, *, direct: bool = False) -> Node: ... @overload def expect(self, target: Callable[[Node], bool], *, direct: bool = False) -> Node: ...
[docs] def expect(self, target, *, direct: bool = False): """:meth:`find`, but a miss is an error rather than a ``None``. Same matcher rules and the same typed result. Use it wherever the scene is expected to contain the node and a missing one is a bug in the scene: it fails at the lookup, naming what was asked for, instead of handing back a ``None`` that raises somewhere later with no clue why. Raises: NodeNotFound: nothing under this node matches ``target``. """ found = self._find_first(_node_matcher(target), direct) if found is None: what = target.__name__ if isinstance(target, type) else target raise NodeNotFound(f"no descendant of {self.name!r} matches {what!r}") return found
@overload def ancestor(self, target: type[T]) -> T | None: ... @overload def ancestor(self, target: str) -> Node | None: ... @overload def ancestor(self, target: Callable[[Node], bool]) -> Node | None: ...
[docs] def ancestor(self, target): """Nearest ANCESTOR matching ``target``, or ``None``. The upward counterpart of :meth:`find`, with the same matcher rules and the same typed result, walking parents from this node outwards. ``self`` is never a candidate. This is how a node reaches the container it lives under (``self.ancestor(Inventory)``) without hard-coding how deep it sits. """ match = _node_matcher(target) node = self.parent # Stops below the tree's own top node for the same reason ``path`` and # ``node_at`` do: the scene root's ancestry ends at the scene root. while node is not None and not node._is_tree_root: if match(node): return node node = node.parent return None
[docs] def walk(self, *, include_self: bool = True) -> Iterator[Node]: """Iterate this node and all descendants in DFS pre-order.""" if include_self: yield self for child in self.children: yield from child.walk(include_self=True)
[docs] @property def path(self) -> str: parent = self.parent if parent is None or parent._is_tree_root: return f"/{self.name}" return f"{parent.path}/{self.name}"
[docs] @property def is_scene_root(self) -> bool: """Whether this node is the top of its scene. True for a node with no parent, and for the current scene root of a :class:`~simvx.core.scene_tree.SceneTree`, whose parent is the tree's own top node rather than another scene node. This is the test to make before offering to delete, duplicate or reparent a node: a scene root can do none of the three. """ parent = self.parent return parent is None or parent._is_tree_root
# --- Groups ---
[docs] def add_to_group(self, group: str): """Add this node to a named group.""" self._groups.add(group) if self._tree: self._tree._group_add(group, self)
[docs] def remove_from_group(self, group: str): """Remove this node from a named group.""" self._groups.discard(group) if self._tree: self._tree._group_remove(group, self)
[docs] def is_in_group(self, group: str) -> bool: """Check if this node belongs to a named group.""" return group in self._groups
# --- Lifecycle (override in subclasses) ---
[docs] def on_ready(self) -> None: """Called once, ever, after the node and all its children enter the tree. Override to perform initialisation that requires the scene tree -- finding sibling nodes, connecting signals, spawning children. The ``tree`` property is available. Called after ``on_enter_tree()`` and after all children's ``on_ready()``. Decorate other methods with ``@on_ready`` to register additional ready handlers; they fire after the override in declaration order. Once per node instance: a node that leaves the tree and comes back does **not** ready again, so the children spawned here are built once rather than once per entry, and the signals connected here are connected once rather than stacking up a duplicate per entry. Which of the two entry hooks:: on_enter_tree() every entry, for as long as the node keeps coming back -- per-stay setup on_ready() once per node instance, ever -- one-time setup The mirror rule follows from that: whatever ``on_exit_tree()`` undoes has to be redone in ``on_enter_tree()``. Putting it here leaves the node dead the second time it joins a tree. Example:: def on_ready(self): self.sprite = self.node_at("Sprite") self.health_changed.connect(self._update_hud) """
[docs] def on_enter_tree(self) -> None: """Called on every entry into the scene tree, before ``on_ready()``. Override for the setup that belongs to each stay in the tree: claiming a slot on the tree, subscribing to a tree signal, restarting whatever ``on_exit_tree()`` stopped. Unlike ``on_ready()``, which fires once per node instance and never again, this fires as often as the node is added. The whole incoming subtree is bound to the tree before any of its entry hooks run, so a hook may look up nodes the walk has not reached yet -- by group, by unique name or by path -- and every one of them already answers ``node.tree``. Their own ``on_enter_tree`` may not have run yet, though, so read their state rather than depending on their setup; ``on_ready()`` is where the subtree is fully built. Example:: def on_enter_tree(self): self.add_to_group("enemies") self.tree.screen_resized.connect(self._relayout) """
[docs] def on_exit_tree(self) -> None: """Called when the node is about to leave the scene tree. Override to clean up resources, disconnect external signals, or persist state. Children have already exited by the time this fires on the parent. Example:: def on_exit_tree(self): self.save_progress() self.remove_from_group("enemies") """
[docs] def on_update(self, dt: float) -> None: """Called every frame for game logic. Args: dt: Seconds elapsed since the previous frame (variable timestep). Override for movement, AI, animation triggers, or any per-frame update. Obeys ``update_mode`` -- disabled or paused nodes are skipped automatically. Decorate other methods with ``@on_update`` to register additional per-frame handlers; they fire after the override in declaration order. For state held while a button is pressed, poll ``Input.is_action_pressed("name")`` from inside ``on_update``. Example:: def on_update(self, dt): self.position += self.velocity * dt """
[docs] def on_fixed_update(self, dt: float) -> None: """Called at a fixed timestep (default 60 Hz) for physics logic. Args: dt: Fixed time step in seconds (e.g. 1/60). Override for deterministic physics updates -- forces, collision responses, rigid-body integration. Runs independently of the render frame rate. Example:: def on_fixed_update(self, dt): self.velocity += self.gravity * dt self.move_and_slide(dt) """
[docs] def on_draw(self, renderer) -> None: """Called each frame for custom 2D drawing. Args: renderer: The active draw-command recorder (e.g. ``Draw2D``). Override to issue immediate-mode draw calls such as ``draw_line``, ``draw_rect``, or ``draw_text``. Called only when ``visible`` is ``True``. The 2D renderer is retained ("build once"): output is re-collected only when a ``Property`` changes. If ``on_draw`` reads non-Property state (a plain attribute updated by a signal or timer, ``tree.now`` animation), call :meth:`queue_redraw` when that state changes so the new frame is collected. This is identical on live and headless: a body that mutates without ``queue_redraw`` freezes on both. Example:: def on_draw(self, renderer): renderer.draw_circle(self.world_position, 10, colour=(1, 0, 0, 1)) """
[docs] def on_picked(self, event: InputEvent) -> None: """Called when a 3D mouse-pick event hits this node's collision shape. Args: event: The input event containing click position, camera ray, etc. Override to react to direct interaction with this 3D object -- selection, dragging, context menus. Example:: def on_picked(self, event): if event.button == MouseButton.LEFT: self.selected = True """
[docs] def on_unhandled_input(self, event: TreeInputEvent) -> None: """Called for input events that no ``@on_input`` handler consumed. Args: event: The unhandled input event. Use for catch-all bindings such as global debug toggles or pause menus that should only fire when no other handler returned a truthy value to consume the event. For most input handling use ``@on_input(...)`` decorators with explicit filters; the dispatch tables route them directly without walking the tree. Example:: def on_unhandled_input(self, event): if event.key == Key.F3: self.toggle_debug_overlay() """
# --- Coroutine support ---
[docs] def start_coroutine(self, gen: Coroutine) -> CoroutineHandle: """Register a generator coroutine to run each frame. Returns a cancellable handle.""" handle = CoroutineHandle(gen) self._coroutines.append(handle) return handle
[docs] def stop_coroutine(self, gen_or_handle): """Stop and remove a running coroutine (accepts generator or CoroutineHandle).""" if isinstance(gen_or_handle, CoroutineHandle): gen_or_handle.cancel() if gen_or_handle in self._coroutines: self._coroutines.remove(gen_or_handle) return for h in self._coroutines: if h._gen is gen_or_handle: h.cancel() self._coroutines.remove(h) return
def _tick_coroutines(self, dt: float): if not self._coroutines: return finished = [] # Snapshot: a coroutine that raises reports through script_error_raised, and a # listener is free to cancel coroutines on this node while that signal is dispatching. for handle in list(self._coroutines): if handle.is_cancelled: finished.append(handle) continue gen = handle._gen try: if handle._primed: gen.send(dt) else: next(gen) handle._primed = True except StopIteration: finished.append(handle) except Exception: # A coroutine that raises is dropped on its own, so its siblings on # this node still tick this frame. Beyond that it follows the same # policy as a failed lifecycle hook: re-raise in strict mode, else # disable the node and surface the traceback. finished.append(handle) if Node.strict_errors: raise self._handle_script_error(getattr(gen, "__qualname__", repr(gen))) for handle in finished: # stop_coroutine() invoked from inside the coroutine may have already removed it. if handle in self._coroutines: self._coroutines.remove(handle) # --- Tree internals --- def _enter_tree(self, tree: SceneTree): """Attach this subtree to ``tree``, in two passes over it. The first pass binds every node's tree reference and registers its groups, unique name and input handlers. Only once the whole subtree is registered does the second pass run the entry hooks, walking the flat list the first pass collected. An ``on_enter_tree`` therefore sees a fully attached subtree: a parent can look its own descendants up by group or by unique name from its entry hook, and every node it reaches already answers ``node.tree``. The recursive step goes through ``_enter_tree`` again, so a subclass override fires once per node as before. The walk in progress is tracked on the tree: the nested calls register only, and the outermost one owns the second pass. """ attaching = tree._attaching outermost = attaching is None if attaching is None: attaching = [] tree._attaching = attaching try: # First pass for this node: bind and register, running no user code. self._tree = tree if self.unique_name: tree._unique_nodes[self.name] = self for group in self._groups: tree._group_add(group, self) # Register @on_input handlers with the tree's dispatch tables. if type(self)._simvx_input_handlers: tree._register_input_node(self) attaching.append(self) for child in self.children: child._enter_tree(tree) finally: if outermost: tree._attaching = None if not outermost: return for node in attaching: # An earlier hook may have removed or destroyed part of the subtree; # a node it carried out has no entry to announce. if node._tree is not tree: continue node._notification(Notification.ENTER_TREE) for method_name in type(node)._simvx_hooks.get("enter_tree", ()): node._safe_call(getattr(node, method_name)) def _exit_tree(self): if self._exiting: # Re-entered from inside this node's own exit: an on_exit_tree that # removes or destroys the node it belongs to. The teardown already # running finishes the job, so the nested call has nothing to do. return self._exiting = True try: self._exit_tree_inner() finally: self._exiting = False def _exit_tree_inner(self): for child in self.children: child._exit_tree() self._notification(Notification.EXIT_TREE) for method_name in type(self)._simvx_hooks.get("exit_tree", ()): self._safe_call(getattr(self, method_name)) # Close any in-flight coroutines so their ``finally:`` blocks run # (releases signal-handler subscriptions, restores transforms, etc.). # Without this, ``wait_signal`` lambdas stay attached to the signal # for the lifetime of the emitter: a slow leak on scene churn. if self._coroutines: for handle in self._coroutines: try: handle._gen.close() except Exception: log.exception("Coroutine close raised on node exit for %r", self) self._coroutines.clear() if self._tree: if type(self)._simvx_input_handlers: self._tree._unregister_input_node(self) if self.unique_name: self._tree._unique_nodes.pop(self.name, None) for group in self._groups: self._tree._group_remove(group, self) self._tree = None def _ready_recursive(self): # Snapshot: a child's on_ready may add a sibling (via parent.add_child), # which already readies it inline. Iterating the live list would re-visit # that node and fire its on_ready twice. The copy makes ready exactly-once. for child in list(self.children): child._ready_recursive() if self._ready_called: # Ready is once per node instance, ever: a subtree that leaves the # tree and comes back re-enters, but does not ready again. return self._ready_called = True self._notification(Notification.READY) for method_name in type(self)._simvx_hooks.get("ready", ()): self._safe_call(getattr(self, method_name)) def _effective_update_mode(self) -> UpdateMode: """Resolve INHERIT by walking up the tree (cached).""" cached = self._cached_update_mode if cached is not None: return cached mode: UpdateMode = self.update_mode if mode == UpdateMode.INHERIT: mode = self.parent._effective_update_mode() if self.parent else UpdateMode.PAUSABLE self._cached_update_mode = mode return mode def _can_update(self, paused: bool) -> bool: """Check if this node should process given the tree's pause state.""" mode = self._effective_update_mode() if mode == UpdateMode.DISABLED: return False if mode == UpdateMode.ALWAYS: return True if mode == UpdateMode.PAUSED_ONLY: return paused # PAUSABLE (or resolved INHERIT → PAUSABLE) return not paused def _process_recursive(self, dt: float, paused: bool = False): if self._script_error: return # Inlined _can_update: resolve mode from cache and check pause state mode = self._cached_update_mode if mode is None: mode = self._effective_update_mode() if mode != UpdateMode.DISABLED and ( mode == UpdateMode.ALWAYS or (not paused if mode == UpdateMode.PAUSABLE else paused) ): self._notification(Notification.PROCESS) handlers = type(self)._simvx_hooks.get("update", ()) for method_name in handlers: try: getattr(self, method_name)(dt) except Exception: if Node.strict_errors: raise self._handle_script_error(method_name) return if self._coroutines: self._tick_coroutines(dt) for child in self.children.safe_iter(): child._process_recursive(dt, paused) def _physics_process_recursive(self, dt: float, paused: bool = False): if self._script_error: return # Inlined _can_update: resolve mode from cache and check pause state mode = self._cached_update_mode if mode is None: mode = self._effective_update_mode() if mode != UpdateMode.DISABLED and ( mode == UpdateMode.ALWAYS or (not paused if mode == UpdateMode.PAUSABLE else paused) ): self._notification(Notification.PHYSICS_PROCESS) handlers = type(self)._simvx_hooks.get("fixed_update", ()) for method_name in handlers: try: getattr(self, method_name)(dt) except Exception: if Node.strict_errors: raise self._handle_script_error(method_name) return for child in self.children.safe_iter(): child._physics_process_recursive(dt, paused) # ------------------------------------------------------------------ draw walk # # ONE unified, layer-banded, pluggable-key walker. # # ``_draw_recursive`` is the single skeleton for every node type. It owns the # ``visible`` guard, the ``_script_error`` short-circuit, the self-dispatch, # and the below/self/above interleave. Per-type behaviour is supplied through # three narrow hooks, NOT by re-implementing the walk: # # * ``_draw_self(renderer)`` -- how this node draws itself # (default: ``_draw_dispatch``). # * ``_ordered_children()`` -- the pluggable ORDERING KEY: returns # ``(below, above)`` child bands (self # draws between them). The CanvasLayer # band is folded into ``above`` by the # types that partition it. Returning # ``(None, None)`` selects the FAST # PATH (no sort, tree order). # * ``_draw_children(renderer)`` -- full override of child traversal, # for types that wrap each child # (Control's per-child clip+offset). # * ``_draw_script_error(renderer)`` -- error presentation (default: walk # children only). def _draw_recursive(self, renderer): if not self.visible: return if self._script_error: self._draw_script_error(renderer) return below, above = self._ordered_children() if below is None: # FAST PATH: self first, then children in their traversal policy. self._draw_self(renderer) self._draw_children(renderer) return for child in below: child._draw_recursive(renderer) self._draw_self(renderer) for child in above: child._draw_recursive(renderer) @staticmethod def _banded_children(children, canvas_count, z_key=None): """Partition ``children`` into ``(below, above)`` with CanvasLayers LAST. Shared helper for the default layer-banded policy (plain ``Node`` and ``Node2D``): CanvasLayer children are sorted by ``layer`` and split into a negative-layer prefix (folded into ``below``) and a zero/positive suffix (folded at the end of ``above``); world children optionally sort by ``z_key``. CanvasLayers always draw last in their band, after world ``above`` content -- matching today's semantics. """ below = [] above = [] canvas_layers = [] for c in children: if c._is_canvas_layer: canvas_layers.append(c) elif z_key is not None and z_key(c) < 0: below.append(c) else: above.append(c) if z_key is not None: below.sort(key=z_key) above.sort(key=z_key) if canvas_count: canvas_layers.sort(key=lambda c: c.layer) below = [c for c in canvas_layers if c.layer < 0] + below above = above + [c for c in canvas_layers if c.layer >= 0] return below, above def _ordered_children(self): """Return ``(below, above)`` child bands for the walk (ORDERING KEY hook). Plain ``Node`` has no z/Y ordering of its own. Its only banding concern is CanvasLayer children (HUD layers drawn last); with none present it takes the FAST PATH (tree order). Negative-layer CanvasLayers draw before non-layer siblings, zero/positive after -- matching today. """ if self._canvas_layer_child_count == 0: return None, None return self._banded_children(self.children.safe_iter(), self._canvas_layer_child_count) def _draw_self(self, renderer): """Draw this node's own content. Default fires all ``on_draw`` handlers. CanvasLayer/Control override to wrap with screen-space identity or the retained draw cache while still firing the same ordered handler set. """ self._draw_dispatch(renderer) def _draw_children(self, renderer): """Walk children in tree order (FAST-PATH traversal). Control overrides this to synthesise a per-child clip + transform offset. Only invoked on the fast path; the banded path walks ``below``/``above`` directly. """ for child in self.children.safe_iter(): child._draw_recursive(renderer) def _draw_script_error(self, renderer): """Present a script-errored node. Default: skip own draw, walk children. Control overrides to paint an error box over its rect. """ for child in self.children.safe_iter(): child._draw_recursive(renderer) def _draw_dispatch(self, renderer): """Invoke all ``on_draw`` handlers (override + decorated) for this node. Used by ``_draw_recursive`` and by Control/Node2D subclasses that wrap drawing with caching, transforms, or clipping but still want to fire the same ordered set of handlers. """ for method_name in type(self)._simvx_hooks.get("draw", ()): self._safe_call(getattr(self, method_name), renderer) # -- 2D render-retention ---------------------------------------------------
[docs] def queue_redraw(self) -> None: """Mark this node's ``on_draw`` output stale (re-capture it next frame). The manual escape hatch for an ``on_draw`` body that reads non-Property state and changes ONCE (a signal/timer poke). For per-frame animation set :attr:`dynamic` instead. Idempotent and cheap (a no-op once already dirty). ``Drawable2D`` (every ``Node2D`` / ``Control`` / ``CanvasLayer``) also gets this called automatically by the blanket ``Property.__set__`` hook on any changed Property, so drawing from Property state never needs it. A plain ``Node`` HUD/menu (``_render_auto_dirty`` is ``False``) calls it by hand. """ self._render_dirty = True
def _clear_render_dirty(self) -> None: """Drain the render-dirty bit. Called ONLY by the cache's upload step.""" self._render_dirty = False
[docs] @property def render_dirty(self) -> bool: """Whether ``on_draw`` output changed since the last upload (introspection).""" return self._render_dirty
[docs] def clear_children(self): """Destroy all children of this node.""" for child in list(self.children): child.destroy()
[docs] def destroy(self): """Schedule this node for removal at the end of the current frame. **This is the engine's deferred removal**, and the only spelling of it: a node marked here stays alive, in the tree, for the rest of the frame, and its subtree is carried out in one piece at the end-of-frame sync point. That is what makes it safe to call from a signal handler, a collision callback or a lifecycle hook, where tearing a node down on the spot would mutate a structure the caller is still walking. :meth:`remove_child` is the immediate counterpart, for the cases that genuinely need the node gone before the call returns (reparenting). Removal does not cost a node the events it was still owed, and that is true of ``remove_child`` too: the physics seam reports the contacts a destroyed body was in on the step after it goes, and ``separated`` / ``body_exited`` carry that node. The seam holds it for that step itself, so the naming does not depend on which removal path was taken. Always final. A node that is not in a tree has no frame to defer to, so it is finished on the spot rather than ignoring the call; one that is in a tree is queued for the end of the frame. Either way the node is spent afterwards, and :meth:`add_child` refuses to put it back in a tree. Idempotent: a second call does nothing, before or after the removal has run, so the same enemy can be destroyed by two handlers in one frame. :attr:`destroying` reports the pending state. Signal connections made through this node's bound methods are proactively disconnected so emitters stop dispatching to it on the next emit. Lazy weak-ref cleanup in ``Signal.__call__`` covers nodes that are GC'd without ``destroy()``. """ if self._destroying or self._destroyed: return for conn in list(self._outgoing_connections): conn.disconnect() self._outgoing_connections.clear() tree = self._tree if tree is not None: self._destroying = True tree._queue_delete(self) else: self._destroyed = True
[docs] @property def destroying(self) -> bool: """Whether :meth:`destroy` has been called and the removal has not run yet. True from the ``destroy()`` call until the end-of-frame drain carries the node out of the tree, and False again afterwards (the node object itself stays valid; Python frees it when the last reference goes). Use it to skip a node that is already on its way out:: for enemy in self.tree.group("enemies"): if enemy.destroying: continue enemy.take_damage(1) Per node, not per subtree: a child of a destroying node reports False until it is itself queued. Only nodes queued while in a tree are ever marked: a detached node has no frame to defer to, so ``destroy()`` on one finishes it immediately and this never turns True for it. """ return self._destroying
[docs] def call_deferred(self, method: Callable[..., Any], *args: Any) -> None: """Escape hatch: run ``method(*args)`` at the end of this frame, outside tree traversal, instead of now. **Discouraged: prefer a safe-by-default path when one exists.** SimVX already makes the common cases safe without deferring: the process loop and signal dispatch iterate snapshots (so adding/removing nodes mid-loop does not corrupt iteration), :meth:`destroy` is already a deferred delete, ``Property(coalesce=True)`` collapses repeated writes, and ``tree.events.publish_deferred(...)`` decouples event delivery. Reach for ``call_deferred`` only when you must mutate from a context none of those cover, and document why at the call site. ``method`` is a bound method or any callable (type-safe; never a string method name). Calls run once, in queue order, at the end-of-frame sync point; anything queued *during* that drain runs on the next frame. A call bound to this node is dropped if the node has left the tree by the time the queue drains, and runs through :meth:`_safe_call` so a failure obeys the same strict/release policy as any other lifecycle hook. """ if self._tree is None: raise RuntimeError( f"{self.name!r}.call_deferred() needs the node to be in a SceneTree " "(there is no frame to defer to otherwise)." ) self._tree.call_deferred(method, *args)
[docs] @property def app(self): """The App running this node's scene tree. Available after enter_tree().""" return self._tree.app if self._tree else None
[docs] @property def tree(self) -> SceneTree: """The SceneTree this node belongs to.""" return self._tree
[docs] @property def physics(self): """Spatial-query accessor bound to this node's physics world, or ``None``. Mirrors :attr:`app` / :attr:`tree`: available once in-tree. Returns a ``PhysicsQuery`` scoped to the same world the node's body lives in (resolved via the nearest ``PhysicsRoot`` ancestor, else the tree default), exposing ``raycast`` / ``raycast_all`` / ``shapecast`` / ``overlap`` with typed results and ``mask`` / ``exclude`` filters. Built fresh per access (not cached): the resolved world can change across re-parent / change_scene, and the wrapper is a thin two-reference object on the cold query path. Bind it locally if a hot loop wants to reuse it. """ if self._tree is None: return None from .physics.query import PhysicsQuery from .physics.root import resolve_world world = resolve_world(self) node_map = self._tree._physics_nodes.get(world) return PhysicsQuery(world, node_map)
[docs] @property def physics_2d(self): """2D spatial-query accessor bound to this node's 2D physics world, or ``None``. The 2D sibling of :attr:`physics`: available once in-tree, returns a ``PhysicsQuery2D`` scoped to the same 2D world the node's body lives in (resolved via the nearest ``PhysicsRoot2D`` ancestor, else the tree's 2D default), exposing ``raycast`` / ``raycast_all`` / ``shapecast`` / ``overlap`` with typed 2D results and ``mask`` / ``exclude`` filters. Built fresh per access (cold query path). """ if self._tree is None: return None from .physics.query2d import PhysicsQuery2D from .physics.root import resolve_world_2d world = resolve_world_2d(self) node_map = self._tree._physics_nodes.get(world) return PhysicsQuery2D(world, node_map)
[docs] def __getitem__(self, key: str): """Shorthand for node_at: ``self["Child/Path"]``.""" return self.node_at(key)
[docs] @classmethod def get_properties(cls) -> dict[str, Property]: """Return all Property descriptors declared on this node class and its bases.""" return getattr(cls, "__properties__", {})
[docs] def __repr__(self): return f"<{type(self).__name__} '{self.name}'>"
# Register Node itself (not covered by __init_subclass__ which only fires for subclasses) Node._registry["Node"] = Node # Collect primary lifecycle hooks defined directly on Node (no decorators on the # base class) so bare Node instances dispatch through the same code path as # subclasses. Node._simvx_hooks, Node._simvx_input_handlers = collect_hooks(Node, Node._PRIMARY_HOOK_METHODS) # ============================================================================ # Timer # ============================================================================
[docs] class Timer(Node): """Node that emits :attr:`timeout` after ``duration`` seconds. A Timer counts down in ``on_update``, so it runs on frame time, not on the fixed-step physics clock, and it obeys ``update_mode``: a ``PAUSABLE`` Timer stops counting while the tree is paused. Nothing happens until :meth:`start` is called or ``autostart`` is passed to the constructor. With ``one_shot`` (the default) the timer fires once and stops. With ``one_shot=False`` it repeats, and the leftover time from the frame that crossed zero is carried into the next cycle, so a repeating timer does not drift at low frame rates. Connect to it like any other signal:: timer = Timer(duration=0.5, one_shot=False, autostart=True) timer.timeout.connect(self.spawn_enemy) self.add_child(timer) :attr:`time_left` reports the seconds remaining and :attr:`stopped` whether the countdown is idle; :meth:`stop` resets both without emitting. """ duration = Property(1.0, range=(0.001, 3600), hint="Countdown length in seconds") one_shot = Property(True, hint="Fire once and stop; False repeats until stopped") autostart = Property(False, hint="Pass autostart=True to the constructor to begin counting down immediately") def __init__(self, duration: float = 1.0, one_shot: bool = True, autostart: bool = False, **kwargs): super().__init__(**kwargs) self.duration = duration self.one_shot = one_shot self.timeout = Signal() self._time_left = duration if autostart else 0.0 self._running = autostart
[docs] def start(self, duration: float = 0): """Start or restart the timer, optionally overriding duration.""" if duration > 0: self.duration = duration self._time_left = self.duration self._running = True
[docs] def stop(self): """Stop the timer and reset time_left to zero.""" self._running = False self._time_left = 0.0
[docs] @property def stopped(self) -> bool: return not self._running
[docs] @property def time_left(self) -> float: return self._time_left
[docs] def on_update(self, dt: float): if not self._running: return self._time_left -= dt if self._time_left <= 0: self.timeout() if self.one_shot: self._running = False else: self._time_left += self.duration