@simpludify/web 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./style.css";
2
- import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, 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";
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, VirtualInput, canvasControlText, canvasHoverHighlight, canvasOpacity, canvasScrollbars, canvasSliderGeometry, cleanupAll, containsScreenPoint, createViewTransform, defineEvent, imageSize, intersectUI, isKeyCode, positive, screenPoint, screenToWorld, spriteFrameBounds, worldToScreen, wrapCanvasText } from "@simpludify/core";
3
3
  import { Application, CanvasTextMetrics, Container, Graphics, Rectangle, RendererType, Sprite, Text, TextStyle, Texture } from "pixi.js";
4
4
  export * from "@simpludify/core";
5
5
  // packages/web/src/canvas-node.ts
@@ -17,6 +17,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();
@@ -180,6 +589,9 @@ var BrowserPointer = class {
180
589
  window.addEventListener("blur", (event) => {
181
590
  if (event.target === window) this.release();
182
591
  }, options);
592
+ host.addEventListener("focusout", (event) => this.guard(() => {
593
+ if (!(event.relatedTarget instanceof Node) || !host.contains(event.relatedTarget) || blocksGameKeyboard(event.relatedTarget)) this.routing.releaseVirtual();
594
+ }), options);
183
595
  document.addEventListener("visibilitychange", () => {
184
596
  if (document.hidden) this.release();
185
597
  }, options);
@@ -459,7 +871,8 @@ function nativeGeometry(el, node, ui, clip) {
459
871
  el.disabled = !node.available || c.width <= 0 || c.height <= 0 || node.style.pointerEvents !== "auto";
460
872
  const background = style.background ?? 2316622;
461
873
  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);`;
874
+ 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);`;
875
+ el.style.fontFamily = style.fontFamily;
463
876
  if (el.disabled && document.activeElement === el) el.blur();
464
877
  }
465
878
  /** Native editors must not cover later Canvas hit surfaces or overlay scrollbars. */
@@ -1497,6 +1910,108 @@ var TilemapVisual = class {
1497
1910
  ], "Tilemap presentation cleanup failed");
1498
1911
  }
1499
1912
  };
1913
+ // packages/web/src/virtual-input-visual.ts
1914
+ /** Screen-space presentation only. The existing BrowserPointer is the sole event adapter. */
1915
+ var VirtualInputVisual = class {
1916
+ input;
1917
+ release;
1918
+ display = new Container();
1919
+ entries = /* @__PURE__ */ new Map();
1920
+ dead = false;
1921
+ off;
1922
+ constructor(input, release) {
1923
+ this.input = input;
1924
+ this.release = release;
1925
+ this.display.eventMode = "none";
1926
+ this.display.sortableChildren = true;
1927
+ this.display.zIndex = .5;
1928
+ this.off = input.onDispose(() => this.dispose());
1929
+ }
1930
+ get diagnostics() {
1931
+ return {
1932
+ controls: this.entries.size,
1933
+ visible: [...this.entries.values()].filter((e) => e.graphics.visible).length
1934
+ };
1935
+ }
1936
+ update() {
1937
+ if (this.dead) return;
1938
+ const live = new Set(this.input.controls);
1939
+ for (const [control, entry] of this.entries) if (!live.has(control)) {
1940
+ this.entries.delete(control);
1941
+ this.release(entry.graphics);
1942
+ }
1943
+ this.display.visible = this.input.visible;
1944
+ for (const control of live) {
1945
+ let entry = this.entries.get(control);
1946
+ if (!entry) {
1947
+ entry = {
1948
+ graphics: new Graphics(),
1949
+ signature: ""
1950
+ };
1951
+ entry.graphics.zIndex = control.kind === "button" ? 1 : 0;
1952
+ this.entries.set(control, entry);
1953
+ this.display.addChild(entry.graphics);
1954
+ }
1955
+ entry.graphics.visible = control.visible;
1956
+ if (!control.visible || !this.input.visible) continue;
1957
+ const center = control.center, knob = control.knob, radius = control.radius;
1958
+ const signature = JSON.stringify([
1959
+ center,
1960
+ knob,
1961
+ radius,
1962
+ control.color,
1963
+ control.enabled,
1964
+ this.input.enabled,
1965
+ control.pressed,
1966
+ control.kind
1967
+ ]);
1968
+ if (signature === entry.signature) continue;
1969
+ entry.signature = signature;
1970
+ const g = entry.graphics;
1971
+ g.clear();
1972
+ g.alpha = control.enabled && this.input.enabled ? 1 : .35;
1973
+ g.circle(center.x, center.y, radius).fill({
1974
+ color: control.color,
1975
+ alpha: .12
1976
+ }).stroke({
1977
+ color: control.color,
1978
+ alpha: .75,
1979
+ width: 2
1980
+ });
1981
+ if (control.kind === "stick") {
1982
+ g.moveTo(center.x, center.y).lineTo(knob.x, knob.y).stroke({
1983
+ color: control.color,
1984
+ alpha: .5,
1985
+ width: 3
1986
+ });
1987
+ g.circle(knob.x, knob.y, radius * .36).fill({
1988
+ color: control.color,
1989
+ alpha: control.pressed ? .85 : .45
1990
+ });
1991
+ } else {
1992
+ if (control.pressed) g.circle(center.x, center.y, radius * .7).fill({
1993
+ color: control.color,
1994
+ alpha: .65
1995
+ });
1996
+ if (control.directional && control.pressed) g.moveTo(center.x, center.y).lineTo(knob.x, knob.y).stroke({
1997
+ color: control.color,
1998
+ width: 4
1999
+ });
2000
+ }
2001
+ }
2002
+ }
2003
+ dispose() {
2004
+ if (this.dead) return;
2005
+ this.dead = true;
2006
+ const entries = [...this.entries.values()];
2007
+ this.entries.clear();
2008
+ cleanupAll([
2009
+ this.off,
2010
+ ...entries.map((e) => () => this.release(e.graphics)),
2011
+ () => this.display.destroy({ children: true })
2012
+ ], "Virtual input presentation cleanup failed");
2013
+ }
2014
+ };
1500
2015
  // packages/web/src/renderer.ts
