@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,278 +1,278 @@
1
- /**
2
- * GridRules — score, lives and the end of the run for a carving board.
3
- *
4
- * Same shape as the other recipes' `GameRules`, with the win condition the board dictates: you win
5
- * by clearing every non-core cube (`board-cleared`), you lose by running out of lives. The optional
6
- * clock is a deadline, not a mode of its own.
7
- *
8
- * Listens on its own node (`game-root`) for what `GridBoard` emits:
9
- * `cell-cleared` (remaining) → +1 score
10
- * `core-hit` (remaining) → −1 life
11
- * `board-cleared` () → win
12
- * `board-built` (clearable, cores) → the target for the HUD
13
- *
14
- * The end-screen buttons dispatch the commands `restart` and `return-to-menu` (`scene.commands`)
15
- * rather than calling methods, so a test can replay the end flow without hunting for buttons.
16
- *
17
- * It publishes the run through `registerGameDebug`: `remaining` is the number that matters here —
18
- * an agent proving "the tap removed a cube" reads it instead of comparing screenshots.
19
- */
20
- import { Button2D, Label2D, Script, registerGameDebug, type PropertySchema } from '@pix3/runtime';
21
-
22
- function roundHundredths(value: number): number {
23
- return Math.round(value * 100) / 100;
24
- }
25
-
26
- export class GridRules extends Script {
27
- private score = 0;
28
- private lives = 3;
29
- private remaining = 0;
30
- private clearable = 0;
31
- private elapsed = 0;
32
- private over = false;
33
- private won = false;
34
- private disposeDebug: (() => void) | null = null;
35
- private disposeCommands: (() => void)[] = [];
36
-
37
- constructor(id: string, type: string) {
38
- super(id, type);
39
- this.config = {
40
- startingLives: 3,
41
- // 0 = no clock. With a limit, running it out loses.
42
- timeLimitSec: 0,
43
- resultNode: 'result-overlay',
44
- resultLabel: 'result-label',
45
- retryButton: 'retry-button',
46
- menuButton: 'menu-button',
47
- winText: 'SHAPE REVEALED',
48
- loseText: 'CORE DESTROYED',
49
- gameScene: 'res://scenes/main.pix3scene',
50
- menuScene: 'res://scenes/menu.pix3scene',
51
- };
52
- }
53
-
54
- static getPropertySchema(): PropertySchema {
55
- const num = (name: string, label: string, min: number, max: number) => ({
56
- name,
57
- type: 'number' as const,
58
- ui: { label, group: 'Rules', min, max, step: 1, slider: true },
59
- getValue: (s: unknown) => (s as GridRules).config[name],
60
- setValue: (s: unknown, v: unknown) => {
61
- const n = Number(v);
62
- (s as GridRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
63
- },
64
- });
65
- const str = (name: string, label: string, group: string) => ({
66
- name,
67
- type: 'string' as const,
68
- ui: { label, group },
69
- getValue: (s: unknown) => (s as GridRules).config[name],
70
- setValue: (s: unknown, v: unknown) => {
71
- (s as GridRules).config[name] = typeof v === 'string' ? v : '';
72
- },
73
- });
74
-
75
- return {
76
- nodeType: 'GridRules',
77
- properties: [
78
- num('startingLives', 'Starting Lives', 1, 9),
79
- num('timeLimitSec', 'Time Limit (s)', 0, 600),
80
- str('resultNode', 'Result Overlay', 'End'),
81
- str('resultLabel', 'Result Label', 'End'),
82
- str('retryButton', 'Retry Button', 'End'),
83
- str('menuButton', 'Menu Button', 'End'),
84
- str('winText', 'Win Text', 'End'),
85
- str('loseText', 'Lose Text', 'End'),
86
- str('gameScene', 'Game Scene', 'Scenes'),
87
- str('menuScene', 'Menu Scene', 'Scenes'),
88
- ],
89
- groups: {
90
- Rules: { label: 'Rules', expanded: true },
91
- End: { label: 'End Screen', expanded: false },
92
- Scenes: { label: 'Scenes', expanded: false },
93
- },
94
- };
95
- }
96
-
97
- onStart(): void {
98
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
99
- const commands = this.scene?.commands;
100
- this.disposeCommands = [
101
- commands?.register('restart', () => this.restart(), {
102
- description: 'Reload the game scene for a fresh board.',
103
- }),
104
- commands?.register('return-to-menu', () => this.toMenu(), {
105
- description: 'Leave the run and go back to the menu scene.',
106
- }),
107
- ].filter((dispose): dispose is () => void => dispose !== undefined);
108
-
109
- this.disposeDebug = registerGameDebug({
110
- name: 'recipe-grid-3d',
111
- version: 1,
112
- actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
113
- snapshot: () => ({
114
- scene: 'game',
115
- phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
116
- score: this.score,
117
- lives: this.lives,
118
- // The board's own number: cubes still to clear. This is what proves a tap landed.
119
- remaining: this.remaining,
120
- clearable: this.clearable,
121
- elapsedSec: roundHundredths(this.elapsed),
122
- timeLeftSec: this.timeLeftSec(),
123
- }),
124
- });
125
-
126
- const owner = this.node;
127
- owner?.connect('board-built', this, (...args: unknown[]) => {
128
- this.clearable = Number(args[0]) || 0;
129
- this.remaining = this.clearable;
130
- this.broadcast();
131
- });
132
- owner?.connect('cell-cleared', this, (...args: unknown[]) => this.onCellCleared(Number(args[0]) || 0));
133
- owner?.connect('core-hit', this, () => this.takeDamage(1));
134
- owner?.connect('board-cleared', this, () => this.finish(true));
135
- owner?.connect('hud-ready', this, () => this.broadcast());
136
-
137
- // Components start in scene order, and the board is listed first — so by now it has already
138
- // built and already emitted `board-built`, into a signal nobody was connected to yet. Pull the
139
- // number as well as listening for it: the connect above covers a later rebuild (or a scene that
140
- // lists the components the other way round), this covers the first one.
141
- this.adoptBoardCount();
142
-
143
- this.setNodeVisible(String(this.config.resultNode ?? ''), false);
144
- // Both end-screen buttons start disabled, because in THIS recipe the menu button lives inside
145
- // the hidden `result-overlay` (the 2D recipes park theirs in the always-visible HUD, where
146
- // enabling it is right). Leaving it enabled read as "you can leave mid-run" while the control
147
- // was invisible and sitting over the middle of the board.
148
- this.bindButton(String(this.config.retryButton ?? ''), () => this.scene?.commands.dispatch('restart'), false);
149
- this.bindButton(String(this.config.menuButton ?? ''), () => this.scene?.commands.dispatch('return-to-menu'), false);
150
- this.broadcast();
151
- }
152
-
153
- onUpdate(dt: number): void {
154
- if (this.over) {
155
- return;
156
- }
157
- this.elapsed += dt;
158
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
159
- this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
160
- if (this.lives <= 0) {
161
- this.finish(false);
162
- return;
163
- }
164
- if (limit > 0 && this.elapsed >= limit) {
165
- this.finish(false);
166
- }
167
- }
168
-
169
- onDetach(): void {
170
- this.disposeDebug?.();
171
- this.disposeDebug = null;
172
- for (const dispose of this.disposeCommands) {
173
- dispose();
174
- }
175
- this.disposeCommands = [];
176
- super.onDetach();
177
- }
178
-
179
- /** End the run. Public so a custom rule can call it. */
180
- finish(won: boolean): void {
181
- if (this.over) {
182
- return;
183
- }
184
- this.over = true;
185
- this.won = won;
186
- const label = this.findNode(String(this.config.resultLabel ?? ''));
187
- if (label instanceof Label2D) {
188
- label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
189
- }
190
- this.setNodeVisible(String(this.config.resultNode ?? ''), true);
191
- this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
192
- this.setButtonEnabled(String(this.config.menuButton ?? ''), true);
193
- this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
194
- }
195
-
196
- /**
197
- * Read the live cube count off whichever sibling component owns the board.
198
- *
199
- * Duck-typed rather than importing `GridBoard`: a project that swaps in its own board keeps
200
- * working as long as it answers `getRemaining()`.
201
- */
202
- private adoptBoardCount(): void {
203
- if (this.clearable > 0) {
204
- return;
205
- }
206
- for (const component of this.node?.components ?? []) {
207
- const getRemaining = (component as { getRemaining?: unknown }).getRemaining;
208
- if (typeof getRemaining === 'function') {
209
- const value = Number((getRemaining as () => number).call(component));
210
- if (Number.isFinite(value) && value > 0) {
211
- this.clearable = value;
212
- this.remaining = value;
213
- return;
214
- }
215
- }
216
- }
217
- }
218
-
219
- private onCellCleared(remaining: number): void {
220
- if (this.over) {
221
- return;
222
- }
223
- this.remaining = remaining;
224
- this.score += 1;
225
- this.node?.emit('score-changed', this.score);
226
- }
227
-
228
- private takeDamage(amount: number): void {
229
- if (this.over) {
230
- return;
231
- }
232
- this.lives = Math.max(0, this.lives - Math.max(1, amount));
233
- this.node?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
234
- }
235
-
236
- private broadcast(): void {
237
- const owner = this.node;
238
- owner?.emit('score-changed', this.score);
239
- owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
240
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
241
- owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
242
- }
243
-
244
- private timeLeftSec(): number | null {
245
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
246
- return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
247
- }
248
-
249
- private restart(): void {
250
- void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
251
- }
252
-
253
- private toMenu(): void {
254
- void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
255
- }
256
-
257
- private bindButton(query: string, handler: () => void, enabled: boolean): void {
258
- const button = this.findNode(query);
259
- if (button instanceof Button2D) {
260
- button.enabled = enabled;
261
- button.connect('pressed', this, handler);
262
- }
263
- }
264
-
265
- private setButtonEnabled(query: string, enabled: boolean): void {
266
- const button = this.findNode(query);
267
- if (button instanceof Button2D) {
268
- button.enabled = enabled;
269
- }
270
- }
271
-
272
- private setNodeVisible(query: string, visible: boolean): void {
273
- const node = this.findNode(query);
274
- if (node) {
275
- node.visible = visible;
276
- }
277
- }
278
- }
1
+ /**
2
+ * GridRules — score, lives and the end of the run for a carving board.
3
+ *
4
+ * Same shape as the other recipes' `GameRules`, with the win condition the board dictates: you win
5
+ * by clearing every non-core cube (`board-cleared`), you lose by running out of lives. The optional
6
+ * clock is a deadline, not a mode of its own.
7
+ *
8
+ * Listens on its own node (`game-root`) for what `GridBoard` emits:
9
+ * `cell-cleared` (remaining) → +1 score
10
+ * `core-hit` (remaining) → −1 life
11
+ * `board-cleared` () → win
12
+ * `board-built` (clearable, cores) → the target for the HUD
13
+ *
14
+ * The end-screen buttons dispatch the commands `restart` and `return-to-menu` (`scene.commands`)
15
+ * rather than calling methods, so a test can replay the end flow without hunting for buttons.
16
+ *
17
+ * It publishes the run through `registerGameDebug`: `remaining` is the number that matters here —
18
+ * an agent proving "the tap removed a cube" reads it instead of comparing screenshots.
19
+ */
20
+ import { Button2D, Label2D, Script, registerGameDebug, type PropertySchema } from '@pix3/runtime';
21
+
22
+ function roundHundredths(value: number): number {
23
+ return Math.round(value * 100) / 100;
24
+ }
25
+
26
+ export class GridRules extends Script {
27
+ private score = 0;
28
+ private lives = 3;
29
+ private remaining = 0;
30
+ private clearable = 0;
31
+ private elapsed = 0;
32
+ private over = false;
33
+ private won = false;
34
+ private disposeDebug: (() => void) | null = null;
35
+ private disposeCommands: (() => void)[] = [];
36
+
37
+ constructor(id: string, type: string) {
38
+ super(id, type);
39
+ this.config = {
40
+ startingLives: 3,
41
+ // 0 = no clock. With a limit, running it out loses.
42
+ timeLimitSec: 0,
43
+ resultNode: 'result-overlay',
44
+ resultLabel: 'result-label',
45
+ retryButton: 'retry-button',
46
+ menuButton: 'menu-button',
47
+ winText: 'SHAPE REVEALED',
48
+ loseText: 'CORE DESTROYED',
49
+ gameScene: 'res://scenes/main.pix3scene',
50
+ menuScene: 'res://scenes/menu.pix3scene',
51
+ };
52
+ }
53
+
54
+ static getPropertySchema(): PropertySchema {
55
+ const num = (name: string, label: string, min: number, max: number) => ({
56
+ name,
57
+ type: 'number' as const,
58
+ ui: { label, group: 'Rules', min, max, step: 1, slider: true },
59
+ getValue: (s: unknown) => (s as GridRules).config[name],
60
+ setValue: (s: unknown, v: unknown) => {
61
+ const n = Number(v);
62
+ (s as GridRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
63
+ },
64
+ });
65
+ const str = (name: string, label: string, group: string) => ({
66
+ name,
67
+ type: 'string' as const,
68
+ ui: { label, group },
69
+ getValue: (s: unknown) => (s as GridRules).config[name],
70
+ setValue: (s: unknown, v: unknown) => {
71
+ (s as GridRules).config[name] = typeof v === 'string' ? v : '';
72
+ },
73
+ });
74
+
75
+ return {
76
+ nodeType: 'GridRules',
77
+ properties: [
78
+ num('startingLives', 'Starting Lives', 1, 9),
79
+ num('timeLimitSec', 'Time Limit (s)', 0, 600),
80
+ str('resultNode', 'Result Overlay', 'End'),
81
+ str('resultLabel', 'Result Label', 'End'),
82
+ str('retryButton', 'Retry Button', 'End'),
83
+ str('menuButton', 'Menu Button', 'End'),
84
+ str('winText', 'Win Text', 'End'),
85
+ str('loseText', 'Lose Text', 'End'),
86
+ str('gameScene', 'Game Scene', 'Scenes'),
87
+ str('menuScene', 'Menu Scene', 'Scenes'),
88
+ ],
89
+ groups: {
90
+ Rules: { label: 'Rules', expanded: true },
91
+ End: { label: 'End Screen', expanded: false },
92
+ Scenes: { label: 'Scenes', expanded: false },
93
+ },
94
+ };
95
+ }
96
+
97
+ onStart(): void {
98
+ this.lives = Math.max(1, Number(this.config.startingLives) || 1);
99
+ const commands = this.scene?.commands;
100
+ this.disposeCommands = [
101
+ commands?.register('restart', () => this.restart(), {
102
+ description: 'Reload the game scene for a fresh board.',
103
+ }),
104
+ commands?.register('return-to-menu', () => this.toMenu(), {
105
+ description: 'Leave the run and go back to the menu scene.',
106
+ }),
107
+ ].filter((dispose): dispose is () => void => dispose !== undefined);
108
+
109
+ this.disposeDebug = registerGameDebug({
110
+ name: 'recipe-grid-3d',
111
+ version: 1,
112
+ actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
113
+ snapshot: () => ({
114
+ scene: 'game',
115
+ phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
116
+ score: this.score,
117
+ lives: this.lives,
118
+ // The board's own number: cubes still to clear. This is what proves a tap landed.
119
+ remaining: this.remaining,
120
+ clearable: this.clearable,
121
+ elapsedSec: roundHundredths(this.elapsed),
122
+ timeLeftSec: this.timeLeftSec(),
123
+ }),
124
+ });
125
+
126
+ const owner = this.node;
127
+ owner?.connect('board-built', this, (...args: unknown[]) => {
128
+ this.clearable = Number(args[0]) || 0;
129
+ this.remaining = this.clearable;
130
+ this.broadcast();
131
+ });
132
+ owner?.connect('cell-cleared', this, (...args: unknown[]) => this.onCellCleared(Number(args[0]) || 0));
133
+ owner?.connect('core-hit', this, () => this.takeDamage(1));
134
+ owner?.connect('board-cleared', this, () => this.finish(true));
135
+ owner?.connect('hud-ready', this, () => this.broadcast());
136
+
137
+ // Components start in scene order, and the board is listed first — so by now it has already
138
+ // built and already emitted `board-built`, into a signal nobody was connected to yet. Pull the
139
+ // number as well as listening for it: the connect above covers a later rebuild (or a scene that
140
+ // lists the components the other way round), this covers the first one.
141
+ this.adoptBoardCount();
142
+
143
+ this.setNodeVisible(String(this.config.resultNode ?? ''), false);
144
+ // Both end-screen buttons start disabled, because in THIS recipe the menu button lives inside
145
+ // the hidden `result-overlay` (the 2D recipes park theirs in the always-visible HUD, where
146
+ // enabling it is right). Leaving it enabled read as "you can leave mid-run" while the control
147
+ // was invisible and sitting over the middle of the board.
148
+ this.bindButton(String(this.config.retryButton ?? ''), () => this.scene?.commands.dispatch('restart'), false);
149
+ this.bindButton(String(this.config.menuButton ?? ''), () => this.scene?.commands.dispatch('return-to-menu'), false);
150
+ this.broadcast();
151
+ }
152
+
153
+ onUpdate(dt: number): void {
154
+ if (this.over) {
155
+ return;
156
+ }
157
+ this.elapsed += dt;
158
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
159
+ this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
160
+ if (this.lives <= 0) {
161
+ this.finish(false);
162
+ return;
163
+ }
164
+ if (limit > 0 && this.elapsed >= limit) {
165
+ this.finish(false);
166
+ }
167
+ }
168
+
169
+ onDetach(): void {
170
+ this.disposeDebug?.();
171
+ this.disposeDebug = null;
172
+ for (const dispose of this.disposeCommands) {
173
+ dispose();
174
+ }
175
+ this.disposeCommands = [];
176
+ super.onDetach();
177
+ }
178
+
179
+ /** End the run. Public so a custom rule can call it. */
180
+ finish(won: boolean): void {
181
+ if (this.over) {
182
+ return;
183
+ }
184
+ this.over = true;
185
+ this.won = won;
186
+ const label = this.findNode(String(this.config.resultLabel ?? ''));
187
+ if (label instanceof Label2D) {
188
+ label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
189
+ }
190
+ this.setNodeVisible(String(this.config.resultNode ?? ''), true);
191
+ this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
192
+ this.setButtonEnabled(String(this.config.menuButton ?? ''), true);
193
+ this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
194
+ }
195
+
196
+ /**
197
+ * Read the live cube count off whichever sibling component owns the board.
198
+ *
199
+ * Duck-typed rather than importing `GridBoard`: a project that swaps in its own board keeps
200
+ * working as long as it answers `getRemaining()`.
201
+ */
202
+ private adoptBoardCount(): void {
203
+ if (this.clearable > 0) {
204
+ return;
205
+ }
206
+ for (const component of this.node?.components ?? []) {
207
+ const getRemaining = (component as { getRemaining?: unknown }).getRemaining;
208
+ if (typeof getRemaining === 'function') {
209
+ const value = Number((getRemaining as () => number).call(component));
210
+ if (Number.isFinite(value) && value > 0) {
211
+ this.clearable = value;
212
+ this.remaining = value;
213
+ return;
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ private onCellCleared(remaining: number): void {
220
+ if (this.over) {
221
+ return;
222
+ }
223
+ this.remaining = remaining;
224
+ this.score += 1;
225
+ this.node?.emit('score-changed', this.score);
226
+ }
227
+
228
+ private takeDamage(amount: number): void {
229
+ if (this.over) {
230
+ return;
231
+ }
232
+ this.lives = Math.max(0, this.lives - Math.max(1, amount));
233
+ this.node?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
234
+ }
235
+
236
+ private broadcast(): void {
237
+ const owner = this.node;
238
+ owner?.emit('score-changed', this.score);
239
+ owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
240
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
241
+ owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
242
+ }
243
+
244
+ private timeLeftSec(): number | null {
245
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
246
+ return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
247
+ }
248
+
249
+ private restart(): void {
250
+ void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
251
+ }
252
+
253
+ private toMenu(): void {
254
+ void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
255
+ }
256
+
257
+ private bindButton(query: string, handler: () => void, enabled: boolean): void {
258
+ const button = this.findNode(query);
259
+ if (button instanceof Button2D) {
260
+ button.enabled = enabled;
261
+ button.connect('pressed', this, handler);
262
+ }
263
+ }
264
+
265
+ private setButtonEnabled(query: string, enabled: boolean): void {
266
+ const button = this.findNode(query);
267
+ if (button instanceof Button2D) {
268
+ button.enabled = enabled;
269
+ }
270
+ }
271
+
272
+ private setNodeVisible(query: string, visible: boolean): void {
273
+ const node = this.findNode(query);
274
+ if (node) {
275
+ node.visible = visible;
276
+ }
277
+ }
278
+ }