@pix3/cli 1.6.0 → 1.6.3

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-CBFYKDS7.mjs} +1 -1
  6. package/dist/smoke/prebuilt/chunk-VJHJ5BCO.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-CFQFEI52.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 +39 -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 +43 -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,298 +1,298 @@
1
- /**
2
- * BallBody — the ball's motion and collisions.
3
- *
4
- * The engine ships no rigidbody solver, and `core:Hitbox2D` is an axis-aligned
5
- * overlap test that ignores rotation — useless for a fast ball and impossible
6
- * for an angled paddle. So this component runs the swept solver in
7
- * `ball-collision.ts` and builds its colliders every frame from the LIVE WORLD
8
- * TRANSFORMS of ordinary marker nodes:
9
- *
10
- * `wallsNode` / `paddlesNode` children → oriented boxes (4 segments each),
11
- * so a rotated paddle or flipper works
12
- * `bumpersNode` children → circles
13
- * `drainNode` → trigger box: entering it loses a ball
14
- *
15
- * Because the geometry is re-read each frame, rotating or moving a marker node
16
- * (from a script, an AnimationPlayer clip, or the inspector) just works.
17
- *
18
- * On contact it emits `ball-hit` (kind, nodeId, speed, x, y) on its own node,
19
- * and `ball-drained` when it is lost. TouchRules turns those into score/damage.
20
- * Never import rapier: it is a ~2 MB lazy wasm payload this does not need.
21
- */
22
- import { NodeBase, Script, type PropertySchema } from '@pix3/runtime';
23
-
24
- import {
25
- stepBall,
26
- type BallOptions,
27
- type BallState,
28
- type CircleCollider,
29
- type SegmentCollider,
30
- } from './ball-collision';
31
-
32
- /** Oriented box in world space: centre, unit axes, half extents. */
33
- interface Obb {
34
- cx: number;
35
- cy: number;
36
- ux: number;
37
- uy: number;
38
- hw: number;
39
- hh: number;
40
- }
41
-
42
- /** Any 2D node that carries a size (ColorRect2D, Sprite2D, Group2D, …). */
43
- interface Sized {
44
- width?: number;
45
- height?: number;
46
- }
47
-
48
- function readObb(node: NodeBase): Obb {
49
- node.updateWorldMatrix(true, false);
50
- const e = node.matrixWorld.elements;
51
- const scaleX = Math.hypot(e[0], e[1]) || 1;
52
- const scaleY = Math.hypot(e[4], e[5]) || 1;
53
- const sized = node as unknown as Sized;
54
- return {
55
- cx: e[12],
56
- cy: e[13],
57
- ux: e[0] / scaleX,
58
- uy: e[1] / scaleX,
59
- hw: ((Number(sized.width) || 0) / 2) * scaleX,
60
- hh: ((Number(sized.height) || 0) / 2) * scaleY,
61
- };
62
- }
63
-
64
- function obbSegments(obb: Obb, restitution: number, id: string, out: SegmentCollider[]): void {
65
- // Perpendicular of (ux, uy) is (-uy, ux) — the box's local Y axis in world space.
66
- const ax = obb.ux * obb.hw;
67
- const ay = obb.uy * obb.hw;
68
- const bx = -obb.uy * obb.hh;
69
- const by = obb.ux * obb.hh;
70
- const corners = [
71
- [obb.cx - ax - bx, obb.cy - ay - by],
72
- [obb.cx + ax - bx, obb.cy + ay - by],
73
- [obb.cx + ax + bx, obb.cy + ay + by],
74
- [obb.cx - ax + bx, obb.cy - ay + by],
75
- ];
76
- for (let i = 0; i < 4; i++) {
77
- const from = corners[i];
78
- const to = corners[(i + 1) % 4];
79
- out.push({ ax: from[0], ay: from[1], bx: to[0], by: to[1], restitution, id });
80
- }
81
- }
82
-
83
- function pointInObb(obb: Obb, x: number, y: number): boolean {
84
- const dx = x - obb.cx;
85
- const dy = y - obb.cy;
86
- const along = dx * obb.ux + dy * obb.uy;
87
- const across = dx * -obb.uy + dy * obb.ux;
88
- return Math.abs(along) <= obb.hw && Math.abs(across) <= obb.hh;
89
- }
90
-
91
- function childNodes(parent: NodeBase | null): NodeBase[] {
92
- if (!parent) {
93
- return [];
94
- }
95
- return parent.children.filter((child): child is NodeBase => child instanceof NodeBase);
96
- }
97
-
98
- export class BallBody extends Script {
99
- private readonly state: BallState = { x: 0, y: 0, vx: 0, vy: 0 };
100
- private readonly segments: SegmentCollider[] = [];
101
- private readonly circles: CircleCollider[] = [];
102
- private readonly kinds = new Map<string, string>();
103
- private homeX = 0;
104
- private homeY = 0;
105
- private respawnIn = 0;
106
-
107
- constructor(id: string, type: string) {
108
- super(id, type);
109
- this.config = {
110
- radius: 30,
111
- gravity: -2400,
112
- maxSpeed: 2600,
113
- minSpeed: 420,
114
- damping: 0.999,
115
- substeps: 4,
116
- launchSpeed: 1150,
117
- launchAngleDeg: 72,
118
- wallsNode: 'walls',
119
- paddlesNode: 'paddles',
120
- bumpersNode: 'bumpers',
121
- drainNode: 'drain',
122
- wallRestitution: 1,
123
- paddleRestitution: 1.05,
124
- bumperRestitution: 1.3,
125
- resetDelaySec: 0.7,
126
- };
127
- }
128
-
129
- static getPropertySchema(): PropertySchema {
130
- const num = (name: string, label: string, min: number, max: number, step: number, group: string) => ({
131
- name,
132
- type: 'number' as const,
133
- ui: { label, group, min, max, step, slider: true },
134
- getValue: (s: unknown) => (s as BallBody).config[name],
135
- setValue: (s: unknown, v: unknown) => {
136
- const n = Number(v);
137
- (s as BallBody).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
138
- },
139
- });
140
- const str = (name: string, label: string) => ({
141
- name,
142
- type: 'string' as const,
143
- ui: { label, group: 'Colliders' },
144
- getValue: (s: unknown) => (s as BallBody).config[name],
145
- setValue: (s: unknown, v: unknown) => {
146
- (s as BallBody).config[name] = typeof v === 'string' ? v : '';
147
- },
148
- });
149
-
150
- return {
151
- nodeType: 'BallBody',
152
- properties: [
153
- num('radius', 'Radius', 2, 400, 1, 'Body'),
154
- num('gravity', 'Gravity (px/s²)', -12000, 12000, 50, 'Body'),
155
- num('maxSpeed', 'Max Speed', 100, 12000, 50, 'Body'),
156
- num('minSpeed', 'Min Speed', 0, 4000, 10, 'Body'),
157
- num('damping', 'Damping', 0.9, 1, 0.001, 'Body'),
158
- num('substeps', 'Substeps', 1, 16, 1, 'Body'),
159
- num('launchSpeed', 'Launch Speed', 0, 6000, 25, 'Launch'),
160
- num('launchAngleDeg', 'Launch Angle (°)', 5, 175, 1, 'Launch'),
161
- num('resetDelaySec', 'Reset Delay (s)', 0, 5, 0.1, 'Launch'),
162
- num('wallRestitution', 'Wall Bounce', 0, 2, 0.01, 'Bounce'),
163
- num('paddleRestitution', 'Paddle Bounce', 0, 2, 0.01, 'Bounce'),
164
- num('bumperRestitution', 'Bumper Bounce', 0, 3, 0.01, 'Bounce'),
165
- str('wallsNode', 'Walls Node'),
166
- str('paddlesNode', 'Paddles Node'),
167
- str('bumpersNode', 'Bumpers Node'),
168
- str('drainNode', 'Drain Node'),
169
- ],
170
- groups: {
171
- Body: { label: 'Body', expanded: true },
172
- Launch: { label: 'Launch', expanded: true },
173
- Bounce: { label: 'Bounce', expanded: true },
174
- Colliders: { label: 'Collider Sources', expanded: false },
175
- },
176
- };
177
- }
178
-
179
- onStart(): void {
180
- const node = this.node;
181
- if (!node) {
182
- return;
183
- }
184
- node.updateWorldMatrix(true, false);
185
- this.homeX = node.matrixWorld.elements[12];
186
- this.homeY = node.matrixWorld.elements[13];
187
- this.launch();
188
- }
189
-
190
- onUpdate(dt: number): void {
191
- const node = this.node;
192
- if (!node || dt <= 0) {
193
- return;
194
- }
195
- if (this.respawnIn > 0) {
196
- this.respawnIn -= dt;
197
- if (this.respawnIn <= 0) {
198
- this.launch();
199
- }
200
- return;
201
- }
202
-
203
- this.rebuildColliders();
204
- const options: BallOptions = {
205
- radius: Math.max(1, Number(this.config.radius) || 1),
206
- gravity: Number(this.config.gravity) || 0,
207
- maxSpeed: Number(this.config.maxSpeed) || 0,
208
- minSpeed: Number(this.config.minSpeed) || 0,
209
- damping: Number(this.config.damping) || 1,
210
- substeps: Number(this.config.substeps) || 1,
211
- };
212
-
213
- for (const hit of stepBall(this.state, options, this.segments, this.circles, dt)) {
214
- node.emit('ball-hit', this.kinds.get(hit.id) ?? 'wall', hit.id, hit.speed, hit.x, hit.y);
215
- }
216
- this.writeBackPosition();
217
- this.checkDrain();
218
- }
219
-
220
- /** Send the ball on its way (also used after a drain). Override the angle for a plunger. */
221
- launch(): void {
222
- const speed = Math.max(0, Number(this.config.launchSpeed) || 0);
223
- const angle = ((Number(this.config.launchAngleDeg) || 90) * Math.PI) / 180;
224
- const dir = Math.random() < 0.5 ? -1 : 1;
225
- this.state.x = this.homeX;
226
- this.state.y = this.homeY;
227
- this.state.vx = Math.cos(angle) * speed * dir;
228
- this.state.vy = Math.sin(angle) * speed;
229
- this.writeBackPosition();
230
- this.node?.emit('ball-launched');
231
- }
232
-
233
- private rebuildColliders(): void {
234
- this.segments.length = 0;
235
- this.circles.length = 0;
236
- this.kinds.clear();
237
-
238
- const wallBounce = Number(this.config.wallRestitution) || 1;
239
- for (const child of childNodes(this.findNode(String(this.config.wallsNode ?? '')))) {
240
- obbSegments(readObb(child), wallBounce, child.nodeId, this.segments);
241
- this.kinds.set(child.nodeId, 'wall');
242
- }
243
-
244
- const paddleBounce = Number(this.config.paddleRestitution) || 1;
245
- for (const child of childNodes(this.findNode(String(this.config.paddlesNode ?? '')))) {
246
- obbSegments(readObb(child), paddleBounce, child.nodeId, this.segments);
247
- this.kinds.set(child.nodeId, 'paddle');
248
- }
249
-
250
- const bumperBounce = Number(this.config.bumperRestitution) || 1;
251
- for (const child of childNodes(this.findNode(String(this.config.bumpersNode ?? '')))) {
252
- const obb = readObb(child);
253
- this.circles.push({ cx: obb.cx, cy: obb.cy, r: Math.max(1, obb.hw), restitution: bumperBounce, id: child.nodeId });
254
- this.kinds.set(child.nodeId, 'bumper');
255
- }
256
- }
257
-
258
- private checkDrain(): void {
259
- const drain = this.findNode(String(this.config.drainNode ?? ''));
260
- if (!drain) {
261
- return;
262
- }
263
- const obb = readObb(drain);
264
- const below = this.state.y < obb.cy - obb.hh;
265
- if (!below && !pointInObb(obb, this.state.x, this.state.y)) {
266
- return;
267
- }
268
- const node = this.node;
269
- node?.emit('ball-hit', 'drain', drain.nodeId, Math.hypot(this.state.vx, this.state.vy), this.state.x, this.state.y);
270
- node?.emit('ball-drained');
271
- this.state.vx = 0;
272
- this.state.vy = 0;
273
- this.respawnIn = Math.max(0.05, Number(this.config.resetDelaySec) || 0.05);
274
- }
275
-
276
- /** World simulation → the ball node's parent-local position. */
277
- private writeBackPosition(): void {
278
- const node = this.node;
279
- const parent = node?.parentNode ?? null;
280
- if (!node) {
281
- return;
282
- }
283
- if (!parent) {
284
- node.position.set(this.state.x, this.state.y, node.position.z);
285
- return;
286
- }
287
- parent.updateWorldMatrix(true, false);
288
- const e = parent.matrixWorld.elements;
289
- const dx = this.state.x - e[12];
290
- const dy = this.state.y - e[13];
291
- const det = e[0] * e[5] - e[1] * e[4];
292
- if (Math.abs(det) < 1e-8) {
293
- node.position.set(dx, dy, node.position.z);
294
- return;
295
- }
296
- node.position.set((e[5] * dx - e[4] * dy) / det, (e[0] * dy - e[1] * dx) / det, node.position.z);
297
- }
298
- }
1
+ /**
2
+ * BallBody — the ball's motion and collisions.
3
+ *
4
+ * The engine ships no rigidbody solver, and `core:Hitbox2D` is an axis-aligned
5
+ * overlap test that ignores rotation — useless for a fast ball and impossible
6
+ * for an angled paddle. So this component runs the swept solver in
7
+ * `ball-collision.ts` and builds its colliders every frame from the LIVE WORLD
8
+ * TRANSFORMS of ordinary marker nodes:
9
+ *
10
+ * `wallsNode` / `paddlesNode` children → oriented boxes (4 segments each),
11
+ * so a rotated paddle or flipper works
12
+ * `bumpersNode` children → circles
13
+ * `drainNode` → trigger box: entering it loses a ball
14
+ *
15
+ * Because the geometry is re-read each frame, rotating or moving a marker node
16
+ * (from a script, an AnimationPlayer clip, or the inspector) just works.
17
+ *
18
+ * On contact it emits `ball-hit` (kind, nodeId, speed, x, y) on its own node,
19
+ * and `ball-drained` when it is lost. TouchRules turns those into score/damage.
20
+ * Never import rapier: it is a ~2 MB lazy wasm payload this does not need.
21
+ */
22
+ import { NodeBase, Script, type PropertySchema } from '@pix3/runtime';
23
+
24
+ import {
25
+ stepBall,
26
+ type BallOptions,
27
+ type BallState,
28
+ type CircleCollider,
29
+ type SegmentCollider,
30
+ } from './ball-collision';
31
+
32
+ /** Oriented box in world space: centre, unit axes, half extents. */
33
+ interface Obb {
34
+ cx: number;
35
+ cy: number;
36
+ ux: number;
37
+ uy: number;
38
+ hw: number;
39
+ hh: number;
40
+ }
41
+
42
+ /** Any 2D node that carries a size (ColorRect2D, Sprite2D, Group2D, …). */
43
+ interface Sized {
44
+ width?: number;
45
+ height?: number;
46
+ }
47
+
48
+ function readObb(node: NodeBase): Obb {
49
+ node.updateWorldMatrix(true, false);
50
+ const e = node.matrixWorld.elements;
51
+ const scaleX = Math.hypot(e[0], e[1]) || 1;
52
+ const scaleY = Math.hypot(e[4], e[5]) || 1;
53
+ const sized = node as unknown as Sized;
54
+ return {
55
+ cx: e[12],
56
+ cy: e[13],
57
+ ux: e[0] / scaleX,
58
+ uy: e[1] / scaleX,
59
+ hw: ((Number(sized.width) || 0) / 2) * scaleX,
60
+ hh: ((Number(sized.height) || 0) / 2) * scaleY,
61
+ };
62
+ }
63
+
64
+ function obbSegments(obb: Obb, restitution: number, id: string, out: SegmentCollider[]): void {
65
+ // Perpendicular of (ux, uy) is (-uy, ux) — the box's local Y axis in world space.
66
+ const ax = obb.ux * obb.hw;
67
+ const ay = obb.uy * obb.hw;
68
+ const bx = -obb.uy * obb.hh;
69
+ const by = obb.ux * obb.hh;
70
+ const corners = [
71
+ [obb.cx - ax - bx, obb.cy - ay - by],
72
+ [obb.cx + ax - bx, obb.cy + ay - by],
73
+ [obb.cx + ax + bx, obb.cy + ay + by],
74
+ [obb.cx - ax + bx, obb.cy - ay + by],
75
+ ];
76
+ for (let i = 0; i < 4; i++) {
77
+ const from = corners[i];
78
+ const to = corners[(i + 1) % 4];
79
+ out.push({ ax: from[0], ay: from[1], bx: to[0], by: to[1], restitution, id });
80
+ }
81
+ }
82
+
83
+ function pointInObb(obb: Obb, x: number, y: number): boolean {
84
+ const dx = x - obb.cx;
85
+ const dy = y - obb.cy;
86
+ const along = dx * obb.ux + dy * obb.uy;
87
+ const across = dx * -obb.uy + dy * obb.ux;
88
+ return Math.abs(along) <= obb.hw && Math.abs(across) <= obb.hh;
89
+ }
90
+
91
+ function childNodes(parent: NodeBase | null): NodeBase[] {
92
+ if (!parent) {
93
+ return [];
94
+ }
95
+ return parent.children.filter((child): child is NodeBase => child instanceof NodeBase);
96
+ }
97
+
98
+ export class BallBody extends Script {
99
+ private readonly state: BallState = { x: 0, y: 0, vx: 0, vy: 0 };
100
+ private readonly segments: SegmentCollider[] = [];
101
+ private readonly circles: CircleCollider[] = [];
102
+ private readonly kinds = new Map<string, string>();
103
+ private homeX = 0;
104
+ private homeY = 0;
105
+ private respawnIn = 0;
106
+
107
+ constructor(id: string, type: string) {
108
+ super(id, type);
109
+ this.config = {
110
+ radius: 30,
111
+ gravity: -2400,
112
+ maxSpeed: 2600,
113
+ minSpeed: 420,
114
+ damping: 0.999,
115
+ substeps: 4,
116
+ launchSpeed: 1150,
117
+ launchAngleDeg: 72,
118
+ wallsNode: 'walls',
119
+ paddlesNode: 'paddles',
120
+ bumpersNode: 'bumpers',
121
+ drainNode: 'drain',
122
+ wallRestitution: 1,
123
+ paddleRestitution: 1.05,
124
+ bumperRestitution: 1.3,
125
+ resetDelaySec: 0.7,
126
+ };
127
+ }
128
+
129
+ static getPropertySchema(): PropertySchema {
130
+ const num = (name: string, label: string, min: number, max: number, step: number, group: string) => ({
131
+ name,
132
+ type: 'number' as const,
133
+ ui: { label, group, min, max, step, slider: true },
134
+ getValue: (s: unknown) => (s as BallBody).config[name],
135
+ setValue: (s: unknown, v: unknown) => {
136
+ const n = Number(v);
137
+ (s as BallBody).config[name] = Math.min(max, Math.max(min, Number.isFinite(n) ? n : min));
138
+ },
139
+ });
140
+ const str = (name: string, label: string) => ({
141
+ name,
142
+ type: 'string' as const,
143
+ ui: { label, group: 'Colliders' },
144
+ getValue: (s: unknown) => (s as BallBody).config[name],
145
+ setValue: (s: unknown, v: unknown) => {
146
+ (s as BallBody).config[name] = typeof v === 'string' ? v : '';
147
+ },
148
+ });
149
+
150
+ return {
151
+ nodeType: 'BallBody',
152
+ properties: [
153
+ num('radius', 'Radius', 2, 400, 1, 'Body'),
154
+ num('gravity', 'Gravity (px/s²)', -12000, 12000, 50, 'Body'),
155
+ num('maxSpeed', 'Max Speed', 100, 12000, 50, 'Body'),
156
+ num('minSpeed', 'Min Speed', 0, 4000, 10, 'Body'),
157
+ num('damping', 'Damping', 0.9, 1, 0.001, 'Body'),
158
+ num('substeps', 'Substeps', 1, 16, 1, 'Body'),
159
+ num('launchSpeed', 'Launch Speed', 0, 6000, 25, 'Launch'),
160
+ num('launchAngleDeg', 'Launch Angle (°)', 5, 175, 1, 'Launch'),
161
+ num('resetDelaySec', 'Reset Delay (s)', 0, 5, 0.1, 'Launch'),
162
+ num('wallRestitution', 'Wall Bounce', 0, 2, 0.01, 'Bounce'),
163
+ num('paddleRestitution', 'Paddle Bounce', 0, 2, 0.01, 'Bounce'),
164
+ num('bumperRestitution', 'Bumper Bounce', 0, 3, 0.01, 'Bounce'),
165
+ str('wallsNode', 'Walls Node'),
166
+ str('paddlesNode', 'Paddles Node'),
167
+ str('bumpersNode', 'Bumpers Node'),
168
+ str('drainNode', 'Drain Node'),
169
+ ],
170
+ groups: {
171
+ Body: { label: 'Body', expanded: true },
172
+ Launch: { label: 'Launch', expanded: true },
173
+ Bounce: { label: 'Bounce', expanded: true },
174
+ Colliders: { label: 'Collider Sources', expanded: false },
175
+ },
176
+ };
177
+ }
178
+
179
+ onStart(): void {
180
+ const node = this.node;
181
+ if (!node) {
182
+ return;
183
+ }
184
+ node.updateWorldMatrix(true, false);
185
+ this.homeX = node.matrixWorld.elements[12];
186
+ this.homeY = node.matrixWorld.elements[13];
187
+ this.launch();
188
+ }
189
+
190
+ onUpdate(dt: number): void {
191
+ const node = this.node;
192
+ if (!node || dt <= 0) {
193
+ return;
194
+ }
195
+ if (this.respawnIn > 0) {
196
+ this.respawnIn -= dt;
197
+ if (this.respawnIn <= 0) {
198
+ this.launch();
199
+ }
200
+ return;
201
+ }
202
+
203
+ this.rebuildColliders();
204
+ const options: BallOptions = {
205
+ radius: Math.max(1, Number(this.config.radius) || 1),
206
+ gravity: Number(this.config.gravity) || 0,
207
+ maxSpeed: Number(this.config.maxSpeed) || 0,
208
+ minSpeed: Number(this.config.minSpeed) || 0,
209
+ damping: Number(this.config.damping) || 1,
210
+ substeps: Number(this.config.substeps) || 1,
211
+ };
212
+
213
+ for (const hit of stepBall(this.state, options, this.segments, this.circles, dt)) {
214
+ node.emit('ball-hit', this.kinds.get(hit.id) ?? 'wall', hit.id, hit.speed, hit.x, hit.y);
215
+ }
216
+ this.writeBackPosition();
217
+ this.checkDrain();
218
+ }
219
+
220
+ /** Send the ball on its way (also used after a drain). Override the angle for a plunger. */
221
+ launch(): void {
222
+ const speed = Math.max(0, Number(this.config.launchSpeed) || 0);
223
+ const angle = ((Number(this.config.launchAngleDeg) || 90) * Math.PI) / 180;
224
+ const dir = Math.random() < 0.5 ? -1 : 1;
225
+ this.state.x = this.homeX;
226
+ this.state.y = this.homeY;
227
+ this.state.vx = Math.cos(angle) * speed * dir;
228
+ this.state.vy = Math.sin(angle) * speed;
229
+ this.writeBackPosition();
230
+ this.node?.emit('ball-launched');
231
+ }
232
+
233
+ private rebuildColliders(): void {
234
+ this.segments.length = 0;
235
+ this.circles.length = 0;
236
+ this.kinds.clear();
237
+
238
+ const wallBounce = Number(this.config.wallRestitution) || 1;
239
+ for (const child of childNodes(this.findNode(String(this.config.wallsNode ?? '')))) {
240
+ obbSegments(readObb(child), wallBounce, child.nodeId, this.segments);
241
+ this.kinds.set(child.nodeId, 'wall');
242
+ }
243
+
244
+ const paddleBounce = Number(this.config.paddleRestitution) || 1;
245
+ for (const child of childNodes(this.findNode(String(this.config.paddlesNode ?? '')))) {
246
+ obbSegments(readObb(child), paddleBounce, child.nodeId, this.segments);
247
+ this.kinds.set(child.nodeId, 'paddle');
248
+ }
249
+
250
+ const bumperBounce = Number(this.config.bumperRestitution) || 1;
251
+ for (const child of childNodes(this.findNode(String(this.config.bumpersNode ?? '')))) {
252
+ const obb = readObb(child);
253
+ this.circles.push({ cx: obb.cx, cy: obb.cy, r: Math.max(1, obb.hw), restitution: bumperBounce, id: child.nodeId });
254
+ this.kinds.set(child.nodeId, 'bumper');
255
+ }
256
+ }
257
+
258
+ private checkDrain(): void {
259
+ const drain = this.findNode(String(this.config.drainNode ?? ''));
260
+ if (!drain) {
261
+ return;
262
+ }
263
+ const obb = readObb(drain);
264
+ const below = this.state.y < obb.cy - obb.hh;
265
+ if (!below && !pointInObb(obb, this.state.x, this.state.y)) {
266
+ return;
267
+ }
268
+ const node = this.node;
269
+ node?.emit('ball-hit', 'drain', drain.nodeId, Math.hypot(this.state.vx, this.state.vy), this.state.x, this.state.y);
270
+ node?.emit('ball-drained');
271
+ this.state.vx = 0;
272
+ this.state.vy = 0;
273
+ this.respawnIn = Math.max(0.05, Number(this.config.resetDelaySec) || 0.05);
274
+ }
275
+
276
+ /** World simulation → the ball node's parent-local position. */
277
+ private writeBackPosition(): void {
278
+ const node = this.node;
279
+ const parent = node?.parentNode ?? null;
280
+ if (!node) {
281
+ return;
282
+ }
283
+ if (!parent) {
284
+ node.position.set(this.state.x, this.state.y, node.position.z);
285
+ return;
286
+ }
287
+ parent.updateWorldMatrix(true, false);
288
+ const e = parent.matrixWorld.elements;
289
+ const dx = this.state.x - e[12];
290
+ const dy = this.state.y - e[13];
291
+ const det = e[0] * e[5] - e[1] * e[4];
292
+ if (Math.abs(det) < 1e-8) {
293
+ node.position.set(dx, dy, node.position.z);
294
+ return;
295
+ }
296
+ node.position.set((e[5] * dx - e[4] * dy) / det, (e[0] * dy - e[1] * dx) / det, node.position.z);
297
+ }
298
+ }