@jgengine/core 0.19.0 → 0.21.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 +346 -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 +2 -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/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 +333 -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/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +1 -1
- 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/jgengine/SKILL.md +22 -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 +200 -57
- 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 +152 -5
- package/skills/jgengine-world/visibility.md +28 -1
package/dist/combat/death.js
CHANGED
|
@@ -9,7 +9,7 @@ export function normalizeOnDeath(spec) {
|
|
|
9
9
|
return normalized;
|
|
10
10
|
}
|
|
11
11
|
function matchesReason(when, reason) {
|
|
12
|
-
return when === undefined || when.reason === reason.kind;
|
|
12
|
+
return when === undefined || when.reason === "any" || when.reason === reason.kind;
|
|
13
13
|
}
|
|
14
14
|
/**
|
|
15
15
|
* Resolve entity death and the on-death consequences — drops, respawn eligibility, kill credit.
|
|
@@ -37,7 +37,7 @@ export function createDeathSystem(deps) {
|
|
|
37
37
|
if (identity.displayName !== undefined)
|
|
38
38
|
event.displayName = identity.displayName;
|
|
39
39
|
deps.events.emit("entity.died", event);
|
|
40
|
-
const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId));
|
|
40
|
+
const onDeath = normalizeOnDeath(deps.resolveOnDeath(instanceId, reason));
|
|
41
41
|
const drops = [];
|
|
42
42
|
for (const rule of onDeath.drops) {
|
|
43
43
|
if (matchesReason(rule.when, reason))
|
|
@@ -47,7 +47,7 @@ export function createDeathSystem(deps) {
|
|
|
47
47
|
if (onDeath.command !== null &&
|
|
48
48
|
matchesReason(onDeath.command.when, reason) &&
|
|
49
49
|
deps.runCommand !== undefined) {
|
|
50
|
-
deps.runCommand(onDeath.command.name, onDeath.command.args);
|
|
50
|
+
deps.runCommand(onDeath.command.name, onDeath.command.args, reason);
|
|
51
51
|
ranCommand = onDeath.command.name;
|
|
52
52
|
}
|
|
53
53
|
deps.despawn(instanceId);
|
|
@@ -17,6 +17,13 @@ export interface MagazineConfig {
|
|
|
17
17
|
*/
|
|
18
18
|
reserve?: number | MagazineReserve;
|
|
19
19
|
}
|
|
20
|
+
/** Live magazine tuning; shrinking below loaded rounds rejects unless overflow is explicit. */
|
|
21
|
+
export interface MagazineTuning {
|
|
22
|
+
capacity?: number;
|
|
23
|
+
reloadMs?: number;
|
|
24
|
+
/** Defaults to `reject`; returning rounds requires a finite reserve with `gain`. */
|
|
25
|
+
overflow?: "reject" | "discard" | "return-to-reserve";
|
|
26
|
+
}
|
|
20
27
|
/** Complete serializable state for a {@link Magazine}; `reserve: null` means infinite reserve. */
|
|
21
28
|
export interface MagazineSnapshot {
|
|
22
29
|
loaded: number;
|
|
@@ -31,6 +38,8 @@ export interface MagazineSnapshot {
|
|
|
31
38
|
export interface Magazine {
|
|
32
39
|
loaded(): number;
|
|
33
40
|
capacity(): number;
|
|
41
|
+
/** Retune without granting ammo or resetting reload elapsed time; false leaves all state unchanged. */
|
|
42
|
+
retune(tuning: MagazineTuning): boolean;
|
|
34
43
|
/** Reserve rounds available to reload from, or `null` when the reserve is infinite. */
|
|
35
44
|
reserve(): number | null;
|
|
36
45
|
isReloading(): boolean;
|
package/dist/combat/magazine.js
CHANGED
|
@@ -26,8 +26,8 @@ const INFINITE_RESERVE = {
|
|
|
26
26
|
* @capability magazine a weapon magazine with capacity, timed reload, and reserve-pool interaction
|
|
27
27
|
*/
|
|
28
28
|
export function createMagazine(config) {
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
let capacity = Math.max(0, config.capacity);
|
|
30
|
+
let reloadMs = Math.max(0, config.reloadMs);
|
|
31
31
|
const isInfiniteReserve = config.reserve === undefined;
|
|
32
32
|
const reserve = isInfiniteReserve
|
|
33
33
|
? INFINITE_RESERVE
|
|
@@ -42,6 +42,28 @@ export function createMagazine(config) {
|
|
|
42
42
|
return {
|
|
43
43
|
loaded: () => loaded,
|
|
44
44
|
capacity: () => capacity,
|
|
45
|
+
retune(tuning) {
|
|
46
|
+
if ((tuning.capacity !== undefined && !Number.isFinite(tuning.capacity)) ||
|
|
47
|
+
(tuning.reloadMs !== undefined && !Number.isFinite(tuning.reloadMs)))
|
|
48
|
+
return false;
|
|
49
|
+
const nextCapacity = Math.max(0, tuning.capacity ?? capacity);
|
|
50
|
+
const nextReloadMs = Math.max(0, tuning.reloadMs ?? reloadMs);
|
|
51
|
+
const overflow = Math.max(0, loaded - nextCapacity);
|
|
52
|
+
if (overflow > 0) {
|
|
53
|
+
const policy = tuning.overflow ?? "reject";
|
|
54
|
+
if (policy === "reject")
|
|
55
|
+
return false;
|
|
56
|
+
if (policy === "return-to-reserve") {
|
|
57
|
+
if (isInfiniteReserve || reserve.gain === undefined)
|
|
58
|
+
return false;
|
|
59
|
+
reserve.gain(overflow);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
capacity = nextCapacity;
|
|
63
|
+
reloadMs = nextReloadMs;
|
|
64
|
+
loaded = clampLoaded(loaded);
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
45
67
|
reserve: () => (isInfiniteReserve ? null : reserve.current()),
|
|
46
68
|
isReloading: () => reloadElapsedMs !== null,
|
|
47
69
|
reloadFraction: () => (reloadElapsedMs === null || reloadMs <= 0 ? 0 : Math.min(1, reloadElapsedMs / reloadMs)),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { BallisticSweep } from "../physics/ballisticSweep.js";
|
|
2
2
|
import type { EntityPosition } from "../scene/entityStore.js";
|
|
3
|
-
import {
|
|
3
|
+
import type { EntityColliderSet } from "../scene/colliders.js";
|
|
4
4
|
import { type SceneRaycastApi } from "../scene/sceneRaycast.js";
|
|
5
5
|
import type { Aim } from "../scene/spatial.js";
|
|
6
6
|
import type { CombatSpatialDeps, EffectResult, EffectSystem, EffectVia } from "./effects.js";
|
|
@@ -10,8 +10,84 @@ export interface ProjectileShotInput {
|
|
|
10
10
|
via: EffectVia;
|
|
11
11
|
aim: Aim;
|
|
12
12
|
effect: string;
|
|
13
|
-
/** Defaults to `{ kind: "
|
|
13
|
+
/** Defaults to `{ kind: "converge" }` — explicit rays pass through; angular aim converges from the muzzle to the sightline. */
|
|
14
14
|
originPolicy?: ShotOriginPolicy;
|
|
15
|
+
/** Opt into simulation-owned travel. Values are captured at launch; time is in game-seconds. */
|
|
16
|
+
travel?: ProjectileTravelConfig;
|
|
17
|
+
}
|
|
18
|
+
/** Captured launch settings for authoritative live travel. */
|
|
19
|
+
export interface ProjectileTravelConfig {
|
|
20
|
+
speed: number;
|
|
21
|
+
lifetime: number;
|
|
22
|
+
/** Forwarded to the injected sweep; the default scene query sweeps a centerline. */
|
|
23
|
+
radius?: number;
|
|
24
|
+
gravity?: EntityPosition;
|
|
25
|
+
/** Game-owned wind coupling in 1/seconds, from 0 to 1e12; omission leaves wind response off. */
|
|
26
|
+
windResponse?: number;
|
|
27
|
+
/** Target bits read by the injected environmental force sampler. */
|
|
28
|
+
forceMask?: number;
|
|
29
|
+
/** Cap injected acceleration in m/s², from 0 to 1e12, independently of gravity. */
|
|
30
|
+
maxAcceleration?: number;
|
|
31
|
+
}
|
|
32
|
+
/** Nearest contact returned by a live segment sweep. */
|
|
33
|
+
export interface ProjectileTravelImpact {
|
|
34
|
+
/** Earliest contact fraction along this step, including moving targets. */
|
|
35
|
+
fraction: number;
|
|
36
|
+
at: EntityPosition;
|
|
37
|
+
target?: ProjectileHit;
|
|
38
|
+
}
|
|
39
|
+
/** Authoritative clock, collision and environmental acceleration providers. */
|
|
40
|
+
export interface ProjectileTravelDeps {
|
|
41
|
+
/** Authoritative game-seconds; no wall-clock fallback is used for live travel. */
|
|
42
|
+
now(): number;
|
|
43
|
+
sweep?(from: EntityPosition, to: EntityPosition, step: {
|
|
44
|
+
shotId: string;
|
|
45
|
+
input: ProjectileShotInput;
|
|
46
|
+
radius: number;
|
|
47
|
+
fromTime: number;
|
|
48
|
+
toTime: number;
|
|
49
|
+
}): ProjectileTravelImpact | null;
|
|
50
|
+
acceleration?(position: EntityPosition, velocity: EntityPosition, time: number, input: ProjectileShotInput): EntityPosition;
|
|
51
|
+
maxActive?: number;
|
|
52
|
+
maxRetained?: number;
|
|
53
|
+
}
|
|
54
|
+
/** Detached pose of one active projectile pellet for presentation. */
|
|
55
|
+
export interface LiveProjectile {
|
|
56
|
+
shotId: string;
|
|
57
|
+
pellet: number;
|
|
58
|
+
from: string;
|
|
59
|
+
effect: string;
|
|
60
|
+
via: EffectVia;
|
|
61
|
+
position: EntityPosition;
|
|
62
|
+
velocity: EntityPosition;
|
|
63
|
+
age: number;
|
|
64
|
+
radius: number;
|
|
65
|
+
}
|
|
66
|
+
/** Serializable motion state of one projectile pellet. */
|
|
67
|
+
export interface ProjectileFlightState {
|
|
68
|
+
position: EntityPosition;
|
|
69
|
+
velocity: EntityPosition;
|
|
70
|
+
age: number;
|
|
71
|
+
distance: number;
|
|
72
|
+
active: boolean;
|
|
73
|
+
}
|
|
74
|
+
/** Serializable captured launch and settlement state. */
|
|
75
|
+
export interface ProjectileShotState {
|
|
76
|
+
shotId: string;
|
|
77
|
+
input: ProjectileShotInput;
|
|
78
|
+
coneSamples: readonly (readonly [number, number])[];
|
|
79
|
+
pellets: number;
|
|
80
|
+
firedAt: number;
|
|
81
|
+
settled: boolean;
|
|
82
|
+
origin?: EntityPosition;
|
|
83
|
+
flights?: ProjectileFlightState[];
|
|
84
|
+
hits: EffectResult[];
|
|
85
|
+
at?: EntityPosition;
|
|
86
|
+
}
|
|
87
|
+
/** Detached projectile owner state for replay and restore. */
|
|
88
|
+
export interface ProjectileSystemState {
|
|
89
|
+
shotCounter: number;
|
|
90
|
+
shots: ProjectileShotState[];
|
|
15
91
|
}
|
|
16
92
|
export interface EntityRaycastHit {
|
|
17
93
|
kind: "entity";
|
|
@@ -34,7 +110,11 @@ export interface ObjectRaycastHit {
|
|
|
34
110
|
}
|
|
35
111
|
export type RaycastHit = EntityRaycastHit | ObjectRaycastHit;
|
|
36
112
|
export type Raycast = (from: string, aim: Aim, range: number, originPolicy?: ShotOriginPolicy) => RaycastHit[];
|
|
113
|
+
/** Exactly-once settlement with launch identity and actual applied effects. */
|
|
37
114
|
export interface ProjectileSettleReport {
|
|
115
|
+
shotId: string;
|
|
116
|
+
/** Actual applied effects; empty for misses and cover impacts. */
|
|
117
|
+
hits: readonly EffectResult[];
|
|
38
118
|
from: string;
|
|
39
119
|
origin: EntityPosition;
|
|
40
120
|
at: EntityPosition;
|
|
@@ -75,7 +155,12 @@ export interface ProjectileSystemDeps {
|
|
|
75
155
|
sweepBallistic?: BallisticSweep;
|
|
76
156
|
defaultOriginPolicy?: ShotOriginPolicy;
|
|
77
157
|
now?: () => number;
|
|
158
|
+
/** Cone samples use two draws per nonzero-spread pellet at fire time; defaults to Math.random. */
|
|
159
|
+
rng?: () => number;
|
|
160
|
+
/** Per-shot ray limit, clamped to [1, 256]; defaults to 64. */
|
|
161
|
+
maxPellets?: number;
|
|
78
162
|
onSettle?(report: ProjectileSettleReport): void;
|
|
163
|
+
travel?: ProjectileTravelDeps;
|
|
79
164
|
}
|
|
80
165
|
export type ProjectileHit = {
|
|
81
166
|
kind: "entity";
|
|
@@ -110,9 +195,16 @@ export type SettleResult = {
|
|
|
110
195
|
reason: string;
|
|
111
196
|
};
|
|
112
197
|
export interface ProjectileSystem {
|
|
198
|
+
/** Predict the center ray without consuming random samples or applying effects. */
|
|
113
199
|
willHitProjectile(input: ProjectileShotInput): ProjectilePrediction;
|
|
200
|
+
/** Capture aim, origin-policy vectors and cone samples independently of subsequent caller mutation. */
|
|
114
201
|
fireProjectile(input: ProjectileShotInput): string;
|
|
115
202
|
settleProjectile(shotId: string): SettleResult;
|
|
203
|
+
/** Advance live shots once per authoritative simulation step; settled shots emit no further effects. */
|
|
204
|
+
advanceProjectiles(dt: number, time: number): SettleResult[];
|
|
205
|
+
activeProjectiles(): LiveProjectile[];
|
|
206
|
+
snapshot(): ProjectileSystemState;
|
|
207
|
+
restore(state: ProjectileSystemState): void;
|
|
116
208
|
}
|
|
117
209
|
/**
|
|
118
210
|
* Spawn and advance projectiles each frame, resolving travel, lifetime, and hits.
|
|
@@ -1,12 +1,33 @@
|
|
|
1
|
-
import { defaultEntityColliders, resolveColliders } from "../scene/colliders.js";
|
|
2
1
|
import { createSceneRaycast, firstImpact, hitsUntilBlocked, } from "../scene/sceneRaycast.js";
|
|
3
2
|
import { aimDirection, aimSpreadDeg, convergeShot, resolveShot, } from "./shotOrigin.js";
|
|
4
3
|
const DEFAULT_RANGE = 100;
|
|
5
4
|
const DEFAULT_PROJECTILE_SPEED = 15;
|
|
6
5
|
const GRAVITY = 9.8;
|
|
7
6
|
const BASE_HIT_RADIUS = 0.5;
|
|
8
|
-
function
|
|
9
|
-
return
|
|
7
|
+
function copyPosition(position) {
|
|
8
|
+
return [position[0], position[1], position[2]];
|
|
9
|
+
}
|
|
10
|
+
function copyAim(aim) {
|
|
11
|
+
return "origin" in aim
|
|
12
|
+
? { ...aim, origin: copyPosition(aim.origin), direction: copyPosition(aim.direction) }
|
|
13
|
+
: { ...aim };
|
|
14
|
+
}
|
|
15
|
+
function copyOriginPolicy(policy) {
|
|
16
|
+
switch (policy.kind) {
|
|
17
|
+
case "camera":
|
|
18
|
+
case "world":
|
|
19
|
+
return { ...policy, origin: copyPosition(policy.origin), ...(policy.direction !== undefined ? { direction: copyPosition(policy.direction) } : {}) };
|
|
20
|
+
case "entityOffset":
|
|
21
|
+
return { ...policy, offset: copyPosition(policy.offset) };
|
|
22
|
+
case "muzzle":
|
|
23
|
+
return { ...policy, ...(policy.offset !== undefined ? { offset: copyPosition(policy.offset) } : {}) };
|
|
24
|
+
case "converge":
|
|
25
|
+
return { ...policy, ...(policy.muzzle !== undefined ? { muzzle: copyPosition(policy.muzzle) } : {}) };
|
|
26
|
+
case "eye":
|
|
27
|
+
case "legacy":
|
|
28
|
+
case "entity":
|
|
29
|
+
return { ...policy };
|
|
30
|
+
}
|
|
10
31
|
}
|
|
11
32
|
function isObjectHit(hit) {
|
|
12
33
|
return hit.kind === "object";
|
|
@@ -67,6 +88,41 @@ function asSceneHits(hits) {
|
|
|
67
88
|
normal: [0, 1, 0],
|
|
68
89
|
}));
|
|
69
90
|
}
|
|
91
|
+
/** Validate captured travel settings before a launch or restore can mutate the owner. */
|
|
92
|
+
function validateProjectileTravel(travel) {
|
|
93
|
+
if (!(travel.speed > 0) || !Number.isFinite(travel.speed) || !(travel.lifetime > 0) || !Number.isFinite(travel.lifetime) ||
|
|
94
|
+
!(travel.radius === undefined || (Number.isFinite(travel.radius) && travel.radius >= 0)) ||
|
|
95
|
+
!(travel.gravity === undefined || travel.gravity.every(Number.isFinite))) {
|
|
96
|
+
throw new RangeError("Projectile travel requires finite positive speed/lifetime and nonnegative radius");
|
|
97
|
+
}
|
|
98
|
+
if (!(travel.windResponse === undefined || (Number.isFinite(travel.windResponse) && travel.windResponse >= 0 && travel.windResponse <= 1e12)) ||
|
|
99
|
+
!(travel.maxAcceleration === undefined || (Number.isFinite(travel.maxAcceleration) && travel.maxAcceleration >= 0 && travel.maxAcceleration <= 1e12))) {
|
|
100
|
+
throw new RangeError("Projectile wind response and acceleration cap must be finite and between 0 and 1e12");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function sampleCone(forward, spreadDeg, sample) {
|
|
104
|
+
const cone = Math.min(Math.PI / 2, spreadDeg * Math.PI / 180);
|
|
105
|
+
const unit = (value) => Number.isFinite(value) ? Math.max(0, Math.min(1, value)) : 0;
|
|
106
|
+
const cosTheta = 1 - unit(sample[0]) * (1 - Math.cos(cone));
|
|
107
|
+
const sinTheta = Math.sqrt(Math.max(0, 1 - cosTheta * cosTheta));
|
|
108
|
+
const phi = unit(sample[1]) * Math.PI * 2;
|
|
109
|
+
const horizontal = Math.hypot(forward[0], forward[2]);
|
|
110
|
+
const right = horizontal > 1e-9
|
|
111
|
+
? [forward[2] / horizontal, 0, -forward[0] / horizontal]
|
|
112
|
+
: [1, 0, 0];
|
|
113
|
+
const up = [
|
|
114
|
+
forward[1] * right[2] - forward[2] * right[1],
|
|
115
|
+
forward[2] * right[0] - forward[0] * right[2],
|
|
116
|
+
forward[0] * right[1] - forward[1] * right[0],
|
|
117
|
+
];
|
|
118
|
+
const x = sinTheta * Math.cos(phi);
|
|
119
|
+
const y = sinTheta * Math.sin(phi);
|
|
120
|
+
return [
|
|
121
|
+
forward[0] * cosTheta + right[0] * x + up[0] * y,
|
|
122
|
+
forward[1] * cosTheta + right[1] * x + up[1] * y,
|
|
123
|
+
forward[2] * cosTheta + right[2] * x + up[2] * y,
|
|
124
|
+
];
|
|
125
|
+
}
|
|
70
126
|
/**
|
|
71
127
|
* Spawn and advance projectiles each frame, resolving travel, lifetime, and hits.
|
|
72
128
|
*
|
|
@@ -75,8 +131,43 @@ function asSceneHits(hits) {
|
|
|
75
131
|
export function createProjectileSystem(deps) {
|
|
76
132
|
const shots = new Map();
|
|
77
133
|
let shotCounter = 0;
|
|
134
|
+
let liveCount = 0;
|
|
78
135
|
const now = deps.now ?? (() => Date.now());
|
|
136
|
+
const rng = deps.rng ?? Math.random;
|
|
137
|
+
const maxPellets = Number.isFinite(deps.maxPellets)
|
|
138
|
+
? Math.max(1, Math.min(256, Math.floor(deps.maxPellets)))
|
|
139
|
+
: 64;
|
|
79
140
|
const defaultPolicy = deps.defaultOriginPolicy ?? { kind: "converge" };
|
|
141
|
+
const maxActive = Math.max(1, Math.floor(deps.travel?.maxActive ?? 256));
|
|
142
|
+
const maxRetained = Math.max(0, Math.floor(deps.travel?.maxRetained ?? 256));
|
|
143
|
+
if (!Number.isFinite(maxActive) || !Number.isFinite(maxRetained))
|
|
144
|
+
throw new RangeError("Projectile budgets must be finite");
|
|
145
|
+
function pruneSettled() {
|
|
146
|
+
let retained = 0;
|
|
147
|
+
for (const shot of shots.values())
|
|
148
|
+
if (shot.settled)
|
|
149
|
+
retained += 1;
|
|
150
|
+
for (const [id, shot] of shots) {
|
|
151
|
+
if (retained <= maxRetained)
|
|
152
|
+
break;
|
|
153
|
+
if (shot.settled) {
|
|
154
|
+
shots.delete(id);
|
|
155
|
+
retained -= 1;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function copyShot(shot) {
|
|
160
|
+
return structuredClone(shot);
|
|
161
|
+
}
|
|
162
|
+
function finishFlight(shot) {
|
|
163
|
+
shot.settled = true;
|
|
164
|
+
liveCount -= 1;
|
|
165
|
+
const origin = shot.origin;
|
|
166
|
+
const at = shot.at ?? shot.flights[0].position;
|
|
167
|
+
deps.onSettle?.({ shotId: shot.shotId, hits: structuredClone(shot.hits), from: shot.input.from, origin: copyPosition(origin), at: copyPosition(at), effect: shot.input.effect, hit: shot.hits.length > 0, ballistic: true });
|
|
168
|
+
pruneSettled();
|
|
169
|
+
return { status: "settled", shotId: shot.shotId, at: [at[0], at[1], at[2]], origin: [origin[0], origin[1], origin[2]], hits: shot.hits };
|
|
170
|
+
}
|
|
80
171
|
function itemStat(via, stat) {
|
|
81
172
|
return via.item === undefined ? null : deps.getStat(via.item, stat);
|
|
82
173
|
}
|
|
@@ -163,38 +254,7 @@ export function createProjectileSystem(deps) {
|
|
|
163
254
|
excludeInstanceIds: [from],
|
|
164
255
|
});
|
|
165
256
|
const until = hitsUntilBlocked(all);
|
|
166
|
-
|
|
167
|
-
const mapped = [];
|
|
168
|
-
for (const hit of until) {
|
|
169
|
-
if (hit.targetKind === "entity" && spreadRad > 0) {
|
|
170
|
-
const position = deps.spatial.positionOf(hit.instanceId);
|
|
171
|
-
if (position !== undefined) {
|
|
172
|
-
const colliders = resolveColliders(deps.entityCollidersOf?.(hit.instanceId) ?? defaultEntityColliders());
|
|
173
|
-
const collider = colliders.find((candidate) => candidate.name === hit.colliderName) ?? colliders[0];
|
|
174
|
-
const localOffset = collider?.shape.offset ?? [0, 0, 0];
|
|
175
|
-
const slack = collider === undefined
|
|
176
|
-
? BASE_HIT_RADIUS
|
|
177
|
-
: collider.shape.kind === "sphere"
|
|
178
|
-
? collider.shape.radius
|
|
179
|
-
: Math.hypot(...collider.shape.halfExtents);
|
|
180
|
-
const rotationY = deps.rotationYOf?.(hit.instanceId) ?? 0;
|
|
181
|
-
const cos = Math.cos(rotationY);
|
|
182
|
-
const sin = Math.sin(rotationY);
|
|
183
|
-
const dx = position[0] + localOffset[0] * cos + localOffset[2] * sin - origin[0];
|
|
184
|
-
const dy = position[1] + localOffset[1] - origin[1];
|
|
185
|
-
const dz = position[2] - localOffset[0] * sin + localOffset[2] * cos - origin[2];
|
|
186
|
-
const along = dx * direction[0] + dy * direction[1] + dz * direction[2];
|
|
187
|
-
const px = dx - direction[0] * along;
|
|
188
|
-
const py = dy - direction[1] * along;
|
|
189
|
-
const pz = dz - direction[2] * along;
|
|
190
|
-
const perpendicular = Math.sqrt(px * px + py * py + pz * pz);
|
|
191
|
-
if (perpendicular > slack + Math.tan(spreadRad) * along)
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
mapped.push(sceneHitToRaycast(hit));
|
|
196
|
-
}
|
|
197
|
-
return mapped;
|
|
257
|
+
return until.map(sceneHitToRaycast);
|
|
198
258
|
});
|
|
199
259
|
function resolveRange(via) {
|
|
200
260
|
return itemStat(via, "range") ?? DEFAULT_RANGE;
|
|
@@ -280,18 +340,179 @@ export function createProjectileSystem(deps) {
|
|
|
280
340
|
return prediction;
|
|
281
341
|
},
|
|
282
342
|
fireProjectile(input) {
|
|
343
|
+
if (input.travel !== undefined) {
|
|
344
|
+
if (deps.travel === undefined)
|
|
345
|
+
throw new Error("Live projectiles require an authoritative travel clock");
|
|
346
|
+
const travel = input.travel;
|
|
347
|
+
validateProjectileTravel(travel);
|
|
348
|
+
if ((travel.radius ?? 0) > 0 && deps.travel.sweep === undefined)
|
|
349
|
+
throw new RangeError("Projectile radius requires an injected radius-aware sweep");
|
|
350
|
+
if (liveCount >= maxActive)
|
|
351
|
+
throw new RangeError("Active projectile budget exhausted");
|
|
352
|
+
}
|
|
283
353
|
shotCounter += 1;
|
|
284
354
|
const shotId = `shot_${shotCounter}`;
|
|
285
|
-
|
|
355
|
+
const captured = {
|
|
356
|
+
...input,
|
|
357
|
+
aim: copyAim(input.aim),
|
|
358
|
+
via: { ...input.via },
|
|
359
|
+
originPolicy: copyOriginPolicy(input.originPolicy ?? defaultPolicy),
|
|
360
|
+
...(input.travel === undefined ? {} : { travel: { ...input.travel, ...(input.travel.gravity === undefined ? {} : { gravity: copyPosition(input.travel.gravity) }) } }),
|
|
361
|
+
};
|
|
362
|
+
captured.aim = withWeaponSpread(captured.via, captured.aim);
|
|
363
|
+
const count = itemStat(captured.via, "pellets") ?? 1;
|
|
364
|
+
const pellets = Number.isFinite(count) ? Math.max(1, Math.min(maxPellets, Math.round(count))) : 1;
|
|
365
|
+
const coneSamples = [];
|
|
366
|
+
if ((captured.travel !== undefined || !isBallistic(captured.via)) && aimSpreadDeg(captured.aim) > 0) {
|
|
367
|
+
for (let pellet = 0; pellet < pellets; pellet += 1) {
|
|
368
|
+
coneSamples.push([rng(), rng()]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const shot = { shotId, input: captured, coneSamples, pellets, firedAt: captured.travel === undefined ? now() : deps.travel.now(), settled: false, hits: [] };
|
|
372
|
+
if (captured.travel !== undefined) {
|
|
373
|
+
if (!Number.isFinite(shot.firedAt))
|
|
374
|
+
throw new RangeError("Projectile authority time must be finite");
|
|
375
|
+
const resolved = resolveShotFor(captured.from, captured.aim, captured.originPolicy, resolveRange(captured.via));
|
|
376
|
+
if (resolved === null)
|
|
377
|
+
throw new Error("Live projectile requires a resolvable launch origin and direction");
|
|
378
|
+
shot.origin = copyPosition(resolved.origin);
|
|
379
|
+
shot.flights = [];
|
|
380
|
+
for (let pellet = 0; pellet < pellets; pellet += 1) {
|
|
381
|
+
const sample = coneSamples[pellet];
|
|
382
|
+
const direction = sample === undefined ? resolved.direction : sampleCone(resolved.direction, aimSpreadDeg(captured.aim), sample);
|
|
383
|
+
shot.flights.push({ position: copyPosition(resolved.origin), velocity: [direction[0] * captured.travel.speed, direction[1] * captured.travel.speed, direction[2] * captured.travel.speed], age: 0, distance: 0, active: true });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
shots.set(shotId, shot);
|
|
387
|
+
if (shot.flights !== undefined)
|
|
388
|
+
liveCount += 1;
|
|
286
389
|
return shotId;
|
|
287
390
|
},
|
|
391
|
+
advanceProjectiles(dt, time) {
|
|
392
|
+
if (!Number.isFinite(dt) || dt < 0 || !Number.isFinite(time))
|
|
393
|
+
throw new RangeError("Projectile steps require finite nonnegative dt and authoritative time");
|
|
394
|
+
const results = [];
|
|
395
|
+
if (dt === 0 || liveCount === 0)
|
|
396
|
+
return results;
|
|
397
|
+
for (const shot of Array.from(shots.values())) {
|
|
398
|
+
if (shot.settled || shot.flights === undefined)
|
|
399
|
+
continue;
|
|
400
|
+
const travel = shot.input.travel;
|
|
401
|
+
const gravity = travel.gravity ?? [0, 0, 0];
|
|
402
|
+
const range = resolveRange(shot.input.via);
|
|
403
|
+
for (const flight of shot.flights) {
|
|
404
|
+
if (!flight.active)
|
|
405
|
+
continue;
|
|
406
|
+
const step = Math.min(dt, Math.max(0, time - shot.firedAt - flight.age), Math.max(0, travel.lifetime - flight.age));
|
|
407
|
+
if (step <= 0)
|
|
408
|
+
continue;
|
|
409
|
+
const fromTime = shot.firedAt + flight.age;
|
|
410
|
+
const external = deps.travel?.acceleration?.(copyPosition(flight.position), copyPosition(flight.velocity), fromTime, shot.input) ?? [0, 0, 0];
|
|
411
|
+
if (!external.every(Number.isFinite))
|
|
412
|
+
throw new RangeError("Projectile acceleration must be finite");
|
|
413
|
+
const magnitude = Math.hypot(...external);
|
|
414
|
+
const cap = travel.maxAcceleration ?? Number.POSITIVE_INFINITY;
|
|
415
|
+
const forceScale = magnitude > cap ? cap / magnitude : 1;
|
|
416
|
+
const acceleration = [gravity[0] + external[0] * forceScale, gravity[1] + external[1] * forceScale, gravity[2] + external[2] * forceScale];
|
|
417
|
+
const from = flight.position;
|
|
418
|
+
const to = [from[0] + flight.velocity[0] * step + acceleration[0] * step * step / 2, from[1] + flight.velocity[1] * step + acceleration[1] * step * step / 2, from[2] + flight.velocity[2] * step + acceleration[2] * step * step / 2];
|
|
419
|
+
const length = Math.hypot(to[0] - from[0], to[1] - from[1], to[2] - from[2]);
|
|
420
|
+
const rangeFraction = length > 0 ? Math.max(0, Math.min(1, (range - flight.distance) / length)) : 1;
|
|
421
|
+
const end = [from[0] + (to[0] - from[0]) * rangeFraction, from[1] + (to[1] - from[1]) * rangeFraction, from[2] + (to[2] - from[2]) * rangeFraction];
|
|
422
|
+
let impact;
|
|
423
|
+
if (deps.travel?.sweep !== undefined) {
|
|
424
|
+
impact = deps.travel.sweep(copyPosition(from), copyPosition(end), { shotId: shot.shotId, input: shot.input, radius: travel.radius ?? 0, fromTime, toTime: fromTime + step * rangeFraction });
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
const distance = length * rangeFraction;
|
|
428
|
+
const hits = distance > 0 ? internalSceneRaycast.raycastAll({ origin: from, direction: [end[0] - from[0], end[1] - from[1], end[2] - from[2]], maxDistance: distance, excludeInstanceIds: [shot.input.from] }) : [];
|
|
429
|
+
const hit = hits.find(hit => hit.blocks || (hit.targetKind === "entity" && hit.damageEligible)) ?? null;
|
|
430
|
+
impact = hit === null ? null : { fraction: hit.distance / distance, at: hit.point, target: toProjectileHit(sceneHitToRaycast(hit)) };
|
|
431
|
+
}
|
|
432
|
+
if (impact !== null && (!Number.isFinite(impact.fraction) || impact.fraction < 0 || impact.fraction > 1 || !impact.at.every(Number.isFinite)))
|
|
433
|
+
throw new RangeError("Projectile sweep must return a finite contact in the segment");
|
|
434
|
+
const fraction = rangeFraction * (impact?.fraction ?? 1);
|
|
435
|
+
flight.position = impact === null ? end : copyPosition(impact.at);
|
|
436
|
+
flight.velocity = [flight.velocity[0] + acceleration[0] * step * fraction, flight.velocity[1] + acceleration[1] * step * fraction, flight.velocity[2] + acceleration[2] * step * fraction];
|
|
437
|
+
flight.age += step * fraction;
|
|
438
|
+
flight.distance += length * fraction;
|
|
439
|
+
if (impact !== null || rangeFraction < 1 || flight.age >= travel.lifetime || flight.distance >= range) {
|
|
440
|
+
flight.active = false;
|
|
441
|
+
shot.at ??= copyPosition(flight.position);
|
|
442
|
+
const target = impact?.target;
|
|
443
|
+
const splash = itemStat(shot.input.via, "explosion.radius");
|
|
444
|
+
if (impact !== null && splash !== null) {
|
|
445
|
+
shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, effect: shot.input.effect, via: shot.input.via, at: flight.position, radius: splash, falloff: "linear" }));
|
|
446
|
+
}
|
|
447
|
+
else if (target?.kind === "entity" && target.damageEligible !== false && deps.effects.canReceive(target.instanceId, shot.input.effect) === null) {
|
|
448
|
+
shot.hits.push(...deps.effects.applyEffect({ from: shot.input.from, to: target.instanceId, effect: shot.input.effect, via: shot.input.via }));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (shot.flights.every(flight => !flight.active))
|
|
453
|
+
results.push(finishFlight(shot));
|
|
454
|
+
}
|
|
455
|
+
return results;
|
|
456
|
+
},
|
|
457
|
+
activeProjectiles() {
|
|
458
|
+
const active = [];
|
|
459
|
+
if (liveCount === 0)
|
|
460
|
+
return active;
|
|
461
|
+
for (const shot of shots.values()) {
|
|
462
|
+
if (shot.settled || shot.flights === undefined)
|
|
463
|
+
continue;
|
|
464
|
+
shot.flights.forEach((flight, pellet) => {
|
|
465
|
+
if (flight.active)
|
|
466
|
+
active.push({ shotId: shot.shotId, pellet, from: shot.input.from, effect: shot.input.effect, via: { ...shot.input.via }, position: copyPosition(flight.position), velocity: copyPosition(flight.velocity), age: flight.age, radius: shot.input.travel?.radius ?? 0 });
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return active;
|
|
470
|
+
},
|
|
471
|
+
snapshot: () => ({ shotCounter, shots: Array.from(shots.values(), copyShot) }),
|
|
472
|
+
restore(state) {
|
|
473
|
+
const next = new Map();
|
|
474
|
+
let active = 0;
|
|
475
|
+
for (const shot of state.shots) {
|
|
476
|
+
if (!Number.isInteger(shot.pellets) || shot.pellets < 1 || shot.pellets > maxPellets ||
|
|
477
|
+
!Array.isArray(shot.coneSamples) || (shot.coneSamples.length !== 0 && shot.coneSamples.length !== shot.pellets)) {
|
|
478
|
+
throw new RangeError("Restored projectile pellet budget or sample count is invalid");
|
|
479
|
+
}
|
|
480
|
+
if (shot.input.travel !== undefined) {
|
|
481
|
+
validateProjectileTravel(shot.input.travel);
|
|
482
|
+
if ((shot.input.travel.radius ?? 0) > 0 && deps.travel?.sweep === undefined)
|
|
483
|
+
throw new RangeError("Projectile radius requires an injected radius-aware sweep");
|
|
484
|
+
}
|
|
485
|
+
if ((shot.flights !== undefined) !== (shot.input.travel !== undefined) ||
|
|
486
|
+
(shot.flights !== undefined && (!Array.isArray(shot.flights) || shot.flights.length !== shot.pellets))) {
|
|
487
|
+
throw new RangeError("Restored projectile flight count must match captured travel pellets");
|
|
488
|
+
}
|
|
489
|
+
if (shot.flights !== undefined && deps.travel === undefined)
|
|
490
|
+
throw new Error("Restoring live projectiles requires an authoritative travel clock");
|
|
491
|
+
if (shot.flights !== undefined && !shot.settled)
|
|
492
|
+
active += 1;
|
|
493
|
+
if (next.has(shot.shotId))
|
|
494
|
+
throw new Error("Restoring duplicate projectile ids");
|
|
495
|
+
next.set(shot.shotId, copyShot(shot));
|
|
496
|
+
}
|
|
497
|
+
if (active > maxActive)
|
|
498
|
+
throw new RangeError("Restored projectile budget exhausted");
|
|
499
|
+
shots.clear();
|
|
500
|
+
shotCounter = state.shotCounter;
|
|
501
|
+
liveCount = active;
|
|
502
|
+
for (const [id, shot] of next)
|
|
503
|
+
shots.set(id, shot);
|
|
504
|
+
pruneSettled();
|
|
505
|
+
},
|
|
288
506
|
settleProjectile(shotId) {
|
|
289
507
|
const shot = shots.get(shotId);
|
|
290
508
|
if (shot === undefined)
|
|
291
509
|
return { status: "rejected", shotId, reason: "unknown-shot" };
|
|
292
510
|
if (shot.settled)
|
|
293
511
|
return { status: "rejected", shotId, reason: "already-settled" };
|
|
512
|
+
if (shot.flights !== undefined)
|
|
513
|
+
return { status: "rejected", shotId, reason: "in-flight" };
|
|
294
514
|
shot.settled = true;
|
|
515
|
+
pruneSettled();
|
|
295
516
|
const { input } = shot;
|
|
296
517
|
const resolved = resolveShotFor(input.from, input.aim, input.originPolicy, resolveRange(input.via));
|
|
297
518
|
const origin = resolved?.origin ?? [0, 0, 0];
|
|
@@ -308,23 +529,28 @@ export function createProjectileSystem(deps) {
|
|
|
308
529
|
radius,
|
|
309
530
|
falloff: splashRadius === null ? "none" : "linear",
|
|
310
531
|
});
|
|
311
|
-
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: true });
|
|
532
|
+
deps.onSettle?.({ shotId, hits: structuredClone(hits), from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: true });
|
|
312
533
|
return { status: "settled", shotId, at, hits, origin: originTuple };
|
|
313
534
|
}
|
|
314
|
-
const
|
|
315
|
-
const ordered = asSceneHits(rawHits);
|
|
316
|
-
const untilBlock = hitsUntilBlocked(ordered);
|
|
317
|
-
const impact = firstImpact(untilBlock);
|
|
318
|
-
const solidBlock = impact !== null && impact.blocks && !impact.damageEligible;
|
|
319
|
-
const damageEntityHits = visible.filter((hit) => isEntityHit(hit) &&
|
|
320
|
-
hit.damageEligible !== false &&
|
|
321
|
-
(!solidBlock || hit.distance <= (impact?.distance ?? Infinity) + 1e-9) &&
|
|
322
|
-
deps.effects.canReceive(hit.instanceId, input.effect) === null);
|
|
323
|
-
const pellets = Math.max(1, Math.round(itemStat(input.via, "pellets") ?? 1));
|
|
535
|
+
const pellets = shot.pellets;
|
|
324
536
|
const hits = [];
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
537
|
+
let at;
|
|
538
|
+
for (let pellet = 0; pellet < pellets; pellet += 1) {
|
|
539
|
+
const sample = shot.coneSamples[pellet];
|
|
540
|
+
const pelletInput = sample === undefined || resolved === null
|
|
541
|
+
? input
|
|
542
|
+
: {
|
|
543
|
+
...input,
|
|
544
|
+
aim: { origin, direction: sampleCone(resolved.direction, aimSpreadDeg(input.aim), sample) },
|
|
545
|
+
originPolicy: { kind: "eye" },
|
|
546
|
+
};
|
|
547
|
+
const { rawHits } = predictHits(pelletInput);
|
|
548
|
+
const untilBlock = hitsUntilBlocked(asSceneHits(rawHits));
|
|
549
|
+
const impact = firstImpact(untilBlock);
|
|
550
|
+
const target = untilBlock.find((hit) => hit.targetKind === "entity" && hit.damageEligible &&
|
|
551
|
+
deps.spatial.hasLineOfSight(input.from, hit.instanceId) &&
|
|
552
|
+
deps.effects.canReceive(hit.instanceId, input.effect) === null);
|
|
553
|
+
if (target !== undefined) {
|
|
328
554
|
hits.push(...deps.effects.applyEffect({
|
|
329
555
|
from: input.from,
|
|
330
556
|
to: target.instanceId,
|
|
@@ -332,18 +558,13 @@ export function createProjectileSystem(deps) {
|
|
|
332
558
|
via: input.via,
|
|
333
559
|
}));
|
|
334
560
|
}
|
|
561
|
+
if (at === undefined) {
|
|
562
|
+
const point = impact?.point ?? target?.point ?? missPoint(pelletInput);
|
|
563
|
+
at = [point[0], point[1], point[2]];
|
|
564
|
+
}
|
|
335
565
|
}
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
at = [impact.point[0], impact.point[1], impact.point[2]];
|
|
339
|
-
}
|
|
340
|
-
else if (damageEntityHits[0] !== undefined) {
|
|
341
|
-
at = [damageEntityHits[0].at[0], damageEntityHits[0].at[1], damageEntityHits[0].at[2]];
|
|
342
|
-
}
|
|
343
|
-
else {
|
|
344
|
-
at = missPoint(input);
|
|
345
|
-
}
|
|
346
|
-
deps.onSettle?.({ from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: false });
|
|
566
|
+
at ??= missPoint(input);
|
|
567
|
+
deps.onSettle?.({ shotId, hits: structuredClone(hits), from: input.from, origin, at, effect: input.effect, hit: hits.length > 0, ballistic: false });
|
|
347
568
|
return { status: "settled", shotId, at, hits, origin: originTuple };
|
|
348
569
|
},
|
|
349
570
|
};
|