react-native-earl-gamepad 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/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/GamepadBridge.d.ts +13 -0
- package/dist/GamepadBridge.js +127 -0
- package/dist/GamepadDebug.d.ts +6 -0
- package/dist/GamepadDebug.js +279 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +27 -0
- package/dist/types.d.ts +26 -0
- package/dist/types.js +2 -0
- package/dist/useGamepad.d.ts +18 -0
- package/dist/useGamepad.js +53 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Rin
|
|
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,116 @@
|
|
|
1
|
+
# react-native-earl-gamepad
|
|
2
|
+
|
|
3
|
+
Lightweight, WebView-based Gamepad bridge for React Native that surfaces all common buttons, sticks, d-pad, and connect/disconnect events.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Hidden WebView polling `navigator.getGamepads()` (index 0) and emitting button/axis/status events.
|
|
8
|
+
- `GamepadBridge` component, `useGamepad` hook, and `GamepadDebug` visual tester.
|
|
9
|
+
- Deadzone handling for sticks (default 0.15) and auto-clear on disconnect.
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
npm install react-native-earl-gamepad react-native-webview
|
|
15
|
+
# or
|
|
16
|
+
yarn add react-native-earl-gamepad react-native-webview
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { GamepadBridge } from 'react-native-earl-gamepad';
|
|
23
|
+
|
|
24
|
+
export function Controls() {
|
|
25
|
+
return (
|
|
26
|
+
<GamepadBridge
|
|
27
|
+
enabled
|
|
28
|
+
onButton={(e) => console.log('button', e.button, e.pressed, e.value)}
|
|
29
|
+
onAxis={(e) => console.log('axis', e.axis, e.value)}
|
|
30
|
+
onDpad={(e) => console.log('dpad', e.key, e.pressed)}
|
|
31
|
+
onStatus={(s) => console.log('status', s.state)}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Using the hook
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { useGamepad } from 'react-native-earl-gamepad';
|
|
41
|
+
|
|
42
|
+
export function HUD() {
|
|
43
|
+
const { pressedButtons, axes, isPressed, bridge } = useGamepad({ enabled: true });
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<>
|
|
47
|
+
{bridge}
|
|
48
|
+
<Text>Pressed: {Array.from(pressedButtons).join(', ') || 'none'}</Text>
|
|
49
|
+
<Text>
|
|
50
|
+
Left stick: x {axes.leftX?.toFixed(2)} / y {axes.leftY?.toFixed(2)}
|
|
51
|
+
</Text>
|
|
52
|
+
<Text>A held? {isPressed('a') ? 'yes' : 'no'}</Text>
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Visual debugger
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { GamepadDebug } from 'react-native-earl-gamepad';
|
|
62
|
+
|
|
63
|
+
export function DebugScreen() {
|
|
64
|
+
return <GamepadDebug />;
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`GamepadDebug` renders a controller diagram that lights up buttons, shows stick offsets, and lists pressed/axes values.
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `GamepadBridge` props
|
|
73
|
+
|
|
74
|
+
- `enabled?: boolean` — mount/unmount the hidden WebView. Default `true`.
|
|
75
|
+
- `axisThreshold?: number` — deadzone for axes. Default `0.15`.
|
|
76
|
+
- `onButton?: (event)` — `{ type:'button', button, index, pressed, value }`.
|
|
77
|
+
- `onAxis?: (event)` — `{ type:'axis', axis, index, value }`.
|
|
78
|
+
- `onDpad?: (event)` — `{ type:'dpad', key, pressed }` convenience mapped from buttons 12–15.
|
|
79
|
+
- `onStatus?: (event)` — `{ type:'status', state:'connected'|'disconnected' }`.
|
|
80
|
+
- `style?: StyleProp<ViewStyle>` — optional override; default is a 1×1 transparent view.
|
|
81
|
+
|
|
82
|
+
### `useGamepad` return
|
|
83
|
+
|
|
84
|
+
- `pressedButtons: Set<GamepadButtonName>` — current pressed buttons (named or `button-N`).
|
|
85
|
+
- `axes: Partial<Record<StickAxisName, number>>` — stick/axis values with deadzone applied.
|
|
86
|
+
- `isPressed(key)` — helper to query a button.
|
|
87
|
+
- `bridge: JSX.Element | null` — render once in your tree to enable polling.
|
|
88
|
+
|
|
89
|
+
### `GamepadDebug`
|
|
90
|
+
|
|
91
|
+
Drop-in component to visualize inputs. Accepts the same `enabled` and `axisThreshold` props as the hook/bridge.
|
|
92
|
+
|
|
93
|
+
### Types
|
|
94
|
+
|
|
95
|
+
- `GamepadButtonName`: `a | b | x | y | lb | rb | lt | rt | back | start | ls | rs | dpadUp | dpadDown | dpadLeft | dpadRight | home | button-N`
|
|
96
|
+
- `StickAxisName`: `leftX | leftY | rightX | rightY | axis-N`
|
|
97
|
+
|
|
98
|
+
## Notes
|
|
99
|
+
|
|
100
|
+
- Reads only the first connected pad (`navigator.getGamepads()[0]`).
|
|
101
|
+
- D-pad events are emitted from buttons 12–15; sticks pass through as axes with deadzone applied.
|
|
102
|
+
- On disconnect, all pressed states are cleared and release events are emitted.
|
|
103
|
+
- The WebView must stay mounted; avoid remounting each render to prevent losing state.
|
|
104
|
+
|
|
105
|
+
## Development
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
npm install
|
|
109
|
+
npm run build
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Outputs to `dist/` with type declarations.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { StyleProp, ViewStyle } from 'react-native';
|
|
2
|
+
import type { AxisEvent, ButtonEvent, DpadEvent, StatusEvent } from './types';
|
|
3
|
+
type Props = {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
axisThreshold?: number;
|
|
6
|
+
onDpad?: (event: DpadEvent) => void;
|
|
7
|
+
onButton?: (event: ButtonEvent) => void;
|
|
8
|
+
onAxis?: (event: AxisEvent) => void;
|
|
9
|
+
onStatus?: (event: StatusEvent) => void;
|
|
10
|
+
style?: StyleProp<ViewStyle>;
|
|
11
|
+
};
|
|
12
|
+
export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = GamepadBridge;
|
|
7
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
|
+
const react_1 = require("react");
|
|
9
|
+
const react_native_webview_1 = __importDefault(require("react-native-webview"));
|
|
10
|
+
// Minimal HTML bridge that polls navigator.getGamepads and posts button/axis changes.
|
|
11
|
+
const buildBridgeHtml = (axisThreshold) => `
|
|
12
|
+
<!doctype html>
|
|
13
|
+
<html><head><meta name="viewport" content="width=device-width,initial-scale=1" /></head>
|
|
14
|
+
<body style="margin:0;padding:0;background:#000">
|
|
15
|
+
<script>
|
|
16
|
+
(function(){
|
|
17
|
+
const deadzone = ${axisThreshold.toFixed(2)};
|
|
18
|
+
const send = (data) => window.ReactNativeWebView.postMessage(JSON.stringify(data));
|
|
19
|
+
const buttonNames = ['a','b','x','y','lb','rb','lt','rt','back','start','ls','rs','dpadUp','dpadDown','dpadLeft','dpadRight','home'];
|
|
20
|
+
const axisNames = ['leftX','leftY','rightX','rightY'];
|
|
21
|
+
let prevButtons = [];
|
|
22
|
+
let prevAxes = [];
|
|
23
|
+
|
|
24
|
+
function poll(){
|
|
25
|
+
const pads = (navigator.getGamepads && navigator.getGamepads()) || [];
|
|
26
|
+
const gp = pads[0];
|
|
27
|
+
if (gp) {
|
|
28
|
+
// Buttons
|
|
29
|
+
gp.buttons?.forEach((btn, index) => {
|
|
30
|
+
const name = buttonNames[index] || ('button-' + index);
|
|
31
|
+
const prevBtn = prevButtons[index] || { pressed:false, value:0 };
|
|
32
|
+
const changed = prevBtn.pressed !== btn.pressed || Math.abs(prevBtn.value - btn.value) > 0.01;
|
|
33
|
+
if (changed) {
|
|
34
|
+
send({ type:'button', button:name, index, pressed: !!btn.pressed, value: btn.value ?? 0 });
|
|
35
|
+
// Also surface d-pad convenience events for the standard indices
|
|
36
|
+
if (index === 12) send({ type:'dpad', key:'up', pressed: !!btn.pressed });
|
|
37
|
+
if (index === 13) send({ type:'dpad', key:'down', pressed: !!btn.pressed });
|
|
38
|
+
if (index === 14) send({ type:'dpad', key:'left', pressed: !!btn.pressed });
|
|
39
|
+
if (index === 15) send({ type:'dpad', key:'right', pressed: !!btn.pressed });
|
|
40
|
+
}
|
|
41
|
+
prevButtons[index] = { pressed: !!btn.pressed, value: btn.value ?? 0 };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Axes
|
|
45
|
+
gp.axes?.forEach((raw, index) => {
|
|
46
|
+
const name = axisNames[index] || ('axis-' + index);
|
|
47
|
+
const value = Math.abs(raw) < deadzone ? 0 : raw;
|
|
48
|
+
const prevVal = prevAxes[index] ?? 0;
|
|
49
|
+
if (Math.abs(prevVal - value) > 0.01) {
|
|
50
|
+
send({ type:'axis', axis:name, index, value });
|
|
51
|
+
}
|
|
52
|
+
prevAxes[index] = value;
|
|
53
|
+
});
|
|
54
|
+
} else {
|
|
55
|
+
if (prevButtons.length) {
|
|
56
|
+
prevButtons.forEach((btn, index) => {
|
|
57
|
+
if (btn?.pressed) {
|
|
58
|
+
const name = buttonNames[index] || ('button-' + index);
|
|
59
|
+
send({ type:'button', button:name, index, pressed:false, value:0 });
|
|
60
|
+
if (index === 12) send({ type:'dpad', key:'up', pressed:false });
|
|
61
|
+
if (index === 13) send({ type:'dpad', key:'down', pressed:false });
|
|
62
|
+
if (index === 14) send({ type:'dpad', key:'left', pressed:false });
|
|
63
|
+
if (index === 15) send({ type:'dpad', key:'right', pressed:false });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
prevButtons = [];
|
|
67
|
+
}
|
|
68
|
+
if (prevAxes.length) {
|
|
69
|
+
prevAxes.forEach((prevVal, index) => {
|
|
70
|
+
if (prevVal !== 0) {
|
|
71
|
+
const name = axisNames[index] || ('axis-' + index);
|
|
72
|
+
send({ type:'axis', axis:name, index, value:0 });
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
prevAxes = [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
requestAnimationFrame(poll);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
window.addEventListener('gamepadconnected', () => send({ type:'status', state:'connected' }));
|
|
82
|
+
window.addEventListener('gamepaddisconnected', () => {
|
|
83
|
+
// Clear previously pressed states so consumers do not get stuck buttons.
|
|
84
|
+
prevButtons.forEach((btn, index) => {
|
|
85
|
+
if (btn?.pressed) {
|
|
86
|
+
const name = buttonNames[index] || ('button-' + index);
|
|
87
|
+
send({ type:'button', button:name, index, pressed:false, value:0 });
|
|
88
|
+
if (index === 12) send({ type:'dpad', key:'up', pressed:false });
|
|
89
|
+
if (index === 13) send({ type:'dpad', key:'down', pressed:false });
|
|
90
|
+
if (index === 14) send({ type:'dpad', key:'left', pressed:false });
|
|
91
|
+
if (index === 15) send({ type:'dpad', key:'right', pressed:false });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
prevButtons = [];
|
|
95
|
+
prevAxes = [];
|
|
96
|
+
send({ type:'status', state:'disconnected' });
|
|
97
|
+
});
|
|
98
|
+
poll();
|
|
99
|
+
})();
|
|
100
|
+
</script>
|
|
101
|
+
</body></html>`;
|
|
102
|
+
function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, style, }) {
|
|
103
|
+
const html = (0, react_1.useMemo)(() => buildBridgeHtml(axisThreshold), [axisThreshold]);
|
|
104
|
+
if (!enabled)
|
|
105
|
+
return null;
|
|
106
|
+
const handleMessage = (event) => {
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(event.nativeEvent.data);
|
|
109
|
+
if (data.type === 'dpad') {
|
|
110
|
+
onDpad === null || onDpad === void 0 ? void 0 : onDpad(data);
|
|
111
|
+
}
|
|
112
|
+
else if (data.type === 'button') {
|
|
113
|
+
onButton === null || onButton === void 0 ? void 0 : onButton(data);
|
|
114
|
+
}
|
|
115
|
+
else if (data.type === 'axis') {
|
|
116
|
+
onAxis === null || onAxis === void 0 ? void 0 : onAxis(data);
|
|
117
|
+
}
|
|
118
|
+
else if (data.type === 'status') {
|
|
119
|
+
onStatus === null || onStatus === void 0 ? void 0 : onStatus(data);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// ignore malformed messages
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
return ((0, jsx_runtime_1.jsx)(react_native_webview_1.default, { source: { html }, originWhitelist: ['*'], onMessage: handleMessage, javaScriptEnabled: true, style: style !== null && style !== void 0 ? style : { width: 1, height: 1, position: 'absolute', opacity: 0 } }));
|
|
127
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = GamepadDebug;
|
|
7
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
|
+
const react_1 = require("react");
|
|
9
|
+
const react_native_1 = require("react-native");
|
|
10
|
+
const useGamepad_1 = __importDefault(require("./useGamepad"));
|
|
11
|
+
const trackedAxes = ['leftX', 'leftY', 'rightX', 'rightY'];
|
|
12
|
+
const format = (value) => (value !== null && value !== void 0 ? value : 0).toFixed(2);
|
|
13
|
+
function GamepadDebug({ enabled = true, axisThreshold = 0.15 }) {
|
|
14
|
+
const { bridge, pressedButtons, axes } = (0, useGamepad_1.default)({ enabled, axisThreshold });
|
|
15
|
+
const pressedList = (0, react_1.useMemo)(() => Array.from(pressedButtons).sort(), [pressedButtons]);
|
|
16
|
+
const pressed = (key) => pressedButtons.has(key);
|
|
17
|
+
const axis = (key) => { var _a; return (_a = axes[key]) !== null && _a !== void 0 ? _a : 0; };
|
|
18
|
+
return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.container, children: [bridge, (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.title, children: "Gamepad Debug" }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.controller, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.shoulders, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.bumper, pressed('lb') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "LB" }) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.bumper, pressed('rb') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "RB" }) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.triggers, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.trigger, pressed('lt') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "LT" }) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.trigger, pressed('rt') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "RT" }) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.midRow, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickZone, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickRing, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
19
|
+
styles.stick,
|
|
20
|
+
{
|
|
21
|
+
transform: [
|
|
22
|
+
{ translateX: axis('leftX') * 20 },
|
|
23
|
+
{ translateY: axis('leftY') * -20 },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
pressed('ls') && styles.active,
|
|
27
|
+
] }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.smallLabel, children: "LS" })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.centerCluster, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.smallKey, pressed('back') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "Back" }) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.smallKey, pressed('start') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "Start" }) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.smallKey, pressed('home') && styles.active], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.label, children: "Home" }) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.faceCluster, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.faceRow, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.faceButton, pressed('y') && styles.faceActive], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.faceText, children: "Y" }) }) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.faceRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.faceButton, pressed('x') && styles.faceActive], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.faceText, children: "X" }) }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.faceButton, pressed('b') && styles.faceActive], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.faceText, children: "B" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.faceRow, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [styles.faceButton, pressed('a') && styles.faceActive], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.faceText, children: "A" }) }) })] })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.bottomRow, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.dpad, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
28
|
+
styles.dpadKey,
|
|
29
|
+
styles.dpadVertical,
|
|
30
|
+
styles.dpadUp,
|
|
31
|
+
pressed('dpadUp') && styles.active,
|
|
32
|
+
] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
33
|
+
styles.dpadKey,
|
|
34
|
+
styles.dpadVertical,
|
|
35
|
+
styles.dpadDown,
|
|
36
|
+
pressed('dpadDown') && styles.active,
|
|
37
|
+
] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
38
|
+
styles.dpadKey,
|
|
39
|
+
styles.dpadHorizontal,
|
|
40
|
+
styles.dpadLeft,
|
|
41
|
+
pressed('dpadLeft') && styles.active,
|
|
42
|
+
] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
43
|
+
styles.dpadKey,
|
|
44
|
+
styles.dpadHorizontal,
|
|
45
|
+
styles.dpadRight,
|
|
46
|
+
pressed('dpadRight') && styles.active,
|
|
47
|
+
] })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickZone, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickRing, children: (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
|
|
48
|
+
styles.stick,
|
|
49
|
+
{
|
|
50
|
+
transform: [
|
|
51
|
+
{ translateX: axis('rightX') * 20 },
|
|
52
|
+
{ translateY: axis('rightY') * -20 },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
pressed('rs') && styles.active,
|
|
56
|
+
] }) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.smallLabel, children: "RS" })] })] })] }), (0, jsx_runtime_1.jsxs)(react_native_1.Text, { style: styles.subtitle, children: ["Pressed (", pressedList.length, ")"] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.badgeRow, children: pressedList.length === 0 ? ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.muted, children: "None" })) : (pressedList.map((name) => ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.badge, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.badgeText, children: name }) }, name)))) }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.subtitle, children: "Axes" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.axesGrid, children: trackedAxes.map((axis) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.axisItem, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.axisLabel, children: axis }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.axisValue, children: format(axes[axis]) })] }, axis))) })] }));
|
|
57
|
+
}
|
|
58
|
+
const styles = react_native_1.StyleSheet.create({
|
|
59
|
+
container: {
|
|
60
|
+
padding: 12,
|
|
61
|
+
gap: 12,
|
|
62
|
+
backgroundColor: '#0b1220',
|
|
63
|
+
borderRadius: 12,
|
|
64
|
+
borderWidth: 1,
|
|
65
|
+
borderColor: '#1f2a3d',
|
|
66
|
+
},
|
|
67
|
+
title: {
|
|
68
|
+
fontSize: 18,
|
|
69
|
+
fontWeight: '700',
|
|
70
|
+
color: '#e5e7eb',
|
|
71
|
+
},
|
|
72
|
+
subtitle: {
|
|
73
|
+
marginTop: 4,
|
|
74
|
+
fontSize: 14,
|
|
75
|
+
fontWeight: '600',
|
|
76
|
+
color: '#cbd5e1',
|
|
77
|
+
},
|
|
78
|
+
muted: {
|
|
79
|
+
color: '#9ca3af',
|
|
80
|
+
fontSize: 13,
|
|
81
|
+
},
|
|
82
|
+
badgeRow: {
|
|
83
|
+
flexDirection: 'row',
|
|
84
|
+
flexWrap: 'wrap',
|
|
85
|
+
gap: 6,
|
|
86
|
+
},
|
|
87
|
+
badge: {
|
|
88
|
+
paddingVertical: 4,
|
|
89
|
+
paddingHorizontal: 8,
|
|
90
|
+
borderRadius: 12,
|
|
91
|
+
backgroundColor: '#1f2937',
|
|
92
|
+
borderWidth: 1,
|
|
93
|
+
borderColor: '#374151',
|
|
94
|
+
},
|
|
95
|
+
badgeText: {
|
|
96
|
+
color: '#e5e7eb',
|
|
97
|
+
fontSize: 12,
|
|
98
|
+
fontWeight: '600',
|
|
99
|
+
},
|
|
100
|
+
axisItem: {
|
|
101
|
+
padding: 8,
|
|
102
|
+
borderRadius: 8,
|
|
103
|
+
backgroundColor: '#111827',
|
|
104
|
+
borderWidth: 1,
|
|
105
|
+
borderColor: '#1f2937',
|
|
106
|
+
minWidth: 90,
|
|
107
|
+
},
|
|
108
|
+
axisLabel: {
|
|
109
|
+
color: '#cbd5e1',
|
|
110
|
+
fontSize: 12,
|
|
111
|
+
fontWeight: '600',
|
|
112
|
+
},
|
|
113
|
+
axisValue: {
|
|
114
|
+
color: '#f8fafc',
|
|
115
|
+
fontSize: 16,
|
|
116
|
+
fontVariant: ['tabular-nums'],
|
|
117
|
+
},
|
|
118
|
+
controller: {
|
|
119
|
+
backgroundColor: '#0f172a',
|
|
120
|
+
borderRadius: 14,
|
|
121
|
+
borderWidth: 1,
|
|
122
|
+
borderColor: '#1f2a3d',
|
|
123
|
+
padding: 12,
|
|
124
|
+
gap: 12,
|
|
125
|
+
},
|
|
126
|
+
shoulders: {
|
|
127
|
+
flexDirection: 'row',
|
|
128
|
+
justifyContent: 'space-between',
|
|
129
|
+
},
|
|
130
|
+
triggers: {
|
|
131
|
+
flexDirection: 'row',
|
|
132
|
+
justifyContent: 'space-between',
|
|
133
|
+
gap: 8,
|
|
134
|
+
},
|
|
135
|
+
bumper: {
|
|
136
|
+
flex: 1,
|
|
137
|
+
paddingVertical: 8,
|
|
138
|
+
borderRadius: 10,
|
|
139
|
+
backgroundColor: '#111827',
|
|
140
|
+
borderWidth: 1,
|
|
141
|
+
borderColor: '#1f2937',
|
|
142
|
+
alignItems: 'center',
|
|
143
|
+
},
|
|
144
|
+
trigger: {
|
|
145
|
+
flex: 1,
|
|
146
|
+
paddingVertical: 8,
|
|
147
|
+
borderRadius: 10,
|
|
148
|
+
backgroundColor: '#111827',
|
|
149
|
+
borderWidth: 1,
|
|
150
|
+
borderColor: '#1f2937',
|
|
151
|
+
alignItems: 'center',
|
|
152
|
+
},
|
|
153
|
+
label: {
|
|
154
|
+
color: '#d1d5db',
|
|
155
|
+
fontSize: 12,
|
|
156
|
+
fontWeight: '700',
|
|
157
|
+
},
|
|
158
|
+
smallLabel: {
|
|
159
|
+
color: '#9ca3af',
|
|
160
|
+
fontSize: 11,
|
|
161
|
+
marginTop: 4,
|
|
162
|
+
textAlign: 'center',
|
|
163
|
+
},
|
|
164
|
+
midRow: {
|
|
165
|
+
flexDirection: 'row',
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
justifyContent: 'space-between',
|
|
168
|
+
gap: 12,
|
|
169
|
+
},
|
|
170
|
+
centerCluster: {
|
|
171
|
+
alignItems: 'center',
|
|
172
|
+
gap: 6,
|
|
173
|
+
},
|
|
174
|
+
smallKey: {
|
|
175
|
+
paddingVertical: 6,
|
|
176
|
+
paddingHorizontal: 10,
|
|
177
|
+
borderRadius: 8,
|
|
178
|
+
backgroundColor: '#111827',
|
|
179
|
+
borderWidth: 1,
|
|
180
|
+
borderColor: '#1f2937',
|
|
181
|
+
},
|
|
182
|
+
faceCluster: {
|
|
183
|
+
alignItems: 'center',
|
|
184
|
+
gap: 4,
|
|
185
|
+
},
|
|
186
|
+
faceRow: {
|
|
187
|
+
flexDirection: 'row',
|
|
188
|
+
gap: 8,
|
|
189
|
+
justifyContent: 'center',
|
|
190
|
+
},
|
|
191
|
+
faceButton: {
|
|
192
|
+
width: 42,
|
|
193
|
+
height: 42,
|
|
194
|
+
borderRadius: 21,
|
|
195
|
+
backgroundColor: '#162032',
|
|
196
|
+
borderWidth: 1,
|
|
197
|
+
borderColor: '#1f2a3d',
|
|
198
|
+
alignItems: 'center',
|
|
199
|
+
justifyContent: 'center',
|
|
200
|
+
},
|
|
201
|
+
faceText: {
|
|
202
|
+
color: '#e5e7eb',
|
|
203
|
+
fontWeight: '800',
|
|
204
|
+
},
|
|
205
|
+
faceActive: {
|
|
206
|
+
backgroundColor: '#0ea5e9',
|
|
207
|
+
borderColor: '#38bdf8',
|
|
208
|
+
shadowColor: '#38bdf8',
|
|
209
|
+
shadowOpacity: 0.6,
|
|
210
|
+
shadowRadius: 6,
|
|
211
|
+
},
|
|
212
|
+
stickZone: {
|
|
213
|
+
width: 96,
|
|
214
|
+
alignItems: 'center',
|
|
215
|
+
gap: 6,
|
|
216
|
+
},
|
|
217
|
+
stickRing: {
|
|
218
|
+
width: 84,
|
|
219
|
+
height: 84,
|
|
220
|
+
borderRadius: 42,
|
|
221
|
+
borderWidth: 1,
|
|
222
|
+
borderColor: '#1f2937',
|
|
223
|
+
backgroundColor: '#0c1426',
|
|
224
|
+
alignItems: 'center',
|
|
225
|
+
justifyContent: 'center',
|
|
226
|
+
},
|
|
227
|
+
stick: {
|
|
228
|
+
width: 38,
|
|
229
|
+
height: 38,
|
|
230
|
+
borderRadius: 19,
|
|
231
|
+
backgroundColor: '#1f2937',
|
|
232
|
+
borderWidth: 2,
|
|
233
|
+
borderColor: '#111827',
|
|
234
|
+
},
|
|
235
|
+
bottomRow: {
|
|
236
|
+
flexDirection: 'row',
|
|
237
|
+
justifyContent: 'space-between',
|
|
238
|
+
alignItems: 'center',
|
|
239
|
+
gap: 12,
|
|
240
|
+
},
|
|
241
|
+
dpad: {
|
|
242
|
+
width: 120,
|
|
243
|
+
height: 120,
|
|
244
|
+
alignItems: 'center',
|
|
245
|
+
justifyContent: 'center',
|
|
246
|
+
position: 'relative',
|
|
247
|
+
},
|
|
248
|
+
dpadKey: {
|
|
249
|
+
position: 'absolute',
|
|
250
|
+
backgroundColor: '#111827',
|
|
251
|
+
borderColor: '#1f2937',
|
|
252
|
+
borderWidth: 1,
|
|
253
|
+
borderRadius: 6,
|
|
254
|
+
},
|
|
255
|
+
dpadVertical: {
|
|
256
|
+
width: 28,
|
|
257
|
+
height: 46,
|
|
258
|
+
},
|
|
259
|
+
dpadHorizontal: {
|
|
260
|
+
width: 46,
|
|
261
|
+
height: 28,
|
|
262
|
+
},
|
|
263
|
+
active: {
|
|
264
|
+
borderColor: '#22d3ee',
|
|
265
|
+
backgroundColor: '#0ea5e9',
|
|
266
|
+
shadowColor: '#22d3ee',
|
|
267
|
+
shadowOpacity: 0.5,
|
|
268
|
+
shadowRadius: 4,
|
|
269
|
+
},
|
|
270
|
+
dpadUp: { top: 6, left: 46 },
|
|
271
|
+
dpadDown: { bottom: 6, left: 46 },
|
|
272
|
+
dpadLeft: { left: 6, top: 46 },
|
|
273
|
+
dpadRight: { right: 6, top: 46 },
|
|
274
|
+
axesGrid: {
|
|
275
|
+
flexDirection: 'row',
|
|
276
|
+
flexWrap: 'wrap',
|
|
277
|
+
gap: 8,
|
|
278
|
+
},
|
|
279
|
+
});
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
17
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
18
|
+
};
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.useGamepad = exports.GamepadDebug = exports.GamepadBridge = void 0;
|
|
21
|
+
var GamepadBridge_1 = require("./GamepadBridge");
|
|
22
|
+
Object.defineProperty(exports, "GamepadBridge", { enumerable: true, get: function () { return __importDefault(GamepadBridge_1).default; } });
|
|
23
|
+
var GamepadDebug_1 = require("./GamepadDebug");
|
|
24
|
+
Object.defineProperty(exports, "GamepadDebug", { enumerable: true, get: function () { return __importDefault(GamepadDebug_1).default; } });
|
|
25
|
+
var useGamepad_1 = require("./useGamepad");
|
|
26
|
+
Object.defineProperty(exports, "useGamepad", { enumerable: true, get: function () { return __importDefault(useGamepad_1).default; } });
|
|
27
|
+
__exportStar(require("./types"), exports);
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type DpadKey = 'up' | 'down' | 'left' | 'right';
|
|
2
|
+
export type GamepadButtonName = 'a' | 'b' | 'x' | 'y' | 'lb' | 'rb' | 'lt' | 'rt' | 'back' | 'start' | 'ls' | 'rs' | 'dpadUp' | 'dpadDown' | 'dpadLeft' | 'dpadRight' | 'home' | `button-${number}`;
|
|
3
|
+
export type StickAxisName = 'leftX' | 'leftY' | 'rightX' | 'rightY' | `axis-${number}`;
|
|
4
|
+
export type DpadEvent = {
|
|
5
|
+
type: 'dpad';
|
|
6
|
+
key: DpadKey;
|
|
7
|
+
pressed: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type ButtonEvent = {
|
|
10
|
+
type: 'button';
|
|
11
|
+
button: GamepadButtonName;
|
|
12
|
+
index: number;
|
|
13
|
+
pressed: boolean;
|
|
14
|
+
value: number;
|
|
15
|
+
};
|
|
16
|
+
export type AxisEvent = {
|
|
17
|
+
type: 'axis';
|
|
18
|
+
axis: StickAxisName;
|
|
19
|
+
index: number;
|
|
20
|
+
value: number;
|
|
21
|
+
};
|
|
22
|
+
export type StatusEvent = {
|
|
23
|
+
type: 'status';
|
|
24
|
+
state: 'connected' | 'disconnected';
|
|
25
|
+
};
|
|
26
|
+
export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type JSX } from 'react';
|
|
2
|
+
import type { AxisEvent, ButtonEvent, DpadEvent, GamepadButtonName, StickAxisName, StatusEvent } from './types';
|
|
3
|
+
type Options = {
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
axisThreshold?: number;
|
|
6
|
+
onButton?: (event: ButtonEvent) => void;
|
|
7
|
+
onAxis?: (event: AxisEvent) => void;
|
|
8
|
+
onDpad?: (event: DpadEvent) => void;
|
|
9
|
+
onStatus?: (event: StatusEvent) => void;
|
|
10
|
+
};
|
|
11
|
+
type Return = {
|
|
12
|
+
pressedButtons: Set<GamepadButtonName>;
|
|
13
|
+
axes: Partial<Record<StickAxisName, number>>;
|
|
14
|
+
isPressed: (key: GamepadButtonName) => boolean;
|
|
15
|
+
bridge: JSX.Element | null;
|
|
16
|
+
};
|
|
17
|
+
export default function useGamepad({ enabled, axisThreshold, onButton, onAxis, onDpad, onStatus, }?: Options): Return;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.default = useGamepad;
|
|
7
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
8
|
+
const react_1 = require("react");
|
|
9
|
+
const GamepadBridge_1 = __importDefault(require("./GamepadBridge"));
|
|
10
|
+
function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, onDpad, onStatus, } = {}) {
|
|
11
|
+
const [pressedButtons, setPressedButtons] = (0, react_1.useState)(new Set());
|
|
12
|
+
const pressedRef = (0, react_1.useRef)(new Set());
|
|
13
|
+
const [axes, setAxes] = (0, react_1.useState)({
|
|
14
|
+
leftX: 0,
|
|
15
|
+
leftY: 0,
|
|
16
|
+
rightX: 0,
|
|
17
|
+
rightY: 0,
|
|
18
|
+
});
|
|
19
|
+
(0, react_1.useEffect)(() => {
|
|
20
|
+
pressedRef.current = pressedButtons;
|
|
21
|
+
}, [pressedButtons]);
|
|
22
|
+
const handleButton = (0, react_1.useCallback)((event) => {
|
|
23
|
+
const next = new Set(pressedRef.current);
|
|
24
|
+
if (event.pressed) {
|
|
25
|
+
next.add(event.button);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
next.delete(event.button);
|
|
29
|
+
}
|
|
30
|
+
pressedRef.current = next;
|
|
31
|
+
setPressedButtons(next);
|
|
32
|
+
onButton === null || onButton === void 0 ? void 0 : onButton(event);
|
|
33
|
+
}, [onButton]);
|
|
34
|
+
const handleAxis = (0, react_1.useCallback)((event) => {
|
|
35
|
+
setAxes((prev) => ({ ...prev, [event.axis]: event.value }));
|
|
36
|
+
onAxis === null || onAxis === void 0 ? void 0 : onAxis(event);
|
|
37
|
+
}, [onAxis]);
|
|
38
|
+
const handleDpad = (0, react_1.useCallback)((event) => {
|
|
39
|
+
onDpad === null || onDpad === void 0 ? void 0 : onDpad(event);
|
|
40
|
+
}, [onDpad]);
|
|
41
|
+
(0, react_1.useEffect)(() => {
|
|
42
|
+
if (!enabled && pressedRef.current.size) {
|
|
43
|
+
pressedRef.current = new Set();
|
|
44
|
+
setPressedButtons(new Set());
|
|
45
|
+
}
|
|
46
|
+
if (!enabled) {
|
|
47
|
+
setAxes({ leftX: 0, leftY: 0, rightX: 0, rightY: 0 });
|
|
48
|
+
}
|
|
49
|
+
}, [enabled]);
|
|
50
|
+
const bridge = (0, react_1.useMemo)(() => ((0, jsx_runtime_1.jsx)(GamepadBridge_1.default, { enabled: enabled, axisThreshold: axisThreshold, onDpad: handleDpad, onButton: handleButton, onAxis: handleAxis, onStatus: onStatus })), [enabled, axisThreshold, handleAxis, handleButton, handleDpad, onStatus]);
|
|
51
|
+
const isPressed = (0, react_1.useCallback)((key) => pressedRef.current.has(key), []);
|
|
52
|
+
return { pressedButtons, axes, isPressed, bridge };
|
|
53
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-native-earl-gamepad",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React Native gamepad bridge via WebView (buttons, sticks, d-pad, status).",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p .",
|
|
13
|
+
"clean": "rimraf dist"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"react": ">=18",
|
|
17
|
+
"react-native": ">=0.72",
|
|
18
|
+
"react-native-webview": ">=13"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "^19.0.0",
|
|
22
|
+
"react": "^19.0.0",
|
|
23
|
+
"react-native": "^0.81.5",
|
|
24
|
+
"react-native-webview": "^13.12.2",
|
|
25
|
+
"typescript": "^5.3.3"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"react-native",
|
|
29
|
+
"gamepad",
|
|
30
|
+
"webview",
|
|
31
|
+
"controller"
|
|
32
|
+
],
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/earlo/react-native-earl-gamepad.git"
|
|
36
|
+
},
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/earlo/react-native-earl-gamepad/issues"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/earlo/react-native-earl-gamepad#readme",
|
|
41
|
+
"author": "Ordovez, Earl Romeo",
|
|
42
|
+
"license": "MIT"
|
|
43
|
+
}
|