Scene files¶
a node tree saved as Python source and loaded back
▶ Run in browserTags: 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.pysource 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 noApp.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())