@jgengine/core 0.13.0 → 0.14.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.
- package/CHANGELOG.md +47 -1
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +21 -1
- package/dist/runtime/context/scene.d.ts +2 -0
- package/dist/runtime/context/scene.js +48 -1
- package/dist/runtime/gameContext.js +2 -1
- package/dist/runtime/gameContextTypes.d.ts +21 -0
- package/dist/scene/builtinSceneKinds.d.ts +6 -1
- package/dist/scene/builtinSceneKinds.js +10 -3
- package/dist/scene/colliders.d.ts +20 -0
- package/dist/scene/colliders.js +78 -0
- package/dist/scene/collisionMesh.d.ts +8 -0
- package/dist/scene/collisionMesh.js +48 -0
- package/dist/scene/sceneKinds.d.ts +19 -0
- package/dist/ui/hudScale.d.ts +16 -1
- package/dist/ui/hudScale.js +22 -1
- package/dist/world/cityKind.js +8 -2
- package/dist/world/grassKind.js +4 -1
- package/dist/world/scatterCoverage.d.ts +98 -0
- package/dist/world/scatterCoverage.js +101 -0
- package/dist/world/scatterRegion.js +6 -0
- package/dist/world.d.ts +1 -0
- package/dist/world.js +1 -0
- package/package.json +2 -2
- package/skills/jgengine/SKILL.md +4 -2
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-world/api.md +30 -1
- package/skills/jgengine-world/capabilities.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,7 +10,53 @@ let consumers pick up the better stuff, not just to list what moved.
|
|
|
10
10
|
Agents building on the published SDK can also read this programmatically:
|
|
11
11
|
`import { VERSION, CHANGELOG } from "@jgengine/core/meta/changelog"` gives the
|
|
12
12
|
same data as typed values, so an updater can diff its installed version against
|
|
13
|
-
the latest and surface the migration steps.
|
|
13
|
+
the latest and surface the migration steps. Easier still: `npx jgengine upgrade`
|
|
14
|
+
run inside a game project diffs the installed `@jgengine/*` versions against the
|
|
15
|
+
latest release and prints every Migrate step and Adopt-worthy addition in
|
|
16
|
+
between (`--json` for structured output).
|
|
17
|
+
|
|
18
|
+
## [Unreleased]
|
|
19
|
+
|
|
20
|
+
<!--
|
|
21
|
+
Every PR that changes `packages/*/src` records its consumer-facing change here, so
|
|
22
|
+
the next release's notes are complete by construction. Add a bullet under the right
|
|
23
|
+
subhead below. `bun run check-changelog` fails a source PR whose `[Unreleased]` block
|
|
24
|
+
is untouched; pure refactors/tests bypass with `[skip changelog]` in a commit message.
|
|
25
|
+
At publish, rename this heading to the new version and mirror the entries into
|
|
26
|
+
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
|
+
-->
|
|
28
|
+
|
|
29
|
+
### Migrate
|
|
30
|
+
|
|
31
|
+
- _Nothing yet._
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
## 0.14.0
|
|
40
|
+
|
|
41
|
+
### Migrate
|
|
42
|
+
|
|
43
|
+
- **Nothing required** — this release is additive drop-in primitives, editor authoring, CLI discovery, and fixes. Bump `@jgengine/*` pins to `^0.14.0` (CLI `jgengine` is 0.11.0; `@jgengine/github` unchanged at 0.1.0) and rebuild.
|
|
44
|
+
- One behavior fix to know about: in a `rig: "chase"` game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig). If a game somehow relied on world-yaw-0 movement under a chase camera, that was the bug this fixes.
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- One authoring gesture for scatterable coverage: `@jgengine/core/world/scatterCoverage` owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for `grass_field`/`scatter`/`city`; the editor inspector leads each kind with the same Area → Assets → Density coverage section, scatter truncates to the shared 250k instance budget, and city single-sources its 2,600-lot cap.
|
|
49
|
+
- **Created games ship a drive/playtest script** (#1248) — `npx jgengine create` scaffolds now include `scripts/drive.mjs` (`bun run drive`): dependency-free headless play/testing of the running game — ordered `--click`/`--key`/`--wait`/`--shot`/`--rpc` steps against the agent bridge, plus `--playtest --strict` progress/softlock verdicts off `capture.probe`. Shared Chrome/CDP machinery lives in `scripts/browser.mjs`; `scripts/shoot.mjs` is unchanged in behavior, now a thin CLI over it.
|
|
50
|
+
- Editor: viewport clip preview for rigged assets (Animation dock "Clips" mode — pick a rig or placed instance, play/scrub/loop/speed any catalog clip) and an Inspector "Animation" section that authors a placement's `ModelConfig.animation` (role→clip dropdowns, auto/none, walk/run/fade, one-shot event bindings) as undoable `marker.meta.animation` edits. New subpaths: `@jgengine/editor/shell/clipPreview`, `@jgengine/editor/modelAnimationAuthoring`, `@jgengine/editor/ClipPreviewLayer`.
|
|
51
|
+
- **`npx jgengine find <intent>`** — active capability discovery: searches every shipped domain's `capabilities.md` (staged inside the CLI tarball, so it works regardless of which `@jgengine/*` packages a project installed) and prints the drop-in primitive + its import for an intent like `"toggleable window"`, `inventory`, or `minimap`. Scaffolded games are now briefed (in `AGENTS.md` and the `jgengine` intake skill) to reach for it before hand-rolling a HUD/inventory/window/rig.
|
|
52
|
+
- **`EntityPreview`** (`@jgengine/shell/render/EntityPreview`) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers. Owns the nested `<Canvas>`, the `GameContextBridge`, and a `StudioStage` lighting rig with optional turntable / face-camera; the game passes its own `renderEntity` as children (or a native `model` + `instanceId`) and, bound to a live entity, the portrait walks/flinches/topples in sync with the world.
|
|
53
|
+
- **`GameContextBridge`** (`@jgengine/react`) — re-provide the running `GameContext` across a nested React reconciler boundary (the R3F `<Canvas>`), so a game building its own preview canvas no longer re-derives the bridge by hand.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **Chase rig reports its yaw to the shell** — `ChaseRig` now writes the camera yaw back to the shared yaw ref like every other player-facing rig, so on-foot movement and aim in a `rig: "chase"` game are camera-relative instead of frozen to world yaw 0 (WASD no longer fights the camera the moment it swings behind the player). Interior views (cockpit/hood/rear) report the vehicle heading.
|
|
58
|
+
- **Combat VFX no longer render as black squares under AO/DOF post-processing** (#1247) — GTAO/Bokeh scene prepasses skip overlay effects. Games with custom additive overlay effects opt out the same way: spread `POSTFX_OVERLAY_USERDATA` (`@jgengine/shell/postfx/postfxOverlay`) onto the overlay group's `userData`.
|
|
59
|
+
- **`PanelHost` windows stack above the HUD by default** (`@jgengine/react`) — the host establishes its own stacking context at `zIndexBase` (default 40), so open windows always paint over stat bars / nameplates / frames instead of bleeding through. Overridable per instance.
|
|
14
60
|
|
|
15
61
|
## 0.13.0
|
|
16
62
|
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.14.0";
|
|
3
3
|
/** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
|
|
4
4
|
export interface ChangelogEntry {
|
|
5
5
|
migrate: readonly string[];
|
package/dist/meta/changelog.js
CHANGED
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.14.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.14.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"Bump lockstep SDK packages to ^0.14.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.11.0; @jgengine/github is unchanged at 0.1.0.",
|
|
8
|
+
"Nothing required — additive drop-in primitives, editor authoring, CLI discovery, and fixes. One behavior fix: in a rig: \"chase\" game, on-foot movement and aim are now camera-relative (the chase rig reports its yaw to the shell like every other rig).",
|
|
9
|
+
],
|
|
10
|
+
added: [
|
|
11
|
+
"One authoring gesture for scatterable coverage — @jgengine/core/world/scatterCoverage owns shared density/budget semantics (per-kind unit, requested→count→capped, one clamp-and-warn phrasing) for grass_field/scatter/city; the editor inspector leads each kind with the same Area → Assets → Density coverage section.",
|
|
12
|
+
"Created games ship a drive/playtest script (#1248) — npx jgengine create scaffolds include scripts/drive.mjs (bun run drive): dependency-free headless play/testing via ordered --click/--key/--wait/--shot/--rpc steps against the agent bridge, plus --playtest --strict verdicts off capture.probe.",
|
|
13
|
+
"Editor viewport clip preview for rigged assets (Animation dock Clips mode) and an Inspector Animation section authoring a placement's ModelConfig.animation as undoable marker.meta.animation edits. New subpaths: @jgengine/editor/shell/clipPreview, @jgengine/editor/modelAnimationAuthoring, @jgengine/editor/ClipPreviewLayer.",
|
|
14
|
+
"npx jgengine find <intent> — active capability discovery: searches every shipped domain's capabilities.md and prints the drop-in primitive + its import for an intent like \"toggleable window\", inventory, or minimap.",
|
|
15
|
+
"EntityPreview (@jgengine/shell/render/EntityPreview) — drop-in live 3D entity portrait for character screens, unit inspectors, and loadout viewers; owns the nested Canvas, GameContextBridge, and a StudioStage lighting rig with optional turntable / face-camera.",
|
|
16
|
+
"GameContextBridge (@jgengine/react) — re-provide the running GameContext across a nested React reconciler boundary (the R3F Canvas).",
|
|
17
|
+
],
|
|
18
|
+
changed: [
|
|
19
|
+
"Chase rig reports its yaw to the shell — on-foot movement and aim in a rig: \"chase\" game are camera-relative instead of frozen to world yaw 0; interior views (cockpit/hood/rear) report the vehicle heading.",
|
|
20
|
+
"Combat VFX no longer render as black squares under AO/DOF post-processing (#1247) — GTAO/Bokeh scene prepasses skip overlay effects; custom additive overlays opt out by spreading POSTFX_OVERLAY_USERDATA (@jgengine/shell/postfx/postfxOverlay) onto the overlay group's userData.",
|
|
21
|
+
"PanelHost windows stack above the HUD by default (@jgengine/react) — the host establishes its own stacking context at zIndexBase (default 40), overridable per instance.",
|
|
22
|
+
],
|
|
23
|
+
removed: [],
|
|
24
|
+
},
|
|
5
25
|
"0.13.0": {
|
|
6
26
|
migrate: [
|
|
7
27
|
"Bump lockstep SDK packages to ^0.13.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.10.0; @jgengine/github is unchanged.",
|
|
@@ -5,6 +5,7 @@ import { type EntityStatsApi, type StatValueMap } from "../../scene/entityStats.
|
|
|
5
5
|
import { type EntityPosition, type EntityStore, type SceneEntity, type SpawnOptions } from "../../scene/entityStore.js";
|
|
6
6
|
import { type Forms } from "../../scene/form.js";
|
|
7
7
|
import { type EntityColliderSet, type MeasuredBounds } from "../../scene/colliders.js";
|
|
8
|
+
import { type CollisionMeshSource } from "../../scene/collisionMesh.js";
|
|
8
9
|
import { type ObjectStore } from "../../scene/objectStore.js";
|
|
9
10
|
import { type PaintLayer } from "../../scene/paintLayer.js";
|
|
10
11
|
import { type SceneRaycastApi } from "../../scene/sceneRaycast.js";
|
|
@@ -42,6 +43,7 @@ export interface SceneSubsystem {
|
|
|
42
43
|
entityCollidersOf: (instanceId: string) => EntityColliderSet | null;
|
|
43
44
|
objectCollidersOf: (instanceId: string) => EntityColliderSet | null;
|
|
44
45
|
reportEntityBounds: (kind: string, bounds: MeasuredBounds | null) => boolean;
|
|
46
|
+
reportEntityCollisionMesh: (kind: string, mesh: CollisionMeshSource | null) => boolean;
|
|
45
47
|
entityVisualScaleOf: (instanceId: string) => number;
|
|
46
48
|
forms: Forms;
|
|
47
49
|
paintLayer: PaintLayer;
|
|
@@ -2,7 +2,8 @@ import { createBodyBind } from "../../scene/bodyBind.js";
|
|
|
2
2
|
import { createEntityStatsApi, seedStatValues, } from "../../scene/entityStats.js";
|
|
3
3
|
import { createEntityStore, } from "../../scene/entityStore.js";
|
|
4
4
|
import { createForms } from "../../scene/form.js";
|
|
5
|
-
import { fittedEntityColliders, fittedObjectColliders, measuredEntityColliders, measuredObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
|
|
5
|
+
import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, measuredEntityColliders, measuredObjectColliders, meshEntityColliders, meshObjectColliders, scaledEntityColliders, scaledObjectColliders, } from "../../scene/colliders.js";
|
|
6
|
+
import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
|
|
6
7
|
import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
|
|
7
8
|
import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
|
|
8
9
|
import { createPaintLayer } from "../../scene/paintLayer.js";
|
|
@@ -98,6 +99,38 @@ export function createSceneSubsystem(d) {
|
|
|
98
99
|
signalNotify();
|
|
99
100
|
return true;
|
|
100
101
|
}
|
|
102
|
+
const meshEntityByKind = new Map();
|
|
103
|
+
const meshObjectByCatalogId = new Map();
|
|
104
|
+
function reportEntityCollisionMesh(kind, mesh) {
|
|
105
|
+
if (mesh === null) {
|
|
106
|
+
const existed = meshEntityByKind.delete(kind);
|
|
107
|
+
if (existed)
|
|
108
|
+
signalNotify();
|
|
109
|
+
return existed;
|
|
110
|
+
}
|
|
111
|
+
const prepared = prepareCollisionMeshSource(mesh);
|
|
112
|
+
const set = prepared === null ? null : meshEntityColliders(prepared);
|
|
113
|
+
if (set === null)
|
|
114
|
+
return false;
|
|
115
|
+
meshEntityByKind.set(kind, set);
|
|
116
|
+
signalNotify();
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function reportObjectCollisionMesh(catalogId, mesh) {
|
|
120
|
+
if (mesh === null) {
|
|
121
|
+
const existed = meshObjectByCatalogId.delete(catalogId);
|
|
122
|
+
if (existed)
|
|
123
|
+
signalNotify();
|
|
124
|
+
return existed;
|
|
125
|
+
}
|
|
126
|
+
const prepared = prepareCollisionMeshSource(mesh);
|
|
127
|
+
const set = prepared === null ? null : meshObjectColliders(prepared);
|
|
128
|
+
if (set === null)
|
|
129
|
+
return false;
|
|
130
|
+
meshObjectByCatalogId.set(catalogId, set);
|
|
131
|
+
signalNotify();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
101
134
|
function reportObjectBounds(catalogId, bounds) {
|
|
102
135
|
if (bounds === null) {
|
|
103
136
|
const existed = measuredObjectByCatalogId.delete(catalogId);
|
|
@@ -122,6 +155,13 @@ export function createSceneSubsystem(d) {
|
|
|
122
155
|
const kind = entities.get(instanceId)?.name;
|
|
123
156
|
if (kind !== undefined) {
|
|
124
157
|
const fitted = fittedEntitySetFor(kind);
|
|
158
|
+
// Index-derived mesh shapes win (identical on a headless host); otherwise a runtime-reported
|
|
159
|
+
// collision mesh upgrades the fitted/measured bounding box to the rendered triangles.
|
|
160
|
+
if (fitted !== null && hasMeshColliderShape(fitted))
|
|
161
|
+
return fitted;
|
|
162
|
+
const runtimeMesh = meshEntityByKind.get(kind);
|
|
163
|
+
if (runtimeMesh !== undefined)
|
|
164
|
+
return runtimeMesh;
|
|
125
165
|
if (fitted !== null)
|
|
126
166
|
return fitted;
|
|
127
167
|
const measured = measuredEntityByKind.get(kind);
|
|
@@ -149,6 +189,11 @@ export function createSceneSubsystem(d) {
|
|
|
149
189
|
if (object === null)
|
|
150
190
|
return null;
|
|
151
191
|
const fitted = fittedObjectSetFor(object.catalogId);
|
|
192
|
+
if (fitted !== null && hasMeshColliderShape(fitted))
|
|
193
|
+
return fitted;
|
|
194
|
+
const runtimeMesh = meshObjectByCatalogId.get(object.catalogId);
|
|
195
|
+
if (runtimeMesh !== undefined)
|
|
196
|
+
return runtimeMesh;
|
|
152
197
|
if (fitted !== null)
|
|
153
198
|
return fitted;
|
|
154
199
|
const measured = measuredObjectByCatalogId.get(object.catalogId);
|
|
@@ -291,6 +336,7 @@ export function createSceneSubsystem(d) {
|
|
|
291
336
|
},
|
|
292
337
|
collidersOf: objectCollidersOf,
|
|
293
338
|
reportBounds: reportObjectBounds,
|
|
339
|
+
reportCollisionMesh: reportObjectCollisionMesh,
|
|
294
340
|
selection: objectSelection,
|
|
295
341
|
};
|
|
296
342
|
return {
|
|
@@ -310,6 +356,7 @@ export function createSceneSubsystem(d) {
|
|
|
310
356
|
entityCollidersOf,
|
|
311
357
|
objectCollidersOf,
|
|
312
358
|
reportEntityBounds,
|
|
359
|
+
reportEntityCollisionMesh,
|
|
313
360
|
entityVisualScaleOf,
|
|
314
361
|
forms,
|
|
315
362
|
paintLayer,
|
|
@@ -56,7 +56,7 @@ export function createGameContext(options) {
|
|
|
56
56
|
...(occluder !== undefined ? { occluder } : {}),
|
|
57
57
|
...(options.models !== undefined ? { models: options.models } : {}),
|
|
58
58
|
});
|
|
59
|
-
const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
|
|
59
|
+
const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, reportEntityCollisionMesh, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
|
|
60
60
|
// --- Shared game services (weapon, feed, features bag, commands, item use) ---
|
|
61
61
|
const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
|
|
62
62
|
const feed = createGameFeed(definition.feed);
|
|
@@ -369,6 +369,7 @@ export function createGameContext(options) {
|
|
|
369
369
|
},
|
|
370
370
|
collidersOf: entityCollidersOf,
|
|
371
371
|
reportBounds: reportEntityBounds,
|
|
372
|
+
reportCollisionMesh: reportEntityCollisionMesh,
|
|
372
373
|
visualScaleOf: entityVisualScaleOf,
|
|
373
374
|
form: forms,
|
|
374
375
|
paint: paintLayer,
|
|
@@ -35,6 +35,7 @@ import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
|
|
|
35
35
|
import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
|
|
36
36
|
import type { Forms } from "../scene/form.js";
|
|
37
37
|
import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
|
|
38
|
+
import type { CollisionMeshSource } from "../scene/collisionMesh.js";
|
|
38
39
|
import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
|
|
39
40
|
import type { ObjectStore } from "../scene/objectStore.js";
|
|
40
41
|
import type { Roster } from "../scene/roster.js";
|
|
@@ -145,6 +146,15 @@ export interface SceneObjectContext extends ObjectStore {
|
|
|
145
146
|
* @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
|
|
146
147
|
*/
|
|
147
148
|
reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
|
|
149
|
+
/**
|
|
150
|
+
* Report the entity-local triangle soup the renderer actually mounted for `catalogId` — stored per
|
|
151
|
+
* catalog id as a blocking physical body whose raycasts hit the real surface (a BVH over the
|
|
152
|
+
* triangles) while movement obstruction keeps the conservative AABB. Consulted right after
|
|
153
|
+
* authored `colliders` / index collision meshes and before the fitted-box, measured-bounds, and
|
|
154
|
+
* visual-scale fallbacks. The shell reports rendered models automatically. Pass `null` to clear.
|
|
155
|
+
* Returns `false` when no valid triangle survives.
|
|
156
|
+
*/
|
|
157
|
+
reportCollisionMesh(catalogId: string, mesh: CollisionMeshSource | null): boolean;
|
|
148
158
|
/**
|
|
149
159
|
* Per-instance selection/highlight state for placed objects — the reactive counterpart to
|
|
150
160
|
* `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
|
|
@@ -275,6 +285,17 @@ export interface SceneEntityContext {
|
|
|
275
285
|
* @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
|
|
276
286
|
*/
|
|
277
287
|
reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
|
|
288
|
+
/**
|
|
289
|
+
* Report the entity-local triangle soup the renderer actually mounted for `kind` — stored per kind
|
|
290
|
+
* as a damage hitbox that raycasts the model's own triangles (BVH), so shots aimed between a
|
|
291
|
+
* character's feet or past its shoulder miss instead of hitting a bounding box. Consulted right
|
|
292
|
+
* after authored `colliders` / index collision meshes and before the fitted-box and
|
|
293
|
+
* measured-bounds fallbacks. The shell reports rendered models automatically; authoritative hosts
|
|
294
|
+
* that never render should author `colliders` or index collision meshes for identical results.
|
|
295
|
+
* Pass `null` to clear. Returns `false` when no valid triangle survives.
|
|
296
|
+
* @capability conforming-hitboxes shots aimed between a character's feet or past its shoulder MISS — damage raycasts hit the rendered model's triangles, not its bounding box.
|
|
297
|
+
*/
|
|
298
|
+
reportCollisionMesh(kind: string, mesh: CollisionMeshSource | null): boolean;
|
|
278
299
|
/** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
|
|
279
300
|
visualScaleOf(instanceId: string): number;
|
|
280
301
|
form: Forms;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { type ParamSchema } from "./sceneKinds.js";
|
|
2
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
65
|
+
return describeScatterCoverage(densityCoverage("scatter", area, density));
|
|
59
66
|
},
|
|
60
67
|
});
|
|
61
68
|
registerWaterKind();
|
|
@@ -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 */
|
package/dist/scene/colliders.js
CHANGED
|
@@ -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
|
|
@@ -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 {
|
package/dist/ui/hudScale.d.ts
CHANGED
|
@@ -37,7 +37,22 @@ export interface HudOverflow {
|
|
|
37
37
|
}
|
|
38
38
|
/** Non-null when any edge of `rect` falls outside `viewport` by more than `tolerance` px; each field is the overflow distance past that edge (0 when inside). */
|
|
39
39
|
export declare function rectOverflow(rect: HudRect, viewport: HudSize, tolerance?: number): Omit<HudOverflow, "id"> | null;
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
|
|
42
|
+
* The boundary check uses this to tell a *shown* surface from a *closed* one: a
|
|
43
|
+
* mail drawer parked fully off-screen (or a menu that has not been opened) never
|
|
44
|
+
* intersects the viewport, so it is not "escaping" — it is simply not on screen.
|
|
45
|
+
* Only a surface that is partly visible and partly past an edge is a real defect.
|
|
46
|
+
*
|
|
47
|
+
* @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
|
|
48
|
+
*/
|
|
49
|
+
export declare function rectIntersectsViewport(rect: HudRect, viewport: HudSize, minVisiblePx?: number): boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Every panel rect that escapes the viewport — the data behind the HUD overflow
|
|
52
|
+
* gate. A rect is only reported when it is partly visible *and* crosses an edge;
|
|
53
|
+
* a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
|
|
54
|
+
* unopened menus never raise a false alarm.
|
|
55
|
+
*/
|
|
41
56
|
export declare function overflowingPanels(panels: readonly {
|
|
42
57
|
id: string;
|
|
43
58
|
rect: HudRect;
|
package/dist/ui/hudScale.js
CHANGED
|
@@ -43,10 +43,31 @@ export function rectOverflow(rect, viewport, tolerance = 1.5) {
|
|
|
43
43
|
const round = (n) => Math.round(n * 10) / 10;
|
|
44
44
|
return { left: round(left), top: round(top), right: round(right), bottom: round(bottom) };
|
|
45
45
|
}
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* True when at least `minVisiblePx²` of `rect` actually falls inside `viewport`.
|
|
48
|
+
* The boundary check uses this to tell a *shown* surface from a *closed* one: a
|
|
49
|
+
* mail drawer parked fully off-screen (or a menu that has not been opened) never
|
|
50
|
+
* intersects the viewport, so it is not "escaping" — it is simply not on screen.
|
|
51
|
+
* Only a surface that is partly visible and partly past an edge is a real defect.
|
|
52
|
+
*
|
|
53
|
+
* @internal Consumed transitively through {@link overflowingPanels}; exported for unit tests.
|
|
54
|
+
*/
|
|
55
|
+
export function rectIntersectsViewport(rect, viewport, minVisiblePx = 1) {
|
|
56
|
+
const visibleWidth = Math.min(rect.x + rect.width, viewport.width) - Math.max(rect.x, 0);
|
|
57
|
+
const visibleHeight = Math.min(rect.y + rect.height, viewport.height) - Math.max(rect.y, 0);
|
|
58
|
+
return visibleWidth >= minVisiblePx && visibleHeight >= minVisiblePx;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Every panel rect that escapes the viewport — the data behind the HUD overflow
|
|
62
|
+
* gate. A rect is only reported when it is partly visible *and* crosses an edge;
|
|
63
|
+
* a fully off-screen (closed/hidden) surface is skipped, so parked drawers and
|
|
64
|
+
* unopened menus never raise a false alarm.
|
|
65
|
+
*/
|
|
47
66
|
export function overflowingPanels(panels, viewport, tolerance = 1.5) {
|
|
48
67
|
const out = [];
|
|
49
68
|
for (const panel of panels) {
|
|
69
|
+
if (!rectIntersectsViewport(panel.rect, viewport))
|
|
70
|
+
continue;
|
|
50
71
|
const overflow = rectOverflow(panel.rect, viewport, tolerance);
|
|
51
72
|
if (overflow !== null)
|
|
52
73
|
out.push({ id: panel.id, ...overflow });
|
package/dist/world/cityKind.js
CHANGED
|
@@ -32,6 +32,7 @@ import { parseParams, registerSceneKind, } from "../scene/sceneKinds.js";
|
|
|
32
32
|
import { BUILDING_STYLE_PALETTES, DEFAULT_BUILDING_STYLE } from "./buildings.js";
|
|
33
33
|
import { buildLotPieces, pickClass, pickSpecies, rollClassPlacement, zoneBand, zoneMetric, } from "./cityContent.js";
|
|
34
34
|
import { furnitureSpots } from "./streets.js";
|
|
35
|
+
import { CITY_BUILDING_BUDGET, budgetWarning, placedCoverage } from "./scatterCoverage.js";
|
|
35
36
|
import { generateStreets, streetNetworkMode, } from "./streetGenerator.js";
|
|
36
37
|
import { buildablePolygon, carveCorridors, conformPolygonToRing, cutParcel, extractBlocks, isSliverBlock, pointsInPolygon, RingWalker, } from "./cityBlocks.js";
|
|
37
38
|
import { fitRectInPolygon, rayDistanceToRing, pointInPolygon, polygonArea, polygonCentroid, polygonMeanWidth, polygonsOverlap, insetRingUniform, ringBounds, } from "./cityGeometry.js";
|
|
@@ -567,7 +568,8 @@ export function readCityRules(meta) {
|
|
|
567
568
|
}
|
|
568
569
|
/** Bounded-work caps so a huge volume can never generate unbounded content. */
|
|
569
570
|
const MAX_STREETS = 320;
|
|
570
|
-
|
|
571
|
+
/** Building budget — the shared scatterable cap so the inspector's "capped at N (budget)" note is truthful. */
|
|
572
|
+
const MAX_LOTS = CITY_BUILDING_BUDGET;
|
|
571
573
|
const MAX_INTERSECTIONS = 600;
|
|
572
574
|
const MAX_TREES = 3200;
|
|
573
575
|
const MAX_LIGHTS = 1200;
|
|
@@ -1530,6 +1532,7 @@ export function registerCityKind() {
|
|
|
1530
1532
|
addCategory: "Studios",
|
|
1531
1533
|
accent: "#8fa8c9",
|
|
1532
1534
|
schema: CITY_SCHEMA,
|
|
1535
|
+
coverage: { spec: "city", densityKey: "roadsideOccupancy" },
|
|
1533
1536
|
resolve: (object, _params, context) => resolveCityObject(object, context),
|
|
1534
1537
|
note: (object) => {
|
|
1535
1538
|
const resolved = resolveCityObject(object);
|
|
@@ -1558,7 +1561,10 @@ export function registerCityKind() {
|
|
|
1558
1561
|
resolved.tunnels.length > 0 ? `${resolved.tunnels.length} tunnels` : "",
|
|
1559
1562
|
].filter((s) => s.length > 0);
|
|
1560
1563
|
const head = mode === "circuit" ? `circuit · ${resolved.streets.length} streets` : `${resolved.streets.length} streets`;
|
|
1561
|
-
|
|
1564
|
+
// Shared clamp-and-warn: when the block/parcel pipeline hits the building budget, surface it in
|
|
1565
|
+
// the same "capped at N (budget)" wording grass and scatter use.
|
|
1566
|
+
const buildingCap = resolved.rules.fabric ? budgetWarning(placedCoverage("city", 0, resolved.lots.length)) : "";
|
|
1567
|
+
const fabric = resolved.rules.fabric ? ` · ${resolved.lots.length} buildings${buildingCap} · ${resolved.parks.length} parks` : " · no fabric";
|
|
1562
1568
|
return `${head}${fabric}${feats.length > 0 ? ` · ${feats.join(" · ")}` : ""}`;
|
|
1563
1569
|
},
|
|
1564
1570
|
});
|
package/dist/world/grassKind.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @capability grass-field editor-authorable GPU grass patch
|
|
9
9
|
*/
|
|
10
10
|
import { registerSceneKind } from "../scene/sceneKinds.js";
|
|
11
|
+
import { budgetWarning, densityCoverage } from "./scatterCoverage.js";
|
|
11
12
|
/** The editor volume kind marking a box as a GPU grass patch. */
|
|
12
13
|
export const GRASS_FIELD_KIND = "grass_field";
|
|
13
14
|
/** Grass defaults: a lush, lightly-swaying meadow. */
|
|
@@ -89,13 +90,15 @@ export function registerGrassKind() {
|
|
|
89
90
|
addCategory: "Studios",
|
|
90
91
|
accent: "#7fbf4d",
|
|
91
92
|
schema: GRASS_SCHEMA,
|
|
93
|
+
coverage: { spec: "grass_field", densityKey: "density" },
|
|
92
94
|
resolve: (object) => resolveGrassObject(object),
|
|
93
95
|
note: (object) => {
|
|
94
96
|
const resolved = resolveGrassObject(object);
|
|
95
97
|
if (resolved === null)
|
|
96
98
|
return "Give the volume a box footprint.";
|
|
97
99
|
const area = resolved.size[0] * resolved.size[1];
|
|
98
|
-
|
|
100
|
+
const coverage = densityCoverage("grass_field", area, resolved.rules.density);
|
|
101
|
+
return `${Math.round(resolved.size[0])}×${Math.round(resolved.size[1])} m · ≈ ${coverage.count.toLocaleString()} blades${budgetWarning(coverage)}`;
|
|
99
102
|
},
|
|
100
103
|
});
|
|
101
104
|
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared "how much stuff grows here" contract for scatterable scene kinds
|
|
3
|
+
* (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances
|
|
4
|
+
* at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request
|
|
5
|
+
* can never generate unbounded work. Before this module each kind hand-rolled its own readout and
|
|
6
|
+
* clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note);
|
|
7
|
+
* this is the one place that
|
|
8
|
+
*
|
|
9
|
+
* 1. names the per-kind unit noun + placement budget ({@link SCATTER_COVERAGE_SPECS}),
|
|
10
|
+
* 2. turns an area + density into a `requested` / placed `count` / `capped` triple
|
|
11
|
+
* ({@link densityCoverage}, {@link placedCoverage}), and
|
|
12
|
+
* 3. renders the identical clamp-and-warn clause the inspector surfaces —
|
|
13
|
+
* `requested N, capped at M (budget)` — via {@link budgetWarning} / {@link describeScatterCoverage}.
|
|
14
|
+
*
|
|
15
|
+
* Pure data + string helpers, no rendering. Each kind's `note` and the editor's shared
|
|
16
|
+
* `CoverageSection` consume it, so grass, scatter, and city tell one coverage story.
|
|
17
|
+
*
|
|
18
|
+
* @capability scatter-coverage shared density/budget semantics for scatterable kinds
|
|
19
|
+
*/
|
|
20
|
+
/** The scatterable kinds that share the coverage contract. */
|
|
21
|
+
export type ScatterCoverageKind = "grass_field" | "scatter" | "city";
|
|
22
|
+
/**
|
|
23
|
+
* Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass
|
|
24
|
+
* renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never
|
|
25
|
+
* renders more than this many placements regardless of density × area.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SCATTER_INSTANCE_BUDGET = 250000;
|
|
28
|
+
/** Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap. */
|
|
29
|
+
export declare const CITY_BUILDING_BUDGET = 2600;
|
|
30
|
+
/** Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget. */
|
|
31
|
+
export interface ScatterCoverageSpec {
|
|
32
|
+
kind: ScatterCoverageKind;
|
|
33
|
+
/** Plural noun for one placed instance: `blades` | `placements` | `buildings`. */
|
|
34
|
+
unit: string;
|
|
35
|
+
/** Density is per-square-meter (grass/scatter). City fills by street fabric, not by a per-m² dial. */
|
|
36
|
+
perArea: boolean;
|
|
37
|
+
/** Hard cap on placed instances for one patch/district. */
|
|
38
|
+
budget: number;
|
|
39
|
+
}
|
|
40
|
+
/** The coverage spec per scatterable kind — the single source for its unit + budget. */
|
|
41
|
+
export declare const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec>;
|
|
42
|
+
/** The coverage spec for a scatterable kind, or `null` when the kind is not scatterable. */
|
|
43
|
+
export declare function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null;
|
|
44
|
+
/**
|
|
45
|
+
* A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can
|
|
46
|
+
* be computed cheaply from geometry × density), how many it will actually place after the budget
|
|
47
|
+
* clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is
|
|
48
|
+
* `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the
|
|
49
|
+
* count comes from the resolved street fabric).
|
|
50
|
+
*/
|
|
51
|
+
export interface ScatterCoverage {
|
|
52
|
+
kind: ScatterCoverageKind;
|
|
53
|
+
unit: string;
|
|
54
|
+
/** Footprint area in m² (0 when unknown / not applicable). */
|
|
55
|
+
area: number;
|
|
56
|
+
/** Instances the density asks for before the budget clamp; `null` when only the placed count is known. */
|
|
57
|
+
requested: number | null;
|
|
58
|
+
/** Instances actually placed: `min(requested, budget)`, or the generator's reported count. */
|
|
59
|
+
count: number;
|
|
60
|
+
budget: number;
|
|
61
|
+
capped: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box
|
|
65
|
+
* (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor
|
|
66
|
+
* volume, or a scatter path without a hard type dependency.
|
|
67
|
+
*/
|
|
68
|
+
export declare function scatterFootprintArea(object: {
|
|
69
|
+
points?: readonly {
|
|
70
|
+
x: number;
|
|
71
|
+
z: number;
|
|
72
|
+
}[];
|
|
73
|
+
halfExtents?: {
|
|
74
|
+
x: number;
|
|
75
|
+
z: number;
|
|
76
|
+
};
|
|
77
|
+
radius?: number;
|
|
78
|
+
}): number;
|
|
79
|
+
/**
|
|
80
|
+
* Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped
|
|
81
|
+
* to the kind's budget. `capped` is true when the request exceeds the budget.
|
|
82
|
+
*/
|
|
83
|
+
export declare function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage;
|
|
84
|
+
/**
|
|
85
|
+
* Coverage for a kind whose placed `count` is already known from its generator (city, from its
|
|
86
|
+
* resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so
|
|
87
|
+
* `capped` is inferred from the placed count reaching the ceiling.
|
|
88
|
+
*/
|
|
89
|
+
export declare function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage;
|
|
90
|
+
/**
|
|
91
|
+
* The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when
|
|
92
|
+
* under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or
|
|
93
|
+
* ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent"
|
|
94
|
+
* budget signal from #1112.
|
|
95
|
+
*/
|
|
96
|
+
export declare function budgetWarning(coverage: ScatterCoverage): string;
|
|
97
|
+
/** The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped. */
|
|
98
|
+
export declare function describeScatterCoverage(coverage: ScatterCoverage): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared "how much stuff grows here" contract for scatterable scene kinds
|
|
3
|
+
* (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances
|
|
4
|
+
* at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request
|
|
5
|
+
* can never generate unbounded work. Before this module each kind hand-rolled its own readout and
|
|
6
|
+
* clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note);
|
|
7
|
+
* this is the one place that
|
|
8
|
+
*
|
|
9
|
+
* 1. names the per-kind unit noun + placement budget ({@link SCATTER_COVERAGE_SPECS}),
|
|
10
|
+
* 2. turns an area + density into a `requested` / placed `count` / `capped` triple
|
|
11
|
+
* ({@link densityCoverage}, {@link placedCoverage}), and
|
|
12
|
+
* 3. renders the identical clamp-and-warn clause the inspector surfaces —
|
|
13
|
+
* `requested N, capped at M (budget)` — via {@link budgetWarning} / {@link describeScatterCoverage}.
|
|
14
|
+
*
|
|
15
|
+
* Pure data + string helpers, no rendering. Each kind's `note` and the editor's shared
|
|
16
|
+
* `CoverageSection` consume it, so grass, scatter, and city tell one coverage story.
|
|
17
|
+
*
|
|
18
|
+
* @capability scatter-coverage shared density/budget semantics for scatterable kinds
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass
|
|
22
|
+
* renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never
|
|
23
|
+
* renders more than this many placements regardless of density × area.
|
|
24
|
+
*/
|
|
25
|
+
export const SCATTER_INSTANCE_BUDGET = 250_000;
|
|
26
|
+
/** Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap. */
|
|
27
|
+
export const CITY_BUILDING_BUDGET = 2_600;
|
|
28
|
+
/** The coverage spec per scatterable kind — the single source for its unit + budget. */
|
|
29
|
+
export const SCATTER_COVERAGE_SPECS = {
|
|
30
|
+
grass_field: { kind: "grass_field", unit: "blades", perArea: true, budget: SCATTER_INSTANCE_BUDGET },
|
|
31
|
+
scatter: { kind: "scatter", unit: "placements", perArea: true, budget: SCATTER_INSTANCE_BUDGET },
|
|
32
|
+
city: { kind: "city", unit: "buildings", perArea: false, budget: CITY_BUILDING_BUDGET },
|
|
33
|
+
};
|
|
34
|
+
/** The coverage spec for a scatterable kind, or `null` when the kind is not scatterable. */
|
|
35
|
+
export function scatterCoverageSpec(kind) {
|
|
36
|
+
return SCATTER_COVERAGE_SPECS[kind] ?? null;
|
|
37
|
+
}
|
|
38
|
+
const nonNegative = (n) => (Number.isFinite(n) && n > 0 ? n : 0);
|
|
39
|
+
/**
|
|
40
|
+
* Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box
|
|
41
|
+
* (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor
|
|
42
|
+
* volume, or a scatter path without a hard type dependency.
|
|
43
|
+
*/
|
|
44
|
+
export function scatterFootprintArea(object) {
|
|
45
|
+
const points = object.points;
|
|
46
|
+
if (points !== undefined && points.length >= 3) {
|
|
47
|
+
let sum = 0;
|
|
48
|
+
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
|
49
|
+
sum += points[j].x * points[i].z - points[i].x * points[j].z;
|
|
50
|
+
}
|
|
51
|
+
return Math.abs(sum) / 2;
|
|
52
|
+
}
|
|
53
|
+
const he = object.halfExtents;
|
|
54
|
+
if (he !== undefined)
|
|
55
|
+
return nonNegative(Math.abs(he.x) * 2) * nonNegative(Math.abs(he.z) * 2);
|
|
56
|
+
if (object.radius !== undefined)
|
|
57
|
+
return Math.PI * nonNegative(object.radius) ** 2;
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped
|
|
62
|
+
* to the kind's budget. `capped` is true when the request exceeds the budget.
|
|
63
|
+
*/
|
|
64
|
+
export function densityCoverage(kind, area, density) {
|
|
65
|
+
const spec = SCATTER_COVERAGE_SPECS[kind];
|
|
66
|
+
const a = nonNegative(area);
|
|
67
|
+
const requested = Math.floor(nonNegative(density) * a);
|
|
68
|
+
const count = Math.min(requested, spec.budget);
|
|
69
|
+
return { kind, unit: spec.unit, area: a, requested, count, budget: spec.budget, capped: requested > spec.budget };
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Coverage for a kind whose placed `count` is already known from its generator (city, from its
|
|
73
|
+
* resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so
|
|
74
|
+
* `capped` is inferred from the placed count reaching the ceiling.
|
|
75
|
+
*/
|
|
76
|
+
export function placedCoverage(kind, area, placed) {
|
|
77
|
+
const spec = SCATTER_COVERAGE_SPECS[kind];
|
|
78
|
+
const count = Math.max(0, Math.floor(nonNegative(placed)));
|
|
79
|
+
return { kind, unit: spec.unit, area: nonNegative(area), requested: null, count, budget: spec.budget, capped: count >= spec.budget };
|
|
80
|
+
}
|
|
81
|
+
const fmt = (n) => Math.round(n).toLocaleString();
|
|
82
|
+
/**
|
|
83
|
+
* The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when
|
|
84
|
+
* under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or
|
|
85
|
+
* ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent"
|
|
86
|
+
* budget signal from #1112.
|
|
87
|
+
*/
|
|
88
|
+
export function budgetWarning(coverage) {
|
|
89
|
+
if (!coverage.capped)
|
|
90
|
+
return "";
|
|
91
|
+
if (coverage.requested !== null && coverage.requested > coverage.budget) {
|
|
92
|
+
return ` · requested ${fmt(coverage.requested)}, capped at ${fmt(coverage.budget)} (budget)`;
|
|
93
|
+
}
|
|
94
|
+
return ` · capped at ${fmt(coverage.budget)} (budget)`;
|
|
95
|
+
}
|
|
96
|
+
/** The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped. */
|
|
97
|
+
export function describeScatterCoverage(coverage) {
|
|
98
|
+
const head = `≈ ${fmt(coverage.count)} ${coverage.unit}`;
|
|
99
|
+
const area = coverage.area > 0 ? ` · ${fmt(coverage.area)} m²` : "";
|
|
100
|
+
return `${head}${area}${budgetWarning(coverage)}`;
|
|
101
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { masksInfluence, } from "./geometry.js";
|
|
2
2
|
import { scatter } from "./scatter.js";
|
|
3
|
+
import { SCATTER_INSTANCE_BUDGET } from "./scatterCoverage.js";
|
|
3
4
|
/** The editor path kind that marks a closed polyline as a foliage/scatter region. */
|
|
4
5
|
export const SCATTER_PATH_KIND = "scatter";
|
|
5
6
|
/** Defaults a bare scatter region fills with: sparse grass, lightly spaced. */
|
|
@@ -192,6 +193,11 @@ export function resolveScatterRegion(region, terrain, avoid) {
|
|
|
192
193
|
scale: rules.minScale + (rules.maxScale - rules.minScale) * scaleRoll,
|
|
193
194
|
...(normal === undefined ? {} : { normal }),
|
|
194
195
|
});
|
|
196
|
+
// Bounded work: a single region never emits more than the shared instance budget, matching the
|
|
197
|
+
// grass patch cap so the inspector's "capped at N (budget)" note is truthful. Placements are
|
|
198
|
+
// generated in deterministic scatter order, so the truncated set is deterministic too.
|
|
199
|
+
if (instances.length >= SCATTER_INSTANCE_BUDGET)
|
|
200
|
+
break;
|
|
195
201
|
}
|
|
196
202
|
return instances;
|
|
197
203
|
}
|
package/dist/world.d.ts
CHANGED
|
@@ -121,6 +121,7 @@ export { composeRealm } from "./world/realm.js";
|
|
|
121
121
|
export { createRegionField, isRegionField, type RegionField } from "./world/regions.js";
|
|
122
122
|
export { buildRoadRibbon, dashSegments } from "./world/roads.js";
|
|
123
123
|
export { scatter, type ScatterPoint } from "./world/scatter.js";
|
|
124
|
+
export { CITY_BUILDING_BUDGET, SCATTER_COVERAGE_SPECS, SCATTER_INSTANCE_BUDGET, budgetWarning, densityCoverage, describeScatterCoverage, placedCoverage, scatterCoverageSpec, scatterFootprintArea, type ScatterCoverage, type ScatterCoverageKind, type ScatterCoverageSpec, } from "./world/scatterCoverage.js";
|
|
124
125
|
export { pickWeighted, scatterItems, type ScatterInstance } from "./world/scatterItems.js";
|
|
125
126
|
export { SCATTER_PATH_KIND, clearanceZonesFrom, distanceToPolygonEdge, isScatterPath, pointInPolygon, polygonArea, polygonBounds, readScatterPalette, readScatterRules, resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, type ScatterTerrain, } from "./world/scatterRegion.js";
|
|
126
127
|
export { SOIL_KIND, SOIL_SCHEMA, type SoilRules } from "./world/soilKind.js";
|
package/dist/world.js
CHANGED
|
@@ -121,6 +121,7 @@ export { composeRealm } from "./world/realm.js";
|
|
|
121
121
|
export { createRegionField, isRegionField } from "./world/regions.js";
|
|
122
122
|
export { buildRoadRibbon, dashSegments } from "./world/roads.js";
|
|
123
123
|
export { scatter } from "./world/scatter.js";
|
|
124
|
+
export { CITY_BUILDING_BUDGET, SCATTER_COVERAGE_SPECS, SCATTER_INSTANCE_BUDGET, budgetWarning, densityCoverage, describeScatterCoverage, placedCoverage, scatterCoverageSpec, scatterFootprintArea, } from "./world/scatterCoverage.js";
|
|
124
125
|
export { pickWeighted, scatterItems } from "./world/scatterItems.js";
|
|
125
126
|
export { SCATTER_PATH_KIND, clearanceZonesFrom, distanceToPolygonEdge, isScatterPath, pointInPolygon, polygonArea, polygonBounds, readScatterPalette, readScatterRules, resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, } from "./world/scatterRegion.js";
|
|
126
127
|
export { SOIL_KIND, SOIL_SCHEMA } from "./world/soilKind.js";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "jgengine core —
|
|
3
|
+
"version": "0.14.0",
|
|
4
|
+
"description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jgengine",
|
|
7
7
|
"agent-first",
|
package/skills/jgengine/SKILL.md
CHANGED
|
@@ -5,7 +5,7 @@ description: Route game work to the smallest useful JGengine guidance set.
|
|
|
5
5
|
|
|
6
6
|
# JGengine intake and router
|
|
7
7
|
|
|
8
|
-
Read the
|
|
8
|
+
Read the JGengine README for package truth, commands, layering, and license — the repo-root [`README.md`](../../../README.md) in the monorepo, or [jgengine.com](https://jgengine.com) from a scaffolded consumer project (where the relative link does not resolve). This skill decides what to load; it is not an engine manual.
|
|
9
9
|
|
|
10
10
|
## 1. Establish the target
|
|
11
11
|
|
|
@@ -20,6 +20,8 @@ Treat the pitch as a unique composition of needs, not a genre to fill in — nev
|
|
|
20
20
|
|
|
21
21
|
For a new in-repo game, scaffold with `bun run new:game "<id-or-title>"` (thin script alias for `jgengine create`, auto-detects the in-repo variant). Outside this monorepo, start with `npx jgengine create`; never copy `Games/*`.
|
|
22
22
|
|
|
23
|
+
For an existing game asked to pick up a new engine release ("jgengine published a new version, grab it"), run `npx jgengine upgrade` in the project first: it diffs installed `@jgengine/*` versions against the latest release and prints every Migrate step and Adopt-worthy addition in between (`--json` for structured output). Bump, migrate oldest-first, then work the Adopt lists instead of reconstructing the changelog by hand.
|
|
24
|
+
|
|
23
25
|
## 2. Select domains
|
|
24
26
|
|
|
25
27
|
Load only rows the target needs. Do not preload every domain.
|
|
@@ -43,7 +45,7 @@ World content routes editor-first: any request that adds, moves, restyles, or re
|
|
|
43
45
|
Within each selected domain:
|
|
44
46
|
|
|
45
47
|
0. Fastest start for a common composition: `npx jgengine recipe <name>` prints a vetted, SDK-typechecked snippet (imports + wired code) for `combat-loop`, `boss-telegraph`, `loot`, `quest`, `coop-presence`, `third-person-camera`; `npx jgengine recipe` lists them. Copy that instead of reconning per-file.
|
|
46
|
-
1. Search `capabilities.md` by intent; it maps needs to imports.
|
|
48
|
+
1. Search `capabilities.md` by intent; it maps needs to imports. Outside the monorepo (or before a domain skill is even loaded), `npx jgengine find <intent>` greps every shipped capability index at once and prints the primitive + import — reach for it the moment you're tempted to hand-roll a HUD window, inventory grid, paperdoll, hotkey listener, minimap, stat bar, or character motion. If you're writing a `z-index`, a `keydown` for a panel, a `<div>` inventory, or a limb bob, a shipped drop-in almost certainly exists (`usePanels`/`PanelHost`, `InventoryGrid`, `CharacterSheet`/`Paperdoll`, `PartMotionRig`, `EntityPreview`); games own the *skin*, not the re-derivation.
|
|
47
49
|
2. Open `api.md` only when exact signatures or export inventory are needed.
|
|
48
50
|
3. Open the linked reference only for a deeper recipe or trap.
|
|
49
51
|
4. For how several primitives wire into a running loop, read the domain's `recipes/` — connected, genre-free walkthroughs organized by composition seam; this skill's own [recipes/minimal-game.md](recipes/minimal-game.md) is the default whole-game walkthrough. Prefer a recipe over reading a game's source; the games under `Games/*` are not templates or references, and a hybrid game is just a different composition of the same primitives.
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -220,7 +220,7 @@
|
|
|
220
220
|
|
|
221
221
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
222
222
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
223
|
-
- `VERSION` (const): const VERSION: "0.
|
|
223
|
+
- `VERSION` (const): const VERSION: "0.14.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
224
224
|
|
|
225
225
|
## @jgengine/core/runtime/adapter
|
|
226
226
|
|
|
@@ -925,7 +925,7 @@
|
|
|
925
925
|
|
|
926
926
|
## @jgengine/core/scene/builtinSceneKinds
|
|
927
927
|
|
|
928
|
-
- `SCATTER_SCHEMA` (const): const SCATTER_SCHEMA: ParamSchema — The scatter/foliage region schema
|
|
928
|
+
- `SCATTER_SCHEMA` (const): const SCATTER_SCHEMA: ParamSchema — The scatter/foliage region schema. `palette` (what fills the area) leads the schema body and `density` is the single coverage slider hoisted into the shared `CoverageSection` (see the kind's `coverage` descriptor), so a scatter region reads as Area → Assets → Density like every other scatterable kind; the rest is kind-specific tuning.
|
|
929
929
|
|
|
930
930
|
## @jgengine/core/scene/captureCheck
|
|
931
931
|
|
|
@@ -955,6 +955,7 @@
|
|
|
955
955
|
- `PreparedCollisionMesh` (interface): interface PreparedCollisionMesh — Decoded, BVH-indexed triangles ready for per-ray queries — build once per asset via {@link prepareCollisionMesh}. Treat the fields as opaque; they exist so the raycast hot path can run allocation-free over flat arrays.
|
|
956
956
|
- `encodeCollisionMesh` (function): function encodeCollisionMesh(source: CollisionMeshSource): CollisionMeshData | null — Quantize a triangle soup onto the 16-bit grid, weld coincident vertices, and drop triangles that collapse — the reindex-time compressor producing {@link CollisionMeshData}. Returns `null` when no finite, non-degenerate triangle survives.
|
|
957
957
|
- `prepareCollisionMesh` (function): function prepareCollisionMesh(data: CollisionMeshData): PreparedCollisionMesh | null — Decode a stored collision mesh and index it with a BVH. Memoized per data object, so the shell and a headless host preparing the same catalog entry share one build. Returns `null` (also memoized) when the payload is malformed.
|
|
958
|
+
- `prepareCollisionMeshSource` (function): function prepareCollisionMeshSource(source: CollisionMeshSource): PreparedCollisionMesh | null — Build a prepared BVH mesh straight from a runtime triangle soup (float positions + indices) — the renderer-fed counterpart to {@link prepareCollisionMesh}, feeding `ctx.scene.entity.reportCollisionMesh` / `ctx.scene.object.reportCollisionMesh` without a quantize/encode round-trip. Copies and sanitizes the input: triangles referencing out-of-range, non-finite, or repeated vertices are dropped. Returns `null` when no triangle survives.
|
|
958
959
|
- `raycastCollisionMesh` (function): function raycastCollisionMesh(mesh: PreparedCollisionMesh, origin: EntityPosition, direction: EntityPosition, maxDistance: number, position: EntityPosition, rotationY: number, scale: number, translate: EntityPosition): CollisionMeshHit | null — Nearest triangle hit for a world-space ray against a prepared mesh placed like its rendered model: model space scaled uniformly by `scale`, translated by entity-local `translate`, yaw-rotated by `rotationY`, and moved to `position` — the same composition {@link "scene/colliders".ModelBodySource} fitting applies to the AABB. Allocation-free until the returned hit. `distance` is world distance along `direction` (callers pass `direction` normalized); `normal` is the world-space geometric triangle normal facing the ray.
|
|
959
960
|
|
|
960
961
|
## @jgengine/core/scene/companion
|
|
@@ -1098,6 +1099,7 @@
|
|
|
1098
1099
|
- `ParamSchema` (interface): interface ParamSchema — A kind's full parameter surface: an ordered list of fields the inspector renders top-to-bottom.
|
|
1099
1100
|
- `ParsedParams` (type): type ParsedParams = Record<string, number | boolean | string | WeightedParamEntry[]> — Parsed params after `parseParams`: every schema field present with a validated, defaulted value.
|
|
1100
1101
|
- `RangeParamField` (interface): interface RangeParamField — A numeric slider row: bounded range with a live readout.
|
|
1102
|
+
- `SceneKindCoverage` (interface): interface SceneKindCoverage — Marks a kind as *scatterable* — it fills an authored area with instances at one density under a bounded budget — so the editor renders the shared `CoverageSection` (Area → Assets → Density slider + budget note) that gives grass/scatter/city one authoring gesture. `spec` selects the unit + budget from `@jgengine/core/world/scatterCoverage`; `densityKey` is the single density slider hoisted into the coverage header (and hidden from the schema body below); `assetsKey` is the optional weighted "what fills it" palette summarized in the header.
|
|
1101
1103
|
- `SceneKindDefinition` (interface): interface SceneKindDefinition<TResolved = unknown> — A registered scene kind — everything the editor/engine need to author and render a studio without bespoke code. `schema` drives the inspector + parse; `resolve` (optional) turns one document object into pure renderable data a matching `shell` renderer consumes; `add*` fields build the `+ Add` menu.
|
|
1102
1104
|
- `SceneKindObject` (interface): interface SceneKindObject — The raw document object a resolver receives — shape shared by markers, volumes, and paths.
|
|
1103
1105
|
- `SceneKindResolveContext` (interface): interface SceneKindResolveContext — Ground sampler + options a resolver may read (terrain height/normal snap).
|
|
@@ -1453,6 +1455,7 @@
|
|
|
1453
1455
|
- `BuildingIndex` (interface): interface BuildingIndex — ⚠ undocumented
|
|
1454
1456
|
- `BuildingPaletteOverrides` (type): type BuildingPaletteOverrides = Partial<BuildingPalette> — ⚠ undocumented
|
|
1455
1457
|
- `BuildingStyle` (type): type BuildingStyle = | "generic" | "capital" | "village" | "desert" | "industrial" | "coastal" | "neon" | "ruin" | "frontier" | "aerial" — ⚠ undocumented
|
|
1458
|
+
- `CITY_BUILDING_BUDGET` (const): const CITY_BUILDING_BUDGET: 2600 — Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap.
|
|
1456
1459
|
- `CITY_DEFAULTS` (const): const CITY_DEFAULTS: CityRules — City defaults: a zoned mixed metropolis — towers downtown, slabs mid-ring, houses at the edge.
|
|
1457
1460
|
- `CITY_KIND` (const): const CITY_KIND: "city" — The editor volume kind marking a box as a procedural city district.
|
|
1458
1461
|
- `CITY_LOT_CLASSES` (const): const CITY_LOT_CLASSES: readonly CityLotClass[] — All classes, for schema hints and validation.
|
|
@@ -1697,6 +1700,8 @@
|
|
|
1697
1700
|
- `RosterEntry` (interface): interface RosterEntry — ⚠ undocumented
|
|
1698
1701
|
- `RoundGround` (interface): interface RoundGround — A planet/sphere you play on the outside of. Sized by `radius` only.
|
|
1699
1702
|
- `RoundGroundSize` (interface): interface RoundGroundSize — Size of a `round` ground: the planet/sphere radius in world units. Nothing else.
|
|
1703
|
+
- `SCATTER_COVERAGE_SPECS` (const): const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec> — The coverage spec per scatterable kind — the single source for its unit + budget.
|
|
1704
|
+
- `SCATTER_INSTANCE_BUDGET` (const): const SCATTER_INSTANCE_BUDGET: 250000 — Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never renders more than this many placements regardless of density × area.
|
|
1700
1705
|
- `SCATTER_PATH_KIND` (const): const SCATTER_PATH_KIND: "scatter" — The editor path kind that marks a closed polyline as a foliage/scatter region.
|
|
1701
1706
|
- `SHAPE_BOX` (const): const SHAPE_BOX: 0 — ⚠ undocumented
|
|
1702
1707
|
- `SHAPE_SPHERE` (const): const SHAPE_SPHERE: 1 — ⚠ undocumented
|
|
@@ -1710,6 +1715,9 @@
|
|
|
1710
1715
|
- `SampleReason` (type): type SampleReason = "accepted" | "exhausted" | "empty-region" — Why a sample ended: a clean hit, budget exhausted, or the region was degenerate.
|
|
1711
1716
|
- `SampleRegion` (interface): interface SampleRegion<P extends SamplePoint = SamplePoint> — A geometric region a sampler can draw from. Built-ins cover point sets, rect/box, circle/sphere, annulus/shell, polygon, and weighted composites; {@link customRegion} wraps a caller-defined sampler + bounds. `sample` draws one raw candidate under the region's own distribution policy; `contains` is the membership test reused for include/exclude constraints and rejection.
|
|
1712
1717
|
- `SampleResult` (interface): interface SampleResult<P extends SamplePoint = SamplePoint> — Structured single-sample outcome — never a bare point, so failure and fallback are visible.
|
|
1718
|
+
- `ScatterCoverage` (interface): interface ScatterCoverage — A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can be computed cheaply from geometry × density), how many it will actually place after the budget clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the count comes from the resolved street fabric).
|
|
1719
|
+
- `ScatterCoverageKind` (type): type ScatterCoverageKind = "grass_field" | "scatter" | "city" — The shared "how much stuff grows here" contract for scatterable scene kinds (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request can never generate unbounded work. Before this module each kind hand-rolled its own readout and clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note); this is the one place that
|
|
1720
|
+
- `ScatterCoverageSpec` (interface): interface ScatterCoverageSpec — Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget.
|
|
1713
1721
|
- `ScatterInstance` (interface): interface ScatterInstance — ⚠ undocumented
|
|
1714
1722
|
- `ScatterPoint` (interface): interface ScatterPoint — ⚠ undocumented
|
|
1715
1723
|
- `ScatterTerrain` (interface): interface ScatterTerrain — Ground sampler a scatter resolve reads height/normal from (the sculpt terrain or the game's ground).
|
|
@@ -1848,6 +1856,7 @@
|
|
|
1848
1856
|
- `boundaryNeighbors` (function): function boundaryNeighbors(grid: FootprintGrid, cells: readonly GridCell[]): AdjacentCell[] — Every occupied cell orthogonally touching `cells` but outside them — the connective-piece neighbor set.
|
|
1849
1857
|
- `boxFormation` (function): function boxFormation(options: BoxFormationOptions): FormationSlotGenerator — A rectangular grid centered on the destination, front row toward `+forward` — a marching block or a phalanx. Rows fill front-to-back, left-to-right.
|
|
1850
1858
|
- `boxRegion` (function): function boxRegion(min: Point3, max: Point3): SampleRegion<Point3> — An axis-aligned box `[min..max]` in 3D. Uniform density; draws x, y, z in order.
|
|
1859
|
+
- `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
|
|
1851
1860
|
- `buildContextMenu` (function): function buildContextMenu(input: BuildContextMenuInput): ContextMenu | null — Assemble a menu from a target's catalog verbs; null when the target lists none.
|
|
1852
1861
|
- `buildRoadRibbon` (function): function buildRoadRibbon(path: readonly RoadPoint[], width: number, sampleHeight: (x: number, z: number) => number, options: RoadRibbonOptions = {}): RoadRibbon — Triangulate a road centerline into a ground-draped ribbon mesh: the polyline is subdivided, each vertex is offset half a `width` along the local perpendicular, and every vertex sits at `sampleHeight(x, z) + elevation`. Pure geometry — the shell (or any renderer) turns the result into a mesh, and tests can assert on it directly.
|
|
1853
1862
|
- `building` (function): function building(config: BuildingEnvironmentConfig = {}): BuildingEnvironmentDescriptor — Declares a cluster of procedurally-massed buildings for `environment()` — count, footprint, stories, style. Pass `along` to line road frontage instead of gridding around `position`.
|
|
@@ -1929,6 +1938,8 @@
|
|
|
1929
1938
|
- `definePatrolOrder` (function): function definePatrolOrder<TCtx extends OrderMover>(config: OrderKindConfig & { arriveRadius?: number } = {}): OrderKind<TCtx, PatrolOrderPayload> — Patrol: walk a waypoint route, advancing the serialized `index` as each point is reached. Loops forever by default, or completes at the last waypoint when `loop` is `false`.
|
|
1930
1939
|
- `defineStopOrder` (function): function defineStopOrder<TCtx extends OrderMover>(config: OrderKindConfig = {}): OrderKind<TCtx, EmptyOrderPayload> — Stop: halt immediately and complete the same tick — the interrupt that clears a unit's intent to standstill.
|
|
1931
1940
|
- `defineTargetedOrder` (function): function defineTargetedOrder<TCtx extends OrderMover & OrderTargeting>(config: EngagementKindConfig = {}): OrderKind<TCtx, TargetedOrderPayload> — Targeted action: pursue one specific target until within `range`, writing the engagement intent into `order.state`. Completes when the target is gone (despawned/dead), so the game's effect loop runs while `inRange` is true.
|
|
1941
|
+
- `densityCoverage` (function): function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage — Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped to the kind's budget. `capped` is true when the request exceeds the budget.
|
|
1942
|
+
- `describeScatterCoverage` (function): function describeScatterCoverage(coverage: ScatterCoverage): string — The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped.
|
|
1932
1943
|
- `discRegion` (function): function discRegion(center: Vec2, radius: number, options: { distribution?: RadialDistribution } = {}): SampleRegion<Vec2> — A filled circle. `"area"` is area-uniform (√-corrected radius, even density); `"radial"` is radius-uniform (clumps toward the center). Angle is drawn first, then radius — matching the common `angle = rng()·2π; r = rng()·R` idiom so migrations keep their stream.
|
|
1933
1944
|
- `distance` (function): function distance(a: Vec3, b: Vec3): number — ⚠ undocumented
|
|
1934
1945
|
- `distance3` (function): function distance3(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): number — ⚠ undocumented
|
|
@@ -2002,6 +2013,7 @@
|
|
|
2002
2013
|
- `placeAuthoredObjects` (function): function placeAuthoredObjects(store: AuthoredObjectPlaceTarget, objects: readonly AuthoredObject[], sampleHeight: (x: number, z: number) => number, options: PlaceAuthoredObjectsOptions = {}): string[] — Places resolved authored objects into an object store, grounding each on `sampleHeight(x,z)` plus per-object and options vertical offsets. Returns the instance ids that were placed (or kept).
|
|
2003
2014
|
- `placeAuthoredObjectsFromDocument` (function): function placeAuthoredObjectsFromDocument(store: AuthoredObjectPlaceTarget, document: AuthoredObjectsDocumentLike, sampleHeight: (x: number, z: number) => number, options: PlaceAuthoredObjectsOptions = {}): string[] — Convenience: resolve a document then place every authored catalog prop.
|
|
2004
2015
|
- `placeFormation` (function): function placeFormation(destination: Vec2, facing: number, count: number, generator: FormationSlotGenerator): Vec2[] — Transform a generator's local slot offsets into world XZ positions around a `destination`, rotated by `facing` (engine yaw). Slot `i` in the returned array is `generator(count)[i]` mapped through the group frame, so it stays aligned with {@link assignFormationSlots}' slot indices. Pure and allocation- light: one array of `count` points, no per-call closures retained.
|
|
2016
|
+
- `placedCoverage` (function): function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage — Coverage for a kind whose placed `count` is already known from its generator (city, from its resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so `capped` is inferred from the placed count reaching the ceiling.
|
|
2005
2017
|
- `player` (function): function player(): PlayerBehavior — ⚠ undocumented
|
|
2006
2018
|
- `plots` (function): function plots(config: PlotsWorldConfig = {}): WorldFeature — Declares a subdivided-plots world — farming, base-building, and other parcel-based layouts.
|
|
2007
2019
|
- `pointGravity` (function): function pointGravity(options: PointGravityOptions): GravityField — Radial gravity toward one center, with optional inverse-square falloff for planetary worlds.
|
|
@@ -2057,6 +2069,8 @@
|
|
|
2057
2069
|
- `sampleStratified` (function): function sampleStratified(options: StratifiedOptions): Vec2[] — Stratified 2D sampling: one jittered point per grid cell over `area`, in deterministic row-major order. Unlike pure rejection sampling this guarantees even coverage (no clumps, no gaps) — the distinct "stratified" distribution policy, kept separate from area-uniform draws.
|
|
2058
2070
|
- `sanitizeGameTimeScale` (function): function sanitizeGameTimeScale(timeScale?: number | null): number — ⚠ undocumented
|
|
2059
2071
|
- `scatter` (function): function scatter(config: ScatterConfig): ScatterPoint[] — ⚠ undocumented
|
|
2072
|
+
- `scatterCoverageSpec` (function): function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null — The coverage spec for a scatterable kind, or `null` when the kind is not scatterable.
|
|
2073
|
+
- `scatterFootprintArea` (function): function scatterFootprintArea(object: { points?: readonly { x: number; z: number }[]; halfExtents?: { x: number; z: number }; radius?: number; }): number — Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor volume, or a scatter path without a hard type dependency.
|
|
2060
2074
|
- `scatterItems` (function): function scatterItems<T>(field: RegionField<T>, area: Aabb, layersFor: (sample: RegionSample<T>) => readonly ScatterLayer[], options: { cell?: number; max?: number; saltKey?: number } = {}): ScatterInstance[] — Deterministically place opaque items across `area`, grounded on a region field. For each grid cell it asks `layersFor` which items may appear in that region and rolls one against their densities. The engine never interprets `item` — a game maps it to a mesh or entity. Content scatter (region-driven density) as opposed to `scatter` in `./scatter`, which is renderer-free geometric point distribution.
|
|
2061
2075
|
- `scatterRegionEstimate` (function): function scatterRegionEstimate(path: ScenePathLike): { area: number; count: number } — Estimated placement count for a scatter path — density × polygon area, for a live UI readout.
|
|
2062
2076
|
- `scatterRegionFromPath` (function): function scatterRegionFromPath(path: ScenePathLike): ScatterRegion | null — Builds a resolvable {@link ScatterRegion} from a scatter path (XZ polygon + rules), or null.
|
|
@@ -2750,6 +2764,21 @@
|
|
|
2750
2764
|
- `scatter` (function): function scatter(config: ScatterConfig): ScatterPoint[] — ⚠ undocumented
|
|
2751
2765
|
- `scatterAabb` (function): function scatterAabb(area: ScatterArea | Aabb): Aabb — ⚠ undocumented
|
|
2752
2766
|
|
|
2767
|
+
## @jgengine/core/world/scatterCoverage
|
|
2768
|
+
|
|
2769
|
+
- `CITY_BUILDING_BUDGET` (const): const CITY_BUILDING_BUDGET: 2600 — Max buildings a single `city` district generates — the block/parcel pipeline's bounded lot cap.
|
|
2770
|
+
- `SCATTER_COVERAGE_SPECS` (const): const SCATTER_COVERAGE_SPECS: Record<ScatterCoverageKind, ScatterCoverageSpec> — The coverage spec per scatterable kind — the single source for its unit + budget.
|
|
2771
|
+
- `SCATTER_INSTANCE_BUDGET` (const): const SCATTER_INSTANCE_BUDGET: 250000 — Max blade/placement instances a single `grass_field` or `scatter` patch places. Mirrors the grass renderer's instance-buffer ceiling; the scatter resolver truncates to it too, so a patch never renders more than this many placements regardless of density × area.
|
|
2772
|
+
- `ScatterCoverage` (interface): interface ScatterCoverage — A resolved coverage readout: how many instances an authored patch WANTS (`requested`, when it can be computed cheaply from geometry × density), how many it will actually place after the budget clamp (`count`), the `budget` ceiling, and whether the request hit it (`capped`). `requested` is `null` for kinds whose pre-cap ask is not knowable without running the generator (city, where the count comes from the resolved street fabric).
|
|
2773
|
+
- `ScatterCoverageKind` (type): type ScatterCoverageKind = "grass_field" | "scatter" | "city" — The shared "how much stuff grows here" contract for scatterable scene kinds (`grass_field` / `scatter` / `city`). Every scatterable kind fills an authored AREA with instances at a DENSITY, and every kind caps the placed count at a bounded BUDGET so a huge or dense request can never generate unbounded work. Before this module each kind hand-rolled its own readout and clamp story (grass silently capped in the renderer, scatter never capped, city had no budget note); this is the one place that
|
|
2774
|
+
- `ScatterCoverageSpec` (interface): interface ScatterCoverageSpec — Per-kind coverage descriptor: the unit noun, whether density is per-m², and the placement budget.
|
|
2775
|
+
- `budgetWarning` (function): function budgetWarning(coverage: ScatterCoverage): string — The shared clamp-and-warn clause every scatterable kind appends, worded identically: `""` when under budget, else ` · requested N, capped at M (budget)` when the pre-cap ask is known, or ` · capped at M (budget)` when only the ceiling is (city). This is the "surfaced, never silent" budget signal from #1112.
|
|
2776
|
+
- `densityCoverage` (function): function densityCoverage(kind: "grass_field" | "scatter", area: number, density: number): ScatterCoverage — Coverage for a per-m² kind (`grass_field` / `scatter`): `requested = floor(density × area)`, clamped to the kind's budget. `capped` is true when the request exceeds the budget.
|
|
2777
|
+
- `describeScatterCoverage` (function): function describeScatterCoverage(coverage: ScatterCoverage): string — The full one-line coverage readout: `≈ N unit · A m²` plus the {@link budgetWarning} when capped.
|
|
2778
|
+
- `placedCoverage` (function): function placedCoverage(kind: ScatterCoverageKind, area: number, placed: number): ScatterCoverage — Coverage for a kind whose placed `count` is already known from its generator (city, from its resolved lots). No pre-cap `requested` is available — the generator stops at the budget — so `capped` is inferred from the placed count reaching the ceiling.
|
|
2779
|
+
- `scatterCoverageSpec` (function): function scatterCoverageSpec(kind: string): ScatterCoverageSpec | null — The coverage spec for a scatterable kind, or `null` when the kind is not scatterable.
|
|
2780
|
+
- `scatterFootprintArea` (function): function scatterFootprintArea(object: { points?: readonly { x: number; z: number }[]; halfExtents?: { x: number; z: number }; radius?: number; }): number — Footprint area (m²) of a scatterable object: a closed path polygon (shoelace), a volume box (`halfExtents`), or a disc (`radius`). Structural so it accepts a `SceneKindObject`, an editor volume, or a scatter path without a hard type dependency.
|
|
2781
|
+
|
|
2753
2782
|
## @jgengine/core/world/scatterItems
|
|
2754
2783
|
|
|
2755
2784
|
- `ScatterInstance` (interface): interface ScatterInstance — ⚠ undocumented
|
|
@@ -355,6 +355,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
|
|
|
355
355
|
- `controlGroupKey` (function) · `import { controlGroupKey } from "@jgengine/core/world"`
|
|
356
356
|
- `resolveControlGroupIntent` (function) · `import { resolveControlGroupIntent } from "@jgengine/core/world"`
|
|
357
357
|
|
|
358
|
+
## scatter-coverage — shared density/budget semantics for scatterable kinds
|
|
359
|
+
|
|
360
|
+
- `ScatterCoverageKind` (type) · `import { ScatterCoverageKind } from "@jgengine/core/world"`
|
|
361
|
+
|
|
358
362
|
## selection-bookmark-recall — fold a saved set into the active selection with stale-ref pruning and a caller focus hook
|
|
359
363
|
|
|
360
364
|
- `recallSelectionBookmark` (function) · `import { recallSelectionBookmark } from "@jgengine/core/world"`
|