Scene files

a node tree saved as Python source and loaded back

▶ Run in browser

Tags: basics scene-files serialisation change-scene

A small level is built in code, serialised to a .py scene file with SceneFile.from_runtime(root).save(path), and the emitted source is shown on screen. A keypress runs load_scene(path) and switches to the loaded tree with tree.change_scene(...); a second in-code scene then shows how state crosses a scene switch. Everything is written under tempfile.mkdtemp().

What it demonstrates

  • SceneFile.from_runtime(root) emits a complete .py source file for a live tree; .save(path) writes it atomically. Scenes ARE Python source, so the file on disk is readable, diffable and hand-editable.

  • load_scene(path) runs that file and instantiates its primary Node subclass. The emitted class holds structure only, so behaviour (input handling, HUD) is composed on by attaching a controller child before the switch.

  • self.tree.change_scene(node) takes an INSTANTIATED Node: there is no path-based switch and no App.tree. Constructor arguments on the new root are how state (here: a load counter and the file path) crosses scenes.

  • All file I/O happens under tempfile.mkdtemp(); the repo is never written to.

Controls: SPACE - advance: save/show -> load and switch -> in-code summary -> back ESC - Quit

Run: uv run python examples/features/basics/scene_files.py Headless self-check: uv run python examples/features/basics/scene_files.py –test

Source

  1"""Scene files: a node tree saved as Python source and loaded back
  2
  3A small level is built in code, serialised to a ``.py`` scene file with
  4``SceneFile.from_runtime(root).save(path)``, and the emitted source is shown on
  5screen. A keypress runs ``load_scene(path)`` and switches to the loaded tree
  6with ``tree.change_scene(...)``; a second in-code scene then shows how state
  7crosses a scene switch. Everything is written under ``tempfile.mkdtemp()``.
  8
  9# /// simvx
 10# tags = ["basics", "scene-files", "serialisation", "change-scene"]
 11# web = { root = "BuilderScene", width = 960, height = 540, responsive = true }
 12# ///
 13
 14## What it demonstrates
 15
 16- `SceneFile.from_runtime(root)` emits a complete `.py` source file for a live
 17  tree; `.save(path)` writes it atomically. Scenes ARE Python source, so the
 18  file on disk is readable, diffable and hand-editable.
 19- `load_scene(path)` runs that file and instantiates its primary Node subclass.
 20  The emitted class holds structure only, so behaviour (input handling, HUD) is
 21  composed on by attaching a controller child before the switch.
 22- `self.tree.change_scene(node)` takes an INSTANTIATED Node: there is no
 23  path-based switch and no `App.tree`. Constructor arguments on the new root
 24  are how state (here: a load counter and the file path) crosses scenes.
 25- All file I/O happens under `tempfile.mkdtemp()`; the repo is never written to.
 26
 27Controls:
 28  SPACE - advance: save/show -> load and switch -> in-code summary -> back
 29  ESC   - Quit
 30
 31Run: uv run python examples/features/basics/scene_files.py
 32Headless self-check: uv run python examples/features/basics/scene_files.py --test
 33"""
 34
 35import tempfile
 36from pathlib import Path
 37
 38from simvx.core import Input, Key, Node2D, SceneFile, Text2D, Vec2, load_scene
 39from simvx.graphics import App
 40
 41WIDTH, HEIGHT = 960, 540
 42
 43#: How many lines of the emitted source the builder screen shows before truncating.
 44MAX_SOURCE_LINES = 16
 45
 46#: The stars the level is built from: (x, y, font_scale, colour).
 47STARS = [
 48    (240, 250, 3.0, (0.45, 0.80, 1.00, 1.0)),
 49    (430, 330, 2.0, (1.00, 0.55, 0.45, 1.0)),
 50    (560, 220, 4.0, (0.70, 1.00, 0.55, 1.0)),
 51    (720, 310, 2.5, (0.95, 0.80, 0.35, 1.0)),
 52]
 53
 54_save_dir: Path | None = None
 55
 56
 57def save_dir() -> Path:
 58    """One throwaway directory per run, so nothing lands in the repo or cwd."""
 59    global _save_dir
 60    if _save_dir is None:
 61        _save_dir = Path(tempfile.mkdtemp(prefix="simvx_scene_files_"))
 62    return _save_dir
 63
 64
 65def build_level() -> Node2D:
 66    """The tree that gets serialised: pure data, Text2D nodes draw themselves.
 67
 68    Text2D is used for every visible node because it is fully declarative
 69    (text, position, scale, colour are all Properties the emitter can write);
 70    the loaded scene therefore renders with no code of its own.
 71    """
 72    level = Node2D(name="Level")
 73    level.add_child(
 74        Text2D(
 75            name="Banner",
 76            text="Every node on this screen was read back from level.py",
 77            position=Vec2(WIDTH / 2, 100),
 78            font_scale=1.5,
 79            align="centre",
 80            colour=(1.0, 0.85, 0.35, 1.0),
 81        )
 82    )
 83    for i, (x, y, scale, colour) in enumerate(STARS):
 84        level.add_child(
 85            Text2D(name=f"Star{i}", text="*", position=Vec2(x, y), font_scale=scale, colour=colour)
 86        )
 87    return level
 88
 89
 90class BuilderScene(Node2D):
 91    """Builds the level in code, saves it as a scene file, and shows the source."""
 92
 93    input_actions = {"advance": [Key.SPACE], "quit": [Key.ESCAPE]}
 94
 95    def __init__(self, loads: int = 0, **kwargs):
 96        self._loads = loads
 97        super().__init__(**kwargs)
 98
 99    def on_ready(self):
