@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/ai/spawnDirector.d.ts +13 -0
  3. package/dist/ai/spawnDirector.js +40 -16
  4. package/dist/ai/spawnPoint.d.ts +5 -0
  5. package/dist/ai/spawnPoint.js +5 -0
  6. package/dist/audio/audioFalloff.d.ts +5 -2
  7. package/dist/audio/music.d.ts +53 -0
  8. package/dist/audio/music.js +34 -0
  9. package/dist/audio/synth.d.ts +60 -0
  10. package/dist/audio/synth.js +8 -0
  11. package/dist/authoring.d.ts +14 -0
  12. package/dist/authoring.js +14 -0
  13. package/dist/cartridge/runtime.js +2 -2
  14. package/dist/combat/dotField.d.ts +43 -0
  15. package/dist/combat/dotField.js +48 -0
  16. package/dist/combat/projectiles.d.ts +2 -2
  17. package/dist/combat/projectiles.js +18 -5
  18. package/dist/combat/regenShield.d.ts +35 -0
  19. package/dist/combat/regenShield.js +45 -0
  20. package/dist/combat/shotOrigin.d.ts +13 -1
  21. package/dist/combat/shotOrigin.js +31 -2
  22. package/dist/editor/commands.d.ts +65 -0
  23. package/dist/editor/commands.js +193 -0
  24. package/dist/editor/document.d.ts +40 -0
  25. package/dist/editor/document.js +153 -0
  26. package/dist/editor/index.d.ts +30 -0
  27. package/dist/editor/index.js +6 -0
  28. package/dist/editor/types.d.ts +69 -0
  29. package/dist/editor/types.js +29 -0
  30. package/dist/game/connectedPlayers.d.ts +28 -0
  31. package/dist/game/connectedPlayers.js +21 -0
  32. package/dist/game/defineGame.d.ts +42 -1
  33. package/dist/game/defineGame.js +1 -0
  34. package/dist/game/events.d.ts +15 -0
  35. package/dist/game/keyValueStore.d.ts +25 -0
  36. package/dist/game/keyValueStore.js +61 -0
  37. package/dist/game/levelSequence.d.ts +24 -2
  38. package/dist/game/levelSequence.js +51 -2
  39. package/dist/game/lootTable.d.ts +4 -0
  40. package/dist/game/lootTable.js +1 -0
  41. package/dist/game/modelAnimation.d.ts +8 -0
  42. package/dist/game/modelAnimation.js +18 -0
  43. package/dist/game/playableGame.d.ts +68 -5
  44. package/dist/game/recordBook.d.ts +3 -5
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +7 -0
  47. package/dist/input/actionBindings.d.ts +2 -0
  48. package/dist/input/actionBindings.js +1 -0
  49. package/dist/input/axisInput.d.ts +7 -0
  50. package/dist/input/axisInput.js +16 -0
  51. package/dist/interaction/contextMenu.d.ts +1 -0
  52. package/dist/interaction/contextMenu.js +1 -0
  53. package/dist/interaction/proximityPrompt.d.ts +3 -1
  54. package/dist/interaction/proximityPrompt.js +3 -3
  55. package/dist/meta/changelog.d.ts +1 -1
  56. package/dist/meta/changelog.js +22 -1
  57. package/dist/movement/movementModel.d.ts +4 -0
  58. package/dist/movement/movementModel.js +13 -0
  59. package/dist/movement/playerMovement.d.ts +41 -0
  60. package/dist/movement/playerMovement.js +181 -0
  61. package/dist/random/rng.d.ts +2 -0
  62. package/dist/random/rng.js +2 -0
  63. package/dist/render/postProcessing.d.ts +56 -0
  64. package/dist/render/postProcessing.js +1 -0
  65. package/dist/runtime/adapter.d.ts +19 -0
  66. package/dist/runtime/adapter.js +11 -5
  67. package/dist/runtime/gameContext.d.ts +53 -10
  68. package/dist/runtime/gameContext.js +192 -54
  69. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  70. package/dist/runtime/hostedGameRunner.js +62 -0
  71. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  72. package/dist/runtime/hostedWorldSession.js +57 -0
  73. package/dist/runtime/inputSnapshot.d.ts +13 -0
  74. package/dist/runtime/inputSnapshot.js +2 -0
  75. package/dist/runtime/motionIntents.d.ts +2 -0
  76. package/dist/runtime/motionIntents.js +11 -0
  77. package/dist/runtime/worldChannel.d.ts +58 -0
  78. package/dist/runtime/worldChannel.js +69 -0
  79. package/dist/runtime/worldMirror.d.ts +24 -0
  80. package/dist/runtime/worldMirror.js +31 -0
  81. package/dist/runtime/worldReplication.d.ts +46 -0
  82. package/dist/runtime/worldReplication.js +185 -0
  83. package/dist/runtime/worldSnapshot.d.ts +17 -0
  84. package/dist/runtime/worldSnapshot.js +15 -0
  85. package/dist/scene/assetCatalog.d.ts +1 -0
  86. package/dist/scene/colliders.d.ts +6 -0
  87. package/dist/scene/colliders.js +25 -1
  88. package/dist/scene/entityStats.d.ts +4 -0
  89. package/dist/scene/entityStats.js +21 -0
  90. package/dist/scene/entityStore.d.ts +29 -0
  91. package/dist/scene/entityStore.js +60 -0
  92. package/dist/store/observableKeyedStore.d.ts +4 -0
  93. package/dist/store/observableKeyedStore.js +9 -0
  94. package/dist/tactics/fallingGrid.d.ts +2 -2
  95. package/dist/tactics/fallingGrid.js +2 -2
  96. package/dist/ui/gameLayout.d.ts +125 -0
  97. package/dist/ui/gameLayout.js +129 -0
  98. package/dist/ui/orientation.d.ts +39 -0
  99. package/dist/ui/orientation.js +49 -0
  100. package/dist/ui/swingTimer.d.ts +42 -0
  101. package/dist/ui/swingTimer.js +38 -0
  102. package/dist/visibility/spatialIndex.js +34 -20
  103. package/dist/world/environmentSummary.d.ts +11 -2
  104. package/dist/world/environmentSummary.js +11 -1
  105. package/dist/world/features.d.ts +134 -5
  106. package/dist/world/features.js +40 -3
  107. package/dist/world/roads.d.ts +47 -0
  108. package/dist/world/roads.js +146 -0
  109. package/dist/world/streets.d.ts +80 -0
  110. package/dist/world/streets.js +144 -0
  111. package/dist/world/terrain.d.ts +32 -5
  112. package/dist/world/terrain.js +124 -12
  113. package/llms.txt +485 -79
  114. package/package.json +1 -1
