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