@simpludify/core 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
@@ -1583,7 +1583,7 @@ var RandomManager = class {
1583
1583
  };
1584
1584
  // packages/core/src/foundation/version.ts
1585
1585
  /** Source workspace version; checked against package.json by the build validator. */
1586
- var ENGINE_VERSION = "0.1.3";
1586
+ var ENGINE_VERSION = "0.1.5";
1587
1587
  // packages/core/src/scene/actor.ts
1588
1588
  var inputPhase$1 = ComponentPhase.Input;
1589
1589
  var updatePhase$1 = ComponentPhase.Update;
@@ -4452,16 +4452,27 @@ function defineActions(definitions) {
4452
4452
  /** @internal Shared lifetime for logical and device contributions. */
4453
4453
  var OwnedInputSource = class {
4454
4454
  dead = false;
4455
+ accepting = true;
4455
4456
  removeOwner;
4456
4457
  get disposed() {
4457
4458
  return this.dead;
4458
4459
  }
4460
+ get enabled() {
4461
+ return this.accepting;
4462
+ }
4463
+ set enabled(value) {
4464
+ if (this.dead) throw new EngineError("OWNER_DISPOSED", "Input source has been disposed");
4465
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VALUE", "Source enabled must be boolean");
4466
+ if (value === this.accepting) return;
4467
+ this.accepting = value;
4468
+ if (!value) this.release(false);
4469
+ }
4459
4470
  /** @internal */ own(owner) {
4460
4471
  if (owner.disposed) throw new EngineError("OWNER_DISPOSED", "Input owner has been disposed");
4461
4472
  this.removeOwner = owner.onDispose(() => this.dispose());
4462
4473
  }
4463
4474
  writable(neutral) {
4464
- if (!this.dead) return true;
4475
+ if (!this.dead) return this.accepting;
4465
4476
  if (neutral) return false;
4466
4477
  throw new EngineError("OWNER_DISPOSED", "Input source has been disposed");
4467
4478
  }
@@ -4509,13 +4520,17 @@ var ActionController = class {
4509
4520
  entries = /* @__PURE__ */ new Map();
4510
4521
  sources = /* @__PURE__ */ new Map();
4511
4522
  bus = new EventBus();
4523
+ keyboardAllowed = true;
4524
+ blockedKeys = /* @__PURE__ */ new Set();
4512
4525
  pending = [];
4513
4526
  transitions = [];
4514
4527
  constructor(actions, options) {
4515
4528
  this.actions = actions;
4516
4529
  this.options = options;
4530
+ if (options.keyboard !== void 0 && typeof options.keyboard !== "boolean") throw new EngineError("INVALID_VALUE", "Keyboard enabled must be boolean");
4531
+ this.keyboardAllowed = options.keyboard ?? true;
4517
4532
  for (const action of Object.values(actions)) {
4518
- const binding = checkedBinding(action, options.bindings?.[action.name] ?? {});
4533
+ const binding = checkedBinding(action, options.bindings && Object.hasOwn(options.bindings, action.name) ? options.bindings[action.name] ?? {} : {});
4519
4534
  const value = neutral(action);
4520
4535
  this.entries.set(action, {
4521
4536
  value,
@@ -4543,6 +4558,20 @@ var ActionController = class {
4543
4558
  get active() {
4544
4559
  return !this.disposed && this.options.scene.state === SceneState.Active && this.options.scene.inputOwner === this.options.input;
4545
4560
  }
4561
+ get keyboardEnabled() {
4562
+ return this.keyboardAllowed;
4563
+ }
4564
+ set keyboardEnabled(value) {
4565
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Action controller has been disposed");
4566
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VALUE", "Keyboard enabled must be boolean");
4567
+ if (value === this.keyboardAllowed) return;
4568
+ this.keyboardAllowed = value;
4569
+ this.blockedKeys.clear();
4570
+ if (value) {
4571
+ for (const entry of this.entries.values()) for (const list of keyLists(entry.binding)) for (const key of list.keys) if (this.options.input.keyDown(key)) this.blockedKeys.add(key);
4572
+ }
4573
+ for (const action of this.entries.keys()) this.recompute(action);
4574
+ }
4546
4575
  get sourceCount() {
4547
4576
  return this.sources.size;
4548
4577
  }
@@ -4641,8 +4670,9 @@ var ActionController = class {
4641
4670
  if (!this.disposed) for (const action of actions) this.recompute(action);
4642
4671
  }
4643
4672
  keyboard(action) {
4673
+ if (!this.keyboardAllowed) return neutral(action);
4644
4674
  const binding = this.entry(action).binding;
4645
- const held = (keys) => keys?.some((k) => this.options.input.keyDown(k)) ?? false;
4675
+ const held = (keys) => keys?.some((k) => !this.blockedKeys.has(k) && this.options.input.keyDown(k)) ?? false;
4646
4676
  const axis = (b) => Number(held(b?.positive)) - Number(held(b?.negative));
4647
4677
  if (action.kind === "button") return held(binding.keys);
4648
4678
  if (action.kind === "axis1d") return axis(binding);
@@ -4696,10 +4726,11 @@ var ActionController = class {
4696
4726
  entry.value = value;
4697
4727
  }
4698
4728
  /** @internal */ keyChanged() {
4729
+ for (const key of this.blockedKeys) if (!this.options.input.keyDown(key)) this.blockedKeys.delete(key);
4699
4730
  if (this.active || !this.disposed && this.options.scene.inputOwner === this.options.input && (this.options.scene.state === SceneState.Preparing || this.options.scene.state === SceneState.Ready)) for (const action of this.entries.keys()) this.recompute(action);
4700
4731
  }
4701
4732
  /** @internal */ accepts(code) {
4702
- return this.active && [...this.entries.values()].some((e) => keyLists(e.binding).some((list) => list.keys.includes(code)));
4733
+ return this.active && this.keyboardAllowed && [...this.entries.values()].some((e) => keyLists(e.binding).some((list) => list.keys.includes(code)));
4703
4734
  }
4704
4735
  /** @internal */ capture(override) {
4705
4736
  if (!this.active) return;
@@ -4741,6 +4772,7 @@ var ActionController = class {
4741
4772
  }
4742
4773
  /** Clears held contributions and unconsumed edges; no synthetic button release. */
4743
4774
  reset() {
4775
+ this.blockedKeys.clear();
4744
4776
  for (const values of this.sources.values()) values.clear();
4745
4777
  this.pending = [];
4746
4778
  for (const [action, entry] of this.entries) entry.value = neutral(action);
@@ -4840,10 +4872,15 @@ var KeyboardSource = class extends OwnedInputSource {
4840
4872
  /** Engine-owned routing and update boundary. No raw game-state/alternate movement API. */
4841
4873
  var InputSystem = class {
4842
4874
  actions = /* @__PURE__ */ new Set();
4875
+ adapters = /* @__PURE__ */ new Set();
4843
4876
  keyboards = /* @__PURE__ */ new Map();
4844
4877
  dead = false;
4845
4878
  playback = false;
4846
4879
  frames;
4880
+ /** @internal Device hosts may reject live gestures during Replay playback. */
4881
+ get liveInput() {
4882
+ return !this.dead && !this.playback;
4883
+ }
4847
4884
  get sourceCount() {
4848
4885
  let count = this.keyboards.size;
4849
4886
  for (const actions of this.actions) count += actions.sourceCount;
@@ -4856,6 +4893,14 @@ var InputSystem = class {
4856
4893
  this.actions.delete(actions);
4857
4894
  };
4858
4895
  }
4896
+ /** Host integration boundary; ordinary game input uses ActionSource instead. */
4897
+ addAdapter(adapter) {
4898
+ if (this.dead) throw new EngineError("OWNER_DISPOSED", "Input has been disposed");
4899
+ this.adapters.add(adapter);
4900
+ return () => {
4901
+ this.adapters.delete(adapter);
4902
+ };
4903
+ }
4859
4904
  acceptsKey(code) {
4860
4905
  if (this.playback) return false;
4861
4906
  for (const actions of this.actions) if (actions.accepts(code)) return true;
@@ -4871,6 +4916,9 @@ var InputSystem = class {
4871
4916
  }
4872
4917
  /** @internal Freeze every controller before invoking any callbacks. */
4873
4918
  beginStep(captured) {
4919
+ if (!this.playback && this.adapters.size) {
4920
+ for (const adapter of [...this.adapters]) if (this.adapters.has(adapter)) adapter.sample();
4921
+ }
4874
4922
  if (!this.actions.size) {
4875
4923
  captured?.();
4876
4924
  return;
@@ -4945,7 +4993,9 @@ var InputSystem = class {
4945
4993
  }
4946
4994
  clear() {
4947
4995
  for (const keys of this.keyboards.values()) keys.clear();
4948
- for (const actions of this.actions) actions.reset();
4996
+ cleanupAll([...[...this.actions].map((actions) => () => actions.reset()), ...[...this.adapters].map((adapter) => () => {
4997
+ if (this.adapters.has(adapter)) adapter.reset();
4998
+ })], "Input reset failed");
4949
4999
  }
4950
5000
  /** Device diagnostics only; game state is ActionController.snapshot(). */
4951
5001
  snapshot() {
@@ -4957,8 +5007,13 @@ var InputSystem = class {
4957
5007
  if (this.dead) return;
4958
5008
  this.dead = true;
4959
5009
  try {
4960
- cleanupAll([...[...this.actions].map((a) => () => a.dispose()), ...[...this.keyboards.keys()].map((s) => () => s.dispose())], "Input cleanup failed");
5010
+ cleanupAll([
5011
+ ...[...this.adapters].map((a) => () => a.dispose()),
5012
+ ...[...this.actions].map((a) => () => a.dispose()),
5013
+ ...[...this.keyboards.keys()].map((s) => () => s.dispose())
5014
+ ], "Input cleanup failed");
4961
5015
  } finally {
5016
+ this.adapters.clear();
4962
5017
  this.actions.clear();
4963
5018
  this.keyboards.clear();
4964
5019
  }
@@ -5258,6 +5313,18 @@ var ScenePointer = class {
5258
5313
  lifetime = new Lifetime();
5259
5314
  events = new EventBus();
5260
5315
  removeScene;
5316
+ targets;
5317
+ /** @internal Exactly one Scene virtual-input provider shares the existing router. */
5318
+ attachTargets(provider) {
5319
+ if (this.disposed || this.targets) throw new EngineError("INVALID_VIRTUAL_INPUT", "Scene already has virtual input or is disposed");
5320
+ this.targets = provider;
5321
+ return () => {
5322
+ if (this.targets === provider) this.targets = void 0;
5323
+ };
5324
+ }
5325
+ /** @internal */ pickTarget(data) {
5326
+ return this.targets?.disposed ? void 0 : this.targets?.pick(data);
5327
+ }
5261
5328
  constructor(scene) {
5262
5329
  this.scene = scene;
5263
5330
  this.removeScene = scene.onDispose(() => this.dispose());
@@ -5280,6 +5347,7 @@ var ScenePointer = class {
5280
5347
  /** @internal Device-independent routing state; DOM is only an adapter. */
5281
5348
  var PointerRouting = class {
5282
5349
  released;
5350
+ targetOwners = /* @__PURE__ */ new Map();
5283
5351
  controlOwners = /* @__PURE__ */ new Map();
5284
5352
  scrollOwners = /* @__PURE__ */ new Map();
5285
5353
  suppressed = /* @__PURE__ */ new Set();
@@ -5304,7 +5372,8 @@ var PointerRouting = class {
5304
5372
  keyboardNodes: this.keys.size,
5305
5373
  deliveries: this.deliveries,
5306
5374
  scrollDrags: this.scrollOwners.size,
5307
- controlDrags: this.controlOwners.size
5375
+ controlDrags: this.controlOwners.size,
5376
+ virtualDrags: this.targetOwners.size
5308
5377
  };
5309
5378
  }
5310
5379
  setScene(channel, ui) {
@@ -5330,6 +5399,14 @@ var PointerRouting = class {
5330
5399
  }
5331
5400
  }
5332
5401
  emit(gesture, type, info) {
5402
+ if (gesture.target) {
5403
+ const phase = type === PointerDown ? "down" : type === PointerMove ? "move" : type === PointerUp ? "up" : type === PointerCancel ? "cancel" : void 0;
5404
+ if (phase) {
5405
+ this.deliveries++;
5406
+ gesture.target.deliver(phase, info);
5407
+ }
5408
+ return;
5409
+ }
5333
5410
  if (gesture.channel.disposed || gesture.node?.disposed) return;
5334
5411
  this.deliveries++;
5335
5412
  (gesture.node?.events ?? gesture.channel.events).emit(type, info);
@@ -5337,7 +5414,7 @@ var PointerRouting = class {
5337
5414
  inside(g, data) {
5338
5415
  return Object.freeze({
5339
5416
  ...data,
5340
- inside: g.scroll && g.ui ? g.ui.containsNode(g.scroll, data.screen.x / g.ui.scale, data.screen.y / g.ui.scale) : g.node ? g.ui?.hitTest(data.screen.x / g.ui.scale, data.screen.y / g.ui.scale) === g.node : data.inside
5417
+ inside: g.target ? g.target.contains(data) : g.scroll && g.ui ? g.ui.containsNode(g.scroll, data.screen.x / g.ui.scale, data.screen.y / g.ui.scale) : g.node ? g.ui?.hitTest(data.screen.x / g.ui.scale, data.screen.y / g.ui.scale) === g.node : data.inside
5341
5418
  });
5342
5419
  }
5343
5420
  down(data) {
@@ -5347,6 +5424,11 @@ var PointerRouting = class {
5347
5424
  this.suppressed.delete(data.pointerId);
5348
5425
  const pick = this.ui?.pick(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale);
5349
5426
  const node = pick?.node, bar = pick?.bar;
5427
+ const target = !node && data.button === 0 ? this.channel.pickTarget(data) : void 0;
5428
+ if (target && this.targetOwners.has(target)) {
5429
+ this.suppressed.add(data.pointerId);
5430
+ return;
5431
+ }
5350
5432
  if (bar && data.button === 0 && (!bar.enabled || this.scrollOwners.has(bar.node))) {
5351
5433
  this.suppressed.add(data.pointerId);
5352
5434
  return node;
@@ -5355,20 +5437,24 @@ var PointerRouting = class {
5355
5437
  this.suppressed.add(data.pointerId);
5356
5438
  return node;
5357
5439
  }
5358
- if (!node && !data.inside) return;
5440
+ if (!node && !target && !data.inside) return;
5359
5441
  const g = {
5360
5442
  channel: this.channel,
5361
5443
  last: data,
5362
5444
  primary: data.button === 0,
5363
5445
  start: data,
5364
5446
  scale: this.ui?.scale ?? 1,
5365
- candidates: this.ui?.scrollTargets(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale) ?? [],
5447
+ candidates: !target ? this.ui?.scrollTargets(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale) ?? [] : [],
5366
5448
  candidateRevisions: [],
5367
5449
  vx: 0,
5368
5450
  vy: 0,
5369
5451
  ...node ? {
5370
5452
  node,
5371
5453
  ui: this.ui
5454
+ } : {},
5455
+ ...target ? {
5456
+ target,
5457
+ targetRevision: target.revision
5372
5458
  } : {}
5373
5459
  };
5374
5460
  g.candidateRevisions = g.candidates.map((n) => n.scrollRevision);
@@ -5390,6 +5476,17 @@ var PointerRouting = class {
5390
5476
  if (node?.control) g.controlRevision = node.control.revision;
5391
5477
  this.gestures.set(data.pointerId, g);
5392
5478
  if (node) g.off = node.onDispose(() => this.cancel(data.pointerId));
5479
+ if (target) {
5480
+ this.targetOwners.set(target, data.pointerId);
5481
+ const off = target.subscribe(() => {
5482
+ if (!target.available || target.revision !== g.targetRevision) this.cancel(data.pointerId);
5483
+ });
5484
+ const disposed = target.onDispose(() => this.cancel(data.pointerId));
5485
+ g.off = () => {
5486
+ off();
5487
+ disposed();
5488
+ };
5489
+ }
5393
5490
  cleanupAll([
5394
5491
  () => {
5395
5492
  const port = node?.control;
@@ -5447,7 +5544,7 @@ var PointerRouting = class {
5447
5544
  const dx = Math.abs(data.screen.x - g.start.screen.x), dy = Math.abs(data.screen.y - g.start.screen.y);
5448
5545
  if (dx >= g.control.style.dragThreshold && dx > dy) g.controlLocked = true;
5449
5546
  }
5450
- if (!g.scroll && g.primary && !g.controlLocked) this.claimScroll(g, data);
5547
+ if (!g.target && !g.scroll && g.primary && !g.controlLocked) this.claimScroll(g, data);
5451
5548
  if (this.gestures.get(data.pointerId) !== g) return;
5452
5549
  if (g.bar && g.scroll && g.ui) {
5453
5550
  const bar = canvasScrollbars(g.scroll).find((b) => b.axis === g.bar?.axis);
@@ -5588,6 +5685,7 @@ var PointerRouting = class {
5588
5685
  g = this.gestures.get(data.pointerId);
5589
5686
  if (!g) return;
5590
5687
  this.gestures.delete(data.pointerId);
5688
+ if (g.target) this.targetOwners.delete(g.target);
5591
5689
  if (g.control) this.controlOwners.delete(g.control);
5592
5690
  if (g.scroll) {
5593
5691
  this.scrollOwners.delete(g.scroll);
@@ -5605,7 +5703,7 @@ var PointerRouting = class {
5605
5703
  if (!g.node || g.node.available) this.emit(g, PointerUp, info);
5606
5704
  },
5607
5705
  () => {
5608
- if (!g.scroll && g.primary && info.inside && (!g.node || g.node.available)) this.emit(g, PointerClick, info);
5706
+ if (!g.target && !g.scroll && g.primary && info.inside && (!g.node || g.node.available)) this.emit(g, PointerClick, info);
5609
5707
  },
5610
5708
  () => {
5611
5709
  if (!g.scroll && g.primary && info.inside && g.node?.available) g.node.activate("pointer");
@@ -5617,6 +5715,7 @@ var PointerRouting = class {
5617
5715
  const g = this.gestures.get(id);
5618
5716
  if (!g) return;
5619
5717
  this.gestures.delete(id);
5718
+ if (g.target) this.targetOwners.delete(g.target);
5620
5719
  if (g.control) this.controlOwners.delete(g.control);
5621
5720
  if (g.scroll) {
5622
5721
  this.scrollOwners.delete(g.scroll);
@@ -5687,6 +5786,10 @@ var PointerRouting = class {
5687
5786
  ...[...this.keys.keys()].map((n) => () => this.releaseKeys(n))
5688
5787
  ], "Pointer release failed");
5689
5788
  }
5789
+ /** @internal Host focus changes interrupt virtual devices without changing Canvas editing rules. */
5790
+ releaseVirtual() {
5791
+ cleanupAll([...this.gestures].filter(([, g]) => g.target).map(([id]) => () => this.cancel(id)), "Virtual pointer release failed");
5792
+ }
5690
5793
  dispose() {
5691
5794
  try {
5692
5795
  this.reset();
@@ -5702,6 +5805,716 @@ var PointerRouting = class {
5702
5805
  cleanupAll([...off, () => this.release()], "Pointer routing cleanup failed");
5703
5806
  }
5704
5807
  };
5808
+ // packages/core/src/interaction/virtual-regions.ts
5809
+ var VirtualRegions = Object.freeze({
5810
+ /** A shared edge is not an overlap. Use separate rectangles to leave a center gap. */
5811
+ Left: Object.freeze({
5812
+ kind: "rect",
5813
+ x: 0,
5814
+ y: 0,
5815
+ width: .5,
5816
+ height: 1
5817
+ }),
5818
+ Right: Object.freeze({
5819
+ kind: "rect",
5820
+ x: .5,
5821
+ y: 0,
5822
+ width: .5,
5823
+ height: 1
5824
+ })
5825
+ });
5826
+ /** @internal */
5827
+ function virtualFinite(value, field) {
5828
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new EngineError("INVALID_VIRTUAL_INPUT", `${field} must be finite`);
5829
+ return value;
5830
+ }
5831
+ /** @internal */
5832
+ function virtualBounds(value) {
5833
+ const result = {
5834
+ x: virtualFinite(value.x, "x"),
5835
+ y: virtualFinite(value.y, "y"),
5836
+ width: virtualFinite(value.width, "width"),
5837
+ height: virtualFinite(value.height, "height")
5838
+ };
5839
+ if (result.width < 0 || result.height < 0 || !Number.isFinite(result.x + result.width) || !Number.isFinite(result.y + result.height)) throw new EngineError("INVALID_VIRTUAL_INPUT", "Region extents must be finite and nonnegative");
5840
+ return Object.freeze(result);
5841
+ }
5842
+ /** @internal */
5843
+ function virtualPosition(value, viewport) {
5844
+ const space = value.space ?? "viewport";
5845
+ if (space !== "viewport" && space !== "screen") throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown virtual coordinate space");
5846
+ const x = virtualFinite(value.x, "position.x"), y = virtualFinite(value.y, "position.y");
5847
+ return Object.freeze(space === "screen" ? {
5848
+ x,
5849
+ y
5850
+ } : {
5851
+ x: virtualFinite(viewport.x + x * viewport.width, "resolved x"),
5852
+ y: virtualFinite(viewport.y + y * viewport.height, "resolved y")
5853
+ });
5854
+ }
5855
+ /** @internal Copy configuration before retaining it. Predicate functions are deliberately live. */
5856
+ function checkedVirtualRegion(region) {
5857
+ if (!region || ![
5858
+ "rect",
5859
+ "circle",
5860
+ "custom"
5861
+ ].includes(region.kind)) throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown activation region");
5862
+ const copy = Object.freeze({ ...region });
5863
+ if (copy.kind === "custom") {
5864
+ if (typeof copy.bounds !== "function" || typeof copy.contains !== "function") throw new EngineError("INVALID_VIRTUAL_INPUT", "Custom regions require bounds and contains");
5865
+ } else resolveVirtualRegion(copy, {
5866
+ x: 0,
5867
+ y: 0,
5868
+ width: 1,
5869
+ height: 1
5870
+ });
5871
+ return copy;
5872
+ }
5873
+ /** @internal */
5874
+ function resolveVirtualRegion(region, viewport) {
5875
+ if (region.kind === "custom") {
5876
+ if (typeof region.bounds !== "function" || typeof region.contains !== "function") throw new EngineError("INVALID_VIRTUAL_INPUT", "Custom regions require bounds and contains functions");
5877
+ return {
5878
+ kind: "custom",
5879
+ bounds: virtualBounds(region.bounds(viewport)),
5880
+ test: region.contains
5881
+ };
5882
+ }
5883
+ const center = virtualPosition(region, viewport);
5884
+ if (region.kind === "rect") {
5885
+ const relative = (region.space ?? "viewport") === "viewport";
5886
+ const width = virtualFinite(region.width, "region.width"), height = virtualFinite(region.height, "region.height");
5887
+ if (width < 0 || height < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Region sizes must be nonnegative");
5888
+ return {
5889
+ kind: "rect",
5890
+ bounds: virtualBounds({
5891
+ ...center,
5892
+ width: width * (relative ? viewport.width : 1),
5893
+ height: height * (relative ? viewport.height : 1)
5894
+ })
5895
+ };
5896
+ }
5897
+ const radius = virtualFinite(region.radius, "region.radius") * ((region.space ?? "viewport") === "viewport" ? Math.min(viewport.width, viewport.height) : 1);
5898
+ if (radius < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Region radius must be nonnegative");
5899
+ return {
5900
+ kind: "circle",
5901
+ center,
5902
+ radius,
5903
+ bounds: virtualBounds({
5904
+ x: center.x - radius,
5905
+ y: center.y - radius,
5906
+ width: radius * 2,
5907
+ height: radius * 2
5908
+ })
5909
+ };
5910
+ }
5911
+ /** @internal Rectangles are half-open; circles exclude their circumference. */
5912
+ function virtualContains(region, point, viewport) {
5913
+ const b = region.bounds;
5914
+ if (point.x < b.x || point.y < b.y || point.x >= b.x + b.width || point.y >= b.y + b.height) return false;
5915
+ if (region.kind === "circle") return Math.hypot(point.x - region.center.x, point.y - region.center.y) < region.radius;
5916
+ if (region.kind === "custom") {
5917
+ const value = region.test(point, viewport);
5918
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VIRTUAL_INPUT", "Custom region predicate must return boolean");
5919
+ return value;
5920
+ }
5921
+ return true;
5922
+ }
5923
+ /** @internal Exact built-in intersection; custom bounds are conservatively ambiguous. */
5924
+ function virtualOverlap(a, b) {
5925
+ const x = a.bounds, y = b.bounds;
5926
+ if (Math.min(x.x + x.width, y.x + y.width) <= Math.max(x.x, y.x) || Math.min(x.y + x.height, y.y + y.height) <= Math.max(x.y, y.y)) return false;
5927
+ if (a.kind === "circle" && b.kind === "circle") return Math.hypot(a.center.x - b.center.x, a.center.y - b.center.y) < a.radius + b.radius;
5928
+ const circle = a.kind === "circle" ? a : b.kind === "circle" ? b : void 0;
5929
+ const rect = a.kind === "circle" ? b : a;
5930
+ if (circle && rect.kind === "rect") {
5931
+ const c = circle.center, r = rect.bounds;
5932
+ return Math.hypot(c.x - Math.max(r.x, Math.min(r.x + r.width, c.x)), c.y - Math.max(r.y, Math.min(r.y + r.height, c.y))) < circle.radius;
5933
+ }
5934
+ return true;
5935
+ }
5936
+ // packages/core/src/interaction/virtual-control.ts
5937
+ var VirtualStickMode = {
5938
+ Fixed: "fixed",
5939
+ Floating: "floating",
5940
+ Follow: "follow"
5941
+ };
5942
+ var screenPoint$1 = (x, y) => ({
5943
+ space: "screen",
5944
+ x: virtualFinite(x, "screen.x"),
5945
+ y: virtualFinite(y, "screen.y")
5946
+ });
5947
+ var zero = Object.freeze({
5948
+ x: 0,
5949
+ y: 0
5950
+ });
5951
+ var Start = defineEvent("VirtualStart");
5952
+ var Change = defineEvent("VirtualChange");
5953
+ var End = defineEvent("VirtualEnd");
5954
+ var Cancel = defineEvent("VirtualCancel");
5955
+ var Hold = defineEvent("VirtualHold");
5956
+ function flag(value) {
5957
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VIRTUAL_INPUT", "Virtual control flags must be boolean");
5958
+ return value;
5959
+ }
5960
+ function zone(value) {
5961
+ virtualFinite(value, "deadZone");
5962
+ if (value < 0 || value >= 1) throw new EngineError("INVALID_VIRTUAL_INPUT", "Dead zone must satisfy 0 <= value < 1");
5963
+ return value;
5964
+ }
5965
+ function mode(value) {
5966
+ if (!Object.values(VirtualStickMode).includes(value)) throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown virtual stick mode");
5967
+ return value;
5968
+ }
5969
+ /** @internal Validate before allocating ActionSources or registering a target. */
5970
+ function checkVirtualOptions(options) {
5971
+ checkedVirtualRegion(options.region);
5972
+ positive(options.radius ?? 64, "radius");
5973
+ zone(options.deadZone ?? .15);
5974
+ flag(options.enabled ?? true);
5975
+ flag(options.visible ?? true);
5976
+ flag(options.direction ?? false);
5977
+ mode(options.mode ?? VirtualStickMode.Fixed);
5978
+ if (virtualFinite(options.holdDelay ?? .5, "holdDelay") < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Hold delay must be nonnegative");
5979
+ if (options.position) {
5980
+ virtualFinite(options.position.x, "position.x");
5981
+ virtualFinite(options.position.y, "position.y");
5982
+ if (options.position.space !== void 0 && options.position.space !== "screen" && options.position.space !== "viewport") throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown position space");
5983
+ }
5984
+ const color = options.color ?? 6736821;
5985
+ if (!Number.isInteger(color) || color < 0 || color > 16777215) throw new EngineError("INVALID_VIRTUAL_INPUT", "Virtual color must be RGB");
5986
+ }
5987
+ /** Portable gesture state. Routing, Action composition and rendering retain their existing owners. */
5988
+ var VirtualControl = class {
5989
+ kind;
5990
+ host;
5991
+ binding;
5992
+ life = new Lifetime();
5993
+ events = new EventBus();
5994
+ observers = /* @__PURE__ */ new Set();
5995
+ activation;
5996
+ activeFlag;
5997
+ shown;
5998
+ size;
5999
+ deadZoneValue;
6000
+ version = 0;
6001
+ gesture;
6002
+ vector = zero;
6003
+ dragMode;
6004
+ position;
6005
+ holdDelay;
6006
+ color;
6007
+ directional;
6008
+ constructor(kind, options, host, binding) {
6009
+ this.kind = kind;
6010
+ this.host = host;
6011
+ this.binding = binding;
6012
+ checkVirtualOptions(options);
6013
+ this.activation = checkedVirtualRegion(options.region);
6014
+ this.position = options.position ? Object.freeze({ ...options.position }) : void 0;
6015
+ this.activeFlag = options.enabled ?? true;
6016
+ this.shown = options.visible ?? true;
6017
+ this.size = options.radius ?? (kind === "stick" ? 64 : 28);
6018
+ this.deadZoneValue = options.deadZone ?? .15;
6019
+ this.dragMode = mode(options.mode ?? VirtualStickMode.Fixed);
6020
+ this.holdDelay = options.holdDelay ?? .5;
6021
+ this.color = options.color ?? 6736821;
6022
+ this.directional = kind === "stick" || (options.direction ?? false);
6023
+ this.life.onDispose(binding.onDispose(() => this.dispose()));
6024
+ }
6025
+ get disposed() {
6026
+ return this.life.disposed;
6027
+ }
6028
+ get available() {
6029
+ return !this.disposed && this.activeFlag && this.host.available() && this.binding.active();
6030
+ }
6031
+ get revision() {
6032
+ return this.version;
6033
+ }
6034
+ get enabled() {
6035
+ return this.activeFlag;
6036
+ }
6037
+ set enabled(value) {
6038
+ this.alive();
6039
+ const next = flag(value);
6040
+ if (next === this.activeFlag) return;
6041
+ this.activeFlag = next;
6042
+ this.interrupt();
6043
+ }
6044
+ get visible() {
6045
+ return this.shown;
6046
+ }
6047
+ set visible(value) {
6048
+ this.alive();
6049
+ this.shown = flag(value);
6050
+ }
6051
+ get radius() {
6052
+ return this.size;
6053
+ }
6054
+ set radius(value) {
6055
+ this.alive();
6056
+ const next = positive(value, "radius");
6057
+ if (next === this.size) return;
6058
+ this.size = next;
6059
+ this.interrupt();
6060
+ }
6061
+ get deadZone() {
6062
+ return this.deadZoneValue;
6063
+ }
6064
+ set deadZone(value) {
6065
+ this.alive();
6066
+ const next = zone(value);
6067
+ if (next === this.deadZoneValue) return;
6068
+ this.deadZoneValue = next;
6069
+ this.interrupt();
6070
+ }
6071
+ get region() {
6072
+ return this.activation;
6073
+ }
6074
+ set region(value) {
6075
+ this.alive();
6076
+ const next = checkedVirtualRegion(value);
6077
+ this.host.region(this, next);
6078
+ this.activation = next;
6079
+ this.interrupt();
6080
+ }
6081
+ get stickMode() {
6082
+ return this.dragMode;
6083
+ }
6084
+ set stickMode(value) {
6085
+ this.alive();
6086
+ const next = mode(value);
6087
+ if (next === this.dragMode) return;
6088
+ this.dragMode = next;
6089
+ this.interrupt();
6090
+ }
6091
+ get pointerId() {
6092
+ return this.gesture?.last.pointerId;
6093
+ }
6094
+ get pressed() {
6095
+ return this.gesture !== void 0;
6096
+ }
6097
+ get elapsed() {
6098
+ return this.gesture?.elapsed ?? 0;
6099
+ }
6100
+ get direction() {
6101
+ return this.vector;
6102
+ }
6103
+ get center() {
6104
+ const p = this.gesture?.center ?? this.host.center(this);
6105
+ return Object.freeze(screenPoint$1(p.x, p.y));
6106
+ }
6107
+ get knob() {
6108
+ const center = this.center, last = this.gesture?.last.screen;
6109
+ if (!last || !this.directional) return center;
6110
+ const origin = this.kind === "button" ? this.gesture?.origin ?? center : center;
6111
+ const dx = last.x - origin.x, dy = last.y - origin.y, length = Math.hypot(dx, dy);
6112
+ const scale = length > this.size ? this.size / length : 1;
6113
+ return Object.freeze(screenPoint$1(center.x + dx * scale, center.y + dy * scale));
6114
+ }
6115
+ onDispose(callback) {
6116
+ return this.life.onDispose(callback);
6117
+ }
6118
+ /** @internal Geometry/availability invalidation, not a per-frame observer. */
6119
+ subscribe(callback) {
6120
+ this.alive();
6121
+ this.observers.add(callback);
6122
+ return () => {
6123
+ this.observers.delete(callback);
6124
+ };
6125
+ }
6126
+ onStart(callback, owner) {
6127
+ return this.events.on(Start, callback, owner);
6128
+ }
6129
+ onChange(callback, owner) {
6130
+ return this.events.on(Change, callback, owner);
6131
+ }
6132
+ onEnd(callback, owner) {
6133
+ return this.events.on(End, callback, owner);
6134
+ }
6135
+ onCancel(callback, owner) {
6136
+ return this.events.on(Cancel, callback, owner);
6137
+ }
6138
+ onHold(callback, owner) {
6139
+ return this.events.on(Hold, callback, owner);
6140
+ }
6141
+ /** Release/cancel only; the captured physical ID stays suppressed until its release. */
6142
+ cancel() {
6143
+ this.alive();
6144
+ this.interrupt();
6145
+ }
6146
+ /** @internal */
6147
+ invalidate() {
6148
+ this.interrupt();
6149
+ }
6150
+ alive() {
6151
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Virtual control has been disposed");
6152
+ }
6153
+ interrupt() {
6154
+ this.version++;
6155
+ cleanupAll([...[...this.observers], () => {
6156
+ if (this.gesture) this.finish(true);
6157
+ else this.binding.clear();
6158
+ }], "Virtual gesture interruption failed");
6159
+ }
6160
+ contains(data) {
6161
+ return this.host.contains(this, data);
6162
+ }
6163
+ snapshot(g) {
6164
+ return Object.freeze({
6165
+ control: this,
6166
+ pointerId: g.last.pointerId,
6167
+ pointerType: g.last.pointerType,
6168
+ origin: g.origin,
6169
+ position: Object.freeze(screenPoint$1(g.last.screen.x, g.last.screen.y)),
6170
+ center: g.center,
6171
+ direction: this.vector,
6172
+ inside: this.contains(g.last),
6173
+ elapsed: g.elapsed
6174
+ });
6175
+ }
6176
+ update(g) {
6177
+ if (!this.directional) {
6178
+ this.vector = zero;
6179
+ return;
6180
+ }
6181
+ const start = this.kind === "button" ? g.origin : g.center;
6182
+ const dx = g.last.screen.x - start.x, dy = g.last.screen.y - start.y;
6183
+ const length = Math.hypot(dx, dy);
6184
+ if (this.kind === "stick" && this.dragMode === VirtualStickMode.Follow && length > this.size) g.center = Object.freeze(screenPoint$1(g.last.screen.x - dx * this.size / length, g.last.screen.y - dy * this.size / length));
6185
+ const magnitude = Math.min(1, length / this.size);
6186
+ const output = magnitude <= this.deadZoneValue ? 0 : (magnitude - this.deadZoneValue) / (1 - this.deadZoneValue);
6187
+ this.vector = output === 0 ? zero : Object.freeze({
6188
+ x: dx / length * output,
6189
+ y: dy / length * output
6190
+ });
6191
+ }
6192
+ deliver(type, data) {
6193
+ if (type === "cancel") {
6194
+ if (this.gesture?.last.pointerId === data.pointerId) this.finish(true);
6195
+ return;
6196
+ }
6197
+ if (type === "down") {
6198
+ if (!this.available || this.gesture) return;
6199
+ const center = this.kind === "stick" && this.dragMode !== VirtualStickMode.Fixed ? data.screen : this.host.center(this);
6200
+ const g = {
6201
+ last: data,
6202
+ origin: Object.freeze(screenPoint$1(data.screen.x, data.screen.y)),
6203
+ center: Object.freeze(screenPoint$1(center.x, center.y)),
6204
+ elapsed: 0,
6205
+ held: false
6206
+ };
6207
+ this.gesture = g;
6208
+ this.update(g);
6209
+ this.binding.write(this.vector, true);
6210
+ const event = this.snapshot(g);
6211
+ cleanupAll([() => this.events.emit(Start, event), () => {
6212
+ if (this.gesture === g) this.events.emit(Change, event);
6213
+ }], "Virtual start handlers failed");
6214
+ return;
6215
+ }
6216
+ const g = this.gesture;
6217
+ if (!g || g.last.pointerId !== data.pointerId) return;
6218
+ if (!this.available) {
6219
+ this.interrupt();
6220
+ return;
6221
+ }
6222
+ if (data.screen.x !== g.last.screen.x || data.screen.y !== g.last.screen.y) {
6223
+ g.last = data;
6224
+ this.update(g);
6225
+ this.binding.write(this.vector, true);
6226
+ this.events.emit(Change, this.snapshot(g));
6227
+ }
6228
+ if (type === "up" && this.gesture === g) {
6229
+ g.last = data;
6230
+ this.finish(false);
6231
+ }
6232
+ }
6233
+ /** @internal Explicit presentation clock; no browser/OS timer in Core. */
6234
+ advance(seconds) {
6235
+ const g = this.gesture;
6236
+ if (!g) return;
6237
+ if (!this.available) {
6238
+ this.interrupt();
6239
+ return;
6240
+ }
6241
+ g.elapsed += seconds;
6242
+ if (!g.held && g.elapsed >= this.holdDelay) {
6243
+ g.held = true;
6244
+ this.events.emit(Hold, this.snapshot(g));
6245
+ }
6246
+ }
6247
+ finish(cancelled) {
6248
+ const g = this.gesture;
6249
+ if (!g) return;
6250
+ let event;
6251
+ try {
6252
+ event = this.snapshot(g);
6253
+ } finally {
6254
+ this.gesture = void 0;
6255
+ this.vector = zero;
6256
+ this.binding.clear();
6257
+ }
6258
+ this.events.emit(cancelled ? Cancel : End, event);
6259
+ }
6260
+ dispose() {
6261
+ if (this.disposed) return;
6262
+ cleanupAll([
6263
+ () => this.life.dispose(),
6264
+ () => this.interrupt(),
6265
+ () => this.binding.dispose(),
6266
+ () => this.events.dispose(),
6267
+ () => this.observers.clear(),
6268
+ () => this.host.remove(this)
6269
+ ], "Virtual control cleanup failed");
6270
+ }
6271
+ };
6272
+ var VirtualStick = class extends VirtualControl {
6273
+ /** @internal Use VirtualInput.stick. */
6274
+ constructor(options, host, binding) {
6275
+ super("stick", options, host, binding);
6276
+ }
6277
+ get value() {
6278
+ return this.direction;
6279
+ }
6280
+ get mode() {
6281
+ return this.stickMode;
6282
+ }
6283
+ set mode(value) {
6284
+ this.stickMode = value;
6285
+ }
6286
+ };
6287
+ var VirtualButton = class extends VirtualControl {
6288
+ /** @internal Use VirtualInput.button. */
6289
+ constructor(options, host, binding) {
6290
+ super("button", options, host, binding);
6291
+ }
6292
+ get value() {
6293
+ return this.pressed;
6294
+ }
6295
+ /** Confirmation on normal release, including a drag outside the activation region; never on cancel. */
6296
+ onCommit(callback, owner) {
6297
+ return this.onEnd(callback, owner);
6298
+ }
6299
+ };
6300
+ // packages/core/src/interaction/virtual-input.ts
6301
+ /** Scene-scoped virtual devices; all gameplay values flow through existing ActionSources. */
6302
+ var VirtualInput = class {
6303
+ pointer;
6304
+ input;
6305
+ life = new Lifetime();
6306
+ list = Object.freeze([]);
6307
+ regions = /* @__PURE__ */ new Map();
6308
+ anchors = /* @__PURE__ */ new Map();
6309
+ view = Object.freeze({
6310
+ x: 0,
6311
+ y: 0,
6312
+ width: 800,
6313
+ height: 450
6314
+ });
6315
+ active = true;
6316
+ shown = true;
6317
+ dirty = true;
6318
+ host;
6319
+ constructor(pointer, input) {
6320
+ this.pointer = pointer;
6321
+ this.input = input;
6322
+ if (pointer.disposed) throw new EngineError("OWNER_DISPOSED", "Scene pointer has been disposed");
6323
+ this.host = {
6324
+ available: () => !this.disposed && this.active && input.liveInput,
6325
+ center: (control) => this.anchors.get(control) ?? {
6326
+ x: 0,
6327
+ y: 0
6328
+ },
6329
+ contains: (control, data) => {
6330
+ const region = this.regions.get(control);
6331
+ return region ? virtualContains(region, data.screen, this.view) : false;
6332
+ },
6333
+ region: (control, region) => {
6334
+ const regions = this.resolve(this.view, control, region);
6335
+ this.regions = regions;
6336
+ this.anchors = this.centers(regions, this.view);
6337
+ this.dirty = true;
6338
+ },
6339
+ remove: (control) => {
6340
+ this.list = Object.freeze(this.list.filter((c) => c !== control));
6341
+ this.regions.delete(control);
6342
+ this.anchors.delete(control);
6343
+ }
6344
+ };
6345
+ try {
6346
+ this.life.onDispose(pointer.attachTargets(this));
6347
+ this.life.onDispose(input.addAdapter(this));
6348
+ this.life.onDispose(pointer.onDispose(() => this.dispose()));
6349
+ } catch (error) {
6350
+ this.life.dispose();
6351
+ throw error;
6352
+ }
6353
+ }
6354
+ get disposed() {
6355
+ return this.life.disposed;
6356
+ }
6357
+ get controls() {
6358
+ return this.list;
6359
+ }
6360
+ get viewport() {
6361
+ return this.view;
6362
+ }
6363
+ get enabled() {
6364
+ return this.active;
6365
+ }
6366
+ set enabled(value) {
6367
+ this.alive();
6368
+ this.flag(value);
6369
+ if (this.active === value) return;
6370
+ this.active = value;
6371
+ this.reset();
6372
+ }
6373
+ get visible() {
6374
+ return this.shown;
6375
+ }
6376
+ set visible(value) {
6377
+ this.alive();
6378
+ this.flag(value);
6379
+ this.shown = value;
6380
+ }
6381
+ onDispose(callback) {
6382
+ return this.life.onDispose(callback);
6383
+ }
6384
+ flag(value) {
6385
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VIRTUAL_INPUT", "Virtual input flags must be boolean");
6386
+ }
6387
+ alive() {
6388
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Virtual input has been disposed");
6389
+ }
6390
+ validate(controller, action, kind, options) {
6391
+ this.alive();
6392
+ checkVirtualOptions(options);
6393
+ if (!this.input.hasController(controller) || controller.reader(action).scene !== this.pointer.scene || action.kind !== kind) throw new EngineError("INVALID_ACTION", "Virtual input requires a matching Action kind and a controller in its own Scene/InputSystem");
6394
+ const region = resolveVirtualRegion(options.region, this.view);
6395
+ const peers = this.resolve(this.view);
6396
+ for (const control of this.list) {
6397
+ const other = peers.get(control);
6398
+ if (other && control.kind === (kind === "axis2d" ? "stick" : "button") && virtualOverlap(region, other)) throw new EngineError("VIRTUAL_REGION_OVERLAP", "Same-priority virtual activation regions overlap");
6399
+ }
6400
+ if (options.position) virtualPosition(options.position, this.view);
6401
+ }
6402
+ binding(controller, action) {
6403
+ const source = controller.createSource();
6404
+ return {
6405
+ get disposed() {
6406
+ return source.disposed;
6407
+ },
6408
+ onDispose: (callback) => controller.onDispose(callback),
6409
+ clear: () => source.clear(),
6410
+ dispose: () => source.dispose(),
6411
+ active: () => controller.active,
6412
+ write: (direction, pressed) => source.set(action, action.kind === "axis2d" ? direction : pressed)
6413
+ };
6414
+ }
6415
+ stick(controller, action, options) {
6416
+ this.validate(controller, action, "axis2d", options);
6417
+ const binding = this.binding(controller, action);
6418
+ try {
6419
+ return this.add(new VirtualStick(options, this.host, binding));
6420
+ } catch (error) {
6421
+ binding.dispose();
6422
+ throw error;
6423
+ }
6424
+ }
6425
+ button(controller, action, options) {
6426
+ this.validate(controller, action, "button", options);
6427
+ const binding = this.binding(controller, action);
6428
+ try {
6429
+ return this.add(new VirtualButton(options, this.host, binding));
6430
+ } catch (error) {
6431
+ binding.dispose();
6432
+ throw error;
6433
+ }
6434
+ }
6435
+ add(control) {
6436
+ this.list = Object.freeze([...this.list, control]);
6437
+ this.dirty = true;
6438
+ try {
6439
+ this.refresh();
6440
+ } catch (error) {
6441
+ control.dispose();
6442
+ throw error;
6443
+ }
6444
+ return control;
6445
+ }
6446
+ resolve(view, replaced, region) {
6447
+ const result = /* @__PURE__ */ new Map();
6448
+ for (const control of this.list) result.set(control, resolveVirtualRegion(control === replaced && region ? region : control.region, view));
6449
+ for (let i = 0; i < this.list.length; i++) {
6450
+ const a = this.list[i];
6451
+ if (!a) continue;
6452
+ for (let j = i + 1; j < this.list.length; j++) {
6453
+ const b = this.list[j], x = result.get(a), y = b ? result.get(b) : void 0;
6454
+ if (b && x && y && a.kind === b.kind && virtualOverlap(x, y)) throw new EngineError("VIRTUAL_REGION_OVERLAP", "Same-priority virtual activation regions overlap", {
6455
+ first: i,
6456
+ second: j
6457
+ });
6458
+ }
6459
+ }
6460
+ return result;
6461
+ }
6462
+ centers(regions, view) {
6463
+ return new Map([...regions].map(([control, region]) => {
6464
+ const b = region.bounds;
6465
+ return [control, control.position ? virtualPosition(control.position, view) : Object.freeze({
6466
+ x: b.x + b.width / 2,
6467
+ y: b.y + b.height / 2
6468
+ })];
6469
+ }));
6470
+ }
6471
+ /** Resolve after Canvas layout and before routing/rendering. Layout changes cancel active gestures. */
6472
+ refresh(viewport = this.view) {
6473
+ this.alive();
6474
+ const view = virtualBounds(viewport);
6475
+ const resized = Object.keys(view).some((key) => view[key] !== this.view[key]);
6476
+ if (!this.dirty && !resized && !this.list.some((c) => c.region.kind === "custom")) return;
6477
+ const regions = this.resolve(view), centers = this.centers(regions, view);
6478
+ const changed = this.list.filter((c) => resized || JSON.stringify(regions.get(c)) !== JSON.stringify(this.regions.get(c)) || JSON.stringify(centers.get(c)) !== JSON.stringify(this.anchors.get(c)));
6479
+ this.view = view;
6480
+ this.regions = regions;
6481
+ this.anchors = centers;
6482
+ this.dirty = false;
6483
+ cleanupAll(changed.map((c) => () => c.invalidate()), "Virtual layout cancellation failed");
6484
+ }
6485
+ pick(data) {
6486
+ if (this.disposed || !this.active || !this.input.liveInput || data.button !== 0) return;
6487
+ this.refresh();
6488
+ for (const kind of ["button", "stick"]) for (const control of this.list) if (control.kind === kind && control.available && control.contains(data)) return control;
6489
+ }
6490
+ sample() {
6491
+ if (this.disposed) return;
6492
+ this.refresh();
6493
+ cleanupAll(this.list.filter((c) => c.pressed && !c.available).map((c) => () => c.invalidate()), "Virtual availability cancellation failed");
6494
+ }
6495
+ advance(seconds) {
6496
+ this.alive();
6497
+ if (virtualFinite(seconds, "seconds") < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Presentation seconds must be nonnegative");
6498
+ cleanupAll(this.list.map((c) => () => {
6499
+ if (!c.disposed) c.advance(seconds);
6500
+ }), "Virtual hold callbacks failed");
6501
+ }
6502
+ reset() {
6503
+ cleanupAll(this.list.map((c) => () => c.invalidate()), "Virtual input reset failed");
6504
+ }
6505
+ dispose() {
6506
+ if (this.disposed) return;
6507
+ cleanupAll([
6508
+ () => this.life.dispose(),
6509
+ ...this.list.map((c) => () => c.dispose()),
6510
+ () => {
6511
+ this.regions.clear();
6512
+ this.anchors.clear();
6513
+ this.list = Object.freeze([]);
6514
+ }
6515
+ ], "Virtual input cleanup failed");
6516
+ }
6517
+ };
5705
6518
  // packages/core/src/runtime/engine.ts
5706
6519
  var Engine = class {
5707
6520
  input = new InputSystem();
@@ -6269,12 +7082,12 @@ var ReplayPlayer = class {
6269
7082
  this.engine = engine;
6270
7083
  this.data = parseReplayData(data);
6271
7084
  this.bound = bindings(engine, options.controllers);
6272
- if (this.data.engineVersion !== "0.1.3" || engine.random.seed !== this.data.random.seed) throw new EngineError("REPLAY_INCOMPATIBLE", "Engine version or initial seed does not match Replay");
7085
+ if (this.data.engineVersion !== "0.1.5" || engine.random.seed !== this.data.random.seed) throw new EngineError("REPLAY_INCOMPATIBLE", "Engine version or initial seed does not match Replay");
6273
7086
  if (!compatible(this.bound.map(([id, c]) => ({
6274
7087
  id,
6275
7088
  actions: c.schema()
6276
7089
  })), this.data.controllers)) throw new EngineError("REPLAY_INCOMPATIBLE", "Controller IDs or Action names/kinds/normalization do not match Replay");
6277
- for (const [key, value] of Object.entries(options.expectedMetadata ?? {})) if (!compatible(copyJson(value), this.data.metadata[key])) throw new EngineError("REPLAY_INCOMPATIBLE", `Metadata mismatch: ${key}`);
7090
+ for (const [key, value] of Object.entries(options.expectedMetadata ?? {})) if (!Object.hasOwn(this.data.metadata, key) || !compatible(copyJson(value), this.data.metadata[key])) throw new EngineError("REPLAY_INCOMPATIBLE", `Metadata mismatch: ${key}`);
6278
7091
  this.detach = engine.attachExecution(this);
6279
7092
  try {
6280
7093
  engine.input.lockPlayback();
@@ -6515,7 +7328,7 @@ function checkedStyle(value) {
6515
7328
  "highlight"
6516
7329
  ]
6517
7330
  };
6518
- if (typeof v !== "string" || (choices[key] ? !choices[key].includes(v) : !v.length)) throw new EngineError("INVALID_UI", `Invalid UI ${key}`);
7331
+ if (typeof v !== "string" || (Object.hasOwn(choices, key) ? !choices[key]?.includes(v) : !validFontFamily(v))) throw new EngineError("INVALID_UI", `Invalid UI ${key}`);
6519
7332
  }
6520
7333
  }
6521
7334
  return Object.freeze(result);
@@ -6947,6 +7760,10 @@ function CanvasScroll(children = [], style = {}) {
6947
7760
  clip: true
6948
7761
  });
6949
7762
  }
7763
+ function validFontFamily(value) {
7764
+ const syntax = value.replace(/"(?:\\[^\r\n]|[^"\\])*"|'(?:\\[^\r\n]|[^'\\])*'/g, "family").replace(/\\(?:[0-9a-f]{1,6}[ \t]?|[^\r\n])/gi, "x");
7765
+ return value.trim().length > 0 && !/[\r\n\0]/.test(value) && !/[;{}<>\\"']|url\s*\(/i.test(syntax);
7766
+ }
6950
7767
  // packages/core/src/ui/canvas-input.ts
6951
7768
  var boolean = (value) => {
6952
7769
  if (typeof value !== "boolean") throw new EngineError("INVALID_UI", "Control flag must be boolean");
@@ -8163,6 +8980,6 @@ var CanvasUI = class {
8163
8980
  ], "Canvas UI cleanup failed");
8164
8981
  }
8165
8982
  };
8166
- export { Action, ActionController, ActionMovement, ActionSource, Actor, ActorState, Aspect, BodyType, Camera, CameraFollow, Cancellation, CanvasButton, CanvasCheckbox, CanvasCheckboxNode, CanvasColumn, CanvasHoldButton, CanvasInputNode, CanvasLabel, CanvasNode, CanvasNumberInput, CanvasNumberInputNode, CanvasPanel, CanvasRow, CanvasScroll, CanvasSelect, CanvasSelectNode, CanvasSlider, CanvasSliderNode, CanvasTextEditorNode, CanvasTextInput, CanvasTextInputNode, CanvasUI, Character, CharacterActor, Collider, CollisionBody, CollisionResponse, CollisionShape, CollisionWorld, Component, ComponentPhase, ContactEnter, ContactExit, ContactStay, DEFAULT_RANDOM_STREAM, ENGINE_VERSION, Easing, Engine, EngineError, EventBus, FixedStepRunner, GeometryTilemap, GeometryTilemapLayer, Grid, HexOrientation, InputSystem, KeyCode, KeyboardBindings, KeyboardSource, Lifetime, Motion, PointerCancel, PointerClick, PointerDown, PointerEnter, PointerLeave, PointerMove, PointerRouting, PointerUp, RANDOM_ALGORITHM, RandomManager, RandomStream, Renderable, ReplayPlayer, ReplayRecorder, Scene, ScenePointer, SceneState, Shape, SpriteAnimation, SpriteAnimationState, SpriteFit, SpriteSheet, TileContactEnter, TileContactExit, TileContactStay, TileGeometry, Tilemap, TilemapLayer, Transform, Tween, TweenBinding, TweenPlayback, TweenScheduler, actorTween, canvasControlText, canvasHoverHighlight, canvasNumericRange, canvasNumericValue, canvasOpacity, canvasScrollbars, canvasSliderGeometry, canvasTween, cleanupAll, containsScreenPoint, containsUI, createViewTransform, defaultCanvasStyle, defineActions, defineComponent, defineEvent, deriveStreamSeed, imageSize, intersectUI, isKeyCode, layoutCanvas, nativeSession, parseKeyCode, parseReplayData, positive, screenPoint, screenToPhysical, screenToWorld, scrollbarContains, sliderTween, spriteFrame, spriteFrameBounds, tweenFinite, worldPoint, worldToScreen, wrapCanvasText };
8983
+ export { Action, ActionController, ActionMovement, ActionSource, Actor, ActorState, Aspect, BodyType, Camera, CameraFollow, Cancellation, CanvasButton, CanvasCheckbox, CanvasCheckboxNode, CanvasColumn, CanvasHoldButton, CanvasInputNode, CanvasLabel, CanvasNode, CanvasNumberInput, CanvasNumberInputNode, CanvasPanel, CanvasRow, CanvasScroll, CanvasSelect, CanvasSelectNode, CanvasSlider, CanvasSliderNode, CanvasTextEditorNode, CanvasTextInput, CanvasTextInputNode, CanvasUI, Character, CharacterActor, Collider, CollisionBody, CollisionResponse, CollisionShape, CollisionWorld, Component, ComponentPhase, ContactEnter, ContactExit, ContactStay, DEFAULT_RANDOM_STREAM, ENGINE_VERSION, Easing, Engine, EngineError, EventBus, FixedStepRunner, GeometryTilemap, GeometryTilemapLayer, Grid, HexOrientation, InputSystem, KeyCode, KeyboardBindings, KeyboardSource, Lifetime, Motion, PointerCancel, PointerClick, PointerDown, PointerEnter, PointerLeave, PointerMove, PointerRouting, PointerUp, RANDOM_ALGORITHM, RandomManager, RandomStream, Renderable, ReplayPlayer, ReplayRecorder, Scene, ScenePointer, SceneState, Shape, SpriteAnimation, SpriteAnimationState, SpriteFit, SpriteSheet, TileContactEnter, TileContactExit, TileContactStay, TileGeometry, Tilemap, TilemapLayer, Transform, Tween, TweenBinding, TweenPlayback, TweenScheduler, VirtualButton, VirtualControl, VirtualInput, VirtualRegions, VirtualStick, VirtualStickMode, actorTween, canvasControlText, canvasHoverHighlight, canvasNumericRange, canvasNumericValue, canvasOpacity, canvasScrollbars, canvasSliderGeometry, canvasTween, cleanupAll, containsScreenPoint, containsUI, createViewTransform, defaultCanvasStyle, defineActions, defineComponent, defineEvent, deriveStreamSeed, imageSize, intersectUI, isKeyCode, layoutCanvas, nativeSession, parseKeyCode, parseReplayData, positive, screenPoint, screenToPhysical, screenToWorld, scrollbarContains, sliderTween, spriteFrame, spriteFrameBounds, tweenFinite, worldPoint, worldToScreen, wrapCanvasText };
8167
8984
 
8168
8985
  //# sourceMappingURL=index.js.map