Source code for simvx.core.scene_step

"""The single canonical per-frame logic-phase sequence, shared by every embedder.

Consumers that drive a :class:`~simvx.core.scene_tree.SceneTree` one frame at a time
(the windowed/headless/streaming :class:`FrameLoop` and the editor's PlayMode today;
the AI rendered session and future embedders next) re-spell the same load-bearing
order: step physics, dispatch queued UI events, run logic. The order is pure
``SceneTree`` logic (``physics_tick`` / ``interpolate_physics`` / ``tick`` are core
methods with zero Vulkan), so it lives in core where every embedder, including the
ones that *cannot* import graphics, can share one definition. (The web runtime and
SceneRunner own deliberately different policies and are not routed through here yet:
see their notes.)

:func:`step_scene_logic` runs exactly that sequence and stops BEFORE the GPU half
(Draw2D reset + ``tree.render`` + submit/present) and BEFORE the windowed Input
frame boundary (``Input._end_frame`` / ``Input._new_frame``): those are the
caller's job, because they diverge per embedder (swapchain present vs offscreen
render-to-target vs nothing). This module imports nothing from graphics.

Per-phase variation is expressed through :class:`ScenePhases`: a frozen set of
optional hooks. ``phases=None`` runs the engine-default behaviour (one fixed
physics step + interpolate to the step boundary, then one logic tick). A caller
that owns a different physics-stepping policy (the windowed accumulator clock) or
a different per-node walk (the editor's error-tolerant / profiled walkers) supplies
``node_walk``; a caller with a queued UI-event drain or a between-physics-and-logic
window-sync supplies ``ui_events``; a profiler supplies ``on_phase``.
"""

from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from .node import Node
    from .scene_tree import SceneTree

__all__ = ["ScenePhases", "step_scene_logic"]


[docs] @dataclass(frozen=True, slots=True) class ScenePhases: """Optional per-phase hooks for :func:`step_scene_logic`. Every field defaults to ``None``, which selects the engine-default behaviour for that phase. Supply a hook only to override one phase; the canonical order (physics, then UI events, then logic) is fixed and not overridable. Attributes: node_walk: ``(root, dt, phase)`` where ``phase`` is ``"physics"`` or ``"process"``. When supplied it replaces the default physics step and/or the default logic tick: the caller owns how the tree is walked for that phase (its own physics-stepping policy, an error-tolerant or profiled per-node walk, etc.). ``root`` is the scene root, or ``None`` when the tree has no root. ui_events: called once, after physics and before logic, to drain queued UI / input events into the tree. This is the same slot the windowed loop uses for gamepad + window-size sync and the editor uses to flush its viewport UI-event queue. on_phase: ``(name, edge)`` with ``edge`` in ``{"begin", "end"}``, fired around each of ``"physics"``, ``"ui"``, ``"process"`` so a profiler can bracket per-phase timings. """ node_walk: Callable[[Node | None, float, str], None] | None = None ui_events: Callable[[], None] | None = None on_phase: Callable[[str, str], None] | None = None
[docs] def step_scene_logic( tree: SceneTree, dt: float, *, time_scale: float = 1.0, phases: ScenePhases | None = None, ) -> None: """Run one frame of the canonical logic-phase sequence on ``tree``. Order (load-bearing): 1. **physics** -- ``tree.physics_tick(dt * time_scale)`` then ``tree.interpolate_physics(1.0)``, OR ``phases.node_walk(root, dt, "physics")``. 2. **ui** -- ``phases.ui_events()`` if supplied (no engine default). 3. **process** -- ``tree.tick(dt * time_scale)``, OR ``phases.node_walk(root, dt, "process")``. Stops before Draw2D / submit / present and before the Input frame boundary: those stay with the caller. Imports nothing from graphics. With ``phases=None`` this is byte-identical to a hand-rolled ``tree.physics_tick(dt * time_scale); tree.interpolate_physics(1.0); tree.tick(dt * time_scale)``. Args: tree: the scene tree to advance. dt: frame delta in seconds (unscaled). time_scale: multiplier applied to ``dt`` for the default physics and logic steps (slow-mo / fast-forward). When ``node_walk`` is supplied the caller owns scaling, so ``dt`` is passed through unscaled. phases: optional per-phase overrides; ``None`` selects engine defaults. """ node_walk = phases.node_walk if phases is not None else None ui_events = phases.ui_events if phases is not None else None on_phase = phases.on_phase if phases is not None else None root = tree.root if on_phase is not None: on_phase("physics", "begin") if node_walk is not None: node_walk(root, dt, "physics") else: scaled = dt * time_scale tree.physics_tick(scaled) tree.interpolate_physics(1.0) if on_phase is not None: on_phase("physics", "end") if ui_events is not None: if on_phase is not None: on_phase("ui", "begin") ui_events() if on_phase is not None: on_phase("ui", "end") if on_phase is not None: on_phase("process", "begin") if node_walk is not None: node_walk(root, dt, "process") else: tree.tick(dt * time_scale) if on_phase is not None: on_phase("process", "end")