Source code for simvx.core.scripted_demo

"""Scripted demo playback: automated input sequences with narration and assertions.

Drive a game/UI scene with pre-recorded steps: move cursor, click, type text,
press keys, wait, assert state, and show narration overlays. Useful for
creating self-playing demos, tutorials, and integration tests.

Usage:
    from simvx.core.scripted_demo import DemoRunner, MoveTo, Click, Narrate, Assert

    steps = [
        Narrate("Welcome to the demo!", duration=2.0),
        MoveTo(200, 150, duration=0.5),
        Click(200, 150),
        Assert(lambda g: g.board[0][0] == "X", "Cell should be X"),
    ]
    runner = DemoRunner(steps, speed=2.0)
    game.add_child(runner)
"""

import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, ClassVar

from .descriptors import Property, UpdateMode
from .input.enums import _KEY_TO_NAME, Key, MouseButton
from .input.state import Input
from .node import Node
from .signals import Connection, Signal
from .testing.input_sim import InputSimulator

log = logging.getLogger(__name__)

# Speed presets: (label, multiplier)
_SPEED_PRESETS: list[tuple[str, float]] = [
    (">", 0.25),
    (">>", 0.5),
    (">>>", 50.0),
]

__all__ = [
    "MoveTo",
    "Click",
    "TypeText",
    "PressKey",
    "KeyDown",
    "KeyUp",
    "Wait",
    "Assert",
    "Do",
    "Scroll",
    "Narrate",
    "DemoRunner",
]

# ============================================================================
# Step dataclasses
# ============================================================================


