Files
gen1recomp/src/core/GamepadMap.lua
T
Shane McGovern 1a53525722 feat(input): full SDL controller support and analog rebinds in Options
- Add normalizePad() and aliases in GamepadMap for triggers and sticks
- Automatically load custom mappings from SDL_GAMECONTROLLERCONFIG and gamecontrollerdb.txt
- Add support for left and right analog sticks, triggers (L2/R2), stick clicks (LS/RS), and extra SDL buttons (Guide, Touchpad, Paddles)
- Route analog stick axis thresholds into discrete directional press/release events for seamless in-game and menu navigation
- Support rebinding buttons, thumbstick directions, triggers, and stick clicks in the CONTROLS menu (BindingsMenu) with clear abbreviated labels (LS-UP, RS-DN, L2, R2, etc.)
- Integrate stick axis event dispatch across Gen1, Gen2, and Gen3 game loops
- Add comprehensive test suite in tests/engine/sdl_controller_support_test.lua covering all axes, buttons, holds, rebinding, and multi-generation routing
2026-10-06 16:56:38 +01:00

198 lines
7.1 KiB
Lua

-- Shared gamepad + raw joystick button tables for launcher and gameplay.
-- Hardware-measured NX overrides live in NX_* tables below.
local GamepadMap = {}
-- LÖVE SDL game-controller mapping (D-pad / face / menu) — desktop/mobile.
GamepadMap.DEFAULT_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "a", b = "b",
start = "start", back = "select",
}
GamepadMap.TRIGGER_AXES = {
triggerleft = "triggerleft", lefttrigger = "triggerleft",
triggerright = "triggerright", righttrigger = "triggerright",
}
GamepadMap.TRIGGER_ON = 0.4
GamepadMap.TRIGGER_OFF = 0.2
GamepadMap.DEFAULT_PAD_ACTIONS = {
rightshoulder = "speedUp", triggerright = "speedUp",
leftshoulder = "speedDown", triggerleft = "speedDown",
-- FRLG: shoulders also drive l/r via Input dual-emit (see Input:gamepadpressed).
}
-- Switch: LÖVE/SDL labels south as "a" and east as "b", but Nintendo UX is
-- physical A (east) = confirm (GB A), physical B (south) = cancel (GB B).
GamepadMap.NX_GAMEPAD_BINDINGS = {
dpup = "up", dpdown = "down", dpleft = "left", dpright = "right",
a = "b", -- SDL south = Nintendo B → GB B
b = "a", -- SDL east = Nintendo A → GB A
start = "start", back = "select",
}
-- Generic SDL joysticks without a game-controller DB entry (Linux handhelds).
-- Desktop XInput order only: raw numbering is per-driver, and SDL's iOS/MFi
-- driver packs only the buttons a pad reports, which slides the D-pad onto
-- 7..10 (#620). These are defaults; Input:applyBindings layers "joyN" pad
-- rebinds over them (#632). Only sticks SDL does NOT recognize as gamepads
-- are served from this table (see GamepadMap.ignoreRawForJoystick).
GamepadMap.RAW_BUTTON_BINDINGS = {
[1] = "a", [2] = "b",
[7] = "select", [8] = "start", [9] = "select", [10] = "start",
}
-- Switch OLED raw indices (1-based). Only when NOT isGamepad() — love-nx
-- also emits gamepadpressed; dual-path face presses break NamingScreen.
-- #1 = Nintendo B, #2 = Nintendo A (probe); Y/X left unmapped for naming.
GamepadMap.NX_RAW_BUTTON_BINDINGS = {
[1] = "b", [2] = "a",
[9] = "select", [10] = "start",
}
GamepadMap.PAD_ALIASES = {
lefttrigger = "triggerleft",
righttrigger = "triggerright",
lsup = "leftstick_up", lsdown = "leftstick_down",
lsleft = "leftstick_left", lsright = "leftstick_right",
rsup = "rightstick_up", rsdown = "rightstick_down",
rsleft = "rightstick_left", rsright = "rightstick_right",
}
function GamepadMap.normalizePad(button)
if not button then return nil end
return GamepadMap.TRIGGER_AXES[button] or GamepadMap.PAD_ALIASES[button] or button
end
function GamepadMap.initGamepadMappings()
local js = love and love.joystick
if not (js and js.loadGamepadMappings) then return end
local envConfig = os.getenv and (os.getenv("SDL_GAMECONTROLLERCONFIG") or os.getenv("SDL_GAMECONTROLLERCONFIG_FILE"))
if envConfig and envConfig ~= "" then
pcall(js.loadGamepadMappings, envConfig)
end
local fs = love.filesystem
if fs and fs.getInfo and fs.getInfo("gamecontrollerdb.txt") then
pcall(js.loadGamepadMappings, "gamecontrollerdb.txt")
end
end
-- Raw index -> gamepad button *name* for RomImporter (then NX face swap applies).
GamepadMap.RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b", [3] = "x", [4] = "y",
[5] = "leftshoulder", [6] = "rightshoulder",
[7] = "back", [8] = "start", [9] = "back", [10] = "start",
[11] = "triggerleft", [12] = "triggerright",
[13] = "leftstick", [14] = "rightstick",
[15] = "guide",
}
GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON = {
[1] = "a", [2] = "b", -- SDL south/east names; NX_GAMEPAD_BINDINGS swaps to GB
[9] = "back", [10] = "start",
}
-- Test hook: force NX tables without stubbing love.
GamepadMap._forceNXForTests = false
function GamepadMap._setForceNXForTests(v)
GamepadMap._forceNXForTests = not not v
end
local function nxActive()
if GamepadMap._forceNXForTests == true then return true end
if GamepadMap._forceNXForTests == false and love and (love._os ~= "NX" and (not love.system or love.system.getOS() ~= "NX")) then
return false
end
if love and love._os == "NX" then return true end
if love and love.system and love.system.getOS() == "NX" then return true end
return false
end
function GamepadMap.gamepadBindings()
if nxActive() then return GamepadMap.NX_GAMEPAD_BINDINGS end
return GamepadMap.DEFAULT_GAMEPAD_BINDINGS
end
-- Whole raw-index table for Input:applyBindings joyBindings seeding (#632).
function GamepadMap.rawBindings()
if nxActive() then return GamepadMap.NX_RAW_BUTTON_BINDINGS end
return GamepadMap.RAW_BUTTON_BINDINGS
end
function GamepadMap.mapGamepadButton(button)
return GamepadMap.gamepadBindings()[button]
end
function GamepadMap.mapLauncherButton(button)
if nxActive() then
if button == "a" then return "b"
elseif button == "b" then return "a"
elseif button == "x" then return "y"
elseif button == "y" then return "x"
elseif button == "back" then return "select"
end
elseif button == "back" then
return "select"
end
return button
end
-- Select+face display chords (docs / Nintendo UX):
-- Select+A → "2" (COLORS), Select+B → "3" (TILT),
-- Select+Y → "5", Select+X → "6", Select+L (leftshoulder) → "7".
-- For a/b: resolve through mapGamepadButton then GB a→"2", b→"3" so NX
-- Nintendo physical A/B match the docs despite SDL face-label swap.
-- Caller (Game:gamepadpressed) must require Select held; this is map-only.
function GamepadMap.displayChordDigit(gamepadButton)
if gamepadButton == "y" then return "5" end
if gamepadButton == "x" then return "6" end
if gamepadButton == "leftshoulder" then return "7" end
if gamepadButton == "a" or gamepadButton == "b" then
local gb = GamepadMap.mapGamepadButton(gamepadButton)
if gb == "a" then return "2" end
if gb == "b" then return "3" end
end
return nil
end
-- love-nx / SDL: when isGamepad(), face+menu already arrive via gamepad*.
-- Applying joystickpressed raw on top double-fires GB A/B in one frame.
function GamepadMap.ignoreRawForJoystick(joystick)
if not joystick then return false end
local ok, isPad = pcall(function()
return joystick.isGamepad and joystick:isGamepad()
end)
return ok and isPad == true
end
-- conf.lua turns the mobile accelerometer-joystick off (#468), but guard the
-- generic joystick path anyway: any sensor-style device that still reaches us
-- has gravity pinning an axis past the deadzone, which would hide the touch
-- overlay every instant and steer the player by tilt through the axis-1/2
-- mapping (#459). Real controllers arrive as SDL gamepads or named sticks,
-- never as "* Accelerometer".
function GamepadMap.isAccelerometer(joystick)
local name = joystick and joystick.getName and joystick:getName()
return name ~= nil and name:lower():find("accelerometer", 1, true) ~= nil
end
function GamepadMap.mapRawButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_BUTTON_BINDINGS[index]
if nx then return nx end
end
return GamepadMap.RAW_BUTTON_BINDINGS[index]
end
function GamepadMap.mapRawToGamepadButton(index)
if nxActive() then
local nx = GamepadMap.NX_RAW_TO_GAMEPAD_BUTTON[index]
if nx then return nx end
end
return GamepadMap.RAW_TO_GAMEPAD_BUTTON[index]
end
return GamepadMap