@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.
- package/LICENSE +202 -0
- package/README.md +233 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/physics.unit.test.d.ts +2 -0
- package/dist/__tests__/physics.unit.test.d.ts.map +1 -0
- package/dist/collision-event.d.ts +26 -0
- package/dist/collision-event.d.ts.map +1 -0
- package/dist/components.d.ts +162 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +164 -0
- package/dist/index.mjs.map +1 -0
- package/dist/physics-world.d.ts +138 -0
- package/dist/physics-world.d.ts.map +1 -0
- package/dist/plugin-factory.d.ts +22 -0
- package/dist/plugin-factory.d.ts.map +1 -0
- package/dist/system-set.d.ts +2 -0
- package/dist/system-set.d.ts.map +1 -0
- package/package.json +65 -0
- package/src/__tests__/physics.unit.test.ts +364 -0
- package/src/collision-event.ts +35 -0
- package/src/components.ts +225 -0
- package/src/errors.ts +10 -0
- package/src/index.ts +36 -0
- package/src/load-rapier-backend.d.mts +2 -0
- package/src/load-rapier-backend.mjs +8 -0
- package/src/physics-world.ts +165 -0
- package/src/plugin-factory.ts +116 -0
- package/src/system-set.ts +3 -0
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
// Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
|
|
2
|
+
// biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
|
|
3
|
+
//
|
|
4
|
+
// Source files (N=2):
|
|
5
|
+
// - packages/physics/__tests__/component-schema.test.ts
|
|
6
|
+
// - packages/physics/__tests__/enum-helpers.test.ts
|
|
7
|
+
//
|
|
8
|
+
// Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
|
|
9
|
+
// source as ancestorTitles[0]. Top-level imports merged + deduped.
|
|
10
|
+
//
|
|
11
|
+
// Note: merged from __tests__/ into src/__tests__/; import paths adjusted (../src/index → ../index).
|
|
12
|
+
|
|
13
|
+
import { World } from '@forgeax/engine-ecs';
|
|
14
|
+
import { componentDefinition, componentSchema } from '@forgeax/engine-ecs/internal';
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
import type { PhysicsErrorCode } from '../index';
|
|
17
|
+
import {
|
|
18
|
+
COLLIDER_SHAPE_CAPSULE,
|
|
19
|
+
COLLIDER_SHAPE_CUBOID,
|
|
20
|
+
COLLIDER_SHAPE_SPHERE,
|
|
21
|
+
Collider,
|
|
22
|
+
ColliderShapeValue,
|
|
23
|
+
CollidingEntities,
|
|
24
|
+
CollisionEvent,
|
|
25
|
+
colliderShapeFromF32,
|
|
26
|
+
PHYSICS_ERROR_HINTS,
|
|
27
|
+
PhysicsError,
|
|
28
|
+
RIGID_BODY_TYPE_DYNAMIC,
|
|
29
|
+
RIGID_BODY_TYPE_KINEMATIC,
|
|
30
|
+
RIGID_BODY_TYPE_STATIC,
|
|
31
|
+
RigidBody,
|
|
32
|
+
RigidBodyTypeValue,
|
|
33
|
+
rigidBodyTypeFromF32,
|
|
34
|
+
} from '../index';
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
// ─── from component-schema.test.ts ───
|
|
38
|
+
|
|
39
|
+
describe('component-schema.test.ts', () => {
|
|
40
|
+
describe('feat-20260528 M1 t7 physics component schema definitions', () => {
|
|
41
|
+
it('RigidBody is a valid Component token with expected fields', () => {
|
|
42
|
+
expect(RigidBody).toBeDefined();
|
|
43
|
+
expect(RigidBody.name).toBe('RigidBody');
|
|
44
|
+
const schema = componentSchema(RigidBody);
|
|
45
|
+
expect(schema).toHaveProperty('type');
|
|
46
|
+
expect(schema).toHaveProperty('mass');
|
|
47
|
+
expect(schema).toHaveProperty('linearDamping');
|
|
48
|
+
expect(schema).toHaveProperty('angularDamping');
|
|
49
|
+
expect(schema).toHaveProperty('gravityScale');
|
|
50
|
+
expect(schema).toHaveProperty('ccdEnabled');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('Collider is a valid Component token with expected fields', () => {
|
|
54
|
+
expect(Collider).toBeDefined();
|
|
55
|
+
expect(Collider.name).toBe('Collider');
|
|
56
|
+
const schema = componentSchema(Collider);
|
|
57
|
+
expect(schema).toHaveProperty('shape');
|
|
58
|
+
// feat-20260709 M4: cuboid half-extents collapsed from 3 per-axis
|
|
59
|
+
// scalar columns into one inline array<f32,3> column (halfExtents).
|
|
60
|
+
// radius / halfHeight stay scalar (OOS-1: independent sphere/capsule
|
|
61
|
+
// params, not part of the cuboid vec).
|
|
62
|
+
expect(schema).toHaveProperty('halfExtents');
|
|
63
|
+
expect(schema.halfExtents).toBe('array<f32, 3>');
|
|
64
|
+
expect('halfExtentsX' in schema).toBe(false);
|
|
65
|
+
expect('halfExtentsY' in schema).toBe(false);
|
|
66
|
+
expect('halfExtentsZ' in schema).toBe(false);
|
|
67
|
+
expect(schema).toHaveProperty('radius');
|
|
68
|
+
expect(schema).toHaveProperty('halfHeight');
|
|
69
|
+
expect(schema).toHaveProperty('friction');
|
|
70
|
+
expect(schema).toHaveProperty('restitution');
|
|
71
|
+
expect(schema).toHaveProperty('density');
|
|
72
|
+
expect(schema).toHaveProperty('isSensor');
|
|
73
|
+
expect(schema).toHaveProperty('collisionGroups');
|
|
74
|
+
expect(schema).toHaveProperty('solverGroups');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('CollidingEntities is a valid Component token with entities field', () => {
|
|
78
|
+
expect(CollidingEntities).toBeDefined();
|
|
79
|
+
expect(CollidingEntities.name).toBe('CollidingEntities');
|
|
80
|
+
const schema = componentSchema(CollidingEntities);
|
|
81
|
+
expect(schema).toHaveProperty('entities');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('CollisionEvent is the expected constant value', () => {
|
|
85
|
+
expect(CollisionEvent).toBe('__CollisionEvent__');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('PhysicsError is importable and constructable', () => {
|
|
89
|
+
const err = new PhysicsError({
|
|
90
|
+
code: 'backend-not-registered',
|
|
91
|
+
expected: 'PhysicsWorld resource to be registered',
|
|
92
|
+
hint: 'use createApp(canvas, { plugins: [physicsPlugin(...)] })',
|
|
93
|
+
detail: {
|
|
94
|
+
code: 'backend-not-registered',
|
|
95
|
+
attemptedBackend: 'rapier-3d',
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
expect(err.code).toBe('backend-not-registered');
|
|
99
|
+
expect(err.name).toBe('PhysicsError');
|
|
100
|
+
expect(err.hint).toContain('createApp');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('PhysicsErrorCode literal union has all 8 members', () => {
|
|
104
|
+
const codes: Set<string> = new Set();
|
|
105
|
+
const all: PhysicsErrorCode[] = [
|
|
106
|
+
'wasm-load-failed',
|
|
107
|
+
'wasm-simd-unsupported',
|
|
108
|
+
'step-failed',
|
|
109
|
+
'invalid-body-config',
|
|
110
|
+
'body-not-found',
|
|
111
|
+
'collider-not-found',
|
|
112
|
+
'backend-not-registered',
|
|
113
|
+
'teleport-invalid-body-type',
|
|
114
|
+
];
|
|
115
|
+
for (const c of all) {
|
|
116
|
+
codes.add(c);
|
|
117
|
+
}
|
|
118
|
+
expect(codes.size).toBe(8);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('PHYSICS_ERROR_HINTS has all 8 code entries', () => {
|
|
122
|
+
const allCodes: PhysicsErrorCode[] = [
|
|
123
|
+
'wasm-load-failed',
|
|
124
|
+
'wasm-simd-unsupported',
|
|
125
|
+
'step-failed',
|
|
126
|
+
'invalid-body-config',
|
|
127
|
+
'body-not-found',
|
|
128
|
+
'collider-not-found',
|
|
129
|
+
'backend-not-registered',
|
|
130
|
+
'teleport-invalid-body-type',
|
|
131
|
+
];
|
|
132
|
+
for (const code of allCodes) {
|
|
133
|
+
expect(PHYSICS_ERROR_HINTS[code]).toBeDefined();
|
|
134
|
+
expect(PHYSICS_ERROR_HINTS[code].length).toBeGreaterThan(0);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('RigidBodyType literal union has 3 discriminants', () => {
|
|
139
|
+
const types = ['static', 'dynamic', 'kinematic'] as const;
|
|
140
|
+
expect(new Set(types).size).toBe(3);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('ColliderShape literal union has 3 discriminants', () => {
|
|
144
|
+
const shapes = ['cuboid', 'sphere', 'capsule'] as const;
|
|
145
|
+
expect(new Set(shapes).size).toBe(3);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// solo round-24 (P7 residue): the enum fields project their label→value
|
|
149
|
+
// map through FieldReflection, sourced from the SAME `*Value` const map
|
|
150
|
+
// (Derive, don't Duplicate). This is what the editor's describeComponent
|
|
151
|
+
// surfaces so a docs-only AI learns static=0/dynamic=1/kinematic=2 without
|
|
152
|
+
// reading engine source. Revert-to-red: drop `labels:` from the descriptor.
|
|
153
|
+
it('RigidBody.type reflects labels === RigidBodyTypeValue', () => {
|
|
154
|
+
const reflection = RigidBody.fields.type as { readonly labels?: Record<string, number> };
|
|
155
|
+
expect(reflection.labels).toEqual({ ...RigidBodyTypeValue });
|
|
156
|
+
expect(reflection.labels).toEqual({ static: 0, dynamic: 1, kinematic: 2 });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('Collider.shape reflects labels === ColliderShapeValue', () => {
|
|
160
|
+
const reflection = Collider.fields.shape as { readonly labels?: Record<string, number> };
|
|
161
|
+
expect(reflection.labels).toEqual({ ...ColliderShapeValue });
|
|
162
|
+
expect(reflection.labels).toEqual({ cuboid: 0, sphere: 1, capsule: 2 });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('a non-enum field (RigidBody.mass) reflects no labels (control)', () => {
|
|
166
|
+
const reflection = RigidBody.fields.mass as { readonly labels?: Record<string, number> };
|
|
167
|
+
expect(reflection.labels).toBeUndefined();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
{
|
|
174
|
+
// ─── from enum-helpers.test.ts ───
|
|
175
|
+
|
|
176
|
+
describe('enum-helpers.test.ts', () => {
|
|
177
|
+
describe('rigidBodyTypeFromF32', () => {
|
|
178
|
+
it('0 maps to static', () => {
|
|
179
|
+
expect(rigidBodyTypeFromF32(0)).toBe('static');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('1 maps to dynamic', () => {
|
|
183
|
+
expect(rigidBodyTypeFromF32(1)).toBe('dynamic');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('2 maps to kinematic', () => {
|
|
187
|
+
expect(rigidBodyTypeFromF32(2)).toBe('kinematic');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('out-of-range values fall back to static', () => {
|
|
191
|
+
expect(rigidBodyTypeFromF32(-1)).toBe('static');
|
|
192
|
+
expect(rigidBodyTypeFromF32(3)).toBe('static');
|
|
193
|
+
expect(rigidBodyTypeFromF32(99)).toBe('static');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('fractional values fall back to static', () => {
|
|
197
|
+
expect(rigidBodyTypeFromF32(0.5)).toBe('static');
|
|
198
|
+
expect(rigidBodyTypeFromF32(1.5)).toBe('static');
|
|
199
|
+
expect(rigidBodyTypeFromF32(2.9)).toBe('static');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('colliderShapeFromF32', () => {
|
|
204
|
+
it('0 maps to cuboid', () => {
|
|
205
|
+
expect(colliderShapeFromF32(0)).toBe('cuboid');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('1 maps to sphere', () => {
|
|
209
|
+
expect(colliderShapeFromF32(1)).toBe('sphere');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('2 maps to capsule', () => {
|
|
213
|
+
expect(colliderShapeFromF32(2)).toBe('capsule');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('out-of-range values fall back to cuboid', () => {
|
|
217
|
+
expect(colliderShapeFromF32(-1)).toBe('cuboid');
|
|
218
|
+
expect(colliderShapeFromF32(3)).toBe('cuboid');
|
|
219
|
+
expect(colliderShapeFromF32(99)).toBe('cuboid');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('fractional values fall back to cuboid', () => {
|
|
223
|
+
expect(colliderShapeFromF32(0.5)).toBe('cuboid');
|
|
224
|
+
expect(colliderShapeFromF32(1.5)).toBe('cuboid');
|
|
225
|
+
expect(colliderShapeFromF32(2.9)).toBe('cuboid');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('numeric enum constants', () => {
|
|
230
|
+
it('RigidBodyTypeValue has expected numeric values', () => {
|
|
231
|
+
expect(RigidBodyTypeValue.static).toBe(0);
|
|
232
|
+
expect(RigidBodyTypeValue.dynamic).toBe(1);
|
|
233
|
+
expect(RigidBodyTypeValue.kinematic).toBe(2);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('ColliderShapeValue has expected numeric values', () => {
|
|
237
|
+
expect(ColliderShapeValue.cuboid).toBe(0);
|
|
238
|
+
expect(ColliderShapeValue.sphere).toBe(1);
|
|
239
|
+
expect(ColliderShapeValue.capsule).toBe(2);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('individual RigidBody constants match RigidBodyTypeValue', () => {
|
|
243
|
+
expect(RIGID_BODY_TYPE_STATIC).toBe(0);
|
|
244
|
+
expect(RIGID_BODY_TYPE_DYNAMIC).toBe(1);
|
|
245
|
+
expect(RIGID_BODY_TYPE_KINEMATIC).toBe(2);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('individual ColliderShape constants match ColliderShapeValue', () => {
|
|
249
|
+
expect(COLLIDER_SHAPE_CUBOID).toBe(0);
|
|
250
|
+
expect(COLLIDER_SHAPE_SPHERE).toBe(1);
|
|
251
|
+
expect(COLLIDER_SHAPE_CAPSULE).toBe(2);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('layer-2 defaults (m3-1e regression)', () => {
|
|
256
|
+
it('RigidBody defaults include gravityScale=1', () => {
|
|
257
|
+
expect(componentSchema(RigidBody)).toHaveProperty('gravityScale');
|
|
258
|
+
const defaults = componentDefinition(RigidBody).defaults;
|
|
259
|
+
expect(defaults).toBeDefined();
|
|
260
|
+
if (defaults) {
|
|
261
|
+
expect(defaults.gravityScale).toBe(1);
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('RigidBody defaults include mass=1', () => {
|
|
266
|
+
const defaults = componentDefinition(RigidBody).defaults;
|
|
267
|
+
expect(defaults).toBeDefined();
|
|
268
|
+
if (defaults) {
|
|
269
|
+
expect(defaults.mass).toBe(1);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('RigidBody defaults include type=dynamic (1)', () => {
|
|
274
|
+
const defaults = componentDefinition(RigidBody).defaults;
|
|
275
|
+
expect(defaults).toBeDefined();
|
|
276
|
+
if (defaults) {
|
|
277
|
+
expect(defaults.type).toBe(1);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('Collider defaults include density=1', () => {
|
|
282
|
+
expect(componentSchema(Collider)).toHaveProperty('density');
|
|
283
|
+
const defaults = componentDefinition(Collider).defaults;
|
|
284
|
+
expect(defaults).toBeDefined();
|
|
285
|
+
if (defaults) {
|
|
286
|
+
expect(defaults.density).toBe(1);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('Collider defaults include friction=0.5', () => {
|
|
291
|
+
const defaults = componentDefinition(Collider).defaults;
|
|
292
|
+
expect(defaults).toBeDefined();
|
|
293
|
+
if (defaults) {
|
|
294
|
+
expect(defaults.friction).toBe(0.5);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('Collider defaults include restitution=0', () => {
|
|
299
|
+
const defaults = componentDefinition(Collider).defaults;
|
|
300
|
+
expect(defaults).toBeDefined();
|
|
301
|
+
if (defaults) {
|
|
302
|
+
expect(defaults.restitution).toBe(0);
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
{
|
|
310
|
+
// ─── feat-20260709 M4 / w17: Collider halfExtents vec-collapse ───
|
|
311
|
+
//
|
|
312
|
+
// AC-01 / E1 / AC-04: halfExtents is array<f32,3> with explicit layer-2
|
|
313
|
+
// default [0.5,0.5,0.5] (the array layer-3 fallback is all-zero, so the
|
|
314
|
+
// non-zero default MUST be declared explicitly). Spawn-omit resolves to the
|
|
315
|
+
// same value the old per-axis scalar defaults produced. radius/halfHeight
|
|
316
|
+
// stay scalar (OOS-1). TDD red until w19 lands the schema collapse.
|
|
317
|
+
|
|
318
|
+
describe('collider-halfextents-vec.test.ts', () => {
|
|
319
|
+
it('Collider.halfExtents is array<f32,3> with explicit layer-2 default [0.5,0.5,0.5]', () => {
|
|
320
|
+
expect(componentSchema(Collider).halfExtents).toBe('array<f32, 3>');
|
|
321
|
+
expect(Array.from(Collider.fields.halfExtents.default as Float32Array)).toEqual([
|
|
322
|
+
0.5, 0.5, 0.5,
|
|
323
|
+
]);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('Collider per-axis scalar keys are gone; radius/halfHeight stay scalar (OOS-1)', () => {
|
|
327
|
+
expect('halfExtentsX' in componentSchema(Collider)).toBe(false);
|
|
328
|
+
expect('halfExtentsY' in componentSchema(Collider)).toBe(false);
|
|
329
|
+
expect('halfExtentsZ' in componentSchema(Collider)).toBe(false);
|
|
330
|
+
expect(componentSchema(Collider).radius).toBe('f32');
|
|
331
|
+
expect(componentSchema(Collider).halfHeight).toBe('f32');
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('E1: Collider spawned with halfExtents omitted resolves to [0.5,0.5,0.5]', () => {
|
|
335
|
+
const world = new World();
|
|
336
|
+
const e = world
|
|
337
|
+
.spawn({ component: Collider, data: { shape: ColliderShapeValue.cuboid } })
|
|
338
|
+
.unwrap();
|
|
339
|
+
const row = world.get(e, Collider).unwrap();
|
|
340
|
+
expect(Array.from(row.halfExtents)).toEqual([0.5, 0.5, 0.5]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('E1: Collider spawned with explicit halfExtents carries the array through', () => {
|
|
344
|
+
const world = new World();
|
|
345
|
+
const e = world
|
|
346
|
+
.spawn({
|
|
347
|
+
component: Collider,
|
|
348
|
+
data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 2, 3] },
|
|
349
|
+
})
|
|
350
|
+
.unwrap();
|
|
351
|
+
const row = world.get(e, Collider).unwrap();
|
|
352
|
+
expect(Array.from(row.halfExtents)).toEqual([1, 2, 3]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('AC-04: residual halfExtentsX at a spawn call-site is a compile error', () => {
|
|
356
|
+
const world = new World();
|
|
357
|
+
world.spawn({
|
|
358
|
+
component: Collider,
|
|
359
|
+
// @ts-expect-error halfExtentsX/Y/Z were collapsed into the halfExtents array.
|
|
360
|
+
data: { shape: ColliderShapeValue.cuboid, halfExtents: [1, 1, 1], halfExtentsX: 1 },
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @forgeax/engine-physics — CollisionEvent ECS Event token placeholder.
|
|
2
|
+
//
|
|
3
|
+
// Emitted by physics tick systems during the Writeback phase.
|
|
4
|
+
// Two states only: 'started' (new contact) and 'stopped' (separated).
|
|
5
|
+
// No 'continued' event — use CollidingEntities component for ongoing contacts
|
|
6
|
+
// (plan-strategy D-3).
|
|
7
|
+
|
|
8
|
+
import type { Vec3 } from '@forgeax/engine-math';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Collision event payload — per-contact-pair event emitted during Writeback.
|
|
12
|
+
*
|
|
13
|
+
* `type: 'started'` — two colliders just began touching.
|
|
14
|
+
* `type: 'stopped'` — two colliders just separated.
|
|
15
|
+
*/
|
|
16
|
+
export interface CollisionEventPayload {
|
|
17
|
+
type: 'started' | 'stopped';
|
|
18
|
+
entityA: number;
|
|
19
|
+
entityB: number;
|
|
20
|
+
contactPoint: Vec3;
|
|
21
|
+
contactNormal: Vec3;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* CollisionEvent constant — identifies the collision event type.
|
|
26
|
+
* Backend systems push CollisionEventPayload instances into the event queue
|
|
27
|
+
* during the Writeback phase; user systems drain via query.
|
|
28
|
+
*
|
|
29
|
+
* The backing ECS event infrastructure (Event<T> generic + world.drainEvent)
|
|
30
|
+
* is deferred to a future feat. For M1, this is a type-only contract.
|
|
31
|
+
*/
|
|
32
|
+
export const CollisionEvent = '__CollisionEvent__' as const;
|
|
33
|
+
|
|
34
|
+
/** Type-level identifier for the CollisionEvent event channel. */
|
|
35
|
+
export type CollisionEvent = typeof CollisionEvent;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// @forgeax/engine-physics — ECS Component schemas.
|
|
2
|
+
//
|
|
3
|
+
// RigidBody and Collider are the two user-facing entry points; AI users
|
|
4
|
+
// spawn entities with these components to opt into physics simulation.
|
|
5
|
+
// CollidingEntities is the runtime set-query component for continuous
|
|
6
|
+
// collision status (started/stopped model, no 'continued' event).
|
|
7
|
+
|
|
8
|
+
import { type Component, defineComponent, type World } from '@forgeax/engine-ecs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* RigidBody motion type — 3-state discriminant mirroring Rapier's
|
|
12
|
+
* Dynamic / Fixed / KinematicPositionBased triplet.
|
|
13
|
+
*
|
|
14
|
+
* `'static'`: infinite mass, never moves (Rapier Fixed).
|
|
15
|
+
* `'dynamic'`: driven by forces, gravity, collisions (Rapier Dynamic).
|
|
16
|
+
* `'kinematic'`: user-controlled position, velocity derived by engine
|
|
17
|
+
* (Rapier KinematicPositionBased).
|
|
18
|
+
*/
|
|
19
|
+
export type RigidBodyType = 'static' | 'dynamic' | 'kinematic';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Collider shape discriminant — 3 AI-friendly shape names.
|
|
23
|
+
*
|
|
24
|
+
* `'cuboid'`: box shape defined by half-extents (x, y, z).
|
|
25
|
+
* `'sphere'`: sphere defined by radius.
|
|
26
|
+
* `'capsule'`: capsule defined by half-height + radius.
|
|
27
|
+
*
|
|
28
|
+
* Named `'sphere'` not `'ball'` per plan-strategy D-5: AI users see
|
|
29
|
+
* the familiar geometric term; backend maps to Rapier `ColliderDesc.ball()`.
|
|
30
|
+
*/
|
|
31
|
+
export type ColliderShape = 'cuboid' | 'sphere' | 'capsule';
|
|
32
|
+
|
|
33
|
+
// ─── D-3: numeric enum constants + narrowing helpers ─────────────────────
|
|
34
|
+
//
|
|
35
|
+
// Aligned with `packages/runtime/src/components/camera.ts:41-53`
|
|
36
|
+
// `cameraProjectionFromF32` pattern. The ECS `enum` field maps to `number`
|
|
37
|
+
// (Uint32Array column); these constants let AI users write
|
|
38
|
+
// `{ type: RigidBodyTypeValue.dynamic }` instead of bare magic numbers,
|
|
39
|
+
// and the narrowing helpers let backends switch cleanly on the string union.
|
|
40
|
+
//
|
|
41
|
+
// Declared BEFORE the RigidBody / Collider components so each component's enum
|
|
42
|
+
// field descriptor can reference the SAME `*Value` map as its `labels`
|
|
43
|
+
// (Derive, don't Duplicate — one object is both the AI-facing const AND the
|
|
44
|
+
// schema-projected label map that `describeComponent` surfaces).
|
|
45
|
+
|
|
46
|
+
/** Numeric value for static rigid body (Rapier Fixed). */
|
|
47
|
+
export const RIGID_BODY_TYPE_STATIC = 0;
|
|
48
|
+
/** Numeric value for dynamic rigid body (Rapier Dynamic). */
|
|
49
|
+
export const RIGID_BODY_TYPE_DYNAMIC = 1;
|
|
50
|
+
/** Numeric value for kinematic rigid body (Rapier KinematicPositionBased). */
|
|
51
|
+
export const RIGID_BODY_TYPE_KINEMATIC = 2;
|
|
52
|
+
|
|
53
|
+
export const RigidBodyTypeValue = {
|
|
54
|
+
static: RIGID_BODY_TYPE_STATIC,
|
|
55
|
+
dynamic: RIGID_BODY_TYPE_DYNAMIC,
|
|
56
|
+
kinematic: RIGID_BODY_TYPE_KINEMATIC,
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
export function rigidBodyTypeFromF32(n: number): RigidBodyType {
|
|
60
|
+
if (n === RIGID_BODY_TYPE_DYNAMIC) return 'dynamic';
|
|
61
|
+
if (n === RIGID_BODY_TYPE_KINEMATIC) return 'kinematic';
|
|
62
|
+
return 'static';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Numeric value for cuboid collider shape. */
|
|
66
|
+
export const COLLIDER_SHAPE_CUBOID = 0;
|
|
67
|
+
/** Numeric value for sphere collider shape. */
|
|
68
|
+
export const COLLIDER_SHAPE_SPHERE = 1;
|
|
69
|
+
/** Numeric value for capsule collider shape. */
|
|
70
|
+
export const COLLIDER_SHAPE_CAPSULE = 2;
|
|
71
|
+
|
|
72
|
+
export const ColliderShapeValue = {
|
|
73
|
+
cuboid: COLLIDER_SHAPE_CUBOID,
|
|
74
|
+
sphere: COLLIDER_SHAPE_SPHERE,
|
|
75
|
+
capsule: COLLIDER_SHAPE_CAPSULE,
|
|
76
|
+
} as const;
|
|
77
|
+
|
|
78
|
+
export function colliderShapeFromF32(n: number): ColliderShape {
|
|
79
|
+
if (n === COLLIDER_SHAPE_SPHERE) return 'sphere';
|
|
80
|
+
if (n === COLLIDER_SHAPE_CAPSULE) return 'capsule';
|
|
81
|
+
return 'cuboid';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* ECS Component: rigid body physics properties.
|
|
86
|
+
*
|
|
87
|
+
* AI user entry point — spawn with `world.spawn(RigidBody({ type: 'dynamic' }))`
|
|
88
|
+
* to opt an entity into physics simulation.
|
|
89
|
+
*
|
|
90
|
+
* | Field | Type | Default | Purpose |
|
|
91
|
+
* |:--|:--|:--|:--|
|
|
92
|
+
* | `type` | `RigidBodyType` | `'dynamic'` | Motion type discriminant |
|
|
93
|
+
* | `mass` | `number` | `1.0` | Linear mass (> 0 for dynamic) |
|
|
94
|
+
* | `linearDamping` | `number` | `0.0` | Velocity damping factor [0, 1] |
|
|
95
|
+
* | `angularDamping` | `number` | `0.0` | Angular velocity damping [0, 1] |
|
|
96
|
+
* | `gravityScale` | `number` | `1.0` | Per-body gravity multiplier |
|
|
97
|
+
* | `ccdEnabled` | `boolean` | `false` | Continuous collision detection |
|
|
98
|
+
*
|
|
99
|
+
* `type` declares `labels: RigidBodyTypeValue` so `describeComponent` projects
|
|
100
|
+
* the `static=0 / dynamic=1 / kinematic=2` map through the front door — an AI
|
|
101
|
+
* learns the legal variants from the schema, not from engine source.
|
|
102
|
+
*/
|
|
103
|
+
export const RigidBody = defineComponent('RigidBody', {
|
|
104
|
+
type: { type: 'enum', default: RIGID_BODY_TYPE_DYNAMIC, labels: RigidBodyTypeValue },
|
|
105
|
+
mass: { type: 'f32', default: 1 },
|
|
106
|
+
linearDamping: { type: 'f32', default: 0 },
|
|
107
|
+
angularDamping: { type: 'f32', default: 0 },
|
|
108
|
+
gravityScale: { type: 'f32', default: 1 },
|
|
109
|
+
ccdEnabled: { type: 'bool', default: false },
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* ECS Component: collision geometry.
|
|
114
|
+
*
|
|
115
|
+
* Spawn alongside RigidBody to give an entity a collision shape. Entities
|
|
116
|
+
* with Collider but no RigidBody are treated as static colliders (Rapier
|
|
117
|
+
* native behavior — collider without parent body is fixed): `physicsSyncBackend`
|
|
118
|
+
* synthesizes an implicit static body for them, so a bare-Collider floor/wall is
|
|
119
|
+
* simulated as immovable level geometry — the natural way to author static
|
|
120
|
+
* scenery without a redundant `RigidBody{static}`.
|
|
121
|
+
*
|
|
122
|
+
* | Field | Type | Default | Purpose |
|
|
123
|
+
* |:--|:--|:--|:--|
|
|
124
|
+
* | `shape` | `ColliderShape` | — | Shape discriminant |
|
|
125
|
+
* | `halfExtents` | `[number, number, number]` | `[0.5, 0.5, 0.5]` | Cuboid half-width/height/depth |
|
|
126
|
+
* | `radius` | `number` | `0.5` | Sphere / capsule radius |
|
|
127
|
+
* | `halfHeight` | `number` | `0.5` | Capsule half-height |
|
|
128
|
+
* | `friction` | `number` | `0.5` | Coulomb friction coefficient |
|
|
129
|
+
* | `restitution` | `number` | `0.0` | Elasticity (1.0 = perfect bounce) |
|
|
130
|
+
* | `density` | `number` | `1.0` | Mass density (alternative to mass) |
|
|
131
|
+
* | `isSensor` | `bool` | `false` | Sensor mode (detect, no physical response) |
|
|
132
|
+
* | `collisionGroups` | `u32` | `0x0001_FFFF` | 32-bit packed membership/filter |
|
|
133
|
+
* | `solverGroups` | `u32` | `0xFFFF_FFFF` | 32-bit packed constraint groups |
|
|
134
|
+
*/
|
|
135
|
+
export const Collider = defineComponent('Collider', {
|
|
136
|
+
shape: { type: 'enum', default: COLLIDER_SHAPE_CUBOID, labels: ColliderShapeValue },
|
|
137
|
+
// feat-20260709 M4: cuboid half-extents collapsed from 3 per-axis scalar
|
|
138
|
+
// columns into one inline array<f32,3> column. Explicit layer-2 default
|
|
139
|
+
// (the array layer-3 fallback is all-zero, which would give a degenerate
|
|
140
|
+
// zero-size box). radius/halfHeight stay scalar (OOS-1: independent
|
|
141
|
+
// sphere/capsule params, not part of the cuboid vec).
|
|
142
|
+
halfExtents: { type: 'array<f32, 3>', default: new Float32Array([0.5, 0.5, 0.5]) },
|
|
143
|
+
radius: { type: 'f32', default: 0.5 },
|
|
144
|
+
halfHeight: { type: 'f32', default: 0.5 },
|
|
145
|
+
friction: { type: 'f32', default: 0.5 },
|
|
146
|
+
restitution: { type: 'f32', default: 0 },
|
|
147
|
+
density: { type: 'f32', default: 1 },
|
|
148
|
+
isSensor: { type: 'bool', default: false },
|
|
149
|
+
collisionGroups: { type: 'u32', default: 0x0001_ffff },
|
|
150
|
+
solverGroups: { type: 'u32', default: 0xffff_ffff },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* ECS Component: kinematic character controller tuning + output state.
|
|
155
|
+
*
|
|
156
|
+
* Spawn alongside a `RigidBody({ type: 'kinematic' })` + `Collider` to opt an
|
|
157
|
+
* entity into collision-aware movement via `PhysicsWorld.moveAndSlide`. The
|
|
158
|
+
* tuning fields are stable character properties; `grounded` is written back by
|
|
159
|
+
* the engine after each `moveAndSlide` (game code reads it, never writes it).
|
|
160
|
+
*
|
|
161
|
+
* All fields are flat scalars in engine units (degrees, world-space distance) —
|
|
162
|
+
* no Rapier types leak through. Slope angles use the `Deg` suffix to make the
|
|
163
|
+
* unit explicit; the backend translates degrees to radians. A single field
|
|
164
|
+
* carries both the on/off switch and the value: `autoStepMaxHeight === 0`
|
|
165
|
+
* disables auto-step, `snapToGroundDist === 0` disables snap-to-ground.
|
|
166
|
+
*
|
|
167
|
+
* 2D and 3D reuse this same component (plan-strategy D-9): every field is a
|
|
168
|
+
* dimension-agnostic scalar, so no separate 2D component is needed.
|
|
169
|
+
*
|
|
170
|
+
* | Field | Type | Default | Purpose |
|
|
171
|
+
* |:--|:--|:--|:--|
|
|
172
|
+
* | `offset` | `f32` | `0.01` | Skin thickness, prevents penetration |
|
|
173
|
+
* | `maxSlopeClimbDeg` | `f32` | `45` | Max climbable slope angle (degrees) |
|
|
174
|
+
* | `minSlopeSlideDeg` | `f32` | `30` | Slope angle past which sliding starts (degrees) |
|
|
175
|
+
* | `autoStepMaxHeight` | `f32` | `0.3` | Max auto-step height (0 = off) |
|
|
176
|
+
* | `autoStepMinWidth` | `f32` | `0.2` | Min step width to be steppable |
|
|
177
|
+
* | `snapToGroundDist` | `f32` | `0.2` | Downhill ground-snap distance (0 = off) |
|
|
178
|
+
* | `grounded` | `bool` | `false` | Engine-written: grounded after last move |
|
|
179
|
+
*/
|
|
180
|
+
export const CharacterController = defineComponent('CharacterController', {
|
|
181
|
+
offset: { type: 'f32', default: 0.01 },
|
|
182
|
+
maxSlopeClimbDeg: { type: 'f32', default: 45 },
|
|
183
|
+
minSlopeSlideDeg: { type: 'f32', default: 30 },
|
|
184
|
+
autoStepMaxHeight: { type: 'f32', default: 0.3 },
|
|
185
|
+
autoStepMinWidth: { type: 'f32', default: 0.2 },
|
|
186
|
+
snapToGroundDist: { type: 'f32', default: 0.2 },
|
|
187
|
+
grounded: { type: 'bool', default: false, transient: true },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* ECS Component: set of entities currently colliding with the holder entity.
|
|
192
|
+
*
|
|
193
|
+
* Maintained by the physics tick systems — entities are added on collision
|
|
194
|
+
* start (`CollisionEvent.started`) and removed on collision stop
|
|
195
|
+
* (`CollisionEvent.stopped`). AI users query this component to know whose
|
|
196
|
+
* colliders overlap right now without consuming per-frame events.
|
|
197
|
+
*
|
|
198
|
+
* This is the `'continued'` equivalent — no repeated per-frame events,
|
|
199
|
+
* one component query per frame exposes the full active contact set
|
|
200
|
+
* (plan-strategy D-3: CollidingEntities set-query mode).
|
|
201
|
+
*/
|
|
202
|
+
export const CollidingEntities = defineComponent(
|
|
203
|
+
'CollidingEntities',
|
|
204
|
+
{
|
|
205
|
+
entities: { type: 'array<entity>' },
|
|
206
|
+
},
|
|
207
|
+
{ transient: true },
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
const PHYSICS_COMPONENTS: readonly Component[] = [
|
|
211
|
+
CharacterController,
|
|
212
|
+
Collider,
|
|
213
|
+
CollidingEntities,
|
|
214
|
+
RigidBody,
|
|
215
|
+
];
|
|
216
|
+
|
|
217
|
+
/** Install the physics component vocabulary in a World and release its leases on teardown. */
|
|
218
|
+
export function registerPhysicsComponents(world: World): () => void {
|
|
219
|
+
const leases = PHYSICS_COMPONENTS.map((component) =>
|
|
220
|
+
world.components.register(component).unwrap(),
|
|
221
|
+
);
|
|
222
|
+
return () => {
|
|
223
|
+
for (let index = leases.length - 1; index >= 0; index -= 1) leases[index]?.dispose();
|
|
224
|
+
};
|
|
225
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @forgeax/engine-physics — PhysicsError wrapper + re-exports from engine-types.
|
|
2
|
+
//
|
|
3
|
+
// PhysicsError, PhysicsErrorCode, PhysicsErrorDetail, and PHYSICS_ERROR_HINTS
|
|
4
|
+
// are registered in @forgeax/engine-types (SSOT, parallel to AssetError /
|
|
5
|
+
// AudioError / GltfError). This module re-exports them under the physics
|
|
6
|
+
// package namespace so AI users import from `@forgeax/engine-physics` without
|
|
7
|
+
// tracing to engine-types.
|
|
8
|
+
|
|
9
|
+
export type { PhysicsErrorCode, PhysicsErrorDetail } from '@forgeax/engine-types';
|
|
10
|
+
export { PHYSICS_ERROR_HINTS, PhysicsError } from '@forgeax/engine-types';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @forgeax/engine-physics — physics interface package barrel.
|
|
2
|
+
//
|
|
3
|
+
// ECS component schemas (RigidBody / Collider / CollisionEvent),
|
|
4
|
+
// PhysicsWorld Resource interface, PhysicsErrorCode union.
|
|
5
|
+
//
|
|
6
|
+
// Dependencies: @forgeax/engine-ecs (Component / event token),
|
|
7
|
+
// @forgeax/engine-math (Vec2 / Vec3 / Quat),
|
|
8
|
+
// @forgeax/engine-types (type utilities).
|
|
9
|
+
|
|
10
|
+
export type { CollisionEventPayload } from './collision-event';
|
|
11
|
+
export { CollisionEvent } from './collision-event';
|
|
12
|
+
export type { ColliderShape, RigidBodyType } from './components';
|
|
13
|
+
export {
|
|
14
|
+
CharacterController,
|
|
15
|
+
COLLIDER_SHAPE_CAPSULE,
|
|
16
|
+
COLLIDER_SHAPE_CUBOID,
|
|
17
|
+
COLLIDER_SHAPE_SPHERE,
|
|
18
|
+
Collider,
|
|
19
|
+
ColliderShapeValue,
|
|
20
|
+
CollidingEntities,
|
|
21
|
+
colliderShapeFromF32,
|
|
22
|
+
RIGID_BODY_TYPE_DYNAMIC,
|
|
23
|
+
RIGID_BODY_TYPE_KINEMATIC,
|
|
24
|
+
RIGID_BODY_TYPE_STATIC,
|
|
25
|
+
RigidBody,
|
|
26
|
+
RigidBodyTypeValue,
|
|
27
|
+
registerPhysicsComponents,
|
|
28
|
+
rigidBodyTypeFromF32,
|
|
29
|
+
} from './components';
|
|
30
|
+
export type { PhysicsErrorCode, PhysicsErrorDetail } from './errors';
|
|
31
|
+
|
|
32
|
+
export { PHYSICS_ERROR_HINTS, PhysicsError } from './errors';
|
|
33
|
+
export type { PhysicsWorld, PhysicsWorld2D, RaycastHit, RaycastHit2D } from './physics-world';
|
|
34
|
+
export type { PhysicsBackend } from './plugin-factory';
|
|
35
|
+
export { physicsPlugin } from './plugin-factory';
|
|
36
|
+
export { PhysicsSet } from './system-set';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Keep literal specifiers visible to bundlers without pulling backend declarations into the physics TypeScript graph.
|
|
2
|
+
export function loadRapier3DBackend() {
|
|
3
|
+
return import('@forgeax/engine-physics-rapier3d');
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function loadRapier2DBackend() {
|
|
7
|
+
return import('@forgeax/engine-physics-rapier2d');
|
|
8
|
+
}
|