@pix3/cli 1.6.3 → 2.0.0-alpha.0

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 (168) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +722 -788
  3. package/dist/index.js +557 -421
  4. package/dist/runtime-types.json +1 -1
  5. package/dist/smoke/prebuilt/{build-7MIXIMSZ.mjs → build-7REMSA2S.mjs} +52 -52
  6. package/dist/smoke/prebuilt/chunk-4DGG2BKR.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-YLBMUJ2H.mjs +238 -0
  8. package/dist/smoke/prebuilt/runtime.mjs +1 -1
  9. package/dist/smoke/prebuilt/smoke-worker.mjs +211 -2
  10. package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
  11. package/dist/validate/prebuilt/{build-AUGKXER3.js → build-ADF5EEE2.js} +52 -52
  12. package/dist/validate/prebuilt/bundle-main.js +223 -18
  13. package/dist/validate/prebuilt/chunk-VIHIF4G2.js +238 -0
  14. package/dist/validate/prebuilt/runtime.js +1 -1
  15. package/kit/files/.claude/skills/pix3-editor/SKILL.md +200 -0
  16. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +13 -15
  17. package/kit/files/.claude/skills/pix3-nodes/reference.md +77 -71
  18. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +38 -41
  19. package/kit/files/.claude/skills/pix3-scene-format/pix3anim.md +185 -0
  20. package/kit/files/.claude/skills/pix3-scene-format/project-files.md +244 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -16
  22. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +34 -24
  23. package/kit/files/.claude/skills/pix3-scripts/reference.md +68 -141
  24. package/kit/files/.claude/skills/pix3-verify/SKILL.md +65 -326
  25. package/kit/files/.claude/skills/pix3-verify/reference.md +2 -14
  26. package/kit/files/AGENTS.md +76 -84
  27. package/kit/files/design/tests/bots/pix3-test-bot.d.ts +89 -0
  28. package/kit/kit.json +8 -5
  29. package/package.json +62 -54
  30. package/templates/2d/files/scenes/main.pix3scene +31 -0
  31. package/templates/2d/template.yaml +11 -0
  32. package/templates/3d/files/scenes/main.pix3scene +50 -0
  33. package/templates/3d/template.yaml +11 -0
  34. package/templates/base/files/README.md +39 -0
  35. package/templates/base/files/gitignore +11 -0
  36. package/templates/base/files/index.html +23 -0
  37. package/templates/base/files/package.json +28 -0
  38. package/templates/base/files/src/main.ts +3 -0
  39. package/templates/base/files/tsconfig.json +17 -0
  40. package/templates/base/files/vite.config.ts +10 -0
  41. package/dist/smoke/prebuilt/chunk-CBFYKDS7.mjs +0 -2
  42. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.mjs +0 -239
  43. package/dist/validate/prebuilt/chunk-CFQFEI52.js +0 -239
  44. package/templates/empty-2d/files/README.md +0 -20
  45. package/templates/empty-2d/files/scenes/main.pix3scene +0 -68
  46. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  47. package/templates/empty-2d/template.yaml +0 -7
  48. package/templates/empty-3d/files/README.md +0 -20
  49. package/templates/empty-3d/files/scenes/main.pix3scene +0 -108
  50. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  51. package/templates/empty-3d/template.yaml +0 -7
  52. package/templates/idea-blank/files/README.md +0 -22
  53. package/templates/idea-blank/files/scenes/main.pix3scene +0 -37
  54. package/templates/idea-blank/template.yaml +0 -10
  55. package/templates/minigame-2d/files/README.md +0 -44
  56. package/templates/minigame-2d/files/design/tests/reachability.json +0 -1
  57. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +0 -44
  58. package/templates/minigame-2d/files/scenes/main.pix3scene +0 -84
  59. package/templates/minigame-2d/files/scenes/menu.pix3scene +0 -118
  60. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +0 -138
  61. package/templates/minigame-2d/files/scripts/GameFlow.ts +0 -133
  62. package/templates/minigame-2d/files/scripts/MenuFlow.ts +0 -217
  63. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +0 -198
  64. package/templates/minigame-2d/template.yaml +0 -10
  65. package/templates/playable-2d/files/README.md +0 -30
  66. package/templates/playable-2d/files/design/recipe.md +0 -83
  67. package/templates/playable-2d/files/design/tests/reachability.json +0 -4
  68. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +0 -32
  69. package/templates/playable-2d/files/scenes/main.pix3scene +0 -111
  70. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +0 -91
  71. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +0 -65
  72. package/templates/playable-2d/files/scripts/CtaButton.ts +0 -55
  73. package/templates/playable-2d/files/scripts/GameFlow.ts +0 -256
  74. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  75. package/templates/playable-2d/template.yaml +0 -9
  76. package/templates/playable-3d/files/README.md +0 -32
  77. package/templates/playable-3d/files/design/recipe.md +0 -127
  78. package/templates/playable-3d/files/design/tests/reachability.json +0 -4
  79. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +0 -32
  80. package/templates/playable-3d/files/scenes/main.pix3scene +0 -230
  81. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +0 -91
  82. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +0 -65
  83. package/templates/playable-3d/files/scripts/CtaButton.ts +0 -55
  84. package/templates/playable-3d/files/scripts/GameFlow.ts +0 -309
  85. package/templates/playable-3d/files/scripts/GameRules.ts +0 -290
  86. package/templates/playable-3d/files/scripts/ScoreHud.ts +0 -113
  87. package/templates/playable-3d/template.yaml +0 -9
  88. package/templates/recipe-arena-2d/files/README.md +0 -48
  89. package/templates/recipe-arena-2d/files/design/recipe.md +0 -127
  90. package/templates/recipe-arena-2d/files/design/tests/reachability.json +0 -4
  91. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +0 -23
  92. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +0 -292
  93. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +0 -100
  94. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
  95. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +0 -45
  96. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +0 -85
  97. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +0 -368
  98. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +0 -106
  99. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +0 -151
  100. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +0 -94
  101. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +0 -214
  102. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +0 -174
  103. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  104. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  105. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  106. package/templates/recipe-arena-2d/template.yaml +0 -10
  107. package/templates/recipe-blank-2d/files/README.md +0 -61
  108. package/templates/recipe-blank-2d/files/design/recipe.md +0 -148
  109. package/templates/recipe-blank-2d/files/design/tests/reachability.json +0 -4
  110. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +0 -27
  111. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +0 -23
  112. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +0 -224
  113. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +0 -85
  114. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +0 -350
  115. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +0 -110
  116. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  117. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  118. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  119. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  120. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  121. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  122. package/templates/recipe-blank-2d/template.yaml +0 -10
  123. package/templates/recipe-bouncer-2d/files/README.md +0 -81
  124. package/templates/recipe-bouncer-2d/files/design/recipe.md +0 -146
  125. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +0 -4
  126. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +0 -23
  127. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +0 -615
  128. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +0 -191
  129. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +0 -87
  130. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +0 -298
  131. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +0 -377
  132. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +0 -109
  133. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +0 -112
  134. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +0 -94
  135. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +0 -263
  136. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +0 -307
  137. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  138. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  139. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  140. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  141. package/templates/recipe-bouncer-2d/template.yaml +0 -10
  142. package/templates/recipe-grid-3d/files/README.md +0 -22
  143. package/templates/recipe-grid-3d/files/design/recipe.md +0 -110
  144. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +0 -169
  145. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +0 -100
  146. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +0 -105
  147. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +0 -319
  148. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +0 -278
  149. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +0 -106
  150. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +0 -94
  151. package/templates/recipe-grid-3d/template.yaml +0 -10
  152. package/templates/recipe-tapper-2d/files/README.md +0 -39
  153. package/templates/recipe-tapper-2d/files/design/recipe.md +0 -132
  154. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +0 -4
  155. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +0 -23
  156. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +0 -269
  157. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +0 -100
  158. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +0 -42
  159. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +0 -45
  160. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +0 -85
  161. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +0 -368
  162. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +0 -106
  163. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +0 -94
  164. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +0 -214
  165. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +0 -192
  166. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  167. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  168. package/templates/recipe-tapper-2d/template.yaml +0 -10
