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.
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())