@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,368 +1,368 @@
1
- /**
2
- * GameRules — score, lives, timer, win/lose and the end flow.
3
- *
4
- * Listens on its own node for the semantic signals `TouchRules` emits
5
- * (`touch-scored` / `touch-damaged`), keeps the run state, and re-broadcasts
6
- * `score-changed` / `lives-changed` / `time-changed` for the HUD. It is the only
7
- * script that decides when the run is over.
8
- *
9
- * Win modes:
10
- * `score` — reach `targetScore`; the time limit (if any) is a deadline.
11
- * `time` — play until the timer ends, then win if `targetScore` was reached.
12
- * `survive` — stay alive until the timer ends; reaching it is the win.
13
- * In every mode, running out of lives loses.
14
- *
15
- * The end-screen buttons dispatch the commands `restart` and `return-to-menu`
16
- * (`scene.commands`) instead of calling their methods: a test replays the end
17
- * flow without hunting for buttons, the run's intents are journalled with the
18
- * frame they happened on, and one real tap proves each binding once.
19
- *
20
- * It also publishes the run's state through `registerGameDebug`: this script
21
- * owns everything a test needs to read (score, lives, clock, outcome), so tools
22
- * and agents can assert on values instead of guessing from pixels. Add your own
23
- * state to `snapshot()` as you extend the rules — keep every value JSON-safe.
24
- */
25
- import {
26
- Button2D,
27
- Label2D,
28
- NodeBase,
29
- Script,
30
- registerGameDebug,
31
- type PropertySchema,
32
- } from '@pix3/runtime';
33
-
34
- type WinMode = 'score' | 'time' | 'survive';
35
-
36
- /** Snapshot numbers are read by humans and diffed by tools — keep them short. */
37
- function roundHundredths(value: number): number {
38
- return Math.round(value * 100) / 100;
39
- }
40
-
41
- export class GameRules extends Script {
42
- private score = 0;
43
- private lives = 3;
44
- private elapsed = 0;
45
- private over = false;
46
- private won = false;
47
- private disposeDebug: (() => void) | null = null;
48
- private disposeCommands: (() => void)[] = [];
49
- /** Components `finish()` switched off, so `resetRun()` revives exactly those. */
50
- private frozen: { enabled: boolean }[] = [];
51
-
52
- constructor(id: string, type: string) {
53
- super(id, type);
54
- this.config = {
55
- winMode: 'time',
56
- targetScore: 18,
57
- timeLimitSec: 30,
58
- startingLives: 3,
59
- resultNode: 'result-overlay',
60
- resultLabel: 'result-label',
61
- winText: 'YOU WIN!',
62
- loseText: 'GAME OVER',
63
- retryButton: 'retry-button',
64
- menuButton: 'menu-button',
65
- menuScene: 'res://scenes/menu.pix3scene',
66
- gameScene: 'res://scenes/main.pix3scene',
67
- // Comma-separated node ids frozen on game over: every component on the node and
68
- // on everything under it (spawned instances included) is disabled.
69
- freezeNodes: 'spawner-targets,spawner-hazards',
70
- };
71
- }
72
-
73
- static getPropertySchema(): PropertySchema {
74
- const num = (name: string, label: string, min: number, max: number, step: number) => ({
75
- name,
76
- type: 'number' as const,
77
- ui: { label, group: 'Rules', min, max, step, slider: true },
78
- getValue: (s: unknown) => (s as GameRules).config[name],
79
- setValue: (s: unknown, v: unknown) => {
80
- const n = Number(v);
81
- (s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
82
- },
83
- });
84
- const str = (name: string, label: string, group: string) => ({
85
- name,
86
- type: 'string' as const,
87
- ui: { label, group },
88
- getValue: (s: unknown) => (s as GameRules).config[name],
89
- setValue: (s: unknown, v: unknown) => {
90
- (s as GameRules).config[name] = typeof v === 'string' ? v : '';
91
- },
92
- });
93
-
94
- return {
95
- nodeType: 'GameRules',
96
- properties: [
97
- {
98
- name: 'winMode',
99
- type: 'select',
100
- ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
101
- getValue: s => (s as GameRules).config.winMode,
102
- setValue: (s, v) => {
103
- (s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
104
- },
105
- },
106
- num('targetScore', 'Target Score', 1, 99999, 1),
107
- num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
108
- num('startingLives', 'Starting Lives', 1, 20, 1),
109
- str('winText', 'Win Text', 'Result'),
110
- str('loseText', 'Lose Text', 'Result'),
111
- str('resultNode', 'Result Overlay', 'Result'),
112
- str('resultLabel', 'Result Label', 'Result'),
113
- str('retryButton', 'Retry Button', 'Flow'),
114
- str('menuButton', 'Menu Button', 'Flow'),
115
- str('menuScene', 'Menu Scene', 'Flow'),
116
- str('gameScene', 'Game Scene', 'Flow'),
117
- str('freezeNodes', 'Freeze Nodes', 'Flow'),
118
- ],
119
- groups: {
120
- Rules: { label: 'Rules', expanded: true },
121
- Result: { label: 'Result Screen', expanded: true },
122
- Flow: { label: 'Flow', expanded: false },
123
- },
124
- };
125
- }
126
-
127
- onStart(): void {
128
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
129
- const commands = this.scene?.commands;
130
- this.disposeCommands = [
131
- commands?.register('restart', () => this.restart(), {
132
- description: 'Reload the game scene for a fresh run.',
133
- }),
134
- commands?.register('return-to-menu', () => this.toMenu(), {
135
- description: 'Leave the run and transition back to the menu scene.',
136
- }),
137
- ].filter((dispose): dispose is () => void => dispose !== undefined);
138
-
139
- this.disposeDebug = registerGameDebug({
140
- name: 'recipe-tapper-2d',
141
- version: 1,
142
- // The registry IS the action list — never a second, hand-kept copy.
143
- actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
144
- // What "the start" means for a run, so a test can put the game back without
145
- // reloading the scene — a reload restores the scene graph but not what a
146
- // script parked outside it, which is why tooling has to label the two
147
- // differently. `seed` is accepted and ignored here: the recipe's spawners
148
- // draw from `Math.random`, so this restores the same starting STATE, not the
149
- // same SEQUENCE. Wire it to a seeded RNG if you need replayable runs.
150
- reset: () => this.resetRun(),
151
- snapshot: () => ({
152
- scene: 'game',
153
- phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
154
- score: this.score,
155
- lives: this.lives,
156
- winMode: String(this.config.winMode ?? 'score'),
157
- targetScore: Math.max(1, Number(this.config.targetScore) || 1),
158
- elapsedSec: roundHundredths(this.elapsed),
159
- // null when the mode has no clock at all.
160
- timeLeftSec: this.timeLeftSec(),
161
- }),
162
- });
163
-
164
- const owner = this.node;
165
- owner?.connect('touch-scored', this, (...args: unknown[]) =>
166
- this.addScore(Number(args[0]) || 0)
167
- );
168
- owner?.connect('touch-damaged', this, (...args: unknown[]) =>
169
- this.takeDamage(Number(args[0]) || 0)
170
- );
171
- // The HUD announces itself once it is connected, so it never misses the
172
- // opening values (its onStart runs after this one).
173
- owner?.connect('hud-ready', this, () => this.broadcast());
174
-
175
- this.setNodeVisible(String(this.config.resultNode ?? ''), false);
176
- this.bindButton(
177
- String(this.config.retryButton ?? ''),
178
- () => this.scene?.commands.dispatch('restart'),
179
- false
180
- );
181
- this.bindButton(
182
- String(this.config.menuButton ?? ''),
183
- () => this.scene?.commands.dispatch('return-to-menu'),
184
- true
185
- );
186
- this.broadcast();
187
- }
188
-
189
- onUpdate(dt: number): void {
190
- if (this.over) {
191
- return;
192
- }
193
- this.elapsed += dt;
194
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
195
- this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
196
-
197
- if (this.lives <= 0) {
198
- this.finish(false);
199
- return;
200
- }
201
- const raw = String(this.config.winMode ?? 'score');
202
- const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
203
- const target = Math.max(1, Number(this.config.targetScore) || 1);
204
- const timeUp = limit > 0 && this.elapsed >= limit;
205
-
206
- if (mode === 'score') {
207
- if (this.score >= target) this.finish(true);
208
- else if (timeUp) this.finish(false);
209
- } else if (mode === 'time') {
210
- if (timeUp) this.finish(this.score >= target);
211
- } else if (timeUp) {
212
- this.finish(true);
213
- }
214
- }
215
-
216
- onDetach(): void {
217
- // Clearing is safe in any order: the disposer only clears the global when it
218
- // still holds *this* provider, so a scene that already registered its own wins.
219
- this.disposeDebug?.();
220
- this.disposeDebug = null;
221
- // The scene's registry is cleared on stop anyway; this covers the other
222
- // case — this script being detached while the scene keeps running.
223
- for (const dispose of this.disposeCommands) {
224
- dispose();
225
- }
226
- this.disposeCommands = [];
227
- super.onDetach();
228
- }
229
-
230
- /** Seconds left on the clock, or `null` when this run has no time limit. */
231
- private timeLeftSec(): number | null {
232
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
233
- return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
234
- }
235
-
236
- private addScore(amount: number): void {
237
- if (this.over) return;
238
- this.score += amount;
239
- this.node?.emit('score-changed', this.score);
240
- }
241
-
242
- private takeDamage(amount: number): void {
243
- if (this.over) return;
244
- this.lives = Math.max(0, this.lives - Math.max(1, amount));
245
- this.node?.emit(
246
- 'lives-changed',
247
- this.lives,
248
- Math.max(1, Number(this.config.startingLives) || 1)
249
- );
250
- }
251
-
252
- private broadcast(): void {
253
- const owner = this.node;
254
- owner?.emit('score-changed', this.score);
255
- owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
256
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
257
- owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
258
- }
259
-
260
- /** End the run. Call this from your own code for a custom win/lose condition. */
261
- finish(won: boolean): void {
262
- if (this.over) return;
263
- this.over = true;
264
- this.won = won;
265
-
266
- const label = this.findNode(String(this.config.resultLabel ?? ''));
267
- if (label instanceof Label2D) {
268
- label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
269
- }
270
- this.setNodeVisible(String(this.config.resultNode ?? ''), true);
271
- this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
272
-
273
- // A freeze node freezes its whole subtree: the spawners' own components AND
274
- // every instance they spawned under themselves (a chaser's script, a pickup's
275
- // pop-in), so nothing keeps moving over the result card.
276
- for (const id of String(this.config.freezeNodes ?? '').split(',')) {
277
- const root = this.findNode(id.trim());
278
- if (!root) continue;
279
- root.traverse(object => {
280
- if (!(object instanceof NodeBase)) return;
281
- for (const component of object.components) {
282
- // Only remember what WE switched off: reviving everything on reset would
283
- // silently enable a component the scene author had left disabled.
284
- if (!component.enabled) continue;
285
- component.enabled = false;
286
- this.frozen.push(component);
287
- }
288
- });
289
- }
290
- this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
291
- }
292
-
293
- private restart(): void {
294
- void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
295
- }
296
-
297
- private toMenu(): void {
298
- void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
299
- }
300
-
301
- /**
302
- * Back to the state `onStart` left behind: score, lives, clock and outcome, the
303
- * result overlay hidden, the freeze `finish()` applied lifted, and anything a
304
- * spawner still has on the field despawned. Those leftovers are state too — a
305
- * run that begins amid the previous run's targets did not begin from the start.
306
- *
307
- * Public because it is useful on its own (an in-run "try again" that skips the
308
- * scene reload); the debug provider's `reset` is this method.
309
- */
310
- resetRun(): void {
311
- for (const component of this.frozen) {
312
- component.enabled = true;
313
- }
314
- this.frozen = [];
315
- this.clearSpawned();
316
-
317
- this.score = 0;
318
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
319
- this.elapsed = 0;
320
- this.over = false;
321
- this.won = false;
322
-
323
- this.setNodeVisible(String(this.config.resultNode ?? ''), false);
324
- this.setButtonEnabled(String(this.config.retryButton ?? ''), false);
325
- this.broadcast();
326
- }
327
-
328
- /**
329
- * Ask every component on a `freezeNodes` node that owns spawned instances to
330
- * drop them. Duck-typed instead of importing `Spawner`: the freeze list is
331
- * whatever the scene author wrote there, and a project may put its own spawner
332
- * in it — one that also happens to offer `clear()`.
333
- */
334
- private clearSpawned(): void {
335
- for (const id of String(this.config.freezeNodes ?? '').split(',')) {
336
- const node = this.findNode(id.trim());
337
- if (!node) continue;
338
- for (const component of node.components) {
339
- const clear = (component as { clear?: unknown }).clear;
340
- if (typeof clear === 'function') {
341
- (clear as () => void).call(component);
342
- }
343
- }
344
- }
345
- }
346
-
347
- private bindButton(query: string, handler: () => void, enabled: boolean): void {
348
- const button = this.findNode(query);
349
- if (button instanceof Button2D) {
350
- button.enabled = enabled;
351
- button.connect('pressed', this, handler);
352
- }
353
- }
354
-
355
- private setButtonEnabled(query: string, enabled: boolean): void {
356
- const button = this.findNode(query);
357
- if (button instanceof Button2D) {
358
- button.enabled = enabled;
359
- }
360
- }
361
-
362
- private setNodeVisible(query: string, visible: boolean): void {
363
- const node = this.findNode(query);
364
- if (node) {
365
- node.visible = visible;
366
- }
367
- }
368
- }
1
+ /**
2
+ * GameRules — score, lives, timer, win/lose and the end flow.
3
+ *
4
+ * Listens on its own node for the semantic signals `TouchRules` emits
5
+ * (`touch-scored` / `touch-damaged`), keeps the run state, and re-broadcasts
6
+ * `score-changed` / `lives-changed` / `time-changed` for the HUD. It is the only
7
+ * script that decides when the run is over.
8
+ *
9
+ * Win modes:
10
+ * `score` — reach `targetScore`; the time limit (if any) is a deadline.
11
+ * `time` — play until the timer ends, then win if `targetScore` was reached.
12
+ * `survive` — stay alive until the timer ends; reaching it is the win.
13
+ * In every mode, running out of lives loses.
14
+ *
15
+ * The end-screen buttons dispatch the commands `restart` and `return-to-menu`
16
+ * (`scene.commands`) instead of calling their methods: a test replays the end
17
+ * flow without hunting for buttons, the run's intents are journalled with the
18
+ * frame they happened on, and one real tap proves each binding once.
19
+ *
20
+ * It also publishes the run's state through `registerGameDebug`: this script
21
+ * owns everything a test needs to read (score, lives, clock, outcome), so tools
22
+ * and agents can assert on values instead of guessing from pixels. Add your own
23
+ * state to `snapshot()` as you extend the rules — keep every value JSON-safe.
24
+ */
25
+ import {
26
+ Button2D,
27
+ Label2D,
28
+ NodeBase,
29
+ Script,
30
+ registerGameDebug,
31
+ type PropertySchema,
32
+ } from '@pix3/runtime';
33
+
34
+ type WinMode = 'score' | 'time' | 'survive';
35
+
36
+ /** Snapshot numbers are read by humans and diffed by tools — keep them short. */
37
+ function roundHundredths(value: number): number {
38
+ return Math.round(value * 100) / 100;
39
+ }
40
+
41
+ export class GameRules extends Script {
42
+ private score = 0;
43
+ private lives = 3;
44
+ private elapsed = 0;
45
+ private over = false;
46
+ private won = false;
47
+ private disposeDebug: (() => void) | null = null;
48
+ private disposeCommands: (() => void)[] = [];
49
+ /** Components `finish()` switched off, so `resetRun()` revives exactly those. */
50
+ private frozen: { enabled: boolean }[] = [];
51
+
52
+ constructor(id: string, type: string) {
53
+ super(id, type);
54
+ this.config = {
55
+ winMode: 'time',
56
+ targetScore: 18,
57
+ timeLimitSec: 30,
58
+ startingLives: 3,
59
+ resultNode: 'result-overlay',
60
+ resultLabel: 'result-label',
61
+ winText: 'YOU WIN!',
62
+ loseText: 'GAME OVER',
63
+ retryButton: 'retry-button',
64
+ menuButton: 'menu-button',
65
+ menuScene: 'res://scenes/menu.pix3scene',
66
+ gameScene: 'res://scenes/main.pix3scene',
67
+ // Comma-separated node ids frozen on game over: every component on the node and
68
+ // on everything under it (spawned instances included) is disabled.
69
+ freezeNodes: 'spawner-targets,spawner-hazards',
70
+ };
71
+ }
72
+
73
+ static getPropertySchema(): PropertySchema {
74
+ const num = (name: string, label: string, min: number, max: number, step: number) => ({
75
+ name,
76
+ type: 'number' as const,
77
+ ui: { label, group: 'Rules', min, max, step, slider: true },
78
+ getValue: (s: unknown) => (s as GameRules).config[name],
79
+ setValue: (s: unknown, v: unknown) => {
80
+ const n = Number(v);
81
+ (s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
82
+ },
83
+ });
84
+ const str = (name: string, label: string, group: string) => ({
85
+ name,
86
+ type: 'string' as const,
87
+ ui: { label, group },
88
+ getValue: (s: unknown) => (s as GameRules).config[name],
89
+ setValue: (s: unknown, v: unknown) => {
90
+ (s as GameRules).config[name] = typeof v === 'string' ? v : '';
91
+ },
92
+ });
93
+
94
+ return {
95
+ nodeType: 'GameRules',
96
+ properties: [
97
+ {
98
+ name: 'winMode',
99
+ type: 'select',
100
+ ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
101
+ getValue: s => (s as GameRules).config.winMode,
102
+ setValue: (s, v) => {
103
+ (s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
104
+ },
105
+ },
106
+ num('targetScore', 'Target Score', 1, 99999, 1),
107
+ num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
108
+ num('startingLives', 'Starting Lives', 1, 20, 1),
109
+ str('winText', 'Win Text', 'Result'),
110
+ str('loseText', 'Lose Text', 'Result'),
111
+ str('resultNode', 'Result Overlay', 'Result'),
112
+ str('resultLabel', 'Result Label', 'Result'),
113
+ str('retryButton', 'Retry Button', 'Flow'),
114
+ str('menuButton', 'Menu Button', 'Flow'),
115
+ str('menuScene', 'Menu Scene', 'Flow'),
116
+ str('gameScene', 'Game Scene', 'Flow'),
117
+ str('freezeNodes', 'Freeze Nodes', 'Flow'),
118
+ ],
119
+ groups: {
120
+ Rules: { label: 'Rules', expanded: true },
121
+ Result: { label: 'Result Screen', expanded: true },
122
+ Flow: { label: 'Flow', expanded: false },
123
+ },
124
+ };
125
+ }
126
+
127
+ onStart(): void {
128
+ this.lives = Math.max(1, Number(this.config.startingLives) || 1);
129
+ const commands = this.scene?.commands;
130
+ this.disposeCommands = [
131
+ commands?.register('restart', () => this.restart(), {
132
+ description: 'Reload the game scene for a fresh run.',
133
+ }),
134
+ commands?.register('return-to-menu', () => this.toMenu(), {
135
+ description: 'Leave the run and transition back to the menu scene.',
136
+ }),
137
+ ].filter((dispose): dispose is () => void => dispose !== undefined);
138
+
139
+ this.disposeDebug = registerGameDebug({
140
+ name: 'recipe-tapper-2d',
141
+ version: 1,
142
+ // The registry IS the action list — never a second, hand-kept copy.
143
+ actions: () => this.scene?.commands.list().map(command => command.name) ?? [],
144
+ // What "the start" means for a run, so a test can put the game back without
145
+ // reloading the scene — a reload restores the scene graph but not what a
146
+ // script parked outside it, which is why tooling has to label the two
147
+ // differently. `seed` is accepted and ignored here: the recipe's spawners
148
+ // draw from `Math.random`, so this restores the same starting STATE, not the
149
+ // same SEQUENCE. Wire it to a seeded RNG if you need replayable runs.
150
+ reset: () => this.resetRun(),
151
+ snapshot: () => ({
152
+ scene: 'game',
153
+ phase: this.over ? (this.won ? 'won' : 'lost') : 'playing',
154
+ score: this.score,
155
+ lives: this.lives,
156
+ winMode: String(this.config.winMode ?? 'score'),
157
+ targetScore: Math.max(1, Number(this.config.targetScore) || 1),
158
+ elapsedSec: roundHundredths(this.elapsed),
159
+ // null when the mode has no clock at all.
160
+ timeLeftSec: this.timeLeftSec(),
161
+ }),
162
+ });
163
+
164
+ const owner = this.node;
165
+ owner?.connect('touch-scored', this, (...args: unknown[]) =>
166
+ this.addScore(Number(args[0]) || 0)
167
+ );
168
+ owner?.connect('touch-damaged', this, (...args: unknown[]) =>
169
+ this.takeDamage(Number(args[0]) || 0)
170
+ );
171
+ // The HUD announces itself once it is connected, so it never misses the
172
+ // opening values (its onStart runs after this one).
173
+ owner?.connect('hud-ready', this, () => this.broadcast());
174
+
175
+ this.setNodeVisible(String(this.config.resultNode ?? ''), false);
176
+ this.bindButton(
177
+ String(this.config.retryButton ?? ''),
178
+ () => this.scene?.commands.dispatch('restart'),
179
+ false
180
+ );
181
+ this.bindButton(
182
+ String(this.config.menuButton ?? ''),
183
+ () => this.scene?.commands.dispatch('return-to-menu'),
184
+ true
185
+ );
186
+ this.broadcast();
187
+ }
188
+
189
+ onUpdate(dt: number): void {
190
+ if (this.over) {
191
+ return;
192
+ }
193
+ this.elapsed += dt;
194
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
195
+ this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
196
+
197
+ if (this.lives <= 0) {
198
+ this.finish(false);
199
+ return;
200
+ }
201
+ const raw = String(this.config.winMode ?? 'score');
202
+ const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
203
+ const target = Math.max(1, Number(this.config.targetScore) || 1);
204
+ const timeUp = limit > 0 && this.elapsed >= limit;
205
+
206
+ if (mode === 'score') {
207
+ if (this.score >= target) this.finish(true);
208
+ else if (timeUp) this.finish(false);
209
+ } else if (mode === 'time') {
210
+ if (timeUp) this.finish(this.score >= target);
211
+ } else if (timeUp) {
212
+ this.finish(true);
213
+ }
214
+ }
215
+
216
+ onDetach(): void {
217
+ // Clearing is safe in any order: the disposer only clears the global when it
218
+ // still holds *this* provider, so a scene that already registered its own wins.
219
+ this.disposeDebug?.();
220
+ this.disposeDebug = null;
221
+ // The scene's registry is cleared on stop anyway; this covers the other
222
+ // case — this script being detached while the scene keeps running.
223
+ for (const dispose of this.disposeCommands) {
224
+ dispose();
225
+ }
226
+ this.disposeCommands = [];
227
+ super.onDetach();
228
+ }
229
+
230
+ /** Seconds left on the clock, or `null` when this run has no time limit. */
231
+ private timeLeftSec(): number | null {
232
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
233
+ return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
234
+ }
235
+
236
+ private addScore(amount: number): void {
237
+ if (this.over) return;
238
+ this.score += amount;
239
+ this.node?.emit('score-changed', this.score);
240
+ }
241
+
242
+ private takeDamage(amount: number): void {
243
+ if (this.over) return;
244
+ this.lives = Math.max(0, this.lives - Math.max(1, amount));
245
+ this.node?.emit(
246
+ 'lives-changed',
247
+ this.lives,
248
+ Math.max(1, Number(this.config.startingLives) || 1)
249
+ );
250
+ }
251
+
252
+ private broadcast(): void {
253
+ const owner = this.node;
254
+ owner?.emit('score-changed', this.score);
255
+ owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
256
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
257
+ owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
258
+ }
259
+
260
+ /** End the run. Call this from your own code for a custom win/lose condition. */
261
+ finish(won: boolean): void {
262
+ if (this.over) return;
263
+ this.over = true;
264
+ this.won = won;
265
+
266
+ const label = this.findNode(String(this.config.resultLabel ?? ''));
267
+ if (label instanceof Label2D) {
268
+ label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
269
+ }
270
+ this.setNodeVisible(String(this.config.resultNode ?? ''), true);
271
+ this.setButtonEnabled(String(this.config.retryButton ?? ''), true);
272
+
273
+ // A freeze node freezes its whole subtree: the spawners' own components AND
274
+ // every instance they spawned under themselves (a chaser's script, a pickup's
275
+ // pop-in), so nothing keeps moving over the result card.
276
+ for (const id of String(this.config.freezeNodes ?? '').split(',')) {
277
+ const root = this.findNode(id.trim());
278
+ if (!root) continue;
279
+ root.traverse(object => {
280
+ if (!(object instanceof NodeBase)) return;
281
+ for (const component of object.components) {
282
+ // Only remember what WE switched off: reviving everything on reset would
283
+ // silently enable a component the scene author had left disabled.
284
+ if (!component.enabled) continue;
285
+ component.enabled = false;
286
+ this.frozen.push(component);
287
+ }
288
+ });
289
+ }
290
+ this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
291
+ }
292
+
293
+ private restart(): void {
294
+ void this.scene?.changeScene(String(this.config.gameScene ?? ''), { transition: 'fade' });
295
+ }
296
+
297
+ private toMenu(): void {
298
+ void this.scene?.changeScene(String(this.config.menuScene ?? ''), { transition: 'fade' });
299
+ }
300
+
301
+ /**
302
+ * Back to the state `onStart` left behind: score, lives, clock and outcome, the
303
+ * result overlay hidden, the freeze `finish()` applied lifted, and anything a
304
+ * spawner still has on the field despawned. Those leftovers are state too — a
305
+ * run that begins amid the previous run's targets did not begin from the start.
306
+ *
307
+ * Public because it is useful on its own (an in-run "try again" that skips the
308
+ * scene reload); the debug provider's `reset` is this method.
309
+ */
310
+ resetRun(): void {
311
+ for (const component of this.frozen) {
312
+ component.enabled = true;
313
+ }
314
+ this.frozen = [];
315
+ this.clearSpawned();
316
+
317
+ this.score = 0;
318
+ this.lives = Math.max(1, Number(this.config.startingLives) || 1);
319
+ this.elapsed = 0;
320
+ this.over = false;
321
+ this.won = false;
322
+
323
+ this.setNodeVisible(String(this.config.resultNode ?? ''), false);
324
+ this.setButtonEnabled(String(this.config.retryButton ?? ''), false);
325
+ this.broadcast();
326
+ }
327
+
328
+ /**
329
+ * Ask every component on a `freezeNodes` node that owns spawned instances to
330
+ * drop them. Duck-typed instead of importing `Spawner`: the freeze list is
331
+ * whatever the scene author wrote there, and a project may put its own spawner
332
+ * in it — one that also happens to offer `clear()`.
333
+ */
334
+ private clearSpawned(): void {
335
+ for (const id of String(this.config.freezeNodes ?? '').split(',')) {
336
+ const node = this.findNode(id.trim());
337
+ if (!node) continue;
338
+ for (const component of node.components) {
339
+ const clear = (component as { clear?: unknown }).clear;
340
+ if (typeof clear === 'function') {
341
+ (clear as () => void).call(component);
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ private bindButton(query: string, handler: () => void, enabled: boolean): void {
348
+ const button = this.findNode(query);
349
+ if (button instanceof Button2D) {
350
+ button.enabled = enabled;
351
+ button.connect('pressed', this, handler);
352
+ }
353
+ }
354
+
355
+ private setButtonEnabled(query: string, enabled: boolean): void {
356
+ const button = this.findNode(query);
357
+ if (button instanceof Button2D) {
358
+ button.enabled = enabled;
359
+ }
360
+ }
361
+
362
+ private setNodeVisible(query: string, visible: boolean): void {
363
+ const node = this.findNode(query);
364
+ if (node) {
365
+ node.visible = visible;
366
+ }
367
+ }
368
+ }