@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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { perContext } from "../runtime/perContext.js";
|
|
2
|
+
import { resolveColliders } from "../scene/colliders.js";
|
|
3
|
+
import { DEFAULT_OBSTACLE_PLAYER_RADIUS, resolveObstacleStep, } from "./movementModel.js";
|
|
4
|
+
/**
|
|
5
|
+
* Broadphase reach floor (XZ): the `inBox` query indexes objects by their center *point*, so the query
|
|
6
|
+
* must extend past the walker by at least the largest blocking-collider extent or a wide object's edge
|
|
7
|
+
* is missed. Floored so default unit boxes stay covered.
|
|
8
|
+
*/
|
|
9
|
+
const OBSTACLE_MAX_HALF_EXTENT = 4;
|
|
10
|
+
/**
|
|
11
|
+
* Hard cap on XZ broadphase reach. City lots ~12-17 m footprint need ~17-20 m after yaw expansion;
|
|
12
|
+
* beyond that we refuse to grow the query into a city-wide box (and rely on the outer AABB of each
|
|
13
|
+
* solid still blocking once its *center* is in range).
|
|
14
|
+
*/
|
|
15
|
+
const OBSTACLE_HORIZONTAL_CAP = 22;
|
|
16
|
+
/**
|
|
17
|
+
* Vertical broadphase floor/cap. A walker only needs nearby ground props / step-ups — never full tower
|
|
18
|
+
* height. Using building height for Y was the Vice Isle pose killer: a 50 m tower turned `inBox` into
|
|
19
|
+
* a 100^3 empty cell walk every frame.
|
|
20
|
+
*/
|
|
21
|
+
const OBSTACLE_VERTICAL_FLOOR = 3;
|
|
22
|
+
const OBSTACLE_VERTICAL_CAP = 8;
|
|
23
|
+
/**
|
|
24
|
+
* Mesh compound `boxes` under this count are treated as intentional openings (archways). Above it we
|
|
25
|
+
* walk against the outer fitted AABB only — solid city buildings often ship dense box soups that
|
|
26
|
+
* multiply narrowphase cost without changing the blocked footprint.
|
|
27
|
+
*/
|
|
28
|
+
const MOVEMENT_MESH_BOX_BUDGET = 12;
|
|
29
|
+
/** Feet-to-head span a walker is tested over — matches the player's, so both agree on what a wall is. */
|
|
30
|
+
const WALKER_HEIGHT = 1.8;
|
|
31
|
+
/** A fresh {@link ObstacleReachCache} for a caller that owns its own source. @internal */
|
|
32
|
+
export function createObstacleReachCache() {
|
|
33
|
+
return { count: -1, value: { horizontal: OBSTACLE_MAX_HALF_EXTENT, vertical: OBSTACLE_VERTICAL_FLOOR } };
|
|
34
|
+
}
|
|
35
|
+
const reachCache = perContext(() => createObstacleReachCache());
|
|
36
|
+
/**
|
|
37
|
+
* Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
|
|
38
|
+
* ctx and recomputed when the object count changes — the broadphase box every consumer inflates by, so
|
|
39
|
+
* a wall wider than the query box can never be missed.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function solidObstacleReach(ctx) {
|
|
43
|
+
return sourceObstacleReach(ctx.scene.object, reachCache(ctx));
|
|
44
|
+
}
|
|
45
|
+
/** {@link solidObstacleReach} against a bare source and caller-owned cache. @internal */
|
|
46
|
+
export function sourceObstacleReach(source, cache) {
|
|
47
|
+
const objects = source.list();
|
|
48
|
+
if (cache.count === objects.length)
|
|
49
|
+
return cache.value;
|
|
50
|
+
let horizontal = OBSTACLE_MAX_HALF_EXTENT;
|
|
51
|
+
let vertical = OBSTACLE_VERTICAL_FLOOR;
|
|
52
|
+
for (const object of objects) {
|
|
53
|
+
const set = source.collidersOf(object.instanceId);
|
|
54
|
+
if (set === null)
|
|
55
|
+
continue;
|
|
56
|
+
for (const collider of resolveColliders(set)) {
|
|
57
|
+
if (!collider.blocks || collider.purpose !== "physical")
|
|
58
|
+
continue;
|
|
59
|
+
const shape = collider.shape;
|
|
60
|
+
const offset = shape.offset;
|
|
61
|
+
const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
|
|
62
|
+
const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
|
|
63
|
+
const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
|
|
64
|
+
if (shape.kind === "sphere") {
|
|
65
|
+
horizontal = Math.max(horizontal, ox + oz + shape.radius);
|
|
66
|
+
vertical = Math.max(vertical, oy + shape.radius);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Horizontal bound sums both axes so any yaw of the box stays covered.
|
|
70
|
+
const h = shape.halfExtents;
|
|
71
|
+
horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
|
|
72
|
+
vertical = Math.max(vertical, oy + h[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const value = {
|
|
77
|
+
horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
|
|
78
|
+
vertical: Math.min(vertical, OBSTACLE_VERTICAL_CAP),
|
|
79
|
+
};
|
|
80
|
+
cache.count = objects.length;
|
|
81
|
+
cache.value = value;
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
/** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
|
|
85
|
+
* as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
|
|
86
|
+
function yawExpandLocalBox(boxMin, boxMax, cos, sin) {
|
|
87
|
+
const centerX = (boxMin[0] + boxMax[0]) / 2;
|
|
88
|
+
const centerY = (boxMin[1] + boxMax[1]) / 2;
|
|
89
|
+
const centerZ = (boxMin[2] + boxMax[2]) / 2;
|
|
90
|
+
const halfX = (boxMax[0] - boxMin[0]) / 2;
|
|
91
|
+
const halfY = (boxMax[1] - boxMin[1]) / 2;
|
|
92
|
+
const halfZ = (boxMax[2] - boxMin[2]) / 2;
|
|
93
|
+
const rcX = centerX * cos + centerZ * sin;
|
|
94
|
+
const rcZ = -centerX * sin + centerZ * cos;
|
|
95
|
+
const rhX = Math.abs(halfX * cos) + Math.abs(halfZ * sin);
|
|
96
|
+
const rhZ = Math.abs(halfX * sin) + Math.abs(halfZ * cos);
|
|
97
|
+
return {
|
|
98
|
+
min: [rcX - rhX, centerY - halfY, rcZ - rhZ],
|
|
99
|
+
max: [rcX + rhX, centerY + halfY, rcZ + rhZ],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
|
|
103
|
+
* carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
|
|
104
|
+
* conservatively expanding each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
|
|
105
|
+
* never under-covers.
|
|
106
|
+
* @internal */
|
|
107
|
+
export function obstacleFromCollider(collider, position, rotationY) {
|
|
108
|
+
const cos = Math.cos(rotationY);
|
|
109
|
+
const sin = Math.sin(rotationY);
|
|
110
|
+
const shape = collider.shape;
|
|
111
|
+
// Dense compound soups (city buildings) collapse to the outer fitted AABB for movement — openings
|
|
112
|
+
// only stay mesh-box accurate when the box count is small (arches, doorways).
|
|
113
|
+
if (shape.kind === "mesh" &&
|
|
114
|
+
shape.boxes !== undefined &&
|
|
115
|
+
shape.boxes.length > 0 &&
|
|
116
|
+
shape.boxes.length <= MOVEMENT_MESH_BOX_BUDGET) {
|
|
117
|
+
return {
|
|
118
|
+
position,
|
|
119
|
+
boxes: shape.boxes.map((b) => yawExpandLocalBox(b.min, b.max, cos, sin)),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
let hx;
|
|
123
|
+
let hy;
|
|
124
|
+
let hz;
|
|
125
|
+
if (shape.kind === "sphere") {
|
|
126
|
+
hx = hy = hz = shape.radius;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
hx = shape.halfExtents[0];
|
|
130
|
+
hy = shape.halfExtents[1];
|
|
131
|
+
hz = shape.halfExtents[2];
|
|
132
|
+
}
|
|
133
|
+
const offset = shape.offset;
|
|
134
|
+
const ox = offset !== undefined ? offset[0] : 0;
|
|
135
|
+
const oy = offset !== undefined ? offset[1] : 0;
|
|
136
|
+
const oz = offset !== undefined ? offset[2] : 0;
|
|
137
|
+
return {
|
|
138
|
+
position,
|
|
139
|
+
// Rotate the offset by yaw (mirror of colliders.worldOffset), expand the extents to the rotated AABB.
|
|
140
|
+
offset: [ox * cos + oz * sin, oy, -ox * sin + oz * cos],
|
|
141
|
+
halfExtents: [Math.abs(hx * cos) + Math.abs(hz * sin), hy, Math.abs(hx * sin) + Math.abs(hz * cos)],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the
|
|
146
|
+
* scene's resolved colliders.
|
|
147
|
+
*
|
|
148
|
+
* This is the engine's single answer to "what is solid here". The player resolver, a walking NPC, and
|
|
149
|
+
* anything else that has to stop at a wall read it rather than deriving their own set — three
|
|
150
|
+
* divergent notions of solid (engine colliders / game catalog flags / nothing at all) is what let a
|
|
151
|
+
* pedestrian stroll through the building the player cannot enter.
|
|
152
|
+
*
|
|
153
|
+
* @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
|
|
154
|
+
*/
|
|
155
|
+
export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
|
|
156
|
+
return sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
|
|
157
|
+
}
|
|
158
|
+
/** {@link solidObstaclesNear} against a bare source and an already-resolved reach. @internal */
|
|
159
|
+
export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT) {
|
|
160
|
+
const min = [
|
|
161
|
+
position[0] - reachX - reach.horizontal,
|
|
162
|
+
position[1] - reach.vertical,
|
|
163
|
+
position[2] - reachZ - reach.horizontal,
|
|
164
|
+
];
|
|
165
|
+
const max = [
|
|
166
|
+
position[0] + reachX + reach.horizontal,
|
|
167
|
+
position[1] + height + reach.vertical,
|
|
168
|
+
position[2] + reachZ + reach.horizontal,
|
|
169
|
+
];
|
|
170
|
+
const obstacles = [];
|
|
171
|
+
for (const object of source.inBox(min, max)) {
|
|
172
|
+
const set = source.collidersOf(object.instanceId);
|
|
173
|
+
if (set === null) {
|
|
174
|
+
// No resolved collider: preserve the default 1×1×1 box.
|
|
175
|
+
obstacles.push({ position: object.position });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const collider of resolveColliders(set)) {
|
|
179
|
+
if (!collider.blocks || collider.purpose !== "physical")
|
|
180
|
+
continue;
|
|
181
|
+
obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return obstacles;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
|
|
188
|
+
* X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
|
|
189
|
+
* other, exactly as {@link stepPlayerMovement} resolves the player's.
|
|
190
|
+
*
|
|
191
|
+
* Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
|
|
192
|
+
* full-world scan, so it stays affordable per NPC per tick.
|
|
193
|
+
*
|
|
194
|
+
* @capability walker-solid-step slide an NPC's step against the geometry that blocks the player
|
|
195
|
+
*/
|
|
196
|
+
export function resolveWalkerStep(ctx, position, stepX, stepZ, options = {}) {
|
|
197
|
+
if (stepX === 0 && stepZ === 0)
|
|
198
|
+
return { stepX, stepZ };
|
|
199
|
+
const radius = options.radius ?? DEFAULT_OBSTACLE_PLAYER_RADIUS;
|
|
200
|
+
const obstacles = solidObstaclesNear(ctx, position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
|
|
201
|
+
return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
|
|
202
|
+
}
|
|
203
|
+
/** {@link resolveWalkerStep} against an already-gathered obstacle set. @internal */
|
|
204
|
+
export function slideStep(position, stepX, stepZ, obstacles, radius = DEFAULT_OBSTACLE_PLAYER_RADIUS, stepUpHeight = 0) {
|
|
205
|
+
if (obstacles.length === 0)
|
|
206
|
+
return { stepX, stepZ };
|
|
207
|
+
return resolveObstacleStep(position, stepX, stepZ, obstacles, radius, stepUpHeight);
|
|
208
|
+
}
|
package/dist/multiplayer.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export { createPoseSyncGate, type PlayerPose, type PoseSyncTuning } from "./mult
|
|
|
8
8
|
export { type EnsurePresenceResult, type PresenceActions, type PresenceFeeds, type PresenceSession, type PresenceTransport, } from "./multiplayer/presenceContract.js";
|
|
9
9
|
export { type PoseSyncRules, type PresencePoseState } from "./multiplayer/presenceModel.js";
|
|
10
10
|
export { createLocalVoiceTransport, createPushToTalk, type PushToTalkMode, type PushToTalkStatus, type VoiceParticipant, type VoiceRoute, type VoiceTransport, } from "./multiplayer/voiceContract.js";
|
|
11
|
+
export { fromRuntimeObjectRow, toRuntimeObjectRow } from "./runtime/objectRows.js";
|
|
11
12
|
export { type ReplicationPolicy } from "./runtime/worldProjection.js";
|
package/dist/multiplayer.js
CHANGED
|
@@ -8,4 +8,5 @@ export { createPoseSyncGate } from "./multiplayer/poseSyncGate.js";
|
|
|
8
8
|
export {} from "./multiplayer/presenceContract.js";
|
|
9
9
|
export {} from "./multiplayer/presenceModel.js";
|
|
10
10
|
export { createLocalVoiceTransport, createPushToTalk, } from "./multiplayer/voiceContract.js";
|
|
11
|
+
export { fromRuntimeObjectRow, toRuntimeObjectRow } from "./runtime/objectRows.js";
|
|
11
12
|
export {} from "./runtime/worldProjection.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type InventoryLayout, type InventorySet, type InventoryState, type ItemTraits } from "../../inventory/inventoryModel.js";
|
|
2
|
+
import type { ObjectStore } from "../../scene/objectStore.js";
|
|
3
|
+
/** Why an object slot mutation was refused — the instance, its catalog entry, the player inventory, or the declared `accepts`/capacity. */
|
|
4
|
+
export type ObjectSlotRejection = "unknown-object" | "no-slot-inventory" | "unknown-inventory" | "no-space" | "wrong-kind" | "slot-occupied" | "invalid-slot" | "insufficient";
|
|
5
|
+
/** Outcome of an object slot mutation: applied, or refused with a reason and no state change on either side. */
|
|
6
|
+
export type ObjectSlotResult = {
|
|
7
|
+
status: "ok";
|
|
8
|
+
} | {
|
|
9
|
+
status: "rejected";
|
|
10
|
+
reason: ObjectSlotRejection;
|
|
11
|
+
};
|
|
12
|
+
/** Which player inventory a transfer moves between, and what it moves. */
|
|
13
|
+
export interface ObjectSlotTransfer {
|
|
14
|
+
instanceId: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
inventoryId: string;
|
|
17
|
+
itemId: string;
|
|
18
|
+
/** Defaults to `1`. */
|
|
19
|
+
count?: number;
|
|
20
|
+
/** Explicit slot index **on the object**; omit to auto-place (transfer in) or drain the object's matching stacks (transfer out). */
|
|
21
|
+
slot?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Per-instance container contents for placed objects whose catalog entry declares `slotInventory` —
|
|
25
|
+
* the engine side of "install a GPU into a rack", "put an item in a chest". Contents live on
|
|
26
|
+
* `SceneObject.slots`, so they replicate and save with the object rather than needing a game-owned
|
|
27
|
+
* table keyed by `instanceId`, and every mutation is validated against the declared `accepts` and
|
|
28
|
+
* stack limits.
|
|
29
|
+
*
|
|
30
|
+
* @capability object-slot-inventory placed objects hold validated per-instance container contents that replicate and save with the placement
|
|
31
|
+
*/
|
|
32
|
+
export interface SceneObjectSlots {
|
|
33
|
+
/** The catalog `slotInventory` layout for a placed instance, or `null` when its catalog entry declares none. */
|
|
34
|
+
layout(instanceId: string): InventoryLayout | null;
|
|
35
|
+
/** Live contents, defaulting to the layout's empty slots. `null` when the instance is unplaced or has no `slotInventory`. */
|
|
36
|
+
state(instanceId: string): InventoryState | null;
|
|
37
|
+
/** How many of `itemId` this object holds; `0` for an unplaced or slot-less instance. */
|
|
38
|
+
count(instanceId: string, itemId: string): number;
|
|
39
|
+
/** Add items directly, bypassing any player inventory — world seeding, loot generation, admin tools. */
|
|
40
|
+
put(instanceId: string, itemId: string, count?: number, options?: {
|
|
41
|
+
slot?: number;
|
|
42
|
+
}): ObjectSlotResult;
|
|
43
|
+
/** Remove items directly, bypassing any player inventory. */
|
|
44
|
+
take(instanceId: string, itemId: string, count?: number): ObjectSlotResult;
|
|
45
|
+
/** Move items from a player inventory into the object's slots; nothing is consumed if the object rejects them. */
|
|
46
|
+
transferIn(transfer: ObjectSlotTransfer): ObjectSlotResult;
|
|
47
|
+
/** Move items from the object's slots into a player inventory; nothing leaves the object if the inventory rejects them. */
|
|
48
|
+
transferOut(transfer: ObjectSlotTransfer): ObjectSlotResult;
|
|
49
|
+
}
|
|
50
|
+
/** @internal */
|
|
51
|
+
export interface ObjectSlotsDeps {
|
|
52
|
+
objects: ObjectStore;
|
|
53
|
+
layoutOf: (instanceId: string) => InventoryLayout | null;
|
|
54
|
+
traits: () => ItemTraits;
|
|
55
|
+
hasInventory: (inventoryId: string) => boolean;
|
|
56
|
+
inventoryFor: (userId: string) => InventorySet<string> | null;
|
|
57
|
+
notify: () => void;
|
|
58
|
+
}
|
|
59
|
+
/** @internal */
|
|
60
|
+
export declare function createObjectSlots(d: ObjectSlotsDeps): SceneObjectSlots;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { countItem, createEmptyInventory, putItem, takeItem, } from "../../inventory/inventoryModel.js";
|
|
2
|
+
/** Drain `count` of `itemId` from one explicit slot, so "pull the GPU out of rack slot 3" is expressible. */
|
|
3
|
+
function takeFromSlot(state, slot, itemId, count) {
|
|
4
|
+
if (!Number.isInteger(slot) || slot < 0 || slot >= state.slots.length) {
|
|
5
|
+
return { status: "rejected", reason: "invalid-slot" };
|
|
6
|
+
}
|
|
7
|
+
const held = state.slots[slot];
|
|
8
|
+
if (held === null || held === undefined || held.itemId !== itemId || held.count < count) {
|
|
9
|
+
return { status: "rejected", reason: "insufficient" };
|
|
10
|
+
}
|
|
11
|
+
const slots = [...state.slots];
|
|
12
|
+
slots[slot] = held.count === count ? null : { itemId, count: held.count - count };
|
|
13
|
+
return { status: "ok", state: { slots } };
|
|
14
|
+
}
|
|
15
|
+
/** @internal */
|
|
16
|
+
export function createObjectSlots(d) {
|
|
17
|
+
function resolve(instanceId) {
|
|
18
|
+
const object = d.objects.get(instanceId);
|
|
19
|
+
if (object === null)
|
|
20
|
+
return { status: "rejected", reason: "unknown-object" };
|
|
21
|
+
const layout = d.layoutOf(instanceId);
|
|
22
|
+
if (layout === null)
|
|
23
|
+
return { status: "rejected", reason: "no-slot-inventory" };
|
|
24
|
+
return { layout, state: object.slots ?? createEmptyInventory(layout) };
|
|
25
|
+
}
|
|
26
|
+
function playerInventory(transfer) {
|
|
27
|
+
if (!d.hasInventory(transfer.inventoryId))
|
|
28
|
+
return null;
|
|
29
|
+
return d.inventoryFor(transfer.userId);
|
|
30
|
+
}
|
|
31
|
+
function commit(instanceId, state) {
|
|
32
|
+
d.objects.setSlots(instanceId, state);
|
|
33
|
+
d.notify();
|
|
34
|
+
return { status: "ok" };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
layout(instanceId) {
|
|
38
|
+
return d.objects.get(instanceId) === null ? null : d.layoutOf(instanceId);
|
|
39
|
+
},
|
|
40
|
+
state(instanceId) {
|
|
41
|
+
const resolved = resolve(instanceId);
|
|
42
|
+
return "status" in resolved ? null : resolved.state;
|
|
43
|
+
},
|
|
44
|
+
count(instanceId, itemId) {
|
|
45
|
+
const resolved = resolve(instanceId);
|
|
46
|
+
return "status" in resolved ? 0 : countItem(resolved.state, itemId);
|
|
47
|
+
},
|
|
48
|
+
put(instanceId, itemId, count = 1, options) {
|
|
49
|
+
const resolved = resolve(instanceId);
|
|
50
|
+
if ("status" in resolved)
|
|
51
|
+
return resolved;
|
|
52
|
+
const result = putItem(resolved.state, resolved.layout, d.traits(), itemId, count, options);
|
|
53
|
+
if (result.status === "rejected")
|
|
54
|
+
return result;
|
|
55
|
+
return commit(instanceId, result.state);
|
|
56
|
+
},
|
|
57
|
+
take(instanceId, itemId, count = 1) {
|
|
58
|
+
const resolved = resolve(instanceId);
|
|
59
|
+
if ("status" in resolved)
|
|
60
|
+
return resolved;
|
|
61
|
+
const result = takeItem(resolved.state, itemId, count);
|
|
62
|
+
if (result.status === "rejected")
|
|
63
|
+
return result;
|
|
64
|
+
return commit(instanceId, result.state);
|
|
65
|
+
},
|
|
66
|
+
transferIn(transfer) {
|
|
67
|
+
const count = transfer.count ?? 1;
|
|
68
|
+
const resolved = resolve(transfer.instanceId);
|
|
69
|
+
if ("status" in resolved)
|
|
70
|
+
return resolved;
|
|
71
|
+
const inventory = playerInventory(transfer);
|
|
72
|
+
if (inventory === null)
|
|
73
|
+
return { status: "rejected", reason: "unknown-inventory" };
|
|
74
|
+
const options = transfer.slot === undefined ? undefined : { slot: transfer.slot };
|
|
75
|
+
const accepted = putItem(resolved.state, resolved.layout, d.traits(), transfer.itemId, count, options);
|
|
76
|
+
if (accepted.status === "rejected")
|
|
77
|
+
return accepted;
|
|
78
|
+
const removed = inventory.take(transfer.inventoryId, transfer.itemId, count);
|
|
79
|
+
if (removed.status === "rejected")
|
|
80
|
+
return removed;
|
|
81
|
+
return commit(transfer.instanceId, accepted.state);
|
|
82
|
+
},
|
|
83
|
+
transferOut(transfer) {
|
|
84
|
+
const count = transfer.count ?? 1;
|
|
85
|
+
const resolved = resolve(transfer.instanceId);
|
|
86
|
+
if ("status" in resolved)
|
|
87
|
+
return resolved;
|
|
88
|
+
const inventory = playerInventory(transfer);
|
|
89
|
+
if (inventory === null)
|
|
90
|
+
return { status: "rejected", reason: "unknown-inventory" };
|
|
91
|
+
const drained = transfer.slot === undefined
|
|
92
|
+
? takeItem(resolved.state, transfer.itemId, count)
|
|
93
|
+
: takeFromSlot(resolved.state, transfer.slot, transfer.itemId, count);
|
|
94
|
+
if (drained.status === "rejected" || drained.state === undefined) {
|
|
95
|
+
return drained.status === "rejected" ? drained : { status: "rejected", reason: "insufficient" };
|
|
96
|
+
}
|
|
97
|
+
const given = inventory.put(transfer.inventoryId, transfer.itemId, count);
|
|
98
|
+
if (given.status === "rejected")
|
|
99
|
+
return given;
|
|
100
|
+
return commit(transfer.instanceId, drained.state);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -3,7 +3,7 @@ import type { GameFeatures } from "../../game/defineGame.js";
|
|
|
3
3
|
import type { InventoryDeclaration } from "../../game/defineGame.js";
|
|
4
4
|
import type { GameEvents } from "../../game/events.js";
|
|
5
5
|
import { type Loadouts } from "../../game/loadout.js";
|
|
6
|
-
import { type InventoryLayout, type InventorySet } from "../../inventory/inventoryModel.js";
|
|
6
|
+
import { type InventoryLayout, type InventorySet, type ItemTraits } from "../../inventory/inventoryModel.js";
|
|
7
7
|
import { type PoseState } from "../../movement/poseState.js";
|
|
8
8
|
import { type Possession } from "../../scene/possession.js";
|
|
9
9
|
import type { EntityStore } from "../../scene/entityStore.js";
|
|
@@ -28,6 +28,8 @@ export interface PlayerSubsystem {
|
|
|
28
28
|
inventoryByUser: ReadonlyMap<string, InventorySet<string>>;
|
|
29
29
|
inventoryFor: (userId: string) => InventorySet<string>;
|
|
30
30
|
layouts: Record<string, InventoryLayout>;
|
|
31
|
+
/** Shared stack-limit/kind table for every declared inventory — also what object slot inventories validate against. */
|
|
32
|
+
itemTraits: ItemTraits;
|
|
31
33
|
wallets: Map<string, WalletState>;
|
|
32
34
|
walletOf: (userId: string) => WalletState;
|
|
33
35
|
economy: GameContextEconomy;
|
|
@@ -7,6 +7,7 @@ import { type Forms } from "../../scene/form.js";
|
|
|
7
7
|
import { type EntityColliderSet, type MeasuredBounds } from "../../scene/colliders.js";
|
|
8
8
|
import { type CollisionMeshSource } from "../../scene/collisionMesh.js";
|
|
9
9
|
import { type ObjectStore } from "../../scene/objectStore.js";
|
|
10
|
+
import type { InventorySet, ItemTraits } from "../../inventory/inventoryModel.js";
|
|
10
11
|
import { type PaintLayer } from "../../scene/paintLayer.js";
|
|
11
12
|
import { type SceneRaycastApi } from "../../scene/sceneRaycast.js";
|
|
12
13
|
import { type SpatialApi } from "../../scene/spatial.js";
|
|
@@ -57,6 +58,14 @@ export interface SceneSubsystem {
|
|
|
57
58
|
setOnAfterSpawn: (fn: (instanceId: string) => void) => void;
|
|
58
59
|
/** Install post-despawn side effects (pose.clear) after player is constructed. */
|
|
59
60
|
setOnAfterDespawn: (fn: (instanceId: string) => void) => void;
|
|
61
|
+
/** Hand the player subsystem's inventories to `sceneObjects.slots`, which is built before they exist. */
|
|
62
|
+
setInventoryAccess: (access: ObjectSlotInventoryAccess) => void;
|
|
63
|
+
}
|
|
64
|
+
/** @internal What `sceneObjects.slots` needs from the player subsystem to move items between a player and an object. */
|
|
65
|
+
export interface ObjectSlotInventoryAccess {
|
|
66
|
+
traits: ItemTraits;
|
|
67
|
+
hasInventory: (inventoryId: string) => boolean;
|
|
68
|
+
inventoryFor: (userId: string) => InventorySet<string>;
|
|
60
69
|
}
|
|
61
70
|
/** @internal */
|
|
62
71
|
export declare function createSceneSubsystem(d: SceneSubsystemDeps): SceneSubsystem;
|
|
@@ -6,10 +6,13 @@ 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";
|
|
12
13
|
import { createSpatialApi } from "../../scene/spatial.js";
|
|
14
|
+
import { DEFAULT_OBSTACLE_PLAYER_RADIUS } from "../../movement/movementModel.js";
|
|
15
|
+
import { createObstacleReachCache, slideStep, sourceObstacleReach, sourceObstaclesNear, } from "../../movement/solidObstacles.js";
|
|
13
16
|
import { createTargeting } from "../../scene/targeting.js";
|
|
14
17
|
import { notifyAfter } from "../../store/changeSignal.js";
|
|
15
18
|
/** @internal */
|
|
@@ -49,11 +52,20 @@ export function createSceneSubsystem(d) {
|
|
|
49
52
|
candidateIdsDirty = false;
|
|
50
53
|
return candidateIds;
|
|
51
54
|
}
|
|
55
|
+
// `moveToward` reads the same blocking colliders the player resolver does, so a chaser stops at the
|
|
56
|
+
// wall that stops the player rather than lerping through it.
|
|
57
|
+
const walkerReach = createObstacleReachCache();
|
|
58
|
+
const solidSource = {
|
|
59
|
+
list: () => objects.list(),
|
|
60
|
+
inBox: (min, max) => objects.inBox(min, max),
|
|
61
|
+
collidersOf: (instanceId) => objectCollidersOf(instanceId),
|
|
62
|
+
};
|
|
52
63
|
const spatial = createSpatialApi({
|
|
53
64
|
resolvePosition: (instanceId) => entities.get(instanceId)?.position,
|
|
54
65
|
candidates: refreshCandidateIds,
|
|
55
66
|
grid: { cellSize: 8 },
|
|
56
67
|
getVersion: () => spatialGeneration,
|
|
68
|
+
resolveStep: (position, stepX, stepZ) => slideStep(position, stepX, stepZ, sourceObstaclesNear(solidSource, sourceObstacleReach(solidSource, walkerReach), position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS)),
|
|
57
69
|
...(occluder !== undefined ? { occluder } : {}),
|
|
58
70
|
});
|
|
59
71
|
entities.subscribe(() => {
|
|
@@ -316,6 +328,15 @@ export function createSceneSubsystem(d) {
|
|
|
316
328
|
return created;
|
|
317
329
|
}
|
|
318
330
|
const objectSelection = notifyAfter(createSelectionSet(), ["add", "remove", "toggle", "replace", "clear"], signalNotify);
|
|
331
|
+
let inventoryAccess = null;
|
|
332
|
+
const objectSlots = createObjectSlots({
|
|
333
|
+
objects,
|
|
334
|
+
layoutOf: (instanceId) => catalogObject(instanceId)?.slotInventory ?? null,
|
|
335
|
+
traits: () => inventoryAccess?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY },
|
|
336
|
+
hasInventory: (inventoryId) => inventoryAccess?.hasInventory(inventoryId) ?? false,
|
|
337
|
+
inventoryFor: (userId) => inventoryAccess?.inventoryFor(userId) ?? null,
|
|
338
|
+
notify: signalNotify,
|
|
339
|
+
});
|
|
319
340
|
const sceneObjects = {
|
|
320
341
|
...objects,
|
|
321
342
|
remove(instanceId) {
|
|
@@ -338,6 +359,7 @@ export function createSceneSubsystem(d) {
|
|
|
338
359
|
reportBounds: reportObjectBounds,
|
|
339
360
|
reportCollisionMesh: reportObjectCollisionMesh,
|
|
340
361
|
selection: objectSelection,
|
|
362
|
+
slots: objectSlots,
|
|
341
363
|
};
|
|
342
364
|
return {
|
|
343
365
|
entities,
|
|
@@ -372,5 +394,8 @@ export function createSceneSubsystem(d) {
|
|
|
372
394
|
setOnAfterDespawn(fn) {
|
|
373
395
|
onAfterDespawn = fn;
|
|
374
396
|
},
|
|
397
|
+
setInventoryAccess(access) {
|
|
398
|
+
inventoryAccess = access;
|
|
399
|
+
},
|
|
375
400
|
};
|
|
376
401
|
}
|
|
@@ -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
|
];
|
|
@@ -14,7 +14,7 @@ import {} from "../scene/entityStore.js";
|
|
|
14
14
|
import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
|
|
15
15
|
import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
|
|
16
16
|
import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
|
|
17
|
-
import { policyProjectsViewers, projectByVisibleIds, projectEntitiesForViewer, projectPerUserForViewer, visibleEntityIds, } from "./worldProjection.js";
|
|
17
|
+
import { policyProjectsViewers, projectByVisibleIds, projectEntitiesForViewer, projectObjectsForViewer, projectPerUserForViewer, viewerOrigin, visibleEntityIds, } from "./worldProjection.js";
|
|
18
18
|
import { createRuntimeSave } from "./runtimeSave.js";
|
|
19
19
|
import { isOffline } from "./adapter.js";
|
|
20
20
|
import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
|
|
@@ -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,
|
|
@@ -293,6 +299,9 @@ export function createGameContext(options) {
|
|
|
293
299
|
if (aoiRadius !== undefined && key === "stats") {
|
|
294
300
|
return typedProject((data, viewer, world) => projectByVisibleIds(data, visibleEntityIds((world["entities"] ?? []), viewer, aoiRadius)));
|
|
295
301
|
}
|
|
302
|
+
if (aoiRadius !== undefined && key === "objects") {
|
|
303
|
+
return typedProject((data, viewer, world) => projectObjectsForViewer(data, viewerOrigin(world, viewer), aoiRadius));
|
|
304
|
+
}
|
|
296
305
|
if (replication?.privatePerUser === true && key === "inventory") {
|
|
297
306
|
return typedProject((data, viewer) => projectPerUserForViewer(data, viewer));
|
|
298
307
|
}
|
|
@@ -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;
|
|
@@ -37,6 +37,12 @@ export type HydrateInput = {
|
|
|
37
37
|
export type GameRuntime = {
|
|
38
38
|
gameId: string;
|
|
39
39
|
save: SaveConfig;
|
|
40
|
+
/**
|
|
41
|
+
* Whether this runtime declares `loop.onTick`. A host's tick cron reads it to skip hydrating and
|
|
42
|
+
* persisting a server whose `tick` is a no-op by construction — the difference between a world that
|
|
43
|
+
* costs a full read/write every second while idle and one that costs nothing.
|
|
44
|
+
*/
|
|
45
|
+
hasTick: boolean;
|
|
40
46
|
hydrate: (input: HydrateInput) => GameRuntimeSnapshot;
|
|
41
47
|
runCommand: (snapshot: GameRuntimeSnapshot, actorUserId: string, commandName: string, input: unknown) => ReturnType<typeof runCommand>;
|
|
42
48
|
tick: (snapshot: GameRuntimeSnapshot, dtSeconds: number) => GameRuntimeSnapshot;
|