├── nui ├── assets │ ├── images │ │ └── wheel.png │ ├── css │ │ └── style.css │ └── js │ │ └── main.js └── index.html ├── fxmanifest.lua ├── README.md └── client.lua /nui/assets/images/wheel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PickleModifications/pickle_wheel/HEAD/nui/assets/images/wheel.png -------------------------------------------------------------------------------- /fxmanifest.lua: -------------------------------------------------------------------------------- 1 | fx_version 'cerulean' 2 | lua54 'yes' 3 | game 'gta5' 4 | 5 | name 'pickle_wheel' 6 | version '1.0.0' 7 | description 'Wheel & Pedal Support for FiveM.' 8 | author 'Pickle Mods' 9 | 10 | ui_page "nui/index.html" 11 | 12 | files { 13 | "nui/index.html", 14 | "nui/assets/**/*.*", 15 | } 16 | 17 | client_scripts { 18 | 'client.lua', 19 | } -------------------------------------------------------------------------------- /nui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pickle's Wheel 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |

Pickle's Wheel Support

14 |
15 | 16 | 17 | 18 |
19 |
x
20 |
21 |
22 |
23 |
24 |
SAVE SETTINGS
25 |
26 |
27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

More Information & Scripts can be found here!

3 | 4 | ## Preview 5 | 6 | https://www.youtube.com/watch?v=rq9f10d4wOk 7 | 8 | ## What is this? 9 | 10 |

This is a fully standalone script that allows players to use their wheel & pedals in FiveM without needing to use Scripthook or controller emulation.

