@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.
- package/LICENSE +201 -201
- package/README.md +788 -754
- package/dist/index.js +208 -203
- package/dist/runtime-types.json +1 -1
- package/dist/smoke/prebuilt/{chunk-J2SJVMDO.mjs → chunk-7MZGPAUK.mjs} +1 -1
- package/dist/smoke/prebuilt/chunk-7WOFGLW3.mjs +239 -0
- package/dist/smoke/prebuilt/runtime.mjs +1 -1
- package/dist/smoke/prebuilt/smoke-worker.mjs +1 -1
- package/dist/smoke/prebuilt/tree-defaults.mjs +1 -1
- package/dist/validate/prebuilt/bundle-main.js +13 -13
- package/dist/validate/prebuilt/chunk-HKG6YI6Y.js +239 -0
- package/dist/validate/prebuilt/runtime.js +1 -1
- package/kit/files/.claude/skills/pix3-nodes/SKILL.md +210 -210
- package/kit/files/.claude/skills/pix3-nodes/reference.md +32 -24
- package/kit/files/.claude/skills/pix3-scene-format/SKILL.md +238 -235
- package/kit/files/.claude/skills/pix3-scene-format/reference.md +28 -28
- package/kit/files/.claude/skills/pix3-scripts/SKILL.md +285 -281
- package/kit/files/.claude/skills/pix3-scripts/reference.md +69 -52
- package/kit/files/.claude/skills/pix3-verify/SKILL.md +298 -256
- package/kit/files/.claude/skills/pix3-verify/reference.md +21 -21
- package/kit/files/AGENTS.md +203 -203
- package/kit/files/CLAUDE.md +1 -1
- package/kit/kit.json +2 -2
- package/package.json +54 -54
- package/templates/empty-2d/files/README.md +20 -20
- package/templates/empty-2d/files/scenes/main.pix3scene +68 -68
- package/templates/empty-2d/template.yaml +7 -7
- package/templates/empty-3d/files/README.md +20 -20
- package/templates/empty-3d/files/scenes/main.pix3scene +108 -108
- package/templates/empty-3d/template.yaml +7 -7
- package/templates/idea-blank/files/README.md +22 -22
- package/templates/idea-blank/files/scenes/main.pix3scene +37 -37
- package/templates/idea-blank/template.yaml +10 -10
- package/templates/minigame-2d/files/README.md +44 -44
- package/templates/minigame-2d/files/design/tests/reachability.json +1 -1
- package/templates/minigame-2d/files/design/tests/routines/mute-music.json +44 -44
- package/templates/minigame-2d/files/scenes/main.pix3scene +84 -84
- package/templates/minigame-2d/files/scenes/menu.pix3scene +118 -118
- package/templates/minigame-2d/files/scenes/ui/settings-window.pix3scene +138 -138
- package/templates/minigame-2d/files/scripts/GameFlow.ts +133 -133
- package/templates/minigame-2d/files/scripts/MenuFlow.ts +217 -217
- package/templates/minigame-2d/files/scripts/SettingsWindow.ts +198 -198
- package/templates/minigame-2d/template.yaml +10 -10
- package/templates/playable-2d/files/README.md +30 -30
- package/templates/playable-2d/files/design/recipe.md +83 -83
- package/templates/playable-2d/files/design/tests/reachability.json +4 -4
- package/templates/playable-2d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-2d/files/scenes/main.pix3scene +111 -111
- package/templates/playable-2d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-2d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-2d/files/scripts/GameFlow.ts +256 -256
- package/templates/playable-2d/template.yaml +9 -9
- package/templates/playable-3d/files/README.md +32 -32
- package/templates/playable-3d/files/design/recipe.md +127 -127
- package/templates/playable-3d/files/design/tests/reachability.json +4 -4
- package/templates/playable-3d/files/design/tests/routines/intro-to-cta.json +32 -32
- package/templates/playable-3d/files/scenes/main.pix3scene +230 -230
- package/templates/playable-3d/files/scenes/ui/end-screen.pix3scene +91 -91
- package/templates/playable-3d/files/scenes/ui/intro.pix3scene +65 -65
- package/templates/playable-3d/files/scripts/GameFlow.ts +309 -309
- package/templates/playable-3d/files/scripts/GameRules.ts +290 -290
- package/templates/playable-3d/files/scripts/ScoreHud.ts +113 -113
- package/templates/playable-3d/template.yaml +9 -9
- package/templates/recipe-arena-2d/files/README.md +48 -48
- package/templates/recipe-arena-2d/files/design/recipe.md +127 -127
- package/templates/recipe-arena-2d/files/scenes/main.pix3scene +292 -292
- package/templates/recipe-arena-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-arena-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-arena-2d/files/scenes/prefabs/pickup.pix3scene +45 -45
- package/templates/recipe-arena-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-arena-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-arena-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-arena-2d/files/scripts/PlayerController.ts +151 -151
- package/templates/recipe-arena-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-arena-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-arena-2d/files/scripts/TouchRules.ts +174 -174
- package/templates/recipe-arena-2d/template.yaml +10 -10
- package/templates/recipe-blank-2d/files/README.md +61 -61
- package/templates/recipe-blank-2d/files/design/recipe.md +148 -148
- package/templates/recipe-blank-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-blank-2d/files/design/tests/routines/restart.json +27 -27
- package/templates/recipe-blank-2d/files/scenes/main.pix3scene +224 -224
- package/templates/recipe-blank-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-blank-2d/files/scripts/GameRules.ts +350 -350
- package/templates/recipe-blank-2d/files/scripts/ScoreHud.ts +110 -110
- package/templates/recipe-blank-2d/template.yaml +10 -10
- package/templates/recipe-bouncer-2d/files/README.md +81 -81
- package/templates/recipe-bouncer-2d/files/design/recipe.md +146 -146
- package/templates/recipe-bouncer-2d/files/scenes/main.pix3scene +615 -615
- package/templates/recipe-bouncer-2d/files/scenes/menu.pix3scene +191 -191
- package/templates/recipe-bouncer-2d/files/scenes/ui/result.pix3scene +87 -87
- package/templates/recipe-bouncer-2d/files/scripts/BallBody.ts +298 -298
- package/templates/recipe-bouncer-2d/files/scripts/GameRules.ts +377 -377
- package/templates/recipe-bouncer-2d/files/scripts/MenuFlow.ts +109 -109
- package/templates/recipe-bouncer-2d/files/scripts/PaddleController.ts +112 -112
- package/templates/recipe-bouncer-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-bouncer-2d/files/scripts/TouchRules.ts +263 -263
- package/templates/recipe-bouncer-2d/files/scripts/ball-collision.ts +307 -307
- package/templates/recipe-bouncer-2d/template.yaml +10 -10
- package/templates/recipe-grid-3d/files/README.md +22 -22
- package/templates/recipe-grid-3d/files/design/recipe.md +110 -110
- package/templates/recipe-grid-3d/files/scenes/main.pix3scene +169 -169
- package/templates/recipe-grid-3d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-grid-3d/files/scenes/ui/result.pix3scene +105 -105
- package/templates/recipe-grid-3d/files/scripts/GridBoard.ts +319 -319
- package/templates/recipe-grid-3d/files/scripts/GridRules.ts +278 -278
- package/templates/recipe-grid-3d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-grid-3d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-grid-3d/template.yaml +10 -10
- package/templates/recipe-tapper-2d/files/README.md +39 -39
- package/templates/recipe-tapper-2d/files/design/recipe.md +132 -132
- package/templates/recipe-tapper-2d/files/design/tests/reachability.json +4 -4
- package/templates/recipe-tapper-2d/files/design/tests/routines/terminal-retry.json +23 -23
- package/templates/recipe-tapper-2d/files/scenes/main.pix3scene +269 -269
- package/templates/recipe-tapper-2d/files/scenes/menu.pix3scene +100 -100
- package/templates/recipe-tapper-2d/files/scenes/prefabs/hazard.pix3scene +42 -42
- package/templates/recipe-tapper-2d/files/scenes/prefabs/target.pix3scene +45 -45
- package/templates/recipe-tapper-2d/files/scenes/ui/result.pix3scene +85 -85
- package/templates/recipe-tapper-2d/files/scripts/GameRules.ts +368 -368
- package/templates/recipe-tapper-2d/files/scripts/MenuFlow.ts +106 -106
- package/templates/recipe-tapper-2d/files/scripts/ScoreHud.ts +94 -94
- package/templates/recipe-tapper-2d/files/scripts/Spawner.ts +214 -214
- package/templates/recipe-tapper-2d/files/scripts/TouchRules.ts +192 -192
- package/templates/recipe-tapper-2d/template.yaml +10 -10
- package/dist/smoke/prebuilt/chunk-OJXQXIIQ.mjs +0 -239
- 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
|