@jgengine/core 0.26.0 → 0.28.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 (143) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/ai/policeResponse.d.ts +253 -0
  3. package/dist/ai/policeResponse.js +421 -0
  4. package/dist/anim/actionLayer.d.ts +47 -0
  5. package/dist/anim/actionLayer.js +63 -0
  6. package/dist/anim/ikSolver.d.ts +14 -0
  7. package/dist/anim/ikSolver.js +37 -0
  8. package/dist/anim/locomotionGraph.d.ts +58 -3
  9. package/dist/anim/locomotionGraph.js +142 -15
  10. package/dist/anim/ragdollRig.d.ts +53 -0
  11. package/dist/anim/ragdollRig.js +132 -0
  12. package/dist/anim/skeletonStandard.d.ts +140 -0
  13. package/dist/anim/skeletonStandard.js +326 -0
  14. package/dist/combat/damageRuntime.d.ts +14 -1
  15. package/dist/combat/damageRuntime.js +62 -4
  16. package/dist/combat/death.js +7 -3
  17. package/dist/combat/deathReason.d.ts +7 -1
  18. package/dist/combat/effects.d.ts +25 -0
  19. package/dist/combat/effects.js +16 -0
  20. package/dist/combat/statusRuntime.d.ts +2 -0
  21. package/dist/combat/statusRuntime.js +1 -0
  22. package/dist/economy/loanBook.d.ts +182 -0
  23. package/dist/economy/loanBook.js +303 -0
  24. package/dist/economy/market.d.ts +161 -0
  25. package/dist/economy/market.js +248 -0
  26. package/dist/economy/transactionLedger.d.ts +162 -0
  27. package/dist/economy/transactionLedger.js +301 -0
  28. package/dist/editor/creatorStorage.js +3 -1
  29. package/dist/editor/simulation.d.ts +5 -2
  30. package/dist/editor/simulation.js +29 -19
  31. package/dist/game/cameraConfig.d.ts +14 -0
  32. package/dist/game/clipRoles.d.ts +5 -4
  33. package/dist/game/clipRoles.js +34 -4
  34. package/dist/game/events.d.ts +27 -0
  35. package/dist/game/gameSave.d.ts +142 -0
  36. package/dist/game/gameSave.js +174 -0
  37. package/dist/game/playableGame.d.ts +74 -6
  38. package/dist/game/saveSlotIndex.d.ts +54 -0
  39. package/dist/game/saveSlotIndex.js +71 -0
  40. package/dist/game/saveSlots.d.ts +2 -0
  41. package/dist/game/saveSlots.js +1 -1
  42. package/dist/game/saveStore.d.ts +8 -0
  43. package/dist/game/saveStore.js +16 -0
  44. package/dist/gameplay.d.ts +17 -2
  45. package/dist/gameplay.js +15 -0
  46. package/dist/inventory/carryLoad.d.ts +48 -0
  47. package/dist/inventory/carryLoad.js +44 -0
  48. package/dist/inventory/equipment.d.ts +95 -0
  49. package/dist/inventory/equipment.js +169 -0
  50. package/dist/inventory/inventoryModel.d.ts +15 -6
  51. package/dist/inventory/inventoryModel.js +54 -5
  52. package/dist/inventory/shapedGrid.d.ts +26 -0
  53. package/dist/inventory/shapedGrid.js +47 -0
  54. package/dist/loot/lootGen.d.ts +186 -0
  55. package/dist/loot/lootGen.js +437 -0
  56. package/dist/loot/lootWeapon.d.ts +35 -0
  57. package/dist/loot/lootWeapon.js +70 -0
  58. package/dist/meta/changelog.d.ts +1 -1
  59. package/dist/meta/changelog.js +156 -1
  60. package/dist/mission/compositions.d.ts +85 -0
  61. package/dist/mission/compositions.js +130 -0
  62. package/dist/mission/mission.d.ts +435 -0
  63. package/dist/mission/mission.js +770 -0
  64. package/dist/mission/triggers.d.ts +61 -0
  65. package/dist/mission/triggers.js +108 -0
  66. package/dist/nav/laneGraph.d.ts +178 -0
  67. package/dist/nav/laneGraph.js +671 -0
  68. package/dist/nav/traffic.d.ts +222 -0
  69. package/dist/nav/traffic.js +918 -0
  70. package/dist/physics/kinematicVehicle.d.ts +36 -0
  71. package/dist/physics/kinematicVehicle.js +15 -1
  72. package/dist/physics/vehicleDamage.d.ts +136 -0
  73. package/dist/physics/vehicleDamage.js +214 -0
  74. package/dist/physics/vehicleParts.d.ts +62 -0
  75. package/dist/physics/vehicleParts.js +65 -0
  76. package/dist/progression/gearScore.d.ts +56 -0
  77. package/dist/progression/gearScore.js +59 -0
  78. package/dist/progression/skillTree.d.ts +129 -0
  79. package/dist/progression/skillTree.js +265 -0
  80. package/dist/progression/xpCurve.d.ts +60 -0
  81. package/dist/progression/xpCurve.js +141 -0
  82. package/dist/runtime/context/combat.js +11 -0
  83. package/dist/scene/builtinSceneKinds.js +2 -0
  84. package/dist/settings/accessibilitySettings.d.ts +81 -0
  85. package/dist/settings/accessibilitySettings.js +152 -0
  86. package/dist/settings/graphicsProfile.d.ts +3 -0
  87. package/dist/settings/graphicsProfile.js +4 -0
  88. package/dist/settings/settingsModel.d.ts +1 -1
  89. package/dist/settings/settingsModel.js +1 -0
  90. package/dist/sim/nearAgents.d.ts +8 -1
  91. package/dist/sim/nearAgents.js +34 -17
  92. package/dist/sim/offScreen.d.ts +2 -1
  93. package/dist/sim/offScreen.js +14 -3
  94. package/dist/sim/population.d.ts +147 -0
  95. package/dist/sim/population.js +182 -0
  96. package/dist/sim/queue.d.ts +90 -0
  97. package/dist/sim/queue.js +171 -0
  98. package/dist/sim/rides.d.ts +287 -0
  99. package/dist/sim/rides.js +471 -0
  100. package/dist/sim/routine.js +19 -11
  101. package/dist/ui/loadingTips.d.ts +41 -0
  102. package/dist/ui/loadingTips.js +64 -0
  103. package/dist/ui/menuNav.d.ts +79 -0
  104. package/dist/ui/menuNav.js +147 -0
  105. package/dist/ui/menuTransition.d.ts +50 -0
  106. package/dist/ui/menuTransition.js +67 -0
  107. package/dist/ui.d.ts +2 -0
  108. package/dist/ui.js +2 -0
  109. package/dist/vfx/effectLibrary.d.ts +98 -0
  110. package/dist/vfx/effectLibrary.js +181 -0
  111. package/dist/vfx/particleBudget.d.ts +40 -0
  112. package/dist/vfx/particleBudget.js +39 -0
  113. package/dist/vfx/particleDirector.d.ts +16 -0
  114. package/dist/vfx/particleDirector.js +18 -0
  115. package/dist/world/authoredSimulation.js +7 -1
  116. package/dist/world/captureCamera.d.ts +30 -0
  117. package/dist/world/captureCamera.js +19 -0
  118. package/dist/world/decalKind.d.ts +48 -0
  119. package/dist/world/decalKind.js +86 -0
  120. package/dist/world/features.d.ts +18 -5
  121. package/dist/world/features.js +1 -0
  122. package/dist/world/grassKind.d.ts +5 -0
  123. package/dist/world/grassKind.js +3 -0
  124. package/dist/world/terraform.d.ts +8 -0
  125. package/dist/world/terrain.d.ts +1 -0
  126. package/dist/world/terrain.js +2 -0
  127. package/dist/world.d.ts +7 -2
  128. package/dist/world.js +5 -0
  129. package/package.json +1 -1
  130. package/skills/jgengine/api.md +2 -2
  131. package/skills/jgengine-combat/SKILL.md +9 -1
  132. package/skills/jgengine-combat/api.md +5 -4
  133. package/skills/jgengine-combat/reference.md +4 -0
  134. package/skills/jgengine-gameplay/SKILL.md +86 -0
  135. package/skills/jgengine-gameplay/api.md +485 -16
  136. package/skills/jgengine-gameplay/capabilities.md +131 -4
  137. package/skills/jgengine-gameplay/reference-systems.md +118 -0
  138. package/skills/jgengine-world/SKILL.md +8 -0
  139. package/skills/jgengine-world/api.md +243 -11
  140. package/skills/jgengine-world/capabilities.md +61 -1
  141. package/skills/jgengine-world/recipes/character-animation.md +25 -2
  142. package/skills/jgengine-world/recipes/vehicle-feel.md +5 -0
  143. package/skills/jgengine-world/reference.md +1 -1
