@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
|
@@ -127,6 +127,207 @@ export function hasValidAdjacency(grid, cells, accepts, requireConnection = fals
|
|
|
127
127
|
return false;
|
|
128
128
|
return !requireConnection || neighbors.some((neighbor) => accepts(neighbor.kind));
|
|
129
129
|
}
|
|
130
|
+
function validId(id) { return typeof id === "string" && id.length > 0; }
|
|
131
|
+
function validCapacity(capacity) { return Number.isFinite(capacity) && capacity >= 0; }
|
|
132
|
+
function contributionCount(region) {
|
|
133
|
+
if (region === null || typeof region !== "object")
|
|
134
|
+
return null;
|
|
135
|
+
if (!Array.isArray(region.cells))
|
|
136
|
+
return null;
|
|
137
|
+
if (!("contributions" in region))
|
|
138
|
+
return 1;
|
|
139
|
+
return Array.isArray(region.contributions) ? region.contributions.length : null;
|
|
140
|
+
}
|
|
141
|
+
function withinRegionBudget(regions, budget) {
|
|
142
|
+
let cells = 0;
|
|
143
|
+
let contributions = 0;
|
|
144
|
+
for (const region of regions) {
|
|
145
|
+
const count = contributionCount(region);
|
|
146
|
+
if (count === null)
|
|
147
|
+
return false;
|
|
148
|
+
cells += region.cells.length;
|
|
149
|
+
contributions += count;
|
|
150
|
+
}
|
|
151
|
+
for (const [limit, count] of [[budget.maxCells, cells], [budget.maxContributions, contributions]]) {
|
|
152
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0 || count > limit))
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
function regionCells(cells) {
|
|
158
|
+
if (cells.length === 0)
|
|
159
|
+
return null;
|
|
160
|
+
const out = new Map();
|
|
161
|
+
for (const cell of cells) {
|
|
162
|
+
if (cell === null || typeof cell !== "object")
|
|
163
|
+
return null;
|
|
164
|
+
if (!Number.isSafeInteger(cell.col) || !Number.isSafeInteger(cell.row))
|
|
165
|
+
return null;
|
|
166
|
+
const key = cellKey(cell);
|
|
167
|
+
if (out.has(key))
|
|
168
|
+
return null;
|
|
169
|
+
out.set(key, cell);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
function connectedRegion(cells) {
|
|
174
|
+
const first = cells.values().next().value;
|
|
175
|
+
if (first === undefined)
|
|
176
|
+
return false;
|
|
177
|
+
const queue = [first];
|
|
178
|
+
const visited = new Set([cellKey(first)]);
|
|
179
|
+
for (let index = 0; index < queue.length; index++) {
|
|
180
|
+
const cell = queue[index];
|
|
181
|
+
for (const offset of CARDINAL_OFFSETS) {
|
|
182
|
+
const key = cellKey({ col: cell.col + offset.col, row: cell.row + offset.row });
|
|
183
|
+
const next = cells.get(key);
|
|
184
|
+
if (next !== undefined && !visited.has(key)) {
|
|
185
|
+
visited.add(key);
|
|
186
|
+
queue.push(next);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return queue.length === cells.size;
|
|
191
|
+
}
|
|
192
|
+
function checkedRegion(region) {
|
|
193
|
+
if (!validId(region.id) || !validId(region.kind) || !Number.isFinite(region.tier) || !validCapacity(region.capacity))
|
|
194
|
+
return null;
|
|
195
|
+
const cells = regionCells(region.cells);
|
|
196
|
+
if (cells === null || !connectedRegion(cells))
|
|
197
|
+
return null;
|
|
198
|
+
const contributions = "contributions" in region ? region.contributions : [region];
|
|
199
|
+
if (contributions.length === 0)
|
|
200
|
+
return null;
|
|
201
|
+
const ids = new Set();
|
|
202
|
+
const covered = new Set();
|
|
203
|
+
let capacity = 0;
|
|
204
|
+
for (const contribution of contributions) {
|
|
205
|
+
if (contribution === null || typeof contribution !== "object")
|
|
206
|
+
return null;
|
|
207
|
+
if (!validId(contribution.id) || ids.has(contribution.id) || !validCapacity(contribution.capacity) || !Array.isArray(contribution.cells))
|
|
208
|
+
return null;
|
|
209
|
+
if (contribution.cells.length > cells.size - covered.size)
|
|
210
|
+
return null;
|
|
211
|
+
ids.add(contribution.id);
|
|
212
|
+
const partCells = regionCells(contribution.cells);
|
|
213
|
+
if (partCells === null || !connectedRegion(partCells))
|
|
214
|
+
return null;
|
|
215
|
+
for (const key of partCells.keys()) {
|
|
216
|
+
if (!cells.has(key) || covered.has(key))
|
|
217
|
+
return null;
|
|
218
|
+
covered.add(key);
|
|
219
|
+
}
|
|
220
|
+
capacity += contribution.capacity;
|
|
221
|
+
}
|
|
222
|
+
if (!Number.isFinite(capacity) || capacity !== region.capacity || covered.size !== cells.size)
|
|
223
|
+
return null;
|
|
224
|
+
return { cells, contributions, ids };
|
|
225
|
+
}
|
|
226
|
+
function mergeableRegions(a, b, budget) {
|
|
227
|
+
if (!withinRegionBudget([a, b], budget) || a.id === b.id || a.kind !== b.kind || a.tier !== b.tier)
|
|
228
|
+
return null;
|
|
229
|
+
const left = checkedRegion(a);
|
|
230
|
+
const right = checkedRegion(b);
|
|
231
|
+
if (left === null || right === null)
|
|
232
|
+
return null;
|
|
233
|
+
if (right.ids.has(a.id) || left.ids.has(b.id))
|
|
234
|
+
return null;
|
|
235
|
+
for (const id of left.ids)
|
|
236
|
+
if (right.ids.has(id))
|
|
237
|
+
return null;
|
|
238
|
+
let adjacent = false;
|
|
239
|
+
for (const [key, cell] of left.cells) {
|
|
240
|
+
if (right.cells.has(key))
|
|
241
|
+
return null;
|
|
242
|
+
for (const offset of CARDINAL_OFFSETS) {
|
|
243
|
+
if (right.cells.has(cellKey({ col: cell.col + offset.col, row: cell.row + offset.row })))
|
|
244
|
+
adjacent = true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
let capacity = 0;
|
|
248
|
+
for (const contribution of left.contributions)
|
|
249
|
+
capacity += contribution.capacity;
|
|
250
|
+
for (const contribution of right.contributions)
|
|
251
|
+
capacity += contribution.capacity;
|
|
252
|
+
return adjacent && Number.isFinite(capacity) ? [left, right] : null;
|
|
253
|
+
}
|
|
254
|
+
function copyRegionCells(cells) {
|
|
255
|
+
return cells.map(cell => ({ col: cell.col, row: cell.row }));
|
|
256
|
+
}
|
|
257
|
+
function pooledRegion(id, kind, tier, parts) {
|
|
258
|
+
const contributions = parts.map(part => ({ id: part.id, capacity: part.capacity, cells: copyRegionCells(part.cells) }));
|
|
259
|
+
return { id, kind, tier, capacity: contributions.reduce((sum, part) => sum + part.capacity, 0),
|
|
260
|
+
cells: contributions.flatMap(part => copyRegionCells(part.cells)), contributions };
|
|
261
|
+
}
|
|
262
|
+
/** Matching kind/tier, disjoint valid source identities/cells, and cardinal contact. Linear in input size.
|
|
263
|
+
* @capability mergeable-footprint-regions check compatible adjacent regions within caller work budgets
|
|
264
|
+
*/
|
|
265
|
+
export function canMerge(a, b, budget = {}) {
|
|
266
|
+
return mergeableRegions(a, b, budget) !== null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Pool compatible footprints under caller-owned `id`, retaining original capacities and identities.
|
|
270
|
+
* Returns null for invalid data, kind/tier mismatch, overlap, no cardinal contact or exhausted budget.
|
|
271
|
+
* Does not mutate inputs or grid reservations; costs, merge width and upgrade policy remain caller-owned.
|
|
272
|
+
* Capacity is the ordered JavaScript-number sum of original contributions, independent of cell area.
|
|
273
|
+
*
|
|
274
|
+
* @capability merge-footprint-regions pool adjacent compatible footprints with serializable capacity provenance
|
|
275
|
+
*/
|
|
276
|
+
export function mergeFootprints(a, b, id, budget = {}) {
|
|
277
|
+
if (!validId(id))
|
|
278
|
+
return null;
|
|
279
|
+
const checked = mergeableRegions(a, b, budget);
|
|
280
|
+
if (checked === null)
|
|
281
|
+
return null;
|
|
282
|
+
return pooledRegion(id, a.kind, a.tier, [...checked[0].contributions, ...checked[1].contributions]);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Partition whole original contributions by `leftIds`; both sides must remain cardinally connected.
|
|
286
|
+
* Singletons restore their original id and capacity; larger sides require explicit output identities.
|
|
287
|
+
* Returns null for invalid provenance/budget, unknown or duplicate ids, empty/disconnected sides, or
|
|
288
|
+
* conflicting output identities (including identities from the opposite side's contributions).
|
|
289
|
+
* An output may reuse its own side's source identity. Cell cuts/capacity repricing require caller policy.
|
|
290
|
+
* Both sides inherit the current region kind/tier. Inputs and grid reservations remain unchanged.
|
|
291
|
+
*
|
|
292
|
+
* @capability split-footprint-regions split pooled regions along retained contributions without area-based capacity loss
|
|
293
|
+
*/
|
|
294
|
+
export function splitRegion(region, leftIds, ids = {}, budget = {}) {
|
|
295
|
+
if (!withinRegionBudget([region], budget))
|
|
296
|
+
return null;
|
|
297
|
+
const checked = checkedRegion(region);
|
|
298
|
+
if (checked === null || leftIds.length === 0 || leftIds.length >= checked.contributions.length)
|
|
299
|
+
return null;
|
|
300
|
+
const selected = new Set();
|
|
301
|
+
for (const id of leftIds) {
|
|
302
|
+
if (!checked.ids.has(id) || selected.has(id))
|
|
303
|
+
return null;
|
|
304
|
+
selected.add(id);
|
|
305
|
+
}
|
|
306
|
+
const left = [];
|
|
307
|
+
const right = [];
|
|
308
|
+
for (const part of checked.contributions)
|
|
309
|
+
(selected.has(part.id) ? left : right).push(part);
|
|
310
|
+
function side(parts, id) {
|
|
311
|
+
if (parts.length === 1) {
|
|
312
|
+
const part = parts[0];
|
|
313
|
+
if (id !== undefined && id !== part.id)
|
|
314
|
+
return null;
|
|
315
|
+
return { id: part.id, kind: region.kind, tier: region.tier, capacity: part.capacity, cells: copyRegionCells(part.cells) };
|
|
316
|
+
}
|
|
317
|
+
if (id === undefined || !validId(id))
|
|
318
|
+
return null;
|
|
319
|
+
const pooled = pooledRegion(id, region.kind, region.tier, parts);
|
|
320
|
+
const cells = regionCells(pooled.cells);
|
|
321
|
+
return cells !== null && connectedRegion(cells) ? pooled : null;
|
|
322
|
+
}
|
|
323
|
+
const a = side(left, ids.left);
|
|
324
|
+
const b = side(right, ids.right);
|
|
325
|
+
if (a === null || b === null || a.id === b.id)
|
|
326
|
+
return null;
|
|
327
|
+
if (right.some(part => part.id === a.id) || left.some(part => part.id === b.id))
|
|
328
|
+
return null;
|
|
329
|
+
return [a, b];
|
|
330
|
+
}
|
|
130
331
|
/** Bridges live reservations into `world/placement`'s `PlacementRules.obstacles` so `validatePlacement`/`createPlacementController` see the grid's committed footprints unchanged. */
|
|
131
332
|
export function footprintObstacles(grid) {
|
|
132
333
|
return grid.list().map((reservation) => {
|
|
@@ -16,6 +16,16 @@ export interface GrassRules {
|
|
|
16
16
|
density: number;
|
|
17
17
|
/** Blade height in meters. */
|
|
18
18
|
bladeHeight: number;
|
|
19
|
+
/** Blade-width range in metres; endpoints are normalized on read. */
|
|
20
|
+
bladeWidthMin: number;
|
|
21
|
+
bladeWidthMax: number;
|
|
22
|
+
/** Bend range, 0..1. */
|
|
23
|
+
bladeBendMin: number;
|
|
24
|
+
bladeBendMax: number;
|
|
25
|
+
/** Spread of blades around each tuft root, in metres. */
|
|
26
|
+
tuftRadius: number;
|
|
27
|
+
/** Normal lift toward the sky, 0..1; controls the card lighting response. */
|
|
28
|
+
normalLift: number;
|
|
19
29
|
/** Base (root) color, hex. */
|
|
20
30
|
colorBase: string;
|
|
21
31
|
/** Tip color, hex. */
|
package/dist/world/grassKind.js
CHANGED
|
@@ -15,6 +15,12 @@ export const GRASS_FIELD_KIND = "grass_field";
|
|
|
15
15
|
export const GRASS_DEFAULTS = {
|
|
16
16
|
density: 12,
|
|
17
17
|
bladeHeight: 0.6,
|
|
18
|
+
bladeWidthMin: 0.05,
|
|
19
|
+
bladeWidthMax: 0.1,
|
|
20
|
+
bladeBendMin: 0.12,
|
|
21
|
+
bladeBendMax: 0.42,
|
|
22
|
+
tuftRadius: 0.16,
|
|
23
|
+
normalLift: 0.6,
|
|
18
24
|
colorBase: "#3f6f37",
|
|
19
25
|
colorTip: "#9ac25a",
|
|
20
26
|
colorVariation: 0.35,
|
|
@@ -29,6 +35,12 @@ export const GRASS_SCHEMA = {
|
|
|
29
35
|
fields: [
|
|
30
36
|
{ type: "range", key: "density", label: "density", min: 0.5, max: 160, step: 0.5, default: GRASS_DEFAULTS.density, unit: "/m²" },
|
|
31
37
|
{ type: "range", key: "bladeHeight", label: "blade height", min: 0.1, max: 2, step: 0.05, default: GRASS_DEFAULTS.bladeHeight, unit: "m" },
|
|
38
|
+
{ type: "range", key: "bladeWidthMin", label: "minimum blade width", min: 0.005, max: 0.3, step: 0.005, default: GRASS_DEFAULTS.bladeWidthMin, unit: "m" },
|
|
39
|
+
{ type: "range", key: "bladeWidthMax", label: "maximum blade width", min: 0.005, max: 0.3, step: 0.005, default: GRASS_DEFAULTS.bladeWidthMax, unit: "m" },
|
|
40
|
+
{ type: "range", key: "bladeBendMin", label: "minimum bend", min: 0, max: 1, step: 0.01, default: GRASS_DEFAULTS.bladeBendMin },
|
|
41
|
+
{ type: "range", key: "bladeBendMax", label: "maximum bend", min: 0, max: 1, step: 0.01, default: GRASS_DEFAULTS.bladeBendMax },
|
|
42
|
+
{ type: "range", key: "tuftRadius", label: "tuft radius", min: 0, max: 0.6, step: 0.01, default: GRASS_DEFAULTS.tuftRadius, unit: "m" },
|
|
43
|
+
{ type: "range", key: "normalLift", label: "normal lift", min: 0, max: 1, step: 0.01, default: GRASS_DEFAULTS.normalLift },
|
|
32
44
|
{ type: "color", key: "colorBase", label: "base color", default: GRASS_DEFAULTS.colorBase },
|
|
33
45
|
{ type: "color", key: "colorTip", label: "tip color", default: GRASS_DEFAULTS.colorTip },
|
|
34
46
|
{ type: "range", key: "colorVariation", label: "color variation", min: 0, max: 1, step: 0.02, default: GRASS_DEFAULTS.colorVariation },
|
|
@@ -49,9 +61,19 @@ function metaString(meta, key, fallback) {
|
|
|
49
61
|
}
|
|
50
62
|
/** Parse a volume's `meta` into fully-defaulted grass rules. @internal */
|
|
51
63
|
export function readGrassRules(meta) {
|
|
64
|
+
const widthMin = Math.max(0.005, metaNumber(meta, "bladeWidthMin", GRASS_DEFAULTS.bladeWidthMin));
|
|
65
|
+
const widthMax = Math.max(0.005, metaNumber(meta, "bladeWidthMax", GRASS_DEFAULTS.bladeWidthMax));
|
|
66
|
+
const bendMin = Math.max(0, Math.min(1, metaNumber(meta, "bladeBendMin", GRASS_DEFAULTS.bladeBendMin)));
|
|
67
|
+
const bendMax = Math.max(0, Math.min(1, metaNumber(meta, "bladeBendMax", GRASS_DEFAULTS.bladeBendMax)));
|
|
52
68
|
return {
|
|
53
69
|
density: Math.max(0, metaNumber(meta, "density", GRASS_DEFAULTS.density)),
|
|
54
70
|
bladeHeight: Math.max(0.05, metaNumber(meta, "bladeHeight", GRASS_DEFAULTS.bladeHeight)),
|
|
71
|
+
bladeWidthMin: Math.min(widthMin, widthMax),
|
|
72
|
+
bladeWidthMax: Math.max(widthMin, widthMax),
|
|
73
|
+
bladeBendMin: Math.min(bendMin, bendMax),
|
|
74
|
+
bladeBendMax: Math.max(bendMin, bendMax),
|
|
75
|
+
tuftRadius: Math.max(0, metaNumber(meta, "tuftRadius", GRASS_DEFAULTS.tuftRadius)),
|
|
76
|
+
normalLift: Math.max(0, Math.min(1, metaNumber(meta, "normalLift", GRASS_DEFAULTS.normalLift))),
|
|
55
77
|
colorBase: metaString(meta, "colorBase", GRASS_DEFAULTS.colorBase),
|
|
56
78
|
colorTip: metaString(meta, "colorTip", GRASS_DEFAULTS.colorTip),
|
|
57
79
|
colorVariation: metaNumber(meta, "colorVariation", GRASS_DEFAULTS.colorVariation),
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (O(points) per sample), and serializable; it composes onto any height sampler and pairs with
|
|
8
8
|
* `resolveTerrainField` so the same authored path drives both the rendered mesh and player collision.
|
|
9
9
|
* @capability path-terrain flatten / grade / carve a heightfield from an authored path with retaining profiles
|
|
10
|
-
* @consumer packages/core/src/world/terrain.ts (resolveTerrainField), Games/
|
|
10
|
+
* @consumer packages/core/src/world/terrain.ts (resolveTerrainField), Games/scrap-signal roads
|
|
11
11
|
*/
|
|
12
12
|
import type { Vec2 } from "./geometry.js";
|
|
13
13
|
/**
|
|
@@ -53,7 +53,7 @@ export interface TerrainMaterialLayer {
|
|
|
53
53
|
tiling?: number;
|
|
54
54
|
/** Project the material on all three axes to hide stretching on cliffs. */
|
|
55
55
|
triplanar?: boolean;
|
|
56
|
-
/**
|
|
56
|
+
/** Authored hex color replacing the default surface palette color in editor and runtime. */
|
|
57
57
|
tint?: string;
|
|
58
58
|
/** Layer master opacity 0..1 — scales this layer's blend weight everywhere. */
|
|
59
59
|
opacity?: number;
|
|
@@ -148,7 +148,7 @@ export interface EditableTerrain extends TerrainField {
|
|
|
148
148
|
revertSurfaceDelta(delta: SurfaceDelta): void;
|
|
149
149
|
/** The current material layer stack (lower index paints under higher). */
|
|
150
150
|
readonly layers: readonly TerrainMaterialLayer[];
|
|
151
|
-
/** Replace the material
|
|
151
|
+
/** Replace the material stack, retaining weights by layer id and normalizing when painted layers are removed. */
|
|
152
152
|
setLayers(layers: readonly TerrainMaterialLayer[]): void;
|
|
153
153
|
/**
|
|
154
154
|
* Paint the active `edit.surface` layer's blend weight into the brush footprint, pushing the
|
package/dist/world/terraform.js
CHANGED
|
@@ -123,14 +123,12 @@ export function createEditableTerrain(config) {
|
|
|
123
123
|
const cx = gridX(edit.center[0]);
|
|
124
124
|
const cz = gridZ(edit.center[1]);
|
|
125
125
|
let changed = 0;
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (gx < 0 || gx >= vertsX)
|
|
133
|
-
continue;
|
|
126
|
+
const minZ = Math.max(0, Math.round(cz) - rCells);
|
|
127
|
+
const maxZ = Math.min(vertsZ - 1, Math.round(cz) + rCells);
|
|
128
|
+
const minX = Math.max(0, Math.round(cx) - rCells);
|
|
129
|
+
const maxX = Math.min(vertsX - 1, Math.round(cx) + rCells);
|
|
130
|
+
for (let gz = minZ; gz <= maxZ; gz += 1) {
|
|
131
|
+
for (let gx = minX; gx <= maxX; gx += 1) {
|
|
134
132
|
const wx = vertexWorldX(gx);
|
|
135
133
|
const wz = vertexWorldZ(gz);
|
|
136
134
|
const dx = wx - edit.center[0];
|
|
@@ -230,14 +228,12 @@ export function createEditableTerrain(config) {
|
|
|
230
228
|
const rCells = Math.ceil(edit.radius / cellSize) + 1;
|
|
231
229
|
const cx = gridX(edit.center[0]);
|
|
232
230
|
const cz = gridZ(edit.center[1]);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (gx < 0 || gx >= cols)
|
|
240
|
-
continue;
|
|
231
|
+
const minZ = Math.max(0, Math.floor(cz) - rCells);
|
|
232
|
+
const maxZ = Math.min(rows - 1, Math.floor(cz) + rCells);
|
|
233
|
+
const minX = Math.max(0, Math.floor(cx) - rCells);
|
|
234
|
+
const maxX = Math.min(cols - 1, Math.floor(cx) + rCells);
|
|
235
|
+
for (let gz = minZ; gz <= maxZ; gz += 1) {
|
|
236
|
+
for (let gx = minX; gx <= maxX; gx += 1) {
|
|
241
237
|
const wx = cellWorldX(gx);
|
|
242
238
|
const wz = cellWorldZ(gz);
|
|
243
239
|
const dx = wx - edit.center[0];
|
|
@@ -469,9 +465,41 @@ export function createEditableTerrain(config) {
|
|
|
469
465
|
return layers;
|
|
470
466
|
},
|
|
471
467
|
setLayers(next) {
|
|
468
|
+
const previous = layers;
|
|
469
|
+
const previousWeights = weights;
|
|
472
470
|
layers = next.map((layer) => ({ ...layer }));
|
|
473
|
-
|
|
474
|
-
|
|
471
|
+
if (previousWeights === null || layers.length === 0) {
|
|
472
|
+
weights = null;
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const previousIndices = layers.map((layer) => previous.findIndex((entry) => entry.id === layer.id));
|
|
476
|
+
const buffer = new Float32Array(cellCount * layers.length);
|
|
477
|
+
let retained = false;
|
|
478
|
+
for (let cell = 0; cell < cellCount; cell += 1) {
|
|
479
|
+
let total = 0;
|
|
480
|
+
for (let layer = 0; layer < layers.length; layer += 1) {
|
|
481
|
+
const before = previousIndices[layer];
|
|
482
|
+
const weight = before < 0 ? 0 : previousWeights[cell * previous.length + before];
|
|
483
|
+
buffer[cell * layers.length + layer] = weight;
|
|
484
|
+
total += weight;
|
|
485
|
+
}
|
|
486
|
+
if (total > 0) {
|
|
487
|
+
retained = true;
|
|
488
|
+
let previousTotal = 0;
|
|
489
|
+
for (let layer = 0; layer < previous.length; layer += 1)
|
|
490
|
+
previousTotal += previousWeights[cell * previous.length + layer];
|
|
491
|
+
if (total < previousTotal) {
|
|
492
|
+
for (let layer = 0; layer < layers.length; layer += 1)
|
|
493
|
+
buffer[cell * layers.length + layer] /= total;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else {
|
|
497
|
+
const fallback = layers.findIndex((layer) => layer.surface === surfaces[cell]);
|
|
498
|
+
if (fallback >= 0)
|
|
499
|
+
buffer[cell * layers.length + fallback] = 1;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
weights = retained ? buffer : null;
|
|
475
503
|
},
|
|
476
504
|
blendRecording(edit, record) {
|
|
477
505
|
return blendPaint(edit, record);
|
package/dist/world/weather.d.ts
CHANGED
|
@@ -48,19 +48,34 @@ export interface FireGridConfig {
|
|
|
48
48
|
wind?: WindVector;
|
|
49
49
|
/** How strongly wind biases downwind vs upwind spread, 0..1. Default 0.6. */
|
|
50
50
|
windBias?: number;
|
|
51
|
+
/** Hard allocation bound; defaults to 65536 cells. */
|
|
52
|
+
maxCells?: number;
|
|
51
53
|
}
|
|
54
|
+
/** Live fire ignition, spread, consumption and wind policies; grid geometry stays fixed. */
|
|
55
|
+
export type FireGridTuning = Pick<FireGridConfig, "ignitionThreshold" | "spreadRate" | "burnRate" | "wind" | "windBias">;
|
|
52
56
|
export interface FireStepOptions {
|
|
53
57
|
/** Global spread multiplier this step (rain suppression from resolveWeather().spread). Default 1. */
|
|
54
58
|
spread?: number;
|
|
59
|
+
/** Live wind in metres/second along world X,Z, overriding configured wind this step. */
|
|
60
|
+
wind?: WindVector;
|
|
61
|
+
windAt?: (x: number, z: number) => WindVector;
|
|
55
62
|
/** Per-cell wetness 0..1 that resists ignition — from an environment field. */
|
|
56
63
|
wetnessAt?: (col: number, row: number) => number;
|
|
64
|
+
/** Heat removed per second at full wetness; 0 (default) leaves extinction to game policy. */
|
|
65
|
+
extinguishRate?: number;
|
|
57
66
|
}
|
|
58
67
|
export interface FireGrid {
|
|
59
68
|
readonly cols: number;
|
|
60
69
|
readonly rows: number;
|
|
70
|
+
readonly cellSize: number;
|
|
71
|
+
readonly origin: readonly [number, number];
|
|
61
72
|
/** Advance the cellular propagation by game-time `dt`. */
|
|
62
73
|
step(dt: number, options?: FireStepOptions): void;
|
|
63
74
|
igniteCell(col: number, row: number): void;
|
|
75
|
+
/** Cool a burning cell to unburnt without replacing its remaining fuel. */
|
|
76
|
+
extinguishCell(col: number, row: number): boolean;
|
|
77
|
+
/** Extinguish the cell under a world position; false outside the grid or when not burning. */
|
|
78
|
+
extinguish(x: number, z: number): boolean;
|
|
64
79
|
/** Ignite the cell under a world position; no-op if outside the grid. */
|
|
65
80
|
ignite(x: number, z: number): boolean;
|
|
66
81
|
cell(col: number, row: number): FireCell;
|
|
@@ -73,5 +88,7 @@ export interface FireGrid {
|
|
|
73
88
|
readonly burning: number;
|
|
74
89
|
/** Flat snapshot (row-major) for rendering. */
|
|
75
90
|
snapshot(): FireCell[];
|
|
91
|
+
restore(state: readonly FireCell[]): void;
|
|
92
|
+
retune(tuning: FireGridTuning): void;
|
|
76
93
|
}
|
|
77
94
|
export declare function createFireGrid(config: FireGridConfig): FireGrid;
|
package/dist/world/weather.js
CHANGED
|
@@ -29,39 +29,66 @@ const NEIGHBOURS = [
|
|
|
29
29
|
];
|
|
30
30
|
export function createFireGrid(config) {
|
|
31
31
|
const { cols, rows, cellSize } = config;
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
const maxCells = config.maxCells ?? 65_536;
|
|
33
|
+
if (!Number.isSafeInteger(maxCells) || maxCells < 1 || maxCells > 1_048_576)
|
|
34
|
+
throw new Error("createFireGrid: maxCells must be an integer in 1..1048576");
|
|
35
|
+
if (!Number.isSafeInteger(cols) || !Number.isSafeInteger(rows) || cols <= 0 || rows <= 0 || cols * rows > maxCells)
|
|
36
|
+
throw new Error("createFireGrid: cols/rows must be positive integers within maxCells");
|
|
37
|
+
if (!Number.isFinite(cellSize) || cellSize <= 0)
|
|
38
|
+
throw new Error("createFireGrid: cellSize must be finite and positive");
|
|
34
39
|
const originX = config.origin?.[0] ?? 0;
|
|
35
40
|
const originZ = config.origin?.[1] ?? 0;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
if (!Number.isFinite(originX) || !Number.isFinite(originZ))
|
|
42
|
+
throw new Error("createFireGrid: origin must be finite");
|
|
43
|
+
let ignitionThreshold = 1;
|
|
44
|
+
let spreadRate = 0.6;
|
|
45
|
+
let burnRate = 0.25;
|
|
46
|
+
let windBias = 0.6;
|
|
47
|
+
let wind;
|
|
48
|
+
function retune(tuning) {
|
|
49
|
+
for (const key of ["ignitionThreshold", "spreadRate", "burnRate", "windBias"]) {
|
|
50
|
+
const value = tuning[key];
|
|
51
|
+
if (value !== undefined && (!Number.isFinite(value) || value < 0 || (key === "windBias" && value > 1)))
|
|
52
|
+
throw new Error(`fire ${key} is invalid`);
|
|
53
|
+
}
|
|
54
|
+
if (tuning.wind !== undefined && (!Number.isFinite(tuning.wind[0]) || !Number.isFinite(tuning.wind[1])))
|
|
55
|
+
throw new Error("fire wind must be finite");
|
|
56
|
+
ignitionThreshold = tuning.ignitionThreshold ?? ignitionThreshold;
|
|
57
|
+
spreadRate = tuning.spreadRate ?? spreadRate;
|
|
58
|
+
burnRate = tuning.burnRate ?? burnRate;
|
|
59
|
+
windBias = tuning.windBias ?? windBias;
|
|
60
|
+
if (tuning.wind !== undefined)
|
|
61
|
+
wind = [tuning.wind[0], tuning.wind[1]];
|
|
62
|
+
}
|
|
63
|
+
retune(config);
|
|
44
64
|
const cells = new Array(cols * rows);
|
|
45
65
|
for (let row = 0; row < rows; row += 1) {
|
|
46
66
|
for (let col = 0; col < cols; col += 1) {
|
|
47
67
|
const fuel = config.fuelAt?.(col, row) ?? 1;
|
|
68
|
+
if (!Number.isFinite(fuel) || fuel < 0)
|
|
69
|
+
throw new Error("fire initial fuel must be finite and nonnegative");
|
|
48
70
|
cells[row * cols + col] = { fuel: Math.max(0, fuel), heat: 0, state: "unburnt" };
|
|
49
71
|
}
|
|
50
72
|
}
|
|
51
73
|
const index = (col, row) => row * cols + col;
|
|
52
|
-
const inBounds = (col, row) => col >= 0 && col < cols && row >= 0 && row < rows;
|
|
74
|
+
const inBounds = (col, row) => Number.isInteger(col) && Number.isInteger(row) && col >= 0 && col < cols && row >= 0 && row < rows;
|
|
53
75
|
const cellCoord = (x, z) => {
|
|
54
76
|
const col = Math.round((x - originX) / cellSize);
|
|
55
77
|
const row = Math.round((z - originZ) / cellSize);
|
|
56
78
|
return inBounds(col, row) ? { col, row } : null;
|
|
57
79
|
};
|
|
58
|
-
const directionalWeight = (dx, dz) => {
|
|
80
|
+
const directionalWeight = (dx, dz, vector) => {
|
|
81
|
+
if (vector === undefined)
|
|
82
|
+
return 1;
|
|
83
|
+
const windLen = Math.hypot(vector[0], vector[1]);
|
|
59
84
|
if (windLen === 0)
|
|
60
85
|
return 1;
|
|
61
86
|
const len = Math.hypot(dx, dz) || 1;
|
|
62
|
-
const alignment = (dx / len) *
|
|
87
|
+
const alignment = (dx / len) * vector[0] / windLen + (dz / len) * vector[1] / windLen;
|
|
63
88
|
return 1 + windBias * alignment;
|
|
64
89
|
};
|
|
90
|
+
const addedHeat = new Float64Array(cols * rows);
|
|
91
|
+
const wetness = new Float64Array(cols * rows);
|
|
65
92
|
let burning = 0;
|
|
66
93
|
for (const cell of cells)
|
|
67
94
|
if (cell.state === "burning")
|
|
@@ -77,20 +104,71 @@ export function createFireGrid(config) {
|
|
|
77
104
|
cell.state = "burning";
|
|
78
105
|
cell.heat = Math.max(cell.heat, ignitionThreshold);
|
|
79
106
|
};
|
|
107
|
+
const extinguishCell = (col, row) => {
|
|
108
|
+
if (!inBounds(col, row))
|
|
109
|
+
return false;
|
|
110
|
+
const cell = cells[index(col, row)];
|
|
111
|
+
if (cell.state !== "burning")
|
|
112
|
+
return false;
|
|
113
|
+
cell.state = "unburnt";
|
|
114
|
+
cell.heat = 0;
|
|
115
|
+
burning -= 1;
|
|
116
|
+
return true;
|
|
117
|
+
};
|
|
80
118
|
return {
|
|
81
119
|
cols,
|
|
82
120
|
rows,
|
|
121
|
+
cellSize,
|
|
122
|
+
origin: [originX, originZ],
|
|
123
|
+
retune,
|
|
124
|
+
restore(state) {
|
|
125
|
+
if (state.length !== cells.length)
|
|
126
|
+
throw new Error("fire snapshot dimensions do not match");
|
|
127
|
+
const next = state.map((cell) => {
|
|
128
|
+
if (!Number.isFinite(cell.fuel) || cell.fuel < 0 || !Number.isFinite(cell.heat) || cell.heat < 0 || !["unburnt", "burning", "burnt"].includes(cell.state) || (cell.state === "burning" && cell.fuel <= 0) || (cell.state === "burnt" && (cell.fuel !== 0 || cell.heat !== 0)))
|
|
129
|
+
throw new Error("invalid fire snapshot cell");
|
|
130
|
+
return { ...cell };
|
|
131
|
+
});
|
|
132
|
+
burning = 0;
|
|
133
|
+
for (let i = 0; i < cells.length; i += 1) {
|
|
134
|
+
cells[i] = next[i];
|
|
135
|
+
if (cells[i].state === "burning")
|
|
136
|
+
burning += 1;
|
|
137
|
+
}
|
|
138
|
+
},
|
|
83
139
|
step(dt, options) {
|
|
84
|
-
if (dt
|
|
140
|
+
if (!Number.isFinite(dt) || dt < 0)
|
|
141
|
+
throw new Error("fire dt must be finite and nonnegative");
|
|
142
|
+
if (dt === 0)
|
|
85
143
|
return;
|
|
86
144
|
const globalSpread = options?.spread ?? 1;
|
|
145
|
+
if (!Number.isFinite(globalSpread) || globalSpread < 0)
|
|
146
|
+
throw new Error("fire spread multiplier must be finite and nonnegative");
|
|
87
147
|
const wetnessAt = options?.wetnessAt;
|
|
88
|
-
const
|
|
148
|
+
const extinguishRate = options?.extinguishRate ?? 0;
|
|
149
|
+
if (!Number.isFinite(extinguishRate) || extinguishRate < 0)
|
|
150
|
+
throw new Error("fire extinguishRate must be finite and nonnegative");
|
|
151
|
+
const resolveWetness = (col, row) => {
|
|
152
|
+
const value = wetnessAt?.(col, row) ?? 0;
|
|
153
|
+
if (!Number.isFinite(value))
|
|
154
|
+
throw new Error("fire wetness must be finite");
|
|
155
|
+
return Math.max(0, Math.min(1, value));
|
|
156
|
+
};
|
|
157
|
+
addedHeat.fill(0);
|
|
158
|
+
wetness.fill(0);
|
|
89
159
|
for (let row = 0; row < rows; row += 1) {
|
|
90
160
|
for (let col = 0; col < cols; col += 1) {
|
|
91
161
|
const cell = cells[index(col, row)];
|
|
92
162
|
if (cell.state !== "burning")
|
|
93
163
|
continue;
|
|
164
|
+
const vector = options?.windAt?.(originX + col * cellSize, originZ + row * cellSize) ?? options?.wind ?? wind;
|
|
165
|
+
if (vector !== undefined && (!Number.isFinite(vector[0]) || !Number.isFinite(vector[1])))
|
|
166
|
+
throw new Error("fire step wind must be finite");
|
|
167
|
+
const i = index(col, row);
|
|
168
|
+
if (extinguishRate > 0)
|
|
169
|
+
wetness[i] = resolveWetness(col, row);
|
|
170
|
+
const coolingRate = extinguishRate * wetness[i];
|
|
171
|
+
const burningDt = Math.min(dt, burnRate === 0 ? Infinity : cell.fuel / burnRate, coolingRate === 0 ? Infinity : cell.heat / coolingRate);
|
|
94
172
|
for (const [dx, dz] of NEIGHBOURS) {
|
|
95
173
|
const nc = col + dx;
|
|
96
174
|
const nr = row + dz;
|
|
@@ -100,7 +178,7 @@ export function createFireGrid(config) {
|
|
|
100
178
|
if (neighbour.state === "burnt" || neighbour.fuel <= 0)
|
|
101
179
|
continue;
|
|
102
180
|
addedHeat[index(nc, nr)] +=
|
|
103
|
-
spreadRate * globalSpread * directionalWeight(dx, dz) *
|
|
181
|
+
spreadRate * globalSpread * directionalWeight(dx, dz, vector) * burningDt;
|
|
104
182
|
}
|
|
105
183
|
}
|
|
106
184
|
}
|
|
@@ -108,19 +186,32 @@ export function createFireGrid(config) {
|
|
|
108
186
|
for (let col = 0; col < cols; col += 1) {
|
|
109
187
|
const i = index(col, row);
|
|
110
188
|
const cell = cells[i];
|
|
189
|
+
if (cell.state === "unburnt" && (addedHeat[i] > 0 || (extinguishRate > 0 && cell.heat > 0)))
|
|
190
|
+
wetness[i] = resolveWetness(col, row);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (let row = 0; row < rows; row += 1) {
|
|
194
|
+
for (let col = 0; col < cols; col += 1) {
|
|
195
|
+
const i = index(col, row);
|
|
196
|
+
const cell = cells[i];
|
|
197
|
+
const coolingRate = extinguishRate * wetness[i];
|
|
111
198
|
if (cell.state === "burning") {
|
|
112
|
-
|
|
199
|
+
const burningDt = Math.min(dt, coolingRate === 0 ? Infinity : cell.heat / coolingRate);
|
|
200
|
+
cell.fuel = Math.max(0, cell.fuel - burnRate * burningDt);
|
|
201
|
+
cell.heat = Math.max(0, cell.heat - coolingRate * dt);
|
|
113
202
|
if (cell.fuel <= 0) {
|
|
114
203
|
cell.state = "burnt";
|
|
115
204
|
cell.heat = 0;
|
|
116
205
|
burning -= 1;
|
|
117
206
|
}
|
|
207
|
+
else if (coolingRate > 0 && cell.heat <= 0) {
|
|
208
|
+
extinguishCell(col, row);
|
|
209
|
+
}
|
|
118
210
|
continue;
|
|
119
211
|
}
|
|
120
|
-
if (cell.state !== "unburnt" || addedHeat[i] === 0)
|
|
212
|
+
if (cell.state !== "unburnt" || (addedHeat[i] === 0 && coolingRate === 0))
|
|
121
213
|
continue;
|
|
122
|
-
|
|
123
|
-
cell.heat += addedHeat[i] * (1 - resist);
|
|
214
|
+
cell.heat = Math.max(0, cell.heat + addedHeat[i] * (1 - wetness[i]) - coolingRate * dt);
|
|
124
215
|
if (cell.heat >= ignitionThreshold && cell.fuel > 0) {
|
|
125
216
|
cell.state = "burning";
|
|
126
217
|
burning += 1;
|
|
@@ -129,6 +220,11 @@ export function createFireGrid(config) {
|
|
|
129
220
|
}
|
|
130
221
|
},
|
|
131
222
|
igniteCell,
|
|
223
|
+
extinguishCell,
|
|
224
|
+
extinguish(x, z) {
|
|
225
|
+
const coord = cellCoord(x, z);
|
|
226
|
+
return coord !== null && extinguishCell(coord.col, coord.row);
|
|
227
|
+
},
|
|
132
228
|
ignite(x, z) {
|
|
133
229
|
const coord = cellCoord(x, z);
|
|
134
230
|
if (coord === null)
|