@jgengine/core 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +265 -2
- package/dist/ai/decisionGraph.d.ts +96 -0
- package/dist/ai/decisionGraph.js +212 -0
- package/dist/ai/groupAssist.d.ts +10 -0
- package/dist/ai/groupAssist.js +11 -0
- package/dist/ai/mobBrainGraph.d.ts +3 -0
- package/dist/ai/mobBrainGraph.js +25 -0
- package/dist/ai/pursuit.d.ts +1 -1
- package/dist/ai/pursuit.js +1 -1
- package/dist/ai/tacticalQueries.d.ts +30 -0
- package/dist/ai/tacticalQueries.js +45 -0
- package/dist/ai/threat.d.ts +8 -0
- package/dist/ai/threat.js +10 -0
- package/dist/anim/animGraph.d.ts +155 -0
- package/dist/anim/animGraph.js +414 -0
- package/dist/anim/boneTexture.d.ts +55 -0
- package/dist/anim/boneTexture.js +43 -0
- package/dist/anim/footPlacement.d.ts +57 -0
- package/dist/anim/footPlacement.js +67 -0
- package/dist/anim/ikSolver.d.ts +51 -0
- package/dist/anim/ikSolver.js +124 -0
- package/dist/anim/locomotionGraph.d.ts +35 -0
- package/dist/anim/locomotionGraph.js +62 -0
- package/dist/assets/spriteAtlas.d.ts +21 -0
- package/dist/assets/spriteAtlas.js +103 -0
- package/dist/audio/audioFalloff.d.ts +12 -0
- package/dist/audio/doppler.d.ts +21 -0
- package/dist/audio/doppler.js +32 -0
- package/dist/audio/engineLayers.d.ts +83 -0
- package/dist/audio/engineLayers.js +115 -0
- package/dist/combat/abilityKit.d.ts +1 -0
- package/dist/combat/abilityKit.js +1 -0
- package/dist/combat/death.d.ts +7 -0
- package/dist/combat/shotOrigin.d.ts +6 -0
- package/dist/combat/shotOrigin.js +6 -0
- package/dist/combat/weaponFeedback.d.ts +36 -0
- package/dist/combat/weaponFeedback.js +42 -0
- package/dist/combat/weaponHandling.d.ts +161 -0
- package/dist/combat/weaponHandling.js +202 -0
- package/dist/combat/weaponPresentation.d.ts +115 -0
- package/dist/combat/weaponPresentation.js +96 -0
- package/dist/devtools/warnOnce.d.ts +8 -0
- package/dist/devtools/warnOnce.js +18 -0
- package/dist/economy/currency.d.ts +16 -2
- package/dist/economy/currency.js +55 -14
- package/dist/economy/wallet.d.ts +5 -4
- package/dist/economy/wallet.js +8 -4
- package/dist/editor/commandApply.js +1 -0
- package/dist/editor/commands.d.ts +4 -1
- package/dist/editor/document.d.ts +2 -1
- package/dist/editor/document.js +92 -1
- package/dist/editor/environment.d.ts +3 -0
- package/dist/editor/environment.js +24 -0
- package/dist/editor/index.d.ts +4 -2
- package/dist/editor/index.js +1 -1
- package/dist/editor/types.d.ts +20 -0
- package/dist/faction/reputation.d.ts +6 -0
- package/dist/faction/reputation.js +11 -0
- package/dist/game/cameraConfig.d.ts +59 -1
- package/dist/game/chat.d.ts +22 -1
- package/dist/game/chat.js +48 -19
- package/dist/game/controlGate.d.ts +6 -0
- package/dist/game/controlGate.js +30 -1
- package/dist/game/defineGame.d.ts +10 -1
- package/dist/game/events.d.ts +14 -1
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +16 -0
- package/dist/game/pixelPerfect.d.ts +25 -0
- package/dist/game/pixelPerfect.js +29 -0
- package/dist/game/playableGame.d.ts +167 -6
- package/dist/game/systemRuntime.js +6 -1
- package/dist/game/toasts.d.ts +8 -0
- package/dist/game/toasts.js +7 -0
- package/dist/game/vfxInstance.d.ts +9 -0
- package/dist/game/vfxInstance.js +12 -1
- package/dist/game/viewports.d.ts +43 -0
- package/dist/game/viewports.js +46 -0
- package/dist/game/worldItem.d.ts +5 -1
- package/dist/game/worldItem.js +0 -1
- package/dist/input/actionBindings.d.ts +17 -2
- package/dist/input/actionBindings.js +24 -11
- package/dist/input/actionContexts.d.ts +45 -0
- package/dist/input/actionContexts.js +113 -0
- package/dist/input/axisInput.d.ts +4 -0
- package/dist/input/axisInput.js +4 -0
- package/dist/input/axisShaper.d.ts +73 -0
- package/dist/input/axisShaper.js +105 -0
- package/dist/input/gamepadModel.d.ts +79 -0
- package/dist/input/gamepadModel.js +108 -0
- package/dist/input/haptics.d.ts +50 -0
- package/dist/input/haptics.js +74 -0
- package/dist/input/inputBuffer.d.ts +40 -0
- package/dist/input/inputBuffer.js +59 -0
- package/dist/input/lookChannel.d.ts +10 -0
- package/dist/input/lookChannel.js +6 -0
- package/dist/input/pointer.d.ts +2 -0
- package/dist/input/pointer.js +4 -0
- package/dist/item/affix.d.ts +6 -0
- package/dist/item/affix.js +6 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +196 -1
- package/dist/movement/characterController.d.ts +76 -0
- package/dist/movement/characterController.js +216 -0
- package/dist/movement/freeFlight.d.ts +119 -0
- package/dist/movement/freeFlight.js +294 -0
- package/dist/movement/glideModel.d.ts +3 -0
- package/dist/movement/glideModel.js +6 -0
- package/dist/movement/grappleSwing.d.ts +2 -0
- package/dist/movement/grappleSwing.js +5 -1
- package/dist/movement/movementModel.d.ts +39 -0
- package/dist/movement/movementModel.js +62 -19
- package/dist/movement/movementProbe.d.ts +51 -0
- package/dist/movement/movementProbe.js +119 -0
- package/dist/movement/playerMovement.d.ts +30 -3
- package/dist/movement/playerMovement.js +304 -7
- package/dist/movement/serverStep.d.ts +5 -0
- package/dist/movement/serverStep.js +6 -0
- package/dist/movement/solidObstacles.d.ts +39 -8
- package/dist/movement/solidObstacles.js +71 -6
- package/dist/movement/voxelController.js +7 -7
- package/dist/multiplayer/poseSyncGate.d.ts +3 -1
- package/dist/multiplayer/poseSyncGate.js +9 -1
- package/dist/multiplayer/presenceContract.d.ts +6 -0
- package/dist/multiplayer/presenceModel.d.ts +2 -0
- package/dist/multiplayer/presenceModel.js +11 -0
- package/dist/nav/navFromEnvironment.d.ts +9 -3
- package/dist/nav/navFromEnvironment.js +18 -12
- package/dist/nav/navMesh.d.ts +74 -0
- package/dist/nav/navMesh.js +570 -0
- package/dist/physics/aircraftDynamics.d.ts +390 -0
- package/dist/physics/aircraftDynamics.js +704 -0
- package/dist/physics/boatDynamics.d.ts +116 -0
- package/dist/physics/boatDynamics.js +160 -0
- package/dist/physics/drivableVehicle.d.ts +22 -8
- package/dist/physics/drivableVehicle.js +4 -2
- package/dist/physics/flightDynamics.d.ts +29 -4
- package/dist/physics/flightDynamics.js +28 -5
- package/dist/physics/handlingProbe.d.ts +249 -0
- package/dist/physics/handlingProbe.js +606 -0
- package/dist/physics/kinematicVehicle.d.ts +37 -2
- package/dist/physics/kinematicVehicle.js +15 -0
- package/dist/physics/physicsBackend.d.ts +203 -0
- package/dist/physics/physicsBackend.js +49 -0
- package/dist/physics/physicsBackendConformance.d.ts +27 -0
- package/dist/physics/physicsBackendConformance.js +151 -0
- package/dist/physics/physicsWorldBackend.d.ts +45 -0
- package/dist/physics/physicsWorldBackend.js +518 -0
- package/dist/physics/vehicleBackendLink.d.ts +49 -0
- package/dist/physics/vehicleBackendLink.js +102 -0
- package/dist/physics/vehicleDynamics.d.ts +374 -0
- package/dist/physics/vehicleDynamics.js +737 -0
- package/dist/physics/worldColliders.d.ts +13 -0
- package/dist/physics/worldColliders.js +128 -0
- package/dist/puzzle/cellGrid.d.ts +15 -9
- package/dist/puzzle/cellGrid.js +13 -9
- package/dist/random/rng.d.ts +12 -1
- package/dist/random/rng.js +20 -3
- package/dist/render/environment.d.ts +23 -0
- package/dist/render/environment.js +1 -0
- package/dist/render/lookPreset.d.ts +19 -6
- package/dist/render/lookPreset.js +56 -4
- package/dist/render/postProcessing.d.ts +40 -1
- package/dist/render/postProcessing.js +1 -1
- package/dist/render/sprite2d.d.ts +38 -0
- package/dist/render/sprite2d.js +40 -0
- package/dist/runtime/adapter.d.ts +20 -1
- package/dist/runtime/adapter.js +8 -1
- package/dist/runtime/cameraDirector.d.ts +17 -3
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/commandInput.d.ts +52 -0
- package/dist/runtime/commandInput.js +71 -0
- package/dist/runtime/commandRunner.d.ts +39 -7
- package/dist/runtime/commandRunner.js +35 -5
- package/dist/runtime/context/combat.d.ts +1 -0
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/deathLoot.d.ts +2 -0
- package/dist/runtime/context/deathLoot.js +1 -0
- package/dist/runtime/gameContext.js +18 -1
- package/dist/runtime/gameContextTypes.d.ts +33 -7
- package/dist/runtime/gameRuntime.d.ts +23 -5
- package/dist/runtime/gameRuntime.js +21 -4
- package/dist/runtime/headlessRunner.d.ts +10 -0
- package/dist/runtime/headlessRunner.js +26 -8
- package/dist/runtime/hostPolicy.d.ts +3 -2
- package/dist/runtime/hostPolicy.js +8 -10
- package/dist/runtime/hostedGameRunner.js +34 -6
- package/dist/runtime/hostedWorldSession.d.ts +12 -16
- package/dist/runtime/hostedWorldSession.js +29 -6
- package/dist/runtime/hostedWorldStore.d.ts +20 -0
- package/dist/runtime/hostedWorldStore.js +1 -0
- package/dist/runtime/inputRecorder.d.ts +30 -0
- package/dist/runtime/inputRecorder.js +59 -0
- package/dist/runtime/inputSnapshot.d.ts +14 -0
- package/dist/runtime/inputSnapshot.js +11 -0
- package/dist/runtime/localPlayers.d.ts +73 -0
- package/dist/runtime/localPlayers.js +119 -0
- package/dist/runtime/poseInterpolation.d.ts +36 -0
- package/dist/runtime/poseInterpolation.js +91 -0
- package/dist/runtime/prediction.d.ts +45 -0
- package/dist/runtime/prediction.js +62 -0
- package/dist/runtime/simContext.d.ts +45 -0
- package/dist/runtime/simContext.js +65 -0
- package/dist/runtime/simLoop.d.ts +52 -0
- package/dist/runtime/simLoop.js +72 -0
- package/dist/runtime/simSnapshot.d.ts +32 -0
- package/dist/runtime/simSnapshot.js +60 -0
- package/dist/runtime/snapshot.d.ts +8 -0
- package/dist/runtime/snapshotBuffer.d.ts +33 -0
- package/dist/runtime/snapshotBuffer.js +58 -0
- package/dist/runtime/transport.d.ts +41 -2
- package/dist/runtime/transport.js +7 -1
- package/dist/runtime/worldChunks.d.ts +2 -0
- package/dist/runtime/worldChunks.js +14 -0
- package/dist/runtime/worldProjection.d.ts +9 -0
- package/dist/runtime/worldProjection.js +17 -1
- package/dist/runtime/worldSnapshot.d.ts +6 -0
- package/dist/runtime/worldSnapshot.js +3 -0
- package/dist/scene/behaviorRuntime.d.ts +29 -1
- package/dist/scene/behaviorRuntime.js +71 -4
- package/dist/scene/behaviors.d.ts +12 -1
- package/dist/scene/builtinSceneKinds.js +10 -0
- package/dist/scene/sceneKinds.d.ts +9 -0
- package/dist/scene/vehicleSeat.d.ts +5 -1
- package/dist/scene/vehicleSeat.js +5 -1
- package/dist/sensor/concealment.d.ts +6 -0
- package/dist/sensor/concealment.js +8 -0
- package/dist/sensor/freezeMonitor.d.ts +6 -0
- package/dist/sensor/freezeMonitor.js +8 -0
- package/dist/sensor/perception.d.ts +70 -0
- package/dist/sensor/perception.js +189 -0
- package/dist/sensor/recordingBuffer.d.ts +3 -0
- package/dist/sensor/recordingBuffer.js +8 -0
- package/dist/session/roundState.d.ts +4 -0
- package/dist/session/roundState.js +11 -0
- package/dist/settings/frameRateLimit.d.ts +16 -0
- package/dist/settings/frameRateLimit.js +37 -0
- package/dist/settings/graphicsProfile.d.ts +21 -0
- package/dist/settings/graphicsProfile.js +48 -0
- package/dist/settings/graphicsSettings.d.ts +40 -0
- package/dist/settings/graphicsSettings.js +81 -0
- package/dist/settings/settingsModel.d.ts +9 -1
- package/dist/settings/settingsModel.js +10 -0
- package/dist/stats/eventMeter.d.ts +1 -1
- package/dist/stats/eventMeter.js +1 -1
- package/dist/time/accrueSince.d.ts +10 -0
- package/dist/time/accrueSince.js +9 -0
- package/dist/time/rateWindow.d.ts +16 -0
- package/dist/time/rateWindow.js +14 -0
- package/dist/time/serverTick.d.ts +21 -3
- package/dist/time/serverTick.js +14 -13
- package/dist/vfx/feedbackMixer.d.ts +61 -0
- package/dist/vfx/feedbackMixer.js +106 -0
- package/dist/vfx/particleDirector.d.ts +68 -0
- package/dist/vfx/particleDirector.js +81 -0
- package/dist/visibility/simulationCulling.d.ts +8 -1
- package/dist/visibility/simulationCulling.js +7 -0
- package/dist/visibility/spatialIndex.d.ts +21 -0
- package/dist/visibility/spatialIndex.js +16 -0
- package/dist/world/authoredSolids.d.ts +46 -0
- package/dist/world/authoredSolids.js +75 -0
- package/dist/world/buildingGenerator.js +2 -2
- package/dist/world/buildingKit.d.ts +3 -1
- package/dist/world/buildings.d.ts +46 -2
- package/dist/world/buildings.js +53 -3
- package/dist/world/cityContent.js +103 -25
- package/dist/world/cityKind.d.ts +139 -0
- package/dist/world/cityKind.js +367 -16
- package/dist/world/features.d.ts +56 -3
- package/dist/world/features.js +13 -2
- package/dist/world/footprintGrid.d.ts +7 -0
- package/dist/world/footprintGrid.js +16 -0
- package/dist/world/interiors.d.ts +1 -0
- package/dist/world/interiors.js +5 -3
- package/dist/world/lod.d.ts +6 -0
- package/dist/world/lod.js +8 -0
- package/dist/world/placement.d.ts +7 -0
- package/dist/world/placement.js +2 -0
- package/dist/world/placementController.d.ts +1 -1
- package/dist/world/placementController.js +7 -0
- package/dist/world/raceCircuit.d.ts +168 -0
- package/dist/world/raceCircuit.js +797 -0
- package/dist/world/terraform.d.ts +8 -0
- package/dist/world/terraform.js +7 -0
- package/dist/world/terrain.d.ts +5 -1
- package/dist/world/terrain.js +5 -0
- package/dist/world/territory.d.ts +97 -0
- package/dist/world/territory.js +182 -0
- package/dist/world/walls.d.ts +7 -0
- package/dist/world/walls.js +7 -0
- package/dist/world/worldSolids.d.ts +84 -0
- package/dist/world/worldSolids.js +240 -0
- package/dist/world.d.ts +5 -3
- package/dist/world.js +4 -2
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +2 -2
- package/skills/game-design/SKILL.md +11 -1
- package/skills/jgengine/SKILL.md +9 -5
- package/skills/jgengine/api.md +205 -101
- package/skills/jgengine/capabilities.md +56 -0
- package/skills/jgengine/recipes/minimal-game.md +3 -0
- package/skills/jgengine-combat/SKILL.md +5 -0
- package/skills/jgengine-combat/api.md +138 -103
- package/skills/jgengine-combat/capabilities.md +29 -1
- package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
- package/skills/jgengine-gameplay/SKILL.md +10 -0
- package/skills/jgengine-gameplay/api.md +501 -453
- package/skills/jgengine-gameplay/capabilities.md +36 -0
- package/skills/jgengine-gameplay/reference-systems.md +4 -0
- package/skills/jgengine-world/SKILL.md +21 -3
- package/skills/jgengine-world/api.md +1172 -625
- package/skills/jgengine-world/capabilities.md +229 -3
- package/skills/jgengine-world/recipes/character-animation.md +39 -0
- package/skills/jgengine-world/recipes/controls.md +44 -0
- package/skills/jgengine-world/recipes/flight-feel.md +76 -0
- package/skills/jgengine-world/recipes/npc-senses.md +43 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
- package/skills/jgengine-world/reference.md +31 -2
- package/skills/jgengine-world/visibility.md +112 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
function indexLayers(layers) {
|
|
2
|
+
const byRpm = (a, b) => layers[a].rpm - layers[b].rpm;
|
|
3
|
+
const index = { on: [], off: [], any: [] };
|
|
4
|
+
layers.forEach((layer, i) => index[layer.load ?? "any"].push(i));
|
|
5
|
+
if (index.on.length === 0 || index.off.length === 0) {
|
|
6
|
+
index.any.push(...index.on, ...index.off);
|
|
7
|
+
index.on = [];
|
|
8
|
+
index.off = [];
|
|
9
|
+
}
|
|
10
|
+
index.on.sort(byRpm);
|
|
11
|
+
index.off.sort(byRpm);
|
|
12
|
+
index.any.sort(byRpm);
|
|
13
|
+
return index;
|
|
14
|
+
}
|
|
15
|
+
function smoothstep(edge0, edge1, x) {
|
|
16
|
+
if (edge1 <= edge0)
|
|
17
|
+
return x >= edge1 ? 1 : 0;
|
|
18
|
+
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
|
|
19
|
+
return t * t * (3 - 2 * t);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Layered engine sound: each set of rpm-keyed samples is equal-power crossfaded between the two
|
|
23
|
+
* samples bracketing the current rpm and pitched by `rpm / layer.rpm`; the on- and off-load sets are
|
|
24
|
+
* equal-power crossfaded by smoothed `engineLoad`. A config with only one load set plays it at every
|
|
25
|
+
* load. `play` drives `ctx.game.audio` directly, so a game needs no per-layer glue.
|
|
26
|
+
*
|
|
27
|
+
* @capability engine-layers layered engine audio: rpm-keyed samples crossfaded, with on-load and off-load sets picked by engine load
|
|
28
|
+
*/
|
|
29
|
+
export function createEngineLayers(initial) {
|
|
30
|
+
let config = initial;
|
|
31
|
+
let index = indexLayers(config.layers);
|
|
32
|
+
let mixes = [];
|
|
33
|
+
let load = 0;
|
|
34
|
+
const params = {};
|
|
35
|
+
function rebuild() {
|
|
36
|
+
index = indexLayers(config.layers);
|
|
37
|
+
mixes = config.layers.map((layer, i) => ({ id: `${config.id}:${i}`, sound: layer.sound, gain: 0, rate: 1 }));
|
|
38
|
+
}
|
|
39
|
+
rebuild();
|
|
40
|
+
function crossfade(set, rpm, weight) {
|
|
41
|
+
if (set.length === 0 || weight <= 0)
|
|
42
|
+
return;
|
|
43
|
+
const layers = config.layers;
|
|
44
|
+
let upper = 0;
|
|
45
|
+
while (upper < set.length && layers[set[upper]].rpm < rpm)
|
|
46
|
+
upper += 1;
|
|
47
|
+
if (upper === 0 || upper === set.length) {
|
|
48
|
+
const only = set[upper === 0 ? 0 : set.length - 1];
|
|
49
|
+
mixes[only].gain += weight;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const lowIndex = set[upper - 1];
|
|
53
|
+
const highIndex = set[upper];
|
|
54
|
+
const low = layers[lowIndex].rpm;
|
|
55
|
+
const high = layers[highIndex].rpm;
|
|
56
|
+
const t = high > low ? (rpm - low) / (high - low) : 1;
|
|
57
|
+
mixes[lowIndex].gain += weight * Math.cos((t * Math.PI) / 2);
|
|
58
|
+
mixes[highIndex].gain += weight * Math.sin((t * Math.PI) / 2);
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
update(dt, signals) {
|
|
62
|
+
const rpm = Number.isFinite(signals.rpm) ? Math.max(0, signals.rpm) : 0;
|
|
63
|
+
const target = Number.isFinite(signals.load) ? Math.min(1, Math.max(0, signals.load)) : 0;
|
|
64
|
+
const response = config.loadResponse ?? 10;
|
|
65
|
+
load = Number.isFinite(response) ? load + (target - load) * (1 - Math.exp(-response * Math.max(0, dt))) : target;
|
|
66
|
+
const [from, to] = config.loadCrossfade ?? [0.15, 0.6];
|
|
67
|
+
const onWeight = smoothstep(from, to, load);
|
|
68
|
+
for (let i = 0; i < mixes.length; i += 1) {
|
|
69
|
+
const layer = config.layers[i];
|
|
70
|
+
mixes[i].gain = 0;
|
|
71
|
+
mixes[i].rate = layer.rpm > 0 ? rpm / layer.rpm : 1;
|
|
72
|
+
}
|
|
73
|
+
crossfade(index.any, rpm, 1);
|
|
74
|
+
crossfade(index.on, rpm, Math.sin((onWeight * Math.PI) / 2));
|
|
75
|
+
crossfade(index.off, rpm, Math.cos((onWeight * Math.PI) / 2));
|
|
76
|
+
for (let i = 0; i < mixes.length; i += 1)
|
|
77
|
+
mixes[i].gain *= config.layers[i].gain ?? 1;
|
|
78
|
+
return mixes;
|
|
79
|
+
},
|
|
80
|
+
play(audio, options = {}) {
|
|
81
|
+
const scale = options.gain ?? 1;
|
|
82
|
+
const loopOptions = options.at === undefined ? undefined : { at: options.at };
|
|
83
|
+
params.at = options.at;
|
|
84
|
+
params.velocity = options.velocity;
|
|
85
|
+
params.lowpass = options.lowpass;
|
|
86
|
+
params.highpass = options.highpass;
|
|
87
|
+
for (const mix of mixes) {
|
|
88
|
+
audio.loop(mix.id, mix.sound, loopOptions);
|
|
89
|
+
params.rate = mix.rate;
|
|
90
|
+
params.gain = mix.gain * scale;
|
|
91
|
+
audio.setLoop(mix.id, params);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
stop(audio) {
|
|
95
|
+
for (const mix of mixes)
|
|
96
|
+
audio.stopLoop(mix.id);
|
|
97
|
+
},
|
|
98
|
+
mix: () => mixes,
|
|
99
|
+
retune(next) {
|
|
100
|
+
config = next;
|
|
101
|
+
rebuild();
|
|
102
|
+
},
|
|
103
|
+
snapshot: () => ({ load }),
|
|
104
|
+
restore(state) {
|
|
105
|
+
load = state.load;
|
|
106
|
+
},
|
|
107
|
+
reset() {
|
|
108
|
+
load = 0;
|
|
109
|
+
for (const mix of mixes) {
|
|
110
|
+
mix.gain = 0;
|
|
111
|
+
mix.rate = 1;
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -76,5 +76,6 @@ export interface AbilityKit {
|
|
|
76
76
|
* A bar of cooldown-gated abilities the player fires by slot, tracking readiness and cooldown per ability.
|
|
77
77
|
*
|
|
78
78
|
* @capability ability-bar a bar of cooldown-gated abilities the player triggers by slot
|
|
79
|
+
* @capability global-cooldown share one cooldown across every ability slot (MMO global cooldown) with a cooldown group
|
|
79
80
|
*/
|
|
80
81
|
export declare function createAbilityKit(configs: readonly AbilitySlotConfig[], options?: AbilityKitOptions): AbilityKit;
|
|
@@ -43,6 +43,7 @@ function snapshotOf(runtime, resourceAvailable, group) {
|
|
|
43
43
|
* A bar of cooldown-gated abilities the player fires by slot, tracking readiness and cooldown per ability.
|
|
44
44
|
*
|
|
45
45
|
* @capability ability-bar a bar of cooldown-gated abilities the player triggers by slot
|
|
46
|
+
* @capability global-cooldown share one cooldown across every ability slot (MMO global cooldown) with a cooldown group
|
|
46
47
|
*/
|
|
47
48
|
export function createAbilityKit(configs, options = {}) {
|
|
48
49
|
const boundResource = options.resource;
|
package/dist/combat/death.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface OnDeathCommandRule {
|
|
|
18
18
|
};
|
|
19
19
|
}
|
|
20
20
|
export type DropMode = "grant" | "world";
|
|
21
|
+
/**
|
|
22
|
+
* Catalog `onDeath` for an entity: loot tables to roll, `dropMode: "world"` to scatter drops on the ground
|
|
23
|
+
* (rendered with loot beams) instead of granting them into the killer's bag, and commands to run. Fires only
|
|
24
|
+
* when the entity dies through `ctx.scene.entity.effect`, not a raw `stats.delta`.
|
|
25
|
+
*
|
|
26
|
+
* @capability on-death-drops make a killed enemy drop loot on the ground or into the killer's bag via catalog onDeath
|
|
27
|
+
*/
|
|
21
28
|
export interface OnDeathSpec {
|
|
22
29
|
drops?: string | OnDeathDropRule[];
|
|
23
30
|
command?: string | OnDeathCommandRule;
|
|
@@ -64,6 +64,12 @@ export declare const DEFAULT_EYE_HEIGHT: number;
|
|
|
64
64
|
export declare function eyeHeightFromColliders(set: EntityColliderSet | null | undefined): number;
|
|
65
65
|
export declare function aimDirection(aim: Aim): EntityPosition | null;
|
|
66
66
|
export declare function aimSpreadDeg(aim: Aim): number;
|
|
67
|
+
/**
|
|
68
|
+
* Resolves a shot's world origin and direction from the shooter's eye or muzzle and an {@link Aim}, ready for
|
|
69
|
+
* `ctx.scene.raycast` — the aim half of a hitscan weapon.
|
|
70
|
+
*
|
|
71
|
+
* @capability hitscan-shot aim a hitscan shot (shoot a gun): resolve origin and direction from the eye or muzzle for a raycast
|
|
72
|
+
*/
|
|
67
73
|
export declare function resolveShot(deps: ShotOriginDeps, from: string, aim: Aim, policy?: ShotOriginPolicy): ResolvedShot | null;
|
|
68
74
|
/**
|
|
69
75
|
* Resolves a `converge` shot with scene knowledge: fires from the gun muzzle but bends the direction
|
|
@@ -29,6 +29,12 @@ export function aimDirection(aim) {
|
|
|
29
29
|
export function aimSpreadDeg(aim) {
|
|
30
30
|
return "origin" in aim ? 0 : aim.spread ?? 0;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Resolves a shot's world origin and direction from the shooter's eye or muzzle and an {@link Aim}, ready for
|
|
34
|
+
* `ctx.scene.raycast` — the aim half of a hitscan weapon.
|
|
35
|
+
*
|
|
36
|
+
* @capability hitscan-shot aim a hitscan shot (shoot a gun): resolve origin and direction from the eye or muzzle for a raycast
|
|
37
|
+
*/
|
|
32
38
|
export function resolveShot(deps, from, aim, policy = { kind: "eye" }) {
|
|
33
39
|
const aimDir = aimDirection(aim);
|
|
34
40
|
if (aimDir === null && policy.kind !== "camera" && policy.kind !== "world")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { WeaponHandlingFrame, WeaponShot } from "./weaponHandling.js";
|
|
2
|
+
/**
|
|
3
|
+
* Signals {@link createWeaponFeedbackSignals} reports each tick, named for `createFeedbackMixer` routes.
|
|
4
|
+
* `shot`, `kick` and `impact` are pulses that read non-zero only on the tick they happened; the rest follow
|
|
5
|
+
* the handling frame.
|
|
6
|
+
*/
|
|
7
|
+
export type WeaponFeedbackSignal = "shot" | "kick" | "impact" | "recoil" | "cameraKick" | "spread" | "ads";
|
|
8
|
+
/** Serializable pending pulses, so a snapshot taken between a shot and the next read loses nothing. */
|
|
9
|
+
export interface WeaponFeedbackState {
|
|
10
|
+
shots: number;
|
|
11
|
+
kick: number;
|
|
12
|
+
impact: number;
|
|
13
|
+
}
|
|
14
|
+
/** Collects weapon events between presentation ticks and hands them to a feedback mixer as one signal record. */
|
|
15
|
+
export interface WeaponFeedbackSignals {
|
|
16
|
+
/** Record a shot from `WeaponHandling.fire()`. Several shots before one `read` sum. */
|
|
17
|
+
shot(shot: WeaponShot): void;
|
|
18
|
+
/** Record a hit landing, in the game's own units (damage, `0..1` strength). Several impacts keep the largest. */
|
|
19
|
+
impact(strength: number): void;
|
|
20
|
+
/**
|
|
21
|
+
* Return this tick's signals and clear the pulses. `frame` (from `WeaponHandling.tick`) fills `recoil`,
|
|
22
|
+
* `cameraKick`, `spread` and `ads`; without it they read `0`. The record is reused between calls.
|
|
23
|
+
*/
|
|
24
|
+
read(frame?: WeaponHandlingFrame): Readonly<Record<WeaponFeedbackSignal, number>>;
|
|
25
|
+
snapshot(): WeaponFeedbackState;
|
|
26
|
+
restore(state: WeaponFeedbackState): void;
|
|
27
|
+
reset(): void;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Creates {@link WeaponFeedbackSignals}: the bridge from shots, recoil kick and impacts to `createFeedbackMixer`
|
|
31
|
+
* routes (camera shake, audio, rumble). Route `kick` with no smoothing into camera-shake trauma, `impact` with
|
|
32
|
+
* a `release` into rumble, and `ads`/`spread` into continuous audio or FOV targets. Deterministic and
|
|
33
|
+
* allocation-free per read.
|
|
34
|
+
* @capability weapon-feedback turn weapon shots, recoil kick and impacts into per-tick feedback-mixer signals for camera shake, audio and rumble
|
|
35
|
+
*/
|
|
36
|
+
export declare function createWeaponFeedbackSignals(): WeaponFeedbackSignals;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates {@link WeaponFeedbackSignals}: the bridge from shots, recoil kick and impacts to `createFeedbackMixer`
|
|
3
|
+
* routes (camera shake, audio, rumble). Route `kick` with no smoothing into camera-shake trauma, `impact` with
|
|
4
|
+
* a `release` into rumble, and `ads`/`spread` into continuous audio or FOV targets. Deterministic and
|
|
5
|
+
* allocation-free per read.
|
|
6
|
+
* @capability weapon-feedback turn weapon shots, recoil kick and impacts into per-tick feedback-mixer signals for camera shake, audio and rumble
|
|
7
|
+
*/
|
|
8
|
+
export function createWeaponFeedbackSignals() {
|
|
9
|
+
const pending = { shots: 0, kick: 0, impact: 0 };
|
|
10
|
+
const out = { shot: 0, kick: 0, impact: 0, recoil: 0, cameraKick: 0, spread: 0, ads: 0 };
|
|
11
|
+
return {
|
|
12
|
+
shot(shot) {
|
|
13
|
+
pending.shots += 1;
|
|
14
|
+
pending.kick += Math.hypot(shot.kickPitch, shot.kickYaw);
|
|
15
|
+
},
|
|
16
|
+
impact(strength) {
|
|
17
|
+
pending.impact = Math.max(pending.impact, strength);
|
|
18
|
+
},
|
|
19
|
+
read(frame) {
|
|
20
|
+
out.shot = pending.shots;
|
|
21
|
+
out.kick = pending.kick;
|
|
22
|
+
out.impact = pending.impact;
|
|
23
|
+
out.recoil = frame === undefined ? 0 : Math.hypot(frame.aimPitch, frame.aimYaw);
|
|
24
|
+
out.cameraKick = frame === undefined ? 0 : Math.hypot(frame.cameraPitch, frame.cameraYaw);
|
|
25
|
+
out.spread = frame?.spread ?? 0;
|
|
26
|
+
out.ads = frame?.adsProgress ?? 0;
|
|
27
|
+
pending.shots = 0;
|
|
28
|
+
pending.kick = 0;
|
|
29
|
+
pending.impact = 0;
|
|
30
|
+
return out;
|
|
31
|
+
},
|
|
32
|
+
snapshot: () => ({ ...pending }),
|
|
33
|
+
restore(state) {
|
|
34
|
+
Object.assign(pending, state);
|
|
35
|
+
},
|
|
36
|
+
reset() {
|
|
37
|
+
pending.shots = 0;
|
|
38
|
+
pending.kick = 0;
|
|
39
|
+
pending.impact = 0;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/** Recoil per shot, in radians of aim. `pitch > 0` climbs the muzzle, `yaw > 0` pulls right. */
|
|
2
|
+
export interface WeaponRecoilTuning {
|
|
3
|
+
/**
|
|
4
|
+
* Fixed kick per shot index as `[pitch, yaw]` rad — the learnable part of a spray pattern. Past its end the
|
|
5
|
+
* last entry repeats. Omit for a flat `pitch` kick every shot.
|
|
6
|
+
*/
|
|
7
|
+
pattern?: readonly (readonly [number, number])[];
|
|
8
|
+
/** Kick per shot when there is no `pattern`, rad (default `0`). */
|
|
9
|
+
pitch?: number;
|
|
10
|
+
/** Random extra kick, rad, drawn per shot from the injected `random` within ±this cone (default `0`). */
|
|
11
|
+
randomCone?: number;
|
|
12
|
+
/** Share of each kick that moves the camera rather than the aim, `0..1` (default `0`: all aim). */
|
|
13
|
+
cameraShare?: number;
|
|
14
|
+
/** Rate the accumulated kick recovers toward centre, rad/s (default `0`: none). */
|
|
15
|
+
recoverRate?: number;
|
|
16
|
+
/** Seconds after a shot before recovery starts (default `0.1`). */
|
|
17
|
+
recoverDelay?: number;
|
|
18
|
+
/** Aim kick multiplier while aiming down sights (default `1`). */
|
|
19
|
+
adsScale?: number;
|
|
20
|
+
}
|
|
21
|
+
/** Cone of fire, as a half-angle in radians. */
|
|
22
|
+
export interface WeaponSpreadTuning {
|
|
23
|
+
/** Resting spread, rad. */
|
|
24
|
+
base: number;
|
|
25
|
+
/** Spread added per shot (bloom), rad (default `0`). */
|
|
26
|
+
perShot?: number;
|
|
27
|
+
/** Largest bloom, rad (default `base + 10 · perShot`). */
|
|
28
|
+
max?: number;
|
|
29
|
+
/** Rate bloom recovers toward `base`, rad/s (default `0`). */
|
|
30
|
+
recoverRate?: number;
|
|
31
|
+
/** Seconds after a shot before bloom recovers (default `0.1`). */
|
|
32
|
+
recoverDelay?: number;
|
|
33
|
+
/** Multipliers on the final spread for stance and aim; each defaults to `1`. */
|
|
34
|
+
ads?: number;
|
|
35
|
+
crouching?: number;
|
|
36
|
+
moving?: number;
|
|
37
|
+
airborne?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Tuning for {@link createWeaponHandling}. Every number is an angle in radians or a time in seconds, so two
|
|
41
|
+
* weapons differ by numbers a person can reason about: a controllable rifle climbs a little and recovers
|
|
42
|
+
* fast; a shotgun kicks hard and blooms wide.
|
|
43
|
+
*/
|
|
44
|
+
export interface WeaponHandlingTuning {
|
|
45
|
+
recoil?: WeaponRecoilTuning;
|
|
46
|
+
spread: WeaponSpreadTuning;
|
|
47
|
+
/** Seconds to go fully into or out of aim-down-sights (default `0.2`). */
|
|
48
|
+
adsTime?: number;
|
|
49
|
+
}
|
|
50
|
+
/** Stance and aim for one tick. */
|
|
51
|
+
export interface WeaponStance {
|
|
52
|
+
ads?: boolean;
|
|
53
|
+
crouching?: boolean;
|
|
54
|
+
moving?: boolean;
|
|
55
|
+
airborne?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/** Serializable handling state. */
|
|
58
|
+
export interface WeaponHandlingState {
|
|
59
|
+
aimPitch: number;
|
|
60
|
+
aimYaw: number;
|
|
61
|
+
cameraPitch: number;
|
|
62
|
+
cameraYaw: number;
|
|
63
|
+
bloom: number;
|
|
64
|
+
shotIndex: number;
|
|
65
|
+
sinceShot: number;
|
|
66
|
+
adsProgress: number;
|
|
67
|
+
}
|
|
68
|
+
/** What one shot did: the cone to sample and the kick it applied. */
|
|
69
|
+
export interface WeaponShot {
|
|
70
|
+
/** Half-angle of the cone this shot fires into, rad, with stance multipliers applied. */
|
|
71
|
+
spread: number;
|
|
72
|
+
/** Aim kick applied by this shot, rad. */
|
|
73
|
+
kickPitch: number;
|
|
74
|
+
kickYaw: number;
|
|
75
|
+
}
|
|
76
|
+
/** Live handling readout: the offsets to add to aim and camera, current spread and ADS blend. */
|
|
77
|
+
export interface WeaponHandlingFrame {
|
|
78
|
+
/** Recoil offset to add to the aim direction, rad. */
|
|
79
|
+
aimPitch: number;
|
|
80
|
+
aimYaw: number;
|
|
81
|
+
/** Recoil offset to add to the camera only (view punch), rad. */
|
|
82
|
+
cameraPitch: number;
|
|
83
|
+
cameraYaw: number;
|
|
84
|
+
/** Current half-angle spread with stance multipliers, rad. */
|
|
85
|
+
spread: number;
|
|
86
|
+
/** `0` hip … `1` fully aimed. */
|
|
87
|
+
adsProgress: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The player-to-weapon relationship as state: recoil that climbs and recovers, spread that blooms and
|
|
91
|
+
* settles, and an aim-down-sights blend. Cadence, ammo and hits stay in `weaponFire`/`magazine`; this owns
|
|
92
|
+
* how the weapon feels between them. First-person, third-person and over-the-shoulder presentations read
|
|
93
|
+
* the same frame.
|
|
94
|
+
*/
|
|
95
|
+
export interface WeaponHandling {
|
|
96
|
+
/** Register one shot (call when the cadence fires) and return its spread and kick. */
|
|
97
|
+
fire(stance?: WeaponStance): WeaponShot;
|
|
98
|
+
/** Advance recovery, bloom decay and ADS by `dt`. */
|
|
99
|
+
tick(dt: number, stance?: WeaponStance): WeaponHandlingFrame;
|
|
100
|
+
frame(): WeaponHandlingFrame;
|
|
101
|
+
retune(next: WeaponHandlingTuning): void;
|
|
102
|
+
snapshot(): WeaponHandlingState;
|
|
103
|
+
restore(state: WeaponHandlingState): void;
|
|
104
|
+
reset(): void;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Creates a {@link WeaponHandling}. Pass `random` (e.g. `ctx.rng` or `seededRng(seed)`) for the random
|
|
108
|
+
* recoil cone; without it the cone is skipped, so the result stays deterministic either way.
|
|
109
|
+
* @capability weapon-handling recoil patterns with camera kick and recovery, spread bloom, stance multipliers and ADS as serializable state
|
|
110
|
+
*/
|
|
111
|
+
export declare function createWeaponHandling(initial: WeaponHandlingTuning, options?: {
|
|
112
|
+
random?: () => number;
|
|
113
|
+
}): WeaponHandling;
|
|
114
|
+
/** Deterministic weapon-feel metrics from {@link measureWeapon}. */
|
|
115
|
+
export interface WeaponReport {
|
|
116
|
+
/** Spread of the first shot from rest, rad. */
|
|
117
|
+
firstShotSpread: number;
|
|
118
|
+
/** Spread of the tenth shot of a held burst, rad. */
|
|
119
|
+
tenthShotSpread: number;
|
|
120
|
+
/** Spread of every shot in the burst, rad; `spreadByShot[n - 1]` is the spread after `n - 1` shots of bloom. */
|
|
121
|
+
spreadByShot: number[];
|
|
122
|
+
/** Aim climb after a full burst, rad; set `burst` to the magazine size for climb over a magazine. */
|
|
123
|
+
burstClimb: number;
|
|
124
|
+
/** Seconds after the burst until aim recoil is back within 5% of its peak and bloom within 5% of base. */
|
|
125
|
+
resetSeconds: number;
|
|
126
|
+
/** Seconds to go fully into aim-down-sights. */
|
|
127
|
+
adsSeconds: number;
|
|
128
|
+
/** Shots until the expected damage reaches `targetHealth` (`0` without `damage`, `Infinity` if it never does). */
|
|
129
|
+
shotsToKill: number;
|
|
130
|
+
/** Seconds from the first to the killing shot at `interval` (`0` without `damage` or when one shot kills). */
|
|
131
|
+
timeToKill: number;
|
|
132
|
+
}
|
|
133
|
+
/** Options for {@link measureWeapon}. */
|
|
134
|
+
export interface WeaponProbeOptions {
|
|
135
|
+
/** Seconds between shots (`60 / rpm`). */
|
|
136
|
+
interval: number;
|
|
137
|
+
/** Shots in the held burst (default `30`). */
|
|
138
|
+
burst?: number;
|
|
139
|
+
/** Damage per projectile at the muzzle. */
|
|
140
|
+
damage?: number;
|
|
141
|
+
/** Projectiles per shot, e.g. shotgun pellets (default `1`). */
|
|
142
|
+
pellets?: number;
|
|
143
|
+
targetHealth?: number;
|
|
144
|
+
/** Distance to the target, m. Without it every projectile hits. */
|
|
145
|
+
range?: number;
|
|
146
|
+
/** Radius of the target's hittable area, m (default `0.4`). */
|
|
147
|
+
targetRadius?: number;
|
|
148
|
+
/** Damage multiplier at a range, the game's falloff policy (default: none). */
|
|
149
|
+
damageAt?: (range: number) => number;
|
|
150
|
+
/** Stance the burst and kill run are fired in (e.g. `{ ads: true }` after the ADS time). */
|
|
151
|
+
stance?: WeaponStance;
|
|
152
|
+
dt?: number;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Fires a held burst through a fresh handling instance at a fixed interval and reports spread growth,
|
|
156
|
+
* climb, reset time, ADS time and time-to-kill. Time-to-kill counts expected damage: each projectile hits
|
|
157
|
+
* with the share of its spread cone the target covers at `range`, scaled by `damageAt(range)`, so a
|
|
158
|
+
* blooming rifle and a wide shotgun compare at the distance the game cares about.
|
|
159
|
+
* @capability weapon-metrics measure a weapon's feel as numbers — spread per shot, climb over a magazine, reset time, ADS time, time-to-kill at a range
|
|
160
|
+
*/
|
|
161
|
+
export declare function measureWeapon(create: () => WeaponHandling, options: WeaponProbeOptions): WeaponReport;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
function fresh(tuning) {
|
|
2
|
+
return {
|
|
3
|
+
aimPitch: 0,
|
|
4
|
+
aimYaw: 0,
|
|
5
|
+
cameraPitch: 0,
|
|
6
|
+
cameraYaw: 0,
|
|
7
|
+
bloom: tuning.spread.base,
|
|
8
|
+
shotIndex: 0,
|
|
9
|
+
// Finite so a snapshot survives JSON; anything past every delay behaves the same.
|
|
10
|
+
sinceShot: 1e9,
|
|
11
|
+
adsProgress: 0,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
function toward(value, amount) {
|
|
15
|
+
if (value === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.abs(value) <= amount ? 0 : value - Math.sign(value) * amount;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Creates a {@link WeaponHandling}. Pass `random` (e.g. `ctx.rng` or `seededRng(seed)`) for the random
|
|
21
|
+
* recoil cone; without it the cone is skipped, so the result stays deterministic either way.
|
|
22
|
+
* @capability weapon-handling recoil patterns with camera kick and recovery, spread bloom, stance multipliers and ADS as serializable state
|
|
23
|
+
*/
|
|
24
|
+
export function createWeaponHandling(initial, options = {}) {
|
|
25
|
+
let tuning = initial;
|
|
26
|
+
const random = options.random;
|
|
27
|
+
const state = fresh(tuning);
|
|
28
|
+
const out = { aimPitch: 0, aimYaw: 0, cameraPitch: 0, cameraYaw: 0, spread: 0, adsProgress: 0 };
|
|
29
|
+
function spreadFor(stance) {
|
|
30
|
+
const spread = tuning.spread;
|
|
31
|
+
const ads = 1 + ((spread.ads ?? 1) - 1) * state.adsProgress;
|
|
32
|
+
const crouch = stance?.crouching === true ? (spread.crouching ?? 1) : 1;
|
|
33
|
+
const moving = stance?.moving === true ? (spread.moving ?? 1) : 1;
|
|
34
|
+
const air = stance?.airborne === true ? (spread.airborne ?? 1) : 1;
|
|
35
|
+
return state.bloom * ads * crouch * moving * air;
|
|
36
|
+
}
|
|
37
|
+
function write(stance) {
|
|
38
|
+
out.aimPitch = state.aimPitch;
|
|
39
|
+
out.aimYaw = state.aimYaw;
|
|
40
|
+
out.cameraPitch = state.cameraPitch;
|
|
41
|
+
out.cameraYaw = state.cameraYaw;
|
|
42
|
+
out.spread = spreadFor(stance);
|
|
43
|
+
out.adsProgress = state.adsProgress;
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
fire(stance) {
|
|
48
|
+
const recoil = tuning.recoil;
|
|
49
|
+
const spread = spreadFor(stance);
|
|
50
|
+
let kickPitch = 0;
|
|
51
|
+
let kickYaw = 0;
|
|
52
|
+
if (recoil !== undefined) {
|
|
53
|
+
const pattern = recoil.pattern;
|
|
54
|
+
const entry = pattern !== undefined && pattern.length > 0 ? pattern[Math.min(state.shotIndex, pattern.length - 1)] : undefined;
|
|
55
|
+
kickPitch = entry?.[0] ?? recoil.pitch ?? 0;
|
|
56
|
+
kickYaw = entry?.[1] ?? 0;
|
|
57
|
+
const cone = recoil.randomCone ?? 0;
|
|
58
|
+
if (cone > 0 && random !== undefined) {
|
|
59
|
+
kickPitch += (random() * 2 - 1) * cone;
|
|
60
|
+
kickYaw += (random() * 2 - 1) * cone;
|
|
61
|
+
}
|
|
62
|
+
const scale = 1 + ((recoil.adsScale ?? 1) - 1) * state.adsProgress;
|
|
63
|
+
kickPitch *= scale;
|
|
64
|
+
kickYaw *= scale;
|
|
65
|
+
const cameraShare = Math.max(0, Math.min(1, recoil.cameraShare ?? 0));
|
|
66
|
+
state.aimPitch += kickPitch * (1 - cameraShare);
|
|
67
|
+
state.aimYaw += kickYaw * (1 - cameraShare);
|
|
68
|
+
state.cameraPitch += kickPitch * cameraShare;
|
|
69
|
+
state.cameraYaw += kickYaw * cameraShare;
|
|
70
|
+
}
|
|
71
|
+
const perShot = tuning.spread.perShot ?? 0;
|
|
72
|
+
const max = tuning.spread.max ?? tuning.spread.base + perShot * 10;
|
|
73
|
+
state.bloom = Math.min(max, state.bloom + perShot);
|
|
74
|
+
state.shotIndex += 1;
|
|
75
|
+
state.sinceShot = 0;
|
|
76
|
+
return { spread, kickPitch, kickYaw };
|
|
77
|
+
},
|
|
78
|
+
tick(dt, stance) {
|
|
79
|
+
const step = Math.max(0, dt);
|
|
80
|
+
state.sinceShot += step;
|
|
81
|
+
const adsTime = Math.max(1e-3, tuning.adsTime ?? 0.2);
|
|
82
|
+
const adsTarget = stance?.ads === true ? 1 : 0;
|
|
83
|
+
state.adsProgress += Math.max(-step / adsTime, Math.min(step / adsTime, adsTarget - state.adsProgress));
|
|
84
|
+
const recoil = tuning.recoil;
|
|
85
|
+
if (recoil !== undefined && state.sinceShot >= (recoil.recoverDelay ?? 0.1)) {
|
|
86
|
+
const amount = (recoil.recoverRate ?? 0) * step;
|
|
87
|
+
state.aimPitch = toward(state.aimPitch, amount);
|
|
88
|
+
state.aimYaw = toward(state.aimYaw, amount);
|
|
89
|
+
state.cameraPitch = toward(state.cameraPitch, amount);
|
|
90
|
+
state.cameraYaw = toward(state.cameraYaw, amount);
|
|
91
|
+
if (state.aimPitch === 0 && state.aimYaw === 0)
|
|
92
|
+
state.shotIndex = 0;
|
|
93
|
+
}
|
|
94
|
+
const spread = tuning.spread;
|
|
95
|
+
if (state.sinceShot >= (spread.recoverDelay ?? 0.1)) {
|
|
96
|
+
state.bloom = Math.max(spread.base, state.bloom - (spread.recoverRate ?? 0) * step);
|
|
97
|
+
}
|
|
98
|
+
return write(stance);
|
|
99
|
+
},
|
|
100
|
+
frame: () => write(undefined),
|
|
101
|
+
retune(next) {
|
|
102
|
+
tuning = next;
|
|
103
|
+
state.bloom = Math.max(next.spread.base, state.bloom);
|
|
104
|
+
},
|
|
105
|
+
snapshot: () => ({ ...state }),
|
|
106
|
+
restore(next) {
|
|
107
|
+
Object.assign(state, next);
|
|
108
|
+
},
|
|
109
|
+
reset() {
|
|
110
|
+
Object.assign(state, fresh(tuning));
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function hitChance(spread, range, radius) {
|
|
115
|
+
if (range === undefined || range <= 0)
|
|
116
|
+
return 1;
|
|
117
|
+
const coneRadius = range * Math.tan(Math.max(0, spread));
|
|
118
|
+
return coneRadius <= radius ? 1 : (radius / coneRadius) ** 2;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Fires a held burst through a fresh handling instance at a fixed interval and reports spread growth,
|
|
122
|
+
* climb, reset time, ADS time and time-to-kill. Time-to-kill counts expected damage: each projectile hits
|
|
123
|
+
* with the share of its spread cone the target covers at `range`, scaled by `damageAt(range)`, so a
|
|
124
|
+
* blooming rifle and a wide shotgun compare at the distance the game cares about.
|
|
125
|
+
* @capability weapon-metrics measure a weapon's feel as numbers — spread per shot, climb over a magazine, reset time, ADS time, time-to-kill at a range
|
|
126
|
+
*/
|
|
127
|
+
export function measureWeapon(create, options) {
|
|
128
|
+
const dt = options.dt ?? 1 / 120;
|
|
129
|
+
const burst = options.burst ?? 30;
|
|
130
|
+
const interval = Math.max(dt, options.interval);
|
|
131
|
+
const stance = options.stance;
|
|
132
|
+
const settle = (weapon) => {
|
|
133
|
+
if (stance?.ads !== true)
|
|
134
|
+
return;
|
|
135
|
+
for (let i = 0; i < Math.ceil(5 / dt) && weapon.frame().adsProgress < 1; i += 1)
|
|
136
|
+
weapon.tick(dt, stance);
|
|
137
|
+
};
|
|
138
|
+
const hold = (weapon) => {
|
|
139
|
+
for (let t = 0; t < interval - 1e-9; t += dt)
|
|
140
|
+
weapon.tick(dt, stance);
|
|
141
|
+
};
|
|
142
|
+
const weapon = create();
|
|
143
|
+
settle(weapon);
|
|
144
|
+
const spreadByShot = [];
|
|
145
|
+
for (let shot = 0; shot < burst; shot += 1) {
|
|
146
|
+
spreadByShot.push(weapon.fire(stance).spread);
|
|
147
|
+
hold(weapon);
|
|
148
|
+
}
|
|
149
|
+
const peak = weapon.snapshot();
|
|
150
|
+
const burstClimb = peak.aimPitch;
|
|
151
|
+
const base = weapon.frame().spread;
|
|
152
|
+
let resetSeconds = Number.POSITIVE_INFINITY;
|
|
153
|
+
const baseSpread = create().frame().spread;
|
|
154
|
+
for (let i = 1; i <= Math.ceil(10 / dt); i += 1) {
|
|
155
|
+
const frame = weapon.tick(dt);
|
|
156
|
+
const aimSettled = Math.abs(frame.aimPitch) <= Math.abs(peak.aimPitch) * 0.05 + 1e-9;
|
|
157
|
+
const spreadSettled = frame.spread <= baseSpread + (base - baseSpread) * 0.05 + 1e-9;
|
|
158
|
+
if (aimSettled && spreadSettled) {
|
|
159
|
+
resetSeconds = i * dt;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const ads = create();
|
|
164
|
+
let adsSeconds = Number.POSITIVE_INFINITY;
|
|
165
|
+
for (let i = 1; i <= Math.ceil(5 / dt); i += 1) {
|
|
166
|
+
if (ads.tick(dt, { ads: true }).adsProgress >= 1) {
|
|
167
|
+
adsSeconds = i * dt;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const damage = options.damage ?? 0;
|
|
172
|
+
let shotsToKill = 0;
|
|
173
|
+
if (damage > 0) {
|
|
174
|
+
const health = options.targetHealth ?? 0;
|
|
175
|
+
const perHit = damage * (options.range !== undefined && options.damageAt !== undefined ? options.damageAt(options.range) : 1);
|
|
176
|
+
const pellets = Math.max(1, Math.floor(options.pellets ?? 1));
|
|
177
|
+
const radius = options.targetRadius ?? 0.4;
|
|
178
|
+
const shooter = create();
|
|
179
|
+
settle(shooter);
|
|
180
|
+
let dealt = 0;
|
|
181
|
+
shotsToKill = Number.POSITIVE_INFINITY;
|
|
182
|
+
for (let shot = 1; shot <= 10_000 && perHit > 0; shot += 1) {
|
|
183
|
+
dealt += perHit * pellets * hitChance(shooter.fire(stance).spread, options.range, radius);
|
|
184
|
+
if (dealt >= health - 1e-9) {
|
|
185
|
+
shotsToKill = shot;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
hold(shooter);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const timeToKill = shotsToKill > 0 ? (shotsToKill - 1) * interval : 0;
|
|
192
|
+
return {
|
|
193
|
+
firstShotSpread: spreadByShot[0] ?? 0,
|
|
194
|
+
tenthShotSpread: spreadByShot[9] ?? 0,
|
|
195
|
+
spreadByShot,
|
|
196
|
+
burstClimb,
|
|
197
|
+
resetSeconds,
|
|
198
|
+
adsSeconds,
|
|
199
|
+
shotsToKill,
|
|
200
|
+
timeToKill,
|
|
201
|
+
};
|
|
202
|
+
}
|