Skip to content

Commit 5dea801

Browse files
sshbioclaude
andcommitted
Add gamepad controller support for physical match control
Introduces a GamepadController provider that maps PS5 DualSense (and compatible) gamepad buttons to game commands (HALT, STOP, kick-offs, free kicks, timeouts, continue actions, auto-continue toggle). Adds a GamepadStatus toolbar component showing connection state, active button, and a button-to-action reference tooltip. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e83a7d0 commit 5dea801

File tree

4 files changed

+362
-0
lines changed

4 files changed

+362
-0
lines changed

frontend/src/App.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import ProtocolList from "@/components/protocol/ProtocolList.vue";
99
import {useQuasar} from "quasar";
1010
import {useUiStateStore} from "@/store/uiState";
1111
import {useProtocolStore} from "@/store/protocolState";
12+
import GamepadStatus from "@/components/GamepadStatus.vue";
1213
1314
const uiStore = useUiStateStore()
1415
const protocolStore = useProtocolStore()
@@ -89,6 +90,7 @@ const dev = computed(() => {
8990
<q-toggle dense flat round class="q-mx-sm" @click="toggleShortcuts" :model-value="showShortcuts" color="black">
9091
Show Shortcuts
9192
</q-toggle>
93+
<GamepadStatus class="q-mx-xs"/>
9294
<StatusMessageButton/>
9395
<q-btn dense flat round icon="menu" @click="toggleRightDrawer"/>
9496
</q-toolbar>
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
<script setup lang="ts">
2+
import {computed, inject} from 'vue'
3+
import type {GamepadController} from "@/providers/gamepadController";
4+
import {GAMEPAD_BUTTON_ACTIONS, GAMEPAD_BUTTON_LABELS} from "@/providers/gamepadController";
5+
6+
const gamepadController = inject<GamepadController>('gamepad-controller')!
7+
8+
const connected = computed(() => gamepadController.state.connected)
9+
const gamepadId = computed(() => {
10+
// Shorten the raw gamepad id for display
11+
const id = gamepadController.state.gamepadId
12+
if (!id) return ''
13+
// Try to extract a human-friendly name from strings like:
14+
// "DualSense Wireless Controller (STANDARD GAMEPAD Vendor: 054c Product: 0ce6)"
15+
const parenIdx = id.indexOf('(')
16+
return parenIdx > 0 ? id.slice(0, parenIdx).trim() : id
17+
})
18+
19+
const activeButton = computed(() => gamepadController.state.activeButton)
20+
const activeButtonLabel = computed(() => {
21+
const b = activeButton.value
22+
if (b === null) return null
23+
return GAMEPAD_BUTTON_LABELS[b] ?? `Button ${b}`
24+
})
25+
const activeActionLabel = computed(() => {
26+
const b = activeButton.value
27+
if (b === null) return null
28+
return GAMEPAD_BUTTON_ACTIONS[b] ?? null
29+
})
30+
31+
// Gamepad reference button layout for the tooltip
32+
const buttonMap = Object.entries(GAMEPAD_BUTTON_LABELS)
33+
.filter(([idx]) => GAMEPAD_BUTTON_ACTIONS[Number(idx)] !== undefined)
34+
.map(([idx, label]) => ({
35+
button: label,
36+
action: GAMEPAD_BUTTON_ACTIONS[Number(idx)],
37+
}))
38+
</script>
39+
40+
<template>
41+
<!-- Gamepad icon shown in the toolbar -->
42+
<q-btn
43+
dense flat round
44+
:icon="connected ? 'sports_esports' : 'videogame_asset_off'"
45+
:color="connected ? 'white' : 'grey-5'"
46+
:title="connected ? `Connected: ${gamepadId}` : 'No gamepad connected'"
47+
>
48+
<!-- Active button flash badge -->
49+
<q-badge v-if="activeButton !== null" floating color="amber" text-color="black">
50+
{{ activeButtonLabel }}
51+
</q-badge>
52+
53+
<!-- Tooltip with full button map -->
54+
<q-tooltip anchor="bottom right" self="top right" :offset="[0, 8]" max-width="360px">
55+
<div class="text-subtitle2 q-mb-xs">
56+
<q-icon name="sports_esports" class="q-mr-xs"/>
57+
Gamepad Controls
58+
</div>
59+
60+
<div v-if="!connected" class="text-caption text-grey-4">
61+
Connect a PS5 DualSense or compatible gamepad to use physical controls.
62+
</div>
63+
64+
<template v-else>
65+
<div class="text-caption text-grey-3 q-mb-sm">{{ gamepadId }}</div>
66+
67+
<q-markup-table dense flat dark class="gamepad-table">
68+
<thead>
69+
<tr>
70+
<th class="text-left">Button</th>
71+
<th class="text-left">Action</th>
72+
</tr>
73+
</thead>
74+
<tbody>
75+
<tr
76+
v-for="entry in buttonMap"
77+
:key="entry.button"
78+
:class="{'text-amber': activeButtonLabel === entry.button}"
79+
>
80+
<td>
81+
<q-chip dense size="sm" color="grey-8" text-color="white">{{ entry.button }}</q-chip>
82+
</td>
83+
<td class="text-caption">{{ entry.action }}</td>
84+
</tr>
85+
</tbody>
86+
</q-markup-table>
87+
</template>
88+
</q-tooltip>
89+
</q-btn>
90+
91+
<!-- Live action indicator when a button is pressed -->
92+
<transition name="fade">
93+
<q-chip
94+
v-if="activeButton !== null && activeActionLabel"
95+
dense
96+
color="amber"
97+
text-color="black"
98+
icon="sports_esports"
99+
class="q-mx-xs"
100+
>
101+
{{ activeActionLabel }}
102+
</q-chip>
103+
</transition>
104+
</template>
105+
106+
<style scoped>
107+
.gamepad-table {
108+
min-width: 280px;
109+
}
110+
111+
.fade-enter-active,
112+
.fade-leave-active {
113+
transition: opacity 0.15s ease;
114+
}
115+
116+
.fade-enter-from,
117+
.fade-leave-to {
118+
opacity: 0;
119+
}
120+
</style>

frontend/src/plugins/control/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ import type {App} from "vue";
66
import {useProtocolStore} from "@/store/protocolState";
77
import {ManualActions} from "@/providers/manualActions";
88
import {Shortcuts} from "@/providers/shortcuts";
9+
import {GamepadController} from "@/providers/gamepadController";
910

1011
export const control = {
1112
install(app: App) {
1213
const controlApi = new ControlApi()
1314
const manualActions = new ManualActions(controlApi)
1415
const shortcuts = new Shortcuts(manualActions, controlApi)
16+
const gamepadController = new GamepadController(manualActions, controlApi)
1517
app.provide('control-api', controlApi)
1618
app.provide('command-actions', manualActions)
1719
app.provide('shortcuts', shortcuts)
20+
app.provide('gamepad-controller', gamepadController)
1821

1922
const matchStateStore = useMatchStateStore()
2023
controlApi.RegisterConsumer((output: OutputJson) => {
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import {reactive} from 'vue'
2+
import type {ManualActions} from "@/providers/manualActions";
3+
import type {ControlApi} from "@/providers/controlApi";
4+
import {useGcStateStore} from "@/store/gcState";
5+
6+
// Standard PS5 DualSense / generic gamepad button labels
7+
export const GAMEPAD_BUTTON_LABELS: Record<number, string> = {
8+
0: 'Cross (×)',
9+
1: 'Circle (○)',
10+
2: 'Square (□)',
11+
3: 'Triangle (△)',
12+
4: 'L1',
13+
5: 'R1',
14+
6: 'L2',
15+
7: 'R2',
16+
8: 'Create',
17+
9: 'Options',
18+
10: 'L3',
19+
11: 'R3',
20+
12: 'D-Pad ↑',
21+
13: 'D-Pad ↓',
22+
14: 'D-Pad ←',
23+
15: 'D-Pad →',
24+
16: 'PS',
25+
17: 'Touchpad',
26+
}
27+
28+
// What action each button triggers (for display in the UI)
29+
export const GAMEPAD_BUTTON_ACTIONS: Record<number, string> = {
30+
0: 'STOP',
31+
1: 'HALT',
32+
2: 'Force Start',
33+
3: 'Normal Start',
34+
4: 'Kick-off Yellow',
35+
5: 'Kick-off Blue',
36+
6: 'Direct Yellow',
37+
7: 'Direct Blue',
38+
8: 'Toggle Auto-Continue',
39+
9: 'Continue (action 1)',
40+
10: 'Timeout Yellow',
41+
11: 'Timeout Blue',
42+
12: 'Continue (action 2)',
43+
13: 'Continue (action 3)',
44+
14: 'Continue (action 4)',
45+
15: 'Continue (action 5)',
46+
}
47+
48+
export interface GamepadControllerState {
49+
connected: boolean
50+
gamepadId: string
51+
activeButton: number | null
52+
}
53+
54+
export class GamepadController {
55+
private readonly manualActions: ManualActions
56+
private readonly controlApi: ControlApi
57+
private readonly gcStateStore = useGcStateStore()
58+
private enabled: boolean = true
59+
private animationFrameId: number | null = null
60+
private previousButtonStates: boolean[] = []
61+
private currentGamepadIndex: number | null = null
62+
63+
public readonly state = reactive<GamepadControllerState>({
64+
connected: false,
65+
gamepadId: '',
66+
activeButton: null,
67+
})
68+
69+
constructor(manualActions: ManualActions, controlApi: ControlApi) {
70+
this.manualActions = manualActions
71+
this.controlApi = controlApi
72+
this.init()
73+
}
74+
75+
public enable() {
76+
this.enabled = true
77+
}
78+
79+
public disable() {
80+
this.enabled = false
81+
}
82+
83+
public destroy() {
84+
this.stopPolling()
85+
}
86+
87+
private init() {
88+
window.addEventListener('gamepadconnected', (e: GamepadEvent) => {
89+
this.onGamepadConnected(e.gamepad)
90+
})
91+
window.addEventListener('gamepaddisconnected', (e: GamepadEvent) => {
92+
this.onGamepadDisconnected(e.gamepad)
93+
})
94+
// Chrome requires user interaction before dispatching gamepadconnected.
95+
// Poll once at startup to detect gamepads already plugged in.
96+
this.checkExistingGamepads()
97+
}
98+
99+
private checkExistingGamepads() {
100+
const gamepads = navigator.getGamepads()
101+
for (const gamepad of gamepads) {
102+
if (gamepad) {
103+
this.onGamepadConnected(gamepad)
104+
break
105+
}
106+
}
107+
}
108+
109+
private onGamepadConnected(gamepad: Gamepad) {
110+
this.currentGamepadIndex = gamepad.index
111+
this.state.connected = true
112+
this.state.gamepadId = gamepad.id
113+
this.previousButtonStates = new Array(gamepad.buttons.length).fill(false)
114+
this.startPolling()
115+
}
116+
117+
private onGamepadDisconnected(gamepad: Gamepad) {
118+
if (gamepad.index === this.currentGamepadIndex) {
119+
this.currentGamepadIndex = null
120+
this.state.connected = false
121+
this.state.gamepadId = ''
122+
this.state.activeButton = null
123+
this.stopPolling()
124+
}
125+
}
126+
127+
private startPolling() {
128+
if (this.animationFrameId !== null) return
129+
const poll = () => {
130+
this.pollGamepad()
131+
this.animationFrameId = requestAnimationFrame(poll)
132+
}
133+
this.animationFrameId = requestAnimationFrame(poll)
134+
}
135+
136+
private stopPolling() {
137+
if (this.animationFrameId !== null) {
138+
cancelAnimationFrame(this.animationFrameId)
139+
this.animationFrameId = null
140+
}
141+
}
142+
143+
private pollGamepad() {
144+
if (this.currentGamepadIndex === null) return
145+
const gamepads = navigator.getGamepads()
146+
const gamepad = gamepads[this.currentGamepadIndex]
147+
if (!gamepad) return
148+
149+
gamepad.buttons.forEach((button, index) => {
150+
const wasPressed = this.previousButtonStates[index] ?? false
151+
const isPressed = button.pressed
152+
153+
if (isPressed && !wasPressed) {
154+
// Rising edge — button just pressed
155+
this.state.activeButton = index
156+
if (this.enabled) {
157+
this.handleButtonPress(index)
158+
}
159+
} else if (!isPressed && wasPressed) {
160+
if (this.state.activeButton === index) {
161+
this.state.activeButton = null
162+
}
163+
}
164+
165+
this.previousButtonStates[index] = isPressed
166+
})
167+
}
168+
169+
private handleButtonPress(buttonIndex: number) {
170+
switch (buttonIndex) {
171+
// Face buttons
172+
case 0: // Cross (×) → STOP
173+
this.manualActions.getCommandAction('STOP').send()
174+
break
175+
case 1: // Circle (○) → HALT
176+
this.manualActions.getCommandAction('HALT').send()
177+
break
178+
case 2: // Square (□) → Force Start
179+
this.manualActions.getCommandAction('FORCE_START').send()
180+
break
181+
case 3: // Triangle (△) → Normal Start
182+
this.manualActions.getCommandAction('NORMAL_START').send()
183+
break
184+
185+
// Shoulder buttons
186+
case 4: // L1 → Kick-off Yellow
187+
this.manualActions.getCommandAction('KICKOFF', 'YELLOW').send()
188+
break
189+
case 5: // R1 → Kick-off Blue
190+
this.manualActions.getCommandAction('KICKOFF', 'BLUE').send()
191+
break
192+
case 6: // L2 → Direct Free Kick Yellow
193+
this.manualActions.getCommandAction('DIRECT', 'YELLOW').send()
194+
break
195+
case 7: // R2 → Direct Free Kick Blue
196+
this.manualActions.getCommandAction('DIRECT', 'BLUE').send()
197+
break
198+
199+
// Center buttons
200+
case 8: // Create/Share → Toggle Auto-Continue
201+
this.controlApi.ChangeConfig({autoContinue: !this.gcStateStore.config.autoContinue})
202+
break
203+
case 9: // Options → Continue with first available action
204+
this.continueWithAction(0)
205+
break
206+
207+
// Stick clicks
208+
case 10: // L3 → Timeout Yellow
209+
this.manualActions.getCommandAction('TIMEOUT', 'YELLOW').send()
210+
break
211+
case 11: // R3 → Timeout Blue
212+
this.manualActions.getCommandAction('TIMEOUT', 'BLUE').send()
213+
break
214+
215+
// D-Pad → cycle through continue actions
216+
case 12: // D-Pad Up → Continue action 2
217+
this.continueWithAction(1)
218+
break
219+
case 13: // D-Pad Down → Continue action 3
220+
this.continueWithAction(2)
221+
break
222+
case 14: // D-Pad Left → Continue action 4
223+
this.continueWithAction(3)
224+
break
225+
case 15: // D-Pad Right → Continue action 5
226+
this.continueWithAction(4)
227+
break
228+
}
229+
}
230+
231+
private continueWithAction(id: number) {
232+
const actions = this.gcStateStore.gcState.continueActions
233+
if (actions && actions.length > id) {
234+
this.controlApi.Continue(actions[id])
235+
}
236+
}
237+
}

0 commit comments

Comments
 (0)