@jgengine/core 0.14.0 → 0.15.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 +500 -1
- package/dist/ai/difficulty.d.ts +154 -0
- package/dist/ai/difficulty.js +194 -0
- package/dist/ai/driver.d.ts +107 -0
- package/dist/ai/driver.js +175 -0
- package/dist/ai/flock.js +13 -23
- package/dist/ai/laneSelect.js +2 -1
- package/dist/ai/mobBrain.js +2 -1
- package/dist/ai/waveRunner.d.ts +81 -0
- package/dist/ai/waveRunner.js +104 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/authoring.d.ts +6 -4
- package/dist/authoring.js +6 -4
- package/dist/combat/castRunner.d.ts +13 -1
- package/dist/combat/castRunner.js +5 -1
- package/dist/combat/comboMeter.d.ts +140 -0
- package/dist/combat/comboMeter.js +215 -0
- package/dist/combat/comboPoints.d.ts +11 -0
- package/dist/combat/comboPoints.js +11 -2
- package/dist/combat/damageResolution.d.ts +1 -1
- package/dist/combat/defensiveWindow.d.ts +5 -0
- package/dist/combat/effects.d.ts +24 -0
- package/dist/combat/effects.js +6 -1
- package/dist/combat/projectiles.d.ts +6 -0
- package/dist/combat/projectiles.js +2 -2
- package/dist/combat/regenShield.d.ts +40 -2
- package/dist/combat/regenShield.js +66 -18
- package/dist/combat/resourcePool.d.ts +19 -0
- package/dist/combat/resourcePool.js +20 -2
- package/dist/combat/shotOrigin.d.ts +5 -0
- package/dist/combat/statusApplication.d.ts +1 -1
- package/dist/combat/statusApplication.js +2 -1
- package/dist/combat/statusEffectView.d.ts +56 -0
- package/dist/combat/statusEffectView.js +52 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +2 -0
- package/dist/devtools/fallbackSeams.d.ts +3 -2
- package/dist/devtools/fallbackSeams.js +1 -1
- package/dist/devtools/textureErrors.d.ts +40 -0
- package/dist/devtools/textureErrors.js +68 -0
- package/dist/economy/shopStock.d.ts +110 -0
- package/dist/economy/shopStock.js +157 -0
- package/dist/economy/wallet.d.ts +6 -0
- package/dist/economy/wallet.js +1 -0
- package/dist/game/achievements.d.ts +80 -0
- package/dist/game/achievements.js +124 -0
- package/dist/game/cameraConfig.d.ts +7 -0
- package/dist/game/codex.d.ts +60 -0
- package/dist/game/codex.js +89 -0
- package/dist/game/credits.d.ts +36 -0
- package/dist/game/credits.js +0 -0
- package/dist/game/defineGame.d.ts +28 -3
- package/dist/game/defineGame.js +5 -0
- package/dist/game/dialogueGraph.d.ts +116 -0
- package/dist/game/dialogueGraph.js +103 -0
- package/dist/game/eventTicker.d.ts +95 -0
- package/dist/game/eventTicker.js +91 -0
- package/dist/game/events.d.ts +24 -5
- package/dist/game/events.js +18 -0
- package/dist/game/gamePhase.d.ts +1 -1
- package/dist/game/gamePhase.js +1 -1
- package/dist/game/leaderboardRank.d.ts +71 -0
- package/dist/game/leaderboardRank.js +76 -0
- package/dist/game/lootTable.d.ts +10 -1
- package/dist/game/lootTable.js +4 -2
- package/dist/game/notifications.d.ts +57 -0
- package/dist/game/notifications.js +107 -0
- package/dist/game/playableGame.d.ts +39 -2
- package/dist/game/quest.d.ts +26 -0
- package/dist/game/quest.js +31 -0
- package/dist/game/runDraft.js +2 -1
- package/dist/game/saveSlots.d.ts +102 -0
- package/dist/game/saveSlots.js +136 -0
- package/dist/game/talentTreeView.d.ts +106 -0
- package/dist/game/talentTreeView.js +114 -0
- package/dist/game/worldItem.js +2 -1
- package/dist/gameplay.d.ts +15 -4
- package/dist/gameplay.js +13 -2
- package/dist/i18n/i18n.d.ts +51 -0
- package/dist/i18n/i18n.js +82 -0
- package/dist/input/axisInput.d.ts +1 -1
- package/dist/input/axisInput.js +18 -2
- package/dist/input/rebindSession.d.ts +141 -0
- package/dist/input/rebindSession.js +235 -0
- package/dist/input/touchControlsMode.d.ts +16 -0
- package/dist/input/touchControlsMode.js +23 -0
- package/dist/input/touchScheme.d.ts +30 -1
- package/dist/input/touchScheme.js +31 -1
- package/dist/interaction/promptRegistry.d.ts +78 -0
- package/dist/interaction/promptRegistry.js +130 -0
- package/dist/inventory/storageTier.js +3 -2
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +145 -1
- package/dist/movement/movementModel.d.ts +26 -3
- package/dist/movement/movementModel.js +121 -15
- package/dist/movement/playerMovement.js +161 -25
- package/dist/physics/drivableVehicle.js +3 -1
- package/dist/physics/kinematicVehicle.d.ts +56 -1
- package/dist/physics/kinematicVehicle.js +99 -10
- package/dist/physics/vehicleObstacles.js +19 -0
- package/dist/random/resolveRng.d.ts +14 -0
- package/dist/random/resolveRng.js +31 -0
- package/dist/render/postProcessing.d.ts +12 -1
- package/dist/runtime/cameraDirector.d.ts +2 -2
- package/dist/runtime/context/combat.js +8 -0
- package/dist/runtime/gameContext.js +6 -1
- package/dist/runtime/gameContextTypes.d.ts +18 -0
- package/dist/runtime/inputSnapshot.d.ts +10 -1
- package/dist/runtime/inputSnapshot.js +13 -1
- package/dist/runtime/runtimeSave.d.ts +3 -3
- package/dist/runtime/runtimeSave.js +11 -2
- package/dist/scene/assetDiagnostics.js +2 -2
- package/dist/scene/autoTarget.js +2 -1
- package/dist/scene/captureCheck.js +3 -2
- package/dist/scene/entityStore.d.ts +4 -1
- package/dist/scene/entityStore.js +3 -0
- package/dist/scene/objectStore.d.ts +10 -0
- package/dist/scene/objectStore.js +29 -6
- package/dist/scene/sceneKinds.d.ts +18 -0
- package/dist/scene/sceneKinds.js +19 -0
- package/dist/scene/sequenceDirector.d.ts +110 -0
- package/dist/scene/sequenceDirector.js +146 -0
- package/dist/session/roles.js +3 -2
- package/dist/settings/settingsModel.d.ts +1 -0
- package/dist/settings/settingsModel.js +1 -0
- package/dist/stats/rollCheck.js +4 -2
- package/dist/time/dayNightCycle.d.ts +114 -0
- package/dist/time/dayNightCycle.js +208 -0
- package/dist/time/timerSet.d.ts +132 -0
- package/dist/time/timerSet.js +243 -0
- package/dist/ui/accessibility.d.ts +51 -0
- package/dist/ui/accessibility.js +83 -0
- package/dist/ui/coachMarks.d.ts +99 -0
- package/dist/ui/coachMarks.js +126 -0
- package/dist/ui/floatingText.d.ts +114 -0
- package/dist/ui/floatingText.js +203 -0
- package/dist/ui/modalStack.d.ts +143 -0
- package/dist/ui/modalStack.js +160 -0
- package/dist/ui/objectiveBanner.d.ts +123 -0
- package/dist/ui/objectiveBanner.js +162 -0
- package/dist/ui/photoMode.d.ts +32 -0
- package/dist/ui/photoMode.js +51 -0
- package/dist/ui/radialMenu.d.ts +47 -0
- package/dist/ui/radialMenu.js +74 -0
- package/dist/ui/screenMarkers.d.ts +128 -0
- package/dist/ui/screenMarkers.js +118 -0
- package/dist/ui.d.ts +8 -0
- package/dist/ui.js +8 -0
- package/dist/vfx/cameraShake.d.ts +97 -0
- package/dist/vfx/cameraShake.js +136 -0
- package/dist/vfx/damageDirection.d.ts +102 -0
- package/dist/vfx/damageDirection.js +188 -0
- package/dist/vfx/particles.d.ts +103 -0
- package/dist/vfx/particles.js +256 -0
- package/dist/vfx/screenEffects.d.ts +149 -0
- package/dist/vfx/screenEffects.js +191 -0
- package/dist/world/authoredObjects.d.ts +18 -0
- package/dist/world/authoredObjects.js +21 -0
- package/dist/world/authoredSpawn.js +19 -0
- package/dist/world/buildingLots.d.ts +54 -11
- package/dist/world/buildingLots.js +116 -30
- package/dist/world/cityBlocks.d.ts +45 -0
- package/dist/world/cityBlocks.js +159 -0
- package/dist/world/cityContent.d.ts +46 -2
- package/dist/world/cityContent.js +160 -2
- package/dist/world/cityGenerator.d.ts +261 -23
- package/dist/world/cityGenerator.js +643 -33
- package/dist/world/cityGeometry.d.ts +20 -0
- package/dist/world/cityGeometry.js +91 -0
- package/dist/world/cityKind.js +1 -44
- package/dist/world/fastTravel.d.ts +64 -0
- package/dist/world/fastTravel.js +108 -0
- package/dist/world/features.d.ts +16 -0
- package/dist/world/features.js +2 -0
- package/dist/world/grounded.d.ts +11 -0
- package/dist/world/grounded.js +12 -0
- package/dist/world/mapAnnotations.d.ts +75 -0
- package/dist/world/mapAnnotations.js +139 -0
- package/dist/world/markers.js +1 -0
- package/dist/world/resourceNode.d.ts +1 -1
- package/dist/world/resourceNode.js +2 -1
- package/dist/world/roads.d.ts +294 -5
- package/dist/world/roads.js +1262 -21
- package/dist/world/sharedAnnotations.d.ts +84 -0
- package/dist/world/sharedAnnotations.js +76 -0
- package/dist/world/spawnOverride.d.ts +37 -0
- package/dist/world/spawnOverride.js +50 -0
- package/dist/world/streetGenerator.d.ts +60 -11
- package/dist/world/streetGenerator.js +1786 -129
- package/dist/world/terrain.d.ts +1 -0
- package/dist/world/terrain.js +2 -0
- package/dist/world/waypoints.d.ts +63 -0
- package/dist/world/waypoints.js +137 -0
- package/dist/world.d.ts +17 -2
- package/dist/world.js +17 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +149 -0
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +3 -1
- package/skills/jgengine/api.md +17 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine/recipes/minimal-game.md +7 -4
- package/skills/jgengine-combat/api.md +40 -4
- package/skills/jgengine-combat/capabilities.md +12 -0
- package/skills/jgengine-combat/reference.md +2 -0
- package/skills/jgengine-gameplay/api.md +212 -11
- package/skills/jgengine-gameplay/capabilities.md +66 -1
- package/skills/jgengine-world/api.md +397 -11
- package/skills/jgengine-world/capabilities.md +123 -2
- package/skills/jgengine-world/reference.md +15 -1
|
@@ -22,17 +22,24 @@ const IDLE_INTENT = {
|
|
|
22
22
|
jumping: false,
|
|
23
23
|
moving: false,
|
|
24
24
|
};
|
|
25
|
+
/** Below this deflection an analog stick reads as centered — filters sensor noise without eating small deliberate tilts (the shell applies its own radial deadzone before publishing). */
|
|
26
|
+
const ANALOG_MOVE_EPSILON = 0.02;
|
|
25
27
|
/**
|
|
26
28
|
* Translate the set of held keys into an intent. When `canMove` is false (a
|
|
27
29
|
* menu is open, the world is paused) the avatar is fully idle so it never
|
|
28
|
-
* drifts behind an overlay.
|
|
30
|
+
* drifts behind an overlay. An `analog` vector (virtual joystick, gamepad
|
|
31
|
+
* stick) replaces the digital WASD axes with its fractional deflection (#1370).
|
|
29
32
|
* @internal
|
|
30
33
|
*/
|
|
31
|
-
export function resolveMovementIntent(keys, canMove) {
|
|
34
|
+
export function resolveMovementIntent(keys, canMove, analog) {
|
|
32
35
|
if (!canMove)
|
|
33
36
|
return IDLE_INTENT;
|
|
34
|
-
|
|
35
|
-
|
|
37
|
+
let forward = (keys.w ? 1 : 0) - (keys.s ? 1 : 0);
|
|
38
|
+
let right = (keys.d ? 1 : 0) - (keys.a ? 1 : 0);
|
|
39
|
+
if (analog !== undefined && analog !== null) {
|
|
40
|
+
forward = Math.abs(analog.forward) < ANALOG_MOVE_EPSILON ? 0 : Math.max(-1, Math.min(1, analog.forward));
|
|
41
|
+
right = Math.abs(analog.right) < ANALOG_MOVE_EPSILON ? 0 : Math.max(-1, Math.min(1, analog.right));
|
|
42
|
+
}
|
|
36
43
|
const crouching = keys.control || keys.c;
|
|
37
44
|
const running = keys.shift && !crouching;
|
|
38
45
|
return {
|
|
@@ -142,7 +149,10 @@ export function advancePlayerMotion(motion, intent, forwardX, forwardZ, baseSpee
|
|
|
142
149
|
targetVelocityZ = fz * intent.forward + rightZ * intent.right;
|
|
143
150
|
const lengthSq = targetVelocityX * targetVelocityX + targetVelocityZ * targetVelocityZ;
|
|
144
151
|
if (lengthSq > 1e-6) {
|
|
145
|
-
|
|
152
|
+
// Analog sticks walk at their deflection: speed scales with the intent magnitude, capped at
|
|
153
|
+
// 1 so digital diagonals (length √2) still normalize to exactly full speed.
|
|
154
|
+
const length = Math.sqrt(lengthSq);
|
|
155
|
+
const scale = (targetSpeed * Math.min(1, length)) / length;
|
|
146
156
|
targetVelocityX *= scale;
|
|
147
157
|
targetVelocityZ *= scale;
|
|
148
158
|
}
|
|
@@ -191,15 +201,31 @@ const DEFAULT_OBSTACLE_HALF_EXTENTS = [0.5, 0.5, 0.5];
|
|
|
191
201
|
export const DEFAULT_OBSTACLE_PLAYER_RADIUS = 0.3;
|
|
192
202
|
/** Feet-to-head span used for the obstacle's vertical overlap test; matches DEFAULT_VOXEL_DIMS.height. */
|
|
193
203
|
const OBSTACLE_PLAYER_HEIGHT = 1.8;
|
|
204
|
+
/** Extra nudge past a solid face when depenetrating so the escaped capsule lands just outside, not on, the box. */
|
|
205
|
+
const PENETRATION_EPSILON = 1e-3;
|
|
206
|
+
/**
|
|
207
|
+
* Swept clamp of a single-axis step against a box span. Entering from outside stops on the near
|
|
208
|
+
* face — including when the raw target would land *beyond* the far face (the old form returned the
|
|
209
|
+
* full step there, tunneling a fast mover straight through the box in one frame). Starting inside
|
|
210
|
+
* the span still permits full exits (the depenetration path owns true stuck-inside cases), and
|
|
211
|
+
* motion beginning at or past a face moving away is untouched.
|
|
212
|
+
*/
|
|
194
213
|
function clampAxisStep(originalCoord, step, min, max) {
|
|
195
214
|
if (step === 0)
|
|
196
215
|
return 0;
|
|
197
216
|
const target = originalCoord + step;
|
|
198
|
-
if (
|
|
217
|
+
if (step > 0) {
|
|
218
|
+
if (originalCoord >= max)
|
|
219
|
+
return step;
|
|
220
|
+
if (originalCoord <= min)
|
|
221
|
+
return target > min ? min - originalCoord : step;
|
|
222
|
+
return target >= max ? step : 0;
|
|
223
|
+
}
|
|
224
|
+
if (originalCoord <= min)
|
|
199
225
|
return step;
|
|
200
|
-
if (
|
|
201
|
-
return
|
|
202
|
-
return
|
|
226
|
+
if (originalCoord >= max)
|
|
227
|
+
return target < max ? max - originalCoord : step;
|
|
228
|
+
return target <= min ? step : 0;
|
|
203
229
|
}
|
|
204
230
|
/**
|
|
205
231
|
* Resolve a horizontal step against nearby placed objects with classic
|
|
@@ -209,17 +235,23 @@ function clampAxisStep(originalCoord, step, min, max) {
|
|
|
209
235
|
* by `playerRadius`; an obstacle whose vertical span misses the player's
|
|
210
236
|
* feet-to-head span is skipped entirely. Callers should pre-filter to nearby
|
|
211
237
|
* objects — this also early-exits per obstacle on horizontal distance.
|
|
238
|
+
*
|
|
239
|
+
* `stepUpHeight` treats boxes whose top sits within that height above the feet
|
|
240
|
+
* as walkable ledges instead of walls: they stop blocking here, and the caller
|
|
241
|
+
* stands the player on them via {@link obstacleSupportHeight} — a curb or stair
|
|
242
|
+
* tread is stepped up, not bumped into.
|
|
212
243
|
* @internal
|
|
213
244
|
*/
|
|
214
|
-
export function resolveObstacleStep(current, stepX, stepZ, obstacles, playerRadius = DEFAULT_OBSTACLE_PLAYER_RADIUS) {
|
|
245
|
+
export function resolveObstacleStep(current, stepX, stepZ, obstacles, playerRadius = DEFAULT_OBSTACLE_PLAYER_RADIUS, stepUpHeight = 0) {
|
|
215
246
|
const feetY = current[1];
|
|
216
247
|
const headY = feetY + OBSTACLE_PLAYER_HEIGHT;
|
|
217
248
|
const reachX = Math.abs(stepX) + playerRadius;
|
|
218
249
|
const reachZ = Math.abs(stepZ) + playerRadius;
|
|
219
250
|
const nearby = [];
|
|
220
251
|
const considerBox = (minX, maxX, minY, maxY, minZ, maxZ) => {
|
|
221
|
-
// Vertical-span cull: a sub-box entirely above the head (walk under the lintel)
|
|
222
|
-
|
|
252
|
+
// Vertical-span cull: a sub-box entirely above the head (walk under the lintel), below the feet,
|
|
253
|
+
// or low enough to step up onto (`stepUpHeight`) is skipped — walkable ledges don't wall.
|
|
254
|
+
if (maxY <= feetY + stepUpHeight || minY >= headY)
|
|
223
255
|
return;
|
|
224
256
|
const centerX = (minX + maxX) / 2;
|
|
225
257
|
const centerZ = (minZ + maxZ) / 2;
|
|
@@ -251,20 +283,94 @@ export function resolveObstacleStep(current, stepX, stepZ, obstacles, playerRadi
|
|
|
251
283
|
const cz = pz + (offset !== undefined ? offset[2] : 0);
|
|
252
284
|
considerBox(cx - halfExtents[0], cx + halfExtents[0], cy - halfExtents[1], cy + halfExtents[1], cz - halfExtents[2], cz + halfExtents[2]);
|
|
253
285
|
}
|
|
254
|
-
|
|
286
|
+
// Depenetration escape: axis-clamped sliding alone returns zero on BOTH axes once the capsule is
|
|
287
|
+
// strictly inside a solid box (a prior frame wedged it into a corner, an object spawned overlapping
|
|
288
|
+
// it, or a ground snap dropped it in), so the player locks up forever — "I collide and get stuck".
|
|
289
|
+
// Boxes the capsule is inside are pushed out here along their shallowest face and excluded from the
|
|
290
|
+
// slide below; resting exactly on a face (the clamp's natural stopping point) is not "inside", so
|
|
291
|
+
// normal wall contact never triggers an escape and every existing slide case is unchanged.
|
|
292
|
+
let escapeX = 0;
|
|
293
|
+
let escapeZ = 0;
|
|
294
|
+
const slideBoxes = [];
|
|
255
295
|
for (const box of nearby) {
|
|
296
|
+
const insideX = current[0] > box.minX && current[0] < box.maxX;
|
|
297
|
+
const insideZ = current[2] > box.minZ && current[2] < box.maxZ;
|
|
298
|
+
if (!insideX || !insideZ) {
|
|
299
|
+
slideBoxes.push(box);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const toMinX = current[0] - box.minX;
|
|
303
|
+
const toMaxX = box.maxX - current[0];
|
|
304
|
+
const toMinZ = current[2] - box.minZ;
|
|
305
|
+
const toMaxZ = box.maxZ - current[2];
|
|
306
|
+
const minPen = Math.min(toMinX, toMaxX, toMinZ, toMaxZ);
|
|
307
|
+
if (minPen === toMinX)
|
|
308
|
+
escapeX = Math.min(escapeX, -(toMinX + PENETRATION_EPSILON));
|
|
309
|
+
else if (minPen === toMaxX)
|
|
310
|
+
escapeX = Math.max(escapeX, toMaxX + PENETRATION_EPSILON);
|
|
311
|
+
else if (minPen === toMinZ)
|
|
312
|
+
escapeZ = Math.min(escapeZ, -(toMinZ + PENETRATION_EPSILON));
|
|
313
|
+
else
|
|
314
|
+
escapeZ = Math.max(escapeZ, toMaxZ + PENETRATION_EPSILON);
|
|
315
|
+
}
|
|
316
|
+
let resultX = stepX;
|
|
317
|
+
for (const box of slideBoxes) {
|
|
256
318
|
if (current[2] <= box.minZ || current[2] >= box.maxZ)
|
|
257
319
|
continue;
|
|
258
320
|
resultX = clampAxisStep(current[0], resultX, box.minX, box.maxX);
|
|
259
321
|
}
|
|
260
322
|
const nextX = current[0] + resultX;
|
|
261
323
|
let resultZ = stepZ;
|
|
262
|
-
for (const box of
|
|
324
|
+
for (const box of slideBoxes) {
|
|
263
325
|
if (nextX <= box.minX || nextX >= box.maxX)
|
|
264
326
|
continue;
|
|
265
327
|
resultZ = clampAxisStep(current[2], resultZ, box.minZ, box.maxZ);
|
|
266
328
|
}
|
|
267
|
-
return { stepX: resultX, stepZ: resultZ };
|
|
329
|
+
return { stepX: resultX + escapeX, stepZ: resultZ + escapeZ };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Highest blocking-box top the player can stand on at `(x, z)`: among obstacles whose footprint
|
|
333
|
+
* (inflated by `playerRadius`, matching the blocking inflation so any horizontal contact that would
|
|
334
|
+
* block also supports) contains the point, the tallest top face at or below `feetY + maxClimb`.
|
|
335
|
+
* `null` when nothing supports. This is what turns a blocking collider into a walkable surface —
|
|
336
|
+
* step up a curb (`maxClimb` = step height while grounded), land a jump on a crate (`maxClimb` = 0
|
|
337
|
+
* while airborne, so only tops already below the feet catch), and stand on placed assets instead of
|
|
338
|
+
* sinking into them and being rubber-banded out by depenetration.
|
|
339
|
+
* @capability standing-on-objects a player steps up onto low ledges and lands ON a jumped-onto asset's top face instead of clipping inside it.
|
|
340
|
+
* @internal
|
|
341
|
+
*/
|
|
342
|
+
export function obstacleSupportHeight(x, z, feetY, maxClimb, obstacles, playerRadius = DEFAULT_OBSTACLE_PLAYER_RADIUS) {
|
|
343
|
+
const ceiling = feetY + maxClimb;
|
|
344
|
+
let best = null;
|
|
345
|
+
const considerTop = (minX, maxX, maxY, minZ, maxZ) => {
|
|
346
|
+
if (maxY > ceiling)
|
|
347
|
+
return;
|
|
348
|
+
if (best !== null && maxY <= best)
|
|
349
|
+
return;
|
|
350
|
+
if (x < minX - playerRadius || x > maxX + playerRadius)
|
|
351
|
+
return;
|
|
352
|
+
if (z < minZ - playerRadius || z > maxZ + playerRadius)
|
|
353
|
+
return;
|
|
354
|
+
best = maxY;
|
|
355
|
+
};
|
|
356
|
+
for (const obstacle of obstacles) {
|
|
357
|
+
const px = obstacle.position[0];
|
|
358
|
+
const py = obstacle.position[1];
|
|
359
|
+
const pz = obstacle.position[2];
|
|
360
|
+
if (obstacle.boxes !== undefined && obstacle.boxes.length > 0) {
|
|
361
|
+
for (const box of obstacle.boxes) {
|
|
362
|
+
considerTop(px + box.min[0], px + box.max[0], py + box.max[1], pz + box.min[2], pz + box.max[2]);
|
|
363
|
+
}
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const halfExtents = obstacle.halfExtents ?? DEFAULT_OBSTACLE_HALF_EXTENTS;
|
|
367
|
+
const offset = obstacle.offset;
|
|
368
|
+
const cx = px + (offset !== undefined ? offset[0] : 0);
|
|
369
|
+
const cy = py + (offset !== undefined ? offset[1] : 0);
|
|
370
|
+
const cz = pz + (offset !== undefined ? offset[2] : 0);
|
|
371
|
+
considerTop(cx - halfExtents[0], cx + halfExtents[0], cy + halfExtents[1], cz - halfExtents[2], cz + halfExtents[2]);
|
|
372
|
+
}
|
|
373
|
+
return best;
|
|
268
374
|
}
|
|
269
375
|
const OBSTACLE_GATHER_RADIUS = 3;
|
|
270
376
|
/** Placed objects within `radius` (XZ) of `center`, as {@link CollisionObstacle}s to pre-filter for {@link resolveObstacleStep}.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
|
|
2
2
|
import { groundFieldFor, hasEnvironmentTerrain, sampleSlope } from "../world/terrain.js";
|
|
3
3
|
import { resolveColliders } from "../scene/colliders.js";
|
|
4
|
-
import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
|
|
4
|
+
import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
|
|
5
5
|
import { approachYaw, steerYaw } from "./steering.js";
|
|
6
6
|
import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
|
|
7
7
|
const DEFAULT_TURN_SPEED = 2.4;
|
|
@@ -11,6 +11,8 @@ const DEFAULT_SWIM_SPEED_MULTIPLIER = 0.65;
|
|
|
11
11
|
const DEFAULT_MAX_CLIMB_NORMAL_Y = Math.cos((50 * Math.PI) / 180);
|
|
12
12
|
/** Downhill slide speed (units/s) per unit of slope steepness while on too-steep ground. */
|
|
13
13
|
const SLOPE_SLIDE_SPEED = 4;
|
|
14
|
+
/** Tallest object ledge walked up (and largest ground drop still snapped down) without jumping/falling. */
|
|
15
|
+
const DEFAULT_PLAYER_STEP_HEIGHT = 0.4;
|
|
14
16
|
/**
|
|
15
17
|
* Maps a game's declared `physics` onto the movement controllers' tuning. `PhysicsConfig.gravity` is a signed
|
|
16
18
|
* world acceleration (negative points down), but the controllers integrate `velocityY -= gravityAcceleration * dt`
|
|
@@ -45,7 +47,14 @@ const stores = new WeakMap();
|
|
|
45
47
|
function storeFor(ctx) {
|
|
46
48
|
let store = stores.get(ctx);
|
|
47
49
|
if (store === undefined) {
|
|
48
|
-
store = {
|
|
50
|
+
store = {
|
|
51
|
+
players: new Map(),
|
|
52
|
+
solids: { count: -1, set: new Set() },
|
|
53
|
+
obstacleReach: {
|
|
54
|
+
count: -1,
|
|
55
|
+
value: { horizontal: OBSTACLE_MAX_HALF_EXTENT, vertical: OBSTACLE_VERTICAL_FLOOR },
|
|
56
|
+
},
|
|
57
|
+
};
|
|
49
58
|
stores.set(ctx, store);
|
|
50
59
|
}
|
|
51
60
|
return store;
|
|
@@ -98,6 +107,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
98
107
|
const player = ctx.scene.entity.get(playerId);
|
|
99
108
|
if (player === null)
|
|
100
109
|
return;
|
|
110
|
+
// Seated / scripted freeze: skip the whole movement + obstacle gather. Without this, a driven car
|
|
111
|
+
// still paid city-scale collision every frame while the rider was frozen in place.
|
|
112
|
+
if (player.movement?.frozen === true)
|
|
113
|
+
return;
|
|
101
114
|
const store = storeFor(ctx);
|
|
102
115
|
const state = stateFor(store, userId);
|
|
103
116
|
const held = new Set(input.held);
|
|
@@ -120,7 +133,16 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
120
133
|
keys.d = isDown("moveRight");
|
|
121
134
|
keys.shift = isDown("sprint") && (tuning.movement?.canSprint?.(ctx) ?? true);
|
|
122
135
|
keys.space = isDown("jump");
|
|
123
|
-
|
|
136
|
+
// A frame carrying analog magnitudes (virtual joystick, gamepad stick) walks at its deflection
|
|
137
|
+
// instead of slamming digital ±1 axes — the fix for "a slight stick tilt reads as a full strafe".
|
|
138
|
+
const analog = input.analog ?? null;
|
|
139
|
+
const analogMove = analog === null
|
|
140
|
+
? null
|
|
141
|
+
: {
|
|
142
|
+
forward: (analog.moveForward ?? 0) - (analog.moveBack ?? 0),
|
|
143
|
+
right: (analog.moveRight ?? 0) - (analog.moveLeft ?? 0),
|
|
144
|
+
};
|
|
145
|
+
const intent = resolveMovementIntent(keys, true, analogMove);
|
|
124
146
|
const motionBatch = ctx.player.motionFor(userId).takePending();
|
|
125
147
|
const walkSpeed = player.movement?.walkSpeed ?? DEFAULT_WALK_SPEED;
|
|
126
148
|
if (tuning.collision?.voxel === true) {
|
|
@@ -172,7 +194,11 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
172
194
|
const motionOptions = submerged
|
|
173
195
|
? { speedScale: swimSpeedMultiplier, floating: true }
|
|
174
196
|
: undefined;
|
|
197
|
+
const prevJumpOffset = motion.jumpOffset;
|
|
175
198
|
const step = advancePlayerMotion(motion, intent, forwardX, forwardZ, walkSpeed, dt, tuning.physics, motionOptions);
|
|
199
|
+
// Airborne means "not resting on the surface below": any jump/impulse height before or after this
|
|
200
|
+
// frame's integration. Grounded-only forgiveness (step-up) and airborne-only landing key off it.
|
|
201
|
+
const airborne = prevJumpOffset > 0 || motion.jumpOffset > 0;
|
|
176
202
|
let stepX = step.stepX;
|
|
177
203
|
let stepZ = step.stepZ;
|
|
178
204
|
if (tuning.movement?.mode === "axis") {
|
|
@@ -180,9 +206,12 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
180
206
|
stepX = constrained.stepX;
|
|
181
207
|
stepZ = constrained.stepZ;
|
|
182
208
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
209
|
+
const stepHeight = tuning.movement?.stepHeight ?? DEFAULT_PLAYER_STEP_HEIGHT;
|
|
210
|
+
let obstacles = null;
|
|
211
|
+
if (tuning.movement?.collideObjects !== false) {
|
|
212
|
+
obstacles = gatherMovementObstacles(ctx, store, player.position, stepX, stepZ);
|
|
213
|
+
// While grounded, a box the player could simply step onto is a ledge, not a wall.
|
|
214
|
+
const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight);
|
|
186
215
|
stepX = resolved.stepX;
|
|
187
216
|
stepZ = resolved.stepZ;
|
|
188
217
|
}
|
|
@@ -206,12 +235,49 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
206
235
|
}
|
|
207
236
|
}
|
|
208
237
|
const groundAtNext = tuning.ground.sampleHeight(nextX, nextZ);
|
|
209
|
-
|
|
238
|
+
// Blocking colliders are walkable surfaces: the effective ground under the player is the higher of
|
|
239
|
+
// the terrain and the tallest object top the player can stand on here. While grounded a top within
|
|
240
|
+
// stepHeight above the feet is stepped onto (matching the obstruction's ledge forgiveness); while
|
|
241
|
+
// airborne only tops at/below the feet catch, so a jump lands ON a crate instead of sinking inside
|
|
242
|
+
// it and being rubber-banded out by depenetration.
|
|
243
|
+
const supportY = obstacles !== null
|
|
244
|
+
? obstacleSupportHeight(nextX, nextZ, player.position[1], airborne ? 0 : stepHeight, obstacles)
|
|
245
|
+
: null;
|
|
246
|
+
const effectiveGround = supportY !== null && supportY > groundAtNext ? supportY : groundAtNext;
|
|
247
|
+
let nextY;
|
|
248
|
+
if (airborne) {
|
|
249
|
+
// Integrate the jump arc in absolute space (previous feet + this frame's offset delta) so the
|
|
250
|
+
// arc stays continuous when the ground under the player changes mid-flight, and land on the
|
|
251
|
+
// effective ground — terrain or object top — the moment the descending feet reach it.
|
|
252
|
+
const nextFeet = player.position[1] + (motion.jumpOffset - prevJumpOffset);
|
|
253
|
+
if (nextFeet <= effectiveGround && motion.verticalVelocity <= 0) {
|
|
254
|
+
nextY = effectiveGround;
|
|
255
|
+
motion.jumpOffset = 0;
|
|
256
|
+
motion.verticalVelocity = 0;
|
|
257
|
+
motion.grounded = true;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
nextY = Math.max(nextFeet, effectiveGround);
|
|
261
|
+
motion.jumpOffset = nextY - effectiveGround;
|
|
262
|
+
motion.grounded = false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else if (!submerged && player.position[1] - effectiveGround > stepHeight) {
|
|
266
|
+
// Walked off a ledge taller than a step (a crate edge, a cliff): fall under gravity from here
|
|
267
|
+
// instead of teleporting the feet down to the ground in one frame.
|
|
268
|
+
nextY = player.position[1];
|
|
269
|
+
motion.jumpOffset = nextY - effectiveGround;
|
|
270
|
+
motion.verticalVelocity = 0;
|
|
271
|
+
motion.grounded = false;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
nextY = effectiveGround;
|
|
275
|
+
}
|
|
210
276
|
if (motionBatch !== null && motionBatch.y !== null) {
|
|
211
277
|
nextY = motionBatch.y;
|
|
212
|
-
motion.jumpOffset = motionBatch.y -
|
|
278
|
+
motion.jumpOffset = motionBatch.y - effectiveGround;
|
|
213
279
|
}
|
|
214
|
-
else if (swimEnabled && waterLevel !== undefined &&
|
|
280
|
+
else if (swimEnabled && waterLevel !== undefined && effectiveGround < waterLevel) {
|
|
215
281
|
nextY = waterLevel;
|
|
216
282
|
}
|
|
217
283
|
if (tuning.movement?.beforeCommit !== undefined) {
|
|
@@ -238,31 +304,95 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
238
304
|
/** Feet-to-head span the obstruction test uses; matches `movementModel`'s `OBSTACLE_PLAYER_HEIGHT`. */
|
|
239
305
|
const OBSTACLE_PLAYER_HEIGHT = 1.8;
|
|
240
306
|
/**
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
307
|
+
* Broadphase reach floor (XZ): the `inBox` query indexes objects by their center *point*, so the query
|
|
308
|
+
* must extend past the player by at least the largest blocking-collider extent or a wide object's edge
|
|
309
|
+
* is missed. Floored so default unit boxes stay covered.
|
|
244
310
|
*/
|
|
245
311
|
const OBSTACLE_MAX_HALF_EXTENT = 4;
|
|
312
|
+
/**
|
|
313
|
+
* Hard cap on XZ broadphase reach. City lots ~12–17 m footprint need ~17–20 m after yaw expansion;
|
|
314
|
+
* beyond that we refuse to grow the query into a city-wide box (and rely on the outer AABB of each
|
|
315
|
+
* solid still blocking once its *center* is in range).
|
|
316
|
+
*/
|
|
317
|
+
const OBSTACLE_HORIZONTAL_CAP = 22;
|
|
318
|
+
/**
|
|
319
|
+
* Vertical broadphase floor/cap. Player only needs nearby ground props / step-ups — never full tower
|
|
320
|
+
* height. Using building height for Y was the Vice Isle pose killer: a 50 m tower turned `inBox` into
|
|
321
|
+
* a 100³ empty cell walk every frame.
|
|
322
|
+
*/
|
|
323
|
+
const OBSTACLE_VERTICAL_FLOOR = 3;
|
|
324
|
+
const OBSTACLE_VERTICAL_CAP = 8;
|
|
325
|
+
/**
|
|
326
|
+
* Mesh compound `boxes` under this count are treated as intentional openings (archways). Above it we
|
|
327
|
+
* walk against the outer fitted AABB only — solid city buildings often ship dense box soups that
|
|
328
|
+
* multiply narrowphase cost without changing the blocked footprint.
|
|
329
|
+
*/
|
|
330
|
+
const MOVEMENT_MESH_BOX_BUDGET = 12;
|
|
331
|
+
/**
|
|
332
|
+
* Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
|
|
333
|
+
* ctx and recomputed when the object count changes.
|
|
334
|
+
*/
|
|
335
|
+
function obstacleReachFor(ctx, store) {
|
|
336
|
+
const objects = ctx.scene.object.list();
|
|
337
|
+
const cache = store.obstacleReach;
|
|
338
|
+
if (cache.count === objects.length)
|
|
339
|
+
return cache.value;
|
|
340
|
+
let horizontal = OBSTACLE_MAX_HALF_EXTENT;
|
|
341
|
+
let vertical = OBSTACLE_VERTICAL_FLOOR;
|
|
342
|
+
for (const object of objects) {
|
|
343
|
+
const set = ctx.scene.object.collidersOf(object.instanceId);
|
|
344
|
+
if (set === null)
|
|
345
|
+
continue;
|
|
346
|
+
for (const collider of resolveColliders(set)) {
|
|
347
|
+
if (!collider.blocks || collider.purpose !== "physical")
|
|
348
|
+
continue;
|
|
349
|
+
const shape = collider.shape;
|
|
350
|
+
const offset = shape.offset;
|
|
351
|
+
const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
|
|
352
|
+
const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
|
|
353
|
+
const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
|
|
354
|
+
if (shape.kind === "sphere") {
|
|
355
|
+
horizontal = Math.max(horizontal, ox + oz + shape.radius);
|
|
356
|
+
vertical = Math.max(vertical, oy + shape.radius);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// Horizontal bound sums both axes so any yaw of the box stays covered.
|
|
360
|
+
const h = shape.halfExtents;
|
|
361
|
+
horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
|
|
362
|
+
vertical = Math.max(vertical, oy + h[1]);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const value = {
|
|
367
|
+
horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
|
|
368
|
+
vertical: Math.min(vertical, OBSTACLE_VERTICAL_CAP),
|
|
369
|
+
};
|
|
370
|
+
cache.count = objects.length;
|
|
371
|
+
cache.value = value;
|
|
372
|
+
return value;
|
|
373
|
+
}
|
|
246
374
|
/**
|
|
247
375
|
* Gather the player's nearby blocking obstacles from resolved colliders — the mesh-accurate replacement
|
|
248
376
|
* for the old `nearbyObstacles(list(), …)` which emitted a default 1×1×1 box per object. Bounded by an
|
|
249
|
-
* `inBox` broadphase (reach ≥ player radius + max
|
|
250
|
-
* candidate, keeps only blocking physical colliders and emits their real
|
|
251
|
-
* mesh collider carrying `boxes
|
|
252
|
-
* `rotationY`. Objects with no resolved collider fall back
|
|
253
|
-
* don't block stop obstructing. Runs for both the shell's
|
|
377
|
+
* `inBox` broadphase (reach ≥ player radius + the scene's max blocking extent so wide walls can't be
|
|
378
|
+
* missed), then, per candidate, keeps only blocking physical colliders and emits their real
|
|
379
|
+
* half-extents/offset (or, for a mesh collider carrying a small `boxes` set, the compound sub-boxes),
|
|
380
|
+
* conservatively yaw-rotated by the object's `rotationY`. Objects with no resolved collider fall back
|
|
381
|
+
* to the default box; objects whose colliders don't block stop obstructing. Runs for both the shell's
|
|
382
|
+
* local player and a host, so parity is automatic.
|
|
254
383
|
*/
|
|
255
|
-
function gatherMovementObstacles(ctx, position, stepX, stepZ) {
|
|
256
|
-
const
|
|
257
|
-
const
|
|
384
|
+
function gatherMovementObstacles(ctx, store, position, stepX, stepZ) {
|
|
385
|
+
const reach = obstacleReachFor(ctx, store);
|
|
386
|
+
const reachX = Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS + reach.horizontal;
|
|
387
|
+
const reachZ = Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS + reach.horizontal;
|
|
258
388
|
const min = [
|
|
259
389
|
position[0] - reachX,
|
|
260
|
-
position[1] -
|
|
390
|
+
position[1] - reach.vertical,
|
|
261
391
|
position[2] - reachZ,
|
|
262
392
|
];
|
|
263
393
|
const max = [
|
|
264
394
|
position[0] + reachX,
|
|
265
|
-
position[1] + OBSTACLE_PLAYER_HEIGHT +
|
|
395
|
+
position[1] + OBSTACLE_PLAYER_HEIGHT + reach.vertical,
|
|
266
396
|
position[2] + reachZ,
|
|
267
397
|
];
|
|
268
398
|
const obstacles = [];
|
|
@@ -282,13 +412,19 @@ function gatherMovementObstacles(ctx, position, stepX, stepZ) {
|
|
|
282
412
|
return obstacles;
|
|
283
413
|
}
|
|
284
414
|
/** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
|
|
285
|
-
* carrying sub-
|
|
286
|
-
* each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
|
|
415
|
+
* carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
|
|
416
|
+
* conservatively expanding each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
|
|
417
|
+
* never under-covers. */
|
|
287
418
|
function obstacleFromCollider(collider, position, rotationY) {
|
|
288
419
|
const cos = Math.cos(rotationY);
|
|
289
420
|
const sin = Math.sin(rotationY);
|
|
290
421
|
const shape = collider.shape;
|
|
291
|
-
|
|
422
|
+
// Dense compound soups (city buildings) collapse to the outer fitted AABB for movement — openings
|
|
423
|
+
// only stay mesh-box accurate when the box count is small (arches, doorways).
|
|
424
|
+
if (shape.kind === "mesh" &&
|
|
425
|
+
shape.boxes !== undefined &&
|
|
426
|
+
shape.boxes.length > 0 &&
|
|
427
|
+
shape.boxes.length <= MOVEMENT_MESH_BOX_BUDGET) {
|
|
292
428
|
return {
|
|
293
429
|
position,
|
|
294
430
|
boxes: shape.boxes.map((b) => yawExpandLocalBox(b.min, b.max, cos, sin)),
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
export function tickDrivableVehicle(vehicle, dt, axis, options = {}) {
|
|
9
9
|
const step = vehicle.tick(dt, axis, options.modifiers);
|
|
10
10
|
const [x, y, z] = step.position;
|
|
11
|
-
|
|
11
|
+
// Ground-snap first, then lift by any hop in progress, so a jumping vehicle still follows the
|
|
12
|
+
// terrain beneath it instead of hanging at a fixed world height over a slope.
|
|
13
|
+
const resolvedY = (options.groundHeight === undefined ? y : options.groundHeight(x, z)) + step.airOffset;
|
|
12
14
|
return {
|
|
13
15
|
pose: { position: [x, resolvedY, z], rotationX: step.bodyPitch, rotationY: step.heading, rotationZ: step.bodyRoll, dt },
|
|
14
16
|
step,
|
|
@@ -12,6 +12,28 @@ export interface KinematicVehicleTuning {
|
|
|
12
12
|
handbrakeGrip: number;
|
|
13
13
|
/** Forward-speed decay per second when coasting (sleds, boats); default `0`. */
|
|
14
14
|
rollingResistance?: number;
|
|
15
|
+
/**
|
|
16
|
+
* Constant coast deceleration in world units/s², applied when the throttle is lifted and the brake
|
|
17
|
+
* is not driving reverse. Unlike `rollingResistance` (a proportional decay, so a fast car sheds
|
|
18
|
+
* speed quickly and a slow one crawls forever), this is the flat road-load feel karts and arcade
|
|
19
|
+
* racers want; it is capped at `|speed| / dt` so coasting can never push the vehicle backwards.
|
|
20
|
+
* The `chassis` block derives this from mass and gearing instead — set one or the other, not both.
|
|
21
|
+
* Default `0` (coast forever).
|
|
22
|
+
*/
|
|
23
|
+
coastDeceleration?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Reverse drive force as a fraction of `engineAccel`, for the arcade path. Reverse is intentionally
|
|
26
|
+
* softer than forward; raise it for karts that need a brisk backup, lower it for heavy vehicles.
|
|
27
|
+
* Default {@link DEFAULT_REVERSE_FORCE_SCALE}. With a `chassis` block, `chassis.reverseForceScale`
|
|
28
|
+
* takes precedence.
|
|
29
|
+
*/
|
|
30
|
+
reverseForceScale?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Downward acceleration in world units/s² applied to the vehicle's hop/air state, enabling
|
|
33
|
+
* {@link KinematicVehicle.hop}. Omit to disable vertical motion entirely, which keeps every
|
|
34
|
+
* existing ground vehicle on the flat path with no air state to integrate.
|
|
35
|
+
*/
|
|
36
|
+
hopGravity?: number;
|
|
15
37
|
/** Optional higher-fidelity powertrain. Omit to preserve the direct arcade acceleration model. */
|
|
16
38
|
powertrain?: KinematicPowertrainTuning;
|
|
17
39
|
/** Optional bicycle-model steering with speed-sensitive lock and smoothed rack response. */
|
|
@@ -49,6 +71,11 @@ export interface KinematicChassisTuning {
|
|
|
49
71
|
comHeight: number;
|
|
50
72
|
/** Lateral wheel spacing (track width), m; larger resists lateral weight transfer. */
|
|
51
73
|
trackWidth: number;
|
|
74
|
+
/**
|
|
75
|
+
* Reverse drive force as a fraction of `engineForce` (default {@link DEFAULT_REVERSE_FORCE_SCALE}).
|
|
76
|
+
* Real gearboxes and games both keep reverse much softer than forward launch.
|
|
77
|
+
*/
|
|
78
|
+
reverseForceScale?: number;
|
|
52
79
|
}
|
|
53
80
|
/** Data-first gearbox and torque-curve tuning for a kinematic ground vehicle. */
|
|
54
81
|
export interface KinematicPowertrainTuning {
|
|
@@ -111,6 +138,11 @@ export interface KinematicVehicleModifiers {
|
|
|
111
138
|
accelScale?: number;
|
|
112
139
|
/** Scale turn rate this tick (brace penalty, heavy-load steering). */
|
|
113
140
|
turnRateScale?: number;
|
|
141
|
+
/**
|
|
142
|
+
* Scale braking force this tick (worn pads, ice, a brace bonus that stiffens the whole drivetrain).
|
|
143
|
+
* Separate from `accelScale` because stronger drive and stronger brakes are not the same upgrade.
|
|
144
|
+
*/
|
|
145
|
+
brakeScale?: number;
|
|
114
146
|
}
|
|
115
147
|
export interface KinematicVehicleStep {
|
|
116
148
|
position: readonly [number, number, number];
|
|
@@ -138,6 +170,14 @@ export interface KinematicVehicleStep {
|
|
|
138
170
|
wheelspin: boolean;
|
|
139
171
|
bodyPitch: number;
|
|
140
172
|
bodyRoll: number;
|
|
173
|
+
/**
|
|
174
|
+
* Height above the vehicle's ground plane from an in-progress hop, world units; `0` while grounded
|
|
175
|
+
* and always `0` without `hopGravity`. `tickDrivableVehicle` adds this on top of the sampled ground
|
|
176
|
+
* height, so a hopping vehicle still follows terrain underneath it.
|
|
177
|
+
*/
|
|
178
|
+
airOffset: number;
|
|
179
|
+
/** `true` while `airOffset > 0` — gate landing effects, air control, and trick scoring off this. */
|
|
180
|
+
airborne: boolean;
|
|
141
181
|
}
|
|
142
182
|
/**
|
|
143
183
|
* The pure-kinematic arcade car every racing game hand-rolled (#282.1): steer-yaw scaled by speed,
|
|
@@ -154,6 +194,21 @@ export interface KinematicVehicle {
|
|
|
154
194
|
velocity(): readonly [number, number];
|
|
155
195
|
/** Scale the current velocity (boost pads, hard collisions). */
|
|
156
196
|
scaleVelocity(factor: number): void;
|
|
197
|
+
/**
|
|
198
|
+
* Launch the vehicle upward with the given vertical impulse (world units/s). Returns `false` — and
|
|
199
|
+
* changes nothing — when the vehicle is already airborne or the tuning has no `hopGravity`, so a
|
|
200
|
+
* held jump key cannot ratchet a vehicle into orbit.
|
|
201
|
+
*/
|
|
202
|
+
hop(impulse: number): boolean;
|
|
203
|
+
/**
|
|
204
|
+
* Replace the tuning in place, keeping position, heading, velocity and gear state. This is the
|
|
205
|
+
* seam for vehicles whose stats change during play — an installed part, a damaged engine, a
|
|
206
|
+
* temporary debuff — which must NOT cost the vehicle its momentum the way rebuilding the sim
|
|
207
|
+
* would. For a change that lasts a single tick, pass {@link KinematicVehicleModifiers} instead.
|
|
208
|
+
*/
|
|
209
|
+
retune(next: KinematicVehicleTuning): void;
|
|
157
210
|
resetTo(position: readonly [number, number, number], heading: number): void;
|
|
158
211
|
}
|
|
159
|
-
export declare function createKinematicVehicle(
|
|
212
|
+
export declare function createKinematicVehicle(initialTuning: KinematicVehicleTuning, options?: KinematicVehicleOptions): KinematicVehicle;
|
|
213
|
+
/** Default reverse drive force as a fraction of forward `engineForce` / `engineAccel`. */
|
|
214
|
+
export declare const DEFAULT_REVERSE_FORCE_SCALE = 0.48;
|