1501
2016
  var PixiRenderer = class {
1502
2017
  host;
@@ -1505,6 +2020,46 @@ var PixiRenderer = class {
1505
2020
  app = new Application();
1506
2021
  pendingUITextKeys = /* @__PURE__ */ new Set();
1507
2022
  canvasPresentations = /* @__PURE__ */ new Set();
2023
+ virtualPresentations = /* @__PURE__ */ new Set();
2024
+ get virtualInputDiagnostics() {
2025
+ let controls = 0, visible = 0;
2026
+ for (const visual of this.virtualPresentations) {
2027
+ controls += visual.diagnostics.controls;
2028
+ visible += visual.diagnostics.visible;
2029
+ }
2030
+ return {
2031
+ presentations: this.virtualPresentations.size,
2032
+ controls,
2033
+ visible
2034
+ };
2035
+ }
2036
+ prepareVirtualInput(input) {
2037
+ if (!this.ready || this.dead) throw new EngineError("RENDERER_NOT_READY", "Renderer is not available");
2038
+ const visual = new VirtualInputVisual(input, (graphics) => {
2039
+ const context = graphics.context.uid;
2040
+ graphics.destroy({ context: true });
2041
+ releaseUIGraphics(this.app.renderer, graphics, context);
2042
+ });
2043
+ this.virtualPresentations.add(visual);
2044
+ const off = input.onDispose(() => {
2045
+ this.virtualPresentations.delete(visual);
2046
+ });
2047
+ let disposed = false;
2048
+ return {
2049
+ update: () => {
2050
+ if (disposed || input.disposed) return;
2051
+ if (!visual.display.parent) this.app.stage.addChild(visual.display);
2052
+ visual.update();
2053
+ },
2054
+ dispose: () => {
2055
+ if (disposed) return;
2056
+ disposed = true;
2057
+ off();
2058
+ this.virtualPresentations.delete(visual);
2059
+ visual.dispose();
2060
+ }
2061
+ };
2062
+ }
1508
2063
  get canvasDiagnostics() {
1509
2064
  let nodes = 0, visibleNodes = 0, buttons = 0, syncs = 0, textMetrics = 0;
1510
2065
  for (const p of this.canvasPresentations) {
@@ -1684,7 +2239,15 @@ var PixiRenderer = class {
1684
2239
  }
1685
2240
  acquireImage(url, signal) {
1686
2241
  try {
1687
- return this.images.acquire(new URL(url, document.baseURI).href, signal);
2242
+ const parsed = new URL(url, document.baseURI);
2243
+ if (![
2244
+ "http:",
2245
+ "https:",
2246
+ "data:",
2247
+ "blob:",
2248
+ "file:"
2249
+ ].includes(parsed.protocol)) throw new Error("Unsupported image URL protocol");
2250
+ return this.images.acquire(parsed.href, signal);
1688
2251
  } catch {
1689
2252
  return Promise.reject(new EngineError("ASSET_LOAD_FAILED", "Invalid image URL", { path: url }));
1690
2253
  }
@@ -2090,6 +2653,10 @@ var PixiRenderer = class {
2090
2653
  this.scenes.clear();
2091
2654
  cleanupAll([
2092
2655
  ...scenes.map((scene) => scene.dispose),
2656
+ ...[...this.virtualPresentations].map((p) => () => {
2657
+ this.virtualPresentations.delete(p);
2658
+ p.dispose();
2659
+ }),
2093
2660
  ...[...this.canvasPresentations].map((p) => () => {
2094
2661
  this.canvasPresentations.delete(p);
2095
2662
  p.dispose();
@@ -2342,10 +2909,42 @@ var Game = class {
2342
2909
  engine;
2343
2910
  element;
2344
2911
  camera;
2912
+ gamepads;
2345
2913
  renderer;
2346
2914
  keyboard;
2347
2915
  pointers;
2348
2916
  pointerChannels = /* @__PURE__ */ new WeakMap();
2917
+ virtualInputs = /* @__PURE__ */ new WeakMap();
2918
+ virtualPresentation;
2919
+ get virtualInput() {
2920
+ this.assertAlive();
2921
+ return this.virtualFor(this.engine.scene);
2922
+ }
2923
+ virtualFor(scene) {
2924
+ let input = this.virtualInputs.get(scene);
2925
+ if (!input) {
2926
+ input = new VirtualInput(this.channelFor(scene), this.engine.input);
2927
+ this.virtualInputs.set(scene, input);
2928
+ const owned = input;
2929
+ input.onDispose(() => {
2930
+ if (this.virtualInputs.get(scene) === owned) this.virtualInputs.delete(scene);
2931
+ if (this.engine.scene === scene) {
2932
+ this.virtualPresentation?.dispose();
2933
+ this.virtualPresentation = void 0;
2934
+ }
2935
+ });
2936
+ }
2937
+ return input;
2938
+ }
2939
+ prepareVirtual(scene) {
2940
+ const input = this.virtualInputs.get(scene);
2941
+ if (!input || input.controls.length === 0) return;
2942
+ const renderer = this.renderer;
2943
+ if (!renderer.prepareVirtualInput) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support virtual input presentation");
2944
+ const presentation = renderer.prepareVirtualInput(input);
2945
+ input.onDispose(() => presentation.dispose());
2946
+ return presentation;
2947
+ }
2349
2948
  canvas;
2350
2949
  canvasPresentation;
2351
2950
  defaultCanvas;
@@ -2425,8 +3024,10 @@ var Game = class {
2425
3024
  camera: this.camera
2426
3025
  });
2427
3026
  if (options.pixelRatio !== void 0) positive(options.pixelRatio, "pixelRatio");
2428
- const theme = Themes[options.theme ?? Theme.Dark];
2429
- if (!theme) throw new EngineError("INVALID_THEME", "Unknown theme");
3027
+ const themeName = options.theme ?? Theme.Dark;
3028
+ if (!Object.hasOwn(Themes, themeName)) throw new EngineError("INVALID_THEME", "Unknown theme");
3029
+ const theme = Themes[themeName];
3030
+ 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}`);
2430
3031
  const tokens = {
2431
3032
  ...theme,
2432
3033
  ...options.tokens
@@ -2454,6 +3055,7 @@ var Game = class {
2454
3055
  cancel: (handle) => cancelAnimationFrame(handle)
2455
3056
  };
2456
3057
  this.keyboard = new BrowserInput(this.element, this.engine.input);
3058
+ this.gamepads = new GamepadInput(this.engine.input, { active: () => !document.hidden && document.hasFocus() && this.element.contains(document.activeElement) && !blocksGameKeyboard(document.activeElement) });
2457
3059
  this.pointers = new BrowserPointer(this.element, (event) => {
2458
3060
  const rect = this.element.getBoundingClientRect();
2459
3061
  if (rect.width <= 0 || rect.height <= 0) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
@@ -2463,7 +3065,7 @@ var Game = class {
2463
3065
  world: this.screenToWorld(screen),
2464
3066
  view: this.view
2465
3067
  };
2466
- }, this.report, () => this.canvasPresentation?.update(this.width, this.height));
3068
+ }, this.report, () => this.updateProjection());
2467
3069
  document.addEventListener("visibilitychange", () => {
2468
3070
  this.previousTime = void 0;
2469
3071
  this.fixed?.reset();
@@ -2618,6 +3220,7 @@ var Game = class {
2618
3220
  const scene = new Scene(id), nodes = [], controller = new AbortController();
2619
3221
  let canvas;
2620
3222
  const pointer = this.channelFor(scene);
3223
+ const virtualFor = () => this.virtualFor(scene);
2621
3224
  this.staging = scene;
2622
3225
  scene.onDispose(() => controller.abort());
2623
3226
  try {
@@ -2633,6 +3236,9 @@ var Game = class {
2633
3236
  add: (actor) => scene.add(actor),
2634
3237
  ui: (...items) => nodes.push(...items),
2635
3238
  pointer,
3239
+ get virtualInput() {
3240
+ return virtualFor();
3241
+ },
2636
3242
  canvasUI: (root, options) => {
2637
3243
  if (canvas) throw new EngineError("UI_OWNERSHIP", "A Scene has one Canvas UI root");
2638
3244
  canvas = new CanvasUI(scene, root, options);
@@ -2671,6 +3277,7 @@ var Game = class {
2671
3277
  async present(scene, nodes, canvas) {
2672
3278
  let prepared, ui, committed = false;
2673
3279
  let canvasPresentation;
3280
+ let virtualPresentation;
2674
3281
  try {
2675
3282
  const preparing = this.renderer.prepare(scene);
2676
3283
  preparing.then((value) => {
@@ -2691,6 +3298,7 @@ var Game = class {
2691
3298
  canvasPresentation = renderer.prepareCanvasUI(canvas, this.pointers.routing);
2692
3299
  scene.onDispose(() => canvasPresentation?.dispose());
2693
3300
  }
3301
+ virtualPresentation = this.prepareVirtual(scene);
2694
3302
  scene.markReady();
2695
3303
  this.errorBox.hidden = true;
2696
3304
  try {
@@ -2701,18 +3309,20 @@ var Game = class {
2701
3309
  this.report(error);
2702
3310
  }
2703
3311
  this.assertAlive();
2704
- const oldPrepared = this.prepared, oldUI = this.ui, oldCanvas = this.canvasPresentation;
3312
+ const oldPrepared = this.prepared, oldUI = this.ui, oldCanvas = this.canvasPresentation, oldVirtual = this.virtualPresentation;
2705
3313
  this.prepared = prepared;
2706
3314
  this.ui = ui;
2707
3315
  this.canvas = canvas;
2708
3316
  this.canvasPresentation = canvasPresentation;
3317
+ this.virtualPresentation = virtualPresentation;
2709
3318
  this.pointers.routing.setScene(this.channelFor(scene), canvas);
2710
3319
  committed = true;
2711
3320
  this.element.append(ui.element);
2712
3321
  for (const cleanup of [
2713
3322
  () => oldPrepared?.dispose(),
2714
3323
  () => oldUI?.dispose(),
2715
- () => oldCanvas?.dispose()
3324
+ () => oldCanvas?.dispose(),
3325
+ () => oldVirtual?.dispose()
2716
3326
  ]) try {
2717
3327
  cleanup();
2718
3328
  } catch (error) {
@@ -2728,7 +3338,8 @@ var Game = class {
2728
3338
  if (!committed) for (const cleanup of [
2729
3339
  () => prepared?.dispose(),
2730
3340
  () => ui?.dispose(),
2731
- () => canvasPresentation?.dispose()
3341
+ () => canvasPresentation?.dispose(),
3342
+ () => virtualPresentation?.dispose()
2732
3343
  ]) try {
2733
3344
  cleanup();
2734
3345
  } catch (cleanupError) {
@@ -2840,6 +3451,7 @@ var Game = class {
2840
3451
  width: this.width,
2841
3452
  height: this.height
2842
3453
  };
3454
+ this.virtualInputs.get(this.engine.scene)?.refresh(viewport);
2843
3455
  const previous = this.projection.viewport;
2844
3456
  const c = this.camera;
2845
3457
  if (!this.projectionDirty && previous.x === viewport.x && previous.y === viewport.y && previous.width === viewport.width && previous.height === viewport.height && this.cameraX === c.x && this.cameraY === c.y && this.cameraZoom === c.zoom) return;
@@ -2884,6 +3496,8 @@ var Game = class {
2884
3496
  }
2885
3497
  render() {
2886
3498
  this.updateProjection();
3499
+ this.virtualPresentation ??= this.prepareVirtual(this.engine.scene);
3500
+ this.virtualPresentation?.update();
2887
3501
  this.prepared?.render(this.projection);
2888
3502
  }
2889
3503
  tick = (time) => {
@@ -2897,12 +3511,14 @@ var Game = class {
2897
3511
  } else {
2898
3512
  const elapsed = this.previousTime === void 0 ? 0 : Math.max(0, (time - this.previousTime) / 1e3);
2899
3513
  this.previousTime = time;
3514
+ if (this.virtualInputs.has(this.engine.scene)) this.updateProjection();
2900
3515
  if (this.playback) this.playback.advance(elapsed, this.options.fixedStep?.maxSteps ?? 8);
2901
3516
  else if (this.fixed) this.fixed.advance(elapsed);
2902
3517
  else this.engine.step(Math.min(this.maxDelta, elapsed));
2903
3518
  if (!this.dead && !this.engine.scene.disposed) this.engine.scene.advancePresentation(Math.min(this.maxDelta, elapsed));
2904
3519
  if (this.engine.paused) this.canvas?.stopMotion();
2905
3520
  else this.canvas?.advance(elapsed);
3521
+ this.virtualInputs.get(this.engine.scene)?.advance(Math.min(this.maxDelta, elapsed));
2906
3522
  if (this.ratio !== (this.options.pixelRatio ?? Math.min(window.devicePixelRatio || 1, 2))) this.resize();
2907
3523
  this.render();
2908
3524
  }
@@ -2929,8 +3545,10 @@ var Game = class {
2929
3545
  () => this.stopReplay(),
2930
3546
  () => this.staging?.dispose(),
2931
3547
  () => this.pointers.dispose(),
3548
+ () => this.gamepads.dispose(),
2932
3549
  () => this.keyboard.dispose(),
2933
3550
  () => this.canvasPresentation?.dispose(),
3551
+ () => this.virtualPresentation?.dispose(),
2934
3552
  () => this.canvas?.dispose(),
2935
3553
  () => this.observer.disconnect(),
2936
3554
  () => this.ui?.dispose(),
@@ -2948,12 +3566,17 @@ var Game = class {
2948
3566
  this.staging = void 0;
2949
3567
  this.canvas = void 0;
2950
3568
  this.canvasPresentation = void 0;
3569
+ this.virtualPresentation = void 0;
2951
3570
  this.defaultCanvas = void 0;
2952
3571
  this.defaultUI.length = 0;
2953
3572
  this.scenes.clear();
2954
3573
  this.pendingScenes.clear();
2955
3574
  }
2956
3575
  };
2957
- export { BrowserInput, Button, CanvasButton, Game, HoldButton, Label, Layout, Panel, PixiRenderer, Placement, Theme, Themes, browserAbortSignal, mountUI };
3576
+ function validFontFamily(value) {
3577
+ const syntax = value.replace(/"(?:\\[^\r\n]|[^"\\])*"|'(?:\\[^\r\n]|[^'\\])*'/g, "family").replace(/\\(?:[0-9a-f]{1,6}[ \t]?|[^\r\n])/gi, "x");
3578
+ return value.trim().length > 0 && !/[\r\n\0]/.test(value) && !/[;{}<>\\"']|url\s*\(/i.test(syntax);
3579
+ }
3580
+ export { BrowserInput, Button, CanvasButton, Game, GamepadAxis, GamepadBindings, GamepadButton, GamepadInput, GamepadSource, HoldButton, Label, Layout, Panel, PixiRenderer, Placement, Theme, Themes, browserAbortSignal, gamepadButtonLabel, mountUI };
2958
3581
 
2959
3582
  //# sourceMappingURL=index.js.map