Merge pull request #2716 from Nexhas28/feat/sdl-controller-support

This commit is contained in:
bryanthaboi
2026-10-06 19:56:51 -04:00
8 changed files with 541 additions and 36 deletions
+1
View File
@@ -806,6 +806,7 @@ function love.load(args)
end
end
love.graphics.setDefaultFilter("nearest", "nearest")
pcall(function() require("src.core.GamepadMap").initGamepadMappings() end)
-- NX: handheld 720p / docked 1080p. Runs for every boot path (launcher,
-- editor, scripted); no-op on desktop/mobile.
NxDisplay.sync()
+12
View File
@@ -1052,6 +1052,18 @@ function Game:gamepadaxis(joystick, axis, value)
end
return
end
local stickEvents = Input.stickAxisEvents and Input:stickAxisEvents(axis, value)
if stickEvents then
for i = 1, #stickEvents do
local ev = stickEvents[i]
if ev.phase == "pressed" then
padPressedBody(self, joystick, ev.button)
elseif ev.phase == "released" then
padReleasedBody(self, joystick, ev.button)
end
end
return
end
Input:gamepadaxis(joystick, axis, value)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
+12
View File
@@ -2595,6 +2595,18 @@ function Game2:gamepadaxis(joystick, axis, value)
end
return
end
local stickEvents = Input.stickAxisEvents and Input:stickAxisEvents(axis, value)
if stickEvents then
for i = 1, #stickEvents do
local ev = stickEvents[i]
if ev.phase == "pressed" then
padPressedBody(self, joystick, ev.button)
elseif ev.phase == "released" then
padReleasedBody(self, joystick, ev.button)
end
end
return
end
Input:gamepadaxis(joystick, axis, value)
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
+12
View File
@@ -1124,6 +1124,18 @@ function Game3:gamepadaxis(joystick, axis, value)
return
end
end
local stickEvents = self.input and self.input.stickAxisEvents and self.input:stickAxisEvents(axis, value)
if stickEvents then
for i = 1, #stickEvents do
local ev = stickEvents[i]
if ev.phase == "pressed" then
self:_padPressedBody(joystick, ev.button)
elseif ev.phase == "released" then
self:_padReleasedBody(joystick, ev.button)
end
end
return
end
if self.input and self.input.gamepadaxis then self.input:gamepadaxis(joystick, axis, value) end
end
if not ModRuntime.wantsHook("input.gamepad") then return vanilla() end
+29
View File
@@ -51,12 +51,41 @@ GamepadMap.NX_RAW_BUTTON_BINDINGS = {
[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 = {
+114 -35
View File
@@ -60,10 +60,24 @@ local STICK_OFF = 0.3
Input.PAD_ACTIONS = { speedUp = true, speedDown = true }
local STICK_DEFAULT_BINDINGS = {
leftstick_up = "up", leftstick_down = "down",
leftstick_left = "left", leftstick_right = "right",
rightstick_up = "up", rightstick_down = "down",
rightstick_left = "left", rightstick_right = "right",
lsup = "up", lsdown = "down", lsleft = "left", lsright = "right",
rsup = "up", rsdown = "down", rsleft = "left", rsright = "right",
}
local POLLABLE_PAD_BUTTONS = {
a = true, b = true, x = true, y = true, back = true, guide = true, start = true,
leftstick = true, rightstick = true, leftshoulder = true, rightshoulder = true,
dpup = true, dpdown = true, dpleft = true, dpright = true,
triggerleft = true, triggerright = true,
leftstick_up = true, leftstick_down = true, leftstick_left = true, leftstick_right = true,
rightstick_up = true, rightstick_down = true, rightstick_left = true, rightstick_right = true,
misc1 = true, paddle1 = true, paddle2 = true, paddle3 = true, paddle4 = true,
touchpad = true,
}
local HAT_DIRECTIONS = {
@@ -101,7 +115,7 @@ function Input:applyBindings(overlay)
if type(binding) == "table" then
if binding.key then keys[binding.key] = id end
if binding.pad then
local button = GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad
local button = GamepadMap.normalizePad and GamepadMap.normalizePad(binding.pad) or GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad
pads[button], acts[button] = id, nil
end
elseif type(binding) == "string" then keys[binding] = id end
@@ -117,7 +131,7 @@ function Input:applyBindings(overlay)
for _, id in ipairs(ids) do
local binding = overlay[id]
if Input.PAD_ACTIONS[id] and type(binding) == "table" and binding.pad then
local button = GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad
local button = GamepadMap.normalizePad and GamepadMap.normalizePad(binding.pad) or GamepadMap.TRIGGER_AXES[binding.pad] or binding.pad
acts[button], explicit[button], pads[button] = id, true, nil
end
end
@@ -161,7 +175,7 @@ function Input.hotkeyKey(key)
end
function Input:padAction(button, shoulderGameplay)
button = GamepadMap.TRIGGER_AXES[button] or button
button = GamepadMap.normalizePad and GamepadMap.normalizePad(button) or GamepadMap.TRIGGER_AXES[button] or button
if shoulderGameplay and (button == "leftshoulder" or button == "rightshoulder")
and not (self.explicitPadActions and self.explicitPadActions[button]) then return nil end
return self.padActions and self.padActions[button] or nil
@@ -183,6 +197,8 @@ function Input:reset()
self.sources = {}
self.stickAxis = { x = 0, y = 0 }
self.stickDir = nil
self.rightStickAxis = { x = 0, y = 0 }
self.rightStickDir = nil
self.hatDirs = {}
self.triggerHeld = {}
self.captureArmed = false
@@ -322,8 +338,32 @@ end
local function anyPadDown(pads, button)
for k = 1, #pads do
local j = pads[k]
local ok, down = pcall(j.isGamepadDown, j, button)
if ok and down then return true end
if button == "triggerleft" or button == "triggerright" then
if j.getGamepadAxis then
local ok, v = pcall(j.getGamepadAxis, j, button)
if ok and type(v) == "number" and v >= GamepadMap.TRIGGER_ON then return true end
end
elseif button == "leftstick_up" or button == "leftstick_down"
or button == "leftstick_left" or button == "leftstick_right"
or button == "rightstick_up" or button == "rightstick_down"
or button == "rightstick_left" or button == "rightstick_right" then
if j.getGamepadAxis then
local axisPrefix = button:sub(1, 9) == "leftstick" and "left" or "right"
local isY = (button:sub(-2) == "up" or button:sub(-4) == "down")
local axis = axisPrefix .. (isY and "y" or "x")
local ok, v = pcall(j.getGamepadAxis, j, axis)
if ok and type(v) == "number" then
if button:sub(-2) == "up" and v <= -STICK_ON then return true
elseif button:sub(-4) == "down" and v >= STICK_ON then return true
elseif button:sub(-4) == "left" and v <= -STICK_ON then return true
elseif button:sub(-5) == "right" and v >= STICK_ON then return true
end
end
end
else
local ok, down = pcall(j.isGamepadDown, j, button)
if ok and down then return true end
end
end
return false
end
@@ -488,17 +528,17 @@ function Input:sourceRelease(btn, source)
end
function Input:gamepadpressed(joystick, button)
button = GamepadMap.TRIGGER_AXES[button] or button
button = GamepadMap.normalizePad and GamepadMap.normalizePad(button) or GamepadMap.TRIGGER_AXES[button] or button
if self.padSuppress then self.padSuppress[button] = nil end
noteCapture(self, "pad", "pressed", button)
local btn = self.padBindings[button]
local btn = self.padBindings[button] or STICK_DEFAULT_BINDINGS[button]
if btn and not self:padAction(button, self.shoulderGameplay) then
press(self, btn, "pad:" .. button)
end
end
function Input:gamepadreleased(joystick, button)
button = GamepadMap.TRIGGER_AXES[button] or button
button = GamepadMap.normalizePad and GamepadMap.normalizePad(button) or GamepadMap.TRIGGER_AXES[button] or button
noteCapture(self, "pad", "released", button)
local source = "pad:" .. button
for btn, sources in pairs(self.sources) do
@@ -550,29 +590,27 @@ function Input:triggerAxis(axis, value)
return name
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)
local trigger, phase = self:triggerAxis(axis, value)
if trigger then
if phase == "pressed" then
self:gamepadpressed(joystick, trigger)
elseif phase == "released" then
self:gamepadreleased(joystick, trigger)
end
return
end
if axis == "leftx" then
self.stickAxis.x = value
elseif axis == "lefty" then
self.stickAxis.y = value
function Input:stickAxisEvents(axis, value)
local isLeft = (axis == "leftx" or axis == "lefty")
local isRight = (axis == "rightx" or axis == "righty")
if not isLeft and not isRight then return nil end
local prefix = isLeft and "leftstick" or "rightstick"
local stateKey = isLeft and "stickAxis" or "rightStickAxis"
local dirKey = isLeft and "stickDir" or "rightStickDir"
self[stateKey] = self[stateKey] or { x = 0, y = 0 }
if axis:sub(-1) == "x" then
self[stateKey].x = value
else
return
self[stateKey].y = value
end
local x, y = self.stickAxis.x, self.stickAxis.y
local x, y = self[stateKey].x, self[stateKey].y
local ax, ay = math.abs(x), math.abs(y)
local newDir = self.stickDir
local currentDir = self[dirKey]
local newDir = currentDir
if ax > STICK_ON or ay > STICK_ON then
if ax >= ay then
newDir = x > 0 and "right" or "left"
@@ -583,14 +621,42 @@ function Input:gamepadaxis(joystick, axis, value)
newDir = nil
end
if newDir ~= self.stickDir then
if self.stickDir then
release(self, self.stickDir, "stick")
if newDir == currentDir then return nil end
self[dirKey] = newDir
local events = {}
if currentDir then
events[#events + 1] = { phase = "released", button = prefix .. "_" .. currentDir }
end
if newDir then
events[#events + 1] = { phase = "pressed", button = prefix .. "_" .. newDir }
end
return events
end
-- left/right stick treated as continuous held directions, 4-way rule:
-- whichever axis has the larger magnitude wins.
function Input:gamepadaxis(joystick, axis, value)
local trigger, phase = self:triggerAxis(axis, value)
if trigger then
if phase == "pressed" then
self:gamepadpressed(joystick, trigger)
elseif phase == "released" then
self:gamepadreleased(joystick, trigger)
end
if newDir then
press(self, newDir, "stick")
return
end
local events = self:stickAxisEvents(axis, value)
if events then
for i = 1, #events do
local ev = events[i]
if ev.phase == "pressed" then
self:gamepadpressed(joystick, ev.button)
elseif ev.phase == "released" then
self:gamepadreleased(joystick, ev.button)
end
end
self.stickDir = newDir
return
end
end
@@ -601,6 +667,14 @@ function Input:joystickaxis(joystick, axis, value)
self:gamepadaxis(joystick, "leftx", value)
elseif axis == 2 then
self:gamepadaxis(joystick, "lefty", value)
elseif axis == 3 then
self:gamepadaxis(joystick, "rightx", value)
elseif axis == 4 then
self:gamepadaxis(joystick, "righty", value)
elseif axis == 5 then
self:gamepadaxis(joystick, "triggerleft", value)
elseif axis == 6 then
self:gamepadaxis(joystick, "triggerright", value)
end
end
@@ -662,7 +736,7 @@ function Input:reconcile()
end
end
if j.getGamepadAxis then
local axes = { "leftx", "lefty", "triggerleft", "triggerright" }
local axes = { "leftx", "lefty", "rightx", "righty", "triggerleft", "triggerright" }
for _, axis in ipairs(axes) do
local ok2, v = pcall(j.getGamepadAxis, j, axis)
if ok2 and type(v) == "number" then self:gamepadaxis(j, axis, v) end
@@ -677,7 +751,12 @@ function Input:reconcile()
end
end
if j.getAxis then
for _, axis in ipairs({ 1, 2 }) do
local axisCount = 6
if j.getAxisCount then
local okC, cnt = pcall(j.getAxisCount, j)
if okC and type(cnt) == "number" then axisCount = math.max(6, cnt) end
end
for axis = 1, axisCount do
local ok2, v = pcall(j.getAxis, j, axis)
if ok2 and type(v) == "number" then self:joystickaxis(j, axis, v) end
end
+15 -1
View File
@@ -9,6 +9,7 @@ local ListMenu = require("src.ui.ListMenu")
local ChoiceBox = require("src.ui.ChoiceBox")
local Input = require("src.core.Input")
local Strings = require("src.core.Strings")
local GamepadMap = require("src.core.GamepadMap")
local BindingsMenu = setmetatable({}, { __index = ListMenu })
BindingsMenu.__index = BindingsMenu
@@ -68,7 +69,17 @@ local PAD_SHORT = {
leftshoulder = "LB", rightshoulder = "RB",
triggerleft = "L2", triggerright = "R2",
lefttrigger = "L2", righttrigger = "R2",
leftstick = "LS", rightstick = "RS", guide = "GUIDE",
leftstick = "LS", rightstick = "RS",
leftstick_up = "LS-UP", leftstick_down = "LS-DN",
leftstick_left = "LS-LT", leftstick_right = "LS-RT",
lsup = "LS-UP", lsdown = "LS-DN", lsleft = "LS-LT", lsright = "LS-RT",
rightstick_up = "RS-UP", rightstick_down = "RS-DN",
rightstick_left = "RS-LT", rightstick_right = "RS-RT",
rsup = "RS-UP", rsdown = "RS-DN", rsleft = "RS-LT", rsright = "RS-RT",
guide = "GUIDE", back = "BACK", start = "START",
misc1 = "MISC1", touchpad = "PAD",
paddle1 = "P1", paddle2 = "P2", paddle3 = "P3", paddle4 = "P4",
x = "X", y = "Y", a = "A", b = "B",
}
local function shortName(name, shorts)
local s = shorts[name]
@@ -223,6 +234,9 @@ function BindingsMenu:storeBinding(slot, value)
self:endCapture()
local game = self.game
if not (item and value and game.save and game.save.options) then return end
if slot == "pad" and GamepadMap.normalizePad then
value = GamepadMap.normalizePad(value)
end
local opts = game.save.options
opts.bindings = opts.bindings or {}
-- Swap, never steal (#589): when the captured input is another row's
@@ -0,0 +1,346 @@
-- 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")