@forgeax/engine-physics 0.0.0-dev.8d955ade1c79

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,165 @@
1
+ // @forgeax/engine-physics — PhysicsWorld Resource interface.
2
+ //
3
+ // The physics backend (RapierPhysicsWorld3D / RapierPhysicsWorld2D) implements
4
+ // this interface and registers as the 'PhysicsWorld' World Resource.
5
+ // AI users obtain it via `world.getResource<PhysicsWorld>('PhysicsWorld')`.
6
+
7
+ import type { Vec2, Vec3 } from '@forgeax/engine-math';
8
+
9
+ /**
10
+ * Raycast hit result — returned by `PhysicsWorld.raycast()`.
11
+ *
12
+ * `entity`: the entity whose collider was hit.
13
+ * `point`: world-space hit point.
14
+ * `normal`: world-space surface normal at hit point.
15
+ * `timeOfImpact`: ray parameter t (origin + direction * toi = hit point).
16
+ */
17
+ export interface RaycastHit {
18
+ entity: number;
19
+ point: Vec3;
20
+ normal: Vec3;
21
+ timeOfImpact: number;
22
+ }
23
+
24
+ /**
25
+ * PhysicsWorld Resource interface — the engine-side API surface for physics
26
+ * operations. Backend implementations (RapierPhysicsWorld3D/2D) satisfy this
27
+ * contract.
28
+ *
29
+ * Inserted as `'PhysicsWorld'` resource by `createApp` when `opts.physics`
30
+ * is set. AI users retrieve via `world.getResource<PhysicsWorld>('PhysicsWorld')`.
31
+ *
32
+ * All mutation methods are synchronous; physics step is driven by the tick
33
+ * systems (syncBackend → stepSimulation → writeback), not by user calls.
34
+ */
35
+ export interface PhysicsWorld {
36
+ /** Release backend-native resources. Called by the owning physics plugin. */
37
+ dispose(): void;
38
+ /** Set world gravity vector. */
39
+ setGravity(gravity: Vec3): void;
40
+
41
+ /** Get current world gravity vector. */
42
+ getGravity(): Vec3;
43
+
44
+ /**
45
+ * Cast a ray into the physics world and return the first hit.
46
+ *
47
+ * @param origin - world-space ray origin.
48
+ * @param direction - normalized world-space ray direction.
49
+ * @param maxDist - maximum ray distance (0 = infinite).
50
+ * @param filterMask - 32-bit packed collision filter mask (optional).
51
+ * @returns RaycastHit on hit, undefined on miss.
52
+ */
53
+ raycast(
54
+ origin: Vec3,
55
+ direction: Vec3,
56
+ maxDist: number,
57
+ filterMask?: number,
58
+ ): RaycastHit | undefined;
59
+
60
+ /**
61
+ * Teleport a dynamic body to a position instantly, zeroing velocity.
62
+ *
63
+ * Use for spawning entities at specific locations or resetting after
64
+ * out-of-bounds. Does not accumulate velocity from the displacement
65
+ * (unlike `world.set(entity, Transform, { translation: ... })` on
66
+ * dynamic bodies, which would cause a velocity spike).
67
+ *
68
+ * @param entity - the entity (must have RigidBody + Collider).
69
+ * @param position - new world-space position.
70
+ */
71
+ teleport(entity: number, position: Vec3): void;
72
+
73
+ /**
74
+ * Move a kinematic character with collision response, slope handling,
75
+ * auto-step, and ground-snap, then write the resolved position back to the
76
+ * entity's `Transform` and `CharacterController.grounded`.
77
+ *
78
+ * This is the engine's unopinionated movement primitive (modeled on Unity
79
+ * `CharacterController.Move`): the game layer computes `desiredDelta` from
80
+ * input + gravity + jump, and `moveAndSlide` resolves it against the world
81
+ * geometry. The entity must carry a `RigidBody({ type: 'kinematic' })`, a
82
+ * `Collider`, and a `CharacterController` component.
83
+ *
84
+ * Tuning (offset / slope / auto-step / ground-snap) is read from the
85
+ * `CharacterController` component each call; there is no per-call options
86
+ * object and no `dt` parameter (the delta already encodes elapsed time).
87
+ *
88
+ * @param entity the character entity (kinematic RigidBody + Collider + CharacterController).
89
+ * @param desiredDelta the requested world-space displacement for this step.
90
+ * @returns the actual displacement applied after collision resolution.
91
+ * @throws PhysicsError `body-not-found` if the entity has no Rapier body,
92
+ * `collider-not-found` if the body has no collider,
93
+ * `controller-requires-kinematic` if the body is not kinematic.
94
+ */
95
+ moveAndSlide(entity: number, desiredDelta: Vec3): Vec3;
96
+
97
+ /** Advance the physics simulation by one timestep. */
98
+ step(deltaTime: number): void;
99
+
100
+ /** Return the number of active rigid bodies in the physics world. */
101
+ getBodyCount(): number;
102
+
103
+ /**
104
+ * Check whether a Rapier body exists for `entity`.
105
+ *
106
+ * Returns `true` after `ensureBody` has created a Rapier body for the entity
107
+ * (which happens asynchronously via WASM fire-and-forget load + tick pipeline).
108
+ * Always returns `false` for entities that have no `RigidBody` + `Collider`.
109
+ *
110
+ * AI-user contract: before calling `moveAndSlide` inside a per-frame driver,
111
+ * guard with `if (!pw.hasBody(entity)) return;` to avoid `body-not-found`
112
+ * errors during the window between `app.start()` and the first
113
+ * `physicsSyncBackend` tick that builds the body.
114
+ */
115
+ hasBody(entity: number): boolean;
116
+ }
117
+
118
+ /** 2D raycast hit result. */
119
+ export interface RaycastHit2D {
120
+ entity: number;
121
+ point: Vec2;
122
+ normal: Vec2;
123
+ timeOfImpact: number;
124
+ }
125
+
126
+ /** 2D PhysicsWorld Resource interface. */
127
+ export interface PhysicsWorld2D {
128
+ /** Release backend-native resources. Called by the owning physics plugin. */
129
+ dispose(): void;
130
+ setGravity(gravity: Vec2): void;
131
+ getGravity(): Vec2;
132
+ raycast(
133
+ origin: Vec2,
134
+ direction: Vec2,
135
+ maxDist: number,
136
+ filterMask?: number,
137
+ ): RaycastHit2D | undefined;
138
+ teleport(entity: number, position: Vec2, rotation: number): void;
139
+ /**
140
+ * Move a kinematic character with collision response, slope handling,
141
+ * auto-step, and ground-snap (2D variant of {@link PhysicsWorld.moveAndSlide}).
142
+ *
143
+ * Resolves `desiredDelta` against the world geometry, writes the resolved
144
+ * position back to the entity's `Transform` and `CharacterController.grounded`,
145
+ * and returns the actual 2D displacement. The entity must carry a
146
+ * `RigidBody({ type: 'kinematic' })`, a `Collider`, and a `CharacterController`.
147
+ *
148
+ * @param entity the character entity (kinematic RigidBody + Collider + CharacterController).
149
+ * @param desiredDelta the requested world-space 2D displacement for this step.
150
+ * @returns the actual 2D displacement applied after collision resolution.
151
+ * @throws PhysicsError `body-not-found`, `collider-not-found`, or
152
+ * `controller-requires-kinematic` (same contract as the 3D primitive).
153
+ */
154
+ moveAndSlide(entity: number, desiredDelta: Vec2): Vec2;
155
+ step(deltaTime: number): void;
156
+ getBodyCount(): number;
157
+
158
+ /**
159
+ * Check whether a Rapier 2D body exists for `entity`.
160
+ *
161
+ * See {@link PhysicsWorld.hasBody} for the full contract — the 2D variant
162
+ * follows the same semantics.
163
+ */
164
+ hasBody(entity: number): boolean;
165
+ }
@@ -0,0 +1,116 @@
1
+ // @forgeax/engine-physics -- physicsPlugin(backend) factory (M2 / w10, plan-strategy D-5 / D-7).
2
+ //
3
+ // physicsPlugin lives in @forgeax/engine-physics (the interface package, C-9)
4
+ // and accepts an interface->backend dependency inversion: its async apply
5
+ // dynamic-imports the rapier 2D / 3D backend on demand. The backends are
6
+ // declared as devDependencies in this package's package.json (a regular
7
+ // dependency would form a physics <-> rapier cycle since the backends depend on
8
+ // the interface package); the consuming app declares the real runtime dep.
9
+ //
10
+ // charter awareness:
11
+ // P3 explicit failure: WASM load failure rejects plugin activation and the
12
+ // App boundary preserves the cause; it is never a silent skip.
13
+ // P4 consistent abstraction: physicsPlugin shares the same Plugin shape as
14
+ // transform / audio -- one mental model covers every wiring.
15
+
16
+ import type { Plugin } from '@forgeax/engine-plugin';
17
+ import { registerPhysicsComponents } from './components';
18
+ import { PhysicsError } from './errors';
19
+ import { loadRapier2DBackend, loadRapier3DBackend } from './load-rapier-backend.mjs';
20
+ import type { PhysicsWorld, PhysicsWorld2D } from './physics-world';
21
+
22
+ interface Rapier3DBackendModule {
23
+ loadRapier3D(): Promise<unknown>;
24
+ createRapier3DPhysicsWorld(rapier: unknown): PhysicsWorld;
25
+ registerPhysicsSystems(world: import('@forgeax/engine-ecs').World): () => void;
26
+ }
27
+
28
+ interface Rapier2DBackendModule {
29
+ loadRapier2D(): Promise<unknown>;
30
+ createRapier2DPhysicsWorld(rapier: unknown): PhysicsWorld2D;
31
+ registerPhysicsSystems2D(world: import('@forgeax/engine-ecs').World): () => void;
32
+ }
33
+
34
+ /** Rapier backend selector. */
35
+ export type PhysicsBackend = 'rapier-2d' | 'rapier-3d';
36
+
37
+ function normalizeWasmLoadFailure(backend: PhysicsBackend, cause: unknown): PhysicsError {
38
+ if (cause instanceof PhysicsError && cause.code === 'wasm-load-failed') return cause;
39
+ const reason = cause instanceof Error ? cause.message : String(cause);
40
+ return new PhysicsError({
41
+ code: 'wasm-load-failed',
42
+ expected: `successful import and WASM initialization for ${backend}`,
43
+ hint: `Rapier backend activation failed: ${reason}`,
44
+ detail: { code: 'wasm-load-failed', reason },
45
+ });
46
+ }
47
+
48
+ declare module '@forgeax/engine-plugin' {
49
+ interface EngineContextServices {
50
+ physics?: PhysicsWorld | PhysicsWorld2D;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * physicsPlugin(backend) dynamically imports the Rapier backend,
56
+ * loads the WASM module, creates the PhysicsWorld, inserts it as the
57
+ * 'PhysicsWorld' world resource, and registers the three-phase tick systems.
58
+ *
59
+ * The resource is inserted before registering systems so moveAndSlide resolves
60
+ * `PhysicsWorld` on the first tick. Cordis owns rollback if any later effect
61
+ * fails.
62
+ *
63
+ * @param backend 'rapier-2d' or 'rapier-3d'
64
+ */
65
+ export function physicsPlugin(backend: PhysicsBackend): Plugin {
66
+ return {
67
+ name: 'physics',
68
+ inject: ['world'],
69
+ provide: 'physics',
70
+ async apply(ctx) {
71
+ const world = ctx.world;
72
+ let physics: PhysicsWorld | PhysicsWorld2D;
73
+ let registerSystems: () => () => void;
74
+ if (backend === 'rapier-3d') {
75
+ let module: Rapier3DBackendModule;
76
+ let rapier: unknown;
77
+ try {
78
+ module = (await loadRapier3DBackend()) as Rapier3DBackendModule;
79
+ rapier = await module.loadRapier3D();
80
+ } catch (cause) {
81
+ throw normalizeWasmLoadFailure(backend, cause);
82
+ }
83
+ if (rapier instanceof PhysicsError) throw normalizeWasmLoadFailure(backend, rapier);
84
+ const { createRapier3DPhysicsWorld, registerPhysicsSystems } = module;
85
+ physics = createRapier3DPhysicsWorld(rapier);
86
+ registerSystems = () => registerPhysicsSystems(world);
87
+ } else {
88
+ let module: Rapier2DBackendModule;
89
+ let rapier: unknown;
90
+ try {
91
+ module = (await loadRapier2DBackend()) as Rapier2DBackendModule;
92
+ rapier = await module.loadRapier2D();
93
+ } catch (cause) {
94
+ throw normalizeWasmLoadFailure(backend, cause);
95
+ }
96
+ if (rapier instanceof PhysicsError) throw normalizeWasmLoadFailure(backend, rapier);
97
+ const { createRapier2DPhysicsWorld, registerPhysicsSystems2D } = module;
98
+ physics = createRapier2DPhysicsWorld(rapier);
99
+ registerSystems = () => registerPhysicsSystems2D(world);
100
+ }
101
+ ctx.effect(() => registerPhysicsComponents(world), 'physics/components');
102
+ ctx.effect(() => {
103
+ world.insertResource('PhysicsWorld', physics);
104
+ return () => {
105
+ world.removeResource('PhysicsWorld');
106
+ physics.dispose();
107
+ };
108
+ }, 'physics/resource');
109
+ ctx.effect(() => {
110
+ const unregister = registerSystems();
111
+ return () => unregister();
112
+ }, 'physics/systems');
113
+ ctx.provide('physics', physics);
114
+ },
115
+ };
116
+ }
@@ -0,0 +1,3 @@
1
+ import { defineSystemSet } from '@forgeax/engine-ecs';
2
+
3
+ export const PhysicsSet = defineSystemSet({ name: 'physics' });