@flare-engine/dynamics 0.4.8 → 0.4.11

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/dist/index.mjs CHANGED
@@ -106,6 +106,58 @@ function writeAabb(verts, vOffset, out, offset) {
106
106
  out[offset + 2] = maxX;
107
107
  out[offset + 3] = maxY;
108
108
  }
109
+ // src/timeline.ts
110
+ const DEFAULT_STRIDE = 3;
111
+ const DEFAULT_MAX_FRAMES = 900;
112
+ function assertCount(name, value, min) {
113
+ if (!Number.isInteger(value) || value < min) throw new RangeError(`createTimeline: ${name} must be an integer >= ${min}, got ${value}`);
114
+ }
115
+ function createTimeline(elementCount, dt, opts = {}) {
116
+ assertCount("elementCount", elementCount, 0);
117
+ if (!Number.isFinite(dt) || dt <= 0) throw new RangeError(`createTimeline: dt must be a finite number > 0, got ${dt}`);
118
+ const stride = opts.stride ?? DEFAULT_STRIDE;
119
+ const maxFrames = opts.maxFrames ?? DEFAULT_MAX_FRAMES;
120
+ assertCount("stride", stride, 1);
121
+ assertCount("maxFrames", maxFrames, 1);
122
+ const frameStride = elementCount * stride;
123
+ const buf = new Float32Array(Math.max(1, frameStride) * maxFrames);
124
+ let frameCount = 0;
125
+ return {
126
+ elementCount,
127
+ get frameCount() {
128
+ return frameCount;
129
+ },
130
+ get duration() {
131
+ return frameCount > 1 ? (frameCount - 1) * dt : 0;
132
+ },
133
+ record(writer, until) {
134
+ if (elementCount === 0) return;
135
+ while (frameCount < maxFrames) {
136
+ writer(buf, frameCount * frameStride);
137
+ frameCount++;
138
+ if (until === void 0 || until()) return;
139
+ }
140
+ },
141
+ sampleInto(progress, out) {
142
+ const n = frameStride;
143
+ if (frameCount === 0) {
144
+ out.fill(0, 0, n);
145
+ return;
146
+ }
147
+ const f = (progress > 1 ? 1 : progress >= 0 ? progress : 0) * (frameCount - 1);
148
+ const i0 = Math.floor(f);
149
+ const a = f - i0;
150
+ const i1 = i0 + 1 < frameCount ? i0 + 1 : i0;
151
+ const b0 = i0 * n;
152
+ const b1 = i1 * n;
153
+ for (let o = 0; o < n; o++) {
154
+ const lo = buf[b0 + o];
155
+ const hi = buf[b1 + o];
156
+ out[o] = lo + (hi - lo) * a;
157
+ }
158
+ }
159
+ };
160
+ }
109
161
  // src/manifold.ts
110
162
  /** OBB-OBB collision: separating-axis test + reference/incident face clipping,
111
163
  * building a 1-2 point contact manifold with per-point penetration depth.
@@ -316,6 +368,12 @@ function createManifold() {
316
368
  return {
317
369
  a: void 0,
318
370
  b: void 0,
371
+ ai: 0,
372
+ bi: 0,
373
+ aInvMass: 0,
374
+ aInvInertia: 0,
375
+ bInvMass: 0,
376
+ bInvInertia: 0,
319
377
  normalX: 0,
320
378
  normalY: 0,
321
379
  tangentX: 0,
@@ -325,19 +383,24 @@ function createManifold() {
325
383
  points: [createManifoldPoint(), createManifoldPoint()]
326
384
  };
327
385
  }
328
- function createPool() {
386
+ function createPool(sleep = null) {
329
387
  return {
330
388
  manifolds: [],
331
389
  manifoldCount: 0,
332
390
  verts: /* @__PURE__ */ new Float64Array(0),
333
391
  normals: /* @__PURE__ */ new Float64Array(0),
334
392
  aabbs: /* @__PURE__ */ new Float64Array(0),
