@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
@@ -0,0 +1,284 @@
1
+ import { colliderBounds, colliderWorldCenter, defaultEntityColliders, defaultObjectColliders, resolveColliders, } from "./colliders.js";
2
+ import { intersectAabb, normalizeDirection } from "./objectQuery.js";
3
+ function excluded(exclude, id) {
4
+ if (exclude === undefined)
5
+ return false;
6
+ if (typeof exclude.has === "function") {
7
+ return exclude.has(id);
8
+ }
9
+ for (const value of exclude) {
10
+ if (value === id)
11
+ return true;
12
+ }
13
+ return false;
14
+ }
15
+ function enabled(filter, key) {
16
+ if (filter === undefined)
17
+ return true;
18
+ return filter[key] !== false;
19
+ }
20
+ function rayHitsSphere(origin, direction, center, radius, maxDistance) {
21
+ const ox = origin[0] - center[0];
22
+ const oy = origin[1] - center[1];
23
+ const oz = origin[2] - center[2];
24
+ const b = ox * direction[0] + oy * direction[1] + oz * direction[2];
25
+ const c = ox * ox + oy * oy + oz * oz - radius * radius;
26
+ const disc = b * b - c;
27
+ if (disc < 0)
28
+ return null;
29
+ const sqrt = Math.sqrt(disc);
30
+ let t = -b - sqrt;
31
+ if (t < 0)
32
+ t = -b + sqrt;
33
+ if (t < 0 || t > maxDistance)
34
+ return null;
35
+ const px = origin[0] + direction[0] * t - center[0];
36
+ const py = origin[1] + direction[1] * t - center[1];
37
+ const pz = origin[2] + direction[2] * t - center[2];
38
+ const len = Math.hypot(px, py, pz) || 1;
39
+ return { distance: t, normal: [px / len, py / len, pz / len] };
40
+ }
41
+ function rayHitsCollider(origin, direction, maxDistance, collider, position, rotationY) {
42
+ if (collider.shape.kind === "sphere") {
43
+ const center = colliderWorldCenter(collider, position, rotationY);
44
+ const hit = rayHitsSphere(origin, direction, center, collider.shape.radius, maxDistance);
45
+ if (hit === null)
46
+ return null;
47
+ return {
48
+ distance: hit.distance,
49
+ normal: hit.normal,
50
+ point: [
51
+ origin[0] + direction[0] * hit.distance,
52
+ origin[1] + direction[1] * hit.distance,
53
+ origin[2] + direction[2] * hit.distance,
54
+ ],
55
+ };
56
+ }
57
+ const bounds = colliderBounds(collider, position, rotationY);
58
+ const hit = intersectAabb(origin, direction, bounds.min, bounds.max, maxDistance);
59
+ if (hit === null)
60
+ return null;
61
+ return {
62
+ distance: hit.distance,
63
+ normal: hit.normal,
64
+ point: [
65
+ origin[0] + direction[0] * hit.distance,
66
+ origin[1] + direction[1] * hit.distance,
67
+ origin[2] + direction[2] * hit.distance,
68
+ ],
69
+ };
70
+ }
71
+ function pushColliderHits(hits, targetKind, instanceId, catalogId, colliders, position, rotationY, origin, direction, maxDistance) {
72
+ for (const collider of colliders) {
73
+ const hit = rayHitsCollider(origin, direction, maxDistance, collider, position, rotationY);
74
+ if (hit === null)
75
+ continue;
76
+ hits.push({
77
+ targetKind,
78
+ instanceId,
79
+ ...(catalogId !== undefined ? { catalogId } : {}),
80
+ colliderName: collider.name,
81
+ purpose: collider.purpose,
82
+ damageEligible: collider.damageEligible,
83
+ blocks: collider.blocks,
84
+ distance: hit.distance,
85
+ point: hit.point,
86
+ normal: hit.normal,
87
+ });
88
+ }
89
+ }
90
+ function wallAabb(wall) {
91
+ const thickness = wall.thickness ?? 0.25;
92
+ const halfHeight = wall.halfHeight ?? 1.5;
93
+ const yCenter = wall.yCenter ?? halfHeight;
94
+ const ax = wall.a[0];
95
+ const az = wall.a[1];
96
+ const bx = wall.b[0];
97
+ const bz = wall.b[1];
98
+ const dx = bx - ax;
99
+ const dz = bz - az;
100
+ const len = Math.hypot(dx, dz) || 1;
101
+ const nx = (-dz / len) * thickness;
102
+ const nz = (dx / len) * thickness;
103
+ const xs = [ax + nx, ax - nx, bx + nx, bx - nx];
104
+ const zs = [az + nz, az - nz, bz + nz, bz - nz];
105
+ const minX = Math.min(...xs);
106
+ const maxX = Math.max(...xs);
107
+ const minZ = Math.min(...zs);
108
+ const maxZ = Math.max(...zs);
109
+ return {
110
+ min: [minX, yCenter - halfHeight, minZ],
111
+ max: [maxX, yCenter + halfHeight, maxZ],
112
+ center: [(ax + bx) / 2, yCenter, (az + bz) / 2],
113
+ };
114
+ }
115
+ function rayHitsTerrain(origin, direction, maxDistance, terrain) {
116
+ const steps = Math.max(8, Math.ceil(maxDistance * 2));
117
+ const step = maxDistance / steps;
118
+ let prevAbove = null;
119
+ let prevT = 0;
120
+ for (let i = 0; i <= steps; i += 1) {
121
+ const t = Math.min(maxDistance, i * step);
122
+ const x = origin[0] + direction[0] * t;
123
+ const y = origin[1] + direction[1] * t;
124
+ const z = origin[2] + direction[2] * t;
125
+ const ground = terrain.sampleHeight(x, z);
126
+ const above = y >= ground;
127
+ if (prevAbove === true && !above) {
128
+ let lo = prevT;
129
+ let hi = t;
130
+ for (let refine = 0; refine < 8; refine += 1) {
131
+ const mid = (lo + hi) / 2;
132
+ const my = origin[1] + direction[1] * mid;
133
+ const mg = terrain.sampleHeight(origin[0] + direction[0] * mid, origin[2] + direction[2] * mid);
134
+ if (my >= mg)
135
+ lo = mid;
136
+ else
137
+ hi = mid;
138
+ }
139
+ const distance = hi;
140
+ const px = origin[0] + direction[0] * distance;
141
+ const py = origin[1] + direction[1] * distance;
142
+ const pz = origin[2] + direction[2] * distance;
143
+ return {
144
+ targetKind: "terrain",
145
+ instanceId: "terrain",
146
+ colliderName: "ground",
147
+ purpose: "physical",
148
+ damageEligible: false,
149
+ blocks: true,
150
+ distance,
151
+ point: [px, py, pz],
152
+ normal: [0, 1, 0],
153
+ };
154
+ }
155
+ prevAbove = above;
156
+ prevT = t;
157
+ }
158
+ return null;
159
+ }
160
+ function candidateEntityIds(entities, origin, maxDistance) {
161
+ if (entities.inRadius !== undefined && entities.get !== undefined) {
162
+ const ids = entities.inRadius(origin, maxDistance + 2);
163
+ const out = [];
164
+ for (const id of ids) {
165
+ const entity = entities.get(id);
166
+ if (entity !== null && entity !== undefined)
167
+ out.push(entity);
168
+ }
169
+ return out;
170
+ }
171
+ return entities.list();
172
+ }
173
+ function candidateObjects(objects, origin, direction, maxDistance) {
174
+ if (objects.inBox !== undefined) {
175
+ const end = [
176
+ origin[0] + direction[0] * maxDistance,
177
+ origin[1] + direction[1] * maxDistance,
178
+ origin[2] + direction[2] * maxDistance,
179
+ ];
180
+ const pad = 2;
181
+ const min = [
182
+ Math.min(origin[0], end[0]) - pad,
183
+ Math.min(origin[1], end[1]) - pad,
184
+ Math.min(origin[2], end[2]) - pad,
185
+ ];
186
+ const max = [
187
+ Math.max(origin[0], end[0]) + pad,
188
+ Math.max(origin[1], end[1]) + pad,
189
+ Math.max(origin[2], end[2]) + pad,
190
+ ];
191
+ return objects.inBox(min, max);
192
+ }
193
+ return objects.list();
194
+ }
195
+ function gatherHits(deps, input) {
196
+ const direction = normalizeDirection(input.direction);
197
+ if (direction[0] === 0 && direction[1] === 0 && direction[2] === 0)
198
+ return [];
199
+ const hits = [];
200
+ const { origin, maxDistance } = input;
201
+ if (enabled(input.filter, "entities") && deps.entities !== undefined) {
202
+ for (const entity of candidateEntityIds(deps.entities, origin, maxDistance)) {
203
+ if (excluded(input.excludeInstanceIds, entity.id))
204
+ continue;
205
+ const set = deps.entities.collidersOf?.(entity.id) ?? defaultEntityColliders();
206
+ const colliders = resolveColliders(set);
207
+ pushColliderHits(hits, "entity", entity.id, entity.name, colliders.length > 0 ? colliders : resolveColliders(defaultEntityColliders()), entity.position, entity.rotationY, origin, direction, maxDistance);
208
+ }
209
+ }
210
+ if (enabled(input.filter, "objects") && deps.objects !== undefined) {
211
+ for (const object of candidateObjects(deps.objects, origin, direction, maxDistance)) {
212
+ if (excluded(input.excludeInstanceIds, object.instanceId))
213
+ continue;
214
+ const half = deps.objects.halfExtentsOf?.(object.catalogId) ?? undefined;
215
+ const set = deps.objects.collidersOf?.(object.instanceId) ??
216
+ defaultObjectColliders(half === null || half === undefined ? undefined : half);
217
+ const colliders = resolveColliders(set);
218
+ pushColliderHits(hits, "object", object.instanceId, object.catalogId, colliders, object.position, object.rotationY, origin, direction, maxDistance);
219
+ }
220
+ }
221
+ if (enabled(input.filter, "walls") && deps.walls !== undefined) {
222
+ for (const wall of deps.walls) {
223
+ if (excluded(input.excludeInstanceIds, wall.id))
224
+ continue;
225
+ const box = wallAabb(wall);
226
+ const hit = intersectAabb(origin, direction, box.min, box.max, maxDistance);
227
+ if (hit === null)
228
+ continue;
229
+ hits.push({
230
+ targetKind: "wall",
231
+ instanceId: wall.id,
232
+ colliderName: "wall",
233
+ purpose: "physical",
234
+ damageEligible: false,
235
+ blocks: true,
236
+ distance: hit.distance,
237
+ point: [
238
+ origin[0] + direction[0] * hit.distance,
239
+ origin[1] + direction[1] * hit.distance,
240
+ origin[2] + direction[2] * hit.distance,
241
+ ],
242
+ normal: hit.normal,
243
+ });
244
+ }
245
+ }
246
+ if (enabled(input.filter, "terrain") && deps.terrain !== undefined) {
247
+ const terrainHit = rayHitsTerrain(origin, direction, maxDistance, deps.terrain);
248
+ if (terrainHit !== null)
249
+ hits.push(terrainHit);
250
+ }
251
+ hits.sort((a, b) => a.distance - b.distance || a.instanceId.localeCompare(b.instanceId) || a.colliderName.localeCompare(b.colliderName));
252
+ if (input.accept !== undefined)
253
+ return hits.filter(input.accept);
254
+ return hits;
255
+ }
256
+ /** First impact: nearest hit that blocks, or nearest hit if none block. */
257
+ export function firstImpact(hits) {
258
+ for (const hit of hits) {
259
+ if (hit.blocks)
260
+ return hit;
261
+ }
262
+ return hits[0] ?? null;
263
+ }
264
+ /** Hits up to and including the first blocking collider (damage hitboxes before a wall stay). */
265
+ export function hitsUntilBlocked(hits) {
266
+ const out = [];
267
+ for (const hit of hits) {
268
+ out.push(hit);
269
+ if (hit.blocks)
270
+ break;
271
+ }
272
+ return out;
273
+ }
274
+ export function createSceneRaycast(deps) {
275
+ return {
276
+ raycast(input) {
277
+ const hits = gatherHits(deps, input);
278
+ return firstImpact(hits);
279
+ },
280
+ raycastAll(input) {
281
+ return gatherHits(deps, input);
282
+ },
283
+ };
284
+ }
@@ -19,21 +19,24 @@ export interface MoveTowardOptions {
19
19
  dt: number;
20
20
  }
