Source code for simvx.editor.play_mode

"""Play Mode — Run/pause/stop lifecycle for in-editor game preview.

Manages scene serialization/restoration, camera switching, input routing,
and per-frame process/physics updates during play mode. Works alongside
EditorState which holds the scene tree and play flags.

Usage:
    play_mode = PlayMode(editor_state)
    play_mode.start()       # F5 — serialize scene, begin processing
    play_mode.toggle_pause() # F7 — pause/resume processing
    play_mode.stop()         # F6 — restore pre-play scene state

    # Called each frame by the editor's main loop:
    play_mode.update(dt)
"""


from __future__ import annotations

import math

from simvx.core import Camera3D, Input, MouseButton, Node, OrbitCamera3D
from simvx.core.scene import _deserialize_node, _serialize_node

from .state import EditorState

# Orbit camera input sensitivity
_ORBIT_SENSITIVITY = math.radians(0.35)  # radians per pixel of mouse movement
_ZOOM_SENSITIVITY = 1.5                  # distance change per scroll step
_PITCH_MIN = math.radians(-85.0)         # upper half-sphere: can't go below floor
_PITCH_MAX = math.radians(-2.0)          # prevent flipping to underside

# Border colours for the viewport overlay (RGBA, 0-1 range)
_COLOUR_PLAYING = (0.2, 0.8, 0.2, 1.0)  # Green — game is running
_COLOUR_PAUSED = (1.0, 0.6, 0.0, 1.0)  # Orange — game is paused
_COLOUR_STOPPED = None  # No border when stopped


