@jgengine/core 0.18.0 → 0.18.1

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.
Files changed (174) hide show
  1. package/CHANGELOG.md +114 -2
  2. package/dist/ai/decisionGraph.d.ts +52 -0
  3. package/dist/ai/decisionGraph.js +82 -0
  4. package/dist/ai/mobBrainGraph.d.ts +3 -0
  5. package/dist/ai/mobBrainGraph.js +25 -0
  6. package/dist/ai/tacticalQueries.d.ts +30 -0
  7. package/dist/ai/tacticalQueries.js +45 -0
  8. package/dist/anim/animGraph.d.ts +144 -0
  9. package/dist/anim/animGraph.js +286 -0
  10. package/dist/anim/ikSolver.d.ts +51 -0
  11. package/dist/anim/ikSolver.js +124 -0
  12. package/dist/anim/locomotionGraph.d.ts +26 -0
  13. package/dist/anim/locomotionGraph.js +33 -0
  14. package/dist/assets/spriteAtlas.d.ts +21 -0
  15. package/dist/assets/spriteAtlas.js +103 -0
  16. package/dist/audio/audioFalloff.d.ts +10 -0
  17. package/dist/economy/currency.d.ts +16 -2
  18. package/dist/economy/currency.js +55 -14
  19. package/dist/economy/wallet.d.ts +5 -4
  20. package/dist/economy/wallet.js +8 -4
  21. package/dist/editor/commandApply.js +1 -0
  22. package/dist/editor/commands.d.ts +4 -1
  23. package/dist/editor/document.d.ts +2 -1
  24. package/dist/editor/document.js +89 -0
  25. package/dist/editor/environment.d.ts +3 -0
  26. package/dist/editor/environment.js +16 -0
  27. package/dist/editor/index.d.ts +4 -2
  28. package/dist/editor/index.js +1 -1
  29. package/dist/editor/types.d.ts +16 -0
  30. package/dist/game/cameraConfig.d.ts +8 -0
  31. package/dist/game/chat.d.ts +22 -1
  32. package/dist/game/chat.js +48 -19
  33. package/dist/game/controlGate.d.ts +6 -0
  34. package/dist/game/controlGate.js +30 -1
  35. package/dist/game/defineGame.d.ts +10 -1
  36. package/dist/game/events.d.ts +8 -1
  37. package/dist/game/pixelPerfect.d.ts +25 -0
  38. package/dist/game/pixelPerfect.js +29 -0
  39. package/dist/game/playableGame.d.ts +97 -3
  40. package/dist/game/systemRuntime.js +6 -1
  41. package/dist/input/actionBindings.d.ts +2 -1
  42. package/dist/input/actionBindings.js +10 -8
  43. package/dist/input/actionContexts.d.ts +21 -0
  44. package/dist/input/actionContexts.js +56 -0
  45. package/dist/input/gamepadModel.d.ts +45 -0
  46. package/dist/input/gamepadModel.js +69 -0
  47. package/dist/meta/changelog.d.ts +1 -1
  48. package/dist/meta/changelog.js +85 -1
  49. package/dist/movement/characterController.d.ts +76 -0
  50. package/dist/movement/characterController.js +216 -0
  51. package/dist/movement/freeFlight.d.ts +119 -0
  52. package/dist/movement/freeFlight.js +294 -0
  53. package/dist/movement/playerMovement.d.ts +10 -2
  54. package/dist/movement/playerMovement.js +218 -4
  55. package/dist/movement/serverStep.d.ts +5 -0
  56. package/dist/movement/serverStep.js +6 -0
  57. package/dist/movement/solidObstacles.d.ts +30 -7
  58. package/dist/movement/solidObstacles.js +27 -4
  59. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  60. package/dist/multiplayer/poseSyncGate.js +9 -1
  61. package/dist/multiplayer/presenceContract.d.ts +6 -0
  62. package/dist/multiplayer/presenceModel.d.ts +2 -0
  63. package/dist/multiplayer/presenceModel.js +11 -0
  64. package/dist/nav/navMesh.d.ts +33 -0
  65. package/dist/nav/navMesh.js +164 -0
  66. package/dist/physics/flightDynamics.d.ts +8 -0
  67. package/dist/physics/kinematicVehicle.d.ts +6 -1
  68. package/dist/physics/physicsBackend.d.ts +191 -0
  69. package/dist/physics/physicsBackend.js +49 -0
  70. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  71. package/dist/physics/physicsBackendConformance.js +117 -0
  72. package/dist/physics/physicsWorldBackend.d.ts +44 -0
  73. package/dist/physics/physicsWorldBackend.js +485 -0
  74. package/dist/physics/worldColliders.d.ts +13 -0
  75. package/dist/physics/worldColliders.js +96 -0
  76. package/dist/random/rng.d.ts +12 -1
  77. package/dist/random/rng.js +20 -3
  78. package/dist/render/environment.d.ts +23 -0
  79. package/dist/render/environment.js +1 -0
  80. package/dist/render/lookPreset.d.ts +11 -5
  81. package/dist/render/lookPreset.js +29 -4
  82. package/dist/render/postProcessing.d.ts +10 -1
  83. package/dist/render/sprite2d.d.ts +38 -0
  84. package/dist/render/sprite2d.js +40 -0
  85. package/dist/runtime/adapter.d.ts +20 -1
  86. package/dist/runtime/adapter.js +8 -1
  87. package/dist/runtime/commandInput.d.ts +15 -0
  88. package/dist/runtime/commandInput.js +13 -0
  89. package/dist/runtime/commandRunner.d.ts +12 -7
  90. package/dist/runtime/commandRunner.js +7 -5
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +20 -4
  94. package/dist/runtime/gameRuntime.js +17 -4
  95. package/dist/runtime/headlessRunner.d.ts +10 -0
  96. package/dist/runtime/headlessRunner.js +26 -8
  97. package/dist/runtime/hostPolicy.d.ts +3 -2
  98. package/dist/runtime/hostPolicy.js +8 -10
  99. package/dist/runtime/hostedGameRunner.js +34 -6
  100. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  101. package/dist/runtime/hostedWorldSession.js +29 -6
  102. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  103. package/dist/runtime/hostedWorldStore.js +1 -0
  104. package/dist/runtime/inputRecorder.d.ts +30 -0
  105. package/dist/runtime/inputRecorder.js +59 -0
  106. package/dist/runtime/inputSnapshot.d.ts +8 -0
  107. package/dist/runtime/inputSnapshot.js +1 -0
  108. package/dist/runtime/poseInterpolation.d.ts +36 -0
  109. package/dist/runtime/poseInterpolation.js +91 -0
  110. package/dist/runtime/prediction.d.ts +45 -0
  111. package/dist/runtime/prediction.js +62 -0
  112. package/dist/runtime/simContext.d.ts +45 -0
  113. package/dist/runtime/simContext.js +65 -0
  114. package/dist/runtime/simLoop.d.ts +52 -0
  115. package/dist/runtime/simLoop.js +72 -0
  116. package/dist/runtime/simSnapshot.d.ts +32 -0
  117. package/dist/runtime/simSnapshot.js +60 -0
  118. package/dist/runtime/snapshot.d.ts +8 -0
  119. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  120. package/dist/runtime/snapshotBuffer.js +58 -0
  121. package/dist/runtime/transport.d.ts +32 -2
  122. package/dist/runtime/worldChunks.d.ts +2 -0
  123. package/dist/runtime/worldChunks.js +14 -0
  124. package/dist/runtime/worldProjection.d.ts +9 -0
  125. package/dist/runtime/worldProjection.js +17 -1
  126. package/dist/runtime/worldSnapshot.d.ts +6 -0
  127. package/dist/runtime/worldSnapshot.js +3 -0
  128. package/dist/scene/behaviorRuntime.d.ts +19 -1
  129. package/dist/scene/behaviorRuntime.js +55 -4
  130. package/dist/scene/behaviors.d.ts +8 -1
  131. package/dist/scene/builtinSceneKinds.js +10 -0
  132. package/dist/sensor/perception.d.ts +60 -0
  133. package/dist/sensor/perception.js +142 -0
  134. package/dist/settings/graphicsProfile.d.ts +19 -0
  135. package/dist/settings/graphicsProfile.js +36 -0
  136. package/dist/time/accrueSince.d.ts +10 -0
  137. package/dist/time/accrueSince.js +9 -0
  138. package/dist/time/rateWindow.d.ts +16 -0
  139. package/dist/time/rateWindow.js +14 -0
  140. package/dist/time/serverTick.d.ts +21 -3
  141. package/dist/time/serverTick.js +14 -13
  142. package/dist/vfx/particleDirector.d.ts +68 -0
  143. package/dist/vfx/particleDirector.js +81 -0
  144. package/dist/world/buildings.d.ts +12 -0
  145. package/dist/world/buildings.js +43 -3
  146. package/dist/world/cityContent.js +103 -25
  147. package/dist/world/cityKind.d.ts +132 -0
  148. package/dist/world/cityKind.js +315 -15
  149. package/dist/world/features.d.ts +27 -1
  150. package/dist/world/features.js +7 -2
  151. package/dist/world/placement.d.ts +7 -0
  152. package/dist/world/placement.js +2 -0
  153. package/dist/world/placementController.d.ts +1 -1
  154. package/dist/world/placementController.js +7 -0
  155. package/dist/world/raceCircuit.d.ts +168 -0
  156. package/dist/world/raceCircuit.js +797 -0
  157. package/dist/world/terrain.d.ts +5 -1
  158. package/dist/world/terrain.js +5 -0
  159. package/dist/world/territory.d.ts +97 -0
  160. package/dist/world/territory.js +182 -0
  161. package/dist/world.d.ts +5 -3
  162. package/dist/world.js +4 -2
  163. package/package.json +1 -1
  164. package/skills/ce-handoff/SKILL.md +1 -1
  165. package/skills/game-design/SKILL.md +11 -1
  166. package/skills/jgengine/SKILL.md +2 -0
  167. package/skills/jgengine/api.md +94 -19
  168. package/skills/jgengine/capabilities.md +28 -0
  169. package/skills/jgengine-gameplay/SKILL.md +10 -0
  170. package/skills/jgengine-gameplay/api.md +40 -18
  171. package/skills/jgengine-gameplay/capabilities.md +12 -0
  172. package/skills/jgengine-world/SKILL.md +6 -0
  173. package/skills/jgengine-world/api.md +312 -12
  174. package/skills/jgengine-world/capabilities.md +102 -0
