@jgengine/core 0.19.0 → 0.22.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 +364 -0
- package/dist/ai/decisionGraph.d.ts +2 -0
- package/dist/ai/decisionGraph.js +6 -0
- package/dist/ai/flock.d.ts +1 -1
- package/dist/ai/flock.js +7 -4
- package/dist/anim/animGraph.d.ts +33 -2
- package/dist/anim/animGraph.js +200 -12
- package/dist/anim/locomotionGraph.d.ts +11 -3
- package/dist/anim/locomotionGraph.js +25 -15
- package/dist/audio/audioFalloff.d.ts +4 -0
- package/dist/audio/voiceAllocator.d.ts +46 -0
- package/dist/audio/voiceAllocator.js +76 -0
- package/dist/combat/abilityKit.d.ts +19 -0
- package/dist/combat/abilityKit.js +40 -4
- package/dist/combat/death.d.ts +4 -3
- package/dist/combat/death.js +3 -3
- package/dist/combat/magazine.d.ts +9 -0
- package/dist/combat/magazine.js +24 -2
- package/dist/combat/projectiles.d.ts +94 -2
- package/dist/combat/projectiles.js +282 -61
- package/dist/combat/regenShield.d.ts +14 -0
- package/dist/combat/regenShield.js +23 -7
- package/dist/crafting/recipeCatalog.d.ts +16 -0
- package/dist/crafting/recipeCatalog.js +55 -0
- package/dist/economy/auctionBook.d.ts +1 -1
- package/dist/economy/auctionBook.js +11 -1
- package/dist/economy/listingBook.d.ts +1 -1
- package/dist/economy/listingBook.js +11 -1
- package/dist/economy/resourceLedger.js +3 -3
- package/dist/economy/shopStock.js +7 -6
- package/dist/economy/techTree.js +7 -6
- package/dist/economy/unlockPoints.js +3 -6
- package/dist/editor/commandApply.js +89 -16
- package/dist/editor/commands.d.ts +38 -1
- package/dist/editor/commands.js +320 -3
- package/dist/editor/creatorStorage.d.ts +59 -0
- package/dist/editor/creatorStorage.js +164 -0
- package/dist/editor/document.d.ts +4 -0
- package/dist/editor/document.js +116 -17
- package/dist/editor/index.d.ts +3 -0
- package/dist/editor/index.js +1 -0
- package/dist/editor/liveSync.d.ts +4 -1
- package/dist/editor/liveSync.js +26 -10
- package/dist/editor/materialAuthoring.d.ts +14 -0
- package/dist/editor/materialAuthoring.js +38 -0
- package/dist/editor/modelNodeVisibility.d.ts +11 -0
- package/dist/editor/modelNodeVisibility.js +29 -0
- package/dist/editor/movementCatalog.d.ts +90 -0
- package/dist/editor/movementCatalog.js +144 -0
- package/dist/editor/simulation.d.ts +60 -0
- package/dist/editor/simulation.js +202 -0
- package/dist/editor/staticPrefab.d.ts +17 -0
- package/dist/editor/staticPrefab.js +51 -0
- package/dist/editor/terrainMaterials.d.ts +10 -0
- package/dist/editor/terrainMaterials.js +13 -0
- package/dist/editor/types.d.ts +11 -0
- package/dist/game/cameraConfig.d.ts +3 -1
- package/dist/game/clipRoles.d.ts +2 -1
- package/dist/game/clipRoles.js +7 -3
- package/dist/game/contentValidation.d.ts +69 -0
- package/dist/game/contentValidation.js +118 -0
- package/dist/game/controlGate.d.ts +8 -0
- package/dist/game/controlGate.js +43 -6
- package/dist/game/defineGame.d.ts +32 -3
- package/dist/game/dialogueGraph.d.ts +20 -0
- package/dist/game/dialogueGraph.js +107 -0
- package/dist/game/events.d.ts +5 -0
- package/dist/game/keyValueStore.d.ts +17 -4
- package/dist/game/keyValueStore.js +35 -23
- package/dist/game/lootModifiers.d.ts +17 -0
- package/dist/game/lootModifiers.js +26 -0
- package/dist/game/narrativeAuthoring.d.ts +67 -0
- package/dist/game/narrativeAuthoring.js +122 -0
- package/dist/game/playableGame.d.ts +52 -20
- package/dist/game/quest.d.ts +15 -0
- package/dist/game/quest.js +209 -63
- package/dist/game/questCatalog.d.ts +50 -0
- package/dist/game/questCatalog.js +205 -0
- package/dist/game/saveStore.d.ts +6 -5
- package/dist/game/saveStore.js +57 -57
- package/dist/game/systemRuntime.d.ts +2 -2
- package/dist/game/systemRuntime.js +84 -33
- package/dist/gameplay.d.ts +3 -0
- package/dist/gameplay.js +3 -0
- package/dist/interaction/promptRegistry.d.ts +5 -4
- package/dist/interaction/promptRegistry.js +8 -2
- package/dist/inventory/inventoryModel.js +9 -6
- package/dist/inventory/shapedGrid.d.ts +48 -9
- package/dist/inventory/shapedGrid.js +69 -10
- package/dist/item/durability.js +4 -0
- package/dist/item/itemInstanceRegistry.d.ts +32 -1
- package/dist/item/itemInstanceRegistry.js +111 -7
- package/dist/item/itemgen.d.ts +1 -1
- package/dist/item/itemgen.js +1 -1
- package/dist/item/modularItem.js +7 -1
- package/dist/material/appearanceSignals.d.ts +53 -0
- package/dist/material/appearanceSignals.js +47 -0
- package/dist/material/hairCards.d.ts +47 -0
- package/dist/material/hairCards.js +136 -0
- package/dist/material/materialAsset.d.ts +162 -0
- package/dist/material/materialAsset.js +382 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +348 -1
- package/dist/movement/characterController.d.ts +5 -2
- package/dist/movement/characterController.js +50 -23
- package/dist/movement/freeFlight.d.ts +2 -2
- package/dist/movement/freeFlight.js +3 -3
- package/dist/movement/movementModel.d.ts +24 -1
- package/dist/movement/movementModel.js +74 -7
- package/dist/movement/playerMovement.d.ts +28 -1
- package/dist/movement/playerMovement.js +425 -102
- package/dist/movement/poseState.d.ts +2 -0
- package/dist/movement/poseState.js +29 -1
- package/dist/movement/solidObstacles.d.ts +3 -1
- package/dist/movement/solidObstacles.js +8 -5
- package/dist/movement/terrainGrade.d.ts +13 -0
- package/dist/movement/terrainGrade.js +33 -0
- package/dist/movement/voxelController.d.ts +2 -2
- package/dist/movement/voxelController.js +3 -3
- package/dist/nav/navGrid.d.ts +19 -1
- package/dist/nav/navGrid.js +26 -10
- package/dist/nav/solidRoute.d.ts +50 -0
- package/dist/nav/solidRoute.js +144 -0
- package/dist/physics/ballisticSweep.d.ts +10 -3
- package/dist/physics/ballisticSweep.js +53 -29
- package/dist/physics/forceVolume.d.ts +41 -0
- package/dist/physics/forceVolume.js +88 -0
- package/dist/physics/kinematicVehicle.d.ts +8 -3
- package/dist/physics/kinematicVehicle.js +2 -2
- package/dist/physics/physicsWorldBackend.js +19 -3
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +8 -9
- package/dist/physics/vehicleDynamics.d.ts +3 -2
- package/dist/physics/vehicleDynamics.js +2 -2
- package/dist/physics/vehicleObstacles.d.ts +3 -2
- package/dist/physics/vehicleObstacles.js +29 -5
- package/dist/runtime/cameraDirector.d.ts +39 -2
- package/dist/runtime/cameraDirector.js +162 -0
- package/dist/runtime/commandDispatch.d.ts +14 -0
- package/dist/runtime/commandDispatch.js +19 -0
- package/dist/runtime/context/combat.d.ts +11 -4
- package/dist/runtime/context/combat.js +178 -11
- package/dist/runtime/context/deathLoot.d.ts +9 -8
- package/dist/runtime/context/deathLoot.js +16 -16
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +13 -21
- package/dist/runtime/descriptors/baseline.js +34 -3
- package/dist/runtime/descriptors/features.d.ts +2 -1
- package/dist/runtime/descriptors/features.js +18 -0
- package/dist/runtime/gameContext.js +79 -13
- package/dist/runtime/gameContextTypes.d.ts +14 -0
- package/dist/runtime/headlessRunner.d.ts +3 -1
- package/dist/runtime/headlessRunner.js +11 -11
- package/dist/runtime/hostedGameRunner.d.ts +24 -1
- package/dist/runtime/hostedGameRunner.js +156 -10
- package/dist/runtime/hostedWorldSession.d.ts +17 -5
- package/dist/runtime/hostedWorldSession.js +154 -16
- package/dist/runtime/hostedWorldStore.d.ts +2 -2
- package/dist/runtime/inputRecorder.d.ts +1 -1
- package/dist/runtime/inputRecorder.js +21 -1
- package/dist/runtime/inputSnapshot.d.ts +16 -4
- package/dist/runtime/inputSnapshot.js +31 -3
- package/dist/runtime/runtimeSave.d.ts +10 -6
- package/dist/runtime/runtimeSave.js +11 -5
- package/dist/runtime/simContext.d.ts +8 -3
- package/dist/runtime/simContext.js +7 -3
- package/dist/runtime/simSnapshot.js +2 -0
- package/dist/runtime/worldReplication.d.ts +2 -0
- package/dist/runtime/worldReplication.js +1 -1
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/behaviorRuntime.d.ts +26 -5
- package/dist/scene/behaviorRuntime.js +263 -16
- package/dist/scene/behaviors.d.ts +30 -1
- package/dist/scene/behaviors.js +29 -0
- package/dist/scene/entityStore.d.ts +14 -3
- package/dist/scene/entityStore.js +45 -9
- package/dist/scene/mount.d.ts +9 -0
- package/dist/scene/mount.js +35 -0
- package/dist/scene/possession.d.ts +2 -0
- package/dist/scene/possession.js +21 -1
- package/dist/scene/sceneRaycast.d.ts +7 -0
- package/dist/scene/sceneRaycast.js +55 -0
- package/dist/scene/spatial.d.ts +20 -1
- package/dist/scene/spatial.js +94 -2
- package/dist/scene/vehicleSeat.d.ts +36 -2
- package/dist/scene/vehicleSeat.js +89 -2
- package/dist/stats/accumulatorMeter.d.ts +10 -0
- package/dist/stats/accumulatorMeter.js +6 -0
- package/dist/stats/eventMeter.d.ts +9 -2
- package/dist/stats/eventMeter.js +5 -2
- package/dist/store/defineStore.d.ts +3 -1
- package/dist/store/defineStore.js +3 -1
- package/dist/store/observableKeyedStore.d.ts +1 -1
- package/dist/store/observableKeyedStore.js +8 -6
- package/dist/time/simClock.d.ts +6 -2
- package/dist/time/simClock.js +5 -0
- package/dist/vfx/particleDirector.d.ts +58 -6
- package/dist/vfx/particleDirector.js +113 -22
- package/dist/vfx/particles.d.ts +68 -5
- package/dist/vfx/particles.js +284 -32
- package/dist/visibility/assetStreaming.d.ts +21 -3
- package/dist/visibility/assetStreaming.js +108 -24
- package/dist/visibility/settings.d.ts +8 -2
- package/dist/visibility/settings.js +3 -0
- package/dist/visibility/visibilitySystem.js +1 -0
- package/dist/work/expedition.d.ts +110 -0
- package/dist/work/expedition.js +195 -0
- package/dist/work/staffedStation.d.ts +29 -0
- package/dist/work/staffedStation.js +27 -0
- package/dist/world/authoredAnimation.d.ts +38 -0
- package/dist/world/authoredAnimation.js +99 -0
- package/dist/world/authoredObjects.d.ts +24 -4
- package/dist/world/authoredObjects.js +43 -16
- package/dist/world/authoredSimulation.d.ts +74 -0
- package/dist/world/authoredSimulation.js +302 -0
- package/dist/world/authoredWeather.d.ts +120 -0
- package/dist/world/authoredWeather.js +314 -0
- package/dist/world/buildingKit.d.ts +8 -3
- package/dist/world/buildingKit.js +21 -8
- package/dist/world/environmentForces.d.ts +21 -0
- package/dist/world/environmentForces.js +42 -0
- package/dist/world/features.d.ts +3 -3
- package/dist/world/footprintGrid.d.ts +55 -0
- package/dist/world/footprintGrid.js +201 -0
- package/dist/world/grassKind.d.ts +10 -0
- package/dist/world/grassKind.js +22 -0
- package/dist/world/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +2 -2
- package/dist/world/terraform.js +46 -18
- package/dist/world/weather.d.ts +17 -0
- package/dist/world/weather.js +116 -20
- package/dist/world/worldSolids.d.ts +11 -0
- package/dist/world/worldSolids.js +112 -1
- package/dist/world.d.ts +5 -5
- package/dist/world.js +4 -4
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +23 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-combat/SKILL.md +8 -2
- package/skills/jgengine-combat/api.md +22 -10
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
- package/skills/jgengine-combat/reference.md +24 -2
- package/skills/jgengine-gameplay/SKILL.md +63 -0
- package/skills/jgengine-gameplay/api.md +131 -37
- package/skills/jgengine-gameplay/capabilities.md +68 -0
- package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
- package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
- package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
- package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
- package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
- package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
- package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
- package/skills/jgengine-gameplay/reference-systems.md +119 -0
- package/skills/jgengine-world/SKILL.md +12 -3
- package/skills/jgengine-world/api.md +201 -58
- package/skills/jgengine-world/capabilities.md +116 -3
- package/skills/jgengine-world/recipes/character-animation.md +31 -8
- package/skills/jgengine-world/recipes/controls.md +4 -0
- package/skills/jgengine-world/recipes/npc-senses.md +12 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
- package/skills/jgengine-world/reference.md +167 -5
- package/skills/jgengine-world/visibility.md +28 -1
|
@@ -2,11 +2,14 @@ import { createInputBuffer } from "../input/inputBuffer.js";
|
|
|
2
2
|
import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
|
|
3
3
|
import { createCharacterController, } from "./characterController.js";
|
|
4
4
|
import { groundFieldFor, hasEnvironmentTerrain, sampleSlope } from "../world/terrain.js";
|
|
5
|
-
import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep,
|
|
5
|
+
import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, DEFAULT_OBSTACLE_PLAYER_HEIGHT, motionStepSeconds, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, resolveObstacleVerticalStep, snapPositionToGrid, } from "./movementModel.js";
|
|
6
6
|
import { advanceFreeFlight, createFreeFlightState, resolveFlightStep, resolveFreeFlightIntentFromInput, } from "./freeFlight.js";
|
|
7
7
|
import { solidObstaclesNear } from "./solidObstacles.js";
|
|
8
|
+
import { withPoseRollback } from "./poseState.js";
|
|
8
9
|
import { approachYaw, steerYaw } from "./steering.js";
|
|
10
|
+
import { resolveTerrainGradeStep } from "./terrainGrade.js";
|
|
9
11
|
import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
|
|
12
|
+
const CONTROLLER_CONFIG_FIELDS = ["radius", "height", "stepHeight", "maxSlopeDeg", "skinWidth", "crouchHeight", "snapDistance", "maxSlides", "mask"];
|
|
10
13
|
const DEFAULT_TURN_SPEED = 2.4;
|
|
11
14
|
const DEFAULT_WALK_SPEED = 2;
|
|
12
15
|
const DEFAULT_SWIM_SPEED_MULTIPLIER = 0.65;
|
|
@@ -35,42 +38,41 @@ export function resolvePhysicsTuning(physics) {
|
|
|
35
38
|
}
|
|
36
39
|
/** Gather a game's collision/movement/physics/world config into a {@link PlayerMovementTuning} — call once per world; both the shell and a host pass the result to {@link stepPlayerMovement}. */
|
|
37
40
|
export function resolvePlayerMovementTuning(opts) {
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
41
|
+
const overrides = {
|
|
42
|
+
get gravityAcceleration() { return opts.physics?.gravity === undefined ? undefined : -opts.physics.gravity; },
|
|
43
|
+
get jumpVelocity() { return opts.physics?.jumpVelocity; },
|
|
44
|
+
get backpedalSpeedMultiplier() { return opts.movement?.backpedalMult; },
|
|
45
|
+
get groundAcceleration() { return opts.movement?.feel?.groundAcceleration; },
|
|
46
|
+
get airAcceleration() { return opts.movement?.feel?.airAcceleration; },
|
|
47
|
+
get groundFriction() { return opts.movement?.feel?.groundFriction; },
|
|
48
|
+
get runSpeedMultiplier() { return opts.movement?.feel?.runMultiplier; },
|
|
49
|
+
get crouchSpeedMultiplier() { return opts.movement?.feel?.crouchMultiplier; },
|
|
50
|
+
get jumpBufferMs() { return opts.movement?.feel?.jumpBufferMs; },
|
|
51
|
+
get coyoteMs() { return opts.movement?.feel?.coyoteMs; },
|
|
52
|
+
get jumpCutFactor() { return opts.movement?.feel?.jumpCutFactor; },
|
|
53
|
+
get apexGravityScale() { return opts.movement?.feel?.apexGravityScale; },
|
|
54
|
+
get apexSpeed() { return opts.movement?.feel?.apexSpeed; },
|
|
55
|
+
get fallGravityScale() { return opts.movement?.feel?.fallGravityScale; },
|
|
56
|
+
get landingRecoveryMs() { return opts.movement?.feel?.landingRecoveryMs; },
|
|
57
|
+
get landingSpeedScale() { return opts.movement?.feel?.landingSpeedScale; },
|
|
58
|
+
};
|
|
59
|
+
const defaultCapsule = {
|
|
60
|
+
radius: DEFAULT_OBSTACLE_PLAYER_RADIUS,
|
|
61
|
+
height: 1.8,
|
|
62
|
+
stepHeight: DEFAULT_PLAYER_STEP_HEIGHT,
|
|
63
|
+
};
|
|
64
|
+
const controller = {
|
|
65
|
+
get backend() { return opts.physics.backend; },
|
|
66
|
+
get capsule() { return opts.physics?.controller ?? defaultCapsule; },
|
|
56
67
|
};
|
|
57
|
-
const overrides = Object.keys(feelOverrides).length === 0 ? physics : { ...(physics ?? {}), ...feelOverrides };
|
|
58
68
|
return {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
backend: opts.physics.backend,
|
|
67
|
-
capsule: opts.physics.controller ?? {
|
|
68
|
-
radius: DEFAULT_OBSTACLE_PLAYER_RADIUS,
|
|
69
|
-
height: 1.8,
|
|
70
|
-
stepHeight: DEFAULT_PLAYER_STEP_HEIGHT,
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
}),
|
|
69
|
+
get collision() { return opts.collision; },
|
|
70
|
+
get movement() { return opts.movement; },
|
|
71
|
+
get physics() {
|
|
72
|
+
return opts.physics === undefined && opts.movement?.feel === undefined && opts.movement?.backpedalMult === undefined
|
|
73
|
+
? undefined : overrides;
|
|
74
|
+
},
|
|
75
|
+
get controller() { return opts.physics?.backend === undefined ? undefined : controller; },
|
|
74
76
|
ground: groundFieldFor(opts.world),
|
|
75
77
|
hasTerrain: hasEnvironmentTerrain(opts.world),
|
|
76
78
|
};
|
|
@@ -84,10 +86,24 @@ function storeFor(ctx) {
|
|
|
84
86
|
solids: { count: -1, set: new Set() },
|
|
85
87
|
};
|
|
86
88
|
stores.set(ctx, store);
|
|
89
|
+
const retained = store;
|
|
90
|
+
ctx.scene.entity.subscribeMembership(() => {
|
|
91
|
+
for (const [userId, state] of retained.players) {
|
|
92
|
+
if (state.liveEntityId !== null) {
|
|
93
|
+
if (ctx.scene.entity.get(state.liveEntityId) === null)
|
|
94
|
+
retained.players.delete(userId);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const entityId = ctx.player.possession.active(userId);
|
|
98
|
+
if (ctx.scene.entity.get(entityId) !== null)
|
|
99
|
+
state.liveEntityId = entityId;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
87
103
|
}
|
|
88
104
|
return store;
|
|
89
105
|
}
|
|
90
|
-
function stateFor(store, userId) {
|
|
106
|
+
function stateFor(ctx, store, userId) {
|
|
91
107
|
let state = store.players.get(userId);
|
|
92
108
|
if (state === undefined) {
|
|
93
109
|
state = {
|
|
@@ -97,14 +113,95 @@ function stateFor(store, userId) {
|
|
|
97
113
|
motion: null,
|
|
98
114
|
flight: null,
|
|
99
115
|
controller: null,
|
|
116
|
+
controllerConfig: null,
|
|
100
117
|
controllerJumpHeld: false,
|
|
101
118
|
jumpBuffer: null,
|
|
102
119
|
pendingController: null,
|
|
120
|
+
heightfieldHeight: null,
|
|
121
|
+
heightfieldEntityId: null,
|
|
122
|
+
liveEntityId: null,
|
|
123
|
+
entityId: null,
|
|
124
|
+
telemetry: null,
|
|
103
125
|
};
|
|
126
|
+
const entityId = ctx.player.possession.active(userId);
|
|
127
|
+
if (ctx.scene.entity.get(entityId) !== null)
|
|
128
|
+
state.liveEntityId = entityId;
|
|
104
129
|
store.players.set(userId, state);
|
|
105
130
|
}
|
|
106
131
|
return state;
|
|
107
132
|
}
|
|
133
|
+
function invalidateTelemetry(state) {
|
|
134
|
+
state.entityId = null;
|
|
135
|
+
state.telemetry = null;
|
|
136
|
+
}
|
|
137
|
+
function validateCollisionHeight(height) {
|
|
138
|
+
if (!Number.isFinite(height) || height <= 0) {
|
|
139
|
+
throw new RangeError("PlayerMovementConfig.collisionHeight must be finite and positive.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function retuneController(state, config) {
|
|
143
|
+
const previous = state.controllerConfig;
|
|
144
|
+
if (previous !== null) {
|
|
145
|
+
let changed = false;
|
|
146
|
+
for (const field of CONTROLLER_CONFIG_FIELDS) {
|
|
147
|
+
if (previous[field] !== config[field]) {
|
|
148
|
+
changed = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!changed)
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const next = {
|
|
156
|
+
radius: config.radius,
|
|
157
|
+
height: config.height,
|
|
158
|
+
stepHeight: config.stepHeight,
|
|
159
|
+
maxSlopeDeg: config.maxSlopeDeg,
|
|
160
|
+
skinWidth: config.skinWidth,
|
|
161
|
+
crouchHeight: config.crouchHeight,
|
|
162
|
+
snapDistance: config.snapDistance,
|
|
163
|
+
maxSlides: config.maxSlides,
|
|
164
|
+
mask: config.mask,
|
|
165
|
+
};
|
|
166
|
+
state.controller.retune(next);
|
|
167
|
+
state.controllerConfig = next;
|
|
168
|
+
}
|
|
169
|
+
function positionAccepted(ctx, entityId, x, y, z) {
|
|
170
|
+
const actual = ctx.scene.entity.get(entityId)?.position;
|
|
171
|
+
return actual !== undefined && actual[0] === x && actual[1] === y && actual[2] === z;
|
|
172
|
+
}
|
|
173
|
+
function updateTelemetry(state, entityId, grounded, verticalVelocity, crouching, collisionHeight, requestedCollisionHeight, collisionHeightBlocked) {
|
|
174
|
+
state.entityId = entityId;
|
|
175
|
+
const telemetry = state.telemetry ??= { grounded, verticalVelocity, crouching };
|
|
176
|
+
telemetry.grounded = grounded;
|
|
177
|
+
telemetry.verticalVelocity = verticalVelocity;
|
|
178
|
+
telemetry.crouching = crouching;
|
|
179
|
+
if (collisionHeight === undefined) {
|
|
180
|
+
delete telemetry.collisionHeight;
|
|
181
|
+
delete telemetry.requestedCollisionHeight;
|
|
182
|
+
delete telemetry.collisionHeightBlocked;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
telemetry.collisionHeight = collisionHeight;
|
|
186
|
+
telemetry.requestedCollisionHeight = requestedCollisionHeight;
|
|
187
|
+
telemetry.collisionHeightBlocked = collisionHeightBlocked;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Read the entity's last shared movement result through indexed possession ownership. Returns `null` before a
|
|
192
|
+
* step, after movement restore/forget, when a commit policy replaces the motor proposal, or for an entity not
|
|
193
|
+
* currently driven by this motor. Reuses one live view;
|
|
194
|
+
* custom movers supply their own animation parameters. Flight never reports ground contact or a walking crouch.
|
|
195
|
+
*
|
|
196
|
+
* @capability movement-telemetry read physical grounded, vertical velocity and crouch state without animation-store writes
|
|
197
|
+
*/
|
|
198
|
+
export function playerMovementTelemetry(ctx, entityId) {
|
|
199
|
+
const userId = ctx.player.possession.ownerOf(entityId) ?? entityId;
|
|
200
|
+
if (ctx.player.possession.active(userId) !== entityId || ctx.scene.entity.get(entityId) === null)
|
|
201
|
+
return null;
|
|
202
|
+
const state = stores.get(ctx)?.players.get(userId);
|
|
203
|
+
return state?.entityId === entityId ? state.telemetry : null;
|
|
204
|
+
}
|
|
108
205
|
/** One player's current heading (radians), integrated by {@link stepPlayerMovement} — the shell reads it back into its camera/aim yaw. */
|
|
109
206
|
export function playerMovementHeading(ctx, userId) {
|
|
110
207
|
return stores.get(ctx)?.players.get(userId)?.heading ?? 0;
|
|
@@ -127,16 +224,32 @@ export function snapshotPlayerMovement(ctx, userId) {
|
|
|
127
224
|
controller: controller === null ? null : copyController(controller),
|
|
128
225
|
controllerJumpHeld: state.controllerJumpHeld,
|
|
129
226
|
jumpBuffer: state.jumpBuffer?.snapshot() ?? null,
|
|
227
|
+
heightfieldHeight: state.heightfieldHeight,
|
|
228
|
+
heightfieldEntityId: state.heightfieldEntityId,
|
|
130
229
|
};
|
|
131
230
|
}
|
|
132
|
-
/** Put a player's movement state back to a {@link snapshotPlayerMovement} copy
|
|
231
|
+
/** Put a player's movement state back to a {@link snapshotPlayerMovement} copy. Pending state survives until first spawn; a driven actor's despawn clears it. */
|
|
133
232
|
export function restorePlayerMovement(ctx, userId, snapshot) {
|
|
134
|
-
const
|
|
233
|
+
const heightfieldHeight = snapshot.heightfieldHeight === undefined
|
|
234
|
+
? (snapshot.motion === null ? null : DEFAULT_OBSTACLE_PLAYER_HEIGHT) : snapshot.heightfieldHeight;
|
|
235
|
+
if (heightfieldHeight !== null)
|
|
236
|
+
validateCollisionHeight(heightfieldHeight);
|
|
237
|
+
const heightfieldEntityId = snapshot.heightfieldEntityId === undefined
|
|
238
|
+
? (heightfieldHeight === null ? null : ctx.player.possession.active(userId)) : snapshot.heightfieldEntityId;
|
|
239
|
+
if (heightfieldEntityId !== null && typeof heightfieldEntityId !== "string") {
|
|
240
|
+
throw new RangeError("PlayerMovementSnapshot.heightfieldEntityId must be a string or null.");
|
|
241
|
+
}
|
|
242
|
+
const state = stateFor(ctx, storeFor(ctx), userId);
|
|
243
|
+
const liveEntityId = ctx.player.possession.active(userId);
|
|
244
|
+
state.liveEntityId = ctx.scene.entity.get(liveEntityId) === null ? null : liveEntityId;
|
|
245
|
+
invalidateTelemetry(state);
|
|
135
246
|
state.heading = snapshot.heading;
|
|
136
247
|
state.facing = snapshot.facing;
|
|
137
248
|
state.voxelBody = snapshot.voxelBody === null ? null : { ...snapshot.voxelBody };
|
|
138
249
|
state.motion = snapshot.motion === null ? null : { ...snapshot.motion };
|
|
139
250
|
state.flight = snapshot.flight === null ? null : { ...snapshot.flight };
|
|
251
|
+
state.heightfieldHeight = heightfieldHeight;
|
|
252
|
+
state.heightfieldEntityId = heightfieldEntityId;
|
|
140
253
|
state.controllerJumpHeld = snapshot.controllerJumpHeld;
|
|
141
254
|
const jumpBuffer = snapshot.jumpBuffer ?? null;
|
|
142
255
|
if (jumpBuffer === null) {
|
|
@@ -224,27 +337,81 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
224
337
|
if (player.movement?.frozen === true)
|
|
225
338
|
return;
|
|
226
339
|
const store = storeFor(ctx);
|
|
227
|
-
const
|
|
340
|
+
const retainedState = store.players.get(userId);
|
|
341
|
+
const state = stateFor(ctx, store, userId);
|
|
342
|
+
let collisionHeight = DEFAULT_OBSTACLE_PLAYER_HEIGHT;
|
|
343
|
+
let requestedCollisionHeight = DEFAULT_OBSTACLE_PLAYER_HEIGHT;
|
|
344
|
+
let collisionHeightBlocked = false;
|
|
228
345
|
const held = new Set(input.held);
|
|
229
346
|
const isDown = (action) => held.has(action);
|
|
347
|
+
let nextHeading = state.heading;
|
|
230
348
|
if (heading !== undefined) {
|
|
231
|
-
|
|
349
|
+
nextHeading = heading;
|
|
232
350
|
}
|
|
233
351
|
else {
|
|
234
352
|
const turnInput = (isDown("turnRight") ? 1 : 0) - (isDown("turnLeft") ? 1 : 0);
|
|
235
353
|
const turnSpeed = tuning.movement?.turnSpeed ?? DEFAULT_TURN_SPEED;
|
|
236
354
|
if (turnInput !== 0)
|
|
237
|
-
|
|
355
|
+
nextHeading = steerYaw(nextHeading, turnInput, turnSpeed, dt);
|
|
238
356
|
}
|
|
239
|
-
const forwardX = Math.sin(
|
|
240
|
-
const forwardZ = Math.cos(
|
|
357
|
+
const forwardX = Math.sin(nextHeading);
|
|
358
|
+
const forwardZ = Math.cos(nextHeading);
|
|
241
359
|
const keys = createEmptyMovementKeys();
|
|
242
360
|
keys.w = isDown("moveForward");
|
|
243
361
|
keys.s = isDown("moveBack");
|
|
244
362
|
keys.a = isDown("moveLeft");
|
|
245
363
|
keys.d = isDown("moveRight");
|
|
246
|
-
|
|
247
|
-
|
|
364
|
+
let flightTuning;
|
|
365
|
+
try {
|
|
366
|
+
flightTuning = withPoseRollback(ctx.player.movement, playerId, () => {
|
|
367
|
+
keys.shift = isDown("sprint") && (tuning.movement?.canSprint?.(ctx) ?? true);
|
|
368
|
+
keys.space = isDown("jump");
|
|
369
|
+
keys.c = isDown("crouch") && ctx.player.movement.setPose(playerId, "crouch") === null;
|
|
370
|
+
if (!keys.c && tuning.controller === undefined) {
|
|
371
|
+
const currentPose = ctx.player.movement.getPose(playerId);
|
|
372
|
+
if (currentPose !== "prone")
|
|
373
|
+
ctx.player.movement.setPose(playerId, keys.shift ? "running" : "standing");
|
|
374
|
+
}
|
|
375
|
+
const flightTuning = flightTuningFor(tuning.movement, ctx);
|
|
376
|
+
if (flightTuning === null && tuning.controller === undefined && tuning.collision?.voxel !== true) {
|
|
377
|
+
const authoredHeight = tuning.movement?.collisionHeight;
|
|
378
|
+
requestedCollisionHeight = authoredHeight === undefined ? DEFAULT_OBSTACLE_PLAYER_HEIGHT : authoredHeight;
|
|
379
|
+
validateCollisionHeight(requestedCollisionHeight);
|
|
380
|
+
const previousHeight = state.heightfieldEntityId !== null && state.heightfieldEntityId !== playerId
|
|
381
|
+
? null : state.heightfieldHeight;
|
|
382
|
+
collisionHeight = previousHeight ?? requestedCollisionHeight;
|
|
383
|
+
if (tuning.movement?.collideObjects !== false &&
|
|
384
|
+
(previousHeight === null && authoredHeight !== undefined || requestedCollisionHeight > collisionHeight)) {
|
|
385
|
+
const swim = tuning.movement?.swim;
|
|
386
|
+
const swimEnabled = swim === true || (typeof swim === "object" && swim !== null);
|
|
387
|
+
const floating = swimEnabled && tuning.ground.waterLevel !== undefined &&
|
|
388
|
+
tuning.ground.sampleHeight(player.position[0], player.position[2]) < tuning.ground.waterLevel;
|
|
389
|
+
const groundedSupport = state.motion?.grounded !== false && (state.motion?.jumpOffset ?? 0) <= 0 && !floating;
|
|
390
|
+
const supportStep = groundedSupport ? tuning.movement?.stepHeight ?? DEFAULT_PLAYER_STEP_HEIGHT : 0;
|
|
391
|
+
const bounds = gatherMovementObstacles(ctx, player.position, 0, 0, 0, supportStep, Math.max(collisionHeight, requestedCollisionHeight));
|
|
392
|
+
// An authored terrain-relative spawn can rest a few centimetres inside a walkable road plate.
|
|
393
|
+
// Test the same support feet the grounded motor will accept, including the resulting headroom.
|
|
394
|
+
const support = groundedSupport
|
|
395
|
+
? obstacleSupportHeight(player.position[0], player.position[2], player.position[1], supportStep, bounds) : null;
|
|
396
|
+
const supportedPosition = support !== null && support > player.position[1]
|
|
397
|
+
? [player.position[0], support, player.position[2]] : player.position;
|
|
398
|
+
const overlap = resolveObstacleVerticalStep(supportedPosition, 0, bounds, DEFAULT_OBSTACLE_PLAYER_RADIUS, requestedCollisionHeight).initialOverlap;
|
|
399
|
+
if (overlap && previousHeight === null) {
|
|
400
|
+
throw new RangeError(`PlayerMovementConfig.collisionHeight ${requestedCollisionHeight} intersects blocking geometry at entity ${playerId}. Move the authored placement or choose a height that fits.`);
|
|
401
|
+
}
|
|
402
|
+
collisionHeightBlocked = overlap;
|
|
403
|
+
}
|
|
404
|
+
if (!collisionHeightBlocked)
|
|
405
|
+
collisionHeight = requestedCollisionHeight;
|
|
406
|
+
}
|
|
407
|
+
return flightTuning;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (retainedState === undefined && store.players.get(userId) === state)
|
|
412
|
+
store.players.delete(userId);
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
248
415
|
// A frame carrying analog magnitudes (virtual joystick, gamepad stick) walks at its deflection
|
|
249
416
|
// instead of slamming digital ±1 axes — the fix for "a slight stick tilt reads as a full strafe".
|
|
250
417
|
const analog = input.analog ?? null;
|
|
@@ -255,9 +422,14 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
255
422
|
right: (analog.moveRight ?? 0) - (analog.moveLeft ?? 0),
|
|
256
423
|
};
|
|
257
424
|
const intent = resolveMovementIntent(keys, true, analogMove);
|
|
258
|
-
const motionBatch = ctx.player.motionFor(userId).takePending();
|
|
259
425
|
const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED;
|
|
260
|
-
|
|
426
|
+
if (flightTuning === null && tuning.controller === undefined && tuning.collision?.voxel !== true) {
|
|
427
|
+
state.heightfieldHeight = collisionHeight;
|
|
428
|
+
state.heightfieldEntityId = playerId;
|
|
429
|
+
}
|
|
430
|
+
state.liveEntityId = playerId;
|
|
431
|
+
state.heading = nextHeading;
|
|
432
|
+
const motionBatch = ctx.player.motionFor(userId).takePending();
|
|
261
433
|
if (flightTuning !== null) {
|
|
262
434
|
const value = (action) => input.analog?.[action] ?? (isDown(action) ? 1 : 0);
|
|
263
435
|
let flightIntent = resolveFreeFlightIntentFromInput(isDown, value, input.pointer, flightTuning.bindings);
|
|
@@ -295,7 +467,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
295
467
|
const cam = ctx.camera;
|
|
296
468
|
cam?.setChaseTuning(normalizedFlightTuning.camera ?? null);
|
|
297
469
|
}
|
|
298
|
-
const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning);
|
|
470
|
+
const step = advanceFreeFlight(flightState, flightIntent, state.heading, pitch, dt, normalizedFlightTuning, { authoritativeStep: tuning.authoritativeStep });
|
|
299
471
|
let stepX = step.stepX;
|
|
300
472
|
let stepY = step.stepY;
|
|
301
473
|
let stepZ = step.stepZ;
|
|
@@ -314,10 +486,12 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
314
486
|
flightState.vy = 0;
|
|
315
487
|
}
|
|
316
488
|
}
|
|
489
|
+
let motorProposalAccepted = true;
|
|
317
490
|
let nextX = player.position[0] + stepX;
|
|
318
491
|
let nextY = player.position[1] + stepY;
|
|
319
492
|
let nextZ = player.position[2] + stepZ;
|
|
320
493
|
if (motionBatch !== null && motionBatch.y !== null) {
|
|
494
|
+
motorProposalAccepted = motionBatch.y === nextY;
|
|
321
495
|
nextY = motionBatch.y;
|
|
322
496
|
}
|
|
323
497
|
if (tuning.movement?.beforeCommit !== undefined) {
|
|
@@ -330,6 +504,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
330
504
|
};
|
|
331
505
|
const replacement = tuning.movement.beforeCommit(frame);
|
|
332
506
|
if (replacement !== undefined) {
|
|
507
|
+
motorProposalAccepted = motorProposalAccepted && replacement[0] === nextX && replacement[1] === nextY && replacement[2] === nextZ;
|
|
333
508
|
nextX = replacement[0];
|
|
334
509
|
nextY = replacement[1];
|
|
335
510
|
nextZ = replacement[2];
|
|
@@ -340,6 +515,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
340
515
|
rotationY: resolveBodyFacing(state, flightIntent.moving, flightState.vx, flightState.vz, player.rotationY, tuning.movement?.turnSpeed, dt),
|
|
341
516
|
dt,
|
|
342
517
|
});
|
|
518
|
+
if (motorProposalAccepted && positionAccepted(ctx, playerId, nextX, nextY, nextZ))
|
|
519
|
+
updateTelemetry(state, playerId, false, flightState.vy, false);
|
|
520
|
+
else
|
|
521
|
+
invalidateTelemetry(state);
|
|
343
522
|
return;
|
|
344
523
|
}
|
|
345
524
|
if (flightTuning === null && state.flight !== null) {
|
|
@@ -361,6 +540,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
361
540
|
});
|
|
362
541
|
state.pendingController = null;
|
|
363
542
|
}
|
|
543
|
+
retuneController(state, tuning.controller.capsule);
|
|
364
544
|
const controllerState = controller.state();
|
|
365
545
|
if (Math.hypot(controllerState.position[0] - player.position[0], controllerState.position[1] - player.position[1], controllerState.position[2] - player.position[2]) > 1e-6) {
|
|
366
546
|
controller.restore({
|
|
@@ -370,43 +550,53 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
370
550
|
groundBody: null,
|
|
371
551
|
});
|
|
372
552
|
}
|
|
373
|
-
// Keep the existing input feel and horizontal impulse seam; the capsule owns vertical motion and collision.
|
|
374
553
|
let horizontal = state.motion;
|
|
375
554
|
if (horizontal === null) {
|
|
376
555
|
horizontal = createPlayerMotionState();
|
|
377
556
|
state.motion = horizontal;
|
|
378
557
|
}
|
|
558
|
+
controller.setCrouch(tuning.controller.backend, intent.crouching);
|
|
379
559
|
const grounded = controller.state().grounded;
|
|
380
560
|
horizontal.grounded = grounded;
|
|
381
|
-
horizontal.verticalVelocity =
|
|
561
|
+
horizontal.verticalVelocity = applyMotionImpulses(controller.state().verticalVelocity, motionBatch);
|
|
382
562
|
horizontal.jumpOffset = 0;
|
|
383
563
|
[horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ] = applyHorizontalImpulses(horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ, motionBatch);
|
|
384
|
-
const
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
564
|
+
const controllerIntent = {
|
|
565
|
+
...intent,
|
|
566
|
+
crouching: controller.state().crouching,
|
|
567
|
+
running: intent.running && !controller.state().crouching,
|
|
568
|
+
};
|
|
569
|
+
if (state.jumpBuffer === null) {
|
|
570
|
+
horizontal.jumpHeld = state.controllerJumpHeld;
|
|
571
|
+
state.jumpBuffer = createInputBuffer({ windowMs: tuning.physics?.jumpBufferMs ?? 0 });
|
|
572
|
+
}
|
|
573
|
+
const horizontalStep = advancePlayerMotion(horizontal, controllerIntent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, { buffer: state.jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep });
|
|
389
574
|
state.controllerJumpHeld = intent.jumping;
|
|
390
|
-
const current = controller.state();
|
|
391
575
|
controller.restore({
|
|
392
|
-
...
|
|
393
|
-
verticalVelocity:
|
|
576
|
+
...controller.state(),
|
|
577
|
+
verticalVelocity: horizontal.verticalVelocity,
|
|
578
|
+
grounded: horizontal.grounded,
|
|
394
579
|
});
|
|
395
580
|
const result = controller.move(tuning.controller.backend, {
|
|
396
581
|
motion: [horizontalStep.stepX, 0, horizontalStep.stepZ],
|
|
397
|
-
dt,
|
|
398
|
-
gravity: tuning.physics?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration,
|
|
399
|
-
...(jumpPressed && !intent.crouching
|
|
400
|
-
? { jumpVelocity: tuning.physics?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity }
|
|
401
|
-
: {}),
|
|
402
|
-
crouch: intent.crouching,
|
|
582
|
+
dt: motionStepSeconds(dt, tuning.authoritativeStep),
|
|
403
583
|
});
|
|
584
|
+
horizontal.verticalVelocity = controller.state().verticalVelocity;
|
|
585
|
+
horizontal.jumpOffset = 0;
|
|
586
|
+
if (!grounded && result.grounded)
|
|
587
|
+
horizontal.landedAtMs = horizontal.clockMs;
|
|
588
|
+
horizontal.wasAirborne = !result.grounded;
|
|
589
|
+
if (result.grounded)
|
|
590
|
+
horizontal.jumpRising = false;
|
|
591
|
+
ctx.player.movement.setPose(playerId, controller.state().crouching ? "crouch" : intent.running ? "running" : "standing");
|
|
592
|
+
let motorProposalAccepted = true;
|
|
404
593
|
let nextPosition = [
|
|
405
594
|
controller.state().position[0],
|
|
406
595
|
controller.state().position[1],
|
|
407
596
|
controller.state().position[2],
|
|
408
597
|
];
|
|
409
598
|
if (motionBatch !== null && motionBatch.y !== null) {
|
|
599
|
+
motorProposalAccepted = motionBatch.y === nextPosition[1];
|
|
410
600
|
nextPosition[1] = motionBatch.y;
|
|
411
601
|
controller.restore({ ...controller.state(), position: nextPosition });
|
|
412
602
|
}
|
|
@@ -420,6 +610,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
420
610
|
};
|
|
421
611
|
const replacement = tuning.movement.beforeCommit(frame);
|
|
422
612
|
if (replacement !== undefined) {
|
|
613
|
+
motorProposalAccepted = motorProposalAccepted && replacement[0] === nextPosition[0] && replacement[1] === nextPosition[1] && replacement[2] === nextPosition[2];
|
|
423
614
|
nextPosition = [replacement[0], replacement[1], replacement[2]];
|
|
424
615
|
controller.restore({ ...controller.state(), position: nextPosition });
|
|
425
616
|
}
|
|
@@ -430,6 +621,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
430
621
|
rotationY: resolveBodyFacing(state, intent.moving, horizontal.horizontalVelocityX, horizontal.horizontalVelocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
|
|
431
622
|
dt,
|
|
432
623
|
});
|
|
624
|
+
if (motorProposalAccepted && positionAccepted(ctx, playerId, nextPosition[0], nextPosition[1], nextPosition[2]))
|
|
625
|
+
updateTelemetry(state, playerId, controller.state().grounded, controller.state().verticalVelocity, controller.state().crouching);
|
|
626
|
+
else
|
|
627
|
+
invalidateTelemetry(state);
|
|
433
628
|
return;
|
|
434
629
|
}
|
|
435
630
|
if (tuning.collision?.voxel === true) {
|
|
@@ -452,7 +647,8 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
452
647
|
};
|
|
453
648
|
body.velocityY = applyMotionImpulses(body.velocityY, motionBatch);
|
|
454
649
|
[body.velocityX, body.velocityZ] = applyHorizontalImpulses(body.velocityX, body.velocityZ, motionBatch);
|
|
455
|
-
advanceVoxelPlayer(body, intent, forwardX, forwardZ, walkSpeed, dt, isSolid, dims, tuning.physics, tuning.hasTerrain ? (x, z) => tuning.ground.sampleHeight(x, z) : undefined);
|
|
650
|
+
advanceVoxelPlayer(body, intent, forwardX, forwardZ, walkSpeed, dt, isSolid, dims, tuning.physics, tuning.hasTerrain ? (x, z) => tuning.ground.sampleHeight(x, z) : undefined, { authoritativeStep: tuning.authoritativeStep });
|
|
651
|
+
const motorProposalAccepted = motionBatch === null || motionBatch.y === null || motionBatch.y === body.y;
|
|
456
652
|
if (motionBatch !== null && motionBatch.y !== null)
|
|
457
653
|
body.y = motionBatch.y;
|
|
458
654
|
ctx.scene.entity.setPose(playerId, {
|
|
@@ -460,6 +656,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
460
656
|
rotationY: resolveBodyFacing(state, intent.moving, body.velocityX, body.velocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
|
|
461
657
|
dt,
|
|
462
658
|
});
|
|
659
|
+
if (motorProposalAccepted && positionAccepted(ctx, playerId, body.x, body.y, body.z))
|
|
660
|
+
updateTelemetry(state, playerId, body.grounded, body.velocityY, intent.crouching);
|
|
661
|
+
else
|
|
662
|
+
invalidateTelemetry(state);
|
|
463
663
|
return;
|
|
464
664
|
}
|
|
465
665
|
let motion = state.motion;
|
|
@@ -485,9 +685,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
485
685
|
state.jumpBuffer = jumpBuffer;
|
|
486
686
|
}
|
|
487
687
|
const motionOptions = submerged
|
|
488
|
-
? { speedScale: swimSpeedMultiplier, floating: true, buffer: jumpBuffer }
|
|
489
|
-
: { buffer: jumpBuffer };
|
|
688
|
+
? { speedScale: swimSpeedMultiplier, floating: true, buffer: jumpBuffer, authoritativeStep: tuning.authoritativeStep }
|
|
689
|
+
: { buffer: jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep };
|
|
490
690
|
const prevJumpOffset = motion.jumpOffset;
|
|
691
|
+
const groundedBeforeStep = motion.grounded;
|
|
491
692
|
const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, motionOptions);
|
|
492
693
|
// Airborne means "not resting on the surface below": any jump/impulse height before or after this
|
|
493
694
|
// frame's integration. Grounded-only forgiveness (step-up) and airborne-only landing key off it.
|
|
@@ -500,11 +701,14 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
500
701
|
stepZ = constrained.stepZ;
|
|
501
702
|
}
|
|
502
703
|
const stepHeight = tuning.movement?.stepHeight ?? DEFAULT_PLAYER_STEP_HEIGHT;
|
|
704
|
+
const verticalStep = airborne ? motion.jumpOffset - prevJumpOffset : 0;
|
|
705
|
+
const queryMinStepY = Math.min(0, verticalStep);
|
|
706
|
+
const queryMaxStepY = Math.max(stepHeight, verticalStep);
|
|
503
707
|
let obstacles = null;
|
|
504
708
|
if (tuning.movement?.collideObjects !== false) {
|
|
505
|
-
obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ);
|
|
709
|
+
obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ, queryMinStepY, queryMaxStepY, collisionHeight);
|
|
506
710
|
// While grounded, a box the player could simply step onto is a ledge, not a wall.
|
|
507
|
-
const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight);
|
|
711
|
+
const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight, collisionHeight);
|
|
508
712
|
stepX = resolved.stepX;
|
|
509
713
|
stepZ = resolved.stepZ;
|
|
510
714
|
}
|
|
@@ -527,46 +731,155 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
527
731
|
nextZ += slope.downhill[1] * slide;
|
|
528
732
|
}
|
|
529
733
|
}
|
|
530
|
-
const
|
|
734
|
+
const maxClimbGrade = tuning.movement?.maxClimbGrade;
|
|
735
|
+
// The full/X/Z candidates share four unique grade samples (origin and three endpoints), even
|
|
736
|
+
// when headroom rejects the grade resolver's first choice. Preserve the game sampler's budget.
|
|
737
|
+
const gradeSamples = maxClimbGrade === undefined ? null : [];
|
|
738
|
+
const gradeSampler = (x, z) => {
|
|
739
|
+
for (const sample of gradeSamples)
|
|
740
|
+
if (sample.x === x && sample.z === z)
|
|
741
|
+
return sample.height;
|
|
742
|
+
const authoredSampler = tuning.movement?.climbGradeHeight;
|
|
743
|
+
const height = authoredSampler === undefined ? tuning.ground.sampleHeight(x, z) : authoredSampler(x, z);
|
|
744
|
+
gradeSamples.push({ x, z, height });
|
|
745
|
+
return height;
|
|
746
|
+
};
|
|
747
|
+
if (maxClimbGrade !== undefined) {
|
|
748
|
+
const accepted = resolveTerrainGradeStep(gradeSampler, player.position, nextX - player.position[0], nextZ - player.position[2], maxClimbGrade);
|
|
749
|
+
nextX = player.position[0] + accepted.stepX;
|
|
750
|
+
nextZ = player.position[2] + accepted.stepZ;
|
|
751
|
+
}
|
|
531
752
|
// Blocking colliders are walkable surfaces: the effective ground under the player is the higher of
|
|
532
753
|
// the terrain and the tallest object top the player can stand on here. While grounded a top within
|
|
533
754
|
// stepHeight above the feet is stepped onto (matching the obstruction's ledge forgiveness); while
|
|
534
755
|
// airborne only tops at/below the feet catch, so a jump lands ON a crate instead of sinking inside
|
|
535
756
|
// it and being rubber-banded out by depenetration.
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
else {
|
|
553
|
-
nextY = Math.max(nextFeet, effectiveGround);
|
|
554
|
-
motion.jumpOffset = nextY - effectiveGround;
|
|
555
|
-
motion.grounded = false;
|
|
757
|
+
const absoluteHeightOverride = motionBatch?.y !== undefined && motionBatch.y !== null;
|
|
758
|
+
const terrainSamples = [];
|
|
759
|
+
const terrainHeightAt = (x, z) => {
|
|
760
|
+
for (const sample of terrainSamples)
|
|
761
|
+
if (sample.x === x && sample.z === z)
|
|
762
|
+
return sample.height;
|
|
763
|
+
const height = tuning.ground.sampleHeight(x, z);
|
|
764
|
+
terrainSamples.push({ x, z, height });
|
|
765
|
+
return height;
|
|
766
|
+
};
|
|
767
|
+
if (obstacles !== null && !absoluteHeightOverride) {
|
|
768
|
+
// A terrain-following style may accept a rise taller than stepHeight. Include those proposed
|
|
769
|
+
// full/X/Z body spans without querying the whole world or falling all the way to distant ground.
|
|
770
|
+
const rise = Math.max(terrainHeightAt(nextX, nextZ), terrainHeightAt(nextX, player.position[2]), terrainHeightAt(player.position[0], nextZ)) - player.position[1];
|
|
771
|
+
if (rise > queryMaxStepY) {
|
|
772
|
+
obstacles = gatherMovementObstacles(ctx, player.position, nextX - player.position[0], nextZ - player.position[2], queryMinStepY, rise, collisionHeight);
|
|
556
773
|
}
|
|
557
774
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
775
|
+
const validStartingBounds = obstacles === null || !resolveObstacleVerticalStep(player.position, 0, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, collisionHeight).initialOverlap;
|
|
776
|
+
const resolveCandidate = (x, z) => {
|
|
777
|
+
const terrainHeight = terrainHeightAt(x, z);
|
|
778
|
+
const supportY = obstacles === null ? null : obstacleSupportHeight(x, z, player.position[1], airborne ? 0 : stepHeight, obstacles);
|
|
779
|
+
const ground = supportY !== null && supportY > terrainHeight ? supportY : terrainHeight;
|
|
780
|
+
let y = ground;
|
|
781
|
+
let grounded = motion.grounded;
|
|
782
|
+
let velocity = motion.verticalVelocity;
|
|
783
|
+
let offset = motion.jumpOffset;
|
|
784
|
+
let rising = motion.jumpRising;
|
|
785
|
+
let blockedRise = false;
|
|
786
|
+
if (airborne) {
|
|
787
|
+
// Keep the arc in absolute space while the terrain/support changes below it.
|
|
788
|
+
const nextFeet = player.position[1] + (motion.jumpOffset - prevJumpOffset);
|
|
789
|
+
if (nextFeet <= ground && velocity <= 0) {
|
|
790
|
+
offset = 0;
|
|
791
|
+
velocity = 0;
|
|
792
|
+
grounded = true;
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
y = Math.max(nextFeet, ground);
|
|
796
|
+
offset = y - ground;
|
|
797
|
+
grounded = false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
else if (!submerged && player.position[1] - ground > stepHeight) {
|
|
801
|
+
y = player.position[1];
|
|
802
|
+
offset = y - ground;
|
|
803
|
+
velocity = 0;
|
|
804
|
+
grounded = false;
|
|
805
|
+
}
|
|
806
|
+
if (obstacles !== null && !absoluteHeightOverride) {
|
|
807
|
+
const vertical = resolveObstacleVerticalStep([x, player.position[1], z], y - player.position[1], obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, collisionHeight);
|
|
808
|
+
if (vertical.hitCeiling) {
|
|
809
|
+
const clippedY = player.position[1] + vertical.stepY;
|
|
810
|
+
// A surface above the ceiling cannot be reached by snapping entirely through a thin roof.
|
|
811
|
+
blockedRise = ground > vertical.ceilingFeetY + vertical.ceilingTolerance;
|
|
812
|
+
y = Math.max(clippedY, ground);
|
|
813
|
+
offset = Math.max(0, y - ground);
|
|
814
|
+
velocity = 0;
|
|
815
|
+
rising = false;
|
|
816
|
+
grounded = y === ground;
|
|
817
|
+
}
|
|
818
|
+
else if (vertical.landed) {
|
|
819
|
+
y = player.position[1] + vertical.stepY;
|
|
820
|
+
offset = 0;
|
|
821
|
+
velocity = 0;
|
|
822
|
+
rising = false;
|
|
823
|
+
grounded = true;
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
const clear = obstacles === null || absoluteHeightOverride || !validStartingBounds ||
|
|
827
|
+
(!blockedRise && !resolveObstacleVerticalStep([x, y, z], 0, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, collisionHeight).initialOverlap);
|
|
828
|
+
return { x, z, y, ground, grounded, velocity, offset, rising, clear };
|
|
829
|
+
};
|
|
830
|
+
let candidate = resolveCandidate(nextX, nextZ);
|
|
831
|
+
if (!candidate.clear) {
|
|
832
|
+
const gradeAllows = (x, z) => {
|
|
833
|
+
if (maxClimbGrade === undefined)
|
|
834
|
+
return true;
|
|
835
|
+
const accepted = resolveTerrainGradeStep(gradeSampler, player.position, x - player.position[0], z - player.position[2], maxClimbGrade);
|
|
836
|
+
return accepted.stepX === x - player.position[0] && accepted.stepZ === z - player.position[2];
|
|
837
|
+
};
|
|
838
|
+
const tryAxis = (x, z) => {
|
|
839
|
+
if (obstacles !== null) {
|
|
840
|
+
// The original Z sweep used post-X coordinates. An alternate axis must be swept again from
|
|
841
|
+
// the original pose; reject a changed endpoint rather than accepting an unsampled grade.
|
|
842
|
+
const swept = resolveObstacleStep(player.position, x - player.position[0], z - player.position[2], obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight, collisionHeight);
|
|
843
|
+
if (swept.stepX !== x - player.position[0] || swept.stepZ !== z - player.position[2])
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
if (!gradeAllows(x, z))
|
|
847
|
+
return false;
|
|
848
|
+
const alternative = resolveCandidate(x, z);
|
|
849
|
+
if (!alternative.clear)
|
|
850
|
+
return false;
|
|
851
|
+
candidate = alternative;
|
|
852
|
+
return true;
|
|
853
|
+
};
|
|
854
|
+
const xAccepted = nextX !== player.position[0] && tryAxis(nextX, player.position[2]);
|
|
855
|
+
const zAccepted = !xAccepted && nextZ !== player.position[2] && tryAxis(player.position[0], nextZ);
|
|
856
|
+
if (!xAccepted && !zAccepted) {
|
|
857
|
+
candidate = resolveCandidate(player.position[0], player.position[2]);
|
|
858
|
+
if (!candidate.clear) {
|
|
859
|
+
// An inflated support edge cannot raise the body into a lintel. Retain the valid starting
|
|
860
|
+
// pose rather than forcing feet into support or repairing an illegal authored placement.
|
|
861
|
+
candidate.y = player.position[1];
|
|
862
|
+
candidate.grounded = groundedBeforeStep;
|
|
863
|
+
candidate.offset = prevJumpOffset;
|
|
864
|
+
candidate.velocity = groundedBeforeStep ? 0 : motion.verticalVelocity;
|
|
865
|
+
candidate.rising = groundedBeforeStep ? false : motion.jumpRising;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
568
868
|
}
|
|
869
|
+
nextX = candidate.x;
|
|
870
|
+
nextZ = candidate.z;
|
|
871
|
+
let nextY = candidate.y;
|
|
872
|
+
const effectiveGround = candidate.ground;
|
|
873
|
+
motion.grounded = candidate.grounded;
|
|
874
|
+
motion.verticalVelocity = candidate.velocity;
|
|
875
|
+
motion.jumpOffset = candidate.offset;
|
|
876
|
+
motion.jumpRising = candidate.rising;
|
|
877
|
+
let motorProposalAccepted = true;
|
|
878
|
+
if (!groundedBeforeStep && motion.grounded)
|
|
879
|
+
motion.landedAtMs = motion.clockMs;
|
|
880
|
+
motion.wasAirborne = !motion.grounded;
|
|
569
881
|
if (motionBatch !== null && motionBatch.y !== null) {
|
|
882
|
+
motorProposalAccepted = motionBatch.y === nextY;
|
|
570
883
|
nextY = motionBatch.y;
|
|
571
884
|
motion.jumpOffset = motionBatch.y - effectiveGround;
|
|
572
885
|
}
|
|
@@ -583,6 +896,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
583
896
|
};
|
|
584
897
|
const replacement = tuning.movement.beforeCommit(frame);
|
|
585
898
|
if (replacement !== undefined) {
|
|
899
|
+
motorProposalAccepted = motorProposalAccepted && replacement[0] === nextX && replacement[1] === nextY && replacement[2] === nextZ;
|
|
586
900
|
nextX = replacement[0];
|
|
587
901
|
nextY = replacement[1];
|
|
588
902
|
nextZ = replacement[2];
|
|
@@ -593,11 +907,20 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading, pitc
|
|
|
593
907
|
rotationY: resolveBodyFacing(state, intent.moving, motion.horizontalVelocityX, motion.horizontalVelocityZ, player.rotationY, tuning.movement?.turnSpeed, dt),
|
|
594
908
|
dt,
|
|
595
909
|
});
|
|
910
|
+
if (motorProposalAccepted && positionAccepted(ctx, playerId, nextX, nextY, nextZ))
|
|
911
|
+
updateTelemetry(state, playerId, motion.grounded, motion.verticalVelocity, intent.crouching, collisionHeight, requestedCollisionHeight, collisionHeightBlocked);
|
|
912
|
+
else
|
|
913
|
+
invalidateTelemetry(state);
|
|
596
914
|
}
|
|
597
915
|
/**
|
|
598
916
|
* The player's blocking obstacles for this step, from the shared solid seam. A walking NPC calls
|
|
599
917
|
* `resolveWalkerStep` against the same query, so "solid" means one thing engine-wide.
|
|
600
918
|
*/
|
|
601
|
-
function gatherMovementObstacles(ctx, position, stepX, stepZ) {
|
|
602
|
-
|
|
919
|
+
function gatherMovementObstacles(ctx, position, stepX, stepZ, minStepY = 0, maxStepY = 0, playerHeight = DEFAULT_OBSTACLE_PLAYER_HEIGHT) {
|
|
920
|
+
if (!Number.isFinite(minStepY) || !Number.isFinite(maxStepY))
|
|
921
|
+
throw new RangeError("Movement obstacle query requires a finite vertical step.");
|
|
922
|
+
const minY = Math.min(0, minStepY);
|
|
923
|
+
const maxY = Math.max(0, maxStepY);
|
|
924
|
+
const queryPosition = minY < 0 ? [position[0], position[1] + minY, position[2]] : position;
|
|
925
|
+
return solidObstaclesNear(ctx, queryPosition, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS, playerHeight + maxY - minY);
|
|
603
926
|
}
|