@daneren2005/shared-memory-physics 1.4.0 → 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.
package/README.md CHANGED
@@ -18,8 +18,9 @@ npm install @daneren2005/shared-memory-physics @daneren2005/shared-memory-ecs @d
18
18
 
19
19
  | Component | Config | Serialization | Block |
20
20
  | --------------- | -------------------------------------------- | -------------------------- | ---------------------------------------------- |
21
+ | `dynamics` | - | `accelerationX?`, `accelerationY?`, `mass?` | `Float32Array` - acceleration and inverse mass |
21
22
  | `transform` | `width`, `height`, `angle?` / `radius` | `x`, `y`, `angle` | `Float32Array` – where, how big, facing |
22
- | `velocity` | – | `velocityX?`, `velocityY?` | `Float32Array` – world units per second |
23
+ | `velocity` | – | `velocityX?`, `velocityY?`, `damping?` | `Float32Array` – world units per second |
23
24
  | `body` | `shape?`, `collideCategory?`, `collideMask?`, `sensor?`, `continuousCollisionDetection?` | – | `Uint32Array` – what shape, what collides with |
24
25
  | `interpolation` | `interpolate` | – | `Float32Array` – where to *draw* it |
25
26
 
@@ -27,6 +28,34 @@ Entity configs are flat and shared across every component, so velocity is keyed
27
28
  rather than `x` / `y` (which already belong to the transform). Either velocity axis on its own is enough to
28
29
  load one, and the missing one defaults to `0`.
29
30
 
31
+ `damping` is optional linear damping in fraction of speed per second: with it set, a mover started once bleeds
32
+ speed off every step and coasts to a stop instead of gliding forever. It is applied implicitly as
33
+ `velocity / (1 + damping * seconds)`, so it stays stable at any step size and never reverses direction. It
34
+ defaults to `0` (no damping) and must be finite and at least zero. An explicit velocity assignment (a queued
35
+ `queueVelocity`, or writing `velocityX` directly) is authoritative for that step and bypasses damping until the
36
+ entity coasts again.
37
+
38
+ ```ts
39
+ const shot = world.loadEntity({ x: 0, y: 0, radius: 3, velocityX: 400, damping: 2 });
40
+ ```
41
+
42
+ `accelerationX` / `accelerationY` are persistent world units per second squared. Either one (or `mass`) loads a
43
+ `dynamics` component; missing acceleration axes default to `0` and mass defaults to `1`. Physics uses
44
+ semi-implicit Euler: it changes velocity by `acceleration * seconds` first, then moves by that new velocity in
45
+ the same fixed step. That puts gravity on the physics backend instead of tying it to render frames:
46
+
47
+ ```ts
48
+ world.loadEntity({
49
+ x: 0, y: 0, width: 20, height: 40,
50
+ velocityX: 0, velocityY: 0,
51
+ accelerationY: 1600,
52
+ });
53
+ ```
54
+
55
+ Mass is stored as inverse mass and round-trips through `save`; persistent acceleration does not depend on it.
56
+ Mass must be finite and greater than zero, and acceleration must be finite. An entity without `dynamics` has
57
+ effective mass `1` when a force or impulse is queued for it.
58
+
30
59
  The transform is split in two. **Config** is what your entity template says - `width` and `height` are
31
60
  required, `angle` defaults to `0` - and a size is what loads the component at all: a config with neither a
32
61
  width nor a height does not get a transform. **Serialization** is the live state that changes as the world
@@ -52,14 +81,15 @@ its shape and categories back from the template rather than from the save. See
52
81
 
53
82
  ## Body shapes
54
83
 
55
- An entity is one of three shapes, and all three live in the same transform - so which one it is changes what
56
- that width and height *mean* rather than where they are kept:
84
+ An entity is one of four shapes. The transform still owns its position, size and angle; a polygon additionally
85
+ has a `polygon` component holding its outline:
57
86
 
58
87
  | `shape` | Config | What it is |
59
88
  | ----------------- | ------------------------------------- | ------------------------------------------------------------- |
60
89
  | `SHAPE_RECTANGLE` | `width`, `height` | a `width` x `height` box turned to `angle` – the default |
61
90
  | `SHAPE_CIRCLE` | `radius` | a circle of diameter `width`, which `angle` does not affect |
62
91
  | `SHAPE_CAPSULE` | `width`, `height`, `shape` | `width` from end to end, `height` thick, lying along `angle` |
92
+ | `SHAPE_POLYGON` | `vertices` | a convex 3-16 vertex outline, scaled and turned by its transform |
63
93
 
64
94
  ```ts
65
95
  import { SHAPE_CAPSULE } from '@daneren2005/shared-memory-physics';
@@ -67,21 +97,30 @@ import { SHAPE_CAPSULE } from '@daneren2005/shared-memory-physics';
67
97
  world.loadEntity({ x: 0, y: 0, width: 10, height: 4 }); // a rectangle
68
98
  world.loadEntity({ x: 0, y: 0, radius: 5 }); // a circle
69
99
  world.loadEntity({ x: 0, y: 0, width: 40, height: 10, shape: SHAPE_CAPSULE }); // a capsule
100
+ world.loadEntity({ x: 0, y: 0, vertices: [[-8, -6], [10, 0], [-8, 6]] }); // a triangle
70
101
  ```
71
102
 
103
+ Polygon vertices are local to the entity. They must describe a convex boundary in clockwise or counter-clockwise
104
+ order; concave, collinear, non-finite, and out-of-range outlines throw during loading. Their bounding box becomes
105
+ the transform's initial `width` and `height`, and the stored vertices are normalized around that box's centre.
106
+ Changing the transform size therefore scales the polygon, while `angle` rotates it around that centre. Give
107
+ `vertices` by themselves rather than alongside `radius` or `width`/`height`.
108
+
72
109
  `radius` is the same size said differently: it loads as a `width` and a `height` of the diameter, so there is
