@jgengine/core 0.11.0 → 0.13.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 (256) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/ai/populationDirector.d.ts +103 -0
  3. package/dist/ai/populationDirector.js +190 -0
  4. package/dist/ai/pursuit.d.ts +56 -0
  5. package/dist/ai/pursuit.js +53 -0
  6. package/dist/authoring.d.ts +1 -1
  7. package/dist/authoring.js +1 -1
  8. package/dist/combat/effects.d.ts +11 -4
  9. package/dist/combat/effects.js +24 -17
  10. package/dist/combat/magazine.d.ts +13 -0
  11. package/dist/combat/magazine.js +27 -0
  12. package/dist/combat/vfxPresets.d.ts +176 -0
  13. package/dist/combat/vfxPresets.js +74 -0
  14. package/dist/combat/weaponFire.d.ts +114 -0
  15. package/dist/combat/weaponFire.js +96 -0
  16. package/dist/combat.d.ts +5 -2
  17. package/dist/combat.js +4 -1
  18. package/dist/devtools/controls.d.ts +38 -0
  19. package/dist/devtools/controls.js +181 -0
  20. package/dist/devtools/devtools.d.ts +5 -197
  21. package/dist/devtools/devtools.js +38 -774
  22. package/dist/devtools/discover.d.ts +32 -0
  23. package/dist/devtools/discover.js +240 -0
  24. package/dist/devtools/fallbackSeams.d.ts +50 -0
  25. package/dist/devtools/fallbackSeams.js +111 -0
  26. package/dist/devtools/frame.d.ts +15 -0
  27. package/dist/devtools/frame.js +247 -0
  28. package/dist/devtools/logs.d.ts +15 -0
  29. package/dist/devtools/logs.js +86 -0
  30. package/dist/devtools/overrides.d.ts +16 -0
  31. package/dist/devtools/overrides.js +66 -0
  32. package/dist/devtools/types.d.ts +212 -0
  33. package/dist/devtools/types.js +1 -0
  34. package/dist/devtools/urlFlags.d.ts +22 -0
  35. package/dist/devtools/urlFlags.js +62 -0
  36. package/dist/economy/auctionBook.d.ts +122 -0
  37. package/dist/economy/auctionBook.js +213 -0
  38. package/dist/economy/priceHistory.d.ts +44 -0
  39. package/dist/economy/priceHistory.js +89 -0
  40. package/dist/economy/unlockPoints.d.ts +92 -0
  41. package/dist/economy/unlockPoints.js +103 -0
  42. package/dist/editor/commandApply.d.ts +5 -0
  43. package/dist/editor/commandApply.js +755 -0
  44. package/dist/editor/commands.d.ts +48 -3
  45. package/dist/editor/commands.js +40 -754
  46. package/dist/editor/directives.d.ts +94 -0
  47. package/dist/editor/directives.js +206 -0
  48. package/dist/editor/document.d.ts +37 -2
  49. package/dist/editor/document.js +422 -6
  50. package/dist/editor/entityCatalog.d.ts +42 -0
  51. package/dist/editor/entityCatalog.js +101 -0
  52. package/dist/editor/environment.d.ts +74 -0
  53. package/dist/editor/environment.js +169 -0
  54. package/dist/editor/index.d.ts +26 -1
  55. package/dist/editor/index.js +8 -1
  56. package/dist/editor/minimap.d.ts +31 -0
  57. package/dist/editor/minimap.js +73 -0
  58. package/dist/editor/streamer.d.ts +66 -0
  59. package/dist/editor/streamer.js +68 -0
  60. package/dist/editor/types.d.ts +143 -0
  61. package/dist/editor/world.d.ts +136 -0
  62. package/dist/editor/world.js +303 -0
  63. package/dist/game/breeding.d.ts +161 -0
  64. package/dist/game/breeding.js +181 -0
  65. package/dist/game/cameraConfig.d.ts +451 -0
  66. package/dist/game/cameraConfig.js +1 -0
  67. package/dist/game/clipRoles.d.ts +51 -0
  68. package/dist/game/clipRoles.js +142 -0
  69. package/dist/game/defineGame.d.ts +16 -9
  70. package/dist/game/defineGame.js +14 -6
  71. package/dist/game/defineSystem.d.ts +2 -0
  72. package/dist/game/defineSystem.js +2 -0
  73. package/dist/game/events.d.ts +28 -0
  74. package/dist/game/partAnimation.d.ts +78 -0
  75. package/dist/game/partAnimation.js +147 -0
  76. package/dist/game/playableGame.d.ts +72 -440
  77. package/dist/game/playableGame.js +1 -1
  78. package/dist/game/progression.d.ts +35 -0
  79. package/dist/game/progression.js +22 -1
  80. package/dist/game/quest.js +1 -3
  81. package/dist/game/tribe.d.ts +184 -0
  82. package/dist/game/tribe.js +432 -0
  83. package/dist/gameplay.d.ts +3 -1
  84. package/dist/gameplay.js +3 -1
  85. package/dist/inventory/encumbrance.d.ts +69 -0
  86. package/dist/inventory/encumbrance.js +68 -0
  87. package/dist/inventory/inventoryModel.d.ts +24 -8
  88. package/dist/inventory/inventoryModel.js +47 -8
  89. package/dist/item/durability.js +1 -3
  90. package/dist/meta/changelog.d.ts +1 -1
  91. package/dist/meta/changelog.js +48 -1
  92. package/dist/movement/movementModel.d.ts +15 -1
  93. package/dist/movement/movementModel.js +33 -15
  94. package/dist/movement/playerMovement.js +102 -2
  95. package/dist/multiplayer/combatSnapshot.js +13 -8
  96. package/dist/multiplayer/presenceModel.d.ts +2 -0
  97. package/dist/multiplayer/presenceModel.js +8 -0
  98. package/dist/physics/drivableVehicle.d.ts +2 -0
  99. package/dist/physics/drivableVehicle.js +1 -1
  100. package/dist/physics/flightDynamics.d.ts +82 -0
  101. package/dist/physics/flightDynamics.js +165 -0
  102. package/dist/physics/gravityField.d.ts +25 -0
  103. package/dist/physics/gravityField.js +41 -0
  104. package/dist/physics/kinematicVehicle.d.ts +87 -0
  105. package/dist/physics/kinematicVehicle.js +193 -11
  106. package/dist/physics/physicsWorld.d.ts +26 -0
  107. package/dist/physics/physicsWorld.js +109 -117
  108. package/dist/physics/vehicleObstacles.d.ts +44 -0
  109. package/dist/physics/vehicleObstacles.js +115 -0
  110. package/dist/random/rng.d.ts +4 -1
  111. package/dist/random/rng.js +4 -1
  112. package/dist/runtime/adapter.d.ts +13 -0
  113. package/dist/runtime/adapter.js +13 -0
  114. package/dist/runtime/context/combat.d.ts +51 -0
  115. package/dist/runtime/context/combat.js +131 -0
  116. package/dist/runtime/context/combatFx.js +15 -5
  117. package/dist/runtime/context/deathLoot.d.ts +30 -0
  118. package/dist/runtime/context/deathLoot.js +39 -0
  119. package/dist/runtime/context/player.d.ts +48 -0
  120. package/dist/runtime/context/player.js +125 -0
  121. package/dist/runtime/context/scene.d.ts +60 -0
  122. package/dist/runtime/context/scene.js +329 -0
  123. package/dist/runtime/context/snapshotCodecs.d.ts +12 -0
  124. package/dist/runtime/context/snapshotCodecs.js +26 -0
  125. package/dist/runtime/descriptors/baseline.js +26 -3
  126. package/dist/runtime/descriptors/features.d.ts +5 -0
  127. package/dist/runtime/descriptors/features.js +20 -2
  128. package/dist/runtime/gameContext.d.ts +2 -428
  129. package/dist/runtime/gameContext.js +133 -500
  130. package/dist/runtime/gameContextTypes.d.ts +500 -0
  131. package/dist/runtime/gameContextTypes.js +1 -0
  132. package/dist/runtime/headlessRunner.d.ts +17 -2
  133. package/dist/runtime/headlessRunner.js +15 -0
  134. package/dist/runtime/hostPolicy.d.ts +51 -0
  135. package/dist/runtime/hostPolicy.js +68 -0
  136. package/dist/runtime/hostedGameRunner.d.ts +3 -1
  137. package/dist/runtime/hostedGameRunner.js +1 -0
  138. package/dist/runtime/hostedWorldSession.d.ts +3 -1
  139. package/dist/runtime/hostedWorldSession.js +1 -0
  140. package/dist/runtime/worldSnapshot.d.ts +13 -1
  141. package/dist/runtime/worldSnapshot.js +15 -3
  142. package/dist/scene/assetCatalog.d.ts +7 -0
  143. package/dist/scene/assetSpace.js +1 -3
  144. package/dist/scene/authoredTriggers.d.ts +50 -0
  145. package/dist/scene/authoredTriggers.js +114 -0
  146. package/dist/scene/builtinSceneKinds.js +8 -3
  147. package/dist/scene/colliders.d.ts +79 -0
  148. package/dist/scene/colliders.js +170 -0
  149. package/dist/scene/collisionMesh.d.ts +94 -0
  150. package/dist/scene/collisionMesh.js +699 -0
  151. package/dist/scene/companion.d.ts +147 -0
  152. package/dist/scene/companion.js +193 -0
  153. package/dist/scene/entityStats.d.ts +7 -9
  154. package/dist/scene/entityStats.js +19 -15
  155. package/dist/scene/sceneKinds.d.ts +16 -0
  156. package/dist/scene/sceneKinds.js +4 -0
  157. package/dist/scene/sceneRaycast.js +15 -0
  158. package/dist/stats/spawnLevelStats.d.ts +110 -0
  159. package/dist/stats/spawnLevelStats.js +140 -0
  160. package/dist/stats/statModifiers.d.ts +16 -0
  161. package/dist/stats/statModifiers.js +30 -1
  162. package/dist/stats/statPool.d.ts +70 -0
  163. package/dist/stats/statPool.js +62 -0
  164. package/dist/store/changeSignal.d.ts +3 -0
  165. package/dist/store/changeSignal.js +2 -0
  166. package/dist/store/observableKeyedStore.d.ts +2 -0
  167. package/dist/store/observableKeyedStore.js +1 -0
  168. package/dist/survival/decayMeter.js +1 -3
  169. package/dist/survival/regionHealth.js +1 -3
  170. package/dist/ui/hudLayout.d.ts +5 -0
  171. package/dist/ui/hudLayout.js +17 -1
  172. package/dist/ui/panelModel.d.ts +120 -0
  173. package/dist/ui/panelModel.js +168 -0
  174. package/dist/ui.d.ts +1 -0
  175. package/dist/ui.js +1 -0
  176. package/dist/world/authoredEntities.d.ts +29 -0
  177. package/dist/world/authoredEntities.js +29 -0
  178. package/dist/world/authoredObjects.d.ts +15 -1
  179. package/dist/world/authoredObjects.js +13 -1
  180. package/dist/world/authoredSpawn.d.ts +39 -0
  181. package/dist/world/authoredSpawn.js +37 -0
  182. package/dist/world/buildSockets.d.ts +152 -0
  183. package/dist/world/buildSockets.js +188 -0
  184. package/dist/world/buildingLots.d.ts +84 -0
  185. package/dist/world/buildingLots.js +119 -0
  186. package/dist/world/buildings.d.ts +6 -0
  187. package/dist/world/cityBlocks.d.ts +152 -0
  188. package/dist/world/cityBlocks.js +670 -0
  189. package/dist/world/cityContent.d.ts +77 -0
  190. package/dist/world/cityContent.js +261 -0
  191. package/dist/world/cityGenerator.d.ts +41 -0
  192. package/dist/world/cityGenerator.js +58 -0
  193. package/dist/world/cityGeometry.d.ts +114 -0
  194. package/dist/world/cityGeometry.js +573 -0
  195. package/dist/world/cityKind.d.ts +314 -0
  196. package/dist/world/cityKind.js +1578 -0
  197. package/dist/world/environmentSummary.js +57 -1
  198. package/dist/world/features.d.ts +53 -5
  199. package/dist/world/features.js +15 -3
  200. package/dist/world/grassKind.js +2 -2
  201. package/dist/world/markers.d.ts +50 -4
  202. package/dist/world/markers.js +49 -0
  203. package/dist/world/massing.js +1 -1
  204. package/dist/world/minimapBake.d.ts +78 -0
  205. package/dist/world/minimapBake.js +208 -0
  206. package/dist/world/minimapTrack.d.ts +17 -0
  207. package/dist/world/minimapTrack.js +22 -0
  208. package/dist/world/place.d.ts +166 -0
  209. package/dist/world/place.js +96 -0
  210. package/dist/world/placeAsset.js +3 -1
  211. package/dist/world/poleLineKind.d.ts +40 -0
  212. package/dist/world/poleLineKind.js +97 -0
  213. package/dist/world/polyline.js +9 -2
  214. package/dist/world/resourceNode.d.ts +109 -0
  215. package/dist/world/resourceNode.js +140 -0
  216. package/dist/world/roads.d.ts +19 -2
  217. package/dist/world/roads.js +60 -3
  218. package/dist/world/scatter.js +2 -20
  219. package/dist/world/streetGenerator.d.ts +159 -0
  220. package/dist/world/streetGenerator.js +684 -0
  221. package/dist/world/streets.d.ts +103 -1
  222. package/dist/world/streets.js +273 -0
  223. package/dist/world/terraform.js +58 -88
  224. package/dist/world/terrain.d.ts +65 -1
  225. package/dist/world/terrain.js +119 -2
  226. package/dist/world.d.ts +14 -5
  227. package/dist/world.js +12 -3
  228. package/package.json +1 -1
  229. package/skills/game-design/SKILL.md +28 -0
  230. package/skills/game-design/agents/openai.yaml +4 -0
  231. package/skills/game-design/references/application-playbook.md +170 -0
  232. package/skills/game-design/references/field-guide.md +288 -0
  233. package/skills/jgengine/SKILL.md +8 -4
  234. package/skills/jgengine/api.md +182 -81
  235. package/skills/jgengine/capabilities.md +104 -2
  236. package/skills/jgengine/recipes/minimal-game.md +88 -0
  237. package/skills/jgengine-combat/SKILL.md +19 -1
  238. package/skills/jgengine-combat/api.md +75 -2
  239. package/skills/jgengine-combat/capabilities.md +61 -1
  240. package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
  241. package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
  242. package/skills/jgengine-combat/recipes/portable-stat-pools.md +122 -0
  243. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
  244. package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
  245. package/skills/jgengine-combat/reference.md +33 -4
  246. package/skills/jgengine-gameplay/SKILL.md +8 -0
  247. package/skills/jgengine-gameplay/api.md +182 -26
  248. package/skills/jgengine-gameplay/capabilities.md +96 -2
  249. package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
  250. package/skills/jgengine-gameplay/reference-systems.md +10 -5
  251. package/skills/jgengine-world/SKILL.md +13 -0
  252. package/skills/jgengine-world/api.md +453 -21
  253. package/skills/jgengine-world/capabilities.md +112 -2
  254. package/skills/jgengine-world/recipes/commanding-entities.md +128 -0
  255. package/skills/jgengine-world/reference.md +43 -4
  256. package/skills/jgengine/reference-cartridge.md +0 -72
