@jgengine/core 0.15.0-next.0 → 0.16.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 +49 -0
- package/dist/audio/musicState.d.ts +47 -0
- package/dist/audio/musicState.js +38 -0
- package/dist/audio/stepCadence.d.ts +47 -0
- package/dist/audio/stepCadence.js +29 -0
- package/dist/audio/synth.d.ts +12 -0
- package/dist/game/playableGame.d.ts +36 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +167 -1
- 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 +14 -0
- package/dist/runtime/descriptors/baseline.d.ts +3 -1
- package/dist/runtime/descriptors/baseline.js +11 -0
- package/dist/runtime/gameContext.js +6 -0
- package/dist/runtime/gameContextTypes.d.ts +13 -0
- package/dist/runtime/hostPersistence.js +9 -5
- 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/scene/objectStore.d.ts +26 -0
- package/dist/scene/objectStore.js +51 -9
- 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 +15 -3
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-gameplay/api.md +2 -1
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +40 -2
- package/skills/jgengine-world/capabilities.md +16 -0
- package/skills/jgengine-world/recipes/building-kit.md +78 -0
|
@@ -6,6 +6,7 @@ import { fittedEntityColliders, fittedObjectColliders, hasMeshColliderShape, mea
|
|
|
6
6
|
import { prepareCollisionMeshSource } from "../../scene/collisionMesh.js";
|
|
7
7
|
import { raycastObjects, raycastObjectsAll } from "../../scene/objectQuery.js";
|
|
8
8
|
import { createObjectStore, objectVisualScale } from "../../scene/objectStore.js";
|
|
9
|
+
import { createObjectSlots } from "./objectSlots.js";
|
|
9
10
|
import { createPaintLayer } from "../../scene/paintLayer.js";
|
|
10
11
|
import { createSelectionSet } from "../../scene/selection.js";
|
|
11
12
|
import { createSceneRaycast, } from "../../scene/sceneRaycast.js";
|
|
@@ -316,6 +317,15 @@ export function createSceneSubsystem(d) {
|
|
|
316
317
|
return created;
|
|
317
318
|
}
|
|
318
319
|
const objectSelection = notifyAfter(createSelectionSet(), ["add", "remove", "toggle", "replace", "clear"], signalNotify);
|
|
320
|
+
let inventoryAccess = null;
|
|
321
|
+
const objectSlots = createObjectSlots({
|
|
322
|
+
objects,
|
|
323
|
+
layoutOf: (instanceId) => catalogObject(instanceId)?.slotInventory ?? null,
|
|
324
|
+
traits: () => inventoryAccess?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY },
|
|
325
|
+
hasInventory: (inventoryId) => inventoryAccess?.hasInventory(inventoryId) ?? false,
|
|
326
|
+
inventoryFor: (userId) => inventoryAccess?.inventoryFor(userId) ?? null,
|
|
327
|
+
notify: signalNotify,
|
|
328
|
+
});
|
|
319
329
|
const sceneObjects = {
|
|
320
330
|
...objects,
|
|
321
331
|
remove(instanceId) {
|
|
@@ -338,6 +348,7 @@ export function createSceneSubsystem(d) {
|
|
|
338
348
|
reportBounds: reportObjectBounds,
|
|
339
349
|
reportCollisionMesh: reportObjectCollisionMesh,
|
|
340
350
|
selection: objectSelection,
|
|
351
|
+
slots: objectSlots,
|
|
341
352
|
};
|
|
342
353
|
return {
|
|
343
354
|
entities,
|
|
@@ -372,5 +383,8 @@ export function createSceneSubsystem(d) {
|
|
|
372
383
|
setOnAfterDespawn(fn) {
|
|
373
384
|
onAfterDespawn = fn;
|
|
374
385
|
},
|
|
386
|
+
setInventoryAccess(access) {
|
|
387
|
+
inventoryAccess = access;
|
|
388
|
+
},
|
|
375
389
|
};
|
|
376
390
|
}
|
|
@@ -4,6 +4,7 @@ import type { InventorySet } from "../../inventory/inventoryModel.js";
|
|
|
4
4
|
import type { PoseState } from "../../movement/poseState.js";
|
|
5
5
|
import { type StatValueMap } from "../../scene/entityStats.js";
|
|
6
6
|
import type { EntityStore } from "../../scene/entityStore.js";
|
|
7
|
+
import type { ObjectStore } from "../../scene/objectStore.js";
|
|
7
8
|
import type { Possession } from "../../scene/possession.js";
|
|
8
9
|
import type { ObservableKeyedStore } from "../../store/observableKeyedStore.js";
|
|
9
10
|
import type { SimClock } from "../../time/simClock.js";
|
|
@@ -13,6 +14,7 @@ import type { SnapshotModule } from "../worldSnapshot.js";
|
|
|
13
14
|
export interface BaselineDeps {
|
|
14
15
|
signalNotify: () => void;
|
|
15
16
|
entities: EntityStore;
|
|
17
|
+
objects: ObjectStore;
|
|
16
18
|
statsByInstance: Map<string, StatValueMap>;
|
|
17
19
|
store: ObservableKeyedStore<unknown>;
|
|
18
20
|
feed: GameFeed;
|
|
@@ -35,7 +37,7 @@ export interface BaselineBuild {
|
|
|
35
37
|
}
|
|
36
38
|
/**
|
|
37
39
|
* Always-on counterpart to {@link featureDescriptors}: each baseline subsystem (entities, stats,
|
|
38
|
-
* store, feed, inventory, economy, time, pose, possession, motion) owns its own serialization here
|
|
40
|
+
* store, feed, inventory, economy, time, pose, possession, motion, objects) owns its own serialization here
|
|
39
41
|
* instead of `createGameContext` hand-maintaining two distant module arrays. Order is
|
|
40
42
|
* load-bearing — it fixes the snapshot key order, so it must stay stable.
|
|
41
43
|
* @internal
|
|
@@ -154,4 +154,15 @@ export const baselineDescriptors = [
|
|
|
154
154
|
},
|
|
155
155
|
}),
|
|
156
156
|
},
|
|
157
|
+
{
|
|
158
|
+
key: "objects",
|
|
159
|
+
create: (d) => ({
|
|
160
|
+
replicate: {
|
|
161
|
+
key: "objects",
|
|
162
|
+
snapshot: () => d.objects.snapshot(),
|
|
163
|
+
decode: (raw) => decodeArray(raw),
|
|
164
|
+
hydrate: (data) => d.objects.hydrate(data),
|
|
165
|
+
},
|
|
166
|
+
}),
|
|
167
|
+
},
|
|
157
168
|
];
|
|
@@ -119,6 +119,11 @@ export function createGameContext(options) {
|
|
|
119
119
|
});
|
|
120
120
|
const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
|
|
121
121
|
scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
|
|
122
|
+
scene.setInventoryAccess({
|
|
123
|
+
traits: playerSys.itemTraits,
|
|
124
|
+
hasInventory: (inventoryId) => layouts[inventoryId] !== undefined,
|
|
125
|
+
inventoryFor,
|
|
126
|
+
});
|
|
122
127
|
// Built-in inventory mutation commands so a HUD grid drives moves/splits through the notifying
|
|
123
128
|
// command path (a raw InventorySet mutation would not re-render React). Each resolves the acting
|
|
124
129
|
// user's own inventory, so it respects `privatePerUser` replication.
|
|
@@ -264,6 +269,7 @@ export function createGameContext(options) {
|
|
|
264
269
|
const baselineDeps = {
|
|
265
270
|
signalNotify: signal.notify,
|
|
266
271
|
entities,
|
|
272
|
+
objects,
|
|
267
273
|
statsByInstance,
|
|
268
274
|
store,
|
|
269
275
|
feed,
|
|
@@ -23,6 +23,7 @@ import type { Social } from "../game/social.js";
|
|
|
23
23
|
import type { TradeField, TradeSystem } from "../game/trade.js";
|
|
24
24
|
import type { Unlocks } from "../game/unlocks.js";
|
|
25
25
|
import type { InventoryLayout, InventorySet } from "../inventory/inventoryModel.js";
|
|
26
|
+
import type { SceneObjectSlots } from "./context/objectSlots.js";
|
|
26
27
|
import type { ContextVerb } from "../interaction/contextMenu.js";
|
|
27
28
|
import type { ProximityPrompt } from "../interaction/proximityPrompt.js";
|
|
28
29
|
import type { ItemUseHandler, ItemUseInput, ItemUseRejection, ItemUseResult } from "../item/use.js";
|
|
@@ -87,6 +88,11 @@ export interface GameContextObjectEntry {
|
|
|
87
88
|
breakable?: false | {
|
|
88
89
|
baseBreakTime: number;
|
|
89
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* Declares this object a container: every placement gets its own slots, held on `SceneObject.slots`
|
|
93
|
+
* and mutated through `ctx.scene.object.slots`, which enforces the declared `slots` count and
|
|
94
|
+
* `accepts` kind filter.
|
|
95
|
+
*/
|
|
90
96
|
slotInventory?: InventoryLayout;
|
|
91
97
|
/** Right-click context-menu verbs for this object (#31). */
|
|
92
98
|
verbs?: readonly ContextVerb[];
|
|
@@ -174,6 +180,13 @@ export interface SceneObjectContext extends ObjectStore {
|
|
|
174
180
|
* (`@jgengine/shell/world/WorldHud`).
|
|
175
181
|
*/
|
|
176
182
|
selection: SelectionSet;
|
|
183
|
+
/**
|
|
184
|
+
* Per-instance container contents for objects whose catalog entry declares {@link GameContextObjectEntry.slotInventory}
|
|
185
|
+
* — chests, racks, machines. Contents live on `SceneObject.slots`, so they replicate and save with
|
|
186
|
+
* the placement, and `transferIn`/`transferOut` move items to and from a player inventory with the
|
|
187
|
+
* declared `accepts` and stack limits enforced.
|
|
188
|
+
*/
|
|
189
|
+
slots: SceneObjectSlots;
|
|
177
190
|
}
|
|
178
191
|
export interface FloatTextInput {
|
|
179
192
|
instanceId?: string;
|
|
@@ -147,6 +147,8 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
147
147
|
const leaderboard = drained.increments.map((entry) => entry.scope === "server" && entry.serverId === undefined
|
|
148
148
|
? { ...entry, serverId: server.serverId }
|
|
149
149
|
: entry);
|
|
150
|
+
const canWritePlayers = isSaveEnabled(save) && saveScopeIncludesPlayer(save.scope);
|
|
151
|
+
const canWriteChunks = isSaveEnabled(save) && saveScopeIncludesChunks(save.scope);
|
|
150
152
|
const sessionPlayers = {};
|
|
151
153
|
const profiles = [];
|
|
152
154
|
for (const userId of Object.keys(snapshot.players)) {
|
|
@@ -155,9 +157,7 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
155
157
|
continue;
|
|
156
158
|
const { persistent, session } = splitProfilePlayer(player);
|
|
157
159
|
sessionPlayers[userId] = { ...persistent, session };
|
|
158
|
-
if (
|
|
159
|
-
saveScopeIncludesPlayer(save.scope) &&
|
|
160
|
-
snapshot.dirty.players.includes(userId)) {
|
|
160
|
+
if (canWritePlayers && snapshot.dirty.players.includes(userId)) {
|
|
161
161
|
profiles.push({
|
|
162
162
|
userId,
|
|
163
163
|
gameId: server.gameId,
|
|
@@ -169,7 +169,7 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
169
169
|
}
|
|
170
170
|
const chunks = [];
|
|
171
171
|
const deletedChunks = [];
|
|
172
|
-
if (
|
|
172
|
+
if (canWriteChunks) {
|
|
173
173
|
for (const chunkKey of snapshot.dirty.chunks) {
|
|
174
174
|
const chunk = snapshot.chunks[chunkKey];
|
|
175
175
|
if (chunk) {
|
|
@@ -180,13 +180,17 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
}
|
|
183
|
+
// Server state is written unconditionally, so only unwritable player/chunk dirt keeps the row dirty;
|
|
184
|
+
// anything else would make shouldAutoSave re-fire forever after a successful full flush.
|
|
185
|
+
const unwritten = (snapshot.dirty.players.length > 0 && !canWritePlayers) ||
|
|
186
|
+
(snapshot.dirty.chunks.length > 0 && !canWriteChunks);
|
|
183
187
|
return {
|
|
184
188
|
server: {
|
|
185
189
|
...server,
|
|
186
190
|
serverState,
|
|
187
191
|
sessionPlayers,
|
|
188
192
|
revision: snapshot.revision,
|
|
189
|
-
dirtyAt:
|
|
193
|
+
dirtyAt: unwritten ? (server.dirtyAt ?? now) : undefined,
|
|
190
194
|
updatedAt: now,
|
|
191
195
|
lastSavedAt: now,
|
|
192
196
|
},
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SceneObject } from "../scene/objectStore.js";
|
|
2
|
+
import type { RuntimeObjectRow } from "./snapshot.js";
|
|
3
|
+
/**
|
|
4
|
+
* Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and
|
|
5
|
+
* `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as
|
|
6
|
+
* `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and
|
|
7
|
+
* deliberately stay off the persisted row — put anything a command must own in `state`.
|
|
8
|
+
*
|
|
9
|
+
* @capability placed-object-rows a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
|
|
10
|
+
*/
|
|
11
|
+
export declare function toRuntimeObjectRow(object: SceneObject): RuntimeObjectRow;
|
|
12
|
+
/**
|
|
13
|
+
* Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
|
|
14
|
+
*
|
|
15
|
+
* @capability placed-object-rehydrate rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
|
|
16
|
+
*/
|
|
17
|
+
export declare function fromRuntimeObjectRow(row: RuntimeObjectRow): SceneObject;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a live placed object into its persisted row — the bridge between `ctx.scene.object` and
|
|
3
|
+
* `GameRuntimeSnapshot.server.objects`/`chunks[].objects`. Per-instance `state` is carried as
|
|
4
|
+
* `flags`. Presentation overrides (`visual`, `animation`) are catalog- and authoring-resolved and
|
|
5
|
+
* deliberately stay off the persisted row — put anything a command must own in `state`.
|
|
6
|
+
*
|
|
7
|
+
* @capability placed-object-rows a placed object's per-instance state and container contents convert to and from the persisted snapshot row a host stores
|
|
8
|
+
*/
|
|
9
|
+
export function toRuntimeObjectRow(object) {
|
|
10
|
+
return {
|
|
11
|
+
instanceId: object.instanceId,
|
|
12
|
+
catalogId: object.catalogId,
|
|
13
|
+
position: [object.position[0], object.position[1], object.position[2]],
|
|
14
|
+
rotationY: object.rotationY,
|
|
15
|
+
...(object.parentSpace !== undefined ? { parentSpace: object.parentSpace } : {}),
|
|
16
|
+
...(object.state !== undefined ? { flags: object.state } : {}),
|
|
17
|
+
...(object.slots !== undefined ? { slots: object.slots } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Inverse of {@link toRuntimeObjectRow}: rebuild the live placed object a host persisted.
|
|
22
|
+
*
|
|
23
|
+
* @capability placed-object-rehydrate rebuild live placed objects, with their state and slot contents, from persisted snapshot rows
|
|
24
|
+
*/
|
|
25
|
+
export function fromRuntimeObjectRow(row) {
|
|
26
|
+
return {
|
|
27
|
+
instanceId: row.instanceId,
|
|
28
|
+
catalogId: row.catalogId,
|
|
29
|
+
position: [row.position[0], row.position[1], row.position[2]],
|
|
30
|
+
rotationY: row.rotationY ?? 0,
|
|
31
|
+
...(row.parentSpace !== undefined ? { parentSpace: row.parentSpace } : {}),
|
|
32
|
+
...(row.flags !== undefined ? { state: row.flags } : {}),
|
|
33
|
+
...(row.slots !== undefined ? { slots: row.slots } : {}),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { InventoryState } from "../inventory/inventoryModel.js";
|
|
1
2
|
export declare const RUNTIME_SNAPSHOT_VERSION = 1;
|
|
2
3
|
export type RuntimeEntityRow = {
|
|
3
4
|
instanceId: string;
|
|
@@ -14,13 +15,20 @@ export type RuntimeEntityRow = {
|
|
|
14
15
|
targetInstanceId?: string | null;
|
|
15
16
|
userId?: string;
|
|
16
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* The persisted form of a placed `SceneObject`: identity, placement, per-instance state, and slot
|
|
20
|
+
* contents. Convert with `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`runtime/objectRows`).
|
|
21
|
+
*/
|
|
17
22
|
export type RuntimeObjectRow = {
|
|
18
23
|
instanceId: string;
|
|
19
24
|
catalogId: string;
|
|
20
25
|
position: [number, number, number];
|
|
21
26
|
rotationY?: number;
|
|
22
27
|
parentSpace?: string;
|
|
28
|
+
/** Opaque per-instance state; the persisted name for `SceneObject.state`. */
|
|
23
29
|
flags?: Record<string, unknown>;
|
|
30
|
+
/** Container contents for a catalog `slotInventory` object. */
|
|
31
|
+
slots?: InventoryState;
|
|
24
32
|
};
|
|
25
33
|
export type RuntimeInventorySlot = {
|
|
26
34
|
item: string;
|
|
@@ -93,3 +101,10 @@ export declare function splitProfilePlayer(player: RuntimePlayerRow): {
|
|
|
93
101
|
persistent: RuntimePlayerRow;
|
|
94
102
|
session: Record<string, unknown>;
|
|
95
103
|
};
|
|
104
|
+
/**
|
|
105
|
+
* Mark every player plus the server row dirty without bumping `revision` — the "write everything now"
|
|
106
|
+
* preparation a flush needs, because hydrating a snapshot from storage resets the dirty set to empty.
|
|
107
|
+
* Revision stays put so a flush cannot look like a mutation to the revision CAS.
|
|
108
|
+
* @internal
|
|
109
|
+
*/
|
|
110
|
+
export declare function markAllPlayersDirty(snapshot: GameRuntimeSnapshot): GameRuntimeSnapshot;
|
package/dist/runtime/snapshot.js
CHANGED
|
@@ -80,3 +80,15 @@ export function splitProfilePlayer(player) {
|
|
|
80
80
|
session,
|
|
81
81
|
};
|
|
82
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Mark every player plus the server row dirty without bumping `revision` — the "write everything now"
|
|
85
|
+
* preparation a flush needs, because hydrating a snapshot from storage resets the dirty set to empty.
|
|
86
|
+
* Revision stays put so a flush cannot look like a mutation to the revision CAS.
|
|
87
|
+
* @internal
|
|
88
|
+
*/
|
|
89
|
+
export function markAllPlayersDirty(snapshot) {
|
|
90
|
+
return {
|
|
91
|
+
...snapshot,
|
|
92
|
+
dirty: { ...snapshot.dirty, server: true, players: Object.keys(snapshot.players) },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -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) {
|
|
@@ -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
|
+
}
|