react-native-earl-gamepad 0.6.0 → 0.7.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/README.md CHANGED
@@ -5,11 +5,11 @@
5
5
  ![downloads](https://img.shields.io/npm/dm/react-native-earl-gamepad)
6
6
  ![license](https://img.shields.io/npm/l/react-native-earl-gamepad)
7
7
 
8
- WebView-based gamepad bridge for React Native. Polls `navigator.getGamepads()` in a hidden WebView and surfaces buttons, sticks, d-pad, and connection events to JS.
8
+ WebView-based gamepad bridge for React Native. Polls `navigator.getGamepads()` in a hidden WebView and surfaces buttons, sticks, d-pad, touchpad click, and connection events to JS.
9
9
 
10
10
  - Components: `GamepadBridge`, `useGamepad`, and `GamepadDebug`.
11
- - Deadzone handling (default `0.15`) with auto-clear on disconnect.
12
- - Typed events for buttons, axes, d-pad, and status.
11
+ - Deadzone handling (default `0.15`) with auto-clear on disconnect and live state snapshots to avoid stuck buttons.
12
+ - Typed events for buttons, axes, d-pad, touchpad click, status, and a full-state snapshot.
13
13
 
14
14
  ### Why this?
15
15
 
@@ -134,6 +134,11 @@ export function HUD() {
134
134
 
135
135
  Drop-in component to see a controller diagram that lights up buttons, shows stick offsets, and lists state. Shows live metadata (name/vendor/product, mapping, axes/buttons count, vibration support) and includes vibration test buttons plus a loader prompt when no pad is connected.
136
136
 
137
+ The State panel includes:
138
+
139
+ - Per-stick plots (left/right) with axis values, crosshairs, and a dashed trace from center to the current dot.
140
+ - Touchpad click indicator (PS touchpad click is mapped to `touchpad`; position is not exposed by the Gamepad API).
141
+
137
142
  ```tsx
138
143
  import { GamepadDebug } from "react-native-earl-gamepad";
139
144
 
@@ -142,9 +147,9 @@ export function DebugScreen() {
142
147
  }
143
148
  ```
144
149
 
145
- ![Gamepad visual idle](https://github.com/user-attachments/assets/9d4cbc94-c3aa-434a-99ae-5ea8b01b06e3)
146
- ![Gamepad visual pressed](https://github.com/user-attachments/assets/7b37d76a-7695-4be9-bda4-7e3d1e6adf41)
147
- ![Gamepad loader](https://github.com/user-attachments/assets/5bb462e5-3259-4680-bfcf-005324ac2aec)
150
+ ![Gamepad visual idle](https://github.com/user-attachments/assets/ae29d0a9-ded5-4b2d-96f8-f432f99cb54c)
151
+ ![Gamepad visual pressed](https://github.com/user-attachments/assets/830323aa-0f6c-4ee4-a276-663a421b9697)
152
+ ![Gamepad Idle](https://github.com/user-attachments/assets/206fe108-8ec3-40cb-a64b-de058d07672f)
148
153
 
149
154
  ## API
150
155
 
@@ -156,6 +161,7 @@ export function DebugScreen() {
156
161
  - `onAxis?: (event: AxisEvent) => void` — fired when an axis changes beyond threshold.
157
162
  - `onDpad?: (event: DpadEvent) => void` — convenience mapping of button indices 12–15.
158
163
  - `onStatus?: (event: StatusEvent) => void` — `connected` / `disconnected` events.
164
+ - `onState?: (event: StateEvent) => void` — full snapshot of pressed buttons, values, and axes each poll.
159
165
  - `style?: StyleProp<ViewStyle>` — override container; default is a 1×1 transparent view.
160
166
 
161
167
  ### `useGamepad` options and return
@@ -188,6 +194,8 @@ Return shape:
188
194
  - `AxisEvent`: `{ type: 'axis'; axis: StickAxisName; index: number; value: number }`
189
195
  - `DpadEvent`: `{ type: 'dpad'; key: 'up' | 'down' | 'left' | 'right'; pressed: boolean }`
190
196
  - `StatusEvent`: `{ type: 'status'; state: 'connected' | 'disconnected' }`
197
+ - `InfoEvent`: controller metadata payload (name/vendor/product, mapping, counts, vibration capability, timestamp, index, etc.)
198
+ - `StateEvent`: `{ type: 'state'; pressed: GamepadButtonName[]; values: Record<GamepadButtonName, number>; axes: Record<StickAxisName, number> }`
191
199
 
192
200
  Button names map to the standard gamepad layout (`a`, `b`, `x`, `y`, `lb`, `rb`, `lt`, `rt`, `back`, `start`, `ls`, `rs`, `dpadUp`, `dpadDown`, `dpadLeft`, `dpadRight`, `home`). Unknown indices fall back to `button-N`. Axes map to `leftX`, `leftY`, `rightX`, `rightY` with fallbacks `axis-N`.
193
201
 
@@ -1,5 +1,5 @@
1
1
  import { StyleProp, ViewStyle } from "react-native";
2
- import type { AxisEvent, ButtonEvent, DpadEvent, InfoEvent, StatusEvent } from "./types";
2
+ import type { AxisEvent, ButtonEvent, DpadEvent, InfoEvent, StateEvent, StatusEvent } from "./types";
3
3
  type VibrationRequest = {
4
4
  type: "once";
5
5
  duration: number;
@@ -18,8 +18,9 @@ type Props = {
18
18
  onAxis?: (event: AxisEvent) => void;
19
19
  onStatus?: (event: StatusEvent) => void;
20
20
  onInfo?: (event: InfoEvent) => void;
21
+ onState?: (event: StateEvent) => void;
21
22
  vibrationRequest?: VibrationRequest;
22
23
  style?: StyleProp<ViewStyle>;
23
24
  };
24
- export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, onInfo, vibrationRequest, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
25
+ export default function GamepadBridge({ enabled, axisThreshold, onDpad, onButton, onAxis, onStatus, onInfo, onState, vibrationRequest, style, }: Props): import("react/jsx-runtime").JSX.Element | null;
25
26
  export {};
@@ -16,7 +16,7 @@ const buildBridgeHtml = (axisThreshold) => `
16
16
  (function(){
17
17
  const deadzone = ${axisThreshold.toFixed(2)};
18
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'];
19
+ const buttonNames = ['a','b','x','y','lb','rb','lt','rt','back','start','ls','rs','dpadUp','dpadDown','dpadLeft','dpadRight','home','touchpad'];
20
20
  const axisNames = ['leftX','leftY','rightX','rightY'];
21
21
  let prevButtons = [];
22
22
  let prevAxes = [];
@@ -79,14 +79,24 @@ const buildBridgeHtml = (axisThreshold) => `
79
79
  window.__earlVibrateOnce = vibrateOnce;
80
80
  window.__earlStopVibration = stopVibration;
81
81
 
82
+ const nameForIndex = (index) => {
83
+ if (buttonNames[index]) return buttonNames[index];
84
+ if (index === 16) return 'home';
85
+ if (index === 17) return 'touchpad';
86
+ return 'button-' + index;
87
+ };
88
+
82
89
  function poll(){
83
90
  const pads = (navigator.getGamepads && navigator.getGamepads()) || [];
84
91
  const gp = pads[0];
85
92
  sendInfo(gp);
86
93
  if (gp) {
94
+ const pressedNow = [];
95
+ const valueMap = {};
96
+ const axesState = {};
87
97
  // Buttons
88
98
  gp.buttons?.forEach((btn, index) => {
89
- const name = buttonNames[index] || ('button-' + index);
99
+ const name = nameForIndex(index);
90
100
  const prevBtn = prevButtons[index] || { pressed:false, value:0 };
91
101
  const changed = prevBtn.pressed !== btn.pressed || Math.abs(prevBtn.value - btn.value) > 0.01;
92
102
  if (changed) {
@@ -97,6 +107,8 @@ const buildBridgeHtml = (axisThreshold) => `
97
107
  if (index === 14) send({ type:'dpad', key:'left', pressed: !!btn.pressed });
98
108
  if (index === 15) send({ type:'dpad', key:'right', pressed: !!btn.pressed });
99
109
  }
110
+ if (btn?.pressed) pressedNow.push(name);
111
+ valueMap[name] = btn?.value ?? 0;
100
112
  prevButtons[index] = { pressed: !!btn.pressed, value: btn.value ?? 0 };
101
113
  });
102
114
 
@@ -108,15 +120,18 @@ const buildBridgeHtml = (axisThreshold) => `
108
120
  if (Math.abs(prevVal - value) > 0.01) {
109
121
  send({ type:'axis', axis:name, index, value });
110
122
  }
123
+ axesState[name] = value;
111
124
  prevAxes[index] = value;
112
125
  });
126
+
127
+ send({ type:'state', pressed: pressedNow, values: valueMap, axes: axesState });
113
128
  } else {
114
129
  // If no pad, send a single disconnected info payload
115
130
  sendInfo(null);
116
131
  if (prevButtons.length) {
117
132
  prevButtons.forEach((btn, index) => {
118
133
  if (btn?.pressed) {
119
- const name = buttonNames[index] || ('button-' + index);
134
+ const name = nameForIndex(index);
120
135
  send({ type:'button', button:name, index, pressed:false, value:0 });
121
136
  if (index === 12) send({ type:'dpad', key:'up', pressed:false });
122
137
  if (index === 13) send({ type:'dpad', key:'down', pressed:false });
@@ -135,6 +150,7 @@ const buildBridgeHtml = (axisThreshold) => `
135
150
  });
136
151
  prevAxes = [];
137
152
  }
153
+ send({ type:'state', pressed: [], values: {}, axes: {} });
138
154
  }
139
155
  requestAnimationFrame(poll);
140
156
  }
@@ -160,7 +176,7 @@ const buildBridgeHtml = (axisThreshold) => `
160
176
  })();
161
177
  </script>
162
178
  </body></html>`;
163
- function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, onInfo, vibrationRequest, style, }) {
179
+ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton, onAxis, onStatus, onInfo, onState, vibrationRequest, style, }) {
164
180
  const webviewRef = (0, react_1.useRef)(null);
165
181
  const html = (0, react_1.useMemo)(() => buildBridgeHtml(axisThreshold), [axisThreshold]);
166
182
  const focusBridge = (0, react_1.useCallback)(() => {
@@ -207,6 +223,9 @@ function GamepadBridge({ enabled = true, axisThreshold = 0.15, onDpad, onButton,
207
223
  else if (data.type === "info") {
208
224
  onInfo === null || onInfo === void 0 ? void 0 : onInfo(data);
209
225
  }
226
+ else if (data.type === "state") {
227
+ onState === null || onState === void 0 ? void 0 : onState(data);
228
+ }
210
229
  }
211
230
  catch {
212
231
  // ignore malformed messages
@@ -18,7 +18,7 @@ function ControllerVisual({ pressed, axis, value }) {
18
18
  const { width, height } = (0, react_native_1.useWindowDimensions)();
19
19
  const isPortrait = height >= width;
20
20
  const containerScaleStyle = isPortrait
21
- ? { transform: [{ scale: 0.8 }] }
21
+ ? { transform: [{ scale: 0.7 }] }
22
22
  : undefined;
23
23
  const mix = (a, b, t) => Math.round(a + (b - a) * t);
24
24
  const stickColor = (mag) => {
@@ -67,7 +67,10 @@ function ControllerVisual({ pressed, axis, value }) {
67
67
  ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
68
68
  styles.psShoulderText,
69
69
  level("rt") > 0 && styles.psShoulderTextActive,
70
- ], children: "R2" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psMiddle }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psPaveTactile }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
70
+ ], children: "R2" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.psMiddle }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
71
+ styles.psPaveTactile,
72
+ pressed("touchpad") && styles.psPaveTactileActive,
73
+ ] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
71
74
  styles.psShare,
72
75
  pressed("back") && styles.psActiveButton,
73
76
  ] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
@@ -126,7 +129,7 @@ function ControllerVisual({ pressed, axis, value }) {
126
129
  {
127
130
  transform: [
128
131
  { translateX: axis("leftX") * 6 },
129
- { translateY: axis("leftY") * -6 },
132
+ { translateY: axis("leftY") * 6 },
130
133
  ],
131
134
  backgroundColor: stickColor(leftMag),
132
135
  },
@@ -147,7 +150,7 @@ function ControllerVisual({ pressed, axis, value }) {
147
150
  {
148
151
  transform: [
149
152
  { translateX: axis("rightX") * 6 },
150
- { translateY: axis("rightY") * -6 },
153
+ { translateY: axis("rightY") * 6 },
151
154
  ],
152
155
  backgroundColor: stickColor(rightMag),
153
156
  },
@@ -169,6 +172,36 @@ function GamepadDebug({ enabled = true, axisThreshold = 0.15, }) {
169
172
  const pressed = (key) => pressedButtons.has(key);
170
173
  const axisValue = (key) => { var _a; return (_a = axes[key]) !== null && _a !== void 0 ? _a : 0; };
171
174
  const buttonValue = (key) => { var _a; return (_a = buttonValues[key]) !== null && _a !== void 0 ? _a : 0; };
175
+ const touchpadPressed = pressed("touchpad");
176
+ const renderStickCard = (title, xLabel, yLabel, x, y) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickCard, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickTitle, children: title }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickContent, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickAxesList, children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickAxisRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisLabel, children: xLabel }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisValue, children: format(x) })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickAxisRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisLabel, children: yLabel }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.stickAxisValue, children: format(y) })] })] }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickPlot, children: (() => {
177
+ const radius = 44;
178
+ const dotX = x * radius;
179
+ const dotY = y * radius;
180
+ const distance = Math.hypot(dotX, dotY);
181
+ const hasMovement = distance > 1;
182
+ return ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickCircle, children: [(0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickCrosshairH }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.stickCrosshairV }), hasMovement && ((0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
183
+ styles.stickGuideDiag,
184
+ {
185
+ width: distance,
186
+ left: 55,
187
+ top: 55,
188
+ transform: [
189
+ {
190
+ rotate: `${Math.atan2(dotY, dotX) *
191
+ (180 / Math.PI)}deg`,
192
+ },
193
+ ],
194
+ },
195
+ ] })), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: [
196
+ styles.stickDot,
197
+ {
198
+ transform: [
199
+ { translateX: dotX },
200
+ { translateY: dotY },
201
+ ],
202
+ },
203
+ ] })] }));
204
+ })() })] })] }));
172
205
  const isConnected = info.connected;
173
206
  const controllerLabel = (_a = info.id) !== null && _a !== void 0 ? _a : "No controller detected";
174
207
  const vendorLabel = (_b = info.vendor) !== null && _b !== void 0 ? _b : "—";
@@ -191,23 +224,7 @@ function GamepadDebug({ enabled = true, axisThreshold = 0.15, }) {
191
224
  isConnected
192
225
  ? styles.statusPillConnected
193
226
  : styles.statusPillWaiting,
194
- ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.statusPillText, children: isConnected ? "Connected" : "Waiting" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperText, children: "Connect your gamepad and press a button to test." }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.infoGrid, children: infoItems.map(({ label, value }) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.infoItem, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.infoLabel, children: label }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.infoValue, children: value })] }, label))) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.controller, children: [!isConnected && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loaderOverlay, children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: "#2563eb", size: "small" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loaderText, children: "Connect your gamepad and press a button to test..." })] })), (0, jsx_runtime_1.jsx)(ControllerVisual, { pressed: pressed, axis: axisValue, value: buttonValue })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.testsRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: [
195
- styles.button,
196
- !info.canVibrate && styles.buttonDisabled,
197
- ], onPress: () => info.canVibrate && vibrate(900, 1), disabled: !info.canVibrate, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
198
- styles.buttonText,
199
- !info.canVibrate &&
200
- styles.buttonTextDisabled,
201
- ], children: "Vibration, 1 sec" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: [
202
- styles.button,
203
- !info.canVibrate && styles.buttonDisabled,
204
- ], onPress: () => info.canVibrate && stopVibration(), disabled: !info.canVibrate, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: [
205
- styles.buttonText,
206
- !info.canVibrate &&
207
- styles.buttonTextDisabled,
208
- ], children: "Stop vibration" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperTextSmall, children: info.canVibrate
209
- ? "Uses vibrationActuator when available."
210
- : "Vibration not reported by this controller." })] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.stateCard], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.cardTitle, children: "State" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sectionTitle, children: "Pressed" }), (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.sectionTitle, children: "Axes" }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.axesGrid, children: trackedAxes.map((axisName) => ((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: axisName }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.axisValue, children: format(axes[axisName]) })] }, axisName))) })] })] })] }) }));
227
+ ], children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.statusPillText, children: isConnected ? "Connected" : "Waiting" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperText, children: "Connect your gamepad and press a button to test." }), (0, jsx_runtime_1.jsx)(react_native_1.View, { style: styles.infoGrid, children: infoItems.map(({ label, value }) => ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.infoItem, children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.infoLabel, children: label }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.infoValue, children: value })] }, label))) }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.controller, children: [!isConnected && ((0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.loaderOverlay, children: [(0, jsx_runtime_1.jsx)(react_native_1.ActivityIndicator, { color: "#2563eb", size: "small" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.loaderText, children: "Connect your gamepad and press a button to test..." })] })), (0, jsx_runtime_1.jsx)(ControllerVisual, { pressed: pressed, axis: axisValue, value: buttonValue })] }), info.canVibrate && ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.testsRow, children: [(0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.button, onPress: () => vibrate(900, 1), children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.buttonText, children: "Vibration, 1 sec" }) }), (0, jsx_runtime_1.jsx)(react_native_1.Pressable, { style: styles.button, onPress: stopVibration, children: (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.buttonText, children: "Stop vibration" }) })] }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperTextSmall, children: "Uses vibrationActuator when available." })] })), !info.canVibrate && ((0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.helperTextSmall, children: "Vibration not reported by this controller." }))] }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: [styles.card, styles.stateCard], children: [(0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.cardTitle, children: "State" }), (0, jsx_runtime_1.jsx)(react_native_1.Text, { style: styles.sectionTitle, children: "Pressed" }), (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.sectionTitle, children: "Axes" }), (0, jsx_runtime_1.jsxs)(react_native_1.View, { style: styles.stickGrid, children: [renderStickCard("L STICK", "Axis 0 (leftX)", "Axis 1 (leftY)", axisValue("leftX"), axisValue("leftY")), renderStickCard("R STICK", "Axis 2 (rightX)", "Axis 3 (rightY)", axisValue("rightX"), axisValue("rightY"))] })] })] })] }) }));
211
228
  }
212
229
  const styles = react_native_1.StyleSheet.create({
213
230
  container: {
@@ -296,6 +313,106 @@ const styles = react_native_1.StyleSheet.create({
296
313
  fontSize: 16,
297
314
  fontVariant: ["tabular-nums"],
298
315
  },
316
+ stickGrid: {
317
+ flexDirection: "row",
318
+ flexWrap: "wrap",
319
+ gap: 10,
320
+ },
321
+ stickCard: {
322
+ flexGrow: 1,
323
+ flexBasis: "48%",
324
+ minWidth: 240,
325
+ backgroundColor: "#f8fafc",
326
+ borderWidth: 1,
327
+ borderColor: "#cbd5e1",
328
+ borderRadius: 10,
329
+ padding: 10,
330
+ gap: 10,
331
+ },
332
+ stickTitle: {
333
+ color: "#475569",
334
+ fontSize: 12,
335
+ fontWeight: "700",
336
+ letterSpacing: 0.3,
337
+ },
338
+ stickContent: {
339
+ flexDirection: "row",
340
+ justifyContent: "space-between",
341
+ alignItems: "center",
342
+ gap: 12,
343
+ },
344
+ stickAxesList: {
345
+ gap: 6,
346
+ },
347
+ stickAxisRow: {
348
+ flexDirection: "row",
349
+ justifyContent: "space-between",
350
+ alignItems: "center",
351
+ gap: 8,
352
+ },
353
+ stickAxisLabel: {
354
+ color: "#475569",
355
+ fontSize: 12,
356
+ fontWeight: "600",
357
+ },
358
+ stickAxisValue: {
359
+ color: "#0f172a",
360
+ fontSize: 16,
361
+ fontWeight: "700",
362
+ fontVariant: ["tabular-nums"],
363
+ },
364
+ stickPlot: {
365
+ width: 120,
366
+ height: 120,
367
+ alignItems: "center",
368
+ justifyContent: "center",
369
+ },
370
+ stickCircle: {
371
+ width: 110,
372
+ height: 110,
373
+ borderRadius: 55,
374
+ borderWidth: 1,
375
+ borderColor: "#cbd5e1",
376
+ backgroundColor: "#fff",
377
+ alignItems: "center",
378
+ justifyContent: "center",
379
+ position: "relative",
380
+ },
381
+ stickCrosshairH: {
382
+ position: "absolute",
383
+ left: 5,
384
+ right: 5,
385
+ top: 55,
386
+ height: 1,
387
+ backgroundColor: "#e2e8f0",
388
+ },
389
+ stickCrosshairV: {
390
+ position: "absolute",
391
+ top: 5,
392
+ bottom: 5,
393
+ left: 55,
394
+ width: 1,
395
+ backgroundColor: "#e2e8f0",
396
+ },
397
+ stickGuideDiag: {
398
+ position: "absolute",
399
+ height: 0,
400
+ borderTopWidth: 1,
401
+ borderColor: "#94a3b8",
402
+ borderStyle: "dashed",
403
+ transformOrigin: "left center",
404
+ },
405
+ stickDot: {
406
+ width: 10,
407
+ height: 10,
408
+ borderRadius: 5,
409
+ backgroundColor: "#1d4ed8",
410
+ position: "absolute",
411
+ top: 55,
412
+ left: 55,
413
+ marginLeft: -5,
414
+ marginTop: -5,
415
+ },
299
416
  body: {
300
417
  flexDirection: "row",
301
418
  flexWrap: "wrap",
@@ -517,6 +634,12 @@ const styles = react_native_1.StyleSheet.create({
517
634
  shadowRadius: 10,
518
635
  elevation: 5,
519
636
  },
637
+ psPaveTactileActive: {
638
+ backgroundColor: "#2563eb",
639
+ shadowColor: "#2563eb",
640
+ shadowOpacity: 0.65,
641
+ shadowRadius: 12,
642
+ },
520
643
  psShare: {
521
644
  width: 12,
522
645
  height: 25,
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
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}`;
2
+ export type GamepadButtonName = "a" | "b" | "x" | "y" | "lb" | "rb" | "lt" | "rt" | "back" | "start" | "ls" | "rs" | "dpadUp" | "dpadDown" | "dpadLeft" | "dpadRight" | "home" | "touchpad" | `button-${number}`;
3
3
  export type StickAxisName = "leftX" | "leftY" | "rightX" | "rightY" | `axis-${number}`;
4
4
  export type DpadEvent = {
5
5
  type: "dpad";
@@ -34,8 +34,14 @@ export type GamepadInfo = {
34
34
  export type InfoEvent = GamepadInfo & {
35
35
  type: "info";
36
36
  };
37
+ export type StateEvent = {
38
+ type: "state";
39
+ pressed: GamepadButtonName[];
40
+ values: Partial<Record<GamepadButtonName, number>>;
41
+ axes: Partial<Record<StickAxisName, number>>;
42
+ };
37
43
  export type StatusEvent = {
38
44
  type: "status";
39
45
  state: "connected" | "disconnected";
40
46
  };
41
- export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent | InfoEvent;
47
+ export type GamepadMessage = DpadEvent | ButtonEvent | AxisEvent | StatusEvent | InfoEvent | StateEvent;
@@ -31,6 +31,13 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
31
31
  });
32
32
  const [vibrationRequest, setVibrationRequest] = (0, react_1.useState)(null);
33
33
  const vibrationNonce = (0, react_1.useRef)(0);
34
+ const resetState = (0, react_1.useCallback)(() => {
35
+ const cleared = new Set();
36
+ pressedRef.current = cleared;
37
+ setPressedButtons(cleared);
38
+ setButtonValues({});
39
+ setAxes({ leftX: 0, leftY: 0, rightX: 0, rightY: 0 });
40
+ }, []);
34
41
  (0, react_1.useEffect)(() => {
35
42
  pressedRef.current = pressedButtons;
36
43
  }, [pressedButtons]);
@@ -58,18 +65,30 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
58
65
  onDpad === null || onDpad === void 0 ? void 0 : onDpad(event);
59
66
  }, [onDpad]);
60
67
  const handleInfo = (0, react_1.useCallback)((event) => {
68
+ if (!event.connected) {
69
+ resetState();
70
+ }
61
71
  setInfo(event);
62
72
  onInfo === null || onInfo === void 0 ? void 0 : onInfo(event);
63
- }, [onInfo]);
73
+ }, [onInfo, resetState]);
74
+ const handleState = (0, react_1.useCallback)((event) => {
75
+ var _a, _b;
76
+ const next = new Set(event.pressed);
77
+ pressedRef.current = next;
78
+ setPressedButtons(next);
79
+ setButtonValues((_a = event.values) !== null && _a !== void 0 ? _a : {});
80
+ setAxes((_b = event.axes) !== null && _b !== void 0 ? _b : {});
81
+ }, []);
64
82
  const handleStatus = (0, react_1.useCallback)((event) => {
65
83
  if (event.state === "disconnected") {
84
+ resetState();
66
85
  setInfo((prev) => ({
67
86
  ...prev,
68
87
  connected: false,
69
88
  }));
70
89
  }
71
90
  onStatus === null || onStatus === void 0 ? void 0 : onStatus(event);
72
- }, [onStatus]);
91
+ }, [onStatus, resetState]);
73
92
  const vibrate = (0, react_1.useCallback)((duration = 800, strength = 1) => {
74
93
  vibrationNonce.current += 1;
75
94
  const safeStrength = Math.max(0, Math.min(strength, 1));
@@ -86,20 +105,15 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
86
105
  setVibrationRequest({ type: "stop", nonce: vibrationNonce.current });
87
106
  }, []);
88
107
  (0, react_1.useEffect)(() => {
89
- if (!enabled && pressedRef.current.size) {
90
- pressedRef.current = new Set();
91
- setPressedButtons(new Set());
92
- }
93
108
  if (!enabled) {
94
- setAxes({ leftX: 0, leftY: 0, rightX: 0, rightY: 0 });
95
- setButtonValues({});
109
+ resetState();
96
110
  setInfo((prev) => ({
97
111
  ...prev,
98
112
  connected: false,
99
113
  }));
100
114
  }
101
- }, [enabled]);
102
- 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: handleStatus, onInfo: handleInfo, vibrationRequest: vibrationRequest !== null && vibrationRequest !== void 0 ? vibrationRequest : undefined })), [
115
+ }, [enabled, resetState]);
116
+ 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: handleStatus, onInfo: handleInfo, onState: handleState, vibrationRequest: vibrationRequest !== null && vibrationRequest !== void 0 ? vibrationRequest : undefined })), [
103
117
  enabled,
104
118
  axisThreshold,
105
119
  handleAxis,
@@ -107,6 +121,7 @@ function useGamepad({ enabled = true, axisThreshold = 0.15, onButton, onAxis, on
107
121
  handleDpad,
108
122
  handleInfo,
109
123
  handleStatus,
124
+ handleState,
110
125
  vibrationRequest,
111
126
  ]);
112
127
  const isPressed = (0, react_1.useCallback)((key) => pressedRef.current.has(key), []);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-earl-gamepad",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "React Native gamepad bridge via WebView (buttons, sticks, d-pad, status).",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",