@jgengine/core 0.8.0 → 0.10.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.
Files changed (303) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/ai/flock.d.ts +36 -0
  3. package/dist/ai/flock.js +182 -0
  4. package/dist/ai/groupAssist.d.ts +19 -0
  5. package/dist/ai/groupAssist.js +59 -0
  6. package/dist/ai/laneSelect.d.ts +21 -0
  7. package/dist/ai/laneSelect.js +37 -0
  8. package/dist/ai/mobBrain.d.ts +68 -0
  9. package/dist/ai/mobBrain.js +115 -0
  10. package/dist/ai/spawnDirector.d.ts +13 -0
  11. package/dist/ai/spawnDirector.js +41 -17
  12. package/dist/ai/spawnPoint.d.ts +5 -0
  13. package/dist/ai/spawnPoint.js +5 -0
  14. package/dist/ai/threat.d.ts +2 -0
  15. package/dist/ai/threat.js +34 -0
  16. package/dist/audio/audioFalloff.d.ts +5 -2
  17. package/dist/audio/music.d.ts +53 -0
  18. package/dist/audio/music.js +34 -0
  19. package/dist/audio/synth.d.ts +60 -0
  20. package/dist/audio/synth.js +8 -0
  21. package/dist/authoring.d.ts +14 -0
  22. package/dist/authoring.js +14 -0
  23. package/dist/cartridge/runtime.d.ts +15 -0
  24. package/dist/cartridge/runtime.js +556 -0
  25. package/dist/cartridge/spec.d.ts +188 -0
  26. package/dist/cartridge/spec.js +19 -0
  27. package/dist/cartridge/validate.d.ts +2 -0
  28. package/dist/cartridge/validate.js +78 -0
  29. package/dist/combat/abilityKit.d.ts +17 -2
  30. package/dist/combat/abilityKit.js +62 -13
  31. package/dist/combat/castRunner.d.ts +40 -0
  32. package/dist/combat/castRunner.js +56 -0
  33. package/dist/combat/comboPoints.d.ts +14 -0
  34. package/dist/combat/comboPoints.js +52 -0
  35. package/dist/combat/defensiveWindow.d.ts +1 -1
  36. package/dist/combat/defensiveWindow.js +5 -3
  37. package/dist/combat/dotField.d.ts +43 -0
  38. package/dist/combat/dotField.js +48 -0
  39. package/dist/combat/projectiles.d.ts +33 -12
  40. package/dist/combat/projectiles.js +214 -129
  41. package/dist/combat/regenShield.d.ts +35 -0
  42. package/dist/combat/regenShield.js +45 -0
  43. package/dist/combat/resistance.d.ts +16 -4
  44. package/dist/combat/resistance.js +43 -7
  45. package/dist/combat/resourcePool.d.ts +20 -0
  46. package/dist/combat/resourcePool.js +55 -0
  47. package/dist/combat/shotOrigin.d.ts +52 -0
  48. package/dist/combat/shotOrigin.js +86 -0
  49. package/dist/combat/telegraph.d.ts +25 -0
  50. package/dist/combat/telegraph.js +47 -0
  51. package/dist/crafting/recipe.js +4 -1
  52. package/dist/devtools/devtools.d.ts +72 -24
  53. package/dist/devtools/devtools.js +449 -101
  54. package/dist/devtools/transformTunables.js +3 -3
  55. package/dist/devtools/tunableSchema.d.ts +140 -0
  56. package/dist/devtools/tunableSchema.js +476 -0
  57. package/dist/editor/commands.d.ts +65 -0
  58. package/dist/editor/commands.js +193 -0
  59. package/dist/editor/document.d.ts +40 -0
  60. package/dist/editor/document.js +153 -0
  61. package/dist/editor/index.d.ts +30 -0
  62. package/dist/editor/index.js +6 -0
  63. package/dist/editor/types.d.ts +69 -0
  64. package/dist/editor/types.js +29 -0
  65. package/dist/faction/factions.d.ts +33 -0
  66. package/dist/faction/factions.js +76 -0
  67. package/dist/faction/reputation.d.ts +32 -0
  68. package/dist/faction/reputation.js +82 -0
  69. package/dist/game/chat.d.ts +2 -0
  70. package/dist/game/chat.js +9 -1
  71. package/dist/game/chatFilter.d.ts +15 -0
  72. package/dist/game/chatFilter.js +55 -0
  73. package/dist/game/connectedPlayers.d.ts +28 -0
  74. package/dist/game/connectedPlayers.js +21 -0
  75. package/dist/game/controlGate.d.ts +4 -0
  76. package/dist/game/controlGate.js +7 -0
  77. package/dist/game/defineGame.d.ts +44 -1
  78. package/dist/game/defineGame.js +1 -0
  79. package/dist/game/events.d.ts +25 -0
  80. package/dist/game/gamePhase.d.ts +20 -0
  81. package/dist/game/gamePhase.js +21 -0
  82. package/dist/game/keyValueStore.d.ts +25 -0
  83. package/dist/game/keyValueStore.js +61 -0
  84. package/dist/game/levelSequence.d.ts +24 -2
  85. package/dist/game/levelSequence.js +51 -2
  86. package/dist/game/lootTable.d.ts +14 -1
  87. package/dist/game/lootTable.js +28 -7
  88. package/dist/game/modelAnimation.d.ts +8 -0
  89. package/dist/game/modelAnimation.js +18 -0
  90. package/dist/game/playableGame.d.ts +116 -4
  91. package/dist/game/playableGame.js +1 -1
  92. package/dist/game/quest.d.ts +7 -3
  93. package/dist/game/quest.js +17 -8
  94. package/dist/game/race.d.ts +39 -1
  95. package/dist/game/race.js +96 -11
  96. package/dist/game/recordBook.d.ts +27 -0
  97. package/dist/game/recordBook.js +86 -0
  98. package/dist/game/talents.d.ts +47 -0
  99. package/dist/game/talents.js +140 -0
  100. package/dist/index.d.ts +8 -0
  101. package/dist/index.js +8 -0
  102. package/dist/input/actionBindings.d.ts +2 -0
  103. package/dist/input/actionBindings.js +1 -0
  104. package/dist/input/axisInput.d.ts +33 -3
  105. package/dist/input/axisInput.js +60 -4
  106. package/dist/input/bindingOverrides.d.ts +25 -0
  107. package/dist/input/bindingOverrides.js +95 -0
  108. package/dist/input/pointerAxis.d.ts +30 -0
  109. package/dist/input/pointerAxis.js +31 -0
  110. package/dist/input/touchScheme.d.ts +6 -0
  111. package/dist/input/touchScheme.js +45 -7
  112. package/dist/interaction/contextMenu.d.ts +1 -0
  113. package/dist/interaction/contextMenu.js +1 -0
  114. package/dist/interaction/proximityPrompt.d.ts +3 -1
  115. package/dist/interaction/proximityPrompt.js +3 -3
  116. package/dist/meta/changelog.d.ts +1 -1
  117. package/dist/meta/changelog.js +44 -1
  118. package/dist/movement/dash.d.ts +3 -0
  119. package/dist/movement/dash.js +19 -5
  120. package/dist/movement/glideModel.d.ts +44 -0
  121. package/dist/movement/glideModel.js +54 -0
  122. package/dist/movement/grappleSwing.d.ts +34 -0
  123. package/dist/movement/grappleSwing.js +56 -0
  124. package/dist/movement/leaderTrail.d.ts +29 -0
  125. package/dist/movement/leaderTrail.js +67 -0
  126. package/dist/movement/movementModel.d.ts +4 -0
  127. package/dist/movement/movementModel.js +13 -0
  128. package/dist/movement/playerMovement.d.ts +41 -0
  129. package/dist/movement/playerMovement.js +181 -0
  130. package/dist/movement/steering.d.ts +27 -0
  131. package/dist/movement/steering.js +40 -0
  132. package/dist/multiplayer/feedWriteGate.d.ts +10 -0
  133. package/dist/multiplayer/feedWriteGate.js +12 -0
  134. package/dist/multiplayer/presenceModel.d.ts +14 -2
  135. package/dist/multiplayer/presenceModel.js +15 -3
  136. package/dist/nav/corridors.d.ts +30 -0
  137. package/dist/nav/corridors.js +69 -0
  138. package/dist/nav/navGrid.js +30 -4
  139. package/dist/nav/railGraph.d.ts +66 -0
  140. package/dist/nav/railGraph.js +129 -0
  141. package/dist/nav/timetable.d.ts +41 -0
  142. package/dist/nav/timetable.js +94 -0
  143. package/dist/physics/buoyancy.d.ts +6 -0
  144. package/dist/physics/buoyancy.js +14 -0
  145. package/dist/physics/flowTube.d.ts +32 -0
  146. package/dist/physics/flowTube.js +64 -0
  147. package/dist/physics/forceVolume.d.ts +27 -0
  148. package/dist/physics/forceVolume.js +38 -0
  149. package/dist/physics/kinematicVehicle.d.ts +52 -0
  150. package/dist/physics/kinematicVehicle.js +81 -0
  151. package/dist/physics/physicsWorld.d.ts +13 -6
  152. package/dist/physics/physicsWorld.js +169 -40
  153. package/dist/physics/ragdoll.js +1 -1
  154. package/dist/physics/vehicleBody.js +2 -1
  155. package/dist/random/rng.d.ts +2 -0
  156. package/dist/random/rng.js +2 -0
  157. package/dist/random/seedLink.d.ts +5 -0
  158. package/dist/random/seedLink.js +35 -0
  159. package/dist/render/postProcessing.d.ts +56 -0
  160. package/dist/render/postProcessing.js +1 -0
  161. package/dist/runtime/adapter.d.ts +19 -0
  162. package/dist/runtime/adapter.js +11 -5
  163. package/dist/runtime/cameraDirector.d.ts +6 -1
  164. package/dist/runtime/cameraDirector.js +6 -0
  165. package/dist/runtime/commandRunner.d.ts +2 -2
  166. package/dist/runtime/commandRunner.js +2 -2
  167. package/dist/runtime/gameContext.d.ts +72 -8
  168. package/dist/runtime/gameContext.js +374 -61
  169. package/dist/runtime/gameRuntime.d.ts +5 -1
  170. package/dist/runtime/gameRuntime.js +13 -12
  171. package/dist/runtime/hostPersistence.d.ts +9 -1
  172. package/dist/runtime/hostPersistence.js +23 -8
  173. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  174. package/dist/runtime/hostedGameRunner.js +62 -0
  175. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  176. package/dist/runtime/hostedWorldSession.js +57 -0
  177. package/dist/runtime/inputSnapshot.d.ts +18 -0
  178. package/dist/runtime/inputSnapshot.js +7 -0
  179. package/dist/runtime/motionIntents.d.ts +16 -6
  180. package/dist/runtime/motionIntents.js +31 -2
  181. package/dist/runtime/snapshot.d.ts +1 -0
  182. package/dist/runtime/snapshot.js +1 -1
  183. package/dist/runtime/visibility.d.ts +74 -0
  184. package/dist/runtime/visibility.js +127 -0
  185. package/dist/runtime/worldChannel.d.ts +58 -0
  186. package/dist/runtime/worldChannel.js +69 -0
  187. package/dist/runtime/worldMirror.d.ts +24 -0
  188. package/dist/runtime/worldMirror.js +31 -0
  189. package/dist/runtime/worldReplication.d.ts +46 -0
  190. package/dist/runtime/worldReplication.js +185 -0
  191. package/dist/runtime/worldSnapshot.d.ts +17 -0
  192. package/dist/runtime/worldSnapshot.js +15 -0
  193. package/dist/scene/assetCatalog.d.ts +1 -0
  194. package/dist/scene/colliders.d.ts +50 -0
  195. package/dist/scene/colliders.js +101 -0
  196. package/dist/scene/entityStats.d.ts +4 -0
  197. package/dist/scene/entityStats.js +21 -0
  198. package/dist/scene/entityStore.d.ts +51 -5
  199. package/dist/scene/entityStore.js +119 -12
  200. package/dist/scene/objectQuery.d.ts +13 -2
  201. package/dist/scene/objectQuery.js +34 -2
  202. package/dist/scene/objectStore.d.ts +2 -0
  203. package/dist/scene/objectStore.js +31 -12
  204. package/dist/scene/sceneRaycast.d.ts +80 -0
  205. package/dist/scene/sceneRaycast.js +284 -0
  206. package/dist/scene/spatial.d.ts +11 -16
  207. package/dist/scene/spatial.js +99 -38
  208. package/dist/sensor/recordingBuffer.d.ts +6 -0
  209. package/dist/sensor/recordingBuffer.js +7 -0
  210. package/dist/sensor/replayLoop.d.ts +39 -0
  211. package/dist/sensor/replayLoop.js +64 -0
  212. package/dist/sensor/visionCone.d.ts +31 -0
  213. package/dist/sensor/visionCone.js +72 -0
  214. package/dist/session/extraction.d.ts +2 -2
  215. package/dist/session/extraction.js +13 -2
  216. package/dist/settings/settingsModel.d.ts +97 -0
  217. package/dist/settings/settingsModel.js +100 -0
  218. package/dist/stats/statModifiers.d.ts +5 -2
  219. package/dist/stats/statModifiers.js +32 -14
  220. package/dist/store/observableKeyedStore.d.ts +4 -0
  221. package/dist/store/observableKeyedStore.js +17 -2
  222. package/dist/survival/decayMeter.d.ts +1 -1
  223. package/dist/survival/decayMeter.js +0 -1
  224. package/dist/tactics/fallingGrid.d.ts +2 -2
  225. package/dist/tactics/fallingGrid.js +2 -2
  226. package/dist/time/beatClock.d.ts +18 -0
  227. package/dist/time/beatClock.js +24 -0
  228. package/dist/time/idleProgress.d.ts +29 -0
  229. package/dist/time/idleProgress.js +31 -0
  230. package/dist/time/serverTick.d.ts +7 -1
  231. package/dist/time/serverTick.js +27 -4
  232. package/dist/time/stateSchedule.d.ts +57 -0
  233. package/dist/time/stateSchedule.js +121 -0
  234. package/dist/turn/commit.d.ts +2 -2
  235. package/dist/turn/commit.js +2 -2
  236. package/dist/ui/gameLayout.d.ts +125 -0
  237. package/dist/ui/gameLayout.js +129 -0
  238. package/dist/ui/hudLayout.d.ts +58 -0
  239. package/dist/ui/hudLayout.js +207 -0
  240. package/dist/ui/hudScale.d.ts +44 -0
  241. package/dist/ui/hudScale.js +55 -0
  242. package/dist/ui/orientation.d.ts +39 -0
  243. package/dist/ui/orientation.js +49 -0
  244. package/dist/ui/swingTimer.d.ts +42 -0
  245. package/dist/ui/swingTimer.js +38 -0
  246. package/dist/visibility/assetStreaming.d.ts +66 -0
  247. package/dist/visibility/assetStreaming.js +201 -0
  248. package/dist/visibility/bounds.d.ts +66 -0
  249. package/dist/visibility/bounds.js +113 -0
  250. package/dist/visibility/camera.d.ts +21 -0
  251. package/dist/visibility/camera.js +3 -0
  252. package/dist/visibility/config.d.ts +21 -0
  253. package/dist/visibility/config.js +1 -0
  254. package/dist/visibility/diagnostics.d.ts +26 -0
  255. package/dist/visibility/diagnostics.js +36 -0
  256. package/dist/visibility/distance.d.ts +11 -0
  257. package/dist/visibility/distance.js +30 -0
  258. package/dist/visibility/frustum.d.ts +47 -0
  259. package/dist/visibility/frustum.js +186 -0
  260. package/dist/visibility/occlusion.d.ts +36 -0
  261. package/dist/visibility/occlusion.js +45 -0
  262. package/dist/visibility/settings.d.ts +75 -0
  263. package/dist/visibility/settings.js +49 -0
  264. package/dist/visibility/simulationCulling.d.ts +34 -0
  265. package/dist/visibility/simulationCulling.js +36 -0
  266. package/dist/visibility/spatialIndex.d.ts +45 -0
  267. package/dist/visibility/spatialIndex.js +212 -0
  268. package/dist/visibility/visibilitySystem.d.ts +90 -0
  269. package/dist/visibility/visibilitySystem.js +257 -0
  270. package/dist/world/buildings.d.ts +6 -0
  271. package/dist/world/buildings.js +152 -0
  272. package/dist/world/carve.d.ts +7 -2
  273. package/dist/world/carve.js +17 -7
  274. package/dist/world/cellStates.d.ts +45 -0
  275. package/dist/world/cellStates.js +103 -0
  276. package/dist/world/environmentSummary.d.ts +21 -4
  277. package/dist/world/environmentSummary.js +22 -3
  278. package/dist/world/features.d.ts +185 -8
  279. package/dist/world/features.js +64 -6
  280. package/dist/world/gridInstances.d.ts +7 -8
  281. package/dist/world/gridInstances.js +145 -7
  282. package/dist/world/lod.d.ts +17 -0
  283. package/dist/world/lod.js +66 -0
  284. package/dist/world/mapLayers.d.ts +60 -0
  285. package/dist/world/mapLayers.js +44 -0
  286. package/dist/world/massing.d.ts +45 -0
  287. package/dist/world/massing.js +581 -0
  288. package/dist/world/minimap.d.ts +13 -1
  289. package/dist/world/minimap.js +21 -2
  290. package/dist/world/roads.d.ts +47 -0
  291. package/dist/world/roads.js +146 -0
  292. package/dist/world/streets.d.ts +80 -0
  293. package/dist/world/streets.js +144 -0
  294. package/dist/world/terrain.d.ts +69 -3
  295. package/dist/world/terrain.js +309 -16
  296. package/dist/world/water.d.ts +7 -0
  297. package/dist/world/water.js +19 -4
  298. package/dist/world/weather.d.ts +3 -18
  299. package/dist/world/weather.js +4 -17
  300. package/dist/world/windZones.d.ts +47 -0
  301. package/dist/world/windZones.js +59 -0
  302. package/llms.txt +3013 -1726
  303. package/package.json +12 -2
