@pix3/cli 1.6.0 → 1.6.3

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-CBFYKDS7.mjs} +1 -1
  6. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.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-CFQFEI52.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 +39 -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 +43 -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,309 +1,309 @@
1
- /**
2
- * GameFlow — the playable's phase driver: intro → playing → ended.
3
- *
4
- * Attach to `hud-root` (the 2D overlay layer above the 3D `game-root`). While
5
- * the intro overlay is visible, the first tap
6
- * hides it and starts the game (the tap also unlocks browser audio — the
7
- * engine's AudioService resumes its context on the first gesture). The end
8
- * screen is revealed either by calling `finish()` from game code or, as a
9
- * placeholder, after `autoWinAfterSec` seconds. Replace the timer with your
10
- * real win/lose condition.
11
- *
12
- * `skipIntro` starts the run already playing, gate hidden — a prototyping vent, so
13
- * that iterating on the game does not cost a tap per reload. Turn it back off before
14
- * shipping: in an ad container the first tap is what unlocks browser audio, so a
15
- * playable that never gates on one is a playable that plays silently.
16
- *
17
- * `GameRules` on the same node keeps score, lives and the clock, and ends the run through
18
- * this script's `finish` command — one phase machine, one end screen, one debug provider.
19
- * Its numbers ride along in the snapshot below.
20
- *
21
- * Two conventions here are worth keeping as you replace the placeholder:
22
- *
23
- * - **Intent-first handlers.** Every reaction to the player lives in a named
24
- * public method marked `Intent:` (`start`, `finish`, `restart`), and the ones
25
- * a test drives are registered as commands (`scene.commands`) that the event
26
- * handler dispatches. Game code and automated tests then drive the game by
27
- * intent instead of by simulated pixels — `scene.commands.dispatch('restart')`
28
- * — the flow survives any relayout of the UI, and every raised intent is
29
- * journalled with the frame it happened on.
30
- * - **A debug provider.** `registerGameDebug` publishes a JSON snapshot of the
31
- * run so external tooling (the editor's debug bridge, DevTools, an agent) can
32
- * read the phase instead of guessing it from the screen. Add your own fields
33
- * as you add state — keep everything JSON-serialisable.
34
- */
35
- import { Script, playable, registerGameDebug, type PropertySchema } from '@pix3/runtime';
36
-
37
- /**
38
- * What this script needs from a bookkeeping component, and the whole of what the two know
39
- * about each other. `bookkeeping`/`resetRun` are required (they are how the component is
40
- * recognised); the lifecycle pair is optional so a hand-written replacement can implement
41
- * only what it uses.
42
- */
43
- interface RunBookkeeping {
44
- bookkeeping(): Record<string, unknown>;
45
- resetRun(): void;
46
- startRun?(): void;
47
- endRun?(won?: boolean): void;
48
- }
49
-
50
- export class GameFlow extends Script {
51
- private phase: 'intro' | 'playing' | 'ended' = 'intro';
52
- private elapsed = 0;
53
- private disposeDebug: (() => void) | null = null;
54
- private disposeCommands: (() => void)[] = [];
55
-
56
- constructor(id: string, type: string) {
57
- super(id, type);
58
- this.config = {
59
- // Node id/name of the tap-to-start overlay.
60
- introNode: 'intro-overlay',
61
- // Node id/name of the end screen with the CTA button.
62
- endNode: 'end-screen',
63
- // Placeholder auto-win timer in seconds (0 = never; call finish() instead).
64
- autoWinAfterSec: 15,
65
- // Prototyping vent: start in `playing` with the gate already gone.
66
- skipIntro: false,
67
- };
68
- }
69
-
70
- static getPropertySchema(): PropertySchema {
71
- return {
72
- nodeType: 'GameFlow',
73
- properties: [
74
- {
75
- name: 'introNode',
76
- type: 'string',
77
- ui: { label: 'Intro Overlay', group: 'Flow' },
78
- getValue: s => (s as GameFlow).config.introNode,
79
- setValue: (s, v) => {
80
- (s as GameFlow).config.introNode = typeof v === 'string' ? v : '';
81
- },
82
- },
83
- {
84
- name: 'endNode',
85
- type: 'string',
86
- ui: { label: 'End Screen', group: 'Flow' },
87
- getValue: s => (s as GameFlow).config.endNode,
88
- setValue: (s, v) => {
89
- (s as GameFlow).config.endNode = typeof v === 'string' ? v : '';
90
- },
91
- },
92
- {
93
- name: 'autoWinAfterSec',
94
- type: 'number',
95
- ui: {
96
- label: 'Auto Win After (s)',
97
- description: 'Placeholder timer that reveals the end screen (0 = disabled)',
98
- group: 'Flow',
99
- min: 0,
100
- step: 1,
101
- },
102
- getValue: s => (s as GameFlow).config.autoWinAfterSec,
103
- setValue: (s, v) => {
104
- (s as GameFlow).config.autoWinAfterSec = Math.max(0, Number(v) || 0);
105
- },
106
- },
107
- {
108
- name: 'skipIntro',
109
- type: 'boolean',
110
- ui: {
111
- label: 'Skip Intro',
112
- description:
113
- 'Start already playing, with the tap gate hidden. For prototyping only — a shipped playable needs the tap to unlock audio.',
114
- group: 'Flow',
115
- },
116
- getValue: s => (s as GameFlow).config.skipIntro === true,
117
- setValue: (s, v) => {
118
- (s as GameFlow).config.skipIntro = v === true;
119
- },
120
- },
121
- ],
122
- groups: { Flow: { label: 'Game Flow', expanded: true } },
123
- };
124
- }
125
-
126
- onStart(): void {
127
- const commands = this.scene?.commands;
128
- this.disposeCommands = [
129
- commands?.register('start-game', () => this.start(), {
130
- description: 'Leave the intro overlay and begin play.',
131
- }),
132
- // `finish` is registered for the same reason `start-game` is: a test (and the
133
- // rest of the game) must be able to END the run by intent instead of waiting
134
- // out the placeholder timer — and a routine that reached in and called
135
- // `finish()` directly would prove the method works while skipping the wire
136
- // from the player to it. Replace the timer with your win/lose condition and
137
- // dispatch this from there.
138
- commands?.register('finish', () => this.finish(), {
139
- description: 'End the run and reveal the end screen.',
140
- }),
141
- commands?.register('restart', () => this.restart(), {
142
- description: 'Return to the tap-to-start gate and arm a fresh run.',
143
- }),
144
- ].filter((dispose): dispose is () => void => dispose !== undefined);
145
-
146
- this.disposeDebug = registerGameDebug({
147
- name: 'playable-3d',
148
- version: 1,
149
- // The registry IS the action list — never a second, hand-kept copy.
150
- actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
151
- // What "the start" means for this playable, so a test can put the game back
152
- // without restarting the scene (a restart keeps whatever a script parked in
153
- // module state; this does not). It is `restart()` plus the one thing that
154
- // lives OUTSIDE the scene and would otherwise leak into the next run: the
155
- // SDK's session `gameEnded` flag, which `playable.gameEnd()` latched.
156
- // `seed` is accepted and ignored — the placeholder flow has no randomness,
157
- // so this restores the same starting state, not the same sequence. Seed
158
- // your own RNG here once you add one.
159
- reset: () => {
160
- this.restart();
161
- playable.reset();
162
- },
163
- snapshot: () => ({
164
- phase: this.phase,
165
- elapsedSec: Math.round(this.elapsed * 100) / 100,
166
- autoWinAfterSec: Number(this.config.autoWinAfterSec) || 0,
167
- skipIntro: this.config.skipIntro === true,
168
- introVisible: this.isNodeVisible(String(this.config.introNode ?? '')),
169
- endVisible: this.isNodeVisible(String(this.config.endNode ?? '')),
170
- // Set by playable.gameEnd() — the CTA button reports the session as over.
171
- gameEnded: playable.hasGameEnded(),
172
- // Score/lives/clock, when the scene carries the bookkeeping component. Merged into
173
- // THIS snapshot rather than published as a second provider: `registerGameDebug` keeps
174
- // one, so a second call would silently displace this one.
175
- ...(this.rules()?.bookkeeping() ?? {}),
176
- }),
177
- });
178
-
179
- if (this.config.skipIntro === true) {
180
- this.openTheGate();
181
- }
182
- }
183
-
184
- onDetach(): void {
185
- this.disposeDebug?.();
186
- this.disposeDebug = null;
187
- // The scene's registry is cleared on stop anyway; this covers the other
188
- // case — this script being detached while the scene keeps running.
189
- for (const dispose of this.disposeCommands) {
190
- dispose();
191
- }
192
- this.disposeCommands = [];
193
- super.onDetach();
194
- }
195
-
196
- onUpdate(dt: number): void {
197
- if (this.phase === 'intro') {
198
- const tapped = this.input?.pointerEvents.some(e => e.type === 'down') ?? false;
199
- if (tapped) {
200
- // Raised through the registry, so the tap-to-start gate shows up in the
201
- // command journal exactly like a dispatched `start-game` would.
202
- this.scene?.commands.dispatch('start-game');
203
- }
204
- return;
205
- }
206
-
207
- if (this.phase === 'playing') {
208
- const autoWin = Number(this.config.autoWinAfterSec) || 0;
209
- if (autoWin > 0) {
210
- this.elapsed += dt;
211
- if (this.elapsed >= autoWin) {
212
- this.finish();
213
- }
214
- }
215
- }
216
- }
217
-
218
- /** Intent: leave the intro overlay and begin play. Raised by the first tap. */
219
- start(): void {
220
- if (this.phase !== 'intro') {
221
- return;
222
- }
223
- this.openTheGate();
224
- }
225
-
226
- /**
227
- * Hide the gate and go to `playing`. Shared by the tap and by `skipIntro`, so the two
228
- * cannot drift into two slightly different notions of "the run has begun".
229
- */
230
- private openTheGate(): void {
231
- this.setNodeVisible(String(this.config.introNode ?? ''), false);
232
- this.phase = 'playing';
233
- // The run begins HERE, not when the scene loads: a clock started behind the tap gate
234
- // measures how long the overlay was on screen.
235
- this.rules()?.startRun?.();
236
- }
237
-
238
- /** Intent: end the run and reveal the end screen. Call this from game code on win/lose. */
239
- finish(): void {
240
- if (this.phase === 'ended') {
241
- return;
242
- }
243
- this.phase = 'ended';
244
- // Tell the rules before revealing the screen: they write the outcome text into the end
245
- // label, and they must stop ticking — otherwise the run reads as `ended` with no
246
- // outcome, and a time limit fires a second ending behind the end screen.
247
- this.rules()?.endRun?.();
248
- this.setNodeVisible(String(this.config.endNode ?? ''), true);
249
- }
250
-
251
- /**
252
- * Intent: arm a fresh run — back at the tap-to-start gate, or straight into play when
253
- * `skipIntro` is on. A restart that re-raised a gate the project has turned off would
254
- * strand every routine that restarts between checks behind a tap nobody is going to make.
255
- */
256
- restart(): void {
257
- this.phase = 'intro';
258
- this.elapsed = 0;
259
- this.setNodeVisible(String(this.config.endNode ?? ''), false);
260
- this.setNodeVisible(String(this.config.introNode ?? ''), true);
261
- // One intent arms a fresh run everywhere: a restart that puts the gate back but keeps
262
- // last run's score is the kind of half-reset that makes a routine's second pass lie.
263
- this.rules()?.resetRun();
264
- if (this.config.skipIntro === true) {
265
- this.openTheGate();
266
- }
267
- }
268
-
269
- /**
270
- * The bookkeeping component on this node, if the scene has one (`GameRules`).
271
- *
272
- * Duck-typed rather than imported: delete `GameRules` and this template still runs the
273
- * gate, the timer and the CTA exactly as it did before — the two scripts know about each
274
- * other only through the two methods named here.
275
- */
276
- private rules(): RunBookkeeping | null {
277
- for (const component of this.node?.components ?? []) {
278
- const candidate = component as unknown as Partial<RunBookkeeping>;
279
- if (
280
- component !== this &&
281
- typeof candidate.bookkeeping === 'function' &&
282
- typeof candidate.resetRun === 'function'
283
- ) {
284
- return candidate as RunBookkeeping;
285
- }
286
- }
287
- return null;
288
- }
289
-
290
- private setNodeVisible(query: string, visible: boolean): void {
291
- if (!query) {
292
- return;
293
- }
294
- const node = this.findNode(query);
295
- if (node) {
296
- node.visible = visible;
297
- } else {
298
- console.warn(`[GameFlow] Node "${query}" not found.`);
299
- }
300
- }
301
-
302
- /** Own `visible` flag of a configured node, or null when it is not in the scene. */
303
- private isNodeVisible(query: string): boolean | null {
304
- if (!query) {
305
- return null;
306
- }
307
- return this.findNode(query)?.visible ?? null;
308
- }
309
- }
1
+ /**
2
+ * GameFlow — the playable's phase driver: intro → playing → ended.
3
+ *
4
+ * Attach to `hud-root` (the 2D overlay layer above the 3D `game-root`). While
5
+ * the intro overlay is visible, the first tap
6
+ * hides it and starts the game (the tap also unlocks browser audio — the
7
+ * engine's AudioService resumes its context on the first gesture). The end
8
+ * screen is revealed either by calling `finish()` from game code or, as a
9
+ * placeholder, after `autoWinAfterSec` seconds. Replace the timer with your
10
+ * real win/lose condition.
11
+ *
12
+ * `skipIntro` starts the run already playing, gate hidden — a prototyping vent, so
13
+ * that iterating on the game does not cost a tap per reload. Turn it back off before
14
+ * shipping: in an ad container the first tap is what unlocks browser audio, so a
15
+ * playable that never gates on one is a playable that plays silently.
16
+ *
17
+ * `GameRules` on the same node keeps score, lives and the clock, and ends the run through
18
+ * this script's `finish` command — one phase machine, one end screen, one debug provider.
19
+ * Its numbers ride along in the snapshot below.
20
+ *
21
+ * Two conventions here are worth keeping as you replace the placeholder:
22
+ *
23
+ * - **Intent-first handlers.** Every reaction to the player lives in a named
24
+ * public method marked `Intent:` (`start`, `finish`, `restart`), and the ones
25
+ * a test drives are registered as commands (`scene.commands`) that the event
26
+ * handler dispatches. Game code and automated tests then drive the game by
27
+ * intent instead of by simulated pixels — `scene.commands.dispatch('restart')`
28
+ * — the flow survives any relayout of the UI, and every raised intent is
29
+ * journalled with the frame it happened on.
30
+ * - **A debug provider.** `registerGameDebug` publishes a JSON snapshot of the
31
+ * run so external tooling (the editor's debug bridge, DevTools, an agent) can
32
+ * read the phase instead of guessing it from the screen. Add your own fields
33
+ * as you add state — keep everything JSON-serialisable.
34
+ */
35
+ import { Script, playable, registerGameDebug, type PropertySchema } from '@pix3/runtime';
36
+
37
+ /**
38
+ * What this script needs from a bookkeeping component, and the whole of what the two know
39
+ * about each other. `bookkeeping`/`resetRun` are required (they are how the component is
40
+ * recognised); the lifecycle pair is optional so a hand-written replacement can implement
41
+ * only what it uses.
42
+ */
43
+ interface RunBookkeeping {
44
+ bookkeeping(): Record<string, unknown>;
45
+ resetRun(): void;
46
+ startRun?(): void;
47
+ endRun?(won?: boolean): void;
48
+ }
49
+
50
+ export class GameFlow extends Script {
51
+ private phase: 'intro' | 'playing' | 'ended' = 'intro';
52
+ private elapsed = 0;
53
+ private disposeDebug: (() => void) | null = null;
54
+ private disposeCommands: (() => void)[] = [];
55
+
56
+ constructor(id: string, type: string) {
57
+ super(id, type);
58
+ this.config = {
59
+ // Node id/name of the tap-to-start overlay.
60
+ introNode: 'intro-overlay',
61
+ // Node id/name of the end screen with the CTA button.
62
+ endNode: 'end-screen',
63
+ // Placeholder auto-win timer in seconds (0 = never; call finish() instead).
64
+ autoWinAfterSec: 15,
65
+ // Prototyping vent: start in `playing` with the gate already gone.
66
+ skipIntro: false,
67
+ };
68
+ }
69
+
70
+ static getPropertySchema(): PropertySchema {
71
+ return {
72
+ nodeType: 'GameFlow',
73
+ properties: [
74
+ {
75
+ name: 'introNode',
76
+ type: 'string',
77
+ ui: { label: 'Intro Overlay', group: 'Flow' },
78
+ getValue: s => (s as GameFlow).config.introNode,
79
+ setValue: (s, v) => {
80
+ (s as GameFlow).config.introNode = typeof v === 'string' ? v : '';
81
+ },
82
+ },
83
+ {
84
+ name: 'endNode',
85
+ type: 'string',
86
+ ui: { label: 'End Screen', group: 'Flow' },
87
+ getValue: s => (s as GameFlow).config.endNode,
88
+ setValue: (s, v) => {
89
+ (s as GameFlow).config.endNode = typeof v === 'string' ? v : '';
90
+ },
91
+ },
92
+ {
93
+ name: 'autoWinAfterSec',
94
+ type: 'number',
95
+ ui: {
96
+ label: 'Auto Win After (s)',
97
+ description: 'Placeholder timer that reveals the end screen (0 = disabled)',
98
+ group: 'Flow',
99
+ min: 0,
100
+ step: 1,
101
+ },
102
+ getValue: s => (s as GameFlow).config.autoWinAfterSec,
103
+ setValue: (s, v) => {
104
+ (s as GameFlow).config.autoWinAfterSec = Math.max(0, Number(v) || 0);
105
+ },
106
+ },
107
+ {
108
+ name: 'skipIntro',
109
+ type: 'boolean',
110
+ ui: {
111
+ label: 'Skip Intro',
112
+ description:
113
+ 'Start already playing, with the tap gate hidden. For prototyping only — a shipped playable needs the tap to unlock audio.',
114
+ group: 'Flow',
115
+ },
116
+ getValue: s => (s as GameFlow).config.skipIntro === true,
117
+ setValue: (s, v) => {
118
+ (s as GameFlow).config.skipIntro = v === true;
119
+ },
120
+ },
121
+ ],
122
+ groups: { Flow: { label: 'Game Flow', expanded: true } },
123
+ };
124
+ }
125
+
126
+ onStart(): void {
127
+ const commands = this.scene?.commands;
128
+ this.disposeCommands = [
129
+ commands?.register('start-game', () => this.start(), {
130
+ description: 'Leave the intro overlay and begin play.',
131
+ }),
132
+ // `finish` is registered for the same reason `start-game` is: a test (and the
133
+ // rest of the game) must be able to END the run by intent instead of waiting
134
+ // out the placeholder timer — and a routine that reached in and called
135
+ // `finish()` directly would prove the method works while skipping the wire
136
+ // from the player to it. Replace the timer with your win/lose condition and
137
+ // dispatch this from there.
138
+ commands?.register('finish', () => this.finish(), {
139
+ description: 'End the run and reveal the end screen.',
140
+ }),
141
+ commands?.register('restart', () => this.restart(), {
142
+ description: 'Return to the tap-to-start gate and arm a fresh run.',
143
+ }),
144
+ ].filter((dispose): dispose is () => void => dispose !== undefined);
145
+
146
+ this.disposeDebug = registerGameDebug({
147
+ name: 'playable-3d',
148
+ version: 1,
149
+ // The registry IS the action list — never a second, hand-kept copy.
150
+ actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
151
+ // What "the start" means for this playable, so a test can put the game back
152
+ // without restarting the scene (a restart keeps whatever a script parked in
153
+ // module state; this does not). It is `restart()` plus the one thing that
154
+ // lives OUTSIDE the scene and would otherwise leak into the next run: the
155
+ // SDK's session `gameEnded` flag, which `playable.gameEnd()` latched.
156
+ // `seed` is accepted and ignored — the placeholder flow has no randomness,
157
+ // so this restores the same starting state, not the same sequence. Seed
158
+ // your own RNG here once you add one.
159
+ reset: () => {
160
+ this.restart();
161
+ playable.reset();
162
+ },
163
+ snapshot: () => ({
164
+ phase: this.phase,
165
+ elapsedSec: Math.round(this.elapsed * 100) / 100,
166
+ autoWinAfterSec: Number(this.config.autoWinAfterSec) || 0,
167
+ skipIntro: this.config.skipIntro === true,
168
+ introVisible: this.isNodeVisible(String(this.config.introNode ?? '')),
169
+ endVisible: this.isNodeVisible(String(this.config.endNode ?? '')),
170
+ // Set by playable.gameEnd() — the CTA button reports the session as over.
171
+ gameEnded: playable.hasGameEnded(),
172
+ // Score/lives/clock, when the scene carries the bookkeeping component. Merged into
173
+ // THIS snapshot rather than published as a second provider: `registerGameDebug` keeps
174
+ // one, so a second call would silently displace this one.
175
+ ...(this.rules()?.bookkeeping() ?? {}),
176
+ }),
177
+ });
178
+
179
+ if (this.config.skipIntro === true) {
180
+ this.openTheGate();
181
+ }
182
+ }
183
+
184
+ onDetach(): void {
185
+ this.disposeDebug?.();
186
+ this.disposeDebug = null;
187
+ // The scene's registry is cleared on stop anyway; this covers the other
188
+ // case — this script being detached while the scene keeps running.
189
+ for (const dispose of this.disposeCommands) {
190
+ dispose();
191
+ }
192
+ this.disposeCommands = [];
193
+ super.onDetach();
194
+ }
195
+
196
+ onUpdate(dt: number): void {
197
+ if (this.phase === 'intro') {
198
+ const tapped = this.input?.pointerEvents.some(e => e.type === 'down') ?? false;
199
+ if (tapped) {
200
+ // Raised through the registry, so the tap-to-start gate shows up in the
201
+ // command journal exactly like a dispatched `start-game` would.
202
+ this.scene?.commands.dispatch('start-game');
203
+ }
204
+ return;
205
+ }
206
+
207
+ if (this.phase === 'playing') {
208
+ const autoWin = Number(this.config.autoWinAfterSec) || 0;
209
+ if (autoWin > 0) {
210
+ this.elapsed += dt;
211
+ if (this.elapsed >= autoWin) {
212
+ this.finish();
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ /** Intent: leave the intro overlay and begin play. Raised by the first tap. */
219
+ start(): void {
220
+ if (this.phase !== 'intro') {
221
+ return;
222
+ }
223
+ this.openTheGate();
224
+ }
225
+
226
+ /**
227
+ * Hide the gate and go to `playing`. Shared by the tap and by `skipIntro`, so the two
228
+ * cannot drift into two slightly different notions of "the run has begun".
229
+ */
230
+ private openTheGate(): void {
231
+ this.setNodeVisible(String(this.config.introNode ?? ''), false);
232
+ this.phase = 'playing';
233
+ // The run begins HERE, not when the scene loads: a clock started behind the tap gate
234
+ // measures how long the overlay was on screen.
235
+ this.rules()?.startRun?.();
236
+ }
237
+
238
+ /** Intent: end the run and reveal the end screen. Call this from game code on win/lose. */
239
+ finish(): void {
240
+ if (this.phase === 'ended') {
241
+ return;
242
+ }
243
+ this.phase = 'ended';
244
+ // Tell the rules before revealing the screen: they write the outcome text into the end
245
+ // label, and they must stop ticking — otherwise the run reads as `ended` with no
246
+ // outcome, and a time limit fires a second ending behind the end screen.
247
+ this.rules()?.endRun?.();
248
+ this.setNodeVisible(String(this.config.endNode ?? ''), true);
249
+ }
250
+
251
+ /**
252
+ * Intent: arm a fresh run — back at the tap-to-start gate, or straight into play when
253
+ * `skipIntro` is on. A restart that re-raised a gate the project has turned off would
254
+ * strand every routine that restarts between checks behind a tap nobody is going to make.
255
+ */
256
+ restart(): void {
257
+ this.phase = 'intro';
258
+ this.elapsed = 0;
259
+ this.setNodeVisible(String(this.config.endNode ?? ''), false);
260
+ this.setNodeVisible(String(this.config.introNode ?? ''), true);
261
+ // One intent arms a fresh run everywhere: a restart that puts the gate back but keeps
262
+ // last run's score is the kind of half-reset that makes a routine's second pass lie.
263
+ this.rules()?.resetRun();
264
+ if (this.config.skipIntro === true) {
265
+ this.openTheGate();
266
+ }
267
+ }
268
+
269
+ /**
270
+ * The bookkeeping component on this node, if the scene has one (`GameRules`).
271
+ *
272
+ * Duck-typed rather than imported: delete `GameRules` and this template still runs the
273
+ * gate, the timer and the CTA exactly as it did before — the two scripts know about each
274
+ * other only through the two methods named here.
275
+ */
276
+ private rules(): RunBookkeeping | null {
277
+ for (const component of this.node?.components ?? []) {
278
+ const candidate = component as unknown as Partial<RunBookkeeping>;
279
+ if (
280
+ component !== this &&
281
+ typeof candidate.bookkeeping === 'function' &&
282
+ typeof candidate.resetRun === 'function'
283
+ ) {
284
+ return candidate as RunBookkeeping;
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+
290
+ private setNodeVisible(query: string, visible: boolean): void {
291
+ if (!query) {
292
+ return;
293
+ }
294
+ const node = this.findNode(query);
295
+ if (node) {
296
+ node.visible = visible;
297
+ } else {
298
+ console.warn(`[GameFlow] Node "${query}" not found.`);
299
+ }
300
+ }
301
+
302
+ /** Own `visible` flag of a configured node, or null when it is not in the scene. */
303
+ private isNodeVisible(query: string): boolean | null {
304
+ if (!query) {
305
+ return null;
306
+ }
307
+ return this.findNode(query)?.visible ?? null;
308
+ }
309
+ }