@@ -1,5 +1,5 @@
1
1
  import type { RoadEnvironmentDescriptor } from "./features.js";
2
- import { type RoadPoint } from "./roads.js";
2
+ import { type DashExclusion, type RoadPoint } from "./roads.js";
3
3
  /**
4
4
  * Offset a centerline sideways by a signed distance along its local perpendicular — the
5
5
  * building block for lanes, curb lines, and sidewalk paths. Positive offsets fall on the
@@ -73,8 +73,110 @@ export interface ParkingSpot {
73
73
  * coordinates in the middle of the carriageway.
74
74
  */
75
75
  export declare function parkingSpots(road: RoadEnvironmentDescriptor, options?: ParkingSpotOptions): readonly ParkingSpot[];
76
+ /**
77
+ * Signed planar distance from `(x, z)` to the nearest road *surface edge* across every segment of
78
+ * every road (#1051): negative inside the asphalt (magnitude = how far in from the closest edge, so
79
+ * `-width/2` on the centerline), `~0` right at the curb, and positive off the road (the gap to the
80
+ * nearest edge). Point-to-segment distance minus the road's half width, minimised over all segments —
81
+ * the "am I on the road, and by how much" seam driving grip, tyre audio, and off-road penalties.
82
+ *
83
+ * Allocation-free per call (only scalars — no arrays or objects) so a per-tick car sim can sample it
84
+ * on the hot path; degenerate roads (fewer than two path points) contribute nothing, and with no road
85
+ * surface at all it returns {@link Number.POSITIVE_INFINITY} (everywhere is off-road).
86
+ */
87
+ export declare function distanceToRoadEdge(roads: readonly RoadEnvironmentDescriptor[], x: number, z: number): number;
88
+ /** Grip levels and blend width for {@link roadSurfaceSampler}; each field is optional and defaulted. */
89
+ export interface RoadSurfaceOptions {
90
+ /** Grip multiplier on the asphalt. Default 1. */
91
+ onRoad?: number;
92
+ /** Grip multiplier once fully off the road past the shoulder. Default 0.72. */
93
+ offRoad?: number;
94
+ /** Width (world units) of the blend band outside the curb from full to off-road grip. Default 3. */
95
+ shoulder?: number;
96
+ }
97
+ /**
98
+ * A `surfaceFriction`-compatible sampler (the `(x, z) => grip` hook a kinematic car reads each tick)
99
+ * that turns road geometry into grip (#1051): full `onRoad` grip on the asphalt, a monotonic linear ramp
100
+ * down across a `shoulder`-wide band at the curb, and flat `offRoad` grip beyond it — so a car that
101
+ * clips onto the grass loses bite progressively instead of snapping. Closes over {@link
102
+ * distanceToRoadEdge}, so the returned sampler is itself allocation-free per call.
103
+ */
104
+ export declare function roadSurfaceSampler(roads: readonly RoadEnvironmentDescriptor[], options?: RoadSurfaceOptions): (x: number, z: number) => number;
76
105
  /**
77
106
  * A deterministic point on one of a road's sidewalks at a normalized position — `side` picks the
78
107
  * band, `fraction` (0..1) picks how far along. The canonical pedestrian spawn helper.
79
108
  */
