@jgengine/core 0.6.0 → 0.8.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 (330) hide show
  1. package/CHANGELOG.md +214 -2
  2. package/README.md +2 -0
  3. package/dist/ai/crowd.d.ts +36 -0
  4. package/dist/ai/crowd.js +213 -0
  5. package/dist/ai/jobBoard.d.ts +49 -0
  6. package/dist/ai/jobBoard.js +155 -0
  7. package/dist/ai/spawnDirector.d.ts +61 -0
  8. package/dist/ai/spawnDirector.js +162 -0
  9. package/dist/ai/threat.d.ts +25 -0
  10. package/dist/ai/threat.js +67 -0
  11. package/dist/anim/easing.d.ts +14 -0
  12. package/dist/anim/easing.js +55 -0
  13. package/dist/audio/audioFalloff.d.ts +38 -0
  14. package/dist/audio/audioFalloff.js +32 -0
  15. package/dist/behaviour/behaviour.d.ts +136 -0
  16. package/dist/behaviour/behaviour.js +388 -0
  17. package/dist/board/laneBoard.d.ts +63 -0
  18. package/dist/board/laneBoard.js +114 -0
  19. package/dist/board/timelineBoard.d.ts +36 -0
  20. package/dist/board/timelineBoard.js +70 -0
  21. package/dist/cards/cardPile.d.ts +64 -0
  22. package/dist/cards/cardPile.js +175 -0
  23. package/dist/cards/modifierPipeline.d.ts +33 -0
  24. package/dist/cards/modifierPipeline.js +48 -0
  25. package/dist/combat/abilityKit.d.ts +47 -0
  26. package/dist/combat/abilityKit.js +139 -0
  27. package/dist/combat/animationState.d.ts +47 -0
  28. package/dist/combat/animationState.js +101 -0
  29. package/dist/combat/attackTags.d.ts +14 -0
  30. package/dist/combat/attackTags.js +26 -0
  31. package/dist/combat/breakMeters.d.ts +32 -0
  32. package/dist/combat/breakMeters.js +51 -0
  33. package/dist/combat/comboString.d.ts +35 -0
  34. package/dist/combat/comboString.js +59 -0
  35. package/dist/combat/death.d.ts +8 -0
  36. package/dist/combat/death.js +5 -2
  37. package/dist/combat/defensiveWindow.d.ts +34 -0
  38. package/dist/combat/defensiveWindow.js +66 -0
  39. package/dist/combat/downed.d.ts +35 -0
  40. package/dist/combat/downed.js +104 -0
  41. package/dist/combat/effects.d.ts +12 -1
  42. package/dist/combat/effects.js +31 -19
  43. package/dist/combat/hitReaction.d.ts +23 -0
  44. package/dist/combat/hitReaction.js +20 -0
  45. package/dist/combat/projectiles.d.ts +42 -5
  46. package/dist/combat/projectiles.js +98 -12
  47. package/dist/combat/resistance.d.ts +13 -0
  48. package/dist/combat/resistance.js +46 -0
  49. package/dist/combat/telegraph.d.ts +31 -0
  50. package/dist/combat/telegraph.js +51 -0
  51. package/dist/commands/commandRegistry.d.ts +1 -1
  52. package/dist/commands/commandRegistry.js +2 -1
  53. package/dist/crafting/crop.d.ts +59 -0
  54. package/dist/crafting/crop.js +181 -0
  55. package/dist/crafting/production.d.ts +64 -0
  56. package/dist/crafting/production.js +130 -0
  57. package/dist/crafting/recipe.d.ts +62 -0
  58. package/dist/crafting/recipe.js +101 -0
  59. package/dist/data/dataSource.d.ts +26 -0
  60. package/dist/data/dataSource.js +93 -0
  61. package/dist/data/devProxy.d.ts +11 -0
  62. package/dist/data/devProxy.js +38 -0
  63. package/dist/data/fetchJson.d.ts +20 -0
  64. package/dist/data/fetchJson.js +43 -0
  65. package/dist/data/jsonDataSource.d.ts +4 -0
  66. package/dist/data/jsonDataSource.js +6 -0
  67. package/dist/devtools/devtools.d.ts +157 -0
  68. package/dist/devtools/devtools.js +484 -0
  69. package/dist/devtools/transformTunables.d.ts +14 -0
  70. package/dist/devtools/transformTunables.js +38 -0
  71. package/dist/economy/sharedWallet.d.ts +28 -0
  72. package/dist/economy/sharedWallet.js +62 -0
  73. package/dist/economy/techTree.d.ts +40 -0
  74. package/dist/economy/techTree.js +66 -0
  75. package/dist/game/chat.d.ts +66 -0
  76. package/dist/game/chat.js +165 -0
  77. package/dist/game/cosmetics.d.ts +25 -0
  78. package/dist/game/cosmetics.js +51 -0
  79. package/dist/game/defineGame.d.ts +10 -2
  80. package/dist/game/defineGame.js +2 -1
  81. package/dist/game/events.d.ts +87 -0
  82. package/dist/game/levelSequence.d.ts +44 -0
  83. package/dist/game/levelSequence.js +78 -0
  84. package/dist/game/lootFilter.d.ts +33 -0
  85. package/dist/game/lootFilter.js +48 -0
  86. package/dist/game/ping.d.ts +67 -0
  87. package/dist/game/ping.js +86 -0
  88. package/dist/game/playableGame.d.ts +516 -8
  89. package/dist/game/playableGame.js +6 -1
  90. package/dist/game/progression.d.ts +1 -0
  91. package/dist/game/progression.js +23 -2
  92. package/dist/game/race.d.ts +100 -0
  93. package/dist/game/race.js +236 -0
  94. package/dist/game/runDraft.d.ts +34 -0
  95. package/dist/game/runDraft.js +97 -0
  96. package/dist/game/social.d.ts +69 -0
  97. package/dist/game/social.js +138 -0
  98. package/dist/game/spawnPoints.d.ts +19 -0
  99. package/dist/game/spawnPoints.js +23 -0
  100. package/dist/game/worldItem.d.ts +95 -0
  101. package/dist/game/worldItem.js +138 -0
  102. package/dist/input/actionBindings.d.ts +10 -0
  103. package/dist/input/actionBindings.js +14 -0
  104. package/dist/input/axisInput.d.ts +43 -0
  105. package/dist/input/axisInput.js +70 -0
  106. package/dist/input/gestureSurface.d.ts +31 -0
  107. package/dist/input/gestureSurface.js +120 -0
  108. package/dist/input/pointer.d.ts +69 -0
  109. package/dist/input/pointer.js +65 -0
  110. package/dist/input/touchScheme.d.ts +83 -0
  111. package/dist/input/touchScheme.js +115 -0
  112. package/dist/interaction/contextMenu.d.ts +30 -0
  113. package/dist/interaction/contextMenu.js +22 -0
  114. package/dist/interaction/qte.d.ts +20 -0
  115. package/dist/interaction/qte.js +24 -0
  116. package/dist/interaction/skillCheck.d.ts +20 -0
  117. package/dist/interaction/skillCheck.js +26 -0
  118. package/dist/inventory/shapedGrid.d.ts +56 -0
  119. package/dist/inventory/shapedGrid.js +176 -0
  120. package/dist/inventory/storageTier.d.ts +52 -0
  121. package/dist/inventory/storageTier.js +77 -0
  122. package/dist/item/affix.d.ts +54 -0
  123. package/dist/item/affix.js +115 -0
  124. package/dist/item/durability.d.ts +51 -0
  125. package/dist/item/durability.js +78 -0
  126. package/dist/item/modularItem.d.ts +46 -0
  127. package/dist/item/modularItem.js +75 -0
  128. package/dist/meta/changelog.d.ts +1 -1
  129. package/dist/meta/changelog.js +71 -1
  130. package/dist/movement/dash.d.ts +37 -0
  131. package/dist/movement/dash.js +65 -0
  132. package/dist/movement/movementModel.d.ts +36 -3
  133. package/dist/movement/movementModel.js +81 -5
  134. package/dist/movement/voxelController.d.ts +61 -0
  135. package/dist/movement/voxelController.js +229 -0
  136. package/dist/multiplayer/chatContract.d.ts +42 -0
  137. package/dist/multiplayer/chatContract.js +57 -0
  138. package/dist/multiplayer/combatSnapshot.d.ts +45 -0
  139. package/dist/multiplayer/combatSnapshot.js +89 -0
  140. package/dist/multiplayer/identity.d.ts +13 -0
  141. package/dist/multiplayer/identity.js +20 -0
  142. package/dist/multiplayer/lagCompensation.d.ts +43 -0
  143. package/dist/multiplayer/lagCompensation.js +154 -0
  144. package/dist/multiplayer/matchmaking.d.ts +33 -0
  145. package/dist/multiplayer/matchmaking.js +60 -0
  146. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  147. package/dist/multiplayer/poseSyncGate.js +13 -1
  148. package/dist/multiplayer/presenceModel.d.ts +3 -0
  149. package/dist/multiplayer/presenceModel.js +15 -1
  150. package/dist/multiplayer/simultaneousCommit.d.ts +34 -0
  151. package/dist/multiplayer/simultaneousCommit.js +53 -0
  152. package/dist/multiplayer/voiceContract.d.ts +44 -0
  153. package/dist/multiplayer/voiceContract.js +108 -0
  154. package/dist/nav/navConstrain.d.ts +14 -0
  155. package/dist/nav/navConstrain.js +26 -0
  156. package/dist/nav/navFromEnvironment.d.ts +11 -0
  157. package/dist/nav/navFromEnvironment.js +16 -0
  158. package/dist/nav/navGrid.d.ts +56 -0
  159. package/dist/nav/navGrid.js +265 -0
  160. package/dist/nav/pathFollow.d.ts +31 -0
  161. package/dist/nav/pathFollow.js +69 -0
  162. package/dist/physics/ballisticSweep.d.ts +18 -0
  163. package/dist/physics/ballisticSweep.js +49 -0
  164. package/dist/physics/buoyancy.d.ts +49 -0
  165. package/dist/physics/buoyancy.js +112 -0
  166. package/dist/physics/carryable.d.ts +46 -0
  167. package/dist/physics/carryable.js +110 -0
  168. package/dist/physics/damageZones.d.ts +56 -0
  169. package/dist/physics/damageZones.js +107 -0
  170. package/dist/physics/forceVolume.d.ts +48 -0
  171. package/dist/physics/forceVolume.js +119 -0
  172. package/dist/physics/physicsWorld.d.ts +124 -2
  173. package/dist/physics/physicsWorld.js +493 -41
  174. package/dist/physics/ragdoll.d.ts +49 -0
  175. package/dist/physics/ragdoll.js +103 -0
  176. package/dist/physics/spatialGrid.d.ts +41 -0
  177. package/dist/physics/spatialGrid.js +143 -0
  178. package/dist/physics/structure.d.ts +83 -0
  179. package/dist/physics/structure.js +0 -0
  180. package/dist/physics/traversal.d.ts +75 -0
  181. package/dist/physics/traversal.js +138 -0
  182. package/dist/physics/vehicleBody.d.ts +99 -0
  183. package/dist/physics/vehicleBody.js +233 -0
  184. package/dist/puzzle/cellGrid.d.ts +26 -0
  185. package/dist/puzzle/cellGrid.js +110 -0
  186. package/dist/puzzle/fallingPiece.d.ts +24 -0
  187. package/dist/puzzle/fallingPiece.js +51 -0
  188. package/dist/random/nameGen.d.ts +18 -0
  189. package/dist/random/nameGen.js +35 -0
  190. package/dist/random/rng.d.ts +2 -0
  191. package/dist/random/rng.js +23 -0
  192. package/dist/runtime/adapter.d.ts +35 -0
  193. package/dist/runtime/adapter.js +37 -1
  194. package/dist/runtime/cameraDirector.d.ts +10 -0
  195. package/dist/runtime/cameraDirector.js +25 -0
  196. package/dist/runtime/gameContext.d.ts +107 -3
  197. package/dist/runtime/gameContext.js +253 -7
  198. package/dist/runtime/hostPersistence.d.ts +39 -0
  199. package/dist/runtime/hostPersistence.js +54 -0
  200. package/dist/runtime/inputSnapshot.d.ts +7 -0
  201. package/dist/runtime/inputSnapshot.js +12 -0
  202. package/dist/runtime/motionIntents.d.ts +25 -0
  203. package/dist/runtime/motionIntents.js +25 -0
  204. package/dist/runtime/persistenceScope.d.ts +32 -0
  205. package/dist/runtime/persistenceScope.js +64 -0
  206. package/dist/runtime/transport.d.ts +32 -0
  207. package/dist/scene/autoTarget.d.ts +15 -0
  208. package/dist/scene/autoTarget.js +54 -0
  209. package/dist/scene/behaviors.d.ts +13 -1
  210. package/dist/scene/behaviors.js +3 -0
  211. package/dist/scene/captureCheck.d.ts +7 -0
  212. package/dist/scene/captureCheck.js +11 -0
  213. package/dist/scene/entityStore.d.ts +9 -1
  214. package/dist/scene/entityStore.js +25 -2
  215. package/dist/scene/form.d.ts +40 -0
  216. package/dist/scene/form.js +56 -0
  217. package/dist/scene/mount.d.ts +62 -0
  218. package/dist/scene/mount.js +107 -0
  219. package/dist/scene/movementSpeed.d.ts +35 -0
  220. package/dist/scene/movementSpeed.js +20 -0
  221. package/dist/scene/objectQuery.d.ts +18 -0
  222. package/dist/scene/objectQuery.js +108 -0
  223. package/dist/scene/objectStore.d.ts +11 -0
  224. package/dist/scene/objectStore.js +109 -4
  225. package/dist/scene/paintLayer.d.ts +15 -0
  226. package/dist/scene/paintLayer.js +53 -0
  227. package/dist/scene/possession.d.ts +30 -0
  228. package/dist/scene/possession.js +51 -0
  229. package/dist/scene/roster.d.ts +25 -0
  230. package/dist/scene/roster.js +57 -0
  231. package/dist/scene/selection.d.ts +30 -0
  232. package/dist/scene/selection.js +38 -0
  233. package/dist/scene/spatial.d.ts +21 -0
  234. package/dist/scene/spatial.js +63 -1
  235. package/dist/scene/stationClaim.d.ts +43 -0
  236. package/dist/scene/stationClaim.js +109 -0
  237. package/dist/sensor/concealment.d.ts +21 -0
  238. package/dist/sensor/concealment.js +63 -0
  239. package/dist/sensor/freezeMonitor.d.ts +17 -0
  240. package/dist/sensor/freezeMonitor.js +35 -0
  241. package/dist/sensor/frustumSensor.d.ts +60 -0
  242. package/dist/sensor/frustumSensor.js +104 -0
  243. package/dist/sensor/hiddenStateProbe.d.ts +31 -0
  244. package/dist/sensor/hiddenStateProbe.js +40 -0
  245. package/dist/sensor/recordingBuffer.d.ts +29 -0
  246. package/dist/sensor/recordingBuffer.js +61 -0
  247. package/dist/sensor/revealQuery.d.ts +22 -0
  248. package/dist/sensor/revealQuery.js +36 -0
  249. package/dist/session/contestedChannel.d.ts +36 -0
  250. package/dist/session/contestedChannel.js +100 -0
  251. package/dist/session/extraction.d.ts +54 -0
  252. package/dist/session/extraction.js +108 -0
  253. package/dist/session/ring.d.ts +36 -0
  254. package/dist/session/ring.js +88 -0
  255. package/dist/session/roles.d.ts +6 -0
  256. package/dist/session/roles.js +32 -0
  257. package/dist/session/roundState.d.ts +79 -0
  258. package/dist/session/roundState.js +158 -0
  259. package/dist/stats/accumulatorMeter.d.ts +31 -0
  260. package/dist/stats/accumulatorMeter.js +81 -0
  261. package/dist/stats/eventMeter.d.ts +34 -0
  262. package/dist/stats/eventMeter.js +76 -0
  263. package/dist/stats/rollCheck.d.ts +15 -0
  264. package/dist/stats/rollCheck.js +15 -0
  265. package/dist/survival/decayMeter.d.ts +59 -0
  266. package/dist/survival/decayMeter.js +95 -0
  267. package/dist/survival/moodle.d.ts +48 -0
  268. package/dist/survival/moodle.js +91 -0
  269. package/dist/survival/regionHealth.d.ts +72 -0
  270. package/dist/survival/regionHealth.js +129 -0
  271. package/dist/tactics/fallingGrid.d.ts +62 -0
  272. package/dist/tactics/fallingGrid.js +158 -0
  273. package/dist/tactics/predictiveQuery.d.ts +34 -0
  274. package/dist/tactics/predictiveQuery.js +44 -0
  275. package/dist/tactics/snapshot.d.ts +18 -0
  276. package/dist/tactics/snapshot.js +65 -0
  277. package/dist/tactics/surface.d.ts +52 -0
  278. package/dist/tactics/surface.js +135 -0
  279. package/dist/tactics/tacticalGrid.d.ts +68 -0
  280. package/dist/tactics/tacticalGrid.js +261 -0
  281. package/dist/time/beatClock.d.ts +40 -0
  282. package/dist/time/beatClock.js +73 -0
  283. package/dist/time/simClock.d.ts +25 -1
  284. package/dist/time/simClock.js +28 -3
  285. package/dist/turn/commit.d.ts +27 -0
  286. package/dist/turn/commit.js +72 -0
  287. package/dist/turn/intent.d.ts +14 -0
  288. package/dist/turn/intent.js +29 -0
  289. package/dist/turn/turnLoop.d.ts +60 -0
  290. package/dist/turn/turnLoop.js +223 -0
  291. package/dist/world/buildPermissions.d.ts +47 -0
  292. package/dist/world/buildPermissions.js +134 -0
  293. package/dist/world/carve.d.ts +105 -0
  294. package/dist/world/carve.js +175 -0
  295. package/dist/world/connectors.d.ts +40 -0
  296. package/dist/world/connectors.js +67 -0
  297. package/dist/world/envField.d.ts +71 -0
  298. package/dist/world/envField.js +96 -0
  299. package/dist/world/environmentSummary.d.ts +53 -0
  300. package/dist/world/environmentSummary.js +129 -0
  301. package/dist/world/features.d.ts +87 -6
  302. package/dist/world/features.js +52 -2
  303. package/dist/world/fog.d.ts +40 -0
  304. package/dist/world/fog.js +137 -0
  305. package/dist/world/gridInstances.d.ts +13 -0
  306. package/dist/world/gridInstances.js +22 -0
  307. package/dist/world/markers.d.ts +52 -0
  308. package/dist/world/markers.js +96 -0
  309. package/dist/world/minimap.d.ts +42 -0
  310. package/dist/world/minimap.js +72 -0
  311. package/dist/world/placedStructureStore.d.ts +39 -0
  312. package/dist/world/placedStructureStore.js +95 -0
  313. package/dist/world/placementController.d.ts +69 -0
  314. package/dist/world/placementController.js +153 -0
  315. package/dist/world/realm.d.ts +61 -0
  316. package/dist/world/realm.js +66 -0
  317. package/dist/world/support.d.ts +27 -0
  318. package/dist/world/support.js +75 -0
  319. package/dist/world/terraform.d.ts +54 -0
  320. package/dist/world/terraform.js +186 -0
  321. package/dist/world/terrain.d.ts +23 -1
  322. package/dist/world/terrain.js +64 -1
  323. package/dist/world/voxelField.d.ts +54 -0
  324. package/dist/world/voxelField.js +241 -0
  325. package/dist/world/walls.d.ts +61 -0
  326. package/dist/world/walls.js +221 -0
  327. package/dist/world/weather.d.ts +92 -0
  328. package/dist/world/weather.js +169 -0
  329. package/llms.txt +4102 -0
  330. package/package.json +3 -2
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Voxel character controller (pure model).
3
+ *
4
+ * The base first-person controller in `movementModel.ts` assumes flat ground at
5
+ * y=0 — it integrates gravity into a `jumpOffset` and never asks the world how
6
+ * tall the ground is. That is fine for a plaza demo but wrong for any game where
7
+ * the player walks on placed blocks and digs down into the world: they float on
8
+ * an invisible floor, clip through walls, and can never descend.
9
+ *
10
+ * This module resolves the player as an axis-aligned box against a set of solid
11
+ * unit cells (a `SolidQuery`), so the same held-keys → intent pipeline drives a
12
+ * body that stands on blocks, falls into holes, is stopped by walls, and steps
13
+ * up single-block ledges. Keeping it renderer-free means the shell can drive it
14
+ * from live scene objects while the descent/landing/step-up behaviour stays unit
15
+ * testable without three.js.
16
+ *
17
+ * Cell convention matches the shell's object rendering and the voxel raycast:
18
+ * cell (x,y,z) is the solid box X∈[x-0.5,x+0.5], Y∈[y,y+1], Z∈[z-0.5,z+0.5]. A
19
+ * block at cell y therefore has its top face at world y+1, so a player standing
20
+ * on the surface layer (cell y=-1) rests with their feet at y=0.
21
+ */
22
+ import { MOVEMENT_TUNING } from "./movementModel.js";
23
+ export const DEFAULT_VOXEL_DIMS = {
24
+ halfWidth: 0.3,
25
+ height: 1.8,
26
+ stepHeight: 0.6,
27
+ };
28
+ export function createVoxelPlayerBody(x, y, z) {
29
+ return { x, y, z, velocityX: 0, velocityY: 0, velocityZ: 0, grounded: true, jumpHeld: false };
30
+ }
31
+ const SUPPORT_EPSILON = 0.02;
32
+ const PENETRATION_EPSILON = 1e-4;
33
+ function cellX(worldX) {
34
+ return Math.floor(worldX + 0.5);
35
+ }
36
+ function cellZ(worldZ) {
37
+ return Math.floor(worldZ + 0.5);
38
+ }
39
+ /** True if the player box centred at (x, y, z) overlaps any solid cell. */
40
+ function overlapsSolid(x, y, z, isSolid, dims) {
41
+ const minCellX = cellX(x - dims.halfWidth);
42
+ const maxCellX = cellX(x + dims.halfWidth);
43
+ const minCellZ = cellZ(z - dims.halfWidth);
44
+ const maxCellZ = cellZ(z + dims.halfWidth);
45
+ const minCellY = Math.floor(y + PENETRATION_EPSILON);
46
+ const maxCellY = Math.floor(y + dims.height - PENETRATION_EPSILON);
47
+ for (let cx = minCellX; cx <= maxCellX; cx += 1) {
48
+ for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
49
+ for (let cy = minCellY; cy <= maxCellY; cy += 1) {
50
+ if (isSolid(cx, cy, cz))
51
+ return true;
52
+ }
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+ /** True if a solid cell sits directly beneath the feet, or terrain supports them (i.e. the player is supported). */
58
+ function hasSupport(x, z, y, isSolid, dims, groundHeight) {
59
+ const cy = Math.floor(y - SUPPORT_EPSILON);
60
+ const minCellX = cellX(x - dims.halfWidth);
61
+ const maxCellX = cellX(x + dims.halfWidth);
62
+ const minCellZ = cellZ(z - dims.halfWidth);
63
+ const maxCellZ = cellZ(z + dims.halfWidth);
64
+ for (let cx = minCellX; cx <= maxCellX; cx += 1) {
65
+ for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
66
+ if (isSolid(cx, cy, cz))
67
+ return true;
68
+ }
69
+ }
70
+ if (groundHeight !== undefined && y <= groundHeight(x, z) + SUPPORT_EPSILON)
71
+ return true;
72
+ return false;
73
+ }
74
+ /**
75
+ * Highest solid-cell top under the footprint at (x, z) within the vertical
76
+ * window [fromY, toY]. Returns null when nothing supports the feet there — used
77
+ * both to snap a fall onto a block and to raise a step-up.
78
+ */
79
+ function supportTopWithin(x, z, fromY, toY, isSolid, dims) {
80
+ const minCellX = cellX(x - dims.halfWidth);
81
+ const maxCellX = cellX(x + dims.halfWidth);
82
+ const minCellZ = cellZ(z - dims.halfWidth);
83
+ const maxCellZ = cellZ(z + dims.halfWidth);
84
+ const lowCellY = Math.floor(fromY - PENETRATION_EPSILON);
85
+ const highCellY = Math.floor(toY - PENETRATION_EPSILON);
86
+ let best = null;
87
+ for (let cx = minCellX; cx <= maxCellX; cx += 1) {
88
+ for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
89
+ for (let cy = lowCellY; cy <= highCellY; cy += 1) {
90
+ if (!isSolid(cx, cy, cz))
91
+ continue;
92
+ const top = cy + 1;
93
+ if (best === null || top > best)
94
+ best = top;
95
+ }
96
+ }
97
+ }
98
+ return best;
99
+ }
100
+ function targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed) {
101
+ if (!intent.moving)
102
+ return { x: 0, z: 0 };
103
+ let fx = forwardX;
104
+ let fz = forwardZ;
105
+ const lengthSq = fx * fx + fz * fz;
106
+ if (lengthSq < 1e-6) {
107
+ fx = 0;
108
+ fz = -1;
109
+ }
110
+ else {
111
+ const inv = 1 / Math.sqrt(lengthSq);
112
+ fx *= inv;
113
+ fz *= inv;
114
+ }
115
+ const rightX = -fz;
116
+ const rightZ = fx;
117
+ let vx = fx * intent.forward + rightX * intent.right;
118
+ let vz = fz * intent.forward + rightZ * intent.right;
119
+ const magSq = vx * vx + vz * vz;
120
+ if (magSq < 1e-6)
121
+ return { x: 0, z: 0 };
122
+ const speedMultiplier = intent.crouching
123
+ ? MOVEMENT_TUNING.crouchSpeedMultiplier
124
+ : intent.running
125
+ ? MOVEMENT_TUNING.runSpeedMultiplier
126
+ : 1;
127
+ const targetSpeed = baseSpeed * MOVEMENT_TUNING.walkSpeedMultiplier * speedMultiplier;
128
+ const scale = targetSpeed / Math.sqrt(magSq);
129
+ vx *= scale;
130
+ vz *= scale;
131
+ return { x: vx, z: vz };
132
+ }
133
+ /**
134
+ * Move the body along one horizontal axis, resolving collisions. Returns the
135
+ * component of the step that survived (0 when a wall stopped the move). When the
136
+ * move is blocked but the obstacle is a short ledge, the body steps up onto it.
137
+ */
138
+ function moveAxis(body, axis, delta, isSolid, dims) {
139
+ if (delta === 0)
140
+ return 0;
141
+ const nextX = axis === "x" ? body.x + delta : body.x;
142
+ const nextZ = axis === "z" ? body.z + delta : body.z;
143
+ if (!overlapsSolid(nextX, body.y, nextZ, isSolid, dims)) {
144
+ body.x = nextX;
145
+ body.z = nextZ;
146
+ return delta;
147
+ }
148
+ if (body.grounded) {
149
+ const raised = supportTopWithin(nextX, nextZ, body.y, body.y + dims.stepHeight, isSolid, dims);
150
+ if (raised !== null &&
151
+ raised - body.y <= dims.stepHeight + PENETRATION_EPSILON &&
152
+ raised >= body.y &&
153
+ !overlapsSolid(nextX, raised, nextZ, isSolid, dims)) {
154
+ body.x = nextX;
155
+ body.z = nextZ;
156
+ body.y = raised;
157
+ return delta;
158
+ }
159
+ }
160
+ return 0;
161
+ }
162
+ /**
163
+ * Advance one frame of voxel-collided player kinematics. Mutates `body`
164
+ * (position, velocity, grounded) in place.
165
+ *
166
+ * `forwardX`/`forwardZ` are the camera heading projected onto the ground plane
167
+ * (need not be normalized). Pulling this out of the render loop is what lets us
168
+ * assert "dig the block underfoot and the player drops a level" in a unit test.
169
+ *
170
+ * `groundHeight`, when supplied, is an additional solid floor sampled at the
171
+ * body's (x, z): the player never sinks below it and standing on it counts as
172
+ * supported (resets jump state like landing on a solid cell). Where terrain and
173
+ * a solid cell's top disagree, the higher of the two wins.
174
+ */
175
+ export function advanceVoxelPlayer(body, intent, forwardX, forwardZ, baseSpeed, rawDeltaSeconds, isSolid, dims = DEFAULT_VOXEL_DIMS, tuning, groundHeight) {
176
+ const dt = Math.min(rawDeltaSeconds, MOVEMENT_TUNING.maxFrameSeconds);
177
+ const gravityAcceleration = tuning?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration;
178
+ const jumpVelocity = tuning?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity;
179
+ const target = targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed);
180
+ const acceleration = body.grounded
181
+ ? MOVEMENT_TUNING.groundAcceleration
182
+ : MOVEMENT_TUNING.airAcceleration;
183
+ const accelerationBlend = 1 - Math.exp(-acceleration * dt);
184
+ body.velocityX += (target.x - body.velocityX) * accelerationBlend;
185
+ body.velocityZ += (target.z - body.velocityZ) * accelerationBlend;
186
+ if (!intent.moving && body.grounded) {
187
+ const friction = Math.exp(-MOVEMENT_TUNING.groundFriction * dt);
188
+ body.velocityX *= friction;
189
+ body.velocityZ *= friction;
190
+ }
191
+ if (moveAxis(body, "x", body.velocityX * dt, isSolid, dims) === 0)
192
+ body.velocityX = 0;
193
+ if (moveAxis(body, "z", body.velocityZ * dt, isSolid, dims) === 0)
194
+ body.velocityZ = 0;
195
+ const supported = body.grounded && hasSupport(body.x, body.z, body.y, isSolid, dims, groundHeight);
196
+ body.grounded = supported;
197
+ const jumpPressed = intent.jumping;
198
+ if (jumpPressed && !body.jumpHeld && body.grounded && !intent.crouching) {
199
+ body.velocityY = jumpVelocity;
200
+ body.grounded = false;
201
+ }
202
+ body.jumpHeld = jumpPressed;
203
+ if (!body.grounded) {
204
+ body.velocityY -= gravityAcceleration * dt;
205
+ const nextY = body.y + body.velocityY * dt;
206
+ if (body.velocityY <= 0) {
207
+ const terrainFloor = groundHeight?.(body.x, body.z);
208
+ const solidBlocked = overlapsSolid(body.x, nextY, body.z, isSolid, dims);
209
+ const terrainBlocked = terrainFloor !== undefined && nextY <= terrainFloor;
210
+ if (solidBlocked || terrainBlocked) {
211
+ const solidTop = solidBlocked
212
+ ? (supportTopWithin(body.x, body.z, nextY, body.y + dims.height, isSolid, dims) ?? Math.floor(body.y))
213
+ : undefined;
214
+ body.y = Math.max(solidTop ?? -Infinity, terrainFloor ?? -Infinity);
215
+ body.velocityY = 0;
216
+ body.grounded = true;
217
+ }
218
+ else {
219
+ body.y = nextY;
220
+ }
221
+ }
222
+ else if (overlapsSolid(body.x, nextY, body.z, isSolid, dims)) {
223
+ body.velocityY = 0;
224
+ }
225
+ else {
226
+ body.y = nextY;
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,42 @@
1
+ import type { ChatMessage } from "../game/chat.js";
2
+ export interface ChatSendArgs {
3
+ channelId: string;
4
+ body: string;
5
+ }
6
+ export interface ChatSendOutcome {
7
+ ok: boolean;
8
+ reason?: string;
9
+ }
10
+ export interface ChatActions {
11
+ sendMessage(args: ChatSendArgs): Promise<ChatSendOutcome>;
12
+ }
13
+ /**
14
+ * Backend seam for remote text chat, mirroring PresenceTransport: the use*
15
+ * members are called as React hooks by consumers, so a mounted transport must
16
+ * never change identity — remount the subtree to switch backends. useMessages
17
+ * returns undefined while the subscription is loading and the channel's recent
18
+ * history once live.
19
+ */
20
+ export interface ChatTransport {
21
+ useMessages(channelId: string | "skip"): readonly ChatMessage[] | undefined;
22
+ useActions(): ChatActions;
23
+ }
24
+ /**
25
+ * Callback seam for backends that cannot host React hooks (e.g. the ws
26
+ * client): subscribe delivers the channel's recent history on every change;
27
+ * send resolves with the host's verdict. @jgengine/react's chatTransportFromSync
28
+ * lifts a ChatSync into a ChatTransport.
29
+ */
30
+ export interface ChatSync {
31
+ subscribe(channelId: string, onChange: (messages: readonly ChatMessage[]) => void): () => void;
32
+ send(channelId: string, body: string): Promise<ChatSendOutcome>;
33
+ }
34
+ export declare function createLocalChatTransport(options?: {
35
+ userId?: string;
36
+ historyLimit?: number;
37
+ now?: () => number;
38
+ }): {
39
+ transport: ChatTransport;
40
+ sync: ChatSync;
41
+ actions: ChatActions;
42
+ };
@@ -0,0 +1,57 @@
1
+ export function createLocalChatTransport(options) {
2
+ const userId = options?.userId ?? "local";
3
+ const historyLimit = options?.historyLimit ?? 100;
4
+ const now = options?.now ?? Date.now;
5
+ const messagesByChannel = new Map();
6
+ const listeners = new Map();
7
+ let counter = 0;
8
+ function snapshot(channelId) {
9
+ return (messagesByChannel.get(channelId) ?? []).slice();
10
+ }
11
+ const sync = {
12
+ subscribe(channelId, onChange) {
13
+ let set = listeners.get(channelId);
14
+ if (set === undefined) {
15
+ set = new Set();
16
+ listeners.set(channelId, set);
17
+ }
18
+ set.add(onChange);
19
+ onChange(snapshot(channelId));
20
+ return () => {
21
+ set.delete(onChange);
22
+ if (set.size === 0)
23
+ listeners.delete(channelId);
24
+ };
25
+ },
26
+ send(channelId, body) {
27
+ const trimmed = body.trim();
28
+ if (trimmed.length === 0)
29
+ return Promise.resolve({ ok: false, reason: "empty message" });
30
+ counter += 1;
31
+ const message = {
32
+ id: `local_${counter}`,
33
+ channelId,
34
+ fromUserId: userId,
35
+ body: trimmed,
36
+ at: now(),
37
+ };
38
+ const ring = messagesByChannel.get(channelId) ?? [];
39
+ ring.push(message);
40
+ if (ring.length > historyLimit)
41
+ ring.splice(0, ring.length - historyLimit);
42
+ messagesByChannel.set(channelId, ring);
43
+ const current = snapshot(channelId);
44
+ for (const listener of listeners.get(channelId) ?? [])
45
+ listener(current);
46
+ return Promise.resolve({ ok: true });
47
+ },
48
+ };
49
+ const actions = {
50
+ sendMessage: (args) => sync.send(args.channelId, args.body),
51
+ };
52
+ const transport = {
53
+ useMessages: (channelId) => (channelId === "skip" ? undefined : snapshot(channelId)),
54
+ useActions: () => actions,
55
+ };
56
+ return { transport, sync, actions };
57
+ }
@@ -0,0 +1,45 @@
1
+ export interface SnapshotUnit {
2
+ id: string;
3
+ stats: Readonly<Record<string, number>>;
4
+ }
5
+ export interface BoardSnapshot {
6
+ ownerId: string;
7
+ units: readonly SnapshotUnit[];
8
+ stats: Readonly<Record<string, number>>;
9
+ seed: number;
10
+ capturedAt: number;
11
+ }
12
+ export interface SerializeBoardArgs {
13
+ ownerId: string;
14
+ units: readonly SnapshotUnit[];
15
+ stats?: Readonly<Record<string, number>>;
16
+ seed?: number;
17
+ capturedAt?: number;
18
+ }
19
+ export declare function serializeBoard(args: SerializeBoardArgs): BoardSnapshot;
20
+ export declare function cloneSnapshot(snapshot: BoardSnapshot): BoardSnapshot;
21
+ export interface CombatRules {
22
+ attackStat: string;
23
+ healthStat: string;
24
+ maxRounds?: number;
25
+ critChance?: number;
26
+ critMultiplier?: number;
27
+ }
28
+ export type CombatSide = "a" | "b" | "draw";
29
+ export interface ReplayBlow {
30
+ round: number;
31
+ attacker: CombatSide;
32
+ attackerUnit: string;
33
+ defenderUnit: string;
34
+ damage: number;
35
+ crit: boolean;
36
+ defenderHealthAfter: number;
37
+ }
38
+ export interface ReplayResult {
39
+ winner: CombatSide;
40
+ rounds: number;
41
+ blows: readonly ReplayBlow[];
42
+ survivorsA: readonly string[];
43
+ survivorsB: readonly string[];
44
+ }
45
+ export declare function replayCombat(a: BoardSnapshot, b: BoardSnapshot, rules: CombatRules): ReplayResult;
@@ -0,0 +1,89 @@
1
+ function cloneStats(stats) {
2
+ return { ...stats };
3
+ }
4
+ export function serializeBoard(args) {
5
+ return {
6
+ ownerId: args.ownerId,
7
+ units: args.units.map((unit) => ({ id: unit.id, stats: cloneStats(unit.stats) })),
8
+ stats: cloneStats(args.stats ?? {}),
9
+ seed: args.seed ?? 0,
10
+ capturedAt: args.capturedAt ?? 0,
11
+ };
12
+ }
13
+ export function cloneSnapshot(snapshot) {
14
+ return serializeBoard({
15
+ ownerId: snapshot.ownerId,
16
+ units: snapshot.units,
17
+ stats: snapshot.stats,
18
+ seed: snapshot.seed,
19
+ capturedAt: snapshot.capturedAt,
20
+ });
21
+ }
22
+ function mulberry32(seed) {
23
+ let a = seed >>> 0;
24
+ return () => {
25
+ a |= 0;
26
+ a = (a + 0x6d2b79f5) | 0;
27
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
28
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
29
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
30
+ };
31
+ }
32
+ function toLive(snapshot, rules) {
33
+ return snapshot.units.map((unit) => ({
34
+ id: unit.id,
35
+ attack: unit.stats[rules.attackStat] ?? 0,
36
+ health: unit.stats[rules.healthStat] ?? 0,
37
+ }));
38
+ }
39
+ export function replayCombat(a, b, rules) {
40
+ const maxRounds = rules.maxRounds ?? 100;
41
+ const critChance = rules.critChance ?? 0;
42
+ const critMultiplier = rules.critMultiplier ?? 2;
43
+ const prng = mulberry32((a.seed ^ (b.seed * 0x9e3779b1)) >>> 0);
44
+ const liveA = toLive(a, rules);
45
+ const liveB = toLive(b, rules);
46
+ const blows = [];
47
+ let indexA = 0;
48
+ let indexB = 0;
49
+ let rounds = 0;
50
+ const strike = (round, attacker, attackers, attackerIndex, defenders) => {
51
+ const source = attackers[attackerIndex];
52
+ const target = defenders[0];
53
+ const crit = critChance > 0 && prng() < critChance;
54
+ const damage = crit ? Math.round(source.attack * critMultiplier) : source.attack;
55
+ target.health -= damage;
56
+ blows.push({
57
+ round,
58
+ attacker,
59
+ attackerUnit: source.id,
60
+ defenderUnit: target.id,
61
+ damage,
62
+ crit,
63
+ defenderHealthAfter: target.health,
64
+ });
65
+ if (target.health <= 0)
66
+ defenders.shift();
67
+ };
68
+ while (liveA.length > 0 && liveB.length > 0 && rounds < maxRounds) {
69
+ rounds += 1;
70
+ strike(rounds, "a", liveA, indexA % liveA.length, liveB);
71
+ if (liveB.length === 0)
72
+ break;
73
+ strike(rounds, "b", liveB, indexB % liveB.length, liveA);
74
+ indexA += 1;
75
+ indexB += 1;
76
+ }
77
+ const winner = liveA.length > 0 && liveB.length === 0
78
+ ? "a"
79
+ : liveB.length > 0 && liveA.length === 0
80
+ ? "b"
81
+ : "draw";
82
+ return {
83
+ winner,
84
+ rounds,
85
+ blows,
86
+ survivorsA: liveA.map((u) => u.id),
87
+ survivorsB: liveB.map((u) => u.id),
88
+ };
89
+ }
@@ -0,0 +1,13 @@
1
+ export interface AuthSession {
2
+ userId: string;
3
+ displayName?: string;
4
+ avatarUrl?: string;
5
+ email?: string;
6
+ isNew?: boolean;
7
+ }
8
+ export interface PlayerIdentity {
9
+ userId: string;
10
+ isNew: boolean;
11
+ }
12
+ export declare function sessionPlayer(session: AuthSession): PlayerIdentity;
13
+ export declare function resolveGuestSession(seed?: string): AuthSession;
@@ -0,0 +1,20 @@
1
+ export function sessionPlayer(session) {
2
+ return { userId: session.userId, isNew: session.isNew ?? false };
3
+ }
4
+ const DEFAULT_GUEST_SEED = "local";
5
+ export function resolveGuestSession(seed) {
6
+ const normalized = seed === undefined || seed.trim().length === 0 ? DEFAULT_GUEST_SEED : seed.trim();
7
+ const hash = fnv1a(normalized);
8
+ return {
9
+ userId: `guest_${hash}`,
10
+ displayName: `Guest ${hash.slice(0, 4).toUpperCase()}`,
11
+ };
12
+ }
13
+ function fnv1a(input) {
14
+ let hash = 0x811c9dc5;
15
+ for (let index = 0; index < input.length; index += 1) {
16
+ hash ^= input.charCodeAt(index);
17
+ hash = Math.imul(hash, 0x01000193) >>> 0;
18
+ }
19
+ return hash.toString(36);
20
+ }
@@ -0,0 +1,43 @@
1
+ export interface Vec3 {
2
+ x: number;
3
+ y: number;
4
+ z: number;
5
+ }
6
+ export interface PositionSample extends Vec3 {
7
+ t: number;
8
+ }
9
+ export interface PositionHistoryConfig {
10
+ historyMs: number;
11
+ maxSamples?: number;
12
+ }
13
+ export declare class PositionHistory {
14
+ private readonly historyMs;
15
+ private readonly maxSamples;
16
+ private readonly tracks;
17
+ constructor(config: PositionHistoryConfig);
18
+ record(entityId: string, t: number, position: Vec3): void;
19
+ private trim;
20
+ entities(): string[];
21
+ samples(entityId: string): readonly PositionSample[];
22
+ sampleAt(entityId: string, t: number): Vec3 | null;
23
+ prune(now: number): void;
24
+ forget(entityId: string): void;
25
+ }
26
+ export declare function createPositionHistory(config: PositionHistoryConfig): PositionHistory;
27
+ export declare function rewindTimestamp(nowMs: number, rttMs: number, interpDelayMs: number): number;
28
+ export interface HitscanRay {
29
+ origin: Vec3;
30
+ direction: Vec3;
31
+ maxDistance?: number;
32
+ }
33
+ export interface HitscanTarget {
34
+ entityId: string;
35
+ radius: number;
36
+ }
37
+ export interface HitscanHit {
38
+ entityId: string;
39
+ distance: number;
40
+ point: Vec3;
41
+ }
42
+ export declare function raySphereDistance(ray: HitscanRay, center: Vec3, radius: number): number | null;
43
+ export declare function resolveHitscan(history: PositionHistory, targets: readonly HitscanTarget[], ray: HitscanRay, atMs: number): HitscanHit | null;
@@ -0,0 +1,154 @@
1
+ const DEFAULT_MAX_SAMPLES = 256;
2
+ export class PositionHistory {
3
+ historyMs;
4
+ maxSamples;
5
+ tracks = new Map();
6
+ constructor(config) {
7
+ if (!(config.historyMs > 0)) {
8
+ throw new RangeError(`historyMs must be positive, got ${config.historyMs}`);
9
+ }
10
+ this.historyMs = config.historyMs;
11
+ this.maxSamples = config.maxSamples ?? DEFAULT_MAX_SAMPLES;
12
+ }
13
+ record(entityId, t, position) {
14
+ let track = this.tracks.get(entityId);
15
+ if (track === undefined) {
16
+ track = [];
17
+ this.tracks.set(entityId, track);
18
+ }
19
+ const sample = { t, x: position.x, y: position.y, z: position.z };
20
+ const last = track[track.length - 1];
21
+ if (last !== undefined && t <= last.t) {
22
+ if (t === last.t) {
23
+ track[track.length - 1] = sample;
24
+ return;
25
+ }
26
+ let index = track.length;
27
+ while (index > 0 && track[index - 1].t > t)
28
+ index -= 1;
29
+ track.splice(index, 0, sample);
30
+ }
31
+ else {
32
+ track.push(sample);
33
+ }
34
+ this.trim(track, t);
35
+ }
36
+ trim(track, newest) {
37
+ const cutoff = newest - this.historyMs;
38
+ let drop = 0;
39
+ while (drop < track.length - 1 && track[drop].t < cutoff)
40
+ drop += 1;
41
+ if (drop > 0)
42
+ track.splice(0, drop);
43
+ if (track.length > this.maxSamples) {
44
+ track.splice(0, track.length - this.maxSamples);
45
+ }
46
+ }
47
+ entities() {
48
+ return [...this.tracks.keys()];
49
+ }
50
+ samples(entityId) {
51
+ return this.tracks.get(entityId) ?? [];
52
+ }
53
+ sampleAt(entityId, t) {
54
+ const track = this.tracks.get(entityId);
55
+ if (track === undefined || track.length === 0)
56
+ return null;
57
+ const first = track[0];
58
+ const last = track[track.length - 1];
59
+ if (t <= first.t)
60
+ return { x: first.x, y: first.y, z: first.z };
61
+ if (t >= last.t)
62
+ return { x: last.x, y: last.y, z: last.z };
63
+ let lo = 0;
64
+ let hi = track.length - 1;
65
+ while (hi - lo > 1) {
66
+ const mid = (lo + hi) >> 1;
67
+ if (track[mid].t <= t)
68
+ lo = mid;
69
+ else
70
+ hi = mid;
71
+ }
72
+ const a = track[lo];
73
+ const b = track[hi];
74
+ const span = b.t - a.t;
75
+ const alpha = span === 0 ? 0 : (t - a.t) / span;
76
+ return {
77
+ x: a.x + (b.x - a.x) * alpha,
78
+ y: a.y + (b.y - a.y) * alpha,
79
+ z: a.z + (b.z - a.z) * alpha,
80
+ };
81
+ }
82
+ prune(now) {
83
+ for (const [entityId, track] of this.tracks) {
84
+ this.trim(track, now);
85
+ if (track.length === 0)
86
+ this.tracks.delete(entityId);
87
+ }
88
+ }
89
+ forget(entityId) {
90
+ this.tracks.delete(entityId);
91
+ }
92
+ }
93
+ export function createPositionHistory(config) {
94
+ return new PositionHistory(config);
95
+ }
96
+ export function rewindTimestamp(nowMs, rttMs, interpDelayMs) {
97
+ return nowMs - rttMs / 2 - interpDelayMs;
98
+ }
99
+ function normalize(v) {
100
+ const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
101
+ if (len === 0)
102
+ return null;
103
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
104
+ }
105
+ export function raySphereDistance(ray, center, radius) {
106
+ const dir = normalize(ray.direction);
107
+ if (dir === null)
108
+ return null;
109
+ const ox = ray.origin.x - center.x;
110
+ const oy = ray.origin.y - center.y;
111
+ const oz = ray.origin.z - center.z;
112
+ const b = ox * dir.x + oy * dir.y + oz * dir.z;
113
+ const c = ox * ox + oy * oy + oz * oz - radius * radius;
114
+ if (c > 0 && b > 0)
115
+ return null;
116
+ const disc = b * b - c;
117
+ if (disc < 0)
118
+ return null;
119
+ const sqrtDisc = Math.sqrt(disc);
120
+ const tNear = -b - sqrtDisc;
121
+ const t = tNear >= 0 ? tNear : -b + sqrtDisc;
122
+ if (t < 0)
123
+ return null;
124
+ const maxDistance = ray.maxDistance ?? Number.POSITIVE_INFINITY;
125
+ if (t > maxDistance)
126
+ return null;
127
+ return t;
128
+ }
129
+ export function resolveHitscan(history, targets, ray, atMs) {
130
+ const dir = normalize(ray.direction);
131
+ if (dir === null)
132
+ return null;
133
+ let best = null;
134
+ for (const target of targets) {
135
+ const center = history.sampleAt(target.entityId, atMs);
136
+ if (center === null)
137
+ continue;
138
+ const distance = raySphereDistance(ray, center, target.radius);
139
+ if (distance === null)
140
+ continue;
141
+ if (best === null || distance < best.distance) {
142
+ best = {
143
+ entityId: target.entityId,
144
+ distance,
145
+ point: {
146
+ x: ray.origin.x + dir.x * distance,
147
+ y: ray.origin.y + dir.y * distance,
148
+ z: ray.origin.z + dir.z * distance,
149
+ },
150
+ };
151
+ }
152
+ }
153
+ return best;
154
+ }