Source code for export.builder

from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
import hashlib
import importlib.util
import json
import os
import re
import shutil
import platform
import subprocess
import sys
import threading
from core.logger import get_logger


def _to_native_path(path: str) -> str:
    """Convert a stored portable path to native OS separators."""
    if not path:
        return path
    return os.path.normpath(path.replace("\\", "/"))


def _resolve_project_asset(stored_path: str, project_path: str) -> str:
    """Resolve an asset path that may have been saved on a different platform."""
    if not stored_path:
        return ""
    normalized = stored_path.replace("\\", "/")
    # Direct match (native absolute or already correct relative)
    if os.path.isfile(normalized):
        return normalized
    relative = os.path.join(project_path, normalized)
    if os.path.isfile(relative):
        return relative
    # Strip a Windows drive-letter prefix (e.g. "D:/") that is meaningless on Linux/macOS
    stripped = re.sub(r'^[A-Za-z]:/', '', normalized).lstrip('/')
    # Try progressively shorter suffixes against the project directory
    parts = stripped.split('/')
    for i in range(len(parts)):
        candidate = os.path.join(project_path, *parts[i:])
        if os.path.isfile(candidate):
            return candidate
    return ""


[docs] class ExportCancelled(Exception): """Raised when the user cancels an in-progress export."""
[docs] @dataclass class BuildContext: project_path: str output_path: str platform: str build_mode: str = "release" logger: any = None metadata: dict = field(default_factory=dict) cancelled: threading.Event = field(default_factory=threading.Event)
[docs] class WebTemplatePart: """A named contribution to the pygbag HTML template from a plugin.""" __slots__ = ("plugin_name", "section", "content", "priority") def __init__(self, plugin_name: str, section: str, content: str, priority: int = 100): self.plugin_name = plugin_name self.section = section # e.g. "head_styles", "head_scripts", "body_top", "body_bottom", "config_overrides" self.content = content self.priority = priority # lower = earlier in output
[docs] class WebTemplateRegistry: """ Central registry that plugins use to contribute template customizations. Multiple plugins can each add parts to named sections; the final template is assembled by merging all contributions sorted by priority. Supported sections: head_styles – CSS injected inside <style> in <head> head_scripts – <script> blocks injected in <head> body_top – HTML inserted at the top of <body> body_bottom – HTML / <script> inserted at the bottom of <body> config_overrides – JavaScript object literals merged into the config block meta_tags – extra <meta> tags in <head> """ _instance: "WebTemplateRegistry | None" = None VALID_SECTIONS = {"head_styles", "head_scripts", "body_top", "body_bottom", "config_overrides", "meta_tags"} def __init__(self): self._parts: list[WebTemplatePart] = []
[docs] @classmethod def instance(cls) -> "WebTemplateRegistry": if cls._instance is None: cls._instance = cls() return cls._instance
[docs] @classmethod def reset(cls): cls._instance = None
# ── Plugin API ───────────────────────────────────────────────────
[docs] def register(self, plugin_name: str, section: str, content: str, priority: int = 100): if section not in self.VALID_SECTIONS: raise ValueError(f"Unknown template section '{section}'. Valid: {sorted(self.VALID_SECTIONS)}") self._parts.append(WebTemplatePart(plugin_name, section, content, priority))
[docs] def unregister(self, plugin_name: str): self._parts = [p for p in self._parts if p.plugin_name != plugin_name]
[docs] def clear(self): self._parts.clear()
# ── Assembly ─────────────────────────────────────────────────────
[docs] def get_section(self, section: str) -> str: parts = sorted( [p for p in self._parts if p.section == section], key=lambda p: p.priority ) return "\n".join(p.content for p in parts if p.content.strip())
[docs] def has_contributions(self) -> bool: return len(self._parts) > 0
[docs] def build_template(self, project_config: dict, logo_url: str = None, bg_image_url: str = None) -> str: """ Assemble the full pygbag custom.tmpl HTML from a base skeleton plus all registered plugin contributions. Returns the complete HTML string. """ designer = project_config.get("pygbag_designer", {}) bg_color = designer.get("background_color", "#222222") text_color = designer.get("text_color", "#cccccc") accent_color = designer.get("accent_color", "#4fc3f7") loading_text = designer.get("loading_text", "Loading, please wait ...") show_progress = designer.get("show_progress_bar", True) # Use provided URLs or fall back to config values logo_url = logo_url if logo_url is not None else designer.get("logo_url", "") bg_image_url = bg_image_url if bg_image_url is not None else designer.get("background_image_url", "") font_family = designer.get("font_family", "Arial, Helvetica, sans-serif") layout = designer.get("layout", "centered") res_cfg = project_config.get("resolution", {}) disp_cfg = project_config.get("display", {}) win_cfg = disp_cfg.get("window", {}) fb_w = int(win_cfg.get("width", res_cfg.get("width", 1280))) fb_h = int(win_cfg.get("height", res_cfg.get("height", 720))) fb_ar = round(fb_w / fb_h, 3) if fb_h > 0 else 1.77 # Layout-dependent overlay alignment if layout == "top-left": overlay_align = "align-items: flex-start; justify-content: flex-start; padding: 40px;" elif layout == "bottom-center": overlay_align = "align-items: center; justify-content: flex-end; padding-bottom: 60px;" else: overlay_align = "align-items: center; justify-content: center;" bg_image_css = "" if bg_image_url: bg_image_css = f"background-image: url('{bg_image_url}'); background-size: cover; background-position: center;" # ── Base styles ────────────────────────────────────────────── base_styles = f""" body {{ font-family: {font_family}; margin: 0; padding: 0; background-color: {bg_color}; overflow: hidden; }} #status {{ display: inline-block; vertical-align: top; margin-top: 20px; margin-left: 30px; font-weight: bold; color: {text_color}; }} #progress {{ height: 20px; width: 300px; accent-color: {accent_color}; }} #infobox {{ position: fixed; background: {accent_color}; color: #fff; font-weight: bold; padding: 12px 24px; border-radius: 6px; z-index: 999999; }} div.emscripten {{ text-align: center; }} canvas.emscripten {{ border: 0px none; background-color: transparent; width: 100%; height: 100%; z-index: 5; padding: 0; margin: 0 auto; position: absolute; top: 0; bottom: 0; left: 0; right: 0; image-rendering: pixelated; image-rendering: crisp-edges; will-change: contents; }} #loading-overlay {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; {overlay_align} z-index: 10; background: {bg_color}; {bg_image_css} }} #loading-overlay .logo {{ max-width: 200px; max-height: 200px; margin-bottom: 20px; }} #loading-overlay .loading-text {{ color: {text_color}; font-size: 18px; margin-bottom: 12px; }} #loading-overlay .progress-bar-container {{ width: 320px; height: 22px; background: rgba(255,255,255,0.1); border-radius: 11px; overflow: hidden; }} #loading-overlay .progress-bar-fill {{ height: 100%; width: 0%; background: {accent_color}; border-radius: 11px; transition: width 0.3s; }} """ plugin_styles = self.get_section("head_styles") plugin_meta = self.get_section("meta_tags") plugin_head_scripts = self.get_section("head_scripts") plugin_body_top = self.get_section("body_top") plugin_body_bottom = self.get_section("body_bottom") plugin_config_overrides = self.get_section("config_overrides") # Build logo HTML logo_html = "" if logo_url: logo_html = f'<img class="logo" src="{logo_url}" alt="logo">' progress_html = "" if show_progress: progress_html = '<div class="progress-bar-container"><div class="progress-bar-fill" id="loading-bar"></div></div>' # ── Assemble template ──────────────────────────────────────── template = ( '<html lang="en-us">' '<script src="{{cookiecutter.cdn}}pythons.js" type=module id="site" ' 'data-python="python{{cookiecutter.PYBUILD}}" data-LINES=42 data-COLUMNS=132 ' 'data-os="vtx,snd,gui" async defer>#<!--\n' '\n' 'print("""\n' 'Loading {{cookiecutter.title}} from {{cookiecutter.archive}}.apk\n' ' Pygbag Version : {{cookiecutter.version}}\n' ' Template Version : 0.9.3\n' ' Python : {{cookiecutter.PYBUILD}}\n' ' CDN URL : {{cookiecutter.cdn}}\n' ' Screen : {{cookiecutter.width}}x{{cookiecutter.height}}\n' ' Title : {{cookiecutter.title}}\n' ' Folder : {{cookiecutter.directory}}\n' ' Authors : {{cookiecutter.authors}}\n' ' SPDX-License-Identifier: {{cookiecutter.spdx}}\n' '""")\n' '\n' 'import sys\n' 'import asyncio\n' 'import platform\n' 'import json\n' 'from pathlib import Path\n' '\n' 'async def custom_site():\n' ' import embed\n' f' platform.document.body.style.background = "{bg_color}"\n' ' platform.window.transfer.hidden = True\n' ' platform.window.canvas.style.visibility = "visible"\n' '\n' ' bundle = "{{cookiecutter.archive}}"\n' ' appdir = Path(f"/data/data/{bundle}")\n' # noqa — raw brace intentional ' appdir.mkdir()\n' '\n' " if platform.window.location.host.find('.itch.zone')>0:\n" ' import zipfile\n' ' async with platform.fopen("{{cookiecutter.archive}}.apk", "rb") as archive:\n' ' with zipfile.ZipFile(archive) as zip_ref:\n' ' zip_ref.extractall(appdir.as_posix())\n' ' else:\n' ' import tarfile\n' ' async with platform.fopen("{{cookiecutter.archive}}.tar.gz", "rb") as archive:\n' ' tar = tarfile.open(fileobj=archive, mode="r:gz")\n' " tar.extractall(path=appdir.as_posix(), filter='tar')\n" ' tar.close()\n' '\n' ' platform.run_main(PyConfig, loaderhome= appdir / "assets", loadermain=None)\n' '\n' ' while embed.counter()<0:\n' ' await asyncio.sleep(.1)\n' '\n' ' main = appdir / "assets" / "main.py"\n' '\n' ' if not platform.window.MM.UME:\n' ' __import__(__name__).__file__ = main\n' ' msg = "Ready to start ! Please click/touch page"\n' ' platform.window.infobox.innerText = msg\n' ' platform.window.show_infobox()\n' ' while not platform.window.MM.UME:\n' ' await asyncio.sleep(.1)\n' '\n' ' await TopLevel_async_handler.start_toplevel(platform.shell, console=window.python.config.debug)\n' '\n' ' __import__(__name__).__file__ = main\n' '\n' ' def ui_callback(pkg):\n' ' platform.window.infobox.innerText = f"installing {pkg}"\n' ' platform.window.show_infobox()\n' '\n' ' await shell.source(main, callback=ui_callback)\n' '\n' ' # Hide loading overlay once game starts\n' ' try:\n' ' overlay = platform.document.getElementById("loading-overlay")\n' ' if overlay:\n' ' overlay.style.display = "none"\n' ' except Exception:\n' ' pass\n' '\n' ' platform.window.infobox.style.display = "none"\n' ' platform.window.config.gui_divider = 1\n' ' platform.window.window_resize()\n' '\n' ' shell.interactive()\n' '\n' 'asyncio.run( custom_site() )\n' '\n' '# --></script><head><!--\n' '//=============================================================================\n' '\n' '--><script type="application/javascript">\n' '// END BLOCK\n' '\n' 'config = {\n' ' xtermjs : "{{cookiecutter.xtermjs}}" ,\n' ' _sdl2 : "canvas",\n' ' user_canvas : 0,\n' ' user_canvas_managed : 0,\n' ' gui_divider : 2,\n' ' ume_block : {{cookiecutter.ume_block}},\n' ' can_close : {{cookiecutter.can_close}},\n' ' archive : "{{cookiecutter.archive}}",\n' ' gui_debug : 2,\n' ' cdn : "{{cookiecutter.cdn}}",\n' ' autorun : {{cookiecutter.autorun}},\n' ' PYBUILD : "{{cookiecutter.PYBUILD}}",\n' f' fb_ar : {fb_ar},\n' f' fb_width : "{fb_w}",\n' f' fb_height : "{fb_h}"\n' ) if plugin_config_overrides: template += ' ,' + plugin_config_overrides + '\n' template += ( '}\n' '\n' 'function show_infobox() {\n' ' infobox.style.display = "block";\n' ' const w = infobox.offsetWidth;\n' ' const h = infobox.offsetHeight;\n' ' const left = (window.innerWidth - w) / 2;\n' ' const top = (window.innerHeight - h) / 2;\n' ' infobox.style.left = left + "px";\n' ' infobox.style.top = top + "px";\n' '}\n' '</script>\n' '\n' ' <title>{{cookiecutter.title}}</title>\n' ' <meta charset="UTF-8">\n' ' <meta http-equiv="X-UA-Compatible" content="IE=edge">\n' ' <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">\n' ' <meta name="mobile-web-app-capable" content="yes">\n' ' <meta name="apple-mobile-web-app-capable" content="yes"/>\n' ' <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>\n' ) if plugin_meta: template += plugin_meta + '\n' if plugin_head_scripts: template += plugin_head_scripts + '\n' template += ( ' <link rel="icon" type="image/png" href="favicon.png" sizes="16x16">\n' ' <style>\n' + base_styles + '\n' ) if plugin_styles: template += plugin_styles + '\n' template += ( ' </style>\n' ' <script src="{{cookiecutter.cdn}}/browserfs.min.js"></script>\n' '</head>\n' '\n' '<body>\n' ) # Loading overlay template += ( ' <div id="loading-overlay">\n' ) if logo_html: template += f' {logo_html}\n' template += ( f' <div class="loading-text">{loading_text}</div>\n' ) if progress_html: template += f' {progress_html}\n' template += ' </div>\n\n' if plugin_body_top: template += plugin_body_top + '\n' template += ( ' <div id="transfer" align=center>\n' ' <div class="emscripten" id="status">Downloading...</div>\n' ' <div class="emscripten"><progress value="0" max="100" id="progress"></progress></div>\n' ' </div>\n' '\n' f' <canvas class="emscripten" id="canvas" width="{fb_w}px" height="{fb_h}px" oncontextmenu="event.preventDefault()" tabindex=1></canvas>\n' f' <canvas class="emscripten" id="canvas3d" width="{fb_w}px" height="{fb_h}px" oncontextmenu="event.preventDefault()" tabindex=1 hidden></canvas>\n' '\n' ' <div id="infobox" style="display: none;"></div>\n' '\n' ' <div id="pyconsole">\n' ' <div id="terminal" tabIndex=1 align="left"></div>\n' ' </div>\n' '\n' ' <script type="application/javascript">\n' ' const ogProg = document.getElementById("progress");\n' ' const custBar = document.getElementById("loading-bar");\n' ' if (ogProg && custBar) {\n' ' new MutationObserver(() => {\n' ' let val = ogProg.value || 0;\n' ' let max = ogProg.max || 100;\n' ' custBar.style.width = ((val / max) * 100) + "%";\n' ' }).observe(ogProg, { attributes: true, attributeFilter: ["value", "max"] });\n' ' }\n' '\n' ' async function custom_onload(debug_hidden) {\n' ' pyconsole.hidden = debug_hidden;\n' ' transfer.hidden = debug_hidden;\n' ' // infobox will be shown when needed during loading\n' ' }\n' ' function custom_prerun(){}\n' ' function custom_postrun(){}\n' ' </script>\n' ) if plugin_body_bottom: template += plugin_body_bottom + '\n' template += ( '</body>\n' '</html>\n' ) return template
[docs] class BuildNode: def __init__(self, name: str, action): self.name = name self.action = action self.dependencies: list[str] = []
[docs] def depends_on(self, *node_names: str): for node_name in node_names: if node_name not in self.dependencies: self.dependencies.append(node_name) return self
[docs] class BuildGraph: def __init__(self): self.nodes: dict[str, BuildNode] = {}
[docs] def add(self, node: BuildNode): self.nodes[node.name] = node return node
[docs] def run(self, context: BuildContext): executed = set() for name in list(self.nodes.keys()): self._run_node(name, context, executed)
def _run_node(self, node_name: str, context: BuildContext, executed: set[str]): if node_name in executed: return if context.cancelled.is_set(): raise ExportCancelled("Export cancelled by user") node = self.nodes[node_name] for dependency in node.dependencies: self._run_node(dependency, context, executed) if context.cancelled.is_set(): raise ExportCancelled("Export cancelled by user") context.logger.info("Build step start", platform=context.platform, step=node.name) node.action(context) context.logger.info("Build step done", platform=context.platform, step=node.name) executed.add(node_name)
[docs] class Exporter: platform = "generic" def __init__(self, build_mode: str = "release"): self.build_mode = build_mode self.logger = get_logger(f"export.{self.platform}") self.graph = self._build_graph() self.cancelled = threading.Event() def _build_context(self, project_path: str, output_path: str): normalized_output = os.path.abspath(os.path.normpath(output_path)) platform_output = normalized_output if os.path.basename(normalized_output).lower() != self.platform.lower(): platform_output = os.path.join(normalized_output, self.platform) return BuildContext( project_path=os.path.abspath(os.path.normpath(project_path)), output_path=os.path.abspath(os.path.normpath(platform_output)), platform=self.platform, build_mode=self.build_mode, logger=self.logger, cancelled=self.cancelled ) def _build_graph(self): graph = BuildGraph() graph.add(BuildNode("prepare_output", self._prepare_output)) graph.add(BuildNode("capture_manifest", self._capture_manifest)).depends_on("prepare_output") graph.add(BuildNode("cook_assets", self._cook_assets)).depends_on("capture_manifest") graph.add(BuildNode("copy_runtime", self._copy_runtime)).depends_on("cook_assets") graph.add(BuildNode("write_template", self._write_platform_template)).depends_on("copy_runtime") graph.add(BuildNode("finalize", self._finalize)).depends_on("write_template") return graph
[docs] def export(self, project_path: str, output_path: str): context = self._build_context(project_path, output_path) self.graph.run(context) return context
def _prepare_output(self, context: BuildContext): os.makedirs(context.output_path, exist_ok=True) def _capture_manifest(self, context: BuildContext): manifest = self._collect_file_manifest(context.project_path) manifest_path = os.path.join(context.output_path, "build_manifest.json") with open(manifest_path, "w", encoding="utf-8") as file: json.dump(manifest, file, indent=2) context.metadata["manifest"] = manifest context.metadata["manifest_path"] = manifest_path def _cook_assets(self, context: BuildContext): source_assets = os.path.join(context.project_path, "assets") cooked_assets = os.path.join(context.output_path, "assets") if os.path.exists(cooked_assets): shutil.rmtree(cooked_assets) if os.path.exists(source_assets): shutil.copytree(source_assets, cooked_assets) context.metadata["cooked_assets"] = cooked_assets def _copy_runtime(self, context: BuildContext): runtime_dirs = ["core", "plugins"] engine_root = self._engine_root() for runtime_dir in runtime_dirs: source = os.path.join(context.project_path, runtime_dir) if not os.path.exists(source): source = os.path.join(engine_root, runtime_dir) if not os.path.exists(source): continue destination = os.path.join(context.output_path, runtime_dir) if os.path.exists(destination): shutil.rmtree(destination) shutil.copytree(source, destination) def _read_game_icon_path(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) icon_value = str(data.get("game_icon", "")).strip() if not icon_value: return "" return _resolve_project_asset(icon_value, project_path) except Exception: return "" def _prepare_web_icon(self, icon_path: str, output_path: str): if not icon_path: return "" if importlib.util.find_spec("PIL") is None: self.logger.warning( "Game icon skipped for web because Pillow is not installed", icon_path=icon_path ) return "" try: from PIL import Image converted_icon = os.path.join(output_path, "favicon.png") with Image.open(icon_path) as image: resized_image = image.resize((32, 32), Image.Resampling.LANCZOS) resized_image.save(converted_icon, format="PNG") if os.path.exists(converted_icon): return converted_icon except Exception as error: self.logger.warning( "Failed to convert game icon for web, using default icon", icon_path=icon_path, error=str(error) ) return "" def _write_platform_template(self, context: BuildContext): template = { "platform": context.platform, "mode": context.build_mode, "entrypoint": "core.player" } template_path = os.path.join(context.output_path, f"{context.platform}_template.json") with open(template_path, "w", encoding="utf-8") as file: json.dump(template, file, indent=2) context.metadata["template_path"] = template_path def _finalize(self, context: BuildContext): stamp_path = os.path.join(context.output_path, ".build_complete") with open(stamp_path, "w", encoding="utf-8") as file: file.write("ok") context.metadata["build_stamp"] = stamp_path def _collect_file_manifest(self, root_path: str): manifest = {"project_root": root_path, "files": []} for current_root, _, files in os.walk(root_path): for filename in sorted(files): full_path = os.path.join(current_root, filename) rel_path = os.path.relpath(full_path, root_path) if rel_path.startswith(".git"): continue stat = os.stat(full_path) manifest["files"].append( { "path": rel_path.replace("\\", "/"), "size": stat.st_size, "sha256": self._hash_file(full_path) } ) manifest["files"].sort(key=lambda item: item["path"]) return manifest def _hash_file(self, file_path: str): digest = hashlib.sha256() with open(file_path, "rb") as file: while True: chunk = file.read(65536) if not chunk: break digest.update(chunk) return digest.hexdigest() def _engine_root(self): return os.path.abspath(os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))) def _resolve_module_command(self, module_name: str): if not getattr(sys, "frozen", False): if importlib.util.find_spec(module_name) is None: return None return [sys.executable, "-m", module_name] candidate_prefixes = self._candidate_python_prefixes() probe_env = self._tool_env() seen = set() for prefix in candidate_prefixes: key = tuple(prefix) if key in seen: continue seen.add(key) if self._probe_module_on_prefix(prefix, module_name, probe_env): return [*prefix, "-m", module_name] packages = self._required_packages_for_module(module_name) if packages and self._install_host_packages(prefix, packages): if self._probe_module_on_prefix(prefix, module_name, probe_env): return [*prefix, "-m", module_name] venv_prefix = self._create_tool_venv() if venv_prefix and tuple(venv_prefix) not in seen: if self._probe_module_on_prefix(venv_prefix, module_name, probe_env): return [*venv_prefix, "-m", module_name] packages = self._required_packages_for_module(module_name) if packages and self._install_host_packages(venv_prefix, packages): if self._probe_module_on_prefix(venv_prefix, module_name, probe_env): return [*venv_prefix, "-m", module_name] return None def _tool_env(self): env = os.environ.copy() if getattr(sys, "frozen", False): for key in ( "PYTHONHOME", "PYTHONPATH", "PYTHONEXECUTABLE", "PYTHONNOUSERSITE", "PYTHONUSERBASE", "_MEIPASS2", "_PYI_SPLASH_IPC", ): env.pop(key, None) meipass = getattr(sys, "_MEIPASS", None) if meipass: for key in ("TCL_LIBRARY", "TK_LIBRARY", "SSL_CERT_FILE", "SSL_CERT_DIR"): val = env.get(key, "") if val.startswith(meipass): env.pop(key, None) if platform.system() != "Windows": original_ld = env.get("LD_LIBRARY_PATH_ORIG") if original_ld is not None: env["LD_LIBRARY_PATH"] = original_ld else: env.pop("LD_LIBRARY_PATH", None) return env def _tool_venv_dir(self): if platform.system() == "Windows": base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~")) else: base = os.environ.get("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) return os.path.join(base, "axispy", "tool-venv") def _tool_venv_prefix(self): venv_dir = self._tool_venv_dir() if platform.system() == "Windows": py = os.path.join(venv_dir, "Scripts", "python.exe") else: py = os.path.join(venv_dir, "bin", "python3") if os.path.isfile(py): return [py] return None def _create_tool_venv(self): venv_dir = self._tool_venv_dir() if os.path.isdir(venv_dir): return self._tool_venv_prefix() env = self._tool_env() for bin_name in ("python3", "python"): py = shutil.which(bin_name) if py is None: continue try: result = subprocess.run( [py, "-m", "venv", venv_dir], check=False, capture_output=True, text=True, timeout=30, env=env, ) if result.returncode == 0: return self._tool_venv_prefix() except Exception: continue return None def _candidate_python_prefixes(self): candidate_prefixes: list[list[str]] = [] host_python = os.environ.get("AXISPY_HOST_PYTHON", "").strip() if host_python: candidate_prefixes.append([host_python]) venv_prefix = self._tool_venv_prefix() if venv_prefix: candidate_prefixes.append(venv_prefix) if platform.system() == "Windows": candidate_prefixes.append(["py", "-3"]) python_bin = shutil.which("python") if python_bin: candidate_prefixes.append([python_bin]) python3_bin = shutil.which("python3") if python3_bin: candidate_prefixes.append([python3_bin]) return candidate_prefixes def _probe_module_on_prefix(self, prefix: list[str], module_name: str, env: dict): try: probe = subprocess.run( [*prefix, "-c", f"import importlib.util,sys;sys.exit(0 if importlib.util.find_spec('{module_name}') else 1)"], check=False, capture_output=True, text=True, timeout=10, env=env ) return probe.returncode == 0 except Exception: return False def _required_packages_for_module(self, module_name: str): mapping = { "PyInstaller": ["pyinstaller", "pyinstaller-hooks-contrib", "cryptography"], "pygbag": ["pygbag"], "buildozer": ["buildozer", "cython"], } return mapping.get(module_name, []) def _install_host_packages(self, prefix: list[str], packages: list[str]): env = self._tool_env() base = [*prefix, "-m", "pip", "install", "--upgrade"] any_installed = False for pkg in packages: for cmd in ([*base, pkg], [*base, "--user", pkg]): result = subprocess.run(cmd, check=False, capture_output=True, text=True, env=env) if result.returncode == 0: any_installed = True break return any_installed def _python_prefix_from_module_command(self, module_cmd: list[str]): if "-m" in module_cmd: return module_cmd[:module_cmd.index("-m")] return module_cmd[:1] def _has_cryptography_hook_failure(self, output: str): text = str(output or "") return "hook-cryptography.py" in text and "NoneType" in text def _repair_pyinstaller_cryptography_host(self, pyinstaller_cmd: list[str]): prefix = self._python_prefix_from_module_command(pyinstaller_cmd) if not prefix: return False repair_cmd = [ *prefix, "-m", "pip", "install", "--upgrade", "--force-reinstall", "cryptography", "pyinstaller-hooks-contrib", ] repair = subprocess.run( repair_cmd, check=False, capture_output=True, text=True, env=self._tool_env() ) return repair.returncode == 0
[docs] class WebExporter(Exporter): platform = "web" def _copy_runtime(self, context: BuildContext): super()._copy_runtime(context) self._copy_project_payload(context) def _write_platform_template(self, context: BuildContext): super()._write_platform_template(context) entry_scene = self._resolve_entry_scene(context) launcher_path = os.path.join(context.output_path, "main.py") with open(launcher_path, "w", encoding="utf-8") as file: file.write("import asyncio\n") file.write("import importlib\n") file.write("import os\n") file.write("import pygame\n") file.write("import sys\n") file.write("base_dir = os.path.dirname(os.path.abspath(__file__))\n") file.write("if base_dir not in sys.path:\n") file.write(" sys.path.insert(0, base_dir)\n") file.write("if not hasattr(pygame, 'init'):\n") file.write(" try:\n") file.write(" pygame_alt = importlib.import_module('pygame_ce')\n") file.write(" sys.modules['pygame'] = pygame_alt\n") file.write(" pygame = pygame_alt\n") file.write(" except Exception:\n") file.write(" pass\n") file.write("project_root = os.path.join(base_dir, 'project')\n") file.write("os.environ['AXISPY_PROJECT_PATH'] = project_root\n") file.write("from core.player import run\n") if entry_scene: file.write(f"scene_path = os.path.join(project_root, {repr(entry_scene)})\n") else: file.write("scene_path = None\n") file.write("async def main():\n") file.write(" try:\n") file.write(" run_result = run(scene_path)\n") file.write(" if hasattr(run_result, '__await__'):\n") file.write(" await run_result\n") file.write(" except SystemExit:\n") file.write(" pass\n") file.write(" await asyncio.sleep(0)\n") file.write("asyncio.run(main())\n") context.metadata["web_launcher_path"] = launcher_path context.metadata["web_entry_scene"] = entry_scene # Prepare web favicon game_icon = self._read_game_icon_path(context.project_path) web_icon = self._prepare_web_icon(game_icon, context.output_path) if web_icon: context.metadata["web_icon_path"] = web_icon
[docs] def export_with_pygbag(self, project_path: str, output_path: str): context = self.export(project_path, output_path) pygbag_cmd = self._resolve_module_command("pygbag") if pygbag_cmd is None: if getattr(sys, "frozen", False): raise RuntimeError( "pygbag export from the packaged editor requires an external Python with pygbag.\n" "Install Python + pygbag and ensure 'python -m pygbag --help' works,\n" "or set AXISPY_HOST_PYTHON to that interpreter path." ) raise RuntimeError( f"pygbag is not installed for interpreter '{sys.executable}'. " "Install it with: pip install pygbag --user --upgrade" ) # Read project config for template generation project_config = {} config_path = os.path.join(context.project_path, "project.config") if os.path.exists(config_path): try: with open(config_path, "r", encoding="utf-8") as f: project_config = json.load(f) except Exception: pass # Copy designer assets (logo, bg image) into export dir if they are local files designer = project_config.get("pygbag_designer", {}) for key in ("logo_url", "background_image_url"): asset_path = designer.get(key, "").strip() if asset_path and not asset_path.startswith(("http://", "https://", "//")): asset_path = _resolve_project_asset(asset_path, context.project_path) if asset_path: dest_name = os.path.basename(asset_path) try: shutil.copy2(asset_path, os.path.join(context.output_path, dest_name)) designer[key] = dest_name if self.logger: self.logger.info(f"Copied designer asset: {key} -> {dest_name}", src=asset_path, dest=dest_name) except Exception as e: if self.logger: self.logger.error(f"Failed to copy designer asset: {key}", error=str(e), path=asset_path) else: if self.logger: self.logger.warning(f"Designer asset file not found: {key}", path=asset_path) else: if asset_path and self.logger: self.logger.info(f"Using remote URL for {key}: {asset_path}") project_config["pygbag_designer"] = designer # Build custom template via the registry pipeline registry = WebTemplateRegistry.instance() # Pass the possibly updated URLs to build_template template_html = registry.build_template(project_config, logo_url=designer.get("logo_url", ""), bg_image_url=designer.get("background_image_url", "")) tmpl_path = os.path.join(context.output_path, "custom.tmpl") with open(tmpl_path, "w", encoding="utf-8") as f: f.write(template_html) context.metadata["web_template_path"] = tmpl_path build_stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") parent_dir = os.path.dirname(context.output_path) folder_name = os.path.basename(context.output_path) pygbag_args = [*pygbag_cmd, "--build", "--template", tmpl_path, folder_name] result = subprocess.run( pygbag_args, cwd=parent_dir, check=False, capture_output=True, text=True, env=self._tool_env() ) combined_output = "" if result.stdout: combined_output += result.stdout if result.stderr: if combined_output: combined_output += "\n" combined_output += result.stderr log_path = os.path.join(context.output_path, f"pygbag_build_{build_stamp}.log") with open(log_path, "w", encoding="utf-8") as log_file: log_file.write(combined_output) if result.returncode != 0: tail_lines = [line for line in combined_output.splitlines() if line.strip()][-20:] tail_text = "\n".join(tail_lines) raise RuntimeError( "pygbag build failed.\n" f"Interpreter: {sys.executable}\n" f"Log: {log_path}\n" f"Last output lines:\n{tail_text}" ) web_build_path = os.path.join(context.output_path, "build", "web") if not os.path.exists(web_build_path): web_build_path = os.path.join(context.output_path, "build") web_icon_path = context.metadata.get("web_icon_path") if web_icon_path and os.path.exists(web_icon_path): try: shutil.copy2(web_icon_path, os.path.join(web_build_path, "favicon.png")) except Exception as e: self.logger.warning("Failed to copy web favicon", error=str(e)) # Copy designer assets to the final web directory after pygbag build for key in ("logo_url", "background_image_url"): filename = designer.get(key, "").strip() if filename and not filename.startswith(("http://", "https://", "//")): src_path = os.path.join(context.output_path, filename) if os.path.isfile(src_path): dst_path = os.path.join(web_build_path, filename) try: shutil.copy2(src_path, dst_path) if self.logger: self.logger.info(f"Copied designer asset to final web dir: {key}", dst=dst_path) except Exception as e: if self.logger: self.logger.warning(f"Failed to copy designer asset to final web dir: {key}", error=str(e)) context.metadata["web_dist_path"] = web_build_path context.metadata["pygbag_log_path"] = log_path return context
def _copy_project_payload(self, context: BuildContext): project_payload = os.path.join(context.output_path, "project") if os.path.exists(project_payload): shutil.rmtree(project_payload) project_root = os.path.abspath(context.project_path) output_root = os.path.abspath(context.output_path) _WEB_IGNORE_DIRS = {"__pycache__", ".axispy", ".git", ".github"} _WEB_IGNORE_NAMES = {"temp_scene", "temp_scene.scn", "temp_scene_runcheck", "temp_scene_runcheck.scn", "build"} _WEB_IGNORE_EXTS = {".psd", ".xcf", ".blend", ".blend1", ".aseprite", ".ase"} def ignore_names(current_dir: str, names: list[str]): ignored = [] for name in names: child_path = os.path.abspath(os.path.join(current_dir, name)) try: if os.path.commonpath([output_root, child_path]) == child_path: ignored.append(name) continue except ValueError: pass if name in _WEB_IGNORE_DIRS: ignored.append(name) continue if name in _WEB_IGNORE_NAMES: ignored.append(name) continue _ext = os.path.splitext(name)[1].lower() if _ext in _WEB_IGNORE_EXTS: ignored.append(name) return ignored shutil.copytree(project_root, project_payload, ignore=ignore_names) def _resolve_entry_scene(self, context: BuildContext): configured = self._read_entry_scene(context.project_path) if configured: configured_path = os.path.join(context.project_path, configured) if os.path.exists(configured_path): return configured.replace("\\", "/") for root, _, files in os.walk(context.project_path): for filename in sorted(files): if filename.lower().endswith(".scn"): scene_full = os.path.join(root, filename) return os.path.relpath(scene_full, context.project_path).replace("\\", "/") return "" def _read_entry_scene(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) entry_scene = str(data.get("entry_scene", "")).strip() if not entry_scene: return "" return _to_native_path(entry_scene).replace("\\", "/") except Exception: return ""
HTML5Exporter = WebExporter
[docs] class DesktopExporter(Exporter): platform = "desktop" def __init__(self, build_mode: str = "release", target_os: str = ""): self.target_os = target_os.lower() if target_os else platform.system().lower() # Normalize common names if self.target_os in ("macos", "darwin", "osx", "mac"): self.target_os = "macos" elif self.target_os in ("win", "windows", "win32"): self.target_os = "windows" elif self.target_os in ("linux", "linux2"): self.target_os = "linux" super().__init__(build_mode=build_mode) def _build_context(self, project_path: str, output_path: str): context = super()._build_context(project_path, output_path) # Append target OS subfolder: build/desktop/windows, build/desktop/linux, build/desktop/macos context.output_path = os.path.join(context.output_path, self.target_os) return context def _copy_runtime(self, context: BuildContext): super()._copy_runtime(context) self._copy_project_payload(context) def _write_platform_template(self, context: BuildContext): super()._write_platform_template(context) entry_scene = self._resolve_entry_scene(context) launcher_path = os.path.join(context.output_path, "desktop_launcher.py") with open(launcher_path, "w", encoding="utf-8") as file: file.write("import os\n") file.write("import sys\n") file.write("base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))\n") file.write("search_paths = [base_dir, os.path.join(base_dir, '_internal')]\n") file.write("meipass = getattr(sys, '_MEIPASS', '')\n") file.write("if meipass:\n") file.write(" search_paths.append(meipass)\n") file.write("for candidate in search_paths:\n") file.write(" if candidate and os.path.exists(candidate) and candidate not in sys.path:\n") file.write(" sys.path.insert(0, candidate)\n") file.write("project_root = os.path.join(base_dir, 'project')\n") file.write("if not os.path.exists(project_root) and meipass:\n") file.write(" project_root = os.path.join(meipass, 'project')\n") file.write("os.environ['AXISPY_PROJECT_PATH'] = project_root\n") file.write("from core.player import run\n") if entry_scene: file.write(f"scene_path = os.path.join(project_root, {repr(entry_scene)})\n") file.write("run(scene_path)\n") else: file.write("run(None)\n") context.metadata["desktop_launcher_path"] = launcher_path context.metadata["desktop_entry_scene"] = entry_scene context.metadata["desktop_target_os"] = self.target_os # Write convenience run scripts for non-PyInstaller usage self._write_run_scripts(context, entry_scene) def _write_run_scripts(self, context: BuildContext, entry_scene: str): """Generate platform-specific run scripts.""" # Bash script (Linux/macOS) bash_path = os.path.join(context.output_path, "run_game.sh") with open(bash_path, "w", encoding="utf-8", newline="\n") as file: file.write("#!/bin/bash\n") file.write("SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n") file.write("cd \"$SCRIPT_DIR\"\n") file.write("python3 desktop_launcher.py \"$@\"\n") context.metadata["desktop_bash_script"] = bash_path # Batch script (Windows) bat_path = os.path.join(context.output_path, "run_game.bat") with open(bat_path, "w", encoding="utf-8") as file: file.write("@echo off\n") file.write("cd /d \"%~dp0\"\n") file.write("python desktop_launcher.py %*\n") context.metadata["desktop_bat_script"] = bat_path
[docs] def export_with_pyinstaller(self, project_path: str, output_path: str): context = self.export(project_path, output_path) pyinstaller_cmd = self._resolve_module_command("PyInstaller") if pyinstaller_cmd is None: if getattr(sys, "frozen", False): raise RuntimeError( "PyInstaller export from the packaged editor requires an external Python with PyInstaller.\n" "Install Python + PyInstaller and ensure 'python -m PyInstaller --version' works,\n" "or set AXISPY_HOST_PYTHON to that interpreter path." ) raise RuntimeError( f"PyInstaller is not installed for interpreter '{sys.executable}'. " "Install it with: pip install pyinstaller" ) # Ensure pygame is available in the host Python so --collect-all can find it prefix = self._python_prefix_from_module_command(pyinstaller_cmd) probe_env = self._tool_env() if not self._probe_module_on_prefix(prefix, "pygame", probe_env): self.logger.info("pygame not found in host Python, attempting install", prefix=prefix) self._install_host_packages(prefix, ["pygame"]) if not self._probe_module_on_prefix(prefix, "pygame", probe_env): self.logger.warning( "pygame could not be installed in the host Python. " "The built executable may fail to run. " "Install pygame manually: pip install pygame" ) launcher_path = context.metadata.get("desktop_launcher_path") if not launcher_path or not os.path.exists(launcher_path): raise RuntimeError("Desktop launcher was not generated.") build_stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") dist_path = os.path.join(context.output_path, "dist", build_stamp) work_path = os.path.join(context.output_path, "build_pyinstaller", build_stamp) spec_path = os.path.join(context.output_path, "spec", build_stamp) os.makedirs(dist_path, exist_ok=True) os.makedirs(work_path, exist_ok=True) os.makedirs(spec_path, exist_ok=True) game_name = self._read_game_name(context.project_path) or "AxisPyGame" game_icon = self._read_game_icon_path(context.project_path) pyinstaller_icon = self._prepare_platform_icon(game_icon, context.output_path, build_stamp) pyinstaller_args = [ *pyinstaller_cmd, "--noconfirm", "--clean", "--name", game_name, "--distpath", dist_path, "--workpath", work_path, "--specpath", spec_path, "--windowed", "--paths", context.output_path, "--paths", self._engine_root(), "--hidden-import", "pygame", "--hidden-import", "pygame.base", "--hidden-import", "pygame.constants", "--hidden-import", "pygame.rect", "--hidden-import", "pygame.rwobject", "--hidden-import", "pygame.surflock", "--hidden-import", "pygame.color", "--hidden-import", "pygame.bufferproxy", "--hidden-import", "pygame.math", "--hidden-import", "pygame.pixelcopy", "--collect-all", "pygame", "--exclude-module", "PyQt5", "--exclude-module", "PySide2", "--exclude-module", "PySide6", ] # Platform-specific PyInstaller args if self.target_os == "macos": bundle_id = f"com.axispy.{game_name.lower()}" pyinstaller_args.extend(["--osx-bundle-identifier", bundle_id]) if pyinstaller_icon: pyinstaller_args.extend(["--icon", pyinstaller_icon]) for data_path in ("project", "core", "plugins"): absolute_data_path = os.path.join(context.output_path, data_path) if os.path.exists(absolute_data_path): pyinstaller_args.extend(["--add-data", f"{absolute_data_path}{os.pathsep}{data_path}"]) pyinstaller_args.append(launcher_path) log_path = os.path.join(context.output_path, f"pyinstaller_build_{build_stamp}.log") result = subprocess.run( pyinstaller_args, cwd=context.output_path, check=False, capture_output=True, text=True, env=self._tool_env() ) retried_after_repair = False if result.returncode != 0: combined_probe = (result.stdout or "") + "\n" + (result.stderr or "") if self._has_cryptography_hook_failure(combined_probe): if self._repair_pyinstaller_cryptography_host(pyinstaller_cmd): retried_after_repair = True result = subprocess.run( pyinstaller_args, cwd=context.output_path, check=False, capture_output=True, text=True, env=self._tool_env() ) combined_output = "" if result.stdout: combined_output += result.stdout if result.stderr: if combined_output: combined_output += "\n" combined_output += result.stderr with open(log_path, "w", encoding="utf-8") as log_file: log_file.write(combined_output) if result.returncode != 0: tail_lines = [line for line in combined_output.splitlines() if line.strip()][-20:] tail_text = "\n".join(tail_lines) hint = "" if self._has_cryptography_hook_failure(combined_output): hint = ( "\n\nDetected cryptography hook failure in the external Python environment.\n" "Run on the host Python:\n" " python -m pip install --upgrade --force-reinstall cryptography pyinstaller-hooks-contrib" ) if retried_after_repair: hint += "\nAutomatic repair was attempted once but the host environment is still failing." raise RuntimeError( "PyInstaller build failed.\n" f"Target OS: {self.target_os}\n" f"Interpreter: {sys.executable}\n" f"Log: {log_path}\n" f"Last output lines:\n{tail_text}{hint}" ) context.metadata["desktop_dist_path"] = dist_path context.metadata["desktop_executable_name"] = game_name context.metadata["desktop_icon_path"] = game_icon context.metadata["desktop_pyinstaller_icon_path"] = pyinstaller_icon context.metadata["desktop_target_os"] = self.target_os context.metadata["pyinstaller_log_path"] = log_path return context
def _copy_project_payload(self, context: BuildContext): project_payload = os.path.join(context.output_path, "project") if os.path.exists(project_payload): shutil.rmtree(project_payload) project_root = os.path.abspath(context.project_path) output_root = os.path.abspath(context.output_path) def ignore_names(current_dir: str, names: list[str]): ignored = [] for name in names: child_path = os.path.abspath(os.path.join(current_dir, name)) try: if os.path.commonpath([output_root, child_path]) == child_path: ignored.append(name) continue except ValueError: pass if name == "__pycache__": ignored.append(name) if name in {"temp_scene", "temp_scene.scn", "temp_scene_runcheck", "temp_scene_runcheck.scn", "build"}: ignored.append(name) return ignored shutil.copytree(project_root, project_payload, ignore=ignore_names) pruned_count = self._prune_images_if_atlas_ready(project_payload) context.metadata["pruned_source_images_count"] = pruned_count def _prune_images_if_atlas_ready(self, project_payload: str): atlas_manifest = os.path.join(project_payload, "assets", ".atlas", "sprites_atlas.json") atlas_image = os.path.join(project_payload, "assets", ".atlas", "sprites_atlas.png") images_root = os.path.join(project_payload, "assets", "images") if not os.path.exists(atlas_manifest) or not os.path.exists(atlas_image) or not os.path.isdir(images_root): return 0 try: with open(atlas_manifest, "r", encoding="utf-8") as file: manifest_data = json.load(file) except Exception: return 0 source_signature = manifest_data.get("source_signature", {}) if not isinstance(source_signature, dict): return 0 source_files = self._collect_image_source_files(images_root) actual_signature = self._compute_image_signature(project_payload, source_files) if source_signature.get("count") != actual_signature.get("count"): return 0 if source_signature.get("hash") != actual_signature.get("hash"): return 0 pruned = 0 for abs_path in source_files: try: os.remove(abs_path) pruned += 1 except Exception: continue return pruned def _collect_image_source_files(self, images_root: str): valid_exts = {".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"} files = [] for root, _, names in os.walk(images_root): for name in names: if os.path.splitext(name)[1].lower() in valid_exts: files.append(os.path.join(root, name)) files.sort() return files def _compute_image_signature(self, project_payload: str, source_files: list[str]): hasher = hashlib.sha1() normalized_project = os.path.normpath(project_payload) for abs_path in source_files: normalized_abs = os.path.normpath(abs_path) rel_path = os.path.relpath(normalized_abs, normalized_project).replace("\\", "/") try: stat_info = os.stat(normalized_abs) except OSError: continue hasher.update(rel_path.encode("utf-8")) hasher.update(b"|") hasher.update(str(int(stat_info.st_size)).encode("utf-8")) hasher.update(b"|") hasher.update(str(int(stat_info.st_mtime_ns)).encode("utf-8")) hasher.update(b"\n") return { "count": len(source_files), "hash": hasher.hexdigest() } def _resolve_entry_scene(self, context: BuildContext): configured = self._read_entry_scene(context.project_path) if configured: configured_path = os.path.join(context.project_path, configured) if os.path.exists(configured_path): return configured for root, _, files in os.walk(context.project_path): for filename in sorted(files): if filename.lower().endswith(".scn"): scene_full = os.path.join(root, filename) return os.path.relpath(scene_full, context.project_path).replace("\\", "/") return "" def _read_entry_scene(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) entry_scene = str(data.get("entry_scene", "")).strip() if not entry_scene: return "" return _to_native_path(entry_scene) except Exception: return "" def _read_game_name(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) name = str(data.get("game_name", "")).strip() if not name: return "" sanitized = "".join(ch for ch in name if ch.isalnum() or ch in ("_", "-")) return sanitized or "" except Exception: return "" def _prepare_platform_icon(self, icon_path: str, output_path: str, build_stamp: str): """Convert game icon to the correct format for the target OS. Windows: .ico, macOS: .icns, Linux: .png (PyInstaller accepts .png). """ if not icon_path: return "" extension = os.path.splitext(icon_path)[1].lower() # Windows — needs .ico if self.target_os == "windows": if extension == ".ico": return icon_path if importlib.util.find_spec("PIL") is None: self.logger.warning("Game icon skipped (Pillow not installed)", icon_path=icon_path) return "" try: from PIL import Image converted = os.path.join(output_path, f"icon_{build_stamp}.ico") with Image.open(icon_path) as img: img.save( converted, format="ICO", sizes=[(256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (16, 16)] ) if os.path.exists(converted): return converted except Exception as error: self.logger.warning("Failed to convert icon to .ico", icon_path=icon_path, error=str(error)) return "" # macOS — needs .icns if self.target_os == "macos": if extension == ".icns": return icon_path if importlib.util.find_spec("PIL") is None: self.logger.warning("Game icon skipped (Pillow not installed)", icon_path=icon_path) return "" try: from PIL import Image converted = os.path.join(output_path, f"icon_{build_stamp}.icns") with Image.open(icon_path) as img: # macOS .icns needs specific sizes; Pillow saves the largest resized = img.resize((1024, 1024), Image.Resampling.LANCZOS) resized.save(converted, format="ICNS") if os.path.exists(converted): return converted except Exception as error: self.logger.warning("Failed to convert icon to .icns", icon_path=icon_path, error=str(error)) return "" # Linux — .png works directly with PyInstaller if self.target_os == "linux": if extension == ".png": return icon_path if importlib.util.find_spec("PIL") is None: self.logger.warning("Game icon skipped (Pillow not installed)", icon_path=icon_path) return "" try: from PIL import Image converted = os.path.join(output_path, f"icon_{build_stamp}.png") with Image.open(icon_path) as img: resized = img.resize((256, 256), Image.Resampling.LANCZOS) resized.save(converted, format="PNG") if os.path.exists(converted): return converted except Exception as error: self.logger.warning("Failed to convert icon to .png", icon_path=icon_path, error=str(error)) return "" return ""
[docs] class MobileExporter(Exporter): platform = "mobile" def __init__(self, build_mode: str = "release", output_format: str = "apk"): self.output_format = output_format.lower() # "apk" or "aab" super().__init__(build_mode=build_mode) def _build_context(self, project_path: str, output_path: str): context = super()._build_context(project_path, output_path) context.output_path = os.path.join(context.output_path, "android") return context def _copy_runtime(self, context: BuildContext): super()._copy_runtime(context) self._copy_project_payload(context) def _copy_project_payload(self, context: BuildContext): project_payload = os.path.join(context.output_path, "project") if os.path.exists(project_payload): shutil.rmtree(project_payload) project_root = os.path.abspath(context.project_path) output_root = os.path.abspath(context.output_path) def ignore_names(current_dir: str, names: list[str]): ignored = [] for name in names: child_path = os.path.abspath(os.path.join(current_dir, name)) try: if os.path.commonpath([output_root, child_path]) == child_path: ignored.append(name) continue except ValueError: pass if name == "__pycache__": ignored.append(name) if name in {"build", "temp_scene", "temp_scene.scn", "temp_scene_runcheck", "temp_scene_runcheck.scn"}: ignored.append(name) return ignored shutil.copytree(project_root, project_payload, ignore=ignore_names) def _read_android_config(self, project_path: str) -> dict: config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return {} try: with open(config_path, "r", encoding="utf-8") as f: data = json.load(f) return data.get("android", {}) except Exception: return {} def _write_platform_template(self, context: BuildContext): super()._write_platform_template(context) android_cfg = self._read_android_config(context.project_path) game_name = self._read_game_name(context.project_path) or "AxisPyGame" game_version = self._read_game_version(context.project_path) or "1.0.0" entry_scene = self._resolve_entry_scene(context) game_icon = self._read_game_icon_path(context.project_path) package_name = android_cfg.get("package_name", "com.axispy.mygame") version_code = int(android_cfg.get("version_code", 1)) min_sdk = int(android_cfg.get("min_sdk", 21)) target_sdk = int(android_cfg.get("target_sdk", 33)) ndk_api = int(android_cfg.get("ndk_api", 21)) orientation = android_cfg.get("orientation", "landscape") fullscreen = bool(android_cfg.get("fullscreen", True)) permissions_list = android_cfg.get("permissions", ["INTERNET"]) if isinstance(permissions_list, list): permissions_str = ",".join(permissions_list) else: permissions_str = "INTERNET" python_deps = android_cfg.get("python_dependencies", "").strip() sdk_path = android_cfg.get("sdk_path", "") ndk_path = android_cfg.get("ndk_path", "") # Build requirements (default engine dependencies always included) requirements = "python3,kivy,pygame==2.5.2,websockets,pywebview,aiortc" if python_deps: requirements += "," + python_deps # Write mobile launcher (main.py for Buildozer) launcher_path = os.path.join(context.output_path, "main.py") with open(launcher_path, "w", encoding="utf-8") as f: f.write("import os\n") f.write("import sys\n") f.write("base_dir = os.path.dirname(os.path.abspath(__file__))\n") f.write("sys.path.insert(0, base_dir)\n") f.write("project_root = os.path.join(base_dir, 'project')\n") f.write("os.environ['AXISPY_PROJECT_PATH'] = project_root\n") f.write("from core.player import run\n") if entry_scene: f.write(f"scene_path = os.path.join(project_root, {repr(entry_scene)})\n") f.write("run(scene_path)\n") else: f.write("run(None)\n") context.metadata["mobile_launcher_path"] = launcher_path # Write buildozer.spec spec_path = os.path.join(context.output_path, "buildozer.spec") spec_lines = [ "[app]", f"title = {game_name}", f"package.name = {package_name.rsplit('.', 1)[-1]}", f"package.domain = {'.'.join(package_name.rsplit('.', 1)[:-1]) or 'com.axispy'}", "source.dir = .", "source.include_exts = py,png,jpg,jpeg,bmp,gif,webp,wav,ogg,mp3,json,scn,cfg,config,txt,ttf,otf,fnt", "source.exclude_dirs = build,dist,.buildozer,__pycache__,.git", f"version = {game_version}", f"requirements = {requirements}", f"android.minapi = {min_sdk}", f"android.api = {target_sdk}", f"android.ndk_api = {ndk_api}", f"orientation = {orientation}", f"fullscreen = {'1' if fullscreen else '0'}", f"android.permissions = {permissions_str}", f"android.archs = arm64-v8a,armeabi-v7a", f"android.numeric_version = {version_code}", ] if game_icon: # Copy icon into export dir icon_dest = os.path.join(context.output_path, "icon.png") try: if os.path.exists(game_icon): shutil.copy2(game_icon, icon_dest) spec_lines.append(f"icon.filename = icon.png") except Exception: pass if sdk_path: spec_lines.append(f"android.sdk_path = {sdk_path}") if ndk_path: spec_lines.append(f"android.ndk_path = {ndk_path}") # Signing configuration keystore_path = android_cfg.get("keystore_path", "") keystore_alias = android_cfg.get("keystore_alias", "") keystore_password = android_cfg.get("keystore_password", "") if keystore_path and keystore_alias: spec_lines.append(f"android.keystore = {keystore_path}") spec_lines.append(f"android.keyalias = {keystore_alias}") if keystore_password: spec_lines.append(f"android.keystore_password = {keystore_password}") spec_lines.append(f"android.keyalias_password = {keystore_password}") # AAB support if self.output_format == "aab": spec_lines.append("android.aab = True") spec_lines.append("") # trailing newline spec_lines.append("[buildozer]") spec_lines.append("log_level = 2") spec_lines.append("warn_on_root = 1") spec_lines.append("") with open(spec_path, "w", encoding="utf-8") as f: f.write("\n".join(spec_lines)) context.metadata["buildozer_spec"] = spec_path context.metadata["mobile_output_format"] = self.output_format context.metadata["mobile_package_name"] = package_name context.metadata["mobile_entry_scene"] = entry_scene # Write build manifest manifest = { "game_name": game_name, "package_name": package_name, "version": game_version, "version_code": version_code, "output_format": self.output_format, "min_sdk": min_sdk, "target_sdk": target_sdk, "orientation": orientation, "build_mode": context.build_mode, "permissions": permissions_list, "has_keystore": bool(keystore_path and keystore_alias) } manifest_path = os.path.join(context.output_path, "build_manifest.json") with open(manifest_path, "w", encoding="utf-8") as f: json.dump(manifest, f, indent=2) context.metadata["mobile_manifest_path"] = manifest_path def _resolve_entry_scene(self, context: BuildContext): configured = self._read_entry_scene(context.project_path) if configured: configured_path = os.path.join(context.project_path, configured) if os.path.exists(configured_path): return configured for root, _, files in os.walk(context.project_path): for filename in sorted(files): if filename.lower().endswith(".scn"): scene_full = os.path.join(root, filename) return os.path.relpath(scene_full, context.project_path).replace("\\", "/") return "" def _read_entry_scene(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as f: data = json.load(f) return _to_native_path(str(data.get("entry_scene", "")).strip()) if data.get("entry_scene") else "" except Exception: return "" def _read_game_name(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as f: data = json.load(f) name = str(data.get("game_name", "")).strip() return name or "" except Exception: return "" def _read_game_version(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as f: data = json.load(f) return str(data.get("version", "1.0.0")).strip() except Exception: return "1.0.0" def _read_game_icon_path(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as f: data = json.load(f) icon = str(data.get("game_icon", "")).strip() if not icon: return "" return _resolve_project_asset(icon, project_path) except Exception: return ""
[docs] def export_with_buildozer(self, project_path: str, output_path: str): context = self.export(project_path, output_path) build_action = "release" if self.build_mode == "release" else "debug" # ── Windows: buildozer cannot run natively, generate WSL script ── if platform.system() == "Windows": wsl_script_path = os.path.join(context.output_path, "build_with_wsl.sh") with open(wsl_script_path, "w", encoding="utf-8", newline="\n") as f: f.write("#!/bin/bash\n") f.write("# Run this script inside WSL or a Linux machine.\n") f.write("# Buildozer does NOT run natively on Windows.\n") f.write("#\n") f.write("# Prerequisites (run once):\n") f.write("# sudo apt update && sudo apt install -y \\\n") f.write("# build-essential git zip unzip openjdk-17-jdk \\\n") f.write("# autoconf libtool pkg-config zlib1g-dev \\\n") f.write("# libncurses5-dev libncursesw5-dev cmake \\\n") f.write("# libffi-dev libssl-dev python3-pip python3-venv\n") f.write("# python3 -m venv ~/.buildozer_venv\n") f.write("# ~/.buildozer_venv/bin/pip install buildozer cython\n") f.write("#\n") f.write("set -e\n") f.write('SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"\n') f.write('cd "$SCRIPT_DIR"\n') f.write("# Use virtual environment if it exists, otherwise use system buildozer\n") f.write('if [ -d "$HOME/.buildozer_venv" ]; then\n') f.write(' source "$HOME/.buildozer_venv/bin/activate"\n') f.write('fi\n') f.write(f"buildozer android {build_action}\n") context.metadata["mobile_wsl_script"] = wsl_script_path context.metadata["mobile_build_action"] = build_action context.metadata["mobile_windows_only"] = True # Convert Windows path to WSL-style for instructions win_path = os.path.abspath(context.output_path) # e.g. D:\foo\bar -> /mnt/d/foo/bar drive = win_path[0].lower() wsl_path = f"/mnt/{drive}/{win_path[3:].replace(os.sep, '/')}" raise RuntimeError( "Buildozer cannot run on Windows natively.\n\n" "The buildozer.spec and project files have been exported successfully.\n" "To build the APK/AAB, open WSL (or a Linux machine) and run:\n\n" f" cd \"{wsl_path}\"\n" f" bash build_with_wsl.sh\n\n" "Buildozer will automatically download the Android SDK and NDK for you.\n\n" "Prerequisites (run once in WSL):\n" " sudo apt update && sudo apt install -y build-essential git zip unzip \\\n" " openjdk-17-jdk autoconf libtool pkg-config zlib1g-dev \\\n" " libncurses5-dev libncursesw5-dev cmake libffi-dev libssl-dev \\\n" " python3-pip\n" " pip3 install --user buildozer cython" ) # ── Linux / macOS: run buildozer directly ── self._check_linux_build_deps() buildozer_cmd = self._resolve_module_command("buildozer") if buildozer_cmd is None: if getattr(sys, "frozen", False): raise RuntimeError( "Buildozer export from the packaged editor requires an external Python with buildozer.\n" "Install Python + buildozer and ensure 'python -m buildozer --version' works,\n" "or set AXISPY_HOST_PYTHON to that interpreter path." ) raise RuntimeError( "Buildozer is not installed or not found.\n" "Install it with: pip install buildozer cython\n" "You also need: sudo apt install build-essential git zip unzip " "openjdk-17-jdk autoconf libtool pkg-config zlib1g-dev libncurses5-dev " "libncursesw5-dev cmake libffi-dev libssl-dev" ) # Ensure cython is installed in the same environment as buildozer self._ensure_buildozer_companions(buildozer_cmd) log_path = os.path.join(context.output_path, f"buildozer_{build_action}.log") self.logger.info( "Starting Buildozer", action=build_action, format=self.output_format, cwd=context.output_path ) build_env = self._tool_env() # Ensure the Python prefix's bin dir and user scripts dir are on # PATH so buildozer can find pip-installed CLI tools like cython. extra_dirs = [] prefix = self._python_prefix_from_module_command(buildozer_cmd) if prefix: extra_dirs.append(os.path.dirname(os.path.abspath(prefix[0]))) user_bin = os.path.join(os.path.expanduser("~"), ".local", "bin") if os.path.isdir(user_bin): extra_dirs.append(user_bin) current_path = build_env.get("PATH", "") path_parts = current_path.split(os.pathsep) for d in extra_dirs: if d not in path_parts: current_path = d + os.pathsep + current_path build_env["PATH"] = current_path _ansi_re = re.compile(r'\x1b\[[0-9;]*m') proc = subprocess.Popen( [*buildozer_cmd, "android", build_action], cwd=context.output_path, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=build_env, ) # Feed automatic "yes" responses to accept Android SDK licenses, # then close stdin so the process never blocks waiting for input. try: assert proc.stdin is not None proc.stdin.write(("y\n") * 50) proc.stdin.flush() proc.stdin.close() except (BrokenPipeError, OSError): pass stdout_lines: list[str] = [] stderr_lines: list[str] = [] def _drain_stderr(): assert proc.stderr is not None for line in proc.stderr: stderr_lines.append(line) stripped = _ansi_re.sub('', line).strip() if stripped: self.logger.info(stripped) stderr_thread = threading.Thread(target=_drain_stderr, daemon=True) stderr_thread.start() assert proc.stdout is not None for line in proc.stdout: stdout_lines.append(line) stripped = _ansi_re.sub('', line).strip() if stripped: self.logger.info(stripped) proc.wait() stderr_thread.join(timeout=5) combined = "".join(stdout_lines) if stderr_lines: if combined: combined += "\n" combined += "".join(stderr_lines) with open(log_path, "w", encoding="utf-8") as f: f.write(combined) result = proc if result.returncode != 0: tail_lines = [l for l in combined.splitlines() if l.strip()][-20:] tail_text = "\n".join(tail_lines) raise RuntimeError( f"Buildozer {build_action} build failed.\n" f"Log: {log_path}\n" f"Last output:\n{tail_text}" ) # Locate output artifact bin_dir = os.path.join(context.output_path, "bin") artifact_path = "" if os.path.isdir(bin_dir): ext = ".aab" if self.output_format == "aab" else ".apk" for fname in sorted(os.listdir(bin_dir), reverse=True): if fname.endswith(ext): artifact_path = os.path.join(bin_dir, fname) break if not artifact_path: for fname in sorted(os.listdir(bin_dir), reverse=True): if fname.endswith(".apk") or fname.endswith(".aab"): artifact_path = os.path.join(bin_dir, fname) break context.metadata["buildozer_log_path"] = log_path context.metadata["mobile_artifact_path"] = artifact_path context.metadata["mobile_build_action"] = build_action self.logger.info( "Buildozer build complete", artifact=artifact_path, log=log_path ) return context
def _ensure_buildozer_companions(self, buildozer_cmd: list[str]): prefix = self._python_prefix_from_module_command(buildozer_cmd) if not prefix: return # Check if cython CLI is already reachable env = self._tool_env() bin_dir = os.path.dirname(os.path.abspath(prefix[0])) user_bin = os.path.join(os.path.expanduser("~"), ".local", "bin") check_path = env.get("PATH", "") for d in (bin_dir, user_bin): if d not in check_path.split(os.pathsep): check_path = d + os.pathsep + check_path env["PATH"] = check_path try: probe = subprocess.run( ["cython", "--version"], check=False, capture_output=True, text=True, timeout=10, env=env, ) if probe.returncode == 0: return except Exception: pass # Install cython into the same prefix self._install_host_packages(prefix, ["cython"]) def _check_linux_build_deps(self): if platform.system() != "Linux": return required_pkgs = [ "zlib1g-dev", "libffi-dev", "libssl-dev", "autoconf", "libtool", "pkg-config", "libncurses5-dev", "libncursesw5-dev", "cmake", "build-essential", "zip", "unzip", "git", ] missing = [] for pkg in required_pkgs: try: result = subprocess.run( ["dpkg", "-s", pkg], check=False, capture_output=True, text=True, timeout=5, ) if result.returncode != 0: missing.append(pkg) except Exception: missing.append(pkg) if missing: raise RuntimeError( "Missing system packages required by Buildozer:\n" f" {' '.join(missing)}\n\n" "Install them with:\n" f" sudo apt-get install -y {' '.join(missing)}" )
[docs] class ServerExporter(Exporter): platform = "server" def __init__(self, build_mode: str = "release", target_os: str = ""): self.target_os = target_os.lower() if target_os else platform.system().lower() super().__init__(build_mode=build_mode) def _build_context(self, project_path: str, output_path: str): context = super()._build_context(project_path, output_path) # Append target OS subfolder: build/server/windows, build/server/linux context.output_path = os.path.join(context.output_path, self.target_os) return context def _copy_runtime(self, context: BuildContext): super()._copy_runtime(context) self._copy_project_payload(context) def _write_platform_template(self, context: BuildContext): super()._write_platform_template(context) entry_scene = self._resolve_entry_scene(context) tick_rate = self._read_server_tick_rate(context.project_path) # Write headless launcher launcher_path = os.path.join(context.output_path, "server_launcher.py") with open(launcher_path, "w", encoding="utf-8") as file: file.write("#!/usr/bin/env python3\n") file.write("import os\n") file.write("import sys\n") file.write("base_dir = os.path.dirname(sys.executable) if getattr(sys, 'frozen', False) else os.path.dirname(os.path.abspath(__file__))\n") file.write("search_paths = [base_dir, os.path.join(base_dir, '_internal')]\n") file.write("meipass = getattr(sys, '_MEIPASS', '')\n") file.write("if meipass:\n") file.write(" search_paths.append(meipass)\n") file.write("for candidate in search_paths:\n") file.write(" if candidate and os.path.exists(candidate) and candidate not in sys.path:\n") file.write(" sys.path.insert(0, candidate)\n") file.write("project_root = os.path.join(base_dir, 'project')\n") file.write("if not os.path.exists(project_root) and meipass:\n") file.write(" project_root = os.path.join(meipass, 'project')\n") file.write("os.environ['AXISPY_PROJECT_PATH'] = project_root\n") file.write("from core.headless_server import run_headless\n") if entry_scene: file.write(f"scene_path = os.path.join(project_root, {repr(entry_scene)})\n") else: file.write("scene_path = None\n") file.write(f"tick_rate = {tick_rate}\n") file.write("run_headless(scene_path, tick_rate=tick_rate, verbose=True)\n") context.metadata["server_launcher_path"] = launcher_path context.metadata["server_entry_scene"] = entry_scene context.metadata["server_tick_rate"] = tick_rate # Write server manifest server_manifest_path = os.path.join(context.output_path, "server_manifest.json") server_manifest = { "platform": context.platform, "target_os": self.target_os, "mode": context.build_mode, "entrypoint": "core.headless_server", "launcher": "server_launcher.py", "tick_rate": tick_rate, "entry_scene": entry_scene, "network_profile": "headless" } with open(server_manifest_path, "w", encoding="utf-8") as file: json.dump(server_manifest, file, indent=2) context.metadata["server_manifest_path"] = server_manifest_path # Write convenience run scripts self._write_run_scripts(context) def _write_run_scripts(self, context: BuildContext): """Generate platform-specific run scripts.""" # Bash script (Linux/macOS) bash_path = os.path.join(context.output_path, "start_server.sh") with open(bash_path, "w", encoding="utf-8", newline="\n") as file: file.write("#!/bin/bash\n") file.write("SCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\n") file.write("cd \"$SCRIPT_DIR\"\n") file.write("echo \"Starting AxisPy Headless Server...\"\n") file.write("python3 server_launcher.py \"$@\"\n") context.metadata["server_bash_script"] = bash_path # Batch script (Windows) bat_path = os.path.join(context.output_path, "start_server.bat") with open(bat_path, "w", encoding="utf-8") as file: file.write("@echo off\n") file.write("cd /d \"%~dp0\"\n") file.write("echo Starting AxisPy Headless Server...\n") file.write("python server_launcher.py %*\n") file.write("pause\n") context.metadata["server_bat_script"] = bat_path
[docs] def export_with_pyinstaller(self, project_path: str, output_path: str): """Build a standalone headless server executable using PyInstaller.""" context = self.export(project_path, output_path) pyinstaller_cmd = self._resolve_module_command("PyInstaller") if pyinstaller_cmd is None: if getattr(sys, "frozen", False): raise RuntimeError( "PyInstaller export from the packaged editor requires an external Python with PyInstaller.\n" "Install Python + PyInstaller and ensure 'python -m PyInstaller --version' works,\n" "or set AXISPY_HOST_PYTHON to that interpreter path." ) raise RuntimeError( f"PyInstaller is not installed for interpreter '{sys.executable}'. " "Install it with: pip install pyinstaller" ) # Ensure pygame is available in the host Python so --collect-all can find it prefix = self._python_prefix_from_module_command(pyinstaller_cmd) probe_env = self._tool_env() if not self._probe_module_on_prefix(prefix, "pygame", probe_env): self.logger.info("pygame not found in host Python, attempting install", prefix=prefix) self._install_host_packages(prefix, ["pygame"]) launcher_path = context.metadata.get("server_launcher_path") if not launcher_path or not os.path.exists(launcher_path): raise RuntimeError("Server launcher was not generated.") build_stamp = datetime.utcnow().strftime("%Y%m%d-%H%M%S") dist_path = os.path.join(context.output_path, "dist", build_stamp) work_path = os.path.join(context.output_path, "build_pyinstaller", build_stamp) spec_path = os.path.join(context.output_path, "spec", build_stamp) os.makedirs(dist_path, exist_ok=True) os.makedirs(work_path, exist_ok=True) os.makedirs(spec_path, exist_ok=True) game_name = self._read_game_name(context.project_path) or "AxisPyServer" server_name = game_name + "Server" pyinstaller_args = [ *pyinstaller_cmd, "--noconfirm", "--clean", "--name", server_name, "--distpath", dist_path, "--workpath", work_path, "--specpath", spec_path, "--console", "--paths", context.output_path, "--paths", self._engine_root(), "--hidden-import", "pygame", "--hidden-import", "pygame.base", "--hidden-import", "pygame.constants", "--hidden-import", "pygame.rect", "--hidden-import", "pygame.rwobject", "--hidden-import", "pygame.surflock", "--hidden-import", "pygame.color", "--hidden-import", "pygame.bufferproxy", "--hidden-import", "pygame.math", "--hidden-import", "pygame.pixelcopy", "--collect-all", "pygame", "--hidden-import", "websockets", ] # Add data paths for data_dir in ("project", "core", "plugins"): abs_data = os.path.join(context.output_path, data_dir) if os.path.exists(abs_data): pyinstaller_args.extend(["--add-data", f"{abs_data}{os.pathsep}{data_dir}"]) pyinstaller_args.append(launcher_path) log_path = os.path.join(context.output_path, f"pyinstaller_server_{build_stamp}.log") result = subprocess.run( pyinstaller_args, cwd=context.output_path, check=False, capture_output=True, text=True, env=self._tool_env() ) if result.returncode != 0: combined_probe = (result.stdout or "") + "\n" + (result.stderr or "") if self._has_cryptography_hook_failure(combined_probe): if self._repair_pyinstaller_cryptography_host(pyinstaller_cmd): result = subprocess.run( pyinstaller_args, cwd=context.output_path, check=False, capture_output=True, text=True, env=self._tool_env() ) combined_output = "" if result.stdout: combined_output += result.stdout if result.stderr: if combined_output: combined_output += "\n" combined_output += result.stderr if self.logger: self.logger.info("PyInstaller build completed", returncode=result.returncode, output_lines=len(combined_output.splitlines()) if combined_output else 0) # Write pygbag output to a log file for debugging if combined_output: log_name = f"pygbag_build_{build_stamp}.log" log_path = os.path.join(context.output_path, log_name) with open(log_path, "w", encoding="utf-8") as log_f: log_f.write(combined_output) context.metadata["pygbag_log_path"] = log_path # After pygbag completes, copy assets to the final web directory # Pygbag creates a nested structure: build/web/build/web/ final_web_dir = os.path.join(context.output_path, "build", "web") if os.path.exists(final_web_dir): for key in ("logo_url", "background_image_url"): filename = designer.get(key, "").strip() if filename and not filename.startswith(("http://", "https://", "//")): src_path = os.path.join(context.output_path, filename) if os.path.isfile(src_path): dst_path = os.path.join(final_web_dir, filename) try: shutil.copy2(src_path, dst_path) if self.logger: self.logger.info(f"Copied asset to final web dir: {filename}", dst=dst_path) except Exception as e: if self.logger: self.logger.error(f"Failed to copy asset to final web dir: {filename}", error=str(e)) context.metadata["server_executable_name"] = server_name context.metadata["server_pyinstaller_log"] = log_path context.metadata["server_target_os"] = self.target_os return context
def _copy_project_payload(self, context: BuildContext): project_payload = os.path.join(context.output_path, "project") if os.path.exists(project_payload): shutil.rmtree(project_payload) project_root = os.path.abspath(context.project_path) output_root = os.path.abspath(context.output_path) def ignore_names(current_dir: str, names: list[str]): ignored = [] for name in names: child_path = os.path.abspath(os.path.join(current_dir, name)) try: if os.path.commonpath([output_root, child_path]) == child_path: ignored.append(name) continue except ValueError: pass if name == "__pycache__": ignored.append(name) if name in {"temp_scene", "temp_scene.scn", "temp_scene_runcheck", "temp_scene_runcheck.scn", "build"}: ignored.append(name) # Strip assets not needed on server (images, sounds) lower = name.lower() if lower.endswith((".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".wav", ".mp3", ".ogg", ".flac")): ignored.append(name) return ignored shutil.copytree(project_root, project_payload, ignore=ignore_names) context.metadata["server_project_payload"] = project_payload def _resolve_entry_scene(self, context: BuildContext): configured = self._read_entry_scene(context.project_path) if configured: configured_path = os.path.join(context.project_path, configured) if os.path.exists(configured_path): return configured for root, _, files in os.walk(context.project_path): for filename in sorted(files): if filename.lower().endswith(".scn"): scene_full = os.path.join(root, filename) return os.path.relpath(scene_full, context.project_path).replace("\\", "/") return "" def _read_entry_scene(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) entry_scene = str(data.get("entry_scene", "")).strip() if not entry_scene: return "" return _to_native_path(entry_scene) except Exception: return "" def _read_game_name(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return "" try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) name = str(data.get("game_name", "")).strip() if not name: return "" sanitized = "".join(ch for ch in name if ch.isalnum() or ch in ("_", "-")) return sanitized or "" except Exception: return "" def _read_server_tick_rate(self, project_path: str): config_path = os.path.join(project_path, "project.config") if not os.path.exists(config_path): return 60.0 try: with open(config_path, "r", encoding="utf-8") as file: data = json.load(file) server_cfg = data.get("server", {}) if isinstance(server_cfg, dict): return max(1.0, float(server_cfg.get("tick_rate", 60.0))) return 60.0 except Exception: return 60.0