@jgengine/core 0.17.0 → 0.18.1
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/CHANGELOG.md +130 -2
- package/dist/ai/decisionGraph.d.ts +52 -0
- package/dist/ai/decisionGraph.js +82 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/anim/animGraph.d.ts +144 -0
- package/dist/anim/animGraph.js +286 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +26 -0
- package/dist/anim/locomotionGraph.js +33 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +10 -0
- package/dist/economy/currency.d.ts +16 -2
- package/dist/economy/currency.js +55 -14
- package/dist/economy/wallet.d.ts +5 -4
- package/dist/economy/wallet.js +8 -4
- package/dist/editor/commandApply.js +1 -0
- package/dist/editor/commands.d.ts +4 -1
- package/dist/editor/document.d.ts +2 -1
- package/dist/editor/document.js +89 -0
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +16 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +16 -0
- package/dist/game/cameraConfig.d.ts +8 -0
- package/dist/game/chat.d.ts +22 -1
- package/dist/game/chat.js +48 -19
- package/dist/game/controlGate.d.ts +6 -0
- package/dist/game/controlGate.js +30 -1
- package/dist/game/defineGame.d.ts +10 -1
- package/dist/game/events.d.ts +8 -1
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +97 -3
- package/dist/game/systemRuntime.js +6 -1
- package/dist/input/actionBindings.d.ts +2 -1
- package/dist/input/actionBindings.js +10 -8
- package/dist/input/actionContexts.d.ts +21 -0
- package/dist/input/actionContexts.js +56 -0
- package/dist/input/gamepadModel.d.ts +45 -0
- package/dist/input/gamepadModel.js +69 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +103 -1
- package/dist/movement/characterController.d.ts +76 -0
- package/dist/movement/characterController.js +216 -0
- package/dist/movement/freeFlight.d.ts +119 -0
- package/dist/movement/freeFlight.js +294 -0
- package/dist/movement/playerMovement.d.ts +10 -2
- package/dist/movement/playerMovement.js +218 -4
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +30 -7
- package/dist/movement/solidObstacles.js +27 -4
- package/dist/multiplayer/poseSyncGate.d.ts +3 -1
- package/dist/multiplayer/poseSyncGate.js +9 -1
- package/dist/multiplayer/presenceContract.d.ts +6 -0
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +11 -0
- package/dist/nav/navMesh.d.ts +33 -0
- package/dist/nav/navMesh.js +164 -0
- package/dist/physics/flightDynamics.d.ts +8 -0
- package/dist/physics/kinematicVehicle.d.ts +6 -1
- package/dist/physics/physicsBackend.d.ts +191 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +117 -0
- package/dist/physics/physicsWorldBackend.d.ts +44 -0
- package/dist/physics/physicsWorldBackend.js +485 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +96 -0
- package/dist/random/rng.d.ts +12 -1
- package/dist/random/rng.js +20 -3
- package/dist/render/environment.d.ts +23 -0
- package/dist/render/environment.js +1 -0
- package/dist/render/lookPreset.d.ts +11 -5
- package/dist/render/lookPreset.js +29 -4
- package/dist/render/postProcessing.d.ts +10 -1
- package/dist/render/sprite2d.d.ts +38 -0
- package/dist/render/sprite2d.js +40 -0
- package/dist/runtime/adapter.d.ts +20 -1
- package/dist/runtime/adapter.js +8 -1
- package/dist/runtime/commandInput.d.ts +15 -0
- package/dist/runtime/commandInput.js +13 -0
- package/dist/runtime/commandRunner.d.ts +44 -3
- package/dist/runtime/commandRunner.js +41 -3
- package/dist/runtime/gameContext.js +11 -1
- package/dist/runtime/gameContextTypes.d.ts +14 -4
- package/dist/runtime/gameRuntime.d.ts +33 -4
- package/dist/runtime/gameRuntime.js +26 -5
- package/dist/runtime/headlessRunner.d.ts +10 -0
- package/dist/runtime/headlessRunner.js +26 -8
- package/dist/runtime/hostPolicy.d.ts +3 -2
- package/dist/runtime/hostPolicy.js +8 -10
- package/dist/runtime/hostedGameRunner.js +34 -6
- package/dist/runtime/hostedWorldSession.d.ts +12 -16
- package/dist/runtime/hostedWorldSession.js +29 -6
- package/dist/runtime/hostedWorldStore.d.ts +20 -0
- package/dist/runtime/hostedWorldStore.js +1 -0
- package/dist/runtime/inputRecorder.d.ts +30 -0
- package/dist/runtime/inputRecorder.js +59 -0
- package/dist/runtime/inputSnapshot.d.ts +8 -0
- package/dist/runtime/inputSnapshot.js +1 -0
- package/dist/runtime/poseInterpolation.d.ts +36 -0
- package/dist/runtime/poseInterpolation.js +91 -0
- package/dist/runtime/prediction.d.ts +45 -0
- package/dist/runtime/prediction.js +62 -0
- package/dist/runtime/simContext.d.ts +45 -0
- package/dist/runtime/simContext.js +65 -0
- package/dist/runtime/simLoop.d.ts +52 -0
- package/dist/runtime/simLoop.js +72 -0
- package/dist/runtime/simSnapshot.d.ts +32 -0
- package/dist/runtime/simSnapshot.js +60 -0
- package/dist/runtime/snapshot.d.ts +8 -0
- package/dist/runtime/snapshotBuffer.d.ts +33 -0
- package/dist/runtime/snapshotBuffer.js +58 -0
- package/dist/runtime/transport.d.ts +38 -2
- package/dist/runtime/worldChunks.d.ts +2 -0
- package/dist/runtime/worldChunks.js +14 -0
- package/dist/runtime/worldProjection.d.ts +9 -0
- package/dist/runtime/worldProjection.js +17 -1
- package/dist/runtime/worldSnapshot.d.ts +6 -0
- package/dist/runtime/worldSnapshot.js +3 -0
- package/dist/scene/behaviorRuntime.d.ts +19 -1
- package/dist/scene/behaviorRuntime.js +55 -4
- package/dist/scene/behaviors.d.ts +8 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/sensor/perception.d.ts +60 -0
- package/dist/sensor/perception.js +142 -0
- package/dist/settings/graphicsProfile.d.ts +19 -0
- package/dist/settings/graphicsProfile.js +36 -0
- package/dist/time/accrueSince.d.ts +10 -0
- package/dist/time/accrueSince.js +9 -0
- package/dist/time/rateWindow.d.ts +16 -0
- package/dist/time/rateWindow.js +14 -0
- package/dist/time/serverTick.d.ts +21 -3
- package/dist/time/serverTick.js +14 -13
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/world/buildings.d.ts +12 -0
- package/dist/world/buildings.js +43 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +132 -0
- package/dist/world/cityKind.js +315 -15
- package/dist/world/features.d.ts +27 -1
- package/dist/world/features.js +7 -2
- package/dist/world/placement.d.ts +7 -0
- package/dist/world/placement.js +2 -0
- package/dist/world/placementController.d.ts +1 -1
- package/dist/world/placementController.js +7 -0
- package/dist/world/raceCircuit.d.ts +168 -0
- package/dist/world/raceCircuit.js +797 -0
- package/dist/world/terrain.d.ts +5 -1
- package/dist/world/terrain.js +5 -0
- package/dist/world/territory.d.ts +97 -0
- package/dist/world/territory.js +182 -0
- package/dist/world.d.ts +5 -3
- package/dist/world.js +4 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +2 -0
- package/skills/jgengine/api.md +98 -22
- package/skills/jgengine/capabilities.md +28 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +40 -18
- package/skills/jgengine-gameplay/capabilities.md +12 -0
- package/skills/jgengine-world/SKILL.md +6 -0
- package/skills/jgengine-world/api.md +312 -12
- package/skills/jgengine-world/capabilities.md +102 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runCommand } from "./commandRunner.js";
|
|
1
|
+
import { resolveCommandScope, runCommand } from "./commandRunner.js";
|
|
2
2
|
import { createEmptyPlayerRow, createRuntimeSnapshot, } from "./snapshot.js";
|
|
3
3
|
/** @internal */
|
|
4
4
|
export function createGameRuntime(definition) {
|
|
@@ -6,6 +6,7 @@ export function createGameRuntime(definition) {
|
|
|
6
6
|
let initialized = false;
|
|
7
7
|
return {
|
|
8
8
|
gameId: definition.gameId,
|
|
9
|
+
topology: definition.topology,
|
|
9
10
|
save: definition.save,
|
|
10
11
|
hasTick: loop?.onTick !== undefined,
|
|
11
12
|
hydrate(input) {
|
|
@@ -23,6 +24,7 @@ export function createGameRuntime(definition) {
|
|
|
23
24
|
get snapshot() {
|
|
24
25
|
return current;
|
|
25
26
|
},
|
|
27
|
+
nowMs: input.nowMs ?? Date.now(),
|
|
26
28
|
setSnapshot(next) {
|
|
27
29
|
current = next;
|
|
28
30
|
},
|
|
@@ -30,10 +32,18 @@ export function createGameRuntime(definition) {
|
|
|
30
32
|
}
|
|
31
33
|
return current;
|
|
32
34
|
},
|
|
33
|
-
runCommand(snapshot, actorUserId, commandName, input) {
|
|
34
|
-
return runCommand(snapshot, definition.commands, commandName, input, actorUserId);
|
|
35
|
+
runCommand(snapshot, actorUserId, commandName, input, nowMs) {
|
|
36
|
+
return runCommand(snapshot, definition.commands, commandName, input, actorUserId, nowMs ?? Date.now());
|
|
35
37
|
},
|
|
36
|
-
|
|
38
|
+
commandScope(commandName, input, actorUserId) {
|
|
39
|
+
return resolveCommandScope(definition.commands, commandName, input, actorUserId);
|
|
40
|
+
},
|
|
41
|
+
joinScope(userId, isNew) {
|
|
42
|
+
if (!loop?.onNewPlayer)
|
|
43
|
+
return { players: [userId], chunkKeys: [] };
|
|
44
|
+
return loop.joinScope?.(userId, isNew);
|
|
45
|
+
},
|
|
46
|
+
tick(snapshot, dtSeconds, nowMs) {
|
|
37
47
|
if (!loop?.onTick)
|
|
38
48
|
return snapshot;
|
|
39
49
|
let current = snapshot;
|
|
@@ -44,6 +54,7 @@ export function createGameRuntime(definition) {
|
|
|
44
54
|
get playerIds() {
|
|
45
55
|
return Object.keys(current.players);
|
|
46
56
|
},
|
|
57
|
+
nowMs: nowMs ?? Date.now(),
|
|
47
58
|
setSnapshot(next) {
|
|
48
59
|
current = next;
|
|
49
60
|
},
|
|
@@ -51,7 +62,7 @@ export function createGameRuntime(definition) {
|
|
|
51
62
|
loop.onTick(ctx, dtSeconds);
|
|
52
63
|
return current;
|
|
53
64
|
},
|
|
54
|
-
joinPlayer(snapshot, userId, isNew) {
|
|
65
|
+
joinPlayer(snapshot, userId, isNew, nowMs) {
|
|
55
66
|
const players = { ...snapshot.players };
|
|
56
67
|
if (!players[userId]) {
|
|
57
68
|
players[userId] = createEmptyPlayerRow(userId);
|
|
@@ -76,6 +87,7 @@ export function createGameRuntime(definition) {
|
|
|
76
87
|
return next;
|
|
77
88
|
},
|
|
78
89
|
player: { userId, isNew },
|
|
90
|
+
nowMs: nowMs ?? Date.now(),
|
|
79
91
|
setSnapshot(updated) {
|
|
80
92
|
next = updated;
|
|
81
93
|
},
|
|
@@ -96,3 +108,12 @@ export function createGameRuntime(definition) {
|
|
|
96
108
|
},
|
|
97
109
|
};
|
|
98
110
|
}
|
|
111
|
+
/** Seed a fresh player through the same onNewPlayer hook as joining, without hydrating or modifying a world. */
|
|
112
|
+
export function initialPlayerState(runtime, userId, nowMs = Date.now()) {
|
|
113
|
+
const snapshot = createRuntimeSnapshot({ gameId: runtime.gameId, serverId: "" });
|
|
114
|
+
const initialized = runtime.joinPlayer(snapshot, userId, true, nowMs);
|
|
115
|
+
const player = initialized.players[userId];
|
|
116
|
+
if (!player)
|
|
117
|
+
throw new Error(`onNewPlayer removed player "${userId}"`);
|
|
118
|
+
return structuredClone(player);
|
|
119
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { PointerAxisState } from "../input/pointerAxis.js";
|
|
|
3
3
|
import type { ModelAssetRef } from "../scene/assetCatalog.js";
|
|
4
4
|
import { type GameContext, type GameContextContent, type GameContextModels } from "./gameContext.js";
|
|
5
5
|
import type { InputSnapshot } from "./inputSnapshot.js";
|
|
6
|
+
import { type SimSnapshot } from "./simSnapshot.js";
|
|
6
7
|
/** One step's worth of player intent handed to {@link HeadlessRunner.step} — the held-action set and pointer state the shell would otherwise publish from the browser each frame. */
|
|
7
8
|
export interface HeadlessInput {
|
|
8
9
|
/** Replaces the held-action set for this step; edge detection (`justPressed`/`justReleased`) rolls the previous set forward. Omit to keep the last published set. */
|
|
@@ -31,8 +32,12 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
|
|
|
31
32
|
* `onTick` and don't need it.
|
|
32
33
|
*/
|
|
33
34
|
playerMovement?: boolean;
|
|
35
|
+
/** Movement tuning — when `playerMovement` is on, the shell resolves from `definition.world`/`physics`; pass a direct config to test flight or other movement overrides headlessly. */
|
|
36
|
+
movement?: import("../game/playableGame.js").PlayerMovementConfig;
|
|
34
37
|
/** Yaw heading (radians) fed to the movement controller when `playerMovement` is on; the shell supplies its camera yaw. */
|
|
35
38
|
heading?: number;
|
|
39
|
+
/** Pitch (radians) fed alongside yaw for 6DOF spectator flight when `alignWithLook` is set. */
|
|
40
|
+
pitch?: number;
|
|
36
41
|
/** Render-model lookup for collider auto-fit — pass what the shell would derive from `entityModels`/`objectModels` so headless raycasts hit the same boxes as play. */
|
|
37
42
|
models?: GameContextModels;
|
|
38
43
|
}
|
|
@@ -71,5 +76,10 @@ export interface HeadlessRunner {
|
|
|
71
76
|
publishInput(input: HeadlessInput): void;
|
|
72
77
|
/** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
|
|
73
78
|
readonly ui: HeadlessCommandInterface;
|
|
79
|
+
/** Capture the whole simulation (world, clock, loop, rng cursor) for {@link restore}; with a fixed `simulation.hz` and recorded input, replaying from it reproduces the run exactly. */
|
|
80
|
+
snapshot(): SimSnapshot;
|
|
81
|
+
restore(snapshot: SimSnapshot): void;
|
|
82
|
+
/** Current simulation tick (`ctx.sim.tick()`). */
|
|
83
|
+
tick(): number;
|
|
74
84
|
}
|
|
75
85
|
export declare function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolvePlayerMovementTuning, stepPlayerMovement, } from "../movement/playerMovement.js";
|
|
2
2
|
import { advanceBehaviors } from "../scene/behaviorRuntime.js";
|
|
3
3
|
import { createGameContext } from "./gameContext.js";
|
|
4
|
+
import { createContextSimSnapshot } from "./simSnapshot.js";
|
|
4
5
|
export function createHeadlessRunner(options) {
|
|
5
6
|
const { definition, loop } = options;
|
|
6
7
|
const content = options.content ?? {};
|
|
@@ -15,8 +16,13 @@ export function createHeadlessRunner(options) {
|
|
|
15
16
|
});
|
|
16
17
|
loop?.onInit?.(ctx);
|
|
17
18
|
loop?.onNewPlayer?.(ctx);
|
|
19
|
+
const simSnapshot = createContextSimSnapshot(ctx);
|
|
18
20
|
const tuning = options.playerMovement === true
|
|
19
|
-
? resolvePlayerMovementTuning({
|
|
21
|
+
? resolvePlayerMovementTuning({
|
|
22
|
+
physics: definition.physics,
|
|
23
|
+
world: definition.world,
|
|
24
|
+
movement: options.movement,
|
|
25
|
+
})
|
|
20
26
|
: null;
|
|
21
27
|
function publishInput(input) {
|
|
22
28
|
if (input.held !== undefined)
|
|
@@ -47,13 +53,25 @@ export function createHeadlessRunner(options) {
|
|
|
47
53
|
if (input !== undefined)
|
|
48
54
|
publishInput(input);
|
|
49
55
|
const dt = Math.min(dtSeconds, maxStep);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
let total = 0;
|
|
57
|
+
ctx.sim.advance(dt, (stepDt) => {
|
|
58
|
+
const gameDt = ctx.time.advance(stepDt);
|
|
59
|
+
total += gameDt;
|
|
60
|
+
ctx.sim.runStages("beforeMovement", stepDt);
|
|
61
|
+
if (tuning !== null) {
|
|
62
|
+
stepPlayerMovement(ctx, player.userId, { held: ctx.input.held(), pointer: ctx.input.pointer() }, stepDt, tuning, options.heading, options.pitch);
|
|
63
|
+
}
|
|
64
|
+
ctx.sim.runStages("afterMovement", stepDt);
|
|
65
|
+
loop?.onTick?.(ctx, gameDt);
|
|
66
|
+
advanceBehaviors(ctx, gameDt);
|
|
67
|
+
ctx.sim.runStages("afterTick", stepDt);
|
|
68
|
+
});
|
|
69
|
+
return total;
|
|
70
|
+
},
|
|
71
|
+
snapshot: () => simSnapshot.capture(),
|
|
72
|
+
restore(snapshot) {
|
|
73
|
+
simSnapshot.restore(snapshot);
|
|
57
74
|
},
|
|
75
|
+
tick: () => ctx.sim.tick(),
|
|
58
76
|
};
|
|
59
77
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
|
|
4
4
|
*
|
|
5
5
|
* A hosted server keeps its whole roster, every member's session state, and the world row in one
|
|
6
6
|
* backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
|
|
@@ -10,7 +10,7 @@ import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export declare const JG_MAX_MEMBERS_PER_SERVER = 256;
|
|
12
12
|
/** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
|
|
13
|
-
export declare function validateSlotsPerServer(slotsPerServer: number): {
|
|
13
|
+
export declare function validateSlotsPerServer(slotsPerServer: number, topology?: "rooms" | "shared"): {
|
|
14
14
|
ok: true;
|
|
15
15
|
} | {
|
|
16
16
|
ok: false;
|
|
@@ -75,6 +75,7 @@ export declare function isServerMember(memberUserIds: readonly string[], userId:
|
|
|
75
75
|
* True when the server has no free slots for a non-member. Existing members never count as
|
|
76
76
|
* "full" so rejoin/leave cycles keep working.
|
|
77
77
|
*/
|
|
78
|
+
export declare function isServerFull(memberCount: number, slotsPerServer: number, isMember: boolean): boolean;
|
|
78
79
|
export declare function isServerFull(memberUserIds: readonly string[], slotsPerServer: number, userId: string): boolean;
|
|
79
80
|
/**
|
|
80
81
|
* Whether a private-visibility server blocks this join (non-member without a matching code).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Room-topology ceiling on `slotsPerServer`; shared membership uses indexed rows instead.
|
|
4
4
|
*
|
|
5
5
|
* A hosted server keeps its whole roster, every member's session state, and the world row in one
|
|
6
6
|
* backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
|
|
@@ -10,11 +10,11 @@ import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export const JG_MAX_MEMBERS_PER_SERVER = 256;
|
|
12
12
|
/** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
|
|
13
|
-
export function validateSlotsPerServer(slotsPerServer) {
|
|
14
|
-
if (!Number.
|
|
13
|
+
export function validateSlotsPerServer(slotsPerServer, topology = "rooms") {
|
|
14
|
+
if (!Number.isSafeInteger(slotsPerServer) || slotsPerServer < 1) {
|
|
15
15
|
return { ok: false, reason: `slotsPerServer must be a positive integer, got ${slotsPerServer}` };
|
|
16
16
|
}
|
|
17
|
-
if (slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
|
|
17
|
+
if (topology !== "shared" && slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
|
|
18
18
|
return {
|
|
19
19
|
ok: false,
|
|
20
20
|
reason: `slotsPerServer ${slotsPerServer} exceeds JG_MAX_MEMBERS_PER_SERVER (${JG_MAX_MEMBERS_PER_SERVER}). ` +
|
|
@@ -76,12 +76,10 @@ export function canJoinPrivateServer(args) {
|
|
|
76
76
|
export function isServerMember(memberUserIds, userId) {
|
|
77
77
|
return memberUserIds.includes(userId);
|
|
78
78
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
export function isServerFull(memberUserIds, slotsPerServer, userId) {
|
|
84
|
-
return memberUserIds.length >= slotsPerServer && !memberUserIds.includes(userId);
|
|
79
|
+
export function isServerFull(members, slotsPerServer, member) {
|
|
80
|
+
const count = typeof members === "number" ? members : members.length;
|
|
81
|
+
const isMember = typeof member === "boolean" ? member : typeof members !== "number" && members.includes(member);
|
|
82
|
+
return count >= slotsPerServer && !isMember;
|
|
85
83
|
}
|
|
86
84
|
/**
|
|
87
85
|
* Whether a private-visibility server blocks this join (non-member without a matching code).
|
|
@@ -2,6 +2,9 @@ import { syncLifecyclePhase } from "../game/gamePhase.js";
|
|
|
2
2
|
import { advanceBehaviors } from "../scene/behaviorRuntime.js";
|
|
3
3
|
import { createGameContext } from "./gameContext.js";
|
|
4
4
|
import {} from "./inputSnapshot.js";
|
|
5
|
+
import { serverStep } from "../movement/serverStep.js";
|
|
6
|
+
import { resolvePlayerMovementTuning } from "../movement/playerMovement.js";
|
|
7
|
+
import { createInputRecorder } from "./inputRecorder.js";
|
|
5
8
|
import { createWorldReplicator } from "./worldReplication.js";
|
|
6
9
|
/** Reserved command name the authoritative host intercepts on the existing `runCommand` transport to route a client's {@link InputFrame} to `session.input`, so per-tick input needs no separate wire. */
|
|
7
10
|
export const INPUT_COMMAND = "engine.input";
|
|
@@ -24,7 +27,10 @@ export function createHostedGameRunner(options) {
|
|
|
24
27
|
});
|
|
25
28
|
const members = new Map();
|
|
26
29
|
const inputs = new Map();
|
|
30
|
+
const latestInputs = new Map();
|
|
31
|
+
const movementTuning = resolvePlayerMovementTuning({ world: definition.world, physics: definition.physics });
|
|
27
32
|
const inputSeq = new Map();
|
|
33
|
+
let hostTick = 0;
|
|
28
34
|
loop.onInit?.(ctx);
|
|
29
35
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
30
36
|
if (restore !== undefined)
|
|
@@ -49,6 +55,7 @@ export function createHostedGameRunner(options) {
|
|
|
49
55
|
return;
|
|
50
56
|
members.delete(userId);
|
|
51
57
|
inputs.delete(userId);
|
|
58
|
+
latestInputs.delete(userId);
|
|
52
59
|
inputSeq.delete(userId);
|
|
53
60
|
loop.onPlayerLeave?.(ctx, player);
|
|
54
61
|
ctx.game.players?.leave(userId);
|
|
@@ -61,24 +68,45 @@ export function createHostedGameRunner(options) {
|
|
|
61
68
|
return;
|
|
62
69
|
inputSeq.set(userId, seq);
|
|
63
70
|
}
|
|
64
|
-
inputs.
|
|
65
|
-
|
|
71
|
+
let recorder = inputs.get(userId);
|
|
72
|
+
if (recorder === undefined) {
|
|
73
|
+
recorder = createInputRecorder();
|
|
74
|
+
inputs.set(userId, recorder);
|
|
75
|
+
}
|
|
76
|
+
recorder.record(frame.tick ?? ctx.sim.tick() + 1, frame);
|
|
77
|
+
latestInputs.set(userId, frame);
|
|
66
78
|
},
|
|
67
79
|
heldInput(userId) {
|
|
68
|
-
return
|
|
80
|
+
return latestInputs.get(userId) ?? null;
|
|
69
81
|
},
|
|
70
82
|
command(userId, name, input) {
|
|
71
83
|
return ctx.game.commands.runAs(userId, name, input);
|
|
72
84
|
},
|
|
73
85
|
tick(dt) {
|
|
74
|
-
|
|
75
|
-
|
|
86
|
+
hostTick += 1;
|
|
87
|
+
ctx.sim.advance(dt, (stepDt, tick) => {
|
|
88
|
+
for (const [userId, recorder] of inputs) {
|
|
89
|
+
const frame = recorder.frameAt(tick);
|
|
90
|
+
if (frame !== null)
|
|
91
|
+
ctx.game.players?.setInput(userId, frame);
|
|
92
|
+
}
|
|
93
|
+
ctx.sim.runStages("beforeMovement", stepDt);
|
|
94
|
+
for (const userId of members.keys()) {
|
|
95
|
+
const frame = inputs.get(userId)?.frameAt(tick) ?? null;
|
|
96
|
+
if (frame !== null)
|
|
97
|
+
serverStep(ctx, userId, frame, stepDt, movementTuning);
|
|
98
|
+
}
|
|
99
|
+
ctx.sim.runStages("afterMovement", stepDt);
|
|
100
|
+
loop.onTick?.(ctx, stepDt);
|
|
101
|
+
advanceBehaviors(ctx, stepDt);
|
|
102
|
+
ctx.sim.runStages("afterTick", stepDt);
|
|
103
|
+
});
|
|
76
104
|
syncLifecyclePhase(ctx, definition.lifecycle);
|
|
77
105
|
return replicator.commit();
|
|
78
106
|
},
|
|
79
107
|
diff: (sinceRevision) => replicator.diff(sinceRevision),
|
|
80
108
|
revision: () => replicator.revision(),
|
|
81
|
-
snapshot: (viewer) => ctx.snapshot(viewer),
|
|
109
|
+
snapshot: (viewer) => ctx.snapshot(viewer === undefined ? undefined : { ...viewer, tick: hostTick }),
|
|
82
110
|
projectsViewers: () => ctx.replicatesPerViewer(),
|
|
83
111
|
members: () => Array.from(members.keys()),
|
|
84
112
|
context: () => ctx,
|
|
@@ -5,24 +5,14 @@ import type { GameDefinition, LoopPlayer } from "../game/defineGame.js";
|
|
|
5
5
|
import { type HostedGameRunner, type InputFrame } from "./hostedGameRunner.js";
|
|
6
6
|
import type { WorldDiff } from "./worldReplication.js";
|
|
7
7
|
import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
snapshot: WorldSnapshot;
|
|
11
|
-
revision: number;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`.
|
|
15
|
-
* Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and
|
|
16
|
-
* saves on a cadence; a stateless host reconstructs from `load()` each invocation.
|
|
17
|
-
*/
|
|
18
|
-
export interface HostedWorldStore {
|
|
19
|
-
load(): HostedWorldRecord | null;
|
|
20
|
-
save(record: HostedWorldRecord): void;
|
|
21
|
-
}
|
|
8
|
+
import type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
|
|
9
|
+
export type { HostedWorldRecord, HostedWorldStore, SyncHostedWorldStore } from "./hostedWorldStore.js";
|
|
22
10
|
/** In-process {@link HostedWorldStore} for tests, local play, and the browser-tab P2P host.
|
|
23
11
|
* @internal
|
|
24
12
|
*/
|
|
25
|
-
export declare function memoryWorldStore(seed?: HostedWorldRecord):
|
|
13
|
+
export declare function memoryWorldStore(seed?: HostedWorldRecord): SyncHostedWorldStore;
|
|
14
|
+
/** Async in-process store for callers exercising the production persistence contract. */
|
|
15
|
+
export declare function asyncMemoryWorldStore(seed?: HostedWorldRecord): HostedWorldStore;
|
|
26
16
|
/** A client replication pull: a full baseline (first sync / fell behind) or a diff since the client's cursor. */
|
|
27
17
|
export type HostedWorldSync = {
|
|
28
18
|
kind: "baseline";
|
|
@@ -39,7 +29,7 @@ export interface HostedWorldSessionOptions<TAssetRef extends ModelAssetRef, TMul
|
|
|
39
29
|
host?: LoopPlayer;
|
|
40
30
|
now?: () => number;
|
|
41
31
|
/** Where the authoritative snapshot persists; defaults to {@link memoryWorldStore}. */
|
|
42
|
-
store?:
|
|
32
|
+
store?: SyncHostedWorldStore;
|
|
43
33
|
/** Minimum elapsed ms between auto-saves on tick, measured against `now` (defaults to `Date.now` when omitted). Default `0` — persist on every revision-changing tick. */
|
|
44
34
|
saveIntervalMs?: number;
|
|
45
35
|
/** Render-model lookup for collider auto-fit, forwarded to the runner — see {@link HostedGameRunnerOptions.models}. */
|
|
@@ -59,6 +49,8 @@ export interface HostedWorldSession {
|
|
|
59
49
|
tick(dt: number): number;
|
|
60
50
|
/** Replication pull: `null`/`0` cursor → full baseline; a prior revision → a diff since it. */
|
|
61
51
|
sync(sinceRevision: number | null): HostedWorldSync;
|
|
52
|
+
/** Alias used by host transports when pulling the next baseline or diff for a subscriber. */
|
|
53
|
+
pull(sinceRevision: number | null): HostedWorldSync;
|
|
62
54
|
/** The full world baseline projected for one viewer (private/AOI). Identity when no replication policy is set. */
|
|
63
55
|
snapshotFor(viewer: SnapshotViewer): WorldSnapshot;
|
|
64
56
|
/** True when {@link snapshotFor} is viewer-dependent — a host must serve each connection its own projected frame instead of a shared diff. */
|
|
@@ -73,3 +65,7 @@ export interface HostedWorldSession {
|
|
|
73
65
|
* @internal
|
|
74
66
|
*/
|
|
75
67
|
export declare function createHostedWorldSession<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HostedWorldSessionOptions<TAssetRef, TMultiplayer>): HostedWorldSession;
|
|
68
|
+
/** Build a hosted session from an asynchronous persistence backend. */
|
|
69
|
+
export declare function createHostedWorldSessionAsync<TAssetRef extends ModelAssetRef, TMultiplayer>(options: Omit<HostedWorldSessionOptions<TAssetRef, TMultiplayer>, "store"> & {
|
|
70
|
+
store: HostedWorldStore;
|
|
71
|
+
}): Promise<HostedWorldSession>;
|
|
@@ -11,6 +11,14 @@ export function memoryWorldStore(seed) {
|
|
|
11
11
|
},
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
/** Async in-process store for callers exercising the production persistence contract. */
|
|
15
|
+
export function asyncMemoryWorldStore(seed) {
|
|
16
|
+
let record = seed ?? null;
|
|
17
|
+
return {
|
|
18
|
+
async load() { return record; },
|
|
19
|
+
async save(next) { record = { snapshot: next.snapshot, revision: next.revision }; },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
14
22
|
/** Build a {@link HostedWorldSession} — a live runner loaded from a {@link HostedWorldStore} and auto-persisted on tick.
|
|
15
23
|
* @internal
|
|
16
24
|
*/
|
|
@@ -33,6 +41,12 @@ export function createHostedWorldSession(options) {
|
|
|
33
41
|
savedRevision = runner.revision();
|
|
34
42
|
store.save({ snapshot: runner.snapshot(), revision: savedRevision });
|
|
35
43
|
}
|
|
44
|
+
const pull = (sinceRevision) => {
|
|
45
|
+
if (sinceRevision === null || sinceRevision <= 0) {
|
|
46
|
+
return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
|
|
47
|
+
}
|
|
48
|
+
return { kind: "diff", diff: runner.diff(sinceRevision) };
|
|
49
|
+
};
|
|
36
50
|
return {
|
|
37
51
|
join: (userId, isNew) => runner.join(userId, isNew),
|
|
38
52
|
leave: (userId) => runner.leave(userId),
|
|
@@ -49,12 +63,8 @@ export function createHostedWorldSession(options) {
|
|
|
49
63
|
}
|
|
50
64
|
return revision;
|
|
51
65
|
},
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return { kind: "baseline", revision: runner.revision(), snapshot: runner.snapshot() };
|
|
55
|
-
}
|
|
56
|
-
return { kind: "diff", diff: runner.diff(sinceRevision) };
|
|
57
|
-
},
|
|
66
|
+
pull,
|
|
67
|
+
sync: pull,
|
|
58
68
|
snapshotFor: (viewer) => runner.snapshot(viewer),
|
|
59
69
|
projectsViewers: () => runner.projectsViewers(),
|
|
60
70
|
revision: () => runner.revision(),
|
|
@@ -63,3 +73,16 @@ export function createHostedWorldSession(options) {
|
|
|
63
73
|
runner: () => runner,
|
|
64
74
|
};
|
|
65
75
|
}
|
|
76
|
+
/** Build a hosted session from an asynchronous persistence backend. */
|
|
77
|
+
export async function createHostedWorldSessionAsync(options) {
|
|
78
|
+
const { store, ...sessionOptions } = options;
|
|
79
|
+
const loaded = await store.load();
|
|
80
|
+
const session = createHostedWorldSession({
|
|
81
|
+
...sessionOptions,
|
|
82
|
+
store: {
|
|
83
|
+
load: () => loaded,
|
|
84
|
+
save: (record) => { void store.save(record); },
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return session;
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WorldSnapshot } from "./worldSnapshot.js";
|
|
2
|
+
/** One hosted world's persisted authoritative state — the unit a {@link HostedWorldStore} loads and saves. */
|
|
3
|
+
export interface HostedWorldRecord {
|
|
4
|
+
snapshot: WorldSnapshot;
|
|
5
|
+
revision: number;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Narrow persistence seam for a hosted world — the {@link HostedWorldRecord} counterpart of `HostPersistence`.
|
|
9
|
+
* Backends implement it (memory/file/sql/convex); the session never names one. A stateful host loads once and
|
|
10
|
+
* saves on a cadence; a stateless host reconstructs from `load()` each invocation.
|
|
11
|
+
*/
|
|
12
|
+
export interface HostedWorldStore {
|
|
13
|
+
load(): Promise<HostedWorldRecord | null>;
|
|
14
|
+
save(record: HostedWorldRecord): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/** Synchronous store adapter retained for deterministic in-process tests. */
|
|
17
|
+
export interface SyncHostedWorldStore {
|
|
18
|
+
load(): HostedWorldRecord | null;
|
|
19
|
+
save(record: HostedWorldRecord): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { InputFrame } from "./inputSnapshot.js";
|
|
2
|
+
/** An input frame and the first tick it applies to. */
|
|
3
|
+
export interface RecordedInput {
|
|
4
|
+
tick: number;
|
|
5
|
+
frame: InputFrame;
|
|
6
|
+
}
|
|
7
|
+
/** Serializable recorder contents, sorted by tick. */
|
|
8
|
+
export interface InputRecorderState {
|
|
9
|
+
frames: RecordedInput[];
|
|
10
|
+
}
|
|
11
|
+
/** Tick-indexed input log: record per tick, look up the frame in force at any tick. */
|
|
12
|
+
export interface InputRecorder {
|
|
13
|
+
/** Store the frame in force from `tick` onward. Recording the same tick twice replaces the earlier frame. */
|
|
14
|
+
record(tick: number, frame: InputFrame): void;
|
|
15
|
+
/** The frame in force at `tick`: the latest recorded frame whose tick is at most `tick`, or `null` before the first. */
|
|
16
|
+
frameAt(tick: number): InputFrame | null;
|
|
17
|
+
frames(): readonly RecordedInput[];
|
|
18
|
+
/** Highest recorded tick, or `-1` when empty. */
|
|
19
|
+
lastTick(): number;
|
|
20
|
+
clear(): void;
|
|
21
|
+
snapshot(): InputRecorderState;
|
|
22
|
+
restore(state: InputRecorderState): void;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Tick-indexed input log for deterministic replay: record what each tick saw, then feed {@link frameAt} back
|
|
26
|
+
* into a runner stepping from a matching {@link createSimSnapshotRegistry} snapshot.
|
|
27
|
+
*
|
|
28
|
+
* @capability input-recorder tick-indexed input log for replay, rollback, and regression tests
|
|
29
|
+
*/
|
|
30
|
+
export declare function createInputRecorder(): InputRecorder;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
function cloneFrame(frame) {
|
|
2
|
+
return {
|
|
3
|
+
held: [...frame.held],
|
|
4
|
+
pointer: frame.pointer === null ? null : { ...frame.pointer },
|
|
5
|
+
...(frame.analog === undefined ? {} : { analog: frame.analog === null ? null : { ...frame.analog } }),
|
|
6
|
+
...(frame.tick === undefined ? {} : { tick: frame.tick }),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Tick-indexed input log for deterministic replay: record what each tick saw, then feed {@link frameAt} back
|
|
11
|
+
* into a runner stepping from a matching {@link createSimSnapshotRegistry} snapshot.
|
|
12
|
+
*
|
|
13
|
+
* @capability input-recorder tick-indexed input log for replay, rollback, and regression tests
|
|
14
|
+
*/
|
|
15
|
+
export function createInputRecorder() {
|
|
16
|
+
let frames = [];
|
|
17
|
+
function indexAtOrBefore(tick) {
|
|
18
|
+
let lo = 0;
|
|
19
|
+
let hi = frames.length - 1;
|
|
20
|
+
let found = -1;
|
|
21
|
+
while (lo <= hi) {
|
|
22
|
+
const mid = (lo + hi) >> 1;
|
|
23
|
+
if (frames[mid].tick <= tick) {
|
|
24
|
+
found = mid;
|
|
25
|
+
lo = mid + 1;
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
hi = mid - 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
record(tick, frame) {
|
|
35
|
+
const entry = { tick, frame: cloneFrame(frame) };
|
|
36
|
+
const index = indexAtOrBefore(tick);
|
|
37
|
+
if (index >= 0 && frames[index].tick === tick) {
|
|
38
|
+
frames[index] = entry;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
frames.splice(index + 1, 0, entry);
|
|
42
|
+
},
|
|
43
|
+
frameAt(tick) {
|
|
44
|
+
const index = indexAtOrBefore(tick);
|
|
45
|
+
return index < 0 ? null : frames[index].frame;
|
|
46
|
+
},
|
|
47
|
+
frames: () => frames,
|
|
48
|
+
lastTick: () => (frames.length === 0 ? -1 : frames[frames.length - 1].tick),
|
|
49
|
+
clear() {
|
|
50
|
+
frames = [];
|
|
51
|
+
},
|
|
52
|
+
snapshot: () => ({ frames: frames.map((entry) => ({ tick: entry.tick, frame: cloneFrame(entry.frame) })) }),
|
|
53
|
+
restore(state) {
|
|
54
|
+
frames = state.frames
|
|
55
|
+
.map((entry) => ({ tick: entry.tick, frame: cloneFrame(entry.frame) }))
|
|
56
|
+
.sort((a, b) => a.tick - b.tick);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -6,8 +6,16 @@ export interface InputFrame {
|
|
|
6
6
|
pointer: PointerAxisState | null;
|
|
7
7
|
/** Analog per-action magnitudes (0..1) for actions driven by a continuous source (virtual joystick, gamepad stick); absent/null means every held action is fully pressed. */
|
|
8
8
|
analog?: Readonly<Record<string, number>> | null;
|
|
9
|
+
/** Simulation tick (`ctx.sim.tick()`) the frame was captured for; hosts and replay align inputs on it. Absent from senders that predate the fixed-step loop. */
|
|
10
|
+
tick?: number;
|
|
9
11
|
}
|
|
10
12
|
export interface InputSnapshot {
|
|
13
|
+
/** Trigger controller vibration for a local user's gamepad when supported. */
|
|
14
|
+
rumble(userId: string, options: {
|
|
15
|
+
strong: number;
|
|
16
|
+
weak: number;
|
|
17
|
+
ms: number;
|
|
18
|
+
}): Promise<boolean>;
|
|
11
19
|
/** Replaces the held-action set for this frame, rolling the previous held set forward for edge detection (#671). Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
|
|
12
20
|
publish(held: readonly string[]): void;
|
|
13
21
|
/** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { EntityStore } from "../scene/entityStore.js";
|
|
2
|
+
/** A render pose sampled between two simulation steps: `[x, y, z, rotationY]`. */
|
|
3
|
+
export type RenderPose = [x: number, y: number, z: number, rotationY: number];
|
|
4
|
+
/** Serializable previous-step poses keyed by entity id. */
|
|
5
|
+
export interface PoseHistoryState {
|
|
6
|
+
previous: Record<string, RenderPose>;
|
|
7
|
+
}
|
|
8
|
+
/** Previous-step pose buffer: capture at each fixed step, sample between steps for rendering. */
|
|
9
|
+
export interface PoseHistory {
|
|
10
|
+
/** Capture every entity's current pose as the "previous" step. Call once at the start of each fixed step. */
|
|
11
|
+
beginStep(entities: EntityStore): void;
|
|
12
|
+
/**
|
|
13
|
+
* Write the pose `alpha` of the way from the previous step to the live pose into `out`. Returns `false` (and
|
|
14
|
+
* leaves `out` untouched) when the entity is unknown; an entity with no history or one that jumped farther
|
|
15
|
+
* than `snapDistance` gets its live pose.
|
|
16
|
+
*/
|
|
17
|
+
sample(entities: EntityStore, id: string, alpha: number, out: RenderPose): boolean;
|
|
18
|
+
/** Forget an entity's previous pose so its next frame renders live (respawn, teleport). */
|
|
19
|
+
snap(id: string): void;
|
|
20
|
+
retune(options: {
|
|
21
|
+
snapDistance?: number;
|
|
22
|
+
}): void;
|
|
23
|
+
snapshot(): PoseHistoryState;
|
|
24
|
+
restore(state: PoseHistoryState): void;
|
|
25
|
+
}
|
|
26
|
+
/** Shortest-arc interpolation between two yaw angles. */
|
|
27
|
+
export declare function lerpAngle(from: number, to: number, t: number): number;
|
|
28
|
+
/**
|
|
29
|
+
* Previous-step pose buffer for render interpolation under a fixed-step loop. Reuses one 4-slot array per
|
|
30
|
+
* entity so a step over N entities allocates only for entities seen for the first time.
|
|
31
|
+
*
|
|
32
|
+
* @capability pose-interpolation render entity poses between fixed simulation steps without stutter
|
|
33
|
+
*/
|
|
34
|
+
export declare function createPoseHistory(options?: {
|
|
35
|
+
snapDistance?: number;
|
|
36
|
+
}): PoseHistory;
|