335
- capacityBodies: 0
393
+ capacityBodies: 0,
394
+ sleep,
395
+ sleepCounter: /* @__PURE__ */ new Int32Array(0),
396
+ asleep: /* @__PURE__ */ new Uint8Array(0),
397
+ sleepSnapshot: /* @__PURE__ */ new Float64Array(0),
398
+ sleepOwner: []
336
399
  };
337
400
  }
338
- /** Grows the per-body geometry buffers to hold at least `count` bodies (8
339
- * doubles per body for verts/normals, 4 for the AABB). No-op if already
340
- * large enough. */
401
+ /** Grows the per-body buffers to hold at least `count` bodies (8 doubles per
402
+ * body for verts/normals, 4 for the AABB, 3 for the sleep snapshot). No-op
403
+ * if already large enough. */
341
404
  function ensureBodyCapacity(pool, count) {
342
405
  if (count <= pool.capacityBodies) return;
343
406
  let cap = pool.capacityBodies === 0 ? 8 : pool.capacityBodies;
@@ -345,6 +408,16 @@ function ensureBodyCapacity(pool, count) {
345
408
  pool.verts = new Float64Array(cap * 8);
346
409
  pool.normals = new Float64Array(cap * 8);
347
410
  pool.aabbs = new Float64Array(cap * 4);
411
+ const sleepCounter = new Int32Array(cap);
412
+ sleepCounter.set(pool.sleepCounter);
413
+ pool.sleepCounter = sleepCounter;
414
+ const asleep = new Uint8Array(cap);
415
+ asleep.set(pool.asleep);
416
+ pool.asleep = asleep;
417
+ const sleepSnapshot = new Float64Array(cap * 3);
418
+ sleepSnapshot.set(pool.sleepSnapshot);
419
+ pool.sleepSnapshot = sleepSnapshot;
420
+ while (pool.sleepOwner.length < cap) pool.sleepOwner.push(void 0);
348
421
  pool.capacityBodies = cap;
349
422
  }
350
423
  /** Grows `pool.manifolds` to hold at least `count` entries, appending
@@ -358,6 +431,23 @@ function ensureManifoldCapacity(pool, count) {
358
431
  const BAUMGARTE = .2;
359
432
  const PENETRATION_SLOP = .5;
360
433
  const pools = /* @__PURE__ */ new WeakMap();
434
+ function nowMs() {
435
+ if (typeof performance !== "undefined" && typeof performance.now === "function") return performance.now();
436
+ return Date.now();
437
+ }
438
+ function copySleepOptions(sleep) {
439
+ const { linearThreshold, angularThreshold, steps } = sleep;
440
+ if (!Number.isFinite(linearThreshold) || linearThreshold < 0) throw new RangeError(`createWorld: sleep.linearThreshold must be a finite number >= 0, got ${linearThreshold}`);
441
+ if (!Number.isFinite(angularThreshold) || angularThreshold < 0) throw new RangeError(`createWorld: sleep.angularThreshold must be a finite number >= 0, got ${angularThreshold}`);
442
+ if (!Number.isInteger(steps) || steps < 1) throw new RangeError(`createWorld: sleep.steps must be an integer >= 1, got ${steps}`);
443
+ return {
444
+ linearThreshold,
445
+ angularThreshold,
446
+ steps
447
+ };
448
+ }
449
+ /** Throws `RangeError` when `opts.sleep` has a negative or non-finite
450
+ * threshold or a `steps` that is not an integer >= 1. */
361
451
  function createWorld(opts = {}) {
362
452
  const world = {
363
453
  bodies: [],
@@ -370,7 +460,7 @@ function createWorld(opts = {}) {
370
460
  },
371
461
  iterations: opts.iterations ?? 10
372
462
  };
373
- pools.set(world, createPool());
463
+ pools.set(world, createPool(opts.sleep ? copySleepOptions(opts.sleep) : null));
374
464
  return world;
375
465
  }
376
466
  function addBody(world, body) {
@@ -386,16 +476,37 @@ function relativeVelocityAlong(a, b, raX, raY, rbX, rbY, dx, dy) {
386
476
  const vby = b.velocity.y + b.angularVelocity * rbX;
387
477
  return (vbx - vax) * dx + (vby - vay) * dy;
388
478
  }
389
- function applyImpulse(body, ix, iy, rx, ry) {
390
- if (body.invMass === 0) return;
391
- body.velocity.x += ix * body.invMass;
392
- body.velocity.y += iy * body.invMass;
393
- body.angularVelocity += body.invInertia * (rx * iy - ry * ix);
479
+ function applyImpulse(body, invMass, invInertia, ix, iy, rx, ry) {
480
+ if (invMass === 0) return;
481
+ body.velocity.x += ix * invMass;
482
+ body.velocity.y += iy * invMass;
483
+ body.angularVelocity += invInertia * (rx * iy - ry * ix);
484
+ }
485
+ /** Binds every slot to the body now at its index, resetting a slot whose body
486
+ * changed (awake, counter 0), then wakes any sleeper the consumer wrote to:
487
+ * a non-zero velocity (sleepers are held at exactly 0) or a position/angle
488
+ * that differs from the snapshot taken at sleep. */
489
+ function syncSleepSlots(pool, bodies) {
490
+ const { sleepOwner, asleep, sleepCounter, sleepSnapshot } = pool;
491
+ for (let i = 0; i < bodies.length; i++) {
492
+ const body = bodies[i];
493
+ if (sleepOwner[i] !== body) {
494
+ sleepOwner[i] = body;
495
+ asleep[i] = 0;
496
+ sleepCounter[i] = 0;
497
+ continue;
498
+ }
499
+ if (asleep[i] === 0) continue;
500
+ const s = i * 3;
501
+ if (body.velocity.x !== 0 || body.velocity.y !== 0 || body.angularVelocity !== 0 || body.position.x !== sleepSnapshot[s] || body.position.y !== sleepSnapshot[s + 1] || body.angle !== sleepSnapshot[s + 2]) {
502
+ asleep[i] = 0;
503
+ sleepCounter[i] = 0;
504
+ }
505
+ }
394
506
  }
395
507
  /** Writes every body's world-space vertices, outward normals, and AABB into
396
508
  * `pool`, once per step, so the broadphase pair loop never recomputes them. */
397
509
  function writeGeometry(pool, bodies) {
398
- ensureBodyCapacity(pool, bodies.length);
399
510
  for (let i = 0; i < bodies.length; i++) {
400
511
  const b = bodies[i];
401
512
  const vOffset = i * 8;
@@ -406,12 +517,14 @@ function writeGeometry(pool, bodies) {
406
517
  }
407
518
  }
408
519
  /** Broadphase (precomputed-AABB reject) + narrowphase (OBB manifold),
409
- * writing surviving contacts into `pool.manifolds[0..manifoldCount)`. */
520
+ * writing surviving contacts into `pool.manifolds[0..manifoldCount)`. A pair
521
+ * whose two bodies are each static or asleep is skipped. */
410
522
  function collectManifolds(pool, bodies) {
411
523
  pool.manifoldCount = 0;
412
- const aabbs = pool.aabbs;
524
+ const { aabbs, asleep } = pool;
413
525
  for (let i = 0; i < bodies.length; i++) {
414
526
  const a = bodies[i];
527
+ const aResting = isStatic(a) || asleep[i] === 1;
415
528
  const aOff = i * 4;
416
529
  const aMinX = aabbs[aOff];
417
530
  const aMinY = aabbs[aOff + 1];
@@ -419,7 +532,7 @@ function collectManifolds(pool, bodies) {
419
532
  const aMaxY = aabbs[aOff + 3];
420
533
  for (let j = i + 1; j < bodies.length; j++) {
421
534
  const b = bodies[j];
422
- if (isStatic(a) && isStatic(b)) continue;
535
+ if (aResting && (isStatic(b) || asleep[j] === 1)) continue;
423
536
  const bOff = j * 4;
424
537
  const bMinX = aabbs[bOff];
425
538
  const bMinY = aabbs[bOff + 1];
@@ -428,17 +541,52 @@ function collectManifolds(pool, bodies) {
428
541
  if (aMaxX < bMinX || aMinX > bMaxX || aMaxY < bMinY || aMinY > bMaxY) continue;
429
542
  ensureManifoldCapacity(pool, pool.manifoldCount + 1);
430
543
  const m = pool.manifolds[pool.manifoldCount];
431
- if (collideInto(a, b, pool.verts, pool.normals, i, j, m)) pool.manifoldCount++;
544
+ if (collideInto(a, b, pool.verts, pool.normals, i, j, m)) {
545
+ m.ai = i;
546
+ m.bi = j;
547
+ pool.manifoldCount++;
548
+ }
432
549
  }
433
550
  }
434
551
  }
552
+ /** Wakes a sleeper touched by an awake body that was moving on the previous
553
+ * step (sleep counter 0), giving it this step's gravity so it continues as
554
+ * if it had been awake for the integration. A sleeper touched only by a
555
+ * settling body stays asleep. Pairs of two resting bodies never reach here,
556
+ * so a manifold with one sleeper always has an awake dynamic partner. */
557
+ function wakeByContact(pool, gravity, dt) {
558
+ const { asleep, sleepCounter } = pool;
559
+ for (let mi = 0; mi < pool.manifoldCount; mi++) {
560
+ const m = pool.manifolds[mi];
561
+ const aAsleep = asleep[m.ai] === 1;
562
+ if (aAsleep === (asleep[m.bi] === 1)) continue;
563
+ const sleeperIdx = aAsleep ? m.ai : m.bi;
564
+ if (sleepCounter[aAsleep ? m.bi : m.ai] !== 0) continue;
565
+ asleep[sleeperIdx] = 0;
566
+ sleepCounter[sleeperIdx] = 0;
567
+ const sleeper = aAsleep ? m.a : m.b;
568
+ sleeper.velocity.x += gravity.x * dt;
569
+ sleeper.velocity.y += gravity.y * dt;
570
+ }
571
+ }
435
572
  /** Derives per-point solver mass terms and the velocity/position bias for
436
573
  * every live manifold, writing them onto the manifold's own contact points. */
437
574
  function prepare(pool, gravity, dt) {
438
575
  const restThreshold = Math.hypot(gravity.x, gravity.y) * dt;
576
+ const { asleep } = pool;
439
577
  for (let mi = 0; mi < pool.manifoldCount; mi++) {
440
578
  const m = pool.manifolds[mi];
441
579
  const { a, b } = m;
580
+ const aAsleep = asleep[m.ai] === 1;
581
+ const bAsleep = asleep[m.bi] === 1;
582
+ const aInvMass = aAsleep ? 0 : a.invMass;
583
+ const aInvInertia = aAsleep ? 0 : a.invInertia;
584
+ const bInvMass = bAsleep ? 0 : b.invMass;
585
+ const bInvInertia = bAsleep ? 0 : b.invInertia;
586
+ m.aInvMass = aInvMass;
587
+ m.aInvInertia = aInvInertia;
588
+ m.bInvMass = bInvMass;
589
+ m.bInvInertia = bInvInertia;
442
590
  const normalX = m.normalX;
443
591
  const normalY = m.normalY;
444
592
  const tangentX = m.tangentX;
@@ -452,10 +600,10 @@ function prepare(pool, gravity, dt) {
452
600
  const rbY = cp.y - b.position.y;
453
601
  const rnA = raX * normalY - raY * normalX;
454
602
  const rnB = rbX * normalY - rbY * normalX;
455
- const kNormal = a.invMass + b.invMass + a.invInertia * rnA * rnA + b.invInertia * rnB * rnB;
603
+ const kNormal = aInvMass + bInvMass + aInvInertia * rnA * rnA + bInvInertia * rnB * rnB;
456
604
  const rtA = raX * tangentY - raY * tangentX;
457
605
  const rtB = rbX * tangentY - rbY * tangentX;
458
- const kTangent = a.invMass + b.invMass + a.invInertia * rtA * rtA + b.invInertia * rtB * rtB;
606
+ const kTangent = aInvMass + bInvMass + aInvInertia * rtA * rtA + bInvInertia * rtB * rtB;
459
607
  const vn = relativeVelocityAlong(a, b, raX, raY, rbX, rbY, normalX, normalY);
460
608
  const restitutionBias = vn < -restThreshold ? -restitution * vn : 0;
461
609
  const baumgarte = BAUMGARTE / dt * Math.max(0, cp.penetration - PENETRATION_SLOP);
@@ -473,7 +621,7 @@ function prepare(pool, gravity, dt) {
473
621
  function solve(pool) {
474
622
  for (let mi = 0; mi < pool.manifoldCount; mi++) {
475
623
  const m = pool.manifolds[mi];
476
- const { a, b, friction } = m;
624
+ const { a, b, friction, aInvMass, aInvInertia, bInvMass, bInvInertia } = m;
477
625
  const normalX = m.normalX;
478
626
  const normalY = m.normalY;
479
627
  const tangentX = m.tangentX;
@@ -491,8 +639,8 @@ function solve(pool) {
491
639
  cp.normalImpulse = newNormal;
492
640
  const pnX = normalX * dPn;
493
641
  const pnY = normalY * dPn;
494
- applyImpulse(a, -pnX, -pnY, raX, raY);
495
- applyImpulse(b, pnX, pnY, rbX, rbY);
642
+ applyImpulse(a, aInvMass, aInvInertia, -pnX, -pnY, raX, raY);
643
+ applyImpulse(b, bInvMass, bInvInertia, pnX, pnY, rbX, rbY);
496
644
  const vt = relativeVelocityAlong(a, b, raX, raY, rbX, rbY, tangentX, tangentY);
497
645
  let dPt = cp.massTangent * -vt;
498
646
  const maxPt = friction * cp.normalImpulse;
@@ -501,20 +649,55 @@ function solve(pool) {
501
649
  cp.tangentImpulse = newTangent;
502
650
  const ptX = tangentX * dPt;
503
651
  const ptY = tangentY * dPt;
504
- applyImpulse(a, -ptX, -ptY, raX, raY);
505
- applyImpulse(b, ptX, ptY, rbX, rbY);
652
+ applyImpulse(a, aInvMass, aInvInertia, -ptX, -ptY, raX, raY);
653
+ applyImpulse(b, bInvMass, bInvInertia, ptX, ptY, rbX, rbY);
506
654
  }
507
655
  }
508
656
  }
657
+ /** Puts every awake dynamic body that stayed under both thresholds for
658
+ * `sleep.steps` consecutive steps to sleep: velocities zeroed, flag set,
659
+ * position/angle snapshotted for the write-wake check. */
660
+ function updateSleep(pool, sleep, bodies) {
661
+ const { asleep, sleepCounter, sleepSnapshot } = pool;
662
+ const linear2 = sleep.linearThreshold * sleep.linearThreshold;
663
+ const angular2 = sleep.angularThreshold * sleep.angularThreshold;
664
+ for (let i = 0; i < bodies.length; i++) {
665
+ const body = bodies[i];
666
+ if (isStatic(body) || asleep[i] === 1) continue;
667
+ const vx = body.velocity.x;
668
+ const vy = body.velocity.y;
669
+ const w = body.angularVelocity;
670
+ if (vx * vx + vy * vy < linear2 && w * w < angular2) {
671
+ const count = sleepCounter[i] + 1;
672
+ sleepCounter[i] = count;
673
+ if (count < sleep.steps) continue;
674
+ body.velocity.x = 0;
675
+ body.velocity.y = 0;
676
+ body.angularVelocity = 0;
677
+ asleep[i] = 1;
678
+ const s = i * 3;
679
+ sleepSnapshot[s] = body.position.x;
680
+ sleepSnapshot[s + 1] = body.position.y;
681
+ sleepSnapshot[s + 2] = body.angle;
682
+ } else sleepCounter[i] = 0;
683
+ }
684
+ }
509
685
  /** Advance the world by `dt` seconds (fixed steps recommended).
510
686
  *
511
- * Resting contacts settle to a stable *position* but retain a small, bounded
512
- * velocity oscillation at the penetration-slop equilibrium — a sequential-
513
- * impulse solver with Baumgarte bias and no sleeping does not converge
514
- * velocity to exactly zero at rest, only the position does. This is
515
- * inherent to the algorithm, not a bug; sleeping and damping are
516
- * deliberately out of scope for v1. A consumer needing pixel-exact
517
- * stillness should clamp small resting velocities at its own layer. */
687
+ * By default (no `WorldOptions.sleep`) resting contacts settle to a stable
688
+ * *position* but retain a small, bounded velocity oscillation at the
689
+ * penetration-slop equilibrium — a sequential-impulse solver with Baumgarte
690
+ * bias does not converge velocity to exactly zero at rest, only the position
691
+ * does. `WorldOptions.sleep` opts into per-body sleeping: a dynamic body
692
+ * under both thresholds for `steps` consecutive steps has its velocities
693
+ * zeroed and is skipped by integration and by pair tests against other
694
+ * resting bodies until an awake body touches it, the consumer writes its
695
+ * velocity, position or angle, or `wakeBody` is called. There are no
696
+ * islands, so a woken stack wakes one level per step, and a sleeper does not
697
+ * follow a static body that is moved under it. Damping stays out of scope.
698
+ *
699
+ * A world that never went through `createWorld` (a spread copy or a
700
+ * `structuredClone`) is adopted on its first `step` with sleeping off. */
518
701
  function step(world, dt) {
519
702
  if (dt <= 0) return;
520
703
  let pool = pools.get(world);
@@ -523,24 +706,104 @@ function step(world, dt) {
523
706
  pools.set(world, pool);
524
707
  }
525
708
  const { bodies } = world;
709
+ ensureBodyCapacity(pool, bodies.length);
710
+ const { sleep, asleep } = pool;
711
+ if (sleep !== null) syncSleepSlots(pool, bodies);
526
712
  for (let i = 0; i < bodies.length; i++) {
527
713
  const body = bodies[i];
528
- if (isStatic(body)) continue;
714
+ if (isStatic(body) || asleep[i] === 1) continue;
529
715
  body.velocity.x += world.gravity.x * dt;
530
716
  body.velocity.y += world.gravity.y * dt;
531
717
  }
532
718
  writeGeometry(pool, bodies);
533
719
  collectManifolds(pool, bodies);
720
+ if (sleep !== null) wakeByContact(pool, world.gravity, dt);
534
721
  prepare(pool, world.gravity, dt);
535
722
  for (let iter = 0; iter < world.iterations; iter++) solve(pool);
536
723
  for (let i = 0; i < bodies.length; i++) {
537
724
  const body = bodies[i];
538
- if (isStatic(body)) continue;
725
+ if (isStatic(body) || asleep[i] === 1) continue;
539
726
  body.position.x += body.velocity.x * dt;
540
727
  body.position.y += body.velocity.y * dt;
541
728
  body.angle += body.angularVelocity * dt;
542
729
  }
730
+ if (sleep !== null) updateSleep(pool, sleep, bodies);
731
+ }
732
+ /** `true` when `body` is asleep in `world`. `false` when the world has
733
+ * sleeping off or never went through `createWorld`, when `body` is not in
734
+ * `world.bodies`, or when its slot has not been bound to it by a `step` yet.
735
+ * O(n) in `world.bodies.length` (an `indexOf`); poll it per body, not per
736
+ * contact. */
737
+ function isSleeping(world, body) {
738
+ const pool = pools.get(world);
739
+ if (!pool || pool.sleep === null) return false;
740
+ const i = world.bodies.indexOf(body);
741
+ if (i < 0 || pool.sleepOwner[i] !== body) return false;
742
+ return pool.asleep[i] === 1;
743
+ }
744
+ /** `true` when every dynamic body in `world.bodies` is asleep — vacuously
745
+ * true with no dynamic bodies, `false` with sleeping off and at least one.
746
+ * The bake stop rule for `stepUntil`: `done: () => isAtRest(world)`. */
747
+ function isAtRest(world) {
748
+ const pool = pools.get(world);
749
+ const { bodies } = world;
750
+ for (let i = 0; i < bodies.length; i++) {
751
+ const body = bodies[i];
752
+ if (isStatic(body)) continue;
753
+ if (!pool || pool.sleep === null || pool.sleepOwner[i] !== body || pool.asleep[i] !== 1) return false;
754
+ }
755
+ return true;
756
+ }
757
+ /** Wakes `body` if it is asleep in `world` (flag cleared, counter 0, its
758
+ * velocities stay 0); the next `step` integrates it again. No-op otherwise,
759
+ * including with sleeping off or a body not in `world.bodies`. Use it for a
760
+ * sleeper whose support was removed from `world.bodies` or moved — nothing
761
+ * else wakes it. */
762
+ function wakeBody(world, body) {
763
+ const pool = pools.get(world);
764
+ if (!pool || pool.sleep === null) return;
765
+ const i = world.bodies.indexOf(body);
766
+ if (i < 0 || pool.sleepOwner[i] !== body || pool.asleep[i] !== 1) return;
767
+ pool.asleep[i] = 0;
768
+ pool.sleepCounter[i] = 0;
769
+ }
770
+ /** Repeats `step(world, dt)` until, checked in this order after each step,
771
+ * `done()` returns true (`complete: true`), `steps >= maxSteps`, or
772
+ * `now() - start >= budgetMs` (both `complete: false`). Always takes at least
773
+ * one step, so a zero budget still makes progress. Writes `out` and returns it;
774
+ * the zero-alloc per-frame form of `stepUntil`. Throws `RangeError` for a
775
+ * non-finite or non-positive `dt`, a negative or non-finite `budgetMs`, or a
776
+ * `maxSteps` that is not an integer >= 1. */
777
+ function stepUntilInto(world, dt, opts, out) {
778
+ if (!Number.isFinite(dt) || dt <= 0) throw new RangeError(`stepUntil: dt must be a finite number > 0, got ${dt}`);
779
+ const { budgetMs, maxSteps, done } = opts;
780
+ if (!Number.isFinite(budgetMs) || budgetMs < 0) throw new RangeError(`stepUntil: budgetMs must be a finite number >= 0, got ${budgetMs}`);
781
+ if (maxSteps !== void 0 && (!Number.isInteger(maxSteps) || maxSteps < 1)) throw new RangeError(`stepUntil: maxSteps must be an integer >= 1, got ${maxSteps}`);
782
+ const now = opts.now ?? nowMs;
783
+ const start = now();
784
+ let steps = 0;
785
+ let complete = false;
786
+ for (;;) {
787
+ step(world, dt);
788
+ steps++;
789
+ if (done?.()) {
790
+ complete = true;
791
+ break;
792
+ }
793
+ if (maxSteps !== void 0 && steps >= maxSteps) break;
794
+ if (now() - start >= budgetMs) break;
795
+ }
796
+ out.steps = steps;
797
+ out.complete = complete;
798
+ return out;
799
+ }
800
+ /** Allocating form of `stepUntilInto`; see it for the stop rules. */
801
+ function stepUntil(world, dt, opts) {
802
+ return stepUntilInto(world, dt, opts, {
803
+ steps: 0,
804
+ complete: false
805
+ });
543
806
  }
544
- export { addBody, createBody, createWorld, isStatic, step };
807
+ export { addBody, createBody, createTimeline, createWorld, isAtRest, isSleeping, isStatic, step, stepUntil, stepUntilInto, wakeBody };
545
808
 
546
809
  //# sourceMappingURL=index.mjs.map