73
110
  still only one size in the block however the config spelled it. Giving both a `radius` and a `width` throws
74
111
  rather than one quietly winning. A config that says nothing about its `shape` is read off the size it gave - a
75
- `radius` describes a circle, anything else a rectangle - so **only capsules have to name their shape**, and an
112
+ `vertices` describes a polygon, `radius` describes a circle, and anything else a rectangle - so **only capsules
113
+ have to name their shape**, and an
76
114
  unrecognised one throws rather than quietly becoming a rectangle.
77
115
 
78
116
  A capsule's `width` is its **whole** length, caps included, and it lies along the way the entity faces: a 40x10
79
117
  capsule is a 30-long core with a 5 radius cap at each end, and it is the same 10 thick all the way down. One
80
118
  told it is no longer than it is wide collapses to a circle rather than turning itself inside out.
81
119
 
82
- Because a circle is a capsule whose core has no length, all three shapes are *an oriented core grown by a
120
+ Because a circle is a capsule whose core has no length, the three primitive shapes are *an oriented core grown by a
83
121
  radius* - a box with no radius, a point, or a segment - which leaves only three overlap tests rather than one
84
- per pairing: box against box (the separating axis test), core against core, and core against box. Touching
122
+ per primitive pairing: box against box (the separating axis test), core against core, and core against box.
123
+ Those fast paths remain unchanged; only a pair involving a polygon enters the polygon separating-axis test. Touching
85
124
  exactly still counts as **not** overlapping, whichever pair it is, and a shape with no area never overlaps
86
125
  anything. Which of the two sizes decides that depends on the shape: a circle with no height is a perfectly
87
126
  good circle, while a rectangle with no height is nothing at all.
@@ -92,7 +131,9 @@ such direction. It splits the same three ways off the same geometry, so the dire
92
131
  actually hit at the angle the pair is turned to - not the nearest world axis. This is what the native bounce
93
132
  reflects around; a game reaching for its own response can use it the same way.
94
133
 
