"""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")