@@ -0,0 +1,142 @@
1
+ import { type SaveAutoConfig, type SaveBackend, type SaveStatus, type SaveTimers } from "./saveStore.js";
2
+ /** Current save-format version of {@link GameSaveDocument}. Bump it with {@link GameSaveConfig.migrate} when the shape changes. */
3
+ export declare const GAME_SAVE_VERSION = 1;
4
+ /**
5
+ * One stateful system inside a game save: a stable `name` (its section key in the document) and the
6
+ * `snapshot()`/`restore()` pair every Lane B module exposes. The name is the contract — a document
7
+ * written by one build restores into the next build's system with the same name. `reset` runs when a
8
+ * loaded slot has no section for this system (a fresh slot), so switching to an empty slot starts a
9
+ * new game instead of leaving the previous one's state behind; a system without it is left as-is.
10
+ */
11
+ export interface GameSaveSystem<T = unknown> {
12
+ readonly name: string;
13
+ snapshot(): T;
14
+ restore(next: T): void;
15
+ reset?(): void;
16
+ }
17
+ /** The persisted cursor of the world's shared injected RNG, plus the seed that produced it. */
18
+ export interface GameSaveRngState {
19
+ /** Seed the stream was created from, so a reload can rebuild the same sequence. */
20
+ seed: string | number;
21
+ /** The stream's cursor, or `null` for a generator that carries no state. */
22
+ cursor: number | null;
23
+ }
24
+ /**
25
+ * The one save document: a format `version`, the game and clock metadata a save-select menu shows,
26
+ * the world's shared RNG cursor, and one section per registered system. There is deliberately no
27
+ * second per-system format — every system's state lives under `systems[<name>]`.
28
+ */
29
+ export interface GameSaveDocument {
30
+ /** {@link GAME_SAVE_VERSION} this document was written at. */
31
+ version: number;
32
+ /** Stable game id, so a document names the game it belongs to. */
33
+ game: string;
34
+ /** Wall-clock time (ms) the document was captured. */
35
+ savedAt: number;
36
+ /** World clock (game seconds) at capture. */
37
+ time: number;
38
+ /** Seconds played at capture. */
39
+ playtime: number;
40
+ /** The shared RNG's seed and cursor, or `null` when the game injects no shared stream. */
41
+ rng: GameSaveRngState | null;
42
+ /** One entry per registered system, keyed by its name. */
43
+ systems: Record<string, unknown>;
44
+ }
45
+ /** The world's shared injected RNG: its `seed` and the live `stream` whose cursor is persisted. */
46
+ export interface GameSaveRng {
47
+ readonly seed: string | number;
48
+ readonly stream: () => number;
49
+ }
50
+ /** How a {@link createGameSave} is wired. Storage is the narrow injected {@link SaveBackend}, never `localStorage` welded in. */
51
+ export interface GameSaveConfig {
52
+ /** Where the document is written — {@link localSaveBackend}, {@link memorySaveBackend}, or a remote/cloud backend. */
53
+ readonly backend: SaveBackend;
54
+ /** Stable game id, stored in every document. */
55
+ readonly game: string;
56
+ /** Systems whose sections make up the document. More can be added later with `register`. */
57
+ readonly systems?: readonly GameSaveSystem[];
58
+ /** Save-format version. Default {@link GAME_SAVE_VERSION}. */
59
+ readonly version?: number;
60
+ /** The world's shared injected RNG. Its seed and cursor are stored so the same sequence resumes. */
61
+ readonly rng?: GameSaveRng;
62
+ /** World clock reader, in game seconds. Default `() => 0`. */
63
+ readonly clock?: () => number;
64
+ /** Playtime reader, in seconds. Default `() => 0`. */
65
+ readonly playtime?: () => number;
66
+ /** Active slot. Default `"default"`. */
67
+ readonly slot?: string;
68
+ /** Storage namespace. Default `game-save:<game>`. */
69
+ readonly key?: string;
70
+ /**
71
+ * Autosave cadence, as caller policy: `false` (default) writes only on an explicit `save()`;
72
+ * `true` debounces on the default window; an object sets its own debounce and ceiling.
73
+ */
74
+ readonly autosave?: boolean | SaveAutoConfig;
75
+ /** Injectable clock (ms) for `savedAt`. Default `Date.now`. */
76
+ readonly now?: () => number;
77
+ /** Injectable timers for autosave. Default the ambient `setTimeout`/`clearTimeout`. */
78
+ readonly timers?: SaveTimers;
79
+ /**
80
+ * Upgrade an older document to the current shape. Receives the stored document and the version it
81
+ * was written at (`0` for a pre-versioned payload) and returns a document at {@link GameSaveConfig.version}.
82
+ */
83
+ readonly migrate?: (document: unknown, fromVersion: number) => GameSaveDocument;
84
+ /** Notified on any backend read/write failure or a restored document written by another world. */
85
+ readonly onError?: (error: unknown) => void;
86
+ /** Register a devtools probe under this name so the overlay shows the slot, status and system count. */
87
+ readonly devtoolsName?: string;
88
+ }
89
+ /**
90
+ * A pluggable-backend game save: one versioned document holding a section per system, with named
91
+ * slots, caller-policy autosave, migration and a state digest. `capture()` builds the document from
92
+ * the live systems; `save()`/`touch()` write it (directly or on the autosave debounce); `load()`
93
+ * reads a slot and applies every section back through its system's `restore`. The shared RNG's
94
+ * cursor rides along, so the seed survives the save and a reloaded run continues the same sequence.
95
+ */
96
+ export interface GameSave {
97
+ /** Register a system after construction; returns an unregister fn. Throws on a duplicate name. */
98
+ register(system: GameSaveSystem): () => void;
99
+ /** Build the current document from every registered system. Does not write. */
100
+ capture(): GameSaveDocument;
101
+ /** Write the live state to the active slot now. */
102
+ save(): Promise<void>;
103
+ /** Read the active slot and apply every section to its system. */
104
+ load(): Promise<GameSaveDocument>;
105
+ /** Flush the current slot, switch, and load the next one. */
106
+ switchSlot(slot: string): Promise<GameSaveDocument>;
107
+ /**
108
+ * Read a slot's document without applying it to the live systems — the save-select menu's data
109
+ * source. Returns `null` for an empty slot.
110
+ */
111
+ peek(slot: string): Promise<GameSaveDocument | null>;
112
+ /** Slots this save has written. */
113
+ slots(): Promise<string[]>;
114
+ slot(): string;
115
+ status(): SaveStatus;
116
+ /** Monotonic change counter — bumps on any value or status change. */
117
+ revision(): number;
118
+ /** Capture the live state and mark it dirty; a caller-policy autosave schedules the write. */
119
+ touch(): GameSaveDocument;
120
+ /** One hash over every section, the clock and the RNG cursor — what a determinism test compares. */
121
+ digest(): string;
122
+ /** Per-system section hashes, so a caller can see which systems the digest covers. */
123
+ digests(): Record<string, string>;
124
+ subscribe(listener: () => void): () => void;
125
+ dispose(): void;
126
+ }
127
+ /**
128
+ * A stable hash over the state-bearing part of a document — every system section, the world clock
129
+ * and the shared RNG cursor — excluding the wall-clock `savedAt`, which is not state.
130
+ *
131
+ * @capability game-save-digest hash a save document's state for a determinism test or a diff
132
+ */
133
+ export declare function gameSaveDigest(document: GameSaveDocument): string;
134
+ /**
135
+ * Create a {@link GameSave}. Register every stateful system once; `capture()` writes one versioned
136
+ * document with a section per system, `save()`/`touch()` persist it through the injected backend
137
+ * (autosave cadence is the caller's), `load()` restores every section and the shared RNG cursor, and
138
+ * `digest()`/`digests()` give a determinism test a hash over the whole state.
139
+ *
140
+ * @capability game-save one versioned save document with per-system sections, named slots, caller-policy autosave, migration and a state digest
141
+ */
142
+ export declare function createGameSave(config: GameSaveConfig): GameSave;
@@ -0,0 +1,174 @@
1
+ import { devtools } from "../devtools/devtools.js";
2
+ import { hashString, restoreRng, rngStateOf } from "../random/rng.js";
3
+ import { createSaveStore, } from "./saveStore.js";
4
+ /** Current save-format version of {@link GameSaveDocument}. Bump it with {@link GameSaveConfig.migrate} when the shape changes. */
5
+ export const GAME_SAVE_VERSION = 1;
6
+ function stableStringify(value) {
7
+ if (value === null || value === undefined)
8
+ return "null";
9
+ const type = typeof value;
10
+ if (type === "number")
11
+ return Number.isFinite(value) ? JSON.stringify(value) : "null";
12
+ if (type === "boolean" || type === "string")
13
+ return JSON.stringify(value);
14
+ if (Array.isArray(value))
15
+ return `[${value.map(stableStringify).join(",")}]`;
16
+ if (type === "object") {
17
+ const record = value;
18
+ const keys = Object.keys(record).filter((key) => record[key] !== undefined).sort();
19
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(",")}}`;
20
+ }
21
+ return "null";
22
+ }
23
+ function digestOf(value) {
24
+ return hashString(stableStringify(value)).toString(36);
25
+ }
26
+ /**
27
+ * A stable hash over the state-bearing part of a document — every system section, the world clock
28
+ * and the shared RNG cursor — excluding the wall-clock `savedAt`, which is not state.
29
+ *
30
+ * @capability game-save-digest hash a save document's state for a determinism test or a diff
31
+ */
32
+ export function gameSaveDigest(document) {
33
+ return digestOf({
34
+ version: document.version,
35
+ game: document.game,
36
+ time: document.time,
37
+ playtime: document.playtime,
38
+ rng: document.rng,
39
+ systems: document.systems,
40
+ });
41
+ }
42
+ function emptyDocument(config, version) {
43
+ return {
44
+ version,
45
+ game: config.game,
46
+ savedAt: (config.now ?? Date.now)(),
47
+ time: config.clock?.() ?? 0,
48
+ playtime: config.playtime?.() ?? 0,
49
+ rng: null,
50
+ systems: {},
51
+ };
52
+ }
53
+ /**
54
+ * Create a {@link GameSave}. Register every stateful system once; `capture()` writes one versioned
55
+ * document with a section per system, `save()`/`touch()` persist it through the injected backend
56
+ * (autosave cadence is the caller's), `load()` restores every section and the shared RNG cursor, and
57
+ * `digest()`/`digests()` give a determinism test a hash over the whole state.
58
+ *
59
+ * @capability game-save one versioned save document with per-system sections, named slots, caller-policy autosave, migration and a state digest
60
+ */
61
+ export function createGameSave(config) {
62
+ const version = config.version ?? GAME_SAVE_VERSION;
63
+ const systems = new Map();
64
+ function add(system) {
65
+ if (systems.has(system.name))
66
+ throw new Error(`game-save: system "${system.name}" is already registered`);
67
+ systems.set(system.name, system);
68
+ }
69
+ for (const system of config.systems ?? [])
70
+ add(system);
71
+ function capture() {
72
+ const sections = {};
73
+ for (const [name, system] of systems)
74
+ sections[name] = system.snapshot();
75
+ return {
76
+ version,
77
+ game: config.game,
78
+ savedAt: (config.now ?? Date.now)(),
79
+ time: config.clock?.() ?? 0,
80
+ playtime: config.playtime?.() ?? 0,
81
+ rng: config.rng === undefined
82
+ ? null
83
+ : { seed: config.rng.seed, cursor: rngStateOf(config.rng.stream) },
84
+ systems: sections,
85
+ };
86
+ }
87
+ function apply(document) {
88
+ const sections = document.systems ?? {};
89
+ for (const [name, system] of systems) {
90
+ if (Object.hasOwn(sections, name))
91
+ system.restore(sections[name]);
92
+ else
93
+ system.reset?.();
94
+ }
95
+ if (config.rng === undefined || document.rng === null)
96
+ return;
97
+ if (document.rng.seed !== config.rng.seed) {
98
+ config.onError?.(new Error(`game-save: document was written by world seed "${String(document.rng.seed)}", not "${String(config.rng.seed)}" — RNG cursor not restored`));
99
+ return;
100
+ }
101
+ if (document.rng.cursor !== null)
102
+ restoreRng(config.rng.stream, document.rng.cursor);
103
+ }
104
+ const store = createSaveStore({
105
+ backend: config.backend,
106
+ initial: emptyDocument(config, version),
107
+ key: config.key ?? `game-save:${config.game}`,
108
+ slot: config.slot,
109
+ version,
110
+ autosave: config.autosave,
111
+ now: config.now,
112
+ timers: config.timers,
113
+ onError: config.onError,
114
+ migrate: (data, fromVersion) => {
115
+ if (fromVersion === version)
116
+ return data;
117
+ if (config.migrate !== undefined)
118
+ return { ...config.migrate(data, fromVersion), version };
119
+ return { ...data, version };
120
+ },
121
+ });
122
+ const unregister = config.devtoolsName === undefined
123
+ ? null
124
+ : devtools.probes.register(config.devtoolsName, () => `${store.slot()} ${store.status()} rev ${store.revision()} ${systems.size} systems`);
125
+ return {
126
+ register(system) {
127
+ add(system);
128
+ return () => systems.delete(system.name);
129
+ },
130
+ capture,
131
+ async save() {
132
+ store.set(capture());
133
+ await store.save();
134
+ },
135
+ async load() {
136
+ const document = await store.load();
137
+ apply(document);
138
+ return document;
139
+ },
140
+ async switchSlot(slot) {
141
+ store.set(capture());
142
+ const document = await store.switchSlot(slot);
143
+ apply(document);
144
+ return document;
145
+ },
146
+ async peek(slot) {
147
+ const stored = await store.peek(slot);
148
+ if (stored === null)
149
+ return null;
150
+ return stored.value;
151
+ },
152
+ slots: () => store.slots(),
153
+ slot: () => store.slot(),
154
+ status: () => store.status(),
155
+ revision: () => store.revision(),
156
+ touch() {
157
+ const document = capture();
158
+ store.set(document);
159
+ return document;
160
+ },
161
+ digest: () => gameSaveDigest(capture()),
162
+ digests() {
163
+ const out = {};
164
+ for (const [name, system] of systems)
165
+ out[name] = digestOf(system.snapshot());
166
+ return out;
167
+ },
168
+ subscribe: (listener) => store.subscribe(listener),
169
+ dispose: () => {
170
+ unregister?.();
171
+ store.dispose();
172
+ },
173
+ };
174
+ }
@@ -48,7 +48,7 @@ export interface NoticePresentationConfig extends NoticeFeedConfig {
48
48
  /** Play a UI cue per new notice (deny → deny, success → confirm, else notify); a push with `silent: true` skips its own. Independent of the HUD control sounds (`audio.hudCues`). Default true. */
49
49
  cues?: boolean;
50
50
  }
51
- export type { CameraFollowState, CameraKeyframe, CameraProjection, CameraRigKind, CameraWeaponView, CameraShakeConfig, ChaseCameraConfig, ChaseView, CinematicCameraConfig, FirstPersonCameraConfig, GameCameraConfig, InspectionCameraConfig, InspectionZoomAnchor, LockOnCameraConfig, ObserverCameraConfig, PlayerFovConfig, RtsCameraConfig, ShoulderCameraConfig, SideScrollCameraConfig, TopDownCameraConfig, TurntableCameraConfig, } from "./cameraConfig.js";
51
+ export type { CameraFollowState, CameraKeyframe, CameraProjection, CameraRigKind, CameraWeaponView, CameraShakeConfig, ChaseCameraConfig, ChaseView, CinematicCameraConfig, FirstPersonCameraConfig, FirstPersonCollisionConfig, GameCameraConfig, InspectionCameraConfig, InspectionZoomAnchor, LockOnCameraConfig, ObserverCameraConfig, PlayerFovConfig, RtsCameraConfig, ShoulderCameraConfig, SideScrollCameraConfig, TopDownCameraConfig, TurntableCameraConfig, } from "./cameraConfig.js";
52
52
  export { CAMERA_FRUSTUM_DEFAULTS } from "./cameraConfig.js";
53
53
  /**
54
54
  * Mouse/touch pointer bindings for `defineGame({ pointer })`.
@@ -132,6 +132,16 @@ export interface ModelAnimationStates {
132
132
  walk: string;
133
133
  /** Clip name above `runSpeed`; omit to keep `walk` at any speed. */
134
134
  run?: string;
135
+ /** Clip while moving to the character's left; with `strafeRight` adds the lateral strafe blend, driven by the `direction` parameter. */
136
+ strafeLeft?: string;
137
+ /** Clip while moving to the character's right; with `strafeLeft` adds the lateral strafe blend. */
138
+ strafeRight?: string;
139
+ /** Clip while moving backwards; adds the backward blend, driven by the `facing` parameter. */
140
+ backward?: string;
141
+ /** Clip while turning on the spot; omit to pivot on `walk`, driven by the `turnRate` parameter. */
142
+ turn?: string;
143
+ /** Play every locomotion state as root motion: the rig holds in place and the blended travel comes out as `rootDelta` for the movement owner to consume. */
144
+ rootMotion?: boolean;
135
145
  /** Speed (world units/sec) above which the entity counts as moving. Default 0.5. */
136
146
  walkSpeed?: number;
137
147
  /** Speed above which `run` plays when provided. Default 6. */
@@ -250,9 +260,15 @@ export interface ModelConfig {
250
260
  material?: ModelMaterialOverride;
251
261
  /** `false` keeps this model's exported material params and skips the engine's default painted surface detail. Default true. */
252
262
  surfaceDetail?: boolean;
263
+ /**
264
+ * Per-model wind sway for foliage: trunk and standing geometry stay planted while the upper
265
+ * canopy drifts in the shared gust field. `strength` is metres of tip travel, `speed` the gust
266
+ * rate, `height` the world height over which sway ramps in (defaults to the model's own height).
267
+ * `false` or omitted keeps the model rigid. Compiles into each cloned material.
268
+ */
269
+ wind?: ModelWindConfig | false;
253
270
  /** Reusable authored surfaces available to this model's named slot assignments. */
254
- materialAssets?: readonly MaterialAsset[];
255
- /** Sparse per-slot assignments; unselected imported materials keep their original appearance. */
271
+ materialAssets?: readonly MaterialAsset[]; /** Sparse per-slot assignments; unselected imported materials keep their original appearance. */
256
272
  materialAssignments?: readonly MaterialAssignment[];
257
273
  /** Hide named visual subtrees on this instance before placement measurement. Exact runtime names or unique original imported names resolve without removing bones or changing the cached model; missing or ambiguous names warn and leave existing visibility unchanged. Malformed lists reject before model or texture allocation. Omit or pass [] to retain imported visibility. */
258
274
  hiddenNodes?: readonly string[];
@@ -281,6 +297,38 @@ export interface ModelConfig {
281
297
  parts?: readonly ModelPart[];
282
298
  /** Tuning for the procedural part-motion driver when any part carries a `role`; omit for defaults. */
283
299
  partMotion?: PartMotionParams;
300
+ /**
301
+ * Distance tiers for a rigged character: how far away it still poses its skeleton every frame, and
302
+ * from where a far one renders through the GPU crowd path instead. `false` keeps this model at full
303
+ * rate everywhere, which is what a player avatar or a character in a cutscene wants. Omit for no
304
+ * distance tiers (full rate) — see {@link ModelLodConfig}.
305
+ */
306
+ lod?: ModelLodConfig | false;
307
+ }
308
+ /**
309
+ * Distance tiers for one character model. Distances are metres from the camera; every field is data
310
+ * so a game can set its own thresholds per model and retune them without touching the engine.
311
+ */
312
+ export interface ModelLodConfig {
313
+ /** Within this distance the rig poses every frame. Default 15. */
314
+ fullRateDistance?: number;
315
+ /** Within this distance the rig poses every other frame, carrying the accumulated time. Default 40. */
316
+ halfRateDistance?: number;
317
+ /** Beyond this distance a crowd-eligible rig renders through the GPU crowd path and updates no bones. Default 25. */
318
+ crowdDistance?: number;
319
+ /** Metres a character must come back inside `crowdDistance` before it returns to its own rig, so the swap cannot flicker at the boundary. Default 2. */
320
+ crowdHysteresis?: number;
321
+ }
322
+ /** Wind sway for a foliage model; see {@link ModelConfig.wind}. */
323
+ export interface ModelWindConfig {
324
+ /** Tip travel in metres. Default 0.35. */
325
+ strength?: number;
326
+ /** Gust rate multiplier. Default 1. */
327
+ speed?: number;
328
+ /** World height over which sway ramps from the base to full tip travel, in metres. Defaults to the model's measured height. */
329
+ height?: number;
330
+ /** Wind heading as a two-axis direction; defaults to the engine's shared gust direction. */
331
+ direction?: readonly [number, number];
284
332
  }
285
333
  /** Bone names and limits for {@link ModelConfig.ik}. */
286
334
  export interface ModelIkConfig {
@@ -296,10 +344,30 @@ export interface ModelIkConfig {
296
344
  maxAdjust?: number;
297
345
  /** Tilt planted feet to the ground normal, `0..1`. Default `1`. */
298
346
  alignToGround?: number;
299
- /** Turns this bone toward the camera. */
300
- lookAt?: {
301
- bone: string;
347
+ /** Turns this bone toward a target. */
348
+ lookAt?: ModelLookAtConfig;
349
+ }
350
+ /** {@link ModelIkConfig.lookAt}: the bone that turns, and what it turns toward. */
351
+ export interface ModelLookAtConfig {
352
+ /** Bone turned toward the target; the head on a humanoid rig. */
353
+ bone: string;
354
+ /**
355
+ * What the bone turns toward: the camera (default), another entity's position, or a fixed world
356
+ * point. An entity target that is not spawned this frame keeps the last known direction.
357
+ */
358
+ target?: "camera" | {
359
+ entity: string;
360
+ } | {
361
+ point: readonly [number, number, number];
302
362
  };
363
+ /** Height above an entity target's origin to aim at; the entity's eye level. Default 1.5. */
364
+ entityEyeHeight?: number;
365
+ /** Yaw limit in radians; the head stops turning past it. Omitted means unconstrained. */
366
+ maxYaw?: number;
367
+ /** Pitch limit in radians. Omitted means unconstrained. */
368
+ maxPitch?: number;
369
+ /** How much of the solved turn reaches the bone, `0..1`. Default `1`. */
370
+ weight?: number;
303
371
  }
304
372
  export interface ObjectStyle {
305
373
  color?: string;
@@ -0,0 +1,54 @@
1
+ import type { GameSave, GameSaveDocument } from "./gameSave.js";
2
+ import type { SaveSlotMeta, SaveSlots } from "./saveSlots.js";
3
+ /**
4
+ * Bridging the versioned save document (`createGameSave`) to the save-select index (`createSaveSlots`):
5
+ * the document already carries `savedAt`, `time` and `playtime`, so a menu built on the index can show
6
+ * real saves instead of hand-maintained metadata.
7
+ */
8
+ /** How one document becomes a menu slot. */
9
+ export interface SaveSlotMetaOptions {
10
+ /** Player-facing name for the slot. Defaults to the slot id. */
11
+ nameOf?: (slot: string, document: GameSaveDocument) => string;
12
+ /**
13
+ * Extra display chips, merged over the playtime chip. The game decides which document fields matter
14
+ * (`chapter`, `location`, `level`, …); the index never interprets meta keys.
15
+ */
16
+ metaOf?: (slot: string, document: GameSaveDocument) => Record<string, string | number>;
17
+ }
18
+ /** "4h 12m" / "12m" / "< 1m" — the playtime chip every save menu wants. */
19
+ export declare function formatSavePlaytime(seconds: number): string;
20
+ /**
21
+ * Project one save document onto the index's display metadata: name, last-saved time and a playtime
22
+ * chip (plus any caller `metaOf` fields). Pure, so a menu can build its list from documents a host
23
+ * fetched for it.
24
+ *
25
+ * @capability save-slot-meta project a save document onto save-select display metadata
26
+ */
27
+ export declare function saveSlotMeta(slot: string, document: GameSaveDocument, options?: SaveSlotMetaOptions): SaveSlotMeta;
28
+ /** The source a slot list is refreshed from — a `GameSave`, or a narrower reader. */
29
+ export interface SaveSlotSource {
30
+ slots(): Promise<string[]>;
31
+ peek(slot: string): Promise<GameSaveDocument | null>;
32
+ }
33
+ /** The result of a refresh: what the menu should show now. */
34
+ export interface SaveSlotsRefresh {
35
+ /** Written slots in the order the save store indexes them. */
36
+ saved: string[];
37
+ /** Slot metadata for every written slot. */
38
+ filled: SaveSlotMeta[];
39
+ /** Written slots the index could not hold (id not in it and the index is full). */
40
+ skipped: string[];
41
+ }
42
+ /**
43
+ * Read every written slot's document and write the display metadata into a `SaveSlots` index — the
44
+ * glue between B5.3's save document and a save-select menu. Slots the index already knows that have
45
+ * no document are cleared, so a deleted save stops showing a stale Continue.
46
+ *
47
+ * @capability save-slot-index refresh a save-select index from the game's save documents
48
+ */
49
+ export declare function refreshSaveSlots(source: SaveSlotSource, model: SaveSlots, options?: SaveSlotMetaOptions): Promise<SaveSlotsRefresh>;
50
+ /**
51
+ * Adapt a {@link GameSave} as a {@link SaveSlotSource}. Kept separate so a host can supply its own
52
+ * reader (a cloud endpoint listing saves) with the same shape.
53
+ */
54
+ export declare function gameSaveSlotSource(save: GameSave): SaveSlotSource;
@@ -0,0 +1,71 @@
1
+ /** "4h 12m" / "12m" / "< 1m" — the playtime chip every save menu wants. */
2
+ export function formatSavePlaytime(seconds) {
3
+ const total = Math.max(0, Math.floor(seconds));
4
+ const hours = Math.floor(total / 3600);
5
+ const minutes = Math.floor((total % 3600) / 60);
6
+ if (hours > 0)
7
+ return `${hours}h ${minutes}m`;
8
+ if (minutes > 0)
9
+ return `${minutes}m`;
10
+ return "< 1m";
11
+ }
12
+ /**
13
+ * Project one save document onto the index's display metadata: name, last-saved time and a playtime
14
+ * chip (plus any caller `metaOf` fields). Pure, so a menu can build its list from documents a host
15
+ * fetched for it.
16
+ *
17
+ * @capability save-slot-meta project a save document onto save-select display metadata
18
+ */
19
+ export function saveSlotMeta(slot, document, options = {}) {
20
+ return {
21
+ id: slot,
22
+ name: options.nameOf?.(slot, document) ?? slot,
23
+ empty: false,
24
+ savedAt: document.savedAt,
25
+ meta: {
26
+ playtime: formatSavePlaytime(document.playtime),
27
+ ...options.metaOf?.(slot, document),
28
+ },
29
+ };
30
+ }
31
+ /**
32
+ * Read every written slot's document and write the display metadata into a `SaveSlots` index — the
33
+ * glue between B5.3's save document and a save-select menu. Slots the index already knows that have
34
+ * no document are cleared, so a deleted save stops showing a stale Continue.
35
+ *
36
+ * @capability save-slot-index refresh a save-select index from the game's save documents
37
+ */
38
+ export async function refreshSaveSlots(source, model, options = {}) {
39
+ const saved = await source.slots();
40
+ const filled = [];
41
+ const skipped = [];
42
+ for (const slot of saved) {
43
+ const document = await source.peek(slot);
44
+ if (document === null)
45
+ continue;
46
+ const meta = saveSlotMeta(slot, document, options);
47
+ try {
48
+ model.write(slot, { name: meta.name, meta: meta.meta, savedAt: meta.savedAt });
49
+ }
50
+ catch {
51
+ skipped.push(slot);
52
+ continue;
53
+ }
54
+ filled.push(meta);
55
+ }
56
+ for (const existing of model.list()) {
57
+ if (!existing.empty && !filled.some((meta) => meta.id === existing.id))
58
+ model.clear(existing.id);
59
+ }
60
+ return { saved, filled, skipped };
61
+ }
62
+ /**
63
+ * Adapt a {@link GameSave} as a {@link SaveSlotSource}. Kept separate so a host can supply its own
64
+ * reader (a cloud endpoint listing saves) with the same shape.
65
+ */
66
+ export function gameSaveSlotSource(save) {
67
+ return {
68
+ slots: () => save.slots(),
69
+ peek: (slot) => save.peek(slot),
70
+ };
71
+ }
@@ -29,6 +29,8 @@ export interface SaveSlotWrite {
29
29
  name?: string;
30
30
  /** Replacement free-string display fields. Omit to keep the existing `meta`. */
31
31
  meta?: Record<string, string | number>;
32
+ /** The save's own write time (ms) when the payload knows it; omit to use the index's clock. */
33
+ savedAt?: number;
32
34
  }
33
35
  /** How a {@link createSaveSlots} index is wired. */
34
36
  export interface SaveSlotsConfig {
@@ -72,7 +72,7 @@ export function createSaveSlots(config = {}) {
72
72
  }
73
73
  const slot = slots[i];
74
74
  slot.empty = false;
75
- slot.savedAt = now();
75
+ slot.savedAt = write?.savedAt ?? now();
76
76
  if (write?.name !== undefined)
77
77
  slot.name = write.name;
78
78
  if (write?.meta !== undefined)
@@ -92,6 +92,14 @@ export interface SaveStore<T> {
92
92
  save(): Promise<void>;
93
93
  /** Delete the active slot and reset `value()` to `initial`. */
94
94
  clear(): Promise<void>;
95
+ /**
96
+ * Read a stored slot without touching the live value, active slot or status — what a save-select
97
+ * menu shows. Returns `null` when the slot holds nothing.
98
+ */
99
+ peek(slot?: string): Promise<{
100
+ value: T;
101
+ savedAt: number;
102
+ } | null>;
95
103
  /** Flush any dirty state, switch to `slot`, and load it. */
96
104
  switchSlot(slot: string): Promise<T>;
97
105
  /** The slots this store has written (for a load/save menu). */
@@ -289,6 +289,22 @@ export function createSaveStore(config) {
289
289
  firstDirtyAt = null;
290
290
  return load();
291
291
  },
292
+ async peek(slot = currentSlot) {
293
+ try {
294
+ const raw = await backend.read(dataKey(slot));
295
+ if (raw === null)
296
+ return null;
297
+ const parsed = deserialize(raw);
298
+ if (isEnvelope(parsed)) {
299
+ return { value: decode(raw), savedAt: typeof parsed.savedAt === "number" ? parsed.savedAt : 0 };
300
+ }
301
+ return { value: decode(raw), savedAt: 0 };
302
+ }
303
+ catch (error) {
304
+ reportError(error);
305
+ return null;
306
+ }
307
+ },
292
308
  async slots() {
293
309
  try {
294
310
  return await readSlots();