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`, the editor's PlayMode and the
headless :class:`~simvx.core.testing.SceneRunner` today; the AI rendered session and
future embedders next) re-spell the same load-bearing order: pump polled input,
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 owns a deliberately different policy and is not routed
through here yet: see its 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 that polls a device the platform does not deliver by callback
supplies ``input_pump``; 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, kw_only=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 (input pump, then physics, then UI events, then logic) is fixed and not overridable. The fields are declared in that order and are keyword-only: four interchangeable callables are not something to pass by position. Attributes: input_pump: called once at the very top of the frame, before physics, to publish input the platform does not deliver by callback. The frame's coalesced pointer motion is delivered just before it, by the engine, for every caller. Controllers are polled, so this is where the poll and the flush of its edges belong: a pad edge published after the physics phase reaches a fixed tick a whole frame later than the same action bound to a key, which the window callback delivers before the frame starts. 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 window-size and theme 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 ``"input"``, ``"physics"``, ``"ui"``, ``"process"`` so a profiler can bracket per-phase timings. """ input_pump: Callable[[], None] | None = None 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. **input** -- ``tree.flush_pointer_motion()``, then ``phases.input_pump()`` if supplied, so the frame's pointer motion and a polled device's edges are both published before any tick can read them. 2. **physics** -- ``tree.physics_tick(dt * time_scale)`` then ``tree.interpolate_physics(1.0)``, OR ``phases.node_walk(root, dt, "physics")``. 3. **ui** -- ``phases.ui_events()`` if supplied (no engine default). 4. **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. """ input_pump = phases.input_pump if phases is not None else None 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("input", "begin") # The frame's pointer motion, coalesced into one event per pointer and # delivered here: ahead of the first tick, so nothing reads a position # that is a frame behind, and ahead of the polled devices, so the pointer # and the pad arrive in the order the player made them. tree.flush_pointer_motion() if input_pump is not None: input_pump() if on_phase is not None: on_phase("input", "end") 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")