@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
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
import type { PhysicsBounds, PhysicsWorld } from "./physicsWorld.js";
|
|
2
2
|
export type ForceMode = "impulse" | "velocity" | "accelerate";
|
|
3
|
+
/** World-space force position, axis or acceleration vector. */
|
|
4
|
+
export type ForceVector = readonly [number, number, number];
|
|
5
|
+
/** Renderer-free localized acceleration shared by physical actors and cosmetic particles. */
|
|
6
|
+
export interface ForceFieldConfig {
|
|
7
|
+
center: ForceVector;
|
|
8
|
+
shape: {
|
|
9
|
+
kind: "sphere";
|
|
10
|
+
radius: number;
|
|
11
|
+
} | {
|
|
12
|
+
kind: "box";
|
|
13
|
+
halfExtents: ForceVector;
|
|
14
|
+
};
|
|
15
|
+
/** Positive attracts toward the center; negative repels. Units/s². */
|
|
16
|
+
strength: number;
|
|
17
|
+
/** Edge-to-center falloff exponent; zero is uniform. Default 1. */
|
|
18
|
+
attenuation?: number;
|
|
19
|
+
/** Blend radial attraction with `direction`, from 0 to 1. */
|
|
20
|
+
directionality?: number;
|
|
21
|
+
/** World-space direction; default [0, 0, -1]. */
|
|
22
|
+
direction?: ForceVector;
|
|
23
|
+
/** Signed tangential acceleration around a world-space axis. */
|
|
24
|
+
vortex?: {
|
|
25
|
+
axis: ForceVector;
|
|
26
|
+
strength: number;
|
|
27
|
+
/** Independent signed acceleration along the normalized axis, in units/s². */
|
|
28
|
+
lift?: number;
|
|
29
|
+
};
|
|
30
|
+
/** Affected target bits; omission affects every target. */
|
|
31
|
+
mask?: number;
|
|
32
|
+
/** Cap the combined acceleration magnitude. */
|
|
33
|
+
maxAcceleration?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Validate force authoring once before sampling in a hot loop.
|
|
36
|
+
* Coordinates stay within the safe integer range; acceleration magnitudes stay within 1e12 units/s².
|
|
37
|
+
* @capability force-field-validation reject invalid localized force authoring before simulation
|
|
38
|
+
*/
|
|
39
|
+
export declare function validateForceField(config: ForceFieldConfig): void;
|
|
40
|
+
/** Sample localized acceleration; `out` permits allocation-free integration.
|
|
41
|
+
* @capability localized-forces sample signed attraction, directional force, vortex spin and axial lift with target masks and caps
|
|
42
|
+
*/
|
|
43
|
+
export declare function sampleForceField(config: ForceFieldConfig, position: ForceVector, targetMask?: number, out?: [number, number, number]): [number, number, number];
|
|
3
44
|
export interface ForceVolumeConfig {
|
|
4
45
|
/** Region a body's center must be inside to be affected. */
|
|
5
46
|
bounds: PhysicsBounds;
|
|
@@ -1,3 +1,91 @@
|
|
|
1
|
+
const DEFAULT_FORCE_DIRECTION = [0, 0, -1];
|
|
2
|
+
/** Validate force authoring once before sampling in a hot loop.
|
|
3
|
+
* Coordinates stay within the safe integer range; acceleration magnitudes stay within 1e12 units/s².
|
|
4
|
+
* @capability force-field-validation reject invalid localized force authoring before simulation
|
|
5
|
+
*/
|
|
6
|
+
export function validateForceField(config) {
|
|
7
|
+
const vector = (value) => Array.isArray(value) && value.length === 3 && value.every(component => typeof component === "number" && Number.isFinite(component) && Math.abs(component) <= Number.MAX_SAFE_INTEGER);
|
|
8
|
+
const finite = (value, min = -1e12, max = 1e12) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max;
|
|
9
|
+
if (config === null || typeof config !== "object" || !vector(config.center) || !finite(config.strength))
|
|
10
|
+
throw new RangeError("Force field requires safe finite coordinates and strength in -1e12..1e12");
|
|
11
|
+
if (config.shape?.kind === "sphere") {
|
|
12
|
+
if (!finite(config.shape.radius, Number.MIN_VALUE))
|
|
13
|
+
throw new RangeError("Force sphere radius must be finite and positive");
|
|
14
|
+
}
|
|
15
|
+
else if (config.shape?.kind === "box") {
|
|
16
|
+
if (!vector(config.shape.halfExtents) || !config.shape.halfExtents.every(value => value > 0))
|
|
17
|
+
throw new RangeError("Force box extents must be finite and positive");
|
|
18
|
+
}
|
|
19
|
+
else
|
|
20
|
+
throw new RangeError("Force field requires sphere or box shape");
|
|
21
|
+
if (config.attenuation !== undefined && !finite(config.attenuation, 0))
|
|
22
|
+
throw new RangeError("Force attenuation must be finite and nonnegative");
|
|
23
|
+
if (config.directionality !== undefined && !finite(config.directionality, 0, 1))
|
|
24
|
+
throw new RangeError("Force directionality must be in 0..1");
|
|
25
|
+
if (config.direction !== undefined && (!vector(config.direction) || Math.hypot(...config.direction) === 0))
|
|
26
|
+
throw new RangeError("Force direction must be finite and nonzero");
|
|
27
|
+
if (config.vortex !== undefined && (!vector(config.vortex.axis) || Math.hypot(...config.vortex.axis) === 0 || !finite(config.vortex.strength)))
|
|
28
|
+
throw new RangeError("Force vortex requires a finite nonzero axis and finite strength");
|
|
29
|
+
if (config.vortex?.lift !== undefined && !finite(config.vortex.lift))
|
|
30
|
+
throw new RangeError("Force vortex lift must be finite");
|
|
31
|
+
if (config.mask !== undefined && (!finite(config.mask, 0, 0xffffffff) || !Number.isInteger(config.mask)))
|
|
32
|
+
throw new RangeError("Force mask must be an unsigned 32-bit integer");
|
|
33
|
+
if (config.maxAcceleration !== undefined && !finite(config.maxAcceleration, 0))
|
|
34
|
+
throw new RangeError("Force acceleration cap must be finite and nonnegative");
|
|
35
|
+
}
|
|
36
|
+
/** Sample localized acceleration; `out` permits allocation-free integration.
|
|
37
|
+
* @capability localized-forces sample signed attraction, directional force, vortex spin and axial lift with target masks and caps
|
|
38
|
+
*/
|
|
39
|
+
export function sampleForceField(config, position, targetMask = 0xffffffff, out = [0, 0, 0]) {
|
|
40
|
+
out[0] = out[1] = out[2] = 0;
|
|
41
|
+
if (((config.mask ?? 0xffffffff) & targetMask) === 0)
|
|
42
|
+
return out;
|
|
43
|
+
const x = position[0] - config.center[0];
|
|
44
|
+
const y = position[1] - config.center[1];
|
|
45
|
+
const z = position[2] - config.center[2];
|
|
46
|
+
const distance = Math.hypot(x, y, z);
|
|
47
|
+
const extent = config.shape.kind === "sphere"
|
|
48
|
+
? distance / config.shape.radius
|
|
49
|
+
: Math.max(Math.abs(x) / config.shape.halfExtents[0], Math.abs(y) / config.shape.halfExtents[1], Math.abs(z) / config.shape.halfExtents[2]);
|
|
50
|
+
if (!Number.isFinite(extent) || extent >= 1)
|
|
51
|
+
return out;
|
|
52
|
+
const envelope = Math.pow(1 - extent, Math.max(0, config.attenuation ?? 1));
|
|
53
|
+
const directional = Math.max(0, Math.min(1, config.directionality ?? 0));
|
|
54
|
+
const direction = config.direction ?? DEFAULT_FORCE_DIRECTION;
|
|
55
|
+
const directionLength = Math.hypot(...direction);
|
|
56
|
+
const radialScale = distance > 1e-9 ? -config.strength * (1 - directional) / distance : 0;
|
|
57
|
+
const directionScale = directionLength > 1e-9 ? config.strength * directional / directionLength : 0;
|
|
58
|
+
out[0] = x * radialScale + direction[0] * directionScale;
|
|
59
|
+
out[1] = y * radialScale + direction[1] * directionScale;
|
|
60
|
+
out[2] = z * radialScale + direction[2] * directionScale;
|
|
61
|
+
if (config.vortex !== undefined) {
|
|
62
|
+
const [ax, ay, az] = config.vortex.axis;
|
|
63
|
+
const axisLength = Math.hypot(ax, ay, az);
|
|
64
|
+
if (axisLength > 1e-9) {
|
|
65
|
+
const liftScale = (config.vortex.lift ?? 0) / axisLength;
|
|
66
|
+
out[0] += ax * liftScale;
|
|
67
|
+
out[1] += ay * liftScale;
|
|
68
|
+
out[2] += az * liftScale;
|
|
69
|
+
}
|
|
70
|
+
const tx = ay * z - az * y;
|
|
71
|
+
const ty = az * x - ax * z;
|
|
72
|
+
const tz = ax * y - ay * x;
|
|
73
|
+
const tangentLength = Math.hypot(tx, ty, tz);
|
|
74
|
+
if (tangentLength > 1e-9) {
|
|
75
|
+
const scale = config.vortex.strength / tangentLength;
|
|
76
|
+
out[0] += tx * scale;
|
|
77
|
+
out[1] += ty * scale;
|
|
78
|
+
out[2] += tz * scale;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const magnitude = Math.hypot(...out) * envelope;
|
|
82
|
+
const cap = Math.max(0, config.maxAcceleration ?? Number.POSITIVE_INFINITY);
|
|
83
|
+
const scale = magnitude > cap ? envelope * cap / magnitude : envelope;
|
|
84
|
+
out[0] = out[0] === 0 ? 0 : out[0] * scale;
|
|
85
|
+
out[1] = out[1] === 0 ? 0 : out[1] * scale;
|
|
86
|
+
out[2] = out[2] === 0 ? 0 : out[2] * scale;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
1
89
|
function inside(bounds, x, y, z) {
|
|
2
90
|
return (x >= bounds.min[0] &&
|
|
3
91
|
x <= bounds.max[0] &&
|
|
@@ -130,10 +130,15 @@ export interface KinematicVehicleOptions {
|
|
|
130
130
|
/**
|
|
131
131
|
* Veto or clamp a planar move before it commits — walls, closed gates, arena bounds. Given the
|
|
132
132
|
* attempted `from`→`to` on the XZ plane, return the destination actually allowed; velocity is
|
|
133
|
-
* rederived from the permitted displacement, so motion blocked on an axis stops on that axis
|
|
134
|
-
*
|
|
133
|
+
* rederived from the permitted displacement, so motion blocked on an axis stops on that axis.
|
|
134
|
+
* A result may carry `motion`, the permitted XZ displacement in world units before position
|
|
135
|
+
* recovery. Use it when the endpoint also depenetrates the body: recovery must not add velocity.
|
|
136
|
+
* Plain tuple results retain displacement-based velocity. Called once per tick; no purity required.
|
|
137
|
+
* Default: no clamp (the move always commits).
|
|
135
138
|
*/
|
|
136
|
-
clampMove?: (from: readonly [number, number], to: readonly [number, number]) => readonly [number, number]
|
|
139
|
+
clampMove?: (from: readonly [number, number], to: readonly [number, number]) => readonly [number, number] & {
|
|
140
|
+
readonly motion?: readonly [number, number];
|
|
141
|
+
};
|
|
137
142
|
}
|
|
138
143
|
/**
|
|
139
144
|
* Per-tick multipliers layered over the base tuning — the transient overrides games apply for one
|
|
@@ -236,8 +236,8 @@ export function createKinematicVehicle(initialTuning, options = {}) {
|
|
|
236
236
|
const toZ = z + vz * dt;
|
|
237
237
|
if (clampMove !== undefined && dt > 0) {
|
|
238
238
|
const allowed = clampMove([x, z], [toX, toZ]);
|
|
239
|
-
vx = (allowed[0] - x) / dt;
|
|
240
|
-
vz = (allowed[1] - z) / dt;
|
|
239
|
+
vx = (allowed.motion?.[0] ?? allowed[0] - x) / dt;
|
|
240
|
+
vz = (allowed.motion?.[1] ?? allowed[1] - z) / dt;
|
|
241
241
|
x = allowed[0];
|
|
242
242
|
z = allowed[1];
|
|
243
243
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { IDENTITY_QUAT, isIdentityQuat, shapeHalfExtents, } from "./physicsBackend.js";
|
|
2
2
|
import { PhysicsWorld } from "./physicsWorld.js";
|
|
3
|
+
/** Exact touching is not overlap; account for the same few-ulp face rounding as shapecast. */
|
|
4
|
+
function axisOverlaps(center, bodyCenter, combinedHalf) {
|
|
5
|
+
const tolerance = Number.EPSILON * 8 * Math.max(1, Math.abs(center), Math.abs(bodyCenter - combinedHalf), Math.abs(bodyCenter + combinedHalf));
|
|
6
|
+
return combinedHalf - Math.abs(bodyCenter - center) > tolerance;
|
|
7
|
+
}
|
|
3
8
|
const ALL_LAYERS = 0xffffffff;
|
|
4
9
|
const CAPABILITIES = {
|
|
5
10
|
rotation: false,
|
|
@@ -408,6 +413,17 @@ export function createPhysicsWorldBackend(options) {
|
|
|
408
413
|
least === 2 ? -1 : least === 3 ? 1 : 0,
|
|
409
414
|
least === 4 ? -1 : least === 5 ? 1 : 0,
|
|
410
415
|
];
|
|
416
|
+
// Expanding an AABB by a capsule's half-height can round exact floor contact inside
|
|
417
|
+
// by a few ulps. Keep entering contacts and real penetration, but allow departing or
|
|
418
|
+
// tangent motion through this numerical touch so it cannot mask a later wall/ceiling.
|
|
419
|
+
const faceScale = least < 2
|
|
420
|
+
? Math.max(1, Math.abs(ox), Math.abs(minX), Math.abs(maxX))
|
|
421
|
+
: least < 4
|
|
422
|
+
? Math.max(1, Math.abs(oy), Math.abs(minY), Math.abs(maxY))
|
|
423
|
+
: Math.max(1, Math.abs(oz), Math.abs(minZ), Math.abs(maxZ));
|
|
424
|
+
const touchTolerance = Number.EPSILON * 8 * faceScale;
|
|
425
|
+
if (motionLen > 0 && pens[least] <= touchTolerance && mx * normal[0] + my * normal[1] + mz * normal[2] >= 0)
|
|
426
|
+
continue;
|
|
411
427
|
}
|
|
412
428
|
else {
|
|
413
429
|
if (!(motionLen > 0))
|
|
@@ -437,11 +453,11 @@ export function createPhysicsWorldBackend(options) {
|
|
|
437
453
|
if (!bodyMatches(rec, desc.mask, desc.exclude, handle))
|
|
438
454
|
continue;
|
|
439
455
|
const i = rec.index;
|
|
440
|
-
if (
|
|
456
|
+
if (!axisOverlaps(desc.position[0], world.posX[i], rec.half[0] + half[0]))
|
|
441
457
|
continue;
|
|
442
|
-
if (
|
|
458
|
+
if (!axisOverlaps(desc.position[1], world.posY[i], rec.half[1] + half[1]))
|
|
443
459
|
continue;
|
|
444
|
-
if (
|
|
460
|
+
if (!axisOverlaps(desc.position[2], world.posZ[i], rec.half[2] + half[2]))
|
|
445
461
|
continue;
|
|
446
462
|
out.push(handle);
|
|
447
463
|
}
|
|
@@ -36,6 +36,6 @@ export declare class SpatialGrid {
|
|
|
36
36
|
rebuild(count: number, xs: Float32Array, zs: Float32Array): void;
|
|
37
37
|
/** Fill `out` with every entity within `radius` of (x,z); returns the hit count (capped at `out.length`). */
|
|
38
38
|
queryCircle(x: number, z: number, radius: number, out: Int32Array): number;
|
|
39
|
-
/** Invoke `cb(a, b)` once for each unordered pair within `maxDistance
|
|
39
|
+
/** Invoke `cb(a, b)` once for each unordered pair within finite, nonnegative `maxDistance`, including distances larger than `cellSize`. */
|
|
40
40
|
forEachPair(maxDistance: number, cb: (a: number, b: number) => void): void;
|
|
41
41
|
}
|
|
@@ -103,25 +103,24 @@ export class SpatialGrid {
|
|
|
103
103
|
}
|
|
104
104
|
return n;
|
|
105
105
|
}
|
|
106
|
-
/** Invoke `cb(a, b)` once for each unordered pair within `maxDistance
|
|
106
|
+
/** Invoke `cb(a, b)` once for each unordered pair within finite, nonnegative `maxDistance`, including distances larger than `cellSize`. */
|
|
107
107
|
forEachPair(maxDistance, cb) {
|
|
108
|
+
if (!Number.isFinite(maxDistance) || maxDistance < 0) {
|
|
109
|
+
throw new RangeError("SpatialGrid: maxDistance must be finite and nonnegative");
|
|
110
|
+
}
|
|
108
111
|
const xs = this.xs;
|
|
109
112
|
const zs = this.zs;
|
|
110
113
|
if (xs === null || zs === null)
|
|
111
114
|
return;
|
|
112
115
|
const d2 = maxDistance * maxDistance;
|
|
113
116
|
const nx = this.nx;
|
|
114
|
-
const nz = this.nz;
|
|
115
117
|
for (let i = 0; i < this.entityCount; i += 1) {
|
|
116
|
-
const c = this.cellOfBody[i];
|
|
117
|
-
const cz = (c / nx) | 0;
|
|
118
|
-
const cx = c - cz * nx;
|
|
119
118
|
const x = xs[i];
|
|
120
119
|
const z = zs[i];
|
|
121
|
-
const z0 =
|
|
122
|
-
const z1 =
|
|
123
|
-
const x0 =
|
|
124
|
-
const x1 =
|
|
120
|
+
const z0 = this.cellZ(z - maxDistance);
|
|
121
|
+
const z1 = this.cellZ(z + maxDistance);
|
|
122
|
+
const x0 = this.cellX(x - maxDistance);
|
|
123
|
+
const x1 = this.cellX(x + maxDistance);
|
|
125
124
|
for (let gz = z0; gz <= z1; gz += 1) {
|
|
126
125
|
const rowBase = gz * nx;
|
|
127
126
|
for (let gx = x0; gx <= x1; gx += 1) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AxisInput } from "../input/axisInput.js";
|
|
2
|
+
import type { KinematicVehicleOptions } from "./kinematicVehicle.js";
|
|
2
3
|
import type { GripCurve } from "./vehicleBody.js";
|
|
3
4
|
/**
|
|
4
5
|
* One axle's tire, in physical terms. Lateral force follows a simplified Pacejka curve
|
|
@@ -243,8 +244,8 @@ export interface VehicleDynamicsOptions {
|
|
|
243
244
|
heading?: number;
|
|
244
245
|
/** Grip multiplier by world position (gravel, ice, wet paint); default `1`. */
|
|
245
246
|
surfaceFriction?: (x: number, z: number) => number;
|
|
246
|
-
/** Clamp a planar move
|
|
247
|
-
clampMove?:
|
|
247
|
+
/** Clamp a planar move; optional result `motion` separates permitted displacement from position recovery. Called once per physics substep. */
|
|
248
|
+
clampMove?: KinematicVehicleOptions["clampMove"];
|
|
248
249
|
/** Terrain height under a point; read per wheel when `suspension` is set (ramps, hills, ledges). Default flat at the spawn height. */
|
|
249
250
|
groundHeight?: (x: number, z: number) => number;
|
|
250
251
|
}
|
|
@@ -390,8 +390,8 @@ export function createVehicleDynamics(initialTuning, options = {}) {
|
|
|
390
390
|
const toZ = state.z + nvz * h;
|
|
391
391
|
if (clampMove !== undefined && h > 0) {
|
|
392
392
|
const allowed = clampMove([state.x, state.z], [toX, toZ]);
|
|
393
|
-
nvx = (allowed[0] - state.x) / h;
|
|
394
|
-
nvz = (allowed[1] - state.z) / h;
|
|
393
|
+
nvx = (allowed.motion?.[0] ?? allowed[0] - state.x) / h;
|
|
394
|
+
nvz = (allowed.motion?.[1] ?? allowed[1] - state.z) / h;
|
|
395
395
|
state.x = allowed[0];
|
|
396
396
|
state.z = allowed[1];
|
|
397
397
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type CollisionObstacle } from "../movement/movementModel.js";
|
|
2
|
+
import type { KinematicVehicleOptions } from "./kinematicVehicle.js";
|
|
2
3
|
/**
|
|
3
4
|
* A single blocked-move event a car can react to (#1051): crash damage, screen shake, a metal-crunch
|
|
4
5
|
* cue. Reported once, when a move is first vetoed, then consumed by {@link VehicleObstacleClamp.takeImpact}.
|
|
@@ -17,8 +18,8 @@ export interface VehicleImpact {
|
|
|
17
18
|
* {@link takeImpact} once per tick for the crash it produced.
|
|
18
19
|
*/
|
|
19
20
|
export interface VehicleObstacleClamp {
|
|
20
|
-
/** Resolve an attempted
|
|
21
|
-
clampMove
|
|
21
|
+
/** Resolve an attempted XZ move; recovery endpoints carry permitted world-unit displacement in `motion`. */
|
|
22
|
+
clampMove: NonNullable<KinematicVehicleOptions["clampMove"]>;
|
|
22
23
|
/** Impact recorded since last take, consumed on read (null when none). Closing speed is the velocity component into the obstacle at block time, units/s. */
|
|
23
24
|
takeImpact(): VehicleImpact | null;
|
|
24
25
|
}
|
|
@@ -106,11 +106,30 @@ export function createVehicleObstacleClamp(options) {
|
|
|
106
106
|
return to;
|
|
107
107
|
const attemptedX = to[0] - from[0];
|
|
108
108
|
const attemptedZ = to[1] - from[1];
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
109
|
+
const current = [from[0], CAR_BODY_MID_Y, from[1]];
|
|
110
|
+
const solids = vehicleView(obstacles);
|
|
111
|
+
const step = resolveObstacleStep(current, attemptedX, attemptedZ, solids, radius);
|
|
112
|
+
if (attemptedX === step.stepX && attemptedZ === step.stepZ)
|
|
113
113
|
return to;
|
|
114
|
+
// The resolver's zero-step escape is the same positional recovery included in `step`.
|
|
115
|
+
// Separate it from motion using this tick's already sampled solids, never a second callback.
|
|
116
|
+
// This preserves the resolver's tangential projection, including diagonal/compound contacts.
|
|
117
|
+
const escape = resolveObstacleStep(current, 0, 0, solids, radius);
|
|
118
|
+
const recovering = escape.stepX !== 0 || escape.stepZ !== 0;
|
|
119
|
+
// Recover first, then collide the motor move at that safe origin. Otherwise the resolver
|
|
120
|
+
// excludes the penetrated box and permits driving straight back into it on every tick.
|
|
121
|
+
const motion = recovering
|
|
122
|
+
? resolveObstacleStep([from[0] + escape.stepX, CAR_BODY_MID_Y, from[1] + escape.stepZ], attemptedX, attemptedZ, solids, radius)
|
|
123
|
+
: step;
|
|
124
|
+
// One bounded recovery pass may meet another overlapping solid. Keep that second escape
|
|
125
|
+
// positional too; overlapping/inconsistent solids never become an unbounded recovery loop.
|
|
126
|
+
const secondaryEscape = recovering
|
|
127
|
+
? resolveObstacleStep([from[0] + escape.stepX, CAR_BODY_MID_Y, from[1] + escape.stepZ], 0, 0, solids, radius)
|
|
128
|
+
: { stepX: 0, stepZ: 0 };
|
|
129
|
+
const motionX = motion.stepX - secondaryEscape.stepX;
|
|
130
|
+
const motionZ = motion.stepZ - secondaryEscape.stepZ;
|
|
131
|
+
const blockedX = attemptedX - motionX;
|
|
132
|
+
const blockedZ = attemptedZ - motionZ;
|
|
114
133
|
const blockedMag = Math.hypot(blockedX, blockedZ);
|
|
115
134
|
if (blockedMag > BLOCK_EPSILON) {
|
|
116
135
|
const dt = options.dt();
|
|
@@ -123,7 +142,12 @@ export function createVehicleObstacleClamp(options) {
|
|
|
123
142
|
};
|
|
124
143
|
}
|
|
125
144
|
}
|
|
126
|
-
|
|
145
|
+
const result = [from[0] + escape.stepX + motion.stepX, from[1] + escape.stepZ + motion.stepZ];
|
|
146
|
+
if (recovering) {
|
|
147
|
+
// Per-result tuples stay stable if a host retains a prior result for diagnostics.
|
|
148
|
+
return Object.assign(result, { motion: [motionX, motionZ] });
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
127
151
|
},
|
|
128
152
|
takeImpact() {
|
|
129
153
|
const impact = pending;
|
|
@@ -1,6 +1,32 @@
|
|
|
1
|
-
import type { ChaseCameraConfig, ChaseView, CinematicCameraConfig } from "../game/playableGame.js";
|
|
1
|
+
import type { CameraRigKind, ChaseCameraConfig, ChaseView, CinematicCameraConfig, GameCameraConfig } from "../game/playableGame.js";
|
|
2
|
+
import type { GameContext } from "./gameContext.js";
|
|
3
|
+
/** Data overlay for an existing rig; callbacks and Canvas projection/preferences remain configured by the game. */
|
|
4
|
+
export type CameraRigConfig = Omit<GameCameraConfig, "rig" | "projection" | "pixelPerfect" | "frustum" | "playerFov" | "weapon" | "onCameraFollow">;
|
|
5
|
+
/** Runtime rig selection and detached data overlay over the configured camera. */
|
|
6
|
+
export interface CameraRigOverride {
|
|
7
|
+
kind: CameraRigKind;
|
|
8
|
+
config: CameraRigConfig;
|
|
9
|
+
}
|
|
10
|
+
/** Camera policy state before the JSON-safe snapshot number encoding. */
|
|
11
|
+
export interface CameraDirectorState {
|
|
12
|
+
followEntityId?: string | null;
|
|
13
|
+
rig: CameraRigOverride | null;
|
|
14
|
+
cinematic: CinematicCameraConfig | null;
|
|
15
|
+
chase: ChaseCameraTuning | null;
|
|
16
|
+
}
|
|
17
|
+
type CameraSerialized<T> = T extends number ? number | {
|
|
18
|
+
$cameraNumber: "Infinity";
|
|
19
|
+
} : T extends object ? {
|
|
20
|
+
[Key in keyof T]: CameraSerialized<T[Key]>;
|
|
21
|
+
} : T;
|
|
22
|
+
/** JSON-safe camera policy; documented instant chase responses use an explicit Infinity marker. */
|
|
23
|
+
export type CameraDirectorSnapshot = CameraSerialized<CameraDirectorState>;
|
|
24
|
+
/** @internal */
|
|
25
|
+
export declare function validateCameraRig(kind: CameraRigKind, config?: CameraRigConfig): void;
|
|
26
|
+
/** @internal */
|
|
27
|
+
export declare function validateChaseCameraTuning(tuning: ChaseCameraTuning | null): void;
|
|
2
28
|
/** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), a whole driving-feel overlay applied only while a vehicle is piloted (#1299), or a `view` switch between chase and seat cameras. */
|
|
3
|
-
export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view">>;
|
|
29
|
+
export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse" | "headingSource" | "distanceBySpeed" | "pitchFollow" | "fovKick" | "lookBackAction" | "collision" | "view">>;
|
|
4
30
|
/** Default order {@link nextChaseView} cycles through. */
|
|
5
31
|
export declare const CHASE_VIEWS: readonly ChaseView[];
|
|
6
32
|
/**
|
|
@@ -15,6 +41,9 @@ export interface CameraDirector {
|
|
|
15
41
|
follow(entityId: string | null): void;
|
|
16
42
|
/** `undefined` means no runtime override — the shell falls back to the static `playable.camera.followEntityId`. `null` means explicitly follow nothing. */
|
|
17
43
|
followedEntityId(): string | null | undefined;
|
|
44
|
+
/** Replace the runtime rig overlay; `null` restores the game's configured rig/config. Existing chase tuning remains independent. */
|
|
45
|
+
setRig(kind: CameraRigKind | null, config?: CameraRigConfig): void;
|
|
46
|
+
rig(): CameraRigOverride | null;
|
|
18
47
|
setCinematic(config: CinematicCameraConfig | null): void;
|
|
19
48
|
cinematic(): CinematicCameraConfig | null;
|
|
20
49
|
/** Overlay a runtime patch on the chase rig's config — a boss-intro pull-back, drift zoom-out; `null` restores the static config. */
|
|
@@ -24,7 +53,15 @@ export interface CameraDirector {
|
|
|
24
53
|
kickFov(degrees: number): void;
|
|
25
54
|
/** Drain the FOV impulses queued since the last call; the shell's chase rig calls this once per frame. */
|
|
26
55
|
takeFovKick(): number;
|
|
56
|
+
snapshot(): CameraDirectorSnapshot;
|
|
57
|
+
/** Invalid state throws before changing any camera field. */
|
|
58
|
+
restore(state: CameraDirectorSnapshot): void;
|
|
59
|
+
/** Clear runtime policy and transient FOV kicks; configured camera data remains untouched. */
|
|
60
|
+
reset(): void;
|
|
27
61
|
subscribe(listener: () => void): () => void;
|
|
28
62
|
}
|
|
29
63
|
/** @internal */
|
|
30
64
|
export declare function createCameraDirector(): CameraDirector;
|
|
65
|
+
/** Local camera policy is saved, never broadcast to another player's presentation. @internal */
|
|
66
|
+
export declare function installCameraPersistence(ctx: GameContext): void;
|
|
67
|
+
export {};
|
|
@@ -1,3 +1,122 @@
|
|
|
1
|
+
const RIG_KINDS = ["orbit", "first", "topDown", "rts", "shoulder", "lockOn", "chase", "observer", "turntable", "sideScroll", "inspection", "none"];
|
|
2
|
+
const number = (value) => typeof value === "number" && Number.isFinite(value);
|
|
3
|
+
const response = (value) => number(value) || value === Number.POSITIVE_INFINITY;
|
|
4
|
+
const boolean = (value) => typeof value === "boolean";
|
|
5
|
+
const string = (value) => typeof value === "string";
|
|
6
|
+
const oneOf = (...values) => (value) => typeof value === "string" && values.includes(value);
|
|
7
|
+
const shape = (fields, required = []) => (value) => {
|
|
8
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
9
|
+
return false;
|
|
10
|
+
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
11
|
+
return false;
|
|
12
|
+
const row = value;
|
|
13
|
+
return required.every((key) => row[key] !== undefined) && Object.entries(row).every(([key, field]) => Object.hasOwn(fields, key) && (field === undefined || fields[key](field)));
|
|
14
|
+
};
|
|
15
|
+
function numbers(...keys) {
|
|
16
|
+
return Object.fromEntries(keys.map((key) => [key, number]));
|
|
17
|
+
}
|
|
18
|
+
const point = shape(numbers("x", "y", "z"));
|
|
19
|
+
const target3d = shape(numbers("x", "y", "z"), ["x", "y", "z"]);
|
|
20
|
+
const topDownFields = {
|
|
21
|
+
...numbers("height", "pitch", "yaw", "followSmoothing"), targetOffset: point,
|
|
22
|
+
zoom: shape(numbers("min", "max", "speed")),
|
|
23
|
+
};
|
|
24
|
+
const chaseFields = (allowInfinity) => ({
|
|
25
|
+
...numbers("distance", "height", "lookHeight", "springDamping", "shakePerSpeed"),
|
|
26
|
+
headingSource: oneOf("body", "input"), view: oneOf("chase", "cockpit", "hood", "rear"),
|
|
27
|
+
yawResponse: allowInfinity ? response : number,
|
|
28
|
+
fov: shape({ ...numbers("base", "max", "speedForMax"), response: allowInfinity ? response : number }),
|
|
29
|
+
distanceBySpeed: shape(numbers("extra", "speedForMax"), ["extra"]),
|
|
30
|
+
pitchFollow: shape(numbers("blend", "max", "response")), fovKick: shape(numbers("decay", "max")),
|
|
31
|
+
lookBackAction: string,
|
|
32
|
+
collision: (value) => boolean(value) || shape(numbers("radius", "minDistance"))(value),
|
|
33
|
+
lead: shape(numbers("time", "max"), ["time"]), bank: shape(numbers("perYawRate", "max", "damping"), ["perYawRate"]),
|
|
34
|
+
velocityYaw: shape(numbers("blend", "minSpeed", "response")),
|
|
35
|
+
});
|
|
36
|
+
const cinematic = shape({
|
|
37
|
+
loop: boolean,
|
|
38
|
+
keyframes: (value) => Array.isArray(value) && value.every(shape({
|
|
39
|
+
position: target3d, lookAt: target3d, ...numbers("fov", "duration"), ease: oneOf("linear", "smooth"),
|
|
40
|
+
}, ["position", "lookAt"])),
|
|
41
|
+
}, ["keyframes"]);
|
|
42
|
+
const rigConfig = shape({
|
|
43
|
+
...numbers("transitionSeconds", "minDistance", "maxDistance", "targetHeight", "initialDistance", "initialHeight", "initialYaw", "initialPitch", "rotateSpeed", "zoomSpeed", "dampingFactor", "targetSmoothing", "dragTargetSmoothing", "distanceSmoothing", "minPolarAngle", "maxPolarAngle"),
|
|
44
|
+
perspective: oneOf("third", "first"), targetOffset: point, followLock: boolean, followEnabled: boolean,
|
|
45
|
+
followEntityId: (value) => value === null || string(value),
|
|
46
|
+
pitchClamp: (value) => Array.isArray(value) && value.length === 2 && value.every(number),
|
|
47
|
+
collision: shape({ enabled: boolean, ...numbers("padding", "minTargetDistance") }),
|
|
48
|
+
firstPerson: shape({ ...numbers("eyeHeight", "sensitivity", "maxPitch"), reticle: boolean, viewmodel: boolean }),
|
|
49
|
+
topDown: shape(topDownFields),
|
|
50
|
+
rts: shape({ ...topDownFields, ...numbers("panSpeed", "rotateSpeed"), pan: boolean,
|
|
51
|
+
edgeScroll: (value) => boolean(value) || shape(numbers("margin", "speed"))(value),
|
|
52
|
+
bounds: shape(numbers("minX", "maxX", "minZ", "maxZ")), start: shape(numbers("x", "z")),
|
|
53
|
+
}),
|
|
54
|
+
shoulder: shape({ ...numbers("shoulderOffset", "heightOffset", "distance", "adsTransitionSpeed", "sensitivity", "fov"),
|
|
55
|
+
side: oneOf("left", "right"), ads: shape(numbers("distance", "shoulderOffset", "fov", "heightOffset")), reticleOffset: shape(numbers("x", "y")),
|
|
56
|
+
}),
|
|
57
|
+
lockOn: shape({ ...numbers("distance", "height", "lookHeight", "framingBias", "yawSmoothing"), targetEntityId: string }),
|
|
58
|
+
chase: shape({ ...chaseFields(true), seatOffsets: shape({ cockpit: point, hood: point, rear: point }) }),
|
|
59
|
+
observer: shape({ ...numbers("distance", "height", "lookHeight", "orbitSpeed", "startAngle", "fov"),
|
|
60
|
+
bind: (value) => shape({ kind: oneOf("entity"), entityId: string }, ["kind", "entityId"])(value) ||
|
|
61
|
+
shape({ kind: oneOf("point"), position: target3d }, ["kind", "position"])(value),
|
|
62
|
+
}),
|
|
63
|
+
turntable: shape({ ...numbers("distance", "height", "lookHeight", "orbitSpeed", "startAngle", "fov"), target: target3d }),
|
|
64
|
+
sideScroll: shape({ ...numbers("distance", "height", "lookHeight", "followSmoothing", "fov"), axis: oneOf("x", "z") }),
|
|
65
|
+
inspection: shape({ ...numbers("initialDistance", "minDistance", "maxDistance", "minPolarAngle", "maxPolarAngle", "rotateSpeed", "zoomSpeed", "dampingFactor"),
|
|
66
|
+
anchor: oneOf("target", "cursor", "center"), target: point, initialPosition: point, pan: boolean,
|
|
67
|
+
}),
|
|
68
|
+
shake: shape(numbers("maxOffset", "maxRoll", "decayPerSecond", "exponent", "frequency")), cinematic,
|
|
69
|
+
});
|
|
70
|
+
/** @internal */
|
|
71
|
+
export function validateCameraRig(kind, config = {}) {
|
|
72
|
+
if (!RIG_KINDS.includes(kind) || !rigConfig(config))
|
|
73
|
+
throw new Error("invalid camera rig override");
|
|
74
|
+
}
|
|
75
|
+
/** @internal */
|
|
76
|
+
export function validateChaseCameraTuning(tuning) {
|
|
77
|
+
if (tuning !== null && !shape(chaseFields(true))(tuning))
|
|
78
|
+
throw new Error("invalid chase camera tuning");
|
|
79
|
+
}
|
|
80
|
+
function reviveNumbers(raw) {
|
|
81
|
+
if (Array.isArray(raw))
|
|
82
|
+
return raw.map(reviveNumbers);
|
|
83
|
+
if (raw === null || typeof raw !== "object")
|
|
84
|
+
return raw;
|
|
85
|
+
if (Object.getPrototypeOf(raw) !== Object.prototype && Object.getPrototypeOf(raw) !== null)
|
|
86
|
+
return raw;
|
|
87
|
+
if (Object.keys(raw).length === 1 && "$cameraNumber" in raw && raw.$cameraNumber === "Infinity")
|
|
88
|
+
return Infinity;
|
|
89
|
+
return Object.fromEntries(Object.entries(raw).map(([key, value]) => [key, reviveNumbers(value)]));
|
|
90
|
+
}
|
|
91
|
+
function decodeCameraState(raw) {
|
|
92
|
+
try {
|
|
93
|
+
raw = reviveNumbers(raw);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
if (raw === null || typeof raw !== "object")
|
|
99
|
+
return null;
|
|
100
|
+
const state = raw;
|
|
101
|
+
if (state.followEntityId !== undefined && state.followEntityId !== null && typeof state.followEntityId !== "string")
|
|
102
|
+
return null;
|
|
103
|
+
if (!("rig" in state) || !("cinematic" in state) || !("chase" in state))
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
if (state.rig !== null) {
|
|
107
|
+
if (!shape({ kind: oneOf(...RIG_KINDS), config: rigConfig }, ["kind", "config"])(state.rig))
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (state.cinematic !== null && !cinematic(state.cinematic))
|
|
111
|
+
return null;
|
|
112
|
+
if (state.chase !== null && !shape(chaseFields(true))(state.chase))
|
|
113
|
+
return null;
|
|
114
|
+
return structuredClone(state);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
1
120
|
/** Default order {@link nextChaseView} cycles through. */
|
|
2
121
|
export const CHASE_VIEWS = ["chase", "hood", "cockpit"];
|
|
3
122
|
/**
|
|
@@ -19,6 +138,7 @@ export function createCameraDirector() {
|
|
|
19
138
|
let followEntity = undefined;
|
|
20
139
|
let cinematicConfig = null;
|
|
21
140
|
let chaseTuningPatch = null;
|
|
141
|
+
let rigOverride = null;
|
|
22
142
|
let pendingFovKick = 0;
|
|
23
143
|
function notify() {
|
|
24
144
|
for (const listener of listeners)
|
|
@@ -30,6 +150,13 @@ export function createCameraDirector() {
|
|
|
30
150
|
notify();
|
|
31
151
|
},
|
|
32
152
|
followedEntityId: () => followEntity,
|
|
153
|
+
setRig(kind, config = {}) {
|
|
154
|
+
if (kind !== null)
|
|
155
|
+
validateCameraRig(kind, config);
|
|
156
|
+
rigOverride = kind === null ? null : { kind, config: structuredClone(config) };
|
|
157
|
+
notify();
|
|
158
|
+
},
|
|
159
|
+
rig: () => rigOverride,
|
|
33
160
|
setCinematic(config) {
|
|
34
161
|
cinematicConfig = config;
|
|
35
162
|
notify();
|
|
@@ -49,9 +176,44 @@ export function createCameraDirector() {
|
|
|
49
176
|
pendingFovKick = 0;
|
|
50
177
|
return kick;
|
|
51
178
|
},
|
|
179
|
+
snapshot: () => JSON.parse(JSON.stringify({ ...(followEntity === undefined ? {} : { followEntityId: followEntity }), rig: rigOverride, cinematic: cinematicConfig, chase: chaseTuningPatch }, (_key, value) => {
|
|
180
|
+
if (value === Infinity)
|
|
181
|
+
return { $cameraNumber: "Infinity" };
|
|
182
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
183
|
+
throw new Error("invalid camera number");
|
|
184
|
+
return value;
|
|
185
|
+
})),
|
|
186
|
+
restore(state) {
|
|
187
|
+
const decoded = decodeCameraState(state);
|
|
188
|
+
if (decoded === null)
|
|
189
|
+
throw new Error("invalid camera director state");
|
|
190
|
+
followEntity = decoded.followEntityId;
|
|
191
|
+
rigOverride = decoded.rig;
|
|
192
|
+
cinematicConfig = decoded.cinematic;
|
|
193
|
+
chaseTuningPatch = decoded.chase;
|
|
194
|
+
pendingFovKick = 0;
|
|
195
|
+
notify();
|
|
196
|
+
},
|
|
197
|
+
reset() {
|
|
198
|
+
followEntity = undefined;
|
|
199
|
+
rigOverride = null;
|
|
200
|
+
cinematicConfig = null;
|
|
201
|
+
chaseTuningPatch = null;
|
|
202
|
+
pendingFovKick = 0;
|
|
203
|
+
notify();
|
|
204
|
+
},
|
|
52
205
|
subscribe(listener) {
|
|
53
206
|
listeners.add(listener);
|
|
54
207
|
return () => listeners.delete(listener);
|
|
55
208
|
},
|
|
56
209
|
};
|
|
57
210
|
}
|
|
211
|
+
/** Local camera policy is saved, never broadcast to another player's presentation. @internal */
|
|
212
|
+
export function installCameraPersistence(ctx) {
|
|
213
|
+
ctx.game.registerSave?.({
|
|
214
|
+
key: "camera",
|
|
215
|
+
snapshot: ctx.camera.snapshot,
|
|
216
|
+
hydrate: ctx.camera.restore,
|
|
217
|
+
decode: decodeCameraState,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CommandResult } from "../commands/commandRegistry.js";
|
|
2
|
+
import type { GameContext } from "./gameContext.js";
|
|
3
|
+
import type { LiveGameBackend } from "./transport.js";
|
|
4
|
+
/** A UI command completes locally or after its authoritative host acknowledges it. */
|
|
5
|
+
export type DispatchedCommandResult = CommandResult<GameContext> | Promise<CommandResult<GameContext>>;
|
|
6
|
+
type Route = {
|
|
7
|
+
transport: LiveGameBackend["transport"];
|
|
8
|
+
serverId: () => string | null;
|
|
9
|
+
};
|
|
10
|
+
/** Bind UI command dispatch to the host for this context. A missing join rejects; it never mutates the local replica. */
|
|
11
|
+
export declare function bindCommandTransport(ctx: GameContext, route: Route): () => void;
|
|
12
|
+
/** Dispatch a player's command through the bound host, or locally for an unbound offline context. Await the result before showing success. */
|
|
13
|
+
export declare function dispatchCommand(ctx: GameContext, name: string, input: unknown): DispatchedCommandResult;
|
|
14
|
+
export {};
|