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