pelican-engine 0.0.0-stage → 0.3.1

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 (192) hide show
  1. package/AGENTS.md +147 -0
  2. package/LICENSE +22 -0
  3. package/README.md +129 -2
  4. package/distribution.json +197 -0
  5. package/docs/AI-WORKFLOW.md +517 -0
  6. package/docs/ARCHITECTURE.md +268 -0
  7. package/docs/CAPABILITIES.md +264 -0
  8. package/docs/DECISIONS.md +107 -0
  9. package/docs/M3-USAGE.md +192 -0
  10. package/docs/PELICAN-MIGRATION.md +38 -0
  11. package/docs/RENDER-BACKENDS.md +50 -0
  12. package/docs/ROADMAP.md +239 -0
  13. package/docs/THIRD-PARTY-NOTICES.md +151 -0
  14. package/docs/api/REFERENCE.md +1435 -0
  15. package/docs/api/index.json +1139 -0
  16. package/docs/npm/DISTRIBUTION.md +130 -0
  17. package/docs/npm/GAME-AUTHOR.md +147 -0
  18. package/engine.manifest.json +15 -0
  19. package/lib/assets/audio.js +67 -0
  20. package/lib/assets/errors.js +36 -0
  21. package/lib/assets/glb.js +277 -0
  22. package/lib/assets/index.js +53 -0
  23. package/lib/assets/jpeg.js +199 -0
  24. package/lib/assets/model.js +558 -0
  25. package/lib/assets/optimize.js +95 -0
  26. package/lib/assets/texture.js +187 -0
  27. package/lib/compiler/distribution.js +17 -0
  28. package/lib/compiler/extract.js +425 -0
  29. package/lib/compiler/graph.js +366 -0
  30. package/lib/compiler/index.js +574 -0
  31. package/lib/compiler/normalize.js +1194 -0
  32. package/lib/compiler/source-map.js +25 -0
  33. package/lib/compiler/types.js +2 -0
  34. package/lib/compiler/version.js +93 -0
  35. package/lib/experiments/simulation/index.js +2 -0
  36. package/lib/experiments/simulation/math.js +41 -0
  37. package/lib/experiments/simulation/probe.js +215 -0
  38. package/lib/experiments/simulation/scenes.js +192 -0
  39. package/lib/experiments/simulation/solver.js +346 -0
  40. package/lib/experiments/simulation/types.js +1 -0
  41. package/lib/experiments/world/adapters.js +30 -0
  42. package/lib/experiments/world/geometry.js +180 -0
  43. package/lib/experiments/world/index.js +2 -0
  44. package/lib/experiments/world/probe.js +288 -0
  45. package/lib/experiments/world/types.js +1 -0
  46. package/lib/experiments/world/world.js +599 -0
  47. package/lib/navigation/index.js +157 -0
  48. package/lib/physics/index.js +909 -0
  49. package/lib/protocol/index.js +92 -0
  50. package/lib/renderer/acceleration.js +609 -0
  51. package/lib/renderer/browser.js +2015 -0
  52. package/lib/renderer/bsdf.js +260 -0
  53. package/lib/renderer/checkpoint.js +88 -0
  54. package/lib/renderer/compute-executor.js +1034 -0
  55. package/lib/renderer/compute-texture-assets.js +49 -0
  56. package/lib/renderer/compute-view-contract.js +1 -0
  57. package/lib/renderer/denoiser.js +361 -0
  58. package/lib/renderer/dielectric.js +360 -0
  59. package/lib/renderer/embedded-image-pixels.js +368 -0
  60. package/lib/renderer/emissive-lights.js +67 -0
  61. package/lib/renderer/environment-importance.js +55 -0
  62. package/lib/renderer/environment.js +346 -0
  63. package/lib/renderer/gameplay.js +464 -0
  64. package/lib/renderer/geometry.js +103 -0
  65. package/lib/renderer/graph-expressions.js +485 -0
  66. package/lib/renderer/graph-tsl.js +141 -0
  67. package/lib/renderer/graph.js +214 -0
  68. package/lib/renderer/managed-texture-sampling.js +29 -0
  69. package/lib/renderer/material-graphs.js +297 -0
  70. package/lib/renderer/medium.js +348 -0
  71. package/lib/renderer/models.js +1053 -0
  72. package/lib/renderer/motion-shader.js +40 -0
  73. package/lib/renderer/path-shader.js +196 -0
  74. package/lib/renderer/pathtrace.js +324 -0
  75. package/lib/renderer/postprocess-contract.js +1 -0
  76. package/lib/renderer/postprocess.js +494 -0
  77. package/lib/renderer/render-buffers.js +132 -0
  78. package/lib/renderer/render-textures.js +155 -0
  79. package/lib/renderer/startup.js +50 -0
  80. package/lib/renderer/surface-capture.js +199 -0
  81. package/lib/renderer/surface-gpu.js +270 -0
  82. package/lib/renderer/surface-motion.js +84 -0
  83. package/lib/renderer/surface-ray-shader.js +91 -0
  84. package/lib/renderer/surface-shader.js +88 -0
  85. package/lib/renderer/surface-tracer.js +1720 -0
  86. package/lib/renderer/surface.js +1425 -0
  87. package/lib/renderer/temporal-filter.js +512 -0
  88. package/lib/renderer/texture-pixels.js +207 -0
  89. package/lib/renderer/traversal-nodes.js +54 -0
  90. package/lib/renderer/ui.js +446 -0
  91. package/lib/renderer/vertex-bounds.js +127 -0
  92. package/lib/renderer/vertex-contract.js +1 -0
  93. package/lib/renderer/vertex-graphs.js +197 -0
  94. package/lib/renderer/volume-shader.js +50 -0
  95. package/lib/runtime/compute-contract.js +1 -0
  96. package/lib/runtime/compute-service.js +674 -0
  97. package/lib/runtime/contracts.js +1 -0
  98. package/lib/runtime/error.js +37 -0
  99. package/lib/runtime/events.js +189 -0
  100. package/lib/runtime/game-services.js +772 -0
  101. package/lib/runtime/host.js +839 -0
  102. package/lib/runtime/material-parameters.js +145 -0
  103. package/lib/runtime/plugins.js +1 -0
  104. package/lib/runtime/postprocess-contract.js +1 -0
  105. package/lib/runtime/postprocess.js +189 -0
  106. package/lib/runtime/prefabs.js +13 -0
  107. package/lib/runtime/random.js +88 -0
  108. package/lib/runtime/render-buffer-contract.js +1 -0
  109. package/lib/runtime/render-buffers.js +243 -0
  110. package/lib/runtime/render-capabilities.js +17 -0
  111. package/lib/runtime/render-settings.js +249 -0
  112. package/lib/runtime/render-texture-contract.js +1 -0
  113. package/lib/runtime/render-textures.js +271 -0
  114. package/lib/runtime/resources.js +648 -0
  115. package/lib/runtime/scheduler.js +765 -0
  116. package/lib/runtime/storage.js +169 -0
  117. package/lib/runtime/world.js +594 -0
  118. package/lib/schema/index.js +24 -0
  119. package/lib/sdk/graph-values.js +16 -0
  120. package/lib/sdk/index.d.ts +800 -0
  121. package/lib/sdk/index.js +82 -0
  122. package/lib/tools/api-index.js +113 -0
  123. package/lib/tools/browser-session.js +89 -0
  124. package/lib/tools/cli.js +513 -0
  125. package/lib/tools/compress-dist.js +59 -0
  126. package/lib/tools/control-client.js +153 -0
  127. package/lib/tools/control-server.js +633 -0
  128. package/lib/tools/diagnostic-code.js +3 -0
  129. package/lib/tools/doctor.js +496 -0
  130. package/lib/tools/npm/browser-cli.js +6 -0
  131. package/lib/tools/npm/entry.js +40 -0
  132. package/lib/tools/npm/layout.js +195 -0
  133. package/lib/tools/setup.js +153 -0
  134. package/licenses/@dimforge/rapier2d-compat/LICENSE +201 -0
  135. package/licenses/@dimforge/rapier3d-compat/LICENSE +201 -0
  136. package/licenses/@gltf-transform/core/LICENSE.md +21 -0
  137. package/licenses/@gltf-transform/extensions/LICENSE.md +21 -0
  138. package/licenses/@gltf-transform/functions/LICENSE.md +21 -0
  139. package/licenses/@img/colour/LICENSE.md +82 -0
  140. package/licenses/@img/sharp-win32-x64/LICENSE +191 -0
  141. package/licenses/@oxc-project/types/LICENSE +22 -0
  142. package/licenses/@rolldown/pluginutils/LICENSE +21 -0
  143. package/licenses/@tweenjs/tween.js/LICENSE +23 -0
  144. package/licenses/@types/ndarray/LICENSE +21 -0
  145. package/licenses/@types/node/LICENSE +21 -0
  146. package/licenses/@types/stats.js/LICENSE +21 -0
  147. package/licenses/@types/three/@dimforge/rapier3d-compat/LICENSE +201 -0
  148. package/licenses/@types/three/LICENSE +21 -0
  149. package/licenses/@types/webxr/LICENSE +21 -0
  150. package/licenses/@webgpu/types/LICENSE +26 -0
  151. package/licenses/THIRD-PARTY-NOTICES.md +139 -0
  152. package/licenses/cwise-compiler/LICENSE +22 -0
  153. package/licenses/detect-libc/LICENSE +201 -0
  154. package/licenses/esbuild/LICENSE.md +21 -0
  155. package/licenses/fast-png/LICENSE +21 -0
  156. package/licenses/fdir/LICENSE +7 -0
  157. package/licenses/fflate/LICENSE +21 -0
  158. package/licenses/iobuffer/LICENSE +22 -0
  159. package/licenses/iota-array/LICENSE +22 -0
  160. package/licenses/is-buffer/LICENSE +21 -0
  161. package/licenses/ktx-parse/LICENSE +21 -0
  162. package/licenses/lightningcss/LICENSE +373 -0
  163. package/licenses/lightningcss-win32-x64-msvc/LICENSE +373 -0
  164. package/licenses/meshoptimizer/LICENSE.md +21 -0
  165. package/licenses/nanoid/LICENSE +20 -0
  166. package/licenses/ndarray/LICENSE +22 -0
  167. package/licenses/ndarray-lanczos/LICENSE +21 -0
  168. package/licenses/ndarray-ops/LICENSE +22 -0
  169. package/licenses/ndarray-pixels/LICENSE +21 -0
  170. package/licenses/ngraph.path/LICENSE +21 -0
  171. package/licenses/picocolors/LICENSE +15 -0
  172. package/licenses/picomatch/LICENSE +21 -0
  173. package/licenses/playwright/LICENSE +202 -0
  174. package/licenses/playwright/NOTICE +5 -0
  175. package/licenses/playwright-core/LICENSE +202 -0
  176. package/licenses/playwright-core/NOTICE +5 -0
  177. package/licenses/postcss/LICENSE +20 -0
  178. package/licenses/prettier/LICENSE +7 -0
  179. package/licenses/property-graph/LICENSE +21 -0
  180. package/licenses/rolldown/LICENSE +25 -0
  181. package/licenses/semver/LICENSE +15 -0
  182. package/licenses/sharp/LICENSE +191 -0
  183. package/licenses/source-map-js/LICENSE +28 -0
  184. package/licenses/three/LICENSE +21 -0
  185. package/licenses/tinyglobby/LICENSE +21 -0
  186. package/licenses/tsx/LICENSE +21 -0
  187. package/licenses/typescript/LICENSE.txt +55 -0
  188. package/licenses/undici-types/LICENSE +21 -0
  189. package/licenses/uniq/LICENSE +22 -0
  190. package/licenses/vite/LICENSE.md +2084 -0
  191. package/package.json +49 -4
  192. package/web/index.html +34 -0
