@jgengine/core 0.17.0 → 0.18.1

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 (174) hide show
  1. package/CHANGELOG.md +130 -2
  2. package/dist/ai/decisionGraph.d.ts +52 -0
  3. package/dist/ai/decisionGraph.js +82 -0
  4. package/dist/ai/mobBrainGraph.d.ts +3 -0
  5. package/dist/ai/mobBrainGraph.js +25 -0
  6. package/dist/ai/tacticalQueries.d.ts +30 -0
  7. package/dist/ai/tacticalQueries.js +45 -0
  8. package/dist/anim/animGraph.d.ts +144 -0
  9. package/dist/anim/animGraph.js +286 -0
  10. package/dist/anim/ikSolver.d.ts +51 -0
  11. package/dist/anim/ikSolver.js +124 -0
  12. package/dist/anim/locomotionGraph.d.ts +26 -0
  13. package/dist/anim/locomotionGraph.js +33 -0
  14. package/dist/assets/spriteAtlas.d.ts +21 -0
  15. package/dist/assets/spriteAtlas.js +103 -0
  16. package/dist/audio/audioFalloff.d.ts +10 -0
  17. package/dist/economy/currency.d.ts +16 -2
  18. package/dist/economy/currency.js +55 -14
  19. package/dist/economy/wallet.d.ts +5 -4
  20. package/dist/economy/wallet.js +8 -4
  21. package/dist/editor/commandApply.js +1 -0
  22. package/dist/editor/commands.d.ts +4 -1
  23. package/dist/editor/document.d.ts +2 -1
  24. package/dist/editor/document.js +89 -0
  25. package/dist/editor/environment.d.ts +3 -0
  26. package/dist/editor/environment.js +16 -0
  27. package/dist/editor/index.d.ts +4 -2
  28. package/dist/editor/index.js +1 -1
  29. package/dist/editor/types.d.ts +16 -0
  30. package/dist/game/cameraConfig.d.ts +8 -0
  31. package/dist/game/chat.d.ts +22 -1
  32. package/dist/game/chat.js +48 -19
  33. package/dist/game/controlGate.d.ts +6 -0
  34. package/dist/game/controlGate.js +30 -1
  35. package/dist/game/defineGame.d.ts +10 -1
  36. package/dist/game/events.d.ts +8 -1
  37. package/dist/game/pixelPerfect.d.ts +25 -0
  38. package/dist/game/pixelPerfect.js +29 -0
  39. package/dist/game/playableGame.d.ts +97 -3
  40. package/dist/game/systemRuntime.js +6 -1
  41. package/dist/input/actionBindings.d.ts +2 -1
  42. package/dist/input/actionBindings.js +10 -8
  43. package/dist/input/actionContexts.d.ts +21 -0
  44. package/dist/input/actionContexts.js +56 -0
  45. package/dist/input/gamepadModel.d.ts +45 -0
  46. package/dist/input/gamepadModel.js +69 -0
  47. package/dist/meta/changelog.d.ts +1 -1
  48. package/dist/meta/changelog.js +103 -1
  49. package/dist/movement/characterController.d.ts +76 -0
  50. package/dist/movement/characterController.js +216 -0
  51. package/dist/movement/freeFlight.d.ts +119 -0
  52. package/dist/movement/freeFlight.js +294 -0
  53. package/dist/movement/playerMovement.d.ts +10 -2
  54. package/dist/movement/playerMovement.js +218 -4
  55. package/dist/movement/serverStep.d.ts +5 -0
  56. package/dist/movement/serverStep.js +6 -0
  57. package/dist/movement/solidObstacles.d.ts +30 -7
  58. package/dist/movement/solidObstacles.js +27 -4
  59. package/dist/multiplayer/poseSyncGate.d.ts +3 -1
  60. package/dist/multiplayer/poseSyncGate.js +9 -1
  61. package/dist/multiplayer/presenceContract.d.ts +6 -0
  62. package/dist/multiplayer/presenceModel.d.ts +2 -0
  63. package/dist/multiplayer/presenceModel.js +11 -0
  64. package/dist/nav/navMesh.d.ts +33 -0
  65. package/dist/nav/navMesh.js +164 -0
  66. package/dist/physics/flightDynamics.d.ts +8 -0
  67. package/dist/physics/kinematicVehicle.d.ts +6 -1
  68. package/dist/physics/physicsBackend.d.ts +191 -0
  69. package/dist/physics/physicsBackend.js +49 -0
  70. package/dist/physics/physicsBackendConformance.d.ts +27 -0
  71. package/dist/physics/physicsBackendConformance.js +117 -0
  72. package/dist/physics/physicsWorldBackend.d.ts +44 -0
  73. package/dist/physics/physicsWorldBackend.js +485 -0
  74. package/dist/physics/worldColliders.d.ts +13 -0
  75. package/dist/physics/worldColliders.js +96 -0
  76. package/dist/random/rng.d.ts +12 -1
  77. package/dist/random/rng.js +20 -3
  78. package/dist/render/environment.d.ts +23 -0
  79. package/dist/render/environment.js +1 -0
  80. package/dist/render/lookPreset.d.ts +11 -5
  81. package/dist/render/lookPreset.js +29 -4
  82. package/dist/render/postProcessing.d.ts +10 -1
  83. package/dist/render/sprite2d.d.ts +38 -0
  84. package/dist/render/sprite2d.js +40 -0
  85. package/dist/runtime/adapter.d.ts +20 -1
  86. package/dist/runtime/adapter.js +8 -1
  87. package/dist/runtime/commandInput.d.ts +15 -0
  88. package/dist/runtime/commandInput.js +13 -0
  89. package/dist/runtime/commandRunner.d.ts +44 -3
  90. package/dist/runtime/commandRunner.js +41 -3
  91. package/dist/runtime/gameContext.js +11 -1
  92. package/dist/runtime/gameContextTypes.d.ts +14 -4
  93. package/dist/runtime/gameRuntime.d.ts +33 -4
  94. package/dist/runtime/gameRuntime.js +26 -5
  95. package/dist/runtime/headlessRunner.d.ts +10 -0
  96. package/dist/runtime/headlessRunner.js +26 -8
  97. package/dist/runtime/hostPolicy.d.ts +3 -2
  98. package/dist/runtime/hostPolicy.js +8 -10
  99. package/dist/runtime/hostedGameRunner.js +34 -6
  100. package/dist/runtime/hostedWorldSession.d.ts +12 -16
  101. package/dist/runtime/hostedWorldSession.js +29 -6
  102. package/dist/runtime/hostedWorldStore.d.ts +20 -0
  103. package/dist/runtime/hostedWorldStore.js +1 -0
  104. package/dist/runtime/inputRecorder.d.ts +30 -0
  105. package/dist/runtime/inputRecorder.js +59 -0
  106. package/dist/runtime/inputSnapshot.d.ts +8 -0
  107. package/dist/runtime/inputSnapshot.js +1 -0
  108. package/dist/runtime/poseInterpolation.d.ts +36 -0
  109. package/dist/runtime/poseInterpolation.js +91 -0
  110. package/dist/runtime/prediction.d.ts +45 -0
  111. package/dist/runtime/prediction.js +62 -0
  112. package/dist/runtime/simContext.d.ts +45 -0
  113. package/dist/runtime/simContext.js +65 -0
  114. package/dist/runtime/simLoop.d.ts +52 -0
  115. package/dist/runtime/simLoop.js +72 -0
  116. package/dist/runtime/simSnapshot.d.ts +32 -0
  117. package/dist/runtime/simSnapshot.js +60 -0
  118. package/dist/runtime/snapshot.d.ts +8 -0
  119. package/dist/runtime/snapshotBuffer.d.ts +33 -0
  120. package/dist/runtime/snapshotBuffer.js +58 -0
  121. package/dist/runtime/transport.d.ts +38 -2
  122. package/dist/runtime/worldChunks.d.ts +2 -0
  123. package/dist/runtime/worldChunks.js +14 -0
  124. package/dist/runtime/worldProjection.d.ts +9 -0
  125. package/dist/runtime/worldProjection.js +17 -1
  126. package/dist/runtime/worldSnapshot.d.ts +6 -0
  127. package/dist/runtime/worldSnapshot.js +3 -0
  128. package/dist/scene/behaviorRuntime.d.ts +19 -1
  129. package/dist/scene/behaviorRuntime.js +55 -4
  130. package/dist/scene/behaviors.d.ts +8 -1
  131. package/dist/scene/builtinSceneKinds.js +10 -0
  132. package/dist/sensor/perception.d.ts +60 -0
  133. package/dist/sensor/perception.js +142 -0
  134. package/dist/settings/graphicsProfile.d.ts +19 -0
  135. package/dist/settings/graphicsProfile.js +36 -0
  136. package/dist/time/accrueSince.d.ts +10 -0
  137. package/dist/time/accrueSince.js +9 -0
  138. package/dist/time/rateWindow.d.ts +16 -0
  139. package/dist/time/rateWindow.js +14 -0
  140. package/dist/time/serverTick.d.ts +21 -3
  141. package/dist/time/serverTick.js +14 -13
  142. package/dist/vfx/particleDirector.d.ts +68 -0
  143. package/dist/vfx/particleDirector.js +81 -0
  144. package/dist/world/buildings.d.ts +12 -0
  145. package/dist/world/buildings.js +43 -3
  146. package/dist/world/cityContent.js +103 -25
  147. package/dist/world/cityKind.d.ts +132 -0
  148. package/dist/world/cityKind.js +315 -15
  149. package/dist/world/features.d.ts +27 -1
  150. package/dist/world/features.js +7 -2
  151. package/dist/world/placement.d.ts +7 -0
  152. package/dist/world/placement.js +2 -0
  153. package/dist/world/placementController.d.ts +1 -1
  154. package/dist/world/placementController.js +7 -0
  155. package/dist/world/raceCircuit.d.ts +168 -0
  156. package/dist/world/raceCircuit.js +797 -0
  157. package/dist/world/terrain.d.ts +5 -1
  158. package/dist/world/terrain.js +5 -0
  159. package/dist/world/territory.d.ts +97 -0
  160. package/dist/world/territory.js +182 -0
  161. package/dist/world.d.ts +5 -3
  162. package/dist/world.js +4 -2
  163. package/package.json +1 -1
  164. package/skills/ce-handoff/SKILL.md +1 -1
  165. package/skills/game-design/SKILL.md +11 -1
  166. package/skills/jgengine/SKILL.md +2 -0
  167. package/skills/jgengine/api.md +98 -22
  168. package/skills/jgengine/capabilities.md +28 -0
  169. package/skills/jgengine-gameplay/SKILL.md +10 -0
  170. package/skills/jgengine-gameplay/api.md +40 -18
  171. package/skills/jgengine-gameplay/capabilities.md +12 -0
  172. package/skills/jgengine-world/SKILL.md +6 -0
  173. package/skills/jgengine-world/api.md +312 -12
  174. package/skills/jgengine-world/capabilities.md +102 -0