80
109
  export declare function sidewalkPoint(road: RoadEnvironmentDescriptor, side: "left" | "right", fraction: number): RoadPoint | null;
110
+ /**
111
+ * The two curb lines of a road — offset polylines running along the outer edge of the asphalt on
112
+ * each side. Mesh these with a thin darker ribbon to give the road a curb/edge strip instead of a
113
+ * hard asphalt-to-terrain seam. `inset` (default 0) nudges the line inward off the raw edge so the
114
+ * strip straddles the border.
115
+ */
116
+ export declare function curbPaths(road: RoadEnvironmentDescriptor, inset?: number): readonly [readonly RoadPoint[], readonly RoadPoint[]];
117
+ /** A single arm of a junction: the road width plus the outward direction the road leaves the center. */
118
+ export interface RoadApproach {
119
+ /** Unit vector pointing from the junction center outward along the road arm. */
120
+ direction: readonly [number, number];
121
+ /** Drivable width of the road on this arm. */
122
+ width: number;
123
+ }
124
+ /** A welded road intersection: a patch center/radius plus the arms that feed it. */
125
+ export interface RoadJunction {
126
+ /** World XZ center of the intersection. */
127
+ center: RoadPoint;
128
+ /** Radius of the covering patch (covers the crossing ribbons). */
129
+ radius: number;
130
+ /** Representative surface elevation (max of the participating roads). */
131
+ elevation: number;
132
+ /** Representative asphalt color (the widest participating road's color). */
133
+ color: string;
134
+ /** Outward arms leaving the junction — one per road direction that continues past the center. */
135
+ approaches: readonly RoadApproach[];
136
+ }
137
+ /** Options for {@link findRoadJunctions}. */
138
+ export interface RoadJunctionOptions {
139
+ /** Roads weld only when their elevations differ by at most this much. Default 0.12. */
140
+ elevationBand?: number;
141
+ /** Crossing points closer than this merge into a single junction. Default 6. */
142
+ mergeDistance?: number;
143
+ /** Hard cap on junctions returned so detection stays bounded. Default 256. */
144
+ maxJunctions?: number;
145
+ /** Extra scale on the patch radius past the widest arm's half-width. Default 1.15. */
146
+ radiusScale?: number;
147
+ }
148
+ /**
149
+ * Detect welded intersections across a set of roads: wherever two road centerlines cross (or a road
150
+ * terminates on another) within the same elevation band, emit a {@link RoadJunction} whose patch
151
+ * covers the crossing ribbons. Nearby crossings merge into one junction. Deterministic and bounded —
152
+ * O(roads² · segments²) with a `maxJunctions` cap — so a scene builds its intersections once.
153
+ */
154
+ export declare function findRoadJunctions(roads: readonly RoadEnvironmentDescriptor[], options?: RoadJunctionOptions): readonly RoadJunction[];
155
+ /** Circular marking-exclusion zones for the junctions, sized to hide the center line under each patch. */
156
+ export declare function junctionExclusions(junctions: readonly RoadJunction[], pad?: number): readonly DashExclusion[];
157
+ /** Options for {@link junctionMarkings}. */
158
+ export interface JunctionMarkingOptions {
159
+ /** Emit a stop-line bar across each approach. Default true. */
160
+ stopLines?: boolean;
161
+ /** Emit crosswalk bars at each approach. Default true. */
162
+ crosswalks?: boolean;
163
+ /** Number of zebra bars per crosswalk. Default 5. */
164
+ crosswalkBars?: number;
165
+ /** Clear gap outside the patch before markings start. Default 0.6. */
166
+ gap?: number;
167
+ /** Depth (along travel) of the crosswalk band. Default 2.4. */
168
+ crosswalkDepth?: number;
169
+ }
170
+ /** Thin marking polylines for a junction: stop-line bars and crosswalk bars, ready for {@link buildRoadRibbon}. */
171
+ export interface JunctionMarkings {
172
+ /** One bar per approach, perpendicular to travel just outside the patch. */
173
+ stopLines: readonly (readonly RoadPoint[])[];
174
+ /** Continental (parallel-to-travel) zebra bars, `crosswalkBars` per approach. */
175
+ crosswalkBars: readonly (readonly RoadPoint[])[];
176
+ }
177
+ /**
178
+ * Data-driven junction entry markings: a stop-line bar across each approach and a band of crosswalk
179
+ * bars just outside the welded patch. Each returned polyline is a thin centerline meant to be meshed
180
+ * with {@link buildRoadRibbon} at a small width. Fully skippable per-feature via the options.
181
+ */
182
+ export declare function junctionMarkings(junction: RoadJunction, options?: JunctionMarkingOptions): JunctionMarkings;
@@ -130,6 +130,61 @@ export function parkingSpots(road, options = {}) {
130
130
  }
