@simpludify/web 0.1.6 → 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 +32 -4
- package/dist/geometry-tilemap-visual.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1530 -245
- package/dist/index.js.map +1 -1
- package/dist/pointer.d.ts +7 -5
- package/dist/renderer.d.ts +7 -2
- package/dist/shared-images.d.ts +9 -2
- package/dist/tilemap-visual.d.ts +3 -3
- 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({
|
|
@@ -703,6 +1919,7 @@ var BrowserPointer = class {
|
|
|
703
1919
|
}
|
|
704
1920
|
this.flush();
|
|
705
1921
|
const data = this.data(e);
|
|
1922
|
+
if (!data) return this.release();
|
|
706
1923
|
try {
|
|
707
1924
|
host.setPointerCapture(e.pointerId);
|
|
708
1925
|
} catch {}
|
|
@@ -720,7 +1937,9 @@ var BrowserPointer = class {
|
|
|
720
1937
|
host.addEventListener("wheel", (e) => this.guard(() => {
|
|
721
1938
|
if (!this.surface(e.target)) return;
|
|
722
1939
|
this.flush();
|
|
723
|
-
const
|
|
1940
|
+
const projected = this.project(e);
|
|
1941
|
+
if (!projected) return;
|
|
1942
|
+
const { screen } = projected;
|
|
724
1943
|
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? host.clientHeight : 1;
|
|
725
1944
|
if (this.routing.scroll(screen.x, screen.y, e.deltaX * unit, e.deltaY * unit)) e.preventDefault();
|
|
726
1945
|
}), {
|
|
@@ -730,19 +1949,27 @@ var BrowserPointer = class {
|
|
|
730
1949
|
window.addEventListener("pointermove", (e) => this.guard(() => {
|
|
731
1950
|
if (!this.routing.owns(e.pointerId) && !this.surface(e.target)) {
|
|
732
1951
|
if (e.pointerType === "mouse") this.releaseMouse(e.pointerId);
|
|
733
|
-
if (this.routing.hasHover(e.pointerId))
|
|
1952
|
+
if (this.routing.hasHover(e.pointerId)) {
|
|
1953
|
+
const data = this.data(e);
|
|
1954
|
+
if (data) this.routing.leave(data);
|
|
1955
|
+
else this.release();
|
|
1956
|
+
}
|
|
734
1957
|
return;
|
|
735
1958
|
}
|
|
736
1959
|
this.flush();
|
|
1960
|
+
const data = this.data(e);
|
|
1961
|
+
if (!data) return this.release();
|
|
737
1962
|
this.updateMouse(e);
|
|
738
|
-
this.routing.move(
|
|
1963
|
+
this.routing.move(data);
|
|
739
1964
|
}), options);
|
|
740
1965
|
window.addEventListener("pointerup", (e) => this.guard(() => {
|
|
741
1966
|
if (e.pointerType === "mouse") this.releaseMouse(e.pointerId);
|
|
742
1967
|
if (this.routing.owns(e.pointerId)) {
|
|
743
1968
|
e.preventDefault();
|
|
744
1969
|
this.flush();
|
|
745
|
-
this.
|
|
1970
|
+
const data = this.data(e);
|
|
1971
|
+
if (data) this.routing.up(data);
|
|
1972
|
+
else this.release();
|
|
746
1973
|
}
|
|
747
1974
|
}), options);
|
|
748
1975
|
window.addEventListener("pointercancel", (e) => this.guard(() => {
|
|
@@ -756,7 +1983,11 @@ var BrowserPointer = class {
|
|
|
756
1983
|
else this.blockedMouse.delete(e.pointerId);
|
|
757
1984
|
this.routing.cancel(e.pointerId);
|
|
758
1985
|
}), options);
|
|
759
|
-
host.addEventListener("pointerleave", (e) => this.guard(() =>
|
|
1986
|
+
host.addEventListener("pointerleave", (e) => this.guard(() => {
|
|
1987
|
+
const data = this.data(e);
|
|
1988
|
+
if (data) this.routing.leave(data);
|
|
1989
|
+
else this.release();
|
|
1990
|
+
}), options);
|
|
760
1991
|
window.addEventListener("blur", (event) => {
|
|
761
1992
|
if (event.target === window) this.release();
|
|
762
1993
|
}, options);
|
|
@@ -806,7 +2037,9 @@ var BrowserPointer = class {
|
|
|
806
2037
|
return target === this.host || target instanceof HTMLCanvasElement && target.parentElement === this.host;
|
|
807
2038
|
}
|
|
808
2039
|
data(e) {
|
|
809
|
-
const
|
|
2040
|
+
const projected = this.project(e);
|
|
2041
|
+
if (!projected) return void 0;
|
|
2042
|
+
const { screen, world, view } = projected;
|
|
810
2043
|
return Object.freeze({
|
|
811
2044
|
pointerId: e.pointerId,
|
|
812
2045
|
pointerType: e.pointerType,
|
|
@@ -1083,7 +2316,7 @@ function nativeGeometry(el, node, ui, clip) {
|
|
|
1083
2316
|
const background = style.background ?? 2316622;
|
|
1084
2317
|
const color = `#${style.color.toString(16).padStart(6, "0")}`;
|
|
1085
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);`;
|
|
1086
|
-
el.style.fontFamily = style.fontFamily;
|
|
2319
|
+
el.style.fontFamily = node.inheritsFont ? `var(--s-font, ${defaultCanvasStyle.fontFamily})` : style.fontFamily;
|
|
1087
2320
|
if (el.disabled && document.activeElement === el) el.blur();
|
|
1088
2321
|
}
|
|
1089
2322
|
/** Native editors must not cover later Canvas hit surfaces or overlay scrollbars. */
|
|
@@ -1149,6 +2382,7 @@ var CanvasVisual = class {
|
|
|
1149
2382
|
entries = /* @__PURE__ */ new Map();
|
|
1150
2383
|
metrics = /* @__PURE__ */ new Map();
|
|
1151
2384
|
dirty = true;
|
|
2385
|
+
defaultFont = defaultCanvasStyle.fontFamily;
|
|
1152
2386
|
dead = false;
|
|
1153
2387
|
active = false;
|
|
1154
2388
|
cacheResolution;
|
|
@@ -1213,7 +2447,8 @@ var CanvasVisual = class {
|
|
|
1213
2447
|
};
|
|
1214
2448
|
}
|
|
1215
2449
|
measure = (node, contentWidth = Infinity) => {
|
|
1216
|
-
const { fontSize,
|
|
2450
|
+
const { fontSize, textWrap } = node.style;
|
|
2451
|
+
const fontFamily = this.fontFamily(node);
|
|
1217
2452
|
const content = canvasControlText(node);
|
|
1218
2453
|
const adornment = node.control?.model.kind === "checkbox" ? 30 : 0;
|
|
1219
2454
|
const key = JSON.stringify([
|
|
@@ -1274,6 +2509,7 @@ var CanvasVisual = class {
|
|
|
1274
2509
|
},
|
|
1275
2510
|
wheel: (event) => {
|
|
1276
2511
|
const rect = this.host.getBoundingClientRect();
|
|
2512
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
1277
2513
|
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? this.host.clientHeight : 1;
|
|
1278
2514
|
if (this.routing.scroll((event.clientX - rect.x) * this.host.clientWidth / rect.width, (event.clientY - rect.y) * this.host.clientHeight / rect.height, event.deltaX * unit, event.deltaY * unit)) event.preventDefault();
|
|
1279
2515
|
}
|
|
@@ -1372,8 +2608,16 @@ var CanvasVisual = class {
|
|
|
1372
2608
|
button.style.cssText = `left:${c.x * s}px;top:${c.y * s}px;width:${c.width * s}px;height:${c.height * s}px;`;
|
|
1373
2609
|
if (button.disabled && document.activeElement === button) button.blur();
|
|
1374
2610
|
}
|
|
2611
|
+
fontFamily(node) {
|
|
2612
|
+
return node.inheritsFont ? this.defaultFont : node.style.fontFamily;
|
|
2613
|
+
}
|
|
1375
2614
|
update(width, height) {
|
|
1376
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
|
+
}
|
|
1377
2621
|
const layout = this.ui.layout(width, height, this.measure);
|
|
1378
2622
|
if (!this.active) {
|
|
1379
2623
|
this.host.append(this.element);
|
|
@@ -1455,12 +2699,14 @@ var CanvasVisual = class {
|
|
|
1455
2699
|
entry.label.setAttribute("aria-hidden", String(canvasOpacity(node) === 0));
|
|
1456
2700
|
}
|
|
1457
2701
|
const b = node.bounds, s = node.style;
|
|
2702
|
+
const fontFamily = this.fontFamily(node);
|
|
1458
2703
|
const scrollBox = intersectUI(b, clip);
|
|
1459
2704
|
if (node.style.scroll && scrollBox.width > 0 && scrollBox.height > 0 && canvasOpacity(node) > 0) scrollVisible = true;
|
|
1460
2705
|
let enabled = !node.busy;
|
|
1461
2706
|
for (let p = node; p; p = p.parent) enabled &&= p.enabled;
|
|
1462
2707
|
const signature = JSON.stringify([
|
|
1463
2708
|
node.paintRevision,
|
|
2709
|
+
fontFamily,
|
|
1464
2710
|
b.x,
|
|
1465
2711
|
b.y,
|
|
1466
2712
|
b.width,
|
|
@@ -1494,6 +2740,7 @@ var CanvasVisual = class {
|
|
|
1494
2740
|
entry.maskSignature = maskSignature;
|
|
1495
2741
|
const paintSignature = JSON.stringify([
|
|
1496
2742
|
node.paintRevision,
|
|
2743
|
+
fontFamily,
|
|
1497
2744
|
b.width,
|
|
1498
2745
|
b.height,
|
|
1499
2746
|
enabled
|
|
@@ -1551,7 +2798,7 @@ var CanvasVisual = class {
|
|
|
1551
2798
|
entry.text.text = m.text;
|
|
1552
2799
|
}
|
|
1553
2800
|
const textStyleKey = JSON.stringify([
|
|
1554
|
-
|
|
2801
|
+
fontFamily,
|
|
1555
2802
|
s.fontSize,
|
|
1556
2803
|
s.color
|
|
1557
2804
|
]);
|
|
@@ -1559,7 +2806,7 @@ var CanvasVisual = class {
|
|
|
1559
2806
|
this.resources.changedText(entry.text.styleKey);
|
|
1560
2807
|
entry.textStyleKey = textStyleKey;
|
|
1561
2808
|
entry.text.style = {
|
|
1562
|
-
fontFamily
|
|
2809
|
+
fontFamily,
|
|
1563
2810
|
fontSize: s.fontSize,
|
|
1564
2811
|
fill: s.color
|
|
1565
2812
|
};
|
|
@@ -1747,11 +2994,11 @@ var GeometryTilemapVisual = class {
|
|
|
1747
2994
|
g.clear().poly(this.map.geometry.vertices(x, y).flatMap((p) => [p.x, p.y]), true).fill(color);
|
|
1748
2995
|
this.stats.updatedTiles++;
|
|
1749
2996
|
}
|
|
1750
|
-
render(view) {
|
|
2997
|
+
render(view, enabled = true) {
|
|
1751
2998
|
if (this.disposed) return;
|
|
1752
2999
|
const m = this.map;
|
|
1753
3000
|
this.display.position.set(m.x, m.y);
|
|
1754
|
-
this.display.visible = m.visible && m.enabled;
|
|
3001
|
+
this.display.visible = enabled && m.visible && m.enabled;
|
|
1755
3002
|
if (!this.display.visible) {
|
|
1756
3003
|
this.invalid = true;
|
|
1757
3004
|
this.bulk = true;
|
|
@@ -1839,119 +3086,6 @@ function pruneUITextKeys(renderer, keys) {
|
|
|
1839
3086
|
keys.delete(key);
|
|
1840
3087
|
}
|
|
1841
3088
|
}
|
|
1842
|
-
// packages/web/src/shared-images.ts
|
|
1843
|
-
/** Renderer-local, live-reference cache. No idle retention or process-global state. */
|
|
1844
|
-
var SharedImages = class {
|
|
1845
|
-
load;
|
|
1846
|
-
entries = /* @__PURE__ */ new Map();
|
|
1847
|
-
references = 0;
|
|
1848
|
-
dead = false;
|
|
1849
|
-
constructor(load) {
|
|
1850
|
-
this.load = load;
|
|
1851
|
-
}
|
|
1852
|
-
get diagnostics() {
|
|
1853
|
-
let pending = 0;
|
|
1854
|
-
for (const entry of this.entries.values()) if (!entry.resource) pending++;
|
|
1855
|
-
return {
|
|
1856
|
-
imageReferences: this.references,
|
|
1857
|
-
imageEntries: this.entries.size,
|
|
1858
|
-
pendingImages: pending
|
|
1859
|
-
};
|
|
1860
|
-
}
|
|
1861
|
-
acquire(key, signal) {
|
|
1862
|
-
const cancelled = () => new EngineError("GAME_DISPOSED", "Image use cancelled", { path: key });
|
|
1863
|
-
if (this.dead || signal.aborted) return Promise.reject(cancelled());
|
|
1864
|
-
let entry = this.entries.get(key);
|
|
1865
|
-
const fresh = !entry;
|
|
1866
|
-
if (!entry) {
|
|
1867
|
-
entry = {
|
|
1868
|
-
key,
|
|
1869
|
-
controller: new AbortController(),
|
|
1870
|
-
consumers: /* @__PURE__ */ new Set(),
|
|
1871
|
-
frames: /* @__PURE__ */ new Map()
|
|
1872
|
-
};
|
|
1873
|
-
this.entries.set(key, entry);
|
|
1874
|
-
}
|
|
1875
|
-
const current = entry;
|
|
1876
|
-
const promise = new Promise((resolve, reject) => {
|
|
1877
|
-
let released = false, settled = false;
|
|
1878
|
-
const release = () => {
|
|
1879
|
-
if (released) return;
|
|
1880
|
-
released = true;
|
|
1881
|
-
signal.removeEventListener("abort", release);
|
|
1882
|
-
current.consumers.delete(consumer);
|
|
1883
|
-
this.references--;
|
|
1884
|
-
if (!settled) {
|
|
1885
|
-
settled = true;
|
|
1886
|
-
reject(cancelled());
|
|
1887
|
-
}
|
|
1888
|
-
if (!current.consumers.size) {
|
|
1889
|
-
if (this.entries.get(key) === current) this.entries.delete(key);
|
|
1890
|
-
current.controller.abort();
|
|
1891
|
-
const resource = current.resource;
|
|
1892
|
-
delete current.resource;
|
|
1893
|
-
for (const texture of current.frames.values()) texture.destroy(false);
|
|
1894
|
-
current.frames.clear();
|
|
1895
|
-
resource?.release();
|
|
1896
|
-
}
|
|
1897
|
-
};
|
|
1898
|
-
const consumer = {
|
|
1899
|
-
ready: (resource) => {
|
|
1900
|
-
if (released || settled) return;
|
|
1901
|
-
settled = true;
|
|
1902
|
-
signal.removeEventListener("abort", release);
|
|
1903
|
-
resolve({
|
|
1904
|
-
texture: resource.texture,
|
|
1905
|
-
release,
|
|
1906
|
-
textureFor: (frame) => {
|
|
1907
|
-
if (released) throw new EngineError("GAME_DISPOSED", "Image lease released");
|
|
1908
|
-
if (!frame) return resource.texture;
|
|
1909
|
-
const rect = spriteFrameBounds(frame, resource.texture.width, resource.texture.height);
|
|
1910
|
-
const key = `${rect.x},${rect.y},${rect.width},${rect.height}`;
|
|
1911
|
-
let texture = current.frames.get(key);
|
|
1912
|
-
if (!texture) {
|
|
1913
|
-
texture = new Texture({
|
|
1914
|
-
source: resource.texture.source,
|
|
1915
|
-
frame: new Rectangle(rect.x, rect.y, rect.width, rect.height)
|
|
1916
|
-
});
|
|
1917
|
-
current.frames.set(key, texture);
|
|
1918
|
-
}
|
|
1919
|
-
return texture;
|
|
1920
|
-
}
|
|
1921
|
-
});
|
|
1922
|
-
},
|
|
1923
|
-
fail: (error) => {
|
|
1924
|
-
if (released || settled) return;
|
|
1925
|
-
settled = true;
|
|
1926
|
-
reject(error);
|
|
1927
|
-
release();
|
|
1928
|
-
},
|
|
1929
|
-
release
|
|
1930
|
-
};
|
|
1931
|
-
this.references++;
|
|
1932
|
-
current.consumers.add(consumer);
|
|
1933
|
-
signal.addEventListener("abort", release, { once: true });
|
|
1934
|
-
if (current.resource) consumer.ready(current.resource);
|
|
1935
|
-
});
|
|
1936
|
-
if (fresh) Promise.resolve().then(() => this.load(key, current.controller.signal)).then((resource) => {
|
|
1937
|
-
if (!current.consumers.size) {
|
|
1938
|
-
resource.release();
|
|
1939
|
-
return;
|
|
1940
|
-
}
|
|
1941
|
-
current.resource = resource;
|
|
1942
|
-
for (const consumer of current.consumers) consumer.ready(resource);
|
|
1943
|
-
}, (error) => {
|
|
1944
|
-
if (this.entries.get(key) === current) this.entries.delete(key);
|
|
1945
|
-
for (const consumer of [...current.consumers]) consumer.fail(error);
|
|
1946
|
-
});
|
|
1947
|
-
return promise;
|
|
1948
|
-
}
|
|
1949
|
-
dispose() {
|
|
1950
|
-
if (this.dead) return;
|
|
1951
|
-
this.dead = true;
|
|
1952
|
-
for (const entry of [...this.entries.values()]) for (const consumer of [...entry.consumers]) consumer.release();
|
|
1953
|
-
}
|
|
1954
|
-
};
|
|
1955
3089
|
// packages/web/src/tilemap-visual.ts
|
|
1956
3090
|
/** Renderer-owned sparse visible set; sheet pixels are owned by SharedImages. */
|
|
1957
3091
|
var TilemapVisual = class {
|
|
@@ -1986,7 +3120,7 @@ var TilemapVisual = class {
|
|
|
1986
3120
|
invalid = true;
|
|
1987
3121
|
off;
|
|
1988
3122
|
offDispose;
|
|
1989
|
-
constructor(map,
|
|
3123
|
+
constructor(map, isCurrent, acquire, changed, onError, released) {
|
|
1990
3124
|
this.map = map;
|
|
1991
3125
|
this.changed = changed;
|
|
1992
3126
|
this.onError = onError;
|
|
@@ -2004,12 +3138,21 @@ var TilemapVisual = class {
|
|
|
2004
3138
|
const controller = this.controller;
|
|
2005
3139
|
if (!controller) throw Error("Missing tilemap request controller");
|
|
2006
3140
|
acquire(map.sheet.url, controller.signal).then((lease) => {
|
|
2007
|
-
if (this.disposed || map.disposed ||
|
|
3141
|
+
if (this.disposed || map.disposed || !isCurrent()) {
|
|
2008
3142
|
lease.release();
|
|
2009
3143
|
return;
|
|
2010
3144
|
}
|
|
2011
3145
|
this.lease = lease;
|
|
3146
|
+
lease.useFrames?.(() => Array.from(this.map.tileIds(), (id) => this.map.sheet.get(id).frame));
|
|
2012
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
|
+
});
|
|
2013
3156
|
}).catch((error) => {
|
|
2014
3157
|
if (!this.disposed) this.fail(error);
|
|
2015
3158
|
}).finally(() => {
|
|
@@ -2066,12 +3209,12 @@ var TilemapVisual = class {
|
|
|
2066
3209
|
sprite.height = this.map.tileHeight;
|
|
2067
3210
|
this.stats.updatedTiles++;
|
|
2068
3211
|
}
|
|
2069
|
-
render(view) {
|
|
3212
|
+
render(view, enabled = true) {
|
|
2070
3213
|
if (this.disposed) return;
|
|
2071
3214
|
this.sync();
|
|
2072
3215
|
const m = this.map;
|
|
2073
3216
|
this.display.position.set(m.x, m.y);
|
|
2074
|
-
this.display.visible = m.visible && m.enabled;
|
|
3217
|
+
this.display.visible = enabled && m.visible && m.enabled;
|
|
2075
3218
|
if (!this.display.visible) {
|
|
2076
3219
|
this.invalid = true;
|
|
2077
3220
|
return;
|
|
@@ -2228,6 +3371,7 @@ var PixiRenderer = class {
|
|
|
2228
3371
|
host;
|
|
2229
3372
|
background;
|
|
2230
3373
|
onError;
|
|
3374
|
+
assetServices;
|
|
2231
3375
|
app = new Application();
|
|
2232
3376
|
pendingUITextKeys = /* @__PURE__ */ new Set();
|
|
2233
3377
|
canvasPresentations = /* @__PURE__ */ new Set();
|
|
@@ -2320,7 +3464,7 @@ var PixiRenderer = class {
|
|
|
2320
3464
|
};
|
|
2321
3465
|
}
|
|
2322
3466
|
scenes = /* @__PURE__ */ new Set();
|
|
2323
|
-
images
|
|
3467
|
+
images;
|
|
2324
3468
|
tilemaps = /* @__PURE__ */ new Set();
|
|
2325
3469
|
get tilemapDiagnostics() {
|
|
2326
3470
|
let sprites = 0, visibleTiles = 0, visitedCells = 0, updatedTiles = 0, createdSprites = 0, destroyedSprites = 0, validatedFrames = 0;
|
|
@@ -2366,19 +3510,20 @@ var PixiRenderer = class {
|
|
|
2366
3510
|
ready = false;
|
|
2367
3511
|
dead = false;
|
|
2368
3512
|
visualCount = 0;
|
|
2369
|
-
textureCount = 0;
|
|
2370
3513
|
get diagnostics() {
|
|
2371
3514
|
return {
|
|
2372
3515
|
presentations: this.scenes.size,
|
|
2373
3516
|
visuals: this.visualCount,
|
|
2374
|
-
textures: this.
|
|
3517
|
+
textures: this.images.textures,
|
|
2375
3518
|
...this.images.diagnostics
|
|
2376
3519
|
};
|
|
2377
3520
|
}
|
|
2378
|
-
constructor(host, background = "#101922", onError = () => {}) {
|
|
3521
|
+
constructor(host, background = "#101922", onError = () => {}, assetServices) {
|
|
2379
3522
|
this.host = host;
|
|
2380
3523
|
this.background = background;
|
|
2381
3524
|
this.onError = onError;
|
|
3525
|
+
this.assetServices = assetServices;
|
|
3526
|
+
this.images = assetServices?.images ?? createImagePool((...args) => this.image(...args));
|
|
2382
3527
|
}
|
|
2383
3528
|
async init() {
|
|
2384
3529
|
await this.app.init({
|
|
@@ -2401,54 +3546,11 @@ var PixiRenderer = class {
|
|
|
2401
3546
|
this.app.canvas.setAttribute("aria-label", "Game world");
|
|
2402
3547
|
this.host.prepend(this.app.canvas);
|
|
2403
3548
|
}
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
await new Promise((resolve, reject) => {
|
|
2407
|
-
const clear = () => {
|
|
2408
|
-
image.onload = null;
|
|
2409
|
-
image.onerror = null;
|
|
2410
|
-
signal.removeEventListener("abort", abort);
|
|
2411
|
-
};
|
|
2412
|
-
const abort = () => {
|
|
2413
|
-
clear();
|
|
2414
|
-
image.src = "";
|
|
2415
|
-
reject(new EngineError("GAME_DISPOSED", "Image loading cancelled", { path: url }));
|
|
2416
|
-
};
|
|
2417
|
-
image.onload = () => {
|
|
2418
|
-
clear();
|
|
2419
|
-
resolve();
|
|
2420
|
-
};
|
|
2421
|
-
image.onerror = () => {
|
|
2422
|
-
clear();
|
|
2423
|
-
reject(new EngineError("ASSET_LOAD_FAILED", "Could not load sprite", { path: url }));
|
|
2424
|
-
};
|
|
2425
|
-
if (signal.aborted) {
|
|
2426
|
-
abort();
|
|
2427
|
-
return;
|
|
2428
|
-
}
|
|
2429
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
2430
|
-
image.crossOrigin = "anonymous";
|
|
2431
|
-
image.src = url;
|
|
2432
|
-
});
|
|
2433
|
-
if (signal.aborted) throw new EngineError("GAME_DISPOSED", "Image loading cancelled", { path: url });
|
|
2434
|
-
const texture = Texture.from(image, true);
|
|
2435
|
-
if (!(texture.width > 0 && texture.height > 0)) {
|
|
2436
|
-
texture.destroy(true);
|
|
2437
|
-
throw new EngineError("ASSET_LOAD_FAILED", "Image has no drawable dimensions", { path: url });
|
|
2438
|
-
}
|
|
2439
|
-
this.textureCount++;
|
|
2440
|
-
let released = false;
|
|
2441
|
-
return {
|
|
2442
|
-
texture,
|
|
2443
|
-
release: () => {
|
|
2444
|
-
if (released) return;
|
|
2445
|
-
released = true;
|
|
2446
|
-
this.textureCount--;
|
|
2447
|
-
texture.destroy(true);
|
|
2448
|
-
}
|
|
2449
|
-
};
|
|
3549
|
+
image(url, signal, mode) {
|
|
3550
|
+
return loadImageResource(url, signal, mode);
|
|
2450
3551
|
}
|
|
2451
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"));
|
|
2452
3554
|
try {
|
|
2453
3555
|
const parsed = new URL(url, document.baseURI);
|
|
2454
3556
|
if (![
|
|
@@ -2471,6 +3573,7 @@ var PixiRenderer = class {
|
|
|
2471
3573
|
root.mask = mask;
|
|
2472
3574
|
const visuals = /* @__PURE__ */ new Map();
|
|
2473
3575
|
const tilemaps = /* @__PURE__ */ new Map();
|
|
3576
|
+
const geometryTilemaps = /* @__PURE__ */ new Map();
|
|
2474
3577
|
const drawOrder = [];
|
|
2475
3578
|
let depthDirty = true;
|
|
2476
3579
|
let revision = -1, disposed = false, clipX = NaN, clipY = NaN, clipWidth = NaN, clipHeight = NaN, removeSceneCleanup;
|
|
@@ -2553,6 +3656,7 @@ var PixiRenderer = class {
|
|
|
2553
3656
|
const commitImage = (entry, resource, source) => {
|
|
2554
3657
|
if (source.kind !== "image") return;
|
|
2555
3658
|
const texture = resource.textureFor(source.frame);
|
|
3659
|
+
resource.useFrames?.(() => source.frame ? [source.frame] : []);
|
|
2556
3660
|
const previous = entry.image;
|
|
2557
3661
|
const sprite = entry.display instanceof Sprite ? entry.display : new Sprite(texture);
|
|
2558
3662
|
sprite.texture = texture;
|
|
@@ -2560,7 +3664,17 @@ var PixiRenderer = class {
|
|
|
2560
3664
|
entry.image = resource;
|
|
2561
3665
|
replace(entry, sprite, source);
|
|
2562
3666
|
appearance(entry);
|
|
2563
|
-
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
|
+
}
|
|
2564
3678
|
};
|
|
2565
3679
|
const update = (entry) => {
|
|
2566
3680
|
const component = entry.component;
|
|
@@ -2670,20 +3784,30 @@ var PixiRenderer = class {
|
|
|
2670
3784
|
entry.dispose();
|
|
2671
3785
|
visuals.delete(actor);
|
|
2672
3786
|
}
|
|
2673
|
-
for (const [actor, entry] of tilemaps)
|
|
2674
|
-
|
|
2675
|
-
if (
|
|
2676
|
-
|
|
2677
|
-
|
|
3787
|
+
for (const [actor, entry] of tilemaps) {
|
|
3788
|
+
const layer = actor.get(TilemapLayer.type);
|
|
3789
|
+
if (actor.disposed || actor.scene !== scene || !layer || layer.disposed || layer.map !== entry.map || entry.disposed) {
|
|
3790
|
+
entry.dispose();
|
|
3791
|
+
this.tilemaps.delete(entry);
|
|
3792
|
+
tilemaps.delete(actor);
|
|
3793
|
+
}
|
|
3794
|
+
}
|
|
3795
|
+
for (const [actor, entry] of geometryTilemaps) {
|
|
3796
|
+
const layer = actor.get(GeometryTilemapLayer.type);
|
|
3797
|
+
if (actor.disposed || actor.scene !== scene || !layer || layer.disposed || layer.map !== entry.map || entry.disposed) {
|
|
3798
|
+
entry.dispose();
|
|
3799
|
+
this.geometryTilemaps.delete(entry);
|
|
3800
|
+
geometryTilemaps.delete(actor);
|
|
3801
|
+
}
|
|
2678
3802
|
}
|
|
2679
3803
|
let order = 0;
|
|
2680
3804
|
for (const actor of scene.actors) {
|
|
2681
3805
|
const layer = actor.get(TilemapLayer.type);
|
|
2682
|
-
if (layer) {
|
|
3806
|
+
if (layer && !layer.disposed && !layer.map.disposed) {
|
|
2683
3807
|
let tile = tilemaps.get(actor);
|
|
2684
3808
|
if (!tile) {
|
|
2685
|
-
tile = new TilemapVisual(layer.map, scene, (url, signal) => this.acquireImage(url, signal), changed, this.onError, () => {
|
|
2686
|
-
if (tile
|
|
3809
|
+
tile = new TilemapVisual(layer.map, () => actor.scene === scene && actor.get(TilemapLayer.type)?.map === layer.map, (url, signal) => this.acquireImage(url, signal), changed, this.onError, () => {
|
|
3810
|
+
if (tile) this.tilemaps.delete(tile);
|
|
2687
3811
|
depthDirty = true;
|
|
2688
3812
|
});
|
|
2689
3813
|
world.addChild(tile.display);
|
|
@@ -2693,8 +3817,8 @@ var PixiRenderer = class {
|
|
|
2693
3817
|
tile.order = order;
|
|
2694
3818
|
}
|
|
2695
3819
|
const geometryLayer = actor.get(GeometryTilemapLayer.type);
|
|
2696
|
-
if (geometryLayer) {
|
|
2697
|
-
let tile =
|
|
3820
|
+
if (geometryLayer && !geometryLayer.disposed && !geometryLayer.map.disposed) {
|
|
3821
|
+
let tile = geometryTilemaps.get(actor);
|
|
2698
3822
|
if (!tile) {
|
|
2699
3823
|
const created = new GeometryTilemapVisual(geometryLayer.map, () => {
|
|
2700
3824
|
this.geometryTilemaps.delete(created);
|
|
@@ -2702,7 +3826,7 @@ var PixiRenderer = class {
|
|
|
2702
3826
|
});
|
|
2703
3827
|
tile = created;
|
|
2704
3828
|
world.addChild(tile.display);
|
|
2705
|
-
|
|
3829
|
+
geometryTilemaps.set(actor, tile);
|
|
2706
3830
|
this.geometryTilemaps.add(created);
|
|
2707
3831
|
}
|
|
2708
3832
|
tile.order = order;
|
|
@@ -2722,7 +3846,7 @@ var PixiRenderer = class {
|
|
|
2722
3846
|
const sync = () => {
|
|
2723
3847
|
reconcile();
|
|
2724
3848
|
for (const entry of visuals.values()) update(entry);
|
|
2725
|
-
for (const entry of tilemaps.values())
|
|
3849
|
+
for (const entry of tilemaps.values()) entry.sync();
|
|
2726
3850
|
};
|
|
2727
3851
|
const prepared = {
|
|
2728
3852
|
ready: async () => {
|
|
@@ -2735,7 +3859,6 @@ var PixiRenderer = class {
|
|
|
2735
3859
|
if (entry.controller) pending = true;
|
|
2736
3860
|
}
|
|
2737
3861
|
for (const entry of tilemaps.values()) {
|
|
2738
|
-
if (!(entry instanceof TilemapVisual)) continue;
|
|
2739
3862
|
if (entry.error) throw entry.error;
|
|
2740
3863
|
if (entry.controller) pending = true;
|
|
2741
3864
|
}
|
|
@@ -2777,8 +3900,15 @@ var PixiRenderer = class {
|
|
|
2777
3900
|
entry.display.scale.set(scaleX, scaleY);
|
|
2778
3901
|
}
|
|
2779
3902
|
}
|
|
2780
|
-
for (const entry of tilemaps
|
|
2781
|
-
entry.render(view);
|
|
3903
|
+
for (const [actor, entry] of tilemaps) {
|
|
3904
|
+
entry.render(view, actor.enabled && actor.get(TilemapLayer.type)?.enabled === true);
|
|
3905
|
+
if (entry.depth !== entry.map.zIndex) {
|
|
3906
|
+
entry.depth = entry.map.zIndex;
|
|
3907
|
+
depthDirty = true;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
for (const [actor, entry] of geometryTilemaps) {
|
|
3911
|
+
entry.render(view, actor.enabled && actor.get(GeometryTilemapLayer.type)?.enabled === true);
|
|
2782
3912
|
if (entry.depth !== entry.map.zIndex) {
|
|
2783
3913
|
entry.depth = entry.map.zIndex;
|
|
2784
3914
|
depthDirty = true;
|
|
@@ -2796,6 +3926,11 @@ var PixiRenderer = class {
|
|
|
2796
3926
|
order: entry.order,
|
|
2797
3927
|
zIndex: entry.map.zIndex
|
|
2798
3928
|
});
|
|
3929
|
+
for (const entry of geometryTilemaps.values()) drawOrder.push({
|
|
3930
|
+
display: entry.display,
|
|
3931
|
+
order: entry.order,
|
|
3932
|
+
zIndex: entry.map.zIndex
|
|
3933
|
+
});
|
|
2799
3934
|
drawOrder.sort((a, b) => a.zIndex - b.zIndex || a.order - b.order);
|
|
2800
3935
|
for (let i = 0; i < drawOrder.length; i++) {
|
|
2801
3936
|
const entry = drawOrder[i];
|
|
@@ -2820,10 +3955,14 @@ var PixiRenderer = class {
|
|
|
2820
3955
|
() => {
|
|
2821
3956
|
for (const entry of tilemaps.values()) {
|
|
2822
3957
|
entry.dispose();
|
|
2823
|
-
|
|
2824
|
-
else this.geometryTilemaps.delete(entry);
|
|
3958
|
+
this.tilemaps.delete(entry);
|
|
2825
3959
|
}
|
|
2826
3960
|
tilemaps.clear();
|
|
3961
|
+
for (const entry of geometryTilemaps.values()) {
|
|
3962
|
+
entry.dispose();
|
|
3963
|
+
this.geometryTilemaps.delete(entry);
|
|
3964
|
+
}
|
|
3965
|
+
geometryTilemaps.clear();
|
|
2827
3966
|
},
|
|
2828
3967
|
() => root.destroy({
|
|
2829
3968
|
children: true,
|
|
@@ -2873,7 +4012,9 @@ var PixiRenderer = class {
|
|
|
2873
4012
|
p.dispose();
|
|
2874
4013
|
}),
|
|
2875
4014
|
() => this.pendingUITextKeys.clear(),
|
|
2876
|
-
() =>
|
|
4015
|
+
() => {
|
|
4016
|
+
if (!this.assetServices) this.images.dispose();
|
|
4017
|
+
},
|
|
2877
4018
|
() => this.destroyApp()
|
|
2878
4019
|
], "Renderer cleanup failed");
|
|
2879
4020
|
}
|
|
@@ -3121,6 +4262,13 @@ var Game = class {
|
|
|
3121
4262
|
element;
|
|
3122
4263
|
camera;
|
|
3123
4264
|
gamepads;
|
|
4265
|
+
logger;
|
|
4266
|
+
assets;
|
|
4267
|
+
audio;
|
|
4268
|
+
defaultFont;
|
|
4269
|
+
fontUse;
|
|
4270
|
+
imagePool = createImagePool();
|
|
4271
|
+
assetStore;
|
|
3124
4272
|
renderer;
|
|
3125
4273
|
keyboard;
|
|
3126
4274
|
captures;
|
|
@@ -3158,8 +4306,14 @@ var Game = class {
|
|
|
3158
4306
|
const renderer = this.renderer;
|
|
3159
4307
|
if (!renderer.prepareVirtualInput) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support virtual input presentation");
|
|
3160
4308
|
const presentation = renderer.prepareVirtualInput(input);
|
|
3161
|
-
input.onDispose(() => presentation.dispose());
|
|
3162
|
-
return
|
|
4309
|
+
const off = input.onDispose(() => presentation.dispose());
|
|
4310
|
+
return {
|
|
4311
|
+
update: () => presentation.update(),
|
|
4312
|
+
dispose: () => {
|
|
4313
|
+
off();
|
|
4314
|
+
presentation.dispose();
|
|
4315
|
+
}
|
|
4316
|
+
};
|
|
3163
4317
|
}
|
|
3164
4318
|
canvas;
|
|
3165
4319
|
canvasPresentation;
|
|
@@ -3201,7 +4355,11 @@ var Game = class {
|
|
|
3201
4355
|
height = 450;
|
|
3202
4356
|
ratio = 1;
|
|
3203
4357
|
scenes = /* @__PURE__ */ new Map();
|
|
3204
|
-
|
|
4358
|
+
retainedScenes = /* @__PURE__ */ new Map();
|
|
4359
|
+
sceneRetention;
|
|
4360
|
+
activeRetention = "dispose";
|
|
4361
|
+
detachPresentation;
|
|
4362
|
+
pendingScene;
|
|
3205
4363
|
defaultUI = [];
|
|
3206
4364
|
ui;
|
|
3207
4365
|
prepared;
|
|
@@ -3218,6 +4376,12 @@ var Game = class {
|
|
|
3218
4376
|
errorBox;
|
|
3219
4377
|
constructor(options = {}) {
|
|
3220
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
|
+
});
|
|
3221
4385
|
this.engine = new Engine(options.seed === void 0 ? {} : { seed: options.seed });
|
|
3222
4386
|
this.fixed = options.fixedStep ? new FixedStepRunner(this.engine, options.fixedStep) : void 0;
|
|
3223
4387
|
this.camera = options.camera ?? new Camera();
|
|
@@ -3248,6 +4412,9 @@ var Game = class {
|
|
|
3248
4412
|
...theme,
|
|
3249
4413
|
...options.tokens
|
|
3250
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);
|
|
3251
4418
|
this.element = document.createElement("div");
|
|
3252
4419
|
this.element.className = "simpludify";
|
|
3253
4420
|
this.element.tabIndex = 0;
|
|
@@ -3259,18 +4426,25 @@ var Game = class {
|
|
|
3259
4426
|
this.errorBox.hidden = true;
|
|
3260
4427
|
this.element.append(this.errorBox);
|
|
3261
4428
|
(options.parent ?? document.body).append(this.element);
|
|
4429
|
+
this.audio = new GameAudio(this.element, this.assetStore, this.logger);
|
|
3262
4430
|
try {
|
|
3263
|
-
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
|
+
});
|
|
3264
4435
|
} catch (error) {
|
|
3265
4436
|
this.element.remove();
|
|
3266
4437
|
this.engine.dispose();
|
|
4438
|
+
this.audio.dispose();
|
|
4439
|
+
this.assetStore.dispose();
|
|
4440
|
+
this.imagePool.dispose();
|
|
3267
4441
|
throw error;
|
|
3268
4442
|
}
|
|
3269
4443
|
this.frames = options.frames ?? {
|
|
3270
4444
|
request: (callback) => requestAnimationFrame(callback),
|
|
3271
4445
|
cancel: (handle) => cancelAnimationFrame(handle)
|
|
3272
4446
|
};
|
|
3273
|
-
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"));
|
|
3274
4448
|
this.keyboard = new BrowserInput(this.element, this.engine.input);
|
|
3275
4449
|
this.gamepads = new GamepadInput(this.engine.input, {
|
|
3276
4450
|
observe: (pads) => this.captures.sample(pads),
|
|
@@ -3278,14 +4452,14 @@ var Game = class {
|
|
|
3278
4452
|
});
|
|
3279
4453
|
this.pointers = new BrowserPointer(this.element, (event) => {
|
|
3280
4454
|
const rect = this.element.getBoundingClientRect();
|
|
3281
|
-
if (rect.width <= 0 || rect.height <= 0)
|
|
4455
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
3282
4456
|
const screen = screenPoint((event.clientX - rect.left) * this.width / rect.width, (event.clientY - rect.top) * this.height / rect.height);
|
|
3283
4457
|
return {
|
|
3284
4458
|
screen,
|
|
3285
4459
|
world: this.screenToWorld(screen),
|
|
3286
4460
|
view: this.view
|
|
3287
4461
|
};
|
|
3288
|
-
}, this.report, () => this.updateProjection(), this.engine.input);
|
|
4462
|
+
}, (error) => this.report(error, "engine.input"), () => this.updateProjection(), this.engine.input);
|
|
3289
4463
|
document.addEventListener("visibilitychange", () => {
|
|
3290
4464
|
this.previousTime = void 0;
|
|
3291
4465
|
this.fixed?.reset();
|
|
@@ -3295,12 +4469,26 @@ var Game = class {
|
|
|
3295
4469
|
try {
|
|
3296
4470
|
this.resize();
|
|
3297
4471
|
} catch (error) {
|
|
3298
|
-
this.report(error);
|
|
4472
|
+
this.report(error, "engine.resize", true);
|
|
3299
4473
|
this.dispose();
|
|
3300
4474
|
}
|
|
3301
4475
|
});
|
|
3302
4476
|
this.observer.observe(this.element);
|
|
3303
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
|
+
}
|
|
3304
4492
|
assertAlive() {
|
|
3305
4493
|
if (this.dead) throw new EngineError("GAME_DISPOSED", "Game has been disposed");
|
|
3306
4494
|
}
|
|
@@ -3382,14 +4570,22 @@ var Game = class {
|
|
|
3382
4570
|
if (this.startPromise) throw new EngineError("UI_ALREADY_STARTED", "Define default UI before run");
|
|
3383
4571
|
this.defaultUI.push(...nodes);
|
|
3384
4572
|
}
|
|
3385
|
-
scene(id, setup) {
|
|
4573
|
+
scene(id, setup, options = {}) {
|
|
3386
4574
|
this.assertAlive();
|
|
4575
|
+
if (typeof id !== "string" || !id.length) throw new EngineError("INVALID_VALUE", "Scene id must not be empty");
|
|
4576
|
+
if (typeof setup !== "function") throw new EngineError("INVALID_VALUE", "Scene setup must be a function");
|
|
3387
4577
|
if (this.scenes.has(id)) throw new EngineError("DUPLICATE_SCENE", "Scene id is already registered", { sceneId: id });
|
|
3388
|
-
this.scenes.set(id,
|
|
4578
|
+
this.scenes.set(id, {
|
|
4579
|
+
setup,
|
|
4580
|
+
retention: retentionPolicy(options.retention ?? this.sceneRetention)
|
|
4581
|
+
});
|
|
3389
4582
|
return this;
|
|
3390
4583
|
}
|
|
3391
4584
|
wait(promise) {
|
|
3392
|
-
if (this.dead)
|
|
4585
|
+
if (this.dead) {
|
|
4586
|
+
promise.catch(() => {});
|
|
4587
|
+
return Promise.reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
|
|
4588
|
+
}
|
|
3393
4589
|
const signal = this.shutdown.signal;
|
|
3394
4590
|
return new Promise((resolve, reject) => {
|
|
3395
4591
|
const abort = () => {
|
|
@@ -3419,24 +4615,43 @@ var Game = class {
|
|
|
3419
4615
|
this.assertAlive();
|
|
3420
4616
|
this.frame = this.frames.request(this.tick);
|
|
3421
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
|
+
});
|
|
3422
4624
|
this.dispose();
|
|
3423
4625
|
throw error;
|
|
3424
4626
|
}
|
|
3425
4627
|
})();
|
|
3426
4628
|
return this.startPromise;
|
|
3427
4629
|
}
|
|
3428
|
-
|
|
4630
|
+
changeScene(id) {
|
|
3429
4631
|
if (this.dead) return Promise.reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
|
|
3430
4632
|
if (!this.ready) return Promise.reject(new EngineError("GAME_NOT_READY", "Await run before navigating"));
|
|
3431
4633
|
return this.navigate(id);
|
|
3432
4634
|
}
|
|
3433
4635
|
navigate(id) {
|
|
3434
|
-
const
|
|
3435
|
-
if (!
|
|
3436
|
-
|
|
3437
|
-
if (existing) return existing;
|
|
4636
|
+
const registration = this.scenes.get(id);
|
|
4637
|
+
if (!registration) return Promise.reject(new EngineError("SCENE_NOT_FOUND", "Unknown scene", { sceneId: id }));
|
|
4638
|
+
if (this.pendingScene?.id === id) return this.pendingScene.task;
|
|
3438
4639
|
const task = this.queue.then(async () => {
|
|
3439
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
|
+
}
|
|
3440
4655
|
const scene = new Scene(id), nodes = [], controller = new AbortController();
|
|
3441
4656
|
let canvas;
|
|
3442
4657
|
const pointer = this.channelFor(scene);
|
|
@@ -3444,8 +4659,10 @@ var Game = class {
|
|
|
3444
4659
|
this.staging = scene;
|
|
3445
4660
|
scene.onDispose(() => controller.abort());
|
|
3446
4661
|
try {
|
|
3447
|
-
|
|
4662
|
+
const assets = this.assets.scope(scene);
|
|
4663
|
+
await this.wait(Promise.resolve(registration.setup({
|
|
3448
4664
|
scene,
|
|
4665
|
+
assets,
|
|
3449
4666
|
get tweens() {
|
|
3450
4667
|
return scene.tweens;
|
|
3451
4668
|
},
|
|
@@ -3476,8 +4693,18 @@ var Game = class {
|
|
|
3476
4693
|
onDispose: (callback) => scene.onDispose(callback)
|
|
3477
4694
|
})));
|
|
3478
4695
|
this.assertAlive();
|
|
3479
|
-
|
|
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
|
|
4705
|
+
});
|
|
3480
4706
|
} catch (error) {
|
|
4707
|
+
this.reportPreparation(error);
|
|
3481
4708
|
try {
|
|
3482
4709
|
scene.dispose();
|
|
3483
4710
|
} catch (cleanupError) {
|
|
@@ -3488,16 +4715,31 @@ var Game = class {
|
|
|
3488
4715
|
if (this.staging === scene) this.staging = void 0;
|
|
3489
4716
|
}
|
|
3490
4717
|
}).finally(() => {
|
|
3491
|
-
if (this.
|
|
4718
|
+
if (this.pendingScene?.task === task) this.pendingScene = void 0;
|
|
3492
4719
|
});
|
|
3493
|
-
this.
|
|
4720
|
+
this.pendingScene = {
|
|
4721
|
+
id,
|
|
4722
|
+
task
|
|
4723
|
+
};
|
|
3494
4724
|
this.queue = task.catch(() => {});
|
|
3495
4725
|
return task;
|
|
3496
4726
|
}
|
|
3497
|
-
|
|
3498
|
-
|
|
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;
|
|
3499
4737
|
let canvasPresentation;
|
|
3500
4738
|
let virtualPresentation;
|
|
4739
|
+
const hooks = [];
|
|
4740
|
+
const detach = () => {
|
|
4741
|
+
for (const off of hooks.splice(0)) off();
|
|
4742
|
+
};
|
|
3501
4743
|
try {
|
|
3502
4744
|
const preparing = this.renderer.prepare(scene);
|
|
3503
4745
|
preparing.then((value) => {
|
|
@@ -3510,43 +4752,60 @@ var Game = class {
|
|
|
3510
4752
|
prepared = await this.wait(preparing);
|
|
3511
4753
|
this.assertAlive();
|
|
3512
4754
|
ui = mountUI(nodes, this.report);
|
|
3513
|
-
scene.onDispose(ui.dispose);
|
|
4755
|
+
hooks.push(scene.onDispose(ui.dispose));
|
|
3514
4756
|
if (canvas) {
|
|
3515
4757
|
canvas.setErrorHandler(this.report);
|
|
3516
4758
|
const renderer = this.renderer;
|
|
3517
4759
|
if (!renderer.prepareCanvasUI) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support Canvas UI");
|
|
3518
4760
|
canvasPresentation = renderer.prepareCanvasUI(canvas, this.pointers.routing);
|
|
3519
|
-
scene.onDispose(() => canvasPresentation?.dispose());
|
|
4761
|
+
hooks.push(scene.onDispose(() => canvasPresentation?.dispose()));
|
|
3520
4762
|
}
|
|
3521
4763
|
virtualPresentation = this.prepareVirtual(scene);
|
|
3522
|
-
scene.markReady();
|
|
4764
|
+
if (scene.state === SceneState.Preparing) scene.markReady();
|
|
4765
|
+
beforeActivate?.();
|
|
3523
4766
|
this.errorBox.hidden = true;
|
|
3524
4767
|
try {
|
|
3525
4768
|
if (this.playback && this.playbackScene !== scene) this.stopReplay();
|
|
3526
|
-
this.engine.activate(scene);
|
|
4769
|
+
this.engine.activate(scene, { previous: this.activeRetention });
|
|
3527
4770
|
} catch (error) {
|
|
3528
|
-
if (this.engine.scene !== scene
|
|
3529
|
-
|
|
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");
|
|
3530
4775
|
}
|
|
4776
|
+
activated = true;
|
|
3531
4777
|
this.assertAlive();
|
|
3532
|
-
|
|
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;
|
|
3533
4780
|
this.prepared = prepared;
|
|
4781
|
+
this.detachPresentation = detach;
|
|
4782
|
+
this.activeRetention = retention;
|
|
3534
4783
|
this.ui = ui;
|
|
3535
4784
|
this.canvas = canvas;
|
|
3536
4785
|
this.canvasPresentation = canvasPresentation;
|
|
3537
4786
|
this.virtualPresentation = virtualPresentation;
|
|
3538
|
-
this.pointers.routing.setScene(this.channelFor(scene), canvas);
|
|
3539
4787
|
committed = true;
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
()
|
|
3543
|
-
|
|
3544
|
-
(
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
4788
|
+
try {
|
|
4789
|
+
this.pointers.routing.setScene(this.channelFor(scene), canvas);
|
|
4790
|
+
this.element.append(ui.element);
|
|
4791
|
+
} finally {
|
|
4792
|
+
for (const cleanup of [
|
|
4793
|
+
() => detachOld?.(),
|
|
4794
|
+
() => {
|
|
4795
|
+
if (oldCanvasUI && !oldCanvasUI.disposed) {
|
|
4796
|
+
oldCanvasUI.focus();
|
|
4797
|
+
oldCanvasUI.stopMotion();
|
|
4798
|
+
}
|
|
4799
|
+
},
|
|
4800
|
+
() => oldPrepared?.dispose(),
|
|
4801
|
+
() => oldUI?.dispose(),
|
|
4802
|
+
() => oldCanvas?.dispose(),
|
|
4803
|
+
() => oldVirtual?.dispose()
|
|
4804
|
+
]) try {
|
|
4805
|
+
cleanup();
|
|
4806
|
+
} catch (error) {
|
|
4807
|
+
this.report(error);
|
|
4808
|
+
}
|
|
3550
4809
|
}
|
|
3551
4810
|
this.assertAlive();
|
|
3552
4811
|
this.previousTime = void 0;
|
|
@@ -3556,6 +4815,7 @@ var Game = class {
|
|
|
3556
4815
|
this.assertAlive();
|
|
3557
4816
|
} catch (error) {
|
|
3558
4817
|
if (!committed) for (const cleanup of [
|
|
4818
|
+
detach,
|
|
3559
4819
|
() => prepared?.dispose(),
|
|
3560
4820
|
() => ui?.dispose(),
|
|
3561
4821
|
() => canvasPresentation?.dispose(),
|
|
@@ -3565,7 +4825,15 @@ var Game = class {
|
|
|
3565
4825
|
} catch (cleanupError) {
|
|
3566
4826
|
this.report(cleanupError);
|
|
3567
4827
|
}
|
|
3568
|
-
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
|
+
}
|
|
3569
4837
|
throw error;
|
|
3570
4838
|
}
|
|
3571
4839
|
}
|
|
@@ -3744,16 +5012,21 @@ var Game = class {
|
|
|
3744
5012
|
}
|
|
3745
5013
|
if (!this.dead) this.frame = this.frames.request(this.tick);
|
|
3746
5014
|
} catch (error) {
|
|
3747
|
-
this.report(error);
|
|
5015
|
+
this.report(error, "engine.frame", true);
|
|
3748
5016
|
this.dispose();
|
|
3749
5017
|
}
|
|
3750
5018
|
};
|
|
3751
|
-
report = (error) => {
|
|
3752
|
-
|
|
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;
|
|
3753
5027
|
this.errorBox.hidden = false;
|
|
3754
5028
|
try {
|
|
3755
5029
|
if (this.options.onError) this.options.onError(error);
|
|
3756
|
-
else console.error(error);
|
|
3757
5030
|
} catch {}
|
|
3758
5031
|
};
|
|
3759
5032
|
dispose() {
|
|
@@ -3772,10 +5045,15 @@ var Game = class {
|
|
|
3772
5045
|
() => this.virtualPresentation?.dispose(),
|
|
3773
5046
|
() => this.canvas?.dispose(),
|
|
3774
5047
|
() => this.observer.disconnect(),
|
|
5048
|
+
() => this.detachPresentation?.(),
|
|
3775
5049
|
() => this.ui?.dispose(),
|
|
3776
5050
|
() => this.prepared?.dispose(),
|
|
3777
5051
|
() => this.renderer.dispose(),
|
|
5052
|
+
() => this.audio.dispose(),
|
|
3778
5053
|
() => this.engine.dispose(),
|
|
5054
|
+
() => this.fontUse?.release(),
|
|
5055
|
+
() => this.assetStore.dispose(),
|
|
5056
|
+
() => this.imagePool.dispose(),
|
|
3779
5057
|
() => this.element.remove()
|
|
3780
5058
|
]) try {
|
|
3781
5059
|
cleanup();
|
|
@@ -3789,11 +5067,18 @@ var Game = class {
|
|
|
3789
5067
|
this.canvasPresentation = void 0;
|
|
3790
5068
|
this.virtualPresentation = void 0;
|
|
3791
5069
|
this.defaultCanvas = void 0;
|
|
5070
|
+
this.fontUse = void 0;
|
|
3792
5071
|
this.defaultUI.length = 0;
|
|
3793
5072
|
this.scenes.clear();
|
|
3794
|
-
this.
|
|
5073
|
+
this.retainedScenes.clear();
|
|
5074
|
+
this.detachPresentation = void 0;
|
|
5075
|
+
this.pendingScene = void 0;
|
|
3795
5076
|
}
|
|
3796
5077
|
};
|
|
5078
|
+
function retentionPolicy(value) {
|
|
5079
|
+
if (value !== "dispose" && value !== "retain") throw new EngineError("INVALID_VALUE", "Unknown scene retention policy");
|
|
5080
|
+
return value;
|
|
5081
|
+
}
|
|
3797
5082
|
function validFontFamily(value) {
|
|
3798
5083
|
const syntax = value.replace(/"(?:\\[^\r\n]|[^"\\])*"|'(?:\\[^\r\n]|[^'\\])*'/g, "family").replace(/\\(?:[0-9a-f]{1,6}[ \t]?|[^\r\n])/gi, "x");
|
|
3799
5084
|
return value.trim().length > 0 && !/[\r\n\0]/.test(value) && !/[;{}<>\\"']|url\s*\(/i.test(syntax);
|