@@ -18,8 +18,10 @@
18
18
  * (`cityContent`) — setback tower tiers, gabled houses, gambrel barns, domed silos. Blocks left
19
19
  * unbuilt become plazas/greens/meadows by band, or crop-row farm fields; slivers and block
20
20
  * interiors classify as buffers and courtyards, never leftover rectangles. Street
21
- * furniture (lights, species-mixed trees, estate hedges, driveways, parking lots) resolves as
22
- * bounded, seeded data hooked to the network. Child `cityzone` volumes override the band/mix
21
+ * furniture (lights, species-mixed trees, estate hedges, driveways, parking lots, parked vehicles,
22
+ * junction signals and sign posts) resolves as bounded, seeded data hooked to the network. Turn `race` on and the district also hands back a
23
+ * {@link ResolvedRace}: a closed lap lifted out of its OWN streets with every side street leaving it
24
+ * sealed off — the Monaco / open-world street-race model, not a separate track. Child `cityzone` volumes override the band/mix
23
25
  * locally, so hand-placed intent wins over the profile. Named presets (Manhattan, Los Angeles,
24
26
  * Beverly Hills, rural Ohio…) ship as plain slider bundles on the schema. Pure config + resolver
25
27
  * here; the instanced renderer lives in the `shell`. Everything is deterministic per seed and
@@ -31,7 +33,8 @@ import { seededStreams } from "../random/rng.js";
31
33
  import { parseParams, registerSceneKind, } from "../scene/sceneKinds.js";
32
34
  import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_STYLE } from "./buildings.js";
33
35
  import { buildLotPieces, pickClass, pickSpecies, rollClassPlacement, zoneBand, zoneMetric, } from "./cityContent.js";
34
- import { furnitureSpots } from "./streets.js";
36
+ import { circuitKerbs, extractCircuitRoute, } from "./raceCircuit.js";
37
+ import { furnitureSpots, parkingSpots } from "./streets.js";
35
38
  import { CITY_BUILDING_BUDGET, budgetWarning, placedCoverage } from "./scatterCoverage.js";
36
39
  import { generateStreets, streetNetworkMode, } from "./streetGenerator.js";
37
40
  import { buildablePolygon, carveCorridors, conformPolygonToRing, cutParcel, extractBlocks, isSliverBlock, pointsInPolygon, RingWalker, } from "./cityBlocks.js";
