@simpludify/web 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
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";
2
+ import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, EventBus, FixedStepRunner, GeometryTilemapLayer, Lifetime, PointerRouting, Renderable, ReplayPlayer, Scene, ScenePointer, SceneState, Shape, TilemapLayer, Transform, canvasControlText, canvasHoverHighlight, canvasOpacity, canvasScrollbars, canvasSliderGeometry, cleanupAll, containsScreenPoint, createViewTransform, defineEvent, imageSize, intersectUI, isKeyCode, positive, screenPoint, screenToWorld, spriteFrameBounds, worldToScreen, wrapCanvasText } from "@simpludify/core";
3
+ import { Application, CanvasTextMetrics, Container, Graphics, Rectangle, RendererType, Sprite, Text, TextStyle, Texture } from "pixi.js";
4
4
  export * from "@simpludify/core";
5
5
  // packages/web/src/canvas-node.ts
6
6
  /** Bridge owner cancellation to browser APIs such as fetch. */
@@ -17,6 +17,415 @@ function CanvasButton(text, click, style = {}) {
17
17
  return click(signal);
18
18
  }, style);
19
19
  }
20
+ // packages/web/src/gamepad-bindings.ts
21
+ /** Standard mapping physical positions, not product-specific printed labels. */
22
+ var GamepadButton = Object.freeze({
23
+ South: 0,
24
+ East: 1,
25
+ West: 2,
26
+ North: 3,
27
+ LeftShoulder: 4,
28
+ RightShoulder: 5,
29
+ LeftTrigger: 6,
30
+ RightTrigger: 7,
31
+ Select: 8,
32
+ Start: 9,
33
+ LeftStick: 10,
34
+ RightStick: 11,
35
+ DPadUp: 12,
36
+ DPadDown: 13,
37
+ DPadLeft: 14,
38
+ DPadRight: 15,
39
+ Home: 16
40
+ });
41
+ var GamepadAxis = Object.freeze({
42
+ LeftX: 0,
43
+ LeftY: 1,
44
+ RightX: 2,
45
+ RightY: 3
46
+ });
47
+ var axis = (index) => Object.freeze({ axis: index });
48
+ /** Ready-to-use data; game Actions retain application-defined names. */
49
+ var GamepadBindings = Object.freeze({
50
+ LeftStick: Object.freeze({
51
+ x: axis(0),
52
+ y: axis(1)
53
+ }),
54
+ RightStick: Object.freeze({
55
+ x: axis(2),
56
+ y: axis(3)
57
+ }),
58
+ DPad: Object.freeze({
59
+ x: Object.freeze({
60
+ negative: Object.freeze([14]),
61
+ positive: Object.freeze([15])
62
+ }),
63
+ y: Object.freeze({
64
+ negative: Object.freeze([12]),
65
+ positive: Object.freeze([13])
66
+ })
67
+ }),
68
+ Move: Object.freeze({
69
+ x: Object.freeze({
70
+ axis: 0,
71
+ negative: Object.freeze([14]),
72
+ positive: Object.freeze([15])
73
+ }),
74
+ y: Object.freeze({
75
+ axis: 1,
76
+ negative: Object.freeze([12]),
77
+ positive: Object.freeze([13])
78
+ })
79
+ })
80
+ });
81
+ var fail = () => {
82
+ throw new EngineError("INVALID_GAMEPAD_BINDING", "Invalid gamepad binding");
83
+ };
84
+ function gamepadIndex(value) {
85
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return fail();
86
+ return value;
87
+ }
88
+ function gamepadDeadZone(value) {
89
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value >= 1) return fail();
90
+ return value;
91
+ }
92
+ function record(value, keys) {
93
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((k) => !keys.includes(k))) return fail();
94
+ return Object.fromEntries(Object.entries(value));
95
+ }
96
+ function axisBinding(value) {
97
+ const v = record(value, [
98
+ "axis",
99
+ "invert",
100
+ "negative",
101
+ "positive",
102
+ "deadZone"
103
+ ]);
104
+ const indices = (value) => {
105
+ if (value === void 0) return Object.freeze([]);
106
+ if (!Array.isArray(value)) return fail();
107
+ return Object.freeze([...new Set(value.map(gamepadIndex))]);
108
+ };
109
+ if (v.invert !== void 0 && typeof v.invert !== "boolean") return fail();
110
+ return Object.freeze({
111
+ ...v.axis === void 0 ? {} : { axis: gamepadIndex(v.axis) },
112
+ invert: v.invert ?? false,
113
+ negative: indices(v.negative),
114
+ positive: indices(v.positive),
115
+ ...v.deadZone === void 0 ? {} : { deadZone: gamepadDeadZone(v.deadZone) }
116
+ });
117
+ }
118
+ function checkedGamepadBindings(actions, value) {
119
+ const v = record(value, Object.keys(actions));
120
+ return Object.freeze(Object.entries(v).map(([name, binding]) => {
121
+ if (!Object.hasOwn(actions, name)) return fail();
122
+ const action = actions[name];
123
+ if (!action) return fail();
124
+ let result;
125
+ if (action.kind === "button") {
126
+ const b = record(binding, ["buttons"]);
127
+ if (b.buttons !== void 0 && !Array.isArray(b.buttons)) return fail();
128
+ result = Object.freeze({ buttons: Object.freeze((b.buttons ?? []).map((item) => {
129
+ if (typeof item === "number") return gamepadIndex(item);
130
+ const b = record(item, ["button", "threshold"]), threshold = b.threshold ?? .5;
131
+ if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold <= 0 || threshold > 1) return fail();
132
+ return Object.freeze({
133
+ button: gamepadIndex(b.button),
134
+ threshold
135
+ });
136
+ })) });
137
+ } else if (action.kind === "axis1d") result = axisBinding(binding);
138
+ else {
139
+ const keys = action.kind === "axis2d" ? ["x", "y"] : [
140
+ "x",
141
+ "y",
142
+ "z"
143
+ ], b = record(binding, keys);
144
+ result = Object.freeze(Object.fromEntries(keys.map((k) => [k, axisBinding(b[k] ?? {})])));
145
+ }
146
+ return Object.freeze([action, result]);
147
+ }));
148
+ }
149
+ // packages/web/src/gamepad-layout.ts
150
+ var positions = Object.fromEntries(Object.entries(GamepadButton).map(([name, index]) => [index, name]));
151
+ var faces = Object.freeze({
152
+ generic: Object.freeze([
153
+ "South",
154
+ "East",
155
+ "West",
156
+ "North"
157
+ ]),
158
+ xbox: Object.freeze([
159
+ "A",
160
+ "B",
161
+ "X",
162
+ "Y"
163
+ ]),
164
+ playstation: Object.freeze([
165
+ "×",
166
+ "○",
167
+ "□",
168
+ "△"
169
+ ]),
170
+ nintendo: Object.freeze([
171
+ "B",
172
+ "A",
173
+ "Y",
174
+ "X"
175
+ ])
176
+ });
177
+ function gamepadButtonLabel(button, options = {}) {
178
+ gamepadIndex(button);
179
+ const layout = options.layout ?? "generic";
180
+ if (!Object.hasOwn(faces, layout)) throw new EngineError("INVALID_GAMEPAD_BINDING", "Unknown gamepad display layout");
181
+ if (options.overrides && Object.hasOwn(options.overrides, button)) {
182
+ const label = options.overrides[button];
183
+ if (typeof label !== "string") throw new EngineError("INVALID_GAMEPAD_BINDING", "Button label must be a string");
184
+ return label;
185
+ }
186
+ return faces[layout][button] ?? positions[button] ?? `Button ${button}`;
187
+ }
188
+ function inferredGamepadLayout(id) {
189
+ if (/dualsense|dualshock|playstation|054c/i.test(id)) return "playstation";
190
+ if (/nintendo|switch|057e/i.test(id)) return "nintendo";
191
+ if (/xbox|xinput|045e/i.test(id)) return "xbox";
192
+ return "generic";
193
+ }
194
+ // packages/web/src/gamepads.ts
195
+ var finite = (v, min, max) => typeof v === "number" && Number.isFinite(v) ? Math.max(min, Math.min(max, v)) : 0;
196
+ var buttonValue = (pad, index) => finite(pad.buttons[index]?.value, 0, 1);
197
+ function scalar(pad, binding, deadZone) {
198
+ const group = (indices) => (indices ?? []).reduce((maximum, index) => Math.max(maximum, buttonValue(pad, index)), 0);
199
+ const raw = finite((binding.axis === void 0 ? 0 : finite(pad.axes[binding.axis], -1, 1)) + group(binding.positive) - group(binding.negative), -1, 1) * (binding.invert ? -1 : 1);
200
+ const zone = binding.deadZone ?? deadZone;
201
+ return Math.abs(raw) <= zone ? 0 : Math.sign(raw) * (Math.abs(raw) - zone) / (1 - zone);
202
+ }
203
+ function values(pad, bindings, zone) {
204
+ return bindings.map(([action, binding]) => {
205
+ if (action.kind === "button") return [action, binding.buttons?.some((b) => buttonValue(pad, typeof b === "number" ? b : b.button) >= (typeof b === "number" ? .5 : b.threshold ?? .5)) ?? false];
206
+ if (action.kind === "axis1d") return [action, scalar(pad, binding, zone)];
207
+ const b = binding, x = scalar(pad, b.x ?? {}, zone), y = scalar(pad, b.y ?? {}, zone);
208
+ return [action, action.kind === "axis2d" ? {
209
+ x,
210
+ y
211
+ } : {
212
+ x,
213
+ y,
214
+ z: scalar(pad, b.z ?? {}, zone)
215
+ }];
216
+ });
217
+ }
218
+ function neutral(value) {
219
+ return typeof value === "boolean" ? !value : typeof value === "number" ? value === 0 : value.x === 0 && value.y === 0 && (value.z ?? 0) === 0;
220
+ }
221
+ /** A controller-owned device contribution. Reassignment does not replace its readers/Actors. */
222
+ var GamepadSource = class {
223
+ input;
224
+ controller;
225
+ life = new Lifetime();
226
+ source;
227
+ bindings;
228
+ assigned;
229
+ armed = false;
230
+ signature;
231
+ zone;
232
+ mapping;
233
+ constructor(input, controller, options) {
234
+ this.input = input;
235
+ this.controller = controller;
236
+ input.assertController(controller);
237
+ this.assigned = gamepadIndex(options.index);
238
+ this.zone = gamepadDeadZone(options.deadZone ?? .15);
239
+ this.mapping = options.mapping ?? "standard";
240
+ if (this.mapping !== "standard" && this.mapping !== "raw") throw new EngineError("INVALID_GAMEPAD_BINDING", "Unknown gamepad mapping policy");
241
+ this.bindings = checkedGamepadBindings(controller.actions, options.bindings);
242
+ this.source = controller.createSource();
243
+ this.life.onDispose(() => this.source.dispose());
244
+ this.life.onDispose(controller.onDispose(() => this.dispose()));
245
+ }
246
+ get disposed() {
247
+ return this.life.disposed;
248
+ }
249
+ get enabled() {
250
+ return this.source.enabled;
251
+ }
252
+ set enabled(value) {
253
+ this.alive();
254
+ if (value === this.source.enabled) return;
255
+ this.source.enabled = value;
256
+ this.clear();
257
+ }
258
+ get index() {
259
+ return this.assigned;
260
+ }
261
+ get info() {
262
+ return this.input.devices.find((d) => d.index === this.assigned);
263
+ }
264
+ get connected() {
265
+ return this.info?.connected ?? false;
266
+ }
267
+ get usable() {
268
+ return this.connected && (this.mapping === "raw" || this.info?.standardMapping === true);
269
+ }
270
+ assign(index) {
271
+ this.alive();
272
+ const checked = gamepadIndex(index);
273
+ if (checked === this.assigned) return;
274
+ this.clear();
275
+ this.signature = void 0;
276
+ this.assigned = checked;
277
+ }
278
+ rebind(bindings) {
279
+ this.alive();
280
+ const checked = checkedGamepadBindings(this.controller.actions, bindings);
281
+ this.clear();
282
+ this.bindings = checked;
283
+ }
284
+ clear() {
285
+ this.source.clear();
286
+ this.armed = false;
287
+ }
288
+ alive() {
289
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Gamepad source has been disposed");
290
+ }
291
+ /** @internal */ sample(pad, allowed) {
292
+ if (this.disposed) return;
293
+ if (!allowed || !this.enabled || !this.controller.active || !pad || this.mapping === "standard" && pad.mapping !== "standard") {
294
+ this.clear();
295
+ this.signature = void 0;
296
+ return;
297
+ }
298
+ const signature = JSON.stringify([
299
+ pad.id,
300
+ pad.mapping,
301
+ pad.buttons.length,
302
+ pad.axes.length
303
+ ]);
304
+ if (signature !== this.signature) {
305
+ this.clear();
306
+ this.signature = signature;
307
+ }
308
+ const next = values(pad, this.bindings, this.zone);
309
+ if (!this.armed) {
310
+ if (next.every(([, value]) => neutral(value))) this.armed = true;
311
+ return;
312
+ }
313
+ for (const [action, value] of next) this.source.set(action, value);
314
+ }
315
+ /** @internal */ onDispose(callback) {
316
+ return this.life.onDispose(callback);
317
+ }
318
+ dispose() {
319
+ this.life.dispose();
320
+ }
321
+ };
322
+ var devicesChanged = defineEvent("GamepadsChanged");
323
+ /** One Web host adapter; multiple sources share a single snapshot per Engine input boundary. */
324
+ var GamepadInput = class {
325
+ input;
326
+ life = new Lifetime();
327
+ changes = new EventBus();
328
+ sources = /* @__PURE__ */ new Set();
329
+ disconnected = /* @__PURE__ */ new Set();
330
+ listed = Object.freeze([]);
331
+ readable = false;
332
+ poll;
333
+ active;
334
+ constructor(input, options = {}) {
335
+ this.input = input;
336
+ this.life.onDispose(() => this.changes.dispose());
337
+ this.poll = options.poll ?? (() => typeof navigator !== "undefined" && typeof navigator.getGamepads === "function" ? navigator.getGamepads() : (() => {
338
+ throw new Error("Gamepad API unavailable");
339
+ })());
340
+ this.active = options.active ?? (() => true);
341
+ this.life.onDispose(input.addAdapter(this));
342
+ const events = options.events ?? (typeof window === "undefined" ? void 0 : window), abort = new AbortController();
343
+ this.life.onDispose(() => abort.abort());
344
+ events?.addEventListener("gamepaddisconnected", (event) => {
345
+ const index = event.gamepad.index;
346
+ this.disconnected.add(index);
347
+ for (const source of this.sources) if (source.index === index) source.clear();
348
+ this.publish(this.listed.filter((d) => d.index !== index));
349
+ }, { signal: abort.signal });
350
+ events?.addEventListener("gamepadconnected", (event) => {
351
+ const index = event.gamepad.index;
352
+ this.disconnected.delete(index);
353
+ for (const source of this.sources) if (source.index === index) source.clear();
354
+ }, { signal: abort.signal });
355
+ }
356
+ get disposed() {
357
+ return this.life.disposed;
358
+ }
359
+ /** Whether the most recent polling attempt completed; an empty list may still need a user gesture. */
360
+ get available() {
361
+ return this.readable;
362
+ }
363
+ get devices() {
364
+ return this.listed;
365
+ }
366
+ onChange(handler, owner) {
367
+ return this.changes.on(devicesChanged, handler, owner);
368
+ }
369
+ publish(devices, force = false) {
370
+ const changed = JSON.stringify(this.listed) !== JSON.stringify(devices);
371
+ this.listed = Object.freeze(devices);
372
+ if (changed || force) this.changes.emit(devicesChanged, void 0);
373
+ }
374
+ /** @internal */ assertController(controller) {
375
+ if (!this.input.hasController(controller)) throw new EngineError("INVALID_ACTION", "Gamepad controller must belong to this InputSystem");
376
+ }
377
+ bind(controller, options) {
378
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Gamepad input has been disposed");
379
+ const source = new GamepadSource(this, controller, options);
380
+ const entry = source;
381
+ this.sources.add(entry);
382
+ const remove = this.life.onDispose(() => source.dispose());
383
+ source.onDispose(() => {
384
+ this.sources.delete(entry);
385
+ remove();
386
+ });
387
+ return source;
388
+ }
389
+ sample() {
390
+ if (this.disposed) return;
391
+ const wasReadable = this.readable;
392
+ let snapshots;
393
+ try {
394
+ snapshots = this.poll();
395
+ if (!Array.isArray(snapshots)) throw new Error("Invalid gamepad snapshot");
396
+ this.readable = true;
397
+ } catch {
398
+ snapshots = [];
399
+ this.readable = false;
400
+ }
401
+ const pads = /* @__PURE__ */ new Map();
402
+ for (const pad of snapshots) if (pad?.connected && typeof pad.id === "string" && typeof pad.mapping === "string" && Array.isArray(pad.axes) && Array.isArray(pad.buttons) && Number.isSafeInteger(pad.index) && pad.index >= 0) pads.set(pad.index, pad);
403
+ for (const index of this.disconnected) if (!pads.has(index)) this.disconnected.delete(index);
404
+ for (const index of this.disconnected) pads.delete(index);
405
+ const listed = Object.freeze([...pads.values()].sort((a, b) => a.index - b.index).map((p) => Object.freeze({
406
+ id: p.id,
407
+ index: p.index,
408
+ connected: p.connected,
409
+ mapping: p.mapping,
410
+ standardMapping: p.mapping === "standard",
411
+ buttonCount: p.buttons.length,
412
+ axisCount: p.axes.length,
413
+ inferredLayout: inferredGamepadLayout(p.id)
414
+ })));
415
+ const allowed = this.active();
416
+ for (const source of this.sources) if (source.disposed) this.sources.delete(source);
417
+ else source.sample(pads.get(source.index), allowed);
418
+ this.publish(listed, wasReadable !== this.readable);
419
+ }
420
+ reset() {
421
+ for (const source of this.sources) source.clear();
422
+ }
423
+ dispose() {
424
+ this.life.dispose();
425
+ this.sources.clear();
426
+ this.listed = Object.freeze([]);
427
+ }
428
+ };
20
429
  // packages/web/src/ui-keyboard.ts
