@simpludify/web 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,2843 @@
1
+ import "./style.css";
2
+ import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, FixedStepRunner, GeometryTilemapLayer, PointerRouting, Renderable, ReplayPlayer, Scene, ScenePointer, SceneState, Shape, TilemapLayer, Transform, canvasControlText, canvasHoverHighlight, canvasOpacity, canvasScrollbars, canvasSliderGeometry, cleanupAll, containsScreenPoint, createViewTransform, imageSize, intersectUI, isKeyCode, positive, screenPoint, screenToWorld, spriteFrameBounds, worldToScreen, wrapCanvasText } from "@simpludify/core";
3
+ import { Application, CanvasTextMetrics, Container, Graphics, Rectangle, Sprite, Text, TextStyle, Texture } from "pixi.js";
4
+ export * from "@simpludify/core";
5
+ // packages/web/src/canvas-node.ts
6
+ /** Bridge owner cancellation to browser APIs such as fetch. */
7
+ function browserAbortSignal(signal) {
8
+ const controller = new AbortController();
9
+ signal.onCancel(() => controller.abort());
10
+ return controller.signal;
11
+ }
12
+ /** Preserve Web callbacks' native AbortSignal; portable definitions import Core's factory. */
13
+ function CanvasButton(text, click, style = {}) {
14
+ let signal;
15
+ return CanvasButton$1(text, (owner) => {
16
+ signal ??= browserAbortSignal(owner);
17
+ return click(signal);
18
+ }, style);
19
+ }
20
+ // packages/web/src/ui-keyboard.ts
21
+ /** Host-only ownership metadata; native editing is never reconstructed from game keys. */
22
+ var gameButtons = /* @__PURE__ */ new WeakSet();
23
+ var consumed = /* @__PURE__ */ new WeakSet();
24
+ function registerGameButton(button, signal) {
25
+ gameButtons.add(button);
26
+ button.addEventListener("keydown", (event) => {
27
+ if (event.code === "Enter" || event.code === "Space") consumed.add(event);
28
+ }, { signal });
29
+ }
30
+ function uiConsumesKey(event) {
31
+ return consumed.has(event) || event.defaultPrevented;
32
+ }
33
+ /** Unmanaged native buttons retain the previous isolated keyboard behavior. */
34
+ function blocksGameKeyboard(target) {
35
+ if (!(target instanceof HTMLElement)) return false;
36
+ if (target.isContentEditable || target.closest("input,textarea,select")) return true;
37
+ const button = target.closest("button");
38
+ return !!button && !gameButtons.has(button);
39
+ }
40
+ /** Grant surface focus from outside/editing; do not evict a compatible UI button. */
41
+ function focusGameSurface(host) {
42
+ const active = document.activeElement;
43
+ if (active === host || active instanceof HTMLElement && host.contains(active) && !blocksGameKeyboard(active)) return;
44
+ host.focus({ preventScroll: true });
45
+ }
46
+ // packages/web/src/input.ts
47
+ /** Scoped keyboard adapter: editors own text; engine buttons consume only their UI keys. */
48
+ var BrowserInput = class {
49
+ controller = new AbortController();
50
+ constructor(host, input) {
51
+ const source = input.createKeyboardSource();
52
+ let compositionOwner = null;
53
+ const down = (event) => {
54
+ if (compositionOwner && (!(compositionOwner instanceof Node) || !compositionOwner.isConnected || !host.contains(compositionOwner) || document.activeElement !== compositionOwner)) compositionOwner = null;
55
+ if (compositionOwner || event.isComposing || event.keyCode === 229 || blocksGameKeyboard(event.target)) {
56
+ source.clear();
57
+ return;
58
+ }
59
+ if (uiConsumesKey(event) || event.code === "Tab") {
60
+ if (isKeyCode(event.code)) source.set(event.code, false);
61
+ return;
62
+ }
63
+ if (!isKeyCode(event.code) || !input.acceptsKey(event.code)) return;
64
+ event.preventDefault();
65
+ if (!event.repeat) source.set(event.code, true);
66
+ };
67
+ const up = (event) => {
68
+ if (isKeyCode(event.code)) source.set(event.code, false);
69
+ };
70
+ const options = { signal: this.controller.signal };
71
+ host.addEventListener("keydown", down, options);
72
+ window.addEventListener("keyup", up, {
73
+ ...options,
74
+ capture: true
75
+ });
76
+ host.addEventListener("compositionstart", (event) => {
77
+ compositionOwner = event.target;
78
+ source.clear();
79
+ }, options);
80
+ host.addEventListener("compositionend", (event) => {
81
+ if (compositionOwner === event.target) compositionOwner = null;
82
+ }, options);
83
+ window.addEventListener("blur", () => {
84
+ compositionOwner = null;
85
+ source.clear();
86
+ }, options);
87
+ document.addEventListener("visibilitychange", () => {
88
+ if (document.hidden) {
89
+ compositionOwner = null;
90
+ source.clear();
91
+ }
92
+ }, options);
93
+ host.addEventListener("focusin", (event) => {
94
+ if (event.target !== compositionOwner) compositionOwner = null;
95
+ if (blocksGameKeyboard(event.target)) source.clear();
96
+ }, options);
97
+ host.addEventListener("focusout", (event) => {
98
+ if (!(event.relatedTarget instanceof Node) || !host.contains(event.relatedTarget) || blocksGameKeyboard(event.relatedTarget)) {
99
+ compositionOwner = null;
100
+ source.clear();
101
+ }
102
+ }, options);
103
+ host.addEventListener("pointerdown", (event) => {
104
+ if (!event.defaultPrevented && (event.target === host || event.target instanceof HTMLCanvasElement && event.target.parentElement === host)) focusGameSurface(host);
105
+ }, options);
106
+ this.clear = () => source.dispose();
107
+ }
108
+ clear;
109
+ dispose() {
110
+ this.controller.abort();
111
+ this.clear();
112
+ }
113
+ };
114
+ // packages/web/src/pointer.ts
115
+ /** One DOM capture-phase adapter per Game; Pixi federated events never route these widgets. */
116
+ var BrowserPointer = class {
117
+ host;
118
+ project;
119
+ report;
120
+ flush;
121
+ routing;
122
+ abort = new AbortController();
123
+ constructor(host, project, report, flush) {
124
+ this.host = host;
125
+ this.project = project;
126
+ this.report = report;
127
+ this.flush = flush;
128
+ this.routing = new PointerRouting((id) => {
129
+ if (host.hasPointerCapture(id)) host.releasePointerCapture(id);
130
+ });
131
+ const options = {
132
+ signal: this.abort.signal,
133
+ capture: true
134
+ };
135
+ host.addEventListener("pointerdown", (e) => this.guard(() => {
136
+ if (!this.surface(e.target)) return;
137
+ this.flush();
138
+ const data = this.data(e);
139
+ try {
140
+ host.setPointerCapture(e.pointerId);
141
+ } catch {}
142
+ const target = this.routing.down(data);
143
+ if (this.routing.has(e.pointerId)) {
144
+ e.preventDefault();
145
+ if (target) focusGameSurface(host);
146
+ else host.focus({ preventScroll: true });
147
+ } else {
148
+ if (this.routing.owns(e.pointerId)) e.preventDefault();
149
+ if (host.hasPointerCapture(e.pointerId)) host.releasePointerCapture(e.pointerId);
150
+ }
151
+ }), options);
152
+ host.addEventListener("wheel", (e) => this.guard(() => {
153
+ if (!this.surface(e.target)) return;
154
+ this.flush();
155
+ const { screen } = this.project(e);
156
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? host.clientHeight : 1;
157
+ if (this.routing.scroll(screen.x, screen.y, e.deltaX * unit, e.deltaY * unit)) e.preventDefault();
158
+ }), {
159
+ ...options,
160
+ passive: false
161
+ });
162
+ window.addEventListener("pointermove", (e) => this.guard(() => {
163
+ if (!this.routing.owns(e.pointerId) && !this.surface(e.target)) {
164
+ if (this.routing.hasHover(e.pointerId)) this.routing.leave(this.data(e));
165
+ return;
166
+ }
167
+ this.flush();
168
+ this.routing.move(this.data(e));
169
+ }), options);
170
+ window.addEventListener("pointerup", (e) => this.guard(() => {
171
+ if (this.routing.owns(e.pointerId)) {
172
+ e.preventDefault();
173
+ this.flush();
174
+ this.routing.up(this.data(e));
175
+ }
176
+ }), options);
177
+ window.addEventListener("pointercancel", (e) => this.guard(() => this.routing.cancel(e.pointerId, false)), options);
178
+ host.addEventListener("lostpointercapture", (e) => this.guard(() => this.routing.cancel(e.pointerId)), options);
179
+ host.addEventListener("pointerleave", (e) => this.guard(() => this.routing.leave(this.data(e))), options);
180
+ window.addEventListener("blur", (event) => {
181
+ if (event.target === window) this.release();
182
+ }, options);
183
+ document.addEventListener("visibilitychange", () => {
184
+ if (document.hidden) this.release();
185
+ }, options);
186
+ }
187
+ surface(target) {
188
+ return target === this.host || target instanceof HTMLCanvasElement && target.parentElement === this.host;
189
+ }
190
+ data(e) {
191
+ const { screen, world, view } = this.project(e);
192
+ return Object.freeze({
193
+ pointerId: e.pointerId,
194
+ pointerType: e.pointerType,
195
+ button: e.button,
196
+ buttons: e.buttons,
197
+ time: e.timeStamp / 1e3,
198
+ screen: Object.freeze(screenPoint(screen.x, screen.y)),
199
+ world: Object.freeze(world),
200
+ inside: containsScreenPoint(view, screen)
201
+ });
202
+ }
203
+ guard(action) {
204
+ try {
205
+ action();
206
+ } catch (error) {
207
+ this.report(error);
208
+ }
209
+ }
210
+ release() {
211
+ this.guard(() => this.routing.release());
212
+ }
213
+ dispose() {
214
+ this.abort.abort();
215
+ this.guard(() => this.routing.dispose());
216
+ }
217
+ };
218
+ // packages/web/src/canvas-native.ts
219
+ /** Engine-owned native surfaces share Core focus/generation/lifetime and never route text through keys. */
220
+ var NativeControl = class {
221
+ node;
222
+ ui;
223
+ host;
224
+ revision;
225
+ abort = new AbortController();
226
+ unparent;
227
+ constructor(node, ui, parentSignal, host) {
228
+ this.node = node;
229
+ this.ui = ui;
230
+ this.host = host;
231
+ this.revision = node.control?.revision ?? 0;
232
+ const stop = () => {
233
+ try {
234
+ this.dispose();
235
+ } catch (error) {
236
+ ui.report(error);
237
+ }
238
+ };
239
+ parentSignal.addEventListener("abort", stop, { once: true });
240
+ this.unparent = () => parentSignal.removeEventListener("abort", stop);
241
+ }
242
+ get disposed() {
243
+ return this.abort.signal.aborted;
244
+ }
245
+ get live() {
246
+ return !this.abort.signal.aborted && !this.ui.disposed && this.node.available && this.node.control?.revision === this.revision;
247
+ }
248
+ run(task) {
249
+ if (!this.live) return;
250
+ try {
251
+ task();
252
+ } catch (error) {
253
+ this.ui.report(error);
254
+ }
255
+ }
256
+ mount() {
257
+ const el = this.element, signal = this.abort.signal;
258
+ el.className = "s-canvas-native";
259
+ el.addEventListener("pointerdown", () => this.run(() => this.ui.stopMotion()), { signal });
260
+ el.addEventListener("focus", () => this.run(() => this.ui.focus(this.node)), { signal });
261
+ el.addEventListener("blur", () => {
262
+ if (this.ui.disposed) return;
263
+ try {
264
+ if (this.ui.focusedNode === this.node) this.ui.focus();
265
+ } catch (error) {
266
+ this.ui.report(error);
267
+ }
268
+ }, { signal });
269
+ el.addEventListener("wheel", (e) => this.run(() => this.host.wheel(e)), {
270
+ signal,
271
+ passive: false
272
+ });
273
+ }
274
+ tab(event) {
275
+ if (event.code !== "Tab") return false;
276
+ if (this.host.traverse(event.shiftKey)) event.preventDefault();
277
+ return true;
278
+ }
279
+ dispose() {
280
+ if (this.abort.signal.aborted) return;
281
+ cleanupAll([
282
+ () => this.abort.abort(),
283
+ this.unparent,
284
+ () => this.node.control?.native?.end("interrupt"),
285
+ () => this.element.remove()
286
+ ], "Native editor cleanup failed");
287
+ }
288
+ };
289
+ /** Native browser/OS popup supplies viewport placement, list scrolling and platform key conventions. */
290
+ var CanvasNativeSelect = class extends NativeControl {
291
+ element = document.createElement("select");
292
+ session;
293
+ signature = "";
294
+ constructor(node, ui, signal, host) {
295
+ super(node, ui, signal, host);
296
+ this.mount();
297
+ const el = this.element, options = { signal: this.abort.signal };
298
+ const begin = (source) => {
299
+ const port = node.control?.native;
300
+ if (port?.kind === "select") this.session = port.begin(source);
301
+ };
302
+ el.addEventListener("pointerdown", (e) => this.run(() => {
303
+ if (node.control?.model.readOnly) {
304
+ e.preventDefault();
305
+ return;
306
+ }
307
+ begin("pointer");
308
+ }), options);
309
+ el.addEventListener("keydown", (e) => this.run(() => {
310
+ if (e.isComposing || e.keyCode === 229) return;
311
+ if (this.tab(e)) return;
312
+ if (e.code === "Escape") {
313
+ this.session?.cancel();
314
+ this.session = void 0;
315
+ this.update();
316
+ return;
317
+ }
318
+ if (node.control?.model.readOnly) {
319
+ e.preventDefault();
320
+ return;
321
+ }
322
+ begin("keyboard");
323
+ }), options);
324
+ el.addEventListener("input", () => this.run(() => {
325
+ if (!this.session?.active) begin("keyboard");
326
+ this.session?.update(el.selectedIndex);
327
+ }), options);
328
+ el.addEventListener("change", () => this.run(() => {
329
+ if (!this.session?.active) begin("keyboard");
330
+ this.session?.update(el.selectedIndex);
331
+ this.session?.commit();
332
+ this.session = void 0;
333
+ this.update();
334
+ }), options);
335
+ }
336
+ update(clip) {
337
+ const model = this.node.control?.model;
338
+ if (model?.kind !== "select") return;
339
+ const el = this.element;
340
+ const key = JSON.stringify(model.options.map((o) => [o.label, o.disabled]));
341
+ if (key !== this.signature) {
342
+ this.signature = key;
343
+ el.replaceChildren(...model.options.map((o, index) => {
344
+ const option = document.createElement("option");
345
+ option.textContent = o.label;
346
+ option.value = String(index);
347
+ option.disabled = o.disabled;
348
+ return option;
349
+ }));
350
+ }
351
+ if (!this.session?.active) el.selectedIndex = model.selectedIndex;
352
+ el.setAttribute("aria-label", this.node.text);
353
+ el.setAttribute("aria-readonly", String(model.readOnly));
354
+ if (clip) nativeGeometry(el, this.node, this.ui, clip);
355
+ }
356
+ };
357
+ /** Native caret, selection, clipboard and IME paint replace Canvas text only while focused. */
358
+ var CanvasNativeText = class extends NativeControl {
359
+ element = document.createElement("input");
360
+ session;
361
+ composing = false;
362
+ source = "keyboard";
363
+ constructor(node, ui, signal, host) {
364
+ super(node, ui, signal, host);
365
+ const el = this.element;
366
+ el.type = "text";
367
+ el.autocomplete = "off";
368
+ el.spellcheck = false;
369
+ this.mount();
370
+ const options = { signal: this.abort.signal };
371
+ el.addEventListener("pointerdown", () => this.run(() => {
372
+ this.source = "pointer";
373
+ this.begin();
374
+ }), options);
375
+ el.addEventListener("focus", () => this.run(() => this.begin()), options);
376
+ el.addEventListener("compositionstart", () => this.run(() => {
377
+ this.composing = true;
378
+ this.send(true);
379
+ }), options);
380
+ el.addEventListener("compositionupdate", () => this.run(() => this.send()), options);
381
+ el.addEventListener("compositionend", () => this.run(() => {
382
+ this.composing = false;
383
+ this.send();
384
+ }), options);
385
+ el.addEventListener("input", () => this.run(() => this.send(true)), options);
386
+ for (const event of [
387
+ "select",
388
+ "keyup",
389
+ "pointerup"
390
+ ]) el.addEventListener(event, () => this.run(() => this.send()), options);
391
+ el.addEventListener("keydown", (e) => this.run(() => {
392
+ if (this.composing || e.isComposing || e.keyCode === 229) return;
393
+ if (this.tab(e)) return;
394
+ if (e.code === "Escape" || e.code === "Enter") {
395
+ e.preventDefault();
396
+ e.stopPropagation();
397
+ if (e.code === "Escape") this.session?.cancel();
398
+ else {
399
+ this.send();
400
+ this.session?.commit();
401
+ }
402
+ this.session = void 0;
403
+ this.update();
404
+ } else if (node.control?.keys.includes(e.code)) {
405
+ e.preventDefault();
406
+ node.control.key(e.code);
407
+ this.session = void 0;
408
+ this.update();
409
+ }
410
+ }), options);
411
+ }
412
+ begin() {
413
+ if (this.session?.active) return;
414
+ const port = this.node.control?.native;
415
+ if (port?.kind === "text") this.session = port.begin(this.source);
416
+ }
417
+ send(begin = false) {
418
+ if (document.activeElement !== this.element) return;
419
+ if (begin) this.begin();
420
+ if (!this.session?.active) return;
421
+ const el = this.element;
422
+ this.session?.update({
423
+ text: el.value,
424
+ selectionStart: el.selectionStart ?? 0,
425
+ selectionEnd: el.selectionEnd ?? 0,
426
+ direction: el.selectionDirection ?? "none",
427
+ composing: this.composing
428
+ });
429
+ }
430
+ update(clip) {
431
+ const model = this.node.control?.model;
432
+ if (model?.kind !== "text") return;
433
+ const el = this.element;
434
+ if (!this.session?.active && el.value !== model.text) el.value = model.text;
435
+ el.placeholder = model.placeholder;
436
+ el.readOnly = model.readOnly;
437
+ el.inputMode = model.inputMode;
438
+ if (model.maxLength === void 0 || model.maxLength > 2147483647) el.removeAttribute("maxlength");
439
+ else el.maxLength = model.maxLength;
440
+ if (model.numeric) {
441
+ el.setAttribute("role", "spinbutton");
442
+ el.setAttribute("aria-valuemin", String(model.numeric.min));
443
+ el.setAttribute("aria-valuemax", String(model.numeric.max));
444
+ el.setAttribute("aria-valuenow", String(model.numeric.value));
445
+ }
446
+ el.setAttribute("aria-label", this.node.text);
447
+ el.setAttribute("aria-readonly", String(model.readOnly));
448
+ if (clip) nativeGeometry(el, this.node, this.ui, clip);
449
+ }
450
+ };
451
+ /** Keep full native geometry; clip without shifting text, selection or the IME anchor. */
452
+ function nativeGeometry(el, node, ui, clip) {
453
+ const b = node.bounds, c = intersectUI(b, clip), scale = ui.scale, style = node.style;
454
+ el.disabled = !node.available || c.width <= 0 || c.height <= 0 || node.style.pointerEvents !== "auto";
455
+ const background = style.background ?? 2316622;
456
+ const color = `#${style.color.toString(16).padStart(6, "0")}`;
457
+ 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-family:${style.fontFamily};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);`;
458
+ if (el.disabled && document.activeElement === el) el.blur();
459
+ }
460
+ /** Native editors must not cover later Canvas hit surfaces or overlay scrollbars. */
461
+ function clipNativeRegions(element, node, ui, clip, covers) {
462
+ let regions = [intersectUI(node.bounds, clip)];
463
+ for (const cover of covers) {
464
+ const next = [];
465
+ for (const region of regions) {
466
+ const cut = intersectUI(region, cover);
467
+ if (cut.width <= 0 || cut.height <= 0) {
468
+ next.push(region);
469
+ continue;
470
+ }
471
+ const right = region.x + region.width, bottom = region.y + region.height;
472
+ if (cut.y > region.y) next.push({
473
+ x: region.x,
474
+ y: region.y,
475
+ width: region.width,
476
+ height: cut.y - region.y
477
+ });
478
+ if (cut.y + cut.height < bottom) next.push({
479
+ x: region.x,
480
+ y: cut.y + cut.height,
481
+ width: region.width,
482
+ height: bottom - cut.y - cut.height
483
+ });
484
+ if (cut.x > region.x) next.push({
485
+ x: region.x,
486
+ y: cut.y,
487
+ width: cut.x - region.x,
488
+ height: cut.height
489
+ });
490
+ if (cut.x + cut.width < right) next.push({
491
+ x: cut.x + cut.width,
492
+ y: cut.y,
493
+ width: right - cut.x - cut.width,
494
+ height: cut.height
495
+ });
496
+ }
497
+ regions = next;
498
+ if (!regions.length) break;
499
+ }
500
+ const scale = ui.scale, b = node.bounds;
501
+ const path = regions.filter((r) => r.width > 0 && r.height > 0).map((r) => {
502
+ const x = (r.x - b.x) * scale, y = (r.y - b.y) * scale, w = r.width * scale;
503
+ return `M${x} ${y}h${w}v${r.height * scale}h${-w}Z`;
504
+ }).join(" ");
505
+ element.style.clipPath = path ? `path("${path}")` : "inset(100%)";
506
+ if (!path) {
507
+ element.disabled = true;
508
+ if (document.activeElement === element) element.blur();
509
+ }
510
+ }
511
+ // packages/web/src/canvas-visual.ts
512
+ /** Pixi rendering and a small native focus mirror share the same resolved boxes. */
513
+ var CanvasVisual = class {
514
+ ui;
515
+ host;
516
+ routing;
517
+ resources;
518
+ display = new Container();
519
+ element = document.createElement("div");
520
+ entries = /* @__PURE__ */ new Map();
521
+ metrics = /* @__PURE__ */ new Map();
522
+ dirty = true;
523
+ dead = false;
524
+ active = false;
525
+ mirroredFocus;
526
+ subscriptions = [];
527
+ syncs = 0;
528
+ constructor(ui, host, routing, resources) {
529
+ this.ui = ui;
530
+ this.host = host;
531
+ this.routing = routing;
532
+ this.resources = resources;
533
+ this.display.eventMode = "none";
534
+ this.display.zIndex = 1;
535
+ this.element.className = "s-canvas-accessibility";
536
+ this.element.setAttribute("aria-label", "Game controls");
537
+ const nativeEvents = new AbortController();
538
+ const interrupt = () => {
539
+ try {
540
+ cleanupAll([...[...this.entries.values()].filter((e) => e.native).map((entry) => () => {
541
+ try {
542
+ entry.native?.dispose();
543
+ } finally {
544
+ entry.button?.blur();
545
+ }
546
+ }), () => {
547
+ if (!this.ui.disposed) this.ui.focus();
548
+ }], "Native controls interruption failed");
549
+ } catch (error) {
550
+ this.ui.report(error);
551
+ } finally {
552
+ this.dirty = true;
553
+ }
554
+ };
555
+ window.addEventListener("blur", (event) => {
556
+ if (event.target === window) interrupt();
557
+ }, { signal: nativeEvents.signal });
558
+ document.addEventListener("visibilitychange", () => {
559
+ if (document.hidden) interrupt();
560
+ }, { signal: nativeEvents.signal });
561
+ this.host.addEventListener("keydown", (event) => {
562
+ if (!this.active || event.target !== this.host || event.code !== "Tab" || event.shiftKey || event.isComposing || event.keyCode === 229) return;
563
+ this.update(this.host.clientWidth, this.host.clientHeight);
564
+ const next = this.ui.focusNext();
565
+ if (next) {
566
+ event.preventDefault();
567
+ this.update(this.host.clientWidth, this.host.clientHeight);
568
+ this.focus(next);
569
+ }
570
+ }, { signal: nativeEvents.signal });
571
+ this.subscriptions.push(() => nativeEvents.abort());
572
+ this.subscriptions.push(ui.subscribe(() => {
573
+ this.dirty = true;
574
+ }), ui.onDispose(() => this.dispose()));
575
+ }
576
+ get diagnostics() {
577
+ return {
578
+ nodes: this.entries.size,
579
+ buttons: [...this.entries.values()].filter((e) => e.button).length,
580
+ syncs: this.syncs,
581
+ textMetrics: this.metrics.size
582
+ };
583
+ }
584
+ measure = (node, contentWidth = Infinity) => {
585
+ const { fontSize, fontFamily, textWrap } = node.style;
586
+ const content = canvasControlText(node);
587
+ const adornment = node.control?.model.kind === "checkbox" ? 30 : 0;
588
+ const key = JSON.stringify([
589
+ content,
590
+ fontSize,
591
+ fontFamily,
592
+ textWrap,
593
+ adornment
594
+ ]);
595
+ let cache = this.metrics.get(node);
596
+ if (cache?.key !== key) {
597
+ cache = {
598
+ key,
599
+ values: /* @__PURE__ */ new Map()
600
+ };
601
+ this.metrics.set(node, cache);
602
+ }
603
+ const width = textWrap ? Math.max(0, contentWidth - adornment) : Infinity;
604
+ const previous = cache.values.get(width);
605
+ if (previous) return previous;
606
+ const style = new TextStyle({
607
+ fontSize,
608
+ fontFamily,
609
+ wordWrap: false
610
+ });
611
+ const text = Number.isFinite(width) ? wrapCanvasText(content, width, (value) => CanvasTextMetrics.measureText(value || " ", style).width) : content.replace(/\r\n?/g, "\n");
612
+ const measured = CanvasTextMetrics.measureText(text || " ", style);
613
+ const result = {
614
+ text,
615
+ width: (text ? measured.width : 0) + adornment,
616
+ height: measured.height
617
+ };
618
+ if (cache.values.size >= 8) cache.values.delete(cache.values.keys().next().value);
619
+ cache.values.set(width, result);
620
+ return result;
621
+ };
622
+ beforeResolutionChange() {
623
+ for (const entry of this.entries.values()) if (entry.text && !entry.text.destroyed) this.resources.changedText(entry.text.styleKey);
624
+ }
625
+ focus(node) {
626
+ if (node.available) this.entries.get(node)?.button?.focus({ preventScroll: true });
627
+ }
628
+ native(node, signal) {
629
+ const host = {
630
+ traverse: (reverse) => {
631
+ const next = this.ui.focusNext(reverse);
632
+ if (!next) return false;
633
+ this.update(this.host.clientWidth, this.host.clientHeight);
634
+ this.focus(next);
635
+ return true;
636
+ },
637
+ wheel: (event) => {
638
+ const rect = this.host.getBoundingClientRect();
639
+ const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? this.host.clientHeight : 1;
640
+ 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();
641
+ }
642
+ };
643
+ return node.control?.native?.kind === "select" ? new CanvasNativeSelect(node, this.ui, signal, host) : new CanvasNativeText(node, this.ui, signal, host);
644
+ }
645
+ button(node) {
646
+ const button = document.createElement("button");
647
+ button.type = "button";
648
+ const signal = this.entries.get(node)?.abort.signal;
649
+ if (!signal) throw new EngineError("INVALID_UI", "Missing Canvas button entry");
650
+ const options = { signal };
651
+ registerGameButton(button, signal);
652
+ const run = (task) => {
653
+ try {
654
+ task();
655
+ } catch (error) {
656
+ this.ui.report(error);
657
+ }
658
+ };
659
+ button.addEventListener("focus", () => run(() => {
660
+ this.ui.focus(node);
661
+ }), options);
662
+ button.addEventListener("blur", () => run(() => {
663
+ if (this.ui.focusedNode === node) this.ui.focus();
664
+ this.routing.releaseKeys(node);
665
+ }), options);
666
+ button.addEventListener("keydown", (event) => {
667
+ if (event.isComposing || event.keyCode === 229) return;
668
+ if (node.control?.keys.includes(event.code)) {
669
+ event.preventDefault();
670
+ run(() => {
671
+ this.routing.controlKey(node, event.code);
672
+ });
673
+ return;
674
+ }
675
+ if (this.ui.scrollKey(node, event.code)) {
676
+ event.preventDefault();
677
+ return;
678
+ }
679
+ if (event.code === "Tab") {
680
+ run(() => {
681
+ const next = this.ui.focusNext(event.shiftKey);
682
+ if (next) {
683
+ event.preventDefault();
684
+ this.update(this.host.clientWidth, this.host.clientHeight);
685
+ this.focus(next);
686
+ }
687
+ });
688
+ return;
689
+ }
690
+ if (event.code !== "Enter" && event.code !== "Space") return;
691
+ event.preventDefault();
692
+ if (!event.repeat) run(() => {
693
+ this.routing.key(node, event.code, true);
694
+ });
695
+ }, options);
696
+ button.addEventListener("keyup", (event) => {
697
+ if (event.isComposing || event.keyCode === 229) return;
698
+ if (event.code !== "Enter" && event.code !== "Space") return;
699
+ event.preventDefault();
700
+ run(() => {
701
+ this.routing.key(node, event.code, false);
702
+ });
703
+ }, options);
704
+ button.addEventListener("click", () => run(() => {
705
+ if (node.kind !== "hold-button" && node.available) node.activate("accessibility");
706
+ }), options);
707
+ return button;
708
+ }
709
+ updateButton(entry, node, clip) {
710
+ if (entry.native) {
711
+ entry.native.update(clip);
712
+ return;
713
+ }
714
+ if (!entry.button) return;
715
+ const button = entry.button, b = node.bounds, s = this.ui.scale, c = intersectUI(b, clip);
716
+ if (button.textContent !== node.text) button.textContent = node.text;
717
+ if (node.busy && document.activeElement === button) this.host.focus({ preventScroll: true });
718
+ button.disabled = !node.available || c.width <= 0 || c.height <= 0 || node.style.pointerEvents !== "auto";
719
+ if (node.kind === "hold-button") button.setAttribute("aria-pressed", String(node.held));
720
+ const model = node.control?.model;
721
+ if (model) {
722
+ button.setAttribute("role", model.kind);
723
+ button.setAttribute("aria-label", node.text);
724
+ button.setAttribute("aria-readonly", String(model.readOnly));
725
+ if (model.kind === "checkbox") button.setAttribute("aria-checked", String(model.checked));
726
+ else if (model.kind === "slider") {
727
+ button.setAttribute("aria-orientation", "horizontal");
728
+ button.setAttribute("aria-valuemin", String(model.min));
729
+ button.setAttribute("aria-valuemax", String(model.max));
730
+ button.setAttribute("aria-valuenow", String(model.value));
731
+ }
732
+ }
733
+ button.setAttribute("aria-busy", String(node.busy));
734
+ button.style.cssText = `left:${c.x * s}px;top:${c.y * s}px;width:${c.width * s}px;height:${c.height * s}px;`;
735
+ if (button.disabled && document.activeElement === button) button.blur();
736
+ }
737
+ update(width, height) {
738
+ if (this.dead || this.ui.disposed) return;
739
+ const layout = this.ui.layout(width, height, this.measure);
740
+ if (!this.active) {
741
+ this.host.append(this.element);
742
+ this.active = true;
743
+ }
744
+ if (!this.dirty && !layout) return;
745
+ this.dirty = false;
746
+ this.syncs++;
747
+ this.display.scale.set(this.ui.scale);
748
+ const visited = /* @__PURE__ */ new Set(), buttons = [];
749
+ const paint = [];
750
+ let order = 0;
751
+ let restoreNativeFocus;
752
+ this.ui.walk((node, parentClip) => {
753
+ const measuredText = node.kind === "panel" ? void 0 : this.measure(node, Math.max(0, node.bounds.width - node.style.padding * 2));
754
+ const clip = measuredText && (measuredText.width > node.bounds.width - node.style.padding * 2 || measuredText.height > node.bounds.height - node.style.padding * 2) ? intersectUI(parentClip, node.bounds) : parentClip;
755
+ visited.add(node);
756
+ let entry = this.entries.get(node);
757
+ if (!entry) {
758
+ const display = new Container(), background = new Graphics(), mask = new Graphics();
759
+ display.addChild(background);
760
+ this.display.addChild(display, mask);
761
+ mask.renderable = false;
762
+ entry = {
763
+ display,
764
+ background,
765
+ mask,
766
+ abort: new AbortController()
767
+ };
768
+ this.entries.set(node, entry);
769
+ if (node.interactive) {
770
+ if (node.control?.native) {
771
+ entry.native = this.native(node, entry.abort.signal);
772
+ entry.button = entry.native.element;
773
+ } else entry.button = this.button(node);
774
+ this.element.append(entry.button);
775
+ }
776
+ if (node.kind !== "panel") {
777
+ entry.text = new Text({ text: node.text });
778
+ display.addChild(entry.text);
779
+ }
780
+ }
781
+ if (entry.native && (entry.native.disposed || entry.native.revision !== node.control?.revision)) {
782
+ if (document.activeElement === entry.button) restoreNativeFocus = node;
783
+ entry.native.dispose();
784
+ entry.native = this.native(node, entry.abort.signal);
785
+ entry.button = entry.native.element;
786
+ this.element.append(entry.button);
787
+ delete entry.signature;
788
+ }
789
+ this.display.setChildIndex(entry.display, order++);
790
+ this.display.setChildIndex(entry.mask, order++);
791
+ if (node.style.pointerEvents === "auto") paint.push({
792
+ node,
793
+ box: intersectUI(node.bounds, clip),
794
+ ...entry.native ? {
795
+ native: entry.native,
796
+ clip
797
+ } : {}
798
+ });
799
+ if (entry.button) buttons.push(entry.button);
800
+ const b = node.bounds, s = node.style;
801
+ let enabled = !node.busy;
802
+ for (let p = node; p; p = p.parent) enabled &&= p.enabled;
803
+ const signature = JSON.stringify([
804
+ node.paintRevision,
805
+ b.x,
806
+ b.y,
807
+ b.width,
808
+ b.height,
809
+ clip.x,
810
+ clip.y,
811
+ clip.width,
812
+ clip.height,
813
+ node.available,
814
+ enabled,
815
+ this.ui.scale,
816
+ canvasOpacity(node)
817
+ ]);
818
+ if (entry.signature === signature) return;
819
+ entry.signature = signature;
820
+ entry.display.position.set(b.x, b.y);
821
+ const clipped = clip.x !== 0 || clip.y !== 0 || clip.width !== width / this.ui.scale || clip.height !== height / this.ui.scale;
822
+ entry.display.mask = clipped ? entry.mask : null;
823
+ entry.mask.renderable = clipped;
824
+ const maskSignature = JSON.stringify([
825
+ clipped,
826
+ clip.x,
827
+ clip.y,
828
+ clip.width,
829
+ clip.height
830
+ ]);
831
+ if (clipped && entry.maskSignature !== maskSignature) entry.mask.clear().rect(clip.x, clip.y, clip.width, clip.height).fill(16777215);
832
+ entry.maskSignature = maskSignature;
833
+ const paintSignature = JSON.stringify([
834
+ node.paintRevision,
835
+ b.width,
836
+ b.height,
837
+ enabled
838
+ ]);
839
+ if (entry.paintSignature !== paintSignature) {
840
+ entry.paintSignature = paintSignature;
841
+ entry.background.visible = s.background !== void 0 || node.interactive;
842
+ entry.background.clear();
843
+ if (s.background !== void 0 || node.interactive) {
844
+ entry.background.roundRect(0, 0, b.width, b.height, Math.min(s.radius, b.width / 2, b.height / 2)).fill(s.background ?? 2316622);
845
+ if (node.held) entry.background.roundRect(0, 0, b.width, b.height, s.radius).fill({
846
+ color: 16777215,
847
+ alpha: .18
848
+ });
849
+ else if (canvasHoverHighlight(node)) entry.background.roundRect(0, 0, b.width, b.height, s.radius).fill({
850
+ color: 16777215,
851
+ alpha: .08
852
+ });
853
+ if (node.focused) entry.background.roundRect(2, 2, Math.max(0, b.width - 4), Math.max(0, b.height - 4), s.radius).stroke({
854
+ color: 16765803,
855
+ width: 3
856
+ });
857
+ }
858
+ const model = node.control?.model;
859
+ if (model?.kind === "checkbox") {
860
+ const size = Math.min(20, Math.max(0, b.height - 8), Math.max(0, b.width - 8)), x = Math.min(s.padding, b.width - size), y = (b.height - size) / 2;
861
+ entry.background.rect(x, y, size, size).fill({
862
+ color: s.color,
863
+ alpha: .15
864
+ }).stroke({
865
+ color: s.color,
866
+ width: 2
867
+ });
868
+ if (model.checked) entry.background.moveTo(x + size * .2, y + size * .5).lineTo(x + size * .43, y + size * .75).lineTo(x + size * .83, y + size * .22).stroke({
869
+ color: s.color,
870
+ width: 3
871
+ });
872
+ } else if (model?.kind === "slider") {
873
+ const geometry = canvasSliderGeometry(node);
874
+ if (geometry) {
875
+ const t = geometry.track, thumb = geometry.thumb;
876
+ entry.background.rect(t.x - b.x, t.y - b.y, t.width, t.height).fill({
877
+ color: s.color,
878
+ alpha: .25
879
+ });
880
+ entry.background.rect(t.x - b.x, t.y - b.y, t.width * model.ratio, t.height).fill(s.color);
881
+ entry.background.circle(thumb.x - b.x + thumb.width / 2, thumb.y - b.y + thumb.height / 2, thumb.width / 2).fill(s.color);
882
+ }
883
+ }
884
+ if (entry.text) {
885
+ entry.text.visible = !entry.native || node.control?.model.kind === "text" && !node.focused;
886
+ const m = measuredText ?? this.measure(node);
887
+ if (entry.text.text !== m.text) {
888
+ this.resources.changedText(entry.text.styleKey);
889
+ entry.text.text = m.text;
890
+ }
891
+ const textStyleKey = JSON.stringify([
892
+ s.fontFamily,
893
+ s.fontSize,
894
+ s.color
895
+ ]);
896
+ if (entry.textStyleKey !== textStyleKey) {
897
+ this.resources.changedText(entry.text.styleKey);
898
+ entry.textStyleKey = textStyleKey;
899
+ entry.text.style = {
900
+ fontFamily: s.fontFamily,
901
+ fontSize: s.fontSize,
902
+ fill: s.color
903
+ };
904
+ }
905
+ entry.text.position.set(model?.kind === "checkbox" ? s.padding + 30 : model?.kind === "slider" ? s.padding : node.interactive ? Math.max(s.padding, (b.width - m.width) / 2) : s.padding, model?.kind === "slider" ? s.padding : node.interactive ? Math.max(s.padding, (b.height - m.height) / 2) : s.padding);
906
+ }
907
+ }
908
+ const opacity = canvasOpacity(node) * (enabled ? node.control?.model.readOnly ? .7 : 1 : .45);
909
+ entry.display.alpha = opacity;
910
+ this.updateButton(entry, node, clip);
911
+ if (entry.native) entry.native.element.style.opacity = String(opacity);
912
+ }, (node, clip) => {
913
+ const entry = this.entries.get(node);
914
+ if (!entry) return;
915
+ const bars = canvasScrollbars(node);
916
+ for (const bar of bars) paint.push({ box: intersectUI(bar.track, clip) });
917
+ if (!entry.scrollbar && bars.length) {
918
+ entry.scrollbar = new Graphics();
919
+ entry.scrollbar.eventMode = "none";
920
+ this.display.addChild(entry.scrollbar);
921
+ }
922
+ const display = entry.scrollbar;
923
+ if (!display) return;
924
+ this.display.setChildIndex(display, order++);
925
+ display.visible = bars.length > 0;
926
+ display.mask = entry.display.mask;
927
+ const signature = JSON.stringify([
928
+ node.style.color,
929
+ clip.x,
930
+ clip.y,
931
+ clip.width,
932
+ clip.height,
933
+ ...bars.map((b) => [
934
+ b.axis,
935
+ b.track,
936
+ b.thumb,
937
+ b.enabled
938
+ ])
939
+ ]);
940
+ if (entry.scrollbarSignature === signature) return;
941
+ entry.scrollbarSignature = signature;
942
+ display.clear();
943
+ for (const bar of bars) {
944
+ const t = bar.track, b = bar.thumb;
945
+ if (t.width <= 0 || t.height <= 0) continue;
946
+ display.roundRect(t.x, t.y, t.width, t.height, Math.min(4, t.width / 2, t.height / 2)).fill({
947
+ color: node.style.color,
948
+ alpha: .13
949
+ });
950
+ display.roundRect(b.x, b.y, b.width, b.height, Math.min(4, b.width / 2, b.height / 2)).fill({
951
+ color: node.style.color,
952
+ alpha: bar.enabled ? .7 : .25
953
+ });
954
+ }
955
+ });
956
+ for (let i = 0; i < paint.length; i++) {
957
+ const item = paint[i];
958
+ if (item?.native && item.node && item.clip) clipNativeRegions(item.native.element, item.node, this.ui, item.clip, paint.slice(i + 1).map((p) => p.box));
959
+ }
960
+ for (const [node, entry] of this.entries) if (!visited.has(node)) {
961
+ this.entries.delete(node);
962
+ this.metrics.delete(node);
963
+ this.releaseEntry(entry);
964
+ }
965
+ let anchor = null;
966
+ for (let i = buttons.length - 1; i >= 0; i--) {
967
+ const button = buttons[i];
968
+ if (!button) continue;
969
+ if (button !== document.activeElement && button.nextElementSibling !== anchor) this.element.insertBefore(button, anchor);
970
+ anchor = button;
971
+ }
972
+ const focused = this.ui.focusedNode;
973
+ if (!focused && this.mirroredFocus) {
974
+ const previous = this.entries.get(this.mirroredFocus)?.button;
975
+ if (previous && document.activeElement === previous) previous.blur();
976
+ }
977
+ const button = focused ? this.entries.get(focused)?.button : void 0;
978
+ if ((focused !== this.mirroredFocus || focused === restoreNativeFocus) && button && !button.disabled && document.activeElement !== button) button.focus({ preventScroll: true });
979
+ this.mirroredFocus = this.ui.focusedNode;
980
+ }
981
+ releaseEntry(entry) {
982
+ const backgroundContext = entry.background.context?.uid, maskContext = entry.mask.context?.uid;
983
+ const scrollbarContext = entry.scrollbar?.context?.uid;
984
+ const textKey = entry.text && !entry.text.destroyed ? entry.text.styleKey : void 0;
985
+ cleanupAll([
986
+ () => entry.abort.abort(),
987
+ () => entry.native?.dispose(),
988
+ () => entry.button?.remove(),
989
+ () => entry.background.destroy({ context: true }),
990
+ () => entry.scrollbar?.destroy({ context: true }),
991
+ () => entry.text?.destroy({ style: true }),
992
+ () => entry.display.destroy({ children: true }),
993
+ () => entry.mask.destroy({ context: true }),
994
+ () => this.resources.graphics(entry.background, backgroundContext),
995
+ () => this.resources.graphics(entry.mask, maskContext),
996
+ () => {
997
+ if (entry.scrollbar) this.resources.graphics(entry.scrollbar, scrollbarContext);
998
+ },
999
+ () => {
1000
+ if (entry.text) this.resources.text(entry.text, textKey);
1001
+ }
1002
+ ], "Canvas visual entry cleanup failed");
1003
+ }
1004
+ dispose() {
1005
+ if (this.dead) return;
1006
+ this.dead = true;
1007
+ const entries = [...this.entries.values()];
1008
+ this.entries.clear();
1009
+ this.metrics.clear();
1010
+ cleanupAll([
1011
+ ...this.subscriptions.splice(0),
1012
+ ...entries.map((e) => () => this.releaseEntry(e)),
1013
+ () => this.display.destroy({ children: true }),
1014
+ () => this.element.remove()
1015
+ ], "Canvas visual cleanup failed");
1016
+ }
1017
+ };
1018
+ // packages/web/src/geometry-tilemap-visual.ts
1019
+ /** Visible-cell Graphics with no image/UV contract; no full Grid walk per frame. */
1020
+ var GeometryTilemapVisual = class {
1021
+ map;
1022
+ released;
1023
+ display = new Container();
1024
+ graphics = /* @__PURE__ */ new Map();
1025
+ stats = {
1026
+ visitedCells: 0,
1027
+ updatedTiles: 0,
1028
+ createdGraphics: 0,
1029
+ destroyedGraphics: 0
1030
+ };
1031
+ order = 0;
1032
+ depth = NaN;
1033
+ disposed = false;
1034
+ dirty = /* @__PURE__ */ new Set();
1035
+ visibleCells = /* @__PURE__ */ new Set();
1036
+ invalid = true;
1037
+ boundsKey = "";
1038
+ off;
1039
+ offDispose;
1040
+ constructor(map, released) {
1041
+ this.map = map;
1042
+ this.released = released;
1043
+ this.off = map.onTilesChanged((index) => {
1044
+ if (index === null) {
1045
+ this.invalid = true;
1046
+ this.bulk = true;
1047
+ this.dirty.clear();
1048
+ } else if (this.visibleCells.has(index)) this.dirty.add(index);
1049
+ });
1050
+ this.offDispose = map.onDispose(() => this.dispose());
1051
+ }
1052
+ remove(index) {
1053
+ const g = this.graphics.get(index);
1054
+ if (g) {
1055
+ this.graphics.delete(index);
1056
+ g.destroy({ context: true });
1057
+ this.stats.destroyedGraphics++;
1058
+ }
1059
+ }
1060
+ cell(x, y) {
1061
+ this.stats.visitedCells++;
1062
+ const index = y * this.map.width + x, color = this.map.get(x, y);
1063
+ if (color === null) {
1064
+ this.remove(index);
1065
+ return;
1066
+ }
1067
+ let g = this.graphics.get(index);
1068
+ if (!g) {
1069
+ g = new Graphics();
1070
+ this.graphics.set(index, g);
1071
+ this.display.addChild(g);
1072
+ this.stats.createdGraphics++;
1073
+ }
1074
+ g.clear().poly(this.map.geometry.vertices(x, y).flatMap((p) => [p.x, p.y]), true).fill(color);
1075
+ this.stats.updatedTiles++;
1076
+ }
1077
+ render(view) {
1078
+ if (this.disposed) return;
1079
+ const m = this.map;
1080
+ this.display.position.set(m.x, m.y);
1081
+ this.display.visible = m.visible && m.enabled;
1082
+ if (!this.display.visible) {
1083
+ this.invalid = true;
1084
+ this.bulk = true;
1085
+ this.dirty.clear();
1086
+ return;
1087
+ }
1088
+ const c = view.clip;
1089
+ const bounds = {
1090
+ x: (c.x - view.x) / view.scale,
1091
+ y: (c.y - view.y) / view.scale,
1092
+ width: c.width / view.scale,
1093
+ height: c.height / view.scale
1094
+ };
1095
+ const key = [
1096
+ bounds.x - m.x,
1097
+ bounds.y - m.y,
1098
+ bounds.width,
1099
+ bounds.height
1100
+ ].join(",");
1101
+ if (this.invalid || key !== this.boundsKey) {
1102
+ this.boundsKey = key;
1103
+ this.invalid = false;
1104
+ const retained = /* @__PURE__ */ new Set();
1105
+ for (const p of m.tilesInBounds(bounds)) {
1106
+ const index = p.y * m.width + p.x;
1107
+ retained.add(index);
1108
+ if (!this.graphics.has(index) || this.dirty.has(index) || this.bulk) this.cell(p.x, p.y);
1109
+ }
1110
+ for (const index of this.graphics.keys()) if (!retained.has(index)) this.remove(index);
1111
+ this.visibleCells = retained;
1112
+ this.bulk = false;
1113
+ } else for (const index of this.dirty) {
1114
+ const x = index % m.width, y = Math.floor(index / m.width), b = m.tileBounds(x, y);
1115
+ if (b.x <= bounds.x + bounds.width && b.x + b.width >= bounds.x && b.y <= bounds.y + bounds.height && b.y + b.height >= bounds.y) this.cell(x, y);
1116
+ }
1117
+ this.dirty.clear();
1118
+ }
1119
+ bulk = false;
1120
+ dispose() {
1121
+ if (this.disposed) return;
1122
+ this.disposed = true;
1123
+ this.stats.destroyedGraphics += this.graphics.size;
1124
+ this.graphics.clear();
1125
+ this.visibleCells.clear();
1126
+ this.dirty.clear();
1127
+ cleanupAll([
1128
+ this.off,
1129
+ this.offDispose,
1130
+ () => this.display.removeFromParent(),
1131
+ () => this.display.destroy({
1132
+ children: true,
1133
+ context: true
1134
+ }),
1135
+ this.released
1136
+ ], "Geometry tilemap cleanup failed");
1137
+ }
1138
+ };
1139
+ // packages/web/src/pixi-ui-cleanup.ts
1140
+ function drop(hash, id, resource) {
1141
+ if (!hash) return;
1142
+ const value = hash.items[id];
1143
+ if (value === null || resource?.destroyed && value === resource) delete hash.items[id];
1144
+ }
1145
+ /** Renderer-local compatibility for pinned Pixi8.22. Drop only destroyed UI owners/null bookkeeping.
1146
+ * CanvasGraphicsPipe has no GPU data for its Graphics; GCManagedHash.remove returns early there.
1147
+ * Text/style keys otherwise accumulate null entries after dynamic text updates. */
1148
+ function releaseUIGraphics(renderer, graphics, context) {
1149
+ if (!graphics.destroyed) return;
1150
+ const r = renderer;
1151
+ drop(r.renderPipes.graphics?._managedGraphics, graphics.uid, graphics);
1152
+ if (context !== void 0) drop(r.graphicsContext?._managedContexts, context);
1153
+ }
1154
+ function releaseUIText(renderer, text, key) {
1155
+ if (!text.destroyed) return;
1156
+ const r = renderer;
1157
+ drop(r.renderPipes.text?._managedTexts, text.uid, text);
1158
+ if (key !== void 0 && r.canvasText?._activeTextures?.[key] === null) delete r.canvasText._activeTextures[key];
1159
+ }
1160
+ /** Called after rendering so replaced text has released its old managed texture. Never drops a live key. */
1161
+ function pruneUITextKeys(renderer, keys) {
1162
+ const textures = renderer.canvasText?._activeTextures;
1163
+ for (const key of keys) {
1164
+ if (textures?.[key] != null) continue;
1165
+ if (textures?.[key] === null) delete textures[key];
1166
+ keys.delete(key);
1167
+ }
1168
+ }
1169
+ // packages/web/src/shared-images.ts
1170
+ /** Renderer-local, live-reference cache. No idle retention or process-global state. */
1171
+ var SharedImages = class {
1172
+ load;
1173
+ entries = /* @__PURE__ */ new Map();
1174
+ references = 0;
1175
+ dead = false;
1176
+ constructor(load) {
1177
+ this.load = load;
1178
+ }
1179
+ get diagnostics() {
1180
+ let pending = 0;
1181
+ for (const entry of this.entries.values()) if (!entry.resource) pending++;
1182
+ return {
1183
+ imageReferences: this.references,
1184
+ imageEntries: this.entries.size,
1185
+ pendingImages: pending
1186
+ };
1187
+ }
1188
+ acquire(key, signal) {
1189
+ const cancelled = () => new EngineError("GAME_DISPOSED", "Image use cancelled", { path: key });
1190
+ if (this.dead || signal.aborted) return Promise.reject(cancelled());
1191
+ let entry = this.entries.get(key);
1192
+ const fresh = !entry;
1193
+ if (!entry) {
1194
+ entry = {
1195
+ key,
1196
+ controller: new AbortController(),
1197
+ consumers: /* @__PURE__ */ new Set(),
1198
+ frames: /* @__PURE__ */ new Map()
1199
+ };
1200
+ this.entries.set(key, entry);
1201
+ }
1202
+ const current = entry;
1203
+ const promise = new Promise((resolve, reject) => {
1204
+ let released = false, settled = false;
1205
+ const release = () => {
1206
+ if (released) return;
1207
+ released = true;
1208
+ signal.removeEventListener("abort", release);
1209
+ current.consumers.delete(consumer);
1210
+ this.references--;
1211
+ if (!settled) {
1212
+ settled = true;
1213
+ reject(cancelled());
1214
+ }
1215
+ if (!current.consumers.size) {
1216
+ if (this.entries.get(key) === current) this.entries.delete(key);
1217
+ current.controller.abort();
1218
+ const resource = current.resource;
1219
+ delete current.resource;
1220
+ for (const texture of current.frames.values()) texture.destroy(false);
1221
+ current.frames.clear();
1222
+ resource?.release();
1223
+ }
1224
+ };
1225
+ const consumer = {
1226
+ ready: (resource) => {
1227
+ if (released || settled) return;
1228
+ settled = true;
1229
+ signal.removeEventListener("abort", release);
1230
+ resolve({
1231
+ texture: resource.texture,
1232
+ release,
1233
+ textureFor: (frame) => {
1234
+ if (released) throw new EngineError("GAME_DISPOSED", "Image lease released");
1235
+ if (!frame) return resource.texture;
1236
+ const rect = spriteFrameBounds(frame, resource.texture.width, resource.texture.height);
1237
+ const key = `${rect.x},${rect.y},${rect.width},${rect.height}`;
1238
+ let texture = current.frames.get(key);
1239
+ if (!texture) {
1240
+ texture = new Texture({
1241
+ source: resource.texture.source,
1242
+ frame: new Rectangle(rect.x, rect.y, rect.width, rect.height)
1243
+ });
1244
+ current.frames.set(key, texture);
1245
+ }
1246
+ return texture;
1247
+ }
1248
+ });
1249
+ },
1250
+ fail: (error) => {
1251
+ if (released || settled) return;
1252
+ settled = true;
1253
+ reject(error);
1254
+ release();
1255
+ },
1256
+ release
1257
+ };
1258
+ this.references++;
1259
+ current.consumers.add(consumer);
1260
+ signal.addEventListener("abort", release, { once: true });
1261
+ if (current.resource) consumer.ready(current.resource);
1262
+ });
1263
+ if (fresh) Promise.resolve().then(() => this.load(key, current.controller.signal)).then((resource) => {
1264
+ if (!current.consumers.size) {
1265
+ resource.release();
1266
+ return;
1267
+ }
1268
+ current.resource = resource;
1269
+ for (const consumer of current.consumers) consumer.ready(resource);
1270
+ }, (error) => {
1271
+ if (this.entries.get(key) === current) this.entries.delete(key);
1272
+ for (const consumer of [...current.consumers]) consumer.fail(error);
1273
+ });
1274
+ return promise;
1275
+ }
1276
+ dispose() {
1277
+ if (this.dead) return;
1278
+ this.dead = true;
1279
+ for (const entry of [...this.entries.values()]) for (const consumer of [...entry.consumers]) consumer.release();
1280
+ }
1281
+ };
1282
+ // packages/web/src/tilemap-visual.ts
1283
+ /** Renderer-owned sparse visible set; sheet pixels are owned by SharedImages. */
1284
+ var TilemapVisual = class {
1285
+ map;
1286
+ changed;
1287
+ onError;
1288
+ released;
1289
+ display = new Container();
1290
+ sprites = /* @__PURE__ */ new Map();
1291
+ order = 0;
1292
+ depth = NaN;
1293
+ controller = new AbortController();
1294
+ error;
1295
+ disposed = false;
1296
+ stats = {
1297
+ visitedCells: 0,
1298
+ updatedTiles: 0,
1299
+ createdSprites: 0,
1300
+ destroyedSprites: 0,
1301
+ validatedFrames: 0
1302
+ };
1303
+ lease;
1304
+ textures = /* @__PURE__ */ new Map();
1305
+ dirty = /* @__PURE__ */ new Set();
1306
+ revision = -1;
1307
+ range = {
1308
+ left: -1,
1309
+ top: -1,
1310
+ right: -1,
1311
+ bottom: -1
1312
+ };
1313
+ invalid = true;
1314
+ off;
1315
+ offDispose;
1316
+ constructor(map, scene, acquire, changed, onError, released) {
1317
+ this.map = map;
1318
+ this.changed = changed;
1319
+ this.onError = onError;
1320
+ this.released = released;
1321
+ this.off = map.onTilesChanged((index) => {
1322
+ if (index === null) {
1323
+ this.invalid = true;
1324
+ this.dirty.clear();
1325
+ } else {
1326
+ const x = index % map.width, y = Math.floor(index / map.width), r = this.range;
1327
+ if (x >= r.left && x < r.right && y >= r.top && y < r.bottom) this.dirty.add(index);
1328
+ }
1329
+ });
1330
+ this.offDispose = map.onDispose(() => this.dispose());
1331
+ const controller = this.controller;
1332
+ if (!controller) throw Error("Missing tilemap request controller");
1333
+ acquire(map.sheet.url, controller.signal).then((lease) => {
1334
+ if (this.disposed || map.disposed || map.scene !== scene) {
1335
+ lease.release();
1336
+ return;
1337
+ }
1338
+ this.lease = lease;
1339
+ this.sync();
1340
+ }).catch((error) => {
1341
+ if (!this.disposed) this.fail(error);
1342
+ }).finally(() => {
1343
+ this.controller = void 0;
1344
+ changed();
1345
+ });
1346
+ }
1347
+ fail(error) {
1348
+ this.error = error;
1349
+ try {
1350
+ this.onError(error);
1351
+ } catch {}
1352
+ }
1353
+ sync() {
1354
+ if (!this.lease || this.disposed || this.revision === this.map.revision) return;
1355
+ this.revision = this.map.revision;
1356
+ this.error = void 0;
1357
+ try {
1358
+ for (const id of this.map.tileIds()) if (!this.textures.has(id)) {
1359
+ this.stats.validatedFrames++;
1360
+ this.textures.set(id, this.lease.textureFor(this.map.sheet.get(id).frame));
1361
+ }
1362
+ } catch (error) {
1363
+ this.fail(error);
1364
+ }
1365
+ }
1366
+ remove(index) {
1367
+ const sprite = this.sprites.get(index);
1368
+ if (sprite) {
1369
+ this.sprites.delete(index);
1370
+ sprite.destroy();
1371
+ this.stats.destroyedSprites++;
1372
+ }
1373
+ }
1374
+ cell(x, y) {
1375
+ this.stats.visitedCells++;
1376
+ const index = y * this.map.width + x, id = this.map.get(x, y);
1377
+ if (id === null) {
1378
+ this.remove(index);
1379
+ return;
1380
+ }
1381
+ const texture = this.textures.get(id);
1382
+ if (!texture) return;
1383
+ let sprite = this.sprites.get(index);
1384
+ if (!sprite) {
1385
+ sprite = new Sprite(texture);
1386
+ sprite.position.set(x * this.map.tileWidth, y * this.map.tileHeight);
1387
+ this.sprites.set(index, sprite);
1388
+ this.display.addChild(sprite);
1389
+ this.stats.createdSprites++;
1390
+ }
1391
+ sprite.texture = texture;
1392
+ sprite.width = this.map.tileWidth;
1393
+ sprite.height = this.map.tileHeight;
1394
+ this.stats.updatedTiles++;
1395
+ }
1396
+ render(view) {
1397
+ if (this.disposed) return;
1398
+ this.sync();
1399
+ const m = this.map;
1400
+ this.display.position.set(m.x, m.y);
1401
+ this.display.visible = m.visible && m.enabled;
1402
+ if (!this.display.visible) {
1403
+ this.invalid = true;
1404
+ return;
1405
+ }
1406
+ if (!this.lease || this.error) return;
1407
+ const c = view.clip;
1408
+ const left = Math.max(0, Math.min(m.width, Math.floor(((c.x - view.x) / view.scale - m.x) / m.tileWidth)));
1409
+ const top = Math.max(0, Math.min(m.height, Math.floor(((c.y - view.y) / view.scale - m.y) / m.tileHeight)));
1410
+ const right = Math.max(left, Math.min(m.width, Math.ceil(((c.x + c.width - view.x) / view.scale - m.x) / m.tileWidth)));
1411
+ const bottom = Math.max(top, Math.min(m.height, Math.ceil(((c.y + c.height - view.y) / view.scale - m.y) / m.tileHeight)));
1412
+ const old = this.range;
1413
+ if (this.invalid || left !== old.left || top !== old.top || right !== old.right || bottom !== old.bottom) {
1414
+ this.range = {
1415
+ left,
1416
+ top,
1417
+ right,
1418
+ bottom
1419
+ };
1420
+ this.invalid = false;
1421
+ for (const index of this.sprites.keys()) {
1422
+ const x = index % m.width, y = Math.floor(index / m.width);
1423
+ if (x < left || x >= right || y < top || y >= bottom) this.remove(index);
1424
+ }
1425
+ for (let y = top; y < bottom; y++) for (let x = left; x < right; x++) this.cell(x, y);
1426
+ } else for (const index of this.dirty) this.cell(index % m.width, Math.floor(index / m.width));
1427
+ this.dirty.clear();
1428
+ }
1429
+ dispose() {
1430
+ if (this.disposed) return;
1431
+ this.disposed = true;
1432
+ this.controller?.abort();
1433
+ this.controller = void 0;
1434
+ this.stats.destroyedSprites += this.sprites.size;
1435
+ this.sprites.clear();
1436
+ this.textures.clear();
1437
+ this.dirty.clear();
1438
+ const lease = this.lease;
1439
+ this.lease = void 0;
1440
+ cleanupAll([
1441
+ this.off,
1442
+ this.offDispose,
1443
+ () => this.display.removeFromParent(),
1444
+ () => this.display.destroy({ children: true }),
1445
+ () => lease?.release(),
1446
+ this.released,
1447
+ this.changed
1448
+ ], "Tilemap presentation cleanup failed");
1449
+ }
1450
+ };
1451
+ // packages/web/src/renderer.ts
1452
+ var PixiRenderer = class {
1453
+ host;
1454
+ background;
1455
+ onError;
1456
+ app = new Application();
1457
+ pendingUITextKeys = /* @__PURE__ */ new Set();
1458
+ canvasPresentations = /* @__PURE__ */ new Set();
1459
+ get canvasDiagnostics() {
1460
+ let nodes = 0, buttons = 0, syncs = 0, textMetrics = 0;
1461
+ for (const p of this.canvasPresentations) {
1462
+ const d = p.diagnostics;
1463
+ nodes += d.nodes;
1464
+ buttons += d.buttons;
1465
+ syncs += d.syncs;
1466
+ textMetrics += d.textMetrics;
1467
+ }
1468
+ return {
1469
+ presentations: this.canvasPresentations.size,
1470
+ nodes,
1471
+ buttons,
1472
+ syncs,
1473
+ textMetrics
1474
+ };
1475
+ }
1476
+ prepareCanvasUI(ui, routing) {
1477
+ if (!this.ready || this.dead) throw new EngineError("RENDERER_NOT_READY", "Renderer is not available");
1478
+ const visual = new CanvasVisual(ui, this.host, routing, {
1479
+ graphics: (graphics, context) => releaseUIGraphics(this.app.renderer, graphics, context),
1480
+ text: (text, key) => releaseUIText(this.app.renderer, text, key),
1481
+ changedText: (key) => this.pendingUITextKeys.add(key)
1482
+ });
1483
+ this.canvasPresentations.add(visual);
1484
+ let disposed = false;
1485
+ const off = ui.onDispose(() => {
1486
+ this.canvasPresentations.delete(visual);
1487
+ });
1488
+ return {
1489
+ update: (width, height) => {
1490
+ if (disposed || ui.disposed) return;
1491
+ if (!visual.display.parent) this.app.stage.addChild(visual.display);
1492
+ visual.update(width, height);
1493
+ },
1494
+ focus: (node) => visual.focus(node),
1495
+ dispose: () => {
1496
+ if (disposed) return;
1497
+ disposed = true;
1498
+ off();
1499
+ this.canvasPresentations.delete(visual);
1500
+ visual.dispose();
1501
+ }
1502
+ };
1503
+ }
1504
+ scenes = /* @__PURE__ */ new Set();
1505
+ images = new SharedImages((url, signal) => this.image(url, signal));
1506
+ tilemaps = /* @__PURE__ */ new Set();
1507
+ get tilemapDiagnostics() {
1508
+ let sprites = 0, visibleTiles = 0, visitedCells = 0, updatedTiles = 0, createdSprites = 0, destroyedSprites = 0, validatedFrames = 0;
1509
+ for (const t of this.tilemaps) {
1510
+ sprites += t.sprites.size;
1511
+ if (t.display.visible) visibleTiles += t.sprites.size;
1512
+ visitedCells += t.stats.visitedCells;
1513
+ updatedTiles += t.stats.updatedTiles;
1514
+ createdSprites += t.stats.createdSprites;
1515
+ destroyedSprites += t.stats.destroyedSprites;
1516
+ validatedFrames += t.stats.validatedFrames;
1517
+ }
1518
+ return {
1519
+ maps: this.tilemaps.size,
1520
+ sprites,
1521
+ visibleTiles,
1522
+ visitedCells,
1523
+ updatedTiles,
1524
+ createdSprites,
1525
+ destroyedSprites,
1526
+ validatedFrames
1527
+ };
1528
+ }
1529
+ geometryTilemaps = /* @__PURE__ */ new Set();
1530
+ get geometryTilemapDiagnostics() {
1531
+ let graphics = 0, visitedCells = 0, updatedTiles = 0, createdGraphics = 0, destroyedGraphics = 0;
1532
+ for (const t of this.geometryTilemaps) {
1533
+ graphics += t.graphics.size;
1534
+ visitedCells += t.stats.visitedCells;
1535
+ updatedTiles += t.stats.updatedTiles;
1536
+ createdGraphics += t.stats.createdGraphics;
1537
+ destroyedGraphics += t.stats.destroyedGraphics;
1538
+ }
1539
+ return {
1540
+ maps: this.geometryTilemaps.size,
1541
+ graphics,
1542
+ visitedCells,
1543
+ updatedTiles,
1544
+ createdGraphics,
1545
+ destroyedGraphics
1546
+ };
1547
+ }
1548
+ ready = false;
1549
+ dead = false;
1550
+ visualCount = 0;
1551
+ textureCount = 0;
1552
+ get diagnostics() {
1553
+ return {
1554
+ presentations: this.scenes.size,
1555
+ visuals: this.visualCount,
1556
+ textures: this.textureCount,
1557
+ ...this.images.diagnostics
1558
+ };
1559
+ }
1560
+ constructor(host, background = "#101922", onError = () => {}) {
1561
+ this.host = host;
1562
+ this.background = background;
1563
+ this.onError = onError;
1564
+ }
1565
+ async init() {
1566
+ await this.app.init({
1567
+ width: 800,
1568
+ height: 450,
1569
+ background: this.background,
1570
+ antialias: true,
1571
+ autoStart: false,
1572
+ sharedTicker: false,
1573
+ preference: "webgl",
1574
+ resolution: 1,
1575
+ autoDensity: true
1576
+ });
1577
+ this.ready = true;
1578
+ if (this.dead) {
1579
+ this.destroyApp();
1580
+ throw new EngineError("GAME_DISPOSED", "Renderer disposed during initialization");
1581
+ }
1582
+ this.app.stage.sortableChildren = true;
1583
+ this.app.canvas.setAttribute("aria-label", "Game world");
1584
+ this.host.prepend(this.app.canvas);
1585
+ }
1586
+ async image(url, signal) {
1587
+ const image = new Image();
1588
+ await new Promise((resolve, reject) => {
1589
+ const clear = () => {
1590
+ image.onload = null;
1591
+ image.onerror = null;
1592
+ signal.removeEventListener("abort", abort);
1593
+ };
1594
+ const abort = () => {
1595
+ clear();
1596
+ image.src = "";
1597
+ reject(new EngineError("GAME_DISPOSED", "Image loading cancelled", { path: url }));
1598
+ };
1599
+ image.onload = () => {
1600
+ clear();
1601
+ resolve();
1602
+ };
1603
+ image.onerror = () => {
1604
+ clear();
1605
+ reject(new EngineError("ASSET_LOAD_FAILED", "Could not load sprite", { path: url }));
1606
+ };
1607
+ if (signal.aborted) {
1608
+ abort();
1609
+ return;
1610
+ }
1611
+ signal.addEventListener("abort", abort, { once: true });
1612
+ image.crossOrigin = "anonymous";
1613
+ image.src = url;
1614
+ });
1615
+ if (signal.aborted) throw new EngineError("GAME_DISPOSED", "Image loading cancelled", { path: url });
1616
+ const texture = Texture.from(image, true);
1617
+ if (!(texture.width > 0 && texture.height > 0)) {
1618
+ texture.destroy(true);
1619
+ throw new EngineError("ASSET_LOAD_FAILED", "Image has no drawable dimensions", { path: url });
1620
+ }
1621
+ this.textureCount++;
1622
+ let released = false;
1623
+ return {
1624
+ texture,
1625
+ release: () => {
1626
+ if (released) return;
1627
+ released = true;
1628
+ this.textureCount--;
1629
+ texture.destroy(true);
1630
+ }
1631
+ };
1632
+ }
1633
+ acquireImage(url, signal) {
1634
+ try {
1635
+ return this.images.acquire(new URL(url, document.baseURI).href, signal);
1636
+ } catch {
1637
+ return Promise.reject(new EngineError("ASSET_LOAD_FAILED", "Invalid image URL", { path: url }));
1638
+ }
1639
+ }
1640
+ async prepare(scene) {
1641
+ if (!this.ready || this.dead) throw new EngineError("RENDERER_NOT_READY", "Renderer is not available");
1642
+ const root = new Container(), world = new Container(), mask = new Graphics();
1643
+ world.sortableChildren = true;
1644
+ root.addChild(world, mask);
1645
+ root.mask = mask;
1646
+ const visuals = /* @__PURE__ */ new Map();
1647
+ const tilemaps = /* @__PURE__ */ new Map();
1648
+ const drawOrder = [];
1649
+ let depthDirty = true;
1650
+ let revision = -1, disposed = false, clipX = NaN, clipY = NaN, clipWidth = NaN, clipHeight = NaN, removeSceneCleanup;
1651
+ const valid = (entry) => !disposed && !scene.disposed && !entry.actor.disposed && entry.actor.scene === scene && entry.actor.get(Renderable.type) === entry.component && !entry.component.disposed;
1652
+ let wake;
1653
+ let wakeResolve;
1654
+ const changed = () => {
1655
+ const resolve = wakeResolve;
1656
+ wake = void 0;
1657
+ wakeResolve = void 0;
1658
+ resolve?.();
1659
+ };
1660
+ const cancel = (entry) => {
1661
+ entry.generation++;
1662
+ const controller = entry.controller;
1663
+ delete entry.controller;
1664
+ if (controller) controller.abort();
1665
+ };
1666
+ const appearance = (entry) => {
1667
+ const { component, display, displayedSource: source } = entry;
1668
+ if (!display || !source) return;
1669
+ if (source.kind === "image" && display instanceof Sprite && entry.image) {
1670
+ const texture = display.texture;
1671
+ const size = imageSize(texture.width, texture.height, component.width, component.height, source.fit);
1672
+ entry.scaleX = size.width / texture.width;
1673
+ entry.scaleY = size.height / texture.height;
1674
+ display.tint = component.color;
1675
+ } else if (source.kind === "shape" && display instanceof Graphics) {
1676
+ const width = component.width, height = component.height;
1677
+ if (entry.geometryShape === source.shape && entry.geometryWidth === width && entry.geometryHeight === height && entry.geometryColor === component.color) return;
1678
+ entry.geometryShape = source.shape;
1679
+ entry.geometryWidth = width;
1680
+ entry.geometryHeight = height;
1681
+ entry.geometryColor = component.color;
1682
+ display.clear();
1683
+ if (source.shape === Shape.Circle) display.circle(0, 0, Math.min(width, height) / 2).fill(component.color);
1684
+ else if (source.shape === Shape.Hexagon) display.poly([
1685
+ 0,
1686
+ -height / 2,
1687
+ width / 2,
1688
+ -height / 4,
1689
+ width / 2,
1690
+ height / 4,
1691
+ 0,
1692
+ height / 2,
1693
+ -width / 2,
1694
+ height / 4,
1695
+ -width / 2,
1696
+ -height / 4
1697
+ ], true).fill(component.color);
1698
+ else if (source.shape === Shape.Triangle) display.poly([
1699
+ 0,
1700
+ -height / 2,
1701
+ width / 2,
1702
+ height / 2,
1703
+ -width / 2,
1704
+ height / 2
1705
+ ], true).fill(component.color);
1706
+ else display.roundRect(-width / 2, -height / 2, width, height, source.shape === Shape.Character ? Math.min(width, height) / 4 : 2).fill(component.color);
1707
+ if (source.shape === Shape.Character) display.circle(-width / 7, -height / 10, Math.min(width, height) / 18).fill(1059880).circle(width / 7, -height / 10, Math.min(width, height) / 18).fill(1059880);
1708
+ entry.scaleX = entry.scaleY = 1;
1709
+ }
1710
+ };
1711
+ const replace = (entry, display, source) => {
1712
+ if (entry.display !== display) {
1713
+ entry.display?.destroy();
1714
+ entry.display = display;
1715
+ delete entry.geometryShape;
1716
+ world.addChild(display);
1717
+ depthDirty = true;
1718
+ }
1719
+ entry.displayedSource = source;
1720
+ };
1721
+ const fail = (entry, error) => {
1722
+ entry.error = error;
1723
+ try {
1724
+ this.onError(error);
1725
+ } catch {}
1726
+ };
1727
+ const commitImage = (entry, resource, source) => {
1728
+ if (source.kind !== "image") return;
1729
+ const texture = resource.textureFor(source.frame);
1730
+ const previous = entry.image;
1731
+ const sprite = entry.display instanceof Sprite ? entry.display : new Sprite(texture);
1732
+ sprite.texture = texture;
1733
+ sprite.anchor.set(.5);
1734
+ entry.image = resource;
1735
+ replace(entry, sprite, source);
1736
+ appearance(entry);
1737
+ if (previous !== resource) previous?.release();
1738
+ };
1739
+ const update = (entry) => {
1740
+ const component = entry.component;
1741
+ if (entry.revision === component.revision) return;
1742
+ entry.revision = component.revision;
1743
+ const source = component.source;
1744
+ const sourceChanged = entry.requestedSource !== source;
1745
+ entry.requestedSource = source;
1746
+ if (source.kind === "shape") {
1747
+ cancel(entry);
1748
+ delete entry.requestUrl;
1749
+ delete entry.error;
1750
+ const previous = entry.image;
1751
+ delete entry.image;
1752
+ replace(entry, entry.display instanceof Graphics ? entry.display : new Graphics(), source);
1753
+ previous?.release();
1754
+ changed();
1755
+ } else if (entry.requestUrl !== source.url || sourceChanged && !entry.controller && (!entry.image || entry.displayedSource?.kind !== "image" || entry.displayedSource.url !== source.url)) {
1756
+ cancel(entry);
1757
+ entry.requestUrl = source.url;
1758
+ delete entry.error;
1759
+ if (entry.image && entry.displayedSource?.kind === "image" && entry.displayedSource.url === source.url) try {
1760
+ commitImage(entry, entry.image, source);
1761
+ } catch (error) {
1762
+ fail(entry, error);
1763
+ }
1764
+ else {
1765
+ const controller = new AbortController(), generation = entry.generation;
1766
+ entry.controller = controller;
1767
+ const current = () => !entry.disposed && valid(entry) && entry.generation === generation && component.source.kind === "image" && component.source.url === source.url;
1768
+ this.acquireImage(source.url, controller.signal).then((resource) => {
1769
+ if (!current()) {
1770
+ resource.release();
1771
+ return;
1772
+ }
1773
+ try {
1774
+ commitImage(entry, resource, component.source);
1775
+ } catch (error) {
1776
+ resource.release();
1777
+ throw error;
1778
+ }
1779
+ }).catch((error) => {
1780
+ if (current()) fail(entry, error);
1781
+ }).finally(() => {
1782
+ if (entry.controller === controller) delete entry.controller;
1783
+ changed();
1784
+ });
1785
+ }
1786
+ changed();
1787
+ } else if (entry.image && entry.displayedSource?.kind === "image" && entry.displayedSource.url === source.url) {
1788
+ if (sourceChanged) delete entry.error;
1789
+ try {
1790
+ commitImage(entry, entry.image, source);
1791
+ } catch (error) {
1792
+ fail(entry, error);
1793
+ }
1794
+ }
1795
+ appearance(entry);
1796
+ };
1797
+ const create = (actor, component) => {
1798
+ this.visualCount++;
1799
+ let removeActorCleanup = () => {}, removeComponentCleanup = () => {};
1800
+ const entry = {
1801
+ revision: -1,
1802
+ generation: 0,
1803
+ disposed: false,
1804
+ geometryWidth: NaN,
1805
+ geometryHeight: NaN,
1806
+ geometryColor: NaN,
1807
+ order: 0,
1808
+ depth: NaN,
1809
+ scaleX: 1,
1810
+ scaleY: 1,
1811
+ actor,
1812
+ component,
1813
+ transform: actor.require(Transform.type),
1814
+ dispose: () => {
1815
+ if (entry.disposed) return;
1816
+ entry.disposed = true;
1817
+ this.visualCount--;
1818
+ depthDirty = true;
1819
+ drawOrder.length = 0;
1820
+ cancel(entry);
1821
+ changed();
1822
+ const display = entry.display, image = entry.image;
1823
+ delete entry.display;
1824
+ delete entry.image;
1825
+ cleanupAll([
1826
+ removeActorCleanup,
1827
+ removeComponentCleanup,
1828
+ () => display?.destroy(),
1829
+ () => image?.release()
1830
+ ], "Visual cleanup failed");
1831
+ }
1832
+ };
1833
+ removeActorCleanup = actor.onDispose(entry.dispose);
1834
+ removeComponentCleanup = component.onDispose(entry.dispose);
1835
+ update(entry);
1836
+ return entry;
1837
+ };
1838
+ const reconcile = () => {
1839
+ if (disposed || scene.disposed) return;
1840
+ if (revision === scene.revision) return;
1841
+ revision = scene.revision;
1842
+ depthDirty = true;
1843
+ for (const [actor, entry] of visuals) if (!valid(entry)) {
1844
+ entry.dispose();
1845
+ visuals.delete(actor);
1846
+ }
1847
+ 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) {
1848
+ entry.dispose();
1849
+ if (entry instanceof TilemapVisual) this.tilemaps.delete(entry);
1850
+ else this.geometryTilemaps.delete(entry);
1851
+ tilemaps.delete(actor);
1852
+ }
1853
+ let order = 0;
1854
+ for (const actor of scene.actors) {
1855
+ const layer = actor.get(TilemapLayer.type);
1856
+ if (layer) {
1857
+ let tile = tilemaps.get(actor);
1858
+ if (!tile) {
1859
+ tile = new TilemapVisual(layer.map, scene, (url, signal) => this.acquireImage(url, signal), changed, this.onError, () => {
1860
+ if (tile instanceof TilemapVisual) this.tilemaps.delete(tile);
1861
+ depthDirty = true;
1862
+ });
1863
+ world.addChild(tile.display);
1864
+ tilemaps.set(actor, tile);
1865
+ this.tilemaps.add(tile);
1866
+ }
1867
+ tile.order = order;
1868
+ }
1869
+ const geometryLayer = actor.get(GeometryTilemapLayer.type);
1870
+ if (geometryLayer) {
1871
+ let tile = tilemaps.get(actor);
1872
+ if (!tile) {
1873
+ const created = new GeometryTilemapVisual(geometryLayer.map, () => {
1874
+ this.geometryTilemaps.delete(created);
1875
+ depthDirty = true;
1876
+ });
1877
+ tile = created;
1878
+ world.addChild(tile.display);
1879
+ tilemaps.set(actor, tile);
1880
+ this.geometryTilemaps.add(created);
1881
+ }
1882
+ tile.order = order;
1883
+ }
1884
+ const component = actor.get(Renderable.type);
1885
+ if (component) {
1886
+ let entry = visuals.get(actor);
1887
+ if (!entry) {
1888
+ entry = create(actor, component);
1889
+ visuals.set(actor, entry);
1890
+ }
1891
+ entry.order = order;
1892
+ }
1893
+ order++;
1894
+ }
1895
+ };
1896
+ const sync = () => {
1897
+ reconcile();
1898
+ for (const entry of visuals.values()) update(entry);
1899
+ for (const entry of tilemaps.values()) if (entry instanceof TilemapVisual) entry.sync();
1900
+ };
1901
+ const prepared = {
1902
+ ready: async () => {
1903
+ for (;;) {
1904
+ if (disposed || this.dead || scene.disposed) throw new EngineError("GAME_DISPOSED", "Scene presentation has been disposed");
1905
+ sync();
1906
+ let pending = false;
1907
+ for (const entry of visuals.values()) {
1908
+ if (entry.error) throw entry.error;
1909
+ if (entry.controller) pending = true;
1910
+ }
1911
+ for (const entry of tilemaps.values()) {
1912
+ if (!(entry instanceof TilemapVisual)) continue;
1913
+ if (entry.error) throw entry.error;
1914
+ if (entry.controller) pending = true;
1915
+ }
1916
+ if (!pending) return;
1917
+ wake ??= new Promise((resolve) => {
1918
+ wakeResolve = resolve;
1919
+ });
1920
+ await wake;
1921
+ }
1922
+ },
1923
+ render: (view) => {
1924
+ if (disposed || this.dead || scene.disposed) return;
1925
+ reconcile();
1926
+ if (!root.parent) this.app.stage.addChild(root);
1927
+ const c = view.clip;
1928
+ if (c.x !== clipX || c.y !== clipY || c.width !== clipWidth || c.height !== clipHeight) {
1929
+ clipX = c.x;
1930
+ clipY = c.y;
1931
+ clipWidth = c.width;
1932
+ clipHeight = c.height;
1933
+ mask.clear().rect(c.x, c.y, c.width, c.height).fill(16777215);
1934
+ }
1935
+ world.scale.set(view.scale);
1936
+ world.position.set(view.x, view.y);
1937
+ for (const entry of visuals.values()) {
1938
+ update(entry);
1939
+ if (entry.display) {
1940
+ const t = entry.transform;
1941
+ entry.display.visible = entry.actor.enabled && entry.component.enabled && entry.component.visible;
1942
+ entry.display.alpha = entry.component.opacity;
1943
+ if (entry.depth !== entry.component.zIndex) {
1944
+ entry.depth = entry.component.zIndex;
1945
+ depthDirty = true;
1946
+ }
1947
+ const x = t.x + entry.component.offsetX, y = t.y + entry.component.offsetY, rotation = t.rotation + entry.component.rotation, scaleX = entry.scaleX * t.scaleX * entry.component.scaleX, scaleY = entry.scaleY * t.scaleY * entry.component.scaleY;
1948
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(rotation) || !Number.isFinite(scaleX) || !Number.isFinite(scaleY)) throw new EngineError("INVALID_VALUE", "Composed visual transform must be finite");
1949
+ entry.display.position.set(x, y);
1950
+ entry.display.rotation = rotation;
1951
+ entry.display.scale.set(scaleX, scaleY);
1952
+ }
1953
+ }
1954
+ for (const entry of tilemaps.values()) {
1955
+ entry.render(view);
1956
+ if (entry.depth !== entry.map.zIndex) {
1957
+ entry.depth = entry.map.zIndex;
1958
+ depthDirty = true;
1959
+ }
1960
+ }
1961
+ if (depthDirty) {
1962
+ drawOrder.length = 0;
1963
+ for (const entry of visuals.values()) if (entry.display) drawOrder.push({
1964
+ display: entry.display,
1965
+ order: entry.order,
1966
+ zIndex: entry.component.zIndex
1967
+ });
1968
+ for (const entry of tilemaps.values()) drawOrder.push({
1969
+ display: entry.display,
1970
+ order: entry.order,
1971
+ zIndex: entry.map.zIndex
1972
+ });
1973
+ drawOrder.sort((a, b) => a.zIndex - b.zIndex || a.order - b.order);
1974
+ for (let i = 0; i < drawOrder.length; i++) {
1975
+ const entry = drawOrder[i];
1976
+ if (entry?.display) entry.display.zIndex = i;
1977
+ }
1978
+ depthDirty = false;
1979
+ }
1980
+ this.app.render();
1981
+ if (this.pendingUITextKeys.size) pruneUITextKeys(this.app.renderer, this.pendingUITextKeys);
1982
+ },
1983
+ dispose: () => {
1984
+ if (disposed) return;
1985
+ disposed = true;
1986
+ changed();
1987
+ this.scenes.delete(prepared);
1988
+ const entries = [...visuals.values()];
1989
+ visuals.clear();
1990
+ drawOrder.length = 0;
1991
+ cleanupAll([
1992
+ () => removeSceneCleanup?.(),
1993
+ ...entries.map((entry) => entry.dispose),
1994
+ () => {
1995
+ for (const entry of tilemaps.values()) {
1996
+ entry.dispose();
1997
+ if (entry instanceof TilemapVisual) this.tilemaps.delete(entry);
1998
+ else this.geometryTilemaps.delete(entry);
1999
+ }
2000
+ tilemaps.clear();
2001
+ },
2002
+ () => root.destroy({
2003
+ children: true,
2004
+ context: true
2005
+ })
2006
+ ], "Presentation cleanup failed");
2007
+ }
2008
+ };
2009
+ removeSceneCleanup = scene.onDispose(prepared.dispose);
2010
+ this.scenes.add(prepared);
2011
+ try {
2012
+ await prepared.ready?.();
2013
+ return prepared;
2014
+ } catch (error) {
2015
+ prepared.dispose();
2016
+ throw error;
2017
+ }
2018
+ }
2019
+ resize(width, height, devicePixelRatio) {
2020
+ if (!this.ready || this.dead) return;
2021
+ if (this.app.renderer.resolution !== devicePixelRatio) for (const visual of this.canvasPresentations) visual.beforeResolutionChange();
2022
+ this.app.renderer.resolution = devicePixelRatio;
2023
+ this.app.renderer.resize(width, height);
2024
+ }
2025
+ destroyApp() {
2026
+ if (this.ready) {
2027
+ this.ready = false;
2028
+ this.app.destroy({ removeView: true }, {
2029
+ children: true,
2030
+ context: true
2031
+ });
2032
+ }
2033
+ }
2034
+ dispose() {
2035
+ if (this.dead) return;
2036
+ this.dead = true;
2037
+ const scenes = [...this.scenes];
2038
+ this.scenes.clear();
2039
+ cleanupAll([
2040
+ ...scenes.map((scene) => scene.dispose),
2041
+ ...[...this.canvasPresentations].map((p) => () => {
2042
+ this.canvasPresentations.delete(p);
2043
+ p.dispose();
2044
+ }),
2045
+ () => this.pendingUITextKeys.clear(),
2046
+ () => this.images.dispose(),
2047
+ () => this.destroyApp()
2048
+ ], "Renderer cleanup failed");
2049
+ }
2050
+ };
2051
+ // packages/web/src/ui.ts
2052
+ var Layout = {
2053
+ Column: "column",
2054
+ Row: "row",
2055
+ Grid: "grid"
2056
+ };
2057
+ var Theme = {
2058
+ Dark: "dark",
2059
+ Light: "light"
2060
+ };
2061
+ var Themes = {
2062
+ dark: {
2063
+ background: "#101922",
2064
+ surface: "#172630",
2065
+ text: "#eff7f6",
2066
+ muted: "#afc4ca",
2067
+ accent: "#73e3c0",
2068
+ font: "system-ui, sans-serif"
2069
+ },
2070
+ light: {
2071
+ background: "#eaf2ef",
2072
+ surface: "#ffffff",
2073
+ text: "#17332d",
2074
+ muted: "#45645d",
2075
+ accent: "#17654f",
2076
+ font: "system-ui, sans-serif"
2077
+ }
2078
+ };
2079
+ var Placement = {
2080
+ Top: "top",
2081
+ Center: "center",
2082
+ Bottom: "bottom"
2083
+ };
2084
+ function textBinding(text) {
2085
+ let value = text;
2086
+ const listeners = /* @__PURE__ */ new Set();
2087
+ let plan = [];
2088
+ return {
2089
+ get text() {
2090
+ return value;
2091
+ },
2092
+ setText(next) {
2093
+ if (next === value) return;
2094
+ value = next;
2095
+ const errors = [];
2096
+ for (const listener of plan) if (listeners.has(listener)) try {
2097
+ listener(value);
2098
+ } catch (error) {
2099
+ errors.push(error);
2100
+ }
2101
+ if (errors.length) throw new AggregateError(errors, "Text subscribers failed");
2102
+ },
2103
+ subscribe(listener) {
2104
+ listeners.add(listener);
2105
+ plan = [...listeners];
2106
+ return () => {
2107
+ listeners.delete(listener);
2108
+ plan = [...listeners];
2109
+ };
2110
+ }
2111
+ };
2112
+ }
2113
+ function withText(node, text) {
2114
+ return Object.defineProperties(node, Object.getOwnPropertyDescriptors(textBinding(text)));
2115
+ }
2116
+ function Label(text, options = {}) {
2117
+ return withText({
2118
+ kind: "label",
2119
+ muted: options.muted ?? false
2120
+ }, text);
2121
+ }
2122
+ function Button(text, onClick) {
2123
+ return withText({
2124
+ kind: "button",
2125
+ onClick
2126
+ }, text);
2127
+ }
2128
+ function HoldButton(text, onHold) {
2129
+ return withText({
2130
+ kind: "hold-button",
2131
+ onHold
2132
+ }, text);
2133
+ }
2134
+ function Panel(children, options = {}) {
2135
+ return {
2136
+ kind: "panel",
2137
+ children: [...children],
2138
+ layout: options.layout ?? Layout.Column,
2139
+ placement: options.placement ?? Placement.Top,
2140
+ ...options.title === void 0 ? {} : { title: options.title }
2141
+ };
2142
+ }
2143
+ function mountUI(nodes, onError) {
2144
+ const element = document.createElement("div");
2145
+ element.className = "s-ui";
2146
+ const controller = new AbortController(), subscriptions = [];
2147
+ let disposed = false;
2148
+ const report = (error) => {
2149
+ try {
2150
+ onError(error);
2151
+ } catch {}
2152
+ };
2153
+ const render = (node) => {
2154
+ if (node.kind === "label") {
2155
+ const label = document.createElement("p");
2156
+ label.textContent = node.text;
2157
+ if (node.muted) label.className = "s-muted";
2158
+ subscriptions.push(node.subscribe((text) => {
2159
+ label.textContent = text;
2160
+ }));
2161
+ return label;
2162
+ }
2163
+ if (node.kind === "button" || node.kind === "hold-button") {
2164
+ const button = document.createElement("button");
2165
+ button.type = "button";
2166
+ button.textContent = node.text;
2167
+ subscriptions.push(node.subscribe((text) => {
2168
+ button.textContent = text;
2169
+ }));
2170
+ const options = { signal: controller.signal };
2171
+ registerGameButton(button, controller.signal);
2172
+ if (node.kind === "button") {
2173
+ let pending = false;
2174
+ button.addEventListener("click", () => {
2175
+ if (pending || button.disabled) return;
2176
+ try {
2177
+ const result = node.onClick(controller.signal);
2178
+ if (!result || typeof result.then !== "function") return;
2179
+ pending = true;
2180
+ button.setAttribute("aria-disabled", "true");
2181
+ button.setAttribute("aria-busy", "true");
2182
+ button.tabIndex = -1;
2183
+ Promise.resolve(result).catch((error) => {
2184
+ if (!controller.signal.aborted) report(error);
2185
+ }).finally(() => {
2186
+ pending = false;
2187
+ button.removeAttribute("aria-disabled");
2188
+ button.removeAttribute("aria-busy");
2189
+ button.tabIndex = 0;
2190
+ });
2191
+ } catch (error) {
2192
+ if (!controller.signal.aborted) report(error);
2193
+ }
2194
+ }, options);
2195
+ } else {
2196
+ button.classList.add("s-hold");
2197
+ button.setAttribute("aria-pressed", "false");
2198
+ const pointers = /* @__PURE__ */ new Set(), keys = /* @__PURE__ */ new Set();
2199
+ let held = false;
2200
+ const update = () => {
2201
+ const next = pointers.size > 0 || keys.size > 0;
2202
+ if (held === next) return;
2203
+ held = next;
2204
+ button.setAttribute("aria-pressed", String(held));
2205
+ try {
2206
+ node.onHold(held);
2207
+ } catch (error) {
2208
+ report(error);
2209
+ }
2210
+ };
2211
+ const release = () => {
2212
+ pointers.clear();
2213
+ keys.clear();
2214
+ update();
2215
+ };
2216
+ button.addEventListener("pointerdown", (event) => {
2217
+ if (event.button !== 0) return;
2218
+ pointers.add(event.pointerId);
2219
+ button.setPointerCapture(event.pointerId);
2220
+ update();
2221
+ }, options);
2222
+ for (const type of [
2223
+ "pointerup",
2224
+ "pointercancel",
2225
+ "lostpointercapture"
2226
+ ]) button.addEventListener(type, (event) => {
2227
+ pointers.delete(event.pointerId);
2228
+ update();
2229
+ }, options);
2230
+ button.addEventListener("keydown", (event) => {
2231
+ if (event.isComposing || event.keyCode === 229) return;
2232
+ if (event.code !== "Space" && event.code !== "Enter") return;
2233
+ event.preventDefault();
2234
+ keys.add(event.code);
2235
+ update();
2236
+ }, options);
2237
+ button.addEventListener("keyup", (event) => {
2238
+ if (event.isComposing || event.keyCode === 229) return;
2239
+ if (event.code !== "Space" && event.code !== "Enter") return;
2240
+ event.preventDefault();
2241
+ keys.delete(event.code);
2242
+ update();
2243
+ }, options);
2244
+ button.addEventListener("blur", () => {
2245
+ keys.clear();
2246
+ update();
2247
+ }, options);
2248
+ window.addEventListener("blur", release, options);
2249
+ document.addEventListener("visibilitychange", () => {
2250
+ if (document.hidden) release();
2251
+ }, options);
2252
+ subscriptions.push(release);
2253
+ }
2254
+ return button;
2255
+ }
2256
+ const panel = document.createElement("section");
2257
+ panel.className = "s-panel s-" + node.layout + " s-" + node.placement;
2258
+ if (node.title) {
2259
+ const title = document.createElement("h2");
2260
+ title.textContent = node.title;
2261
+ panel.append(title);
2262
+ }
2263
+ panel.append(...node.children.map(render));
2264
+ return panel;
2265
+ };
2266
+ const dispose = () => {
2267
+ if (disposed) return;
2268
+ disposed = true;
2269
+ controller.abort();
2270
+ const cleanup = subscriptions.splice(0).reverse();
2271
+ cleanupAll([...cleanup, () => element.remove()], "UI cleanup failed");
2272
+ };
2273
+ try {
2274
+ element.append(...nodes.map(render));
2275
+ } catch (error) {
2276
+ try {
2277
+ dispose();
2278
+ } catch (cleanupError) {
2279
+ throw new AggregateError([error, cleanupError], "UI creation and cleanup failed");
2280
+ }
2281
+ throw error;
2282
+ }
2283
+ return {
2284
+ element,
2285
+ dispose
2286
+ };
2287
+ }
2288
+ // packages/web/src/game.ts
2289
+ var Game = class {
2290
+ engine;
2291
+ element;
2292
+ camera;
2293
+ renderer;
2294
+ keyboard;
2295
+ pointers;
2296
+ pointerChannels = /* @__PURE__ */ new WeakMap();
2297
+ canvas;
2298
+ canvasPresentation;
2299
+ defaultCanvas;
2300
+ get pointer() {
2301
+ this.assertAlive();
2302
+ return this.channelFor(this.engine.scene);
2303
+ }
2304
+ get pointerDiagnostics() {
2305
+ return this.pointers.routing.diagnostics;
2306
+ }
2307
+ channelFor(scene) {
2308
+ let channel = this.pointerChannels.get(scene);
2309
+ if (!channel) {
2310
+ channel = new ScenePointer(scene);
2311
+ this.pointerChannels.set(scene, channel);
2312
+ }
2313
+ return channel;
2314
+ }
2315
+ observer;
2316
+ frames;
2317
+ options;
2318
+ resolution;
2319
+ aspect;
2320
+ maxDelta;
2321
+ fixed;
2322
+ playback;
2323
+ playbackScene;
2324
+ removeReplayOwner;
2325
+ requestedViewport;
2326
+ projection;
2327
+ projectionDirty = true;
2328
+ cameraX = NaN;
2329
+ cameraY = NaN;
2330
+ cameraZoom = NaN;
2331
+ width = 800;
2332
+ height = 450;
2333
+ ratio = 1;
2334
+ scenes = /* @__PURE__ */ new Map();
2335
+ pendingScenes = /* @__PURE__ */ new Map();
2336
+ defaultUI = [];
2337
+ ui;
2338
+ prepared;
2339
+ startPromise;
2340
+ queue = Promise.resolve();
2341
+ frame;
2342
+ previousTime;
2343
+ dead = false;
2344
+ ready = false;
2345
+ rendererReady = false;
2346
+ rendererSized = false;
2347
+ staging;
2348
+ shutdown = new AbortController();
2349
+ errorBox;
2350
+ constructor(options = {}) {
2351
+ this.options = options;
2352
+ this.engine = new Engine(options.seed === void 0 ? {} : { seed: options.seed });
2353
+ this.fixed = options.fixedStep ? new FixedStepRunner(this.engine, options.fixedStep) : void 0;
2354
+ this.camera = options.camera ?? new Camera();
2355
+ this.maxDelta = positive(options.maxDelta ?? .05, "maxDelta");
2356
+ this.resolution = {
2357
+ width: positive(options.resolution?.width ?? 800, "resolution.width"),
2358
+ height: positive(options.resolution?.height ?? 450, "resolution.height")
2359
+ };
2360
+ this.aspect = options.aspect ?? Aspect.Fit;
2361
+ this.requestedViewport = options.viewport ? { ...options.viewport } : void 0;
2362
+ this.projection = createViewTransform({
2363
+ viewport: this.requestedViewport ?? {
2364
+ x: 0,
2365
+ y: 0,
2366
+ width: this.width,
2367
+ height: this.height
2368
+ },
2369
+ resolution: this.resolution,
2370
+ aspect: this.aspect,
2371
+ camera: this.camera
2372
+ });
2373
+ if (options.pixelRatio !== void 0) positive(options.pixelRatio, "pixelRatio");
2374
+ const theme = Themes[options.theme ?? Theme.Dark];
2375
+ if (!theme) throw new EngineError("INVALID_THEME", "Unknown theme");
2376
+ const tokens = {
2377
+ ...theme,
2378
+ ...options.tokens
2379
+ };
2380
+ this.element = document.createElement("div");
2381
+ this.element.className = "simpludify";
2382
+ this.element.tabIndex = 0;
2383
+ this.element.setAttribute("aria-label", "Game. Focus this area to use movement keys.");
2384
+ for (const [name, value] of Object.entries(tokens)) this.element.style.setProperty(`--s-${name}`, value);
2385
+ this.errorBox = document.createElement("p");
2386
+ this.errorBox.className = "s-error";
2387
+ this.errorBox.setAttribute("role", "alert");
2388
+ this.errorBox.hidden = true;
2389
+ this.element.append(this.errorBox);
2390
+ (options.parent ?? document.body).append(this.element);
2391
+ try {
2392
+ this.renderer = options.renderer?.(this.element) ?? new PixiRenderer(this.element, tokens.background, (error) => this.report(error));
2393
+ } catch (error) {
2394
+ this.element.remove();
2395
+ this.engine.dispose();
2396
+ throw error;
2397
+ }
2398
+ this.frames = options.frames ?? {
2399
+ request: (callback) => requestAnimationFrame(callback),
2400
+ cancel: (handle) => cancelAnimationFrame(handle)
2401
+ };
2402
+ this.keyboard = new BrowserInput(this.element, this.engine.input);
2403
+ this.pointers = new BrowserPointer(this.element, (event) => {
2404
+ const rect = this.element.getBoundingClientRect();
2405
+ if (rect.width <= 0 || rect.height <= 0) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
2406
+ const screen = screenPoint((event.clientX - rect.left) * this.width / rect.width, (event.clientY - rect.top) * this.height / rect.height);
2407
+ return {
2408
+ screen,
2409
+ world: this.screenToWorld(screen),
2410
+ view: this.view
2411
+ };
2412
+ }, this.report, () => this.canvasPresentation?.update(this.width, this.height));
2413
+ document.addEventListener("visibilitychange", () => {
2414
+ this.previousTime = void 0;
2415
+ this.fixed?.reset();
2416
+ this.engine.input.clear();
2417
+ }, { signal: this.shutdown.signal });
2418
+ this.observer = new ResizeObserver(() => {
2419
+ try {
2420
+ this.resize();
2421
+ } catch (error) {
2422
+ this.report(error);
2423
+ this.dispose();
2424
+ }
2425
+ });
2426
+ this.observer.observe(this.element);
2427
+ }
2428
+ assertAlive() {
2429
+ if (this.dead) throw new EngineError("GAME_DISPOSED", "Game has been disposed");
2430
+ }
2431
+ get tweens() {
2432
+ return this.engine.scene.tweens;
2433
+ }
2434
+ get presentationTweens() {
2435
+ return this.engine.scene.presentationTweens;
2436
+ }
2437
+ get paused() {
2438
+ return this.engine.paused;
2439
+ }
2440
+ pause() {
2441
+ this.assertAlive();
2442
+ this.pointers.release();
2443
+ this.engine.pause();
2444
+ this.previousTime = void 0;
2445
+ this.fixed?.reset();
2446
+ }
2447
+ resume() {
2448
+ this.assertAlive();
2449
+ this.pointers.release();
2450
+ this.engine.resume();
2451
+ this.previousTime = void 0;
2452
+ this.fixed?.reset();
2453
+ }
2454
+ /** Configure inside the initial Scene factory after game setup, before the first Engine step. */
2455
+ playReplay(data, options) {
2456
+ this.assertAlive();
2457
+ if (options.scene !== this.engine.scene && options.scene !== this.staging) throw new EngineError("REPLAY_STATE", "Replay Scene must be current or staged by this Game");
2458
+ if (this.playback) throw new EngineError("REPLAY_STATE", "Game already has a Replay player");
2459
+ const player = new ReplayPlayer(this.engine, data, options);
2460
+ this.playback = player;
2461
+ this.playbackScene = options.scene;
2462
+ try {
2463
+ this.removeReplayOwner = options.scene.onDispose(() => this.stopReplay());
2464
+ } catch (error) {
2465
+ this.stopReplay();
2466
+ throw error;
2467
+ }
2468
+ return player;
2469
+ }
2470
+ stopReplay() {
2471
+ const player = this.playback, off = this.removeReplayOwner;
2472
+ this.playback = void 0;
2473
+ this.playbackScene = void 0;
2474
+ this.removeReplayOwner = void 0;
2475
+ off?.();
2476
+ player?.dispose();
2477
+ this.previousTime = void 0;
2478
+ this.fixed?.reset();
2479
+ }
2480
+ add(actor) {
2481
+ this.assertAlive();
2482
+ return this.engine.scene.add(actor);
2483
+ }
2484
+ /** Scene-owned Actions for the current Scene. In a staged factory use context.actions. */
2485
+ actions(actions, bindings) {
2486
+ this.assertAlive();
2487
+ return new ActionController(actions, {
2488
+ scene: this.engine.scene,
2489
+ input: this.engine.input,
2490
+ ...bindings === void 0 ? {} : { bindings }
2491
+ });
2492
+ }
2493
+ remove(actor) {
2494
+ this.assertAlive();
2495
+ return this.engine.scene.remove(actor);
2496
+ }
2497
+ canvasUI(root, options = {}) {
2498
+ this.assertAlive();
2499
+ if (this.startPromise || this.defaultCanvas) throw new EngineError("UI_ALREADY_STARTED", "Define one default Canvas UI before run");
2500
+ this.defaultCanvas = new CanvasUI(this.engine.scene, root, options);
2501
+ return this.defaultCanvas;
2502
+ }
2503
+ uiOverlay(...nodes) {
2504
+ this.assertAlive();
2505
+ if (this.startPromise) throw new EngineError("UI_ALREADY_STARTED", "Define default UI before run");
2506
+ this.defaultUI.push(...nodes);
2507
+ }
2508
+ scene(id, setup) {
2509
+ this.assertAlive();
2510
+ if (this.scenes.has(id)) throw new EngineError("DUPLICATE_SCENE", "Scene id is already registered", { sceneId: id });
2511
+ this.scenes.set(id, setup);
2512
+ return this;
2513
+ }
2514
+ wait(promise) {
2515
+ if (this.dead) return Promise.reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
2516
+ const signal = this.shutdown.signal;
2517
+ return new Promise((resolve, reject) => {
2518
+ const abort = () => {
2519
+ signal.removeEventListener("abort", abort);
2520
+ reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
2521
+ };
2522
+ signal.addEventListener("abort", abort, { once: true });
2523
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
2524
+ });
2525
+ }
2526
+ run(initialScene) {
2527
+ if (this.dead) return Promise.reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
2528
+ if (this.startPromise) return this.startPromise;
2529
+ this.startPromise = (async () => {
2530
+ try {
2531
+ await this.wait(this.renderer.init());
2532
+ this.assertAlive();
2533
+ this.rendererReady = true;
2534
+ if (initialScene !== void 0) await this.navigate(initialScene);
2535
+ else await this.present(this.engine.scene, this.defaultUI, this.defaultCanvas);
2536
+ this.assertAlive();
2537
+ this.defaultCanvas = void 0;
2538
+ this.defaultUI.length = 0;
2539
+ this.ready = true;
2540
+ this.resize();
2541
+ this.render();
2542
+ this.assertAlive();
2543
+ this.frame = this.frames.request(this.tick);
2544
+ } catch (error) {
2545
+ this.dispose();
2546
+ throw error;
2547
+ }
2548
+ })();
2549
+ return this.startPromise;
2550
+ }
2551
+ goTo(id) {
2552
+ if (this.dead) return Promise.reject(new EngineError("GAME_DISPOSED", "Game has been disposed"));
2553
+ if (!this.ready) return Promise.reject(new EngineError("GAME_NOT_READY", "Await run before navigating"));
2554
+ return this.navigate(id);
2555
+ }
2556
+ navigate(id) {
2557
+ const setup = this.scenes.get(id);
2558
+ if (!setup) return Promise.reject(new EngineError("SCENE_NOT_FOUND", "Unknown scene", { sceneId: id }));
2559
+ const existing = this.pendingScenes.get(id);
2560
+ if (existing) return existing;
2561
+ const task = this.queue.then(async () => {
2562
+ this.assertAlive();
2563
+ const scene = new Scene(id), nodes = [], controller = new AbortController();
2564
+ let canvas;
2565
+ const pointer = this.channelFor(scene);
2566
+ this.staging = scene;
2567
+ scene.onDispose(() => controller.abort());
2568
+ try {
2569
+ await this.wait(Promise.resolve(setup({
2570
+ scene,
2571
+ get tweens() {
2572
+ return scene.tweens;
2573
+ },
2574
+ get presentationTweens() {
2575
+ return scene.presentationTweens;
2576
+ },
2577
+ signal: controller.signal,
2578
+ add: (actor) => scene.add(actor),
2579
+ ui: (...items) => nodes.push(...items),
2580
+ pointer,
2581
+ canvasUI: (root, options) => {
2582
+ if (canvas) throw new EngineError("UI_OWNERSHIP", "A Scene has one Canvas UI root");
2583
+ canvas = new CanvasUI(scene, root, options);
2584
+ return canvas;
2585
+ },
2586
+ actions: (actions, bindings) => new ActionController(actions, {
2587
+ scene,
2588
+ input: this.engine.input,
2589
+ ...bindings === void 0 ? {} : { bindings }
2590
+ }),
2591
+ onUpdate: (callback) => scene.onUpdate(callback),
2592
+ onActivate: (callback) => scene.onActivate(callback),
2593
+ onDeactivate: (callback) => scene.onDeactivate(callback),
2594
+ onDispose: (callback) => scene.onDispose(callback)
2595
+ })));
2596
+ this.assertAlive();
2597
+ await this.present(scene, nodes, canvas);
2598
+ } catch (error) {
2599
+ try {
2600
+ scene.dispose();
2601
+ } catch (cleanupError) {
2602
+ this.report(cleanupError);
2603
+ }
2604
+ throw error;
2605
+ } finally {
2606
+ if (this.staging === scene) this.staging = void 0;
2607
+ }
2608
+ }).finally(() => {
2609
+ if (this.pendingScenes.get(id) === task) this.pendingScenes.delete(id);
2610
+ });
2611
+ this.pendingScenes.set(id, task);
2612
+ this.queue = task.catch(() => {});
2613
+ return task;
2614
+ }
2615
+ async present(scene, nodes, canvas) {
2616
+ let prepared, ui, committed = false;
2617
+ let canvasPresentation;
2618
+ try {
2619
+ const preparing = this.renderer.prepare(scene);
2620
+ preparing.then((value) => {
2621
+ if (this.dead || scene.disposed) try {
2622
+ value.dispose();
2623
+ } catch (error) {
2624
+ this.report(error);
2625
+ }
2626
+ }, () => {});
2627
+ prepared = await this.wait(preparing);
2628
+ this.assertAlive();
2629
+ ui = mountUI(nodes, this.report);
2630
+ scene.onDispose(ui.dispose);
2631
+ if (canvas) {
2632
+ canvas.setErrorHandler(this.report);
2633
+ const renderer = this.renderer;
2634
+ if (!renderer.prepareCanvasUI) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support Canvas UI");
2635
+ canvasPresentation = renderer.prepareCanvasUI(canvas, this.pointers.routing);
2636
+ scene.onDispose(() => canvasPresentation?.dispose());
2637
+ }
2638
+ scene.markReady();
2639
+ this.errorBox.hidden = true;
2640
+ try {
2641
+ if (this.playback && this.playbackScene !== scene) this.stopReplay();
2642
+ this.engine.activate(scene);
2643
+ } catch (error) {
2644
+ if (this.engine.scene !== scene || scene.state !== SceneState.Active) throw error;
2645
+ this.report(error);
2646
+ }
2647
+ this.assertAlive();
2648
+ const oldPrepared = this.prepared, oldUI = this.ui, oldCanvas = this.canvasPresentation;
2649
+ this.prepared = prepared;
2650
+ this.ui = ui;
2651
+ this.canvas = canvas;
2652
+ this.canvasPresentation = canvasPresentation;
2653
+ this.pointers.routing.setScene(this.channelFor(scene), canvas);
2654
+ committed = true;
2655
+ this.element.append(ui.element);
2656
+ for (const cleanup of [
2657
+ () => oldPrepared?.dispose(),
2658
+ () => oldUI?.dispose(),
2659
+ () => oldCanvas?.dispose()
2660
+ ]) try {
2661
+ cleanup();
2662
+ } catch (error) {
2663
+ this.report(error);
2664
+ }
2665
+ this.assertAlive();
2666
+ this.previousTime = void 0;
2667
+ this.fixed?.reset();
2668
+ this.updateProjection();
2669
+ this.render();
2670
+ this.assertAlive();
2671
+ } catch (error) {
2672
+ if (!committed) for (const cleanup of [
2673
+ () => prepared?.dispose(),
2674
+ () => ui?.dispose(),
2675
+ () => canvasPresentation?.dispose()
2676
+ ]) try {
2677
+ cleanup();
2678
+ } catch (cleanupError) {
2679
+ this.report(cleanupError);
2680
+ }
2681
+ if (committed) this.dispose();
2682
+ throw error;
2683
+ }
2684
+ }
2685
+ get viewport() {
2686
+ this.updateProjection();
2687
+ return this.projection.viewport;
2688
+ }
2689
+ get view() {
2690
+ this.updateProjection();
2691
+ return this.projection;
2692
+ }
2693
+ setViewport(viewport) {
2694
+ this.assertAlive();
2695
+ const next = viewport ? { ...viewport } : void 0;
2696
+ createViewTransform({
2697
+ viewport: next ?? {
2698
+ x: 0,
2699
+ y: 0,
2700
+ width: this.width,
2701
+ height: this.height
2702
+ },
2703
+ resolution: this.resolution,
2704
+ aspect: this.aspect,
2705
+ camera: this.camera
2706
+ });
2707
+ this.requestedViewport = next;
2708
+ this.projectionDirty = true;
2709
+ this.updateProjection();
2710
+ if (this.ready) this.render();
2711
+ }
2712
+ worldToScreen(point) {
2713
+ return worldToScreen(this.view, point);
2714
+ }
2715
+ screenToWorld(point) {
2716
+ return screenToWorld(this.view, point);
2717
+ }
2718
+ pointerToWorld(pointer) {
2719
+ const rect = this.element.getBoundingClientRect();
2720
+ if (rect.width <= 0 || rect.height <= 0) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
2721
+ return this.screenToWorld(screenPoint((pointer.clientX - rect.left) * this.width / rect.width, (pointer.clientY - rect.top) * this.height / rect.height));
2722
+ }
2723
+ async whenReady() {
2724
+ if (!this.startPromise) throw new EngineError("GAME_NOT_READY", "Call run before waiting for resources");
2725
+ await this.startPromise;
2726
+ this.assertAlive();
2727
+ const prepared = this.prepared;
2728
+ this.render();
2729
+ await this.wait(prepared?.ready?.() ?? Promise.resolve());
2730
+ this.assertAlive();
2731
+ if (prepared !== this.prepared) throw new EngineError("GAME_DISPOSED", "Scene presentation changed while waiting for resources");
2732
+ this.render();
2733
+ }
2734
+ updateProjection() {
2735
+ const v = this.requestedViewport, c = this.camera;
2736
+ if (!this.projectionDirty && this.cameraX === c.x && this.cameraY === c.y && this.cameraZoom === c.zoom) return;
2737
+ this.projectionDirty = false;
2738
+ this.cameraX = c.x;
2739
+ this.cameraY = c.y;
2740
+ this.cameraZoom = c.zoom;
2741
+ this.projection = createViewTransform({
2742
+ viewport: v ?? {
2743
+ x: 0,
2744
+ y: 0,
2745
+ width: this.width,
2746
+ height: this.height
2747
+ },
2748
+ resolution: this.resolution,
2749
+ aspect: this.aspect,
2750
+ camera: c
2751
+ });
2752
+ }
2753
+ resize() {
2754
+ if (this.dead || !this.rendererReady) return;
2755
+ const width = this.element.clientWidth, height = this.element.clientHeight;
2756
+ if (width <= 0 || height <= 0) return;
2757
+ const ratio = this.options.pixelRatio ?? Math.min(window.devicePixelRatio || 1, 2);
2758
+ if (this.rendererSized && this.width === width && this.height === height && this.ratio === ratio) return;
2759
+ this.rendererSized = true;
2760
+ if (this.width !== width || this.height !== height) this.projectionDirty = true;
2761
+ this.width = width;
2762
+ this.height = height;
2763
+ this.ratio = ratio;
2764
+ this.renderer.resize(width, height, this.ratio);
2765
+ this.updateProjection();
2766
+ if (this.ready) this.render();
2767
+ }
2768
+ render() {
2769
+ this.updateProjection();
2770
+ this.canvasPresentation?.update(this.width, this.height);
2771
+ this.prepared?.render(this.projection);
2772
+ }
2773
+ tick = (time) => {
2774
+ if (this.dead) return;
2775
+ try {
2776
+ if (document.hidden) {
2777
+ this.canvas?.stopMotion();
2778
+ this.previousTime = void 0;
2779
+ this.fixed?.reset();
2780
+ this.engine.input.clear();
2781
+ } else {
2782
+ const elapsed = this.previousTime === void 0 ? 0 : Math.max(0, (time - this.previousTime) / 1e3);
2783
+ this.previousTime = time;
2784
+ if (this.playback) this.playback.advance(elapsed, this.options.fixedStep?.maxSteps ?? 8);
2785
+ else if (this.fixed) this.fixed.advance(elapsed);
2786
+ else this.engine.step(Math.min(this.maxDelta, elapsed));
2787
+ if (!this.dead && !this.engine.scene.disposed) this.engine.scene.advancePresentation(Math.min(this.maxDelta, elapsed));
2788
+ if (this.engine.paused) this.canvas?.stopMotion();
2789
+ else this.canvas?.advance(elapsed);
2790
+ if (this.ratio !== (this.options.pixelRatio ?? Math.min(window.devicePixelRatio || 1, 2))) this.resize();
2791
+ this.render();
2792
+ }
2793
+ if (!this.dead) this.frame = this.frames.request(this.tick);
2794
+ } catch (error) {
2795
+ this.report(error);
2796
+ this.dispose();
2797
+ }
2798
+ };
2799
+ report = (error) => {
2800
+ this.errorBox.textContent = error instanceof Error ? error.message : String(error);
2801
+ this.errorBox.hidden = false;
2802
+ try {
2803
+ if (this.options.onError) this.options.onError(error);
2804
+ else console.error(error);
2805
+ } catch {}
2806
+ };
2807
+ dispose() {
2808
+ if (this.dead) return;
2809
+ this.dead = true;
2810
+ this.shutdown.abort();
2811
+ if (this.frame !== void 0) this.frames.cancel(this.frame);
2812
+ for (const cleanup of [
2813
+ () => this.stopReplay(),
2814
+ () => this.staging?.dispose(),
2815
+ () => this.pointers.dispose(),
2816
+ () => this.keyboard.dispose(),
2817
+ () => this.canvasPresentation?.dispose(),
2818
+ () => this.canvas?.dispose(),
2819
+ () => this.observer.disconnect(),
2820
+ () => this.ui?.dispose(),
2821
+ () => this.prepared?.dispose(),
2822
+ () => this.renderer.dispose(),
2823
+ () => this.engine.dispose(),
2824
+ () => this.element.remove()
2825
+ ]) try {
2826
+ cleanup();
2827
+ } catch (error) {
2828
+ this.report(error);
2829
+ }
2830
+ this.ui = void 0;
2831
+ this.prepared = void 0;
2832
+ this.staging = void 0;
2833
+ this.canvas = void 0;
2834
+ this.canvasPresentation = void 0;
2835
+ this.defaultCanvas = void 0;
2836
+ this.defaultUI.length = 0;
2837
+ this.scenes.clear();
2838
+ this.pendingScenes.clear();
2839
+ }
2840
+ };
2841
+ export { BrowserInput, Button, CanvasButton, Game, HoldButton, Label, Layout, Panel, PixiRenderer, Placement, Theme, Themes, browserAbortSignal, mountUI };
2842
+
2843
+ //# sourceMappingURL=index.js.map