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.
@@ -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 rope. Call
124
- `ctx.ropes.step(id)` **once per game fixed tick** when movement is desired;
125
- the host does not advance it automatically. The first particle is pinned by
126
- default; `anchors: []` creates a free rope. `setAnchor`, `releaseAnchor`,
127
- `applyForce` (next tick only), `applyImpulse`, `inspect`, and `destroy` manage
128
- it. `setColliders` accepts sampled planes and spheres; contacts affect only
129
- particles, not rigid bodies. Host snapshots include rope state. An example in
130
- the source checkout is `examples/rope-particles`.
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 fixed-tick scheduling, rigid-body
134
- reaction, segment or self collision, or tearing. Game code may use `positions`
135
- to move entities but this is not a substitute for a rope renderer or coupled
136
- physics. Plane normals must be unit length; `offset` is the plane's signed
137
- normal distance from the origin. The host interval must be 1/1000?1/30 seconds.
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"],
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "milestone": "M3",
5
5
  "stage": "advanced-rendering-development",
6
6
  "license": "MIT",
@@ -746,6 +746,9 @@ function createPlugin(context) {
746
746
  ]
747
747
  : tuple(b.body.linvel());
748
748
  },
749
+ centerOfMass(id) {
750
+ return tuple(get(id).body.worldCom());
751
+ },
749
752
  setVelocity(id, value) {
750
753
  get(id);
751
754
  record({ kind: "velocity", id, value });
@@ -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,
@@ -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;
@@ -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.10";
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 M4 physics. */
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 fixed solver ticks; not automatically advanced with the host. A failed batch leaves the rope unchanged. */
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
@@ -1,5 +1,5 @@
1
1
  /** Pelican Engine public API. No renderer or GPU implementation types escape this module. */
2
- export const SDK_VERSION = "0.3.10";
2
+ export const SDK_VERSION = "0.3.12";
3
3
  export const SYSTEM_PHASES = [
4
4
  "input",
5
5
  "prePhysics",
@@ -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
- /** Explicitly stepped CPU rope ownership; independent from the Rapier physics plugin. */
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) { return get(id).solver.step(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); },
@@ -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(c => ({ ...c })), maxDistanceError,
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(c => ({ ...c }));
333
+ this.contacts = input.contacts.map(copyContact);
305
334
  this.tickValue = input.tick;
306
335
  }
307
336
  dispose() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pelican-engine",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "description": "Pelican Engine public SDK and offline development CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",