21
21
  export interface SpatialGridOptions {
22
- /** Cell edge over the x/z plane; tune to ≈ the typical query radius so a query touches a handful of cells. */
23
22
  cellSize: number;
24
23
  }
25
24
  export interface SpatialApiOptions {
26
25
  resolvePosition: (instanceId: string) => EntityPosition | undefined;
27
- candidates: () => string[];
26
+ candidates: () => readonly string[];
27
+ /**
28
+ * When omitted, `hasLineOfSight` always returns true for known entities (open-field bypass).
29
+ * Wire a wall/collision test here for combat LoS; `true` means the segment is blocked.
30
+ */
28
31
  occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
29
32
  /**
30
- * Opt-in broadphase acceleration for `inRadius`/`queryArc` over large candidate sets. When set,
31
- * a uniform x/z cell index is built lazily from `candidates()` + `resolvePosition()` on first use
32
- * and reused across calls until `invalidate()` is called — so many queries against one tick's
33
- * frozen positions pay for one rebuild, not one linear scan each. Results are exactly the linear
34
- * scan's results (same filtering; see `SpatialApi.invalidate` for the staleness contract).
33
+ * Broadphase for `inRadius`/`queryArc`. Defaults to `{ cellSize: 8 }`. Pass `false` to force a
34
+ * linear scan. When enabled, the index rebuilds lazily on first use after `invalidate()` or when
35
+ * `getVersion()` advances — call `invalidate()` after moves unless a version source is wired.
35
36
  */
36
- grid?: SpatialGridOptions;
37
+ grid?: SpatialGridOptions | false;
38
+ /** When this number changes between queries, the grid rebuilds without an explicit `invalidate()`. */
39
+ getVersion?: () => number;
37
40
  }