[docs] class PlayMode: """Manages the run/pause/stop lifecycle for previewing games in the editor. Coordinates with EditorState for scene access and flag storage. The editor's process loop should call ``update(dt)`` every frame. """ def __init__(self, state: EditorState): self._state = state # Serialized snapshot of the scene before play started self._saved_scene_data: dict | None = None # The game's own camera (if the scene contains one) self._game_camera: Camera3D | None = None # Runtime metrics self._elapsed_time: float = 0.0 self._frame_count: int = 0 # Whether ready() has been called on the scene tree self._ready_called: bool = False # Orbit camera drag state self._orbit_dragging: bool = False self._last_mouse: tuple[float, float] = (0.0, 0.0) # ------------------------------------------------------------------ # Public API # ------------------------------------------------------------------
[docs] def start(self) -> None: """Begin play mode (triggered by F5). Serializes the current scene so it can be restored on stop, locates the game camera, and starts processing the scene tree. """ if self._state.is_playing: return root = self._get_root() if root is None: return # Deep-serialize the scene for later restoration self._saved_scene_data = _serialize_node(root) # Locate the scene's Camera3D (if any) for rendering self._game_camera = self._find_game_camera(root) # Reset runtime metrics self._elapsed_time = 0.0 self._frame_count = 0 self._ready_called = False # User game code gets recovery behaviour, not crashes Node.strict_errors = False # Flip the state flags self._state.is_playing = True self._state.is_paused = False self._state.play_state_changed.emit() # Initialize the scene tree — call ready() on all nodes self._call_ready(root) self._ready_called = True
[docs] def toggle_pause(self) -> None: """Toggle pause during play mode (triggered by F7). When paused, process/physics updates are skipped but the scene continues to render so the user can inspect the frozen state. """ if not self._state.is_playing: return self._state.is_paused = not self._state.is_paused self._state.play_state_changed.emit()
[docs] def stop(self) -> None: """Stop play mode and restore the pre-play scene (triggered by F6). Deserializes the saved snapshot back into the scene tree, clearing any runtime state that was created during play. """ if not self._state.is_playing: return # Clear play flags first self._state.is_playing = False self._state.is_paused = False # Restore strict errors for editor's own code Node.strict_errors = True # Restore scene from the saved snapshot if self._saved_scene_data is not None: restored_root = _deserialize_node(self._saved_scene_data) if restored_root is not None: self._state.edited_scene.set_root(restored_root) self._saved_scene_data = None # Discard runtime references self._game_camera = None self._elapsed_time = 0.0 self._frame_count = 0 self._ready_called = False # Notify listeners self._state.play_state_changed.emit() self._state.scene_changed.emit()
[docs] def update(self, dt: float) -> None: """Per-frame update, called by the editor's process loop. When playing and not paused this drives the scene's ``process`` and ``physics_process`` callbacks. Metrics are always updated so the status bar can show elapsed time and frame count. Args: dt: Delta time in seconds since the last frame. """ if not self._state.is_playing: self._orbit_dragging = False self._game_camera = None return # Always tick metrics (even when paused, for display purposes) self._elapsed_time += dt self._frame_count += 1 # Lazily locate the game camera (PlayMode.start() may not have been called) if self._game_camera is None: root = self._get_root() if root is not None: self._game_camera = self._find_game_camera(root) # Orbit camera controls (active even when paused so user can inspect) self._handle_orbit_input() if self._state.is_paused: return root = self._get_root() if root is None: return # Drive the scene tree (physics first, matching real engine frame order) self._physics_process_tree(root, dt) self._process_tree(root, dt) # Flush any nodes queued for deletion tree = self._state.edited_scene if tree is not None: tree._flush_deletes()
# ------------------------------------------------------------------ # Input routing # ------------------------------------------------------------------
[docs] def should_route_input_to_game(self) -> bool: """Return True when game input events should be forwarded to the scene. Editor-global shortcuts (F5/F6/F7) are always processed by the editor regardless of this flag. """ return self._state.is_playing and not self._state.is_paused
# ------------------------------------------------------------------ # Camera management # ------------------------------------------------------------------
[docs] def get_active_camera(self) -> Camera3D | None: """Return the camera that should drive the viewport. During play mode the game's own Camera3D is used. Outside of play mode the editor's orbit camera is returned. """ if self._state.is_playing and self._game_camera is not None: return self._game_camera return self._state.editor_camera
def _find_game_camera(self, root: Node) -> Camera3D | None: """Traverse the scene tree and return the first Camera3D node. Skips the editor's own camera instance (stored in EditorState). """ editor_cam = self._state.editor_camera for node in root.find_all(Camera3D, recursive=True): if node is not editor_cam: return node return None # Alias for external callers find_game_camera = _find_game_camera def _handle_orbit_input(self) -> None: """Handle mouse drag to orbit the game camera during play mode. Works with OrbitCamera3D nodes: left-drag orbits, scroll zooms. Pitch is clamped to the upper half-sphere so the camera can't go below the floor. """ cam = self._game_camera if cam is None or not isinstance(cam, OrbitCamera3D): return mx, my = Input.get_mouse_position() lmb = Input.is_mouse_button_pressed(MouseButton.LEFT) if lmb: if self._orbit_dragging: dx = mx - self._last_mouse[0] dy = my - self._last_mouse[1] if dx != 0 or dy != 0: cam.yaw -= dx * _ORBIT_SENSITIVITY cam.pitch = max(_PITCH_MIN, min(_PITCH_MAX, cam.pitch - dy * _ORBIT_SENSITIVITY)) cam._update_transform() self._orbit_dragging = True else: self._orbit_dragging = False self._last_mouse = (mx, my) # Scroll to zoom _sx, sy = Input.get_scroll_delta() if sy != 0: cam.zoom(sy * _ZOOM_SENSITIVITY) # ------------------------------------------------------------------ # Visual indicator # ------------------------------------------------------------------
[docs] def get_border_colour(self) -> tuple[float, float, float, float] | None: """Return a viewport border colour indicating the current play state. * Green ``(0.2, 0.8, 0.2, 1.0)`` — game is running. * Orange ``(1.0, 0.6, 0.0, 1.0)`` — game is paused. * ``None`` — editor is in normal (stopped) mode. """ if not self._state.is_playing: return _COLOUR_STOPPED if self._state.is_paused: return _COLOUR_PAUSED return _COLOUR_PLAYING
# ------------------------------------------------------------------ # Runtime metrics (read-only) # ------------------------------------------------------------------ @property def elapsed_time(self) -> float: """Seconds elapsed since play mode started.""" return self._elapsed_time @property def frame_count(self) -> int: """Number of frames processed since play mode started.""" return self._frame_count @property def is_active(self) -> bool: """Convenience: True when the game is playing (paused or not).""" return self._state.is_playing # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _get_root(self) -> Node | None: """Return the scene root, or None.""" tree = self._state.edited_scene if tree is None: return None return tree.root @staticmethod def _call_ready(node: Node) -> None: """Recursively call ``ready()`` on a node and all its descendants. Children are readied before their parent, matching the SceneTree convention (depth-first, bottom-up). """ for child in list(node.children): PlayMode._call_ready(child) node.ready() @staticmethod def _process_tree(node: Node, dt: float) -> None: """Recursively call ``process(dt)`` and tick coroutines.""" if getattr(node, "_script_error", False): return try: node.process(dt) except AssertionError: raise except Exception: node._script_error = True import traceback as _tb tb = _tb.format_exc() import logging logging.getLogger("simvx.play").exception("Script error in %s.process — node disabled", node.name) try: Node.script_error_raised.emit(node, "process", tb) except Exception: pass return node._tick_coroutines(dt) for child in list(node.children): PlayMode._process_tree(child, dt) @staticmethod def _physics_process_tree(node: Node, dt: float) -> None: """Recursively call ``physics_process(dt)``.""" if getattr(node, "_script_error", False): return try: node.physics_process(dt) except AssertionError: raise except Exception: node._script_error = True import traceback as _tb tb = _tb.format_exc() import logging logging.getLogger("simvx.play").exception("Script error in %s.physics_process — node disabled", node.name) try: Node.script_error_raised.emit(node, "physics_process", tb) except Exception: pass return for child in list(node.children): PlayMode._physics_process_tree(child, dt)