@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,307 +1,307 @@
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
+ /**
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 +1,10 @@
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
+ 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