From 8a649e8d95d86e8eeb810486091bb8d7d5c3795e Mon Sep 17 00:00:00 2001 From: johnjohto Date: Thu, 30 Jul 2026 10:38:24 -0400 Subject: [PATCH] Handle generic joystick input Unmapped handheld controllers emit joystick events, leaving the launcher cursor and in-game input inactive. --- main.lua | 24 ++++++++++++++++++++ src/core/Game.lua | 19 ++++++++++++++++ src/core/Input.lua | 45 ++++++++++++++++++++++++++++++++++++++ src/import/RomImporter.lua | 31 ++++++++++++++++++++++++++ src/update/Boot.lua | 4 +++- tests/input_hold_test.lua | 45 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 167 insertions(+), 1 deletion(-) diff --git a/main.lua b/main.lua index f88a7381..d940b816 100644 --- a/main.lua +++ b/main.lua @@ -331,6 +331,30 @@ function love.gamepadaxis(joystick, axis, value) Game:gamepadaxis(joystick, axis, value) end +function love.joystickpressed(joystick, button) + if editorMode then return end + if Importer then return Importer:joystickpressed(joystick, button) end + Game:joystickpressed(joystick, button) +end + +function love.joystickreleased(joystick, button) + if editorMode then return end + if Importer then return Importer:joystickreleased(joystick, button) end + Game:joystickreleased(joystick, button) +end + +function love.joystickaxis(joystick, axis, value) + if editorMode then return end + if Importer then return Importer:joystickaxis(joystick, axis, value) end + Game:joystickaxis(joystick, axis, value) +end + +function love.joystickhat(joystick, hat, direction) + if editorMode then return end + if Importer then return Importer:joystickhat(joystick, hat, direction) end + Game:joystickhat(joystick, hat, direction) +end + function love.joystickremoved(joystick) if editorMode then return end if Importer then return end diff --git a/src/core/Game.lua b/src/core/Game.lua index 05c2427e..85324748 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -491,6 +491,25 @@ function Game:gamepadaxis(joystick, axis, value) Input:gamepadaxis(joystick, axis, value) end +function Game:joystickpressed(joystick, button) + TouchControls:noteGamepad() + Input:joystickpressed(joystick, button) +end + +function Game:joystickreleased(joystick, button) + Input:joystickreleased(joystick, button) +end + +function Game:joystickaxis(joystick, axis, value) + if math.abs(value) > 0.5 then TouchControls:noteGamepad() end + Input:joystickaxis(joystick, axis, value) +end + +function Game:joystickhat(joystick, hat, direction) + if direction ~= "c" then TouchControls:noteGamepad() end + Input:joystickhat(joystick, hat, direction) +end + -- Window focus/visibility flips: a release due while unfocused/hidden can -- be swallowed by the OS. Reset on both edges -- gaining focus with a -- physically held key won't re-fire keypressed, so trusting leftover diff --git a/src/core/Input.lua b/src/core/Input.lua index d10feef1..f9985613 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -39,6 +39,20 @@ local DEFAULT_GAMEPAD_BINDINGS = { local STICK_ON = 0.5 local STICK_OFF = 0.3 +-- Generic SDL joysticks expose the left stick as the first two numbered +-- axes and the D-pad as a hat. This is common on Linux handhelds whose +-- controller has no game-controller database entry. +local RAW_BUTTON_BINDINGS = { + [1] = "a", [2] = "b", + [7] = "select", [8] = "start", [9] = "select", [10] = "start", +} + +local HAT_DIRECTIONS = { + u = { "up" }, d = { "down" }, l = { "left" }, r = { "right" }, + lu = { "left", "up" }, ru = { "right", "up" }, + ld = { "left", "down" }, rd = { "right", "down" }, +} + function Input:init() self:applyBindings(nil) self:reset() @@ -79,6 +93,7 @@ function Input:reset() self.sources = {} self.stickAxis = { x = 0, y = 0 } self.stickDir = nil + self.hatDirs = {} end -- Multiple physical sources (W + Up, d-pad + stick, etc.) can claim the @@ -179,6 +194,16 @@ function Input:gamepadreleased(joystick, button) end end +function Input:joystickpressed(joystick, button) + local btn = RAW_BUTTON_BINDINGS[button] + if btn then press(self, btn, "joy:" .. button) end +end + +function Input:joystickreleased(joystick, button) + local btn = RAW_BUTTON_BINDINGS[button] + if btn then release(self, btn, "joy:" .. button) end +end + -- left stick treated as a continuous held direction, same 4-way rule as -- the touch swipe d-pad: whichever axis has the larger magnitude wins. function Input:gamepadaxis(joystick, axis, value) @@ -214,6 +239,26 @@ function Input:gamepadaxis(joystick, axis, value) end end +function Input:joystickaxis(joystick, axis, value) + if axis == 1 then + self:gamepadaxis(joystick, "leftx", value) + elseif axis == 2 then + self:gamepadaxis(joystick, "lefty", value) + end +end + +function Input:joystickhat(joystick, hat, direction) + local source = "hat:" .. hat + for _, btn in ipairs(self.hatDirs[hat] or {}) do + release(self, btn, source) + end + local dirs = HAT_DIRECTIONS[direction] or {} + for _, btn in ipairs(dirs) do + press(self, btn, source) + end + self.hatDirs[hat] = dirs +end + function Input:isDown(btn) return self.state[btn] or false end diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 82609f51..0ea9452e 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -540,6 +540,7 @@ function RomImporter.new(onComplete, opts) _padCursorActive = false, _padAxis = { leftx = 0, lefty = 0, righty = 0 }, _padDir = {}, + _rawHatDirs = {}, _padInited = false, }, RomImporter) @@ -1184,6 +1185,36 @@ function RomImporter:gamepadaxis(_, axis, value) end end +function RomImporter:joystickpressed(joystick, button) + if button == 1 then self:gamepadpressed(joystick, "a") end +end + +function RomImporter:joystickreleased(joystick, button) + if button == 1 then self:gamepadreleased(joystick, "a") end +end + +function RomImporter:joystickaxis(joystick, axis, value) + if axis == 1 then + self:gamepadaxis(joystick, "leftx", value) + elseif axis == 2 then + self:gamepadaxis(joystick, "lefty", value) + end +end + +function RomImporter:joystickhat(_, hat, direction) + for _, dir in ipairs(self._rawHatDirs[hat] or {}) do + self._padDir[dir] = nil + end + local dirs = ({ + u = { "dpup" }, d = { "dpdown" }, l = { "dpleft" }, r = { "dpright" }, + lu = { "dpleft", "dpup" }, ru = { "dpright", "dpup" }, + ld = { "dpleft", "dpdown" }, rd = { "dpright", "dpdown" }, + })[direction] or {} + for _, dir in ipairs(dirs) do self._padDir[dir] = true end + self._rawHatDirs[hat] = dirs + if #dirs > 0 then self:_activatePadCursor() end +end + -- Player pressed Play on a game whose ROM is imported: hand off to boot. function RomImporter:play(version) if self.workState == "working" then return end diff --git a/src/update/Boot.lua b/src/update/Boot.lua index 3c706c4e..069fbe43 100644 --- a/src/update/Boot.lua +++ b/src/update/Boot.lua @@ -50,7 +50,9 @@ local CALLBACK_NAMES = { "keypressed", "keyreleased", "textinput", "mousepressed", "mousereleased", "mousemoved", "wheelmoved", "touchpressed", "touchmoved", "touchreleased", - "gamepadpressed", "gamepadreleased", "gamepadaxis", "joystickremoved", + "gamepadpressed", "gamepadreleased", "gamepadaxis", + "joystickpressed", "joystickreleased", "joystickaxis", "joystickhat", + "joystickremoved", "focus", "visible", "resize", "filedropped", "directorydropped", "errorhandler", "threaderror", "lowmemory", } diff --git a/tests/input_hold_test.lua b/tests/input_hold_test.lua index 9700e159..9ec07552 100644 --- a/tests/input_hold_test.lua +++ b/tests/input_hold_test.lua @@ -46,6 +46,51 @@ Input:step() check(Input:wasPressed("left"), "stick flick edges wasPressed") check(not Input:isDown("left"), "stick flick does not stick isDown") +-- Linux handhelds without an SDL game-controller mapping send raw joystick +-- axes and D-pad hats instead of gamepad events. +Input:reset() +Input:joystickaxis(nil, 1, -0.9) +Input:step() +check(Input:isDown("left"), "raw joystick left axis holds left") +Input:joystickaxis(nil, 1, 0) +check(not Input:isDown("left"), "raw joystick axis release clears left") + +Input:reset() +Input:joystickhat(nil, 1, "u") +Input:step() +check(Input:isDown("up"), "raw joystick hat holds up") +Input:joystickhat(nil, 1, "c") +check(not Input:isDown("up"), "raw joystick hat release clears up") + +Input:reset() +Input:joystickpressed(nil, 1) +Input:step() +check(Input:isDown("a"), "raw joystick primary button presses A") + +-- The launcher has a separate virtual cursor, so prove generic joystick +-- events reach its left-stick and D-pad state too. +local RomImporter = require("src.import.RomImporter") +local importer = setmetatable({ + _padCursor = { x = 0, y = 0 }, _padCursorActive = false, + _padAxis = { leftx = 0, lefty = 0, righty = 0 }, + _padDir = {}, _rawHatDirs = {}, _padInited = true, +}, RomImporter) +local clicked = false +function importer:mousepressed(_, _, button) + clicked = button == 1 +end +importer:joystickaxis(nil, 1, -0.8) +check(importer._padAxis.leftx == -0.8, + "raw joystick left axis reaches the launcher cursor") +importer:joystickhat(nil, 1, "r") +check(importer._padDir.dpright, + "raw joystick hat reaches the launcher cursor") +importer:joystickhat(nil, 1, "c") +check(not importer._padDir.dpright, + "raw joystick hat release clears the launcher cursor") +importer:joystickpressed(nil, 1) +check(clicked, "raw joystick primary button clicks the launcher cursor") + -- Drivers that only inject pressQueue still get a one-step hold. Input:reset() table.insert(Input.pressQueue, "down")