11 | 12 | With this resource, you will be able to do the following: 13 | 14 | - Wheel & Pedal Support. 15 | - Wheel, Pedal, and Controls Display. 16 | - Works with any wheel / pedal combo, and allows you to choose your device. 17 | - Change Input Indexes. 18 | - Modify Wheel Deadzone. 19 | - Bind both Controller and Command Actions. 20 | - Locally saved settings. 21 | - Completely standalone, just plug-and-play! 22 | 23 | ## What do I need? 24 | 25 | Nothing at all! 26 | Just add the resource to the server, make sure the resource is started as "pickle_wheel", and have fun! 27 | 28 | ## Basic Usage 29 | 30 | To pull up the wheel panel, use "/wheel" in the chat. 31 | You'll need to select your device from the dropdown, and save the settings after you've tuned it to your liking. 32 | 33 | ## Need Support? 34 | 35 | Click here! 36 | 37 | ## Credits: 38 | 39 | [Spendibus: Wheel & Pedal Gamepad API Snippet](http://spenibus.net/b/p/F/PC-steering-wheel-viewer-prototype-in-html-javascript) 40 | 41 | ## Ready to download? 42 | 43 | Enjoy! 44 | 45 | https://github.com/PickleModifications/pickle_wheel/releases 46 | -------------------------------------------------------------------------------- /client.lua: -------------------------------------------------------------------------------- 1 | local Controls = {} 2 | local DeviceSettings = {} 3 | local PressedBinds = {} 4 | 5 | local lastUpdate = 0 6 | 7 | RegisterNUICallback("updateInput", function(data, cb) 8 | for k,v in pairs(data) do 9 | local value = v 10 | if value > 0.9999 then 11 | value = 0.9999 12 | elseif value < -0.9999 then 13 | value = -0.9999 14 | end 15 | if k == "wheelAxis" then 16 | if value > 0 then 17 | value = value + DeviceSettings.wheelDeadzone 18 | elseif value < 0 then 19 | value = value - DeviceSettings.wheelDeadzone 20 | end 21 | end 22 | Controls[k] = value 23 | end 24 | cb(true) 25 | end) 26 | 27 | RegisterNUICallback("buttonUpdate", function(data, cb) 28 | ButtonInteract(data.index, data.pressed) 29 | cb(true) 30 | end) 31 | 32 | RegisterNUICallback("updateSettings", function(data, cb) 33 | UpdateSettings(data) 34 | cb(true) 35 | end) 36 | 37 | RegisterNUICallback("close", function(data, cb) 38 | SetNuiFocus(false, false) 39 | cb(true) 40 | end) 41 | 42 | function ButtonInteract(index, pressed) 43 | local bind = DeviceSettings.binds[index .. ""] 44 | if bind then 45 | if bind.type == "control" and bind.values[2] ~= nil then 46 | if pressed then 47 | local startTime = GetGameTimer() 48 | PressedBinds[index] = true 49 | CreateThread(function() 50 | local key = tonumber(bind.values[2]) 51 | while lastUpdate < startTime and PressedBinds[index] do 52 | SetControlNormal(0, key, 1.0) 53 | SetControlNormal(1, key, 1.0) 54 | SetControlNormal(2, key, 1.0) 55 | Wait(0) 56 | end 57 | end) 58 | else 59 | PressedBinds[index] = false 60 | end 61 | elseif bind.type == "command" then 62 | if pressed and bind.values[1] ~= nil then 63 | PressedBinds[index] = true 64 | ExecuteCommand(bind.values[1]) 65 | elseif not pressed and bind.values[2] ~= nil then 66 | PressedBinds[index] = false 67 | ExecuteCommand(bind.values[2]) 68 | end 69 | end 70 | end 71 | end 72 | 73 | function UpdateSettings(data) 74 | lastUpdate = GetGameTimer() 75 | DeviceSettings = data 76 | SetResourceKvp("picklewheel", json.encode(DeviceSettings)) 77 | end 78 | 79 | RegisterCommand("wheel", function() 80 | SetNuiFocus(true, true) 81 | SendNUIMessage({ 82 | type = "open" 83 | }) 84 | end) 85 | 86 | CreateThread(function() 87 | Wait(1000) 88 | DeviceSettings = json.decode(GetResourceKvpString("picklewheel")) 89 | SendNUIMessage({ 90 | type = "updateSettings", 91 | data = DeviceSettings 92 | }) 93 | while true do 94 | SetControlNormal(0, 59, Controls.wheelAxis or 0.0) 95 | SetControlNormal(0, 71, Controls.throttleAxis or 0.0) 96 | SetControlNormal(0, 72, Controls.brakeAxis or 0.0) 97 | -- SetControlNormal(0, 59, Controls.axisValueClutch or 0.0) 98 | Wait(0) 99 | end 100 | end) -------------------------------------------------------------------------------- /nui/assets/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color:transparent; 3 | margin:0; 4 | padding:0; 5 | color: white; 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | #container { 10 | display: flex; 11 | opacity: 0; 12 | flex-direction: column; 13 | position: absolute; 14 | top: 50%; 15 | left: 50%; 16 | transform: translate(-50%, -50%); 17 | background-color: rgba(0, 0, 0, 0.5); 18 | width: 50vw; 19 | height: 30vw; 20 | } 21 | 22 | #container > div { 23 | display: flex; 24 | flex-direction: column; 25 | width: 100%; 26 | } 27 | 28 | .wheel-display { 29 | height: 5vw; 30 | } 31 | 32 | #wheel { 33 | display:inline-block; 34 | vertical-align:bottom; 35 | height: 100%; 36 | transform-origin:center center; 37 | margin-right:1vw; 38 | } 39 | 40 | .pedal { 41 | display:inline-block; 42 | vertical-align:bottom; 43 | width:1vw; 44 | height: 100%; 45 | margin-left: 0.25vw; 46 | margin-right:0.25vw; 47 | background-color: rgba(0, 0, 0, 0.5); 48 | } 49 | .pedal > span { 50 | display:inline-block; 51 | width:100%; 52 | height:100%; 53 | background-color:rgba(0, 0, 0, 0.5); 54 | vertical-align:top; 55 | } 56 | 57 | .top { 58 | align-items: center; 59 | padding-bottom: 1vw; 60 | } 61 | 62 | .middle { 63 | overflow-y: scroll; 64 | } 65 | 66 | ::-webkit-scrollbar { 67 | width: 0.5vw; 68 | } 69 | 70 | /* Track */ 71 | ::-webkit-scrollbar-track { 72 | background: transparent; 73 | } 74 | 75 | /* Handle */ 76 | ::-webkit-scrollbar-thumb { 77 | background: #484848; 78 | } 79 | 80 | /* Handle on hover */ 81 | ::-webkit-scrollbar-thumb:hover { 82 | background: #2b2b2b; 83 | } 84 | 85 | .middle > h3, .middle > span { 86 | align-self: center; 87 | width: 50%; 88 | } 89 | 90 | .middle > span { 91 | margin-left: 4vw; 92 | margin-bottom: 1.15vw; 93 | } 94 | 95 | .middle > div { 96 | margin-left: 4vw; 97 | } 98 | 99 | .option { 100 | display: flex; 101 | align-self: center; 102 | width: 50%; 103 | margin-top: 0.1vw; 104 | margin-bottom: 0.1vw; 105 | } 106 | 107 | .option > div:first-child { 108 | display: flex; 109 | } 110 | 111 | .option > div > b { 112 | align-self: center; 113 | } 114 | 115 | .option > div:last-child { 116 | margin-left: auto; 117 | } 118 | 119 | .bottom { 120 | align-items: center; 121 | margin-top: auto; 122 | padding-top: 1vw; 123 | margin-bottom: 1vw; 124 | } 125 | 126 | #throttle { 127 | background-color:#0A0; 128 | } 129 | #brake { 130 | background-color:#C00; 131 | } 132 | 133 | h1 { 134 | font-size: 1.25vw; 135 | } 136 | 137 | h3 { 138 | font-size: 1.0vw; 139 | } 140 | 141 | input { 142 | font-size: 0.5vw; 143 | width: 4.5vw; 144 | } 145 | 146 | select { 147 | width: 5vw; 148 | } 149 | 150 | .pressed > div > b { 151 | color: red; 152 | } 153 | 154 | .button { 155 | display: flex; 156 | justify-content: center; 157 | align-items: center; 158 | width: 25vw; 159 | height: 2vw; 160 | font-size: 1.15vw; 161 | } 162 | 163 | .button.success { 164 | cursor: pointer; 165 | background-color: rgb(0, 100, 0); 166 | transition: 0.25s; 167 | } 168 | 169 | .button.success:hover { 170 | background-color: rgb(0, 200, 0); 171 | } 172 | 173 | #close { 174 | display: flex; 175 | justify-content: center; 176 | align-items: center; 177 | cursor: pointer; 178 | position: absolute; 179 | top: 0; 180 | right: 0; 181 | font-size: 1vw; 182 | width: 1.5vw; 183 | height: 1.5vw; 184 | background-color: rgb(100, 0, 0); 185 | color: white; 186 | transition: 0.25s; 187 | } 188 | 189 | #close:hover { 190 | background-color: red; 191 | } -------------------------------------------------------------------------------- /nui/assets/js/main.js: -------------------------------------------------------------------------------- 1 | let device; 2 | let dataDisplay; 3 | 4 | let elemWheel; 5 | let elemThrottle; 6 | let elemBrake; 7 | 8 | let loopRunning = false; 9 | let deviceFound = false; 10 | let deviceIndex; 11 | let deviceSettings; 12 | 13 | function updateSettings(data, forceUpdate) { 14 | var data = data || { 15 | inputs: {}, 16 | binds: {} 17 | } 18 | if (!forceUpdate) { 19 | deviceSettings = { 20 | deviceIndex: data.inputs[0] || "null", 21 | wheelDeadzone: data.inputs[1] || 0.25, 22 | wheelRadius: data.inputs[2] || 900, 23 | wheelIndex: data.inputs[3] || 0, 24 | throttleIndex: data.inputs[4] || 1, 25 | brakeIndex: data.inputs[5] || 2, 26 | binds: data.binds 27 | } 28 | fetch('https://pickle_wheel/updateSettings', { 29 | method: 'post', 30 | headers: { 31 | 'Accept': 'application/json', 32 | 'Content-Type': 'application/json' 33 | }, 34 | body: JSON.stringify(deviceSettings) 35 | }) 36 | .then(response => { }) 37 | .catch(error => { }); 38 | } 39 | else { 40 | deviceSettings = data; 41 | if (deviceIndex != deviceSettings.deviceIndex) { 42 | deviceIndex = deviceSettings.deviceIndex 43 | if (deviceIndex != "null") { 44 | initApp(deviceIndex) 45 | } 46 | } 47 | } 48 | } 49 | 50 | function safeValue(value) { 51 | if (value) { 52 | return value 53 | } 54 | else { 55 | return null 56 | } 57 | } 58 | 59 | function bindHtml(type, index, val1, val2) { 60 | let bind; 61 | if (type == "control") { 62 | bind = ` 63 |
64 | Button #${index} 65 |
66 |
67 | 71 | 72 | 73 |
74 | ` 75 | } 76 | else if (type == "command") { 77 | bind = ` 78 |
79 | Button #${index} 80 |
81 |
82 | 86 | 87 | 88 |
89 | ` 90 | } 91 | return bind; 92 | } 93 | 94 | function initApp(index) { 95 | var device; 96 | var deviceOptions = ``; 97 | let gps = navigator.getGamepads(); 98 | for(let gp of gps) { 99 | if (gp) { 100 | if (gp.index == index) { 101 | device = gp 102 | deviceOptions += `` 103 | } 104 | else { 105 | deviceOptions += `` 106 | } 107 | } 108 | } 109 | if (!device || !deviceSettings) { 110 | deviceIndex = null; 111 | $("#middle").html(` 112 |

