"""
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 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