@@ -1,3 +1,4 @@
1
+ import { nearestOnPath } from "./roads.js";
1
2
  function clamp01(value) {
2
3
  return value < 0 ? 0 : value > 1 ? 1 : value;
3
4
  }
@@ -13,6 +14,7 @@ function hash2(ix, iz, seed) {
13
14
  h ^= h >>> 12;
14
15
  return (h >>> 0) / 4294967295;
15
16
  }
17
+ /** Smoothly interpolated 2D value noise in `[-1, 1]` for the given seed. */
16
18
  export function valueNoise(x, z, seed) {
17
19
  const x0 = Math.floor(x);
18
20
  const z0 = Math.floor(z);
@@ -28,6 +30,7 @@ export function valueNoise(x, z, seed) {
28
30
  const bottom = lerp(v01, v11, sx);
29
31
  return lerp(top, bottom, sz) * 2 - 1;
30
32
  }
33
+ /** Layers `valueNoise` octaves per `config` into a single normalized noise sample. */
31
34
  export function fractalNoise(x, z, config) {
32
35
  let amplitude = 1;
33
36
  let frequency = config.frequency;
@@ -57,6 +60,7 @@ export function seedFrom(value, fallback) {
57
60
  }
58
61
  return fallback;
59
62
  }
63
+ /** Derives a `TerrainField.sampleNormal` from a height sampler via finite-difference gradients. */
60
64
  export function withNormal(sampleHeight) {
61
65
  const epsilon = 0.75;
62
66
  return (x, z) => {
@@ -81,9 +85,11 @@ export const FLAT_FIELD = {
81
85
  sampleHeight: () => 0,
82
86
  sampleNormal: () => [0, 1, 0],
83
87
  };
88
+ /** A flat, zero-height `TerrainField` for arenas with no elevation. */
84
89
  export function flatField() {
85
90
  return FLAT_FIELD;
86
91
  }
92
+ /** Builds a `TerrainField` whose height is fractal noise shaped by `config`. */
87
93
  export function noiseField(config = {}) {
88
94
  const fractal = {
89
95
  seed: seedFrom(config.seed, 1337),
@@ -120,6 +126,7 @@ function smoothstep(edge0, edge1, value) {
120
126
  const t = clamp01((value - edge0) / (edge1 - edge0));
121
127
  return t * t * (3 - 2 * t);
122
128
  }
129
+ /** Builds a `TerrainField` with a flat spawn plateau, rolling hills, and a basin, for combat arenas. */
123
130
  export function arenaField(config = {}) {
124
131
  const seed = seedFrom(config.seed ?? "arena", 7);
125
132
  const rolling = {
@@ -150,9 +157,86 @@ export function arenaField(config = {}) {
150
157
  }
151
158
  export function groundFieldFor(world) {
152
159
  if (world !== undefined && world.kind === "environment")
153
- return resolveTerrainField(world.terrain);
160
+ return resolveEnvironmentField(world);
154
161
  return flatField();
155
162
  }
163
+ /** Whether a world declares real terrain (base heightfield or islands) rather than a flat plane — gates terrain-floor sampling in the movement controllers. */
164
+ export function hasEnvironmentTerrain(world) {
165
+ return world?.kind === "environment" && (world.terrain !== undefined || (world.islands?.length ?? 0) > 0);
166
+ }
167
+ /** Ground height between islands when no base terrain exists — deep enough to read as a fall into the void, finite so physics stays sane. */
168
+ export const ISLAND_VOID_HEIGHT = -256;
169
+ function islandContains(descriptor, x, z) {
170
+ return (Math.abs(x - descriptor.origin[0]) <= descriptor.bounds.w / 2 &&
171
+ Math.abs(z - descriptor.origin[1]) <= descriptor.bounds.d / 2);
172
+ }
173
+ /**
174
+ * Composes a base terrain and any number of bounded islands into one world field: inside an island's
175
+ * rect the island's own field (sampled in island-local coordinates) wins, elsewhere the base terrain
176
+ * answers, and with no base the gap is `ISLAND_VOID_HEIGHT` void. Later islands win overlaps.
177
+ */
178
+ export function composeIslandFields(base, islands, voidHeight = ISLAND_VOID_HEIGHT) {
179
+ if (islands.length === 0)
180
+ return base ?? flatField();
181
+ const resolved = islands.map((descriptor) => ({
182
+ descriptor,
183
+ field: resolveTerrainField({ ...descriptor, kind: "terrain" }),
184
+ }));
185
+ function islandAt(x, z) {
186
+ for (let index = resolved.length - 1; index >= 0; index -= 1) {
187
+ if (islandContains(resolved[index].descriptor, x, z))
188
+ return resolved[index];
189
+ }
190
+ return null;
191
+ }
192
+ const sampleHeight = (x, z) => {
193
+ const island = islandAt(x, z);
194
+ if (island !== null) {
195
+ return island.field.sampleHeight(x - island.descriptor.origin[0], z - island.descriptor.origin[1]);
196
+ }
197
+ return base !== null ? base.sampleHeight(x, z) : voidHeight;
198
+ };
199
+ return {
200
+ sampleHeight,
201
+ sampleNormal(x, z) {
202
+ const island = islandAt(x, z);
203
+ if (island !== null) {
204
+ return island.field.sampleNormal(x - island.descriptor.origin[0], z - island.descriptor.origin[1]);
205
+ }
206
+ return base !== null ? base.sampleNormal(x, z) : [0, 1, 0];
207
+ },
208
+ ...(base?.bounds === undefined ? {} : { bounds: base.bounds }),
209
+ ...(base?.waterLevel === undefined ? {} : { waterLevel: base.waterLevel }),
210
+ };
211
+ }
212
+ /** The full ground field for an environment world: base `terrain` composed with any `islands`. */
213
+ export function resolveEnvironmentField(feature) {
214
+ const base = feature.terrain === undefined ? null : resolveTerrainField(feature.terrain);
215
+ if (feature.islands === undefined || feature.islands.length === 0)
216
+ return base ?? flatField();
217
+ return composeIslandFields(base, feature.islands);
218
+ }
219
+ /** Local slope from the field's normal — the input for gravity-roll, ski acceleration, and slide checks (#284.6). */
220
+ export function sampleSlope(field, x, z) {
221
+ const [nx, ny, nz] = field.sampleNormal(x, z);
222
+ const horizontal = Math.hypot(nx, nz);
223
+ if (horizontal < 1e-9)
224
+ return { downhill: [0, 0], steepness: 0 };
225
+ return {
226
+ downhill: [nx / horizontal, nz / horizontal],
227
+ steepness: horizontal / Math.max(ny, 1e-9),
228
+ };
229
+ }
230
+ /**
231
+ * Downhill force/acceleration from the local slope: direction × sin(slope angle) × `scale`.
232
+ * Add it to any integrator's XZ velocity each tick (`scale` ≈ gravity for a free-rolling body).
233
+ */
234
+ export function slopeForce(field, x, z, scale = 9.8) {
235
+ const [nx, , nz] = field.sampleNormal(x, z);
236
+ if (Math.hypot(nx, nz) < 1e-9)
237
+ return [0, 0];
238
+ return [nx * scale, nz * scale];
239
+ }
156
240
  export const DEFAULT_TERRAIN_MATERIAL = "grass";
157
241
  export const TERRAIN_MATERIAL_PALETTES = {
158
242
  grass: { low: "#30402c", high: "#7f8b50", waterline: "#1d4c6e" },
@@ -160,9 +244,41 @@ export const TERRAIN_MATERIAL_PALETTES = {
160
244
  snow: { low: "#c7d3dc", high: "#ffffff", waterline: "#3a6ea5" },
161
245
  rock: { low: "#3a3a3d", high: "#8a8a8d", waterline: "#1d4c6e" },
162
246
  ash: { low: "#2b2622", high: "#5c534a", waterline: "#3a3630" },
247
+ highland: { low: "#414f33", high: "#a3a86b", waterline: "#365f63" },
248
+ slate: { low: "#2f3540", high: "#7d8896", waterline: "#26404f" },
249
+ };
250
+ const DEFAULT_TERRAIN_DETAIL = {
251
+ rockColor: "#6f7175",
252
+ sandColor: "#c2b283",
253
+ snowColor: "#eef3f7",
254
+ rockSlopeStart: 0.42,
255
+ snowHeight: 26,
256
+ detailScale: 5,
257
+ macroScale: 45,
258
+ roughness: 0.9,
259
+ strength: 1,
163
260
  };
261
+ /** Fill a `TerrainDetailConfig` with defaults; `waterLevel` falls back to the terrain's own water level. */
262
+ export function resolveTerrainDetail(config, terrainWaterLevel = 0) {
263
+ return {
264
+ rockColor: config.rockColor ?? DEFAULT_TERRAIN_DETAIL.rockColor,
265
+ sandColor: config.sandColor ?? DEFAULT_TERRAIN_DETAIL.sandColor,
266
+ snowColor: config.snowColor ?? DEFAULT_TERRAIN_DETAIL.snowColor,
267
+ rockSlopeStart: config.rockSlopeStart ?? DEFAULT_TERRAIN_DETAIL.rockSlopeStart,
268
+ snowHeight: config.snowHeight ?? DEFAULT_TERRAIN_DETAIL.snowHeight,
269
+ detailScale: config.detailScale ?? DEFAULT_TERRAIN_DETAIL.detailScale,
270
+ macroScale: config.macroScale ?? DEFAULT_TERRAIN_DETAIL.macroScale,
271
+ roughness: config.roughness ?? DEFAULT_TERRAIN_DETAIL.roughness,
272
+ strength: config.strength ?? DEFAULT_TERRAIN_DETAIL.strength,
273
+ waterLevel: config.waterLevel ?? terrainWaterLevel,
274
+ };
275
+ }
164
276
  export function resolveTerrainPalette(descriptor = {}) {
165
- const preset = TERRAIN_MATERIAL_PALETTES[descriptor.material ?? DEFAULT_TERRAIN_MATERIAL] ?? TERRAIN_MATERIAL_PALETTES[DEFAULT_TERRAIN_MATERIAL];
277
+ const material = descriptor.material ?? DEFAULT_TERRAIN_MATERIAL;
278
+ const preset = TERRAIN_MATERIAL_PALETTES[material];
279
+ if (preset === undefined) {
280
+ throw new Error(`Unknown terrain material "${material}". Valid materials: ${Object.keys(TERRAIN_MATERIAL_PALETTES).join(", ")}. Use colors: { low, high, waterline } for a custom look.`);
281
+ }
166
282
  const colors = descriptor.colors ?? {};
167
283
  return {
168
284
  low: colors.low ?? preset.low,
@@ -170,6 +286,137 @@ export function resolveTerrainPalette(descriptor = {}) {
170
286
  waterline: colors.waterline ?? preset.waterline,
171
287
  };
172
288
  }
289
+ function hexToRgb(hex) {
290
+ const value = Number.parseInt(hex.slice(1), 16);
291
+ return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff];
292
+ }
293
+ function rgbToHex(r, g, b) {
294
+ const value = ((Math.round(r) & 0xff) << 16) | ((Math.round(g) & 0xff) << 8) | (Math.round(b) & 0xff);
295
+ return `#${value.toString(16).padStart(6, "0")}`;
296
+ }
297
+ function mixHex(from, to, t) {
298
+ if (t <= 0)
299
+ return from;
300
+ if (t >= 1)
301
+ return to;
302
+ const [fr, fg, fb] = hexToRgb(from);
303
+ const [tr, tg, tb] = hexToRgb(to);
304
+ return rgbToHex(lerp(fr, tr, t), lerp(fg, tg, t), lerp(fb, tb, t));
305
+ }
306
+ function mixPalette(from, to, t) {
307
+ if (t <= 0)
308
+ return from;
309
+ if (t >= 1)
310
+ return to;
311
+ return {
312
+ low: mixHex(from.low, to.low, t),
313
+ high: mixHex(from.high, to.high, t),
314
+ waterline: mixHex(from.waterline, to.waterline, t),
315
+ };
316
+ }
317
+ /**
318
+ * Per-z palette sampler over a descriptor's `biomeBands` — ordered zones that cross-fade their
319
+ * ground palette across a `fade`-wide window centered on the midpoint z between adjacent centers.
320
+ * Below the first / above the last band clamps to that band's palette. Returns `fallback` when no
321
+ * bands are declared. Pure math, unit-testable independent of rendering.
322
+ */
323
+ export function createBiomeBandSampler(bands, fallback) {
324
+ if (bands.length === 0)
325
+ return () => fallback;
326
+ const resolved = bands
327
+ .map((band) => ({ z: band.z, fade: band.fade ?? 64, palette: resolveTerrainPalette(band) }))
328
+ .sort((a, b) => a.z - b.z);
329
+ const first = resolved[0];
330
+ const last = resolved[resolved.length - 1];
331
+ return (z) => {
332
+ if (z <= first.z)
333
+ return first.palette;
334
+ if (z >= last.z)
335
+ return last.palette;
336
+ for (let i = 0; i < resolved.length - 1; i += 1) {
337
+ const lower = resolved[i];
338
+ const upper = resolved[i + 1];
339
+ if (z < lower.z || z > upper.z)
340
+ continue;
341
+ const boundary = (lower.z + upper.z) / 2;
342
+ const half = Math.min(lower.fade, upper.fade) / 2;
343
+ if (z <= boundary - half)
344
+ return lower.palette;
345
+ if (z >= boundary + half)
346
+ return upper.palette;
347
+ return mixPalette(lower.palette, upper.palette, smoothstep(boundary - half, boundary + half, z));
348
+ }
349
+ return fallback;
350
+ };
351
+ }
352
+ /**
353
+ * Per-position palette sampler over the descriptor's base `material`/`colors` plus its z-ordered
354
+ * `biomeBands` (painted first) and radial `materialRegions` (painted over) — the multi-biome coloring
355
+ * seam. Regions paint fully inside `radius` and blend back across `falloff`; later regions win overlaps.
356
+ */
357
+ export function createTerrainPaletteSampler(descriptor) {
358
+ const base = resolveTerrainPalette(descriptor);
359
+ const bandSampler = descriptor.biomeBands === undefined || descriptor.biomeBands.length === 0
360
+ ? null
361
+ : createBiomeBandSampler(descriptor.biomeBands, base);
362
+ const regions = (descriptor.materialRegions ?? []).map((region) => ({
363
+ ...resolveRegionShape(region),
364
+ palette: resolveTerrainPalette(region),
365
+ }));
366
+ if (regions.length === 0 && bandSampler === null)
367
+ return () => base;
368
+ return (x, z) => {
369
+ let palette = bandSampler === null ? base : bandSampler(z);
370
+ for (const region of regions) {
371
+ const distance = region.coreDistance(x, z);
372
+ if (distance <= 0) {
373
+ palette = region.palette;
374
+ }
375
+ else if (distance <= region.falloff) {
376
+ const t = smoothstep(0, region.falloff, distance);
377
+ palette = mixPalette(region.palette, palette, t);
378
+ }
379
+ }
380
+ return palette;
381
+ };
382
+ }
383
+ /**
384
+ * Resolves a `TerrainMaterialRegion` of any shape to a `coreDistance(x, z)` — 0 inside the painted
385
+ * core, growing positive outward — plus its blend-ring `falloff`, so the palette sampler blends every
386
+ * shape through one shape-agnostic loop.
387
+ */
388
+ function resolveRegionShape(region) {
389
+ if (region.shape === "polyline") {
390
+ const half = region.width * 0.5;
391
+ return {
392
+ falloff: region.falloff ?? half,
393
+ coreDistance: (x, z) => {
394
+ const sample = nearestOnPath(region.points, x, z);
395
+ return sample === null ? Number.POSITIVE_INFINITY : Math.max(0, sample.distance - half);
396
+ },
397
+ };
398
+ }
399
+ if (region.shape === "rect") {
400
+ const [hx, hz] = region.halfExtents;
401
+ const angle = region.rotationY ?? 0;
402
+ const cos = Math.cos(angle);
403
+ const sin = Math.sin(angle);
404
+ return {
405
+ falloff: region.falloff ?? Math.min(hx, hz) * 0.5,
406
+ coreDistance: (x, z) => {
407
+ const dx = x - region.center[0];
408
+ const dz = z - region.center[1];
409
+ const lx = dx * cos + dz * sin;
410
+ const lz = -dx * sin + dz * cos;
411
+ return Math.hypot(Math.max(0, Math.abs(lx) - hx), Math.max(0, Math.abs(lz) - hz));
412
+ },
413
+ };
414
+ }
415
+ return {
416
+ falloff: region.falloff ?? region.radius * 0.5,
417
+ coreDistance: (x, z) => Math.max(0, Math.hypot(x - region.center[0], z - region.center[1]) - region.radius),
418
+ };
419
+ }
173
420
  function withFlattenMasks(sampleHeight, masks) {
174
421
  const resolved = masks.map((mask) => ({
175
422
  center: mask.center,
@@ -193,24 +440,69 @@ function withFlattenMasks(sampleHeight, masks) {
193
440
  return height;
194
441
  };
195
442
  }
443
+ export function heightMapField(config) {
444
+ const { columns, rows, samples } = config;
445
+ if (columns <= 0 || rows <= 0)
446
+ throw new Error("heightMapField: columns/rows must be positive");
447
+ if (samples.length < columns * rows) {
448
+ throw new Error(`heightMapField: samples length ${samples.length} < columns*rows ${columns * rows}`);
449
+ }
450
+ const bounds = config.bounds ?? { w: columns, d: rows };
451
+ const heightScale = config.heightScale ?? 1;
452
+ const baseHeight = config.baseHeight ?? 0;
453
+ const halfW = bounds.w / 2;
454
+ const halfD = bounds.d / 2;
455
+ const sampleAt = (col, row) => {
456
+ const c = Math.max(0, Math.min(columns - 1, col));
457
+ const r = Math.max(0, Math.min(rows - 1, row));
458
+ return samples[r * columns + c];
459
+ };
460
+ return fieldFromHeight((x, z) => {
461
+ const u = columns <= 1 ? 0 : ((x + halfW) / bounds.w) * (columns - 1);
462
+ const v = rows <= 1 ? 0 : ((z + halfD) / bounds.d) * (rows - 1);
463
+ const c0 = Math.floor(u);
464
+ const r0 = Math.floor(v);
465
+ const fu = u - c0;
466
+ const fv = v - r0;
467
+ const h00 = sampleAt(c0, r0);
468
+ const h10 = sampleAt(c0 + 1, r0);
469
+ const h01 = sampleAt(c0, r0 + 1);
470
+ const h11 = sampleAt(c0 + 1, r0 + 1);
471
+ const top = lerp(h00, h10, fu);
472
+ const bottom = lerp(h01, h11, fu);
473
+ return baseHeight + heightScale * lerp(top, bottom, fv);
474
+ }, {
475
+ bounds,
476
+ ...(config.waterLevel === undefined ? {} : { waterLevel: config.waterLevel }),
477
+ });
478
+ }
479
+ /** Resolves a `TerrainEnvironmentDescriptor` into a concrete `TerrainField`, applying flatten masks. */
196
480
  export function resolveTerrainField(descriptor) {
197
481
  if (descriptor === undefined)
198
482
  return flatField();
199
- const noise = noiseField({
200
- seed: descriptor.seed,
201
- amplitude: descriptor.height,
202
- frequency: descriptor.frequency,
203
- octaves: descriptor.octaves,
204
- ridged: descriptor.ridged,
205
- baseHeight: descriptor.baseHeight,
206
- waterLevel: descriptor.waterLevel,
207
- bounds: descriptor.bounds,
208
- });
483
+ if (descriptor.heightField === undefined && descriptor.heightMap !== undefined) {
484
+ throw new Error(`terrain heightMap "${descriptor.heightMap}" is not auto-loaded. Decode elevation samples into heightMapField({ columns, rows, samples }) and pass heightField: field.sampleHeight (or omit heightMap and set heightField directly).`);
485
+ }
486
+ const base = descriptor.heightField !== undefined
487
+ ? fieldFromHeight(descriptor.heightField, {
488
+ bounds: descriptor.bounds,
489
+ ...(descriptor.waterLevel === undefined ? {} : { waterLevel: descriptor.waterLevel }),
490
+ })
491
+ : noiseField({
492
+ seed: descriptor.seed,
493
+ amplitude: descriptor.height,
494
+ frequency: descriptor.frequency,
495
+ octaves: descriptor.octaves,
496
+ ridged: descriptor.ridged,
497
+ baseHeight: descriptor.baseHeight,
498
+ waterLevel: descriptor.waterLevel,
499
+ bounds: descriptor.bounds,
500
+ });
209
501
  if (descriptor.flatten === undefined || descriptor.flatten.length === 0)
210
- return noise;
211
- return fieldFromHeight(withFlattenMasks(noise.sampleHeight, descriptor.flatten), {
212
- bounds: noise.bounds,
213
- waterLevel: noise.waterLevel,
502
+ return base;
503
+ return fieldFromHeight(withFlattenMasks(base.sampleHeight, descriptor.flatten), {
504
+ bounds: base.bounds,
505
+ waterLevel: base.waterLevel,
214
506
  });
215
507
  }
216
508
  /** Returns `position` with `y` replaced by the field's ground height (plus `offset`) at its `x`/`z`. */
@@ -226,6 +518,7 @@ export function snapEntityToGround(entities, id, field, offset = 0) {
226
518
  return entities.setPose(id, { position: snapToGround(field, entity.position, offset) });
227
519
  }
228
520
  export const DEFAULT_MAX_WALK_SLOPE = 0.6;
521
+ /** Zeroes out a movement step's x or z component where it would climb steeper than `maxSlope`. */
229
522
  export function resolveGroundStep(field, x, z, stepX, stepZ, maxSlope = DEFAULT_MAX_WALK_SLOPE) {
230
523
  const baseHeight = field.sampleHeight(x, z);
231
524
  const tooSteep = (dx, dz) => {
@@ -9,6 +9,8 @@ export interface GerstnerWave {
9
9
  }
10
10
  export interface WaterSurfaceConfig {
11
11
  level?: number;
12
+ /** Scheduled base level as a function of time — tides, floods (#284.1). Wins over `level`/`setLevel` whenever set. */
13
+ levelAt?: (time: number) => number;
12
14
  waveHeight?: number;
13
15
  waveScale?: number;
14
16
  waveSpeed?: number;
@@ -16,11 +18,16 @@ export interface WaterSurfaceConfig {
16
18
  choppiness?: number;
17
19
  }
18
20
  export interface WaterSurface {
21
+ /** The current base level — the initial config value until `setLevel` replaces it. With a `levelAt` schedule, use `levelAtTime` instead. */
19
22
  readonly level: number;
20
23
  readonly waves: readonly GerstnerWave[];
21
24
  height(x: number, z: number, time: number): number;
22
25
  normal(x: number, z: number, time: number): WaterNormal;
23
26
  displace(x: number, z: number, time: number): readonly [number, number, number];
27
+ /** Raise or drain the water at runtime (#284.1) — every `height`/`displace` consumer (buoyancy included) sees it immediately. Ignored while a `levelAt` schedule is set. */
28
+ setLevel(level: number): void;
29
+ /** The effective base level at `time`: the `levelAt` schedule when set, else the current `level`. */
30
+ levelAtTime(time: number): number;
24
31
  }
25
32
  export declare function synthesizeWaves(config?: WaterSurfaceConfig): GerstnerWave[];
26
33
  export declare function waterSurface(config?: WaterSurfaceConfig): WaterSurface;
@@ -41,11 +41,13 @@ export function synthesizeWaves(config = {}) {
41
41
  }
42
42
  export function waterSurface(config = {}) {
43
43
  const resolved = resolveConfig(config);
44
- const level = resolved.level;
44
+ let currentLevel = resolved.level;
45
+ const levelAt = config.levelAt;
46
+ const levelAtTime = (time) => (levelAt !== undefined ? levelAt(time) : currentLevel);
45
47
  const choppiness = resolved.choppiness;
46
48
  const waves = synthesizeWaves(config);
47
49
  const height = (x, z, time) => {
48
- let y = level;
50
+ let y = levelAtTime(time);
49
51
  for (const wave of waves) {
50
52
  const k = TWO_PI / wave.wavelength;
51
53
  const omega = wave.speed * k;
@@ -57,7 +59,7 @@ export function waterSurface(config = {}) {
57
59
  const displace = (x, z, time) => {
58
60
  let dx = x;
59
61
  let dz = z;
60
- let dy = level;
62
+ let dy = levelAtTime(time);
61
63
  for (const wave of waves) {
62
64
  const k = TWO_PI / wave.wavelength;
63
65
  const omega = wave.speed * k;
@@ -112,11 +114,24 @@ export function waterSurface(config = {}) {
112
114
  }
113
115
  return [nx, ny, nz];
114
116
  };
115
- return { level, waves, height, normal, displace };
117
+ return {
118
+ get level() {
119
+ return currentLevel;
120
+ },
121
+ waves,
122
+ height,
123
+ normal,
124
+ displace,
125
+ setLevel(nextLevel) {
126
+ currentLevel = nextLevel;
127
+ },
128
+ levelAtTime,
129
+ };
116
130
  }
117
131
  export function waterSurfaceFromDescriptor(descriptor, waves) {
118
132
  return waterSurface({
119
133
  level: descriptor.level,
134
+ ...(descriptor.levelAt === undefined ? {} : { levelAt: descriptor.levelAt }),
120
135
  waveHeight: descriptor.waveHeight,
121
136
  waveScale: descriptor.waveScale,
122
137
  waveSpeed: descriptor.waveSpeed,
@@ -1,28 +1,20 @@
1
1
  import type { WindVector } from "./wind.js";
2
2
  export type WeatherKind = string;
3
3
  export interface WeatherState {
4
- /** Game-defined kind: "clear", "rain", "storm", "snow", "fog", "sandstorm". */
5
4
  kind: WeatherKind;
6
- /** 0..1 severity — scales every modifier. */
7
5
  intensity: number;
8
6
  wind?: WindVector;
9
7
  }
10
- /** Per-kind gameplay effect values at full intensity. Intensity interpolates from neutral. */
11
8
  export interface WeatherModifier {
12
- /** Movement traction multiplier at full intensity — <1 = mud/ice slows, 1 = normal. */
13
9
  grip?: number;
14
- /** View distance multiplier at full intensity — 0..1, 1 = clear. */
15
10
  visibility?: number;
16
- /** Structure HP lost per game-second at full intensity (hail, storm). */
17
11
  structureDamage?: number;
18
- /** Temperature delta at full intensity (a blizzard chills). */
19
12
  chill?: number;
20
- /** Lightning ignition chance per game-second at full intensity. */
21
13
  ignition?: number;
22
- /** Fire spread-rate multiplier at full intensity — rain suppresses (<1). Default neutral 1. */
23
14
  spread?: number;
24
15
  }
25
- export type WeatherModifierTable = Record<WeatherKind, WeatherModifier>;
16
+ export type WeatherModifierTable<K extends string = string> = Record<K, WeatherModifier>;
17
+ export type WeatherKindOf<TTable extends WeatherModifierTable> = Extract<keyof TTable, string>;
26
18
  export interface ResolvedWeather {
27
19
  grip: number;
28
20
  visibility: number;
@@ -31,14 +23,7 @@ export interface ResolvedWeather {
31
23
  ignition: number;
32
24
  spread: number;
33
25
  }
34
- /**
35
- * Resolve a weather state against a game-owned modifier table into concrete gameplay
36
- * numbers. Multiplier-style effects (grip, visibility, spread) interpolate from the
37
- * neutral 1 by intensity; rate-style effects (structureDamage, ignition, chill) scale
38
- * linearly. Games read `grip`/`visibility` in movement and AI, `structureDamage` on a
39
- * building tick, and `spread`/`ignition` feed the fire grid.
40
- */
41
- export declare function resolveWeather(state: WeatherState, table: WeatherModifierTable): ResolvedWeather;
26
+ export declare function resolveWeather<TTable extends WeatherModifierTable>(state: WeatherState, table: TTable): ResolvedWeather;
42
27
  export type FireCellState = "unburnt" | "burning" | "burnt";
43
28
  export interface FireCell {
44
29
  fuel: number;
@@ -1,25 +1,12 @@
1
- const NEUTRAL = {
2
- grip: 1,
3
- visibility: 1,
4
- structureDamage: 0,
5
- chill: 0,
6
- ignition: 0,
7
- spread: 1,
8
- };
9
1
  function lerp(from, to, t) {
10
2
  return from + (to - from) * t;
11
3
  }
12
- /**
13
- * Resolve a weather state against a game-owned modifier table into concrete gameplay
14
- * numbers. Multiplier-style effects (grip, visibility, spread) interpolate from the
15
- * neutral 1 by intensity; rate-style effects (structureDamage, ignition, chill) scale
16
- * linearly. Games read `grip`/`visibility` in movement and AI, `structureDamage` on a
17
- * building tick, and `spread`/`ignition` feed the fire grid.
18
- */
19
4
  export function resolveWeather(state, table) {
20
5
  const entry = table[state.kind];
21
- if (entry === undefined)
22
- return { ...NEUTRAL };
6
+ if (entry === undefined) {
7
+ const known = Object.keys(table).sort().join(", ");
8
+ throw new Error(`Unknown weather kind "${state.kind}". Known kinds: ${known.length > 0 ? known : "(none)"}.`);
9
+ }
23
10
  const t = Math.max(0, Math.min(1, state.intensity));
24
11
  return {
25
12
  grip: lerp(1, entry.grip ?? 1, t),
@@ -0,0 +1,47 @@
1
+ import { type SchedulePhase } from "../time/stateSchedule.js";
2
+ import type { WindField, WindVector } from "./wind.js";
3
+ export interface WindZoneState {
4
+ direction: WindVector;
5
+ speed: number;
6
+ /** Announcement label for HUD countdowns ("gale", "calm", "tailwind"). */
7
+ label?: string;
8
+ }
9
+ export interface WindZoneConfig {
10
+ id: string;
11
+ center: readonly [number, number];
12
+ radius: number;
13
+ /** The zone's scheduled wind states; a single phase makes a static zone. */
14
+ phases: readonly SchedulePhase<WindZoneState>[];
15
+ /** Staggers this zone's schedule against identical ones. */
16
+ offsetSeconds?: number;
17
+ }
18
+ export interface WindZonesConfig {
19
+ zones: readonly WindZoneConfig[];
20
+ /** Wind outside every zone; omit for dead air. */
21
+ ambient?: WindField;
22
+ }
23
+ export interface WindShiftForecast {
24
+ zoneId: string;
25
+ /** Absolute time the zone's next state begins. */
26
+ at: number;
27
+ /** Seconds from the queried time until the shift. */
28
+ inSeconds: number;
29
+ next: WindZoneState;
30
+ }
31
+ /**
32
+ * Named, discrete wind zones over an ambient field — each zone runs a deterministic
33
+ * `StateSchedule` of wind states, so games get scheduled shifts with advance announcement
34
+ * (`forecastShift` drives the "gale in 12s" countdown) instead of one continuous field.
35
+ */
36
+ export interface WindZones {
37
+ zoneIds(): readonly string[];
38
+ /** Innermost zone containing the point (later declarations win ties); `null` in open air. */
39
+ zoneAt(x: number, z: number): string | null;
40
+ /** The wind vector at a point and time: the containing zone's scheduled state, else ambient. */
41
+ windAt(x: number, z: number, t: number): WindVector;
42
+ /** A zone's live state; `null` for an unknown zone. */
43
+ stateAt(zoneId: string, t: number): WindZoneState | null;
44
+ /** The zone's next scheduled shift after `t`; `null` for unknown or single-phase zones. */
45
+ forecastShift(zoneId: string, t: number): WindShiftForecast | null;
46
+ }
47
+ export declare function createWindZones(config: WindZonesConfig): WindZones;
@@ -0,0 +1,59 @@
1
+ import { createStateSchedule } from "../time/stateSchedule.js";
2
+ function windVectorOf(state) {
3
+ const length = Math.hypot(state.direction[0], state.direction[1]);
4
+ if (length === 0 || state.speed === 0)
5
+ return [0, 0];
6
+ return [(state.direction[0] / length) * state.speed, (state.direction[1] / length) * state.speed];
7
+ }
8
+ export function createWindZones(config) {
9
+ const zones = new Map();
10
+ const order = [];
11
+ for (const zone of config.zones) {
12
+ if (zones.has(zone.id))
13
+ throw new Error(`duplicate wind zone: ${zone.id}`);
14
+ zones.set(zone.id, {
15
+ config: zone,
16
+ schedule: createStateSchedule({
17
+ phases: zone.phases,
18
+ ...(zone.offsetSeconds === undefined ? {} : { offsetSeconds: zone.offsetSeconds }),
19
+ }),
20
+ });
21
+ order.push(zone.id);
22
+ }
23
+ function zoneAt(x, z) {
24
+ let best = null;
25
+ let bestRadius = Number.POSITIVE_INFINITY;
26
+ for (const id of order) {
27
+ const zone = zones.get(id);
28
+ const distance = Math.hypot(x - zone.config.center[0], z - zone.config.center[1]);
29
+ if (distance <= zone.config.radius && zone.config.radius <= bestRadius) {
30
+ best = id;
31
+ bestRadius = zone.config.radius;
32
+ }
33
+ }
34
+ return best;
35
+ }
36
+ return {
37
+ zoneIds: () => order.slice(),
38
+ zoneAt,
39
+ windAt(x, z, t) {
40
+ const id = zoneAt(x, z);
41
+ if (id !== null)
42
+ return windVectorOf(zones.get(id).schedule.stateAt(t));
43
+ return config.ambient === undefined ? [0, 0] : config.ambient.atPoint(x, z, t);
44
+ },
45
+ stateAt(zoneId, t) {
46
+ const zone = zones.get(zoneId);
47
+ return zone === undefined ? null : zone.schedule.stateAt(t);
48
+ },
49
+ forecastShift(zoneId, t) {
50
+ const zone = zones.get(zoneId);
51
+ if (zone === undefined || zone.config.phases.length < 2)
52
+ return null;
53
+ const at = zone.schedule.nextTransitionAt(t);
54
+ if (!Number.isFinite(at))
55
+ return null;
56
+ return { zoneId, at, inSeconds: at - t, next: zone.schedule.stateAt(at) };
57
+ },
58
+ };
59
+ }