100        # from_runtime emits source for the live tree; save writes it out and
101        # returns the path. The level is built fresh each visit, never mounted
102        # here: the stars only appear on screen once the FILE is loaded back.
103        self._path = SceneFile.from_runtime(build_level()).save(save_dir() / "level.py")
104        self._source_lines = self._path.read_text(encoding="utf-8").splitlines()
105
106    def load_and_switch(self):
107        # load_scene runs the file and instantiates its primary Node subclass.
108        loaded = load_scene(self._path)
109        # The emitted class is structure only, so behaviour is composed on:
110        # a controller child gives the data scene input handling and a HUD.
111        loaded.add_child(LoadedControls(self._loads + 1, self._path))
112        # change_scene takes the instance itself; there is no switch-by-path.
113        self.tree.change_scene(loaded)
114
115    def on_update(self, dt: float):
116        if Input.is_action_just_pressed("advance"):
117            self.load_and_switch()
118        elif Input.is_action_just_pressed("quit"):
119            self.app.quit()
120
121    def on_draw(self, renderer):
122        renderer.draw_text("Scene files: the emitted source of the level tree", (20, 16), scale=2, colour=(1, 1, 1))
123        renderer.draw_text(f"saved to {self._path}", (20, 52), colour=(0.55, 0.6, 0.7))
124        shown = self._source_lines[:MAX_SOURCE_LINES]
125        for i, line in enumerate(shown):
126            renderer.draw_text(line[:100], (20, 88 + i * 24), colour=(0.55, 0.9, 0.6))
127        hidden = len(self._source_lines) - len(shown)
128        if hidden > 0:
129            renderer.draw_text(f"... {hidden} more lines", (20, 88 + len(shown) * 24), colour=(0.45, 0.5, 0.55))
130        renderer.draw_text(
131            "SPACE: load_scene(path) and change_scene to it    ESC: quit",
132            (20, HEIGHT - 30),
133            colour=(0.6, 0.6, 0.6),
134        )
135
136
137class LoadedControls(Node2D):
138    """Behaviour composed onto the loaded scene: HUD and the next switch."""
139
140    def __init__(self, loads: int, path: Path, **kwargs):
141        self._loads = loads
142        self._path = path
143        super().__init__(**kwargs)
144
145    def to_summary(self):
146        # Constructor arguments are how state crosses a scene switch: the load
147        # counter and the file path ride into the next root here.
148        self.tree.change_scene(SummaryScene(self._loads, self._path))
149
150    def on_update(self, dt: float):
151        if Input.is_action_just_pressed("advance"):
152            self.to_summary()
153        elif Input.is_action_just_pressed("quit"):
154            self.app.quit()
155
156    def on_draw(self, renderer):
157        renderer.draw_text("This root came from load_scene(...)", (20, 16), scale=2, colour=(1, 1, 1))
158        renderer.draw_text(
159            "The banner and stars are Text2D nodes deserialised from disk; this HUD is a",
160            (20, 52),
161            colour=(0.55, 0.6, 0.7),
162        )
163        renderer.draw_text(
164            "controller child attached to the loaded root before change_scene.",
165            (20, 74),
166            colour=(0.55, 0.6, 0.7),
167        )
168        renderer.draw_text(
169            "SPACE: switch to an in-code scene, carrying state via constructor args    ESC: quit",
170            (20, HEIGHT - 30),
171            colour=(0.6, 0.6, 0.6),
172        )
173
174
175class SummaryScene(Node2D):
176    """In-code scene whose constructor arguments arrived from the previous root."""
177
178    input_actions = {"advance": [Key.SPACE], "quit": [Key.ESCAPE]}
179
180    def __init__(self, loads: int = 0, path: Path | None = None, **kwargs):
181        self._loads = loads
182        self._path = path
183        super().__init__(**kwargs)
184
185    def back_to_builder(self):
186        # The counter keeps riding: the builder shows the next load as loads+1.
187        self.tree.change_scene(BuilderScene(self._loads))
188
189    def on_update(self, dt: float):
190        if Input.is_action_just_pressed("advance"):
191            self.back_to_builder()
192        elif Input.is_action_just_pressed("quit"):
193            self.app.quit()
194
195    def on_draw(self, renderer):
196        renderer.draw_text("In-code scene: state carried by constructor args", (20, 16), scale=2, colour=(1, 1, 1))
197        renderer.draw_text(
198            f"level.py has been loaded {self._loads} time(s) this run.",
199            (20, 70),
200            colour=(0.95, 0.8, 0.35, 1.0),
201        )
202        renderer.draw_text(f"file: {self._path}", (20, 100), colour=(0.55, 0.6, 0.7))
203        renderer.draw_text(
204            "change_scene(node) takes an instantiated Node, so passing values to the new",
205            (20, 150),
206            colour=(0.55, 0.9, 0.6),
207        )
208        renderer.draw_text(
209            "root's constructor is the whole state-transfer mechanism: no globals needed.",
210            (20, 172),
211            colour=(0.55, 0.9, 0.6),
212        )
213        renderer.draw_text(
214            "SPACE: back to the builder (the counter rides along)    ESC: quit",
215            (20, HEIGHT - 30),
216            colour=(0.6, 0.6, 0.6),
217        )
218
219
220def _selftest() -> bool:
221    """Headless: round-trip the scene file and walk the demo's own scene flow."""
222    import shutil
223
224    from simvx.core import SceneTree
225
226    ok = True
227
228    def check(label: str, passed: bool, detail: str) -> None:
229        nonlocal ok
230        ok = ok and passed
231        print(f"{'ok  ' if passed else 'FAIL'} {label}: {detail}")
232
233    # 1. Save: the emitted file is real Python source describing the tree.
234    original = build_level()
235    path = SceneFile.from_runtime(original).save(save_dir() / "level.py")
236    text = path.read_text(encoding="utf-8")
237    check("the scene file exists on disk", path.is_file(), str(path))
238    check(
239        "the source declares the primary class and its children",
240        "class Level(" in text and "Banner" in text and "Star0" in text,
241        f"{len(text.splitlines())} lines emitted",
242    )
243
244    # 2. Load: the round-tripped tree matches the one that was saved.
245    loaded = load_scene(path)
246
247    def snapshot(node):
248        return sorted(
249            (c.name, c.text, round(c.position.x, 3), round(c.position.y, 3), c.font_scale, tuple(c.colour))
250            for c in node.children
251        )
252
253    check(
254        "load_scene rebuilds the identical tree",
255        type(loaded).__name__ == "Level" and snapshot(loaded) == snapshot(original),
256        f"{len(loaded.children)} children round-tripped",
257    )
258
259    # 3. The demo's own flow, on a headless SceneTree: builder -> loaded ->
260    # summary -> builder, with the load counter riding the constructor args.
261    tree = SceneTree()
262    builder = BuilderScene(name="Builder")
263    tree.set_root(builder)
264    tree.tick(1 / 60)
265    check("the builder saved the file in on_ready", builder._path.is_file(), str(builder._path))
266
267    builder.load_and_switch()
268    tree.tick(1 / 60)
269    controls = [c for c in tree.root.children if isinstance(c, LoadedControls)]
270    check(
271        "change_scene mounted the loaded instance",
272        type(tree.root).__name__ == "Level" and len(controls) == 1,
273        f"root is {type(tree.root).__name__} with {len(tree.root.children)} children",
274    )
275
276    controls[0].to_summary()
277    tree.tick(1 / 60)
278    check(
279        "constructor args carried state into the in-code scene",
280        isinstance(tree.root, SummaryScene) and tree.root._loads == 1 and tree.root._path == builder._path,
281        f"root is {type(tree.root).__name__}, loads={getattr(tree.root, '_loads', None)}",
282    )
283
284    tree.root.back_to_builder()
285    tree.tick(1 / 60)
286    check(
287        "the counter rides back into the builder",
288        isinstance(tree.root, BuilderScene) and tree.root._loads == 1,
289        f"loads={getattr(tree.root, '_loads', None)}",
290    )
291
292    shutil.rmtree(save_dir(), ignore_errors=True)
293    print("SELFTEST:", "PASS" if ok else "FAIL")
294    return ok
295
296
297if __name__ == "__main__":
298    import sys
299
300    if "--test" in sys.argv:
301        sys.exit(0 if _selftest() else 1)
302    App(title="Scene Files", width=WIDTH, height=HEIGHT).run(BuilderScene())