@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
package/dist/anim/animGraph.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveOneShotClip } from "../game/modelAnimation.js";
|
|
1
2
|
const DEFAULT_FADE = 0.2;
|
|
2
3
|
/** Entity blackboard key the shell reads extra graph parameters from: `ctx.scene.entity.blackboard.set(id, ANIM_PARAMS_KEY, { aiming: true })`. */
|
|
3
4
|
export const ANIM_PARAMS_KEY = "anim.params";
|
|
@@ -126,7 +127,7 @@ function clipTimeFor(time, duration, loop) {
|
|
|
126
127
|
*
|
|
127
128
|
* @capability anim-graph data-first animation state machine with blend trees, crossfades, layers, and clip events
|
|
128
129
|
*/
|
|
129
|
-
export function createAnimGraphRuntime(initial) {
|
|
130
|
+
export function createAnimGraphRuntime(initial, options = {}) {
|
|
130
131
|
let graph = initial;
|
|
131
132
|
let triggers = new Set();
|
|
132
133
|
let layers = {};
|
|
@@ -136,6 +137,14 @@ export function createAnimGraphRuntime(initial) {
|
|
|
136
137
|
layers[layer.id] = { current: layer.entry, time: 0, transition: null };
|
|
137
138
|
}
|
|
138
139
|
reset();
|
|
140
|
+
function chooseClip(state, def) {
|
|
141
|
+
if (def.kind === "clip" && def.variants !== undefined && def.variants.length > 0) {
|
|
142
|
+
state.selectedClip = resolveOneShotClip({ choice: def.variants }, "choice", options.rng?.() ?? 0) ?? def.clip;
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
delete state.selectedClip;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
139
148
|
function pickTransition(layer, state, params, normalized) {
|
|
140
149
|
for (const transition of layer.transitions) {
|
|
141
150
|
if (transition.from !== "*" && transition.from !== state.current)
|
|
@@ -162,8 +171,7 @@ export function createAnimGraphRuntime(initial) {
|
|
|
162
171
|
continue;
|
|
163
172
|
const at = event.atSec;
|
|
164
173
|
if (loop) {
|
|
165
|
-
const
|
|
166
|
-
const hit = wrapped ? at > before || at <= after : at > before && at <= after;
|
|
174
|
+
const hit = at >= 0 && at <= duration && Math.floor((after - at) / duration) > Math.floor((before - at) / duration);
|
|
167
175
|
if (hit)
|
|
168
176
|
out.push({ name: event.name, clip });
|
|
169
177
|
}
|
|
@@ -205,18 +213,24 @@ export function createAnimGraphRuntime(initial) {
|
|
|
205
213
|
const def = layer.states[state.current];
|
|
206
214
|
if (def === undefined)
|
|
207
215
|
continue;
|
|
216
|
+
if (def.kind === "clip" && def.variants !== undefined && state.selectedClip === undefined)
|
|
217
|
+
chooseClip(state, def);
|
|
208
218
|
const layerWeight = layer.weight ?? 1;
|
|
209
219
|
if (def.rootMotion === true && layerWeight > 0)
|
|
210
220
|
rootMotion = true;
|
|
211
221
|
const before = state.time;
|
|
212
222
|
state.time += dt * stateSpeed(def);
|
|
213
|
-
const weights =
|
|
223
|
+
const weights = def.kind === "clip" && state.selectedClip !== undefined && def.variants?.includes(state.selectedClip)
|
|
224
|
+
? { [state.selectedClip]: 1 }
|
|
225
|
+
: stateClipWeights(def, params);
|
|
214
226
|
const duration = stateDuration(weights, clips);
|
|
215
227
|
const loop = stateLoops(def);
|
|
216
228
|
const normalized = duration > 0 ? (loop ? (state.time % duration) / duration : Math.min(1, state.time / duration)) : 1;
|
|
217
229
|
for (const clip of Object.keys(weights)) {
|
|
230
|
+
if (!(weights[clip] * layerWeight > 0))
|
|
231
|
+
continue;
|
|
218
232
|
const duration = clipDuration(clips[clip]);
|
|
219
|
-
collectEvents(output.events, clip,
|
|
233
|
+
collectEvents(output.events, clip, before, state.time, duration, loop);
|
|
220
234
|
if (def.rootMotion) {
|
|
221
235
|
const info = clips[clip];
|
|
222
236
|
if (typeof info !== "number" && info?.rootTrack !== undefined) {
|
|
@@ -235,7 +249,13 @@ export function createAnimGraphRuntime(initial) {
|
|
|
235
249
|
const times = {};
|
|
236
250
|
for (const [clip, w] of Object.entries(transition.fromWeights)) {
|
|
237
251
|
merged[clip] = (merged[clip] ?? 0) + w * (1 - t);
|
|
238
|
-
|
|
252
|
+
const playback = transition.fromPlayback?.[clip];
|
|
253
|
+
if (w * (1 - t) * layerWeight > 0 && playback?.rootMotion === true)
|
|
254
|
+
rootMotion = true;
|
|
255
|
+
const time = transition.fromTimes[clip] ?? 0;
|
|
256
|
+
const nextTime = playback === undefined ? time : clipTimeFor(time + dt * playback.speed, clipDuration(clips[clip]), playback.loop);
|
|
257
|
+
transition.fromTimes[clip] = nextTime;
|
|
258
|
+
times[clip] = nextTime;
|
|
239
259
|
}
|
|
240
260
|
for (const [clip, w] of Object.entries(weights)) {
|
|
241
261
|
merged[clip] = (merged[clip] ?? 0) + w * t;
|
|
@@ -257,28 +277,39 @@ export function createAnimGraphRuntime(initial) {
|
|
|
257
277
|
}
|
|
258
278
|
}
|
|
259
279
|
const next = pickTransition(layer, state, params, normalized);
|
|
260
|
-
if (next !== null
|
|
280
|
+
if (next !== null) {
|
|
261
281
|
const fromWeights = {};
|
|
262
282
|
const fromTimes = {};
|
|
283
|
+
const fromPlayback = {};
|
|
263
284
|
for (const entry of output.clips) {
|
|
264
285
|
if (entry.layer !== layer.id)
|
|
265
286
|
continue;
|
|
266
287
|
fromWeights[entry.clip] = (fromWeights[entry.clip] ?? 0) + entry.weight / (layerWeight || 1);
|
|
267
288
|
fromTimes[entry.clip] = entry.time;
|
|
289
|
+
const previous = transition?.fromPlayback?.[entry.clip];
|
|
290
|
+
const outgoingRoot = previous?.rootMotion === true && transition !== null &&
|
|
291
|
+
transition.duration > 0 && transition.elapsed < transition.duration &&
|
|
292
|
+
(transition.fromWeights[entry.clip] ?? 0) > 0;
|
|
293
|
+
const playback = weights[entry.clip] === undefined && previous !== undefined
|
|
294
|
+
? previous : { speed: stateSpeed(def), loop };
|
|
295
|
+
// Same-clip states share one clock, but an interrupted fade must retain either
|
|
296
|
+
// influencing source's in-place policy until that source leaves the pose.
|
|
297
|
+
fromPlayback[entry.clip] = { ...playback, ...(def.rootMotion === true || outgoingRoot ? { rootMotion: true } : {}) };
|
|
268
298
|
}
|
|
269
299
|
state.current = next.to;
|
|
270
300
|
state.time = 0;
|
|
301
|
+
const target = layer.states[next.to];
|
|
302
|
+
if (target !== undefined)
|
|
303
|
+
chooseClip(state, target);
|
|
271
304
|
state.transition = {
|
|
272
305
|
to: next.to,
|
|
273
306
|
elapsed: 0,
|
|
274
307
|
duration: next.duration ?? DEFAULT_FADE,
|
|
275
308
|
fromWeights,
|
|
276
309
|
fromTimes,
|
|
310
|
+
fromPlayback,
|
|
277
311
|
};
|
|
278
312
|
}
|
|
279
|
-
else if (next !== null && next.to === state.current) {
|
|
280
|
-
state.time = 0;
|
|
281
|
-
}
|
|
282
313
|
}
|
|
283
314
|
triggers.clear();
|
|
284
315
|
if (rootDelta[0] !== 0 || rootDelta[1] !== 0 || rootDelta[2] !== 0)
|
|
@@ -305,8 +336,10 @@ function parseState(value) {
|
|
|
305
336
|
...(typeof raw.loop === "boolean" ? { loop: raw.loop } : {}),
|
|
306
337
|
...(typeof raw.rootMotion === "boolean" ? { rootMotion: raw.rootMotion } : {}),
|
|
307
338
|
};
|
|
308
|
-
if (raw.kind === "clip" && typeof raw.clip === "string")
|
|
309
|
-
|
|
339
|
+
if (raw.kind === "clip" && typeof raw.clip === "string") {
|
|
340
|
+
const variants = Array.isArray(raw.variants) ? raw.variants.filter((clip) => typeof clip === "string" && clip.length > 0) : undefined;
|
|
341
|
+
return { kind: "clip", clip: raw.clip, ...(variants === undefined || variants.length === 0 ? {} : { variants }), ...extra };
|
|
342
|
+
}
|
|
310
343
|
if (!Array.isArray(raw.points))
|
|
311
344
|
return null;
|
|
312
345
|
if (raw.kind === "blend1D" && typeof raw.param === "string") {
|
|
@@ -412,3 +445,158 @@ export function parseAnimGraph(value) {
|
|
|
412
445
|
: [];
|
|
413
446
|
return { layers, ...(events.length === 0 ? {} : { events }) };
|
|
414
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* Validate an authored graph without dropping broken combat states or weakening transition conditions.
|
|
450
|
+
* Known malformed fields and dangling references reject the whole graph with relative repair locations;
|
|
451
|
+
* valid data, including authored object/array order, is returned unchanged. Unknown extension fields are
|
|
452
|
+
* retained. Clip availability belongs to the imported-rig diagnostics. Use {@link parseAnimGraph} when
|
|
453
|
+
* permissive repair is intentional.
|
|
454
|
+
* @capability anim-graph validate authored animation graphs with located repair diagnostics without partial fallback
|
|
455
|
+
*/
|
|
456
|
+
export function validateAnimGraph(value) {
|
|
457
|
+
const diagnostics = [];
|
|
458
|
+
const issue = (path, message, repair) => { diagnostics.push({ path, message, repair }); };
|
|
459
|
+
const field = (path, key) => path === "" ? key : /^[A-Za-z_$][\w$]*$/.test(key) ? `${path}.${key}` : `${path}[${JSON.stringify(key)}]`;
|
|
460
|
+
const object = (candidate, path) => {
|
|
461
|
+
const raw = record(candidate);
|
|
462
|
+
if (raw === null)
|
|
463
|
+
issue(path, "Expected an animation graph object.", "Supply an object with the supported fields.");
|
|
464
|
+
return raw;
|
|
465
|
+
};
|
|
466
|
+
const text = (candidate, path, empty = false) => {
|
|
467
|
+
if (typeof candidate === "string" && (empty || candidate.trim().length > 0))
|
|
468
|
+
return true;
|
|
469
|
+
issue(path, "Expected a nonempty string.", "Set an explicit name from this graph or imported rig.");
|
|
470
|
+
return false;
|
|
471
|
+
};
|
|
472
|
+
const number = (candidate, path, min = -Infinity, max = Infinity) => {
|
|
473
|
+
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate >= min && candidate <= max)
|
|
474
|
+
return true;
|
|
475
|
+
issue(path, `Expected a finite number${min === -Infinity && max === Infinity ? "" : max === Infinity ? ` at least ${min}` : ` between ${min} and ${max}`}.`, "Set a finite value in the supported range.");
|
|
476
|
+
return false;
|
|
477
|
+
};
|
|
478
|
+
const boolean = (candidate, path) => {
|
|
479
|
+
if (typeof candidate !== "boolean")
|
|
480
|
+
issue(path, "Expected a boolean.", "Set true or false, or omit the optional field.");
|
|
481
|
+
};
|
|
482
|
+
const array = (candidate, path) => {
|
|
483
|
+
if (Array.isArray(candidate))
|
|
484
|
+
return candidate;
|
|
485
|
+
issue(path, "Expected an array.", "Supply an array of supported entries.");
|
|
486
|
+
return null;
|
|
487
|
+
};
|
|
488
|
+
const tuple = (candidate, path, numeric) => {
|
|
489
|
+
const entries = array(candidate, path);
|
|
490
|
+
if (entries === null)
|
|
491
|
+
return;
|
|
492
|
+
if (entries.length !== 2)
|
|
493
|
+
issue(path, "Expected exactly two entries.", "Supply the two blend axes in authored order.");
|
|
494
|
+
entries.forEach((entry, index) => numeric ? number(entry, `${path}[${index}]`) : text(entry, `${path}[${index}]`));
|
|
495
|
+
};
|
|
496
|
+
const state = (candidate, path) => {
|
|
497
|
+
const raw = object(candidate, path);
|
|
498
|
+
if (raw === null)
|
|
499
|
+
return;
|
|
500
|
+
if (!["clip", "blend1D", "blend2D"].includes(raw.kind)) {
|
|
501
|
+
issue(`${path}.kind`, "Unknown animation state kind.", "Choose clip, blend1D or blend2D.");
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
if (raw.speed !== undefined)
|
|
505
|
+
number(raw.speed, `${path}.speed`);
|
|
506
|
+
for (const key of ["loop", "rootMotion"])
|
|
507
|
+
if (raw[key] !== undefined)
|
|
508
|
+
boolean(raw[key], `${path}.${key}`);
|
|
509
|
+
const allowed = raw.kind === "clip" ? ["clip", "variants"] : raw.kind === "blend1D" ? ["param", "points"] : ["params", "points"];
|
|
510
|
+
for (const key of ["clip", "variants", "param", "params", "points"])
|
|
511
|
+
if (raw[key] !== undefined && !allowed.includes(key)) {
|
|
512
|
+
issue(`${path}.${key}`, `Field ${key} is not used by a ${raw.kind} state.`, "Remove the field or choose the matching state kind.");
|
|
513
|
+
}
|
|
514
|
+
if (raw.kind === "clip") {
|
|
515
|
+
text(raw.clip, `${path}.clip`);
|
|
516
|
+
if (raw.variants !== undefined)
|
|
517
|
+
array(raw.variants, `${path}.variants`)?.forEach((clip, index) => text(clip, `${path}.variants[${index}]`));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (raw.kind === "blend1D")
|
|
521
|
+
text(raw.param, `${path}.param`);
|
|
522
|
+
else
|
|
523
|
+
tuple(raw.params, `${path}.params`, false);
|
|
524
|
+
array(raw.points, `${path}.points`)?.forEach((candidate, index) => {
|
|
525
|
+
const pointPath = `${path}.points[${index}]`, point = object(candidate, pointPath);
|
|
526
|
+
if (point === null)
|
|
527
|
+
return;
|
|
528
|
+
text(point.clip, `${pointPath}.clip`);
|
|
529
|
+
if (raw.kind === "blend1D")
|
|
530
|
+
number(point.at, `${pointPath}.at`);
|
|
531
|
+
else
|
|
532
|
+
tuple(point.at, `${pointPath}.at`, true);
|
|
533
|
+
});
|
|
534
|
+
};
|
|
535
|
+
const raw = object(value, "");
|
|
536
|
+
if (raw === null)
|
|
537
|
+
return { graph: undefined, diagnostics };
|
|
538
|
+
const layers = array(raw.layers, "layers"), seen = new Set();
|
|
539
|
+
if (layers?.length === 0)
|
|
540
|
+
issue("layers", "An animation graph needs at least one layer.", "Add a layer with an entry state.");
|
|
541
|
+
layers?.forEach((candidate, index) => {
|
|
542
|
+
const path = `layers[${index}]`, layer = object(candidate, path);
|
|
543
|
+
if (layer === null)
|
|
544
|
+
return;
|
|
545
|
+
if (text(layer.id, `${path}.id`)) {
|
|
546
|
+
if (seen.has(layer.id))
|
|
547
|
+
issue(`${path}.id`, "Layer id is duplicated.", "Give each layer a distinct id.");
|
|
548
|
+
seen.add(layer.id);
|
|
549
|
+
}
|
|
550
|
+
const validEntry = text(layer.entry, `${path}.entry`), states = object(layer.states, `${path}.states`);
|
|
551
|
+
if (states !== null) {
|
|
552
|
+
for (const [name, candidate] of Object.entries(states)) {
|
|
553
|
+
text(name, field(`${path}.states`, name));
|
|
554
|
+
state(candidate, field(`${path}.states`, name));
|
|
555
|
+
}
|
|
556
|
+
if (validEntry && !Object.hasOwn(states, layer.entry))
|
|
557
|
+
issue(`${path}.entry`, "Entry state does not exist in this layer.", "Choose a state declared in this layer.");
|
|
558
|
+
}
|
|
559
|
+
if (layer.weight !== undefined)
|
|
560
|
+
number(layer.weight, `${path}.weight`, 0, 1);
|
|
561
|
+
if (layer.additive !== undefined)
|
|
562
|
+
boolean(layer.additive, `${path}.additive`);
|
|
563
|
+
if (layer.mask !== undefined)
|
|
564
|
+
array(layer.mask, `${path}.mask`)?.forEach((prefix, i) => text(prefix, `${path}.mask[${i}]`, true));
|
|
565
|
+
array(layer.transitions, `${path}.transitions`)?.forEach((candidate, i) => {
|
|
566
|
+
const edgePath = `${path}.transitions[${i}]`, edge = object(candidate, edgePath);
|
|
567
|
+
if (edge === null)
|
|
568
|
+
return;
|
|
569
|
+
for (const key of ["from", "to"])
|
|
570
|
+
if (text(edge[key], `${edgePath}.${key}`) && states !== null && !(key === "from" && edge[key] === "*") && !Object.hasOwn(states, edge[key])) {
|
|
571
|
+
issue(`${edgePath}.${key}`, "Transition references an unknown state.", "Choose a state declared in this layer; only from may use *.");
|
|
572
|
+
}
|
|
573
|
+
if (edge.trigger !== undefined)
|
|
574
|
+
text(edge.trigger, `${edgePath}.trigger`);
|
|
575
|
+
if (edge.duration !== undefined)
|
|
576
|
+
number(edge.duration, `${edgePath}.duration`, 0);
|
|
577
|
+
if (edge.exitTime !== undefined)
|
|
578
|
+
number(edge.exitTime, `${edgePath}.exitTime`);
|
|
579
|
+
if (edge.when !== undefined)
|
|
580
|
+
array(edge.when, `${edgePath}.when`)?.forEach((candidate, i) => {
|
|
581
|
+
const conditionPath = `${edgePath}.when[${i}]`, condition = object(candidate, conditionPath);
|
|
582
|
+
if (condition === null)
|
|
583
|
+
return;
|
|
584
|
+
text(condition.param, `${conditionPath}.param`);
|
|
585
|
+
if (!COMPARES.includes(condition.op))
|
|
586
|
+
issue(`${conditionPath}.op`, "Unknown comparison operator.", "Choose >, <, >=, <=, == or !=.");
|
|
587
|
+
if (typeof condition.value !== "boolean")
|
|
588
|
+
number(condition.value, `${conditionPath}.value`);
|
|
589
|
+
});
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
if (raw.events !== undefined)
|
|
593
|
+
array(raw.events, "events")?.forEach((candidate, index) => {
|
|
594
|
+
const path = `events[${index}]`, event = object(candidate, path);
|
|
595
|
+
if (event === null)
|
|
596
|
+
return;
|
|
597
|
+
text(event.clip, `${path}.clip`);
|
|
598
|
+
text(event.name, `${path}.name`);
|
|
599
|
+
number(event.atSec, `${path}.atSec`, 0);
|
|
600
|
+
});
|
|
601
|
+
return { graph: diagnostics.length === 0 ? value : undefined, diagnostics };
|
|
602
|
+
}
|
|
@@ -12,10 +12,16 @@ export interface LocomotionGraphInput {
|
|
|
12
12
|
/** Crossfade seconds between locomotion states. Default 0.2. */
|
|
13
13
|
fadeSec?: number;
|
|
14
14
|
/** One-shot clips keyed by trigger name; `death` holds its last frame instead of returning. */
|
|
15
|
-
oneShots?: Readonly<Record<string, string>>;
|
|
15
|
+
oneShots?: Readonly<Record<string, string | readonly string[]>>;
|
|
16
16
|
}
|
|
17
17
|
/** The parameter name the shell feeds with the entity's smoothed ground speed. */
|
|
18
18
|
export declare const LOCOMOTION_SPEED_PARAM = "speed";
|
|
19
|
+
/** Ground contact reported by the shared player motor; absent for custom movers without authored parameters. */
|
|
20
|
+
export declare const LOCOMOTION_GROUNDED_PARAM = "grounded";
|
|
21
|
+
/** Resolved vertical velocity from the shared player motor, in world units/sec. */
|
|
22
|
+
export declare const LOCOMOTION_VERTICAL_SPEED_PARAM = "verticalSpeed";
|
|
23
|
+
/** Physically resolved crouch stance from the shared player motor. */
|
|
24
|
+
export declare const LOCOMOTION_CROUCHED_PARAM = "crouched";
|
|
19
25
|
/** Layer id the locomotion graph uses; query `runtime.stateOf(LOCOMOTION_LAYER)`. */
|
|
20
26
|
export declare const LOCOMOTION_LAYER = "base";
|
|
21
27
|
/**
|
|
@@ -27,8 +33,10 @@ export declare const LOCOMOTION_LAYER = "base";
|
|
|
27
33
|
export declare function locomotionGraph(input: LocomotionGraphInput): AnimGraph;
|
|
28
34
|
/**
|
|
29
35
|
* The graph a model animation config plays: its `graph`, or the {@link locomotionGraph} its
|
|
30
|
-
* `states` and `oneShots` describe
|
|
31
|
-
* single-clip config
|
|
36
|
+
* `states` and `oneShots` describe, retaining every one-shot variant. `undefined` for a
|
|
37
|
+
* single-clip config or incomplete locomotion roles. Idle must name a clip; an absent or blank
|
|
38
|
+
* walk role holds that idle clip at walking speeds until configured. The shell plays this and
|
|
39
|
+
* the editor inspects it, so both see the same graph.
|
|
32
40
|
*
|
|
33
41
|
* @capability locomotion-graph resolve the animation graph a model config plays
|
|
34
42
|
*/
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** The parameter name the shell feeds with the entity's smoothed ground speed. */
|
|
2
2
|
export const LOCOMOTION_SPEED_PARAM = "speed";
|
|
3
|
+
/** Ground contact reported by the shared player motor; absent for custom movers without authored parameters. */
|
|
4
|
+
export const LOCOMOTION_GROUNDED_PARAM = "grounded";
|
|
5
|
+
/** Resolved vertical velocity from the shared player motor, in world units/sec. */
|
|
6
|
+
export const LOCOMOTION_VERTICAL_SPEED_PARAM = "verticalSpeed";
|
|
7
|
+
/** Physically resolved crouch stance from the shared player motor. */
|
|
8
|
+
export const LOCOMOTION_CROUCHED_PARAM = "crouched";
|
|
3
9
|
/** Layer id the locomotion graph uses; query `runtime.stateOf(LOCOMOTION_LAYER)`. */
|
|
4
10
|
export const LOCOMOTION_LAYER = "base";
|
|
5
11
|
/**
|
|
@@ -20,10 +26,18 @@ export function locomotionGraph(input) {
|
|
|
20
26
|
const states = {
|
|
21
27
|
locomotion: { kind: "blend1D", param: LOCOMOTION_SPEED_PARAM, points },
|
|
22
28
|
};
|
|
29
|
+
const oneShots = Object.entries(input.oneShots ?? {}).filter(([, spec]) => typeof spec === "string" || spec.length > 0);
|
|
30
|
+
for (const [name, spec] of oneShots) {
|
|
31
|
+
const clip = typeof spec === "string" ? spec : spec[0];
|
|
32
|
+
states[name] = { kind: "clip", clip, loop: false, ...(typeof spec === "string" || spec.length < 2 ? {} : { variants: [...spec] }) };
|
|
33
|
+
}
|
|
23
34
|
const transitions = [];
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
35
|
+
oneShots.sort(([a], [b]) => Number(b === "death") - Number(a === "death"));
|
|
36
|
+
for (const [name] of oneShots) {
|
|
37
|
+
for (const from of Object.keys(states)) {
|
|
38
|
+
if (from !== "death")
|
|
39
|
+
transitions.push({ from, to: name, trigger: name, duration: 0.1 });
|
|
40
|
+
}
|
|
27
41
|
if (name !== "death")
|
|
28
42
|
transitions.push({ from: name, to: "locomotion", exitTime: 1, duration: fade });
|
|
29
43
|
}
|
|
@@ -33,8 +47,10 @@ export function locomotionGraph(input) {
|
|
|
33
47
|
}
|
|
34
48
|
/**
|
|
35
49
|
* The graph a model animation config plays: its `graph`, or the {@link locomotionGraph} its
|
|
36
|
-
* `states` and `oneShots` describe
|
|
37
|
-
* single-clip config
|
|
50
|
+
* `states` and `oneShots` describe, retaining every one-shot variant. `undefined` for a
|
|
51
|
+
* single-clip config or incomplete locomotion roles. Idle must name a clip; an absent or blank
|
|
52
|
+
* walk role holds that idle clip at walking speeds until configured. The shell plays this and
|
|
53
|
+
* the editor inspects it, so both see the same graph.
|
|
38
54
|
*
|
|
39
55
|
* @capability locomotion-graph resolve the animation graph a model config plays
|
|
40
56
|
*/
|
|
@@ -42,21 +58,15 @@ export function animGraphFromConfig(config) {
|
|
|
42
58
|
if (config.graph !== undefined)
|
|
43
59
|
return config.graph;
|
|
44
60
|
const states = config.states;
|
|
45
|
-
if (states === undefined)
|
|
61
|
+
if (states === undefined || typeof states.idle !== "string" || states.idle.trim().length === 0)
|
|
46
62
|
return undefined;
|
|
47
|
-
const oneShots = {};
|
|
48
|
-
for (const [event, spec] of Object.entries(config.oneShots ?? {})) {
|
|
49
|
-
const clip = typeof spec === "string" ? spec : spec[0];
|
|
50
|
-
if (clip !== undefined)
|
|
51
|
-
oneShots[event] = clip;
|
|
52
|
-
}
|
|
53
63
|
return locomotionGraph({
|
|
54
64
|
idle: states.idle,
|
|
55
|
-
walk: states.walk,
|
|
56
|
-
...(states.run ===
|
|
65
|
+
walk: typeof states.walk === "string" && states.walk.trim().length > 0 ? states.walk : states.idle,
|
|
66
|
+
...(typeof states.run !== "string" || states.run.trim().length === 0 ? {} : { run: states.run }),
|
|
57
67
|
walkSpeed: states.walkSpeed,
|
|
58
68
|
runSpeed: states.runSpeed,
|
|
59
69
|
fadeSec: states.fadeSec,
|
|
60
|
-
...(
|
|
70
|
+
...(config.oneShots === undefined ? {} : { oneShots: config.oneShots }),
|
|
61
71
|
});
|
|
62
72
|
}
|
|
@@ -24,6 +24,10 @@ export interface SoundDef {
|
|
|
24
24
|
/** Base gain before bus/falloff multipliers. Default 1. */
|
|
25
25
|
gain?: number;
|
|
26
26
|
loop?: boolean;
|
|
27
|
+
/** Maximum simultaneous reservations for this sound, including pending loads. Default the engine's total voice limit. */
|
|
28
|
+
maxVoices?: number;
|
|
29
|
+
/** Playback priority; higher priorities survive voice-budget pressure. Default 0. */
|
|
30
|
+
priority?: number;
|
|
27
31
|
/** Positional emitters attenuate by distance from the listener; non-positional sounds (UI, music) play at flat gain. Default true. */
|
|
28
32
|
positional?: boolean;
|
|
29
33
|
falloff?: AudioFalloffConfig;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** One reserved playback slot, including sources still loading. */
|
|
2
|
+
export interface AudioVoice {
|
|
3
|
+
id: number;
|
|
4
|
+
soundId: string;
|
|
5
|
+
priority: number;
|
|
6
|
+
}
|
|
7
|
+
/** Detached allocator state; ids increase monotonically for deterministic oldest-first ties. */
|
|
8
|
+
export interface VoiceAllocatorSnapshot {
|
|
9
|
+
nextId: number;
|
|
10
|
+
voices: AudioVoice[];
|
|
11
|
+
}
|
|
12
|
+
/** Caller-owned reservation storage. Reads and writes use detached state. */
|
|
13
|
+
export interface VoiceAllocatorStorage {
|
|
14
|
+
read(): VoiceAllocatorSnapshot;
|
|
15
|
+
write(state: VoiceAllocatorSnapshot): void;
|
|
16
|
+
}
|
|
17
|
+
/** Playback budget. Zero disables playback; default 64. */
|
|
18
|
+
export interface VoiceAllocatorConfig {
|
|
19
|
+
maxTotal?: number;
|
|
20
|
+
/** At capacity, steal the lowest-priority oldest voice, or reject new voices. Default "steal-lowest". */
|
|
21
|
+
overflow?: "steal-lowest" | "reject";
|
|
22
|
+
storage?: VoiceAllocatorStorage;
|
|
23
|
+
}
|
|
24
|
+
/** Admission result; stop every stolen source before starting the admitted voice. */
|
|
25
|
+
export type VoiceAllocation = {
|
|
26
|
+
ok: false;
|
|
27
|
+
} | {
|
|
28
|
+
ok: true;
|
|
29
|
+
voiceId: number;
|
|
30
|
+
stolen: number[];
|
|
31
|
+
};
|
|
32
|
+
/** Bounded reservations with higher-priority protection and oldest-first ties. */
|
|
33
|
+
export interface VoiceAllocator {
|
|
34
|
+
request(soundId: string, priority?: number, maxVoices?: number): VoiceAllocation;
|
|
35
|
+
release(voiceId: number): void;
|
|
36
|
+
snapshot(): VoiceAllocatorSnapshot;
|
|
37
|
+
restore(state: VoiceAllocatorSnapshot): void;
|
|
38
|
+
/** Return reservations removed to meet the new limit; the playback owner stops them. */
|
|
39
|
+
retune(config: Pick<VoiceAllocatorConfig, "maxTotal" | "overflow">): number[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Reserve bounded sound playback before fetching or constructing a graph. Equal priorities steal
|
|
43
|
+
* the oldest reservation; lower priorities cannot interrupt higher priorities. Policy is retunable.
|
|
44
|
+
* @capability audio-voice-budget bound simultaneous sound playback with per-sound caps, priorities, and deterministic stealing
|
|
45
|
+
*/
|
|
46
|
+
export declare function createVoiceAllocator(config?: VoiceAllocatorConfig): VoiceAllocator;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
function limit(value) {
|
|
2
|
+
if (!Number.isFinite(value) || value < 0)
|
|
3
|
+
throw new RangeError("Voice limit must be finite and nonnegative");
|
|
4
|
+
return Math.floor(value);
|
|
5
|
+
}
|
|
6
|
+
function copy(state) {
|
|
7
|
+
return { nextId: state.nextId, voices: state.voices.map((voice) => ({ ...voice })) };
|
|
8
|
+
}
|
|
9
|
+
function weakest(voices) {
|
|
10
|
+
return [...voices].sort((a, b) => a.priority - b.priority || a.id - b.id);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Reserve bounded sound playback before fetching or constructing a graph. Equal priorities steal
|
|
14
|
+
* the oldest reservation; lower priorities cannot interrupt higher priorities. Policy is retunable.
|
|
15
|
+
* @capability audio-voice-budget bound simultaneous sound playback with per-sound caps, priorities, and deterministic stealing
|
|
16
|
+
*/
|
|
17
|
+
export function createVoiceAllocator(config = {}) {
|
|
18
|
+
let maxTotal = limit(config.maxTotal ?? 64);
|
|
19
|
+
let overflow = config.overflow ?? "steal-lowest";
|
|
20
|
+
let memory = { nextId: 1, voices: [] };
|
|
21
|
+
const storage = config.storage ?? { read: () => memory, write: (next) => { memory = next; } };
|
|
22
|
+
function snapshot() { return copy(storage.read()); }
|
|
23
|
+
function restore(state) {
|
|
24
|
+
const ids = new Set();
|
|
25
|
+
if (!Number.isSafeInteger(state.nextId) || state.nextId < 1 || state.voices.length > maxTotal)
|
|
26
|
+
throw new RangeError("Invalid voice state");
|
|
27
|
+
for (const voice of state.voices) {
|
|
28
|
+
if (!Number.isSafeInteger(voice.id) || voice.id < 1 || voice.id >= state.nextId || ids.has(voice.id) || !Number.isFinite(voice.priority))
|
|
29
|
+
throw new RangeError("Invalid voice state");
|
|
30
|
+
ids.add(voice.id);
|
|
31
|
+
}
|
|
32
|
+
storage.write(copy(state));
|
|
33
|
+
}
|
|
34
|
+
restore(snapshot());
|
|
35
|
+
return {
|
|
36
|
+
snapshot, restore,
|
|
37
|
+
request(soundId, priority = 0, maxVoices = maxTotal) {
|
|
38
|
+
if (!Number.isFinite(priority))
|
|
39
|
+
throw new RangeError("Voice priority must be finite");
|
|
40
|
+
const soundLimit = limit(maxVoices);
|
|
41
|
+
if (maxTotal === 0 || soundLimit === 0)
|
|
42
|
+
return { ok: false };
|
|
43
|
+
const state = snapshot();
|
|
44
|
+
const same = weakest(state.voices.filter((voice) => voice.soundId === soundId));
|
|
45
|
+
const victims = same.slice(0, Math.max(0, same.length - soundLimit + 1));
|
|
46
|
+
const selected = new Set(victims.map((voice) => voice.id));
|
|
47
|
+
const remaining = weakest(state.voices.filter((voice) => !selected.has(voice.id)));
|
|
48
|
+
victims.push(...remaining.slice(0, Math.max(0, state.voices.length - victims.length - maxTotal + 1)));
|
|
49
|
+
if ((overflow === "reject" && victims.length > 0) || victims.some((voice) => voice.priority > priority))
|
|
50
|
+
return { ok: false };
|
|
51
|
+
const stolen = victims.map((voice) => voice.id);
|
|
52
|
+
const removed = new Set(stolen);
|
|
53
|
+
const voiceId = state.nextId++;
|
|
54
|
+
state.voices = state.voices.filter((voice) => !removed.has(voice.id));
|
|
55
|
+
state.voices.push({ id: voiceId, soundId, priority });
|
|
56
|
+
restore(state);
|
|
57
|
+
return { ok: true, voiceId, stolen };
|
|
58
|
+
},
|
|
59
|
+
release(voiceId) {
|
|
60
|
+
const state = snapshot();
|
|
61
|
+
state.voices = state.voices.filter((voice) => voice.id !== voiceId);
|
|
62
|
+
restore(state);
|
|
63
|
+
},
|
|
64
|
+
retune(next) {
|
|
65
|
+
const nextLimit = limit(next.maxTotal ?? maxTotal);
|
|
66
|
+
const state = snapshot();
|
|
67
|
+
const removed = weakest(state.voices).slice(0, Math.max(0, state.voices.length - nextLimit)).map((voice) => voice.id);
|
|
68
|
+
const ids = new Set(removed);
|
|
69
|
+
state.voices = state.voices.filter((voice) => !ids.has(voice.id));
|
|
70
|
+
maxTotal = nextLimit;
|
|
71
|
+
overflow = next.overflow ?? overflow;
|
|
72
|
+
restore(state);
|
|
73
|
+
return removed;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -51,6 +51,21 @@ export interface AbilitySlotRetune {
|
|
|
51
51
|
cooldownMs?: number;
|
|
52
52
|
resourceCost?: number;
|
|
53
53
|
}
|
|
54
|
+
/** Mutable kit state; slot/group definitions and any bound resource are saved by the caller. */
|
|
55
|
+
export interface AbilityKitState {
|
|
56
|
+
slots: {
|
|
57
|
+
id: string;
|
|
58
|
+
cooldownMs: number;
|
|
59
|
+
resourceCost: number;
|
|
60
|
+
charges: number;
|
|
61
|
+
rechargeRemainingMs: number;
|
|
62
|
+
flashRemainingMs: number;
|
|
63
|
+
}[];
|
|
64
|
+
groups: {
|
|
65
|
+
id: string;
|
|
66
|
+
remainingMs: number;
|
|
67
|
+
}[];
|
|
68
|
+
}
|
|
54
69
|
export type AbilityCastResult = {
|
|
55
70
|
ok: true;
|
|
56
71
|
slot: AbilitySlotSnapshot;
|
|
@@ -62,8 +77,12 @@ export type AbilityCastResult = {
|
|
|
62
77
|
export interface AbilityKit {
|
|
63
78
|
slots(): readonly string[];
|
|
64
79
|
config(slotId: string): AbilitySlotConfig | null;
|
|
80
|
+
/** Detached persistence state, including in-flight timers and slot retuning. */
|
|
81
|
+
state(): AbilityKitState;
|
|
65
82
|
state(slotId: string, resourceAvailable?: number): AbilitySlotSnapshot | null;
|
|
66
83
|
snapshot(resourceAvailable?: number): AbilitySlotSnapshot[];
|
|
84
|
+
/** Restore without spending resources; returns false without mutation for mismatched slot/group ids. */
|
|
85
|
+
restore(next: AbilityKitState): boolean;
|
|
67
86
|
canCast(slotId: string, resourceAvailable?: number): AbilityCastResult;
|
|
68
87
|
cast(slotId: string, resourceAvailable?: number): AbilityCastResult;
|
|
69
88
|
tick(dtSeconds: number): void;
|
|
@@ -108,6 +108,26 @@ export function createAbilityKit(configs, options = {}) {
|
|
|
108
108
|
return fail(slotId, "no-resource", resourceAvailable);
|
|
109
109
|
return { ok: true, slot: snapshotOf(runtime, resourceAvailable, groupBlockOf(runtime)) };
|
|
110
110
|
}
|
|
111
|
+
function state(slotId, resourceAvailable) {
|
|
112
|
+
if (slotId !== undefined) {
|
|
113
|
+
const runtime = runtimes.get(slotId);
|
|
114
|
+
return runtime === undefined ? null : snapshotOf(runtime, currentResource(resourceAvailable), groupBlockOf(runtime));
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
slots: order.map((id) => {
|
|
118
|
+
const runtime = runtimes.get(id);
|
|
119
|
+
return {
|
|
120
|
+
id,
|
|
121
|
+
cooldownMs: runtime.config.cooldownMs,
|
|
122
|
+
resourceCost: runtime.config.resourceCost,
|
|
123
|
+
charges: runtime.charges,
|
|
124
|
+
rechargeRemainingMs: runtime.rechargeRemainingMs,
|
|
125
|
+
flashRemainingMs: runtime.flashRemainingMs,
|
|
126
|
+
};
|
|
127
|
+
}),
|
|
128
|
+
groups: Array.from(groupRemaining, ([id, remainingMs]) => ({ id, remainingMs })),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
111
131
|
return {
|
|
112
132
|
slots() {
|
|
113
133
|
return order.slice();
|
|
@@ -115,10 +135,7 @@ export function createAbilityKit(configs, options = {}) {
|
|
|
115
135
|
config(slotId) {
|
|
116
136
|
return runtimes.get(slotId)?.config ?? null;
|
|
117
137
|
},
|
|
118
|
-
state
|
|
119
|
-
const runtime = runtimes.get(slotId);
|
|
120
|
-
return runtime === undefined ? null : snapshotOf(runtime, currentResource(resourceAvailable), groupBlockOf(runtime));
|
|
121
|
-
},
|
|
138
|
+
state,
|
|
122
139
|
snapshot(resourceAvailable) {
|
|
123
140
|
const available = currentResource(resourceAvailable);
|
|
124
141
|
return order.map((slotId) => {
|
|
@@ -126,6 +143,25 @@ export function createAbilityKit(configs, options = {}) {
|
|
|
126
143
|
return snapshotOf(runtime, available, groupBlockOf(runtime));
|
|
127
144
|
});
|
|
128
145
|
},
|
|
146
|
+
restore(next) {
|
|
147
|
+
if (next.slots.length !== runtimes.size || next.groups.length !== groupRemaining.size ||
|
|
148
|
+
new Set(next.slots.map((slot) => slot.id)).size !== runtimes.size ||
|
|
149
|
+
new Set(next.groups.map((group) => group.id)).size !== groupRemaining.size ||
|
|
150
|
+
next.slots.some((slot) => !runtimes.has(slot.id)) ||
|
|
151
|
+
next.groups.some((group) => !groupRemaining.has(group.id)))
|
|
152
|
+
return false;
|
|
153
|
+
for (const slot of next.slots) {
|
|
154
|
+
const runtime = runtimes.get(slot.id);
|
|
155
|
+
runtime.config.cooldownMs = Math.max(0, slot.cooldownMs);
|
|
156
|
+
runtime.config.resourceCost = Math.max(0, slot.resourceCost);
|
|
157
|
+
runtime.charges = Math.max(0, Math.min(runtime.config.chargesMax, slot.charges));
|
|
158
|
+
runtime.rechargeRemainingMs = Math.max(0, slot.rechargeRemainingMs);
|
|
159
|
+
runtime.flashRemainingMs = Math.max(0, slot.flashRemainingMs);
|
|
160
|
+
}
|
|
161
|
+
for (const group of next.groups)
|
|
162
|
+
groupRemaining.set(group.id, Math.max(0, group.remainingMs));
|
|
163
|
+
return true;
|
|
164
|
+
},
|
|
129
165
|
canCast(slotId, resourceAvailable) {
|
|
130
166
|
return evaluate(slotId, currentResource(resourceAvailable));
|
|
131
167
|
},
|
package/dist/combat/death.d.ts
CHANGED
|
@@ -6,8 +6,9 @@ import type { EffectVia } from "./effects.js";
|
|
|
6
6
|
export type DeathReasonKind = DeathReason["kind"];
|
|
7
7
|
export interface OnDeathDropRule {
|
|
8
8
|
table: string;
|
|
9
|
+
/** `"any"` explicitly permits world item drops without a player killer. */
|
|
9
10
|
when?: {
|
|
10
|
-
reason: DeathReasonKind;
|
|
11
|
+
reason: DeathReasonKind | "any";
|
|
11
12
|
};
|
|
12
13
|
}
|
|
13
14
|
export interface OnDeathCommandRule {
|
|
@@ -47,13 +48,13 @@ export interface DeathIdentity {
|
|
|
47
48
|
position: [number, number, number];
|
|
48
49
|
}
|
|
49
50
|
export interface DeathSystemDeps {
|
|
50
|
-
resolveOnDeath(instanceId: string): OnDeathSpec | null | undefined;
|
|
51
|
+
resolveOnDeath(instanceId: string, reason?: DeathReason): OnDeathSpec | null | undefined;
|
|
51
52
|
resolveIdentity(instanceId: string): DeathIdentity | null;
|
|
52
53
|
loot: {
|
|
53
54
|
roll(tableId: string): Drop[];
|
|
54
55
|
};
|
|
55
56
|
events: GameEvents;
|
|
56
|
-
runCommand?(name: string, args: unknown): void;
|
|
57
|
+
runCommand?(name: string, args: unknown, reason?: DeathReason): void;
|
|
57
58
|
despawn(instanceId: string): void;
|
|
58
59
|
}
|
|
59
60
|
export type DeathResolution = {
|