pelican-engine 0.3.9 → 0.3.11
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 +26 -0
- package/distribution.json +15 -13
- package/docs/STATUS.md +2 -2
- package/docs/api/REFERENCE.md +156 -88
- package/docs/api/index.json +150 -90
- package/docs/npm/GAME-AUTHOR.md +26 -0
- package/engine.manifest.json +1 -1
- package/lib/renderer/gameplay.js +2 -0
- package/lib/runtime/host.js +17 -1
- package/lib/runtime/scheduler.js +11 -1
- package/lib/sdk/index.d.ts +70 -1
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +124 -0
- package/lib/simulation/rope.js +318 -0
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -118,6 +118,32 @@ batch, and the last update wins if a cell appears more than once. Entity and
|
|
|
118
118
|
transform changes are separate from this grid-only batch. This API is new and
|
|
119
119
|
has not been runtime or browser validated under the current project policy.
|
|
120
120
|
|
|
121
|
+
### Development-stage rope particles
|
|
122
|
+
|
|
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
|
+
This is an unverified development-stage increment, not complete rope physics:
|
|
141
|
+
there is no built-in rope mesh, rigid-body reaction, segment or self collision,
|
|
142
|
+
or tearing. Moving entities to particle positions is not a rope renderer or
|
|
143
|
+
coupled physics. Plane normals must be unit length; `offset` is the signed
|
|
144
|
+
normal distance from the origin. Host intervals must be between 1/1000 and
|
|
145
|
+
1/30 seconds inclusive.
|
|
146
|
+
|
|
121
147
|
Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
|
|
122
148
|
`KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
|
|
123
149
|
restart: ["r", "R"] }`, not `"KeyW"` or `"KeyR"`. Letter case depends on the
|
package/engine.manifest.json
CHANGED
package/lib/renderer/gameplay.js
CHANGED
|
@@ -4,6 +4,7 @@ import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
|
|
|
4
4
|
import { createGameServices } from "../runtime/game-services.js";
|
|
5
5
|
import { createPhysicsFactory } from "../physics/index.js";
|
|
6
6
|
import { createNavigationPlugin } from "../navigation/index.js";
|
|
7
|
+
import { createRopePlugin } from "../simulation/rope-plugin.js";
|
|
7
8
|
import { createModelLibrary } from "./models.js";
|
|
8
9
|
import { resolveEntityDefinition } from "../runtime/prefabs.js";
|
|
9
10
|
/** Private presentation ownership. Games receive only SDK services, never browser/library objects. */
|
|
@@ -134,6 +135,7 @@ export async function prepareGameplay(ir, baseURL, renderer, materials = new Map
|
|
|
134
135
|
const plugins = [
|
|
135
136
|
services.factory,
|
|
136
137
|
createNavigationPlugin,
|
|
138
|
+
createRopePlugin,
|
|
137
139
|
(context) => ({
|
|
138
140
|
id: "$engine.models",
|
|
139
141
|
version: "2",
|
package/lib/runtime/host.js
CHANGED
|
@@ -99,10 +99,12 @@ 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 ?? []) {
|
|
105
106
|
const plugin = factory({
|
|
107
|
+
fixedDt: this.fixedDt,
|
|
106
108
|
world: this.world,
|
|
107
109
|
scene: this.scene,
|
|
108
110
|
events: (type) => this.scheduler.events(type),
|
|
@@ -197,6 +199,9 @@ export class RuntimeHost {
|
|
|
197
199
|
get navigation() {
|
|
198
200
|
return host.service("navigation");
|
|
199
201
|
},
|
|
202
|
+
get ropes() {
|
|
203
|
+
return host.service("ropes");
|
|
204
|
+
},
|
|
200
205
|
entities: {
|
|
201
206
|
resolve: (entity) => this.world.resolve(entity),
|
|
202
207
|
isAlive: (entity) => this.world.isAlive(entity),
|
|
@@ -251,6 +256,15 @@ export class RuntimeHost {
|
|
|
251
256
|
},
|
|
252
257
|
};
|
|
253
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
|
+
}
|
|
254
268
|
registerPlugin(plugin) {
|
|
255
269
|
if (!plugin || typeof plugin !== "object")
|
|
256
270
|
throw new Error("TC_PLUGIN_ID: Plugin must have a stable non-empty id.");
|
|
@@ -263,7 +277,8 @@ export class RuntimeHost {
|
|
|
263
277
|
typeof plugin.validateSnapshot !== "function" ||
|
|
264
278
|
typeof plugin.restore !== "function" ||
|
|
265
279
|
typeof plugin.dispose !== "function" ||
|
|
266
|
-
(plugin.settle !== undefined && typeof plugin.settle !== "function")
|
|
280
|
+
(plugin.settle !== undefined && typeof plugin.settle !== "function") ||
|
|
281
|
+
(plugin.afterPhysics !== undefined && typeof plugin.afterPhysics !== "function"))
|
|
267
282
|
throw new Error(`TC_PLUGIN_CONTRACT: Plugin ${plugin.id} is missing lifecycle methods.`);
|
|
268
283
|
for (const [key, service] of Object.entries(plugin.services ?? {})) {
|
|
269
284
|
if (service === undefined)
|
|
@@ -407,6 +422,7 @@ export class RuntimeHost {
|
|
|
407
422
|
world: this.world,
|
|
408
423
|
fixedDt: this.fixedDt,
|
|
409
424
|
seed: this.seed,
|
|
425
|
+
afterPhysics: () => this.afterPhysics(),
|
|
410
426
|
systems: [
|
|
411
427
|
...engineSystems.map((system) => ({
|
|
412
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.11";
|
|
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];
|
|
@@ -101,6 +101,75 @@ export interface SystemContext extends EngineFacade {
|
|
|
101
101
|
readonly render: RenderService;
|
|
102
102
|
readonly compute: ComputeService;
|
|
103
103
|
readonly navigation: NavigationService;
|
|
104
|
+
readonly ropes: RopeService;
|
|
105
|
+
}
|
|
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. */
|
|
108
|
+
export type RopeCollider = {
|
|
109
|
+
kind: "sphere";
|
|
110
|
+
id: string;
|
|
111
|
+
center: Vec3;
|
|
112
|
+
radius: number;
|
|
113
|
+
} | {
|
|
114
|
+
kind: "plane";
|
|
115
|
+
id: string;
|
|
116
|
+
normal: Vec3;
|
|
117
|
+
offset: number;
|
|
118
|
+
};
|
|
119
|
+
export interface RopeDefinition {
|
|
120
|
+
id: string;
|
|
121
|
+
points: readonly Vec3[];
|
|
122
|
+
/** Kilograms per particle, positive and at most 1e6. */
|
|
123
|
+
mass: number;
|
|
124
|
+
radius?: number;
|
|
125
|
+
/** Defaults to the host interval; an explicit value must exactly match it. */
|
|
126
|
+
fixedDt?: number;
|
|
127
|
+
/** When true, step once after each physics phase; defaults to false (manual stepping). */
|
|
128
|
+
autoStep?: boolean;
|
|
129
|
+
substeps?: number;
|
|
130
|
+
iterations?: number;
|
|
131
|
+
damping?: number;
|
|
132
|
+
gravity?: Vec3;
|
|
133
|
+
anchors?: readonly {
|
|
134
|
+
index: number;
|
|
135
|
+
position: Vec3;
|
|
136
|
+
}[];
|
|
137
|
+
}
|
|
138
|
+
export interface RopeState {
|
|
139
|
+
tick: number;
|
|
140
|
+
simulatedSeconds: number;
|
|
141
|
+
disposed: boolean;
|
|
142
|
+
positions: Vec3[];
|
|
143
|
+
velocities: Vec3[];
|
|
144
|
+
anchors: {
|
|
145
|
+
index: number;
|
|
146
|
+
position: Vec3;
|
|
147
|
+
}[];
|
|
148
|
+
contacts: {
|
|
149
|
+
tick: number;
|
|
150
|
+
particle: number;
|
|
151
|
+
colliderId: string;
|
|
152
|
+
penetration: number;
|
|
153
|
+
}[];
|
|
154
|
+
maxDistanceError: number;
|
|
155
|
+
resources: {
|
|
156
|
+
particles: number;
|
|
157
|
+
constraints: number;
|
|
158
|
+
anchors: number;
|
|
159
|
+
colliders: number;
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export interface RopeService {
|
|
163
|
+
create(definition: RopeDefinition): void;
|
|
164
|
+
/** Explicit ticks for manual ropes only. Auto-stepped ropes reject this call. A failed batch leaves the rope unchanged. */
|
|
165
|
+
step(id: string, ticks?: number): RopeState;
|
|
166
|
+
inspect(id: string): RopeState;
|
|
167
|
+
setColliders(id: string, colliders: readonly RopeCollider[]): void;
|
|
168
|
+
setAnchor(id: string, index: number, position: Vec3): void;
|
|
169
|
+
releaseAnchor(id: string, index: number): void;
|
|
170
|
+
applyForce(id: string, index: number, force: Vec3): void;
|
|
171
|
+
applyImpulse(id: string, index: number, impulse: Vec3): void;
|
|
172
|
+
destroy(id: string): void;
|
|
104
173
|
}
|
|
105
174
|
/** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
|
|
106
175
|
* Searches allocate sparse state and depend on available memory. At most 2^32-1 cells may be defined.
|
package/lib/sdk/index.js
CHANGED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { cloneSerializable } from "../runtime/random.js";
|
|
2
|
+
import { createRope } from "./rope.js";
|
|
3
|
+
const fail = (code, detail) => { throw new Error(`TC_ROPE_${code}: ${detail}`); };
|
|
4
|
+
const validId = (id) => typeof id === "string" && id.trim() === id && id.length > 0 && id.length <= 128;
|
|
5
|
+
function createEntry(input, fixedDt) {
|
|
6
|
+
const definition = cloneSerializable(input);
|
|
7
|
+
if (!definition || !validId(definition.id))
|
|
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.");
|
|
11
|
+
if (definition.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
12
|
+
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
13
|
+
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
14
|
+
return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
|
|
15
|
+
const solver = createRope({ ...definition, fixedDt });
|
|
16
|
+
return { definition, solver };
|
|
17
|
+
}
|
|
18
|
+
function disposeEntries(entries) {
|
|
19
|
+
for (const { solver } of entries.values())
|
|
20
|
+
solver.dispose();
|
|
21
|
+
entries.clear();
|
|
22
|
+
}
|
|
23
|
+
function parseSnapshot(value, fixedDt) {
|
|
24
|
+
if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
|
|
25
|
+
return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
|
|
26
|
+
const entries = new Map();
|
|
27
|
+
try {
|
|
28
|
+
for (const item of value.ropes) {
|
|
29
|
+
if (!item || typeof item !== "object")
|
|
30
|
+
fail("SNAPSHOT", "Invalid rope entry.");
|
|
31
|
+
const entry = createEntry(item.definition, fixedDt);
|
|
32
|
+
if (entries.has(entry.definition.id)) {
|
|
33
|
+
entry.solver.dispose();
|
|
34
|
+
fail("SNAPSHOT", "Duplicate rope id.");
|
|
35
|
+
}
|
|
36
|
+
entries.set(entry.definition.id, entry);
|
|
37
|
+
entry.solver.restore(item.state);
|
|
38
|
+
}
|
|
39
|
+
return entries;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
disposeEntries(entries);
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** CPU rope ownership; optional host-scheduled stepping remains independent from Rapier. */
|
|
47
|
+
export const createRopePlugin = ({ fixedDt }) => {
|
|
48
|
+
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
49
|
+
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
50
|
+
const hostDt = fixedDt;
|
|
51
|
+
let entries = new Map();
|
|
52
|
+
let disposed = false;
|
|
53
|
+
const alive = () => { if (disposed)
|
|
54
|
+
fail("DISPOSED", "Rope service is disposed."); };
|
|
55
|
+
const get = (id) => {
|
|
56
|
+
alive();
|
|
57
|
+
if (!validId(id))
|
|
58
|
+
return fail("ID", "Expected a nonempty rope id.");
|
|
59
|
+
return entries.get(id) ?? fail("MISSING", `Rope '${id}' does not exist.`);
|
|
60
|
+
};
|
|
61
|
+
const service = {
|
|
62
|
+
create(definition) {
|
|
63
|
+
alive();
|
|
64
|
+
if (!definition || !validId(definition.id))
|
|
65
|
+
return fail("ID", "Expected a nonempty rope id.");
|
|
66
|
+
if (entries.has(definition.id))
|
|
67
|
+
return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
|
|
68
|
+
if (entries.size >= 256)
|
|
69
|
+
return fail("CAPACITY", "At most 256 ropes per host.");
|
|
70
|
+
const entry = createEntry(definition, hostDt);
|
|
71
|
+
entries.set(entry.definition.id, entry);
|
|
72
|
+
},
|
|
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
|
+
},
|
|
79
|
+
inspect(id) { return get(id).solver.inspect(); },
|
|
80
|
+
setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
|
|
81
|
+
setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
|
|
82
|
+
releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
|
|
83
|
+
applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
|
|
84
|
+
applyImpulse(id, index, impulse) { get(id).solver.applyImpulse(index, cloneSerializable(impulse)); },
|
|
85
|
+
destroy(id) { const entry = get(id); entry.solver.dispose(); entries.delete(id); },
|
|
86
|
+
};
|
|
87
|
+
return {
|
|
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
|
+
},
|
|
117
|
+
legacySnapshot: () => ({ version: 1, ropes: [] }),
|
|
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() })) }),
|
|
119
|
+
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
|
120
|
+
restore(value) { alive(); const staged = parseSnapshot(value, hostDt); disposeEntries(entries); entries = staged; },
|
|
121
|
+
dispose() { if (disposed)
|
|
122
|
+
return; disposed = true; disposeEntries(entries); },
|
|
123
|
+
};
|
|
124
|
+
};
|