@simpludify/web 0.1.7 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/dist/asset-loaders.d.ts +6 -0
- package/dist/assets.d.ts +158 -0
- package/dist/audio.d.ts +97 -0
- package/dist/canvas-visual.d.ts +2 -0
- package/dist/game.d.ts +30 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1425 -197
- package/dist/index.js.map +1 -1
- package/dist/renderer.d.ts +7 -2
- package/dist/shared-images.d.ts +9 -2
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "./style.css";
|
|
2
|
-
import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, EventBus, FixedStepRunner, GamepadAxis, GamepadBindings, GamepadButton, GeometryTilemapLayer, InputCapture, InputCaptureState, InputDevice, Lifetime, MouseButton, PointerRouting, Renderable, ReplayPlayer, Scene, ScenePointer, SceneState, Shape, TilemapLayer, Transform, VirtualInput, canvasControlText, canvasHoverHighlight, canvasOpacity, canvasScrollbars, canvasSliderGeometry, checkedGamepadBindings, cleanupAll, containsScreenPoint, createViewTransform, defineEvent, gamepadDeadZone, gamepadIndex, imageSize, intersectUI, isKeyCode, isMouseButton, positive, screenPoint, screenToWorld, spriteFrameBounds, worldToScreen, wrapCanvasText } from "@simpludify/core";
|
|
2
|
+
import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, EventBus, FixedStepRunner, GamepadAxis, GamepadBindings, GamepadButton, GeometryTilemapLayer, InputCapture, InputCaptureState, InputDevice, Lifetime, Logger, MouseButton, PointerRouting, Renderable, ReplayPlayer, Scene, ScenePointer, SceneState, Shape, TilemapLayer, Transform, VirtualInput, canvasControlText, canvasHoverHighlight, canvasOpacity, canvasScrollbars, canvasSliderGeometry, checkedGamepadBindings, cleanupAll, containsScreenPoint, createViewTransform, defaultCanvasStyle, defineEvent, gamepadDeadZone, gamepadIndex, imageSize, intersectUI, isKeyCode, isMouseButton, positive, screenPoint, screenToWorld, spriteFrameBounds, worldToScreen, wrapCanvasText } from "@simpludify/core";
|
|
3
3
|
import { Application, CanvasTextMetrics, Container, Graphics, Rectangle, RendererType, Sprite, Text, TextStyle, Texture } from "pixi.js";
|
|
4
4
|
export * from "@simpludify/core";
|
|
5
5
|
// packages/web/src/canvas-node.ts
|
|
@@ -17,6 +17,1222 @@ function CanvasButton(text, click, style = {}) {
|
|
|
17
17
|
return click(signal);
|
|
18
18
|
}, style);
|
|
19
19
|
}
|
|
20
|
+
// packages/web/src/shared-images.ts
|
|
21
|
+
/** Renderer-local, live-reference cache. No idle retention or process-global state. */
|
|
22
|
+
var SharedImages = class {
|
|
23
|
+
load;
|
|
24
|
+
entries = /* @__PURE__ */ new Map();
|
|
25
|
+
loaded = 0;
|
|
26
|
+
references = 0;
|
|
27
|
+
dead = false;
|
|
28
|
+
constructor(load) {
|
|
29
|
+
this.load = load;
|
|
30
|
+
}
|
|
31
|
+
get diagnostics() {
|
|
32
|
+
let pending = 0;
|
|
33
|
+
for (const entry of this.entries.values()) if (!entry.resource) pending++;
|
|
34
|
+
return {
|
|
35
|
+
imageReferences: this.references,
|
|
36
|
+
imageEntries: this.entries.size,
|
|
37
|
+
pendingImages: pending
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
get textures() {
|
|
41
|
+
return this.loaded;
|
|
42
|
+
}
|
|
43
|
+
acquire(key, signal, request) {
|
|
44
|
+
const url = request?.url ?? String(key);
|
|
45
|
+
const cancelled = () => new EngineError("GAME_DISPOSED", "Image use cancelled", { path: url });
|
|
46
|
+
if (this.dead || signal.aborted) return Promise.reject(cancelled());
|
|
47
|
+
let entry = this.entries.get(key);
|
|
48
|
+
const fresh = !entry;
|
|
49
|
+
if (!entry) {
|
|
50
|
+
entry = {
|
|
51
|
+
controller: new AbortController(),
|
|
52
|
+
consumers: /* @__PURE__ */ new Set(),
|
|
53
|
+
frames: /* @__PURE__ */ new Map()
|
|
54
|
+
};
|
|
55
|
+
this.entries.set(key, entry);
|
|
56
|
+
}
|
|
57
|
+
const current = entry;
|
|
58
|
+
const promise = new Promise((resolve, reject) => {
|
|
59
|
+
let released = false, settled = false;
|
|
60
|
+
const release = () => {
|
|
61
|
+
if (released) return;
|
|
62
|
+
released = true;
|
|
63
|
+
signal.removeEventListener("abort", release);
|
|
64
|
+
current.consumers.delete(consumer);
|
|
65
|
+
this.references--;
|
|
66
|
+
if (!settled) {
|
|
67
|
+
settled = true;
|
|
68
|
+
reject(cancelled());
|
|
69
|
+
}
|
|
70
|
+
if (!current.consumers.size) {
|
|
71
|
+
if (this.entries.get(key) === current) this.entries.delete(key);
|
|
72
|
+
current.controller.abort();
|
|
73
|
+
const resource = current.resource;
|
|
74
|
+
delete current.resource;
|
|
75
|
+
for (const texture of current.frames.values()) texture.destroy(false);
|
|
76
|
+
current.frames.clear();
|
|
77
|
+
if (resource) {
|
|
78
|
+
this.loaded--;
|
|
79
|
+
resource.release();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const consumer = {
|
|
84
|
+
ready: (resource) => {
|
|
85
|
+
if (released || settled) return;
|
|
86
|
+
settled = true;
|
|
87
|
+
signal.removeEventListener("abort", release);
|
|
88
|
+
resolve({
|
|
89
|
+
texture: resource.texture,
|
|
90
|
+
release,
|
|
91
|
+
textureFor: (frame) => {
|
|
92
|
+
if (released) throw new EngineError("GAME_DISPOSED", "Image lease released");
|
|
93
|
+
if (!frame) return resource.texture;
|
|
94
|
+
const rect = spriteFrameBounds(frame, resource.texture.width, resource.texture.height);
|
|
95
|
+
const key = `${rect.x},${rect.y},${rect.width},${rect.height}`;
|
|
96
|
+
let texture = current.frames.get(key);
|
|
97
|
+
if (!texture) {
|
|
98
|
+
texture = new Texture({
|
|
99
|
+
source: resource.texture.source,
|
|
100
|
+
frame: new Rectangle(rect.x, rect.y, rect.width, rect.height)
|
|
101
|
+
});
|
|
102
|
+
current.frames.set(key, texture);
|
|
103
|
+
}
|
|
104
|
+
return texture;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
},
|
|
108
|
+
fail: (error) => {
|
|
109
|
+
if (released || settled) return;
|
|
110
|
+
settled = true;
|
|
111
|
+
reject(error);
|
|
112
|
+
release();
|
|
113
|
+
},
|
|
114
|
+
release
|
|
115
|
+
};
|
|
116
|
+
this.references++;
|
|
117
|
+
current.consumers.add(consumer);
|
|
118
|
+
signal.addEventListener("abort", release, { once: true });
|
|
119
|
+
if (current.resource) consumer.ready(current.resource);
|
|
120
|
+
});
|
|
121
|
+
if (fresh) Promise.resolve().then(() => this.load(url, current.controller.signal, request?.mode)).then((resource) => {
|
|
122
|
+
if (!current.consumers.size) {
|
|
123
|
+
resource.release();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
current.resource = resource;
|
|
127
|
+
this.loaded++;
|
|
128
|
+
for (const consumer of current.consumers) consumer.ready(resource);
|
|
129
|
+
}, (error) => {
|
|
130
|
+
if (this.entries.get(key) === current) this.entries.delete(key);
|
|
131
|
+
for (const consumer of [...current.consumers]) consumer.fail(error);
|
|
132
|
+
});
|
|
133
|
+
return promise;
|
|
134
|
+
}
|
|
135
|
+
dispose() {
|
|
136
|
+
if (this.dead) return;
|
|
137
|
+
this.dead = true;
|
|
138
|
+
for (const entry of [...this.entries.values()]) for (const consumer of [...entry.consumers]) consumer.release();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
// packages/web/src/asset-loaders.ts
|
|
142
|
+
var missing = "simpludify:missing-image";
|
|
143
|
+
async function assetResponse(url, signal, reload) {
|
|
144
|
+
let response;
|
|
145
|
+
try {
|
|
146
|
+
response = await fetch(url, {
|
|
147
|
+
signal,
|
|
148
|
+
cache: reload ? "reload" : "default"
|
|
149
|
+
});
|
|
150
|
+
} catch (cause) {
|
|
151
|
+
signal.throwIfAborted();
|
|
152
|
+
throw new EngineError("ASSET_NETWORK", "Asset request did not complete", {}, { cause });
|
|
153
|
+
}
|
|
154
|
+
if (!response.ok) {
|
|
155
|
+
const status = response.status;
|
|
156
|
+
throw new EngineError(status === 408 || status === 429 || status >= 500 ? "ASSET_NETWORK" : "ASSET_LOAD_FAILED", "Asset request failed", { status });
|
|
157
|
+
}
|
|
158
|
+
return response;
|
|
159
|
+
}
|
|
160
|
+
function decodeImage(url, signal) {
|
|
161
|
+
return new Promise((resolve, reject) => {
|
|
162
|
+
signal.throwIfAborted();
|
|
163
|
+
const image = new Image();
|
|
164
|
+
const cleanup = () => {
|
|
165
|
+
image.onload = image.onerror = null;
|
|
166
|
+
signal.removeEventListener("abort", abort);
|
|
167
|
+
};
|
|
168
|
+
const abort = () => {
|
|
169
|
+
cleanup();
|
|
170
|
+
image.removeAttribute("src");
|
|
171
|
+
reject(signal.reason);
|
|
172
|
+
};
|
|
173
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
174
|
+
image.onload = () => {
|
|
175
|
+
cleanup();
|
|
176
|
+
resolve(image);
|
|
177
|
+
};
|
|
178
|
+
image.onerror = () => {
|
|
179
|
+
cleanup();
|
|
180
|
+
reject(new EngineError("ASSET_LOAD_FAILED", "Image could not be decoded", { path: url }));
|
|
181
|
+
};
|
|
182
|
+
image.crossOrigin = "anonymous";
|
|
183
|
+
image.src = url;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function checkerboard() {
|
|
187
|
+
const canvas = document.createElement("canvas");
|
|
188
|
+
canvas.width = canvas.height = 16;
|
|
189
|
+
const draw = canvas.getContext("2d");
|
|
190
|
+
if (draw) {
|
|
191
|
+
draw.fillStyle = "#000";
|
|
192
|
+
draw.fillRect(0, 0, 16, 16);
|
|
193
|
+
draw.fillStyle = "#f0f";
|
|
194
|
+
draw.fillRect(0, 0, 8, 8);
|
|
195
|
+
draw.fillRect(8, 8, 8, 8);
|
|
196
|
+
}
|
|
197
|
+
return canvas;
|
|
198
|
+
}
|
|
199
|
+
async function loadImageResource(url, signal, mode) {
|
|
200
|
+
let objectURL;
|
|
201
|
+
try {
|
|
202
|
+
if (mode && url !== missing) {
|
|
203
|
+
const response = await assetResponse(url, signal, mode === "reload");
|
|
204
|
+
objectURL = URL.createObjectURL(await response.blob());
|
|
205
|
+
}
|
|
206
|
+
const image = url === missing ? checkerboard() : await decodeImage(objectURL ?? url, signal);
|
|
207
|
+
signal.throwIfAborted();
|
|
208
|
+
const texture = Texture.from(image, true);
|
|
209
|
+
if (!(texture.width > 0 && texture.height > 0)) {
|
|
210
|
+
texture.destroy(true);
|
|
211
|
+
throw new EngineError("ASSET_LOAD_FAILED", "Image has no drawable dimensions");
|
|
212
|
+
}
|
|
213
|
+
let released = false;
|
|
214
|
+
return {
|
|
215
|
+
texture,
|
|
216
|
+
release: () => {
|
|
217
|
+
if (released) return;
|
|
218
|
+
released = true;
|
|
219
|
+
texture.destroy(true);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
} finally {
|
|
223
|
+
if (objectURL) URL.revokeObjectURL(objectURL);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function createImagePool(load = loadImageResource) {
|
|
227
|
+
return new SharedImages(load);
|
|
228
|
+
}
|
|
229
|
+
function imageAsset(image) {
|
|
230
|
+
return {
|
|
231
|
+
value: Object.freeze({
|
|
232
|
+
width: image.texture.width,
|
|
233
|
+
height: image.texture.height
|
|
234
|
+
}),
|
|
235
|
+
image,
|
|
236
|
+
release: image.release
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
var nextFont = 0;
|
|
240
|
+
var systemFont = "system-ui, sans-serif";
|
|
241
|
+
async function registerFont(data, signal) {
|
|
242
|
+
const family = `SimpludifyFont${++nextFont}`;
|
|
243
|
+
const face = await new FontFace(family, data).load();
|
|
244
|
+
signal.throwIfAborted();
|
|
245
|
+
document.fonts.add(face);
|
|
246
|
+
return {
|
|
247
|
+
value: `${family}, ${systemFont}`,
|
|
248
|
+
release: () => {
|
|
249
|
+
document.fonts.delete(face);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function browserAssetAdapter(images, decode) {
|
|
254
|
+
const load = async (kind, url, signal, reload) => {
|
|
255
|
+
if (kind === "image") return imageAsset(await images.acquire(reload ? Symbol() : url, signal, {
|
|
256
|
+
url,
|
|
257
|
+
mode: reload ? "reload" : "explicit"
|
|
258
|
+
}));
|
|
259
|
+
const response = await assetResponse(url, signal, reload);
|
|
260
|
+
if (kind === "json") return {
|
|
261
|
+
value: await response.json(),
|
|
262
|
+
release() {}
|
|
263
|
+
};
|
|
264
|
+
const data = await response.arrayBuffer();
|
|
265
|
+
signal.throwIfAborted();
|
|
266
|
+
if (kind === "font") return await registerFont(data, signal);
|
|
267
|
+
return {
|
|
268
|
+
value: await decode(data),
|
|
269
|
+
release() {}
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
load,
|
|
274
|
+
fallback: async (kind, url, signal) => {
|
|
275
|
+
if (url) return await load(kind, url, signal, false);
|
|
276
|
+
if (kind === "image") return imageAsset(await images.acquire(missing, signal));
|
|
277
|
+
if (kind === "audio") return {
|
|
278
|
+
value: null,
|
|
279
|
+
release() {}
|
|
280
|
+
};
|
|
281
|
+
if (kind === "font") return {
|
|
282
|
+
value: systemFont,
|
|
283
|
+
release() {}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
// packages/web/src/assets.ts
|
|
289
|
+
var cancelled = () => new EngineError("ASSET_CANCELLED", "Asset use has been released");
|
|
290
|
+
var invalid = (field) => new EngineError("INVALID_VALUE", `Invalid asset ${field}`);
|
|
291
|
+
function numberIn(value, min, max, field) {
|
|
292
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) throw invalid(field);
|
|
293
|
+
return value;
|
|
294
|
+
}
|
|
295
|
+
function settings(options) {
|
|
296
|
+
const retry = options.retry;
|
|
297
|
+
const once = retry?.once;
|
|
298
|
+
if (once !== void 0 && once !== "immediate") {
|
|
299
|
+
if (!once || typeof once !== "object") throw invalid("retry");
|
|
300
|
+
if ("delayMs" in once) numberIn(once.delayMs, 0, 3e5, "retry delay");
|
|
301
|
+
else if (!("until" in once) || typeof once.until?.then !== "function") throw invalid("retry event");
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
timeoutMs: numberIn(options.timeoutMs ?? 3e4, 1, 3e5, "timeout"),
|
|
305
|
+
network: numberIn(retry?.network ?? 0, 0, 5, "network retries"),
|
|
306
|
+
networkDelayMs: numberIn(retry?.networkDelayMs ?? 250, 0, 3e5, "network retry delay"),
|
|
307
|
+
once
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
function assetURL(value, base) {
|
|
311
|
+
if (typeof value === "string" && !value.trim()) throw invalid("URL");
|
|
312
|
+
if (typeof value !== "string" && !(value instanceof URL)) throw invalid("URL");
|
|
313
|
+
try {
|
|
314
|
+
const url = new URL(value, base);
|
|
315
|
+
if (![
|
|
316
|
+
"http:",
|
|
317
|
+
"https:",
|
|
318
|
+
"data:",
|
|
319
|
+
"blob:"
|
|
320
|
+
].includes(url.protocol) || url.username || url.password) throw invalid("URL");
|
|
321
|
+
return url.href;
|
|
322
|
+
} catch {
|
|
323
|
+
throw invalid("URL");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function wait(promise, signal) {
|
|
327
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
328
|
+
return new Promise((resolve, reject) => {
|
|
329
|
+
const abort = () => reject(signal.reason);
|
|
330
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
331
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
function pause(ms, signal) {
|
|
335
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
336
|
+
if (!ms) return Promise.resolve();
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
const abort = () => {
|
|
339
|
+
clearTimeout(timer);
|
|
340
|
+
reject(signal.reason);
|
|
341
|
+
};
|
|
342
|
+
const timer = setTimeout(() => {
|
|
343
|
+
signal.removeEventListener("abort", abort);
|
|
344
|
+
resolve();
|
|
345
|
+
}, ms);
|
|
346
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
function progress(entries) {
|
|
350
|
+
let total = 0;
|
|
351
|
+
let failed = 0;
|
|
352
|
+
let fallback = 0;
|
|
353
|
+
let loading = 0;
|
|
354
|
+
let completed = 0;
|
|
355
|
+
for (const entry of entries) {
|
|
356
|
+
total++;
|
|
357
|
+
if (entry.task) loading++;
|
|
358
|
+
if (entry.status === "failed") failed++;
|
|
359
|
+
if (entry.status === "fallback") fallback++;
|
|
360
|
+
if (entry.status !== "loading") completed++;
|
|
361
|
+
}
|
|
362
|
+
return Object.freeze({
|
|
363
|
+
total,
|
|
364
|
+
completed,
|
|
365
|
+
failed,
|
|
366
|
+
fallback,
|
|
367
|
+
loading,
|
|
368
|
+
ratio: total ? completed / total : 1,
|
|
369
|
+
state: loading ? "loading" : total ? "complete" : "idle"
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
var nextStore = 0;
|
|
373
|
+
var AssetHandle = class AssetHandle {
|
|
374
|
+
kind;
|
|
375
|
+
entry;
|
|
376
|
+
store;
|
|
377
|
+
remove;
|
|
378
|
+
ready;
|
|
379
|
+
cancelled = new AbortController();
|
|
380
|
+
imageSource;
|
|
381
|
+
/** @internal */
|
|
382
|
+
constructor(kind, entry, store, remove) {
|
|
383
|
+
this.kind = kind;
|
|
384
|
+
this.entry = entry;
|
|
385
|
+
this.store = store;
|
|
386
|
+
this.remove = remove;
|
|
387
|
+
this.imageSource = kind === "image" ? Object.freeze({
|
|
388
|
+
kind: "image",
|
|
389
|
+
url: entry.id
|
|
390
|
+
}) : void 0;
|
|
391
|
+
this.ready = store.ready(entry, this.cancelled.signal).then(() => this);
|
|
392
|
+
this.ready.catch(() => {});
|
|
393
|
+
}
|
|
394
|
+
get id() {
|
|
395
|
+
return this.entry.id;
|
|
396
|
+
}
|
|
397
|
+
get url() {
|
|
398
|
+
return this.entry.url;
|
|
399
|
+
}
|
|
400
|
+
get status() {
|
|
401
|
+
return this.cancelled.signal.aborted ? "released" : this.entry.status;
|
|
402
|
+
}
|
|
403
|
+
get loading() {
|
|
404
|
+
return !this.cancelled.signal.aborted && this.entry.task !== void 0;
|
|
405
|
+
}
|
|
406
|
+
get value() {
|
|
407
|
+
return this.status === "released" ? void 0 : this.entry.asset?.value;
|
|
408
|
+
}
|
|
409
|
+
get error() {
|
|
410
|
+
return this.status === "released" ? void 0 : this.entry.error;
|
|
411
|
+
}
|
|
412
|
+
get progress() {
|
|
413
|
+
return progress(this.status === "released" ? [] : [this.entry]);
|
|
414
|
+
}
|
|
415
|
+
/** Stable Renderable source. SpriteSheet accepts its URL. */
|
|
416
|
+
get source() {
|
|
417
|
+
return this.imageSource;
|
|
418
|
+
}
|
|
419
|
+
onChange(listener) {
|
|
420
|
+
const signal = this.cancelled.signal;
|
|
421
|
+
signal.throwIfAborted();
|
|
422
|
+
const notify = () => listener(this);
|
|
423
|
+
const off = () => {
|
|
424
|
+
this.entry.listeners.delete(notify);
|
|
425
|
+
signal.removeEventListener("abort", off);
|
|
426
|
+
};
|
|
427
|
+
this.entry.listeners.add(notify);
|
|
428
|
+
signal.addEventListener("abort", off, { once: true });
|
|
429
|
+
return off;
|
|
430
|
+
}
|
|
431
|
+
/** @internal A playback or Game font owns a separate reference. */
|
|
432
|
+
retain(store) {
|
|
433
|
+
this.cancelled.signal.throwIfAborted();
|
|
434
|
+
if (this.store !== store) throw new EngineError("ASSET_OWNERSHIP", "Asset belongs to another Game");
|
|
435
|
+
let release = () => {};
|
|
436
|
+
const use = new AssetHandle(this.kind, this.entry, store, () => release());
|
|
437
|
+
release = store.retain(this.entry, () => use.release());
|
|
438
|
+
return use;
|
|
439
|
+
}
|
|
440
|
+
async reload(options = {}) {
|
|
441
|
+
if (this.cancelled.signal.aborted) throw cancelled();
|
|
442
|
+
await wait(this.store.start(this.entry, options, true), this.cancelled.signal);
|
|
443
|
+
return this;
|
|
444
|
+
}
|
|
445
|
+
release() {
|
|
446
|
+
if (this.cancelled.signal.aborted) return;
|
|
447
|
+
this.cancelled.abort(cancelled());
|
|
448
|
+
this.remove();
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
var AssetScope = class AssetScope {
|
|
452
|
+
store;
|
|
453
|
+
global;
|
|
454
|
+
lifetime = new Lifetime();
|
|
455
|
+
handles = /* @__PURE__ */ new Map();
|
|
456
|
+
listeners = /* @__PURE__ */ new Set();
|
|
457
|
+
previous;
|
|
458
|
+
/** @internal */
|
|
459
|
+
constructor(store, owner, global = false) {
|
|
460
|
+
this.store = store;
|
|
461
|
+
this.global = global;
|
|
462
|
+
if (owner?.disposed) throw cancelled();
|
|
463
|
+
const unregister = store.register(this);
|
|
464
|
+
let removeOwner;
|
|
465
|
+
try {
|
|
466
|
+
removeOwner = owner?.onDispose(() => this.dispose());
|
|
467
|
+
} catch (error) {
|
|
468
|
+
unregister();
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
471
|
+
const off = store.observe(() => this.notify());
|
|
472
|
+
this.lifetime.onDispose(() => {
|
|
473
|
+
off();
|
|
474
|
+
unregister();
|
|
475
|
+
removeOwner?.();
|
|
476
|
+
this.listeners.clear();
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
get disposed() {
|
|
480
|
+
return this.lifetime.disposed;
|
|
481
|
+
}
|
|
482
|
+
onDispose(callback) {
|
|
483
|
+
return this.lifetime.onDispose(callback);
|
|
484
|
+
}
|
|
485
|
+
scope(owner = this) {
|
|
486
|
+
if (this.disposed) throw cancelled();
|
|
487
|
+
return new AssetScope(this.store, owner);
|
|
488
|
+
}
|
|
489
|
+
load(request) {
|
|
490
|
+
if (this.disposed) throw cancelled();
|
|
491
|
+
const entry = this.store.entry(request);
|
|
492
|
+
const existing = this.handles.get(entry);
|
|
493
|
+
if (existing) return existing;
|
|
494
|
+
let drop = () => {};
|
|
495
|
+
const handle = new AssetHandle(request.kind, entry, this.store, () => {
|
|
496
|
+
this.handles.delete(entry);
|
|
497
|
+
drop();
|
|
498
|
+
});
|
|
499
|
+
this.handles.set(entry, handle);
|
|
500
|
+
drop = this.store.retain(entry, () => handle.release());
|
|
501
|
+
if (entry.status === "loading" && !entry.task) this.store.start(entry, request, false).catch(() => {});
|
|
502
|
+
else this.store.changed();
|
|
503
|
+
return handle;
|
|
504
|
+
}
|
|
505
|
+
get progress() {
|
|
506
|
+
return this.global ? this.store.progress : progress(this.handles.keys());
|
|
507
|
+
}
|
|
508
|
+
onProgress(listener) {
|
|
509
|
+
if (this.disposed) throw cancelled();
|
|
510
|
+
this.listeners.add(listener);
|
|
511
|
+
return () => {
|
|
512
|
+
this.listeners.delete(listener);
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
notify() {
|
|
516
|
+
if (!this.listeners.size) return;
|
|
517
|
+
const snapshot = this.progress;
|
|
518
|
+
const previous = this.previous;
|
|
519
|
+
if (previous && previous.total === snapshot.total && previous.completed === snapshot.completed && previous.failed === snapshot.failed && previous.fallback === snapshot.fallback && previous.loading === snapshot.loading) return;
|
|
520
|
+
this.previous = snapshot;
|
|
521
|
+
for (const listener of [...this.listeners]) if (this.listeners.has(listener)) this.store.deliver(() => listener(snapshot));
|
|
522
|
+
}
|
|
523
|
+
dispose() {
|
|
524
|
+
if (this.disposed) return;
|
|
525
|
+
try {
|
|
526
|
+
this.lifetime.dispose();
|
|
527
|
+
} finally {
|
|
528
|
+
for (const handle of [...this.handles.values()]) handle.release();
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
/** @internal One resource directory per Game. The adapter owns decoded resources. */
|
|
533
|
+
var AssetStore = class {
|
|
534
|
+
adapter;
|
|
535
|
+
logger;
|
|
536
|
+
base;
|
|
537
|
+
prefix = `simpludify-asset:${++nextStore}:`;
|
|
538
|
+
entries = /* @__PURE__ */ new Map();
|
|
539
|
+
images = /* @__PURE__ */ new Map();
|
|
540
|
+
idle = /* @__PURE__ */ new Set();
|
|
541
|
+
listeners = /* @__PURE__ */ new Set();
|
|
542
|
+
scopes = /* @__PURE__ */ new Set();
|
|
543
|
+
notifying = false;
|
|
544
|
+
notificationScheduled = false;
|
|
545
|
+
capacity;
|
|
546
|
+
customFallback = {};
|
|
547
|
+
sequence = 0;
|
|
548
|
+
dead = false;
|
|
549
|
+
constructor(adapter, logger, base, options = {}) {
|
|
550
|
+
this.adapter = adapter;
|
|
551
|
+
this.logger = logger;
|
|
552
|
+
this.base = base;
|
|
553
|
+
this.capacity = numberIn(options.cacheEntries ?? 16, 0, 1024, "cache capacity");
|
|
554
|
+
for (const kind of [
|
|
555
|
+
"image",
|
|
556
|
+
"audio",
|
|
557
|
+
"font"
|
|
558
|
+
]) {
|
|
559
|
+
const custom = options.fallback?.[kind];
|
|
560
|
+
if (custom !== void 0) this.customFallback[kind] = assetURL(custom, base);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
register(scope) {
|
|
564
|
+
if (this.dead) throw cancelled();
|
|
565
|
+
this.scopes.add(scope);
|
|
566
|
+
return () => {
|
|
567
|
+
this.scopes.delete(scope);
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
observe(listener) {
|
|
571
|
+
this.listeners.add(listener);
|
|
572
|
+
return () => {
|
|
573
|
+
this.listeners.delete(listener);
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
deliver(listener) {
|
|
577
|
+
const report = (cause) => {
|
|
578
|
+
this.logger.report(cause, {
|
|
579
|
+
source: "engine.assets",
|
|
580
|
+
message: "Asset observer failed",
|
|
581
|
+
fatal: false
|
|
582
|
+
});
|
|
583
|
+
};
|
|
584
|
+
try {
|
|
585
|
+
const result = listener();
|
|
586
|
+
if (result != null) Promise.resolve(result).catch(report);
|
|
587
|
+
} catch (cause) {
|
|
588
|
+
report(cause);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
changed() {
|
|
592
|
+
if (this.notifying) {
|
|
593
|
+
if (!this.notificationScheduled) {
|
|
594
|
+
this.notificationScheduled = true;
|
|
595
|
+
queueMicrotask(() => {
|
|
596
|
+
this.notificationScheduled = false;
|
|
597
|
+
this.changed();
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
this.notifying = true;
|
|
603
|
+
try {
|
|
604
|
+
for (const listener of [...this.listeners]) if (this.listeners.has(listener)) this.deliver(listener);
|
|
605
|
+
} finally {
|
|
606
|
+
this.notifying = false;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
get progress() {
|
|
610
|
+
return progress([...this.entries.values()].filter((entry) => entry.users.size > 0));
|
|
611
|
+
}
|
|
612
|
+
entry(request) {
|
|
613
|
+
if (this.dead) throw cancelled();
|
|
614
|
+
if (![
|
|
615
|
+
"image",
|
|
616
|
+
"audio",
|
|
617
|
+
"font",
|
|
618
|
+
"json"
|
|
619
|
+
].includes(request.kind)) throw invalid("kind");
|
|
620
|
+
settings(request);
|
|
621
|
+
const url = assetURL(request.url, this.base);
|
|
622
|
+
const key = `${request.kind}\0${url}`;
|
|
623
|
+
const existing = this.entries.get(key);
|
|
624
|
+
if (existing) return existing;
|
|
625
|
+
let usable = () => {};
|
|
626
|
+
let fail = () => {};
|
|
627
|
+
const first = new Promise((resolve, reject) => {
|
|
628
|
+
usable = resolve;
|
|
629
|
+
fail = reject;
|
|
630
|
+
});
|
|
631
|
+
first.catch(() => {});
|
|
632
|
+
const entry = {
|
|
633
|
+
key,
|
|
634
|
+
url,
|
|
635
|
+
kind: request.kind,
|
|
636
|
+
id: this.prefix + ++this.sequence,
|
|
637
|
+
first,
|
|
638
|
+
usable,
|
|
639
|
+
fail,
|
|
640
|
+
users: /* @__PURE__ */ new Set(),
|
|
641
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
642
|
+
validators: /* @__PURE__ */ new Set(),
|
|
643
|
+
status: "loading",
|
|
644
|
+
asset: void 0,
|
|
645
|
+
task: void 0,
|
|
646
|
+
controller: void 0,
|
|
647
|
+
error: void 0
|
|
648
|
+
};
|
|
649
|
+
this.entries.set(key, entry);
|
|
650
|
+
if (entry.kind === "image") this.images.set(entry.id, entry);
|
|
651
|
+
return entry;
|
|
652
|
+
}
|
|
653
|
+
retain(entry, dispose) {
|
|
654
|
+
this.idle.delete(entry);
|
|
655
|
+
entry.users.add(dispose);
|
|
656
|
+
return () => {
|
|
657
|
+
if (!entry.users.delete(dispose)) return;
|
|
658
|
+
if (!entry.users.size) {
|
|
659
|
+
if (!this.dead && !entry.task && entry.status === "ready" && this.capacity) {
|
|
660
|
+
this.idle.add(entry);
|
|
661
|
+
if (this.idle.size > this.capacity) {
|
|
662
|
+
const oldest = this.idle.values().next().value;
|
|
663
|
+
if (oldest) this.evict(oldest);
|
|
664
|
+
}
|
|
665
|
+
} else this.evict(entry);
|
|
666
|
+
}
|
|
667
|
+
this.changed();
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
release(resource) {
|
|
671
|
+
if (resource) this.deliver(() => resource.release());
|
|
672
|
+
}
|
|
673
|
+
evict(entry) {
|
|
674
|
+
if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
|
|
675
|
+
this.images.delete(entry.id);
|
|
676
|
+
this.idle.delete(entry);
|
|
677
|
+
entry.controller?.abort(cancelled());
|
|
678
|
+
entry.fail(cancelled());
|
|
679
|
+
const asset = entry.asset;
|
|
680
|
+
entry.asset = void 0;
|
|
681
|
+
this.release(asset);
|
|
682
|
+
}
|
|
683
|
+
async ready(entry, signal) {
|
|
684
|
+
signal.throwIfAborted();
|
|
685
|
+
if (entry.asset) return;
|
|
686
|
+
await wait(entry.status === "failed" && entry.task ? entry.task : entry.first, signal);
|
|
687
|
+
signal.throwIfAborted();
|
|
688
|
+
if (!entry.asset) throw entry.error ?? cancelled();
|
|
689
|
+
}
|
|
690
|
+
start(entry, options, reload) {
|
|
691
|
+
if (entry.task) return entry.task;
|
|
692
|
+
if (this.dead || !entry.users.size) return Promise.reject(cancelled());
|
|
693
|
+
const policy = settings(options);
|
|
694
|
+
const controller = new AbortController();
|
|
695
|
+
entry.controller = controller;
|
|
696
|
+
entry.task = Promise.resolve().then(async () => {
|
|
697
|
+
try {
|
|
698
|
+
await this.load(entry, policy, reload, controller.signal);
|
|
699
|
+
} finally {
|
|
700
|
+
entry.task = void 0;
|
|
701
|
+
entry.controller = void 0;
|
|
702
|
+
this.changed();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
entry.task.catch(() => {});
|
|
706
|
+
const task = entry.task;
|
|
707
|
+
this.changed();
|
|
708
|
+
return task;
|
|
709
|
+
}
|
|
710
|
+
async attempt(promise, signal) {
|
|
711
|
+
promise.then((asset) => {
|
|
712
|
+
if (signal.aborted) this.release(asset);
|
|
713
|
+
}, () => {});
|
|
714
|
+
return await wait(promise, signal);
|
|
715
|
+
}
|
|
716
|
+
commit(entry, asset, fallback) {
|
|
717
|
+
try {
|
|
718
|
+
for (const validate of entry.validators) validate(asset);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
this.release(asset);
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
const old = entry.asset;
|
|
724
|
+
entry.asset = asset;
|
|
725
|
+
entry.status = fallback ? "fallback" : "ready";
|
|
726
|
+
if (!fallback) entry.error = void 0;
|
|
727
|
+
entry.usable();
|
|
728
|
+
for (const listener of [...entry.listeners]) if (entry.listeners.has(listener)) this.deliver(listener);
|
|
729
|
+
this.release(old);
|
|
730
|
+
this.changed();
|
|
731
|
+
}
|
|
732
|
+
async fallback(entry, signal, owner) {
|
|
733
|
+
const custom = this.customFallback[entry.kind];
|
|
734
|
+
if (custom && !signal.aborted) try {
|
|
735
|
+
const asset = await this.attempt(this.adapter.fallback(entry.kind, custom, signal), signal);
|
|
736
|
+
if (asset) {
|
|
737
|
+
if (owner.aborted) {
|
|
738
|
+
this.release(asset);
|
|
739
|
+
throw owner.reason;
|
|
740
|
+
}
|
|
741
|
+
this.commit(entry, asset, true);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
} catch (cause) {
|
|
745
|
+
if (owner.aborted) throw owner.reason;
|
|
746
|
+
this.logger.warn("Custom asset fallback failed", {
|
|
747
|
+
source: "engine.assets",
|
|
748
|
+
cause
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
const asset = await this.attempt(this.adapter.fallback(entry.kind, void 0, owner), owner);
|
|
752
|
+
if (asset) {
|
|
753
|
+
if (owner.aborted) {
|
|
754
|
+
this.release(asset);
|
|
755
|
+
throw owner.reason;
|
|
756
|
+
}
|
|
757
|
+
this.commit(entry, asset, true);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
async load(entry, policy, reload, owner) {
|
|
761
|
+
const deadline = AbortSignal.timeout(policy.timeoutMs);
|
|
762
|
+
const signal = AbortSignal.any([owner, deadline]);
|
|
763
|
+
let network = policy.network;
|
|
764
|
+
let once = policy.once;
|
|
765
|
+
let failure;
|
|
766
|
+
try {
|
|
767
|
+
for (;;) try {
|
|
768
|
+
signal.throwIfAborted();
|
|
769
|
+
const asset = await this.attempt(this.adapter.load(entry.kind, entry.url, signal, reload), signal);
|
|
770
|
+
if (!asset) throw invalid("adapter result");
|
|
771
|
+
if (signal.aborted) {
|
|
772
|
+
this.release(asset);
|
|
773
|
+
throw signal.reason;
|
|
774
|
+
}
|
|
775
|
+
this.commit(entry, asset, false);
|
|
776
|
+
return;
|
|
777
|
+
} catch (cause) {
|
|
778
|
+
owner.throwIfAborted();
|
|
779
|
+
failure = cause;
|
|
780
|
+
entry.error = cause;
|
|
781
|
+
if (!entry.asset) {
|
|
782
|
+
await this.fallback(entry, signal, owner);
|
|
783
|
+
if (entry.asset) this.logger.warn("Using an asset fallback", {
|
|
784
|
+
source: "engine.assets",
|
|
785
|
+
code: "ASSET_FALLBACK",
|
|
786
|
+
details: {
|
|
787
|
+
kind: entry.kind,
|
|
788
|
+
id: entry.id
|
|
789
|
+
},
|
|
790
|
+
cause
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (signal.aborted) break;
|
|
794
|
+
if (cause instanceof EngineError && cause.code === "ASSET_NETWORK") {
|
|
795
|
+
if (!network) break;
|
|
796
|
+
network--;
|
|
797
|
+
await pause(policy.networkDelayMs, signal);
|
|
798
|
+
} else {
|
|
799
|
+
const retry = once;
|
|
800
|
+
once = void 0;
|
|
801
|
+
if (!retry) break;
|
|
802
|
+
if (retry !== "immediate") if ("delayMs" in retry) await pause(retry.delayMs, signal);
|
|
803
|
+
else await wait(Promise.resolve(retry.until), signal);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
} catch (cause) {
|
|
807
|
+
owner.throwIfAborted();
|
|
808
|
+
failure = cause;
|
|
809
|
+
}
|
|
810
|
+
if (deadline.aborted) failure = new EngineError("ASSET_TIMEOUT", "Asset load deadline expired", {}, { cause: failure });
|
|
811
|
+
failure ??= new EngineError("ASSET_LOAD_FAILED", "Asset could not be loaded");
|
|
812
|
+
entry.error = failure;
|
|
813
|
+
if (!entry.asset) {
|
|
814
|
+
entry.status = "failed";
|
|
815
|
+
entry.fail(failure);
|
|
816
|
+
}
|
|
817
|
+
this.logger.report(failure, {
|
|
818
|
+
source: "engine.assets",
|
|
819
|
+
message: "Asset loading failed",
|
|
820
|
+
fatal: false
|
|
821
|
+
});
|
|
822
|
+
if (reload || !entry.asset) throw failure;
|
|
823
|
+
}
|
|
824
|
+
async image(id, signal) {
|
|
825
|
+
const entry = this.images.get(id);
|
|
826
|
+
if (!entry || this.dead || signal.aborted) throw cancelled();
|
|
827
|
+
const controller = new AbortController();
|
|
828
|
+
const pending = AbortSignal.any([signal, controller.signal]);
|
|
829
|
+
let frames;
|
|
830
|
+
const subscriptions = /* @__PURE__ */ new Set();
|
|
831
|
+
const validate = (asset) => {
|
|
832
|
+
for (const frame of frames?.() ?? []) asset.image?.textureFor(frame);
|
|
833
|
+
};
|
|
834
|
+
const release = () => {
|
|
835
|
+
if (controller.signal.aborted) return;
|
|
836
|
+
controller.abort(cancelled());
|
|
837
|
+
for (const listener of subscriptions) entry.listeners.delete(listener);
|
|
838
|
+
subscriptions.clear();
|
|
839
|
+
entry.validators.delete(validate);
|
|
840
|
+
drop();
|
|
841
|
+
};
|
|
842
|
+
const drop = this.retain(entry, release);
|
|
843
|
+
try {
|
|
844
|
+
await this.ready(entry, pending);
|
|
845
|
+
pending.throwIfAborted();
|
|
846
|
+
entry.validators.add(validate);
|
|
847
|
+
const current = () => {
|
|
848
|
+
if (controller.signal.aborted || !entry.asset?.image) throw cancelled();
|
|
849
|
+
return entry.asset.image;
|
|
850
|
+
};
|
|
851
|
+
return {
|
|
852
|
+
get texture() {
|
|
853
|
+
return current().texture;
|
|
854
|
+
},
|
|
855
|
+
textureFor: (frame) => {
|
|
856
|
+
const lease = current();
|
|
857
|
+
if (!frame) return lease.texture;
|
|
858
|
+
return entry.status === "fallback" ? lease.texture : lease.textureFor(frame);
|
|
859
|
+
},
|
|
860
|
+
useFrames: (required) => {
|
|
861
|
+
frames = required;
|
|
862
|
+
},
|
|
863
|
+
onChange: (listener) => {
|
|
864
|
+
if (controller.signal.aborted) return () => {};
|
|
865
|
+
subscriptions.add(listener);
|
|
866
|
+
entry.listeners.add(listener);
|
|
867
|
+
return () => {
|
|
868
|
+
subscriptions.delete(listener);
|
|
869
|
+
entry.listeners.delete(listener);
|
|
870
|
+
};
|
|
871
|
+
},
|
|
872
|
+
release
|
|
873
|
+
};
|
|
874
|
+
} catch (error) {
|
|
875
|
+
release();
|
|
876
|
+
throw error;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
dispose() {
|
|
880
|
+
if (this.dead) return;
|
|
881
|
+
this.dead = true;
|
|
882
|
+
this.listeners.clear();
|
|
883
|
+
for (const scope of [...this.scopes]) this.deliver(() => scope.dispose());
|
|
884
|
+
for (const entry of [...this.entries.values()]) {
|
|
885
|
+
for (const dispose of [...entry.users]) this.deliver(dispose);
|
|
886
|
+
if (this.entries.get(entry.key) === entry) this.evict(entry);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
};
|
|
890
|
+
function isAssetImage(url) {
|
|
891
|
+
return url.startsWith("simpludify-asset:");
|
|
892
|
+
}
|
|
893
|
+
// packages/web/src/audio.ts
|
|
894
|
+
function volume(value) {
|
|
895
|
+
if (!Number.isFinite(value) || value < 0 || value > 1) throw new EngineError("INVALID_VALUE", "Audio volume must be between zero and one");
|
|
896
|
+
return value;
|
|
897
|
+
}
|
|
898
|
+
function voiceLimit(value) {
|
|
899
|
+
if (value !== void 0 && (!Number.isSafeInteger(value) || value <= 0)) throw new EngineError("INVALID_VALUE", "Audio voice limit must be a positive integer");
|
|
900
|
+
return value;
|
|
901
|
+
}
|
|
902
|
+
var AudioGroup = class {
|
|
903
|
+
name;
|
|
904
|
+
parent;
|
|
905
|
+
gain;
|
|
906
|
+
level;
|
|
907
|
+
maxVoices;
|
|
908
|
+
/** @internal */
|
|
909
|
+
constructor(name, parent, options = {}) {
|
|
910
|
+
this.name = name;
|
|
911
|
+
this.parent = parent;
|
|
912
|
+
this.level = volume(options.volume ?? 1);
|
|
913
|
+
this.maxVoices = voiceLimit(options.maxVoices);
|
|
914
|
+
}
|
|
915
|
+
get volume() {
|
|
916
|
+
return this.level;
|
|
917
|
+
}
|
|
918
|
+
set volume(value) {
|
|
919
|
+
this.level = volume(value);
|
|
920
|
+
if (this.gain) this.gain.gain.value = this.level;
|
|
921
|
+
}
|
|
922
|
+
/** @internal */
|
|
923
|
+
node(context) {
|
|
924
|
+
if (this.gain) return this.gain;
|
|
925
|
+
const output = this.parent?.node(context) ?? context.destination;
|
|
926
|
+
const gain = context.createGain();
|
|
927
|
+
gain.gain.value = this.level;
|
|
928
|
+
gain.connect(output);
|
|
929
|
+
this.gain = gain;
|
|
930
|
+
return gain;
|
|
931
|
+
}
|
|
932
|
+
/** @internal */
|
|
933
|
+
disconnect() {
|
|
934
|
+
this.gain?.disconnect();
|
|
935
|
+
this.gain = void 0;
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
var AudioPlayback = class {
|
|
939
|
+
audio;
|
|
940
|
+
use;
|
|
941
|
+
group;
|
|
942
|
+
finished;
|
|
943
|
+
assetId;
|
|
944
|
+
resolve = () => {};
|
|
945
|
+
phase = "pending";
|
|
946
|
+
done = false;
|
|
947
|
+
source;
|
|
948
|
+
gain;
|
|
949
|
+
buffer;
|
|
950
|
+
position = 0;
|
|
951
|
+
startedAt = 0;
|
|
952
|
+
level;
|
|
953
|
+
looping;
|
|
954
|
+
offOwner;
|
|
955
|
+
/** @internal */
|
|
956
|
+
constructor(audio, use, group, options) {
|
|
957
|
+
this.audio = audio;
|
|
958
|
+
this.use = use;
|
|
959
|
+
this.group = group;
|
|
960
|
+
this.assetId = use.id;
|
|
961
|
+
this.level = volume(options.volume ?? 1);
|
|
962
|
+
this.looping = options.loop ?? false;
|
|
963
|
+
this.finished = new Promise((resolve) => {
|
|
964
|
+
this.resolve = resolve;
|
|
965
|
+
});
|
|
966
|
+
this.offOwner = options.owner?.onDispose(() => this.stop());
|
|
967
|
+
use.ready.then(() => this.startIfReady()).catch((cause) => this.fail(cause));
|
|
968
|
+
}
|
|
969
|
+
get state() {
|
|
970
|
+
return this.phase;
|
|
971
|
+
}
|
|
972
|
+
get volume() {
|
|
973
|
+
return this.level;
|
|
974
|
+
}
|
|
975
|
+
set volume(value) {
|
|
976
|
+
this.level = volume(value);
|
|
977
|
+
if (this.gain) this.gain.gain.value = this.level;
|
|
978
|
+
}
|
|
979
|
+
get loop() {
|
|
980
|
+
return this.looping;
|
|
981
|
+
}
|
|
982
|
+
set loop(value) {
|
|
983
|
+
if (typeof value !== "boolean") throw new EngineError("INVALID_VALUE", "Audio loop must be boolean");
|
|
984
|
+
this.looping = value;
|
|
985
|
+
if (this.source) this.source.loop = value;
|
|
986
|
+
}
|
|
987
|
+
pause() {
|
|
988
|
+
if (this.phase !== "playing" && this.phase !== "pending") return;
|
|
989
|
+
try {
|
|
990
|
+
if (this.phase === "playing") {
|
|
991
|
+
this.position += Math.max(0, this.audio.device().currentTime - this.startedAt);
|
|
992
|
+
this.clearSource(true);
|
|
993
|
+
}
|
|
994
|
+
this.phase = "paused";
|
|
995
|
+
} catch (cause) {
|
|
996
|
+
this.fail(cause);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
resume() {
|
|
1000
|
+
if (this.phase !== "paused") return;
|
|
1001
|
+
this.phase = "pending";
|
|
1002
|
+
this.startIfReady();
|
|
1003
|
+
}
|
|
1004
|
+
stop() {
|
|
1005
|
+
this.finish("stopped");
|
|
1006
|
+
}
|
|
1007
|
+
/** @internal */
|
|
1008
|
+
replace() {
|
|
1009
|
+
this.finish("replaced");
|
|
1010
|
+
}
|
|
1011
|
+
/** @internal */
|
|
1012
|
+
startIfReady() {
|
|
1013
|
+
if (this.phase !== "pending") return;
|
|
1014
|
+
const current = this.buffer === void 0 ? this.use.value : this.buffer;
|
|
1015
|
+
if (current === void 0) return;
|
|
1016
|
+
try {
|
|
1017
|
+
const context = this.audio.device();
|
|
1018
|
+
if (context.state !== "running") return;
|
|
1019
|
+
this.buffer = current;
|
|
1020
|
+
const buffer = current ?? context.createBuffer(1, 1, context.sampleRate);
|
|
1021
|
+
if (this.position >= buffer.duration) {
|
|
1022
|
+
if (!this.looping) {
|
|
1023
|
+
this.finish("ended");
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
this.position %= buffer.duration;
|
|
1027
|
+
}
|
|
1028
|
+
if (!this.gain) {
|
|
1029
|
+
this.gain = context.createGain();
|
|
1030
|
+
this.gain.gain.value = this.level;
|
|
1031
|
+
this.gain.connect(this.group.node(context));
|
|
1032
|
+
}
|
|
1033
|
+
const source = context.createBufferSource();
|
|
1034
|
+
this.source = source;
|
|
1035
|
+
source.buffer = buffer;
|
|
1036
|
+
source.loop = this.looping;
|
|
1037
|
+
source.connect(this.gain);
|
|
1038
|
+
source.onended = () => {
|
|
1039
|
+
if (this.source === source) this.finish("ended");
|
|
1040
|
+
};
|
|
1041
|
+
this.startedAt = context.currentTime;
|
|
1042
|
+
source.start(0, this.position);
|
|
1043
|
+
this.phase = "playing";
|
|
1044
|
+
} catch (cause) {
|
|
1045
|
+
this.fail(cause);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
clearSource(stop) {
|
|
1049
|
+
const source = this.source;
|
|
1050
|
+
this.source = void 0;
|
|
1051
|
+
if (!source) return;
|
|
1052
|
+
source.onended = null;
|
|
1053
|
+
try {
|
|
1054
|
+
if (stop) source.stop();
|
|
1055
|
+
} finally {
|
|
1056
|
+
source.disconnect();
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
fail(cause) {
|
|
1060
|
+
if (this.done) return;
|
|
1061
|
+
this.audio.report(cause, "Audio playback failed");
|
|
1062
|
+
this.finish("failed");
|
|
1063
|
+
}
|
|
1064
|
+
finish(reason) {
|
|
1065
|
+
if (this.done) return;
|
|
1066
|
+
this.done = true;
|
|
1067
|
+
this.phase = reason;
|
|
1068
|
+
this.buffer = void 0;
|
|
1069
|
+
this.audio.remove(this);
|
|
1070
|
+
try {
|
|
1071
|
+
cleanupAll([
|
|
1072
|
+
() => this.clearSource(reason !== "ended"),
|
|
1073
|
+
() => this.gain?.disconnect(),
|
|
1074
|
+
() => this.offOwner?.(),
|
|
1075
|
+
() => this.use.release()
|
|
1076
|
+
], "Audio playback cleanup failed");
|
|
1077
|
+
} catch (cause) {
|
|
1078
|
+
this.audio.report(cause, "Audio playback cleanup failed");
|
|
1079
|
+
} finally {
|
|
1080
|
+
this.offOwner = void 0;
|
|
1081
|
+
this.gain = void 0;
|
|
1082
|
+
this.resolve(reason);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
/** One Web Audio context per Game, created only when audio is requested. */
|
|
1087
|
+
var GameAudio = class {
|
|
1088
|
+
assets;
|
|
1089
|
+
logger;
|
|
1090
|
+
shutdown = new AbortController();
|
|
1091
|
+
groups = /* @__PURE__ */ new Map();
|
|
1092
|
+
voices = /* @__PURE__ */ new Set();
|
|
1093
|
+
context;
|
|
1094
|
+
master = new AudioGroup("master", void 0);
|
|
1095
|
+
/** @internal */
|
|
1096
|
+
constructor(host, assets, logger) {
|
|
1097
|
+
this.assets = assets;
|
|
1098
|
+
this.logger = logger;
|
|
1099
|
+
this.groups.set("master", this.master);
|
|
1100
|
+
this.group("music");
|
|
1101
|
+
this.group("effects");
|
|
1102
|
+
const gesture = (event) => {
|
|
1103
|
+
if (event.isTrusted && (this.context || this.voices.size)) this.unlock();
|
|
1104
|
+
};
|
|
1105
|
+
const options = {
|
|
1106
|
+
capture: true,
|
|
1107
|
+
signal: this.shutdown.signal
|
|
1108
|
+
};
|
|
1109
|
+
host.addEventListener("pointerup", gesture, options);
|
|
1110
|
+
host.addEventListener("keydown", gesture, options);
|
|
1111
|
+
}
|
|
1112
|
+
get state() {
|
|
1113
|
+
return this.context?.state ?? (this.shutdown.signal.aborted ? "closed" : "idle");
|
|
1114
|
+
}
|
|
1115
|
+
group(name, options) {
|
|
1116
|
+
this.assertAlive();
|
|
1117
|
+
if (typeof name !== "string" || !name.trim()) throw new EngineError("INVALID_VALUE", "Audio group name cannot be empty");
|
|
1118
|
+
const existing = this.groups.get(name);
|
|
1119
|
+
if (existing) {
|
|
1120
|
+
if (options) throw new EngineError("AUDIO_GROUP_EXISTS", "Audio group is already configured");
|
|
1121
|
+
return existing;
|
|
1122
|
+
}
|
|
1123
|
+
const parent = this.groups.get(options?.parent ?? "master");
|
|
1124
|
+
if (!parent) throw new EngineError("AUDIO_GROUP_UNKNOWN", "Unknown parent audio group");
|
|
1125
|
+
const group = new AudioGroup(name, parent, options);
|
|
1126
|
+
this.groups.set(name, group);
|
|
1127
|
+
return group;
|
|
1128
|
+
}
|
|
1129
|
+
play(asset, options = {}) {
|
|
1130
|
+
this.assertAlive();
|
|
1131
|
+
if (asset?.kind !== "audio") throw new EngineError("INVALID_VALUE", "An audio asset is required");
|
|
1132
|
+
if (options.owner?.disposed) throw new EngineError("OWNER_DISPOSED", "Audio playback owner is disposed");
|
|
1133
|
+
volume(options.volume ?? 1);
|
|
1134
|
+
if (options.loop !== void 0 && typeof options.loop !== "boolean") throw new EngineError("INVALID_VALUE", "Audio loop must be boolean");
|
|
1135
|
+
const limit = voiceLimit(options.maxVoices);
|
|
1136
|
+
const group = this.groups.get(options.group ?? "effects");
|
|
1137
|
+
if (!group) throw new EngineError("AUDIO_GROUP_UNKNOWN", "Unknown playback group");
|
|
1138
|
+
const use = asset.retain(this.assets);
|
|
1139
|
+
let voice;
|
|
1140
|
+
try {
|
|
1141
|
+
voice = new AudioPlayback(this, use, group, options);
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
use.release();
|
|
1144
|
+
throw error;
|
|
1145
|
+
}
|
|
1146
|
+
this.voices.add(voice);
|
|
1147
|
+
if (limit !== void 0) this.enforceLimit(limit, (item) => item.assetId === voice.assetId);
|
|
1148
|
+
for (let node = group; node; node = node.parent) {
|
|
1149
|
+
const limit = node.maxVoices;
|
|
1150
|
+
const ancestor = node;
|
|
1151
|
+
if (limit !== void 0) this.enforceLimit(limit, (item) => {
|
|
1152
|
+
for (let parent = item.group; parent; parent = parent.parent) if (parent === ancestor) return true;
|
|
1153
|
+
return false;
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
return voice;
|
|
1157
|
+
}
|
|
1158
|
+
enforceLimit(limit, applies) {
|
|
1159
|
+
const voices = [...this.voices].filter(applies);
|
|
1160
|
+
for (const voice of voices.slice(0, Math.max(0, voices.length - limit))) voice.replace();
|
|
1161
|
+
}
|
|
1162
|
+
assertAlive() {
|
|
1163
|
+
if (this.shutdown.signal.aborted) throw new EngineError("GAME_DISPOSED", "Audio is disposed");
|
|
1164
|
+
}
|
|
1165
|
+
/** @internal */
|
|
1166
|
+
device() {
|
|
1167
|
+
this.assertAlive();
|
|
1168
|
+
if (!this.context) {
|
|
1169
|
+
this.context = new AudioContext();
|
|
1170
|
+
this.context.addEventListener("statechange", this.drain, { signal: this.shutdown.signal });
|
|
1171
|
+
}
|
|
1172
|
+
return this.context;
|
|
1173
|
+
}
|
|
1174
|
+
/** @internal */
|
|
1175
|
+
decode(bytes) {
|
|
1176
|
+
return this.device().decodeAudioData(bytes);
|
|
1177
|
+
}
|
|
1178
|
+
/** Call from a trusted user gesture to start pending playback. */
|
|
1179
|
+
async unlock() {
|
|
1180
|
+
if (this.shutdown.signal.aborted) return false;
|
|
1181
|
+
try {
|
|
1182
|
+
const context = this.device();
|
|
1183
|
+
if (context.state !== "running") {
|
|
1184
|
+
if (navigator.userActivation?.isActive === false) return false;
|
|
1185
|
+
const signal = this.shutdown.signal;
|
|
1186
|
+
let timer;
|
|
1187
|
+
let abort = () => {};
|
|
1188
|
+
try {
|
|
1189
|
+
await Promise.race([context.resume(), new Promise((resolve) => {
|
|
1190
|
+
abort = resolve;
|
|
1191
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1192
|
+
timer = setTimeout(resolve, 1500);
|
|
1193
|
+
})]);
|
|
1194
|
+
} finally {
|
|
1195
|
+
clearTimeout(timer);
|
|
1196
|
+
signal.removeEventListener("abort", abort);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (this.shutdown.signal.aborted || context.state !== "running") return false;
|
|
1200
|
+
this.drain();
|
|
1201
|
+
return true;
|
|
1202
|
+
} catch (cause) {
|
|
1203
|
+
if (!this.shutdown.signal.aborted) this.report(cause, "Audio context could not resume");
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
drain = () => {
|
|
1208
|
+
for (const voice of [...this.voices]) voice.startIfReady();
|
|
1209
|
+
};
|
|
1210
|
+
/** @internal */
|
|
1211
|
+
remove(voice) {
|
|
1212
|
+
this.voices.delete(voice);
|
|
1213
|
+
}
|
|
1214
|
+
/** @internal */
|
|
1215
|
+
report(cause, message) {
|
|
1216
|
+
this.logger.report(cause, {
|
|
1217
|
+
source: "engine.audio",
|
|
1218
|
+
message,
|
|
1219
|
+
fatal: false
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
/** @internal */
|
|
1223
|
+
dispose() {
|
|
1224
|
+
if (this.shutdown.signal.aborted) return;
|
|
1225
|
+
this.shutdown.abort();
|
|
1226
|
+
const context = this.context;
|
|
1227
|
+
this.context = void 0;
|
|
1228
|
+
try {
|
|
1229
|
+
cleanupAll([...[...this.voices].map((voice) => () => voice.stop()), ...[...this.groups.values()].reverse().map((group) => () => group.disconnect())], "Audio cleanup failed");
|
|
1230
|
+
} finally {
|
|
1231
|
+
this.groups.clear();
|
|
1232
|
+
if (context && context.state !== "closed") context.close().catch((cause) => this.report(cause, "Audio context could not close"));
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
20
1236
|
// packages/web/src/gamepad-layout.ts
|
|
21
1237
|
var positions = Object.fromEntries(Object.entries(GamepadButton).map(([name, index]) => [index, name]));
|
|
22
1238
|
var faces = Object.freeze({
|
|
@@ -1100,7 +2316,7 @@ function nativeGeometry(el, node, ui, clip) {
|
|
|
1100
2316
|
const background = style.background ?? 2316622;
|
|
1101
2317
|
const color = `#${style.color.toString(16).padStart(6, "0")}`;
|
|
1102
2318
|
el.style.cssText = `left:${b.x * scale}px;top:${b.y * scale}px;width:${b.width * scale}px;height:${b.height * scale}px;padding:${style.padding * scale}px;font-size:${style.fontSize * scale}px;--s-native-color:${color};--s-native-background:#${background.toString(16).padStart(6, "0")};color-scheme:${(background >> 16) * .2126 + (background >> 8 & 255) * .7152 + (background & 255) * .0722 > 128 ? "light" : "dark"};clip-path:inset(${Math.max(0, c.y - b.y) * scale}px ${Math.max(0, b.x + b.width - c.x - c.width) * scale}px ${Math.max(0, b.y + b.height - c.y - c.height) * scale}px ${Math.max(0, c.x - b.x) * scale}px);`;
|
|
1103
|
-
el.style.fontFamily = style.fontFamily;
|
|
2319
|
+
el.style.fontFamily = node.inheritsFont ? `var(--s-font, ${defaultCanvasStyle.fontFamily})` : style.fontFamily;
|
|
1104
2320
|
if (el.disabled && document.activeElement === el) el.blur();
|
|
1105
2321
|
}
|
|
1106
2322
|
/** Native editors must not cover later Canvas hit surfaces or overlay scrollbars. */
|
|
@@ -1166,6 +2382,7 @@ var CanvasVisual = class {
|
|
|
1166
2382
|
entries = /* @__PURE__ */ new Map();
|
|
1167
2383
|
metrics = /* @__PURE__ */ new Map();
|
|
1168
2384
|
dirty = true;
|
|
2385
|
+
defaultFont = defaultCanvasStyle.fontFamily;
|
|
1169
2386
|
dead = false;
|
|
1170
2387
|
active = false;
|
|
1171
2388
|
cacheResolution;
|
|
@@ -1230,7 +2447,8 @@ var CanvasVisual = class {
|
|
|
1230
2447
|
};
|
|
1231
2448
|
}
|
|
1232
2449
|
measure = (node, contentWidth = Infinity) => {
|
|
1233
|
-
const { fontSize,
|
|
2450
|
+
const { fontSize, textWrap } = node.style;
|
|
2451
|
+
const fontFamily = this.fontFamily(node);
|
|
1234
2452
|
const content = canvasControlText(node);
|
|
1235
2453
|
const adornment = node.control?.model.kind === "checkbox" ? 30 : 0;
|
|
1236
2454
|
const key = JSON.stringify([
|
|
@@ -1390,8 +2608,16 @@ var CanvasVisual = class {
|
|
|
1390
2608
|
button.style.cssText = `left:${c.x * s}px;top:${c.y * s}px;width:${c.width * s}px;height:${c.height * s}px;`;
|
|
1391
2609
|
if (button.disabled && document.activeElement === button) button.blur();
|
|
1392
2610
|
}
|
|
2611
|
+
fontFamily(node) {
|
|
2612
|
+
return node.inheritsFont ? this.defaultFont : node.style.fontFamily;
|
|
2613
|
+
}
|
|
1393
2614
|
update(width, height) {
|
|
1394
2615
|
if (this.dead || this.ui.disposed) return;
|
|
2616
|
+
const family = this.host.style.getPropertyValue("--s-font").trim() || defaultCanvasStyle.fontFamily;
|
|
2617
|
+
if (this.defaultFont !== family) {
|
|
2618
|
+
this.defaultFont = family;
|
|
2619
|
+
this.ui.invalidate(true);
|
|
2620
|
+
}
|
|
1395
2621
|
const layout = this.ui.layout(width, height, this.measure);
|
|
1396
2622
|
if (!this.active) {
|
|
1397
2623
|
this.host.append(this.element);
|
|
@@ -1473,12 +2699,14 @@ var CanvasVisual = class {
|
|
|
1473
2699
|
entry.label.setAttribute("aria-hidden", String(canvasOpacity(node) === 0));
|
|
1474
2700
|
}
|
|
1475
2701
|
const b = node.bounds, s = node.style;
|
|
2702
|
+
const fontFamily = this.fontFamily(node);
|
|
1476
2703
|
const scrollBox = intersectUI(b, clip);
|
|
1477
2704
|
if (node.style.scroll && scrollBox.width > 0 && scrollBox.height > 0 && canvasOpacity(node) > 0) scrollVisible = true;
|
|
1478
2705
|
let enabled = !node.busy;
|
|
1479
2706
|
for (let p = node; p; p = p.parent) enabled &&= p.enabled;
|
|
1480
2707
|
const signature = JSON.stringify([
|
|
1481
2708
|
node.paintRevision,
|
|
2709
|
+
fontFamily,
|
|
1482
2710
|
b.x,
|
|
1483
2711
|
b.y,
|
|
1484
2712
|
b.width,
|
|
@@ -1512,6 +2740,7 @@ var CanvasVisual = class {
|
|
|
1512
2740
|
entry.maskSignature = maskSignature;
|
|
1513
2741
|
const paintSignature = JSON.stringify([
|
|
1514
2742
|
node.paintRevision,
|
|
2743
|
+
fontFamily,
|
|
1515
2744
|
b.width,
|
|
1516
2745
|
b.height,
|
|
1517
2746
|
enabled
|
|
@@ -1569,7 +2798,7 @@ var CanvasVisual = class {
|
|
|
1569
2798
|
entry.text.text = m.text;
|
|
1570
2799
|
}
|
|
1571
2800
|
const textStyleKey = JSON.stringify([
|
|
1572
|
-
|
|
2801
|
+
fontFamily,
|
|
1573
2802
|
s.fontSize,
|
|
1574
2803
|
s.color
|
|
1575
2804
|
]);
|
|
@@ -1577,7 +2806,7 @@ var CanvasVisual = class {
|
|
|
1577
2806
|
this.resources.changedText(entry.text.styleKey);
|
|
1578
2807
|
entry.textStyleKey = textStyleKey;
|
|
1579
2808
|
entry.text.style = {
|
|
1580
|
-
fontFamily
|
|
2809
|
+
fontFamily,
|
|
1581
2810
|
fontSize: s.fontSize,
|
|
1582
2811
|
fill: s.color
|
|
1583
2812
|
};
|
|
@@ -1857,119 +3086,6 @@ function pruneUITextKeys(renderer, keys) {
|
|
|
1857
3086
|
keys.delete(key);
|
|
1858
3087
|
}
|
|
1859
3088
|
}
|
|
1860
|
-
// packages/web/src/shared-images.ts
|
|
1861
|
-
/** Renderer-local, live-reference cache. No idle retention or process-global state. */
|
|
1862
|
-
var SharedImages = class {
|
|
1863
|
-
load;
|
|
1864
|
-
entries = /* @__PURE__ */ new Map();
|
|
1865
|
-
references = 0;
|
|
1866
|
-
dead = false;
|
|
1867
|
-
constructor(load) {
|
|
1868
|
-
this.load = load;
|
|
1869
|
-
}
|
|
1870
|
-
get diagnostics() {
|
|
1871
|
-
let pending = 0;
|
|
1872
|
-
for (const entry of this.entries.values()) if (!entry.resource) pending++;
|
|
1873
|
-
return {
|
|
1874
|
-
imageReferences: this.references,
|
|
1875
|
-
imageEntries: this.entries.size,
|
|
1876
|
-
pendingImages: pending
|
|
1877
|
-
};
|
|
1878
|
-
}
|
|
1879
|
-
acquire(key, signal) {
|
|
1880
|
-
const cancelled = () => new EngineError("GAME_DISPOSED", "Image use cancelled", { path: key });
|
|
1881
|
-
if (this.dead || signal.aborted) return Promise.reject(cancelled());
|
|
1882
|
-
let entry = this.entries.get(key);
|
|
1883
|
-
const fresh = !entry;
|
|
1884
|
-
if (!entry) {
|
|
1885
|
-
entry = {
|
|
1886
|
-
key,
|
|
1887
|
-
controller: new AbortController(),
|
|
1888
|
-
consumers: /* @__PURE__ */ new Set(),
|
|
1889
|
-
frames: /* @__PURE__ */ new Map()
|
|
1890
|
-
};
|
|
1891
|
-
this.entries.set(key, entry);
|
|
1892
|
-
}
|
|
1893
|
-
const current = entry;
|
|
1894
|
-
const promise = new Promise((resolve, reject) => {
|
|
1895
|
-
let released = false, settled = false;
|
|
1896
|
-
const release = () => {
|
|
1897
|
-
if (released) return;
|
|
1898
|
-
released = true;
|
|
1899
|
-
signal.removeEventListener("abort", release);
|
|
1900
|
-
current.consumers.delete(consumer);
|
|
1901
|
-
this.references--;
|
|
1902
|
-
if (!settled) {
|
|
1903
|
-
settled = true;
|
|
1904
|
-
reject(cancelled());
|
|
1905
|
-
}
|
|
1906
|
-
if (!current.consumers.size) {
|
|
1907
|
-
if (this.entries.get(key) === current) this.entries.delete(key);
|
|
1908
|
-
current.controller.abort();
|
|
1909
|
-
const resource = current.resource;
|
|
1910
|
-
delete current.resource;
|
|
1911
|
-
for (const texture of current.frames.values()) texture.destroy(false);
|
|
1912
|
-
current.frames.clear();
|
|
1913
|
-
resource?.release();
|
|
1914
|
-
}
|
|
1915
|
-
};
|
|
1916
|
-
const consumer = {
|
|
1917
|
-
ready: (resource) => {
|
|
1918
|
-
if (released || settled) return;
|
|
1919
|
-
settled = true;
|
|
1920
|
-
signal.removeEventListener("abort", release);
|
|
1921
|
-
resolve({
|
|
1922
|
-
texture: resource.texture,
|
|
1923
|
-
release,
|
|
1924
|
-
textureFor: (frame) => {
|
|
1925
|
-
if (released) throw new EngineError("GAME_DISPOSED", "Image lease released");
|
|
1926
|
-
if (!frame) return resource.texture;
|
|
1927
|
-
const rect = spriteFrameBounds(frame, resource.texture.width, resource.texture.height);
|
|
1928
|
-
const key = `${rect.x},${rect.y},${rect.width},${rect.height}`;
|
|
1929
|
-
let texture = current.frames.get(key);
|
|
1930
|
-
if (!texture) {
|
|
1931
|
-
texture = new Texture({
|
|
1932
|
-
source: resource.texture.source,
|
|
1933
|
-
frame: new Rectangle(rect.x, rect.y, rect.width, rect.height)
|
|
1934
|
-
});
|
|
1935
|
-
current.frames.set(key, texture);
|
|
1936
|
-
}
|
|
1937
|
-
return texture;
|
|
1938
|
-
}
|
|
1939
|
-
});
|
|
1940
|
-
},
|
|
1941
|
-
fail: (error) => {
|
|
1942
|
-
if (released || settled) return;
|
|
1943
|
-
settled = true;
|
|
1944
|
-
reject(error);
|
|
1945
|
-
release();
|
|
1946
|
-
},
|
|
1947
|
-
release
|
|
1948
|
-
};
|
|
1949
|
-
this.references++;
|
|
1950
|
-
current.consumers.add(consumer);
|
|
1951
|
-
signal.addEventListener("abort", release, { once: true });
|
|
1952
|
-
if (current.resource) consumer.ready(current.resource);
|
|
1953
|
-
});
|
|
1954
|
-
if (fresh) Promise.resolve().then(() => this.load(key, current.controller.signal)).then((resource) => {
|
|
1955
|
-
if (!current.consumers.size) {
|
|
1956
|
-
resource.release();
|
|
1957
|
-
return;
|
|
1958
|
-
}
|
|
1959
|
-
current.resource = resource;
|
|
1960
|
-
for (const consumer of current.consumers) consumer.ready(resource);
|
|
1961
|
-
}, (error) => {
|
|
1962
|
-
if (this.entries.get(key) === current) this.entries.delete(key);
|
|
1963
|
-
for (const consumer of [...current.consumers]) consumer.fail(error);
|
|
1964
|
-
});
|
|
1965
|
-
return promise;
|
|
1966
|
-
}
|
|
1967
|
-
dispose() {
|
|
1968
|
-
if (this.dead) return;
|
|
1969
|
-
this.dead = true;
|
|
1970
|
-
for (const entry of [...this.entries.values()]) for (const consumer of [...entry.consumers]) consumer.release();
|
|
1971
|
-
}
|
|
1972
|
-
};
|
|
1973
3089
|
// packages/web/src/tilemap-visual.ts
|
|
1974
3090
|
/** Renderer-owned sparse visible set; sheet pixels are owned by SharedImages. */
|
|
1975
3091
|
var TilemapVisual = class {
|
|
@@ -2027,7 +3143,16 @@ var TilemapVisual = class {
|
|
|
2027
3143
|
return;
|
|
2028
3144
|
}
|
|
2029
3145
|
this.lease = lease;
|
|
3146
|
+
lease.useFrames?.(() => Array.from(this.map.tileIds(), (id) => this.map.sheet.get(id).frame));
|
|
2030
3147
|
this.sync();
|
|
3148
|
+
lease.onChange?.(() => {
|
|
3149
|
+
if (this.disposed || !isCurrent()) return;
|
|
3150
|
+
this.textures.clear();
|
|
3151
|
+
this.revision = -1;
|
|
3152
|
+
this.invalid = true;
|
|
3153
|
+
this.sync();
|
|
3154
|
+
this.changed();
|
|
3155
|
+
});
|
|
2031
3156
|
}).catch((error) => {
|
|
2032
3157
|
if (!this.disposed) this.fail(error);
|
|
2033
3158
|
}).finally(() => {
|
|
@@ -2246,6 +3371,7 @@ var PixiRenderer = class {
|
|
|
2246
3371
|
host;
|
|
2247
3372
|
background;
|
|
2248
3373
|
onError;
|
|
3374
|
+
assetServices;
|
|
2249
3375
|
app = new Application();
|
|
2250
3376
|
pendingUITextKeys = /* @__PURE__ */ new Set();
|
|
2251
3377
|
canvasPresentations = /* @__PURE__ */ new Set();
|
|
@@ -2338,7 +3464,7 @@ var PixiRenderer = class {
|
|
|
2338
3464
|
};
|
|
2339
3465
|
}
|
|
2340
3466
|
scenes = /* @__PURE__ */ new Set();
|
|
2341
|
-
images
|
|
3467
|
+
images;
|
|
2342
3468
|
tilemaps = /* @__PURE__ */ new Set();
|
|
2343
3469
|
get tilemapDiagnostics() {
|
|
2344
3470
|
let sprites = 0, visibleTiles = 0, visitedCells = 0, updatedTiles = 0, createdSprites = 0, destroyedSprites = 0, validatedFrames = 0;
|
|
@@ -2384,19 +3510,20 @@ var PixiRenderer = class {
|
|
|
2384
3510
|
ready = false;
|
|
2385
3511
|
dead = false;
|
|
2386
3512
|
visualCount = 0;
|
|
2387
|
-
textureCount = 0;
|
|
2388
3513
|
get diagnostics() {
|
|
2389
3514
|
return {
|
|
2390
3515
|
presentations: this.scenes.size,
|
|
2391
3516
|
visuals: this.visualCount,
|
|
2392
|
-
textures: this.
|
|
3517
|
+
textures: this.images.textures,
|
|
2393
3518
|
...this.images.diagnostics
|
|
2394
3519
|
};
|
|
2395
3520
|
}
|
|
2396
|
-
constructor(host, background = "#101922", onError = () => {}) {
|
|
3521
|
+
constructor(host, background = "#101922", onError = () => {}, assetServices) {
|
|
2397
3522
|
this.host = host;
|
|
2398
3523
|
this.background = background;
|
|
2399
3524
|
this.onError = onError;
|
|
3525
|
+
this.assetServices = assetServices;
|
|
3526
|
+
this.images = assetServices?.images ?? createImagePool((...args) => this.image(...args));
|
|
2400
3527
|
}
|
|
2401
3528
|
async init() {
|
|
2402
3529
|
await this.app.init({
|
|
@@ -2419,54 +3546,11 @@ var PixiRenderer = class {
|
|
|
2419
3546
|
this.app.canvas.setAttribute("aria-label", "Game world");
|
|
2420
3547
|
this.host.prepend(this.app.canvas);
|
|
2421
3548
|
}
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
await new Promise((resolve, reject) => {
|
|
2425
|
-
const clear = () => {
|
|
2426
|
-
image.onload = null;
|
|
2427
|
-
image.onerror = null;
|
|
2428
|
-
signal.removeEventListener("abort", abort);
|
|
2429
|
-
};
|
|
2430
|
-
const abort = () => {
|
|
2431
|
-
clear();
|
|
2432
|
-
image.src = "";
|
|
2433
|
-
reject(new EngineError("GAME_DISPOSED", "Image loading cancelled", { path: url }));
|
|
2434
|
-
};
|
|
2435
|
-
image.onload = () => {
|
|
2436
|
-
clear();
|
|
2437
|
-
resolve();
|
|
2438
|
-
};
|
|
2439
|
-
image.onerror = () => {
|
|
2440
|
-
clear();
|
|
2441
|
-
reject(new EngineError("ASSET_LOAD_FAILED", "Could not load sprite", { path: url }));
|
|
2442
|
-
};
|
|
2443
|
-
if (signal.aborted) {
|
|
2444
|
-
abort();
|
|
2445
|
-
return;
|
|
2446
|
-
}
|
|
2447
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
2448
|
-
image.crossOrigin = "anonymous";
|
|
2449
|
-
image.src = url;
|
|
2450
|
-
});
|
|
2451
|
-
if (signal.aborted) throw new EngineError("GAME_DISPOSED", "Image loading cancelled", { path: url });
|
|
2452
|
-
const texture = Texture.from(image, true);
|
|
2453
|
-
if (!(texture.width > 0 && texture.height > 0)) {
|
|
2454
|
-
texture.destroy(true);
|
|
2455
|
-
throw new EngineError("ASSET_LOAD_FAILED", "Image has no drawable dimensions", { path: url });
|
|
2456
|
-
}
|
|
2457
|
-
this.textureCount++;
|
|
2458
|
-
let released = false;
|
|
2459
|
-
return {
|
|
2460
|
-
texture,
|
|
2461
|
-
release: () => {
|
|
2462
|
-
if (released) return;
|
|
2463
|
-
released = true;
|
|
2464
|
-
this.textureCount--;
|
|
2465
|
-
texture.destroy(true);
|
|
2466
|
-
}
|
|
2467
|
-
};
|
|
3549
|
+
image(url, signal, mode) {
|
|
3550
|
+
return loadImageResource(url, signal, mode);
|
|
2468
3551
|
}
|
|
2469
3552
|
acquireImage(url, signal) {
|
|
3553
|
+
if (isAssetImage(url)) return this.assetServices?.store.image(url, signal) ?? Promise.reject(new EngineError("ASSET_CANCELLED", "Image asset does not belong to this renderer"));
|
|
2470
3554
|
try {
|
|
2471
3555
|
const parsed = new URL(url, document.baseURI);
|
|
2472
3556
|
if (![
|
|
@@ -2572,6 +3656,7 @@ var PixiRenderer = class {
|
|
|
2572
3656
|
const commitImage = (entry, resource, source) => {
|
|
2573
3657
|
if (source.kind !== "image") return;
|
|
2574
3658
|
const texture = resource.textureFor(source.frame);
|
|
3659
|
+
resource.useFrames?.(() => source.frame ? [source.frame] : []);
|
|
2575
3660
|
const previous = entry.image;
|
|
2576
3661
|
const sprite = entry.display instanceof Sprite ? entry.display : new Sprite(texture);
|
|
2577
3662
|
sprite.texture = texture;
|
|
@@ -2579,7 +3664,17 @@ var PixiRenderer = class {
|
|
|
2579
3664
|
entry.image = resource;
|
|
2580
3665
|
replace(entry, sprite, source);
|
|
2581
3666
|
appearance(entry);
|
|
2582
|
-
if (previous !== resource)
|
|
3667
|
+
if (previous !== resource) {
|
|
3668
|
+
previous?.release();
|
|
3669
|
+
resource.onChange?.(() => {
|
|
3670
|
+
if (!valid(entry) || entry.image !== resource || !entry.displayedSource) return;
|
|
3671
|
+
try {
|
|
3672
|
+
commitImage(entry, resource, entry.displayedSource);
|
|
3673
|
+
} catch (error) {
|
|
3674
|
+
fail(entry, error);
|
|
3675
|
+
}
|
|
3676
|
+
});
|
|
3677
|
+
}
|
|
2583
3678
|
};
|
|
2584
3679
|
const update = (entry) => {
|
|
2585
3680
|
const component = entry.component;
|
|
@@ -2917,7 +4012,9 @@ var PixiRenderer = class {
|
|
|
2917
4012
|
p.dispose();
|
|
2918
4013
|
}),
|
|
2919
4014
|
() => this.pendingUITextKeys.clear(),
|
|
2920
|
-
() =>
|
|
4015
|
+
() => {
|
|
4016
|
+
if (!this.assetServices) this.images.dispose();
|
|
4017
|
+
},
|
|
2921
4018
|
() => this.destroyApp()
|
|
2922
4019
|
], "Renderer cleanup failed");
|
|
2923
4020
|
}
|
|
@@ -3165,6 +4262,13 @@ var Game = class {
|
|
|
3165
4262
|
element;
|
|
3166
4263
|
camera;
|
|
3167
4264
|
gamepads;
|
|
4265
|
+
logger;
|
|
4266
|
+
assets;
|
|
4267
|
+
audio;
|
|
4268
|
+
defaultFont;
|
|
4269
|
+
fontUse;
|
|
4270
|
+
imagePool = createImagePool();
|
|
4271
|
+
assetStore;
|
|
3168
4272
|
renderer;
|
|
3169
4273
|
keyboard;
|
|
3170
4274
|
captures;
|
|
@@ -3202,8 +4306,14 @@ var Game = class {
|
|
|
3202
4306
|
const renderer = this.renderer;
|
|
3203
4307
|
if (!renderer.prepareVirtualInput) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support virtual input presentation");
|
|
3204
4308
|
const presentation = renderer.prepareVirtualInput(input);
|
|
3205
|
-
input.onDispose(() => presentation.dispose());
|
|
3206
|
-
return
|
|
4309
|
+
const off = input.onDispose(() => presentation.dispose());
|
|
4310
|
+
return {
|
|
4311
|
+
update: () => presentation.update(),
|
|
4312
|
+
dispose: () => {
|
|
4313
|
+
off();
|
|
4314
|
+
presentation.dispose();
|
|
4315
|
+
}
|
|
4316
|
+
};
|
|
3207
4317
|
}
|
|
3208
4318
|
canvas;
|
|
3209
4319
|
canvasPresentation;
|
|
@@ -3245,6 +4355,10 @@ var Game = class {
|
|
|
3245
4355
|
height = 450;
|
|
3246
4356
|
ratio = 1;
|
|
3247
4357
|
scenes = /* @__PURE__ */ new Map();
|
|
4358
|
+
retainedScenes = /* @__PURE__ */ new Map();
|
|
4359
|
+
sceneRetention;
|
|
4360
|
+
activeRetention = "dispose";
|
|
4361
|
+
detachPresentation;
|
|
3248
4362
|
pendingScene;
|
|
3249
4363
|
defaultUI = [];
|
|
3250
4364
|
ui;
|
|
@@ -3262,6 +4376,12 @@ var Game = class {
|
|
|
3262
4376
|
errorBox;
|
|
3263
4377
|
constructor(options = {}) {
|
|
3264
4378
|
this.options = options;
|
|
4379
|
+
this.sceneRetention = retentionPolicy(options.sceneRetention ?? "dispose");
|
|
4380
|
+
const { console: consoleEnabled = !options.onError, ...logging } = options.logging ?? {};
|
|
4381
|
+
this.logger = new Logger({
|
|
4382
|
+
...logging,
|
|
4383
|
+
...consoleEnabled ? { sink: (entry) => console[entry.level](entry.message) } : {}
|
|
4384
|
+
});
|
|
3265
4385
|
this.engine = new Engine(options.seed === void 0 ? {} : { seed: options.seed });
|
|
3266
4386
|
this.fixed = options.fixedStep ? new FixedStepRunner(this.engine, options.fixedStep) : void 0;
|
|
3267
4387
|
this.camera = options.camera ?? new Camera();
|
|
@@ -3292,6 +4412,9 @@ var Game = class {
|
|
|
3292
4412
|
...theme,
|
|
3293
4413
|
...options.tokens
|
|
3294
4414
|
};
|
|
4415
|
+
this.defaultFont = tokens.font;
|
|
4416
|
+
this.assetStore = new AssetStore(browserAssetAdapter(this.imagePool, (data) => this.audio.decode(data)), this.logger, document.baseURI, options.assets);
|
|
4417
|
+
this.assets = new AssetScope(this.assetStore, void 0, true);
|
|
3295
4418
|
this.element = document.createElement("div");
|
|
3296
4419
|
this.element.className = "simpludify";
|
|
3297
4420
|
this.element.tabIndex = 0;
|
|
@@ -3303,18 +4426,25 @@ var Game = class {
|
|
|
3303
4426
|
this.errorBox.hidden = true;
|
|
3304
4427
|
this.element.append(this.errorBox);
|
|
3305
4428
|
(options.parent ?? document.body).append(this.element);
|
|
4429
|
+
this.audio = new GameAudio(this.element, this.assetStore, this.logger);
|
|
3306
4430
|
try {
|
|
3307
|
-
this.renderer = options.renderer?.(this.element) ?? new PixiRenderer(this.element, tokens.background, (error) => this.report(error)
|
|
4431
|
+
this.renderer = options.renderer?.(this.element) ?? new PixiRenderer(this.element, tokens.background, (error) => this.report(error, "engine.renderer"), {
|
|
4432
|
+
store: this.assetStore,
|
|
4433
|
+
images: this.imagePool
|
|
4434
|
+
});
|
|
3308
4435
|
} catch (error) {
|
|
3309
4436
|
this.element.remove();
|
|
3310
4437
|
this.engine.dispose();
|
|
4438
|
+
this.audio.dispose();
|
|
4439
|
+
this.assetStore.dispose();
|
|
4440
|
+
this.imagePool.dispose();
|
|
3311
4441
|
throw error;
|
|
3312
4442
|
}
|
|
3313
4443
|
this.frames = options.frames ?? {
|
|
3314
4444
|
request: (callback) => requestAnimationFrame(callback),
|
|
3315
4445
|
cancel: (handle) => cancelAnimationFrame(handle)
|
|
3316
4446
|
};
|
|
3317
|
-
this.captures = new InputCaptureManager(this.element, this.engine.input, () => this.engine.scene, () => this.pointers.release(), this.report);
|
|
4447
|
+
this.captures = new InputCaptureManager(this.element, this.engine.input, () => this.engine.scene, () => this.pointers.release(), (error) => this.report(error, "engine.input"));
|
|
3318
4448
|
this.keyboard = new BrowserInput(this.element, this.engine.input);
|
|
3319
4449
|
this.gamepads = new GamepadInput(this.engine.input, {
|
|
3320
4450
|
observe: (pads) => this.captures.sample(pads),
|
|
@@ -3329,7 +4459,7 @@ var Game = class {
|
|
|
3329
4459
|
world: this.screenToWorld(screen),
|
|
3330
4460
|
view: this.view
|
|
3331
4461
|
};
|
|
3332
|
-
}, this.report, () => this.updateProjection(), this.engine.input);
|
|
4462
|
+
}, (error) => this.report(error, "engine.input"), () => this.updateProjection(), this.engine.input);
|
|
3333
4463
|
document.addEventListener("visibilitychange", () => {
|
|
3334
4464
|
this.previousTime = void 0;
|
|
3335
4465
|
this.fixed?.reset();
|
|
@@ -3339,12 +4469,26 @@ var Game = class {
|
|
|
3339
4469
|
try {
|
|
3340
4470
|
this.resize();
|
|
3341
4471
|
} catch (error) {
|
|
3342
|
-
this.report(error);
|
|
4472
|
+
this.report(error, "engine.resize", true);
|
|
3343
4473
|
this.dispose();
|
|
3344
4474
|
}
|
|
3345
4475
|
});
|
|
3346
4476
|
this.observer.observe(this.element);
|
|
3347
4477
|
}
|
|
4478
|
+
/** Choose the Game default font for DOM and Canvas UI. */
|
|
4479
|
+
setFont(asset) {
|
|
4480
|
+
this.assertAlive();
|
|
4481
|
+
if (asset && asset.kind !== "font") throw new EngineError("INVALID_VALUE", "A font asset is required");
|
|
4482
|
+
const next = asset?.retain(this.assetStore);
|
|
4483
|
+
const old = this.fontUse;
|
|
4484
|
+
this.fontUse = next;
|
|
4485
|
+
const update = () => {
|
|
4486
|
+
this.element.style.setProperty("--s-font", next?.value ?? this.defaultFont);
|
|
4487
|
+
};
|
|
4488
|
+
next?.onChange(update);
|
|
4489
|
+
update();
|
|
4490
|
+
old?.release();
|
|
4491
|
+
}
|
|
3348
4492
|
assertAlive() {
|
|
3349
4493
|
if (this.dead) throw new EngineError("GAME_DISPOSED", "Game has been disposed");
|
|
3350
4494
|
}
|
|
@@ -3426,12 +4570,15 @@ var Game = class {
|
|
|
3426
4570
|
if (this.startPromise) throw new EngineError("UI_ALREADY_STARTED", "Define default UI before run");
|
|
3427
4571
|
this.defaultUI.push(...nodes);
|
|
3428
4572
|
}
|
|
3429
|
-
scene(id, setup) {
|
|
4573
|
+
scene(id, setup, options = {}) {
|
|
3430
4574
|
this.assertAlive();
|
|
3431
4575
|
if (typeof id !== "string" || !id.length) throw new EngineError("INVALID_VALUE", "Scene id must not be empty");
|
|
3432
4576
|
if (typeof setup !== "function") throw new EngineError("INVALID_VALUE", "Scene setup must be a function");
|
|
3433
4577
|
if (this.scenes.has(id)) throw new EngineError("DUPLICATE_SCENE", "Scene id is already registered", { sceneId: id });
|
|
3434
|
-
this.scenes.set(id,
|
|
4578
|
+
this.scenes.set(id, {
|
|
4579
|
+
setup,
|
|
4580
|
+
retention: retentionPolicy(options.retention ?? this.sceneRetention)
|
|
4581
|
+
});
|
|
3435
4582
|
return this;
|
|
3436
4583
|
}
|
|
3437
4584
|
wait(promise) {
|
|
@@ -3468,6 +4615,12 @@ var Game = class {
|
|
|
3468
4615
|
this.assertAlive();
|
|
3469
4616
|
this.frame = this.frames.request(this.tick);
|
|
3470
4617
|
} catch (error) {
|
|
4618
|
+
if (!this.dead) this.logger.report(error, {
|
|
4619
|
+
source: "engine.startup",
|
|
4620
|
+
message: "Simpludify could not start",
|
|
4621
|
+
fatal: true,
|
|
4622
|
+
standardOutput: false
|
|
4623
|
+
});
|
|
3471
4624
|
this.dispose();
|
|
3472
4625
|
throw error;
|
|
3473
4626
|
}
|
|
@@ -3480,11 +4633,25 @@ var Game = class {
|
|
|
3480
4633
|
return this.navigate(id);
|
|
3481
4634
|
}
|
|
3482
4635
|
navigate(id) {
|
|
3483
|
-
const
|
|
3484
|
-
if (!
|
|
4636
|
+
const registration = this.scenes.get(id);
|
|
4637
|
+
if (!registration) return Promise.reject(new EngineError("SCENE_NOT_FOUND", "Unknown scene", { sceneId: id }));
|
|
3485
4638
|
if (this.pendingScene?.id === id) return this.pendingScene.task;
|
|
3486
4639
|
const task = this.queue.then(async () => {
|
|
3487
4640
|
this.assertAlive();
|
|
4641
|
+
const beforeActivate = () => {
|
|
4642
|
+
if (this.pendingScene?.task === task) this.pendingScene = void 0;
|
|
4643
|
+
};
|
|
4644
|
+
const existing = this.retainedScenes.get(id);
|
|
4645
|
+
if (existing) {
|
|
4646
|
+
if (this.engine.scene === existing.scene && existing.scene.state === SceneState.Active) return;
|
|
4647
|
+
try {
|
|
4648
|
+
await this.present(existing.scene, existing.nodes, existing.canvas, beforeActivate, registration.retention);
|
|
4649
|
+
} catch (error) {
|
|
4650
|
+
this.reportPreparation(error);
|
|
4651
|
+
throw error;
|
|
4652
|
+
}
|
|
4653
|
+
return;
|
|
4654
|
+
}
|
|
3488
4655
|
const scene = new Scene(id), nodes = [], controller = new AbortController();
|
|
3489
4656
|
let canvas;
|
|
3490
4657
|
const pointer = this.channelFor(scene);
|
|
@@ -3492,8 +4659,10 @@ var Game = class {
|
|
|
3492
4659
|
this.staging = scene;
|
|
3493
4660
|
scene.onDispose(() => controller.abort());
|
|
3494
4661
|
try {
|
|
3495
|
-
|
|
4662
|
+
const assets = this.assets.scope(scene);
|
|
4663
|
+
await this.wait(Promise.resolve(registration.setup({
|
|
3496
4664
|
scene,
|
|
4665
|
+
assets,
|
|
3497
4666
|
get tweens() {
|
|
3498
4667
|
return scene.tweens;
|
|
3499
4668
|
},
|
|
@@ -3524,10 +4693,18 @@ var Game = class {
|
|
|
3524
4693
|
onDispose: (callback) => scene.onDispose(callback)
|
|
3525
4694
|
})));
|
|
3526
4695
|
this.assertAlive();
|
|
3527
|
-
|
|
3528
|
-
if (this.
|
|
4696
|
+
if (registration.retention === "retain") scene.onDispose(() => {
|
|
4697
|
+
if (this.retainedScenes.get(id)?.scene === scene) this.retainedScenes.delete(id);
|
|
4698
|
+
});
|
|
4699
|
+
await this.present(scene, nodes, canvas, beforeActivate, registration.retention);
|
|
4700
|
+
this.assertAlive();
|
|
4701
|
+
if (registration.retention === "retain" && !scene.disposed) this.retainedScenes.set(id, {
|
|
4702
|
+
scene,
|
|
4703
|
+
nodes,
|
|
4704
|
+
canvas
|
|
3529
4705
|
});
|
|
3530
4706
|
} catch (error) {
|
|
4707
|
+
this.reportPreparation(error);
|
|
3531
4708
|
try {
|
|
3532
4709
|
scene.dispose();
|
|
3533
4710
|
} catch (cleanupError) {
|
|
@@ -3547,10 +4724,22 @@ var Game = class {
|
|
|
3547
4724
|
this.queue = task.catch(() => {});
|
|
3548
4725
|
return task;
|
|
3549
4726
|
}
|
|
3550
|
-
|
|
3551
|
-
|
|
4727
|
+
reportPreparation(error) {
|
|
4728
|
+
if (this.ready && !this.dead) this.logger.report(error, {
|
|
4729
|
+
source: "engine.scene",
|
|
4730
|
+
message: "Simpludify could not prepare the requested scene",
|
|
4731
|
+
fatal: false,
|
|
4732
|
+
standardOutput: false
|
|
4733
|
+
});
|
|
4734
|
+
}
|
|
4735
|
+
async present(scene, nodes, canvas, beforeActivate, retention = "dispose") {
|
|
4736
|
+
let prepared, ui, activated = false, committed = false;
|
|
3552
4737
|
let canvasPresentation;
|
|
3553
4738
|
let virtualPresentation;
|
|
4739
|
+
const hooks = [];
|
|
4740
|
+
const detach = () => {
|
|
4741
|
+
for (const off of hooks.splice(0)) off();
|
|
4742
|
+
};
|
|
3554
4743
|
try {
|
|
3555
4744
|
const preparing = this.renderer.prepare(scene);
|
|
3556
4745
|
preparing.then((value) => {
|
|
@@ -3563,28 +4752,34 @@ var Game = class {
|
|
|
3563
4752
|
prepared = await this.wait(preparing);
|
|
3564
4753
|
this.assertAlive();
|
|
3565
4754
|
ui = mountUI(nodes, this.report);
|
|
3566
|
-
scene.onDispose(ui.dispose);
|
|
4755
|
+
hooks.push(scene.onDispose(ui.dispose));
|
|
3567
4756
|
if (canvas) {
|
|
3568
4757
|
canvas.setErrorHandler(this.report);
|
|
3569
4758
|
const renderer = this.renderer;
|
|
3570
4759
|
if (!renderer.prepareCanvasUI) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support Canvas UI");
|
|
3571
4760
|
canvasPresentation = renderer.prepareCanvasUI(canvas, this.pointers.routing);
|
|
3572
|
-
scene.onDispose(() => canvasPresentation?.dispose());
|
|
4761
|
+
hooks.push(scene.onDispose(() => canvasPresentation?.dispose()));
|
|
3573
4762
|
}
|
|
3574
4763
|
virtualPresentation = this.prepareVirtual(scene);
|
|
3575
|
-
scene.markReady();
|
|
4764
|
+
if (scene.state === SceneState.Preparing) scene.markReady();
|
|
3576
4765
|
beforeActivate?.();
|
|
3577
4766
|
this.errorBox.hidden = true;
|
|
3578
4767
|
try {
|
|
3579
4768
|
if (this.playback && this.playbackScene !== scene) this.stopReplay();
|
|
3580
|
-
this.engine.activate(scene);
|
|
4769
|
+
this.engine.activate(scene, { previous: this.activeRetention });
|
|
3581
4770
|
} catch (error) {
|
|
3582
|
-
if (this.engine.scene !== scene
|
|
3583
|
-
|
|
4771
|
+
if (this.engine.scene !== scene) throw error;
|
|
4772
|
+
activated = scene.disposed;
|
|
4773
|
+
if (scene.state !== SceneState.Active) throw error;
|
|
4774
|
+
this.report(error, "engine.scene");
|
|
3584
4775
|
}
|
|
4776
|
+
activated = true;
|
|
3585
4777
|
this.assertAlive();
|
|
3586
|
-
|
|
4778
|
+
if (scene.disposed) throw new EngineError("SCENE_DISPOSED", "Scene was disposed during activation");
|
|
4779
|
+
const oldPrepared = this.prepared, oldUI = this.ui, oldCanvasUI = this.canvas, detachOld = this.detachPresentation, oldCanvas = this.canvasPresentation, oldVirtual = this.virtualPresentation;
|
|
3587
4780
|
this.prepared = prepared;
|
|
4781
|
+
this.detachPresentation = detach;
|
|
4782
|
+
this.activeRetention = retention;
|
|
3588
4783
|
this.ui = ui;
|
|
3589
4784
|
this.canvas = canvas;
|
|
3590
4785
|
this.canvasPresentation = canvasPresentation;
|
|
@@ -3595,6 +4790,13 @@ var Game = class {
|
|
|
3595
4790
|
this.element.append(ui.element);
|
|
3596
4791
|
} finally {
|
|
3597
4792
|
for (const cleanup of [
|
|
4793
|
+
() => detachOld?.(),
|
|
4794
|
+
() => {
|
|
4795
|
+
if (oldCanvasUI && !oldCanvasUI.disposed) {
|
|
4796
|
+
oldCanvasUI.focus();
|
|
4797
|
+
oldCanvasUI.stopMotion();
|
|
4798
|
+
}
|
|
4799
|
+
},
|
|
3598
4800
|
() => oldPrepared?.dispose(),
|
|
3599
4801
|
() => oldUI?.dispose(),
|
|
3600
4802
|
() => oldCanvas?.dispose(),
|
|
@@ -3613,6 +4815,7 @@ var Game = class {
|
|
|
3613
4815
|
this.assertAlive();
|
|
3614
4816
|
} catch (error) {
|
|
3615
4817
|
if (!committed) for (const cleanup of [
|
|
4818
|
+
detach,
|
|
3616
4819
|
() => prepared?.dispose(),
|
|
3617
4820
|
() => ui?.dispose(),
|
|
3618
4821
|
() => canvasPresentation?.dispose(),
|
|
@@ -3622,7 +4825,15 @@ var Game = class {
|
|
|
3622
4825
|
} catch (cleanupError) {
|
|
3623
4826
|
this.report(cleanupError);
|
|
3624
4827
|
}
|
|
3625
|
-
if (
|
|
4828
|
+
if (activated && !this.dead) {
|
|
4829
|
+
this.logger.report(error, {
|
|
4830
|
+
source: "engine.presentation",
|
|
4831
|
+
message: "Simpludify could not present the active scene",
|
|
4832
|
+
fatal: true,
|
|
4833
|
+
standardOutput: false
|
|
4834
|
+
});
|
|
4835
|
+
this.dispose();
|
|
4836
|
+
}
|
|
3626
4837
|
throw error;
|
|
3627
4838
|
}
|
|
3628
4839
|
}
|
|
@@ -3801,16 +5012,21 @@ var Game = class {
|
|
|
3801
5012
|
}
|
|
3802
5013
|
if (!this.dead) this.frame = this.frames.request(this.tick);
|
|
3803
5014
|
} catch (error) {
|
|
3804
|
-
this.report(error);
|
|
5015
|
+
this.report(error, "engine.frame", true);
|
|
3805
5016
|
this.dispose();
|
|
3806
5017
|
}
|
|
3807
5018
|
};
|
|
3808
|
-
report = (error) => {
|
|
3809
|
-
|
|
5019
|
+
report = (error, source = "engine.runtime", fatal = false) => {
|
|
5020
|
+
const message = fatal ? "Simpludify stopped after a runtime error" : "Simpludify encountered a runtime error";
|
|
5021
|
+
this.logger.report(error, {
|
|
5022
|
+
source,
|
|
5023
|
+
fatal,
|
|
5024
|
+
message
|
|
5025
|
+
});
|
|
5026
|
+
this.errorBox.textContent = message;
|
|
3810
5027
|
this.errorBox.hidden = false;
|
|
3811
5028
|
try {
|
|
3812
5029
|
if (this.options.onError) this.options.onError(error);
|
|
3813
|
-
else console.error(error);
|
|
3814
5030
|
} catch {}
|
|
3815
5031
|
};
|
|
3816
5032
|
dispose() {
|
|
@@ -3829,10 +5045,15 @@ var Game = class {
|
|
|
3829
5045
|
() => this.virtualPresentation?.dispose(),
|
|
3830
5046
|
() => this.canvas?.dispose(),
|
|
3831
5047
|
() => this.observer.disconnect(),
|
|
5048
|
+
() => this.detachPresentation?.(),
|
|
3832
5049
|
() => this.ui?.dispose(),
|
|
3833
5050
|
() => this.prepared?.dispose(),
|
|
3834
5051
|
() => this.renderer.dispose(),
|
|
5052
|
+
() => this.audio.dispose(),
|
|
3835
5053
|
() => this.engine.dispose(),
|
|
5054
|
+
() => this.fontUse?.release(),
|
|
5055
|
+
() => this.assetStore.dispose(),
|
|
5056
|
+
() => this.imagePool.dispose(),
|
|
3836
5057
|
() => this.element.remove()
|
|
3837
5058
|
]) try {
|
|
3838
5059
|
cleanup();
|
|
@@ -3846,11 +5067,18 @@ var Game = class {
|
|
|
3846
5067
|
this.canvasPresentation = void 0;
|
|
3847
5068
|
this.virtualPresentation = void 0;
|
|
3848
5069
|
this.defaultCanvas = void 0;
|
|
5070
|
+
this.fontUse = void 0;
|
|
3849
5071
|
this.defaultUI.length = 0;
|
|
3850
5072
|
this.scenes.clear();
|
|
5073
|
+
this.retainedScenes.clear();
|
|
5074
|
+
this.detachPresentation = void 0;
|
|
3851
5075
|
this.pendingScene = void 0;
|
|
3852
5076
|
}
|
|
3853
5077
|
};
|
|
5078
|
+
function retentionPolicy(value) {
|
|
5079
|
+
if (value !== "dispose" && value !== "retain") throw new EngineError("INVALID_VALUE", "Unknown scene retention policy");
|
|
5080
|
+
return value;
|
|
5081
|
+
}
|
|
3854
5082
|
function validFontFamily(value) {
|
|
3855
5083
|
const syntax = value.replace(/"(?:\\[^\r\n]|[^"\\])*"|'(?:\\[^\r\n]|[^'\\])*'/g, "family").replace(/\\(?:[0-9a-f]{1,6}[ \t]?|[^\r\n])/gi, "x");
|
|
3856
5084
|
return value.trim().length > 0 && !/[\r\n\0]/.test(value) && !/[;{}<>\\"']|url\s*\(/i.test(syntax);
|