@simpludify/web 0.1.5 → 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, VirtualInput, 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,31 +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);
592
763
  host.addEventListener("focusout", (event) => this.guard(() => {
593
- if (!(event.relatedTarget instanceof Node) || !host.contains(event.relatedTarget) || blocksGameKeyboard(event.relatedTarget)) this.routing.releaseVirtual();
764
+ if (!(event.relatedTarget instanceof Node) || !host.contains(event.relatedTarget) || blocksGameKeyboard(event.relatedTarget)) {
765
+ this.resetMouse();
766
+ this.routing.releaseVirtual();
767
+ }
594
768
  }), options);
595
769
  document.addEventListener("visibilitychange", () => {
596
770
  if (document.hidden) this.release();
597
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();
598
804
  }
599
805
  surface(target) {
600
806
  return target === this.host || target instanceof HTMLCanvasElement && target.parentElement === this.host;
@@ -620,10 +826,15 @@ var BrowserPointer = class {
620
826
  }
621
827
  }
622
828
  release() {
829
+ this.resetMouse();
623
830
  this.guard(() => this.routing.release());
624
831
  }
625
832
  dispose() {
626
833
  this.abort.abort();
834
+ this.offInput?.();
835
+ this.resetMouse();
836
+ this.mouse?.dispose();
837
+ this.blockedMouse.clear();
627
838
  this.guard(() => this.routing.dispose());
628
839
  }
629
840
  };
@@ -2912,6 +3123,11 @@ var Game = class {
2912
3123
  gamepads;
2913
3124
  renderer;
2914
3125
  keyboard;
3126
+ captures;
3127
+ captureInput(options = {}) {
3128
+ this.assertAlive();
3129
+ return this.captures.start(options);
3130
+ }
2915
3131
  pointers;
2916
3132
  pointerChannels = /* @__PURE__ */ new WeakMap();
2917
3133
  virtualInputs = /* @__PURE__ */ new WeakMap();
@@ -3054,8 +3270,12 @@ var Game = class {
3054
3270
  request: (callback) => requestAnimationFrame(callback),
3055
3271
  cancel: (handle) => cancelAnimationFrame(handle)
3056
3272
  };
3273
+ this.captures = new InputCaptureManager(this.element, this.engine.input, () => this.engine.scene, () => this.pointers.release(), this.report);
3057
3274
  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) });
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
+ });
3059
3279
  this.pointers = new BrowserPointer(this.element, (event) => {
3060
3280
  const rect = this.element.getBoundingClientRect();
3061
3281
  if (rect.width <= 0 || rect.height <= 0) throw new EngineError("VIEWPORT_UNAVAILABLE", "Game has no visible bounds");
@@ -3065,7 +3285,7 @@ var Game = class {
3065
3285
  world: this.screenToWorld(screen),
3066
3286
  view: this.view
3067
3287
  };
3068
- }, this.report, () => this.updateProjection());
3288
+ }, this.report, () => this.updateProjection(), this.engine.input);
3069
3289
  document.addEventListener("visibilitychange", () => {
3070
3290
  this.previousTime = void 0;
3071
3291
  this.fixed?.reset();
@@ -3544,6 +3764,7 @@ var Game = class {
3544
3764
  for (const cleanup of [
3545
3765
  () => this.stopReplay(),
3546
3766
  () => this.staging?.dispose(),
3767
+ () => this.captures.dispose(),
3547
3768
  () => this.pointers.dispose(),
3548
3769
  () => this.gamepads.dispose(),
3549
3770
  () => this.keyboard.dispose(),