@@ -1,85 +0,0 @@
1
- # Pix3 Scene File (YAML)
2
- # Blank 2D — the RESULT overlay (win/lose), in its own file so the gameplay
3
- # scene opens clean. `main.pix3scene` carries it as a one-line
4
- # `instance:` reference marked `visible: false`, which hides it in the EDITOR
5
- # only; `user:GameRules` reveals it at run time by setting `visible`.
6
- #
7
- # Edit it here, full-screen, in its own tab. To preview it composited over the
8
- # field, tick the eye on `result-overlay` in main.pix3scene — just do not commit
9
- # that tick, or every iteration starts behind a GAME OVER screen again.
10
- #
11
- # Stable node ids (design/recipe.md is the contract — do not rename these):
12
- # result-overlay, result-dim, result-label, retry-button
13
- version: 1.0.0
14
- metadata:
15
- author: Pix3 Recipe
16
- description: Result overlay — dim, win/lose headline and a RETRY button
17
- root:
18
- - id: result-overlay
19
- type: Group2D
20
- name: Result Overlay
21
- properties:
22
- width: 1080
23
- height: 1920
24
- initiallyVisible: false
25
- layout:
26
- enabled: true
27
- horizontalAlign: stretch
28
- verticalAlign: stretch
29
- children:
30
- - id: result-dim
31
- type: ColorRect2D
32
- name: Result Dim
33
- properties:
34
- width: 1080
35
- height: 1920
36
- color: "#000000"
37
- opacity: 0.7
38
- transform:
39
- position: [0, 0]
40
- scale: [1, 1]
41
- rotation: 0
42
- layout:
43
- enabled: true
44
- horizontalAlign: stretch
45
- verticalAlign: stretch
46
- children: []
47
- - id: result-label
48
- type: Label2D
49
- name: Result Label
50
- properties:
51
- label: "GAME OVER"
52
- labelFontSize: 88
53
- labelColor: "#ffffff"
54
- labelAlign: center
55
- transform:
56
- position: [0, 160]
57
- scale: [1, 1]
58
- rotation: 0
59
- layout:
60
- enabled: true
61
- horizontalAlign: center
62
- verticalAlign: center
63
- children: []
64
- - id: retry-button
65
- type: Button2D
66
- name: Retry Button
67
- properties:
68
- width: 420
69
- height: 130
70
- label: "RETRY"
71
- labelFontSize: 50
72
- labelColor: "#12141c"
73
- backgroundColor: "#f5ae39"
74
- hoverColor: "#ffc862"
75
- pressedColor: "#d4922a"
76
- buttonAction: retry
77
- transform:
78
- position: [0, -60]
79
- scale: [1, 1]
80
- rotation: 0
81
- layout:
82
- enabled: true
83
- horizontalAlign: center
84
- verticalAlign: center
85
- children: []
@@ -1,350 +0,0 @@
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,110 +0,0 @@
1
- /**
2
- * ScoreHud — signal-driven HUD. Display only, no game logic.
3
- *
4
- * Listens on `sourceNode` (the node carrying `GameRules`) for:
5
- * `score-changed` (score)
6
- * `lives-changed` (lives, maxLives)
7
- * `time-changed` (seconds)
8
- *
9
- * If a HUD widget is missing from the scene it is quietly skipped, so you can
10
- * delete the timer or the lives bar without touching any script.
11
- *
12
- * With `GameRules.timeLimitSec` at 0 there is no clock, and `time-changed` carries
13
- * ELAPSED seconds instead — the label counts up. Set a limit and it counts down.
14
- */
15
- import { Bar2D, Label2D, Script, type PropertySchema } from '@pix3/runtime';
16
-
17
- export class ScoreHud extends Script {
18
- constructor(id: string, type: string) {
19
- super(id, type);
20
- this.config = {
21
- // Node that emits the HUD signals (the GameRules host).
22
- sourceNode: 'game-root',
23
- scoreLabel: 'score-label',
24
- scoreFormat: 'SCORE {value}',
25
- timeLabel: 'time-label',
26
- timeFormat: '{value}',
27
- livesBar: 'lives-bar',
28
- };
29
- }
30
-
31
- static getPropertySchema(): PropertySchema {
32
- const str = (name: string, label: string, description?: string) => ({
33
- name,
34
- type: 'string' as const,
35
- ui: { label, group: 'HUD', description },
36
- getValue: (s: unknown) => (s as ScoreHud).config[name],
37
- setValue: (s: unknown, v: unknown) => {
38
- (s as ScoreHud).config[name] = typeof v === 'string' ? v : '';
39
- },
40
- });
41
-
42
- return {
43
- nodeType: 'ScoreHud',
44
- properties: [
45
- str(
46
- 'sourceNode',
47
- 'Source Node',
48
- 'Node that emits score-changed / lives-changed / time-changed'
49
- ),
50
- str('scoreLabel', 'Score Label'),
51
- str('scoreFormat', 'Score Format', '{value} is replaced by the score'),
52
- str('timeLabel', 'Time Label'),
53
- str('timeFormat', 'Time Format', '{value} is replaced by whole seconds'),
54
- str('livesBar', 'Lives Bar'),
55
- ],
56
- groups: { HUD: { label: 'HUD', expanded: true } },
57
- };
58
- }
59
-
60
- onStart(): void {
61
- const source = this.findNode(String(this.config.sourceNode ?? ''));
62
- if (!source) {
63
- console.warn(`[ScoreHud] Source node "${this.config.sourceNode}" not found.`);
64
- return;
65
- }
66
- source.connect('score-changed', this, (...args: unknown[]) =>
67
- this.setScore(Number(args[0]) || 0)
68
- );
69
- source.connect('time-changed', this, (...args: unknown[]) =>
70
- this.setTime(Number(args[0]) || 0)
71
- );
72
- source.connect('lives-changed', this, (...args: unknown[]) =>
73
- this.setLives(Number(args[0]) || 0, Number(args[1]) || 0)
74
- );
75
- // Ask for the opening values now that the listeners are live: GameRules
76
- // starts before this HUD (parent components tick before children).
77
- source.emit('hud-ready');
78
- }
79
-
80
- private setScore(score: number): void {
81
- const label = this.findNode(String(this.config.scoreLabel ?? ''));
82
- if (label instanceof Label2D) {
83
- label.setText(
84
- String(this.config.scoreFormat ?? '{value}').replace('{value}', String(Math.round(score)))
85
- );
86
- }
87
- }
88
-
89
- private setTime(seconds: number): void {
90
- const label = this.findNode(String(this.config.timeLabel ?? ''));
91
- if (label instanceof Label2D) {
92
- label.setText(
93
- String(this.config.timeFormat ?? '{value}').replace(
94
- '{value}',
95
- String(Math.max(0, Math.ceil(seconds)))
96
- )
97
- );
98
- }
99
- }
100
-
101
- private setLives(lives: number, maxLives: number): void {
102
- const bar = this.findNode(String(this.config.livesBar ?? ''));
103
- if (bar instanceof Bar2D) {
104
- if (maxLives > 0) {
105
- bar.maxValue = maxLives;
106
- }
107
- bar.setValue(lives);
108
- }
109
- }
110
- }
@@ -1,10 +0,0 @@
1
- id: recipe-blank-2d
2
- title: Blank 2D
3
- description: No mechanics on purpose — an empty field with score, lives, timer, a HUD and a win/lose overlay already wired. For an idea whose core loop is not what any other recipe ships (grid or turn-based movement, word/card/board games, builders, idle); the first increment builds the mechanic itself, controls included.
4
- projectType: 2d
5
- targetPlatform: universal
6
- viewport: { width: 1080, height: 1920 }
7
- # No `entryScene`: this recipe ships ONE scene, and the whole point is that nothing
8
- # stands between opening the project and the gameplay. Add a menu later (see
9
- # design/recipe.md → Extension points) and set it in Project Settings then.
10
- order: 64
@@ -1,81 +0,0 @@
1
- # Bouncer 2D
2
-
3
- A playable skeleton: a ball under gravity, three bumpers, a paddle you steer,
4
- and a drain at the bottom that costs a life. It plays — and glows, pops and
5
- clicks — as shipped: press play in `scenes/main.pix3scene` before changing
6
- anything.
7
-
8
- **`design/recipe.md` is the contract.** Stable node ids, tunables with ranges,
9
- extension points (including the full pinball path), and what must not be
10
- renamed. Read it first.
11
-
12
- ## The ball physics is in this project, by choice
13
-
14
- `scripts/ball-collision.ts` implements swept (continuous) circle-vs-segment and
15
- circle-vs-circle collision with fixed substeps — the ball's whole displacement
16
- is tested each substep, so no speed can skip a wall — and `scripts/BallBody.ts`
17
- builds the colliders every frame from the **live world transforms** of ordinary
18
- marker nodes. Rotate `paddle` in the inspector, animate it from a clip, or add
19
- flippers: the physics follows with no extra code. `core:Hitbox2D` is an
20
- axis-aligned overlap test that ignores rotation — never put it on the ball.
21
-
22
- The engine also ships a general 2D rigid-body solver (`core:PhysicsBody2D` +
23
- `core:Collider2D`, `scene.physics2d`) — reach for it when the game grows bodies
24
- that push each other (stacks, debris, several balls); one ball against static
25
- geometry is exactly what the swept solver here is for, and mixing the two on the
26
- same ball is a bug.
27
-
28
- Never `import` rapier here. It is a ~2 MB lazily-loaded wasm payload for 3D
29
- only, it is not needed for this, and it would blow the export budget.
30
-
31
- **Idle / builder variant (idle-pinball, plinko-tycoon):** the drain does not
32
- have to cost a life. Set `startingLives` high or make the drain relaunch the ball
33
- (`BallBody.resetBall()` after `ball-drained`) — a conveyor returning the ball
34
- forever — and let the player spend the hits' coins on more bumpers. The paddle
35
- can be deleted or hidden; the bumpers and the juice on `ball-hit` stay.
36
-
37
- ## The neon look is three cheap ingredients
38
-
39
- 1. **`post-fx` (a `PostProcess` node with `affect2D: true`)** — bloom picks up
40
- whatever is already bright (the cyan `board-trim` strips, the bumper cores, the
41
- gold paddle, the white ball) plus a light vignette. Bloom cannot invent
42
- brightness: to make something glow, brighten its colour — don't just raise
43
- `bloomIntensity`.
44
- 2. **`bg-glow`** — `ColorRect2D` has no gradients, so the background glow is a
45
- near-white radial-gradient PNG (`sprites/ph-bg.png`) tinted with `core:tint`,
46
- sitting over the darkest `ColorRect2D`. The board floor is translucent so it
47
- reads through the playfield.
48
- 3. **`Label2D` glow** (`glowColor` / `glowStrength`) on the HUD. The HUD is a
49
- `CanvasLayer2D`, which the runtime draws *after* post-processing and never
50
- blooms — deliberately, so score text stays crisp. Its glow is canvas-drawn.
51
-
52
- Feel is engine one-liners, not code you have to write: `TouchRules` already calls
53
- `scene.audio.sfx` (procedural SFX — no audio assets in this project),
54
- `scene.juice.burst` / `floatText` / `punchScale` / `flash` / `shake` and
55
- `scene.time.hitstop` on contact, and `GameRules` plays a win/lose jingle. Spawn
56
- effects at board-space anchors: anything anchored to the HUD lands in the
57
- un-bloomed overlay band.
58
-
59
- ## Layout
60
-
61
- ```
62
- scenes/menu.pix3scene entry / export scene (menu-post-fx, glowing title)
63
- scenes/main.pix3scene the GAME (editor startup scene — iterate here)
64
- scenes/ui/result.pix3scene win/lose overlay, instanced into main hidden (editor-only)
65
- scripts/ball-collision.ts pure swept-collision math (unit-tested)
66
- scripts/BallBody.ts ball motion; colliders from marker world transforms
67
- scripts/PaddleController.ts input → paddle (swap this for flippers)
68
- scripts/TouchRules.ts ball-hit → score / damage + sound, particles, popups
69
- scripts/ScoreHud.ts signals → HUD widgets
70
- scripts/GameRules.ts score / lives / win / lose / end flow + jingle
71
- scripts/MenuFlow.ts menu PLAY button
72
- sprites/ph-*.png near-white placeholders (tinted via core:tint)
73
- ```
74
-
75
- Signals:
76
-
77
- ```
78
- BallBody --ball-hit(kind,nodeId,speed,x,y)--> TouchRules
79
- TouchRules --touch-scored/touch-damaged--> GameRules --score-changed--> ScoreHud
80
- --lives-changed-->
81
- ```