@jgengine/core 0.19.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +364 -0
- package/dist/ai/decisionGraph.d.ts +2 -0
- package/dist/ai/decisionGraph.js +6 -0
- package/dist/ai/flock.d.ts +1 -1
- package/dist/ai/flock.js +7 -4
- package/dist/anim/animGraph.d.ts +33 -2
- package/dist/anim/animGraph.js +200 -12
- package/dist/anim/locomotionGraph.d.ts +11 -3
- package/dist/anim/locomotionGraph.js +25 -15
- package/dist/audio/audioFalloff.d.ts +4 -0
- package/dist/audio/voiceAllocator.d.ts +46 -0
- package/dist/audio/voiceAllocator.js +76 -0
- package/dist/combat/abilityKit.d.ts +19 -0
- package/dist/combat/abilityKit.js +40 -4
- package/dist/combat/death.d.ts +4 -3
- package/dist/combat/death.js +3 -3
- package/dist/combat/magazine.d.ts +9 -0
- package/dist/combat/magazine.js +24 -2
- package/dist/combat/projectiles.d.ts +94 -2
- package/dist/combat/projectiles.js +282 -61
- package/dist/combat/regenShield.d.ts +14 -0
- package/dist/combat/regenShield.js +23 -7
- package/dist/crafting/recipeCatalog.d.ts +16 -0
- package/dist/crafting/recipeCatalog.js +55 -0
- package/dist/economy/auctionBook.d.ts +1 -1
- package/dist/economy/auctionBook.js +11 -1
- package/dist/economy/listingBook.d.ts +1 -1
- package/dist/economy/listingBook.js +11 -1
- package/dist/economy/resourceLedger.js +3 -3
- package/dist/economy/shopStock.js +7 -6
- package/dist/economy/techTree.js +7 -6
- package/dist/economy/unlockPoints.js +3 -6
- package/dist/editor/commandApply.js +89 -16
- package/dist/editor/commands.d.ts +38 -1
- package/dist/editor/commands.js +320 -3
- package/dist/editor/creatorStorage.d.ts +59 -0
- package/dist/editor/creatorStorage.js +164 -0
- package/dist/editor/document.d.ts +4 -0
- package/dist/editor/document.js +116 -17
- package/dist/editor/index.d.ts +3 -0
- package/dist/editor/index.js +1 -0
- package/dist/editor/liveSync.d.ts +4 -1
- package/dist/editor/liveSync.js +26 -10
- package/dist/editor/materialAuthoring.d.ts +14 -0
- package/dist/editor/materialAuthoring.js +38 -0
- package/dist/editor/modelNodeVisibility.d.ts +11 -0
- package/dist/editor/modelNodeVisibility.js +29 -0
- package/dist/editor/movementCatalog.d.ts +90 -0
- package/dist/editor/movementCatalog.js +144 -0
- package/dist/editor/simulation.d.ts +60 -0
- package/dist/editor/simulation.js +202 -0
- package/dist/editor/staticPrefab.d.ts +17 -0
- package/dist/editor/staticPrefab.js +51 -0
- package/dist/editor/terrainMaterials.d.ts +10 -0
- package/dist/editor/terrainMaterials.js +13 -0
- package/dist/editor/types.d.ts +11 -0
- package/dist/game/cameraConfig.d.ts +3 -1
- package/dist/game/clipRoles.d.ts +2 -1
- package/dist/game/clipRoles.js +7 -3
- package/dist/game/contentValidation.d.ts +69 -0
- package/dist/game/contentValidation.js +118 -0
- package/dist/game/controlGate.d.ts +8 -0
- package/dist/game/controlGate.js +43 -6
- package/dist/game/defineGame.d.ts +32 -3
- package/dist/game/dialogueGraph.d.ts +20 -0
- package/dist/game/dialogueGraph.js +107 -0
- package/dist/game/events.d.ts +5 -0
- package/dist/game/keyValueStore.d.ts +17 -4
- package/dist/game/keyValueStore.js +35 -23
- package/dist/game/lootModifiers.d.ts +17 -0
- package/dist/game/lootModifiers.js +26 -0
- package/dist/game/narrativeAuthoring.d.ts +67 -0
- package/dist/game/narrativeAuthoring.js +122 -0
- package/dist/game/playableGame.d.ts +52 -20
- package/dist/game/quest.d.ts +15 -0
- package/dist/game/quest.js +209 -63
- package/dist/game/questCatalog.d.ts +50 -0
- package/dist/game/questCatalog.js +205 -0
- package/dist/game/saveStore.d.ts +6 -5
- package/dist/game/saveStore.js +57 -57
- package/dist/game/systemRuntime.d.ts +2 -2
- package/dist/game/systemRuntime.js +84 -33
- package/dist/gameplay.d.ts +3 -0
- package/dist/gameplay.js +3 -0
- package/dist/interaction/promptRegistry.d.ts +5 -4
- package/dist/interaction/promptRegistry.js +8 -2
- package/dist/inventory/inventoryModel.js +9 -6
- package/dist/inventory/shapedGrid.d.ts +48 -9
- package/dist/inventory/shapedGrid.js +69 -10
- package/dist/item/durability.js +4 -0
- package/dist/item/itemInstanceRegistry.d.ts +32 -1
- package/dist/item/itemInstanceRegistry.js +111 -7
- package/dist/item/itemgen.d.ts +1 -1
- package/dist/item/itemgen.js +1 -1
- package/dist/item/modularItem.js +7 -1
- package/dist/material/appearanceSignals.d.ts +53 -0
- package/dist/material/appearanceSignals.js +47 -0
- package/dist/material/hairCards.d.ts +47 -0
- package/dist/material/hairCards.js +136 -0
- package/dist/material/materialAsset.d.ts +162 -0
- package/dist/material/materialAsset.js +382 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +348 -1
- package/dist/movement/characterController.d.ts +5 -2
- package/dist/movement/characterController.js +50 -23
- package/dist/movement/freeFlight.d.ts +2 -2
- package/dist/movement/freeFlight.js +3 -3
- package/dist/movement/movementModel.d.ts +24 -1
- package/dist/movement/movementModel.js +74 -7
- package/dist/movement/playerMovement.d.ts +28 -1
- package/dist/movement/playerMovement.js +425 -102
- package/dist/movement/poseState.d.ts +2 -0
- package/dist/movement/poseState.js +29 -1
- package/dist/movement/solidObstacles.d.ts +3 -1
- package/dist/movement/solidObstacles.js +8 -5
- package/dist/movement/terrainGrade.d.ts +13 -0
- package/dist/movement/terrainGrade.js +33 -0
- package/dist/movement/voxelController.d.ts +2 -2
- package/dist/movement/voxelController.js +3 -3
- package/dist/nav/navGrid.d.ts +19 -1
- package/dist/nav/navGrid.js +26 -10
- package/dist/nav/solidRoute.d.ts +50 -0
- package/dist/nav/solidRoute.js +144 -0
- package/dist/physics/ballisticSweep.d.ts +10 -3
- package/dist/physics/ballisticSweep.js +53 -29
- package/dist/physics/forceVolume.d.ts +41 -0
- package/dist/physics/forceVolume.js +88 -0
- package/dist/physics/kinematicVehicle.d.ts +8 -3
- package/dist/physics/kinematicVehicle.js +2 -2
- package/dist/physics/physicsWorldBackend.js +19 -3
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +8 -9
- package/dist/physics/vehicleDynamics.d.ts +3 -2
- package/dist/physics/vehicleDynamics.js +2 -2
- package/dist/physics/vehicleObstacles.d.ts +3 -2
- package/dist/physics/vehicleObstacles.js +29 -5
- package/dist/runtime/cameraDirector.d.ts +39 -2
- package/dist/runtime/cameraDirector.js +162 -0
- package/dist/runtime/commandDispatch.d.ts +14 -0
- package/dist/runtime/commandDispatch.js +19 -0
- package/dist/runtime/context/combat.d.ts +11 -4
- package/dist/runtime/context/combat.js +178 -11
- package/dist/runtime/context/deathLoot.d.ts +9 -8
- package/dist/runtime/context/deathLoot.js +16 -16
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +13 -21
- package/dist/runtime/descriptors/baseline.js +34 -3
- package/dist/runtime/descriptors/features.d.ts +2 -1
- package/dist/runtime/descriptors/features.js +18 -0
- package/dist/runtime/gameContext.js +79 -13
- package/dist/runtime/gameContextTypes.d.ts +14 -0
- package/dist/runtime/headlessRunner.d.ts +3 -1
- package/dist/runtime/headlessRunner.js +11 -11
- package/dist/runtime/hostedGameRunner.d.ts +24 -1
- package/dist/runtime/hostedGameRunner.js +156 -10
- package/dist/runtime/hostedWorldSession.d.ts +17 -5
- package/dist/runtime/hostedWorldSession.js +154 -16
- package/dist/runtime/hostedWorldStore.d.ts +2 -2
- package/dist/runtime/inputRecorder.d.ts +1 -1
- package/dist/runtime/inputRecorder.js +21 -1
- package/dist/runtime/inputSnapshot.d.ts +16 -4
- package/dist/runtime/inputSnapshot.js +31 -3
- package/dist/runtime/runtimeSave.d.ts +10 -6
- package/dist/runtime/runtimeSave.js +11 -5
- package/dist/runtime/simContext.d.ts +8 -3
- package/dist/runtime/simContext.js +7 -3
- package/dist/runtime/simSnapshot.js +2 -0
- package/dist/runtime/worldReplication.d.ts +2 -0
- package/dist/runtime/worldReplication.js +1 -1
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/behaviorRuntime.d.ts +26 -5
- package/dist/scene/behaviorRuntime.js +263 -16
- package/dist/scene/behaviors.d.ts +30 -1
- package/dist/scene/behaviors.js +29 -0
- package/dist/scene/entityStore.d.ts +14 -3
- package/dist/scene/entityStore.js +45 -9
- package/dist/scene/mount.d.ts +9 -0
- package/dist/scene/mount.js +35 -0
- package/dist/scene/possession.d.ts +2 -0
- package/dist/scene/possession.js +21 -1
- package/dist/scene/sceneRaycast.d.ts +7 -0
- package/dist/scene/sceneRaycast.js +55 -0
- package/dist/scene/spatial.d.ts +20 -1
- package/dist/scene/spatial.js +94 -2
- package/dist/scene/vehicleSeat.d.ts +36 -2
- package/dist/scene/vehicleSeat.js +89 -2
- package/dist/stats/accumulatorMeter.d.ts +10 -0
- package/dist/stats/accumulatorMeter.js +6 -0
- package/dist/stats/eventMeter.d.ts +9 -2
- package/dist/stats/eventMeter.js +5 -2
- package/dist/store/defineStore.d.ts +3 -1
- package/dist/store/defineStore.js +3 -1
- package/dist/store/observableKeyedStore.d.ts +1 -1
- package/dist/store/observableKeyedStore.js +8 -6
- package/dist/time/simClock.d.ts +6 -2
- package/dist/time/simClock.js +5 -0
- package/dist/vfx/particleDirector.d.ts +58 -6
- package/dist/vfx/particleDirector.js +113 -22
- package/dist/vfx/particles.d.ts +68 -5
- package/dist/vfx/particles.js +284 -32
- package/dist/visibility/assetStreaming.d.ts +21 -3
- package/dist/visibility/assetStreaming.js +108 -24
- package/dist/visibility/settings.d.ts +8 -2
- package/dist/visibility/settings.js +3 -0
- package/dist/visibility/visibilitySystem.js +1 -0
- package/dist/work/expedition.d.ts +110 -0
- package/dist/work/expedition.js +195 -0
- package/dist/work/staffedStation.d.ts +29 -0
- package/dist/work/staffedStation.js +27 -0
- package/dist/world/authoredAnimation.d.ts +38 -0
- package/dist/world/authoredAnimation.js +99 -0
- package/dist/world/authoredObjects.d.ts +24 -4
- package/dist/world/authoredObjects.js +43 -16
- package/dist/world/authoredSimulation.d.ts +74 -0
- package/dist/world/authoredSimulation.js +302 -0
- package/dist/world/authoredWeather.d.ts +120 -0
- package/dist/world/authoredWeather.js +314 -0
- package/dist/world/buildingKit.d.ts +8 -3
- package/dist/world/buildingKit.js +21 -8
- package/dist/world/environmentForces.d.ts +21 -0
- package/dist/world/environmentForces.js +42 -0
- package/dist/world/features.d.ts +3 -3
- package/dist/world/footprintGrid.d.ts +55 -0
- package/dist/world/footprintGrid.js +201 -0
- package/dist/world/grassKind.d.ts +10 -0
- package/dist/world/grassKind.js +22 -0
- package/dist/world/pathTerrain.d.ts +1 -1
- package/dist/world/terraform.d.ts +2 -2
- package/dist/world/terraform.js +46 -18
- package/dist/world/weather.d.ts +17 -0
- package/dist/world/weather.js +116 -20
- package/dist/world/worldSolids.d.ts +11 -0
- package/dist/world/worldSolids.js +112 -1
- package/dist/world.d.ts +5 -5
- package/dist/world.js +4 -4
- package/package.json +1 -1
- package/skills/ce-handoff/SKILL.md +1 -1
- package/skills/game-design/SKILL.md +2 -0
- package/skills/jgengine/SKILL.md +23 -2
- package/skills/jgengine/api.md +28 -17
- package/skills/jgengine/recipes/minimal-game.md +7 -2
- package/skills/jgengine-combat/SKILL.md +8 -2
- package/skills/jgengine-combat/api.md +22 -10
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
- package/skills/jgengine-combat/reference.md +24 -2
- package/skills/jgengine-gameplay/SKILL.md +63 -0
- package/skills/jgengine-gameplay/api.md +131 -37
- package/skills/jgengine-gameplay/capabilities.md +68 -0
- package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
- package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
- package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
- package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
- package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
- package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
- package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
- package/skills/jgengine-gameplay/reference-systems.md +119 -0
- package/skills/jgengine-world/SKILL.md +12 -3
- package/skills/jgengine-world/api.md +201 -58
- package/skills/jgengine-world/capabilities.md +116 -3
- package/skills/jgengine-world/recipes/character-animation.md +31 -8
- package/skills/jgengine-world/recipes/controls.md +4 -0
- package/skills/jgengine-world/recipes/npc-senses.md +12 -0
- package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
- package/skills/jgengine-world/reference.md +167 -5
- package/skills/jgengine-world/visibility.md +28 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { createMountController, MountController } from "./mount.js";
|
|
2
|
+
import { validateCameraRig, validateChaseCameraTuning } from "../runtime/cameraDirector.js";
|
|
3
|
+
import { forgetPlayerMovement } from "../movement/playerMovement.js";
|
|
2
4
|
/**
|
|
3
5
|
* Composes `scene/mount`'s control-transfer bookkeeping with the seat/camera/movement-mode transition
|
|
4
6
|
* every enter/exit-vehicle flow needs (#533.2): boarding resolves a free seat and reports the camera
|
|
@@ -27,14 +29,21 @@ export class VehicleSeats {
|
|
|
27
29
|
};
|
|
28
30
|
}
|
|
29
31
|
exit(riderId, vehiclePose, offset = {}) {
|
|
30
|
-
const vehicleId = this.controller.
|
|
31
|
-
if (vehicleId ===
|
|
32
|
+
const vehicleId = this.controller.seatOf(riderId)?.mountId;
|
|
33
|
+
if (vehicleId === undefined)
|
|
32
34
|
return { ok: false, reason: "not_seated" };
|
|
33
35
|
const distance = offset.distance ?? 2.2;
|
|
36
|
+
if (!Array.isArray(vehiclePose.position) || vehiclePose.position.length !== 3 || !vehiclePose.position.every(Number.isFinite) ||
|
|
37
|
+
!Number.isFinite(vehiclePose.rotationY) || !Number.isFinite(distance) ||
|
|
38
|
+
(offset.side !== undefined && offset.side !== "left" && offset.side !== "right"))
|
|
39
|
+
return { ok: false, reason: "invalid_pose" };
|
|
34
40
|
const sign = offset.side === "left" ? -1 : 1;
|
|
35
41
|
const side = vehiclePose.rotationY + (Math.PI / 2) * sign;
|
|
36
42
|
const x = vehiclePose.position[0] + Math.sin(side) * distance;
|
|
37
43
|
const z = vehiclePose.position[2] + Math.cos(side) * distance;
|
|
44
|
+
if (!Number.isFinite(x) || !Number.isFinite(z))
|
|
45
|
+
return { ok: false, reason: "invalid_pose" };
|
|
46
|
+
this.controller.dismount(riderId);
|
|
38
47
|
return {
|
|
39
48
|
ok: true,
|
|
40
49
|
vehicleId,
|
|
@@ -49,6 +58,19 @@ export class VehicleSeats {
|
|
|
49
58
|
driverOf(vehicleId) {
|
|
50
59
|
return this.controller.driver(vehicleId);
|
|
51
60
|
}
|
|
61
|
+
/** The seated rider's controlled mount; `null` for passengers and unseated riders. */
|
|
62
|
+
drivenBy(riderId) {
|
|
63
|
+
return this.isSeated(riderId) ? this.controller.driveTarget(riderId) : null;
|
|
64
|
+
}
|
|
65
|
+
snapshot() {
|
|
66
|
+
return this.controller.snapshot();
|
|
67
|
+
}
|
|
68
|
+
restore(next) {
|
|
69
|
+
this.controller.restore(next);
|
|
70
|
+
}
|
|
71
|
+
reset() {
|
|
72
|
+
this.controller.reset();
|
|
73
|
+
}
|
|
52
74
|
get mounts() {
|
|
53
75
|
return this.controller;
|
|
54
76
|
}
|
|
@@ -61,3 +83,68 @@ export class VehicleSeats {
|
|
|
61
83
|
export function createVehicleSeats(controller) {
|
|
62
84
|
return new VehicleSeats(controller);
|
|
63
85
|
}
|
|
86
|
+
function forgetRiderMovement(ctx, riderId) {
|
|
87
|
+
const possession = ctx.player.possession;
|
|
88
|
+
if (possession.active(riderId) === riderId)
|
|
89
|
+
forgetPlayerMovement(ctx, riderId);
|
|
90
|
+
for (const [userId, entityId] of Object.entries(possession.snapshotAll().active)) {
|
|
91
|
+
if (entityId === riderId)
|
|
92
|
+
forgetPlayerMovement(ctx, userId);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Apply an existing seat transition to the explicitly named rider. Motor/input policy stays caller-owned.
|
|
96
|
+
* @capability board-vehicle board a rideable, freeze/hide its rider and apply caller camera policy
|
|
97
|
+
*/
|
|
98
|
+
export function boardVehicle(ctx, seats, riderId, vehicleId, options = {}) {
|
|
99
|
+
const rider = ctx.scene.entity.get(riderId);
|
|
100
|
+
if (rider === null)
|
|
101
|
+
return { ok: false, reason: "unknown_rider" };
|
|
102
|
+
if (ctx.scene.entity.get(vehicleId) === null)
|
|
103
|
+
return { ok: false, reason: "unknown_vehicle" };
|
|
104
|
+
if (options.camera !== false && options.rig !== undefined && options.rig !== null)
|
|
105
|
+
validateCameraRig(options.rig, options.config);
|
|
106
|
+
if (options.camera !== false && options.chase !== undefined)
|
|
107
|
+
validateChaseCameraTuning(options.chase);
|
|
108
|
+
const result = seats.enter(riderId, vehicleId, options);
|
|
109
|
+
if (!result.ok)
|
|
110
|
+
return result;
|
|
111
|
+
ctx.scene.entity.update(riderId, { hidden: true, movement: { ...rider.movement, ...result.riderMovementPatch } });
|
|
112
|
+
ctx.scene.entity.setVelocity(riderId, [0, 0, 0]);
|
|
113
|
+
forgetRiderMovement(ctx, riderId);
|
|
114
|
+
if (options.camera !== false) {
|
|
115
|
+
ctx.camera.follow(result.cameraTarget);
|
|
116
|
+
if (options.rig !== undefined)
|
|
117
|
+
ctx.camera.setRig(options.rig, options.config);
|
|
118
|
+
if (options.chase !== undefined)
|
|
119
|
+
ctx.camera.setChaseTuning(structuredClone(options.chase));
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/** Apply dismount placement, restore rider movement/visibility and the configured camera rig.
|
|
124
|
+
* @capability leave-vehicle leave a rideable beside its current pose and restore on-foot camera/movement
|
|
125
|
+
*/
|
|
126
|
+
export function leaveVehicle(ctx, seats, riderId, options = {}) {
|
|
127
|
+
const rider = ctx.scene.entity.get(riderId);
|
|
128
|
+
if (rider === null)
|
|
129
|
+
return { ok: false, reason: "unknown_rider" };
|
|
130
|
+
const ref = seats.mounts.seatOf(riderId);
|
|
131
|
+
if (ref === null)
|
|
132
|
+
return { ok: false, reason: "not_seated" };
|
|
133
|
+
const vehicle = ctx.scene.entity.get(ref.mountId);
|
|
134
|
+
const pose = options.pose ?? (vehicle === null ? undefined : { position: vehicle.position, rotationY: vehicle.rotationY });
|
|
135
|
+
if (pose === undefined)
|
|
136
|
+
return { ok: false, reason: "unknown_vehicle" };
|
|
137
|
+
const result = seats.exit(riderId, pose, options);
|
|
138
|
+
if (!result.ok)
|
|
139
|
+
return result;
|
|
140
|
+
ctx.scene.entity.setPose(riderId, result.placement);
|
|
141
|
+
ctx.scene.entity.update(riderId, { hidden: false, movement: { ...rider.movement, ...result.riderMovementPatch } });
|
|
142
|
+
ctx.scene.entity.setVelocity(riderId, [0, 0, 0]);
|
|
143
|
+
forgetRiderMovement(ctx, riderId);
|
|
144
|
+
if (options.camera !== false) {
|
|
145
|
+
ctx.camera.follow(result.cameraTarget);
|
|
146
|
+
ctx.camera.setRig(null);
|
|
147
|
+
ctx.camera.setChaseTuning(null);
|
|
148
|
+
}
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
@@ -17,7 +17,17 @@ export interface MeterAddResult {
|
|
|
17
17
|
tier: string | null;
|
|
18
18
|
tierChanged: boolean;
|
|
19
19
|
}
|
|
20
|
+
/** Mutable meter value, hold latch, and time since the last gain. */
|
|
21
|
+
export interface AccumulatorMeterState {
|
|
22
|
+
value: number;
|
|
23
|
+
broken: boolean;
|
|
24
|
+
idleMs: number;
|
|
25
|
+
}
|
|
20
26
|
export interface AccumulatorMeter {
|
|
27
|
+
/** Detached state for persistence and deterministic continuation. */
|
|
28
|
+
state(): AccumulatorMeterState;
|
|
29
|
+
/** Restore using the caller's existing configuration. */
|
|
30
|
+
restore(next: AccumulatorMeterState): void;
|
|
21
31
|
value(): number;
|
|
22
32
|
fraction(): number;
|
|
23
33
|
tier(): string | null;
|
|
@@ -31,6 +31,12 @@ export function createAccumulatorMeter(config) {
|
|
|
31
31
|
return Math.max(0, Math.min(config.max, next));
|
|
32
32
|
}
|
|
33
33
|
return {
|
|
34
|
+
state: () => ({ value, broken, idleMs }),
|
|
35
|
+
restore(next) {
|
|
36
|
+
value = clamp(next.value);
|
|
37
|
+
broken = next.broken;
|
|
38
|
+
idleMs = Math.max(0, next.idleMs);
|
|
39
|
+
},
|
|
34
40
|
value() {
|
|
35
41
|
return value;
|
|
36
42
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type MeterMode, type MeterTier } from "./accumulatorMeter.js";
|
|
1
|
+
import { type AccumulatorMeterState, type MeterMode, type MeterTier } from "./accumulatorMeter.js";
|
|
2
2
|
export interface EventMeterConfig {
|
|
3
3
|
max: number;
|
|
4
4
|
mode?: MeterMode;
|
|
@@ -20,7 +20,13 @@ export interface EventMeterFeedResult {
|
|
|
20
20
|
tier: string | null;
|
|
21
21
|
tierChanged: boolean;
|
|
22
22
|
}
|
|
23
|
+
/** Event meter persistence uses the underlying accumulator's complete state. */
|
|
24
|
+
export type EventMeterState = AccumulatorMeterState;
|
|
23
25
|
export interface EventMeter {
|
|
26
|
+
/** Detached value, hold latch, and decay-delay timer. */
|
|
27
|
+
state(): EventMeterState;
|
|
28
|
+
/** Restore using the caller's existing event and decay configuration. */
|
|
29
|
+
restore(next: EventMeterState): void;
|
|
24
30
|
value(): number;
|
|
25
31
|
fraction(): number;
|
|
26
32
|
tier(): string | null;
|
|
@@ -29,7 +35,8 @@ export interface EventMeter {
|
|
|
29
35
|
consume(): boolean;
|
|
30
36
|
drain(amount: number): void;
|
|
31
37
|
reset(): void;
|
|
32
|
-
|
|
38
|
+
/** Advance decay and its delay; pass false to pause both while a source is visible. */
|
|
39
|
+
tick(dtSeconds: number, decayEnabled?: boolean): void;
|
|
33
40
|
}
|
|
34
41
|
/**
|
|
35
42
|
* A heat/hype gauge that rises as tagged events land and cools between them, firing when it fills or breaks — the streak/overdrive meter shooters and fighters hand-roll.
|
package/dist/stats/eventMeter.js
CHANGED
|
@@ -18,6 +18,8 @@ export function createEventMeter(config) {
|
|
|
18
18
|
tiers: config.tiers,
|
|
19
19
|
});
|
|
20
20
|
return {
|
|
21
|
+
state: meter.state,
|
|
22
|
+
restore: meter.restore,
|
|
21
23
|
value() {
|
|
22
24
|
return meter.value();
|
|
23
25
|
},
|
|
@@ -74,8 +76,9 @@ export function createEventMeter(config) {
|
|
|
74
76
|
reset() {
|
|
75
77
|
meter.reset();
|
|
76
78
|
},
|
|
77
|
-
tick(dtSeconds) {
|
|
78
|
-
|
|
79
|
+
tick(dtSeconds, decayEnabled = true) {
|
|
80
|
+
if (decayEnabled)
|
|
81
|
+
meter.tick(dtSeconds);
|
|
79
82
|
},
|
|
80
83
|
};
|
|
81
84
|
}
|
|
@@ -26,7 +26,9 @@ export interface StoreHandle<T> {
|
|
|
26
26
|
* with the value type it means, and the `unknown → T` cast lives once, here, behind the boundary. Pass
|
|
27
27
|
* a factory for `initial` when the fallback is a fresh mutable object; the factory runs at most once and
|
|
28
28
|
* its result is reused, so an unwritten slot keeps a stable identity across reads (no per-read churn for
|
|
29
|
-
* a React selector, no allocation on a hot path).
|
|
29
|
+
* a React selector, no allocation on a hot path). A value that carries functions (a closure-based run
|
|
30
|
+
* store, an input handle) is runtime-only: snapshots, saves and replication skip it and hydration keeps
|
|
31
|
+
* it in place. Hold such objects with `perContext` when they need no store notifications.
|
|
30
32
|
*
|
|
31
33
|
* @capability typed-store a cast-free typed handle onto one reactive game-store slot, replayable and host-authoritative
|
|
32
34
|
*/
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
* with the value type it means, and the `unknown → T` cast lives once, here, behind the boundary. Pass
|
|
4
4
|
* a factory for `initial` when the fallback is a fresh mutable object; the factory runs at most once and
|
|
5
5
|
* its result is reused, so an unwritten slot keeps a stable identity across reads (no per-read churn for
|
|
6
|
-
* a React selector, no allocation on a hot path).
|
|
6
|
+
* a React selector, no allocation on a hot path). A value that carries functions (a closure-based run
|
|
7
|
+
* store, an input handle) is runtime-only: snapshots, saves and replication skip it and hydration keeps
|
|
8
|
+
* it in place. Hold such objects with `perContext` when they need no store notifications.
|
|
7
9
|
*
|
|
8
10
|
* @capability typed-store a cast-free typed handle onto one reactive game-store slot, replayable and host-authoritative
|
|
9
11
|
*/
|
|
@@ -18,4 +18,4 @@ export interface ObservableKeyedStore<T> {
|
|
|
18
18
|
hydrate(data: readonly (readonly [string, T])[]): void;
|
|
19
19
|
}
|
|
20
20
|
/** @internal */
|
|
21
|
-
export declare function createObservableKeyedStore<T>(areEqual?: (previous: T, next: T) => boolean): ObservableKeyedStore<T>;
|
|
21
|
+
export declare function createObservableKeyedStore<T>(areEqual?: (previous: T, next: T) => boolean, beforeNotify?: (key: string | undefined, membershipChanged: boolean) => void): ObservableKeyedStore<T>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @internal */
|
|
2
|
-
export function createObservableKeyedStore(areEqual) {
|
|
2
|
+
export function createObservableKeyedStore(areEqual, beforeNotify) {
|
|
3
3
|
const store = new Map();
|
|
4
4
|
const listeners = new Set();
|
|
5
5
|
const membershipListeners = new Set();
|
|
@@ -11,14 +11,16 @@ export function createObservableKeyedStore(areEqual) {
|
|
|
11
11
|
let keysDirty = false;
|
|
12
12
|
let mapCache = new Map();
|
|
13
13
|
let mapDirty = false;
|
|
14
|
-
function emit() {
|
|
14
|
+
function emit(key, membershipChanged) {
|
|
15
15
|
arrayDirty = true;
|
|
16
16
|
mapDirty = true;
|
|
17
|
+
if (membershipChanged)
|
|
18
|
+
keysDirty = true;
|
|
19
|
+
beforeNotify?.(key, membershipChanged);
|
|
17
20
|
for (const listener of listeners)
|
|
18
21
|
listener();
|
|
19
22
|
}
|
|
20
23
|
function emitMembership() {
|
|
21
|
-
keysDirty = true;
|
|
22
24
|
for (const listener of membershipListeners)
|
|
23
25
|
listener();
|
|
24
26
|
}
|
|
@@ -30,7 +32,7 @@ export function createObservableKeyedStore(areEqual) {
|
|
|
30
32
|
return;
|
|
31
33
|
if (previous !== value)
|
|
32
34
|
store.set(key, value);
|
|
33
|
-
emit();
|
|
35
|
+
emit(key, !had);
|
|
34
36
|
if (!had)
|
|
35
37
|
emitMembership();
|
|
36
38
|
},
|
|
@@ -38,7 +40,7 @@ export function createObservableKeyedStore(areEqual) {
|
|
|
38
40
|
if (!store.has(key))
|
|
39
41
|
return;
|
|
40
42
|
store.delete(key);
|
|
41
|
-
emit();
|
|
43
|
+
emit(key, true);
|
|
42
44
|
emitMembership();
|
|
43
45
|
},
|
|
44
46
|
get(key) {
|
|
@@ -83,7 +85,7 @@ export function createObservableKeyedStore(areEqual) {
|
|
|
83
85
|
store.clear();
|
|
84
86
|
for (const [key, value] of data)
|
|
85
87
|
store.set(key, value);
|
|
86
|
-
emit();
|
|
88
|
+
emit(undefined, true);
|
|
87
89
|
emitMembership();
|
|
88
90
|
},
|
|
89
91
|
};
|
package/dist/time/simClock.d.ts
CHANGED
|
@@ -47,9 +47,11 @@ export interface ClockSnapshot {
|
|
|
47
47
|
calendar: CalendarTime;
|
|
48
48
|
}
|
|
49
49
|
export interface SimClock {
|
|
50
|
-
/** Advance the
|
|
50
|
+
/** Advance the clock directly when used alone. A GameContext's `ctx.sim.advance` owns this call once per step and passes scaled dt to its callback; game loops must not advance it again. */
|
|
51
51
|
advance(realDt: number): number;
|
|
52
52
|
now(): number;
|
|
53
|
+
/** Local accepted advance seconds × play speed × timescale, excluding calendar scale and saved offsets. Never hydrated or serialized; optional for external clocks. */
|
|
54
|
+
advancedSeconds?(): number;
|
|
53
55
|
snapshot(): ClockSnapshot;
|
|
54
56
|
/** Restore clock position — game-time, pause state, and speed multipliers — from a {@link ClockSnapshot} for whole-world save/load. Live timers registered via `after`/`every`/`at` are unaffected. */
|
|
55
57
|
hydrate(snapshot: ClockSnapshot): void;
|
|
@@ -85,4 +87,6 @@ export interface SimClockOptions {
|
|
|
85
87
|
/** Called on control changes and once per displayed in-game minute so UI can rebind; never fires per frame. */
|
|
86
88
|
onChange?: () => void;
|
|
87
89
|
}
|
|
88
|
-
export declare function createSimClock(options?: SimClockOptions): SimClock
|
|
90
|
+
export declare function createSimClock(options?: SimClockOptions): SimClock & {
|
|
91
|
+
advancedSeconds(): number;
|
|
92
|
+
};
|
package/dist/time/simClock.js
CHANGED
|
@@ -16,6 +16,7 @@ export function createSimClock(options = {}) {
|
|
|
16
16
|
const daysPerYear = config.daysPerYear !== undefined && config.daysPerYear > 0 ? config.daysPerYear : 365;
|
|
17
17
|
const seasons = config.seasons;
|
|
18
18
|
let now = config.start !== undefined && config.start > 0 ? config.start : 0;
|
|
19
|
+
let advancedSeconds = 0;
|
|
19
20
|
let paused = config.startPaused ?? false;
|
|
20
21
|
let playSpeed = speeds[0];
|
|
21
22
|
let timescale = 1;
|
|
@@ -76,7 +77,10 @@ export function createSimClock(options = {}) {
|
|
|
76
77
|
if (paused || realDt <= 0 || timescale === 0)
|
|
77
78
|
return 0;
|
|
78
79
|
const gameDt = realDt * scale * playSpeed * timescale;
|
|
80
|
+
const localDt = realDt * playSpeed * timescale;
|
|
79
81
|
now += gameDt;
|
|
82
|
+
if (Number.isFinite(localDt) && localDt > 0)
|
|
83
|
+
advancedSeconds += localDt;
|
|
80
84
|
fireDueTimers();
|
|
81
85
|
const minute = minuteIndex(now, dayLength);
|
|
82
86
|
if (minute !== displayMinute) {
|
|
@@ -138,6 +142,7 @@ export function createSimClock(options = {}) {
|
|
|
138
142
|
return {
|
|
139
143
|
advance,
|
|
140
144
|
now: () => now,
|
|
145
|
+
advancedSeconds: () => advancedSeconds,
|
|
141
146
|
calendar,
|
|
142
147
|
hydrate,
|
|
143
148
|
snapshot: () => ({
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EmitterConfig, type ParticleEvent, type Vec3 } from "./particles.js";
|
|
2
2
|
/** How a particle effect composites on screen: `additive` for sparks/fire/glow, `normal` for smoke/dust. */
|
|
3
3
|
export type ParticleBlendHint = "additive" | "normal";
|
|
4
|
+
/** Render output selected independently of particle spawn and simulation. */
|
|
5
|
+
export interface ParticleRenderHint {
|
|
6
|
+
shape: "point" | "streak" | "flake" | "flame" | "smoke" | "ribbon" | "ripple";
|
|
7
|
+
/** Velocity streak length in seconds. Default 0.08. */
|
|
8
|
+
stretch?: number;
|
|
9
|
+
}
|
|
4
10
|
/** A one-shot burst request — consumed once by the renderer, never serialized. */
|
|
5
11
|
export interface ParticleBurst {
|
|
6
12
|
/** Monotonic sequence id so a renderer can consume each burst exactly once. */
|
|
@@ -9,6 +15,15 @@ export interface ParticleBurst {
|
|
|
9
15
|
/** Particles to emit immediately. */
|
|
10
16
|
count: number;
|
|
11
17
|
blending?: ParticleBlendHint;
|
|
18
|
+
emitterId?: string;
|
|
19
|
+
options?: ParticleAttachOptions;
|
|
20
|
+
}
|
|
21
|
+
/** A bounded collision child effect; children cannot spawn further collision children. */
|
|
22
|
+
export interface ParticleCollisionEffect {
|
|
23
|
+
config: EmitterConfig;
|
|
24
|
+
count: number;
|
|
25
|
+
render?: ParticleRenderHint;
|
|
26
|
+
blending?: ParticleBlendHint;
|
|
12
27
|
}
|
|
13
28
|
/** Options for {@link ParticleDirector.attach}. */
|
|
14
29
|
export interface ParticleAttachOptions {
|
|
@@ -17,15 +32,36 @@ export interface ParticleAttachOptions {
|
|
|
17
32
|
/** World offset from the followed entity (or from the config position when unfollowed). */
|
|
18
33
|
offset?: Vec3;
|
|
19
34
|
blending?: ParticleBlendHint;
|
|
35
|
+
/** Local particles move with the followed pose; world particles retain their emitted positions. */
|
|
36
|
+
space?: "world" | "local";
|
|
37
|
+
active?: boolean;
|
|
38
|
+
render?: ParticleRenderHint;
|
|
39
|
+
collisionEffect?: ParticleCollisionEffect;
|
|
40
|
+
/** Explicit environment influence: windResponse in 1/second, maxAcceleration in metres/second², both 0..1e12. */
|
|
41
|
+
environment?: {
|
|
42
|
+
windResponse?: number;
|
|
43
|
+
maxAcceleration?: number;
|
|
44
|
+
mask?: number;
|
|
45
|
+
};
|
|
20
46
|
}
|
|
21
47
|
/** A keyed continuous emitter owned by the director until detached. */
|
|
22
|
-
export interface ParticleEmitterSpec {
|
|
48
|
+
export interface ParticleEmitterSpec extends ParticleAttachOptions {
|
|
23
49
|
id: string;
|
|
24
50
|
config: EmitterConfig;
|
|
25
51
|
follow?: string;
|
|
26
52
|
offset?: Vec3;
|
|
27
53
|
blending?: ParticleBlendHint;
|
|
28
54
|
}
|
|
55
|
+
/** A cosmetic simulation event, labelled with its emitting effect. */
|
|
56
|
+
export interface ParticleDirectorEvent extends ParticleEvent {
|
|
57
|
+
emitterId: string;
|
|
58
|
+
}
|
|
59
|
+
/** Bounded storage policy for transient effects and event exchange. */
|
|
60
|
+
export interface ParticleDirectorOptions {
|
|
61
|
+
maxEmitters?: number;
|
|
62
|
+
maxBursts?: number;
|
|
63
|
+
maxEvents?: number;
|
|
64
|
+
}
|
|
29
65
|
/** Serializable director state: the standing emitters (bursts are transient by nature). */
|
|
30
66
|
export interface ParticleDirectorState {
|
|
31
67
|
nextSeq: number;
|
|
@@ -40,17 +76,28 @@ export interface ParticleDirectorState {
|
|
|
40
76
|
*/
|
|
41
77
|
export interface ParticleDirector {
|
|
42
78
|
/** Queue a one-shot burst (impact puff, debris, muzzle smoke). */
|
|
43
|
-
burst(config: EmitterConfig, count: number, blending?: ParticleBlendHint): void;
|
|
79
|
+
burst(config: EmitterConfig, count: number, blending?: ParticleBlendHint, options?: ParticleAttachOptions): void;
|
|
44
80
|
/** Create or replace a standing emitter under `id` (exhaust, dust plume, torch smoke). */
|
|
45
81
|
attach(id: string, config: EmitterConfig, options?: ParticleAttachOptions): void;
|
|
46
82
|
/** Patch a standing emitter's config in place (e.g. scale exhaust rate with speed). */
|
|
47
|
-
retune(id: string, patch: Partial<EmitterConfig>): void;
|
|
83
|
+
retune(id: string, patch: Partial<EmitterConfig>, options?: Partial<ParticleAttachOptions>): void;
|
|
84
|
+
/** Resume a named emitter while preserving its live particles. */
|
|
85
|
+
start(id: string): void;
|
|
86
|
+
/** Stop spawning; the renderer lets existing particles finish. */
|
|
87
|
+
stop(id: string): void;
|
|
88
|
+
/** Emit once through a named emitter's existing pool, including when stopped. */
|
|
89
|
+
burstNamed(id: string, count: number): void;
|
|
90
|
+
/** Renderer-to-game cosmetic event exchange, bounded by the director's event limit. */
|
|
91
|
+
reportEvents(id: string, events: readonly ParticleEvent[]): void;
|
|
92
|
+
drainEvents(): ParticleDirectorEvent[];
|
|
48
93
|
/** Remove a standing emitter. Unknown ids are ignored. */
|
|
49
94
|
detach(id: string): void;
|
|
50
95
|
/** Drop every standing emitter and any unconsumed bursts. */
|
|
51
96
|
clear(): void;
|
|
52
97
|
/** The standing emitters, for renderers and assertions. */
|
|
53
98
|
emitters(): readonly ParticleEmitterSpec[];
|
|
99
|
+
/** Fixed registry and transient queue capacities for transactional preflight. */
|
|
100
|
+
limits(): Required<ParticleDirectorOptions>;
|
|
54
101
|
/** Return and forget the queued bursts — the renderer's consume-once read. */
|
|
55
102
|
drainBursts(): ParticleBurst[];
|
|
56
103
|
subscribe(listener: () => void): () => void;
|
|
@@ -63,6 +110,11 @@ export interface ParticleDirector {
|
|
|
63
110
|
* queued bursts are transient. The renderer subscribes, drains bursts, and mirrors
|
|
64
111
|
* the emitter list — the director never allocates particle pools itself.
|
|
65
112
|
*
|
|
66
|
-
* @capability particle-director
|
|
113
|
+
* @capability particle-director named particle start/stop/burst/retune lifecycle, local/world following, render outputs, bounded cosmetic event exchange, and serializable emitter definitions
|
|
114
|
+
*/
|
|
115
|
+
export declare function createParticleDirector(options?: ParticleDirectorOptions): ParticleDirector;
|
|
116
|
+
/**
|
|
117
|
+
* Validate serializable binding, output, and bounded collision-child descriptors.
|
|
118
|
+
* @capability particle-binding-validation validate named particle follow spaces, render outputs, environment influence, and bounded collision child effects
|
|
67
119
|
*/
|
|
68
|
-
export declare function
|
|
120
|
+
export declare function validateParticleAttachOptions(value: unknown): string[];
|
|
@@ -1,60 +1,92 @@
|
|
|
1
|
+
import { validateEmitterConfig } from "./particles.js";
|
|
1
2
|
/**
|
|
2
3
|
* Create the particle intent registry a `GameContext` exposes as `ctx.particles`.
|
|
3
4
|
* State is data-only and serializable: standing emitters survive `snapshot`/`restore`,
|
|
4
5
|
* queued bursts are transient. The renderer subscribes, drains bursts, and mirrors
|
|
5
6
|
* the emitter list — the director never allocates particle pools itself.
|
|
6
7
|
*
|
|
7
|
-
* @capability particle-director
|
|
8
|
+
* @capability particle-director named particle start/stop/burst/retune lifecycle, local/world following, render outputs, bounded cosmetic event exchange, and serializable emitter definitions
|
|
8
9
|
*/
|
|
9
|
-
export function createParticleDirector() {
|
|
10
|
+
export function createParticleDirector(options = {}) {
|
|
10
11
|
let nextSeq = 1;
|
|
11
12
|
let bursts = [];
|
|
12
13
|
const emitters = new Map();
|
|
13
14
|
const listeners = new Set();
|
|
15
|
+
let events = [];
|
|
16
|
+
const limit = (n, fallback, cap) => Number.isFinite(n) ? Math.max(0, Math.min(cap, Math.floor(n))) : fallback;
|
|
17
|
+
const maxEmitters = limit(options.maxEmitters, 128, 1024);
|
|
18
|
+
const maxBursts = limit(options.maxBursts, 32, 256);
|
|
19
|
+
const maxEvents = limit(options.maxEvents, 128, 4096);
|
|
20
|
+
function queue(config, count, blending, emitterId, attachOptions) {
|
|
21
|
+
const wanted = Number.isFinite(count) ? Math.min(65536, Math.floor(count)) : 0;
|
|
22
|
+
if (wanted <= 0 || bursts.length >= maxBursts)
|
|
23
|
+
return;
|
|
24
|
+
bursts.push({ seq: nextSeq++, config: structuredClone(config), count: wanted,
|
|
25
|
+
...(blending === undefined ? {} : { blending }),
|
|
26
|
+
...(emitterId === undefined ? {} : { emitterId }),
|
|
27
|
+
...(attachOptions === undefined ? {} : { options: structuredClone(attachOptions) }) });
|
|
28
|
+
notify();
|
|
29
|
+
}
|
|
30
|
+
function setActive(id, active) {
|
|
31
|
+
const current = emitters.get(id);
|
|
32
|
+
if (current === undefined || (current.active ?? true) === active)
|
|
33
|
+
return;
|
|
34
|
+
emitters.set(id, { ...current, active });
|
|
35
|
+
notify();
|
|
36
|
+
}
|
|
14
37
|
function notify() {
|
|
15
38
|
for (const listener of listeners)
|
|
16
39
|
listener();
|
|
17
40
|
}
|
|
18
41
|
return {
|
|
19
|
-
burst(config, count, blending) {
|
|
20
|
-
|
|
21
|
-
if (wanted <= 0)
|
|
22
|
-
return;
|
|
23
|
-
bursts.push({ seq: nextSeq++, config, count: wanted, ...(blending === undefined ? {} : { blending }) });
|
|
24
|
-
notify();
|
|
42
|
+
burst(config, count, blending, options) {
|
|
43
|
+
queue(config, count, blending, undefined, options);
|
|
25
44
|
},
|
|
26
45
|
attach(id, config, options) {
|
|
27
|
-
emitters.
|
|
28
|
-
id
|
|
29
|
-
|
|
30
|
-
...(options?.follow === undefined ? {} : { follow: options.follow }),
|
|
31
|
-
...(options?.offset === undefined ? {} : { offset: options.offset }),
|
|
32
|
-
...(options?.blending === undefined ? {} : { blending: options.blending }),
|
|
33
|
-
});
|
|
46
|
+
if (!emitters.has(id) && emitters.size >= maxEmitters)
|
|
47
|
+
throw new RangeError(`Particle emitter capacity ${maxEmitters} exceeded by ${id}`);
|
|
48
|
+
emitters.set(id, { id, config: structuredClone(config), ...structuredClone(options ?? {}) });
|
|
34
49
|
notify();
|
|
35
50
|
},
|
|
36
|
-
retune(id, patch) {
|
|
51
|
+
retune(id, patch, options) {
|
|
37
52
|
const current = emitters.get(id);
|
|
38
53
|
if (current === undefined)
|
|
39
54
|
return;
|
|
40
|
-
emitters.set(id, { ...current, config: { ...current.config, ...patch } });
|
|
55
|
+
emitters.set(id, { ...current, ...structuredClone(options ?? {}), config: { ...current.config, ...structuredClone(patch) } });
|
|
41
56
|
notify();
|
|
42
57
|
},
|
|
58
|
+
start(id) { setActive(id, true); },
|
|
59
|
+
stop(id) { setActive(id, false); },
|
|
60
|
+
burstNamed(id, count) {
|
|
61
|
+
const spec = emitters.get(id);
|
|
62
|
+
if (spec === undefined)
|
|
63
|
+
return;
|
|
64
|
+
const { id: _, config, ...options } = spec;
|
|
65
|
+
queue(config, count, spec.blending, id, options);
|
|
66
|
+
},
|
|
67
|
+
reportEvents(id, next) {
|
|
68
|
+
for (let i = 0, n = Math.min(next.length, maxEvents - events.length); i < n; i++) {
|
|
69
|
+
events.push({ ...structuredClone(next[i]), emitterId: id });
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
drainEvents() { const drained = events; events = []; return drained; },
|
|
43
73
|
detach(id) {
|
|
44
74
|
if (!emitters.delete(id))
|
|
45
75
|
return;
|
|
46
76
|
notify();
|
|
47
77
|
},
|
|
48
78
|
clear() {
|
|
49
|
-
if (emitters.size === 0 && bursts.length === 0)
|
|
79
|
+
if (emitters.size === 0 && bursts.length === 0 && events.length === 0)
|
|
50
80
|
return;
|
|
51
81
|
emitters.clear();
|
|
52
82
|
bursts = [];
|
|
83
|
+
events = [];
|
|
53
84
|
notify();
|
|
54
85
|
},
|
|
55
86
|
emitters() {
|
|
56
87
|
return Array.from(emitters.values());
|
|
57
88
|
},
|
|
89
|
+
limits() { return { maxEmitters, maxBursts, maxEvents }; },
|
|
58
90
|
drainBursts() {
|
|
59
91
|
if (bursts.length === 0)
|
|
60
92
|
return [];
|
|
@@ -67,15 +99,74 @@ export function createParticleDirector() {
|
|
|
67
99
|
return () => listeners.delete(listener);
|
|
68
100
|
},
|
|
69
101
|
snapshot() {
|
|
70
|
-
return { nextSeq, emitters: Array.from(emitters.values()) };
|
|
102
|
+
return { nextSeq, emitters: structuredClone(Array.from(emitters.values())) };
|
|
71
103
|
},
|
|
72
104
|
restore(state) {
|
|
73
|
-
nextSeq = state.nextSeq;
|
|
105
|
+
nextSeq = Math.max(1, Number.isFinite(state.nextSeq) ? Math.floor(state.nextSeq) : 1);
|
|
74
106
|
bursts = [];
|
|
75
107
|
emitters.clear();
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
events = [];
|
|
109
|
+
for (const spec of state.emitters.slice(0, maxEmitters))
|
|
110
|
+
emitters.set(spec.id, structuredClone(spec));
|
|
78
111
|
notify();
|
|
79
112
|
},
|
|
80
113
|
};
|
|
81
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Validate serializable binding, output, and bounded collision-child descriptors.
|
|
117
|
+
* @capability particle-binding-validation validate named particle follow spaces, render outputs, environment influence, and bounded collision child effects
|
|
118
|
+
*/
|
|
119
|
+
export function validateParticleAttachOptions(value) {
|
|
120
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
121
|
+
return ["particle options must be an object"];
|
|
122
|
+
const options = value;
|
|
123
|
+
const errors = [];
|
|
124
|
+
if (options.follow !== undefined && typeof options.follow !== "string")
|
|
125
|
+
errors.push("follow must be an entity id");
|
|
126
|
+
if (options.offset !== undefined && (!Array.isArray(options.offset) || options.offset.length !== 3 || options.offset.some((v) => typeof v !== "number" || !Number.isFinite(v))))
|
|
127
|
+
errors.push("offset must contain three finite numbers");
|
|
128
|
+
if (options.space !== undefined && options.space !== "world" && options.space !== "local")
|
|
129
|
+
errors.push("space must be world or local");
|
|
130
|
+
if (options.active !== undefined && typeof options.active !== "boolean")
|
|
131
|
+
errors.push("active must be boolean");
|
|
132
|
+
const output = (render, blending, prefix) => {
|
|
133
|
+
if (blending !== undefined && blending !== "normal" && blending !== "additive")
|
|
134
|
+
errors.push(`${prefix}blending must be normal or additive`);
|
|
135
|
+
if (render === undefined)
|
|
136
|
+
return;
|
|
137
|
+
if (render === null || typeof render !== "object" || Array.isArray(render)) {
|
|
138
|
+
errors.push(`${prefix}render must be an output descriptor`);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const hint = render;
|
|
142
|
+
if (!["point", "streak", "flake", "flame", "smoke", "ribbon", "ripple"].includes(hint.shape))
|
|
143
|
+
errors.push(`${prefix}render.shape is unsupported`);
|
|
144
|
+
if (hint.stretch !== undefined && (typeof hint.stretch !== "number" || !Number.isFinite(hint.stretch) || hint.stretch < 0))
|
|
145
|
+
errors.push(`${prefix}render.stretch must be finite and nonnegative`);
|
|
146
|
+
};
|
|
147
|
+
output(options.render, options.blending, "");
|
|
148
|
+
if (options.environment !== undefined) {
|
|
149
|
+
const influence = options.environment;
|
|
150
|
+
if (influence === null || typeof influence !== "object" || Array.isArray(influence))
|
|
151
|
+
errors.push("environment must be an influence descriptor");
|
|
152
|
+
else {
|
|
153
|
+
for (const name of ["windResponse", "maxAcceleration"])
|
|
154
|
+
if (influence[name] !== undefined && (typeof influence[name] !== "number" || !Number.isFinite(influence[name]) || influence[name] < 0 || influence[name] > 1e12))
|
|
155
|
+
errors.push(`environment.${name} must be finite in 0..1e12`);
|
|
156
|
+
if (influence.mask !== undefined && (typeof influence.mask !== "number" || !Number.isInteger(influence.mask) || influence.mask < 0 || influence.mask > 0xffffffff))
|
|
157
|
+
errors.push("environment.mask must be an unsigned 32-bit integer");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (options.collisionEffect !== undefined) {
|
|
161
|
+
const child = options.collisionEffect;
|
|
162
|
+
if (child === null || typeof child !== "object" || Array.isArray(child))
|
|
163
|
+
errors.push("collisionEffect must be a child effect descriptor");
|
|
164
|
+
else {
|
|
165
|
+
errors.push(...validateEmitterConfig(child.config).map((error) => `collisionEffect.config.${error}`));
|
|
166
|
+
if (typeof child.count !== "number" || !Number.isInteger(child.count) || child.count < 1 || child.count > 65536)
|
|
167
|
+
errors.push("collisionEffect.count must be an integer in [1, 65536]");
|
|
168
|
+
output(child.render, child.blending, "collisionEffect.");
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return errors;
|
|
172
|
+
}
|