@jgengine/core 0.15.0 → 0.17.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 +62 -0
- package/dist/game/playableGame.d.ts +36 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +49 -1
- package/dist/movement/playerMovement.js +6 -172
- package/dist/movement/solidObstacles.d.ts +87 -0
- package/dist/movement/solidObstacles.js +208 -0
- package/dist/multiplayer.d.ts +1 -0
- package/dist/multiplayer.js +1 -0
- package/dist/runtime/context/objectSlots.d.ts +60 -0
- package/dist/runtime/context/objectSlots.js +103 -0
- package/dist/runtime/context/player.d.ts +3 -1
- package/dist/runtime/context/player.js +1 -0
- package/dist/runtime/context/scene.d.ts +9 -0
- package/dist/runtime/context/scene.js +25 -0
- package/dist/runtime/descriptors/baseline.d.ts +3 -1
- package/dist/runtime/descriptors/baseline.js +11 -0
- package/dist/runtime/gameContext.js +10 -1
- package/dist/runtime/gameContextTypes.d.ts +13 -0
- package/dist/runtime/gameRuntime.d.ts +6 -0
- package/dist/runtime/gameRuntime.js +1 -0
- package/dist/runtime/hostPersistence.d.ts +27 -2
- package/dist/runtime/hostPersistence.js +68 -12
- package/dist/runtime/hostPolicy.d.ts +54 -0
- package/dist/runtime/hostPolicy.js +53 -0
- package/dist/runtime/objectRows.d.ts +17 -0
- package/dist/runtime/objectRows.js +35 -0
- package/dist/runtime/snapshot.d.ts +15 -0
- package/dist/runtime/snapshot.js +12 -0
- package/dist/runtime/worldChunks.d.ts +39 -0
- package/dist/runtime/worldChunks.js +83 -0
- package/dist/runtime/worldProjection.d.ts +16 -2
- package/dist/runtime/worldProjection.js +20 -0
- package/dist/scene/behaviorRuntime.js +25 -3
- package/dist/scene/objectStore.d.ts +26 -0
- package/dist/scene/objectStore.js +51 -9
- package/dist/scene/spatial.d.ts +16 -0
- package/dist/scene/spatial.js +7 -4
- package/dist/world/buildingKit.d.ts +110 -0
- package/dist/world/buildingKit.js +93 -0
- package/dist/world/buildings.d.ts +25 -3
- package/dist/world/buildings.js +39 -5
- package/dist/world/cityKind.d.ts +6 -0
- package/dist/world/cityKind.js +3 -0
- package/dist/world/environmentSummary.js +13 -3
- package/dist/world/features.d.ts +7 -1
- package/dist/world/features.js +1 -0
- package/dist/world.d.ts +1 -0
- package/dist/world.js +1 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +38 -5
- package/skills/jgengine/capabilities.md +24 -0
- package/skills/jgengine-gameplay/api.md +2 -1
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +27 -2
- package/skills/jgengine-world/capabilities.md +24 -0
- package/skills/jgengine-world/recipes/building-kit.md +78 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ModelAnimationConfig } from "../game/playableGame.js";
|
|
2
|
+
import type { InventoryState } from "../inventory/inventoryModel.js";
|
|
2
3
|
import type { EntityPosition } from "./entityStore.js";
|
|
3
4
|
export interface ObjectVisual {
|
|
4
5
|
scale?: number | readonly [number, number, number];
|
|
@@ -19,12 +20,29 @@ export interface SceneObject {
|
|
|
19
20
|
* `resolveAuthoredObjects` → `placeAuthoredObjects`; `undefined` keeps the catalog-resolved default.
|
|
20
21
|
*/
|
|
21
22
|
animation?: ModelAnimationConfig | "auto" | "none";
|
|
23
|
+
/**
|
|
24
|
+
* Opaque per-instance state for this placement — a chest's locked flag, a machine's fill level, the
|
|
25
|
+
* owner of a placed turret. Replicated and saved with the object through the `objects` snapshot
|
|
26
|
+
* module and round-trips as `RuntimeObjectRow.flags`, so a command may own it without the game
|
|
27
|
+
* hand-rolling a parallel store keyed by `instanceId`.
|
|
28
|
+
*/
|
|
29
|
+
state?: Record<string, unknown>;
|
|
30
|
+
/**
|
|
31
|
+
* Container contents when this object's catalog entry declares `slotInventory`. Mutate through
|
|
32
|
+
* `ctx.scene.object.slots` rather than writing it directly — that path validates against the
|
|
33
|
+
* declared `accepts` and stack limits.
|
|
34
|
+
*/
|
|
35
|
+
slots?: InventoryState;
|
|
22
36
|
}
|
|
23
37
|
export interface PlaceOptions {
|
|
24
38
|
instanceId?: string;
|
|
25
39
|
parentSpace?: string;
|
|
26
40
|
rotation?: number;
|
|
27
41
|
visual?: ObjectVisual;
|
|
42
|
+
/** Initial {@link SceneObject.state} for this placement. */
|
|
43
|
+
state?: Record<string, unknown>;
|
|
44
|
+
/** Initial {@link SceneObject.slots} contents; omit to start from the catalog `slotInventory` layout's empty slots. */
|
|
45
|
+
slots?: InventoryState;
|
|
28
46
|
/** Per-placement rig animation override stored on the placed {@link SceneObject}; same shape as `ModelConfig.animation`. Omit to keep the catalog-resolved default. */
|
|
29
47
|
animation?: ModelAnimationConfig | "auto" | "none";
|
|
30
48
|
/** 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). */
|
|
@@ -39,6 +57,12 @@ export interface ObjectStore {
|
|
|
39
57
|
move(instanceId: string, x: number, y: number, z: number): boolean;
|
|
40
58
|
rotate(instanceId: string, rotationY: number): boolean;
|
|
41
59
|
setVisual(instanceId: string, visual: ObjectVisual | undefined): boolean;
|
|
60
|
+
/** Replace {@link SceneObject.state} wholesale; `undefined` drops the field. Returns `false` when the instance is not placed. */
|
|
61
|
+
setState(instanceId: string, state: Record<string, unknown> | undefined): boolean;
|
|
62
|
+
/** Shallow-merge `patch` into {@link SceneObject.state}; a key set to `undefined` is removed. Returns `false` when the instance is not placed. */
|
|
63
|
+
patchState(instanceId: string, patch: Record<string, unknown>): boolean;
|
|
64
|
+
/** Replace {@link SceneObject.slots} wholesale; prefer `ctx.scene.object.slots` which validates against the catalog layout. */
|
|
65
|
+
setSlots(instanceId: string, slots: InventoryState | undefined): boolean;
|
|
42
66
|
get(instanceId: string): SceneObject | null;
|
|
43
67
|
list(filter?: ObjectListFilter): readonly SceneObject[];
|
|
44
68
|
/** Stable-identity list of all placed object ids; identity changes only on place/remove, not on move/rotate/setVisual. Pair with {@link subscribeMembership} for membership-only React subscriptions (#625). */
|
|
@@ -50,6 +74,8 @@ export interface ObjectStore {
|
|
|
50
74
|
/** Notified only when the object set changes (place/remove), never on move/rotate/setVisual — markers read live poses imperatively. */
|
|
51
75
|
subscribeMembership(listener: () => void): () => void;
|
|
52
76
|
snapshot(): readonly SceneObject[];
|
|
77
|
+
/** Replace the placed set with `objects` — the client half of `objects` replication; instances absent from the payload are removed. */
|
|
78
|
+
hydrate(objects: readonly SceneObject[]): void;
|
|
53
79
|
}
|
|
54
80
|
export declare function createObjectStore(): ObjectStore;
|
|
55
81
|
export declare function objectVisualScale(visual: ObjectVisual | undefined): readonly [number, number, number];
|
|
@@ -72,6 +72,8 @@ export function createObjectStore() {
|
|
|
72
72
|
...(options.parentSpace !== undefined ? { parentSpace: options.parentSpace } : {}),
|
|
73
73
|
...(options.visual !== undefined ? { visual: options.visual } : {}),
|
|
74
74
|
...(options.animation !== undefined ? { animation: options.animation } : {}),
|
|
75
|
+
...(options.state !== undefined ? { state: options.state } : {}),
|
|
76
|
+
...(options.slots !== undefined ? { slots: options.slots } : {}),
|
|
75
77
|
});
|
|
76
78
|
indexAdd(instanceId, position);
|
|
77
79
|
return instanceId;
|
|
@@ -105,15 +107,39 @@ export function createObjectStore() {
|
|
|
105
107
|
const current = store.get(instanceId);
|
|
106
108
|
if (!current)
|
|
107
109
|
return false;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
110
|
+
const { visual: _cleared, ...rest } = current;
|
|
111
|
+
store.set(instanceId, visual === undefined ? rest : { ...rest, visual });
|
|
112
|
+
return true;
|
|
113
|
+
},
|
|
114
|
+
setState(instanceId, state) {
|
|
115
|
+
const current = store.get(instanceId);
|
|
116
|
+
if (!current)
|
|
117
|
+
return false;
|
|
118
|
+
const { state: _cleared, ...rest } = current;
|
|
119
|
+
store.set(instanceId, state === undefined ? rest : { ...rest, state });
|
|
120
|
+
return true;
|
|
121
|
+
},
|
|
122
|
+
patchState(instanceId, patch) {
|
|
123
|
+
const current = store.get(instanceId);
|
|
124
|
+
if (!current)
|
|
125
|
+
return false;
|
|
126
|
+
const state = { ...current.state };
|
|
127
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
128
|
+
if (value === undefined)
|
|
129
|
+
delete state[key];
|
|
130
|
+
else
|
|
131
|
+
state[key] = value;
|
|
132
|
+
}
|
|
133
|
+
const { state: _cleared, ...rest } = current;
|
|
134
|
+
store.set(instanceId, Object.keys(state).length === 0 ? rest : { ...rest, state });
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
setSlots(instanceId, slots) {
|
|
138
|
+
const current = store.get(instanceId);
|
|
139
|
+
if (!current)
|
|
140
|
+
return false;
|
|
141
|
+
const { slots: _cleared, ...rest } = current;
|
|
142
|
+
store.set(instanceId, slots === undefined ? rest : { ...rest, slots });
|
|
117
143
|
return true;
|
|
118
144
|
},
|
|
119
145
|
get(instanceId) {
|
|
@@ -212,6 +238,22 @@ export function createObjectStore() {
|
|
|
212
238
|
snapshot() {
|
|
213
239
|
return store.arraySnapshot();
|
|
214
240
|
},
|
|
241
|
+
hydrate(objects) {
|
|
242
|
+
const incoming = new Set(objects.map((object) => object.instanceId));
|
|
243
|
+
for (const current of store.arraySnapshot()) {
|
|
244
|
+
if (!incoming.has(current.instanceId)) {
|
|
245
|
+
store.delete(current.instanceId);
|
|
246
|
+
indexRemove(current.instanceId, current.position);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
for (const object of objects) {
|
|
250
|
+
const current = store.get(object.instanceId);
|
|
251
|
+
if (current !== undefined)
|
|
252
|
+
indexRemove(object.instanceId, current.position);
|
|
253
|
+
store.set(object.instanceId, object);
|
|
254
|
+
indexAdd(object.instanceId, object.position);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
215
257
|
};
|
|
216
258
|
}
|
|
217
259
|
export function objectVisualScale(visual) {
|
package/dist/scene/spatial.d.ts
CHANGED
|
@@ -17,6 +17,12 @@ export interface MoveTowardOptions {
|
|
|
17
17
|
speed: number;
|
|
18
18
|
stopDistance?: number;
|
|
19
19
|
dt: number;
|
|
20
|
+
/**
|
|
21
|
+
* Slide the step against the solid geometry that blocks the player instead of lerping through it
|
|
22
|
+
* (default `true` wherever the runtime wires a resolver). Set `false` for a mover that is meant to
|
|
23
|
+
* ignore the world — a flier, a camera rig, a decorative drift.
|
|
24
|
+
*/
|
|
25
|
+
avoidSolids?: boolean;
|
|
20
26
|
}
|
|
21
27
|
export interface SpatialGridOptions {
|
|
22
28
|
cellSize: number;
|
|
@@ -37,6 +43,16 @@ export interface SpatialApiOptions {
|
|
|
37
43
|
grid?: SpatialGridOptions | false;
|
|
38
44
|
/** When this number changes between queries, the grid rebuilds without an explicit `invalidate()`. */
|
|
39
45
|
getVersion?: () => number;
|
|
46
|
+
/**
|
|
47
|
+
* Slide a horizontal step against the scene's solid geometry, returning the step actually allowed.
|
|
48
|
+
* Wired by the runtime to the same blocking-collider query the player resolver reads, so a chaser
|
|
49
|
+
* calling `moveToward` stops at the wall the player stops at. Omitted (tests, bare indexes) leaves
|
|
50
|
+
* `moveToward` a pure lerp.
|
|
51
|
+
*/
|
|
52
|
+
resolveStep?: (position: EntityPosition, stepX: number, stepZ: number) => {
|
|
53
|
+
stepX: number;
|
|
54
|
+
stepZ: number;
|
|
55
|
+
};
|
|
40
56
|
}
|
|
41
57
|
export interface SpatialApi {
|
|
42
58
|
distance(aInstanceId: string, bInstanceId: string): number | null;
|
package/dist/scene/spatial.js
CHANGED
|
@@ -23,7 +23,7 @@ function packCell(cx, cz) {
|
|
|
23
23
|
}
|
|
24
24
|
const DEFAULT_CELL_SIZE = 8;
|
|
25
25
|
export function createSpatialApi(options) {
|
|
26
|
-
const { resolvePosition, candidates, occluder, getVersion } = options;
|
|
26
|
+
const { resolvePosition, candidates, occluder, getVersion, resolveStep } = options;
|
|
27
27
|
const gridConfig = options.grid === false ? undefined : (options.grid ?? { cellSize: DEFAULT_CELL_SIZE });
|
|
28
28
|
const cellSize = gridConfig?.cellSize;
|
|
29
29
|
let gridIndex = null;
|
|
@@ -198,7 +198,7 @@ export function createSpatialApi(options) {
|
|
|
198
198
|
invalidate() {
|
|
199
199
|
gridDirty = true;
|
|
200
200
|
},
|
|
201
|
-
moveToward(instanceId, target, { speed, stopDistance = 0, dt }) {
|
|
201
|
+
moveToward(instanceId, target, { speed, stopDistance = 0, dt, avoidSolids = true }) {
|
|
202
202
|
const current = resolvePosition(instanceId);
|
|
203
203
|
const destination = resolveTarget(target);
|
|
204
204
|
if (current === undefined || destination === undefined)
|
|
@@ -209,10 +209,13 @@ export function createSpatialApi(options) {
|
|
|
209
209
|
return current;
|
|
210
210
|
const step = Math.min(speed * dt, remaining);
|
|
211
211
|
const scale = step / total;
|
|
212
|
+
const stepX = (destination[0] - current[0]) * scale;
|
|
213
|
+
const stepZ = (destination[2] - current[2]) * scale;
|
|
214
|
+
const slid = avoidSolids && resolveStep !== undefined ? resolveStep(current, stepX, stepZ) : { stepX, stepZ };
|
|
212
215
|
return [
|
|
213
|
-
current[0] +
|
|
216
|
+
current[0] + slid.stepX,
|
|
214
217
|
current[1] + (destination[1] - current[1]) * scale,
|
|
215
|
-
current[2] +
|
|
218
|
+
current[2] + slid.stepZ,
|
|
216
219
|
];
|
|
217
220
|
},
|
|
218
221
|
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binds the part slots `world/buildings` generates to real models, so a procedural building renders
|
|
3
|
+
* as an instanced asset kit instead of untextured blocks. Pure serializable data — core resolves
|
|
4
|
+
* bindings, the renderer loads them.
|
|
5
|
+
*/
|
|
6
|
+
import type { BuildingKitSlot, BuildingPartKind, BuildingVariantCounts, Vec3 } from "./buildings.js";
|
|
7
|
+
/**
|
|
8
|
+
* How a kit model's native bounds are mapped onto the slot box `BuildingPartPlacement.scale`
|
|
9
|
+
* describes.
|
|
10
|
+
*
|
|
11
|
+
* - `stretch` — non-uniform scale to exactly fill the slot on all three axes. Right for tiling
|
|
12
|
+
* panels (walls, shutters) whose art is meant to fill its bay.
|
|
13
|
+
* - `contain` — uniform scale so the model fits entirely inside the slot. Right for props that
|
|
14
|
+
* must not distort (AC units, chimneys, roof clutter).
|
|
15
|
+
* - `cover` — uniform scale so the model covers the slot on its widest axis; may overhang.
|
|
16
|
+
* - `native` — no fit scaling; the model's authored size is used as-is.
|
|
17
|
+
*/
|
|
18
|
+
export type BuildingKitFit = "stretch" | "contain" | "cover" | "native";
|
|
19
|
+
/**
|
|
20
|
+
* One bindable variant for a building part slot. `model` is an opaque reference the host resolves
|
|
21
|
+
* (an asset id, a pack-relative path, or a URL) — core never loads anything.
|
|
22
|
+
*
|
|
23
|
+
* Adjustments apply in slot-local space after `fit`: `x` runs along the facade, `y` is up, `z`
|
|
24
|
+
* points outward from the wall.
|
|
25
|
+
*/
|
|
26
|
+
export interface BuildingKitPart {
|
|
27
|
+
model: string;
|
|
28
|
+
fit?: BuildingKitFit;
|
|
29
|
+
offset?: Vec3;
|
|
30
|
+
/** Euler XYZ in radians, applied on top of the slot's facade rotation. */
|
|
31
|
+
rotation?: Vec3;
|
|
32
|
+
/** Multiplier applied after `fit`. */
|
|
33
|
+
scale?: Vec3;
|
|
34
|
+
tint?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Quarter-turn the model when its long horizontal axis disagrees with the slot's, so a panel
|
|
37
|
+
* authored running along Z still tiles a bay that runs along X. Default true — modular kits do not
|
|
38
|
+
* agree on a facing convention, and a stretched panel that is 90° off is always wrong. Set false
|
|
39
|
+
* when a model's authored orientation is deliberate.
|
|
40
|
+
*/
|
|
41
|
+
autoOrient?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Binds a building's generated part slots to models. A kit is plain serializable data — scene
|
|
45
|
+
* documents can carry one — and is deliberately unopinionated about art direction: the engine ships
|
|
46
|
+
* the seam, games ship the bindings.
|
|
47
|
+
*/
|
|
48
|
+
export interface BuildingKit {
|
|
49
|
+
id: string;
|
|
50
|
+
/** Variants per part kind; `BuildingKitSlot.variant` indexes this list, wrapping. */
|
|
51
|
+
parts: Readonly<Partial<Record<BuildingPartKind, readonly BuildingKitPart[]>>>;
|
|
52
|
+
/** Overrides keyed by `BuildingKitSlot.key` (`${facade}.${kind}`); beats `parts` for that facade. */
|
|
53
|
+
slots?: Readonly<Record<string, readonly BuildingKitPart[]>>;
|
|
54
|
+
/** Kinds this kit deliberately drops — no mesh, and no box fallback either. */
|
|
55
|
+
omit?: readonly BuildingPartKind[];
|
|
56
|
+
/** Fit for parts that do not set their own. Defaults to `stretch`. */
|
|
57
|
+
fit?: BuildingKitFit;
|
|
58
|
+
}
|
|
59
|
+
/** A variant as authored: a bare model reference, or the full {@link BuildingKitPart} when it needs fit or transform tuning. */
|
|
60
|
+
export type BuildingKitPartInput = string | BuildingKitPart;
|
|
61
|
+
/** The authoring shape {@link defineBuildingKit} normalizes into a {@link BuildingKit}. */
|
|
62
|
+
export interface BuildingKitInput {
|
|
63
|
+
id?: string;
|
|
64
|
+
parts: Readonly<Partial<Record<BuildingPartKind, readonly BuildingKitPartInput[]>>>;
|
|
65
|
+
slots?: Readonly<Record<string, readonly BuildingKitPartInput[]>>;
|
|
66
|
+
omit?: readonly BuildingPartKind[];
|
|
67
|
+
fit?: BuildingKitFit;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* What a renderer should do with one generated part.
|
|
71
|
+
*
|
|
72
|
+
* `model` carries a resolved variant with its fit already defaulted; `box` means fall back to the
|
|
73
|
+
* engine's untextured block; `omit` means draw nothing.
|
|
74
|
+
*/
|
|
75
|
+
export type BuildingKitBinding = {
|
|
76
|
+
readonly type: "model";
|
|
77
|
+
readonly part: BuildingKitPart;
|
|
78
|
+
readonly fit: BuildingKitFit;
|
|
79
|
+
} | {
|
|
80
|
+
readonly type: "box";
|
|
81
|
+
} | {
|
|
82
|
+
readonly type: "omit";
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Validates and normalizes a kit description, accepting bare model strings as single-field variants.
|
|
86
|
+
* Throws on empty variant lists so a typo fails at authoring time rather than silently falling back
|
|
87
|
+
* to untextured boxes.
|
|
88
|
+
*
|
|
89
|
+
* @capability building-kit bind generated facade part slots to real models instead of untextured blocks
|
|
90
|
+
*/
|
|
91
|
+
export declare function defineBuildingKit(input: BuildingKitInput): BuildingKit;
|
|
92
|
+
/**
|
|
93
|
+
* Resolves one generated part against a kit. Pass the placement's `kind` and its `kit` slot; the
|
|
94
|
+
* slot's `variant` wraps around the bound list, so a kit may carry any number of variants
|
|
95
|
+
* independent of the generator's `variants` counts.
|
|
96
|
+
*
|
|
97
|
+
* Returns `box` when the kind is unbound — the renderer keeps drawing today's block, so a partial
|
|
98
|
+
* kit is a valid kit.
|
|
99
|
+
*
|
|
100
|
+
* @capability building-kit resolve one generated part to its bound model, a block fallback, or nothing
|
|
101
|
+
*/
|
|
102
|
+
export declare function resolveBuildingKitPart(kit: BuildingKit | undefined, kind: BuildingPartKind, slot?: BuildingKitSlot): BuildingKitBinding;
|
|
103
|
+
/**
|
|
104
|
+
* The variant counts a kit actually supplies, shaped for `BuildingConfigInput.variants`. Feed this
|
|
105
|
+
* into `generateBuilding` so seeded picks spread across exactly the models the kit binds instead of
|
|
106
|
+
* indexing past them and wrapping unevenly.
|
|
107
|
+
*
|
|
108
|
+
* @capability building-kit bind generated facade part slots to real models instead of untextured blocks
|
|
109
|
+
*/
|
|
110
|
+
export declare function buildingKitVariantCounts(kit: BuildingKit): Partial<BuildingVariantCounts>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const BOX = { type: "box" };
|
|
2
|
+
const OMIT = { type: "omit" };
|
|
3
|
+
const DEFAULT_FIT = "stretch";
|
|
4
|
+
function normalizePart(input, where) {
|
|
5
|
+
const part = typeof input === "string" ? { model: input } : input;
|
|
6
|
+
if (typeof part.model !== "string" || part.model.length === 0) {
|
|
7
|
+
throw new Error(`Building kit ${where} has a variant with no model reference.`);
|
|
8
|
+
}
|
|
9
|
+
return part;
|
|
10
|
+
}
|
|
11
|
+
function normalizeList(list, where) {
|
|
12
|
+
if (list.length === 0) {
|
|
13
|
+
throw new Error(`Building kit ${where} binds an empty variant list. Bind at least one model, or add the kind to \`omit\` to drop it.`);
|
|
14
|
+
}
|
|
15
|
+
return list.map((entry) => normalizePart(entry, where));
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validates and normalizes a kit description, accepting bare model strings as single-field variants.
|
|
19
|
+
* Throws on empty variant lists so a typo fails at authoring time rather than silently falling back
|
|
20
|
+
* to untextured boxes.
|
|
21
|
+
*
|
|
22
|
+
* @capability building-kit bind generated facade part slots to real models instead of untextured blocks
|
|
23
|
+
*/
|
|
24
|
+
export function defineBuildingKit(input) {
|
|
25
|
+
const parts = {};
|
|
26
|
+
for (const [kind, list] of Object.entries(input.parts)) {
|
|
27
|
+
if (list === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
parts[kind] = normalizeList(list, `"${input.id ?? "kit"}" part "${kind}"`);
|
|
30
|
+
}
|
|
31
|
+
const kit = {
|
|
32
|
+
id: input.id ?? "building-kit",
|
|
33
|
+
parts,
|
|
34
|
+
...(input.omit === undefined ? {} : { omit: input.omit }),
|
|
35
|
+
...(input.fit === undefined ? {} : { fit: input.fit }),
|
|
36
|
+
};
|
|
37
|
+
if (input.slots === undefined)
|
|
38
|
+
return kit;
|
|
39
|
+
const slots = {};
|
|
40
|
+
for (const [key, list] of Object.entries(input.slots)) {
|
|
41
|
+
slots[key] = normalizeList(list, `"${kit.id}" slot "${key}"`);
|
|
42
|
+
}
|
|
43
|
+
return { ...kit, slots };
|
|
44
|
+
}
|
|
45
|
+
function variantsFor(kit, kind, slotKey) {
|
|
46
|
+
if (slotKey !== undefined) {
|
|
47
|
+
const override = kit.slots?.[slotKey];
|
|
48
|
+
if (override !== undefined)
|
|
49
|
+
return override;
|
|
50
|
+
}
|
|
51
|
+
return kit.parts[kind];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolves one generated part against a kit. Pass the placement's `kind` and its `kit` slot; the
|
|
55
|
+
* slot's `variant` wraps around the bound list, so a kit may carry any number of variants
|
|
56
|
+
* independent of the generator's `variants` counts.
|
|
57
|
+
*
|
|
58
|
+
* Returns `box` when the kind is unbound — the renderer keeps drawing today's block, so a partial
|
|
59
|
+
* kit is a valid kit.
|
|
60
|
+
*
|
|
61
|
+
* @capability building-kit resolve one generated part to its bound model, a block fallback, or nothing
|
|
62
|
+
*/
|
|
63
|
+
export function resolveBuildingKitPart(kit, kind, slot) {
|
|
64
|
+
if (kit === undefined)
|
|
65
|
+
return BOX;
|
|
66
|
+
if (kit.omit?.includes(kind) === true)
|
|
67
|
+
return OMIT;
|
|
68
|
+
const variants = variantsFor(kit, kind, slot?.key);
|
|
69
|
+
if (variants === undefined || variants.length === 0)
|
|
70
|
+
return BOX;
|
|
71
|
+
const index = slot === undefined ? 0 : Math.abs(Math.trunc(slot.variant)) % variants.length;
|
|
72
|
+
const part = variants[index];
|
|
73
|
+
return { type: "model", part, fit: part.fit ?? kit.fit ?? DEFAULT_FIT };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The variant counts a kit actually supplies, shaped for `BuildingConfigInput.variants`. Feed this
|
|
77
|
+
* into `generateBuilding` so seeded picks spread across exactly the models the kit binds instead of
|
|
78
|
+
* indexing past them and wrapping unevenly.
|
|
79
|
+
*
|
|
80
|
+
* @capability building-kit bind generated facade part slots to real models instead of untextured blocks
|
|
81
|
+
*/
|
|
82
|
+
export function buildingKitVariantCounts(kit) {
|
|
83
|
+
const counts = {};
|
|
84
|
+
for (const [kind, list] of Object.entries(kit.parts)) {
|
|
85
|
+
if (list === undefined || list.length === 0)
|
|
86
|
+
continue;
|
|
87
|
+
// `roof`, `guardrail`, and `corner` have no variant knob on BuildingVariantCounts.
|
|
88
|
+
if (kind === "roof" || kind === "guardrail" || kind === "corner")
|
|
89
|
+
continue;
|
|
90
|
+
counts[kind] = list.length;
|
|
91
|
+
}
|
|
92
|
+
return counts;
|
|
93
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeded procedural buildings: a footprint of bays × floors emitted as placed facade parts — walls,
|
|
3
|
+
* windows, storefronts, roof and decor — each tagged with a kit slot `world/buildingKit` binds to a
|
|
4
|
+
* model. Deterministic and renderer-agnostic.
|
|
5
|
+
*/
|
|
1
6
|
import { type Aabb, type Footprint, type Vec2 } from "./geometry.js";
|
|
2
7
|
export type BuildingSeed = number | string;
|
|
3
8
|
export type Vec3 = readonly [number, number, number];
|
|
@@ -105,11 +110,28 @@ export declare const DEFAULT_BUILDING_STYLE: BuildingStyle;
|
|
|
105
110
|
export declare const BUILDING_STYLE_PALETTES: Record<BuildingStyle, BuildingPalette>;
|
|
106
111
|
/** @internal */
|
|
107
112
|
export declare function resolveBuildingPalette(style?: BuildingStyle, overrides?: BuildingPaletteOverrides): BuildingPalette;
|
|
108
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Fill a partial building description out to a complete {@link BuildingConfig}, clamping counts and
|
|
115
|
+
* probabilities. Use it to inspect or adjust the resolved shape before generating.
|
|
116
|
+
*
|
|
117
|
+
* @capability building-generator generate seeded buildings as placed facade parts — bays, floors, roof, decor
|
|
118
|
+
*/
|
|
109
119
|
export declare function createBuildingConfig(input?: BuildingConfigInput): BuildingConfig;
|
|
110
|
-
/**
|
|
120
|
+
/**
|
|
121
|
+
* Generate one building as a flat list of placed facade parts: bays × floors per facade, a ground
|
|
122
|
+
* row, corners, and a roof, with every decorative part rolled from `probabilities` and every part
|
|
123
|
+
* tagged with a `kit` slot the renderer binds to a model (see `world/buildingKit`). Deterministic
|
|
124
|
+
* from `seed`.
|
|
125
|
+
*
|
|
126
|
+
* @capability building-generator generate a seeded building as placed facade parts (bays, floors, roof, decor)
|
|
127
|
+
*/
|
|
111
128
|
export declare function generateBuilding(input?: BuildingConfigInput): GeneratedBuilding;
|
|
112
129
|
/** @internal */
|
|
113
130
|
export declare function createBuildingGrid(config: BuildingGridConfig): BuildingLot[];
|
|
114
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* Generate a grid of buildings on lots sized by `lotSize` and separated by `streetWidth`, each with
|
|
133
|
+
* its floor count rolled from `floorRange`.
|
|
134
|
+
*
|
|
135
|
+
* @capability building-generator generate a grid district of seeded buildings
|
|
136
|
+
*/
|
|
115
137
|
export declare function generateBuildingDistrict(config: BuildingGridConfig): GeneratedBuilding[];
|
package/dist/world/buildings.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seeded procedural buildings: a footprint of bays × floors emitted as placed facade parts — walls,
|
|
3
|
+
* windows, storefronts, roof and decor — each tagged with a kit slot `world/buildingKit` binds to a
|
|
4
|
+
* model. Deterministic and renderer-agnostic.
|
|
5
|
+
*/
|
|
1
6
|
import { footprintAabb } from "./geometry.js";
|
|
2
7
|
const DEFAULT_PROBABILITIES = {
|
|
3
8
|
window: 0.88,
|
|
@@ -229,7 +234,12 @@ function rangeInt(seed, key, min, max) {
|
|
|
229
234
|
const hi = Math.max(min, max);
|
|
230
235
|
return lo + Math.floor(hash01(seed, key) * (hi - lo + 1));
|
|
231
236
|
}
|
|
232
|
-
/**
|
|
237
|
+
/**
|
|
238
|
+
* Fill a partial building description out to a complete {@link BuildingConfig}, clamping counts and
|
|
239
|
+
* probabilities. Use it to inspect or adjust the resolved shape before generating.
|
|
240
|
+
*
|
|
241
|
+
* @capability building-generator generate seeded buildings as placed facade parts — bays, floors, roof, decor
|
|
242
|
+
*/
|
|
233
243
|
export function createBuildingConfig(input = {}) {
|
|
234
244
|
return {
|
|
235
245
|
id: input.id ?? DEFAULT_BUILDING_CONFIG.id,
|
|
@@ -291,6 +301,13 @@ function panelPosition(config, facade, bay, level, width, depth) {
|
|
|
291
301
|
const z = config.center[1] - depth / 2 + config.bayWidth * (bay + 0.5);
|
|
292
302
|
return [faceDepth(facade, width, depth, config), y, z];
|
|
293
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* Panel dimensions inside one bay. Structural parts (wall, shutter) must pass 1/1 so they tile their
|
|
306
|
+
* bay completely — a generated building is a shell of thin panels with no solid core, so any ratio
|
|
307
|
+
* below 1 leaves a continuous slit at every bay seam and floor line that you can see through the
|
|
308
|
+
* building and out the far side. Decorative overlays (window, storefront, awning, sign) sit on top of
|
|
309
|
+
* a full wall and are meant to be inset.
|
|
310
|
+
*/
|
|
294
311
|
function panelScale(config, _facade, heightRatio, widthRatio = 0.92) {
|
|
295
312
|
const along = config.bayWidth * widthRatio;
|
|
296
313
|
const high = config.floorHeight * heightRatio;
|
|
@@ -333,7 +350,7 @@ function pushUpperFloorParts(parts, config, facade, bay, level, width, depth) {
|
|
|
333
350
|
const cell = { facade, level, bay };
|
|
334
351
|
const key = `${facade}:${level}:${bay}`;
|
|
335
352
|
const position = panelPosition(config, facade, bay, level, width, depth);
|
|
336
|
-
parts.push(part(config, "wall", facade, position, panelScale(config, facade,
|
|
353
|
+
parts.push(part(config, "wall", facade, position, panelScale(config, facade, 1, 1), `${key}:wall`, cell, ["structure", "upper"]));
|
|
337
354
|
if (!chance(config.seed, `${key}:window`, config.probabilities.window))
|
|
338
355
|
return;
|
|
339
356
|
parts.push(part(config, "window", facade, position, panelScale(config, facade, 0.46, 0.58), `${key}:window`, cell, ["upper"]));
|
|
@@ -355,6 +372,11 @@ function pushGroundFloorParts(parts, config, facade, bay, width, depth) {
|
|
|
355
372
|
const key = `${facade}:0:${bay}`;
|
|
356
373
|
const position = panelPosition(config, facade, bay, 0, width, depth);
|
|
357
374
|
if (facade === "front" && chance(config.seed, `${key}:open`, config.probabilities.openStore)) {
|
|
375
|
+
// Back the glass with a wall the way an upper floor backs its window. A generated building is a
|
|
376
|
+
// hollow shell, so a translucent storefront with nothing behind it is a hole straight through the
|
|
377
|
+
// building to the world beyond — and open stores only land on the street-facing facade, so a row
|
|
378
|
+
// of shops along a road was the most visible place for it to read as see-through.
|
|
379
|
+
parts.push(part(config, "wall", facade, position, panelScale(config, facade, 1, 1), `${key}:wall`, cell, ["structure", "ground"]));
|
|
358
380
|
parts.push(part(config, "storefront", facade, position, panelScale(config, facade, 0.86), `${key}:store`, cell, ["ground", "open"]));
|
|
359
381
|
if (chance(config.seed, `${key}:sign`, config.probabilities.storeSign)) {
|
|
360
382
|
const signPosition = [position[0], position[1] + config.floorHeight * 0.34, position[2]];
|
|
@@ -363,7 +385,7 @@ function pushGroundFloorParts(parts, config, facade, bay, width, depth) {
|
|
|
363
385
|
return;
|
|
364
386
|
}
|
|
365
387
|
const kind = facade === "front" ? "shutter" : "wall";
|
|
366
|
-
parts.push(part(config, kind, facade, position, panelScale(config, facade,
|
|
388
|
+
parts.push(part(config, kind, facade, position, panelScale(config, facade, 1, 1), `${key}:${kind}`, cell, ["ground", "closed"]));
|
|
367
389
|
}
|
|
368
390
|
function pushFacade(parts, config, facade, width, depth) {
|
|
369
391
|
const bays = facade === "front" || facade === "back" ? config.baysWide : config.baysDeep;
|
|
@@ -408,7 +430,14 @@ function pushCorners(parts, config, width, depth) {
|
|
|
408
430
|
parts.push(part(config, "corner", index < 2 ? "back" : "front", position, [config.facadeDepth * 1.35, height, config.facadeDepth * 1.35], `corner:${index}`, undefined, ["structure"]));
|
|
409
431
|
});
|
|
410
432
|
}
|
|
411
|
-
/**
|
|
433
|
+
/**
|
|
434
|
+
* Generate one building as a flat list of placed facade parts: bays × floors per facade, a ground
|
|
435
|
+
* row, corners, and a roof, with every decorative part rolled from `probabilities` and every part
|
|
436
|
+
* tagged with a `kit` slot the renderer binds to a model (see `world/buildingKit`). Deterministic
|
|
437
|
+
* from `seed`.
|
|
438
|
+
*
|
|
439
|
+
* @capability building-generator generate a seeded building as placed facade parts (bays, floors, roof, decor)
|
|
440
|
+
*/
|
|
412
441
|
export function generateBuilding(input = {}) {
|
|
413
442
|
const config = createBuildingConfig(input);
|
|
414
443
|
const width = config.baysWide * config.bayWidth;
|
|
@@ -471,7 +500,12 @@ export function createBuildingGrid(config) {
|
|
|
471
500
|
}
|
|
472
501
|
return lots;
|
|
473
502
|
}
|
|
474
|
-
/**
|
|
503
|
+
/**
|
|
504
|
+
* Generate a grid of buildings on lots sized by `lotSize` and separated by `streetWidth`, each with
|
|
505
|
+
* its floor count rolled from `floorRange`.
|
|
506
|
+
*
|
|
507
|
+
* @capability building-generator generate a grid district of seeded buildings
|
|
508
|
+
*/
|
|
475
509
|
export function generateBuildingDistrict(config) {
|
|
476
510
|
return createBuildingGrid(config).map((lot) => generateBuilding(lot.config));
|
|
477
511
|
}
|
package/dist/world/cityKind.d.ts
CHANGED
|
@@ -106,6 +106,12 @@ export interface CityRules {
|
|
|
106
106
|
sidewalkWidth: number;
|
|
107
107
|
/** Building style palette id (see {@link BUILDING_STYLE_PALETTES}). */
|
|
108
108
|
style: BuildingStyle;
|
|
109
|
+
/**
|
|
110
|
+
* Name of the `BuildingKit` the near-LOD facades render with, or `""` for the palette blocks.
|
|
111
|
+
* A game registers its bound art under this name; the document carries only the name, so a scene
|
|
112
|
+
* stays portable and small.
|
|
113
|
+
*/
|
|
114
|
+
buildingKit: string;
|
|
109
115
|
/** Seed string; same seed reproduces the same city. Empty falls back to the volume id. */
|
|
110
116
|
seed: string;
|
|
111
117
|
}
|
package/dist/world/cityKind.js
CHANGED
|
@@ -103,6 +103,7 @@ export const CITY_DEFAULTS = {
|
|
|
103
103
|
sidewalks: true,
|
|
104
104
|
sidewalkWidth: 1.9,
|
|
105
105
|
style: DEFAULT_BUILDING_STYLE,
|
|
106
|
+
buildingKit: "",
|
|
106
107
|
seed: "",
|
|
107
108
|
};
|
|
108
109
|
/** The city parameter schema — drives the inspector sliders and `meta` parse via the studio seam. */
|
|
@@ -184,6 +185,7 @@ export const CITY_SCHEMA = {
|
|
|
184
185
|
default: CITY_DEFAULTS.style,
|
|
185
186
|
options: Object.keys(BUILDING_STYLE_PALETTES).map((style) => ({ value: style })),
|
|
186
187
|
},
|
|
188
|
+
{ type: "text", key: "buildingKit", label: "building kit", group: "buildings", default: CITY_DEFAULTS.buildingKit },
|
|
187
189
|
{ type: "range", key: "treeDensity", label: "tree density", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.treeDensity },
|
|
188
190
|
{ type: "weightedList", key: "treeMix", label: "tree mix", group: "greenery", itemLabel: "species", default: CITY_DEFAULTS.treeMix },
|
|
189
191
|
{ type: "range", key: "lightDensity", label: "street lights", group: "greenery", min: 0, max: 1, step: 0.01, default: CITY_DEFAULTS.lightDensity },
|
|
@@ -563,6 +565,7 @@ export function readCityRules(meta) {
|
|
|
563
565
|
sidewalks: params["sidewalks"],
|
|
564
566
|
sidewalkWidth: params["sidewalkWidth"],
|
|
565
567
|
style: params["style"],
|
|
568
|
+
buildingKit: params["buildingKit"] ?? CITY_DEFAULTS.buildingKit,
|
|
566
569
|
seed: params["seed"],
|
|
567
570
|
};
|
|
568
571
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { DEFAULT_BUILDING_CONFIG, generateBuilding, generateBuildingDistrict, resolveBuildingPalette, } from "./buildings.js";
|
|
2
|
+
import { buildingKitVariantCounts } from "./buildingKit.js";
|
|
2
3
|
import { deriveBuildingLots } from "./buildingLots.js";
|
|
3
4
|
import { pathLength } from "./roads.js";
|
|
4
5
|
import { resolveTerrainField, resolveTerrainPalette } from "./terrain.js";
|
|
@@ -21,10 +22,17 @@ export function resolveStructureBuildings(descriptor) {
|
|
|
21
22
|
lotSize: descriptor.footprint,
|
|
22
23
|
streetWidth: spacing,
|
|
23
24
|
floorRange: descriptor.stories,
|
|
24
|
-
base: { floorHeight: descriptor.storyHeight },
|
|
25
|
+
base: { floorHeight: descriptor.storyHeight, ...kitVariants(descriptor.kit) },
|
|
25
26
|
...(descriptor.seed === undefined ? {} : { seed: descriptor.seed }),
|
|
26
27
|
}).slice(0, descriptor.count);
|
|
27
28
|
}
|
|
29
|
+
/** Spread seeded variant picks across exactly the models a kit binds, so no bound variant goes unused. */
|
|
30
|
+
function kitVariants(kit) {
|
|
31
|
+
if (kit === undefined)
|
|
32
|
+
return {};
|
|
33
|
+
const variants = buildingKitVariantCounts(kit);
|
|
34
|
+
return Object.keys(variants).length === 0 ? {} : { variants };
|
|
35
|
+
}
|
|
28
36
|
function hashU32(input) {
|
|
29
37
|
let hash = 2166136261;
|
|
30
38
|
for (let i = 0; i < input.length; i += 1) {
|
|
@@ -57,9 +65,10 @@ function resolveFrontageBuildings(descriptor, along) {
|
|
|
57
65
|
});
|
|
58
66
|
const [floorsMin, floorsMax] = descriptor.stories;
|
|
59
67
|
const bayWidth = DEFAULT_BUILDING_CONFIG.bayWidth;
|
|
60
|
-
|
|
68
|
+
const variants = kitVariants(descriptor.kit);
|
|
69
|
+
return lots.map((lot, index) => buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, descriptor.storyHeight, variants));
|
|
61
70
|
}
|
|
62
|
-
function buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, storyHeight) {
|
|
71
|
+
function buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, storyHeight, variants = {}) {
|
|
63
72
|
const lo = Math.min(floorsMin, floorsMax);
|
|
64
73
|
const hi = Math.max(floorsMin, floorsMax);
|
|
65
74
|
const floors = lo + Math.floor((hashU32(`${seed}:floors:${index}`) / 4294967296) * (hi - lo + 1));
|
|
@@ -74,6 +83,7 @@ function buildLot(lot, index, seed, floorsMin, floorsMax, bayWidth, storyHeight)
|
|
|
74
83
|
baysDeep,
|
|
75
84
|
bayWidth,
|
|
76
85
|
floorHeight: storyHeight,
|
|
86
|
+
...variants,
|
|
77
87
|
});
|
|
78
88
|
return { ...building, rotationY: lot.rotationY };
|
|
79
89
|
}
|
package/dist/world/features.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { BuildingPaletteOverrides, BuildingStyle } from "./buildings.js";
|
|
2
|
+
import type { BuildingKit } from "./buildingKit.js";
|
|
2
3
|
import type { AvoidZone } from "./geometry.js";
|
|
3
4
|
import type { TerrainPathProfile } from "./pathTerrain.js";
|
|
4
5
|
import type { PlaceWorldFeature } from "./place.js";
|
|
@@ -352,6 +353,11 @@ export interface BuildingEnvironmentConfig {
|
|
|
352
353
|
* back by a sidewalk strip) instead of the grid around `position`. See {@link BuildingFrontageConfig}.
|
|
353
354
|
*/
|
|
354
355
|
along?: BuildingFrontageConfig;
|
|
356
|
+
/**
|
|
357
|
+
* Binds facade part slots to real models (see `world/buildingKit`). Unbound kinds keep the
|
|
358
|
+
* untextured block; without a kit the whole facade stays blocks, as before.
|
|
359
|
+
*/
|
|
360
|
+
kit?: BuildingKit;
|
|
355
361
|
}
|
|
356
362
|
export type PadSize = readonly [number, number] | {
|
|
357
363
|
radius: number;
|
|
@@ -393,7 +399,7 @@ export type OceanEnvironmentDescriptor = {
|
|
|
393
399
|
} & Required<Pick<OceanEnvironmentConfig, "bounds" | "level" | "waveHeight" | "waveScale" | "waveSpeed" | "color">> & Pick<OceanEnvironmentConfig, "position" | "levelAt">;
|
|
394
400
|
export type BuildingEnvironmentDescriptor = {
|
|
395
401
|
kind: "building";
|
|
396
|
-
} & Required<Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style">> & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along">;
|
|
402
|
+
} & Required<Pick<BuildingEnvironmentConfig, "count" | "footprint" | "stories" | "storyHeight" | "spacing" | "style">> & Pick<BuildingEnvironmentConfig, "seed" | "position" | "palette" | "along" | "kit">;
|
|
397
403
|
export type PadEnvironmentDescriptor = {
|
|
398
404
|
kind: "pad";
|
|
399
405
|
} & Required<Pick<PadEnvironmentConfig, "center" | "size" | "height" | "color">> & Pick<PadEnvironmentConfig, "rotationY" | "elevation">;
|