131
131
  return spots;
132
132
  }
133
+ /**
134
+ * Signed planar distance from `(x, z)` to the nearest road *surface edge* across every segment of
135
+ * every road (#1051): negative inside the asphalt (magnitude = how far in from the closest edge, so
136
+ * `-width/2` on the centerline), `~0` right at the curb, and positive off the road (the gap to the
137
+ * nearest edge). Point-to-segment distance minus the road's half width, minimised over all segments —
138
+ * the "am I on the road, and by how much" seam driving grip, tyre audio, and off-road penalties.
139
+ *
140
+ * Allocation-free per call (only scalars — no arrays or objects) so a per-tick car sim can sample it
141
+ * on the hot path; degenerate roads (fewer than two path points) contribute nothing, and with no road
142
+ * surface at all it returns {@link Number.POSITIVE_INFINITY} (everywhere is off-road).
143
+ */
144
+ export function distanceToRoadEdge(roads, x, z) {
145
+ let best = Number.POSITIVE_INFINITY;
146
+ for (let r = 0; r < roads.length; r += 1) {
147
+ const road = roads[r];
148
+ const path = road.path;
149
+ if (path.length < 2)
150
+ continue;
151
+ const half = road.width / 2;
152
+ for (let i = 0; i < path.length - 1; i += 1) {
153
+ const a = path[i];
154
+ const b = path[i + 1];
155
+ const abx = b[0] - a[0];
156
+ const abz = b[1] - a[1];
157
+ const lengthSq = abx * abx + abz * abz;
158
+ const t = lengthSq === 0 ? 0 : Math.max(0, Math.min(1, ((x - a[0]) * abx + (z - a[1]) * abz) / lengthSq));
159
+ const px = a[0] + abx * t;
160
+ const pz = a[1] + abz * t;
161
+ const edge = Math.hypot(x - px, z - pz) - half;
162
+ if (edge < best)
163
+ best = edge;
164
+ }
165
+ }
166
+ return best;
167
+ }
168
+ /**
169
+ * A `surfaceFriction`-compatible sampler (the `(x, z) => grip` hook a kinematic car reads each tick)
170
+ * that turns road geometry into grip (#1051): full `onRoad` grip on the asphalt, a monotonic linear ramp
171
+ * down across a `shoulder`-wide band at the curb, and flat `offRoad` grip beyond it — so a car that
172
+ * clips onto the grass loses bite progressively instead of snapping. Closes over {@link
173
+ * distanceToRoadEdge}, so the returned sampler is itself allocation-free per call.
174
+ */
175
+ export function roadSurfaceSampler(roads, options = {}) {
176
+ const onRoad = options.onRoad ?? 1;
177
+ const offRoad = options.offRoad ?? 0.72;
178
+ const shoulder = options.shoulder ?? 3;
179
+ return (x, z) => {
180
+ const edge = distanceToRoadEdge(roads, x, z);
181
+ if (edge <= 0)
182
+ return onRoad;
183
+ if (shoulder <= 0 || edge >= shoulder)
184
+ return offRoad;
185
+ return onRoad + (offRoad - onRoad) * (edge / shoulder);
186
+ };
187
+ }
133
188
  /**
134
189
  * A deterministic point on one of a road's sidewalks at a normalized position — `side` picks the
135
190
  * band, `fraction` (0..1) picks how far along. The canonical pedestrian spawn helper.
@@ -142,3 +197,221 @@ export function sidewalkPoint(road, side, fraction) {
142
197
  const along = Math.max(0, Math.min(1, fraction)) * pathLength(path);
143
198
  return pointAlong(path, along).point;
144
199
  }
200
+ /**
201
+ * The two curb lines of a road — offset polylines running along the outer edge of the asphalt on
202
+ * each side. Mesh these with a thin darker ribbon to give the road a curb/edge strip instead of a
203
+ * hard asphalt-to-terrain seam. `inset` (default 0) nudges the line inward off the raw edge so the
204
+ * strip straddles the border.
205
+ */
206
+ export function curbPaths(road, inset = 0) {
207
+ const edge = road.width / 2 - inset;
208
+ return [offsetPath(road.path, edge), offsetPath(road.path, -edge)];
209
+ }
210
+ const EPS = 1e-6;
211
+ /** Intersection point of segments a→b and c→d, or null when they don't cross (endpoints included). */
212
+ function segmentIntersection(a, b, c, d) {
213
+ const r0 = b[0] - a[0];
214
+ const r1 = b[1] - a[1];
215
+ const s0 = d[0] - c[0];
216
+ const s1 = d[1] - c[1];
217
+ const denom = r0 * s1 - r1 * s0;
218
+ if (Math.abs(denom) < EPS)
219
+ return null; // parallel or degenerate
220
+ const t = ((c[0] - a[0]) * s1 - (c[1] - a[1]) * s0) / denom;
221
+ const u = ((c[0] - a[0]) * r1 - (c[1] - a[1]) * r0) / denom;
222
+ if (t < -EPS || t > 1 + EPS || u < -EPS || u > 1 + EPS)
223
+ return null;
224
+ return [a[0] + r0 * t, a[1] + r1 * t];
225
+ }
226
+ function unit(dx, dz) {
227
+ const len = Math.hypot(dx, dz) || 1;
228
+ return [dx / len, dz / len];
229
+ }
230
+ /**
231
+ * Add the arms a road contributes at junction point `p`: the road continues past `p` in each
232
+ * direction that still has centerline beyond it, so a mid-path crossing yields two opposing arms
233
+ * and a road that terminates at `p` (a T-junction) yields only the one inward arm.
234
+ */
235
+ function armsAt(path, p, width, roadIndex) {
236
+ const arms = [];
237
+ // Nearest vertex "before" (toward path start) and "after" (toward path end) of p.
238
+ // Walk the path measuring where p falls; use the two adjacent tangents.
239
+ let hitSeg = -1;
240
+ for (let i = 0; i < path.length - 1; i += 1) {
241
+ const a = path[i];
242
+ const b = path[i + 1];
243
+ const abx = b[0] - a[0];
244
+ const abz = b[1] - a[1];
245
+ const lengthSq = abx * abx + abz * abz;
246
+ const t = lengthSq === 0 ? 0 : ((p[0] - a[0]) * abx + (p[1] - a[1]) * abz) / lengthSq;
247
+ const px = a[0] + abx * Math.max(0, Math.min(1, t));
248
+ const pz = a[1] + abz * Math.max(0, Math.min(1, t));
249
+ if (Math.hypot(p[0] - px, p[1] - pz) < 1e-3) {
250
+ hitSeg = i;
251
+ break;
252
+ }
253
+ }
254
+ if (hitSeg < 0)
255
+ return arms;
256
+ const a = path[hitSeg];
257
+ const b = path[hitSeg + 1];
258
+ const forward = unit(b[0] - a[0], b[1] - a[1]);
259
+ const atStart = Math.hypot(p[0] - path[0][0], p[1] - path[0][1]) < 1e-3;
260
+ const atEnd = Math.hypot(p[0] - path[path.length - 1][0], p[1] - path[path.length - 1][1]) < 1e-3;
261
+ // Arm toward path end exists unless p sits exactly at the end.
262
+ if (!atEnd)
263
+ arms.push({ roadIndex, direction: forward, width });
264
+ // Arm toward path start exists unless p sits exactly at the start.
265
+ if (!atStart)
266
+ arms.push({ roadIndex, direction: [-forward[0], -forward[1]], width });
267
+ return arms;
268
+ }
269
+ function mergeArm(arms, arm) {
270
+ for (const existing of arms) {
271
+ if (existing.roadIndex === arm.roadIndex) {
272
+ const dot = existing.direction[0] * arm.direction[0] + existing.direction[1] * arm.direction[1];
273
+ if (dot > 0.985)
274
+ return; // same road, near-identical direction — already have this arm
275
+ }
276
+ }
277
+ arms.push(arm);
278
+ }
279
+ /**
280
+ * Detect welded intersections across a set of roads: wherever two road centerlines cross (or a road
281
+ * terminates on another) within the same elevation band, emit a {@link RoadJunction} whose patch
282
+ * covers the crossing ribbons. Nearby crossings merge into one junction. Deterministic and bounded —
283
+ * O(roads² · segments²) with a `maxJunctions` cap — so a scene builds its intersections once.
284
+ */
285
+ export function findRoadJunctions(roads, options = {}) {
286
+ const elevationBand = options.elevationBand ?? 0.12;
287
+ const mergeDistance = options.mergeDistance ?? 6;
288
+ const maxJunctions = options.maxJunctions ?? 256;
289
+ const radiusScale = options.radiusScale ?? 1.15;
290
+ const mergeSq = mergeDistance * mergeDistance;
291
+ const candidates = [];
292
+ const emit = (p, ri, ci) => {
293
+ if (candidates.length >= maxJunctions && findNear(candidates, p, mergeSq) === null)
294
+ return;
295
+ const roadA = roads[ri];
296
+ const roadB = roads[ci];
297
+ const half = Math.max(roadA.width, roadB.width) / 2;
298
+ const radius = half * radiusScale;
299
+ const elevation = Math.max(roadA.elevation, roadB.elevation);
300
+ const wider = roadA.width >= roadB.width ? roadA : roadB;
301
+ const near = findNear(candidates, p, mergeSq);
302
+ const arms = [
303
+ ...armsAt(roadA.path, p, roadA.width, ri),
304
+ ...armsAt(roadB.path, p, roadB.width, ci),
305
+ ];
306
+ if (near === null) {
307
+ const merged = [];
308
+ for (const arm of arms)
309
+ mergeArm(merged, arm);
310
+ candidates.push({
311
+ x: p[0],
312
+ z: p[1],
313
+ radius,
314
+ elevation,
315
+ color: wider.color,
316
+ colorWidth: wider.width,
317
+ arms: merged,
318
+ });
319
+ }
320
+ else {
321
+ near.radius = Math.max(near.radius, radius);
322
+ near.elevation = Math.max(near.elevation, elevation);
323
+ if (wider.width > near.colorWidth) {
324
+ near.color = wider.color;
325
+ near.colorWidth = wider.width;
326
+ }
327
+ for (const arm of arms)
328
+ mergeArm(near.arms, arm);
329
+ }
330
+ };
331
+ for (let i = 0; i < roads.length; i += 1) {
332
+ const roadA = roads[i];
333
+ if (roadA.path.length < 2)
334
+ continue;
335
+ for (let j = i + 1; j < roads.length; j += 1) {
336
+ const roadB = roads[j];
337
+ if (roadB.path.length < 2)
338
+ continue;
339
+ if (Math.abs(roadA.elevation - roadB.elevation) > elevationBand)
340
+ continue;
341
+ for (let si = 0; si < roadA.path.length - 1; si += 1) {
342
+ for (let sj = 0; sj < roadB.path.length - 1; sj += 1) {
343
+ const hit = segmentIntersection(roadA.path[si], roadA.path[si + 1], roadB.path[sj], roadB.path[sj + 1]);
344
+ if (hit !== null)
345
+ emit(hit, i, j);
346
+ }
347
+ }
348
+ }
349
+ }
350
+ return candidates.map((c) => ({
351
+ center: [c.x, c.z],
352
+ radius: c.radius,
353
+ elevation: c.elevation,
354
+ color: c.color,
355
+ approaches: c.arms.map((arm) => ({ direction: arm.direction, width: arm.width })),
356
+ }));
357
+ }
358
+ function findNear(candidates, p, mergeSq) {
359
+ for (const c of candidates) {
360
+ const dx = c.x - p[0];
361
+ const dz = c.z - p[1];
362
+ if (dx * dx + dz * dz <= mergeSq)
363
+ return c;
364
+ }
365
+ return null;
366
+ }
367
+ /** Circular marking-exclusion zones for the junctions, sized to hide the center line under each patch. */
368
+ export function junctionExclusions(junctions, pad = 0.5) {
369
+ return junctions.map((j) => ({ center: j.center, radius: j.radius + pad }));
370
+ }
371
+ /**
372
+ * Data-driven junction entry markings: a stop-line bar across each approach and a band of crosswalk
373
+ * bars just outside the welded patch. Each returned polyline is a thin centerline meant to be meshed
374
+ * with {@link buildRoadRibbon} at a small width. Fully skippable per-feature via the options.
375
+ */
376
+ export function junctionMarkings(junction, options = {}) {
377
+ const wantStop = options.stopLines ?? true;
378
+ const wantCross = options.crosswalks ?? true;
379
+ const barCount = Math.max(1, Math.floor(options.crosswalkBars ?? 5));
380
+ const gap = options.gap ?? 0.6;
381
+ const depth = options.crosswalkDepth ?? 2.4;
382
+ const [cx, cz] = junction.center;
383
+ const stopLines = [];
384
+ const crosswalkBars = [];
385
+ for (const approach of junction.approaches) {
386
+ const [dx, dz] = approach.direction;
387
+ // Across-the-road axis, perpendicular to travel.
388
+ const ax = -dz;
389
+ const az = dx;
390
+ const half = approach.width / 2;
391
+ const stopDist = junction.radius + gap;
392
+ if (wantStop) {
393
+ const px = cx + dx * stopDist;
394
+ const pz = cz + dz * stopDist;
395
+ stopLines.push([
396
+ [px + ax * half, pz + az * half],
397
+ [px - ax * half, pz - az * half],
398
+ ]);
399
+ }
400
+ if (wantCross) {
401
+ const near = stopDist + 0.4;
402
+ const barWidth = half * 2 * 0.16;
403
+ const usable = half * 2 - barWidth;
404
+ for (let b = 0; b < barCount; b += 1) {
405
+ const frac = barCount === 1 ? 0 : b / (barCount - 1) - 0.5;
406
+ const offset = frac * usable;
407
+ const bx = cx + ax * offset;
408
+ const bz = cz + az * offset;
409
+ crosswalkBars.push([
410
+ [bx + dx * near, bz + dz * near],
411
+ [bx + dx * (near + depth), bz + dz * (near + depth)],
412
+ ]);
413
+ }
414
+ }
415
+ }
416
+ return { stopLines, crosswalkBars };
417
+ }
@@ -218,13 +218,18 @@ export function createEditableTerrain(config) {
218
218
  record?.(index, before, surface);
219
219
  return true;
220
220
  }
