Skip to content

Commit 0359f20

Browse files
committed
feat: make it possible to change slider value with gamepad in ui
1 parent 4b77fe0 commit 0359f20

File tree

2 files changed

+157
-4
lines changed

2 files changed

+157
-4
lines changed

src/app/gamepadCursor.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { Command, contro } from '../controls'
22
import { hideAllModals, hideCurrentModal, miscUiState } from '../globalState'
33
import { gamepadUiCursorState, moveGamepadCursorByPx } from '../react/GamepadUiCursor'
44

5+
let lastHoveredElement: HTMLElement | null = null
6+
57
contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
68
if (gamepadIndex !== undefined && gamepadUiCursorState.display) {
79
const deadzone = 0.1 // TODO make deadzone configurable
@@ -11,6 +13,7 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
1113
moveGamepadCursorByPx(soleVector.x, true)
1214
moveGamepadCursorByPx(soleVector.z, false)
1315
emitMousemove()
16+
trackHoveredElement()
1417
}
1518
})
1619

@@ -26,7 +29,40 @@ const emitMousemove = () => {
2629
}))
2730
}
2831

29-
// Setup right stick scrolling for UI mode
32+
const trackHoveredElement = () => {
33+
const { x, y } = gamepadUiCursorState
34+
const xAbs = x / 100 * window.innerWidth
35+
const yAbs = y / 100 * window.innerHeight
36+
const element = document.elementFromPoint(xAbs, yAbs) as HTMLElement | null
37+
38+
if (element !== lastHoveredElement) {
39+
// Emit mouseout for previous element
40+
if (lastHoveredElement) {
41+
const mouseoutEvent = new MouseEvent('mouseout', {
42+
bubbles: true,
43+
clientX: xAbs,
44+
clientY: yAbs
45+
}) as any
46+
mouseoutEvent.isGamepadCursor = true
47+
lastHoveredElement.dispatchEvent(mouseoutEvent)
48+
}
49+
50+
// Emit mouseover for new element
51+
if (element) {
52+
const mouseoverEvent = new MouseEvent('mouseover', {
53+
bubbles: true,
54+
clientX: xAbs,
55+
clientY: yAbs
56+
}) as any
57+
mouseoverEvent.isGamepadCursor = true
58+
element.dispatchEvent(mouseoverEvent)
59+
}
60+
61+
lastHoveredElement = element
62+
}
63+
}
64+
65+
// Setup right stick scrolling and input value changing for UI mode
3066
contro.on('stickMovement', ({ stick, vector }) => {
3167
if (stick !== 'right') return
3268
if (!gamepadUiCursorState.display) return
@@ -35,13 +71,36 @@ contro.on('stickMovement', ({ stick, vector }) => {
3571
if (Math.abs(x) < 0.18) x = 0
3672
if (Math.abs(z) < 0.18) z = 0
3773

38-
if (z === 0) return // No vertical movement
74+
// Handle horizontal movement (for inputs)
75+
if (x !== 0) {
76+
emitGamepadInputChange(x, true)
77+
}
3978

40-
emulateGamepadScroll(z)
79+
// Handle vertical movement (for scrolling)
80+
if (z !== 0) {
81+
emulateGamepadScroll(z)
82+
}
4183

4284
miscUiState.usingGamepadInput = true
4385
})
4486

87+
// todo make also control with left/right
88+
89+
const emitGamepadInputChange = (x: number, isStickMovement: boolean) => {
90+
const cursorX = gamepadUiCursorState.x / 100 * window.innerWidth
91+
const cursorY = gamepadUiCursorState.y / 100 * window.innerHeight
92+
const element = document.elementFromPoint(cursorX, cursorY) as HTMLElement | null
93+
94+
if (element) {
95+
// Emit custom event for input value change
96+
const customEvent = new CustomEvent('gamepadInputChange', {
97+
bubbles: true,
98+
detail: { direction: x > 0 ? 1 : -1, value: x, isStickMovement },
99+
})
100+
element.dispatchEvent(customEvent)
101+
}
102+
}
103+
45104
const emulateGamepadScroll = (z: number) => {
46105
// Get element under cursor
47106
const cursorX = gamepadUiCursorState.x / 100 * window.innerWidth

src/react/Slider.tsx

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Slider.tsx
22
import React, { useState, useEffect, useRef, useCallback } from 'react'
3+
import { useFloating, arrow, FloatingArrow, offset as offsetMiddleware } from '@floating-ui/react'
34
import styles from './slider.module.css'
45
import SharedHudVars from './SharedHudVars'
56

@@ -18,6 +19,9 @@ interface Props extends React.ComponentProps<'div'> {
1819
updateOnDragEnd?: boolean;
1920
}
2021

22+
const ARROW_HEIGHT = 7
23+
const GAP = 0
24+
2125
const Slider: React.FC<Props> = ({
2226
label,
2327
unit = '%',
@@ -45,6 +49,11 @@ const Slider: React.FC<Props> = ({
4549
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
4650
const lastValueRef = useRef<number>(valueProp)
4751

52+
// Gamepad support
53+
const [showGamepadTooltip, setShowGamepadTooltip] = useState(false)
54+
const lastChangeTime = useRef(0)
55+
const containerRef = useRef<HTMLDivElement>(null!)
56+
4857
useEffect(() => {
4958
setValue(valueProp)
5059
}, [valueProp])
@@ -89,15 +98,80 @@ const Slider: React.FC<Props> = ({
8998
}
9099
}, [updateValue])
91100

101+
// Handle gamepad hover and input changes
102+
useEffect(() => {
103+
const element = containerRef.current
104+
if (!element) return
105+
106+
const handleMouseOver = (e: MouseEvent & { isGamepadCursor?: boolean }) => {
107+
if (e.isGamepadCursor && !disabledReason) {
108+
setShowGamepadTooltip(true)
109+
}
110+
}
111+
112+
const handleMouseOut = (e: MouseEvent & { isGamepadCursor?: boolean }) => {
113+
if (e.isGamepadCursor) {
114+
setShowGamepadTooltip(false)
115+
}
116+
}
117+
118+
const handleGamepadInputChange = (e: CustomEvent<{ direction: number, value: number, isStickMovement: boolean }>) => {
119+
if (disabledReason) return
120+
121+
const now = Date.now()
122+
// Throttle changes to prevent too rapid updates
123+
if (now - lastChangeTime.current < 200 && e.detail.isStickMovement) return
124+
lastChangeTime.current = now
125+
126+
const step = 1
127+
const newValue = value + (e.detail.direction * step)
128+
129+
// Apply min/max constraints
130+
const constrainedValue = Math.max(min, Math.min(max, newValue))
131+
132+
setValue(constrainedValue)
133+
fireValueUpdate(false, constrainedValue)
134+
}
135+
136+
element.addEventListener('mouseover', handleMouseOver as EventListener)
137+
element.addEventListener('mouseout', handleMouseOut as EventListener)
138+
element.addEventListener('gamepadInputChange', handleGamepadInputChange as EventListener)
139+
140+
return () => {
141+
element.removeEventListener('mouseover', handleMouseOver as EventListener)
142+
element.removeEventListener('mouseout', handleMouseOut as EventListener)
143+
element.removeEventListener('gamepadInputChange', handleGamepadInputChange as EventListener)
144+
}
145+
}, [disabledReason, value, min, max])
146+
92147
const fireValueUpdate = (dragEnd: boolean, v = value) => {
93148
throttledUpdateValue(v, dragEnd)
94149
}
95150

96151
const labelText = `${label}: ${valueDisplay ?? value} ${unit}`
97152

153+
const arrowRef = useRef<any>(null)
154+
const { refs, floatingStyles, context } = useFloating({
155+
middleware: [
156+
arrow({
157+
element: arrowRef
158+
}),
159+
offsetMiddleware(ARROW_HEIGHT + GAP),
160+
],
161+
placement: 'top',
162+
})
163+
98164
return (
99165
<SharedHudVars>
100-
<div className={`${styles['slider-container']} settings-text-container ${labelText.length > 17 ? 'settings-text-container-long' : ''}`} style={{ width }} {...divProps}>
166+
<div
167+
ref={(node) => {
168+
containerRef.current = node!
169+
refs.setReference(node)
170+
}}
171+
className={`${styles['slider-container']} settings-text-container ${labelText.length > 17 ? 'settings-text-container-long' : ''}`}
172+
style={{ width }}
173+
{...divProps}
174+
>
101175
<input
102176
type="range"
103177
className={styles.slider}
@@ -127,6 +201,26 @@ const Slider: React.FC<Props> = ({
127201
{labelText}
128202
</label>
129203
</div>
204+
{showGamepadTooltip && (
205+
<div
206+
ref={refs.setFloating}
207+
style={{
208+
...floatingStyles,
209+
background: 'rgba(0, 0, 0, 0.8)',
210+
fontSize: 10,
211+
pointerEvents: 'none',
212+
userSelect: 'none',
213+
padding: '4px 8px',
214+
borderRadius: 4,
215+
textShadow: '1px 1px 2px BLACK',
216+
zIndex: 1000,
217+
whiteSpace: 'nowrap'
218+
}}
219+
>
220+
Use right stick left/right to change value
221+
<FloatingArrow ref={arrowRef} context={context} style={{ fill: 'rgba(0, 0, 0, 0.8)' }} />
222+
</div>
223+
)}
130224
</SharedHudVars>
131225
)
132226
}

0 commit comments

Comments
 (0)