@simpludify/web 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import "./style.css";
2
- import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, EventBus, FixedStepRunner, 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";
2
+ import { ActionController, Aspect, Camera, CanvasButton as CanvasButton$1, CanvasUI, Engine, EngineError, EventBus, FixedStepRunner, GamepadAxis, GamepadBindings, GamepadButton, GeometryTilemapLayer, InputCapture, InputCaptureState, InputDevice, Lifetime, MouseButton, PointerRouting, Renderable, ReplayPlayer, Scene, ScenePointer, SceneState, Shape, TilemapLayer, Transform, VirtualInput, canvasControlText, canvasHoverHighlight, canvasOpacity, canvasScrollbars, canvasSliderGeometry, checkedGamepadBindings, cleanupAll, containsScreenPoint, createViewTransform, defineEvent, gamepadDeadZone, gamepadIndex, imageSize, intersectUI, isKeyCode, isMouseButton, positive, screenPoint, screenToWorld, spriteFrameBounds, worldToScreen, wrapCanvasText } from "@simpludify/core";
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,135 +17,6 @@ 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
20
  // packages/web/src/gamepad-layout.ts
150
21
  var positions = Object.fromEntries(Object.entries(GamepadButton).map(([name, index]) => [index, name]));
151
22
  var faces = Object.freeze({
@@ -230,6 +101,9 @@ var GamepadSource = class {
230
101
  signature;
231
102
  zone;
232
103
  mapping;
104
+ following;
105
+ bindingRevision;
106
+ waiting = /* @__PURE__ */ new Set();
233
107
  constructor(input, controller, options) {
234
108
  this.input = input;
235
109
  this.controller = controller;
@@ -238,7 +112,9 @@ var GamepadSource = class {
238
112
  this.zone = gamepadDeadZone(options.deadZone ?? .15);
239
113
  this.mapping = options.mapping ?? "standard";
240
114
  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);
115
+ this.following = options.bindings === void 0;
116
+ this.bindingRevision = controller.bindingRevision;
117
+ this.bindings = this.following ? this.commonBindings() : checkedGamepadBindings(controller.actions, options.bindings);
242
118
  this.source = controller.createSource();
243
119
  this.life.onDispose(() => this.source.dispose());
244
120
  this.life.onDispose(controller.onDispose(() => this.dispose()));
@@ -277,19 +153,42 @@ var GamepadSource = class {
277
153
  }
278
154
  rebind(bindings) {
279
155
  this.alive();
280
- const checked = checkedGamepadBindings(this.controller.actions, bindings);
156
+ const checked = bindings === void 0 ? this.commonBindings() : checkedGamepadBindings(this.controller.actions, bindings);
281
157
  this.clear();
282
158
  this.bindings = checked;
159
+ this.following = bindings === void 0;
160
+ this.bindingRevision = this.controller.bindingRevision;
161
+ }
162
+ commonBindings() {
163
+ return Object.freeze(Object.values(this.controller.actions).flatMap((action) => {
164
+ const binding = this.controller.getBinding(action).gamepad;
165
+ return binding ? [Object.freeze([action, binding])] : [];
166
+ }));
167
+ }
168
+ syncBindings() {
169
+ if (!this.following || this.bindingRevision === this.controller.bindingRevision) return;
170
+ const next = this.commonBindings();
171
+ const before = new Map(this.bindings), after = new Map(next);
172
+ for (const action of /* @__PURE__ */ new Set([...before.keys(), ...after.keys()])) {
173
+ if (JSON.stringify(before.get(action)) === JSON.stringify(after.get(action))) continue;
174
+ this.source.resetBinding(action);
175
+ if (after.has(action)) this.waiting.add(action);
176
+ else this.waiting.delete(action);
177
+ }
178
+ this.bindings = next;
179
+ this.bindingRevision = this.controller.bindingRevision;
283
180
  }
284
181
  clear() {
285
182
  this.source.clear();
286
183
  this.armed = false;
184
+ this.waiting.clear();
287
185
  }
288
186
  alive() {
289
187
  if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Gamepad source has been disposed");
290
188
  }
291
189
  /** @internal */ sample(pad, allowed) {
292
190
  if (this.disposed) return;
191
+ this.syncBindings();
293
192
  if (!allowed || !this.enabled || !this.controller.active || !pad || this.mapping === "standard" && pad.mapping !== "standard") {
294
193
  this.clear();
295
194
  this.signature = void 0;
@@ -310,7 +209,13 @@ var GamepadSource = class {
310
209
  if (next.every(([, value]) => neutral(value))) this.armed = true;
311
210
  return;
312
211
  }
313
- for (const [action, value] of next) this.source.set(action, value);
212
+ for (const [action, value] of next) {
213
+ if (this.waiting.has(action)) {
214
+ if (!neutral(value)) continue;
215
+ this.waiting.delete(action);
216
+ }
217
+ this.source.set(action, value);
218
+ }
314
219
  }
315
220
  /** @internal */ onDispose(callback) {
316
221
  return this.life.onDispose(callback);
@@ -331,6 +236,7 @@ var GamepadInput = class {
331
236
  readable = false;
332
237
  poll;
333
238
  active;
239
+ observe;
334
240
  constructor(input, options = {}) {
335
241
  this.input = input;
336
242
  this.life.onDispose(() => this.changes.dispose());
@@ -338,6 +244,7 @@ var GamepadInput = class {
338
244
  throw new Error("Gamepad API unavailable");
339
245
  })());
340
246
  this.active = options.active ?? (() => true);
247
+ this.observe = options.observe;
341
248
  this.life.onDispose(input.addAdapter(this));
342
249
  const events = options.events ?? (typeof window === "undefined" ? void 0 : window), abort = new AbortController();
343
250
  this.life.onDispose(() => abort.abort());
@@ -412,7 +319,8 @@ var GamepadInput = class {
412
319
  axisCount: p.axes.length,
413
320
  inferredLayout: inferredGamepadLayout(p.id)
414
321
  })));
415
- const allowed = this.active();
322
+ this.observe?.(pads);
323
+ const allowed = this.active() && this.input.liveInput;
416
324
  for (const source of this.sources) if (source.disposed) this.sources.delete(source);
417
325
  else source.sample(pads.get(source.index), allowed);
418
326
  this.publish(listed, wasReadable !== this.readable);
@@ -520,35 +428,286 @@ var BrowserInput = class {
520
428
  this.clear();
521
429
  }
522
430
  };
431
+ // packages/web/src/input-capture.ts
432
+ var mouseMasks$1 = [
433
+ [0, 1],
434
+ [1, 4],
435
+ [2, 2],
436
+ [3, 8],
437
+ [4, 16]
438
+ ];
439
+ /** @internal One Game-owned observer; existing adapters retain gameplay ownership. */
440
+ var InputCaptureManager = class {
441
+ host;
442
+ input;
443
+ scene;
444
+ releasePointers;
445
+ report;
446
+ abort = new AbortController();
447
+ current;
448
+ keys = /* @__PURE__ */ new Set();
449
+ blockedKeys = /* @__PURE__ */ new Set();
450
+ blockedPointers = /* @__PURE__ */ new Set();
451
+ blockedAuxiliary = /* @__PURE__ */ new Set();
452
+ mouseButtons = 0;
453
+ needsPadSample = false;
454
+ pads = /* @__PURE__ */ new Map();
455
+ readyPads = /* @__PURE__ */ new Set();
456
+ dead = false;
457
+ constructor(host, input, scene, releasePointers, report) {
458
+ this.host = host;
459
+ this.input = input;
460
+ this.scene = scene;
461
+ this.releasePointers = releasePointers;
462
+ this.report = report;
463
+ const options = {
464
+ capture: true,
465
+ signal: this.abort.signal
466
+ };
467
+ window.addEventListener("keydown", (event) => this.guard(() => this.keyDown(event)), options);
468
+ window.addEventListener("keyup", (event) => this.guard(() => {
469
+ if (isKeyCode(event.code)) {
470
+ this.keys.delete(event.code);
471
+ this.blockedKeys.delete(event.code);
472
+ }
473
+ this.arm();
474
+ }), options);
475
+ for (const kind of [
476
+ "pointerdown",
477
+ "pointermove",
478
+ "pointerup",
479
+ "pointercancel"
480
+ ]) window.addEventListener(kind, (event) => this.guard(() => this.pointer(event)), options);
481
+ for (const kind of ["auxclick", "contextmenu"]) window.addEventListener(kind, (event) => {
482
+ if (this.surface(event.target) && isMouseButton(event.button) && this.blockedAuxiliary.has(event.button)) {
483
+ this.consume(event);
484
+ if (kind === "auxclick") this.blockedAuxiliary.delete(event.button);
485
+ }
486
+ }, options);
487
+ window.addEventListener("blur", (event) => {
488
+ if (event.target === window) this.interrupt();
489
+ }, options);
490
+ document.addEventListener("visibilitychange", () => {
491
+ if (document.hidden) this.interrupt();
492
+ }, options);
493
+ host.addEventListener("focusout", (event) => {
494
+ if (!(event.relatedTarget instanceof Node) || !host.contains(event.relatedTarget) || blocksGameKeyboard(event.relatedTarget)) this.interrupt();
495
+ }, options);
496
+ host.addEventListener("focusin", (event) => {
497
+ if (blocksGameKeyboard(event.target)) this.interrupt();
498
+ }, options);
499
+ }
500
+ start(options = {}) {
501
+ if (this.dead) throw new EngineError("GAME_DISPOSED", "Capture host has been disposed");
502
+ if (this.current) throw new EngineError("INPUT_CAPTURE_ACTIVE", "Cancel the active capture before starting another");
503
+ const scene = this.scene();
504
+ if (scene.state !== SceneState.Active || document.hidden || !document.hasFocus()) throw new EngineError("INPUT_CAPTURE_UNAVAILABLE", "Capture requires an active Scene and focused visible document");
505
+ const capture = new InputCapture(options);
506
+ let release;
507
+ try {
508
+ capture.onDispose(scene.onDispose(() => capture.cancel()));
509
+ capture.onDispose(() => {
510
+ if (this.current === capture) this.current = void 0;
511
+ this.pads.clear();
512
+ this.readyPads.clear();
513
+ this.needsPadSample = false;
514
+ release?.();
515
+ });
516
+ this.current = capture;
517
+ this.needsPadSample = capture.devices.includes(InputDevice.Gamepad);
518
+ for (const key of this.keys) this.blockedKeys.add(key);
519
+ for (const [button, mask] of mouseMasks$1) if (this.mouseButtons & mask) this.blockedAuxiliary.add(button);
520
+ this.releasePointers();
521
+ if (capture.disposed) return capture;
522
+ release = this.input.suspendLiveInput(() => capture.cancel());
523
+ if (capture.disposed) {
524
+ release();
525
+ return capture;
526
+ }
527
+ this.host.focus({ preventScroll: true });
528
+ this.arm();
529
+ return capture;
530
+ } catch (error) {
531
+ capture.cancel();
532
+ throw error;
533
+ }
534
+ }
535
+ arm() {
536
+ const capture = this.current;
537
+ if (!capture || capture.state !== InputCaptureState.Arming || this.needsPadSample) return;
538
+ if (capture.devices.includes(InputDevice.Keyboard) && this.keys.size) return;
539
+ if (capture.devices.includes(InputDevice.Mouse) && this.mouseButtons) return;
540
+ if (capture.devices.includes(InputDevice.Gamepad) && [...this.pads.values()].some((pad) => (capture.gamepadIndex === void 0 || capture.gamepadIndex === pad.index) && pad.buttons.size)) return;
541
+ capture.arm();
542
+ }
543
+ keyDown(event) {
544
+ if (event.isComposing || event.keyCode === 229 || !isKeyCode(event.code)) return;
545
+ const previous = this.keys.has(event.code);
546
+ this.keys.add(event.code);
547
+ if (!(event.target instanceof Node) || !this.host.contains(event.target)) return;
548
+ if (blocksGameKeyboard(event.target)) return;
549
+ if (event.target instanceof HTMLElement && event.target.closest("button")) return;
550
+ const capture = this.current;
551
+ if (!capture && !this.blockedKeys.has(event.code)) return;
552
+ this.blockedKeys.add(event.code);
553
+ this.consume(event);
554
+ if (capture && !previous && !event.repeat) capture.accept({
555
+ device: InputDevice.Keyboard,
556
+ code: event.code
557
+ });
558
+ }
559
+ surface(target) {
560
+ return target === this.host || target instanceof HTMLCanvasElement && target.parentElement === this.host;
561
+ }
562
+ pointer(event) {
563
+ const capture = this.current, mouse = event.pointerType === "mouse", old = this.mouseButtons;
564
+ if (mouse) this.mouseButtons = event.type === "pointercancel" ? 0 : event.buttons;
565
+ if (event.type === "pointerdown" && !capture) {
566
+ this.blockedPointers.delete(event.pointerId);
567
+ if (isMouseButton(event.button)) this.blockedAuxiliary.delete(event.button);
568
+ }
569
+ const blocked = this.blockedPointers.has(event.pointerId);
570
+ if (capture && this.surface(event.target)) {
571
+ if (event.type === "pointerdown") this.host.focus({ preventScroll: true });
572
+ this.blockedPointers.add(event.pointerId);
573
+ this.consume(event);
574
+ if (mouse && isMouseButton(event.button)) {
575
+ const mask = mouseMasks$1.find(([button]) => button === event.button)?.[1] ?? 0;
576
+ if (event.buttons & mask && !(old & mask)) {
577
+ this.blockedAuxiliary.add(event.button);
578
+ capture.accept({
579
+ device: InputDevice.Mouse,
580
+ button: event.button
581
+ });
582
+ }
583
+ }
584
+ } else if (blocked) this.consume(event);
585
+ if (event.type === "pointerup" || event.type === "pointercancel" || mouse && event.buttons === 0) this.blockedPointers.delete(event.pointerId);
586
+ this.arm();
587
+ }
588
+ /** Same validated gamepad poll as gameplay; no navigator polling in this manager. */
589
+ sample(pads) {
590
+ const capture = this.current;
591
+ if (!capture?.devices.includes(InputDevice.Gamepad)) return;
592
+ const before = this.pads, first = this.needsPadSample, next = /* @__PURE__ */ new Map();
593
+ for (const pad of pads.values()) {
594
+ const buttons = /* @__PURE__ */ new Set();
595
+ pad.buttons.forEach((button, index) => {
596
+ if (Number.isFinite(button?.value) && button.value >= .5) buttons.add(index);
597
+ });
598
+ const signature = JSON.stringify([
599
+ pad.id,
600
+ pad.mapping,
601
+ pad.buttons.length
602
+ ]);
603
+ const entry = {
604
+ index: pad.index,
605
+ id: pad.id,
606
+ mapping: pad.mapping,
607
+ signature,
608
+ buttons
609
+ };
610
+ next.set(pad.index, entry);
611
+ if (first || before.get(pad.index)?.signature !== signature) this.readyPads.delete(pad.index);
612
+ if (!buttons.size) this.readyPads.add(pad.index);
613
+ }
614
+ for (const index of this.readyPads) if (!next.has(index)) this.readyPads.delete(index);
615
+ this.pads = next;
616
+ this.needsPadSample = false;
617
+ this.arm();
618
+ if (first || capture.state !== InputCaptureState.Listening) return;
619
+ for (const pad of [...next.values()].sort((a, b) => a.index - b.index)) {
620
+ if (!this.readyPads.has(pad.index) || before.get(pad.index)?.signature !== pad.signature) continue;
621
+ for (const button of pad.buttons) if (!before.get(pad.index)?.buttons.has(button) && capture.accept({
622
+ device: InputDevice.Gamepad,
623
+ index: pad.index,
624
+ id: pad.id,
625
+ mapping: pad.mapping,
626
+ button
627
+ })) return;
628
+ }
629
+ }
630
+ consume(event) {
631
+ event.preventDefault();
632
+ event.stopImmediatePropagation();
633
+ }
634
+ guard(action) {
635
+ try {
636
+ action();
637
+ } catch (error) {
638
+ this.report(error);
639
+ }
640
+ }
641
+ interrupt() {
642
+ this.guard(() => this.current?.cancel());
643
+ this.keys.clear();
644
+ this.blockedKeys.clear();
645
+ this.blockedPointers.clear();
646
+ this.blockedAuxiliary.clear();
647
+ this.mouseButtons = 0;
648
+ }
649
+ dispose() {
650
+ if (this.dead) return;
651
+ this.dead = true;
652
+ this.abort.abort();
653
+ this.interrupt();
654
+ }
655
+ };
523
656
  // packages/web/src/pointer.ts
657
+ var mouseMasks = [
658
+ [MouseButton.Left, 1],
659
+ [MouseButton.Middle, 4],
660
+ [MouseButton.Right, 2],
661
+ [MouseButton.Back, 8],
662
+ [MouseButton.Forward, 16]
663
+ ];
524
664
  /** One DOM capture-phase adapter per Game; Pixi federated events never route these widgets. */
525
665
  var BrowserPointer = class {
526
666
  host;
527
667
  project;
528
668
  report;
529
669
  flush;
670
+ input;
530
671
  routing;
531
672
  abort = new AbortController();
532
- constructor(host, project, report, flush) {
673
+ mouse;
674
+ mouseStates = /* @__PURE__ */ new Map();
675
+ blockedMouse = /* @__PURE__ */ new Set();
676
+ auxiliary = /* @__PURE__ */ new Set();
677
+ offInput;
678
+ constructor(host, project, report, flush, input) {
533
679
  this.host = host;
534
680
  this.project = project;
535
681
  this.report = report;
536
682
  this.flush = flush;
683
+ this.input = input;
537
684
  this.routing = new PointerRouting((id) => {
538
685
  if (host.hasPointerCapture(id)) host.releasePointerCapture(id);
539
686
  });
687
+ this.mouse = input?.createMouseSource();
688
+ this.offInput = input?.addAdapter({
689
+ sample: () => {},
690
+ reset: () => this.resetMouse(),
691
+ dispose: () => this.dispose()
692
+ });
540
693
  const options = {
541
694
  signal: this.abort.signal,
542
695
  capture: true
543
696
  };
544
697
  host.addEventListener("pointerdown", (e) => this.guard(() => {
545
698
  if (!this.surface(e.target)) return;
699
+ if (e.pointerType === "mouse") {
700
+ this.releaseMouse(e.pointerId);
701
+ this.blockedMouse.delete(e.pointerId);
702
+ if (isMouseButton(e.button)) this.auxiliary.delete(e.button);
703
+ }
546
704
  this.flush();
547
705
  const data = this.data(e);
548
706
  try {
549
707
  host.setPointerCapture(e.pointerId);
550
708
  } catch {}
551
709
  const target = this.routing.down(data);
710
+ this.updateMouse(e);
552
711
  if (this.routing.has(e.pointerId)) {
553
712
  e.preventDefault();
554
713
  if (target) focusGameSurface(host);
@@ -570,28 +729,78 @@ var BrowserPointer = class {
570
729
  });
571
730
  window.addEventListener("pointermove", (e) => this.guard(() => {
572
731
  if (!this.routing.owns(e.pointerId) && !this.surface(e.target)) {
732
+ if (e.pointerType === "mouse") this.releaseMouse(e.pointerId);
573
733
  if (this.routing.hasHover(e.pointerId)) this.routing.leave(this.data(e));
574
734
  return;
575
735
  }
576
736
  this.flush();
737
+ this.updateMouse(e);
577
738
  this.routing.move(this.data(e));
578
739
  }), options);
579
740
  window.addEventListener("pointerup", (e) => this.guard(() => {
741
+ if (e.pointerType === "mouse") this.releaseMouse(e.pointerId);
580
742
  if (this.routing.owns(e.pointerId)) {
581
743
  e.preventDefault();
582
744
  this.flush();
583
745
  this.routing.up(this.data(e));
584
746
  }
585
747
  }), options);
586
- window.addEventListener("pointercancel", (e) => this.guard(() => this.routing.cancel(e.pointerId, false)), options);
587
- host.addEventListener("lostpointercapture", (e) => this.guard(() => this.routing.cancel(e.pointerId)), options);
748
+ window.addEventListener("pointercancel", (e) => this.guard(() => {
749
+ this.releaseMouse(e.pointerId);
750
+ this.blockedMouse.delete(e.pointerId);
751
+ this.routing.cancel(e.pointerId, false);
752
+ }), options);
753
+ host.addEventListener("lostpointercapture", (e) => this.guard(() => {
754
+ this.releaseMouse(e.pointerId);
755
+ if (e.pointerType === "mouse" && e.buttons !== 0) this.blockedMouse.add(e.pointerId);
756
+ else this.blockedMouse.delete(e.pointerId);
757
+ this.routing.cancel(e.pointerId);
758
+ }), options);
588
759
  host.addEventListener("pointerleave", (e) => this.guard(() => this.routing.leave(this.data(e))), options);
589
760
  window.addEventListener("blur", (event) => {
590
761
  if (event.target === window) this.release();
591
762
  }, options);
763
+ host.addEventListener("focusout", (event) => this.guard(() => {
764
+ if (!(event.relatedTarget instanceof Node) || !host.contains(event.relatedTarget) || blocksGameKeyboard(event.relatedTarget)) {
765
+ this.resetMouse();
766
+ this.routing.releaseVirtual();
767
+ }
768
+ }), options);
592
769
  document.addEventListener("visibilitychange", () => {
593
770
  if (document.hidden) this.release();
594
771
  }, options);
772
+ for (const kind of ["auxclick", "contextmenu"]) host.addEventListener(kind, (event) => {
773
+ if (this.surface(event.target) && isMouseButton(event.button) && this.auxiliary.has(event.button) && input?.acceptsMouse(event.button)) {
774
+ event.preventDefault();
775
+ if (kind === "auxclick") this.auxiliary.delete(event.button);
776
+ }
777
+ }, options);
778
+ }
779
+ applyMouse() {
780
+ let buttons = 0;
781
+ for (const state of this.mouseStates.values()) buttons |= state;
782
+ for (const [button, mask] of mouseMasks) this.mouse?.set(button, (buttons & mask) !== 0);
783
+ }
784
+ releaseMouse(id) {
785
+ if (this.mouseStates.delete(id)) this.applyMouse();
786
+ }
787
+ updateMouse(event) {
788
+ if (event.pointerType !== "mouse" || !this.mouse) return;
789
+ if (event.buttons === 0) this.blockedMouse.delete(event.pointerId);
790
+ const world = this.routing.worldOwns(event.pointerId) && !this.blockedMouse.has(event.pointerId) && this.input?.liveInput;
791
+ if (world) this.mouseStates.set(event.pointerId, event.buttons);
792
+ else this.mouseStates.delete(event.pointerId);
793
+ if (isMouseButton(event.button)) {
794
+ if (world && this.input?.acceptsMouse(event.button)) this.auxiliary.add(event.button);
795
+ else this.auxiliary.delete(event.button);
796
+ }
797
+ this.applyMouse();
798
+ }
799
+ resetMouse() {
800
+ for (const id of this.mouseStates.keys()) this.blockedMouse.add(id);
801
+ this.mouseStates.clear();
802
+ this.auxiliary.clear();
803
+ this.mouse?.clear();
595
804
  }
596
805
  surface(target) {
597
806
  return target === this.host || target instanceof HTMLCanvasElement && target.parentElement === this.host;
@@ -617,10 +826,15 @@ var BrowserPointer = class {
617
826
  }
618
827
  }
619
828
  release() {
829
+ this.resetMouse();
620
830
  this.guard(() => this.routing.release());
621
831
  }
622
832
  dispose() {
623
833
  this.abort.abort();
834
+ this.offInput?.();
835
+ this.resetMouse();
836
+ this.mouse?.dispose();
837
+ this.blockedMouse.clear();
624
838
  this.guard(() => this.routing.dispose());
625
839
  }
626
840
  };
@@ -1907,6 +2121,108 @@ var TilemapVisual = class {
1907
2121
  ], "Tilemap presentation cleanup failed");
1908
2122
  }
1909
2123
  };
2124
+ // packages/web/src/virtual-input-visual.ts
2125
+ /** Screen-space presentation only. The existing BrowserPointer is the sole event adapter. */
2126
+ var VirtualInputVisual = class {
2127
+ input;
2128
+ release;
2129
+ display = new Container();
2130
+ entries = /* @__PURE__ */ new Map();
2131
+ dead = false;
2132
+ off;
2133
+ constructor(input, release) {
2134
+ this.input = input;
2135
+ this.release = release;
2136
+ this.display.eventMode = "none";
2137
+ this.display.sortableChildren = true;
2138
+ this.display.zIndex = .5;
2139
+ this.off = input.onDispose(() => this.dispose());
2140
+ }
2141
+ get diagnostics() {
2142
+ return {
2143
+ controls: this.entries.size,
2144
+ visible: [...this.entries.values()].filter((e) => e.graphics.visible).length
2145
+ };
2146
+ }
2147
+ update() {
2148
+ if (this.dead) return;
2149
+ const live = new Set(this.input.controls);
2150
+ for (const [control, entry] of this.entries) if (!live.has(control)) {
2151
+ this.entries.delete(control);
2152
+ this.release(entry.graphics);
2153
+ }
2154
+ this.display.visible = this.input.visible;
2155
+ for (const control of live) {
2156
+ let entry = this.entries.get(control);
2157
+ if (!entry) {
2158
+ entry = {
2159
+ graphics: new Graphics(),
2160
+ signature: ""
2161
+ };
2162
+ entry.graphics.zIndex = control.kind === "button" ? 1 : 0;
2163
+ this.entries.set(control, entry);
2164
+ this.display.addChild(entry.graphics);
2165
+ }
2166
+ entry.graphics.visible = control.visible;
2167
+ if (!control.visible || !this.input.visible) continue;
2168
+ const center = control.center, knob = control.knob, radius = control.radius;
2169
+ const signature = JSON.stringify([
2170
+ center,
2171
+ knob,
2172
+ radius,
2173
+ control.color,
2174
+ control.enabled,
2175
+ this.input.enabled,
2176
+ control.pressed,
2177
+ control.kind
2178
+ ]);
2179
+ if (signature === entry.signature) continue;
2180
+ entry.signature = signature;
2181
+ const g = entry.graphics;
2182
+ g.clear();
2183
+ g.alpha = control.enabled && this.input.enabled ? 1 : .35;
2184
+ g.circle(center.x, center.y, radius).fill({
2185
+ color: control.color,
2186
+ alpha: .12
2187
+ }).stroke({
2188
+ color: control.color,
2189
+ alpha: .75,
2190
+ width: 2
2191
+ });
2192
+ if (control.kind === "stick") {
2193
+ g.moveTo(center.x, center.y).lineTo(knob.x, knob.y).stroke({
2194
+ color: control.color,
2195
+ alpha: .5,
2196
+ width: 3
2197
+ });
2198
+ g.circle(knob.x, knob.y, radius * .36).fill({
2199
+ color: control.color,
2200
+ alpha: control.pressed ? .85 : .45
2201
+ });
2202
+ } else {
2203
+ if (control.pressed) g.circle(center.x, center.y, radius * .7).fill({
2204
+ color: control.color,
2205
+ alpha: .65
2206
+ });
2207
+ if (control.directional && control.pressed) g.moveTo(center.x, center.y).lineTo(knob.x, knob.y).stroke({
2208
+ color: control.color,
2209
+ width: 4
2210
+ });
2211
+ }
2212
+ }
2213
+ }
2214
+ dispose() {
2215
+ if (this.dead) return;
2216
+ this.dead = true;
2217
+ const entries = [...this.entries.values()];
2218
+ this.entries.clear();
2219
+ cleanupAll([
2220
+ this.off,
2221
+ ...entries.map((e) => () => this.release(e.graphics)),
2222
+ () => this.display.destroy({ children: true })
2223
+ ], "Virtual input presentation cleanup failed");
2224
+ }
2225
+ };
1910
2226
  // packages/web/src/renderer.ts
1911
2227
  var PixiRenderer = class {
1912
2228
  host;
@@ -1915,6 +2231,46 @@ var PixiRenderer = class {
1915
2231
  app = new Application();
1916
2232
  pendingUITextKeys = /* @__PURE__ */ new Set();
1917
2233
  canvasPresentations = /* @__PURE__ */ new Set();
2234
+ virtualPresentations = /* @__PURE__ */ new Set();
2235
+ get virtualInputDiagnostics() {
2236
+ let controls = 0, visible = 0;
2237
+ for (const visual of this.virtualPresentations) {
2238
+ controls += visual.diagnostics.controls;
2239
+ visible += visual.diagnostics.visible;
2240
+ }
2241
+ return {
2242
+ presentations: this.virtualPresentations.size,
2243
+ controls,
2244
+ visible
2245
+ };
2246
+ }
2247
+ prepareVirtualInput(input) {
2248
+ if (!this.ready || this.dead) throw new EngineError("RENDERER_NOT_READY", "Renderer is not available");
2249
+ const visual = new VirtualInputVisual(input, (graphics) => {
2250
+ const context = graphics.context.uid;
2251
+ graphics.destroy({ context: true });
2252
+ releaseUIGraphics(this.app.renderer, graphics, context);
2253
+ });
2254
+ this.virtualPresentations.add(visual);
2255
+ const off = input.onDispose(() => {
2256
+ this.virtualPresentations.delete(visual);
2257
+ });
2258
+ let disposed = false;
2259
+ return {
2260
+ update: () => {
2261
+ if (disposed || input.disposed) return;
2262
+ if (!visual.display.parent) this.app.stage.addChild(visual.display);
2263
+ visual.update();
2264
+ },
2265
+ dispose: () => {
2266
+ if (disposed) return;
2267
+ disposed = true;
2268
+ off();
2269
+ this.virtualPresentations.delete(visual);
2270
+ visual.dispose();
2271
+ }
2272
+ };
2273
+ }
1918
2274
  get canvasDiagnostics() {
1919
2275
  let nodes = 0, visibleNodes = 0, buttons = 0, syncs = 0, textMetrics = 0;
1920
2276
  for (const p of this.canvasPresentations) {
@@ -2508,6 +2864,10 @@ var PixiRenderer = class {
2508
2864
  this.scenes.clear();
2509
2865
  cleanupAll([
2510
2866
  ...scenes.map((scene) => scene.dispose),
2867
+ ...[...this.virtualPresentations].map((p) => () => {
2868
+ this.virtualPresentations.delete(p);
2869
+ p.dispose();
2870
+ }),
2511
2871
  ...[...this.canvasPresentations].map((p) => () => {
2512
2872
  this.canvasPresentations.delete(p);
2513
2873
  p.dispose();
@@ -2763,8 +3123,44 @@ var Game = class {
2763
3123
  gamepads;
2764
3124
  renderer;
2765
3125
  keyboard;
3126
+ captures;
3127
+ captureInput(options = {}) {
3128
+ this.assertAlive();
3129
+ return this.captures.start(options);
3130
+ }
2766
3131
  pointers;
2767
3132
  pointerChannels = /* @__PURE__ */ new WeakMap();
3133
+ virtualInputs = /* @__PURE__ */ new WeakMap();
3134
+ virtualPresentation;
3135
+ get virtualInput() {
3136
+ this.assertAlive();
3137
+ return this.virtualFor(this.engine.scene);
3138
+ }
3139
+ virtualFor(scene) {
3140
+ let input = this.virtualInputs.get(scene);
3141
+ if (!input) {
3142
+ input = new VirtualInput(this.channelFor(scene), this.engine.input);
3143
+ this.virtualInputs.set(scene, input);
3144
+ const owned = input;
3145
+ input.onDispose(() => {
3146
+ if (this.virtualInputs.get(scene) === owned) this.virtualInputs.delete(scene);
3147
+ if (this.engine.scene === scene) {
3148
+ this.virtualPresentation?.dispose();
3149
+ this.virtualPresentation = void 0;
3150
+ }
3151
+ });
3152
+ }
3153
+ return input;
3154
+ }
3155
+ prepareVirtual(scene) {
3156
+ const input = this.virtualInputs.get(scene);
3157
+ if (!input || input.controls.length === 0) return;
3158
+ const renderer = this.renderer;
3159
+ if (!renderer.prepareVirtualInput) throw new EngineError("RENDERER_CAPABILITY", "Renderer does not support virtual input presentation");
3160
+ const presentation = renderer.prepareVirtualInput(input);
3161
+ input.onDispose(() => presentation.dispose());
3162
+ return presentation;
3163
+ }
2768
3164
  canvas;
2769
3165
  canvasPresentation;
2770
3166
  defaultCanvas;
@@ -2874,8 +3270,12 @@ var Game = class {
2874
3270
  request: (callback) => requestAnimationFrame(callback),
2875
3271
  cancel: (handle) => cancelAnimationFrame(handle)
2876
3272
  };
3273
+ this.captures = new InputCaptureManager(this.element, this.engine.input, () => this.engine.scene, () => this.pointers.release(), this.report);
2877
3274
  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) });
3275
+ this.gamepads = new GamepadInput(this.engine.input, {
3276
+ observe: (pads) => this.captures.sample(pads),
3277
+ active: () => !document.hidden && document.hasFocus() && this.element.contains(document.activeElement) && !blocksGameKeyboard(document.activeElement)
3278
+ });
2879
3279
  this.pointers = new BrowserPointer(this.element, (event) => {
2880
3280
  const rect = this.element.getBoundingClientRect();
2881
3281
  if (rect.width <= 0 || rect.height <= 0) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
@@ -2885,7 +3285,7 @@ var Game = class {
2885
3285
  world: this.screenToWorld(screen),
2886
3286
  view: this.view
2887
3287
  };
2888
- }, this.report, () => this.canvasPresentation?.update(this.width, this.height));
3288
+ }, this.report, () => this.updateProjection(), this.engine.input);
2889
3289
  document.addEventListener("visibilitychange", () => {
2890
3290
  this.previousTime = void 0;
2891
3291
  this.fixed?.reset();
@@ -3040,6 +3440,7 @@ var Game = class {
3040
3440
  const scene = new Scene(id), nodes = [], controller = new AbortController();
3041
3441
  let canvas;
3042
3442
  const pointer = this.channelFor(scene);
3443
+ const virtualFor = () => this.virtualFor(scene);
3043
3444
  this.staging = scene;
3044
3445
  scene.onDispose(() => controller.abort());
3045
3446
  try {
@@ -3055,6 +3456,9 @@ var Game = class {
3055
3456
  add: (actor) => scene.add(actor),
3056
3457
  ui: (...items) => nodes.push(...items),
3057
3458
  pointer,
3459
+ get virtualInput() {
3460
+ return virtualFor();
3461
+ },
3058
3462
  canvasUI: (root, options) => {
3059
3463
  if (canvas) throw new EngineError("UI_OWNERSHIP", "A Scene has one Canvas UI root");
3060
3464
  canvas = new CanvasUI(scene, root, options);
@@ -3093,6 +3497,7 @@ var Game = class {
3093
3497
  async present(scene, nodes, canvas) {
3094
3498
  let prepared, ui, committed = false;
3095
3499
  let canvasPresentation;
3500
+ let virtualPresentation;
3096
3501
  try {
3097
3502
  const preparing = this.renderer.prepare(scene);
3098
3503
  preparing.then((value) => {
@@ -3113,6 +3518,7 @@ var Game = class {
3113
3518
  canvasPresentation = renderer.prepareCanvasUI(canvas, this.pointers.routing);
3114
3519
  scene.onDispose(() => canvasPresentation?.dispose());
3115
3520
  }
3521
+ virtualPresentation = this.prepareVirtual(scene);
3116
3522
  scene.markReady();
3117
3523
  this.errorBox.hidden = true;
3118
3524
  try {
@@ -3123,18 +3529,20 @@ var Game = class {
3123
3529
  this.report(error);
3124
3530
  }
3125
3531
  this.assertAlive();
3126
- const oldPrepared = this.prepared, oldUI = this.ui, oldCanvas = this.canvasPresentation;
3532
+ const oldPrepared = this.prepared, oldUI = this.ui, oldCanvas = this.canvasPresentation, oldVirtual = this.virtualPresentation;
3127
3533
  this.prepared = prepared;
3128
3534
  this.ui = ui;
3129
3535
  this.canvas = canvas;
3130
3536
  this.canvasPresentation = canvasPresentation;
3537
+ this.virtualPresentation = virtualPresentation;
3131
3538
  this.pointers.routing.setScene(this.channelFor(scene), canvas);
3132
3539
  committed = true;
3133
3540
  this.element.append(ui.element);
3134
3541
  for (const cleanup of [
3135
3542
  () => oldPrepared?.dispose(),
3136
3543
  () => oldUI?.dispose(),
3137
- () => oldCanvas?.dispose()
3544
+ () => oldCanvas?.dispose(),
3545
+ () => oldVirtual?.dispose()
3138
3546
  ]) try {
3139
3547
  cleanup();
3140
3548
  } catch (error) {
@@ -3150,7 +3558,8 @@ var Game = class {
3150
3558
  if (!committed) for (const cleanup of [
3151
3559
  () => prepared?.dispose(),
3152
3560
  () => ui?.dispose(),
3153
- () => canvasPresentation?.dispose()
3561
+ () => canvasPresentation?.dispose(),
3562
+ () => virtualPresentation?.dispose()
3154
3563
  ]) try {
3155
3564
  cleanup();
3156
3565
  } catch (cleanupError) {
@@ -3262,6 +3671,7 @@ var Game = class {
3262
3671
  width: this.width,
3263
3672
  height: this.height
3264
3673
  };
3674
+ this.virtualInputs.get(this.engine.scene)?.refresh(viewport);
3265
3675
  const previous = this.projection.viewport;
3266
3676
  const c = this.camera;
3267
3677
  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;
@@ -3306,6 +3716,8 @@ var Game = class {
3306
3716
  }
3307
3717
  render() {
3308
3718
  this.updateProjection();
3719
+ this.virtualPresentation ??= this.prepareVirtual(this.engine.scene);
3720
+ this.virtualPresentation?.update();
3309
3721
  this.prepared?.render(this.projection);
3310
3722
  }
3311
3723
  tick = (time) => {
@@ -3319,12 +3731,14 @@ var Game = class {
3319
3731
  } else {
3320
3732
  const elapsed = this.previousTime === void 0 ? 0 : Math.max(0, (time - this.previousTime) / 1e3);
3321
3733
  this.previousTime = time;
3734
+ if (this.virtualInputs.has(this.engine.scene)) this.updateProjection();
3322
3735
  if (this.playback) this.playback.advance(elapsed, this.options.fixedStep?.maxSteps ?? 8);
3323
3736
  else if (this.fixed) this.fixed.advance(elapsed);
3324
3737
  else this.engine.step(Math.min(this.maxDelta, elapsed));
3325
3738
  if (!this.dead && !this.engine.scene.disposed) this.engine.scene.advancePresentation(Math.min(this.maxDelta, elapsed));
3326
3739
  if (this.engine.paused) this.canvas?.stopMotion();
3327
3740
  else this.canvas?.advance(elapsed);
3741
+ this.virtualInputs.get(this.engine.scene)?.advance(Math.min(this.maxDelta, elapsed));
3328
3742
  if (this.ratio !== (this.options.pixelRatio ?? Math.min(window.devicePixelRatio || 1, 2))) this.resize();
3329
3743
  this.render();
3330
3744
  }
@@ -3350,10 +3764,12 @@ var Game = class {
3350
3764
  for (const cleanup of [
3351
3765
  () => this.stopReplay(),
3352
3766
  () => this.staging?.dispose(),
3767
+ () => this.captures.dispose(),
3353
3768
  () => this.pointers.dispose(),
3354
3769
  () => this.gamepads.dispose(),
3355
3770
  () => this.keyboard.dispose(),
3356
3771
  () => this.canvasPresentation?.dispose(),
3772
+ () => this.virtualPresentation?.dispose(),
3357
3773
  () => this.canvas?.dispose(),
3358
3774
  () => this.observer.disconnect(),
3359
3775
  () => this.ui?.dispose(),
@@ -3371,6 +3787,7 @@ var Game = class {
3371
3787
  this.staging = void 0;
3372
3788
  this.canvas = void 0;
3373
3789
  this.canvasPresentation = void 0;
3790
+ this.virtualPresentation = void 0;
3374
3791
  this.defaultCanvas = void 0;
3375
3792
  this.defaultUI.length = 0;
3376
3793
  this.scenes.clear();