@pix3/cli 1.6.4 → 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,263 +0,0 @@
1
- /**
2
- * TouchRules — turns ball contacts into meaning, and into feel.
3
- *
4
- * Same role (and the same `rules` format) as in the other recipes; only the
5
- * source of the contact differs. Here it listens to `BallBody`'s `ball-hit`
6
- * signal instead of running its own overlap test, because the swept solver
7
- * already knows exactly what was hit and how hard.
8
- *
9
- * `ball-hit` payload: (kind, nodeId, speed, x, y) where kind is `wall`,
10
- * `paddle`, `bumper` or `drain`. `x`/`y` are WORLD coordinates, which is why the
11
- * particles and the score popup can be spawned straight at the contact point.
12
- *
13
- * It emits the same semantic signals every recipe uses, on `game-root`:
14
- * `touch-scored` (amount, x, y)
15
- * `touch-damaged` (amount, x, y)
16
- *
17
- * `rules` format: `kind:outcome:amount`, entries separated by `;`.
18
- *
19
- * The feel is all engine one-liners — copy them next to whatever mechanic you
20
- * add, they cost nothing to call:
21
- * `scene.audio.sfx('score')` procedural SFX, no asset needed
22
- * `scene.juice.burst({x, y})` 2D particles that free themselves
23
- * `scene.juice.floatText('+100')` score popup
24
- * `scene.juice.punchScale(node)` / `flash` / `shake` / `scene.time.hitstop`
25
- * Spawn them at BOARD-space anchors (a node or the hit point) rather than on the
26
- * HUD: the HUD is a CanvasLayer2D drawn after post-processing, so effects
27
- * parented there never bloom.
28
- */
29
- import { Script, type PropertySchema } from '@pix3/runtime';
30
-
31
- type TouchOutcome = 'score' | 'damage';
32
-
33
- interface TouchRule {
34
- kind: string;
35
- outcome: TouchOutcome;
36
- amount: number;
37
- }
38
-
39
- function parseRules(text: string): Map<string, TouchRule> {
40
- const rules = new Map<string, TouchRule>();
41
- for (const entry of text.split(';')) {
42
- const parts = entry.trim().split(':');
43
- if (parts.length < 2 || !parts[0].trim()) {
44
- continue;
45
- }
46
- const amount = Number(parts[2] ?? 1);
47
- rules.set(parts[0].trim(), {
48
- kind: parts[0].trim(),
49
- outcome: parts[1].trim() === 'damage' ? 'damage' : 'score',
50
- amount: Number.isFinite(amount) && amount > 0 ? amount : 1,
51
- });
52
- }
53
- return rules;
54
- }
55
-
56
- export class TouchRules extends Script {
57
- constructor(id: string, type: string) {
58
- super(id, type);
59
- this.config = {
60
- // Node carrying BallBody.
61
- ballNode: 'ball',
62
- // kind:outcome:amount pairs, separated by ';'.
63
- rules: 'bumper:score:100;paddle:score:10;drain:damage:1',
64
- // Ignore contacts softer than this (px/s) so a resting ball scores nothing.
65
- minImpactSpeed: 60,
66
- // Feel. Raise for arcade, drop to 0 to switch a piece of it off.
67
- punchAmount: 0.34,
68
- hitstopMs: 50,
69
- burstCount: 18,
70
- popupFormat: '+{value}',
71
- feedbackColor: '#ffe066',
72
- sfxEnabled: true,
73
- // Optional asset overrides. When set they REPLACE the synth preset.
74
- scoreSound: '',
75
- damageSound: '',
76
- };
77
- }
78
-
79
- static getPropertySchema(): PropertySchema {
80
- const str = (name: string, label: string, group: string, editor?: 'audio-resource') => ({
81
- name,
82
- type: 'string' as const,
83
- ui: { label, group, editor },
84
- getValue: (s: unknown) => (s as TouchRules).config[name],
85
- setValue: (s: unknown, v: unknown) => {
86
- (s as TouchRules).config[name] = typeof v === 'string' ? v : '';
87
- },
88
- });
89
- const num = (name: string, label: string, min: number, max: number, step: number) => ({
90
- name,
91
- type: 'number' as const,
92
- ui: { label, group: 'Feel', min, max, step, slider: true },
93
- getValue: (s: unknown) => (s as TouchRules).config[name],
94
- setValue: (s: unknown, v: unknown) => {
95
- const n = Number(v);
96
- (s as TouchRules).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
97
- },
98
- });
99
-
100
- return {
101
- nodeType: 'TouchRules',
102
- properties: [
103
- str('ballNode', 'Ball Node', 'Contact'),
104
- str('rules', 'Rules', 'Contact'),
105
- {
106
- name: 'minImpactSpeed',
107
- type: 'number',
108
- ui: { label: 'Min Impact Speed', group: 'Contact', min: 0, max: 2000, step: 10, slider: true },
109
- getValue: s => (s as TouchRules).config.minImpactSpeed,
110
- setValue: (s, v) => {
111
- const n = Number(v);
112
- (s as TouchRules).config.minImpactSpeed = Math.min(2000, Math.max(0, Number.isFinite(n) ? n : 0));
113
- },
114
- },
115
- num('punchAmount', 'Punch Scale', 0, 1, 0.01),
116
- num('hitstopMs', 'Hitstop (ms)', 0, 200, 5),
117
- num('burstCount', 'Burst Particles', 0, 120, 1),
118
- str('popupFormat', 'Score Popup', 'Feel'),
119
- str('feedbackColor', 'Feedback Colour', 'Feel'),
120
- {
121
- name: 'sfxEnabled',
122
- type: 'boolean',
123
- ui: { label: 'Sound', group: 'Feedback' },
124
- getValue: s => (s as TouchRules).config.sfxEnabled,
125
- setValue: (s, v) => {
126
- (s as TouchRules).config.sfxEnabled = Boolean(v);
127
- },
128
- },
129
- str('scoreSound', 'Score Sound', 'Feedback', 'audio-resource'),
130
- str('damageSound', 'Damage Sound', 'Feedback', 'audio-resource'),
131
- ],
132
- groups: {
133
- Contact: { label: 'Contact', expanded: true },
134
- Feel: { label: 'Feel', expanded: true },
135
- Feedback: { label: 'Sound', expanded: false },
136
- },
137
- };
138
- }
139
-
140
- onStart(): void {
141
- const ball = this.findNode(String(this.config.ballNode ?? ''));
142
- if (!ball) {
143
- console.warn(`[TouchRules] Ball node "${this.config.ballNode}" not found.`);
144
- return;
145
- }
146
- ball.connect('ball-hit', this, (...args: unknown[]) =>
147
- this.resolve(String(args[0] ?? ''), String(args[1] ?? ''), Number(args[2]) || 0, Number(args[3]) || 0, Number(args[4]) || 0)
148
- );
149
- }
150
-
151
- private resolve(kind: string, nodeId: string, speed: number, x: number, y: number): void {
152
- const scene = this.scene;
153
- const owner = this.node;
154
- if (!scene || !owner) {
155
- return;
156
- }
157
- // A resting ball rattling on the paddle must not score — or click.
158
- if (kind !== 'drain' && speed < Math.max(0, Number(this.config.minImpactSpeed) || 0)) {
159
- return;
160
- }
161
-
162
- const rule = parseRules(String(this.config.rules ?? '')).get(kind);
163
- // Every contact is audible, including the walls the rules say nothing about.
164
- this.playContactSound(kind, rule?.outcome ?? null, speed);
165
- if (!rule) {
166
- return;
167
- }
168
-
169
- if (rule.outcome === 'damage') {
170
- owner.emit('touch-damaged', rule.amount, x, y);
171
- scene.juice.flash({ color: '#ff2d6f', intensity: 0.45, durationSec: 0.24 });
172
- // The board is safe to shake here: BallBody freezes the ball for
173
- // `resetDelaySec` after a drain, so no collider moves under a live ball.
174
- scene.juice.shake('board', { amplitude: 26, frequency: 22, duration: 0.3 });
175
- this.burst(x, y, { colors: ['#ff2d6f', '#ffffff'], scale: 1, speed: 320 });
176
- return;
177
- }
178
-
179
- owner.emit('touch-scored', rule.amount, x, y);
180
- const target = nodeId ? this.findNode(nodeId) : null;
181
- if (target) {
182
- const amount = Math.max(0, Number(this.config.punchAmount) || 0);
183
- if (amount > 0) {
184
- scene.juice.punchScale(target, { amount, duration: 0.24 });
185
- }
186
- }
187
- // Hitstop is the bumper's weight — the paddle is touched constantly and
188
- // would stutter the whole run.
189
- if (kind === 'bumper') {
190
- scene.time.hitstop(Math.max(0, Number(this.config.hitstopMs) || 0));
191
- }
192
- const accent = this.accentColor();
193
- this.burst(x, y, {
194
- colors: ['#ffffff', accent],
195
- scale: kind === 'bumper' ? 1 : 0.5,
196
- speed: kind === 'bumper' ? 420 : 260,
197
- });
198
- this.popup(rule.amount, x, y, accent);
199
- }
200
-
201
- private accentColor(): string {
202
- const color = String(this.config.feedbackColor ?? '').trim();
203
- return color.length > 0 ? color : '#ffe066';
204
- }
205
-
206
- /** Contact particles at the hit point (world space, so they bloom with the board). */
207
- private burst(
208
- x: number,
209
- y: number,
210
- options: { colors: string[]; scale: number; speed: number }
211
- ): void {
212
- const count = Math.round(Math.max(0, Number(this.config.burstCount) || 0) * options.scale);
213
- if (count <= 0) {
214
- return;
215
- }
216
- this.scene?.juice.burst(
217
- { x, y },
218
- { count, colors: options.colors, speed: options.speed, sizePx: 12, lifeSec: 0.45 }
219
- );
220
- }
221
-
222
- /** "+100" rising off the contact. Empty `popupFormat` switches it off. */
223
- private popup(amount: number, x: number, y: number, color: string): void {
224
- const format = String(this.config.popupFormat ?? '');
225
- if (!format) {
226
- return;
227
- }
228
- this.scene?.juice.floatText(format.replace('{value}', String(Math.round(amount))), {
229
- at: { x, y },
230
- color,
231
- fontSizePx: 42,
232
- glow: true,
233
- glowStrength: 2,
234
- });
235
- }
236
-
237
- /**
238
- * An asset wins when one is authored; otherwise the procedural preset plays,
239
- * so a fresh project has sound with no audio files at all. Bounces are
240
- * pitched by impact speed — the cheapest "harder hit = brighter sound" there is.
241
- */
242
- private playContactSound(kind: string, outcome: TouchOutcome | null, speed: number): void {
243
- if (this.config.sfxEnabled === false) {
244
- return;
245
- }
246
- const damage = outcome === 'damage';
247
- const asset = String((damage ? this.config.damageSound : this.config.scoreSound) ?? '');
248
- if (asset && (damage || outcome === 'score')) {
249
- void this.scene?.audio.play(asset, { bus: 'sfx', pitchVariation: 0.08 });
250
- return;
251
- }
252
- if (damage) {
253
- this.scene?.audio.sfx('lose', { volume: 0.9 });
254
- return;
255
- }
256
- if (kind === 'bumper') {
257
- this.scene?.audio.sfx('score', { pitch: 1 + Math.random() * 0.12 });
258
- return;
259
- }
260
- const pitch = Math.min(1.5, Math.max(0.8, 0.85 + speed / 3200));
261
- this.scene?.audio.sfx('bounce', { pitch, volume: 0.8 });
262
- }
263
- }
@@ -1,307 +0,0 @@
1
- /**
2
- * ball-collision — swept (continuous) circle physics. Pure math, no engine
3
- * imports, so it is unit-testable and cheap to reason about.
4
- *
5
- * Why this exists: the engine has NO rigidbody solver, and `core:Hitbox2D` is an
6
- * axis-aligned overlap test (rotation ignored). A ball that moves 900 px in a
7
- * frame would tunnel straight through a wall under any discrete overlap test,
8
- * and a rotated paddle/flipper cannot be described by an AABB at all. So the
9
- * recipe carries its own: every collider is a SEGMENT (four per oriented box) or
10
- * a CIRCLE, and each substep sweeps the ball's whole displacement against them,
11
- * takes the earliest time of impact, reflects, and continues with what is left
12
- * of the step. No amount of speed can skip a collider.
13
- */
14
-
15
- export interface SegmentCollider {
16
- ax: number;
17
- ay: number;
18
- bx: number;
19
- by: number;
20
- /** 1 = perfect bounce, <1 absorbs, >1 kicks (bumpers). */
21
- restitution: number;
22
- /** Caller-defined tag, handed back on every hit. */
23
- id: string;
24
- }
25
-
26
- export interface CircleCollider {
27
- cx: number;
28
- cy: number;
29
- r: number;
30
- restitution: number;
31
- id: string;
32
- }
33
-
34
- export interface BallState {
35
- x: number;
36
- y: number;
37
- vx: number;
38
- vy: number;
39
- }
40
-
41
- export interface BallOptions {
42
- radius: number;
43
- /** Vertical acceleration in px/s² (negative pulls down — Y is up). */
44
- gravity: number;
45
- maxSpeed: number;
46
- /** Re-energise the ball if it drops below this (0 = off). */
47
- minSpeed: number;
48
- /** Fraction of speed kept per second (1 = frictionless). */
49
- damping: number;
50
- /** Collision substeps per frame. More = more stable, never needed for tunneling. */
51
- substeps: number;
52
- }
53
-
54
- export interface BallHit {
55
- id: string;
56
- x: number;
57
- y: number;
58
- /** Impact speed along the contact normal (always positive). */
59
- speed: number;
60
- }
61
-
62
- interface SweepHit {
63
- t: number;
64
- nx: number;
65
- ny: number;
66
- }
67
-
68
- const EPS = 1e-9;
69
- /** Pushed out this far along the normal after a hit so it cannot re-hit at t=0. */
70
- const SKIN = 0.01;
71
- const MAX_ITERATIONS = 6;
72
-
73
- function closestPointOnSegment(
74
- px: number,
75
- py: number,
76
- ax: number,
77
- ay: number,
78
- bx: number,
79
- by: number
80
- ): { x: number; y: number } {
81
- const ex = bx - ax;
82
- const ey = by - ay;
83
- const len2 = ex * ex + ey * ey;
84
- if (len2 < EPS) {
85
- return { x: ax, y: ay };
86
- }
87
- const s = Math.min(1, Math.max(0, ((px - ax) * ex + (py - ay) * ey) / len2));
88
- return { x: ax + ex * s, y: ay + ey * s };
89
- }
90
-
91
- /** Sweep a moving circle against a static point (the endpoint caps of a segment). */
92
- function sweepCirclePoint(
93
- px: number,
94
- py: number,
95
- vx: number,
96
- vy: number,
97
- r: number,
98
- cx: number,
99
- cy: number
100
- ): SweepHit | null {
101
- const mx = px - cx;
102
- const my = py - cy;
103
- const a = vx * vx + vy * vy;
104
- if (a < EPS) {
105
- return null;
106
- }
107
- const b = 2 * (mx * vx + my * vy);
108
- const c = mx * mx + my * my - r * r;
109
- const disc = b * b - 4 * a * c;
110
- if (disc < 0) {
111
- return null;
112
- }
113
- const root = Math.sqrt(disc);
114
- const t = (-b - root) / (2 * a);
115
- if (t < 0 || t > 1) {
116
- return null;
117
- }
118
- const hx = px + vx * t - cx;
119
- const hy = py + vy * t - cy;
120
- const len = Math.hypot(hx, hy) || 1;
121
- return { t, nx: hx / len, ny: hy / len };
122
- }
123
-
124
- /** Sweep a moving circle against a static segment. Returns the approaching hit only. */
125
- export function sweepCircleSegment(
126
- px: number,
127
- py: number,
128
- vx: number,
129
- vy: number,
130
- r: number,
131
- ax: number,
132
- ay: number,
133
- bx: number,
134
- by: number
135
- ): SweepHit | null {
136
- const ex = bx - ax;
137
- const ey = by - ay;
138
- const len2 = ex * ex + ey * ey;
139
- if (len2 < EPS) {
140
- return approaching(sweepCirclePoint(px, py, vx, vy, r, ax, ay), vx, vy);
141
- }
142
- const len = Math.sqrt(len2);
143
- const nx = -ey / len;
144
- const ny = ex / len;
145
- const d0 = (px - ax) * nx + (py - ay) * ny;
146
- const dv = vx * nx + vy * ny;
147
- const side = d0 >= 0 ? 1 : -1;
148
-
149
- // Face hit: the moment the centre reaches distance r from the infinite line,
150
- // provided the contact point lies within the segment span.
151
- if (dv * side < -EPS) {
152
- const t = (side * r - d0) / dv;
153
- if (t >= 0 && t <= 1) {
154
- const hx = px + vx * t;
155
- const hy = py + vy * t;
156
- const s = ((hx - ax) * ex + (hy - ay) * ey) / len2;
157
- if (s >= 0 && s <= 1) {
158
- return approaching({ t, nx: nx * side, ny: ny * side }, vx, vy);
159
- }
160
- }
161
- }
162
-
163
- // Otherwise the only possible contacts are the rounded endpoints.
164
- const ha = sweepCirclePoint(px, py, vx, vy, r, ax, ay);
165
- const hb = sweepCirclePoint(px, py, vx, vy, r, bx, by);
166
- const best = ha && hb ? (ha.t <= hb.t ? ha : hb) : (ha ?? hb);
167
- return approaching(best, vx, vy);
168
- }
169
-
170
- /** Sweep a moving circle against a static circle. */
171
- export function sweepCircleCircle(
172
- px: number,
173
- py: number,
174
- vx: number,
175
- vy: number,
176
- r: number,
177
- cx: number,
178
- cy: number,
179
- cr: number
180
- ): SweepHit | null {
181
- return approaching(sweepCirclePoint(px, py, vx, vy, r + cr, cx, cy), vx, vy);
182
- }
183
-
184
- /** Drop hits whose normal points along the motion (already separating). */
185
- function approaching(hit: SweepHit | null, vx: number, vy: number): SweepHit | null {
186
- if (!hit) {
187
- return null;
188
- }
189
- return vx * hit.nx + vy * hit.ny < -EPS ? hit : null;
190
- }
191
-
192
- /** Push the ball out of anything it already overlaps (authored overlaps, rounding). */
193
- function depenetrate(
194
- ball: BallState,
195
- radius: number,
196
- segments: readonly SegmentCollider[],
197
- circles: readonly CircleCollider[]
198
- ): void {
199
- for (const seg of segments) {
200
- const p = closestPointOnSegment(ball.x, ball.y, seg.ax, seg.ay, seg.bx, seg.by);
201
- const dx = ball.x - p.x;
202
- const dy = ball.y - p.y;
203
- const dist = Math.hypot(dx, dy);
204
- if (dist < radius && dist > EPS) {
205
- ball.x = p.x + (dx / dist) * (radius + SKIN);
206
- ball.y = p.y + (dy / dist) * (radius + SKIN);
207
- }
208
- }
209
- for (const circle of circles) {
210
- const dx = ball.x - circle.cx;
211
- const dy = ball.y - circle.cy;
212
- const dist = Math.hypot(dx, dy);
213
- const min = radius + circle.r;
214
- if (dist < min && dist > EPS) {
215
- ball.x = circle.cx + (dx / dist) * (min + SKIN);
216
- ball.y = circle.cy + (dy / dist) * (min + SKIN);
217
- }
218
- }
219
- }
220
-
221
- /**
222
- * Advance the ball by `dt` seconds, resolving every collider continuously.
223
- * Mutates `ball`; returns one entry per contact resolved this frame.
224
- */
225
- export function stepBall(
226
- ball: BallState,
227
- options: BallOptions,
228
- segments: readonly SegmentCollider[],
229
- circles: readonly CircleCollider[],
230
- dt: number
231
- ): BallHit[] {
232
- const hits: BallHit[] = [];
233
- if (dt <= 0) {
234
- return hits;
235
- }
236
- const substeps = Math.max(1, Math.min(16, Math.round(options.substeps) || 1));
237
- const radius = Math.max(0.001, options.radius);
238
- const subDt = dt / substeps;
239
-
240
- for (let step = 0; step < substeps; step++) {
241
- ball.vy += options.gravity * subDt;
242
- if (options.damping < 1) {
243
- const keep = Math.pow(Math.max(0, options.damping), subDt);
244
- ball.vx *= keep;
245
- ball.vy *= keep;
246
- }
247
- clampSpeed(ball, options);
248
- depenetrate(ball, radius, segments, circles);
249
-
250
- let remaining = subDt;
251
- for (let iteration = 0; iteration < MAX_ITERATIONS && remaining > EPS; iteration++) {
252
- const dx = ball.vx * remaining;
253
- const dy = ball.vy * remaining;
254
-
255
- let best: SweepHit | null = null;
256
- let bestId = '';
257
- let bestRestitution = 1;
258
- for (const seg of segments) {
259
- const hit = sweepCircleSegment(ball.x, ball.y, dx, dy, radius, seg.ax, seg.ay, seg.bx, seg.by);
260
- if (hit && (!best || hit.t < best.t)) {
261
- best = hit;
262
- bestId = seg.id;
263
- bestRestitution = seg.restitution;
264
- }
265
- }
266
- for (const circle of circles) {
267
- const hit = sweepCircleCircle(ball.x, ball.y, dx, dy, radius, circle.cx, circle.cy, circle.r);
268
- if (hit && (!best || hit.t < best.t)) {
269
- best = hit;
270
- bestId = circle.id;
271
- bestRestitution = circle.restitution;
272
- }
273
- }
274
-
275
- if (!best) {
276
- ball.x += dx;
277
- ball.y += dy;
278
- remaining = 0;
279
- break;
280
- }
281
-
282
- ball.x += dx * best.t + best.nx * SKIN;
283
- ball.y += dy * best.t + best.ny * SKIN;
284
-
285
- const vn = ball.vx * best.nx + ball.vy * best.ny;
286
- const impact = Math.abs(vn);
287
- ball.vx -= (1 + bestRestitution) * vn * best.nx;
288
- ball.vy -= (1 + bestRestitution) * vn * best.ny;
289
- clampSpeed(ball, options);
290
-
291
- hits.push({ id: bestId, x: ball.x, y: ball.y, speed: impact });
292
- remaining *= 1 - best.t;
293
- }
294
- }
295
- return hits;
296
- }
297
-
298
- function clampSpeed(ball: BallState, options: BallOptions): void {
299
- const speed = Math.hypot(ball.vx, ball.vy);
300
- if (options.maxSpeed > 0 && speed > options.maxSpeed) {
301
- ball.vx = (ball.vx / speed) * options.maxSpeed;
302
- ball.vy = (ball.vy / speed) * options.maxSpeed;
303
- } else if (options.minSpeed > 0 && speed > EPS && speed < options.minSpeed) {
304
- ball.vx = (ball.vx / speed) * options.minSpeed;
305
- ball.vy = (ball.vy / speed) * options.minSpeed;
306
- }
307
- }
@@ -1,10 +0,0 @@
1
- id: recipe-bouncer-2d
2
- title: Bouncer 2D
3
- description: A ball under gravity bounces off walls, bumpers and a paddle you steer; hits score, a bottom drain costs a life. Continuous (swept) collision, so nothing tunnels at speed — arkanoid, pong, plinko, pinball.
4
- projectType: 2d
5
- targetPlatform: universal
6
- viewport: { width: 1080, height: 1920 }
7
- # The build/full-run entry scene (Project Settings → Default Export Scene Path).
8
- # The menu boots first; PLAY transitions to main.pix3scene (the game).
9
- entryScene: scenes/menu.pix3scene
10
- order: 61
@@ -1,22 +0,0 @@
1
- # Grid 3D
2
-
3
- A playable skeleton for tap-to-carve 3D puzzles: a solid block of cubes, a
4
- perspective camera looking at it, and a tap that removes one cube. Some cubes
5
- are core — hitting one costs a life. Clear every non-core cube to win.
6
-
7
- It plays as shipped — press play in `scenes/main.pix3scene` before changing
8
- anything, so you know what "working" looked like.
9
-
10
- - `scenes/menu.pix3scene` — entry scene; PLAY transitions to the board.
11
- - `scenes/main.pix3scene` — the board: camera, lights, `board-anchor`, HUD.
12
- - `scenes/ui/result.pix3scene` — the end screen (RETRY / MENU), instanced into
13
- main hidden (`visible: false` is editor-only; `GridRules` shows it at run time).
14
- - `scripts/GridBoard.ts` — builds the cubes and turns a tap into a removal.
15
- - `scripts/GridRules.ts` — score, lives, win/lose, the debug provider.
16
- - `scripts/ScoreHud.ts`, `scripts/MenuFlow.ts` — display and menu, shared with
17
- the other recipes.
18
-
19
- The cubes are generated, not authored: board size lives in `GridBoard`'s config
20
- (`sizeX/sizeY/sizeZ`), so a bigger block is one number, not a scene edit.
21
-
22
- Read `design/recipe.md` for the node map, the tunables and what not to touch.