Files
gen1recomp/tests/engine/sdl_controller_support_test.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

347 lines
13 KiB
Lua

-- Full SDL controller support tests: thumbsticks (left & right), analog triggers,
-- stick clicks, and rebinding in CONTROLS (BindingsMenu).
-- luajit tests/engine/sdl_controller_support_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = love or require("tests.love_stub")
local Input = require("src.core.Input")
local GamepadMap = require("src.core.GamepadMap")
local BindingsMenu = require("src.ui.BindingsMenu")
local Game = require("src.core.Game")
local pad = { isGamepad = function() return true end }
local function newGame()
local game = { save = { options = {} }, data = {}, wroteOptions = 0 }
game.stack = {
states = {},
push = function(self, s) table.insert(self.states, s) end,
pop = function(self) return table.remove(self.states) end,
top = function(self) return self.states[#self.states] end,
}
game.input = Input
function game:writeOptions() self.wroteOptions = self.wroteOptions + 1 end
function game:_cycleSpeed(dir) self.cycledSpeed = (self.cycledSpeed or 0) + dir end
return game
end
Input:init()
-- =========================================================================
-- 1. Thumbstick directions (left stick and right stick default movement)
-- =========================================================================
-- (a) Left stick cardinal directions
Input:reset()
Input:gamepadaxis(pad, "leftx", -0.9)
Input:step()
check(Input:wasPressed("left"), "left stick left presses left")
check(Input:isDown("left"), "left stick left holds left")
Input:gamepadaxis(pad, "leftx", 0)
check(not Input:isDown("left"), "centering left stick releases left")
Input:reset()
Input:gamepadaxis(pad, "lefty", -0.8)
Input:step()
check(Input:wasPressed("up"), "left stick up presses up")
check(Input:isDown("up"), "left stick up holds up")
Input:gamepadaxis(pad, "lefty", 0)
check(not Input:isDown("up"), "centering left stick releases up")
-- (b) Right stick also provides default directional movement
Input:reset()
Input:gamepadaxis(pad, "rightx", 0.9)
Input:step()
check(Input:wasPressed("right"), "right stick right presses right")
check(Input:isDown("right"), "right stick right holds right")
Input:gamepadaxis(pad, "rightx", 0)
check(not Input:isDown("right"), "centering right stick releases right")
Input:reset()
Input:gamepadaxis(pad, "righty", 0.8)
Input:step()
check(Input:wasPressed("down"), "right stick down presses down")
check(Input:isDown("down"), "right stick down holds down")
Input:gamepadaxis(pad, "righty", 0)
check(not Input:isDown("down"), "centering right stick releases down")
-- (c) Stick deadzone hysteresis: deflection under 0.5 does not trigger,
-- deflection past 0.5 triggers, stays held until dropping below 0.3.
Input:reset()
Input:gamepadaxis(pad, "lefty", -0.4)
check(not Input:isDown("up"), "lefty -0.4 is below STICK_ON (0.5), does not trigger")
Input:gamepadaxis(pad, "lefty", -0.7)
check(Input:isDown("up"), "lefty -0.7 triggers up")
Input:gamepadaxis(pad, "lefty", -0.4)
check(Input:isDown("up"), "lefty -0.4 stays held above STICK_OFF (0.3)")
Input:gamepadaxis(pad, "lefty", -0.2)
check(not Input:isDown("up"), "lefty -0.2 drops below STICK_OFF (0.3), releases up")
-- (d) Stick rolling: rolling from right to down without hitting center
Input:reset()
Input:gamepadaxis(pad, "leftx", 0.9)
Input:step()
check(Input:isDown("right"), "stick tilted right")
check(not Input:isDown("down"), "down not held")
Input:gamepadaxis(pad, "lefty", 0.95)
Input:step()
check(not Input:isDown("right"), "rolling to down releases right")
check(Input:isDown("down"), "and presses down")
Input:gamepadaxis(pad, "leftx", 0)
Input:gamepadaxis(pad, "lefty", 0)
check(not Input:isDown("down"), "centering releases down")
-- =========================================================================
-- 2. Multi-source hold integrity: D-pad and analog stick simultaneously
-- =========================================================================
Input:reset()
Input:gamepadpressed(pad, "dpup")
Input:gamepadaxis(pad, "lefty", -0.9)
Input:step()
check(Input:isDown("up"), "both D-pad and stick hold up")
Input:gamepadreleased(pad, "dpup")
check(Input:isDown("up"), "releasing D-pad still keeps up held via stick")
Input:gamepadaxis(pad, "lefty", 0)
check(not Input:isDown("up"), "releasing stick finally clears up")
-- =========================================================================
-- 3. Triggers (analog triggers with threshold hysteresis)
-- =========================================================================
Input:reset()
local name, phase = Input:triggerAxis("triggerleft", 0.3)
check(phase == nil, "trigger at 0.3 is below threshold")
name, phase = Input:triggerAxis("triggerleft", 0.6)
eq(phase, "pressed", "trigger at 0.6 fires pressed")
name, phase = Input:triggerAxis("triggerleft", 0.3)
check(phase == nil, "trigger releasing to 0.3 stays held (hysteresis)")
name, phase = Input:triggerAxis("triggerleft", 0.1)
eq(phase, "released", "trigger dropping to 0.1 fires released")
-- =========================================================================
-- 4. Rebinding in CONTROLS (BindingsMenu) with thumbsticks and triggers
-- =========================================================================
local game = newGame()
local bm = BindingsMenu.new(game)
game.stack:push(bm)
-- (a) Rebind row A to Right Trigger (triggerright / R2)
bm.index = 5 -- Row A
bm.onChoose(bm.items[5])
check(bm.capture == bm.items[5], "A row capture armed")
bm:onGamepadPressed("triggerright")
check(game.save.options.bindings == nil, "pending trigger release")
bm:onGamepadReleased("triggerright")
local bindings = game.save.options.bindings
eq(bindings.a.pad, "triggerright", "triggerright stored on row A")
eq(bm.items[5].right, "Z/R2", "row A displays Z/R2")
-- Apply and verify gameplay with R2
Input:applyBindings(bindings)
Input:reset()
Input:gamepadaxis(pad, "triggerright", 0.8)
Input:step()
check(Input:isDown("a"), "pulling R2 presses A")
Input:gamepadaxis(pad, "triggerright", 0)
check(not Input:isDown("a"), "releasing R2 releases A")
-- (b) Rebind row UP to Left Stick Up (leftstick_up / LS-UP)
bm.index = 1 -- Row UP
bm.onChoose(bm.items[1])
bm:onGamepadPressed("leftstick_up")
bm:onGamepadReleased("leftstick_up")
eq(bindings.up.pad, "leftstick_up", "leftstick_up stored on row UP")
eq(bm.items[1].right, "UP/LS-UP", "row UP displays UP/LS-UP")
-- (c) Rebind row DOWN to Right Stick Down (rightstick_down / RS-DN)
bm.index = 2 -- Row DOWN
bm.onChoose(bm.items[2])
bm:onGamepadPressed("rightstick_down")
bm:onGamepadReleased("rightstick_down")
eq(bindings.down.pad, "rightstick_down", "rightstick_down stored on row DOWN")
eq(bm.items[2].right, "DOWN/RS-DN", "row DOWN displays DOWN/RS-DN")
-- (d) Rebind SPEED + to Right Stick Click (rightstick / RS)
bm.index = 10 -- Row SPEED +
bm.onChoose(bm.items[10])
bm:onGamepadPressed("rightstick")
bm:onGamepadReleased("rightstick")
eq(bindings.speedUp.pad, "rightstick", "rightstick stored on SPEED +")
eq(bm.items[10].right, "RS", "row SPEED + displays RS")
-- Apply bindings and verify SPEED + action on rightstick click
Input:applyBindings(bindings)
eq(Input:padAction("rightstick"), "speedUp", "rightstick triggers speedUp action")
-- (e) Rebind SPEED - to Left Trigger (triggerleft / L2)
bm.index = 9 -- Row SPEED -
bm.onChoose(bm.items[9])
bm:onGamepadPressed("triggerleft")
bm:onGamepadReleased("triggerleft")
eq(bindings.speedDown.pad, "triggerleft", "triggerleft stored on SPEED -")
eq(bm.items[9].right, "L2", "row SPEED - displays L2")
Input:applyBindings(bindings)
eq(Input:padAction("triggerleft"), "speedDown", "triggerleft triggers speedDown action")
-- =========================================================================
-- 5. Game.gamepadaxis routes stick events to open menu capture
-- =========================================================================
local gameInstance = setmetatable(newGame(), { __index = Game })
local bm2 = BindingsMenu.new(gameInstance)
gameInstance.stack:push(bm2)
bm2.index = 6 -- Row B
bm2.onChoose(bm2.items[6])
check(bm2.capture == bm2.items[6], "Row B capture armed")
-- Tilt right stick left into Game.gamepadaxis
Game.gamepadaxis(gameInstance, pad, "rightx", -0.8)
check(bm2.pending ~= nil and bm2.pending.value == "rightstick_left",
"Game:gamepadaxis routes stick tilt into capture as rightstick_left")
-- Return stick to center
Game.gamepadaxis(gameInstance, pad, "rightx", 0)
check(bm2.capture == nil, "centering stick completes capture")
eq(gameInstance.save.options.bindings.b.pad, "rightstick_left", "Row B bound to rightstick_left")
eq(bm2.items[6].right, "X/RS-LT", "Row B displays X/RS-LT")
-- =========================================================================
-- 6. Stick clicks, face buttons, guide and extra SDL buttons
-- =========================================================================
local bm3 = BindingsMenu.new(game)
game.stack:push(bm3)
-- (a) Left stick click (leftstick / LS)
bm3.index = 5 -- Row A
bm3.onChoose(bm3.items[5])
bm3:onGamepadPressed("leftstick")
bm3:onGamepadReleased("leftstick")
eq(bm3.items[5].right, "Z/LS", "Row A bound to leftstick shows Z/LS")
-- (b) X and Y buttons
bm3.index = 6 -- Row B
bm3.onChoose(bm3.items[6])
bm3:onGamepadPressed("x")
bm3:onGamepadReleased("x")
eq(bm3.items[6].right, "X/X", "Row B bound to x shows X/X")
-- (c) Guide button
bm3.index = 7 -- Row START
bm3.onChoose(bm3.items[7])
bm3:onGamepadPressed("guide")
bm3:onGamepadReleased("guide")
eq(bm3.items[7].right, "ESC/GUIDE", "Row START bound to guide shows ESC/GUIDE")
-- (d) Misc1 / Touchpad / Paddle buttons display
bm3.index = 8 -- Row SELECT
bm3.onChoose(bm3.items[8])
bm3:onGamepadPressed("touchpad")
bm3:onGamepadReleased("touchpad")
eq(bm3.items[8].right, "TAB/PAD", "Row SELECT bound to touchpad shows TAB/PAD")
-- (e) Clear binding (onSelectKey resets row to default)
bm3.index = 5 -- Row A
bm3.onSelectKey(bm3.items[5])
eq(bm3.items[5].right, "Z/A", "clearing row A restores default Z/A")
-- (f) Reset all bindings
game.save.options.bindings = {
a = { pad = "leftstick" },
b = { pad = "rightstick_up" },
up = { pad = "triggerright" },
}
bm3.confirmReset(bm3)
-- Accept confirm reset
local confirmBox = game.stack:top()
check(confirmBox ~= nil, "confirm reset dialog pushed")
if confirmBox and confirmBox.onChoose then confirmBox.onChoose(true) end
check(game.save.options.bindings == nil, "reset all cleared options.bindings")
-- =========================================================================
-- 7. Game2 and Game3 stick and trigger routing
-- =========================================================================
local Game2 = require("src.core.Game2")
local Game3 = require("src.core.Game3")
-- Game2 stick axis routing
local g2 = setmetatable(newGame(), { __index = Game2 })
local bmG2 = BindingsMenu.new(g2)
g2.stack:push(bmG2)
bmG2.index = 5
bmG2.onChoose(bmG2.items[5])
Game2.gamepadaxis(g2, pad, "lefty", -0.9)
check(bmG2.pending ~= nil and bmG2.pending.value == "leftstick_up",
"Game2:gamepadaxis routes stick tilt into menu capture")
Game2.gamepadaxis(g2, pad, "lefty", 0)
check(bmG2.capture == nil, "Game2 stick release completes capture")
eq(bmG2.items[5].right, "Z/LS-UP", "Game2 row A displays Z/LS-UP")
-- Game3 stick axis routing
local g3 = setmetatable(newGame(), { __index = Game3 })
g3.input = Input
Input:reset()
Input:armCapture()
Game3.gamepadaxis(g3, pad, "righty", 0.9)
local capEvents = Input:takeCaptureEvents()
check(capEvents ~= nil and #capEvents > 0, "Game3:gamepadaxis records captureEvents")
check(capEvents[1].kind == "pad" and capEvents[1].phase == "pressed" and capEvents[1].value == "rightstick_down",
"Game3 capture event is rightstick_down pressed")
Game3.gamepadaxis(g3, pad, "righty", 0)
local relEvents = Input:takeCaptureEvents()
check(relEvents ~= nil and #relEvents > 0 and relEvents[1].phase == "released" and relEvents[1].value == "rightstick_down",
"Game3 capture event is rightstick_down released")
Input:disarmCapture()
-- =========================================================================
-- 8. Polling & Reconcile for triggers and stick directions
-- =========================================================================
local mockPad = {
isGamepad = function() return true end,
isGamepadDown = function(_, b) return false end,
getGamepadAxis = function(_, axis)
if axis == "triggerleft" then return 0.9 end
if axis == "lefty" then return -0.9 end
return 0
end,
}
Input:init()
Input:applyBindings({
speedDown = { pad = "triggerleft" },
up = { pad = "leftstick_up" },
})
-- Reconcile detects the held trigger and held stick axis
Input:reconcile()
-- Note: mockPad was not in love.joystick.getJoysticks(), so reconcile with mock directly:
Input:gamepadaxis(mockPad, "triggerleft", 0.9)
Input:gamepadaxis(mockPad, "lefty", -0.9)
Input:step()
check(Input:isDown("up"), "reconciled stick axis holds up")
Input:init()
T.finish("sdl_controller_support_test")