@@ -0,0 +1,28 @@
1
+ import type { InputFrame } from "../runtime/inputSnapshot.js";
2
+ /** A player currently joined to a hosted world — the unit a shared-world loop iterates instead of `ctx.player`. */
3
+ export interface ConnectedPlayer {
4
+ userId: string;
5
+ isNew: boolean;
6
+ /** This player's latest {@link InputFrame}, written by the host each time the client sends input; `null` until the first frame arrives. The per-player movement seam reads it in `onTick`. */
7
+ input: InputFrame | null;
8
+ }
9
+ /**
10
+ * The set of players connected to one hosted world. A single-player game uses `ctx.player`; a shared-world loop
11
+ * reads `ctx.game.players` so `onTick` can advance every connected hero, not just the one local player. The host
12
+ * (`HostedGameRunner`) drives `join`/`leave`/`setInput`; game code reads `list`/`ids`/`has`/`count`/`input`.
13
+ */
14
+ export interface ConnectedPlayers {
15
+ join(userId: string, isNew: boolean): void;
16
+ leave(userId: string): boolean;
17
+ has(userId: string): boolean;
18
+ get(userId: string): ConnectedPlayer | null;
19
+ list(): readonly ConnectedPlayer[];
20
+ ids(): readonly string[];
21
+ count(): number;
22
+ /** Record a player's latest input frame (host-driven); a no-op for a player that hasn't joined. */
23
+ setInput(userId: string, frame: InputFrame): void;
24
+ /** This player's latest input frame, or `null` if they haven't joined or no frame has arrived yet. */
25
+ input(userId: string): InputFrame | null;
26
+ }
27
+ /** Build an empty {@link ConnectedPlayers} registry — the host joins/leaves players; the game loop reads them. */
28
+ export declare function createConnectedPlayers(): ConnectedPlayers;
@@ -0,0 +1,21 @@
1
+ /** Build an empty {@link ConnectedPlayers} registry — the host joins/leaves players; the game loop reads them. */
2
+ export function createConnectedPlayers() {
3
+ const players = new Map();
4
+ return {
5
+ join(userId, isNew) {
6
+ players.set(userId, { userId, isNew, input: null });
7
+ },
8
+ leave: (userId) => players.delete(userId),
9
+ has: (userId) => players.has(userId),
10
+ get: (userId) => players.get(userId) ?? null,
11
+ list: () => Array.from(players.values()),
12
+ ids: () => Array.from(players.keys()),
13
+ count: () => players.size,
14
+ setInput(userId, frame) {
15
+ const player = players.get(userId);
16
+ if (player !== undefined)
17
+ player.input = frame;
18
+ },
19
+ input: (userId) => players.get(userId)?.input ?? null,
20
+ };
21
+ }
@@ -7,12 +7,14 @@ import { type AssetCatalog, type ModelAssetRef } from "../scene/assetCatalog.js"
7
7
  import { type EntityStore } from "../scene/entityStore.js";
8
8
  import type { TimeConfig } from "../time/simClock.js";
9
9
  import type { WorldFeature } from "../world/features.js";
10
+ /** World gravity and jump tuning, plus scene-object collision opt-ins, for the game's physics step. */
10
11
  export interface PhysicsConfig {
11
12
  gravity?: number;
12
13
  jumpVelocity?: number;
13
14
  /** Opt placed scene objects into projectile raycasts as blocking cover AABBs. Default false — enabling changes where shots land. */
14
15
  projectileObstacles?: boolean;
15
16
  }