@@ -0,0 +1,164 @@
1
+ function vertex(mesh, index) {
2
+ return [mesh.verts[index * 3] ?? 0, mesh.verts[index * 3 + 1] ?? 0, mesh.verts[index * 3 + 2] ?? 0];
3
+ }
4
+ function center(mesh, poly) {
5
+ const result = [0, 0, 0];
6
+ for (const index of poly) {
7
+ const point = vertex(mesh, index);
8
+ result[0] += point[0];
9
+ result[1] += point[1];
10
+ result[2] += point[2];
11
+ }
12
+ const scale = poly.length > 0 ? 1 / poly.length : 0;
13
+ return [result[0] * scale, result[1] * scale, result[2] * scale];
14
+ }
15
+ function distance(a, b) {
16
+ return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
17
+ }
18
+ /** Build polygon adjacency from shared edges and explicit off-mesh links. */
19
+ export function buildNavAdjacency(mesh) {
20
+ const edges = new Map();
21
+ mesh.polys.forEach((poly, polyIndex) => {
22
+ for (let i = 0; i < poly.length; i += 1) {
23
+ const a = poly[i];
24
+ const b = poly[(i + 1) % poly.length];
25
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
26
+ const owners = edges.get(key) ?? [];
27
+ owners.push(polyIndex);
28
+ edges.set(key, owners);
29
+ }
30
+ });
31
+ const result = mesh.polys.map(() => ({ neighbors: [], cost: 1 }));
32
+ for (const owners of edges.values()) {
33
+ for (const a of owners)
34
+ for (const b of owners) {
35
+ if (a !== b && !result[a].neighbors.includes(b))
36
+ result[a].neighbors.push(b);
37
+ }
38
+ }
39
+ for (const link of mesh.links ?? []) {
40
+ if (!result[link.from] || !result[link.to])
41
+ continue;
42
+ if (!result[link.from].neighbors.includes(link.to))
43
+ result[link.from].neighbors.push(link.to);
44
+ }
45
+ return result;
46
+ }
47
+ function pointInPoly(mesh, poly, point) {
48
+ let inside = false;
49
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
50
+ const a = vertex(mesh, poly[i]);
51
+ const b = vertex(mesh, poly[j]);
52
+ const cross = (point[0] - a[0]) * (b[2] - a[2]) - (point[2] - a[2]) * (b[0] - a[0]);
53
+ const dot = (point[0] - a[0]) * (point[0] - b[0]) + (point[2] - a[2]) * (point[2] - b[2]);
54
+ if (Math.abs(cross) < 1e-8 && dot <= 1e-8)
55
+ return true;
56
+ if ((a[2] > point[2]) !== (b[2] > point[2]) && point[0] < (b[0] - a[0]) * (point[2] - a[2]) / (b[2] - a[2]) + a[0])
57
+ inside = !inside;
58
+ }
59
+ return inside;
60
+ }
61
+ function closestOnSegment(point, a, b) {
62
+ const dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
63
+ const len = dx * dx + dy * dy + dz * dz;
64
+ const t = len > 0 ? Math.max(0, Math.min(1, ((point[0] - a[0]) * dx + (point[1] - a[1]) * dy + (point[2] - a[2]) * dz) / len)) : 0;
65
+ return [a[0] + dx * t, a[1] + dy * t, a[2] + dz * t];
66
+ }
67
+ /** Return the closest point on the mesh surface, or null for an empty mesh. */
68
+ export function closestPoint(mesh, point) {
69
+ let best = null;
70
+ let bestDistance = Number.POSITIVE_INFINITY;
71
+ for (const poly of mesh.polys) {
72
+ if (pointInPoly(mesh, poly, point))
73
+ return [point[0], center(mesh, poly)[1], point[2]];
74
+ for (let i = 0; i < poly.length; i += 1) {
75
+ const candidate = closestOnSegment(point, vertex(mesh, poly[i]), vertex(mesh, poly[(i + 1) % poly.length]));
76
+ const d = distance(point, candidate);
77
+ if (d < bestDistance) {
78
+ bestDistance = d;
79
+ best = candidate;
80
+ }
81
+ }
82
+ }
83
+ return best;
84
+ }
85
+ function polyAt(mesh, point) {
86
+ for (let i = 0; i < mesh.polys.length; i += 1)
87
+ if (pointInPoly(mesh, mesh.polys[i], point))
88
+ return i;
89
+ const nearest = closestPoint(mesh, point);
90
+ if (!nearest)
91
+ return null;
92
+ let best = 0;
93
+ let score = Number.POSITIVE_INFINITY;
94
+ mesh.polys.forEach((poly, i) => { const d = distance(nearest, center(mesh, poly)); if (d < score) {
95
+ score = d;
96
+ best = i;
97
+ } });
98
+ return best;
99
+ }
100
+ /** A* over polygon centers, followed by deterministic visibility string-pulling. */
101
+ export function findPath(mesh, from, to) {
102
+ if (mesh.polys.length === 0)
103
+ return null;
104
+ const start = polyAt(mesh, from), goal = polyAt(mesh, to);
105
+ if (start === null || goal === null)
106
+ return null;
107
+ const adjacency = buildNavAdjacency(mesh);
108
+ const g = new Map([[start, 0]]);
109
+ const came = new Map();
110
+ const open = [start];
111
+ while (open.length) {
112
+ open.sort((a, b) => (g.get(a) + distance(center(mesh, mesh.polys[a]), center(mesh, mesh.polys[goal]))) - (g.get(b) + distance(center(mesh, mesh.polys[b]), center(mesh, mesh.polys[goal]))));
113
+ const current = open.shift();
114
+ if (current === goal)
115
+ break;
116
+ for (const next of adjacency[current].neighbors) {
117
+ const link = mesh.links.find((candidate) => candidate.from === current && candidate.to === next);
118
+ const cost = link?.cost ?? distance(center(mesh, mesh.polys[current]), center(mesh, mesh.polys[next]));
119
+ const nextG = g.get(current) + Math.max(0, cost);
120
+ if (nextG < (g.get(next) ?? Number.POSITIVE_INFINITY)) {
121
+ g.set(next, nextG);
122
+ came.set(next, current);
123
+ if (!open.includes(next))
124
+ open.push(next);
125
+ }
126
+ }
127
+ }
128
+ if (start !== goal && !came.has(goal))
129
+ return null;
130
+ const polys = [goal];
131
+ while (polys[0] !== start)
132
+ polys.unshift(came.get(polys[0]));
133
+ const anchors = [from, ...polys.slice(1, -1).map((i) => center(mesh, mesh.polys[i])), to];
134
+ const points = [anchors[0]];
135
+ let anchor = 0;
136
+ for (let i = 2; i < anchors.length; i += 1) {
137
+ const steps = 24;
138
+ let visible = true;
139
+ for (let step = 1; step < steps; step += 1) {
140
+ const t = step / steps;
141
+ const p = [anchors[anchor][0] + (anchors[i][0] - anchors[anchor][0]) * t, anchors[anchor][1] + (anchors[i][1] - anchors[anchor][1]) * t, anchors[anchor][2] + (anchors[i][2] - anchors[anchor][2]) * t];
142
+ if (!mesh.polys.some((poly) => pointInPoly(mesh, poly, p))) {
143
+ visible = false;
144
+ break;
145
+ }
146
+ }
147
+ if (!visible) {
148
+ points.push(anchors[i - 1]);
149
+ anchor = i - 1;
150
+ }
151
+ }
152
+ points.push(anchors[anchors.length - 1]);
153
+ return { points, polys };
154
+ }
155
+ /** True when the segment remains over walkable polygons. */
156
+ export function raycastNav(mesh, from, to) {
157
+ for (let i = 0; i <= 32; i += 1) {
158
+ const t = i / 32;
159
+ const point = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t, from[2] + (to[2] - from[2]) * t];
160
+ if (!mesh.polys.some((poly) => pointInPoly(mesh, poly, point)))
161
+ return false;
162
+ }
163
+ return true;
164
+ }
@@ -1,3 +1,5 @@
1
+ import type { AxisBinding } from "../input/axisInput.js";
2
+ import type { ChaseCameraTuning } from "../runtime/cameraDirector.js";
1
3
  import { type GravityField } from "./gravityField.js";
