From 1a535257223185f0b06d4f0564aaa038a4e92ae8 Mon Sep 17 00:00:00 2001 From: Shane McGovern Date: Tue, 6 Oct 2026 16:56:38 +0100 Subject: [PATCH] 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 --- main.lua | 1 + src/core/Game.lua | 12 + src/core/Game2.lua | 12 + src/core/Game3.lua | 12 + src/core/GamepadMap.lua | 29 ++ src/core/Input.lua | 149 ++++++-- src/ui/BindingsMenu.lua | 16 +- tests/engine/sdl_controller_support_test.lua | 346 +++++++++++++++++++ 8 files changed, 541 insertions(+), 36 deletions(-) create mode 100644 tests/engine/sdl_controller_support_test.lua diff --git a/main.lua b/main.lua index 77d3200f..17c981c4 100644 --- a/main.lua +++ b/main.lua @@ -804,6 +804,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() diff --git a/src/core/Game.lua b/src/core/Game.lua index fd75d1d4..bb89ab34 100644 --- a/src/core/Game.lua +++ b/src/core/Game.lua @@ -1047,6 +1047,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 diff --git a/src/core/Game2.lua b/src/core/Game2.lua index c63f4580..06190202 100644 --- a/src/core/Game2.lua +++ b/src/core/Game2.lua @@ -2587,6 +2587,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 diff --git a/src/core/Game3.lua b/src/core/Game3.lua index 8598835e..9c0bcb75 100644 --- a/src/core/Game3.lua +++ b/src/core/Game3.lua @@ -1115,6 +1115,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 diff --git a/src/core/GamepadMap.lua b/src/core/GamepadMap.lua index 20557cdf..7dce2757 100644 --- a/src/core/GamepadMap.lua +++ b/src/core/GamepadMap.lua @@ -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 = { diff --git a/src/core/Input.lua b/src/core/Input.lua index ce5a7c63..8432fac3 100644 --- a/src/core/Input.lua +++ b/src/core/Input.lua @@ -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 diff --git a/src/ui/BindingsMenu.lua b/src/ui/BindingsMenu.lua index 7acb1b75..ff9f1342 100644 --- a/src/ui/BindingsMenu.lua +++ b/src/ui/BindingsMenu.lua @@ -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] @@ -222,6 +233,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 diff --git a/tests/engine/sdl_controller_support_test.lua b/tests/engine/sdl_controller_support_test.lua new file mode 100644 index 00000000..d25aa6db --- /dev/null +++ b/tests/engine/sdl_controller_support_test.lua @@ -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")