17
+ /** Shape of one named inventory a game declares — slot count, accepted item types, HUD binding. */
16
18
  export interface InventoryDeclaration {
17
19
  slots: number;
18
20
  hud?: string;
@@ -21,20 +23,57 @@ export interface InventoryDeclaration {
21
23
  applyModifiers?: boolean;
22
24
  tier?: StorageTier;
23
25
  }
26
+ /** Hosting mode for a game's multiplayer server: `"persistent"`, or a custom mode with its own options. */
24
27
  export type GameServerConfig = "persistent" | {
25
28
  mode: string;
26
29
  [key: string]: unknown;
27
30
  };
31
+ /** Identity of a player joining or leaving a hosted world — passed to the multiplayer loop hooks. */
32
+ export interface LoopPlayer {
33
+ userId: string;
34
+ isNew: boolean;
35
+ }
36
+ /** Lifecycle hooks a game implements to drive init, per-tick simulation, and player join/leave. */
28
37
  export interface GameLoop<TContext = unknown> {
29
38
  onInit?(ctx: TContext): void;
30
- onNewPlayer?(ctx: TContext): void;
39
+ /** Once per join. `player` identifies the joiner in a hosted world; single-player callers may omit it. */
40
+ onNewPlayer?(ctx: TContext, player?: LoopPlayer): void;
31
41
  onTick?(ctx: TContext, dt: number): void;
42
+ /** Once per leave in a hosted world (never fired single-player). Despawn the player's entities / release their slots here. */
43
+ onPlayerLeave?(ctx: TContext, player: LoopPlayer): void;
44
+ }
45
+ /**
46
+ * Opt-in `ctx.game.*` subsystems. Absent = off: the game doesn't carry (or expose) it, and `ctx.game.<name>`
47
+ * is `undefined`. Present (`true`) builds it. The universal base — `commands`, `events`, `store`, `feed` — is
48
+ * always on and not listed here. This is what keeps core genre-agnostic: a puzzle game isn't handed an MMO's
49
+ * leaderboard/roster/turn plumbing it never asked for.
50
+ */
51
+ export interface GameFeatures {
52
+ /** Owned/captured entity roster (`ctx.game.roster`) — pet/monster collection games. */
53
+ roster?: boolean;
54
+ /** Card pile zones (`ctx.game.cards`) — deckbuilders, card games. */
55
+ cards?: boolean;
56
+ /** Turn/phase loop (`ctx.game.turn`) — turn-based games. */
57
+ turn?: boolean;
58
+ /** Lap/checkpoint race state (`ctx.game.race`) — racers. */
59
+ race?: boolean;
60
+ /** Competitive score tracking (`ctx.game.leaderboard`) — ranked/arcade games. */
61
+ leaderboard?: boolean;
62
+ /** Friends/party/presence/emotes/world-invites (`ctx.game.social`) — social/MMO games. */
63
+ social?: boolean;
64
+ /** Channels + messages (`ctx.game.chat`) — implies `social`; multiplayer/social games. */
65
+ chat?: boolean;
66
+ /** Connected-player set (`ctx.game.players`) — shared-world hosts whose loop ticks every player, not just `ctx.player`. */
67
+ players?: boolean;
32
68
  }
69
+ /** Fully-resolved game description produced by {@link defineGame} — assets, scene, and opted-in subsystems. */
33
70
  export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> {
34
71
  name: string;
35
72
  assets: AssetCatalog<TAssetRef>;
36
73
  multiplayer: TMultiplayer;
37
74
  scene: EntityStore;
75
+ /** Opt-in `ctx.game.*` subsystems beyond the always-on base; omitted systems are `undefined` on `ctx.game`. */
76
+ features?: GameFeatures;
38
77
  world?: WorldFeature;
39
78
  physics?: PhysicsConfig;
40
79
  /** Simulation clock: real→game time scale, selectable speeds, calendar. Exposed as `ctx.time`; the shell feeds its scaled dt to `loop.onTick`. */
@@ -48,7 +87,9 @@ export interface GameDefinition<TAssetRef extends ModelAssetRef = ModelAssetRef,
48
87
  ui?: unknown;
49
88
  loop?: GameLoop<any>;
50
89
  }
90
+ /** Input to {@link defineGame} — a `GameDefinition` with `scene` derived and `assets` optional. */
51
91
  export type GameDefinitionConfig<TAssetRef extends ModelAssetRef = ModelAssetRef, TMultiplayer = unknown> = Omit<GameDefinition<TAssetRef, TMultiplayer>, "scene" | "assets"> & {
52
92
  assets?: AssetCatalog<TAssetRef>;
53
93
  };
94
+ /** Task-first entry point for authoring a game: fills in `scene` and default `assets`, validates `name`. */
54
95
  export declare function defineGame<TAssetRef extends ModelAssetRef, TMultiplayer>(config: GameDefinitionConfig<TAssetRef, TMultiplayer>): GameDefinition<TAssetRef, TMultiplayer>;
@@ -1,5 +1,6 @@
1
1
  import { createAssetCatalog } from "../scene/assetCatalog.js";
2
2
  import { createEntityStore } from "../scene/entityStore.js";
3
+ /** Task-first entry point for authoring a game: fills in `scene` and default `assets`, validates `name`. */
3
4
  export function defineGame(config) {
4
5
  if (config.name.trim().length === 0) {
5
6
  throw new Error("defineGame: name must be non-empty");
@@ -22,6 +22,11 @@ export interface EntityDiedEvent {
22
22
  position: [number, number, number];
23
23
  serverId?: string;
24
24
  }
25
+ /** Request that an entity's rig play a one-shot animation clip bound to `event` in its `animation.oneShots` (e.g. an "attack" swing); the shell resolves the clip and plays it once over the locomotion state. */
26
+ export interface EntityAnimationEvent {
27
+ instanceId: string;
28
+ event: string;
29
+ }
25
30
  export interface LootGrantedEvent {
26
31
  userId: string;
27
32
  drops: {
@@ -164,6 +169,13 @@ export interface AudioPlayEvent {
164
169
  sound: string;
165
170
  at?: readonly [number, number, number];
166
171
  }
172
+ /** Request that the shell's audio engine resume its (browser-gesture-suspended) context; carries no payload. */
173
+ export type AudioResumeEvent = Record<string, never>;
174
+ /** Crossfade the procedural soundtrack to `theme` (null fades out), optionally transposing the incoming theme by `transpose` semitones. */
175
+ export interface AudioMusicEvent {
176
+ theme: string | null;
177
+ transpose?: number;
178
+ }
167
179
  export interface GameEventMap {
168
180
  "entity.died": EntityDiedEvent;
169
181
  "entity.floatText": EntityFloatTextEvent;
@@ -190,6 +202,9 @@ export interface GameEventMap {
190
202
  "possession.swapped": PossessionSwappedEvent;
191
203
  "form.changed": FormChangedEvent;
192
204
  "audio.play": AudioPlayEvent;
205
+ "audio.music": AudioMusicEvent;
206
+ "audio.resume": AudioResumeEvent;
207
+ "entity.animation": EntityAnimationEvent;
193
208
  }
194
209
  export type GameEventHandler<TPayload> = (payload: TPayload) => void;
195
210
  export interface GameEvents<TMap extends GameEventMap = GameEventMap> {
@@ -0,0 +1,25 @@
1
+ /** Structural, DOM-free storage backend: the browser `localStorage` satisfies it, as does a test stub or `null`. The one storage seam core primitives target so persistence code never needs the DOM `Storage` lib. */
2
+ export interface KeyValueStorage {
3
+ getItem(key: string): string | null;
4
+ setItem(key: string, value: string): void;
5
+ removeItem(key: string): void;
6
+ }
7
+ /** The ambient `localStorage` as a {@link KeyValueStorage} when one exists (browser), otherwise `null` (server/tests) — never references the DOM `Storage` type, so it is safe in core. */
8
+ export declare function defaultKeyValueStorage(): KeyValueStorage | null;
9
+ /** A single persisted, mutable cell: read the current value, overwrite it, or read-modify-write with {@link KeyValueStore.update}. Unlike a record book it has no monotonic guard — the value goes wherever you set it. */
10
+ export interface KeyValueStore<T> {
11
+ get(): T;
12
+ set(value: T): void;
13
+ update(mutate: (previous: T) => T): T;
14
+ clear(): void;
15
+ }
16
+ /** Config for {@link createKeyValueStore}: the storage `key`, the `initial` value used before anything is saved, an optional `storage` backend (defaults to `localStorage`, pass `null` for memory-only), and optional custom `serialize`/`deserialize` (default JSON). */
17
+ export interface KeyValueStoreConfig<T> {
18
+ readonly key: string;
19
+ readonly initial: T;
20
+ readonly storage?: KeyValueStorage | null;
21
+ readonly serialize?: (value: T) => string;
22
+ readonly deserialize?: (raw: string) => T;
23
+ }
24
+ /** A lightweight mutable local save cell for single-player state (a credit bank, a settings blob, level progress) — the read-modify-write counterpart to the monotonic `recordBook`. Persists through a {@link KeyValueStorage} (browser `localStorage` by default); corrupt or unavailable storage degrades to in-memory and never throws into a game tick. */
25
+ export declare function createKeyValueStore<T>(config: KeyValueStoreConfig<T>): KeyValueStore<T>;
@@ -0,0 +1,61 @@
1
+ /** The ambient `localStorage` as a {@link KeyValueStorage} when one exists (browser), otherwise `null` (server/tests) — never references the DOM `Storage` type, so it is safe in core. */
2
+ export function defaultKeyValueStorage() {
3
+ try {
4
+ if (typeof globalThis !== "undefined" && "localStorage" in globalThis) {
5
+ return globalThis.localStorage ?? null;
6
+ }
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ return null;
12
+ }
13
+ /** A lightweight mutable local save cell for single-player state (a credit bank, a settings blob, level progress) — the read-modify-write counterpart to the monotonic `recordBook`. Persists through a {@link KeyValueStorage} (browser `localStorage` by default); corrupt or unavailable storage degrades to in-memory and never throws into a game tick. */
14
+ export function createKeyValueStore(config) {
15
+ const storage = config.storage === undefined ? defaultKeyValueStorage() : config.storage;
16
+ const serialize = config.serialize ?? ((value) => JSON.stringify(value));
17
+ const deserialize = config.deserialize ?? ((raw) => JSON.parse(raw));
18
+ const read = () => {
19
+ if (storage === null)
20
+ return config.initial;
21
+ try {
22
+ const raw = storage.getItem(config.key);
23
+ return raw === null ? config.initial : deserialize(raw);
24
+ }
25
+ catch {
26
+ return config.initial;
27
+ }
28
+ };
29
+ let current = read();
30
+ const write = (value) => {
31
+ current = value;
32
+ if (storage === null)
33
+ return;
34
+ try {
35
+ storage.setItem(config.key, serialize(value));
36
+ }
37
+ catch {
38
+ // storage rejected the write; keep the value in memory for this session
39
+ }
40
+ };
41
+ return {
42
+ get: () => current,
43
+ set: (value) => write(value),
44
+ update: (mutate) => {
45
+ const next = mutate(current);
46
+ write(next);
47
+ return next;
48
+ },
49
+ clear: () => {
50
+ current = config.initial;
51
+ if (storage === null)
52
+ return;
53
+ try {
54
+ storage.removeItem(config.key);
55
+ }
56
+ catch {
57
+ // ignore removal failure; in-memory value already reset
58
+ }
59
+ },
60
+ };
61
+ }
@@ -1,11 +1,23 @@
1
+ import { type KeyValueStorage } from "./keyValueStore.js";
1
2
  export interface LevelDescriptor<TLevelConfig> {
2
3
  id: string;
3
4
  config: TLevelConfig;
4
5
  }
6
+ /** A level's star rating, 0 (cleared, no stars) to 3. */
7
+ export type LevelStars = 0 | 1 | 2 | 3;
8
+ /** Persisted per-level outcome: whether it has ever been cleared and the best star rating achieved. */
9
+ export interface LevelRecord {
10
+ cleared: boolean;
11
+ stars: LevelStars;
12
+ }
5
13
  export interface LevelSequenceConfig<TLevelConfig> {
6
14
  levels: readonly LevelDescriptor<TLevelConfig>[];
7
15
  /** Extra attempts granted after the first per level. Defaults to 0 (a single attempt, no retries). */
8
16
  retriesPerLevel?: number;
17
+ /** Persistence key for per-level completion/stars. Omit to keep records in-memory only. */
18
+ key?: string;
19
+ /** Storage backend for the records (browser `localStorage` by default when `key` is set); pass `null` for memory-only. */
20
+ storage?: KeyValueStorage | null;
9
21
  }
10
22
  export type LevelSequenceStatus = "idle" | "playing" | "cleared" | "failed" | "complete";
11
23
  export interface CurrentLevel<TLevelConfig> {
@@ -24,15 +36,25 @@ export interface LevelSequence<TLevelConfig> {
24
36
  status(): LevelSequenceStatus;
25
37
  /** Enters level 0. A no-op on an empty level list, which leaves `status()` at "idle" forever. */
26
38
  start(): void;
27
- /** Marks the current level cleared; call `advance()` to move to the next one. */
28
- clear(): void;
39
+ /** Marks the current level cleared, recording its best star rating; call `advance()` to move to the next one. */
40
+ clear(stars?: LevelStars): void;
29
41
  /** Consumes an attempt on the current level. Returns "retry" while attempts remain, else "failed". */
30
42
  fail(): "retry" | "failed";
31
43
  /** Restarts the current level after a "retry"-eligible failure. Returns false if there was nothing to retry. */
32
44
  retry(): boolean;
33
45
  /** Moves past a cleared level, or to "complete" if it was the last one. Returns false unless status is "cleared". */
34
46
  advance(): boolean;
47
+ /** Jumps directly to an unlocked level and starts it. Returns false for an unknown or still-locked level. */
48
+ select(level: string): boolean;
49
+ /** Whether a level can be entered: the first level always, later ones once the preceding level has a cleared record. */
50
+ isUnlocked(level: string): boolean;
51
+ /** The persisted record for a level, or `null` if it has never been cleared. */
52
+ record(level: string): LevelRecord | null;
53
+ /** A snapshot of every persisted level record, keyed by level id. */
54
+ records(): Readonly<Record<string, LevelRecord>>;
35
55
  reset(): void;
56
+ /** Wipes all persisted completion/star records (and the frontier), returning to a fresh campaign. */
57
+ clearRecords(): void;
36
58
  progress(): LevelSequenceProgress;
37
59
  }
38
60
  /**
@@ -1,3 +1,4 @@
1
+ import { createKeyValueStore } from "./keyValueStore.js";
1
2
  /**
2
3
  * A pure, deterministic level campaign: an ordered list of levels, each with its own opaque config, played
3
4
  * through a `start` → (`clear` → `advance`)* → `complete` happy path, with `fail`/`retry` handling per-level
@@ -7,10 +8,32 @@
7
8
  export function createLevelSequence(config) {
8
9
  const levels = config.levels;
9
10
  const maxAttempts = 1 + Math.max(0, Math.floor(config.retriesPerLevel ?? 0));
11
+ const store = config.key === undefined
12
+ ? null
13
+ : createKeyValueStore({
14
+ key: config.key,
15
+ initial: {},
16
+ storage: config.storage,
17
+ });
10
18
  let index = 0;
11
19
  let attempt = 1;
12
20
  let status = "idle";
13
21
  const clearedIds = [];
22
+ const levelRecords = store === null ? {} : { ...store.get() };
23
+ function persistRecords() {
24
+ store?.set({ ...levelRecords });
25
+ }
26
+ function indexOfLevel(id) {
27
+ return levels.findIndex((level) => level.id === id);
28
+ }
29
+ function isUnlocked(id) {
30
+ const idx = indexOfLevel(id);
31
+ if (idx < 0)
32
+ return false;
33
+ if (idx === 0)
34
+ return true;
35
+ return levelRecords[levels[idx - 1].id]?.cleared === true;
36
+ }
14
37
  function current() {
15
38
  if (status === "idle" || status === "complete")
16
39
  return null;
@@ -30,12 +53,38 @@ export function createLevelSequence(config) {
30
53
  clearedIds.length = 0;
31
54
  status = "playing";
32
55
  },
33
- clear() {
56
+ clear(stars) {
34
57
  if (status !== "playing" || current() === null)
35
58
  return;
36
- clearedIds.push(levels[index].id);
59
+ const id = levels[index].id;
60
+ clearedIds.push(id);
61
+ const previousStars = levelRecords[id]?.stars ?? 0;
62
+ const bestStars = Math.max(previousStars, stars ?? 0);
63
+ levelRecords[id] = { cleared: true, stars: bestStars };
64
+ persistRecords();
37
65
  status = "cleared";
38
66
  },
67
+ select(level) {
68
+ const idx = indexOfLevel(level);
69
+ if (idx < 0 || !isUnlocked(level))
70
+ return false;
71
+ index = idx;
72
+ attempt = 1;
73
+ status = "playing";
74
+ return true;
75
+ },
76
+ isUnlocked,
77
+ record: (level) => levelRecords[level] ?? null,
78
+ records: () => ({ ...levelRecords }),
79
+ clearRecords() {
80
+ for (const id of Object.keys(levelRecords))
81
+ delete levelRecords[id];
82
+ store?.clear();
83
+ index = 0;
84
+ attempt = 1;
85
+ clearedIds.length = 0;
86
+ status = "idle";
87
+ },
39
88
  fail() {
40
89
  if (status !== "playing" || current() === null)
41
90
  return "failed";
@@ -1,3 +1,4 @@
1
+ /** One possible drop in a {@link LootTableDef} — an item or currency, its count range, and its odds. */
1
2
  export interface LootEntry {
2
3
  item?: string;
3
4
  currency?: string;
@@ -7,6 +8,7 @@ export interface LootEntry {
7
8
  /** Per-entry drop probability in `(0, 1]` — required in `"independent"` mode, forbidden in `"weighted"` mode. */
8
9
  chance?: number;
9
10
  }
11
+ /** A named, validated loot table — its roll count, weighted-vs-independent mode, and candidate entries. */
10
12
  export interface LootTableDef {
11
13
  id: string;
12
14
  rolls?: number;
@@ -18,6 +20,7 @@ export interface LootTableDef {
18
20
  mode?: "weighted" | "independent";
19
21
  entries: LootEntry[];
20
22
  }
23
+ /** A resolved loot outcome — one item or currency grant with its rolled count. */
21
24
  export interface Drop {
22
25
  item?: string;
23
26
  currency?: string;
@@ -29,6 +32,7 @@ export interface LootRegistry {
29
32
  roll(id: string, rng?: () => number): Drop[];
30
33
  }
31
34
  export declare function createLootRegistry(): LootRegistry;
35
+ /** Validates a loot table definition and returns it unchanged, for use with {@link createLootRegistry}. */
32
36
  export declare function lootTable(def: LootTableDef): LootTableDef;
33
37
  export declare function grantDrops(drops: Drop[], appliers: {
34
38
  putItem: (itemId: string, count: number) => unknown;
@@ -82,6 +82,7 @@ export function createLootRegistry() {
82
82
  },
83
83
  };
84
84
  }
85
+ /** Validates a loot table definition and returns it unchanged, for use with {@link createLootRegistry}. */
85
86
  export function lootTable(def) {
86
87
  assertValidDef(def);
87
88
  return def;
@@ -0,0 +1,8 @@
1
+ /** The two one-shot events the shell fires automatically for an entity, from `combat.hitReaction` and `entity.died`. */
2
+ export declare const AUTO_ONE_SHOT_EVENTS: readonly ["hit", "death"];
3
+ /**
4
+ * Resolves the clip name a one-shot `event` should play from a model's `animation.oneShots` map, or `null`
5
+ * if the event isn't bound. A `string[]` binding picks a variant by `roll` (a value in `[0, 1)`), so combat
6
+ * can vary attack swings. Pure and deterministic given `roll` — the shell supplies the randomness.
7
+ */
8
+ export declare function resolveOneShotClip(oneShots: Record<string, string | readonly string[]> | undefined, event: string, roll: number): string | null;
@@ -0,0 +1,18 @@
1
+ /** The two one-shot events the shell fires automatically for an entity, from `combat.hitReaction` and `entity.died`. */
2
+ export const AUTO_ONE_SHOT_EVENTS = ["hit", "death"];
3
+ /**
4
+ * Resolves the clip name a one-shot `event` should play from a model's `animation.oneShots` map, or `null`
5
+ * if the event isn't bound. A `string[]` binding picks a variant by `roll` (a value in `[0, 1)`), so combat
6
+ * can vary attack swings. Pure and deterministic given `roll` — the shell supplies the randomness.
7
+ */
8
+ export function resolveOneShotClip(oneShots, event, roll) {
9
+ const spec = oneShots?.[event];
10
+ if (spec === undefined)
11
+ return null;
12
+ if (typeof spec === "string")
13
+ return spec;
14
+ if (spec.length === 0)
15
+ return null;
16
+ const index = Math.min(spec.length - 1, Math.max(0, Math.floor(roll * spec.length)));
17
+ return spec[index] ?? null;
18
+ }
@@ -1,6 +1,9 @@
1
1
  import type { AudioBusDef, SoundDef } from "../audio/audioFalloff.js";
2
+ import type { MusicTheme } from "../audio/music.js";
3
+ import type { PostProcessingConfig } from "../render/postProcessing.js";
2
4
  import type { TouchControlsConfig } from "../input/touchScheme.js";
3
5
  import type { GameSettingsConfig } from "../settings/settingsModel.js";
6
+ import type { GameOrientation } from "../ui/orientation.js";
4
7
  import type { HudPlatform, HudViewportConfig } from "../ui/hudScale.js";
5
8
  import type { PositionedPrompt } from "../interaction/proximityPrompt.js";
6
9
  import type { CatalogEntityRole, GameContext, GameContextContent } from "../runtime/gameContext.js";
@@ -455,6 +458,12 @@ export interface GameCameraConfig {
455
458
  /** Vertical look clamp (three.js polar angle, radians): 0 = top-down over the head, PI/2 = level, >PI/2 = look up from below. Widen for top-down or vertical aim; unset keeps the standard chase feel. */
456
459
  minPolarAngle?: number;
457
460
  maxPolarAngle?: number;
461
+ /** Spring-arm occlusion for the orbit rig: pull the camera in past walls/terrain so it never clips inside geometry. Off unless `enabled`. */
462
+ collision?: {
463
+ enabled?: boolean;
464
+ padding?: number;
465
+ minTargetDistance?: number;
466
+ };
458
467
  }
459
468
  export interface EntitySpriteConfig {
460
469
  url: string;
@@ -492,6 +501,21 @@ export interface VoxelCollisionConfig {
492
501
  /** Tallest ledge walked up without jumping. Default 0.6. */
493
502
  stepHeight?: number;
494
503
  }
504
+ /** Movement-state clip set for `ModelAnimationConfig.states`: the shell reads the entity's live speed each frame and crossfades between these clips, so a walking mob animates without any game-side driver. */
505
+ export interface ModelAnimationStates {
506
+ /** Clip name while the entity is stationary. */
507
+ idle: string;
508
+ /** Clip name while the entity is moving. */
509
+ walk: string;
510
+ /** Clip name above `runSpeed`; omit to keep `walk` at any speed. */
511
+ run?: string;
512
+ /** Speed (world units/sec) above which the entity counts as moving. Default 0.5. */
513
+ walkSpeed?: number;
514
+ /** Speed above which `run` plays when provided. Default 6. */
515
+ runSpeed?: number;
516
+ /** Crossfade duration in seconds when the state changes. Default 0.2. */
517
+ fadeSec?: number;
518
+ }
495
519
  /** Rig playback for a `ModelConfig`'s GLTF animation clips — looping idles, one-shots, and held poses. */
496
520
  export interface ModelAnimationConfig {
497
521
  /** Clip name to play; defaults to the GLB's first clip. */
@@ -504,6 +528,15 @@ export interface ModelAnimationConfig {
504
528
  paused?: boolean;
505
529
  /** Seek the clip to this time in seconds; combine with `paused: true` to hold a specific pose ("pose library" usage). */
506
530
  time?: number;
531
+ /** Speed-driven idle/walk/run clip switching, crossfaded by the shell from the entity's live movement; overrides `clip` while set. */
532
+ states?: ModelAnimationStates;
533
+ /**
534
+ * One-shot clips keyed by event name, each played once over the locomotion state then released back to it.
535
+ * Reserved keys `hit` and `death` auto-fire on this entity's `combat.hitReaction` / `entity.died`; any other
536
+ * key fires when the game emits `entity.animation` (`ctx.game.playEntityAnimation(instanceId, event)`). A
537
+ * `string[]` picks a random variant per trigger. `death` clamps on its final frame instead of returning.
538
+ */
539
+ oneShots?: Record<string, string | readonly string[]>;
507
540
  }
508
541
  /** Per-entity PBR material override (#151.3) applied to every `MeshStandardMaterial` in the model's cloned scene graph. */
509
542
  export interface ModelMaterialOverride {
@@ -513,9 +546,24 @@ export interface ModelMaterialOverride {
513
546
  emissive?: string;
514
547
  emissiveIntensity?: number;
515
548
  }
549
+ /** Parents a prop/weapon model to a named bone or node on the host model's rig — a sword on `handslot.r`, a spellbook offhand — following the bone's animated transform each frame. */
550
+ export interface ModelAttachment {
551
+ /** Bone or node name in the rig to parent to (e.g. `"handslot.r"`). */
552
+ slot: string;
553
+ /** The attached model — a catalog asset id or an inline `ModelConfig`. */
554
+ model: string | ModelConfig;
555
+ /** Local position offset at the bone, in the attached model's own space. */
556
+ position?: [number, number, number];
557
+ /** Local Euler rotation (radians) applied at the bone. */
558
+ rotation?: [number, number, number];
559
+ /** Uniform scale of the attached model at the bone. Default 1. */
560
+ scale?: number;
561
+ }
516
562
  export interface ModelConfig {
517
563
  url: string;
518
564
  scale?: number;
565
+ /** Normalize the rendered model to this world-unit height: the shell measures the loaded scene's bounding box, scales it so its height matches, and grounds its lowest point at the placement Y. Composes with `scale` as a multiplier. */
566
+ targetHeight?: number;
519
567
  y?: number;
520
568
  /** How the model registers on its placement point. `"center"` (default) horizontally centers the measured footprint on the point and ground-snaps its lowest vertex to the point's Y — the correct behavior for corner-pivot modular kits. `"origin"` renders at the raw GLB origin (legacy). */
521
569
  anchor?: "center" | "origin";
@@ -525,6 +573,8 @@ export interface ModelConfig {
525
573
  material?: ModelMaterialOverride;
526
574
  /** Plays a GLTF animation clip on the model when the source has any (skinned or not); omit to render the rig's bind pose. */
527
575
  animation?: ModelAnimationConfig;
576
+ /** Props/weapons parented to named bones on this model's rig; each follows its bone through animation. */
577
+ attachments?: readonly ModelAttachment[];
528
578
  }
529
579
  export interface ObjectStyle {
530
580
  color?: string;
@@ -540,6 +590,14 @@ export interface DirectionalLightingConfig {
540
590
  intensity?: number;
541
591
  position: readonly [number, number, number];
542
592
  castShadow?: boolean;
593
+ /** Shadow map resolution in px (square) when `castShadow`. Higher = crisper, costlier. Default 1024. */
594
+ shadowMapSize?: number;
595
+ /** Half-extent of the orthographic shadow camera in world units — sized to the shadowed area. Default 40. */
596
+ shadowCameraSize?: number;
597
+ /** Shadow depth bias to fight acne. Default -0.0004. */
598
+ shadowBias?: number;
599
+ /** Shadow normal bias to fight peter-panning. Default 0.02. */
600
+ shadowNormalBias?: number;
543
601
  }
544
602
  export interface HemisphereLightingConfig {
545
603
  skyColor?: string;
@@ -592,7 +650,7 @@ export interface MovementCommitFrame {
592
650
  export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEntity = never, TRenderObject = never> {
593
651
  game: GameDefinition;
594
652
  content: GameContextContent;
595
- loop: Required<GameLoop<GameContext>>;
653
+ loop: Required<Omit<GameLoop<GameContext>, "onPlayerLeave">> & Pick<GameLoop<GameContext>, "onPlayerLeave">;
596
654
  GameUI: TUi;
597
655
  /** Which shell mount to use. Default `"3d"` (canvas, camera rig, pointer, world rendering). `"hud"` mounts no 3D canvas, camera rig, or pointer — the game is `GameUI` plus the command/input loop, for board/card/menu games. */
598
656
  presentation?: "3d" | "hud";
@@ -624,21 +682,24 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
624
682
  pointer?: PointerConfig;
625
683
  /** Touch controls on coarse-pointer devices. Unset derives a scheme from `input` (virtual joystick for movement actions, on-screen buttons for the rest); a config refines it with gestures and curated buttons; `false` opts out. */
626
684
  touch?: TouchControlsConfig | false;
627
- /** Preferred phone orientation. When a coarse-pointer device is held the other way the shell shows a dismissible rotate hint; purely advisory, never blocks play. */
628
- orientation?: "landscape" | "portrait";
685
+ /** Phone orientation contract. Legacy `"landscape"`/`"portrait"` stays advisory (a dismissible rotate hint). The object form `{ mobile: "landscape-required" }` is strict — the shell shows an engine-owned rotate screen and blocks gameplay until the device is turned. See `GameOrientation`. */
686
+ orientation?: GameOrientation;
629
687
  /** Where the game is meant to be played. Default `["web", "mobile"]` — design-resolution HUD fit is on for every game: `HudCanvas` auto-scales from `hudFit.designSize` down to the live viewport, so the desktop layout shrinks to fit a phone instead of overflowing it. Declare `["web"]` to opt a desktop-only game out (compact displays fall back to the legacy fixed 0.85 zoom). */
630
688
  platforms?: readonly HudPlatform[];
631
689
  /** HUD design resolution + scale clamps (default 1600×900, scale 0.4–1). `mobile` overrides tune the phone fit separately — the same resolution system drives desktop UI-scale and phone shrink. */
632
690
  hudFit?: HudViewportConfig;
633
- /** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles. */
691
+ /** Opt in to world-space health bars floating over non-local entities that carry the stat. `roles` restricts bars to entities whose catalog entry declares one of the given roles; `maxDistance` hides bars beyond this many world units from the player (default 60). */
634
692
  worldHealthBars?: boolean | {
635
693
  statId?: string;
636
694
  roles?: readonly CatalogEntityRole[];
695
+ maxDistance?: number;
637
696
  };
638
- /** Sound catalog + mix buses (music/sfx/ambient/…) the shell's Web Audio glue plays from. Catalog-first — no per-game audio wiring. */
697
+ /** Sound catalog + mix buses (music/sfx/ambient/…) the shell's Web Audio glue plays from. Catalog-first — no per-game audio wiring. `sounds` may be sample (`url`) or procedural (`synth`); `music` holds procedural themes crossfaded via `ctx.game.audio.music(id)`. */
639
698
  audio?: {
640
699
  sounds: Record<string, SoundDef>;
641
700
  buses?: Record<string, AudioBusDef>;
701
+ music?: Record<string, MusicTheme>;
702
+ musicBus?: string;
642
703
  };
643
704
  /** Continuous positional emitter keyed by entity kind name: while a matching entity exists, the shell plays and repositions the mapped `audio.sounds` id (looping engine hum, campfire crackle, footstep loop) with listener-distance falloff. */
644
705
  entitySounds?: Record<string, string>;
@@ -654,6 +715,8 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
654
715
  lighting?: LightingConfig;
655
716
  /** Generic background/sky/fog (#207.6), applied for any world kind including a custom `environment` component. */
656
717
  backdrop?: BackdropConfig;
718
+ /** Declarative post-processing chain (AO/bloom/tone-map/grade). When set, the shell mounts an EffectComposer and owns the render; absent leaves the renderer drawing directly (unchanged). */
719
+ postProcessing?: PostProcessingConfig;
657
720
  /** F2 debug overlay (frame/sim timing, logs, backend latency, keybinds, live tunables). On for every game by default; `false` disables the toggle. */
658
721
  devtools?: boolean;
659
722
  /** Player settings menu. Auto-mounted for every game (Sound / Graphics / Gameplay / Controls); unset uses the defaults, `false` opts out. Add game-specific rows via `extra`, switch overlay/full-page via `mode`, or swap the gear for compact on-screen buttons via `surface: "quick"`. */
@@ -1,8 +1,6 @@
1
- export interface RecordStorage {
2
- getItem(key: string): string | null;
3
- setItem(key: string, value: string): void;
4
- removeItem(key: string): void;
5
- }
1
+ import type { KeyValueStorage } from "./keyValueStore.js";
2
+ /** The structural storage backend a record book persists through — an alias of the shared {@link KeyValueStorage} seam (browser `localStorage`, a test stub, or `null`). */
3
+ export type RecordStorage = KeyValueStorage;
6
4
  export type RecordDirection = "lower" | "higher";
7
5
  export interface RecordBookConfig<K extends string> {
8
6
  readonly key: string;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export * from "./runtime/save.js";
2
2
  export * from "./runtime/snapshot.js";
3
+ export * from "./runtime/worldSnapshot.js";
4
+ export * from "./runtime/worldReplication.js";
5
+ export * from "./game/connectedPlayers.js";
6
+ export * from "./runtime/hostedGameRunner.js";
7
+ export * from "./runtime/hostedWorldSession.js";
8
+ export * from "./runtime/worldMirror.js";
9
+ export * from "./runtime/worldChannel.js";
3
10
  export * from "./runtime/commandRunner.js";
4
11
  export * from "./runtime/gameRuntime.js";
5
12
  export * from "./runtime/adapter.js";