@aethervtt/shard-input 0.0.1 → 0.1.0

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/devices.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { MouseButton } from '@aethervtt/shard-platform';
1
+ import type { MouseButton, RawInputEvent } from '@aethervtt/shard-platform';
2
2
  /** Pressed/just-pressed/just-released bookkeeping for a set of named buttons. */
3
3
  declare class ButtonSet<K extends string> {
4
4
  private readonly down;
@@ -74,4 +74,17 @@ export declare class TouchesState {
74
74
  /** @internal */
75
75
  beginFrame(): void;
76
76
  }
77
+ /** A raw event the gesture recognizer reads: a pointer, the wheel, or focus. */
78
+ export type PointerInput = Extract<RawInputEvent, {
79
+ type: 'pointer' | 'wheel' | 'focus';
80
+ }>;
81
+ /** Pointer events in CSS pixels (0060), for gestures. Mouse and Touches hold the device state. */
82
+ export declare class PointersState {
83
+ /** This frame's pointer, wheel and focus events, in order. Read-only: they're the raw events. */
84
+ readonly events: PointerInput[];
85
+ /** The last pointer position seen, in CSS pixels: where a wheel without one zooms about. */
86
+ readonly position: [number, number];
87
+ /** @internal */
88
+ beginFrame(): void;
89
+ }
77
90
  export {};
package/dist/devices.js CHANGED
@@ -126,6 +126,16 @@ class TouchesState {
126
126
  this.ended.length = 0;
127
127
  }
128
128
  }
