@jgengine/core 0.9.0 → 0.10.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 +21 -1
- package/dist/ai/spawnDirector.d.ts +13 -0
- package/dist/ai/spawnDirector.js +40 -16
- package/dist/ai/spawnPoint.d.ts +5 -0
- package/dist/ai/spawnPoint.js +5 -0
- package/dist/audio/audioFalloff.d.ts +5 -2
- package/dist/audio/music.d.ts +53 -0
- package/dist/audio/music.js +34 -0
- package/dist/audio/synth.d.ts +60 -0
- package/dist/audio/synth.js +8 -0
- package/dist/authoring.d.ts +14 -0
- package/dist/authoring.js +14 -0
- package/dist/cartridge/runtime.js +2 -2
- package/dist/combat/dotField.d.ts +43 -0
- package/dist/combat/dotField.js +48 -0
- package/dist/combat/projectiles.d.ts +2 -2
- package/dist/combat/projectiles.js +18 -5
- package/dist/combat/regenShield.d.ts +35 -0
- package/dist/combat/regenShield.js +45 -0
- package/dist/combat/shotOrigin.d.ts +13 -1
- package/dist/combat/shotOrigin.js +31 -2
- package/dist/editor/commands.d.ts +65 -0
- package/dist/editor/commands.js +193 -0
- package/dist/editor/document.d.ts +40 -0
- package/dist/editor/document.js +153 -0
- package/dist/editor/index.d.ts +30 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/types.d.ts +69 -0
- package/dist/editor/types.js +29 -0
- package/dist/game/connectedPlayers.d.ts +28 -0
- package/dist/game/connectedPlayers.js +21 -0
- package/dist/game/defineGame.d.ts +42 -1
- package/dist/game/defineGame.js +1 -0
- package/dist/game/events.d.ts +15 -0
- package/dist/game/keyValueStore.d.ts +25 -0
- package/dist/game/keyValueStore.js +61 -0
- package/dist/game/levelSequence.d.ts +24 -2
- package/dist/game/levelSequence.js +51 -2
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +1 -0
- package/dist/game/modelAnimation.d.ts +8 -0
- package/dist/game/modelAnimation.js +18 -0
- package/dist/game/playableGame.d.ts +68 -5
- package/dist/game/recordBook.d.ts +3 -5
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/input/actionBindings.d.ts +2 -0
- package/dist/input/actionBindings.js +1 -0
- package/dist/input/axisInput.d.ts +7 -0
- package/dist/input/axisInput.js +16 -0
- package/dist/interaction/contextMenu.d.ts +1 -0
- package/dist/interaction/contextMenu.js +1 -0
- package/dist/interaction/proximityPrompt.d.ts +3 -1
- package/dist/interaction/proximityPrompt.js +3 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +22 -1
- package/dist/movement/movementModel.d.ts +4 -0
- package/dist/movement/movementModel.js +13 -0
- package/dist/movement/playerMovement.d.ts +41 -0
- package/dist/movement/playerMovement.js +181 -0
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +2 -0
- package/dist/render/postProcessing.d.ts +56 -0
- package/dist/render/postProcessing.js +1 -0
- package/dist/runtime/adapter.d.ts +19 -0
- package/dist/runtime/adapter.js +11 -5
- package/dist/runtime/gameContext.d.ts +53 -10
- package/dist/runtime/gameContext.js +192 -54
- package/dist/runtime/hostedGameRunner.d.ts +49 -0
- package/dist/runtime/hostedGameRunner.js +62 -0
- package/dist/runtime/hostedWorldSession.d.ts +65 -0
- package/dist/runtime/hostedWorldSession.js +57 -0
- package/dist/runtime/inputSnapshot.d.ts +13 -0
- package/dist/runtime/inputSnapshot.js +2 -0
- package/dist/runtime/motionIntents.d.ts +2 -0
- package/dist/runtime/motionIntents.js +11 -0
- package/dist/runtime/worldChannel.d.ts +58 -0
- package/dist/runtime/worldChannel.js +69 -0
- package/dist/runtime/worldMirror.d.ts +24 -0
- package/dist/runtime/worldMirror.js +31 -0
- package/dist/runtime/worldReplication.d.ts +46 -0
- package/dist/runtime/worldReplication.js +185 -0
- package/dist/runtime/worldSnapshot.d.ts +17 -0
- package/dist/runtime/worldSnapshot.js +15 -0
- package/dist/scene/assetCatalog.d.ts +1 -0
- package/dist/scene/colliders.d.ts +6 -0
- package/dist/scene/colliders.js +25 -1
- package/dist/scene/entityStats.d.ts +4 -0
- package/dist/scene/entityStats.js +21 -0
- package/dist/scene/entityStore.d.ts +29 -0
- package/dist/scene/entityStore.js +60 -0
- package/dist/store/observableKeyedStore.d.ts +4 -0
- package/dist/store/observableKeyedStore.js +9 -0
- package/dist/tactics/fallingGrid.d.ts +2 -2
- package/dist/tactics/fallingGrid.js +2 -2
- package/dist/ui/gameLayout.d.ts +125 -0
- package/dist/ui/gameLayout.js +129 -0
- package/dist/ui/orientation.d.ts +39 -0
- package/dist/ui/orientation.js +49 -0
- package/dist/ui/swingTimer.d.ts +42 -0
- package/dist/ui/swingTimer.js +38 -0
- package/dist/visibility/spatialIndex.js +34 -20
- package/dist/world/environmentSummary.d.ts +11 -2
- package/dist/world/environmentSummary.js +11 -1
- package/dist/world/features.d.ts +134 -5
- package/dist/world/features.js +40 -3
- package/dist/world/roads.d.ts +47 -0
- package/dist/world/roads.js +146 -0
- package/dist/world/streets.d.ts +80 -0
- package/dist/world/streets.js +144 -0
- package/dist/world/terrain.d.ts +32 -5
- package/dist/world/terrain.js +124 -12
- package/llms.txt +485 -79
- package/package.json +1 -1
|
@@ -28,18 +28,20 @@ import { type PoseAllowedStates, type PoseState } from "../movement/poseState.js
|
|
|
28
28
|
import type { ModelAssetRef } from "../scene/assetCatalog.js";
|
|
29
29
|
import { type PaintLayer } from "../scene/paintLayer.js";
|
|
30
30
|
import { type EntityStatsApi, type StatCatalog } from "../scene/entityStats.js";
|
|
31
|
-
import type { EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
|
|
31
|
+
import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
|
|
32
32
|
import { type Forms } from "../scene/form.js";
|
|
33
|
-
import type
|
|
33
|
+
import { type EntityColliderSet } from "../scene/colliders.js";
|
|
34
34
|
import { type ObjectRaycastHit, type ObjectRaycastInput } from "../scene/objectQuery.js";
|
|
35
35
|
import { type ObjectStore } from "../scene/objectStore.js";
|
|
36
36
|
import { type Roster } from "../scene/roster.js";
|
|
37
|
+
import { type ConnectedPlayers } from "../game/connectedPlayers.js";
|
|
37
38
|
import { type Possession } from "../scene/possession.js";
|
|
38
39
|
import { type SceneRaycastHit, type SceneRaycastInput } from "../scene/sceneRaycast.js";
|
|
39
40
|
import { type SpatialApi } from "../scene/spatial.js";
|
|
40
41
|
import { type CycleTargetOptions } from "../scene/targeting.js";
|
|
41
42
|
import { type Stats } from "../stats/statModifiers.js";
|
|
42
43
|
import { type ObservableKeyedStore } from "../store/observableKeyedStore.js";
|
|
44
|
+
import { type WorldSnapshot } from "./worldSnapshot.js";
|
|
43
45
|
import { type SimClock } from "../time/simClock.js";
|
|
44
46
|
import { type TurnLoop, type TurnLoopConfig } from "../turn/turnLoop.js";
|
|
45
47
|
import { RaceState, type RaceStateConfig } from "../game/race.js";
|
|
@@ -145,6 +147,7 @@ export interface SceneEntityContext {
|
|
|
145
147
|
spawnPoseOf(instanceId: string): SpawnPose | null;
|
|
146
148
|
resetToSpawn(instanceId: string): boolean;
|
|
147
149
|
resetAllToSpawn(filter?: (entity: SceneEntity) => boolean): number;
|
|
150
|
+
blackboard: EntityBlackboard;
|
|
148
151
|
stats: EntityStatsApi;
|
|
149
152
|
floatText(input: FloatTextInput): void;
|
|
150
153
|
telegraph(input: TelegraphInput): () => void;
|
|
@@ -189,6 +192,10 @@ export interface GameContextCommands {
|
|
|
189
192
|
has(name: string): boolean;
|
|
190
193
|
names(): string[];
|
|
191
194
|
run(name: string, input: unknown): CommandResult<GameContext>;
|
|
195
|
+
/** Run a command attributed to `actorUserId` — a shared-world host routes each client's command through this so the handler can read {@link actor}. */
|
|
196
|
+
runAs(actorUserId: string, name: string, input: unknown): CommandResult<GameContext>;
|
|
197
|
+
/** The user a command is running on behalf of, or `null` outside a {@link runAs} call — commands default to `ctx.player.userId` when this is `null`. */
|
|
198
|
+
actor(): string | null;
|
|
192
199
|
}
|
|
193
200
|
export interface GameContextFeed extends Omit<GameFeed, "bind"> {
|
|
194
201
|
bind(action: keyof GameEventMap): () => void;
|
|
@@ -228,6 +235,13 @@ export interface GameContextRace {
|
|
|
228
235
|
/** Lazily creates (on first call, `config` required) or returns the existing reactive race for `id` — discrete mutations and eventful `update` calls bump `ctx.version()`, so HUDs stop hand-managing `store.set` (#286.2). */
|
|
229
236
|
state(id: string, config?: RaceStateConfig): RaceState;
|
|
230
237
|
}
|
|
238
|
+
/** Reachable audio seam on `ctx.game`: `play`, `music`, and `resume` route through the `audio.play`/`audio.music`/`audio.resume` events the shell's audio engine listens on, so game code triggers sound without importing the shell. */
|
|
239
|
+
export interface GameAudio {
|
|
240
|
+
play(sound: string, at?: readonly [number, number, number]): void;
|
|
241
|
+
/** Crossfade the procedural soundtrack to `theme` (null fades out); `transpose` shifts the incoming theme's key in semitones. */
|
|
242
|
+
music(theme: string | null, transpose?: number): void;
|
|
243
|
+
resume(): void;
|
|
244
|
+
}
|
|
231
245
|
export interface GameContext {
|
|
232
246
|
scene: {
|
|
233
247
|
object: SceneObjectContext;
|
|
@@ -240,27 +254,46 @@ export interface GameContext {
|
|
|
240
254
|
game: {
|
|
241
255
|
commands: GameContextCommands;
|
|
242
256
|
events: GameEvents;
|
|
257
|
+
audio: GameAudio;
|
|
258
|
+
/** Play a model's one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an attack swing) — emits `entity.animation` for the shell to pick up. */
|
|
259
|
+
playEntityAnimation(instanceId: string, event: string): void;
|
|
243
260
|
feed: GameContextFeed;
|
|
244
261
|
loot: GameContextLoot;
|
|
245
262
|
trade: TradeSystem;
|
|
246
263
|
quest: QuestJournal;
|
|
247
|
-
social
|
|
248
|
-
|
|
264
|
+
/** Friends/party/presence/emotes/world-invites — present only when `features.social` is set. */
|
|
265
|
+
social?: Social;
|
|
266
|
+
/** Channels + messages — present only when `features.chat` is set (implies `social`). */
|
|
267
|
+
chat?: Chat;
|
|
249
268
|
unlocks: Unlocks;
|
|
250
269
|
economy: GameContextEconomy;
|
|
251
|
-
leaderboard
|
|
252
|
-
|
|
270
|
+
/** Competitive score tracking — present only when `features.leaderboard` is set. */
|
|
271
|
+
leaderboard?: Leaderboard;
|
|
272
|
+
/** Owned-entity roster — present only when `features.roster` is set. */
|
|
273
|
+
roster?: Roster;
|
|
253
274
|
/** Game-defined keyed reactive store slot (#163.1); mutations bump `ctx.version()`/notify `ctx.subscribe`. */
|
|
254
275
|
store: ObservableKeyedStore<unknown>;
|
|
255
|
-
cards
|
|
256
|
-
|
|
257
|
-
|
|
276
|
+
/** Card pile zones — present only when `features.cards` is set. */
|
|
277
|
+
cards?: GameContextCards;
|
|
278
|
+
/** Turn/phase loop — present only when `features.turn` is set. */
|
|
279
|
+
turn?: GameContextTurn;
|
|
280
|
+
/** Lap/checkpoint race state — present only when `features.race` is set. */
|
|
281
|
+
race?: GameContextRace;
|
|
282
|
+
/** Connected-player set for a shared world — present only when `features.players` is set. */
|
|
283
|
+
players?: ConnectedPlayers;
|
|
258
284
|
};
|
|
259
285
|
player: {
|
|
286
|
+
/** The acting player's id — the command actor inside `runAs`, the local player everywhere else. */
|
|
260
287
|
userId: string;
|
|
261
288
|
isNew: boolean;
|
|
289
|
+
/** The acting player's inventory set — the command actor's bags inside `runAs`, the local player's everywhere else. */
|
|
262
290
|
inventory: InventorySet<string>;
|
|
291
|
+
/** A specific player's inventory set — how a host reads or grants into any connected player's bags. */
|
|
292
|
+
inventoryFor(userId: string): InventorySet<string>;
|
|
293
|
+
/** The acting player's stat modifiers — the command actor's inside `runAs`, the local player's everywhere else. */
|
|
263
294
|
stats: Stats<string>;
|
|
295
|
+
/** A specific player's stat modifiers — how a host reads or buffs any connected player's stats. */
|
|
296
|
+
statsFor(userId: string): Stats<string>;
|
|
264
297
|
loadout: Loadouts;
|
|
265
298
|
applyLoadout(userId: string, loadoutId: string): {
|
|
266
299
|
reason: string;
|
|
@@ -268,8 +301,10 @@ export interface GameContext {
|
|
|
268
301
|
movement: PoseState;
|
|
269
302
|
possession: Possession;
|
|
270
303
|
cosmetics: Cosmetics;
|
|
271
|
-
/**
|
|
304
|
+
/** Motion seam into the movement integrator (#162.4); routes to the command actor's queue (or the local player outside a command), so a command's impulse lands on whoever ran it. See `MotionIntents`. */
|
|
272
305
|
motion: MotionIntents;
|
|
306
|
+
/** A specific player's motion queue — how the host-side per-player movement integrator drains each connected player's impulses. */
|
|
307
|
+
motionFor(userId: string): MotionIntents;
|
|
273
308
|
};
|
|
274
309
|
item: {
|
|
275
310
|
use: GameContextItemUse;
|
|
@@ -282,5 +317,13 @@ export interface GameContext {
|
|
|
282
317
|
input: InputSnapshot;
|
|
283
318
|
subscribe(listener: () => void): () => void;
|
|
284
319
|
version(): number;
|
|
320
|
+
/**
|
|
321
|
+
* Gather every opted-in live subsystem into one {@link WorldSnapshot} — entities, entity stats, the
|
|
322
|
+
* keyed store, the action feed, plus leaderboard/chat when those features are on. The full-world
|
|
323
|
+
* baseline a host sends a joining client; {@link hydrate} is its inverse.
|
|
324
|
+
*/
|
|
325
|
+
snapshot(): WorldSnapshot;
|
|
326
|
+
/** Apply a {@link WorldSnapshot} from an authoritative host, hydrating each subsystem key present in it. */
|
|
327
|
+
hydrate(snapshot: WorldSnapshot): void;
|
|
285
328
|
}
|
|
286
329
|
export declare function createGameContext<TAssetRef extends ModelAssetRef, TMultiplayer>(options: GameContextOptions<TAssetRef, TMultiplayer>): GameContext;
|
|
@@ -24,11 +24,13 @@ import { createItemUse, } from "../item/use.js";
|
|
|
24
24
|
import { createWeaponStats } from "../item/weapon.js";
|
|
25
25
|
import { createPoseState } from "../movement/poseState.js";
|
|
26
26
|
import { createPaintLayer } from "../scene/paintLayer.js";
|
|
27
|
-
import { createEntityStatsApi, seedStatValues, setStatValue, } from "../scene/entityStats.js";
|
|
27
|
+
import { createEntityStatsApi, hydrateEntityStats, seedStatValues, setStatValue, snapshotEntityStats, } from "../scene/entityStats.js";
|
|
28
28
|
import { createForms } from "../scene/form.js";
|
|
29
|
+
import { scaledObjectColliders } from "../scene/colliders.js";
|
|
29
30
|
import { raycastObjects, raycastObjectsAll } from "../scene/objectQuery.js";
|
|
30
|
-
import { createObjectStore } from "../scene/objectStore.js";
|
|
31
|
+
import { createObjectStore, objectVisualScale } from "../scene/objectStore.js";
|
|
31
32
|
import { createRoster } from "../scene/roster.js";
|
|
33
|
+
import { createConnectedPlayers } from "../game/connectedPlayers.js";
|
|
32
34
|
import { createPossession } from "../scene/possession.js";
|
|
33
35
|
import { createSceneRaycast, } from "../scene/sceneRaycast.js";
|
|
34
36
|
import { createSpatialApi } from "../scene/spatial.js";
|
|
@@ -36,6 +38,7 @@ import { createTargeting } from "../scene/targeting.js";
|
|
|
36
38
|
import { createStats } from "../stats/statModifiers.js";
|
|
37
39
|
import { createChangeSignal, notifyAfter } from "../store/changeSignal.js";
|
|
38
40
|
import { createObservableKeyedStore } from "../store/observableKeyedStore.js";
|
|
41
|
+
import { applyWorldSnapshot, composeWorldSnapshot, } from "./worldSnapshot.js";
|
|
39
42
|
import { createSimClock } from "../time/simClock.js";
|
|
40
43
|
import { createTurnLoop } from "../turn/turnLoop.js";
|
|
41
44
|
import { RaceState } from "../game/race.js";
|
|
@@ -47,6 +50,8 @@ export function createGameContext(options) {
|
|
|
47
50
|
const now = options.now ?? Date.now;
|
|
48
51
|
const occluder = options.occluder;
|
|
49
52
|
const signal = createChangeSignal();
|
|
53
|
+
let actingUserId = null;
|
|
54
|
+
const activeUserId = () => actingUserId ?? player.userId;
|
|
50
55
|
const time = createSimClock({ config: definition.time, onChange: signal.notify });
|
|
51
56
|
const ground = groundFieldFor(definition.world);
|
|
52
57
|
const entities = definition.scene;
|
|
@@ -107,7 +112,15 @@ export function createGameContext(options) {
|
|
|
107
112
|
const override = objectColliders.get(instanceId);
|
|
108
113
|
if (override !== undefined)
|
|
109
114
|
return override;
|
|
110
|
-
|
|
115
|
+
const catalog = catalogObject(instanceId);
|
|
116
|
+
if (catalog?.colliders !== undefined)
|
|
117
|
+
return catalog.colliders;
|
|
118
|
+
if (catalog?.halfExtents !== undefined)
|
|
119
|
+
return null;
|
|
120
|
+
const object = objects.get(instanceId);
|
|
121
|
+
if (object === null || object.visual?.scale === undefined)
|
|
122
|
+
return null;
|
|
123
|
+
return scaledObjectColliders(objectVisualScale(object.visual));
|
|
111
124
|
}
|
|
112
125
|
const targeting = notifyAfter(createTargeting({
|
|
113
126
|
candidates: () => [...refreshCandidateIds()],
|
|
@@ -169,34 +182,55 @@ export function createGameContext(options) {
|
|
|
169
182
|
const feed = createGameFeed(definition.feed);
|
|
170
183
|
const lootRegistry = createLootRegistry();
|
|
171
184
|
const unlocks = notifyAfter(createUnlocks(), ["grant", "hydrate"], signal.notify);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
185
|
+
const features = definition.features ?? {};
|
|
186
|
+
const rawSocial = features.social || features.chat
|
|
187
|
+
? createSocial({
|
|
188
|
+
events,
|
|
189
|
+
now,
|
|
190
|
+
emotes: {
|
|
191
|
+
entities: { get: (id) => entities.get(id) },
|
|
192
|
+
spatial: { inRadius: (center, radius, filter) => spatial.inRadius(center, radius, filter) },
|
|
193
|
+
},
|
|
194
|
+
})
|
|
195
|
+
: null;
|
|
196
|
+
let social;
|
|
197
|
+
if (features.social && rawSocial !== null) {
|
|
198
|
+
social = {
|
|
199
|
+
friends: notifyAfter(rawSocial.friends, ["request", "accept", "decline", "remove", "block", "hydrate"], signal.notify),
|
|
200
|
+
party: notifyAfter(rawSocial.party, ["invite", "accept", "decline", "kick", "leave", "promote"], signal.notify),
|
|
201
|
+
presence: rawSocial.presence,
|
|
202
|
+
emotes: rawSocial.emotes,
|
|
203
|
+
worldInvites: notifyAfter(rawSocial.worldInvites, ["invite", "accept", "decline"], signal.notify),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
let chat;
|
|
207
|
+
if (features.chat && rawSocial !== null) {
|
|
208
|
+
chat = notifyAfter(createChat({
|
|
209
|
+
events,
|
|
210
|
+
now,
|
|
211
|
+
party: rawSocial.party,
|
|
212
|
+
proximity: {
|
|
213
|
+
entities: { get: (id) => entities.get(id) },
|
|
214
|
+
spatial: { inRadius: (center, radius, filter) => spatial.inRadius(center, radius, filter) },
|
|
215
|
+
},
|
|
216
|
+
blockedBy: (userId) => rawSocial.friends.snapshot(userId).blocked,
|
|
217
|
+
}), ["register", "send", "whisper", "hydrate"], signal.notify);
|
|
218
|
+
}
|
|
219
|
+
const leaderboard = features.leaderboard
|
|
220
|
+
? notifyAfter(createLeaderboard(), ["increment", "hydrate"], signal.notify)
|
|
221
|
+
: undefined;
|
|
222
|
+
const roster = features.roster
|
|
223
|
+
? notifyAfter(createRoster({ now }), ["capture", "release", "setEquipped", "hydrate"], signal.notify)
|
|
224
|
+
: undefined;
|
|
225
|
+
const playerStatsByUser = new Map();
|
|
226
|
+
function playerStatsFor(userId) {
|
|
227
|
+
let stats = playerStatsByUser.get(userId);
|
|
228
|
+
if (stats === undefined) {
|
|
229
|
+
stats = createStats({});
|
|
230
|
+
playerStatsByUser.set(userId, stats);
|
|
231
|
+
}
|
|
232
|
+
return stats;
|
|
233
|
+
}
|
|
200
234
|
const pose = createPoseState((instanceId) => catalogEntry(instanceId)?.movement);
|
|
201
235
|
const commandRegistry = createCommandRegistry();
|
|
202
236
|
const itemUse = createItemUse((itemId) => content.itemById?.(itemId)?.use);
|
|
@@ -211,7 +245,15 @@ export function createGameContext(options) {
|
|
|
211
245
|
layouts[inventoryId] = { slots: declaration.slots, accepts: declaration.accepts };
|
|
212
246
|
}
|
|
213
247
|
const traits = Object.values(inventoryDeclarations).find((declaration) => declaration.traits !== undefined)?.traits ?? { stackLimit: () => Number.POSITIVE_INFINITY };
|
|
214
|
-
const
|
|
248
|
+
const inventoryByUser = new Map();
|
|
249
|
+
function inventoryFor(userId) {
|
|
250
|
+
let set = inventoryByUser.get(userId);
|
|
251
|
+
if (set === undefined) {
|
|
252
|
+
set = notifyAfter(createInventorySet(layouts, traits), ["put", "take", "move", "replaceState"], signal.notify);
|
|
253
|
+
inventoryByUser.set(userId, set);
|
|
254
|
+
}
|
|
255
|
+
return set;
|
|
256
|
+
}
|
|
215
257
|
const wallets = new Map();
|
|
216
258
|
const walletOf = (userId) => wallets.get(userId) ?? createEmptyWallet();
|
|
217
259
|
const economy = {
|
|
@@ -229,7 +271,8 @@ export function createGameContext(options) {
|
|
|
229
271
|
return null;
|
|
230
272
|
},
|
|
231
273
|
};
|
|
232
|
-
function putIntoAnyInventory(itemId, count) {
|
|
274
|
+
function putIntoAnyInventory(userId, itemId, count) {
|
|
275
|
+
const inventory = inventoryFor(userId);
|
|
233
276
|
for (const inventoryId of inventoryIds) {
|
|
234
277
|
if (inventory.put(inventoryId, itemId, count).status === "ok")
|
|
235
278
|
return;
|
|
@@ -241,7 +284,7 @@ export function createGameContext(options) {
|
|
|
241
284
|
roll: lootRegistry.roll,
|
|
242
285
|
grantToPlayer(userId, drops, source) {
|
|
243
286
|
grantDrops(drops, {
|
|
244
|
-
putItem: (itemId, count) => putIntoAnyInventory(itemId, count),
|
|
287
|
+
putItem: (itemId, count) => putIntoAnyInventory(userId, itemId, count),
|
|
245
288
|
grantCurrency: (currencyId, amount) => economy.grant(userId, currencyId, amount),
|
|
246
289
|
});
|
|
247
290
|
const event = { userId, drops };
|
|
@@ -253,17 +296,17 @@ export function createGameContext(options) {
|
|
|
253
296
|
const trade = createTradeSystem({
|
|
254
297
|
resolveTrade: (itemId) => content.itemById?.(itemId)?.trade,
|
|
255
298
|
wallet: {
|
|
256
|
-
canAfford: (costs) => (walletCanAfford(walletOf(
|
|
299
|
+
canAfford: (costs) => (walletCanAfford(walletOf(activeUserId()), costs) ? null : "insufficient-funds"),
|
|
257
300
|
charge(costs) {
|
|
258
|
-
const result = walletChargeAll(walletOf(
|
|
301
|
+
const result = walletChargeAll(walletOf(activeUserId()), costs);
|
|
259
302
|
if (result.status === "ok") {
|
|
260
|
-
wallets.set(
|
|
303
|
+
wallets.set(activeUserId(), result.state);
|
|
261
304
|
signal.notify();
|
|
262
305
|
}
|
|
263
306
|
},
|
|
264
307
|
grant(gains) {
|
|
265
308
|
for (const [currencyId, amount] of Object.entries(gains)) {
|
|
266
|
-
economy.grant(
|
|
309
|
+
economy.grant(activeUserId(), currencyId, amount);
|
|
267
310
|
}
|
|
268
311
|
},
|
|
269
312
|
},
|
|
@@ -271,16 +314,16 @@ export function createGameContext(options) {
|
|
|
271
314
|
put(inventoryId, itemId, count) {
|
|
272
315
|
if (layouts[inventoryId] === undefined)
|
|
273
316
|
return { reason: `unknown inventory "${inventoryId}"` };
|
|
274
|
-
const result =
|
|
317
|
+
const result = inventoryFor(activeUserId()).put(inventoryId, itemId, count);
|
|
275
318
|
return result.status === "ok" ? null : { reason: result.reason };
|
|
276
319
|
},
|
|
277
320
|
take(inventoryId, itemId, count) {
|
|
278
321
|
if (layouts[inventoryId] === undefined)
|
|
279
322
|
return { reason: `unknown inventory "${inventoryId}"` };
|
|
280
|
-
const result =
|
|
323
|
+
const result = inventoryFor(activeUserId()).take(inventoryId, itemId, count);
|
|
281
324
|
return result.status === "ok" ? null : { reason: result.reason };
|
|
282
325
|
},
|
|
283
|
-
count: (inventoryId, itemId) =>
|
|
326
|
+
count: (inventoryId, itemId) => inventoryFor(activeUserId()).count(inventoryId, itemId),
|
|
284
327
|
},
|
|
285
328
|
});
|
|
286
329
|
function seedUserPool(userId, statId, pool) {
|
|
@@ -298,11 +341,9 @@ export function createGameContext(options) {
|
|
|
298
341
|
},
|
|
299
342
|
grantEconomy: (userId, currencyId, amount) => economy.grant(userId, currencyId, amount),
|
|
300
343
|
grantItem(userId, inventoryId, itemId, count) {
|
|
301
|
-
if (userId !== player.userId)
|
|
302
|
-
return { reason: `unknown user "${userId}"` };
|
|
303
344
|
if (layouts[inventoryId] === undefined)
|
|
304
345
|
return { reason: `unknown inventory "${inventoryId}"` };
|
|
305
|
-
const result =
|
|
346
|
+
const result = inventoryFor(userId).put(inventoryId, itemId, count);
|
|
306
347
|
return result.status === "ok" ? null : { reason: result.reason };
|
|
307
348
|
},
|
|
308
349
|
grantUnlock: (userId, unlockId) => unlocks.grant(userId, unlockId),
|
|
@@ -312,7 +353,8 @@ export function createGameContext(options) {
|
|
|
312
353
|
const quest = notifyAfter(rawQuest, ["accept", "abandon", "progress", "turnIn", "grant", "revoke", "hydrate"], signal.notify);
|
|
313
354
|
const loadouts = notifyAfter(createLoadouts({
|
|
314
355
|
inventory: {
|
|
315
|
-
begin() {
|
|
356
|
+
begin(userId) {
|
|
357
|
+
const inventory = inventoryFor(userId);
|
|
316
358
|
const staged = new Map();
|
|
317
359
|
return {
|
|
318
360
|
put(inventoryId, itemId, count, slot) {
|
|
@@ -646,7 +688,10 @@ export function createGameContext(options) {
|
|
|
646
688
|
},
|
|
647
689
|
collidersOf: objectCollidersOf,
|
|
648
690
|
};
|
|
649
|
-
const store = notifyAfter(createObservableKeyedStore(), ["set", "delete"], signal.notify);
|
|
691
|
+
const store = notifyAfter(createObservableKeyedStore(), ["set", "delete", "hydrate"], signal.notify);
|
|
692
|
+
const players = features.players
|
|
693
|
+
? notifyAfter(createConnectedPlayers(), ["join", "leave"], signal.notify)
|
|
694
|
+
: undefined;
|
|
650
695
|
const cardPiles = new Map();
|
|
651
696
|
function pile(id, config) {
|
|
652
697
|
const existing = cardPiles.get(id);
|
|
@@ -725,7 +770,63 @@ export function createGameContext(options) {
|
|
|
725
770
|
}
|
|
726
771
|
const camera = notifyAfter(createCameraDirector(), ["follow", "setCinematic", "setChaseTuning"], signal.notify);
|
|
727
772
|
const input = createInputSnapshot();
|
|
728
|
-
const
|
|
773
|
+
const motionByUser = new Map();
|
|
774
|
+
function motionFor(userId) {
|
|
775
|
+
let queue = motionByUser.get(userId);
|
|
776
|
+
if (queue === undefined) {
|
|
777
|
+
queue = createMotionIntents();
|
|
778
|
+
motionByUser.set(userId, queue);
|
|
779
|
+
}
|
|
780
|
+
return queue;
|
|
781
|
+
}
|
|
782
|
+
const snapshotModules = [
|
|
783
|
+
{ key: "entities", snapshot: () => entities.snapshot(), hydrate: (data) => entities.hydrate(data) },
|
|
784
|
+
{
|
|
785
|
+
key: "stats",
|
|
786
|
+
snapshot: () => snapshotEntityStats(statsByInstance),
|
|
787
|
+
hydrate: (data) => hydrateEntityStats(statsByInstance, data),
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
key: "store",
|
|
791
|
+
snapshot: () => store.snapshot(),
|
|
792
|
+
hydrate: (data) => store.hydrate(data),
|
|
793
|
+
},
|
|
794
|
+
{ key: "feed", snapshot: () => feed.snapshot(), hydrate: (data) => feed.hydrate(data) },
|
|
795
|
+
{
|
|
796
|
+
key: "inventory",
|
|
797
|
+
snapshot: () => {
|
|
798
|
+
const byUser = {};
|
|
799
|
+
for (const [userId, set] of inventoryByUser) {
|
|
800
|
+
const states = {};
|
|
801
|
+
for (const inventoryId of inventoryIds)
|
|
802
|
+
states[inventoryId] = set.state(inventoryId);
|
|
803
|
+
byUser[userId] = states;
|
|
804
|
+
}
|
|
805
|
+
return byUser;
|
|
806
|
+
},
|
|
807
|
+
hydrate: (data) => {
|
|
808
|
+
for (const [userId, states] of Object.entries(data)) {
|
|
809
|
+
const set = inventoryFor(userId);
|
|
810
|
+
for (const [inventoryId, state] of Object.entries(states))
|
|
811
|
+
set.replaceState(inventoryId, state);
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
},
|
|
815
|
+
];
|
|
816
|
+
if (leaderboard) {
|
|
817
|
+
snapshotModules.push({
|
|
818
|
+
key: "leaderboard",
|
|
819
|
+
snapshot: () => leaderboard.snapshot(),
|
|
820
|
+
hydrate: (data) => leaderboard.hydrate(data),
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
if (chat) {
|
|
824
|
+
snapshotModules.push({
|
|
825
|
+
key: "chat",
|
|
826
|
+
snapshot: () => chat.snapshot(),
|
|
827
|
+
hydrate: (data) => chat.hydrate(data),
|
|
828
|
+
});
|
|
829
|
+
}
|
|
729
830
|
const ctx = {
|
|
730
831
|
scene: {
|
|
731
832
|
object: sceneObjects,
|
|
@@ -740,6 +841,7 @@ export function createGameContext(options) {
|
|
|
740
841
|
spawnPoseOf: entities.spawnPoseOf,
|
|
741
842
|
resetToSpawn: entities.resetToSpawn,
|
|
742
843
|
resetAllToSpawn,
|
|
844
|
+
blackboard: entities.blackboard,
|
|
743
845
|
stats: entityStats,
|
|
744
846
|
floatText: emitFloatText,
|
|
745
847
|
telegraph: fireTelegraph,
|
|
@@ -795,8 +897,27 @@ export function createGameContext(options) {
|
|
|
795
897
|
signal.notify();
|
|
796
898
|
return result;
|
|
797
899
|
},
|
|
900
|
+
runAs(actorUserId, name, input) {
|
|
901
|
+
const previous = actingUserId;
|
|
902
|
+
actingUserId = actorUserId;
|
|
903
|
+
try {
|
|
904
|
+
const result = commandRegistry.run(ctx, name, input);
|
|
905
|
+
signal.notify();
|
|
906
|
+
return result;
|
|
907
|
+
}
|
|
908
|
+
finally {
|
|
909
|
+
actingUserId = previous;
|
|
910
|
+
}
|
|
911
|
+
},
|
|
912
|
+
actor: () => actingUserId,
|
|
798
913
|
},
|
|
799
914
|
events,
|
|
915
|
+
playEntityAnimation: (instanceId, event) => events.emit("entity.animation", { instanceId, event }),
|
|
916
|
+
audio: {
|
|
917
|
+
play: (sound, at) => events.emit("audio.play", at === undefined ? { sound } : { sound, at }),
|
|
918
|
+
music: (theme, transpose) => events.emit("audio.music", transpose === undefined ? { theme } : { theme, transpose }),
|
|
919
|
+
resume: () => events.emit("audio.resume", {}),
|
|
920
|
+
},
|
|
800
921
|
feed: {
|
|
801
922
|
bind: (action) => feed.bind(action, events),
|
|
802
923
|
push(action, entry) {
|
|
@@ -821,21 +942,33 @@ export function createGameContext(options) {
|
|
|
821
942
|
leaderboard,
|
|
822
943
|
roster,
|
|
823
944
|
store,
|
|
824
|
-
cards: { pile },
|
|
825
|
-
turn: { loop },
|
|
826
|
-
race: { state: raceState },
|
|
945
|
+
cards: features.cards ? { pile } : undefined,
|
|
946
|
+
turn: features.turn ? { loop } : undefined,
|
|
947
|
+
race: features.race ? { state: raceState } : undefined,
|
|
948
|
+
players,
|
|
827
949
|
},
|
|
828
950
|
player: {
|
|
829
|
-
userId
|
|
951
|
+
get userId() {
|
|
952
|
+
return activeUserId();
|
|
953
|
+
},
|
|
830
954
|
isNew: player.isNew,
|
|
831
|
-
inventory
|
|
832
|
-
|
|
955
|
+
get inventory() {
|
|
956
|
+
return inventoryFor(activeUserId());
|
|
957
|
+
},
|
|
958
|
+
inventoryFor,
|
|
959
|
+
get stats() {
|
|
960
|
+
return playerStatsFor(activeUserId());
|
|
961
|
+
},
|
|
962
|
+
statsFor: playerStatsFor,
|
|
833
963
|
loadout: loadouts,
|
|
834
964
|
applyLoadout: loadouts.applyLoadout,
|
|
835
965
|
movement: pose,
|
|
836
966
|
possession,
|
|
837
967
|
cosmetics,
|
|
838
|
-
motion
|
|
968
|
+
get motion() {
|
|
969
|
+
return motionFor(activeUserId());
|
|
970
|
+
},
|
|
971
|
+
motionFor,
|
|
839
972
|
},
|
|
840
973
|
item: {
|
|
841
974
|
use: {
|
|
@@ -855,6 +988,11 @@ export function createGameContext(options) {
|
|
|
855
988
|
input,
|
|
856
989
|
subscribe: signal.subscribe,
|
|
857
990
|
version: signal.version,
|
|
991
|
+
snapshot: () => composeWorldSnapshot(snapshotModules),
|
|
992
|
+
hydrate(snapshot) {
|
|
993
|
+
applyWorldSnapshot(snapshotModules, snapshot);
|
|
994
|
+
signal.notify();
|
|
995
|
+
},
|
|
858
996
|
};
|
|
859
997
|
return ctx;
|
|
860
998
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CommandResult } from "../commands/commandRegistry.js";
|
|
2
|
+
import type { GameDefinition, LoopPlayer } from "../game/defineGame.js";
|
|
3
|
+
import type { ModelAssetRef } from "../scene/assetCatalog.js";
|
|
4
|
+
import { type GameContext, type GameContextContent } from "./gameContext.js";
|
|
5
|
+
import { type InputFrame } from "./inputSnapshot.js";
|
|
6
|
+
import { type WorldDiff } from "./worldReplication.js";
|
|
7
|
+
import type { WorldSnapshot } from "./worldSnapshot.js";
|
|
8
|
+
export type { InputFrame };
|
|
9
|
+
/** Reserved command name the authoritative host intercepts on the existing `runCommand` transport to route a client's {@link InputFrame} to `session.input`, so per-tick input needs no separate wire. */
|
|
10
|
+
export declare const INPUT_COMMAND = "engine.input";
|
|
11
|
+
/** Config for {@link createHostedGameRunner}: the game definition, its content lookup, and an optional host identity. */
|
|
12
|
+
export interface HostedGameRunnerOptions<TAssetRef extends ModelAssetRef, TMultiplayer> {
|
|
13
|
+
definition: GameDefinition<TAssetRef, TMultiplayer>;
|
|
14
|
+
content: GameContextContent;
|
|
15
|
+
/** The world's own authoritative identity (`ctx.player` server-side); real players join as members. */
|
|
16
|
+
host?: LoopPlayer;
|
|
17
|
+
now?: () => number;
|
|
18
|
+
/**
|
|
19
|
+
* Rehydrate a persisted world instead of booting a fresh one — for stateless hosts (Convex) that reconstruct
|
|
20
|
+
* the runner each invocation. `onInit` still runs (so commands and systems it registers exist), then this
|
|
21
|
+
* snapshot overlays the world state it seeded. Omit for a long-lived stateful host (ws) that keeps one runner.
|
|
22
|
+
*/
|
|
23
|
+
restore?: WorldSnapshot;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The GameContext-loop equivalent of the pure-reducer `createGameHost`: one authoritative `createGameContext`
|
|
27
|
+
* per world, driven server-side. `onInit` runs once at construction; `onNewPlayer`/`onPlayerLeave` fire per
|
|
28
|
+
* join/leave; `tick` advances `onTick` then commits a revision. Clients pull a full {@link WorldSnapshot}
|
|
29
|
+
* baseline once, then per-tick {@link WorldDiff}s from their last revision. Games ship only normal GameContext
|
|
30
|
+
* code — the runner adds no per-game surface.
|
|
31
|
+
*/
|
|
32
|
+
export interface HostedGameRunner {
|
|
33
|
+
join(userId: string, isNew: boolean): void;
|
|
34
|
+
/** Re-attach an already-joined member on a reconstructed runner — restores membership and `ctx.game.players` without re-firing `onNewPlayer`. Stateless hosts call it once per persisted member after `restore`. */
|
|
35
|
+
resume(userId: string): void;
|
|
36
|
+
leave(userId: string): void;
|
|
37
|
+
/** Record a client's latest input frame — stashed for {@link heldInput} and mirrored onto `ctx.game.players` so the movement seam reads each player's intent in `onTick`. */
|
|
38
|
+
input(userId: string, frame: InputFrame): void;
|
|
39
|
+
heldInput(userId: string): InputFrame | null;
|
|
40
|
+
command(userId: string, name: string, input: unknown): CommandResult<GameContext>;
|
|
41
|
+
tick(dt: number): number;
|
|
42
|
+
diff(sinceRevision: number): WorldDiff;
|
|
43
|
+
revision(): number;
|
|
44
|
+
snapshot(): WorldSnapshot;
|
|
45
|
+
members(): readonly string[];
|
|
46
|
+
context(): GameContext;
|
|
47
|
+
}
|
|
48
|
+
/** Build a {@link HostedGameRunner} — one authoritative GameContext world driven server-side from the game's own loop. */
|
|
49
|
+
export declare function createHostedGameRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HostedGameRunnerOptions<TAssetRef, TMultiplayer>): HostedGameRunner;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createGameContext } from "./gameContext.js";
|
|
2
|
+
import {} from "./inputSnapshot.js";
|
|
3
|
+
import { createWorldReplicator } from "./worldReplication.js";
|
|
4
|
+
/** Reserved command name the authoritative host intercepts on the existing `runCommand` transport to route a client's {@link InputFrame} to `session.input`, so per-tick input needs no separate wire. */
|
|
5
|
+
export const INPUT_COMMAND = "engine.input";
|
|
6
|
+
/** Build a {@link HostedGameRunner} — one authoritative GameContext world driven server-side from the game's own loop. */
|
|
7
|
+
export function createHostedGameRunner(options) {
|
|
8
|
+
const { definition, content, host, now, restore } = options;
|
|
9
|
+
const ctx = createGameContext({
|
|
10
|
+
definition,
|
|
11
|
+
content,
|
|
12
|
+
player: host ?? { userId: "host", isNew: true },
|
|
13
|
+
...(now === undefined ? {} : { now }),
|
|
14
|
+
});
|
|
15
|
+
const loop = definition.loop ?? {};
|
|
16
|
+
const replicator = createWorldReplicator(() => ctx.snapshot());
|
|
17
|
+
const members = new Map();
|
|
18
|
+
const inputs = new Map();
|
|
19
|
+
loop.onInit?.(ctx);
|
|
20
|
+
if (restore !== undefined)
|
|
21
|
+
ctx.hydrate(restore);
|
|
22
|
+
return {
|
|
23
|
+
join(userId, isNew) {
|
|
24
|
+
const player = { userId, isNew };
|
|
25
|
+
members.set(userId, player);
|
|
26
|
+
ctx.game.players?.join(userId, isNew);
|
|
27
|
+
loop.onNewPlayer?.(ctx, player);
|
|
28
|
+
},
|
|
29
|
+
resume(userId) {
|
|
30
|
+
members.set(userId, { userId, isNew: false });
|
|
31
|
+
ctx.game.players?.join(userId, false);
|
|
32
|
+
},
|
|
33
|
+
leave(userId) {
|
|
34
|
+
const player = members.get(userId);
|
|
35
|
+
if (player === undefined)
|
|
36
|
+
return;
|
|
37
|
+
members.delete(userId);
|
|
38
|
+
inputs.delete(userId);
|
|
39
|
+
loop.onPlayerLeave?.(ctx, player);
|
|
40
|
+
ctx.game.players?.leave(userId);
|
|
41
|
+
},
|
|
42
|
+
input(userId, frame) {
|
|
43
|
+
inputs.set(userId, frame);
|
|
44
|
+
ctx.game.players?.setInput(userId, frame);
|
|
45
|
+
},
|
|
46
|
+
heldInput(userId) {
|
|
47
|
+
return inputs.get(userId) ?? null;
|
|
48
|
+
},
|
|
49
|
+
command(userId, name, input) {
|
|
50
|
+
return ctx.game.commands.runAs(userId, name, input);
|
|
51
|
+
},
|
|
52
|
+
tick(dt) {
|
|
53
|
+
loop.onTick?.(ctx, dt);
|
|
54
|
+
return replicator.commit();
|
|
55
|
+
},
|
|
56
|
+
diff: (sinceRevision) => replicator.diff(sinceRevision),
|
|
57
|
+
revision: () => replicator.revision(),
|
|
58
|
+
snapshot: () => ctx.snapshot(),
|
|
59
|
+
members: () => Array.from(members.keys()),
|
|
60
|
+
context: () => ctx,
|
|
61
|
+
};
|
|
62
|
+
}
|