@jgengine/core 0.18.0 → 0.19.0
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 +265 -2
- package/dist/ai/decisionGraph.d.ts +96 -0
- package/dist/ai/decisionGraph.js +212 -0
- package/dist/ai/groupAssist.d.ts +10 -0
- package/dist/ai/groupAssist.js +11 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/pursuit.d.ts +1 -1
- package/dist/ai/pursuit.js +1 -1
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/ai/threat.d.ts +8 -0
- package/dist/ai/threat.js +10 -0
- package/dist/anim/animGraph.d.ts +155 -0
- package/dist/anim/animGraph.js +414 -0
- package/dist/anim/boneTexture.d.ts +55 -0
- package/dist/anim/boneTexture.js +43 -0
- package/dist/anim/footPlacement.d.ts +57 -0
- package/dist/anim/footPlacement.js +67 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +35 -0
- package/dist/anim/locomotionGraph.js +62 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +12 -0
- package/dist/audio/doppler.d.ts +21 -0
- package/dist/audio/doppler.js +32 -0
- package/dist/audio/engineLayers.d.ts +83 -0
- package/dist/audio/engineLayers.js +115 -0
- package/dist/combat/abilityKit.d.ts +1 -0
- package/dist/combat/abilityKit.js +1 -0
- package/dist/combat/death.d.ts +7 -0
- package/dist/combat/shotOrigin.d.ts +6 -0
- package/dist/combat/shotOrigin.js +6 -0
- package/dist/combat/weaponFeedback.d.ts +36 -0
- package/dist/combat/weaponFeedback.js +42 -0
- package/dist/combat/weaponHandling.d.ts +161 -0
- package/dist/combat/weaponHandling.js +202 -0
- package/dist/combat/weaponPresentation.d.ts +115 -0
- package/dist/combat/weaponPresentation.js +96 -0
- package/dist/devtools/warnOnce.d.ts +8 -0
- package/dist/devtools/warnOnce.js +18 -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 +92 -1
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +24 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +20 -0
- package/dist/faction/reputation.d.ts +6 -0
- package/dist/faction/reputation.js +11 -0
- package/dist/game/cameraConfig.d.ts +59 -1
- 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 +14 -1
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +16 -0
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +167 -6
- package/dist/game/systemRuntime.js +6 -1
- package/dist/game/toasts.d.ts +8 -0
- package/dist/game/toasts.js +7 -0
- package/dist/game/vfxInstance.d.ts +9 -0
- package/dist/game/vfxInstance.js +12 -1
- package/dist/game/viewports.d.ts +43 -0
- package/dist/game/viewports.js +46 -0
- package/dist/game/worldItem.d.ts +5 -1
- package/dist/game/worldItem.js +0 -1
- package/dist/input/actionBindings.d.ts +17 -2
- package/dist/input/actionBindings.js +24 -11
- package/dist/input/actionContexts.d.ts +45 -0
- package/dist/input/actionContexts.js +113 -0
- package/dist/input/axisInput.d.ts +4 -0
- package/dist/input/axisInput.js +4 -0
- package/dist/input/axisShaper.d.ts +73 -0
- package/dist/input/axisShaper.js +105 -0
- package/dist/input/gamepadModel.d.ts +79 -0
- package/dist/input/gamepadModel.js +108 -0
- package/dist/input/haptics.d.ts +50 -0
- package/dist/input/haptics.js +74 -0
- package/dist/input/inputBuffer.d.ts +40 -0
- package/dist/input/inputBuffer.js +59 -0
- package/dist/input/lookChannel.d.ts +10 -0
- package/dist/input/lookChannel.js +6 -0
- package/dist/input/pointer.d.ts +2 -0
- package/dist/input/pointer.js +4 -0
- package/dist/item/affix.d.ts +6 -0
- package/dist/item/affix.js +6 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +196 -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/glideModel.d.ts +3 -0
- package/dist/movement/glideModel.js +6 -0
- package/dist/movement/grappleSwing.d.ts +2 -0
- package/dist/movement/grappleSwing.js +5 -1
- package/dist/movement/movementModel.d.ts +39 -0
- package/dist/movement/movementModel.js +62 -19
- package/dist/movement/movementProbe.d.ts +51 -0
- package/dist/movement/movementProbe.js +119 -0
- package/dist/movement/playerMovement.d.ts +30 -3
- package/dist/movement/playerMovement.js +304 -7
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +39 -8
- package/dist/movement/solidObstacles.js +71 -6
- package/dist/movement/voxelController.js +7 -7
- 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/navFromEnvironment.d.ts +9 -3
- package/dist/nav/navFromEnvironment.js +18 -12
- package/dist/nav/navMesh.d.ts +74 -0
- package/dist/nav/navMesh.js +570 -0
- package/dist/physics/aircraftDynamics.d.ts +390 -0
- package/dist/physics/aircraftDynamics.js +704 -0
- package/dist/physics/boatDynamics.d.ts +116 -0
- package/dist/physics/boatDynamics.js +160 -0
- package/dist/physics/drivableVehicle.d.ts +22 -8
- package/dist/physics/drivableVehicle.js +4 -2
- package/dist/physics/flightDynamics.d.ts +29 -4
- package/dist/physics/flightDynamics.js +28 -5
- package/dist/physics/handlingProbe.d.ts +249 -0
- package/dist/physics/handlingProbe.js +606 -0
- package/dist/physics/kinematicVehicle.d.ts +37 -2
- package/dist/physics/kinematicVehicle.js +15 -0
- package/dist/physics/physicsBackend.d.ts +203 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +151 -0
- package/dist/physics/physicsWorldBackend.d.ts +45 -0
- package/dist/physics/physicsWorldBackend.js +518 -0
- package/dist/physics/vehicleBackendLink.d.ts +49 -0
- package/dist/physics/vehicleBackendLink.js +102 -0
- package/dist/physics/vehicleDynamics.d.ts +374 -0
- package/dist/physics/vehicleDynamics.js +737 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +128 -0
- package/dist/puzzle/cellGrid.d.ts +15 -9
- package/dist/puzzle/cellGrid.js +13 -9
- 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 +19 -6
- package/dist/render/lookPreset.js +56 -4
- package/dist/render/postProcessing.d.ts +40 -1
- package/dist/render/postProcessing.js +1 -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/cameraDirector.d.ts +17 -3
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/commandInput.d.ts +52 -0
- package/dist/runtime/commandInput.js +71 -0
- package/dist/runtime/commandRunner.d.ts +39 -7
- package/dist/runtime/commandRunner.js +35 -5
- package/dist/runtime/context/combat.d.ts +1 -0
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/deathLoot.d.ts +2 -0
- package/dist/runtime/context/deathLoot.js +1 -0
- package/dist/runtime/gameContext.js +18 -1
- package/dist/runtime/gameContextTypes.d.ts +33 -7
- package/dist/runtime/gameRuntime.d.ts +23 -5
- package/dist/runtime/gameRuntime.js +21 -4
- 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 +14 -0
- package/dist/runtime/inputSnapshot.js +11 -0
- package/dist/runtime/localPlayers.d.ts +73 -0
- package/dist/runtime/localPlayers.js +119 -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 +41 -2
- package/dist/runtime/transport.js +7 -1
- 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 +29 -1
- package/dist/scene/behaviorRuntime.js +71 -4
- package/dist/scene/behaviors.d.ts +12 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/scene/sceneKinds.d.ts +9 -0
- package/dist/scene/vehicleSeat.d.ts +5 -1
- package/dist/scene/vehicleSeat.js +5 -1
- package/dist/sensor/concealment.d.ts +6 -0
- package/dist/sensor/concealment.js +8 -0
- package/dist/sensor/freezeMonitor.d.ts +6 -0
- package/dist/sensor/freezeMonitor.js +8 -0
- package/dist/sensor/perception.d.ts +70 -0
- package/dist/sensor/perception.js +189 -0
- package/dist/sensor/recordingBuffer.d.ts +3 -0
- package/dist/sensor/recordingBuffer.js +8 -0
- package/dist/session/roundState.d.ts +4 -0
- package/dist/session/roundState.js +11 -0
- package/dist/settings/frameRateLimit.d.ts +16 -0
- package/dist/settings/frameRateLimit.js +37 -0
- package/dist/settings/graphicsProfile.d.ts +21 -0
- package/dist/settings/graphicsProfile.js +48 -0
- package/dist/settings/graphicsSettings.d.ts +40 -0
- package/dist/settings/graphicsSettings.js +81 -0
- package/dist/settings/settingsModel.d.ts +9 -1
- package/dist/settings/settingsModel.js +10 -0
- package/dist/stats/eventMeter.d.ts +1 -1
- package/dist/stats/eventMeter.js +1 -1
- 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/feedbackMixer.d.ts +61 -0
- package/dist/vfx/feedbackMixer.js +106 -0
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/visibility/simulationCulling.d.ts +8 -1
- package/dist/visibility/simulationCulling.js +7 -0
- package/dist/visibility/spatialIndex.d.ts +21 -0
- package/dist/visibility/spatialIndex.js +16 -0
- package/dist/world/authoredSolids.d.ts +46 -0
- package/dist/world/authoredSolids.js +75 -0
- package/dist/world/buildingGenerator.js +2 -2
- package/dist/world/buildingKit.d.ts +3 -1
- package/dist/world/buildings.d.ts +46 -2
- package/dist/world/buildings.js +53 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +139 -0
- package/dist/world/cityKind.js +367 -16
- package/dist/world/features.d.ts +56 -3
- package/dist/world/features.js +13 -2
- package/dist/world/footprintGrid.d.ts +7 -0
- package/dist/world/footprintGrid.js +16 -0
- package/dist/world/interiors.d.ts +1 -0
- package/dist/world/interiors.js +5 -3
- package/dist/world/lod.d.ts +6 -0
- package/dist/world/lod.js +8 -0
- 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/terraform.d.ts +8 -0
- package/dist/world/terraform.js +7 -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/walls.d.ts +7 -0
- package/dist/world/walls.js +7 -0
- package/dist/world/worldSolids.d.ts +84 -0
- package/dist/world/worldSolids.js +240 -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 +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-combat/SKILL.md +5 -0
- package/skills/jgengine-combat/api.md +138 -103
- package/skills/jgengine-combat/capabilities.md +29 -1
- package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +501 -453
- package/skills/jgengine-gameplay/capabilities.md +36 -0
- package/skills/jgengine-gameplay/reference-systems.md +4 -0
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { sampleAxisBindings } from "../input/axisInput.js";
|
|
2
|
+
import { createHapticChannels } from "../input/haptics.js";
|
|
2
3
|
export function createInputSnapshot() {
|
|
3
4
|
let heldSet = new Set();
|
|
4
5
|
let previousHeldSet = new Set();
|
|
5
6
|
let heldList = [];
|
|
6
7
|
let pointerState = null;
|
|
7
8
|
let analogValues = null;
|
|
9
|
+
const haptics = new Map();
|
|
8
10
|
const value = (action) => {
|
|
9
11
|
const analog = analogValues?.[action];
|
|
10
12
|
if (analog !== undefined)
|
|
@@ -12,6 +14,15 @@ export function createInputSnapshot() {
|
|
|
12
14
|
return heldSet.has(action) ? 1 : 0;
|
|
13
15
|
};
|
|
14
16
|
return {
|
|
17
|
+
rumble: async () => false,
|
|
18
|
+
haptics(userId) {
|
|
19
|
+
let channels = haptics.get(userId);
|
|
20
|
+
if (channels === undefined) {
|
|
21
|
+
channels = createHapticChannels();
|
|
22
|
+
haptics.set(userId, channels);
|
|
23
|
+
}
|
|
24
|
+
return channels;
|
|
25
|
+
},
|
|
15
26
|
publish(held) {
|
|
16
27
|
previousHeldSet = heldSet;
|
|
17
28
|
heldList = Object.freeze([...held]);
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { GameContext } from "./gameContext.js";
|
|
2
|
+
import { type InputSnapshot } from "./inputSnapshot.js";
|
|
3
|
+
/** One local seat on a shared screen: a stable slot id, the user id the game spawns for it, and the device driving it. */
|
|
4
|
+
export interface LocalPlayerSlot {
|
|
5
|
+
readonly slotId: string;
|
|
6
|
+
readonly index: number;
|
|
7
|
+
readonly userId: string;
|
|
8
|
+
/** Device that claimed the seat (`"gamepad:1"`), `null` for the primary seat before a device claims it. */
|
|
9
|
+
readonly deviceId: string | null;
|
|
10
|
+
}
|
|
11
|
+
/** Retunable seat policy. */
|
|
12
|
+
export interface LocalPlayersConfig {
|
|
13
|
+
/** Seats on this screen, including the primary (default `1`: every device drives the primary player). */
|
|
14
|
+
maxSlots: number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether the first device to press something claims the primary seat alongside keyboard and touch
|
|
17
|
+
* (`"first"`, default), or the primary stays keyboard/touch-only and every pad opens a new seat (`"none"`).
|
|
18
|
+
*/
|
|
19
|
+
claimPrimary?: "first" | "none";
|
|
20
|
+
}
|
|
21
|
+
/** Options for {@link createLocalPlayers}. */
|
|
22
|
+
export interface LocalPlayersOptions extends LocalPlayersConfig {
|
|
23
|
+
/** User id of seat 0 — `ctx.player.userId`. */
|
|
24
|
+
primaryUserId: string;
|
|
25
|
+
/** Input of seat 0 — `ctx.input`; other seats get their own snapshot. */
|
|
26
|
+
primaryInput?: InputSnapshot;
|
|
27
|
+
/** User id for seat `index >= 1` (default `` `${primaryUserId}:p${index + 1}` ``). */
|
|
28
|
+
userIdFor?: (index: number) => string;
|
|
29
|
+
}
|
|
30
|
+
/** Result of {@link LocalPlayers.assign}. */
|
|
31
|
+
export interface LocalPlayerAssignment {
|
|
32
|
+
slot: LocalPlayerSlot;
|
|
33
|
+
/** True when this call opened the seat: spawn its entity through `onNewPlayer`. */
|
|
34
|
+
joined: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** Serializable seat table. */
|
|
37
|
+
export interface LocalPlayersSnapshot {
|
|
38
|
+
maxSlots: number;
|
|
39
|
+
claimPrimary: "first" | "none";
|
|
40
|
+
slots: LocalPlayerSlot[];
|
|
41
|
+
}
|
|
42
|
+
/** Seats for couch co-op: devices hot-join into slots, each slot has its own input. */
|
|
43
|
+
export interface LocalPlayers {
|
|
44
|
+
/** Seat for a device that just produced input: its existing seat, the primary seat, a newly opened seat, or `null` when full. */
|
|
45
|
+
assign(deviceId: string): LocalPlayerAssignment | null;
|
|
46
|
+
/** Free a seat other than the primary; its device may hot-join again. */
|
|
47
|
+
release(slotId: string): boolean;
|
|
48
|
+
slots(): readonly LocalPlayerSlot[];
|
|
49
|
+
slot(slotId: string): LocalPlayerSlot | null;
|
|
50
|
+
slotForDevice(deviceId: string): LocalPlayerSlot | null;
|
|
51
|
+
/** The seat's user id and input, or `null` for an unknown seat. Seat 0 returns `ctx.player`'s id and `ctx.input`. */
|
|
52
|
+
local(slotId: string): {
|
|
53
|
+
userId: string;
|
|
54
|
+
input: InputSnapshot;
|
|
55
|
+
} | null;
|
|
56
|
+
/** Called after every assign that changes the table, release, restore and retune; returns an unsubscribe. */
|
|
57
|
+
subscribe(listener: () => void): () => void;
|
|
58
|
+
config(): Required<LocalPlayersConfig>;
|
|
59
|
+
/** Change seat count or claim policy mid-game; seats above a lowered `maxSlots` stay until released. */
|
|
60
|
+
retune(next: LocalPlayersConfig): void;
|
|
61
|
+
snapshot(): LocalPlayersSnapshot;
|
|
62
|
+
restore(snapshot: LocalPlayersSnapshot): void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Seat table for local multiplayer on one screen.
|
|
66
|
+
* @capability local-players Couch co-op seats: devices hot-join into slots, each with its own user id and input snapshot.
|
|
67
|
+
*/
|
|
68
|
+
export declare function createLocalPlayers(options: LocalPlayersOptions): LocalPlayers;
|
|
69
|
+
/**
|
|
70
|
+
* The seat table of a game context, created on first use with one seat for `ctx.player` and `ctx.input`.
|
|
71
|
+
* The shell retunes it from `defineGame({ localPlayers })` and hot-joins pads into it.
|
|
72
|
+
*/
|
|
73
|
+
export declare function localPlayers(ctx: GameContext): LocalPlayers;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { createInputSnapshot } from "./inputSnapshot.js";
|
|
2
|
+
const PRIMARY_SLOT = "slot:0";
|
|
3
|
+
/**
|
|
4
|
+
* Seat table for local multiplayer on one screen.
|
|
5
|
+
* @capability local-players Couch co-op seats: devices hot-join into slots, each with its own user id and input snapshot.
|
|
6
|
+
*/
|
|
7
|
+
export function createLocalPlayers(options) {
|
|
8
|
+
const userIdFor = options.userIdFor ?? ((index) => `${options.primaryUserId}:p${index + 1}`);
|
|
9
|
+
const primaryInput = options.primaryInput ?? createInputSnapshot();
|
|
10
|
+
let config = {
|
|
11
|
+
maxSlots: Math.max(1, Math.floor(options.maxSlots)),
|
|
12
|
+
claimPrimary: options.claimPrimary ?? "first",
|
|
13
|
+
};
|
|
14
|
+
let slots = [];
|
|
15
|
+
const inputs = new Map();
|
|
16
|
+
const listeners = new Set();
|
|
17
|
+
const changed = () => {
|
|
18
|
+
for (const listener of [...listeners])
|
|
19
|
+
listener();
|
|
20
|
+
};
|
|
21
|
+
const primarySlot = (deviceId) => Object.freeze({ slotId: PRIMARY_SLOT, index: 0, userId: options.primaryUserId, deviceId });
|
|
22
|
+
const reset = (next) => {
|
|
23
|
+
slots = next.map((slot) => Object.freeze({ ...slot }));
|
|
24
|
+
if (!slots.some((slot) => slot.index === 0))
|
|
25
|
+
slots.unshift(primarySlot(null));
|
|
26
|
+
slots.sort((a, b) => a.index - b.index);
|
|
27
|
+
for (const slotId of [...inputs.keys()]) {
|
|
28
|
+
if (!slots.some((slot) => slot.slotId === slotId))
|
|
29
|
+
inputs.delete(slotId);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
reset([]);
|
|
33
|
+
const inputFor = (slot) => {
|
|
34
|
+
if (slot.index === 0)
|
|
35
|
+
return primaryInput;
|
|
36
|
+
let input = inputs.get(slot.slotId);
|
|
37
|
+
if (input === undefined) {
|
|
38
|
+
input = createInputSnapshot();
|
|
39
|
+
inputs.set(slot.slotId, input);
|
|
40
|
+
}
|
|
41
|
+
return input;
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
assign(deviceId) {
|
|
45
|
+
const existing = slots.find((slot) => slot.deviceId === deviceId);
|
|
46
|
+
if (existing !== undefined)
|
|
47
|
+
return { slot: existing, joined: false };
|
|
48
|
+
if (config.claimPrimary === "first" || config.maxSlots === 1) {
|
|
49
|
+
const primary = slots[0];
|
|
50
|
+
if (primary.deviceId === null) {
|
|
51
|
+
slots[0] = primarySlot(deviceId);
|
|
52
|
+
changed();
|
|
53
|
+
return { slot: slots[0], joined: false };
|
|
54
|
+
}
|
|
55
|
+
if (config.maxSlots === 1)
|
|
56
|
+
return { slot: primary, joined: false };
|
|
57
|
+
}
|
|
58
|
+
if (slots.length >= config.maxSlots)
|
|
59
|
+
return null;
|
|
60
|
+
let index = 1;
|
|
61
|
+
while (slots.some((slot) => slot.index === index))
|
|
62
|
+
index += 1;
|
|
63
|
+
const slot = Object.freeze({ slotId: `slot:${index}`, index, userId: userIdFor(index), deviceId });
|
|
64
|
+
slots = [...slots, slot].sort((a, b) => a.index - b.index);
|
|
65
|
+
changed();
|
|
66
|
+
return { slot, joined: true };
|
|
67
|
+
},
|
|
68
|
+
release(slotId) {
|
|
69
|
+
if (slotId === PRIMARY_SLOT)
|
|
70
|
+
return false;
|
|
71
|
+
const next = slots.filter((slot) => slot.slotId !== slotId);
|
|
72
|
+
if (next.length === slots.length)
|
|
73
|
+
return false;
|
|
74
|
+
slots = next;
|
|
75
|
+
inputs.delete(slotId);
|
|
76
|
+
changed();
|
|
77
|
+
return true;
|
|
78
|
+
},
|
|
79
|
+
slots: () => slots,
|
|
80
|
+
slot: (slotId) => slots.find((slot) => slot.slotId === slotId) ?? null,
|
|
81
|
+
slotForDevice: (deviceId) => slots.find((slot) => slot.deviceId === deviceId) ?? null,
|
|
82
|
+
local(slotId) {
|
|
83
|
+
const slot = slots.find((candidate) => candidate.slotId === slotId);
|
|
84
|
+
return slot === undefined ? null : { userId: slot.userId, input: inputFor(slot) };
|
|
85
|
+
},
|
|
86
|
+
subscribe(listener) {
|
|
87
|
+
listeners.add(listener);
|
|
88
|
+
return () => {
|
|
89
|
+
listeners.delete(listener);
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
config: () => config,
|
|
93
|
+
retune(next) {
|
|
94
|
+
config = { maxSlots: Math.max(1, Math.floor(next.maxSlots)), claimPrimary: next.claimPrimary ?? config.claimPrimary };
|
|
95
|
+
changed();
|
|
96
|
+
},
|
|
97
|
+
snapshot() {
|
|
98
|
+
return { ...config, slots: slots.map((slot) => ({ ...slot })) };
|
|
99
|
+
},
|
|
100
|
+
restore(snapshot) {
|
|
101
|
+
config = { maxSlots: Math.max(1, Math.floor(snapshot.maxSlots)), claimPrimary: snapshot.claimPrimary };
|
|
102
|
+
reset(snapshot.slots);
|
|
103
|
+
changed();
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const LOCAL_PLAYERS = new WeakMap();
|
|
108
|
+
/**
|
|
109
|
+
* The seat table of a game context, created on first use with one seat for `ctx.player` and `ctx.input`.
|
|
110
|
+
* The shell retunes it from `defineGame({ localPlayers })` and hot-joins pads into it.
|
|
111
|
+
*/
|
|
112
|
+
export function localPlayers(ctx) {
|
|
113
|
+
let players = LOCAL_PLAYERS.get(ctx);
|
|
114
|
+
if (players === undefined) {
|
|
115
|
+
players = createLocalPlayers({ maxSlots: 1, primaryUserId: ctx.player.userId, primaryInput: ctx.input });
|
|
116
|
+
LOCAL_PLAYERS.set(ctx, players);
|
|
117
|
+
}
|
|
118
|
+
return players;
|
|
119
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const TWO_PI = Math.PI * 2;
|
|
2
|
+
/** Shortest-arc interpolation between two yaw angles. */
|
|
3
|
+
export function lerpAngle(from, to, t) {
|
|
4
|
+
let delta = (to - from) % TWO_PI;
|
|
5
|
+
if (delta > Math.PI)
|
|
6
|
+
delta -= TWO_PI;
|
|
7
|
+
else if (delta < -Math.PI)
|
|
8
|
+
delta += TWO_PI;
|
|
9
|
+
return from + delta * t;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Previous-step pose buffer for render interpolation under a fixed-step loop. Reuses one 4-slot array per
|
|
13
|
+
* entity so a step over N entities allocates only for entities seen for the first time.
|
|
14
|
+
*
|
|
15
|
+
* @capability pose-interpolation render entity poses between fixed simulation steps without stutter
|
|
16
|
+
*/
|
|
17
|
+
export function createPoseHistory(options) {
|
|
18
|
+
let snapDistance = options?.snapDistance ?? 8;
|
|
19
|
+
const previous = new Map();
|
|
20
|
+
const seen = new Set();
|
|
21
|
+
return {
|
|
22
|
+
beginStep(entities) {
|
|
23
|
+
seen.clear();
|
|
24
|
+
for (const entity of entities.list()) {
|
|
25
|
+
seen.add(entity.id);
|
|
26
|
+
let slot = previous.get(entity.id);
|
|
27
|
+
if (slot === undefined) {
|
|
28
|
+
slot = [0, 0, 0, 0];
|
|
29
|
+
previous.set(entity.id, slot);
|
|
30
|
+
}
|
|
31
|
+
slot[0] = entity.position[0];
|
|
32
|
+
slot[1] = entity.position[1];
|
|
33
|
+
slot[2] = entity.position[2];
|
|
34
|
+
slot[3] = entity.rotationY;
|
|
35
|
+
}
|
|
36
|
+
if (previous.size !== seen.size) {
|
|
37
|
+
for (const id of previous.keys()) {
|
|
38
|
+
if (!seen.has(id))
|
|
39
|
+
previous.delete(id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
sample(entities, id, alpha, out) {
|
|
44
|
+
const live = entities.get(id);
|
|
45
|
+
if (live === null)
|
|
46
|
+
return false;
|
|
47
|
+
const prev = previous.get(id);
|
|
48
|
+
const t = alpha < 0 ? 0 : alpha > 1 ? 1 : alpha;
|
|
49
|
+
if (prev === undefined || t >= 1) {
|
|
50
|
+
out[0] = live.position[0];
|
|
51
|
+
out[1] = live.position[1];
|
|
52
|
+
out[2] = live.position[2];
|
|
53
|
+
out[3] = live.rotationY;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
const dx = live.position[0] - prev[0];
|
|
57
|
+
const dy = live.position[1] - prev[1];
|
|
58
|
+
const dz = live.position[2] - prev[2];
|
|
59
|
+
if (dx * dx + dy * dy + dz * dz > snapDistance * snapDistance) {
|
|
60
|
+
out[0] = live.position[0];
|
|
61
|
+
out[1] = live.position[1];
|
|
62
|
+
out[2] = live.position[2];
|
|
63
|
+
out[3] = live.rotationY;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
out[0] = prev[0] + dx * t;
|
|
67
|
+
out[1] = prev[1] + dy * t;
|
|
68
|
+
out[2] = prev[2] + dz * t;
|
|
69
|
+
out[3] = lerpAngle(prev[3], live.rotationY, t);
|
|
70
|
+
return true;
|
|
71
|
+
},
|
|
72
|
+
snap(id) {
|
|
73
|
+
previous.delete(id);
|
|
74
|
+
},
|
|
75
|
+
retune(next) {
|
|
76
|
+
if (next.snapDistance !== undefined)
|
|
77
|
+
snapDistance = next.snapDistance;
|
|
78
|
+
},
|
|
79
|
+
snapshot() {
|
|
80
|
+
const copy = {};
|
|
81
|
+
for (const [id, pose] of previous)
|
|
82
|
+
copy[id] = [pose[0], pose[1], pose[2], pose[3]];
|
|
83
|
+
return { previous: copy };
|
|
84
|
+
},
|
|
85
|
+
restore(state) {
|
|
86
|
+
previous.clear();
|
|
87
|
+
for (const [id, pose] of Object.entries(state.previous))
|
|
88
|
+
previous.set(id, [pose[0], pose[1], pose[2], pose[3]]);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { InputFrame } from "./inputSnapshot.js";
|
|
2
|
+
/** Deterministic state transition used to advance one predicted input frame. */
|
|
3
|
+
export type PredictionStep<S> = (state: S, input: InputFrame, dt: number) => S;
|
|
4
|
+
/** One input, tick, and resulting state retained for authoritative replay. */
|
|
5
|
+
export type PredictionRecord<S> = {
|
|
6
|
+
tick: number;
|
|
7
|
+
input: InputFrame;
|
|
8
|
+
state: S;
|
|
9
|
+
dt: number;
|
|
10
|
+
};
|
|
11
|
+
/** Serializable prediction state containing the confirmation baseline and pending input history. */
|
|
12
|
+
export type PredictionSnapshot<S> = {
|
|
13
|
+
confirmedTick: number;
|
|
14
|
+
confirmed: S;
|
|
15
|
+
predicted: S;
|
|
16
|
+
records: PredictionRecord<S>[];
|
|
17
|
+
};
|
|
18
|
+
/** Stateful prediction handle supporting recording, reconciliation, inspection, and restoration. */
|
|
19
|
+
export type PredictionBuffer<S> = {
|
|
20
|
+
record(input: InputFrame, dt?: number, observedState?: S): S;
|
|
21
|
+
reconcile(tick: number, authoritative: S): {
|
|
22
|
+
error: number;
|
|
23
|
+
replayed: number;
|
|
24
|
+
snapped: boolean;
|
|
25
|
+
state: S;
|
|
26
|
+
};
|
|
27
|
+
state(): S;
|
|
28
|
+
snapshot(): PredictionSnapshot<S>;
|
|
29
|
+
restore(next: PredictionSnapshot<S>): void;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Keeps local input predictions ahead of the last server confirmation. Reconciliation replaces the
|
|
33
|
+
* confirmed state and deterministically replays unconfirmed inputs through the supplied step function.
|
|
34
|
+
* `maxTicks` bounds retained work and memory; dropped inputs are folded into the confirmed baseline.
|
|
35
|
+
* @capability client-side input prediction with replay reconciliation
|
|
36
|
+
*/
|
|
37
|
+
export declare function createPredictionBuffer<S>(config: {
|
|
38
|
+
initial: S;
|
|
39
|
+
step: PredictionStep<S>;
|
|
40
|
+
maxTicks: number;
|
|
41
|
+
startTick?: number;
|
|
42
|
+
dt?: number;
|
|
43
|
+
/** Error above which a caller should snap its rendered pose to the reconciled result. */
|
|
44
|
+
snapThreshold?: number;
|
|
45
|
+
}): PredictionBuffer<S>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps local input predictions ahead of the last server confirmation. Reconciliation replaces the
|
|
3
|
+
* confirmed state and deterministically replays unconfirmed inputs through the supplied step function.
|
|
4
|
+
* `maxTicks` bounds retained work and memory; dropped inputs are folded into the confirmed baseline.
|
|
5
|
+
* @capability client-side input prediction with replay reconciliation
|
|
6
|
+
*/
|
|
7
|
+
export function createPredictionBuffer(config) {
|
|
8
|
+
const maxTicks = Math.max(1, Math.floor(config.maxTicks));
|
|
9
|
+
const step = config.step;
|
|
10
|
+
let confirmedTick = config.startTick ?? 0;
|
|
11
|
+
let confirmed = config.initial;
|
|
12
|
+
let predicted = config.initial;
|
|
13
|
+
let records = [];
|
|
14
|
+
const defaultDt = config.dt ?? 1 / 60;
|
|
15
|
+
const snapThreshold = Math.max(0, config.snapThreshold ?? 0.1);
|
|
16
|
+
const replay = (base, pending) => {
|
|
17
|
+
let next = base;
|
|
18
|
+
for (const record of pending)
|
|
19
|
+
next = step(next, record.input, record.dt);
|
|
20
|
+
return next;
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
record(input, dt = defaultDt, observedState) {
|
|
24
|
+
const tick = confirmedTick + records.length + 1;
|
|
25
|
+
const state = observedState === undefined ? step(predicted, input, dt) : observedState;
|
|
26
|
+
records.push({ tick, input, state, dt });
|
|
27
|
+
predicted = state;
|
|
28
|
+
if (records.length > maxTicks) {
|
|
29
|
+
const oldest = records.shift();
|
|
30
|
+
confirmedTick = oldest.tick;
|
|
31
|
+
confirmed = oldest.state;
|
|
32
|
+
}
|
|
33
|
+
return predicted;
|
|
34
|
+
},
|
|
35
|
+
reconcile(tick, authoritative) {
|
|
36
|
+
const predictedAtTick = records.find((record) => record.tick === tick)?.state ?? confirmed;
|
|
37
|
+
const distance = (a, b) => {
|
|
38
|
+
if (typeof a === "number" && typeof b === "number")
|
|
39
|
+
return Math.abs(a - b);
|
|
40
|
+
if (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v) => typeof v === "number") && b.every((v) => typeof v === "number")) {
|
|
41
|
+
return Math.sqrt(a.reduce((sum, value, index) => sum + (Number(value) - Number(b[index])) ** 2, 0));
|
|
42
|
+
}
|
|
43
|
+
return JSON.stringify(a) === JSON.stringify(b) ? 0 : 1;
|
|
44
|
+
};
|
|
45
|
+
const dx = distance(predictedAtTick, authoritative);
|
|
46
|
+
const pending = records.filter((record) => record.tick > tick);
|
|
47
|
+
confirmedTick = tick;
|
|
48
|
+
confirmed = authoritative;
|
|
49
|
+
records = pending;
|
|
50
|
+
predicted = replay(confirmed, records);
|
|
51
|
+
return { error: dx, replayed: records.length, snapped: dx > snapThreshold, state: predicted };
|
|
52
|
+
},
|
|
53
|
+
state: () => predicted,
|
|
54
|
+
snapshot: () => ({ confirmedTick, confirmed, predicted, records: records.map((record) => ({ ...record })) }),
|
|
55
|
+
restore(next) {
|
|
56
|
+
confirmedTick = next.confirmedTick;
|
|
57
|
+
confirmed = next.confirmed;
|
|
58
|
+
predicted = next.predicted;
|
|
59
|
+
records = next.records.map((record) => ({ ...record }));
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EntityStore } from "../scene/entityStore.js";
|
|
2
|
+
import { type PoseHistory, type RenderPose } from "./poseInterpolation.js";
|
|
3
|
+
import { type SimAdvanceResult, type SimLoop, type SimulationConfig } from "./simLoop.js";
|
|
4
|
+
/** Where a registered stage runs inside one simulation step. */
|
|
5
|
+
export type SimStagePhase = "beforeMovement" | "afterMovement" | "afterTick";
|
|
6
|
+
/** Extra work a game or engine seam (a physics backend, a netcode buffer) runs inside every simulation step. */
|
|
7
|
+
export interface SimStage {
|
|
8
|
+
id: string;
|
|
9
|
+
phase: SimStagePhase;
|
|
10
|
+
run(dt: number, tick: number): void;
|
|
11
|
+
}
|
|
12
|
+
/** Serializable `ctx.sim` state: loop position plus the interpolation pose history. */
|
|
13
|
+
export interface SimContextState {
|
|
14
|
+
loop: ReturnType<SimLoop["snapshot"]>;
|
|
15
|
+
poses: ReturnType<PoseHistory["snapshot"]>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `ctx.sim`: the simulation stepper shared by every driver plus the render-side interpolation it enables.
|
|
19
|
+
* Drivers call {@link advance} with real elapsed seconds and do their per-step work inside the callback;
|
|
20
|
+
* renderers call {@link renderPose} each frame.
|
|
21
|
+
*/
|
|
22
|
+
export interface SimContext {
|
|
23
|
+
readonly loop: SimLoop;
|
|
24
|
+
/** Advance the loop; `body` runs once per simulation step with that step's dt. Pose history is captured before each step. */
|
|
25
|
+
advance(realDt: number, body: (dt: number, tick: number) => void): SimAdvanceResult;
|
|
26
|
+
tick(): number;
|
|
27
|
+
/** Interpolation fraction for the current frame, 1 on a variable loop. */
|
|
28
|
+
alpha(): number;
|
|
29
|
+
/** Interpolated pose for rendering; live pose on a variable loop. `false` when the entity does not exist. */
|
|
30
|
+
renderPose(id: string, out: RenderPose): boolean;
|
|
31
|
+
/** Forget an entity's interpolation history so it renders at its live pose next frame. */
|
|
32
|
+
snapPose(id: string): void;
|
|
33
|
+
/** Register a stage; returns an unregister handle. Stages run in registration order within a phase. */
|
|
34
|
+
addStage(stage: SimStage): () => void;
|
|
35
|
+
/** Run every stage registered for `phase`; drivers call this from inside {@link advance}'s body. */
|
|
36
|
+
runStages(phase: SimStagePhase, dt: number): void;
|
|
37
|
+
retune(config: SimulationConfig): void;
|
|
38
|
+
snapshot(): SimContextState;
|
|
39
|
+
restore(state: SimContextState): void;
|
|
40
|
+
}
|
|
41
|
+
/** Build `ctx.sim` for a context; `createGameContext` calls this from `definition.simulation`. */
|
|
42
|
+
export declare function createSimContext(options: {
|
|
43
|
+
config?: SimulationConfig;
|
|
44
|
+
entities: EntityStore;
|
|
45
|
+
}): SimContext;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createPoseHistory } from "./poseInterpolation.js";
|
|
2
|
+
import { createSimLoop } from "./simLoop.js";
|
|
3
|
+
/** Build `ctx.sim` for a context; `createGameContext` calls this from `definition.simulation`. */
|
|
4
|
+
export function createSimContext(options) {
|
|
5
|
+
const { entities } = options;
|
|
6
|
+
const loop = createSimLoop(options.config);
|
|
7
|
+
const poses = createPoseHistory({ snapDistance: loop.config().snapDistance });
|
|
8
|
+
const stages = new Map();
|
|
9
|
+
let advancing = false;
|
|
10
|
+
return {
|
|
11
|
+
loop,
|
|
12
|
+
advance(realDt, body) {
|
|
13
|
+
if (advancing)
|
|
14
|
+
throw new Error("ctx.sim.advance is not reentrant");
|
|
15
|
+
advancing = true;
|
|
16
|
+
try {
|
|
17
|
+
return loop.advance(realDt, (dt, tick) => {
|
|
18
|
+
if (loop.isFixed())
|
|
19
|
+
poses.beginStep(entities);
|
|
20
|
+
body(dt, tick);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
advancing = false;
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
tick: () => loop.tick(),
|
|
28
|
+
alpha: () => loop.alpha(),
|
|
29
|
+
renderPose(id, out) {
|
|
30
|
+
return poses.sample(entities, id, loop.isFixed() ? loop.alpha() : 1, out);
|
|
31
|
+
},
|
|
32
|
+
snapPose: (id) => poses.snap(id),
|
|
33
|
+
addStage(stage) {
|
|
34
|
+
const list = stages.get(stage.phase) ?? [];
|
|
35
|
+
list.push(stage);
|
|
36
|
+
stages.set(stage.phase, list);
|
|
37
|
+
return () => {
|
|
38
|
+
const current = stages.get(stage.phase);
|
|
39
|
+
if (current === undefined)
|
|
40
|
+
return;
|
|
41
|
+
const index = current.indexOf(stage);
|
|
42
|
+
if (index >= 0)
|
|
43
|
+
current.splice(index, 1);
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
runStages(phase, dt) {
|
|
47
|
+
const list = stages.get(phase);
|
|
48
|
+
if (list === undefined)
|
|
49
|
+
return;
|
|
50
|
+
const tick = loop.tick();
|
|
51
|
+
for (const stage of list)
|
|
52
|
+
stage.run(dt, tick);
|
|
53
|
+
},
|
|
54
|
+
retune(config) {
|
|
55
|
+
loop.retune(config);
|
|
56
|
+
if (config.snapDistance !== undefined)
|
|
57
|
+
poses.retune({ snapDistance: config.snapDistance });
|
|
58
|
+
},
|
|
59
|
+
snapshot: () => ({ loop: loop.snapshot(), poses: poses.snapshot() }),
|
|
60
|
+
restore(state) {
|
|
61
|
+
loop.restore(state.loop);
|
|
62
|
+
poses.restore(state.poses);
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** How the simulation is stepped: a fixed rate with a catch-up cap, or once per frame with the frame's dt. */
|
|
2
|
+
export interface SimulationConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Fixed simulation rate in steps per second, or `"variable"` to run one step per frame with the frame's
|
|
5
|
+
* clamped dt. Default `"variable"` (the historical behavior). A fixed rate makes every step's dt identical,
|
|
6
|
+
* which is what determinism, replay, rollback, and a stable physics solver need; renderers read
|
|
7
|
+
* `ctx.sim.renderPose` to interpolate between steps.
|
|
8
|
+
*/
|
|
9
|
+
hz?: number | "variable";
|
|
10
|
+
/** Fixed steps run per advance before the loop drops the remaining time instead of spiralling. Default 5. */
|
|
11
|
+
maxCatchUpSteps?: number;
|
|
12
|
+
/** Entities that move farther than this in one step snap instead of interpolating (teleports, respawns). Default 8 world units. */
|
|
13
|
+
snapDistance?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Serializable loop position: the tick count and the real seconds accumulated toward the next fixed step. */
|
|
16
|
+
export interface SimLoopState {
|
|
17
|
+
tick: number;
|
|
18
|
+
accumulator: number;
|
|
19
|
+
}
|
|
20
|
+
/** What one {@link SimLoop.advance} did. */
|
|
21
|
+
export interface SimAdvanceResult {
|
|
22
|
+
/** Steps run this advance. */
|
|
23
|
+
steps: number;
|
|
24
|
+
/** Fraction of a step accumulated but not yet simulated, 0..1; always 1 on a variable loop. */
|
|
25
|
+
alpha: number;
|
|
26
|
+
/** Tick count after this advance. */
|
|
27
|
+
tick: number;
|
|
28
|
+
/** Steps discarded because the catch-up cap was hit. */
|
|
29
|
+
dropped: number;
|
|
30
|
+
}
|
|
31
|
+
/** The stepper handle: advance with real time, read the tick and interpolation alpha, retune the rate, snapshot and restore. */
|
|
32
|
+
export interface SimLoop {
|
|
33
|
+
/** Feed real elapsed seconds; runs `step(dt, tick)` zero or more times and returns what happened. */
|
|
34
|
+
advance(realDt: number, step: (dt: number, tick: number) => void): SimAdvanceResult;
|
|
35
|
+
tick(): number;
|
|
36
|
+
alpha(): number;
|
|
37
|
+
/** Seconds per fixed step, `null` on a variable loop. */
|
|
38
|
+
stepSeconds(): number | null;
|
|
39
|
+
isFixed(): boolean;
|
|
40
|
+
retune(config: SimulationConfig): void;
|
|
41
|
+
config(): Readonly<Required<SimulationConfig>>;
|
|
42
|
+
snapshot(): SimLoopState;
|
|
43
|
+
restore(state: SimLoopState): void;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The simulation stepper every driver (shell frame, headless runner, authoritative host) advances through, so
|
|
47
|
+
* the sim sees the same dt sequence regardless of who drives it. Fixed mode accumulates real time and steps in
|
|
48
|
+
* equal slices with a catch-up cap; variable mode steps once per advance.
|
|
49
|
+
*
|
|
50
|
+
* @capability sim-loop fixed-step or variable simulation stepping with tick count, catch-up cap, and interpolation alpha
|
|
51
|
+
*/
|
|
52
|
+
export declare function createSimLoop(initial?: SimulationConfig): SimLoop;
|