@pix3/cli 1.6.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 (155) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +754 -0
  3. package/dist/index.js +494 -0
  4. package/dist/runtime-types.json +1 -0
  5. package/dist/smoke/prebuilt/build-7MIXIMSZ.mjs +948 -0
  6. package/dist/smoke/prebuilt/chunk-J2SJVMDO.mjs +2 -0
  7. package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +239 -0
  8. package/dist/smoke/prebuilt/chunk-P2CBRXPJ.mjs +4050 -0
  9. package/dist/smoke/prebuilt/runtime.mjs +2 -0
  10. package/dist/smoke/prebuilt/smoke-worker.mjs +3 -0
  11. package/dist/smoke/prebuilt/three.mjs +2 -0
  12. package/dist/smoke/prebuilt/tree-defaults.mjs +2 -0
  13. package/dist/validate/prebuilt/build-AUGKXER3.js +948 -0
  14. package/dist/validate/prebuilt/bundle-main.js +24 -0
  15. package/dist/validate/prebuilt/chunk-6ACT6FNV.js +4050 -0
  16. package/dist/validate/prebuilt/chunk-BS2OZM7U.js +239 -0
  17. package/dist/validate/prebuilt/runtime.js +2 -0
  18. package/dist/validate/prebuilt/three.js +2 -0
  19. package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -0
  20. package/kit/files/.claude/skills/pix3-nodes/reference.md +1331 -0
  21. package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +235 -0
  22. package/kit/files/.claude/skills/pix3-scene-format/reference.md +176 -0
  23. package/kit/files/.claude/skills/pix3-scripts/SKILL.md +281 -0
  24. package/kit/files/.claude/skills/pix3-scripts/reference.md +638 -0
  25. package/kit/files/.claude/skills/pix3-verify/SKILL.md +386 -0
  26. package/kit/files/.claude/skills/pix3-verify/reference.md +21 -0
  27. package/kit/files/AGENTS.md +203 -0
  28. package/kit/files/CLAUDE.md +1 -0
  29. package/kit/kit.json +24 -0
  30. package/package.json +54 -0
  31. package/templates/empty-2d/files/README.md +20 -0
  32. package/templates/empty-2d/files/scenes/main.pix3scene +68 -0
  33. package/templates/empty-2d/files/sprites/pix3-logo.png +0 -0
  34. package/templates/empty-2d/template.yaml +7 -0
  35. package/templates/empty-3d/files/README.md +20 -0
  36. package/templates/empty-3d/files/scenes/main.pix3scene +108 -0
  37. package/templates/empty-3d/files/sprites/pix3-logo.png +0 -0
  38. package/templates/empty-3d/template.yaml +7 -0
  39. package/templates/idea-blank/files/README.md +22 -0
  40. package/templates/idea-blank/files/scenes/main.pix3scene +37 -0
  41. package/templates/idea-blank/template.yaml +10 -0
  42. package/templates/minigame-2d/files/README.md +44 -0
  43. package/templates/minigame-2d/files/design/tests/reachability.json +1 -0
  44. package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -0
  45. package/templates/minigame-2d/files/scenes/main.pix3scene +84 -0
  46. package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -0
  47. package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -0
  48. package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -0
  49. package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -0
  50. package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -0
  51. package/templates/minigame-2d/template.yaml +10 -0
  52. package/templates/playable-2d/files/README.md +30 -0
  53. package/templates/playable-2d/files/design/recipe.md +83 -0
  54. package/templates/playable-2d/files/design/tests/reachability.json +4 -0
  55. package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -0
  56. package/templates/playable-2d/files/scenes/main.pix3scene +111 -0
  57. package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -0
  58. package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -0
  59. package/templates/playable-2d/files/scripts/CtaButton.ts +55 -0
  60. package/templates/playable-2d/files/scripts/GameFlow.ts +256 -0
  61. package/templates/playable-2d/files/sprites/pix3-logo.png +0 -0
  62. package/templates/playable-2d/template.yaml +9 -0
  63. package/templates/playable-3d/files/README.md +32 -0
  64. package/templates/playable-3d/files/design/recipe.md +127 -0
  65. package/templates/playable-3d/files/design/tests/reachability.json +4 -0
  66. package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -0
  67. package/templates/playable-3d/files/scenes/main.pix3scene +230 -0
  68. package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -0
  69. package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -0
  70. package/templates/playable-3d/files/scripts/CtaButton.ts +55 -0
  71. package/templates/playable-3d/files/scripts/GameFlow.ts +309 -0
  72. package/templates/playable-3d/files/scripts/GameRules.ts +290 -0
  73. package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -0
  74. package/templates/playable-3d/template.yaml +9 -0
  75. package/templates/recipe-arena-2d/files/README.md +48 -0
  76. package/templates/recipe-arena-2d/files/design/recipe.md +127 -0
  77. package/templates/recipe-arena-2d/files/design/tests/reachability.json +4 -0
  78. package/templates/recipe-arena-2d/files/design/tests/routines/terminal-retry.json +23 -0
  79. package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -0
  80. package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -0
  81. package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  82. package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -0
  83. package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -0
  84. package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -0
  85. package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -0
  86. package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -0
  87. package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -0
  88. package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -0
  89. package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -0
  90. package/templates/recipe-arena-2d/files/sprites/ph-avatar.png +0 -0
  91. package/templates/recipe-arena-2d/files/sprites/ph-hazard.png +0 -0
  92. package/templates/recipe-arena-2d/files/sprites/ph-pickup.png +0 -0
  93. package/templates/recipe-arena-2d/template.yaml +10 -0
  94. package/templates/recipe-blank-2d/files/README.md +61 -0
  95. package/templates/recipe-blank-2d/files/design/recipe.md +148 -0
  96. package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -0
  97. package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -0
  98. package/templates/recipe-blank-2d/files/design/tests/routines/terminal-retry.json +23 -0
  99. package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -0
  100. package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -0
  101. package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -0
  102. package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -0
  103. package/templates/recipe-blank-2d/files/sprites/ph-bg.png +0 -0
  104. package/templates/recipe-blank-2d/files/sprites/ph-capsule.png +0 -0
  105. package/templates/recipe-blank-2d/files/sprites/ph-circle.png +0 -0
  106. package/templates/recipe-blank-2d/files/sprites/ph-orb.png +0 -0
  107. package/templates/recipe-blank-2d/files/sprites/ph-ring.png +0 -0
  108. package/templates/recipe-blank-2d/files/sprites/ph-star.png +0 -0
  109. package/templates/recipe-blank-2d/template.yaml +10 -0
  110. package/templates/recipe-bouncer-2d/files/README.md +81 -0
  111. package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -0
  112. package/templates/recipe-bouncer-2d/files/design/tests/reachability.json +4 -0
  113. package/templates/recipe-bouncer-2d/files/design/tests/routines/terminal-retry.json +23 -0
  114. package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -0
  115. package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -0
  116. package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -0
  117. package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -0
  118. package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -0
  119. package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -0
  120. package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -0
  121. package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -0
  122. package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -0
  123. package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -0
  124. package/templates/recipe-bouncer-2d/files/sprites/ph-ball.png +0 -0
  125. package/templates/recipe-bouncer-2d/files/sprites/ph-bg.png +0 -0
  126. package/templates/recipe-bouncer-2d/files/sprites/ph-bumper.png +0 -0
  127. package/templates/recipe-bouncer-2d/files/sprites/ph-paddle.png +0 -0
  128. package/templates/recipe-bouncer-2d/template.yaml +10 -0
  129. package/templates/recipe-grid-3d/files/README.md +22 -0
  130. package/templates/recipe-grid-3d/files/design/recipe.md +110 -0
  131. package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -0
  132. package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -0
  133. package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -0
  134. package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -0
  135. package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -0
  136. package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -0
  137. package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -0
  138. package/templates/recipe-grid-3d/template.yaml +10 -0
  139. package/templates/recipe-tapper-2d/files/README.md +39 -0
  140. package/templates/recipe-tapper-2d/files/design/recipe.md +132 -0
  141. package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -0
  142. package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -0
  143. package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -0
  144. package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -0
  145. package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -0
  146. package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -0
  147. package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -0
  148. package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -0
  149. package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -0
  150. package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -0
  151. package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -0
  152. package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -0
  153. package/templates/recipe-tapper-2d/files/sprites/ph-hazard.png +0 -0
  154. package/templates/recipe-tapper-2d/files/sprites/ph-target.png +0 -0
  155. package/templates/recipe-tapper-2d/template.yaml +10 -0