129
+ class PointersState {
130
+ /** This frame's pointer, wheel and focus events, in order. Read-only: they're the raw events. */
131
+ events = [];
132
+ /** The last pointer position seen, in CSS pixels: where a wheel without one zooms about. */
133
+ position = [0, 0];
134
+ /** @internal */
135
+ beginFrame() {
136
+ this.events.length = 0;
137
+ }
138
+ }
129
139
  export {
130
140
  GAMEPAD_AXES,
131
141
  GAMEPAD_BUTTONS,
@@ -133,5 +143,6 @@ export {
133
143
  GamepadsState,
134
144
  KeyboardState,
135
145
  MouseState,
146
+ PointersState,
136
147
  TouchesState
137
148
  };
@@ -0,0 +1,226 @@
1
+ import { type World } from '@aethervtt/shard-core';
2
+ import { type MouseButton, type PointerKind, type PointerPolicy, type RawInputEvent } from '@aethervtt/shard-platform';
3
+ import { type Plugin } from '@aethervtt/shard-runtime';
4
+ export type GestureKind = 'tap' | 'double-tap' | 'long-press' | 'drag-start' | 'drag' | 'drag-end' | 'pinch' | 'twist' | 'pan2' | 'wheel';
5
+ export interface GestureEvent {
6
+ kind: GestureKind;
7
+ /**
8
+ * The gesture this event belongs to: a drag's events share it, and so do a two-finger gesture's.
9
+ * Claims are by id. 0 for the wheel (and a ctrl-wheel pinch), which nothing claims.
10
+ */
11
+ id: number;
12
+ pointer: PointerKind;
13
+ /** The button the gesture started with ('left' for touch and the wheel). */
14
+ button: MouseButton;
15
+ /** `MODIFIERS` bits held when it started (the wheel: when it turned). */
16
+ modifiers: number;
17
+ /** Where it is now, in CSS pixels; a two-finger gesture's is the fingers' center. */
18
+ x: number;
19
+ y: number;
20
+ /** Where it started. */
21
+ startX: number;
22
+ startY: number;
23
+ /** Movement since this gesture's previous event (`wheel`: the wheel's, in pixels). */
24
+ dx: number;
25
+ dy: number;
26
+ /** `pinch`: this event's scale factor, above 1 as the fingers spread. 1 otherwise. */
27
+ scale: number;
28
+ /** `twist`: this event's turn in radians, clockwise on screen. 0 otherwise. */
29
+ angle: number;
30
+ /** `drag-end`: the drag was cancelled (Escape, focus loss, a pointercancel). */
31
+ cancelled: boolean;
32
+ }
33
+ export declare const Gesture: import("@aethervtt/shard-core").EventDef<GestureEvent>;
34
+ /** Thresholds the recognizer uses. Change them on `world.resource(Gestures).settings`. */
35
+ export interface GestureSettings {
36
+ /** A press that moves at least this far (CSS px) is a drag. */
37
+ dragPx: number;
38
+ /** A second tap within this long and this near the first is a double tap. */
39
+ doubleTapMs: number;
40
+ doubleTapPx: number;
41
+ /** A press held this long without dragging is a long press. */
42
+ longPressMs: number;
43
+ }
44
+ type TrackState = 'pending' | 'pressed' | 'dragging' | 'two' | 'done';
45
+ interface Track {
46
+ pointerId: number;
47
+ gesture: number;
48
+ pointer: PointerKind;
49
+ button: MouseButton;
50
+ modifiers: number;
51
+ startX: number;
52
+ startY: number;
53
+ x: number;
54
+ y: number;
55
+ downAt: number;
56
+ state: TrackState;
57
+ }
58
+ interface TwoFinger {
59
+ gesture: number;
60
+ a: Track;
61
+ b: Track;
62
+ startX: number;
63
+ startY: number;
64
+ x: number;
65
+ y: number;
66
+ distance: number;
67
+ angle: number;
68
+ }
69
+ interface ClaimState {
70
+ owner: string | undefined;
71
+ held: number;
72
+ pointers: number[];
73
+ ended: boolean;
74
+ }
75
+ /**
76
+ * The gesture recognizer's state, and who owns which gesture. Consumers read `Gesture` events,
77
+ * claim the ones they use on their first event (`claim`), and ignore the rest.
78
+ */
79
+ export declare class GesturesState {
80
+ readonly settings: GestureSettings;
81
+ /** @internal */ readonly tracks: Map<number, Track>;
82
+ /** @internal */ two: TwoFinger | undefined;
83
+ /** @internal */ readonly claims: Map<number, ClaimState>;
84
+ /** @internal */ lastTap: {
85
+ at: number;
86
+ x: number;
87
+ y: number;
88
+ button: MouseButton;
89
+ } | undefined;
90
+ /** @internal */ nextId: number;
91
+ /** @internal */ readonly policies: Map<string, PointerPolicy>;
92
+ /** @internal */ policyKey: string;
93
+ /** @internal A policy changed since the source last heard. */
94
+ policyDirty: boolean;
95
+ /** @internal */ world: World | undefined;
96
+ /** @internal Cancels the next frame applies (`cancel()` between frames). */
97
+ cancelRequested: boolean;
98
+ /**
99
+ * Takes gesture `id` for `owner`, from its first event on: its pointers are the scene's (the DOM
100
+ * source stops their default handling and captures them). False when someone else owns it.
101
+ */
102
+ claim(id: number, owner: string): boolean;
103
+ /**
104
+ * Takes gesture `id` for `owner` even from another owner (whose next look at `owner(id)` tells
105
+ * it). PlaneDrag takes a drag a fallback control took while the host was still deciding.
106
+ */
107
+ take(id: number, owner: string): boolean;
108
+ /**
109
+ * Says a consumer is deciding about gesture `id` (an async pick): fallback consumers (a control
110
+ * panning on a drag over empty floor) leave it alone until it's claimed or every hold is let go.
111
+ */
112
+ hold(id: number): void;
113
+ /** Lets go of a `hold`, or of a claim `owner` made. */
114
+ release(id: number, owner?: string): void;
115
+ /** Who owns gesture `id`, if anyone. */
116
+ owner(id: number): string | undefined;
117
+ /** Whether a fallback consumer may claim gesture `id`: it's live, unowned and nobody holds it. */
118
+ free(id: number): boolean;
119
+ /**
120
+ * Where gesture `id`'s pointer is now (a two-finger gesture: the center), in CSS pixels. False
121
+ * once it ended. A consumer that claims late (after an async pick) catches up from here.
122
+ */
123
+ position(id: number, out: {
124
+ [index: number]: number;
125
+ }): boolean;
126
+ /** Whether gesture `id` is still going (its pointers are down). */
127
+ live(id: number): boolean;
128
+ /**
129
+ * Ends every active drag and two-finger gesture, with `drag-end { cancelled: true }` for drags,
130
+ * at the start of the next frame. Their pointers are ignored until they lift. The `cancel` action
131
+ * (Escape) calls it.
132
+ */
133
+ cancel(): void;
134
+ /**
135
+ * What `owner` takes from the pointer while enabled; `undefined` removes it. The DOM source gets
136
+ * the union of every owner's, so it stops default handling for those and nothing else.
137
+ */
138
+ setPolicy(owner: string, policy: PointerPolicy | undefined): void;
139
+ /** Active pointers, gestures and claims. For agents. */
140
+ describe(): {
141
+ settings: {
142
+ /** A press that moves at least this far (CSS px) is a drag. */
143
+ dragPx: number;
144
+ /** A second tap within this long and this near the first is a double tap. */
145
+ doubleTapMs: number;
146
+ doubleTapPx: number;
147
+ /** A press held this long without dragging is a long press. */
148
+ longPressMs: number;
149
+ };
150
+ pointers: {
151
+ pointer: PointerKind;
152
+ button: MouseButton;
153
+ state: TrackState;
154
+ gesture: number;
155
+ position: number[];
156
+ }[];
157
+ claims: {
158
+ id: number;
159
+ owner: string | null;
160
+ held: boolean;
161
+ }[];
162
+ policy: PointerPolicy;
163
+ };
164
+ private own;
165
+ }
166
+ export declare const Gestures: import("@aethervtt/shard-core").ResourceDef<GesturesState>;
167
+ /** The built-in `cancel` action: Escape ends a drag. Rebind it with `rebindAction`. */
168
+ export declare const GestureActions: import("./actions.js").ActionMapDef<"cancel">;
169
+ /** Turns this frame's pointer events into gestures, after `input/actions` (for Escape). */
170
+ export declare const updateGestures: import("@aethervtt/shard-core").SystemDef<undefined>;
171
+ /**
172
+ * Gestures (0060): `Gesture` events from pointers and the wheel, claims, and the `cancel` action
173
+ * (Escape). Needs `inputPlugin`.
174
+ */
175
+ export declare const gesturesPlugin: Plugin;
176
+ type Point = readonly [number, number];
177
+ /** A gesture to simulate (`simulateGestures`, the protocol's `input.simulate`), in CSS pixels. */
178
+ export type SimulatedGesture = {
179
+ drag: {
180
+ from: Point;
181
+ to: Point;
182
+ button?: MouseButton;
183
+ pointer?: PointerKind;
184
+ modifiers?: number;
185
+ /** Frames the move takes. Default 10. */
186
+ frames?: number;
187
+ /** Ends with Escape instead of lifting (a cancelled drag). */
188
+ cancel?: boolean;
189
+ };
190
+ } | {
191
+ pinch: {
192
+ center: Point;
193
+ /** Distance between the fingers at the start and the end, px. */
194
+ from: number;
195
+ to: number;
196
+ /** Turn over the gesture, radians clockwise. Default 0. */
197
+ twist?: number;
198
+ /** The center's movement over the gesture (two-finger pan). Default none. */
199
+ pan?: Point;
200
+ frames?: number;
201
+ };
202
+ } | {
203
+ wheel: {
204
+ at: Point;
205
+ dy: number;
206
+ dx?: number;
207
+ modifiers?: number;
208
+ };
209
+ } | {
210
+ tap: Point;
211
+ button?: MouseButton;
212
+ pointer?: PointerKind;
213
+ } | {
214
+ wait: number;
215
+ };
216
+ /**
217
+ * Raw input frames that play `gestures` one after another, a step per frame. Pointers start at
218
+ * id 1,000,000 so they never collide with a live one.
219
+ */
220
+ export declare function gestureFrames(gestures: readonly SimulatedGesture[]): RawInputEvent[][];
221
+ /**
222
+ * Plays gestures through the input stream, a step per frame from the next one on, so gameplay
223
+ * tests and agents drive controls and drags headless. Returns how many frames they take.
224
+ */
225
+ export declare function simulateGestures(world: World, gestures: readonly SimulatedGesture[]): number;
226
+ export {};
@@ -0,0 +1,535 @@
1
+ import {
2
+ defineEvent,
3
+ defineResource,
4
+ defineSystem,
5
+ First,
6
+ ShardError
7
+ } from "@aethervtt/shard-core";
8
+ import {
9
+ MODIFIERS
10
+ } from "@aethervtt/shard-platform";
11
+ import { definePlugin, FrameDemand, Time } from "@aethervtt/shard-runtime";
12
+ import { defineActions } from "./actions.js";
13
+ import {
14
+ addActions,
15
+ InputQueue,
16
+ Mouse,
17
+ Pointers,
18
+ scheduleInput,
19
+ Touches,
20
+ updateActions
21
+ } from "./plugin.js";
22
+ const Gesture = defineEvent("input/Gesture", {
23
+ description: "Taps, long presses, drags, pinches, twists, two-finger pans and the wheel, in CSS pixels (0060)."
24
+ });
25
+ class GesturesState {
26
+ settings = {
27
+ dragPx: 4,
28
+ doubleTapMs: 300,
29
+ doubleTapPx: 6,
30
+ longPressMs: 500
31
+ };
32
+ /** @internal */
33
+ tracks = /* @__PURE__ */ new Map();
34
+ /** @internal */
35
+ two = void 0;
36
+ /** @internal */
37
+ claims = /* @__PURE__ */ new Map();
38
+ /** @internal */
39
+ lastTap;
40
+ /** @internal */
41
+ nextId = 1;
42
+ /** @internal */
43
+ policies = /* @__PURE__ */ new Map();
44
+ /** @internal */
45
+ policyKey = "";
46
+ /** @internal A policy changed since the source last heard. */
47
+ policyDirty = false;
48
+ /** @internal */
49
+ world = void 0;
50
+ /** @internal Cancels the next frame applies (`cancel()` between frames). */
51
+ cancelRequested = false;
52
+ /**
53
+ * Takes gesture `id` for `owner`, from its first event on: its pointers are the scene's (the DOM
54
+ * source stops their default handling and captures them). False when someone else owns it.
55
+ */
56
+ claim(id, owner) {
57
+ const c = this.claims.get(id);
58
+ if (!c || c.ended) return false;
59
+ if (c.owner !== void 0 && c.owner !== owner) return false;
60
+ this.own(c, owner);
61
+ return true;
62
+ }
63
+ /**
64
+ * Takes gesture `id` for `owner` even from another owner (whose next look at `owner(id)` tells
65
+ * it). PlaneDrag takes a drag a fallback control took while the host was still deciding.
66
+ */
67
+ take(id, owner) {
68
+ const c = this.claims.get(id);
69
+ if (!c || c.ended) return false;
70
+ this.own(c, owner);
71
+ return true;
72
+ }
73
+ /**
74
+ * Says a consumer is deciding about gesture `id` (an async pick): fallback consumers (a control
75
+ * panning on a drag over empty floor) leave it alone until it's claimed or every hold is let go.
76
+ */
77
+ hold(id) {
78
+ const c = this.claims.get(id);
79
+ if (c && !c.ended) c.held++;
80
+ }
81
+ /** Lets go of a `hold`, or of a claim `owner` made. */
82
+ release(id, owner) {
83
+ const c = this.claims.get(id);
84
+ if (!c) return;
85
+ if (owner === void 0) c.held = Math.max(0, c.held - 1);
86
+ else if (c.owner === owner) c.owner = void 0;
87
+ }
88
+ /** Who owns gesture `id`, if anyone. */
89
+ owner(id) {
90
+ return this.claims.get(id)?.owner;
91
+ }
92
+ /** Whether a fallback consumer may claim gesture `id`: it's live, unowned and nobody holds it. */
93
+ free(id) {
94
+ const c = this.claims.get(id);
95
+ return c !== void 0 && !c.ended && c.owner === void 0 && c.held === 0;
96
+ }
97
+ /**
98
+ * Where gesture `id`'s pointer is now (a two-finger gesture: the center), in CSS pixels. False
99
+ * once it ended. A consumer that claims late (after an async pick) catches up from here.
100
+ */
101
+ position(id, out) {
102
+ for (const t of this.tracks.values()) {
103
+ if (t.gesture !== id || t.state === "done") continue;
104
+ out[0] = t.x;
105
+ out[1] = t.y;
106
+ return true;
107
+ }
108
+ if (this.two?.gesture === id) {
109
+ out[0] = this.two.x;
110
+ out[1] = this.two.y;
111
+ return true;
112
+ }
113
+ return false;
114
+ }
115
+ /** Whether gesture `id` is still going (its pointers are down). */
116
+ live(id) {
117
+ const c = this.claims.get(id);
118
+ return c !== void 0 && !c.ended;
119
+ }
120
+ /**
121
+ * Ends every active drag and two-finger gesture, with `drag-end { cancelled: true }` for drags,
122
+ * at the start of the next frame. Their pointers are ignored until they lift. The `cancel` action
123
+ * (Escape) calls it.
124
+ */
125
+ cancel() {
126
+ this.cancelRequested = true;
127
+ }
128
+ /**
129
+ * What `owner` takes from the pointer while enabled; `undefined` removes it. The DOM source gets
130
+ * the union of every owner's, so it stops default handling for those and nothing else.
131
+ */
132
+ setPolicy(owner, policy) {
133
+ if (policy) this.policies.set(owner, policy);
134
+ else if (!this.policies.delete(owner)) return;
135
+ this.policyDirty = true;
136
+ }
137
+ /** Active pointers, gestures and claims. For agents. */
138
+ describe() {
139
+ return {
140
+ settings: { ...this.settings },
141
+ pointers: [...this.tracks.values()].map((t) => ({
142
+ pointer: t.pointer,
143
+ button: t.button,
144
+ state: t.state,
145
+ gesture: t.gesture,
146
+ position: [t.x, t.y]
147
+ })),
148
+ claims: [...this.claims].filter(([, c]) => !c.ended).map(([id, c]) => ({ id, owner: c.owner ?? null, held: c.held > 0 })),
149
+ policy: union(this.policies)
150
+ };
151
+ }
152
+ own(c, owner) {
153
+ c.owner = owner;
154
+ const source = this.world?.tryResource(InputQueue)?.source;
155
+ for (const p of c.pointers) source?.claimPointer?.(p);
156
+ }
157
+ }
158
+ const Gestures = defineResource("input/Gestures", {
159
+ description: "Gesture recognizer (0060): thresholds, active pointers, and who claimed which gesture."
160
+ });
161
+ const GestureActions = defineActions(
162
+ "input/GestureActions",
163
+ { cancel: { kind: "button", bindings: ["Key:Escape"] } },
164
+ { context: "any" }
165
+ );
166
+ const BUTTON_ORDER = ["left", "middle", "right", "back", "forward"];
167
+ function union(policies) {
168
+ const buttons = /* @__PURE__ */ new Set();
169
+ let wheel2 = false;
170
+ let touch = false;
171
+ for (const p of policies.values()) {
172
+ for (const b of p.buttons) buttons.add(b);
173
+ wheel2 ||= p.wheel;
174
+ touch ||= p.touch;
175
+ }
176
+ return { buttons: BUTTON_ORDER.filter((b) => buttons.has(b)), wheel: wheel2, touch };
177
+ }
178
+ function send(world, kind, id, t, x, y, startX, startY, dx, dy, scale = 1, angle = 0, cancelled = false) {
179
+ world.send(Gesture, {
180
+ kind,
181
+ id,
182
+ pointer: t.pointer,
183
+ button: t.button,
184
+ modifiers: t.modifiers,
185
+ x,
186
+ y,
187
+ startX,
188
+ startY,
189
+ dx,
190
+ dy,
191
+ scale,
192
+ angle,
193
+ cancelled
194
+ });
195
+ }
196
+ function endDrag(world, s, t, cancelled) {
197
+ if (t.state === "dragging")
198
+ send(world, "drag-end", t.gesture, t, t.x, t.y, t.startX, t.startY, 0, 0, 1, 0, cancelled);
199
+ end(s, t.gesture);
200
+ }
201
+ function end(s, id) {
202
+ const c = s.claims.get(id);
203
+ if (c) c.ended = true;
204
+ }
205
+ function cancelAll(world, s) {
206
+ for (const t of s.tracks.values()) {
207
+ if (t.state === "dragging") endDrag(world, s, t, true);
208
+ else if (t.state !== "done") end(s, t.gesture);
209
+ t.state = "done";
210
+ }
211
+ if (s.two) {
212
+ end(s, s.two.gesture);
213
+ s.two = void 0;
214
+ }
215
+ }
216
+ function startTwo(world, s, a, b) {
217
+ for (const t of [a, b]) {
218
+ if (t.state === "dragging") endDrag(world, s, t, false);
219
+ else end(s, t.gesture);
220
+ t.state = "two";
221
+ }
222
+ const id = s.nextId++;
223
+ s.claims.set(id, {
224
+ owner: void 0,
225
+ held: 0,
226
+ pointers: [a.pointerId, b.pointerId],
227
+ ended: false
228
+ });
229
+ const x = (a.x + b.x) / 2;
230
+ const y = (a.y + b.y) / 2;
231
+ s.two = {
232
+ gesture: id,
233
+ a,
234
+ b,
235
+ startX: x,
236
+ startY: y,
237
+ x,
238
+ y,
239
+ distance: Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2),
240
+ angle: Math.atan2(b.y - a.y, b.x - a.x)
241
+ };
242
+ }
243
+ function moveTwo(world, two) {
244
+ const { a, b } = two;
245
+ const x = (a.x + b.x) / 2;
246
+ const y = (a.y + b.y) / 2;
247
+ const distance = Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
248
+ const angle = Math.atan2(b.y - a.y, b.x - a.x);
249
+ if (x !== two.x || y !== two.y)
250
+ send(world, "pan2", two.gesture, a, x, y, two.startX, two.startY, x - two.x, y - two.y);
251
+ if (distance > 0 && two.distance > 0 && distance !== two.distance)
252
+ send(
253
+ world,
254
+ "pinch",
255
+ two.gesture,
256
+ a,
257
+ x,
258
+ y,
259
+ two.startX,
260
+ two.startY,
261
+ 0,
262
+ 0,
263
+ distance / two.distance
264
+ );
265
+ let turn = angle - two.angle;
266
+ if (turn > Math.PI) turn -= 2 * Math.PI;
267
+ else if (turn < -Math.PI) turn += 2 * Math.PI;
268
+ if (turn !== 0) send(world, "twist", two.gesture, a, x, y, two.startX, two.startY, 0, 0, 1, turn);
269
+ two.x = x;
270
+ two.y = y;
271
+ two.distance = distance;
272
+ two.angle = angle;
273
+ }
274
+ function down(world, s, e, now, ui) {
275
+ const old = s.tracks.get(e.id);
276
+ if (old) up(world, s, old, now, true);
277
+ const id = s.nextId++;
278
+ const t = {
279
+ pointerId: e.id,
280
+ gesture: id,
281
+ pointer: e.pointer,
282
+ button: e.pointer === "touch" ? "left" : e.button,
283
+ modifiers: e.modifiers,
284
+ startX: e.x,
285
+ startY: e.y,
286
+ x: e.x,
287
+ y: e.y,
288
+ downAt: now,
289
+ state: "pending"
290
+ };
291
+ s.tracks.set(e.id, t);
292
+ s.claims.set(id, { owner: ui ? "ui" : void 0, held: 0, pointers: [e.id], ended: false });
293
+ if (e.pointer !== "touch") return;
294
+ let touching = 0;
295
+ let other;
296
+ for (const u of s.tracks.values()) {
297
+ if (u.pointer !== "touch") continue;
298
+ touching++;
299
+ if (u !== t && u.state !== "done") other = u;
300
+ }
301
+ if (touching === 2 && other && !s.two) startTwo(world, s, other, t);
302
+ else if (touching > 2) t.state = "done";
303
+ }
304
+ function move(world, s, t, x, y) {
305
+ const dx = x - t.x;
306
+ const dy = y - t.y;
307
+ t.x = x;
308
+ t.y = y;
309
+ if (t.state === "two") {
310
+ if (s.two && (s.two.a === t || s.two.b === t)) moveTwo(world, s.two);
311
+ return;
312
+ }
313
+ if (t.state === "dragging") {
314
+ send(world, "drag", t.gesture, t, x, y, t.startX, t.startY, dx, dy);
315
+ return;
316
+ }
317
+ if (t.state !== "pending" && t.state !== "pressed") return;
318
+ const ox = x - t.startX;
319
+ const oy = y - t.startY;
320
+ if (ox * ox + oy * oy < s.settings.dragPx * s.settings.dragPx) return;
321
+ t.state = "dragging";
322
+ send(world, "drag-start", t.gesture, t, x, y, t.startX, t.startY, ox, oy);
323
+ }
324
+ function up(world, s, t, now, cancelled) {
325
+ s.tracks.delete(t.pointerId);
326
+ if (t.state === "dragging") {
327
+ endDrag(world, s, t, cancelled);
328
+ return;
329
+ }
330
+ if (t.state === "two") {
331
+ const two = s.two;
332
+ if (two && (two.a === t || two.b === t)) {
333
+ end(s, two.gesture);
334
+ const rest = two.a === t ? two.b : two.a;
335
+ rest.state = "done";
336
+ s.two = void 0;
337
+ }
338
+ end(s, t.gesture);
339
+ return;
340
+ }
341
+ end(s, t.gesture);
342
+ if (t.state !== "pending" || cancelled) return;
343
+ send(world, "tap", t.gesture, t, t.x, t.y, t.startX, t.startY, 0, 0);
344
+ const last = s.lastTap;
345
+ const { doubleTapMs, doubleTapPx } = s.settings;
346
+ if (last && last.button === t.button && now - last.at <= doubleTapMs && (t.x - last.x) ** 2 + (t.y - last.y) ** 2 <= doubleTapPx * doubleTapPx) {
347
+ send(world, "double-tap", t.gesture, t, t.x, t.y, t.startX, t.startY, 0, 0);
348
+ s.lastTap = void 0;
349
+ } else {
350
+ s.lastTap = { at: now, x: t.x, y: t.y, button: t.button };
351
+ }
352
+ }
353
+ const WHEEL = { pointer: "mouse", button: "left", modifiers: 0 };
354
+ function wheel(world, e, fx, fy) {
355
+ const x = e.x ?? fx;
356
+ const y = e.y ?? fy;
357
+ WHEEL.modifiers = e.modifiers ?? 0;
358
+ if ((WHEEL.modifiers & MODIFIERS.ctrl) !== 0)
359
+ send(world, "pinch", 0, WHEEL, x, y, x, y, 0, 0, Math.exp(-e.dy * 0.01));
360
+ else send(world, "wheel", 0, WHEEL, x, y, x, y, e.dx, e.dy);
361
+ }
362
+ function onUi(world, e) {
363
+ if (e.pointer === "touch") return world.tryResource(Touches)?.captured.has(e.id) ?? false;
364
+ return world.tryResource(Mouse)?.captured ?? false;
365
+ }
366
+ function apply(world, s, e, now, fx, fy) {
367
+ if (e.type === "wheel") {
368
+ wheel(world, e, fx, fy);
369
+ return;
370
+ }
371
+ if (e.type === "focus") {
372
+ if (!e.focused) {
373
+ cancelAll(world, s);
374
+ s.tracks.clear();
375
+ }
376
+ return;
377
+ }
378
+ if (e.phase === "down") {
379
+ down(world, s, e, now, onUi(world, e));
380
+ return;
381
+ }
382
+ const t = s.tracks.get(e.id);
383
+ if (!t) return;
384
+ if (e.phase === "move") move(world, s, t, e.x, e.y);
385
+ else {
386
+ if (e.x !== t.x || e.y !== t.y) move(world, s, t, e.x, e.y);
387
+ up(world, s, t, now, e.phase === "cancel");
388
+ }
389
+ }
390
+ const updateGestures = defineSystem({
391
+ name: "input/gestures",
392
+ description: "Recognizes taps, presses, drags and two-finger gestures from pointer events.",
393
+ run: (_, world) => {
394
+ const s = world.resource(Gestures);
395
+ if (s.claims.size > 0) {
396
+ for (const [id, c] of s.claims) if (c.ended) s.claims.delete(id);
397
+ }
398
+ const now = world.resource(Time).elapsed * 1e3;
399
+ const actions = world.tryResource(GestureActions.resource);
400
+ if (s.cancelRequested) {
401
+ s.cancelRequested = false;
402
+ cancelAll(world, s);
403
+ }
404
+ const pointers = world.resource(Pointers);
405
+ const fx = pointers.position[0];
406
+ const fy = pointers.position[1];
407
+ for (const e of pointers.events) apply(world, s, e, now, fx, fy);
408
+ if (actions?.justPressed("cancel")) cancelAll(world, s);
409
+ let soonest = Number.POSITIVE_INFINITY;
410
+ if (s.tracks.size > 0)
411
+ for (const t of s.tracks.values()) {
412
+ if (t.state !== "pending") continue;
413
+ const left = t.downAt + s.settings.longPressMs - now;
414
+ if (left <= 0) {
415
+ t.state = "pressed";
416
+ send(world, "long-press", t.gesture, t, t.x, t.y, t.startX, t.startY, 0, 0);
417
+ } else if (left < soonest) soonest = left;
418
+ }
419
+ if (soonest !== Number.POSITIVE_INFINITY) world.resource(FrameDemand).after(soonest);
420
+ syncPolicy(world, s);
421
+ }
422
+ });
423
+ function syncPolicy(world, s) {
424
+ if (!s.policyDirty) return;
425
+ s.policyDirty = false;
426
+ const policy = union(s.policies);
427
+ const key = `${policy.buttons.join(",")}|${policy.wheel}|${policy.touch}`;
428
+ if (key === s.policyKey) return;
429
+ s.policyKey = key;
430
+ world.resource(InputQueue).source?.setPointerPolicy?.(policy);
431
+ }
432
+ const gesturesPlugin = definePlugin({
433
+ name: "input/gestures",
434
+ dependencies: ["input"],
435
+ provides: [Gestures, Gesture, GestureActions.resource, updateGestures],
436
+ build(app) {
437
+ const state = new GesturesState();
438
+ state.world = app.world;
439
+ app.insertResource(Gestures, state);
440
+ addActions(app.world, GestureActions);
441
+ app.addSystems(First, updateGestures.after(updateActions));
442
+ }
443
+ });
444
+ const SIM_ID = 1e6;
445
+ function gestureFrames(gestures) {
446
+ const frames = [];
447
+ let id = SIM_ID;
448
+ const p = (pid, phase, x, y, pointer = "mouse", button = "left", modifiers = 0) => ({ type: "pointer", id: pid, phase, pointer, button, x, y, modifiers });
449
+ for (const g of gestures) {
450
+ if ("drag" in g) {
451
+ const d = g.drag;
452
+ const n = Math.max(1, d.frames ?? 10);
453
+ const pid = id++;
454
+ const kind = d.pointer ?? "mouse";
455
+ const button = d.button ?? "left";
456
+ frames.push([p(pid, "down", d.from[0], d.from[1], kind, button, d.modifiers)]);
457
+ for (let i = 1; i <= n; i++) {
458
+ const t = i / n;
459
+ const x = d.from[0] + (d.to[0] - d.from[0]) * t;
460
+ const y = d.from[1] + (d.to[1] - d.from[1]) * t;
461
+ frames.push([p(pid, "move", x, y, kind, button, d.modifiers)]);
462
+ }
463
+ if (d.cancel) {
464
+ frames.push([{ type: "key", code: "Escape", pressed: true }]);
465
+ frames.push([{ type: "key", code: "Escape", pressed: false }]);
466
+ }
467
+ frames.push([p(pid, "up", d.to[0], d.to[1], kind, button, d.modifiers)]);
468
+ } else if ("pinch" in g) {
469
+ const q = g.pinch;
470
+ const n = Math.max(1, q.frames ?? 10);
471
+ const a = id++;
472
+ const b = id++;
473
+ const at = (t) => {
474
+ const r = (q.from + (q.to - q.from) * t) / 2;
475
+ const turn = (q.twist ?? 0) * t;
476
+ const cx = q.center[0] + (q.pan?.[0] ?? 0) * t;
477
+ const cy = q.center[1] + (q.pan?.[1] ?? 0) * t;
478
+ const c = Math.cos(turn) * r;
479
+ const s = Math.sin(turn) * r;
480
+ return [cx - c, cy - s, cx + c, cy + s];
481
+ };
482
+ const start = at(0);
483
+ frames.push([
484
+ p(a, "down", start[0], start[1], "touch"),
485
+ p(b, "down", start[2], start[3], "touch")
486
+ ]);
487
+ for (let i = 1; i <= n; i++) {
488
+ const f = at(i / n);
489
+ frames.push([p(a, "move", f[0], f[1], "touch"), p(b, "move", f[2], f[3], "touch")]);
490
+ }
491
+ const end2 = at(1);
492
+ frames.push([p(a, "up", end2[0], end2[1], "touch"), p(b, "up", end2[2], end2[3], "touch")]);
493
+ } else if ("wheel" in g) {
494
+ const w = g.wheel;
495
+ frames.push([
496
+ {
497
+ type: "wheel",
498
+ dx: w.dx ?? 0,
499
+ dy: w.dy,
500
+ x: w.at[0],
501
+ y: w.at[1],
502
+ modifiers: w.modifiers ?? 0
503
+ }
504
+ ]);
505
+ } else if ("tap" in g) {
506
+ const pid = id++;
507
+ const kind = g.pointer ?? "mouse";
508
+ const button = g.button ?? "left";
509
+ frames.push([p(pid, "down", g.tap[0], g.tap[1], kind, button)]);
510
+ frames.push([p(pid, "up", g.tap[0], g.tap[1], kind, button)]);
511
+ } else if ("wait" in g) {
512
+ for (let i = 0; i < g.wait; i++) frames.push([]);
513
+ } else {
514
+ throw new ShardError("input/unknown-gesture", `Can't simulate ${JSON.stringify(g)}`, {
515
+ hint: "Simulate { drag }, { pinch }, { wheel }, { tap } or { wait }."
516
+ });
517
+ }
518
+ }
519
+ return frames;
520
+ }
521
+ function simulateGestures(world, gestures) {
522
+ const frames = gestureFrames(gestures);
523
+ scheduleInput(world, frames);
524
+ return frames.length;
525
+ }
526
+ export {
527
+ Gesture,
528
+ GestureActions,
529
+ Gestures,
530
+ GesturesState,
531
+ gestureFrames,
532
+ gesturesPlugin,
533
+ simulateGestures,
534
+ updateGestures
535
+ };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { type ActionDef, type ActionMapDef, ActionState, type Axis1dAction, type Axis2dAction, actionMapSchema, type ButtonAction, defineActions, type Interaction, } from './actions.js';
2
2
  export { type ParsedBinding, parseBinding } from './bindings.js';
