@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/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 { screen } = this.project(e);
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)) this.routing.leave(this.data(e));
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(this.data(e));
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.routing.up(this.data(e));
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(() => this.routing.leave(this.data(e))), options);
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 { screen, world, view } = this.project(e);
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, fontFamily, textWrap } = node.style;
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
- s.fontFamily,
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: s.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, scene, acquire, changed, onError, released) {
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 || map.scene !== scene) {
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 = new SharedImages((url, signal) => this.image(url, signal));
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.textureCount,
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
- async image(url, signal) {
2405
- const image = new Image();
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) 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
+ }
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) if (actor.disposed || actor.scene !== scene || !(entry instanceof TilemapVisual ? actor.get(TilemapLayer.type) : actor.get(GeometryTilemapLayer.type)) || entry.disposed) {
2674
- entry.dispose();
2675
- if (entry instanceof TilemapVisual) this.tilemaps.delete(entry);
2676
- else this.geometryTilemaps.delete(entry);
2677
- tilemaps.delete(actor);
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 instanceof TilemapVisual) this.tilemaps.delete(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 = tilemaps.get(actor);
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
- tilemaps.set(actor, tile);
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()) if (entry instanceof TilemapVisual) entry.sync();
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.values()) {
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
- if (entry instanceof TilemapVisual) this.tilemaps.delete(entry);
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
- () => this.images.dispose(),
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 presentation;
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
- pendingScenes = /* @__PURE__ */ new Map();
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) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
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, setup);
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) return Promise.reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
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
- goTo(id) {
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 setup = this.scenes.get(id);
3435
- if (!setup) return Promise.reject(new EngineError("SCENE_NOT_FOUND", "Unknown scene", { sceneId: id }));
3436
- const existing = this.pendingScenes.get(id);
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
- await this.wait(Promise.resolve(setup({
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
- await this.present(scene, nodes, canvas);
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.pendingScenes.get(id) === task) this.pendingScenes.delete(id);
4718
+ if (this.pendingScene?.task === task) this.pendingScene = void 0;
3492
4719
  });
3493
- this.pendingScenes.set(id, task);
4720
+ this.pendingScene = {
4721
+ id,
4722
+ task
4723
+ };
3494
4724
  this.queue = task.catch(() => {});
3495
4725
  return task;
3496
4726
  }
3497
- async present(scene, nodes, canvas) {
3498
- 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;
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 || scene.state !== SceneState.Active) throw error;
3529
- 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");
3530
4775
  }
4776
+ activated = true;
3531
4777
  this.assertAlive();
3532
- 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;
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
- this.element.append(ui.element);
3541
- for (const cleanup of [
3542
- () => oldPrepared?.dispose(),
3543
- () => oldUI?.dispose(),
3544
- () => oldCanvas?.dispose(),
3545
- () => oldVirtual?.dispose()
3546
- ]) try {
3547
- cleanup();
3548
- } catch (error) {
3549
- this.report(error);
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 (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
+ }
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
- 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;
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.pendingScenes.clear();
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);