221
- function paintSurface(edit, record) {
222
- const surface = edit.surface ?? null;
221
+ /**
222
+ * Visits every cell in a brush footprint on the CELL grid, yielding each cell's flat `index`, its
223
+ * {@link brushWeight} `weight`, world center (`wx`,`wz`), and raw `dist` from the brush center.
224
+ * Shared by the cell-grid brushes ({@link paintSurface}, {@link blendPaint}); each applies its own
225
+ * inclusion test on the yielded values (paint uses `dist <= radius`, blend uses `weight > 0`).
226
+ */
227
+ function forEachBrushCell(edit, visit) {
228
+ const falloff = edit.falloff ?? "smooth";
223
229
  const shape = edit.shape ?? "circle";
224
230
  const rCells = Math.ceil(edit.radius / cellSize) + 1;
225
231
  const cx = gridX(edit.center[0]);
226
232
  const cz = gridZ(edit.center[1]);
227
- let painted = 0;
228
233
  for (let iz = -rCells; iz <= rCells; iz += 1) {
229
234
  const gz = Math.floor(cz) + iz;
230
235
  if (gz < 0 || gz >= rows)
@@ -233,15 +238,25 @@ export function createEditableTerrain(config) {
233
238
  const gx = Math.floor(cx) + ix;
234
239
  if (gx < 0 || gx >= cols)
235
240
  continue;
236
- const dx = cellWorldX(gx) - edit.center[0];
237
- const dz = cellWorldZ(gz) - edit.center[1];
241
+ const wx = cellWorldX(gx);
242
+ const wz = cellWorldZ(gz);
243
+ const dx = wx - edit.center[0];
244
+ const dz = wz - edit.center[1];
238
245
  const dist = shape === "square" ? Math.max(Math.abs(dx), Math.abs(dz)) : Math.hypot(dx, dz);
239
- if (dist > edit.radius)
240
- continue;
241
- if (writeSurface(gz * cols + gx, surface, record))
242
- painted += 1;
246
+ visit(gz * cols + gx, brushWeight(dist, edit.radius, falloff), wx, wz, dist);
243
247
  }
244
248
  }
249
+ }
250
+ function paintSurface(edit, record) {
251
+ const surface = edit.surface ?? null;
252
+ let painted = 0;
253
+ // Binary inclusion by raw distance (includes dist == radius), independent of falloff shaping.
254
+ forEachBrushCell(edit, (index, _weight, _wx, _wz, dist) => {
255
+ if (dist > edit.radius)
256
+ return;
257
+ if (writeSurface(index, surface, record))
258
+ painted += 1;
259
+ });
245
260
  return painted;
246
261
  }
247
262
  function localNormal(wx, wz) {
@@ -371,31 +386,14 @@ export function createEditableTerrain(config) {
371
386
  const target = layers.findIndex((entry) => entry.surface === surface);
372
387
  if (target < 0)
373
388
  return 0;
374
- const falloff = edit.falloff ?? "smooth";
375
- const shape = edit.shape ?? "circle";
376
389
  const strength = edit.strength ?? 1;
377
- const rCells = Math.ceil(edit.radius / cellSize) + 1;
378
- const cx = gridX(edit.center[0]);
379
- const cz = gridZ(edit.center[1]);
380
390
  let painted = 0;
381
- for (let iz = -rCells; iz <= rCells; iz += 1) {
382
- const gz = Math.floor(cz) + iz;
383
- if (gz < 0 || gz >= rows)
384
- continue;
385
- for (let ix = -rCells; ix <= rCells; ix += 1) {
386
- const gx = Math.floor(cx) + ix;
387
- if (gx < 0 || gx >= cols)
388
- continue;
389
- const dx = cellWorldX(gx) - edit.center[0];
390
- const dz = cellWorldZ(gz) - edit.center[1];
391
- const dist = shape === "square" ? Math.max(Math.abs(dx), Math.abs(dz)) : Math.hypot(dx, dz);
392
- const weight = brushWeight(dist, edit.radius, falloff);
393
- if (weight <= 0)
394
- continue;
395
- if (blendCell(gz * cols + gx, target, weight * strength, record))
396
- painted += 1;
397
- }
398
- }
391
+ forEachBrushCell(edit, (index, weight) => {
392
+ if (weight <= 0)
393
+ return;
394
+ if (blendCell(index, target, weight * strength, record))
395
+ painted += 1;
396
+ });
399
397
  return painted;
400
398
  }
401
399
  function runHeightEdit(edit, record) {
@@ -545,23 +543,29 @@ export function createEditableTerrain(config) {
545
543
  };
546
544
  return field;
547
545
  }
548
- /** Opens a stroke recorder over `terrain`; stamp edits into it, then read one net delta. */
549
- export function beginTerraformStroke(terrain) {
546
+ /**
547
+ * The shared first/latest Map accumulator behind every `begin*Stroke`. Records each key's first
548
+ * `before` and latest `after` across many stamps, then assembles the net changed entries (skipping
549
+ * keys whose latest equals their first). Each stroke supplies its own recording call via `recordStamp`
550
+ * and wraps {@link StrokeChanges} into its concrete delta shape.
551
+ */
552
+ function createStroke(recordStamp) {
550
553
  const first = new Map();
551
554
  const latest = new Map();
552
555
  return {
553
556
  stamp(edit) {
554
- return terrain.applyRecording(edit, (index, before, after) => {
557
+ return recordStamp(edit, (index, before, after) => {
555
558
  if (!first.has(index))
556
559
  first.set(index, before);
557
560
  latest.set(index, after);
558
561
  });
559
562
  },
560
- delta() {
563
+ changes() {
561
564
  const indices = [];
562
565
  const before = [];
563
566
  const after = [];
564
567
  for (const [index, next] of latest) {
568
+ // Every latest key was set in `first` on the same callback, so this is always present.
565
569
  const prev = first.get(index);
566
570
  if (next === prev)
567
571
  continue;
@@ -576,35 +580,22 @@ export function beginTerraformStroke(terrain) {
576
580
  },
577
581
  };
578
582
  }
583
+ /** Opens a stroke recorder over `terrain`; stamp edits into it, then read one net delta. */
584
+ export function beginTerraformStroke(terrain) {
585
+ const stroke = createStroke((edit, record) => terrain.applyRecording(edit, record));
586
+ return {
587
+ stamp: stroke.stamp,
588
+ delta: () => stroke.changes(),
589
+ isEmpty: stroke.isEmpty,
590
+ };
591
+ }
579
592
  /** Opens a paint-stroke recorder over `terrain`; stamp paint edits into it, then read one net delta. */
580
593
  export function beginSurfaceStroke(terrain) {
581
- const first = new Map();
582
- const latest = new Map();
594
+ const stroke = createStroke((edit, record) => terrain.paintRecording(edit, record));
583
595
  return {
584
- stamp(edit) {
585
- return terrain.paintRecording(edit, (index, before, after) => {
586
- if (!first.has(index))
587
- first.set(index, before);
588
- latest.set(index, after);
589
- });
590
- },
591
- delta() {
592
- const indices = [];
593
- const before = [];
594
- const after = [];
595
- for (const [index, next] of latest) {
596
- const prev = first.get(index) ?? null;
597
- if (next === prev)
598
- continue;
599
- indices.push(index);
600
- before.push(prev);
601
- after.push(next);
602
- }
603
- return { indices, before, after };
604
- },
605
- isEmpty() {
606
- return latest.size === 0;
607
- },
596
+ stamp: stroke.stamp,
597
+ delta: () => stroke.changes(),
598
+ isEmpty: stroke.isEmpty,
608
599
  };
609
600
  }
610
601
  /** Returns a new snapshot with a surface delta's `after` ids applied (copy-on-write). */
@@ -700,33 +691,12 @@ export function revertWeightDeltaFromSnapshot(snapshot, delta) {
700
691
  * @internal — the stroke recorder behind the blend brush and the `blendTerrain` command.
701
692
  */
702
693
  export function beginBlendStroke(terrain) {
703
- const first = new Map();
704
- const latest = new Map();
694
+ const stroke = createStroke((edit, record) => terrain.blendRecording(edit, record));
705
695
  return {
706
- stamp(edit) {
707
- return terrain.blendRecording(edit, (index, before, after) => {
708
- if (!first.has(index))
709
- first.set(index, before);
710
- latest.set(index, after);
711
- });
712
- },
713
- delta() {
714
- const indices = [];
715
- const before = [];
716
- const after = [];
717
- for (const [index, next] of latest) {
718
- const prev = first.get(index);
719
- if (next === prev)
720
- continue;
721
- indices.push(index);
722
- before.push(prev);
723
- after.push(next);
724
- }
725
- return { layerCount: terrain.layers.length, indices, before, after };
726
- },
727
- isEmpty() {
728
- return latest.size === 0;
729
- },
696
+ stamp: stroke.stamp,
697
+ // layerCount is read at delta() time, guarding replay after the layer stack changed shape.
698
+ delta: () => ({ layerCount: terrain.layers.length, ...stroke.changes() }),
699
+ isEmpty: stroke.isEmpty,
730
700
  };
731
701
  }
732
702
  /**