@pix3/cli 1.6.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +788 -754
  3. package/dist/index.js +208 -203
  4. package/dist/runtime-types.json +1 -1
  5. package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-7MZGPAUK.mjs} +1 -1
  6. package/dist/smoke/prebuilt/chunk-7WOFGLW3.mjs +239 -0
  7. package/dist/smoke/prebuilt/runtime.mjs +1 -1
  8. package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
  9. package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
  10. package/dist/validate/prebuilt/bundle-main.js +13 -13
  11. package/dist/validate/prebuilt/chunk-HKG6YI6Y.js +239 -0
  12. package/dist/validate/prebuilt/runtime.js +1 -1
  13. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
  14. package/kit/files/.claude/skills/pix3-nodes/reference.md +32 -24
  15. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
  16. package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -28
  17. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
  18. package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
  19. package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
  20. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
  21. package/kit/files/AGENTS.md +203 -203
  22. package/kit/files/CLAUDE.md +1 -1
  23. package/kit/kit.json +2 -2
  24. package/package.json +54 -54
  25. package/templates/empty-2d/files/README.md +20 -20
  26. package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
  27. package/templates/empty-2d/template.yaml +7 -7
  28. package/templates/empty-3d/files/README.md +20 -20
  29. package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
  30. package/templates/empty-3d/template.yaml +7 -7
  31. package/templates/idea-blank/files/README.md +22 -22
  32. package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
  33. package/templates/idea-blank/template.yaml +10 -10
  34. package/templates/minigame-2d/files/README.md +44 -44
  35. package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
  36. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
  37. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
  38. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
  39. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
  40. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
  41. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
  42. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
  43. package/templates/minigame-2d/template.yaml +10 -10
  44. package/templates/playable-2d/files/README.md +30 -30
  45. package/templates/playable-2d/files/design/recipe.md +83 -83
  46. package/templates/playable-2d/files/design/tests/reachability.json +4 -4
  47. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
  48. package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
  49. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
  50. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
  51. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
  52. package/templates/playable-2d/template.yaml +9 -9
  53. package/templates/playable-3d/files/README.md +32 -32
  54. package/templates/playable-3d/files/design/recipe.md +127 -127
  55. package/templates/playable-3d/files/design/tests/reachability.json +4 -4
  56. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
  57. package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
  58. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
  59. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
  60. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
  61. package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
  62. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
  63. package/templates/playable-3d/template.yaml +9 -9
  64. package/templates/recipe-arena-2d/files/README.md +48 -48
  65. package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
  66. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
  67. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
  68. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
  69. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
  70. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
  71. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
  72. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
  73. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
  74. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
  75. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
  76. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
  77. package/templates/recipe-arena-2d/template.yaml +10 -10
  78. package/templates/recipe-blank-2d/files/README.md +61 -61
  79. package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
  80. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
  81. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
  82. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
  83. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
  84. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
  85. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
  86. package/templates/recipe-blank-2d/template.yaml +10 -10
  87. package/templates/recipe-bouncer-2d/files/README.md +81 -81
  88. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
  89. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
  90. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
  91. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
  92. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
  93. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
  94. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
  95. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
  96. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
  97. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
  98. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
  99. package/templates/recipe-bouncer-2d/template.yaml +10 -10
  100. package/templates/recipe-grid-3d/files/README.md +22 -22
  101. package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
  102. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
  103. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
  104. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
  105. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
  106. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
  107. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
  108. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
  109. package/templates/recipe-grid-3d/template.yaml +10 -10
  110. package/templates/recipe-tapper-2d/files/README.md +39 -39
  111. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
  112. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
  113. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
  114. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
  115. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
  116. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
  117. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
  118. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
  119. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
  120. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
  121. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
  122. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
  123. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
  124. package/templates/recipe-tapper-2d/template.yaml +10 -10
  125. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
  126. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +0 -239