3
- export { GAMEPAD_AXES, GAMEPAD_BUTTONS, type GamepadAxis, type GamepadButton, GamepadState, GamepadsState, KeyboardState, MouseState, type Touch, TouchesState, } from './devices.js';
4
- export { addActions, describeInput, Gamepads, type InjectedInput, InputContext, type InputContextValue, type InputPluginOptions, InputQueue, injectInput, inputPlugin, Keyboard, Mouse, rebindAction, startRecording, startReplay, stopRecording, Touches, updateActions, updateInput, } from './plugin.js';
3
+ export { GAMEPAD_AXES, GAMEPAD_BUTTONS, type GamepadAxis, type GamepadButton, GamepadState, GamepadsState, KeyboardState, MouseState, type PointerInput, PointersState, type Touch, TouchesState, } from './devices.js';
4
+ export { Gesture, GestureActions, type GestureEvent, type GestureKind, type GestureSettings, Gestures, GesturesState, gestureFrames, gesturesPlugin, type SimulatedGesture, simulateGestures, updateGestures, } from './gestures.js';
5
+ export { addActions, describeInput, Gamepads, type InjectedInput, InputContext, type InputContextValue, type InputPluginOptions, InputQueue, injectInput, inputPlugin, Keyboard, Mouse, Pointers, rebindAction, scheduleInput, startRecording, startReplay, stopRecording, Touches, updateActions, updateInput, } from './plugin.js';
package/dist/index.js CHANGED
@@ -11,8 +11,19 @@ import {
11
11
  GamepadsState,
12
12
  KeyboardState,
13
13
  MouseState,
14
+ PointersState,
14
15
  TouchesState
15
16
  } from "./devices.js";
