@sweberdev/lagrangian 0.1.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.
- package/LICENSE +21 -0
- package/README.md +95 -0
- package/dist/index.cjs +1163 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +382 -0
- package/dist/index.d.ts +382 -0
- package/dist/index.js +1112 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
- package/src/animate.ts +172 -0
- package/src/decay.ts +127 -0
- package/src/drag.ts +174 -0
- package/src/index.ts +54 -0
- package/src/integrate.ts +61 -0
- package/src/loop.ts +73 -0
- package/src/reduced-motion.ts +17 -0
- package/src/spring.ts +140 -0
- package/src/value.ts +147 -0
- package/src/velocity.ts +50 -0
- package/src/world.ts +553 -0
package/src/velocity.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Estimates velocity from recent samples with a least-squares line over the last 100 ms,
|
|
2
|
+
// which is far less jittery than the difference of the last two pointer events.
|
|
3
|
+
|
|
4
|
+
const WINDOW = 100;
|
|
5
|
+
const STALE = 60;
|
|
6
|
+
|
|
7
|
+
export class VelocityTracker {
|
|
8
|
+
#samples: { t: number; x: number }[] = [];
|
|
9
|
+
|
|
10
|
+
/** Adds a sample: `time` in milliseconds, `value` in any unit. */
|
|
11
|
+
add(time: number, value: number): void {
|
|
12
|
+
const samples = this.#samples;
|
|
13
|
+
const last = samples[samples.length - 1];
|
|
14
|
+
if (last && time <= last.t) {
|
|
15
|
+
last.x = value;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
samples.push({ t: time, x: value });
|
|
19
|
+
while (samples.length > 2 && (samples[0] as { t: number }).t < time - WINDOW) samples.shift();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Velocity in units per second at `time` (ms). 0 when the pointer has been resting. */
|
|
23
|
+
velocity(time?: number): number {
|
|
24
|
+
const samples = this.#samples;
|
|
25
|
+
const last = samples[samples.length - 1];
|
|
26
|
+
if (!last || samples.length < 2) return 0;
|
|
27
|
+
if (time !== undefined && time - last.t > STALE) return 0;
|
|
28
|
+
const recent = samples.filter((s) => s.t >= last.t - WINDOW);
|
|
29
|
+
if (recent.length < 2) return 0;
|
|
30
|
+
let mt = 0;
|
|
31
|
+
let mx = 0;
|
|
32
|
+
for (const s of recent) {
|
|
33
|
+
mt += s.t;
|
|
34
|
+
mx += s.x;
|
|
35
|
+
}
|
|
36
|
+
mt /= recent.length;
|
|
37
|
+
mx /= recent.length;
|
|
38
|
+
let num = 0;
|
|
39
|
+
let den = 0;
|
|
40
|
+
for (const s of recent) {
|
|
41
|
+
num += (s.t - mt) * (s.x - mx);
|
|
42
|
+
den += (s.t - mt) * (s.t - mt);
|
|
43
|
+
}
|
|
44
|
+
return den === 0 ? 0 : (num / den) * 1000;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
reset(): void {
|
|
48
|
+
this.#samples = [];
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/world.ts
ADDED
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
// A small 2D physics world for UI: round bodies with gravity, air drag, collisions with
|
|
2
|
+
// restitution, Coulomb friction that makes them roll, springs between bodies and a pointer
|
|
3
|
+
// joint for grabbing and throwing. Fixed time step, so it behaves the same at 60 and 144 Hz.
|
|
4
|
+
|
|
5
|
+
import { loop } from "./loop";
|
|
6
|
+
|
|
7
|
+
export interface Vector {
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Rect {
|
|
13
|
+
left: number;
|
|
14
|
+
top: number;
|
|
15
|
+
right: number;
|
|
16
|
+
bottom: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BodyOptions {
|
|
20
|
+
/** Centre in pixels. */
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
radius: number;
|
|
24
|
+
/** Mass in kg. Defaults to the area times `density`. */
|
|
25
|
+
mass?: number;
|
|
26
|
+
/** kg per square meter, used when `mass` is not given. Default 1. */
|
|
27
|
+
density?: number;
|
|
28
|
+
/** Bounciness, 0 (clay) to 1 (perfectly elastic). Default 0.5. */
|
|
29
|
+
restitution?: number;
|
|
30
|
+
/** Coulomb friction coefficient. Default 0.4. */
|
|
31
|
+
friction?: number;
|
|
32
|
+
/** Fixed bodies never move but others collide with them. */
|
|
33
|
+
fixed?: boolean;
|
|
34
|
+
/** Initial velocity in pixels per second. */
|
|
35
|
+
vx?: number;
|
|
36
|
+
vy?: number;
|
|
37
|
+
/** Angle in radians and angular velocity in radians per second. */
|
|
38
|
+
angle?: number;
|
|
39
|
+
spin?: number;
|
|
40
|
+
/** An element positioned at the top left of the world that follows this body. */
|
|
41
|
+
element?: HTMLElement | SVGElement | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Body {
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
vx: number;
|
|
48
|
+
vy: number;
|
|
49
|
+
angle: number;
|
|
50
|
+
spin: number;
|
|
51
|
+
readonly radius: number;
|
|
52
|
+
readonly mass: number;
|
|
53
|
+
restitution: number;
|
|
54
|
+
friction: number;
|
|
55
|
+
readonly fixed: boolean;
|
|
56
|
+
element: HTMLElement | SVGElement | null;
|
|
57
|
+
/** @internal */ invMass: number;
|
|
58
|
+
/** @internal */ invInertia: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface LinkOptions {
|
|
62
|
+
/** Rest length in pixels. Defaults to the current distance. */
|
|
63
|
+
length?: number;
|
|
64
|
+
/** Oscillation frequency in Hz. Higher is stiffer. Default 4. */
|
|
65
|
+
frequency?: number;
|
|
66
|
+
/** Damping ratio, 0 to 1. Default 0.3. */
|
|
67
|
+
damping?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface Link {
|
|
71
|
+
a: Body;
|
|
72
|
+
b: Body;
|
|
73
|
+
length: number;
|
|
74
|
+
frequency: number;
|
|
75
|
+
damping: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WorldOptions {
|
|
79
|
+
/** Gravity in m/s². Default { x: 0, y: 9.81 }. */
|
|
80
|
+
gravity?: Vector;
|
|
81
|
+
/** Scale between the simulation (meters) and the screen. Default 500 pixels per meter. */
|
|
82
|
+
pixelsPerMeter?: number;
|
|
83
|
+
/** Linear air drag per second. Default 0.05. */
|
|
84
|
+
airDrag?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Rolling resistance coefficient: a braking torque proportional to the contact force that
|
|
87
|
+
* makes rolling bodies come to a stop and keeps resting piles still. Default 0.05.
|
|
88
|
+
*/
|
|
89
|
+
rollingResistance?: number;
|
|
90
|
+
/** Walls in pixels, or an element whose content box becomes the walls. */
|
|
91
|
+
bounds?: Rect | HTMLElement | null;
|
|
92
|
+
/** Simulation steps per second. Default 240. */
|
|
93
|
+
rate?: number;
|
|
94
|
+
/** Collision solver passes per step. More gives steadier stacks. Default 4. */
|
|
95
|
+
iterations?: number;
|
|
96
|
+
/** Called after every frame, e.g. to draw on a canvas. */
|
|
97
|
+
onFrame?: (world: World) => void;
|
|
98
|
+
/** Called when two bodies (or a body and a wall) hit with an impact speed in px/s. */
|
|
99
|
+
onCollide?: (a: Body, b: Body | null, speed: number) => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface Grab {
|
|
103
|
+
move(x: number, y: number): void;
|
|
104
|
+
release(): void;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const SLEEP_DISTANCE = 1;
|
|
108
|
+
const SLEEP_TIME = 1;
|
|
109
|
+
|
|
110
|
+
export class World {
|
|
111
|
+
readonly bodies: Body[] = [];
|
|
112
|
+
readonly links: Link[] = [];
|
|
113
|
+
gravity: Vector;
|
|
114
|
+
pixelsPerMeter: number;
|
|
115
|
+
airDrag: number;
|
|
116
|
+
rollingResistance: number;
|
|
117
|
+
iterations: number;
|
|
118
|
+
bounds: Rect | null = null;
|
|
119
|
+
readonly #rate: number;
|
|
120
|
+
readonly #options: WorldOptions;
|
|
121
|
+
#container: HTMLElement | null = null;
|
|
122
|
+
#grabs = new Set<{ body: Body; target: Vector }>();
|
|
123
|
+
#stopLoop: (() => void) | null = null;
|
|
124
|
+
#pending = 0;
|
|
125
|
+
#still = 0;
|
|
126
|
+
#snapshot: [number, number, number][] = [];
|
|
127
|
+
#observer: ResizeObserver | null = null;
|
|
128
|
+
#running = false;
|
|
129
|
+
|
|
130
|
+
constructor(options: WorldOptions = {}) {
|
|
131
|
+
this.#options = options;
|
|
132
|
+
this.gravity = options.gravity ?? { x: 0, y: 9.81 };
|
|
133
|
+
this.pixelsPerMeter = options.pixelsPerMeter ?? 500;
|
|
134
|
+
this.airDrag = options.airDrag ?? 0.05;
|
|
135
|
+
this.rollingResistance = options.rollingResistance ?? 0.05;
|
|
136
|
+
this.iterations = options.iterations ?? 4;
|
|
137
|
+
this.#rate = options.rate ?? 240;
|
|
138
|
+
this.setBounds(options.bounds ?? null);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Sets the walls. With an element, they follow its size. */
|
|
142
|
+
setBounds(bounds: Rect | HTMLElement | null): void {
|
|
143
|
+
this.#observer?.disconnect();
|
|
144
|
+
this.#observer = null;
|
|
145
|
+
this.#container = null;
|
|
146
|
+
if (bounds && typeof HTMLElement !== "undefined" && bounds instanceof HTMLElement) {
|
|
147
|
+
this.#container = bounds;
|
|
148
|
+
const read = () => {
|
|
149
|
+
this.bounds = { left: 0, top: 0, right: bounds.clientWidth, bottom: bounds.clientHeight };
|
|
150
|
+
this.wake();
|
|
151
|
+
};
|
|
152
|
+
read();
|
|
153
|
+
if (typeof ResizeObserver === "function") {
|
|
154
|
+
this.#observer = new ResizeObserver(read);
|
|
155
|
+
this.#observer.observe(bounds);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
this.bounds = (bounds as Rect | null) ?? null;
|
|
159
|
+
this.wake();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
add(options: BodyOptions): Body {
|
|
164
|
+
const ppm = this.pixelsPerMeter;
|
|
165
|
+
const r = options.radius;
|
|
166
|
+
const area = Math.PI * (r / ppm) ** 2;
|
|
167
|
+
const mass = options.fixed
|
|
168
|
+
? Number.POSITIVE_INFINITY
|
|
169
|
+
: (options.mass ?? area * (options.density ?? 1));
|
|
170
|
+
const body: Body = {
|
|
171
|
+
x: options.x,
|
|
172
|
+
y: options.y,
|
|
173
|
+
vx: options.vx ?? 0,
|
|
174
|
+
vy: options.vy ?? 0,
|
|
175
|
+
angle: options.angle ?? 0,
|
|
176
|
+
spin: options.spin ?? 0,
|
|
177
|
+
radius: r,
|
|
178
|
+
mass,
|
|
179
|
+
restitution: options.restitution ?? 0.5,
|
|
180
|
+
friction: options.friction ?? 0.4,
|
|
181
|
+
fixed: options.fixed ?? false,
|
|
182
|
+
element: options.element ?? null,
|
|
183
|
+
invMass: options.fixed ? 0 : 1 / mass,
|
|
184
|
+
// Solid disc: I = ½·m·r², here with r in pixels so impulses stay in pixel units.
|
|
185
|
+
invInertia: options.fixed ? 0 : 2 / (mass * r * r),
|
|
186
|
+
};
|
|
187
|
+
this.bodies.push(body);
|
|
188
|
+
this.#draw(body);
|
|
189
|
+
this.wake();
|
|
190
|
+
return body;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
remove(body: Body): void {
|
|
194
|
+
const index = this.bodies.indexOf(body);
|
|
195
|
+
if (index >= 0) this.bodies.splice(index, 1);
|
|
196
|
+
for (let i = this.links.length - 1; i >= 0; i--) {
|
|
197
|
+
const link = this.links[i] as Link;
|
|
198
|
+
if (link.a === body || link.b === body) this.links.splice(i, 1);
|
|
199
|
+
}
|
|
200
|
+
for (const grab of this.#grabs) if (grab.body === body) this.#grabs.delete(grab);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Connects two bodies with a damped spring. */
|
|
204
|
+
link(a: Body, b: Body, options: LinkOptions = {}): Link {
|
|
205
|
+
const link: Link = {
|
|
206
|
+
a,
|
|
207
|
+
b,
|
|
208
|
+
length: options.length ?? Math.hypot(b.x - a.x, b.y - a.y),
|
|
209
|
+
frequency: options.frequency ?? 4,
|
|
210
|
+
damping: options.damping ?? 0.3,
|
|
211
|
+
};
|
|
212
|
+
this.links.push(link);
|
|
213
|
+
this.wake();
|
|
214
|
+
return link;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
unlink(link: Link): void {
|
|
218
|
+
const index = this.links.indexOf(link);
|
|
219
|
+
if (index >= 0) this.links.splice(index, 1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Body under a point, if any. */
|
|
223
|
+
bodyAt(x: number, y: number): Body | null {
|
|
224
|
+
for (let i = this.bodies.length - 1; i >= 0; i--) {
|
|
225
|
+
const body = this.bodies[i] as Body;
|
|
226
|
+
if ((body.x - x) ** 2 + (body.y - y) ** 2 <= body.radius ** 2) return body;
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Pulls a body towards a moving point with a stiff spring. Released bodies keep their speed. */
|
|
232
|
+
grab(body: Body, x: number, y: number): Grab {
|
|
233
|
+
const grab = { body, target: { x, y } };
|
|
234
|
+
this.#grabs.add(grab);
|
|
235
|
+
this.wake();
|
|
236
|
+
return {
|
|
237
|
+
move: (nx, ny) => {
|
|
238
|
+
grab.target = { x: nx, y: ny };
|
|
239
|
+
this.wake();
|
|
240
|
+
},
|
|
241
|
+
release: () => {
|
|
242
|
+
this.#grabs.delete(grab);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Adds an impulse in kg·px/s at the centre (or `at` a point on the body for spin). */
|
|
248
|
+
push(body: Body, impulse: Vector, at?: Vector): void {
|
|
249
|
+
body.vx += impulse.x * body.invMass;
|
|
250
|
+
body.vy += impulse.y * body.invMass;
|
|
251
|
+
if (at)
|
|
252
|
+
body.spin += ((at.x - body.x) * impulse.y - (at.y - body.y) * impulse.x) * body.invInertia;
|
|
253
|
+
this.wake();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Lets the pointer grab and throw bodies inside `element` (default: the bounds element).
|
|
258
|
+
* Returns a function that removes the listeners.
|
|
259
|
+
*/
|
|
260
|
+
bindPointer(element: HTMLElement | null = this.#container): () => void {
|
|
261
|
+
if (!element) return () => {};
|
|
262
|
+
const active = new Map<number, Grab>();
|
|
263
|
+
const local = (event: PointerEvent) => {
|
|
264
|
+
const rect = element.getBoundingClientRect();
|
|
265
|
+
return { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
|
266
|
+
};
|
|
267
|
+
const down = (event: PointerEvent) => {
|
|
268
|
+
const p = local(event);
|
|
269
|
+
const body = this.bodyAt(p.x, p.y);
|
|
270
|
+
if (!body || body.fixed) return;
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
element.setPointerCapture?.(event.pointerId);
|
|
273
|
+
active.set(event.pointerId, this.grab(body, p.x, p.y));
|
|
274
|
+
};
|
|
275
|
+
const move = (event: PointerEvent) => {
|
|
276
|
+
const grab = active.get(event.pointerId);
|
|
277
|
+
if (!grab) return;
|
|
278
|
+
const p = local(event);
|
|
279
|
+
grab.move(p.x, p.y);
|
|
280
|
+
};
|
|
281
|
+
const up = (event: PointerEvent) => {
|
|
282
|
+
active.get(event.pointerId)?.release();
|
|
283
|
+
active.delete(event.pointerId);
|
|
284
|
+
};
|
|
285
|
+
const touchAction = element.style.touchAction;
|
|
286
|
+
element.style.touchAction = "none";
|
|
287
|
+
element.addEventListener("pointerdown", down);
|
|
288
|
+
element.addEventListener("pointermove", move);
|
|
289
|
+
element.addEventListener("pointerup", up);
|
|
290
|
+
element.addEventListener("pointercancel", up);
|
|
291
|
+
return () => {
|
|
292
|
+
for (const grab of active.values()) grab.release();
|
|
293
|
+
element.style.touchAction = touchAction;
|
|
294
|
+
element.removeEventListener("pointerdown", down);
|
|
295
|
+
element.removeEventListener("pointermove", move);
|
|
296
|
+
element.removeEventListener("pointerup", up);
|
|
297
|
+
element.removeEventListener("pointercancel", up);
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Starts the simulation on the shared frame loop. */
|
|
302
|
+
start(): this {
|
|
303
|
+
this.#running = true;
|
|
304
|
+
this.wake();
|
|
305
|
+
return this;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Pauses the simulation. */
|
|
309
|
+
stop(): this {
|
|
310
|
+
this.#running = false;
|
|
311
|
+
this.#stopLoop?.();
|
|
312
|
+
this.#stopLoop = null;
|
|
313
|
+
return this;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** True while the world runs and something moves. A resting world uses no CPU. */
|
|
317
|
+
get awake(): boolean {
|
|
318
|
+
return this.#stopLoop !== null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Resumes a resting world, e.g. after you changed bodies by hand. */
|
|
322
|
+
wake(): void {
|
|
323
|
+
this.#still = 0;
|
|
324
|
+
this.#snapshot = [];
|
|
325
|
+
if (!this.#running || this.#stopLoop) return;
|
|
326
|
+
this.#stopLoop = loop.add((dt) => this.#frame(dt));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
destroy(): void {
|
|
330
|
+
this.stop();
|
|
331
|
+
this.#observer?.disconnect();
|
|
332
|
+
this.bodies.length = 0;
|
|
333
|
+
this.links.length = 0;
|
|
334
|
+
this.#grabs.clear();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
#frame(dt: number): void {
|
|
338
|
+
const h = 1 / this.#rate;
|
|
339
|
+
this.#pending = Math.min(this.#pending + dt, h * 32);
|
|
340
|
+
while (this.#pending >= h) {
|
|
341
|
+
this.step(h);
|
|
342
|
+
this.#pending -= h;
|
|
343
|
+
}
|
|
344
|
+
for (const body of this.bodies) this.#draw(body);
|
|
345
|
+
this.#options.onFrame?.(this);
|
|
346
|
+
|
|
347
|
+
// Sleep when nothing has moved more than a pixel for a second. Measuring displacement
|
|
348
|
+
// instead of speed ignores the tiny velocity that gravity adds to resting bodies every step.
|
|
349
|
+
if (this.#grabs.size > 0 || this.#moved()) {
|
|
350
|
+
this.#still = 0;
|
|
351
|
+
this.#snapshot = this.bodies.map((b) => [b.x, b.y, b.angle * b.radius]);
|
|
352
|
+
} else {
|
|
353
|
+
this.#still += dt;
|
|
354
|
+
}
|
|
355
|
+
if (this.#still >= SLEEP_TIME) {
|
|
356
|
+
this.#stopLoop?.();
|
|
357
|
+
this.#stopLoop = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#moved(): boolean {
|
|
362
|
+
const snapshot = this.#snapshot;
|
|
363
|
+
if (snapshot.length !== this.bodies.length) return true;
|
|
364
|
+
return this.bodies.some((b, i) => {
|
|
365
|
+
const [x, y, arc] = snapshot[i] as [number, number, number];
|
|
366
|
+
return (
|
|
367
|
+
Math.abs(b.x - x) > SLEEP_DISTANCE ||
|
|
368
|
+
Math.abs(b.y - y) > SLEEP_DISTANCE ||
|
|
369
|
+
Math.abs(b.angle * b.radius - arc) > SLEEP_DISTANCE
|
|
370
|
+
);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
#draw(body: Body): void {
|
|
375
|
+
const el = body.element;
|
|
376
|
+
if (!el) return;
|
|
377
|
+
const r = body.radius;
|
|
378
|
+
el.style.transform = `translate3d(${body.x - r}px, ${body.y - r}px, 0) rotate(${body.angle}rad)`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Advances the simulation by `dt` seconds. Called for you while the world runs. */
|
|
382
|
+
step(dt: number): void {
|
|
383
|
+
const ppm = this.pixelsPerMeter;
|
|
384
|
+
const gx = this.gravity.x * ppm;
|
|
385
|
+
const gy = this.gravity.y * ppm;
|
|
386
|
+
const drag = Math.exp(-this.airDrag * dt);
|
|
387
|
+
|
|
388
|
+
for (const body of this.bodies) {
|
|
389
|
+
if (body.fixed) continue;
|
|
390
|
+
body.vx = (body.vx + gx * dt) * drag;
|
|
391
|
+
body.vy = (body.vy + gy * dt) * drag;
|
|
392
|
+
body.spin *= drag;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (const link of this.links) this.#spring(link, dt);
|
|
396
|
+
|
|
397
|
+
for (const { body, target } of this.#grabs) {
|
|
398
|
+
// A critically damped 8 Hz joint, integrated implicitly so it never explodes:
|
|
399
|
+
// v' = (v + dt·k·(target − x)) / (1 + dt·c + dt²·k)
|
|
400
|
+
const w = 2 * Math.PI * 8;
|
|
401
|
+
const k = w * w;
|
|
402
|
+
const c = 2 * w;
|
|
403
|
+
const den = 1 + dt * c + dt * dt * k;
|
|
404
|
+
body.vx = (body.vx + dt * k * (target.x - body.x)) / den;
|
|
405
|
+
body.vy = (body.vy + dt * k * (target.y - body.y)) / den;
|
|
406
|
+
body.spin *= 0.9;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const body of this.bodies) {
|
|
410
|
+
if (body.fixed) continue;
|
|
411
|
+
body.x += body.vx * dt;
|
|
412
|
+
body.y += body.vy * dt;
|
|
413
|
+
body.angle += body.spin * dt;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const bodies = [...this.bodies].sort((a, b) => a.x - a.radius - (b.x - b.radius));
|
|
417
|
+
const restingSpeed = 0.5 * ppm;
|
|
418
|
+
for (let pass = 0; pass < this.iterations; pass++) {
|
|
419
|
+
for (let i = 0; i < bodies.length; i++) {
|
|
420
|
+
const a = bodies[i] as Body;
|
|
421
|
+
for (let j = i + 1; j < bodies.length; j++) {
|
|
422
|
+
const b = bodies[j] as Body;
|
|
423
|
+
if (b.x - b.radius > a.x + a.radius) break;
|
|
424
|
+
const dx = b.x - a.x;
|
|
425
|
+
const dy = b.y - a.y;
|
|
426
|
+
const sum = a.radius + b.radius;
|
|
427
|
+
const d2 = dx * dx + dy * dy;
|
|
428
|
+
if (d2 >= sum * sum) continue;
|
|
429
|
+
const d = Math.sqrt(d2);
|
|
430
|
+
const nx = d > 1e-9 ? dx / d : 1;
|
|
431
|
+
const ny = d > 1e-9 ? dy / d : 0;
|
|
432
|
+
this.#contact(a, b, nx, ny, sum - d, restingSpeed, pass === 0);
|
|
433
|
+
}
|
|
434
|
+
if (this.bounds && !a.fixed) this.#walls(a, this.bounds, restingSpeed, pass === 0);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#spring(link: Link, dt: number): void {
|
|
440
|
+
const { a, b } = link;
|
|
441
|
+
const inv = a.invMass + b.invMass;
|
|
442
|
+
if (inv === 0) return;
|
|
443
|
+
const dx = b.x - a.x;
|
|
444
|
+
const dy = b.y - a.y;
|
|
445
|
+
const d = Math.hypot(dx, dy) || 1e-9;
|
|
446
|
+
const nx = dx / d;
|
|
447
|
+
const ny = dy / d;
|
|
448
|
+
const m = 1 / inv;
|
|
449
|
+
const w = 2 * Math.PI * link.frequency;
|
|
450
|
+
const k = m * w * w;
|
|
451
|
+
const c = 2 * m * link.damping * w;
|
|
452
|
+
const vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
|
453
|
+
const force = -k * (d - link.length) - c * vn;
|
|
454
|
+
const j = force * dt;
|
|
455
|
+
a.vx -= j * nx * a.invMass;
|
|
456
|
+
a.vy -= j * ny * a.invMass;
|
|
457
|
+
b.vx += j * nx * b.invMass;
|
|
458
|
+
b.vy += j * ny * b.invMass;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Resolves one contact. n points from a to b; a may be a wall (null).
|
|
462
|
+
#contact(
|
|
463
|
+
a: Body | null,
|
|
464
|
+
b: Body,
|
|
465
|
+
nx: number,
|
|
466
|
+
ny: number,
|
|
467
|
+
depth: number,
|
|
468
|
+
restingSpeed: number,
|
|
469
|
+
report: boolean,
|
|
470
|
+
): void {
|
|
471
|
+
const ima = a ? a.invMass : 0;
|
|
472
|
+
const imb = b.invMass;
|
|
473
|
+
const inv = ima + imb;
|
|
474
|
+
if (inv === 0) return;
|
|
475
|
+
|
|
476
|
+
// Push apart, shared by inverse mass.
|
|
477
|
+
const correction = Math.max(depth - 0.01, 0) / inv;
|
|
478
|
+
if (a) {
|
|
479
|
+
a.x -= nx * correction * ima;
|
|
480
|
+
a.y -= ny * correction * ima;
|
|
481
|
+
}
|
|
482
|
+
b.x += nx * correction * imb;
|
|
483
|
+
b.y += ny * correction * imb;
|
|
484
|
+
|
|
485
|
+
const ra = a ? a.radius : 0;
|
|
486
|
+
const rb = b.radius;
|
|
487
|
+
const iia = a ? a.invInertia : 0;
|
|
488
|
+
const iib = b.invInertia;
|
|
489
|
+
// Velocity of the contact point on each body: v + ω × r, with r = ±radius·n.
|
|
490
|
+
const tx = -ny;
|
|
491
|
+
const ty = nx;
|
|
492
|
+
const relative = () => {
|
|
493
|
+
const vax = a ? a.vx - a.spin * ra * ny : 0;
|
|
494
|
+
const vay = a ? a.vy + a.spin * ra * nx : 0;
|
|
495
|
+
const vbx = b.vx + b.spin * rb * ny;
|
|
496
|
+
const vby = b.vy - b.spin * rb * nx;
|
|
497
|
+
return { x: vbx - vax, y: vby - vay };
|
|
498
|
+
};
|
|
499
|
+
let rv = relative();
|
|
500
|
+
const vn = rv.x * nx + rv.y * ny;
|
|
501
|
+
if (vn >= 0) return;
|
|
502
|
+
|
|
503
|
+
const e = -vn < restingSpeed ? 0 : Math.min(a ? a.restitution : 1, b.restitution);
|
|
504
|
+
const jn = (-(1 + e) * vn) / inv;
|
|
505
|
+
if (a) {
|
|
506
|
+
a.vx -= jn * nx * ima;
|
|
507
|
+
a.vy -= jn * ny * ima;
|
|
508
|
+
}
|
|
509
|
+
b.vx += jn * nx * imb;
|
|
510
|
+
b.vy += jn * ny * imb;
|
|
511
|
+
if (report && -vn > restingSpeed) this.#options.onCollide?.(b, a, -vn);
|
|
512
|
+
|
|
513
|
+
// Coulomb friction along the tangent, which also makes round bodies roll.
|
|
514
|
+
rv = relative();
|
|
515
|
+
const vt = rv.x * tx + rv.y * ty;
|
|
516
|
+
const kt = inv + ra * ra * iia + rb * rb * iib;
|
|
517
|
+
const mu = Math.sqrt((a ? a.friction : 1) * b.friction);
|
|
518
|
+
const jt = Math.max(Math.min(-vt / kt, mu * jn), -mu * jn);
|
|
519
|
+
if (a) {
|
|
520
|
+
a.vx -= jt * tx * ima;
|
|
521
|
+
a.vy -= jt * ty * ima;
|
|
522
|
+
a.spin -= jt * ra * iia;
|
|
523
|
+
}
|
|
524
|
+
b.vx += jt * tx * imb;
|
|
525
|
+
b.vy += jt * ty * imb;
|
|
526
|
+
b.spin -= jt * rb * iib;
|
|
527
|
+
|
|
528
|
+
// Rolling resistance: a torque against the spin, limited so it never reverses it.
|
|
529
|
+
const brake = (body: Body, r: number) => {
|
|
530
|
+
const limit = this.rollingResistance * jn * r * body.invInertia;
|
|
531
|
+
body.spin = Math.abs(body.spin) <= limit ? 0 : body.spin - Math.sign(body.spin) * limit;
|
|
532
|
+
};
|
|
533
|
+
if (a) brake(a, ra);
|
|
534
|
+
brake(b, rb);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#walls(body: Body, walls: Rect, restingSpeed: number, report: boolean): void {
|
|
538
|
+
const r = body.radius;
|
|
539
|
+
if (body.y + r > walls.bottom)
|
|
540
|
+
this.#contact(null, body, 0, -1, body.y + r - walls.bottom, restingSpeed, report);
|
|
541
|
+
if (body.y - r < walls.top)
|
|
542
|
+
this.#contact(null, body, 0, 1, walls.top - (body.y - r), restingSpeed, report);
|
|
543
|
+
if (body.x - r < walls.left)
|
|
544
|
+
this.#contact(null, body, 1, 0, walls.left - (body.x - r), restingSpeed, report);
|
|
545
|
+
if (body.x + r > walls.right)
|
|
546
|
+
this.#contact(null, body, -1, 0, body.x + r - walls.right, restingSpeed, report);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Creates a world. Call `start()` to run it on the frame loop, or `step()` it yourself. */
|
|
551
|
+
export function world(options: WorldOptions = {}): World {
|
|
552
|
+
return new World(options);
|
|
553
|
+
}
|