21
430
  /** Host-only ownership metadata; native editing is never reconstructed from game keys. */
22
431
  var gameButtons = /* @__PURE__ */ new WeakSet();
@@ -459,7 +868,8 @@ function nativeGeometry(el, node, ui, clip) {
459
868
  el.disabled = !node.available || c.width <= 0 || c.height <= 0 || node.style.pointerEvents !== "auto";
460
869
  const background = style.background ?? 2316622;
461
870
  const color = `#${style.color.toString(16).padStart(6, "0")}`;
462
- 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);`;
871
+ el.style.cssText = `left:${b.x * scale}px;top:${b.y * scale}px;width:${b.width * scale}px;height:${b.height * scale}px;padding:${style.padding * scale}px;font-size:${style.fontSize * scale}px;--s-native-color:${color};--s-native-background:#${background.toString(16).padStart(6, "0")};color-scheme:${(background >> 16) * .2126 + (background >> 8 & 255) * .7152 + (background & 255) * .0722 > 128 ? "light" : "dark"};clip-path:inset(${Math.max(0, c.y - b.y) * scale}px ${Math.max(0, b.x + b.width - c.x - c.width) * scale}px ${Math.max(0, b.y + b.height - c.y - c.height) * scale}px ${Math.max(0, c.x - b.x) * scale}px);`;
872
+ el.style.fontFamily = style.fontFamily;
463
873
  if (el.disabled && document.activeElement === el) el.blur();
