simvx.core.scene_tree¶
SceneTree: Central manager for the node tree, groups, input routing, and UI focus.
Module Contents¶
Classes¶
A transient one-shot timer owned by the :class: |
|
Central manager for the node tree, groups, input routing, and UI focus. |
Data¶
API¶
- simvx.core.scene_tree.RESERVED_SINGLETON_EVENTS¶
‘events’
- simvx.core.scene_tree.log¶
‘getLogger(…)’
- class simvx.core.scene_tree.SceneTreeTimer(seconds: float)[source]¶
A transient one-shot timer owned by the :class:
SceneTree.Created via :meth:
SceneTree.create_timer. Emitstimeoutonce after its delay elapses, then the tree drops it. Lighter than a :class:~simvx.core.Timernode: it lives only on the tree’s timer list, not in the scene graph.Initialization
- __slots__¶
(‘timeout’, ‘_time_left’)
- class simvx.core.scene_tree.SceneTree(screen_size=None, *, isolated_input: bool = False)[source]¶
Central manager for the node tree, groups, input routing, and UI focus.
Owns the root node and drives per-frame
process/physics_process/drawtraversals. Also manages pause state, scene changes, and the UI input pipeline (mouse, keyboard, popups).Initialization
- classmethod current() simvx.core.scene_tree.SceneTree | None[source]¶
Return the most recently activated SceneTree, or
None.Activation happens automatically on
set_root/change_scene. Used byInputSimulatorto deliver scene-tree + UI-tree events without requiring callers to thread a tree reference through.Held weakly: returns
Noneonce the tree has no remaining strong references (e.g. a test dropped it), which is the correct “no active tree” semantics.
- property events: simvx.core.event_bus.EventBus[source]¶
Engine-provided typed event bus.
Use
tree.events.subscribe(EventCls, handler)to register a handler andtree.events.publish(event)(orpublish_deferred) to dispatch. The bus surviveschange_scene()– subscriptions held by singletons or other long-lived objects keep firing across scene swaps. Deferred events queued during a frame are dispatched at the start of the nextprocess()tick, before any node_processruns.
- property audio_backend: simvx.core.audio_protocol.AudioBackend | None[source]¶
The active audio backend, or
Noneif none was initialised.Returns the union :class:
AudioBackendtype for backwards compatibility: callers that only need one facet should prefer the narrowed :attr:audio_playback/ :attr:audio_streaming/- Attr:
audio_busesproperties so the type checker enforces the boundary. Reaching for the underscore-prefixed attribute is engine-private and may change.
The engine sets this during
App.runviamake_backend. Tests and headless harnesses that don’t initialise audio seeNone.
- install_audio_backend(backend: simvx.core.audio_protocol.AudioPlaybackBackend) None[source]¶
Install the audio backend for this tree: the one canonical path.
This is the only supported way to attach a backend; assigning the private
_audio_backendslot is no longer part of the contract. Used byApp.run/WebAppat startup and by tests that inject a- Class:
NullAudioBackendor a mock.
Semantics mirror startup driver selection in other engines (Godot’s
--audio-driver, pyglet’saudiooption): a backend is installed once, before the tree starts producing sound. Re-installing shuts the previous backend down first so device handles don’t leak.Facet conformance is enforced where it is consumed, not here: the
- Attr:
audio_playback/ :attr:audio_streaming/ :attr:audio_busesaccessors narrow viaisinstanceand returnNonefor a backend that doesn’t implement that facet, and callers raise- Class:
AudioCapabilityErroronNone. Gating here as well would duplicate that boundary and reject legitimate partial backends.
A backend that paces itself by the engine rather than by a device clock declares :class:
~simvx.core.audio_protocol.TreeBoundAudioBackend, and installing hands it this tree: several trees can be alive at once, so it has to be told which one’s ticks are its own.
- property audio_playback: simvx.core.audio_protocol.AudioPlaybackBackend | None[source]¶
The active backend narrowed to :class:
AudioPlaybackBackend, orNone.Always equals :attr:
audio_backendcast to the playback facet when a backend is present: every shipped backend implements playback, including the silent :class:NullAudioBackend.
- property audio_streaming: simvx.core.audio_protocol.AudioStreamingBackend | None[source]¶
The active backend narrowed to :class:
AudioStreamingBackend, orNone.Returns
Nonewhen the active backend doesn’t implement streaming (the Null backend, any future no-device test stub). Callers that need streaming (:class:AudioSynthdriver, AudioWorklet feeds) should raise :class:AudioCapabilityErroronNone.
- property audio_buses: simvx.core.audio_protocol.AudioBusBackend | None[source]¶
The active backend narrowed to :class:
AudioBusBackend, orNone.Always equals :attr:
audio_backendcast to the bus facet when a backend is present: every shipped backend implements bus + capability advertisement.
- audio_listener_3d() simvx.core.audio_listener.AudioListener3D | None[source]¶
The active 3D audio listener, lazy-creating one if none exists.
Returns the most recently entered :class:
AudioListener3Din the scene. If none has been added, auto-creates a fallback parented to the activeCamera3Dwith a one-time warning. ReturnsNoneonly if there’s no camera either.Audio players call this every frame, so the auto-creation is cheap once it’s happened (the cached listener is returned).
- audio_listener_2d() simvx.core.audio_listener.AudioListener2D | None[source]¶
The active 2D audio listener, lazy-creating one if none exists.
Same contract as :meth:
audio_listener_3dbut for 2D.
- activate_input()[source]¶
Return a context manager making this tree’s Input/InputMap the active ones.
Wrap any engine-driven tree work in this. Node code that runs inside the span sees this tree, so
Input.is_action_pressed(...)reads this tree’s state andInputMap.add_action(...)lands in this tree’s own map rather than the process-wide default. The span is also howInputMap.add_actiontells such a registration from a genuinely out-of-band one made while the scene is running.Every tree entry point that runs this tree’s node code opens the span, so the rule holds without the caller doing anything: a tick, a physics step, a mount (
set_root,change_scene,add_singleton,Node.add_child), a teardown (change_scene,remove_singleton,Node.remove_child),@on_inputandon_unhandled_inputdispatch, UI, multi-touch and 3D pick dispatch, a focus or hover change, a layout flush, drawing (on_draw), acall_groupbroadcast, and the tree’s ownscreen_resizedandquit_requestedemits.What that does not cover is code reaching node methods without going through the tree at all: calling a hook by hand, or emitting a signal the tree owns from outside it. Such a caller wraps the work itself.
_input_span_depthis a re-entrant depth, so spans may nest freely (achange_scenecalled fromon_updateor from an@on_inputhandler mounts inside the span that is already open).
- property is_running: bool[source]¶
Whether the tree is actively being ticked by its driving app.
Set to False by :meth:
quit(or the graphics backend’s appquit()), signalling the main loop to exit at the end of the current frame.
- property now: float[source]¶
Monotonically-increasing scene time, in seconds.
Accumulates
dtat the start of every :meth:tickcall (after any :attr:simvx.graphics.App.time_scalescaling has been applied), so slow-motion and hitstop also slow this clock. Frozen while- Attr:
pausedis True or any inert overlay is open. Resets to0.0only by constructing a freshSceneTree.
Use for time-based animation, slow-mo gating, and any “how long has the scene been running” query: preferable to per-node
self._time += dtaccumulators because every consumer reads the same monotonic value.
- quit() None[source]¶
Request a clean shutdown of the running tree.
Emits :attr:
quit_requestedand flips :attr:is_runningto False. The driving app polls this state and exits its loop at the end of the current frame. Safe to call from node callbacks or signal handlers.
- property root: simvx.core.node.Node | None[source]¶
The current scene’s root node.
A sibling of this tree’s singletons under the tree’s own top node, and the node every scene-facing walk starts from: rendering, 3D submission, saving, hot reload and the editor’s scene panel all begin here, so a singleton is ticked but is not part of the scene.
Assigning re-links the child and runs no lifecycle, which is what assigning to it has always done. Use :meth:
set_rootto mount a scene (enter + ready) and :meth:change_sceneto swap one.
- set_root(root: simvx.core.node.Node)[source]¶
Mount
rootas the current scene.Before the root enters the tree, any
input_actionsdeclared on the root (class- or instance-leveldict[str, list]) is bulk- registered with this tree’sInputMap. This is the canonical replacement for the wrapper-class +on_readyboilerplate and surviveschange_sceneswaps – every new root’s actions are re-registered automatically.A scene already mounted is carried out first, so its
on_exit_treeruns, exactly as :meth:change_scenedoes.change_sceneremains the call for a swap: it also clears pending deletes, popup state and the active 2D camera.The mount itself runs inside this tree’s input span, so
on_enter_tree/on_readycode sees this tree’sInputandInputMap, and any action it registers lands in this tree’s own map.
- change_scene(new_root: simvx.core.node.Node)[source]¶
Swap the active root with
new_root.The old root receives
_exit_tree;new_rootthen runs the full_enter_tree/_ready_recursivepath, identical to the initial root. Singletons the tree owns are left in place, keeping their groups and unique names; a singleton registered on a node the old scene owned leaves with that scene, and its name binding is dropped. Pending deletes, UI popup state, and the active 2D camera are cleared.Use this for title → gameplay → game-over navigation. See
- Doc:
../patternsfor a full example.
- flush_layout() None[source]¶
Reflow every dirty container now, root-first, and expire the rect cache.
Synchronous layout settle: after this returns, container children have their final positions/sizes for this frame, so
get_global_rectis valid immediately (no one-tick lag). Called at end-of-tick and, crucially, right when an overlay/popup opens so a freshly-shown dialog can be hit-tested and focus-elected on the same tick. Idempotent and O(1) when no container is dirty.The reflow itself runs inside this tree’s input span:
_do_update_layoutis an overridable hook, so a custom container laying itself out sees this tree’sInputandInputMap.
- tick(dt: float)[source]¶
Run process callbacks and coroutines on all nodes for one frame.
Order: singletons’
_processfirst, in registration order (global nodes, before the scene), thenself.events.flush_deferred()so deferred events queued during the previous frame (process, physics, or input) reach all subscribers before scene logic runs, then the scene root’s_process. Anythingemit_deferred’d during this frame’s singleton pass is also drained in the same flush, keeping the scene’s view of the world consistent.Singletons and the current scene are siblings under the tree’s own top node, and the scene is always its last child, so every node the tree ticks is reached through exactly one containment path and processes exactly once. A node registered as a singleton and parented into the scene is a scene node that happens to have a name: it processes in its scene position, not ahead of the scene.
- property physics_world: simvx.core.physics.world.PhysicsWorld[source]¶
The tree’s default PhysicsWorld, created lazily on first access.
Bodies with no PhysicsRoot ancestor resolve here. Lazy so non-physics scenes never allocate a world. Backend follows the selection precedence (no node override -> project
physics_backendsetting > auto-discovered native > Builtin), gravity -Y. Tree-scoped: it persists acrosschange_scene(likeevents) and is released only byquit()/ GC.
- property has_physics_world: bool[source]¶
True iff a default world has been lazily created (no allocation).
- property physics_world_2d: simvx.core.physics.world2d.Physics2DWorld[source]¶
The tree’s default 2D PhysicsWorld, created lazily on first access.
The 2D sibling of :attr:
physics_world. 2D bodies with noPhysicsRoot2Dancestor resolve here. Lazy so non-2D-physics scenes never allocate it. Backend follows the selection precedence (no node override -> projectphysics_backendsetting > auto-discovered native > Builtin), gravityVec2(0, -9.81)(Y-up). Tree-scoped: persists acrosschange_scene, released byquit()/ GC.
- register_physics_world(world: simvx.core.physics.world.PhysicsWorld) None[source]¶
Register a PhysicsRoot’s isolated world so physics_tick steps it.
Idempotent: a world already present is not added twice (defends against re-entrant enter_tree). Called from PhysicsRoot.on_enter_tree.
- unregister_physics_world(world: simvx.core.physics.world.PhysicsWorld) None[source]¶
Unregister a PhysicsRoot’s world (PhysicsRoot.on_exit_tree). No-op if absent.
The world will not be stepped by this tree again, so any body still in its retirement grace period is released here rather than left holding a node alive (see :meth:
retire_physics_node).
- register_physics_body(world: simvx.core.physics.world.PhysicsWorld, handle: simvx.core.physics.world.BodyHandle, node: simvx.core.node.Node) None[source]¶
Register a body node for post-step transform read-back.
Called by
PhysicsBody3D.on_enter_treefor non-static bodies. Marks the world’s membershipdirtyso the next sync rebuilds the bulk order + buffer. Weak reference: a GC’d node auto-drops.
- unregister_physics_body(world: simvx.core.physics.world.PhysicsWorld, handle: simvx.core.physics.world.BodyHandle) None[source]¶
Unregister a body node (
PhysicsBody3D.on_exit_tree). No-op if absent.Drops the whole per-world
_BodySynconce empty so idle worlds cost nothing.
- register_physics_node(world: simvx.core.physics.world.PhysicsWorld, handle: simvx.core.physics.world.BodyHandle, node: simvx.core.node.Node) None[source]¶
Register a body node in the collision-event handle->node map.
Called by
PhysicsBody3D.on_enter_treefor ALL modes (static included), so :meth:_dispatch_contact_eventscan resolve both sides of a contact pair. Mode-independent: unlikeregister_physics_bodythis map never changes on a mode flip. Weak reference: a GC’d node auto-drops.
- retire_physics_node(world: simvx.core.scene_tree.AnyPhysicsWorld, handle: simvx.core.physics.world.BodyHandle) None[source]¶
Move a body node out of the live map and into the retirement grace period.
Called by
PhysicsObject3D.on_exit_treeimmediately before the backend body is destroyed. Destroying a body ENDS every contact and sensor overlap it was in, and the seam reports those closingEXITs on the first drain after the NEXT step (see- Meth:
~simvx.core.physics.world.PhysicsWorld.destroy_body). Dropping the handle->node entry here would leave that dispatch unable to name the peer that went away, so the entry is retired instead: held, with a STRONG reference to the node (the live map is weak, and the game has usually let go of a node it destroyed), until the events it is owed have been dispatched.
The grace period is one step, measured on this tree’s per-world step count, and :meth:
_release_retired_physics_nodesends it. Retirement is never consulted before the live map, so a backend that recycles handles resolves a reused handle to its NEW body.One STEP, not one frame: a tree that stops stepping keeps the entry until it steps again, so pausing (or clearing
auto_physics) immediately after a kill holds that one node for the duration of the pause. It is released on the next step regardless, and bychange_sceneand by unregistering the world, so it cannot outlive the scene either way.No-op if the handle is not registered.
- interpolate_physics(alpha: float) None[source]¶
Write the render pose of every captured new-world body: lerp(prev, cur, alpha).
alphais the fixed-step fractionphysics_accum / physics_dtin [0, 1). Position lerps linearly; rotation slerps (shortest-arc, Quat.slerp). A sleeping body has prev == cur, so it lerps to a constant (correct, cheap). No-op whenphysics_interpolationis False (driver still calls it; the raw write already happened in _sync_physics_world during physics_tick).
- physics_tick(dt: float)[source]¶
Run physics_process callbacks on all nodes, then auto-step physics.
- propagate_input(event: simvx.core.events.TreeInputEvent) None[source]¶
Dispatch an input event to registered
@on_inputhandlers.Looks up handlers via the typed dispatch tables (built when nodes carrying
@on_input-decorated methods enter the tree). The traversal is O(handlers per event), not O(nodes): nodes without any input handlers cost nothing.A handler returning a truthy value marks the event consumed:
on_unhandled_inputonly fires if nothing consumed the event.event.handledis also flipped True so callers can short-circuit.Dispatch runs inside this tree’s input span, so a handler querying
Inputor registering onInputMapsees the tree the event was delivered to and not whichever pair is active for the caller draining the event queue.
- render(renderer)[source]¶
Draw the scene, then the overlay layer, into
renderer.Runs inside this tree’s input span:
on_drawis game code like any other hook, so a handler that readsInputor registers an action sees this tree rather than the process-wide default. That matters for an isolated tree, which is how the editor renders a game in play mode.
- input_cast(screen_pos: tuple[float, float] | numpy.ndarray, button: simvx.core.input.enums.MouseButton = MouseButton.LEFT)[source]¶
Cast a ray from screen_pos through the camera into the scene. Finds the nearest pickable CollisionShape3D and delivers an InputEvent to its parent node.
- group(name: str) list[simvx.core.node.Node][source]¶
Get all nodes in a group.
- get_first_in_group(name: str) simvx.core.node.Node | None[source]¶
Return one node from
name(any member), orNoneif empty.
- call_group(name: str, method: str, *args) None[source]¶
Call
method(*args)on every node innamethat defines it.Convenience for the common broadcast (
for n in tree.group(...): n.method(...)). Nodes lackingmethodare skipped, so mixed-type groups are safe. The broadcast runs inside this tree’s input span, so the methods it calls see this tree’sInputandInputMapwhether the caller was node code or a tool driving the tree from outside.
- create_timer(seconds: float) simvx.core.scene_tree.SceneTreeTimer[source]¶
Create a one-shot timer that emits
timeoutafterseconds.Returns immediately with a :class:
SceneTreeTimer; connect itstimeoutsignal to run code after the delay. The timer is owned and ticked by the tree (right after singletons) and discarded once it fires, so it needs no Node in the scene and no manual cleanup. Honourstree.paused. For delays inside a coroutine, preferwait(seconds).
- call_deferred(method: collections.abc.Callable[..., Any], *args: Any) None[source]¶
Queue
method(*args)to run at the end of this frame (escape hatch).The tree-level counterpart to :meth:
Node.call_deferred, for code that has no node to defer from (plugins, singleton helpers, non-node systems). Same discouraged-escape-hatch guidance applies: prefer a safe-by-default path (destroy,Property(coalesce=True),events.publish_deferred) when one exists.methodis a callable, never a string.
- property singletons: dict[str, simvx.core.node.Node][source]¶
Read-only view of registered singletons.
- add_singleton(name: str, node: simvx.core.node.Node)[source]¶
Register
nodeas a persistent singleton attached to the tree.An unparented node is mounted as a child of the tree’s own top node: it enters the tree, runs
on_ready()immediately, processes ahead of the scene in registration order, and surviveschange_scene(). That makes singletons the canonical home for global state (score, settings, audio manager). Retrieve one viatree.singletons[name]; it is not reachable from the scene root. See :doc:../patterns.A node the scene already owns may also be given a name. It stays where it is, so it processes in its scene position rather than ahead of the scene, and it leaves with its scene: after a
change_scenethe name is gone.A singleton is ticked but not drawn – drawing walks the scene. A global HUD is either a child of the scene or a full-screen overlay.
The name
"events"is reserved for the engine-provided- Class:
~simvx.core.event_bus.EventBus; usetree.eventsinstead.
Raises: ValueError: the name is
"events"; the name is already bound to a different node;nodealready holds a different name; ornodeis a child of some other tree.
- remove_singleton(name: str)[source]¶
Drop a singleton’s name binding, and unmount it if the tree owns it.
A singleton the tree mounted (registered while unparented) is carried out of the tree, so its
on_exit_treeruns and anything it registered on the way in is released. A singleton the scene owns keeps its place in the scene: dropping the name is the whole of the operation, because the node is a scene node that happens to have a name.
- unique(name: str) simvx.core.node.Node | None[source]¶
Get a unique node by name. Returns None if not found.
- clear_delete_queue() None[source]¶
Abandon every pending :meth:
Node.destroywithout running it.For a teardown that is carrying the whole tree out anyway (a scene swap, a harness dropping its tree): the queued nodes go with it, so the removal they were waiting for is moot. Clears their pending state as well, since a node left marked as destroying would report a removal that is never coming.
- ui_input(mouse_pos: tuple[float, float] | numpy.ndarray = None, button: simvx.core.input.enums.MouseButton | None = None, pressed: bool = True, key: str = '', char: str = '', ctrl: bool | None = None, shift: bool | None = None, alt: bool | None = None, meta: bool | None = None)[source]¶
Route UI input events to controls.
buttonis aMouseButtonfor press/release,Nonefor keyboard / char / pure mouse-move events.ctrl/shift/alt/metaare the modifier state at event time, and reach the control on every event kind, so a widget can implement a Shift-click. Left unset, each is read from the held-key state, which is what a caller with no modifier information of its own wants.Routing runs inside this tree’s input span:
_on_gui_input, focus signals and thepressed/releasedhandlers reached from here are arbitrary game code, and they see this tree’sInputandInputMap.