Source code for simvx.core.testing.scene_runner

"""SceneRunner -- headless scene runner for testing game logic."""

from __future__ import annotations

from typing import Any

from ..input import Input
from ..node import Node
from ..scene_tree import SceneTree

__all__ = ["SceneRunner"]


[docs] class SceneRunner: """Headless scene runner for testing game logic without rendering. Manages a SceneTree, advances frames, and provides query helpers. """ def __init__(self, screen_size: tuple[float, float] = (800, 600)): self.tree = SceneTree(screen_size=screen_size) self._frame_count = 0 self._dt = 1 / 60 self._physics_dt = 1 / 60 self._time = 0.0 self._draw_renderer = None # Set to a DrawLog for draw validation
[docs] def load(self, root_node: Node) -> SceneRunner: """Load a scene by setting the root node.""" self.tree.set_root(root_node) self._frame_count = 0 self._time = 0.0 return self
[docs] def advance_frames(self, count: int = 1, dt: float | None = None, draw: bool = False) -> SceneRunner: """Advance the scene by N frames (process + physics_process each). If *draw* is True, also calls ``tree.draw()`` with a mock renderer each frame. This exercises draw() methods and catches errors that would otherwise only surface with a real Vulkan renderer. """ frame_dt = dt or self._dt for _ in range(count): self.tree.process(frame_dt) self.tree.physics_process(self._physics_dt) if draw: from ..ui.testing import DrawLog self.tree.draw(self._draw_renderer or DrawLog()) Input._end_frame() Input._new_frame() self._frame_count += 1 self._time += frame_dt return self
[docs] def advance_time(self, seconds: float, dt: float | None = None) -> SceneRunner: """Advance the scene by approximately N seconds worth of frames.""" frame_dt = dt or self._dt frames = max(1, int(seconds / frame_dt)) return self.advance_frames(frames, frame_dt)
[docs] def find(self, name_or_type, recursive: bool = True) -> Node | None: """Find a node by name (str) or by type in the scene tree.""" if self.tree.root is None: return None if isinstance(name_or_type, str): return self._find_by_name(self.tree.root, name_or_type) return self._find_by_type(self.tree.root, name_or_type, recursive)
[docs] def find_all(self, node_type: type) -> list[Node]: """Find all nodes of a given type in the scene.""" if self.tree.root is None: return [] result = [] if isinstance(self.tree.root, node_type): result.append(self.tree.root) result.extend(self.tree.root.find_all(node_type)) return result
def _find_by_name(self, node: Node, name: str) -> Node | None: if node.name == name: return node for child in node.children: result = self._find_by_name(child, name) if result: return result return None def _find_by_type(self, node: Node, node_type: type, recursive: bool) -> Node | None: if isinstance(node, node_type): return node if recursive: for child in node.children: result = self._find_by_type(child, node_type, recursive) if result: return result else: for child in node.children: if isinstance(child, node_type): return child return None @property def root(self) -> Node | None: return self.tree.root @property def frame_count(self) -> int: return self._frame_count @property def elapsed_time(self) -> float: return self._time
[docs] def snapshot(self) -> dict: """Capture current scene state as a serializable dict.""" if self.tree.root is None: return {} return _snapshot_node(self.tree.root)
def _snapshot_node(node: Node) -> dict: """Recursively capture a node's state as a serializable dict.""" state: dict[str, Any] = { "name": node.name, "type": type(node).__name__, "settings": {}, "properties": {}, "children": [], } # Capture Property descriptor values for name in type(node).get_properties(): try: val = getattr(node, name) if isinstance(val, (int, float, bool, str, tuple, list)): state["settings"][name] = val except Exception: pass # Capture spatial properties (not Settings, but essential for game testing) for attr in ("position", "rotation", "scale"): if hasattr(node, attr): val = getattr(node, attr) try: # Convert Vec2/Vec3/Quat to tuple for serializability if hasattr(val, "x") and hasattr(val, "y"): if hasattr(val, "z"): if hasattr(val, "w"): state["properties"][attr] = (float(val.x), float(val.y), float(val.z), float(val.w)) else: state["properties"][attr] = (float(val.x), float(val.y), float(val.z)) else: state["properties"][attr] = (float(val.x), float(val.y)) elif isinstance(val, (int, float)): state["properties"][attr] = val except Exception: pass # Capture visible state state["properties"]["visible"] = node.visible for child in node.children: state["children"].append(_snapshot_node(child)) return state