@@ -0,0 +1,191 @@
1
+ # Pix3 Scene File (YAML)
2
+ # Bouncer 2D — the MENU scene. This is the project's ENTRY / export scene
3
+ # (Project Settings → Default Export Scene Path): a full build and "Play Game (Entry Scene)"
4
+ # boot here. PLAY transitions to the game scene (main.pix3scene) via a fade.
5
+ # Wired by scripts/MenuFlow.ts. Iterate on the GAME in main.pix3scene.
6
+ #
7
+ # Same neon recipe as the game scene: darkest ColorRect + a tinted radial
8
+ # gradient placeholder, a `PostProcess` node so bright accents bloom, and
9
+ # Label2D's own glow on the text. Nothing here is a CanvasLayer2D, so the title
10
+ # gets BOTH the canvas glow and the bloom — keep `menuBloomIntensity` modest or
11
+ # large white type turns to mush.
12
+ version: 1.0.0
13
+ metadata:
14
+ author: Pix3 Recipe
15
+ description: Menu scene — entry point; PLAY transitions to the game
16
+ root:
17
+ - id: menu-post-fx
18
+ type: PostProcess
19
+ name: Menu Post FX
20
+ properties:
21
+ affect2D: true
22
+ bloomEnabled: true
23
+ bloomIntensity: 0.7
24
+ bloomThreshold: 0.6
25
+ bloomSmoothing: 0.2
26
+ bloomRadius: 0.8
27
+ vignetteEnabled: true
28
+ vignetteOffset: 0.28
29
+ vignetteDarkness: 0.55
30
+ chromaticAberrationEnabled: false
31
+ children: []
32
+ - id: menu-root
33
+ type: Group2D
34
+ name: Menu Root
35
+ properties:
36
+ width: 1080
37
+ height: 1920
38
+ layout:
39
+ enabled: true
40
+ horizontalAlign: stretch
41
+ verticalAlign: stretch
42
+ components:
43
+ - id: menu-flow
44
+ type: user:MenuFlow
45
+ enabled: true
46
+ config:
47
+ gameScene: res://scenes/main.pix3scene
48
+ playButton: play-button
49
+ children:
50
+ - id: menu-background
51
+ type: ColorRect2D
52
+ name: Menu Background
53
+ properties:
54
+ width: 1080
55
+ height: 1920
56
+ color: "#05030f"
57
+ transform:
58
+ position: [0, 0]
59
+ scale: [1, 1]
60
+ rotation: 0
61
+ layout:
62
+ enabled: true
63
+ horizontalAlign: stretch
64
+ verticalAlign: stretch
65
+ children: []
66
+ - id: menu-bg-glow
67
+ type: Sprite2D
68
+ name: Menu Background Glow
69
+ properties:
70
+ texture: { type: 'texture', url: 'res://sprites/ph-bg.png' }
71
+ width: 1080
72
+ height: 1920
73
+ effects:
74
+ - type: core:tint
75
+ params: { color: "#5a2df0", amount: 1 }
76
+ transform:
77
+ position: [0, 0]
78
+ scale: [1, 1]
79
+ rotation: 0
80
+ layout:
81
+ enabled: true
82
+ horizontalAlign: stretch
83
+ verticalAlign: stretch
84
+ children: []
85
+ # The project title is patched into this node on project creation — keep
86
+ # the id. `width` is set so a long title WRAPS instead of running off
87
+ # screen (Label2D word-wraps to its box; 0 would auto-size and overflow).
88
+ - id: title-label
89
+ type: Label2D
90
+ name: Title Label
91
+ properties:
92
+ label: "{{PROJECT_NAME}}"
93
+ labelFontSize: 112
94
+ labelColor: "#ffffff"
95
+ labelAlign: center
96
+ width: 960
97
+ glowColor: "#2ef2ff"
98
+ glowStrength: 2.5
99
+ transform:
100
+ position: [0, 420]
101
+ scale: [1, 1]
102
+ rotation: 0
103
+ layout:
104
+ enabled: true
105
+ horizontalAlign: center
106
+ verticalAlign: center
107
+ components:
108
+ - id: title-pop
109
+ type: core:PopIn
110
+ enabled: true
111
+ config:
112
+ from: 0.55
113
+ duration: 0.5
114
+ easing: backOut
115
+ playOnStart: true
116
+ triggerEvent: ""
117
+ - id: title-breathe
118
+ type: core:Sine
119
+ enabled: true
120
+ config:
121
+ amplitude: 12
122
+ frequency: 0.28
123
+ axis: y
124
+ children: []
125
+ - id: title-rule
126
+ type: ColorRect2D
127
+ name: Title Rule
128
+ properties:
129
+ width: 520
130
+ height: 6
131
+ color: "#2ef2ff"
132
+ transform:
133
+ position: [0, 352]
134
+ scale: [1, 1]
135
+ rotation: 0
136
+ layout:
137
+ enabled: true
138
+ horizontalAlign: center
139
+ verticalAlign: center
140
+ children: []
141
+ - id: subtitle-label
142
+ type: Label2D
143
+ name: Subtitle Label
144
+ properties:
145
+ label: "KEEP THE BALL ALIVE."
146
+ labelFontSize: 40
147
+ labelColor: "#8ffcff"
148
+ labelAlign: center
149
+ glowColor: "#2ef2ff"
150
+ glowStrength: 1
151
+ transform:
152
+ position: [0, 280]
153
+ scale: [1, 1]
154
+ rotation: 0
155
+ layout:
156
+ enabled: true
157
+ horizontalAlign: center
158
+ verticalAlign: center
159
+ children: []
160
+ - id: play-button
161
+ type: Button2D
162
+ name: Play Button
163
+ properties:
164
+ width: 480
165
+ height: 140
166
+ label: "PLAY"
167
+ labelFontSize: 56
168
+ labelColor: "#0d0724"
169
+ backgroundColor: "#ffd23f"
170
+ hoverColor: "#ffe98a"
171
+ pressedColor: "#d49a1f"
172
+ buttonAction: play
173
+ transform:
174
+ position: [0, -60]
175
+ scale: [1, 1]
176
+ rotation: 0
177
+ layout:
178
+ enabled: true
179
+ horizontalAlign: center
180
+ verticalAlign: center
181
+ components:
182
+ - id: play-pop
183
+ type: core:PopIn
184
+ enabled: true
185
+ config:
186
+ from: 0.7
187
+ duration: 0.45
188
+ easing: backOut
189
+ playOnStart: true
190
+ triggerEvent: ""
191
+ children: []
@@ -0,0 +1,87 @@
1
+ # Pix3 Scene File (YAML)
2
+ # Bouncer 2D — the RESULT overlay (win/lose), in its own file so the gameplay
3
+ # scene opens clean. `main.pix3scene` carries it as a one-line
4
+ # `instance:` reference marked `visible: false`, which hides it in the EDITOR
5
+ # only; `user:GameRules` reveals it at run time by setting `visible`.
6
+ #
7
+ # Edit it here, full-screen, in its own tab. To preview it composited over the
8
+ # game, tick the eye on `result-overlay` in main.pix3scene — just do not commit
9
+ # that tick, or every iteration starts behind a GAME OVER screen again.
10
+ #
11
+ # Stable node ids (design/recipe.md is the contract — do not rename these):
12
+ # result-overlay, result-label, retry-button
13
+ version: 1.0.0
14
+ metadata:
15
+ author: Pix3 Recipe
16
+ description: Result overlay — dim, glowing win/lose headline and a RETRY button
17
+ root:
18
+ - id: result-overlay
19
+ type: Group2D
20
+ name: Result Overlay
21
+ properties:
22
+ width: 1080
23
+ height: 1920
24
+ initiallyVisible: false
25
+ layout:
26
+ enabled: true
27
+ horizontalAlign: stretch
28
+ verticalAlign: stretch
29
+ children:
30
+ - id: result-dim
31
+ type: ColorRect2D
32
+ name: Result Dim
33
+ properties:
34
+ width: 1080
35
+ height: 1920
36
+ color: "#05030f"
37
+ opacity: 0.78
38
+ transform:
39
+ position: [0, 0]
40
+ scale: [1, 1]
41
+ rotation: 0
42
+ layout:
43
+ enabled: true
44
+ horizontalAlign: stretch
45
+ verticalAlign: stretch
46
+ children: []
47
+ - id: result-label
48
+ type: Label2D
49
+ name: Result Label
50
+ properties:
51
+ label: "GAME OVER"
52
+ labelFontSize: 96
53
+ labelColor: "#ffffff"
54
+ labelAlign: center
55
+ glowColor: "#2ef2ff"
56
+ glowStrength: 2.5
57
+ transform:
58
+ position: [0, 160]
59
+ scale: [1, 1]
60
+ rotation: 0
61
+ layout:
62
+ enabled: true
63
+ horizontalAlign: center
64
+ verticalAlign: center
65
+ children: []
66
+ - id: retry-button
67
+ type: Button2D
68
+ name: Retry Button
69
+ properties:
70
+ width: 420
71
+ height: 130
72
+ label: "RETRY"
73
+ labelFontSize: 50
74
+ labelColor: "#0d0724"
75
+ backgroundColor: "#ffd23f"
76
+ hoverColor: "#ffe98a"
77
+ pressedColor: "#d49a1f"
78
+ buttonAction: retry
79
+ transform:
80
+ position: [0, -60]
81
+ scale: [1, 1]
82
+ rotation: 0
83
+ layout:
84
+ enabled: true
85
+ horizontalAlign: center
86
+ verticalAlign: center
87
+ children: []
@@ -0,0 +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
+ }