|
| 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