Source code for simvx.editor.templates

"""Script Templates -- Boilerplate generators for new nodes and projects.

Provides templates for each major node type so that users can quickly scaffold
new scripts via ``File > New Script``. Also includes project wizard templates
for creating new projects (Empty, 2D Game, 3D Game).

Template variables:
    ${class_name}   - PascalCase class name
    ${base_class}   - Base class to inherit from
    ${file_name}    - Snake_case file name (without .py)
"""


from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path

log = logging.getLogger(__name__)

__all__ = [
    "ScriptTemplate",
    "TEMPLATES",
    "generate_script",
    "ProjectTemplate",
    "PROJECT_TEMPLATES",
    "generate_project",
]


# ============================================================================
# Script Templates
# ============================================================================


[docs] @dataclass(frozen=True) class ScriptTemplate: """A script template with a name, base class, and code body.""" name: str base_class: str body: str description: str = ""
# -- Template bodies -------------------------------------------------------- _NODE_BODY = '''\ """${class_name} -- ${base_class} script.""" from simvx.core import ${base_class}, Signal, Property class ${class_name}(${base_class}): def ready(self): pass def process(self, dt: float): pass ''' _NODE2D_BODY = '''\ """${class_name} -- 2D node script.""" from simvx.core import ${base_class}, Signal, Property, Vec2 class ${class_name}(${base_class}): speed = Property(100.0, range=(0, 500), hint="Movement speed") def ready(self): pass def process(self, dt: float): pass def draw(self, renderer): pass ''' _NODE3D_BODY = '''\ """${class_name} -- 3D node script.""" from simvx.core import ${base_class}, Signal, Property, Vec3, Quat class ${class_name}(${base_class}): speed = Property(5.0, range=(0, 50), hint="Movement speed") def ready(self): pass def process(self, dt: float): pass ''' _CHARACTER2D_BODY = '''\ """${class_name} -- 2D character with physics.""" from simvx.core import ${base_class}, Signal, Property, Vec2, Input class ${class_name}(${base_class}): speed = Property(200.0, range=(0, 1000), hint="Movement speed") jump_force = Property(400.0, range=(0, 1000), hint="Jump velocity") def ready(self): self.velocity = Vec2() def physics_process(self, dt: float): # Horizontal movement direction = Vec2() if Input.is_key_pressed("d"): direction.x += 1 if Input.is_key_pressed("a"): direction.x -= 1 self.velocity.x = direction.x * self.speed # Gravity and jump self.velocity.y += 980.0 * dt if self.is_on_floor() and Input.is_key_pressed("space"): self.velocity.y = -self.jump_force self.move_and_slide(dt) ''' _CHARACTER3D_BODY = '''\ """${class_name} -- 3D character with physics.""" from simvx.core import ${base_class}, Signal, Property, Vec3, Input class ${class_name}(${base_class}): speed = Property(5.0, range=(0, 50), hint="Movement speed") jump_force = Property(8.0, range=(0, 20), hint="Jump velocity") def ready(self): self.velocity = Vec3() def physics_process(self, dt: float): direction = Vec3() if Input.is_key_pressed("w"): direction.z -= 1 if Input.is_key_pressed("s"): direction.z += 1 if Input.is_key_pressed("a"): direction.x -= 1 if Input.is_key_pressed("d"): direction.x += 1 if direction.length() > 0: direction = direction.normalized() self.velocity.x = direction.x * self.speed self.velocity.z = direction.z * self.speed # Gravity and jump self.velocity.y -= 9.8 * dt if self.is_on_floor() and Input.is_key_pressed("space"): self.velocity.y = self.jump_force self.move_and_slide(dt) ''' _CAMERA3D_BODY = '''\ """${class_name} -- Camera3D script.""" from simvx.core import ${base_class}, Property class ${class_name}(${base_class}): fov = Property(70.0, range=(30, 120), hint="Field of view") def ready(self): pass def process(self, dt: float): pass ''' _MESH3D_BODY = '''\ """${class_name} -- Mesh instance script.""" from simvx.core import ${base_class}, Property, Vec3 class ${class_name}(${base_class}): spin_speed = Property(1.0, range=(0, 10), hint="Rotation speed") def ready(self): pass def process(self, dt: float): self.rotation.y += self.spin_speed * dt ''' _TIMER_BODY = '''\ """${class_name} -- Timer script.""" from simvx.core import ${base_class}, Signal, Property class ${class_name}(${base_class}): interval = Property(1.0, range=(0.01, 60), hint="Timer interval") def ready(self): self.timeout.connect(self._on_timeout) def _on_timeout(self): pass ''' _AREA2D_BODY = '''\ """${class_name} -- Area2D script for detecting overlaps.""" from simvx.core import ${base_class}, Signal, Property class ${class_name}(${base_class}): def ready(self): pass def _on_body_entered(self, body): pass def _on_body_exited(self, body): pass ''' _AREA3D_BODY = '''\ """${class_name} -- Area3D script for detecting overlaps.""" from simvx.core import ${base_class}, Signal, Property class ${class_name}(${base_class}): def ready(self): pass def _on_body_entered(self, body): pass def _on_body_exited(self, body): pass ''' _UI_CONTROL_BODY = '''\ """${class_name} -- Custom UI control.""" from simvx.core import ${base_class}, Signal, Property, Vec2 class ${class_name}(${base_class}): def ready(self): self.size = Vec2(200, 100) def draw(self, renderer): x, y, w, h = self.get_global_rect() renderer.draw_filled_rect(x, y, w, h, (0.2, 0.2, 0.2, 1.0)) def _on_gui_input(self, event): pass ''' # -- Template registry ------------------------------------------------------- TEMPLATES: dict[str, ScriptTemplate] = { "Node": ScriptTemplate("Node", "Node", _NODE_BODY, "Basic node"), "Node2D": ScriptTemplate("Node2D", "Node2D", _NODE2D_BODY, "2D node with transform"), "Node3D": ScriptTemplate("Node3D", "Node3D", _NODE3D_BODY, "3D node with transform"), "CharacterBody2D": ScriptTemplate("CharacterBody2D", "CharacterBody2D", _CHARACTER2D_BODY, "2D physics character"), "CharacterBody3D": ScriptTemplate("CharacterBody3D", "CharacterBody3D", _CHARACTER3D_BODY, "3D physics character"), "Camera3D": ScriptTemplate("Camera3D", "Camera3D", _CAMERA3D_BODY, "3D camera"), "MeshInstance3D": ScriptTemplate("MeshInstance3D", "MeshInstance3D", _MESH3D_BODY, "3D mesh"), "Timer": ScriptTemplate("Timer", "Timer", _TIMER_BODY, "Timer node"), "Area2D": ScriptTemplate("Area2D", "Area2D", _AREA2D_BODY, "2D overlap detector"), "Area3D": ScriptTemplate("Area3D", "Area3D", _AREA3D_BODY, "3D overlap detector"), "Control": ScriptTemplate("Control", "Control", _UI_CONTROL_BODY, "Custom UI control"), }
[docs] def generate_script( template_name: str, class_name: str, file_name: str | None = None, output_path: str | Path | None = None, ) -> str: """Generate a script from a template. Args: template_name: Key in TEMPLATES (e.g. "Node3D"). class_name: PascalCase class name. file_name: Snake_case file name (auto-derived if None). output_path: If given, write the script to this path. Returns: The generated script text. """ tmpl = TEMPLATES.get(template_name) if tmpl is None: raise ValueError(f"Unknown template: {template_name!r}. Available: {list(TEMPLATES)}") if file_name is None: file_name = _to_snake_case(class_name) code = tmpl.body code = code.replace("${class_name}", class_name) code = code.replace("${base_class}", tmpl.base_class) code = code.replace("${file_name}", file_name) if output_path: p = Path(output_path) p.parent.mkdir(parents=True, exist_ok=True) p.write_text(code) return code
[docs] def generate_script_text(template_name: str, class_name: str) -> str: """Generate script source text from a template (no file I/O).""" return generate_script(template_name, class_name)
def _to_snake_case(name: str) -> str: """Convert PascalCase to snake_case. Inserts underscores before uppercase letters that follow lowercase letters, producing names like ``node3d``, ``character_body2d``. Digit-to-uppercase transitions (e.g. ``3D``) do NOT get an underscore. """ result = [] for i, ch in enumerate(name): if ch.isupper() and i > 0: prev = name[i - 1] if prev.islower(): result.append("_") result.append(ch.lower()) return "".join(result) # ============================================================================ # Project Templates # ============================================================================
[docs] @dataclass(frozen=True) class ProjectTemplate: """Template for creating a new project.""" name: str description: str files: dict[str, str] # relative_path -> content
_EMPTY_PROJECT_JSON = """\ { "project_name": "${project_name}", "default_scene": "", "window_width": 1280, "window_height": 720, "fullscreen": false, "vsync": true, "msaa": 4, "gravity": 9.8, "physics_fps": 60, "bloom_enabled": false, "shadows_enabled": true, "shadow_resolution": 1024, "input_actions": [], "autoloads": [] } """ _GAME2D_MAIN = '''\ """Main scene for 2D game.""" from simvx.core import Node2D, Camera2D, Property, Vec2 from simvx.graphics import App class Main(Node2D): def ready(self): camera = Camera2D(name="Camera") self.add_child(camera) def process(self, dt: float): pass if __name__ == "__main__": app = App(width=1280, height=720, title="${project_name}") app.run(Main()) ''' _GAME3D_MAIN = '''\ """Main scene for 3D game.""" from simvx.core import Node3D, Camera3D, MeshInstance3D, DirectionalLight3D, Property, Vec3 from simvx.graphics import App class Main(Node3D): def ready(self): camera = Camera3D(name="Camera") camera.position = Vec3(0, 5, 10) self.add_child(camera) light = DirectionalLight3D(name="Sun") self.add_child(light) def process(self, dt: float): pass if __name__ == "__main__": app = App(width=1280, height=720, title="${project_name}") app.run(Main()) ''' PROJECT_TEMPLATES: dict[str, ProjectTemplate] = { "Empty": ProjectTemplate( name="Empty", description="Empty project with just a project file.", files={ "project.simvx": _EMPTY_PROJECT_JSON, }, ), "2D Game": ProjectTemplate( name="2D Game", description="2D game starter with camera and main scene.", files={ "project.simvx": _EMPTY_PROJECT_JSON, "main.py": _GAME2D_MAIN, }, ), "3D Game": ProjectTemplate( name="3D Game", description="3D game starter with camera, light, and main scene.", files={ "project.simvx": _EMPTY_PROJECT_JSON, "main.py": _GAME3D_MAIN, }, ), }
[docs] def generate_project( template_name: str, project_name: str, output_dir: str | Path, ) -> Path: """Create a new project from a template. Args: template_name: Key in PROJECT_TEMPLATES (e.g. "3D Game"). project_name: Name for the project. output_dir: Directory to create the project in. Returns: Path to the created project directory. """ tmpl = PROJECT_TEMPLATES.get(template_name) if tmpl is None: raise ValueError(f"Unknown project template: {template_name!r}. Available: {list(PROJECT_TEMPLATES)}") out = Path(output_dir) / _to_snake_case(project_name) out.mkdir(parents=True, exist_ok=True) for rel_path, content in tmpl.files.items(): content = content.replace("${project_name}", project_name) file_path = out / rel_path file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content) # Create standard directories for subdir in ("scenes", "scripts", "assets", "addons"): (out / subdir).mkdir(exist_ok=True) log.info("Created project '%s' at %s", project_name, out) return out