2
4
  /** Three-dimensional world-space vector used by the flight model. */
3
5
  export type FlightVector = readonly [number, number, number];
@@ -23,6 +25,8 @@ export interface FlightControlRates {
23
25
  damping: number;
24
26
  stability: number;
25
27
  }
28
+ /** Axes an aircraft can bind — which actions drive each flight control. */
29
+ export type AircraftAxis = "pitch" | "roll" | "yaw" | "throttle" | "collective" | "airbrake" | "afterburner" | "vectoring";
26
30
  /** Data-first physical tuning shared by all aircraft instances of one catalog type. */
27
31
  export interface AircraftTuning {
28
32
  kind: AircraftKind;
@@ -41,6 +45,10 @@ export interface AircraftTuning {
41
45
  groundEffectHeight?: number;
42
46
  vtolTransitionSpeed?: number;
43
47
  groundClearance?: number;
48
+ /** Per-axis bindings for this aircraft — which actions drive each control. Unlisted axes keep the defaults (WASD + flightThrottleUp/Down etc.). */
49
+ bindings?: Partial<Record<AircraftAxis, AxisBinding>>;
50
+ /** Camera POV while this aircraft is piloted — chase tuning overlay applied on mount, cleared on dismount. */
51
+ camera?: ChaseCameraTuning | null;
44
52
  }
45
53
  /** Spawn state and injectable world-field samplers for an aircraft instance. */
46
54
  export interface AircraftOptions {
@@ -1,4 +1,5 @@
1
- import type { AxisInput } from "../input/axisInput.js";
1
+ import type { AxisBinding, AxisInput } from "../input/axisInput.js";
2
+ import type { ChaseCameraTuning } from "../runtime/cameraDirector.js";
2
3
  import { type GripCurve } from "./vehicleBody.js";
3
4
  export interface KinematicVehicleTuning {
4
5
  engineAccel: number;
@@ -47,6 +48,10 @@ export interface KinematicVehicleTuning {
47
48
  * Omit to preserve the direct arcade acceleration model exactly.
48
49
  */
49
50
  chassis?: KinematicChassisTuning;
51
+ /** Per-axis bindings for this vehicle — which actions drive throttle/brake/steer/handbrake. Unlisted axes keep car defaults (W/S/A/D/Space). */
52
+ bindings?: Partial<Record<"throttle" | "brake" | "steer" | "handbrake", AxisBinding>>;
53
+ /** Camera POV while this vehicle is piloted — chase tuning overlay applied on mount, cleared on dismount. */
54
+ camera?: ChaseCameraTuning | null;
50
55
  }
51
56
  /**
52
57
  * Mass-and-force chassis layer (#1051); when present it supersedes `engineAccel`/`brakeAccel` with
@@ -0,0 +1,191 @@
1
+ /** `[x, y, z]`. */
2
+ export type PhysicsVec3 = readonly [number, number, number];
3
+ /** Unit quaternion `[x, y, z, w]`; `[0, 0, 0, 1]` is identity. */
4
+ export type PhysicsQuat = readonly [number, number, number, number];
5
+ /** The identity rotation. */
6
+ export declare const IDENTITY_QUAT: PhysicsQuat;
7
+ /** Collision shape of a body or a cast. Capsules stand along +Y; a trimesh or heightfield is static-only on every backend. */
8
+ export type BodyShape = {
9
+ kind: "box";
10
+ halfExtents: PhysicsVec3;
11
+ } | {
12
+ kind: "sphere";
13
+ radius: number;
14
+ } | {
15
+ kind: "capsule";
16
+ radius: number;
17
+ halfHeight: number;
18
+ } | {
19
+ kind: "convex";
20
+ points: readonly PhysicsVec3[];
21
+ } | {
22
+ kind: "trimesh";
23
+ vertices: ArrayLike<number>;
24
+ indices: ArrayLike<number>;
25
+ } | {
26
+ kind: "heightfield";
27
+ rows: number;
28
+ columns: number;
29
+ heights: ArrayLike<number>;
30
+ scale: PhysicsVec3;
31
+ };
32
+ /** Shape discriminator, as listed in {@link PhysicsCapabilities.shapes}. */
33
+ export type BodyShapeKind = BodyShape["kind"];
34
+ /** `dynamic` integrates under forces, `kinematic` moves only through {@link PhysicsBackend.setKinematicTarget}, `static` never moves. */
35
+ export type BodyKind = "dynamic" | "kinematic" | "static";
36
+ /** Everything a backend needs to create a body. Rotation-free backends ignore `rotation` and `angularVelocity` and say so in {@link PhysicsCapabilities}. */
37
+ export interface BodyDesc {
38
+ shape: BodyShape;
39
+ /** Default `dynamic`. */
40
+ kind?: BodyKind;
41
+ position: PhysicsVec3;
42
+ rotation?: PhysicsQuat;
43
+ velocity?: PhysicsVec3;
44
+ angularVelocity?: PhysicsVec3;
45
+ /** Default 1; ignored for kinematic and static bodies. */
46
+ mass?: number;
47
+ friction?: number;
48
+ restitution?: number;
49
+ /** Collision layer bitmask this body belongs to. Default `1`. */
50
+ layers?: number;
51
+ /** Collision layer bitmask this body collides with. Default all bits. */
52
+ mask?: number;
53
+ /** Continuous collision detection for fast small bodies (bullets, thrown props). */
54
+ ccd?: boolean;
55
+ asleep?: boolean;
56
+ userData?: unknown;
57
+ }
58
+ /** Backend-issued body id; stable for the body's lifetime and across snapshot/restore. */
59
+ export type BodyHandle = number;
60
+ /** Backend-issued joint id. */
61
+ export type JointHandle = number;
62
+ /** A body's live pose and motion. Rotation-free backends report identity rotation and zero angular velocity. */
63
+ export interface BodyState {
64
+ position: PhysicsVec3;
65
+ rotation: PhysicsQuat;
66
+ velocity: PhysicsVec3;
67
+ angularVelocity: PhysicsVec3;
68
+ sleeping: boolean;
69
+ }
70
+ /** Joint between two bodies, or between `bodyA` and a world anchor when `bodyB` is omitted. */
71
+ export interface JointDesc {
72
+ kind: "fixed" | "hinge" | "ball" | "distance" | "spring";
73
+ bodyA: BodyHandle;
74
+ bodyB?: BodyHandle;
75
+ /** Anchor in A's local frame. Default origin. */
76
+ anchorA?: PhysicsVec3;
77
+ /** Anchor in B's local frame, or the world point when B is omitted. Default origin. */
78
+ anchorB?: PhysicsVec3;
79
+ /** Hinge axis in world space. */
80
+ axis?: PhysicsVec3;
81
+ /** Hinge angle limits in radians. */
82
+ limits?: readonly [min: number, max: number];
83
+ restLength?: number;
84
+ stiffness?: number;
85
+ damping?: number;
86
+ }
87
+ /** A ray query. */
88
+ export interface RayDesc {
89
+ origin: PhysicsVec3;
90
+ /** Need not be normalized. */
91
+ direction: PhysicsVec3;
92
+ maxDistance: number;
93
+ /** Only bodies whose `layers` intersect this mask are hit. Default all. */
94
+ mask?: number;
95
+ exclude?: BodyHandle;
96
+ }
97
+ /** Nearest surface a ray reached. */
98
+ export interface RayHit {
99
+ body: BodyHandle;
100
+ point: PhysicsVec3;
101
+ normal: PhysicsVec3;
102
+ distance: number;
103
+ }
104
+ /** Sweep `shape` from `position` along `motion` and report the first body it touches. */
105
+ export interface ShapeCastDesc {
106
+ shape: BodyShape;
107
+ position: PhysicsVec3;
108
+ rotation?: PhysicsQuat;
109
+ motion: PhysicsVec3;
110
+ mask?: number;
111
+ exclude?: BodyHandle;
112
+ }
113
+ /** First contact of a shape sweep. */
114
+ export interface ShapeCastHit {
115
+ body: BodyHandle;
116
+ /** Fraction of `motion` travelled at first contact, 0..1. */
117
+ toi: number;
118
+ point: PhysicsVec3;
119
+ normal: PhysicsVec3;
120
+ distance: number;
121
+ }
122
+ /** Which bodies a shape placed at `position` intersects. */
123
+ export interface OverlapDesc {
124
+ shape: BodyShape;
125
+ position: PhysicsVec3;
126
+ rotation?: PhysicsQuat;
127
+ mask?: number;
128
+ exclude?: BodyHandle;
129
+ }
130
+ /** A contact reported during {@link PhysicsBackend.step}; the object is reused between calls, so copy what you keep. */
131
+ export interface ContactEvent {
132
+ a: BodyHandle;
133
+ b: BodyHandle;
134
+ normal: PhysicsVec3;
135
+ approachSpeed: number;
136
+ impulse: number;
137
+ }
138
+ /** What a backend can actually simulate, so a game or the conformance suite can adapt instead of guessing. */
139
+ export interface PhysicsCapabilities {
140
+ rotation: boolean;
141
+ shapes: readonly BodyShapeKind[];
142
+ ccd: boolean;
143
+ joints: readonly JointDesc["kind"][];
144
+ }
145
+ /** Solver-wide knobs a backend accepts at creation and through `retune`. */
146
+ export interface PhysicsBackendConfig {
147
+ gravity?: PhysicsVec3;
148
+ /** Fixed substep length the backend integrates with; the caller's `step(dt)` is accumulated into it. */
149
+ fixedDt?: number;
150
+ maxSubsteps?: number;
151
+ }
152
+ /**
153
+ * The seam every physics consumer talks to: the in-tree `PhysicsWorld` behind `createPhysicsWorldBackend`, or a
154
+ * native solver such as Rapier behind its own adapter. Handles are backend-issued and survive snapshot/restore,
155
+ * so gameplay state can store them.
156
+ *
157
+ * @capability physics-backend one interface over interchangeable rigid-body solvers — bodies, joints, raycast, shapecast, snapshot
158
+ */
159
+ export interface PhysicsBackend {
160
+ readonly name: string;
161
+ readonly capabilities: PhysicsCapabilities;
162
+ addBody(desc: BodyDesc): BodyHandle;
163
+ removeBody(handle: BodyHandle): void;
164
+ hasBody(handle: BodyHandle): boolean;
165
+ /** Read a body's state into `out` (or a fresh object); `null` for an unknown handle. */
166
+ body(handle: BodyHandle, out?: BodyState): BodyState | null;
167
+ userDataOf(handle: BodyHandle): unknown;
168
+ setPosition(handle: BodyHandle, position: PhysicsVec3): void;
169
+ setRotation(handle: BodyHandle, rotation: PhysicsQuat): void;
170
+ setVelocity(handle: BodyHandle, velocity: PhysicsVec3): void;
171
+ setAngularVelocity(handle: BodyHandle, angularVelocity: PhysicsVec3): void;
172
+ applyImpulse(handle: BodyHandle, impulse: PhysicsVec3, point?: PhysicsVec3): void;
173
+ /** Move a kinematic body so the solver sees its velocity over the next step (platforms, doors, hands). */
174
+ setKinematicTarget(handle: BodyHandle, position: PhysicsVec3, rotation?: PhysicsQuat): void;
175
+ wake(handle: BodyHandle): void;
176
+ addJoint(desc: JointDesc): JointHandle;
177
+ removeJoint(handle: JointHandle): void;
178
+ step(dt: number): void;
179
+ raycast(desc: RayDesc): RayHit | null;
180
+ shapecast(desc: ShapeCastDesc): ShapeCastHit | null;
181
+ overlap(desc: OverlapDesc): BodyHandle[];
182
+ onContact(listener: ((event: ContactEvent) => void) | null): void;
183
+ retune(config: PhysicsBackendConfig): void;
184
+ snapshot(): unknown;
185
+ restore(state: unknown): void;
186
+ dispose(): void;
187
+ }
188
+ /** Axis-aligned bounds of a shape at identity rotation, as half extents. */
189
+ export declare function shapeHalfExtents(shape: BodyShape): [number, number, number];
190
+ /** True for an absent or identity rotation. */
191
+ export declare function isIdentityQuat(q: PhysicsQuat | undefined): boolean;
@@ -0,0 +1,49 @@
1
+ /** The identity rotation. */
2
+ export const IDENTITY_QUAT = [0, 0, 0, 1];
3
+ /** Axis-aligned bounds of a shape at identity rotation, as half extents. */
4
+ export function shapeHalfExtents(shape) {
5
+ switch (shape.kind) {
6
+ case "box":
7
+ return [shape.halfExtents[0], shape.halfExtents[1], shape.halfExtents[2]];
8
+ case "sphere":
9
+ return [shape.radius, shape.radius, shape.radius];
10
+ case "capsule":
11
+ return [shape.radius, shape.halfHeight + shape.radius, shape.radius];
12
+ case "convex": {
13
+ let x = 0;
14
+ let y = 0;
15
+ let z = 0;
16
+ for (const p of shape.points) {
17
+ x = Math.max(x, Math.abs(p[0]));
18
+ y = Math.max(y, Math.abs(p[1]));
19
+ z = Math.max(z, Math.abs(p[2]));
20
+ }
21
+ return [x, y, z];
22
+ }
23
+ case "trimesh": {
24
+ let x = 0;
25
+ let y = 0;
26
+ let z = 0;
27
+ for (let i = 0; i + 2 < shape.vertices.length; i += 3) {
28
+ x = Math.max(x, Math.abs(shape.vertices[i]));
29
+ y = Math.max(y, Math.abs(shape.vertices[i + 1]));
30
+ z = Math.max(z, Math.abs(shape.vertices[i + 2]));
31
+ }
32
+ return [x, y, z];
33
+ }
34
+ case "heightfield": {
35
+ let maxH = 0;
36
+ for (let i = 0; i < shape.heights.length; i += 1)
37
+ maxH = Math.max(maxH, Math.abs(shape.heights[i]));
38
+ return [
39
+ (shape.scale[0] * (shape.columns - 1)) / 2,
40
+ Math.max(maxH * shape.scale[1], 1e-3),
41
+ (shape.scale[2] * (shape.rows - 1)) / 2,
42
+ ];
43
+ }
44
+ }
45
+ }
46
+ /** True for an absent or identity rotation. */
47
+ export function isIdentityQuat(q) {
48
+ return q === undefined || (q[0] === 0 && q[1] === 0 && q[2] === 0 && Math.abs(q[3] - 1) < 1e-9);
49
+ }
@@ -0,0 +1,27 @@
1
+ import type { PhysicsBackend } from "./physicsBackend.js";
2
+ /** The slice of a test runner the conformance suite needs; `bun:test` satisfies it directly. */
3
+ export interface ConformanceHarness {
4
+ test(name: string, run: () => void): void;
5
+ expect(value: unknown): {
6
+ toBe(expected: unknown): void;
7
+ toBeNull(): void;
8
+ not: {
9
+ toBeNull(): void;
10
+ toBe(expected: unknown): void;
11
+ };
12
+ toBeGreaterThan(expected: number): void;
13
+ toBeLessThan(expected: number): void;
14
+ toBeCloseTo(expected: number, digits?: number): void;
15
+ toEqual(expected: unknown): void;
16
+ toContain(expected: unknown): void;
17
+ };
18
+ }
19
+ /**
20
+ * Behavioral contract every {@link PhysicsBackend} must pass: bodies rest on floors, casts and rays report the
21
+ * first surface with an outward normal, kinematic targets produce velocity, masks filter, and snapshot/restore
22
+ * round-trips with stable handles. Backends declare what they cannot do through `capabilities`; the suite reads it.
23
+ * Expects `create()` to return a fresh backend whose world spans at least ±50 on every axis with a floor at y=0.
24
+ *
25
+ * @capability physics-backend-conformance the shared test contract a physics adapter runs to prove it matches the seam
26
+ */
27
+ export declare function runPhysicsBackendConformance(create: () => PhysicsBackend, harness: ConformanceHarness): void;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Behavioral contract every {@link PhysicsBackend} must pass: bodies rest on floors, casts and rays report the
3
+ * first surface with an outward normal, kinematic targets produce velocity, masks filter, and snapshot/restore
4
+ * round-trips with stable handles. Backends declare what they cannot do through `capabilities`; the suite reads it.
5
+ * Expects `create()` to return a fresh backend whose world spans at least ±50 on every axis with a floor at y=0.
6
+ *
7
+ * @capability physics-backend-conformance the shared test contract a physics adapter runs to prove it matches the seam
8
+ */
9
+ export function runPhysicsBackendConformance(create, harness) {
10
+ const { test, expect } = harness;
11
+ function settle(backend, seconds) {
12
+ const steps = Math.ceil(seconds * 60);
13
+ for (let i = 0; i < steps; i += 1)
14
+ backend.step(1 / 60);
15
+ }
16
+ function floor(backend) {
17
+ return backend.addBody({ shape: { kind: "box", halfExtents: [50, 0.5, 50] }, position: [0, -0.5, 0], kind: "static" });
18
+ }
19
+ test("handles are unique, readable, and removable", () => {
20
+ const backend = create();
21
+ const a = backend.addBody({ shape: { kind: "sphere", radius: 0.5 }, position: [1, 2, 3], userData: "a" });
22
+ const b = backend.addBody({ shape: { kind: "box", halfExtents: [1, 1, 1] }, position: [4, 5, 6] });
23
+ expect(a).not.toBe(b);
24
+ expect(backend.body(a)?.position).toEqual([1, 2, 3]);
25
+ expect(backend.userDataOf(a)).toBe("a");
26
+ backend.removeBody(a);
27
+ expect(backend.hasBody(a)).toBe(false);
28
+ expect(backend.body(a)).toBeNull();
29
+ expect(backend.hasBody(b)).toBe(true);
30
+ });
31
+ test("a dynamic box falls onto a static floor and rests", () => {
32
+ const backend = create();
33
+ floor(backend);
34
+ const crate = backend.addBody({ shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] }, position: [0, 4, 0] });
35
+ settle(backend, 3);
36
+ const state = backend.body(crate);
37
+ expect(state.position[1]).toBeGreaterThan(0.4);
38
+ expect(state.position[1]).toBeLessThan(0.7);
39
+ expect(Math.abs(state.velocity[1])).toBeLessThan(0.5);
40
+ });
41
+ test("raycast reports the floor with an upward normal and the right distance", () => {
42
+ const backend = create();
43
+ const ground = floor(backend);
44
+ const hit = backend.raycast({ origin: [0, 3, 0], direction: [0, -1, 0], maxDistance: 10 });
45
+ expect(hit).not.toBeNull();
46
+ expect(hit.body).toBe(ground);
47
+ expect(hit.distance).toBeCloseTo(3, 3);
48
+ expect(hit.normal[1]).toBeCloseTo(1, 3);
49
+ expect(backend.raycast({ origin: [0, 3, 0], direction: [0, -1, 0], maxDistance: 1 })).toBeNull();
50
+ });
51
+ test("shapecast stops at a wall with a normal facing the mover", () => {
52
+ const backend = create();
53
+ backend.addBody({ shape: { kind: "box", halfExtents: [0.5, 2, 5] }, position: [5, 2, 0], kind: "static" });
54
+ const hit = backend.shapecast({
55
+ shape: { kind: "capsule", radius: 0.4, halfHeight: 0.5 },
56
+ position: [0, 1, 0],
57
+ motion: [10, 0, 0],
58
+ });
59
+ expect(hit).not.toBeNull();
60
+ expect(hit.toi).toBeGreaterThan(0.3);
61
+ expect(hit.toi).toBeLessThan(0.5);
62
+ expect(hit.normal[0]).toBeCloseTo(-1, 3);
63
+ expect(backend.shapecast({ shape: { kind: "sphere", radius: 0.4 }, position: [0, 1, 0], motion: [2, 0, 0] })).toBeNull();
64
+ });
65
+ test("overlap finds bodies at a point and nowhere else", () => {
66
+ const backend = create();
67
+ const crate = backend.addBody({ shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] }, position: [0, 0.5, 0], kind: "static" });
68
+ expect(backend.overlap({ shape: { kind: "sphere", radius: 0.2 }, position: [0.3, 0.5, 0] })).toContain(crate);
69
+ expect(backend.overlap({ shape: { kind: "sphere", radius: 0.2 }, position: [3, 0.5, 0] })).toEqual([]);
70
+ });
71
+ test("layer masks filter rays and casts", () => {
72
+ const backend = create();
73
+ backend.addBody({ shape: { kind: "box", halfExtents: [1, 1, 1] }, position: [0, 1, 0], kind: "static", layers: 2 });
74
+ expect(backend.raycast({ origin: [0, 5, 0], direction: [0, -1, 0], maxDistance: 10, mask: 1 })).toBeNull();
75
+ expect(backend.raycast({ origin: [0, 5, 0], direction: [0, -1, 0], maxDistance: 10, mask: 2 })).not.toBeNull();
76
+ });
77
+ test("a kinematic target moves the body and reports its velocity", () => {
78
+ const backend = create();
79
+ const platform = backend.addBody({ shape: { kind: "box", halfExtents: [2, 0.25, 2] }, position: [0, 1, 0], kind: "kinematic" });
80
+ backend.setKinematicTarget(platform, [0.5, 1, 0]);
81
+ backend.step(1 / 60);
82
+ const state = backend.body(platform);
83
+ expect(state.position[0]).toBeCloseTo(0.5, 3);
84
+ expect(state.velocity[0]).toBeGreaterThan(20);
85
+ });
86
+ test("contacts are reported when a body lands", () => {
87
+ const backend = create();
88
+ const ground = floor(backend);
89
+ const crate = backend.addBody({ shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] }, position: [0, 3, 0] });
90
+ const pairs = [];
91
+ backend.onContact((event) => {
92
+ pairs.push([event.a, event.b].sort((x, y) => x - y).join(":"));
93
+ });
94
+ settle(backend, 2);
95
+ expect(pairs).toContain([ground, crate].sort((x, y) => x - y).join(":"));
96
+ });
97
+ test("snapshot and restore round-trip state with stable handles", () => {
98
+ const backend = create();
99
+ floor(backend);
100
+ const crate = backend.addBody({ shape: { kind: "box", halfExtents: [0.5, 0.5, 0.5] }, position: [0, 5, 0] });
101
+ settle(backend, 0.5);
102
+ const saved = backend.snapshot();
103
+ const at = backend.body(crate).position[1];
104
+ settle(backend, 1);
105
+ expect(backend.body(crate).position[1]).not.toBe(at);
106
+ backend.restore(saved);
107
+ expect(backend.hasBody(crate)).toBe(true);
108
+ expect(backend.body(crate).position[1]).toBeCloseTo(at, 5);
109
+ const later = backend.addBody({ shape: { kind: "sphere", radius: 0.3 }, position: [2, 2, 2] });
110
+ expect(later).not.toBe(crate);
111
+ });
112
+ test("capabilities describe the backend", () => {
113
+ const backend = create();
114
+ expect(backend.capabilities.shapes.length).toBeGreaterThan(1);
115
+ expect(typeof backend.capabilities.rotation).toBe("boolean");
116
+ });
117
+ }
@@ -0,0 +1,44 @@
1
+ import { type BodyHandle, type BodyKind, type BodyShape, type JointDesc, type JointHandle, type PhysicsBackend, type PhysicsBackendConfig } from "./physicsBackend.js";
2
+ import { type PhysicsBounds, type PhysicsPrecision } from "./physicsWorld.js";
3
+ /** Construction options: the `PhysicsWorld` capacity and bounds plus the shared backend config. */
4
+ export interface PhysicsWorldBackendOptions extends PhysicsBackendConfig {
5
+ capacity: number;
6
+ bounds: PhysicsBounds;
7
+ precision?: PhysicsPrecision;
8
+ friction?: number;
9
+ restitution?: number;
10
+ /** Warn (once) when a body asks for rotation or a shape this backend approximates. Default `true`. */
11
+ warn?: boolean;
12
+ }
13
+ interface SavedBody {
14
+ handle: BodyHandle;
15
+ shape: BodyShape;
16
+ kind: BodyKind;
17
+ layers: number;
18
+ mask: number;
19
+ userData: unknown;
20
+ mass: number;
21
+ position: [number, number, number];
22
+ velocity: [number, number, number];
23
+ sleeping: boolean;
24
+ }
25
+ /** Serializable backend state: every live body and joint, with handles preserved. */
26
+ export interface PhysicsWorldBackendState {
27
+ nextHandle: number;
28
+ nextJoint: number;
29
+ bodies: SavedBody[];
30
+ joints: {
31
+ handle: JointHandle;
32
+ desc: JointDesc;
33
+ }[];
34
+ }
35
+ /**
36
+ * `PhysicsWorld` behind the {@link PhysicsBackend} interface: zero dependencies, translation-only. Capsules,
37
+ * convex hulls, meshes, and heightfields collide as their bounding boxes; rotation, angular velocity, CCD, and
38
+ * hinge limits are accepted and ignored (see {@link PhysicsBackend.capabilities}). Reach for `@jgengine/rapier`
39
+ * when a game needs those for real; the seam is the same.
40
+ *
41
+ * @capability physics-world-backend the zero-dependency PhysicsWorld solver exposed through the PhysicsBackend seam
42
+ */
43
+ export declare function createPhysicsWorldBackend(options: PhysicsWorldBackendOptions): PhysicsBackend;
44
+ export {};