Source code for simvx.core.project

"""
Project configuration -- load/save project.simvx (JSON) files.

A project file defines the game's display settings, input actions,
autoloaded scripts, physics framerate, and main scene path.

Public API:
    from simvx.core.project import ProjectSettings, load_project, save_project

    settings = load_project("project.simvx")
    settings.name = "My Game"
    save_project(settings, "project.simvx")
"""


from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any

log = logging.getLogger(__name__)

__all__ = ["ProjectSettings", "load_project", "save_project"]

_DEFAULTS: dict[str, Any] = {
    "name": "Untitled",
    "main_scene": "",
    "display": {
        "width": 1280,
        "height": 720,
        "fullscreen": False,
        "vsync": True,
        "stretch_mode": "viewport",  # "viewport", "canvas_items", "disabled"
        "stretch_aspect": "keep",  # "keep", "expand", "ignore"
    },
    "input_actions": {},
    "autoloads": {},
    "physics_fps": 60,
    "target_fps": 0,  # 0 = unlimited
    "audio": {
        "default_bus_layout": "default",
    },
    "rendering": {
        "backend": "vulkan",  # "vulkan", "sdl3"
        "msaa": 0,
    },
}


class _DisplaySettings:
    """Display sub-settings with attribute access."""

    __slots__ = ("width", "height", "fullscreen", "vsync", "stretch_mode", "stretch_aspect")

    def __init__(self, d: dict[str, Any] | None = None):
        d = d or _DEFAULTS["display"]
        self.width: int = d.get("width", 1280)
        self.height: int = d.get("height", 720)
        self.fullscreen: bool = d.get("fullscreen", False)
        self.vsync: bool = d.get("vsync", True)
        self.stretch_mode: str = d.get("stretch_mode", "viewport")
        self.stretch_aspect: str = d.get("stretch_aspect", "keep")

    def to_dict(self) -> dict[str, Any]:
        return {
            "width": self.width,
            "height": self.height,
            "fullscreen": self.fullscreen,
            "vsync": self.vsync,
            "stretch_mode": self.stretch_mode,
            "stretch_aspect": self.stretch_aspect,
        }


class _AudioSettings:
    """Audio sub-settings."""

    __slots__ = ("default_bus_layout",)

    def __init__(self, d: dict[str, Any] | None = None):
        d = d or _DEFAULTS["audio"]
        self.default_bus_layout: str = d.get("default_bus_layout", "default")

    def to_dict(self) -> dict[str, Any]:
        return {"default_bus_layout": self.default_bus_layout}


class _RenderingSettings:
    """Rendering sub-settings."""

    __slots__ = ("backend", "msaa")

    def __init__(self, d: dict[str, Any] | None = None):
        d = d or _DEFAULTS["rendering"]
        self.backend: str = d.get("backend", "vulkan")
        self.msaa: int = d.get("msaa", 0)

    def to_dict(self) -> dict[str, Any]:
        return {"backend": self.backend, "msaa": self.msaa}


[docs] class ProjectSettings: """Project configuration loaded from project.simvx. Attributes: name: Project display name. main_scene: Path to the main scene file (relative to project root). display: Display settings (width, height, fullscreen, vsync, stretch). input_actions: Mapping of action name -> list of key names. autoloads: Mapping of name -> script path (loaded before main scene). physics_fps: Physics update rate. target_fps: Target frame rate (0 = unlimited). audio: Audio configuration. rendering: Rendering backend configuration. project_path: Path to the project.simvx file (set on load). """ def __init__(self, data: dict[str, Any] | None = None): data = data or {} self.name: str = data.get("name", _DEFAULTS["name"]) self.main_scene: str = data.get("main_scene", _DEFAULTS["main_scene"]) self.display = _DisplaySettings(data.get("display")) self.input_actions: dict[str, list[str]] = data.get("input_actions", {}) self.autoloads: dict[str, str] = data.get("autoloads", {}) self.physics_fps: int = data.get("physics_fps", _DEFAULTS["physics_fps"]) self.target_fps: int = data.get("target_fps", _DEFAULTS["target_fps"]) self.audio = _AudioSettings(data.get("audio")) self.rendering = _RenderingSettings(data.get("rendering")) self.project_path: str = ""
[docs] def to_dict(self) -> dict[str, Any]: """Serialize to a JSON-compatible dict.""" return { "name": self.name, "main_scene": self.main_scene, "display": self.display.to_dict(), "input_actions": self.input_actions, "autoloads": self.autoloads, "physics_fps": self.physics_fps, "target_fps": self.target_fps, "audio": self.audio.to_dict(), "rendering": self.rendering.to_dict(), }
[docs] def get_project_dir(self) -> str: """Return the directory containing the project file.""" if self.project_path: return str(Path(self.project_path).parent) return "."
[docs] def resolve_path(self, relative: str) -> str: """Resolve a project-relative path to an absolute path.""" base = Path(self.get_project_dir()) return str((base / relative).resolve())
[docs] def apply_input_actions(self): """Register all input_actions with the Input singleton.""" from .input.state import Input for action_name, keys in self.input_actions.items(): Input.add_action(action_name, keys)
[docs] def load_project(path: str | Path) -> ProjectSettings: """Load project settings from a project.simvx file. Args: path: Path to the project.simvx JSON file. Returns: ProjectSettings with all fields populated. Raises: FileNotFoundError: If the file does not exist. json.JSONDecodeError: If the file is not valid JSON. """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"Project file not found: {path}") data = json.loads(path.read_text(encoding="utf-8")) settings = ProjectSettings(data) settings.project_path = str(path.resolve()) log.debug("project: loaded %s (%s)", settings.name, path) return settings
[docs] def save_project(settings: ProjectSettings, path: str | Path | None = None) -> None: """Save project settings to a project.simvx file. Args: settings: ProjectSettings to save. path: Output file path. If None, uses settings.project_path. """ if path is None: path = settings.project_path if not path: raise ValueError("No path specified and settings.project_path is empty") path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(settings.to_dict(), indent=2) + "\n", encoding="utf-8") settings.project_path = str(path.resolve()) log.debug("project: saved %s to %s", settings.name, path)
[docs] def boot_project(path: str | Path) -> ProjectSettings: """Load a project and execute the boot sequence. Boot sequence: 1. Read project.simvx 2. Register input actions 3. Return settings (caller creates App with display settings, loads autoloads/main_scene) Args: path: Path to project.simvx. Returns: Fully initialized ProjectSettings. """ settings = load_project(path) settings.apply_input_actions() return settings