95
- `shapesOverlap` is exported, along with `shapeHalfWidth` / `shapeHalfHeight` (the axis-aligned box a shape is
134
+ `shapesOverlap` and `contactNormal` are the allocation-free primitive-shape functions. Their polygon counterparts
135
+ are `polygonShapesOverlap` and `polygonContactNormal`, which additionally take the polygon component blocks.
136
+ `shapeHalfWidth` / `shapeHalfHeight` (the axis-aligned box a shape is
96
137
  indexed under), `shapeRadius`, `shapeIsEmpty`, `capsuleHalfLength`, and the distance primitives underneath -
97
138
  `segmentSegmentDistanceSquared`, `segmentBoxDistanceSquared` and `pointSegmentDistanceSquared`, each of which
98
139
  takes an optional `out` vector and fills it with the gap it measured. `orientedBoxesOverlap`, `boundsHalfWidth`
@@ -119,8 +160,9 @@ console.log(entity.components.transform!.x); // 0
119
160
 
120
161
  ## PhysicsSystem
121
162
 
122
- `PhysicsSystem` moves every entity that has both a `transform` and a `velocity`, integrating velocity into
123
- position once per run - and, once you give it an update built by [`createPhysicsUpdate`](#collisions), tells
163
+ `PhysicsSystem` moves every entity that has both a `transform` and a `velocity`, integrating optional acceleration
164
+ and queued commands into velocity and then velocity into position once per run - and, once you give it an update built by
165
+ [`createPhysicsUpdate`](#collisions), tells
124
166
  you which entities have run into each other. It is generic over your full component map, so your world passes
125
167
  straight in:
126
168
 
@@ -133,8 +175,49 @@ await world.init();
133
175
  world.update(1000); // one second: the entity above is now at x = 10
134
176
  ```
135
177
 
136
- `elapsedTime` is in milliseconds (what `BaseWorld#update` is driven with) and velocity is in world units per
137
- **second**, so a run covering 16ms moves an entity 16/1000ths of its velocity.
178
+ `elapsedTime` is in milliseconds (what `BaseWorld#update` is driven with), velocity is in world units per
179
+ **second**, and acceleration is in world units per **second squared**. A run covering 16ms first adds 16/1000ths
180
+ of the acceleration to velocity, then moves 16/1000ths of that updated velocity.
181
+
182
+ ### Dynamics commands
183
+
184
+ Queue forces, impulses, or velocity assignments on the `PhysicsSystem` that owns the mover. A force acts for the
185
+ duration of the next physics run; an impulse changes velocity once without being scaled by that run's duration.
186
+ Both are divided by the entity's mass. A velocity assignment replaces only the axes it names, after acceleration,
187
+ force, and impulse integration but before movement, so it is exact and independent of mass:
188
+
189
+ ```ts
190
+ const physics = world.addSystem(new PhysicsSystem(world));
191
+
192
+ physics.queueForce(entity, 0, 20); // next run: acceleration += force / mass
193
+ physics.queueImpulse(entity, 12, -40); // next run: velocity += impulse / mass
194
+ physics.queueVelocity(entity, { velocityY: -980 }); // next run: set y without changing x
195
+ ```
196
+
197
+ The methods also accept an entity id. Forces and impulses queued for the same entity before dispatch are summed;
198
+ the last velocity assignment for each axis wins. Zero force/impulse vectors and empty velocity assignments are
199
+ ignored. Non-finite values throw immediately.
200
+
201
+ Commands have a strict run boundary. A command queued before dispatch belongs to that run and is consumed once.
202
+ A command queued while a worker run is in flight belongs to the following run. A command is still consumed if
203
+ its entity is absent from the mover query because it was removed, filtered, scoped out, or in a skipped group;
204
+ queue it again when the entity becomes eligible. In a sharded simulation, queue on the system whose mover filter
205
+ owns the entity.
206
+
207
+ The same methods are available on `world` inside a `createPhysicsUpdate` collision callback, with entity ids:
208
+
209
+ ```ts
210
+ onCollision(world, self, other, queries, callbacks, contact) {
211
+ world.queueVelocity(self.entityId, {
212
+ velocityX: contact.normalX * 480,
213
+ velocityY: -720,
214
+ });
215
+ }
216
+ ```
217
+
218
+ Callback commands always target the next run and survive between runs in either backend. They combine with
219
+ commands queued through `PhysicsSystem`: forces and impulses add together, while a newer main-thread velocity
220
+ assignment replaces a callback assignment on each axis it names.
138
221
 
139
222
  ### The step
140
223
 
@@ -167,8 +250,8 @@ class GamePhysicsSystem extends PhysicsSystem<Components, GameComponents, GameWo
167
250
  }
168
251
  ```
169
252
 
170
- `GameWorld` extends `PhysicsWorld` (which is `ComponentSystemWorld` plus that `tick`) rather than
171
- `ComponentSystemWorld` directly.
253
+ `GameWorld` extends `PhysicsWorld` (which is `EntityWorkerSystemWorld` plus that `tick`) rather than
254
+ `EntityWorkerSystemWorld` directly.
172
255
 
173
256
  A run's moves are reported back to the main thread as a single `position-updated` event **on the system**,
174
257
  carrying the ids of everything that moved, so anything you keep keyed off position can follow the moves
@@ -218,10 +301,10 @@ lives in your game and is handed to the system through `getWorker`:
218
301
 
219
302
  ```ts
220
303
  // physics.worker.ts
221
- import { createComponentWorker } from '@daneren2005/shared-memory-ecs';
304
+ import { createEntitySystemWorker } from '@daneren2005/shared-memory-ecs';
222
305
  import { physicsUpdate } from '@daneren2005/shared-memory-physics';
223
306
 
224
- createComponentWorker(self, physicsUpdate);
307
+ createEntitySystemWorker(self, physicsUpdate);
225
308
  ```
226
309
 
227
310
  ```ts
@@ -362,6 +445,39 @@ A no-op for an entity with no velocity or no interpolation, or while the system
362
445
  by hand rather than through `PhysicsSystem`? Call the underlying `startSpawnInterpolation(entity, { stepMs,
363
446
  stepFraction, tick })` directly.
364
447
 
448
+ ### A moving death: `dieAtImpact`
449
+
450
+ The same lag runs the other way at the end of a life. A fast entity moves a whole step at a time and, with
451
+ `continuousCollisionDetection` on, is killed the run its *swept path* first crosses what it hits - which can be a
452
+ near-full stride before its step actually lands there. Remove it on the spot and the render, itself a step behind,
453
+ draws it blinking out well short of the target. A bullet dies in mid-air.
454
+
455
+ `dieAtImpact` fixes it as the mirror of the spawn seeding: instead of `callbacks.entityDied`, a collision callback
456
+ calls it to **end the dying entity's current segment on the exact point it struck** (`CollisionBroadphase.contactPoint`,
457
+ the same entry fraction the sweep already refines) and **defer the kill by one run** - the one extra step the render
458
+ needs to play that last stride onto the impact before the entity is gone:
459
+
460
+ ```ts
461
+ onCollision(world, self, other, queries, callbacks, contact) {
462
+ // `self` (a fast sensor bullet) just swept through `other`. Draw it reaching `other`, then die - rather than
463
+ // vanishing a stride short.
464
+ world.dieAtImpact(self, other);
465
+
466
+ // Gameplay still happens now: the hit lands this run, only the visual removal waits.
467
+ callbacks.emitEntityEvent(other.entityId, 'hit');
468
+ }
469
+ ```
470
+
471
+ Either side of the pair can be the one to kill - `dieAtImpact(dying, other)` takes whichever carried the fatal
472
+ role, working out that entity's own approach from its velocity. The struck entity is marked with a runtime `dying`
473
+ bit (`BODY_DYING_FLAG`) that leaves it out of the broadphase at once, so nothing runs into it again while it plays
474
+ out, and it is removed at the top of its next update. The bit lives in shared memory, so any worker stepping the
475
+ entity honours it. Nothing clears it - a dying entity is gone a run later.
476
+
477
+ `dieAtImpact` is set on the world every run by `createPhysicsUpdate`; keep a `callbacks.entityDied` fallback if you
478
+ call `onCollision` by hand without a `preRun`. It moves position only - raise your own hit or score event on the
479
+ spot, as above.
480
+
365
481
  ### The cost
366
482
 
367
483
  32 bytes per interpolated entity, three extra writes per entity per physics step, and a per-frame pass of one
@@ -400,7 +516,6 @@ instead, which is also the only one that costs nothing per entity for the collid
400
516
  // game-physics-update.ts - imported by *both* the worker file and the system below
401
517
  import {
402
518
  createPhysicsUpdate,
403
- VELOCITY_X_INDEX,
404
519
  VELOCITY_Y_INDEX,
405
520
  type PhysicsUpdateComponents,
406
521
  } from '@daneren2005/shared-memory-physics';
@@ -412,10 +527,9 @@ export type GameUpdateComponents = PhysicsUpdateComponents & { health?: Float32A
412
527
  export const gamePhysicsUpdate = createPhysicsUpdate<Components, GameUpdateComponents>({
413
528
  // Extra components to send along to the callback, on top of the transform and velocity.
414
529
  optional: ['health'],
415
- onCollision(world, self, other, queries, callbacks) {
416
- // `self` just moved into `other`. Bounce it back off whatever it hit...
417
- self.components.velocity[VELOCITY_X_INDEX] *= -1;
418
- self.components.velocity[VELOCITY_Y_INDEX] *= -1;
530
+ onCollision(world, self, other, queries, callbacks, contact) {
531
+ // `self` just moved into `other`. Throw it away from the contact on the next run.
532
+ world.queueImpulse(self.entityId, contact.normalX * 40, contact.normalY * 40);
419
533
 
420
534
  // ...and take a bite out of the thing it ran into, if that is something with health.
421
535
  const health = other.components.health;
@@ -429,10 +543,10 @@ export const gamePhysicsUpdate = createPhysicsUpdate<Components, GameUpdateCompo
429
543
 
430
544
  ```ts
431
545
  // physics.worker.ts
432
- import { createComponentWorker } from '@daneren2005/shared-memory-ecs';
546
+ import { createEntitySystemWorker } from '@daneren2005/shared-memory-ecs';
433
547
  import { gamePhysicsUpdate } from './game-physics-update';
434
548
 
435
- createComponentWorker(self, gamePhysicsUpdate);
549
+ createEntitySystemWorker(self, gamePhysicsUpdate);
436
550
  ```
437
551
 
438
552
  ```ts
@@ -445,12 +559,28 @@ world.addSystem(new PhysicsSystem<Components, GameUpdateComponents>(world, {
445
559
  `createPhysicsUpdate` stamps what it needs onto the function it returns, so `optional` and the collidable
446
560
  query come across with `updateFunction` and never have to be repeated on the system.
447
561
 
562
+ The final `contact` argument carries the unit normal at first touch as `normalX` and `normalY`. It points from
563
+ `other` toward `self`; negate both values when applying it to `other`. This is the same shape-accurate direction
564
+ used by native bounce, including rotated rectangles, circles, capsules, and polygons. A continuous sensor that
565
+ crossed an entire target gets the normal where it entered, not from wherever its step ended after passing through.
566
+ Exactly coincident shapes have no unique direction and report `(0, 0)`.
567
+
568
+ ```ts
569
+ onCollision(world, self, other, queries, callbacks, contact) {
570
+ // Screen coordinates grow downwards, so a normal pointing up means self landed on other.
571
+ if (contact.normalY < -0.5) {
572
+ self.components.velocity[VELOCITY_Y_INDEX] = 0;
573
+ }
574
+ }
575
+ ```
576
+
448
577
  **Once per pair, per run.** Both sides of a contact find it - one by sweeping into it, the other by overlapping
449
578
  it on its own turn - and the callback fires once, for whichever got there first. `self` is that entity, with
450
579
  every block the system asked for; `other` is the one it hit, where only `transform` is guaranteed. **The other
451
580
  side gets no call of its own**, so a callback that only touches `self` leaves it untouched: apply damage, tag a
452
581
  kill, or raise an event for *both* entities in the one call. Which of the two is `self` follows update order
453
- and is not something to depend on. An entity with no velocity is never `self`, because the system never moves
582
+ and is not something to depend on. The normal follows that same ordering and always points away from `other`.
583
+ An entity with no velocity is never `self`, because the system never moves
454
584
  it, but it is still found as `other`.
455
585
 
456
586
  Writes go straight into shared memory, so a callback can bounce an entity by flipping its velocity, or push a
@@ -458,7 +588,14 @@ value another thread also touches with the atomics from `@daneren2005/shared-mem
458
588
  the two entities in front of it - to credit a third for a kill, say - a callback can walk
459
589
  `queries[COLLIDABLE_QUERY]`, the full collidable list. Anything that has to happen on the main thread goes
460
590
  through `callbacks`: `entityDied`, `entityComponentChanged`, `createEntity`, and `emitEntityEvent` for an
461
- event of your own - the same one the move above reports itself through.
591
+ event of your own - the same one the move above reports itself through. To kill a *fast* mover on impact and
592
+ still have it drawn reaching what it hit, call `world.dieAtImpact(dying, other)` in place of `entityDied` - see
593
+ [`dieAtImpact`](#a-moving-death-dieatimpact).
594
+
595
+ For a dynamics response that does not touch raw blocks, the callback world has `queueForce`, `queueImpulse`, and
596
+ `queueVelocity`. They take an entity id and retain the command inside whichever backend is running, including a
597
+ worker. A callback happens after this run's move, so its command is integrated at the start of the next run;
598
+ deferring it also makes the outcome independent of which side of the pair updated first.
462
599
 
463
600
  **What collides.** Everything with a transform and a `body`, not only the entities the system moves, so a
464
601
  ship can run into a station that has no velocity of its own. Shapes that only just touch do not count as
@@ -493,6 +630,22 @@ Two details worth knowing:
493
630
  forwards each run, so it reports no position change while it pushes - but it does keep reporting the
494
631
  collision for as long as it keeps pushing.
495
632
 
633
+ By default, the sweep changes position but leaves velocity alone for an entity without `bounciness`, preserving
634
+ the original behavior for games that implement their own response. Set `stopVelocityOnContact: true` on
635
+ `createPhysicsUpdate` to opt into a non-bouncing solid response: physics removes only the velocity component
636
+ pointing into the contact normal, so bodies stop naturally against floors, ceilings, walls, and slopes while
637
+ keeping their motion along the surface.
638
+
639
+ ```ts
640
+ export const gamePhysicsUpdate = createPhysicsUpdate<Components>({
641
+ stopVelocityOnContact: true,
642
+ });
643
+ ```
644
+
645
+ This applies to both sides of each solid contact when they have velocity. Sensors still pass through, and an
646
+ entity with a `bounciness` component keeps its configured bounce instead; `bounciness: 0` remains equivalent to
647
+ removing the inward velocity component.
648
+
496
649
  **What it costs.** A move is checked where it *ends*, so a clear one - which is nearly every move - is a single
497
650
  shape test against each thing near where it lands, and nothing more. Only a move that is genuinely stopping
498
651
  somewhere pays for working out where: contact is then halved in on rather than solved, since three shapes at any
@@ -601,6 +754,11 @@ it rather than adding a second system - the block offsets it reads are exported
601
754
  import { physicsUpdate, TRANSFORM_X_INDEX, VELOCITY_X_INDEX } from '@daneren2005/shared-memory-physics';
602
755
  ```
603
756
 
757
+ Both `physicsUpdate` and updates built by `createPhysicsUpdate` integrate `dynamics` automatically. A custom
758
+ update that replaces library movement owns that step too: request `dynamics` through the system's `optional`
759
+ components and call `integrateDynamics(world, entityId, components.velocity, components.dynamics)` before
760
+ turning velocity into movement.
761
+
604
762
  `CollisionBroadphase` is available the same way, for a system that wants the collision detection without the
605
763
  movement. Build one per run from a list of `{ entityId, components }` and how many **seconds** the run covers
606
764
  (that is what sizes the room left for movement), then ask it what any given entity is overlapping:
@@ -608,10 +766,17 @@ movement. Build one per run from a list of `{ entityId, components }` and how ma
608
766
  ```ts
609
767
  import { CollisionBroadphase, COLLIDABLE_QUERY } from '@daneren2005/shared-memory-physics';
610
768
 
611
- const broadphase = new CollisionBroadphase(queries[COLLIDABLE_QUERY], world.elapsedTime / 1000);
769
+ const broadphase = new CollisionBroadphase(
770
+ queries[COLLIDABLE_QUERY],
771
+ world.elapsedTime / 1000,
772
+ world.dynamicsCommands,
773
+ );
612
774
  broadphase.forEachOverlapping({ entityId, components }, other => { /* ... */ });
613
775
  ```
614
776
 
777
+ The third argument is optional, but a custom update that applies queued commands in the same run must pass it so
778
+ the index leaves enough travel room for the resulting velocity.
779
+
615
780
  `sweep` is the other half, for a system that moves things itself and wants the same
616
781
  [stopping at the edge](#stopping-at-the-edge). Hand it the move you were about to make and it says how much of
617
782
  it is available, along with whatever the rest of it would have run into:
@@ -632,14 +797,44 @@ body just swept, while the two-argument form (and every non-continuous body) tes
632
797
 
633
798
  ## Spatial queries
634
799
 
635
- `SpatialIndex` answers the other kind of question about where things are - who is in this area, and what is
636
- the closest thing to this point - for the systems that are not about collisions at all: targeting, aggro
637
- range, spawning somewhere clear, area effects. It is the same R-tree the broadphase is built on, without the
638
- collide categories or the shape tests.
800
+ `PhysicalWorld` keeps every transformed entity in a live, shared `SharedSpatialMap`. The physics worker updates
801
+ the entry immediately after it writes a final position, while entity add/remove and transform/body component
802
+ changes are tracked on the main thread. That makes the same index available for occasional main-thread checks
803
+ such as picking an entity under the mouse, and for worker systems that would otherwise rebuild an index for only
804
+ a few queries:
805
+
806
+ ```ts
807
+ import { PhysicalWorld, physicsRegistry } from '@daneren2005/shared-memory-physics';
808
+
809
+ const world = new PhysicalWorld(physicsRegistry, {
810
+ spatial: { gridSize: 50, maxEntities: 100_000 },
811
+ });
812
+
813
+ const clicked = world.findNearestSpatial(mouseX, mouseY, 0);
814
+ const nearby = world.searchSpatialAround(x, y, 100, 100);
815
+ ```
639
816
 
640
- Build one from anything shaped like a query result and it reads the transform (and the body, to know which
641
- outline that transform describes) off each entity. Only the transform is needed, so it indexes anything with
642
- a place in the world rather than only what collides:
817
+ `searchSpatial`, `searchSpatialAround`, `findNearestSpatial`, and `findNearbySpatial` return live entities and
818
+ accept entity filters. Shape-aware axis-aligned bounds are stored: rotated rectangles and capsules occupy the
819
+ box around their actual outline. A direct transform/body edit outside the physics update must be followed by
820
+ `world.updateSpatialEntity(entity)`. A custom physics wrapper that changes a transform after calling the library
821
+ update can call `updateSpatialMap(workerWorld, entityId, components)` instead.
822
+
823
+ `PhysicsSystem` requires a `PhysicalWorld` (or another `BaseWorld` that implements `PhysicalWorldSource`) and
824
+ automatically shares its map with the physics worker. Another worker system can receive the same map by calling
825
+ `addPhysicalWorldData(sourceWorld, runWorld)` from its system's `addDataToWorld`, then `getSpatialMap(runWorld)` in
826
+ its update. The worker world must come from `@daneren2005/shared-memory-ecs` 1.5.1 or newer so it carries the
827
+ shared heap used to reconstruct the map handle.
828
+
829
+ The live map is intentionally not the collision or bulk-query hot path. `CollisionBroadphase` and
830
+ `SpatialIndex` still use Flatbush snapshots: packing one immutable R-tree per run is faster when that run will
831
+ make a large number of queries. Use the live map for continuous availability and sparse queries; use
832
+ `SpatialIndex` for query-heavy systems such as targeting every entity every tick.
833
+
834
+ `SpatialIndex` is the same R-tree the collision broadphase is built on, without collide categories or shape
835
+ tests. Build one from anything shaped like a query result and it reads the transform (and the body, to know which
836
+ outline that transform describes) off each entity. Only the transform is needed, so it indexes anything with a
837
+ place in the world rather than only what collides:
643
838
 
644
839
  ```ts
645
840
  import { SpatialIndex } from '@daneren2005/shared-memory-physics';
@@ -1,4 +1,5 @@
1
1
  import type { ComponentDefinition } from '@daneren2005/shared-memory-ecs';
2
+ import type { PolygonVertex } from './polygon-component';
2
3
  export interface BodyComponent {
3
4
  index: number;
4
5
  shape: number;
@@ -10,6 +11,7 @@ export interface BodyComponent {
10
11
  export declare const SHAPE_RECTANGLE = 1;
11
12
  export declare const SHAPE_CIRCLE = 2;
12
13
  export declare const SHAPE_CAPSULE = 3;
14
+ export declare const SHAPE_POLYGON = 4;
13
15
  export declare const DEFAULT_COLLIDE_CATEGORY = 1;
14
16
  export declare const DEFAULT_COLLIDE_MASK = 4294967295;
15
17
  export declare function canCollide(categoryA: number, maskA: number, categoryB: number, maskB: number): boolean;
@@ -18,6 +20,7 @@ export interface BodyConfig {
18
20
  height?: number;
19
21
  shape?: number;
20
22
  radius?: number;
23
+ vertices?: ReadonlyArray<PolygonVertex>;
21
24
  collideCategory?: number;
22
25
  collideMask?: number;
23
26
  sensor?: boolean;
@@ -27,11 +30,14 @@ export declare const BODY_FLAGS_INDEX = 0;
27
30
  export declare const BODY_CATEGORY_INDEX = 1;
28
31
  export declare const BODY_MASK_INDEX = 2;
29
32
  export declare const BODY_SIZE = 3;
30
- export declare const BODY_SHAPE_MASK = 3;
31
- export declare const BODY_SENSOR_FLAG = 4;
32
- export declare const BODY_CCD_FLAG = 8;
33
+ export declare const BODY_SHAPE_MASK = 7;
34
+ export declare const BODY_SENSOR_FLAG = 8;
35
+ export declare const BODY_CCD_FLAG = 16;
36
+ export declare const BODY_DYING_FLAG = 32;
33
37
  export declare function bodyShape(body: Uint32Array): number;
34
38
  export declare function isSensor(body: Uint32Array): boolean;
35
39
  export declare function isContinuous(body: Uint32Array): boolean;
40
+ export declare function isDying(body: Uint32Array): boolean;
41
+ export declare function markDying(body: Uint32Array): void;
36
42
  export declare const bodyDefinition: ComponentDefinition<BodyComponent, Uint32Array, BodyConfig>;
37
43
  export default bodyDefinition;
@@ -0,0 +1,19 @@
1
+ import type { ComponentDefinition } from '@daneren2005/shared-memory-ecs';
2
+ export interface DynamicsComponent {
3
+ index: number;
4
+ accelerationX: number;
5
+ accelerationY: number;
6
+ mass: number;
7
+ }
8
+ export interface DynamicsConfig {
9
+ accelerationX?: number;
10
+ accelerationY?: number;
11
+ mass?: number;
12
+ }
13
+ export type DynamicsSerialization = DynamicsConfig;
14
+ export declare const DYNAMICS_ACCELERATION_X_INDEX = 0;
15
+ export declare const DYNAMICS_ACCELERATION_Y_INDEX = 1;
16
+ export declare const DYNAMICS_INVERSE_MASS_INDEX = 2;
17
+ export declare const DYNAMICS_SIZE = 3;
18
+ export declare const dynamicsDefinition: ComponentDefinition<DynamicsComponent, Float32Array, DynamicsConfig, DynamicsSerialization>;
19
+ export default dynamicsDefinition;
@@ -0,0 +1,22 @@
1
+ import type { ComponentDefinition } from '@daneren2005/shared-memory-ecs';
2
+ export type PolygonVertex = readonly [x: number, y: number];
3
+ export interface PolygonConfig {
4
+ vertices?: ReadonlyArray<PolygonVertex>;
5
+ }
6
+ export interface PolygonComponent {
7
+ index: number;
8
+ readonly block: Float32Array;
9
+ readonly vertexCount: number;
10
+ }
11
+ export declare const MAX_POLYGON_VERTICES = 16;
12
+ export declare const POLYGON_VERTEX_COUNT_INDEX = 0;
13
+ export declare const POLYGON_VERTICES_INDEX = 1;
14
+ export declare const POLYGON_SIZE: number;
15
+ export interface PreparedPolygon {
16
+ width: number;
17
+ height: number;
18
+ vertices: Array<number>;
19
+ }
20
+ export declare const polygonDefinition: ComponentDefinition<PolygonComponent, Float32Array, PolygonConfig>;
21
+ export declare function preparePolygon(vertices: ReadonlyArray<PolygonVertex> | undefined): PreparedPolygon;
22
+ export default polygonDefinition;
@@ -1,20 +1,26 @@
1
1
  import { type BodyComponent } from './body-component';
2
2
  import { type BouncinessComponent } from './bounciness-component';
3
+ import { type DynamicsComponent } from './dynamics-component';
3
4
  import { type InterpolationComponent } from './interpolation-component';
5
+ import { type PolygonComponent } from './polygon-component';
4
6
  import { type TransformComponent } from './transform-component';
5
7
  import { type VelocityComponent } from './velocity-component';
6
8
  export declare const physicsRegistry: {
7
9
  transform: import("@daneren2005/shared-memory-ecs").ComponentDefinition<TransformComponent, Float32Array<ArrayBufferLike>, import("./transform-component").TransformConfig, import("./transform-component").TransformSerialization>;
8
10
  velocity: import("@daneren2005/shared-memory-ecs").ComponentDefinition<VelocityComponent, Float32Array<ArrayBufferLike>, import("./velocity-component").VelocityConfig, import("./velocity-component").VelocityConfig>;
9
11
  body: import("@daneren2005/shared-memory-ecs").ComponentDefinition<BodyComponent, Uint32Array<ArrayBufferLike>, import("./body-component").BodyConfig, object>;
12
+ polygon: import("@daneren2005/shared-memory-ecs").ComponentDefinition<PolygonComponent, Float32Array<ArrayBufferLike>, import("./polygon-component").PolygonConfig, object>;
10
13
  bounciness: import("@daneren2005/shared-memory-ecs").ComponentDefinition<BouncinessComponent, Float32Array<ArrayBufferLike>, import("./bounciness-component").BouncinessConfig, object>;
14
+ dynamics: import("@daneren2005/shared-memory-ecs").ComponentDefinition<DynamicsComponent, Float32Array<ArrayBufferLike>, import("./dynamics-component").DynamicsConfig, import("./dynamics-component").DynamicsConfig>;
11
15
  interpolation: import("@daneren2005/shared-memory-ecs").ComponentDefinition<InterpolationComponent, Float32Array<ArrayBufferLike>, import("./interpolation-component").InterpolationConfig, object>;
12
16
  };
13
17
  export type PhysicsComponents = {
14
18
  transform: TransformComponent;
15
19
  velocity: VelocityComponent;
16
20
  body: BodyComponent;
21
+ polygon: PolygonComponent;
17
22
  bounciness: BouncinessComponent;
23
+ dynamics: DynamicsComponent;
18
24
  interpolation: InterpolationComponent;
19
25
  };
20
26
  export type InterpolationComponents = {
@@ -25,7 +31,9 @@ export type PhysicsUpdateComponents = {
25
31
  transform: Float32Array;
26
32
  velocity: Float32Array;
27
33
  body?: Uint32Array;
34
+ polygon?: Float32Array;
28
35
  bounciness?: Float32Array;
36
+ dynamics?: Float32Array;
29
37
  interpolation?: Float32Array;
30
38
  entity?: Uint32Array;
31
39
  };
@@ -1,4 +1,5 @@
1
1
  import type { ComponentDefinition } from '@daneren2005/shared-memory-ecs';
2
+ import { type PolygonVertex } from './polygon-component';
2
3
  export interface TransformComponent {
3
4
  index: number;
4
5
  x: number;
@@ -12,6 +13,7 @@ export interface TransformConfig {
12
13
  height: number;
13
14
  angle?: number;
14
15
  radius?: number;
16
+ vertices?: ReadonlyArray<PolygonVertex>;
15
17
  }
16
18
  export interface TransformSerialization {
17
19
  x: number;
@@ -3,14 +3,17 @@ export interface VelocityComponent {
3
3
  index: number;
4
4
  velocityX: number;
5
5
  velocityY: number;
6
+ damping: number;
6
7
  }
7
8
  export interface VelocityConfig {
8
9
  velocityX?: number;
9
10
  velocityY?: number;
11
+ damping?: number;
10
12
  }
11
13
  export type VelocitySerialization = VelocityConfig;
12
14
  export declare const VELOCITY_X_INDEX = 0;
13
15
  export declare const VELOCITY_Y_INDEX = 1;
14
- export declare const VELOCITY_SIZE = 2;
16
+ export declare const VELOCITY_DAMPING_INDEX = 2;
17
+ export declare const VELOCITY_SIZE = 3;
15
18
  export declare const velocityDefinition: ComponentDefinition<VelocityComponent, Float32Array, VelocityConfig, VelocitySerialization>;
16
19
  export default velocityDefinition;
package/dist/index.d.ts CHANGED
@@ -2,11 +2,17 @@ export { default as transformDefinition } from './components/transform-component
2
2
  export { TRANSFORM_X_INDEX, TRANSFORM_Y_INDEX, TRANSFORM_WIDTH_INDEX, TRANSFORM_HEIGHT_INDEX, TRANSFORM_ANGLE_INDEX, TRANSFORM_SIZE, } from './components/transform-component';
3
3
  export type { TransformComponent, TransformConfig, TransformSerialization, } from './components/transform-component';
4
4
  export { default as velocityDefinition } from './components/velocity-component';
5
- export { VELOCITY_X_INDEX, VELOCITY_Y_INDEX, VELOCITY_SIZE, } from './components/velocity-component';
5
+ export { VELOCITY_X_INDEX, VELOCITY_Y_INDEX, VELOCITY_DAMPING_INDEX, VELOCITY_SIZE, } from './components/velocity-component';
6
6
  export type { VelocityComponent, VelocityConfig, VelocitySerialization, } from './components/velocity-component';
7
- export { default as bodyDefinition, canCollide, isSensor, isContinuous, bodyShape } from './components/body-component';
8
- export { SHAPE_RECTANGLE, SHAPE_CIRCLE, SHAPE_CAPSULE, DEFAULT_COLLIDE_CATEGORY, DEFAULT_COLLIDE_MASK, BODY_FLAGS_INDEX, BODY_SHAPE_MASK, BODY_SENSOR_FLAG, BODY_CCD_FLAG, BODY_CATEGORY_INDEX, BODY_MASK_INDEX, BODY_SIZE, } from './components/body-component';
7
+ export { default as dynamicsDefinition } from './components/dynamics-component';
8
+ export { DYNAMICS_ACCELERATION_X_INDEX, DYNAMICS_ACCELERATION_Y_INDEX, DYNAMICS_INVERSE_MASS_INDEX, DYNAMICS_SIZE, } from './components/dynamics-component';
9
+ export type { DynamicsComponent, DynamicsConfig, DynamicsSerialization, } from './components/dynamics-component';
10
+ export { default as bodyDefinition, canCollide, isSensor, isContinuous, isDying, markDying, bodyShape } from './components/body-component';
11
+ export { SHAPE_RECTANGLE, SHAPE_CIRCLE, SHAPE_CAPSULE, SHAPE_POLYGON, DEFAULT_COLLIDE_CATEGORY, DEFAULT_COLLIDE_MASK, BODY_FLAGS_INDEX, BODY_SHAPE_MASK, BODY_SENSOR_FLAG, BODY_CCD_FLAG, BODY_DYING_FLAG, BODY_CATEGORY_INDEX, BODY_MASK_INDEX, BODY_SIZE, } from './components/body-component';
9
12
  export type { BodyComponent, BodyConfig, } from './components/body-component';
13
+ export { default as polygonDefinition, preparePolygon } from './components/polygon-component';
14
+ export { MAX_POLYGON_VERTICES, POLYGON_VERTEX_COUNT_INDEX, POLYGON_VERTICES_INDEX, POLYGON_SIZE, } from './components/polygon-component';
15
+ export type { PolygonComponent, PolygonConfig, PolygonVertex, PreparedPolygon } from './components/polygon-component';
10
16
  export { default as bouncinessDefinition } from './components/bounciness-component';
11
17
  export { BOUNCINESS_INDEX, BOUNCINESS_SIZE, } from './components/bounciness-component';
12
18
  export type { BouncinessComponent, BouncinessConfig, } from './components/bounciness-component';
@@ -15,16 +21,21 @@ export { INTERPOLATION_X_INDEX, INTERPOLATION_Y_INDEX, INTERPOLATION_PREV_X_INDE
15
21
  export type { InterpolationComponent, InterpolationConfig, SnappableEntity, SpawnableEntity, SpawnInterpolationOptions, } from './components/interpolation-component';
16
22
  export { physicsRegistry } from './components/registry';
17
23
  export type { PhysicsComponents, PhysicsUpdateComponents, InterpolationComponents, InterpolationUpdateComponents, } from './components/registry';
24
+ export { default as PhysicalWorld, addPhysicalWorldData, getSpatialMap } from './world';
25
+ export type { PhysicalSystemWorld, PhysicalWorldData, PhysicalWorldSource, PhysicalWorldEntity, PhysicalWorldFilter, PhysicalWorldOptions, } from './world';
18
26
  export { shapesOverlap, contactNormal, shapeHalfWidth, shapeHalfHeight, shapeIsEmpty, shapeRadius, capsuleHalfLength, boundsHalfWidth, boundsHalfHeight, orientedBoxesOverlap, segmentSegmentDistanceSquared, segmentBoxDistanceSquared, pointSegmentDistanceSquared, } from './math/shapes';
19
27
  export type { Vector } from './math/shapes';
28
+ export { polygonShapesOverlap, polygonContactNormal } from './math/polygons';
20
29
  export { default as CollisionBroadphase, COLLIDABLE_QUERY } from './systems/collision';
21
- export type { CollisionComponents, CollisionEntity, CollisionFunction, MoveResult, MovingEntity, SweepResult } from './systems/collision';
30
+ export type { CollisionComponents, CollisionContact, CollisionEntity, CollisionFunction, MoveResult, MovingEntity, SweepResult } from './systems/collision';
22
31
  export { default as SpatialIndex } from './systems/spatial-index';
23
32
  export type { SpatialComponents, SpatialEntity, SpatialFilter } from './systems/spatial-index';
24
- export { default as physicsUpdate, createPhysicsUpdate, POSITION_UPDATED_EVENT } from './systems/physics-update';
25
- export type { PhysicsGroupConfig, PhysicsUpdateFunction, PhysicsUpdateMetadata, PhysicsUpdateOptions, PhysicsWorld } from './systems/physics-update';
33
+ export { default as physicsUpdate, createPhysicsUpdate, POSITION_UPDATED_EVENT, updateSpatialMap } from './systems/physics-update';
34
+ export type { DeathInterpolationEntity, PhysicsCallbackWorld, PhysicsGroupConfig, PhysicsUpdateFunction, PhysicsUpdateMetadata, PhysicsUpdateOptions, PhysicsWorld } from './systems/physics-update';
26
35
  export { default as PhysicsSystem, DEFAULT_PHYSICS_STEP_MS } from './systems/physics-system';
27
- export type { PhysicsSystemConfig } from './systems/physics-system';
36
+ export type { PhysicsSystemConfig, VelocityAssignment } from './systems/physics-system';
37
+ export { default as integrateDynamics } from './systems/dynamics';
38
+ export type { DynamicsCommand, DynamicsCommandBuffer, DynamicsCommandQueue, DynamicsCommands, DynamicsWorld } from './systems/dynamics';
28
39
  export { default as interpolationUpdate } from './systems/interpolation-update';
29
40
  export { default as InterpolationSystem } from './systems/interpolation-system';
30
41
  export type { InterpolationSystemConfig } from './systems/interpolation-system';