pelican-engine 0.3.8 → 0.3.10
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 +18 -0
- package/distribution.json +14 -12
- package/docs/STATUS.md +2 -1
- package/docs/api/REFERENCE.md +154 -88
- package/docs/api/index.json +149 -90
- package/docs/npm/GAME-AUTHOR.md +18 -0
- package/engine.manifest.json +1 -1
- package/lib/renderer/gameplay.js +2 -0
- package/lib/runtime/host.js +4 -0
- package/lib/sdk/index.d.ts +68 -1
- package/lib/sdk/index.js +1 -1
- package/lib/simulation/rope-plugin.js +89 -0
- package/lib/simulation/rope.js +318 -0
- package/package.json +1 -1
package/docs/npm/GAME-AUTHOR.md
CHANGED
|
@@ -118,6 +118,24 @@ 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 })` 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`.
|
|
131
|
+
|
|
132
|
+
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.
|
|
138
|
+
|
|
121
139
|
Keyboard bindings use the browser's `KeyboardEvent.key` values, not physical
|
|
122
140
|
`KeyboardEvent.code` values. For example, use `input: { up: ["w", "W", "ArrowUp"],
|
|
123
141
|
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
|
@@ -103,6 +103,7 @@ export class RuntimeHost {
|
|
|
103
103
|
this.scheduler = createdScheduler;
|
|
104
104
|
for (const factory of options.plugins ?? []) {
|
|
105
105
|
const plugin = factory({
|
|
106
|
+
fixedDt: this.fixedDt,
|
|
106
107
|
world: this.world,
|
|
107
108
|
scene: this.scene,
|
|
108
109
|
events: (type) => this.scheduler.events(type),
|
|
@@ -197,6 +198,9 @@ export class RuntimeHost {
|
|
|
197
198
|
get navigation() {
|
|
198
199
|
return host.service("navigation");
|
|
199
200
|
},
|
|
201
|
+
get ropes() {
|
|
202
|
+
return host.service("ropes");
|
|
203
|
+
},
|
|
200
204
|
entities: {
|
|
201
205
|
resolve: (entity) => this.world.resolve(entity),
|
|
202
206
|
isAlive: (entity) => this.world.isAlive(entity),
|
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.10";
|
|
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,73 @@ 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
|
+
substeps?: number;
|
|
128
|
+
iterations?: number;
|
|
129
|
+
damping?: number;
|
|
130
|
+
gravity?: Vec3;
|
|
131
|
+
anchors?: readonly {
|
|
132
|
+
index: number;
|
|
133
|
+
position: Vec3;
|
|
134
|
+
}[];
|
|
135
|
+
}
|
|
136
|
+
export interface RopeState {
|
|
137
|
+
tick: number;
|
|
138
|
+
simulatedSeconds: number;
|
|
139
|
+
disposed: boolean;
|
|
140
|
+
positions: Vec3[];
|
|
141
|
+
velocities: Vec3[];
|
|
142
|
+
anchors: {
|
|
143
|
+
index: number;
|
|
144
|
+
position: Vec3;
|
|
145
|
+
}[];
|
|
146
|
+
contacts: {
|
|
147
|
+
tick: number;
|
|
148
|
+
particle: number;
|
|
149
|
+
colliderId: string;
|
|
150
|
+
penetration: number;
|
|
151
|
+
}[];
|
|
152
|
+
maxDistanceError: number;
|
|
153
|
+
resources: {
|
|
154
|
+
particles: number;
|
|
155
|
+
constraints: number;
|
|
156
|
+
anchors: number;
|
|
157
|
+
colliders: number;
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export interface RopeService {
|
|
161
|
+
create(definition: RopeDefinition): void;
|
|
162
|
+
/** Explicit fixed solver ticks; not automatically advanced with the host. A failed batch leaves the rope unchanged. */
|
|
163
|
+
step(id: string, ticks?: number): RopeState;
|
|
164
|
+
inspect(id: string): RopeState;
|
|
165
|
+
setColliders(id: string, colliders: readonly RopeCollider[]): void;
|
|
166
|
+
setAnchor(id: string, index: number, position: Vec3): void;
|
|
167
|
+
releaseAnchor(id: string, index: number): void;
|
|
168
|
+
applyForce(id: string, index: number, force: Vec3): void;
|
|
169
|
+
applyImpulse(id: string, index: number, impulse: Vec3): void;
|
|
170
|
+
destroy(id: string): void;
|
|
104
171
|
}
|
|
105
172
|
/** Integer cells on a finite 2D orthogonal grid; not continuous, flying or underwater navigation.
|
|
106
173
|
* 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,89 @@
|
|
|
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.fixedDt !== undefined && definition.fixedDt !== fixedDt)
|
|
10
|
+
return fail("FIXED_DT", "Rope fixedDt must exactly match the host fixedDt.");
|
|
11
|
+
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
12
|
+
return fail("FIXED_DT", "Host fixedDt must be within [1/1000, 1/30] seconds for ropes.");
|
|
13
|
+
const solver = createRope({ ...definition, fixedDt });
|
|
14
|
+
return { definition, solver };
|
|
15
|
+
}
|
|
16
|
+
function disposeEntries(entries) {
|
|
17
|
+
for (const { solver } of entries.values())
|
|
18
|
+
solver.dispose();
|
|
19
|
+
entries.clear();
|
|
20
|
+
}
|
|
21
|
+
function parseSnapshot(value, fixedDt) {
|
|
22
|
+
if (!value || typeof value !== "object" || value.version !== 1 || !Array.isArray(value.ropes) || value.ropes.length > 256)
|
|
23
|
+
return fail("SNAPSHOT", "Expected a version 1 rope snapshot with at most 256 ropes.");
|
|
24
|
+
const entries = new Map();
|
|
25
|
+
try {
|
|
26
|
+
for (const item of value.ropes) {
|
|
27
|
+
if (!item || typeof item !== "object")
|
|
28
|
+
fail("SNAPSHOT", "Invalid rope entry.");
|
|
29
|
+
const entry = createEntry(item.definition, fixedDt);
|
|
30
|
+
if (entries.has(entry.definition.id)) {
|
|
31
|
+
entry.solver.dispose();
|
|
32
|
+
fail("SNAPSHOT", "Duplicate rope id.");
|
|
33
|
+
}
|
|
34
|
+
entries.set(entry.definition.id, entry);
|
|
35
|
+
entry.solver.restore(item.state);
|
|
36
|
+
}
|
|
37
|
+
return entries;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
disposeEntries(entries);
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Explicitly stepped CPU rope ownership; independent from the Rapier physics plugin. */
|
|
45
|
+
export const createRopePlugin = ({ fixedDt }) => {
|
|
46
|
+
if (typeof fixedDt !== "number" || !Number.isFinite(fixedDt))
|
|
47
|
+
return fail("FIXED_DT", "RuntimeHost must provide a finite fixed interval.");
|
|
48
|
+
const hostDt = fixedDt;
|
|
49
|
+
let entries = new Map();
|
|
50
|
+
let disposed = false;
|
|
51
|
+
const alive = () => { if (disposed)
|
|
52
|
+
fail("DISPOSED", "Rope service is disposed."); };
|
|
53
|
+
const get = (id) => {
|
|
54
|
+
alive();
|
|
55
|
+
if (!validId(id))
|
|
56
|
+
return fail("ID", "Expected a nonempty rope id.");
|
|
57
|
+
return entries.get(id) ?? fail("MISSING", `Rope '${id}' does not exist.`);
|
|
58
|
+
};
|
|
59
|
+
const service = {
|
|
60
|
+
create(definition) {
|
|
61
|
+
alive();
|
|
62
|
+
if (!definition || !validId(definition.id))
|
|
63
|
+
return fail("ID", "Expected a nonempty rope id.");
|
|
64
|
+
if (entries.has(definition.id))
|
|
65
|
+
return fail("DUPLICATE", `Rope '${definition.id}' already exists.`);
|
|
66
|
+
if (entries.size >= 256)
|
|
67
|
+
return fail("CAPACITY", "At most 256 ropes per host.");
|
|
68
|
+
const entry = createEntry(definition, hostDt);
|
|
69
|
+
entries.set(entry.definition.id, entry);
|
|
70
|
+
},
|
|
71
|
+
step(id, ticks) { return get(id).solver.step(ticks); },
|
|
72
|
+
inspect(id) { return get(id).solver.inspect(); },
|
|
73
|
+
setColliders(id, colliders) { get(id).solver.setColliders(cloneSerializable(colliders)); },
|
|
74
|
+
setAnchor(id, index, position) { get(id).solver.setAnchor(index, cloneSerializable(position)); },
|
|
75
|
+
releaseAnchor(id, index) { get(id).solver.releaseAnchor(index); },
|
|
76
|
+
applyForce(id, index, force) { get(id).solver.applyForce(index, cloneSerializable(force)); },
|
|
77
|
+
applyImpulse(id, index, impulse) { get(id).solver.applyImpulse(index, cloneSerializable(impulse)); },
|
|
78
|
+
destroy(id) { const entry = get(id); entry.solver.dispose(); entries.delete(id); },
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
id: "$engine.ropes", version: "1", services: { ropes: service },
|
|
82
|
+
legacySnapshot: () => ({ version: 1, ropes: [] }),
|
|
83
|
+
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
|
+
validateSnapshot(value) { const staged = parseSnapshot(value, hostDt); disposeEntries(staged); },
|
|
85
|
+
restore(value) { alive(); const staged = parseSnapshot(value, hostDt); disposeEntries(entries); entries = staged; },
|
|
86
|
+
dispose() { if (disposed)
|
|
87
|
+
return; disposed = true; disposeEntries(entries); },
|
|
88
|
+
};
|
|
89
|
+
};
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
export class RopeError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(code, message) {
|
|
4
|
+
super(`${code}: ${message}`);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = "RopeError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const fail = (code, reason) => { throw new RopeError(code, reason); };
|
|
10
|
+
const vec = (v) => Array.isArray(v) && v.length === 3 && v.every(x => typeof x === "number" && Number.isFinite(x) && Math.abs(x) <= 1e6);
|
|
11
|
+
const copy = (v) => [v[0], v[1], v[2]];
|
|
12
|
+
const length = (v) => Math.hypot(v[0], v[1], v[2]);
|
|
13
|
+
const validVec = (v, name) => vec(v) ? copy(v) : fail("ROPE_INPUT", `${name} must be a finite 3D vector (|component| <= 1e6)`);
|
|
14
|
+
const positive = (v, name) => typeof v === "number" && Number.isFinite(v) && v > 0 && v <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be positive and <= 1e6`);
|
|
15
|
+
const bounded = (v, name, max) => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= max ? v : fail("ROPE_INPUT", `${name} must be within [0, ${max}]`);
|
|
16
|
+
const indexOf = (v, count) => Number.isSafeInteger(v) && v >= 0 && v < count ? v : fail("ROPE_INDEX", "particle index out of range");
|
|
17
|
+
const finiteState = (v) => vec(v);
|
|
18
|
+
function readColliders(input) {
|
|
19
|
+
if (!Array.isArray(input) || input.length > 4096)
|
|
20
|
+
fail("ROPE_INPUT", "colliders must be an array (max 4096)");
|
|
21
|
+
const ids = new Set();
|
|
22
|
+
return input.map((c) => {
|
|
23
|
+
if (!c || typeof c !== "object" || typeof c.id !== "string" || !c.id || ids.has(c.id))
|
|
24
|
+
fail("ROPE_INPUT", "collider ids must be unique nonempty strings");
|
|
25
|
+
ids.add(c.id);
|
|
26
|
+
if (c.kind === "sphere")
|
|
27
|
+
return { kind: "sphere", id: c.id, center: validVec(c.center, "center"), radius: positive(c.radius, "sphere radius") };
|
|
28
|
+
if (c.kind === "plane") {
|
|
29
|
+
const normal = validVec(c.normal, "normal"), n = length(normal);
|
|
30
|
+
if (Math.abs(n - 1) > 1e-6)
|
|
31
|
+
fail("ROPE_INPUT", "plane normal must have unit length");
|
|
32
|
+
return { kind: "plane", id: c.id, normal, offset: boundedSigned(c.offset, "plane offset") };
|
|
33
|
+
}
|
|
34
|
+
return fail("ROPE_INPUT", "unsupported collider kind");
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function boundedSigned(v, name) {
|
|
38
|
+
return typeof v === "number" && Number.isFinite(v) && Math.abs(v) <= 1e6 ? v : fail("ROPE_INPUT", `${name} must be finite (|value| <= 1e6)`);
|
|
39
|
+
}
|
|
40
|
+
function readAnchors(input, count) {
|
|
41
|
+
if (!Array.isArray(input) || input.length > count)
|
|
42
|
+
fail("ROPE_INPUT", "anchors must be an array of unique particle indices");
|
|
43
|
+
const result = new Map();
|
|
44
|
+
for (const a of input) {
|
|
45
|
+
if (!a || typeof a !== "object")
|
|
46
|
+
fail("ROPE_INPUT", "invalid anchor");
|
|
47
|
+
const i = indexOf(a.index, count);
|
|
48
|
+
if (result.has(i))
|
|
49
|
+
fail("ROPE_INPUT", "duplicate anchor index");
|
|
50
|
+
result.set(i, validVec(a.position, "anchor position"));
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
export function createRope(options) {
|
|
55
|
+
if (!options || typeof options !== "object" || !Array.isArray(options.points) || options.points.length < 2 || options.points.length > 4096)
|
|
56
|
+
fail("ROPE_INPUT", "points must contain 2..4096 particles");
|
|
57
|
+
const positions = options.points.map((p, i) => validVec(p, `points[${i}]`));
|
|
58
|
+
const mass = positive(options.mass, "mass");
|
|
59
|
+
const radius = positive(options.radius ?? 0.025, "radius");
|
|
60
|
+
const fixedDt = positive(options.fixedDt ?? 1 / 60, "fixedDt");
|
|
61
|
+
if (fixedDt < 1 / 1000 || fixedDt > 1 / 30)
|
|
62
|
+
fail("ROPE_INPUT", "fixedDt must be in [1/1000, 1/30]");
|
|
63
|
+
const substeps = options.substeps ?? 4, iterations = options.iterations ?? 12;
|
|
64
|
+
if (!Number.isInteger(substeps) || substeps < 1 || substeps > 32 || !Number.isInteger(iterations) || iterations < 1 || iterations > 64)
|
|
65
|
+
fail("ROPE_INPUT", "substeps/iterations must be positive bounded integers");
|
|
66
|
+
const damping = bounded(options.damping ?? 0.15, "damping", 100);
|
|
67
|
+
const gravity = validVec(options.gravity ?? [0, -9.81, 0], "gravity");
|
|
68
|
+
const restLengths = positions.slice(1).map((p, i) => length([p[0] - positions[i][0], p[1] - positions[i][1], p[2] - positions[i][2]]));
|
|
69
|
+
if (restLengths.some(d => !Number.isFinite(d) || d <= 1e-8))
|
|
70
|
+
fail("ROPE_INPUT", "adjacent particles must have distinct positions");
|
|
71
|
+
const anchors = readAnchors(options.anchors ?? [{ index: 0, position: positions[0] }], positions.length);
|
|
72
|
+
for (const [i, p] of anchors)
|
|
73
|
+
positions[i] = copy(p);
|
|
74
|
+
return new RopeSolver({ mass, radius, fixedDt, substeps, iterations, damping, gravity, restLengths }, positions, anchors);
|
|
75
|
+
}
|
|
76
|
+
export class RopeSolver {
|
|
77
|
+
config;
|
|
78
|
+
positions;
|
|
79
|
+
anchors;
|
|
80
|
+
tickValue = 0;
|
|
81
|
+
disposedValue = false;
|
|
82
|
+
velocities;
|
|
83
|
+
forces;
|
|
84
|
+
colliders = [];
|
|
85
|
+
contacts = [];
|
|
86
|
+
constructor(config, positions, anchors) {
|
|
87
|
+
this.config = config;
|
|
88
|
+
this.positions = positions;
|
|
89
|
+
this.anchors = anchors;
|
|
90
|
+
this.velocities = positions.map(() => [0, 0, 0]);
|
|
91
|
+
this.forces = positions.map(() => [0, 0, 0]);
|
|
92
|
+
}
|
|
93
|
+
alive() { if (this.disposedValue)
|
|
94
|
+
fail("ROPE_DISPOSED", "rope has been disposed"); }
|
|
95
|
+
/** Copies all inputs: callers may mutate their arrays after calling this. */
|
|
96
|
+
setColliders(input) {
|
|
97
|
+
this.alive();
|
|
98
|
+
this.colliders = readColliders(input);
|
|
99
|
+
this.contacts = [];
|
|
100
|
+
}
|
|
101
|
+
/** Pins or moves a particle; new anchor position takes effect immediately. */
|
|
102
|
+
setAnchor(index, position) {
|
|
103
|
+
this.alive();
|
|
104
|
+
const i = indexOf(index, this.positions.length), p = validVec(position, "anchor position");
|
|
105
|
+
this.anchors.set(i, p);
|
|
106
|
+
this.positions[i] = copy(p);
|
|
107
|
+
this.velocities[i] = [0, 0, 0];
|
|
108
|
+
this.forces[i] = [0, 0, 0];
|
|
109
|
+
}
|
|
110
|
+
releaseAnchor(index) { this.alive(); this.anchors.delete(indexOf(index, this.positions.length)); }
|
|
111
|
+
/** N·s (kg·m/s); changes velocity immediately, not position. */
|
|
112
|
+
applyImpulse(index, impulse) {
|
|
113
|
+
this.alive();
|
|
114
|
+
const i = indexOf(index, this.positions.length), v = validVec(impulse, "impulse");
|
|
115
|
+
if (this.anchors.has(i))
|
|
116
|
+
fail("ROPE_ANCHORED", "cannot apply impulse to an anchor");
|
|
117
|
+
const next = [0, 1, 2].map(k => this.velocities[i][k] + v[k] / this.config.mass);
|
|
118
|
+
if (!finiteState(next))
|
|
119
|
+
fail("ROPE_NUMERIC", "impulse exceeds numeric bounds");
|
|
120
|
+
this.velocities[i] = next;
|
|
121
|
+
}
|
|
122
|
+
/** Newtons accumulated for exactly one next fixed tick; not a persistent force. */
|
|
123
|
+
applyForce(index, force) {
|
|
124
|
+
this.alive();
|
|
125
|
+
const i = indexOf(index, this.positions.length), v = validVec(force, "force");
|
|
126
|
+
if (this.anchors.has(i))
|
|
127
|
+
fail("ROPE_ANCHORED", "cannot apply force to an anchor");
|
|
128
|
+
const next = [0, 1, 2].map(k => this.forces[i][k] + v[k]);
|
|
129
|
+
if (!finiteState(next))
|
|
130
|
+
fail("ROPE_NUMERIC", "force accumulation exceeds numeric bounds");
|
|
131
|
+
this.forces[i] = next;
|
|
132
|
+
}
|
|
133
|
+
/** Exactly N fixed ticks; a failed batch restores the state before the first tick. */
|
|
134
|
+
step(steps = 1) {
|
|
135
|
+
this.alive();
|
|
136
|
+
if (!Number.isSafeInteger(steps) || steps < 1 || steps > 1000 || this.tickValue + steps > Number.MAX_SAFE_INTEGER)
|
|
137
|
+
fail("ROPE_INPUT", "steps must be an integer in [1,1000] without tick overflow");
|
|
138
|
+
// stepOne replaces these arrays rather than mutating the prior committed state.
|
|
139
|
+
const before = {
|
|
140
|
+
positions: this.positions,
|
|
141
|
+
velocities: this.velocities,
|
|
142
|
+
forces: this.forces,
|
|
143
|
+
contacts: this.contacts,
|
|
144
|
+
tick: this.tickValue,
|
|
145
|
+
};
|
|
146
|
+
try {
|
|
147
|
+
for (let s = 0; s < steps; s++)
|
|
148
|
+
this.stepOne();
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
this.positions = before.positions;
|
|
152
|
+
this.velocities = before.velocities;
|
|
153
|
+
this.forces = before.forces;
|
|
154
|
+
this.contacts = before.contacts;
|
|
155
|
+
this.tickValue = before.tick;
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
return this.inspect();
|
|
159
|
+
}
|
|
160
|
+
stepOne() {
|
|
161
|
+
const { fixedDt, substeps, iterations, mass, gravity, damping, restLengths, radius } = this.config;
|
|
162
|
+
const h = fixedDt / substeps, decay = Math.exp(-damping * h);
|
|
163
|
+
// Work on copies; numeric failures must never leave half a tick applied.
|
|
164
|
+
const positions = this.positions.map(copy), velocities = this.velocities.map(copy);
|
|
165
|
+
const contacts = new Map();
|
|
166
|
+
for (let sub = 0; sub < substeps; sub++) {
|
|
167
|
+
const previous = positions.map(copy);
|
|
168
|
+
for (let i = 0; i < positions.length; i++) {
|
|
169
|
+
const anchor = this.anchors.get(i);
|
|
170
|
+
if (anchor) {
|
|
171
|
+
positions[i] = copy(anchor);
|
|
172
|
+
velocities[i] = [0, 0, 0];
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
for (let k = 0; k < 3; k++) {
|
|
176
|
+
velocities[i][k] = velocities[i][k] * decay + (gravity[k] + this.forces[i][k] / mass) * h;
|
|
177
|
+
positions[i][k] += velocities[i][k] * h;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
181
|
+
for (let j = 0; j < restLengths.length; j++) {
|
|
182
|
+
const a = positions[j], b = positions[j + 1];
|
|
183
|
+
let dx = b[0] - a[0], dy = b[1] - a[1], dz = b[2] - a[2];
|
|
184
|
+
let dist = Math.hypot(dx, dy, dz);
|
|
185
|
+
// If a contact collapses a link, separate it on a deterministic axis.
|
|
186
|
+
if (dist < 1e-12) {
|
|
187
|
+
dx = 1e-6;
|
|
188
|
+
dy = 0;
|
|
189
|
+
dz = 0;
|
|
190
|
+
dist = 1e-6;
|
|
191
|
+
}
|
|
192
|
+
const wa = this.anchors.has(j) ? 0 : 1, wb = this.anchors.has(j + 1) ? 0 : 1;
|
|
193
|
+
if (wa + wb === 0)
|
|
194
|
+
continue; // two incompatible kinematic anchors are intentionally authoritative
|
|
195
|
+
const d = (dist - restLengths[j]) / (dist * (wa + wb));
|
|
196
|
+
for (let k = 0; k < 3; k++) {
|
|
197
|
+
const correction = (k === 0 ? dx : k === 1 ? dy : dz) * d;
|
|
198
|
+
a[k] += correction * wa;
|
|
199
|
+
b[k] -= correction * wb;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (let i = 0; i < positions.length; i++) {
|
|
203
|
+
if (this.anchors.has(i))
|
|
204
|
+
continue; // anchors cannot be displaced by external colliders
|
|
205
|
+
const p = positions[i];
|
|
206
|
+
for (const c of this.colliders) {
|
|
207
|
+
let depth = 0;
|
|
208
|
+
let normal;
|
|
209
|
+
if (c.kind === "plane") {
|
|
210
|
+
depth = c.offset + radius - (p[0] * c.normal[0] + p[1] * c.normal[1] + p[2] * c.normal[2]);
|
|
211
|
+
normal = c.normal;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
const delta = [p[0] - c.center[0], p[1] - c.center[1], p[2] - c.center[2]];
|
|
215
|
+
const distance = length(delta);
|
|
216
|
+
depth = c.radius + radius - distance;
|
|
217
|
+
normal = distance < 1e-12 ? [1, 0, 0] : [delta[0] / distance, delta[1] / distance, delta[2] / distance];
|
|
218
|
+
}
|
|
219
|
+
if (depth <= 0)
|
|
220
|
+
continue;
|
|
221
|
+
for (let k = 0; k < 3; k++)
|
|
222
|
+
p[k] += normal[k] * depth;
|
|
223
|
+
const key = `${i}:${c.id}`;
|
|
224
|
+
const existing = contacts.get(key);
|
|
225
|
+
if (existing)
|
|
226
|
+
existing.penetration = Math.max(existing.penetration, depth);
|
|
227
|
+
else
|
|
228
|
+
contacts.set(key, { tick: this.tickValue + 1, particle: i, colliderId: c.id, penetration: depth });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (let i = 0; i < positions.length; i++) {
|
|
233
|
+
if (this.anchors.has(i))
|
|
234
|
+
continue;
|
|
235
|
+
for (let k = 0; k < 3; k++)
|
|
236
|
+
velocities[i][k] = (positions[i][k] - previous[i][k]) / h;
|
|
237
|
+
if (!finiteState(positions[i]) || !finiteState(velocities[i]))
|
|
238
|
+
fail("ROPE_NUMERIC", "solver exceeded finite state bounds");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this.positions = positions;
|
|
242
|
+
this.velocities = velocities;
|
|
243
|
+
this.forces = positions.map(() => [0, 0, 0]);
|
|
244
|
+
this.contacts = [...contacts.values()];
|
|
245
|
+
this.tickValue++;
|
|
246
|
+
}
|
|
247
|
+
inspect() {
|
|
248
|
+
if (this.disposedValue)
|
|
249
|
+
return { tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: true, positions: [], velocities: [], anchors: [], contacts: [], maxDistanceError: 0, resources: { particles: 0, constraints: 0, anchors: 0, colliders: 0 } };
|
|
250
|
+
const maxDistanceError = Math.max(0, ...this.config.restLengths.map((d, i) => {
|
|
251
|
+
const a = this.positions[i], b = this.positions[i + 1];
|
|
252
|
+
return Math.abs(Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) - d);
|
|
253
|
+
}));
|
|
254
|
+
return {
|
|
255
|
+
tick: this.tickValue, simulatedSeconds: this.tickValue * this.config.fixedDt, disposed: false,
|
|
256
|
+
positions: this.positions.map(copy), velocities: this.velocities.map(copy),
|
|
257
|
+
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,
|
|
259
|
+
resources: { particles: this.positions.length, constraints: this.config.restLengths.length, anchors: this.anchors.size, colliders: this.colliders.length },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
snapshot() {
|
|
263
|
+
this.alive();
|
|
264
|
+
const state = this.inspect();
|
|
265
|
+
return {
|
|
266
|
+
format: "pelican-rope-cpu", version: 1, tick: this.tickValue,
|
|
267
|
+
config: { ...this.config, gravity: copy(this.config.gravity), restLengths: [...this.config.restLengths] },
|
|
268
|
+
positions: state.positions, velocities: state.velocities, anchors: state.anchors,
|
|
269
|
+
forces: this.forces.map(copy), colliders: readColliders(this.colliders), contacts: state.contacts,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/** Accepts a JSON-round-tripped snapshot only from the same rope configuration. Atomic on failure. */
|
|
273
|
+
restore(input) {
|
|
274
|
+
this.alive();
|
|
275
|
+
if (!input || typeof input !== "object" || input.format !== "pelican-rope-cpu" || input.version !== 1 ||
|
|
276
|
+
!Number.isSafeInteger(input.tick) || input.tick < 0)
|
|
277
|
+
fail("ROPE_SNAPSHOT", "unsupported snapshot format or tick");
|
|
278
|
+
const c = input.config;
|
|
279
|
+
if (!c || c.mass !== this.config.mass || c.radius !== this.config.radius || c.fixedDt !== this.config.fixedDt ||
|
|
280
|
+
c.substeps !== this.config.substeps || c.iterations !== this.config.iterations || c.damping !== this.config.damping ||
|
|
281
|
+
!vec(c.gravity) || c.gravity.some((v, i) => v !== this.config.gravity[i]) ||
|
|
282
|
+
!Array.isArray(c.restLengths) || c.restLengths.length !== this.config.restLengths.length ||
|
|
283
|
+
c.restLengths.some((v, i) => v !== this.config.restLengths[i]))
|
|
284
|
+
fail("ROPE_SNAPSHOT", "rope configuration/topology mismatch");
|
|
285
|
+
const count = this.positions.length;
|
|
286
|
+
if (!Array.isArray(input.positions) || !Array.isArray(input.velocities) || !Array.isArray(input.forces) ||
|
|
287
|
+
[input.positions, input.velocities, input.forces].some(v => v.length !== count || !v.every(vec)))
|
|
288
|
+
fail("ROPE_SNAPSHOT", "invalid particle state");
|
|
289
|
+
const anchors = readAnchors(input.anchors, count);
|
|
290
|
+
if ([...anchors].some(([i, p]) => p.some((v, k) => v !== input.positions[i][k]) || input.velocities[i].some(v => v !== 0) || input.forces[i].some(v => v !== 0)))
|
|
291
|
+
fail("ROPE_SNAPSHOT", "inconsistent anchored particle state");
|
|
292
|
+
const colliders = readColliders(input.colliders);
|
|
293
|
+
if (!Array.isArray(input.contacts) || input.contacts.length > count * Math.max(1, colliders.length) ||
|
|
294
|
+
input.contacts.some(contact => !contact || contact.tick !== input.tick ||
|
|
295
|
+
!Number.isSafeInteger(contact.particle) || contact.particle < 0 || contact.particle >= count ||
|
|
296
|
+
!colliders.some(collider => collider.id === contact.colliderId) ||
|
|
297
|
+
typeof contact.penetration !== "number" || !Number.isFinite(contact.penetration) || contact.penetration <= 0))
|
|
298
|
+
fail("ROPE_SNAPSHOT", "invalid contact state");
|
|
299
|
+
this.positions = input.positions.map(copy);
|
|
300
|
+
this.velocities = input.velocities.map(copy);
|
|
301
|
+
this.forces = input.forces.map(copy);
|
|
302
|
+
this.anchors = anchors;
|
|
303
|
+
this.colliders = colliders;
|
|
304
|
+
this.contacts = input.contacts.map(c => ({ ...c }));
|
|
305
|
+
this.tickValue = input.tick;
|
|
306
|
+
}
|
|
307
|
+
dispose() {
|
|
308
|
+
if (this.disposedValue)
|
|
309
|
+
return;
|
|
310
|
+
this.disposedValue = true;
|
|
311
|
+
this.positions = [];
|
|
312
|
+
this.velocities = [];
|
|
313
|
+
this.forces = [];
|
|
314
|
+
this.colliders = [];
|
|
315
|
+
this.contacts = [];
|
|
316
|
+
this.anchors.clear();
|
|
317
|
+
}
|
|
318
|
+
}
|