pelican-engine 0.3.10 → 0.3.12
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/AGENTS.md +37 -13
- package/distribution.json +15 -15
- package/docs/STATUS.md +3 -2
- package/docs/api/REFERENCE.md +110 -92
- package/docs/api/index.json +110 -94
- package/docs/npm/GAME-AUTHOR.md +37 -13
- package/engine.manifest.json +1 -1
- package/lib/physics/index.js +3 -0
- package/lib/runtime/host.js +13 -1
- package/lib/runtime/scheduler.js +11 -1
- package/lib/sdk/index.d.ts +19 -9
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +37 -2
- package/lib/simulation/rope.js +34 -5
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -120,21 +120,45 @@ has not been runtime or browser validated under the current project policy.
|
|
|
120
120
|
|
|
121
121
|
### Development-stage rope particles
|
|
122
122
|
|
|
123
|
-
`ctx.ropes.create({ id, points, mass })` creates a CPU particle
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
123
|
+
`ctx.ropes.create({ id, points, mass, autoStep: true })` creates a CPU particle
|
|
124
|
+
rope that advances once after each physics phase, before `postPhysics` systems.
|
|
125
|
+
Set anchors, forces and sampled colliders in `prePhysics`; read positions in
|
|
126
|
+
`postPhysics`. Without `autoStep`, the rope remains manually stepped with
|
|
127
|
+
`ctx.ropes.step(id)`; calling `step` for an automatic rope returns
|
|
128
|
+
`TC_ROPE_AUTO_STEP` instead of advancing twice. An automatic rope created during
|
|
129
|
+
`prePhysics` advances in that tick. The first particle is pinned by default;
|
|
130
|
+
`anchors: []` creates a free rope. `setAnchor`, `releaseAnchor`, `applyForce`
|
|
131
|
+
(next tick only), `applyImpulse`, `inspect`, and `destroy` manage it.
|
|
132
|
+
|
|
133
|
+
`setColliders` accepts sampled planes and spheres; contacts affect only rope
|
|
134
|
+
particles, not rigid bodies. Host snapshots include the rope definition, mode
|
|
135
|
+
and state. If advancing an automatic rope fails, the engine attempts to restore
|
|
136
|
+
all earlier automatic ropes from that phase; the host tick may still have other
|
|
137
|
+
partial changes and must be recovered from a known-good checkpoint. The source
|
|
138
|
+
checkout includes `examples/rope-particles`.
|
|
139
|
+
|
|
140
|
+
For each latest-tick contact, `reactionImpulse` and `reactionAngularImpulse`
|
|
141
|
+
report the opposite impulse produced by the particle's position projections,
|
|
142
|
+
and its moment about the world origin. These optional fields are absent when
|
|
143
|
+
restoring older contact snapshots. They do not automatically act on a rigid body.
|
|
144
|
+
For an explicitly mapped dynamic body, game code can read its actual world center
|
|
145
|
+
of mass via `ctx.physics.centerOfMass(id)`, apply the linear impulse, and apply
|
|
146
|
+
`reactionAngularImpulse - centerOfMass × reactionImpulse` as torque impulse.
|
|
147
|
+
Apply each contact at most once for its `tick`; repeated `inspect` calls return
|
|
148
|
+
the same contacts. The source checkout has an illustrative `examples/rope-reaction`.
|
|
149
|
+
|
|
150
|
+
This sampled, manually applied reaction is delayed and uses positional correction,
|
|
151
|
+
not a coupled contact solver: it omits body velocity/effective mass, friction and
|
|
152
|
+
continuous collision. Applying the linear and angular impulses uses separate
|
|
153
|
+
operations and is not transactional if a tick fails; recover from a known-good
|
|
154
|
+
checkpoint. It must not be represented as complete two-way rigid-body interaction.
|
|
131
155
|
|
|
132
156
|
This is an unverified development-stage increment, not complete rope physics:
|
|
133
|
-
there is no built-in rope mesh, automatic
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
157
|
+
there is no built-in rope mesh, automatic rigid-body reaction, segment or self collision,
|
|
158
|
+
or tearing. Moving entities to particle positions is not a rope renderer or
|
|
159
|
+
coupled physics. Plane normals must be unit length; `offset` is the signed
|
|
160
|
+
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
161
|
+
1/30 seconds inclusive.
|
|
138
162
|
|
|
139
163
|
Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
|
|
140
164
|
`KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
|
package/engine.manifest.json
CHANGED
package/lib/physics/index.js
CHANGED
package/lib/runtime/host.js
CHANGED
|
@@ -99,6 +99,7 @@ export class RuntimeHost {
|
|
|
99
99
|
world: this.world,
|
|
100
100
|
fixedDt: this.fixedDt,
|
|
101
101
|
seed: this.seed,
|
|
102
|
+
afterPhysics: () => this.afterPhysics(),
|
|
102
103
|
});
|
|
103
104
|
this.scheduler = createdScheduler;
|
|
104
105
|
for (const factory of options.plugins ?? []) {
|
|
@@ -255,6 +256,15 @@ export class RuntimeHost {
|
|
|
255
256
|
},
|
|
256
257
|
};
|
|
257
258
|
}
|
|
259
|
+
afterPhysics() {
|
|
260
|
+
for (const plugin of this.plugins) {
|
|
261
|
+
const result = plugin.afterPhysics?.();
|
|
262
|
+
if (result && (typeof result === "object" || typeof result === "function") && "then" in result) {
|
|
263
|
+
void Promise.resolve(result).catch(() => { });
|
|
264
|
+
throw new Error(`TC_PLUGIN_ASYNC: afterPhysics for ${plugin.id} must complete synchronously.`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
258
268
|
registerPlugin(plugin) {
|
|
259
269
|
if (!plugin || typeof plugin !== "object")
|
|
260
270
|
throw new Error("TC_PLUGIN_ID: Plugin must have a stable non-empty id.");
|
|
@@ -267,7 +277,8 @@ export class RuntimeHost {
|
|
|
267
277
|
typeof plugin.validateSnapshot !== "function" ||
|
|
268
278
|
typeof plugin.restore !== "function" ||
|
|
269
279
|
typeof plugin.dispose !== "function" ||
|
|
270
|
-
(plugin.settle !== undefined && typeof plugin.settle !== "function")
|
|
280
|
+
(plugin.settle !== undefined && typeof plugin.settle !== "function") ||
|
|
281
|
+
(plugin.afterPhysics !== undefined && typeof plugin.afterPhysics !== "function"))
|
|
271
282
|
throw new Error(`TC_PLUGIN_CONTRACT: Plugin ${plugin.id} is missing lifecycle methods.`);
|
|
272
283
|
for (const [key, service] of Object.entries(plugin.services ?? {})) {
|
|
273
284
|
if (service === undefined)
|
|
@@ -411,6 +422,7 @@ export class RuntimeHost {
|
|
|
411
422
|
world: this.world,
|
|
412
423
|
fixedDt: this.fixedDt,
|
|
413
424
|
seed: this.seed,
|
|
425
|
+
afterPhysics: () => this.afterPhysics(),
|
|
414
426
|
systems: [
|
|
415
427
|
...engineSystems.map((system) => ({
|
|
416
428
|
...system,
|
package/lib/runtime/scheduler.js
CHANGED
|
@@ -131,10 +131,12 @@ function schedule(input) {
|
|
|
131
131
|
* advance() is an alternative wall-clock driver, never a second fixed-step layer.
|
|
132
132
|
* Flush boundaries: start tick, after input events, end each phase. Failed ticks
|
|
133
133
|
* may have changed world/random/events: pause and require a known-good restore. */
|
|
134
|
-
export function createScheduler({ world, systems = [], fixedDt = 1 / 60, maxSubsteps = 8, seed = 0, }) {
|
|
134
|
+
export function createScheduler({ world, systems = [], fixedDt = 1 / 60, maxSubsteps = 8, seed = 0, afterPhysics, }) {
|
|
135
135
|
if (!Number.isFinite(fixedDt) || fixedDt <= 0)
|
|
136
136
|
throw new Error("TC_SCHEDULER_DT: fixedDt must be positive and finite.");
|
|
137
137
|
integer(maxSubsteps, "maxSubsteps", 1);
|
|
138
|
+
if (afterPhysics !== undefined && typeof afterPhysics !== "function")
|
|
139
|
+
throw new Error("TC_SCHEDULER_AFTER_PHYSICS: Expected a synchronous callback.");
|
|
138
140
|
const random = createRandom(seed);
|
|
139
141
|
let plan = schedule(systems);
|
|
140
142
|
let completedTick = 0, accumulator = 0, compensation = 0, paused = false, running = false;
|
|
@@ -260,6 +262,14 @@ export function createScheduler({ world, systems = [], fixedDt = 1 / 60, maxSubs
|
|
|
260
262
|
}
|
|
261
263
|
stage = `flush:${phase}`;
|
|
262
264
|
world.flush();
|
|
265
|
+
if (phase === "physics" && afterPhysics) {
|
|
266
|
+
stage = "engine:afterPhysics";
|
|
267
|
+
const result = afterPhysics();
|
|
268
|
+
if (result && (typeof result === "object" || typeof result === "function") && "then" in result) {
|
|
269
|
+
void Promise.resolve(result).catch(() => { });
|
|
270
|
+
throw new Error("TC_SCHEDULER_ASYNC: Engine callbacks must complete synchronously.");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
263
273
|
}
|
|
264
274
|
completedTick++;
|
|
265
275
|
return true;
|
package/lib/sdk/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
|
|
2
|
-
export declare const SDK_VERSION = "0.3.
|
|
2
|
+
export declare const SDK_VERSION = "0.3.12";
|
|
3
3
|
export type Vec2 = readonly [number, number];
|
|
4
4
|
export type Vec3 = readonly [number, number, number];
|
|
5
5
|
export type Vec4 = readonly [number, number, number, number];
|
|
@@ -104,7 +104,7 @@ export interface SystemContext extends EngineFacade {
|
|
|
104
104
|
readonly ropes: RopeService;
|
|
105
105
|
}
|
|
106
106
|
/** CPU particles with sampled sphere/plane collisions. Collision only moves rope particles;
|
|
107
|
-
* no rigid-body reaction, segment/self collision, rendering or complete
|
|
107
|
+
* no automatic rigid-body reaction, segment/self collision, rendering or complete physics. */
|
|
108
108
|
export type RopeCollider = {
|
|
109
109
|
kind: "sphere";
|
|
110
110
|
id: string;
|
|
@@ -124,6 +124,8 @@ export interface RopeDefinition {
|
|
|
124
124
|
radius?: number;
|
|
125
125
|
/** Defaults to the host interval; an explicit value must exactly match it. */
|
|
126
126
|
fixedDt?: number;
|
|
127
|
+
/** When true, step once after each physics phase; defaults to false (manual stepping). */
|
|
128
|
+
autoStep?: boolean;
|
|
127
129
|
substeps?: number;
|
|
128
130
|
iterations?: number;
|
|
129
131
|
damping?: number;
|
|
@@ -133,6 +135,17 @@ export interface RopeDefinition {
|
|
|
133
135
|
position: Vec3;
|
|
134
136
|
}[];
|
|
135
137
|
}
|
|
138
|
+
/** Sampled-collider contact from the most recently completed rope tick. */
|
|
139
|
+
export interface RopeContact {
|
|
140
|
+
tick: number;
|
|
141
|
+
particle: number;
|
|
142
|
+
colliderId: string;
|
|
143
|
+
penetration: number;
|
|
144
|
+
/** Opposite of particle projection impulses, N·s; absent in older snapshots. */
|
|
145
|
+
reactionImpulse?: Vec3;
|
|
146
|
+
/** Angular impulse about the world origin; absent in older snapshots. */
|
|
147
|
+
reactionAngularImpulse?: Vec3;
|
|
148
|
+
}
|
|
136
149
|
export interface RopeState {
|
|
137
150
|
tick: number;
|
|
138
151
|
simulatedSeconds: number;
|
|
@@ -143,12 +156,7 @@ export interface RopeState {
|
|
|
143
156
|
index: number;
|
|
144
157
|
position: Vec3;
|
|
145
158
|
}[];
|
|
146
|
-
contacts:
|
|
147
|
-
tick: number;
|
|
148
|
-
particle: number;
|
|
149
|
-
colliderId: string;
|
|
150
|
-
penetration: number;
|
|
151
|
-
}[];
|
|
159
|
+
contacts: RopeContact[];
|
|
152
160
|
maxDistanceError: number;
|
|
153
161
|
resources: {
|
|
154
162
|
particles: number;
|
|
@@ -159,7 +167,7 @@ export interface RopeState {
|
|
|
159
167
|
}
|
|
160
168
|
export interface RopeService {
|
|
161
169
|
create(definition: RopeDefinition): void;
|
|
162
|
-
/** Explicit
|
|
170
|
+
/** Explicit ticks for manual ropes only. Auto-stepped ropes reject this call. A failed batch leaves the rope unchanged. */
|
|
163
171
|
step(id: string, ticks?: number): RopeState;
|
|
164
172
|
inspect(id: string): RopeState;
|
|
165
173
|
setColliders(id: string, colliders: readonly RopeCollider[]): void;
|
|
@@ -394,6 +402,8 @@ export interface PhysicsService {
|
|
|
394
402
|
moveCharacter(entity: string, displacement: Vec3): void;
|
|
395
403
|
grounded(entity: string): boolean;
|
|
396
404
|
velocity(entity: string): Vec3;
|
|
405
|
+
/** World-space rigid-body center of mass, including collider offsets. */
|
|
406
|
+
centerOfMass(entity: string): Vec3;
|
|
397
407
|
setVelocity(entity: string, velocity: Vec3): void;
|
|
398
408
|
impulse(entity: string, impulse: Vec3): void;
|
|
399
409
|
/** Angular velocity in world-space radians per second. */
|
package/lib/sdk/index.js
CHANGED
|
@@ -6,6 +6,8 @@ function createEntry(input, fixedDt) {
|
|
|
6
6
|
const definition = cloneSerializable(input);
|
|
7
7
|
if (!definition || !validId(definition.id))
|
|
8
8
|
return fail("ID", "Expected a nonempty id of at most 128 characters.");
|
|
9
|
+
if (definition.autoStep !== undefined && typeof definition.autoStep !== "boolean")
|
|
10
|
+
return fail("AUTO_STEP", "autoStep must be a boolean.");
|
|
9
11
|
if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
10
12
|
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
11
13
|
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
@@ -41,7 +43,7 @@ function parseSnapshot(value, fixedDt) {
|
|
|
41
43
|
throw error;
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
|
-
/**
|
|
46
|
+
/** CPU rope ownership; optional host-scheduled stepping remains independent from Rapier. */
|
|
45
47
|
export const createRopePlugin = ({ fixedDt }) => {
|
|
46
48
|
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
47
49
|
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
@@ -68,7 +70,12 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
68
70
|
const entry = createEntry(definition, hostDt);
|
|
69
71
|
entries.set(entry.definition.id, entry);
|
|
70
72
|
},
|
|
71
|
-
step(id, ticks) {
|
|
73
|
+
step(id, ticks) {
|
|
74
|
+
const entry = get(id);
|
|
75
|
+
if (entry.definition.autoStep)
|
|
76
|
+
return fail("AUTO_STEP", `Rope '${id}' is automatically stepped; omit autoStep for manual stepping.`);
|
|
77
|
+
return entry.solver.step(ticks);
|
|
78
|
+
},
|
|
72
79
|
inspect(id) { return get(id).solver.inspect(); },
|
|
73
80
|
setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
|
|
74
81
|
setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
|
|
@@ -79,6 +86,34 @@ export const createRopePlugin = ({ fixedDt }) => {
|
|
|
79
86
|
};
|
|
80
87
|
return {
|
|
81
88
|
id: "$engine.ropes", version: "1", services: { ropes: service },
|
|
89
|
+
afterPhysics() {
|
|
90
|
+
alive();
|
|
91
|
+
const committed = [];
|
|
92
|
+
try {
|
|
93
|
+
// Stable ordering also makes failure and recording behavior independent of insertion order.
|
|
94
|
+
const automatic = [...entries.values()].filter(entry => entry.definition.autoStep)
|
|
95
|
+
.sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0);
|
|
96
|
+
for (const { solver } of automatic) {
|
|
97
|
+
const snapshot = solver.snapshot();
|
|
98
|
+
solver.step(); // A failed solver step rolls itself back.
|
|
99
|
+
committed.push({ solver, snapshot });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const failures = [];
|
|
104
|
+
for (const { solver, snapshot } of committed.reverse()) {
|
|
105
|
+
try {
|
|
106
|
+
solver.restore(snapshot);
|
|
107
|
+
}
|
|
108
|
+
catch (rollbackError) {
|
|
109
|
+
failures.push(String(rollbackError));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (failures.length)
|
|
113
|
+
throw new Error(`TC_ROPE_ROLLBACK: ${String(error)}; partialState=true; rollback failures: ${failures.join("; ")}`);
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
82
117
|
legacySnapshot: () => ({ version: 1, ropes: [] }),
|
|
83
118
|
snapshot: () => ({ version: 1, ropes: [...entries.values()].sort((a, b) => a.definition.id < b.definition.id ? -1 : a.definition.id > b.definition.id ? 1 : 0).map(({ definition, solver }) => ({ definition: cloneSerializable(definition), state: solver.snapshot() })) }),
|
|
84
119
|
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
package/lib/simulation/rope.js
CHANGED
|
@@ -9,6 +9,15 @@ export class RopeError extends Error {
|
|
|
9
9
|
const fail = (code, reason) => { throw new RopeError(code, reason); };
|
|
10
10
|
const vec = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x) && Math.abs(x) <= 1e6);
|
|
11
11
|
const copy = (v) => [v[0], v[1], v[2]];
|
|
12
|
+
const finiteVector = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x));
|
|
13
|
+
const copyContact = (contact) => {
|
|
14
|
+
const { reactionImpulse, reactionAngularImpulse, ...rest } = contact;
|
|
15
|
+
return {
|
|
16
|
+
...rest,
|
|
17
|
+
...(reactionImpulse === undefined ? {} : { reactionImpulse: copy(reactionImpulse) }),
|
|
18
|
+
...(reactionAngularImpulse === undefined ? {} : { reactionAngularImpulse: copy(reactionAngularImpulse) }),
|
|
19
|
+
};
|
|
20
|
+
};
|
|
12
21
|
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
13
22
|
const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
|
|
14
23
|
const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
|
|
@@ -220,12 +229,30 @@ export class RopeSolver {
|
|
|
220
229
|
continue;
|
|
221
230
|
for (let k = 0; k < 3; k++)
|
|
222
231
|
p[k] += normal[k] * depth;
|
|
232
|
+
// Each positional projection changes the particle's velocity by depth / h.
|
|
233
|
+
// Report its equal-and-opposite impulse and moment on the sampled surface;
|
|
234
|
+
// the collider itself remains an immutable input and is not moved here.
|
|
235
|
+
const magnitude = -mass * depth / h;
|
|
236
|
+
const reaction = [normal[0] * magnitude, normal[1] * magnitude, normal[2] * magnitude];
|
|
237
|
+
const point = c.kind === "sphere"
|
|
238
|
+
? [c.center[0] + normal[0] * c.radius, c.center[1] + normal[1] * c.radius, c.center[2] + normal[2] * c.radius]
|
|
239
|
+
: [p[0] - normal[0] * radius, p[1] - normal[1] * radius, p[2] - normal[2] * radius];
|
|
240
|
+
const moment = [point[1] * reaction[2] - point[2] * reaction[1], point[2] * reaction[0] - point[0] * reaction[2], point[0] * reaction[1] - point[1] * reaction[0]];
|
|
241
|
+
if (!finiteVector(reaction) || !finiteVector(moment))
|
|
242
|
+
fail("ROPE_NUMERIC", "contact reaction exceeded finite numeric bounds");
|
|
223
243
|
const key = `${i}:${c.id}`;
|
|
224
244
|
const existing = contacts.get(key);
|
|
225
|
-
if (existing)
|
|
245
|
+
if (existing) {
|
|
226
246
|
existing.penetration = Math.max(existing.penetration, depth);
|
|
247
|
+
for (let k = 0; k < 3; k++) {
|
|
248
|
+
existing.reactionImpulse[k] += reaction[k];
|
|
249
|
+
existing.reactionAngularImpulse[k] += moment[k];
|
|
250
|
+
}
|
|
251
|
+
if (!finiteVector(existing.reactionImpulse) || !finiteVector(existing.reactionAngularImpulse))
|
|
252
|
+
fail("ROPE_NUMERIC", "contact reaction accumulation exceeded finite numeric bounds");
|
|
253
|
+
}
|
|
227
254
|
else
|
|
228
|
-
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth });
|
|
255
|
+
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth, reactionImpulse: reaction, reactionAngularImpulse: moment });
|
|
229
256
|
}
|
|
230
257
|
}
|
|
231
258
|
}
|
|
@@ -255,7 +282,7 @@ export class RopeSolver {
|
|
|
255
282
|
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
256
283
|
positions: this.positions.map(copy), velocities: this.velocities.map(copy),
|
|
257
284
|
anchors: [...this.anchors].map(([index, position]) => ({ index, position: copy(position) })).sort((a, b) => a.index - b.index),
|
|
258
|
-
contacts: this.contacts.map(
|
|
285
|
+
contacts: this.contacts.map(copyContact), maxDistanceError,
|
|
259
286
|
resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
|
|
260
287
|
};
|
|
261
288
|
}
|
|
@@ -294,14 +321,16 @@ export class RopeSolver {
|
|
|
294
321
|
input.contacts.some(contact => !contact || contact.tick !== input.tick ||
|
|
295
322
|
!Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count ||
|
|
296
323
|
!colliders.some(collider => collider.id === contact.colliderId) ||
|
|
297
|
-
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0
|
|
324
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0 ||
|
|
325
|
+
(contact.reactionImpulse === undefined) !== (contact.reactionAngularImpulse === undefined) ||
|
|
326
|
+
(contact.reactionImpulse !== undefined && (!finiteVector(contact.reactionImpulse) || !finiteVector(contact.reactionAngularImpulse)))))
|
|
298
327
|
fail("ROPE_SNAPSHOT", "invalid contact state");
|
|
299
328
|
this.positions = input.positions.map(copy);
|
|
300
329
|
this.velocities = input.velocities.map(copy);
|
|
301
330
|
this.forces = input.forces.map(copy);
|
|
302
331
|
this.anchors = anchors;
|
|
303
332
|
this.colliders = colliders;
|
|
304
|
-
this.contacts = input.contacts.map(
|
|
333
|
+
this.contacts = input.contacts.map(copyContact);
|
|
305
334
|
this.tickValue = input.tick;
|
|
306
335
|
}
|
|
307
336
|
dispose() {
|