@@ -51,6 +54,11 @@ export const CITY_DEFAULTS = {
51
54
  minCurveRadius: 24,
52
55
  minTurnAngle: 0,
53
56
  maxTurnAngle: 150,
57
+ outline: 0,
58
+ residentialBranches: 0,
59
+ elevation: 0,
60
+ maxGrade: 0.07,
61
+ compactness: 0,
54
62
  fabric: true,
55
63
  tunnels: true,
56
64
  blockSize: 48,
@@ -92,6 +100,8 @@ export const CITY_DEFAULTS = {
92
100
  { item: "conifer", weight: 1 },
93
101
  ],
94
102
  lightDensity: 0.5,
103
+ parkedCars: 0.55,
104
+ signals: 0.7,
95
105
  hedges: true,
96
106
  driveways: true,
97
107
  parking: true,
@@ -102,6 +112,14 @@ export const CITY_DEFAULTS = {
102
112
  bridgeStyle: "arch",
103
113
  sidewalks: true,
104
114
  sidewalkWidth: 1.9,
115
+ race: false,
116
+ raceLapLength: 1200,
117
+ raceArterial: 0.7,
118
+ raceCheckpoints: 12,
119
+ raceLaps: 3,
120
+ raceSeals: true,
121
+ raceGridSlots: 12,
122
+ raceCornerRadius: 9,
105
123
  style: DEFAULT_BUILDING_STYLE,
106
124
  buildingKit: "",
107
125
  seed: "",
@@ -110,6 +128,7 @@ export const CITY_DEFAULTS = {
110
128
  export const CITY_SCHEMA = {
111
129
  groups: [
112
130
  { id: "layout", label: "Layout" },
131
+ { id: "race", label: "Race" },
113
132
  { id: "placement", label: "Placement" },
114
133
  { id: "zoning", label: "Zoning" },
115
134
  { id: "buildings", label: "Buildings" },
@@ -125,6 +144,11 @@ export const CITY_SCHEMA = {
125
144
  { type: "range", key: "minCurveRadius", label: "min curve radius", group: "layout", min: 6, max: 120, step: 1, default: CITY_DEFAULTS.minCurveRadius, unit: "m" },
126
145
  { type: "range", key: "minTurnAngle", label: "min turn angle", group: "layout", min: 0, max: 60, step: 1, default: CITY_DEFAULTS.minTurnAngle, unit: "°" },
127
146
  { type: "range", key: "maxTurnAngle", label: "max turn angle", group: "layout", min: 30, max: 180, step: 1, default: CITY_DEFAULTS.maxTurnAngle, unit: "°" },
147
+ { type: "range", key: "outline", label: "outline", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.outline },
148
+ { type: "range", key: "residentialBranches", label: "residential branches", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.residentialBranches },
149
+ { type: "range", key: "elevation", label: "road elevation", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.elevation },
150
+ { type: "range", key: "maxGrade", label: "max grade", group: "layout", min: 0.01, max: 0.3, step: 0.005, default: CITY_DEFAULTS.maxGrade },
151
+ { type: "range", key: "compactness", label: "circuit compactness", group: "layout", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.compactness },
128
152
  { type: "range", key: "blockSize", label: "block size", group: "layout", min: 20, max: 140, step: 1, default: CITY_DEFAULTS.blockSize, unit: "m" },
129
153
  { type: "range", key: "blockAspect", label: "block aspect", group: "layout", min: 1, max: 3, step: 0.05, default: CITY_DEFAULTS.blockAspect },
130
154
  { type: "range", key: "openSpace", label: "open space", group: "layout", min: 0, max: 0.9, step: 0.01, default: CITY_DEFAULTS.openSpace },
@@ -153,6 +177,14 @@ export const CITY_SCHEMA = {
153
177
  { type: "bool", key: "sidewalks", label: "sidewalks", group: "layout", default: CITY_DEFAULTS.sidewalks },
154
178
  { type: "range", key: "sidewalkWidth", label: "sidewalk width", group: "layout", min: 1, max: 4, step: 0.1, default: CITY_DEFAULTS.sidewalkWidth, unit: "m" },
155
179
  { type: "action", key: "layoutRandomize", label: "randomize layout", group: "layout", action: "randomize" },
180
+ { type: "bool", key: "race", label: "street race", group: "race", default: CITY_DEFAULTS.race },
181
+ { type: "range", key: "raceLapLength", label: "lap length", group: "race", min: 200, max: 6000, step: 50, default: CITY_DEFAULTS.raceLapLength, unit: "m" },
182
+ { type: "range", key: "raceArterial", label: "arterial bias", group: "race", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.raceArterial },
183
+ { type: "range", key: "raceCheckpoints", label: "checkpoints", group: "race", min: 2, max: 48, step: 1, default: CITY_DEFAULTS.raceCheckpoints },
184
+ { type: "range", key: "raceLaps", label: "laps", group: "race", min: 1, max: 20, step: 1, default: CITY_DEFAULTS.raceLaps },
185
+ { type: "bool", key: "raceSeals", label: "seal side streets", group: "race", default: CITY_DEFAULTS.raceSeals },
186
+ { type: "range", key: "raceGridSlots", label: "grid slots", group: "race", min: 0, max: 40, step: 1, default: CITY_DEFAULTS.raceGridSlots },
187
+ { type: "range", key: "raceCornerRadius", label: "corner radius", group: "race", min: 3, max: 40, step: 1, default: CITY_DEFAULTS.raceCornerRadius, unit: "m" },
156
188
  {
157
189
  type: "select",
158
190
  key: "profile",
@@ -189,6 +221,8 @@ export const CITY_SCHEMA = {
189
221
  { type: "range", key: "treeDensity", label: "tree density", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.treeDensity },
190
222
  { type: "weightedList", key: "treeMix", label: "tree mix", group: "greenery", itemLabel: "species", default: CITY_DEFAULTS.treeMix },
191
223
  { type: "range", key: "lightDensity", label: "street lights", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.lightDensity },
224
+ { type: "range", key: "parkedCars", label: "parked cars", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.parkedCars },
225
+ { type: "range", key: "signals", label: "traffic signals", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.signals },
192
226
  { type: "bool", key: "hedges", label: "estate hedges", group: "greenery", default: CITY_DEFAULTS.hedges },
193
227
  { type: "bool", key: "driveways", label: "driveways", group: "greenery", default: CITY_DEFAULTS.driveways },
194
228
  { type: "bool", key: "parking", label: "parking lots", group: "greenery", default: CITY_DEFAULTS.parking },
@@ -208,8 +242,8 @@ export const CITY_SCHEMA = {
208
242
  openSpace: 0.05,
209
243
  roadsideOccupancy: 0.97,
210
244
  blockDensity: 0.85,
211
- buildingRoadSetback: 0.8,
212
- buildingSpacing: 0.8,
245
+ buildingRoadSetback: 0.55,
246
+ buildingSpacing: 0.55,
213
247
  clusterStrength: 0.25,
214
248
  streetWidth: 9,
215
249
  boulevards: 0.5,
@@ -235,6 +269,8 @@ export const CITY_SCHEMA = {
235
269
  treeDensity: 0.3,
236
270
  treeMix: [{ item: "broadleaf", weight: 1 }],
237
271
  lightDensity: 0.8,
272
+ parkedCars: 0.95,
273
+ signals: 1,
238
274
  hedges: false,
239
275
  driveways: false,
240
276
  parking: false,
@@ -288,6 +324,8 @@ export const CITY_SCHEMA = {
288
324
  { item: "cypress", weight: 1 },
289
325
  ],
290
326
  lightDensity: 0.6,
327
+ parkedCars: 0.7,
328
+ signals: 0.8,
291
329
  hedges: false,
292
330
  driveways: true,
293
331
  parking: true,
@@ -339,6 +377,8 @@ export const CITY_SCHEMA = {
339
377
  { item: "broadleaf", weight: 2 },
340
378
  ],
341
379
  lightDensity: 0.35,
380
+ parkedCars: 0.2,
381
+ signals: 0.12,
342
382
  hedges: true,
343
383
  driveways: true,
344
384
  parking: false,
@@ -392,6 +432,8 @@ export const CITY_SCHEMA = {
392
432
  { item: "conifer", weight: 2 },
393
433
  ],
394
434
  lightDensity: 0.1,
435
+ parkedCars: 0.04,
436
+ signals: 0,
395
437
  hedges: false,
396
438
  driveways: true,
397
439
  parking: false,
@@ -413,6 +455,9 @@ export const CITY_SCHEMA = {
413
455
  minCurveRadius: 34,
414
456
  minTurnAngle: 0,
415
457
  maxTurnAngle: 95,
458
+ compactness: 0.5,
459
+ elevation: 0.3,
460
+ maxGrade: 0.06,
416
461
  blockSize: 95,
417
462
  blockAspect: 1,
418
463
  openSpace: 0,
@@ -426,6 +471,8 @@ export const CITY_SCHEMA = {
426
471
  sidewalks: false,
427
472
  treeDensity: 0.2,
428
473
  lightDensity: 0.25,
474
+ parkedCars: 0,
475
+ signals: 0,
429
476
  hedges: false,
430
477
  driveways: false,
431
478
  parking: false,
@@ -446,6 +493,9 @@ export const CITY_SCHEMA = {
446
493
  minCurveRadius: 12,
447
494
  minTurnAngle: 0,
448
495
  maxTurnAngle: 150,
496
+ outline: 0.5,
497
+ elevation: 0.8,
498
+ maxGrade: 0.14,
449
499
  blockSize: 72,
450
500
  blockAspect: 1,
451
501
  openSpace: 0,
@@ -459,6 +509,8 @@ export const CITY_SCHEMA = {
459
509
  sidewalks: false,
460
510
  treeDensity: 0.65,
461
511
  lightDensity: 0.05,
512
+ parkedCars: 0,
513
+ signals: 0,
462
514
  hedges: false,
463
515
  driveways: false,
464
516
  parking: false,
@@ -490,6 +542,8 @@ export const CITY_SCHEMA = {
490
542
  floorsMax: 34,
491
543
  treeDensity: 0.55,
492
544
  lightDensity: 0.6,
545
+ parkedCars: 0.85,
546
+ signals: 0.9,
493
547
  style: "generic",
494
548
  },
495
549
  },
@@ -527,6 +581,11 @@ export function readCityRules(meta) {
527
581
  minCurveRadius: params["minCurveRadius"],
528
582
  minTurnAngle: params["minTurnAngle"],
529
583
  maxTurnAngle: params["maxTurnAngle"],
584
+ outline: params["outline"],
585
+ residentialBranches: params["residentialBranches"],
586
+ elevation: params["elevation"],
587
+ maxGrade: params["maxGrade"],
588
+ compactness: params["compactness"],
530
589
  fabric: params["fabric"],
531
590
  tunnels: params["tunnels"],
532
591
  blockSize: params["blockSize"],
@@ -554,6 +613,8 @@ export function readCityRules(meta) {
554
613
  treeDensity: params["treeDensity"],
555
614
  treeMix: params["treeMix"],
556
615
  lightDensity: params["lightDensity"],
616
+ parkedCars: params["parkedCars"],
617
+ signals: params["signals"],
557
618
  hedges: params["hedges"],
558
619
  driveways: params["driveways"],
559
620
  parking: params["parking"],
@@ -564,6 +625,14 @@ export function readCityRules(meta) {
564
625
  bridgeStyle: params["bridgeStyle"],
565
626
  sidewalks: params["sidewalks"],
566
627
  sidewalkWidth: params["sidewalkWidth"],
628
+ race: params["race"],
629
+ raceLapLength: params["raceLapLength"],
630
+ raceArterial: params["raceArterial"],
631
+ raceCheckpoints: params["raceCheckpoints"],
632
+ raceLaps: params["raceLaps"],
633
+ raceSeals: params["raceSeals"],
634
+ raceGridSlots: params["raceGridSlots"],
635
+ raceCornerRadius: params["raceCornerRadius"],
567
636
  style: params["style"],
568
637
  buildingKit: params["buildingKit"] ?? CITY_DEFAULTS.buildingKit,
569
638
  seed: params["seed"],
@@ -578,7 +647,19 @@ const MAX_TREES = 3200;
578
647
  const MAX_LIGHTS = 1200;
579
648
  const MAX_FIELD_ROWS = 2400;
580
649
  const MAX_PARKING = 380;
650
+ const MAX_VEHICLES = 900;
651
+ const MAX_SIGNALS = 640;
652
+ const MAX_RACE_SEALS = 400;
653
+ const MAX_RACE_KERBS = 320;
581
654
  const TAU = Math.PI * 2;
655
+ /** Body footprint per silhouette as `[half width across, half length along the heading]`. */
656
+ const VEHICLE_HALF_EXTENTS = {
657
+ car: [0.95, 2.25],
658
+ van: [1.05, 2.65],
659
+ truck: [1.2, 3.5],
660
+ };
661
+ /** Paint slots the resolver spreads vehicles across; the renderer owns the actual colors. */
662
+ const VEHICLE_PAINT_COUNT = 8;
582
663
  function axisValue(value) {
583
664
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
584
665
  }
@@ -818,7 +899,8 @@ function buildFabric(rules, streams, streets, index, hx, hz, slopeAt, heightAt,
818
899
  if (prevClass !== null && stickRoll < 0.45 + rules.clusterStrength * 0.4 && mix.some((entry) => entry.item === prevClass && entry.weight > 0)) {
819
900
  cls = prevClass;
820
901
  }
821
- const placement = rollClassPlacement(cls, lotRng, rules.lotScale, rules.floorsMin, rules.floorsMax, rules.buildingRoadSetback, rules.buildingSpacing);
902
+ const stretchCommercial = rules.blockDensity > 0.75 && (cls === "tower" || cls === "slab" || cls === "shop" || cls === "rowhouse");
903
+ const placement = rollClassPlacement(cls, lotRng, rules.lotScale, rules.floorsMin, rules.floorsMax, rules.buildingRoadSetback, rules.buildingSpacing, stretchCommercial ? 1 + rules.blockDensity * 0.35 : 1);
822
904
  const frontW = placement.width + placement.gap;
823
905
  if (cursor + frontW > walker.total - 0.5)
824
906
  break;
@@ -904,18 +986,21 @@ function buildFabric(rules, streams, streets, index, hx, hz, slopeAt, heightAt,
904
986
  prevClass = null;
905
987
  continue;
906
988
  }
907
- const sideGap = Math.max(0.3, placement.gap / 2);
908
- const buildable = buildablePolygon(poly, A, B, placement.setback, sideGap, 1.2, usedDepth);
989
+ const commercial = cls === "tower" || cls === "slab" || cls === "shop" || cls === "rowhouse";
990
+ const dense = rules.blockDensity > 0.75 && commercial;
991
+ const sideGap = Math.max(dense ? (cls === "shop" || cls === "rowhouse" ? 0.08 : 0.15) : 0.3, (placement.gap / 2) * (dense ? 0.45 : 1));
992
+ const frontSetback = Math.max(0.05, placement.setback * (dense ? 0.35 : 1));
993
+ const buildable = buildablePolygon(poly, A, B, frontSetback, sideGap, 1.2, usedDepth);
909
994
  parcel.buildable = buildable;
910
995
  let placedLot = false;
911
996
  if (buildable.length >= 3) {
912
997
  const outDir = [-midS.normal[0], -midS.normal[1]];
913
998
  const rotationY = Math.atan2(outDir[0], outDir[1]);
914
- const maxW = Math.max(3, Math.min(placement.width, frontW - placement.gap));
915
- const availDepth = usedDepth - placement.setback - 1.2;
916
- const maxD = Math.max(3, Math.min(placement.depth, availDepth));
917
- const cx0 = midS.p[0] + midS.normal[0] * (placement.setback + maxD / 2);
918
- const cz0 = midS.p[1] + midS.normal[1] * (placement.setback + maxD / 2);
999
+ const maxW = Math.max(3, Math.min(dense ? Math.max(placement.width, frontW * 0.9) : placement.width, frontW - Math.max(0.15, placement.gap * (dense ? 0.35 : 1))));
1000
+ const availDepth = usedDepth - frontSetback - 1.2;
1001
+ const maxD = Math.max(3, Math.min(dense ? Math.max(placement.depth, availDepth * 0.85) : placement.depth, availDepth));
1002
+ const cx0 = midS.p[0] + midS.normal[0] * (frontSetback + maxD / 2);
1003
+ const cz0 = midS.p[1] + midS.normal[1] * (frontSetback + maxD / 2);
919
1004
  const fit = fitRectInPolygon(buildable, cx0, cz0, maxW, maxD, rotationY, 0.45);
920
1005
  if (fit !== null && fit.w >= 3 && fit.d >= 2.6) {
921
1006
  const slopeRoll = rng();
@@ -1022,8 +1107,8 @@ function buildFabric(rules, streams, streets, index, hx, hz, slopeAt, heightAt,
1022
1107
  // elsewhere) when it is big enough to read as a place rather than back yards.
1023
1108
  if (maxDepthUsed > 0) {
1024
1109
  const core = insetRingUniform(land, maxDepthUsed + 0.5);
1025
- if (core.length >= 3 && polygonArea(core) > 240) {
1026
- const type = band === "core" ? "courtyard" : band === "mid" ? (parkJitter < 0.5 ? "courtyard" : "green") : "meadow";
1110
+ if (core.length >= 3 && polygonArea(core) > 120) {
1111
+ const type = band === "core" ? "courtyard" : band === "mid" ? (parkJitter < 0.55 ? "courtyard" : "green") : "meadow";
1027
1112
  result.parks.push(parkFromRing(`court:${bi}`, type, core, parkJitter));
1028
1113
  }
1029
1114
  }
@@ -1071,6 +1156,89 @@ function buildFieldRows(parks, placed, streams) {
1071
1156
  park.rows = rows;
1072
1157
  }
1073
1158
  }
1159
+ /** Ids of the through-streets carrying a lap's graph edges, in lap order and without repeats. */
1160
+ function lapStreetIds(network, edges, streetCount) {
1161
+ const pairKey = (a, b) => (a < b ? `${a}:${b}` : `${b}:${a}`);
1162
+ const owner = new Map();
1163
+ for (let index = 0; index < Math.min(network.streets.length, streetCount); index += 1) {
1164
+ const nodes = network.streets[index].nodes;
1165
+ for (let i = 0; i + 1 < nodes.length; i += 1)
1166
+ owner.set(pairKey(nodes[i], nodes[i + 1]), index);
1167
+ }
1168
+ const byId = new Map(network.edges.map((edge) => [edge.id, edge]));
1169
+ const ids = [];
1170
+ const seen = new Set();
1171
+ for (const id of edges) {
1172
+ const edge = byId.get(id);
1173
+ if (edge === undefined)
1174
+ continue;
1175
+ const index = owner.get(pairKey(edge.a, edge.b));
1176
+ if (index === undefined || seen.has(index))
1177
+ continue;
1178
+ seen.add(index);
1179
+ ids.push(`street:${index}`);
1180
+ }
1181
+ return ids;
1182
+ }
1183
+ /** Rotate and translate a volume-local circuit route into the world frame the rest of the plan uses. */
1184
+ function raceToWorld(route, streets, seals, toWorld, rotationY) {
1185
+ // `toWorld` rotates a local direction BY `rotationY`, so a heading turns with it rather than against.
1186
+ const heading = (local) => local + rotationY;
1187
+ // A checkpoint is an AABB with no yaw of its own; grow it to the bounds of the rotated gate.
1188
+ const gateX = Math.abs(Math.cos(rotationY));
1189
+ const gateZ = Math.abs(Math.sin(rotationY));
1190
+ const [sx, sz] = toWorld(route.start.x, route.start.z);
1191
+ return {
1192
+ centerline: route.centerline.map(([x, z]) => toWorld(x, z)),
1193
+ widths: [...route.widths],
1194
+ ...(route.heights === undefined ? {} : { heights: [...route.heights] }),
1195
+ length: route.length,
1196
+ streets,
1197
+ seals: seals
1198
+ ? route.seals.slice(0, MAX_RACE_SEALS).map((seal) => {
1199
+ const [x, z] = toWorld(seal.x, seal.z);
1200
+ return { x, z, heading: heading(seal.heading), width: seal.width, kind: seal.kind };
1201
+ })
1202
+ : [],
1203
+ corners: route.corners.map((corner) => ({ ...corner })),
1204
+ kerbs: circuitKerbs(route)
1205
+ .slice(0, MAX_RACE_KERBS)
1206
+ .map((kerb) => ({
1207
+ points: kerb.points.map(([x, z]) => toWorld(x, z)),
1208
+ width: kerb.width,
1209
+ side: kerb.side,
1210
+ stripe: kerb.stripe,
1211
+ corner: kerb.corner,
1212
+ })),
1213
+ checkpoints: route.checkpoints.map((gate) => {
1214
+ const [x, z] = toWorld(gate.center[0], gate.center[2]);
1215
+ return {
1216
+ id: gate.id,
1217
+ center: [x, gate.center[1], z],
1218
+ half: [
1219
+ gate.half[0] * gateX + gate.half[2] * gateZ,
1220
+ gate.half[1],
1221
+ gate.half[0] * gateZ + gate.half[2] * gateX,
1222
+ ],
1223
+ };
1224
+ }),
1225
+ laps: route.laps,
1226
+ start: {
1227
+ x: sx,
1228
+ z: sz,
1229
+ heading: heading(route.start.heading),
1230
+ line: [toWorld(route.start.line[0][0], route.start.line[0][1]), toWorld(route.start.line[1][0], route.start.line[1][1])],
1231
+ distance: route.start.distance,
1232
+ width: route.start.width,
1233
+ },
1234
+ grid: route.grid.map((slot) => {
1235
+ const [x, z] = toWorld(slot.x, slot.z);
1236
+ return { x, z, heading: heading(slot.heading) };
1237
+ }),
1238
+ sectors: [...route.sectors],
1239
+ tightestRadius: route.tightestRadius,
1240
+ };
1241
+ }
1074
1242
  /**
1075
1243
  * Synthesize the deterministic city plan for one `city` volume: streets → bridges → parks → zoned
1076
1244
  * frontage lots with massing pieces → furniture, all in the volume's local frame and then
@@ -1109,6 +1277,8 @@ export function resolveCityObject(object, context) {
1109
1277
  hedges: [],
1110
1278
  driveways: [],
1111
1279
  parkingLots: [],
1280
+ vehicles: [],
1281
+ signals: [],
1112
1282
  };
1113
1283
  if (hx < rules.blockSize * 0.75 || hz < rules.blockSize * 0.75)
1114
1284
  return empty;
@@ -1163,10 +1333,12 @@ export function resolveCityObject(object, context) {
1163
1333
  // net and a closed race circuit are the SAME generator at opposite slider extremes.
1164
1334
  const netRules = {
1165
1335
  seed: rules.seed.length > 0 ? rules.seed : object.id,
1336
+ outline: rules.outline,
1166
1337
  gridness: rules.gridness,
1167
1338
  loopiness: rules.loopiness,
1168
1339
  connectivity: rules.connectivity,
1169
1340
  branching: rules.branching,
1341
+ residentialBranches: rules.residentialBranches,
1170
1342
  deadEnds: rules.deadEnds,
1171
1343
  segmentLength: rules.blockSize,
1172
1344
  aspect: rules.blockAspect,
@@ -1176,6 +1348,10 @@ export function resolveCityObject(object, context) {
1176
1348
  maxTurnAngle: rules.maxTurnAngle,
1177
1349
  width: rules.streetWidth,
1178
1350
  boulevards: rules.boulevards,
1351
+ sidewalkWidth: rules.sidewalkWidth,
1352
+ elevation: rules.elevation,
1353
+ maxGrade: rules.maxGrade,
1354
+ compactness: rules.compactness,
1179
1355
  };
1180
1356
  const network = generateStreets(netRules, hx, hz, {
1181
1357
  heightAt: heightAt ?? undefined,
@@ -1193,6 +1369,7 @@ export function resolveCityObject(object, context) {
1193
1369
  level: street.level,
1194
1370
  surface,
1195
1371
  sidewalk: rules.sidewalks && surface === "asphalt",
1372
+ ...(street.heights === undefined ? {} : { heights: [...street.heights] }),
1196
1373
  ...(street.bulb === undefined ? {} : { bulb: [street.bulb[0], street.bulb[1]] }),
1197
1374
  ...(street.features.length === 0 ? {} : { features: street.features }),
1198
1375
  };
@@ -1212,6 +1389,19 @@ export function resolveCityObject(object, context) {
1212
1389
  width: t.width,
1213
1390
  bankHeight: t.bankHeight,
1214
1391
  }));
1392
+ // The race is a SECTION OF THIS CITY blocked off: the same streets the district already grew,
1393
+ // with a closed lap lifted out of them. A district with no drivable cycle simply has no race.
1394
+ const route = rules.race
1395
+ ? extractCircuitRoute(network, {
1396
+ seed: netRules.seed,
1397
+ lapLength: rules.raceLapLength,
1398
+ arterialBias: rules.raceArterial,
1399
+ minCornerRadius: rules.raceCornerRadius,
1400
+ checkpoints: rules.raceCheckpoints,
1401
+ laps: rules.raceLaps,
1402
+ gridSlots: rules.raceGridSlots,
1403
+ })
1404
+ : null;
1215
1405
  // Fabric (block/parcel/building subdivision) is optional: a bare road or race track leaves it off,
1216
1406
  // so a circuit never carries forced-on plots — parcels and buildings appear only when you want it.
1217
1407
  const emptyFabric = { blocks: [], parcels: [], parks: [], lots: [], placed: new LotIndex(), hedges: [], driveways: [] };
@@ -1372,6 +1562,99 @@ export function resolveCityObject(object, context) {
1372
1562
  parking.push({ center: [x, z], size: [lot.size[0] * 1.05, depth], rotationY: lot.rotationY });
1373
1563
  }
1374
1564
  }
1565
+ // Parked vehicles: curbside anchors come from the shared street helper, pad stalls from the
1566
+ // pads laid above. Every candidate clears other streets, footprints, and the cars already parked.
1567
+ const vehicles = [];
1568
+ if (rules.parkedCars > 0) {
1569
+ const carRng = streams("vehicles");
1570
+ const carIndex = new LotIndex();
1571
+ const park = (x, z, heading, mix, stall, street) => {
1572
+ const kind = mix < 0.68 ? "car" : mix < 0.9 ? "van" : "truck";
1573
+ const half = VEHICLE_HALF_EXTENTS[kind];
1574
+ const rect = { x, z, hw: half[0], hd: half[1], angle: heading };
1575
+ if (!insideBounds(x, z))
1576
+ return;
1577
+ if (index.clearance(x, z, stall === "curb" ? 0.6 : 0.5, street) < 0)
1578
+ return;
1579
+ if (lotResult.placed.overlapsAny(rect, 0.4))
1580
+ return;
1581
+ if (carIndex.overlapsAny(rect, 0.35))
1582
+ return;
1583
+ carIndex.add(rect);
1584
+ vehicles.push({ x, z, rotationY: heading, kind, paint: Math.floor(mix * 9973) % VEHICLE_PAINT_COUNT, stall });
1585
+ };
1586
+ const spacing = Math.max(7, 26 - 19 * rules.parkedCars);
1587
+ for (let si = 0; si < local.length && vehicles.length < MAX_VEHICLES; si += 1) {
1588
+ const street = local[si];
1589
+ // Gravel shoulders and narrow lanes have no curb lane: `parkingSpots` would seat the body
1590
+ // over the centreline of anything under ~6.5 m.
1591
+ if (street.surface === "gravel" || street.width < 6.5)
1592
+ continue;
1593
+ for (const spot of parkingSpots(asDescriptor(street), { spacing })) {
1594
+ if (vehicles.length >= MAX_VEHICLES)
1595
+ break;
1596
+ const mix = carRng();
1597
+ if (carRng() > rules.parkedCars)
1598
+ continue;
1599
+ const [x, z] = spot.position;
1600
+ if (nearIntersection(x, z, 4))
1601
+ continue;
1602
+ park(x, z, spot.heading, mix, "curb", si);
1603
+ }
1604
+ }
1605
+ for (const pad of parking) {
1606
+ if (vehicles.length >= MAX_VEHICLES)
1607
+ break;
1608
+ const ca = Math.cos(pad.rotationY);
1609
+ const sa = Math.sin(pad.rotationY);
1610
+ const rows = pad.size[1] >= 11 ? 2 : 1;
1611
+ const stalls = Math.max(1, Math.floor(pad.size[0] / 2.9));
1612
+ for (let row = 0; row < rows; row += 1) {
1613
+ const lz = rows === 1 ? 0 : (row === 0 ? -1 : 1) * pad.size[1] * 0.25;
1614
+ for (let s = 0; s < stalls; s += 1) {
1615
+ if (vehicles.length >= MAX_VEHICLES)
1616
+ break;
1617
+ const mix = carRng();
1618
+ if (carRng() > rules.parkedCars * 0.92)
1619
+ continue;
1620
+ const lx = ((s + 0.5) / stalls - 0.5) * pad.size[0];
1621
+ // Facing rows nose in on the aisle between them.
1622
+ park(pad.center[0] + lx * ca + lz * sa, pad.center[1] - lx * sa + lz * ca, pad.rotationY + row * Math.PI, mix, "lot", -1);
1623
+ }
1624
+ }
1625
+ }
1626
+ }
1627
+ // Junction control, one device per approach arm: arterial crossings get a signal on a mast arm,
1628
+ // everything above lane level gets a sign post instead.
1629
+ const signals = [];
1630
+ if (rules.signals > 0) {
1631
+ const signalRng = streams("signals");
1632
+ for (const cross of intersections) {
1633
+ if (signals.length >= MAX_SIGNALS)
1634
+ break;
1635
+ if (cross.arms.length < 3 || cross.level === "lane")
1636
+ continue;
1637
+ const arterial = LEVEL_RANK[cross.level] >= LEVEL_RANK.avenue;
1638
+ if (signalRng() > rules.signals * (arterial ? 1 : 0.75))
1639
+ continue;
1640
+ for (const arm of cross.arms) {
1641
+ if (signals.length >= MAX_SIGNALS)
1642
+ break;
1643
+ const dir = [Math.sin(arm.angle), Math.cos(arm.angle)];
1644
+ // Right-hand traffic: the pole stands on the near-right corner of the approach.
1645
+ const perp = [-dir[1], dir[0]];
1646
+ const out = cross.radius + 1.8;
1647
+ const side = arm.width / 2 + 1;
1648
+ const x = cross.x + dir[0] * out + perp[0] * side;
1649
+ const z = cross.z + dir[1] * out + perp[1] * side;
1650
+ if (!insideBounds(x, z))
1651
+ continue;
1652
+ if (index.clearance(x, z, 0.3) < 0)
1653
+ continue;
1654
+ signals.push({ x, z, heading: arm.angle, kind: arterial ? "signal" : "sign", reach: arm.width / 2 + 0.6 });
1655
+ }
1656
+ }
1657
+ }
1375
1658
  return {
1376
1659
  center: [center.x, center.y, center.z],
1377
1660
  size: [hx * 2, hz * 2],
@@ -1384,6 +1667,7 @@ export function resolveCityObject(object, context) {
1384
1667
  level: street.level,
1385
1668
  surface: street.surface,
1386
1669
  sidewalk: street.sidewalk,
1670
+ ...(street.heights === undefined ? {} : { heights: [...street.heights] }),
1387
1671
  ...(street.bulb === undefined ? {} : { bulb: toWorld(street.bulb[0], street.bulb[1]) }),
1388
1672
  ...(street.features === undefined ? {} : { features: street.features.map((f) => ({ ...f })) }),
1389
1673
  })),
@@ -1481,6 +1765,17 @@ export function resolveCityObject(object, context) {
1481
1765
  const [x, z] = toWorld(pad.center[0], pad.center[1]);
1482
1766
  return { center: [x, z], size: pad.size, rotationY: pad.rotationY - rotationY };
1483
1767
  }),
1768
+ vehicles: vehicles.map((vehicle) => {
1769
+ const [x, z] = toWorld(vehicle.x, vehicle.z);
1770
+ return { x, z, rotationY: vehicle.rotationY + rotationY, kind: vehicle.kind, paint: vehicle.paint, stall: vehicle.stall };
1771
+ }),
1772
+ signals: signals.map((signal) => {
1773
+ const [x, z] = toWorld(signal.x, signal.z);
1774
+ return { x, z, heading: signal.heading + rotationY, kind: signal.kind, reach: signal.reach };
1775
+ }),
1776
+ ...(route === null
1777
+ ? {}
1778
+ : { race: raceToWorld(route, lapStreetIds(network, route.edges, local.length), rules.raceSeals, toWorld, rotationY) }),
1484
1779
  };
1485
1780
  }
1486
1781
  /** Registers the `city` + `cityzone` scene kinds (schema + resolver). Called by {@link registerBuiltinSceneKinds}. @internal */
@@ -1519,6 +1814,11 @@ export function registerCityKind() {
1519
1814
  const feats = [
1520
1815
  resolved.bridges.length > 0 ? `${resolved.bridges.length} bridges` : "",
1521
1816
  resolved.tunnels.length > 0 ? `${resolved.tunnels.length} tunnels` : "",
1817
+ resolved.rules.race
1818
+ ? resolved.race === undefined
1819
+ ? "no raceable lap in these streets"
1820
+ : `${Math.round(resolved.race.length)} m lap · ${resolved.race.seals.length} seals`
1821
+ : "",
1522
1822
  ].filter((s) => s.length > 0);
1523
1823
  const head = mode === "circuit" ? `circuit · ${resolved.streets.length} streets` : `${resolved.streets.length} streets`;
1524
1824
  // Shared clamp-and-warn: when the block/parcel pipeline hits the building budget, surface it in
@@ -4,6 +4,7 @@ import type { AvoidZone } from "./geometry.js";
4
4
  import type { TerrainPathProfile } from "./pathTerrain.js";
5
5
  import type { PlaceWorldFeature } from "./place.js";
6
6
  import type { TerraformSnapshot } from "./terraform.js";
7
+ import type { SpriteAtlas } from "../assets/spriteAtlas.js";
7
8
  import type { VolumetricCloudsConfig } from "./volumetricClouds.js";
8
9
  export interface WorldBounds {
9
10
  w: number;
@@ -48,6 +49,16 @@ export interface TerrainDetailConfig {
48
49
  roughness?: number;
49
50
  /** Overall detail intensity 0..1. Default 1. */
50
51
  strength?: number;
52
+ /**
53
+ * RGB multipliers for the two macro colour sweeps painted over the base ground. `dry` tints the
54
+ * sun-bleached patches, `wet` the cooler pockets between them. The defaults ([1.14, 1.05, 0.76]
55
+ * and [0.78, 1.0, 0.86]) suit temperate meadows; the green-leaning `wet` default paints mossy
56
+ * pockets onto any biome that keeps it, so arid/ashen worlds should supply warm or neutral tints.
57
+ */
58
+ sweeps?: {
59
+ dry?: readonly [number, number, number];
60
+ wet?: readonly [number, number, number];
61
+ };
51
62
  /** Real PBR texture maps blended over the procedural detail — the seam for `@jgengine/assets` `buildMaterialCatalog` output. Omit to keep the procedural-only look. */
52
63
  material?: TerrainDetailMaterialConfig;
53
64
  }
@@ -259,6 +270,14 @@ export interface GrassEnvironmentConfig {
259
270
  windStrength?: number;
260
271
  colors?: readonly string[];
261
272
  seed?: string;
273
+ /** Blade curl range (arc amount, min..max). Shell default [0.12, 0.42] — raise for wind-combed scrub, lower for stiff turf. */
274
+ bladeBend?: readonly [number, number];
275
+ /** Tuft spread radius in meters — how far a clump's blades splay from its root. Shell default 0.16; wider reads as bushy clumps rather than lone blades. */
276
+ tuftRadius?: number;
277
+ /** Per-tuft brightness jitter 0..1 (shell default 0.28) — higher breaks up the field so it never reads as one repeated blade. */
278
+ colorVariation?: number;
279
+ /** 0..1 blend of blade normals toward straight-up (shell default 0.6). Low values light each blade plane individually and go dark when side-lit; high values light the field like a soft carpet. */
280
+ normalLift?: number;
262
281
  }
263
282
  export interface OceanEnvironmentConfig {
264
283
  bounds?: WorldBounds;
@@ -393,7 +412,7 @@ export type SnowEnvironmentDescriptor = {
393
412
  } & Required<Pick<SnowEnvironmentConfig, "area" | "density" | "speed" | "flakeSize" | "drift" | "wind" | "color" | "opacity">>;
394
413
  export type GrassEnvironmentDescriptor = {
395
414
  kind: "grass";
396
- } & Required<Pick<GrassEnvironmentConfig, "area" | "density" | "bladeHeight" | "bladeWidth" | "windStrength" | "colors">> & Pick<GrassEnvironmentConfig, "seed">;
415
+ } & Required<Pick<GrassEnvironmentConfig, "area" | "density" | "bladeHeight" | "bladeWidth" | "windStrength" | "colors">> & Pick<GrassEnvironmentConfig, "seed" | "bladeBend" | "tuftRadius" | "colorVariation" | "normalLift">;
397
416
  export type OceanEnvironmentDescriptor = {
398
417
  kind: "ocean";
399
418
  } & Required<Pick<OceanEnvironmentConfig, "bounds" | "level" | "waveHeight" | "waveScale" | "waveSpeed" | "color">> & Pick<OceanEnvironmentConfig, "position" | "levelAt">;
@@ -519,6 +538,13 @@ export interface PlotsWorldConfig extends WorldGridConfig {
519
538
  }
520
539
  export interface TilemapWorldConfig extends WorldGridConfig {
521
540
  map: string;
541
+ /** Atlas-backed tile rendering; cell glyphs map to atlas frame ids. */
542
+ tileSet?: {
543
+ atlas: SpriteAtlas;
544
+ tiles: Record<string, string>;
545
+ };
546
+ /** Render ordering key consumed by the shell tile renderer. */
547
+ sortingLayer?: string;
522
548
  }
523
549
  /**
524
550
  * A declared world shape passed to `defineGame`. The preferred model is the place feature from
@@ -183,7 +183,7 @@ export function snow(config = {}) {
183
183
  }
184
184
  /** Declares a grass vegetation patch for `environment()` — area, blade sizing, density, and colors. */
185
185
  export function grass(config = {}) {
186
- return withOptional({
186
+ return {
187
187
  kind: "grass",
188
188
  area: withAreaPosition(config.area ?? { w: 128, d: 128 }, config.position),
189
189
  density: config.density ?? 4,
@@ -191,7 +191,12 @@ export function grass(config = {}) {
191
191
  bladeWidth: config.bladeWidth ?? 0.035,
192
192
  windStrength: config.windStrength ?? 0.35,
193
193
  colors: config.colors ?? ["#3f7d2d", "#66a83f"],
194
- }, config.seed === undefined ? undefined : { seed: config.seed });
194
+ ...(config.seed === undefined ? {} : { seed: config.seed }),
195
+ ...(config.bladeBend === undefined ? {} : { bladeBend: config.bladeBend }),
196
+ ...(config.tuftRadius === undefined ? {} : { tuftRadius: config.tuftRadius }),
197
+ ...(config.colorVariation === undefined ? {} : { colorVariation: config.colorVariation }),
198
+ ...(config.normalLift === undefined ? {} : { normalLift: config.normalLift }),
199
+ };
195
200
  }
196
201
  /** Declares an ocean water body for `environment()` — bounds, level, and wave tuning. */
197
202
  export function ocean(config = {}) {