@jgengine/core 0.13.0 → 0.15.0-next.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 (223) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/authoring.d.ts +6 -4
  12. package/dist/authoring.js +6 -4
  13. package/dist/combat/castRunner.d.ts +13 -1
  14. package/dist/combat/castRunner.js +5 -1
  15. package/dist/combat/comboMeter.d.ts +140 -0
  16. package/dist/combat/comboMeter.js +215 -0
  17. package/dist/combat/comboPoints.d.ts +11 -0
  18. package/dist/combat/comboPoints.js +11 -2
  19. package/dist/combat/damageResolution.d.ts +1 -1
  20. package/dist/combat/defensiveWindow.d.ts +5 -0
  21. package/dist/combat/effects.d.ts +24 -0
  22. package/dist/combat/effects.js +6 -1
  23. package/dist/combat/projectiles.d.ts +6 -0
  24. package/dist/combat/projectiles.js +2 -2
  25. package/dist/combat/regenShield.d.ts +40 -2
  26. package/dist/combat/regenShield.js +66 -18
  27. package/dist/combat/resourcePool.d.ts +19 -0
  28. package/dist/combat/resourcePool.js +20 -2
  29. package/dist/combat/shotOrigin.d.ts +5 -0
  30. package/dist/combat/statusApplication.d.ts +1 -1
  31. package/dist/combat/statusApplication.js +2 -1
  32. package/dist/combat/statusEffectView.d.ts +56 -0
  33. package/dist/combat/statusEffectView.js +52 -0
  34. package/dist/combat.d.ts +4 -2
  35. package/dist/combat.js +2 -0
  36. package/dist/devtools/fallbackSeams.d.ts +3 -2
  37. package/dist/devtools/fallbackSeams.js +1 -1
  38. package/dist/devtools/textureErrors.d.ts +40 -0
  39. package/dist/devtools/textureErrors.js +68 -0
  40. package/dist/economy/shopStock.d.ts +110 -0
  41. package/dist/economy/shopStock.js +157 -0
  42. package/dist/economy/wallet.d.ts +6 -0
  43. package/dist/economy/wallet.js +1 -0
  44. package/dist/game/achievements.d.ts +80 -0
  45. package/dist/game/achievements.js +124 -0
  46. package/dist/game/cameraConfig.d.ts +7 -0
  47. package/dist/game/codex.d.ts +60 -0
  48. package/dist/game/codex.js +89 -0
  49. package/dist/game/credits.d.ts +36 -0
  50. package/dist/game/credits.js +0 -0
  51. package/dist/game/defineGame.d.ts +28 -3
  52. package/dist/game/defineGame.js +5 -0
  53. package/dist/game/dialogueGraph.d.ts +116 -0
  54. package/dist/game/dialogueGraph.js +103 -0
  55. package/dist/game/eventTicker.d.ts +95 -0
  56. package/dist/game/eventTicker.js +91 -0
  57. package/dist/game/events.d.ts +24 -5
  58. package/dist/game/events.js +18 -0
  59. package/dist/game/gamePhase.d.ts +1 -1
  60. package/dist/game/gamePhase.js +1 -1
  61. package/dist/game/leaderboardRank.d.ts +71 -0
  62. package/dist/game/leaderboardRank.js +76 -0
  63. package/dist/game/lootTable.d.ts +10 -1
  64. package/dist/game/lootTable.js +4 -2
  65. package/dist/game/notifications.d.ts +57 -0
  66. package/dist/game/notifications.js +107 -0
  67. package/dist/game/playableGame.d.ts +39 -2
  68. package/dist/game/quest.d.ts +26 -0
  69. package/dist/game/quest.js +31 -0
  70. package/dist/game/runDraft.js +2 -1
  71. package/dist/game/saveSlots.d.ts +102 -0
  72. package/dist/game/saveSlots.js +136 -0
  73. package/dist/game/talentTreeView.d.ts +106 -0
  74. package/dist/game/talentTreeView.js +114 -0
  75. package/dist/game/worldItem.js +2 -1
  76. package/dist/gameplay.d.ts +15 -4
  77. package/dist/gameplay.js +13 -2
  78. package/dist/i18n/i18n.d.ts +51 -0
  79. package/dist/i18n/i18n.js +82 -0
  80. package/dist/input/axisInput.d.ts +1 -1
  81. package/dist/input/axisInput.js +18 -2
  82. package/dist/input/rebindSession.d.ts +141 -0
  83. package/dist/input/rebindSession.js +235 -0
  84. package/dist/input/touchControlsMode.d.ts +16 -0
  85. package/dist/input/touchControlsMode.js +23 -0
  86. package/dist/input/touchScheme.d.ts +30 -1
  87. package/dist/input/touchScheme.js +31 -1
  88. package/dist/interaction/promptRegistry.d.ts +78 -0
  89. package/dist/interaction/promptRegistry.js +130 -0
  90. package/dist/inventory/storageTier.js +3 -2
  91. package/dist/meta/changelog.d.ts +1 -1
  92. package/dist/meta/changelog.js +21 -1
  93. package/dist/movement/movementModel.d.ts +26 -3
  94. package/dist/movement/movementModel.js +121 -15
  95. package/dist/movement/playerMovement.js +161 -25
  96. package/dist/physics/drivableVehicle.js +3 -1
  97. package/dist/physics/kinematicVehicle.d.ts +56 -1
  98. package/dist/physics/kinematicVehicle.js +99 -10
  99. package/dist/physics/vehicleObstacles.js +19 -0
  100. package/dist/random/resolveRng.d.ts +14 -0
  101. package/dist/random/resolveRng.js +31 -0
  102. package/dist/render/postProcessing.d.ts +12 -1
  103. package/dist/runtime/cameraDirector.d.ts +2 -2
  104. package/dist/runtime/context/combat.js +8 -0
  105. package/dist/runtime/context/scene.d.ts +2 -0
  106. package/dist/runtime/context/scene.js +48 -1
  107. package/dist/runtime/gameContext.js +8 -2
  108. package/dist/runtime/gameContextTypes.d.ts +39 -0
  109. package/dist/runtime/inputSnapshot.d.ts +10 -1
  110. package/dist/runtime/inputSnapshot.js +13 -1
  111. package/dist/runtime/runtimeSave.d.ts +3 -3
  112. package/dist/runtime/runtimeSave.js +11 -2
  113. package/dist/scene/assetDiagnostics.js +2 -2
  114. package/dist/scene/autoTarget.js +2 -1
  115. package/dist/scene/builtinSceneKinds.d.ts +6 -1
  116. package/dist/scene/builtinSceneKinds.js +10 -3
  117. package/dist/scene/captureCheck.js +3 -2
  118. package/dist/scene/colliders.d.ts +20 -0
  119. package/dist/scene/colliders.js +78 -0
  120. package/dist/scene/collisionMesh.d.ts +8 -0
  121. package/dist/scene/collisionMesh.js +48 -0
  122. package/dist/scene/entityStore.d.ts +4 -1
  123. package/dist/scene/entityStore.js +3 -0
  124. package/dist/scene/objectStore.d.ts +10 -0
  125. package/dist/scene/objectStore.js +29 -6
  126. package/dist/scene/sceneKinds.d.ts +37 -0
  127. package/dist/scene/sceneKinds.js +19 -0
  128. package/dist/scene/sequenceDirector.d.ts +110 -0
  129. package/dist/scene/sequenceDirector.js +146 -0
  130. package/dist/session/roles.js +3 -2
  131. package/dist/settings/settingsModel.d.ts +1 -0
  132. package/dist/settings/settingsModel.js +1 -0
  133. package/dist/stats/rollCheck.js +4 -2
  134. package/dist/time/dayNightCycle.d.ts +114 -0
  135. package/dist/time/dayNightCycle.js +208 -0
  136. package/dist/time/timerSet.d.ts +132 -0
  137. package/dist/time/timerSet.js +243 -0
  138. package/dist/ui/accessibility.d.ts +51 -0
  139. package/dist/ui/accessibility.js +83 -0
  140. package/dist/ui/coachMarks.d.ts +99 -0
  141. package/dist/ui/coachMarks.js +126 -0
  142. package/dist/ui/floatingText.d.ts +114 -0
  143. package/dist/ui/floatingText.js +203 -0
  144. package/dist/ui/hudScale.d.ts +16 -1
  145. package/dist/ui/hudScale.js +22 -1
  146. package/dist/ui/modalStack.d.ts +143 -0
  147. package/dist/ui/modalStack.js +160 -0
  148. package/dist/ui/objectiveBanner.d.ts +123 -0
  149. package/dist/ui/objectiveBanner.js +162 -0
  150. package/dist/ui/photoMode.d.ts +32 -0
  151. package/dist/ui/photoMode.js +51 -0
  152. package/dist/ui/radialMenu.d.ts +47 -0
  153. package/dist/ui/radialMenu.js +74 -0
  154. package/dist/ui/screenMarkers.d.ts +128 -0
  155. package/dist/ui/screenMarkers.js +118 -0
  156. package/dist/ui.d.ts +8 -0
  157. package/dist/ui.js +8 -0
  158. package/dist/vfx/cameraShake.d.ts +97 -0
  159. package/dist/vfx/cameraShake.js +136 -0
  160. package/dist/vfx/damageDirection.d.ts +102 -0
  161. package/dist/vfx/damageDirection.js +188 -0
  162. package/dist/vfx/particles.d.ts +103 -0
  163. package/dist/vfx/particles.js +256 -0
  164. package/dist/vfx/screenEffects.d.ts +149 -0
  165. package/dist/vfx/screenEffects.js +191 -0
  166. package/dist/world/authoredObjects.d.ts +18 -0
  167. package/dist/world/authoredObjects.js +21 -0
  168. package/dist/world/authoredSpawn.js +19 -0
  169. package/dist/world/buildingLots.d.ts +54 -11
  170. package/dist/world/buildingLots.js +116 -30
  171. package/dist/world/cityBlocks.d.ts +45 -0
  172. package/dist/world/cityBlocks.js +159 -0
  173. package/dist/world/cityContent.d.ts +46 -2
  174. package/dist/world/cityContent.js +160 -2
  175. package/dist/world/cityGenerator.d.ts +261 -23
  176. package/dist/world/cityGenerator.js +643 -33
  177. package/dist/world/cityGeometry.d.ts +20 -0
  178. package/dist/world/cityGeometry.js +91 -0
  179. package/dist/world/cityKind.js +9 -46
  180. package/dist/world/fastTravel.d.ts +64 -0
  181. package/dist/world/fastTravel.js +108 -0
  182. package/dist/world/features.d.ts +16 -0
  183. package/dist/world/features.js +2 -0
  184. package/dist/world/grassKind.js +4 -1
  185. package/dist/world/grounded.d.ts +11 -0
  186. package/dist/world/grounded.js +12 -0
  187. package/dist/world/mapAnnotations.d.ts +75 -0
  188. package/dist/world/mapAnnotations.js +139 -0
  189. package/dist/world/markers.js +1 -0
  190. package/dist/world/resourceNode.d.ts +1 -1
  191. package/dist/world/resourceNode.js +2 -1
  192. package/dist/world/roads.d.ts +294 -5
  193. package/dist/world/roads.js +1262 -21
  194. package/dist/world/scatterCoverage.d.ts +98 -0
  195. package/dist/world/scatterCoverage.js +101 -0
  196. package/dist/world/scatterRegion.js +6 -0
  197. package/dist/world/sharedAnnotations.d.ts +84 -0
  198. package/dist/world/sharedAnnotations.js +76 -0
  199. package/dist/world/spawnOverride.d.ts +37 -0
  200. package/dist/world/spawnOverride.js +50 -0
  201. package/dist/world/streetGenerator.d.ts +60 -11
  202. package/dist/world/streetGenerator.js +1786 -129
  203. package/dist/world/terrain.d.ts +1 -0
  204. package/dist/world/terrain.js +2 -0
  205. package/dist/world/waypoints.d.ts +63 -0
  206. package/dist/world/waypoints.js +137 -0
  207. package/dist/world.d.ts +18 -2
  208. package/dist/world.js +18 -2
  209. package/package.json +2 -2
  210. package/skills/ce-handoff/SKILL.md +149 -0
  211. package/skills/game-design/SKILL.md +2 -0
  212. package/skills/jgengine/SKILL.md +7 -3
  213. package/skills/jgengine/api.md +17 -7
  214. package/skills/jgengine/capabilities.md +12 -0
  215. package/skills/jgengine/recipes/minimal-game.md +7 -4
  216. package/skills/jgengine-combat/api.md +40 -4
  217. package/skills/jgengine-combat/capabilities.md +12 -0
  218. package/skills/jgengine-combat/reference.md +2 -0
  219. package/skills/jgengine-gameplay/api.md +212 -11
  220. package/skills/jgengine-gameplay/capabilities.md +66 -1
  221. package/skills/jgengine-world/api.md +408 -12
  222. package/skills/jgengine-world/capabilities.md +127 -2
  223. package/skills/jgengine-world/reference.md +15 -1