Device

113 |
114 |
115 | Select Device 116 |
117 |
118 | 121 |
122 |
`) 123 | return; 124 | } 125 | var html = ` 126 |

Device

127 |
128 |
129 | Select Device 130 |
131 |
132 | 135 |
136 |
137 |
138 |
139 | Wheel Deadzone 140 |
141 |
142 | 143 |
144 |
145 |
146 |
147 | Wheel Radius 148 |
149 |
150 | 151 |
152 |
153 |
154 |
155 | Wheel Input Index 156 |
157 |
158 | 159 |
160 |
161 |
162 |
163 | Throttle Input Index 164 |
165 |
166 | 167 |
168 |
169 |
170 |
171 | Brake Input Index 172 |
173 |
174 | 175 |
176 |
177 | 178 |

Binding

179 | ` 180 | for (var i=0; i 190 | ${optionHTML} 191 | 192 | ` 193 | html += bind; 194 | } 195 | $(".middle").html(html); 196 | deviceIndex = index 197 | gameLoop(index) 198 | } 199 | 200 | function gameLoop(index) { 201 | let gps = navigator.getGamepads(); 202 | for(let gp of gps) { 203 | if(gp && gp.index == index) { 204 | device = gp; 205 | break; 206 | } 207 | } 208 | if(device && index == deviceIndex) { 209 | let wheelAxis = device.axes[deviceSettings.wheelIndex]; 210 | let throttleAxis = device.axes[deviceSettings.throttleIndex]; 211 | let brakeAxis = device.axes[deviceSettings.brakeIndex]; 212 | 213 | fetch('https://pickle_wheel/updateInput', { 214 | method: 'post', 215 | headers: { 216 | 'Accept': 'application/json', 217 | 'Content-Type': 'application/json' 218 | }, 219 | body: JSON.stringify({ 220 | wheelAxis: wheelAxis, 221 | throttleAxis: (throttleAxis * -1) + 1, 222 | brakeAxis: (brakeAxis * -1) + 1 223 | }) 224 | }) 225 | .then(response => { }) 226 | .catch(error => { }); 227 | 228 | // convert wheel axis to angle 229 | wheelAxis = Math.round(deviceSettings.wheelRadius / 2 * wheelAxis * 1000) / 1000; 230 | 231 | // normalize pedals axes to range 0-1 232 | throttleAxis = (throttleAxis * -1 + 1) / 2; 233 | brakeAxis = (brakeAxis * -1 + 1) / 2; 234 | 235 | elemWheel.style.transform = 'rotate('+wheelAxis+'deg)'; 236 | 237 | elemThrottle.style.height = (100 - throttleAxis * 100)+'%'; 238 | elemBrake.style.height = (100 - brakeAxis * 100)+'%'; 239 | 240 | for (var i=0; i { }) 255 | .catch(error => { }); 256 | } 257 | else if (!device.buttons[i].pressed && $("#button-" + i).hasClass("pressed")) { 258 | $("#button-" + i).removeClass("pressed"); 259 | fetch('https://pickle_wheel/buttonUpdate', { 260 | method: 'post', 261 | headers: { 262 | 'Accept': 'application/json', 263 | 'Content-Type': 'application/json' 264 | }, 265 | body: JSON.stringify({ 266 | index: i, 267 | pressed: false 268 | }) 269 | }) 270 | .then(response => { }) 271 | .catch(error => { }); 272 | } 273 | } 274 | requestAnimationFrame(function() { 275 | gameLoop(index) 276 | }); 277 | } 278 | } 279 | 280 | $(document).ready(function() { 281 | var html = ` 282 |

Device

283 |
284 |
285 | Select Device 286 |
287 |
288 | 290 |
291 |
292 | ` 293 | $(".middle").html(html); 294 | 295 | $(document).on("change", "select", function () { 296 | var id = $(this).parent().parent().attr("id") 297 | if (id) { 298 | if (id.includes("button-")) { 299 | var index = id.replaceAll("button-", ""); 300 | var bind = bindHtml($(this).val(), index) 301 | $("#" + id).html(bind); 302 | } 303 | return; 304 | } 305 | var id = $(this).attr("id") 306 | if (id == "input-0") { 307 | var value = $(this).val(); 308 | if (value != "null") { 309 | initApp(value) 310 | } 311 | else { 312 | deviceIndex = null 313 | var deviceOptions = ``; 314 | let gps = navigator.getGamepads(); 315 | for(let gp of gps) { 316 | if (gp) { 317 | deviceOptions += `` 318 | } 319 | } 320 | var html = ` 321 |

Device

322 |
323 |
324 | Select Device 325 |
326 |
327 | 330 |
331 |
332 | ` 333 | $(".middle").html(html); 334 | } 335 | } 336 | }); 337 | $(document).on("click", "#save-button", function () { 338 | if ($("#input-0").val() == "null") { 339 | return; 340 | } 341 | var inputs = {}; 342 | var binds = {}; 343 | // Device Settings 344 | for (let i=0; i<6; i++) { 345 | var element = $("#input-"+ i); 346 | if (element && element.val()) { 347 | inputs[i] = element.val() 348 | } 349 | else { 350 | inputs[i] = null; 351 | } 352 | } 353 | // Binds 354 | for (let i=0; i { }) 380 | .catch(error => { }); 381 | }); 382 | }) 383 | 384 | window.addEventListener('gamepadconnected', function(e) { 385 | dataDisplay = document.getElementById('dataDisplay'); 386 | elemWheel = document.getElementById('wheel'); 387 | elemThrottle = document.querySelector('#throttle > span'); 388 | elemBrake = document.querySelector('#brake > span'); 389 | elemClutch = document.querySelector('#clutch > span'); 390 | var deviceOptions = ``; 391 | let gps = navigator.getGamepads(); 392 | for(let gp of gps) { 393 | if (gp) { 394 | deviceOptions += `` 395 | } 396 | } 397 | $("#input-0").html(deviceOptions) 398 | }); 399 | 400 | window.addEventListener("message", function(ev) { 401 | var event = ev.data 402 | if (event.type == "updateSettings") { 403 | updateSettings(event.data, true) 404 | } 405 | else if (event.type == "open") { 406 | $("#container").animate({opacity: 1}, 'fast'); 407 | } 408 | }) --------------------------------------------------------------------------------