464
874
  }
465
875
  /** Native editors must not cover later Canvas hit surfaces or overlay scrollbars. */
@@ -527,6 +937,7 @@ var CanvasVisual = class {
527
937
  dirty = true;
528
938
  dead = false;
529
939
  active = false;
940
+ cacheResolution;
530
941
  mirroredFocus;
531
942
  subscriptions = [];
532
943
  syncs = 0;
@@ -581,6 +992,7 @@ var CanvasVisual = class {
581
992
  get diagnostics() {
582
993
  return {
583
994
  nodes: this.entries.size,
995
+ visibleNodes: [...this.entries.values()].filter((e) => e.display.visible).length,
584
996
  buttons: [...this.entries.values()].filter((e) => e.button).length,
585
997
  syncs: this.syncs,
586
998
  textMetrics: this.metrics.size
@@ -624,7 +1036,14 @@ var CanvasVisual = class {
624
1036
  cache.values.set(width, result);
625
1037
  return result;
626
1038
  };
1039
+ clearCache() {
1040
+ if (this.cacheResolution === void 0) return;
1041
+ this.display.cacheAsTexture(false);
1042
+ this.cacheResolution = void 0;
1043
+ }
627
1044
  beforeResolutionChange() {
1045
+ this.clearCache();
1046
+ this.dirty = true;
628
1047
  for (const entry of this.entries.values()) if (entry.text && !entry.text.destroyed) this.resources.changedText(entry.text.styleKey);
629
1048
  }
630
1049
  focus(node) {
@@ -753,6 +1172,7 @@ var CanvasVisual = class {
753
1172
  const visited = /* @__PURE__ */ new Set(), buttons = [];
754
1173
  const paint = [];
755
1174
  let order = 0;
1175
+ let scrollVisible = false;
756
1176
  let restoreNativeFocus;
757
1177
  this.ui.walk((node, parentClip) => {
758
1178
  const measuredText = node.kind === "panel" ? void 0 : this.measure(node, Math.max(0, node.bounds.width - node.style.padding * 2));
@@ -777,6 +1197,19 @@ var CanvasVisual = class {
777
1197
  entry.button = entry.native.element;
778
1198
  } else entry.button = this.button(node);
779
1199
  this.element.append(entry.button);
1200
+ } else if (node.kind === "label") {
1201
+ entry.label = document.createElement("span");
1202
+ entry.label.className = "s-canvas-label";
1203
+ const semantics = node.labelSemantics;
1204
+ if (semantics?.role) {
1205
+ entry.label.setAttribute("role", semantics.role);
1206
+ if (semantics.role === "heading") entry.label.setAttribute("aria-level", String(semantics.level));
1207
+ else {
1208
+ entry.label.setAttribute("aria-live", semantics.role === "alert" ? "assertive" : "polite");
1209
+ entry.label.setAttribute("aria-atomic", "true");
1210
+ }
1211
+ }
1212
+ this.element.append(entry.label);
780
1213
  }
781
1214
  if (node.kind !== "panel") {
782
1215
  entry.text = new Text({ text: node.text });
@@ -802,7 +1235,14 @@ var CanvasVisual = class {
802
1235
  } : {}
803
1236
  });
804
1237
  if (entry.button) buttons.push(entry.button);
1238
+ if (entry.label) {
1239
+ buttons.push(entry.label);
1240
+ if (entry.label.textContent !== node.text) entry.label.textContent = node.text;
1241
+ entry.label.setAttribute("aria-hidden", String(canvasOpacity(node) === 0));
1242
+ }
805
1243
  const b = node.bounds, s = node.style;
1244
+ const scrollBox = intersectUI(b, clip);
1245
+ if (node.style.scroll && scrollBox.width > 0 && scrollBox.height > 0 && canvasOpacity(node) > 0) scrollVisible = true;
806
1246
  let enabled = !node.busy;
807
1247
  for (let p = node; p; p = p.parent) enabled &&= p.enabled;
808
1248
  const signature = JSON.stringify([
@@ -823,9 +1263,12 @@ var CanvasVisual = class {
823
1263
  if (entry.signature === signature) return;
824
1264
  entry.signature = signature;
825
1265
  entry.display.position.set(b.x, b.y);
1266
+ const paintBox = intersectUI(b, clip);
1267
+ const visible = paintBox.width > 0 && paintBox.height > 0 && canvasOpacity(node) > 0;
1268
+ entry.display.visible = visible;
826
1269
  const clipped = clip.x !== 0 || clip.y !== 0 || clip.width !== width / this.ui.scale || clip.height !== height / this.ui.scale;
827
1270
  entry.display.mask = clipped ? entry.mask : null;
828
- entry.mask.renderable = clipped;
1271
+ entry.mask.renderable = clipped && visible;
829
1272
  const maskSignature = JSON.stringify([
830
1273
  clipped,
831
1274
  clip.x,
@@ -927,7 +1370,8 @@ var CanvasVisual = class {
927
1370
  const display = entry.scrollbar;
928
1371
  if (!display) return;
929
1372
  this.display.setChildIndex(display, order++);
930
- display.visible = bars.length > 0;
1373
+ const viewport = intersectUI(node.bounds, clip);
1374
+ display.visible = bars.length > 0 && viewport.width > 0 && viewport.height > 0 && canvasOpacity(node) > 0;
931
1375
  display.mask = entry.display.mask;
932
1376
  const signature = JSON.stringify([
933
1377
  node.style.color,
@@ -982,6 +1426,14 @@ var CanvasVisual = class {
982
1426
  const button = focused ? this.entries.get(focused)?.button : void 0;
983
1427
  if ((focused !== this.mirroredFocus || focused === restoreNativeFocus) && button && !button.disabled && document.activeElement !== button) button.focus({ preventScroll: true });
984
1428
  this.mirroredFocus = this.ui.focusedNode;
1429
+ const ratio = this.resources.cacheResolution();
1430
+ const cache = scrollVisible && ratio !== void 0 && Math.max(width, height) * ratio <= 4096 ? ratio * this.ui.scale : void 0;
1431
+ if (cache !== this.cacheResolution) {
1432
+ this.clearCache();
1433
+ this.cacheResolution = cache;
1434
+ if (cache !== void 0) this.display.cacheAsTexture({ resolution: cache });
1435
+ }
1436
+ if (cache !== void 0) this.display.updateCacheTexture();
985
1437
  }
986
1438
  releaseEntry(entry) {
987
1439
  const backgroundContext = entry.background.context?.uid, maskContext = entry.mask.context?.uid;
@@ -991,6 +1443,7 @@ var CanvasVisual = class {
991
1443
  () => entry.abort.abort(),
992
1444
  () => entry.native?.dispose(),
993
1445
  () => entry.button?.remove(),
1446
+ () => entry.label?.remove(),
994
1447
  () => entry.background.destroy({ context: true }),
995
1448
  () => entry.scrollbar?.destroy({ context: true }),
996
1449
  () => entry.text?.destroy({ style: true }),
@@ -1015,6 +1468,7 @@ var CanvasVisual = class {
1015
1468
  cleanupAll([
1016
1469
  ...this.subscriptions.splice(0),
1017
1470
  ...entries.map((e) => () => this.releaseEntry(e)),
1471
+ () => this.clearCache(),
1018
1472
  () => this.display.destroy({ children: true }),
1019
1473
  () => this.element.remove()
1020
1474
  ], "Canvas visual cleanup failed");
@@ -1462,10 +1916,11 @@ var PixiRenderer = class {
1462
1916
  pendingUITextKeys = /* @__PURE__ */ new Set();
1463
1917
  canvasPresentations = /* @__PURE__ */ new Set();
1464
1918
  get canvasDiagnostics() {
1465
- let nodes = 0, buttons = 0, syncs = 0, textMetrics = 0;
1919
+ let nodes = 0, visibleNodes = 0, buttons = 0, syncs = 0, textMetrics = 0;
1466
1920
  for (const p of this.canvasPresentations) {
1467
1921
  const d = p.diagnostics;
1468
1922
  nodes += d.nodes;
1923
+ visibleNodes += d.visibleNodes;
1469
1924
  buttons += d.buttons;
1470
1925
  syncs += d.syncs;
1471
1926
  textMetrics += d.textMetrics;
@@ -1473,6 +1928,7 @@ var PixiRenderer = class {
1473
1928
  return {
1474
1929
  presentations: this.canvasPresentations.size,
1475
1930
  nodes,
1931
+ visibleNodes,
1476
1932
  buttons,
1477
1933
  syncs,
1478
1934
  textMetrics
@@ -1483,7 +1939,8 @@ var PixiRenderer = class {
1483
1939
  const visual = new CanvasVisual(ui, this.host, routing, {
1484
1940
  graphics: (graphics, context) => releaseUIGraphics(this.app.renderer, graphics, context),
1485
1941
  text: (text, key) => releaseUIText(this.app.renderer, text, key),
1486
- changedText: (key) => this.pendingUITextKeys.add(key)
1942
+ changedText: (key) => this.pendingUITextKeys.add(key),
1943
+ cacheResolution: () => this.app.renderer.type === RendererType.WEBGL ? this.app.renderer.resolution : void 0
1487
1944
  });
1488
1945
  this.canvasPresentations.add(visual);
1489
1946
  let disposed = false;
@@ -1637,7 +2094,15 @@ var PixiRenderer = class {
1637
2094
  }
1638
2095
  acquireImage(url, signal) {
1639
2096
  try {
1640
- return this.images.acquire(new URL(url, document.baseURI).href, signal);
2097
+ const parsed = new URL(url, document.baseURI);
2098
+ if (![
2099
+ "http:",
2100
+ "https:",
2101
+ "data:",
2102
+ "blob:",
2103
+ "file:"
2104
+ ].includes(parsed.protocol)) throw new Error("Unsupported image URL protocol");
2105
+ return this.images.acquire(parsed.href, signal);
1641
2106
  } catch {
1642
2107
  return Promise.reject(new EngineError("ASSET_LOAD_FAILED", "Invalid image URL", { path: url }));
1643
2108
  }
@@ -2295,6 +2760,7 @@ var Game = class {
2295
2760
  engine;
2296
2761
  element;
2297
2762
  camera;
2763
+ gamepads;
2298
2764
  renderer;
2299
2765
  keyboard;
2300
2766
  pointers;
@@ -2378,8 +2844,10 @@ var Game = class {
2378
2844
  camera: this.camera
2379
2845
  });
2380
2846
  if (options.pixelRatio !== void 0) positive(options.pixelRatio, "pixelRatio");
2381
- const theme = Themes[options.theme ?? Theme.Dark];
2382
- if (!theme) throw new EngineError("INVALID_THEME", "Unknown theme");
2847
+ const themeName = options.theme ?? Theme.Dark;
2848
+ if (!Object.hasOwn(Themes, themeName)) throw new EngineError("INVALID_THEME", "Unknown theme");
2849
+ const theme = Themes[themeName];
2850
+ for (const [name, value] of Object.entries(options.tokens ?? {})) if (!Object.hasOwn(theme, name) || typeof value !== "string" || (name === "font" ? !validFontFamily(value) : !value.trim().length || /[;{}<>\r\n\0]|url\s*\(/i.test(value) || !CSS.supports("color", value))) throw new EngineError("INVALID_THEME_TOKEN", `Invalid token: ${name}`);
2383
2851
  const tokens = {
2384
2852
  ...theme,
2385
2853
  ...options.tokens
@@ -2407,6 +2875,7 @@ var Game = class {
2407
2875
  cancel: (handle) => cancelAnimationFrame(handle)
2408
2876
  };
2409
2877
  this.keyboard = new BrowserInput(this.element, this.engine.input);
2878
+ this.gamepads = new GamepadInput(this.engine.input, { active: () => !document.hidden && document.hasFocus() && this.element.contains(document.activeElement) && !blocksGameKeyboard(document.activeElement) });
2410
2879
  this.pointers = new BrowserPointer(this.element, (event) => {
2411
2880
  const rect = this.element.getBoundingClientRect();
2412
2881
  if (rect.width <= 0 || rect.height <= 0) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
@@ -2882,6 +3351,7 @@ var Game = class {
2882
3351
  () => this.stopReplay(),
2883
3352
  () => this.staging?.dispose(),
2884
3353
  () => this.pointers.dispose(),
3354
+ () => this.gamepads.dispose(),
2885
3355
  () => this.keyboard.dispose(),
2886
3356
  () => this.canvasPresentation?.dispose(),
2887
3357
  () => this.canvas?.dispose(),
@@ -2907,6 +3377,10 @@ var Game = class {
2907
3377
  this.pendingScenes.clear();
2908
3378
  }
2909
3379
  };
2910
- export { BrowserInput, Button, CanvasButton, Game, HoldButton, Label, Layout, Panel, PixiRenderer, Placement, Theme, Themes, browserAbortSignal, mountUI };
3380
+ function validFontFamily(value) {
3381
+ const syntax = value.replace(/"(?:\\[^\r\n]|[^"\\])*"|'(?:\\[^\r\n]|[^'\\])*'/g, "family").replace(/\\(?:[0-9a-f]{1,6}[ \t]?|[^\r\n])/gi, "x");
3382
+ return value.trim().length > 0 && !/[\r\n\0]/.test(value) && !/[;{}<>\\"']|url\s*\(/i.test(syntax);
3383
+ }
3384
+ export { BrowserInput, Button, CanvasButton, Game, GamepadAxis, GamepadBindings, GamepadButton, GamepadInput, GamepadSource, HoldButton, Label, Layout, Panel, PixiRenderer, Placement, Theme, Themes, browserAbortSignal, gamepadButtonLabel, mountUI };
2911
3385
 
2912
3386
  //# sourceMappingURL=index.js.map