@@ -12,7 +12,7 @@ export interface RuntimeSaveTarget {
12
12
  hydrate(snapshot: WorldSnapshot): void;
13
13
  subscribe(listener: () => void): () => void;
14
14
  }
15
- /** `"autosave"` writes a fresh capture on a debounce after any world change; `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
15
+ /** `"autosave"` writes a fresh capture on a trailing timer while the world changes (at most once per `autosaveMs`, so a never-idle world still persists); `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
16
16
  export type RuntimeSaveMode = "autosave" | "manual";
17
17
  /** How {@link createRuntimeSave} is wired — the live world `target`, the `backend` it persists through, and optional mode/slot/versioning/cadence knobs. */
18
18
  export interface RuntimeSaveConfig {
@@ -29,7 +29,7 @@ export interface RuntimeSaveConfig {
29
29
  /** Save-format version; bump + pass {@link migrate} on a breaking world-shape change. */
30
30
  version?: number;
31
31
  migrate?: (data: unknown, fromVersion: number) => WorldSnapshot;
32
- /** Debounce for `autosave` mode. Default `3000`. */
32
+ /** Trailing autosave interval (ms) for `autosave` mode — the world persists at most once per interval and always within one interval of its first unsaved change. Default `3000`. */
33
33
  autosaveMs?: number;
34
34
  now?: () => number;
35
35
  timers?: SaveTimers;
@@ -41,7 +41,7 @@ export type RuntimeSaveOptions = Omit<RuntimeSaveConfig, "target">;
41
41
  * Whole-world save/load bound to a live world and a pluggable backend. `save()`
42
42
  * captures `target.snapshot()` and writes it; `load()` reads it back and
43
43
  * `target.hydrate()`s the whole world. In `autosave` mode it also writes on a
44
- * debounce after any change. Named slots, versioned migration, and offline↔cloud
44
+ * trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud
45
45
  * (backend swap) all come for free from the underlying save store.
46
46
  */
47
47
  export interface RuntimeSave {
@@ -51,10 +51,19 @@ export function createRuntimeSave(config) {
51
51
  store.set(config.target.snapshot());
52
52
  return store.save();
53
53
  }
54
+ /**
55
+ * Arm a trailing autosave. A living world (AI, physics, the day/night clock)
56
+ * changes every frame, so a debounce that *reset* its timer on every change
57
+ * would never elapse and the game would never persist — the timer would be
58
+ * pushed forward faster than it could fire. Instead the first change since the
59
+ * last write arms a single timer and later changes ride that same timer; when
60
+ * it fires it captures the latest snapshot. The result is at most one write per
61
+ * `autosaveMs`, and always within `autosaveMs` of the first unsaved change,
62
+ * even while the world never stops moving.
63
+ */
54
64
  function scheduleAutosave() {
55
- if (disposed || restoring)
65
+ if (disposed || restoring || pendingTimer !== null)
56
66
  return;
57
- clearTimer();
58
67
  pendingTimer = timers.set(() => {
59
68
  pendingTimer = null;
60
69
  void persist();
@@ -92,11 +92,11 @@ export function classifyAssetResponse(probe) {
92
92
  });
93
93
  if (status !== undefined && (status === 0 || status >= 400)) {
94
94
  const suffix = statusText === undefined || statusText.length === 0 ? "" : ` ${statusText}`;
95
- return finish("missing", `Asset ${who} did not load: HTTP ${status}${suffix}. The file is not being served — provision the pack (e.g. \`assets pull <source>\`) or check the model path.`);
95
+ return finish("missing", `Asset ${who} did not load: HTTP ${status}${suffix}. The bytes are not being served at that path — run \`assets pull <source>\`, which provisions the pack into the dev server's served models dir (\`apps/dev/public/models\` in the jgengine monorepo) so no manual copy is needed; or check the model path.`);
96
96
  }
97
97
  const htmlContentType = contentType !== undefined && contentType.toLowerCase().includes("text/html");
98
98
  if (signature === "html" || (htmlContentType && signature !== "glb" && signature !== "gltf-json")) {
99
- return finish("html", `Asset ${who} resolved to an HTML page, not a model — the dev server returned its fallback document because the file is missing. Provision the pack (\`assets pull <source>\`) so the bytes exist under the served path.`);
99
+ return finish("html", `Asset ${who} resolved to an HTML page, not a model — the dev server returned its fallback document because the file is missing. Run \`assets pull <source>\` to provision the pack into the served models dir (\`apps/dev/public/models\` in the jgengine monorepo); the bytes must exist there, no manual copy needed.`);
100
100
  }
101
101
  if (signature === "glb" || signature === "gltf-json")
102
102
  return finish("ok", `Asset ${who} loaded (${signature}).`);
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  function metric(policy, fromId, toId, deps) {
2
3
  switch (policy) {
3
4
  case "nearest":
@@ -21,7 +22,7 @@ export function selectAutoTarget(policy, fromId, deps) {
21
22
  if (candidates.length === 0)
22
23
  return null;
23
24
  if (policy === "random") {
24
- const rng = deps.rng ?? Math.random;
25
+ const rng = resolveRng(deps.rng, "autoTarget");
25
26
  return candidates[Math.min(candidates.length - 1, Math.floor(rng() * candidates.length))];
26
27
  }
27
28
  const higher = prefersHigher(policy);
@@ -1,5 +1,10 @@
1
1
  import { type ParamSchema } from "./sceneKinds.js";
2
- /** The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`. */
2
+ /**
3
+ * The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and
4
+ * `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's
5
+ * `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other
6
+ * scatterable kind; the rest is kind-specific tuning.
7
+ */
3
8
  export declare const SCATTER_SCHEMA: ParamSchema;
4
9
  /** Idempotently registers the engine's built-in environment scene kinds + generators. @internal */
5
10
  export declare function registerBuiltinSceneKinds(): void;
@@ -15,10 +15,17 @@ import { registerSoilKind } from "../world/soilKind.js";
15
15
  import { registerPoleLineKind } from "../world/poleLineKind.js";
16
16
  import { registerBuildingGenerator } from "../world/buildingGenerator.js";
17
17
  import { registerCityKind } from "../world/cityKind.js";
18
+ import { describeScatterCoverage, densityCoverage } from "../world/scatterCoverage.js";
18
19
  import { registerSceneKind } from "./sceneKinds.js";
19
- /** The scatter/foliage region schema — the fields the inspector exposed as hand-written `ScatterFields`. */
20
+ /**
21
+ * The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and
22
+ * `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's
23
+ * `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other
24
+ * scatterable kind; the rest is kind-specific tuning.
25
+ */
20
26
  export const SCATTER_SCHEMA = {
21
27
  fields: [
28
+ { type: "weightedList", key: "palette", label: "species (weighted)", itemLabel: "grass / tree id", default: [{ item: "grass", weight: 1 }] },
22
29
  { type: "range", key: "density", label: "density", min: 0, max: 2, step: 0.01, default: 0.15, unit: "/m²" },
23
30
  { type: "number", key: "minSpacing", label: "spacing", step: 0.25, default: 1.5, min: 0 },
24
31
  { type: "number", key: "minScale", label: "min scale", step: 0.05, default: 0.8, min: 0 },
@@ -27,7 +34,6 @@ export const SCATTER_SCHEMA = {
27
34
  { type: "number", key: "edgeFalloff", label: "edge fade", step: 0.5, default: 0, min: 0 },
28
35
  { type: "bool", key: "alignToNormal", label: "align to slope", default: false },
29
36
  { type: "bool", key: "autoAvoid", label: "auto-avoid gameplay spots", default: true },
30
- { type: "weightedList", key: "palette", label: "species (weighted)", itemLabel: "grass / tree id", default: [{ item: "grass", weight: 1 }] },
31
37
  { type: "seed", key: "seed", label: "seed", default: "" },
32
38
  ],
33
39
  };
@@ -45,6 +51,7 @@ export function registerBuiltinSceneKinds() {
45
51
  addCategory: "Studios",
46
52
  accent: "#34d399",
47
53
  schema: SCATTER_SCHEMA,
54
+ coverage: { spec: "scatter", densityKey: "density", assetsKey: "palette" },
48
55
  note: (object, params) => {
49
56
  const points = object.points ?? [];
50
57
  if (points.length < 3)
@@ -55,7 +62,7 @@ export function registerBuiltinSceneKinds() {
55
62
  }
56
63
  area = Math.abs(area) / 2;
57
64
  const density = typeof params["density"] === "number" ? params["density"] : 0;
58
- return `≈ ${Math.floor(area * density).toLocaleString()} placements over ${Math.round(area).toLocaleString()} m²`;
65
+ return describeScatterCoverage(densityCoverage("scatter", area, density));
59
66
  },
60
67
  });
61
68
  registerWaterKind();
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  function clamp01(value) {
2
3
  return Math.min(1, Math.max(0, value));
3
4
  }
@@ -6,6 +7,6 @@ export function captureChance(input) {
6
7
  const hpFactor = 1 - clamp01(input.hpFraction) * 0.6;
7
8
  return clamp01((input.catchPower * hpFactor) / Math.max(difficulty, 0.0001));
8
9
  }
9
- export function rollCapture(input, rng = Math.random) {
10
- return rng() < captureChance(input);
10
+ export function rollCapture(input, rng) {
11
+ return resolveRng(rng, "rollCapture")() < captureChance(input);
11
12
  }
@@ -128,6 +128,26 @@ export declare function measuredEntityColliders(bounds: MeasuredBounds): EntityC
128
128
  * @internal
129
129
  */
130
130
  export declare function measuredObjectColliders(bounds: MeasuredBounds): EntityColliderSet | null;
131
+ /** Damage hitbox raycasting the actual triangles the renderer mounted for an entity kind — what
132
+ * `reportCollisionMesh` stores. Triangles are entity-local (scale/placement already applied), so the
133
+ * shape composes with identity `meshScale`/`meshTranslate`; bounds/broadphase read the clamped AABB.
134
+ * `null` when the mesh is degenerate, keeping the caller's existing fallback.
135
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — the damage hitbox is the rendered model's own triangles, not a bounding box.
136
+ * @internal
137
+ */
138
+ export declare function meshEntityColliders(mesh: PreparedCollisionMesh): EntityColliderSet | null;
139
+ /** Blocking physical body raycasting the actual triangles the renderer mounted for an object catalog
140
+ * id — the mesh-accurate counterpart to `measuredObjectColliders`. Movement obstruction stays on the
141
+ * conservative AABB (no `boxes` decomposition at runtime); rays hit the real surface. `null` when
142
+ * degenerate.
143
+ * @internal
144
+ */
145
+ export declare function meshObjectColliders(mesh: PreparedCollisionMesh): EntityColliderSet | null;
146
+ /** True when any collider in the set already raycasts real triangles (index-derived mesh shapes win
147
+ * over runtime-measured ones — they are identical on a headless host).
148
+ * @internal
149
+ */
150
+ export declare function hasMeshColliderShape(set: EntityColliderSet | null | undefined): boolean;
131
151
  /** @internal */
132
152
  export declare function resolveColliders(set: EntityColliderSet | null | undefined): ResolvedCollider[];
133
153
  /** @internal */
@@ -250,6 +250,84 @@ export function measuredObjectColliders(bounds) {
250
250
  },
251
251
  };
252
252
  }
253
+ /** Root-node AABB of a prepared mesh (the BVH root bounds the whole soup) as a clamped
254
+ * entity-local box — `null` when degenerate. */
255
+ function preparedMeshShape(mesh) {
256
+ const bounds = mesh.nodeBounds;
257
+ if (bounds.length < 6)
258
+ return null;
259
+ const min = [bounds[0], bounds[1], bounds[2]];
260
+ const max = [bounds[3], bounds[4], bounds[5]];
261
+ const box = measuredBox({ min, max });
262
+ if (box === null)
263
+ return null;
264
+ return {
265
+ kind: "mesh",
266
+ mesh,
267
+ meshScale: 1,
268
+ meshTranslate: [0, 0, 0],
269
+ halfExtents: box.halfExtents,
270
+ offset: box.offset,
271
+ };
272
+ }
273
+ /** Damage hitbox raycasting the actual triangles the renderer mounted for an entity kind — what
274
+ * `reportCollisionMesh` stores. Triangles are entity-local (scale/placement already applied), so the
275
+ * shape composes with identity `meshScale`/`meshTranslate`; bounds/broadphase read the clamped AABB.
276
+ * `null` when the mesh is degenerate, keeping the caller's existing fallback.
277
+ * @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — the damage hitbox is the rendered model's own triangles, not a bounding box.
278
+ * @internal
279
+ */
280
+ export function meshEntityColliders(mesh) {
281
+ const shape = preparedMeshShape(mesh);
282
+ if (shape === null)
283
+ return null;
284
+ return {
285
+ hitboxes: [
286
+ {
287
+ name: "body",
288
+ purpose: "damage",
289
+ shape,
290
+ damageEligible: true,
291
+ blocks: false,
292
+ },
293
+ ],
294
+ };
295
+ }
296
+ /** Blocking physical body raycasting the actual triangles the renderer mounted for an object catalog
297
+ * id — the mesh-accurate counterpart to `measuredObjectColliders`. Movement obstruction stays on the
298
+ * conservative AABB (no `boxes` decomposition at runtime); rays hit the real surface. `null` when
299
+ * degenerate.
300
+ * @internal
301
+ */
302
+ export function meshObjectColliders(mesh) {
303
+ const shape = preparedMeshShape(mesh);
304
+ if (shape === null)
305
+ return null;
306
+ return {
307
+ body: {
308
+ name: "body",
309
+ purpose: "physical",
310
+ shape,
311
+ damageEligible: false,
312
+ blocks: true,
313
+ },
314
+ };
315
+ }
316
+ /** True when any collider in the set already raycasts real triangles (index-derived mesh shapes win
317
+ * over runtime-measured ones — they are identical on a headless host).
318
+ * @internal
319
+ */
320
+ export function hasMeshColliderShape(set) {
321
+ if (set === null || set === undefined)
322
+ return false;
323
+ if (set.body?.shape.kind === "mesh")
324
+ return true;
325
+ for (const hitbox of set.hitboxes ?? []) {
326
+ if (hitbox.shape.kind === "mesh")
327
+ return true;
328
+ }
329
+ return false;
330
+ }
253
331
  /** @internal */
254
332
  export function resolveColliders(set) {
255
333
  if (set === null || set === undefined)
@@ -77,6 +77,14 @@ export declare function voxelizeToBoxes(source: CollisionMeshSource, cellSize?:
77
77
  min: [number, number, number];
78
78
  max: [number, number, number];
79
79
  }[];
80
+ /**
81
+ * Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) —
82
+ * the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding
83
+ * `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a
84
+ * quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range,
85
+ * non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
86
+ */
87
+ export declare function prepareCollisionMeshSource(source: CollisionMeshSource): PreparedCollisionMesh | null;
80
88
  /**
81
89
  * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
82
90
  * a headless host preparing the same catalog entry share one build. Returns `null` (also memoized)
@@ -500,6 +500,54 @@ function buildBvh(positions, indices) {
500
500
  triOrder,
501
501
  };
502
502
  }
503
+ /**
504
+ * Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) —
505
+ * the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding
506
+ * `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a
507
+ * quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range,
508
+ * non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
509
+ */
510
+ export function prepareCollisionMeshSource(source) {
511
+ const raw = source.positions;
512
+ const rawIndices = source.indices;
513
+ if (raw.length < 9 || raw.length % 3 !== 0)
514
+ return null;
515
+ if (rawIndices.length < 3 || rawIndices.length % 3 !== 0)
516
+ return null;
517
+ const vertexCount = raw.length / 3;
518
+ const positions = new Float32Array(raw.length);
519
+ const finite = new Uint8Array(vertexCount);
520
+ for (let v = 0; v < vertexCount; v += 1) {
521
+ const x = raw[v * 3];
522
+ const y = raw[v * 3 + 1];
523
+ const z = raw[v * 3 + 2];
524
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z))
525
+ continue;
526
+ positions[v * 3] = x;
527
+ positions[v * 3 + 1] = y;
528
+ positions[v * 3 + 2] = z;
529
+ finite[v] = 1;
530
+ }
531
+ const triangles = [];
532
+ for (let t = 0; t + 2 < rawIndices.length; t += 3) {
533
+ const a = rawIndices[t];
534
+ const b = rawIndices[t + 1];
535
+ const c = rawIndices[t + 2];
536
+ if (!Number.isInteger(a) || a < 0 || a >= vertexCount || finite[a] === 0)
537
+ continue;
538
+ if (!Number.isInteger(b) || b < 0 || b >= vertexCount || finite[b] === 0)
539
+ continue;
540
+ if (!Number.isInteger(c) || c < 0 || c >= vertexCount || finite[c] === 0)
541
+ continue;
542
+ if (a === b || b === c || a === c)
543
+ continue;
544
+ triangles.push(a, b, c);
545
+ }
546
+ if (triangles.length === 0)
547
+ return null;
548
+ const indices = new Uint32Array(triangles);
549
+ return { positions, indices, ...buildBvh(positions, indices) };
550
+ }
503
551
  const preparedCache = new WeakMap();
504
552
  /**
505
553
  * Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and
@@ -22,6 +22,8 @@ export interface SceneEntity<TMeta = unknown> {
22
22
  role: EntityRole;
23
23
  movement: EntityMovement;
24
24
  behaviors: readonly BehaviorDescriptor[];
25
+ /** Render-only visibility: `true` skips the entity's visual entirely (seated vehicle riders, cutscene actors, stealth cloaks) while the entity keeps simulating — position, stats, AI targeting, and queries are untouched. Absent/`false` renders normally. */
26
+ hidden?: boolean;
25
27
  /** Game-defined per-instance data set at `spawn`/`update` and carried to `renderEntity`/queries — narrow with {@link entityMetaOf} (or a type guard) on read (#286.1); never encode state in the id/name string. */
26
28
  meta: TMeta;
27
29
  }
@@ -44,6 +46,7 @@ export interface SpawnOptions<TMeta = unknown> {
44
46
  role?: EntityRole;
45
47
  movement?: EntityMovement;
46
48
  behaviors?: readonly BehaviorDescriptor[];
49
+ hidden?: boolean;
47
50
  meta?: TMeta;
48
51
  /** When `id` is already spawned: `"throw"` (default), `"replace"` (fresh spawn over it — remount-safe world setup, #284.10), or `"keep"` (leave it untouched and return the id). */
49
52
  onExisting?: "throw" | "replace" | "keep";
@@ -68,7 +71,7 @@ export interface PoseConstraintFrame {
68
71
  }
69
72
  /** Return a replacement position to constrain the step, or nothing to accept it. */
70
73
  export type PoseConstraint = (frame: PoseConstraintFrame) => readonly [number, number, number] | undefined | void;
71
- export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>, "name" | "rotationY" | "rotationX" | "rotationZ" | "role" | "movement" | "behaviors" | "meta">> & {
74
+ export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>, "name" | "rotationY" | "rotationX" | "rotationZ" | "role" | "movement" | "behaviors" | "hidden" | "meta">> & {
72
75
  /** Accepts the same friendly shapes as `spawn`/`setPose` (#286.13). Raw patch semantics — velocity is not derived; use `setPose` with `dt` for that. */
73
76
  position?: SpawnPositionInput;
74
77
  };
@@ -91,6 +91,7 @@ export function createEntityStore() {
91
91
  role: options.role ?? "prop",
92
92
  movement: options.movement ?? {},
93
93
  behaviors: options.behaviors ?? [],
94
+ ...(options.hidden === undefined ? {} : { hidden: options.hidden }),
94
95
  meta: options.meta,
95
96
  });
96
97
  spawnPoses.set(id, { position, rotationY });
@@ -124,6 +125,8 @@ export function createEntityStore() {
124
125
  current.movement = patch.movement;
125
126
  if (patch.behaviors !== undefined)
126
127
  current.behaviors = patch.behaviors;
128
+ if (patch.hidden !== undefined)
129
+ current.hidden = patch.hidden;
127
130
  if (patch.meta !== undefined)
128
131
  current.meta = patch.meta;
129
132
  store.set(id, current);
@@ -1,3 +1,4 @@
1
+ import type { ModelAnimationConfig } from "../game/playableGame.js";
1
2
  import type { EntityPosition } from "./entityStore.js";
2
3
  export interface ObjectVisual {
3
4
  scale?: number | readonly [number, number, number];
@@ -11,12 +12,21 @@ export interface SceneObject {
11
12
  rotationY: number;
12
13
  parentSpace?: string;
13
14
  visual?: ObjectVisual;
15
+ /**
16
+ * Per-placement rig animation override applied on top of catalog resolution — the same shape as
17
+ * `ModelConfig.animation` (role→clip states, `"auto"`/`"none"`, speeds, fade, one-shot bindings).
18
+ * Authored in the editor as `marker.meta.animation` (#1274) and threaded here by
19
+ * `resolveAuthoredObjects` → `placeAuthoredObjects`; `undefined` keeps the catalog-resolved default.
20
+ */
21
+ animation?: ModelAnimationConfig | "auto" | "none";
14
22
  }
15
23
  export interface PlaceOptions {
16
24
  instanceId?: string;
17
25
  parentSpace?: string;
18
26
  rotation?: number;
19
27
  visual?: ObjectVisual;
28
+ /** Per-placement rig animation override stored on the placed {@link SceneObject}; same shape as `ModelConfig.animation`. Omit to keep the catalog-resolved default. */
29
+ animation?: ModelAnimationConfig | "auto" | "none";
20
30
  /** When `instanceId` is already placed: `"throw"` (default), `"replace"` (fresh placement over it — remount-safe world setup, #284.10), or `"keep"` (leave it untouched and return the id). */
21
31
  onExisting?: "throw" | "replace" | "keep";
22
32
  }
@@ -1,5 +1,12 @@
1
1
  import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
2
2
  const CELL_SIZE = 1;
3
+ /**
4
+ * When an `inBox` query spans more than this many 1 m cells, walk the object list instead of the
5
+ * cell grid. Tall/wide colliders (city buildings) can inflate movement broadphase to hundreds of
6
+ * metres on each axis — at 1 m cells that is O(10⁶) empty `Map.get`s per frame and freezes `pose`.
7
+ * Linear scan over a few hundred objects is cheaper the moment the cell volume exceeds the scene.
8
+ */
9
+ const IN_BOX_CELL_VOLUME_LIMIT = 4096;
3
10
  function cellIndexOf(value) {
4
11
  return Math.floor(value / CELL_SIZE);
5
12
  }
@@ -9,6 +16,10 @@ function cellKey(cx, cy, cz) {
9
16
  function cellKeyOf(position) {
10
17
  return cellKey(cellIndexOf(position[0]), cellIndexOf(position[1]), cellIndexOf(position[2]));
11
18
  }
19
+ function objectInAabb(object, min, max) {
20
+ const [x, y, z] = object.position;
21
+ return x >= min[0] && x <= max[0] && y >= min[1] && y <= max[1] && z >= min[2] && z <= max[2];
22
+ }
12
23
  export function createObjectStore() {
13
24
  const store = createObservableKeyedStore();
14
25
  const cellIndex = new Map();
@@ -60,6 +71,7 @@ export function createObjectStore() {
60
71
  rotationY: options.rotation ?? 0,
61
72
  ...(options.parentSpace !== undefined ? { parentSpace: options.parentSpace } : {}),
62
73
  ...(options.visual !== undefined ? { visual: options.visual } : {}),
74
+ ...(options.animation !== undefined ? { animation: options.animation } : {}),
63
75
  });
64
76
  indexAdd(instanceId, position);
65
77
  return instanceId;
@@ -99,6 +111,7 @@ export function createObjectStore() {
99
111
  position: current.position,
100
112
  rotationY: current.rotationY,
101
113
  ...(current.parentSpace !== undefined ? { parentSpace: current.parentSpace } : {}),
114
+ ...(current.animation !== undefined ? { animation: current.animation } : {}),
102
115
  ...(visual !== undefined ? { visual } : {}),
103
116
  });
104
117
  return true;
@@ -145,13 +158,27 @@ export function createObjectStore() {
145
158
  return results;
146
159
  },
147
160
  inBox(min, max) {
148
- const hits = [];
149
161
  const minCx = cellIndexOf(min[0]);
150
162
  const maxCx = cellIndexOf(max[0]);
151
163
  const minCy = cellIndexOf(min[1]);
152
164
  const maxCy = cellIndexOf(max[1]);
153
165
  const minCz = cellIndexOf(min[2]);
154
166
  const maxCz = cellIndexOf(max[2]);
167
+ const cellsX = Math.max(0, maxCx - minCx + 1);
168
+ const cellsY = Math.max(0, maxCy - minCy + 1);
169
+ const cellsZ = Math.max(0, maxCz - minCz + 1);
170
+ const cellVolume = cellsX * cellsY * cellsZ;
171
+ // Huge queries (city-scale movement broadphase inflated by tall building colliders): scanning
172
+ // every empty 1 m cell is far more expensive than testing the few hundred placed objects.
173
+ if (cellVolume > IN_BOX_CELL_VOLUME_LIMIT) {
174
+ const hits = [];
175
+ for (const object of store.arraySnapshot()) {
176
+ if (objectInAabb(object, min, max))
177
+ hits.push(object);
178
+ }
179
+ return hits;
180
+ }
181
+ const hits = [];
155
182
  for (let cx = minCx; cx <= maxCx; cx += 1) {
156
183
  for (let cy = minCy; cy <= maxCy; cy += 1) {
157
184
  for (let cz = minCz; cz <= maxCz; cz += 1) {
@@ -162,12 +189,8 @@ export function createObjectStore() {
162
189
  const object = store.get(instanceId);
163
190
  if (object === undefined)
164
191
  continue;
165
- const [x, y, z] = object.position;
166
- if (x >= min[0] && x <= max[0] &&
167
- y >= min[1] && y <= max[1] &&
168
- z >= min[2] && z <= max[2]) {
192
+ if (objectInAabb(object, min, max))
169
193
  hits.push(object);
170
- }
171
194
  }
172
195
  }
173
196
  }
@@ -145,6 +145,19 @@ export interface ParamSchema {
145
145
  export declare function findSchemaPreset(schema: ParamSchema, id: string): ParamPreset | undefined;
146
146
  /** Parsed params after `parseParams`: every schema field present with a validated, defaulted value. */
147
147
  export type ParsedParams = Record<string, number | boolean | string | WeightedParamEntry[]>;
148
+ /**
149
+ * Marks a kind as *scatterable* — it fills an authored area with instances at one density under a
150
+ * bounded budget — so the editor renders the shared `CoverageSection` (Area → Assets → Density slider
151
+ * + budget note) that gives grass/scatter/city one authoring gesture. `spec` selects the unit + budget
152
+ * from `@jgengine/core/world/scatterCoverage`; `densityKey` is the single density slider hoisted into
153
+ * the coverage header (and hidden from the schema body below); `assetsKey` is the optional weighted
154
+ * "what fills it" palette summarized in the header.
155
+ */
156
+ export interface SceneKindCoverage {
157
+ spec: import("../world/scatterCoverage.js").ScatterCoverageKind;
158
+ densityKey: string;
159
+ assetsKey?: string;
160
+ }
148
161
  /**
149
162
  * Parse a raw `meta` bag against a schema into typed params — every field present, invalid/missing
150
163
  * values replaced by the field default, numbers clamped to their range. The single parser every
@@ -201,6 +214,12 @@ export interface SceneKindDefinition<TResolved = unknown> {
201
214
  resolve?: (object: SceneKindObject, params: ParsedParams, context: SceneKindResolveContext) => TResolved;
202
215
  /** Optional one-line readout under the inspector fields (estimate, dimensions) — owned by the studio. */
203
216
  note?: (object: SceneKindObject, params: ParsedParams) => string;
217
+ /**
218
+ * Optional scatterable-coverage descriptor. When set, the editor leads this kind's inspector with
219
+ * the shared Area / Assets / Density `CoverageSection` and surfaces the budget clamp-and-warn note,
220
+ * giving every scatterable kind (grass/scatter/city) one authoring pattern.
221
+ */
222
+ coverage?: SceneKindCoverage;
204
223
  }
205
224
  /** The raw document object a resolver receives — shape shared by markers, volumes, and paths. */
206
225
  export interface SceneKindObject {
@@ -246,6 +265,24 @@ export interface SceneKindResolveContext {
246
265
  * inspector, `+ Add` menu, and `AuthoredScene` renderer lookup all read this registry.
247
266
  */
248
267
  export declare function registerSceneKind<TResolved>(definition: SceneKindDefinition<TResolved>): void;
268
+ /**
269
+ * Declare a game's own **click-to-place** marker kind in one call — the light path for logical
270
+ * markers a game reads off the document by `kind` (a stash, checkpoint, spawn point, bounty…), with
271
+ * none of a full studio's schema/resolver boilerplate. Registering it makes the kind a click-to-place
272
+ * tool in the editor's `+ Add` menu (grouped under `category`) and gives its markers an inspector for
273
+ * any `fields` you pass. Call at module load from a module the editor loads (a game's `editorLayers`
274
+ * import graph). Rendering is unaffected — the game still reads `kind` off `editorLayers.markers`.
275
+ */
276
+ export declare function definePlaceableMarkerKind(options: {
277
+ kind: string;
278
+ label: string;
279
+ /** `+ Add` menu section header this kind groups under. Defaults to `"Custom"`. */
280
+ category?: string;
281
+ /** Accent hex for the menu entry + inspector header. */
282
+ accent?: string;
283
+ /** Optional inspector fields written into each marker's `meta` (e.g. a payout value). */
284
+ fields?: readonly ParamField[];
285
+ }): string;
249
286
  /** The registered definition for a kind, or undefined if the kind is a plain (non-studio) kind. @internal */
250
287
  export declare function getSceneKind(kind: string): SceneKindDefinition | undefined;
251
288
  /** True when a kind has a registered studio definition (schema/inspector/renderer). @internal */
@@ -198,6 +198,25 @@ const registry = new Map();
198
198
  export function registerSceneKind(definition) {
199
199
  registry.set(definition.kind, definition);
200
200
  }
201
+ /**
202
+ * Declare a game's own **click-to-place** marker kind in one call — the light path for logical
203
+ * markers a game reads off the document by `kind` (a stash, checkpoint, spawn point, bounty…), with
204
+ * none of a full studio's schema/resolver boilerplate. Registering it makes the kind a click-to-place
205
+ * tool in the editor's `+ Add` menu (grouped under `category`) and gives its markers an inspector for
206
+ * any `fields` you pass. Call at module load from a module the editor loads (a game's `editorLayers`
207
+ * import graph). Rendering is unaffected — the game still reads `kind` off `editorLayers.markers`.
208
+ */
209
+ export function definePlaceableMarkerKind(options) {
210
+ registerSceneKind({
211
+ kind: options.kind,
212
+ target: "marker",
213
+ label: options.label,
214
+ addCategory: options.category ?? "Custom",
215
+ ...(options.accent === undefined ? {} : { accent: options.accent }),
216
+ schema: { fields: options.fields ?? [] },
217
+ });
218
+ return options.kind;
219
+ }
201
220
  /** The registered definition for a kind, or undefined if the kind is a plain (non-studio) kind. @internal */
202
221
  export function getSceneKind(kind) {
203
222
  return registry.get(kind);