diff --git a/COPYING-ASSETS b/COPYING-ASSETS index 726fd0e30..0d47177e5 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 87a4baca6..d83032e29 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 000000000..23bfafec2 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/discord_logo.png differ 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 000000000..dd259c2e0 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_add.png differ 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 000000000..84cf92b66 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_gamecube.png differ 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 000000000..fec749439 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_generic.png differ 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 000000000..a722048a7 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_n64.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation1.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation1.png new file mode 100644 index 000000000..989109a68 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_playstation1.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation2.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation2.png new file mode 100644 index 000000000..40cf7e39a Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_playstation2.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation3.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation3.png new file mode 100644 index 000000000..c3c49113c Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_playstation3.png differ 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 000000000..a18b0e757 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_playstation4.png differ 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 000000000..1c56be113 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_playstation5.png differ 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 000000000..da4fcf96f Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_snes.png differ 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 000000000..57326f23e Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png differ 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 000000000..cf192394d Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_xbox360.png differ 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 000000000..8940b55bb Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_xboxone.png differ 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 000000000..40eeba3de Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_0.png b/client/assets/themes/Panel Attack Modern/input/device_number_0.png new file mode 100644 index 000000000..24b9a170c Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_0.png differ 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 000000000..779263038 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_1.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_2.png b/client/assets/themes/Panel Attack Modern/input/device_number_2.png new file mode 100644 index 000000000..71edd8889 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_2.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_3.png b/client/assets/themes/Panel Attack Modern/input/device_number_3.png new file mode 100644 index 000000000..bf87da843 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_3.png differ 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 000000000..cc431d953 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_4.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_5.png b/client/assets/themes/Panel Attack Modern/input/device_number_5.png new file mode 100644 index 000000000..112ffefd3 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_5.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_6.png b/client/assets/themes/Panel Attack Modern/input/device_number_6.png new file mode 100644 index 000000000..32ebca18a Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_6.png differ 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 000000000..17e3ca098 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_7.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_8.png b/client/assets/themes/Panel Attack Modern/input/device_number_8.png new file mode 100644 index 000000000..8675e2089 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_8.png differ 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 000000000..6b1fbeeca Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/device_number_9.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/error.png b/client/assets/themes/Panel Attack Modern/input/error.png new file mode 100644 index 000000000..ee04ada4b Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/error.png differ diff --git a/client/assets/themes/Panel Attack Modern/input/keyboard.png b/client/assets/themes/Panel Attack Modern/input/keyboard.png new file mode 100644 index 000000000..5d7ba875d Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/keyboard.png differ 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 000000000..1bf4b077c Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/mouse.png differ 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 000000000..ea1a45847 Binary files /dev/null and b/client/assets/themes/Panel Attack Modern/input/touch.png differ diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 95b2a7f8e..30661273a 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -12,10 +12,11 @@ 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 ----@field mode GameMode +---@field mode GameMode The game mode configuration defining rules, player count, and match settings for this battle room ---@field players Player[] ---@field spectators string[] ---@field spectating boolean @@ -59,7 +60,8 @@ end) BattleRoom.states = { Setup = 1, MatchInProgress = 2 } function BattleRoom.createFromServerMessage(message) - local battleRoom = BattleRoom(message.gameMode) + local gameMode = GameModes.createFromServerData(message.gameMode) + local battleRoom = BattleRoom(gameMode) if message.spectate_request_granted then logger.debug("Joining a match as spectator") @@ -108,19 +110,8 @@ function BattleRoom.createFromServerMessage(message) p = Player(player.name, player.publicId or -i, false) end - -- order is important here as setting style will indirectly also override levelData so it needs to be before updateSettings - if gameMode.style ~= GameModes.Styles.CHOOSE then - p:setStyle(gameMode.style) - else - if player.settings.levelData then - if player.settings.levelData.frameConstants.GARBAGE_HOVER then - p:setStyle(GameModes.Styles.MODERN) - else - p:setStyle(GameModes.Styles.CLASSIC) - end - end - end - + -- updateSettings will set levelData which triggers levelDataChanged signal + -- which will automatically update style based on the levelData p:updateSettings(player.settings) if player.ratingInfo then @@ -135,37 +126,40 @@ function BattleRoom.createFromServerMessage(message) battleRoom:updateRankedStatus(message.ranked) - battleRoom:assignInputConfigurations() + battleRoom:restoreInputConfigurations() GAME.netClient:registerPlayerUpdates(battleRoom) return battleRoom end -function BattleRoom.createLocalFromGameMode(gameMode, gameScene) +-- Creates a local (offline) BattleRoom from a GameMode configuration. +-- For single-player modes, uses the game's main local player. For multi-player modes, +-- creates temporary local players that don't persist settings changes. +---@param gameMode GameMode The game mode configuration defining rules and player count +---@param gameScene table? Optional scene class to use for matches (defaults to mode's gameScene) +---@param settingChangesUpdateConfig boolean? If true, setting changes update config (default: true). Only applies to single-player modes. +---@return BattleRoom? battleRoom The created battle room, or nil if input configuration assignment fails +function BattleRoom.createLocalFromGameMode(gameMode, gameScene, settingChangesUpdateConfig) + if settingChangesUpdateConfig == nil then + settingChangesUpdateConfig = true + end + local battleRoom = BattleRoom(gameMode, gameScene) - if gameMode.playerCount == 1 then + if settingChangesUpdateConfig and gameMode.playerCount == 1 then -- always use the game client's local player battleRoom:addPlayer(GAME.localPlayer) else -- with more than 1 local player we can't be sure which player is the "real" regular user -- so make them both local players that don't update config settings for i = 1, gameMode.playerCount do - local player = Player.getLocalPlayer() + local player = Player.createLocalPlayerFromConfig() player.name = loc("player_n", i) battleRoom:addPlayer(player) end end - if gameMode.style ~= GameModes.Styles.CHOOSE then - for i, player in ipairs(battleRoom.players) do - if player.human then - battleRoom.players[i]:setStyle(gameMode.style) - end - end - end - - if battleRoom:assignInputConfigurations() then + if battleRoom:restoreInputConfigurations() then return battleRoom else return nil @@ -390,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 @@ -404,28 +398,6 @@ function BattleRoom:createScene(match) end end --- sets the style of "level" presets the players select from --- 1 = classic --- 2 = modern --- longterm we want to abandon the concept of "style" on the player / battleRoom level --- just setting difficulty or level should set the levelData and done with it, style is a menu-only concept --- there is no technical reason why someone on level 10 shouldn't be able to play against someone on Hard --- for now it's a battleRoom wide setting and players have to match -function BattleRoom:setStyle(styleChoice) - -- style could be configurable per play instead but let's not for now - if self.mode.style == GameModes.Styles.CHOOSE then - self.style = styleChoice - self.onStyleChanged(styleChoice) - else - error("Trying to set difficulty style in a game mode that doesn't support style selection") - end -end - --- not player specific, so this gets a separate callback that can only be overwritten once --- so the UI can update and load up the different controls for it -function BattleRoom.onStyleChanged(style, player) -end - function BattleRoom:startLoadingNewAssets() if ModLoader.loading_mod == nil then for _, player in ipairs(self.players) do @@ -441,87 +413,147 @@ function BattleRoom:startLoadingNewAssets() end end --- updates a player's input configuration --- if lock is true it tries to claim the first unclaim inputConfiguration for which a key is down (may not claim any) --- if lock is false it unclaims the player's current inputConfiguration -function BattleRoom.updateInputConfigurationForPlayer(player, lock) - if lock then - for i, inputConfiguration in ipairs(GAME.input.inputConfigurations) do - if not inputConfiguration.claimed and tableUtils.length(inputConfiguration.isDown) > 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 - -- 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 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 - 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) @@ -530,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 680a3ba75..53dae68a7 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/ChallengeModePlayerStack.lua b/client/src/ChallengeModePlayerStack.lua index 91d3bb198..eea1a3be5 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 67ae77946..1574a3b0a 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 @@ -395,10 +396,10 @@ function ClientMatch:finalizeReplay() ---@cast player Player metadata.name = player.name metadata.publicId = player.publicId - if player.settings.style == GameModes.Styles.MODERN then - metadata.level = player.settings.level - else - metadata.difficulty = player.settings.difficulty + if stack.level then + metadata.level = stack.level + elseif stack.difficulty then + metadata.difficulty = stack.difficulty end metadata.analytics = player.stack.analytic.data ---@diagnostic disable-next-line: inject-field @@ -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 c8052bfb0..3f05bb2c6 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -11,10 +11,11 @@ require("client.src.mods.Theme") -- Not to be confused with "Match" which is the current battle / instance of the game. local consts = require("common.engine.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") +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") @@ -32,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 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") +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 @@ -65,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 @@ -105,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 @@ -125,13 +141,16 @@ 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 - self.navigationStack = require("client.src.NavigationStack") + inputManager:load() + + self:setupInputSignals() + + 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 @@ -222,7 +241,7 @@ end function Game:setupRoutine() -- loading various assets into the game - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) detectHardwareProblems() @@ -252,12 +271,14 @@ 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 -- it basically represents the player that is operating the client (and thus binds to its configuration) function Game:initializeLocalPlayer() - self.localPlayer = Player.getLocalPlayer() + self.localPlayer = Player.createLocalPlayerFromConfig() self.localPlayer:connectSignal("selectedCharacterIdChanged", config, function(config, newId) config.character = newId end) self.localPlayer:connectSignal("selectedStageIdChanged", config, function(config, newId) config.stage = newId end) self.localPlayer:connectSignal("panelIdChanged", config, function(config, newId) config.panels = newId end) @@ -266,11 +287,17 @@ function Game:initializeLocalPlayer() self.localPlayer:connectSignal("difficultyChanged", config, function(config, difficulty) config.endless_difficulty = difficulty end) self.localPlayer:connectSignal("levelChanged", config, function(config, level) config.level = level end) self.localPlayer:connectSignal("wantsRankedChanged", config, function(config, wantsRanked) config.ranked = wantsRanked end) - self.localPlayer:connectSignal("styleChanged", config, function(config, style) - if style == GameModes.Styles.CLASSIC then - config.endless_level = nil - else - config.endless_level = config.level + + self.localPlayer:connectSignal("levelDataChanged", config, function(config, levelData, player) + local presetInfo = LevelPresets.getStyleAndPreset(levelData) + if presetInfo then + if presetInfo.style == GameModes.Styles.MODERN then + config.level = presetInfo.level + config.endless_level = presetInfo.level + else + config.endless_difficulty = presetInfo.difficulty + config.endless_level = nil + end end end) end @@ -305,21 +332,6 @@ function Game:createDirectoriesIfNeeded() end end -function Game:runUnitTests() - coroutine.yield("Running Unit Tests") - - -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate - -- basically the player that is operating the client - GAME.localPlayer = Player.getLocalPlayer() - -- we need to overwrite the local player as all replay related tests need a non-local player - GAME.localPlayer.isLocal = false - - logger.info("Running Unit Tests...") - GAME.muteSound = true - --require("client.tests.Tests") - SoundController:applyConfigVolumes() -end - function Game:runPerformanceTests() coroutine.yield("Running Performance Tests") require("tests.StackReplayPerformanceTests") @@ -360,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) @@ -374,9 +395,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) @@ -394,7 +415,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() @@ -410,8 +431,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 @@ -632,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 @@ -659,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()) @@ -674,4 +693,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 e9ef669c3..89ff75a42 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/Player.lua b/client/src/Player.lua index a7711081d..4ff36d926 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) @@ -107,12 +112,19 @@ function Player:createClientStack(engineStack) player = self, } - if self.settings.style == GameModes.Styles.MODERN then - args.level = self.settings.level - else - args.difficulty = self.settings.difficulty + local presetInfo = LevelPresets.getStyleAndPreset(self.settings.levelData) + if presetInfo then + if presetInfo.style == GameModes.Styles.MODERN then + if presetInfo.level then + args.level = self.settings.level + end + else + if presetInfo.difficulty then + args.difficulty = self.settings.difficulty + end + end end - + self.stack = PlayerStack(args) return self.stack @@ -144,7 +156,7 @@ end function Player:setLevelData(levelData) self.settings.levelData = levelData self:setSpeed(levelData.startingSpeed) - self:emitSignal("levelDataChanged", levelData) + self:emitSignal("levelDataChanged", levelData, self) end function Player:setSpeed(speed) @@ -172,18 +184,11 @@ end -- sets the style of "level" presets the player selects from -- 1 = classic -- 2 = modern --- longterm we want to abandon the concept of "style" on the player / battleRoom level --- just setting difficulty or level should set the levelData and done with it, style is a menu-only concept --- there is no technical reason why someone on level 10 shouldn't be able to play against someone on Hard +-- style is a menu-only concept for UI display +-- derived settings (levelData) should be updated by calling gameMode.updateLocalPlayersDerivedSettings function Player:setStyle(style) if style ~= self.settings.style then self.settings.style = style - if style == GameModes.Styles.MODERN then - self:setLevelData(LevelPresets.getModern(self.settings.level or config.level)) - else - self:setLevelData(LevelPresets.getClassic(self.settings.difficulty or config.endless_difficulty)) - self:setSpeed(self.settings.speed) - end self:emitSignal("styleChanged", style) end end @@ -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,11 +235,12 @@ function Player:unrestrictInputs() self.lastUsedInputConfiguration = self.inputConfiguration input:releaseConfiguration(self, self.inputConfiguration) self.inputConfiguration = nil + self:emitSignal("inputConfigurationChanged", nil) end end ---@return Player -function Player.getLocalPlayer() +function Player.createLocalPlayerFromConfig() local player = Player(config.name, -1, true) player:setDifficulty(config.endless_difficulty) @@ -246,10 +253,8 @@ function Player.getLocalPlayer() player:setInputMethod(config.inputMethod) if config.endless_level then player:setStyle(GameModes.Styles.MODERN) - player:setLevelData(LevelPresets.getModern(player.settings.level)) else player:setStyle(GameModes.Styles.CLASSIC) - player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) player:setSpeed(config.endless_speed) end @@ -325,16 +330,6 @@ function Player:updateSettings(settings) end if settings.levelData ~= nil then - if settings.level ~= nil then - if settings.levelData.frameConstants.GARBAGE_HOVER then - self:setStyle(GameModes.Styles.MODERN) - self:setLevel(settings.level) - elseif settings.level <= LevelPresets.classicPresetCount then - self:setStyle(GameModes.Styles.CLASSIC) - self:setDifficulty(settings.level) - end - end - self:setLevelData(settings.levelData) end diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index 4b3252096..81f410220 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 2d428a57f..7c009e3ff 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 c83d8b2e2..cafb3ab77 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -3,12 +3,13 @@ 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 ---@class UserConfig ---@field version string ----@field language_code string +---@field language_code string? ---@field theme string ---@field panels string? ---@field character string @@ -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 @@ -55,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, @@ -93,12 +90,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, @@ -138,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 ) @@ -226,19 +222,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 +293,11 @@ 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 end diff --git a/client/src/debug/DebugMenu.lua b/client/src/debug/DebugMenu.lua new file mode 100644 index 000000000..311e352fc --- /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 000000000..43b02a2dc --- /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 ff4cc477f..22f52222c 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/graphics/InputPromptRenderer.lua b/client/src/graphics/InputPromptRenderer.lua new file mode 100644 index 000000000..8147b3c3b --- /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 000000000..63130e616 --- /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 000000000..b753d3299 --- /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 d5aaf35cb..b450c89fe 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 821972eb3..b5ca96a49 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 f7c630686..7eccafb7d 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/network/NetClient.lua b/client/src/network/NetClient.lua index 4bb83eab1..4954bae60 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -125,7 +125,12 @@ local function processLeaveRoomMessage(self, message) -- instead we actively abort the match ourselves self.room.match:abort() self.room.match:deinit() - transition = MessageTransition(love.timer.getTime(), 5, message.reason or "", false) + + if message.reason then + -- the server sends a reason for leaveRoom only if a player (not a spectator) in the room leaves/crashes/disconnects + -- the other player and spectators should be informed why the room is being closed + transition = MessageTransition(love.timer.getTime(), 5, message.reason, false) + end end -- and then shutdown the room @@ -458,6 +463,7 @@ function NetClient:requestSpectate(roomNumber) end end +---@param gameMode GameMode function NetClient:requestRoom(gameMode) if self:isConnected() then self.tcpClient:sendRequest(ClientMessages.sendRoomRequest(gameMode)) diff --git a/client/src/save.lua b/client/src/save.lua index d3cc425af..6c5aaa4cd 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 f41efaf34..0690c0018 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -9,6 +9,7 @@ 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 @@ -58,6 +59,50 @@ function CharacterSelect:load() 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 + if player.isLocal then + self:initializeFromLocalPlayerSettings(player) + end + player:connectSignal("levelDataChanged", self, self.onLevelDataChanged) + self:onLevelDataChanged(player.settings.levelData, player) + end + end +end + +function CharacterSelect:onLevelDataChanged(levelData, player) + local presetInfo = LevelPresets.getStyleAndPreset(levelData) + + if not presetInfo then + -- Custom levelData, default to current settings + return + end + + if presetInfo.style == GameModes.Styles.MODERN then + player:setStyle(GameModes.Styles.MODERN) + if presetInfo.level then + player:setLevel(presetInfo.level) + end + else + player:setStyle(GameModes.Styles.CLASSIC) + if presetInfo.difficulty then + player:setDifficulty(presetInfo.difficulty) + end + end + + self:refresh() +end + +function CharacterSelect:initializeFromLocalPlayerSettings(player) + player:setStyle(GameModes.Styles.MODERN) + player:setLevel(player.settings.level) + player:setLevelData(LevelPresets.getModern(player.settings.level)) end ---@param player Player @@ -247,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 ) @@ -636,9 +743,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) @@ -648,31 +764,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 @@ -689,19 +814,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) @@ -893,7 +1019,7 @@ function CharacterSelect:createSpeedSlider(player, height, min) return uiElement end -function CharacterSelect:createDifficultyCarousel(player, height) +function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) local passengers = { { id = 1, uiElement = ui.Label({text = "easy", vAlign = "center", hAlign = "center"})}, { id = 2, uiElement = ui.Label({text = "normal", vAlign = "center", hAlign = "center"})}, @@ -918,30 +1044,26 @@ function CharacterSelect:createDifficultyCarousel(player, height) -- Just update on every passenger change end - local updateDifficultyData = function(difficultyID) - local levelData = LevelPresets.getClassic(difficultyID) - player:setDifficulty(difficultyID) - if self.battleRoom.mode.name == "endless" and difficultyID == 1 then - -- Endless easy uses 5 colors instead of 6 - levelData:setColorCount(5) - -- and by extension also allows adjacent panels of the same colors - levelData:setAdjacentDenialFrequency(0) - end - player:setLevelData(levelData) - end difficultyCarousel.onPassengerUpdateCallback = function(carousel, selectedPassenger) - updateDifficultyData(selectedPassenger.id) + player:setDifficulty(selectedPassenger.id) + if getPresetFunc then + player:setLevelData(getPresetFunc(selectedPassenger.id)) + end GAME.theme:playMoveSfx() - self:refresh() end - -- Note that this updates the player level data which could be wrong before because of the weird endless case - -- its probably fine for now, but ideally the model should be right when the battle room is created - updateDifficultyData(difficultyCarousel.selectedId) + + -- to update the UI if code gets changed from the backend (e.g. network messages) + player:connectSignal("difficultyChanged", difficultyCarousel, difficultyCarousel.setPassengerById) return difficultyCarousel 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 664ccddc1..6bc6285ce 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 90b100d87..48f3fa28f 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 dda09e6e3..6a4ad970e 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) @@ -85,14 +87,13 @@ function CharacterSelectVsSelf:refresh() local level if self.battleRoom then level = self.battleRoom.players[1].settings.level - else - level = GAME.localPlayer.settings.level + self.lastScore = GAME.scores:lastVsScoreForLevel(level) + self.record = GAME.scores:recordVsScoreForLevel(level) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end end - - self.lastScore = GAME.scores:lastVsScoreForLevel(level) - self.record = GAME.scores:recordVsScoreForLevel(level) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) end -return CharacterSelectVsSelf \ No newline at end of file +return CharacterSelectVsSelf diff --git a/client/src/scenes/DesignHelper.lua b/client/src/scenes/DesignHelper.lua index ac9627716..2fc419301 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/DiscordCommunitySetup.lua b/client/src/scenes/DiscordCommunitySetup.lua new file mode 100644 index 000000000..ea8930a02 --- /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/EndlessGame.lua b/client/src/scenes/EndlessGame.lua index 6f8b0ea91..f8134d49e 100644 --- a/client/src/scenes/EndlessGame.lua +++ b/client/src/scenes/EndlessGame.lua @@ -17,7 +17,7 @@ end ---@param match ClientMatch function EndlessGame:onMatchEnded(match) - if match.players[1].settings.style == GameModes.Styles.CLASSIC then + if match.players[1].stack.difficulty then GAME.scores:saveEndlessScoreForLevel(match.players[1].stack.engine.score, match.players[1].stack.difficulty) end end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index 626c9c600..ea5936caf 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -1,6 +1,7 @@ local CharacterSelect = require("client.src.scenes.CharacterSelect") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local ui = require("client.src.ui") -- Scene for the endless game setup menu @@ -48,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) @@ -71,7 +72,7 @@ function EndlessMenu:loadUserInterface() }) self.ui.difficultySelection:setTitle("difficulty") - local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height) + local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height, LevelPresets.getClassicEndless) self.ui.difficultySelection:addElement(difficultyCarousel, player) self.ui.levelSelection = ui.MultiPlayerSelectionWrapper({hFill = true, alignment = "top", hAlign = "center", vAlign = "top"}) @@ -79,25 +80,14 @@ function EndlessMenu:loadUserInterface() local levelSlider = self:createLevelSlider(player, 20, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.levelSelection.height) self.ui.levelSelection:addElement(levelSlider, player) - if player.settings.style == GameModes.Styles.MODERN then - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - else - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - end - styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() - self.ui.grid:removeElementsIn(6, 2, 3, 1) if value and player.settings.style ~= GameModes.Styles.MODERN then - player:setStyle(GameModes.Styles.MODERN) - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - self.ui.recordBox:setVisibility(false) + -- Set levelData for modern style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getModern(player.settings.level)) elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then - player:setStyle(GameModes.Styles.CLASSIC) - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - self.ui.recordBox:setVisibility(true) + -- Set levelData for classic endless style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getClassicEndless(player.settings.difficulty)) end end @@ -114,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) @@ -124,19 +117,42 @@ function EndlessMenu:loadUserInterface() self.ui.cursors[1].raise2Callback = function() self.ui.characterGrid:turnPage(1) end + + player:connectSignal("styleChanged", self, self.onStyleChanged) + self:onStyleChanged(player.settings.style, player) +end + +function EndlessMenu:onStyleChanged(style, player) + if style == GameModes.Styles.MODERN then + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) + self.ui.recordBox:setVisibility(false) + else + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) + self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) + self.ui.recordBox:setVisibility(true) + end +end + +function EndlessMenu:initializeFromLocalPlayerSettings(player) + if player.settings.style == GameModes.Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + else + player:setLevelData(LevelPresets.getClassicEndless(player.settings.difficulty)) + end end function EndlessMenu:refresh() - local difficulty if self.battleRoom then - difficulty = self.battleRoom.players[1].settings.difficulty - else - difficulty = GAME.localPlayer.settings.difficulty + local difficulty = self.battleRoom.players[1].settings.difficulty + self.lastScore = GAME.scores:lastEndlessForLevel(difficulty) + self.record = GAME.scores:recordEndlessForLevel(difficulty) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end end - self.lastScore = GAME.scores:lastEndlessForLevel(difficulty) - self.record = GAME.scores:recordEndlessForLevel(difficulty) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) end return EndlessMenu \ No newline at end of file diff --git a/client/src/scenes/GameBase.lua b/client/src/scenes/GameBase.lua index 8c8605577..bf1f793ef 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/InputConfigMenu.lua b/client/src/scenes/InputConfigMenu.lua index ab9187a09..4c12f96f0 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 000000000..a4731cf88 --- /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 8054187c7..43e6ee87b 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") @@ -12,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") @@ -23,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 @@ -40,6 +39,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 +113,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)) @@ -149,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/ModManagement.lua b/client/src/scenes/ModManagement.lua index 95761b775..5b5bac0d5 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 daf252512..186967886 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) @@ -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 }) @@ -486,30 +480,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 3f05ab166..d95886cb9 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/PuzzleMenu.lua b/client/src/scenes/PuzzleMenu.lua index b2fe595b5..48f95dc03 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/ReplayBrowser.lua b/client/src/scenes/ReplayBrowser.lua index 30d42ad3f..14f0c8d11 100644 --- a/client/src/scenes/ReplayBrowser.lua +++ b/client/src/scenes/ReplayBrowser.lua @@ -223,8 +223,10 @@ function ReplayBrowser:draw() if player.level then GraphicsUtil.print(loc("rp_browser_info_level", player.level), menu_x + offsetX, menu_y + 95) else - GraphicsUtil.print(loc("rp_browser_info_speed", stack.levelData.startingSpeed), menu_x + offsetX, menu_y + 95) - GraphicsUtil.print(loc("rp_browser_info_difficulty", player.difficulty), menu_x + offsetX, menu_y + 110) + if player.difficulty then + GraphicsUtil.print(loc("rp_browser_info_speed", stack.levelData.startingSpeed), menu_x + offsetX, menu_y + 95) + GraphicsUtil.print(loc("rp_browser_info_difficulty", player.difficulty), menu_x + offsetX, menu_y + 110) + end end else ---@cast player SimulatedStackMetadata diff --git a/client/src/scenes/ReplayGame.lua b/client/src/scenes/ReplayGame.lua index f2e306a7f..911cc0a9a 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 ea9f9adce..bd64d66bf 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") @@ -16,10 +17,12 @@ local directsFocus = require("client.src.ui.FocusDirector") ---@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 @@ -34,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 ) @@ -83,7 +89,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/SceneCoordinator.lua b/client/src/scenes/SceneCoordinator.lua new file mode 100644 index 000000000..e0e94cc39 --- /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 ab135b3d1..e345a20ce 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/TimeAttackGame.lua b/client/src/scenes/TimeAttackGame.lua index b604eb27d..9a8884255 100644 --- a/client/src/scenes/TimeAttackGame.lua +++ b/client/src/scenes/TimeAttackGame.lua @@ -17,7 +17,7 @@ end ---@param match ClientMatch function TimeAttackGame:onMatchEnded(match) - if match.players[1].settings.style == GameModes.Styles.CLASSIC then + if match.players[1].stack.difficulty then GAME.scores:saveTimeAttack1PScoreForLevel(match.players[1].stack.engine.score, match.players[1].stack.difficulty) end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 75904d97a..41fc73388 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local CharacterSelect = require("client.src.scenes.CharacterSelect") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local ui = require("client.src.ui") -- Scene for the time attack game setup menu @@ -47,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) @@ -70,7 +71,7 @@ function TimeAttackMenu:loadUserInterface() }) self.ui.difficultySelection:setTitle("difficulty") - local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height) + local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height, LevelPresets.getClassic) self.ui.difficultySelection:addElement(difficultyCarousel, player) self.ui.levelSelection = ui.MultiPlayerSelectionWrapper({hFill = true, alignment = "top", hAlign = "center", vAlign = "top"}) @@ -78,25 +79,14 @@ function TimeAttackMenu:loadUserInterface() local levelSlider = self:createLevelSlider(player, 20, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.levelSelection.height) self.ui.levelSelection:addElement(levelSlider, player) - if player.settings.style == GameModes.Styles.MODERN then - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - else - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - end - styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() - self.ui.grid:removeElementsIn(6, 2, 3, 1) if value and player.settings.style ~= GameModes.Styles.MODERN then - player:setStyle(GameModes.Styles.MODERN) - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - self.ui.recordBox:setVisibility(false) + -- Set levelData for modern style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getModern(player.settings.level)) elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then - player:setStyle(GameModes.Styles.CLASSIC) - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - self.ui.recordBox:setVisibility(true) + -- Set levelData for classic style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) end end @@ -113,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) @@ -123,20 +116,42 @@ function TimeAttackMenu:loadUserInterface() self.ui.cursors[1].raise2Callback = function() self.ui.characterGrid:turnPage(1) end + + player:connectSignal("styleChanged", self, self.onStyleChanged) + self:onStyleChanged(player.settings.style, player) end -function TimeAttackMenu:refresh() - local difficulty - if self.battleRoom then - difficulty = self.battleRoom.players[1].settings.difficulty +function TimeAttackMenu:onStyleChanged(style, player) + if style == GameModes.Styles.MODERN then + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) + self.ui.recordBox:setVisibility(false) else - difficulty = GAME.localPlayer.settings.difficulty + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) + self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) + self.ui.recordBox:setVisibility(true) end +end - self.lastScore = GAME.scores:lastTimeAttack1PForLevel(difficulty) - self.record = GAME.scores:recordTimeAttack1PForLevel(difficulty) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) +function TimeAttackMenu:initializeFromLocalPlayerSettings(player) + if player.settings.style == GameModes.Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + else + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) + end +end + +function TimeAttackMenu:refresh() + if self.battleRoom then + local difficulty = self.battleRoom.players[1].settings.difficulty + self.lastScore = GAME.scores:lastTimeAttack1PForLevel(difficulty) + self.record = GAME.scores:recordTimeAttack1PForLevel(difficulty) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end + end end return TimeAttackMenu \ No newline at end of file diff --git a/client/src/scenes/TitleScreen.lua b/client/src/scenes/TitleScreen.lua index 05fc98972..93e37ace6 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/VsSelfGame.lua b/client/src/scenes/VsSelfGame.lua index b3f7063b2..f8f91d64e 100644 --- a/client/src/scenes/VsSelfGame.lua +++ b/client/src/scenes/VsSelfGame.lua @@ -17,7 +17,9 @@ end ---@param match ClientMatch function VsSelfGame:onMatchEnded(match) local P1 = match.players[1].stack - GAME.scores:saveVsSelfScoreForLevel(P1.analytic.data.sent_garbage_lines, P1.level) + if P1.level then + GAME.scores:saveVsSelfScoreForLevel(P1.analytic.data.sent_garbage_lines, P1.level) + end end return VsSelfGame \ No newline at end of file diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua new file mode 100644 index 000000000..f718e990e --- /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 000000000..c71f67e68 --- /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/BoolSelector.lua b/client/src/ui/BoolSelector.lua index 5d4d7e83e..2827daeb2 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 4215d017f..bfd71988a 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/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua new file mode 100644 index 000000000..1da6f475a --- /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 000000000..0d400b74a --- /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/Grid.lua b/client/src/ui/Grid.lua index 8d1d1cbbd..aedf54c00 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) @@ -91,8 +92,12 @@ function Grid:drawSelf() end end --- removes all gridElements overlapping with the specified box --- the box is top left anchored +--- removes all gridElements overlapping with the specified box +--- the box is top left anchored +---@param x integer +---@param y integer +---@param width integer +---@param height integer function Grid:removeElementsIn(x, y, width, height) height = height or 1 width = width or 1 diff --git a/client/src/ui/GridElement.lua b/client/src/ui/GridElement.lua index d0725fdf8..ff9165674 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/InputConfigSlider.lua b/client/src/ui/InputConfigSlider.lua new file mode 100644 index 000000000..237b25233 --- /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 000000000..4ec4559f1 --- /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 31d6383b4..7cc77d498 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 0bc6fbab0..1b46b178b 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 7931404d2..e8649425b 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -5,9 +5,20 @@ 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) +---@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, @@ -15,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) @@ -27,7 +40,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 +51,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 @@ -50,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) @@ -74,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) @@ -96,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) @@ -108,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) @@ -120,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) @@ -128,10 +189,24 @@ 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 +---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 @@ -140,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/MultiPlayerSelectionWrapper.lua b/client/src/ui/MultiPlayerSelectionWrapper.lua index c0802d71c..969dba238 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 000000000..dfc1c9a91 --- /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, "OverlayContainer" +) + +-- 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 2dfe1bc41..a96458946 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/SliderMenuItem.lua b/client/src/ui/SliderMenuItem.lua new file mode 100644 index 000000000..406882a87 --- /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 e1b226976..da7a729b0 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 +---@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 a342bb645..9aeba5b22 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/UIElement.lua b/client/src/ui/UIElement.lua index 4c1bc4c52..0ab2c3980 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 a884de6b9..5b74969dc 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 e297c2646..d1cfc9e25 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -10,16 +10,20 @@ 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 + -- prevent multitouch + if not self.touchedElement then + self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) + 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) + 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 end end diff --git a/client/tests/DiscreteImageSliderTests.lua b/client/tests/DiscreteImageSliderTests.lua new file mode 100644 index 000000000..ed8f62c78 --- /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 000000000..0071073fc --- /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/client/tests/PlayerSettingsTests.lua b/client/tests/PlayerSettingsTests.lua new file mode 100644 index 000000000..680b0dfa7 --- /dev/null +++ b/client/tests/PlayerSettingsTests.lua @@ -0,0 +1,108 @@ +local Player = require("client.src.Player") +local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") +local CharacterSelect = require("client.src.scenes.CharacterSelect") +local BattleRoom = require("client.src.BattleRoom") + +local function testDifficultyCarouselShouldNotMutatePlayerOnCreation() + + local player = Player("TestPlayer", 1, true) + + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local battleRoom = BattleRoom(gameMode) + battleRoom:addPlayer(player) + + local characterSelect = CharacterSelect({battleRoom = battleRoom}) + + local originalLevel = player.settings.level + local originalDifficulty = player.settings.difficulty + local originalStyle = player.settings.style + local originalGarbageHover = player.settings.levelData.frameConstants.GARBAGE_HOVER + local originalColorCount = player.settings.levelData.colors + + local _ = characterSelect:createDifficultyCarousel(player, 100) + + assert(player.settings.level == originalLevel, "createDifficultyCarousel should not change level, but changed from " .. originalLevel .. " to " .. player.settings.level) + assert(player.settings.difficulty == originalDifficulty, "createDifficultyCarousel should not change difficulty, but changed from " .. tostring(originalDifficulty) .. " to " .. tostring(player.settings.difficulty)) + assert(player.settings.style == originalStyle, "createDifficultyCarousel should not change style, but changed from " .. originalStyle .. " to " .. player.settings.style) + assert(player.settings.levelData.frameConstants.GARBAGE_HOVER == originalGarbageHover, "createDifficultyCarousel should not change GARBAGE_HOVER, but changed from " .. tostring(originalGarbageHover) .. " to " .. tostring(player.settings.levelData.frameConstants.GARBAGE_HOVER)) + assert(player.settings.levelData.colors == originalColorCount, "createDifficultyCarousel should not change color count, but changed from " .. originalColorCount .. " to " .. player.settings.levelData.colors) + + battleRoom:shutdown() +end + +testDifficultyCarouselShouldNotMutatePlayerOnCreation() + +local function testEndlessModeClassicDifficulty1SetsCorrectSettings() + + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local battleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) + + assert(battleRoom ~= nil, "BattleRoom should be created successfully") + assert(#battleRoom.players == 1, "BattleRoom should have exactly 1 player") + + local battleRoomPlayer = battleRoom.players[1] + + battleRoomPlayer:setLevelData(LevelPresets.getClassicEndless(1)) + + assert(battleRoomPlayer.settings.levelData.colors == 5, + "Endless mode with classic difficulty 1 should have 5 colors, but got " .. + tostring(battleRoomPlayer.settings.levelData.colors)) + + assert(battleRoomPlayer.settings.levelData.adjacentDenialFrequency == 0, + "Endless mode with classic difficulty 1 should have adjacent denial frequency of 0, but got " .. + tostring(battleRoomPlayer.settings.levelData.adjacentDenialFrequency)) + + battleRoom:shutdown() +end + +testEndlessModeClassicDifficulty1SetsCorrectSettings() + +local function testVsSelfChangesEndlessClassicSettingsToModern() + -- First create an endless battle room with classic difficulty 1 + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local endlessBattleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) + + assert(endlessBattleRoom ~= nil, "Endless BattleRoom should be created successfully") + + local endlessPlayer = endlessBattleRoom.players[1] + + -- Set to classic difficulty 1 (simulating what happens in endless mode) + endlessPlayer:setLevelData(LevelPresets.getClassicEndless(1)) + + -- Verify endless settings + assert(endlessPlayer.settings.style == GameModes.Styles.CLASSIC, + "Player should have classic style before vs self") + assert(endlessPlayer.settings.levelData.colors == 5, + "Player should have 5 colors before vs self") + assert(endlessPlayer.settings.levelData.adjacentDenialFrequency == 0, + "Player should have adjacent denial frequency of 0 before vs self") + + endlessBattleRoom:shutdown() + + -- Now create a vs self battle room which should change settings to modern + local vsSelfGameMode = GameModes.getPreset("ONE_PLAYER_VS_SELF") + local vsSelfBattleRoom = BattleRoom.createLocalFromGameMode(vsSelfGameMode, nil, false) + + assert(vsSelfBattleRoom ~= nil, "Vs self BattleRoom should be created successfully") + + local vsSelfPlayer = vsSelfBattleRoom.players[1] + vsSelfPlayer:setLevelData(LevelPresets.getModern(10)) + + assert(vsSelfPlayer.settings.levelData.frameConstants.GARBAGE_HOVER ~= nil, + "Modern style should have GARBAGE_HOVER set") + + -- Modern levels have 6 colors (not 5 like endless classic difficulty 1) + assert(vsSelfPlayer.settings.levelData.colors == 6, + "Vs self should change color count to 6 (modern default), but got " .. + tostring(vsSelfPlayer.settings.levelData.colors)) + + -- Modern levels have non-zero adjacent denial frequency + assert(vsSelfPlayer.settings.levelData.adjacentDenialFrequency > 0, + "Vs self should have adjacent denial frequency > 0 (modern default), but got " .. + tostring(vsSelfPlayer.settings.levelData.adjacentDenialFrequency)) + + vsSelfBattleRoom:shutdown() +end + +testVsSelfChangesEndlessClassicSettingsToModern() diff --git a/client/tests/StackGraphicsTests.lua b/client/tests/StackGraphicsTests.lua index ddaa19a0d..09db7e0bc 100644 --- a/client/tests/StackGraphicsTests.lua +++ b/client/tests/StackGraphicsTests.lua @@ -26,7 +26,7 @@ local function createEndlessClientMatch(playerCount, theme) playerCount = 1 end for i = 1, playerCount do - local player = Player.getLocalPlayer() + local player = Player.createLocalPlayerFromConfig() player.isLocal = false player:setLevel(10) player:setLevelData(LevelPresets.getModern(10)) diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index 398ffbb77..2e4efbc70 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -1,3 +1,4 @@ +local class = require("common.lib.class") local MatchRules = require("common.data.MatchRules") local TIME_ATTACK_TIME = 120 @@ -13,6 +14,25 @@ local GameModes = {} ---@field gameScene string ---@field style Styles ---@field richPresenceLabel string? +---@field updateLocalPlayersDerivedSettings function +local GameMode = class(function(self, properties) + for key, value in pairs(properties) do + self[key] = value + end +end) + +-- Returns a copy of the game mode data suitable for JSON serialization +-- Removes all methods/functions from the copied data +---@return table +function GameMode:getGameModeJSONData() + local gameModeData = deepcpy(self) + for key, value in pairs(gameModeData) do + if type(value) == "function" then + gameModeData[key] = nil + end + end + return gameModeData +end -- longterm we want to abandon the concept of "style" on the engine and room setup level -- the engine only cares about levelData, style is a menu-only concept @@ -24,8 +44,7 @@ local Styles = { CHOOSE = 0, CLASSIC = 1, MODERN = 2} local StackInteractions = { NONE = 0, VERSUS = 1, SELF = 2, ATTACK_ENGINE = 3 } ---@type GameMode -local OnePlayerVsSelf = { - style = Styles.MODERN, +local OnePlayerVsSelf = GameMode({ gameScene = "VsSelfGame", richPresenceLabel = "1p vs self", -- loc("mm_1_vs"), name = "vsSelf", @@ -40,12 +59,12 @@ local OnePlayerVsSelf = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerTimeAttack = { - style = Styles.CHOOSE, +local OnePlayerTimeAttack = GameMode({ gameScene = "TimeAttackGame", richPresenceLabel = "Time Attack", -- loc("mm_1_time"), name = "timeattack", @@ -60,12 +79,12 @@ local OnePlayerTimeAttack = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerEndless = { - style = Styles.CHOOSE, +local OnePlayerEndless = GameMode({ gameScene = "EndlessGame", richPresenceLabel = "Endless", -- loc("mm_1_endless"), name = "endless", @@ -80,12 +99,12 @@ local OnePlayerEndless = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerTraining = { - style = Styles.MODERN, +local OnePlayerTraining = GameMode({ gameScene = "GameBase", richPresenceLabel = "Training", -- loc("mm_1_training"), name = "training", @@ -100,13 +119,13 @@ local OnePlayerTraining = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerPuzzle = { +local OnePlayerPuzzle = GameMode({ -- flags for battleRoom to evaluate and in some cases offer UI for - style = Styles.MODERN, richPresenceLabel = "Puzzle", -- loc("mm_1_puzzle"), gameScene = "PuzzleGame", name = "puzzle", @@ -124,12 +143,12 @@ local OnePlayerPuzzle = { -- these are extended based on the loaded puzzle stackSetupModifications = {}, doCountdown = false, - } -} + }, + +}) ---@type GameMode -local OnePlayerChallenge = { - style = Styles.MODERN, +local OnePlayerChallenge = GameMode({ gameScene = "Game1pChallenge", richPresenceLabel = "Challenge Mode", -- loc("mm_1_challenge_mode"), name = "challenge", @@ -144,12 +163,12 @@ local OnePlayerChallenge = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local TwoPlayerVersus = { - style = Styles.MODERN, +local TwoPlayerVersus = GameMode({ gameScene = "GameBase", richPresenceLabel = "2p versus", -- loc("mm_2_vs"), name = "VS", @@ -164,11 +183,11 @@ local TwoPlayerVersus = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true - } -} + }, + +}) ---@type GameMode -local TwoPlayerTimeAttack = { - style = Styles.MODERN, +local TwoPlayerTimeAttack = GameMode({ gameScene = "TimeAttackGame", richPresenceLabel = "2p Time Attack", -- loc("mm_2_time"), name = "2p_timeattack", @@ -182,8 +201,9 @@ local TwoPlayerTimeAttack = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) GameModes.Styles = Styles GameModes.StackInteractions = StackInteractions @@ -213,4 +233,27 @@ function GameModes.getPreset(mode) return deepcpy(privateGameModes[mode]) end +-- Creates a GameMode from server message data by loading the preset and applying overrides +---@param gameModeData table The game mode data from the server message +---@return GameMode +function GameModes.createFromServerData(gameModeData) + local preset = nil + for _, gameMode in pairs(privateGameModes) do + if gameMode.name == gameModeData.name then + preset = gameMode + break + end + end + + assert(preset, "Unknown game mode name: " .. tostring(gameModeData.name)) + + local result = deepcpy(preset) + + for key, value in pairs(gameModeData) do + result[key] = value + end + + return result +end + return GameModes diff --git a/common/data/LevelPresets.lua b/common/data/LevelPresets.lua index fa49c68d8..380ac1aea 100644 --- a/common/data/LevelPresets.lua +++ b/common/data/LevelPresets.lua @@ -1,6 +1,7 @@ -- this file documents presets for level data local LevelData = require("common.data.LevelData") local JsonSafePrecision = require("common.data.JsonSafePrecision") +local GameModes = require("common.data.GameModes") ---@type LevelData[] local modern = {} @@ -326,4 +327,71 @@ end LevelPresets.classicPresetCount = #classic +---@type (table) +local classicEndless = {} +-- Deep copy from classic presets and modify only what's different for endless mode +classicEndless[1] = deepcpy(classic[1]) +-- Endless easy uses 5 colors instead of 6 +classicEndless[1]:setColorCount(5) +-- and allows adjacent panels of the same colors +classicEndless[1]:setAdjacentDenialFrequency(0) +classicEndless.easy = classicEndless[1] + +-- Normal, hard, and ex are identical to classic mode for endless +classicEndless[2] = deepcpy(classic[2]) +classicEndless.normal = classicEndless[2] + +classicEndless[3] = deepcpy(classic[3]) +classicEndless.hard = classicEndless[3] + +classicEndless[4] = deepcpy(classic[4]) +classicEndless.ex = classicEndless[4] + +---@param difficulty number | string the difficulty expressed as index 1 2 3 4 or easy normal hard ex +---@return LevelData # a deepcopy of the classic endless preset +function LevelPresets.getClassicEndless(difficulty) + assert(classicEndless[difficulty], "trying to load inexistent difficulty preset" .. difficulty) + return deepcpy(classicEndless[difficulty]) +end + +LevelPresets.classicEndlessPresetCount = #classicEndless + +---@class PresetInfo +---@field style Styles +---@field level integer? +---@field difficulty integer? +---@field isEndless boolean? + +---@param levelData LevelData +---@return PresetInfo? # style and preset information, or nil if levelData doesn't match any preset +function LevelPresets.getStyleAndPreset(levelData) + if not levelData then + return nil + end + + -- Check modern presets + for level = 1, #modern do + if LevelData.__eq(levelData, modern[level]) then + return {style = GameModes.Styles.MODERN, level = level, difficulty = nil, isEndless = false} + end + end + + -- Check classicEndless presets + for difficulty = 1, #classicEndless do + if LevelData.__eq(levelData, classicEndless[difficulty]) then + return {style = GameModes.Styles.CLASSIC, level = nil, difficulty = difficulty, isEndless = true} + end + end + + -- Check classic presets + for difficulty = 1, #classic do + if LevelData.__eq(levelData, classic[difficulty]) then + return {style = GameModes.Styles.CLASSIC, level = nil, difficulty = difficulty, isEndless = false} + end + end + + -- Doesn't match any preset - return nil + return nil +end + return LevelPresets \ No newline at end of file diff --git a/common/engine/Health.lua b/common/engine/Health.lua index 0d0ce4465..7728929e2 100644 --- a/common/engine/Health.lua +++ b/common/engine/Health.lua @@ -1,6 +1,7 @@ local logger = require("common.lib.logger") local consts = require("common.engine.consts") local class = require("common.lib.class") +local JsonSafePrecision = require("common.data.JsonSafePrecision") ---@class HealthSettings ---@field framesToppedOutToLose number Starting value of framesToppedOutToLose @@ -24,7 +25,7 @@ local Health = class( function(self, framesToppedOutToLose, lineClearGPM, height, riseSpeed) self.framesToppedOutToLose = framesToppedOutToLose self.maxSecondsToppedOutToLose = framesToppedOutToLose - self.lineClearRate = lineClearGPM / 60 + self.lineClearRate = JsonSafePrecision.toSafePrecision(lineClearGPM / 60) self.currentLines = 0 self.height = height self.lastWasFourCombo = false @@ -139,7 +140,7 @@ end function Health:getSettings() return { framesToppedOutToLose = self.maxSecondsToppedOutToLose, - lineClearGPM = self.lineClearRate * 60, + lineClearGPM = JsonSafePrecision.toSafePrecision(self.lineClearRate * 60), lineHeightToKill = self.height, riseSpeed = self.initialRiseSpeed } diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 18172e4ce..2efa110a4 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/common/lib/class.lua b/common/lib/class.lua index c85c3f789..71eb8223e 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 c62a0d232..069a6aa0f 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/common/lib/util.lua b/common/lib/util.lua index 3801ccd03..78439b103 100644 --- a/common/lib/util.lua +++ b/common/lib/util.lua @@ -114,7 +114,9 @@ function real_deepcpy(tab) return setmetatable(ret, getmetatable(tab)) end --- copys the full variable deeply +-- Creates a deep copy of a table, recursively copying all nested tables +-- Preserves metatables and handles circular references +-- If the input is not a table, returns it unchanged ---@generic T ---@param tab T ---@return T deepCopy diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 3ed87bf98..5f8d72a98 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -134,11 +134,13 @@ function ClientMessages.sendTaunt(direction, index) } end +---@param gameMode GameMode function ClientMessages.sendRoomRequest(gameMode) + local gameModeData = gameMode:getGameModeJSONData() local roomRequestMessage = { recipient = "server", type = "roomRequest", - content = { gameMode = gameMode } + content = { gameMode = gameModeData } } return { messageType = msgTypes.jsonMessage, diff --git a/docs/DebugSettings.md b/docs/DebugSettings.md new file mode 100644 index 000000000..36dc2a74b --- /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/docs/InputDeviceSelection.md b/docs/InputDeviceSelection.md new file mode 100644 index 000000000..96da5672c --- /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 952272a39..7f2c5f4e4 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() @@ -186,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/server/Game.lua b/server/Game.lua index b585da5c6..28f67a583 100644 --- a/server/Game.lua +++ b/server/Game.lua @@ -77,6 +77,8 @@ function Game.createFromRoomState(room) metadata.level = player.level else -- TODO: https://github.com/panel-attack/panel-game/issues/602 + -- Use this pattern when we are in this area again and testing server + -- local presetInfo = LevelPresets.getStyleAndPreset(levelData) metadata.difficulty = player.level end diff --git a/server/Room.lua b/server/Room.lua index 87cc1d177..f9fa247ed 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -21,7 +21,7 @@ local ServerGame = require("server.Game") ---@field ratings table[] ratings by player number ---@field matchCount integer ---@field game ServerGame? ----@field gameMode GameMode +---@field gameMode table -- only the data portion of the game mode ---@field ranked boolean if the next match is anticipated to be ranked ---@field rankedReasons string[] ---@overload fun(roomNumber: integer, players: ServerPlayer[], gameMode: GameMode, leaderboard: Leaderboard?): Room @@ -29,7 +29,7 @@ local Room = class( ---@param self Room ---@param roomNumber integer ---@param players ServerPlayer[] ----@param gameMode GameMode +---@param gameMode table -- only the data portion of the game mode ---@param leaderboard Leaderboard? function(self, roomNumber, players, gameMode, leaderboard) self.players = players @@ -192,7 +192,7 @@ function Room:remove_spectator(spectator) self.spectators[i].state = "lobby" logger.debug(spectator.name .. " left " .. self.name .. " as a spectator") table.remove(self.spectators, i) - spectator:removeFromRoom(self, spectator.name .. " left") + spectator:removeFromRoom(self) lobbyChanged = true break end diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index e830581ab..17ea93d2c 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -269,7 +269,7 @@ local function testSinglePlayer() assert(message.type == "spectatorUpdate") message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectateRequestGranted" and message.content.replay == nil) - assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"))) + assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"):getGameModeJSONData())) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectatorUpdate") diff --git a/testLauncher.lua b/testLauncher.lua index 2d7064e6c..2029e388c 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -93,6 +93,9 @@ local allTests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "client.tests.StackGraphicsTests", + "client.tests.InputConfigurationTests", + "client.tests.DiscreteImageSliderTests", + "client.tests.PlayerSettingsTests", } -- Check for specific test name argument