@aethervtt/shard-physics 0.0.1 → 0.2.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.
@@ -0,0 +1,163 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { decodeTrack } from "../track/format.js";
3
+ import { trackCancelled } from "../track/record.js";
4
+ import { createTrackServer } from "./serve.js";
5
+ function inlineWorker(rules) {
6
+ const listeners = [];
7
+ const server = createTrackServer((reply) => {
8
+ queueMicrotask(() => {
9
+ for (const listener of listeners) listener({ data: reply });
10
+ });
11
+ }, rules);
12
+ return {
13
+ postMessage: (message) => server.handle(message),
14
+ addEventListener(type, listener) {
15
+ if (type === "message") listeners.push(listener);
16
+ },
17
+ terminate: () => server.dispose()
18
+ };
19
+ }
20
+ function crashed(reason) {
21
+ return new ShardError("physics/worker-crashed", `The track worker crashed: ${reason}`, {
22
+ hint: "The next recording starts a new worker."
23
+ });
24
+ }
25
+ function createTrackClient(options) {
26
+ let worker;
27
+ let warm;
28
+ let readyReject;
29
+ let disposed = false;
30
+ let spawns = 0;
31
+ let nextId = 1;
32
+ const pending = /* @__PURE__ */ new Map();
33
+ function settle(id) {
34
+ const p = pending.get(id);
35
+ if (!p) return void 0;
36
+ pending.delete(id);
37
+ p.cleanup();
38
+ return p;
39
+ }
40
+ function rejectAll(err) {
41
+ for (const id of [...pending.keys()]) settle(id).reject(err);
42
+ }
43
+ function crash(reason) {
44
+ const w = worker;
45
+ worker = void 0;
46
+ warm = void 0;
47
+ const err = crashed(reason);
48
+ readyReject?.(err);
49
+ readyReject = void 0;
50
+ rejectAll(err);
51
+ w?.terminate();
52
+ }
53
+ function onReply(reply) {
54
+ if (reply.type === "ready") {
55
+ readyReject = void 0;
56
+ return;
57
+ }
58
+ if (reply.id === null) {
59
+ crash(reply.type === "error" ? reply.message : "init failed");
60
+ return;
61
+ }
62
+ const p = settle(reply.id);
63
+ if (!p) return;
64
+ if (reply.type === "track") {
65
+ try {
66
+ p.resolve(decodeTrack(reply.buffer));
67
+ } catch (err) {
68
+ p.reject(err);
69
+ }
70
+ return;
71
+ }
72
+ p.reject(new ShardError(reply.code, reply.message, { hint: reply.hint, path: reply.path }));
73
+ }
74
+ function ensure() {
75
+ if (disposed) {
76
+ throw new ShardError("physics/track-client-disposed", "The track client was disposed", {
77
+ hint: "Create another with createTrackClient."
78
+ });
79
+ }
80
+ if (worker) return worker;
81
+ const w = options.spawn === "inline" ? inlineWorker(options.rules) : options.spawn();
82
+ spawns++;
83
+ worker = w;
84
+ warm = new Promise((resolve, reject) => {
85
+ readyReject = reject;
86
+ w.addEventListener("message", (event) => {
87
+ if (worker !== w) return;
88
+ const reply = event.data;
89
+ if (reply.type === "ready") resolve();
90
+ onReply(reply);
91
+ });
92
+ });
93
+ warm.catch(() => {
94
+ });
95
+ const onCrash = (event) => {
96
+ if (worker !== w) return;
97
+ const message = event.message;
98
+ crash(message || (event.type === "messageerror" ? "a message could not be read" : "error"));
99
+ };
100
+ w.addEventListener("error", onCrash);
101
+ w.addEventListener("messageerror", onCrash);
102
+ w.postMessage({ type: "init" });
103
+ return w;
104
+ }
105
+ return {
106
+ get disposed() {
107
+ return disposed;
108
+ },
109
+ get spawns() {
110
+ return spawns;
111
+ },
112
+ async ready() {
113
+ ensure();
114
+ await warm;
115
+ },
116
+ record(scene, recordOptions = {}) {
117
+ let w;
118
+ try {
119
+ w = ensure();
120
+ } catch (err) {
121
+ return Promise.reject(err);
122
+ }
123
+ const { signal } = recordOptions;
124
+ if (signal?.aborted) return Promise.reject(trackCancelled());
125
+ const id = nextId++;
126
+ return new Promise((resolve, reject) => {
127
+ const onAbort = () => {
128
+ if (!settle(id)) return;
129
+ if (worker === w) w.postMessage({ type: "cancel", id });
130
+ reject(trackCancelled());
131
+ };
132
+ signal?.addEventListener("abort", onAbort, { once: true });
133
+ pending.set(id, {
134
+ resolve,
135
+ reject,
136
+ cleanup: () => signal?.removeEventListener("abort", onAbort)
137
+ });
138
+ w.postMessage({
139
+ type: "record",
140
+ id,
141
+ scene,
142
+ settle: recordOptions.settle,
143
+ contacts: recordOptions.contacts
144
+ });
145
+ });
146
+ },
147
+ dispose() {
148
+ if (disposed) return;
149
+ disposed = true;
150
+ const w = worker;
151
+ worker = void 0;
152
+ warm = void 0;
153
+ rejectAll(new ShardError("physics/track-cancelled", "The track client was disposed"));
154
+ if (w) {
155
+ w.postMessage({ type: "dispose" });
156
+ w.terminate();
157
+ }
158
+ }
159
+ };
160
+ }
161
+ export {
162
+ createTrackClient
163
+ };
@@ -0,0 +1,8 @@
1
+ export { createTrackClient, type TrackClient, type TrackClientOptions, type TrackRecordOptions, type TrackWorkerLike, } from './client.js';
2
+ export type { SettleRuleRef, TrackReply, TrackRequest } from './protocol.js';
3
+ export { createTrackServer, type ServeTracksOptions, serveTracks, type TrackScope } from './serve.js';
4
+ /**
5
+ * The default track worker, bundled by the host's bundler: Vite and webpack both recognize this
6
+ * `new Worker(new URL(…, import.meta.url))` form and bundle the entry with Rapier inside it.
7
+ */
8
+ export declare function trackWorker(): Worker;
@@ -0,0 +1,13 @@
1
+ import {
2
+ createTrackClient
3
+ } from "./client.js";
4
+ import { createTrackServer, serveTracks } from "./serve.js";
5
+ function trackWorker() {
6
+ return new Worker(new URL("./track-worker.js", import.meta.url), { type: "module" });
7
+ }
8
+ export {
9
+ createTrackClient,
10
+ createTrackServer,
11
+ serveTracks,
12
+ trackWorker
13
+ };
@@ -0,0 +1,38 @@
1
+ import type { TrackContactOptions } from '../track/record.js';
2
+ import type { TrackScene } from '../track/scene.js';
3
+ /** A settle rule the worker registered, and the parameters for this recording. */
4
+ export interface SettleRuleRef {
5
+ rule: string;
6
+ params?: unknown;
7
+ }
8
+ export type TrackRequest = {
9
+ type: 'init';
10
+ } | {
11
+ type: 'record';
12
+ id: number;
13
+ scene: TrackScene;
14
+ settle?: SettleRuleRef;
15
+ contacts?: TrackContactOptions;
16
+ } | {
17
+ type: 'cancel';
18
+ id: number;
19
+ } | {
20
+ type: 'dispose';
21
+ };
22
+ export type TrackReply = {
23
+ type: 'ready';
24
+ engine: string;
25
+ } | {
26
+ type: 'track';
27
+ id: number;
28
+ buffer: ArrayBuffer;
29
+ }
30
+ /** `id` null: `init` failed. */
31
+ | {
32
+ type: 'error';
33
+ id: number | null;
34
+ code: string;
35
+ message: string;
36
+ hint?: string;
37
+ path?: string;
38
+ };
File without changes
@@ -0,0 +1,34 @@
1
+ import { type SettleRule } from '../track/record.js';
2
+ import type { TrackReply, TrackRequest } from './protocol.js';
3
+ /** The side of a worker that receives requests: a worker's global scope, or a stand-in for one. */
4
+ export interface TrackScope {
5
+ postMessage(message: TrackReply, transfer?: Transferable[]): void;
6
+ addEventListener(type: 'message', listener: (event: {
7
+ data: unknown;
8
+ }) => void): void;
9
+ close?(): void;
10
+ }
11
+ export interface ServeTracksOptions {
12
+ /** Settle rules by name, besides the built-in `sleep`. */
13
+ rules?: {
14
+ [name: string]: SettleRule;
15
+ };
16
+ /** Default: the worker's global scope. */
17
+ scope?: TrackScope;
18
+ }
19
+ /**
20
+ * Handles track requests one recording at a time, replying through `post`. `serveTracks` binds it
21
+ * to a worker; the inline client binds it to the calling thread.
22
+ */
23
+ export declare function createTrackServer(post: (reply: TrackReply, transfer?: Transferable[]) => void, rules?: {
24
+ [name: string]: SettleRule;
25
+ }): {
26
+ handle(request: TrackRequest): void;
27
+ dispose(): void;
28
+ };
29
+ /**
30
+ * A track worker's entry: records what the client asks for with the settle rules named here (and
31
+ * `sleep`). A package with its own rules ships its own entry, e.g.
32
+ * `serveTracks({ rules: { 'dice-settle': diceSettle } })`.
33
+ */
34
+ export declare function serveTracks(options?: ServeTracksOptions): void;
@@ -0,0 +1,111 @@
1
+ import { ShardError } from "@aethervtt/shard-core";
2
+ import { loadDeterministic3d } from "../rapier.js";
3
+ import { encodeTrack } from "../track/format.js";
4
+ import { builtinSettleRules, recordTrack, trackCancelled } from "../track/record.js";
5
+ function replyError(id, err) {
6
+ if (err instanceof ShardError) {
7
+ return {
8
+ type: "error",
9
+ id,
10
+ code: err.code,
11
+ message: err.message,
12
+ hint: err.hint,
13
+ path: err.path
14
+ };
15
+ }
16
+ return {
17
+ type: "error",
18
+ id,
19
+ code: "physics/track-failed",
20
+ message: err instanceof Error ? err.message : String(err)
21
+ };
22
+ }
23
+ function createTrackServer(post, rules) {
24
+ const allRules = { ...builtinSettleRules, ...rules };
25
+ const queue = [];
26
+ let current;
27
+ let draining = false;
28
+ let disposed = false;
29
+ async function run(job) {
30
+ const abort = new AbortController();
31
+ current = { id: job.id, abort };
32
+ try {
33
+ const ref = job.settle ?? { rule: "sleep" };
34
+ const rule = allRules[ref.rule];
35
+ if (!rule) {
36
+ throw new ShardError("physics/unknown-settle-rule", `No settle rule "${ref.rule}"`, {
37
+ hint: `This worker has: ${Object.keys(allRules).join(", ")}. Register yours with serveTracks({ rules }).`
38
+ });
39
+ }
40
+ const track = await recordTrack(job.scene, {
41
+ signal: abort.signal,
42
+ settle: rule(ref.params),
43
+ contacts: job.contacts
44
+ });
45
+ const buffer = encodeTrack(track);
46
+ post({ type: "track", id: job.id, buffer }, [buffer]);
47
+ } catch (err) {
48
+ post(replyError(job.id, err));
49
+ } finally {
50
+ current = void 0;
51
+ }
52
+ }
53
+ async function drain() {
54
+ draining = true;
55
+ while (queue.length > 0 && !disposed) await run(queue.shift());
56
+ draining = false;
57
+ }
58
+ function dispose() {
59
+ disposed = true;
60
+ queue.length = 0;
61
+ current?.abort.abort();
62
+ }
63
+ return {
64
+ handle(request) {
65
+ if (disposed) return;
66
+ switch (request.type) {
67
+ case "init":
68
+ loadDeterministic3d().then(
69
+ (R) => post({ type: "ready", engine: `rapier3d-deterministic@${R.version()}` }),
70
+ (err) => post(replyError(null, err))
71
+ );
72
+ return;
73
+ case "record":
74
+ queue.push(request);
75
+ if (!draining) void drain();
76
+ return;
77
+ case "cancel": {
78
+ if (current?.id === request.id) {
79
+ current.abort.abort();
80
+ return;
81
+ }
82
+ const i = queue.findIndex((job) => job.id === request.id);
83
+ if (i >= 0) {
84
+ queue.splice(i, 1);
85
+ post(replyError(request.id, trackCancelled()));
86
+ }
87
+ return;
88
+ }
89
+ case "dispose":
90
+ dispose();
91
+ }
92
+ },
93
+ dispose
94
+ };
95
+ }
96
+ function serveTracks(options = {}) {
97
+ const scope = options.scope ?? globalThis;
98
+ const server = createTrackServer(
99
+ (reply, transfer) => scope.postMessage(reply, transfer),
100
+ options.rules
101
+ );
102
+ scope.addEventListener("message", (event) => {
103
+ const request = event.data;
104
+ server.handle(request);
105
+ if (request.type === "dispose") scope.close?.();
106
+ });
107
+ }
108
+ export {
109
+ createTrackServer,
110
+ serveTracks
111
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { serveTracks } from "./serve.js";
3
+ const port = parentPort;
4
+ serveTracks({
5
+ rules: {
6
+ crash: () => () => {
7
+ setTimeout(() => {
8
+ throw new Error("boom");
9
+ });
10
+ return "continue";
11
+ }
12
+ },
13
+ scope: {
14
+ postMessage: (message, transfer) => port.postMessage(message, transfer),
15
+ addEventListener: (_type, listener) => port.on("message", (data) => listener({ data })),
16
+ close: () => port.close()
17
+ }
18
+ });
@@ -0,0 +1,11 @@
1
+ import type { TrackWorkerLike } from './client.js';
2
+ /**
3
+ * Runs the TypeScript worker entry at `entry` in a worker thread, through the web Worker interface
4
+ * the track client expects. Needs `tsx` installed.
5
+ *
6
+ * The thread registers tsx itself before it imports the entry. `--import tsx` in `execArgv` is not
7
+ * enough: tsx registers its hooks in a worker thread only on Node 22.22.3 and later (where it can
8
+ * tell a worker from Node's own loader thread). On earlier 22.x it skips them, and Node's built-in
9
+ * type stripping then loads the entry and fails on its extensionless imports.
10
+ */
11
+ export declare function nodeTrackWorker(entry: URL): TrackWorkerLike;
@@ -0,0 +1,26 @@
1
+ import { Worker } from "node:worker_threads";
2
+ function nodeTrackWorker(entry) {
3
+ const tsx = JSON.stringify(import.meta.resolve("tsx/esm/api"));
4
+ const worker = new Worker(
5
+ `import(${tsx}).then((tsx) => { tsx.register(); return import(${JSON.stringify(entry.href)}) })`,
6
+ { eval: true }
7
+ );
8
+ const listeners = {
9
+ message: [],
10
+ error: [],
11
+ messageerror: []
12
+ };
13
+ const emit = (type, event) => {
14
+ for (const listener of listeners[type]) listener(event);
15
+ };
16
+ worker.on("message", (data) => emit("message", { data }));
17
+ worker.on("error", (err) => emit("error", { type: "error", message: err.message }));
18
+ return {
19
+ postMessage: (message, transfer) => worker.postMessage(message, transfer),
20
+ addEventListener: (type, listener) => void listeners[type].push(listener),
21
+ terminate: () => void worker.terminate()
22
+ };
23
+ }
24
+ export {
25
+ nodeTrackWorker
26
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { serveTracks } from "./serve.js";
2
+ serveTracks();
package/dist/world.d.ts CHANGED
@@ -1,16 +1,20 @@
1
- import { type Entity, ShardError, type World } from '@aethervtt/shard-core';
1
+ import { type AssetRef, type Entity, ShardError, type World } from '@aethervtt/shard-core';
2
2
  import type { Mesh } from '@aethervtt/shard-mesh';
3
3
  import type RAPIER from '@dimforge/rapier3d-compat';
4
4
  import { type CollisionEventData, type PhysicsConfigValue } from './components.js';
5
- export type Rapier = typeof RAPIER;
5
+ import type { Rapier, RapierVariant } from './rapier.js';
6
+ export { loadRapier, type Rapier, type RapierVariant } from './rapier.js';
6
7
  type RWorld = InstanceType<Rapier['World']>;
7
8
  type RBody = InstanceType<Rapier['RigidBody']>;
8
9
  type RCollider = InstanceType<Rapier['Collider']>;
9
10
  type RJoint = InstanceType<Rapier['ImpulseJoint']>;
10
11
  /** The physics world, once Rapier has loaded (the plugin's `ready` step). */
11
12
  export declare const Physics: import("@aethervtt/shard-core").ResourceDef<PhysicsWorld>;
12
- /** Loads and initializes Rapier's WASM for a dimension, once per process. */
13
- export declare function loadRapier(dim: 2 | 3): Promise<Rapier>;
13
+ /**
14
+ * A loaded mesh by reference, or undefined while it isn't loaded. The plugin passes the renderer's
15
+ * mesh store (`@aethervtt/shard-physics/render`), so the simulation doesn't import the renderer.
16
+ */
17
+ export type MeshLookup = (world: World, ref: AssetRef) => Mesh | undefined;
14
18
  /** A body the physics world owns, by entity. */
15
19
  export interface BodyRecord {
16
20
  entity: Entity;
@@ -91,6 +95,10 @@ export interface PhysicsStats {
91
95
  export declare class PhysicsWorld {
92
96
  readonly R: Rapier;
93
97
  readonly dim: 2 | 3;
98
+ /** The Rapier build this world runs on. */
99
+ readonly variant: RapierVariant;
100
+ /** Finds collider meshes; without it, mesh colliders wait for one. */
101
+ meshes: MeshLookup | undefined;
94
102
  readonly raw: RWorld;
95
103
  readonly events: InstanceType<Rapier['EventQueue']>;
96
104
  readonly bodies: Map<number, BodyRecord>;
@@ -152,7 +160,7 @@ export declare class PhysicsWorld {
152
160
  readonly rawVec: unknown | undefined;
153
161
  private readonly ray;
154
162
  private logged;
155
- constructor(R: Rapier, dim: 2 | 3, config: PhysicsConfigValue);
163
+ constructor(R: Rapier, dim: 2 | 3, config: PhysicsConfigValue, variant?: RapierVariant, meshes?: MeshLookup);
156
164
  gravityVector(): {
157
165
  x: number;
158
166
  y: number;
@@ -243,4 +251,3 @@ export declare class PhysicsWorld {
243
251
  }
244
252
  /** Angle around Z of a quaternion (2D rotation). */
245
253
  export declare function quatToAngle(q: ArrayLike<number>, o?: number): number;
246
- export {};
package/dist/world.js CHANGED
@@ -4,7 +4,6 @@ import {
4
4
  defineResource,
5
5
  ShardError
6
6
  } from "@aethervtt/shard-core";
7
- import { Meshes } from "@aethervtt/shard-render";
8
7
  import { LogResource } from "@aethervtt/shard-runtime";
9
8
  import { GlobalTransform, GridFramesResource } from "@aethervtt/shard-transform";
10
9
  import {
@@ -18,19 +17,10 @@ import {
18
17
  Velocity
19
18
  } from "./components.js";
20
19
  import { createPose, mulQuat, parentOf, poseOf, rotate } from "./pose.js";
20
+ import { loadRapier } from "./rapier.js";
21
21
  const Physics = defineResource("physics/World", {
22
22
  description: "The Rapier world: raycast, shapeCast, overlapPoint, overlapShape, describe. Present after the physics plugin is ready."
23
23
  });
24
- const modules = {};
25
- function loadRapier(dim) {
26
- modules[dim] ??= (async () => {
27
- const mod = dim === 3 ? await import("@dimforge/rapier3d-compat") : await import("@dimforge/rapier2d-compat");
28
- const R = mod.default ?? mod;
29
- await R.init();
30
- return R;
31
- })();
32
- return modules[dim];
33
- }
34
24
  const CHARACTER_OFFSET = 0.01;
35
25
  const createRayHit = () => ({
36
26
  entity: -1,
@@ -45,6 +35,10 @@ const KIND_KINEMATIC_POSITION = 2;
45
35
  class PhysicsWorld {
46
36
  R;
47
37
  dim;
38
+ /** The Rapier build this world runs on. */
39
+ variant;
40
+ /** Finds collider meshes; without it, mesh colliders wait for one. */
41
+ meshes;
48
42
  raw;
49
43
  events;
50
44
  bodies = /* @__PURE__ */ new Map();
@@ -90,9 +84,11 @@ class PhysicsWorld {
90
84
  rawVec;
91
85
  ray;
92
86
  logged = /* @__PURE__ */ new Set();
93
- constructor(R, dim, config) {
87
+ constructor(R, dim, config, variant = "regular", meshes) {
94
88
  this.R = R;
95
89
  this.dim = dim;
90
+ this.variant = variant;
91
+ this.meshes = meshes;
96
92
  this.config = config;
97
93
  this.raw = new R.World(this.gravityVector());
98
94
  this.events = new R.EventQueue(true);
@@ -627,7 +623,7 @@ class PhysicsWorld {
627
623
  if (owner !== void 0 && world.has(owner, Mass)) this.fixMass(world, owner);
628
624
  }
629
625
  meshFor(world, entity, ref) {
630
- const mesh = world.tryResource(Meshes)?.get(ref);
626
+ const mesh = this.meshes?.(world, ref);
631
627
  if (mesh) return mesh;
632
628
  const server = assetServer(world);
633
629
  const entry = server.entry(ref);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-physics",
3
- "version": "0.0.1",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -8,6 +8,22 @@
8
8
  ".": {
9
9
  "types": "./dist/index.d.ts",
10
10
  "default": "./dist/index.js"
11
+ },
12
+ "./render": {
13
+ "types": "./dist/render.d.ts",
14
+ "default": "./dist/render.js"
15
+ },
16
+ "./track": {
17
+ "types": "./dist/track/index.d.ts",
18
+ "default": "./dist/track/index.js"
19
+ },
20
+ "./worker": {
21
+ "types": "./dist/worker/index.d.ts",
22
+ "default": "./dist/worker/index.js"
23
+ },
24
+ "./worker/testing": {
25
+ "types": "./dist/worker/testing.d.ts",
26
+ "default": "./dist/worker/testing.js"
11
27
  }
12
28
  },
13
29
  "files": [
@@ -16,14 +32,16 @@
16
32
  "README.md"
17
33
  ],
18
34
  "dependencies": {
35
+ "@aethervtt/shard-assets": "0.2.0",
36
+ "@aethervtt/shard-core": "0.2.0",
37
+ "@aethervtt/shard-mesh": "0.2.0",
38
+ "@aethervtt/shard-render": "0.2.0",
39
+ "@aethervtt/shard-runtime": "0.2.0",
40
+ "@aethervtt/shard-transform": "0.2.0",
19
41
  "@dimforge/rapier2d-compat": "0.20.0",
42
+ "@dimforge/rapier2d-deterministic-compat": "0.20.0",
20
43
  "@dimforge/rapier3d-compat": "0.20.0",
21
- "@aethervtt/shard-assets": "0.0.1",
22
- "@aethervtt/shard-core": "0.0.1",
23
- "@aethervtt/shard-mesh": "0.0.1",
24
- "@aethervtt/shard-render": "0.0.1",
25
- "@aethervtt/shard-runtime": "0.0.1",
26
- "@aethervtt/shard-transform": "0.0.1"
44
+ "@dimforge/rapier3d-deterministic-compat": "0.20.0"
27
45
  },
28
46
  "repository": {
29
47
  "type": "git",
@@ -35,6 +53,6 @@
35
53
  "provenance": true
36
54
  },
37
55
  "shard": {
38
- "buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
56
+ "buildSha": "32c22a3944ed23f5d1baa90bf7cc9fae724f5fd8"
39
57
  }
40
58
  }