@simpludify/core 0.1.4 → 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.4";
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;
@@ -4877,6 +4877,10 @@ var InputSystem = class {
4877
4877
  dead = false;
4878
4878
  playback = false;
4879
4879
  frames;
4880
+ /** @internal Device hosts may reject live gestures during Replay playback. */
4881
+ get liveInput() {
4882
+ return !this.dead && !this.playback;
4883
+ }
4880
4884
  get sourceCount() {
4881
4885
  let count = this.keyboards.size;
4882
4886
  for (const actions of this.actions) count += actions.sourceCount;
@@ -5309,6 +5313,18 @@ var ScenePointer = class {
5309
5313
  lifetime = new Lifetime();
5310
5314
  events = new EventBus();
5311
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
+ }
5312
5328
  constructor(scene) {
5313
5329
  this.scene = scene;
5314
5330
  this.removeScene = scene.onDispose(() => this.dispose());
@@ -5331,6 +5347,7 @@ var ScenePointer = class {
5331
5347
  /** @internal Device-independent routing state; DOM is only an adapter. */
5332
5348
  var PointerRouting = class {
5333
5349
  released;
5350
+ targetOwners = /* @__PURE__ */ new Map();
5334
5351
  controlOwners = /* @__PURE__ */ new Map();
5335
5352
  scrollOwners = /* @__PURE__ */ new Map();
5336
5353
  suppressed = /* @__PURE__ */ new Set();
@@ -5355,7 +5372,8 @@ var PointerRouting = class {
5355
5372
  keyboardNodes: this.keys.size,
5356
5373
  deliveries: this.deliveries,
5357
5374
  scrollDrags: this.scrollOwners.size,
5358
- controlDrags: this.controlOwners.size
5375
+ controlDrags: this.controlOwners.size,
5376
+ virtualDrags: this.targetOwners.size
5359
5377
  };
5360
5378
  }
5361
5379
  setScene(channel, ui) {
@@ -5381,6 +5399,14 @@ var PointerRouting = class {
5381
5399
  }
5382
5400
  }
5383
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
+ }
5384
5410
  if (gesture.channel.disposed || gesture.node?.disposed) return;
5385
5411
  this.deliveries++;
5386
5412
  (gesture.node?.events ?? gesture.channel.events).emit(type, info);
@@ -5388,7 +5414,7 @@ var PointerRouting = class {
5388
5414
  inside(g, data) {
5389
5415
  return Object.freeze({
5390
5416
  ...data,
5391
- 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
5392
5418
  });
5393
5419
  }
5394
5420
  down(data) {
@@ -5398,6 +5424,11 @@ var PointerRouting = class {
5398
5424
  this.suppressed.delete(data.pointerId);
5399
5425
  const pick = this.ui?.pick(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale);
5400
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
+ }
5401
5432
  if (bar && data.button === 0 && (!bar.enabled || this.scrollOwners.has(bar.node))) {
5402
5433
  this.suppressed.add(data.pointerId);
5403
5434
  return node;
@@ -5406,20 +5437,24 @@ var PointerRouting = class {
5406
5437
  this.suppressed.add(data.pointerId);
5407
5438
  return node;
5408
5439
  }
5409
- if (!node && !data.inside) return;
5440
+ if (!node && !target && !data.inside) return;
5410
5441
  const g = {
5411
5442
  channel: this.channel,
5412
5443
  last: data,
5413
5444
  primary: data.button === 0,
5414
5445
  start: data,
5415
5446
  scale: this.ui?.scale ?? 1,
5416
- 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) ?? [] : [],
5417
5448
  candidateRevisions: [],
5418
5449
  vx: 0,
5419
5450
  vy: 0,
5420
5451
  ...node ? {
5421
5452
  node,
5422
5453
  ui: this.ui
5454
+ } : {},
5455
+ ...target ? {
5456
+ target,
5457
+ targetRevision: target.revision
5423
5458
  } : {}
5424
5459
  };
5425
5460
  g.candidateRevisions = g.candidates.map((n) => n.scrollRevision);
@@ -5441,6 +5476,17 @@ var PointerRouting = class {
5441
5476
  if (node?.control) g.controlRevision = node.control.revision;
5442
5477
  this.gestures.set(data.pointerId, g);
5443
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
+ }
5444
5490
  cleanupAll([
5445
5491
  () => {
5446
5492
  const port = node?.control;
@@ -5498,7 +5544,7 @@ var PointerRouting = class {
5498
5544
  const dx = Math.abs(data.screen.x - g.start.screen.x), dy = Math.abs(data.screen.y - g.start.screen.y);
5499
5545
  if (dx >= g.control.style.dragThreshold && dx > dy) g.controlLocked = true;
5500
5546
  }
5501
- 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);
5502
5548
  if (this.gestures.get(data.pointerId) !== g) return;
5503
5549
  if (g.bar && g.scroll && g.ui) {
5504
5550
  const bar = canvasScrollbars(g.scroll).find((b) => b.axis === g.bar?.axis);
@@ -5639,6 +5685,7 @@ var PointerRouting = class {
5639
5685
  g = this.gestures.get(data.pointerId);
5640
5686
  if (!g) return;
5641
5687
  this.gestures.delete(data.pointerId);
5688
+ if (g.target) this.targetOwners.delete(g.target);
5642
5689
  if (g.control) this.controlOwners.delete(g.control);
5643
5690
  if (g.scroll) {
5644
5691
  this.scrollOwners.delete(g.scroll);
@@ -5656,7 +5703,7 @@ var PointerRouting = class {
5656
5703
  if (!g.node || g.node.available) this.emit(g, PointerUp, info);
5657
5704
  },
5658
5705
  () => {
5659
- 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);
5660
5707
  },
5661
5708
  () => {
5662
5709
  if (!g.scroll && g.primary && info.inside && g.node?.available) g.node.activate("pointer");
@@ -5668,6 +5715,7 @@ var PointerRouting = class {
5668
5715
  const g = this.gestures.get(id);
5669
5716
  if (!g) return;
5670
5717
  this.gestures.delete(id);
5718
+ if (g.target) this.targetOwners.delete(g.target);
5671
5719
  if (g.control) this.controlOwners.delete(g.control);
5672
5720
  if (g.scroll) {
5673
5721
  this.scrollOwners.delete(g.scroll);
@@ -5738,6 +5786,10 @@ var PointerRouting = class {
5738
5786
  ...[...this.keys.keys()].map((n) => () => this.releaseKeys(n))
5739
5787
  ], "Pointer release failed");
5740
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
+ }
5741
5793
  dispose() {
5742
5794
  try {
5743
5795
  this.reset();
@@ -5753,6 +5805,716 @@ var PointerRouting = class {
5753
5805
  cleanupAll([...off, () => this.release()], "Pointer routing cleanup failed");
5754
5806
  }
5755
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
+ };
5756
6518
  // packages/core/src/runtime/engine.ts
5757
6519
  var Engine = class {
5758
6520
  input = new InputSystem();
@@ -6320,7 +7082,7 @@ var ReplayPlayer = class {
6320
7082
  this.engine = engine;
6321
7083
  this.data = parseReplayData(data);
6322
7084
  this.bound = bindings(engine, options.controllers);
6323
- if (this.data.engineVersion !== "0.1.4" || 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");
6324
7086
  if (!compatible(this.bound.map(([id, c]) => ({
6325
7087
  id,
6326
7088
  actions: c.schema()
@@ -8218,6 +8980,6 @@ var CanvasUI = class {
8218
8980
  ], "Canvas UI cleanup failed");
8219
8981
  }
8220
8982
  };
8221
- 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 };
8222
8984
 
8223
8985
  //# sourceMappingURL=index.js.map