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