@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,290 +1,290 @@
1
- /**
2
- * GameRules — score, lives, timer and the win/lose decision for the 3D recipe.
3
- *
4
- * The same bookkeeping the 2D blank recipe ships, so a 3D idea starts with the *outcome*
5
- * of a run already built and spends its first increment on the mechanic. It owns:
6
- * - the run state (score, lives, clock, outcome),
7
- * - the HUD signals (`score-changed`, `lives-changed`, `time-changed`),
8
- * - the win/lose decision.
9
- *
10
- * Your mechanic talks to it in either of two ways, whichever suits the script:
11
- * 1. call the methods — `rules.addScore(1)`, `rules.loseLife()`, `rules.finish(true)`;
12
- * 2. emit on the node that carries this component — `node.emit('score-added', 1)`
13
- * or `node.emit('life-lost', 1)` — which needs no reference at all.
14
- *
15
- * Win modes:
16
- * `score` — reach `targetScore`; a non-zero time limit is a deadline.
17
- * `time` — play until the clock ends, then win if `targetScore` was reached.
18
- * `survive` — stay alive until the clock ends; reaching it is the win.
19
- * In every mode, running out of lives loses. `timeLimitSec: 0` means no clock,
20
- * so the HUD's time label counts elapsed seconds up instead of down.
21
- *
22
- * **Two things it deliberately does NOT own**, because `GameFlow` on the same node already
23
- * does, and a playable may have exactly one of each:
24
- *
25
- * - **The end screen.** `finish(won)` writes the outcome text and dispatches GameFlow's
26
- * `finish` command. One result surface, one phase machine — a second overlay would give
27
- * the playable two ways to be over and no way to agree which one happened.
28
- * - **The debug provider.** `registerGameDebug` keeps ONE provider, so a second call here
29
- * would silently displace GameFlow's and take every intro/end verification down with it.
30
- * Instead {@link GameRules.bookkeeping} is read by GameFlow's snapshot, and the numbers
31
- * below arrive in the same JSON as the phase.
32
- *
33
- * The wiring in both directions is duck-typed on purpose: delete this component and
34
- * GameFlow still runs the gate, the timer and the CTA exactly as it did before. Delete
35
- * GameFlow instead and set `startWithFlow: false`, or nothing ever starts the run.
36
- */
37
- import { Label2D, Script, type PropertySchema } from '@pix3/runtime';
38
-
39
- type WinMode = 'score' | 'time' | 'survive';
40
-
41
- /** Snapshot numbers are read by humans and diffed by tools — keep them short. */
42
- function roundHundredths(value: number): number {
43
- return Math.round(value * 100) / 100;
44
- }
45
-
46
- export class GameRules extends Script {
47
- private score = 0;
48
- private lives = 3;
49
- private elapsed = 0;
50
- private over = false;
51
- private won = false;
52
- /** False until the run actually begins — see `startWithFlow`. */
53
- private running = false;
54
-
55
- constructor(id: string, type: string) {
56
- super(id, type);
57
- this.config = {
58
- winMode: 'score',
59
- targetScore: 10,
60
- // No clock by default: a recipe that ended runs on a deadline nobody asked for
61
- // would look like a bug in the mechanic being built.
62
- timeLimitSec: 0,
63
- startingLives: 3,
64
- resultLabel: 'end-label',
65
- winText: 'YOU WIN!',
66
- loseText: 'GAME OVER',
67
- // Wait for GameFlow to leave the tap gate before the clock starts and the win
68
- // condition is evaluated. Without it a `survive` run with a time limit is WON while
69
- // the intro overlay is still up, having survived nothing. Set it false if you delete
70
- // GameFlow — then the run starts with the scene.
71
- startWithFlow: true,
72
- };
73
- }
74
-
75
- static getPropertySchema(): PropertySchema {
76
- const num = (name: string, label: string, min: number, max: number, step: number) => ({
77
- name,
78
- type: 'number' as const,
79
- ui: { label, group: 'Rules', min, max, step, slider: true },
80
- getValue: (s: unknown) => (s as GameRules).config[name],
81
- setValue: (s: unknown, v: unknown) => {
82
- const n = Number(v);
83
- (s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
84
- },
85
- });
86
- const str = (name: string, label: string, group: string) => ({
87
- name,
88
- type: 'string' as const,
89
- ui: { label, group },
90
- getValue: (s: unknown) => (s as GameRules).config[name],
91
- setValue: (s: unknown, v: unknown) => {
92
- (s as GameRules).config[name] = typeof v === 'string' ? v : '';
93
- },
94
- });
95
-
96
- return {
97
- nodeType: 'GameRules',
98
- properties: [
99
- {
100
- name: 'winMode',
101
- type: 'select',
102
- ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
103
- getValue: s => (s as GameRules).config.winMode,
104
- setValue: (s, v) => {
105
- (s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
106
- },
107
- },
108
- num('targetScore', 'Target Score', 1, 99999, 1),
109
- num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
110
- num('startingLives', 'Starting Lives', 1, 20, 1),
111
- str('winText', 'Win Text', 'Result'),
112
- str('loseText', 'Lose Text', 'Result'),
113
- str('resultLabel', 'Result Label', 'Result'),
114
- {
115
- name: 'startWithFlow',
116
- type: 'boolean',
117
- ui: {
118
- label: 'Start With Flow',
119
- description: 'Wait for GameFlow to leave the tap gate before the run begins',
120
- group: 'Rules',
121
- },
122
- getValue: s => (s as GameRules).config.startWithFlow !== false,
123
- setValue: (s, v) => {
124
- (s as GameRules).config.startWithFlow = v !== false;
125
- },
126
- },
127
- ],
128
- groups: {
129
- Rules: { label: 'Rules', expanded: true },
130
- Result: { label: 'Result Text', expanded: true },
131
- },
132
- };
133
- }
134
-
135
- onStart(): void {
136
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
137
- this.running = this.config.startWithFlow === false;
138
-
139
- const owner = this.node;
140
- // The signal channel: a mechanic script can score without holding a reference
141
- // to this component. Same effect as calling the methods below.
142
- owner?.connect('score-added', this, (...args: unknown[]) =>
143
- this.addScore(Number(args[0]) || 0)
144
- );
145
- owner?.connect('life-lost', this, (...args: unknown[]) => this.loseLife(Number(args[0]) || 1));
146
- // The HUD announces itself once it is connected, so it never misses the
147
- // opening values (its onStart runs after this one).
148
- owner?.connect('hud-ready', this, () => this.broadcast());
149
- this.broadcast();
150
- }
151
-
152
- onUpdate(dt: number): void {
153
- if (this.over || !this.running) {
154
- return;
155
- }
156
- this.elapsed += dt;
157
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
158
- this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
159
-
160
- if (this.lives <= 0) {
161
- this.finish(false);
162
- return;
163
- }
164
- const raw = String(this.config.winMode ?? 'score');
165
- const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
166
- const target = Math.max(1, Number(this.config.targetScore) || 1);
167
- const timeUp = limit > 0 && this.elapsed >= limit;
168
-
169
- if (mode === 'score') {
170
- if (this.score >= target) this.finish(true);
171
- else if (timeUp) this.finish(false);
172
- } else if (mode === 'time') {
173
- if (timeUp) this.finish(this.score >= target);
174
- } else if (timeUp) {
175
- this.finish(true);
176
- }
177
- }
178
-
179
- /**
180
- * The run has begun: start the clock and start judging the win condition. Called by
181
- * `GameFlow` when the tap gate opens, so the timer measures play and not the time the
182
- * intro overlay spent on screen.
183
- */
184
- startRun(): void {
185
- if (this.over) return;
186
- this.running = true;
187
- }
188
-
189
- /**
190
- * The run ended somewhere else — `GameFlow`'s placeholder timer, or a `finish` command
191
- * dispatched by a test. Without this the phase said `ended` while the rules kept ticking,
192
- * reported `outcome: null`, and (with a time limit) fired a SECOND ending that rewrote the
193
- * result label behind the end screen.
194
- *
195
- * With no verdict passed, the rules read their own win condition — the run is over, and
196
- * "was it won?" is exactly the question this component exists to answer.
197
- */
198
- endRun(won?: boolean): void {
199
- if (this.over) return;
200
- this.finish(won ?? this.score >= Math.max(1, Number(this.config.targetScore) || 1));
201
- }
202
-
203
- /** Score. Call it from your mechanic, or emit `score-added` on this node. */
204
- addScore(amount: number): void {
205
- if (this.over) return;
206
- this.score += amount;
207
- this.node?.emit('score-changed', this.score);
208
- }
209
-
210
- /** Lose lives. Call it from your mechanic, or emit `life-lost` on this node. */
211
- loseLife(amount = 1): void {
212
- if (this.over) return;
213
- this.lives = Math.max(0, this.lives - Math.max(1, amount));
214
- this.node?.emit(
215
- 'lives-changed',
216
- this.lives,
217
- Math.max(1, Number(this.config.startingLives) || 1)
218
- );
219
- }
220
-
221
- /**
222
- * End the run: write the outcome text, then hand the end screen to `GameFlow`.
223
- *
224
- * Dispatching its `finish` command rather than reaching for the component keeps the
225
- * ending in ONE place and journals it, so a routine can watch the run end by intent.
226
- */
227
- finish(won: boolean): void {
228
- if (this.over) return;
229
- this.over = true;
230
- this.running = false;
231
- this.won = won;
232
-
233
- const label = this.findNode(String(this.config.resultLabel ?? ''));
234
- if (label instanceof Label2D) {
235
- label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
236
- }
237
- this.scene?.commands.dispatch('finish');
238
- this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
239
- }
240
-
241
- /**
242
- * The run's numbers, for GameFlow's debug snapshot. Kept flat and JSON-serialisable —
243
- * it is merged into that snapshot, so it is what every verification tool reads.
244
- */
245
- bookkeeping(): Record<string, unknown> {
246
- return {
247
- score: this.score,
248
- lives: this.lives,
249
- winMode: String(this.config.winMode ?? 'score'),
250
- targetScore: Math.max(1, Number(this.config.targetScore) || 1),
251
- // null when the run has no clock at all.
252
- timeLeftSec: this.timeLeftSec(),
253
- outcome: this.over ? (this.won ? 'won' : 'lost') : null,
254
- // Whether the clock and the win condition are live. A run that reads `phase: playing`
255
- // while this is false is a run stuck behind the gate.
256
- running: this.running,
257
- };
258
- }
259
-
260
- /**
261
- * Back to the state `onStart` left behind. Called by GameFlow's `restart`, so one intent
262
- * arms a fresh run everywhere — a restart that put the gate back but kept last run's score
263
- * is the kind of half-reset that makes a routine's second pass meaningless.
264
- */
265
- resetRun(): void {
266
- this.score = 0;
267
- this.lives = Math.max(1, Number(this.config.startingLives) || 1);
268
- this.elapsed = 0;
269
- this.over = false;
270
- this.won = false;
271
- // Back behind the gate, exactly as at `onStart` — a reset that left the clock running
272
- // would spend the next intro overlay burning the new run's time limit.
273
- this.running = this.config.startWithFlow === false;
274
- this.broadcast();
275
- }
276
-
277
- /** Seconds left on the clock, or `null` when this run has no time limit. */
278
- private timeLeftSec(): number | null {
279
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
280
- return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
281
- }
282
-
283
- private broadcast(): void {
284
- const owner = this.node;
285
- owner?.emit('score-changed', this.score);
286
- owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
287
- const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
288
- owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
289
- }
290
- }
1
+ /**
2
+ * GameRules — score, lives, timer and the win/lose decision for the 3D recipe.
3
+ *
4
+ * The same bookkeeping the 2D blank recipe ships, so a 3D idea starts with the *outcome*
5
+ * of a run already built and spends its first increment on the mechanic. It owns:
6
+ * - the run state (score, lives, clock, outcome),
7
+ * - the HUD signals (`score-changed`, `lives-changed`, `time-changed`),
8
+ * - the win/lose decision.
9
+ *
10
+ * Your mechanic talks to it in either of two ways, whichever suits the script:
11
+ * 1. call the methods — `rules.addScore(1)`, `rules.loseLife()`, `rules.finish(true)`;
12
+ * 2. emit on the node that carries this component — `node.emit('score-added', 1)`
13
+ * or `node.emit('life-lost', 1)` — which needs no reference at all.
14
+ *
15
+ * Win modes:
16
+ * `score` — reach `targetScore`; a non-zero time limit is a deadline.
17
+ * `time` — play until the clock ends, then win if `targetScore` was reached.
18
+ * `survive` — stay alive until the clock ends; reaching it is the win.
19
+ * In every mode, running out of lives loses. `timeLimitSec: 0` means no clock,
20
+ * so the HUD's time label counts elapsed seconds up instead of down.
21
+ *
22
+ * **Two things it deliberately does NOT own**, because `GameFlow` on the same node already
23
+ * does, and a playable may have exactly one of each:
24
+ *
25
+ * - **The end screen.** `finish(won)` writes the outcome text and dispatches GameFlow's
26
+ * `finish` command. One result surface, one phase machine — a second overlay would give
27
+ * the playable two ways to be over and no way to agree which one happened.
28
+ * - **The debug provider.** `registerGameDebug` keeps ONE provider, so a second call here
29
+ * would silently displace GameFlow's and take every intro/end verification down with it.
30
+ * Instead {@link GameRules.bookkeeping} is read by GameFlow's snapshot, and the numbers
31
+ * below arrive in the same JSON as the phase.
32
+ *
33
+ * The wiring in both directions is duck-typed on purpose: delete this component and
34
+ * GameFlow still runs the gate, the timer and the CTA exactly as it did before. Delete
35
+ * GameFlow instead and set `startWithFlow: false`, or nothing ever starts the run.
36
+ */
37
+ import { Label2D, Script, type PropertySchema } from '@pix3/runtime';
38
+
39
+ type WinMode = 'score' | 'time' | 'survive';
40
+
41
+ /** Snapshot numbers are read by humans and diffed by tools — keep them short. */
42
+ function roundHundredths(value: number): number {
43
+ return Math.round(value * 100) / 100;
44
+ }
45
+
46
+ export class GameRules extends Script {
47
+ private score = 0;
48
+ private lives = 3;
49
+ private elapsed = 0;
50
+ private over = false;
51
+ private won = false;
52
+ /** False until the run actually begins — see `startWithFlow`. */
53
+ private running = false;
54
+
55
+ constructor(id: string, type: string) {
56
+ super(id, type);
57
+ this.config = {
58
+ winMode: 'score',
59
+ targetScore: 10,
60
+ // No clock by default: a recipe that ended runs on a deadline nobody asked for
61
+ // would look like a bug in the mechanic being built.
62
+ timeLimitSec: 0,
63
+ startingLives: 3,
64
+ resultLabel: 'end-label',
65
+ winText: 'YOU WIN!',
66
+ loseText: 'GAME OVER',
67
+ // Wait for GameFlow to leave the tap gate before the clock starts and the win
68
+ // condition is evaluated. Without it a `survive` run with a time limit is WON while
69
+ // the intro overlay is still up, having survived nothing. Set it false if you delete
70
+ // GameFlow — then the run starts with the scene.
71
+ startWithFlow: true,
72
+ };
73
+ }
74
+
75
+ static getPropertySchema(): PropertySchema {
76
+ const num = (name: string, label: string, min: number, max: number, step: number) => ({
77
+ name,
78
+ type: 'number' as const,
79
+ ui: { label, group: 'Rules', min, max, step, slider: true },
80
+ getValue: (s: unknown) => (s as GameRules).config[name],
81
+ setValue: (s: unknown, v: unknown) => {
82
+ const n = Number(v);
83
+ (s as GameRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
84
+ },
85
+ });
86
+ const str = (name: string, label: string, group: string) => ({
87
+ name,
88
+ type: 'string' as const,
89
+ ui: { label, group },
90
+ getValue: (s: unknown) => (s as GameRules).config[name],
91
+ setValue: (s: unknown, v: unknown) => {
92
+ (s as GameRules).config[name] = typeof v === 'string' ? v : '';
93
+ },
94
+ });
95
+
96
+ return {
97
+ nodeType: 'GameRules',
98
+ properties: [
99
+ {
100
+ name: 'winMode',
101
+ type: 'select',
102
+ ui: { label: 'Win Mode', group: 'Rules', options: ['score', 'time', 'survive'] },
103
+ getValue: s => (s as GameRules).config.winMode,
104
+ setValue: (s, v) => {
105
+ (s as GameRules).config.winMode = v === 'time' || v === 'survive' ? v : 'score';
106
+ },
107
+ },
108
+ num('targetScore', 'Target Score', 1, 99999, 1),
109
+ num('timeLimitSec', 'Time Limit (s)', 0, 600, 1),
110
+ num('startingLives', 'Starting Lives', 1, 20, 1),
111
+ str('winText', 'Win Text', 'Result'),
112
+ str('loseText', 'Lose Text', 'Result'),
113
+ str('resultLabel', 'Result Label', 'Result'),
114
+ {
115
+ name: 'startWithFlow',
116
+ type: 'boolean',
117
+ ui: {
118
+ label: 'Start With Flow',
119
+ description: 'Wait for GameFlow to leave the tap gate before the run begins',
120
+ group: 'Rules',
121
+ },
122
+ getValue: s => (s as GameRules).config.startWithFlow !== false,
123
+ setValue: (s, v) => {
124
+ (s as GameRules).config.startWithFlow = v !== false;
125
+ },
126
+ },
127
+ ],
128
+ groups: {
129
+ Rules: { label: 'Rules', expanded: true },
130
+ Result: { label: 'Result Text', expanded: true },
131
+ },
132
+ };
133
+ }
134
+
135
+ onStart(): void {
136
+ this.lives = Math.max(1, Number(this.config.startingLives) || 1);
137
+ this.running = this.config.startWithFlow === false;
138
+
139
+ const owner = this.node;
140
+ // The signal channel: a mechanic script can score without holding a reference
141
+ // to this component. Same effect as calling the methods below.
142
+ owner?.connect('score-added', this, (...args: unknown[]) =>
143
+ this.addScore(Number(args[0]) || 0)
144
+ );
145
+ owner?.connect('life-lost', this, (...args: unknown[]) => this.loseLife(Number(args[0]) || 1));
146
+ // The HUD announces itself once it is connected, so it never misses the
147
+ // opening values (its onStart runs after this one).
148
+ owner?.connect('hud-ready', this, () => this.broadcast());
149
+ this.broadcast();
150
+ }
151
+
152
+ onUpdate(dt: number): void {
153
+ if (this.over || !this.running) {
154
+ return;
155
+ }
156
+ this.elapsed += dt;
157
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
158
+ this.node?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
159
+
160
+ if (this.lives <= 0) {
161
+ this.finish(false);
162
+ return;
163
+ }
164
+ const raw = String(this.config.winMode ?? 'score');
165
+ const mode: WinMode = raw === 'time' ? 'time' : raw === 'survive' ? 'survive' : 'score';
166
+ const target = Math.max(1, Number(this.config.targetScore) || 1);
167
+ const timeUp = limit > 0 && this.elapsed >= limit;
168
+
169
+ if (mode === 'score') {
170
+ if (this.score >= target) this.finish(true);
171
+ else if (timeUp) this.finish(false);
172
+ } else if (mode === 'time') {
173
+ if (timeUp) this.finish(this.score >= target);
174
+ } else if (timeUp) {
175
+ this.finish(true);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * The run has begun: start the clock and start judging the win condition. Called by
181
+ * `GameFlow` when the tap gate opens, so the timer measures play and not the time the
182
+ * intro overlay spent on screen.
183
+ */
184
+ startRun(): void {
185
+ if (this.over) return;
186
+ this.running = true;
187
+ }
188
+
189
+ /**
190
+ * The run ended somewhere else — `GameFlow`'s placeholder timer, or a `finish` command
191
+ * dispatched by a test. Without this the phase said `ended` while the rules kept ticking,
192
+ * reported `outcome: null`, and (with a time limit) fired a SECOND ending that rewrote the
193
+ * result label behind the end screen.
194
+ *
195
+ * With no verdict passed, the rules read their own win condition — the run is over, and
196
+ * "was it won?" is exactly the question this component exists to answer.
197
+ */
198
+ endRun(won?: boolean): void {
199
+ if (this.over) return;
200
+ this.finish(won ?? this.score >= Math.max(1, Number(this.config.targetScore) || 1));
201
+ }
202
+
203
+ /** Score. Call it from your mechanic, or emit `score-added` on this node. */
204
+ addScore(amount: number): void {
205
+ if (this.over) return;
206
+ this.score += amount;
207
+ this.node?.emit('score-changed', this.score);
208
+ }
209
+
210
+ /** Lose lives. Call it from your mechanic, or emit `life-lost` on this node. */
211
+ loseLife(amount = 1): void {
212
+ if (this.over) return;
213
+ this.lives = Math.max(0, this.lives - Math.max(1, amount));
214
+ this.node?.emit(
215
+ 'lives-changed',
216
+ this.lives,
217
+ Math.max(1, Number(this.config.startingLives) || 1)
218
+ );
219
+ }
220
+
221
+ /**
222
+ * End the run: write the outcome text, then hand the end screen to `GameFlow`.
223
+ *
224
+ * Dispatching its `finish` command rather than reaching for the component keeps the
225
+ * ending in ONE place and journals it, so a routine can watch the run end by intent.
226
+ */
227
+ finish(won: boolean): void {
228
+ if (this.over) return;
229
+ this.over = true;
230
+ this.running = false;
231
+ this.won = won;
232
+
233
+ const label = this.findNode(String(this.config.resultLabel ?? ''));
234
+ if (label instanceof Label2D) {
235
+ label.setText(String((won ? this.config.winText : this.config.loseText) ?? ''));
236
+ }
237
+ this.scene?.commands.dispatch('finish');
238
+ this.node?.emit(won ? 'game-won' : 'game-lost', this.score);
239
+ }
240
+
241
+ /**
242
+ * The run's numbers, for GameFlow's debug snapshot. Kept flat and JSON-serialisable —
243
+ * it is merged into that snapshot, so it is what every verification tool reads.
244
+ */
245
+ bookkeeping(): Record<string, unknown> {
246
+ return {
247
+ score: this.score,
248
+ lives: this.lives,
249
+ winMode: String(this.config.winMode ?? 'score'),
250
+ targetScore: Math.max(1, Number(this.config.targetScore) || 1),
251
+ // null when the run has no clock at all.
252
+ timeLeftSec: this.timeLeftSec(),
253
+ outcome: this.over ? (this.won ? 'won' : 'lost') : null,
254
+ // Whether the clock and the win condition are live. A run that reads `phase: playing`
255
+ // while this is false is a run stuck behind the gate.
256
+ running: this.running,
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Back to the state `onStart` left behind. Called by GameFlow's `restart`, so one intent
262
+ * arms a fresh run everywhere — a restart that put the gate back but kept last run's score
263
+ * is the kind of half-reset that makes a routine's second pass meaningless.
264
+ */
265
+ resetRun(): void {
266
+ this.score = 0;
267
+ this.lives = Math.max(1, Number(this.config.startingLives) || 1);
268
+ this.elapsed = 0;
269
+ this.over = false;
270
+ this.won = false;
271
+ // Back behind the gate, exactly as at `onStart` — a reset that left the clock running
272
+ // would spend the next intro overlay burning the new run's time limit.
273
+ this.running = this.config.startWithFlow === false;
274
+ this.broadcast();
275
+ }
276
+
277
+ /** Seconds left on the clock, or `null` when this run has no time limit. */
278
+ private timeLeftSec(): number | null {
279
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
280
+ return limit > 0 ? roundHundredths(Math.max(0, limit - this.elapsed)) : null;
281
+ }
282
+
283
+ private broadcast(): void {
284
+ const owner = this.node;
285
+ owner?.emit('score-changed', this.score);
286
+ owner?.emit('lives-changed', this.lives, Math.max(1, Number(this.config.startingLives) || 1));
287
+ const limit = Math.max(0, Number(this.config.timeLimitSec) || 0);
288
+ owner?.emit('time-changed', limit > 0 ? Math.max(0, limit - this.elapsed) : this.elapsed);
289
+ }
290
+ }