@aethervtt/shard-input 0.0.1
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/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/actions.d.ts +149 -0
- package/dist/actions.js +443 -0
- package/dist/bindings.d.ts +31 -0
- package/dist/bindings.js +107 -0
- package/dist/devices.d.ts +77 -0
- package/dist/devices.js +137 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +62 -0
- package/dist/plugin.d.ts +93 -0
- package/dist/plugin.js +270 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Patrick Dias
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type JsonSchema, type ResourceDef } from '@aethervtt/shard-core';
|
|
2
|
+
import { type ParsedBinding } from './bindings.js';
|
|
3
|
+
import type { GamepadsState, KeyboardState, MouseState, TouchesState } from './devices.js';
|
|
4
|
+
export type Interaction = {
|
|
5
|
+
hold: number;
|
|
6
|
+
} | {
|
|
7
|
+
tap: number;
|
|
8
|
+
} | {
|
|
9
|
+
multiTap: {
|
|
10
|
+
count: number;
|
|
11
|
+
window: number;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export interface ButtonAction {
|
|
15
|
+
kind: 'button';
|
|
16
|
+
bindings: readonly string[];
|
|
17
|
+
/** Timing rules, in ms. Without one, the action performs on press. */
|
|
18
|
+
interaction?: Interaction;
|
|
19
|
+
}
|
|
20
|
+
export type Axis1dBinding = string | {
|
|
21
|
+
positive: string;
|
|
22
|
+
negative: string;
|
|
23
|
+
};
|
|
24
|
+
export interface Axis1dAction {
|
|
25
|
+
kind: 'axis1d';
|
|
26
|
+
bindings: readonly Axis1dBinding[];
|
|
27
|
+
deadZone?: number;
|
|
28
|
+
}
|
|
29
|
+
export type Axis2dBinding = string | {
|
|
30
|
+
composite: 'wasd' | 'arrows';
|
|
31
|
+
} | {
|
|
32
|
+
up: string;
|
|
33
|
+
down: string;
|
|
34
|
+
left: string;
|
|
35
|
+
right: string;
|
|
36
|
+
};
|
|
37
|
+
export interface Axis2dAction {
|
|
38
|
+
kind: 'axis2d';
|
|
39
|
+
bindings: readonly Axis2dBinding[];
|
|
40
|
+
deadZone?: number;
|
|
41
|
+
}
|
|
42
|
+
export type ActionDef = ButtonAction | Axis1dAction | Axis2dAction;
|
|
43
|
+
export interface Devices {
|
|
44
|
+
keyboard: KeyboardState;
|
|
45
|
+
mouse: MouseState;
|
|
46
|
+
gamepads: GamepadsState;
|
|
47
|
+
touches: TouchesState;
|
|
48
|
+
}
|
|
49
|
+
type Compiled = {
|
|
50
|
+
kind: 'button';
|
|
51
|
+
bindings: ParsedBinding[];
|
|
52
|
+
interaction: Interaction | undefined;
|
|
53
|
+
} | {
|
|
54
|
+
kind: 'axis1d';
|
|
55
|
+
parts: ({
|
|
56
|
+
single: ParsedBinding;
|
|
57
|
+
} | {
|
|
58
|
+
pos: ParsedBinding;
|
|
59
|
+
neg: ParsedBinding;
|
|
60
|
+
})[];
|
|
61
|
+
deadZone: number;
|
|
62
|
+
} | {
|
|
63
|
+
kind: 'axis2d';
|
|
64
|
+
parts: ({
|
|
65
|
+
stick: ParsedBinding;
|
|
66
|
+
} | {
|
|
67
|
+
up: ParsedBinding;
|
|
68
|
+
down: ParsedBinding;
|
|
69
|
+
left: ParsedBinding;
|
|
70
|
+
right: ParsedBinding;
|
|
71
|
+
})[];
|
|
72
|
+
deadZone: number;
|
|
73
|
+
};
|
|
74
|
+
/** Current values of one action map. Action names are type-checked against the definition. */
|
|
75
|
+
export declare class ActionState<K extends string> {
|
|
76
|
+
/** Disabled sets report nothing (use for contexts like gameplay vs menu). */
|
|
77
|
+
enabled: boolean;
|
|
78
|
+
readonly name: string;
|
|
79
|
+
/**
|
|
80
|
+
* The input context the map listens in: it reports nothing while another is active
|
|
81
|
+
* (`InputContext`; UI makes it `ui` while a node has focus). `any` always listens.
|
|
82
|
+
*/
|
|
83
|
+
readonly context: string;
|
|
84
|
+
/** Its context isn't the active one this frame. */
|
|
85
|
+
private suspended;
|
|
86
|
+
private readonly compiled;
|
|
87
|
+
private readonly runtime;
|
|
88
|
+
/** Pressed state injected by synthetic `action` events, by action name. */
|
|
89
|
+
private readonly injected;
|
|
90
|
+
/** The bindings as defined in code. */
|
|
91
|
+
private readonly authored;
|
|
92
|
+
/** Bindings changed at runtime (a player's rebinding), by action. */
|
|
93
|
+
private readonly rebound;
|
|
94
|
+
/** Increments on every rebinding, so settings can tell the map changed. */
|
|
95
|
+
version: number;
|
|
96
|
+
constructor(name: string, compiled: Map<K, Compiled>, context?: string, authored?: Readonly<Record<K, ActionDef>>);
|
|
97
|
+
/** The action's bindings now: a rebinding if there is one, else the authored ones. */
|
|
98
|
+
bindings(action: K): ActionDef['bindings'];
|
|
99
|
+
/**
|
|
100
|
+
* Replaces the action's bindings (a player's rebinding): `rebind('jump', ['Key:KeyJ'])`. Checked
|
|
101
|
+
* like the authored ones, so a typo throws `input/unknown-binding`. `undefined` restores the
|
|
102
|
+
* authored bindings.
|
|
103
|
+
*/
|
|
104
|
+
rebind(action: K, bindings: ActionDef['bindings'] | undefined): void;
|
|
105
|
+
/** Every rebinding, by action (empty when the map is as authored). */
|
|
106
|
+
rebindings(): Partial<Record<K, ActionDef['bindings']>>;
|
|
107
|
+
/** The action names. */
|
|
108
|
+
actions(): K[];
|
|
109
|
+
pressed(action: K): boolean;
|
|
110
|
+
justPressed(action: K): boolean;
|
|
111
|
+
justReleased(action: K): boolean;
|
|
112
|
+
/** The interaction began this frame (press for hold/tap). */
|
|
113
|
+
started(action: K): boolean;
|
|
114
|
+
/** The action fired this frame: press, completed hold, tap, or multi-tap. */
|
|
115
|
+
performed(action: K): boolean;
|
|
116
|
+
/** The interaction was abandoned this frame (hold released early, tap held too long). */
|
|
117
|
+
canceled(action: K): boolean;
|
|
118
|
+
/** 0..1 progress of a hold interaction, for UI. */
|
|
119
|
+
holdProgress(action: K): number;
|
|
120
|
+
/** Button strength (0..1) or axis1d value (-1..1, or raw wheel units). */
|
|
121
|
+
value(action: K): number;
|
|
122
|
+
/** axis2d value, length ≤ 1. The array is reused; copy it to keep it. */
|
|
123
|
+
axis2d(action: K): readonly [number, number];
|
|
124
|
+
/** Enabled, and listening in the active context. */
|
|
125
|
+
get live(): boolean;
|
|
126
|
+
/** @internal */
|
|
127
|
+
inject(action: string, pressed: boolean, value?: number): void;
|
|
128
|
+
/** @internal Recompute every action from device state. `now` is in ms. */
|
|
129
|
+
update(devices: Devices, now: number, context?: string): void;
|
|
130
|
+
/** Current values, for describe(). */
|
|
131
|
+
snapshot(): Record<string, unknown>;
|
|
132
|
+
private rt;
|
|
133
|
+
}
|
|
134
|
+
export interface ActionMapDef<K extends string> {
|
|
135
|
+
readonly kind: 'actions';
|
|
136
|
+
readonly name: string;
|
|
137
|
+
/** The input context it listens in (default `game`; `ui`, or `any` for always). */
|
|
138
|
+
readonly context: string;
|
|
139
|
+
readonly definition: Readonly<Record<K, ActionDef>>;
|
|
140
|
+
readonly resource: ResourceDef<ActionState<K>>;
|
|
141
|
+
create(): ActionState<K>;
|
|
142
|
+
}
|
|
143
|
+
/** An action map. Validates every binding now, so typos fail at startup, not at first press. */
|
|
144
|
+
export declare function defineActions<const A extends Record<string, ActionDef>>(name: string, definition: A, options?: {
|
|
145
|
+
context?: string;
|
|
146
|
+
}): ActionMapDef<keyof A & string>;
|
|
147
|
+
/** JSON Schema for action maps as data files. */
|
|
148
|
+
export declare const actionMapSchema: JsonSchema;
|
|
149
|
+
export {};
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineResource,
|
|
3
|
+
ShardError
|
|
4
|
+
} from "@aethervtt/shard-core";
|
|
5
|
+
import { parseBinding } from "./bindings.js";
|
|
6
|
+
function newRuntime() {
|
|
7
|
+
return {
|
|
8
|
+
pressed: false,
|
|
9
|
+
justPressed: false,
|
|
10
|
+
justReleased: false,
|
|
11
|
+
started: false,
|
|
12
|
+
performed: false,
|
|
13
|
+
canceled: false,
|
|
14
|
+
holdProgress: 0,
|
|
15
|
+
downAt: 0,
|
|
16
|
+
heldPerformed: false,
|
|
17
|
+
taps: 0,
|
|
18
|
+
lastReleaseAt: -Infinity,
|
|
19
|
+
value: 0,
|
|
20
|
+
axis: [0, 0]
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const COMPOSITES = {
|
|
24
|
+
wasd: { up: "Key:KeyW", down: "Key:KeyS", left: "Key:KeyA", right: "Key:KeyD" },
|
|
25
|
+
arrows: {
|
|
26
|
+
up: "Key:ArrowUp",
|
|
27
|
+
down: "Key:ArrowDown",
|
|
28
|
+
left: "Key:ArrowLeft",
|
|
29
|
+
right: "Key:ArrowRight"
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
class ActionState {
|
|
33
|
+
/** Disabled sets report nothing (use for contexts like gameplay vs menu). */
|
|
34
|
+
enabled = true;
|
|
35
|
+
name;
|
|
36
|
+
/**
|
|
37
|
+
* The input context the map listens in: it reports nothing while another is active
|
|
38
|
+
* (`InputContext`; UI makes it `ui` while a node has focus). `any` always listens.
|
|
39
|
+
*/
|
|
40
|
+
context;
|
|
41
|
+
/** Its context isn't the active one this frame. */
|
|
42
|
+
suspended = false;
|
|
43
|
+
compiled;
|
|
44
|
+
runtime = /* @__PURE__ */ new Map();
|
|
45
|
+
/** Pressed state injected by synthetic `action` events, by action name. */
|
|
46
|
+
injected = /* @__PURE__ */ new Map();
|
|
47
|
+
/** The bindings as defined in code. */
|
|
48
|
+
authored;
|
|
49
|
+
/** Bindings changed at runtime (a player's rebinding), by action. */
|
|
50
|
+
rebound = /* @__PURE__ */ new Map();
|
|
51
|
+
/** Increments on every rebinding, so settings can tell the map changed. */
|
|
52
|
+
version = 0;
|
|
53
|
+
constructor(name, compiled, context = "game", authored = {}) {
|
|
54
|
+
this.name = name;
|
|
55
|
+
this.context = context;
|
|
56
|
+
this.compiled = compiled;
|
|
57
|
+
this.authored = authored;
|
|
58
|
+
for (const key of compiled.keys()) this.runtime.set(key, newRuntime());
|
|
59
|
+
}
|
|
60
|
+
/** The action's bindings now: a rebinding if there is one, else the authored ones. */
|
|
61
|
+
bindings(action) {
|
|
62
|
+
this.rt(action);
|
|
63
|
+
return this.rebound.get(action) ?? this.authored[action].bindings;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Replaces the action's bindings (a player's rebinding): `rebind('jump', ['Key:KeyJ'])`. Checked
|
|
67
|
+
* like the authored ones, so a typo throws `input/unknown-binding`. `undefined` restores the
|
|
68
|
+
* authored bindings.
|
|
69
|
+
*/
|
|
70
|
+
rebind(action, bindings) {
|
|
71
|
+
this.rt(action);
|
|
72
|
+
const authored = this.authored[action];
|
|
73
|
+
if (bindings === void 0) {
|
|
74
|
+
if (!this.rebound.delete(action)) return;
|
|
75
|
+
this.compiled.set(action, compile(action, authored));
|
|
76
|
+
} else {
|
|
77
|
+
const def = { ...authored, bindings };
|
|
78
|
+
this.compiled.set(action, compile(action, def));
|
|
79
|
+
this.rebound.set(action, bindings);
|
|
80
|
+
}
|
|
81
|
+
this.version++;
|
|
82
|
+
}
|
|
83
|
+
/** Every rebinding, by action (empty when the map is as authored). */
|
|
84
|
+
rebindings() {
|
|
85
|
+
const out = {};
|
|
86
|
+
for (const [action, bindings] of this.rebound) out[action] = bindings;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/** The action names. */
|
|
90
|
+
actions() {
|
|
91
|
+
return [...this.compiled.keys()];
|
|
92
|
+
}
|
|
93
|
+
pressed(action) {
|
|
94
|
+
return this.live && this.rt(action).pressed;
|
|
95
|
+
}
|
|
96
|
+
justPressed(action) {
|
|
97
|
+
return this.live && this.rt(action).justPressed;
|
|
98
|
+
}
|
|
99
|
+
justReleased(action) {
|
|
100
|
+
return this.live && this.rt(action).justReleased;
|
|
101
|
+
}
|
|
102
|
+
/** The interaction began this frame (press for hold/tap). */
|
|
103
|
+
started(action) {
|
|
104
|
+
return this.live && this.rt(action).started;
|
|
105
|
+
}
|
|
106
|
+
/** The action fired this frame: press, completed hold, tap, or multi-tap. */
|
|
107
|
+
performed(action) {
|
|
108
|
+
return this.live && this.rt(action).performed;
|
|
109
|
+
}
|
|
110
|
+
/** The interaction was abandoned this frame (hold released early, tap held too long). */
|
|
111
|
+
canceled(action) {
|
|
112
|
+
return this.live && this.rt(action).canceled;
|
|
113
|
+
}
|
|
114
|
+
/** 0..1 progress of a hold interaction, for UI. */
|
|
115
|
+
holdProgress(action) {
|
|
116
|
+
return this.live ? this.rt(action).holdProgress : 0;
|
|
117
|
+
}
|
|
118
|
+
/** Button strength (0..1) or axis1d value (-1..1, or raw wheel units). */
|
|
119
|
+
value(action) {
|
|
120
|
+
return this.live ? this.rt(action).value : 0;
|
|
121
|
+
}
|
|
122
|
+
/** axis2d value, length ≤ 1. The array is reused; copy it to keep it. */
|
|
123
|
+
axis2d(action) {
|
|
124
|
+
return this.live ? this.rt(action).axis : ZERO2;
|
|
125
|
+
}
|
|
126
|
+
/** Enabled, and listening in the active context. */
|
|
127
|
+
get live() {
|
|
128
|
+
return this.enabled && !this.suspended;
|
|
129
|
+
}
|
|
130
|
+
/** @internal */
|
|
131
|
+
inject(action, pressed, value = pressed ? 1 : 0) {
|
|
132
|
+
if (!this.compiled.has(action)) {
|
|
133
|
+
throw new ShardError("input/unknown-action", `"${this.name}" has no action "${action}"`, {
|
|
134
|
+
hint: `Actions: ${[...this.compiled.keys()].join(", ")}.`
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
this.injected.set(action, { pressed, value });
|
|
138
|
+
}
|
|
139
|
+
/** @internal Recompute every action from device state. `now` is in ms. */
|
|
140
|
+
update(devices, now, context = "game") {
|
|
141
|
+
this.suspended = this.context !== "any" && this.context !== context;
|
|
142
|
+
for (const [name, def] of this.compiled) {
|
|
143
|
+
const r = this.rt(name);
|
|
144
|
+
const injected = this.injected.get(name);
|
|
145
|
+
if (def.kind === "button") {
|
|
146
|
+
let value = 0;
|
|
147
|
+
for (const b of def.bindings) value = Math.max(value, readButton(devices, b));
|
|
148
|
+
if (injected) value = Math.max(value, injected.value);
|
|
149
|
+
updateButton(r, value, def.interaction, now);
|
|
150
|
+
} else if (def.kind === "axis1d") {
|
|
151
|
+
let best = 0;
|
|
152
|
+
for (const part of def.parts) {
|
|
153
|
+
const v = "single" in part ? readAxis(devices, part.single) : readButton(devices, part.pos) - readButton(devices, part.neg);
|
|
154
|
+
if (Math.abs(v) > Math.abs(best)) best = v;
|
|
155
|
+
}
|
|
156
|
+
if (injected) best = injected.value;
|
|
157
|
+
r.value = Math.abs(best) < def.deadZone ? 0 : best;
|
|
158
|
+
r.pressed = r.value !== 0;
|
|
159
|
+
} else {
|
|
160
|
+
let bx = 0;
|
|
161
|
+
let by = 0;
|
|
162
|
+
for (const part of def.parts) {
|
|
163
|
+
let x;
|
|
164
|
+
let y;
|
|
165
|
+
if ("stick" in part) {
|
|
166
|
+
const stick = part.stick.stick;
|
|
167
|
+
x = readStick(devices, stick, 0);
|
|
168
|
+
y = readStick(devices, stick, 1);
|
|
169
|
+
} else {
|
|
170
|
+
x = readButton(devices, part.right) - readButton(devices, part.left);
|
|
171
|
+
y = readButton(devices, part.up) - readButton(devices, part.down);
|
|
172
|
+
}
|
|
173
|
+
if (x * x + y * y > bx * bx + by * by) {
|
|
174
|
+
bx = x;
|
|
175
|
+
by = y;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
let len = Math.sqrt(bx * bx + by * by);
|
|
179
|
+
if (len > 1) {
|
|
180
|
+
bx /= len;
|
|
181
|
+
by /= len;
|
|
182
|
+
len = 1;
|
|
183
|
+
}
|
|
184
|
+
if (len < def.deadZone) {
|
|
185
|
+
bx = 0;
|
|
186
|
+
by = 0;
|
|
187
|
+
} else if (def.deadZone > 0) {
|
|
188
|
+
const scale = (len - def.deadZone) / (1 - def.deadZone) / len;
|
|
189
|
+
bx *= scale;
|
|
190
|
+
by *= scale;
|
|
191
|
+
}
|
|
192
|
+
r.axis[0] = bx;
|
|
193
|
+
r.axis[1] = by;
|
|
194
|
+
r.pressed = bx !== 0 || by !== 0;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** Current values, for describe(). */
|
|
199
|
+
snapshot() {
|
|
200
|
+
const out = {};
|
|
201
|
+
for (const [name, def] of this.compiled) {
|
|
202
|
+
const r = this.rt(name);
|
|
203
|
+
out[name] = def.kind === "axis2d" ? [...r.axis] : def.kind === "axis1d" ? r.value : r.pressed;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
rt(action) {
|
|
208
|
+
const r = this.runtime.get(action);
|
|
209
|
+
if (!r) {
|
|
210
|
+
throw new ShardError("input/unknown-action", `"${this.name}" has no action "${action}"`, {
|
|
211
|
+
hint: `Actions: ${[...this.compiled.keys()].join(", ")}.`
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return r;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const ZERO2 = [0, 0];
|
|
218
|
+
function updateButton(r, value, interaction, now) {
|
|
219
|
+
const wasPressed = r.pressed;
|
|
220
|
+
r.value = value;
|
|
221
|
+
r.pressed = value > 0.5;
|
|
222
|
+
r.justPressed = r.pressed && !wasPressed;
|
|
223
|
+
r.justReleased = !r.pressed && wasPressed;
|
|
224
|
+
r.started = false;
|
|
225
|
+
r.performed = false;
|
|
226
|
+
r.canceled = false;
|
|
227
|
+
if (!interaction) {
|
|
228
|
+
r.started = r.justPressed;
|
|
229
|
+
r.performed = r.justPressed;
|
|
230
|
+
r.holdProgress = r.pressed ? 1 : 0;
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (r.justPressed) {
|
|
234
|
+
r.downAt = now;
|
|
235
|
+
r.heldPerformed = false;
|
|
236
|
+
r.started = true;
|
|
237
|
+
}
|
|
238
|
+
const held = now - r.downAt;
|
|
239
|
+
if ("hold" in interaction) {
|
|
240
|
+
r.holdProgress = r.pressed ? Math.min(1, held / interaction.hold) : 0;
|
|
241
|
+
if (r.pressed && !r.heldPerformed && held >= interaction.hold) {
|
|
242
|
+
r.performed = true;
|
|
243
|
+
r.heldPerformed = true;
|
|
244
|
+
}
|
|
245
|
+
if (r.justReleased && !r.heldPerformed) r.canceled = true;
|
|
246
|
+
} else if ("tap" in interaction) {
|
|
247
|
+
if (r.justReleased) {
|
|
248
|
+
if (held < interaction.tap) r.performed = true;
|
|
249
|
+
else r.canceled = true;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
const { count, window } = interaction.multiTap;
|
|
253
|
+
if (r.justPressed && now - r.lastReleaseAt > window) r.taps = 0;
|
|
254
|
+
if (r.justReleased) {
|
|
255
|
+
r.taps++;
|
|
256
|
+
r.lastReleaseAt = now;
|
|
257
|
+
if (r.taps >= count) {
|
|
258
|
+
r.performed = true;
|
|
259
|
+
r.taps = 0;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function readButton(d, b) {
|
|
265
|
+
switch (b.device) {
|
|
266
|
+
case "key":
|
|
267
|
+
return d.keyboard.pressed(b.code) ? 1 : 0;
|
|
268
|
+
case "mouse-button":
|
|
269
|
+
return !d.mouse.captured && d.mouse.pressed(b.button) ? 1 : 0;
|
|
270
|
+
case "gamepad-button": {
|
|
271
|
+
let v = 0;
|
|
272
|
+
for (const pad of d.gamepads.connected()) v = Math.max(v, pad.button(b.button));
|
|
273
|
+
return v;
|
|
274
|
+
}
|
|
275
|
+
case "touch":
|
|
276
|
+
return d.touches.active.size > d.touches.captured.size ? 1 : 0;
|
|
277
|
+
default:
|
|
278
|
+
return Math.min(1, Math.abs(readAxis(d, b)));
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function readAxis(d, b) {
|
|
282
|
+
switch (b.device) {
|
|
283
|
+
case "mouse-axis":
|
|
284
|
+
if (d.mouse.captured && (b.axis === "WheelX" || b.axis === "WheelY")) return 0;
|
|
285
|
+
return b.axis === "WheelX" ? d.mouse.wheel[0] : b.axis === "WheelY" ? d.mouse.wheel[1] : b.axis === "DeltaX" ? d.mouse.delta[0] : d.mouse.delta[1];
|
|
286
|
+
case "gamepad-axis": {
|
|
287
|
+
let v = 0;
|
|
288
|
+
for (const pad of d.gamepads.connected()) {
|
|
289
|
+
const a = pad.axis(b.axis);
|
|
290
|
+
if (Math.abs(a) > Math.abs(v)) v = a;
|
|
291
|
+
}
|
|
292
|
+
return v;
|
|
293
|
+
}
|
|
294
|
+
default:
|
|
295
|
+
return readButton(d, b);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function readStick(d, stick, component) {
|
|
299
|
+
const axis = stick === "LeftStick" ? component === 0 ? "LeftStickX" : "LeftStickY" : component === 0 ? "RightStickX" : "RightStickY";
|
|
300
|
+
let v = 0;
|
|
301
|
+
for (const pad of d.gamepads.connected()) {
|
|
302
|
+
const a = pad.axis(axis);
|
|
303
|
+
if (Math.abs(a) > Math.abs(v)) v = a;
|
|
304
|
+
}
|
|
305
|
+
return v;
|
|
306
|
+
}
|
|
307
|
+
function compile(name, def) {
|
|
308
|
+
const path = (binding) => {
|
|
309
|
+
try {
|
|
310
|
+
return parseBinding(binding);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
if (err instanceof ShardError) {
|
|
313
|
+
throw new ShardError(err.code, `${err.message} in action "${name}"`, {
|
|
314
|
+
hint: err.hint,
|
|
315
|
+
path: `${name}/${binding}`
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
if (def.kind === "button") {
|
|
322
|
+
return { kind: "button", bindings: def.bindings.map(path), interaction: def.interaction };
|
|
323
|
+
}
|
|
324
|
+
if (def.kind === "axis1d") {
|
|
325
|
+
return {
|
|
326
|
+
kind: "axis1d",
|
|
327
|
+
deadZone: def.deadZone ?? 0,
|
|
328
|
+
parts: def.bindings.map(
|
|
329
|
+
(b) => typeof b === "string" ? { single: path(b) } : { pos: path(b.positive), neg: path(b.negative) }
|
|
330
|
+
)
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
kind: "axis2d",
|
|
335
|
+
deadZone: def.deadZone ?? 0,
|
|
336
|
+
parts: def.bindings.map((b) => {
|
|
337
|
+
if (typeof b === "string") {
|
|
338
|
+
const parsed = path(b);
|
|
339
|
+
if (parsed.device !== "gamepad-stick") {
|
|
340
|
+
throw new ShardError(
|
|
341
|
+
"input/unknown-binding",
|
|
342
|
+
`axis2d binding "${b}" must be a stick or composite`,
|
|
343
|
+
{
|
|
344
|
+
path: `${name}/${b}`,
|
|
345
|
+
hint: 'Use Gamepad:LeftStick, Gamepad:RightStick, { composite: "wasd" | "arrows" }, or { up, down, left, right }.'
|
|
346
|
+
}
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return { stick: parsed };
|
|
350
|
+
}
|
|
351
|
+
const keys = "composite" in b ? COMPOSITES[b.composite] : b;
|
|
352
|
+
return {
|
|
353
|
+
up: path(keys.up),
|
|
354
|
+
down: path(keys.down),
|
|
355
|
+
left: path(keys.left),
|
|
356
|
+
right: path(keys.right)
|
|
357
|
+
};
|
|
358
|
+
})
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function defineActions(name, definition, options = {}) {
|
|
362
|
+
const context = options.context ?? "game";
|
|
363
|
+
const compiled = /* @__PURE__ */ new Map();
|
|
364
|
+
for (const [action, def] of Object.entries(definition))
|
|
365
|
+
compiled.set(action, compile(action, def));
|
|
366
|
+
const resource = defineResource(name, {
|
|
367
|
+
description: `Action map ${name}`,
|
|
368
|
+
reload: "replace"
|
|
369
|
+
});
|
|
370
|
+
return {
|
|
371
|
+
kind: "actions",
|
|
372
|
+
name,
|
|
373
|
+
context,
|
|
374
|
+
definition,
|
|
375
|
+
resource,
|
|
376
|
+
create: () => new ActionState(name, new Map(compiled), context, definition)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const actionMapSchema = {
|
|
380
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
381
|
+
title: "Shard action map",
|
|
382
|
+
type: "object",
|
|
383
|
+
additionalProperties: {
|
|
384
|
+
oneOf: [
|
|
385
|
+
{
|
|
386
|
+
type: "object",
|
|
387
|
+
properties: {
|
|
388
|
+
kind: { const: "button" },
|
|
389
|
+
bindings: { type: "array", items: { type: "string" } },
|
|
390
|
+
interaction: {
|
|
391
|
+
oneOf: [
|
|
392
|
+
{
|
|
393
|
+
type: "object",
|
|
394
|
+
properties: { hold: { type: "number", minimum: 0 } },
|
|
395
|
+
required: ["hold"],
|
|
396
|
+
additionalProperties: false
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
type: "object",
|
|
400
|
+
properties: { tap: { type: "number", minimum: 0 } },
|
|
401
|
+
required: ["tap"],
|
|
402
|
+
additionalProperties: false
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
type: "object",
|
|
406
|
+
properties: {
|
|
407
|
+
multiTap: {
|
|
408
|
+
type: "object",
|
|
409
|
+
properties: {
|
|
410
|
+
count: { type: "integer", minimum: 2 },
|
|
411
|
+
window: { type: "number", minimum: 0 }
|
|
412
|
+
},
|
|
413
|
+
required: ["count", "window"]
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
required: ["multiTap"],
|
|
417
|
+
additionalProperties: false
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
required: ["kind", "bindings"],
|
|
423
|
+
additionalProperties: false
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
type: "object",
|
|
427
|
+
properties: {
|
|
428
|
+
kind: { enum: ["axis1d", "axis2d"] },
|
|
429
|
+
bindings: { type: "array" },
|
|
430
|
+
deadZone: { type: "number", minimum: 0, maximum: 1 }
|
|
431
|
+
},
|
|
432
|
+
required: ["kind", "bindings"],
|
|
433
|
+
additionalProperties: false
|
|
434
|
+
}
|
|
435
|
+
]
|
|
436
|
+
},
|
|
437
|
+
description: "Named actions with bindings. Binding strings: Key:<KeyboardEvent.code>, Mouse:<Left|Middle|Right|Back|Forward|WheelX|WheelY|DeltaX|DeltaY>, Gamepad:<South|East|West|North|\u2026|LeftStick|RightStick>, Touch:Any."
|
|
438
|
+
};
|
|
439
|
+
export {
|
|
440
|
+
ActionState,
|
|
441
|
+
actionMapSchema,
|
|
442
|
+
defineActions
|
|
443
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { MouseButton } from '@aethervtt/shard-platform';
|
|
2
|
+
import { type GamepadAxis, type GamepadButton } from './devices.js';
|
|
3
|
+
/**
|
|
4
|
+
* Binding grammar:
|
|
5
|
+
* Key:<code> KeyboardEvent.code, e.g. Key:Space, Key:KeyW, Key:ArrowUp
|
|
6
|
+
* Mouse:<Left|Middle|Right|Back|Forward|WheelX|WheelY|DeltaX|DeltaY>
|
|
7
|
+
* Gamepad:<button|axis|LeftStick|RightStick> any connected gamepad
|
|
8
|
+
* Touch:Any any active touch
|
|
9
|
+
*/
|
|
10
|
+
export type ParsedBinding = {
|
|
11
|
+
device: 'key';
|
|
12
|
+
code: string;
|
|
13
|
+
} | {
|
|
14
|
+
device: 'mouse-button';
|
|
15
|
+
button: MouseButton;
|
|
16
|
+
} | {
|
|
17
|
+
device: 'mouse-axis';
|
|
18
|
+
axis: 'WheelX' | 'WheelY' | 'DeltaX' | 'DeltaY';
|
|
19
|
+
} | {
|
|
20
|
+
device: 'gamepad-button';
|
|
21
|
+
button: GamepadButton;
|
|
22
|
+
} | {
|
|
23
|
+
device: 'gamepad-axis';
|
|
24
|
+
axis: GamepadAxis;
|
|
25
|
+
} | {
|
|
26
|
+
device: 'gamepad-stick';
|
|
27
|
+
stick: 'LeftStick' | 'RightStick';
|
|
28
|
+
} | {
|
|
29
|
+
device: 'touch';
|
|
30
|
+
};
|
|
31
|
+
export declare function parseBinding(binding: string): ParsedBinding;
|