import asyncio
import pygame
import sys
import os
import json
import math
from collections import deque
from core.scene import Scene
from core.systems import RenderSystem, AnimationSystem, ParticleSystem, LightingSystem
from core.systems.physics_system import PhysicsSystem
from core.systems.script_system import ScriptSystem
from core.systems.audio_system import AudioSystem
from core.systems.network_system import NetworkSystem
from core.systems.ui_system import UISystem
from core.systems.steering_system import SteeringSystem
from core.systems.timer_system import TimerSystem
from core.systems.event_dispatch_system import EventDispatchSystem
from core.serializer import SceneSerializer
from core.input import Input
from core.input_map import InputMap
from core.debug_overlay import DebugOverlay
from core.components import Transform, BoxCollider2D, CircleCollider2D, PolygonCollider2D
from core.vector import Vector2
from core.resources import ResourceManager
from core.scene_transition import SceneTransition
from core.logger import get_logger
# Check if running in editor mode
EDITOR_MODE = os.environ.get("AXISPY_EDITOR_MODE") == "1"
_player_logger = get_logger("player")
# In production builds, we don't want console output to interfere
if not EDITOR_MODE:
# Suppress logger output in production
import core.logger
core.logger.set_min_level(core.logger.LogLevels.ERROR)
else:
# In editor mode, enable all logging levels
import core.logger
core.logger.set_min_level(core.logger.LogLevels.DEBUG)
[docs]
def editor_print(*args, **kwargs):
"""Print function that only outputs when running in editor mode."""
if EDITOR_MODE:
print(*args, **kwargs)
[docs]
class RuntimePlayer:
"""Encapsulates the runtime game loop, systems, and scene management."""
def __init__(self, scene_path: str | None = None, web_mode: bool = False):
self.scene_path = scene_path
self.web_mode = web_mode
self.project_dir = ""
self.project_config: dict = {}
# Display defaults
self.window_width = 800
self.window_height = 600
self.design_width = 800
self.design_height = 600
self.window_resizable = True
self.window_fullscreen = False
self.stretch_mode = "fit"
self.stretch_aspect = "keep"
self.stretch_scale = "fractional"
self.window_title = "AxisPy Engine - Player"
self.bg_color = (33, 33, 33)
self.game_icon_path = ""
self.web_target_fps = 60
# Pygame surfaces
self.screen = None
self.render_surface = None
self.use_virtual_surface = True
self.flags = 0
self.presentation_rect = pygame.Rect(0, 0, 800, 600)
# Systems (created once, reused across scene changes)
self.physics_system = None
self.script_system = None
self.audio_system = None
self.network_system = None
self.animation_system = None
self.particle_system = None
self.ui_system = None
self.steering_system = None
self.timer_system = None
self.event_dispatch_system = None
self.render_system = None
self.lighting_system = None
# Scene state
self.scene: Scene | None = None
self.current_scene_path = ""
self._pending_scene_path: str | None = None
self._scene_transition = SceneTransition(duration=0.35, color=(0, 0, 0))
# Loop state
self.running = True
self.fixed_dt = 1.0 / 60.0
self.max_frame_dt = 0.25
self.max_substeps = 8
self.accumulator = 0.0
self._dt_buffer: deque[float] = deque(maxlen=5)
# Physics debug (editor mode)
self.physics_debug_mode = False
self.collider_drag_state = None
self.collider_handle_min_screen_distance = 88
# ------------------------------------------------------------------
# Initialization
# ------------------------------------------------------------------
def _resolve_project_dir(self):
if not self.scene_path:
return
scene_abs_path = os.path.abspath(self.scene_path)
env_project_dir = os.environ.get("AXISPY_PROJECT_PATH", "").strip()
if env_project_dir and os.path.exists(env_project_dir):
self.project_dir = os.path.abspath(env_project_dir)
else:
scene_parent = os.path.dirname(scene_abs_path)
if os.path.basename(scene_parent).lower() == "scenes":
self.project_dir = os.path.dirname(scene_parent)
else:
self.project_dir = scene_parent
if self.project_dir not in sys.path:
sys.path.insert(0, self.project_dir)
_player_logger.info("Added project directory to sys.path", path=self.project_dir)
os.chdir(self.project_dir)
_player_logger.info("Changed CWD", path=self.project_dir)
ResourceManager.set_base_path(self.project_dir)
def _read_config(self):
if not self.scene_path:
return
config_path = os.path.join(self.project_dir, "project.config")
if not os.path.exists(config_path):
return
try:
with open(config_path, "r") as f:
config = json.load(f)
self.project_config = config
res = config.get("resolution", {})
display = config.get("display", {})
virtual_resolution = display.get("virtual_resolution", {})
self.design_width = int(virtual_resolution.get("width", res.get("width", self.design_width)))
self.design_height = int(virtual_resolution.get("height", res.get("height", self.design_height)))
window_cfg = display.get("window", {})
stretch_cfg = display.get("stretch", {})
self.window_width = int(window_cfg.get("width", res.get("width", self.window_width)))
self.window_height = int(window_cfg.get("height", res.get("height", self.window_height)))
self.window_resizable = bool(window_cfg.get("resizable", True))
self.window_fullscreen = bool(window_cfg.get("fullscreen", False))
self.stretch_mode = str(stretch_cfg.get("mode", "fit")).lower()
self.stretch_aspect = str(stretch_cfg.get("aspect", "keep")).lower()
self.stretch_scale = str(stretch_cfg.get("scale", "fractional")).lower()
game_name = config.get("game_name")
if game_name:
self.window_title = str(game_name)
icon_value = str(config.get("game_icon", "")).strip()
if icon_value:
native_icon = ResourceManager.to_os_path(icon_value)
if os.path.isabs(native_icon):
self.game_icon_path = native_icon
else:
self.game_icon_path = os.path.normpath(os.path.join(self.project_dir, native_icon))
self.bg_color = tuple(config.get("background_color", [0, 0, 0]))
# P11-8: Load input action mappings from config
if "input_actions" in config:
InputMap.load_from_config(config)
except Exception as e:
_player_logger.error("Failed to read project.config", error=str(e))
def _apply_web_overrides(self):
if not self.web_mode:
return
web_cfg = self.project_config.get("web", {})
resolution_scale = float(web_cfg.get("resolution_scale", 1.0))
resolution_scale = max(0.25, min(1.0, resolution_scale))
if resolution_scale < 1.0:
self.design_width = max(1, int(self.design_width * resolution_scale))
self.design_height = max(1, int(self.design_height * resolution_scale))
_player_logger.info("Web resolution scaled", scale=resolution_scale, width=self.design_width, height=self.design_height)
self.web_target_fps = int(web_cfg.get("target_fps", 30))
self.web_target_fps = max(15, min(120, self.web_target_fps))
# Browsers strictly reject API full-screen requests without a direct user DOM gesture.
# Startup full-screen is impossible without throwing an error, so we disable it for initialization.
self.window_fullscreen = False
# Prevent scratchy audio in browsers by explicitly raising the SDL audio buffer size
audio_buffer = 4096
pygame.mixer.pre_init(44100, -16, 2, audio_buffer)
def _init_display(self):
pygame.init()
self.flags = 0
if self.window_resizable:
self.flags |= pygame.RESIZABLE
if self.window_fullscreen:
self.flags |= pygame.FULLSCREEN
self.screen = pygame.display.set_mode((self.window_width, self.window_height), self.flags)
if self.game_icon_path and os.path.exists(self.game_icon_path):
try:
pygame.display.set_icon(pygame.image.load(self.game_icon_path))
except Exception as e:
_player_logger.warning("Failed to set game icon", path=self.game_icon_path, error=str(e))
pygame.display.set_caption(self.window_title)
def _create_systems(self):
self.physics_system = PhysicsSystem()
self.script_system = ScriptSystem()
self.audio_system = AudioSystem()
self.network_system = NetworkSystem()
self.animation_system = AnimationSystem()
self.particle_system = ParticleSystem()
self.ui_system = UISystem()
self.steering_system = SteeringSystem()
self.timer_system = TimerSystem()
self.event_dispatch_system = EventDispatchSystem()
self.use_virtual_surface = self.stretch_mode != "disabled"
self.render_surface = pygame.Surface((self.design_width, self.design_height)) if self.use_virtual_surface else self.screen
self.render_system = RenderSystem(self.render_surface)
self.render_system.design_size = (self.design_width, self.design_height)
if self.web_mode:
self.render_system.smooth_present = False
self.lighting_system = LightingSystem(self.render_surface, self.project_config)
def _attach_systems(self, target_scene: Scene):
target_scene.world.add_system(self.physics_system)
target_scene.world.add_system(self.script_system)
target_scene.world.add_system(self.audio_system)
target_scene.world.add_system(self.network_system)
target_scene.world.add_system(self.animation_system)
target_scene.world.add_system(self.particle_system)
target_scene.world.add_system(self.ui_system)
target_scene.world.add_system(self.steering_system)
target_scene.world.add_system(self.timer_system)
target_scene.world.add_system(self.event_dispatch_system)
target_scene.world.add_system(self.render_system)
target_scene.world.add_system(self.lighting_system)
# ------------------------------------------------------------------
# Scene management
# ------------------------------------------------------------------
def _apply_project_world_settings(self, target_scene: Scene):
config_layers = self.project_config.get("layers", ["Default"])
normalized_layers = []
seen_layers = set()
if isinstance(config_layers, list):
for layer in config_layers:
name = str(layer).strip()
if not name:
continue
lowered = name.lower()
if lowered in seen_layers:
continue
seen_layers.add(lowered)
normalized_layers.append(name)
if "default" in seen_layers:
normalized_layers = [layer for layer in normalized_layers if layer.lower() != "default"]
normalized_layers.insert(0, "Default")
target_scene.world.layers = normalized_layers
config_groups = self.project_config.get("groups", [])
normalized_groups = []
seen_groups = set()
if isinstance(config_groups, list):
for group_name in config_groups:
group_text = str(group_name).strip()
if not group_text:
continue
lowered = group_text.lower()
if lowered in seen_groups:
continue
seen_groups.add(lowered)
normalized_groups.append(group_text)
world = target_scene.world
for group_name in list(world.groups.keys()):
if group_name not in normalized_groups:
members = list(world.groups.get(group_name, set()))
for entity in members:
entity.remove_group(group_name)
for group_name in normalized_groups:
world.groups.setdefault(group_name, set())
raw_matrix = self.project_config.get("physics_collision_matrix", {})
if not isinstance(raw_matrix, dict):
raw_matrix = {}
normalized_matrix = {}
for row_group in normalized_groups:
targets = raw_matrix.get(row_group, normalized_groups)
if not isinstance(targets, list):
targets = normalized_groups
allowed_targets = []
seen_targets = set()
for target in targets:
target_name = str(target).strip()
if target_name not in normalized_groups:
continue
lowered_target = target_name.lower()
if lowered_target in seen_targets:
continue
seen_targets.add(lowered_target)
allowed_targets.append(target_name)
normalized_matrix[row_group] = allowed_targets
for row_group in normalized_groups:
for target in list(normalized_matrix.get(row_group, [])):
peer = normalized_matrix.setdefault(target, [])
if row_group not in peer:
peer.append(row_group)
world.physics_group_order = list(normalized_groups)
world.physics_collision_matrix = normalized_matrix
def _resolve_scene_change_path(self, scene_name: str):
requested = str(scene_name or "").strip()
if not requested:
return ""
requested = os.path.normpath(requested)
has_extension = bool(os.path.splitext(requested)[1])
variants = [requested] if has_extension else [requested, requested + ".scn"]
candidates = []
for variant in variants:
if os.path.isabs(variant):
candidates.append(variant)
continue
if self.project_dir:
candidates.append(os.path.normpath(os.path.join(self.project_dir, variant)))
candidates.append(os.path.normpath(os.path.join(self.project_dir, "scenes", variant)))
if self.current_scene_path:
scene_dir = os.path.dirname(self.current_scene_path)
candidates.append(os.path.normpath(os.path.join(scene_dir, variant)))
for candidate in candidates:
if candidate and os.path.exists(candidate):
return os.path.abspath(candidate)
return ""
def _load_scene(self, target_scene_path: str) -> Scene:
if target_scene_path and os.path.exists(target_scene_path):
try:
with open(target_scene_path, "r") as f:
loaded_scene = SceneSerializer.from_json(f.read())
self._apply_project_world_settings(loaded_scene)
return loaded_scene
except Exception as e:
_player_logger.error("Failed to load scene", scene=target_scene_path, error=str(e))
fallback_scene = Scene()
fallback_scene.setup_default()
self._apply_project_world_settings(fallback_scene)
return fallback_scene
def _teardown_scene(self):
if not self.scene:
return
world = self.scene.world
for entity in list(world.entities):
world.destroy_entity(entity)
if pygame.mixer.get_init():
pygame.mixer.stop()
pygame.mixer.music.stop()
def _preload_and_cleanup(self):
_preload = ResourceManager.preload_scene_assets(self.scene.world.entities)
ResourceManager.unload_unused(
_preload.get("used_image_paths"),
_preload.get("used_sound_paths"),
)
# ------------------------------------------------------------------
# Coordinate mapping & presentation
# ------------------------------------------------------------------
[docs]
def update_presentation_rect(self):
screen_w, screen_h = self.screen.get_size()
if not self.use_virtual_surface:
self.presentation_rect = pygame.Rect(0, 0, screen_w, screen_h)
return
base_w, base_h = self.render_surface.get_size()
mode = self.stretch_mode if self.stretch_mode in ("stretch", "fit", "crop") else "fit"
keep_aspect = self.stretch_aspect != "ignore"
integer_scale = self.stretch_scale == "integer"
if mode == "stretch" and not keep_aspect:
self.presentation_rect = pygame.Rect(0, 0, screen_w, screen_h)
return
ratio_w = screen_w / max(1, base_w)
ratio_h = screen_h / max(1, base_h)
if mode == "crop":
factor = max(ratio_w, ratio_h)
if integer_scale:
factor = max(1.0, math.ceil(factor))
else:
factor = min(ratio_w, ratio_h)
if integer_scale:
factor = max(1.0, math.floor(factor))
factor = max(1.0 if integer_scale else 0.01, factor)
target_w = max(1, int(base_w * factor))
target_h = max(1, int(base_h * factor))
offset_x = (screen_w - target_w) // 2
offset_y = (screen_h - target_h) // 2
self.presentation_rect = pygame.Rect(offset_x, offset_y, target_w, target_h)
[docs]
def window_to_render(self, window_x, window_y):
if not self.use_virtual_surface:
return window_x, window_y
if self.presentation_rect.width <= 0 or self.presentation_rect.height <= 0:
return None
if (
window_x < self.presentation_rect.x
or window_x >= self.presentation_rect.x + self.presentation_rect.width
or window_y < self.presentation_rect.y
or window_y >= self.presentation_rect.y + self.presentation_rect.height
):
return None
normalized_x = (window_x - self.presentation_rect.x) / self.presentation_rect.width
normalized_y = (window_y - self.presentation_rect.y) / self.presentation_rect.height
render_x = normalized_x * self.render_surface.get_width()
render_y = normalized_y * self.render_surface.get_height()
return render_x, render_y
[docs]
def world_to_screen(self, world_x, world_y):
return self.render_system.world_to_screen(world_x, world_y, entities=self.scene.world.entities)
[docs]
def screen_to_world(self, window_x, window_y):
mapped = self.window_to_render(window_x, window_y)
if mapped is None:
return None
return self.render_system.screen_to_world(mapped[0], mapped[1], entities=self.scene.world.entities)
[docs]
def present_frame(self):
if not self.use_virtual_surface:
return
if self.presentation_rect.width <= 0 or self.presentation_rect.height <= 0:
return
self.screen.fill((0, 0, 0))
scale_fn = pygame.transform.smoothscale if self.render_system.smooth_present else pygame.transform.scale
scaled = scale_fn(
self.render_surface,
(self.presentation_rect.width, self.presentation_rect.height)
)
self.screen.blit(scaled, self.presentation_rect)
# ------------------------------------------------------------------
# Physics debug drawing & collider handles
# ------------------------------------------------------------------
[docs]
def build_collider_handles(self):
handles = []
for entity in self.scene.world.entities:
transform = entity.get_component(Transform)
if not transform:
continue
box = entity.get_component(BoxCollider2D)
circle = entity.get_component(CircleCollider2D)
polygon = entity.get_component(PolygonCollider2D)
if not box and not circle and not polygon:
continue
if box:
center_x = transform.x + box.offset_x
center_y = transform.y + box.offset_y
half_w = max(0.5, abs(box.width) * 0.5)
half_h = max(0.5, abs(box.height) * 0.5)
handle_defs = [
("width", 1, center_x + half_w, center_y),
("width", -1, center_x - half_w, center_y),
("height", 1, center_x, center_y + half_h),
("height", -1, center_x, center_y - half_h),
]
for attr, direction, world_x, world_y in handle_defs:
screen_x, screen_y = self.world_to_screen(world_x, world_y)
center_screen_x, center_screen_y = self.world_to_screen(center_x, center_y)
dx = screen_x - center_screen_x
dy = screen_y - center_screen_y
distance = math.hypot(dx, dy)
if distance < self.collider_handle_min_screen_distance:
if distance == 0:
if attr == "width":
dx = direction
dy = 0
else:
dx = 0
dy = direction
distance = 1.0
scale = self.collider_handle_min_screen_distance / distance
screen_x = center_screen_x + (dx * scale)
screen_y = center_screen_y + (dy * scale)
handles.append({
"entity": entity,
"transform": transform,
"component": box,
"attr": attr,
"direction": direction,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
center_screen_x, center_screen_y = self.world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
handles.append({
"entity": entity,
"transform": transform,
"component": box,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": center_screen_x - move_offset,
"screen_y": center_screen_y - move_offset
})
if circle:
center_x = transform.x + circle.offset_x
center_y = transform.y + circle.offset_y
radius = max(0.5, abs(circle.radius))
handle_defs = [
("radius", 1, center_x + radius, center_y),
("radius", -1, center_x - radius, center_y),
("radius", 1, center_x, center_y + radius),
("radius", -1, center_x, center_y - radius),
]
for _, direction, world_x, world_y in handle_defs:
screen_x, screen_y = self.world_to_screen(world_x, world_y)
center_screen_x, center_screen_y = self.world_to_screen(center_x, center_y)
dx = screen_x - center_screen_x
dy = screen_y - center_screen_y
distance = math.hypot(dx, dy)
if distance < self.collider_handle_min_screen_distance:
if distance == 0:
dx = direction
dy = 0
distance = 1.0
scale = self.collider_handle_min_screen_distance / distance
screen_x = center_screen_x + (dx * scale)
screen_y = center_screen_y + (dy * scale)
handles.append({
"entity": entity,
"transform": transform,
"component": circle,
"attr": "radius",
"direction": direction,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
center_screen_x, center_screen_y = self.world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
handles.append({
"entity": entity,
"transform": transform,
"component": circle,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": center_screen_x - move_offset,
"screen_y": center_screen_y - move_offset
})
if polygon and len(polygon.points) >= 3:
world_points = [
(transform.x + polygon.offset_x + point.x, transform.y + polygon.offset_y + point.y)
for point in polygon.points
]
center_x = sum(point[0] for point in world_points) / len(world_points)
center_y = sum(point[1] for point in world_points) / len(world_points)
for index, (world_x, world_y) in enumerate(world_points):
screen_x, screen_y = self.world_to_screen(world_x, world_y)
handles.append({
"entity": entity,
"transform": transform,
"component": polygon,
"attr": "point",
"point_index": index,
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": screen_x,
"screen_y": screen_y
})
center_screen_x, center_screen_y = self.world_to_screen(center_x, center_y)
move_offset = self.collider_handle_min_screen_distance * 0.75
handles.append({
"entity": entity,
"transform": transform,
"component": polygon,
"attr": "offset",
"direction": 0,
"center_x": center_x,
"center_y": center_y,
"screen_x": center_screen_x - move_offset,
"screen_y": center_screen_y - move_offset
})
return handles
[docs]
def update_collider_resize(self, mouse_pos):
if not self.collider_drag_state:
return
mapped_world = self.screen_to_world(mouse_pos[0], mouse_pos[1])
if mapped_world is None:
return
world_x, world_y = mapped_world
state = self.collider_drag_state
comp = state["component"]
attr = state["attr"]
direction = state["direction"]
cx = state["center_x"]
cy = state["center_y"]
if attr == "width":
if direction > 0:
comp.width = max(1.0, (world_x - cx) * 2.0)
else:
comp.width = max(1.0, (cx - world_x) * 2.0)
elif attr == "height":
if direction > 0:
comp.height = max(1.0, (world_y - cy) * 2.0)
else:
comp.height = max(1.0, (cy - world_y) * 2.0)
elif attr == "radius":
dx = world_x - cx
dy = world_y - cy
comp.radius = max(0.5, math.hypot(dx, dy))
elif attr == "offset":
transform = state["transform"]
center_x = world_x - state["grab_dx"]
center_y = world_y - state["grab_dy"]
comp.offset_x = center_x - transform.x
comp.offset_y = center_y - transform.y
elif attr == "point":
transform = state["transform"]
point_index = state["point_index"]
if point_index < 0 or point_index >= len(comp.points):
return
new_points = [Vector2(point.x, point.y) for point in comp.points]
new_points[point_index] = Vector2(
world_x - transform.x - comp.offset_x,
world_y - transform.y - comp.offset_y
)
comp.points = new_points
[docs]
def draw_physics_debug(self):
collider_color = (80, 190, 255)
for entity in self.scene.world.entities:
transform = entity.get_component(Transform)
if not transform:
continue
box = entity.get_component(BoxCollider2D)
circle = entity.get_component(CircleCollider2D)
polygon = entity.get_component(PolygonCollider2D)
if not box and not circle and not polygon:
continue
if box:
center_x = transform.x + box.offset_x
center_y = transform.y + box.offset_y
half_w = max(0.5, abs(box.width) * 0.5)
half_h = max(0.5, abs(box.height) * 0.5)
left_top = self.world_to_screen(center_x - half_w, center_y - half_h)
right_bottom = self.world_to_screen(center_x + half_w, center_y + half_h)
rect = pygame.Rect(
int(min(left_top[0], right_bottom[0])),
int(min(left_top[1], right_bottom[1])),
max(1, int(abs(right_bottom[0] - left_top[0]))),
max(1, int(abs(right_bottom[1] - left_top[1])))
)
pygame.draw.rect(self.render_system.surface, collider_color, rect, 2)
if circle:
center_x = transform.x + circle.offset_x
center_y = transform.y + circle.offset_y
screen_x, screen_y = self.world_to_screen(center_x, center_y)
view = self.render_system.get_primary_camera_view(self.scene.world.entities)
screen_radius = max(1, int(abs(circle.radius) * view["zoom"]))
pygame.draw.circle(self.render_system.surface, collider_color, (int(screen_x), int(screen_y)), screen_radius, 2)
if polygon and len(polygon.points) >= 3:
screen_points = []
for point in polygon.points:
world_x = transform.x + polygon.offset_x + point.x
world_y = transform.y + polygon.offset_y + point.y
screen_x, screen_y = self.world_to_screen(world_x, world_y)
screen_points.append((int(screen_x), int(screen_y)))
if len(screen_points) >= 3:
pygame.draw.polygon(self.render_system.surface, collider_color, screen_points, 2)
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
[docs]
async def run(self):
self._resolve_project_dir()
self._read_config()
self._apply_web_overrides()
self._init_display()
self._create_systems()
self.current_scene_path = os.path.abspath(self.scene_path) if self.scene_path and os.path.exists(self.scene_path) else ""
self.scene = self._load_scene(self.current_scene_path)
editor_view_state = getattr(self.scene, "editor_view_state", {})
self.physics_debug_mode = bool(editor_view_state.get("physics_debug_mode", False))
self._attach_systems(self.scene)
self._preload_and_cleanup()
clock = pygame.time.Clock()
last_tick_ms = pygame.time.get_ticks()
self.presentation_rect = pygame.Rect(0, 0, self.screen.get_width(), self.screen.get_height())
self.update_presentation_rect()
Input.set_mouse_mapper(self.window_to_render)
self.scene.world.sync_interpolation_state()
web_min_frame_ms = (1000.0 / self.web_target_fps) if self.web_mode else 0
while self.running:
if self.web_mode:
current_tick_ms = pygame.time.get_ticks()
delta_ms = current_tick_ms - last_tick_ms
if delta_ms < web_min_frame_ms:
await asyncio.sleep(0)
continue
if delta_ms < 0:
delta_ms = 0
last_tick_ms = current_tick_ms
if delta_ms == 0:
delta_ms = 16
raw_dt = min(self.max_frame_dt, delta_ms / 1000.0)
else:
raw_dt = min(self.max_frame_dt, clock.tick(240) / 1000.0)
# Smooth delta time with a rolling average to reduce jitter
self._dt_buffer.append(raw_dt)
frame_dt = sum(self._dt_buffer) / len(self._dt_buffer)
self.accumulator += frame_dt
# Update Input manager
Input.update()
InputMap.update()
for event in Input._events:
if event.type == pygame.QUIT:
self.running = False
elif event.type == pygame.VIDEORESIZE and self.window_resizable:
self.screen = pygame.display.set_mode((event.w, event.h), self.flags)
if not self.use_virtual_surface:
self.render_system.surface = self.screen
self.update_presentation_rect()
requested_scene_name = getattr(self.scene.world, "_requested_scene_name", "")
if requested_scene_name and self._pending_scene_path is None:
self.scene.world._requested_scene_name = ""
resolved_scene_path = self._resolve_scene_change_path(requested_scene_name)
if resolved_scene_path:
self._pending_scene_path = resolved_scene_path
self._scene_transition.start_out()
else:
_player_logger.warning("Scene change failed", scene=requested_scene_name)
# When fade-out completes, swap scenes and begin fade-in
if self._pending_scene_path is not None and self._scene_transition.is_fade_out_done():
self._teardown_scene()
self.scene = self._load_scene(self._pending_scene_path)
self.current_scene_path = self._pending_scene_path
self._pending_scene_path = None
self._attach_systems(self.scene)
self._preload_and_cleanup()
self.physics_system._active_collisions.clear()
self.scene.world.sync_interpolation_state()
self.accumulator = 0.0
self._scene_transition.start_in()
self._scene_transition.update(frame_dt)
step_count = 0
while self.accumulator >= self.fixed_dt and step_count < self.max_substeps:
self.scene.world.simulate(self.fixed_dt)
self.accumulator -= self.fixed_dt
step_count += 1
if step_count == self.max_substeps and self.accumulator >= self.fixed_dt:
self.accumulator = min(self.accumulator, self.fixed_dt)
alpha = self.accumulator / self.fixed_dt
self.render_system.surface.fill(self.bg_color)
self.scene.world.render(frame_dt, alpha)
if self.physics_debug_mode:
self.draw_physics_debug()
self._scene_transition.draw(self.render_system.surface)
# P11-6: Debug overlay
DebugOverlay.update(frame_dt, self.scene.world)
DebugOverlay.draw(self.render_system.surface)
self.present_frame()
pygame.display.flip()
if self.web_mode:
await asyncio.sleep(0)
self._teardown_scene()
pygame.quit()
if not self.web_mode:
sys.exit()
async def _run(scene_path=None, web_mode: bool = False):
player = RuntimePlayer(scene_path, web_mode)
await player.run()
[docs]
def run(scene_path=None):
if sys.platform == "emscripten":
try:
running_loop = asyncio.get_running_loop()
return running_loop.create_task(_run(scene_path, web_mode=True))
except RuntimeError:
return asyncio.run(_run(scene_path, web_mode=True))
return asyncio.run(_run(scene_path, web_mode=False))
if __name__ == "__main__":
scene_path = sys.argv[1] if len(sys.argv) > 1 else None
run(scene_path)