@flighthq/physics2d 0.3.0-edge.1458.0c01b63
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/dist/colliderTransform.d.ts +10 -0
- package/dist/colliderTransform.d.ts.map +1 -0
- package/dist/colliderTransform.js +137 -0
- package/dist/colliderTransform.js.map +1 -0
- package/dist/contract.d.ts +9 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +9 -0
- package/dist/contract.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/islands.d.ts +38 -0
- package/dist/islands.d.ts.map +1 -0
- package/dist/islands.js +159 -0
- package/dist/islands.js.map +1 -0
- package/dist/jointRegistry.d.ts +6 -0
- package/dist/jointRegistry.d.ts.map +1 -0
- package/dist/jointRegistry.js +78 -0
- package/dist/jointRegistry.js.map +1 -0
- package/dist/joints.d.ts +39 -0
- package/dist/joints.d.ts.map +1 -0
- package/dist/joints.js +561 -0
- package/dist/joints.js.map +1 -0
- package/dist/massProperties.d.ts +4 -0
- package/dist/massProperties.d.ts.map +1 -0
- package/dist/massProperties.js +146 -0
- package/dist/massProperties.js.map +1 -0
- package/dist/solver.d.ts +7 -0
- package/dist/solver.d.ts.map +1 -0
- package/dist/solver.js +129 -0
- package/dist/solver.js.map +1 -0
- package/dist/step.d.ts +3 -0
- package/dist/step.d.ts.map +1 -0
- package/dist/step.js +414 -0
- package/dist/step.js.map +1 -0
- package/dist/world.d.ts +10 -0
- package/dist/world.d.ts.map +1 -0
- package/dist/world.js +133 -0
- package/dist/world.js.map +1 -0
- package/package.json +49 -0
- package/src/colliderTransform.test.ts +115 -0
- package/src/islands.test.ts +262 -0
- package/src/jointRegistry.test.ts +205 -0
- package/src/joints.test.ts +951 -0
- package/src/massProperties.test.ts +169 -0
- package/src/solver.test.ts +186 -0
- package/src/step.test.ts +745 -0
- package/src/world.test.ts +142 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { CollisionShape } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
createPhysics2DColliderWorldShape,
|
|
6
|
+
updatePhysics2DColliderWorldShape,
|
|
7
|
+
writePhysics2DColliderBounds,
|
|
8
|
+
} from './colliderTransform';
|
|
9
|
+
import { createPhysics2DCollider, createRigidBody2D } from './world';
|
|
10
|
+
|
|
11
|
+
const STONE = { density: 1, friction: 0.3, restitution: 0 };
|
|
12
|
+
|
|
13
|
+
function bounds() {
|
|
14
|
+
return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('createPhysics2DColliderWorldShape', () => {
|
|
18
|
+
it('promotes an axis-aligned box to an oriented box, since a rotated box is not axis-aligned', () => {
|
|
19
|
+
expect(createPhysics2DColliderWorldShape({ kind: 'aabb', minX: 0, minY: 0, maxX: 1, maxY: 1 }).kind).toBe('obb');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps the kind for shapes a rigid transform preserves', () => {
|
|
23
|
+
expect(createPhysics2DColliderWorldShape({ kind: 'circle', x: 0, y: 0, radius: 1 }).kind).toBe('circle');
|
|
24
|
+
expect(createPhysics2DColliderWorldShape({ kind: 'polygon', points: [0, 0, 1, 0, 0, 1] }).kind).toBe('polygon');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('gives a polygon its own points array so the per-step transform cannot write through to the local shape', () => {
|
|
28
|
+
const local: CollisionShape = { kind: 'polygon', points: [0, 0, 1, 0, 0, 1] };
|
|
29
|
+
const world = createPhysics2DColliderWorldShape(local);
|
|
30
|
+
expect(world.kind === 'polygon' && world.points).not.toBe(local.points);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe('updatePhysics2DColliderWorldShape', () => {
|
|
35
|
+
it('rotates and translates a circle offset from the body origin', () => {
|
|
36
|
+
const collider = createPhysics2DCollider({ kind: 'circle', x: 1, y: 0, radius: 0.5 }, STONE);
|
|
37
|
+
const body = createRigidBody2D('dynamic', 10, 5, Math.PI / 2);
|
|
38
|
+
updatePhysics2DColliderWorldShape(collider, body);
|
|
39
|
+
expect(collider.world.kind).toBe('circle');
|
|
40
|
+
if (collider.world.kind !== 'circle') return;
|
|
41
|
+
expect(collider.world.x).toBeCloseTo(10);
|
|
42
|
+
expect(collider.world.y).toBeCloseTo(6);
|
|
43
|
+
expect(collider.world.radius).toBe(0.5);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('carries the body angle onto the promoted oriented box rather than growing its extents', () => {
|
|
47
|
+
const collider = createPhysics2DCollider({ kind: 'aabb', minX: -1, minY: -0.5, maxX: 1, maxY: 0.5 }, STONE);
|
|
48
|
+
const body = createRigidBody2D('dynamic', 0, 0, 0.7);
|
|
49
|
+
updatePhysics2DColliderWorldShape(collider, body);
|
|
50
|
+
expect(collider.world.kind).toBe('obb');
|
|
51
|
+
if (collider.world.kind !== 'obb') return;
|
|
52
|
+
expect(collider.world.rotation).toBeCloseTo(0.7);
|
|
53
|
+
expect(collider.world.halfW).toBeCloseTo(1);
|
|
54
|
+
expect(collider.world.halfH).toBeCloseTo(0.5);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('composes the body angle with an oriented box collider own rotation', () => {
|
|
58
|
+
const collider = createPhysics2DCollider({ kind: 'obb', x: 0, y: 0, halfW: 1, halfH: 1, rotation: 0.2 }, STONE);
|
|
59
|
+
const body = createRigidBody2D('dynamic', 0, 0, 0.5);
|
|
60
|
+
updatePhysics2DColliderWorldShape(collider, body);
|
|
61
|
+
if (collider.world.kind !== 'obb') return;
|
|
62
|
+
expect(collider.world.rotation).toBeCloseTo(0.7);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('transforms every polygon vertex in place', () => {
|
|
66
|
+
const collider = createPhysics2DCollider({ kind: 'polygon', points: [0, 0, 1, 0, 0, 1] }, STONE);
|
|
67
|
+
const body = createRigidBody2D('dynamic', 3, 4, 0);
|
|
68
|
+
updatePhysics2DColliderWorldShape(collider, body);
|
|
69
|
+
if (collider.world.kind !== 'polygon') return;
|
|
70
|
+
expect(Array.from(collider.world.points)).toEqual([3, 4, 4, 4, 3, 5]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('writePhysics2DColliderBounds', () => {
|
|
75
|
+
it('bounds a circle by its radius', () => {
|
|
76
|
+
const collider = createPhysics2DCollider({ kind: 'circle', x: 0, y: 0, radius: 2 }, STONE);
|
|
77
|
+
updatePhysics2DColliderWorldShape(collider, createRigidBody2D('dynamic', 1, 1));
|
|
78
|
+
const out = bounds();
|
|
79
|
+
writePhysics2DColliderBounds(collider, out);
|
|
80
|
+
expect(out).toEqual({ minX: -1, minY: -1, maxX: 3, maxY: 3 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('grows an oriented box bound as it turns, and returns to its extents at a quarter turn', () => {
|
|
84
|
+
// The bound of a rotated box is larger than the box; at 45 degrees it is largest, and at 90 it is the
|
|
85
|
+
// original extents with width and height exchanged. Pinned because a bound that did not grow would
|
|
86
|
+
// let the broadphase miss a genuine overlap.
|
|
87
|
+
const collider = createPhysics2DCollider({ kind: 'aabb', minX: -1, minY: -0.5, maxX: 1, maxY: 0.5 }, STONE);
|
|
88
|
+
const out = bounds();
|
|
89
|
+
|
|
90
|
+
updatePhysics2DColliderWorldShape(collider, createRigidBody2D('dynamic', 0, 0, Math.PI / 4));
|
|
91
|
+
writePhysics2DColliderBounds(collider, out);
|
|
92
|
+
expect(out.maxX).toBeGreaterThan(1);
|
|
93
|
+
|
|
94
|
+
updatePhysics2DColliderWorldShape(collider, createRigidBody2D('dynamic', 0, 0, Math.PI / 2));
|
|
95
|
+
writePhysics2DColliderBounds(collider, out);
|
|
96
|
+
expect(out.maxX).toBeCloseTo(0.5);
|
|
97
|
+
expect(out.maxY).toBeCloseTo(1);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('bounds a polygon by its extreme vertices', () => {
|
|
101
|
+
const collider = createPhysics2DCollider({ kind: 'polygon', points: [0, 0, 4, 1, 2, 5] }, STONE);
|
|
102
|
+
updatePhysics2DColliderWorldShape(collider, createRigidBody2D('dynamic', 0, 0));
|
|
103
|
+
const out = bounds();
|
|
104
|
+
writePhysics2DColliderBounds(collider, out);
|
|
105
|
+
expect(out).toEqual({ minX: 0, minY: 0, maxX: 4, maxY: 5 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('gives an area-less shape empty bounds rather than infinities', () => {
|
|
109
|
+
const collider = createPhysics2DCollider({ kind: 'point', x: 1, y: 1 }, STONE);
|
|
110
|
+
const out = bounds();
|
|
111
|
+
writePhysics2DColliderBounds(collider, out);
|
|
112
|
+
expect(Number.isFinite(out.minX)).toBe(true);
|
|
113
|
+
expect(Number.isFinite(out.maxY)).toBe(true);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { Physics2DWorld, RigidBody2D } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { isRigidBody2DPairAwake, updatePhysics2DSleep, wakePhysics2DBody } from './islands';
|
|
5
|
+
import { addPhysics2DBody, createPhysics2DWorld, createRigidBody2D } from './world';
|
|
6
|
+
|
|
7
|
+
// A contact carries no mass properties and no geometry that sleep cares about — only which two bodies it
|
|
8
|
+
// links — so the island tests build the linkage directly rather than dropping shapes on each other and
|
|
9
|
+
// hoping the narrow phase produces the pair under test.
|
|
10
|
+
function link(world: Physics2DWorld, a: Readonly<RigidBody2D>, b: Readonly<RigidBody2D>, sensor = false): void {
|
|
11
|
+
world.contacts.push({
|
|
12
|
+
bodyA: Math.min(a.index, b.index),
|
|
13
|
+
bodyB: Math.max(a.index, b.index),
|
|
14
|
+
colliderA: 0,
|
|
15
|
+
colliderB: 0,
|
|
16
|
+
normalX: 0,
|
|
17
|
+
normalY: 1,
|
|
18
|
+
pointCount: 0,
|
|
19
|
+
points: [],
|
|
20
|
+
friction: 0,
|
|
21
|
+
restitution: 0,
|
|
22
|
+
sensor,
|
|
23
|
+
touching: true,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function still(world: Physics2DWorld, x = 0): RigidBody2D {
|
|
28
|
+
return addPhysics2DBody(world, createRigidBody2D('dynamic', x, 0));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function moving(world: Physics2DWorld, x = 0): RigidBody2D {
|
|
32
|
+
const body = addPhysics2DBody(world, createRigidBody2D('dynamic', x, 0));
|
|
33
|
+
body.velocityX = 5;
|
|
34
|
+
return body;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('isRigidBody2DPairAwake', () => {
|
|
38
|
+
it('reports a pair awake when one end is an awake dynamic body', () => {
|
|
39
|
+
const world = createPhysics2DWorld();
|
|
40
|
+
const anchor = addPhysics2DBody(world, createRigidBody2D('static', 0, 0));
|
|
41
|
+
const crate = still(world);
|
|
42
|
+
|
|
43
|
+
expect(isRigidBody2DPairAwake(anchor, crate)).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('reports a pair asleep when both ends are sleeping', () => {
|
|
47
|
+
const world = createPhysics2DWorld();
|
|
48
|
+
const first = still(world, 0);
|
|
49
|
+
const second = still(world, 1);
|
|
50
|
+
first.sleeping = true;
|
|
51
|
+
second.sleeping = true;
|
|
52
|
+
|
|
53
|
+
expect(isRigidBody2DPairAwake(first, second)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reports a sleeper anchored to static scenery asleep', () => {
|
|
57
|
+
const world = createPhysics2DWorld();
|
|
58
|
+
const anchor = addPhysics2DBody(world, createRigidBody2D('static', 0, 0));
|
|
59
|
+
const crate = still(world);
|
|
60
|
+
crate.sleeping = true;
|
|
61
|
+
|
|
62
|
+
expect(isRigidBody2DPairAwake(anchor, crate)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('updatePhysics2DSleep', () => {
|
|
67
|
+
it('sleeps a body that has been still for the full timeToSleep', () => {
|
|
68
|
+
const world = createPhysics2DWorld();
|
|
69
|
+
const crate = still(world);
|
|
70
|
+
|
|
71
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
72
|
+
|
|
73
|
+
expect(crate.sleeping).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('leaves a still body awake until timeToSleep has elapsed', () => {
|
|
77
|
+
const world = createPhysics2DWorld();
|
|
78
|
+
const crate = still(world);
|
|
79
|
+
|
|
80
|
+
updatePhysics2DSleep(world, world.config.timeToSleep - 0.01);
|
|
81
|
+
|
|
82
|
+
expect(crate.sleeping).toBe(false);
|
|
83
|
+
expect(crate.sleepTimer).toBeCloseTo(world.config.timeToSleep - 0.01);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('resets the timer of a body moving faster than the linear threshold', () => {
|
|
87
|
+
const world = createPhysics2DWorld();
|
|
88
|
+
const crate = still(world);
|
|
89
|
+
updatePhysics2DSleep(world, 0.4);
|
|
90
|
+
crate.velocityX = world.config.sleepLinearThreshold * 10;
|
|
91
|
+
|
|
92
|
+
updatePhysics2DSleep(world, 0.4);
|
|
93
|
+
|
|
94
|
+
expect(crate.sleepTimer).toBe(0);
|
|
95
|
+
expect(crate.sleeping).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('resets the timer of a body spinning faster than the angular threshold', () => {
|
|
99
|
+
const world = createPhysics2DWorld();
|
|
100
|
+
const crate = still(world);
|
|
101
|
+
updatePhysics2DSleep(world, 0.4);
|
|
102
|
+
crate.angularVelocity = world.config.sleepAngularThreshold * 10;
|
|
103
|
+
|
|
104
|
+
updatePhysics2DSleep(world, 0.4);
|
|
105
|
+
|
|
106
|
+
expect(crate.sleepTimer).toBe(0);
|
|
107
|
+
expect(crate.sleeping).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('treats a body carrying an applied force as moving even at zero velocity', () => {
|
|
111
|
+
const world = createPhysics2DWorld();
|
|
112
|
+
const crate = still(world);
|
|
113
|
+
crate.forceX = 100;
|
|
114
|
+
|
|
115
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
116
|
+
|
|
117
|
+
expect(crate.sleeping).toBe(false);
|
|
118
|
+
expect(crate.sleepTimer).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('treats a body carrying an applied torque as moving even at zero velocity', () => {
|
|
122
|
+
const world = createPhysics2DWorld();
|
|
123
|
+
const crate = still(world);
|
|
124
|
+
crate.torque = 100;
|
|
125
|
+
|
|
126
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
127
|
+
|
|
128
|
+
expect(crate.sleeping).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('zeroes the velocity of a body it puts to sleep', () => {
|
|
132
|
+
const world = createPhysics2DWorld();
|
|
133
|
+
const crate = still(world);
|
|
134
|
+
// Under both thresholds, so it still qualifies as at rest — but not exactly zero, which is the drift
|
|
135
|
+
// a sleeping body would otherwise resume on waking.
|
|
136
|
+
crate.velocityX = world.config.sleepLinearThreshold / 2;
|
|
137
|
+
crate.angularVelocity = world.config.sleepAngularThreshold / 2;
|
|
138
|
+
|
|
139
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
140
|
+
|
|
141
|
+
expect(crate.sleeping).toBe(true);
|
|
142
|
+
expect(crate.velocityX).toBe(0);
|
|
143
|
+
expect(crate.angularVelocity).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('keeps a contact island awake while any one member is moving', () => {
|
|
147
|
+
const world = createPhysics2DWorld();
|
|
148
|
+
const settled = still(world, 0);
|
|
149
|
+
const shoved = moving(world, 1);
|
|
150
|
+
link(world, settled, shoved);
|
|
151
|
+
|
|
152
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
153
|
+
|
|
154
|
+
expect(shoved.sleeping).toBe(false);
|
|
155
|
+
expect(settled.sleeping).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps a transitively linked member awake through an intermediate body', () => {
|
|
159
|
+
const world = createPhysics2DWorld();
|
|
160
|
+
const settled = still(world, 0);
|
|
161
|
+
const middle = still(world, 1);
|
|
162
|
+
const shoved = moving(world, 2);
|
|
163
|
+
link(world, settled, middle);
|
|
164
|
+
link(world, middle, shoved);
|
|
165
|
+
|
|
166
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
167
|
+
|
|
168
|
+
expect(settled.sleeping).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('keeps a joint island awake while any one member is moving', () => {
|
|
172
|
+
const world = createPhysics2DWorld();
|
|
173
|
+
const settled = still(world, 0);
|
|
174
|
+
const shoved = moving(world, 1);
|
|
175
|
+
world.joints.push({ kind: 'test', bodyA: settled.index, bodyB: shoved.index, collideConnected: false } as never);
|
|
176
|
+
|
|
177
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
178
|
+
|
|
179
|
+
expect(settled.sleeping).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('does not let a shared static body merge two islands', () => {
|
|
183
|
+
// The failure this prevents is world-sized: every crate in a level rests on the same ground, so a
|
|
184
|
+
// static bridge would make the whole level one island that sleeps only when nothing anywhere moves.
|
|
185
|
+
const world = createPhysics2DWorld();
|
|
186
|
+
const ground = addPhysics2DBody(world, createRigidBody2D('static', 0, 0));
|
|
187
|
+
const settled = still(world, 0);
|
|
188
|
+
const shoved = moving(world, 20);
|
|
189
|
+
link(world, ground, settled);
|
|
190
|
+
link(world, ground, shoved);
|
|
191
|
+
|
|
192
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
193
|
+
|
|
194
|
+
expect(settled.sleeping).toBe(true);
|
|
195
|
+
expect(shoved.sleeping).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('does not let a sensor contact merge two islands', () => {
|
|
199
|
+
// A sensor reports overlap and resolves nothing, so it transmits no motion between the bodies it
|
|
200
|
+
// reports on and must not hold a settled body awake.
|
|
201
|
+
const world = createPhysics2DWorld();
|
|
202
|
+
const settled = still(world, 0);
|
|
203
|
+
const shoved = moving(world, 1);
|
|
204
|
+
link(world, settled, shoved, true);
|
|
205
|
+
|
|
206
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
207
|
+
|
|
208
|
+
expect(settled.sleeping).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('leaves static bodies neither asleep nor accumulating a timer', () => {
|
|
212
|
+
const world = createPhysics2DWorld();
|
|
213
|
+
const ground = addPhysics2DBody(world, createRigidBody2D('static', 0, 0));
|
|
214
|
+
|
|
215
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
216
|
+
|
|
217
|
+
expect(ground.sleeping).toBe(false);
|
|
218
|
+
expect(ground.sleepTimer).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('wakes everything already asleep when allowSleeping is turned off', () => {
|
|
222
|
+
// A body left asleep after the mechanism is disabled would never be integrated again, so it would
|
|
223
|
+
// hang frozen for the rest of the session.
|
|
224
|
+
const world = createPhysics2DWorld();
|
|
225
|
+
const crate = still(world);
|
|
226
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
227
|
+
expect(crate.sleeping).toBe(true);
|
|
228
|
+
|
|
229
|
+
world.config.allowSleeping = false;
|
|
230
|
+
updatePhysics2DSleep(world, 0.016);
|
|
231
|
+
|
|
232
|
+
expect(crate.sleeping).toBe(false);
|
|
233
|
+
expect(crate.sleepTimer).toBe(0);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('clears the timer of a body it wakes so rest has to be earned again', () => {
|
|
237
|
+
const world = createPhysics2DWorld();
|
|
238
|
+
const settled = still(world, 0);
|
|
239
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
240
|
+
expect(settled.sleeping).toBe(true);
|
|
241
|
+
|
|
242
|
+
const shoved = moving(world, 1);
|
|
243
|
+
link(world, settled, shoved);
|
|
244
|
+
updatePhysics2DSleep(world, 0.016);
|
|
245
|
+
|
|
246
|
+
expect(settled.sleeping).toBe(false);
|
|
247
|
+
expect(settled.sleepTimer).toBe(0);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe('wakePhysics2DBody', () => {
|
|
252
|
+
it('clears both the sleeping flag and the stillness timer', () => {
|
|
253
|
+
const world = createPhysics2DWorld();
|
|
254
|
+
const crate = still(world);
|
|
255
|
+
updatePhysics2DSleep(world, world.config.timeToSleep + 0.01);
|
|
256
|
+
|
|
257
|
+
wakePhysics2DBody(crate);
|
|
258
|
+
|
|
259
|
+
expect(crate.sleeping).toBe(false);
|
|
260
|
+
expect(crate.sleepTimer).toBe(0);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { Physics2DJoint } from '@flighthq/types/contract';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
addPhysics2DJoint,
|
|
6
|
+
getPhysics2DJointSolver,
|
|
7
|
+
registerPhysics2DJointSolver,
|
|
8
|
+
removePhysics2DJoint,
|
|
9
|
+
} from './jointRegistry';
|
|
10
|
+
import { stepPhysics2D } from './step';
|
|
11
|
+
import {
|
|
12
|
+
addPhysics2DBody,
|
|
13
|
+
createPhysics2DCollider,
|
|
14
|
+
createPhysics2DWorld,
|
|
15
|
+
createRigidBody2D,
|
|
16
|
+
removePhysics2DBody,
|
|
17
|
+
} from './world';
|
|
18
|
+
|
|
19
|
+
const STONE = { density: 1, friction: 0.3, restitution: 0 };
|
|
20
|
+
|
|
21
|
+
function body(world: ReturnType<typeof createPhysics2DWorld>, x: number, y: number) {
|
|
22
|
+
const made = createRigidBody2D('dynamic', x, y);
|
|
23
|
+
made.colliders.push(createPhysics2DCollider({ kind: 'aabb', minX: -0.5, minY: -0.5, maxX: 0.5, maxY: 0.5 }, STONE));
|
|
24
|
+
return addPhysics2DBody(world, made);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function joint(kind: string, bodyA: number, bodyB: number, anchorAX = 0, anchorBX = 0): Physics2DJoint {
|
|
28
|
+
return {
|
|
29
|
+
kind,
|
|
30
|
+
bodyA,
|
|
31
|
+
bodyB,
|
|
32
|
+
localAnchorAX: anchorAX,
|
|
33
|
+
localAnchorAY: 0,
|
|
34
|
+
localAnchorBX: anchorBX,
|
|
35
|
+
localAnchorBY: 0,
|
|
36
|
+
collideConnected: false,
|
|
37
|
+
impulse0: 0,
|
|
38
|
+
impulse1: 0,
|
|
39
|
+
impulse2: 0,
|
|
40
|
+
rAX: 0,
|
|
41
|
+
rAY: 0,
|
|
42
|
+
rBX: 0,
|
|
43
|
+
rBY: 0,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('addPhysics2DJoint', () => {
|
|
48
|
+
it('orders the joint by body index, carrying its anchors with the swap', () => {
|
|
49
|
+
// Obligation 1 applies to joints too: they enter the SAME sequential solve list as contacts, and a
|
|
50
|
+
// joint has no broadphase to canonicalise it, so this is the one place it can be enforced. The
|
|
51
|
+
// anchors have to travel with the bodies — a joint whose ends were exchanged without its anchors
|
|
52
|
+
// would attach to the wrong points and hold the pair in a pose nobody asked for.
|
|
53
|
+
const world = createPhysics2DWorld();
|
|
54
|
+
registerPhysics2DJointSolver(world, 'Test', { prepare: () => {}, solve: () => {} });
|
|
55
|
+
const first = body(world, 0, 0);
|
|
56
|
+
const second = body(world, 2, 0);
|
|
57
|
+
const added = addPhysics2DJoint(world, joint('Test', second.index, first.index, 7, 9));
|
|
58
|
+
|
|
59
|
+
expect(added.bodyA).toBe(first.index);
|
|
60
|
+
expect(added.bodyB).toBe(second.index);
|
|
61
|
+
expect(added.localAnchorAX).toBe(9);
|
|
62
|
+
expect(added.localAnchorBX).toBe(7);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Adding a joint whose kind has no solver yet is explicitly supported — a scene can deserialize
|
|
66
|
+
// ahead of the code that solves it. Canonicalizing there would exchange bodies and anchors while the
|
|
67
|
+
// kind's own direction fields stayed as authored, and no later registration would repair that half
|
|
68
|
+
// swap. So ordering waits for the kind, and an unsolved joint constrains nothing meanwhile.
|
|
69
|
+
it('leaves a joint whose kind has no solver in its authored order', () => {
|
|
70
|
+
const world = createPhysics2DWorld();
|
|
71
|
+
const first = body(world, 0, 0);
|
|
72
|
+
const second = body(world, 2, 0);
|
|
73
|
+
|
|
74
|
+
const added = addPhysics2DJoint(world, joint('Unknown', second.index, first.index, 7, 9));
|
|
75
|
+
|
|
76
|
+
expect(added.bodyA).toBe(second.index);
|
|
77
|
+
expect(added.localAnchorAX).toBe(7);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Critic's repro: register the solver AFTER the joint is already in the world, and the kind's own
|
|
81
|
+
// reversal must still run. Previously the generic swap had already happened at add time without the
|
|
82
|
+
// kind's consent, so the directional field kept its authored sign forever.
|
|
83
|
+
it('canonicalizes an already-added joint when its solver registers later, reversing direction', () => {
|
|
84
|
+
const world = createPhysics2DWorld();
|
|
85
|
+
const first = body(world, 0, 0);
|
|
86
|
+
const second = body(world, 2, 0);
|
|
87
|
+
const added = addPhysics2DJoint(world, {
|
|
88
|
+
...joint('Directional', second.index, first.index, 7, 9),
|
|
89
|
+
direction: 1,
|
|
90
|
+
} as Physics2DJoint & { direction: number });
|
|
91
|
+
|
|
92
|
+
registerPhysics2DJointSolver(world, 'Directional', {
|
|
93
|
+
prepare: () => {},
|
|
94
|
+
solve: () => {},
|
|
95
|
+
swapEnds: (target) => {
|
|
96
|
+
const directional = target as Physics2DJoint & { direction: number };
|
|
97
|
+
directional.direction = -directional.direction;
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(added.bodyA).toBe(first.index);
|
|
103
|
+
expect(added.localAnchorAX).toBe(9);
|
|
104
|
+
expect((added as Physics2DJoint & { direction: number }).direction).toBe(-1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// A kind that vetoes the exchange must still be obeyed on the deferred path.
|
|
108
|
+
it('honours a solver that vetoes the swap when it registers later', () => {
|
|
109
|
+
const world = createPhysics2DWorld();
|
|
110
|
+
const first = body(world, 0, 0);
|
|
111
|
+
const second = body(world, 2, 0);
|
|
112
|
+
const added = addPhysics2DJoint(world, joint('Vetoing', second.index, first.index, 7, 9));
|
|
113
|
+
|
|
114
|
+
registerPhysics2DJointSolver(world, 'Vetoing', {
|
|
115
|
+
prepare: () => {},
|
|
116
|
+
solve: () => {},
|
|
117
|
+
swapEnds: () => false,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(added.bodyA).toBe(second.index);
|
|
121
|
+
expect(added.localAnchorAX).toBe(7);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('leaves an already-ordered joint untouched', () => {
|
|
125
|
+
const world = createPhysics2DWorld();
|
|
126
|
+
const first = body(world, 0, 0);
|
|
127
|
+
const second = body(world, 2, 0);
|
|
128
|
+
const added = addPhysics2DJoint(world, joint('Test', first.index, second.index, 7, 9));
|
|
129
|
+
expect(added.bodyA).toBe(first.index);
|
|
130
|
+
expect(added.localAnchorAX).toBe(7);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('getPhysics2DJointSolver', () => {
|
|
135
|
+
it('returns null for an unregistered kind rather than throwing', () => {
|
|
136
|
+
// A scene deserialized with a joint kind this build does not know about should import and simply not
|
|
137
|
+
// constrain, not refuse to load.
|
|
138
|
+
const world = createPhysics2DWorld();
|
|
139
|
+
expect(getPhysics2DJointSolver(world, 'acme.Conveyor')).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('steps a world holding a joint whose kind has no solver', () => {
|
|
143
|
+
const world = createPhysics2DWorld();
|
|
144
|
+
const first = body(world, 0, 0);
|
|
145
|
+
const second = body(world, 2, 0);
|
|
146
|
+
addPhysics2DJoint(world, joint('acme.Unknown', first.index, second.index));
|
|
147
|
+
expect(() => stepPhysics2D(world, 1 / 60)).not.toThrow();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('registerPhysics2DJointSolver', () => {
|
|
152
|
+
it('makes a custom kind solvable without this package knowing it', () => {
|
|
153
|
+
const world = createPhysics2DWorld(0, 0);
|
|
154
|
+
let prepared = 0;
|
|
155
|
+
let solved = 0;
|
|
156
|
+
registerPhysics2DJointSolver(world, 'acme.Conveyor', {
|
|
157
|
+
prepare: () => void prepared++,
|
|
158
|
+
solve: () => void solved++,
|
|
159
|
+
});
|
|
160
|
+
const first = body(world, 0, 0);
|
|
161
|
+
const second = body(world, 2, 0);
|
|
162
|
+
addPhysics2DJoint(world, joint('acme.Conveyor', first.index, second.index));
|
|
163
|
+
stepPhysics2D(world, 1 / 60);
|
|
164
|
+
|
|
165
|
+
expect(prepared).toBe(1);
|
|
166
|
+
expect(solved).toBe(world.config.velocityIterations);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('is scoped to its world, so two worlds can register different kinds', () => {
|
|
170
|
+
const first = createPhysics2DWorld();
|
|
171
|
+
const second = createPhysics2DWorld();
|
|
172
|
+
registerPhysics2DJointSolver(first, 'acme.One', { prepare: () => {}, solve: () => {} });
|
|
173
|
+
expect(getPhysics2DJointSolver(first, 'acme.One')).not.toBeNull();
|
|
174
|
+
expect(getPhysics2DJointSolver(second, 'acme.One')).toBeNull();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('lets a later registration replace an earlier one', () => {
|
|
178
|
+
const world = createPhysics2DWorld();
|
|
179
|
+
const replacement = { prepare: () => {}, solve: () => {} };
|
|
180
|
+
registerPhysics2DJointSolver(world, 'acme.One', { prepare: () => {}, solve: () => {} });
|
|
181
|
+
registerPhysics2DJointSolver(world, 'acme.One', replacement);
|
|
182
|
+
expect(getPhysics2DJointSolver(world, 'acme.One')).toBe(replacement);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('removePhysics2DJoint', () => {
|
|
187
|
+
it('removes the joint and reports false for one the world does not hold', () => {
|
|
188
|
+
const world = createPhysics2DWorld();
|
|
189
|
+
const first = body(world, 0, 0);
|
|
190
|
+
const second = body(world, 2, 0);
|
|
191
|
+
const added = addPhysics2DJoint(world, joint('Test', first.index, second.index));
|
|
192
|
+
expect(removePhysics2DJoint(world, added)).toBe(true);
|
|
193
|
+
expect(world.joints).toHaveLength(0);
|
|
194
|
+
expect(removePhysics2DJoint(world, added)).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('drops joints naming a removed body, so a later body cannot inherit the constraint', () => {
|
|
198
|
+
const world = createPhysics2DWorld();
|
|
199
|
+
const first = body(world, 0, 0);
|
|
200
|
+
const second = body(world, 2, 0);
|
|
201
|
+
addPhysics2DJoint(world, joint('Test', first.index, second.index));
|
|
202
|
+
removePhysics2DBody(world, second);
|
|
203
|
+
expect(world.joints).toHaveLength(0);
|
|
204
|
+
});
|
|
205
|
+
});
|