17
+ import {
18
+ Gesture,
19
+ GestureActions,
20
+ Gestures,
21
+ GesturesState,
22
+ gestureFrames,
23
+ gesturesPlugin,
24
+ simulateGestures,
25
+ updateGestures
26
+ } from "./gestures.js";
16
27
  import {
17
28
  addActions,
18
29
  describeInput,
@@ -23,7 +34,9 @@ import {
23
34
  inputPlugin,
24
35
  Keyboard,
25
36
  Mouse,
37
+ Pointers,
26
38
  rebindAction,
39
+ scheduleInput,
27
40
  startRecording,
28
41
  startReplay,
29
42
  stopRecording,
@@ -38,25 +51,36 @@ export {
38
51
  GamepadState,
39
52
  Gamepads,
40
53
  GamepadsState,
54
+ Gesture,
55
+ GestureActions,
56
+ Gestures,
57
+ GesturesState,
41
58
  InputContext,
42
59
  InputQueue,
43
60
  Keyboard,
44
61
  KeyboardState,
45
62
  Mouse,
46
63
  MouseState,
64
+ Pointers,
65
+ PointersState,
47
66
  Touches,
48
67
  TouchesState,
49
68
  actionMapSchema,
50
69
  addActions,
51
70
  defineActions,
52
71
  describeInput,
72
+ gestureFrames,
73
+ gesturesPlugin,
53
74
  injectInput,
54
75
  inputPlugin,
55
76
  parseBinding,
56
77
  rebindAction,
78
+ scheduleInput,
79
+ simulateGestures,
57
80
  startRecording,
58
81
  startReplay,
59
82
  stopRecording,
60
83
  updateActions,
84
+ updateGestures,
61
85
  updateInput
62
86
  };
package/dist/plugin.d.ts CHANGED
@@ -2,11 +2,12 @@ import { type World } from '@aethervtt/shard-core';
2
2
  import type { InputSource, RawInputEvent } from '@aethervtt/shard-platform';
3
3
  import { type Plugin } from '@aethervtt/shard-runtime';
4
4
  import type { ActionMapDef, ActionState } from './actions.js';
5
- import { GamepadsState, KeyboardState, MouseState, TouchesState } from './devices.js';
5
+ import { GamepadsState, KeyboardState, MouseState, PointersState, TouchesState } from './devices.js';
6
6
  export declare const Keyboard: import("@aethervtt/shard-core").ResourceDef<KeyboardState>;
7
7
  export declare const Mouse: import("@aethervtt/shard-core").ResourceDef<MouseState>;
8
8
  export declare const Gamepads: import("@aethervtt/shard-core").ResourceDef<GamepadsState>;
9
9
  export declare const Touches: import("@aethervtt/shard-core").ResourceDef<TouchesState>;
10
+ export declare const Pointers: import("@aethervtt/shard-core").ResourceDef<PointersState>;
10
11
  interface InputQueueState {
11
12
  source: InputSource | undefined;
12
13
  /** Injected events, applied at the start of the next frame. */
@@ -21,6 +22,8 @@ interface InputQueueState {
21
22
  frame: number;
22
23
  length: number;
23
24
  } | undefined;
25
+ /** Simulated input still to come (`simulateInput`), one entry per frame, merged with live input. */
26
+ scheduled: RawInputEvent[][];
24
27
  }
25
28
  export declare const InputQueue: import("@aethervtt/shard-core").ResourceDef<InputQueueState>;
26
29
  export interface InputPluginOptions {
@@ -66,6 +69,11 @@ export type InjectedInput = RawInputEvent | {
66
69
  * input (so it's recorded and replayed too). Actions are named `"<map>.<action>"`.
67
70
  */
68
71
  export declare function injectInput(world: World, input: InjectedInput): void;
72
+ /**
73
+ * Queues raw input over the next frames, one entry per frame (empty entries wait a frame),
74
+ * after anything already queued. Gesture simulation (`simulateGestures`) builds on it.
75
+ */
76
+ export declare function scheduleInput(world: World, frames: readonly (readonly RawInputEvent[])[]): void;
69
77
  export declare function startRecording(world: World): void;
70
78
  /** Stops recording and returns it as JSON lines: `{"f":<frame>,"e":[...events]}` per non-empty frame. */
71
79
  export declare function stopRecording(world: World): string;
@@ -79,6 +87,7 @@ export declare function describeInput(world: World): {
79
87
  keysHeld: string[];
80
88
  gamepads: number;
81
89
  touches: number;
90
+ scheduledFrames: number;
82
91
  context: string;
83
92
  pointerCaptured: boolean;
84
93
  actionMaps: {
package/dist/plugin.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { defineResource, defineSystem, First, ShardError } from "@aethervtt/shard-core";
2
- import { definePlugin, Time } from "@aethervtt/shard-runtime";
2
+ import { definePlugin, FrameDemand, Time } from "@aethervtt/shard-runtime";
3
3
  import {
4
4
  GAMEPAD_AXES,
5
5
  GAMEPAD_BUTTONS,
6
6
  GamepadsState,
7
7
  KeyboardState,
8
8
  MouseState,
9
+ PointersState,
9
10
  TouchesState
10
11
  } from "./devices.js";
11
12
  import * as pluginModule from "./plugin.js";
@@ -21,6 +22,9 @@ const Gamepads = defineResource("input/Gamepads", {
21
22
  const Touches = defineResource("input/Touches", {
22
23
  description: "Active touches."
23
24
  });
25
+ const Pointers = defineResource("input/Pointers", {
26
+ description: "This frame's pointer and wheel events in CSS pixels, for gestures (0060)."
27
+ });
24
28
  const InputQueue = defineResource("input/Queue", {
25
29
  description: "Raw input buffer: live source, injected events, recording, replay."
26
30
  });
@@ -32,6 +36,15 @@ function devices(world) {
32
36
  touches: world.resource(Touches)
33
37
  };
34
38
  }
39
+ function pointers(world, e) {
40
+ if (e.type !== "pointer" && e.type !== "wheel" && e.type !== "focus") return;
41
+ const p = world.resource(Pointers);
42
+ p.events.push(e);
43
+ if (e.type === "pointer") {
44
+ p.position[0] = e.x;
45
+ p.position[1] = e.y;
46
+ }
47
+ }
35
48
  function apply(world, d, e) {
36
49
  switch (e.type) {
37
50
  case "key":
@@ -84,6 +97,8 @@ function apply(world, d, e) {
84
97
  }
85
98
  break;
86
99
  }
100
+ case "pointer":
101
+ break;
87
102
  case "focus":
88
103
  if (!e.focused) {
89
104
  d.keyboard.releaseAll();
@@ -124,6 +139,7 @@ const updateInput = defineSystem({
124
139
  d.mouse.beginFrame();
125
140
  d.gamepads.beginFrame();
126
141
  d.touches.beginFrame();
142
+ world.resource(Pointers).beginFrame();
127
143
  const events = q.scratch;
128
144
  events.length = 0;
129
145
  if (q.replay) {
@@ -136,7 +152,14 @@ const updateInput = defineSystem({
136
152
  }
137
153
  for (const e of q.pending) events.push(e);
138
154
  q.pending.length = 0;
139
- for (const e of events) apply(world, d, e);
155
+ if (q.scheduled.length > 0) {
156
+ for (const e of q.scheduled.shift()) events.push(e);
157
+ world.resource(FrameDemand).set(SIMULATE_DEMAND, q.scheduled.length > 0);
158
+ }
159
+ for (const e of events) {
160
+ apply(world, d, e);
161
+ pointers(world, e);
162
+ }
140
163
  if (q.recording) q.recording.push(events.map((e) => ({ ...e })));
141
164
  }
142
165
  });
@@ -152,6 +175,7 @@ const updateActions = defineSystem({
152
175
  }
153
176
  });
154
177
  function inputPlugin(options = {}) {
178
+ let unsubscribe;
155
179
  return definePlugin({
156
180
  name: "input",
157
181
  provides: [pluginModule],
@@ -163,12 +187,23 @@ function inputPlugin(options = {}) {
163
187
  scratch: [],
164
188
  maps: [],
165
189
  recording: void 0,
166
- replay: void 0
190
+ replay: void 0,
191
+ scheduled: []
167
192
  };
168
- app.insertResource(Keyboard, new KeyboardState()).insertResource(Mouse, new MouseState()).insertResource(Gamepads, new GamepadsState()).insertResource(Touches, new TouchesState()).insertResource(InputQueue, queue);
193
+ app.insertResource(Keyboard, new KeyboardState()).insertResource(Mouse, new MouseState()).insertResource(Gamepads, new GamepadsState()).insertResource(Touches, new TouchesState()).insertResource(Pointers, new PointersState()).insertResource(InputQueue, queue);
169
194
  app.world.initResource(InputContext);
170
195
  for (const map of options.actions ?? []) addActions(app.world, map);
171
196
  app.addSystems(First, updateInput, updateActions.after(updateInput));
197
+ unsubscribe = options.source?.onInput?.(() => {
198
+ if (!app.disposed) app.requestFrame();
199
+ });
200
+ },
201
+ dispose(app) {
202
+ unsubscribe?.();
203
+ unsubscribe = void 0;
204
+ const queue = app.world.tryResource(InputQueue);
205
+ queue?.source?.dispose();
206
+ if (queue) queue.source = void 0;
172
207
  }
173
208
  });
174
209
  }
@@ -206,6 +241,12 @@ function injectInput(world, input) {
206
241
  value: input.value
207
242
  });
208
243
  }
244
+ const SIMULATE_DEMAND = "input/simulate";
245
+ function scheduleInput(world, frames) {
246
+ const q = world.resource(InputQueue);
247
+ for (const frame of frames) q.scheduled.push([...frame]);
248
+ if (q.scheduled.length > 0) world.resource(FrameDemand).hold(SIMULATE_DEMAND);
249
+ }
209
250
  function startRecording(world) {
210
251
  world.resource(InputQueue).recording = [];
211
252
  }
@@ -238,6 +279,7 @@ function describeInput(world) {
238
279
  keysHeld: world.resource(Keyboard).held(),
239
280
  gamepads: world.resource(Gamepads).connected().length,
240
281
  touches: world.resource(Touches).active.size,
282
+ scheduledFrames: q.scheduled.length,
241
283
  context: world.resource(InputContext).active,
242
284
  pointerCaptured: world.resource(Mouse).captured,
243
285
  actionMaps: q.maps.map((m) => ({
@@ -256,12 +298,14 @@ export {
256
298
  InputQueue,
257
299
  Keyboard,
258
300
  Mouse,
301
+ Pointers,
259
302
  Touches,
260
303
  addActions,
261
304
  describeInput,
262
305
  injectInput,
263
306
  inputPlugin,
264
307
  rebindAction,
308
+ scheduleInput,
265
309
  startRecording,
266
310
  startReplay,
267
311
  stopRecording,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aethervtt/shard-input",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -16,9 +16,9 @@
16
16
  "README.md"
17
17
  ],
18
18
  "dependencies": {
19
- "@aethervtt/shard-core": "0.0.1",
20
- "@aethervtt/shard-platform": "0.0.1",
21
- "@aethervtt/shard-runtime": "0.0.1"
19
+ "@aethervtt/shard-core": "0.1.0",
20
+ "@aethervtt/shard-platform": "0.1.0",
21
+ "@aethervtt/shard-runtime": "0.1.0"
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
@@ -30,6 +30,6 @@
30
30
  "provenance": true
31
31
  },
32
32
  "shard": {
33
- "buildSha": "d6c5c6aef4675575c878b02cf2c8f738b4190229"
33
+ "buildSha": "8e14c9896f82a3554b156f1ffdd68f2cc3e7d78a"
34
34
  }
35
35
  }