From ea5675f1a93a42a1afa8b6b405803f78871c2abf Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:24:16 -0700 Subject: [PATCH 01/30] Add Debug Settings Architecture Repost of this commit since it was reverted. The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to config.debug and can be accessed anywhere via the DebugSettings singleton. Also provide a way to generate the debug menu and surface it from a debug button. To support this new button and future overlays, add a root element to the game so we can order overlays Updated Bool Selector to size correctly Updated Style Selector creation to make sure sizing and layout is right Reload main menu when you come back to it Make navigation stack a UIElement so it works with touch handling Fixes #697 --- client/src/BattleRoom.lua | 3 +- client/src/ChallengeModePlayerStack.lua | 3 +- client/src/ClientMatch.lua | 5 +- client/src/Game.lua | 69 ++- client/src/NavigationStack.lua | 36 +- client/src/PlayerStack.lua | 7 +- client/src/Shortcuts.lua | 8 +- client/src/config.lua | 31 +- client/src/debug/DebugMenu.lua | 90 ++++ client/src/debug/DebugSettings.lua | 418 ++++++++++++++++++ client/src/developer.lua | 3 +- client/src/scenes/CharacterSelect.lua | 77 ++-- client/src/scenes/DesignHelper.lua | 2 +- client/src/scenes/EndlessMenu.lua | 4 +- client/src/scenes/GameBase.lua | 5 +- client/src/scenes/MainMenu.lua | 15 +- client/src/scenes/ModManagement.lua | 8 +- client/src/scenes/OptionsMenu.lua | 34 +- client/src/scenes/PortraitGame.lua | 5 +- client/src/scenes/ReplayGame.lua | 3 +- client/src/scenes/Scene.lua | 3 +- client/src/scenes/TimeAttackMenu.lua | 4 +- client/src/ui/BoolSelector.lua | 81 ++-- client/src/ui/Carousel.lua | 3 +- client/src/ui/Grid.lua | 3 +- client/src/ui/GridElement.lua | 3 +- client/src/ui/MenuItem.lua | 19 +- client/src/ui/MultiPlayerSelectionWrapper.lua | 1 + client/src/ui/OverlayContainer.lua | 124 ++++++ client/src/ui/PagedUniGrid.lua | 3 +- client/src/ui/StackPanel.lua | 49 +- client/src/ui/Stepper.lua | 3 +- client/src/ui/touchHandler.lua | 14 +- common/engine/Match.lua | 6 +- docs/DebugSettings.md | 117 +++++ main.lua | 9 +- 36 files changed, 1083 insertions(+), 185 deletions(-) create mode 100644 client/src/debug/DebugMenu.lua create mode 100644 client/src/debug/DebugSettings.lua create mode 100644 client/src/ui/OverlayContainer.lua create mode 100644 docs/DebugSettings.md diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 5d1f1643..0beeb535 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -12,6 +12,7 @@ local BlackFadeTransition = require("client.src.scenes.Transitions.BlackFadeTran local Easings = require("client.src.Easings") local system = require("client.src.system") local GeneratorSource = require("common.engine.GeneratorSource") +local DebugSettings = require("client.src.debug.DebugSettings") -- A Battle Room is a session of matches, keeping track of the room number, player settings, wins / losses etc ---@class BattleRoom : Signal @@ -383,7 +384,7 @@ function BattleRoom:createScene(match) end -- for touch android players load a different scene - if (system.isMobileOS() or DEBUG_ENABLED) and self.gameScene.name ~= "PuzzleGame" and + if (system.isMobileOS() or DebugSettings.simulateMobileOS()) and self.gameScene.name ~= "PuzzleGame" and --but only if they are the only local player cause for 2p vs local using portrait mode would be bad tableUtils.count(self.players, function(p) return p.isLocal and p.human end) == 1 then for _, player in ipairs(self.players) do diff --git a/client/src/ChallengeModePlayerStack.lua b/client/src/ChallengeModePlayerStack.lua index 91d3bb19..eea1a3be 100644 --- a/client/src/ChallengeModePlayerStack.lua +++ b/client/src/ChallengeModePlayerStack.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local ClientStack = require("client.src.ClientStack") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class ChallengeModePlayerStack : ClientStack ---@field engine SimulatedStack @@ -198,7 +199,7 @@ function ChallengeModePlayerStack:drawMultibar() end function ChallengeModePlayerStack:drawDebug() - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local drawX = self.frameOriginX + self:canvasWidth() / 2 local drawY = 10 local padding = 14 diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index bf71af74..1574a3b0 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -16,6 +16,7 @@ local Telegraph = require("client.src.graphics.Telegraph") local MatchParticipant = require("client.src.MatchParticipant") local ChallengeModePlayerStack = require("client.src.ChallengeModePlayerStack") local NetworkProtocol = require("common.network.NetworkProtocol") +local DebugSettings = require("client.src.debug.DebugSettings") ---@module "client.src.ChallengeModePlayerStack" ---@class ClientMatch @@ -559,7 +560,7 @@ end function ClientMatch:drawCommunityMessage() -- Draw the community message - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then GraphicsUtil.printf(join_community_msg or "", 0, 668, consts.CANVAS_WIDTH, "center") end end @@ -598,7 +599,7 @@ function ClientMatch:render() end end - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local padding = 14 local drawX = 500 local drawY = -4 diff --git a/client/src/Game.lua b/client/src/Game.lua index 1952ff0c..22f765e5 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -33,6 +33,14 @@ local system = require("client.src.system") local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") +local DebugSettings = require("client.src.debug.DebugSettings") +local Button = require("client.src.ui.Button") +local TextButton = require("client.src.ui.TextButton") +local OverlayContainer = require("client.src.ui.OverlayContainer") +local DebugMenu = require("client.src.debug.DebugMenu") +local Label = require("client.src.ui.Label") +local UIElement = require("client.src.ui.UIElement") +local NavigationStack = require("client.src.NavigationStack") -- Provides a scale that is on .5 boundary to make sure it renders well. -- Useful for creating new canvas with a solid DPI @@ -106,12 +114,19 @@ local Game = class( -- time in seconds, can be used by other elements to track the passing of time beyond dt self.timer = love.timer.getTime() + + self.debugOverlay = nil + self.debugButton = nil + + -- Root UI element that contains all UI (scenes + overlays + debug) + self.uiRoot = UIElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) end ) Game.newCanvasSnappedScale = newCanvasSnappedScale function Game:load() + DebugSettings.init() PuzzleLibrary.cleanupDefaultPuzzles(consts.PUZZLES_SAVE_DIRECTORY) -- move to constructor @@ -131,8 +146,11 @@ function Game:load() self.input:importConfigurations(user_input_conf) end - self.navigationStack = require("client.src.NavigationStack") + self.navigationStack = NavigationStack({}) self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) + + -- Add navigation stack to root UI + self.uiRoot:addChild(self.navigationStack) self.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) end @@ -253,6 +271,8 @@ function Game:setupRoutine() self:initializeLocalPlayer() ModController:loadModFor(characters[GAME.localPlayer.settings.characterId], GAME.localPlayer, true) + + self:initializeDebugOverlay() end -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate @@ -366,9 +386,9 @@ function Game:update(dt) handleShortcuts() - prof.push("navigationStack update") - self.navigationStack:update(dt) - prof.pop("navigationStack update") + prof.push("uiRoot update") + self.uiRoot:update(dt) + prof.pop("uiRoot update") if self.backgroundImage then self.backgroundImage:update(dt) @@ -386,7 +406,7 @@ function Game:draw() love.graphics.clear() -- With this, self.globalCanvas is clear and set as our active canvas everything is being drawn to - self.navigationStack:draw() + self.uiRoot:draw() self:drawFPS() self:drawScaleInfo() @@ -402,8 +422,7 @@ function Game:draw() end function Game:drawFPS() - -- Draw the FPS if enabled - if self.config.show_fps then + if self.config.show_fps or DebugSettings.forceFPS() then love.graphics.print("FPS: " .. love.timer.getFPS(), 1, 1) end end @@ -666,4 +685,40 @@ function Game:setLanguage(lang_code) Localization:refresh_global_strings() end +function Game:initializeDebugOverlay() + if not DEBUG_ENABLED then + return + end + + self.debugButton = TextButton({ + x = consts.CANVAS_WIDTH - 50, + y = consts.CANVAS_HEIGHT - 50, + label = Label({ + text = "Debug", + translate = false, + hAlign = "center", + vAlign = "center" + }), + width = 40, + height = 40, + onClick = function() + if self.debugOverlay then + if not self.debugOverlay:isActive() then + self.debugOverlay:open() + end + end + end + }) + + local debugMenu = DebugMenu.makeDebugMenu({height = consts.CANVAS_HEIGHT - 40}) + self.debugOverlay = OverlayContainer({ + content = debugMenu + }) + + -- Add debug UI to root + self.uiRoot:addChild(self.debugButton) + self.uiRoot:addChild(self.debugOverlay) +end + + return Game diff --git a/client/src/NavigationStack.lua b/client/src/NavigationStack.lua index e9ef669c..89ff75a4 100644 --- a/client/src/NavigationStack.lua +++ b/client/src/NavigationStack.lua @@ -1,11 +1,23 @@ local DirectTransition = require("client.src.scenes.Transitions.DirectTransition") local logger = require("common.lib.logger") - -local NavigationStack = { - scenes = {}, - transition = nil, - callback = nil, -} +local UIElement = require("client.src.ui.UIElement") +local class = require("common.lib.class") +local consts = require("common.engine.consts") + +---@class NavigationStack : UiElement +---@field scenes Scene[] +---@field transition table? +---@field callback function? +local NavigationStack = class( + function(self) + self.scenes = {} + self.transition = nil + self.callback = nil + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + end, + UIElement +) function NavigationStack:push(newScene, transition) local activeScene = self.scenes[#self.scenes] @@ -142,7 +154,7 @@ function NavigationStack:getActiveScene() end end -function NavigationStack:update(dt) +function NavigationStack:updateSelf(dt) if self.transition then self.transition:update(dt) @@ -163,7 +175,7 @@ function NavigationStack:update(dt) end end -function NavigationStack:draw() +function NavigationStack:drawSelf() if self.transition then self.transition:draw() else @@ -174,4 +186,12 @@ function NavigationStack:draw() end end +function NavigationStack:getTouchedElement(x, y) + local activeScene = self:getActiveScene() + if activeScene and activeScene.uiRoot then + return activeScene.uiRoot:getTouchedElement(x, y) + end + return nil +end + return NavigationStack \ No newline at end of file diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index 4b325209..81f41022 100644 --- a/client/src/PlayerStack.lua +++ b/client/src/PlayerStack.lua @@ -15,6 +15,7 @@ local logger = require("common.lib.logger") require("client.src.analytics") local KeyDataEncoding = require("common.data.KeyDataEncoding") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") ---@module "common.data.LevelData" local floor, min, max = math.floor, math.min, math.max @@ -767,7 +768,7 @@ function PlayerStack:drawPopBurstParticle(atlas, quad, frameIndex, atlasDimensio end function PlayerStack:drawDebug() - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local engine = self.engine local x = self.origin_x + 480 @@ -863,7 +864,7 @@ function PlayerStack:drawDebug() end function PlayerStack:drawDebugPanels(shakeOffset) - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then return end @@ -952,7 +953,7 @@ function PlayerStack:drawRating() local rating if self.player.rating and tonumber(self.player.rating) then rating = self.player.rating - elseif config.debug_mode then + elseif DebugSettings.showStackDebugInfo() then rating = 1544 + self.player.playerNumber end diff --git a/client/src/Shortcuts.lua b/client/src/Shortcuts.lua index 2d428a57..7c009e3f 100644 --- a/client/src/Shortcuts.lua +++ b/client/src/Shortcuts.lua @@ -7,7 +7,13 @@ local logger = require("common.lib.logger") local function runSystemCommands() -- toggle debug mode if input.allKeys.isDown["d"] then - config.debug_mode = not config.debug_mode + if GAME.debugOverlay then + if GAME.debugOverlay.active then + GAME.debugOverlay:close() + else + GAME.debugOverlay:open() + end + end -- reload characters elseif input.allKeys.isDown["c"] then characters_reload_graphics() diff --git a/client/src/config.lua b/client/src/config.lua index c83d8b2e..93c69a3f 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -3,6 +3,7 @@ json = require("common.lib.dkjson") local util = require("common.lib.util") local fileUtils = require("client.src.FileUtils") local consts = require("common.engine.consts") +local DebugSettings = require("client.src.debug.DebugSettings") require("client.src.globals") -- Default configuration values @@ -27,12 +28,7 @@ require("client.src.globals") ---@field SFX_volume number ---@field music_volume number ---@field enableMenuMusic boolean ----@field debug_mode boolean ----@field debugShowServers boolean ----@field debugShowDesignHelper boolean ----@field debugProfile boolean ----@field debugProfileThreshold integer ----@field debug_vsFramesBehind integer +---@field debug DebugConfig? ---@field show_fps boolean ---@field show_ingame_infos boolean ---@field danger_music_changeback_delay boolean @@ -93,12 +89,8 @@ config = { SFX_volume = 50, music_volume = 50, enableMenuMusic = true, - -- Debug mode flag - debug_mode = false, - debugShowServers = false, - debugShowDesignHelper = false, - debugProfile = false, - debugProfileThreshold = 50, + -- Debug settings persisted separately + debug = DebugSettings.getDefaultConfigValues(), -- Show FPS in the top-left corner of the screen show_fps = false, @@ -226,19 +218,6 @@ config = { if type(read_data.music_volume) == "number" then configTable.music_volume = util.bound(0, read_data.music_volume, 100) end - if type(read_data.debug_mode) == "boolean" then - configTable.debug_mode = read_data.debug_mode - end - if type(read_data.debugShowServers) == "boolean" then - configTable.debugShowServers = read_data.debugShowServers - end - if type(read_data.debugShowDesignHelper) == "boolean" then - configTable.debugShowDesignHelper = read_data.debugShowDesignHelper - end - if type(read_data.debugProfile) == "boolean" then - configTable.debugProfile = read_data.debugProfile - end - -- debugProfileThreshold is not saved to prevent accidental dense profiling if type(read_data.show_fps) == "boolean" then configTable.show_fps = read_data.show_fps end @@ -310,6 +289,8 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end + + configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end end diff --git a/client/src/debug/DebugMenu.lua b/client/src/debug/DebugMenu.lua new file mode 100644 index 00000000..311e352f --- /dev/null +++ b/client/src/debug/DebugMenu.lua @@ -0,0 +1,90 @@ +local class = require("common.lib.class") +local ui = require("client.src.ui") +local DebugSettings = require("client.src.debug.DebugSettings") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +local function createDebugBoolSelector(debugKey, onChangeFn) + return ui.BoolSelector({ + startValue = DebugSettings.get(debugKey) --[[@as boolean]], + onValueChange = function(selfElement, value) + GAME.theme:playMoveSfx() + DebugSettings.set(debugKey, value) + if onChangeFn then + onChangeFn() + end + end + }) +end + +local function createDebugSlider(debugKey, min, max, onValueChangeFn) + return ui.Slider({ + min = min, + max = max, + value = DebugSettings.get(debugKey) --[[@as number]], + tickLength = math.ceil(100 / max), + onValueChange = function(slider) + DebugSettings.set(debugKey, slider.value) + if onValueChangeFn then + onValueChangeFn(slider) + end + end + }) +end + +local function buildDebugMenuItems(options) + local debugMenuOptions = {} + + for _, def in ipairs(DebugSettings.getDefinitions()) do + if def.type == "boolean" then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createBoolSelectorMenuItem( + def.label, + nil, + false, + createDebugBoolSelector(def.key) + ) + elseif def.type == "number" then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createSliderMenuItem( + def.label, + nil, + false, + createDebugSlider(def.key, def.min or 0, def.max or 100) + ) + end + end + + if DEBUG_ENABLED then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() + GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) + end) + end + + if options.showBackButton then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + GAME.theme:playCancelSfx() + if options.onBack then + options.onBack() + end + end) + end + + return debugMenuOptions +end + +-- Menu for configuring debug settings +---@class DebugMenu : Menu +local DebugMenu = class(function(self, options) + +end, ui.Menu) + +-- We need a factory because menu items must be passed into the base class init +function DebugMenu.makeDebugMenu(options) + options = options or {} + options.x = 0 + options.y = 0 + options.hAlign = "center" + options.vAlign = "center" + options.menuItems = buildDebugMenuItems(options) + return DebugMenu(options) +end + +return DebugMenu diff --git a/client/src/debug/DebugSettings.lua b/client/src/debug/DebugSettings.lua new file mode 100644 index 00000000..43b02a2d --- /dev/null +++ b/client/src/debug/DebugSettings.lua @@ -0,0 +1,418 @@ +local logger = require("common.lib.logger") + +-- Singleton class for managing runtime debug settings +-- Settings are persisted to the config file under config.debug +---@class DebugSettings +local DebugSettings = {} + +---@class DebugConfig +---@field showStackDebugInfo boolean +---@field showUIElementBorders boolean +---@field simulateMobileOS boolean +---@field forceFPS boolean +---@field drawGraphicsStats boolean +---@field showRuntimeGraph boolean +---@field vsFramesBehind number +---@field showDebugServers boolean +---@field showDesignHelper boolean +---@field profileFrameTimes boolean +---@field profileThreshold number + +---@class DebugSettingDefinition +---@field key string The key used in config.debug table +---@field type "boolean"|"number" The type of the setting +---@field default boolean|number The default value +---@field label string The display label for the UI +---@field min number? Minimum value for number types +---@field max number? Maximum value for number types +---@field debugBuildOnly boolean? Forces the setting off and hides it in non-debug builds + +-- All debug settings defined in one place with their metadata +local settingDefinitions = { + { + key = "showStackDebugInfo", + type = "boolean", + default = false, + label = "Show Stack Debug Info", + debugBuildOnly = false + }, + { + key = "showUIElementBorders", + type = "boolean", + default = false, + label = "Show UI Element Borders", + debugBuildOnly = true + }, + { + key = "simulateMobileOS", + type = "boolean", + default = false, + label = "Simulate Mobile OS", + debugBuildOnly = true + }, + { + key = "forceFPS", + type = "boolean", + default = false, + label = "Force FPS Display", + debugBuildOnly = true + }, + { + key = "drawGraphicsStats", + type = "boolean", + default = false, + label = "Draw Graphics Stats", + debugBuildOnly = true + }, + { + key = "showRuntimeGraph", + type = "boolean", + default = false, + label = "Show Runtime Graph", + debugBuildOnly = true + }, + { + key = "vsFramesBehind", + type = "number", + default = 0, + label = "VS Frames Behind", + min = 0, + max = 200, + debugBuildOnly = true + }, + { + key = "showDebugServers", + type = "boolean", + default = false, + label = "Show Debug Servers", + debugBuildOnly = false + }, + { + key = "showDesignHelper", + type = "boolean", + default = false, + label = "Show Design Helper", + debugBuildOnly = true + }, + { + key = "profileFrameTimes", + type = "boolean", + default = false, + label = "Profile frame times", + debugBuildOnly = false + }, + { + key = "profileThreshold", + type = "number", + default = 50, + label = "Discard frames below duration (ms)", + min = 0, + max = 100, + debugBuildOnly = false + } +} + +local function isNonDebugBuild() + return not DEBUG_ENABLED +end + +local function shouldLockToDefault(def) + return isNonDebugBuild() and def.debugBuildOnly +end + +---Creates a DebugConfig table populated with default values. +---@return DebugConfig +local function createDefaultConfig() + local defaults = {} + for _, def in ipairs(settingDefinitions) do + local value = def.default + defaults[def.key] = value + end + ---@type DebugConfig + return defaults +end + +--- Current settings values (loaded from config.debug) +---@type DebugConfig +local settings = createDefaultConfig() + +local function clampNumber(value, minValue, maxValue) + if minValue then + value = math.max(minValue, value) + end + if maxValue then + value = math.min(maxValue, value) + end + return value +end + +local function findDefinition(key) + for _, def in ipairs(settingDefinitions) do + if def.key == key then + return def + end + end +end + +---Returns default debug configuration values keyed by setting. +---@return DebugConfig +function DebugSettings.getDefaultConfigValues() + return createDefaultConfig() +end + +---Normalizes persisted debug configuration values using definitions. +---@param persisted table|nil +---@return DebugConfig +function DebugSettings.normalizeConfigValues(persisted) + local normalized = createDefaultConfig() + local source = persisted or {} + + for _, def in ipairs(settingDefinitions) do + local value = source[def.key] + if def.type == "boolean" then + if type(value) == "boolean" then + normalized[def.key] = value + end + elseif def.type == "number" then + if type(value) ~= "number" then + value = def.default + end + normalized[def.key] = clampNumber(value, def.min, def.max) + end + end + + return normalized +end + +-- Initializes debug settings from config +function DebugSettings.init() + config.debug = config.debug or {} + + config.debug = DebugSettings.normalizeConfigValues(config.debug) + + for _, def in ipairs(settingDefinitions) do + local value = config.debug[def.key] + if shouldLockToDefault(def) then + settings[def.key] = def.default + else + settings[def.key] = value + end + end +end + +-- Saves current settings to config +local function saveSettings() + config.debug = config.debug or {} + + for _, def in ipairs(settingDefinitions) do + config.debug[def.key] = settings[def.key] + end + + write_conf_file() +end + +local releaseDefinitionCache +-- Returns all setting definitions for UI generation +---@return DebugSettingDefinition[] +function DebugSettings.getDefinitions() + if not isNonDebugBuild() then + return settingDefinitions + end + + if not releaseDefinitionCache then + releaseDefinitionCache = {} + for _, def in ipairs(settingDefinitions) do + if not def.debugBuildOnly then + releaseDefinitionCache[#releaseDefinitionCache + 1] = def + end + end + end + + return releaseDefinitionCache +end + +-- Gets the value of a setting by key +---@param key string +---@return boolean|number +function DebugSettings.get(key) + local def = findDefinition(key) + + if isNonDebugBuild() then + if def then + if shouldLockToDefault(def) then + return def.default + end + + if def.type == "boolean" then + return settings[key] or false + elseif def.type == "number" then + return settings[key] or 0 + end + end + return false + end + + if not def then + return false + end + + return settings[key] +end + +-- Sets the value of a setting by key +---@param key string +---@param value boolean|number +function DebugSettings.set(key, value) + local def = findDefinition(key) + if not def then + logger.warn("Attempted to set unknown debug setting: " .. key) + return + end + + if def.type == "boolean" then + settings[key] = value and true or false + else + local numericValue = type(value) == "number" and value or def.default + settings[key] = clampNumber(numericValue, def.min, def.max) + end + saveSettings() +end + +-- Returns whether to show stack debug information +---@return boolean +function DebugSettings.showStackDebugInfo() + return DebugSettings.get("showStackDebugInfo") --[[@as boolean]] +end + +-- Returns whether to show UI element borders +---@return boolean +function DebugSettings.showUIElementBorders() + return DebugSettings.get("showUIElementBorders") --[[@as boolean]] +end + +-- Returns whether to simulate mobile OS on desktop +---@return boolean +function DebugSettings.simulateMobileOS() + return DebugSettings.get("simulateMobileOS") --[[@as boolean]] +end + +-- Returns whether to force FPS display in debug builds +---@return boolean +function DebugSettings.forceFPS() + return DebugSettings.get("forceFPS") --[[@as boolean]] +end + +-- Returns whether to draw graphics stats (draw calls, texture memory, etc.) +---@return boolean +function DebugSettings.drawGraphicsStats() + return DebugSettings.get("drawGraphicsStats") --[[@as boolean]] +end + +-- Returns whether to show the runtime graph +---@return boolean +function DebugSettings.showRuntimeGraph() + return DebugSettings.get("showRuntimeGraph") --[[@as boolean]] +end + +-- Returns the VS frames behind value (only applicable when showStackDebugInfo is true) +---@return number +function DebugSettings.getVSFramesBehind() + return DebugSettings.get("vsFramesBehind") --[[@as number]] +end + +-- Sets whether to show stack debug information +---@param value boolean +function DebugSettings.setShowStackDebugInfo(value) + DebugSettings.set("showStackDebugInfo", value) +end + +-- Sets whether to show UI element borders +---@param value boolean +function DebugSettings.setShowUIElementBorders(value) + DebugSettings.set("showUIElementBorders", value) +end + +-- Sets whether to simulate mobile OS on desktop +---@param value boolean +function DebugSettings.setSimulateMobileOS(value) + DebugSettings.set("simulateMobileOS", value) +end + +-- Sets whether to force FPS display in debug builds +---@param value boolean +function DebugSettings.setForceFPS(value) + DebugSettings.set("forceFPS", value) +end + +-- Sets whether to draw graphics stats +---@param value boolean +function DebugSettings.setDrawGraphicsStats(value) + DebugSettings.set("drawGraphicsStats", value) +end + +-- Sets whether to show the runtime graph +---@param value boolean +function DebugSettings.setShowRuntimeGraph(value) + DebugSettings.set("showRuntimeGraph", value) +end + +-- Sets the VS frames behind value +---@param value number +function DebugSettings.setVSFramesBehind(value) + DebugSettings.set("vsFramesBehind", value) +end + +-- Returns whether to show debug servers in main menu +---@return boolean +function DebugSettings.showDebugServers() + return DebugSettings.get("showDebugServers") --[[@as boolean]] +end + +-- Sets whether to show debug servers in main menu +---@param value boolean +function DebugSettings.setShowDebugServers(value) + DebugSettings.set("showDebugServers", value) +end + +-- Returns whether to show design helper in main menu +---@return boolean +function DebugSettings.showDesignHelper() + return DebugSettings.get("showDesignHelper") --[[@as boolean]] +end + +-- Sets whether to show design helper in main menu +---@param value boolean +function DebugSettings.setShowDesignHelper(value) + DebugSettings.set("showDesignHelper", value) +end + +-- Returns whether to profile frame times +---@return boolean +function DebugSettings.getProfileFrameTimes() + return DebugSettings.get("profileFrameTimes") --[[@as boolean]] +end + +-- Sets whether to profile frame times +---@param value boolean +function DebugSettings.setProfileFrameTimes(value) + DebugSettings.set("profileFrameTimes", value) + local prof = require("common.lib.zoneProfiler") + prof.enable(value) + prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) +end + +-- Returns the profile threshold in milliseconds +---@return number +function DebugSettings.getProfileThreshold() + return DebugSettings.get("profileThreshold") --[[@as number]] +end + +-- Sets the profile threshold in milliseconds +---@param value number +function DebugSettings.setProfileThreshold(value) + DebugSettings.set("profileThreshold", value) + local prof = require("common.lib.zoneProfiler") + prof.setDurationFilter(value / 1000) +end + +return DebugSettings diff --git a/client/src/developer.lua b/client/src/developer.lua index ff4cc477..22f52222 100644 --- a/client/src/developer.lua +++ b/client/src/developer.lua @@ -1,4 +1,5 @@ local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") ---@diagnostic disable: duplicate-set-field -- Put any local development changes you need in here that you don't want commited. @@ -7,7 +8,7 @@ local system = require("client.src.system") local function enableProfiler(threshold) local prof = require("common.lib.zoneProfiler") prof.enable(true) - prof.setDurationFilter((threshold or config.debugProfileThreshold) / 1000) + prof.setDurationFilter((threshold or DebugSettings.getProfileThreshold()) / 1000) end local developerTools = {} diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 8ceac080..eb29eb46 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -676,9 +676,18 @@ end ---@param player Player ---@param width number ----@return BoolSelector rankedSelector +---@return StackPanel rankedSelectionContainer function CharacterSelect:createRankedSelection(player, width) - local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vFill = true, width = width, vAlign = "center", hAlign = "center"}) + + -- player number icon + local playerIndex = tableUtils.indexOf(self.players, player) + local playerNumberIcon = ui.ImageContainer({ + image = themes[config.theme].images.IMG_players[playerIndex], + scale = 2, + vAlign = "center" + }) + + local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vAlign = "center"}) rankedSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() player:setWantsRanked(value) @@ -688,31 +697,40 @@ function CharacterSelect:createRankedSelection(player, width) player:connectSignal("wantsRankedChanged", rankedSelector, rankedSelector.setValue) + local container = ui.StackPanel( + { + alignment = "left", + height = rankedSelector.height, + hAlign = "center", + vAlign = "center", + } + ) + container.playerNumberIcon = playerNumberIcon + container.rankedSelector = rankedSelector + container:addElement(playerNumberIcon) + container:addElement(ui.UiElement({width = 8, height = 8})) + container:addElement(rankedSelector) + container:addElement(ui.UiElement({width = 8, height = 8})) + + return container +end + +---@param player Player +---@param width number +---@return StackPanel styleSelectionContainer +---@return BoolSelector styleSelector +function CharacterSelect:createStyleSelection(player, width) -- player number icon local playerIndex = tableUtils.indexOf(self.players, player) local playerNumberIcon = ui.ImageContainer({ image = themes[config.theme].images.IMG_players[playerIndex], - hAlign = "left", - vAlign = "center", - x = 2, scale = 2, + vAlign = "center" }) - rankedSelector.playerNumberIcon = playerNumberIcon - rankedSelector:addChild(rankedSelector.playerNumberIcon) - return rankedSelector -end - ----@param player Player ----@param width number ----@return BoolSelector styleSelector -function CharacterSelect:createStyleSelection(player, width) local styleSelector = ui.BoolSelector({ startValue = (player.settings.style == GameModes.Styles.MODERN), - vFill = true, - width = width, - vAlign = "center", - hAlign = "center", + isEnabled = player.isLocal }) -- onValueChange should get implemented by the caller @@ -729,19 +747,20 @@ function CharacterSelect:createStyleSelection(player, width) end ) - -- player number icon - local playerIndex = tableUtils.indexOf(self.players, player) - local playerNumberIcon = ui.ImageContainer({ - image = themes[config.theme].images.IMG_players[playerIndex], - hAlign = "left", + local container = ui.StackPanel({ + alignment = "left", + height = styleSelector.height, + hAlign = "center", vAlign = "center", - x = 8, - scale = 2, }) - styleSelector.playerNumberIcon = playerNumberIcon - styleSelector:addChild(styleSelector.playerNumberIcon) - - return styleSelector + container.playerNumberIcon = playerNumberIcon + container.styleSelector = styleSelector + container:addElement(playerNumberIcon) + container:addElement(ui.UiElement({width = 8, height = 8})) + container:addElement(styleSelector) + container:addElement(ui.UiElement({width = 8, height = 8})) + + return container, styleSelector end function CharacterSelect:createRecordsBox(lastText) diff --git a/client/src/scenes/DesignHelper.lua b/client/src/scenes/DesignHelper.lua index ac962771..2fc41930 100644 --- a/client/src/scenes/DesignHelper.lua +++ b/client/src/scenes/DesignHelper.lua @@ -41,7 +41,7 @@ function DesignHelper:loadGrid() end function DesignHelper:loadRankedSelection(width) - local rankedSelector = ui.BoolSelector({startValue = true, vFill = true, width = width, vAlign = "center", hAlign = "center"}) + local rankedSelector = ui.BoolSelector({startValue = true, width = width, vAlign = "center", hAlign = "center"}) return rankedSelector end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index 92bd9d96..a0a00b27 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -49,8 +49,8 @@ function EndlessMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleSelector, player) + local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleContainer, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/scenes/GameBase.lua b/client/src/scenes/GameBase.lua index 8c860557..bf1f793e 100644 --- a/client/src/scenes/GameBase.lua +++ b/client/src/scenes/GameBase.lua @@ -15,6 +15,7 @@ local ui = require("client.src.ui") local FileUtils = require("client.src.FileUtils") local ClientStack = require("client.src.ClientStack") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") -- Scene template for running any type of game instance (endless, vs-self, replays, etc.) ---@class GameBase : Scene @@ -444,14 +445,14 @@ function GameBase:drawHUD() end stack:drawLevel() - if stack.analytic and not config.debug_mode then + if stack.analytic and not DebugSettings.showStackDebugInfo() then --prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() --prof.pop("Stack:drawAnalyticData") end end - if not config.debug_mode and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details + if not DebugSettings.showStackDebugInfo() and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details GraphicsUtil.print(GAME.battleRoom.spectatorString, themes[config.theme].spectators_Pos[1], themes[config.theme].spectators_Pos[2]) end diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index 8054187c..ffb9f72c 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -4,6 +4,7 @@ local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") +local DebugSettings = require("client.src.debug.DebugSettings") local EndlessMenu = require("client.src.scenes.EndlessMenu") local PuzzleMenu = require("client.src.scenes.PuzzleMenu") local TimeAttackMenu = require("client.src.scenes.TimeAttackMenu") @@ -40,6 +41,16 @@ local function switchToScene(scene, transition) GAME.navigationStack:push(scene, transition) end +function MainMenu:refresh() + if self.menu then + self.menu:detach() + self.menu = nil + end + + self.menu = self:createMainMenu() + self.uiRoot:addChild(self.menu) +end + function MainMenu:createMainMenu() local menuItems = {ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() @@ -104,12 +115,12 @@ function MainMenu:createMainMenu() } local function addDebugMenuItems() - if config.debugShowServers then + if DebugSettings.showDebugServers() then for i, menuItem in ipairs(debugMenuItems) do menu:addMenuItem(i + 7, menuItem) end end - if config.debugShowDesignHelper then + if DebugSettings.showDesignHelper() then menu:addMenuItem(#menu.menuItems, ui.MenuItem.createButtonMenuItem("Design Helper", nil, nil, function() switchToScene(DesignHelper()) end)) diff --git a/client/src/scenes/ModManagement.lua b/client/src/scenes/ModManagement.lua index 95761b77..5b5bac0d 100644 --- a/client/src/scenes/ModManagement.lua +++ b/client/src/scenes/ModManagement.lua @@ -167,7 +167,7 @@ function ModManagement:loadStageGrid() if stageId ~= consts.RANDOM_STAGE_SPECIAL_VALUE then local stage = allStages[stageId] local icon = ui.ImageContainer({drawBorders = true, image = stage.images.thumbnail, hFill = true, vFill = true, hAlign = "center", vAlign = "center"}) - local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center"}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() stage:enable(boolSelector.value) @@ -181,7 +181,7 @@ function ModManagement:loadStageGrid() GAME.localPlayer:setStage(stages[consts.RANDOM_STAGE_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center"}) visibilitySelector.onValueChange = function(boolSelector, value) end local name = ui.Label({text = stage.display_name, translate = false, hAlign = "center", vAlign = "center"}) @@ -241,7 +241,7 @@ function ModManagement:loadCharacterGrid() if characterId ~= consts.RANDOM_CHARACTER_SPECIAL_VALUE then local character = allCharacters[characterId] local icon = ui.ImageContainer({drawBorders = true, image = character.images.icon, hFill = true, vFill = true}) - local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center"}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() character:enable(boolSelector.value) @@ -255,7 +255,7 @@ function ModManagement:loadCharacterGrid() GAME.localPlayer:setCharacter(characters[consts.RANDOM_CHARACTER_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center"}) visibilitySelector.onValueChange = function(boolSelector, value) end local displayName = ui.Label({text = character.display_name, translate = false, hAlign = "center", vAlign = "center"}) diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index daf25251..7b7477f3 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -2,6 +2,7 @@ local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local inputManager = require("client.src.inputManager") local save = require("client.src.save") +local DebugMenu = require("client.src.debug.DebugMenu") local consts = require("common.engine.consts") local fileUtils = require("client.src.FileUtils") local analytics = require("client.src.analytics") @@ -15,7 +16,6 @@ local ModManagement = require("client.src.scenes.ModManagement") local system = require("client.src.system") local JsonSafePrecision = require("common.data.JsonSafePrecision") local logger = require("common.lib.logger") -local prof = require("common.lib.zoneProfiler") -- Scene for the options menu local OptionsMenu = class(function(self, sceneParams) @@ -486,30 +486,14 @@ function OptionsMenu:loadSoundMenu() end function OptionsMenu:loadDebugMenu() - local debugMenuOptions = { - ui.MenuItem.createToggleButtonGroupMenuItem("op_debug_mode", nil, nil, createToggleButtonGroup("debug_mode")), - ui.MenuItem.createSliderMenuItem("VS Frames Behind", nil, false, createConfigSlider("debug_vsFramesBehind", 0, 200)), - ui.MenuItem.createToggleButtonGroupMenuItem("Show Debug Servers", nil, false, createToggleButtonGroup("debugShowServers")), - ui.MenuItem.createToggleButtonGroupMenuItem("Show Design Helper", nil, false, createToggleButtonGroup("debugShowDesignHelper")), - ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() - GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) - end), - ui.MenuItem.createToggleButtonGroupMenuItem("Profile frame times", nil, false, createToggleButtonGroup("debugProfile", - function() - prof.enable(config.debugProfile) - prof.setDurationFilter(config.debugProfileThreshold / 1000) - end)), - ui.MenuItem.createSliderMenuItem("Discard frames below duration (ms)", nil, false, createConfigSlider("debugProfileThreshold", 0, 100, - function() - prof.setDurationFilter(config.debugProfileThreshold / 1000) - end)), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() - GAME.theme:playCancelSfx() - self:switchToScreen("baseMenu") - end), - } - - return ui.Menu.createCenteredMenu(debugMenuOptions) + local debugMenu = DebugMenu.makeDebugMenu({ + showBackButton = true, + onBack = function() + self:switchToScreen("baseMenu") + end, + height = themes[config.theme].main_menu_max_height + }) + return debugMenu end function OptionsMenu:loadAboutMenu() diff --git a/client/src/scenes/PortraitGame.lua b/client/src/scenes/PortraitGame.lua index 3f05ab16..d95886cb 100644 --- a/client/src/scenes/PortraitGame.lua +++ b/client/src/scenes/PortraitGame.lua @@ -6,6 +6,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local ui = require("client.src.ui") local input = require("client.src.inputManager") local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") local PortraitGame = class(function(self, sceneParams) end, @@ -193,7 +194,7 @@ function PortraitGame:flipToPortrait() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_HEIGHT, consts.CANVAS_WIDTH, {dpiscale=GAME:newCanvasSnappedScale()}) local width, height, _ = love.window.getMode() - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then -- flip the window dimensions to portrait love.window.updateMode(height, width, {}) love.window.setFullscreen(true) @@ -245,7 +246,7 @@ function PortraitGame:returnToLandscape() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) -- flip the window dimensions to landscape local width, height, _ = love.window.getMode() - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then love.window.updateMode(height, width, {}) love.window.setFullscreen(false) --GAME:updateCanvasPositionAndScale(width, height) diff --git a/client/src/scenes/ReplayGame.lua b/client/src/scenes/ReplayGame.lua index f2e306a7..911cc0a9 100644 --- a/client/src/scenes/ReplayGame.lua +++ b/client/src/scenes/ReplayGame.lua @@ -5,6 +5,7 @@ local util = require("common.lib.util") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local prof = require("common.lib.zoneProfiler") +local DebugSettings = require("client.src.debug.DebugSettings") local ReplayGame = class( function (self, sceneParams) @@ -141,7 +142,7 @@ function ReplayGame:drawHUD() end stack:drawLevel() - if stack.analytic and not DEBUG_ENABLED then + if stack.analytic and not DebugSettings.showStackDebugInfo() then prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() prof.pop("Stack:drawAnalyticData") diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index ea9f9adc..0270b7cc 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -5,6 +5,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") local SoundController = require("client.src.music.SoundController") local directsFocus = require("client.src.ui.FocusDirector") +local DebugSettings = require("client.src.debug.DebugSettings") ---@alias sceneMusic ("none" | "main" | "title_screen" | "select_screen") @@ -83,7 +84,7 @@ end function Scene:drawCommunityMessage() -- Draw the community message - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then GraphicsUtil.printf(join_community_msg or "", 0, (668 / 720) * GAME.globalCanvas:getHeight(), GAME.globalCanvas:getWidth(), "center") end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 1ab39f81..c846420e 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -48,8 +48,8 @@ function TimeAttackMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleSelector, player) + local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleContainer, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/ui/BoolSelector.lua b/client/src/ui/BoolSelector.lua index 5d4d7e83..2827daeb 100644 --- a/client/src/ui/BoolSelector.lua +++ b/client/src/ui/BoolSelector.lua @@ -3,6 +3,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class BoolSelectorOptions : UiElementOptions ---@field startValue boolean? @@ -11,9 +12,22 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@class BoolSelector : UiElement ---@field value boolean ---@field vertical boolean -local BoolSelector = class(function(boolSelector, options) - boolSelector.value = options.startValue or false - boolSelector.vertical = false +---@field circleRadius number +---@field extraDistance number +---@field lengthPadding number +---@field widthPadding number +local BoolSelector = class(function(self, options) + self.value = options.startValue or false + self.vertical = false + self.circleRadius = 10 + self.extraDistance = 16 + self.lengthPadding = 2 + self.widthPadding = 2 + self.onValueChange = options.onValueChange or function() end + + -- Calculate initial dimensions + self.width = self:calculateWidth() + self.height = self:calculateHeight() end, UiElement) @@ -62,57 +76,60 @@ function BoolSelector:setValue(value) end end +---@return number +function BoolSelector:calculateWidth() + local width = self.circleRadius * 2 + 2 * self.widthPadding + if not self.vertical then + width = width + self.extraDistance + end + return width +end + +---@return number +function BoolSelector:calculateHeight() + local height = self.circleRadius * 2 + 2 * self.lengthPadding + if self.vertical then + height = height + self.extraDistance + end + return height +end + -- other code may implement a callback here -- function BoolSelector.onValueChange() end -local circleRadius = 10 -local extraDistance = 16 -local lengthPadding = 2 -local widthPadding = 2 -local totalWidth = 0 -local totalLength = 0 -local fakeCenteredChild = {hAlign = "center", vAlign = "center", width = totalWidth, height = totalLength} - function BoolSelector:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(0, 0, 1, 1) GraphicsUtil.drawRectangle("line", self.x + 1, self.y + 1, self.width - 2, self.height - 2) GraphicsUtil.setColor(1, 1, 1, 1) end - local circleX = circleRadius + widthPadding - local circleY = circleRadius + lengthPadding - totalWidth = circleRadius * 2 + 2 * widthPadding - totalLength = circleRadius * 2 + 2 * lengthPadding + local drawX = self.x + self.widthPadding + local drawY = self.y + self.lengthPadding + local drawWidth = self.width - 2 * self.widthPadding + local drawHeight = self.height - 2 * self.lengthPadding + + local circleX = self.circleRadius + local circleY = self.circleRadius + if self.vertical then - totalLength = totalLength + extraDistance if self.value == false then - circleY = circleY + extraDistance + circleY = circleY + self.extraDistance end else - totalWidth = totalWidth + extraDistance if self.value then - circleX = circleX + extraDistance + circleX = circleX + self.extraDistance end end - fakeCenteredChild.width = totalWidth - fakeCenteredChild.height = totalLength - - -- we want these to be centered but creating a Rectangle / Circle ui element is maybe a bit too much? - -- so just apply the translation via a fake element with all necessary props - GraphicsUtil.applyAlignment(self, fakeCenteredChild) - love.graphics.translate(self.x, self.y) if self.value then GraphicsUtil.setColor(30/255, 190/255, 67/255, 1) - GraphicsUtil.drawRectangle("fill", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) + GraphicsUtil.drawRectangle("fill", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) GraphicsUtil.setColor(1, 1, 1, 1) end - GraphicsUtil.drawRectangle("line", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) - love.graphics.circle("fill", circleX, circleY, circleRadius) - - GraphicsUtil.resetAlignment() + GraphicsUtil.drawRectangle("line", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) + love.graphics.circle("fill", drawX + circleX, drawY + circleY, self.circleRadius) end return BoolSelector \ No newline at end of file diff --git a/client/src/ui/Carousel.lua b/client/src/ui/Carousel.lua index 4215d017..bfd71988 100644 --- a/client/src/ui/Carousel.lua +++ b/client/src/ui/Carousel.lua @@ -4,6 +4,7 @@ local Focusable = require(PATH .. ".Focusable") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") +local DebugSettings = require("client.src.debug.DebugSettings") local function calculateFontSize(height) return math.floor(height / 2) + 1 @@ -87,7 +88,7 @@ function Carousel.setPassengerByIndex(self, index) end function Carousel:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) end end diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index 2dfc7a2b..aedf54c0 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -3,6 +3,7 @@ local UiElement = require(PATH .. ".UIElement") local GridElement = require(PATH .. ".GridElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local Grid = class(function(self, options) self.unitSize = options.unitSize @@ -73,7 +74,7 @@ function Grid:createElementAt(x, y, width, height, description, uiElement, noPad end function Grid:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 1, 1, 0.5) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/GridElement.lua b/client/src/ui/GridElement.lua index d0725fdf..ff916567 100644 --- a/client/src/ui/GridElement.lua +++ b/client/src/ui/GridElement.lua @@ -2,6 +2,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local GridElement = class(function(gridElement, options) if options.content then @@ -16,7 +17,7 @@ local GridElement = class(function(gridElement, options) gridElement.gridHeight = options.gridHeight if options.drawBorders ~= nil then gridElement.drawBorders = options.drawBorders - elseif DEBUG_ENABLED then + elseif DebugSettings.showUIElementBorders() then gridElement.drawBorders = true else gridElement.drawBorders = false diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 7931404d..400192c1 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -5,6 +5,7 @@ local TextButton = require(PATH .. ".TextButton") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") -- MenuItem is a specific UIElement that all children of Menu should be local MenuItem = class(function(self, options) @@ -27,7 +28,7 @@ function MenuItem.createMenuItem(label, item) menuItem.width = label.width + (2 * MenuItem.PADDING) - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then label.height = math.max(30, label.height + (2 * MenuItem.PADDING)) menuItem.height = math.max(30, label.height, item and item.height or 0) else @@ -38,7 +39,7 @@ function MenuItem.createMenuItem(label, item) local spaceBetween = 16 item.x = label.width + spaceBetween item.vAlign = "center" - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then item.height = math.max(30, item.height) end menuItem.width = item.x + item.width + MenuItem.PADDING @@ -128,7 +129,19 @@ function MenuItem.createSliderMenuItem(text, replacements, translate, slider) end local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) local menuItem = MenuItem.createMenuItem(label, slider) - + + return menuItem +end + +function MenuItem.createBoolSelectorMenuItem(text, replacements, translate, boolSelector) + assert(text ~= nil) + assert(boolSelector ~= nil) + if translate == nil then + translate = true + end + local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) + local menuItem = MenuItem.createMenuItem(label, boolSelector) + return menuItem end diff --git a/client/src/ui/MultiPlayerSelectionWrapper.lua b/client/src/ui/MultiPlayerSelectionWrapper.lua index c0802d71..969dba23 100644 --- a/client/src/ui/MultiPlayerSelectionWrapper.lua +++ b/client/src/ui/MultiPlayerSelectionWrapper.lua @@ -18,6 +18,7 @@ end, StackPanel) function MultiPlayerSelectionWrapper:addElement(uiElement, player) + assert(uiElement.receiveInputs) self.wrappedElements[player] = uiElement uiElement.yieldFocus = function() self.yieldFocus() diff --git a/client/src/ui/OverlayContainer.lua b/client/src/ui/OverlayContainer.lua new file mode 100644 index 00000000..e3a41467 --- /dev/null +++ b/client/src/ui/OverlayContainer.lua @@ -0,0 +1,124 @@ +local class = require("common.lib.class") +local UiElement = require("client.src.ui.UIElement") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") + +-- Full-screen semi-transparent overlay that holds a centered UI element +-- Closes on click outside content area +---@class OverlayContainer : UiElement +---@field content UiElement? The centered content element +---@field onClose fun()? Callback invoked when overlay closes +---@field active boolean True when overlay is open and processing input +local OverlayContainer = class( + function(self, options) + options = options or {} + self.content = options.content + self.onClose = options.onClose + self.active = false + + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + self:setVisibility(false) + + if self.content then + self:addChild(self.content) + self.content.hAlign = "center" + self.content.vAlign = "center" + end + end, + UiElement +) + +-- Opens the overlay +function OverlayContainer:open() + self.active = true + self:setVisibility(true) +end + +-- Closes the overlay +function OverlayContainer:close() + if not self.active then + return + end + + self.active = false + self:setVisibility(false) + + if self.onClose then + self.onClose() + end +end + +-- Checks if the overlay is currently active +---@return boolean +function OverlayContainer:isActive() + return self.active +end + +-- Sets the content element for the overlay +---@param content UiElement The content to display in the center +function OverlayContainer:setContent(content) + if self.content then + self.content:detach() + end + + self.content = content + if self.content then + self:addChild(self.content) + self.content.hAlign = "center" + self.content.vAlign = "center" + end +end + +-- Draws the semi-transparent background +function OverlayContainer:drawSelf() + if not self.active then + return + end + + GraphicsUtil.setColor(0, 0, 0, 0.75) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Touch handler - closes overlay if clicking outside content +---@return boolean? True to block touch event propagation +function OverlayContainer:onTouch(x, y) + if not self.active then + return false + end + + if self.content then + local contentX, contentY = self.content:getScreenPos() + local inContentBounds = x >= contentX and x < contentX + self.content.width and + y >= contentY and y < contentY + self.content.height + + if not inContentBounds then + self:close() + return true + end + end + + return true +end + +-- Release handler - blocks event propagation +---@return boolean? True to block release event propagation +function OverlayContainer:onRelease() + if self.active then + return true + end +end + +-- Input handler - closes overlay on ESC key +function OverlayContainer:receiveInputs(inputs, dt) + if not self.active then + return + end + + if inputs.isDown["MenuEsc"] then + self:close() + end +end + +return OverlayContainer diff --git a/client/src/ui/PagedUniGrid.lua b/client/src/ui/PagedUniGrid.lua index 2dfe1bc4..a9645894 100644 --- a/client/src/ui/PagedUniGrid.lua +++ b/client/src/ui/PagedUniGrid.lua @@ -6,6 +6,7 @@ local Grid = require(PATH .. ".Grid") local class = require("common.lib.class") local Signal = require("common.lib.signal") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local function addNewPage(pagedUniGrid) local grid = Grid({ @@ -101,7 +102,7 @@ function PagedUniGrid:refreshPageTurnButtonVisibility() end function PagedUniGrid:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 0, 0, 1) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index e1b22697..a1018b35 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -3,23 +3,26 @@ local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") --- StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting --- Useful for auto-aligning multiple ui elements that only know one of their dimensions +---@class StackPanel : UiElement +---StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting. +---Useful for auto-aligning multiple ui elements that only know one of their dimensions. +---@field alignment "left"|"right"|"top"|"bottom" Direction in which children are stacked +---@field pixelsTaken number Tracks how many pixels are already taken in the stacking direction +---@field TYPE string Class type identifier local StackPanel = class(function(stackPanel, options) - -- all children are aligned automatically towards that option inside the StackPanel - -- possible values: "left", "right", "top", "bottom" + ---@type "left"|"right"|"top"|"bottom" stackPanel.alignment = options.alignment - - -- StackPanels are unidirectional but can go into either direction - -- pixelsTaken tracks how many pixels are already taken in the direction the StackPanel propagates towards + ---@type number stackPanel.pixelsTaken = 0 - -- a stack panel does not have a size limit it's alignment dimension grows with its content end, UiElement) StackPanel.TYPE = "StackPanel" +---Applies positioning and sizing settings to a UI element based on the StackPanel's alignment +---@param uiElement UiElement The element to apply settings to function StackPanel:applyStackPanelSettings(uiElement) if self.alignment == "left" then uiElement.hFill = false @@ -48,19 +51,29 @@ function StackPanel:applyStackPanelSettings(uiElement) end end +---Adds a UI element to the StackPanel, applying proper positioning and resizing +---@param uiElement UiElement The element to add function StackPanel:addElement(uiElement) self:applyStackPanelSettings(uiElement) self:addChild(uiElement) self:resize() + uiElement.yieldFocus = function() + self.yieldFocus() + end end - +---Inserts a UI element at a specific index in the StackPanel +---@param uiElement UiElement The element to insert +---@param index number The position to insert at (1-based) function StackPanel:insertElementAtIndex(uiElement, index) -- add it at the end StackPanel.addElement(self, uiElement) StackPanel.shiftTo(self, uiElement, index) end +---Shifts an element to a specific index by swapping positions with preceding elements +---@param uiElement UiElement The element to shift +---@param index number The target position (1-based) function StackPanel:shiftTo(uiElement, index) -- swap the previous element with it while updating values until it reached the desired index for i = #self.children - 1, index, -1 do @@ -82,6 +95,9 @@ function StackPanel:shiftTo(uiElement, index) end end +---Removes an element from the StackPanel, updating positions and pixel tracking +---IMPORTANT: Use this method instead of element:detach() to maintain proper layout state +---@param uiElement UiElement The element to remove function StackPanel:remove(uiElement) local index = tableUtils.indexOf(self.children, uiElement) @@ -114,8 +130,21 @@ function StackPanel:remove(uiElement) uiElement:detach() end +---Processes user input and forwards it to child elements +---@param input table Input state +---@param dt number Delta time since last frame +function StackPanel:receiveInputs(input, dt) + for _, child in ipairs(self.children) do + if child.receiveInputs then + child:receiveInputs(input, dt) + return + end + end +end + +---Draws the StackPanel's debug borders if enabled in debug settings function StackPanel:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 0, 0, 0.7) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/Stepper.lua b/client/src/ui/Stepper.lua index a342bb64..9aeba5b2 100644 --- a/client/src/ui/Stepper.lua +++ b/client/src/ui/Stepper.lua @@ -5,6 +5,7 @@ local Label = require(PATH .. ".Label") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local NAV_BUTTON_WIDTH = 25 local EMPTY_STEPPER_WIDTH = 160 @@ -110,7 +111,7 @@ function Stepper:refreshLocalization() end function Stepper:drawSelf() - if config.debug_mode then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(self.color) GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(self.borderColor) diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index e297c264..f4470052 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -10,15 +10,11 @@ local touchHandler = { } function touchHandler:touch(x, y) - local activeScene = GAME.navigationStack:getActiveScene() - -- if there is no active scene that implies an on-going scene switch, no interactions should be possible - if activeScene then - -- prevent multitouch - if not self.touchedElement then - self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) - if self.touchedElement and self.touchedElement.onTouch then - self.touchedElement:onTouch(x, y) - end + -- prevent multitouch + if not self.touchedElement then + self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) + if self.touchedElement and self.touchedElement.onTouch then + self.touchedElement:onTouch(x, y) end end end diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 18172e4c..2efa110a 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -12,6 +12,7 @@ local LegacyPanelSource = require("common.compatibility.LegacyPanelSource") local InputCompression = require("common.data.InputCompression") local ReplayV3 = require("common.data.ReplayV3") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class Match ---@field stacks (Stack | SimulatedStack)[] The stacks to run as part of the match @@ -615,10 +616,11 @@ function Match:shouldRun(stack, runsSoFar) end -- In debug mode allow non-local player 2 to fall a certain number of frames behind - if config and config.debug_mode and not stack.is_local and config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then + local framesBehind = DebugSettings.getVSFramesBehind() + if not stack.is_local and framesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then -- Only stay behind if the game isn't over for the local player (=garbageTarget) yet if self.garbageTargets[2][1] and self.garbageTargets[2][1].game_ended and self.garbageTargets[2][1]:game_ended() == false then - if stack.clock + config.debug_vsFramesBehind >= self.garbageTargets[2][1].clock then + if stack.clock + framesBehind >= self.garbageTargets[2][1].clock then return false end end diff --git a/docs/DebugSettings.md b/docs/DebugSettings.md new file mode 100644 index 00000000..36dc2a74 --- /dev/null +++ b/docs/DebugSettings.md @@ -0,0 +1,117 @@ +# Debug Settings System Documentation + +## Overview +The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to `config.debug` and can be accessed anywhere via the `DebugSettings` singleton. + +## Architecture + +### Components +- **DebugSettings** (`client/src/debug/DebugSettings.lua`) - Singleton managing all debug settings +- **OverlayContainer** (`client/src/ui/OverlayContainer.lua`) - Full-screen overlay UI for settings menu +- **Debug Button** (`client/src/Game.lua`) - Bottom-right button that opens the overlay (only visible when `DEBUG_ENABLED`) + +## How Debug-Only Settings Work + +Settings can be marked as `debugBuildOnly = true` to control their availability: + +### Debug Builds (`DEBUG_ENABLED = true`) +- Setting appears in the UI overlay +- Can be toggled on/off by the user +- Value persists to config file +- Returns actual user-configured value + +### Release Builds (`DEBUG_ENABLED = false`) +- Setting is hidden from the UI overlay +- Always returns the default value (typically `false`) +- Persisted value is ignored +- Cannot be changed at runtime + +This is implemented through two mechanisms: + +**1. UI Filtering** - `DebugSettings.getDefinitions()` filters out `debugBuildOnly` settings in release builds + +**2. Value Locking** - `DebugSettings.get()` returns the default value for debug-only settings in release builds + +## Adding New Debug Settings + +### Step 1: Add Setting Definition + +Add a new entry to the `settingDefinitions` table in `DebugSettings.lua`: + +```lua +{ + key = "myNewSetting", + type = "boolean", -- or "number" + default = false, + label = "My New Feature", + debugBuildOnly = true -- false if should be available in release builds +} +``` + +For number settings, add `min` and `max`: +```lua +{ + key = "myNumberSetting", + type = "number", + default = 0, + label = "My Number Setting", + min = 0, + max = 100, + debugBuildOnly = true +} +``` + +### Step 2: Add Accessor Methods + +Add getter and optional setter methods following the naming convention: + +```lua +-- Getter +function DebugSettings.myNewSetting() + return DebugSettings.get("myNewSetting") --[[@as boolean]] +end + +-- Setter (if needed for programmatic access) +function DebugSettings.setMyNewSetting(value) + DebugSettings.set("myNewSetting", value) +end +``` + +### Step 3: Use in Code + +Replace existing debug checks with the new method: + +```lua + +if DebugSettings.myNewSetting() then + -- debug behavior +end +``` + +## Setting Definition Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | Yes | Unique key used in config.debug | +| `type` | "boolean" \| "number" | Yes | Data type of the setting | +| `default` | boolean \| number | Yes | Default value when not configured | +| `label` | string | Yes | Display label shown in UI overlay | +| `min` | number | No | Minimum value (number types only) | +| `max` | number | No | Maximum value (number types only) | +| `debugBuildOnly` | boolean | No | If true, only available in DEBUG_ENABLED builds (defaults to false) | + +## Persistence + +Settings are automatically saved to `config.debug` in the user's config file: + +```lua +config.debug = { + showStackDebugInfo = false, + showUIElementBorders = true, + vsFramesBehind = 0, + -- ... other settings +} +``` + +- Settings load on startup via `DebugSettings.init()` +- Settings save immediately when changed via `DebugSettings.set()` diff --git a/main.lua b/main.lua index 952272a3..bcd2dcb9 100644 --- a/main.lua +++ b/main.lua @@ -1,6 +1,7 @@ local logger = require("common.lib.logger") require("common.lib.mathExtensions") local utf8 = require("common.lib.utf8Additions") +local DebugSettings = require("client.src.debug.DebugSettings") local inputManager = require("client.src.inputManager") require("client.src.globals") local touchHandler = require("client.src.ui.touchHandler") @@ -62,8 +63,8 @@ function love.load(args, rawArgs) GAME:load() if not PROFILE_MEMORY then - prof.enable(config.debugProfile) - prof.setDurationFilter(config.debugProfileThreshold / 1000) + prof.enable(DebugSettings.getProfileFrameTimes()) + prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) end end @@ -78,7 +79,7 @@ end -- Intentional override ---@diagnostic disable-next-line: duplicate-set-field function love.update(dt) - if config.show_fps and config.debug_mode then + if DebugSettings.showRuntimeGraph() then if CustomRun.runTimeGraph == nil then CustomRun.runTimeGraph = RunTimeGraph() end @@ -126,7 +127,7 @@ end function love.draw() GAME:draw() - if DEBUG_ENABLED then + if DebugSettings.drawGraphicsStats() then local stats = love.graphics.getStats() local width, height = love.graphics.getDimensions() From 8f84a10edd58c07144ac7175965b2b4d02bca0b6 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:25:39 -0700 Subject: [PATCH 02/30] New User Setup and Input Overlay Language selection setup screen created, shown when language not set Discord information setup screen created, new config for when it has been shown New Input Overlay Scene for picking inputs New controller theme graphics for representing input device type Comprehensive BattleRoom methods for managing players and their input assignments Restore previous assignments in 1P if they have been picked this session Language now defaults to English but not confirmed until picked Set Language method decoupled from saving language to support above InputPromptRenderer for drawing device images and eventually button prompts InputConfiguration has become a full class with device name, image, label etc fully accessible. InputManager manages the input configurations and provides a touch one. Added a second WASD default configuration for when new players don't have any yet and expect to navigate menus with WASD Input Device Overlay is shown in character select when not all human local players are assigned. Added change input device button for changing after you have already assigned Input Config Menu visuals improved, added auto configure joystick Added SceneCoordinator class for auto showing next scenes based on state Added ability for labels to autosize Added ability for menus to size to fit Added a slider menu item Made UIElement properly traverse touch from topmost down Auto show input config on new controller Add way to set TYPE variable on class constructor Added debugging print functions on UIElement Clear previous bindings when reusing key Require all keys bound before exiting config Input manager cleanup --- COPYING-ASSETS | 14 + client/assets/localization.csv | 25 + .../Panel Attack Modern/discord_logo.png | Bin 0 -> 206115 bytes .../input/controller_add.png | Bin 0 -> 1323 bytes .../input/controller_gamecube.png | Bin 0 -> 1917 bytes .../input/controller_generic.png | Bin 0 -> 1658 bytes .../input/controller_n64.png | Bin 0 -> 5403 bytes .../input/controller_playstation1.png | Bin 0 -> 1737 bytes .../input/controller_playstation2.png | Bin 0 -> 1833 bytes .../input/controller_playstation3.png | Bin 0 -> 1857 bytes .../input/controller_playstation4.png | Bin 0 -> 1805 bytes .../input/controller_playstation5.png | Bin 0 -> 1882 bytes .../input/controller_snes.png | Bin 0 -> 4415 bytes .../input/controller_switch_pro.png | Bin 0 -> 1759 bytes .../input/controller_xbox360.png | Bin 0 -> 1868 bytes .../input/controller_xboxone.png | Bin 0 -> 1814 bytes .../input/controller_xboxseries.png | Bin 0 -> 1842 bytes .../input/device_number_0.png | Bin 0 -> 1607 bytes .../input/device_number_1.png | Bin 0 -> 1296 bytes .../input/device_number_2.png | Bin 0 -> 1564 bytes .../input/device_number_3.png | Bin 0 -> 1592 bytes .../input/device_number_4.png | Bin 0 -> 1338 bytes .../input/device_number_5.png | Bin 0 -> 1527 bytes .../input/device_number_6.png | Bin 0 -> 1617 bytes .../input/device_number_7.png | Bin 0 -> 1510 bytes .../input/device_number_8.png | Bin 0 -> 1618 bytes .../input/device_number_9.png | Bin 0 -> 1628 bytes .../Panel Attack Modern/input/error.png | Bin 0 -> 8364 bytes .../Panel Attack Modern/input/keyboard.png | Bin 0 -> 1288 bytes .../Panel Attack Modern/input/mouse.png | Bin 0 -> 1639 bytes .../Panel Attack Modern/input/touch.png | Bin 0 -> 1511 bytes client/src/BattleRoom.lua | 185 +++-- client/src/ChallengeMode.lua | 2 +- client/src/Game.lua | 32 +- client/src/Player.lua | 7 + client/src/config.lua | 11 +- client/src/graphics/InputPromptRenderer.lua | 106 +++ client/src/input/InputConfiguration.lua | 442 +++++++++++ client/src/input/JoystickProvider.lua | 6 + client/src/inputManager.lua | 456 ++++++++++- client/src/localization.lua | 42 +- client/src/mods/Theme.lua | 96 +++ client/src/save.lua | 32 - client/src/scenes/CharacterSelect.lua | 76 +- client/src/scenes/CharacterSelect2p.lua | 4 +- .../src/scenes/CharacterSelectChallenge.lua | 4 +- client/src/scenes/CharacterSelectVsSelf.lua | 4 +- client/src/scenes/DiscordCommunitySetup.lua | 127 ++++ client/src/scenes/EndlessMenu.lua | 3 + client/src/scenes/InputConfigMenu.lua | 370 ++++++--- client/src/scenes/LanguageSelectSetup.lua | 78 ++ client/src/scenes/MainMenu.lua | 4 +- client/src/scenes/OptionsMenu.lua | 28 +- client/src/scenes/PuzzleMenu.lua | 39 +- client/src/scenes/Scene.lua | 5 + client/src/scenes/SceneCoordinator.lua | 110 +++ client/src/scenes/StartUp.lua | 13 +- client/src/scenes/TimeAttackMenu.lua | 3 + client/src/scenes/TitleScreen.lua | 3 +- .../scenes/components/InputDeviceOverlay.lua | 578 ++++++++++++++ .../components/PlayerInputDeviceSlot.lua | 252 +++++++ client/src/ui/ChangeInputButton.lua | 207 +++++ client/src/ui/DiscreteImageSlider.lua | 304 ++++++++ client/src/ui/InputConfigSlider.lua | 164 ++++ client/src/ui/KeyBindingMenuItem.lua | 125 +++ client/src/ui/Label.lua | 11 +- client/src/ui/Menu.lua | 60 +- client/src/ui/MenuItem.lua | 121 ++- client/src/ui/OverlayContainer.lua | 2 +- client/src/ui/SliderMenuItem.lua | 91 +++ client/src/ui/StackPanel.lua | 4 +- client/src/ui/UIElement.lua | 59 +- client/src/ui/init.lua | 5 + client/src/ui/touchHandler.lua | 8 + client/tests/DiscreteImageSliderTests.lua | 387 ++++++++++ client/tests/InputConfigurationTests.lua | 714 ++++++++++++++++++ common/lib/class.lua | 8 +- common/lib/joystickManager.lua | 33 +- docs/InputDeviceSelection.md | 26 + main.lua | 12 + testLauncher.lua | 2 + 81 files changed, 5128 insertions(+), 372 deletions(-) create mode 100644 client/assets/themes/Panel Attack Modern/discord_logo.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_add.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_gamecube.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_generic.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_n64.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation1.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation2.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation3.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation4.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation5.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_snes.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_xbox360.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_xboxone.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_0.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_1.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_2.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_3.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_4.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_5.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_6.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_7.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_8.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_9.png create mode 100644 client/assets/themes/Panel Attack Modern/input/error.png create mode 100644 client/assets/themes/Panel Attack Modern/input/keyboard.png create mode 100644 client/assets/themes/Panel Attack Modern/input/mouse.png create mode 100644 client/assets/themes/Panel Attack Modern/input/touch.png create mode 100644 client/src/graphics/InputPromptRenderer.lua create mode 100644 client/src/input/InputConfiguration.lua create mode 100644 client/src/input/JoystickProvider.lua create mode 100644 client/src/scenes/DiscordCommunitySetup.lua create mode 100644 client/src/scenes/LanguageSelectSetup.lua create mode 100644 client/src/scenes/SceneCoordinator.lua create mode 100644 client/src/scenes/components/InputDeviceOverlay.lua create mode 100644 client/src/scenes/components/PlayerInputDeviceSlot.lua create mode 100644 client/src/ui/ChangeInputButton.lua create mode 100644 client/src/ui/DiscreteImageSlider.lua create mode 100644 client/src/ui/InputConfigSlider.lua create mode 100644 client/src/ui/KeyBindingMenuItem.lua create mode 100644 client/src/ui/SliderMenuItem.lua create mode 100644 client/tests/DiscreteImageSliderTests.lua create mode 100644 client/tests/InputConfigurationTests.lua create mode 100644 docs/InputDeviceSelection.md diff --git a/COPYING-ASSETS b/COPYING-ASSETS index 726fd0e3..0d47177e 100644 --- a/COPYING-ASSETS +++ b/COPYING-ASSETS @@ -61,6 +61,9 @@ CC BY-SA 4.0 CC BY-NC-SA 4.0 To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ +CC0 1.0 + To view a copy of this license, visit http://creativecommons.org/publicdomain/zero/1.0/ + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 PREAMBLE @@ -230,6 +233,17 @@ themes/Panel Attack Modern/sfx/thud_# The conclusion is that we cannot obtain new licenses by fault of the licensor but the already acquired license is valid. Accordingly these assets should be replaced as soon as convenient or once the licensor becomes available again, maintainers shall obtain additional licenses, whichever is first. +themes/Panel Attack Modern/input/ + Copyright (C) Kenney + License: CC0 1.0 + https://kenney.nl/assets/input-prompts + +themes/Panel Attack Modern/input/controller_snes +themes/Panel Attack Modern/input/controller_n64 +themes/Panel Attack Modern/input/error + Copyright (C) 2025 JamBox + License: CC0 1.0 + Characters ========== diff --git a/client/assets/localization.csv b/client/assets/localization.csv index 87a4baca..d83032e2 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -637,3 +637,28 @@ mod_manage_music,Label for music in mod management,Music,Musique,Música,音楽, mod_manage_submods,Label for submods in mod management,Sub Mods,Sous-mods,Submods,サブMOD,Submods,Submods,Submod,ซับม็อด mod_manage_enabled,Label for enabled in mod management,Enabled,Activé,Ativado,有効,Activado,Aktiviert,Abilitato,เปิดใช้งาน mm_2_time,,2P time attack,2J contre la montre,2J contra o tempo,2P スコアアタック,2J Contrareloj,2P Time Attack,2P a tempo,2P time attack +discord_welcome_title,Title for Discord community welcome screen,Welcome to Panel Attack!,Bienvenue dans Panel Attack!,Bem-vindo ao Panel Attack!,パネルアタックへようこそ!,¡Bienvenido a Panel Attack!,Willkommen bei Panel Attack!,Benvenuti in Panel Attack!,ยินดีต้อนรับสู่ Panel Attack! +discord_message_line1,First line of Discord welcome message,"Join our Discord to meet players, share ideas, and talk all things Panel Attack.","Rejoignez notre Discord pour rencontrer des joueurs, partager vos idées et discuter de tout ce qui concerne Panel Attack.","Junte-se ao nosso Discord para conhecer jogadores, compartilhar ideias e falar tudo sobre Panel Attack.","Discordに参加してプレイヤーと出会い、アイデアを共有し、パネルアタックのすべてについて語り合いましょう。","Únete a nuestro Discord para conocer jugadores, compartir ideas y hablar de todo lo relacionado con Panel Attack.","Tritt unserem Discord bei, lerne Spieler kennen, teile Ideen und sprich über alles rund um Panel Attack.","Unisciti al nostro Discord per conoscere giocatori, condividere idee e parlare di tutto ciò che riguarda Panel Attack.","เข้าร่วม Discord ของเราเพื่อพบปะผู้เล่น แชร์ไอเดีย และพูดคุยทุกเรื่องเกี่ยวกับ Panel Attack" +discord_message_line2,Second line of Discord welcome message,"Show off your mods and art, rediscover classic characters and stages, and create exciting new ones.","Présentez vos mods et vos créations, redécouvrez des personnages et des stages classiques, et créez-en de nouveaux passionnants.","Mostre seus mods e artes, redescubra personagens e fases clássicas e crie novidades empolgantes.","自分のMODやアートを披露し、クラシックなキャラクターやステージを再発見し、ワクワクする新しい作品を作りましょう。","Presume tus mods y arte, redescubre personajes y escenarios clásicos y crea otros nuevos emocionantes.","Zeig deine Mods und Kunst, entdecke klassische Charaktere und Bühnen neu und erschaffe spannende neue Kreationen.","Mostra i tuoi mod e le tue opere, riscopri personaggi e stage classici e crea nuove emozionanti creazioni.","โชว์ม็อดและงานศิลป์ของคุณ ค้นพบตัวละครและฉากคลาสสิกอีกครั้ง และสร้างสิ่งใหม่ที่น่าตื่นเต้น" +discord_message_line3,Third line of Discord welcome message,"Compete in monthly tournaments and join events for players of every skill level!","Participez à des tournois mensuels et rejoignez des événements pour tous les niveaux !","Compita em torneios mensais e participe de eventos para jogadores de todos os níveis!","毎月のトーナメントで競い合い、あらゆるスキルレベルのプレイヤー向けのイベントに参加しましょう!","Compite en torneos mensuales y únete a eventos para jugadores de todos los niveles.","Tritt in monatlichen Turnieren an und nimm an Events für Spielende aller Fähigkeitsstufen teil!","Competi nei tornei mensili e partecipa a eventi per giocatori di ogni livello di abilità!","เข้าร่วมแข่งขันในทัวร์นาเมนต์รายเดือนและกิจกรรมสำหรับผู้เล่นทุกระดับฝีมือ!" +discord_join_link,Button to join Discord server,Join Discord Server,Rejoindre le serveur Discord,Entrar no servidor Discord,Discordサーバーに参加,Unirse al servidor Discord,Discord-Server beitreten,Unisciti al server Discord,เข้าร่วมเซิร์ฟเวอร์ Discord +next_button,Text shown to continue to next screen,Next,Suivant,Próximo,次へ,Siguiente,Weiter,Avanti,ถัดไป +input_config_new_controller,Message shown when a new controller is detected and configured,"Input configurations added, please verify the button mappings, especially the Confirm, Cancel and Raise keys",Nouvelle manette détectée ! Veuillez vérifier les mappages des boutons.,Novo controlador detectado! Verifique os mapeamentos dos botões.,新しいコントローラーが検出されました!ボタンマッピングを確認してください。,¡Nuevo controlador detectado! Por favor verifique los mapeos de botones.,Neuer Controller erkannt! Bitte überprüfe die Tastenbelegung.,Nuovo controller rilevato! Verifica le mappature dei pulsanti.,ตรวจพบจอยใหม่! กรุณาตรวจสอบการตั้งค่าปุ่ม +swap1,Input configuration label for first swap button,Swap 1,Échanger 1,Trocar 1,スワップ1,Intercambiar 1,Tausch 1,Scambia 1,สลับ 1 +swap2,Input configuration label for second swap button,Swap 2,Échanger 2,Trocar 2,スワップ2,Intercambiar 2,Tausch 2,Scambia 2,สลับ 2 +raise1,Input configuration label for first raise button,Raise 1,Monter 1,Levantar 1,上げる1,Elevar 1,Heben 1,Alza 1,เลื่อน 1 +raise2,Input configuration label for second raise button,Raise 2,Monter 2,Levantar 2,上げる2,Elevar 2,Heben 2,Alza 2,เลื่อน 2 +tauntup,Input configuration label for taunt up button,Taunt Up,Provocation Haut,Provocação Cima,挑発上,Burla Arriba,Spott Oben,Provocazione Su,เยาะเย้ยขึ้น +tauntdown,Input configuration label for taunt down button,Taunt Down,Provocation Bas,Provocação Baixo,挑発下,Burla Abajo,Spott Unten,Provocazione Giù,เยาะเย้ยลง +change_input_device,Label for changing input device,Change Input Device,Changer de périphérique d'entrée,Alterar dispositivo de entrada,入力デバイスを変更,Cambiar dispositivo de entrada,Eingabegerät ändern,Cambia dispositivo di input,เปลี่ยนอุปกรณ์ควบคุม +press_button_device,Prompt to press a button on desired input device,Press a button on the device you want to use,Appuyez sur un bouton du périphérique que vous souhaitez utiliser,Pressione um botão no dispositivo que deseja usar,使用したいデバイスのボタンを押してください,Presiona un botón en el dispositivo que quieres usar,Drücke eine Taste auf dem Gerät\, das du verwenden möchtest,Premi un pulsante sul dispositivo che vuoi usare,กดปุ่มบนอุปกรณ์ที่คุณต้องการใช้ +or_touch_player_slot,Prompt to touch player slot for touch input,or touch the player slot if you want to use touch,ou touchez l'emplacement du joueur si vous souhaitez utiliser le tactile,ou toque no espaço do jogador se quiser usar toque,またはタッチを使用する場合はプレイヤースロットをタッチしてください,o toca el espacio del jugador si quieres usar táctil,oder berühre das Spielerfeld\, wenn du Touch verwenden möchtest,o tocca lo slot del giocatore se vuoi usare il touch,หรือแตะช่องผู้เล่นหากคุณต้องการใช้ระบบสัมผัส +more_players_than_configs,Error message when there are more local players than input configurations,"There are more local players than input configurations configured. +Please configure enough input configurations and try again","Il y a plus de joueurs locaux que de configurations d'entrée configurées. +Veuillez configurer suffisamment de configurations d'entrée et réessayer.","Há mais jogadores locais do que configurações de entrada configuradas. +Configure configurações de entrada suficientes e tente novamente.","ローカルプレイヤーの数がコンフィグ数より多い。 +十分なインプットコンフィグを設定してもう一度試してください。","Hay más jugadores locales que configuraciones de entrada configuradas. +Configure suficientes configuraciones de entrada e intente de nuevo.","Es gibt mehr lokale Spieler als Eingabekonfigurationen konfiguriert. +Bitte konfigurieren Sie genügend Eingabekonfigurationen und versuchen Sie es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. +Si prega di configurare sufficienti configurazioni di input e riprovare.","มีผู้เล่นท้องถิ่นมากกว่าการกำหนดค่า input +โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" \ No newline at end of file diff --git a/client/assets/themes/Panel Attack Modern/discord_logo.png b/client/assets/themes/Panel Attack Modern/discord_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..23bfafec245182051068e1f1c712772dcf85aceb GIT binary patch literal 206115 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

#k1XA}pR<$bg)3V}oU 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_add.png b/client/assets/themes/Panel Attack Modern/input/controller_add.png new file mode 100644 index 0000000000000000000000000000000000000000..dd259c2e075b9ae6713285ad04a16cfb3b29394d GIT binary patch literal 1323 zcmb7EL5LJb6s^@P!oVJkLYABgH1?u9T|F}{?lv>4N2dC^*sR(^mpxI2NukhmKz9d9)bO<2>q+iUm-NG5PGwQ zkbN7WFM_+jd|QNrJ!-kRg3$Vnt$A&=&z-HJi5J%n+y`r)syAx;UT3C0&)_?qpZ|ib zuT*VSIaG0*g&Y=?xAJq@oR!TLTDf^UXW51PG0U&A8S`HlcjI-;}j@qm}lZuTHx zW}HC@k4K7n!N+4BJ$d{Xp}kw8wW?Md=WQlElRC1^%^1S;RXiC7l=Zp7ZQd1r$$0zg zZw3}l$+$4ruo^*y_r%g>$X7O-EwpfXbuae47((^u&_X^ zJjv&?;E|0sd`07|9~}aO3{EhrJYt~;l#o8wn6xbis$>`$Jc4FyjGV|K);TcYNk2+b zq+l2q8MCQUp=b{@R+D;s@oVaxmT{5nas==?!WE4B5l=FQJd%Tu@ibioo$Qux6cLg3 zMmD14xM>lCEJ8CxSN}mbpWdOf-C-~Y-J~st5$|hsMulYrzt~irUb6= z?-L`nTruui*8^tam=rhiERvlt8Z{K%o{F|X%dXl1sUT>RAjUbKJzOo`~>JhJ6g{h%pBbe20fv^*FBR`2rzvB~=oax0S;yJsR?|EBYJ* zySVNv9{S)eed^-#sbG*o;&s7?m|_N1VA%oS?PRlX7zu^-C;2~=Ohw#5+K7pSv@uJ& zh@Ec1M8Is2@q~dfo>A_@V1_V(Pj3|t>z_frT5c`<$=0SP?_IVYzB_T$`fT#QO8JlA m;PmVd#iOTw{C#G6@)SZ(wjNyC|4;opJ@WNhv-;rl+LiY=eW98F literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_gamecube.png b/client/assets/themes/Panel Attack Modern/input/controller_gamecube.png new file mode 100644 index 0000000000000000000000000000000000000000..84cf92b667346c7e3c68ffdac1cc31d4bcad6717 GIT binary patch literal 1917 zcmb7_c~BEq9LG1L1>_X1XAxcniKUPYSWzMgM}mR^9)!yS*=!bANp|CA34vCO(uz3B z;Z?PY44^$cKs>0&6cj}3QBVg3tq7hVBI0;+^(8P^XZ)k?%wYD;Wp>O=ZogNw{Vy;FGLnNOC8exiC68Fbr^IX&MpWGM{4iT zj-9r?D=U`nM(5^~?2C-+LlEvcQWnidhlU7oTE)i{w4C7URBGsiAU?i2HHIeD(!e6C_vwBST7PaE4T~BuxPW6O+@) ztcb_60E1{8A0p!nfw82PfJ&%nN@EN$00Y1W$AuWnGNe4&zzV}ugm?}?QADZ_`jIJ! zH+U#XoS-y>F)~AF=wt>b2Eu7zCLfEZPL@L_1+BtJ3QZ#z8uj1{P#!9V`Jjdb{*%!E zPg3zdNp&)#G-js2Q9?&Z00gLBohh zh7a&E_EXGA{ha2b&$otPKo42Q!0`58-*y}5}d(LK+$9KAeV9*#}jUA(Qu!R|y= z&#JS#h6xMzWGwe-&ps`7{58P2-6rR_S0#g`mN;b`>0EM0>azXs*E!0^-PYea>Yv7} z%v%5WP<7VGxWZYdhR2LrYsVbRi_!L#w_h_~W4pf}RCcbOEjs@u)2flz@os1N-jc%7 zc8MpsqIdbEw7#wD7tgjiU!AZoynwC>*OX=NFxlHwS%*xMTGov_`SGv54d%=RpOZ=D6~HNJnP4r~ACf*CVC9i%R>bmRl{0M@P7ufLC9( z^rY)=Hk;LZWoz_x|Lk-xu*3Sq7bG?R5HK!tze?xiD7Fe$Nh8-E1tgjt#$a1 zT8FF)^2NP92V++p@Jo1NDmGV}A-mhUGP}ocYL+>52kO-$s!EV%=IQzAT?L?ei_eXR zFHNd*qvkZsTxSc=k~(a)T|tTb;b*$ch=2p8o7e5noAb&xLpx2{d5!DfHzwihddhsH ze;Vg*(&-|qwFnVg=1(p>hN5Hd;6CkZk09ADN_Z z(jG5pT~o7gRiLT*asCCE~yPZ`%y)zXrpJ>~HI;Y+`m6kDb?+&%Jpldsz?FY=k$ zIaitf`Pkl==T;rx+|G=@%B@A}9$oxn6K$n;iJvP^zfs+#o`CR2IX@KYzcN9pHr(?+ zX)TS@A`<)B<=nX|oZ?Zpp4?WiN)93|cgT8eS-k0-ork#XNo0Pl;Cju3y0)Xg$=})B zFW*)fn@{~*&?sKBeQG+l=}vaY(;Am)OWtjj8jkhg>2|Dle8i0IeseP~&hTps4h)kX I42WC(FIDvrivR!s literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_generic.png b/client/assets/themes/Panel Attack Modern/input/controller_generic.png new file mode 100644 index 0000000000000000000000000000000000000000..fec7494395169dc1684e7b63c2b178ca58bab80f GIT binary patch literal 1658 zcmb7^e@qi+7{`x-0tE&W$efY+8mEU0dPhYaT}y!$0fDaaLqP+0+`Z6~_O9MtX+bB5 zhRqD8W^OT?4(D8Emgr0rwulZlMDP~^nVV5Jn2S?GgyCGoA+opErq0AawoBXhectzd zKF|AnpL@E^Y|0E%&Qf9+7M7i5%tvE``y1qsejg0&dW42W=G?-JOndHQ`I(rQn3A8# z$w@T&7SY6KqAg^$E`B5+_XsK9RDgXoxPu6<*SBM(}+ce91XQgL3c zhURT5is37u%4M@7Ck)f-U3QADh7zcNRV=5&N83Aaz%n{~)#5xd&u)MgHmgp61$CxE zx~`f|W^jFqQtQ$nfDK9%aM`S!sB!6VnX5r__cDP4`G{1l!?SHv71U@lTx?|_&s#y_ zA{9yE2qG|*ntbCjF9KOQ+#*SK4M8}aPL*?!iWjPg#N^~;f>aY~bpkq(Al7mc)bIgsOEt(JJs7Q(wdtSk*V5z3JTDDn=0hMsUD%H({&v+fVzV4^%gRG-3d9=k^<_zC5q@P7^aBKHl`K2uHF0SoUoM)H3nXv`Ek+I zRO{U9T}^9S&zL_Lc%!#bQ=AZ-S4MP_q27;$+FsgQdtl{_CwIT;UEXiFxHVBV>(~A} z<+(XT$RIvLJ-xqWG^;r>HYjo-8Fu+>cl3khW6o(;7d9VkGMtRa-gRb&{{6VeNm(Zp z_dAz1#A-MAkC&KB{&=|kLe{3Mch}7$Cx}lKCq`$fm6uOP8E1MA!5BA3Rtv^4O`ge9uTvLduL8w>v#23$7mvV4msz~=vs9#KZYVGjwXFXSA z`{MRLs4oqlUD>$O@78d0PE6>Qhe1I*L)J{&{X~B#@U^7vcjwRToE{uue$;U!aNqvu zbGQ9I4Bs{q+jVcro%ssW?aljM<};M{_VmBk)*f*D+UcBtt@@DzFYQU4AH2pk^s1uw z_U6fjB}3^kbvFV=HyTSvB9ES`Piz<`2XXM*0`uxCT5DrlP1HzKU&WTcOn$@17Fdp@ z;XfZcc){e?PL5@s7n~|}T6)v!wy?&tpIm4uW6rL5vAXN#`Z>$?=~~{l?cCjV>6KHa zAFtKmM`PG7eE}BSaI+;gHK1W^M>Ljn*;zqRDvGMr>xWf4~dc S4N30*_3R9j@ksjG#(w~Bd5J#& literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_n64.png b/client/assets/themes/Panel Attack Modern/input/controller_n64.png new file mode 100644 index 0000000000000000000000000000000000000000..a722048a7a5771b18fe0292eb2ba4a7f461fe663 GIT binary patch literal 5403 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 YuV3^q^?w{F^ZX`~6H_#e9~n#j1;W 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation4.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation4.png new file mode 100644 index 0000000000000000000000000000000000000000..a18b0e75787e5ab34758e3957789e0466f3e2cda GIT binary patch literal 1805 zcmb7_dr%Ws6o(^^HUu1P2eD$+TSZm`vLO_Zm_#8#K`}!+~5%@P)p-MHD1fE5GC zLyQWoW3^aB>tL&hwJMeR0ClEdX$4z4)(4_fwIHR)R0OMHZxRZ1#y{%L?%q9j@A=L> zzq4m{lTxvGJkOiQ;c&*sM9bshXt7;fXLx^jtNbw>f|aqVsKxr&=dXCQUM|I;`$RTg zk-%w~fzq9OHmq6DH{r;(*0{v=GJg)oxrtOIu}RBfML4bFV`@5;;G1=N=)~cO7nt=J zo=LDEmC%rs1nE1~fB;f0K@vlj36|+)L^>Ir!w?BM3KgD{iHEC^1yY{aEP?ynN9J0$LIz zon`eR6g8Pld{Z!=W;AF}cz8G}5TZh1AY2h>$fj7#97q{vK>`oh8FIpaGo+p+X$shw zSSoE~B?!U;BWN5SCF2Z%v1AqjmC(_Y!5+c^13--9B8+7jGSz5fDKH%&T}n_Cku64F z@(SW@9$FG7C<9@S%n%0J$l!z{oB?L?vUqx9Ds)oQI*g>y9DaisCMn-GTOpT+2me3ItYk)C=P^cN)v>F%Lw?#8H)1hGkvDR)lEG;>jxNSes zv@f)8Ze*nxeGy}$GNdM*HH<+cwCoE|TRB8pjM7LDbD)|?!;D%Mk;-U&w%s!TIMn6? zRXS+^CYs3va2yd>4UCkUU;vv=0NF?tfy_}xAd?9I6cMbTU~L%#rUgp7$Utsa8ORAW ziNnIv6ok_tizG|{12~Kf=ro8N3ljie8b}t{zQk86prdv#Xf+~$U8_-XUQ%Pn6zm1q z73gShL1VN#2@19(1A8yjY0@toj#G7vJVIr@*1M}CrvP{qMLb?yGq~YI4<6>~UM|nt zG1z=DRT{%B=baOHT=@5NzTG?1RI_bfOIL3bbF}i$P6~0;d@4*>>Xq{Kf#xZAc*_s% z%4bL3&pS$c)#o)oYlzJ@te4s_H|B{ZE_;Q>q^fuNj(BnEEKvy{++Q z?~r$#e7#rYSC!OR!BlN{a_yx`UXp(JfbO_O7wM*)zp; zQq$Y38waNQzE(ef@a~>Djo3Hm8r%o_=}S%Jk(O(o1Gm!>^+S_S`UGecctTZ|o3)YK z{MYl-m1Ua+6N>#lhz)7!{LL)sqgD09=C4PP^v#`Uh^=a64Y0VAyi7^j)_w2uwTw)n;ADCHx zu@bkDBQ`1ZNh6l|^c4occ&=^C)rh zTy625n{wWL=OP}M_Ndm`bxtWKM6s~Mb6ilFOV{$^j)8-@4ZFRnI-3-E)w>htCL>p` zcg{V3|8l;yy5{%0-9f?a9RV)0{H6mx^keTeAMWp5??l9f$b?^gRQy_lWnih;Cv*85 vch$mES2I30#~y!DUG<~STSeH|S^eDvpJR=i_F6ZG+I}@LQ3`o&WJ>-&A?euP literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation5.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation5.png new file mode 100644 index 0000000000000000000000000000000000000000..1c56be1131aba9851ed96e14a269f6374c553351 GIT binary patch literal 1882 zcmb7_dr%Ws6o*4dD-RJZ;2XX5(XAA+0SZDSAcj{g0fbl{QXyr?nPasBNh@wNM!W(PFJuVRYnC1s`MtRHQcvgF53Mb!T?(p1t>c=bqoW zXLe^)M5vw3d>a;vWhV*?iiYDB<85ULpN5|OV{q_{3YP?js>3JV@#^T@C57%YndpcZ z7S+8t;9+Ibxm^3m4s`Fg6B)~USS-sHLK4TsiNpCArQ%9uR0_`3tJKhm#qtZ#tEE^v z&VUqLL68FE;m_v~K*$70te2Q4Rtxb|A}pK6W3nS8Sav$*D?kNgk}7aMKUh!5B^0Fu?w(v84}lO`Cg(>7g-jx#B|uUc zM$Jc2oleKqd2%UQfx7$p`l37!)WgFKu5i<2k&IODMrxKqf(%gGDhQ4hRDRB5-N(+m_nFg0Qg}TU&=5vk)k!SBBUzZ|1+E<@hm^| zEmIJ0^iUEQPHJ#dWE$5{S{lR6;WRLlx5ZOyQ=pTKQb`FC&Bkd8b?5R>1oemcpvDAV zOX&Y6DSMTqT1zWUnaMB|SK=z1WHc~_I|?;}o0egG)3zwuXqq*Q!81&T*-{cyi5vF= zP18ct<|bGAp>JZ0RGLtvGMZP=Kqd1A)L0I_Qc5ZWh~7N0Xm9K2XLGeSO&DD45tB;iUZ+f6@k!PMj#aO02C3dCt+=AjZ_JuOd^9Ilgc0n zmk}5&JVin{1u_X-2c!UlkpU%#$X8)p;iQIOfbk^0TLI0rdqb;90Zdv=iSw2kn?J!` zfL(!3^%gXyx)Ud1OVY6SyxC4&EEaoM6ci}Y-x!SlJAW&fZs;+%OicWcKKPY{{n7bf z@rnm^dG3cf)5S|?T5;HUtATll3ZZUI^XzQT8TLN2sq0B=p>yWA?Np!OL@%o2EKFd3elkq-z4&~ebwSA&CD{s(oEna*#L?zh`GJokx83#1yK}-Y zx22lX`fp>?1BWvEts~Fml|zO5xHI!RIAQ&F?Y!S_dA7h}6E^2L_i1@e2%wt-b934k z70bgW`i8@HuilYc7I*Ju1pB+;;|d`(SH8Evq2HrR-_IHg?l@9rnKY)jD?)e4s z%kFVYu8MPeleetO$Ja!ix-)R>PTa&FA+CAocxZ=%cCk-2=A-C**vLA!_&T#`_EvAf z_4r+!rnWFN?RR!=Sz_~*LF*OlLx_4=kFC#eVhf8sBN)i6`+Y@Y!{Z0s0mtIZ%W|a0 zc6(;>rJB&2hrd2wz9Xk~uDH;js<4-2x$Y|t9y-L~xjfiBFTc2)bJi-Mb?yqg-h8cL z|IKyHVMNv1t}aLaqit;pucwwLQx_TPtM@uv`Yke}a(izBCtN&F&n}Maxn*g% zJ@AD6Y@&OV<6#?qA!4AMm`#w*OBz-=HGJ?m-G^Hm&h|Xo^kSJsH?OC1X;T^_Px!`_ z?K(}C*yKASRo<|G4s4l)UbU#Qjr(%nru$bkR--p&WA%yxgZ%mG%Cl;p^nvA)l}?w7 zN;Z4Nr&+lggaqBUPW$+;t3w-r(@!-v#TA|7x__Ga?Cv>M^Y69SKdGyYir=q`?cM2O z73_HH&9RE6)4kfhU(#MXxbXZYzpxM_Uc7c` SmCJ79e@he`5p-%za{hlm`1!y9 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_snes.png b/client/assets/themes/Panel Attack Modern/input/controller_snes.png new file mode 100644 index 0000000000000000000000000000000000000000..da4fcf96f3b99e7cab7ce6c0626286bb206f3816 GIT binary patch literal 4415 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png b/client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png new file mode 100644 index 0000000000000000000000000000000000000000..57326f23e265136deffc126f0c9ab08786eb0e62 GIT binary patch literal 1759 zcmb7_dr%Ws6o;1tgr=wn_@ateeC)JBHUY~(ObD1n!9p4VjbJToc5lc^vKu!W6R8~u z=!lP2p~|2gsurzEtKz7gPSqmV4y{@r)YoW>%&4`Fqu`?=zUa-PSZDmB?(FW}bN8O_ z-19qoW>*;W(+5goBp8Mb%*xc}qOsh2g^AJqL1%R@8j=jz`MT-$?7r7LPMujvBKHU( zSD%MrP^&L zsZuBa6D(m^72b8Q83zol!j13d$a3r&XkjwTc$inF&!@^tsZ<(Qr%9A<1p?ThKmxbT z$~qKo74G9I(6e`%z=3axP^!YSY-9;kD0FVdoX>GqAWxFYWH^HGv{{j>ofbeKONCnm z!LA?(m&+w}B}qBHgpjAErV_G5A~7)mtw?YzVg=Hjz&geuf&};&TIis7#x5`%3%pFy z#5n~Oj!QrQO;P=1iif1YEQCl28^=0)A^b1^loX{P1%YQwPA^MO+F;sykY(W_CGm<^ z2=Dczl$GI>=zyVHc6XwF75EKvq|j*v@b1Wu%(d|4Hco zCrQ6Z((dG~zRYNffL3UOtl&T~5*Ys`B}iI*{6D$kTy1jh?#hE;e8>Me)DO0p#?+?_x}GwHMnc$$W@FYK& zA~**YGSCG`K%vNhGb7}SF!AW6gAsuDOMJZo`fK-+RsjKgTJ?+biW>W;P%ogaAo_a? z8U5V}S=5p|>b+#q&~q3ja%X8X^4*tiZ_KJ~m4#~;*p+l^kJ`3;KupxAwKFPo>Sy!Z zBP9jztf)KMA`3qnIK$|!tIOaqPG4VpYiCDE{r2r|*KUj`iip*Jk{79do-%AmLwxuz z!{w`hWG5kcAdXMJ-uty^@_>0W<0^WMXHzmn!&E`4dy9*r3$UI^joH?q3}gA#*{wS> zN84faHFD3)#|NtA_w73xF4Q;Y+(;4KbHv?!YH$4hiu3PCd=lH^`K>#4itB08L_G3H zVWrS~Qgc#Pw7c%ssYtU(GezD(_Z3CXKDSmr{2R5IUNXmcXGCmcOH%Oa9p$!%7uv>| zD-TV$IAhlu@w&XwN%t}P=g$VWjoiCsR?F>)pHeNC=XVyIF-PBBkv4|g z*H?C8l;q3rp8R>YzvxvaAl0{behLU2h4S=2qypL{-(u z__ndv6UPtIg+!#+$3z#N*{y9qE30ZWc;XvdiNo8wH$``DJ8U@iXuZc^3BL20=y&79 z+zmqxHF+|pnSwt`KE1bN-?l-?6$LkSS0C^nm@lWNG`+ETh&y8(zItfv{-EOHV?EO% z){B~=DykAj@4r#gMCZPFL>~r%I={+mkHiLS9xb}2QXRTqxAu>Q9mC6Khs70dso7;b z(jNK~HhF6Lh*b?AVqa*|H&i7)EL?WJF!9O8=U{8-4>=be{~FpSZVkG*uJY3TNgH*3mQJtTJ+-*vAHf&0 Ap#T5? literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_xbox360.png b/client/assets/themes/Panel Attack Modern/input/controller_xbox360.png new file mode 100644 index 0000000000000000000000000000000000000000..cf192394dc5f7337094685813abcb11854e023bf GIT binary patch literal 1868 zcmb7_dr%Ws6o&(Wh6X`}0kw)=lrhzTYy!oR6aw<51>{`>rn+o4VVh((Y&ImIASmFV zwNyc+_^8EER20zh(Gish3dppGRGg}{K#Nw2qKJw_f!+iLb;dvH&g|Yjd++(qJ->6$ zY*AEXhz<7>E{nyo2@4I3fn%=mwqnEQv+ivpaPWug^z#Wb?y3Q6uQq~Vj^Q% zs~1r9(;xeGU8_NUT)(n7@1S;&#bP%TafwV~M7Ri}lzdb|rQ&>@QU#q@EN@?(3dQ6& z15$ArLHh6>*VXd?A@SjTyU2vFh-3Uo>Z zsTS#ccqXn0z8gOyJYZVF$bEQWN>ql6L_s=28b?tIAoSo11Uv|#B~npLVDKaYT0Xoq zhEa(SM61>EwH|zmmLWnfFE2#kj<~zK!4+=mOp-x$Zlrny@oB|mHt_3K-U}Qi^A@WTaS2(FA7+^e!A6CGL+P$OIqyQ$Zro?$qjVC_A zUVvSJO!XEtrn(a+VN24m_m-JEbh23H_rd~K#OZE7O6o5^0H!&e&)<@$8?W4ZL)7=&q_V!wh8tkkr%=7%f4AY?E`WX6b&qm2y$=%%{cd2aK#o``LYw4wW zX_-JOVSI^136*GTAWN z%IGiKm$}`l9O@c;Sf!hDW39R0rjcf|?gD*EA8V1tS=|!ySKH+cvCa<`SR8wKceHI_ zPIc#)Us3*e+0NN2L+w8gQ#AT?(V*^7bpP}9v)NTMk5}5AP8e?<>`2Nl_P^cHGIK{r%=jkZ zY3C7Zi_lii+(Xxjg!!A*NqyHB%37Z5$DVR}RX>Ft-|CthGAlmxYUc*_DVMUNFVYK2 zzrN7ZQQl+`|LSM|)@sM?-`}uxe99?2k`nZc5%@qRqLm;SBQ>=j)o1f2$fQ{F8Q9 zs*|I`oTD|d4-`Rn%W~@3_b=F97SA_C*y z96ZG5v?R~F*qlcrW`+|^%yPl=J2jbWMtbD6tE11@*Ueovf5XxYkB00`_eXQCbg~b- zu$!Ep*pyilhNm_Aib{(cQ?@1U9Bmv!t#?Se3ct%Xc&|-Znk@0yzQtu`TBRcg9eC9? zUIHE@EFGTp+A{S?`yb(Fay}X`?_XcBKf7!E?Q;rV)(_g;RE&pvVs4L(f2y#c$iQm< HH5>i|?~ek| literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_xboxone.png b/client/assets/themes/Panel Attack Modern/input/controller_xboxone.png new file mode 100644 index 0000000000000000000000000000000000000000..8940b55bb3aef452294c9a93fac858d86199abc6 GIT binary patch literal 1814 zcmb7_drTBp6o-dpZDAFqJX%l-x1x@wu)`zLvV{fQMcD|HmnkZ>JIq{Q%I?fEI|~a^ zMWwBeS~N!4=l!@)kd_^6@^$8+}@ofSQG!Klg!*XbMN`i zJ->5LW_@D9LKoRo8HQmlnpkx*8jEeWlOuW#-Q4mN4IznfsWA%;an?7yE_Q7ok-Mvq zoRETj(MMP=`bJJU$W^X7UixW&!+j%$Ii6)w(}cA6I0eP&?fqynHJ}};(g4s-LArDgu6H=+d94d^)BbBmnvjPG1P#}R> zuVal0a|AAO73kgenZSX#M97K2HF`1&DikqhCNq`ebRZ~19vFxt2v27!lGO{w5Xg$a zvjxGRAc*|@e0hF|oa3{Ips=toA~2W;4h}#o0*nQ$K$-(sqaPy3fXGloBgHcYf#FzS zW0D!1Nr=F485l!T)Hs>qAt^9<5GkSOSfd!i4g(;Zq7*5(q8OVv|^d!R)g^=fnAbB8x6H1g1VN2jY z3H|>h>6b|wOuSCajHU>vgL=pcMie86K$@XV)09Ho7RTF6yM`$+S2XOFju}tfwjX4Q z3&qWitqdn##n`Akla(zPUqYjG!Yfc)ITSjQ&5FRy0W{1cO*#QrsyIV|=;;saYRf@t zHe&?&9G?TwI3_R+Oe_s~AY?U)9HKZ8| z6`o@eoCA3b%m*Z(P-MVmBIL_3{%F$32*7p{->iW4+P$LHm;j&EJR?`pj)X?1I& z4wTfrNb74LT#2f}#@m)N?=+V?e%R|7w^(A?+1dI}>zeqP&eve^;!Zmad<5GG(s+;||GXXIE)KNn}xKjp3raviw$u)BeQt*txZvd!;i! znC0SEmF?L6 zWzTQA1MOe6|83QtlP11@um5!3)zF{sXqQ``_Fmc zcB&d2c>7S$&4TE}4Y@%-rb%T==iM>KoVHmf?wYb~a=YTfBjIe#f~sG9{bME1%06FS zKHBEK$j@P;pH_K$^NEVtw~|us<&B)S?5*}~yJozyS-ZlmerL;!bJ|T<^-4+icP6}( zKL5=?v#T!Pc1u(4)`!w9+a(6?!uIyAt!-N8qS<7 zBW2GdTd0KJsTNd^t}ojosCsgGr?l*Q zYu=@Y`=kS17uxb`I-&L&~0nkJ5KXZ2ufhOoF<8zIM&O DuQA^K literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png b/client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png new file mode 100644 index 0000000000000000000000000000000000000000..40eeba3de1f4e03e47911df2b0d385cdf0efa354 GIT binary patch literal 1842 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_1.png b/client/assets/themes/Panel Attack Modern/input/device_number_1.png new file mode 100644 index 0000000000000000000000000000000000000000..779263038380b5f44f10cc31d852e976f214572e GIT binary patch literal 1296 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_4.png b/client/assets/themes/Panel Attack Modern/input/device_number_4.png new file mode 100644 index 0000000000000000000000000000000000000000..cc431d953242009a05bba3cd551ac3513437ad1e GIT binary patch literal 1338 zcmb7^UufG#9LMFjUW#e9($Y~%$8L;GFOAPmXd$WF%ey`}}^t?@vE1j^_{dM0Z66LFgGdl$n5W!M`_$;rZ9Y&;EfSIX+s*9<)Yb`kYs@ z*<4;;ZZsOHRBCK&?3cx_ufWL{`9ywFSbi1%+F&nTpFj8AS0}#zK!~D%ks*$1 zQ;Sn(BA=-gnagESjG{JLRXb$WCJLt~2`FJO(+OO(UprdBq@uZxhob zb4k3x65#z4I#r43kifG|9Ol|8X}NPCla2gYZV?V?%uuL_Ys6+a5sPCH4?;fJPvDt^ zowlth1vw_jFblOSWj|a?PV-Q?> zXA-Z+_*9#g%G_Cl9?iK~*bE@o-0XoxhjQe9iT|&Ft=g^Asx3gERY#l+YTSASy#QT-JG}))r#p!WEonpV z?d#fc4?6eONajGnd+_`H`nJ<(>(HIUKSn-3ztU`0qVDiw|I|m{+>~Frcbp2@XMZVw zOiE|Yzx-O}i>t@Zz4!j9#@*X*9QpU!lfSDMm%gocCwBaFDRg=F!%Ht**uJIf@mY8E zR89zO+B$S26}MpSQ2}|AhzEUhAmZ@2kbrySi3Z zR^st^PEJlV8l9e=E-ESthrW?=`pN<)1k z5f2sRP$)VbGuxcu!O`K?@nM2Uo8%o$N>g1e%_6Uk;ZPgY1-(8sDuX%bV^|MVKpS-P zqLGY`MM=POM)Kejb^1D=1-A3mT@q~Us&}wm9=3ub&8BQakj4NnR2UHS3ZhI0jikz@ z@jS6ikwD#|c#LF?mvKXyt_t!l2SNfU*`w3zNeq!V7u{gpmqK96NVY4AkEW@-jtyu$dz34v#!C_~k= z1ZCuxSeSGt<4iX4=kvGWLmculyhwFH2~j0FJw;L`oDY>q;9m*-|0KD!Bz=BKP&4CL z3JTB*MMcIwN+_%u4w_?WH7q10%%p~m@UUtmErDN6JP{8z)s1R!shtLDJtjewcz3%Z zuR$vXWgRq84q9MDw~-8LIp|{ifZi%OHRx55QweU=H|24oX02 zhrp7mBCsUO2rL#oz#@VJBCf3@GXk)yA_J?cGO$99XK~??h~Wry@Gt-vz+%q;xiE6g zO)-8c^9o3OiFa4PWbM{zl@dVJYE_&KYMflcy@0!dTJ0@ttac|9aZ5_L_sTW-KN7^I z)*5T2BRI40^JK>neQt*RyH{R4b>r&s*_CMNT$5&?r>AP)xv8;3+Uo@;`X5gJ?+<8s zxar;ZJDzPU_~ECp=ChrzSBF+A5p93zh&`VBK&HK0^XLT8wRMp=|DXmCn&nT4n^}2R zp1+U>xchUz7~jhHyUPadtxliRy=Quj82SD3CrrzXXXnA5lFBdh!RW&2KJ7Di^cV9E z^-R?E&dz82>iP4xzZ+%(*Ic`u>2F?KD!=sd8CTid`5ou7wtfA^j-jRUjO)?E-uaLJ zxH*5U=vd#PsWp-rnZMHaQDL()-_*Qm`{I2^%VHOr&c?ofa(3#i8(Zc>CvJZ;rPH)% z{k>xaPxog|)%Ht~A<>{*TOzmFV#}9L?jAp07y0U)x$eidEWG&Cz{t?a568c4HW&^( zls}#JOJs6l;MT73;;8+RiBrOz7d{xd^>~Gi+&WqqRl+%A?Tr?&zNTXd$c0%!S2{GIaY$pQ)*- zKp>#k>%-x&#bOy68p3hh>-EOR$9Hvg`ThP-DAd>2mztVdSy{=l>|Wo`CsBd~p}L|* zX7RE^siF8l7}xu|`YI1xuSu54q6TU6Mqy)lnU-YjDuQCIQ02Beq^ffDZU;d&LIGG| zJXiA4|SY56zcNk#P$Q{Q;Bvc`R$BAxRngQ91#>iVwfOLa2Pv3 zsWXbip$*z0Bk(9k297jCMN_0!DvRaBFrr}%+$IH)$VSgJF4luWX`@u!^v+y-E=Hts zbbW)s&qDKU!W^h*ht@_g^*YS0q+lK4v;|Uw6l0*h3No8Py6ip)ISq8yb&_qKQAOMLn z1GWw!XVavkmpm;1@t62$21dM_qt&zkl2$X~%;Rz75t;=w75L0-pOi9H?T5yc^8<-TJKpeY>S`{&^NYs!J9l?U@crbK zkA0%^l^P3<(@YA#fuQ;~uWr_kvk9>IQ`ci6@&(D>9blY*xbmynNT|(~;y(2oX zv@JF<*n8IS!%K$JzuQNbe0%spcdPtZ?2+g<{qj{;>5c>U(vpKc`ztL!{^gkz^7f7G z-_mb6HE{TaYE69byMfY>x99l_^~MzkZx&sN%1!C~I<9PUU{}hzwJD#EuRpY~^e z+8e7J0iEHU`AN;=#iN5aPAcBJ`ruv5=@!w1u|-pd&TOC*TSLpl|KF0r3e&NImwo>L D58zK! literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_7.png b/client/assets/themes/Panel Attack Modern/input/device_number_7.png new file mode 100644 index 0000000000000000000000000000000000000000..17e3ca0983b1a196d0c890024f9d9546bbfe708e GIT binary patch literal 1510 zcmb7^e@q)?7{?22M2pB|Gn(l6sF^w{hPh}Qov2J*mM⪼r=PgriSQBVhoJLbY!w+nt<LkaPw(nl zUS2LLDk?24O{deTR4N*cB7~Znnmis)EEbE$6r`y%`hI)!cqM6~47n;LjKo8002nQIp3#z~ieY|8R z(q|F`;5j?-)ZPY5L%<3B{K1F}TO*A$8|h-L9O0-m+QJkDh)`ufSQI3M3fl>tOX0cp znk0a}MeVW^ZjtdpimD0ooisuMDBEMUSO^S}xlXFZbs&qtmYwjcYJeiiP$*;$?J*VmY19G4wKR=sZ7`;DLXL12y})EDy+;0R31q{V=`V8RP6*|1X(o8u8>(7 zGAiE#u@WMZ6x~Gz27rxaDMnRg-W$|djf@B@>!2jTUK_d262@yM1fGSG0(H+aR8UZ6 zVaA<;Gg;4HAn3)1I3zN>L`I;D$TG8qB*;pf52+<^M?(KUNp3aCKu{L+%s7^W0u-U7 zD%eLEi8aGPb1bEYg=EdlXxIw7bt7X5{7T|lJlNDX>cM4q+Q_vSjVkj#zpAW4s|9rp zR4WH1Fp|$sgiRdmWP*Z9R60?hS3kKMWYjhT+RrN>gyb%O$E?5{2ud83f$E3AnXMvl zX37YhP7A;yf+r&$-TccH009~sUan`AE<`wP*+!f?XZ((DlJE4SIQpUZvFZaPK218DZ+f_}6 zr~mr(2l+hMRC9vB{QvY#oEa~C zY5M2eV~gU}=jVhUXHze~zwgmsIvzOCzU4m4_RSq_ma1*X!~LhLHvmJZ$T@zqC&}Phqu^*tpY;GeEretPJI({tfr!awxp-YY8woF|!hRFyxeD6v?68>gE(m?k{IY)*EvBM;W7bxCKDvFXYhhu* z=ku{F8xaxV@p#;BH;&_4tu`hmrmd~5tgNi7t7~|8*z5Hc78W)i3iBcQ^FpzqMCSIe zc#L7%~0X8*`<Tu!3eZ zn6KA^0h-caJKii%7T9&LlFq5&U`dU^NY+@$6bjR($x~fw1h7GY04|%A;ngk;CUMp1 zUVMyWK-wZ$G+3^UsDNs9mWwVovaA&(B`cIl3_&=mTwScsUPPcsgH;NGU5(>Tr&Hlf zR0(PoFpG7vVHwhb~2tWL#Od4=%e2`f!PhKEvS z4)UynBcVSXk2+Z`pWR_bhbY!Y&#L%x{3H(3`{E?MjYFw-bh0;c;xW%2R_)3gO<>-n^ zfnSDZScMf((GInhU@A12E0Kcbgu^OeX*$+kEuBmNezg_ASV{B1$#NEe)A^peUz4=I{N#Wd>|H zLN2FCKreY(0OBw4=?wIHw?eB$0VJ)K#975-|06UDXe#if*+Rk6bV3G=B!^~iQ{dWh znJl0$SD#^YP5w1;nHx~92{CQiJrrE|^?{Fn|HpeK@r*8f(`dtIEk7(AJA4O3UOp}U zJFU1b^xu0m_e!r%`^~L=+1~rQS?k&T4P941KQ^*s;tm|@&l&Pk@Mo-U-aTJ1uUaq_ zn(FJ`Ki+xnk8_XCQ((OAQP1>P!8Uv&A2j*+?){4w`+kjpn+S6G?;|Y z#(~gpqh_k6Ew$ZIMK4r!$NxET#QryN=ts;QmmZg!vN_f`mA1ii;%rCl;5AlP7k}K@ za=|=y-V%_I9oTR~6|r$}__vWu*MmL^Gcd2WzWq+)%e@Z|u4^BUvd6u&b3UUZxaH>F zp6jNszKfqaTHoYZGq^Wt_kg!9wLLoTVb_bXhwCRTuRZBm`&R4v-mvhFke=J^s5aes1eG^B^}oXTq+O!tg^@t`?2^VCGN_uU&C;+T{BT=SXr%I?S6 zp|^tBD_sBS;)8uV*Oi}a>bdFRF3r3bfNU80Fo2#io4KobbD}A^Sq6P)rfJW=*cVs# literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_9.png b/client/assets/themes/Panel Attack Modern/input/device_number_9.png new file mode 100644 index 0000000000000000000000000000000000000000..6b1fbeeca9c2941ba6a8b5cc629293e82b5183a0 GIT binary patch literal 1628 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/mouse.png b/client/assets/themes/Panel Attack Modern/input/mouse.png new file mode 100644 index 0000000000000000000000000000000000000000..1bf4b077c2ed7df4ce803ed8803d3ff0405ef101 GIT binary patch literal 1639 zcmb7@e@qi+7{`yolD2AS1ZFf--)zQ<5qho2kQ*&%%dZHfLjAR6ljHi9p0s!8-IbQg zv_tpD)Cm4$icCV{jC10giMqN8b^e$Re=WK#G6zDo>71gO*(6igd)Ed=;vcig^}Xlr zeLm0oe82a6V7C=a&`!}}7&f7(&{~GpkbJ4*(f{M#ErV!Dw--C|3i#sT7yMnhwt+(F zI;qT7jx`MA?7h;lBC&_yN)Jz8``4~_-53^so^dRbmX#EfH0RY*POcK_1702_VOVxf zfT!qcD1k~?#jqxP;A9657^eweno(jX;Vsb36b1!Y9<({=U^SiP#B;Q=XUMNu@ z;PtQ~88G1rmqhpSV*&@t7OC2V7kQ~FNRoL0#^vBR4=|?d4F()R1gDEEv*yPTC^F%0 zN#aR@@caFGf4ZI%st99NRu*BHL(G|zhIXWhwX8%1(pd2|M9>0-VTB?sFucTYERdO0 zCFhe&IIaaTG)<3?X#r9aQv;C_UXB%&5>Xfc*)&a3k|Z#dKAB~sywJQ5vMj94CZ6*O z;pGeuLqk@CN@W3xoKK)(w48`Kd0su;yj62?ZdzA+W?;1eU0cz+y1~BqCVMB5wte@&LOcGO#Kt11oegG%}uJ5u5`x z4DBeR8qk?Dji z8c6}o-s`HA8yKd-imdY;ft&Yt-#S(gbop_4Lvw3~_nW5v;#gR6fa>k5`=`Avmi zf4ws1Snt;6EngeX#1Fms%Cnl`jdPDreDp?2&#IZ9wUWWQr2fBE#wjbG8ox{ED@^7c zapyC!TGg5PckZf8Kd4VMEll5BJ>I78I(6*mGhfb*l*aCzpSR}@N2J?2zNY7Tc!P@Z z9l3p~FER4b;pO91O}q~ccpufI+DGt4bX`Z2BSK{#Vw z=U}ETge3qhPMtjQn;q3#k6Bv32(3)JIxub9u9LORFMsH5a~(VsFKpA!TD_#WLRXj= zF;AY?v1Z!u`&yG((dR&IKAtitD@Ae=hSq zT~fx7HCgT5-`TxBefsR1o8x>5?LY5bvB2EPud46W7BrOJt5;3Uz1etiI~#T#tjO=1 zJUOxS%THfgaoJKePNN=f2@y*VrC)l0ot}H=efLWHyrJ{mmAbRWOXr4uz1sO$j*Iea*7gMz_5T9rAA-98 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/touch.png b/client/assets/themes/Panel Attack Modern/input/touch.png new file mode 100644 index 0000000000000000000000000000000000000000..ea1a45847a34bd79970ac70bf15cc43a2c06e7a8 GIT binary patch literal 1511 zcmb7@YfKzf6vqd!E!}`_X{*&xxG9Ze>h8`0Qo^tct-G{ry1W;K#6D(c?(R;R$1*d^ z4x|;Rlv>h4eWa)#d_-$&TBXKnmk`@(n()CON|Y*AX=AKfXegnkmWp_1b_GfJU?-Wm z_uPBW@7(i0a}Ji5ZcR2kV!$vg*;VYUL}QEgCMKZott&6wK|^-A+f%eP=$^gDXYlI| z3b`AV%F-$<{f(~X2gLJ*6FWzH28lAxpXlZmOg67`(8+U{gs1;GseR zmFEPRRPDIVCDB}4CUBr1QR?iti>LgMB#Ttm=MhB?WM*5eRvbYj#z$5t_Y>O!QiOjsbJi?kqWM!Gr3A5ZND3odzt4(hN=N*AgYojA~c~>vbb)aqL{;+I^6zAJi`wJ82^p zVl=A6`U8qQ58ccu3!qv#NRATxc3d?x&_{(h1ut-j!A9NF1fptNfG5DpAS_CC0F5z$ z84wZ}C;=q^fg@H$;E0wHI2=}hLrN=3mXuKM zZH!<2Er!LlxSX3k>d1}n;yT+v(vtG~Rv5qeY4*&?{Jwm5YO>?afk$Vzx1^s~a&nd4 z=Kp>?Q*ZxJQu)PTYnt!XKXRutK3aC|VQ?xX?s7K9{*E!QYty@iVcUgu^-D9$Kt=n? z<=t10EK7W9I{!%Ez*KM2jh8#eK1(viacM1`ImbuGT8HY_KYJ;s?jEuk;%jRkFOnFqfG*}BOhLT zFp%>1s@zF);<0LQx$~~Qc6bPLMb@r#o&8yui9}W_clT~c-t|t`g`Bj(zaHcF^9hGq zH{9HbeHyQ(+<)czaM$?g_Ot6wC4D8d8?VQo8EbpbvZ=56;>WvZj+RaJUNgQ{P`q?% za{8@1+kShcc|3Pt!{^ 0 then - -- assign the first unclaimed input configuration that is used - player:setInputMethod("controller") - logger.debug("Claiming input configuration " .. i .. " for player " .. player.playerNumber) - player:restrictInputs(inputConfiguration) - break +-- Validates that there are enough input configurations for local players and attempts to restore previous assignments +function BattleRoom:restoreInputConfigurations() + local localPlayers = self:getLocalHumanPlayers() + + if #GAME.input:getAssignableDevices() < #localPlayers then + local transition = MessageTransition(GAME.timer, 5, "more_players_than_configs") + GAME.navigationStack:popToTop(transition, function() self:shutdown() end) + return false + end + + -- Try to restore previous device assignments + for _, player in ipairs(localPlayers) do + if player.lastUsedInputConfiguration then + -- Check if the device is available (not already claimed by another player) + local deviceAvailable = true + for _, otherPlayer in ipairs(localPlayers) do + if otherPlayer ~= player and otherPlayer.inputConfiguration == player.lastUsedInputConfiguration then + deviceAvailable = false + break + end end - end - if not player.inputConfiguration and not GAME.input.mouse.claimed then - if tableUtils.length(GAME.input.mouse.isDown) > 0 or tableUtils.length(GAME.input.mouse.isPressed) > 0 then - player:setInputMethod("touch") - logger.debug("Claiming touch configuration for player " .. player.playerNumber) - player:restrictInputs(GAME.input.mouse) + + if deviceAvailable then + local success = self:claimDeviceForPlayer(player, player.lastUsedInputConfiguration) + if success then + logger.debug(string.format("BattleRoom: restored device for player %d", player.playerNumber)) + end end end - else - -- player can always go from controller to touch but not the other way around - player:setInputMethod("controller") - player:unrestrictInputs() end + + return true end --- sets up the process to get an input configuration assigned for every local player --- returns false if there are more players than input configurations -function BattleRoom:assignInputConfigurations() +-- Gets all local human players in the battle room +function BattleRoom:getLocalHumanPlayers() local localPlayers = {} - for i = 1, #self.players do - if self.players[i].isLocal and self.players[i].human then - localPlayers[#localPlayers + 1] = self.players[i] + for _, player in ipairs(self.players) do + if player.isLocal and player.human then + localPlayers[#localPlayers + 1] = player end end + return localPlayers +end + +-- Checks if a player has an assigned input device +function BattleRoom:isPlayerAssigned(player) + assert(player, "player is required") + local assigned = player.inputConfiguration ~= nil + return assigned +end - -- assert that there are enough valid input configurations actually configured - -- 1 is the baseline because you can always use touch without configuration - local validInputConfigurationCount = 1 - for _, inputConfiguration in ipairs(GAME.input.inputConfigurations) do - if inputConfiguration["Swap1"] then - validInputConfigurationCount = validInputConfigurationCount + 1 +-- Checks if all local human players have assigned input devices +function BattleRoom:areLocalPlayersAssigned() + for _, player in ipairs(self:getLocalHumanPlayers()) do + if not self:isPlayerAssigned(player) then + return false end end - if validInputConfigurationCount < #localPlayers then - local messageText = "There are more local players than input configurations configured." .. - "\nPlease configure enough input configurations and try again" - local transition = MessageTransition(GAME.timer, 5, messageText) - GAME.navigationStack:popToTop(transition, function() self:shutdown() end) - return false - else - if #localPlayers == 1 then - -- lock the inputConfiguration whenever the player readies up (and release it when they unready) - -- the ready up press guarantees that at least 1 input config has a key down - localPlayers[1]:connectSignal("wantsReadyChanged", localPlayers[1], self.updateInputConfigurationForPlayer) - elseif #localPlayers > 1 then - -- with multiple local players we need to lock immediately so they can configure - -- set a flag so this is continuously attempted in update - self.tryLockInputs = true + return true +end + +-- Gets player by player number +function BattleRoom:getPlayerByNumber(playerNumber) + for _, player in ipairs(self.players) do + if player.playerNumber == playerNumber then + return player end end + return nil +end + +-- Gets the player currently assigned to a specific input device +function BattleRoom:getPlayerAssignedToDevice(device) + assert(device, "device is required") + logger.debug(string.format("BattleRoom:getPlayerAssignedToDevice device=%s", tostring(device))) + if device.player then + return device.player + end + + for _, player in ipairs(self.players) do + if player.inputConfiguration == device then + return player + end + end + + return nil +end + +-- Claims an input device for a specific player +function BattleRoom:claimDeviceForPlayer(player, device) + assert(player, "player is required") + assert(device, "device is required") + logger.debug(string.format("BattleRoom:claimDeviceForPlayer player=%s device=%s", tostring(player.playerNumber), tostring(device))) + + if player.inputConfiguration == device then + logger.debug("BattleRoom:claimDeviceForPlayer device already assigned to player") + return true + end + + assert(not device.claimed or device.player == player, "device already claimed by another player") + + self:clearPlayerAssignment(player) + + if device.deviceType == "touch" then + player:setInputMethod("touch") + else + player:setInputMethod("controller") + end + + player:restrictInputs(device) return true end --- tries to assign unclaimed input configurations for all local players based on currently used inputs -function BattleRoom:tryAssignInputConfigurations() - if self.tryLockInputs then - for _, player in ipairs(self.players) do - if player.isLocal and player.human and not player.inputConfiguration then - BattleRoom.updateInputConfigurationForPlayer(player, true) - end +-- Clears input device assignment for a player +function BattleRoom:clearPlayerAssignment(player) + assert(player, "player is required") + logger.debug(string.format("BattleRoom:clearPlayerAssignment player=%s", tostring(player.playerNumber))) + + if player.inputConfiguration then + player:unrestrictInputs() + end + + if player.settings.inputMethod ~= "controller" then + player:setInputMethod("controller") + end +end + +-- Releases all input device assignments for local players +function BattleRoom:releaseAllLocalAssignments() + local released = false + for _, player in ipairs(self:getLocalHumanPlayers()) do + if player.inputConfiguration then + self:clearPlayerAssignment(player) + released = true end - self.tryLockInputs = tableUtils.trueForAny(self.players, - function(p) - return p.isLocal and p.human and not p.inputConfiguration - end) end + + return released end function BattleRoom:update(dt) @@ -502,7 +562,6 @@ function BattleRoom:update(dt) if self.state == BattleRoom.states.Setup then -- the setup phase of the room - self:tryAssignInputConfigurations() self:updateLoadingState() self:refreshReadyStates() if self:allReady() then diff --git a/client/src/ChallengeMode.lua b/client/src/ChallengeMode.lua index 680a3ba7..53dae68a 100644 --- a/client/src/ChallengeMode.lua +++ b/client/src/ChallengeMode.lua @@ -33,7 +33,7 @@ local ChallengeMode = class( self.player = ChallengeModePlayer(#self.players + 1) self.player.settings.difficulty = difficulty self:addPlayer(self.player) - self:assignInputConfigurations() + self:restoreInputConfigurations() self:setStage(stageIndex or 1) end, BattleRoom diff --git a/client/src/Game.lua b/client/src/Game.lua index 22f765e5..3f05bb2c 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -15,7 +15,7 @@ local LevelPresets = require("common.data.LevelPresets") local class = require("common.lib.class") local logger = require("common.lib.logger") local analytics = require("client.src.analytics") -local input = require("client.src.inputManager") +local inputManager = require("client.src.inputManager") local PuzzleLibrary = require("client.src.PuzzleLibrary") local save = require("client.src.save") local fileUtils = require("client.src.FileUtils") @@ -34,7 +34,7 @@ local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") local DebugSettings = require("client.src.debug.DebugSettings") -local Button = require("client.src.ui.Button") +local SceneCoordinator = require("client.src.scenes.SceneCoordinator") local TextButton = require("client.src.ui.TextButton") local OverlayContainer = require("client.src.ui.OverlayContainer") local DebugMenu = require("client.src.debug.DebugMenu") @@ -74,7 +74,7 @@ end local Game = class( function(self) self.scores = Scores.createFromScoreFile() - self.input = input + self.input = inputManager self.match = nil -- Match - the current match going on or nil if inbetween games self.battleRoom = nil -- BattleRoom - the current room being used for battles self.focused = true -- if the window is focused @@ -141,10 +141,10 @@ function Game:load() else logger.debug("Launching game without updater") end - local user_input_conf = save.read_key_file() - if user_input_conf then - self.input:importConfigurations(user_input_conf) - end + + inputManager:load() + + self:setupInputSignals() self.navigationStack = NavigationStack({}) self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) @@ -241,7 +241,7 @@ end function Game:setupRoutine() -- loading various assets into the game - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) detectHardwareProblems() @@ -372,6 +372,15 @@ function Game:handleResize(newWidth, newHeight) end end +function Game:onJoystickAdded(joystick) + self.input:onJoystickAdded(joystick) +end + +-- Setup signal listener for unconfigured joysticks +function Game:setupInputSignals() + self.input:connectSignal("unconfiguredJoystickAdded", SceneCoordinator, SceneCoordinator.onUnconfiguredJoystickAdded) +end + -- Called every few fractions of a second to update the game -- dt is the amount of time in seconds that has passed. function Game:update(dt) @@ -643,7 +652,7 @@ function Game:refreshCanvasAndImagesForNewScale() characters_reload_graphics() -- Reload loc to get the new font - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) end -- Transform from window coordinates to game coordinates @@ -670,13 +679,12 @@ function Game:setLanguage(lang_code) break end end - config.language_code = Localization.codes[Localization.lang_index] if themes[config.theme] and themes[config.theme].font and themes[config.theme].font.path then GraphicsUtil.setGlobalFont(themes[config.theme].font.path, themes[config.theme].font.size, self:newCanvasSnappedScale()) - elseif config.language_code == "JP" then + elseif lang_code == "JP" then GraphicsUtil.setGlobalFont("client/assets/fonts/jp.ttf", 14, self:newCanvasSnappedScale()) - elseif config.language_code == "TH" then + elseif lang_code == "TH" then GraphicsUtil.setGlobalFont("client/assets/fonts/th.otf", 14, self:newCanvasSnappedScale()) else GraphicsUtil.setGlobalFont(nil, 12, self:newCanvasSnappedScale()) diff --git a/client/src/Player.lua b/client/src/Player.lua index 375b5866..4ff36d92 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -82,6 +82,7 @@ function(self, name, publicId, isLocal) self:createSignal("levelChanged") self:createSignal("levelDataChanged") self:createSignal("inputMethodChanged") + self:createSignal("inputConfigurationChanged") self:createSignal("puzzleSetChanged") self:createSignal("ratingChanged") self:createSignal("leagueChanged") @@ -96,6 +97,10 @@ function Player:reset() self:unrestrictInputs() end +function Player:isLocalHuman() + return self.isLocal and self.human +end + ---@param engineStack Stack ---@return PlayerStack function Player:createClientStack(engineStack) @@ -217,6 +222,7 @@ function Player:restrictInputs(inputConfiguration) error("Player " .. self.playerNumber .. " is trying to claim a second input configuration") end self.inputConfiguration = input:claimConfiguration(self, inputConfiguration) + self:emitSignal("inputConfigurationChanged", self.inputConfiguration) end function Player:unrestrictInputs() @@ -229,6 +235,7 @@ function Player:unrestrictInputs() self.lastUsedInputConfiguration = self.inputConfiguration input:releaseConfiguration(self, self.inputConfiguration) self.inputConfiguration = nil + self:emitSignal("inputConfigurationChanged", nil) end end diff --git a/client/src/config.lua b/client/src/config.lua index 93c69a3f..cafb3ab7 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -9,7 +9,7 @@ require("client.src.globals") -- Default configuration values ---@class UserConfig ---@field version string ----@field language_code string +---@field language_code string? ---@field theme string ---@field panels string? ---@field character string @@ -51,12 +51,13 @@ require("client.src.globals") ---@field display integer ---@field windowX number? ---@field windowY number? +---@field discordCommunityShown boolean config = { -- The last used engine version version = consts.ENGINE_VERSION, -- Lang used for localization - language_code = "EN", + language_code = nil, -- Last selected theme, panels, character and stage theme = consts.DEFAULT_THEME_DIRECTORY, @@ -130,12 +131,15 @@ config = { display = 1, windowX = nil, windowY = nil, + discordCommunityShown = false, } -- writes to the "conf.json" file function write_conf_file() pcall( function() + local encoded = json.encode(config) + ---@cast encoded string love.filesystem.write("conf.json", json.encode(config)) end ) @@ -289,6 +293,9 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end + if type(read_data.discordCommunityShown) == "boolean" then + configTable.discordCommunityShown = read_data.discordCommunityShown + end configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end diff --git a/client/src/graphics/InputPromptRenderer.lua b/client/src/graphics/InputPromptRenderer.lua new file mode 100644 index 00000000..8147b3c3 --- /dev/null +++ b/client/src/graphics/InputPromptRenderer.lua @@ -0,0 +1,106 @@ +local GraphicsUtil = require("client.src.graphics.graphics_util") + +-- Rendering utility for drawing input device icons with optional numbering +local InputPromptRenderer = {} + +-- Renders an input prompt icon at the specified position +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param x number +---@param y number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +function InputPromptRenderer.renderIcon(deviceType, x, y, size, alpha, controllerImageVariant) + if not GAME.theme then + return + end + + size = size or 32 + alpha = alpha or 1 + + local icon = GAME.theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not icon then + return + end + + GraphicsUtil.setColor(1, 1, 1, alpha) + + local iconWidth = icon:getWidth() + local iconHeight = icon:getHeight() + local scale = size / math.max(iconWidth, iconHeight) + + love.graphics.draw(icon, x, y, 0, scale, scale) + + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Renders an input prompt icon centered at the specified position +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param centerX number +---@param centerY number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +function InputPromptRenderer.renderIconCentered(deviceType, centerX, centerY, size, alpha, controllerImageVariant) + if not GAME.theme then + return + end + + size = size or 32 + local icon = GAME.theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not icon then + return + end + + local iconWidth = icon:getWidth() + local iconHeight = icon:getHeight() + local scale = size / math.max(iconWidth, iconHeight) + + local scaledWidth = iconWidth * scale + local scaledHeight = iconHeight * scale + + local x = centerX - scaledWidth / 2 + local y = centerY - scaledHeight / 2 + + InputPromptRenderer.renderIcon(deviceType, x, y, size, alpha, controllerImageVariant) +end + +-- Renders an input prompt icon with a device number overlay +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param centerX number +---@param centerY number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +---@param deviceNumber integer? device configuration number (1-9, nil for no number) +function InputPromptRenderer.renderIconWithNumber(deviceType, centerX, centerY, size, alpha, controllerImageVariant, deviceNumber) + if not GAME.theme then + return + end + + -- Render the main device icon + InputPromptRenderer.renderIconCentered(deviceType, centerX, centerY, size, alpha, controllerImageVariant) + + -- Render device number overlay if provided + if deviceNumber and deviceNumber > 1 and deviceNumber <= 9 then + local numberIcon = GAME.theme:getDeviceNumberIcon(deviceNumber) + if numberIcon then + GraphicsUtil.setColor(1, 1, 1, alpha) + + local numberSize = math.max(size * 0.4, 16) -- Number should be smaller but visible + local numberWidth = numberIcon:getWidth() + local numberHeight = numberIcon:getHeight() + local numberScale = numberSize / math.max(numberWidth, numberHeight) + + -- Position number in bottom-right corner of device icon + local numberX = centerX + (size * 0.25) - (numberWidth * numberScale * 0.5) + local numberY = centerY + (size * 0.25) - (numberHeight * numberScale * 0.5) + + love.graphics.draw(numberIcon, numberX, numberY, 0, numberScale, numberScale) + + GraphicsUtil.setColor(1, 1, 1, 1) + end + end +end + +return InputPromptRenderer \ No newline at end of file diff --git a/client/src/input/InputConfiguration.lua b/client/src/input/InputConfiguration.lua new file mode 100644 index 00000000..63130e61 --- /dev/null +++ b/client/src/input/InputConfiguration.lua @@ -0,0 +1,442 @@ +local class = require("common.lib.class") +local consts = require("common.engine.consts") +local util = require("common.lib.util") +local joystickManager = require("common.lib.joystickManager") +require("client.src.input.JoystickProvider") + +-- Represents a single input configuration slot with key bindings +---@class InputConfiguration +---@field index number Configuration slot number (1-8) +---@field claimed boolean Whether this config is assigned to a player +---@field player Player? Reference to assigned player (if claimed) +---@field isDown table Input state tracking +---@field isPressed table Input press duration tracking +---@field isUp table Input release state tracking +---@field isPressedWithRepeat function +---@field joystickProvider JoystickProvider +---@field id string Unique identifier (e.g., "config_1") +---@field deviceType string? Device type ("keyboard", "controller", "touch", or nil if empty) +---@field deviceName string? Human-readable device name +---@field controllerImageVariant string? Controller icon variant +---@field deviceNumber number? Device count of this type (e.g., 2nd keyboard) +local InputConfiguration = class( + function(self, index, isPressedWithRepeatFn, joystickProvider) + self.index = index + self.claimed = false + self.player = nil + self.isDown = {} + self.isPressed = {} + self.isUp = {} + self.isPressedWithRepeat = isPressedWithRepeatFn + assert(joystickProvider) + self.joystickProvider = joystickProvider + + -- Cached properties (calculated on init and when bindings change) + self.id = string.format("config_%d", index) + self.deviceType = nil + self.deviceName = nil + self.controllerImageVariant = nil + self.deviceNumber = nil + + -- Calculate initial cached properties + self:updateCachedProperties() + end +) + +-- Recalculates cached properties for this configuration (does not update deviceNumber - use updateAllDeviceNumbers for that) +function InputConfiguration:updateCachedProperties() + self.deviceType = self:getDeviceType() + self.deviceName = self:getDeviceName() + self.controllerImageVariant = self:getControllerImageVariant() +end + +-- Updates this configuration's cached properties when bindings change +-- Note: This does NOT update deviceNumber - caller must update all configs' deviceNumbers +function InputConfiguration:update() + self:updateCachedProperties() +end + +-- Check if this configuration has any key bindings +---@return boolean isEmpty True if no keys are bound +function InputConfiguration:isEmpty() + for _, keyName in ipairs(consts.KEY_NAMES) do + if self[keyName] then + return false + end + end + return true +end + +-- Check if this configuration has all required key bindings +---@return boolean isFullyConfigured True if all required keys are bound +function InputConfiguration:isFullyConfigured() + for _, keyName in ipairs(consts.KEY_NAMES) do + if not self[keyName] then + return false + end + end + return true +end + +-- Parse a binding string to extract GUID, slot, and button ID (static helper) +---@param binding string? Binding string (e.g., "guid:slot:button") +---@return string? guid Controller GUID or nil if not a controller binding +---@return number? slot Controller slot number or nil if not a controller binding +---@return string? buttonId Button identifier or nil if not a controller binding +function InputConfiguration.parseBindingString(binding) + if not binding or not binding:match(":") then + return nil, nil, nil + end + + local guid, slot, buttonId = binding:match("([^:]+):([^:]+):(.+)") + if guid and slot and buttonId then + return guid, tonumber(slot), buttonId + end + + return nil, nil, nil +end + +-- Parse controller binding string to extract GUID and slot +---@param keyName string Key name to parse (e.g., "up", "down", "left") +---@return string? guid Controller GUID or nil if not a controller binding +---@return number? slot Controller slot number or nil if not a controller binding +function InputConfiguration:parseControllerBinding(keyName) + local binding = self[keyName] + local guid, slot, _ = InputConfiguration.parseBindingString(binding) + return guid, slot +end + +-- Determine device type based on the first available binding +---@return "keyboard"|"controller"|"touch"|nil deviceType Type of device or nil if no bindings +function InputConfiguration:getDeviceType() + if self:isEmpty() then + return nil + end + + local firstBinding + for _, keyName in ipairs(consts.KEY_NAMES) do + local binding = self[keyName] + if binding then + firstBinding = binding + break + end + end + + if not firstBinding then + return nil + end + + if firstBinding:find(":", 1, true) then + return "controller" + end + + if firstBinding:match("^mouse") then + return "touch" + end + + return "keyboard" +end + +-- Get a human-readable device name for this configuration +---@return string|nil deviceName Human-readable device name or nil if unknown +function InputConfiguration:getDeviceName() + local deviceType = self:getDeviceType() + + if deviceType == "keyboard" then + return "Keyboard" + elseif deviceType == "touch" then + return "Touch" + elseif deviceType == "controller" then + local guid + local keyNames = consts.KEY_NAMES or {} + + for _, keyName in ipairs(keyNames) do + guid, _ = self:parseControllerBinding(keyName) + if guid then + break + end + end + + if not guid then + return "Controller" + end + + local joysticks = self.joystickProvider:getJoysticks() + for _, joystick in ipairs(joysticks) do + if joystick:getGUID() == guid then + local name = joystick:getName() + if name and #name > 0 then + return name + end + end + end + + return "Controller" + end + + return nil +end + +-- Maps controller names to specific image variants for theme selection (static helper) +---@param controllerName string? Controller name from Love2D +---@return string Image variant key (e.g., "playstation4", "xboxone", "generic") +function InputConfiguration.getControllerImageVariantFromName(controllerName) + if not controllerName then + return "generic" + end + + local name = controllerName:lower() + + -- PlayStation controllers + if name:find("playstation") or name:find("dualshock") or name:find("dualsense") or name:find("ps%d") then + if name:find("5") or name:find("dualsense") then + return "playstation5" + elseif name:find("4") or name:find("dualshock 4") then + return "playstation4" + elseif name:find("3") then + return "playstation3" + elseif name:find("2") then + return "playstation2" + elseif name:find("1") then + return "playstation1" + else + return "playstation4" -- Default to PS4 for generic PlayStation + end + end + + -- Xbox controllers + if name:find("xbox") or name:find("microsoft") then + if name:find("series") or name:find("xbox series") then + return "xboxseries" + elseif name:find("one") or name:find("xbox one") then + return "xboxone" + elseif name:find("360") then + return "xbox360" + else + return "xboxone" -- Default to Xbox One for generic Xbox + end + end + + -- SNES controllers (8BitDo and others) - Check before Switch to avoid "Super Nintendo" matching "Nintendo" + if name:find("snes") or name:find("sn30") or name:find("sf30") or name:find("super nintendo") or name:find("super famicom") then + return "snes" + end + + -- N64 controllers (8BitDo and others) - Check before Switch to avoid "Nintendo 64" matching "Nintendo" + if name:find("n64") or name:find("nintendo 64") or name:find("64 controller") or (name:find("8bitdo") and name:find("64")) then + return "n64" + end + + -- Hyperkin Admiral N64 Controller + if name:find("admiral") then + return "n64" + end + + -- Nintendo Switch controllers + if name:find("switch") or name:find("nintendo") or (name:find("pro controller") and not name:find("sn30") and not name:find("sf30")) then + return "switch_pro" + end + + -- Hyperkin Scout (SNES-style controller) + if name:find("scout") and name:find("hyperkin") then + return "snes" + end + + -- iBuffalo SNES controllers + if name:find("ibuffalo") or name:find("2%-axis 8%-button") then + return "snes" + end + + -- SEGA Genesis/Mega Drive controllers (M30 style) + if name:find("m30") or name:find("genesis") or name:find("mega drive") or name:find("neogeo") then + -- Check it's not a Nintendo M30 variant + if not name:find("nintendo") then + return "generic" -- No SEGA controller image, use generic + end + end + + -- GameCube controllers + if name:find("gamecube") or name:find("game cube") then + return "gamecube" + end + + -- 8BitDo GameCube adapter + if name:find("gbros") then + return "gamecube" + end + + -- Hori Xbox-style controllers (Horipad for Xbox) - Check first + if name:find("hori") and name:find("xbox") then + return "xboxone" + end + + -- Hori Nintendo Switch controllers - Check for explicit Switch mention + if name:find("hori") and name:find("switch") then + return "switch_pro" + end + + -- Hori GameCube-style controllers (Battle Pad and generic Horipad default to GameCube) + -- Generic "Horipad" without Xbox/Switch specifier defaults to GameCube style + if name:find("hori") and (name:find("battle pad") or name:find("horipad")) then + return "gamecube" + end + + -- 8BitDo Pro 2 and Pro 3 - PlayStation style (symmetrical sticks) + if name:find("8bitdo") and (name:find("pro 2") or name:find("pro 3") or name:find("pro2") or name:find("pro3")) then + return "playstation4" -- Use PS4 as the generic PlayStation style + end + + -- 8BitDo Ultimate series - Xbox style (asymmetrical sticks) + if name:find("8bitdo") and name:find("ultimate") then + return "xboxone" -- Use Xbox One as the generic Xbox style + end + + -- GameSir Tarantula - PlayStation style (symmetrical sticks) + if name:find("gamesir") and name:find("tarantula") then + return "playstation4" + end + + -- Default to generic controller for other modern controllers + -- This includes: 8BitDo Lite/Zero/F40/Micro/Arcade, GameSir G7/T4/X2, Hori Fighting Edge, etc. + return "generic" +end + +-- Instance method to get controller image variant for this configuration +---@return string? controllerImageVariant Controller image variant or nil if not a controller +function InputConfiguration:getControllerImageVariant() + if self:getDeviceType() ~= "controller" then + return nil + end + + local controllerName = self:getDeviceName() + return InputConfiguration.getControllerImageVariantFromName(controllerName) +end + +-- Maps gamepad button IDs to display names (static helper) +---@param joystick PanelAttackJoystick? Joystick object +---@param buttonId string Button identifier (e.g., "0", "dpup11", "+y3") +---@return string Display name for the button +function InputConfiguration.getButtonNameFromMapping(joystick, buttonId) + if not joystick or not joystick:isGamepad() then + return buttonId + end + + local gamepadButtonNames = { + dpup = "Up", + dpdown = "Down", + dpleft = "Left", + dpright = "Right", + a = "A", + b = "B", + x = "X", + y = "Y", + leftshoulder = "LB", + rightshoulder = "RB", + leftstick = "LS", + rightstick = "RS", + start = "Start", + back = "Back", + guide = "Guide", + triggerleft = "LT", + triggerright = "RT" + } + + for gamepadButton, displayName in pairs(gamepadButtonNames) do + local inputtype, inputindex, hatdir = joystick:getGamepadMapping(gamepadButton) + if inputtype == "button" then + if tostring(inputindex) == tostring(buttonId) then + return displayName + end + if buttonId == (gamepadButton .. inputindex) then + return displayName + end + elseif inputtype == "hat" then + if buttonId == (gamepadButton .. inputindex) then + return displayName + end + elseif inputtype == "axis" then + local stickIndex = math.floor((1 + inputindex) / 2) + local direction = (inputindex % 2 == 0) and "y" or "x" + local axisString = direction .. stickIndex + + if buttonId == ("+" .. axisString) or buttonId == ("-" .. axisString) then + return displayName + end + end + end + + return buttonId +end + +-- Find a joystick by GUID and slot +---@param guid string Controller GUID +---@param slot number Controller slot number +---@return PanelAttackJoystick? joystick Joystick object or nil if not found +function InputConfiguration:findJoystick(guid, slot) + for _, stick in ipairs(self.joystickProvider:getJoysticks()) do + if stick:getGUID() == guid then + local guidMap = joystickManager.guidsToJoysticks and joystickManager.guidsToJoysticks[guid] + if guidMap and guidMap[stick:getID()] == slot then + return stick + end + end + end + return nil +end + +-- Get human-readable display name for a key binding +---@param keyBinding string? Key binding string (e.g., "space", "guid:slot:button", nil) +---@return string Display name for the key binding +function InputConfiguration:getButtonDisplayName(keyBinding) + if not keyBinding then + return loc("op_none") + end + + local guid, slot, buttonId = InputConfiguration.parseBindingString(keyBinding) + + if not guid or not slot or not buttonId then + -- Not a controller binding, return as-is + return keyBinding + end + + local joystick = self:findJoystick(guid, slot) + if joystick then + return InputConfiguration.getButtonNameFromMapping(joystick, buttonId) + else + return buttonId + end +end + +-- Singleton touch configuration instance +local touchConfiguration = nil + +-- Gets or creates the special Touch InputConfiguration that wraps the mouse +---@return InputConfiguration Touch configuration +function InputConfiguration.getTouchConfiguration() + if not touchConfiguration then + -- Create a special InputConfiguration for touch + -- We pass dummy values since touch doesn't use the normal config system + local dummyFn = function() return false end + touchConfiguration = InputConfiguration(0, dummyFn, love.joystick) + + -- Set touch-specific properties + touchConfiguration.id = "touch" + touchConfiguration.deviceType = "touch" + touchConfiguration.deviceName = "Touch" + touchConfiguration.controllerImageVariant = nil + touchConfiguration.deviceNumber = 1 + touchConfiguration.index = nil + + -- Override isEmpty to return false (touch is always "available") + touchConfiguration.isEmpty = function() return false end + + -- Link to the actual mouse input state + touchConfiguration.isDown = GAME.input.mouse.isDown + touchConfiguration.isPressed = GAME.input.mouse.isPressed + touchConfiguration.isUp = GAME.input.mouse.isUp + end + + return touchConfiguration +end + +return InputConfiguration diff --git a/client/src/input/JoystickProvider.lua b/client/src/input/JoystickProvider.lua new file mode 100644 index 00000000..b753d329 --- /dev/null +++ b/client/src/input/JoystickProvider.lua @@ -0,0 +1,6 @@ +---@class PanelAttackJoystick +---@field getGUID fun(self): string +---@field getName fun(self): string + +---@class JoystickProvider +---@field getJoysticks fun(self): PanelAttackJoystick[] diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index d5aaf35c..b450c89f 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -1,7 +1,11 @@ +local FileUtils = require("client.src.FileUtils") local tableUtils = require("common.lib.tableUtils") local joystickManager = require("common.lib.joystickManager") local consts = require("common.engine.consts") local logger = require("common.lib.logger") +local InputConfiguration = require("client.src.input.InputConfiguration") +local Signal = require("common.lib.signal") +require("client.src.input.JoystickProvider") -- table containing the set of keys in various states -- base structure: @@ -23,19 +27,8 @@ local inputManager = { mouse = {isDown = {}, isPressed = {}, isUp = {}, x = 0, y = 0}, inputConfigurations = {}, maxConfigurations = 8, - defaultKeys = { - Up = "up", - Down = "down", - Left = "left", - Right = "right", - Swap1 = "z", - Swap2 = "x", - TauntUp = "y", - TauntDown = "u", - Raise1 = "c", - Raise2 = "v", - Start = "return" - } + hasUnsavedChanges = false, + unconfiguredJoysticksCache = nil } -- Represents the state of love.run while the key in isDown/isUp is active @@ -87,18 +80,50 @@ function inputManager:keyReleased(key, scancode) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end -function inputManager:joystickPressed(joystick, button) - if not joystickManager.devices[joystick:getID()] then - love.joystickadded(joystick) +function inputManager:onJoystickAdded(joystick) + joystickManager:registerJoystick(joystick) + local unconfiguredJoysticks = self:updateUnconfiguredJoysticksCache() + + -- Check if the newly added joystick is unconfigured + for _, unconfiguredJoystick in ipairs(unconfiguredJoysticks) do + if unconfiguredJoystick == joystick then + self:emitSignal("unconfiguredJoystickAdded", joystick) + break + end + end +end + +function inputManager:onJoystickRemoved(joystick) + -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID + -- the GUID is consistent across sessions + local guid = joystick:getGUID() + -- ID is a per-session identifier for each controller regardless of type + local id = joystick:getID() + + local vendorID, productID, productVersion = joystick:getDeviceInfo() + + logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) + + if joystickManager.guidsToJoysticks[guid] then + joystickManager.guidsToJoysticks[guid][id] = nil + + if tableUtils.length(joystickManager.guidsToJoysticks[guid]) == 0 then + joystickManager.guidsToJoysticks[guid] = nil + end end + + joystickManager.devices[id] = nil + self:updateUnconfiguredJoysticksCache() +end + +function inputManager:joystickPressed(joystick, button) + joystickManager:registerJoystick(joystick) local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isDown[key] = KEY_CHANGE.DETECTED end function inputManager:joystickReleased(joystick, button) - if not joystickManager.devices[joystick:getID()] then - love.joystickadded(joystick) - end + joystickManager:registerJoystick(joystick) local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end @@ -439,25 +464,75 @@ function inputManager:getSaveKeyMap() return result end +function inputManager:write_key_file() + FileUtils.writeJson("", "keysV3.json", self:getSaveKeyMap()) + self.hasUnsavedChanges = false +end + +-- Saves input configuration mappings to disk +function inputManager:saveInputConfigurationMappings() + self:write_key_file() +end + + +local currentVersionFilename = "keysV3.json" +local previousVersionFilename = "keysV2.json" + +function inputManager:hasKeyFile() + local filename = nil + local migrateInputs = false + if FileUtils.exists(currentVersionFilename) then + filename = currentVersionFilename + elseif FileUtils.exists(previousVersionFilename) then + filename = previousVersionFilename + migrateInputs = true + end + + return filename, migrateInputs +end + +-- Loads input file and setups defaults +function inputManager:load() + local filename, migrateInputs = self:hasKeyFile() + + if filename == nil then + -- No key file exists - set up default keys + inputManager:setupDefaultKeyConfigurations() + return inputManager.inputConfigurations + end + + local inputConfigs = FileUtils.readJsonFile(filename) + + if migrateInputs then + -- migrate old input configs + inputConfigs = inputManager:migrateInputConfigs(inputConfigs) + end + self:importConfigurations(inputConfigs) + + return inputConfigs +end + for i = 1, inputManager.maxConfigurations do - inputManager.inputConfigurations[i] = { - isDown = {}, - isPressed = {}, - isUp = {}, - isPressedWithRepeat = isPressedWithRepeat, - claimed = false, - player = nil - } + inputManager.inputConfigurations[i] = InputConfiguration(i, isPressedWithRepeat, love.joystick) end inputManager.allKeys.isPressedWithRepeat = isPressedWithRepeat +-- Turn inputManager into a Signal emitter +Signal.turnIntoEmitter(inputManager) +inputManager:createSignal("unconfiguredJoystickAdded") + function inputManager:importConfigurations(configurations) for i = 1, #configurations do for key, value in pairs(configurations[i]) do self.inputConfigurations[i][key] = value end end + -- Update all cached properties after importing + for _, config in ipairs(self.inputConfigurations) do + config:updateCachedProperties() + end + self:updateAllDeviceNumbers() end function inputManager:claimConfiguration(player, inputConfiguration) @@ -488,4 +563,331 @@ function inputManager:releaseConfiguration(player, inputConfiguration) self:updateKeyMaps() end +-- Updates deviceNumber for all InputConfigurations based on device type counts +function inputManager:updateAllDeviceNumbers() + local deviceTypeCounters = {} + + for _, config in ipairs(self.inputConfigurations) do + -- Only count non-empty configurations with bindings + if not config:isEmpty() and config.deviceType then + deviceTypeCounters[config.deviceType] = (deviceTypeCounters[config.deviceType] or 0) + 1 + config.deviceNumber = deviceTypeCounters[config.deviceType] + else + config.deviceNumber = nil + end + end +end + +-- Updates a specific InputConfiguration when its bindings change +---@param config InputConfiguration Configuration to update +function inputManager:updateInputConfiguration(config) + config:update() + self:updateAllDeviceNumbers() +end + +-- Clears a button binding from all other input configurations +---@param buttonBinding string The button binding to clear (e.g., "space", "guid:slot:button") +function inputManager:clearButtonFromAllConfigs(buttonBinding) + if not buttonBinding then + return + end + + for _, config in ipairs(self.inputConfigurations) do + for _, keyName in ipairs(consts.KEY_NAMES) do + if config[keyName] == buttonBinding then + config[keyName] = nil + end + end + end +end + +-- Changes a key binding on an input configuration +---@param inputConfiguration InputConfiguration Configuration to modify +---@param keyName string Key name to change (e.g., "Up", "Down", "Swap1") +---@param keyToUse string? New key binding (nil to clear) +---@param skipSave boolean? If true, skip writing to file (for batch operations) +function inputManager:changeKeyBindingOnInputConfiguration(inputConfiguration, keyName, keyToUse, skipSave) + + -- Clear the new binding from all other configurations + if keyToUse then + self:clearButtonFromAllConfigs(keyToUse) + end + + inputConfiguration[keyName] = keyToUse + + self.hasUnsavedChanges = true + self:updateInputConfiguration(inputConfiguration) + self:updateUnconfiguredJoysticksCache() + if not skipSave then + self:write_key_file() + end +end + +-- Clears all key bindings on an input configuration +---@param inputConfiguration InputConfiguration Configuration to clear +function inputManager:clearKeyBindingsOnInputConfiguration(inputConfiguration) + for _, keyName in ipairs(consts.KEY_NAMES) do + inputConfiguration[keyName] = nil + end + self.hasUnsavedChanges = true + self:updateInputConfiguration(inputConfiguration) + self:updateUnconfiguredJoysticksCache() + self:write_key_file() +end + +function inputManager:setupDefaultKeyConfigurations() + local defaultKeys = {} + defaultKeys[#defaultKeys+1] = { + Up = "up", + Down = "down", + Left = "left", + Right = "right", + Swap1 = "z", + Swap2 = "x", + TauntUp = "y", + TauntDown = "u", + Raise1 = "c", + Raise2 = "v", + Start = "return" + } + defaultKeys[#defaultKeys+1] = { + Up = "w", + Down = "s", + Left = "a", + Right = "d", + Swap1 = "j", + Swap2 = "k", + TauntUp = "i", + TauntDown = "l", + Raise1 = "o", + Raise2 = "u", + Start = "space" + } + for i = 1, inputManager.maxConfigurations do + if i <= #defaultKeys then + for keyName, key in pairs(defaultKeys[i]) do + self.inputConfigurations[i][keyName] = key + end + else + for _, keyName in ipairs(consts.KEY_NAMES) do + self.inputConfigurations[i][keyName] = nil + end + end + end + + -- Auto-configure all connected gamepads + local connectedJoysticks = love.joystick.getJoysticks() + for _, joystick in ipairs(connectedJoysticks) do + self:autoConfigureJoystick(joystick, false) + end + + -- Update all cached properties after setting defaults + for _, config in ipairs(self.inputConfigurations) do + config:updateCachedProperties() + end + self:updateAllDeviceNumbers() +end + +---@return table? Input configuration with active input, or nil +function inputManager:detectActiveInputConfiguration() + for i = 1, #self.inputConfigurations do + local config = self.inputConfigurations[i] + for _, keyName in ipairs(consts.KEY_NAMES) do + if config.isDown and config.isDown[keyName] then + return config + end + end + end + + return nil +end + +---@param battleRoom BattleRoom? +---@return boolean True if an unassigned configuration has active input +function inputManager:checkForUnassignedConfigurationInputs(battleRoom) + if not battleRoom then + return false + end + + local activeConfig = self:detectActiveInputConfiguration() + if not activeConfig then + return false + end + + local assignedConfigs = {} + for _, player in ipairs(battleRoom:getLocalHumanPlayers()) do + if player.inputConfiguration then + assignedConfigs[player.inputConfiguration] = true + end + end + + return not assignedConfigs[activeConfig] +end + +-- Gets all configured joystick GUIDs from input configurations +---@return table Map of configured GUIDs +function inputManager:getConfiguredJoystickGuids() + local configuredGuids = {} + for i = 1, self.maxConfigurations do + local config = self.inputConfigurations[i] + if config then + for _, keyName in ipairs(consts.KEY_NAMES) do + local keyMapping = config[keyName] + if keyMapping and type(keyMapping) == "string" then + -- Extract GUID from mapping format like "guid:id:button" + local guid = keyMapping:match("^([^:]+):") + if guid then + configuredGuids[guid] = true + end + end + end + end + end + return configuredGuids +end + +-- Updates the unconfigured joysticks cache immediately +function inputManager:updateUnconfiguredJoysticksCache() + -- Build the list + local unconfiguredJoysticks = {} + local connectedJoysticks = love.joystick.getJoysticks() + local configuredGuids = self:getConfiguredJoystickGuids() + + for _, joystick in ipairs(connectedJoysticks) do + local guid = joystick:getGUID() + -- Check if this joystick could be auto-configured (has gamepad mapping) + local customId = joystickManager.guidsToJoysticks[guid] and joystickManager.guidsToJoysticks[guid][joystick:getID()] + + if customId and not configuredGuids[guid] then + unconfiguredJoysticks[#unconfiguredJoysticks + 1] = joystick + end + end + + -- Update the cache + self.unconfiguredJoysticksCache = unconfiguredJoysticks + return unconfiguredJoysticks +end + +-- Gets a list of joysticks that don't have input configurations +---@return love.Joystick[] Array of unconfigured joysticks +function inputManager:getUnconfiguredJoysticks() + -- Return cached value (always fresh since we update immediately) + return self.unconfiguredJoysticksCache or {} +end + +-- Check if there are any connected joysticks that aren't configured +function inputManager:hasUnconfiguredJoysticks() + local unconfigured = self:getUnconfiguredJoysticks() + return #unconfigured > 0 +end + +-- Finds the next available (empty) input configuration slot +---@return number? Index of empty configuration, or nil if all slots are full +function inputManager:findNextAvailableConfig() + for i = 1, self.maxConfigurations do + local isEmpty = self.inputConfigurations[i]:isEmpty() + if isEmpty then + return i + end + end + -- If all slots are full, return nil to indicate no available slot + return nil +end + +-- Automatically configures a joystick by mapping gamepad buttons to Panel Attack actions +---@param joystick love.Joystick The joystick to configure +---@param shouldSave boolean if the configuration should be saved to disk +---@return number? configIndex The index of the configuration that was set up, or nil if configuration failed +function inputManager:autoConfigureJoystick(joystick, shouldSave) + local configIndex = self:findNextAvailableConfig() + + -- If no available slot, skip configuration + if not configIndex then + return nil + end + + -- Only auto-configure gamepads (devices with known button mappings) + if not joystick:isGamepad() then + return nil + end + + logger.debug("Autoconfiguring joystick at index " .. configIndex) + + local guid = joystick:getGUID() + local customId = joystickManager.guidsToJoysticks[guid] and joystickManager.guidsToJoysticks[guid][joystick:getID()] + + if customId ~= nil then + -- Map Panel Attack actions to Love2D gamepad button names + local gamepadButtonMappings = { + Up = "dpup", + Down = "dpdown", + Left = "dpleft", + Right = "dpright", + Swap1 = "a", + Swap2 = "b", + TauntUp = "y", + TauntDown = "x", + Raise1 = "leftshoulder", + Raise2 = "rightshoulder", + Start = "start" + } + + local basicMapping = {} + + -- Query the actual button mappings from Love2D + for panelAttackAction, gamepadButton in pairs(gamepadButtonMappings) do + local inputtype, inputindex, _ = joystick:getGamepadMapping(gamepadButton) + if inputtype == "button" then + basicMapping[panelAttackAction] = guid .. ":" .. customId .. ":" .. inputindex + elseif inputtype == "hat" then + -- Some controllers use hat for D-pad instead of individual buttons + basicMapping[panelAttackAction] = guid .. ":" .. customId .. ":" .. gamepadButton .. inputindex + end + end + + -- Only proceed if we got at least some mappings + if next(basicMapping) then + -- Ensure the configuration slot has all the keys we need + local config = self.inputConfigurations[configIndex] + for keyName, keyMapping in pairs(basicMapping) do + self:changeKeyBindingOnInputConfiguration(config, keyName, keyMapping, true) + end + + -- Make sure all required keys are set (fill any missing ones with nil to be explicit) + for _, keyName in ipairs(consts.KEY_NAMES) do + if config[keyName] == nil and not basicMapping[keyName] then + self:changeKeyBindingOnInputConfiguration(config, keyName, nil, true) + end + end + + self:updateUnconfiguredJoysticksCache() + if shouldSave then + self:write_key_file() + end + return configIndex + end + end + return nil +end + +-- Gets list of all assignable input devices (controllers, keyboard, touch) +-- Returns InputConfiguration objects directly with all metadata already calculated +---@return InputConfiguration[] Array of InputConfiguration objects +function inputManager:getAssignableDevices() + local devices = {} + + -- Add all non-empty InputConfigurations + for _, config in ipairs(self.inputConfigurations) do + if not config:isEmpty() then + devices[#devices + 1] = config + end + end + + -- Add touch configuration + local touchConfig = InputConfiguration.getTouchConfiguration() + devices[#devices + 1] = touchConfig + + return devices +end + return inputManager diff --git a/client/src/localization.lua b/client/src/localization.lua index 821972eb..b5ca96a4 100644 --- a/client/src/localization.lua +++ b/client/src/localization.lua @@ -2,7 +2,7 @@ local FILENAME = "client/assets/localization.csv" local consts = require("common.engine.consts") local logger = require("common.lib.logger") -local GraphicsUtil = require("client.src.graphics.graphics_util") +local ui = require("client.src.ui") local class = require("common.lib.class") local fileUtils = require("client.src.FileUtils") @@ -15,11 +15,11 @@ Localization = { init = false, } -function Localization.get_list_codes(self) +function Localization:get_list_codes() return self.codes end -function Localization.get_language(self) +function Localization:get_language() return self.codes[self.lang_index] end @@ -179,4 +179,40 @@ function loc(text_key, ...) return ret end +function Localization:getCurrentLanguageCode() + if config.language_code then + return config.language_code + end + return "EN" +end + +-- Creates language labels by temporarily switching to each language to load proper fonts +-- Returns: array of {code, name} pairs, array of labels with proper fonts +function Localization:getLanguageLabelsWithFonts() + local languageData = {} + local languageLabels = {} + local originalLanguageCode = self:getCurrentLanguageCode() + + for k, languageCode in ipairs(self:get_list_codes()) do + GAME:setLanguage(languageCode) + local languageName = self.data[languageCode]["LANG"] + languageData[#languageData + 1] = {code = languageCode, name = languageName} + languageLabels[#languageLabels + 1] = ui.Label({text = languageName, translate = false}) + end + + GAME:setLanguage(originalLanguageCode) + + return languageData, languageLabels +end + +-- Gets the index of a language code in the list +function Localization:getLanguageIndex(languageCode) + for k, code in ipairs(self:get_list_codes()) do + if code == languageCode then + return k + end + end + return 1 +end + return Localization \ No newline at end of file diff --git a/client/src/mods/Theme.lua b/client/src/mods/Theme.lua index f7c63068..7eccafb7 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -40,6 +40,7 @@ local flags = { ---@field main_menu_y_max number ---@field main_menu_max_height number ---@field defaultStage Stage +---@field colors table color palette where each value is an RGBA array of four numbers (0-1 range) Theme = class( ---@param self Theme @@ -57,6 +58,22 @@ Theme = self.main_menu_screen_pos = {0, 0} -- the top center position of most menus self.main_menu_y_max = 0 self.main_menu_max_height = 0 + + self.colors = { + menuDefaultBackgroundColor = {1, 1, 1, 0.15}, + menuDefaultBorderColor = {1, 1, 1, 0.15}, + menuSelectedBackgroundColor = {0.6, 0.6, 1, 0.15}, + menuSelectedBorderColor = {0.6, 0.6, 1, 0.15}, + activeBackgroundColor = {0.2, 0.3, 0.4, 0.9}, + darkTransparentBackgroundColor = {0, 0, 0, 0.75}, + highlightTextColor = {1, 1, 0.3, 1}, + inputSlotDefaultBackgroundColor = {0.2, 0.2, 0.2, 0.9}, + inputSlotDefaultBorderColor = {0.4, 0.4, 0.4, 0.9}, + inputSlotSelectedBackgroundColor = {0.2, 0.2, 0.34, 1.0}, + inputSlotSelectedBorderColor = {0.5, 0.5, 0.8, 1.0}, + incompleteConfigBackgroundColor = {0.918, 0.251, 0.275, 1.0}, + configCorrectBackgroundColor = {0.3, .3, .3, 0.7} + } end ) @@ -360,6 +377,42 @@ local function loadPlayerNumberIcons(theme) return theme.images.IMG_players end +local function loadInputPromptIcons(theme) + local icons = {} + + -- Load basic device types + icons.controller = theme:load_theme_img("input/controller") + icons.keyboard = theme:load_theme_img("input/keyboard") + icons.touch = theme:load_theme_img("input/touch") + icons.mouse = theme:load_theme_img("input/mouse") + + -- Load specific controller variants + icons.controller_variants = {} + icons.controller_variants.generic = theme:load_theme_img("input/controller_generic") + icons.controller_variants.playstation1 = theme:load_theme_img("input/controller_playstation1") + icons.controller_variants.playstation2 = theme:load_theme_img("input/controller_playstation2") + icons.controller_variants.playstation3 = theme:load_theme_img("input/controller_playstation3") + icons.controller_variants.playstation4 = theme:load_theme_img("input/controller_playstation4") + icons.controller_variants.playstation5 = theme:load_theme_img("input/controller_playstation5") + icons.controller_variants.xbox360 = theme:load_theme_img("input/controller_xbox360") + icons.controller_variants.xboxone = theme:load_theme_img("input/controller_xboxone") + icons.controller_variants.xboxseries = theme:load_theme_img("input/controller_xboxseries") + icons.controller_variants.switch_pro = theme:load_theme_img("input/controller_switch_pro") + + -- Load add controller icon + icons.controller_add = theme:load_theme_img("input/controller_add") + icons.controller_error = theme:load_theme_img("input/error") + + -- Load device number overlays + icons.device_numbers = {} + for i = 0, 9 do + icons.device_numbers[i] = theme:load_theme_img("input/device_number_" .. i) + end + + theme.images.inputPrompts = icons + return theme.images.inputPrompts +end + function Theme:loadSelectionGraphics() self.images.flags = {} for _, flag in ipairs(flags) do @@ -393,6 +446,7 @@ function Theme:loadSelectionGraphics() self.images.IMG_random_character = self:load_theme_img("random_character") loadPlayerNumberIcons(self) + loadInputPromptIcons(self) loadGridCursors(self) end @@ -1029,6 +1083,48 @@ function Theme:getPlayerNumberIcon(index) return self.images.IMG_players[index] end +---@param deviceType string +---@return love.Texture +function Theme:getInputPromptIcon(deviceType) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + return self.images.inputPrompts[deviceType] +end + +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param controllerImageVariant string? specific controller image variant key +---@return love.Texture +function Theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + + if deviceType == "controller" and controllerImageVariant and self.images.inputPrompts.controller_variants then + local specificIcon = self.images.inputPrompts.controller_variants[controllerImageVariant] + if specificIcon then + return specificIcon + end + end + + -- Fallback to basic device type + return self.images.inputPrompts[deviceType] +end + +---@param number integer the device number (0-9) +---@return love.Texture? +function Theme:getDeviceNumberIcon(number) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + + if self.images.inputPrompts.device_numbers and number >= 0 and number <= 9 then + return self.images.inputPrompts.device_numbers[number] + end + + return nil +end + ---@param index integer? ---@return love.Texture function Theme:getHealthBarFrameAbsolute(index) diff --git a/client/src/save.lua b/client/src/save.lua index d3cc425a..6c5aaa4c 100644 --- a/client/src/save.lua +++ b/client/src/save.lua @@ -1,4 +1,3 @@ -local inputManager = require("client.src.inputManager") local FileUtils = require("client.src.FileUtils") local logger = require("common.lib.logger") @@ -6,37 +5,6 @@ local logger = require("common.lib.logger") local save = {} --- writes to the "keys.txt" file -function save.write_key_file() - FileUtils.writeJson("", "keysV3.json", inputManager:getSaveKeyMap()) -end - --- reads the "keys.txt" file -function save.read_key_file() - local filename - local migrateInputs = false - - if FileUtils.exists("keysV3.json") then - filename = "keysV3.json" - else - filename = "keysV2.txt" - migrateInputs = true - end - - if not FileUtils.exists(filename) then - return inputManager.inputConfigurations - else - local inputConfigs = FileUtils.readJsonFile(filename) - - if migrateInputs then - -- migrate old input configs - inputConfigs = inputManager:migrateInputConfigs(inputConfigs) - end - - return inputConfigs - end -end - -- reads the .txt file of the given path and filename function save.read_txt_file(path_and_filename) local s diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index eb29eb46..0690c001 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -4,11 +4,12 @@ local class = require("common.lib.class") local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") local GameModes = require("common.data.GameModes") -local LevelPresets = require("common.data.LevelPresets") local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local Character = require("client.src.mods.Character") +local LevelPresets = require("common.data.LevelPresets") +local InputDeviceOverlay = require("client.src.scenes.components.InputDeviceOverlay") -- The character select screen scene ---@class CharacterSelect : Scene @@ -57,8 +58,12 @@ function CharacterSelect:load() self.ui.cursors = {} self.ui.characterIcons = {} self.ui.playerInfos = {} - self:customLoad() + + self:createInputDeviceOverlay() + + self:setChangeInputButtonVisibility(false) + self:setChangeInputButtonVisibleIfNeeded() for _, player in ipairs(self.players) do if player:isHuman() then @@ -287,6 +292,68 @@ function CharacterSelect:createStageCarousel(player, width) return stageCarousel end +function CharacterSelect:createInputDeviceOverlay() + + self.inputDeviceOverlay = InputDeviceOverlay({ + battleRoom = self.battleRoom, + onClose = function() + self:onInputDeviceOverlayClosed() + end, + onCancel = function() + self:leave() + end + }) + self.uiRoot:addChild(self.inputDeviceOverlay) +end + +function CharacterSelect:onInputDeviceOverlayClosed() + self:setChangeInputButtonVisibleIfNeeded() +end + +function CharacterSelect:setChangeInputButtonVisibleIfNeeded() + if self.ui and self.ui.changeInputButton then + if self.battleRoom and #self.battleRoom:getLocalHumanPlayers() == 0 then + return + end + + self.ui.changeInputButton:setVisibility(true) + end +end + +function CharacterSelect:setChangeInputButtonVisibility(isVisible) + if self.ui and self.ui.changeInputButton then + self.ui.changeInputButton:setVisibility(isVisible) + end +end + +function CharacterSelect:createChangeInputButton() + return ui.ChangeInputButton({ + hFill = true, + vFill = true, + battleRoom = self.battleRoom, + onChangeInputRequested = function() + self:onChangeInputDeviceRequested() + end + }) +end + +function CharacterSelect:onChangeInputDeviceRequested() + if not self.battleRoom then + return + end + + local hasLocalPlayers = #self.battleRoom:getLocalHumanPlayers() > 0 + if not hasLocalPlayers then + return + end + + if self.battleRoom:releaseAllLocalAssignments() then + GAME.theme:playCancelSfx() + else + GAME.theme:playMoveSfx() + end +end + local super_select_pixelcode = [[ uniform float percent; vec4 effect( vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords ) @@ -992,6 +1059,11 @@ function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) end function CharacterSelect:updateSelf(dt) + local overlayActive = self.inputDeviceOverlay and self.inputDeviceOverlay:isActive() + if overlayActive then + return + end + for _, cursor in ipairs(self.ui.cursors) do if cursor.player.isLocal and cursor.player.human then if not cursor.player.inputConfiguration then diff --git a/client/src/scenes/CharacterSelect2p.lua b/client/src/scenes/CharacterSelect2p.lua index 664ccddc..6bc6285c 100644 --- a/client/src/scenes/CharacterSelect2p.lua +++ b/client/src/scenes/CharacterSelect2p.lua @@ -37,6 +37,7 @@ function CharacterSelect2p:loadUserInterface() self.ui.pageIndicator = self:createPageIndicator(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() self.ui.rankedSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.rankedSelection:setTitle("ss_ranked") @@ -67,6 +68,7 @@ function CharacterSelect2p:loadUserInterface() self.ui.grid:createElementAt(9, 2, 1, 1, "readyButton", self.ui.readyButton) self.ui.grid:createElementAt(1, 3, characterGridWidth, characterGridHeight, "characterSelection", self.ui.characterGrid, true) self.ui.grid:createElementAt(5, 6, 1, 1, "pageIndicator", self.ui.pageIndicator) + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.characterIcons = {} @@ -108,4 +110,4 @@ function CharacterSelect2p:loadUserInterface() end -return CharacterSelect2p \ No newline at end of file +return CharacterSelect2p diff --git a/client/src/scenes/CharacterSelectChallenge.lua b/client/src/scenes/CharacterSelectChallenge.lua index 90b100d8..48f3fa28 100644 --- a/client/src/scenes/CharacterSelectChallenge.lua +++ b/client/src/scenes/CharacterSelectChallenge.lua @@ -29,6 +29,7 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.characterGrid = self:createCharacterGrid(characterButtons, self.ui.grid, characterGridWidth, characterGridHeight) self.ui.pageIndicator = self:createPageIndicator(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() local panelHeight local stageWidth @@ -42,6 +43,7 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.grid:createElementAt(9, 2, 1, 1, "readyButton", self.ui.readyButton) self.ui.grid:createElementAt(1, 3, characterGridWidth, characterGridHeight, "characterSelection", self.ui.characterGrid, true) self.ui.grid:createElementAt(5, 6, 1, 1, "pageIndicator", self.ui.pageIndicator) + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.characterIcons = {} @@ -76,4 +78,4 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) end -return CharacterSelectChallenge \ No newline at end of file +return CharacterSelectChallenge diff --git a/client/src/scenes/CharacterSelectVsSelf.lua b/client/src/scenes/CharacterSelectVsSelf.lua index 59a22c56..6a4ad970 100644 --- a/client/src/scenes/CharacterSelectVsSelf.lua +++ b/client/src/scenes/CharacterSelectVsSelf.lua @@ -70,6 +70,8 @@ function CharacterSelectVsSelf:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.cursors[1] = self:createCursor(self.ui.grid, player) @@ -94,4 +96,4 @@ function CharacterSelectVsSelf:refresh() end end -return CharacterSelectVsSelf \ No newline at end of file +return CharacterSelectVsSelf diff --git a/client/src/scenes/DiscordCommunitySetup.lua b/client/src/scenes/DiscordCommunitySetup.lua new file mode 100644 index 00000000..ea8930a0 --- /dev/null +++ b/client/src/scenes/DiscordCommunitySetup.lua @@ -0,0 +1,127 @@ +local Scene = require("client.src.scenes.Scene") +local ui = require("client.src.ui") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") +local save = require("client.src.save") +local InputConfigMenu = require("client.src.scenes.InputConfigMenu") +local logger = require("common.lib.logger") + +local DiscordCommunitySetup = class(function(self, sceneParams) + assert(sceneParams, "DiscordCommunitySetup requires sceneParams") + assert(sceneParams.triggerNextScene, "DiscordCommunitySetup requires triggerNextScene callback") + + self.music = "main" + + local titleFontSize = 28 + local bodyFontSize = 14 + + -- Create a centered vertical stack panel for all content + local contentStack = ui.StackPanel({ + alignment = "top", + width = consts.CANVAS_WIDTH, + hAlign = "center", + vAlign = "center" + }) + + -- Title + local titleLabel = ui.Label({ + fontSize = titleFontSize, + text = "discord_welcome_title", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(titleLabel) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- Discord logo + local discordLogo = ui.ImageContainer({ + image = love.graphics.newImage("client/assets/themes/Panel Attack Modern/discord_logo.png"), + width = 160, + height = 160, + hAlign = "center" + }) + contentStack:addElement(discordLogo) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- Message lines + local messageLine1 = ui.Label({ + fontSize = bodyFontSize, + text = "discord_message_line1", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(messageLine1) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + local messageLine2 = ui.Label({ + fontSize = bodyFontSize, + text = "discord_message_line2", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(messageLine2) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + local messageLine3 = ui.Label({ + fontSize = bodyFontSize, + text = "discord_message_line3", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(messageLine3) + + local discordLinkButton = ui.MenuItem.createButtonMenuItem("discord_join_link", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://discord.panelattack.com") + end) + + local continueButton = ui.MenuItem.createButtonMenuItem("next_button", nil, nil, function() + GAME.theme:playValidationSfx() + config.discordCommunityShown = true + write_conf_file() + self.triggerNextScene() + end) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 20 + })) + + -- Menu buttons + local menu = ui.Menu.createCenteredMenu({discordLinkButton, continueButton}, 0) + contentStack:addElement(menu) + self.menu = menu + + self.uiRoot:addChild(contentStack) +end, Scene) + +DiscordCommunitySetup.name = "DiscordCommunitySetup" + +function DiscordCommunitySetup:update(dt) + GAME.theme.images.bg_main:update(dt) + self.menu:receiveInputs() +end + +function DiscordCommunitySetup:draw() + GAME.theme.images.bg_main:draw() + self.uiRoot:draw() +end + +return DiscordCommunitySetup \ No newline at end of file diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index a0a00b27..ea5936ca 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -104,6 +104,9 @@ function EndlessMenu:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) + self.ui.leaveButton = self:createLeaveButton() self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) diff --git a/client/src/scenes/InputConfigMenu.lua b/client/src/scenes/InputConfigMenu.lua index ab9187a0..4c12f96f 100644 --- a/client/src/scenes/InputConfigMenu.lua +++ b/client/src/scenes/InputConfigMenu.lua @@ -2,82 +2,120 @@ local Scene = require("client.src.scenes.Scene") local tableUtils = require("common.lib.tableUtils") local ui = require("client.src.ui") local consts = require("common.engine.consts") -local input = require("client.src.inputManager") -local joystickManager = require("common.lib.joystickManager") -local util = require("common.lib.util") +local inputManager = require("client.src.inputManager") local class = require("common.lib.class") -local save = require("client.src.save") +local InputConfigSlider = require("client.src.ui.InputConfigSlider") +local KeyBindingMenuItem = require("client.src.ui.KeyBindingMenuItem") + +-- Sometimes controllers register buttons as "pressed" even though they aren't. If they have been pressed longer than this they don't count. +local MAX_PRESS_DURATION = 0.5 +local pendingInputText = "__" + +-- Represents the state of the InputConfigMenu +-- NOT_SETTING: when we are not polling for a new key +-- SETTING_KEY_TRANSITION: skip a frame so we don't use the button activation key as the configured key +-- SETTING_KEY: currently polling for a single key +-- SETTING_ALL_KEYS: currently polling for all keys +local KEY_SETTING_STATE = { NOT_SETTING = nil, SETTING_KEY_TRANSITION = 1, SETTING_KEY = 2, SETTING_ALL_KEYS_TRANSITION = 3, SETTING_ALL_KEYS = 4 } -- Scene for configuring input local InputConfigMenu = class( function (self, sceneParams) - self.backgroundImg = themes[config.theme].images.bg_main self.music = "main" self.settingKey = false - self.menu = nil -- set in load - - self:load(sceneParams) + self.menu = nil + self.backgroundImg = nil + self.newInputsConfigured = inputManager.hasUnsavedChanges + self.configIndex = 1 + + self:loadUI() + + self:autoConfigureJoysticks() + + self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) + + -- Listen for unconfigured joysticks being added + inputManager:connectSignal("unconfiguredJoystickAdded", self, self.onUnconfiguredJoystickAdded) end, Scene ) InputConfigMenu.name = "InputConfigMenu" --- Sometimes controllers register buttons as "pressed" even though they aren't. If they have been pressed longer than this they don't count. -local MAX_PRESS_DURATION = 0.5 -local KEY_NAME_LABEL_WIDTH = 180 -local PADDING = 8 -local pendingInputText = "__" - -local function shortenControllerName(name) - local nameToShortName = { - ["Nintendo Switch Pro Controller"] = "Switch Pro Con" - } - return nameToShortName[name] or name -end - --- Represents the state of love.run while the key in isDown/isUp is active --- NOT_SETTING: when we are not polling for a new key --- SETTING_KEY_TRANSITION: skip a frame so we don't use the button activation key as the configured key --- SETTING_KEY: currently polling for a single key --- SETTING_ALL_KEYS: currently polling for all keys --- This is only used within this file, external users should simply treat isDown/isUp as a boolean -local KEY_SETTING_STATE = { NOT_SETTING = nil, SETTING_KEY_TRANSITION = 1, SETTING_KEY = 2, SETTING_ALL_KEYS_TRANSITION = 3, SETTING_ALL_KEYS = 4 } - function InputConfigMenu:setSettingKeyState(keySettingState) self.settingKey = keySettingState ~= KEY_SETTING_STATE.NOT_SETTING self.settingKeyState = keySettingState self.menu:setEnabled(not self.settingKey) + + -- Update back button color based on configuration completeness + if self.backMenuItem and self.backMenuItem.textButton then + if self:allInputConfigurationsValid() then + self.backMenuItem.textButton.backgroundColor = GAME.theme.colors.configCorrectBackgroundColor + else + self.backMenuItem.textButton.backgroundColor = GAME.theme.colors.incompleteConfigBackgroundColor + end + end end -function InputConfigMenu:getKeyDisplayName(key) - local keyDisplayName = key - if key and string.match(key, ":") then - local controllerKeySplit = util.split(key, ":") - local controllerName = shortenControllerName(joystickManager.guidToName[controllerKeySplit[1]] or "Unplugged Controller") - keyDisplayName = string.format("%s (%s-%s)", controllerKeySplit[3], controllerName, controllerKeySplit[2]) +function InputConfigMenu:allInputConfigurationsValid() + local result = true + + for _, config in ipairs(inputManager.inputConfigurations) do + + local isIncomplete = not config:isEmpty() and not config:isFullyConfigured() + if isIncomplete then + result = false + break + end end - return keyDisplayName or loc("op_none") + + return result +end + +function InputConfigMenu:getKeyDisplayName(key) + local config = inputManager.inputConfigurations[self.configIndex] + return config:getButtonDisplayName(key) end -function InputConfigMenu:updateInputConfigMenuLabels(index) - self.configIndex = index +function InputConfigMenu:updateInputConfigMenuLabels() for i, key in ipairs(consts.KEY_NAMES) do - local keyDisplayName = self:getKeyDisplayName(GAME.input.inputConfigurations[self.configIndex][key]) - self:currentKeyLabelForIndex(i + 1):setText(keyDisplayName) + local keyDisplayName = self:getKeyDisplayName(inputManager.inputConfigurations[self.configIndex][key]) + self:currentKeyLabelForIndex(i + 1):setText(keyDisplayName, nil, false) + end +end + +function InputConfigMenu:currentKeyLabelForIndex(index) + -- Index is 1-based for key bindings (1 = first key) + -- Menu item index = index + 1 (account for slider at index 1) + local menuItem = self.menu.menuItems[index] + if menuItem.bindingButton and menuItem.bindingButton.label then + return menuItem.bindingButton.label + else + return menuItem.textButton.children[1] end end function InputConfigMenu:updateKey(key, pressedKey, index) GAME.theme:playValidationSfx() - GAME.input.inputConfigurations[self.configIndex][key] = pressedKey + local config = inputManager.inputConfigurations[self.configIndex] + inputManager:changeKeyBindingOnInputConfiguration(config, key, pressedKey) local keyDisplayName = self:getKeyDisplayName(pressedKey) - self:currentKeyLabelForIndex(index + 1):setText(keyDisplayName) - save.write_key_file() + + -- Update the menu item (index + 1 to account for slider at position 1) + local menuItemIndex = index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(keyDisplayName) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(false) + end + + self:refreshUI() end function InputConfigMenu:setKey(key, index) - local pressedKey = next(input.allKeys.isDown) + local pressedKey = next(inputManager.allKeys.isDown) if pressedKey then self:updateKey(key, pressedKey, index) self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) @@ -85,13 +123,20 @@ function InputConfigMenu:setKey(key, index) end function InputConfigMenu:setAllKeys() - local pressedKey = next(input.allKeys.isDown) + local pressedKey = next(inputManager.allKeys.isDown) if pressedKey then self:updateKey(consts.KEY_NAMES[self.index], pressedKey, self.index) if self.index < #consts.KEY_NAMES then self.index = self.index + 1 - self:currentKeyLabelForIndex(self.index + 1):setText(pendingInputText) - self.menu.selectedIndex = self.index + 1 + local menuItemIndex = self.index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(pendingInputText) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(true) + end + self.menu:setSelectedIndex(menuItemIndex) self:setSettingKeyState(KEY_SETTING_STATE.SETTING_ALL_KEYS_TRANSITION) else self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) @@ -99,10 +144,6 @@ function InputConfigMenu:setAllKeys() end end -function InputConfigMenu:currentKeyLabelForIndex(index) - return self.menu.menuItems[index].textButton.children[1] -end - function InputConfigMenu:setKeyStart(key) GAME.theme:playValidationSfx() self.key = key @@ -113,87 +154,206 @@ function InputConfigMenu:setKeyStart(key) break end end - self:currentKeyLabelForIndex(self.index + 1):setText(pendingInputText) - self.menu.selectedIndex = self.index + 1 + local menuItemIndex = self.index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(pendingInputText) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(true) + end + self.menu:setSelectedIndex(menuItemIndex) self:setSettingKeyState(KEY_SETTING_STATE.SETTING_KEY_TRANSITION) end function InputConfigMenu:setAllKeysStart() GAME.theme:playValidationSfx() self.index = 1 - self:currentKeyLabelForIndex(self.index + 1):setText(pendingInputText) - self.menu:setSelectedIndex(self.index + 1) + local menuItemIndex = self.index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(pendingInputText) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(true) + end + self.menu:setSelectedIndex(menuItemIndex) self:setSettingKeyState(KEY_SETTING_STATE.SETTING_ALL_KEYS_TRANSITION) end function InputConfigMenu:clearAllInputs() GAME.theme:playValidationSfx() - for i, key in ipairs(consts.KEY_NAMES) do - GAME.input.inputConfigurations[self.configIndex][key] = nil - local keyName = loc("op_none") - self:currentKeyLabelForIndex(i + 1):setText(keyName) + local config = inputManager.inputConfigurations[self.configIndex] + inputManager:clearKeyBindingsOnInputConfiguration(config) + self:refreshUI() + self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) +end + +function InputConfigMenu:resetToDefault(menuOptions) + GAME.theme:playValidationSfx() + inputManager:setupDefaultKeyConfigurations() + GAME.theme:playMoveSfx() + self.slider:setValue(1) + self.configIndex = 1 + self:refreshUI() + self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) +end + +function InputConfigMenu:autoConfigureJoysticks() + + -- Auto-configure any newly connected joysticks + for _, joystick in ipairs(inputManager:getUnconfiguredJoysticks()) do + -- Use inputManager to perform the actual configuration + local configIndex = inputManager:autoConfigureJoystick(joystick, true) + + if configIndex then + -- Flag that a new controller was just configured + self.newInputsConfigured = true + + self.configIndex = configIndex + self.slider:setValue(configIndex) + self:refreshUI() + end end - save.write_key_file() end -function InputConfigMenu:resetToDefault(menuOptions) - GAME.theme:playValidationSfx() - local i = 1 - for keyName, key in pairs(input.defaultKeys) do - GAME.input.inputConfigurations[1][keyName] = key - self:currentKeyLabelForIndex(i + 1):setText(GAME.input.inputConfigurations[1][keyName]) - i = i + 1 +-- Signal handler called when an unconfigured joystick is added +function InputConfigMenu:onUnconfiguredJoystickAdded(joystick) + -- Auto-configure the joystick + local configIndex = inputManager:autoConfigureJoystick(joystick, true) + + if configIndex then + -- Flag that a new controller was just configured + self.newInputsConfigured = true + + -- Switch to the newly configured input + self.configIndex = configIndex + self.slider:setValue(configIndex) + self:refreshUI() end - for j = 2, input.maxConfigurations do - for _, key in ipairs(consts.KEY_NAMES) do - GAME.input.inputConfigurations[j][key] = nil +end + +function InputConfigMenu:createExitMenuFunction() + return function () + local currentConfig = inputManager.inputConfigurations[self.configIndex] + + -- Check if current configuration is half-configured + if not currentConfig:isEmpty() and not currentConfig:isFullyConfigured() then + return + end + + GAME.theme:playValidationSfx() + if inputManager.hasUnsavedChanges then + inputManager:saveInputConfigurationMappings() + end + if self.triggerNextScene then + self.triggerNextScene() + else + GAME.navigationStack:pop() end end - GAME.theme:playMoveSfx() - self.slider:setValue(1) - self:updateInputConfigMenuLabels(1) - save.write_key_file() end -local function exitMenu() - GAME.theme:playValidationSfx() - GAME.navigationStack:pop() -end +function InputConfigMenu:loadUI() -function InputConfigMenu:load(sceneParams) - self.configIndex = 1 + self.backgroundImg = themes[config.theme].images.bg_main + + -- Create a centered vertical stack panel for all content + local contentStack = ui.StackPanel({ + alignment = "top", + width = consts.CANVAS_WIDTH, + hAlign = "center", + vAlign = "center" + }) + + -- Header text + local headerText = ui.Label({ + text = "config_input_welcome", + hAlign = "center", + vAlign = "center", + fontSize = 16 + }) + contentStack:addElement(headerText) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- New controller message (conditionally visible) + self.newControllerLabel = ui.Label({ + text = "input_config_new_controller", + hAlign = "center", + vAlign = "center", + textColor = GAME.theme.colors.highlightTextColor, + fontSize = 14 + }) + self.newControllerLabel.isVisible = false + contentStack:addElement(self.newControllerLabel) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- Create menu options local menuOptions = {} - self.slider = ui.Slider({ - min = 1, - max = input.maxConfigurations, - value = 1, - tickLength = 10, - onValueChange = function(slider) self:updateInputConfigMenuLabels(slider.value) end}) - menuOptions[1] = ui.MenuItem.createSliderMenuItem("configuration", nil, nil, self.slider) + + -- 1. Slider + self.slider = InputConfigSlider({ + value = self.configIndex, + onValueChange = function(slider) + self.configIndex = slider.value + self:refreshUI() + end + }) + menuOptions[1] = ui.SliderMenuItem.create({ + labelText = "configuration", + slider = self.slider + }) + + -- 2. Key binding items for i, key in ipairs(consts.KEY_NAMES) do - local clickFunction = function() - if not self.settingKey then - self:setKeyStart(key) + local keyDisplayName = self:getKeyDisplayName(inputManager.inputConfigurations[self.configIndex][key]) + local keyBindingItem = KeyBindingMenuItem.create({ + keyName = key, + bindingText = keyDisplayName, + onActivate = function() + if not self.settingKey then + self:setKeyStart(key) + end end - end - local keyName = self:getKeyDisplayName(GAME.input.inputConfigurations[self.configIndex][key]) - menuOptions[#menuOptions + 1] = ui.MenuItem.createLabeledButtonMenuItem(key, nil, false, keyName, nil, false, clickFunction) + }) + menuOptions[#menuOptions + 1] = keyBindingItem end + + -- 3. Action buttons menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("op_all_keys", nil, nil, function() self:setAllKeysStart() end) menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("Clear All Inputs", nil, false, function() self:clearAllInputs() end) - menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("Reset Keys To Default", nil, false, function() self:resetToDefault(menuOptions) end) - menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) + menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("Reset Keys To Default", nil, false, function() self:resetToDefault() end) + + -- Back button with warning for incomplete configurations + self.backMenuItem = ui.MenuItem.createButtonMenuItem("back", nil, nil, self:createExitMenuFunction()) + menuOptions[#menuOptions + 1] = self.backMenuItem - self.menu = ui.Menu.createCenteredMenu(menuOptions) + self.menu = ui.Menu.createCenteredMenu(menuOptions, 0) + contentStack:addElement(self.menu) - self.uiRoot:addChild(self.menu) + self.uiRoot:addChild(contentStack) end function InputConfigMenu:update(dt) - self.backgroundImg:update(dt) - self.menu:receiveInputs() - local noKeysHeld = (tableUtils.first(input.allKeys.isPressed, function (value) + if self.backgroundImg then + self.backgroundImg:update(dt) + end + + -- Only allow menu navigation when not setting a key + if self.menu and not self.settingKey then + self.menu:receiveInputs() + end + + local noKeysHeld = (tableUtils.first(inputManager.allKeys.isPressed, function (value) return value < MAX_PRESS_DURATION end)) == nil @@ -210,6 +370,14 @@ function InputConfigMenu:update(dt) elseif self.settingKeyState == KEY_SETTING_STATE.SETTING_ALL_KEYS then self:setAllKeys() end + + self:refreshUI() +end + +function InputConfigMenu:refreshUI() + self.slider:refresh() + self.newControllerLabel.isVisible = self.newInputsConfigured + self:updateInputConfigMenuLabels() end function InputConfigMenu:draw() @@ -217,4 +385,4 @@ function InputConfigMenu:draw() self.uiRoot:draw() end -return InputConfigMenu \ No newline at end of file +return InputConfigMenu diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua new file mode 100644 index 00000000..a4731cf8 --- /dev/null +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -0,0 +1,78 @@ +local Scene = require("client.src.scenes.Scene") +local ui = require("client.src.ui") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") +local save = require("client.src.save") +local logger = require("common.lib.logger") + +local LanguageSelectSetup = class(function(self, sceneParams) + assert(sceneParams, "LanguageSelectSetup requires sceneParams") + assert(sceneParams.triggerNextScene, "LanguageSelectSetup requires triggerNextScene callback") + + self.music = "main" + self:load(sceneParams) +end, Scene) + +function LanguageSelectSetup:load(sceneParams) + -- Create a centered vertical stack panel for all content + local contentStack = ui.StackPanel({ + alignment = "top", + width = consts.CANVAS_WIDTH, + hAlign = "center", + vAlign = "center" + }) + self.uiRoot:addChild(contentStack) + + -- Title + local titleLabel = ui.Label({ + text = "Select your language", + translate = false, + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(titleLabel) + + -- Spacer for gap between title and menu + local spacer = ui.UiElement({ + width = 1, + height = 30 + }) + contentStack:addElement(spacer) + + -- Language selection menu + self.menu = self:createLanguageMenu() + contentStack:addElement(self.menu) +end + +LanguageSelectSetup.name = "LanguageSelectSetup" + +function LanguageSelectSetup:createLanguageMenu() + local languageMenuItems = {} + local languageData, languageLabels = Localization:getLanguageLabelsWithFonts() + + for i, language in ipairs(languageData) do + table.insert(languageMenuItems, ui.MenuItem.createButtonMenuItemWithLabel(languageLabels[i], function() + GAME.theme:playValidationSfx() + config.language_code = language.code + GAME:setLanguage(language.code) + write_conf_file() + self.triggerNextScene() + end)) + end + + local menu = ui.Menu.createCenteredMenu(languageMenuItems, 0, {supportsBackButton = false}) + return menu +end + +function LanguageSelectSetup:update(dt) + GAME.theme.images.bg_main:update(dt) + self.menu:receiveInputs() +end + +function LanguageSelectSetup:draw() + GAME.theme.images.bg_main:draw() + self.uiRoot:draw() +end + +return LanguageSelectSetup \ No newline at end of file diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index ffb9f72c..43e6ee87 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -13,7 +13,6 @@ local TrainingMenu = require("client.src.scenes.TrainingMenu") local ChallengeModeMenu = require("client.src.scenes.ChallengeModeMenu") local Lobby = require("client.src.scenes.Lobby") local LocalGameModeSelectionScene = require("client.src.scenes.LocalGameModeSelectionScene") -local CharacterSelect2p = require("client.src.scenes.CharacterSelect2p") local ReplayBrowser = require("client.src.scenes.ReplayBrowser") local InputConfigMenu = require("client.src.scenes.InputConfigMenu") local SetNameMenu = require("client.src.scenes.SetNameMenu") @@ -24,7 +23,6 @@ local system = require("client.src.system") local TimeAttackGame = require("client.src.scenes.TimeAttackGame") local EndlessGame = require("client.src.scenes.EndlessGame") local VsSelfGame = require("client.src.scenes.VsSelfGame") -local GameBase = require("client.src.scenes.GameBase") local PuzzleGame = require("client.src.scenes.PuzzleGame") -- Scene for the main menu @@ -160,7 +158,7 @@ end function MainMenu:updateSelf(dt) GAME.theme.images.bg_main:update(dt) - self.menu:receiveInputs() + self.menu:receiveInputs(GAME.input, dt) self:checkForUpdates() end diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index 7b7477f3..18696788 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -149,29 +149,23 @@ function OptionsMenu:loadInfoScreen(text) end function OptionsMenu:loadBaseMenu() - local languageNumber - local languageName = {} - for k, v in ipairs(Localization:get_list_codes()) do - languageName[#languageName + 1] = {v, Localization.data[v]["LANG"]} - if Localization:get_language() == v then - languageNumber = k - end - end - local languageLabels = {} - for k, v in ipairs(languageName) do - local lang = config.language_code - GAME:setLanguage(v[1]) - languageLabels[#languageLabels + 1] = ui.Label({text = v[2], translate = false}) - GAME:setLanguage(lang) + local languageData, languageLabels = Localization:getLanguageLabelsWithFonts() + local currentLanguageCode = Localization:getCurrentLanguageCode() + local languageIndex = Localization:getLanguageIndex(currentLanguageCode) + + local languageCodes = {} + for i, language in ipairs(languageData) do + languageCodes[#languageCodes + 1] = language.code end local languageStepper = ui.Stepper({ labels = languageLabels, - values = languageName, - selectedIndex = languageNumber, + values = languageCodes, + selectedIndex = languageIndex, onChange = function(value) GAME.theme:playMoveSfx() - GAME:setLanguage(value[1]) + config.language_code = value + GAME:setLanguage(value) self:updateMenuLanguage() end }) diff --git a/client/src/scenes/PuzzleMenu.lua b/client/src/scenes/PuzzleMenu.lua index b2fe595b..48f95dc0 100644 --- a/client/src/scenes/PuzzleMenu.lua +++ b/client/src/scenes/PuzzleMenu.lua @@ -1,4 +1,3 @@ -local Game = require("client.src.Game") local Scene = require("client.src.scenes.Scene") local consts = require("common.engine.consts") local logger = require("common.lib.logger") @@ -10,6 +9,7 @@ local PuzzleSetIterator = require("client.src.PuzzleSetIterator") local PuzzleHierarchyDisplay = require("client.src.graphics.PuzzleHierarchyDisplay") local PuzzleGame = require("client.src.scenes.PuzzleGame") local PuzzleEditorScene = require("client.src.scenes.PuzzleEditorScene") +local InputDeviceOverlay = require("client.src.scenes.components.InputDeviceOverlay") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local LevelPresets = require("common.data.LevelPresets") @@ -27,6 +27,7 @@ local Stack = require("common.engine.Stack") ---@field selectedIndexStack table stores selected menu index for each navigation level ---@field puzzlePreviewStack StackElement ---@field puzzleDescriptionLabel Label +---@field inputDeviceOverlay InputDeviceOverlay local PuzzleMenu = class( function (self, sceneParams) self.music = "select_screen" @@ -74,10 +75,12 @@ end function PuzzleMenu:startGame(puzzleSet, puzzleSetIterator) assert(puzzleSetIterator) - GAME.localPlayer:setLevel(config.puzzle_level) - GAME.localPlayer:setLevelData(LevelPresets.getModern(config.puzzle_level)) local player = self.battleRoom.players[1] + assert(player.inputConfiguration, "Player must have an input configuration assigned before starting puzzle game") + + GAME.localPlayer:setLevel(config.puzzle_level) + GAME.localPlayer:setLevelData(LevelPresets.getModern(config.puzzle_level)) -- Lock character and stage for the entire puzzle session -- This prevents them from changing between puzzles @@ -216,7 +219,25 @@ function PuzzleMenu:load(sceneParams) self.uiRoot:addChild(self.containerStackPanel) self.uiRoot:addChild(self.puzzleHierarchyDisplay) - + + self:createInputDeviceOverlay() +end + +function PuzzleMenu:createInputDeviceOverlay() + self.inputDeviceOverlay = InputDeviceOverlay({ + battleRoom = self.battleRoom, + onClose = function() + self:onInputDeviceOverlayClosed() + end, + onCancel = function() + self:exit() + end + }) + self.uiRoot:addChild(self.inputDeviceOverlay) +end + +function PuzzleMenu:onInputDeviceOverlayClosed() + -- Input device overlay closed, all assignments should be done end function PuzzleMenu:refreshMenu() @@ -675,8 +696,14 @@ function PuzzleMenu:updateCurrentPuzzleSet() end end -function PuzzleMenu:update(dt) - self.menu:receiveInputs() + +function PuzzleMenu:updateSelf(dt) + local overlayActive = self.inputDeviceOverlay and self.inputDeviceOverlay:isActive() + if overlayActive then + return + end + + self.menu:receiveInputs(GAME.input, dt) end function PuzzleMenu:draw() diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index 0270b7cc..bd64d66b 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -17,10 +17,12 @@ local DebugSettings = require("client.src.debug.DebugSettings") ---@field music sceneMusic ---@field fallbackMusic sceneMusic ---@field keepMusic boolean +---@field triggerNextScene function? ---@overload fun(sceneParams: table): Scene local Scene = class( ---@param self Scene function (self, sceneParams) + sceneParams = sceneParams or {} self.uiRoot = ui.UiElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) directsFocus(self.uiRoot) -- scenes may specify theme music to use that is played once they are switched to @@ -35,6 +37,9 @@ local Scene = class( -- the scene can alternatively specify it wants to keep the music that is currently playing -- if kept at false, the music will always change at scene switch self.keepMusic = false + -- callback provided by scene creator to trigger the next scene + -- scenes should call this when they complete their purpose + self.triggerNextScene = sceneParams.triggerNextScene end ) diff --git a/client/src/scenes/SceneCoordinator.lua b/client/src/scenes/SceneCoordinator.lua new file mode 100644 index 00000000..e0e94cc3 --- /dev/null +++ b/client/src/scenes/SceneCoordinator.lua @@ -0,0 +1,110 @@ +local logger = require("common.lib.logger") +local input = require("client.src.inputManager") +local TitleScreen = require("client.src.scenes.TitleScreen") +local MainMenu = require("client.src.scenes.MainMenu") +local ModLoader = require("client.src.mods.ModLoader") +local ModValidationScene = require("client.src.scenes.ModValidationScene") +local LanguageSelectSetup = require("client.src.scenes.LanguageSelectSetup") +local DiscordCommunitySetup = require("client.src.scenes.DiscordCommunitySetup") +local InputConfigMenu = require("client.src.scenes.InputConfigMenu") + +---@class SceneCoordinator +---@field joystickAdded boolean +local SceneCoordinator = { + startupComplete = false +} + +-- Called when an unconfigured joystick is added +-- Pushes InputConfigMenu if we're not in the middle of a game +function SceneCoordinator:onUnconfiguredJoystickAdded(joystick) + -- Check if we're in a game (BattleRoom exists and has an active match) + local inGame = false + if GAME.battleRoom and GAME.battleRoom.match ~= nil then + inGame = true + end + + if self.startupComplete and not inGame then + -- Not in a game, so push the InputConfigMenu + GAME.navigationStack:push(InputConfigMenu({})) + end +end + +-- Called when the StartUp scene completes asset loading +-- Begins the setup flow sequence +function SceneCoordinator:handleStartupComplete() + self.startupComplete = true + + if themes[config.theme].images.bg_title then + GAME.navigationStack:replace(TitleScreen({ + triggerNextScene = function() + self:handleTitleScreenComplete() + end + })) + else + self:handleTitleScreenComplete() + end + + if next(ModLoader.invalidMods) then + GAME.navigationStack:replace(ModValidationScene()) + end +end + +function SceneCoordinator:handleTitleScreenComplete() + self:continueSetupFlow() +end + +function SceneCoordinator:continueSetupFlow() + + -- Check language selection + if not config.language_code then + self:showLanguageSelect() + return true + end + + -- Check Discord community welcome + if not config.discordCommunityShown then + self:showDiscordWelcome() + return true + end + + -- Check for unconfigured joysticks + if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() then + self:showInputConfig() + return true + end + + GAME.navigationStack:replace(MainMenu({})) + return true +end + +-- Shows the language selection scene with completion callback +function SceneCoordinator:showLanguageSelect() + local scene = LanguageSelectSetup({ + triggerNextScene = function() + self:continueSetupFlow() + end + }) + GAME.navigationStack:replace(scene) +end + +-- Shows the Discord welcome scene with completion callback +function SceneCoordinator:showDiscordWelcome() + local scene = DiscordCommunitySetup({ + triggerNextScene = function() + self:continueSetupFlow() + end + }) + GAME.navigationStack:replace(scene) +end + +-- Shows the input configuration scene with completion callback +function SceneCoordinator:showInputConfig() + local scene = InputConfigMenu({ + triggerNextScene = function() + self:continueSetupFlow() + end + }) + GAME.navigationStack:replace(scene) +end + +return SceneCoordinator diff --git a/client/src/scenes/StartUp.lua b/client/src/scenes/StartUp.lua index ab135b3d..e345a20c 100644 --- a/client/src/scenes/StartUp.lua +++ b/client/src/scenes/StartUp.lua @@ -50,16 +50,9 @@ function StartUp:updateSelf(dt) if coroutine.status(self.setupRoutine) == "dead" then love.graphics.setFont(GraphicsUtil.getGlobalFont()) - -- we need the indirection for the scenes here because startup initializes localization which following scenes need - if themes[config.theme].images.bg_title then - GAME.navigationStack:replace(require("client.src.scenes.TitleScreen")()) - else - GAME.navigationStack:replace(require("client.src.scenes.MainMenu")()) - end - - if next(ModLoader.invalidMods) then - GAME.navigationStack:push(require("client.src.scenes.ModValidationScene")()) - end + -- Delegate to SceneCoordinator to handle initial scene and setup flow + local SceneCoordinator = require("client.src.scenes.SceneCoordinator") + SceneCoordinator.handleStartupComplete(SceneCoordinator) end end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index c846420e..41fc7338 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -103,6 +103,9 @@ function TimeAttackMenu:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) + self.ui.leaveButton = self:createLeaveButton() self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) diff --git a/client/src/scenes/TitleScreen.lua b/client/src/scenes/TitleScreen.lua index 05fc9897..93e37ace 100644 --- a/client/src/scenes/TitleScreen.lua +++ b/client/src/scenes/TitleScreen.lua @@ -4,7 +4,6 @@ local input = require("client.src.inputManager") local tableUtils = require("common.lib.tableUtils") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local MainMenu = require("client.src.scenes.MainMenu") -- The title screen scene local TitleScreen = class( @@ -30,7 +29,7 @@ function TitleScreen:update(dt) local keyPressed = tableUtils.trueForAny(input.allKeys.isDown, function(key) return key end) if love.mouse.isDown(1, 2, 3) or #love.touch.getTouches() > 0 or keyPressed then GAME.theme:playValidationSfx() - GAME.navigationStack:replace(MainMenu()) + self.triggerNextScene() end end diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua new file mode 100644 index 00000000..f718e990 --- /dev/null +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -0,0 +1,578 @@ +local class = require("common.lib.class") +local tableUtils = require("common.lib.tableUtils") +local UiElement = require("client.src.ui.UIElement") +local Label = require("client.src.ui.Label") +local TextButton = require("client.src.ui.TextButton") +local StackPanel = require("client.src.ui.StackPanel") +local PlayerInputDeviceSlot = require("client.src.scenes.components.PlayerInputDeviceSlot") +local inputManager = require("client.src.inputManager") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") +local logger = require("common.lib.logger") +local Scene = require("client.src.scenes.Scene") +local directsFocus = require("client.src.ui.FocusDirector") + +local HOLD_THRESHOLD = 0.25 +local AUTO_CLOSE_DELAY = 0.25 +local PLAYER_SLOT_SIZE = 150 + +-- Modal overlay that blocks game start until all local players have assigned input devices using hold-to-confirm interaction +---@class InputDeviceOverlay : UiElement +---@field battleRoom BattleRoom Reference to battle room for player/device management +---@field holdThreshold number Duration in seconds required to confirm assignment (default 0.25) +---@field active boolean True when overlay is open and processing input +---@field hasFocus boolean True when overlay has keyboard/input focus (managed by FocusDirector) +---@field playerSlots PlayerInputDeviceSlot[] Array of player slot UI elements +---@field deviceState table Tracks hold state per device +---@field touchTargetSlot PlayerInputDeviceSlot? Slot where touch started (locked for duration of touch) +---@field autoCloseTimer number Timer for auto-closing after all assignments complete +---@field escapeHoldTime number Duration escape key has been held +---@field onClose fun()? Callback invoked when overlay closes +---@field onCancel fun()? Callback invoked when user cancels overlay +---@field titleLabel Label Title text element +---@field subtitleLabel Label Subtitle text element +---@field slotsContainer StackPanel Container for player slots +---@field backButton TextButton Button to exit input configuration +---@field cancelHintLabel Label Hint text for escape key to cancel + +---@class InputDeviceOverlayOptions +---@field battleRoom BattleRoom +---@field holdThreshold number? +---@field onClose fun()? +---@field onCancel fun()? Callback when user presses back/cancel + +---@param options InputDeviceOverlayOptions +local InputDeviceOverlay = class(function(self, options) + options = options or {} + self.battleRoom = options.battleRoom + self.holdThreshold = options.holdThreshold or HOLD_THRESHOLD + self.onClose = options.onClose + self.onCancel = options.onCancel + + self.active = false + self.playerSlots = {} + self.deviceState = {} + self.touchTargetSlot = nil + self.autoCloseTimer = 0 + self.escapeHoldTime = 0 + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + self:setVisibility(false) + + directsFocus(self) + self:buildUi() +end, UiElement, "InputDeviceOverlay") + +---@param config InputConfiguration Input configuration object +---@return number? Maximum hold duration across all checked keys +local function getHoldDurationForInputConfiguration(config) + local maxDuration = 0 + + for _, alias in ipairs(consts.KEY_NAMES) do + local duration = config.isPressed[alias] + if duration and duration > maxDuration then + maxDuration = duration + end + end + + return maxDuration +end + + +-- Builds the main UI elements for the overlay +function InputDeviceOverlay:buildUi() + -- Title + self.titleLabel = Label({ + text = "press_button_device", + hAlign = "center", + vAlign = "top", + y = 60, + fontSize = GraphicsUtil.fontSize + 4 + }) + self:addChild(self.titleLabel) + + -- Subtitle + self.subtitleLabel = Label({ + text = "or_touch_player_slot", + hAlign = "center", + vAlign = "top", + y = 100, + fontSize = GraphicsUtil.fontSize + }) + self:addChild(self.subtitleLabel) + + -- Player slots container + self.slotsContainer = StackPanel({ + alignment = "left", + hAlign = "center", + vAlign = "center", + height = PLAYER_SLOT_SIZE + }) + self:addChild(self.slotsContainer) + + self.backButton = TextButton({ + label = Label({ + text = "back" + }), + hAlign = "center", + vAlign = "bottom", + y = -10, + onClick = function() + self:onBackPressed() + end + }) + self:addChild(self.backButton) +end + +---@return Player[] Array of local human players +function InputDeviceOverlay:getLocalPlayers() + assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") + return self.battleRoom:getLocalHumanPlayers() +end + +-- Gets the next player that needs device assignment +---@return Player? Next unassigned player or nil if all assigned +function InputDeviceOverlay:getNextUnassignedPlayer() + for _, player in ipairs(self:getLocalPlayers()) do + if not self.battleRoom:isPlayerAssigned(player) then + return player + end + end + return nil +end + +---@return PlayerInputDeviceSlot? Player slot under mouse cursor or nil +function InputDeviceOverlay:getPlayerSlotForTouch() + for _, slot in ipairs(self.playerSlots) do + if slot:isMouseOver() then + return slot + end + end + return nil +end + +function InputDeviceOverlay:buildPlayerSlots() + self.playerSlots = {} + while #self.slotsContainer.children > 0 do + self.slotsContainer:remove(self.slotsContainer.children[1]) + end + + local players = self:getLocalPlayers() + for i, player in ipairs(players) do + local slot = PlayerInputDeviceSlot({playerNumber = i, parentOverlay = self}) + self.playerSlots[i] = slot + self.slotsContainer:addElement(slot) + + -- Add spacing after each slot except the last + if i < #players then + local spacer = UiElement({ + width = 20, + height = PLAYER_SLOT_SIZE + }) + self.slotsContainer:addElement(spacer) + end + + -- Check if player is already assigned + local assignedDevice = self:getAssignedDeviceForPlayer(player) + if assignedDevice then + slot:setAssignedDevice(assignedDevice) + end + end +end + +---@param player Player +---@return InputConfiguration? Input configuration if player is assigned, nil otherwise +function InputDeviceOverlay:getAssignedDeviceForPlayer(player) + if not self.battleRoom or not player then + return nil + end + + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config then + local assignedPlayer = self.battleRoom:getPlayerAssignedToDevice(config) + if assignedPlayer == player then + return config + end + end + end + return nil +end + +function InputDeviceOverlay:updatePlayerSlots() + if not self.playerSlots then + return + end + + for i, slot in ipairs(self.playerSlots) do + if slot and slot.setAssignedDevice then + local players = self:getLocalPlayers() + if players then + local player = players[i] + if player then + local assignedDevice = self:getAssignedDeviceForPlayer(player) + slot:setAssignedDevice(assignedDevice) + end + end + end + end +end + + + + +-- Assigns a device to a player and plays feedback +---@param config InputConfiguration Input configuration to assign +---@param targetPlayer Player? Player to assign to, or nil to assign to next unassigned player +function InputDeviceOverlay:assignDevice(config, targetPlayer) + assert(config, "config is required") + assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") + + if not targetPlayer then + targetPlayer = self:getNextUnassignedPlayer() + end + + if not targetPlayer then + return + end + + local success = self.battleRoom:claimDeviceForPlayer(targetPlayer, config) + if success then + if GAME.theme and GAME.theme.playValidationSfx then + GAME.theme:playValidationSfx() + end + self:updatePlayerSlots() + + -- Trigger pop animation on the slot that was just assigned + local players = self:getLocalPlayers() + for i, player in ipairs(players) do + if player == targetPlayer and self.playerSlots[i] then + self.playerSlots[i]:triggerPopAnimation() + break + end + end + + if self.battleRoom:areLocalPlayersAssigned() then + self.autoCloseTimer = AUTO_CLOSE_DELAY + end + end +end + +-- Processes hold input for a configuration device +---@param config InputConfiguration Input configuration for controller/keyboard +---@param dt number Delta time in seconds +function InputDeviceOverlay:processConfigHold(config, dt) + assert(config, "Config is required") + assert(type(dt) == "number", "dt must be numeric") + + if config.claimed == true then + return + end + + local state = self.deviceState[config.id] + if not state then + state = {confirmTriggered = false, holdTime = 0} + self.deviceState[config.id] = state + end + + local confirmDuration = getHoldDurationForInputConfiguration(config) + if confirmDuration and confirmDuration > 0 then + state.holdTime = confirmDuration + else + state.holdTime = 0 + end + + -- Update visual feedback on all slots + local progress = math.min(state.holdTime / self.holdThreshold, 1) + for i, slot in ipairs(self.playerSlots) do + if not slot.assignedDevice and progress > 0 then + self.escapeHoldTime = 0 + slot:setHoldProgress(progress, config.deviceType) + break + end + end + + if state.holdTime >= self.holdThreshold and not state.confirmTriggered then + self:assignDevice(config) + state.confirmTriggered = true + elseif state.holdTime < self.holdThreshold then + state.confirmTriggered = false + end +end + + +-- Updates touch hold state and visual feedback +---@param dt number Delta time in seconds +function InputDeviceOverlay:updateTouchHold(dt) + local touchDescriptor = self:getTouchDescriptor() + if not touchDescriptor then + return + end + + local holding = self:isMouseHolding() + if holding then + self:processTouchHold(dt, touchDescriptor) + else + self:clearTouchTarget() + end +end + +-- Checks if mouse is currently being held down +---@return boolean True if mouse button 1 is held +function InputDeviceOverlay:isMouseHolding() + local mousePressed = inputManager.mouse.isPressed[1] + local mouseDown = inputManager.mouse.isDown[1] + return (type(mousePressed) == "number" and mousePressed > 0) or type(mouseDown) == "number" +end + +-- Checks if touch device is already assigned to any player +---@param touchConfig InputConfiguration Touch input configuration +---@return boolean True if touch is already assigned +function InputDeviceOverlay:isTouchAlreadyAssigned(touchConfig) + if not self.battleRoom then + return false + end + + local assignedPlayer = self.battleRoom:getPlayerAssignedToDevice(touchConfig) + return assignedPlayer ~= nil +end + +-- Processes touch hold logic when mouse is held down +---@param dt number Delta time in seconds +---@param touchConfig InputConfiguration Touch input configuration +function InputDeviceOverlay:processTouchHold(dt, touchConfig) + -- Don't allow claiming another slot if touch is already assigned + if self:isTouchAlreadyAssigned(touchConfig) then + self:clearTouchTarget() + return + end + + -- Lock to initial slot where touch started + if not self.touchTargetSlot then + local slotUnderMouse = self:getPlayerSlotForTouch() + if not slotUnderMouse then + self:clearTouchTarget() + return + end + self.touchTargetSlot = slotUnderMouse + self.touchTargetSlot:setTouchTarget(true) + end + + local targetSlot = self.touchTargetSlot + + local state = self.deviceState[touchConfig.id] + if not state then + state = {confirmTriggered = false, holdTime = 0} + self.deviceState[touchConfig.id] = state + end + + state.holdTime = state.holdTime + dt + self.escapeHoldTime = 0 + + local progress = math.min(state.holdTime / self.holdThreshold, 1) + targetSlot:setHoldProgress(progress, "touch") + + if state.holdTime >= self.holdThreshold and not state.confirmTriggered then + self:assignTouchToSlot(touchConfig, targetSlot) + state.confirmTriggered = true + elseif state.holdTime < self.holdThreshold then + state.confirmTriggered = false + end +end + + + +-- Assigns touch device to specific slot +---@param touchConfig InputConfiguration Touch input configuration +---@param targetSlot PlayerInputDeviceSlot Slot to assign touch to +function InputDeviceOverlay:assignTouchToSlot(touchConfig, targetSlot) + local players = self:getLocalPlayers() + for i, slot in ipairs(self.playerSlots) do + if slot == targetSlot then + local targetPlayer = players[i] + if targetPlayer then + self:assignDevice(touchConfig, targetPlayer) + end + break + end + end +end + +function InputDeviceOverlay:clearTouchTarget() + if self.touchTargetSlot then + self.touchTargetSlot:setTouchTarget(false) + self.touchTargetSlot:setHoldProgress(0, nil) + self.touchTargetSlot = nil + end + -- Clear touch device state + local touchConfig = self:getTouchDescriptor() + if touchConfig then + local state = self.deviceState[touchConfig.id] + if state then + state.holdTime = 0 + state.confirmTriggered = false + end + end +end + +---@return InputConfiguration? Touch input configuration or nil if not found +function InputDeviceOverlay:getTouchDescriptor() + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType == "touch" then + return config + end + end + return nil +end + +-- Checks if any button is currently being pressed on any device +---@return boolean True if any device has active input +function InputDeviceOverlay:isAnyButtonCurrentlyPressed() + -- Check if mouse is being held (for touch) + if self:isMouseHolding() then + return true + end + + if tableUtils.length(inputManager.allKeys.isPressed) > 0 then + return true + end + + return false +end + +---@param dt number Delta time in seconds +function InputDeviceOverlay:updateSelf(dt) + if not self.active then + if not self.battleRoom.spectating and GAME.input:checkForUnassignedConfigurationInputs(self.battleRoom) then + self.battleRoom:releaseAllLocalAssignments() + end + + self:openInputDeviceOverlayIfNeeded() + + return + end + + for _, slot in ipairs(self.playerSlots) do + if not slot.assignedDevice then + slot:setHoldProgress(0, nil) + end + end + + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType ~= "touch" then + self:processConfigHold(config, dt) + end + end + + self:updateTouchHold(dt) + + self:receiveInputs(GAME.input, dt) + + -- Handle auto-close timer + if self.autoCloseTimer > 0 and not self:isAnyButtonCurrentlyPressed() then + self.autoCloseTimer = self.autoCloseTimer - dt + if self.autoCloseTimer <= 0 then + self:close() + end + end +end + +function InputDeviceOverlay:openInputDeviceOverlayIfNeeded() + if self.active then + return + end + + local hasLocalPlayers = #self.battleRoom:getLocalHumanPlayers() > 0 + if not hasLocalPlayers then + return + end + + if not self.battleRoom.hasShutdown and not self.battleRoom:areLocalPlayersAssigned() then + self:open() + end +end + +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function InputDeviceOverlay:drawSelf() + if not self.active then + return + end + + local bgColor = GAME.theme.colors.darkTransparentBackgroundColor + GraphicsUtil.setColor(bgColor[1], bgColor[2], bgColor[3], bgColor[4]) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +function InputDeviceOverlay:open() + assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") + + self.deviceState = {} + self.touchTargetSlot = nil + self.autoCloseTimer = 0 + self.active = true + self:setVisibility(true) + + self:buildPlayerSlots() +end + +function InputDeviceOverlay:close() + if not self.active then + return + end + + self.active = false + self:setVisibility(false) + self:clearTouchTarget() + self.autoCloseTimer = 0 + self:setFocus(nil) + + if self.onClose then + self.onClose() + end +end + +---@return boolean True if overlay is currently active +function InputDeviceOverlay:isActive() + return self.active +end + +-- Handles back button press - closes overlay and invokes cancel callback +function InputDeviceOverlay:onBackPressed() + GAME.theme:playCancelSfx() + self:close() + if self.onCancel then + self.onCancel() + end +end + +---@return boolean? True to block touch event propagation +function InputDeviceOverlay:onTouch() + if self.active then + return true + end +end + +---@return boolean? True to block release event propagation +function InputDeviceOverlay:onRelease() + if self.active then + return true + end +end + +function InputDeviceOverlay:receiveInputs(input, dt) + + if input.isDown["MenuEsc"] then + self.escapeHoldTime = self.escapeHoldTime + dt + elseif input.isPressed["MenuEsc"] and self.escapeHoldTime > 0 then + self.escapeHoldTime = self.escapeHoldTime + dt + if self.escapeHoldTime >= self.holdThreshold then + self:onBackPressed() + self.escapeHoldTime = 0 + end + else + self.escapeHoldTime = 0 + end +end + +return InputDeviceOverlay diff --git a/client/src/scenes/components/PlayerInputDeviceSlot.lua b/client/src/scenes/components/PlayerInputDeviceSlot.lua new file mode 100644 index 00000000..c71f67e6 --- /dev/null +++ b/client/src/scenes/components/PlayerInputDeviceSlot.lua @@ -0,0 +1,252 @@ +local class = require("common.lib.class") +local UiElement = require("client.src.ui.UIElement") +local ImageContainer = require("client.src.ui.ImageContainer") +local inputManager = require("client.src.inputManager") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") + +local PLAYER_SLOT_SIZE = 150 +local DEVICE_ICON_SIZE = 64 + +-- Visual UI element representing one player's input device assignment status +---@class PlayerInputDeviceSlot : UiElement +---@field playerNumber number Visual player index (1, 2, etc.) +---@field assignedDevice InputConfiguration? Input configuration if assigned, nil otherwise +---@field holdProgress number Current hold progress from 0-1 +---@field pendingDeviceType string? Device type being held during assignment (keyboard/controller/touch) +---@field playerImage ImageContainer? Player number icon from theme +---@field deviceIcon UiElement? Device icon showing keyboard/controller/touch type +---@field isTargetedForTouch boolean True when mouse is hovering over this slot for touch assignment +---@field parentOverlay InputDeviceOverlay? Reference to parent overlay for accessing device configs +---@field popScale number Current scale for pop animation (1.0 = normal, >1.0 = enlarged) +---@field popAnimationSpeed number Speed of pop animation decay per second + +---@param options {playerNumber: number, parentOverlay: InputDeviceOverlay} +local PlayerInputDeviceSlot = class(function(self, options) + local playerNumber = options.playerNumber or options + self.playerNumber = playerNumber + self.assignedDevice = nil + self.holdProgress = 0 + self.pendingDeviceType = nil + self.isTargetedForTouch = false + self.parentOverlay = options.parentOverlay + self.popScale = 1.0 + self.maxPopScale = 1.12 + self.popAnimationSpeed = 1 + + -- Set size after parent initialization + self.width = PLAYER_SLOT_SIZE + self.height = PLAYER_SLOT_SIZE + + self:createPlayerNumberImage() +end, UiElement) + +function PlayerInputDeviceSlot:drawSelf() + love.graphics.push() + + -- Apply scale animation from center of slot + if self.popScale ~= 1.0 then + local centerX = self.x + self.width / 2 + local centerY = self.y + self.height / 2 + love.graphics.translate(centerX, centerY) + love.graphics.scale(self.popScale, self.popScale) + love.graphics.translate(-centerX, -centerY) + end + + self:drawSlotBackground(self) + self:drawSlotBorder(self) + + love.graphics.pop() +end + +-- Draws slot background with progress-based color transitions +---@param slot PlayerInputDeviceSlot +function PlayerInputDeviceSlot:drawSlotBackground(slot) + local progress = self.holdProgress or 0 + local bgColor = self:getBackgroundColor(progress) + GraphicsUtil.setColor(bgColor[1], bgColor[2], bgColor[3], bgColor[4]) + GraphicsUtil.drawRectangle("fill", self.x, self.y, slot.width, slot.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Draws slot border with progress-based color transitions +---@param slot PlayerInputDeviceSlot +function PlayerInputDeviceSlot:drawSlotBorder(slot) + local progress = self.holdProgress or 0 + local borderColor = self:getBorderColor(progress) + GraphicsUtil.setColor(borderColor[1], borderColor[2], borderColor[3], borderColor[4]) + GraphicsUtil.drawRectangle("line", self.x, self.y, slot.width, slot.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Gets background color based on assignment and progress +---@param progress number Hold progress from 0-1 +---@return table Color array {r, g, b, a} +function PlayerInputDeviceSlot:getBackgroundColor(progress) + if self.assignedDevice then + return GAME.theme.colors.activeBackgroundColor + else + -- Interpolate from inputSlotDefaultBackgroundColor to inputSlotSelectedBackgroundColor + local defaultColor = GAME.theme.colors.inputSlotDefaultBackgroundColor + local selectedColor = GAME.theme.colors.inputSlotSelectedBackgroundColor + return { + defaultColor[1] + (selectedColor[1] - defaultColor[1]) * progress, + defaultColor[2] + (selectedColor[2] - defaultColor[2]) * progress, + defaultColor[3] + (selectedColor[3] - defaultColor[3]) * progress, + defaultColor[4] + (selectedColor[4] - defaultColor[4]) * progress, + } + end +end + +-- Gets border color based on assignment and progress +---@param progress number Hold progress from 0-1 +---@return table Color array {r, g, b, a} +function PlayerInputDeviceSlot:getBorderColor(progress) + if self.assignedDevice then + return GAME.theme.colors.inputSlotSelectedBorderColor + else + -- Interpolate from inputSlotDefaultBorderColor to inputSlotSelectedBorderColor + local defaultColor = GAME.theme.colors.inputSlotDefaultBorderColor + local selectedColor = GAME.theme.colors.inputSlotSelectedBorderColor + return { + defaultColor[1] + (selectedColor[1] - defaultColor[1]) * progress, + defaultColor[2] + (selectedColor[2] - defaultColor[2]) * progress, + defaultColor[3] + (selectedColor[3] - defaultColor[3]) * progress, + defaultColor[4] + (selectedColor[4] - defaultColor[4]) * progress, + } + end +end + +-- Creates the player number image from theme +function PlayerInputDeviceSlot:createPlayerNumberImage() + local playerIcon = GAME.theme:getPlayerNumberIcon(self.playerNumber) + assert(playerIcon, string.format("Missing player %d icon in current theme", self.playerNumber)) + + self.playerImage = ImageContainer({ + image = playerIcon, + hAlign = "center", + vAlign = "top", + y = 14, + scale = 2 + }) + self:addChild(self.playerImage) +end + +-- Sets the assigned device for this player slot +---@param config InputConfiguration? Input configuration or nil +function PlayerInputDeviceSlot:setAssignedDevice(config) + self.assignedDevice = config +end + +-- Updates the device icon based on current assignment or hold progress +function PlayerInputDeviceSlot:updateDeviceIcon() + if self.deviceIcon then + self.deviceIcon:detach() + self.deviceIcon = nil + end + + -- Show icon for assigned device OR pending device during hold + local deviceType = nil + if self.assignedDevice and self.assignedDevice.deviceType then + deviceType = self.assignedDevice.deviceType + elseif self.pendingDeviceType and self.holdProgress > 0 then + deviceType = self.pendingDeviceType + end + + if deviceType then + local iconElement = UiElement({ + width = DEVICE_ICON_SIZE, + height = DEVICE_ICON_SIZE, + hAlign = "center", + vAlign = "center" + }) + + -- Intentional override + ---@diagnostic disable-next-line: duplicate-set-field + iconElement.drawSelf = function(icon) + -- Device icon transitions from grey to blue based on progress + local progress = self.holdProgress or 0 + local alpha + if self.assignedDevice then + -- Assigned device: full opacity + alpha = 1 + else + -- Pending device: grey to blue transition (fade in) + alpha = 0.4 + progress * 0.6 + end + + local centerX = icon.width / 2 + local centerY = icon.height / 2 + + -- Get pre-calculated controller image variant and device number from config + local controllerImageVariant = nil + local deviceNumber = nil + + if self.assignedDevice then + -- Use pre-calculated values from assigned device config + controllerImageVariant = self.assignedDevice.controllerImageVariant + deviceNumber = self.assignedDevice.deviceNumber + elseif self.pendingDeviceType and self.parentOverlay then + -- For pending devices, find the config being held to show specific controller icon + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType == self.pendingDeviceType then + local state = self.parentOverlay.deviceState[config.id] + if state and state.holdTime > 0 then + controllerImageVariant = config.controllerImageVariant + deviceNumber = config.deviceNumber + break + end + end + end + end + + -- Render the device icon with number if applicable + InputPromptRenderer.renderIconWithNumber(deviceType, centerX, centerY, DEVICE_ICON_SIZE, alpha, controllerImageVariant, deviceNumber) + end + + self.deviceIcon = iconElement + self:addChild(iconElement) + end +end + +-- Sets hold progress and pending device type for visual feedback +---@param progress number Hold progress from 0-1 +---@param pendingDeviceType string? Device type being held (keyboard/controller/touch) +function PlayerInputDeviceSlot:setHoldProgress(progress, pendingDeviceType) + self.holdProgress = math.max(0, math.min(1, progress)) + self.pendingDeviceType = pendingDeviceType +end + +---@param isTarget boolean True when mouse is hovering over this slot +function PlayerInputDeviceSlot:setTouchTarget(isTarget) + self.isTargetedForTouch = isTarget +end + +---@param dt number Delta time in seconds +function PlayerInputDeviceSlot:updateSelf(dt) + -- Update device icon if needed during each frame + self:updateDeviceIcon() + + -- Animate pop scale back to 1.0 + if self.popScale > 1.0 then + self.popScale = self.popScale - (self.popAnimationSpeed * dt) + if self.popScale < 1.0 then + self.popScale = 1.0 + end + end +end + +-- Triggers pop animation when assignment completes +function PlayerInputDeviceSlot:triggerPopAnimation() + self.popScale = self.maxPopScale +end + +-- Checks if mouse cursor is over this player slot +---@return boolean True if mouse is over this slot +function PlayerInputDeviceSlot:isMouseOver() + local mx, my = inputManager.mouse.x, inputManager.mouse.y + local x, y = self:getScreenPos() + return mx >= x and mx <= x + self.width and my >= y and my <= y + self.height +end + +return PlayerInputDeviceSlot diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua new file mode 100644 index 00000000..1da6f475 --- /dev/null +++ b/client/src/ui/ChangeInputButton.lua @@ -0,0 +1,207 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local Button = require(PATH .. ".Button") +local Label = require(PATH .. ".Label") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local inputManager = require("client.src.inputManager") +local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") +local StackPanel = require(PATH .. ".StackPanel") +local UiElement = require(PATH .. ".UIElement") + +---@class ChangeInputButtonOptions : ButtonOptions +---@field battleRoom BattleRoom? +---@field onChangeInputRequested fun()? + +-- Button that displays current player input assignments and allows changing them +---@class ChangeInputButton : Button +---@field battleRoom BattleRoom? Reference to battle room for querying player assignments +---@field onChangeInputRequested fun() Callback invoked when button is clicked to change inputs +---@field titleLabel Label Title text label +---@field iconContainer StackPanel Container for player assignment icons +---@field signalConnections table[] Array of signal subscriptions for live updates +local ChangeInputButton = class( + function(self, options) + options = options or {} + + self.battleRoom = options.battleRoom + self.onChangeInputRequested = options.onChangeInputRequested or function() end + self.signalConnections = {} + + local width = 80 + + self.titleLabel = Label({ + -- fontSize = 8, + wrapWidth = width, + text = "change_input_device", + }) + self.titleLabel.hAlign = "center" + + self.iconContainer = StackPanel({ + alignment = "top", + hAlign = "center", + vAlign = "center", + width = width + }) + + self:addChild(self.iconContainer) + + self.onClick = function(selfElement, inputSource, holdTime) + selfElement.onChangeInputRequested() + end + self.onSelect = self.onClick + + -- Subscribe to player signals and update initial state + self:subscribeToPlayerSignals() + self:updateSummary() + end, + Button +) + + +function ChangeInputButton:onResize() + -- Icon container has fixed width now +end + +function ChangeInputButton:updateSummary() + -- Clear existing player rows + while #self.iconContainer.children > 0 do + self.iconContainer:remove(self.iconContainer.children[1]) + end + + if not self.battleRoom then + self.isEnabled = true + return + end + + local players = self.battleRoom:getLocalHumanPlayers() + if #players == 0 then + self.isEnabled = true + return + end + + self.iconContainer:addElement(self.titleLabel) + + local spacer = UiElement({ + width = 1, + height = 4 + }) + self.iconContainer:addElement(spacer) + + -- Create a row for each player + for i, player in ipairs(players) do + self:addPlayerRow(player, i) + + -- Add spacing between player rows (except after last) + if i < #players then + spacer = UiElement({ + width = 1, + height = 4 + }) + self.iconContainer:addElement(spacer) + end + end + + self.isEnabled = true +end + +local iconSize = 20 + +---@param player Player +---@param playerIndex number +function ChangeInputButton:addPlayerRow(player, playerIndex) + -- Create horizontal StackPanel for this player's row + local playerRow = StackPanel({ + alignment = "left", + height = iconSize, + hAlign = "center" + }) + + self:addPlayerIcons(playerRow, player, playerIndex) + self.iconContainer:addElement(playerRow) +end + +---@param playerRow StackPanel +---@param player Player +---@param playerIndex number +function ChangeInputButton:addPlayerIcons(playerRow, player, playerIndex) + + -- Add player number icon (P1, P2, etc.) + local playerIcon = UiElement({ + x = 0, + y = 2, + width = iconSize, + height = iconSize + }) + playerIcon.drawSelf = function(elementSelf) + if GAME.theme then + local playerNumberIcon = GAME.theme:getPlayerNumberIcon(playerIndex) + if playerNumberIcon then + local scale = iconSize / math.max(playerNumberIcon:getWidth(), playerNumberIcon:getHeight()) + love.graphics.draw(playerNumberIcon, elementSelf.x, elementSelf.y, 0, scale, scale) + end + end + end + playerRow:addElement(playerIcon) + + -- Add device type icon and index + if player.inputConfiguration then + -- Small spacing between player icon and device icon + local smallSpacer = UiElement({ + width = 4, + height = iconSize + }) + playerRow:addElement(smallSpacer) + + -- Device icon + local deviceIcon = UiElement({ + width = iconSize, + height = iconSize + }) + deviceIcon.drawSelf = function(elementSelf) + InputPromptRenderer.renderIconWithNumber( + player.inputConfiguration.deviceType, + elementSelf.x + iconSize/2, + elementSelf.y + iconSize/2, + iconSize, + 1, + player.inputConfiguration.controllerImageVariant, + player.inputConfiguration.deviceNumber + ) + end + playerRow:addElement(deviceIcon) + end +end + +---@param battleRoom BattleRoom +function ChangeInputButton:setBattleRoom(battleRoom) + self:unsubscribeFromPlayerSignals() + self.battleRoom = battleRoom + self:subscribeToPlayerSignals() + self:updateSummary() +end + +function ChangeInputButton:subscribeToPlayerSignals() + if not self.battleRoom then + return + end + + local players = self.battleRoom:getLocalHumanPlayers() + for _, player in ipairs(players) do + local connection = player:connectSignal("inputConfigurationChanged", self, self.onInputConfigurationChanged) + self.signalConnections[#self.signalConnections + 1] = {player = player, connection = connection} + end +end + +function ChangeInputButton:unsubscribeFromPlayerSignals() + for _, connectionInfo in ipairs(self.signalConnections) do + connectionInfo.player:disconnectSignal("inputConfigurationChanged", connectionInfo.connection) + end + self.signalConnections = {} +end + +function ChangeInputButton:onInputConfigurationChanged() + self:updateSummary() +end + + +return ChangeInputButton \ No newline at end of file diff --git a/client/src/ui/DiscreteImageSlider.lua b/client/src/ui/DiscreteImageSlider.lua new file mode 100644 index 00000000..0d400b74 --- /dev/null +++ b/client/src/ui/DiscreteImageSlider.lua @@ -0,0 +1,304 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local Slider = require(PATH .. ".Slider") +local StackPanel = require(PATH .. ".StackPanel") +local ImageContainer = require(PATH .. ".ImageContainer") +local UIElement = require(PATH .. ".UIElement") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +---@class DiscreteValue +---@field id any Unique identifier for this value +---@field image love.Texture Image to display for this value +---@field selectedImage love.Texture? Optional image to display when selected (defaults to image) +---@field scale number? Scale factor for the image (default: 1) + +---@class DiscreteImageSliderOptions : UiElementOptions +---@field values DiscreteValue[]? Array of discrete values to display +---@field itemSpacing number? Spacing between items in pixels (default: 0) +---@field selectedValue any? Initially selected value ID +---@field onValueChange fun(slider:DiscreteImageSlider)? Callback when value changes + +---@class DiscreteImageSlider: Slider +---@field values DiscreteValue[] Array of discrete values +---@field itemSpacing number Spacing between items +---@field stackPanel StackPanel Layout container for items +---@field valueIdToIndex table Map from value ID to array index +---@overload fun(options: DiscreteImageSliderOptions): DiscreteImageSlider +local DiscreteImageSlider = class( + function(self, options) + self.values = options.values or {} + self.itemSpacing = options.itemSpacing or 0 + self.onValueChange = options.onValueChange or function() end + self.onlyChangeOnRelease = options.onlyChangeOnRelease or false + + -- Create StackPanel for layout (as a child) + self.stackPanel = StackPanel({ + alignment = "left", + hAlign = "left", + vAlign = "top" + }) + + -- Build index map, populate StackPanel, and calculate dimensions + self:rebuildLayout() + + -- Add StackPanel as a child so it draws automatically + self:addChild(self.stackPanel) + + -- Set initial value + local initialValue = options.selectedValue + if initialValue then + self.value = self:getIndexForId(initialValue) or 1 + else + self.value = 1 + end + + -- Update image selection for initial state + self:updateImageSelection() + + -- Set tickAmount for parent Slider compatibility + self.tickAmount = 1 + end, + Slider +) +DiscreteImageSlider.TYPE = "DiscreteImageSlider" + +---@param id any Value identifier +---@return number? index Array index for this ID, or nil if not found +function DiscreteImageSlider:getIndexForId(id) + return self.valueIdToIndex[id] +end + +---@param index number Array index +---@return any? id Value identifier at this index, or nil if out of bounds +function DiscreteImageSlider:getIdForIndex(index) + if index >= 1 and index <= #self.values then + return self.values[index].id + end + return nil +end + +---@return any? id Currently selected value ID +function DiscreteImageSlider:getSelectedId() + return self:getIdForIndex(self.value) +end + +---@param id any Value identifier to select +---@param committed boolean Whether to trigger onValueChange callback +function DiscreteImageSlider:setSelectedId(id, committed) + local index = self:getIndexForId(id) + if index then + self:setValue(index, committed) + end +end + +function DiscreteImageSlider:setValue(newValue, committed) + local oldValue = self.value + + -- Call parent to handle value change and callbacks + Slider.setValue(self, newValue, committed) + + -- Update images if value actually changed + if oldValue ~= self.value then + self:updateImageSelection() + end +end + +function DiscreteImageSlider:updateImageSelection() + for i, imageContainer in ipairs(self.imageContainers) do + local value = imageContainer.discreteValue + local isSelected = (i == self.value) + local newImage = (isSelected and value.selectedImage) or value.image + + if imageContainer.image ~= newImage then + imageContainer:setImage(newImage, nil, nil, value.scale or 1) + end + end +end + +function DiscreteImageSlider:rebuildLayout() + -- Clear existing layout + while #self.stackPanel.children > 0 do + self.stackPanel:remove(self.stackPanel.children[1]) + end + + -- Rebuild index map + self.valueIdToIndex = {} + for i, value in ipairs(self.values) do + self.valueIdToIndex[value.id] = i + end + + -- Store references to ImageContainers for updating selection state + self.imageContainers = {} + + -- Populate StackPanel with ImageContainers for each value + for i, value in ipairs(self.values) do + local scale = value.scale or 1 + local image = value.image + + local imageContainer = ImageContainer({ + image = image, + scale = scale, + hAlign = "left", + vAlign = "top" + }) + + -- Store reference for tracking selection and value + imageContainer.discreteIndex = i + imageContainer.discreteValue = value + self.imageContainers[i] = imageContainer + + self.stackPanel:addElement(imageContainer) + + -- Add spacing after each item except the last + if i < #self.values and self.itemSpacing > 0 then + local spacer = UIElement({ + width = self.itemSpacing, + height = imageContainer.height, + hAlign = "left", + vAlign = "top" + }) + self.stackPanel:addElement(spacer) + end + end + + -- Update dimensions + self.width = self.stackPanel.width + + -- Calculate max height from StackPanel children (the image containers) + local stackPanelMaxHeight = 0 + for _, child in ipairs(self.stackPanel.children) do + if child.height > stackPanelMaxHeight then + stackPanelMaxHeight = child.height + end + end + self.stackPanel.height = stackPanelMaxHeight + + -- Calculate total height including all direct children (e.g., labels in subclasses) + local totalHeight = stackPanelMaxHeight + for _, child in ipairs(self.children) do + if child ~= self.stackPanel then + -- For children positioned below stackPanel (with positive y offset) + local childBottomEdge = child.y + child.height + if childBottomEdge > totalHeight then + totalHeight = childBottomEdge + end + end + end + self.height = totalHeight + + self.min = 1 + self.max = math.max(1, #self.values) +end + +---@param newValues DiscreteValue[] New array of discrete values +function DiscreteImageSlider:setValues(newValues) + self.values = newValues + local oldValue = self.value + self:rebuildLayout() + + -- Try to maintain selection if possible + local newValue + if oldValue > #self.values then + newValue = math.max(1, #self.values) + else + newValue = oldValue + end + + self:setValue(newValue, false) +end + +---@param x number Screen x coordinate +---@return number index Value index for this position +function DiscreteImageSlider:getValueForPos(x) + if #self.values == 0 then + return 1 + end + + local screenX, screenY = self:getScreenPos() + local relativeX = x - screenX + + -- Find which item was clicked based on StackPanel children positions + local bestIndex = 1 + local bestDistance = math.huge + + for i, child in ipairs(self.stackPanel.children) do + if child.discreteIndex then + local itemScreenX = screenX + child.x + local itemCenterX = itemScreenX + child.width / 2 + local distance = math.abs(relativeX - (child.x + child.width / 2)) + + if distance < bestDistance then + bestDistance = distance + bestIndex = child.discreteIndex + end + end + end + + return bestIndex +end + +---@return number x X position of current value's center +function DiscreteImageSlider:getCurrentXForValue() + if self.value < 1 or self.value > #self.values then + return self.x + end + + -- Find the UI element for this value index + for _, child in ipairs(self.stackPanel.children) do + if child.discreteIndex == self.value then + return self.x + child.x + child.width / 2 + end + end + + return self.x +end + +-- Gets the rectangle of the currently selected item (for SliderMenuItem highlighting) +---@return {x: number, y: number, width: number, height: number}? +function DiscreteImageSlider:getSelectedItemRect() + if self.value < 1 or self.value > #self.values then + return nil + end + + local selectedContainer = self.imageContainers[self.value] + if not selectedContainer then + return nil + end + + return { + x = selectedContainer.x, + y = selectedContainer.y, + width = selectedContainer.width, + height = selectedContainer.height + } +end + +function DiscreteImageSlider:drawSelf() + -- Draw simple static border around the currently selected item + if self.value < 1 or self.value > #self.values then + return + end + + -- Find the selected image container + local selectedContainer = self.imageContainers[self.value] + if not selectedContainer then + return + end + + -- Draw static border + local borderColor = GAME.theme.colors.menuDefaultBorderColor + + GraphicsUtil.setColor(borderColor[1], borderColor[2], borderColor[3], 0.8) + GraphicsUtil.drawRectangle( + "line", + self.x + selectedContainer.x, + self.y + selectedContainer.y, + selectedContainer.width, + selectedContainer.height + ) + + -- Reset color + GraphicsUtil.setColor(1, 1, 1, 1) +end + +return DiscreteImageSlider diff --git a/client/src/ui/InputConfigSlider.lua b/client/src/ui/InputConfigSlider.lua new file mode 100644 index 00000000..237b2523 --- /dev/null +++ b/client/src/ui/InputConfigSlider.lua @@ -0,0 +1,164 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local DiscreteImageSlider = require(PATH .. ".DiscreteImageSlider") +local Label = require(PATH .. ".Label") +local ImageContainer = require(PATH .. ".ImageContainer") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") + +---@class InputConfigSliderOptions : DiscreteImageSliderOptions +---@field onValueChange fun(slider:InputConfigSlider)? callback for whenever the value is changed + +-- A visual slider for input configuration selection showing controller/keyboard icons +---@class InputConfigSlider: DiscreteImageSlider +---@field deviceLabel Label Label showing selected device name +---@field iconSize number Size of device icons +---@overload fun(options: InputConfigSliderOptions): InputConfigSlider +local InputConfigSlider = class( +---@param self InputConfigSlider +---@param options InputConfigSliderOptions + function(self, options) + -- Get controller image width to calculate icon size + local controllerImage = GAME.theme:getInputPromptIcon("controller") + local imageWidth = controllerImage and controllerImage:getWidth() or 128 + local iconSize = imageWidth / 2 + + -- Store iconSize for later use + self.iconSize = iconSize + + self.itemSpacing = 2 + self.selectedValue = options.selectedValue or 1 + + -- Create label for device name + local config = GAME.input.inputConfigurations[options.selectedValue or 1] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel = Label({ + text = labelText, + translate = false, + hAlign = "center", + y = iconSize + 6 + }) + + -- Add label as child + self:addChild(self.deviceLabel) + + self:refresh() + end, + DiscreteImageSlider +) +InputConfigSlider.TYPE = "InputConfigSlider" + +-- Checks if a configuration slot has any key bindings +---@param configIndex number Configuration slot index +---@return boolean +function InputConfigSlider:hasBindings(configIndex) + local config = GAME.input.inputConfigurations[configIndex] + if not config then + return false + end + + return not config:isEmpty() +end + + +-- Finds the first empty configuration slot +---@return number? index of first empty slot or nil if all full +function InputConfigSlider:findNextAvailableSlot() + for i = 1, input.maxConfigurations do + if not self:hasBindings(i) then + return i + end + end + return nil +end + +-- Builds DiscreteValue array from current input configurations +---@return DiscreteValue[] values Array of values for DiscreteImageSlider +function InputConfigSlider:buildValuesArray() + local values = {} + local iconScale = self.iconSize / 128 -- Controller images are 128x128 + local nextAvailableSlot = self:findNextAvailableSlot() + + for i = 1, #input.inputConfigurations do + if self:hasBindings(i) then + -- Get device-specific icon + local config = GAME.input.inputConfigurations[i] + if config and config.deviceType and config.deviceType ~= "touch" then + local icon = GAME.theme:getSpecificInputIcon(config.deviceType, config.controllerImageVariant) + if icon then + values[#values + 1] = { + id = i, + image = icon, + scale = iconScale + } + end + end + elseif i == nextAvailableSlot then + -- Show "+" icon for first empty slot + local plusIcon = GAME.theme:getInputPromptIcon("controller_add") + if plusIcon then + values[#values + 1] = { + id = i, + image = plusIcon, + scale = iconScale + } + end + end + end + + return values +end + +-- Refreshes the slider visual (call when configs change) +function InputConfigSlider:refresh() + local newValues = self:buildValuesArray() + self:setValues(newValues) + + -- Update label text + local config = GAME.input.inputConfigurations[self.value] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel:setText(labelText) +end + +function InputConfigSlider:setValue(newValue, committed) + -- Call parent to handle value change + DiscreteImageSlider.setValue(self, newValue, committed) + + -- Update label text when selection changes + local config = GAME.input.inputConfigurations[self.value] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel:setText(labelText) +end + +function InputConfigSlider:rebuildLayout() + -- Call parent to rebuild layout + DiscreteImageSlider.rebuildLayout(self) + + -- Add error indicators to incomplete configurations + for _, imageContainer in ipairs(self.imageContainers) do + local valueId = imageContainer.discreteValue.id + local config = GAME.input.inputConfigurations[valueId] + + -- Check if configuration is incomplete (has bindings but not all keys) + if config and not config:isEmpty() and not config:isFullyConfigured() then + -- Get error indicator icon + local errorIcon = GAME.theme:getInputPromptIcon("controller_error") + if errorIcon then + -- Create error indicator as child with red tint + local errorIndicator = ImageContainer({ + image = errorIcon, + scale = 0.18 + }) + + -- Position in bottom-right corner of the device icon + errorIndicator.x = imageContainer.width / 2 - (errorIndicator.width / 2) + errorIndicator.y = imageContainer.height - (errorIndicator.height) - 2 + + -- Add as child of the image container + imageContainer:addChild(errorIndicator) + end + end + end +end + +return InputConfigSlider diff --git a/client/src/ui/KeyBindingMenuItem.lua b/client/src/ui/KeyBindingMenuItem.lua new file mode 100644 index 00000000..4ec4559f --- /dev/null +++ b/client/src/ui/KeyBindingMenuItem.lua @@ -0,0 +1,125 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local MenuItem = require(PATH .. ".MenuItem") +local Label = require(PATH .. ".Label") +local TextButton = require(PATH .. ".TextButton") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local class = require("common.lib.class") +local logger = require("common.lib.logger") + +---@class KeyBindingMenuItem : MenuItem +local KeyBindingMenuItem = class(function(self, options) + self.selected = false + self.settingKey = false + self.TYPE = "KeyBindingMenuItem" + self.keyName = options and options.keyName or nil + self.onActivate = options and options.onActivate or nil + self.x = 0 + self.y = 0 +end, MenuItem) + +--- Creates a KeyBindingMenuItem with key name label and binding button +---@param options table Options table with keyName, bindingText, onActivate +---@return KeyBindingMenuItem +function KeyBindingMenuItem.create(options) + assert(options.keyName ~= nil) + assert(options.bindingText ~= nil) + + local BUTTON_WIDTH = 120 + local SPACE_BETWEEN = 16 + + local menuItem = KeyBindingMenuItem(options) + + -- Create key name label (left side) + local keyLabel = Label({ + text = string.lower(options.keyName), + vAlign = "center", + fontSize = 12, + width = 96 + }) + + -- Create binding button (right side) + local bindingButton = TextButton({ + label = Label({ + text = options.bindingText, + translate = false, + hAlign = "center", + vAlign = "center", + fontSize = 12 + }), + onClick = options.onActivate, + width = BUTTON_WIDTH + }) + bindingButton.x = keyLabel.width + SPACE_BETWEEN + bindingButton.vAlign = "center" + + -- Store references + menuItem.keyLabel = keyLabel + menuItem.bindingButton = bindingButton + + -- Calculate dimensions + menuItem.width = keyLabel.width + MenuItem.PADDING + bindingButton.width + menuItem.height = math.max(keyLabel.height, bindingButton.height) + + -- Add children + menuItem:addChild(keyLabel) + menuItem:addChild(bindingButton) + + return menuItem +end + +--- Sets the binding text displayed on the button +---@param text string The binding text to display +function KeyBindingMenuItem:setBinding(text) + if self.bindingButton and self.bindingButton.label then + self.bindingButton.label:setText(text, nil, false) + end +end + +--- Sets whether this item is currently setting a key +---@param setting boolean True if actively setting a key +function KeyBindingMenuItem:setSettingKey(setting) + self.settingKey = setting +end + +function KeyBindingMenuItem:drawSelf() + local baseOpacity = 0.15 + + -- Draw subtle glow on button area + local buttonX = self.x + self.bindingButton.x + local buttonY = self.y + self.bindingButton.y + if self.settingKey then + -- Active key setting: strong glow on button area with pulse (regardless of selection state) + local selectedAdditionalOpacity = 0.5 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + elseif self.selected then + -- Normal selection: subtle pulse on button area + local selectedAdditionalOpacity = 0.1 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + else + -- no special drawing for base case for now, just the elements + end +end + +function KeyBindingMenuItem:receiveInputs(inputs) + if self.bindingButton then + self.bindingButton:receiveInputs(inputs) + end +end + +return KeyBindingMenuItem diff --git a/client/src/ui/Label.lua b/client/src/ui/Label.lua index 31d6383b..7cc77d49 100644 --- a/client/src/ui/Label.lua +++ b/client/src/ui/Label.lua @@ -27,7 +27,8 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@field strokeColors table? List of red, green, blue, and alpha color for stroke, no stroke if nil ---@field textColor table? List of red, green, blue, and alpha color for text ---@field drawable love.TextBatch Cached love.TextBatch for redrawing ----@field autoSizeToText boolean true if the text should change the width and height +---@field autoSizeWidth boolean true if the text should change the width +---@field autoSizeHeight boolean true if the text should change the height ---@field paddingTop number Top padding in pixels ---@field paddingRight number Right padding in pixels ---@field paddingBottom number Bottom padding in pixels @@ -38,7 +39,8 @@ local Label = class( self.hAlign = options.hAlign or "left" self.vAlign = options.vAlign or "top" self.hFill = options.hFill or false - self.autoSizeToText = (self.width == 0 or self.height == 0) + self.autoSizeWidth = self.width == 0 + self.autoSizeHeight = self.height == 0 self.wrapWidth = options.wrapWidth or nil self.fontSize = options.fontSize or GraphicsUtil.fontSize local padding = options.padding or 0 @@ -151,8 +153,11 @@ function Label:refreshFormatting() self.drawable:set(text) end - if self.autoSizeToText then + if self.autoSizeWidth then self.width = self.drawable:getWidth() + self.paddingLeft + self.paddingRight + end + + if self.autoSizeHeight then self.height = self.drawable:getHeight() + self.paddingTop + self.paddingBottom end end diff --git a/client/src/ui/Menu.lua b/client/src/ui/Menu.lua index 0bc6fbab..1b46b178 100644 --- a/client/src/ui/Menu.lua +++ b/client/src/ui/Menu.lua @@ -24,6 +24,11 @@ local Menu = class( self.totalHeight = 0 self.menuItemYOffsets = {} self.allContentShowing = true + self.sizeToFit = options.height == 0 + self.supportsBackButton = true + if options.supportsBackButton ~= nil and options.supportsBackButton == false then + self.supportsBackButton = false + end self.upIndicator = Label({text = "^", translate = false, isVisible = false, vAlign = "top", hAlign = "center", y = -14}) self.downIndicator = Label({text = "v", translate = false, isVisible = false, vAlign = "bottom", hAlign = "center"}) @@ -31,7 +36,7 @@ local Menu = class( self:addChild(self.downIndicator) -- bogus this should be passed in? - self.centerVertically = themes[config.theme].centerMenusVertically + self.centerVertically = themes[config.theme].centerMenusVertically and not self.sizeToFit self.yOffset = 0 self.firstActiveIndex = 1 @@ -46,16 +51,14 @@ Menu.NAVIGATION_BUTTON_WIDTH = NAVIGATION_BUTTON_WIDTH Menu.BUTTON_HORIZONTAL_PADDING = 0 Menu.BUTTON_VERTICAL_PADDING = 8 -function Menu.createCenteredMenu(items) - local menu = Menu({ - x = 0, - y = 0, - hAlign = "center", - vAlign = "center", - menuItems = items, - height = themes[config.theme].main_menu_max_height - }) +function Menu.createCenteredMenu(items, height, options) + options = options or {} + options.hAlign = "center" + options.vAlign = "center" + options.menuItems = items + options.height = height or themes[config.theme].main_menu_max_height + local menu = Menu(options) return menu end @@ -93,6 +96,17 @@ function Menu:layout() return end + -- If sizeToFit is enabled, recalculate height from content + if self.sizeToFit then + self.height = 0 + for i, menuItem in ipairs(self.menuItems) do + self.height = self.height + menuItem.height + if i < #self.menuItems then + self.height = self.height + Menu.BUTTON_VERTICAL_PADDING + end + end + end + local currentY = 0 local totalMenuHeight = 0 local menuFull = false @@ -104,7 +118,7 @@ function Menu:layout() self.upIndicator:setVisibility(true) end if menuFull == false and realY >= 0 then - if realY + menuItem.height < self.height then + if realY + menuItem.height <= self.height then if self.firstActiveIndex == nil then self.firstActiveIndex = i end @@ -117,18 +131,24 @@ function Menu:layout() menuFull = true end end - currentY = currentY + menuItem.height + Menu.BUTTON_VERTICAL_PADDING + currentY = currentY + menuItem.height + if i < #self.menuItems then + currentY = currentY + Menu.BUTTON_VERTICAL_PADDING + end if menuFull == false then self.lastActiveIndex = i totalMenuHeight = realY + menuItem.height end self.width = math.max(self.width, menuItem.width) - self.totalHeight = self.totalHeight + menuItem.height + Menu.BUTTON_VERTICAL_PADDING + self.totalHeight = self.totalHeight + menuItem.height + if i < #self.menuItems then + self.totalHeight = self.totalHeight + Menu.BUTTON_VERTICAL_PADDING + end end if self.centerVertically then self.y = self.yMin + (self.height / 2) - (totalMenuHeight / 2) - else + elseif not self.sizeToFit then self.y = self.yMin end end @@ -243,11 +263,13 @@ function Menu:receiveInputs(inputs, dt) if self.focused then self.focused:receiveInputs(inputs, dt) elseif inputs.isDown["MenuEsc"] then - if self.selectedIndex ~= #self.menuItems then - self:setSelectedIndex(#self.menuItems) - GAME.theme:playCancelSfx() - else - selectedElement:receiveInputs(inputs, dt) + if self.supportsBackButton then + if self.selectedIndex ~= #self.menuItems then + self:setSelectedIndex(#self.menuItems) + GAME.theme:playCancelSfx() + else + selectedElement:receiveInputs(inputs, dt) + end end elseif inputs:isPressedWithRepeat("MenuUp") then self:scrollUp() diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 400192c1..e8649425 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -7,8 +7,18 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local system = require("client.src.system") local DebugSettings = require("client.src.debug.DebugSettings") --- MenuItem is a specific UIElement that all children of Menu should be -local MenuItem = class(function(self, options) +---@class MenuItem : UiElement +---@field selected boolean whether this menu item is currently selected +---@field TYPE string type identifier for this class +---@field textButton TextButton? optional reference to a text button if this item contains one +---@field onSelectedFunction function? callback function to execute when the item is selected + +---@class MenuItemOptions : UiElementOptions + +---@class MenuItem +---@overload fun(options: MenuItemOptions): MenuItem +local MenuItem = class( + function(self, options) self.selected = false self.TYPE = "MenuItem" end, @@ -16,8 +26,10 @@ UiElement) MenuItem.PADDING = 2 --- Takes a label and an optional extra element and makes and combines them into a menu item --- which is suitable for inserting into a menu +---Takes a label and an optional extra element and makes and combines them into a menu item which is suitable for inserting into a menu +---@param label UiElement the label or left element to display +---@param item UiElement? optional right element to display +---@return MenuItem function MenuItem.createMenuItem(label, item) assert(label ~= nil) @@ -51,22 +63,21 @@ function MenuItem.createMenuItem(label, item) return menuItem end --- Creates a menu item with just a button -function MenuItem.createButtonMenuItem(text, replacements, translate, onClick, width) - assert(text ~= nil) +---Creates a menu item with just a button, using a pre-created Label +---@param label Label the label to use for the button +---@param onClick function callback function when the button is clicked +---@param width number? optional width for the button (defaults to 140) +---@return MenuItem +function MenuItem.createButtonMenuItemWithLabel(label, onClick, width) + assert(label ~= nil) local BUTTON_WIDTH = width or 140 - if translate == nil then - translate = true - end + label.hAlign = "center" + label.vAlign = "center" + local textButton = TextButton({ - label = Label({ - text = text, - replacements = replacements, - translate = translate, - hAlign = "center", - vAlign = "center" - }), - onClick = onClick, width = BUTTON_WIDTH + label = label, + onClick = onClick, + width = BUTTON_WIDTH }) local menuItem = MenuItem.createMenuItem(textButton) @@ -75,7 +86,38 @@ function MenuItem.createButtonMenuItem(text, replacements, translate, onClick, w return menuItem end --- Creates a menu item with a label followed by a button +---Creates a menu item with just a button +---@param text string the text to display on the button +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param onClick function callback function when the button is clicked +---@param width number? optional width for the button (defaults to 140) +---@return MenuItem +function MenuItem.createButtonMenuItem(text, replacements, translate, onClick, width) + assert(text ~= nil) + if translate == nil then + translate = true + end + + local label = Label({ + text = text, + replacements = replacements, + translate = translate + }) + + return MenuItem.createButtonMenuItemWithLabel(label, onClick, width) +end + +---Creates a menu item with a label followed by a button +---@param labelText string the text for the left label +---@param labelTextReplacements table? optional text replacements for label localization +---@param labelTextTranslate boolean? whether to translate the label text (defaults to true) +---@param buttonText string the text for the button +---@param buttonTextReplacements table? optional text replacements for button localization +---@param buttonTextTranslate boolean? whether to translate the button text (defaults to true) +---@param buttonOnClick function callback function when the button is clicked +---@param width number? optional width for the button (defaults to 140) +---@return MenuItem function MenuItem.createLabeledButtonMenuItem(labelText, labelTextReplacements, labelTextTranslate, buttonText, buttonTextReplacements, buttonTextTranslate, buttonOnClick, width) assert(labelText ~= nil) assert(buttonText ~= nil) @@ -97,6 +139,12 @@ function MenuItem.createLabeledButtonMenuItem(labelText, labelTextReplacements, return menuItem end +---Creates a menu item with a label and a stepper control +---@param text string the text for the label +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param stepper UiElement the stepper control element +---@return MenuItem function MenuItem.createStepperMenuItem(text, replacements, translate, stepper) assert(text ~= nil) assert(stepper ~= nil) @@ -109,6 +157,12 @@ function MenuItem.createStepperMenuItem(text, replacements, translate, stepper) return menuItem end +---Creates a menu item with a label and a toggle button group +---@param text string the text for the label +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param toggleButtonGroup UiElement the toggle button group element +---@return MenuItem function MenuItem.createToggleButtonGroupMenuItem(text, replacements, translate, toggleButtonGroup) assert(text ~= nil) assert(toggleButtonGroup ~= nil) @@ -121,6 +175,12 @@ function MenuItem.createToggleButtonGroupMenuItem(text, replacements, translate, return menuItem end +---Creates a menu item with a label and a slider control +---@param text string the text for the label +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param slider UiElement the slider control element +---@return MenuItem function MenuItem.createSliderMenuItem(text, replacements, translate, slider) assert(text ~= nil) assert(slider ~= nil) @@ -145,6 +205,8 @@ function MenuItem.createBoolSelectorMenuItem(text, replacements, translate, bool return menuItem end +---Sets the selected state of this menu item +---@param selected boolean whether the item should be selected function MenuItem:setSelected(selected) self.selected = selected if selected and self.onSelectedFunction then @@ -153,26 +215,27 @@ function MenuItem:setSelected(selected) end -local DEFAULT_BACKGROUND_COLOR = {1, 1, 1} -local SELECTED_BACKGROUND_COLOR = {0.6, 0.6, 1} -local DEFAULT_BORDER_COLOR = {1, 1, 1} -local SELECTED_BORDER_COLOR = {0.6, 0.6, 1} - +---Draws the menu item background and selection highlight function MenuItem:drawSelf() local baseOpacity = 0.15 if self.selected then local selectedAdditionalOpacity = 0.5 local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, SELECTED_BACKGROUND_COLOR[1], SELECTED_BACKGROUND_COLOR[2], SELECTED_BACKGROUND_COLOR[3], fillOpacity) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, SELECTED_BORDER_COLOR[1], SELECTED_BORDER_COLOR[2], SELECTED_BORDER_COLOR[3], borderOpacity) + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, borderColor[1], borderColor[2], borderColor[3], borderOpacity) else - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, DEFAULT_BACKGROUND_COLOR[1], DEFAULT_BACKGROUND_COLOR[2], DEFAULT_BACKGROUND_COLOR[3], baseOpacity) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, DEFAULT_BORDER_COLOR[1], DEFAULT_BORDER_COLOR[2], DEFAULT_BORDER_COLOR[3], baseOpacity) + local bgColor = GAME.theme.colors.menuDefaultBackgroundColor + local borderColor = GAME.theme.colors.menuDefaultBorderColor + GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, bgColor[1], bgColor[2], bgColor[3], baseOpacity) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, borderColor[1], borderColor[2], borderColor[3], baseOpacity) end end --- inputs as a passthrough in case we ever implement player specific menus +---Passes inputs to child elements that can receive them +---@param inputs table input state table function MenuItem:receiveInputs(inputs) for _, child in ipairs(self.children) do if child.receiveInputs then diff --git a/client/src/ui/OverlayContainer.lua b/client/src/ui/OverlayContainer.lua index e3a41467..dfc1c9a9 100644 --- a/client/src/ui/OverlayContainer.lua +++ b/client/src/ui/OverlayContainer.lua @@ -26,7 +26,7 @@ local OverlayContainer = class( self.content.vAlign = "center" end end, - UiElement + UiElement, "OverlayContainer" ) -- Opens the overlay diff --git a/client/src/ui/SliderMenuItem.lua b/client/src/ui/SliderMenuItem.lua new file mode 100644 index 00000000..406882a8 --- /dev/null +++ b/client/src/ui/SliderMenuItem.lua @@ -0,0 +1,91 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local MenuItem = require(PATH .. ".MenuItem") +local Label = require(PATH .. ".Label") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local class = require("common.lib.class") + +---@class SliderMenuItem : MenuItem +local SliderMenuItem = class(function(self, options) + self.selected = false + self.TYPE = "SliderMenuItem" + self.labelText = options and options.labelText or nil + self.slider = options and options.slider or nil + self.x = 0 + self.y = 0 +end, MenuItem) + +--- Creates a SliderMenuItem with label and slider +---@param options table Options table with labelText, slider +---@return SliderMenuItem +function SliderMenuItem.create(options) + assert(options.labelText ~= nil) + assert(options.slider ~= nil) + + local SPACE_BETWEEN = 16 + + local menuItem = SliderMenuItem(options) + + -- Create label (left side) + local label = Label({ + text = options.labelText, + vAlign = "center" + }) + + -- Position slider (right side) + local slider = options.slider + slider.x = label.width + SPACE_BETWEEN + slider.vAlign = "center" + + -- Store references + menuItem.label = label + menuItem.slider = slider + + -- Calculate dimensions + menuItem.width = label.width + SPACE_BETWEEN + slider.width + MenuItem.PADDING + menuItem.height = math.max(label.height, slider.height) + (2 * MenuItem.PADDING) + + -- Add children + menuItem:addChild(label) + menuItem:addChild(slider) + + return menuItem +end + +function SliderMenuItem:drawSelf() + if self.selected and self.slider.getSelectedItemRect then + -- Get the rectangle of the currently selected slider item + local rect = self.slider:getSelectedItemRect() + + if rect then + -- Use same pulsing effect as regular menu items + local baseOpacity = 0.15 + local selectedAdditionalOpacity = 0.5 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + -- Convert slider-relative coordinates to absolute screen coordinates + -- Account for vAlign/hAlign offsets that are applied during child drawing + local alignOffsetX, alignOffsetY = GraphicsUtil.getAlignmentOffset(self, self.slider) + local absoluteX = self.x + self.slider.x + alignOffsetX + rect.x + local absoluteY = self.y + self.slider.y + alignOffsetY + rect.y + + -- Draw pulsing background fill + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + GraphicsUtil.drawRectangle("fill", absoluteX, absoluteY, rect.width, rect.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + + -- Draw pulsing border + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("line", absoluteX, absoluteY, rect.width, rect.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + end + end +end + +function SliderMenuItem:receiveInputs(inputs) + if self.slider then + self.slider:receiveInputs(inputs) + end +end + +return SliderMenuItem diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index a1018b35..da7a729b 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -5,9 +5,9 @@ local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") local DebugSettings = require("client.src.debug.DebugSettings") +-- StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting +-- Useful for auto-aligning multiple ui elements that only know one of their dimensions ---@class StackPanel : UiElement ----StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting. ----Useful for auto-aligning multiple ui elements that only know one of their dimensions. ---@field alignment "left"|"right"|"top"|"bottom" Direction in which children are stacked ---@field pixelsTaken number Tracks how many pixels are already taken in the stacking direction ---@field TYPE string Class type identifier diff --git a/client/src/ui/UIElement.lua b/client/src/ui/UIElement.lua index 4c1bc4c5..0ab2c398 100644 --- a/client/src/ui/UIElement.lua +++ b/client/src/ui/UIElement.lua @@ -1,5 +1,7 @@ local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") +local logger = require("common.lib.logger") ---@class UiElement ---@field x number relative x offset to the parent element (canvas if no parent) @@ -144,10 +146,8 @@ function UIElement:refreshLocalization() end function UIElement:update(dt) - if self.isVisible then - self:updateSelf(dt) - self:updateChildren(dt) - end + self:updateSelf(dt) + self:updateChildren(dt) end -- UiElements can override this method to do custom update logic @@ -163,6 +163,11 @@ end function UIElement:draw() if self.isVisible then + if DebugSettings.showUIElementBorders() then + GraphicsUtil.setColor(0, 0, 1, 1) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) + end self:drawSelf() -- if DEBUG_ENABLED then -- GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, 1, 1, 1, 0.5) @@ -174,7 +179,7 @@ function UIElement:draw() end end --- UiElements can overrid this method to do custom drawing +-- UiElements can override this method to do custom drawing -- implementation is optional function UIElement:drawSelf() end @@ -219,10 +224,15 @@ function UIElement:isTouchable() or self.onRelease end +---Returns the foremost visible, enabled element containing the given screen-space coordinates. +---@param x number screen x coordinate of the touch +---@param y number screen y coordinate of the touch +---@return UiElement? element the coordinates intersect, or nil when none match function UIElement:getTouchedElement(x, y) if self.isVisible and self.isEnabled and self:inBounds(x, y) then local touchedElement - for i = 1, #self.children do + -- Check children in reverse order (last drawn = first touched) + for i = #self.children, 1, -1 do touchedElement = self.children[i]:getTouchedElement(x, y) if touchedElement then return touchedElement @@ -259,4 +269,41 @@ function UIElement:handleFocusedInput(inputs, dt) return false -- No focused element found end +---Returns a formatted tree of this element and all children with class name, TYPE, and root position +---@return string +function UIElement:debugTree() + local function getElementInfo(element, depth) + local indent = string.rep(" ", depth) + local typeStr = element.TYPE and (" [" .. element.TYPE .. "]") or "" + local x, y = element:getScreenPos() + local info = string.format("%s%s @ (%.1f, %.1f)", indent, typeStr, x, y) + + local lines = {info} + for _, child in ipairs(element.children) do + local childInfo = getElementInfo(child, depth + 1) + table.insert(lines, childInfo) + end + + return table.concat(lines, "\n") + end + + return getElementInfo(self, 0) +end + +---Returns a formatted list of this element and its direct children only (non-recursive) +---@return string +function UIElement:debugChildren() + local typeStr = self.TYPE and (" [" .. self.TYPE .. "]") or "" + local x, y = self:getScreenPos() + local lines = {string.format("%s @ (%.1f, %.1f)", typeStr, x, y)} + + for _, child in ipairs(self.children) do + local childTypeStr = child.TYPE and (" [" .. child.TYPE .. "]") or "" + local childX, childY = child:getScreenPos() + table.insert(lines, string.format(" %s @ (%.1f, %.1f)", childTypeStr, childX, childY)) + end + + return table.concat(lines, "\n") +end + return UIElement \ No newline at end of file diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index a884de6b..5b74969d 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -9,6 +9,9 @@ local ui = { Button = require(PATH .. ".Button"), ButtonGroup = require(PATH .. ".ButtonGroup"), Carousel = require(PATH .. ".Carousel"), + ---@see ChangeInputButton + ---@type fun(options: ChangeInputButtonOptions): ChangeInputButton + ChangeInputButton = require(PATH .. ".ChangeInputButton"), Focusable = require(PATH .. ".Focusable"), FocusDirector = require(PATH .. ".FocusDirector"), Grid = require(PATH .. ".Grid"), @@ -18,6 +21,7 @@ local ui = { ImageButton = require(PATH .. ".ImageButton"), ImageContainer = require(PATH .. ".ImageContainer"), InputField = require(PATH .. ".InputField"), + KeyBindingMenuItem = require(PATH .. ".KeyBindingMenuItem"), ---@see Label ---@type fun(options: LabelOptions): Label Label = require(PATH .. ".Label"), @@ -40,6 +44,7 @@ local ui = { ---@see Slider ---@type fun(options: SliderOptions): Slider Slider = require(PATH .. ".Slider"), + SliderMenuItem = require(PATH .. ".SliderMenuItem"), ---@source StackElement.lua StackElement = require(PATH .. ".StackElement"), StackPanel = require(PATH .. ".StackPanel"), diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index f4470052..d1cfc9e2 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -13,6 +13,14 @@ function touchHandler:touch(x, y) -- prevent multitouch if not self.touchedElement then self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) + + if not self.touchedElement then + local activeScene = GAME.navigationStack:getActiveScene() + if activeScene and activeScene.uiRoot then + self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) + end + end + if self.touchedElement and self.touchedElement.onTouch then self.touchedElement:onTouch(x, y) end diff --git a/client/tests/DiscreteImageSliderTests.lua b/client/tests/DiscreteImageSliderTests.lua new file mode 100644 index 00000000..ed8f62c7 --- /dev/null +++ b/client/tests/DiscreteImageSliderTests.lua @@ -0,0 +1,387 @@ +local DiscreteImageSlider = require("client.src.ui.DiscreteImageSlider") +local logger = require("common.lib.logger") + +local function createMockImage(width, height) + local imageData = love.image.newImageData(width, height) + return love.graphics.newImage(imageData) +end + +local function createMockValue(id, width, height) + width = width or 50 + height = height or 50 + return { + id = id, + image = createMockImage(width, height), + scale = 1 + } +end + +local function testBasicConstruction() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + selectedValue = "item2" + }) + + assert(slider ~= nil, "Slider should be created") + assert(#slider.values == 3, "Should have 3 values") + assert(slider.value == 2, "Should select item2 (index 2)") + assert(slider:getSelectedId() == "item2", "Should return correct selected ID") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 3, "Max should be 3") + + logger.trace("passed test testBasicConstruction") +end + +local function testEmptyConstruction() + local slider = DiscreteImageSlider({ + values = {} + }) + + assert(slider ~= nil, "Slider should be created with empty values") + assert(#slider.values == 0, "Should have 0 values") + assert(slider.value == 1, "Value should default to 1") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 1, "Max should be 1 even when empty") + + logger.trace("passed test testEmptyConstruction") +end + +local function testIndexIdMapping() + local values = { + createMockValue("alpha", 50, 50), + createMockValue("beta", 50, 50), + createMockValue("gamma", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + assert(slider:getIndexForId("alpha") == 1, "Should map alpha to index 1") + assert(slider:getIndexForId("beta") == 2, "Should map beta to index 2") + assert(slider:getIndexForId("gamma") == 3, "Should map gamma to index 3") + assert(slider:getIndexForId("nonexistent") == nil, "Should return nil for invalid ID") + + assert(slider:getIdForIndex(1) == "alpha", "Should map index 1 to alpha") + assert(slider:getIdForIndex(2) == "beta", "Should map index 2 to beta") + assert(slider:getIdForIndex(3) == "gamma", "Should map index 3 to gamma") + assert(slider:getIdForIndex(0) == nil, "Should return nil for index 0") + assert(slider:getIdForIndex(4) == nil, "Should return nil for out of bounds index") + + logger.trace("passed test testIndexIdMapping") +end + +local function testValueSelection() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + slider:setSelectedId("item3", false) + assert(slider.value == 3, "Should select item3") + assert(slider:getSelectedId() == "item3", "Should return item3") + + slider:setSelectedId("item1", false) + assert(slider.value == 1, "Should select item1") + assert(slider:getSelectedId() == "item1", "Should return item1") + + slider:setSelectedId("nonexistent", false) + assert(slider.value == 1, "Should not change value for invalid ID") + + logger.trace("passed test testValueSelection") +end + +local function testValueChangeCallback() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local callbackCount = 0 + local callbackSlider = nil + + local slider = DiscreteImageSlider({ + values = values, + onValueChange = function(s) + callbackCount = callbackCount + 1 + callbackSlider = s + end + }) + + slider:setSelectedId("item2", true) + assert(callbackCount == 1, "Callback should be called when committed=true") + assert(callbackSlider == slider, "Callback should receive slider instance") + + slider:setSelectedId("item3", false) + assert(callbackCount == 2, "Callback should be called even when committed=false (onlyChangeOnRelease=false)") + + logger.trace("passed test testValueChangeCallback") +end + +local function testValueChangeCallbackOnlyOnRelease() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local callbackCount = 0 + + local slider = DiscreteImageSlider({ + values = values, + onlyChangeOnRelease = true, + onValueChange = function(s) + callbackCount = callbackCount + 1 + end + }) + + slider:setSelectedId("item2", false) + assert(callbackCount == 0, "Callback should not be called when committed=false and onlyChangeOnRelease=true") + + slider:setSelectedId("item3", true) + assert(callbackCount == 1, "Callback should be called when committed=true") + + logger.trace("passed test testValueChangeCallbackOnlyOnRelease") +end + +local function testSetValues() + local values1 = { + createMockValue("a", 50, 50), + createMockValue("b", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values1, + selectedValue = "b" + }) + + assert(slider.value == 2, "Should start at value 2") + assert(slider.max == 2, "Max should be 2") + + local values2 = { + createMockValue("x", 50, 50), + createMockValue("y", 50, 50), + createMockValue("z", 50, 50), + createMockValue("w", 50, 50) + } + + slider:setValues(values2) + assert(#slider.values == 4, "Should have 4 values after setValues") + assert(slider.max == 4, "Max should be 4") + assert(slider.value == 2, "Value should be maintained if valid") + assert(slider:getSelectedId() == "y", "Should now reference new value at index 2") + + logger.trace("passed test testSetValues") +end + +local function testSetValuesWithClampedValue() + local values1 = { + createMockValue("a", 50, 50), + createMockValue("b", 50, 50), + createMockValue("c", 50, 50), + createMockValue("d", 50, 50), + createMockValue("e", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values1, + selectedValue = "e" + }) + + assert(slider.value == 5, "Should start at value 5") + + local values2 = { + createMockValue("x", 50, 50), + createMockValue("y", 50, 50) + } + + slider:setValues(values2) + assert(slider.value == 2, "Value should be clamped to max when reduced") + assert(slider:getSelectedId() == "y", "Should select last item") + + logger.trace("passed test testSetValuesWithClampedValue") +end + +local function testLayoutDimensions() + local values = { + createMockValue("item1", 50, 60), + createMockValue("item2", 40, 60), + createMockValue("item3", 30, 60) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 10 + }) + + -- Expected width: 50 + 10 + 40 + 10 + 30 = 140 + assert(slider.width == 140, "Width should sum all items and spacing") + assert(slider.height == 60, "Height should match item height") + + logger.trace("passed test testLayoutDimensions") +end + +local function testLayoutDimensionsNoSpacing() + local values = { + createMockValue("item1", 50, 60), + createMockValue("item2", 40, 60), + createMockValue("item3", 30, 60) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + -- Expected width: 50 + 40 + 30 = 120 + assert(slider.width == 120, "Width should sum all items without spacing") + + logger.trace("passed test testLayoutDimensionsNoSpacing") +end + +local function testStackPanelLayoutPositions() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 40, 50), + createMockValue("item3", 30, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 5 + }) + + local children = slider.stackPanel.children + local itemCount = 0 + local positions = {} + + for _, child in ipairs(children) do + if child.discreteIndex then + itemCount = itemCount + 1 + positions[child.discreteIndex] = child.x + end + end + + assert(itemCount == 3, "Should have 3 item children") + assert(positions[1] == 0, "Item 1 should be at x=0") + assert(positions[2] == 55, "Item 2 should be at x=55 (50 + 5)") + assert(positions[3] == 100, "Item 3 should be at x=100 (50 + 5 + 40 + 5)") + + logger.trace("passed test testStackPanelLayoutPositions") +end + +local function testGetValueForPos() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + slider.x = 100 + slider.y = 100 + + -- Click near center of first item (x=100, width=50, center=125) + local index1 = slider:getValueForPos(125) + assert(index1 == 1, "Should return index 1 for x=125") + + -- Click near center of second item (x=150, width=50, center=175) + local index2 = slider:getValueForPos(175) + assert(index2 == 2, "Should return index 2 for x=175") + + -- Click near center of third item (x=200, width=50, center=225) + local index3 = slider:getValueForPos(225) + assert(index3 == 3, "Should return index 3 for x=225") + + logger.trace("passed test testGetValueForPos") +end + +local function testSingleValue() + local values = { + createMockValue("only", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + assert(slider.value == 1, "Should have value 1") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 1, "Max should be 1") + assert(slider:getSelectedId() == "only", "Should select the only item") + + logger.trace("passed test testSingleValue") +end + +local function testMixedWidthsAndHeights() + local values = { + createMockValue("small", 20, 30), + createMockValue("medium", 50, 60), + createMockValue("large", 80, 90), + createMockValue("tiny", 10, 15) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + -- Width should be sum: 20 + 50 + 80 + 10 = 160 + assert(slider.width == 160, "Width should handle mixed widths") + -- Height should be max: 90 + assert(slider.height == 90, "Height should be tallest item") + + logger.trace("passed test testMixedWidthsAndHeights") +end + +local function testDuplicateIds() + local values = { + createMockValue("duplicate", 50, 50), + createMockValue("unique", 50, 50), + createMockValue("duplicate", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + -- With duplicate IDs, the last one wins in the map + local index = slider:getIndexForId("duplicate") + assert(index == 3, "Should map to last occurrence of duplicate ID") + + logger.trace("passed test testDuplicateIds") +end + +testBasicConstruction() +testEmptyConstruction() +testIndexIdMapping() +testValueSelection() +testValueChangeCallback() +testValueChangeCallbackOnlyOnRelease() +testSetValues() +testSetValuesWithClampedValue() +testLayoutDimensions() +testLayoutDimensionsNoSpacing() +testStackPanelLayoutPositions() +testGetValueForPos() +testSingleValue() +testMixedWidthsAndHeights() +testDuplicateIds() + +logger.trace("All DiscreteImageSlider tests passed!") diff --git a/client/tests/InputConfigurationTests.lua b/client/tests/InputConfigurationTests.lua new file mode 100644 index 00000000..0071073f --- /dev/null +++ b/client/tests/InputConfigurationTests.lua @@ -0,0 +1,714 @@ +local InputConfiguration = require("client.src.input.InputConfiguration") +local logger = require("common.lib.logger") + +local function createMockIsPressedWithRepeat(key) + return key == "test" +end + +local function createJoystickProvider(joysticks) + local storedJoysticks = joysticks or {} + local provider = {} + + function provider:getJoysticks() + return storedJoysticks + end + + return provider +end + +local function testBasicConstruction() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config ~= nil, "InputConfiguration should be created") + assert(config.index == 1, "Index should be set to 1") + assert(config.claimed == false, "Should start unclaimed") + assert(config.player == nil, "Should have no player") + assert(type(config.isDown) == "table", "isDown should be a table") + assert(type(config.isPressed) == "table", "isPressed should be a table") + assert(type(config.isUp) == "table", "isUp should be a table") + assert(type(config.isPressedWithRepeat) == "function", "isPressedWithRepeat should be a function") + + logger.trace("passed test testBasicConstruction") +end + +local function testConstructionWithDifferentIndex() + local config1 = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local config2 = InputConfiguration(5, createMockIsPressedWithRepeat, createJoystickProvider()) + local config3 = InputConfiguration(8, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config1.index == 1, "Config1 should have index 1") + assert(config2.index == 5, "Config2 should have index 5") + assert(config3.index == 8, "Config3 should have index 8") + + logger.trace("passed test testConstructionWithDifferentIndex") +end + +local function testIsPressedWithRepeatFunction() + local testFunction = function(key) + return key == "testkey" + end + + local config = InputConfiguration(1, testFunction, createJoystickProvider()) + + assert(config.isPressedWithRepeat("testkey") == true, "isPressedWithRepeat should return true for testkey") + assert(config.isPressedWithRepeat("otherkey") == false, "isPressedWithRepeat should return false for otherkey") + + logger.trace("passed test testIsPressedWithRepeatFunction") +end + +local function testKeyBindingStorage() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + + assert(config.Up == "w", "Up key should be stored") + assert(config.Down == "s", "Down key should be stored") + assert(config.Left == "a", "Left key should be stored") + assert(config.Right == "d", "Right key should be stored") + + logger.trace("passed test testKeyBindingStorage") +end + +local function testControllerBindingStorage() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "03000000de280000ff11000001000000:1:dpdown" + config.SwapL = "03000000de280000ff11000001000000:1:a" + + assert(config.Up == "03000000de280000ff11000001000000:1:dpup", "Controller binding should be stored") + assert(config.Down == "03000000de280000ff11000001000000:1:dpdown", "Controller binding should be stored") + assert(config.SwapL == "03000000de280000ff11000001000000:1:a", "Controller binding should be stored") + + logger.trace("passed test testControllerBindingStorage") +end + +local function testMixedInputBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "03000000de280000ff11000001000000:1:dpdown" + config.SwapL = "space" + + assert(config.Up == "w", "Keyboard binding should work") + assert(config.Down == "03000000de280000ff11000001000000:1:dpdown", "Controller binding should work") + assert(config.SwapL == "space", "Keyboard binding should work") + + logger.trace("passed test testMixedInputBindings") +end + +local function testClaimedProperty() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config.claimed == false, "Should start unclaimed") + + config.claimed = true + assert(config.claimed == true, "Should be claimable") + + config.claimed = false + assert(config.claimed == false, "Should be unclaimable") + + logger.trace("passed test testClaimedProperty") +end + +local function testPlayerProperty() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local mockPlayer = {playerNumber = 1} + + assert(config.player == nil, "Should start with no player") + + config.player = mockPlayer + assert(config.player == mockPlayer, "Should store player reference") + + config.player = nil + assert(config.player == nil, "Should allow clearing player") + + logger.trace("passed test testPlayerProperty") +end + +local function testIsDownTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isDown["w"] = true + config.isDown["s"] = false + + assert(config.isDown["w"] == true, "isDown should track key state") + assert(config.isDown["s"] == false, "isDown should track key state") + + logger.trace("passed test testIsDownTable") +end + +local function testIsPressedTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isPressed["w"] = 5 + config.isPressed["s"] = 10 + + assert(config.isPressed["w"] == 5, "isPressed should track press duration") + assert(config.isPressed["s"] == 10, "isPressed should track press duration") + + logger.trace("passed test testIsPressedTable") +end + +local function testIsUpTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isUp["w"] = true + config.isUp["s"] = false + + assert(config.isUp["w"] == true, "isUp should track release state") + assert(config.isUp["s"] == false, "isUp should track release state") + + logger.trace("passed test testIsUpTable") +end + +local function testEmptyConfiguration() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config.Up == nil, "Empty config should have no Up binding") + assert(config.Down == nil, "Empty config should have no Down binding") + assert(config.Left == nil, "Empty config should have no Left binding") + assert(config.Right == nil, "Empty config should have no Right binding") + assert(config.SwapL == nil, "Empty config should have no SwapL binding") + assert(config.SwapR == nil, "Empty config should have no SwapR binding") + + logger.trace("passed test testEmptyConfiguration") +end + +local function testMultipleConfigurationsAreIndependent() + local config1 = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local config2 = InputConfiguration(2, createMockIsPressedWithRepeat, createJoystickProvider()) + + config1.Up = "w" + config1.claimed = true + + config2.Up = "i" + config2.claimed = false + + assert(config1.Up == "w", "Config1 should have its own bindings") + assert(config2.Up == "i", "Config2 should have its own bindings") + assert(config1.claimed == true, "Config1 should have its own claimed state") + assert(config2.claimed == false, "Config2 should have its own claimed state") + + logger.trace("passed test testMultipleConfigurationsAreIndependent") +end + +local function testConfigurationIndex() + local configs = {} + for i = 1, 8 do + configs[i] = InputConfiguration(i, createMockIsPressedWithRepeat, createJoystickProvider()) + end + + for i = 1, 8 do + assert(configs[i].index == i, "Config " .. i .. " should have index " .. i) + end + + logger.trace("passed test testConfigurationIndex") +end + +local function testBindingOverwrite() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + assert(config.Up == "w", "Should set initial binding") + + config.Up = "i" + assert(config.Up == "i", "Should overwrite binding") + + config.Up = "03000000de280000ff11000001000000:1:dpup" + assert(config.Up == "03000000de280000ff11000001000000:1:dpup", "Should overwrite with controller binding") + + logger.trace("passed test testBindingOverwrite") +end + +local function testNilBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + assert(config.Up == "w", "Should set binding") + + config.Up = nil + assert(config.Up == nil, "Should allow clearing binding") + + logger.trace("passed test testNilBindings") +end + +local function testAllKeyNames() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + config.SwapL = "space" + config.SwapR = "lshift" + config.TauntUp = "1" + config.TauntDown = "2" + config.Raise = "r" + config.Pause = "escape" + + assert(config.Up == "w", "Up should be set") + assert(config.Down == "s", "Down should be set") + assert(config.Left == "a", "Left should be set") + assert(config.Right == "d", "Right should be set") + assert(config.SwapL == "space", "SwapL should be set") + assert(config.SwapR == "lshift", "SwapR should be set") + assert(config.TauntUp == "1", "TauntUp should be set") + assert(config.TauntDown == "2", "TauntDown should be set") + assert(config.Raise == "r", "Raise should be set") + assert(config.Pause == "escape", "Pause should be set") + + logger.trace("passed test testAllKeyNames") +end + +local function testIsEmptyWithNoBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:isEmpty() == true, "Empty config should return true for isEmpty()") + + logger.trace("passed test testIsEmptyWithNoBindings") +end + +local function testIsEmptyWithOneBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:isEmpty() == false, "Config with one binding should return false for isEmpty()") + + logger.trace("passed test testIsEmptyWithOneBinding") +end + +local function testIsEmptyWithAllBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + config.Swap1 = "space" + config.Swap2 = "lshift" + config.TauntUp = "1" + config.TauntDown = "2" + config.Raise1 = "r" + config.Raise2 = "t" + config.Start = "escape" + + assert(config:isEmpty() == false, "Config with all bindings should return false for isEmpty()") + + logger.trace("passed test testIsEmptyWithAllBindings") +end + +local function testIsEmptyAfterClearing() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + + assert(config:isEmpty() == false, "Config with bindings should return false") + + config.Up = nil + config.Down = nil + config.Left = nil + + assert(config:isEmpty() == true, "Config after clearing all bindings should return true for isEmpty()") + + logger.trace("passed test testIsEmptyAfterClearing") +end + +local function testGetDeviceTypeWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:getDeviceType() == "keyboard", "Keyboard binding should return keyboard device type") + + logger.trace("passed test testGetDeviceTypeWithKeyboardBinding") +end + +local function testGetDeviceTypeWithControllerBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "03000000de280000ff11000001000000:1:dpup" + + assert(config:getDeviceType() == "controller", "Controller binding should return controller device type") + + logger.trace("passed test testGetDeviceTypeWithControllerBinding") +end + +local function testGetDeviceTypeWithTouchBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "mouse1" + + assert(config:getDeviceType() == "touch", "Mouse binding should return touch device type") + + logger.trace("passed test testGetDeviceTypeWithTouchBinding") +end + +local function testGetDeviceTypeWithEmptyConfig() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:getDeviceType() == nil, "Empty configuration should return nil device type") + + logger.trace("passed test testGetDeviceTypeWithEmptyConfig") +end + +local function testParseControllerBindingWithValidBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "03000000de280000ff11000001000000:1:dpup" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == "03000000de280000ff11000001000000", "Should extract GUID correctly") + assert(slot == 1, "Should extract slot correctly") + + logger.trace("passed test testParseControllerBindingWithValidBinding") +end + +local function testParseControllerBindingWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for keyboard binding") + assert(slot == nil, "Should return nil slot for keyboard binding") + + logger.trace("passed test testParseControllerBindingWithKeyboardBinding") +end + +local function testParseControllerBindingWithNilBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for nil binding") + assert(slot == nil, "Should return nil slot for nil binding") + + logger.trace("passed test testParseControllerBindingWithNilBinding") +end + +local function testParseControllerBindingWithMalformedBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "malformed:binding" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for malformed binding") + assert(slot == nil, "Should return nil slot for malformed binding") + + logger.trace("passed test testParseControllerBindingWithMalformedBinding") +end + +local function testParseControllerBindingWithDifferentSlots() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "03000000de280000ff11000001000000:2:dpdown" + config.Left = "03000000de280000ff11000001000000:3:dpleft" + + local guid1, slot1 = config:parseControllerBinding("Up") + local guid2, slot2 = config:parseControllerBinding("Down") + local guid3, slot3 = config:parseControllerBinding("Left") + + assert(slot1 == 1, "Should parse slot 1 correctly") + assert(slot2 == 2, "Should parse slot 2 correctly") + assert(slot3 == 3, "Should parse slot 3 correctly") + + logger.trace("passed test testParseControllerBindingWithDifferentSlots") +end + +local function testParseControllerBindingWithDifferentGUIDs() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "030000005e040000120b000005050000:1:dpdown" + + local guid1, slot1 = config:parseControllerBinding("Up") + local guid2, slot2 = config:parseControllerBinding("Down") + + assert(guid1 == "03000000de280000ff11000001000000", "Should parse first GUID correctly") + assert(guid2 == "030000005e040000120b000005050000", "Should parse second GUID correctly") + + logger.trace("passed test testParseControllerBindingWithDifferentGUIDs") +end + +local function testGetDeviceNameWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:getDeviceName() == "Keyboard", "Keyboard binding should return 'Keyboard'") + + logger.trace("passed test testGetDeviceNameWithKeyboardBinding") +end + +local function testGetDeviceNameWithTouchBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "mouse1" + + assert(config:getDeviceName() == "Touch", "Mouse binding should return 'Touch'") + + logger.trace("passed test testGetDeviceNameWithTouchBinding") +end + +local function testGetDeviceNameWithConnectedController() + local guid = "03000000de280000ff11000001000000" + local joystick = { + getGUID = function() + return guid + end, + getName = function() + return "Test Controller" + end, + getID = function() + return 1 + end + } + + local provider = createJoystickProvider({joystick}) + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Test Controller", "Connected controller should use joystick name") + + logger.trace("passed test testGetDeviceNameWithConnectedController") +end + +local function testGetDeviceNameWithDisconnectedController() + local guid = "00000000deadbeef0000000000000000" + + local provider = createJoystickProvider() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Controller", "Disconnected controller should fall back to generic name") + + logger.trace("passed test testGetDeviceNameWithDisconnectedController") +end + +local function testGetDeviceNameWithUnknownController() + local guid = "unknown-guid-0000" + + local provider = createJoystickProvider() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Controller", "Unknown controller should return 'Controller'") + + logger.trace("passed test testGetDeviceNameWithUnknownController") +end + +local function testGetDeviceNameWithEmptyConfig() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:getDeviceName() == nil, "Empty configuration should return nil device name") + + logger.trace("passed test testGetDeviceNameWithEmptyConfig") +end + +testBasicConstruction() +testConstructionWithDifferentIndex() +testIsPressedWithRepeatFunction() +testKeyBindingStorage() +testControllerBindingStorage() +testMixedInputBindings() +testClaimedProperty() +testPlayerProperty() +testIsDownTable() +testIsPressedTable() +testIsUpTable() +testEmptyConfiguration() +testMultipleConfigurationsAreIndependent() +testConfigurationIndex() +testBindingOverwrite() +testNilBindings() +testAllKeyNames() +testIsEmptyWithNoBindings() +testIsEmptyWithOneBinding() +testIsEmptyWithAllBindings() +testIsEmptyAfterClearing() +testGetDeviceTypeWithKeyboardBinding() +testGetDeviceTypeWithControllerBinding() +testGetDeviceTypeWithTouchBinding() +testGetDeviceTypeWithEmptyConfig() +testParseControllerBindingWithValidBinding() +testParseControllerBindingWithKeyboardBinding() +testParseControllerBindingWithNilBinding() +testParseControllerBindingWithMalformedBinding() +testParseControllerBindingWithDifferentSlots() +testParseControllerBindingWithDifferentGUIDs() +testGetDeviceNameWithKeyboardBinding() +testGetDeviceNameWithTouchBinding() +testGetDeviceNameWithConnectedController() +testGetDeviceNameWithDisconnectedController() +testGetDeviceNameWithUnknownController() +testGetDeviceNameWithEmptyConfig() + +-- Controller Image Variant Tests (using static helper) +local function testPlayStation5Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS5 Controller") == "playstation5") + assert(InputConfiguration.getControllerImageVariantFromName("DualSense Wireless Controller") == "playstation5") + assert(InputConfiguration.getControllerImageVariantFromName("Sony DualSense") == "playstation5") + logger.trace("passed test testPlayStation5Controllers") +end + +local function testPlayStation4Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS4 Controller") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("DUALSHOCK 4 Wireless Controller") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("Sony DualShock 4") == "playstation4") + logger.trace("passed test testPlayStation4Controllers") +end + +local function testPlayStation3Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS3 Controller") == "playstation3") + assert(InputConfiguration.getControllerImageVariantFromName("Sony PLAYSTATION(R)3 Controller") == "playstation3") + logger.trace("passed test testPlayStation3Controllers") +end + +local function testPlayStation2Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS2 Controller") == "playstation2") + logger.trace("passed test testPlayStation2Controllers") +end + +local function testPlayStation1Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS1 Controller") == "playstation1") + assert(InputConfiguration.getControllerImageVariantFromName("PlayStation 1 Controller") == "playstation1") + logger.trace("passed test testPlayStation1Controllers") +end + +local function testXboxSeriesControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Series X Controller") == "xboxseries") + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Series S Controller") == "xboxseries") + logger.trace("passed test testXboxSeriesControllers") +end + +local function testXboxOneControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox One Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Microsoft Xbox One Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Wireless Controller") == "xboxone") + logger.trace("passed test testXboxOneControllers") +end + +local function testXbox360Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox 360 Controller") == "xbox360") + assert(InputConfiguration.getControllerImageVariantFromName("Microsoft Xbox 360 Controller") == "xbox360") + logger.trace("passed test testXbox360Controllers") +end + +local function testSwitchProControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Pro Controller") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("Nintendo Switch Pro Controller") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("Switch Pro Controller") == "switch_pro") + logger.trace("passed test testSwitchProControllers") +end + +local function testSNESControllers() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SN30") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SN30 Pro") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SF30") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SF30 Pro") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("SNES Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Super Nintendo Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Super Famicom Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Scout") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Scout Premium SNES Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("iBuffalo BSGP1204 Series") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("2-axis 8-button gamepad") == "snes") + logger.trace("passed test testSNESControllers") +end + +local function testN64Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo 64") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo N64") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo 64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("N64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Nintendo 64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Admiral N64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Admiral Controller") == "n64") + logger.trace("passed test testN64Controllers") +end + +local function testGameCubeControllers() + assert(InputConfiguration.getControllerImageVariantFromName("GameCube Controller") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Game Cube Controller") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo GameCube") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo GBros") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("GBros Adapter") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Battle Pad") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Horipad") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("HORIPAD") == "gamecube") + logger.trace("passed test testGameCubeControllers") +end + +local function test8BitDoProSeries() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro 2") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro2") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro 3") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro3") == "playstation4") + logger.trace("passed test test8BitDoProSeries") +end + +local function test8BitDoUltimateSeries() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 2") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 2C") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 3-mode Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate Wired Controller") == "xboxone") + logger.trace("passed test test8BitDoUltimateSeries") +end + +local function testGameSirTarantula() + assert(InputConfiguration.getControllerImageVariantFromName("GameSir Tarantula") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir Tarantula Pro") == "playstation4") + logger.trace("passed test testGameSirTarantula") +end + +local function testHoriControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Horipad Pro for Xbox") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("HORI Xbox Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Horipad for Nintendo Switch") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("HORI Nintendo Switch Controller") == "switch_pro") + logger.trace("passed test testHoriControllers") +end + +local function testGenericControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Unknown Controller") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("Random Gamepad") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Lite") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Lite 2") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Zero") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Zero 2") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Micro") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo F40") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Arcade Stick") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo M30") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir T4") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir G7") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Fighting Edge") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName(nil) == "generic") + logger.trace("passed test testGenericControllers") +end + +testPlayStation5Controllers() +testPlayStation4Controllers() +testPlayStation3Controllers() +testPlayStation2Controllers() +testPlayStation1Controllers() +testXboxSeriesControllers() +testXboxOneControllers() +testXbox360Controllers() +testSwitchProControllers() +testSNESControllers() +testN64Controllers() +testGameCubeControllers() +test8BitDoProSeries() +test8BitDoUltimateSeries() +testGameSirTarantula() +testHoriControllers() +testGenericControllers() + +logger.trace("All InputConfiguration tests passed!") diff --git a/common/lib/class.lua b/common/lib/class.lua index c85c3f78..71eb8223 100644 --- a/common/lib/class.lua +++ b/common/lib/class.lua @@ -10,8 +10,9 @@ local classMetaTable = {__call = newTable} ---@param init function function called on new objects of the class after the metatables have been applied ---@param parent any? parent class that has its own constructor called before init +---@param typeName string? optional type name for the class, sets classTable.TYPE if provided ---@return table classTable table acting as metatable for the class and acting as the constructor; uses the parent as its metatable -local class = function(init, parent) +local class = function(init, parent, typeName) local classTable = {} -- class table acts as the metatable for new tables -- all function calls on the table should find the functions on the class table, so set __index @@ -21,6 +22,11 @@ local class = function(init, parent) classTable.__call = newTable -- make parent functions accessible, even if they may be shadowed classTable.super = parent + + -- Set TYPE if provided + if typeName then + classTable.TYPE = typeName + end classTable.initializeObject = function(new, super, ...) if new.super then if not super then diff --git a/common/lib/joystickManager.lua b/common/lib/joystickManager.lua index c62a0d23..069a6aa0 100644 --- a/common/lib/joystickManager.lua +++ b/common/lib/joystickManager.lua @@ -101,9 +101,12 @@ function joystickManager:getDPadState(joystick, hatIndex) } end --- Intentional override ----@diagnostic disable-next-line: duplicate-set-field -function love.joystickadded(joystick) +function joystickManager:registerJoystick(joystick) + + if joystickManager.devices[joystick:getID()] then + return + end + -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID -- the GUID is consistent across sessions local guid = joystick:getGUID() @@ -167,28 +170,4 @@ function love.joystickadded(joystick) joystickManager.devices[id] = device end --- Intentional override ----@diagnostic disable-next-line: duplicate-set-field -function love.joystickremoved(joystick) - -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID - -- the GUID is consistent across sessions - local guid = joystick:getGUID() - -- ID is a per-session identifier for each controller regardless of type - local id = joystick:getID() - - local vendorID, productID, productVersion = joystick:getDeviceInfo( ) - - logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) - - if joystickManager.guidsToJoysticks[guid] then - joystickManager.guidsToJoysticks[guid][id] = nil - - if tableUtils.length(joystickManager.guidsToJoysticks[guid]) == 0 then - joystickManager.guidsToJoysticks[guid] = nil - end - end - - joystickManager.devices[id] = nil -end - return joystickManager \ No newline at end of file diff --git a/docs/InputDeviceSelection.md b/docs/InputDeviceSelection.md new file mode 100644 index 00000000..96da5672 --- /dev/null +++ b/docs/InputDeviceSelection.md @@ -0,0 +1,26 @@ +# Input Device Selection + +## Requirements +- Ensure character select automatically triggers the input device overlay whenever local human slots lack device assignments. +- The overlay does not dismiss until all local players have an input configuration assigned. +- You can't start the game until the overlay is dismissed. +- The overlay should supports all created input configurations plus touch. +- The overlay shows a box for each local player, online players box is not shown. +- Touch is assigned by tapping on the player box you want to use touch with. +- Each player box shows the player number +- When you touch or use a controller the device used shows in the box and becomes active. +- The overlay dismisses once every assignment is made. +- A "Change Input Device" button is on character select to allow you to reselect, triggering the overlay again with all local assignments reset. +- The change input device button shows all assignments in a compact form. +- Any input config should be able to navigate the menus outside of character select. +- When an input configuration is used that isn't assigned, release configs and bring up the overlay again +- When more than one input configuration of a device type are assigned, they should be numbered by order they are in the input configuration, so second keyboard configuration says "2" +- An attempt should be made to show an image close to the input method used. Touch, keyboard, controller shape + +## Testing Plan +- Manual scenarios: + - Keyboard only, controller only, mixed devices, touch selection via mouse. + - Multiple controllers to confirm naming and unique assignments. + - Works in endless, time attack, training, challenge mode, online, 2p vs +- Online/local modes to verify assignments persist and don’t conflict with server expectations. +- Run `love ./testLauncher.lua` post-implementation. diff --git a/main.lua b/main.lua index bcd2dcb9..7f2c5f4e 100644 --- a/main.lua +++ b/main.lua @@ -187,6 +187,18 @@ function love.joystickreleased(joystick, button) inputManager:joystickReleased(joystick, button) end +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function love.joystickadded(joystick) + GAME:onJoystickAdded(joystick) +end + +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function love.joystickremoved(joystick) + inputManager:onJoystickRemoved(joystick) +end + -- Handle a touch press -- Note we are specifically not implementing this because mousepressed above handles mouse and touch -- function love.touchpressed(id, x, y, dx, dy, pressure) diff --git a/testLauncher.lua b/testLauncher.lua index 53d23c1c..2029e388 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -93,6 +93,8 @@ local allTests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "client.tests.StackGraphicsTests", + "client.tests.InputConfigurationTests", + "client.tests.DiscreteImageSliderTests", "client.tests.PlayerSettingsTests", } From c136956459f1cc6e35d4a281552c9e0a45c39635 Mon Sep 17 00:00:00 2001 From: Endaris Date: Tue, 13 Jan 2026 12:22:48 +0100 Subject: [PATCH 03/30] rename StartUp to BootScene --- client/src/Game.lua | 4 ++-- client/src/scenes/{StartUp.lua => BootScene.lua} | 16 ++++++++-------- client/src/scenes/SceneCoordinator.lua | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) rename client/src/scenes/{StartUp.lua => BootScene.lua} (92%) diff --git a/client/src/Game.lua b/client/src/Game.lua index 3f05bb2c..bbec365b 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -24,7 +24,7 @@ local handleShortcuts = require("client.src.Shortcuts") local Player = require("client.src.Player") local GameModes = require("common.data.GameModes") local NetClient = require("client.src.network.NetClient") -local StartUp = require("client.src.scenes.StartUp") +local BootScene = require("client.src.scenes.BootScene") local SoundController = require("client.src.music.SoundController") require("client.src.BattleRoom") local prof = require("common.lib.zoneProfiler") @@ -147,7 +147,7 @@ function Game:load() self:setupInputSignals() self.navigationStack = NavigationStack({}) - self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) + self.navigationStack:push(BootScene({setupRoutine = self.setupRoutine})) -- Add navigation stack to root UI self.uiRoot:addChild(self.navigationStack) diff --git a/client/src/scenes/StartUp.lua b/client/src/scenes/BootScene.lua similarity index 92% rename from client/src/scenes/StartUp.lua rename to client/src/scenes/BootScene.lua index e345a20c..deaabfe8 100644 --- a/client/src/scenes/StartUp.lua +++ b/client/src/scenes/BootScene.lua @@ -6,7 +6,7 @@ local logger = require("common.lib.logger") local fileUtils = require("client.src.FileUtils") local ModLoader = require("client.src.mods.ModLoader") -local StartUp = class(function(scene, sceneParams) +local BootScene = class(function(scene, sceneParams) scene.migrationRoutine = coroutine.create(scene.migrate) scene.setupRoutine = coroutine.create(sceneParams.setupRoutine) scene.message = "Startup" @@ -24,9 +24,9 @@ local StartUp = class(function(scene, sceneParams) love.graphics.setFont(GraphicsUtil.getGlobalFontWithSize(GraphicsUtil.fontSize + 10)) end, Scene) -StartUp.name = "StartUp" +BootScene.name = "BootScene" -function StartUp:updateSelf(dt) +function BootScene:updateSelf(dt) if self.migrationPath then local success, status = coroutine.resume(self.migrationRoutine, self) if success then @@ -57,7 +57,7 @@ function StartUp:updateSelf(dt) end end -function StartUp:drawLoadingString(loadingString) +function BootScene:drawLoadingString(loadingString) local textHeight = 40 local x = 0 local y = consts.CANVAS_HEIGHT / 2 - textHeight / 2 @@ -65,11 +65,11 @@ function StartUp:drawLoadingString(loadingString) love.graphics.printf(loadingString, x, y, consts.CANVAS_WIDTH, "center", 0, 1) end -function StartUp:drawSelf() +function BootScene:drawSelf() self:drawLoadingString(self.message) end -function StartUp:checkIfMigrationIsPossible() +function BootScene:checkIfMigrationIsPossible() local loveMajor = love.getVersion() if loveMajor < 12 then return false @@ -102,7 +102,7 @@ function StartUp:checkIfMigrationIsPossible() end end -function StartUp:migrate() +function BootScene:migrate() fileUtils.recursiveCopy("oldInstall", "", true) love.filesystem.unmountFullPath(self.migrationPath) self.migrationPath = nil @@ -120,4 +120,4 @@ function StartUp:migrate() love.load() end -return StartUp +return BootScene diff --git a/client/src/scenes/SceneCoordinator.lua b/client/src/scenes/SceneCoordinator.lua index e0e94cc3..6d21d0d9 100644 --- a/client/src/scenes/SceneCoordinator.lua +++ b/client/src/scenes/SceneCoordinator.lua @@ -29,7 +29,7 @@ function SceneCoordinator:onUnconfiguredJoystickAdded(joystick) end end --- Called when the StartUp scene completes asset loading +-- Called when the BootScene completes asset loading -- Begins the setup flow sequence function SceneCoordinator:handleStartupComplete() self.startupComplete = true From 86feea3d3a1cfccf790cced4b5b3ffe3e8a86f3e Mon Sep 17 00:00:00 2001 From: Endaris Date: Tue, 13 Jan 2026 12:52:22 +0100 Subject: [PATCH 04/30] replace triggerNextScene pattern with a simple conditional push sequence within the BootScene --- client/src/scenes/BootScene.lua | 30 ++++++- client/src/scenes/DiscordCommunitySetup.lua | 5 +- client/src/scenes/InputConfigMenu.lua | 7 +- client/src/scenes/LanguageSelectSetup.lua | 5 +- client/src/scenes/Scene.lua | 4 - client/src/scenes/SceneCoordinator.lua | 86 --------------------- client/src/scenes/TitleScreen.lua | 3 +- 7 files changed, 33 insertions(+), 107 deletions(-) diff --git a/client/src/scenes/BootScene.lua b/client/src/scenes/BootScene.lua index deaabfe8..dbcf06d4 100644 --- a/client/src/scenes/BootScene.lua +++ b/client/src/scenes/BootScene.lua @@ -50,9 +50,33 @@ function BootScene:updateSelf(dt) if coroutine.status(self.setupRoutine) == "dead" then love.graphics.setFont(GraphicsUtil.getGlobalFont()) - -- Delegate to SceneCoordinator to handle initial scene and setup flow - local SceneCoordinator = require("client.src.scenes.SceneCoordinator") - SceneCoordinator.handleStartupComplete(SceneCoordinator) + + -- we need the late require for all scenes here because localization is only initialized by the coroutine and all scenes depend on it being loaded + if themes[config.theme].images.bg_title then + GAME.navigationStack:replace(require("client.src.scenes.TitleScreen")()) + else + GAME.navigationStack:replace(require("client.src.scenes.MainMenu")()) + end + + -- scenes that are displayed before anything else on either first startup or if a new input device was found + -- they are just pushed on top and will pop off as the player works through them until the regular game start is left + + local input = require("client.src.inputManager") + + if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() then + local InputConfigMenu = require("client.src.scenes.InputConfigMenu") + GAME.navigationStack:push(InputConfigMenu({})) + end + + if not config.discordCommunityShown then + local DiscordCommunitySetup = require("client.src.scenes.DiscordCommunitySetup") + GAME.navigationStack:push(DiscordCommunitySetup({})) + end + + if not config.language_code then + local LanguageSelectSetup = require("client.src.scenes.LanguageSelectSetup") + GAME.navigationStack:push(LanguageSelectSetup({})) + end end end end diff --git a/client/src/scenes/DiscordCommunitySetup.lua b/client/src/scenes/DiscordCommunitySetup.lua index ea8930a0..9da350b1 100644 --- a/client/src/scenes/DiscordCommunitySetup.lua +++ b/client/src/scenes/DiscordCommunitySetup.lua @@ -8,9 +8,6 @@ local InputConfigMenu = require("client.src.scenes.InputConfigMenu") local logger = require("common.lib.logger") local DiscordCommunitySetup = class(function(self, sceneParams) - assert(sceneParams, "DiscordCommunitySetup requires sceneParams") - assert(sceneParams.triggerNextScene, "DiscordCommunitySetup requires triggerNextScene callback") - self.music = "main" local titleFontSize = 28 @@ -96,7 +93,7 @@ local DiscordCommunitySetup = class(function(self, sceneParams) GAME.theme:playValidationSfx() config.discordCommunityShown = true write_conf_file() - self.triggerNextScene() + GAME.navigationStack:pop() end) contentStack:addElement(ui.UiElement({ diff --git a/client/src/scenes/InputConfigMenu.lua b/client/src/scenes/InputConfigMenu.lua index 4c12f96f..cde71540 100644 --- a/client/src/scenes/InputConfigMenu.lua +++ b/client/src/scenes/InputConfigMenu.lua @@ -246,11 +246,8 @@ function InputConfigMenu:createExitMenuFunction() if inputManager.hasUnsavedChanges then inputManager:saveInputConfigurationMappings() end - if self.triggerNextScene then - self.triggerNextScene() - else - GAME.navigationStack:pop() - end + + GAME.navigationStack:pop() end end diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua index a4731cf8..768f0940 100644 --- a/client/src/scenes/LanguageSelectSetup.lua +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -7,9 +7,6 @@ local save = require("client.src.save") local logger = require("common.lib.logger") local LanguageSelectSetup = class(function(self, sceneParams) - assert(sceneParams, "LanguageSelectSetup requires sceneParams") - assert(sceneParams.triggerNextScene, "LanguageSelectSetup requires triggerNextScene callback") - self.music = "main" self:load(sceneParams) end, Scene) @@ -57,7 +54,7 @@ function LanguageSelectSetup:createLanguageMenu() config.language_code = language.code GAME:setLanguage(language.code) write_conf_file() - self.triggerNextScene() + GAME.navigationStack:pop() end)) end diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index bd64d66b..3d0e11ef 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -17,7 +17,6 @@ local DebugSettings = require("client.src.debug.DebugSettings") ---@field music sceneMusic ---@field fallbackMusic sceneMusic ---@field keepMusic boolean ----@field triggerNextScene function? ---@overload fun(sceneParams: table): Scene local Scene = class( ---@param self Scene @@ -37,9 +36,6 @@ local Scene = class( -- the scene can alternatively specify it wants to keep the music that is currently playing -- if kept at false, the music will always change at scene switch self.keepMusic = false - -- callback provided by scene creator to trigger the next scene - -- scenes should call this when they complete their purpose - self.triggerNextScene = sceneParams.triggerNextScene end ) diff --git a/client/src/scenes/SceneCoordinator.lua b/client/src/scenes/SceneCoordinator.lua index 6d21d0d9..74cc5a62 100644 --- a/client/src/scenes/SceneCoordinator.lua +++ b/client/src/scenes/SceneCoordinator.lua @@ -1,11 +1,3 @@ -local logger = require("common.lib.logger") -local input = require("client.src.inputManager") -local TitleScreen = require("client.src.scenes.TitleScreen") -local MainMenu = require("client.src.scenes.MainMenu") -local ModLoader = require("client.src.mods.ModLoader") -local ModValidationScene = require("client.src.scenes.ModValidationScene") -local LanguageSelectSetup = require("client.src.scenes.LanguageSelectSetup") -local DiscordCommunitySetup = require("client.src.scenes.DiscordCommunitySetup") local InputConfigMenu = require("client.src.scenes.InputConfigMenu") ---@class SceneCoordinator @@ -29,82 +21,4 @@ function SceneCoordinator:onUnconfiguredJoystickAdded(joystick) end end --- Called when the BootScene completes asset loading --- Begins the setup flow sequence -function SceneCoordinator:handleStartupComplete() - self.startupComplete = true - - if themes[config.theme].images.bg_title then - GAME.navigationStack:replace(TitleScreen({ - triggerNextScene = function() - self:handleTitleScreenComplete() - end - })) - else - self:handleTitleScreenComplete() - end - - if next(ModLoader.invalidMods) then - GAME.navigationStack:replace(ModValidationScene()) - end -end - -function SceneCoordinator:handleTitleScreenComplete() - self:continueSetupFlow() -end - -function SceneCoordinator:continueSetupFlow() - - -- Check language selection - if not config.language_code then - self:showLanguageSelect() - return true - end - - -- Check Discord community welcome - if not config.discordCommunityShown then - self:showDiscordWelcome() - return true - end - - -- Check for unconfigured joysticks - if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() then - self:showInputConfig() - return true - end - - GAME.navigationStack:replace(MainMenu({})) - return true -end - --- Shows the language selection scene with completion callback -function SceneCoordinator:showLanguageSelect() - local scene = LanguageSelectSetup({ - triggerNextScene = function() - self:continueSetupFlow() - end - }) - GAME.navigationStack:replace(scene) -end - --- Shows the Discord welcome scene with completion callback -function SceneCoordinator:showDiscordWelcome() - local scene = DiscordCommunitySetup({ - triggerNextScene = function() - self:continueSetupFlow() - end - }) - GAME.navigationStack:replace(scene) -end - --- Shows the input configuration scene with completion callback -function SceneCoordinator:showInputConfig() - local scene = InputConfigMenu({ - triggerNextScene = function() - self:continueSetupFlow() - end - }) - GAME.navigationStack:replace(scene) -end - return SceneCoordinator diff --git a/client/src/scenes/TitleScreen.lua b/client/src/scenes/TitleScreen.lua index 93e37ace..05fc9897 100644 --- a/client/src/scenes/TitleScreen.lua +++ b/client/src/scenes/TitleScreen.lua @@ -4,6 +4,7 @@ local input = require("client.src.inputManager") local tableUtils = require("common.lib.tableUtils") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local MainMenu = require("client.src.scenes.MainMenu") -- The title screen scene local TitleScreen = class( @@ -29,7 +30,7 @@ function TitleScreen:update(dt) local keyPressed = tableUtils.trueForAny(input.allKeys.isDown, function(key) return key end) if love.mouse.isDown(1, 2, 3) or #love.touch.getTouches() > 0 or keyPressed then GAME.theme:playValidationSfx() - self.triggerNextScene() + GAME.navigationStack:replace(MainMenu()) end end From c1a9f3618d5846e8fa84231a64788bbc30c75d03 Mon Sep 17 00:00:00 2001 From: Endaris Date: Tue, 13 Jan 2026 13:49:35 +0100 Subject: [PATCH 05/30] move code for triggering InputConfigMenu on top into Game and remove SceneCoordinator --- client/src/Game.lua | 17 +++++++++-------- client/src/config.lua | 4 ++++ client/src/inputManager.lua | 5 ++++- client/src/scenes/SceneCoordinator.lua | 24 ------------------------ 4 files changed, 17 insertions(+), 33 deletions(-) delete mode 100644 client/src/scenes/SceneCoordinator.lua diff --git a/client/src/Game.lua b/client/src/Game.lua index bbec365b..45663837 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -25,6 +25,7 @@ local Player = require("client.src.Player") local GameModes = require("common.data.GameModes") local NetClient = require("client.src.network.NetClient") local BootScene = require("client.src.scenes.BootScene") +local InputConfigMenu = require("client.src.scenes.InputConfigMenu") local SoundController = require("client.src.music.SoundController") require("client.src.BattleRoom") local prof = require("common.lib.zoneProfiler") @@ -34,7 +35,6 @@ local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") local DebugSettings = require("client.src.debug.DebugSettings") -local SceneCoordinator = require("client.src.scenes.SceneCoordinator") local TextButton = require("client.src.ui.TextButton") local OverlayContainer = require("client.src.ui.OverlayContainer") local DebugMenu = require("client.src.debug.DebugMenu") @@ -56,7 +56,7 @@ end ---@field globalCanvas love.graphics.Texture ---@field muteSound boolean ---@field rich_presence table ----@field input table +---@field input InputManager ---@field backgroundImage table ---@field backgroundColor number[] ---@field updater table? @@ -144,8 +144,6 @@ function Game:load() inputManager:load() - self:setupInputSignals() - self.navigationStack = NavigationStack({}) self.navigationStack:push(BootScene({setupRoutine = self.setupRoutine})) @@ -373,12 +371,15 @@ function Game:handleResize(newWidth, newHeight) end function Game:onJoystickAdded(joystick) - self.input:onJoystickAdded(joystick) + local isNotConfigured = self.input:onJoystickAdded(joystick) + if isNotConfigured and self.navigationStack.scenes[1].name ~= "BootScene" and config:initializationCompleted() and not self:hasOngoingMatch() then + -- Not critically occupied, so push the InputConfigMenu on top + GAME.navigationStack:push(InputConfigMenu({})) + end end --- Setup signal listener for unconfigured joysticks -function Game:setupInputSignals() - self.input:connectSignal("unconfiguredJoystickAdded", SceneCoordinator, SceneCoordinator.onUnconfiguredJoystickAdded) +function Game:hasOngoingMatch() + return not not (GAME.battleRoom and GAME.battleRoom.match ~= nil) end -- Called every few fractions of a second to update the game diff --git a/client/src/config.lua b/client/src/config.lua index cafb3ab7..3d770405 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -134,6 +134,10 @@ config = { discordCommunityShown = false, } +function config:initializationCompleted() + return self.discordCommunityShown and self.language_code +end + -- writes to the "conf.json" file function write_conf_file() pcall( diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index b450c89f..aa16acfd 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -19,6 +19,7 @@ require("client.src.input.JoystickProvider") -- inputConfigurations: raw key inputs mapped to internal aliases for that configuration -- base (top level): the union of all inputConfigurations not already claimed by a player -- mouse: all mouse buttons and the position of the mouse +---@class InputManager local inputManager = { isDown = {}, isPressed = {}, @@ -80,6 +81,8 @@ function inputManager:keyReleased(key, scancode) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end +---@param joystick love.Joystick +---@return boolean? isNotConfigured function inputManager:onJoystickAdded(joystick) joystickManager:registerJoystick(joystick) local unconfiguredJoysticks = self:updateUnconfiguredJoysticksCache() @@ -88,7 +91,7 @@ function inputManager:onJoystickAdded(joystick) for _, unconfiguredJoystick in ipairs(unconfiguredJoysticks) do if unconfiguredJoystick == joystick then self:emitSignal("unconfiguredJoystickAdded", joystick) - break + return true end end end diff --git a/client/src/scenes/SceneCoordinator.lua b/client/src/scenes/SceneCoordinator.lua deleted file mode 100644 index 74cc5a62..00000000 --- a/client/src/scenes/SceneCoordinator.lua +++ /dev/null @@ -1,24 +0,0 @@ -local InputConfigMenu = require("client.src.scenes.InputConfigMenu") - ----@class SceneCoordinator ----@field joystickAdded boolean -local SceneCoordinator = { - startupComplete = false -} - --- Called when an unconfigured joystick is added --- Pushes InputConfigMenu if we're not in the middle of a game -function SceneCoordinator:onUnconfiguredJoystickAdded(joystick) - -- Check if we're in a game (BattleRoom exists and has an active match) - local inGame = false - if GAME.battleRoom and GAME.battleRoom.match ~= nil then - inGame = true - end - - if self.startupComplete and not inGame then - -- Not in a game, so push the InputConfigMenu - GAME.navigationStack:push(InputConfigMenu({})) - end -end - -return SceneCoordinator From fc5eb60c174e47d9b71c74809d4965afad239383 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 13:16:16 +0100 Subject: [PATCH 06/30] move unregister process for joysticks into joystickManager remove return from updating the cache, just call the getter instead in the one location it is required --- client/src/inputManager.lua | 24 +++--------------------- common/lib/joystickManager.lua | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index b450c89f..574859cc 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -82,7 +82,8 @@ end function inputManager:onJoystickAdded(joystick) joystickManager:registerJoystick(joystick) - local unconfiguredJoysticks = self:updateUnconfiguredJoysticksCache() + self:updateUnconfiguredJoysticksCache() + local unconfiguredJoysticks = self:getUnconfiguredJoysticks() -- Check if the newly added joystick is unconfigured for _, unconfiguredJoystick in ipairs(unconfiguredJoysticks) do @@ -94,25 +95,7 @@ function inputManager:onJoystickAdded(joystick) end function inputManager:onJoystickRemoved(joystick) - -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID - -- the GUID is consistent across sessions - local guid = joystick:getGUID() - -- ID is a per-session identifier for each controller regardless of type - local id = joystick:getID() - - local vendorID, productID, productVersion = joystick:getDeviceInfo() - - logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) - - if joystickManager.guidsToJoysticks[guid] then - joystickManager.guidsToJoysticks[guid][id] = nil - - if tableUtils.length(joystickManager.guidsToJoysticks[guid]) == 0 then - joystickManager.guidsToJoysticks[guid] = nil - end - end - - joystickManager.devices[id] = nil + joystickManager:unregisterJoystick(joystick) self:updateUnconfiguredJoysticksCache() end @@ -765,7 +748,6 @@ function inputManager:updateUnconfiguredJoysticksCache() -- Update the cache self.unconfiguredJoysticksCache = unconfiguredJoysticks - return unconfiguredJoysticks end -- Gets a list of joysticks that don't have input configurations diff --git a/common/lib/joystickManager.lua b/common/lib/joystickManager.lua index 069a6aa0..5f6ecd27 100644 --- a/common/lib/joystickManager.lua +++ b/common/lib/joystickManager.lua @@ -170,4 +170,26 @@ function joystickManager:registerJoystick(joystick) joystickManager.devices[id] = device end +function joystickManager:unregisterJoystick(joystick) +-- GUID identifies the device type, 2 controllers of the same type will have a matching GUID + -- the GUID is consistent across sessions + local guid = joystick:getGUID() + -- ID is a per-session identifier for each controller regardless of type + local id = joystick:getID() + + local vendorID, productID, productVersion = joystick:getDeviceInfo() + + logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) + + if joystickManager.guidsToJoysticks[guid] then + joystickManager.guidsToJoysticks[guid][id] = nil + + if tableUtils.length(joystickManager.guidsToJoysticks[guid]) == 0 then + joystickManager.guidsToJoysticks[guid] = nil + end + end + + joystickManager.devices[id] = nil +end + return joystickManager \ No newline at end of file From 3018ccf1f6603beb8de5365dea242c5ab526d5ba Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 13:36:00 +0100 Subject: [PATCH 07/30] extract check for joystick being registered on inputs for more clarity --- client/src/inputManager.lua | 12 ++++++++++-- common/lib/joystickManager.lua | 15 ++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index 574859cc..ecd6d6ea 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -100,13 +100,21 @@ function inputManager:onJoystickRemoved(joystick) end function inputManager:joystickPressed(joystick, button) - joystickManager:registerJoystick(joystick) + if not joystickManager:isRegistered(joystick) then + -- always check and register to be sure, in rare cases joystickadded is not called or not called early enough + joystickManager:registerJoystick(joystick) + end + local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isDown[key] = KEY_CHANGE.DETECTED end function inputManager:joystickReleased(joystick, button) - joystickManager:registerJoystick(joystick) + if not joystickManager:isRegistered(joystick) then + -- always check and register to be sure, in rare cases joystickadded is not called or not called early enough + joystickManager:registerJoystick(joystick) + end + local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end diff --git a/common/lib/joystickManager.lua b/common/lib/joystickManager.lua index 5f6ecd27..39b395fc 100644 --- a/common/lib/joystickManager.lua +++ b/common/lib/joystickManager.lua @@ -40,6 +40,7 @@ local joystickHatToDirs = { rd = {"right", "down"} } +---@param joystick love.Joystick function joystickManager:getJoystickButtonName(joystick, button) return string.format("%s:%s:%s", joystick:getGUID(), joystickManager.guidsToJoysticks[joystick:getGUID()][joystick:getID()], button) end @@ -90,6 +91,7 @@ end -- end -- maps dpad dir to buttons +---@param joystick love.Joystick function joystickManager:getDPadState(joystick, hatIndex) local dir = joystick:getHat(hatIndex) local activeButtons = joystickHatToDirs[dir] @@ -101,12 +103,14 @@ function joystickManager:getDPadState(joystick, hatIndex) } end -function joystickManager:registerJoystick(joystick) - - if joystickManager.devices[joystick:getID()] then - return - end +---@param joystick love.Joystick +function joystickManager:isRegistered(joystick) + -- converting the joystick into a bool + return not not joystickManager.devices[joystick:getID()] +end +---@param joystick love.Joystick +function joystickManager:registerJoystick(joystick) -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID -- the GUID is consistent across sessions local guid = joystick:getGUID() @@ -170,6 +174,7 @@ function joystickManager:registerJoystick(joystick) joystickManager.devices[id] = device end +---@param joystick love.Joystick function joystickManager:unregisterJoystick(joystick) -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID -- the GUID is consistent across sessions From 8442931cd54b19b678fcacd0cb8d81539d13de45 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 13:38:10 +0100 Subject: [PATCH 08/30] rename write_key_file to writeKeyConfigurationToFile --- client/src/inputManager.lua | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index ecd6d6ea..e8ce8d70 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -455,14 +455,14 @@ function inputManager:getSaveKeyMap() return result end -function inputManager:write_key_file() +function inputManager:writeKeyConfigurationToFile() FileUtils.writeJson("", "keysV3.json", self:getSaveKeyMap()) self.hasUnsavedChanges = false end -- Saves input configuration mappings to disk function inputManager:saveInputConfigurationMappings() - self:write_key_file() + self:writeKeyConfigurationToFile() end @@ -610,7 +610,7 @@ function inputManager:changeKeyBindingOnInputConfiguration(inputConfiguration, k self:updateInputConfiguration(inputConfiguration) self:updateUnconfiguredJoysticksCache() if not skipSave then - self:write_key_file() + self:writeKeyConfigurationToFile() end end @@ -623,7 +623,7 @@ function inputManager:clearKeyBindingsOnInputConfiguration(inputConfiguration) self.hasUnsavedChanges = true self:updateInputConfiguration(inputConfiguration) self:updateUnconfiguredJoysticksCache() - self:write_key_file() + self:writeKeyConfigurationToFile() end function inputManager:setupDefaultKeyConfigurations() @@ -852,7 +852,7 @@ function inputManager:autoConfigureJoystick(joystick, shouldSave) self:updateUnconfiguredJoysticksCache() if shouldSave then - self:write_key_file() + self:writeKeyConfigurationToFile() end return configIndex end From ac73821e60fd1b1f772df49572daafeea7e12657 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 13:44:44 +0100 Subject: [PATCH 09/30] rename all local config variables to inputConfig to prevent confusion with the config global --- client/src/inputManager.lua | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index e8ce8d70..27b59802 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -520,8 +520,8 @@ function inputManager:importConfigurations(configurations) end end -- Update all cached properties after importing - for _, config in ipairs(self.inputConfigurations) do - config:updateCachedProperties() + for _, inputConfig in ipairs(self.inputConfigurations) do + inputConfig:updateCachedProperties() end self:updateAllDeviceNumbers() end @@ -558,21 +558,21 @@ end function inputManager:updateAllDeviceNumbers() local deviceTypeCounters = {} - for _, config in ipairs(self.inputConfigurations) do + for _, inputConfig in ipairs(self.inputConfigurations) do -- Only count non-empty configurations with bindings - if not config:isEmpty() and config.deviceType then - deviceTypeCounters[config.deviceType] = (deviceTypeCounters[config.deviceType] or 0) + 1 - config.deviceNumber = deviceTypeCounters[config.deviceType] + if not inputConfig:isEmpty() and inputConfig.deviceType then + deviceTypeCounters[inputConfig.deviceType] = (deviceTypeCounters[inputConfig.deviceType] or 0) + 1 + inputConfig.deviceNumber = deviceTypeCounters[inputConfig.deviceType] else - config.deviceNumber = nil + inputConfig.deviceNumber = nil end end end -- Updates a specific InputConfiguration when its bindings change ----@param config InputConfiguration Configuration to update -function inputManager:updateInputConfiguration(config) - config:update() +---@param inputConfig InputConfiguration Configuration to update +function inputManager:updateInputConfiguration(inputConfig) + inputConfig:update() self:updateAllDeviceNumbers() end @@ -583,10 +583,10 @@ function inputManager:clearButtonFromAllConfigs(buttonBinding) return end - for _, config in ipairs(self.inputConfigurations) do + for _, inputConfig in ipairs(self.inputConfigurations) do for _, keyName in ipairs(consts.KEY_NAMES) do - if config[keyName] == buttonBinding then - config[keyName] = nil + if inputConfig[keyName] == buttonBinding then + inputConfig[keyName] = nil end end end @@ -673,8 +673,8 @@ function inputManager:setupDefaultKeyConfigurations() end -- Update all cached properties after setting defaults - for _, config in ipairs(self.inputConfigurations) do - config:updateCachedProperties() + for _, inputConfig in ipairs(self.inputConfigurations) do + inputConfig:updateCachedProperties() end self:updateAllDeviceNumbers() end @@ -682,10 +682,10 @@ end ---@return table? Input configuration with active input, or nil function inputManager:detectActiveInputConfiguration() for i = 1, #self.inputConfigurations do - local config = self.inputConfigurations[i] + local inputConfig = self.inputConfigurations[i] for _, keyName in ipairs(consts.KEY_NAMES) do - if config.isDown and config.isDown[keyName] then - return config + if inputConfig.isDown and inputConfig.isDown[keyName] then + return inputConfig end end end @@ -720,10 +720,10 @@ end function inputManager:getConfiguredJoystickGuids() local configuredGuids = {} for i = 1, self.maxConfigurations do - local config = self.inputConfigurations[i] - if config then + local inputConfig = self.inputConfigurations[i] + if inputConfig then for _, keyName in ipairs(consts.KEY_NAMES) do - local keyMapping = config[keyName] + local keyMapping = inputConfig[keyName] if keyMapping and type(keyMapping) == "string" then -- Extract GUID from mapping format like "guid:id:button" local guid = keyMapping:match("^([^:]+):") @@ -838,15 +838,15 @@ function inputManager:autoConfigureJoystick(joystick, shouldSave) -- Only proceed if we got at least some mappings if next(basicMapping) then -- Ensure the configuration slot has all the keys we need - local config = self.inputConfigurations[configIndex] + local inputConfig = self.inputConfigurations[configIndex] for keyName, keyMapping in pairs(basicMapping) do - self:changeKeyBindingOnInputConfiguration(config, keyName, keyMapping, true) + self:changeKeyBindingOnInputConfiguration(inputConfig, keyName, keyMapping, true) end -- Make sure all required keys are set (fill any missing ones with nil to be explicit) for _, keyName in ipairs(consts.KEY_NAMES) do - if config[keyName] == nil and not basicMapping[keyName] then - self:changeKeyBindingOnInputConfiguration(config, keyName, nil, true) + if inputConfig[keyName] == nil and not basicMapping[keyName] then + self:changeKeyBindingOnInputConfiguration(inputConfig, keyName, nil, true) end end @@ -867,9 +867,9 @@ function inputManager:getAssignableDevices() local devices = {} -- Add all non-empty InputConfigurations - for _, config in ipairs(self.inputConfigurations) do - if not config:isEmpty() then - devices[#devices + 1] = config + for _, inputConfig in ipairs(self.inputConfigurations) do + if not inputConfig:isEmpty() then + devices[#devices + 1] = inputConfig end end From 9dd0443a5fba94447b90cbe304be761ff1f5f059 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 14:08:42 +0100 Subject: [PATCH 10/30] add some annotations for inputManager/InputConfiguration --- client/src/input/InputConfiguration.lua | 6 ++++-- client/src/inputManager.lua | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/client/src/input/InputConfiguration.lua b/client/src/input/InputConfiguration.lua index 63130e61..726fb4e0 100644 --- a/client/src/input/InputConfiguration.lua +++ b/client/src/input/InputConfiguration.lua @@ -4,6 +4,8 @@ local util = require("common.lib.util") local joystickManager = require("common.lib.joystickManager") require("client.src.input.JoystickProvider") +---@alias InputDeviceType ("keyboard" | "controller" | "touch" | nil) + -- Represents a single input configuration slot with key bindings ---@class InputConfiguration ---@field index number Configuration slot number (1-8) @@ -15,7 +17,7 @@ require("client.src.input.JoystickProvider") ---@field isPressedWithRepeat function ---@field joystickProvider JoystickProvider ---@field id string Unique identifier (e.g., "config_1") ----@field deviceType string? Device type ("keyboard", "controller", "touch", or nil if empty) +---@field deviceType InputDeviceType Device type ("keyboard", "controller", "touch", or nil if empty) ---@field deviceName string? Human-readable device name ---@field controllerImageVariant string? Controller icon variant ---@field deviceNumber number? Device count of this type (e.g., 2nd keyboard) @@ -107,7 +109,7 @@ function InputConfiguration:parseControllerBinding(keyName) end -- Determine device type based on the first available binding ----@return "keyboard"|"controller"|"touch"|nil deviceType Type of device or nil if no bindings +---@return InputDeviceType deviceType Type of device or nil if no bindings function InputConfiguration:getDeviceType() if self:isEmpty() then return nil diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index 27b59802..2beeb811 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -25,6 +25,7 @@ local inputManager = { isUp = {}, allKeys = {isDown = {}, isPressed = {}, isUp = {}}, mouse = {isDown = {}, isPressed = {}, isUp = {}, x = 0, y = 0}, + ---@type InputConfiguration[] inputConfigurations = {}, maxConfigurations = 8, hasUnsavedChanges = false, @@ -526,6 +527,8 @@ function inputManager:importConfigurations(configurations) self:updateAllDeviceNumbers() end +---@param player Player +---@param inputConfiguration InputConfiguration function inputManager:claimConfiguration(player, inputConfiguration) if inputConfiguration.claimed and inputConfiguration.player ~= player then error("Trying to assign input configuration to player " .. player.playerNumber .. @@ -540,6 +543,8 @@ function inputManager:claimConfiguration(player, inputConfiguration) return inputConfiguration end +---@param player Player +---@param inputConfiguration InputConfiguration function inputManager:releaseConfiguration(player, inputConfiguration) if not inputConfiguration.claimed then error("Trying to release an unclaimed inputConfiguration") @@ -679,7 +684,7 @@ function inputManager:setupDefaultKeyConfigurations() self:updateAllDeviceNumbers() end ----@return table? Input configuration with active input, or nil +---@return InputConfiguration? Input configuration with active input, or nil function inputManager:detectActiveInputConfiguration() for i = 1, #self.inputConfigurations do local inputConfig = self.inputConfigurations[i] From c202411094586219505bd896d2b6fb8bc16572c0 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 14:17:09 +0100 Subject: [PATCH 11/30] change checkForUnassignedConfigurationInputs to directly take a player array as its argument --- client/src/BattleRoom.lua | 1 + client/src/inputManager.lua | 8 ++++---- client/src/scenes/components/InputDeviceOverlay.lua | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 30661273..790b6d20 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -448,6 +448,7 @@ function BattleRoom:restoreInputConfigurations() end -- Gets all local human players in the battle room +---@return Player[] localHumanPlayers function BattleRoom:getLocalHumanPlayers() local localPlayers = {} for _, player in ipairs(self.players) do diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index 2beeb811..2a979358 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -698,10 +698,10 @@ function inputManager:detectActiveInputConfiguration() return nil end ----@param battleRoom BattleRoom? +---@param localHumanPlayers Player[] ---@return boolean True if an unassigned configuration has active input -function inputManager:checkForUnassignedConfigurationInputs(battleRoom) - if not battleRoom then +function inputManager:checkForUnassignedConfigurationInputs(localHumanPlayers) + if #localHumanPlayers == 0 then return false end @@ -711,7 +711,7 @@ function inputManager:checkForUnassignedConfigurationInputs(battleRoom) end local assignedConfigs = {} - for _, player in ipairs(battleRoom:getLocalHumanPlayers()) do + for _, player in ipairs(localHumanPlayers) do if player.inputConfiguration then assignedConfigs[player.inputConfiguration] = true end diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua index f718e990..3759a898 100644 --- a/client/src/scenes/components/InputDeviceOverlay.lua +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -442,7 +442,7 @@ end ---@param dt number Delta time in seconds function InputDeviceOverlay:updateSelf(dt) if not self.active then - if not self.battleRoom.spectating and GAME.input:checkForUnassignedConfigurationInputs(self.battleRoom) then + if not self.battleRoom.spectating and GAME.input:checkForUnassignedConfigurationInputs(self.battleRoom:getLocalHumanPlayers()) then self.battleRoom:releaseAllLocalAssignments() end From 2878e130554c8ab9dd8d2625661a2ed6c51bd797 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 14:42:19 +0100 Subject: [PATCH 12/30] proxy joystickRemoved through Game and fix extra encode for config write --- client/src/Game.lua | 4 ++++ client/src/config.lua | 2 +- main.lua | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/client/src/Game.lua b/client/src/Game.lua index 3f05bb2c..e9b40a53 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -376,6 +376,10 @@ function Game:onJoystickAdded(joystick) self.input:onJoystickAdded(joystick) end +function Game:onJoystickRemoved(joystick) + self.input:onJoystickRemoved(joystick) +end + -- Setup signal listener for unconfigured joysticks function Game:setupInputSignals() self.input:connectSignal("unconfiguredJoystickAdded", SceneCoordinator, SceneCoordinator.onUnconfiguredJoystickAdded) diff --git a/client/src/config.lua b/client/src/config.lua index cafb3ab7..6b6a6f2d 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -140,7 +140,7 @@ config = { function() local encoded = json.encode(config) ---@cast encoded string - love.filesystem.write("conf.json", json.encode(config)) + love.filesystem.write("conf.json", encoded) end ) end diff --git a/main.lua b/main.lua index 7f2c5f4e..45a34938 100644 --- a/main.lua +++ b/main.lua @@ -196,7 +196,7 @@ end -- Intentional override ---@diagnostic disable-next-line: duplicate-set-field function love.joystickremoved(joystick) - inputManager:onJoystickRemoved(joystick) + GAME:onJoystickRemoved(joystick) end -- Handle a touch press From 188f6ca97b4ec5b3463132da4c1021fbd8db20aa Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 14:44:05 +0100 Subject: [PATCH 13/30] rename UiElement functions for generating debug strings --- client/src/ui/UIElement.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/ui/UIElement.lua b/client/src/ui/UIElement.lua index 0ab2c398..a86166f8 100644 --- a/client/src/ui/UIElement.lua +++ b/client/src/ui/UIElement.lua @@ -271,7 +271,7 @@ end ---Returns a formatted tree of this element and all children with class name, TYPE, and root position ---@return string -function UIElement:debugTree() +function UIElement:toStringWithDepth() local function getElementInfo(element, depth) local indent = string.rep(" ", depth) local typeStr = element.TYPE and (" [" .. element.TYPE .. "]") or "" @@ -292,7 +292,7 @@ end ---Returns a formatted list of this element and its direct children only (non-recursive) ---@return string -function UIElement:debugChildren() +function UIElement:toString() local typeStr = self.TYPE and (" [" .. self.TYPE .. "]") or "" local x, y = self:getScreenPos() local lines = {string.format("%s @ (%.1f, %.1f)", typeStr, x, y)} From 4f4e1d174cec7d14a3e38d6af648c90ff61fbf1f Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 14:44:41 +0100 Subject: [PATCH 14/30] remove obsolete require from Match --- common/engine/Match.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 09516308..cb7433fb 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -12,7 +12,6 @@ local LegacyPanelSource = require("common.compatibility.LegacyPanelSource") local InputCompression = require("common.data.InputCompression") local ReplayV3 = require("common.data.ReplayV3") local MatchRules = require("common.data.MatchRules") -local DebugSettings = require("client.src.debug.DebugSettings") ---@class Match ---@field stacks (Stack | SimulatedStack)[] The stacks to run as part of the match From a9c377c11101becfce3f9fdf2a8bed8fd5418d24 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 14 Jan 2026 17:46:02 +0100 Subject: [PATCH 15/30] readd ModValidationScene (forgot about it) --- client/src/scenes/BootScene.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/src/scenes/BootScene.lua b/client/src/scenes/BootScene.lua index dbcf06d4..5f32ee2a 100644 --- a/client/src/scenes/BootScene.lua +++ b/client/src/scenes/BootScene.lua @@ -58,6 +58,10 @@ function BootScene:updateSelf(dt) GAME.navigationStack:replace(require("client.src.scenes.MainMenu")()) end + if next(ModLoader.invalidMods) then + GAME.navigationStack:push(require("client.src.scenes.ModValidationScene")()) + end + -- scenes that are displayed before anything else on either first startup or if a new input device was found -- they are just pushed on top and will pop off as the player works through them until the regular game start is left From 789750674a1bc357e2bbcf75a1022523d35a78e2 Mon Sep 17 00:00:00 2001 From: Endaris Date: Wed, 21 Jan 2026 16:40:12 +0100 Subject: [PATCH 16/30] update german localization and change wording for input device selection from "press" to "hold" as short presses don't select the device --- client/assets/localization.csv | 12 ++++++------ client/src/scenes/components/InputDeviceOverlay.lua | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/assets/localization.csv b/client/assets/localization.csv index 2c51d98f..b66530d0 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -640,26 +640,26 @@ mm_2_time,,2P time attack,2J contre la montre,2J contra o tempo,2P スコアア op_about_puzzles,,About custom puzzles,À propos des puzzles personnalisés,Sobre quebra-cabeças personalizados,カスタムパズルについて,Acerca de rompecabezas personalizados,How to: Eigene Puzzles erstellen,A proposito dei puzzle personalizzabili,เกี่ยวกับ custom puzzles discord_welcome_title,Title for Discord community welcome screen,Welcome to Panel Attack!,Bienvenue dans Panel Attack!,Bem-vindo ao Panel Attack!,パネルアタックへようこそ!,¡Bienvenido a Panel Attack!,Willkommen bei Panel Attack!,Benvenuti in Panel Attack!,ยินดีต้อนรับสู่ Panel Attack! discord_message_line1,First line of Discord welcome message,"Join our Discord to meet players, share ideas, and talk all things Panel Attack.","Rejoignez notre Discord pour rencontrer des joueurs, partager vos idées et discuter de tout ce qui concerne Panel Attack.","Junte-se ao nosso Discord para conhecer jogadores, compartilhar ideias e falar tudo sobre Panel Attack.","Discordに参加してプレイヤーと出会い、アイデアを共有し、パネルアタックのすべてについて語り合いましょう。","Únete a nuestro Discord para conocer jugadores, compartir ideas y hablar de todo lo relacionado con Panel Attack.","Tritt unserem Discord bei, lerne Spieler kennen, teile Ideen und sprich über alles rund um Panel Attack.","Unisciti al nostro Discord per conoscere giocatori, condividere idee e parlare di tutto ciò che riguarda Panel Attack.","เข้าร่วม Discord ของเราเพื่อพบปะผู้เล่น แชร์ไอเดีย และพูดคุยทุกเรื่องเกี่ยวกับ Panel Attack" -discord_message_line2,Second line of Discord welcome message,"Show off your mods and art, rediscover classic characters and stages, and create exciting new ones.","Présentez vos mods et vos créations, redécouvrez des personnages et des stages classiques, et créez-en de nouveaux passionnants.","Mostre seus mods e artes, redescubra personagens e fases clássicas e crie novidades empolgantes.","自分のMODやアートを披露し、クラシックなキャラクターやステージを再発見し、ワクワクする新しい作品を作りましょう。","Presume tus mods y arte, redescubre personajes y escenarios clásicos y crea otros nuevos emocionantes.","Zeig deine Mods und Kunst, entdecke klassische Charaktere und Bühnen neu und erschaffe spannende neue Kreationen.","Mostra i tuoi mod e le tue opere, riscopri personaggi e stage classici e crea nuove emozionanti creazioni.","โชว์ม็อดและงานศิลป์ของคุณ ค้นพบตัวละครและฉากคลาสสิกอีกครั้ง และสร้างสิ่งใหม่ที่น่าตื่นเต้น" +discord_message_line2,Second line of Discord welcome message,"Show off your mods and art, rediscover classic characters and stages, and create exciting new ones.","Présentez vos mods et vos créations, redécouvrez des personnages et des stages classiques, et créez-en de nouveaux passionnants.","Mostre seus mods e artes, redescubra personagens e fases clássicas e crie novidades empolgantes.","自分のMODやアートを披露し、クラシックなキャラクターやステージを再発見し、ワクワクする新しい作品を作りましょう。","Presume tus mods y arte, redescubre personajes y escenarios clásicos y crea otros nuevos emocionantes.","Zeige deine Mods und Kunst, entdecke bekannte Charaktere und Arenen neu und erschaffe deine eigenen.","Mostra i tuoi mod e le tue opere, riscopri personaggi e stage classici e crea nuove emozionanti creazioni.","โชว์ม็อดและงานศิลป์ของคุณ ค้นพบตัวละครและฉากคลาสสิกอีกครั้ง และสร้างสิ่งใหม่ที่น่าตื่นเต้น" discord_message_line3,Third line of Discord welcome message,"Compete in monthly tournaments and join events for players of every skill level!","Participez à des tournois mensuels et rejoignez des événements pour tous les niveaux !","Compita em torneios mensais e participe de eventos para jogadores de todos os níveis!","毎月のトーナメントで競い合い、あらゆるスキルレベルのプレイヤー向けのイベントに参加しましょう!","Compite en torneos mensuales y únete a eventos para jugadores de todos los niveles.","Tritt in monatlichen Turnieren an und nimm an Events für Spielende aller Fähigkeitsstufen teil!","Competi nei tornei mensili e partecipa a eventi per giocatori di ogni livello di abilità!","เข้าร่วมแข่งขันในทัวร์นาเมนต์รายเดือนและกิจกรรมสำหรับผู้เล่นทุกระดับฝีมือ!" discord_join_link,Button to join Discord server,Join Discord Server,Rejoindre le serveur Discord,Entrar no servidor Discord,Discordサーバーに参加,Unirse al servidor Discord,Discord-Server beitreten,Unisciti al server Discord,เข้าร่วมเซิร์ฟเวอร์ Discord next_button,Text shown to continue to next screen,Next,Suivant,Próximo,次へ,Siguiente,Weiter,Avanti,ถัดไป input_config_new_controller,Message shown when a new controller is detected and configured,"Input configurations added, please verify the button mappings, especially the Confirm, Cancel and Raise keys",Nouvelle manette détectée ! Veuillez vérifier les mappages des boutons.,Novo controlador detectado! Verifique os mapeamentos dos botões.,新しいコントローラーが検出されました!ボタンマッピングを確認してください。,¡Nuevo controlador detectado! Por favor verifique los mapeos de botones.,Neuer Controller erkannt! Bitte überprüfe die Tastenbelegung.,Nuovo controller rilevato! Verifica le mappature dei pulsanti.,ตรวจพบจอยใหม่! กรุณาตรวจสอบการตั้งค่าปุ่ม -swap1,Input configuration label for first swap button,Swap 1,Échanger 1,Trocar 1,スワップ1,Intercambiar 1,Tausch 1,Scambia 1,สลับ 1 -swap2,Input configuration label for second swap button,Swap 2,Échanger 2,Trocar 2,スワップ2,Intercambiar 2,Tausch 2,Scambia 2,สลับ 2 +swap1,Input configuration label for first swap button,Swap 1 / Select / Confirm,Échanger 1,Trocar 1,スワップ1,Intercambiar 1,Tausch 1 / Auswählen / Bestätigen,Scambia 1,สลับ 1 +swap2,Input configuration label for second swap button,Swap 2 / Back,Échanger 2,Trocar 2,スワップ2,Intercambiar 2,Tausch 2 / Zurück,Scambia 2,สลับ 2 raise1,Input configuration label for first raise button,Raise 1,Monter 1,Levantar 1,上げる1,Elevar 1,Heben 1,Alza 1,เลื่อน 1 raise2,Input configuration label for second raise button,Raise 2,Monter 2,Levantar 2,上げる2,Elevar 2,Heben 2,Alza 2,เลื่อน 2 tauntup,Input configuration label for taunt up button,Taunt Up,Provocation Haut,Provocação Cima,挑発上,Burla Arriba,Spott Oben,Provocazione Su,เยาะเย้ยขึ้น tauntdown,Input configuration label for taunt down button,Taunt Down,Provocation Bas,Provocação Baixo,挑発下,Burla Abajo,Spott Unten,Provocazione Giù,เยาะเย้ยลง change_input_device,Label for changing input device,Change Input Device,Changer de périphérique d'entrée,Alterar dispositivo de entrada,入力デバイスを変更,Cambiar dispositivo de entrada,Eingabegerät ändern,Cambia dispositivo di input,เปลี่ยนอุปกรณ์ควบคุม -press_button_device,Prompt to press a button on desired input device,Press a button on the device you want to use,Appuyez sur un bouton du périphérique que vous souhaitez utiliser,Pressione um botão no dispositivo que deseja usar,使用したいデバイスのボタンを押してください,Presiona un botón en el dispositivo que quieres usar,Drücke eine Taste auf dem Gerät\, das du verwenden möchtest,Premi un pulsante sul dispositivo che vuoi usare,กดปุ่มบนอุปกรณ์ที่คุณต้องการใช้ +hold_button_device,Prompt to press a button on desired input device,Hold a button on the device you want to use,Maintenez enfoncé un bouton sur l'appareil que vous souhaitez utiliser,Mantenha premido um botão no dispositivo que pretende utilizar,使用したいデバイスのボタンを押し続けてください,Mantenga pulsado un botón en el dispositivo que desee utilizar.,"Halte auf dem Gerät, das du verwenden möchtest, eine Taste gedrückt.",Tieni premuto un pulsante sul dispositivo che desideri utilizzare,กดปุ่มบนอุปกรณ์ที่คุณต้องการใช้ or_touch_player_slot,Prompt to touch player slot for touch input,or touch the player slot if you want to use touch,ou touchez l'emplacement du joueur si vous souhaitez utiliser le tactile,ou toque no espaço do jogador se quiser usar toque,またはタッチを使用する場合はプレイヤースロットをタッチしてください,o toca el espacio del jugador si quieres usar táctil,oder berühre das Spielerfeld\, wenn du Touch verwenden möchtest,o tocca lo slot del giocatore se vuoi usare il touch,หรือแตะช่องผู้เล่นหากคุณต้องการใช้ระบบสัมผัส more_players_than_configs,Error message when there are more local players than input configurations,"There are more local players than input configurations configured. Please configure enough input configurations and try again","Il y a plus de joueurs locaux que de configurations d'entrée configurées. Veuillez configurer suffisamment de configurations d'entrée et réessayer.","Há mais jogadores locais do que configurações de entrada configuradas. Configure configurações de entrada suficientes e tente novamente.","ローカルプレイヤーの数がコンフィグ数より多い。 十分なインプットコンフィグを設定してもう一度試してください。","Hay más jugadores locales que configuraciones de entrada configuradas. -Configure suficientes configuraciones de entrada e intente de nuevo.","Es gibt mehr lokale Spieler als Eingabekonfigurationen konfiguriert. -Bitte konfigurieren Sie genügend Eingabekonfigurationen und versuchen Sie es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. +Configure suficientes configuraciones de entrada e intente de nuevo.","Es gibt mehr lokale Spieler als Eingabekonfigurationen. +Bitte konfiguriere genügend Eingabemethoden und versuche es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. Si prega di configurare sufficienti configurazioni di input e riprovare.","มีผู้เล่นท้องถิ่นมากกว่าการกำหนดค่า input โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" \ No newline at end of file diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua index 3759a898..5d1c6edc 100644 --- a/client/src/scenes/components/InputDeviceOverlay.lua +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -83,7 +83,7 @@ end function InputDeviceOverlay:buildUi() -- Title self.titleLabel = Label({ - text = "press_button_device", + text = "hold_button_device", hAlign = "center", vAlign = "top", y = 60, From 0ac9d6e15254ed278316afd2ef9d450705c4a19e Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 22 Jan 2026 16:57:53 +0100 Subject: [PATCH 17/30] add a disclaimer label to warn users about the quality of the translation and make them aware that contributions are possible --- client/assets/localization.csv | 3 +- client/src/localization.lua | 85 ++++++++++++++++------- client/src/scenes/LanguageSelectSetup.lua | 25 +++++++ 3 files changed, 87 insertions(+), 26 deletions(-) diff --git a/client/assets/localization.csv b/client/assets/localization.csv index b66530d0..b62be3fc 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -662,4 +662,5 @@ Configure configurações de entrada suficientes e tente novamente.","ローカ Configure suficientes configuraciones de entrada e intente de nuevo.","Es gibt mehr lokale Spieler als Eingabekonfigurationen. Bitte konfiguriere genügend Eingabemethoden und versuche es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. Si prega di configurare sufficienti configurazioni di input e riprovare.","มีผู้เล่นท้องถิ่นมากกว่าการกำหนดค่า input -โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" \ No newline at end of file +โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" +translation_disclaimer,Disclaimer shown in language selection to inform about the quality / source of translation,"Initial translation is performed with tools and may be substandard. Translation is open to improvements from the community.","La traduction initiale est effectuée à l'aide d'outils et peut être de qualité médiocre. La traduction peut être améliorée par la communauté.","A tradução inicial é feita com ferramentas e pode não estar à altura dos padrões. A tradução está aberta a melhorias da comunidade.","初期翻訳はツールによって行われ、水準に達していない可能性があります。翻訳はコミュニティからの改善を受け入れています。","La traducción inicial se realiza con herramientas y puede ser de calidad inferior. La traducción está abierta a mejoras por parte de la comunidad.","Die erste Übersetzung von neuem Text wird mit Tools durchgeführt und kann fehlerhaft sein. Übersetzungen sind offen für Verbesserungsvorschläge aus der Community.","La traduzione iniziale viene eseguita con strumenti e potrebbe non essere di qualità ottimale. La traduzione è aperta a miglioramenti da parte della comunità.", \ No newline at end of file diff --git a/client/src/localization.lua b/client/src/localization.lua index b5ca96a4..c1683136 100644 --- a/client/src/localization.lua +++ b/client/src/localization.lua @@ -6,15 +6,31 @@ local ui = require("client.src.ui") local class = require("common.lib.class") local fileUtils = require("client.src.FileUtils") +---@alias LanguageCode ("EN" | "FR" | "PT" | "JP" | "ES" | "GE" | "IT" | "TH") + -- Holds all the data for localizing the game Localization = { data = {}, langs = {}, + ---@type LanguageCode[] codes = {}, lang_index = 1, init = false, } +---@type table +Localization.languageCodeToFontData = +{ + EN = { fontPath = nil, fontSize = 12 }, + FR = { fontPath = nil, fontSize = 12 }, + PT = { fontPath = nil, fontSize = 12 }, + JP = { fontPath = "client/assets/fonts/jp.ttf", fontSize = 14 }, + ES = { fontPath = nil, fontSize = 12 }, + GE = { fontPath = nil, fontSize = 12 }, + IT = { fontPath = nil, fontSize = 12 }, + TH = { fontPath = "client/assets/fonts/th.otf", fontSize = 14 }, +} + function Localization:get_list_codes() return self.codes end @@ -150,33 +166,12 @@ function Localization.init(self) end -- Gets the localized string for a loc key -function loc(text_key, ...) +---@param textKey string +---@param ... string? +function loc(textKey, ...) local code = Localization.codes[Localization.lang_index] - if not code or not Localization.data[code] then - code = Localization.codes[1] - end - assert(code) - - local ret = nil - if Localization.init then - ret = Localization.data[code][text_key] - end - - if ret then - for i = 1, select("#", ...) do - local tmp = select(i, ...) - ret = ret:gsub("%%" .. i, tmp) - end - else - love.filesystem.append("warnings.txt", text_key .. ",,,,,,,,," .. "\n") - ret = "#" .. text_key - for i = 1, select("#", ...) do - ret = ret .. " " .. select(i, ...) - end - end - - return ret + return Localization.localize(code, textKey, ...) end function Localization:getCurrentLanguageCode() @@ -215,4 +210,44 @@ function Localization:getLanguageIndex(languageCode) return 1 end +---@return LanguageCode? +function Localization:getLanguageCode(languageName) + for languageCode, translations in pairs(self.data) do + if translations["LANG"] == languageName then + return languageCode + end + end +end + +---@param languageCode LanguageCode +---@param textKey string +---@param ... string? +---@return string +function Localization.localize(languageCode, textKey, ...) + if not languageCode or not Localization.data[languageCode] then + languageCode = Localization.codes[1] + end + assert(languageCode) + + local ret = nil + if Localization.init then + ret = Localization.data[languageCode][textKey] + end + + if ret then + for i = 1, select("#", ...) do + local tmp = select(i, ...) + ret = ret:gsub("%%" .. i, tmp) + end + else + love.filesystem.append("warnings.txt", textKey .. ",,,,,,,,," .. "\n") + ret = "#" .. textKey + for i = 1, select("#", ...) do + ret = ret .. " " .. select(i, ...) + end + end + + return ret +end + return Localization \ No newline at end of file diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua index 768f0940..2d74bc3d 100644 --- a/client/src/scenes/LanguageSelectSetup.lua +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -40,6 +40,18 @@ function LanguageSelectSetup:load(sceneParams) -- Language selection menu self.menu = self:createLanguageMenu() contentStack:addElement(self.menu) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 60 + })) + + self.disclaimerLabel = ui.Label({ + text = "translation_disclaimer", + hAlign = "center" + }) + + contentStack:addElement(self.disclaimerLabel) end LanguageSelectSetup.name = "LanguageSelectSetup" @@ -65,6 +77,19 @@ end function LanguageSelectSetup:update(dt) GAME.theme.images.bg_main:update(dt) self.menu:receiveInputs() + + for i, menuItem in ipairs(self.menu.menuItems) do + if menuItem.selected then + local code = Localization:getLanguageCode(menuItem.textButton.label.text) + if Localization:get_language() ~= code then + GAME:setLanguage(code) + self.disclaimerLabel.fontSize = Localization.languageCodeToFontData[code].fontSize + end + + self.disclaimerLabel:refreshLocalization() + end + end + end function LanguageSelectSetup:draw() From 63815096bd39bc2e4901026913160e14539fdf43 Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 22 Jan 2026 17:02:16 +0100 Subject: [PATCH 18/30] remove unused function from Player --- client/src/Player.lua | 4 ---- 1 file changed, 4 deletions(-) diff --git a/client/src/Player.lua b/client/src/Player.lua index 4ff36d92..21bfd41d 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -97,10 +97,6 @@ function Player:reset() self:unrestrictInputs() end -function Player:isLocalHuman() - return self.isLocal and self.human -end - ---@param engineStack Stack ---@return PlayerStack function Player:createClientStack(engineStack) From 044076e17ff0c53f49939ca9c72e3c80ab8b3467 Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 22 Jan 2026 17:50:44 +0100 Subject: [PATCH 19/30] make ChangeInputButton use Players array directly and remove unused code fix config not saving --- client/src/Game.lua | 2 +- client/src/config.lua | 4 -- client/src/scenes/CharacterSelect.lua | 2 +- client/src/scenes/LanguageSelectSetup.lua | 8 ++-- client/src/ui/ChangeInputButton.lua | 50 ++++++++--------------- 5 files changed, 24 insertions(+), 42 deletions(-) diff --git a/client/src/Game.lua b/client/src/Game.lua index ba436c70..a693baf3 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -372,7 +372,7 @@ end function Game:onJoystickAdded(joystick) local isNotConfigured = self.input:onJoystickAdded(joystick) - if isNotConfigured and self.navigationStack.scenes[1].name ~= "BootScene" and config:initializationCompleted() and not self:hasOngoingMatch() then + if isNotConfigured and self.navigationStack.scenes[1].name ~= "BootScene" and (config.discordCommunityShown and config.language_code) and not self:hasOngoingMatch() then -- Not critically occupied, so push the InputConfigMenu on top GAME.navigationStack:push(InputConfigMenu({})) end diff --git a/client/src/config.lua b/client/src/config.lua index 35564f4e..6b6a6f2d 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -134,10 +134,6 @@ config = { discordCommunityShown = false, } -function config:initializationCompleted() - return self.discordCommunityShown and self.language_code -end - -- writes to the "conf.json" file function write_conf_file() pcall( diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 2bdc7344..55eabab7 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -330,7 +330,7 @@ function CharacterSelect:createChangeInputButton() return ui.ChangeInputButton({ hFill = true, vFill = true, - battleRoom = self.battleRoom, + players = self.battleRoom.players, onChangeInputRequested = function() self:onChangeInputDeviceRequested() end diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua index 2d74bc3d..d89e2150 100644 --- a/client/src/scenes/LanguageSelectSetup.lua +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -76,8 +76,7 @@ end function LanguageSelectSetup:update(dt) GAME.theme.images.bg_main:update(dt) - self.menu:receiveInputs() - + for i, menuItem in ipairs(self.menu.menuItems) do if menuItem.selected then local code = Localization:getLanguageCode(menuItem.textButton.label.text) @@ -85,11 +84,12 @@ function LanguageSelectSetup:update(dt) GAME:setLanguage(code) self.disclaimerLabel.fontSize = Localization.languageCodeToFontData[code].fontSize end - + self.disclaimerLabel:refreshLocalization() end end - + + self.menu:receiveInputs() end function LanguageSelectSetup:draw() diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua index 1da6f475..017e17b3 100644 --- a/client/src/ui/ChangeInputButton.lua +++ b/client/src/ui/ChangeInputButton.lua @@ -9,12 +9,12 @@ local StackPanel = require(PATH .. ".StackPanel") local UiElement = require(PATH .. ".UIElement") ---@class ChangeInputButtonOptions : ButtonOptions ----@field battleRoom BattleRoom? +---@field players Player[]? ---@field onChangeInputRequested fun()? -- Button that displays current player input assignments and allows changing them ---@class ChangeInputButton : Button ----@field battleRoom BattleRoom? Reference to battle room for querying player assignments +---@field players Player[]? The players we query assignments for ---@field onChangeInputRequested fun() Callback invoked when button is clicked to change inputs ---@field titleLabel Label Title text label ---@field iconContainer StackPanel Container for player assignment icons @@ -23,7 +23,15 @@ local ChangeInputButton = class( function(self, options) options = options or {} - self.battleRoom = options.battleRoom + self.players = options.players + self.localHumanPlayers = {} + for _, player in ipairs(self.players) do + if player.isLocal and player.human then + self.localHumanPlayers[#self.localHumanPlayers+1] = player + end + end + + self.onChangeInputRequested = options.onChangeInputRequested or function() end self.signalConnections = {} @@ -68,13 +76,12 @@ function ChangeInputButton:updateSummary() self.iconContainer:remove(self.iconContainer.children[1]) end - if not self.battleRoom then + if not self.localHumanPlayers then self.isEnabled = true return end - local players = self.battleRoom:getLocalHumanPlayers() - if #players == 0 then + if #self.localHumanPlayers == 0 then self.isEnabled = true return end @@ -88,11 +95,11 @@ function ChangeInputButton:updateSummary() self.iconContainer:addElement(spacer) -- Create a row for each player - for i, player in ipairs(players) do + for i, player in ipairs(self.localHumanPlayers) do self:addPlayerRow(player, i) -- Add spacing between player rows (except after last) - if i < #players then + if i < #self.localHumanPlayers then spacer = UiElement({ width = 1, height = 4 @@ -172,36 +179,15 @@ function ChangeInputButton:addPlayerIcons(playerRow, player, playerIndex) end end ----@param battleRoom BattleRoom -function ChangeInputButton:setBattleRoom(battleRoom) - self:unsubscribeFromPlayerSignals() - self.battleRoom = battleRoom - self:subscribeToPlayerSignals() - self:updateSummary() -end - function ChangeInputButton:subscribeToPlayerSignals() - if not self.battleRoom then + if not self.localHumanPlayers then return end - local players = self.battleRoom:getLocalHumanPlayers() - for _, player in ipairs(players) do - local connection = player:connectSignal("inputConfigurationChanged", self, self.onInputConfigurationChanged) + for _, player in ipairs(self.localHumanPlayers) do + local connection = player:connectSignal("inputConfigurationChanged", self, self.updateSummary) self.signalConnections[#self.signalConnections + 1] = {player = player, connection = connection} end end -function ChangeInputButton:unsubscribeFromPlayerSignals() - for _, connectionInfo in ipairs(self.signalConnections) do - connectionInfo.player:disconnectSignal("inputConfigurationChanged", connectionInfo.connection) - end - self.signalConnections = {} -end - -function ChangeInputButton:onInputConfigurationChanged() - self:updateSummary() -end - - return ChangeInputButton \ No newline at end of file From c9bc45e11307372cf9dd1b1cfa2664fabf3ea10b Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 22 Jan 2026 19:03:20 +0100 Subject: [PATCH 20/30] consolidate ChangeInputButton by making the inputdevice assignment clear part of its core functionality relieve CharacterSelect/BattleRoom of some glue code that could live on Player/ChangeInputButton instead --- client/src/BattleRoom.lua | 18 ++------------- client/src/Player.lua | 10 ++++++++ client/src/scenes/CharacterSelect.lua | 20 ---------------- client/src/ui/Button.lua | 8 ++++--- client/src/ui/ChangeInputButton.lua | 33 +++++++++++++++++++-------- 5 files changed, 41 insertions(+), 48 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 790b6d20..cf0e6058 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -517,7 +517,7 @@ function BattleRoom:claimDeviceForPlayer(player, device) assert(not device.claimed or device.player == player, "device already claimed by another player") - self:clearPlayerAssignment(player) + player:clearInputDeviceAssignment() if device.deviceType == "touch" then player:setInputMethod("touch") @@ -530,26 +530,12 @@ function BattleRoom:claimDeviceForPlayer(player, device) return true end --- Clears input device assignment for a player -function BattleRoom:clearPlayerAssignment(player) - assert(player, "player is required") - logger.debug(string.format("BattleRoom:clearPlayerAssignment player=%s", tostring(player.playerNumber))) - - if player.inputConfiguration then - player:unrestrictInputs() - end - - if player.settings.inputMethod ~= "controller" then - player:setInputMethod("controller") - end -end - -- Releases all input device assignments for local players function BattleRoom:releaseAllLocalAssignments() local released = false for _, player in ipairs(self:getLocalHumanPlayers()) do if player.inputConfiguration then - self:clearPlayerAssignment(player) + player:clearInputDeviceAssignment() released = true end end diff --git a/client/src/Player.lua b/client/src/Player.lua index 21bfd41d..feca3107 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -235,6 +235,16 @@ function Player:unrestrictInputs() end end +function Player:clearInputDeviceAssignment() + if self.inputConfiguration then + self:unrestrictInputs() + end + + if self.settings.inputMethod ~= "controller" then + self:setInputMethod("controller") + end +end + ---@return Player function Player.createLocalPlayerFromConfig() local player = Player(config.name, -1, true) diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 55eabab7..c98b678a 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -331,29 +331,9 @@ function CharacterSelect:createChangeInputButton() hFill = true, vFill = true, players = self.battleRoom.players, - onChangeInputRequested = function() - self:onChangeInputDeviceRequested() - end }) end -function CharacterSelect:onChangeInputDeviceRequested() - if not self.battleRoom then - return - end - - local hasLocalPlayers = #self.battleRoom:getLocalHumanPlayers() > 0 - if not hasLocalPlayers then - return - end - - if self.battleRoom:releaseAllLocalAssignments() then - GAME.theme:playCancelSfx() - else - GAME.theme:playMoveSfx() - end -end - local super_select_pixelcode = [[ uniform float percent; vec4 effect( vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords ) diff --git a/client/src/ui/Button.lua b/client/src/ui/Button.lua index b99ddbb6..0d1026e7 100644 --- a/client/src/ui/Button.lua +++ b/client/src/ui/Button.lua @@ -21,15 +21,17 @@ local Button = class( self.currentlyPressed = false -- callbacks - self.onClick = options.onClick or function() - GAME.theme:playValidationSfx() - end + self.onClick = options.onClick end, UIElement ) Button.TYPE = "Button" +function Button:onClick() + GAME.theme:playValidationSfx() +end + function Button:onTouch(x, y) self.currentlyPressed = true end diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua index 017e17b3..a4a970db 100644 --- a/client/src/ui/ChangeInputButton.lua +++ b/client/src/ui/ChangeInputButton.lua @@ -2,8 +2,6 @@ local PATH = (...):gsub('%.[^%.]+$', '') local Button = require(PATH .. ".Button") local Label = require(PATH .. ".Label") local class = require("common.lib.class") -local GraphicsUtil = require("client.src.graphics.graphics_util") -local inputManager = require("client.src.inputManager") local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") local StackPanel = require(PATH .. ".StackPanel") local UiElement = require(PATH .. ".UIElement") @@ -31,8 +29,6 @@ local ChangeInputButton = class( end end - - self.onChangeInputRequested = options.onChangeInputRequested or function() end self.signalConnections = {} local width = 80 @@ -53,11 +49,6 @@ local ChangeInputButton = class( self:addChild(self.iconContainer) - self.onClick = function(selfElement, inputSource, holdTime) - selfElement.onChangeInputRequested() - end - self.onSelect = self.onClick - -- Subscribe to player signals and update initial state self:subscribeToPlayerSignals() self:updateSummary() @@ -65,6 +56,30 @@ local ChangeInputButton = class( Button ) +function ChangeInputButton:onClick() + if #self.localHumanPlayers == 0 then + GAME.theme:playCancelSfx() + return + else + local released = false + for i, player in ipairs(self.localHumanPlayers) do + if player.inputConfiguration then + player:clearInputDeviceAssignment() + released = true + end + end + + if released then + GAME.theme:playCancelSfx() + else + GAME.theme:playMoveSfx() + end + end +end + +function ChangeInputButton:onSelect() + self:onClick() +end function ChangeInputButton:onResize() -- Icon container has fixed width now From c2b0e47f38d160d117268009360c1da161b03ccf Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 22 Jan 2026 23:15:32 +0100 Subject: [PATCH 21/30] open InputDeviceOverlay actively through CharacterSelect instead of having it spring up on its own --- client/src/scenes/CharacterSelect.lua | 8 ++++++-- client/src/scenes/components/InputDeviceOverlay.lua | 13 +------------ client/src/ui/ChangeInputButton.lua | 11 +++++++---- client/src/ui/UIElement.lua | 6 ++++-- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index c98b678a..7aaf1592 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -331,6 +331,9 @@ function CharacterSelect:createChangeInputButton() hFill = true, vFill = true, players = self.battleRoom.players, + openInputDeviceOverlay = function () + self.inputDeviceOverlay:open() + end }) end @@ -1046,8 +1049,9 @@ function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) end function CharacterSelect:updateSelf(dt) - local overlayActive = self.inputDeviceOverlay and self.inputDeviceOverlay:isActive() - if overlayActive then + self.inputDeviceOverlay:openInputDeviceOverlayIfNeeded() + + if self.inputDeviceOverlay:isActive() then return end diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua index 5d1c6edc..9e676f60 100644 --- a/client/src/scenes/components/InputDeviceOverlay.lua +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -9,7 +9,6 @@ local inputManager = require("client.src.inputManager") local GraphicsUtil = require("client.src.graphics.graphics_util") local consts = require("common.engine.consts") local logger = require("common.lib.logger") -local Scene = require("client.src.scenes.Scene") local directsFocus = require("client.src.ui.FocusDirector") local HOLD_THRESHOLD = 0.25 @@ -112,7 +111,7 @@ function InputDeviceOverlay:buildUi() self.backButton = TextButton({ label = Label({ - text = "back" + text = "leave" }), hAlign = "center", vAlign = "bottom", @@ -441,16 +440,6 @@ end ---@param dt number Delta time in seconds function InputDeviceOverlay:updateSelf(dt) - if not self.active then - if not self.battleRoom.spectating and GAME.input:checkForUnassignedConfigurationInputs(self.battleRoom:getLocalHumanPlayers()) then - self.battleRoom:releaseAllLocalAssignments() - end - - self:openInputDeviceOverlayIfNeeded() - - return - end - for _, slot in ipairs(self.playerSlots) do if not slot.assignedDevice then slot:setHoldProgress(0, nil) diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua index a4a970db..e7f72ff7 100644 --- a/client/src/ui/ChangeInputButton.lua +++ b/client/src/ui/ChangeInputButton.lua @@ -7,13 +7,13 @@ local StackPanel = require(PATH .. ".StackPanel") local UiElement = require(PATH .. ".UIElement") ---@class ChangeInputButtonOptions : ButtonOptions ----@field players Player[]? ----@field onChangeInputRequested fun()? +---@field players Player[] +---@field openInputDeviceOverlay fun() -- Button that displays current player input assignments and allows changing them ---@class ChangeInputButton : Button ----@field players Player[]? The players we query assignments for ----@field onChangeInputRequested fun() Callback invoked when button is clicked to change inputs +---@field players Player[] The players we query assignments for +---@field openInputDeviceOverlay fun() Callback invoked when button is clicked to change inputs ---@field titleLabel Label Title text label ---@field iconContainer StackPanel Container for player assignment icons ---@field signalConnections table[] Array of signal subscriptions for live updates @@ -29,6 +29,8 @@ local ChangeInputButton = class( end end + self.openInputDeviceOverlay = options.openInputDeviceOverlay + self.signalConnections = {} local width = 80 @@ -71,6 +73,7 @@ function ChangeInputButton:onClick() if released then GAME.theme:playCancelSfx() + self.openInputDeviceOverlay() else GAME.theme:playMoveSfx() end diff --git a/client/src/ui/UIElement.lua b/client/src/ui/UIElement.lua index a86166f8..61f3049f 100644 --- a/client/src/ui/UIElement.lua +++ b/client/src/ui/UIElement.lua @@ -146,8 +146,10 @@ function UIElement:refreshLocalization() end function UIElement:update(dt) - self:updateSelf(dt) - self:updateChildren(dt) + if self.isVisible then + self:updateSelf(dt) + self:updateChildren(dt) + end end -- UiElements can override this method to do custom update logic From 13f70f6e07a787193b2ab88e9a4b449e4846e2cb Mon Sep 17 00:00:00 2001 From: Endaris Date: Thu, 22 Jan 2026 23:26:33 +0100 Subject: [PATCH 22/30] fix annotations for InputDeviceOverlay --- .../scenes/components/InputDeviceOverlay.lua | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua index 9e676f60..32f3802b 100644 --- a/client/src/scenes/components/InputDeviceOverlay.lua +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -40,8 +40,11 @@ local PLAYER_SLOT_SIZE = 150 ---@field onClose fun()? ---@field onCancel fun()? Callback when user presses back/cancel +---@class InputDeviceOverlay +---@operator call(InputDeviceOverlayOptions): InputDeviceOverlay +local InputDeviceOverlay = class( ---@param options InputDeviceOverlayOptions -local InputDeviceOverlay = class(function(self, options) +function(self, options) options = options or {} self.battleRoom = options.battleRoom self.holdThreshold = options.holdThreshold or HOLD_THRESHOLD @@ -63,7 +66,7 @@ local InputDeviceOverlay = class(function(self, options) end, UiElement, "InputDeviceOverlay") ---@param config InputConfiguration Input configuration object ----@return number? Maximum hold duration across all checked keys +---@return number? maxDuration Maximum hold duration across all checked keys local function getHoldDurationForInputConfiguration(config) local maxDuration = 0 @@ -123,14 +126,14 @@ function InputDeviceOverlay:buildUi() self:addChild(self.backButton) end ----@return Player[] Array of local human players +---@return Player[] localHumanPlayers Array of local human players function InputDeviceOverlay:getLocalPlayers() assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") return self.battleRoom:getLocalHumanPlayers() end -- Gets the next player that needs device assignment ----@return Player? Next unassigned player or nil if all assigned +---@return Player? player Next unassigned player or nil if all assigned function InputDeviceOverlay:getNextUnassignedPlayer() for _, player in ipairs(self:getLocalPlayers()) do if not self.battleRoom:isPlayerAssigned(player) then @@ -140,7 +143,7 @@ function InputDeviceOverlay:getNextUnassignedPlayer() return nil end ----@return PlayerInputDeviceSlot? Player slot under mouse cursor or nil +---@return PlayerInputDeviceSlot? slot Player slot under mouse cursor or nil function InputDeviceOverlay:getPlayerSlotForTouch() for _, slot in ipairs(self.playerSlots) do if slot:isMouseOver() then @@ -180,7 +183,7 @@ function InputDeviceOverlay:buildPlayerSlots() end ---@param player Player ----@return InputConfiguration? Input configuration if player is assigned, nil otherwise +---@return InputConfiguration? inputConfig Input configuration if player is assigned, nil otherwise function InputDeviceOverlay:getAssignedDeviceForPlayer(player) if not self.battleRoom or not player then return nil @@ -316,7 +319,7 @@ function InputDeviceOverlay:updateTouchHold(dt) end -- Checks if mouse is currently being held down ----@return boolean True if mouse button 1 is held +---@return boolean # True if mouse button 1 is held function InputDeviceOverlay:isMouseHolding() local mousePressed = inputManager.mouse.isPressed[1] local mouseDown = inputManager.mouse.isDown[1] @@ -325,7 +328,7 @@ end -- Checks if touch device is already assigned to any player ---@param touchConfig InputConfiguration Touch input configuration ----@return boolean True if touch is already assigned +---@return boolean # True if touch is already assigned function InputDeviceOverlay:isTouchAlreadyAssigned(touchConfig) if not self.battleRoom then return false @@ -413,7 +416,7 @@ function InputDeviceOverlay:clearTouchTarget() end end ----@return InputConfiguration? Touch input configuration or nil if not found +---@return InputConfiguration? touchInputConfig Touch input configuration or nil if not found function InputDeviceOverlay:getTouchDescriptor() for _, config in ipairs(inputManager:getAssignableDevices()) do if config.deviceType == "touch" then @@ -424,7 +427,7 @@ function InputDeviceOverlay:getTouchDescriptor() end -- Checks if any button is currently being pressed on any device ----@return boolean True if any device has active input +---@return boolean # True if any device has active input function InputDeviceOverlay:isAnyButtonCurrentlyPressed() -- Check if mouse is being held (for touch) if self:isMouseHolding() then @@ -480,8 +483,6 @@ function InputDeviceOverlay:openInputDeviceOverlayIfNeeded() end end --- Intentional override ----@diagnostic disable-next-line: duplicate-set-field function InputDeviceOverlay:drawSelf() if not self.active then return @@ -521,7 +522,7 @@ function InputDeviceOverlay:close() end end ----@return boolean True if overlay is currently active +---@return boolean # True if overlay is currently active function InputDeviceOverlay:isActive() return self.active end @@ -535,14 +536,14 @@ function InputDeviceOverlay:onBackPressed() end end ----@return boolean? True to block touch event propagation +---@return boolean? # True to block touch event propagation function InputDeviceOverlay:onTouch() if self.active then return true end end ----@return boolean? True to block release event propagation +---@return boolean? # True to block release event propagation function InputDeviceOverlay:onRelease() if self.active then return true @@ -550,7 +551,6 @@ function InputDeviceOverlay:onRelease() end function InputDeviceOverlay:receiveInputs(input, dt) - if input.isDown["MenuEsc"] then self.escapeHoldTime = self.escapeHoldTime + dt elseif input.isPressed["MenuEsc"] and self.escapeHoldTime > 0 then From 2569f07fd8a5486dcae3f715e21f6ab98f2e98d1 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 01:51:36 +0100 Subject: [PATCH 23/30] make InputDeviceOverlay work directly with players instead of going through BattleRoom --- client/src/Player.lua | 5 + client/src/inputManager.lua | 2 +- client/src/scenes/CharacterSelect.lua | 2 +- .../scenes/components/InputDeviceOverlay.lua | 129 +++++++++--------- 4 files changed, 74 insertions(+), 64 deletions(-) diff --git a/client/src/Player.lua b/client/src/Player.lua index feca3107..b4c1ee4e 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -245,6 +245,11 @@ function Player:clearInputDeviceAssignment() end end +function Player:hasInputConfiguration() + local assigned = (self.inputConfiguration ~= nil) + return assigned +end + ---@return Player function Player.createLocalPlayerFromConfig() local player = Player(config.name, -1, true) diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index 80435a77..6d6c89c4 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -870,7 +870,7 @@ end -- Gets list of all assignable input devices (controllers, keyboard, touch) -- Returns InputConfiguration objects directly with all metadata already calculated ----@return InputConfiguration[] Array of InputConfiguration objects +---@return InputConfiguration[] inputConfigurations Array of InputConfiguration objects function inputManager:getAssignableDevices() local devices = {} diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 7aaf1592..bae3c0b1 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -295,7 +295,7 @@ end function CharacterSelect:createInputDeviceOverlay() self.inputDeviceOverlay = InputDeviceOverlay({ - battleRoom = self.battleRoom, + players = self.battleRoom.players, onClose = function() self:onInputDeviceOverlayClosed() end, diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua index 32f3802b..40bdfe3d 100644 --- a/client/src/scenes/components/InputDeviceOverlay.lua +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -17,10 +17,9 @@ local PLAYER_SLOT_SIZE = 150 -- Modal overlay that blocks game start until all local players have assigned input devices using hold-to-confirm interaction ---@class InputDeviceOverlay : UiElement ----@field battleRoom BattleRoom Reference to battle room for player/device management +---@field players Player[] Reference to players that can reassign their input device with this overlay ---@field holdThreshold number Duration in seconds required to confirm assignment (default 0.25) ---@field active boolean True when overlay is open and processing input ----@field hasFocus boolean True when overlay has keyboard/input focus (managed by FocusDirector) ---@field playerSlots PlayerInputDeviceSlot[] Array of player slot UI elements ---@field deviceState table Tracks hold state per device ---@field touchTargetSlot PlayerInputDeviceSlot? Slot where touch started (locked for duration of touch) @@ -35,7 +34,7 @@ local PLAYER_SLOT_SIZE = 150 ---@field cancelHintLabel Label Hint text for escape key to cancel ---@class InputDeviceOverlayOptions ----@field battleRoom BattleRoom +---@field players Player[] Reference to players that may be eligible for reassigning their input device with this overlay ---@field holdThreshold number? ---@field onClose fun()? ---@field onCancel fun()? Callback when user presses back/cancel @@ -43,10 +42,18 @@ local PLAYER_SLOT_SIZE = 150 ---@class InputDeviceOverlay ---@operator call(InputDeviceOverlayOptions): InputDeviceOverlay local InputDeviceOverlay = class( +---@param self InputDeviceOverlay ---@param options InputDeviceOverlayOptions function(self, options) options = options or {} - self.battleRoom = options.battleRoom + self.players = {} + + for _, player in ipairs(options.players) do + if player.isLocal and player.human then + self.players[#self.players+1] = player + end + end + self.holdThreshold = options.holdThreshold or HOLD_THRESHOLD self.onClose = options.onClose self.onCancel = options.onCancel @@ -128,15 +135,14 @@ end ---@return Player[] localHumanPlayers Array of local human players function InputDeviceOverlay:getLocalPlayers() - assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") - return self.battleRoom:getLocalHumanPlayers() + return self.players end -- Gets the next player that needs device assignment ---@return Player? player Next unassigned player or nil if all assigned function InputDeviceOverlay:getNextUnassignedPlayer() for _, player in ipairs(self:getLocalPlayers()) do - if not self.battleRoom:isPlayerAssigned(player) then + if not player:hasInputConfiguration() then return player end end @@ -175,29 +181,11 @@ function InputDeviceOverlay:buildPlayerSlots() end -- Check if player is already assigned - local assignedDevice = self:getAssignedDeviceForPlayer(player) - if assignedDevice then - slot:setAssignedDevice(assignedDevice) - end - end -end - ----@param player Player ----@return InputConfiguration? inputConfig Input configuration if player is assigned, nil otherwise -function InputDeviceOverlay:getAssignedDeviceForPlayer(player) - if not self.battleRoom or not player then - return nil - end - - for _, config in ipairs(inputManager:getAssignableDevices()) do - if config then - local assignedPlayer = self.battleRoom:getPlayerAssignedToDevice(config) - if assignedPlayer == player then - return config - end + local assignedInputConfig = player.inputConfiguration + if assignedInputConfig then + slot:setAssignedDevice(assignedInputConfig) end end - return nil end function InputDeviceOverlay:updatePlayerSlots() @@ -211,8 +199,8 @@ function InputDeviceOverlay:updatePlayerSlots() if players then local player = players[i] if player then - local assignedDevice = self:getAssignedDeviceForPlayer(player) - slot:setAssignedDevice(assignedDevice) + local assignedInputConfig = player.inputConfiguration + slot:setAssignedDevice(assignedInputConfig) end end end @@ -223,11 +211,10 @@ end -- Assigns a device to a player and plays feedback ----@param config InputConfiguration Input configuration to assign +---@param inputConfig InputConfiguration Input configuration to assign ---@param targetPlayer Player? Player to assign to, or nil to assign to next unassigned player -function InputDeviceOverlay:assignDevice(config, targetPlayer) - assert(config, "config is required") - assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") +function InputDeviceOverlay:assignDevice(inputConfig, targetPlayer) + assert(inputConfig, "config is required") if not targetPlayer then targetPlayer = self:getNextUnassignedPlayer() @@ -237,26 +224,43 @@ function InputDeviceOverlay:assignDevice(config, targetPlayer) return end - local success = self.battleRoom:claimDeviceForPlayer(targetPlayer, config) - if success then - if GAME.theme and GAME.theme.playValidationSfx then - GAME.theme:playValidationSfx() + if targetPlayer.inputConfiguration ~= inputConfig then + targetPlayer:clearInputDeviceAssignment() + + if inputConfig.deviceType == "touch" then + targetPlayer:setInputMethod("touch") + else + targetPlayer:setInputMethod("controller") end - self:updatePlayerSlots() - - -- Trigger pop animation on the slot that was just assigned - local players = self:getLocalPlayers() - for i, player in ipairs(players) do - if player == targetPlayer and self.playerSlots[i] then - self.playerSlots[i]:triggerPopAnimation() - break - end + + targetPlayer:restrictInputs(inputConfig) + end + + GAME.theme:playValidationSfx() + self:updatePlayerSlots() + + -- Trigger pop animation on the slot that was just assigned + for i, player in ipairs(self.players) do + if player == targetPlayer and self.playerSlots[i] then + self.playerSlots[i]:triggerPopAnimation() + break end + end + + if self:allPlayersAssigned() then + self.autoCloseTimer = AUTO_CLOSE_DELAY + end +end - if self.battleRoom:areLocalPlayersAssigned() then - self.autoCloseTimer = AUTO_CLOSE_DELAY +---@return boolean # true if all players are assigned, false otherwise +function InputDeviceOverlay:allPlayersAssigned() + for _, player in ipairs(self.players) do + if not player:hasInputConfiguration() then + return false end end + + return true end -- Processes hold input for a configuration device @@ -330,12 +334,17 @@ end ---@param touchConfig InputConfiguration Touch input configuration ---@return boolean # True if touch is already assigned function InputDeviceOverlay:isTouchAlreadyAssigned(touchConfig) - if not self.battleRoom then - return false + assert(touchConfig.deviceType == "touch", "Checked device is not a touch device") + + if touchConfig.claimed then + for _, player in ipairs(self.players) do + if touchConfig.player == player and player.inputConfiguration == touchConfig then + return true + end + end end - local assignedPlayer = self.battleRoom:getPlayerAssignedToDevice(touchConfig) - return assignedPlayer ~= nil + return false end -- Processes touch hold logic when mouse is held down @@ -359,8 +368,6 @@ function InputDeviceOverlay:processTouchHold(dt, touchConfig) self.touchTargetSlot:setTouchTarget(true) end - local targetSlot = self.touchTargetSlot - local state = self.deviceState[touchConfig.id] if not state then state = {confirmTriggered = false, holdTime = 0} @@ -371,10 +378,10 @@ function InputDeviceOverlay:processTouchHold(dt, touchConfig) self.escapeHoldTime = 0 local progress = math.min(state.holdTime / self.holdThreshold, 1) - targetSlot:setHoldProgress(progress, "touch") + self.touchTargetSlot:setHoldProgress(progress, "touch") if state.holdTime >= self.holdThreshold and not state.confirmTriggered then - self:assignTouchToSlot(touchConfig, targetSlot) + self:assignTouchToSlot(touchConfig, self.touchTargetSlot) state.confirmTriggered = true elseif state.holdTime < self.holdThreshold then state.confirmTriggered = false @@ -473,12 +480,12 @@ function InputDeviceOverlay:openInputDeviceOverlayIfNeeded() return end - local hasLocalPlayers = #self.battleRoom:getLocalHumanPlayers() > 0 - if not hasLocalPlayers then + if #self.players == 0 then + -- no local players return end - if not self.battleRoom.hasShutdown and not self.battleRoom:areLocalPlayersAssigned() then + if not self:allPlayersAssigned() then self:open() end end @@ -495,8 +502,6 @@ function InputDeviceOverlay:drawSelf() end function InputDeviceOverlay:open() - assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") - self.deviceState = {} self.touchTargetSlot = nil self.autoCloseTimer = 0 From d039599f01c5c4996131495f0b8049cd584e2205 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 02:03:06 +0100 Subject: [PATCH 24/30] remove unused functions from BattleRoom that have been moved to Player/Input components --- client/src/BattleRoom.lua | 45 --------------------------- client/src/scenes/CharacterSelect.lua | 6 ++-- 2 files changed, 2 insertions(+), 49 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index cf0e6058..aeb274ef 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -459,51 +459,6 @@ function BattleRoom:getLocalHumanPlayers() return localPlayers end --- Checks if a player has an assigned input device -function BattleRoom:isPlayerAssigned(player) - assert(player, "player is required") - local assigned = player.inputConfiguration ~= nil - return assigned -end - --- Checks if all local human players have assigned input devices -function BattleRoom:areLocalPlayersAssigned() - for _, player in ipairs(self:getLocalHumanPlayers()) do - if not self:isPlayerAssigned(player) then - return false - end - end - - return true -end - --- Gets player by player number -function BattleRoom:getPlayerByNumber(playerNumber) - for _, player in ipairs(self.players) do - if player.playerNumber == playerNumber then - return player - end - end - return nil -end - --- Gets the player currently assigned to a specific input device -function BattleRoom:getPlayerAssignedToDevice(device) - assert(device, "device is required") - logger.debug(string.format("BattleRoom:getPlayerAssignedToDevice device=%s", tostring(device))) - if device.player then - return device.player - end - - for _, player in ipairs(self.players) do - if player.inputConfiguration == device then - return player - end - end - - return nil -end - -- Claims an input device for a specific player function BattleRoom:claimDeviceForPlayer(player, device) assert(player, "player is required") diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index bae3c0b1..285c706a 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -312,11 +312,9 @@ end function CharacterSelect:setChangeInputButtonVisibleIfNeeded() if self.ui and self.ui.changeInputButton then - if self.battleRoom and #self.battleRoom:getLocalHumanPlayers() == 0 then - return + if #self.battleRoom:getLocalHumanPlayers() > 0 then + self.ui.changeInputButton:setVisibility(true) end - - self.ui.changeInputButton:setVisibility(true) end end From 9b2d60e201c111808f072324946abb66144c28d6 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 17:18:47 +0100 Subject: [PATCH 25/30] patch up some annotations and remove fuzziness when local inputConfig is unclear during receipt of matchStart message --- client/src/Player.lua | 3 +++ client/src/input/InputConfiguration.lua | 8 ++++---- client/src/inputManager.lua | 4 ++++ client/src/network/NetClient.lua | 12 +++++++----- client/src/ui/ChangeInputButton.lua | 1 + 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/client/src/Player.lua b/client/src/Player.lua index b4c1ee4e..1fd4e0fc 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -33,6 +33,8 @@ local StackBehaviours = require("common.data.StackBehaviours") ---@field settings PlayerSettings ---@field publicId integer ---@field playerNumber integer? +---@field inputConfiguration InputConfiguration? +---@field lastUsedInputConfiguration InputConfiguration? ---@overload fun(name: string, publicId: integer, isLocal: boolean?): Player local Player = class( ---@param self Player @@ -213,6 +215,7 @@ function Player:setLeague(league) end end +---@param inputConfiguration InputConfiguration function Player:restrictInputs(inputConfiguration) if self.inputConfiguration and self.inputConfiguration ~= inputConfiguration then error("Player " .. self.playerNumber .. " is trying to claim a second input configuration") diff --git a/client/src/input/InputConfiguration.lua b/client/src/input/InputConfiguration.lua index 726fb4e0..e166f413 100644 --- a/client/src/input/InputConfiguration.lua +++ b/client/src/input/InputConfiguration.lua @@ -181,7 +181,7 @@ end -- Maps controller names to specific image variants for theme selection (static helper) ---@param controllerName string? Controller name from Love2D ----@return string Image variant key (e.g., "playstation4", "xboxone", "generic") +---@return string controllerImageVariant Image variant key (e.g., "playstation4", "xboxone", "generic") function InputConfiguration.getControllerImageVariantFromName(controllerName) if not controllerName then return "generic" @@ -317,7 +317,7 @@ end -- Maps gamepad button IDs to display names (static helper) ---@param joystick PanelAttackJoystick? Joystick object ---@param buttonId string Button identifier (e.g., "0", "dpup11", "+y3") ----@return string Display name for the button +---@return string displayName Display name for the button function InputConfiguration.getButtonNameFromMapping(joystick, buttonId) if not joystick or not joystick:isGamepad() then return buttonId @@ -388,7 +388,7 @@ end -- Get human-readable display name for a key binding ---@param keyBinding string? Key binding string (e.g., "space", "guid:slot:button", nil) ----@return string Display name for the key binding +---@return string displayName Display name for the key binding function InputConfiguration:getButtonDisplayName(keyBinding) if not keyBinding then return loc("op_none") @@ -413,7 +413,7 @@ end local touchConfiguration = nil -- Gets or creates the special Touch InputConfiguration that wraps the mouse ----@return InputConfiguration Touch configuration +---@return InputConfiguration touchConfig Touch configuration function InputConfiguration.getTouchConfiguration() if not touchConfiguration then -- Create a special InputConfiguration for touch diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index 6d6c89c4..1b0a3b26 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -888,4 +888,8 @@ function inputManager:getAssignableDevices() return devices end +function inputManager.getTouchInputConfiguration() + return InputConfiguration.getTouchConfiguration() +end + return inputManager diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 4954bae6..4c4a2e78 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -193,18 +193,20 @@ local function processMatchStartMessage(self, message) if player.isLocal then if not player.inputConfiguration then + -- fallback in case the player lost their input config while the server sent the message if player.settings.inputMethod == "touch" then - player:restrictInputs(GAME.input.mouse) - else - if player.lastUsedInputConfiguration and player.lastUsedInputConfiguration.x then + player:restrictInputs(GAME.input.getTouchInputConfiguration()) + elseif player.lastUsedInputConfiguration then + if player.lastUsedInputConfiguration.deviceType == "touch" then -- there is no configuration and the last one is a touch configuration - -- there is no way to know which input configuration the player wanted to use in this scenario so throw an error + -- while we could assume that the player wanted to use touch after all, if the server reports the setting as controller, we can no longer change + -- because the other client already has us clocked as controller and the inputs have to match + -- there is no way to know which input configuration the player would want to use in this scenario so throw an error error("Player's input configuration does not match input method " .. player.settings.inputMethod .. " sent by server.") else player:restrictInputs(player.lastUsedInputConfiguration) end end - -- fallback in case the player lost their input config while the server sent the message end end -- generally I don't think it's a good idea to try and rematch the other diverging settings here diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua index e7f72ff7..9bed8418 100644 --- a/client/src/ui/ChangeInputButton.lua +++ b/client/src/ui/ChangeInputButton.lua @@ -13,6 +13,7 @@ local UiElement = require(PATH .. ".UIElement") -- Button that displays current player input assignments and allows changing them ---@class ChangeInputButton : Button ---@field players Player[] The players we query assignments for +---@field localHumanPlayers Player[] ---@field openInputDeviceOverlay fun() Callback invoked when button is clicked to change inputs ---@field titleLabel Label Title text label ---@field iconContainer StackPanel Container for player assignment icons From 1b554a9102adaa3a33163f19ea47a1b6ded14562 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 17:32:46 +0100 Subject: [PATCH 26/30] always set inputMethod with restrictInputs and don't reset it with unrestrict that way it is more likely that the inputMethod matches for a lastUsedInputConfig recovery in case of a matchStart --- client/src/BattleRoom.lua | 11 ++--------- client/src/Player.lua | 16 ++++++---------- .../src/scenes/components/InputDeviceOverlay.lua | 9 +-------- client/src/ui/ChangeInputButton.lua | 2 +- 4 files changed, 10 insertions(+), 28 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index aeb274ef..5610f620 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -472,14 +472,7 @@ function BattleRoom:claimDeviceForPlayer(player, device) assert(not device.claimed or device.player == player, "device already claimed by another player") - player:clearInputDeviceAssignment() - - if device.deviceType == "touch" then - player:setInputMethod("touch") - else - player:setInputMethod("controller") - end - + player:unrestrictInputs() player:restrictInputs(device) return true @@ -490,7 +483,7 @@ function BattleRoom:releaseAllLocalAssignments() local released = false for _, player in ipairs(self:getLocalHumanPlayers()) do if player.inputConfiguration then - player:clearInputDeviceAssignment() + player:unrestrictInputs() released = true end end diff --git a/client/src/Player.lua b/client/src/Player.lua index 1fd4e0fc..b1759a70 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -220,6 +220,12 @@ function Player:restrictInputs(inputConfiguration) if self.inputConfiguration and self.inputConfiguration ~= inputConfiguration then error("Player " .. self.playerNumber .. " is trying to claim a second input configuration") end + if inputConfiguration.deviceType == "touch" then + self:setInputMethod("touch") + else + self:setInputMethod("controller") + end + self.inputConfiguration = input:claimConfiguration(self, inputConfiguration) self:emitSignal("inputConfigurationChanged", self.inputConfiguration) end @@ -238,16 +244,6 @@ function Player:unrestrictInputs() end end -function Player:clearInputDeviceAssignment() - if self.inputConfiguration then - self:unrestrictInputs() - end - - if self.settings.inputMethod ~= "controller" then - self:setInputMethod("controller") - end -end - function Player:hasInputConfiguration() local assigned = (self.inputConfiguration ~= nil) return assigned diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua index 40bdfe3d..8ee3583c 100644 --- a/client/src/scenes/components/InputDeviceOverlay.lua +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -225,14 +225,7 @@ function InputDeviceOverlay:assignDevice(inputConfig, targetPlayer) end if targetPlayer.inputConfiguration ~= inputConfig then - targetPlayer:clearInputDeviceAssignment() - - if inputConfig.deviceType == "touch" then - targetPlayer:setInputMethod("touch") - else - targetPlayer:setInputMethod("controller") - end - + targetPlayer:unrestrictInputs() targetPlayer:restrictInputs(inputConfig) end diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua index 9bed8418..224b7d28 100644 --- a/client/src/ui/ChangeInputButton.lua +++ b/client/src/ui/ChangeInputButton.lua @@ -67,7 +67,7 @@ function ChangeInputButton:onClick() local released = false for i, player in ipairs(self.localHumanPlayers) do if player.inputConfiguration then - player:clearInputDeviceAssignment() + player:unrestrictInputs() released = true end end From 93f54ca31c7de40a214f8e00e1d2db9f7da2f038 Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 17:41:19 +0100 Subject: [PATCH 27/30] fix construction args of InputDeviceOverlay for PuzzleMenu --- client/src/BattleRoom.lua | 13 ------------- client/src/scenes/PuzzleMenu.lua | 2 +- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 5610f620..2630428c 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -478,19 +478,6 @@ function BattleRoom:claimDeviceForPlayer(player, device) return true end --- Releases all input device assignments for local players -function BattleRoom:releaseAllLocalAssignments() - local released = false - for _, player in ipairs(self:getLocalHumanPlayers()) do - if player.inputConfiguration then - player:unrestrictInputs() - released = true - end - end - - return released -end - function BattleRoom:update(dt) -- if there are still unloaded assets, we can load them 1 asset a frame in the background ModController:update() diff --git a/client/src/scenes/PuzzleMenu.lua b/client/src/scenes/PuzzleMenu.lua index 48f95dc0..053a05c2 100644 --- a/client/src/scenes/PuzzleMenu.lua +++ b/client/src/scenes/PuzzleMenu.lua @@ -225,7 +225,7 @@ end function PuzzleMenu:createInputDeviceOverlay() self.inputDeviceOverlay = InputDeviceOverlay({ - battleRoom = self.battleRoom, + players = self.battleRoom.players, onClose = function() self:onInputDeviceOverlayClosed() end, From 1a9acd2c9cf304679f83e15ce7ad595f97f525ec Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 18:32:21 +0100 Subject: [PATCH 28/30] update localization some more, widen labels for keybinds to fit the text fixed PuzzleMenu not opening the inputoverlay --- client/assets/localization.csv | 22 +++++++++++----------- client/src/scenes/PuzzleMenu.lua | 5 +++-- client/src/ui/KeyBindingMenuItem.lua | 2 +- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/client/assets/localization.csv b/client/assets/localization.csv index b62be3fc..961e4d88 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -16,12 +16,12 @@ large_garbage,Training mode that drops huge chain blocks,Large Garbage,Gros déc width,width of garbage blocks in training mode,Width,Largeur,Largura,幅,Ancho,Breite,Larghezza,ความกว้างก้อนขยะ height,height of garbage blocks in training mode,Height,Hauteur,Altura,高さ,Altura,Höhe,Altezza,ความสูงก้อนขยะ vs,,vs,vs,vs,対,vs,vs,vs,vs -up,,Up,Haut,Cima,上,Arriba,hoch,Su,ขึ้น -down,,Down,Bas,Baixo,下,Abajo,runter,Giù,ลง -left,,Left,Gauche,Esquerda,左,Izquierda,links,Sinistra,ซ้าย -right,,Right,Droite,Direita,右,Derecha,rechts,Destra,ขวา +up,,Up,Haut,Cima,上,Arriba,Hoch,Su,ขึ้น +down,,Down,Bas,Baixo,下,Abajo,Runter,Giù,ลง +left,,Left,Gauche,Esquerda,左,Izquierda,Links,Sinistra,ซ้าย +right,,Right,Droite,Direita,右,Derecha,Rechts,Destra,ขวา start,,Start,Start,Começar,START,Empezar,Start,Avvio,เริ่ม -raise,button used to raise the stack,Raise,Monter,Levantar,高める,Elevar,Heben,Aumentare,เลื่อน board ขึ้น +raise,button used to raise the stack with touch inputs,Raise,Monter,Levantar,高める,Elevar,Anheben,Aumentare,เลื่อน board ขึ้น player,,Player,Joueur,Jogador,プレイヤー,Jugador,Spieler,Giocatore,ผู้เล่น player_n,,Player %1,Joueur %1,Jogador %1,プレイヤー%1,Jugador %1,Spieler %1,Giocatore %1,ผูู้เล่น %1 page,,Page,Page,Página,ページ,Página,Seite,Pagina,หน้า @@ -645,12 +645,12 @@ discord_message_line3,Third line of Discord welcome message,"Compete in monthly discord_join_link,Button to join Discord server,Join Discord Server,Rejoindre le serveur Discord,Entrar no servidor Discord,Discordサーバーに参加,Unirse al servidor Discord,Discord-Server beitreten,Unisciti al server Discord,เข้าร่วมเซิร์ฟเวอร์ Discord next_button,Text shown to continue to next screen,Next,Suivant,Próximo,次へ,Siguiente,Weiter,Avanti,ถัดไป input_config_new_controller,Message shown when a new controller is detected and configured,"Input configurations added, please verify the button mappings, especially the Confirm, Cancel and Raise keys",Nouvelle manette détectée ! Veuillez vérifier les mappages des boutons.,Novo controlador detectado! Verifique os mapeamentos dos botões.,新しいコントローラーが検出されました!ボタンマッピングを確認してください。,¡Nuevo controlador detectado! Por favor verifique los mapeos de botones.,Neuer Controller erkannt! Bitte überprüfe die Tastenbelegung.,Nuovo controller rilevato! Verifica le mappature dei pulsanti.,ตรวจพบจอยใหม่! กรุณาตรวจสอบการตั้งค่าปุ่ม -swap1,Input configuration label for first swap button,Swap 1 / Select / Confirm,Échanger 1,Trocar 1,スワップ1,Intercambiar 1,Tausch 1 / Auswählen / Bestätigen,Scambia 1,สลับ 1 -swap2,Input configuration label for second swap button,Swap 2 / Back,Échanger 2,Trocar 2,スワップ2,Intercambiar 2,Tausch 2 / Zurück,Scambia 2,สลับ 2 -raise1,Input configuration label for first raise button,Raise 1,Monter 1,Levantar 1,上げる1,Elevar 1,Heben 1,Alza 1,เลื่อน 1 -raise2,Input configuration label for second raise button,Raise 2,Monter 2,Levantar 2,上げる2,Elevar 2,Heben 2,Alza 2,เลื่อน 2 -tauntup,Input configuration label for taunt up button,Taunt Up,Provocation Haut,Provocação Cima,挑発上,Burla Arriba,Spott Oben,Provocazione Su,เยาะเย้ยขึ้น -tauntdown,Input configuration label for taunt down button,Taunt Down,Provocation Bas,Provocação Baixo,挑発下,Burla Abajo,Spott Unten,Provocazione Giù,เยาะเย้ยลง +swap1,Input configuration label for first swap button,Swap 1 / Select / Confirm,Échanger 1,Trocar 1,スワップ1,Intercambiar 1 / Seleccionar / Confirmar,Tausch 1 / Auswählen / Bestätigen,Scambia 1,สลับ 1 +swap2,Input configuration label for second swap button,Swap 2 / Back,Échanger 2,Trocar 2,スワップ2,Intercambiar 2 / Volver,Tausch 2 / Zurück,Scambia 2,สลับ 2 +raise1,Input configuration label for first raise button,Raise 1 / Previous Page,Monter 1,Levantar 1,上げる1,Elevar 1 / Página anterior,Stapel anheben 1 / Vorherige Seite,Alza 1,เลื่อน 1 +raise2,Input configuration label for second raise button,Raise 2 / Next Page,Monter 2,Levantar 2,上げる2,Elevar 2 / Página posterior,Stapel anheben 2 / Nächste Seite,Alza 2,เลื่อน 2 +tauntup,Input configuration label for taunt up button,Taunt Up / Reset Puzzle,Provocation Haut,Provocação Cima,挑発上,Burla Arriba,Spott 1 / Puzzle zurücksetzen,Provocazione Su,เยาะเย้ยขึ้น +tauntdown,Input configuration label for taunt down button,Taunt Down,Provocation Bas,Provocação Baixo,挑発下,Burla Abajo,Spott 2,Provocazione Giù,เยาะเย้ยลง change_input_device,Label for changing input device,Change Input Device,Changer de périphérique d'entrée,Alterar dispositivo de entrada,入力デバイスを変更,Cambiar dispositivo de entrada,Eingabegerät ändern,Cambia dispositivo di input,เปลี่ยนอุปกรณ์ควบคุม hold_button_device,Prompt to press a button on desired input device,Hold a button on the device you want to use,Maintenez enfoncé un bouton sur l'appareil que vous souhaitez utiliser,Mantenha premido um botão no dispositivo que pretende utilizar,使用したいデバイスのボタンを押し続けてください,Mantenga pulsado un botón en el dispositivo que desee utilizar.,"Halte auf dem Gerät, das du verwenden möchtest, eine Taste gedrückt.",Tieni premuto un pulsante sul dispositivo che desideri utilizzare,กดปุ่มบนอุปกรณ์ที่คุณต้องการใช้ or_touch_player_slot,Prompt to touch player slot for touch input,or touch the player slot if you want to use touch,ou touchez l'emplacement du joueur si vous souhaitez utiliser le tactile,ou toque no espaço do jogador se quiser usar toque,またはタッチを使用する場合はプレイヤースロットをタッチしてください,o toca el espacio del jugador si quieres usar táctil,oder berühre das Spielerfeld\, wenn du Touch verwenden möchtest,o tocca lo slot del giocatore se vuoi usare il touch,หรือแตะช่องผู้เล่นหากคุณต้องการใช้ระบบสัมผัส diff --git a/client/src/scenes/PuzzleMenu.lua b/client/src/scenes/PuzzleMenu.lua index 053a05c2..3b799bfe 100644 --- a/client/src/scenes/PuzzleMenu.lua +++ b/client/src/scenes/PuzzleMenu.lua @@ -698,8 +698,9 @@ end function PuzzleMenu:updateSelf(dt) - local overlayActive = self.inputDeviceOverlay and self.inputDeviceOverlay:isActive() - if overlayActive then + self.inputDeviceOverlay:openInputDeviceOverlayIfNeeded() + + if self.inputDeviceOverlay:isActive() then return end diff --git a/client/src/ui/KeyBindingMenuItem.lua b/client/src/ui/KeyBindingMenuItem.lua index 4ec4559f..77cbc289 100644 --- a/client/src/ui/KeyBindingMenuItem.lua +++ b/client/src/ui/KeyBindingMenuItem.lua @@ -34,7 +34,7 @@ function KeyBindingMenuItem.create(options) text = string.lower(options.keyName), vAlign = "center", fontSize = 12, - width = 96 + width = 224 }) -- Create binding button (right side) From 0daa5ed064b1e02057a45feca77023e09e54a02a Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 19:20:15 +0100 Subject: [PATCH 29/30] fix localization not being refreshed after selecting language in the languageselectsetup screen --- client/src/scenes/LanguageSelectSetup.lua | 3 +++ 1 file changed, 3 insertions(+) diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua index d89e2150..966da044 100644 --- a/client/src/scenes/LanguageSelectSetup.lua +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -67,6 +67,9 @@ function LanguageSelectSetup:createLanguageMenu() GAME:setLanguage(language.code) write_conf_file() GAME.navigationStack:pop() + for _, scene in ipairs(GAME.navigationStack.scenes) do + scene:refreshLocalization() + end end)) end From 0080fa80d09bb8ced13c9440888091aa2969113e Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 23 Jan 2026 21:33:35 +0100 Subject: [PATCH 30/30] always show the input menu on first start up, just to be sure --- client/src/scenes/BootScene.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/scenes/BootScene.lua b/client/src/scenes/BootScene.lua index 5f32ee2a..60b95de0 100644 --- a/client/src/scenes/BootScene.lua +++ b/client/src/scenes/BootScene.lua @@ -67,7 +67,7 @@ function BootScene:updateSelf(dt) local input = require("client.src.inputManager") - if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() then + if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() or not config.discordCommunityShown then local InputConfigMenu = require("client.src.scenes.InputConfigMenu") GAME.navigationStack:push(InputConfigMenu({})) end