@jgengine/core 0.16.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 +30 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +27 -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/runtime/context/scene.js +11 -0
- package/dist/runtime/gameContext.js +4 -1
- 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 +59 -7
- package/dist/runtime/hostPolicy.d.ts +54 -0
- package/dist/runtime/hostPolicy.js +53 -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/spatial.d.ts +16 -0
- package/dist/scene/spatial.js +7 -4
- package/package.json +1 -1
- package/skills/jgengine/api.md +25 -4
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-world/api.md +6 -0
- package/skills/jgengine-world/capabilities.md +8 -0
|
@@ -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;
|
|
@@ -151,14 +151,39 @@ export type HostPersistence = {
|
|
|
151
151
|
userId: string;
|
|
152
152
|
}) => Promise<Record<string, number>>;
|
|
153
153
|
};
|
|
154
|
-
/**
|
|
155
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Hydrate the runtime's player map from the server row plus whatever profiles were loaded.
|
|
156
|
+
*
|
|
157
|
+
* `userIds` narrows the work to a subset of the roster (partial hydration); omit it for the whole
|
|
158
|
+
* roster. A member with a loaded profile takes it as authoritative and keeps its session blob from
|
|
159
|
+
* the server row.
|
|
160
|
+
*
|
|
161
|
+
* A member with no profile row resolves by save scope, not by falling back to the blob: when the game
|
|
162
|
+
* persists players, a missing profile means the profile was deleted, so the member hydrates empty and
|
|
163
|
+
* the deletion sticks. Only a game that never writes profiles (`save: "none"`, or a scope without
|
|
164
|
+
* `player`) treats the server row's session copy as the player's state.
|
|
165
|
+
* @internal
|
|
166
|
+
*/
|
|
167
|
+
export declare function buildHydratePlayers(server: GameServerRecord, profiles: Record<string, PlayerProfileRecord | null>, userIds?: readonly string[]): Record<string, RuntimePlayerRow>;
|
|
168
|
+
/** True when a snapshot carries no unwritten mutation — nothing for a persist to do. */
|
|
169
|
+
export declare function isSnapshotClean(snapshot: GameRuntimeSnapshot): boolean;
|
|
156
170
|
export type ServerPersistPlan = {
|
|
157
171
|
server: GameServerRecord;
|
|
158
172
|
profiles: PlayerProfileRecord[];
|
|
159
173
|
chunks: WorldChunkRecord[];
|
|
160
174
|
deletedChunks: string[];
|
|
161
175
|
leaderboard: LeaderboardIncrement[];
|
|
176
|
+
/**
|
|
177
|
+
* Which fields of the server row the plan actually moved. A store that pays per written field —
|
|
178
|
+
* or whose subscribers re-push a whole document on any change — writes only what changed and skips
|
|
179
|
+
* the row entirely when `changed.any` is false.
|
|
180
|
+
*/
|
|
181
|
+
changed: {
|
|
182
|
+
serverState: boolean;
|
|
183
|
+
sessionPlayers: boolean;
|
|
184
|
+
revision: boolean;
|
|
185
|
+
any: boolean;
|
|
186
|
+
};
|
|
162
187
|
};
|
|
163
188
|
/** @internal */
|
|
164
189
|
export declare function planServerPersist(server: GameServerRecord, snapshot: GameRuntimeSnapshot, save: SaveConfig, now: number): ServerPersistPlan;
|
|
@@ -119,27 +119,55 @@ export function toOpenServerListings(listings, limit = OPEN_SERVER_LISTING_LIMIT
|
|
|
119
119
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
120
120
|
.slice(0, clampLimit(limit, OPEN_SERVER_LISTING_LIMIT, OPEN_SERVER_LISTING_MAX));
|
|
121
121
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Hydrate the runtime's player map from the server row plus whatever profiles were loaded.
|
|
124
|
+
*
|
|
125
|
+
* `userIds` narrows the work to a subset of the roster (partial hydration); omit it for the whole
|
|
126
|
+
* roster. A member with a loaded profile takes it as authoritative and keeps its session blob from
|
|
127
|
+
* the server row.
|
|
128
|
+
*
|
|
129
|
+
* A member with no profile row resolves by save scope, not by falling back to the blob: when the game
|
|
130
|
+
* persists players, a missing profile means the profile was deleted, so the member hydrates empty and
|
|
131
|
+
* the deletion sticks. Only a game that never writes profiles (`save: "none"`, or a scope without
|
|
132
|
+
* `player`) treats the server row's session copy as the player's state.
|
|
133
|
+
* @internal
|
|
134
|
+
*/
|
|
135
|
+
export function buildHydratePlayers(server, profiles, userIds) {
|
|
136
|
+
const persistsPlayers = isSaveEnabled(server.save) && saveScopeIncludesPlayer(server.save.scope);
|
|
124
137
|
const players = {};
|
|
125
|
-
for (const userId of server.memberUserIds) {
|
|
138
|
+
for (const userId of userIds ?? server.memberUserIds) {
|
|
126
139
|
const profile = profiles[userId] ?? null;
|
|
127
140
|
const sessionPlayer = server.sessionPlayers[userId];
|
|
141
|
+
const session = sessionPlayer?.session;
|
|
128
142
|
if (profile) {
|
|
129
143
|
players[userId] = {
|
|
130
144
|
...profile.playerState,
|
|
131
|
-
...(
|
|
145
|
+
...(session ? { session } : {}),
|
|
132
146
|
};
|
|
133
147
|
}
|
|
134
|
-
else if (sessionPlayer) {
|
|
148
|
+
else if (sessionPlayer && !persistsPlayers) {
|
|
135
149
|
players[userId] = sessionPlayer;
|
|
136
150
|
}
|
|
137
151
|
else {
|
|
138
|
-
players[userId] =
|
|
152
|
+
players[userId] = {
|
|
153
|
+
...createEmptyPlayerRow(userId),
|
|
154
|
+
...(session ? { session } : {}),
|
|
155
|
+
};
|
|
139
156
|
}
|
|
140
157
|
}
|
|
141
158
|
return players;
|
|
142
159
|
}
|
|
160
|
+
/** True when a snapshot carries no unwritten mutation — nothing for a persist to do. */
|
|
161
|
+
export function isSnapshotClean(snapshot) {
|
|
162
|
+
return (!snapshot.dirty.server &&
|
|
163
|
+
snapshot.dirty.players.length === 0 &&
|
|
164
|
+
snapshot.dirty.chunks.length === 0);
|
|
165
|
+
}
|
|
166
|
+
function sameJson(a, b) {
|
|
167
|
+
if (a === b)
|
|
168
|
+
return true;
|
|
169
|
+
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
|
|
170
|
+
}
|
|
143
171
|
/** @internal */
|
|
144
172
|
export function planServerPersist(server, snapshot, save, now) {
|
|
145
173
|
const drained = drainPendingLeaderboardIncrements(snapshot.server.session);
|
|
@@ -149,14 +177,22 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
149
177
|
: entry);
|
|
150
178
|
const canWritePlayers = isSaveEnabled(save) && saveScopeIncludesPlayer(save.scope);
|
|
151
179
|
const canWriteChunks = isSaveEnabled(save) && saveScopeIncludesChunks(save.scope);
|
|
180
|
+
// Merged over the stored map rather than rebuilt from `snapshot.players`, so a partially hydrated
|
|
181
|
+
// snapshot cannot drop the session state of members it never loaded.
|
|
152
182
|
const sessionPlayers = {};
|
|
183
|
+
const roster = new Set(server.memberUserIds);
|
|
184
|
+
for (const [userId, stored] of Object.entries(server.sessionPlayers ?? {})) {
|
|
185
|
+
if (roster.has(userId))
|
|
186
|
+
sessionPlayers[userId] = stored;
|
|
187
|
+
}
|
|
153
188
|
const profiles = [];
|
|
154
189
|
for (const userId of Object.keys(snapshot.players)) {
|
|
155
190
|
const player = snapshot.players[userId];
|
|
156
191
|
if (!player)
|
|
157
192
|
continue;
|
|
158
193
|
const { persistent, session } = splitProfilePlayer(player);
|
|
159
|
-
|
|
194
|
+
if (roster.has(userId))
|
|
195
|
+
sessionPlayers[userId] = { ...persistent, session };
|
|
160
196
|
if (canWritePlayers && snapshot.dirty.players.includes(userId)) {
|
|
161
197
|
profiles.push({
|
|
162
198
|
userId,
|
|
@@ -184,6 +220,9 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
184
220
|
// anything else would make shouldAutoSave re-fire forever after a successful full flush.
|
|
185
221
|
const unwritten = (snapshot.dirty.players.length > 0 && !canWritePlayers) ||
|
|
186
222
|
(snapshot.dirty.chunks.length > 0 && !canWriteChunks);
|
|
223
|
+
const serverStateChanged = !sameJson(serverState, server.serverState);
|
|
224
|
+
const sessionPlayersChanged = !sameJson(sessionPlayers, server.sessionPlayers);
|
|
225
|
+
const revisionChanged = snapshot.revision !== server.revision;
|
|
187
226
|
return {
|
|
188
227
|
server: {
|
|
189
228
|
...server,
|
|
@@ -198,5 +237,18 @@ export function planServerPersist(server, snapshot, save, now) {
|
|
|
198
237
|
chunks,
|
|
199
238
|
deletedChunks,
|
|
200
239
|
leaderboard,
|
|
240
|
+
changed: {
|
|
241
|
+
serverState: serverStateChanged,
|
|
242
|
+
sessionPlayers: sessionPlayersChanged,
|
|
243
|
+
revision: revisionChanged,
|
|
244
|
+
any: serverStateChanged ||
|
|
245
|
+
sessionPlayersChanged ||
|
|
246
|
+
revisionChanged ||
|
|
247
|
+
profiles.length > 0 ||
|
|
248
|
+
chunks.length > 0 ||
|
|
249
|
+
deletedChunks.length > 0 ||
|
|
250
|
+
leaderboard.length > 0 ||
|
|
251
|
+
(server.dirtyAt !== undefined) !== unwritten,
|
|
252
|
+
},
|
|
201
253
|
};
|
|
202
254
|
}
|
|
@@ -1,4 +1,58 @@
|
|
|
1
1
|
import type { GameServerStatus, SessionVisibility } from "./hostPersistence.js";
|
|
2
|
+
/**
|
|
3
|
+
* Hard ceiling on `slotsPerServer` for a single hosted server row.
|
|
4
|
+
*
|
|
5
|
+
* A hosted server keeps its whole roster, every member's session state, and the world row in one
|
|
6
|
+
* backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
|
|
7
|
+
* the host rewrites the session map on every persist — so member count is a per-transaction cost, not
|
|
8
|
+
* just a number in a config. 256 leaves room for a real world row and a real session blob per member
|
|
9
|
+
* inside those limits. A world that needs more concurrent players needs more than one server row.
|
|
10
|
+
*/
|
|
11
|
+
export declare const JG_MAX_MEMBERS_PER_SERVER = 256;
|
|
12
|
+
/** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
|
|
13
|
+
export declare function validateSlotsPerServer(slotsPerServer: number): {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
reason: string;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* How auto-match behaves when no `serverId` is supplied.
|
|
21
|
+
*
|
|
22
|
+
* - `"auto"` — fill open public rooms, create a new one when none has capacity (many parallel rooms).
|
|
23
|
+
* - `"singleton"` — one shared world per game: always match the existing room, and refuse the join
|
|
24
|
+
* when it is full rather than silently starting a second world the player would be alone in.
|
|
25
|
+
*/
|
|
26
|
+
export type MatchmakingMode = "auto" | "singleton";
|
|
27
|
+
/** A row `selectJoinTarget` can choose between — the matchmaking-relevant fields of a server record. */
|
|
28
|
+
export type JoinCandidate = {
|
|
29
|
+
memberUserIds: readonly string[];
|
|
30
|
+
slotsPerServer: number;
|
|
31
|
+
visibility?: SessionVisibility | undefined;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
};
|
|
34
|
+
/** What a join should do with the candidate rows it found. */
|
|
35
|
+
export type JoinTarget<T extends JoinCandidate> = {
|
|
36
|
+
kind: "join";
|
|
37
|
+
row: T;
|
|
38
|
+
} | {
|
|
39
|
+
kind: "create";
|
|
40
|
+
} | {
|
|
41
|
+
kind: "refuse";
|
|
42
|
+
reason: string;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}.
|
|
46
|
+
* Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is
|
|
47
|
+
* where a singleton world silently shards into per-player copies.
|
|
48
|
+
* @capability host-join-target choose or refuse an auto-matched server under a matchmaking mode
|
|
49
|
+
*/
|
|
50
|
+
export declare function selectJoinTarget<T extends JoinCandidate>(rows: readonly T[], args: {
|
|
51
|
+
userId: string;
|
|
52
|
+
mode?: MatchmakingMode;
|
|
53
|
+
/** Capacity to judge rows against — the host's current option, when it supersedes the stored value. */
|
|
54
|
+
slotsPerServer?: number;
|
|
55
|
+
}): JoinTarget<T>;
|
|
2
56
|
/**
|
|
3
57
|
* True when a server's `visibility` should surface in public listings / browse results.
|
|
4
58
|
* @capability host-listing-filter exclude private sessions from public browse results
|
|
@@ -1,4 +1,57 @@
|
|
|
1
1
|
import { normalizeJoinCode } from "../multiplayer/matchmaking.js";
|
|
2
|
+
/**
|
|
3
|
+
* Hard ceiling on `slotsPerServer` for a single hosted server row.
|
|
4
|
+
*
|
|
5
|
+
* A hosted server keeps its whole roster, every member's session state, and the world row in one
|
|
6
|
+
* backing document. On Convex that document caps at 1 MiB with at most 1024 entries in an object, and
|
|
7
|
+
* the host rewrites the session map on every persist — so member count is a per-transaction cost, not
|
|
8
|
+
* just a number in a config. 256 leaves room for a real world row and a real session blob per member
|
|
9
|
+
* inside those limits. A world that needs more concurrent players needs more than one server row.
|
|
10
|
+
*/
|
|
11
|
+
export const JG_MAX_MEMBERS_PER_SERVER = 256;
|
|
12
|
+
/** Whether a `slotsPerServer` value is a capacity a single hosted server row can actually hold. */
|
|
13
|
+
export function validateSlotsPerServer(slotsPerServer) {
|
|
14
|
+
if (!Number.isInteger(slotsPerServer) || slotsPerServer < 1) {
|
|
15
|
+
return { ok: false, reason: `slotsPerServer must be a positive integer, got ${slotsPerServer}` };
|
|
16
|
+
}
|
|
17
|
+
if (slotsPerServer > JG_MAX_MEMBERS_PER_SERVER) {
|
|
18
|
+
return {
|
|
19
|
+
ok: false,
|
|
20
|
+
reason: `slotsPerServer ${slotsPerServer} exceeds JG_MAX_MEMBERS_PER_SERVER (${JG_MAX_MEMBERS_PER_SERVER}). ` +
|
|
21
|
+
"One hosted server row holds its roster, every member's session state, and the world row in a " +
|
|
22
|
+
"single document; past this the host cannot guarantee the write fits. Shard across servers instead.",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { ok: true };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve an auto-match join against the game's joinable rows under a {@link MatchmakingMode}.
|
|
29
|
+
* Reach for it instead of hand-rolling the "find a room with space, else make one" scan, which is
|
|
30
|
+
* where a singleton world silently shards into per-player copies.
|
|
31
|
+
* @capability host-join-target choose or refuse an auto-matched server under a matchmaking mode
|
|
32
|
+
*/
|
|
33
|
+
export function selectJoinTarget(rows, args) {
|
|
34
|
+
const capacityOf = (row) => args.slotsPerServer ?? row.slotsPerServer;
|
|
35
|
+
const member = rows.find((row) => isServerMember(row.memberUserIds, args.userId));
|
|
36
|
+
if (member)
|
|
37
|
+
return { kind: "join", row: member };
|
|
38
|
+
if ((args.mode ?? "auto") === "singleton") {
|
|
39
|
+
const oldest = rows.reduce((best, row) => (best === null || row.createdAt < best.createdAt ? row : best), null);
|
|
40
|
+
if (oldest === null)
|
|
41
|
+
return { kind: "create" };
|
|
42
|
+
if (oldest.memberUserIds.length >= capacityOf(oldest)) {
|
|
43
|
+
return { kind: "refuse", reason: "Server is full" };
|
|
44
|
+
}
|
|
45
|
+
return { kind: "join", row: oldest };
|
|
46
|
+
}
|
|
47
|
+
const open = rows.find((row) => isAutoJoinCandidate({
|
|
48
|
+
memberUserIds: row.memberUserIds,
|
|
49
|
+
slotsPerServer: capacityOf(row),
|
|
50
|
+
visibility: row.visibility,
|
|
51
|
+
userId: args.userId,
|
|
52
|
+
}));
|
|
53
|
+
return open === undefined ? { kind: "create" } : { kind: "join", row: open };
|
|
54
|
+
}
|
|
2
55
|
/**
|
|
3
56
|
* True when a server's `visibility` should surface in public listings / browse results.
|
|
4
57
|
* @capability host-listing-filter exclude private sessions from public browse results
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { GameRuntimeSnapshot, RuntimeChunkRow } from "./snapshot.js";
|
|
2
|
+
/** Default edge length, in world units, of one persisted world chunk. */
|
|
3
|
+
export declare const DEFAULT_CHUNK_SIZE = 64;
|
|
4
|
+
/** Integer cell coordinates of a chunk on the ground plane. */
|
|
5
|
+
export type ChunkCoord = {
|
|
6
|
+
cx: number;
|
|
7
|
+
cz: number;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under.
|
|
11
|
+
* Games used to invent this mapping each time, which made two systems in one game disagree about
|
|
12
|
+
* which row a position belongs to.
|
|
13
|
+
* @capability world-chunk-key map world positions to persisted chunk keys at a declared cell size
|
|
14
|
+
*/
|
|
15
|
+
export declare function chunkKeyOf(coord: ChunkCoord): string;
|
|
16
|
+
/** Chunk coordinates containing a world position at `size` units per cell. */
|
|
17
|
+
export declare function chunkCoordAt(position: readonly [number, number, number], size?: number): ChunkCoord;
|
|
18
|
+
/** Chunk key containing a world position at `size` units per cell. */
|
|
19
|
+
export declare function chunkKeyAt(position: readonly [number, number, number], size?: number): string;
|
|
20
|
+
/** Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form. */
|
|
21
|
+
export declare function parseChunkKey(key: string): ChunkCoord | null;
|
|
22
|
+
/**
|
|
23
|
+
* Every chunk key whose cell intersects a radius around a world position — the key list a viewer's
|
|
24
|
+
* bounded hydration or client chunk query asks for, instead of loading a whole world.
|
|
25
|
+
*/
|
|
26
|
+
export declare function chunkKeysInRadius(center: readonly [number, number, number], radius: number, size?: number): string[];
|
|
27
|
+
/** An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet. */
|
|
28
|
+
export declare function createEmptyChunkRow(chunkKey: string): RuntimeChunkRow;
|
|
29
|
+
/**
|
|
30
|
+
* Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it.
|
|
31
|
+
* The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command
|
|
32
|
+
* that mutated the map by hand silently never saved that chunk.
|
|
33
|
+
* @capability world-chunk-write mutate a snapshot's chunk and have the host persist it
|
|
34
|
+
*/
|
|
35
|
+
export declare function setChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, chunk: RuntimeChunkRow): GameRuntimeSnapshot;
|
|
36
|
+
/** Read-modify-write a chunk (creating it empty when absent), marking it dirty. */
|
|
37
|
+
export declare function updateChunk(snapshot: GameRuntimeSnapshot, chunkKey: string, update: (chunk: RuntimeChunkRow) => RuntimeChunkRow): GameRuntimeSnapshot;
|
|
38
|
+
/** Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row. */
|
|
39
|
+
export declare function deleteChunk(snapshot: GameRuntimeSnapshot, chunkKey: string): GameRuntimeSnapshot;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** Default edge length, in world units, of one persisted world chunk. */
|
|
2
|
+
export const DEFAULT_CHUNK_SIZE = 64;
|
|
3
|
+
/**
|
|
4
|
+
* Chunk key for a cell — the canonical `"cx,cz"` string a `jgWorldChunks` row is stored under.
|
|
5
|
+
* Games used to invent this mapping each time, which made two systems in one game disagree about
|
|
6
|
+
* which row a position belongs to.
|
|
7
|
+
* @capability world-chunk-key map world positions to persisted chunk keys at a declared cell size
|
|
8
|
+
*/
|
|
9
|
+
export function chunkKeyOf(coord) {
|
|
10
|
+
return `${coord.cx},${coord.cz}`;
|
|
11
|
+
}
|
|
12
|
+
/** Chunk coordinates containing a world position at `size` units per cell. */
|
|
13
|
+
export function chunkCoordAt(position, size = DEFAULT_CHUNK_SIZE) {
|
|
14
|
+
return { cx: Math.floor(position[0] / size), cz: Math.floor(position[2] / size) };
|
|
15
|
+
}
|
|
16
|
+
/** Chunk key containing a world position at `size` units per cell. */
|
|
17
|
+
export function chunkKeyAt(position, size = DEFAULT_CHUNK_SIZE) {
|
|
18
|
+
return chunkKeyOf(chunkCoordAt(position, size));
|
|
19
|
+
}
|
|
20
|
+
/** Parse a `chunkKeyOf` string back to coordinates; `null` for anything not in that form. */
|
|
21
|
+
export function parseChunkKey(key) {
|
|
22
|
+
const [rawX, rawZ, ...rest] = key.split(",");
|
|
23
|
+
if (rest.length > 0 || rawX === undefined || rawZ === undefined)
|
|
24
|
+
return null;
|
|
25
|
+
const cx = Number(rawX);
|
|
26
|
+
const cz = Number(rawZ);
|
|
27
|
+
if (!Number.isInteger(cx) || !Number.isInteger(cz))
|
|
28
|
+
return null;
|
|
29
|
+
return { cx, cz };
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Every chunk key whose cell intersects a radius around a world position — the key list a viewer's
|
|
33
|
+
* bounded hydration or client chunk query asks for, instead of loading a whole world.
|
|
34
|
+
*/
|
|
35
|
+
export function chunkKeysInRadius(center, radius, size = DEFAULT_CHUNK_SIZE) {
|
|
36
|
+
const min = chunkCoordAt([center[0] - radius, center[1], center[2] - radius], size);
|
|
37
|
+
const max = chunkCoordAt([center[0] + radius, center[1], center[2] + radius], size);
|
|
38
|
+
const keys = [];
|
|
39
|
+
for (let cx = min.cx; cx <= max.cx; cx += 1) {
|
|
40
|
+
for (let cz = min.cz; cz <= max.cz; cz += 1)
|
|
41
|
+
keys.push(chunkKeyOf({ cx, cz }));
|
|
42
|
+
}
|
|
43
|
+
return keys;
|
|
44
|
+
}
|
|
45
|
+
/** An empty chunk row for `chunkKey` — the starting value for a cell nothing has been placed in yet. */
|
|
46
|
+
export function createEmptyChunkRow(chunkKey) {
|
|
47
|
+
return { chunkKey, objects: [], entities: [] };
|
|
48
|
+
}
|
|
49
|
+
function withChunkDirty(snapshot, chunkKey) {
|
|
50
|
+
return snapshot.dirty.chunks.includes(chunkKey)
|
|
51
|
+
? snapshot.dirty.chunks
|
|
52
|
+
: [...snapshot.dirty.chunks, chunkKey];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Write a chunk into a snapshot and mark it dirty, so the next persist actually stores it.
|
|
56
|
+
* The engine never writes `dirty.chunks` for a raw `snapshot.chunks[key] = …` assignment, so a command
|
|
57
|
+
* that mutated the map by hand silently never saved that chunk.
|
|
58
|
+
* @capability world-chunk-write mutate a snapshot's chunk and have the host persist it
|
|
59
|
+
*/
|
|
60
|
+
export function setChunk(snapshot, chunkKey, chunk) {
|
|
61
|
+
return {
|
|
62
|
+
...snapshot,
|
|
63
|
+
chunks: { ...snapshot.chunks, [chunkKey]: { ...chunk, chunkKey } },
|
|
64
|
+
revision: snapshot.revision + 1,
|
|
65
|
+
dirty: { ...snapshot.dirty, server: true, chunks: withChunkDirty(snapshot, chunkKey) },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Read-modify-write a chunk (creating it empty when absent), marking it dirty. */
|
|
69
|
+
export function updateChunk(snapshot, chunkKey, update) {
|
|
70
|
+
const current = snapshot.chunks[chunkKey] ?? createEmptyChunkRow(chunkKey);
|
|
71
|
+
return setChunk(snapshot, chunkKey, update(current));
|
|
72
|
+
}
|
|
73
|
+
/** Drop a chunk from a snapshot and mark the key dirty so the host deletes its stored row. */
|
|
74
|
+
export function deleteChunk(snapshot, chunkKey) {
|
|
75
|
+
const chunks = { ...snapshot.chunks };
|
|
76
|
+
delete chunks[chunkKey];
|
|
77
|
+
return {
|
|
78
|
+
...snapshot,
|
|
79
|
+
chunks,
|
|
80
|
+
revision: snapshot.revision + 1,
|
|
81
|
+
dirty: { ...snapshot.dirty, server: true, chunks: withChunkDirty(snapshot, chunkKey) },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { SceneEntity } from "../scene/entityStore.js";
|
|
2
|
-
import type { SnapshotViewer } from "./worldSnapshot.js";
|
|
1
|
+
import type { EntityPosition, SceneEntity } from "../scene/entityStore.js";
|
|
2
|
+
import type { SnapshotViewer, WorldSnapshot } from "./worldSnapshot.js";
|
|
3
3
|
/**
|
|
4
4
|
* Host-side interest/privacy policy — how the authoritative world projects to each viewer over the
|
|
5
5
|
* wire. Unset (the default) means every client receives the whole world, exactly as before. Enabling a
|
|
@@ -39,6 +39,20 @@ export declare function visibleEntityIds(entities: readonly SceneEntity[], viewe
|
|
|
39
39
|
* @internal host projection plumbing
|
|
40
40
|
*/
|
|
41
41
|
export declare function projectByVisibleIds<T>(byId: Record<string, T>, visible: Set<string>): Record<string, T>;
|
|
42
|
+
/** Where a viewer is standing, read off the replicated entity set; `undefined` when it has no entity yet.
|
|
43
|
+
* @internal host projection plumbing
|
|
44
|
+
*/
|
|
45
|
+
export declare function viewerOrigin(world: WorldSnapshot, viewer: SnapshotViewer): EntityPosition | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* Cull placed objects to a viewer's area of interest. Placed objects are the one large spatial set a
|
|
48
|
+
* world grows without bound — a datacenter of racks, a city of props — so without this every viewer
|
|
49
|
+
* downloads every placement. Fails open (full list) when the viewer has no locatable entity, matching
|
|
50
|
+
* {@link projectEntitiesForViewer}.
|
|
51
|
+
* @internal host projection plumbing
|
|
52
|
+
*/
|
|
53
|
+
export declare function projectObjectsForViewer<T extends {
|
|
54
|
+
position: EntityPosition;
|
|
55
|
+
}>(objects: readonly T[], origin: EntityPosition | undefined, radius: number): readonly T[];
|
|
42
56
|
/**
|
|
43
57
|
* Narrow a `userId → state` record to only the viewer's own entry — the projection for private
|
|
44
58
|
* per-user state (inventory, wallets) so one client never receives another player's private data.
|
|
@@ -41,6 +41,26 @@ export function projectByVisibleIds(byId, visible) {
|
|
|
41
41
|
out[id] = value;
|
|
42
42
|
return out;
|
|
43
43
|
}
|
|
44
|
+
/** Where a viewer is standing, read off the replicated entity set; `undefined` when it has no entity yet.
|
|
45
|
+
* @internal host projection plumbing
|
|
46
|
+
*/
|
|
47
|
+
export function viewerOrigin(world, viewer) {
|
|
48
|
+
const entities = (world["entities"] ?? []);
|
|
49
|
+
return entities.find((entity) => entity.id === viewer.userId)?.position;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Cull placed objects to a viewer's area of interest. Placed objects are the one large spatial set a
|
|
53
|
+
* world grows without bound — a datacenter of racks, a city of props — so without this every viewer
|
|
54
|
+
* downloads every placement. Fails open (full list) when the viewer has no locatable entity, matching
|
|
55
|
+
* {@link projectEntitiesForViewer}.
|
|
56
|
+
* @internal host projection plumbing
|
|
57
|
+
*/
|
|
58
|
+
export function projectObjectsForViewer(objects, origin, radius) {
|
|
59
|
+
if (origin === undefined)
|
|
60
|
+
return objects;
|
|
61
|
+
const radiusSquared = radius * radius;
|
|
62
|
+
return objects.filter((object) => distanceSquared(object.position, origin) <= radiusSquared);
|
|
63
|
+
}
|
|
44
64
|
/**
|
|
45
65
|
* Narrow a `userId → state` record to only the viewer's own entry — the projection for private
|
|
46
66
|
* per-user state (inventory, wallets) so one client never receives another player's private data.
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { seededRng } from "../random/rng.js";
|
|
2
2
|
import { advancePathFollow, createPathFollow, pathFollowSeek, } from "../nav/pathFollow.js";
|
|
3
|
+
import { resolveWalkerStep } from "../movement/solidObstacles.js";
|
|
3
4
|
import { perContext } from "../runtime/perContext.js";
|
|
4
5
|
const DEFAULT_WANDER_SPEED = 1.5;
|
|
5
6
|
const WANDER_ARRIVAL = 0.6;
|
|
7
|
+
/** Below this share of its intended step a wanderer counts as blocked and re-rolls its target. */
|
|
8
|
+
const WANDER_BLOCKED_FRACTION = 0.25;
|
|
6
9
|
function patrolOf(entity) {
|
|
7
10
|
return entity.behaviors.find((b) => b.kind === "patrol") ?? null;
|
|
8
11
|
}
|
|
@@ -72,18 +75,37 @@ function stepWander(ctx, id, nav, dt) {
|
|
|
72
75
|
if (dist < 1e-6)
|
|
73
76
|
return;
|
|
74
77
|
const step = Math.min(dist, speed * dt);
|
|
75
|
-
const
|
|
76
|
-
const
|
|
78
|
+
const desired = resolveWalkerStep(ctx, entity.position, (dx / dist) * step, (dz / dist) * step);
|
|
79
|
+
const nx = px + desired.stepX;
|
|
80
|
+
const nz = pz + desired.stepZ;
|
|
81
|
+
// A wanderer pinned against a solid picks a fresh target next tick instead of grinding into it.
|
|
82
|
+
if (Math.hypot(desired.stepX, desired.stepZ) < step * WANDER_BLOCKED_FRACTION)
|
|
83
|
+
nav.target = null;
|
|
77
84
|
ctx.scene.entity.setPose(id, {
|
|
78
85
|
position: [nx, ctx.world.groundHeightAt(nx, nz), nz],
|
|
79
86
|
rotationY: Math.atan2(dx, dz),
|
|
80
87
|
dt,
|
|
81
88
|
});
|
|
82
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Pose a patroller at its path point, with the *move* to that point slid against solids. The path state
|
|
92
|
+
* still advances on its own clock — the route is authored, and rewinding it on contact would stall the
|
|
93
|
+
* whole patrol behind one prop — but the entity can no longer end a tick inside geometry the player
|
|
94
|
+
* cannot walk through. A route drawn through a building shows up as an NPC pressed against its wall,
|
|
95
|
+
* which is the authoring bug made visible rather than hidden.
|
|
96
|
+
*/
|
|
83
97
|
function posePatrol(ctx, id, nav, dt) {
|
|
84
98
|
const [x, y, z] = nav.state.position;
|
|
99
|
+
const entity = ctx.scene.entity.get(id);
|
|
100
|
+
let px = x;
|
|
101
|
+
let pz = z;
|
|
102
|
+
if (entity !== null) {
|
|
103
|
+
const step = resolveWalkerStep(ctx, entity.position, x - entity.position[0], z - entity.position[2]);
|
|
104
|
+
px = entity.position[0] + step.stepX;
|
|
105
|
+
pz = entity.position[2] + step.stepZ;
|
|
106
|
+
}
|
|
85
107
|
ctx.scene.entity.setPose(id, {
|
|
86
|
-
position: nav.groundClamp ? [
|
|
108
|
+
position: nav.groundClamp ? [px, ctx.world.groundHeightAt(px, pz), pz] : [px, y, pz],
|
|
87
109
|
rotationY: nav.state.heading,
|
|
88
110
|
dt,
|
|
89
111
|
});
|
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
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "jgengine core — pure-TypeScript game framework SDK (npm @jgengine/core). Entity stores, commands, combat, inventory, multiplayer seams. Zero dependencies. Not an ECS. Agent skills ship in the tarball. https://jgengine.com",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jgengine",
|