38
41
  export interface SpatialApi {
39
42
  distance(aInstanceId: string, bInstanceId: string): number | null;
@@ -41,14 +44,6 @@ export interface SpatialApi {
41
44
  hasLineOfSight(fromInstanceId: string, toInstanceId: string): boolean;
42
45
  queryArc(options: QueryArcOptions): string[];
43
46
  moveToward(instanceId: string, target: EntityPosition | string, options: MoveTowardOptions): EntityPosition | null;
44
- /**
45
- * Marks the `grid` broadphase index stale so the next `inRadius`/`queryArc` call rebuilds it from
46
- * `candidates()` and `resolvePosition()`. Call this after moving, spawning, or despawning a
47
- * candidate; a candidate absent from the index at query time is never skipped (it falls back to
48
- * an exact check), but a candidate that *moved* since the last rebuild can be missed until you
49
- * invalidate — so treat "invalidate after any position change" as the contract, not an
50
- * optimization. A no-op when no `grid` config was supplied. Safe to call every tick.
51
- */
52
47
  invalidate(): void;
53
48
  }
54
49
  export declare function distanceBetween(a: EntityPosition, b: EntityPosition): number;
@@ -17,58 +17,117 @@ function forwardXZ(aim) {
17
17
  function cellCoord(value, cellSize) {
18
18
  return Math.floor(value / cellSize);
19
19
  }
20
- function gridCellKey(cx, cz) {
21
- return `${cx}:${cz}`;
20
+ const CELL_KEY_OFFSET = 0x8000;
21
+ function packCell(cx, cz) {
22
+ return ((cx + CELL_KEY_OFFSET) << 16) | ((cz + CELL_KEY_OFFSET) & 0xffff);
22
23
  }
24
+ const DEFAULT_CELL_SIZE = 8;
23
25
  export function createSpatialApi(options) {
24
- const { resolvePosition, candidates, occluder, grid } = options;
25
- const cellSize = grid?.cellSize;
26
+ const { resolvePosition, candidates, occluder, getVersion } = options;
27
+ const gridConfig = options.grid === false ? undefined : (options.grid ?? { cellSize: DEFAULT_CELL_SIZE });
28
+ const cellSize = gridConfig?.cellSize;
26
29
  let gridIndex = null;
27
30
  let gridDirty = true;
31
+ let lastVersion;
28
32
  function resolveTarget(target) {
29
33
  return typeof target === "string" ? resolvePosition(target) : target;
30
34
  }
35
+ function takeBucket(index) {
36
+ if (index.poolCursor < index.bucketPool.length) {
37
+ const bucket = index.bucketPool[index.poolCursor];
38
+ index.poolCursor += 1;
39
+ bucket.length = 0;
40
+ return bucket;
41
+ }
42
+ const bucket = [];
43
+ index.bucketPool.push(bucket);
44
+ index.poolCursor += 1;
45
+ return bucket;
46
+ }
31
47
  function ensureGrid() {
32
48
  if (cellSize === undefined)
33
49
  return null;
50
+ const version = getVersion?.();
51
+ if (version !== undefined && version !== lastVersion) {
52
+ gridDirty = true;
53
+ lastVersion = version;
54
+ }
34
55
  if (!gridDirty && gridIndex !== null)
35
56
  return gridIndex;
36
- const cells = new Map();
37
- const indexed = new Set();
57
+ const previous = gridIndex;
58
+ const cells = previous?.cells ?? new Map();
59
+ cells.clear();
60
+ const indexed = previous?.indexed ?? new Set();
61
+ indexed.clear();
62
+ const index = {
63
+ cells,
64
+ indexed,
65
+ bucketPool: previous?.bucketPool ?? [],
66
+ poolCursor: 0,
67
+ };
38
68
  for (const instanceId of candidates()) {
39
69
  const position = resolvePosition(instanceId);
40
70
  if (position === undefined)
41
71
  continue;
42
72
  indexed.add(instanceId);
43
- const key = gridCellKey(cellCoord(position[0], cellSize), cellCoord(position[2], cellSize));
73
+ const key = packCell(cellCoord(position[0], cellSize), cellCoord(position[2], cellSize));
44
74
  let bucket = cells.get(key);
45
75
  if (bucket === undefined) {
46
- bucket = [];
76
+ bucket = takeBucket(index);
47
77
  cells.set(key, bucket);
48
78
  }
49
79
  bucket.push(instanceId);
50
80
  }
51
- gridIndex = { cells, indexed };
81
+ gridIndex = index;
52
82
  gridDirty = false;
53
- return gridIndex;
83
+ return index;
54
84
  }
55
- function inRangeSet(index, centerX, centerZ, radius) {
85
+ function collectNear(index, centerX, centerZ, radius, out) {
56
86
  const size = cellSize;
57
87
  const cxMin = cellCoord(centerX - radius, size);
58
88
  const cxMax = cellCoord(centerX + radius, size);
59
89
  const czMin = cellCoord(centerZ - radius, size);
60
90
  const czMax = cellCoord(centerZ + radius, size);
61
- const near = new Set();
91
+ out.length = 0;
92
+ const seen = collectNearSeen;
93
+ seen.clear();
62
94
  for (let cz = czMin; cz <= czMax; cz += 1) {
63
95
  for (let cx = cxMin; cx <= cxMax; cx += 1) {
64
- const bucket = index.cells.get(gridCellKey(cx, cz));
96
+ const bucket = index.cells.get(packCell(cx, cz));
65
97
  if (bucket === undefined)
66
98
  continue;
67
- for (const id of bucket)
68
- near.add(id);
99
+ for (const id of bucket) {
100
+ if (seen.has(id))
101
+ continue;
102
+ seen.add(id);
103
+ out.push(id);
104
+ }
69
105
  }
70
106
  }
71
- return near;
107
+ }
108
+ const collectNearSeen = new Set();
109
+ const nearScratch = [];
110
+ const resultScratch = [];
111
+ function appendUnindexed(index, out) {
112
+ for (const instanceId of candidates()) {
113
+ if (index.indexed.has(instanceId))
114
+ continue;
115
+ out.push(instanceId);
116
+ }
117
+ }
118
+ function filterInRadius(ids, centerId, centerPosition, radius, filter) {
119
+ resultScratch.length = 0;
120
+ for (const instanceId of ids) {
121
+ if (instanceId === centerId)
122
+ continue;
123
+ if (filter !== undefined && !filter(instanceId))
124
+ continue;
125
+ const position = resolvePosition(instanceId);
126
+ if (position !== undefined && distanceBetween(centerPosition, position) <= radius) {
127
+ resultScratch.push(instanceId);
128
+ }
129
+ }
130
+ return resultScratch.slice();
72
131
  }
73
132
  return {
74
133
  distance(aInstanceId, bInstanceId) {
@@ -84,17 +143,12 @@ export function createSpatialApi(options) {
84
143
  if (centerPosition === undefined)
85
144
  return [];
86
145
  const index = ensureGrid();
87
- const near = index === null ? null : inRangeSet(index, centerPosition[0], centerPosition[2], radius);
88
- return candidates().filter((instanceId) => {
89
- if (instanceId === centerId)
90
- return false;
91
- if (filter !== undefined && !filter(instanceId))
92
- return false;
93
- if (near !== null && index.indexed.has(instanceId) && !near.has(instanceId))
94
- return false;
95
- const position = resolvePosition(instanceId);
96
- return position !== undefined && distanceBetween(centerPosition, position) <= radius;
97
- });
146
+ if (index === null) {
147
+ return filterInRadius(candidates(), centerId, centerPosition, radius, filter);
148
+ }
149
+ collectNear(index, centerPosition[0], centerPosition[2], radius, nearScratch);
150
+ appendUnindexed(index, nearScratch);
151
+ return filterInRadius(nearScratch, centerId, centerPosition, radius, filter);
98
152
  },
99
153
  hasLineOfSight(fromInstanceId, toInstanceId) {
100
154
  const from = resolvePosition(fromInstanceId);
@@ -114,25 +168,32 @@ export function createSpatialApi(options) {
114
168
  return [];
115
169
  const minDot = Math.cos((halfAngleDeg * Math.PI) / 180);
116
170
  const index = ensureGrid();
117
- const near = index === null ? null : inRangeSet(index, origin[0], origin[2], radius);
118
- return candidates().filter((instanceId) => {
171
+ const ids = index === null
172
+ ? candidates()
173
+ : (collectNear(index, origin[0], origin[2], radius, nearScratch),
174
+ appendUnindexed(index, nearScratch),
175
+ nearScratch);
176
+ resultScratch.length = 0;
177
+ for (const instanceId of ids) {
119
178
  if (instanceId === from)
120
- return false;
121
- if (near !== null && index.indexed.has(instanceId) && !near.has(instanceId))
122
- return false;
179
+ continue;
123
180
  const position = resolvePosition(instanceId);
124
181
  if (position === undefined)
125
- return false;
182
+ continue;
126
183
  const dx = position[0] - origin[0];
127
184
  const dz = position[2] - origin[2];
128
185
  const planarDistance = Math.sqrt(dx * dx + dz * dz);
129
186
  if (planarDistance > radius)
130
- return false;
131
- if (planarDistance === 0)
132
- return true;
187
+ continue;
188
+ if (planarDistance === 0) {
189
+ resultScratch.push(instanceId);
190
+ continue;
191
+ }
133
192
  const dot = (forward[0] * dx + forward[1] * dz) / planarDistance;
134
- return dot >= minDot;
135
- });
193
+ if (dot >= minDot)
194
+ resultScratch.push(instanceId);
195
+ }
196
+ return resultScratch.slice();
136
197
  },
137
198
  invalidate() {
138
199
  gridDirty = true;
@@ -9,10 +9,16 @@ export interface RecordingBufferOptions {
9
9
  /** Hard cap on frame count, oldest evicted first. Default unlimited. */
10
10
  maxFrames?: number;
11
11
  }
12
+ export interface RecordingPair<T> {
13
+ before: RecordingFrame<T> | null;
14
+ after: RecordingFrame<T> | null;
15
+ }
12
16
  export interface RecordingBuffer<T> {
13
17
  append(t: number, data: T): void;
14
18
  /** Nearest frame at or before `t`, or null if the buffer is empty or `t` precedes every frame. */
15
19
  seek(t: number): RecordingFrame<T> | null;
20
+ /** The frames bracketing `t` for interpolated playback: `before` is `seek(t)`, `after` is the next recorded frame (null past the end). */
21
+ seekPair(t: number): RecordingPair<T>;
16
22
  /** Frames with `fromT <= t <= toT`, in recorded order. */
17
23
  range(fromT: number, toT: number): RecordingFrame<T>[];
18
24
  clear(): void;
@@ -45,6 +45,13 @@ export function createRecordingBuffer(options = {}) {
45
45
  const index = floorIndex(t);
46
46
  return index === -1 ? null : frames[index];
47
47
  },
48
+ seekPair(t) {
49
+ const index = floorIndex(t);
50
+ return {
51
+ before: index === -1 ? null : frames[index],
52
+ after: frames[index + 1] ?? null,
53
+ };
54
+ },
48
55
  range(fromT, toT) {
49
56
  return frames.filter((frame) => frame.t >= fromT && frame.t <= toT);
50
57
  },
@@ -0,0 +1,39 @@
1
+ import type { RecordingBuffer } from "./recordingBuffer.js";
2
+ export interface ReplayLoopOptions<T> {
3
+ /** Blend between the two frames straddling the seek time; omit to hold the earlier frame. */
4
+ interpolate?: (before: T, after: T, alpha: number) => T;
5
+ /** Returns `null` for this long at the start of every loop pass — a spawn-grace gap so a looping ghost blinks out instead of teleporting. Default `0`. */
6
+ spawnGraceSeconds?: number;
7
+ }
8
+ /**
9
+ * Replays a finished `RecordingBuffer` on a loop — modulo-time seek, frame interpolation, and
10
+ * spawn grace, the pieces every ghost-lap/echo feature hand-rolled (#286.4). `sample(t)` is a pure
11
+ * function of absolute time; `null` means "the ghost isn't on track right now".
12
+ */
13
+ export interface ReplayLoop<T> {
14
+ duration(): number;
15
+ sample(t: number): T | null;
16
+ }
17
+ export declare function createReplayLoop<T>(buffer: RecordingBuffer<T>, options?: ReplayLoopOptions<T>): ReplayLoop<T>;
18
+ export interface ReplayEntityDeps {
19
+ has(id: string): boolean;
20
+ spawn(id: string): void;
21
+ setPose(id: string, pose: {
22
+ position: readonly [number, number, number];
23
+ rotationY?: number;
24
+ dt?: number;
25
+ }): unknown;
26
+ despawn(id: string): unknown;
27
+ }
28
+ export interface RecordedPoseLike {
29
+ position: readonly [number, number, number];
30
+ rotationY?: number;
31
+ }
32
+ /** Linear pose blend for `ReplayLoopOptions.interpolate` — position lerp plus shortest-arc yaw. */
33
+ export declare function interpolateRecordedPose<T extends RecordedPoseLike>(before: T, after: T, alpha: number): T;
34
+ /**
35
+ * Reconcile a replayed pose against an entity store: spawns the ghost on the first non-null sample,
36
+ * poses it while samples flow, despawns it through grace gaps or after the recording empties.
37
+ * `deps` matches `ctx.scene.entity` structurally — pass `{ has: (id) => ctx.scene.entity.get(id) !== null, spawn, setPose, despawn }`.
38
+ */
39
+ export declare function syncReplayEntity(deps: ReplayEntityDeps, id: string, pose: RecordedPoseLike | null, dt?: number): void;
@@ -0,0 +1,64 @@
1
+ export function createReplayLoop(buffer, options = {}) {
2
+ const grace = Math.max(0, options.spawnGraceSeconds ?? 0);
3
+ const interpolate = options.interpolate;
4
+ return {
5
+ duration: () => buffer.duration(),
6
+ sample(t) {
7
+ const frames = buffer.frames();
8
+ if (frames.length === 0)
9
+ return null;
10
+ const first = frames[0];
11
+ const duration = buffer.duration();
12
+ if (duration <= 0)
13
+ return first.data;
14
+ const local = ((t % duration) + duration) % duration;
15
+ if (local < grace)
16
+ return null;
17
+ const seekT = first.t + local;
18
+ const pair = buffer.seekPair(seekT);
19
+ if (pair.before === null)
20
+ return pair.after?.data ?? null;
21
+ if (pair.after === null || interpolate === undefined)
22
+ return pair.before.data;
23
+ const span = pair.after.t - pair.before.t;
24
+ const alpha = span <= 1e-9 ? 0 : (seekT - pair.before.t) / span;
25
+ return interpolate(pair.before.data, pair.after.data, alpha);
26
+ },
27
+ };
28
+ }
29
+ /** Linear pose blend for `ReplayLoopOptions.interpolate` — position lerp plus shortest-arc yaw. */
30
+ export function interpolateRecordedPose(before, after, alpha) {
31
+ let yawDelta = (after.rotationY ?? 0) - (before.rotationY ?? 0);
32
+ while (yawDelta > Math.PI)
33
+ yawDelta -= Math.PI * 2;
34
+ while (yawDelta < -Math.PI)
35
+ yawDelta += Math.PI * 2;
36
+ return {
37
+ ...before,
38
+ position: [
39
+ before.position[0] + (after.position[0] - before.position[0]) * alpha,
40
+ before.position[1] + (after.position[1] - before.position[1]) * alpha,
41
+ before.position[2] + (after.position[2] - before.position[2]) * alpha,
42
+ ],
43
+ ...(before.rotationY === undefined ? {} : { rotationY: (before.rotationY ?? 0) + yawDelta * alpha }),
44
+ };
45
+ }
46
+ /**
47
+ * Reconcile a replayed pose against an entity store: spawns the ghost on the first non-null sample,
48
+ * poses it while samples flow, despawns it through grace gaps or after the recording empties.
49
+ * `deps` matches `ctx.scene.entity` structurally — pass `{ has: (id) => ctx.scene.entity.get(id) !== null, spawn, setPose, despawn }`.
50
+ */
51
+ export function syncReplayEntity(deps, id, pose, dt) {
52
+ if (pose === null) {
53
+ if (deps.has(id))
54
+ deps.despawn(id);
55
+ return;
56
+ }
57
+ if (!deps.has(id))
58
+ deps.spawn(id);
59
+ deps.setPose(id, {
60
+ position: pose.position,
61
+ ...(pose.rotationY === undefined ? {} : { rotationY: pose.rotationY }),
62
+ ...(dt === undefined ? {} : { dt }),
63
+ });
64
+ }