"""Client-side web app runtime — runs inside Pyodide (browser WASM Python).
Replaces ``App`` for fully client-side web export. Drives the SceneTree, Draw2D,
and DrawSerializer in the browser without any server or Vulkan dependency.
The JavaScript ``web_runtime.js`` bridge calls ``tick(dt)`` each frame via
``requestAnimationFrame`` and renders the returned binary data with WebGPU.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
import numpy as np
from simvx.core import Input, SceneTree
from simvx.core.text.font import GlyphMetrics
from simvx.core.text.msdf import GlyphRegion, MSDFAtlas
__all__ = ["WebApp"]
# Key code → string name (same mapping as input_adapter._KEY_MAP,
# inlined to avoid importing the adapter which isn't available in Pyodide).
_KEY_MAP: dict[int, str] = {
65: "a", 66: "b", 67: "c", 68: "d", 69: "e", 70: "f", 71: "g", 72: "h",
73: "i", 74: "j", 75: "k", 76: "l", 77: "m", 78: "n", 79: "o", 80: "p",
81: "q", 82: "r", 83: "s", 84: "t", 85: "u", 86: "v", 87: "w", 88: "x",
89: "y", 90: "z",
48: "0", 49: "1", 50: "2", 51: "3", 52: "4", 53: "5", 54: "6", 55: "7", 56: "8", 57: "9",
32: "space", 257: "enter", 256: "escape", 258: "tab", 259: "backspace",
261: "delete", 260: "insert", 268: "home", 269: "end", 266: "pageup", 267: "pagedown",
265: "up", 264: "down", 263: "left", 262: "right",
340: "shift", 344: "shift", 341: "ctrl", 345: "ctrl", 342: "alt", 346: "alt",
47: "/", 92: "\\", 45: "-", 61: "=", 91: "[", 93: "]",
59: ";", 39: "'", 44: ",", 46: ".", 96: "`",
290: "f1", 291: "f2", 292: "f3", 293: "f4", 294: "f5", 295: "f6",
296: "f7", 297: "f8", 298: "f9", 299: "f10", 300: "f11", 301: "f12",
}
@dataclass
class _FontStub:
"""Lightweight Font stand-in with cached metrics (no freetype dependency)."""
size: int
ascender: float
descender: float
line_height: float
_metrics: dict[str, GlyphMetrics]
def get_glyph(self, char: str) -> GlyphMetrics:
if char in self._metrics:
return self._metrics[char]
# Fallback: return space-width glyph with zero dimensions
space = self._metrics.get(" ")
adv = space.advance_x if space else self.size * 0.5
return GlyphMetrics(char=char, advance_x=adv, bearing_x=0, bearing_y=0, width=0, height=0)
def has_glyph(self, char: str) -> bool:
return char in self._metrics
[docs]
class WebApp:
"""Browser-side app runtime for Pyodide.
Usage from JavaScript::
const app = pyodide.runPython(`
from simvx.graphics.web_app import WebApp
app = WebApp(800, 600)
app
`)
app.set_root(pyodide.runPython('from game import GameScene; GameScene()'))
# In rAF loop:
app.process_input(events_json)
frame_bytes = app.tick(dt)
"""
def __init__(self, width: int, height: int, physics_fps: int = 60) -> None:
self.width = width
self.height = height
self._physics_fps = physics_fps
self._physics_dt = 1.0 / physics_fps
self._physics_accum = 0.0
self._frame_id = 0
self._primary_finger: int | None = None
self._tree = SceneTree(screen_size=(width, height))
# Provide a stub _app reference so nodes can access .app
self._tree._app = self # type: ignore[attr-defined]
@property
def title(self) -> str:
return ""
[docs]
def set_root(self, root_node) -> None:
"""Set the scene root node."""
self._tree.set_root(root_node)
[docs]
def load_atlas(self, atlas_rgba: bytes, atlas_size: int, regions_json: str,
font_size: int, ascender: float, descender: float,
line_height: float, sdf_range: float, glyph_padding: int) -> None:
"""Reconstruct the MSDF atlas from pre-baked data (no freetype needed).
Args:
atlas_rgba: Raw RGBA pixel bytes (atlas_size * atlas_size * 4).
atlas_size: Width/height of the square atlas texture.
regions_json: JSON-serialized glyph regions.
font_size: Original font pixel size used to generate the atlas.
ascender, descender, line_height: Font metrics.
sdf_range: SDF range used during generation.
glyph_padding: Glyph padding used during generation.
"""
from simvx.graphics.draw2d import Draw2D
# Rebuild atlas numpy array
atlas_array = np.frombuffer(atlas_rgba, dtype=np.uint8).reshape(atlas_size, atlas_size, 4).copy()
# Rebuild glyph metrics and regions
regions_data = json.loads(regions_json)
metrics_cache: dict[str, GlyphMetrics] = {}
regions: dict[str, GlyphRegion] = {}
for rd in regions_data:
gm = GlyphMetrics(
char=rd["char"], advance_x=rd["advance_x"],
bearing_x=rd["bearing_x"], bearing_y=rd["bearing_y"],
width=rd["width"], height=rd["height"],
)
metrics_cache[rd["char"]] = gm
regions[rd["char"]] = GlyphRegion(
char=rd["char"], x=rd["x"], y=rd["y"], w=rd["w"], h=rd["h"],
metrics=gm, u0=rd["u0"], v0=rd["v0"], u1=rd["u1"], v1=rd["v1"],
)
# Build font stub
font_stub = _FontStub(
size=font_size, ascender=ascender, descender=descender,
line_height=line_height, _metrics=metrics_cache,
)
# Build MSDFAtlas stub (bypass __init__ which needs a real Font)
atlas = object.__new__(MSDFAtlas)
atlas.font = font_stub # type: ignore[attr-defined]
atlas.atlas_size = atlas_size
atlas.glyph_padding = glyph_padding
atlas.sdf_range = sdf_range
atlas.atlas = atlas_array
atlas.regions = regions
atlas.version = 1
atlas.dirty = False
atlas._shelf_y = 0
atlas._shelf_h = 0
atlas._cursor_x = 0
# Inject into Draw2D
Draw2D._font = atlas
Draw2D._font_obj = font_stub # type: ignore[assignment]
[docs]
def resize(self, width: int, height: int) -> None:
"""Update engine dimensions on viewport resize (called from JS)."""
self.width = width
self.height = height
self._tree.screen_size = (width, height)
[docs]
def tick(self, dt: float) -> bytes:
"""Advance one frame and return serialized draw commands.
Args:
dt: Delta time in seconds since last frame.
Returns:
Binary frame data for ``Renderer2D.renderFrame()``.
"""
from simvx.graphics.draw2d import Draw2D
from simvx.graphics.streaming.draw_serializer import DrawSerializer
dt = min(dt, 0.1) # Clamp to avoid spiral of death
# Fixed-timestep physics
self._physics_accum += dt
while self._physics_accum >= self._physics_dt:
self._tree.physics_process(self._physics_dt)
self._physics_accum -= self._physics_dt
self._tree.process(dt)
# Draw
Draw2D._reset()
self._tree.draw(Draw2D)
# Mouse picking on click
if Input._keys_just_pressed.get("mouse_1"):
self._tree.input_cast(Input._mouse_pos, button=1)
Input._end_frame()
# Serialize
batches = Draw2D._get_batches()
atlas_ver = Draw2D._font.version if Draw2D._font else 0
data = DrawSerializer.serialize_frame(self._frame_id, batches, atlas_ver)
self._frame_id += 1
return data