@simpludify/core 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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.6";
1587
1587
  // packages/core/src/scene/actor.ts
1588
1588
  var inputPhase$1 = ComponentPhase.Input;
1589
1589
  var updatePhase$1 = ComponentPhase.Update;
@@ -2814,7 +2814,7 @@ function boxCircle(box, circle, out, tolerance) {
2814
2814
  out.normalY = nx * box.sin + ny * box.cos;
2815
2815
  return true;
2816
2816
  }
2817
- function axis(a, b, x, y, out, tolerance) {
2817
+ function axis$1(a, b, x, y, out, tolerance) {
2818
2818
  const distance = (b.x - a.x) * x + (b.y - a.y) * y;
2819
2819
  const depth = a.hx * Math.abs(a.cos * x + a.sin * y) + a.hy * Math.abs(-a.sin * x + a.cos * y) + (b.hx * Math.abs(b.cos * x + b.sin * y) + b.hy * Math.abs(-b.sin * x + b.cos * y)) - Math.abs(distance);
2820
2820
  if (depth < -tolerance) return false;
@@ -2845,7 +2845,7 @@ function contactManifold(a, b, out, tolerance = 0) {
2845
2845
  }
2846
2846
  if (b.collider.shape === CollisionShape.Circle) return boxCircle(ag, bg, out, tolerance);
2847
2847
  out.depth = Infinity;
2848
- return axis(ag, bg, ag.cos, ag.sin, out, tolerance) && axis(ag, bg, -ag.sin, ag.cos, out, tolerance) && axis(ag, bg, bg.cos, bg.sin, out, tolerance) && axis(ag, bg, -bg.sin, bg.cos, out, tolerance);
2848
+ return axis$1(ag, bg, ag.cos, ag.sin, out, tolerance) && axis$1(ag, bg, -ag.sin, ag.cos, out, tolerance) && axis$1(ag, bg, bg.cos, bg.sin, out, tolerance) && axis$1(ag, bg, -bg.sin, bg.cos, out, tolerance);
2849
2849
  }
2850
2850
  // packages/core/src/game/collision-response.ts
2851
2851
  /** Translate along the minimum separation axis, preserving tangential movement intent. */
@@ -4080,6 +4080,135 @@ var TileGeometry = class TileGeometry {
4080
4080
  for (let dy = -radius; dy <= radius; dy++) for (let dx = Math.max(-radius, -dy - radius); dx <= Math.min(radius, -dy + radius); dx++) yield tile(x + dx, y + dy);
4081
4081
  }
4082
4082
  };
4083
+ // packages/core/src/interaction/gamepad-bindings.ts
4084
+ /** Standard mapping physical positions, not product-specific printed labels. */
4085
+ var GamepadButton = Object.freeze({
4086
+ South: 0,
4087
+ East: 1,
4088
+ West: 2,
4089
+ North: 3,
4090
+ LeftShoulder: 4,
4091
+ RightShoulder: 5,
4092
+ LeftTrigger: 6,
4093
+ RightTrigger: 7,
4094
+ Select: 8,
4095
+ Start: 9,
4096
+ LeftStick: 10,
4097
+ RightStick: 11,
4098
+ DPadUp: 12,
4099
+ DPadDown: 13,
4100
+ DPadLeft: 14,
4101
+ DPadRight: 15,
4102
+ Home: 16
4103
+ });
4104
+ var GamepadAxis = Object.freeze({
4105
+ LeftX: 0,
4106
+ LeftY: 1,
4107
+ RightX: 2,
4108
+ RightY: 3
4109
+ });
4110
+ var axis = (index) => Object.freeze({ axis: index });
4111
+ /** Ready-to-use data; game Actions retain application-defined names. */
4112
+ var GamepadBindings = Object.freeze({
4113
+ LeftStick: Object.freeze({
4114
+ x: axis(0),
4115
+ y: axis(1)
4116
+ }),
4117
+ RightStick: Object.freeze({
4118
+ x: axis(2),
4119
+ y: axis(3)
4120
+ }),
4121
+ DPad: Object.freeze({
4122
+ x: Object.freeze({
4123
+ negative: Object.freeze([14]),
4124
+ positive: Object.freeze([15])
4125
+ }),
4126
+ y: Object.freeze({
4127
+ negative: Object.freeze([12]),
4128
+ positive: Object.freeze([13])
4129
+ })
4130
+ }),
4131
+ Move: Object.freeze({
4132
+ x: Object.freeze({
4133
+ axis: 0,
4134
+ negative: Object.freeze([14]),
4135
+ positive: Object.freeze([15])
4136
+ }),
4137
+ y: Object.freeze({
4138
+ axis: 1,
4139
+ negative: Object.freeze([12]),
4140
+ positive: Object.freeze([13])
4141
+ })
4142
+ })
4143
+ });
4144
+ var fail = () => {
4145
+ throw new EngineError("INVALID_GAMEPAD_BINDING", "Invalid gamepad binding");
4146
+ };
4147
+ function gamepadIndex(value) {
4148
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return fail();
4149
+ return value;
4150
+ }
4151
+ function gamepadDeadZone(value) {
4152
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value >= 1) return fail();
4153
+ return value;
4154
+ }
4155
+ function record(value, keys) {
4156
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((k) => !keys.includes(k))) return fail();
4157
+ return Object.fromEntries(Object.entries(value));
4158
+ }
4159
+ function axisBinding(value) {
4160
+ const v = record(value, [
4161
+ "axis",
4162
+ "invert",
4163
+ "negative",
4164
+ "positive",
4165
+ "deadZone"
4166
+ ]);
4167
+ const indices = (value) => {
4168
+ if (value === void 0) return Object.freeze([]);
4169
+ if (!Array.isArray(value)) return fail();
4170
+ return Object.freeze([...new Set(value.map(gamepadIndex))]);
4171
+ };
4172
+ if (v.invert !== void 0 && typeof v.invert !== "boolean") return fail();
4173
+ return Object.freeze({
4174
+ ...v.axis === void 0 ? {} : { axis: gamepadIndex(v.axis) },
4175
+ ...v.invert === void 0 ? {} : { invert: v.invert },
4176
+ negative: indices(v.negative),
4177
+ positive: indices(v.positive),
4178
+ ...v.deadZone === void 0 ? {} : { deadZone: gamepadDeadZone(v.deadZone) }
4179
+ });
4180
+ }
4181
+ function checkedGamepadBindings(actions, value) {
4182
+ const v = record(value, Object.keys(actions));
4183
+ return Object.freeze(Object.entries(v).map(([name, binding]) => {
4184
+ if (!Object.hasOwn(actions, name)) return fail();
4185
+ const action = actions[name];
4186
+ if (!action) return fail();
4187
+ let result;
4188
+ if (action.kind === "button") {
4189
+ const b = record(binding, ["buttons"]);
4190
+ if (b.buttons !== void 0 && !Array.isArray(b.buttons)) return fail();
4191
+ result = Object.freeze({ buttons: Object.freeze((b.buttons ?? []).map((item) => {
4192
+ if (typeof item === "number") return gamepadIndex(item);
4193
+ const b = record(item, ["button", "threshold"]), threshold = b.threshold ?? .5;
4194
+ if (typeof threshold !== "number" || !Number.isFinite(threshold) || threshold <= 0 || threshold > 1) return fail();
4195
+ return Object.freeze({
4196
+ button: gamepadIndex(b.button),
4197
+ threshold
4198
+ });
4199
+ })) });
4200
+ } else if (action.kind === "axis1d") result = axisBinding(binding);
4201
+ else {
4202
+ const keys = action.kind === "axis2d" ? ["x", "y"] : [
4203
+ "x",
4204
+ "y",
4205
+ "z"
4206
+ ], b = record(binding, keys);
4207
+ result = Object.freeze(Object.fromEntries(keys.map((k) => [k, axisBinding(b[k] ?? {})])));
4208
+ }
4209
+ return Object.freeze([action, result]);
4210
+ }));
4211
+ }
4083
4212
  // packages/core/src/interaction/key-code.ts
4084
4213
  /** Physical UI Events code values plus F13–F24; Unidentified is not bindable. */
