Scene Files.

A SimVX scene is a Python source file holding a Node subclass, so a saved scene is readable, diffable and editable by hand. This example builds a small level in code, saves it, shows the source it wrote, then loads the file back and switches to it.

▶ Run in browser

Tags: basics scene-files serialisation change-scene

SceneFile.from_runtime(root) emits the source for a live node tree and .save(path) writes it and returns the path. Here the level is a Node2D holding a banner and four stars, all Text2D nodes, whose text, position, scale and colour are Properties the emitter writes out. The level is never mounted by the builder: the stars reach the screen only once the file is loaded back.

load_scene(path) runs the file and returns an instance of its Node subclass (Level). That class holds structure only, so the behaviour the loaded scene needs, its HUD and the way back, is a LoadedControls child added before self.tree.change_scene(level) mounts it.

change_scene takes a node, not a path, so a value crosses a switch as a constructor keyword of the next root. BuilderScene and LoadedControls both declare loads as a Property and hand it on (BuilderScene(loads=...)), so the builder shows how many times the file has been loaded.

The file is generated, so it is written to $XDG_CACHE_HOME/simvx/scene_files/level.py (~/.cache when the variable is unset), or under $SCENE_FILES_CACHE_DIR when that is set, never beside the example. save creates the directory when the builder writes the file.

Press Space, or click or tap, to switch scenes.

Source

  1# /// simvx
  2# tags = ["basics", "scene-files", "serialisation", "change-scene"]
  3# web = { width = 960, height = 540, responsive = true }
  4# ///
  5"""Scene Files.
  6
  7A SimVX scene is a Python source file holding a `Node` subclass, so a saved
  8scene is readable, diffable and editable by hand. This example builds a small
  9level in code, saves it, shows the source it wrote, then loads the file back
 10and switches to it.
 11
 12`SceneFile.from_runtime(root)` emits the source for a live node tree and
 13`.save(path)` writes it and returns the path. Here the level is a `Node2D`
 14holding a banner and four stars, all `Text2D` nodes, whose text, position,
 15scale and colour are Properties the emitter writes out. The level is never
 16mounted by the builder: the stars reach the screen only once the file is
 17loaded back.
 18
 19`load_scene(path)` runs the file and returns an instance of its `Node`
 20subclass (`Level`). That class holds structure only, so the behaviour the
 21loaded scene needs, its HUD and the way back, is a `LoadedControls` child added
 22before `self.tree.change_scene(level)` mounts it.
 23
 24`change_scene` takes a node, not a path, so a value crosses a switch as a
 25constructor keyword of the next root. `BuilderScene` and `LoadedControls` both
 26declare `loads` as a `Property` and hand it on (`BuilderScene(loads=...)`), so
 27the builder shows how many times the file has been loaded.
 28
 29The file is generated, so it is written to
 30`$XDG_CACHE_HOME/simvx/scene_files/level.py` (`~/.cache` when the variable is
 31unset), or under `$SCENE_FILES_CACHE_DIR` when that is set, never beside the
 32example. `save` creates the directory when the builder writes the file.
 33
 34Press Space, or click or tap, to switch scenes.
 35"""
 36
 37import os
 38from pathlib import Path
 39
 40from simvx.core import (
 41    Anchor,
 42    CanvasLayer,
 43    Key,
 44    Label,
 45    Node,
 46    Node2D,
 47    Pointer,
 48    Property,
 49    SceneFile,
 50    Text2D,
 51    load_scene,
 52    on_input,
 53)
 54from simvx.graphics import App
 55
 56
 57def level_path() -> Path:
 58    """Where the generated level goes: a per-user cache directory, overridable by `$SCENE_FILES_CACHE_DIR`."""
 59    override = os.environ.get("SCENE_FILES_CACHE_DIR")
 60    if override:
 61        return Path(override) / "level.py"
 62    base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
 63    return base / "simvx" / "scene_files" / "level.py"
 64
 65
 66ADVANCE = {"advance": [Key.SPACE, Pointer.PRIMARY]}
 67
 68# (x, y) as fractions of the screen, font scale, colour.
 69STARS = [
 70    (0.25, 0.46, 3.0, (0.45, 0.80, 1.00, 1.0)),
 71    (0.45, 0.61, 2.0, (1.00, 0.55, 0.45, 1.0)),
 72    (0.58, 0.41, 4.0, (0.70, 1.00, 0.55, 1.0)),
 73    (0.75, 0.57, 2.5, (0.95, 0.80, 0.35, 1.0)),
 74]
 75
 76
 77def build_level(width: float, height: float) -> Node2D:
 78    level = Node2D(name="Level")
 79    level.add_child(
 80        Text2D(
 81            name="Banner",
 82            text="Every star on this screen was read back from level.py",
 83            position=(width / 2, height * 0.2),
 84            font_scale=1.5,
 85            align="centre",
 86            colour=(1.0, 0.85, 0.35, 1.0),
 87        )
 88    )
 89    for i, (x, y, scale, colour) in enumerate(STARS):
 90        level.add_child(
 91            Text2D(
 92                name=f"Star{i}",
 93                text="*",
 94                position=(round(x * width), round(y * height)),
 95                font_scale=scale,
 96                colour=colour,
 97            )
 98        )
 99    return level
100
101
102class BuilderScene(Node2D):
103    """Saves the level as a scene file and shows the source it wrote."""
104
105    input_context = ADVANCE
106    loads = Property(0)
107
108    def on_ready(self):
109        scene = SceneFile.from_runtime(build_level(*self.viewport_size))
110        self.level_file = scene.save(level_path())
111        source = self.level_file.read_text()
112        hud = self.add_child(CanvasLayer())
113        listing = f"{self.level_file}  (loads: {self.loads})\n\n{source}"
114        self.listing = hud.add_child(Label(listing, anchor=Anchor.TOP_LEFT, inset=16))
115        hud.add_child(
116            Label("Space or click: load_scene(path) and change_scene to it", anchor=Anchor.BOTTOM_LEFT, inset=16)
117        )
118
119    def on_update(self, dt):
120        # Engine limitation: Label cannot fit multi-line text to a box. `autoshrink` fits only the width of an
121        # explicitly sized label, so this listing is fitted on neither axis by the engine. The height fit below
122        # copies Label's line pitch and inset by hand and stops at 8, the floor of `font_size`'s range, so a
123        # small window still overflows (at 400x300 the listing runs off the right edge and over the hint).
124        lines = self.listing.text.count("\n") + 1
125        self.listing.font_size = min(14.0, (self.viewport_size.y - 64) / (lines * 1.2))
126
127    @on_input("advance")
128    def load_and_switch(self, _event):
129        level = load_scene(self.level_file)
130        level.add_child(LoadedControls(loads=self.loads + 1))
131        self.tree.change_scene(level)
132
133
134class LoadedControls(Node):
135    """The loaded scene's behaviour: a HUD, and the way back carrying the load count."""
136
137    input_context = ADVANCE
138    loads = Property(0)
139
140    def on_ready(self):
141        hud = self.add_child(CanvasLayer())
142        hud.add_child(Label("This root came from load_scene(path)", anchor=Anchor.TOP_LEFT, inset=16, font_size=24))
143        hud.add_child(Label("Space or click: back to the builder", anchor=Anchor.BOTTOM_LEFT, inset=16))
144
145    @on_input("advance")
146    def back_to_builder(self, _event):
147        self.tree.change_scene(BuilderScene(loads=self.loads))
148
149
150if __name__ == "__main__":
151    App(title="Scene Files", width=960, height=540).run(BuilderScene())