@simpludify/web 0.1.7 → 0.2.0

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