@@ -1,217 +1,217 @@
1
- /**
2
- * MenuFlow — drives the menu scene.
3
- *
4
- * Attach to the menu scene root. PLAY transitions to the game scene via a fade
5
- * (`this.scene.changeScene`), SETTINGS shows the settings-window prefab instance.
6
- * The game lives in its own scene (main.pix3scene) so it can be opened and played
7
- * on its own; this menu is the entry point wired into the full build flow.
8
- *
9
- * Handlers here are **intent-first**: each button's behaviour lives in a named
10
- * public method marked `Intent:`, and the intent is registered as a **command**
11
- * (`scene.commands`) that the button's signal handler dispatches. Three things
12
- * follow, all for free: a test drives the menu with
13
- * `scene.commands.dispatch('start-game')` instead of a tap; every raised intent
14
- * lands in `scene.commands.log` with the frame it happened on; and one real tap
15
- * is enough to prove the button is still wired to the intent it claims.
16
- *
17
- * It also registers a debug provider, exactly like `GameFlow` does for the game
18
- * scene. `registerGameDebug` is global and last-wins, and only the running
19
- * scene's flow script registers one — so without this, every tool reading the
20
- * game while the menu is up would get nothing (or stale game-scene state).
21
- */
22
- import {
23
- Checkbox2D,
24
- NodeBase,
25
- Script,
26
- registerGameDebug,
27
- type PropertySchema,
28
- } from '@pix3/runtime';
29
-
30
- export class MenuFlow extends Script {
31
- private playButtonReady = false;
32
- private settingsButtonReady = false;
33
- private disposeDebug: (() => void) | null = null;
34
- private disposeCommands: (() => void)[] = [];
35
-
36
- constructor(id: string, type: string) {
37
- super(id, type);
38
- this.config = {
39
- // res:// path of the scene PLAY transitions to.
40
- gameScene: 'res://scenes/main.pix3scene',
41
- // Node id/name of the settings-window prefab instance to toggle.
42
- settingsNode: 'settings-window',
43
- playButton: 'play-button',
44
- settingsButton: 'settings-button',
45
- };
46
- }
47
-
48
- static getPropertySchema(): PropertySchema {
49
- const stringProp = (name: string, label: string) => ({
50
- name,
51
- type: 'string' as const,
52
- ui: { label, group: 'Menu' },
53
- getValue: (s: unknown) => (s as MenuFlow).config[name],
54
- setValue: (s: unknown, v: unknown) => {
55
- (s as MenuFlow).config[name] = typeof v === 'string' ? v : '';
56
- },
57
- });
58
-
59
- return {
60
- nodeType: 'MenuFlow',
61
- properties: [
62
- stringProp('gameScene', 'Game Scene'),
63
- stringProp('settingsNode', 'Settings Window'),
64
- stringProp('playButton', 'Play Button'),
65
- stringProp('settingsButton', 'Settings Button'),
66
- ],
67
- groups: { Menu: { label: 'Menu Flow', expanded: true } },
68
- };
69
- }
70
-
71
- onStart(): void {
72
- // Register the intents first, then point the buttons at them: a button
73
- // dispatches, it does not call the method, so the command journal is the
74
- // whole truth about what the player asked for.
75
- const commands = this.scene?.commands;
76
- this.disposeCommands = [
77
- commands?.register('start-game', () => this.startGame(), {
78
- description: 'Leave the menu and transition into the game scene.',
79
- }),
80
- commands?.register(
81
- 'open-settings',
82
- () => {
83
- this.openSettings();
84
- // Returning a reverse step is all it takes to make an intent
85
- // undoable: `scene.commands.undo()` closes the window again.
86
- return { undo: () => this.closeSettings() };
87
- },
88
- { description: 'Show the settings window over the menu.', undoable: true }
89
- ),
90
- commands?.register('close-settings', () => this.closeSettings(), {
91
- description: 'Hide the settings window.',
92
- }),
93
- ].filter((dispose): dispose is () => void => dispose !== undefined);
94
-
95
- this.playButtonReady = this.connectButton(String(this.config.playButton ?? ''), () => {
96
- this.scene?.commands.dispatch('start-game');
97
- });
98
- this.settingsButtonReady = this.connectButton(String(this.config.settingsButton ?? ''), () => {
99
- this.scene?.commands.dispatch('open-settings');
100
- });
101
-
102
- this.disposeDebug = registerGameDebug({
103
- name: 'minigame-2d',
104
- version: 1,
105
- // The registry IS the action list — never a second, hand-kept copy.
106
- actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
107
- // What "the start" means for the menu: the fresh menu has the settings
108
- // window closed, and an earlier `open-settings` is the one piece of state a
109
- // later run would inherit. Deliberately NOT reset here: the checkboxes
110
- // inside the window, which are player settings — a reset that silently
111
- // reverted someone's audio choice would be a worse lie than not resetting.
112
- // `seed` is ignored; the menu has no randomness to seed.
113
- reset: () => this.closeSettings(),
114
- // Same `name` as GameFlow — one game, one identity; `scene` says which
115
- // flow answered. Everything here must stay JSON-serialisable.
116
- snapshot: () => ({
117
- scene: 'menu',
118
- gameScene: String(this.config.gameScene ?? ''),
119
- playButtonReady: this.playButtonReady,
120
- settingsButtonReady: this.settingsButtonReady,
121
- settingsVisible: this.isSettingsVisible(),
122
- // Checkbox state inside the settings window, keyed by node id
123
- // (`music-toggle` / `sfx-toggle` in the shipped prefab).
124
- settingsToggles: this.readSettingsToggles(),
125
- }),
126
- });
127
- }
128
-
129
- onDetach(): void {
130
- // Safe in any order: the disposer clears the global only while it still
131
- // holds *this* provider, so the game scene's registration is never wiped.
132
- this.disposeDebug?.();
133
- this.disposeDebug = null;
134
- // The scene's registry is cleared on stop anyway; this covers the other
135
- // case — this script being detached while the scene keeps running.
136
- for (const dispose of this.disposeCommands) {
137
- dispose();
138
- }
139
- this.disposeCommands = [];
140
- this.playButtonReady = false;
141
- this.settingsButtonReady = false;
142
- super.onDetach();
143
- }
144
-
145
- /** Intent: leave the menu and transition into the game scene. */
146
- startGame(): void {
147
- const gameScene = String(this.config.gameScene ?? '');
148
- if (!gameScene) {
149
- console.warn('[MenuFlow] No game scene configured.');
150
- return;
151
- }
152
- void this.scene?.changeScene(gameScene, { transition: 'fade', durationSec: 0.3 });
153
- }
154
-
155
- /** Intent: show the settings window over the menu. */
156
- openSettings(): void {
157
- this.setNodeVisible(String(this.config.settingsNode ?? ''), true);
158
- }
159
-
160
- /** Intent: hide the settings window (its own CLOSE button does this too). */
161
- closeSettings(): void {
162
- this.setNodeVisible(String(this.config.settingsNode ?? ''), false);
163
- }
164
-
165
- /** Returns whether the button was found and wired (reported in the snapshot). */
166
- private connectButton(query: string, handler: () => void): boolean {
167
- if (!query) {
168
- return false;
169
- }
170
- const button = this.findNode(query);
171
- if (!button) {
172
- console.warn(`[MenuFlow] Button "${query}" not found.`);
173
- return false;
174
- }
175
- button.connect('pressed', this, handler);
176
- return true;
177
- }
178
-
179
- private isSettingsVisible(): boolean {
180
- const node = this.findNode(String(this.config.settingsNode ?? ''));
181
- return node?.visible === true;
182
- }
183
-
184
- /** Every Checkbox2D under the settings window, by node id → checked. */
185
- private readSettingsToggles(): Record<string, boolean> {
186
- const toggles: Record<string, boolean> = {};
187
- const root = this.findNode(String(this.config.settingsNode ?? ''));
188
- if (!root) {
189
- return toggles;
190
- }
191
- const visit = (node: NodeBase): void => {
192
- if (node instanceof Checkbox2D) {
193
- toggles[node.nodeId] = node.checked;
194
- }
195
- for (const child of node.children) {
196
- // `children` also holds plain Three.js objects (visuals), not just nodes.
197
- if (child instanceof NodeBase) {
198
- visit(child);
199
- }
200
- }
201
- };
202
- visit(root);
203
- return toggles;
204
- }
205
-
206
- private setNodeVisible(query: string, visible: boolean): void {
207
- if (!query) {
208
- return;
209
- }
210
- const node = this.findNode(query);
211
- if (node) {
212
- node.visible = visible;
213
- } else {
214
- console.warn(`[MenuFlow] Node "${query}" not found.`);
215
- }
216
- }
217
- }
1
+ /**
2
+ * MenuFlow — drives the menu scene.
3
+ *
4
+ * Attach to the menu scene root. PLAY transitions to the game scene via a fade
5
+ * (`this.scene.changeScene`), SETTINGS shows the settings-window prefab instance.
6
+ * The game lives in its own scene (main.pix3scene) so it can be opened and played
7
+ * on its own; this menu is the entry point wired into the full build flow.
8
+ *
9
+ * Handlers here are **intent-first**: each button's behaviour lives in a named
10
+ * public method marked `Intent:`, and the intent is registered as a **command**
11
+ * (`scene.commands`) that the button's signal handler dispatches. Three things
12
+ * follow, all for free: a test drives the menu with
13
+ * `scene.commands.dispatch('start-game')` instead of a tap; every raised intent
14
+ * lands in `scene.commands.log` with the frame it happened on; and one real tap
15
+ * is enough to prove the button is still wired to the intent it claims.
16
+ *
17
+ * It also registers a debug provider, exactly like `GameFlow` does for the game
18
+ * scene. `registerGameDebug` is global and last-wins, and only the running
19
+ * scene's flow script registers one — so without this, every tool reading the
20
+ * game while the menu is up would get nothing (or stale game-scene state).
21
+ */
22
+ import {
23
+ Checkbox2D,
24
+ NodeBase,
25
+ Script,
26
+ registerGameDebug,
27
+ type PropertySchema,
28
+ } from '@pix3/runtime';
29
+
30
+ export class MenuFlow extends Script {
31
+ private playButtonReady = false;
32
+ private settingsButtonReady = false;
33
+ private disposeDebug: (() => void) | null = null;
34
+ private disposeCommands: (() => void)[] = [];
35
+
36
+ constructor(id: string, type: string) {
37
+ super(id, type);
38
+ this.config = {
39
+ // res:// path of the scene PLAY transitions to.
40
+ gameScene: 'res://scenes/main.pix3scene',
41
+ // Node id/name of the settings-window prefab instance to toggle.
42
+ settingsNode: 'settings-window',
43
+ playButton: 'play-button',
44
+ settingsButton: 'settings-button',
45
+ };
46
+ }
47
+
48
+ static getPropertySchema(): PropertySchema {
49
+ const stringProp = (name: string, label: string) => ({
50
+ name,
51
+ type: 'string' as const,
52
+ ui: { label, group: 'Menu' },
53
+ getValue: (s: unknown) => (s as MenuFlow).config[name],
54
+ setValue: (s: unknown, v: unknown) => {
55
+ (s as MenuFlow).config[name] = typeof v === 'string' ? v : '';
56
+ },
57
+ });
58
+
59
+ return {
60
+ nodeType: 'MenuFlow',
61
+ properties: [
62
+ stringProp('gameScene', 'Game Scene'),
63
+ stringProp('settingsNode', 'Settings Window'),
64
+ stringProp('playButton', 'Play Button'),
65
+ stringProp('settingsButton', 'Settings Button'),
66
+ ],
67
+ groups: { Menu: { label: 'Menu Flow', expanded: true } },
68
+ };
69
+ }
70
+
71
+ onStart(): void {
72
+ // Register the intents first, then point the buttons at them: a button
73
+ // dispatches, it does not call the method, so the command journal is the
74
+ // whole truth about what the player asked for.
75
+ const commands = this.scene?.commands;
76
+ this.disposeCommands = [
77
+ commands?.register('start-game', () => this.startGame(), {
78
+ description: 'Leave the menu and transition into the game scene.',
79
+ }),
80
+ commands?.register(
81
+ 'open-settings',
82
+ () => {
83
+ this.openSettings();
84
+ // Returning a reverse step is all it takes to make an intent
85
+ // undoable: `scene.commands.undo()` closes the window again.
86
+ return { undo: () => this.closeSettings() };
87
+ },
88
+ { description: 'Show the settings window over the menu.', undoable: true }
89
+ ),
90
+ commands?.register('close-settings', () => this.closeSettings(), {
91
+ description: 'Hide the settings window.',
92
+ }),
93
+ ].filter((dispose): dispose is () => void => dispose !== undefined);
94
+
95
+ this.playButtonReady = this.connectButton(String(this.config.playButton ?? ''), () => {
96
+ this.scene?.commands.dispatch('start-game');
97
+ });
98
+ this.settingsButtonReady = this.connectButton(String(this.config.settingsButton ?? ''), () => {
99
+ this.scene?.commands.dispatch('open-settings');
100
+ });
101
+
102
+ this.disposeDebug = registerGameDebug({
103
+ name: 'minigame-2d',
104
+ version: 1,
105
+ // The registry IS the action list — never a second, hand-kept copy.
106
+ actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
107
+ // What "the start" means for the menu: the fresh menu has the settings
108
+ // window closed, and an earlier `open-settings` is the one piece of state a
109
+ // later run would inherit. Deliberately NOT reset here: the checkboxes
110
+ // inside the window, which are player settings — a reset that silently
111
+ // reverted someone's audio choice would be a worse lie than not resetting.
112
+ // `seed` is ignored; the menu has no randomness to seed.
113
+ reset: () => this.closeSettings(),
114
+ // Same `name` as GameFlow — one game, one identity; `scene` says which
115
+ // flow answered. Everything here must stay JSON-serialisable.
116
+ snapshot: () => ({
117
+ scene: 'menu',
118
+ gameScene: String(this.config.gameScene ?? ''),
119
+ playButtonReady: this.playButtonReady,
120
+ settingsButtonReady: this.settingsButtonReady,
121
+ settingsVisible: this.isSettingsVisible(),
122
+ // Checkbox state inside the settings window, keyed by node id
123
+ // (`music-toggle` / `sfx-toggle` in the shipped prefab).
124
+ settingsToggles: this.readSettingsToggles(),
125
+ }),
126
+ });
127
+ }
128
+
129
+ onDetach(): void {
130
+ // Safe in any order: the disposer clears the global only while it still
131
+ // holds *this* provider, so the game scene's registration is never wiped.
132
+ this.disposeDebug?.();
133
+ this.disposeDebug = null;
134
+ // The scene's registry is cleared on stop anyway; this covers the other
135
+ // case — this script being detached while the scene keeps running.
136
+ for (const dispose of this.disposeCommands) {
137
+ dispose();
138
+ }
139
+ this.disposeCommands = [];
140
+ this.playButtonReady = false;
141
+ this.settingsButtonReady = false;
142
+ super.onDetach();
143
+ }
144
+
145
+ /** Intent: leave the menu and transition into the game scene. */
146
+ startGame(): void {
147
+ const gameScene = String(this.config.gameScene ?? '');
148
+ if (!gameScene) {
149
+ console.warn('[MenuFlow] No game scene configured.');
150
+ return;
151
+ }
152
+ void this.scene?.changeScene(gameScene, { transition: 'fade', durationSec: 0.3 });
153
+ }
154
+
155
+ /** Intent: show the settings window over the menu. */
156
+ openSettings(): void {
157
+ this.setNodeVisible(String(this.config.settingsNode ?? ''), true);
158
+ }
159
+
160
+ /** Intent: hide the settings window (its own CLOSE button does this too). */
161
+ closeSettings(): void {
162
+ this.setNodeVisible(String(this.config.settingsNode ?? ''), false);
163
+ }
164
+
165
+ /** Returns whether the button was found and wired (reported in the snapshot). */
166
+ private connectButton(query: string, handler: () => void): boolean {
167
+ if (!query) {
168
+ return false;
169
+ }
170
+ const button = this.findNode(query);
171
+ if (!button) {
172
+ console.warn(`[MenuFlow] Button "${query}" not found.`);
173
+ return false;
174
+ }
175
+ button.connect('pressed', this, handler);
176
+ return true;
177
+ }
178
+
179
+ private isSettingsVisible(): boolean {
180
+ const node = this.findNode(String(this.config.settingsNode ?? ''));
181
+ return node?.visible === true;
182
+ }
183
+
184
+ /** Every Checkbox2D under the settings window, by node id → checked. */
185
+ private readSettingsToggles(): Record<string, boolean> {
186
+ const toggles: Record<string, boolean> = {};
187
+ const root = this.findNode(String(this.config.settingsNode ?? ''));
188
+ if (!root) {
189
+ return toggles;
190
+ }
191
+ const visit = (node: NodeBase): void => {
192
+ if (node instanceof Checkbox2D) {
193
+ toggles[node.nodeId] = node.checked;
194
+ }
195
+ for (const child of node.children) {
196
+ // `children` also holds plain Three.js objects (visuals), not just nodes.
197
+ if (child instanceof NodeBase) {
198
+ visit(child);
199
+ }
200
+ }
201
+ };
202
+ visit(root);
203
+ return toggles;
204
+ }
205
+
206
+ private setNodeVisible(query: string, visible: boolean): void {
207
+ if (!query) {
208
+ return;
209
+ }
210
+ const node = this.findNode(query);
211
+ if (node) {
212
+ node.visible = visible;
213
+ } else {
214
+ console.warn(`[MenuFlow] Node "${query}" not found.`);
215
+ }
216
+ }
217
+ }