4085
4214
  var KeyCode = {
@@ -4275,6 +4404,27 @@ function parseKeyCode(value) {
4275
4404
  if (!isKeyCode(value)) throw new EngineError("INVALID_KEY_CODE", "Unknown physical keyboard code", { value });
4276
4405
  return value;
4277
4406
  }
4407
+ // packages/core/src/interaction/physical-input.ts
4408
+ var InputDevice = Object.freeze({
4409
+ Keyboard: "keyboard",
4410
+ Mouse: "mouse",
4411
+ Gamepad: "gamepad"
4412
+ });
4413
+ /** Standard PointerEvent.button indices; these are not the buttons bit mask. */
4414
+ var MouseButton = Object.freeze({
4415
+ Left: 0,
4416
+ Middle: 1,
4417
+ Right: 2,
4418
+ Back: 3,
4419
+ Forward: 4
4420
+ });
4421
+ function isMouseButton(value) {
4422
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 4;
4423
+ }
4424
+ function parseMouseButton(value) {
4425
+ if (!isMouseButton(value)) throw new EngineError("INVALID_MOUSE_BUTTON", "Expected a standard mouse button index (0–4)");
4426
+ return value;
4427
+ }
4278
4428
  // packages/core/src/interaction/action-bindings.ts
4279
4429
  function checkedBinding(action, value) {
4280
4430
  const record = (v, allowed) => {
@@ -4288,43 +4438,123 @@ function checkedBinding(action, value) {
4288
4438
  if (!Array.isArray(v)) throw new EngineError("INVALID_BINDING", "Key list must be an array");
4289
4439
  return Object.freeze([...new Set(v.map(parseKeyCode))]);
4290
4440
  };
4291
- const axis = (v) => {
4292
- const r = record(v ?? {}, ["negative", "positive"]);
4441
+ const mice = (v) => {
4442
+ if (!Array.isArray(v)) throw new EngineError("INVALID_BINDING", "Mouse button list must be an array");
4443
+ return Object.freeze([...new Set(v.map(parseMouseButton))]);
4444
+ };
4445
+ const axis = (v, root = false) => {
4446
+ const r = record(v ?? {}, [
4447
+ "negative",
4448
+ "positive",
4449
+ "mouseNegative",
4450
+ "mousePositive",
4451
+ ...root ? ["gamepad"] : []
4452
+ ]);
4293
4453
  return Object.freeze({
4294
4454
  negative: codes(r.negative),
4295
- positive: codes(r.positive)
4455
+ positive: codes(r.positive),
4456
+ ...r.mouseNegative === void 0 ? {} : { mouseNegative: mice(r.mouseNegative) },
4457
+ ...r.mousePositive === void 0 ? {} : { mousePositive: mice(r.mousePositive) }
4296
4458
  });
4297
4459
  };
4298
- if (action.kind === "button") return Object.freeze({ keys: codes(record(value, ["keys"]).keys) });
4299
- if (action.kind === "axis1d") return axis(value);
4300
- const r = record(value, action.kind === "axis2d" ? ["x", "y"] : [
4460
+ const r = record(value, action.kind === "button" ? [
4461
+ "keys",
4462
+ "mouse",
4463
+ "gamepad"
4464
+ ] : action.kind === "axis1d" ? [
4465
+ "negative",
4466
+ "positive",
4467
+ "mouseNegative",
4468
+ "mousePositive",
4469
+ "gamepad"
4470
+ ] : action.kind === "axis2d" ? [
4301
4471
  "x",
4302
4472
  "y",
4303
- "z"
4473
+ "gamepad"
4474
+ ] : [
4475
+ "x",
4476
+ "y",
4477
+ "z",
4478
+ "gamepad"
4304
4479
  ]);
4305
- return Object.freeze(action.kind === "axis2d" ? {
4480
+ let gamepad;
4481
+ if (r.gamepad !== void 0) {
4482
+ const checked = checkedGamepadBindings({ [action.name]: action }, { [action.name]: r.gamepad })[0];
4483
+ if (!checked) throw new EngineError("INVALID_BINDING", "Missing gamepad binding");
4484
+ gamepad = checked[1];
4485
+ }
4486
+ const base = action.kind === "button" ? {
4487
+ keys: codes(r.keys),
4488
+ ...r.mouse === void 0 ? {} : { mouse: mice(r.mouse) }
4489
+ } : action.kind === "axis1d" ? axis(value, true) : action.kind === "axis2d" ? {
4306
4490
  x: axis(r.x),
4307
4491
  y: axis(r.y)
4308
4492
  } : {
4309
4493
  x: axis(r.x),
4310
4494
  y: axis(r.y),
4311
4495
  z: axis(r.z)
4496
+ };
4497
+ return Object.freeze({
4498
+ ...base,
4499
+ ...gamepad === void 0 ? {} : { gamepad }
4312
4500
  });
4313
4501
  }
4314
4502
  function keyLists(binding, path = "") {
4315
4503
  const lists = [];
4316
- for (const [key, value] of Object.entries(binding)) if (Array.isArray(value)) lists.push({
4504
+ for (const [key, value] of Object.entries(binding)) if (key === "keys" || key === "negative" || key === "positive") lists.push({
4317
4505
  path: path + key,
4318
4506
  keys: value
4319
4507
  });
4320
- else lists.push(...keyLists(value, `${path}${key}.`));
4508
+ else if (key === "x" || key === "y" || key === "z") lists.push(...keyLists(value, `${path}${key}.`));
4321
4509
  return lists;
4322
4510
  }
4511
+ function mouseLists(binding, path = "") {
4512
+ const lists = [];
4513
+ for (const [key, value] of Object.entries(binding)) if (key === "mouse" || key === "mouseNegative" || key === "mousePositive") lists.push({
4514
+ path: path + key,
4515
+ buttons: value
4516
+ });
4517
+ else if (key === "x" || key === "y" || key === "z") lists.push(...mouseLists(value, `${path}${key}.`));
4518
+ return lists;
4519
+ }
4520
+ function gamepadControls(binding, path = "gamepad") {
4521
+ const result = [];
4522
+ for (const [name, value] of Object.entries(binding)) if (name === "axis" && typeof value === "number") result.push({
4523
+ kind: "axis",
4524
+ index: value,
4525
+ path: `${path}.axis`
4526
+ });
4527
+ else if (name === "buttons" || name === "negative" || name === "positive") for (const item of value) result.push({
4528
+ kind: "button",
4529
+ index: typeof item === "number" ? item : item.button,
4530
+ path: `${path}.${name}`
4531
+ });
4532
+ else if (name === "x" || name === "y" || name === "z") result.push(...gamepadControls(value, `${path}.${name}`));
4533
+ return result;
4534
+ }
4535
+ function editGamepad(current, patch, remove) {
4536
+ if (Array.isArray(patch)) {
4537
+ const old = Array.isArray(current) ? current : [];
4538
+ const control = (v) => v && typeof v === "object" && "button" in v ? v.button : v;
4539
+ return remove ? old.filter((v) => !patch.some((p) => control(p) === control(v))) : [...new Map([...old, ...patch].map((v) => [JSON.stringify(v), v])).values()];
4540
+ }
4541
+ if (patch && typeof patch === "object") {
4542
+ const result = current && typeof current === "object" ? { ...current } : {};
4543
+ for (const [key, value] of Object.entries(patch)) {
4544
+ const edited = editGamepad(result[key], value, remove);
4545
+ if (edited === void 0) delete result[key];
4546
+ else result[key] = edited;
4547
+ }
4548
+ return result;
4549
+ }
4550
+ return remove ? current === patch ? void 0 : current : patch;
4551
+ }
4323
4552
  function editBinding(current, patch, remove) {
4324
4553
  const result = { ...current };
4325
4554
  for (const [name, value] of Object.entries(patch)) {
4326
4555
  const old = current[name];
4327
- if (Array.isArray(value)) result[name] = remove ? old.filter((k) => !value.includes(k)) : [.../* @__PURE__ */ new Set([...old, ...value])];
4556
+ if (name === "gamepad") result[name] = editGamepad(old, value, remove);
4557
+ else if (Array.isArray(value)) result[name] = remove ? (old ?? []).filter((k) => !value.includes(k)) : [.../* @__PURE__ */ new Set([...old ?? [], ...value])];
4328
4558
  else result[name] = editBinding(old, value, remove);
4329
4559
  }
4330
4560
  return result;
@@ -4522,6 +4752,9 @@ var ActionController = class {
4522
4752
  bus = new EventBus();
4523
4753
  keyboardAllowed = true;
4524
4754
  blockedKeys = /* @__PURE__ */ new Set();
4755
+ mouseAllowed = true;
4756
+ blockedMouse = /* @__PURE__ */ new Set();
4757
+ bindingGeneration = 0;
4525
4758
  pending = [];
4526
4759
  transitions = [];
4527
4760
  constructor(actions, options) {
@@ -4529,6 +4762,8 @@ var ActionController = class {
4529
4762
  this.options = options;
4530
4763
  if (options.keyboard !== void 0 && typeof options.keyboard !== "boolean") throw new EngineError("INVALID_VALUE", "Keyboard enabled must be boolean");
4531
4764
  this.keyboardAllowed = options.keyboard ?? true;
4765
+ if (options.mouse !== void 0 && typeof options.mouse !== "boolean") throw new EngineError("INVALID_VALUE", "Mouse enabled must be boolean");
4766
+ this.mouseAllowed = options.mouse ?? true;
4532
4767
  for (const action of Object.values(actions)) {
4533
4768
  const binding = checkedBinding(action, options.bindings && Object.hasOwn(options.bindings, action.name) ? options.bindings[action.name] ?? {} : {});
4534
4769
  const value = neutral(action);
@@ -4575,6 +4810,24 @@ var ActionController = class {
4575
4810
  get sourceCount() {
4576
4811
  return this.sources.size;
4577
4812
  }
4813
+ get mouseEnabled() {
4814
+ return this.mouseAllowed;
4815
+ }
4816
+ set mouseEnabled(value) {
4817
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Action controller has been disposed");
4818
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VALUE", "Mouse enabled must be boolean");
4819
+ if (value === this.mouseAllowed) return;
4820
+ this.mouseAllowed = value;
4821
+ this.blockedMouse.clear();
4822
+ if (value) {
4823
+ for (const entry of this.entries.values()) for (const list of mouseLists(entry.binding)) for (const button of list.buttons) if (this.options.input.mouseDown(button)) this.blockedMouse.add(button);
4824
+ }
4825
+ for (const action of this.entries.keys()) this.recompute(action);
4826
+ }
4827
+ /** @internal Used by existing host adapters to follow common binding settings. */
4828
+ get bindingRevision() {
4829
+ return this.bindingGeneration;
4830
+ }
4578
4831
  onDispose(callback) {
4579
4832
  return this.lifetime.onDispose(callback);
4580
4833
  }
@@ -4653,13 +4906,13 @@ var ActionController = class {
4653
4906
  }
4654
4907
  return source;
4655
4908
  }
4656
- /** @internal */ write(source, action, value) {
4909
+ /** @internal */ write(source, action, value, edges = true) {
4657
4910
  this.options.input.assertWrite(isNeutral(value));
4658
4911
  this.entry(action);
4659
4912
  const values = this.sources.get(source);
4660
4913
  if (!values) throw new EngineError("OWNER_DISPOSED", "Action source has been disposed");
4661
4914
  values.set(action, value);
4662
- this.recompute(action);
4915
+ this.recompute(action, edges);
4663
4916
  }
4664
4917
  /** @internal */ clearSource(source, remove) {
4665
4918
  const values = this.sources.get(source);
@@ -4670,11 +4923,11 @@ var ActionController = class {
4670
4923
  if (!this.disposed) for (const action of actions) this.recompute(action);
4671
4924
  }
4672
4925
  keyboard(action) {
4673
- if (!this.keyboardAllowed) return neutral(action);
4674
4926
  const binding = this.entry(action).binding;
4675
- const held = (keys) => keys?.some((k) => !this.blockedKeys.has(k) && this.options.input.keyDown(k)) ?? false;
4676
- const axis = (b) => Number(held(b?.positive)) - Number(held(b?.negative));
4677
- if (action.kind === "button") return held(binding.keys);
4927
+ const held = (keys) => this.keyboardAllowed && (keys?.some((k) => !this.blockedKeys.has(k) && this.options.input.keyDown(k)) ?? false);
4928
+ const mouse = (buttons) => this.mouseAllowed && (buttons?.some((b) => !this.blockedMouse.has(b) && this.options.input.mouseDown(b)) ?? false);
4929
+ const axis = (b) => Number(held(b?.positive) || mouse(b?.mousePositive)) - Number(held(b?.negative) || mouse(b?.mouseNegative));
4930
+ if (action.kind === "button") return held(binding.keys) || mouse(binding.mouse);
4678
4931
  if (action.kind === "axis1d") return axis(binding);
4679
4932
  const b = binding;
4680
4933
  return action.kind === "axis2d" ? {
@@ -4727,11 +4980,15 @@ var ActionController = class {
4727
4980
  }
4728
4981
  /** @internal */ keyChanged() {
4729
4982
  for (const key of this.blockedKeys) if (!this.options.input.keyDown(key)) this.blockedKeys.delete(key);
4983
+ for (const button of this.blockedMouse) if (!this.options.input.mouseDown(button)) this.blockedMouse.delete(button);
4730
4984
  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);
4731
4985
  }
4732
4986
  /** @internal */ accepts(code) {
4733
4987
  return this.active && this.keyboardAllowed && [...this.entries.values()].some((e) => keyLists(e.binding).some((list) => list.keys.includes(code)));
4734
4988
  }
4989
+ /** @internal */ acceptsMouse(button) {
4990
+ return this.active && this.mouseAllowed && [...this.entries.values()].some((e) => mouseLists(e.binding).some((list) => list.buttons.includes(button)));
4991
+ }
4735
4992
  /** @internal */ capture(override) {
4736
4993
  if (!this.active) return;
4737
4994
  const tokens = override ? new Map([...this.entries.keys()].map((a) => [a.name, a])) : void 0;
@@ -4773,16 +5030,25 @@ var ActionController = class {
4773
5030
  /** Clears held contributions and unconsumed edges; no synthetic button release. */
4774
5031
  reset() {
4775
5032
  this.blockedKeys.clear();
5033
+ this.blockedMouse.clear();
4776
5034
  for (const values of this.sources.values()) values.clear();
4777
5035
  this.pending = [];
4778
5036
  for (const [action, entry] of this.entries) entry.value = neutral(action);
4779
5037
  }
5038
+ /** @internal Flush device transitions without destroying explicit program values. */
5039
+ resetPhysical() {
5040
+ this.blockedKeys.clear();
5041
+ this.blockedMouse.clear();
5042
+ this.pending = [];
5043
+ for (const action of this.entries.keys()) this.recompute(action, false);
5044
+ }
4780
5045
  getBinding(action) {
4781
5046
  return this.entry(action).binding;
4782
5047
  }
4783
5048
  rebind(action, binding) {
4784
5049
  const checked = checkedBinding(action, binding), entry = this.entry(action);
4785
5050
  entry.binding = checked;
5051
+ this.bindingGeneration++;
4786
5052
  this.pending = this.pending.filter((event) => event.action !== action);
4787
5053
  this.recompute(action, false);
4788
5054
  }
@@ -4795,7 +5061,13 @@ var ActionController = class {
4795
5061
  resetBindings(action) {
4796
5062
  for (const token of action ? [action] : this.entries.keys()) this.rebind(token, this.entry(token).defaults);
4797
5063
  }
4798
- conflicts() {
5064
+ conflicts(device = "keyboard") {
5065
+ if (![
5066
+ "keyboard",
5067
+ "mouse",
5068
+ "gamepad",
5069
+ "all"
5070
+ ].includes(device)) throw new EngineError("INVALID_BINDING", "Unknown conflict device");
4799
5071
  const keys = /* @__PURE__ */ new Map();
4800
5072
  for (const [action, entry] of this.entries) for (const list of keyLists(entry.binding)) for (const key of list.keys) {
4801
5073
  const group = keys.get(key) ?? [];
@@ -4805,10 +5077,54 @@ var ActionController = class {
4805
5077
  });
4806
5078
  keys.set(key, group);
4807
5079
  }
4808
- return [...keys].filter(([, bindings]) => bindings.length > 1).map(([key, bindings]) => ({
5080
+ const keyboard = [...keys].filter(([, bindings]) => bindings.length > 1).map(([key, bindings]) => ({
4809
5081
  key,
4810
5082
  bindings
4811
5083
  }));
5084
+ if (device === "keyboard") return keyboard;
5085
+ const mice = /* @__PURE__ */ new Map();
5086
+ const pads = /* @__PURE__ */ new Map();
5087
+ for (const [action, entry] of this.entries) {
5088
+ for (const list of mouseLists(entry.binding)) for (const button of list.buttons) {
5089
+ const uses = mice.get(button) ?? [];
5090
+ uses.push({
5091
+ action,
5092
+ path: list.path
5093
+ });
5094
+ mice.set(button, uses);
5095
+ }
5096
+ const pad = entry.binding.gamepad;
5097
+ if (pad) for (const input of gamepadControls(pad)) {
5098
+ const key = `${input.kind}:${input.index}`;
5099
+ const group = pads.get(key) ?? {
5100
+ kind: input.kind,
5101
+ index: input.index,
5102
+ bindings: []
5103
+ };
5104
+ group.bindings.push({
5105
+ action,
5106
+ path: input.path
5107
+ });
5108
+ pads.set(key, group);
5109
+ }
5110
+ }
5111
+ const mouse = [...mice].filter(([, uses]) => uses.length > 1).map(([button, bindings]) => ({
5112
+ device: "mouse",
5113
+ button,
5114
+ bindings
5115
+ }));
5116
+ const gamepad = [...pads.values()].filter((group) => group.bindings.length > 1).map((group) => ({
5117
+ device: "gamepad",
5118
+ ...group
5119
+ }));
5120
+ return device === "mouse" ? mouse : device === "gamepad" ? gamepad : [
5121
+ ...keyboard.map((group) => ({
5122
+ device: "keyboard",
5123
+ ...group
5124
+ })),
5125
+ ...mouse,
5126
+ ...gamepad
5127
+ ];
4812
5128
  }
4813
5129
  exportBindings() {
4814
5130
  return {
@@ -4847,6 +5163,10 @@ var ActionSource = class extends OwnedInputSource {
4847
5163
  const checked = checkedActionValue(action, value);
4848
5164
  if (this.writable(isNeutral(checked))) this.controller.write(this, action, checked);
4849
5165
  }
5166
+ /** @internal Reset one device binding without synthesizing logical edges. */
5167
+ resetBinding(action) {
5168
+ if (this.writable(true)) this.controller.write(this, action, neutral(action), false);
5169
+ }
4850
5170
  release(remove) {
4851
5171
  this.controller.clearSource(this, remove);
4852
5172
  }
@@ -4869,16 +5189,38 @@ var KeyboardSource = class extends OwnedInputSource {
4869
5189
  this.input.releaseKeyboard(this, remove);
4870
5190
  }
4871
5191
  };
5192
+ /** Physical mouse contribution; game state is read through ActionController. */
5193
+ var MouseSource = class extends OwnedInputSource {
5194
+ input;
5195
+ /** @internal */ constructor(input) {
5196
+ super();
5197
+ this.input = input;
5198
+ }
5199
+ set(button, pressed) {
5200
+ parseMouseButton(button);
5201
+ if (typeof pressed !== "boolean") throw new EngineError("INVALID_VALUE", "Mouse state must be boolean");
5202
+ if (this.writable(!pressed)) this.input.writeMouse(this, button, pressed);
5203
+ }
5204
+ release(remove) {
5205
+ this.input.releaseMouse(this, remove);
5206
+ }
5207
+ };
4872
5208
  /** Engine-owned routing and update boundary. No raw game-state/alternate movement API. */
4873
5209
  var InputSystem = class {
4874
5210
  actions = /* @__PURE__ */ new Set();
4875
5211
  adapters = /* @__PURE__ */ new Set();
4876
5212
  keyboards = /* @__PURE__ */ new Map();
5213
+ mice = /* @__PURE__ */ new Map();
5214
+ captureCancel;
4877
5215
  dead = false;
4878
5216
  playback = false;
4879
5217
  frames;
5218
+ /** @internal Device hosts may reject live gestures during Replay playback. */
5219
+ get liveInput() {
5220
+ return !this.dead && !this.playback && !this.captureCancel;
5221
+ }
4880
5222
  get sourceCount() {
4881
- let count = this.keyboards.size;
5223
+ let count = this.keyboards.size + this.mice.size;
4882
5224
  for (const actions of this.actions) count += actions.sourceCount;
4883
5225
  return count;
4884
5226
  }
@@ -4898,10 +5240,19 @@ var InputSystem = class {
4898
5240
  };
4899
5241
  }
4900
5242
  acceptsKey(code) {
4901
- if (this.playback) return false;
5243
+ if (!this.liveInput) return false;
4902
5244
  for (const actions of this.actions) if (actions.accepts(code)) return true;
4903
5245
  return false;
4904
5246
  }
5247
+ acceptsMouse(button) {
5248
+ if (!this.liveInput) return false;
5249
+ for (const actions of this.actions) if (actions.acceptsMouse(button)) return true;
5250
+ return false;
5251
+ }
5252
+ /** @internal */ mouseDown(button) {
5253
+ for (const buttons of this.mice.values()) if (buttons.has(button)) return true;
5254
+ return false;
5255
+ }
4905
5256
  notify() {
4906
5257
  for (const actions of this.actions) actions.keyChanged();
4907
5258
  }
@@ -4945,10 +5296,42 @@ var InputSystem = class {
4945
5296
  }
4946
5297
  return source;
4947
5298
  }
5299
+ createMouseSource(owner) {
5300
+ if (this.dead || owner?.disposed) throw new EngineError("OWNER_DISPOSED", "Input owner has been disposed");
5301
+ const source = new MouseSource(this);
5302
+ this.mice.set(source, /* @__PURE__ */ new Set());
5303
+ try {
5304
+ if (owner) source.own(owner);
5305
+ } catch (error) {
5306
+ source.dispose();
5307
+ throw error;
5308
+ }
5309
+ return source;
5310
+ }
5311
+ /** @internal */ writeMouse(source, button, pressed) {
5312
+ this.assertWrite(!pressed);
5313
+ const buttons = this.mice.get(source);
5314
+ if (!buttons || this.dead) throw new EngineError("OWNER_DISPOSED", "Input has been disposed");
5315
+ if (this.captureCancel && pressed) return;
5316
+ if (buttons.has(button) === pressed) return;
5317
+ const before = this.mouseDown(button);
5318
+ if (pressed) buttons.add(button);
5319
+ else buttons.delete(button);
5320
+ if (before !== this.mouseDown(button)) this.notify();
5321
+ }
5322
+ /** @internal */ releaseMouse(source, remove) {
5323
+ const buttons = this.mice.get(source);
5324
+ if (!buttons) return;
5325
+ const changed = buttons.size > 0;
5326
+ buttons.clear();
5327
+ if (remove) this.mice.delete(source);
5328
+ if (changed && !this.dead) this.notify();
5329
+ }
4948
5330
  /** @internal */ writeKey(source, code, pressed) {
4949
5331
  this.assertWrite(!pressed);
4950
5332
  const keys = this.keyboards.get(source);
4951
5333
  if (!keys || this.dead) throw new EngineError("OWNER_DISPOSED", "Input has been disposed");
5334
+ if (this.captureCancel && pressed) return;
4952
5335
  if (keys.has(code) === pressed) return;
4953
5336
  const before = this.keyDown(code);
4954
5337
  if (pressed) keys.add(code);
@@ -4987,11 +5370,41 @@ var InputSystem = class {
4987
5370
  this.playback = false;
4988
5371
  this.clear();
4989
5372
  }
4990
- clear() {
5373
+ /** @internal Host capture gate. Program ActionSource values remain explicit. */
5374
+ suspendLiveInput(cancel) {
5375
+ if (!this.liveInput) throw new EngineError("INPUT_CAPTURE_UNAVAILABLE", "Live input is unavailable or another capture is active");
5376
+ this.captureCancel = cancel;
5377
+ try {
5378
+ this.resetPhysical();
5379
+ } catch (error) {
5380
+ this.captureCancel = void 0;
5381
+ throw error;
5382
+ }
5383
+ return () => {
5384
+ if (this.captureCancel !== cancel) return;
5385
+ this.captureCancel = void 0;
5386
+ this.resetPhysical();
5387
+ };
5388
+ }
5389
+ resetPhysical() {
4991
5390
  for (const keys of this.keyboards.values()) keys.clear();
4992
- cleanupAll([...[...this.actions].map((actions) => () => actions.reset()), ...[...this.adapters].map((adapter) => () => {
5391
+ for (const buttons of this.mice.values()) buttons.clear();
5392
+ cleanupAll([...[...this.adapters].map((adapter) => () => {
4993
5393
  if (this.adapters.has(adapter)) adapter.reset();
4994
- })], "Input reset failed");
5394
+ }), ...[...this.actions].map((actions) => () => actions.resetPhysical())], "Physical input reset failed");
5395
+ }
5396
+ clear() {
5397
+ const cancel = this.captureCancel;
5398
+ this.captureCancel = void 0;
5399
+ for (const keys of this.keyboards.values()) keys.clear();
5400
+ for (const buttons of this.mice.values()) buttons.clear();
5401
+ cleanupAll([
5402
+ () => cancel?.(),
5403
+ ...[...this.actions].map((actions) => () => actions.reset()),
5404
+ ...[...this.adapters].map((adapter) => () => {
5405
+ if (this.adapters.has(adapter)) adapter.reset();
5406
+ })
5407
+ ], "Input reset failed");
4995
5408
  }
4996
5409
  /** Device diagnostics only; game state is ActionController.snapshot(). */
4997
5410
  snapshot() {
@@ -5002,19 +5415,123 @@ var InputSystem = class {
5002
5415
  dispose() {
5003
5416
  if (this.dead) return;
5004
5417
  this.dead = true;
5418
+ const cancel = this.captureCancel;
5419
+ this.captureCancel = void 0;
5005
5420
  try {
5006
5421
  cleanupAll([
5422
+ () => cancel?.(),
5007
5423
  ...[...this.adapters].map((a) => () => a.dispose()),
5008
5424
  ...[...this.actions].map((a) => () => a.dispose()),
5009
- ...[...this.keyboards.keys()].map((s) => () => s.dispose())
5425
+ ...[...this.keyboards.keys()].map((s) => () => s.dispose()),
5426
+ ...[...this.mice.keys()].map((s) => () => s.dispose())
5010
5427
  ], "Input cleanup failed");
5011
5428
  } finally {
5012
5429
  this.adapters.clear();
5013
5430
  this.actions.clear();
5014
5431
  this.keyboards.clear();
5432
+ this.mice.clear();
5015
5433
  }
5016
5434
  }
5017
5435
  };
5436
+ // packages/core/src/interaction/input-capture.ts
5437
+ var InputCaptureState = Object.freeze({
5438
+ Arming: "arming",
5439
+ Listening: "listening",
5440
+ Completed: "completed",
5441
+ Cancelled: "cancelled"
5442
+ });
5443
+ /** A portable single-input session. Hosts supply neutral arming and physical events. */
5444
+ var InputCapture = class {
5445
+ life = new Lifetime();
5446
+ status = InputCaptureState.Arming;
5447
+ devices;
5448
+ gamepadIndex;
5449
+ result;
5450
+ settle = () => {};
5451
+ constructor(options = {}) {
5452
+ if (!options || typeof options !== "object" || Array.isArray(options) || Object.keys(options).some((key) => ![
5453
+ "devices",
5454
+ "gamepadIndex",
5455
+ "owner"
5456
+ ].includes(key))) throw new EngineError("INVALID_INPUT_CAPTURE", "Invalid input capture options");
5457
+ const devices = options.devices ?? Object.values(InputDevice);
5458
+ if (!Array.isArray(devices) || !devices.length || devices.some((device) => !Object.values(InputDevice).includes(device))) throw new EngineError("INVALID_INPUT_CAPTURE", "Select at least one known capture device");
5459
+ this.devices = Object.freeze([...new Set(devices)]);
5460
+ this.gamepadIndex = options.gamepadIndex === void 0 ? void 0 : gamepadIndex(options.gamepadIndex);
5461
+ if (options.owner?.disposed) throw new EngineError("OWNER_DISPOSED", "Capture owner has been disposed");
5462
+ this.result = new Promise((resolve) => {
5463
+ this.settle = resolve;
5464
+ });
5465
+ if (options.owner) this.life.onDispose(options.owner.onDispose(() => this.cancel()));
5466
+ }
5467
+ get state() {
5468
+ return this.status;
5469
+ }
5470
+ get disposed() {
5471
+ return this.life.disposed;
5472
+ }
5473
+ onDispose(callback) {
5474
+ return this.life.onDispose(callback);
5475
+ }
5476
+ /** @internal Hosts call only after all eligible preheld buttons/keys are neutral. */
5477
+ arm() {
5478
+ if (this.status === InputCaptureState.Arming) this.status = InputCaptureState.Listening;
5479
+ }
5480
+ /** @internal Offer one fresh physical down, never a repeat or opening gesture. */
5481
+ accept(input) {
5482
+ if (this.status !== InputCaptureState.Listening) return false;
5483
+ if (!input || typeof input !== "object" || !Object.values(InputDevice).includes(input.device)) throw new EngineError("INVALID_INPUT_CAPTURE", "Unknown physical input device");
5484
+ if (!this.devices.includes(input.device)) return false;
5485
+ let descriptor, binding;
5486
+ if (input.device === InputDevice.Keyboard) {
5487
+ const code = parseKeyCode(input.code);
5488
+ descriptor = {
5489
+ device: InputDevice.Keyboard,
5490
+ code
5491
+ };
5492
+ binding = Object.freeze({ keys: Object.freeze([code]) });
5493
+ } else if (input.device === InputDevice.Mouse) {
5494
+ const button = parseMouseButton(input.button);
5495
+ descriptor = {
5496
+ device: InputDevice.Mouse,
5497
+ button
5498
+ };
5499
+ binding = Object.freeze({ mouse: Object.freeze([button]) });
5500
+ } else {
5501
+ const index = gamepadIndex(input.index), button = gamepadIndex(input.button);
5502
+ if (this.gamepadIndex !== void 0 && this.gamepadIndex !== index) return false;
5503
+ if (typeof input.id !== "string" || typeof input.mapping !== "string") throw new EngineError("INVALID_INPUT_CAPTURE", "Gamepad capture requires device facts");
5504
+ descriptor = {
5505
+ device: InputDevice.Gamepad,
5506
+ index,
5507
+ button,
5508
+ id: input.id,
5509
+ mapping: input.mapping
5510
+ };
5511
+ binding = Object.freeze({ gamepad: Object.freeze({ buttons: Object.freeze([button]) }) });
5512
+ }
5513
+ this.finish(InputCaptureState.Completed, Object.freeze({
5514
+ ...descriptor,
5515
+ binding
5516
+ }));
5517
+ return true;
5518
+ }
5519
+ finish(state, input) {
5520
+ if (this.disposed) return;
5521
+ this.status = state;
5522
+ try {
5523
+ this.life.dispose();
5524
+ } finally {
5525
+ this.settle(input);
5526
+ }
5527
+ }
5528
+ cancel() {
5529
+ this.finish(InputCaptureState.Cancelled, void 0);
5530
+ }
5531
+ dispose() {
5532
+ this.cancel();
5533
+ }
5534
+ };
5018
5535
  // packages/core/src/ui/canvas-layout.ts
5019
5536
  var finite = (v) => {
5020
5537
  if (!Number.isFinite(v) || v < 0) throw new EngineError("INVALID_UI", "UI layout overflow");
@@ -5309,6 +5826,18 @@ var ScenePointer = class {
5309
5826
  lifetime = new Lifetime();
5310
5827
  events = new EventBus();
5311
5828
  removeScene;
5829
+ targets;
5830
+ /** @internal Exactly one Scene virtual-input provider shares the existing router. */
5831
+ attachTargets(provider) {
5832
+ if (this.disposed || this.targets) throw new EngineError("INVALID_VIRTUAL_INPUT", "Scene already has virtual input or is disposed");
5833
+ this.targets = provider;
5834
+ return () => {
5835
+ if (this.targets === provider) this.targets = void 0;
5836
+ };
5837
+ }
5838
+ /** @internal */ pickTarget(data) {
5839
+ return this.targets?.disposed ? void 0 : this.targets?.pick(data);
5840
+ }
5312
5841
  constructor(scene) {
5313
5842
  this.scene = scene;
5314
5843
  this.removeScene = scene.onDispose(() => this.dispose());
@@ -5331,6 +5860,7 @@ var ScenePointer = class {
5331
5860
  /** @internal Device-independent routing state; DOM is only an adapter. */
5332
5861
  var PointerRouting = class {
5333
5862
  released;
5863
+ targetOwners = /* @__PURE__ */ new Map();
5334
5864
  controlOwners = /* @__PURE__ */ new Map();
5335
5865
  scrollOwners = /* @__PURE__ */ new Map();
5336
5866
  suppressed = /* @__PURE__ */ new Set();
@@ -5355,7 +5885,8 @@ var PointerRouting = class {
5355
5885
  keyboardNodes: this.keys.size,
5356
5886
  deliveries: this.deliveries,
5357
5887
  scrollDrags: this.scrollOwners.size,
5358
- controlDrags: this.controlOwners.size
5888
+ controlDrags: this.controlOwners.size,
5889
+ virtualDrags: this.targetOwners.size
5359
5890
  };
5360
5891
  }
5361
5892
  setScene(channel, ui) {
@@ -5381,6 +5912,14 @@ var PointerRouting = class {
5381
5912
  }
5382
5913
  }
5383
5914
  emit(gesture, type, info) {
5915
+ if (gesture.target) {
5916
+ const phase = type === PointerDown ? "down" : type === PointerMove ? "move" : type === PointerUp ? "up" : type === PointerCancel ? "cancel" : void 0;
5917
+ if (phase) {
5918
+ this.deliveries++;
5919
+ gesture.target.deliver(phase, info);
5920
+ }
5921
+ return;
5922
+ }
5384
5923
  if (gesture.channel.disposed || gesture.node?.disposed) return;
5385
5924
  this.deliveries++;
5386
5925
  (gesture.node?.events ?? gesture.channel.events).emit(type, info);
@@ -5388,7 +5927,7 @@ var PointerRouting = class {
5388
5927
  inside(g, data) {
5389
5928
  return Object.freeze({
5390
5929
  ...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
5930
+ 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
5931
  });
5393
5932
  }
5394
5933
  down(data) {
@@ -5398,6 +5937,11 @@ var PointerRouting = class {
5398
5937
  this.suppressed.delete(data.pointerId);
5399
5938
  const pick = this.ui?.pick(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale);
5400
5939
  const node = pick?.node, bar = pick?.bar;
5940
+ const target = !node && data.button === 0 ? this.channel.pickTarget(data) : void 0;
5941
+ if (target && this.targetOwners.has(target)) {
5942
+ this.suppressed.add(data.pointerId);
5943
+ return;
5944
+ }
5401
5945
  if (bar && data.button === 0 && (!bar.enabled || this.scrollOwners.has(bar.node))) {
5402
5946
  this.suppressed.add(data.pointerId);
5403
5947
  return node;
@@ -5406,20 +5950,24 @@ var PointerRouting = class {
5406
5950
  this.suppressed.add(data.pointerId);
5407
5951
  return node;
5408
5952
  }
5409
- if (!node && !data.inside) return;
5953
+ if (!node && !target && !data.inside) return;
5410
5954
  const g = {
5411
5955
  channel: this.channel,
5412
5956
  last: data,
5413
5957
  primary: data.button === 0,
5414
5958
  start: data,
5415
5959
  scale: this.ui?.scale ?? 1,
5416
- candidates: this.ui?.scrollTargets(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale) ?? [],
5960
+ candidates: !target ? this.ui?.scrollTargets(data.screen.x / this.ui.scale, data.screen.y / this.ui.scale) ?? [] : [],
5417
5961
  candidateRevisions: [],
5418
5962
  vx: 0,
5419
5963
  vy: 0,
5420
5964
  ...node ? {
5421
5965
  node,
5422
5966
  ui: this.ui
5967
+ } : {},
5968
+ ...target ? {
5969
+ target,
5970
+ targetRevision: target.revision
5423
5971
  } : {}
5424
5972
  };
5425
5973
  g.candidateRevisions = g.candidates.map((n) => n.scrollRevision);
@@ -5441,6 +5989,17 @@ var PointerRouting = class {
5441
5989
  if (node?.control) g.controlRevision = node.control.revision;
5442
5990
  this.gestures.set(data.pointerId, g);
5443
5991
  if (node) g.off = node.onDispose(() => this.cancel(data.pointerId));
5992
+ if (target) {
5993
+ this.targetOwners.set(target, data.pointerId);
5994
+ const off = target.subscribe(() => {
5995
+ if (!target.available || target.revision !== g.targetRevision) this.cancel(data.pointerId);
5996
+ });
5997
+ const disposed = target.onDispose(() => this.cancel(data.pointerId));
5998
+ g.off = () => {
5999
+ off();
6000
+ disposed();
6001
+ };
6002
+ }
5444
6003
  cleanupAll([
5445
6004
  () => {
5446
6005
  const port = node?.control;
@@ -5489,6 +6048,11 @@ var PointerRouting = class {
5489
6048
  owns(id) {
5490
6049
  return this.gestures.has(id) || this.suppressed.has(id);
5491
6050
  }
6051
+ /** @internal Mouse bindings observe world ownership without claiming a second gesture. */
6052
+ worldOwns(id) {
6053
+ const gesture = this.gestures.get(id);
6054
+ return Boolean(gesture && !gesture.node && !gesture.target && !gesture.scroll && !gesture.candidates.length);
6055
+ }
5492
6056
  move(data) {
5493
6057
  this.validate(data);
5494
6058
  if (this.suppressed.has(data.pointerId)) return;
@@ -5498,7 +6062,7 @@ var PointerRouting = class {
5498
6062
  const dx = Math.abs(data.screen.x - g.start.screen.x), dy = Math.abs(data.screen.y - g.start.screen.y);
5499
6063
  if (dx >= g.control.style.dragThreshold && dx > dy) g.controlLocked = true;
5500
6064
  }
5501
- if (!g.scroll && g.primary && !g.controlLocked) this.claimScroll(g, data);
6065
+ if (!g.target && !g.scroll && g.primary && !g.controlLocked) this.claimScroll(g, data);
5502
6066
  if (this.gestures.get(data.pointerId) !== g) return;
5503
6067
  if (g.bar && g.scroll && g.ui) {
5504
6068
  const bar = canvasScrollbars(g.scroll).find((b) => b.axis === g.bar?.axis);
@@ -5639,6 +6203,7 @@ var PointerRouting = class {
5639
6203
  g = this.gestures.get(data.pointerId);
5640
6204
  if (!g) return;
5641
6205
  this.gestures.delete(data.pointerId);
6206
+ if (g.target) this.targetOwners.delete(g.target);
5642
6207
  if (g.control) this.controlOwners.delete(g.control);
5643
6208
  if (g.scroll) {
5644
6209
  this.scrollOwners.delete(g.scroll);
@@ -5656,7 +6221,7 @@ var PointerRouting = class {
5656
6221
  if (!g.node || g.node.available) this.emit(g, PointerUp, info);
5657
6222
  },
5658
6223
  () => {
5659
- if (!g.scroll && g.primary && info.inside && (!g.node || g.node.available)) this.emit(g, PointerClick, info);
6224
+ if (!g.target && !g.scroll && g.primary && info.inside && (!g.node || g.node.available)) this.emit(g, PointerClick, info);
5660
6225
  },
5661
6226
  () => {
5662
6227
  if (!g.scroll && g.primary && info.inside && g.node?.available) g.node.activate("pointer");
@@ -5668,6 +6233,7 @@ var PointerRouting = class {
5668
6233
  const g = this.gestures.get(id);
5669
6234
  if (!g) return;
5670
6235
  this.gestures.delete(id);
6236
+ if (g.target) this.targetOwners.delete(g.target);
5671
6237
  if (g.control) this.controlOwners.delete(g.control);
5672
6238
  if (g.scroll) {
5673
6239
  this.scrollOwners.delete(g.scroll);
@@ -5738,6 +6304,10 @@ var PointerRouting = class {
5738
6304
  ...[...this.keys.keys()].map((n) => () => this.releaseKeys(n))
5739
6305
  ], "Pointer release failed");
5740
6306
  }
6307
+ /** @internal Host focus changes interrupt virtual devices without changing Canvas editing rules. */
6308
+ releaseVirtual() {
6309
+ cleanupAll([...this.gestures].filter(([, g]) => g.target).map(([id]) => () => this.cancel(id)), "Virtual pointer release failed");
6310
+ }
5741
6311
  dispose() {
5742
6312
  try {
5743
6313
  this.reset();
@@ -5753,6 +6323,716 @@ var PointerRouting = class {
5753
6323
  cleanupAll([...off, () => this.release()], "Pointer routing cleanup failed");
5754
6324
  }
5755
6325
  };
6326
+ // packages/core/src/interaction/virtual-regions.ts
6327
+ var VirtualRegions = Object.freeze({
6328
+ /** A shared edge is not an overlap. Use separate rectangles to leave a center gap. */
6329
+ Left: Object.freeze({
6330
+ kind: "rect",
6331
+ x: 0,
6332
+ y: 0,
6333
+ width: .5,
6334
+ height: 1
6335
+ }),
6336
+ Right: Object.freeze({
6337
+ kind: "rect",
6338
+ x: .5,
6339
+ y: 0,
6340
+ width: .5,
6341
+ height: 1
6342
+ })
6343
+ });
6344
+ /** @internal */
6345
+ function virtualFinite(value, field) {
6346
+ if (typeof value !== "number" || !Number.isFinite(value)) throw new EngineError("INVALID_VIRTUAL_INPUT", `${field} must be finite`);
6347
+ return value;
6348
+ }
6349
+ /** @internal */
6350
+ function virtualBounds(value) {
6351
+ const result = {
6352
+ x: virtualFinite(value.x, "x"),
6353
+ y: virtualFinite(value.y, "y"),
6354
+ width: virtualFinite(value.width, "width"),
6355
+ height: virtualFinite(value.height, "height")
6356
+ };
6357
+ 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");
6358
+ return Object.freeze(result);
6359
+ }
6360
+ /** @internal */
6361
+ function virtualPosition(value, viewport) {
6362
+ const space = value.space ?? "viewport";
6363
+ if (space !== "viewport" && space !== "screen") throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown virtual coordinate space");
6364
+ const x = virtualFinite(value.x, "position.x"), y = virtualFinite(value.y, "position.y");
6365
+ return Object.freeze(space === "screen" ? {
6366
+ x,
6367
+ y
6368
+ } : {
6369
+ x: virtualFinite(viewport.x + x * viewport.width, "resolved x"),
6370
+ y: virtualFinite(viewport.y + y * viewport.height, "resolved y")
6371
+ });
6372
+ }
6373
+ /** @internal Copy configuration before retaining it. Predicate functions are deliberately live. */
6374
+ function checkedVirtualRegion(region) {
6375
+ if (!region || ![
6376
+ "rect",
6377
+ "circle",
6378
+ "custom"
6379
+ ].includes(region.kind)) throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown activation region");
6380
+ const copy = Object.freeze({ ...region });
6381
+ if (copy.kind === "custom") {
6382
+ if (typeof copy.bounds !== "function" || typeof copy.contains !== "function") throw new EngineError("INVALID_VIRTUAL_INPUT", "Custom regions require bounds and contains");
6383
+ } else resolveVirtualRegion(copy, {
6384
+ x: 0,
6385
+ y: 0,
6386
+ width: 1,
6387
+ height: 1
6388
+ });
6389
+ return copy;
6390
+ }
6391
+ /** @internal */
6392
+ function resolveVirtualRegion(region, viewport) {
6393
+ if (region.kind === "custom") {
6394
+ if (typeof region.bounds !== "function" || typeof region.contains !== "function") throw new EngineError("INVALID_VIRTUAL_INPUT", "Custom regions require bounds and contains functions");
6395
+ return {
6396
+ kind: "custom",
6397
+ bounds: virtualBounds(region.bounds(viewport)),
6398
+ test: region.contains
6399
+ };
6400
+ }
6401
+ const center = virtualPosition(region, viewport);
6402
+ if (region.kind === "rect") {
6403
+ const relative = (region.space ?? "viewport") === "viewport";
6404
+ const width = virtualFinite(region.width, "region.width"), height = virtualFinite(region.height, "region.height");
6405
+ if (width < 0 || height < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Region sizes must be nonnegative");
6406
+ return {
6407
+ kind: "rect",
6408
+ bounds: virtualBounds({
6409
+ ...center,
6410
+ width: width * (relative ? viewport.width : 1),
6411
+ height: height * (relative ? viewport.height : 1)
6412
+ })
6413
+ };
6414
+ }
6415
+ const radius = virtualFinite(region.radius, "region.radius") * ((region.space ?? "viewport") === "viewport" ? Math.min(viewport.width, viewport.height) : 1);
6416
+ if (radius < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Region radius must be nonnegative");
6417
+ return {
6418
+ kind: "circle",
6419
+ center,
6420
+ radius,
6421
+ bounds: virtualBounds({
6422
+ x: center.x - radius,
6423
+ y: center.y - radius,
6424
+ width: radius * 2,
6425
+ height: radius * 2
6426
+ })
6427
+ };
6428
+ }
6429
+ /** @internal Rectangles are half-open; circles exclude their circumference. */
6430
+ function virtualContains(region, point, viewport) {
6431
+ const b = region.bounds;
6432
+ if (point.x < b.x || point.y < b.y || point.x >= b.x + b.width || point.y >= b.y + b.height) return false;
6433
+ if (region.kind === "circle") return Math.hypot(point.x - region.center.x, point.y - region.center.y) < region.radius;
6434
+ if (region.kind === "custom") {
6435
+ const value = region.test(point, viewport);
6436
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VIRTUAL_INPUT", "Custom region predicate must return boolean");
6437
+ return value;
6438
+ }
6439
+ return true;
6440
+ }
6441
+ /** @internal Exact built-in intersection; custom bounds are conservatively ambiguous. */
6442
+ function virtualOverlap(a, b) {
6443
+ const x = a.bounds, y = b.bounds;
6444
+ 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;
6445
+ 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;
6446
+ const circle = a.kind === "circle" ? a : b.kind === "circle" ? b : void 0;
6447
+ const rect = a.kind === "circle" ? b : a;
6448
+ if (circle && rect.kind === "rect") {
6449
+ const c = circle.center, r = rect.bounds;
6450
+ 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;
6451
+ }
6452
+ return true;
6453
+ }
6454
+ // packages/core/src/interaction/virtual-control.ts
6455
+ var VirtualStickMode = {
6456
+ Fixed: "fixed",
6457
+ Floating: "floating",
6458
+ Follow: "follow"
6459
+ };
6460
+ var screenPoint$1 = (x, y) => ({
6461
+ space: "screen",
6462
+ x: virtualFinite(x, "screen.x"),
6463
+ y: virtualFinite(y, "screen.y")
6464
+ });
6465
+ var zero = Object.freeze({
6466
+ x: 0,
6467
+ y: 0
6468
+ });
6469
+ var Start = defineEvent("VirtualStart");
6470
+ var Change = defineEvent("VirtualChange");
6471
+ var End = defineEvent("VirtualEnd");
6472
+ var Cancel = defineEvent("VirtualCancel");
6473
+ var Hold = defineEvent("VirtualHold");
6474
+ function flag(value) {
6475
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VIRTUAL_INPUT", "Virtual control flags must be boolean");
6476
+ return value;
6477
+ }
6478
+ function zone(value) {
6479
+ virtualFinite(value, "deadZone");
6480
+ if (value < 0 || value >= 1) throw new EngineError("INVALID_VIRTUAL_INPUT", "Dead zone must satisfy 0 <= value < 1");
6481
+ return value;
6482
+ }
6483
+ function mode(value) {
6484
+ if (!Object.values(VirtualStickMode).includes(value)) throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown virtual stick mode");
6485
+ return value;
6486
+ }
6487
+ /** @internal Validate before allocating ActionSources or registering a target. */
6488
+ function checkVirtualOptions(options) {
6489
+ checkedVirtualRegion(options.region);
6490
+ positive(options.radius ?? 64, "radius");
6491
+ zone(options.deadZone ?? .15);
6492
+ flag(options.enabled ?? true);
6493
+ flag(options.visible ?? true);
6494
+ flag(options.direction ?? false);
6495
+ mode(options.mode ?? VirtualStickMode.Fixed);
6496
+ if (virtualFinite(options.holdDelay ?? .5, "holdDelay") < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Hold delay must be nonnegative");
6497
+ if (options.position) {
6498
+ virtualFinite(options.position.x, "position.x");
6499
+ virtualFinite(options.position.y, "position.y");
6500
+ if (options.position.space !== void 0 && options.position.space !== "screen" && options.position.space !== "viewport") throw new EngineError("INVALID_VIRTUAL_INPUT", "Unknown position space");
6501
+ }
6502
+ const color = options.color ?? 6736821;
6503
+ if (!Number.isInteger(color) || color < 0 || color > 16777215) throw new EngineError("INVALID_VIRTUAL_INPUT", "Virtual color must be RGB");
6504
+ }
6505
+ /** Portable gesture state. Routing, Action composition and rendering retain their existing owners. */
6506
+ var VirtualControl = class {
6507
+ kind;
6508
+ host;
6509
+ binding;
6510
+ life = new Lifetime();
6511
+ events = new EventBus();
6512
+ observers = /* @__PURE__ */ new Set();
6513
+ activation;
6514
+ activeFlag;
6515
+ shown;
6516
+ size;
6517
+ deadZoneValue;
6518
+ version = 0;
6519
+ gesture;
6520
+ vector = zero;
6521
+ dragMode;
6522
+ position;
6523
+ holdDelay;
6524
+ color;
6525
+ directional;
6526
+ constructor(kind, options, host, binding) {
6527
+ this.kind = kind;
6528
+ this.host = host;
6529
+ this.binding = binding;
6530
+ checkVirtualOptions(options);
6531
+ this.activation = checkedVirtualRegion(options.region);
6532
+ this.position = options.position ? Object.freeze({ ...options.position }) : void 0;
6533
+ this.activeFlag = options.enabled ?? true;
6534
+ this.shown = options.visible ?? true;
6535
+ this.size = options.radius ?? (kind === "stick" ? 64 : 28);
6536
+ this.deadZoneValue = options.deadZone ?? .15;
6537
+ this.dragMode = mode(options.mode ?? VirtualStickMode.Fixed);
6538
+ this.holdDelay = options.holdDelay ?? .5;
6539
+ this.color = options.color ?? 6736821;
6540
+ this.directional = kind === "stick" || (options.direction ?? false);
6541
+ this.life.onDispose(binding.onDispose(() => this.dispose()));
6542
+ }
6543
+ get disposed() {
6544
+ return this.life.disposed;
6545
+ }
6546
+ get available() {
6547
+ return !this.disposed && this.activeFlag && this.host.available() && this.binding.active();
6548
+ }
6549
+ get revision() {
6550
+ return this.version;
6551
+ }
6552
+ get enabled() {
6553
+ return this.activeFlag;
6554
+ }
6555
+ set enabled(value) {
6556
+ this.alive();
6557
+ const next = flag(value);
6558
+ if (next === this.activeFlag) return;
6559
+ this.activeFlag = next;
6560
+ this.interrupt();
6561
+ }
6562
+ get visible() {
6563
+ return this.shown;
6564
+ }
6565
+ set visible(value) {
6566
+ this.alive();
6567
+ this.shown = flag(value);
6568
+ }
6569
+ get radius() {
6570
+ return this.size;
6571
+ }
6572
+ set radius(value) {
6573
+ this.alive();
6574
+ const next = positive(value, "radius");
6575
+ if (next === this.size) return;
6576
+ this.size = next;
6577
+ this.interrupt();
6578
+ }
6579
+ get deadZone() {
6580
+ return this.deadZoneValue;
6581
+ }
6582
+ set deadZone(value) {
6583
+ this.alive();
6584
+ const next = zone(value);
6585
+ if (next === this.deadZoneValue) return;
6586
+ this.deadZoneValue = next;
6587
+ this.interrupt();
6588
+ }
6589
+ get region() {
6590
+ return this.activation;
6591
+ }
6592
+ set region(value) {
6593
+ this.alive();
6594
+ const next = checkedVirtualRegion(value);
6595
+ this.host.region(this, next);
6596
+ this.activation = next;
6597
+ this.interrupt();
6598
+ }
6599
+ get stickMode() {
6600
+ return this.dragMode;
6601
+ }
6602
+ set stickMode(value) {
6603
+ this.alive();
6604
+ const next = mode(value);
6605
+ if (next === this.dragMode) return;
6606
+ this.dragMode = next;
6607
+ this.interrupt();
6608
+ }
6609
+ get pointerId() {
6610
+ return this.gesture?.last.pointerId;
6611
+ }
6612
+ get pressed() {
6613
+ return this.gesture !== void 0;
6614
+ }
6615
+ get elapsed() {
6616
+ return this.gesture?.elapsed ?? 0;
6617
+ }
6618
+ get direction() {
6619
+ return this.vector;
6620
+ }
6621
+ get center() {
6622
+ const p = this.gesture?.center ?? this.host.center(this);
6623
+ return Object.freeze(screenPoint$1(p.x, p.y));
6624
+ }
6625
+ get knob() {
6626
+ const center = this.center, last = this.gesture?.last.screen;
6627
+ if (!last || !this.directional) return center;
6628
+ const origin = this.kind === "button" ? this.gesture?.origin ?? center : center;
6629
+ const dx = last.x - origin.x, dy = last.y - origin.y, length = Math.hypot(dx, dy);
6630
+ const scale = length > this.size ? this.size / length : 1;
6631
+ return Object.freeze(screenPoint$1(center.x + dx * scale, center.y + dy * scale));
6632
+ }
6633
+ onDispose(callback) {
6634
+ return this.life.onDispose(callback);
6635
+ }
6636
+ /** @internal Geometry/availability invalidation, not a per-frame observer. */
6637
+ subscribe(callback) {
6638
+ this.alive();
6639
+ this.observers.add(callback);
6640
+ return () => {
6641
+ this.observers.delete(callback);
6642
+ };
6643
+ }
6644
+ onStart(callback, owner) {
6645
+ return this.events.on(Start, callback, owner);
6646
+ }
6647
+ onChange(callback, owner) {
6648
+ return this.events.on(Change, callback, owner);
6649
+ }
6650
+ onEnd(callback, owner) {
6651
+ return this.events.on(End, callback, owner);
6652
+ }
6653
+ onCancel(callback, owner) {
6654
+ return this.events.on(Cancel, callback, owner);
6655
+ }
6656
+ onHold(callback, owner) {
6657
+ return this.events.on(Hold, callback, owner);
6658
+ }
6659
+ /** Release/cancel only; the captured physical ID stays suppressed until its release. */
6660
+ cancel() {
6661
+ this.alive();
6662
+ this.interrupt();
6663
+ }
6664
+ /** @internal */
6665
+ invalidate() {
6666
+ this.interrupt();
6667
+ }
6668
+ alive() {
6669
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Virtual control has been disposed");
6670
+ }
6671
+ interrupt() {
6672
+ this.version++;
6673
+ cleanupAll([...[...this.observers], () => {
6674
+ if (this.gesture) this.finish(true);
6675
+ else this.binding.clear();
6676
+ }], "Virtual gesture interruption failed");
6677
+ }
6678
+ contains(data) {
6679
+ return this.host.contains(this, data);
6680
+ }
6681
+ snapshot(g) {
6682
+ return Object.freeze({
6683
+ control: this,
6684
+ pointerId: g.last.pointerId,
6685
+ pointerType: g.last.pointerType,
6686
+ origin: g.origin,
6687
+ position: Object.freeze(screenPoint$1(g.last.screen.x, g.last.screen.y)),
6688
+ center: g.center,
6689
+ direction: this.vector,
6690
+ inside: this.contains(g.last),
6691
+ elapsed: g.elapsed
6692
+ });
6693
+ }
6694
+ update(g) {
6695
+ if (!this.directional) {
6696
+ this.vector = zero;
6697
+ return;
6698
+ }
6699
+ const start = this.kind === "button" ? g.origin : g.center;
6700
+ const dx = g.last.screen.x - start.x, dy = g.last.screen.y - start.y;
6701
+ const length = Math.hypot(dx, dy);
6702
+ 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));
6703
+ const magnitude = Math.min(1, length / this.size);
6704
+ const output = magnitude <= this.deadZoneValue ? 0 : (magnitude - this.deadZoneValue) / (1 - this.deadZoneValue);
6705
+ this.vector = output === 0 ? zero : Object.freeze({
6706
+ x: dx / length * output,
6707
+ y: dy / length * output
6708
+ });
6709
+ }
6710
+ deliver(type, data) {
6711
+ if (type === "cancel") {
6712
+ if (this.gesture?.last.pointerId === data.pointerId) this.finish(true);
6713
+ return;
6714
+ }
6715
+ if (type === "down") {
6716
+ if (!this.available || this.gesture) return;
6717
+ const center = this.kind === "stick" && this.dragMode !== VirtualStickMode.Fixed ? data.screen : this.host.center(this);
6718
+ const g = {
6719
+ last: data,
6720
+ origin: Object.freeze(screenPoint$1(data.screen.x, data.screen.y)),
6721
+ center: Object.freeze(screenPoint$1(center.x, center.y)),
6722
+ elapsed: 0,
6723
+ held: false
6724
+ };
6725
+ this.gesture = g;
6726
+ this.update(g);
6727
+ this.binding.write(this.vector, true);
6728
+ const event = this.snapshot(g);
6729
+ cleanupAll([() => this.events.emit(Start, event), () => {
6730
+ if (this.gesture === g) this.events.emit(Change, event);
6731
+ }], "Virtual start handlers failed");
6732
+ return;
6733
+ }
6734
+ const g = this.gesture;
6735
+ if (!g || g.last.pointerId !== data.pointerId) return;
6736
+ if (!this.available) {
6737
+ this.interrupt();
6738
+ return;
6739
+ }
6740
+ if (data.screen.x !== g.last.screen.x || data.screen.y !== g.last.screen.y) {
6741
+ g.last = data;
6742
+ this.update(g);
6743
+ this.binding.write(this.vector, true);
6744
+ this.events.emit(Change, this.snapshot(g));
6745
+ }
6746
+ if (type === "up" && this.gesture === g) {
6747
+ g.last = data;
6748
+ this.finish(false);
6749
+ }
6750
+ }
6751
+ /** @internal Explicit presentation clock; no browser/OS timer in Core. */
6752
+ advance(seconds) {
6753
+ const g = this.gesture;
6754
+ if (!g) return;
6755
+ if (!this.available) {
6756
+ this.interrupt();
6757
+ return;
6758
+ }
6759
+ g.elapsed += seconds;
6760
+ if (!g.held && g.elapsed >= this.holdDelay) {
6761
+ g.held = true;
6762
+ this.events.emit(Hold, this.snapshot(g));
6763
+ }
6764
+ }
6765
+ finish(cancelled) {
6766
+ const g = this.gesture;
6767
+ if (!g) return;
6768
+ let event;
6769
+ try {
6770
+ event = this.snapshot(g);
6771
+ } finally {
6772
+ this.gesture = void 0;
6773
+ this.vector = zero;
6774
+ this.binding.clear();
6775
+ }
6776
+ this.events.emit(cancelled ? Cancel : End, event);
6777
+ }
6778
+ dispose() {
6779
+ if (this.disposed) return;
6780
+ cleanupAll([
6781
+ () => this.life.dispose(),
6782
+ () => this.interrupt(),
6783
+ () => this.binding.dispose(),
6784
+ () => this.events.dispose(),
6785
+ () => this.observers.clear(),
6786
+ () => this.host.remove(this)
6787
+ ], "Virtual control cleanup failed");
6788
+ }
6789
+ };
6790
+ var VirtualStick = class extends VirtualControl {
6791
+ /** @internal Use VirtualInput.stick. */
6792
+ constructor(options, host, binding) {
6793
+ super("stick", options, host, binding);
6794
+ }
6795
+ get value() {
6796
+ return this.direction;
6797
+ }
6798
+ get mode() {
6799
+ return this.stickMode;
6800
+ }
6801
+ set mode(value) {
6802
+ this.stickMode = value;
6803
+ }
6804
+ };
6805
+ var VirtualButton = class extends VirtualControl {
6806
+ /** @internal Use VirtualInput.button. */
6807
+ constructor(options, host, binding) {
6808
+ super("button", options, host, binding);
6809
+ }
6810
+ get value() {
6811
+ return this.pressed;
6812
+ }
6813
+ /** Confirmation on normal release, including a drag outside the activation region; never on cancel. */
6814
+ onCommit(callback, owner) {
6815
+ return this.onEnd(callback, owner);
6816
+ }
6817
+ };
6818
+ // packages/core/src/interaction/virtual-input.ts
6819
+ /** Scene-scoped virtual devices; all gameplay values flow through existing ActionSources. */
6820
+ var VirtualInput = class {
6821
+ pointer;
6822
+ input;
6823
+ life = new Lifetime();
6824
+ list = Object.freeze([]);
6825
+ regions = /* @__PURE__ */ new Map();
6826
+ anchors = /* @__PURE__ */ new Map();
6827
+ view = Object.freeze({
6828
+ x: 0,
6829
+ y: 0,
6830
+ width: 800,
6831
+ height: 450
6832
+ });
6833
+ active = true;
6834
+ shown = true;
6835
+ dirty = true;
6836
+ host;
6837
+ constructor(pointer, input) {
6838
+ this.pointer = pointer;
6839
+ this.input = input;
6840
+ if (pointer.disposed) throw new EngineError("OWNER_DISPOSED", "Scene pointer has been disposed");
6841
+ this.host = {
6842
+ available: () => !this.disposed && this.active && input.liveInput,
6843
+ center: (control) => this.anchors.get(control) ?? {
6844
+ x: 0,
6845
+ y: 0
6846
+ },
6847
+ contains: (control, data) => {
6848
+ const region = this.regions.get(control);
6849
+ return region ? virtualContains(region, data.screen, this.view) : false;
6850
+ },
6851
+ region: (control, region) => {
6852
+ const regions = this.resolve(this.view, control, region);
6853
+ this.regions = regions;
6854
+ this.anchors = this.centers(regions, this.view);
6855
+ this.dirty = true;
6856
+ },
6857
+ remove: (control) => {
6858
+ this.list = Object.freeze(this.list.filter((c) => c !== control));
6859
+ this.regions.delete(control);
6860
+ this.anchors.delete(control);
6861
+ }
6862
+ };
6863
+ try {
6864
+ this.life.onDispose(pointer.attachTargets(this));
6865
+ this.life.onDispose(input.addAdapter(this));
6866
+ this.life.onDispose(pointer.onDispose(() => this.dispose()));
6867
+ } catch (error) {
6868
+ this.life.dispose();
6869
+ throw error;
6870
+ }
6871
+ }
6872
+ get disposed() {
6873
+ return this.life.disposed;
6874
+ }
6875
+ get controls() {
6876
+ return this.list;
6877
+ }
6878
+ get viewport() {
6879
+ return this.view;
6880
+ }
6881
+ get enabled() {
6882
+ return this.active;
6883
+ }
6884
+ set enabled(value) {
6885
+ this.alive();
6886
+ this.flag(value);
6887
+ if (this.active === value) return;
6888
+ this.active = value;
6889
+ this.reset();
6890
+ }
6891
+ get visible() {
6892
+ return this.shown;
6893
+ }
6894
+ set visible(value) {
6895
+ this.alive();
6896
+ this.flag(value);
6897
+ this.shown = value;
6898
+ }
6899
+ onDispose(callback) {
6900
+ return this.life.onDispose(callback);
6901
+ }
6902
+ flag(value) {
6903
+ if (typeof value !== "boolean") throw new EngineError("INVALID_VIRTUAL_INPUT", "Virtual input flags must be boolean");
6904
+ }
6905
+ alive() {
6906
+ if (this.disposed) throw new EngineError("OWNER_DISPOSED", "Virtual input has been disposed");
6907
+ }
6908
+ validate(controller, action, kind, options) {
6909
+ this.alive();
6910
+ checkVirtualOptions(options);
6911
+ 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");
6912
+ const region = resolveVirtualRegion(options.region, this.view);
6913
+ const peers = this.resolve(this.view);
6914
+ for (const control of this.list) {
6915
+ const other = peers.get(control);
6916
+ if (other && control.kind === (kind === "axis2d" ? "stick" : "button") && virtualOverlap(region, other)) throw new EngineError("VIRTUAL_REGION_OVERLAP", "Same-priority virtual activation regions overlap");
6917
+ }
6918
+ if (options.position) virtualPosition(options.position, this.view);
6919
+ }
6920
+ binding(controller, action) {
6921
+ const source = controller.createSource();
6922
+ return {
6923
+ get disposed() {
6924
+ return source.disposed;
6925
+ },
6926
+ onDispose: (callback) => controller.onDispose(callback),
6927
+ clear: () => source.clear(),
6928
+ dispose: () => source.dispose(),
6929
+ active: () => controller.active,
6930
+ write: (direction, pressed) => source.set(action, action.kind === "axis2d" ? direction : pressed)
6931
+ };
6932
+ }
6933
+ stick(controller, action, options) {
6934
+ this.validate(controller, action, "axis2d", options);
6935
+ const binding = this.binding(controller, action);
6936
+ try {
6937
+ return this.add(new VirtualStick(options, this.host, binding));
6938
+ } catch (error) {
6939
+ binding.dispose();
6940
+ throw error;
6941
+ }
6942
+ }
6943
+ button(controller, action, options) {
6944
+ this.validate(controller, action, "button", options);
6945
+ const binding = this.binding(controller, action);
6946
+ try {
6947
+ return this.add(new VirtualButton(options, this.host, binding));
6948
+ } catch (error) {
6949
+ binding.dispose();
6950
+ throw error;
6951
+ }
6952
+ }
6953
+ add(control) {
6954
+ this.list = Object.freeze([...this.list, control]);
6955
+ this.dirty = true;
6956
+ try {
6957
+ this.refresh();
6958
+ } catch (error) {
6959
+ control.dispose();
6960
+ throw error;
6961
+ }
6962
+ return control;
6963
+ }
6964
+ resolve(view, replaced, region) {
6965
+ const result = /* @__PURE__ */ new Map();
6966
+ for (const control of this.list) result.set(control, resolveVirtualRegion(control === replaced && region ? region : control.region, view));
6967
+ for (let i = 0; i < this.list.length; i++) {
6968
+ const a = this.list[i];
6969
+ if (!a) continue;
6970
+ for (let j = i + 1; j < this.list.length; j++) {
6971
+ const b = this.list[j], x = result.get(a), y = b ? result.get(b) : void 0;
6972
+ if (b && x && y && a.kind === b.kind && virtualOverlap(x, y)) throw new EngineError("VIRTUAL_REGION_OVERLAP", "Same-priority virtual activation regions overlap", {
6973
+ first: i,
6974
+ second: j
6975
+ });
6976
+ }
6977
+ }
6978
+ return result;
6979
+ }
6980
+ centers(regions, view) {
6981
+ return new Map([...regions].map(([control, region]) => {
6982
+ const b = region.bounds;
6983
+ return [control, control.position ? virtualPosition(control.position, view) : Object.freeze({
6984
+ x: b.x + b.width / 2,
6985
+ y: b.y + b.height / 2
6986
+ })];
6987
+ }));
6988
+ }
6989
+ /** Resolve after Canvas layout and before routing/rendering. Layout changes cancel active gestures. */
6990
+ refresh(viewport = this.view) {
6991
+ this.alive();
6992
+ const view = virtualBounds(viewport);
6993
+ const resized = Object.keys(view).some((key) => view[key] !== this.view[key]);
6994
+ if (!this.dirty && !resized && !this.list.some((c) => c.region.kind === "custom")) return;
6995
+ const regions = this.resolve(view), centers = this.centers(regions, view);
6996
+ 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)));
6997
+ this.view = view;
6998
+ this.regions = regions;
6999
+ this.anchors = centers;
7000
+ this.dirty = false;
7001
+ cleanupAll(changed.map((c) => () => c.invalidate()), "Virtual layout cancellation failed");
7002
+ }
7003
+ pick(data) {
7004
+ if (this.disposed || !this.active || !this.input.liveInput || data.button !== 0) return;
7005
+ this.refresh();
7006
+ for (const kind of ["button", "stick"]) for (const control of this.list) if (control.kind === kind && control.available && control.contains(data)) return control;
7007
+ }
7008
+ sample() {
7009
+ if (this.disposed) return;
7010
+ this.refresh();
7011
+ cleanupAll(this.list.filter((c) => c.pressed && !c.available).map((c) => () => c.invalidate()), "Virtual availability cancellation failed");
7012
+ }
7013
+ advance(seconds) {
7014
+ this.alive();
7015
+ if (virtualFinite(seconds, "seconds") < 0) throw new EngineError("INVALID_VIRTUAL_INPUT", "Presentation seconds must be nonnegative");
7016
+ cleanupAll(this.list.map((c) => () => {
7017
+ if (!c.disposed) c.advance(seconds);
7018
+ }), "Virtual hold callbacks failed");
7019
+ }
7020
+ reset() {
7021
+ cleanupAll(this.list.map((c) => () => c.invalidate()), "Virtual input reset failed");
7022
+ }
7023
+ dispose() {
7024
+ if (this.disposed) return;
7025
+ cleanupAll([
7026
+ () => this.life.dispose(),
7027
+ ...this.list.map((c) => () => c.dispose()),
7028
+ () => {
7029
+ this.regions.clear();
7030
+ this.anchors.clear();
7031
+ this.list = Object.freeze([]);
7032
+ }
7033
+ ], "Virtual input cleanup failed");
7034
+ }
7035
+ };
5756
7036
  // packages/core/src/runtime/engine.ts
5757
7037
  var Engine = class {
5758
7038
  input = new InputSystem();
@@ -6320,7 +7600,7 @@ var ReplayPlayer = class {
6320
7600
  this.engine = engine;
6321
7601
  this.data = parseReplayData(data);
6322
7602
  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");
7603
+ if (this.data.engineVersion !== "0.1.6" || engine.random.seed !== this.data.random.seed) throw new EngineError("REPLAY_INCOMPATIBLE", "Engine version or initial seed does not match Replay");
6324
7604
  if (!compatible(this.bound.map(([id, c]) => ({
6325
7605
  id,
6326
7606
  actions: c.schema()
@@ -8218,6 +9498,6 @@ var CanvasUI = class {
8218
9498
  ], "Canvas UI cleanup failed");
8219
9499
  }
8220
9500
  };
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 };
9501
+ 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, GamepadAxis, GamepadBindings, GamepadButton, GeometryTilemap, GeometryTilemapLayer, Grid, HexOrientation, InputCapture, InputCaptureState, InputDevice, InputSystem, KeyCode, KeyboardBindings, KeyboardSource, Lifetime, Motion, MouseButton, MouseSource, 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, checkedGamepadBindings, cleanupAll, containsScreenPoint, containsUI, createViewTransform, defaultCanvasStyle, defineActions, defineComponent, defineEvent, deriveStreamSeed, gamepadDeadZone, gamepadIndex, imageSize, intersectUI, isKeyCode, isMouseButton, layoutCanvas, nativeSession, parseKeyCode, parseMouseButton, parseReplayData, positive, screenPoint, screenToPhysical, screenToWorld, scrollbarContains, sliderTween, spriteFrame, spriteFrameBounds, tweenFinite, worldPoint, worldToScreen, wrapCanvasText };
8222
9502
 
8223
9503
  //# sourceMappingURL=index.js.map