[docs] @dataclass class MoveTo: """Smoothly move the virtual cursor to a screen position.""" x: float y: float duration: float = 0.5
[docs] @dataclass class Click: """Move to position then click (press + release).""" x: float y: float button: MouseButton = MouseButton.LEFT
[docs] @dataclass class TypeText: """Type a string character by character.""" text: str delay_per_char: float = 0.05
[docs] @dataclass class PressKey: """Press a key, hold it, then release it. ``hold_duration`` is in real seconds and is never scaled by playback speed: the runner converts it to whole fixed updates (``hold_duration`` divided by the fixed timestep, at least one), so the key is observably held for the same number of ticks at every speed. Reach for :class:`KeyDown`, :class:`Wait` and :class:`KeyUp` when the tick count is the thing under test, or when other steps have to run while the key is down. """ key: int hold_duration: float = 0.1
[docs] @dataclass class KeyDown: """Press a key and leave it held until a matching :class:`KeyUp`. Completes in one fixed update. Steps placed between the two edges decide how long the game sees the key held, so a hold is expressed in ticks rather than in speed-scaled seconds. """ key: int
[docs] @dataclass class KeyUp: """Release a key held by an earlier :class:`KeyDown`. Completes in one fixed update.""" key: int
[docs] @dataclass class Wait: """Pause playback, either for demo seconds or for a count of fixed updates. ``duration`` is demo time: it is scaled by playback speed, so it says how long a viewer should watch and collapses to a single tick at the fast preset. ``frames`` is a tick count and is never scaled, which is what a test needs when the game has to be given a known number of updates (while a key is held, for instance). Exactly one of the two is set. """ duration: float = 0.0 frames: int = 0
[docs] def __post_init__(self): if self.duration < 0 or self.frames < 0: raise ValueError("Wait takes a non-negative duration or frames count") if self.duration and self.frames: raise ValueError( "Wait takes either duration (demo seconds, scaled by speed) or frames (fixed updates), not both" ) if not self.duration and not self.frames: raise ValueError("Wait needs a duration in demo seconds or frames=N fixed updates")
[docs] @dataclass class Assert: """Run a check function against the game node (parent of DemoRunner).""" check_fn: Any # callable(game_node) -> bool message: str = "" actual_fn: Any = None # optional callable(game_node) -> value for diagnostics
[docs] @dataclass class Do: """Execute an action against the game node (parent of DemoRunner). Like Assert but semantically different: never fails on return value. In test_mode, exceptions propagate; in interactive mode, they're logged. """ action: Any # callable(game_node) message: str = ""
[docs] @dataclass class Scroll: """Scroll the mouse wheel at a position.""" x: float y: float dy: float = -3.0 # negative = scroll down, positive = scroll up dx: float = 0.0
[docs] @dataclass class Narrate: """Display narration text at the bottom of the screen.""" text: str duration: float = 2.0
# ============================================================================ # DemoRunner: Node that plays back a sequence of steps # ============================================================================
[docs] class DemoRunner(Node): """Plays a scripted sequence of demo steps, injecting input and drawing overlays. Add as a child of the game/scene root. In test_mode, hotkeys are disabled and assertions raise on failure. Args: steps: List of step dataclasses to execute in order. test_mode: If True, skip hotkeys and raise on assertion failure. halt_on_failure: In interactive mode, freeze playback at a failed check or step (RIGHT skips, Escape ends) instead of running every later step against derailed state. Pass False to record failures and keep playing (the playtest harness's reporting mode). on_complete: Optional callback invoked when all steps finish. speed: Explicit speed override. If set, takes precedence over speed_mode. speed_mode: Speed preset index into :data:`_SPEED_PRESETS` (0 = 0.25x, 1 = 0.5x, 2 = 50x). Default 0, so a demo opens at quarter speed unless the caller says otherwise. delay_between_steps: Natural pause (seconds) between steps. Default 0.15. Speed is cosmetic: it scales cursor glide, typing rate, narration and the pause between steps, which is what somebody watching a demo wants to change. It never scales how many fixed updates an input-bearing step occupies, so a key held across ``Wait(frames=3)`` is held for three ticks at every speed. """ # The overlay's cursor, narration fade and action text change every tick from plain # attributes, not Properties, so the retained 2D pipeline must re-capture on_draw # each frame while playback is live. Dropped again once playback finishes, where # nothing the overlay draws can change any more. dynamic = True # The runner drives the game from inside the game's own tree, so a game that # pauses that tree (a pause menu, a modal overlay) would otherwise freeze its # driver too and no later step could ever unpause it. Redeclared as a Property # rather than assigned in ``__init__``: an assignment there would run after # the kwarg gate and quietly discard a caller's ``update_mode=``. # ``on_change`` is repeated from the base declaration on purpose. A Property is # a descriptor, so redeclaring one to move its default replaces the base's # descriptor outright, hook included, and the resolved-mode cache would then # never be invalidated for this class or anything under it. update_mode = Property( UpdateMode.ALWAYS, hint="A demo runner outlives any pause it drives the game into", on_change="_invalidate_update_mode_cache", ) # Step handler registry: maps step type -> handler(runner, step, dt) _step_handlers: ClassVar[dict[type, Callable]] = {}
[docs] @classmethod def register_step_handler(cls, step_type: type, handler: Callable): """Register a handler for a custom step type. Handler signature: (runner: DemoRunner, step, dt: float) -> None. The handler must call runner._advance() when the step is complete. """ cls._step_handlers[step_type] = handler
def __init__( self, steps: list, test_mode: bool = False, on_complete: Callable | None = None, speed: float | None = None, speed_mode: int = 0, delay_between_steps: float = 0.15, halt_on_failure: bool = True, **kwargs, ): super().__init__(**kwargs) self.name = "DemoRunner" self._steps = list(steps) self._test_mode = test_mode self._halt_on_failure = halt_on_failure self._on_complete = on_complete # Speed: explicit override or preset self._speed_mode = max(0, min(2, speed_mode)) if speed is not None: self._speed = speed # Find closest preset for display, or leave mode as-is diffs = [abs(speed - p[1]) for p in _SPEED_PRESETS] self._speed_mode = diffs.index(min(diffs)) else: self._speed = _SPEED_PRESETS[self._speed_mode][1] self._inter_step_delay = delay_between_steps self._sim = InputSimulator() self._cursor_pos = (0.0, 0.0) # No step has positioned the cursor yet, so there is nothing to draw for it. self._cursor_placed = False self._current_step = 0 self._step_time = 0.0 # Fixed updates spent on the current step. Steps that carry input are # counted in ticks rather than in speed-scaled demo time. self._step_frames = 0 self._paused = False self._done = False # Inter-step delay state self._in_inter_delay = False self._inter_delay_accum = 0.0 # Assertion tracking self._failed = False self._halted = False self._failures: list[str] = [] # Narration overlay self._narration = "" self._narration_time = 0.0 # Current action description for input visualization overlay self._action_desc = "" # Internal state for multi-phase steps self._click_phase = 0 # 0=move, 1=press, 2=release self._click_move_origin = (0.0, 0.0) self._key_pressed = False self._type_index = 0 self._type_accum = 0.0 # Clickable speed bar hit rects (populated each frame by draw()) self._speed_btn_rects: list[tuple[float, float, float, float]] = [] self._pause_btn_rect: tuple[float, float, float, float] = (0, 0, 0, 0) # Live subscription to the tree's resize signal (see on_enter_tree) self._resize_conn: Connection | None = None # Signals self.completed = Signal() self.step_changed = Signal() self.assertion_failed = Signal() # ------------------------------------------------------------------ properties
[docs] @property def current_step_index(self) -> int: return self._current_step
[docs] @property def total_steps(self) -> int: return len(self._steps)
[docs] @property def is_done(self) -> bool: return self._done
[docs] @property def failures(self) -> list[str]: return list(self._failures)
[docs] def step_failed(self, message: str) -> None: """Record that a driver step could not do its job. A step that cannot find its target (a menu label, a popup item, a widget) must say so: silently advancing lets every later step run against derailed state, typing into whatever happens to hold focus. Test mode raises like a failed :class:`Assert`; visual mode records the failure, shows it, and halts playback at the failed step. """ self._failed = True self._failures.append(message) self.assertion_failed(message) if self._test_mode: raise AssertionError(message) if self._halt_on_failure: self._halted = True self._narration = f"STEP FAILED: {message}" self._narration_time = 4.0
# ------------------------------------------------------------------ lifecycle def _set_speed_mode(self, mode: int): """Switch speed preset (0=slow, 1=fast, 2=instant).""" self._speed_mode = max(0, min(2, mode)) self._speed = _SPEED_PRESETS[self._speed_mode][1] def _check_speed_bar_click(self): """Check if a mouse click landed on a speed or pause button.""" from .input.enums import MouseButton if not Input.is_mouse_button_just_pressed(MouseButton.LEFT): return mx, my = Input._mouse_pos for i, (bx, by, bw, bh) in enumerate(self._speed_btn_rects): if bx <= mx <= bx + bw and by <= my <= by + bh: self._set_speed_mode(i) return px, py, pw, ph = self._pause_btn_rect if px <= mx <= px + pw and py <= my <= py + ph: self._paused = not self._paused def _get_action_desc(self) -> str: """Return a human-readable description of the current step for the overlay.""" if self._current_step >= len(self._steps): return "" step = self._steps[self._current_step] if isinstance(step, Click): return f"Click ({int(step.x)}, {int(step.y)})" if isinstance(step, TypeText): typed = step.text[: self._type_index] # Show only the current line's last few characters to keep overlay compact last_line = typed.rsplit("\n", 1)[-1] if len(last_line) > 20: last_line = last_line[-20:] return f"Type: {last_line}" if last_line else "Type: \u21b5" if isinstance(step, PressKey): return f"Key: {_key_event_name(step.key)}" if isinstance(step, KeyDown): return f"Key down: {_key_event_name(step.key)}" if isinstance(step, KeyUp): return f"Key up: {_key_event_name(step.key)}" if isinstance(step, MoveTo): return "Moving cursor..." if isinstance(step, Assert): return "Checking..." if isinstance(step, Do): return f"Doing: {step.message}" if step.message else "Doing..." if isinstance(step, Wait): return "..." if isinstance(step, Scroll): return f"Scroll {'down' if step.dy < 0 else 'up'}" if isinstance(step, Narrate): return "" return ""
[docs] def on_enter_tree(self) -> None: """Subscribe to window resizes so the overlay re-lays out after playback ends.""" super().on_enter_tree() tree = self.tree if tree is not None: self._resize_conn = tree.screen_resized.connect(self._on_screen_resized)
[docs] def on_exit_tree(self) -> None: """Drop the resize subscription taken in :meth:`on_enter_tree`.""" super().on_exit_tree() if self._resize_conn is not None: self._resize_conn.disconnect() self._resize_conn = None
def _on_screen_resized(self, _size) -> None: # The speed bar, narration panel and step counter are positioned from the # screen width. A viewport change is a view-only update that never re-runs # on_draw, so ask for one explicitly: once playback ends the runner is no # longer dynamic and this is its only route back to the current width. self.queue_redraw()
[docs] def on_fixed_update(self, dt: float): # DemoRunner ticks in physics_process so that input simulation (PressKey, Click, # TypeText) happens BEFORE the game node's process(), matching real GLFW/SDL input # timing. This ensures is_action_just_pressed() sees the key on the same frame. if self._done: return # Bind the simulator to the runner's tree the first time we tick. # InputSimulator falls back to SceneTree.current() otherwise, which # can be a stale tree in editor scenarios that own multiple trees. if self._sim._bound_tree is None and self._tree is not None: self._sim._bound_tree = self._tree # Hotkey controls (only in interactive mode) if not self._test_mode: if Input.is_key_just_pressed(Key.SPACE): self._paused = not self._paused if Input.is_key_just_pressed(Key.ESCAPE): self._finish() return if Input.is_key_just_pressed(Key.RIGHT): self._skip_step() return # Clickable speed bar: check if mouse clicked on a speed button self._check_speed_bar_click() if self._paused: return # A failed step froze playback: hold the frame so the failure (and the # state that caused it) stays on screen instead of cascading into every # later step. RIGHT skips past the failed step; Escape ends the demo. if self._halted: return # Tick narration if self._narration_time > 0: self._narration_time -= dt if self._narration_time <= 0: self._narration = "" if self._current_step >= len(self._steps): self._finish() return # Inter-step delay: natural pause between steps, scaled by speed if self._in_inter_delay: self._inter_delay_accum += dt delay_scaled = self._inter_step_delay / max(self._speed, 0.01) if self._inter_delay_accum >= delay_scaled: self._in_inter_delay = False self._inter_delay_accum = 0.0 else: return effective_dt = dt * self._speed step = self._steps[self._current_step] self._step_time += effective_dt self._step_frames += 1 # Update action description for input visualization self._action_desc = self._get_action_desc() if isinstance(step, MoveTo): self._process_move_to(step, effective_dt) elif isinstance(step, Click): self._process_click(step, effective_dt) elif isinstance(step, TypeText): self._process_type_text(step, effective_dt) elif isinstance(step, PressKey): # Raw dt, not the scaled one: the hold is measured in fixed updates. self._process_press_key(step, dt) elif isinstance(step, KeyDown): self._dispatch_key(step.key, True) self._advance() elif isinstance(step, KeyUp): self._dispatch_key(step.key, False) self._advance() elif isinstance(step, Wait): if step.frames: if self._step_frames >= step.frames: self._advance() elif self._step_time >= step.duration: self._advance() elif isinstance(step, Assert): self._process_assert(step) elif isinstance(step, Do): self._process_do(step) elif isinstance(step, Scroll): self._process_scroll(step) elif isinstance(step, Narrate): self._process_narrate(step) elif type(step) in self._step_handlers: self._step_handlers[type(step)](self, step, effective_dt) else: log.warning("Unknown step type %s at step %d: skipping", type(step).__name__, self._current_step) self._advance()
[docs] def on_draw(self, renderer): cx, cy = self._cursor_pos # The cursor is only drawn once a step has actually put it somewhere. A demo # that opens with narration has no pointer yet, and painting one at the origin # would show a cursor parked in the corner for as long as that opening runs. step = self._steps[self._current_step] if self._current_step < len(self._steps) else None if self._cursor_placed: # Yellow, turning red while a click is being pressed. is_clicking = isinstance(step, Click) and self._click_phase >= 1 cursor_colour = ( (1.0, 60 / 255, 60 / 255, 200 / 255) if is_clicking else (1.0, 220 / 255, 50 / 255, 200 / 255) ) renderer.draw_circle((cx, cy), 16, colour=cursor_colour, filled=True, segments=16) # Crosshair crosshair_colour = (1.0, 1.0, 1.0, 150 / 255) renderer.draw_line((cx - 24, cy), (cx + 24, cy), colour=crosshair_colour) renderer.draw_line((cx, cy - 24), (cx, cy + 24), colour=crosshair_colour) # Narration panel at bottom (fades out at end of display time) if self._narration: sw, sh = self._get_screen_size() _FADE_DUR = 0.5 fade = min(self._narration_time / _FADE_DUR, 1.0) if self._narration_time < _FADE_DUR else 1.0 panel_h = 80 panel_y = sh - panel_h - 16 renderer.draw_rect((10, panel_y), (sw - 20, panel_h), colour=(0.0, 0.0, 0.0, 200 / 255 * fade), filled=True) renderer.draw_text( self._narration, (28, panel_y + 18), colour=(1.0, 1.0, 1.0, 240 / 255 * fade), scale=2.0, ) # --- Speed controls + play/pause (top-center, clickable) --- sw, _ = self._get_screen_size() bar_w = 400 bar_h = 48 bar_x = (sw - bar_w) / 2 bar_y = 8 renderer.draw_rect((bar_x, bar_y), (bar_w, bar_h), colour=(0.0, 0.0, 0.0, 140 / 255), filled=True) x_off = bar_x + 12 self._speed_btn_rects.clear() for i, (label, _mult) in enumerate(_SPEED_PRESETS): btn_w = len(label) * 24 + 28 self._speed_btn_rects.append((x_off - 6, bar_y, btn_w, bar_h)) label_colour = ( (1.0, 1.0, 1.0, 1.0) if i == self._speed_mode else (120 / 255, 120 / 255, 120 / 255, 180 / 255) ) renderer.draw_text(label, (x_off, 16), colour=label_colour, scale=1.6) x_off += btn_w # --- Play/Pause indicator (clickable) --- pause_x = x_off - 6 pause_w = bar_x + bar_w - pause_x self._pause_btn_rect = (pause_x, bar_y, pause_w, bar_h) if self._paused: renderer.draw_text("PAUSED", (x_off, 16), colour=(1.0, 165 / 255, 0.0, 1.0), scale=1.6) else: renderer.draw_text(">", (x_off, 16), colour=(100 / 255, 220 / 255, 100 / 255, 200 / 255), scale=1.6) # Step counter in top-right # Clamped: the step index runs one past the last step once playback finishes, # and the final overlay stays on screen, so an unclamped counter reads "4/3". counter = f"{min(self._current_step + 1, len(self._steps))}/{len(self._steps)}" renderer.draw_text(counter, (sw - 160, 16), colour=(200 / 255, 200 / 255, 200 / 255, 180 / 255), scale=1.6) # --- Input visualization (below step counter) --- if self._action_desc: desc_w = min(len(self._action_desc) * 18 + 32, 480) desc_x = sw - desc_w - 16 renderer.draw_rect((desc_x, 60), (desc_w, 44), colour=(0.0, 0.0, 0.0, 140 / 255), filled=True) renderer.draw_text( self._action_desc, (desc_x + 16, 68), colour=(180 / 255, 220 / 255, 1.0, 220 / 255), scale=1.4 )
# ------------------------------------------------------------------ step processors def _place_cursor(self, x: float, y: float) -> None: """Put the overlay cursor at a point and drive the simulated pointer there. The two always go together: the drawn cursor is only honest if the simulated pointer agrees with it. Placing it also marks the cursor as real, which is what lets ``on_draw`` leave it off screen entirely until a step has positioned it. """ self._cursor_pos = (x, y) self._cursor_placed = True self._sim.move_mouse(x, y) def _process_move_to(self, step: MoveTo, dt: float): t = min(self._step_time / max(step.duration, 0.001), 1.0) t = _smooth_step(t) ox, oy = self._click_move_origin self._place_cursor(ox + (step.x - ox) * t, oy + (step.y - oy) * t) if t >= 1.0: self._place_cursor(step.x, step.y) self._advance() def _process_click(self, step: Click, dt: float): if self._click_phase == 0: # Move phase: lerp to target move_dur = 0.3 / max(self._speed, 0.01) t = min(self._step_time / max(move_dur, 0.001), 1.0) t = _smooth_step(t) ox, oy = self._click_move_origin self._place_cursor(ox + (step.x - ox) * t, oy + (step.y - oy) * t) if t >= 1.0: self._place_cursor(step.x, step.y) self._click_phase = 1 self._step_time = 0.0 elif self._click_phase == 1: # Press: sim drives Input state, ``@on_input`` decorators, and # UI widget dispatch in one call. self._sim.press_mouse(step.button, (step.x, step.y)) self._click_phase = 2 self._step_time = 0.0 elif self._click_phase == 2: # Release after brief hold if self._step_time >= 0.05: self._sim.release_mouse(step.button) self._advance() def _process_type_text(self, step: TypeText, dt: float): self._type_accum += dt while self._type_accum >= step.delay_per_char and self._type_index < len(step.text): ch = step.text[self._type_index] if ch == "\n": # Newline → dispatch Enter key press/release if self._tree: self._tree.ui_input(key="enter", pressed=True) self._tree.ui_input(key="enter", pressed=False) else: # Dispatch character through the scene tree so focused widgets receive it if self._tree: self._tree.ui_input(char=ch) # Also inject into Input singleton for non-UI consumers key_code = ord(ch.upper()) if ch.isalpha() else ord(ch) self._sim.tap_key(key_code) self._type_index += 1 self._type_accum -= step.delay_per_char if self._type_index >= len(step.text): self._advance() def _dispatch_key(self, key: int, pressed: bool) -> None: """Drive one key edge into every path a real window feeds. The simulator routes through ``InputRouter``, so the state write, the tree input event and the ``tree.ui_input`` call focused widgets and popups listen on all come from the one call. """ if pressed: self._sim.press_key(key) else: self._sim.release_key(key) def _process_press_key(self, step: PressKey, fixed_dt: float): if not self._key_pressed: self._dispatch_key(step.key, True) self._key_pressed = True # Whole ticks, so the hold survives any speed multiplier and a game # sampling once per fixed update sees the key down on each of them. hold_frames = max(1, round(step.hold_duration / max(fixed_dt, 1e-6))) if self._step_frames > hold_frames: self._dispatch_key(step.key, False) self._advance() def _process_assert(self, step: Assert): game_node = self.parent try: result = step.check_fn(game_node) except Exception as exc: import traceback result = False tb = traceback.format_exc() step = Assert(check_fn=step.check_fn, message=f"{step.message} (raised {exc})\n{tb}") if not result: msg = step.message or f"Assertion failed at step {self._current_step}" if step.actual_fn is not None: try: actual = step.actual_fn(game_node) msg = f"{msg} (actual: {actual!r})" except (AttributeError, TypeError, KeyError): log.exception("Could not resolve assertion value during scripted demo") self._failed = True self._failures.append(msg) self.assertion_failed(msg) if self._test_mode: raise AssertionError(msg) if self._halt_on_failure: # Freeze at the failed check instead of running every later # step against derailed state (RIGHT skips, Escape ends). self._halted = True self._narration = f"CHECK FAILED: {msg.splitlines()[0]}" self._narration_time = 4.0 return self._advance() def _process_do(self, step: Do): game_node = self.parent try: step.action(game_node) except Exception as exc: if self._test_mode: raise log.error("Do step failed at step %d: %s: %s", self._current_step, step.message, exc) self._advance() def _process_scroll(self, step: Scroll): """Move cursor to position and dispatch scroll events through UI.""" self._place_cursor(step.x, step.y) if self._tree: self._tree.ui_input( mouse_pos=(step.x, step.y), key="scroll_down" if step.dy < 0 else "scroll_up", pressed=True ) # Dispatch multiple scroll ticks for larger scroll amounts ticks = max(1, int(abs(step.dy))) key = "scroll_down" if step.dy < 0 else "scroll_up" for _ in range(ticks - 1): self._tree.ui_input(mouse_pos=(step.x, step.y), key=key, pressed=True) self._advance() def _process_narrate(self, step: Narrate): if self._step_time == 0 or (self._step_time < 0.02): # First frame of narration step: persist for 2x the step duration self._narration = step.text self._narration_time = step.duration * 2.0 if self._step_time >= step.duration: self._advance() # ------------------------------------------------------------------ navigation def _advance(self): """Move to the next step, resetting per-step state.""" self._current_step += 1 self._step_time = 0.0 self._step_frames = 0 self._click_phase = 0 self._click_move_origin = self._cursor_pos self._key_pressed = False self._type_index = 0 self._type_accum = 0.0 self._action_desc = "" self.step_changed(self._current_step) if self._current_step >= len(self._steps): self._finish() elif self._inter_step_delay > 0: # Enter inter-step delay so the demo has a natural pause self._in_inter_delay = True self._inter_delay_accum = 0.0 def _skip_step(self): """Skip current step immediately (interactive mode).""" step = self._steps[self._current_step] if self._current_step < len(self._steps) else None # Clean up in-flight input if isinstance(step, PressKey) and self._key_pressed: self._dispatch_key(step.key, False) if isinstance(step, Click) and self._click_phase == 1: self._sim.release_mouse(step.button) self._halted = False self._advance() def _finish(self): """Mark playback as done and fire completion.""" if self._done: return self._done = True # on_fixed_update returns immediately from here on, so no attribute the # overlay draws can change again: stop re-capturing every frame and take # one last capture of the final picture. self.dynamic = False self.queue_redraw() self.completed() if self._on_complete: self._on_complete() # ------------------------------------------------------------------ class methods
[docs] @classmethod def run_headless( cls, scene: Node, steps: list, *, speed: float = 50.0, screen_size: tuple[int, int] = (800, 600), max_frames: int = 20000, delay_between_steps: float = 0.0, ) -> bool: """Run a demo headlessly and return True if all steps pass. Creates a DemoRunner in test_mode, adds it to *scene*, and advances frames via SceneRunner until completion or *max_frames* is reached. """ from .testing.scene_runner import SceneRunner as _SR runner = cls(steps, test_mode=True, speed=speed, delay_between_steps=delay_between_steps) scene.add_child(runner) sr = _SR(screen_size=screen_size) sr.load(scene) for _ in range(max_frames): sr.advance_frames(1) if runner.is_done: break if not runner.is_done: log.error("Stuck at step %d/%d", runner.current_step_index + 1, runner.total_steps) if runner.failures: for f in runner.failures: log.error(" %s", f) return False if runner.failures: for f in runner.failures: log.error("FAIL: %s", f) return False log.info("OK: All %d steps passed", runner.total_steps) return True
[docs] @classmethod def run_visual( cls, scene: Node, steps: list, *, speed: float | None = None, speed_mode: int = 0, title: str = "Demo", width: int = 800, height: int = 600, backend: str | None = None, ): """Run a demo visually with the Vulkan App. Lazily imports ``simvx.graphics.App`` to keep core free of graphics deps. """ from simvx.graphics import App if speed is not None: runner = cls(steps, test_mode=False, speed=speed) else: runner = cls(steps, test_mode=False, speed_mode=speed_mode) scene.add_child(runner) App(title=title, width=width, height=height, backend=backend).run(scene)
# ------------------------------------------------------------------ internals def _get_screen_size(self) -> tuple[float, float]: if self._tree: ss = self._tree.screen_size return (float(ss[0]), float(ss[1])) if not isinstance(ss, tuple) else ss return (800.0, 600.0)
# ============================================================================ # Utilities # ============================================================================ def _key_event_name(key: int | Key) -> str: """Return the lowercase key name ``SceneTree.ui_input`` expects. Codes outside :class:`Key` (a raw character code, say) get a stable ``key_<code>`` spelling rather than raising, so an unmapped key still reaches the UI path as something a widget can ignore by name. """ try: key_enum = key if isinstance(key, Key) else Key(int(key)) except ValueError: return f"key_{int(key)}" return _KEY_TO_NAME.get(key_enum, "") or key_enum.name.lower() def _smooth_step(t: float) -> float: """Hermite smoothstep for natural cursor movement.""" t = max(0.0, min(1.0, t)) return t * t * (3.0 - 2.0 * t)