diff --git a/COPYING-ASSETS b/COPYING-ASSETS index 726fd0e3..0d47177e 100644 --- a/COPYING-ASSETS +++ b/COPYING-ASSETS @@ -61,6 +61,9 @@ CC BY-SA 4.0 CC BY-NC-SA 4.0 To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ +CC0 1.0 + To view a copy of this license, visit http://creativecommons.org/publicdomain/zero/1.0/ + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 PREAMBLE @@ -230,6 +233,17 @@ themes/Panel Attack Modern/sfx/thud_# The conclusion is that we cannot obtain new licenses by fault of the licensor but the already acquired license is valid. Accordingly these assets should be replaced as soon as convenient or once the licensor becomes available again, maintainers shall obtain additional licenses, whichever is first. +themes/Panel Attack Modern/input/ + Copyright (C) Kenney + License: CC0 1.0 + https://kenney.nl/assets/input-prompts + +themes/Panel Attack Modern/input/controller_snes +themes/Panel Attack Modern/input/controller_n64 +themes/Panel Attack Modern/input/error + Copyright (C) 2025 JamBox + License: CC0 1.0 + Characters ========== diff --git a/client/assets/localization.csv b/client/assets/localization.csv index 7a7a8441..961e4d88 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -16,12 +16,12 @@ large_garbage,Training mode that drops huge chain blocks,Large Garbage,Gros déc width,width of garbage blocks in training mode,Width,Largeur,Largura,幅,Ancho,Breite,Larghezza,ความกว้างก้อนขยะ height,height of garbage blocks in training mode,Height,Hauteur,Altura,高さ,Altura,Höhe,Altezza,ความสูงก้อนขยะ vs,,vs,vs,vs,対,vs,vs,vs,vs -up,,Up,Haut,Cima,上,Arriba,hoch,Su,ขึ้น -down,,Down,Bas,Baixo,下,Abajo,runter,Giù,ลง -left,,Left,Gauche,Esquerda,左,Izquierda,links,Sinistra,ซ้าย -right,,Right,Droite,Direita,右,Derecha,rechts,Destra,ขวา +up,,Up,Haut,Cima,上,Arriba,Hoch,Su,ขึ้น +down,,Down,Bas,Baixo,下,Abajo,Runter,Giù,ลง +left,,Left,Gauche,Esquerda,左,Izquierda,Links,Sinistra,ซ้าย +right,,Right,Droite,Direita,右,Derecha,Rechts,Destra,ขวา start,,Start,Start,Começar,START,Empezar,Start,Avvio,เริ่ม -raise,button used to raise the stack,Raise,Monter,Levantar,高める,Elevar,Heben,Aumentare,เลื่อน board ขึ้น +raise,button used to raise the stack with touch inputs,Raise,Monter,Levantar,高める,Elevar,Anheben,Aumentare,เลื่อน board ขึ้น player,,Player,Joueur,Jogador,プレイヤー,Jugador,Spieler,Giocatore,ผู้เล่น player_n,,Player %1,Joueur %1,Jogador %1,プレイヤー%1,Jugador %1,Spieler %1,Giocatore %1,ผูู้เล่น %1 page,,Page,Page,Página,ページ,Página,Seite,Pagina,หน้า @@ -638,3 +638,29 @@ mod_manage_submods,Label for submods in mod management,Sub Mods,Sous-mods,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 op_about_puzzles,,About custom puzzles,À propos des puzzles personnalisés,Sobre quebra-cabeças personalizados,カスタムパズルについて,Acerca de rompecabezas personalizados,How to: Eigene Puzzles erstellen,A proposito dei puzzle personalizzabili,เกี่ยวกับ custom puzzles +discord_welcome_title,Title for Discord community welcome screen,Welcome to Panel Attack!,Bienvenue dans Panel Attack!,Bem-vindo ao Panel Attack!,パネルアタックへようこそ!,¡Bienvenido a Panel Attack!,Willkommen bei Panel Attack!,Benvenuti in Panel Attack!,ยินดีต้อนรับสู่ Panel Attack! +discord_message_line1,First line of Discord welcome message,"Join our Discord to meet players, share ideas, and talk all things Panel Attack.","Rejoignez notre Discord pour rencontrer des joueurs, partager vos idées et discuter de tout ce qui concerne Panel Attack.","Junte-se ao nosso Discord para conhecer jogadores, compartilhar ideias e falar tudo sobre Panel Attack.","Discordに参加してプレイヤーと出会い、アイデアを共有し、パネルアタックのすべてについて語り合いましょう。","Únete a nuestro Discord para conocer jugadores, compartir ideas y hablar de todo lo relacionado con Panel Attack.","Tritt unserem Discord bei, lerne Spieler kennen, teile Ideen und sprich über alles rund um Panel Attack.","Unisciti al nostro Discord per conoscere giocatori, condividere idee e parlare di tutto ciò che riguarda Panel Attack.","เข้าร่วม Discord ของเราเพื่อพบปะผู้เล่น แชร์ไอเดีย และพูดคุยทุกเรื่องเกี่ยวกับ Panel Attack" +discord_message_line2,Second line of Discord welcome message,"Show off your mods and art, rediscover classic characters and stages, and create exciting new ones.","Présentez vos mods et vos créations, redécouvrez des personnages et des stages classiques, et créez-en de nouveaux passionnants.","Mostre seus mods e artes, redescubra personagens e fases clássicas e crie novidades empolgantes.","自分のMODやアートを披露し、クラシックなキャラクターやステージを再発見し、ワクワクする新しい作品を作りましょう。","Presume tus mods y arte, redescubre personajes y escenarios clásicos y crea otros nuevos emocionantes.","Zeige deine Mods und Kunst, entdecke bekannte Charaktere und Arenen neu und erschaffe deine eigenen.","Mostra i tuoi mod e le tue opere, riscopri personaggi e stage classici e crea nuove emozionanti creazioni.","โชว์ม็อดและงานศิลป์ของคุณ ค้นพบตัวละครและฉากคลาสสิกอีกครั้ง และสร้างสิ่งใหม่ที่น่าตื่นเต้น" +discord_message_line3,Third line of Discord welcome message,"Compete in monthly tournaments and join events for players of every skill level!","Participez à des tournois mensuels et rejoignez des événements pour tous les niveaux !","Compita em torneios mensais e participe de eventos para jogadores de todos os níveis!","毎月のトーナメントで競い合い、あらゆるスキルレベルのプレイヤー向けのイベントに参加しましょう!","Compite en torneos mensuales y únete a eventos para jugadores de todos los niveles.","Tritt in monatlichen Turnieren an und nimm an Events für Spielende aller Fähigkeitsstufen teil!","Competi nei tornei mensili e partecipa a eventi per giocatori di ogni livello di abilità!","เข้าร่วมแข่งขันในทัวร์นาเมนต์รายเดือนและกิจกรรมสำหรับผู้เล่นทุกระดับฝีมือ!" +discord_join_link,Button to join Discord server,Join Discord Server,Rejoindre le serveur Discord,Entrar no servidor Discord,Discordサーバーに参加,Unirse al servidor Discord,Discord-Server beitreten,Unisciti al server Discord,เข้าร่วมเซิร์ฟเวอร์ Discord +next_button,Text shown to continue to next screen,Next,Suivant,Próximo,次へ,Siguiente,Weiter,Avanti,ถัดไป +input_config_new_controller,Message shown when a new controller is detected and configured,"Input configurations added, please verify the button mappings, especially the Confirm, Cancel and Raise keys",Nouvelle manette détectée ! Veuillez vérifier les mappages des boutons.,Novo controlador detectado! Verifique os mapeamentos dos botões.,新しいコントローラーが検出されました!ボタンマッピングを確認してください。,¡Nuevo controlador detectado! Por favor verifique los mapeos de botones.,Neuer Controller erkannt! Bitte überprüfe die Tastenbelegung.,Nuovo controller rilevato! Verifica le mappature dei pulsanti.,ตรวจพบจอยใหม่! กรุณาตรวจสอบการตั้งค่าปุ่ม +swap1,Input configuration label for first swap button,Swap 1 / Select / Confirm,Échanger 1,Trocar 1,スワップ1,Intercambiar 1 / Seleccionar / Confirmar,Tausch 1 / Auswählen / Bestätigen,Scambia 1,สลับ 1 +swap2,Input configuration label for second swap button,Swap 2 / Back,Échanger 2,Trocar 2,スワップ2,Intercambiar 2 / Volver,Tausch 2 / Zurück,Scambia 2,สลับ 2 +raise1,Input configuration label for first raise button,Raise 1 / Previous Page,Monter 1,Levantar 1,上げる1,Elevar 1 / Página anterior,Stapel anheben 1 / Vorherige Seite,Alza 1,เลื่อน 1 +raise2,Input configuration label for second raise button,Raise 2 / Next Page,Monter 2,Levantar 2,上げる2,Elevar 2 / Página posterior,Stapel anheben 2 / Nächste Seite,Alza 2,เลื่อน 2 +tauntup,Input configuration label for taunt up button,Taunt Up / Reset Puzzle,Provocation Haut,Provocação Cima,挑発上,Burla Arriba,Spott 1 / Puzzle zurücksetzen,Provocazione Su,เยาะเย้ยขึ้น +tauntdown,Input configuration label for taunt down button,Taunt Down,Provocation Bas,Provocação Baixo,挑発下,Burla Abajo,Spott 2,Provocazione Giù,เยาะเย้ยลง +change_input_device,Label for changing input device,Change Input Device,Changer de périphérique d'entrée,Alterar dispositivo de entrada,入力デバイスを変更,Cambiar dispositivo de entrada,Eingabegerät ändern,Cambia dispositivo di input,เปลี่ยนอุปกรณ์ควบคุม +hold_button_device,Prompt to press a button on desired input device,Hold a button on the device you want to use,Maintenez enfoncé un bouton sur l'appareil que vous souhaitez utiliser,Mantenha premido um botão no dispositivo que pretende utilizar,使用したいデバイスのボタンを押し続けてください,Mantenga pulsado un botón en el dispositivo que desee utilizar.,"Halte auf dem Gerät, das du verwenden möchtest, eine Taste gedrückt.",Tieni premuto un pulsante sul dispositivo che desideri utilizzare,กดปุ่มบนอุปกรณ์ที่คุณต้องการใช้ +or_touch_player_slot,Prompt to touch player slot for touch input,or touch the player slot if you want to use touch,ou touchez l'emplacement du joueur si vous souhaitez utiliser le tactile,ou toque no espaço do jogador se quiser usar toque,またはタッチを使用する場合はプレイヤースロットをタッチしてください,o toca el espacio del jugador si quieres usar táctil,oder berühre das Spielerfeld\, wenn du Touch verwenden möchtest,o tocca lo slot del giocatore se vuoi usare il touch,หรือแตะช่องผู้เล่นหากคุณต้องการใช้ระบบสัมผัส +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. +Bitte konfiguriere genügend Eingabemethoden und versuche es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. +Si prega di configurare sufficienti configurazioni di input e riprovare.","มีผู้เล่นท้องถิ่นมากกว่าการกำหนดค่า input +โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" +translation_disclaimer,Disclaimer shown in language selection to inform about the quality / source of translation,"Initial translation is performed with tools and may be substandard. Translation is open to improvements from the community.","La traduction initiale est effectuée à l'aide d'outils et peut être de qualité médiocre. La traduction peut être améliorée par la communauté.","A tradução inicial é feita com ferramentas e pode não estar à altura dos padrões. A tradução está aberta a melhorias da comunidade.","初期翻訳はツールによって行われ、水準に達していない可能性があります。翻訳はコミュニティからの改善を受け入れています。","La traducción inicial se realiza con herramientas y puede ser de calidad inferior. La traducción está abierta a mejoras por parte de la comunidad.","Die erste Übersetzung von neuem Text wird mit Tools durchgeführt und kann fehlerhaft sein. Übersetzungen sind offen für Verbesserungsvorschläge aus der Community.","La traduzione iniziale viene eseguita con strumenti e potrebbe non essere di qualità ottimale. La traduzione è aperta a miglioramenti da parte della comunità.", \ No newline at end of file diff --git a/client/assets/themes/Panel Attack Modern/discord_logo.png b/client/assets/themes/Panel Attack Modern/discord_logo.png new file mode 100644 index 00000000..23bfafec 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 00000000..dd259c2e 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 00000000..84cf92b6 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 00000000..fec74943 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 00000000..a722048a 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 00000000..989109a6 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 00000000..40cf7e39 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 00000000..c3c49113 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 00000000..a18b0e75 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 00000000..1c56be11 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 00000000..da4fcf96 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 00000000..57326f23 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 00000000..cf192394 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 00000000..8940b55b 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 00000000..40eeba3d 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 00000000..24b9a170 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 00000000..77926303 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 00000000..71edd888 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 00000000..bf87da84 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 00000000..cc431d95 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 00000000..112ffefd 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 00000000..32ebca18 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 00000000..17e3ca09 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 00000000..8675e208 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 00000000..6b1fbeec 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 00000000..ee04ada4 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 00000000..5d7ba875 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 00000000..1bf4b077 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 00000000..ea1a4584 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 0beeb535..2630428c 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -126,7 +126,7 @@ function BattleRoom.createFromServerMessage(message) battleRoom:updateRankedStatus(message.ranked) - battleRoom:assignInputConfigurations() + battleRoom:restoreInputConfigurations() GAME.netClient:registerPlayerUpdates(battleRoom) return battleRoom @@ -159,7 +159,7 @@ function BattleRoom.createLocalFromGameMode(gameMode, gameScene, settingChangesU end end - if battleRoom:assignInputConfigurations() then + if battleRoom:restoreInputConfigurations() then return battleRoom else return nil @@ -413,87 +413,69 @@ 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 +---@return Player[] localHumanPlayers +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 - end - 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 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 - end + if player.inputConfiguration == device then + logger.debug("BattleRoom:claimDeviceForPlayer device already assigned to player") + return true end - return true -end + assert(not device.claimed or device.player == player, "device already claimed by another player") --- 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 - end - self.tryLockInputs = tableUtils.trueForAny(self.players, - function(p) - return p.isLocal and p.human and not p.inputConfiguration - end) - end + player:unrestrictInputs() + player:restrictInputs(device) + + return true end function BattleRoom:update(dt) @@ -502,7 +484,6 @@ function BattleRoom:update(dt) if self.state == BattleRoom.states.Setup then -- the setup phase of the room - self:tryAssignInputConfigurations() self:updateLoadingState() self:refreshReadyStates() if self:allReady() then diff --git a/client/src/ChallengeMode.lua b/client/src/ChallengeMode.lua index 680a3ba7..53dae68a 100644 --- a/client/src/ChallengeMode.lua +++ b/client/src/ChallengeMode.lua @@ -33,7 +33,7 @@ local ChallengeMode = class( self.player = ChallengeModePlayer(#self.players + 1) self.player.settings.difficulty = difficulty self:addPlayer(self.player) - self:assignInputConfigurations() + self:restoreInputConfigurations() self:setStage(stageIndex or 1) end, BattleRoom diff --git a/client/src/Game.lua b/client/src/Game.lua index 22f765e5..a693baf3 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -15,7 +15,7 @@ local LevelPresets = require("common.data.LevelPresets") local class = require("common.lib.class") local logger = require("common.lib.logger") local analytics = require("client.src.analytics") -local input = require("client.src.inputManager") +local inputManager = require("client.src.inputManager") local PuzzleLibrary = require("client.src.PuzzleLibrary") local save = require("client.src.save") local fileUtils = require("client.src.FileUtils") @@ -24,7 +24,8 @@ local handleShortcuts = require("client.src.Shortcuts") local Player = require("client.src.Player") local GameModes = require("common.data.GameModes") local NetClient = require("client.src.network.NetClient") -local StartUp = require("client.src.scenes.StartUp") +local BootScene = require("client.src.scenes.BootScene") +local InputConfigMenu = require("client.src.scenes.InputConfigMenu") local SoundController = require("client.src.music.SoundController") require("client.src.BattleRoom") local prof = require("common.lib.zoneProfiler") @@ -34,7 +35,6 @@ local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") local DebugSettings = require("client.src.debug.DebugSettings") -local Button = require("client.src.ui.Button") local TextButton = require("client.src.ui.TextButton") local OverlayContainer = require("client.src.ui.OverlayContainer") local DebugMenu = require("client.src.debug.DebugMenu") @@ -56,7 +56,7 @@ end ---@field globalCanvas love.graphics.Texture ---@field muteSound boolean ---@field rich_presence table ----@field input table +---@field input InputManager ---@field backgroundImage table ---@field backgroundColor number[] ---@field updater table? @@ -74,7 +74,7 @@ end local Game = class( function(self) self.scores = Scores.createFromScoreFile() - self.input = input + self.input = inputManager self.match = nil -- Match - the current match going on or nil if inbetween games self.battleRoom = nil -- BattleRoom - the current room being used for battles self.focused = true -- if the window is focused @@ -141,13 +141,11 @@ function Game:load() else logger.debug("Launching game without updater") end - local user_input_conf = save.read_key_file() - if user_input_conf then - self.input:importConfigurations(user_input_conf) - end + + inputManager:load() self.navigationStack = NavigationStack({}) - self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) + self.navigationStack:push(BootScene({setupRoutine = self.setupRoutine})) -- Add navigation stack to root UI self.uiRoot:addChild(self.navigationStack) @@ -241,7 +239,7 @@ end function Game:setupRoutine() -- loading various assets into the game - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) detectHardwareProblems() @@ -372,6 +370,22 @@ function Game:handleResize(newWidth, newHeight) end end +function Game:onJoystickAdded(joystick) + local isNotConfigured = self.input:onJoystickAdded(joystick) + if isNotConfigured and self.navigationStack.scenes[1].name ~= "BootScene" and (config.discordCommunityShown and config.language_code) and not self:hasOngoingMatch() then + -- Not critically occupied, so push the InputConfigMenu on top + GAME.navigationStack:push(InputConfigMenu({})) + end +end + +function Game:hasOngoingMatch() + return not not (GAME.battleRoom and GAME.battleRoom.match ~= nil) +end + +function Game:onJoystickRemoved(joystick) + self.input:onJoystickRemoved(joystick) +end + -- Called every few fractions of a second to update the game -- dt is the amount of time in seconds that has passed. function Game:update(dt) @@ -643,7 +657,7 @@ function Game:refreshCanvasAndImagesForNewScale() characters_reload_graphics() -- Reload loc to get the new font - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) end -- Transform from window coordinates to game coordinates @@ -670,13 +684,12 @@ function Game:setLanguage(lang_code) break end end - config.language_code = Localization.codes[Localization.lang_index] if themes[config.theme] and themes[config.theme].font and themes[config.theme].font.path then GraphicsUtil.setGlobalFont(themes[config.theme].font.path, themes[config.theme].font.size, self:newCanvasSnappedScale()) - elseif config.language_code == "JP" then + elseif lang_code == "JP" then GraphicsUtil.setGlobalFont("client/assets/fonts/jp.ttf", 14, self:newCanvasSnappedScale()) - elseif config.language_code == "TH" then + elseif lang_code == "TH" then GraphicsUtil.setGlobalFont("client/assets/fonts/th.otf", 14, self:newCanvasSnappedScale()) else GraphicsUtil.setGlobalFont(nil, 12, self:newCanvasSnappedScale()) diff --git a/client/src/Player.lua b/client/src/Player.lua index 375b5866..b1759a70 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -33,6 +33,8 @@ local StackBehaviours = require("common.data.StackBehaviours") ---@field settings PlayerSettings ---@field publicId integer ---@field playerNumber integer? +---@field inputConfiguration InputConfiguration? +---@field lastUsedInputConfiguration InputConfiguration? ---@overload fun(name: string, publicId: integer, isLocal: boolean?): Player local Player = class( ---@param self Player @@ -82,6 +84,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") @@ -212,11 +215,19 @@ function Player:setLeague(league) end end +---@param inputConfiguration InputConfiguration function Player:restrictInputs(inputConfiguration) if self.inputConfiguration and self.inputConfiguration ~= inputConfiguration then error("Player " .. self.playerNumber .. " is trying to claim a second input configuration") end + if inputConfiguration.deviceType == "touch" then + self:setInputMethod("touch") + else + self:setInputMethod("controller") + end + self.inputConfiguration = input:claimConfiguration(self, inputConfiguration) + self:emitSignal("inputConfigurationChanged", self.inputConfiguration) end function Player:unrestrictInputs() @@ -229,9 +240,15 @@ function Player:unrestrictInputs() self.lastUsedInputConfiguration = self.inputConfiguration input:releaseConfiguration(self, self.inputConfiguration) self.inputConfiguration = nil + self:emitSignal("inputConfigurationChanged", nil) end end +function Player:hasInputConfiguration() + local assigned = (self.inputConfiguration ~= nil) + return assigned +end + ---@return Player function Player.createLocalPlayerFromConfig() local player = Player(config.name, -1, true) diff --git a/client/src/config.lua b/client/src/config.lua index 93c69a3f..6b6a6f2d 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -9,7 +9,7 @@ require("client.src.globals") -- Default configuration values ---@class UserConfig ---@field version string ----@field language_code string +---@field language_code string? ---@field theme string ---@field panels string? ---@field character string @@ -51,12 +51,13 @@ require("client.src.globals") ---@field display integer ---@field windowX number? ---@field windowY number? +---@field discordCommunityShown boolean config = { -- The last used engine version version = consts.ENGINE_VERSION, -- Lang used for localization - language_code = "EN", + language_code = nil, -- Last selected theme, panels, character and stage theme = consts.DEFAULT_THEME_DIRECTORY, @@ -130,13 +131,16 @@ config = { display = 1, windowX = nil, windowY = nil, + discordCommunityShown = false, } -- writes to the "conf.json" file function write_conf_file() pcall( function() - love.filesystem.write("conf.json", json.encode(config)) + local encoded = json.encode(config) + ---@cast encoded string + love.filesystem.write("conf.json", encoded) end ) end @@ -289,6 +293,9 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end + if type(read_data.discordCommunityShown) == "boolean" then + configTable.discordCommunityShown = read_data.discordCommunityShown + end configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end diff --git a/client/src/graphics/InputPromptRenderer.lua b/client/src/graphics/InputPromptRenderer.lua new file mode 100644 index 00000000..8147b3c3 --- /dev/null +++ b/client/src/graphics/InputPromptRenderer.lua @@ -0,0 +1,106 @@ +local GraphicsUtil = require("client.src.graphics.graphics_util") + +-- Rendering utility for drawing input device icons with optional numbering +local InputPromptRenderer = {} + +-- Renders an input prompt icon at the specified position +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param x number +---@param y number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +function InputPromptRenderer.renderIcon(deviceType, x, y, size, alpha, controllerImageVariant) + if not GAME.theme then + return + end + + size = size or 32 + alpha = alpha or 1 + + local icon = GAME.theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not icon then + return + end + + GraphicsUtil.setColor(1, 1, 1, alpha) + + local iconWidth = icon:getWidth() + local iconHeight = icon:getHeight() + local scale = size / math.max(iconWidth, iconHeight) + + love.graphics.draw(icon, x, y, 0, scale, scale) + + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Renders an input prompt icon centered at the specified position +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param centerX number +---@param centerY number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +function InputPromptRenderer.renderIconCentered(deviceType, centerX, centerY, size, alpha, controllerImageVariant) + if not GAME.theme then + return + end + + size = size or 32 + local icon = GAME.theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not icon then + return + end + + local iconWidth = icon:getWidth() + local iconHeight = icon:getHeight() + local scale = size / math.max(iconWidth, iconHeight) + + local scaledWidth = iconWidth * scale + local scaledHeight = iconHeight * scale + + local x = centerX - scaledWidth / 2 + local y = centerY - scaledHeight / 2 + + InputPromptRenderer.renderIcon(deviceType, x, y, size, alpha, controllerImageVariant) +end + +-- Renders an input prompt icon with a device number overlay +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param centerX number +---@param centerY number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +---@param deviceNumber integer? device configuration number (1-9, nil for no number) +function InputPromptRenderer.renderIconWithNumber(deviceType, centerX, centerY, size, alpha, controllerImageVariant, deviceNumber) + if not GAME.theme then + return + end + + -- Render the main device icon + InputPromptRenderer.renderIconCentered(deviceType, centerX, centerY, size, alpha, controllerImageVariant) + + -- Render device number overlay if provided + if deviceNumber and deviceNumber > 1 and deviceNumber <= 9 then + local numberIcon = GAME.theme:getDeviceNumberIcon(deviceNumber) + if numberIcon then + GraphicsUtil.setColor(1, 1, 1, alpha) + + local numberSize = math.max(size * 0.4, 16) -- Number should be smaller but visible + local numberWidth = numberIcon:getWidth() + local numberHeight = numberIcon:getHeight() + local numberScale = numberSize / math.max(numberWidth, numberHeight) + + -- Position number in bottom-right corner of device icon + local numberX = centerX + (size * 0.25) - (numberWidth * numberScale * 0.5) + local numberY = centerY + (size * 0.25) - (numberHeight * numberScale * 0.5) + + love.graphics.draw(numberIcon, numberX, numberY, 0, numberScale, numberScale) + + GraphicsUtil.setColor(1, 1, 1, 1) + end + end +end + +return InputPromptRenderer \ No newline at end of file diff --git a/client/src/input/InputConfiguration.lua b/client/src/input/InputConfiguration.lua new file mode 100644 index 00000000..e166f413 --- /dev/null +++ b/client/src/input/InputConfiguration.lua @@ -0,0 +1,444 @@ +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") + +---@alias InputDeviceType ("keyboard" | "controller" | "touch" | nil) + +-- Represents a single input configuration slot with key bindings +---@class InputConfiguration +---@field index number Configuration slot number (1-8) +---@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 InputDeviceType Device type ("keyboard", "controller", "touch", or nil if empty) +---@field deviceName string? Human-readable device name +---@field controllerImageVariant string? Controller icon variant +---@field deviceNumber number? Device count of this type (e.g., 2nd keyboard) +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 InputDeviceType 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 controllerImageVariant 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 displayName 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 displayName 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 touchConfig Touch configuration +function InputConfiguration.getTouchConfiguration() + if not touchConfiguration then + -- Create a special InputConfiguration for touch + -- We pass dummy values since touch doesn't use the normal config system + local dummyFn = function() return false end + touchConfiguration = InputConfiguration(0, dummyFn, love.joystick) + + -- Set touch-specific properties + touchConfiguration.id = "touch" + touchConfiguration.deviceType = "touch" + touchConfiguration.deviceName = "Touch" + touchConfiguration.controllerImageVariant = nil + touchConfiguration.deviceNumber = 1 + touchConfiguration.index = nil + + -- Override isEmpty to return false (touch is always "available") + touchConfiguration.isEmpty = function() return false end + + -- Link to the actual mouse input state + touchConfiguration.isDown = GAME.input.mouse.isDown + touchConfiguration.isPressed = GAME.input.mouse.isPressed + touchConfiguration.isUp = GAME.input.mouse.isUp + end + + return touchConfiguration +end + +return InputConfiguration diff --git a/client/src/input/JoystickProvider.lua b/client/src/input/JoystickProvider.lua new file mode 100644 index 00000000..b753d329 --- /dev/null +++ b/client/src/input/JoystickProvider.lua @@ -0,0 +1,6 @@ +---@class PanelAttackJoystick +---@field getGUID fun(self): string +---@field getName fun(self): string + +---@class JoystickProvider +---@field getJoysticks fun(self): PanelAttackJoystick[] diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index d5aaf35c..1b0a3b26 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: @@ -15,27 +19,18 @@ local logger = require("common.lib.logger") -- inputConfigurations: raw key inputs mapped to internal aliases for that configuration -- base (top level): the union of all inputConfigurations not already claimed by a player -- mouse: all mouse buttons and the position of the mouse +---@class InputManager local inputManager = { isDown = {}, isPressed = {}, isUp = {}, allKeys = {isDown = {}, isPressed = {}, isUp = {}}, mouse = {isDown = {}, isPressed = {}, isUp = {}, x = 0, y = 0}, + ---@type InputConfiguration[] 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 +82,43 @@ function inputManager:keyReleased(key, scancode) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end +---@param joystick love.Joystick +---@return boolean? isNotConfigured +function inputManager:onJoystickAdded(joystick) + joystickManager:registerJoystick(joystick) + self:updateUnconfiguredJoysticksCache() + local unconfiguredJoysticks = self:getUnconfiguredJoysticks() + + -- Check if the newly added joystick is unconfigured + for _, unconfiguredJoystick in ipairs(unconfiguredJoysticks) do + if unconfiguredJoystick == joystick then + self:emitSignal("unconfiguredJoystickAdded", joystick) + return true + end + end +end + +function inputManager:onJoystickRemoved(joystick) + joystickManager:unregisterJoystick(joystick) + self:updateUnconfiguredJoysticksCache() +end + function inputManager:joystickPressed(joystick, button) - if not joystickManager.devices[joystick:getID()] then - love.joystickadded(joystick) + if not joystickManager:isRegistered(joystick) then + -- always check and register to be sure, in rare cases joystickadded is not called or not called early enough + joystickManager:registerJoystick(joystick) end + local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isDown[key] = KEY_CHANGE.DETECTED end function inputManager:joystickReleased(joystick, button) - if not joystickManager.devices[joystick:getID()] then - love.joystickadded(joystick) + if not joystickManager:isRegistered(joystick) then + -- always check and register to be sure, in rare cases joystickadded is not called or not called early enough + joystickManager:registerJoystick(joystick) end + local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end @@ -439,27 +459,79 @@ function inputManager:getSaveKeyMap() return result end +function inputManager:writeKeyConfigurationToFile() + FileUtils.writeJson("", "keysV3.json", self:getSaveKeyMap()) + self.hasUnsavedChanges = false +end + +-- Saves input configuration mappings to disk +function inputManager:saveInputConfigurationMappings() + self:writeKeyConfigurationToFile() +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 _, inputConfig in ipairs(self.inputConfigurations) do + inputConfig:updateCachedProperties() + end + self:updateAllDeviceNumbers() end +---@param player Player +---@param inputConfiguration InputConfiguration function inputManager:claimConfiguration(player, inputConfiguration) if inputConfiguration.claimed and inputConfiguration.player ~= player then error("Trying to assign input configuration to player " .. player.playerNumber .. @@ -474,6 +546,8 @@ function inputManager:claimConfiguration(player, inputConfiguration) return inputConfiguration end +---@param player Player +---@param inputConfiguration InputConfiguration function inputManager:releaseConfiguration(player, inputConfiguration) if not inputConfiguration.claimed then error("Trying to release an unclaimed inputConfiguration") @@ -488,4 +562,334 @@ function inputManager:releaseConfiguration(player, inputConfiguration) self:updateKeyMaps() end +-- Updates deviceNumber for all InputConfigurations based on device type counts +function inputManager:updateAllDeviceNumbers() + local deviceTypeCounters = {} + + for _, inputConfig in ipairs(self.inputConfigurations) do + -- Only count non-empty configurations with bindings + if not inputConfig:isEmpty() and inputConfig.deviceType then + deviceTypeCounters[inputConfig.deviceType] = (deviceTypeCounters[inputConfig.deviceType] or 0) + 1 + inputConfig.deviceNumber = deviceTypeCounters[inputConfig.deviceType] + else + inputConfig.deviceNumber = nil + end + end +end + +-- Updates a specific InputConfiguration when its bindings change +---@param inputConfig InputConfiguration Configuration to update +function inputManager:updateInputConfiguration(inputConfig) + inputConfig: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 _, inputConfig in ipairs(self.inputConfigurations) do + for _, keyName in ipairs(consts.KEY_NAMES) do + if inputConfig[keyName] == buttonBinding then + inputConfig[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:writeKeyConfigurationToFile() + 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:writeKeyConfigurationToFile() +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 _, inputConfig in ipairs(self.inputConfigurations) do + inputConfig:updateCachedProperties() + end + self:updateAllDeviceNumbers() +end + +---@return InputConfiguration? Input configuration with active input, or nil +function inputManager:detectActiveInputConfiguration() + for i = 1, #self.inputConfigurations do + local inputConfig = self.inputConfigurations[i] + for _, keyName in ipairs(consts.KEY_NAMES) do + if inputConfig.isDown and inputConfig.isDown[keyName] then + return inputConfig + end + end + end + + return nil +end + +---@param localHumanPlayers Player[] +---@return boolean True if an unassigned configuration has active input +function inputManager:checkForUnassignedConfigurationInputs(localHumanPlayers) + if #localHumanPlayers == 0 then + return false + end + + local activeConfig = self:detectActiveInputConfiguration() + if not activeConfig then + return false + end + + local assignedConfigs = {} + for _, player in ipairs(localHumanPlayers) 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 inputConfig = self.inputConfigurations[i] + if inputConfig then + for _, keyName in ipairs(consts.KEY_NAMES) do + local keyMapping = inputConfig[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 +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 inputConfig = self.inputConfigurations[configIndex] + for keyName, keyMapping in pairs(basicMapping) do + self:changeKeyBindingOnInputConfiguration(inputConfig, keyName, keyMapping, true) + end + + -- Make sure all required keys are set (fill any missing ones with nil to be explicit) + for _, keyName in ipairs(consts.KEY_NAMES) do + if inputConfig[keyName] == nil and not basicMapping[keyName] then + self:changeKeyBindingOnInputConfiguration(inputConfig, keyName, nil, true) + end + end + + self:updateUnconfiguredJoysticksCache() + if shouldSave then + self:writeKeyConfigurationToFile() + 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[] inputConfigurations Array of InputConfiguration objects +function inputManager:getAssignableDevices() + local devices = {} + + -- Add all non-empty InputConfigurations + for _, inputConfig in ipairs(self.inputConfigurations) do + if not inputConfig:isEmpty() then + devices[#devices + 1] = inputConfig + end + end + + -- Add touch configuration + local touchConfig = InputConfiguration.getTouchConfiguration() + devices[#devices + 1] = touchConfig + + return devices +end + +function inputManager.getTouchInputConfiguration() + return InputConfiguration.getTouchConfiguration() +end + return inputManager diff --git a/client/src/localization.lua b/client/src/localization.lua index 821972eb..c1683136 100644 --- a/client/src/localization.lua +++ b/client/src/localization.lua @@ -2,24 +2,40 @@ 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") +---@alias LanguageCode ("EN" | "FR" | "PT" | "JP" | "ES" | "GE" | "IT" | "TH") + -- Holds all the data for localizing the game Localization = { data = {}, langs = {}, + ---@type LanguageCode[] codes = {}, lang_index = 1, init = false, } -function Localization.get_list_codes(self) +---@type table +Localization.languageCodeToFontData = +{ + EN = { fontPath = nil, fontSize = 12 }, + FR = { fontPath = nil, fontSize = 12 }, + PT = { fontPath = nil, fontSize = 12 }, + JP = { fontPath = "client/assets/fonts/jp.ttf", fontSize = 14 }, + ES = { fontPath = nil, fontSize = 12 }, + GE = { fontPath = nil, fontSize = 12 }, + IT = { fontPath = nil, fontSize = 12 }, + TH = { fontPath = "client/assets/fonts/th.otf", fontSize = 14 }, +} + +function Localization:get_list_codes() return self.codes end -function Localization.get_language(self) +function Localization:get_language() return self.codes[self.lang_index] end @@ -150,17 +166,72 @@ function Localization.init(self) end -- Gets the localized string for a loc key -function loc(text_key, ...) +---@param textKey string +---@param ... string? +function loc(textKey, ...) local code = Localization.codes[Localization.lang_index] - if not code or not Localization.data[code] then - code = Localization.codes[1] + return Localization.localize(code, textKey, ...) +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 LanguageCode? +function Localization:getLanguageCode(languageName) + for languageCode, translations in pairs(self.data) do + if translations["LANG"] == languageName then + return languageCode + end + end +end + +---@param languageCode LanguageCode +---@param textKey string +---@param ... string? +---@return string +function Localization.localize(languageCode, textKey, ...) + if not languageCode or not Localization.data[languageCode] then + languageCode = Localization.codes[1] end - assert(code) + assert(languageCode) local ret = nil if Localization.init then - ret = Localization.data[code][text_key] + ret = Localization.data[languageCode][textKey] end if ret then @@ -169,8 +240,8 @@ function loc(text_key, ...) ret = ret:gsub("%%" .. i, tmp) end else - love.filesystem.append("warnings.txt", text_key .. ",,,,,,,,," .. "\n") - ret = "#" .. text_key + love.filesystem.append("warnings.txt", textKey .. ",,,,,,,,," .. "\n") + ret = "#" .. textKey for i = 1, select("#", ...) do ret = ret .. " " .. select(i, ...) end diff --git a/client/src/mods/Theme.lua b/client/src/mods/Theme.lua index f7c63068..7eccafb7 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -40,6 +40,7 @@ local flags = { ---@field main_menu_y_max number ---@field main_menu_max_height number ---@field defaultStage Stage +---@field colors table color palette where each value is an RGBA array of four numbers (0-1 range) Theme = class( ---@param self Theme @@ -57,6 +58,22 @@ Theme = self.main_menu_screen_pos = {0, 0} -- the top center position of most menus self.main_menu_y_max = 0 self.main_menu_max_height = 0 + + self.colors = { + menuDefaultBackgroundColor = {1, 1, 1, 0.15}, + menuDefaultBorderColor = {1, 1, 1, 0.15}, + menuSelectedBackgroundColor = {0.6, 0.6, 1, 0.15}, + menuSelectedBorderColor = {0.6, 0.6, 1, 0.15}, + activeBackgroundColor = {0.2, 0.3, 0.4, 0.9}, + darkTransparentBackgroundColor = {0, 0, 0, 0.75}, + highlightTextColor = {1, 1, 0.3, 1}, + inputSlotDefaultBackgroundColor = {0.2, 0.2, 0.2, 0.9}, + inputSlotDefaultBorderColor = {0.4, 0.4, 0.4, 0.9}, + inputSlotSelectedBackgroundColor = {0.2, 0.2, 0.34, 1.0}, + inputSlotSelectedBorderColor = {0.5, 0.5, 0.8, 1.0}, + incompleteConfigBackgroundColor = {0.918, 0.251, 0.275, 1.0}, + configCorrectBackgroundColor = {0.3, .3, .3, 0.7} + } end ) @@ -360,6 +377,42 @@ local function loadPlayerNumberIcons(theme) return theme.images.IMG_players end +local function loadInputPromptIcons(theme) + local icons = {} + + -- Load basic device types + icons.controller = theme:load_theme_img("input/controller") + icons.keyboard = theme:load_theme_img("input/keyboard") + icons.touch = theme:load_theme_img("input/touch") + icons.mouse = theme:load_theme_img("input/mouse") + + -- Load specific controller variants + icons.controller_variants = {} + icons.controller_variants.generic = theme:load_theme_img("input/controller_generic") + icons.controller_variants.playstation1 = theme:load_theme_img("input/controller_playstation1") + icons.controller_variants.playstation2 = theme:load_theme_img("input/controller_playstation2") + icons.controller_variants.playstation3 = theme:load_theme_img("input/controller_playstation3") + icons.controller_variants.playstation4 = theme:load_theme_img("input/controller_playstation4") + icons.controller_variants.playstation5 = theme:load_theme_img("input/controller_playstation5") + icons.controller_variants.xbox360 = theme:load_theme_img("input/controller_xbox360") + icons.controller_variants.xboxone = theme:load_theme_img("input/controller_xboxone") + icons.controller_variants.xboxseries = theme:load_theme_img("input/controller_xboxseries") + icons.controller_variants.switch_pro = theme:load_theme_img("input/controller_switch_pro") + + -- Load add controller icon + icons.controller_add = theme:load_theme_img("input/controller_add") + icons.controller_error = theme:load_theme_img("input/error") + + -- Load device number overlays + icons.device_numbers = {} + for i = 0, 9 do + icons.device_numbers[i] = theme:load_theme_img("input/device_number_" .. i) + end + + theme.images.inputPrompts = icons + return theme.images.inputPrompts +end + function Theme:loadSelectionGraphics() self.images.flags = {} for _, flag in ipairs(flags) do @@ -393,6 +446,7 @@ function Theme:loadSelectionGraphics() self.images.IMG_random_character = self:load_theme_img("random_character") loadPlayerNumberIcons(self) + loadInputPromptIcons(self) loadGridCursors(self) end @@ -1029,6 +1083,48 @@ function Theme:getPlayerNumberIcon(index) return self.images.IMG_players[index] end +---@param deviceType string +---@return love.Texture +function Theme:getInputPromptIcon(deviceType) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + return self.images.inputPrompts[deviceType] +end + +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param controllerImageVariant string? specific controller image variant key +---@return love.Texture +function Theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + + if deviceType == "controller" and controllerImageVariant and self.images.inputPrompts.controller_variants then + local specificIcon = self.images.inputPrompts.controller_variants[controllerImageVariant] + if specificIcon then + return specificIcon + end + end + + -- Fallback to basic device type + return self.images.inputPrompts[deviceType] +end + +---@param number integer the device number (0-9) +---@return love.Texture? +function Theme:getDeviceNumberIcon(number) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + + if self.images.inputPrompts.device_numbers and number >= 0 and number <= 9 then + return self.images.inputPrompts.device_numbers[number] + end + + return nil +end + ---@param index integer? ---@return love.Texture function Theme:getHealthBarFrameAbsolute(index) diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 4954bae6..4c4a2e78 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -193,18 +193,20 @@ local function processMatchStartMessage(self, message) if player.isLocal then if not player.inputConfiguration then + -- fallback in case the player lost their input config while the server sent the message if player.settings.inputMethod == "touch" then - player:restrictInputs(GAME.input.mouse) - else - if player.lastUsedInputConfiguration and player.lastUsedInputConfiguration.x then + player:restrictInputs(GAME.input.getTouchInputConfiguration()) + elseif player.lastUsedInputConfiguration then + if player.lastUsedInputConfiguration.deviceType == "touch" then -- there is no configuration and the last one is a touch configuration - -- there is no way to know which input configuration the player wanted to use in this scenario so throw an error + -- while we could assume that the player wanted to use touch after all, if the server reports the setting as controller, we can no longer change + -- because the other client already has us clocked as controller and the inputs have to match + -- there is no way to know which input configuration the player would want to use in this scenario so throw an error error("Player's input configuration does not match input method " .. player.settings.inputMethod .. " sent by server.") else player:restrictInputs(player.lastUsedInputConfiguration) end end - -- fallback in case the player lost their input config while the server sent the message end end -- generally I don't think it's a good idea to try and rematch the other diverging settings here diff --git a/client/src/save.lua b/client/src/save.lua index d3cc425a..6c5aaa4c 100644 --- a/client/src/save.lua +++ b/client/src/save.lua @@ -1,4 +1,3 @@ -local inputManager = require("client.src.inputManager") local FileUtils = require("client.src.FileUtils") local logger = require("common.lib.logger") @@ -6,37 +5,6 @@ local logger = require("common.lib.logger") local save = {} --- writes to the "keys.txt" file -function save.write_key_file() - FileUtils.writeJson("", "keysV3.json", inputManager:getSaveKeyMap()) -end - --- reads the "keys.txt" file -function save.read_key_file() - local filename - local migrateInputs = false - - if FileUtils.exists("keysV3.json") then - filename = "keysV3.json" - else - filename = "keysV2.txt" - migrateInputs = true - end - - if not FileUtils.exists(filename) then - return inputManager.inputConfigurations - else - local inputConfigs = FileUtils.readJsonFile(filename) - - if migrateInputs then - -- migrate old input configs - inputConfigs = inputManager:migrateInputConfigs(inputConfigs) - end - - return inputConfigs - end -end - -- reads the .txt file of the given path and filename function save.read_txt_file(path_and_filename) local s diff --git a/client/src/scenes/StartUp.lua b/client/src/scenes/BootScene.lua similarity index 72% rename from client/src/scenes/StartUp.lua rename to client/src/scenes/BootScene.lua index ab135b3d..60b95de0 100644 --- a/client/src/scenes/StartUp.lua +++ b/client/src/scenes/BootScene.lua @@ -6,7 +6,7 @@ local logger = require("common.lib.logger") local fileUtils = require("client.src.FileUtils") local ModLoader = require("client.src.mods.ModLoader") -local StartUp = class(function(scene, sceneParams) +local BootScene = class(function(scene, sceneParams) scene.migrationRoutine = coroutine.create(scene.migrate) scene.setupRoutine = coroutine.create(sceneParams.setupRoutine) scene.message = "Startup" @@ -24,9 +24,9 @@ local StartUp = class(function(scene, sceneParams) love.graphics.setFont(GraphicsUtil.getGlobalFontWithSize(GraphicsUtil.fontSize + 10)) end, Scene) -StartUp.name = "StartUp" +BootScene.name = "BootScene" -function StartUp:updateSelf(dt) +function BootScene:updateSelf(dt) if self.migrationPath then local success, status = coroutine.resume(self.migrationRoutine, self) if success then @@ -50,7 +50,8 @@ 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 + + -- we need the late require for all scenes here because localization is only initialized by the coroutine and all scenes depend on it being loaded if themes[config.theme].images.bg_title then GAME.navigationStack:replace(require("client.src.scenes.TitleScreen")()) else @@ -60,11 +61,31 @@ function StartUp:updateSelf(dt) if next(ModLoader.invalidMods) then GAME.navigationStack:push(require("client.src.scenes.ModValidationScene")()) end + + -- scenes that are displayed before anything else on either first startup or if a new input device was found + -- they are just pushed on top and will pop off as the player works through them until the regular game start is left + + local input = require("client.src.inputManager") + + if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() or not config.discordCommunityShown then + local InputConfigMenu = require("client.src.scenes.InputConfigMenu") + GAME.navigationStack:push(InputConfigMenu({})) + end + + if not config.discordCommunityShown then + local DiscordCommunitySetup = require("client.src.scenes.DiscordCommunitySetup") + GAME.navigationStack:push(DiscordCommunitySetup({})) + end + + if not config.language_code then + local LanguageSelectSetup = require("client.src.scenes.LanguageSelectSetup") + GAME.navigationStack:push(LanguageSelectSetup({})) + end end end end -function StartUp:drawLoadingString(loadingString) +function BootScene:drawLoadingString(loadingString) local textHeight = 40 local x = 0 local y = consts.CANVAS_HEIGHT / 2 - textHeight / 2 @@ -72,11 +93,11 @@ function StartUp:drawLoadingString(loadingString) love.graphics.printf(loadingString, x, y, consts.CANVAS_WIDTH, "center", 0, 1) end -function StartUp:drawSelf() +function BootScene:drawSelf() self:drawLoadingString(self.message) end -function StartUp:checkIfMigrationIsPossible() +function BootScene:checkIfMigrationIsPossible() local loveMajor = love.getVersion() if loveMajor < 12 then return false @@ -109,7 +130,7 @@ function StartUp:checkIfMigrationIsPossible() end end -function StartUp:migrate() +function BootScene:migrate() fileUtils.recursiveCopy("oldInstall", "", true) love.filesystem.unmountFullPath(self.migrationPath) self.migrationPath = nil @@ -127,4 +148,4 @@ function StartUp:migrate() love.load() end -return StartUp +return BootScene diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 7e70321d..285c706a 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -4,11 +4,12 @@ local class = require("common.lib.class") local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") local GameModes = require("common.data.GameModes") -local LevelPresets = require("common.data.LevelPresets") local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local Character = require("client.src.mods.Character") +local LevelPresets = require("common.data.LevelPresets") +local InputDeviceOverlay = require("client.src.scenes.components.InputDeviceOverlay") -- The character select screen scene ---@class CharacterSelect : Scene @@ -57,8 +58,12 @@ function CharacterSelect:load() self.ui.cursors = {} self.ui.characterIcons = {} self.ui.playerInfos = {} - self:customLoad() + + self:createInputDeviceOverlay() + + self:setChangeInputButtonVisibility(false) + self:setChangeInputButtonVisibleIfNeeded() for _, player in ipairs(self.players) do if player:isHuman() then @@ -287,6 +292,49 @@ function CharacterSelect:createStageCarousel(player, width) return stageCarousel end +function CharacterSelect:createInputDeviceOverlay() + + self.inputDeviceOverlay = InputDeviceOverlay({ + players = self.battleRoom.players, + 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:getLocalHumanPlayers() > 0 then + self.ui.changeInputButton:setVisibility(true) + end + 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, + players = self.battleRoom.players, + openInputDeviceOverlay = function () + self.inputDeviceOverlay:open() + end + }) +end + local super_select_pixelcode = [[ uniform float percent; vec4 effect( vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords ) @@ -999,6 +1047,12 @@ function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) end function CharacterSelect:updateSelf(dt) + self.inputDeviceOverlay:openInputDeviceOverlayIfNeeded() + + if self.inputDeviceOverlay:isActive() then + return + end + for _, cursor in ipairs(self.ui.cursors) do if cursor.player.isLocal and cursor.player.human then if not cursor.player.inputConfiguration then diff --git a/client/src/scenes/CharacterSelect2p.lua b/client/src/scenes/CharacterSelect2p.lua index 664ccddc..6bc6285c 100644 --- a/client/src/scenes/CharacterSelect2p.lua +++ b/client/src/scenes/CharacterSelect2p.lua @@ -37,6 +37,7 @@ function CharacterSelect2p:loadUserInterface() self.ui.pageIndicator = self:createPageIndicator(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() self.ui.rankedSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.rankedSelection:setTitle("ss_ranked") @@ -67,6 +68,7 @@ function CharacterSelect2p:loadUserInterface() self.ui.grid:createElementAt(9, 2, 1, 1, "readyButton", self.ui.readyButton) self.ui.grid:createElementAt(1, 3, characterGridWidth, characterGridHeight, "characterSelection", self.ui.characterGrid, true) self.ui.grid:createElementAt(5, 6, 1, 1, "pageIndicator", self.ui.pageIndicator) + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.characterIcons = {} @@ -108,4 +110,4 @@ function CharacterSelect2p:loadUserInterface() end -return CharacterSelect2p \ No newline at end of file +return CharacterSelect2p diff --git a/client/src/scenes/CharacterSelectChallenge.lua b/client/src/scenes/CharacterSelectChallenge.lua index 90b100d8..48f3fa28 100644 --- a/client/src/scenes/CharacterSelectChallenge.lua +++ b/client/src/scenes/CharacterSelectChallenge.lua @@ -29,6 +29,7 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.characterGrid = self:createCharacterGrid(characterButtons, self.ui.grid, characterGridWidth, characterGridHeight) self.ui.pageIndicator = self:createPageIndicator(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() local panelHeight local stageWidth @@ -42,6 +43,7 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.grid:createElementAt(9, 2, 1, 1, "readyButton", self.ui.readyButton) self.ui.grid:createElementAt(1, 3, characterGridWidth, characterGridHeight, "characterSelection", self.ui.characterGrid, true) self.ui.grid:createElementAt(5, 6, 1, 1, "pageIndicator", self.ui.pageIndicator) + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.characterIcons = {} @@ -76,4 +78,4 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) end -return CharacterSelectChallenge \ No newline at end of file +return CharacterSelectChallenge diff --git a/client/src/scenes/CharacterSelectVsSelf.lua b/client/src/scenes/CharacterSelectVsSelf.lua index 59a22c56..6a4ad970 100644 --- a/client/src/scenes/CharacterSelectVsSelf.lua +++ b/client/src/scenes/CharacterSelectVsSelf.lua @@ -70,6 +70,8 @@ function CharacterSelectVsSelf:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.cursors[1] = self:createCursor(self.ui.grid, player) @@ -94,4 +96,4 @@ function CharacterSelectVsSelf:refresh() end end -return CharacterSelectVsSelf \ No newline at end of file +return CharacterSelectVsSelf diff --git a/client/src/scenes/DiscordCommunitySetup.lua b/client/src/scenes/DiscordCommunitySetup.lua new file mode 100644 index 00000000..9da350b1 --- /dev/null +++ b/client/src/scenes/DiscordCommunitySetup.lua @@ -0,0 +1,124 @@ +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) + 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() + GAME.navigationStack:pop() + end) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 20 + })) + + -- Menu buttons + local menu = ui.Menu.createCenteredMenu({discordLinkButton, continueButton}, 0) + contentStack:addElement(menu) + self.menu = menu + + self.uiRoot:addChild(contentStack) +end, Scene) + +DiscordCommunitySetup.name = "DiscordCommunitySetup" + +function DiscordCommunitySetup:update(dt) + GAME.theme.images.bg_main:update(dt) + self.menu:receiveInputs() +end + +function DiscordCommunitySetup:draw() + GAME.theme.images.bg_main:draw() + self.uiRoot:draw() +end + +return DiscordCommunitySetup \ No newline at end of file diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index a0a00b27..ea5936ca 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -104,6 +104,9 @@ function EndlessMenu:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) + self.ui.leaveButton = self:createLeaveButton() self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) diff --git a/client/src/scenes/InputConfigMenu.lua b/client/src/scenes/InputConfigMenu.lua index ab9187a0..cde71540 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,203 @@ 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 + + GAME.navigationStack:pop() 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 +367,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 +382,4 @@ function InputConfigMenu:draw() self.uiRoot:draw() end -return InputConfigMenu \ No newline at end of file +return InputConfigMenu diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua new file mode 100644 index 00000000..966da044 --- /dev/null +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -0,0 +1,103 @@ +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) + 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) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 60 + })) + + self.disclaimerLabel = ui.Label({ + text = "translation_disclaimer", + hAlign = "center" + }) + + contentStack:addElement(self.disclaimerLabel) +end + +LanguageSelectSetup.name = "LanguageSelectSetup" + +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() + GAME.navigationStack:pop() + for _, scene in ipairs(GAME.navigationStack.scenes) do + scene:refreshLocalization() + end + 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) + + for i, menuItem in ipairs(self.menu.menuItems) do + if menuItem.selected then + local code = Localization:getLanguageCode(menuItem.textButton.label.text) + if Localization:get_language() ~= code then + GAME:setLanguage(code) + self.disclaimerLabel.fontSize = Localization.languageCodeToFontData[code].fontSize + end + + self.disclaimerLabel:refreshLocalization() + end + end + + self.menu:receiveInputs() +end + +function LanguageSelectSetup:draw() + GAME.theme.images.bg_main:draw() + self.uiRoot:draw() +end + +return LanguageSelectSetup \ No newline at end of file diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index ffb9f72c..43e6ee87 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -13,7 +13,6 @@ local TrainingMenu = require("client.src.scenes.TrainingMenu") local ChallengeModeMenu = require("client.src.scenes.ChallengeModeMenu") local Lobby = require("client.src.scenes.Lobby") local LocalGameModeSelectionScene = require("client.src.scenes.LocalGameModeSelectionScene") -local CharacterSelect2p = require("client.src.scenes.CharacterSelect2p") local ReplayBrowser = require("client.src.scenes.ReplayBrowser") local InputConfigMenu = require("client.src.scenes.InputConfigMenu") local SetNameMenu = require("client.src.scenes.SetNameMenu") @@ -24,7 +23,6 @@ local system = require("client.src.system") local TimeAttackGame = require("client.src.scenes.TimeAttackGame") local EndlessGame = require("client.src.scenes.EndlessGame") local VsSelfGame = require("client.src.scenes.VsSelfGame") -local GameBase = require("client.src.scenes.GameBase") local PuzzleGame = require("client.src.scenes.PuzzleGame") -- Scene for the main menu @@ -160,7 +158,7 @@ end function MainMenu:updateSelf(dt) GAME.theme.images.bg_main:update(dt) - self.menu:receiveInputs() + self.menu:receiveInputs(GAME.input, dt) self:checkForUpdates() end diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index 58036e08..3c340e2b 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -149,29 +149,23 @@ function OptionsMenu:loadInfoScreen(text) end function OptionsMenu:loadBaseMenu() - local languageNumber - local languageName = {} - for k, v in ipairs(Localization:get_list_codes()) do - languageName[#languageName + 1] = {v, Localization.data[v]["LANG"]} - if Localization:get_language() == v then - languageNumber = k - end - end - local languageLabels = {} - for k, v in ipairs(languageName) do - local lang = config.language_code - GAME:setLanguage(v[1]) - languageLabels[#languageLabels + 1] = ui.Label({text = v[2], translate = false}) - GAME:setLanguage(lang) + local languageData, languageLabels = Localization:getLanguageLabelsWithFonts() + local currentLanguageCode = Localization:getCurrentLanguageCode() + local languageIndex = Localization:getLanguageIndex(currentLanguageCode) + + local languageCodes = {} + for i, language in ipairs(languageData) do + languageCodes[#languageCodes + 1] = language.code end local languageStepper = ui.Stepper({ labels = languageLabels, - values = languageName, - selectedIndex = languageNumber, + values = languageCodes, + selectedIndex = languageIndex, onChange = function(value) GAME.theme:playMoveSfx() - GAME:setLanguage(value[1]) + config.language_code = value + GAME:setLanguage(value) self:updateMenuLanguage() end }) diff --git a/client/src/scenes/PuzzleMenu.lua b/client/src/scenes/PuzzleMenu.lua index b2fe595b..3b799bfe 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({ + players = self.battleRoom.players, + 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,15 @@ function PuzzleMenu:updateCurrentPuzzleSet() end end -function PuzzleMenu:update(dt) - self.menu:receiveInputs() + +function PuzzleMenu:updateSelf(dt) + self.inputDeviceOverlay:openInputDeviceOverlayIfNeeded() + + if self.inputDeviceOverlay:isActive() then + return + end + + self.menu:receiveInputs(GAME.input, dt) end function PuzzleMenu:draw() diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index 0270b7cc..3d0e11ef 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -21,6 +21,7 @@ local DebugSettings = require("client.src.debug.DebugSettings") 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 diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index c846420e..41fc7338 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -103,6 +103,9 @@ function TimeAttackMenu:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) + self.ui.leaveButton = self:createLeaveButton() self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua new file mode 100644 index 00000000..8ee3583c --- /dev/null +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -0,0 +1,565 @@ +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 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 players Player[] Reference to players that can reassign their input device with this overlay +---@field holdThreshold number Duration in seconds required to confirm assignment (default 0.25) +---@field active boolean True when overlay is open and processing input +---@field 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 players Player[] Reference to players that may be eligible for reassigning their input device with this overlay +---@field holdThreshold number? +---@field onClose fun()? +---@field onCancel fun()? Callback when user presses back/cancel + +---@class InputDeviceOverlay +---@operator call(InputDeviceOverlayOptions): InputDeviceOverlay +local InputDeviceOverlay = class( +---@param self InputDeviceOverlay +---@param options InputDeviceOverlayOptions +function(self, options) + options = options or {} + self.players = {} + + for _, player in ipairs(options.players) do + if player.isLocal and player.human then + self.players[#self.players+1] = player + end + end + + self.holdThreshold = options.holdThreshold or HOLD_THRESHOLD + self.onClose = options.onClose + self.onCancel = options.onCancel + + 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? maxDuration 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 = "hold_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 = "leave" + }), + hAlign = "center", + vAlign = "bottom", + y = -10, + onClick = function() + self:onBackPressed() + end + }) + self:addChild(self.backButton) +end + +---@return Player[] localHumanPlayers Array of local human players +function InputDeviceOverlay:getLocalPlayers() + return self.players +end + +-- Gets the next player that needs device assignment +---@return Player? player Next unassigned player or nil if all assigned +function InputDeviceOverlay:getNextUnassignedPlayer() + for _, player in ipairs(self:getLocalPlayers()) do + if not player:hasInputConfiguration() then + return player + end + end + return nil +end + +---@return PlayerInputDeviceSlot? slot 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 assignedInputConfig = player.inputConfiguration + if assignedInputConfig then + slot:setAssignedDevice(assignedInputConfig) + end + end +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 assignedInputConfig = player.inputConfiguration + slot:setAssignedDevice(assignedInputConfig) + end + end + end + end +end + + + + +-- Assigns a device to a player and plays feedback +---@param inputConfig InputConfiguration Input configuration to assign +---@param targetPlayer Player? Player to assign to, or nil to assign to next unassigned player +function InputDeviceOverlay:assignDevice(inputConfig, targetPlayer) + assert(inputConfig, "config is required") + + if not targetPlayer then + targetPlayer = self:getNextUnassignedPlayer() + end + + if not targetPlayer then + return + end + + if targetPlayer.inputConfiguration ~= inputConfig then + targetPlayer:unrestrictInputs() + targetPlayer:restrictInputs(inputConfig) + end + + GAME.theme:playValidationSfx() + self:updatePlayerSlots() + + -- Trigger pop animation on the slot that was just assigned + for i, player in ipairs(self.players) do + if player == targetPlayer and self.playerSlots[i] then + self.playerSlots[i]:triggerPopAnimation() + break + end + end + + if self:allPlayersAssigned() then + self.autoCloseTimer = AUTO_CLOSE_DELAY + end +end + +---@return boolean # true if all players are assigned, false otherwise +function InputDeviceOverlay:allPlayersAssigned() + for _, player in ipairs(self.players) do + if not player:hasInputConfiguration() then + return false + end + end + + return true +end + +-- Processes hold input for a configuration device +---@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) + assert(touchConfig.deviceType == "touch", "Checked device is not a touch device") + + if touchConfig.claimed then + for _, player in ipairs(self.players) do + if touchConfig.player == player and player.inputConfiguration == touchConfig then + return true + end + end + end + + return false +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 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) + self.touchTargetSlot:setHoldProgress(progress, "touch") + + if state.holdTime >= self.holdThreshold and not state.confirmTriggered then + self:assignTouchToSlot(touchConfig, self.touchTargetSlot) + 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? touchInputConfig 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) + 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 + + if #self.players == 0 then + -- no local players + return + end + + if not self:allPlayersAssigned() then + self:open() + end +end + +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() + self.deviceState = {} + self.touchTargetSlot = nil + self.autoCloseTimer = 0 + self.active = true + self:setVisibility(true) + + self:buildPlayerSlots() +end + +function InputDeviceOverlay:close() + if not self.active then + return + end + + self.active = false + self:setVisibility(false) + self:clearTouchTarget() + self.autoCloseTimer = 0 + self:setFocus(nil) + + if self.onClose then + self.onClose() + end +end + +---@return boolean # True if overlay is currently active +function InputDeviceOverlay:isActive() + return self.active +end + +-- Handles back button press - closes overlay and invokes cancel callback +function InputDeviceOverlay:onBackPressed() + GAME.theme:playCancelSfx() + self:close() + if self.onCancel then + self.onCancel() + end +end + +---@return boolean? # True to block touch event propagation +function InputDeviceOverlay:onTouch() + if self.active then + return true + end +end + +---@return boolean? # True to block release event propagation +function InputDeviceOverlay:onRelease() + if self.active then + return true + end +end + +function InputDeviceOverlay:receiveInputs(input, dt) + if input.isDown["MenuEsc"] then + self.escapeHoldTime = self.escapeHoldTime + dt + elseif input.isPressed["MenuEsc"] and self.escapeHoldTime > 0 then + self.escapeHoldTime = self.escapeHoldTime + dt + if self.escapeHoldTime >= self.holdThreshold then + self:onBackPressed() + self.escapeHoldTime = 0 + end + else + self.escapeHoldTime = 0 + end +end + +return InputDeviceOverlay diff --git a/client/src/scenes/components/PlayerInputDeviceSlot.lua b/client/src/scenes/components/PlayerInputDeviceSlot.lua new file mode 100644 index 00000000..c71f67e6 --- /dev/null +++ b/client/src/scenes/components/PlayerInputDeviceSlot.lua @@ -0,0 +1,252 @@ +local class = require("common.lib.class") +local UiElement = require("client.src.ui.UIElement") +local ImageContainer = require("client.src.ui.ImageContainer") +local inputManager = require("client.src.inputManager") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") + +local PLAYER_SLOT_SIZE = 150 +local DEVICE_ICON_SIZE = 64 + +-- Visual UI element representing one player's input device assignment status +---@class PlayerInputDeviceSlot : UiElement +---@field playerNumber number Visual player index (1, 2, etc.) +---@field assignedDevice InputConfiguration? Input configuration if assigned, nil otherwise +---@field holdProgress number Current hold progress from 0-1 +---@field pendingDeviceType string? Device type being held during assignment (keyboard/controller/touch) +---@field playerImage ImageContainer? Player number icon from theme +---@field deviceIcon UiElement? Device icon showing keyboard/controller/touch type +---@field isTargetedForTouch boolean True when mouse is hovering over this slot for touch assignment +---@field parentOverlay InputDeviceOverlay? Reference to parent overlay for accessing device configs +---@field popScale number Current scale for pop animation (1.0 = normal, >1.0 = enlarged) +---@field popAnimationSpeed number Speed of pop animation decay per second + +---@param options {playerNumber: number, parentOverlay: InputDeviceOverlay} +local PlayerInputDeviceSlot = class(function(self, options) + local playerNumber = options.playerNumber or options + self.playerNumber = playerNumber + self.assignedDevice = nil + self.holdProgress = 0 + self.pendingDeviceType = nil + self.isTargetedForTouch = false + self.parentOverlay = options.parentOverlay + self.popScale = 1.0 + self.maxPopScale = 1.12 + self.popAnimationSpeed = 1 + + -- Set size after parent initialization + self.width = PLAYER_SLOT_SIZE + self.height = PLAYER_SLOT_SIZE + + self:createPlayerNumberImage() +end, UiElement) + +function PlayerInputDeviceSlot:drawSelf() + love.graphics.push() + + -- Apply scale animation from center of slot + if self.popScale ~= 1.0 then + local centerX = self.x + self.width / 2 + local centerY = self.y + self.height / 2 + love.graphics.translate(centerX, centerY) + love.graphics.scale(self.popScale, self.popScale) + love.graphics.translate(-centerX, -centerY) + end + + self:drawSlotBackground(self) + self:drawSlotBorder(self) + + love.graphics.pop() +end + +-- Draws slot background with progress-based color transitions +---@param slot PlayerInputDeviceSlot +function PlayerInputDeviceSlot:drawSlotBackground(slot) + local progress = self.holdProgress or 0 + local bgColor = self:getBackgroundColor(progress) + GraphicsUtil.setColor(bgColor[1], bgColor[2], bgColor[3], bgColor[4]) + GraphicsUtil.drawRectangle("fill", self.x, self.y, slot.width, slot.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Draws slot border with progress-based color transitions +---@param slot PlayerInputDeviceSlot +function PlayerInputDeviceSlot:drawSlotBorder(slot) + local progress = self.holdProgress or 0 + local borderColor = self:getBorderColor(progress) + GraphicsUtil.setColor(borderColor[1], borderColor[2], borderColor[3], borderColor[4]) + GraphicsUtil.drawRectangle("line", self.x, self.y, slot.width, slot.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Gets background color based on assignment and progress +---@param progress number Hold progress from 0-1 +---@return table Color array {r, g, b, a} +function PlayerInputDeviceSlot:getBackgroundColor(progress) + if self.assignedDevice then + return GAME.theme.colors.activeBackgroundColor + else + -- Interpolate from inputSlotDefaultBackgroundColor to inputSlotSelectedBackgroundColor + local defaultColor = GAME.theme.colors.inputSlotDefaultBackgroundColor + local selectedColor = GAME.theme.colors.inputSlotSelectedBackgroundColor + return { + defaultColor[1] + (selectedColor[1] - defaultColor[1]) * progress, + defaultColor[2] + (selectedColor[2] - defaultColor[2]) * progress, + defaultColor[3] + (selectedColor[3] - defaultColor[3]) * progress, + defaultColor[4] + (selectedColor[4] - defaultColor[4]) * progress, + } + end +end + +-- Gets border color based on assignment and progress +---@param progress number Hold progress from 0-1 +---@return table Color array {r, g, b, a} +function PlayerInputDeviceSlot:getBorderColor(progress) + if self.assignedDevice then + return GAME.theme.colors.inputSlotSelectedBorderColor + else + -- Interpolate from inputSlotDefaultBorderColor to inputSlotSelectedBorderColor + local defaultColor = GAME.theme.colors.inputSlotDefaultBorderColor + local selectedColor = GAME.theme.colors.inputSlotSelectedBorderColor + return { + defaultColor[1] + (selectedColor[1] - defaultColor[1]) * progress, + defaultColor[2] + (selectedColor[2] - defaultColor[2]) * progress, + defaultColor[3] + (selectedColor[3] - defaultColor[3]) * progress, + defaultColor[4] + (selectedColor[4] - defaultColor[4]) * progress, + } + end +end + +-- Creates the player number image from theme +function PlayerInputDeviceSlot:createPlayerNumberImage() + local playerIcon = GAME.theme:getPlayerNumberIcon(self.playerNumber) + assert(playerIcon, string.format("Missing player %d icon in current theme", self.playerNumber)) + + self.playerImage = ImageContainer({ + image = playerIcon, + hAlign = "center", + vAlign = "top", + y = 14, + scale = 2 + }) + self:addChild(self.playerImage) +end + +-- Sets the assigned device for this player slot +---@param config InputConfiguration? Input configuration or nil +function PlayerInputDeviceSlot:setAssignedDevice(config) + self.assignedDevice = config +end + +-- Updates the device icon based on current assignment or hold progress +function PlayerInputDeviceSlot:updateDeviceIcon() + if self.deviceIcon then + self.deviceIcon:detach() + self.deviceIcon = nil + end + + -- Show icon for assigned device OR pending device during hold + local deviceType = nil + if self.assignedDevice and self.assignedDevice.deviceType then + deviceType = self.assignedDevice.deviceType + elseif self.pendingDeviceType and self.holdProgress > 0 then + deviceType = self.pendingDeviceType + end + + if deviceType then + local iconElement = UiElement({ + width = DEVICE_ICON_SIZE, + height = DEVICE_ICON_SIZE, + hAlign = "center", + vAlign = "center" + }) + + -- Intentional override + ---@diagnostic disable-next-line: duplicate-set-field + iconElement.drawSelf = function(icon) + -- Device icon transitions from grey to blue based on progress + local progress = self.holdProgress or 0 + local alpha + if self.assignedDevice then + -- Assigned device: full opacity + alpha = 1 + else + -- Pending device: grey to blue transition (fade in) + alpha = 0.4 + progress * 0.6 + end + + local centerX = icon.width / 2 + local centerY = icon.height / 2 + + -- Get pre-calculated controller image variant and device number from config + local controllerImageVariant = nil + local deviceNumber = nil + + if self.assignedDevice then + -- Use pre-calculated values from assigned device config + controllerImageVariant = self.assignedDevice.controllerImageVariant + deviceNumber = self.assignedDevice.deviceNumber + elseif self.pendingDeviceType and self.parentOverlay then + -- For pending devices, find the config being held to show specific controller icon + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType == self.pendingDeviceType then + local state = self.parentOverlay.deviceState[config.id] + if state and state.holdTime > 0 then + controllerImageVariant = config.controllerImageVariant + deviceNumber = config.deviceNumber + break + end + end + end + end + + -- Render the device icon with number if applicable + InputPromptRenderer.renderIconWithNumber(deviceType, centerX, centerY, DEVICE_ICON_SIZE, alpha, controllerImageVariant, deviceNumber) + end + + self.deviceIcon = iconElement + self:addChild(iconElement) + end +end + +-- Sets hold progress and pending device type for visual feedback +---@param progress number Hold progress from 0-1 +---@param pendingDeviceType string? Device type being held (keyboard/controller/touch) +function PlayerInputDeviceSlot:setHoldProgress(progress, pendingDeviceType) + self.holdProgress = math.max(0, math.min(1, progress)) + self.pendingDeviceType = pendingDeviceType +end + +---@param isTarget boolean True when mouse is hovering over this slot +function PlayerInputDeviceSlot:setTouchTarget(isTarget) + self.isTargetedForTouch = isTarget +end + +---@param dt number Delta time in seconds +function PlayerInputDeviceSlot:updateSelf(dt) + -- Update device icon if needed during each frame + self:updateDeviceIcon() + + -- Animate pop scale back to 1.0 + if self.popScale > 1.0 then + self.popScale = self.popScale - (self.popAnimationSpeed * dt) + if self.popScale < 1.0 then + self.popScale = 1.0 + end + end +end + +-- Triggers pop animation when assignment completes +function PlayerInputDeviceSlot:triggerPopAnimation() + self.popScale = self.maxPopScale +end + +-- Checks if mouse cursor is over this player slot +---@return boolean True if mouse is over this slot +function PlayerInputDeviceSlot:isMouseOver() + local mx, my = inputManager.mouse.x, inputManager.mouse.y + local x, y = self:getScreenPos() + return mx >= x and mx <= x + self.width and my >= y and my <= y + self.height +end + +return PlayerInputDeviceSlot diff --git a/client/src/ui/Button.lua b/client/src/ui/Button.lua index b99ddbb6..0d1026e7 100644 --- a/client/src/ui/Button.lua +++ b/client/src/ui/Button.lua @@ -21,15 +21,17 @@ local Button = class( self.currentlyPressed = false -- callbacks - self.onClick = options.onClick or function() - GAME.theme:playValidationSfx() - end + self.onClick = options.onClick end, UIElement ) Button.TYPE = "Button" +function Button:onClick() + GAME.theme:playValidationSfx() +end + function Button:onTouch(x, y) self.currentlyPressed = true end diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua new file mode 100644 index 00000000..224b7d28 --- /dev/null +++ b/client/src/ui/ChangeInputButton.lua @@ -0,0 +1,212 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local Button = require(PATH .. ".Button") +local Label = require(PATH .. ".Label") +local class = require("common.lib.class") +local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") +local StackPanel = require(PATH .. ".StackPanel") +local UiElement = require(PATH .. ".UIElement") + +---@class ChangeInputButtonOptions : ButtonOptions +---@field players Player[] +---@field openInputDeviceOverlay fun() + +-- Button that displays current player input assignments and allows changing them +---@class ChangeInputButton : Button +---@field players Player[] The players we query assignments for +---@field localHumanPlayers Player[] +---@field openInputDeviceOverlay fun() Callback invoked when button is clicked to change inputs +---@field titleLabel Label Title text label +---@field iconContainer StackPanel Container for player assignment icons +---@field signalConnections table[] Array of signal subscriptions for live updates +local ChangeInputButton = class( + function(self, options) + options = options or {} + + self.players = options.players + self.localHumanPlayers = {} + for _, player in ipairs(self.players) do + if player.isLocal and player.human then + self.localHumanPlayers[#self.localHumanPlayers+1] = player + end + end + + self.openInputDeviceOverlay = options.openInputDeviceOverlay + + 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) + + -- Subscribe to player signals and update initial state + self:subscribeToPlayerSignals() + self:updateSummary() + end, + Button +) + +function ChangeInputButton:onClick() + if #self.localHumanPlayers == 0 then + GAME.theme:playCancelSfx() + return + else + local released = false + for i, player in ipairs(self.localHumanPlayers) do + if player.inputConfiguration then + player:unrestrictInputs() + released = true + end + end + + if released then + GAME.theme:playCancelSfx() + self.openInputDeviceOverlay() + else + GAME.theme:playMoveSfx() + end + end +end + +function ChangeInputButton:onSelect() + self:onClick() +end + +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.localHumanPlayers then + self.isEnabled = true + return + end + + if #self.localHumanPlayers == 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(self.localHumanPlayers) do + self:addPlayerRow(player, i) + + -- Add spacing between player rows (except after last) + if i < #self.localHumanPlayers 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 + +function ChangeInputButton:subscribeToPlayerSignals() + if not self.localHumanPlayers then + return + end + + for _, player in ipairs(self.localHumanPlayers) do + local connection = player:connectSignal("inputConfigurationChanged", self, self.updateSummary) + self.signalConnections[#self.signalConnections + 1] = {player = player, connection = connection} + end +end + +return ChangeInputButton \ No newline at end of file diff --git a/client/src/ui/DiscreteImageSlider.lua b/client/src/ui/DiscreteImageSlider.lua new file mode 100644 index 00000000..0d400b74 --- /dev/null +++ b/client/src/ui/DiscreteImageSlider.lua @@ -0,0 +1,304 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local Slider = require(PATH .. ".Slider") +local StackPanel = require(PATH .. ".StackPanel") +local ImageContainer = require(PATH .. ".ImageContainer") +local UIElement = require(PATH .. ".UIElement") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +---@class DiscreteValue +---@field id any Unique identifier for this value +---@field image love.Texture Image to display for this value +---@field selectedImage love.Texture? Optional image to display when selected (defaults to image) +---@field scale number? Scale factor for the image (default: 1) + +---@class DiscreteImageSliderOptions : UiElementOptions +---@field values DiscreteValue[]? Array of discrete values to display +---@field itemSpacing number? Spacing between items in pixels (default: 0) +---@field selectedValue any? Initially selected value ID +---@field onValueChange fun(slider:DiscreteImageSlider)? Callback when value changes + +---@class DiscreteImageSlider: Slider +---@field values DiscreteValue[] Array of discrete values +---@field itemSpacing number Spacing between items +---@field stackPanel StackPanel Layout container for items +---@field valueIdToIndex table Map from value ID to array index +---@overload fun(options: DiscreteImageSliderOptions): DiscreteImageSlider +local DiscreteImageSlider = class( + function(self, options) + self.values = options.values or {} + self.itemSpacing = options.itemSpacing or 0 + self.onValueChange = options.onValueChange or function() end + self.onlyChangeOnRelease = options.onlyChangeOnRelease or false + + -- Create StackPanel for layout (as a child) + self.stackPanel = StackPanel({ + alignment = "left", + hAlign = "left", + vAlign = "top" + }) + + -- Build index map, populate StackPanel, and calculate dimensions + self:rebuildLayout() + + -- Add StackPanel as a child so it draws automatically + self:addChild(self.stackPanel) + + -- Set initial value + local initialValue = options.selectedValue + if initialValue then + self.value = self:getIndexForId(initialValue) or 1 + else + self.value = 1 + end + + -- Update image selection for initial state + self:updateImageSelection() + + -- Set tickAmount for parent Slider compatibility + self.tickAmount = 1 + end, + Slider +) +DiscreteImageSlider.TYPE = "DiscreteImageSlider" + +---@param id any Value identifier +---@return number? index Array index for this ID, or nil if not found +function DiscreteImageSlider:getIndexForId(id) + return self.valueIdToIndex[id] +end + +---@param index number Array index +---@return any? id Value identifier at this index, or nil if out of bounds +function DiscreteImageSlider:getIdForIndex(index) + if index >= 1 and index <= #self.values then + return self.values[index].id + end + return nil +end + +---@return any? id Currently selected value ID +function DiscreteImageSlider:getSelectedId() + return self:getIdForIndex(self.value) +end + +---@param id any Value identifier to select +---@param committed boolean Whether to trigger onValueChange callback +function DiscreteImageSlider:setSelectedId(id, committed) + local index = self:getIndexForId(id) + if index then + self:setValue(index, committed) + end +end + +function DiscreteImageSlider:setValue(newValue, committed) + local oldValue = self.value + + -- Call parent to handle value change and callbacks + Slider.setValue(self, newValue, committed) + + -- Update images if value actually changed + if oldValue ~= self.value then + self:updateImageSelection() + end +end + +function DiscreteImageSlider:updateImageSelection() + for i, imageContainer in ipairs(self.imageContainers) do + local value = imageContainer.discreteValue + local isSelected = (i == self.value) + local newImage = (isSelected and value.selectedImage) or value.image + + if imageContainer.image ~= newImage then + imageContainer:setImage(newImage, nil, nil, value.scale or 1) + end + end +end + +function DiscreteImageSlider:rebuildLayout() + -- Clear existing layout + while #self.stackPanel.children > 0 do + self.stackPanel:remove(self.stackPanel.children[1]) + end + + -- Rebuild index map + self.valueIdToIndex = {} + for i, value in ipairs(self.values) do + self.valueIdToIndex[value.id] = i + end + + -- Store references to ImageContainers for updating selection state + self.imageContainers = {} + + -- Populate StackPanel with ImageContainers for each value + for i, value in ipairs(self.values) do + local scale = value.scale or 1 + local image = value.image + + local imageContainer = ImageContainer({ + image = image, + scale = scale, + hAlign = "left", + vAlign = "top" + }) + + -- Store reference for tracking selection and value + imageContainer.discreteIndex = i + imageContainer.discreteValue = value + self.imageContainers[i] = imageContainer + + self.stackPanel:addElement(imageContainer) + + -- Add spacing after each item except the last + if i < #self.values and self.itemSpacing > 0 then + local spacer = UIElement({ + width = self.itemSpacing, + height = imageContainer.height, + hAlign = "left", + vAlign = "top" + }) + self.stackPanel:addElement(spacer) + end + end + + -- Update dimensions + self.width = self.stackPanel.width + + -- Calculate max height from StackPanel children (the image containers) + local stackPanelMaxHeight = 0 + for _, child in ipairs(self.stackPanel.children) do + if child.height > stackPanelMaxHeight then + stackPanelMaxHeight = child.height + end + end + self.stackPanel.height = stackPanelMaxHeight + + -- Calculate total height including all direct children (e.g., labels in subclasses) + local totalHeight = stackPanelMaxHeight + for _, child in ipairs(self.children) do + if child ~= self.stackPanel then + -- For children positioned below stackPanel (with positive y offset) + local childBottomEdge = child.y + child.height + if childBottomEdge > totalHeight then + totalHeight = childBottomEdge + end + end + end + self.height = totalHeight + + self.min = 1 + self.max = math.max(1, #self.values) +end + +---@param newValues DiscreteValue[] New array of discrete values +function DiscreteImageSlider:setValues(newValues) + self.values = newValues + local oldValue = self.value + self:rebuildLayout() + + -- Try to maintain selection if possible + local newValue + if oldValue > #self.values then + newValue = math.max(1, #self.values) + else + newValue = oldValue + end + + self:setValue(newValue, false) +end + +---@param x number Screen x coordinate +---@return number index Value index for this position +function DiscreteImageSlider:getValueForPos(x) + if #self.values == 0 then + return 1 + end + + local screenX, screenY = self:getScreenPos() + local relativeX = x - screenX + + -- Find which item was clicked based on StackPanel children positions + local bestIndex = 1 + local bestDistance = math.huge + + for i, child in ipairs(self.stackPanel.children) do + if child.discreteIndex then + local itemScreenX = screenX + child.x + local itemCenterX = itemScreenX + child.width / 2 + local distance = math.abs(relativeX - (child.x + child.width / 2)) + + if distance < bestDistance then + bestDistance = distance + bestIndex = child.discreteIndex + end + end + end + + return bestIndex +end + +---@return number x X position of current value's center +function DiscreteImageSlider:getCurrentXForValue() + if self.value < 1 or self.value > #self.values then + return self.x + end + + -- Find the UI element for this value index + for _, child in ipairs(self.stackPanel.children) do + if child.discreteIndex == self.value then + return self.x + child.x + child.width / 2 + end + end + + return self.x +end + +-- Gets the rectangle of the currently selected item (for SliderMenuItem highlighting) +---@return {x: number, y: number, width: number, height: number}? +function DiscreteImageSlider:getSelectedItemRect() + if self.value < 1 or self.value > #self.values then + return nil + end + + local selectedContainer = self.imageContainers[self.value] + if not selectedContainer then + return nil + end + + return { + x = selectedContainer.x, + y = selectedContainer.y, + width = selectedContainer.width, + height = selectedContainer.height + } +end + +function DiscreteImageSlider:drawSelf() + -- Draw simple static border around the currently selected item + if self.value < 1 or self.value > #self.values then + return + end + + -- Find the selected image container + local selectedContainer = self.imageContainers[self.value] + if not selectedContainer then + return + end + + -- Draw static border + local borderColor = GAME.theme.colors.menuDefaultBorderColor + + GraphicsUtil.setColor(borderColor[1], borderColor[2], borderColor[3], 0.8) + GraphicsUtil.drawRectangle( + "line", + self.x + selectedContainer.x, + self.y + selectedContainer.y, + selectedContainer.width, + selectedContainer.height + ) + + -- Reset color + GraphicsUtil.setColor(1, 1, 1, 1) +end + +return DiscreteImageSlider diff --git a/client/src/ui/InputConfigSlider.lua b/client/src/ui/InputConfigSlider.lua new file mode 100644 index 00000000..237b2523 --- /dev/null +++ b/client/src/ui/InputConfigSlider.lua @@ -0,0 +1,164 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local DiscreteImageSlider = require(PATH .. ".DiscreteImageSlider") +local Label = require(PATH .. ".Label") +local ImageContainer = require(PATH .. ".ImageContainer") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") + +---@class InputConfigSliderOptions : DiscreteImageSliderOptions +---@field onValueChange fun(slider:InputConfigSlider)? callback for whenever the value is changed + +-- A visual slider for input configuration selection showing controller/keyboard icons +---@class InputConfigSlider: DiscreteImageSlider +---@field deviceLabel Label Label showing selected device name +---@field iconSize number Size of device icons +---@overload fun(options: InputConfigSliderOptions): InputConfigSlider +local InputConfigSlider = class( +---@param self InputConfigSlider +---@param options InputConfigSliderOptions + function(self, options) + -- Get controller image width to calculate icon size + local controllerImage = GAME.theme:getInputPromptIcon("controller") + local imageWidth = controllerImage and controllerImage:getWidth() or 128 + local iconSize = imageWidth / 2 + + -- Store iconSize for later use + self.iconSize = iconSize + + self.itemSpacing = 2 + self.selectedValue = options.selectedValue or 1 + + -- Create label for device name + local config = GAME.input.inputConfigurations[options.selectedValue or 1] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel = Label({ + text = labelText, + translate = false, + hAlign = "center", + y = iconSize + 6 + }) + + -- Add label as child + self:addChild(self.deviceLabel) + + self:refresh() + end, + DiscreteImageSlider +) +InputConfigSlider.TYPE = "InputConfigSlider" + +-- Checks if a configuration slot has any key bindings +---@param configIndex number Configuration slot index +---@return boolean +function InputConfigSlider:hasBindings(configIndex) + local config = GAME.input.inputConfigurations[configIndex] + if not config then + return false + end + + return not config:isEmpty() +end + + +-- Finds the first empty configuration slot +---@return number? index of first empty slot or nil if all full +function InputConfigSlider:findNextAvailableSlot() + for i = 1, input.maxConfigurations do + if not self:hasBindings(i) then + return i + end + end + return nil +end + +-- Builds DiscreteValue array from current input configurations +---@return DiscreteValue[] values Array of values for DiscreteImageSlider +function InputConfigSlider:buildValuesArray() + local values = {} + local iconScale = self.iconSize / 128 -- Controller images are 128x128 + local nextAvailableSlot = self:findNextAvailableSlot() + + for i = 1, #input.inputConfigurations do + if self:hasBindings(i) then + -- Get device-specific icon + local config = GAME.input.inputConfigurations[i] + if config and config.deviceType and config.deviceType ~= "touch" then + local icon = GAME.theme:getSpecificInputIcon(config.deviceType, config.controllerImageVariant) + if icon then + values[#values + 1] = { + id = i, + image = icon, + scale = iconScale + } + end + end + elseif i == nextAvailableSlot then + -- Show "+" icon for first empty slot + local plusIcon = GAME.theme:getInputPromptIcon("controller_add") + if plusIcon then + values[#values + 1] = { + id = i, + image = plusIcon, + scale = iconScale + } + end + end + end + + return values +end + +-- Refreshes the slider visual (call when configs change) +function InputConfigSlider:refresh() + local newValues = self:buildValuesArray() + self:setValues(newValues) + + -- Update label text + local config = GAME.input.inputConfigurations[self.value] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel:setText(labelText) +end + +function InputConfigSlider:setValue(newValue, committed) + -- Call parent to handle value change + DiscreteImageSlider.setValue(self, newValue, committed) + + -- Update label text when selection changes + local config = GAME.input.inputConfigurations[self.value] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel:setText(labelText) +end + +function InputConfigSlider:rebuildLayout() + -- Call parent to rebuild layout + DiscreteImageSlider.rebuildLayout(self) + + -- Add error indicators to incomplete configurations + for _, imageContainer in ipairs(self.imageContainers) do + local valueId = imageContainer.discreteValue.id + local config = GAME.input.inputConfigurations[valueId] + + -- Check if configuration is incomplete (has bindings but not all keys) + if config and not config:isEmpty() and not config:isFullyConfigured() then + -- Get error indicator icon + local errorIcon = GAME.theme:getInputPromptIcon("controller_error") + if errorIcon then + -- Create error indicator as child with red tint + local errorIndicator = ImageContainer({ + image = errorIcon, + scale = 0.18 + }) + + -- Position in bottom-right corner of the device icon + errorIndicator.x = imageContainer.width / 2 - (errorIndicator.width / 2) + errorIndicator.y = imageContainer.height - (errorIndicator.height) - 2 + + -- Add as child of the image container + imageContainer:addChild(errorIndicator) + end + end + end +end + +return InputConfigSlider diff --git a/client/src/ui/KeyBindingMenuItem.lua b/client/src/ui/KeyBindingMenuItem.lua new file mode 100644 index 00000000..77cbc289 --- /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 = 224 + }) + + -- Create binding button (right side) + local bindingButton = TextButton({ + label = Label({ + text = options.bindingText, + translate = false, + hAlign = "center", + vAlign = "center", + fontSize = 12 + }), + onClick = options.onActivate, + width = BUTTON_WIDTH + }) + bindingButton.x = keyLabel.width + SPACE_BETWEEN + bindingButton.vAlign = "center" + + -- Store references + menuItem.keyLabel = keyLabel + menuItem.bindingButton = bindingButton + + -- Calculate dimensions + menuItem.width = keyLabel.width + MenuItem.PADDING + bindingButton.width + menuItem.height = math.max(keyLabel.height, bindingButton.height) + + -- Add children + menuItem:addChild(keyLabel) + menuItem:addChild(bindingButton) + + return menuItem +end + +--- Sets the binding text displayed on the button +---@param text string The binding text to display +function KeyBindingMenuItem:setBinding(text) + if self.bindingButton and self.bindingButton.label then + self.bindingButton.label:setText(text, nil, false) + end +end + +--- Sets whether this item is currently setting a key +---@param setting boolean True if actively setting a key +function KeyBindingMenuItem:setSettingKey(setting) + self.settingKey = setting +end + +function KeyBindingMenuItem:drawSelf() + local baseOpacity = 0.15 + + -- Draw subtle glow on button area + local buttonX = self.x + self.bindingButton.x + local buttonY = self.y + self.bindingButton.y + if self.settingKey then + -- Active key setting: strong glow on button area with pulse (regardless of selection state) + local selectedAdditionalOpacity = 0.5 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + elseif self.selected then + -- Normal selection: subtle pulse on button area + local selectedAdditionalOpacity = 0.1 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + else + -- no special drawing for base case for now, just the elements + end +end + +function KeyBindingMenuItem:receiveInputs(inputs) + if self.bindingButton then + self.bindingButton:receiveInputs(inputs) + end +end + +return KeyBindingMenuItem diff --git a/client/src/ui/Label.lua b/client/src/ui/Label.lua index 31d6383b..7cc77d49 100644 --- a/client/src/ui/Label.lua +++ b/client/src/ui/Label.lua @@ -27,7 +27,8 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@field strokeColors table? List of red, green, blue, and alpha color for stroke, no stroke if nil ---@field textColor table? List of red, green, blue, and alpha color for text ---@field drawable love.TextBatch Cached love.TextBatch for redrawing ----@field autoSizeToText boolean true if the text should change the width and height +---@field autoSizeWidth boolean true if the text should change the width +---@field autoSizeHeight boolean true if the text should change the height ---@field paddingTop number Top padding in pixels ---@field paddingRight number Right padding in pixels ---@field paddingBottom number Bottom padding in pixels @@ -38,7 +39,8 @@ local Label = class( self.hAlign = options.hAlign or "left" self.vAlign = options.vAlign or "top" self.hFill = options.hFill or false - self.autoSizeToText = (self.width == 0 or self.height == 0) + self.autoSizeWidth = self.width == 0 + self.autoSizeHeight = self.height == 0 self.wrapWidth = options.wrapWidth or nil self.fontSize = options.fontSize or GraphicsUtil.fontSize local padding = options.padding or 0 @@ -151,8 +153,11 @@ function Label:refreshFormatting() self.drawable:set(text) end - if self.autoSizeToText then + if self.autoSizeWidth then self.width = self.drawable:getWidth() + self.paddingLeft + self.paddingRight + end + + if self.autoSizeHeight then self.height = self.drawable:getHeight() + self.paddingTop + self.paddingBottom end end diff --git a/client/src/ui/Menu.lua b/client/src/ui/Menu.lua index 0bc6fbab..1b46b178 100644 --- a/client/src/ui/Menu.lua +++ b/client/src/ui/Menu.lua @@ -24,6 +24,11 @@ local Menu = class( self.totalHeight = 0 self.menuItemYOffsets = {} self.allContentShowing = true + self.sizeToFit = options.height == 0 + self.supportsBackButton = true + if options.supportsBackButton ~= nil and options.supportsBackButton == false then + self.supportsBackButton = false + end self.upIndicator = Label({text = "^", translate = false, isVisible = false, vAlign = "top", hAlign = "center", y = -14}) self.downIndicator = Label({text = "v", translate = false, isVisible = false, vAlign = "bottom", hAlign = "center"}) @@ -31,7 +36,7 @@ local Menu = class( self:addChild(self.downIndicator) -- bogus this should be passed in? - self.centerVertically = themes[config.theme].centerMenusVertically + self.centerVertically = themes[config.theme].centerMenusVertically and not self.sizeToFit self.yOffset = 0 self.firstActiveIndex = 1 @@ -46,16 +51,14 @@ Menu.NAVIGATION_BUTTON_WIDTH = NAVIGATION_BUTTON_WIDTH Menu.BUTTON_HORIZONTAL_PADDING = 0 Menu.BUTTON_VERTICAL_PADDING = 8 -function Menu.createCenteredMenu(items) - local menu = Menu({ - x = 0, - y = 0, - hAlign = "center", - vAlign = "center", - menuItems = items, - height = themes[config.theme].main_menu_max_height - }) +function Menu.createCenteredMenu(items, height, options) + options = options or {} + options.hAlign = "center" + options.vAlign = "center" + options.menuItems = items + options.height = height or themes[config.theme].main_menu_max_height + local menu = Menu(options) return menu end @@ -93,6 +96,17 @@ function Menu:layout() return end + -- If sizeToFit is enabled, recalculate height from content + if self.sizeToFit then + self.height = 0 + for i, menuItem in ipairs(self.menuItems) do + self.height = self.height + menuItem.height + if i < #self.menuItems then + self.height = self.height + Menu.BUTTON_VERTICAL_PADDING + end + end + end + local currentY = 0 local totalMenuHeight = 0 local menuFull = false @@ -104,7 +118,7 @@ function Menu:layout() self.upIndicator:setVisibility(true) end if menuFull == false and realY >= 0 then - if realY + menuItem.height < self.height then + if realY + menuItem.height <= self.height then if self.firstActiveIndex == nil then self.firstActiveIndex = i end @@ -117,18 +131,24 @@ function Menu:layout() menuFull = true end end - currentY = currentY + menuItem.height + Menu.BUTTON_VERTICAL_PADDING + currentY = currentY + menuItem.height + if i < #self.menuItems then + currentY = currentY + Menu.BUTTON_VERTICAL_PADDING + end if menuFull == false then self.lastActiveIndex = i totalMenuHeight = realY + menuItem.height end self.width = math.max(self.width, menuItem.width) - self.totalHeight = self.totalHeight + menuItem.height + Menu.BUTTON_VERTICAL_PADDING + self.totalHeight = self.totalHeight + menuItem.height + if i < #self.menuItems then + self.totalHeight = self.totalHeight + Menu.BUTTON_VERTICAL_PADDING + end end if self.centerVertically then self.y = self.yMin + (self.height / 2) - (totalMenuHeight / 2) - else + elseif not self.sizeToFit then self.y = self.yMin end end @@ -243,11 +263,13 @@ function Menu:receiveInputs(inputs, dt) if self.focused then self.focused:receiveInputs(inputs, dt) elseif inputs.isDown["MenuEsc"] then - if self.selectedIndex ~= #self.menuItems then - self:setSelectedIndex(#self.menuItems) - GAME.theme:playCancelSfx() - else - selectedElement:receiveInputs(inputs, dt) + if self.supportsBackButton then + if self.selectedIndex ~= #self.menuItems then + self:setSelectedIndex(#self.menuItems) + GAME.theme:playCancelSfx() + else + selectedElement:receiveInputs(inputs, dt) + end end elseif inputs:isPressedWithRepeat("MenuUp") then self:scrollUp() diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 400192c1..e8649425 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -7,8 +7,18 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local system = require("client.src.system") local DebugSettings = require("client.src.debug.DebugSettings") --- MenuItem is a specific UIElement that all children of Menu should be -local MenuItem = class(function(self, options) +---@class MenuItem : UiElement +---@field selected boolean whether this menu item is currently selected +---@field TYPE string type identifier for this class +---@field textButton TextButton? optional reference to a text button if this item contains one +---@field onSelectedFunction function? callback function to execute when the item is selected + +---@class MenuItemOptions : UiElementOptions + +---@class MenuItem +---@overload fun(options: MenuItemOptions): MenuItem +local MenuItem = class( + function(self, options) self.selected = false self.TYPE = "MenuItem" end, @@ -16,8 +26,10 @@ UiElement) MenuItem.PADDING = 2 --- Takes a label and an optional extra element and makes and combines them into a menu item --- which is suitable for inserting into a menu +---Takes a label and an optional extra element and makes and combines them into a menu item which is suitable for inserting into a menu +---@param label UiElement the label or left element to display +---@param item UiElement? optional right element to display +---@return MenuItem function MenuItem.createMenuItem(label, item) assert(label ~= nil) @@ -51,22 +63,21 @@ function MenuItem.createMenuItem(label, item) return menuItem end --- Creates a menu item with just a button -function MenuItem.createButtonMenuItem(text, replacements, translate, onClick, width) - assert(text ~= nil) +---Creates a menu item with just a button, using a pre-created Label +---@param label Label the label to use for the button +---@param onClick function callback function when the button is clicked +---@param width number? optional width for the button (defaults to 140) +---@return MenuItem +function MenuItem.createButtonMenuItemWithLabel(label, onClick, width) + assert(label ~= nil) local BUTTON_WIDTH = width or 140 - if translate == nil then - translate = true - end + label.hAlign = "center" + label.vAlign = "center" + local textButton = TextButton({ - label = Label({ - text = text, - replacements = replacements, - translate = translate, - hAlign = "center", - vAlign = "center" - }), - onClick = onClick, width = BUTTON_WIDTH + label = label, + onClick = onClick, + width = BUTTON_WIDTH }) local menuItem = MenuItem.createMenuItem(textButton) @@ -75,7 +86,38 @@ function MenuItem.createButtonMenuItem(text, replacements, translate, onClick, w return menuItem end --- Creates a menu item with a label followed by a button +---Creates a menu item with just a button +---@param text string the text to display on the button +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param onClick function callback function when the button is clicked +---@param width number? optional width for the button (defaults to 140) +---@return MenuItem +function MenuItem.createButtonMenuItem(text, replacements, translate, onClick, width) + assert(text ~= nil) + if translate == nil then + translate = true + end + + local label = Label({ + text = text, + replacements = replacements, + translate = translate + }) + + return MenuItem.createButtonMenuItemWithLabel(label, onClick, width) +end + +---Creates a menu item with a label followed by a button +---@param labelText string the text for the left label +---@param labelTextReplacements table? optional text replacements for label localization +---@param labelTextTranslate boolean? whether to translate the label text (defaults to true) +---@param buttonText string the text for the button +---@param buttonTextReplacements table? optional text replacements for button localization +---@param buttonTextTranslate boolean? whether to translate the button text (defaults to true) +---@param buttonOnClick function callback function when the button is clicked +---@param width number? optional width for the button (defaults to 140) +---@return MenuItem function MenuItem.createLabeledButtonMenuItem(labelText, labelTextReplacements, labelTextTranslate, buttonText, buttonTextReplacements, buttonTextTranslate, buttonOnClick, width) assert(labelText ~= nil) assert(buttonText ~= nil) @@ -97,6 +139,12 @@ function MenuItem.createLabeledButtonMenuItem(labelText, labelTextReplacements, return menuItem end +---Creates a menu item with a label and a stepper control +---@param text string the text for the label +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param stepper UiElement the stepper control element +---@return MenuItem function MenuItem.createStepperMenuItem(text, replacements, translate, stepper) assert(text ~= nil) assert(stepper ~= nil) @@ -109,6 +157,12 @@ function MenuItem.createStepperMenuItem(text, replacements, translate, stepper) return menuItem end +---Creates a menu item with a label and a toggle button group +---@param text string the text for the label +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param toggleButtonGroup UiElement the toggle button group element +---@return MenuItem function MenuItem.createToggleButtonGroupMenuItem(text, replacements, translate, toggleButtonGroup) assert(text ~= nil) assert(toggleButtonGroup ~= nil) @@ -121,6 +175,12 @@ function MenuItem.createToggleButtonGroupMenuItem(text, replacements, translate, return menuItem end +---Creates a menu item with a label and a slider control +---@param text string the text for the label +---@param replacements table? optional text replacements for localization +---@param translate boolean? whether to translate the text (defaults to true) +---@param slider UiElement the slider control element +---@return MenuItem function MenuItem.createSliderMenuItem(text, replacements, translate, slider) assert(text ~= nil) assert(slider ~= nil) @@ -145,6 +205,8 @@ function MenuItem.createBoolSelectorMenuItem(text, replacements, translate, bool return menuItem end +---Sets the selected state of this menu item +---@param selected boolean whether the item should be selected function MenuItem:setSelected(selected) self.selected = selected if selected and self.onSelectedFunction then @@ -153,26 +215,27 @@ function MenuItem:setSelected(selected) end -local DEFAULT_BACKGROUND_COLOR = {1, 1, 1} -local SELECTED_BACKGROUND_COLOR = {0.6, 0.6, 1} -local DEFAULT_BORDER_COLOR = {1, 1, 1} -local SELECTED_BORDER_COLOR = {0.6, 0.6, 1} - +---Draws the menu item background and selection highlight function MenuItem:drawSelf() local baseOpacity = 0.15 if self.selected then local selectedAdditionalOpacity = 0.5 local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, SELECTED_BACKGROUND_COLOR[1], SELECTED_BACKGROUND_COLOR[2], SELECTED_BACKGROUND_COLOR[3], fillOpacity) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, SELECTED_BORDER_COLOR[1], SELECTED_BORDER_COLOR[2], SELECTED_BORDER_COLOR[3], borderOpacity) + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, borderColor[1], borderColor[2], borderColor[3], borderOpacity) else - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, DEFAULT_BACKGROUND_COLOR[1], DEFAULT_BACKGROUND_COLOR[2], DEFAULT_BACKGROUND_COLOR[3], baseOpacity) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, DEFAULT_BORDER_COLOR[1], DEFAULT_BORDER_COLOR[2], DEFAULT_BORDER_COLOR[3], baseOpacity) + local bgColor = GAME.theme.colors.menuDefaultBackgroundColor + local borderColor = GAME.theme.colors.menuDefaultBorderColor + GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height, bgColor[1], bgColor[2], bgColor[3], baseOpacity) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, borderColor[1], borderColor[2], borderColor[3], baseOpacity) end end --- inputs as a passthrough in case we ever implement player specific menus +---Passes inputs to child elements that can receive them +---@param inputs table input state table function MenuItem:receiveInputs(inputs) for _, child in ipairs(self.children) do if child.receiveInputs then diff --git a/client/src/ui/OverlayContainer.lua b/client/src/ui/OverlayContainer.lua index e3a41467..dfc1c9a9 100644 --- a/client/src/ui/OverlayContainer.lua +++ b/client/src/ui/OverlayContainer.lua @@ -26,7 +26,7 @@ local OverlayContainer = class( self.content.vAlign = "center" end end, - UiElement + UiElement, "OverlayContainer" ) -- Opens the overlay diff --git a/client/src/ui/SliderMenuItem.lua b/client/src/ui/SliderMenuItem.lua new file mode 100644 index 00000000..406882a8 --- /dev/null +++ b/client/src/ui/SliderMenuItem.lua @@ -0,0 +1,91 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local MenuItem = require(PATH .. ".MenuItem") +local Label = require(PATH .. ".Label") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local class = require("common.lib.class") + +---@class SliderMenuItem : MenuItem +local SliderMenuItem = class(function(self, options) + self.selected = false + self.TYPE = "SliderMenuItem" + self.labelText = options and options.labelText or nil + self.slider = options and options.slider or nil + self.x = 0 + self.y = 0 +end, MenuItem) + +--- Creates a SliderMenuItem with label and slider +---@param options table Options table with labelText, slider +---@return SliderMenuItem +function SliderMenuItem.create(options) + assert(options.labelText ~= nil) + assert(options.slider ~= nil) + + local SPACE_BETWEEN = 16 + + local menuItem = SliderMenuItem(options) + + -- Create label (left side) + local label = Label({ + text = options.labelText, + vAlign = "center" + }) + + -- Position slider (right side) + local slider = options.slider + slider.x = label.width + SPACE_BETWEEN + slider.vAlign = "center" + + -- Store references + menuItem.label = label + menuItem.slider = slider + + -- Calculate dimensions + menuItem.width = label.width + SPACE_BETWEEN + slider.width + MenuItem.PADDING + menuItem.height = math.max(label.height, slider.height) + (2 * MenuItem.PADDING) + + -- Add children + menuItem:addChild(label) + menuItem:addChild(slider) + + return menuItem +end + +function SliderMenuItem:drawSelf() + if self.selected and self.slider.getSelectedItemRect then + -- Get the rectangle of the currently selected slider item + local rect = self.slider:getSelectedItemRect() + + if rect then + -- Use same pulsing effect as regular menu items + local baseOpacity = 0.15 + local selectedAdditionalOpacity = 0.5 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + -- Convert slider-relative coordinates to absolute screen coordinates + -- Account for vAlign/hAlign offsets that are applied during child drawing + local alignOffsetX, alignOffsetY = GraphicsUtil.getAlignmentOffset(self, self.slider) + local absoluteX = self.x + self.slider.x + alignOffsetX + rect.x + local absoluteY = self.y + self.slider.y + alignOffsetY + rect.y + + -- Draw pulsing background fill + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + GraphicsUtil.drawRectangle("fill", absoluteX, absoluteY, rect.width, rect.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + + -- Draw pulsing border + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("line", absoluteX, absoluteY, rect.width, rect.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + end + end +end + +function SliderMenuItem:receiveInputs(inputs) + if self.slider then + self.slider:receiveInputs(inputs) + end +end + +return SliderMenuItem diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index a1018b35..da7a729b 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -5,9 +5,9 @@ local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") local DebugSettings = require("client.src.debug.DebugSettings") +-- StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting +-- Useful for auto-aligning multiple ui elements that only know one of their dimensions ---@class StackPanel : UiElement ----StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting. ----Useful for auto-aligning multiple ui elements that only know one of their dimensions. ---@field alignment "left"|"right"|"top"|"bottom" Direction in which children are stacked ---@field pixelsTaken number Tracks how many pixels are already taken in the stacking direction ---@field TYPE string Class type identifier diff --git a/client/src/ui/UIElement.lua b/client/src/ui/UIElement.lua index 4c1bc4c5..61f3049f 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) @@ -163,6 +165,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 +181,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 +226,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 +271,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:toStringWithDepth() + 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:toString() + local typeStr = self.TYPE and (" [" .. self.TYPE .. "]") or "" + local x, y = self:getScreenPos() + local lines = {string.format("%s @ (%.1f, %.1f)", typeStr, x, y)} + + for _, child in ipairs(self.children) do + local childTypeStr = child.TYPE and (" [" .. child.TYPE .. "]") or "" + local childX, childY = child:getScreenPos() + table.insert(lines, string.format(" %s @ (%.1f, %.1f)", childTypeStr, childX, childY)) + end + + return table.concat(lines, "\n") +end + return UIElement \ No newline at end of file diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index a884de6b..5b74969d 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -9,6 +9,9 @@ local ui = { Button = require(PATH .. ".Button"), ButtonGroup = require(PATH .. ".ButtonGroup"), Carousel = require(PATH .. ".Carousel"), + ---@see ChangeInputButton + ---@type fun(options: ChangeInputButtonOptions): ChangeInputButton + ChangeInputButton = require(PATH .. ".ChangeInputButton"), Focusable = require(PATH .. ".Focusable"), FocusDirector = require(PATH .. ".FocusDirector"), Grid = require(PATH .. ".Grid"), @@ -18,6 +21,7 @@ local ui = { ImageButton = require(PATH .. ".ImageButton"), ImageContainer = require(PATH .. ".ImageContainer"), InputField = require(PATH .. ".InputField"), + KeyBindingMenuItem = require(PATH .. ".KeyBindingMenuItem"), ---@see Label ---@type fun(options: LabelOptions): Label Label = require(PATH .. ".Label"), @@ -40,6 +44,7 @@ local ui = { ---@see Slider ---@type fun(options: SliderOptions): Slider Slider = require(PATH .. ".Slider"), + SliderMenuItem = require(PATH .. ".SliderMenuItem"), ---@source StackElement.lua StackElement = require(PATH .. ".StackElement"), StackPanel = require(PATH .. ".StackPanel"), diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index f4470052..d1cfc9e2 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -13,6 +13,14 @@ function touchHandler:touch(x, y) -- prevent multitouch if not self.touchedElement then self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) + + if not self.touchedElement then + local activeScene = GAME.navigationStack:getActiveScene() + if activeScene and activeScene.uiRoot then + self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) + end + end + if self.touchedElement and self.touchedElement.onTouch then self.touchedElement:onTouch(x, y) end diff --git a/client/tests/DiscreteImageSliderTests.lua b/client/tests/DiscreteImageSliderTests.lua new file mode 100644 index 00000000..ed8f62c7 --- /dev/null +++ b/client/tests/DiscreteImageSliderTests.lua @@ -0,0 +1,387 @@ +local DiscreteImageSlider = require("client.src.ui.DiscreteImageSlider") +local logger = require("common.lib.logger") + +local function createMockImage(width, height) + local imageData = love.image.newImageData(width, height) + return love.graphics.newImage(imageData) +end + +local function createMockValue(id, width, height) + width = width or 50 + height = height or 50 + return { + id = id, + image = createMockImage(width, height), + scale = 1 + } +end + +local function testBasicConstruction() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + selectedValue = "item2" + }) + + assert(slider ~= nil, "Slider should be created") + assert(#slider.values == 3, "Should have 3 values") + assert(slider.value == 2, "Should select item2 (index 2)") + assert(slider:getSelectedId() == "item2", "Should return correct selected ID") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 3, "Max should be 3") + + logger.trace("passed test testBasicConstruction") +end + +local function testEmptyConstruction() + local slider = DiscreteImageSlider({ + values = {} + }) + + assert(slider ~= nil, "Slider should be created with empty values") + assert(#slider.values == 0, "Should have 0 values") + assert(slider.value == 1, "Value should default to 1") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 1, "Max should be 1 even when empty") + + logger.trace("passed test testEmptyConstruction") +end + +local function testIndexIdMapping() + local values = { + createMockValue("alpha", 50, 50), + createMockValue("beta", 50, 50), + createMockValue("gamma", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + assert(slider:getIndexForId("alpha") == 1, "Should map alpha to index 1") + assert(slider:getIndexForId("beta") == 2, "Should map beta to index 2") + assert(slider:getIndexForId("gamma") == 3, "Should map gamma to index 3") + assert(slider:getIndexForId("nonexistent") == nil, "Should return nil for invalid ID") + + assert(slider:getIdForIndex(1) == "alpha", "Should map index 1 to alpha") + assert(slider:getIdForIndex(2) == "beta", "Should map index 2 to beta") + assert(slider:getIdForIndex(3) == "gamma", "Should map index 3 to gamma") + assert(slider:getIdForIndex(0) == nil, "Should return nil for index 0") + assert(slider:getIdForIndex(4) == nil, "Should return nil for out of bounds index") + + logger.trace("passed test testIndexIdMapping") +end + +local function testValueSelection() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + slider:setSelectedId("item3", false) + assert(slider.value == 3, "Should select item3") + assert(slider:getSelectedId() == "item3", "Should return item3") + + slider:setSelectedId("item1", false) + assert(slider.value == 1, "Should select item1") + assert(slider:getSelectedId() == "item1", "Should return item1") + + slider:setSelectedId("nonexistent", false) + assert(slider.value == 1, "Should not change value for invalid ID") + + logger.trace("passed test testValueSelection") +end + +local function testValueChangeCallback() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local callbackCount = 0 + local callbackSlider = nil + + local slider = DiscreteImageSlider({ + values = values, + onValueChange = function(s) + callbackCount = callbackCount + 1 + callbackSlider = s + end + }) + + slider:setSelectedId("item2", true) + assert(callbackCount == 1, "Callback should be called when committed=true") + assert(callbackSlider == slider, "Callback should receive slider instance") + + slider:setSelectedId("item3", false) + assert(callbackCount == 2, "Callback should be called even when committed=false (onlyChangeOnRelease=false)") + + logger.trace("passed test testValueChangeCallback") +end + +local function testValueChangeCallbackOnlyOnRelease() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local callbackCount = 0 + + local slider = DiscreteImageSlider({ + values = values, + onlyChangeOnRelease = true, + onValueChange = function(s) + callbackCount = callbackCount + 1 + end + }) + + slider:setSelectedId("item2", false) + assert(callbackCount == 0, "Callback should not be called when committed=false and onlyChangeOnRelease=true") + + slider:setSelectedId("item3", true) + assert(callbackCount == 1, "Callback should be called when committed=true") + + logger.trace("passed test testValueChangeCallbackOnlyOnRelease") +end + +local function testSetValues() + local values1 = { + createMockValue("a", 50, 50), + createMockValue("b", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values1, + selectedValue = "b" + }) + + assert(slider.value == 2, "Should start at value 2") + assert(slider.max == 2, "Max should be 2") + + local values2 = { + createMockValue("x", 50, 50), + createMockValue("y", 50, 50), + createMockValue("z", 50, 50), + createMockValue("w", 50, 50) + } + + slider:setValues(values2) + assert(#slider.values == 4, "Should have 4 values after setValues") + assert(slider.max == 4, "Max should be 4") + assert(slider.value == 2, "Value should be maintained if valid") + assert(slider:getSelectedId() == "y", "Should now reference new value at index 2") + + logger.trace("passed test testSetValues") +end + +local function testSetValuesWithClampedValue() + local values1 = { + createMockValue("a", 50, 50), + createMockValue("b", 50, 50), + createMockValue("c", 50, 50), + createMockValue("d", 50, 50), + createMockValue("e", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values1, + selectedValue = "e" + }) + + assert(slider.value == 5, "Should start at value 5") + + local values2 = { + createMockValue("x", 50, 50), + createMockValue("y", 50, 50) + } + + slider:setValues(values2) + assert(slider.value == 2, "Value should be clamped to max when reduced") + assert(slider:getSelectedId() == "y", "Should select last item") + + logger.trace("passed test testSetValuesWithClampedValue") +end + +local function testLayoutDimensions() + local values = { + createMockValue("item1", 50, 60), + createMockValue("item2", 40, 60), + createMockValue("item3", 30, 60) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 10 + }) + + -- Expected width: 50 + 10 + 40 + 10 + 30 = 140 + assert(slider.width == 140, "Width should sum all items and spacing") + assert(slider.height == 60, "Height should match item height") + + logger.trace("passed test testLayoutDimensions") +end + +local function testLayoutDimensionsNoSpacing() + local values = { + createMockValue("item1", 50, 60), + createMockValue("item2", 40, 60), + createMockValue("item3", 30, 60) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + -- Expected width: 50 + 40 + 30 = 120 + assert(slider.width == 120, "Width should sum all items without spacing") + + logger.trace("passed test testLayoutDimensionsNoSpacing") +end + +local function testStackPanelLayoutPositions() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 40, 50), + createMockValue("item3", 30, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 5 + }) + + local children = slider.stackPanel.children + local itemCount = 0 + local positions = {} + + for _, child in ipairs(children) do + if child.discreteIndex then + itemCount = itemCount + 1 + positions[child.discreteIndex] = child.x + end + end + + assert(itemCount == 3, "Should have 3 item children") + assert(positions[1] == 0, "Item 1 should be at x=0") + assert(positions[2] == 55, "Item 2 should be at x=55 (50 + 5)") + assert(positions[3] == 100, "Item 3 should be at x=100 (50 + 5 + 40 + 5)") + + logger.trace("passed test testStackPanelLayoutPositions") +end + +local function testGetValueForPos() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + slider.x = 100 + slider.y = 100 + + -- Click near center of first item (x=100, width=50, center=125) + local index1 = slider:getValueForPos(125) + assert(index1 == 1, "Should return index 1 for x=125") + + -- Click near center of second item (x=150, width=50, center=175) + local index2 = slider:getValueForPos(175) + assert(index2 == 2, "Should return index 2 for x=175") + + -- Click near center of third item (x=200, width=50, center=225) + local index3 = slider:getValueForPos(225) + assert(index3 == 3, "Should return index 3 for x=225") + + logger.trace("passed test testGetValueForPos") +end + +local function testSingleValue() + local values = { + createMockValue("only", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + assert(slider.value == 1, "Should have value 1") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 1, "Max should be 1") + assert(slider:getSelectedId() == "only", "Should select the only item") + + logger.trace("passed test testSingleValue") +end + +local function testMixedWidthsAndHeights() + local values = { + createMockValue("small", 20, 30), + createMockValue("medium", 50, 60), + createMockValue("large", 80, 90), + createMockValue("tiny", 10, 15) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + -- Width should be sum: 20 + 50 + 80 + 10 = 160 + assert(slider.width == 160, "Width should handle mixed widths") + -- Height should be max: 90 + assert(slider.height == 90, "Height should be tallest item") + + logger.trace("passed test testMixedWidthsAndHeights") +end + +local function testDuplicateIds() + local values = { + createMockValue("duplicate", 50, 50), + createMockValue("unique", 50, 50), + createMockValue("duplicate", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + -- With duplicate IDs, the last one wins in the map + local index = slider:getIndexForId("duplicate") + assert(index == 3, "Should map to last occurrence of duplicate ID") + + logger.trace("passed test testDuplicateIds") +end + +testBasicConstruction() +testEmptyConstruction() +testIndexIdMapping() +testValueSelection() +testValueChangeCallback() +testValueChangeCallbackOnlyOnRelease() +testSetValues() +testSetValuesWithClampedValue() +testLayoutDimensions() +testLayoutDimensionsNoSpacing() +testStackPanelLayoutPositions() +testGetValueForPos() +testSingleValue() +testMixedWidthsAndHeights() +testDuplicateIds() + +logger.trace("All DiscreteImageSlider tests passed!") diff --git a/client/tests/InputConfigurationTests.lua b/client/tests/InputConfigurationTests.lua new file mode 100644 index 00000000..0071073f --- /dev/null +++ b/client/tests/InputConfigurationTests.lua @@ -0,0 +1,714 @@ +local InputConfiguration = require("client.src.input.InputConfiguration") +local logger = require("common.lib.logger") + +local function createMockIsPressedWithRepeat(key) + return key == "test" +end + +local function createJoystickProvider(joysticks) + local storedJoysticks = joysticks or {} + local provider = {} + + function provider:getJoysticks() + return storedJoysticks + end + + return provider +end + +local function testBasicConstruction() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config ~= nil, "InputConfiguration should be created") + assert(config.index == 1, "Index should be set to 1") + assert(config.claimed == false, "Should start unclaimed") + assert(config.player == nil, "Should have no player") + assert(type(config.isDown) == "table", "isDown should be a table") + assert(type(config.isPressed) == "table", "isPressed should be a table") + assert(type(config.isUp) == "table", "isUp should be a table") + assert(type(config.isPressedWithRepeat) == "function", "isPressedWithRepeat should be a function") + + logger.trace("passed test testBasicConstruction") +end + +local function testConstructionWithDifferentIndex() + local config1 = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local config2 = InputConfiguration(5, createMockIsPressedWithRepeat, createJoystickProvider()) + local config3 = InputConfiguration(8, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config1.index == 1, "Config1 should have index 1") + assert(config2.index == 5, "Config2 should have index 5") + assert(config3.index == 8, "Config3 should have index 8") + + logger.trace("passed test testConstructionWithDifferentIndex") +end + +local function testIsPressedWithRepeatFunction() + local testFunction = function(key) + return key == "testkey" + end + + local config = InputConfiguration(1, testFunction, createJoystickProvider()) + + assert(config.isPressedWithRepeat("testkey") == true, "isPressedWithRepeat should return true for testkey") + assert(config.isPressedWithRepeat("otherkey") == false, "isPressedWithRepeat should return false for otherkey") + + logger.trace("passed test testIsPressedWithRepeatFunction") +end + +local function testKeyBindingStorage() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + + assert(config.Up == "w", "Up key should be stored") + assert(config.Down == "s", "Down key should be stored") + assert(config.Left == "a", "Left key should be stored") + assert(config.Right == "d", "Right key should be stored") + + logger.trace("passed test testKeyBindingStorage") +end + +local function testControllerBindingStorage() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "03000000de280000ff11000001000000:1:dpdown" + config.SwapL = "03000000de280000ff11000001000000:1:a" + + assert(config.Up == "03000000de280000ff11000001000000:1:dpup", "Controller binding should be stored") + assert(config.Down == "03000000de280000ff11000001000000:1:dpdown", "Controller binding should be stored") + assert(config.SwapL == "03000000de280000ff11000001000000:1:a", "Controller binding should be stored") + + logger.trace("passed test testControllerBindingStorage") +end + +local function testMixedInputBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "03000000de280000ff11000001000000:1:dpdown" + config.SwapL = "space" + + assert(config.Up == "w", "Keyboard binding should work") + assert(config.Down == "03000000de280000ff11000001000000:1:dpdown", "Controller binding should work") + assert(config.SwapL == "space", "Keyboard binding should work") + + logger.trace("passed test testMixedInputBindings") +end + +local function testClaimedProperty() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config.claimed == false, "Should start unclaimed") + + config.claimed = true + assert(config.claimed == true, "Should be claimable") + + config.claimed = false + assert(config.claimed == false, "Should be unclaimable") + + logger.trace("passed test testClaimedProperty") +end + +local function testPlayerProperty() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local mockPlayer = {playerNumber = 1} + + assert(config.player == nil, "Should start with no player") + + config.player = mockPlayer + assert(config.player == mockPlayer, "Should store player reference") + + config.player = nil + assert(config.player == nil, "Should allow clearing player") + + logger.trace("passed test testPlayerProperty") +end + +local function testIsDownTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isDown["w"] = true + config.isDown["s"] = false + + assert(config.isDown["w"] == true, "isDown should track key state") + assert(config.isDown["s"] == false, "isDown should track key state") + + logger.trace("passed test testIsDownTable") +end + +local function testIsPressedTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isPressed["w"] = 5 + config.isPressed["s"] = 10 + + assert(config.isPressed["w"] == 5, "isPressed should track press duration") + assert(config.isPressed["s"] == 10, "isPressed should track press duration") + + logger.trace("passed test testIsPressedTable") +end + +local function testIsUpTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isUp["w"] = true + config.isUp["s"] = false + + assert(config.isUp["w"] == true, "isUp should track release state") + assert(config.isUp["s"] == false, "isUp should track release state") + + logger.trace("passed test testIsUpTable") +end + +local function testEmptyConfiguration() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config.Up == nil, "Empty config should have no Up binding") + assert(config.Down == nil, "Empty config should have no Down binding") + assert(config.Left == nil, "Empty config should have no Left binding") + assert(config.Right == nil, "Empty config should have no Right binding") + assert(config.SwapL == nil, "Empty config should have no SwapL binding") + assert(config.SwapR == nil, "Empty config should have no SwapR binding") + + logger.trace("passed test testEmptyConfiguration") +end + +local function testMultipleConfigurationsAreIndependent() + local config1 = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local config2 = InputConfiguration(2, createMockIsPressedWithRepeat, createJoystickProvider()) + + config1.Up = "w" + config1.claimed = true + + config2.Up = "i" + config2.claimed = false + + assert(config1.Up == "w", "Config1 should have its own bindings") + assert(config2.Up == "i", "Config2 should have its own bindings") + assert(config1.claimed == true, "Config1 should have its own claimed state") + assert(config2.claimed == false, "Config2 should have its own claimed state") + + logger.trace("passed test testMultipleConfigurationsAreIndependent") +end + +local function testConfigurationIndex() + local configs = {} + for i = 1, 8 do + configs[i] = InputConfiguration(i, createMockIsPressedWithRepeat, createJoystickProvider()) + end + + for i = 1, 8 do + assert(configs[i].index == i, "Config " .. i .. " should have index " .. i) + end + + logger.trace("passed test testConfigurationIndex") +end + +local function testBindingOverwrite() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + assert(config.Up == "w", "Should set initial binding") + + config.Up = "i" + assert(config.Up == "i", "Should overwrite binding") + + config.Up = "03000000de280000ff11000001000000:1:dpup" + assert(config.Up == "03000000de280000ff11000001000000:1:dpup", "Should overwrite with controller binding") + + logger.trace("passed test testBindingOverwrite") +end + +local function testNilBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + assert(config.Up == "w", "Should set binding") + + config.Up = nil + assert(config.Up == nil, "Should allow clearing binding") + + logger.trace("passed test testNilBindings") +end + +local function testAllKeyNames() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + config.SwapL = "space" + config.SwapR = "lshift" + config.TauntUp = "1" + config.TauntDown = "2" + config.Raise = "r" + config.Pause = "escape" + + assert(config.Up == "w", "Up should be set") + assert(config.Down == "s", "Down should be set") + assert(config.Left == "a", "Left should be set") + assert(config.Right == "d", "Right should be set") + assert(config.SwapL == "space", "SwapL should be set") + assert(config.SwapR == "lshift", "SwapR should be set") + assert(config.TauntUp == "1", "TauntUp should be set") + assert(config.TauntDown == "2", "TauntDown should be set") + assert(config.Raise == "r", "Raise should be set") + assert(config.Pause == "escape", "Pause should be set") + + logger.trace("passed test testAllKeyNames") +end + +local function testIsEmptyWithNoBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:isEmpty() == true, "Empty config should return true for isEmpty()") + + logger.trace("passed test testIsEmptyWithNoBindings") +end + +local function testIsEmptyWithOneBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:isEmpty() == false, "Config with one binding should return false for isEmpty()") + + logger.trace("passed test testIsEmptyWithOneBinding") +end + +local function testIsEmptyWithAllBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + config.Swap1 = "space" + config.Swap2 = "lshift" + config.TauntUp = "1" + config.TauntDown = "2" + config.Raise1 = "r" + config.Raise2 = "t" + config.Start = "escape" + + assert(config:isEmpty() == false, "Config with all bindings should return false for isEmpty()") + + logger.trace("passed test testIsEmptyWithAllBindings") +end + +local function testIsEmptyAfterClearing() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + + assert(config:isEmpty() == false, "Config with bindings should return false") + + config.Up = nil + config.Down = nil + config.Left = nil + + assert(config:isEmpty() == true, "Config after clearing all bindings should return true for isEmpty()") + + logger.trace("passed test testIsEmptyAfterClearing") +end + +local function testGetDeviceTypeWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:getDeviceType() == "keyboard", "Keyboard binding should return keyboard device type") + + logger.trace("passed test testGetDeviceTypeWithKeyboardBinding") +end + +local function testGetDeviceTypeWithControllerBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "03000000de280000ff11000001000000:1:dpup" + + assert(config:getDeviceType() == "controller", "Controller binding should return controller device type") + + logger.trace("passed test testGetDeviceTypeWithControllerBinding") +end + +local function testGetDeviceTypeWithTouchBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "mouse1" + + assert(config:getDeviceType() == "touch", "Mouse binding should return touch device type") + + logger.trace("passed test testGetDeviceTypeWithTouchBinding") +end + +local function testGetDeviceTypeWithEmptyConfig() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:getDeviceType() == nil, "Empty configuration should return nil device type") + + logger.trace("passed test testGetDeviceTypeWithEmptyConfig") +end + +local function testParseControllerBindingWithValidBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "03000000de280000ff11000001000000:1:dpup" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == "03000000de280000ff11000001000000", "Should extract GUID correctly") + assert(slot == 1, "Should extract slot correctly") + + logger.trace("passed test testParseControllerBindingWithValidBinding") +end + +local function testParseControllerBindingWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for keyboard binding") + assert(slot == nil, "Should return nil slot for keyboard binding") + + logger.trace("passed test testParseControllerBindingWithKeyboardBinding") +end + +local function testParseControllerBindingWithNilBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for nil binding") + assert(slot == nil, "Should return nil slot for nil binding") + + logger.trace("passed test testParseControllerBindingWithNilBinding") +end + +local function testParseControllerBindingWithMalformedBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "malformed:binding" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for malformed binding") + assert(slot == nil, "Should return nil slot for malformed binding") + + logger.trace("passed test testParseControllerBindingWithMalformedBinding") +end + +local function testParseControllerBindingWithDifferentSlots() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "03000000de280000ff11000001000000:2:dpdown" + config.Left = "03000000de280000ff11000001000000:3:dpleft" + + local guid1, slot1 = config:parseControllerBinding("Up") + local guid2, slot2 = config:parseControllerBinding("Down") + local guid3, slot3 = config:parseControllerBinding("Left") + + assert(slot1 == 1, "Should parse slot 1 correctly") + assert(slot2 == 2, "Should parse slot 2 correctly") + assert(slot3 == 3, "Should parse slot 3 correctly") + + logger.trace("passed test testParseControllerBindingWithDifferentSlots") +end + +local function testParseControllerBindingWithDifferentGUIDs() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "030000005e040000120b000005050000:1:dpdown" + + local guid1, slot1 = config:parseControllerBinding("Up") + local guid2, slot2 = config:parseControllerBinding("Down") + + assert(guid1 == "03000000de280000ff11000001000000", "Should parse first GUID correctly") + assert(guid2 == "030000005e040000120b000005050000", "Should parse second GUID correctly") + + logger.trace("passed test testParseControllerBindingWithDifferentGUIDs") +end + +local function testGetDeviceNameWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:getDeviceName() == "Keyboard", "Keyboard binding should return 'Keyboard'") + + logger.trace("passed test testGetDeviceNameWithKeyboardBinding") +end + +local function testGetDeviceNameWithTouchBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "mouse1" + + assert(config:getDeviceName() == "Touch", "Mouse binding should return 'Touch'") + + logger.trace("passed test testGetDeviceNameWithTouchBinding") +end + +local function testGetDeviceNameWithConnectedController() + local guid = "03000000de280000ff11000001000000" + local joystick = { + getGUID = function() + return guid + end, + getName = function() + return "Test Controller" + end, + getID = function() + return 1 + end + } + + local provider = createJoystickProvider({joystick}) + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Test Controller", "Connected controller should use joystick name") + + logger.trace("passed test testGetDeviceNameWithConnectedController") +end + +local function testGetDeviceNameWithDisconnectedController() + local guid = "00000000deadbeef0000000000000000" + + local provider = createJoystickProvider() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Controller", "Disconnected controller should fall back to generic name") + + logger.trace("passed test testGetDeviceNameWithDisconnectedController") +end + +local function testGetDeviceNameWithUnknownController() + local guid = "unknown-guid-0000" + + local provider = createJoystickProvider() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Controller", "Unknown controller should return 'Controller'") + + logger.trace("passed test testGetDeviceNameWithUnknownController") +end + +local function testGetDeviceNameWithEmptyConfig() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:getDeviceName() == nil, "Empty configuration should return nil device name") + + logger.trace("passed test testGetDeviceNameWithEmptyConfig") +end + +testBasicConstruction() +testConstructionWithDifferentIndex() +testIsPressedWithRepeatFunction() +testKeyBindingStorage() +testControllerBindingStorage() +testMixedInputBindings() +testClaimedProperty() +testPlayerProperty() +testIsDownTable() +testIsPressedTable() +testIsUpTable() +testEmptyConfiguration() +testMultipleConfigurationsAreIndependent() +testConfigurationIndex() +testBindingOverwrite() +testNilBindings() +testAllKeyNames() +testIsEmptyWithNoBindings() +testIsEmptyWithOneBinding() +testIsEmptyWithAllBindings() +testIsEmptyAfterClearing() +testGetDeviceTypeWithKeyboardBinding() +testGetDeviceTypeWithControllerBinding() +testGetDeviceTypeWithTouchBinding() +testGetDeviceTypeWithEmptyConfig() +testParseControllerBindingWithValidBinding() +testParseControllerBindingWithKeyboardBinding() +testParseControllerBindingWithNilBinding() +testParseControllerBindingWithMalformedBinding() +testParseControllerBindingWithDifferentSlots() +testParseControllerBindingWithDifferentGUIDs() +testGetDeviceNameWithKeyboardBinding() +testGetDeviceNameWithTouchBinding() +testGetDeviceNameWithConnectedController() +testGetDeviceNameWithDisconnectedController() +testGetDeviceNameWithUnknownController() +testGetDeviceNameWithEmptyConfig() + +-- Controller Image Variant Tests (using static helper) +local function testPlayStation5Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS5 Controller") == "playstation5") + assert(InputConfiguration.getControllerImageVariantFromName("DualSense Wireless Controller") == "playstation5") + assert(InputConfiguration.getControllerImageVariantFromName("Sony DualSense") == "playstation5") + logger.trace("passed test testPlayStation5Controllers") +end + +local function testPlayStation4Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS4 Controller") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("DUALSHOCK 4 Wireless Controller") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("Sony DualShock 4") == "playstation4") + logger.trace("passed test testPlayStation4Controllers") +end + +local function testPlayStation3Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS3 Controller") == "playstation3") + assert(InputConfiguration.getControllerImageVariantFromName("Sony PLAYSTATION(R)3 Controller") == "playstation3") + logger.trace("passed test testPlayStation3Controllers") +end + +local function testPlayStation2Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS2 Controller") == "playstation2") + logger.trace("passed test testPlayStation2Controllers") +end + +local function testPlayStation1Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS1 Controller") == "playstation1") + assert(InputConfiguration.getControllerImageVariantFromName("PlayStation 1 Controller") == "playstation1") + logger.trace("passed test testPlayStation1Controllers") +end + +local function testXboxSeriesControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Series X Controller") == "xboxseries") + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Series S Controller") == "xboxseries") + logger.trace("passed test testXboxSeriesControllers") +end + +local function testXboxOneControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox One Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Microsoft Xbox One Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Wireless Controller") == "xboxone") + logger.trace("passed test testXboxOneControllers") +end + +local function testXbox360Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox 360 Controller") == "xbox360") + assert(InputConfiguration.getControllerImageVariantFromName("Microsoft Xbox 360 Controller") == "xbox360") + logger.trace("passed test testXbox360Controllers") +end + +local function testSwitchProControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Pro Controller") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("Nintendo Switch Pro Controller") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("Switch Pro Controller") == "switch_pro") + logger.trace("passed test testSwitchProControllers") +end + +local function testSNESControllers() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SN30") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SN30 Pro") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SF30") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SF30 Pro") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("SNES Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Super Nintendo Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Super Famicom Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Scout") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Scout Premium SNES Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("iBuffalo BSGP1204 Series") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("2-axis 8-button gamepad") == "snes") + logger.trace("passed test testSNESControllers") +end + +local function testN64Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo 64") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo N64") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo 64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("N64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Nintendo 64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Admiral N64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Admiral Controller") == "n64") + logger.trace("passed test testN64Controllers") +end + +local function testGameCubeControllers() + assert(InputConfiguration.getControllerImageVariantFromName("GameCube Controller") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Game Cube Controller") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo GameCube") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo GBros") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("GBros Adapter") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Battle Pad") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Horipad") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("HORIPAD") == "gamecube") + logger.trace("passed test testGameCubeControllers") +end + +local function test8BitDoProSeries() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro 2") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro2") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro 3") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro3") == "playstation4") + logger.trace("passed test test8BitDoProSeries") +end + +local function test8BitDoUltimateSeries() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 2") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 2C") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 3-mode Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate Wired Controller") == "xboxone") + logger.trace("passed test test8BitDoUltimateSeries") +end + +local function testGameSirTarantula() + assert(InputConfiguration.getControllerImageVariantFromName("GameSir Tarantula") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir Tarantula Pro") == "playstation4") + logger.trace("passed test testGameSirTarantula") +end + +local function testHoriControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Horipad Pro for Xbox") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("HORI Xbox Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Horipad for Nintendo Switch") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("HORI Nintendo Switch Controller") == "switch_pro") + logger.trace("passed test testHoriControllers") +end + +local function testGenericControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Unknown Controller") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("Random Gamepad") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Lite") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Lite 2") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Zero") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Zero 2") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Micro") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo F40") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Arcade Stick") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo M30") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir T4") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir G7") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Fighting Edge") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName(nil) == "generic") + logger.trace("passed test testGenericControllers") +end + +testPlayStation5Controllers() +testPlayStation4Controllers() +testPlayStation3Controllers() +testPlayStation2Controllers() +testPlayStation1Controllers() +testXboxSeriesControllers() +testXboxOneControllers() +testXbox360Controllers() +testSwitchProControllers() +testSNESControllers() +testN64Controllers() +testGameCubeControllers() +test8BitDoProSeries() +test8BitDoUltimateSeries() +testGameSirTarantula() +testHoriControllers() +testGenericControllers() + +logger.trace("All InputConfiguration tests passed!") diff --git a/common/lib/class.lua b/common/lib/class.lua index c85c3f78..71eb8223 100644 --- a/common/lib/class.lua +++ b/common/lib/class.lua @@ -10,8 +10,9 @@ local classMetaTable = {__call = newTable} ---@param init function function called on new objects of the class after the metatables have been applied ---@param parent any? parent class that has its own constructor called before init +---@param typeName string? optional type name for the class, sets classTable.TYPE if provided ---@return table classTable table acting as metatable for the class and acting as the constructor; uses the parent as its metatable -local class = function(init, parent) +local class = function(init, parent, typeName) local classTable = {} -- class table acts as the metatable for new tables -- all function calls on the table should find the functions on the class table, so set __index @@ -21,6 +22,11 @@ local class = function(init, parent) classTable.__call = newTable -- make parent functions accessible, even if they may be shadowed classTable.super = parent + + -- Set TYPE if provided + if typeName then + classTable.TYPE = typeName + end classTable.initializeObject = function(new, super, ...) if new.super then if not super then diff --git a/common/lib/joystickManager.lua b/common/lib/joystickManager.lua index c62a0d23..39b395fc 100644 --- a/common/lib/joystickManager.lua +++ b/common/lib/joystickManager.lua @@ -40,6 +40,7 @@ local joystickHatToDirs = { rd = {"right", "down"} } +---@param joystick love.Joystick function joystickManager:getJoystickButtonName(joystick, button) return string.format("%s:%s:%s", joystick:getGUID(), joystickManager.guidsToJoysticks[joystick:getGUID()][joystick:getID()], button) end @@ -90,6 +91,7 @@ end -- end -- maps dpad dir to buttons +---@param joystick love.Joystick function joystickManager:getDPadState(joystick, hatIndex) local dir = joystick:getHat(hatIndex) local activeButtons = joystickHatToDirs[dir] @@ -101,9 +103,14 @@ function joystickManager:getDPadState(joystick, hatIndex) } end --- Intentional override ----@diagnostic disable-next-line: duplicate-set-field -function love.joystickadded(joystick) +---@param joystick love.Joystick +function joystickManager:isRegistered(joystick) + -- converting the joystick into a bool + return not not joystickManager.devices[joystick:getID()] +end + +---@param joystick love.Joystick +function joystickManager:registerJoystick(joystick) -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID -- the GUID is consistent across sessions local guid = joystick:getGUID() @@ -167,16 +174,15 @@ 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 +---@param joystick love.Joystick +function joystickManager:unregisterJoystick(joystick) +-- GUID identifies the device type, 2 controllers of the same type will have a matching GUID -- the GUID is consistent across sessions 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( ) + local vendorID, productID, productVersion = joystick:getDeviceInfo() logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) diff --git a/docs/InputDeviceSelection.md b/docs/InputDeviceSelection.md new file mode 100644 index 00000000..96da5672 --- /dev/null +++ b/docs/InputDeviceSelection.md @@ -0,0 +1,26 @@ +# Input Device Selection + +## Requirements +- Ensure character select automatically triggers the input device overlay whenever local human slots lack device assignments. +- The overlay does not dismiss until all local players have an input configuration assigned. +- You can't start the game until the overlay is dismissed. +- The overlay should supports all created input configurations plus touch. +- The overlay shows a box for each local player, online players box is not shown. +- Touch is assigned by tapping on the player box you want to use touch with. +- Each player box shows the player number +- When you touch or use a controller the device used shows in the box and becomes active. +- The overlay dismisses once every assignment is made. +- A "Change Input Device" button is on character select to allow you to reselect, triggering the overlay again with all local assignments reset. +- The change input device button shows all assignments in a compact form. +- Any input config should be able to navigate the menus outside of character select. +- When an input configuration is used that isn't assigned, release configs and bring up the overlay again +- When more than one input configuration of a device type are assigned, they should be numbered by order they are in the input configuration, so second keyboard configuration says "2" +- An attempt should be made to show an image close to the input method used. Touch, keyboard, controller shape + +## Testing Plan +- Manual scenarios: + - Keyboard only, controller only, mixed devices, touch selection via mouse. + - Multiple controllers to confirm naming and unique assignments. + - Works in endless, time attack, training, challenge mode, online, 2p vs +- Online/local modes to verify assignments persist and don’t conflict with server expectations. +- Run `love ./testLauncher.lua` post-implementation. diff --git a/main.lua b/main.lua index bcd2dcb9..45a34938 100644 --- a/main.lua +++ b/main.lua @@ -187,6 +187,18 @@ function love.joystickreleased(joystick, button) inputManager:joystickReleased(joystick, button) end +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function love.joystickadded(joystick) + GAME:onJoystickAdded(joystick) +end + +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function love.joystickremoved(joystick) + GAME:onJoystickRemoved(joystick) +end + -- Handle a touch press -- Note we are specifically not implementing this because mousepressed above handles mouse and touch -- function love.touchpressed(id, x, y, dx, dy, pressure) diff --git a/testLauncher.lua b/testLauncher.lua index b38496d9..4882facf 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -94,6 +94,8 @@ local allTests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "client.tests.StackGraphicsTests", + "client.tests.InputConfigurationTests", + "client.tests.DiscreteImageSliderTests", "client.tests.PlayerSettingsTests", }