Source code for mini_arcade.modules.game_scaffold.processors

"""
Processor for creating starter Mini Arcade game projects.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

from mini_arcade.cli.base_command_processor import BaseCommandProcessor
from mini_arcade.cli.exceptions import CommandException
from mini_arcade.constants import APP


def _dependency_series(version: str) -> str:
    parts = version.split(".")
    if len(parts) < 2:
        return version
    return f"{parts[0]}.{parts[1]}"


def _default_package_name(game_id: str) -> str:
    return game_id.strip().lower().replace("-", "_")


def _default_title(game_id: str) -> str:
    return game_id.replace("-", " ").replace("_", " ").title()


def _validate_game_id(game_id: str) -> str:
    normalized = game_id.strip().lower()
    if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", normalized):
        raise CommandException(
            "game-id must be kebab-case using letters, numbers, and hyphens"
        )
    return normalized


def _validate_package_name(package: str) -> str:
    normalized = package.strip().lower()
    if not re.fullmatch(r"[a-z_][a-z0-9_]*", normalized):
        raise CommandException(
            "package must be snake_case and start with a letter or underscore"
        )
    return normalized


[docs] @dataclass(frozen=True) class ScaffoldSpec: """ Specification for generating a game scaffold, containing all necessary information about the game and project structure. :ivar game_id: The unique identifier for the game, used for package naming and project structure. :ivar package: The Python package name for the game's source code. :ivar title: The human-readable title for the game. :ivar target_dir: The target directory where the game scaffold will be created. :ivar dependency_series: The version series (e.g. "0.1") of mini-arcade dependencies to use in the generated pyproject.toml. """ game_id: str package: str title: str target_dir: Path dependency_series: str
def _template_files(spec: ScaffoldSpec) -> dict[Path, str]: game_id = spec.game_id package = spec.package title = spec.title dep = spec.dependency_series project_dir = spec.target_dir src_dir = project_dir / "src" / package play_dir = src_dir / "scenes" / "play" systems_dir = play_dir / "systems" return { project_dir / "pyproject.toml": f"""[build-system] requires = ["poetry-core>=2.0.0,<3.0.0"] build-backend = "poetry.core.masonry.api" [project] name = "{game_id}" version = "0.1.0" description = "{title} built with Mini Arcade." requires-python = ">=3.9,<3.12" dependencies = [ "mini-arcade-core~={dep}", "mini-arcade~={dep}", "mini-arcade-pygame-backend~={dep}", "mini-arcade-native-backend~={dep}", ] [tool.poetry] packages = [{{ include = "{package}", from = "src" }}] [project.scripts] {game_id} = "{package}.app:run" [tool.mini-arcade.game] id = "{game_id}" entrypoint = "manage.py" source_roots = ["src"] """, project_dir / "manage.py": f"""from {package}.app import run if __name__ == "__main__": run() """, project_dir / "settings" / "settings.yml": f"""game: id: {game_id} project: root: ${{settings_dir}}/.. assets_root: ${{project_root}}/assets scene: initial_scene: menu scene_registry: discover_packages: - {package}.scenes - mini_arcade_core.scenes engine_config: fps: 60 virtual_resolution: [960, 540] enable_profiler: false backend: provider: pygame window: width: 960 height: 540 title: {title} resizable: true renderer: background_color: [18, 18, 24] audio: enable: false gameplay: difficulty: default: normal """, src_dir / "__init__.py": "", src_dir / "__main__.py": f"""from {package}.app import run if __name__ == "__main__": run() """, src_dir / "app.py": f"""from __future__ import annotations from mini_arcade.modules.backend_loader import BackendLoader from mini_arcade.modules.settings import Settings from mini_arcade_core import run_game def run() -> None: settings = Settings.for_game("{game_id}", required=True) backend_cfg = settings.backend_defaults(resolve_paths=True) backend = BackendLoader.load_backend(backend_cfg) engine_cfg = settings.engine_config_defaults() scene_cfg = settings.scene_defaults() gameplay_cfg = settings.gameplay_defaults() run_game( engine_config=engine_cfg, scene_config=scene_cfg, backend=backend, gameplay_config=gameplay_cfg, ) if __name__ == "__main__": run() """, src_dir / "scenes" / "__init__.py": """from . import menu, pause from .play import scene """, src_dir / "scenes" / "commands.py": """from mini_arcade_core.engine.commands import ( Command, CommandContext, PushSceneIfMissingCommand, RemoveSceneCommand, ) from mini_arcade_core.engine.scenes.models import ScenePolicy class StartGameCommand(Command): def execute(self, context: CommandContext): context.managers.scenes.change("play") class PauseGameCommand(Command): def execute(self, context: CommandContext): PushSceneIfMissingCommand( "pause", as_overlay=True, policy=ScenePolicy( blocks_update=True, blocks_input=True, is_opaque=False, receives_input=True, ), ).execute(context) class ContinueCommand(Command): def execute(self, context: CommandContext): RemoveSceneCommand("pause").execute(context) class BackToMenuCommand(Command): def execute(self, context: CommandContext): context.managers.scenes.change("menu") """, src_dir / "scenes" / "menu.py": f"""from __future__ import annotations from mini_arcade_core.engine.commands import QuitCommand from mini_arcade_core.scenes.autoreg import register_scene from mini_arcade_core.ui.menu import BaseMenuScene, MenuItem from {package}.scenes.commands import StartGameCommand @register_scene("menu") class MenuScene(BaseMenuScene): @property def menu_title(self) -> str | None: return "{title.upper()}" def menu_items(self): return [ MenuItem("start", "START", StartGameCommand), MenuItem("quit", "QUIT", QuitCommand), ] """, src_dir / "scenes" / "pause.py": f"""from __future__ import annotations from mini_arcade_core.scenes.autoreg import register_scene from mini_arcade_core.ui.menu import BaseMenuScene, MenuItem from {package}.scenes.commands import BackToMenuCommand, ContinueCommand @register_scene("pause") class PauseScene(BaseMenuScene): @property def menu_title(self) -> str | None: return "PAUSED" def menu_items(self): return [ MenuItem("continue", "CONTINUE", ContinueCommand), MenuItem("menu", "MAIN MENU", BackToMenuCommand), ] def quit_command(self): return ContinueCommand() """, play_dir / "__init__.py": "", play_dir / "models.py": """from dataclasses import dataclass from mini_arcade_core.scenes.sim_scene import BaseIntent, BaseTickContext, BaseWorld @dataclass class PlayWorld(BaseWorld): viewport: tuple[float, float] player_x: float = 100.0 player_speed: float = 260.0 @dataclass(frozen=True) class PlayIntent(BaseIntent): move_x: float = 0.0 pause: bool = False @dataclass class PlayTickContext(BaseTickContext[PlayWorld, PlayIntent]): pass """, play_dir / "bootstrap.py": """from __future__ import annotations from .models import PlayWorld def build_play_world(*, viewport: tuple[float, float]) -> PlayWorld: vw, _ = viewport return PlayWorld( entities=[], viewport=viewport, player_x=vw * 0.5, ) """, play_dir / "pipeline.py": """from __future__ import annotations from .systems import PlayRenderSystem, PlayRulesSystem def build_play_systems() -> tuple[object, ...]: return ( PlayRulesSystem(), PlayRenderSystem(), ) """, play_dir / "scene.py": f"""from __future__ import annotations from mini_arcade_core.scenes.autoreg import register_scene from mini_arcade_core.scenes.bootstrap import scene_viewport from mini_arcade_core.scenes.game_scene import GameScene, GameSceneSystemsConfig from {package}.scenes.commands import PauseGameCommand from {package}.scenes.play.bootstrap import build_play_world from {package}.scenes.play.models import PlayIntent, PlayTickContext, PlayWorld from {package}.scenes.play.pipeline import build_play_systems def _build_play_intent(actions, _ctx: PlayTickContext) -> PlayIntent: return PlayIntent( move_x=actions.value("move_x"), pause=actions.pressed("pause"), ) @register_scene("play") class PlayScene(GameScene[PlayTickContext, PlayWorld]): tick_context_type = PlayTickContext systems_config = GameSceneSystemsConfig( controls_scene_key="play", intent_factory=_build_play_intent, input_fallback_bindings={{ "move_x": {{ "type": "axis", "negative_keys": ["LEFT", "A"], "positive_keys": ["RIGHT", "D"], }}, "pause": {{ "type": "digital", "keys": ["ESCAPE"], }}, }}, pause_command_factory=lambda _ctx: PauseGameCommand(), ) def on_enter(self): self.world = build_play_world(viewport=scene_viewport(self)) self.systems.extend(build_play_systems()) """, systems_dir / "__init__.py": """from .render import PlayRenderSystem from .rules import PlayRulesSystem __all__ = [ "PlayRenderSystem", "PlayRulesSystem", ] """, systems_dir / "rules.py": """from dataclasses import dataclass from mini_arcade_core.scenes.systems.base_system import BaseSystem from mini_arcade_core.scenes.systems.phases import SystemPhase from ..models import PlayTickContext @dataclass class PlayRulesSystem(BaseSystem[PlayTickContext]): name: str = "play_rules" phase: int = SystemPhase.SIMULATION order: int = 20 def step(self, ctx: PlayTickContext): if ctx.intent is None: return world = ctx.world world.player_x += ctx.intent.move_x * world.player_speed * ctx.dt world.player_x = max(20.0, min(world.viewport[0] - 20.0, world.player_x)) """, systems_dir / "render.py": """from dataclasses import dataclass from mini_arcade_core.engine.render.packet import RenderPacket from mini_arcade_core.scenes.systems.base_system import BaseSystem from mini_arcade_core.scenes.systems.phases import SystemPhase from ..models import PlayTickContext @dataclass class PlayRenderSystem(BaseSystem[PlayTickContext]): name: str = "play_render" phase: int = SystemPhase.RENDERING order: int = 100 def step(self, ctx: PlayTickContext): world = ctx.world vw, vh = world.viewport def draw(backend): backend.render.draw_rect(0, 0, int(vw), int(vh), color=(12, 14, 20)) backend.render.draw_rect( int(world.player_x) - 20, int(vh) - 60, 40, 20, color=(240, 240, 240), ) backend.text.draw( 16, 16, "Arrows or A/D to move | Esc pause", color=(220, 220, 220), font_size=18, ) ctx.packet = RenderPacket.from_ops([draw]) """, src_dir / "entities" / "__init__.py": "", src_dir / "controllers" / "__init__.py": "", project_dir / "assets" / "sprites" / ".gitkeep": "", project_dir / "assets" / "fonts" / ".gitkeep": "", project_dir / "assets" / "sfx" / ".gitkeep": "", }
[docs] @dataclass(init=False) class GameScaffoldProcessor(BaseCommandProcessor): """ Processor for creating starter Mini Arcade game projects. :ivar game_id (str): The unique identifier for the game, used for package naming and project structure. :ivar package (str | None): Optional custom package name for the game's source code. :ivar title (str | None): Optional human-readable title for the game. :ivar destination (str): The parent directory where the game scaffold will be created (default is "games"). :ivar force (bool): Whether to overwrite existing files if the target directory already exists (default is False). :ivar dry_run (bool): If True, do not create any files but print the intended actions (default is False). """ game_id: str package: str | None = None title: str | None = None destination: str = "games" force: bool = False dry_run: bool = False def __init__(self, **kwargs): self.game_id = kwargs.get("game_id") self.package = kwargs.get("package") self.title = kwargs.get("title") self.destination = kwargs.get("destination", "games") self.force = bool(kwargs.get("force", False)) self.dry_run = bool(kwargs.get("dry_run", False)) def _build_spec(self) -> ScaffoldSpec: game_id = _validate_game_id(self.game_id) package = _validate_package_name( self.package or _default_package_name(game_id) ) title = (self.title or _default_title(game_id)).strip() target_dir = Path(self.destination).expanduser().resolve() / game_id dep = _dependency_series(APP.version) return ScaffoldSpec( game_id=game_id, package=package, title=title, target_dir=target_dir, dependency_series=dep, ) def _write_files(self, files: dict[Path, str], *, force: bool) -> None: for path, content in files.items(): path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and not force: raise CommandException( f"Refusing to overwrite existing file: {path}" ) path.write_text(content, encoding="utf-8")
[docs] def run(self) -> int: spec = self._build_spec() files = _template_files(spec) if spec.target_dir.exists() and not self.force and not self.dry_run: raise CommandException( f"Target directory already exists: {spec.target_dir}" ) if self.dry_run: print(f"Scaffold target: {spec.target_dir}") for path in sorted(files): print(path.relative_to(spec.target_dir.parent)) return 0 spec.target_dir.mkdir(parents=True, exist_ok=True) self._write_files(files, force=self.force) print(f"Created game scaffold at {spec.target_dir}") return 0