"""Main export_web function."""
from __future__ import annotations
import ast
import json
import logging
import re
from pathlib import Path
from ._atlas import _prebake_atlas
from ._dependencies import _parse_pyproject_dependencies, _parse_script_dependencies, _resolve_web_packages
from ._detection import _detect_3d
from ._html_2d import _build_html
from ._html_3d import _build_html_3d
from ._paths import _CLIENT_DIR, _CORE_SRC, _GRAPHICS_SRC
from ._sources import _collect_3d_sources, _collect_python_sources
log = logging.getLogger(__name__)
[docs]
def export_web(
game_path: str | Path,
output: str | Path = "game.html",
*,
width: int = 800,
height: int = 600,
title: str = "SimVX",
root_class: str | None = None,
physics_fps: int = 60,
charset: str | None = None,
responsive: bool = False,
pyodide_version: str = "0.29.3",
extra_packages: list[str] | None = None,
) -> Path:
"""Export a SimVX game as a standalone HTML file.
Auto-detects 3D usage and includes the appropriate WebGPU pipeline.
Dependencies are resolved from PEP 723 inline metadata, pyproject.toml,
or the *extra_packages* parameter (PEP 723 takes precedence).
Args:
game_path: Path to the game's Python module.
output: Output HTML file path.
width, height: Engine viewport dimensions.
title: Browser page title.
root_class: Name of the root Node subclass. Auto-detected if None.
physics_fps: Physics tick rate.
charset: Characters to pre-bake in the MSDF atlas.
responsive: Adapt engine viewport to browser window size.
pyodide_version: Pyodide CDN version.
extra_packages: Additional Pyodide packages to load (e.g. ``["requests"]``).
Returns:
Path to the generated HTML file.
"""
game_path = Path(game_path)
output = Path(output)
if not game_path.exists():
raise FileNotFoundError(f"Game module not found: {game_path}")
game_source = game_path.read_text()
is_3d = _detect_3d(game_source)
if is_3d:
log.info("3D nodes detected — including 3D pipeline")
# Auto-detect root class
if root_class is None:
classes = re.findall(r"class\s+(\w+)\s*\(", game_source)
if not classes:
raise ValueError("No class found in game module; specify --root")
root_class = classes[0]
log.info("Auto-detected root class: %s", root_class)
# Resolve web packages from PEP 723, pyproject.toml, or extra_packages
game_deps = _parse_script_dependencies(game_source)
if not game_deps:
game_deps = _parse_pyproject_dependencies(game_path)
web_packages = _resolve_web_packages(game_deps, extra_packages)
if len(web_packages) > 1:
log.info("Web packages: %s", ", ".join(web_packages))
# Scan game source for string literals to expand charset
game_chars = set()
try:
tree = ast.parse(game_source)
for node in ast.walk(tree):
if isinstance(node, ast.Constant) and isinstance(node.value, str):
game_chars.update(node.value)
except SyntaxError:
pass
full_charset = charset
if game_chars:
base = (
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
"0123456789 .!?:;,'-\"()[]{}/<>@#$%^&*+=_~`\\|"
)
full_charset = base + "".join(c for c in game_chars if c not in base and c.isprintable())
log.info("Pre-baking MSDF atlas...")
atlas_data = _prebake_atlas(full_charset)
# Read JS and shader sources
renderer2d_js = (_CLIENT_DIR / "renderer2d.js").read_text()
fill_wgsl = (_CLIENT_DIR / "shaders" / "fill.wgsl").read_text()
text_wgsl = (_CLIENT_DIR / "shaders" / "text.wgsl").read_text()
textured_wgsl = (_CLIENT_DIR / "shaders" / "textured.wgsl").read_text()
renderer3d_js = ""
combined_js = ""
forward3d_wgsl = ""
bloom_extract_wgsl = ""
bloom_blur_wgsl = ""
tonemap3d_wgsl = ""
if is_3d:
renderer3d_js = (_CLIENT_DIR / "renderer3d.js").read_text()
combined_js = (_CLIENT_DIR / "combined_renderer.js").read_text()
forward3d_wgsl = (_CLIENT_DIR / "shaders" / "forward3d.wgsl").read_text()
bloom_extract_wgsl = (_CLIENT_DIR / "shaders" / "bloom_extract.wgsl").read_text()
bloom_blur_wgsl = (_CLIENT_DIR / "shaders" / "bloom_blur.wgsl").read_text()
tonemap3d_wgsl = (_CLIENT_DIR / "shaders" / "tonemap3d.wgsl").read_text()
# Collect Python sources
log.info("Collecting Python sources...")
py_sources = _collect_python_sources(_CORE_SRC, _GRAPHICS_SRC)
if is_3d:
_collect_3d_sources(_GRAPHICS_SRC, py_sources)
py_sources["__game__.py"] = game_source
# Include sibling .py files from the game's directory (for multi-file examples)
game_dir = game_path.parent
for sibling in sorted(game_dir.glob("*.py")):
if sibling == game_path or sibling.name.startswith("_"):
continue
py_sources[sibling.name] = sibling.read_text()
# Recursively include sub-packages (scripts/, data/, etc.) for multi-file games
for sub_py in sorted(game_dir.rglob("**/*.py")):
rel = sub_py.relative_to(game_dir)
key = str(rel)
if key in py_sources or sub_py == game_path:
continue
# Skip test files and __pycache__
if "__pycache__" in key or key.startswith("tests/") or key.startswith("test_"):
continue
py_sources[key] = sub_py.read_text()
# Include data files (.toml, .json) as virtual files for tomllib/json.load
data_files: dict[str, str] = {}
for data_file in sorted(game_dir.rglob("**/*.toml")) + sorted(game_dir.rglob("**/*.json")):
rel = str(data_file.relative_to(game_dir))
if "__pycache__" in rel:
continue
data_files[rel] = data_file.read_text()
py_sources_json = json.dumps(py_sources)
data_files_json = json.dumps(data_files) if data_files else "{}"
# Build HTML
log.info("Generating %s...", output)
if is_3d:
html = _build_html_3d(
title=title, width=width, height=height, physics_fps=physics_fps,
root_class=root_class, responsive=responsive,
renderer2d_js=renderer2d_js, renderer3d_js=renderer3d_js,
combined_js=combined_js,
fill_wgsl=fill_wgsl, text_wgsl=text_wgsl, textured_wgsl=textured_wgsl,
forward3d_wgsl=forward3d_wgsl,
bloom_extract_wgsl=bloom_extract_wgsl,
bloom_blur_wgsl=bloom_blur_wgsl,
tonemap3d_wgsl=tonemap3d_wgsl,
py_sources_json=py_sources_json, data_files_json=data_files_json,
atlas_data=atlas_data,
pyodide_version=pyodide_version, web_packages=web_packages,
)
else:
html = _build_html(
title=title, width=width, height=height, physics_fps=physics_fps,
root_class=root_class, responsive=responsive,
renderer_js=renderer2d_js,
fill_wgsl=fill_wgsl, text_wgsl=text_wgsl, textured_wgsl=textured_wgsl,
py_sources_json=py_sources_json, data_files_json=data_files_json,
atlas_data=atlas_data,
pyodide_version=pyodide_version, web_packages=web_packages,
)
output.write_text(html)
size_kb = output.stat().st_size / 1024
log.info("Exported %s (%.0f KB)", output, size_kb)
return output