@@ -0,0 +1,772 @@
1
+ import { cloneSerializable } from "./random.js";
2
+ /** A failed play never evicts an existing voice or consumes an id. */
3
+ export const GAME_SERVICES_MAX_VOICES = 32;
4
+ const SNAPSHOT_ERROR = "TC_SERVICES_SNAPSHOT";
5
+ const fail = (code, message) => {
6
+ throw new Error(`${code}: ${message}`);
7
+ };
8
+ function check(value, code, message) {
9
+ if (!value)
10
+ fail(code, message);
11
+ }
12
+ function object(value, keys, code) {
13
+ check(value !== null && typeof value === "object" && !Array.isArray(value), code, "Expected an object.");
14
+ const result = value;
15
+ check(Object.keys(result).sort().join("\0") === [...keys].sort().join("\0"), code, `Expected fields ${keys.join(", ")}.`);
16
+ return result;
17
+ }
18
+ function name(value, code, label) {
19
+ check(typeof value === "string" &&
20
+ value.trim().length > 0 &&
21
+ value.length <= 64 &&
22
+ !/[\x00-\x1f\x7f]/.test(value), code, `${label} must contain 1..64 printable characters.`);
23
+ }
24
+ function inputKey(value, code) {
25
+ check(typeof value === "string" &&
26
+ value.length > 0 &&
27
+ value.length <= 64 &&
28
+ !/[\x00-\x1f\x7f]/.test(value), code, "Input key must contain 1..64 printable characters; a literal space is valid.");
29
+ }
30
+ function finite(value, code, label, min = -Infinity, max = Infinity) {
31
+ check(typeof value === "number" &&
32
+ Number.isFinite(value) &&
33
+ value >= min &&
34
+ value <= max, code, `${label} must be finite in [${min}, ${max}].`);
35
+ }
36
+ function integer(value, code, label, min = 0) {
37
+ check(Number.isSafeInteger(value) && value >= min, code, `${label} must be a safe integer >= ${min}.`);
38
+ }
39
+ function canonical(value) {
40
+ if (Array.isArray(value))
41
+ return `[${value.map(canonical).join(",")}]`;
42
+ if (value !== null && typeof value === "object")
43
+ return `{${Object.keys(value)
44
+ .sort()
45
+ .map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
46
+ .join(",")}}`;
47
+ return JSON.stringify(value);
48
+ }
49
+ function cameraValue(value, code) {
50
+ const c = object(value, ["entity", "distance", "height", "lookHeight", "yaw", "smoothing"], code);
51
+ name(c.entity, code, "Camera target");
52
+ finite(c.distance, code, "Camera distance", Number.MIN_VALUE);
53
+ finite(c.height, code, "Camera height", 0);
54
+ finite(c.lookHeight, code, "Camera lookHeight", 0);
55
+ finite(c.yaw, code, "Camera yaw");
56
+ finite(c.smoothing, code, "Camera smoothing", 0, 1);
57
+ }
58
+ const UI_FIELDS = [
59
+ "id",
60
+ "kind",
61
+ "text",
62
+ "value",
63
+ "visible",
64
+ "anchor",
65
+ "x",
66
+ "y",
67
+ "width",
68
+ "color",
69
+ ];
70
+ function uiValue(value, code) {
71
+ const ui = object(value, value && typeof value === "object" && Object.hasOwn(value, "world")
72
+ ? [...UI_FIELDS, "world"]
73
+ : UI_FIELDS, code);
74
+ name(ui.id, code, "UI id");
75
+ check(["text", "bar", "button", "input"].includes(ui.kind), code, "Unknown UI kind.");
76
+ check(typeof ui.text === "string" && typeof ui.visible === "boolean", code, "Invalid UI text/visible.");
77
+ finite(ui.value, code, "UI progress", 0, 1);
78
+ if (ui.world !== undefined) {
79
+ const world = object(ui.world, ["entity", "offset", "occlude"], code);
80
+ name(world.entity, code, "World UI entity");
81
+ check(typeof world.occlude === "boolean", code, "World UI occlude must be boolean.");
82
+ check(Array.isArray(world.offset) &&
83
+ world.offset.length === 3 &&
84
+ world.offset.every((value) => typeof value === "number" && Number.isFinite(value)), code, "World UI offset must be finite Vec3.");
85
+ }
86
+ check(["top-left", "top-right", "bottom-left", "bottom-right", "center"].includes(ui.anchor), code, "Unknown UI anchor.");
87
+ finite(ui.x, code, "UI x");
88
+ finite(ui.y, code, "UI y");
89
+ finite(ui.width, code, "UI width", Number.MIN_VALUE);
90
+ check(typeof ui.color === "string", code, "Invalid UI color.");
91
+ }
92
+ /** Pure deterministic gameplay state. The caller owns platform I/O and the animation adapter. */
93
+ export function createGameServices(ir, animation) {
94
+ const sceneId = ir.scene.id;
95
+ const gameId = ir.gameId;
96
+ const bindings = cloneSerializable(ir.input ?? {});
97
+ const initialUI = cloneSerializable(ir.ui ?? []);
98
+ const sounds = cloneSerializable(ir.sounds ?? []);
99
+ const hasBuses = ir.audioBuses !== undefined;
100
+ const buses = cloneSerializable(ir.audioBuses ?? []);
101
+ const initialCamera = cloneSerializable(ir.scene.follow ?? null);
102
+ const sceneIds = [ir.scene.id, ...(ir.scenes ?? []).map((scene) => scene.id)];
103
+ const entityIds = ir.scene.entities.map((entity) => entity.id);
104
+ const actions = Object.keys(bindings).sort();
105
+ const knownKeys = new Set();
106
+ for (const action of actions) {
107
+ name(action, "TC_INPUT_ACTION", "Input action");
108
+ const keys = bindings[action];
109
+ check(Array.isArray(keys) && keys.length > 0 && keys.length <= 16, "TC_INPUT_BINDINGS", `Action ${action} needs 1..16 keys.`);
110
+ for (const key of keys) {
111
+ inputKey(key, "TC_INPUT_BINDINGS");
112
+ knownKeys.add(key);
113
+ }
114
+ check(new Set(keys).size === keys.length, "TC_INPUT_BINDINGS", `Duplicate keys for ${action}.`);
115
+ }
116
+ for (const item of initialUI)
117
+ uiValue(item, "TC_UI_VALUE");
118
+ check(new Set(initialUI.map((item) => item.id)).size === initialUI.length, "TC_UI_ID", "Duplicate UI ids.");
119
+ check(new Set(sounds.map((sound) => sound.id)).size === sounds.length, "TC_AUDIO_SOUND", "Duplicate sound ids.");
120
+ check(new Set(buses.map((bus) => bus.id)).size === buses.length, "TC_AUDIO_BUS", "Duplicate audio bus ids.");
121
+ for (const bus of buses) {
122
+ name(bus.id, "TC_AUDIO_BUS", "Audio bus id");
123
+ finite(bus.volume, "TC_AUDIO_VOLUME", `Bus ${bus.id} volume`, 0, 1);
124
+ }
125
+ for (const id of sceneIds)
126
+ name(id, "TC_SCENE_ID", "Scene id");
127
+ check(new Set(sceneIds).size === sceneIds.length, "TC_SCENE_ID", "Duplicate scene ids.");
128
+ if (initialCamera) {
129
+ cameraValue(initialCamera, "TC_CAMERA_VALUE");
130
+ check(entityIds.includes(initialCamera.entity), "TC_CAMERA_TARGET", `Unknown target ${initialCamera.entity}; declare it in the scene.`);
131
+ }
132
+ const durations = {};
133
+ for (const sound of sounds) {
134
+ name(sound.id, "TC_AUDIO_SOUND", "Sound id");
135
+ if (sound.bus !== undefined)
136
+ check(buses.some((bus) => bus.id === sound.bus), "TC_AUDIO_BUS", `Unknown audio bus ${sound.bus}.`);
137
+ finite(sound.volume, "TC_AUDIO_VOLUME", `Sound ${sound.id} volume`, 0, 1);
138
+ check(typeof sound.loop === "boolean", "TC_AUDIO_VALUE", `Sound ${sound.id} loop must be boolean.`);
139
+ const asset = ir.assets.find((item) => item.id === sound.asset);
140
+ check(asset?.type === "audio", "TC_AUDIO_ASSET", `Sound ${sound.id} needs audio asset ${sound.asset}.`);
141
+ const duration = asset.metadata?.duration;
142
+ if (duration !== undefined)
143
+ finite(duration, "TC_AUDIO_DURATION", `Sound ${sound.id} duration`, 0);
144
+ Object.defineProperty(durations, sound.id, {
145
+ value: duration ?? null,
146
+ enumerable: true,
147
+ });
148
+ }
149
+ const definitions = cloneSerializable({
150
+ game: gameId,
151
+ scene: sceneId,
152
+ sceneIds,
153
+ entityIds,
154
+ bindings,
155
+ ui: initialUI,
156
+ sounds,
157
+ ...(hasBuses ? { audioBuses: buses } : {}),
158
+ durations,
159
+ camera: initialCamera,
160
+ assets: ir.assets.filter((asset) => sounds.some((sound) => sound.asset === asset.id)),
161
+ });
162
+ const definitionKey = canonical(definitions);
163
+ const initial = () => ({
164
+ format: "pelican.game-services",
165
+ version: 1,
166
+ definitions,
167
+ tick: 0,
168
+ fixedDt: null,
169
+ input: {
170
+ keys: [],
171
+ held: [],
172
+ pressed: [],
173
+ released: [],
174
+ },
175
+ camera: cloneSerializable(initialCamera),
176
+ ui: cloneSerializable(initialUI),
177
+ audio: {
178
+ volume: 1,
179
+ ...(hasBuses ? { busVolumes: cloneSerializable(buses) } : {}),
180
+ voices: [],
181
+ nextVoice: 1,
182
+ },
183
+ storage: { state: "idle", slot: null, error: null },
184
+ scene: sceneId,
185
+ requests: [],
186
+ requestTick: null,
187
+ scenePending: false,
188
+ });
189
+ let state = initial();
190
+ let audioRevision = 0;
191
+ const voiceVersions = new WeakMap();
192
+ let context;
193
+ let disposed = false;
194
+ // Dispatch belongs to the platform operation, not the saved game. In particular,
195
+ // a load must remain completable after restore replaces deterministic state.
196
+ let dispatchedStorage = null;
197
+ const alive = () => {
198
+ check(!disposed, "TC_SERVICE_DISPOSED", "Game services were disposed; create a new driver.");
199
+ };
200
+ const target = (id, code) => {
201
+ name(id, code, "Entity id");
202
+ check(context?.world.isAlive(id), code, `Entity ${id} does not exist; use a live scene entity.`);
203
+ };
204
+ const action = (id) => {
205
+ alive();
206
+ check(typeof id === "string" && Object.hasOwn(bindings, id), "TC_INPUT_ACTION", `Unknown action ${String(id)}; declare it in game.input.`);
207
+ };
208
+ const ui = (id, kind) => {
209
+ alive();
210
+ const item = state.ui.find((item) => item.id === id);
211
+ check(item, "TC_UI_ID", `Unknown UI id ${String(id)}; declare it in game.ui.`);
212
+ check(kind === undefined || item.kind === kind, "TC_UI_KIND", `UI ${id} must be a ${kind}.`);
213
+ return item;
214
+ };
215
+ const request = (type, id) => {
216
+ alive();
217
+ name(id, type === "scene" ? "TC_SCENE_ID" : "TC_STORAGE_SLOT", type === "scene" ? "Scene id" : "Storage slot");
218
+ if (type === "scene")
219
+ check(sceneIds.includes(id), "TC_SCENE_ID", `Unknown scene ${id}; declare it in game.scenes.`);
220
+ check(state.requestTick !== state.tick &&
221
+ !dispatchedStorage &&
222
+ !state.scenePending &&
223
+ !["saving", "loading"].includes(state.storage.state), "TC_SERVICE_BUSY", "A save/load/scene request is active or was issued this tick; finish it and retry next tick.");
224
+ state.requestTick = state.tick;
225
+ state.requests.push({ type, id });
226
+ if (type === "scene")
227
+ state.scenePending = true;
228
+ else
229
+ state.storage = {
230
+ state: type === "save" ? "saving" : "loading",
231
+ slot: id,
232
+ error: null,
233
+ };
234
+ };
235
+ const services = {
236
+ input: {
237
+ held(id) {
238
+ action(id);
239
+ return state.input.held.includes(id);
240
+ },
241
+ pressed(id) {
242
+ action(id);
243
+ return state.input.pressed.includes(id);
244
+ },
245
+ released(id) {
246
+ action(id);
247
+ return state.input.released.includes(id);
248
+ },
249
+ axis(negative, positive) {
250
+ action(negative);
251
+ action(positive);
252
+ return (Number(state.input.held.includes(positive)) -
253
+ Number(state.input.held.includes(negative)));
254
+ },
255
+ },
256
+ animation: {
257
+ play(entity, clip, options) {
258
+ alive();
259
+ animation.play(entity, clip, options);
260
+ },
261
+ state(entity) {
262
+ alive();
263
+ return animation.state(entity);
264
+ },
265
+ },
266
+ camera: {
267
+ follow(entity, options = {}) {
268
+ alive();
269
+ target(entity, "TC_CAMERA_TARGET");
270
+ const values = cloneSerializable(options);
271
+ check(values &&
272
+ typeof values === "object" &&
273
+ !Array.isArray(values) &&
274
+ Object.keys(values).every((key) => ["distance", "height", "lookHeight", "yaw", "smoothing"].includes(key)), "TC_CAMERA_VALUE", "Unknown camera options.");
275
+ const next = {
276
+ distance: 5,
277
+ height: 2,
278
+ lookHeight: 1,
279
+ yaw: 0,
280
+ smoothing: 0.1,
281
+ ...values,
282
+ entity,
283
+ };
284
+ cameraValue(next, "TC_CAMERA_VALUE");
285
+ state.camera = next;
286
+ },
287
+ orbit(yaw) {
288
+ alive();
289
+ finite(yaw, "TC_CAMERA_VALUE", "Orbit yaw delta");
290
+ check(state.camera, "TC_CAMERA_FOLLOW", "Call camera.follow before orbit.");
291
+ const next = state.camera.yaw + yaw;
292
+ finite(next, "TC_CAMERA_VALUE", "Resulting camera yaw");
293
+ state.camera.yaw = next;
294
+ },
295
+ },
296
+ ui: {
297
+ setText(id, text) {
298
+ const item = ui(id);
299
+ check(item.kind !== "bar", "TC_UI_KIND", `UI ${id} is a progress bar.`);
300
+ check(typeof text === "string", "TC_UI_VALUE", `UI ${id} text must be a string.`);
301
+ item.text = text;
302
+ },
303
+ setProgress(id, value) {
304
+ const item = ui(id, "bar");
305
+ finite(value, "TC_UI_VALUE", `UI ${id} progress`, 0, 1);
306
+ item.value = value;
307
+ },
308
+ setVisible(id, visible) {
309
+ const item = ui(id);
310
+ check(typeof visible === "boolean", "TC_UI_VALUE", `UI ${id} visible must be boolean.`);
311
+ item.visible = visible;
312
+ },
313
+ },
314
+ audio: {
315
+ play(id, options = {}) {
316
+ alive();
317
+ const sound = sounds.find((sound) => sound.id === id);
318
+ check(sound, "TC_AUDIO_SOUND", `Unknown sound ${String(id)}; declare it in game.sounds.`);
319
+ const values = cloneSerializable(options);
320
+ check(values &&
321
+ typeof values === "object" &&
322
+ !Array.isArray(values) &&
323
+ Object.keys(values).every((key) => ["volume", "loop", "entity", "bus", "priority"].includes(key)), "TC_AUDIO_VALUE", "Unknown audio options.");
324
+ const volume = Object.hasOwn(values, "volume")
325
+ ? values.volume
326
+ : sound.volume;
327
+ const loop = Object.hasOwn(values, "loop") ? values.loop : sound.loop;
328
+ const bus = Object.hasOwn(values, "bus") ? values.bus : sound.bus;
329
+ if (bus !== undefined || Object.hasOwn(values, "bus")) {
330
+ name(bus, "TC_AUDIO_BUS", "Audio bus id");
331
+ check(buses.some((item) => item.id === bus), "TC_AUDIO_BUS", `Unknown audio bus ${bus}.`);
332
+ }
333
+ finite(volume, "TC_AUDIO_VOLUME", `Sound ${id} volume`, 0, 1);
334
+ const priority = Object.hasOwn(values, "priority") ? values.priority : 0;
335
+ integer(priority, "TC_AUDIO_PRIORITY", "Voice priority", 0);
336
+ check(priority <= 255, "TC_AUDIO_PRIORITY", "Voice priority must be 0..255.");
337
+ check(typeof loop === "boolean", "TC_AUDIO_VALUE", `Sound ${id} loop must be boolean.`);
338
+ if (Object.hasOwn(values, "entity"))
339
+ target(values.entity, "TC_AUDIO_TARGET");
340
+ check(state.audio.nextVoice < Number.MAX_SAFE_INTEGER, "TC_AUDIO_VOICE_LIMIT", "Voice id counter exhausted; create a new runtime.");
341
+ if (state.audio.voices.length >= GAME_SERVICES_MAX_VOICES) {
342
+ const lowest = state.audio.voices.reduce((candidate, voice) => (voice.priority ?? 0) < (candidate.priority ?? 0) ? voice : candidate);
343
+ check(priority > (lowest.priority ?? 0), "TC_AUDIO_VOICE_LIMIT", `At most ${GAME_SERVICES_MAX_VOICES} voices may play; use a higher priority to replace the oldest lowest-priority voice.`);
344
+ state.audio.voices = state.audio.voices.filter((voice) => voice !== lowest);
345
+ }
346
+ const voice = `voice:${state.audio.nextVoice}`;
347
+ state.audio.voices.push({
348
+ id: voice,
349
+ sound: id,
350
+ ...(bus !== undefined ? { bus } : {}),
351
+ ...(priority !== 0 ? { priority } : {}),
352
+ volume,
353
+ loop,
354
+ entity: values.entity ?? null,
355
+ startedAt: state.tick,
356
+ });
357
+ state.audio.nextVoice++;
358
+ return voice;
359
+ },
360
+ stop(id) {
361
+ alive();
362
+ name(id, "TC_AUDIO_VOICE", "Voice id");
363
+ state.audio.voices = state.audio.voices.filter((voice) => voice.id !== id);
364
+ },
365
+ pause(id) {
366
+ alive();
367
+ name(id, "TC_AUDIO_VOICE", "Voice id");
368
+ const voice = state.audio.voices.find((item) => item.id === id);
369
+ if (voice && voice.pausedAt == null) {
370
+ voice.pausedTicks ??= 0;
371
+ voice.pausedAt = state.tick;
372
+ voiceVersions.set(voice, (voiceVersions.get(voice) ?? 0) + 1);
373
+ }
374
+ },
375
+ resume(id) {
376
+ alive();
377
+ name(id, "TC_AUDIO_VOICE", "Voice id");
378
+ const voice = state.audio.voices.find((item) => item.id === id);
379
+ if (voice && voice.pausedAt != null) {
380
+ voice.pausedTicks = (voice.pausedTicks ?? 0) + state.tick - voice.pausedAt;
381
+ voice.pausedAt = null;
382
+ voiceVersions.set(voice, (voiceVersions.get(voice) ?? 0) + 1);
383
+ }
384
+ },
385
+ volume(value) {
386
+ alive();
387
+ finite(value, "TC_AUDIO_VOLUME", "Master volume", 0, 1);
388
+ state.audio.volume = value;
389
+ },
390
+ busVolume(id, value) {
391
+ alive();
392
+ name(id, "TC_AUDIO_BUS", "Audio bus id");
393
+ const bus = state.audio.busVolumes?.find((item) => item.id === id);
394
+ check(bus, "TC_AUDIO_BUS", `Unknown audio bus ${id}.`);
395
+ finite(value, "TC_AUDIO_VOLUME", `Bus ${id} volume`, 0, 1);
396
+ bus.volume = value;
397
+ delete bus.fade;
398
+ },
399
+ fadeBus(id, target, duration) {
400
+ alive();
401
+ name(id, "TC_AUDIO_BUS", "Audio bus id");
402
+ const bus = state.audio.busVolumes?.find((item) => item.id === id);
403
+ check(bus, "TC_AUDIO_BUS", `Unknown audio bus ${id}.`);
404
+ finite(target, "TC_AUDIO_VOLUME", `Bus ${id} fade target`, 0, 1);
405
+ finite(duration, "TC_AUDIO_FADE", "Fade duration", 0);
406
+ if (duration === 0 || bus.volume === target) {
407
+ bus.volume = target;
408
+ delete bus.fade;
409
+ }
410
+ else
411
+ bus.fade = { from: bus.volume, target, duration, startedAt: state.tick };
412
+ },
413
+ },
414
+ storage: {
415
+ save(slot) {
416
+ request("save", slot);
417
+ },
418
+ load(slot) {
419
+ request("load", slot);
420
+ },
421
+ status() {
422
+ alive();
423
+ return cloneSerializable(state.storage);
424
+ },
425
+ },
426
+ scenes: {
427
+ current() {
428
+ alive();
429
+ return state.scene;
430
+ },
431
+ load(id) {
432
+ request("scene", id);
433
+ },
434
+ },
435
+ };
436
+ const inspect = () => {
437
+ alive();
438
+ const { held, pressed, released } = state.input;
439
+ return cloneSerializable({
440
+ input: { held, pressed, released },
441
+ camera: state.camera,
442
+ ui: state.ui,
443
+ audio: { volume: state.audio.volume, ...(state.audio.busVolumes !== undefined ? { busVolumes: state.audio.busVolumes } : {}), voices: state.audio.voices },
444
+ storage: state.storage,
445
+ scene: state.scene,
446
+ });
447
+ };
448
+ const validate = (value) => {
449
+ alive();
450
+ let data;
451
+ try {
452
+ data = cloneSerializable(value);
453
+ }
454
+ catch {
455
+ return fail(SNAPSHOT_ERROR, "Expected finite plain JSON without accessors, cycles, sparse arrays, or extra array properties.");
456
+ }
457
+ object(data, [
458
+ "format",
459
+ "version",
460
+ "definitions",
461
+ "tick",
462
+ "fixedDt",
463
+ "input",
464
+ "camera",
465
+ "ui",
466
+ "audio",
467
+ "storage",
468
+ "scene",
469
+ "requests",
470
+ "requestTick",
471
+ "scenePending",
472
+ ], SNAPSHOT_ERROR);
473
+ check(data.format === "pelican.game-services" && data.version === 1, SNAPSHOT_ERROR, "Unsupported format/version.");
474
+ check(canonical(data.definitions) === definitionKey, SNAPSHOT_ERROR, "Immutable gameplay definitions differ; use the same compiled game.");
475
+ integer(data.tick, SNAPSHOT_ERROR, "Tick");
476
+ if (data.tick === 0)
477
+ check(data.fixedDt === null, SNAPSHOT_ERROR, "Initial fixedDt must be null.");
478
+ else
479
+ finite(data.fixedDt, SNAPSHOT_ERROR, "fixedDt", Number.MIN_VALUE);
480
+ check(data.fixedDt === null || Number.isFinite(data.tick * data.fixedDt), SNAPSHOT_ERROR, "Clock time exceeds the finite range.");
481
+ object(data.input, ["keys", "held", "pressed", "released"], SNAPSHOT_ERROR);
482
+ const strings = (value, allowed, label) => {
483
+ check(Array.isArray(value), SNAPSHOT_ERROR, `${label} must be an array.`);
484
+ check(value.every((item) => typeof item === "string" && allowed.has(item)) &&
485
+ new Set(value).size === value.length &&
486
+ canonical(value) === canonical([...value].sort()), SNAPSHOT_ERROR, `${label} contains unknown, duplicate, or unsorted values.`);
487
+ };
488
+ strings(data.input.keys, knownKeys, "Input keys");
489
+ for (const field of ["held", "pressed", "released"])
490
+ strings(data.input[field], new Set(actions), `Input ${field}`);
491
+ check(canonical(data.input.held) ===
492
+ canonical(actions.filter((action) => bindings[action].some((key) => data.input.keys.includes(key)))), SNAPSHOT_ERROR, "Held actions disagree with input keys.");
493
+ check(data.input.pressed.every((id) => data.input.held.includes(id) || data.input.released.includes(id)) &&
494
+ data.input.released.every((id) => !data.input.held.includes(id) || data.input.pressed.includes(id)), SNAPSHOT_ERROR, "Input edges disagree with final held state.");
495
+ if (data.tick === 0)
496
+ check(Object.values(data.input).every((items) => items.length === 0), SNAPSHOT_ERROR, "Initial input must be empty.");
497
+ if (data.camera !== null)
498
+ cameraValue(data.camera, SNAPSHOT_ERROR);
499
+ check(Array.isArray(data.ui) && data.ui.length === initialUI.length, SNAPSHOT_ERROR, "UI definitions differ.");
500
+ for (let i = 0; i < data.ui.length; i++) {
501
+ const item = data.ui[i];
502
+ uiValue(item, SNAPSHOT_ERROR);
503
+ const original = initialUI[i];
504
+ const immutable = (element) => {
505
+ const { text: _text, value: _value, visible: _visible, ...rest } = element;
506
+ return rest;
507
+ };
508
+ check(canonical(immutable(item)) === canonical(immutable(original)), SNAPSHOT_ERROR, "Immutable UI layout differs.");
509
+ if (item.kind === "bar")
510
+ check(item.text === original.text, SNAPSHOT_ERROR, "Progress bar text cannot change.");
511
+ else
512
+ check(item.value === original.value, SNAPSHOT_ERROR, "Non-bar progress cannot change.");
513
+ }
514
+ object(data.audio, ["volume", "voices", "nextVoice", ...(Object.hasOwn(data.audio, "busVolumes") ? ["busVolumes"] : [])], SNAPSHOT_ERROR);
515
+ finite(data.audio.volume, SNAPSHOT_ERROR, "Master volume", 0, 1);
516
+ if (data.audio.busVolumes !== undefined) {
517
+ check(hasBuses && Array.isArray(data.audio.busVolumes) && data.audio.busVolumes.length === buses.length, SNAPSHOT_ERROR, "Invalid audio buses.");
518
+ for (let i = 0; i < buses.length; i++) {
519
+ const bus = object(data.audio.busVolumes[i], ["id", "volume", ...(Object.hasOwn(data.audio.busVolumes[i], "fade") ? ["fade"] : [])], SNAPSHOT_ERROR);
520
+ check(bus.id === buses[i].id, SNAPSHOT_ERROR, "Invalid audio bus id or order.");
521
+ finite(bus.volume, SNAPSHOT_ERROR, "Bus volume", 0, 1);
522
+ if (Object.hasOwn(bus, "fade")) {
523
+ const fade = object(bus.fade, ["from", "target", "duration", "startedAt"], SNAPSHOT_ERROR);
524
+ finite(fade.from, SNAPSHOT_ERROR, "Fade start", 0, 1);
525
+ finite(fade.target, SNAPSHOT_ERROR, "Fade target", 0, 1);
526
+ finite(fade.duration, SNAPSHOT_ERROR, "Fade duration", Number.MIN_VALUE);
527
+ integer(fade.startedAt, SNAPSHOT_ERROR, "Fade tick", 0);
528
+ check(fade.startedAt <= data.tick && (data.fixedDt === null || (data.tick - fade.startedAt) * data.fixedDt < fade.duration), SNAPSHOT_ERROR, "Expired or future fade.");
529
+ const expected = fade.from + (fade.target - fade.from) * Math.min(1, (data.tick - fade.startedAt) * (data.fixedDt ?? 0) / fade.duration);
530
+ check(Math.abs(Number(bus.volume) - expected) < 1e-9, SNAPSHOT_ERROR, "Fade gain does not match tick.");
531
+ }
532
+ }
533
+ }
534
+ else if (hasBuses) {
535
+ data.audio.busVolumes = cloneSerializable(buses);
536
+ }
537
+ integer(data.audio.nextVoice, SNAPSHOT_ERROR, "Next voice", 1);
538
+ check(Array.isArray(data.audio.voices) &&
539
+ data.audio.voices.length <= GAME_SERVICES_MAX_VOICES, SNAPSHOT_ERROR, "Too many voices or invalid voice list.");
540
+ let previousVoice = 0;
541
+ for (const voice of data.audio.voices) {
542
+ object(voice, ["id", "sound", "volume", "loop", "entity", "startedAt", ...(Object.hasOwn(voice, "bus") ? ["bus"] : []), ...(Object.hasOwn(voice, "priority") ? ["priority"] : []), ...(Object.hasOwn(voice, "pausedAt") || Object.hasOwn(voice, "pausedTicks") ? ["pausedAt", "pausedTicks"] : [])], SNAPSHOT_ERROR);
543
+ check(typeof voice.id === "string" && /^voice:[1-9]\d*$/.test(voice.id), SNAPSHOT_ERROR, "Invalid voice id.");
544
+ const sequence = Number(voice.id.slice(6));
545
+ integer(sequence, SNAPSHOT_ERROR, "Voice sequence", 1);
546
+ check(sequence > previousVoice && sequence < data.audio.nextVoice, SNAPSHOT_ERROR, "Voice ids are duplicated, unordered or beyond counter.");
547
+ previousVoice = sequence;
548
+ check(sounds.some((sound) => sound.id === voice.sound), SNAPSHOT_ERROR, "Unknown voice sound.");
549
+ if (Object.hasOwn(voice, "bus"))
550
+ check(typeof voice.bus === "string" && buses.some((bus) => bus.id === voice.bus), SNAPSHOT_ERROR, "Unknown voice bus.");
551
+ else {
552
+ const defaultBus = sounds.find((sound) => sound.id === voice.sound)?.bus;
553
+ if (defaultBus !== undefined)
554
+ voice.bus = defaultBus;
555
+ }
556
+ finite(voice.volume, SNAPSHOT_ERROR, "Voice volume", 0, 1);
557
+ if (Object.hasOwn(voice, "priority")) {
558
+ integer(voice.priority, SNAPSHOT_ERROR, "Voice priority", 1);
559
+ check(voice.priority <= 255, SNAPSHOT_ERROR, "Invalid voice priority.");
560
+ }
561
+ check(typeof voice.loop === "boolean", SNAPSHOT_ERROR, "Invalid voice loop.");
562
+ if (voice.entity !== null)
563
+ name(voice.entity, SNAPSHOT_ERROR, "Voice entity");
564
+ integer(voice.startedAt, SNAPSHOT_ERROR, "Voice startedAt");
565
+ check(voice.startedAt <= data.tick, SNAPSHOT_ERROR, "Voice starts in the future.");
566
+ if (Object.hasOwn(voice, "pausedAt")) {
567
+ integer(voice.pausedTicks, SNAPSHOT_ERROR, "Voice pausedTicks");
568
+ if (voice.pausedAt !== null)
569
+ integer(voice.pausedAt, SNAPSHOT_ERROR, "Voice pausedAt");
570
+ check((voice.pausedAt ?? data.tick) >= voice.startedAt &&
571
+ (voice.pausedAt ?? data.tick) <= data.tick &&
572
+ voice.pausedTicks <= (voice.pausedAt ?? data.tick) - voice.startedAt, SNAPSHOT_ERROR, "Invalid voice pause history.");
573
+ }
574
+ const duration = durations[voice.sound];
575
+ check(voice.loop ||
576
+ duration === null ||
577
+ data.fixedDt === null ||
578
+ ((voice.pausedAt ?? data.tick) - voice.startedAt - (voice.pausedTicks ?? 0)) * data.fixedDt < duration ||
579
+ (voice.pausedAt ?? data.tick) - voice.startedAt - (voice.pausedTicks ?? 0) === 0, SNAPSHOT_ERROR, "Expired voice must have been reclaimed.");
580
+ }
581
+ object(data.storage, ["state", "slot", "error"], SNAPSHOT_ERROR);
582
+ check(["idle", "saving", "loading", "saved", "loaded", "failed"].includes(data.storage.state), SNAPSHOT_ERROR, "Invalid storage state.");
583
+ if (data.storage.state === "idle")
584
+ check(data.storage.slot === null && data.storage.error === null, SNAPSHOT_ERROR, "Idle storage must have no slot/error.");
585
+ else
586
+ name(data.storage.slot, SNAPSHOT_ERROR, "Storage slot");
587
+ check(data.storage.state === "failed"
588
+ ? typeof data.storage.error === "string"
589
+ : data.storage.error === null, SNAPSHOT_ERROR, "Storage error disagrees with state.");
590
+ check(data.scene === sceneId, SNAPSHOT_ERROR, "Current scene must match the host scene.");
591
+ if (data.requestTick !== null) {
592
+ integer(data.requestTick, SNAPSHOT_ERROR, "Request tick");
593
+ check(data.requestTick <= data.tick, SNAPSHOT_ERROR, "Request is in the future.");
594
+ }
595
+ check(typeof data.scenePending === "boolean", SNAPSHOT_ERROR, "Invalid scenePending.");
596
+ const storageBusy = data.storage.state === "saving" || data.storage.state === "loading";
597
+ check(!data.scenePending || !storageBusy, SNAPSHOT_ERROR, "Scene/storage requests conflict.");
598
+ check((!data.scenePending && data.storage.state === "idle") ||
599
+ data.requestTick !== null, SNAPSHOT_ERROR, "Request tick is missing.");
600
+ check(Array.isArray(data.requests) && data.requests.length <= 1, SNAPSHOT_ERROR, "Expected at most one pending request.");
601
+ for (const pending of data.requests) {
602
+ object(pending, ["type", "id"], SNAPSHOT_ERROR);
603
+ name(pending.id, SNAPSHOT_ERROR, "Request id");
604
+ check(["save", "load", "scene"].includes(pending.type), SNAPSHOT_ERROR, "Unknown request type.");
605
+ if (pending.type === "scene")
606
+ check(data.scenePending && sceneIds.includes(pending.id), SNAPSHOT_ERROR, "Invalid pending scene.");
607
+ else
608
+ check(!data.scenePending &&
609
+ data.storage.slot === pending.id &&
610
+ data.storage.state ===
611
+ (pending.type === "save" ? "saving" : "loading"), SNAPSHOT_ERROR, "Pending request disagrees with storage status.");
612
+ }
613
+ return data;
614
+ };
615
+ const plugin = {
616
+ id: "game-services",
617
+ version: "1",
618
+ services,
619
+ systems: [
620
+ {
621
+ id: "$engine.input",
622
+ phase: "input",
623
+ run(dt) {
624
+ alive();
625
+ finite(dt, "TC_SERVICE_CLOCK", "fixedDt", Number.MIN_VALUE);
626
+ check(state.fixedDt === null || state.fixedDt === dt, "TC_SERVICE_CLOCK", "fixedDt changed; create a new runtime.");
627
+ check(state.tick < Number.MAX_SAFE_INTEGER, "TC_SERVICE_CLOCK", "Tick counter exhausted.");
628
+ const keys = new Set(state.input.keys);
629
+ for (const event of context.events("ui.text")) {
630
+ const payload = object(event.payload, ["id", "text", "composing"], "TC_UI_INPUT_EVENT");
631
+ name(payload.id, "TC_UI_INPUT_EVENT", "Input id");
632
+ check(typeof payload.text === "string" &&
633
+ payload.text.length <= 4096 &&
634
+ payload.composing === false, "TC_UI_INPUT_EVENT", "Only committed input text up to 4096 characters is delivered to gameplay.");
635
+ ui(payload.id, "input").text = payload.text;
636
+ }
637
+ const held = new Set(state.input.held);
638
+ const pressed = new Set();
639
+ const released = new Set();
640
+ for (const event of context.events("input.key")) {
641
+ const payload = object(event.payload, ["key", "pressed"], "TC_INPUT_EVENT");
642
+ inputKey(payload.key, "TC_INPUT_EVENT");
643
+ check(typeof payload.pressed === "boolean", "TC_INPUT_EVENT", "pressed must be boolean.");
644
+ if (!knownKeys.has(payload.key))
645
+ continue;
646
+ if (payload.pressed)
647
+ keys.add(payload.key);
648
+ else
649
+ keys.delete(payload.key);
650
+ for (const id of actions) {
651
+ const down = bindings[id].some((key) => keys.has(key));
652
+ if (down && !held.has(id))
653
+ pressed.add(id);
654
+ if (!down && held.has(id))
655
+ released.add(id);
656
+ if (down)
657
+ held.add(id);
658
+ else
659
+ held.delete(id);
660
+ }
661
+ }
662
+ state.tick++;
663
+ state.fixedDt = dt;
664
+ state.input = {
665
+ keys: [...keys].sort(),
666
+ held: [...held].sort(),
667
+ pressed: [...pressed].sort(),
668
+ released: [...released].sort(),
669
+ };
670
+ state.audio.voices = state.audio.voices.filter((voice) => voice.loop ||
671
+ voice.pausedAt != null ||
672
+ durations[voice.sound] === null ||
673
+ (state.tick - voice.startedAt - (voice.pausedTicks ?? 0)) * dt < durations[voice.sound]);
674
+ for (const bus of state.audio.busVolumes ?? []) {
675
+ if (!bus.fade)
676
+ continue;
677
+ const { from, target, duration, startedAt } = bus.fade;
678
+ const progress = Math.min(1, (state.tick - startedAt) * dt / duration);
679
+ bus.volume = from + (target - from) * progress;
680
+ if (progress === 1)
681
+ delete bus.fade;
682
+ }
683
+ },
684
+ },
685
+ ],
686
+ inspect,
687
+ snapshot() {
688
+ alive();
689
+ return cloneSerializable(state);
690
+ },
691
+ validateSnapshot(value) {
692
+ validate(value);
693
+ },
694
+ restore(value) {
695
+ const restored = validate(value);
696
+ state = restored;
697
+ audioRevision++;
698
+ if (dispatchedStorage?.type !== "load")
699
+ dispatchedStorage = null;
700
+ },
701
+ dispose() {
702
+ disposed = true;
703
+ context = undefined;
704
+ dispatchedStorage = null;
705
+ },
706
+ };
707
+ return {
708
+ factory(value) {
709
+ alive();
710
+ check(!context, "TC_SERVICE_BOUND", "A game services driver belongs to one host; create a new driver.");
711
+ check(value.scene.scene.id === sceneId && value.scene.gameId === gameId, "TC_SERVICE_SCENE", "Driver and host must use the same compiled scene.");
712
+ context = value;
713
+ return plugin;
714
+ },
715
+ inspect,
716
+ takeRequests() {
717
+ alive();
718
+ const pending = cloneSerializable(state.requests);
719
+ state.requests = [];
720
+ const storage = pending.find((request) => request.type !== "scene");
721
+ if (storage)
722
+ dispatchedStorage = storage;
723
+ return pending;
724
+ },
725
+ adoptStorageRequest(type, slot) {
726
+ alive();
727
+ check(type === "save" || type === "load", "TC_STORAGE_REQUEST", "Adopted request type must be save/load.");
728
+ name(slot, "TC_STORAGE_SLOT", "Storage slot");
729
+ check(!dispatchedStorage &&
730
+ !state.scenePending &&
731
+ state.requests.length === 0, "TC_SERVICE_BUSY", "Cannot adopt storage while a dispatch or queued save/load/scene request is active.");
732
+ dispatchedStorage = { type, id: slot };
733
+ },
734
+ storageComplete(type, slot, error) {
735
+ alive();
736
+ check(type === "save" || type === "load", "TC_STORAGE_COMPLETION", "Completion type must be save/load.");
737
+ name(slot, "TC_STORAGE_SLOT", "Storage slot");
738
+ check(error === undefined || typeof error === "string", "TC_STORAGE_COMPLETION", "Completion error must be a string.");
739
+ check(dispatchedStorage?.type === type &&
740
+ dispatchedStorage.id === slot &&
741
+ !state.scenePending &&
742
+ state.requests.length === 0 &&
743
+ (type === "load" ||
744
+ (state.storage.slot === slot && state.storage.state === "saving")), "TC_STORAGE_COMPLETION", "Completion must match a dispatched active request.");
745
+ dispatchedStorage = null;
746
+ state.requestTick = state.tick;
747
+ state.storage = {
748
+ state: error === undefined
749
+ ? type === "save"
750
+ ? "saved"
751
+ : "loaded"
752
+ : "failed",
753
+ slot,
754
+ error: error ?? null,
755
+ };
756
+ },
757
+ completeVoice(id) {
758
+ alive();
759
+ name(id, "TC_AUDIO_VOICE", "Voice id");
760
+ state.audio.voices = state.audio.voices.filter((voice) => voice.id !== id);
761
+ },
762
+ audioClock() {
763
+ alive();
764
+ return {
765
+ tick: state.tick,
766
+ fixedDt: state.fixedDt,
767
+ revision: audioRevision,
768
+ versions: Object.fromEntries(state.audio.voices.map((voice) => [voice.id, voiceVersions.get(voice) ?? 0])),
769
+ };
770
+ },
771
+ };
772
+ }