Source code for simvx.graphics.web_app

"""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 process_input(self, events_json: str) -> None: """Process batched input events from JavaScript. Args: events_json: JSON array of input event objects. """ try: events = json.loads(events_json) except (json.JSONDecodeError, TypeError): return tree = self._tree for evt in events: etype = evt.get("type") if etype == "key": code = evt.get("code", 0) pressed = evt.get("pressed", False) Input._on_key(code, pressed) key_name = _KEY_MAP.get(code) if key_name: if pressed: if not Input._keys.get(key_name): Input._keys_just_pressed[key_name] = True Input._keys[key_name] = True else: Input._keys[key_name] = False Input._keys_just_released[key_name] = True tree.ui_input(key=key_name, pressed=pressed) elif etype == "char": codepoint = evt.get("codepoint", 0) tree.ui_input(char=chr(codepoint)) elif etype == "mouse": button = evt.get("button", 0) pressed = evt.get("pressed", False) Input._on_mouse_button(button, pressed) btn = f"mouse_{button + 1}" if pressed: if not Input._keys.get(btn): Input._keys_just_pressed[btn] = True Input._keys[btn] = True else: Input._keys[btn] = False Input._keys_just_released[btn] = True tree.ui_input(mouse_pos=Input._mouse_pos, button=button + 1, pressed=pressed) elif etype == "mousemove": x, y = evt.get("x", 0.0), evt.get("y", 0.0) old = Input._mouse_pos Input._mouse_pos = (x, y) Input._mouse_delta = (x - old[0], y - old[1]) tree.ui_input(mouse_pos=(x, y), button=0, pressed=False) elif etype == "scroll": dx, dy = evt.get("dx", 0.0), evt.get("dy", 0.0) Input._scroll_delta = ( Input._scroll_delta[0] + dx, Input._scroll_delta[1] + dy, ) if dy > 0: tree.ui_input(key="scroll_up", pressed=True) elif dy < 0: tree.ui_input(key="scroll_down", pressed=True) elif etype == "touch": finger_id = evt.get("id", 0) action = evt.get("action", 0) x, y = evt.get("x", 0.0), evt.get("y", 0.0) pressure = evt.get("pressure", 1.0) Input._update_touch(finger_id, action, x, y, pressure) # Primary finger emulates mouse for UI and game input if action == 0 and self._primary_finger is None: self._primary_finger = finger_id Input._mouse_pos = (x, y) Input._mouse_delta = (0.0, 0.0) Input._on_mouse_button(0, True) # LEFT button press btn = "mouse_1" if not Input._keys.get(btn): Input._keys_just_pressed[btn] = True Input._keys[btn] = True tree.ui_input(mouse_pos=(x, y), button=0, pressed=False) tree.ui_input(mouse_pos=(x, y), button=1, pressed=True) elif action == 1 and finger_id == self._primary_finger: self._primary_finger = None Input._mouse_pos = (x, y) Input._on_mouse_button(0, False) # LEFT button release Input._keys["mouse_1"] = False Input._keys_just_released["mouse_1"] = True tree.ui_input(mouse_pos=(x, y), button=0, pressed=False) tree.ui_input(mouse_pos=(x, y), button=1, pressed=False) elif action == 2 and finger_id == self._primary_finger: old = Input._mouse_pos Input._mouse_pos = (x, y) Input._mouse_delta = (x - old[0], y - old[1]) tree.ui_input(mouse_pos=(x, y), button=0, pressed=False)
[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