From eeddb6f185cad5f01e93d6948a14b6bac72f4ebf Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 21 Jun 2025 01:34:46 +0200 Subject: [PATCH 01/16] remove engine-preview from release definitions --- client/src/Game.lua | 9 --------- 1 file changed, 9 deletions(-) diff --git a/client/src/Game.lua b/client/src/Game.lua index 0a122e990..04698b81b 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -193,15 +193,6 @@ function Game:writeReleaseStreamDefinition() url = "https://panelattack.com/downloads/updates/beta", prefix = "panel-beta-" } - }, - { - name = "engine-preview", - versioningType = "timestamp", - serverEndPoint = { - type = "filesystem", - url = "https://panelattack.com/downloads/updates/engine-preview", - prefix = "panel-" - } } }, default = "stable" From 5a35a95cd9ccf21309ee5ead157c59201866910d Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 20 Jun 2025 23:26:19 +0200 Subject: [PATCH 02/16] ignore puzzles that have a version number that cannot be loaded and write a warning instead of crashing --- client/src/PuzzleSet.lua | 42 +++++++++++++++++++++++++++++++--------- common/engine/Puzzle.lua | 15 +++++--------- 2 files changed, 38 insertions(+), 19 deletions(-) diff --git a/client/src/PuzzleSet.lua b/client/src/PuzzleSet.lua index e3ddd54b1..953616af7 100644 --- a/client/src/PuzzleSet.lua +++ b/client/src/PuzzleSet.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local FileUtils = require("client.src.FileUtils") local Puzzle = require("common.engine.Puzzle") +local logger = require("common.lib.logger") -- A puzzle set is a set of puzzles, typically they have a common difficulty or theme. ---@class PuzzleSet @@ -26,11 +27,28 @@ function PuzzleSet.loadFromFile(filePath) for _, puzzleSetData in pairs(data["Puzzle Sets"]) do puzzleSets[#puzzleSets+1] = PuzzleSet.loadV2(puzzleSetData) end - elseif data["Version"] ~= 2 and data["Version"] then - error("Puzzle " .. filePath .. " specifies invalid version " .. data["Version"]) - else -- old file format compatibility - for setName, puzzleSet in pairs(data) do - puzzleSets[#puzzleSets+1] = PuzzleSet.loadV1(setName, puzzleSet) + elseif data["Version"] and type(data["Version"]) == "number" then + logger.warn("Puzzle " .. filePath .. " specifies invalid version " .. data["Version"]) + else + -- old file format compatibility + -- the old file format actually has NO markers to identify it as a puzzle which means that we just have to try and import + local successCounter = 0 + local result, error = pcall(function() + for setName, puzzleSet in pairs(data) do + if type(setName) == "string" and type(puzzleSet) == "table" then + local v1Set = PuzzleSet.loadV1(setName, puzzleSet) + if v1Set then + puzzleSets[#puzzleSets+1] = v1Set + successCounter = successCounter + 1 + end + end + end + end) + + if successCounter == 0 then + logger.warn("Failed to import invalid file " .. filePath .. " as a puzzle") + elseif result == false then + logger.warn("Encountered an error when trying to import puzzle file:\n" .. error) end end end @@ -42,15 +60,21 @@ function PuzzleSet.loadFromFile(filePath) return puzzleSets end ----@return PuzzleSet +---@return PuzzleSet? function PuzzleSet.loadV1(setName, puzzleSetData) local puzzles = {} for _, puzzleData in pairs(puzzleSetData) do - local puzzle = Puzzle("moves", true, puzzleData[2], puzzleData[1]) - puzzles[#puzzles + 1] = puzzle + if type(puzzleData) == "table" and #puzzleData >= 2 and type(puzzleData[1]) == "string" and type(puzzleData[2]) == "number" then + local puzzle = Puzzle("moves", true, puzzleData[2], puzzleData[1]) + if puzzle:validate() then + puzzles[#puzzles + 1] = puzzle + end + end end - return PuzzleSet(setName, puzzles) + if #puzzles > 0 then + return PuzzleSet(setName, puzzles) + end end ---@return PuzzleSet diff --git a/common/engine/Puzzle.lua b/common/engine/Puzzle.lua index 42c9a18a1..e15fbe157 100644 --- a/common/engine/Puzzle.lua +++ b/common/engine/Puzzle.lua @@ -26,13 +26,8 @@ Puzzle = class( end ) -function Puzzle.getPuzzleTypes() - return { "moves", "chain", "clear" } -end - -function Puzzle.getLegalCharacters() - return { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "[", "]", "{", "}", "=" } -end +Puzzle.PUZZLE_TYPES = { "moves", "chain", "clear" } +Puzzle.LEGAL_CHARACTERS = { "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "[", "]", "{", "}", "=" } ---@param width integer ---@param height integer @@ -129,7 +124,7 @@ function Puzzle:validate() local pendingGarbageStartIndex = 0 for i = 1, #self.stack do local char = string.sub(self.stack, i, i) - if not tableUtils.contains(Puzzle.getLegalCharacters(), char) + if not tableUtils.contains(Puzzle.LEGAL_CHARACTERS, char) and not tableUtils.contains(illegalCharacters, char) then table.insert(illegalCharacters, char) end @@ -168,9 +163,9 @@ function Puzzle:validate() errMessage = errMessage .. "\nPuzzlestring contains invalid characters: " .. table.concat(illegalCharacters, ", ") end - if not tableUtils.contains(Puzzle.getPuzzleTypes(), self.puzzleType) then + if not tableUtils.contains(Puzzle.PUZZLE_TYPES, self.puzzleType) then errMessage = errMessage .. - "\nInvalid puzzle type detected, available puzzle types are: " .. table.concat(Puzzle.getPuzzleTypes(), ", ") + "\nInvalid puzzle type detected, available puzzle types are: " .. table.concat(Puzzle.PUZZLE_TYPES, ", ") end if string.lower(self.puzzleType) == "moves" and (not tonumber(self.moves) or tonumber(self.moves) < 1 ) then From 5f7951ec226108f6be0c019f00e2f4db71da507e Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 21 Jun 2025 22:18:45 +0200 Subject: [PATCH 03/16] fix time attack games incorrectly being flagged as aborted in a post-game check that had become obsolete --- client/src/BattleRoom.lua | 1 + common/engine/Match.lua | 51 --------------------------------------- 2 files changed, 1 insertion(+), 51 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 207b825ac..39ce1999f 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -545,6 +545,7 @@ end -- a callback function that is getting registered to the ClientMatch's matchEnded signal -- may get unregistered from the match in case of abortion +---@param match ClientMatch function BattleRoom:onMatchEnded(match) self.matchesPlayed = self.matchesPlayed + 1 diff --git a/common/engine/Match.lua b/common/engine/Match.lua index ca35c0daa..55e970a86 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -551,8 +551,6 @@ function Match:hasEnded() end function Match:handleMatchEnd() - self:checkAborted() - if self.aborted then self.winners = {} else @@ -578,55 +576,6 @@ local checkGameEnded = function(stack) return stack:game_ended() end -local TOTAL_COUNTDOWN_LENGTH = consts.COUNTDOWN_LENGTH + consts.COUNTDOWN_START - ----@return boolean hasAborted -function Match:checkAborted() - -- the aborted flag may get set if the game is aborted through outside causes (usually network) - -- this function checks if the match got aborted through inside causes (local player abort or local desync) - if not self.aborted then - if self:isIrrecoverablyDesynced() then - -- someone got a desync error, this definitely died - self.aborted = true - self.winners = {} - elseif self.rules.matchEndConditions[MatchRules.MatchEndConditions.STACKS_ACTIVE] then - local alive = 0 - for i = 1, #self.stacks do - if not self.stacks[i]:game_ended() then - alive = alive + 1 - end - -- if there is more than n alive with a stacksActive condition, this must have been aborted - if alive > self.rules.matchEndConditions[MatchRules.MatchEndConditions.STACKS_ACTIVE] then - self.aborted = true - self.winners = {} - break - end - end - elseif self.rules.matchEndConditions[MatchRules.MatchEndConditions.TIME_LIMIT] then - local timeLimit = self.timeLimit - if self.doCountdown then - timeLimit = timeLimit + TOTAL_COUNTDOWN_LENGTH - end - for i, stack in ipairs(self.stacks) do - if not stack:game_ended() and stack.clock < timeLimit then - self.aborted = true - self.winners = {} - break - end - end - else - -- if this is not last alive and no desync that means we expect EVERY stack to be game over - if not tableUtils.trueForAll(self.stacks, checkGameEnded) then - -- someone didn't lose so this got aborted (e.g. through a pause -> leave) - self.aborted = true - self.winners = {} - end - end - end - - return self.aborted -end - -- returns true if the stack should run once more during the current match:run -- returns false otherwise ---@param stack BaseStack From f72d146b4bbcc986c831ef6389380c48c172988e Mon Sep 17 00:00:00 2001 From: Endaris Date: Fri, 20 Jun 2025 22:55:07 +0200 Subject: [PATCH 04/16] add a dedicated function to NetClient to handle disconnects to ensure cleanup is always reliable and the same and that lobby data is always cleared --- client/src/BattleRoom.lua | 2 +- client/src/network/NetClient.lua | 22 +++++++++++++++------- client/src/scenes/Lobby.lua | 6 +++--- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 39ce1999f..c34226b7c 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -44,7 +44,7 @@ function(self, mode, gameScene) -- this is a bit naive but effective for now self.online = GAME.netClient:isConnected() if self.online then - GAME.netClient:connectSignal("disconnect", self, self.onDisconnect) + GAME.netClient:connectSignal("clientDisconnected", self, self.onDisconnect) end Signal.turnIntoEmitter(self) diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index d974a41d7..8597ffe78 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -393,7 +393,7 @@ local NetClient = class(function(self) self:createSignal("lobbyStateUpdate") self:createSignal("leaderboardUpdate") -- only fires for unintended disconnects - self:createSignal("disconnect") + self:createSignal("clientDisconnected") self:createSignal("loginFinished") end) @@ -528,13 +528,25 @@ end function NetClient:logout() self.tcpClient:sendRequest(ClientMessages.logout()) - love.timer.sleep(0.005) + -- we want to give the message a chance to actually be sent to the network before we free the socket + -- otherwise the socket might get cleared before that and the server will only disconnect the player after a delay (which means they still get shown in lobby for ~10s) + -- it would be more reliable to only actually reset the socket after a server confirmation so there is no delay (however small) + -- but then we'd have the same problem on the server (how does the server know the client received logout?) so it's actually not nearly as simple as this + love.timer.sleep(0.05) + self:disconnect(true) +end + +---@param voluntary boolean if the disconnect happened through player intent or not +function NetClient:disconnect(voluntary) + self.room = nil self.tcpClient:resetNetwork() self:setState(states.OFFLINE) + resetLobbyData(self) GAME.localPlayer:disconnectSubscriber(GAME.netClient) -- this is because the online updates are currently subscribed to the player itself -- that should probably get changed because while mildly convenient it is unexpected for the interaction GAME.localPlayer:disconnectSubscriber(GAME.localPlayer) + self:emitSignal("clientDisconnected", voluntary) end function NetClient:update() @@ -560,11 +572,7 @@ function NetClient:update() end if not self.tcpClient:processIncomingMessages() then - self:setState(states.OFFLINE) - self.room = nil - self.tcpClient:resetNetwork() - resetLobbyData(self) - self:emitSignal("disconnect") + self:disconnect(false) return end diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 484554574..54ac2abd6 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -49,7 +49,7 @@ function Lobby:load(sceneParams) end GAME.netClient:connectSignal("lobbyStateUpdate", self, self.onLobbyStateUpdate) - GAME.netClient:connectSignal("disconnect", self, self.onDisconnect) + GAME.netClient:connectSignal("clientDisconnected", self, self.onDisconnect) GAME.netClient:connectSignal("leaderboardUpdate", self.leaderboard, self.leaderboard.updateData) GAME.netClient:connectSignal("loginFinished", self, self.onLoginFinish) @@ -224,8 +224,8 @@ function Lobby:draw() end end -function Lobby:onDisconnect() - if not GAME.navigationStack.transition then +function Lobby:onDisconnect(voluntary) + if not GAME.navigationStack.transition and not voluntary then -- automatic reconnect if we're not about to switch scene GAME.netClient:login(GAME.connected_server_ip, GAME.connected_server_port) end From 3ca86ec24c7cc54a30b69b9551dacb01c9356eb1 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 21 Jun 2025 00:03:42 +0200 Subject: [PATCH 05/16] move inputcompression tests to its own file --- common/tests/data/InputCompressionTests.lua | 24 ++++++++++++++++++++ common/tests/engine/ReplayTests.lua | 25 +-------------------- testLauncher.lua | 1 + 3 files changed, 26 insertions(+), 24 deletions(-) create mode 100644 common/tests/data/InputCompressionTests.lua diff --git a/common/tests/data/InputCompressionTests.lua b/common/tests/data/InputCompressionTests.lua new file mode 100644 index 000000000..2a0c45885 --- /dev/null +++ b/common/tests/data/InputCompressionTests.lua @@ -0,0 +1,24 @@ +local InputCompression = require("common.data.InputCompression") + +assert(InputCompression.compressInputString("") == "") + +local replayUncompressed1 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEEEEEEEEEEEEEEEEEEAAAAAAAAAAAAAAAAAAAAAAAAQAAAAABBBBBBBAAQAAAAAAAAIIIIAAAAggggggggggggggggggggggggkkkgiiiiiiiiiiiiiiiiiiigwgghBBBBBBJJJIYAAAAAAAAAAAABBBBBBBBBBBBBBBAAAAAAAAAAACCCCCAAQAAAAAAAAAAIIIIQAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAEEEAAAAAAAEEEAAAAAAEEEAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAAAACCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEEEEFBBBBAQAAACCCCCKIIIAAQAAAAAAEEEEAAAAAEEFBBBBBBBBBBBBBBQAAACCCCCCKKIIIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAABBBBBBBBBBRAAAAACCCCCCCCCCCCCCCCKKKIAAAAAAIIIAAAAQAAAAAIIIAAAAAAIIAAAAAAIIIAAAAAQAAAAEEEEEEAABBBBBRAAAAAEEEEEEAAQAAAAAACCCCCCCAAAAAAAIIIAAQAAAAEEEgggggggkkgggggAAEEAAAAAAAEEAAAAAAAAABBBBAAAAAAABBAAAAAABBBBRBAAAEEEEEGCCCAAAAQAAACCCCSCCCCCCCCCKKIIAAAAAAIIIAAAAAAIIAAAAAIIIIAAAQAAAAAEEEAAAAAAAAEEEEEBBBBBBAAAAAAABBBBAAAAAAAQAAAAAAAAACCCCCCCCCCCCCGGGEEEEAQAAAAABBAAAAAAAAABBBBBBJJJJIIAAAAAAAAAQAAAAEEEEEAQAAAAAAAAAAAAAAACCCCAAQAABBBBBBAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCAAAAAAAAAAAAAAAAAAAAAAAAAEEEEAAAAAAAAEEEEEAAAAAAAQAAAABBBAAAQAAABBBAAAAAAAAAAAAAAQAAABBBBAAAAAAAAACCCCCCAAAAAAAACCCKIIAAAAAAIIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAAAQAAAAAEAAAAAAAACCCCCCCKKIIIAAQAABBBBBBBBBBBBBBBFEEEEEAAQAAAACCCCAQAAAACCCCCSCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCCCCCCCSCAAABBBBBBBBBBBBBBBBBAAACCCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAAAAQAAAAAABBBBBBAAQAAAACCCCSAAAAAAAACCCCCAAAAAAACCCCAAQAAAAAACCCCCCCCCAAAAQABBBBAAAAQAABBAAAAQAAABBBBRBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBAAAAAAAAAAAAAQAAAACCCCAAAAAAACCCCAAAAAAAACCCAAAAAAQAAAABBBAQAAAAAABAAAAAAAAAAAAAAACCCCCCCCCCCCCAAAAABBBBBBAQAAAAAAAAAAAAABBBBBAAAAAAAAAAAAAAAAAAAAEEEEAAAAAAAEEEEEAABBBBBAQAAACCCCCSCAAAAEEEEAAAAAEEEAAAAQAAAABBBBJJJJIIQAAAAAAAACCCCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQACCCCCCCCCCCKKKKIIAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBAAAAEEEEEAAAAAAAAAAAAAAAAAAAAAAAAQCCCCCCCCCCCCCCCKKIJJBBBBAAAAAIIIIAAAAAQAAAAAEEEEAAAAAAAAAAAAIIIIAAAAAAAIIAAAAAAAAAIIIAAAQAAACCCCCAAAAQABBBBAAAQAACCCCCCCCCCCCAAAAQABBBAAAAQAABBBAQAAAAAEEEAAAAAAAEEAAAAAAAAAAAAAAAAEEEAAAAABBBBBBBBBBBRBBBBCCCCCCCAAAAAAAEEEEEEEGCCCCCCCCCCCCCCCAAAQAABBBAAAAQAABBBBRAAAACCCCCCCCCCCCCCAAAABBBBBAAAAAAAQAAACCCCSAABBBBBJJIIYICCCCCCSCAAAAAIJBBBBBBJJIIAAAAAIIAAAAAAAAAAAAEEEEEAAAAAAQAAAAAAAAAAAAAAAAAAAAAEEEEAAAAAQAAAAAAEEEAAAAAABBBBBRAAAEEEEEGCCCCCQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAQAAAAAAAAAAAAAQAABBBBBRBBBFEEEEEAAAAAAAAAAAAAAAAAAACCCCCAAAQAAABBBBAAQAAAAAABBBBBRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCKKIIIAAAAAAAAAAAAAAAAAAAAIIIIIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCKKIIAAAAAAIIIAAAAAAIIAAAAAIIIAAQAAAAAEEAAAAAAEAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIAAAAAIIAAQAAAAAEEEAAAAAAEEEEFBBBBBBQAAACCCCSCCAAAAAAAAABBBAAAAQAAABBBBRBACCCCCCCCCCCCCCCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAQABBBBAAAAQAABBAAAAAQABBAAAAQABBBBBBRBBBFFFFFFEEGGCCCAAAAAAACCCCAAAAQAAAAAEEEEAAQAAAAAAAAAEEEEAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBJAAAAAAAIIIIAAAAAQAAAAAEEEAAAAAAAAAAAAAQAAAAAAAEEEEEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAEEEEEGGCCCAAAAACCCCAAAAQABBBBBRAAACCCCAAAAAACCCAAAQAAABBBBRBBBBBJIIIAQAAAAAAAAABBBBBBBBBBBBBRAACCCCCCCCCCCCCCCAAABBBBBBAAAAAAAAAAAAAABBAAAAAAAAEEEEEAAAAAAEEAAAQAAAAAAEEEEEFBBBBBBBBBBBBRAAACCCCAAQAAACCCAQAAAAAAACCCCCCCKKKIIAAAAAAAAAAAAAAAAQAAAAAAAAAAQAAAAAAAAQAAAAAAAAQAAAAAQAABBBBBBBBBBBBBBBBBJJJJIAQAAAAACCSCCCCCCCCCCCCKKKIIIAAAAAAAIIIAAAAAAAIIIAAAAAAIIAAAAAAAAAAABBBBBBQAAAAAAAABBBRBBBBAAACCCCAAQAAAAAACCCCCCCKKIIAAAAAIIIAAQAAAAAEEAAAAAAEEAAAAAAQAAABBBBBJJJIIAAAQAAAABBBRBBBEEEEEEAAAAQAAAAAAACCCCCQAAAABBBRBAAAAAEEEEEEAAAAAAAAABBBBBBBBBBBBBRBBBAACCCCAAAAAACCCAAAAAAAQABBBAAAAAQABBBAAQAABBBBBBBDDCCCCCCCCCCCCCAEEEEEEAABBBBBBAAAAAAABBBBBRAAAAAAEEEEEEEEGAAAAAAAAAAAAAAAAAEEEEEAAAACCCCCCAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBJJIIIIKKCCCCAAQAAAACCCCCCCCCCCCCCCKKIIIAAAAAAAAIIIIIAAAAAAAAAAAAAQAAAEEEEAAAAAAAAAAABBBBBAAAAAAAAAAAAAAIIIIIAQAAAAAAAAAAAAAQAAAAAAAA" +local replayCompressed1 = "A152E19A24Q1A5B7A2Q1A8I4A4g24k3g1i19g1w1g2h1B6J3I1Y1A12B15A11C5A2Q1A10I4Q1A25I5A11E3A7E3A6E3A18I5A15C3A34E5F1B4A1Q1A3C5K1I3A2Q1A6E4A5E2F1B14Q1A3C6K2I4A40Q1A4B10R1A5C16K3I1A6I3A4Q1A5I3A6I2A6I3A5Q1A4E6A2B5R1A5E6A2Q1A6C7A7I3A2Q1A4E3g7k2g5A2E2A7E2A9B4A7B2A6B4R1B1A3E5G1C3A4Q1A3C4S1C9K2I2A6I3A6I2A5I4A3Q1A5E3A8E5B6A7B4A7Q1A9C13G3E4A1Q1A5B2A9B6J4I2A9Q1A4E5A1Q1A15C4A2Q1A2B6A27C6A25E4A8E5A7Q1A4B3A3Q1A3B3A14Q1A3B4A9C6A8C3K1I2A6I3A37I5A14Q1A5E1A8C7K2I3A2Q1A2B15F1E5A2Q1A4C4A1Q1A4C5S1C1A41C12S1C1A3B17A3C4A34I5A15Q1A6B6A2Q1A4C4S1A8C5A7C4A2Q1A6C9A4Q1A1B4A4Q1A2B2A4Q1A3B4R1B1A108B10A13Q1A4C4A7C4A8C3A6Q1A4B3A1Q1A6B1A15C13A5B6A1Q1A13B5A20E4A7E5A2B5A1Q1A3C5S1C1A4E4A5E3A4Q1A4B4J4I2Q1A8C5A56Q1A1C11K4I2A16B15A4E5A24Q1C15K2I1J2B4A5I4A5Q1A5E4A12I4A7I2A9I3A3Q1A3C5A4Q1A1B4A3Q1A2C12A4Q1A1B3A4Q1A2B3A1Q1A5E3A7E2A16E3A5B11R1B4C7A7E7G1C15A3Q1A2B3A4Q1A2B4R1A4C14A4B5A7Q1A3C4S1A2B5J2I2Y1I1C6S1C1A5I1J1B6J2I2A5I2A12E5A6Q1A21E4A5Q1A6E3A6B5R1A3E5G1C5Q1A55I5A12Q1A13Q1A2B5R1B3F1E5A19C5A3Q1A3B4A2Q1A6B5R1A78C6K2I3A20I6A51C5K2I2A6I3A6I2A5I3A2Q1A5E2A6E1A4Q1A65I4A5I2A2Q1A5E3A6E4F1B6Q1A3C4S1C2A9B3A4Q1A3B4R1B1A1C16A29Q1A10Q1A1B4A4Q1A2B2A5Q1A1B2A4Q1A1B6R1B3F6E2G2C3A7C4A4Q1A5E4A2Q1A9E4A21B15J1A7I4A5Q1A5E3A13Q1A7E6A51Q1A6E5G2C3A5C4A4Q1A1B5R1A3C4A6C3A3Q1A3B4R1B5J1I3A1Q1A9B13R1A2C15A3B6A14B2A8E5A6E2A3Q1A6E5F1B12R1A3C4A2Q1A3C3A1Q1A7C7K3I2A16Q1A10Q1A8Q1A8Q1A5Q1A2B17J4I1A1Q1A5C2S1C12K3I3A7I3A7I3A6I2A11B6Q1A8B3R1B4A3C4A2Q1A6C7K2I2A5I3A2Q1A5E2A6E2A6Q1A3B5J3I2A3Q1A4B3R1B3E6A4Q1A7C5Q1A4B3R1B1A5E6A9B13R1B3A2C4A6C3A7Q1A1B3A5Q1A1B3A2Q1A2B7D2C13A1E6A2B6A7B5R1A6E8G1A17E5A4C6A2Q1A42B15J2I4K2C4A2Q1A4C15K2I3A8I5A13Q1A3E4A11B5A14I5A1Q1A13Q1A8" +local replayUncompressed2 = "AAAAAAAAAAAAAAAAAAAAAAEEEEEEEEEEEEEEEEEEEGGGGCCKKKKIIIAAggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggghhhhhhhpoooophhhhhhpooooggggggooooooogggggggggggggggggggggggggggggggwggggggkkkkkggggkk1kkkggggghhhhhhllkkkkggggkk1kkmiiiiiqqoophggAACCCCCQABBBRBBBAAAAAAAAACCCCCCAAAACCSCAABBRBBBBEEEEEEGCCCSCCCCCCCCCCCCCCCSABBBBRBBAAAAAAAAAIIIIIAAAAAAAABBBBBRAAACCCCGEEEEEAAAEEEUEEEAAAAEEEEEEAAAAEEEEEEEEEEEEEEEEUEAABBBBBBBBBBBBBJJZIIKCCCCQAAACCSCCCCAAAAAAIIIIIIIIIIIIIIAACCCCCCCAAAAAAAAIIIIYIJBEEEEEEEEEEEEEEEEABBBBBBBBBBBBBBBBBBAACCCCSCCAAAAABBBJJJJJJJIAAAAAAQAAAAAACCCCCAAAAACCCCCCAAAQAAAAAABBBBBRBAAAAAAAAAIIIIIIAAAAAIIIIIKKCCCCCIJJJBBBBBBBAEGEEUEAABBRBBBAAAACCCCCCAIIIIYIBBBBRBBAAAACCCCCCAAAACCCCCCCCCCCCCCCCCCAAAAAAAAAAAAAAAEEEEEEAAAAEEEEFBBRBBBAAACCCCCCQEEEEUEEEBBBBBBJIIIIIAAAAAAAAIIIIIIIIAAAAAAAIIIIIIIIAAABBBBBBBRAAAAAAAACCCCSCAAAAAAAAIIIAAAQAAAAAAAAAAAAAAQAAABBRBBBBAAAAEEEEEAAAAAEEEEEAABBBFFFEEEEEEEEEEEEQAIIYIIKKCCCCCCCCCCCCCCCAAABBBBBBBAAAAAAIIIIYIIAAAEEEEEEAAAQAAACCCCSCAABBRBBBBBABBBBBBBBBBBBBBJJJJIIKCCCSCCAAAAAAAAAACCCCCCAAAACCCCCCAAAAAAAAAA" +local replayCompressed2 = "A22E19G4C2K4I3A2g67h7p1o4p1h6p1o4g6o7g31w1g6k5g4k2(1)k3g5h6l2k4g4k2(1)k2m1i5q2o2p1h1g2A2C5Q1A1B3R1B3A9C6A4C2S1C1A2B2R1B4E6G1C3S1C15S1A1B4R1B2A9I5A8B5R1A3C4G1E5A3E3U1E3A4E6A4E16U1E1A2B13J2Z1I2K1C4Q1A3C2S1C4A6I14A2C7A8I4Y1I1J1B1E16A1B18A2C4S1C2A5B3J7I1A6Q1A6C5A5C6A3Q1A6B5R1B1A9I6A5I5K2C5I1J3B7A1E1G1E2U1E1A2B2R1B3A4C6A1I4Y1I1B4R1B2A4C6A4C18A15E6A4E4F1B2R1B3A3C6Q1E4U1E3B6J1I5A8I8A7I8A3B7R1A8C4S1C1A8I3A3Q1A14Q1A3B2R1B4A4E5A5E5A2B3F3E12Q1A1I2Y1I2K2C15A3B7A6I4Y1I2A3E6A3Q1A3C4S1C1A2B2R1B5A1B14J4I2K1C3S1C2A10C6A4C6A10" + +assert(replayCompressed1 == InputCompression.compressInputString(replayUncompressed1)) +assert(replayCompressed2 == InputCompression.compressInputString(replayUncompressed2)) +assert(replayUncompressed1 == InputCompression.decompressInputString(replayCompressed1)) +assert(replayUncompressed2 == InputCompression.decompressInputString(replayCompressed2)) + +local latinUncompressed1 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀńńńńłłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĺĺĸĸĸĪĪĨĨĨĨĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĢĢĢĬĬĪĪĨĨĨĦĚĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĶĶĶĶĶłńńńńńńńńńńńńńńńŅŅŃŃŃŃŃŃōōōōřřťŧŧŧũũũũũũśśřřŗŗŗřśśőőőőőőőőőŏŏŏŁŁĿŋŋŋŋŋōŅŅŅŇŇŅŅŃŃŁŁŁŃŅŅŇŇŇŇŇŅŃķķķķķĹĹĻįįįĹ" +local latinCompressed1 = "Ā255ā29Ā10ń4ł6Ā12ĺ2ĸ3Ī2Ĩ4Ā11ā34Ā11Ģ3Ĭ2Ī2Ĩ3Ħ1Ě5Ā11Ķ5ł1ń15Ņ2Ń6ō4ř2ť1ŧ3ũ6ś2ř2ŗ3ř1ś2ő9ŏ3Ł2Ŀ1ŋ5ō1Ņ3Ň2Ņ2Ń2Ł3Ń1Ņ2Ň5Ņ1Ń1ķ5Ĺ2Ļ1į3Ĺ1" +assert(latinCompressed1 == InputCompression.compressInputString(latinUncompressed1)) +assert(latinUncompressed1 == InputCompression.decompressInputString(latinCompressed1)) + + +local latinCompressed2 = "Ā96Ğ1Ĝ3Ğ4Ġ17Ā62Ĩ7Ī10Ĩ1Ĝ3Ě3Ĝ2Ğ2Ġ2Ģ3Ĥ6Ā34Ħ3Ĩ2Ī1Ĭ5Ā37Ğ2Ġ5Ā40İ5Į5Ā53Ć3Ĉ4Ċ6Ā81Ĕ2Ė3Ę3Ā43Ę3Ė6Ā34ļ3ĺ3ĸ3Ā16ā74Ā22ŀ6ł5Ā38Ġ4Ğ4Ā28Ĩ4Ī4Ĭ2Ā47Ĭ2Ī8Ā27Ĭ5Ī8Ā42Ī4Ā5Ī4Ā5Ī3Ā5Ī3Ā80Ď3Đ2Ā22Ć2Ą5Ā69Ģ3Ġ2Ğ1Ĝ2Ě4Ā29Ē4Đ2Ď6Ā41Ē5Đ2Ď5Ā20Ą3Ă5Ā11ā21Ā23Ķ1Ĵ2IJ6Ā63Ĵ2Ķ1ĸ1ĺ7Ā39ĺ3ļ9Ā82Ċ2Ĉ7Ā24Ē2Ĕ5Ġ2Ā45Ģ3Ġ6Ā21ĺ6ĸ6Ā81Ğ11Ġ4Ā22Ĕ5Ē6Ā16Ė2Ĕ6Ā51Ē4Ĕ5Ā62Ĝ4Ğ2Ġ4Ā52Ģ2Ĕ4Ā36Ĥ5Ģ5Ā38Ĝ5Ğ1Ġ3Ā23Ģ6Ġ4Ā23Ę5Ė4Ĕ2Ā16ā161" +local latinUncompressed2 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĜĜĜĞĞĞĞĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĨĨĨĪĪĪĪĪĪĪĪĪĪĨĜĜĜĚĚĚĜĜĞĞĠĠĢĢĢĤĤĤĤĤĤĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĦĦĦĨĨĪĬĬĬĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀİİİİİĮĮĮĮĮĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĆĈĈĈĈĊĊĊĊĊĊĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĖĖĖĘĘĘĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĖĖĖĖĖĖĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀļļļĺĺĺĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀŀŀŀŀŀŀłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĠĠĠĠĞĞĞĞĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĪĪĪĪĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĬĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĎĎĎĐĐĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĄĄĄĄĄĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĞĜĜĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĐĐĎĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĒĐĐĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĄĄĄĂĂĂĂĂĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĶĴĴIJIJIJIJIJIJĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĴĴĶĸĺĺĺĺĺĺĺĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺļļļļļļļļļĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĊĊĈĈĈĈĈĈĈĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĔĔĔĔĔĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺĺĺĺĸĸĸĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĞĞĞĞĞĞĞĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĔĔĔĒĒĒĒĒĒĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĖĖĔĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĤĤĤĤĤĢĢĢĢĢĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĜĞĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĢĢĢĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĘĘĖĖĖĖĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā" +assert(latinCompressed2 == InputCompression.compressInputString(latinUncompressed2)) +assert(latinUncompressed2 == InputCompression.decompressInputString(latinCompressed2)) \ No newline at end of file diff --git a/common/tests/engine/ReplayTests.lua b/common/tests/engine/ReplayTests.lua index 5c4b6b023..1b803d08e 100644 --- a/common/tests/engine/ReplayTests.lua +++ b/common/tests/engine/ReplayTests.lua @@ -26,27 +26,4 @@ local function endlessSaveTest() StackReplayTestingUtils:cleanup(match) end -endlessSaveTest() - -assert(InputCompression.compressInputString("") == "") - -local replayUncompressed1 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEEEEEEEEEEEEEEEEEEAAAAAAAAAAAAAAAAAAAAAAAAQAAAAABBBBBBBAAQAAAAAAAAIIIIAAAAggggggggggggggggggggggggkkkgiiiiiiiiiiiiiiiiiiigwgghBBBBBBJJJIYAAAAAAAAAAAABBBBBBBBBBBBBBBAAAAAAAAAAACCCCCAAQAAAAAAAAAAIIIIQAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAEEEAAAAAAAEEEAAAAAAEEEAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAAAACCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEEEEFBBBBAQAAACCCCCKIIIAAQAAAAAAEEEEAAAAAEEFBBBBBBBBBBBBBBQAAACCCCCCKKIIIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAABBBBBBBBBBRAAAAACCCCCCCCCCCCCCCCKKKIAAAAAAIIIAAAAQAAAAAIIIAAAAAAIIAAAAAAIIIAAAAAQAAAAEEEEEEAABBBBBRAAAAAEEEEEEAAQAAAAAACCCCCCCAAAAAAAIIIAAQAAAAEEEgggggggkkgggggAAEEAAAAAAAEEAAAAAAAAABBBBAAAAAAABBAAAAAABBBBRBAAAEEEEEGCCCAAAAQAAACCCCSCCCCCCCCCKKIIAAAAAAIIIAAAAAAIIAAAAAIIIIAAAQAAAAAEEEAAAAAAAAEEEEEBBBBBBAAAAAAABBBBAAAAAAAQAAAAAAAAACCCCCCCCCCCCCGGGEEEEAQAAAAABBAAAAAAAAABBBBBBJJJJIIAAAAAAAAAQAAAAEEEEEAQAAAAAAAAAAAAAAACCCCAAQAABBBBBBAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCAAAAAAAAAAAAAAAAAAAAAAAAAEEEEAAAAAAAAEEEEEAAAAAAAQAAAABBBAAAQAAABBBAAAAAAAAAAAAAAQAAABBBBAAAAAAAAACCCCCCAAAAAAAACCCKIIAAAAAAIIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAAAQAAAAAEAAAAAAAACCCCCCCKKIIIAAQAABBBBBBBBBBBBBBBFEEEEEAAQAAAACCCCAQAAAACCCCCSCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCCCCCCCSCAAABBBBBBBBBBBBBBBBBAAACCCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAAAAQAAAAAABBBBBBAAQAAAACCCCSAAAAAAAACCCCCAAAAAAACCCCAAQAAAAAACCCCCCCCCAAAAQABBBBAAAAQAABBAAAAQAAABBBBRBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBAAAAAAAAAAAAAQAAAACCCCAAAAAAACCCCAAAAAAAACCCAAAAAAQAAAABBBAQAAAAAABAAAAAAAAAAAAAAACCCCCCCCCCCCCAAAAABBBBBBAQAAAAAAAAAAAAABBBBBAAAAAAAAAAAAAAAAAAAAEEEEAAAAAAAEEEEEAABBBBBAQAAACCCCCSCAAAAEEEEAAAAAEEEAAAAQAAAABBBBJJJJIIQAAAAAAAACCCCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQACCCCCCCCCCCKKKKIIAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBAAAAEEEEEAAAAAAAAAAAAAAAAAAAAAAAAQCCCCCCCCCCCCCCCKKIJJBBBBAAAAAIIIIAAAAAQAAAAAEEEEAAAAAAAAAAAAIIIIAAAAAAAIIAAAAAAAAAIIIAAAQAAACCCCCAAAAQABBBBAAAQAACCCCCCCCCCCCAAAAQABBBAAAAQAABBBAQAAAAAEEEAAAAAAAEEAAAAAAAAAAAAAAAAEEEAAAAABBBBBBBBBBBRBBBBCCCCCCCAAAAAAAEEEEEEEGCCCCCCCCCCCCCCCAAAQAABBBAAAAQAABBBBRAAAACCCCCCCCCCCCCCAAAABBBBBAAAAAAAQAAACCCCSAABBBBBJJIIYICCCCCCSCAAAAAIJBBBBBBJJIIAAAAAIIAAAAAAAAAAAAEEEEEAAAAAAQAAAAAAAAAAAAAAAAAAAAAEEEEAAAAAQAAAAAAEEEAAAAAABBBBBRAAAEEEEEGCCCCCQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIIAAAAAAAAAAAAQAAAAAAAAAAAAAQAABBBBBRBBBFEEEEEAAAAAAAAAAAAAAAAAAACCCCCAAAQAAABBBBAAQAAAAAABBBBBRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCCKKIIIAAAAAAAAAAAAAAAAAAAAIIIIIIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCCCCKKIIAAAAAAIIIAAAAAAIIAAAAAIIIAAQAAAAAEEAAAAAAEAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIIIAAAAAIIAAQAAAAAEEEAAAAAAEEEEFBBBBBBQAAACCCCSCCAAAAAAAAABBBAAAAQAAABBBBRBACCCCCCCCCCCCCCCCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAQABBBBAAAAQAABBAAAAAQABBAAAAQABBBBBBRBBBFFFFFFEEGGCCCAAAAAAACCCCAAAAQAAAAAEEEEAAQAAAAAAAAAEEEEAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBJAAAAAAAIIIIAAAAAQAAAAAEEEAAAAAAAAAAAAAQAAAAAAAEEEEEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAEEEEEGGCCCAAAAACCCCAAAAQABBBBBRAAACCCCAAAAAACCCAAAQAAABBBBRBBBBBJIIIAQAAAAAAAAABBBBBBBBBBBBBRAACCCCCCCCCCCCCCCAAABBBBBBAAAAAAAAAAAAAABBAAAAAAAAEEEEEAAAAAAEEAAAQAAAAAAEEEEEFBBBBBBBBBBBBRAAACCCCAAQAAACCCAQAAAAAAACCCCCCCKKKIIAAAAAAAAAAAAAAAAQAAAAAAAAAAQAAAAAAAAQAAAAAAAAQAAAAAQAABBBBBBBBBBBBBBBBBJJJJIAQAAAAACCSCCCCCCCCCCCCKKKIIIAAAAAAAIIIAAAAAAAIIIAAAAAAIIAAAAAAAAAAABBBBBBQAAAAAAAABBBRBBBBAAACCCCAAQAAAAAACCCCCCCKKIIAAAAAIIIAAQAAAAAEEAAAAAAEEAAAAAAQAAABBBBBJJJIIAAAQAAAABBBRBBBEEEEEEAAAAQAAAAAAACCCCCQAAAABBBRBAAAAAEEEEEEAAAAAAAAABBBBBBBBBBBBBRBBBAACCCCAAAAAACCCAAAAAAAQABBBAAAAAQABBBAAQAABBBBBBBDDCCCCCCCCCCCCCAEEEEEEAABBBBBBAAAAAAABBBBBRAAAAAAEEEEEEEEGAAAAAAAAAAAAAAAAAEEEEEAAAACCCCCCAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBJJIIIIKKCCCCAAQAAAACCCCCCCCCCCCCCCKKIIIAAAAAAAAIIIIIAAAAAAAAAAAAAQAAAEEEEAAAAAAAAAAABBBBBAAAAAAAAAAAAAAIIIIIAQAAAAAAAAAAAAAQAAAAAAAA" -local replayCompressed1 = "A152E19A24Q1A5B7A2Q1A8I4A4g24k3g1i19g1w1g2h1B6J3I1Y1A12B15A11C5A2Q1A10I4Q1A25I5A11E3A7E3A6E3A18I5A15C3A34E5F1B4A1Q1A3C5K1I3A2Q1A6E4A5E2F1B14Q1A3C6K2I4A40Q1A4B10R1A5C16K3I1A6I3A4Q1A5I3A6I2A6I3A5Q1A4E6A2B5R1A5E6A2Q1A6C7A7I3A2Q1A4E3g7k2g5A2E2A7E2A9B4A7B2A6B4R1B1A3E5G1C3A4Q1A3C4S1C9K2I2A6I3A6I2A5I4A3Q1A5E3A8E5B6A7B4A7Q1A9C13G3E4A1Q1A5B2A9B6J4I2A9Q1A4E5A1Q1A15C4A2Q1A2B6A27C6A25E4A8E5A7Q1A4B3A3Q1A3B3A14Q1A3B4A9C6A8C3K1I2A6I3A37I5A14Q1A5E1A8C7K2I3A2Q1A2B15F1E5A2Q1A4C4A1Q1A4C5S1C1A41C12S1C1A3B17A3C4A34I5A15Q1A6B6A2Q1A4C4S1A8C5A7C4A2Q1A6C9A4Q1A1B4A4Q1A2B2A4Q1A3B4R1B1A108B10A13Q1A4C4A7C4A8C3A6Q1A4B3A1Q1A6B1A15C13A5B6A1Q1A13B5A20E4A7E5A2B5A1Q1A3C5S1C1A4E4A5E3A4Q1A4B4J4I2Q1A8C5A56Q1A1C11K4I2A16B15A4E5A24Q1C15K2I1J2B4A5I4A5Q1A5E4A12I4A7I2A9I3A3Q1A3C5A4Q1A1B4A3Q1A2C12A4Q1A1B3A4Q1A2B3A1Q1A5E3A7E2A16E3A5B11R1B4C7A7E7G1C15A3Q1A2B3A4Q1A2B4R1A4C14A4B5A7Q1A3C4S1A2B5J2I2Y1I1C6S1C1A5I1J1B6J2I2A5I2A12E5A6Q1A21E4A5Q1A6E3A6B5R1A3E5G1C5Q1A55I5A12Q1A13Q1A2B5R1B3F1E5A19C5A3Q1A3B4A2Q1A6B5R1A78C6K2I3A20I6A51C5K2I2A6I3A6I2A5I3A2Q1A5E2A6E1A4Q1A65I4A5I2A2Q1A5E3A6E4F1B6Q1A3C4S1C2A9B3A4Q1A3B4R1B1A1C16A29Q1A10Q1A1B4A4Q1A2B2A5Q1A1B2A4Q1A1B6R1B3F6E2G2C3A7C4A4Q1A5E4A2Q1A9E4A21B15J1A7I4A5Q1A5E3A13Q1A7E6A51Q1A6E5G2C3A5C4A4Q1A1B5R1A3C4A6C3A3Q1A3B4R1B5J1I3A1Q1A9B13R1A2C15A3B6A14B2A8E5A6E2A3Q1A6E5F1B12R1A3C4A2Q1A3C3A1Q1A7C7K3I2A16Q1A10Q1A8Q1A8Q1A5Q1A2B17J4I1A1Q1A5C2S1C12K3I3A7I3A7I3A6I2A11B6Q1A8B3R1B4A3C4A2Q1A6C7K2I2A5I3A2Q1A5E2A6E2A6Q1A3B5J3I2A3Q1A4B3R1B3E6A4Q1A7C5Q1A4B3R1B1A5E6A9B13R1B3A2C4A6C3A7Q1A1B3A5Q1A1B3A2Q1A2B7D2C13A1E6A2B6A7B5R1A6E8G1A17E5A4C6A2Q1A42B15J2I4K2C4A2Q1A4C15K2I3A8I5A13Q1A3E4A11B5A14I5A1Q1A13Q1A8" -local replayUncompressed2 = "AAAAAAAAAAAAAAAAAAAAAAEEEEEEEEEEEEEEEEEEEGGGGCCKKKKIIIAAggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggghhhhhhhpoooophhhhhhpooooggggggooooooogggggggggggggggggggggggggggggggwggggggkkkkkggggkk1kkkggggghhhhhhllkkkkggggkk1kkmiiiiiqqoophggAACCCCCQABBBRBBBAAAAAAAAACCCCCCAAAACCSCAABBRBBBBEEEEEEGCCCSCCCCCCCCCCCCCCCSABBBBRBBAAAAAAAAAIIIIIAAAAAAAABBBBBRAAACCCCGEEEEEAAAEEEUEEEAAAAEEEEEEAAAAEEEEEEEEEEEEEEEEUEAABBBBBBBBBBBBBJJZIIKCCCCQAAACCSCCCCAAAAAAIIIIIIIIIIIIIIAACCCCCCCAAAAAAAAIIIIYIJBEEEEEEEEEEEEEEEEABBBBBBBBBBBBBBBBBBAACCCCSCCAAAAABBBJJJJJJJIAAAAAAQAAAAAACCCCCAAAAACCCCCCAAAQAAAAAABBBBBRBAAAAAAAAAIIIIIIAAAAAIIIIIKKCCCCCIJJJBBBBBBBAEGEEUEAABBRBBBAAAACCCCCCAIIIIYIBBBBRBBAAAACCCCCCAAAACCCCCCCCCCCCCCCCCCAAAAAAAAAAAAAAAEEEEEEAAAAEEEEFBBRBBBAAACCCCCCQEEEEUEEEBBBBBBJIIIIIAAAAAAAAIIIIIIIIAAAAAAAIIIIIIIIAAABBBBBBBRAAAAAAAACCCCSCAAAAAAAAIIIAAAQAAAAAAAAAAAAAAQAAABBRBBBBAAAAEEEEEAAAAAEEEEEAABBBFFFEEEEEEEEEEEEQAIIYIIKKCCCCCCCCCCCCCCCAAABBBBBBBAAAAAAIIIIYIIAAAEEEEEEAAAQAAACCCCSCAABBRBBBBBABBBBBBBBBBBBBBJJJJIIKCCCSCCAAAAAAAAAACCCCCCAAAACCCCCCAAAAAAAAAA" -local replayCompressed2 = "A22E19G4C2K4I3A2g67h7p1o4p1h6p1o4g6o7g31w1g6k5g4k2(1)k3g5h6l2k4g4k2(1)k2m1i5q2o2p1h1g2A2C5Q1A1B3R1B3A9C6A4C2S1C1A2B2R1B4E6G1C3S1C15S1A1B4R1B2A9I5A8B5R1A3C4G1E5A3E3U1E3A4E6A4E16U1E1A2B13J2Z1I2K1C4Q1A3C2S1C4A6I14A2C7A8I4Y1I1J1B1E16A1B18A2C4S1C2A5B3J7I1A6Q1A6C5A5C6A3Q1A6B5R1B1A9I6A5I5K2C5I1J3B7A1E1G1E2U1E1A2B2R1B3A4C6A1I4Y1I1B4R1B2A4C6A4C18A15E6A4E4F1B2R1B3A3C6Q1E4U1E3B6J1I5A8I8A7I8A3B7R1A8C4S1C1A8I3A3Q1A14Q1A3B2R1B4A4E5A5E5A2B3F3E12Q1A1I2Y1I2K2C15A3B7A6I4Y1I2A3E6A3Q1A3C4S1C1A2B2R1B5A1B14J4I2K1C3S1C2A10C6A4C6A10" - -assert(replayCompressed1 == InputCompression.compressInputString(replayUncompressed1)) -assert(replayCompressed2 == InputCompression.compressInputString(replayUncompressed2)) -assert(replayUncompressed1 == InputCompression.decompressInputString(replayCompressed1)) -assert(replayUncompressed2 == InputCompression.decompressInputString(replayCompressed2)) - -local latinUncompressed1 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀńńńńłłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĺĺĸĸĸĪĪĨĨĨĨĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĢĢĢĬĬĪĪĨĨĨĦĚĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĶĶĶĶĶłńńńńńńńńńńńńńńńŅŅŃŃŃŃŃŃōōōōřřťŧŧŧũũũũũũśśřřŗŗŗřśśőőőőőőőőőŏŏŏŁŁĿŋŋŋŋŋōŅŅŅŇŇŅŅŃŃŁŁŁŃŅŅŇŇŇŇŇŅŃķķķķķĹĹĻįįįĹ" -local latinCompressed1 = "Ā255ā29Ā10ń4ł6Ā12ĺ2ĸ3Ī2Ĩ4Ā11ā34Ā11Ģ3Ĭ2Ī2Ĩ3Ħ1Ě5Ā11Ķ5ł1ń15Ņ2Ń6ō4ř2ť1ŧ3ũ6ś2ř2ŗ3ř1ś2ő9ŏ3Ł2Ŀ1ŋ5ō1Ņ3Ň2Ņ2Ń2Ł3Ń1Ņ2Ň5Ņ1Ń1ķ5Ĺ2Ļ1į3Ĺ1" -assert(latinCompressed1 == InputCompression.compressInputString(latinUncompressed1)) -assert(latinUncompressed1 == InputCompression.decompressInputString(latinCompressed1)) - - -local latinCompressed2 = "Ā96Ğ1Ĝ3Ğ4Ġ17Ā62Ĩ7Ī10Ĩ1Ĝ3Ě3Ĝ2Ğ2Ġ2Ģ3Ĥ6Ā34Ħ3Ĩ2Ī1Ĭ5Ā37Ğ2Ġ5Ā40İ5Į5Ā53Ć3Ĉ4Ċ6Ā81Ĕ2Ė3Ę3Ā43Ę3Ė6Ā34ļ3ĺ3ĸ3Ā16ā74Ā22ŀ6ł5Ā38Ġ4Ğ4Ā28Ĩ4Ī4Ĭ2Ā47Ĭ2Ī8Ā27Ĭ5Ī8Ā42Ī4Ā5Ī4Ā5Ī3Ā5Ī3Ā80Ď3Đ2Ā22Ć2Ą5Ā69Ģ3Ġ2Ğ1Ĝ2Ě4Ā29Ē4Đ2Ď6Ā41Ē5Đ2Ď5Ā20Ą3Ă5Ā11ā21Ā23Ķ1Ĵ2IJ6Ā63Ĵ2Ķ1ĸ1ĺ7Ā39ĺ3ļ9Ā82Ċ2Ĉ7Ā24Ē2Ĕ5Ġ2Ā45Ģ3Ġ6Ā21ĺ6ĸ6Ā81Ğ11Ġ4Ā22Ĕ5Ē6Ā16Ė2Ĕ6Ā51Ē4Ĕ5Ā62Ĝ4Ğ2Ġ4Ā52Ģ2Ĕ4Ā36Ĥ5Ģ5Ā38Ĝ5Ğ1Ġ3Ā23Ģ6Ġ4Ā23Ę5Ė4Ĕ2Ā16ā161" -local latinUncompressed2 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĜĜĜĞĞĞĞĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĨĨĨĪĪĪĪĪĪĪĪĪĪĨĜĜĜĚĚĚĜĜĞĞĠĠĢĢĢĤĤĤĤĤĤĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĦĦĦĨĨĪĬĬĬĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀİİİİİĮĮĮĮĮĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĆĈĈĈĈĊĊĊĊĊĊĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĖĖĖĘĘĘĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĖĖĖĖĖĖĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀļļļĺĺĺĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀŀŀŀŀŀŀłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĠĠĠĠĞĞĞĞĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĪĪĪĪĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĬĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĎĎĎĐĐĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĄĄĄĄĄĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĞĜĜĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĐĐĎĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĒĐĐĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĄĄĄĂĂĂĂĂĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĶĴĴIJIJIJIJIJIJĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĴĴĶĸĺĺĺĺĺĺĺĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺļļļļļļļļļĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĊĊĈĈĈĈĈĈĈĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĔĔĔĔĔĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺĺĺĺĸĸĸĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĞĞĞĞĞĞĞĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĔĔĔĒĒĒĒĒĒĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĖĖĔĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĤĤĤĤĤĢĢĢĢĢĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĜĞĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĢĢĢĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĘĘĖĖĖĖĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā" -assert(latinCompressed2 == InputCompression.compressInputString(latinUncompressed2)) -assert(latinUncompressed2 == InputCompression.decompressInputString(latinCompressed2)) \ No newline at end of file +endlessSaveTest() \ No newline at end of file diff --git a/testLauncher.lua b/testLauncher.lua index 053a3449f..b5f0c8cfb 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -70,6 +70,7 @@ local tests = { "common.tests.lib.utilTests", "common.tests.network.NetworkProtocolTests", "common.tests.network.TouchDataEncodingTests", + "common.tests.data.InputCompressionTests", } local updateCount = 0 From 8798b4745d3ec3b6c3e937fe30a47500749d75cc Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 21 Jun 2025 01:19:38 +0200 Subject: [PATCH 06/16] add test case for issue 661 and intended number compression --- common/tests/data/InputCompressionTests.lua | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/common/tests/data/InputCompressionTests.lua b/common/tests/data/InputCompressionTests.lua index 2a0c45885..6c963e8e4 100644 --- a/common/tests/data/InputCompressionTests.lua +++ b/common/tests/data/InputCompressionTests.lua @@ -21,4 +21,21 @@ assert(latinUncompressed1 == InputCompression.decompressInputString(latinCompres local latinCompressed2 = "Ā96Ğ1Ĝ3Ğ4Ġ17Ā62Ĩ7Ī10Ĩ1Ĝ3Ě3Ĝ2Ğ2Ġ2Ģ3Ĥ6Ā34Ħ3Ĩ2Ī1Ĭ5Ā37Ğ2Ġ5Ā40İ5Į5Ā53Ć3Ĉ4Ċ6Ā81Ĕ2Ė3Ę3Ā43Ę3Ė6Ā34ļ3ĺ3ĸ3Ā16ā74Ā22ŀ6ł5Ā38Ġ4Ğ4Ā28Ĩ4Ī4Ĭ2Ā47Ĭ2Ī8Ā27Ĭ5Ī8Ā42Ī4Ā5Ī4Ā5Ī3Ā5Ī3Ā80Ď3Đ2Ā22Ć2Ą5Ā69Ģ3Ġ2Ğ1Ĝ2Ě4Ā29Ē4Đ2Ď6Ā41Ē5Đ2Ď5Ā20Ą3Ă5Ā11ā21Ā23Ķ1Ĵ2IJ6Ā63Ĵ2Ķ1ĸ1ĺ7Ā39ĺ3ļ9Ā82Ċ2Ĉ7Ā24Ē2Ĕ5Ġ2Ā45Ģ3Ġ6Ā21ĺ6ĸ6Ā81Ğ11Ġ4Ā22Ĕ5Ē6Ā16Ė2Ĕ6Ā51Ē4Ĕ5Ā62Ĝ4Ğ2Ġ4Ā52Ģ2Ĕ4Ā36Ĥ5Ģ5Ā38Ĝ5Ğ1Ġ3Ā23Ģ6Ġ4Ā23Ę5Ė4Ĕ2Ā16ā161" local latinUncompressed2 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĜĜĜĞĞĞĞĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĨĨĨĪĪĪĪĪĪĪĪĪĪĨĜĜĜĚĚĚĜĜĞĞĠĠĢĢĢĤĤĤĤĤĤĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĦĦĦĨĨĪĬĬĬĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀİİİİİĮĮĮĮĮĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĆĈĈĈĈĊĊĊĊĊĊĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĖĖĖĘĘĘĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĖĖĖĖĖĖĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀļļļĺĺĺĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀŀŀŀŀŀŀłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĠĠĠĠĞĞĞĞĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĪĪĪĪĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĬĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĎĎĎĐĐĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĄĄĄĄĄĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĞĜĜĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĐĐĎĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĒĐĐĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĄĄĄĂĂĂĂĂĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĶĴĴIJIJIJIJIJIJĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĴĴĶĸĺĺĺĺĺĺĺĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺļļļļļļļļļĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĊĊĈĈĈĈĈĈĈĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĔĔĔĔĔĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺĺĺĺĸĸĸĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĞĞĞĞĞĞĞĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĔĔĔĒĒĒĒĒĒĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĖĖĔĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĤĤĤĤĤĢĢĢĢĢĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĜĞĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĢĢĢĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĘĘĖĖĖĖĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā" assert(latinCompressed2 == InputCompression.compressInputString(latinUncompressed2)) -assert(latinUncompressed2 == InputCompression.decompressInputString(latinCompressed2)) \ No newline at end of file +assert(latinUncompressed2 == InputCompression.decompressInputString(latinCompressed2)) + +local replayCompressed3 = "A120E3k5g5k5g6k5g23i4g6k5g9i6o7(5)g3h4x1h1g1k5g1w1g4k7g1h8g8w1g6i13k6g4k3(1)" +local replayUncompressed3 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEEkkkkkgggggkkkkkggggggkkkkkgggggggggggggggggggggggiiiiggggggkkkkkgggggggggiiiiiiooooooo5ggghhhhxhgkkkkkgwggggkkkkkkkghhhhhhhhggggggggwggggggiiiiiiiiiiiiikkkkkkggggkkk1" + +local replay3Compressed = InputCompression.compressInputString(replayUncompressed3) +assert(replay3Compressed == replayCompressed3) +local replay3Uncompressed = InputCompression.decompressInputString(replayCompressed3) +-- this assert fails!!! See issue 661 +--assert(replayCompressed3 ~= replay3Uncompressed) + + +-- intended number compression +local replayUncompressed4 = "AAA4445214AA" +local replayCompressed4 = "A3(444)(5)(2)(1)(4)A2" + +assert(InputCompression.compressInputString(replayUncompressed4) == replayCompressed4) +assert(replayUncompressed4 == InputCompression.decompressInputString(replayCompressed4)) \ No newline at end of file From 72840bb24f67813c98803d91d6324cfc8207e870 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 21 Jun 2025 15:46:13 +0200 Subject: [PATCH 07/16] replace input decompression function and add a function to compress inputs directly from a table --- common/compatibility/ReplayV2.lua | 6 +- common/data/InputCompression.lua | 148 +++++++++++++++----- common/data/ReplayV3.lua | 2 +- common/engine/Match.lua | 4 +- common/engine/Stack.lua | 2 +- common/lib/stringExtensions.lua | 5 +- common/tests/data/InputCompressionTests.lua | 39 +++++- common/tests/engine/StackReplayTests.lua | 3 +- server/Game.lua | 10 +- testLauncher.lua | 24 ++-- 10 files changed, 173 insertions(+), 70 deletions(-) diff --git a/common/compatibility/ReplayV2.lua b/common/compatibility/ReplayV2.lua index 33b0460b5..8f85cbb8c 100644 --- a/common/compatibility/ReplayV2.lua +++ b/common/compatibility/ReplayV2.lua @@ -135,7 +135,7 @@ function ReplayV2.createFromV2Data(replayData) replayPlayer:setLevelData(player.settings.levelData) end replayPlayer:setInputMethod(player.settings.inputMethod) - replayPlayer:setInputs(InputCompression.decompressInputString(player.settings.inputs)) + replayPlayer:setInputs(InputCompression.decompressInputString2(player.settings.inputs)) else replayPlayer:setHealthSettings(player.settings.healthSettings) end @@ -216,7 +216,7 @@ function ReplayV2.createFromLegacyReplay(legacyReplay, timestamp, winnerIndex) p1:setInputMethod(v1r.inputMethod or "controller") end - p1:setInputs(InputCompression.decompressInputString(v1r.in_buf)) + p1:setInputs(InputCompression.decompressInputString2(v1r.in_buf)) p1:setBehaviours(StackBehaviours.getV048Default()) if v1r.P1_level then @@ -245,7 +245,7 @@ function ReplayV2.createFromLegacyReplay(legacyReplay, timestamp, winnerIndex) -- not saved in v1 p2:setPanelId(config and config.panels or "pacci") p2:setInputMethod(v1r.P2_inputMethod or "controller") - p2:setInputs(InputCompression.decompressInputString(v1r.I)) + p2:setInputs(InputCompression.decompressInputString2(v1r.I)) -- presence of V2 means level and vs p2:setBehaviours(StackBehaviours.getV048Default()) diff --git a/common/data/InputCompression.lua b/common/data/InputCompression.lua index f854a8644..2078a3d95 100644 --- a/common/data/InputCompression.lua +++ b/common/data/InputCompression.lua @@ -18,6 +18,22 @@ local function codePointIsDigit(codePoint) return false end +local function codePointIsAlphabet(codePoint) + if codePoint < 65 then + return false + elseif codePoint > 123 then + return false + else + if codePoint < 92 then + return true + elseif codePoint > 97 then + return true + else + return false + end + end +end + ---@param inputs string ---@return string compressedInputs function InputCompression.compressInputString(inputs) @@ -65,56 +81,114 @@ function InputCompression.compressInputString(inputs) return table.concat(compressedTable) end +local readingStates = { uninitialized = 0, character = 1, count = 2, uncompressed = 3 } + +-- replaces the previous buggy decompressInputString ---@param inputs string ---@return string decompressedInputs -function InputCompression.decompressInputString(inputs) - local previousCodePoint = nil +function InputCompression.decompressInputString2(inputs) + -- reading state is based on the last character(s) to determine how we interpret the next one + local readingState = 0 local inputChunks = {} - local numberString = nil - local characterCodePoint = nil - -- Go through the characters one by one, saving character and then the number sequence and after passing it writing out that many characters - for p, codePoint in utf8.codes(inputs) do - if p > 1 then + local count = 0 + + for _, codePoint in utf8.codes(inputs) do + if readingState == readingStates.uninitialized then + -- uninitialized state means that we either haven't read anything yet + -- or the previous character was a magic character for closing an uncompressed segment + -- in either case we expect a character or the start of a new uncompressed segment + + -- 40 is ( and indicates the start of an uncompressed segment of the same character + if codePoint == 40 then + readingState = readingStates.uncompressed + else + readingState = readingStates.character + inputChunks[#inputChunks+1] = utf8.char(codePoint) + end + elseif readingState == readingStates.character then + -- when we have read a character we always expect a count next, anything else is invalid if codePointIsDigit(codePoint) then - local number = utf8.char(codePoint) - if numberString == nil then - characterCodePoint = previousCodePoint - numberString = "" - end - numberString = numberString .. number + readingState = readingStates.count +---@diagnostic disable-next-line: cast-local-type + count = tonumber(utf8.char(codePoint)) else - if numberString ~= nil then - if codePointIsParenthesis(characterCodePoint) then - inputChunks[#inputChunks+1] = numberString - else - local character = utf8.char(characterCodePoint) - local repeatCount = tonumber(numberString) - inputChunks[#inputChunks+1] = string.rep(character, repeatCount) - end - numberString = nil - end - if previousCodePoint == codePoint then - -- Detected two consecutive letters or symbols in the inputs, the inputs are not compressed. - return inputs + -- getting here means either getting a completely unexpected input or two repeated characters + -- two repeated inputs after another indicate non-compressed inputs + -- due to digits being both valid inputs and count indicators there is no way to differentiate the two + -- so we have to give up immediately + -- return inputs under the assumption that the input string is just not compressed rather than invalid + return inputs + end + elseif readingState == readingStates.count then + -- numbers stretch over multiple characters so concatenate for as long as there are numbers + if codePointIsDigit(codePoint) then + count = count * 10 + tonumber(utf8.char(codePoint)) + else + -- otherwise apply the repeats of the previous character + inputChunks[#inputChunks+1] = string.rep(inputChunks[#inputChunks], count - 1) + + -- 40 is ( and indicates the start of an uncompressed segment of the same character + if codePoint == 40 then + readingState = readingStates.uncompressed else - -- Nothing to do yet + readingState = readingStates.character + inputChunks[#inputChunks+1] = utf8.char(codePoint) end end + elseif readingState == readingStates.uncompressed then + -- 41 is ) and indicates the end of the uncompressed segment + if codePoint == 41 then + readingState = readingStates.uninitialized + else + -- in uncompressed segments we take any non ) character at face value + inputChunks[#inputChunks+1] = utf8.char(codePoint) + end end - previousCodePoint = codePoint end - local result - if numberString ~= nil then - local character = utf8.char(characterCodePoint) - local repeatCount = tonumber(numberString) - inputChunks[#inputChunks+1] = string.rep(character, repeatCount) - result = table.concat(inputChunks) + if readingState == readingStates.count then + -- counts defer appending until the number is confirmed to be complete; end of input string is yet another count termination + inputChunks[#inputChunks+1] = string.rep(inputChunks[#inputChunks], count - 1) + end + + return table.concat(inputChunks) +end + +local function writeToCache(cache, character, count) + if tonumber(character) then + cache[#cache+1] = "(" .. string.rep(character, count) .. ")" else - -- We never encountered a single number, this string wasn't compressed - result = inputs + cache[#cache+1] = character .. count end - return result +end + +--- convenience function to compress inputs directly from a table +--- this saves doing a table.concat as well as juggling utf8 codepoints +---@param inputs string[] +---@return string compressedInputs +function InputCompression.compressInputTable(inputs) + if #inputs == 0 then + return "" + end + + local count = 1 + local lastCharacter = inputs[1] + local cache = {} + + for i = 2, #inputs do + local character = inputs[i] + if character == lastCharacter then + count = count + 1 + else + writeToCache(cache, lastCharacter, count) + lastCharacter = character + count = 1 + end + end + + writeToCache(cache, lastCharacter, count) + + return table.concat(cache) end return InputCompression \ No newline at end of file diff --git a/common/data/ReplayV3.lua b/common/data/ReplayV3.lua index 664c2a90c..4309ed0f8 100644 --- a/common/data/ReplayV3.lua +++ b/common/data/ReplayV3.lua @@ -232,7 +232,7 @@ function ReplayV3.finalizeReplay(match, replay) for i, stack in ipairs(match.stacks) do if stack.TYPE == "Stack" then ---@cast stack Stack - replay.stacks[i].inputs = InputCompression.compressInputString(table.concat(stack.confirmedInput)) + replay.stacks[i].inputs = InputCompression.compressInputTable(stack.confirmedInput) end end diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 55e970a86..120944244 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -396,7 +396,7 @@ function Match:createNewReplay() levelData = stack.levelData, stackBehaviours = stack.behaviours, inputMethod = stack.inputMethod, - inputs = InputCompression.compressInputString(table.concat(stack.confirmedInput)) + inputs = InputCompression.compressInputTable(stack.confirmedInput) } replay.stacks[i] = replayStack elseif stack.TYPE == "SimulatedStack" then @@ -653,7 +653,7 @@ function Match:createStackWithSettings(levelData, isLocal, inputMethod, inputs) self.garbageTargets[#self.stacks] = {} self.garbageSources[stack] = {} if inputs then - stack:receiveConfirmedInput(InputCompression.decompressInputString(inputs)) + stack:receiveConfirmedInput(InputCompression.decompressInputString2(inputs)) end return stack diff --git a/common/engine/Stack.lua b/common/engine/Stack.lua index 54609275d..7d151c698 100644 --- a/common/engine/Stack.lua +++ b/common/engine/Stack.lua @@ -1723,7 +1723,7 @@ function Stack:toReplayStack(stackIndex) levelData = self.levelData, stackBehaviours = self.behaviours, inputMethod = self.inputMethod, - inputs = InputCompression.compressInputString(table.concat(self.confirmedInput)), + inputs = InputCompression.compressInputTable(self.confirmedInput), } end diff --git a/common/lib/stringExtensions.lua b/common/lib/stringExtensions.lua index bd364abe7..236dfc855 100644 --- a/common/lib/stringExtensions.lua +++ b/common/lib/stringExtensions.lua @@ -1,8 +1,9 @@ local utf8 = require("common.lib.utf8Additions") -function string.toCharTable(self) +---@param str string +function string.toCharTable(str) local t = {} - for _, codePoint in utf8.codes(self) do + for _, codePoint in utf8.codes(str) do local character = utf8.char(codePoint) t[#t+1] = character end diff --git a/common/tests/data/InputCompressionTests.lua b/common/tests/data/InputCompressionTests.lua index 6c963e8e4..c9ca695f2 100644 --- a/common/tests/data/InputCompressionTests.lua +++ b/common/tests/data/InputCompressionTests.lua @@ -1,3 +1,4 @@ +require("common.lib.stringExtensions") local InputCompression = require("common.data.InputCompression") assert(InputCompression.compressInputString("") == "") @@ -9,28 +10,41 @@ local replayCompressed2 = "A22E19G4C2K4I3A2g67h7p1o4p1h6p1o4g6o7g31w1g6k5g4k2(1) assert(replayCompressed1 == InputCompression.compressInputString(replayUncompressed1)) assert(replayCompressed2 == InputCompression.compressInputString(replayUncompressed2)) -assert(replayUncompressed1 == InputCompression.decompressInputString(replayCompressed1)) -assert(replayUncompressed2 == InputCompression.decompressInputString(replayCompressed2)) +assert(replayCompressed1 == InputCompression.compressInputTable(string.toCharTable(replayUncompressed1))) +assert(replayCompressed2 == InputCompression.compressInputTable(string.toCharTable(replayUncompressed2))) +local replay1Uncompressed = InputCompression.decompressInputString2(replayCompressed1) +assert(replayUncompressed1 == replay1Uncompressed) +assert(replayUncompressed2 == InputCompression.decompressInputString2(replayCompressed2)) +-- the decompression function should return the decompressed string in case it receives one as input +assert(replayUncompressed1 == InputCompression.decompressInputString2(replayUncompressed1)) +assert(replayUncompressed2 == InputCompression.decompressInputString2(replayUncompressed2)) local latinUncompressed1 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀńńńńłłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĺĺĸĸĸĪĪĨĨĨĨĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĢĢĢĬĬĪĪĨĨĨĦĚĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĶĶĶĶĶłńńńńńńńńńńńńńńńŅŅŃŃŃŃŃŃōōōōřřťŧŧŧũũũũũũśśřřŗŗŗřśśőőőőőőőőőŏŏŏŁŁĿŋŋŋŋŋōŅŅŅŇŇŅŅŃŃŁŁŁŃŅŅŇŇŇŇŇŅŃķķķķķĹĹĻįįįĹ" local latinCompressed1 = "Ā255ā29Ā10ń4ł6Ā12ĺ2ĸ3Ī2Ĩ4Ā11ā34Ā11Ģ3Ĭ2Ī2Ĩ3Ħ1Ě5Ā11Ķ5ł1ń15Ņ2Ń6ō4ř2ť1ŧ3ũ6ś2ř2ŗ3ř1ś2ő9ŏ3Ł2Ŀ1ŋ5ō1Ņ3Ň2Ņ2Ń2Ł3Ń1Ņ2Ň5Ņ1Ń1ķ5Ĺ2Ļ1į3Ĺ1" assert(latinCompressed1 == InputCompression.compressInputString(latinUncompressed1)) -assert(latinUncompressed1 == InputCompression.decompressInputString(latinCompressed1)) +assert(latinCompressed1 == InputCompression.compressInputTable(string.toCharTable(latinUncompressed1))) +assert(latinUncompressed1 == InputCompression.decompressInputString2(latinCompressed1)) +assert(latinUncompressed1 == InputCompression.decompressInputString2(latinUncompressed1)) local latinCompressed2 = "Ā96Ğ1Ĝ3Ğ4Ġ17Ā62Ĩ7Ī10Ĩ1Ĝ3Ě3Ĝ2Ğ2Ġ2Ģ3Ĥ6Ā34Ħ3Ĩ2Ī1Ĭ5Ā37Ğ2Ġ5Ā40İ5Į5Ā53Ć3Ĉ4Ċ6Ā81Ĕ2Ė3Ę3Ā43Ę3Ė6Ā34ļ3ĺ3ĸ3Ā16ā74Ā22ŀ6ł5Ā38Ġ4Ğ4Ā28Ĩ4Ī4Ĭ2Ā47Ĭ2Ī8Ā27Ĭ5Ī8Ā42Ī4Ā5Ī4Ā5Ī3Ā5Ī3Ā80Ď3Đ2Ā22Ć2Ą5Ā69Ģ3Ġ2Ğ1Ĝ2Ě4Ā29Ē4Đ2Ď6Ā41Ē5Đ2Ď5Ā20Ą3Ă5Ā11ā21Ā23Ķ1Ĵ2IJ6Ā63Ĵ2Ķ1ĸ1ĺ7Ā39ĺ3ļ9Ā82Ċ2Ĉ7Ā24Ē2Ĕ5Ġ2Ā45Ģ3Ġ6Ā21ĺ6ĸ6Ā81Ğ11Ġ4Ā22Ĕ5Ē6Ā16Ė2Ĕ6Ā51Ē4Ĕ5Ā62Ĝ4Ğ2Ġ4Ā52Ģ2Ĕ4Ā36Ĥ5Ģ5Ā38Ĝ5Ğ1Ġ3Ā23Ģ6Ġ4Ā23Ę5Ė4Ĕ2Ā16ā161" local latinUncompressed2 = "ĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĜĜĜĞĞĞĞĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĨĨĨĪĪĪĪĪĪĪĪĪĪĨĜĜĜĚĚĚĜĜĞĞĠĠĢĢĢĤĤĤĤĤĤĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĦĦĦĨĨĪĬĬĬĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀİİİİİĮĮĮĮĮĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĆĈĈĈĈĊĊĊĊĊĊĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĖĖĖĘĘĘĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĖĖĖĖĖĖĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀļļļĺĺĺĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀŀŀŀŀŀŀłłłłłĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĠĠĠĠĞĞĞĞĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĨĨĨĨĪĪĪĪĬĬĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĬĬĬĬĬĪĪĪĪĪĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĪĪĪĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĎĎĎĐĐĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĆĆĄĄĄĄĄĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĞĜĜĚĚĚĚĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĐĐĎĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĒĐĐĎĎĎĎĎĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĄĄĄĂĂĂĂĂĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĶĴĴIJIJIJIJIJIJĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĴĴĶĸĺĺĺĺĺĺĺĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺļļļļļļļļļĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĊĊĈĈĈĈĈĈĈĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĔĔĔĔĔĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĠĠĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĺĺĺĺĺĺĸĸĸĸĸĸĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĞĞĞĞĞĞĞĞĞĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĔĔĔĔĔĒĒĒĒĒĒĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĖĖĔĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĒĒĒĒĔĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĞĞĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĔĔĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĤĤĤĤĤĢĢĢĢĢĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĜĜĜĜĜĞĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĢĢĢĢĢĢĠĠĠĠĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĘĘĘĘĘĖĖĖĖĔĔĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀĀāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā" assert(latinCompressed2 == InputCompression.compressInputString(latinUncompressed2)) -assert(latinUncompressed2 == InputCompression.decompressInputString(latinCompressed2)) +assert(latinCompressed2 == InputCompression.compressInputTable(string.toCharTable(latinUncompressed2))) +assert(latinUncompressed2 == InputCompression.decompressInputString2(latinCompressed2)) +assert(latinUncompressed2 == InputCompression.decompressInputString2(latinUncompressed2)) local replayCompressed3 = "A120E3k5g5k5g6k5g23i4g6k5g9i6o7(5)g3h4x1h1g1k5g1w1g4k7g1h8g8w1g6i13k6g4k3(1)" local replayUncompressed3 = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEEEkkkkkgggggkkkkkggggggkkkkkgggggggggggggggggggggggiiiiggggggkkkkkgggggggggiiiiiiooooooo5ggghhhhxhgkkkkkgwggggkkkkkkkghhhhhhhhggggggggwggggggiiiiiiiiiiiiikkkkkkggggkkk1" local replay3Compressed = InputCompression.compressInputString(replayUncompressed3) assert(replay3Compressed == replayCompressed3) -local replay3Uncompressed = InputCompression.decompressInputString(replayCompressed3) --- this assert fails!!! See issue 661 +assert(replayCompressed3 == InputCompression.compressInputTable(string.toCharTable(replayUncompressed3))) +-- this fails for the first decompression function!!! See issue 661 --assert(replayCompressed3 ~= replay3Uncompressed) +replay3Uncompressed = InputCompression.decompressInputString2(replayCompressed3) +assert(replayCompressed3 ~= replay3Uncompressed) +assert(replayUncompressed3 == InputCompression.decompressInputString2(replayUncompressed3)) -- intended number compression @@ -38,4 +52,15 @@ local replayUncompressed4 = "AAA4445214AA" local replayCompressed4 = "A3(444)(5)(2)(1)(4)A2" assert(InputCompression.compressInputString(replayUncompressed4) == replayCompressed4) -assert(replayUncompressed4 == InputCompression.decompressInputString(replayCompressed4)) \ No newline at end of file +assert(replayCompressed4 == InputCompression.compressInputTable(string.toCharTable(replayUncompressed4))) +assert(replayUncompressed4 == InputCompression.decompressInputString2(replayUncompressed4)) + + +-- testing codepoint boundaries +local replayUncompressed5 = "+++////AAAAZZZZaaaaaazzzzz10110000999A" +local replayCompressed5 = "+3/4A4Z4a6z5(1)(0)(11)(0000)(999)A1" + +assert(InputCompression.compressInputString(replayUncompressed5) == replayCompressed5) +assert(replayCompressed5 == InputCompression.compressInputTable(string.toCharTable(replayUncompressed5))) +assert(InputCompression.decompressInputString2(replayCompressed5) == replayUncompressed5) +assert(replayUncompressed5 == InputCompression.decompressInputString2(replayUncompressed5)) \ No newline at end of file diff --git a/common/tests/engine/StackReplayTests.lua b/common/tests/engine/StackReplayTests.lua index cd2b2dd8c..466588567 100644 --- a/common/tests/engine/StackReplayTests.lua +++ b/common/tests/engine/StackReplayTests.lua @@ -375,6 +375,7 @@ local function platformTest(waitFrames, useMatchSide) local puzzle = Puzzle("chain", false, 0, "3000994339949999994999999999999999999999999999999999", 60, 0) local match = StackReplayTestingUtils.createSinglePlayerMatch(puzzle:toGameMode(), puzzle:toPanelSource(), "controller", LevelPresets.getModern(10)) local stack = match.stacks[1] + ---@cast stack Stack assert(stack.panels[8][3].color == 4, "wrong color") assert(stack.panels[8][4].color == 3, "wrong color") @@ -387,7 +388,7 @@ local function platformTest(waitFrames, useMatchSide) end compressedInputs = compressedInputs .. "Q1A80" -- do the platform, and wait for the chain - local fullInputs = InputCompression.decompressInputString(compressedInputs) + local fullInputs = InputCompression.decompressInputString2(compressedInputs) stack:receiveConfirmedInput(fullInputs) -- make the clear and then do the platform assert(#stack.confirmedInput > stack.clock) StackReplayTestingUtils:fullySimulateMatch(match) diff --git a/server/Game.lua b/server/Game.lua index 4033b1c22..b585da5c6 100644 --- a/server/Game.lua +++ b/server/Game.lua @@ -129,9 +129,10 @@ function Game:getPartialReplay(compressInputs) for i, stack in ipairs(self.replay.stacks) do if stack.stackType == 1 then ---@cast stack ReplayStack - stack.inputs = table.concat(self.inputs[i]) if compressInputs then - stack.inputs = InputCompression.compressInputString(stack.inputs) + stack.inputs = InputCompression.compressInputTable(self.inputs[i]) + else + stack.inputs = table.concat(self.inputs[i]) end end end @@ -194,9 +195,10 @@ function Game:finalizeReplay(result) for i, stack in ipairs(self.replay.stacks) do if stack.stackType == 1 then ---@cast stack ReplayStack - stack.inputs = table.concat(self.inputs[i]) if COMPRESS_REPLAYS_ENABLED then - stack.inputs = InputCompression.compressInputString(stack.inputs) + stack.inputs = InputCompression.compressInputTable(self.inputs[i]) + else + stack.inputs = table.concat(self.inputs[i]) end end end diff --git a/testLauncher.lua b/testLauncher.lua index b5f0c8cfb..be12857a4 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -40,18 +40,6 @@ function love.load() end local tests = { - "server.tests.ServerTests", - "server.tests.LeaderboardTests", - "server.tests.RoomTests", - "server.tests.LoginTests", - "client.tests.FileUtilsTests", - "client.tests.ModControllerTests", - "client.tests.QueueTests", - "client.tests.ServerQueueTests", - "client.tests.SoundGroupTests", - "client.tests.TcpClientTests", - "client.tests.ThemeTests", - "client.tests.StackGraphicsTests", "common.tests.engine.PanelGenTests", "common.tests.engine.HealthTests", "common.tests.engine.RollbackBufferTests", @@ -71,6 +59,18 @@ local tests = { "common.tests.network.NetworkProtocolTests", "common.tests.network.TouchDataEncodingTests", "common.tests.data.InputCompressionTests", + "server.tests.ServerTests", + "server.tests.LeaderboardTests", + "server.tests.RoomTests", + "server.tests.LoginTests", + "client.tests.FileUtilsTests", + "client.tests.ModControllerTests", + "client.tests.QueueTests", + "client.tests.ServerQueueTests", + "client.tests.SoundGroupTests", + "client.tests.TcpClientTests", + "client.tests.ThemeTests", + "client.tests.StackGraphicsTests", } local updateCount = 0 From b17c8e2f13dc77144666a43190449623703a5781 Mon Sep 17 00:00:00 2001 From: Endaris Date: Sun, 29 Jun 2025 00:38:37 +0200 Subject: [PATCH 08/16] disable WigglePay for move puzzles --- common/engine/Puzzle.lua | 2 ++ common/engine/WigglePay.lua | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/common/engine/Puzzle.lua b/common/engine/Puzzle.lua index e15fbe157..6fcb47cfb 100644 --- a/common/engine/Puzzle.lua +++ b/common/engine/Puzzle.lua @@ -246,6 +246,8 @@ function Puzzle:toGameMode() mode.matchRules.stackSetupModifications.behaviours = { allowManualRaise = false, passiveRaise = false, + swapStallingMode = 0, + swapStallingPunish = 0, } if self.puzzleType == "chain" then mode.matchRules.stackOverConditions[MatchRules.StackOverConditions.CHAIN] = false diff --git a/common/engine/WigglePay.lua b/common/engine/WigglePay.lua index 0a0b0e90a..74e894052 100644 --- a/common/engine/WigglePay.lua +++ b/common/engine/WigglePay.lua @@ -1,5 +1,17 @@ +--[[ + WigglePay is named after a move that was coined by the community as "wiggling". + To wiggle means to chain swaps so rapidly that the passive raise of the stack is fully halted. + While physically intense, players with the required talents could stall death for several seconds without actually interacting with the stack. + Even more talented players could even move during a wiggle, thus potentially reaching solves they could not have with their legitimate invincibility frames alone. + This module adds functions that attach a health cost to each swap of a wiggle if and only if a wiggle is used to stall death (rather than a stack raise) + Notably swaps are only considered part of a wiggle if they repeat a swap between two panels that were already swapped to escape death once. + In that scenario, a set amount of health (swapStallingPunish) is subtracted from the Stack's health provided the player health would still be at least 1 afterwards. + If there is not enough health, the swap is denied instead. +]] local WigglePay = {} +---@param stack Stack +---@return boolean # if the stack is in a state where swaps are the only thing keeping the player alive function WigglePay.isActive(stack) if stack.behaviours.swapStallingMode == 0 then return false From fba88b9c92b28cc6a9555b0d0b2ffcf6f28ab15a Mon Sep 17 00:00:00 2001 From: Endaris Date: Sun, 29 Jun 2025 02:41:19 +0200 Subject: [PATCH 09/16] fix a possible issue with rewind in relation to wigglepay --- common/engine/WigglePay.lua | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/common/engine/WigglePay.lua b/common/engine/WigglePay.lua index 74e894052..458119a1e 100644 --- a/common/engine/WigglePay.lua +++ b/common/engine/WigglePay.lua @@ -45,7 +45,13 @@ function WigglePay.canSwap(stack, panel1, panel2) local row = stack.cur_row local col = stack.cur_col for _, oldRecord in ipairs(stack.swapStallingBackLog) do - if oldRecord.leftId == panel1.id and oldRecord.rightId == panel2.id and oldRecord.row == row and oldRecord.col == col then + if oldRecord.clock >= stack.clock then + -- backLog is in timely sequence and saves the clock time of the record + -- with this check, rollback does not need to be considered: + -- in live play a garbage drop from the rollback implies WigglePay is not active (because garbage can only fall when not topped out) so it would naturally reset during resimulation + -- if there is no garbage drop after the rollback, the rerun is deterministic and will arrive at the same result already in the backlog + return true, 0 + elseif oldRecord.leftId == panel1.id and oldRecord.rightId == panel2.id and oldRecord.row == row and oldRecord.col == col then if stack.health > stack.behaviours.swapStallingPunish then return true, stack.behaviours.swapStallingPunish else @@ -64,11 +70,13 @@ end function WigglePay.registerSwap(stack, panel1, panel2, healthCost) if WigglePay.isActive(stack) then if healthCost == 0 then - local newRecord = { leftId = panel1.id, rightId = panel2.id, row = stack.cur_row, col = stack.cur_col } - -- mark the reverse swap of the swap initiated just now - stack.swapStallingBackLog[#stack.swapStallingBackLog+1] = { leftId = newRecord.rightId, rightId = newRecord.leftId, row = stack.cur_row, col = stack.cur_col } - -- and the swap itself so it's already marked in case the reverse swap happens and logic stays simple for when data is added - stack.swapStallingBackLog[#stack.swapStallingBackLog+1] = newRecord + if not stack:behindRollback() then + local newRecord = { leftId = panel1.id, rightId = panel2.id, row = stack.cur_row, col = stack.cur_col, clock = stack.clock } + -- mark the reverse swap of the swap initiated just now + stack.swapStallingBackLog[#stack.swapStallingBackLog+1] = { leftId = newRecord.rightId, rightId = newRecord.leftId, row = stack.cur_row, col = stack.cur_col, clock = stack.clock } + -- and the swap itself so it's already marked in case the reverse swap happens and logic stays simple for when data is added + stack.swapStallingBackLog[#stack.swapStallingBackLog+1] = newRecord + end else stack.health = stack.health - healthCost end From 5a49b667cdaaf044407e8365e7e0eb7ca8bfa0dd Mon Sep 17 00:00:00 2001 From: Endaris Date: Mon, 30 Jun 2025 22:25:13 +0200 Subject: [PATCH 10/16] adjust wiggle in puzzle fix to disable the wigglecheck on all puzzles, not just move puzzles --- common/engine/Puzzle.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/engine/Puzzle.lua b/common/engine/Puzzle.lua index 6fcb47cfb..a77f04eb1 100644 --- a/common/engine/Puzzle.lua +++ b/common/engine/Puzzle.lua @@ -246,8 +246,6 @@ function Puzzle:toGameMode() mode.matchRules.stackSetupModifications.behaviours = { allowManualRaise = false, passiveRaise = false, - swapStallingMode = 0, - swapStallingPunish = 0, } if self.puzzleType == "chain" then mode.matchRules.stackOverConditions[MatchRules.StackOverConditions.CHAIN] = false @@ -257,6 +255,7 @@ function Puzzle:toGameMode() end end + mode.matchRules.stackSetupModifications.behaviours.swapStallingMode = 0 mode.matchRules.doCountdown = self.doCountdown return mode From a661326b4cb70634523972de54e8ddf4aa0b600c Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sat, 25 Oct 2025 11:00:38 -0700 Subject: [PATCH 11/16] Refactor game mode system to fix level data synchronization (#713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #698 Various problems existed with the style system and classic endless mode and updating settings. The style wasn't updated all the time, and it wasn't preserved when opening the game again. In addition style settings could carry over to other game types. The flow is now this: UI changes → setLevelData() → levelDataChanged signal → auto-updates style/difficulty/level/UI/config Each character select subclass knows what difficulty / level / style mean and update accordingly. The server doesn't need to know about style at all. * LevelPresets.lua : Added classicEndless presets (5 colors for easy), getStyleAndPreset() to detect which preset a levelData matches This lets us support more presets later and update the UI to show something reasonable when level data doesn't match a preset. * Convert GameMode to proper class with methods * Separate UI style preference from changing level data * Also fixed a challenge mode JSON serialization crash by using our JSON sanitizer again. --- client/src/BattleRoom.lua | 67 ++++-------- client/src/ClientMatch.lua | 8 +- client/src/Game.lua | 34 +++--- client/src/Player.lua | 44 +++----- client/src/network/NetClient.lua | 1 + client/src/scenes/CharacterSelect.lua | 67 ++++++++---- client/src/scenes/CharacterSelectVsSelf.lua | 13 ++- client/src/scenes/EndlessGame.lua | 2 +- client/src/scenes/EndlessMenu.lua | 61 ++++++----- client/src/scenes/ReplayBrowser.lua | 6 +- client/src/scenes/TimeAttackGame.lua | 2 +- client/src/scenes/TimeAttackMenu.lua | 62 ++++++----- client/src/scenes/VsSelfGame.lua | 4 +- client/src/ui/Grid.lua | 8 +- client/tests/PlayerSettingsTests.lua | 108 ++++++++++++++++++++ client/tests/StackGraphicsTests.lua | 2 +- common/data/GameModes.lua | 107 +++++++++++++------ common/data/LevelPresets.lua | 68 ++++++++++++ common/engine/Health.lua | 5 +- common/lib/util.lua | 4 +- common/network/ClientProtocol.lua | 4 +- server/Game.lua | 2 + server/Room.lua | 4 +- server/tests/ServerTests.lua | 2 +- testLauncher.lua | 1 + 25 files changed, 464 insertions(+), 222 deletions(-) create mode 100644 client/tests/PlayerSettingsTests.lua diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 95b2a7f8e..5d1f16432 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -15,7 +15,7 @@ local GeneratorSource = require("common.engine.GeneratorSource") -- A Battle Room is a session of matches, keeping track of the room number, player settings, wins / losses etc ---@class BattleRoom : Signal ----@field mode GameMode +---@field mode GameMode The game mode configuration defining rules, player count, and match settings for this battle room ---@field players Player[] ---@field spectators string[] ---@field spectating boolean @@ -59,7 +59,8 @@ end) BattleRoom.states = { Setup = 1, MatchInProgress = 2 } function BattleRoom.createFromServerMessage(message) - local battleRoom = BattleRoom(message.gameMode) + local gameMode = GameModes.createFromServerData(message.gameMode) + local battleRoom = BattleRoom(gameMode) if message.spectate_request_granted then logger.debug("Joining a match as spectator") @@ -108,19 +109,8 @@ function BattleRoom.createFromServerMessage(message) p = Player(player.name, player.publicId or -i, false) end - -- order is important here as setting style will indirectly also override levelData so it needs to be before updateSettings - if gameMode.style ~= GameModes.Styles.CHOOSE then - p:setStyle(gameMode.style) - else - if player.settings.levelData then - if player.settings.levelData.frameConstants.GARBAGE_HOVER then - p:setStyle(GameModes.Styles.MODERN) - else - p:setStyle(GameModes.Styles.CLASSIC) - end - end - end - + -- updateSettings will set levelData which triggers levelDataChanged signal + -- which will automatically update style based on the levelData p:updateSettings(player.settings) if player.ratingInfo then @@ -141,30 +131,33 @@ function BattleRoom.createFromServerMessage(message) return battleRoom end -function BattleRoom.createLocalFromGameMode(gameMode, gameScene) +-- Creates a local (offline) BattleRoom from a GameMode configuration. +-- For single-player modes, uses the game's main local player. For multi-player modes, +-- creates temporary local players that don't persist settings changes. +---@param gameMode GameMode The game mode configuration defining rules and player count +---@param gameScene table? Optional scene class to use for matches (defaults to mode's gameScene) +---@param settingChangesUpdateConfig boolean? If true, setting changes update config (default: true). Only applies to single-player modes. +---@return BattleRoom? battleRoom The created battle room, or nil if input configuration assignment fails +function BattleRoom.createLocalFromGameMode(gameMode, gameScene, settingChangesUpdateConfig) + if settingChangesUpdateConfig == nil then + settingChangesUpdateConfig = true + end + local battleRoom = BattleRoom(gameMode, gameScene) - if gameMode.playerCount == 1 then + if settingChangesUpdateConfig and gameMode.playerCount == 1 then -- always use the game client's local player battleRoom:addPlayer(GAME.localPlayer) else -- with more than 1 local player we can't be sure which player is the "real" regular user -- so make them both local players that don't update config settings for i = 1, gameMode.playerCount do - local player = Player.getLocalPlayer() + local player = Player.createLocalPlayerFromConfig() player.name = loc("player_n", i) battleRoom:addPlayer(player) end end - if gameMode.style ~= GameModes.Styles.CHOOSE then - for i, player in ipairs(battleRoom.players) do - if player.human then - battleRoom.players[i]:setStyle(gameMode.style) - end - end - end - if battleRoom:assignInputConfigurations() then return battleRoom else @@ -404,28 +397,6 @@ function BattleRoom:createScene(match) end end --- sets the style of "level" presets the players select from --- 1 = classic --- 2 = modern --- longterm we want to abandon the concept of "style" on the player / battleRoom level --- just setting difficulty or level should set the levelData and done with it, style is a menu-only concept --- there is no technical reason why someone on level 10 shouldn't be able to play against someone on Hard --- for now it's a battleRoom wide setting and players have to match -function BattleRoom:setStyle(styleChoice) - -- style could be configurable per play instead but let's not for now - if self.mode.style == GameModes.Styles.CHOOSE then - self.style = styleChoice - self.onStyleChanged(styleChoice) - else - error("Trying to set difficulty style in a game mode that doesn't support style selection") - end -end - --- not player specific, so this gets a separate callback that can only be overwritten once --- so the UI can update and load up the different controls for it -function BattleRoom.onStyleChanged(style, player) -end - function BattleRoom:startLoadingNewAssets() if ModLoader.loading_mod == nil then for _, player in ipairs(self.players) do diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index 67ae77946..bf71af747 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -395,10 +395,10 @@ function ClientMatch:finalizeReplay() ---@cast player Player metadata.name = player.name metadata.publicId = player.publicId - if player.settings.style == GameModes.Styles.MODERN then - metadata.level = player.settings.level - else - metadata.difficulty = player.settings.difficulty + if stack.level then + metadata.level = stack.level + elseif stack.difficulty then + metadata.difficulty = stack.difficulty end metadata.analytics = player.stack.analytic.data ---@diagnostic disable-next-line: inject-field diff --git a/client/src/Game.lua b/client/src/Game.lua index c8052bfb0..1952ff0c4 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -11,6 +11,7 @@ require("client.src.mods.Theme") -- Not to be confused with "Match" which is the current battle / instance of the game. local consts = require("common.engine.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") +local LevelPresets = require("common.data.LevelPresets") local class = require("common.lib.class") local logger = require("common.lib.logger") local analytics = require("client.src.analytics") @@ -257,7 +258,7 @@ end -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate -- it basically represents the player that is operating the client (and thus binds to its configuration) function Game:initializeLocalPlayer() - self.localPlayer = Player.getLocalPlayer() + self.localPlayer = Player.createLocalPlayerFromConfig() self.localPlayer:connectSignal("selectedCharacterIdChanged", config, function(config, newId) config.character = newId end) self.localPlayer:connectSignal("selectedStageIdChanged", config, function(config, newId) config.stage = newId end) self.localPlayer:connectSignal("panelIdChanged", config, function(config, newId) config.panels = newId end) @@ -266,11 +267,17 @@ function Game:initializeLocalPlayer() self.localPlayer:connectSignal("difficultyChanged", config, function(config, difficulty) config.endless_difficulty = difficulty end) self.localPlayer:connectSignal("levelChanged", config, function(config, level) config.level = level end) self.localPlayer:connectSignal("wantsRankedChanged", config, function(config, wantsRanked) config.ranked = wantsRanked end) - self.localPlayer:connectSignal("styleChanged", config, function(config, style) - if style == GameModes.Styles.CLASSIC then - config.endless_level = nil - else - config.endless_level = config.level + + self.localPlayer:connectSignal("levelDataChanged", config, function(config, levelData, player) + local presetInfo = LevelPresets.getStyleAndPreset(levelData) + if presetInfo then + if presetInfo.style == GameModes.Styles.MODERN then + config.level = presetInfo.level + config.endless_level = presetInfo.level + else + config.endless_difficulty = presetInfo.difficulty + config.endless_level = nil + end end end) end @@ -305,21 +312,6 @@ function Game:createDirectoriesIfNeeded() end end -function Game:runUnitTests() - coroutine.yield("Running Unit Tests") - - -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate - -- basically the player that is operating the client - GAME.localPlayer = Player.getLocalPlayer() - -- we need to overwrite the local player as all replay related tests need a non-local player - GAME.localPlayer.isLocal = false - - logger.info("Running Unit Tests...") - GAME.muteSound = true - --require("client.tests.Tests") - SoundController:applyConfigVolumes() -end - function Game:runPerformanceTests() coroutine.yield("Running Performance Tests") require("tests.StackReplayPerformanceTests") diff --git a/client/src/Player.lua b/client/src/Player.lua index a7711081d..375b5866e 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -107,12 +107,19 @@ function Player:createClientStack(engineStack) player = self, } - if self.settings.style == GameModes.Styles.MODERN then - args.level = self.settings.level - else - args.difficulty = self.settings.difficulty + local presetInfo = LevelPresets.getStyleAndPreset(self.settings.levelData) + if presetInfo then + if presetInfo.style == GameModes.Styles.MODERN then + if presetInfo.level then + args.level = self.settings.level + end + else + if presetInfo.difficulty then + args.difficulty = self.settings.difficulty + end + end end - + self.stack = PlayerStack(args) return self.stack @@ -144,7 +151,7 @@ end function Player:setLevelData(levelData) self.settings.levelData = levelData self:setSpeed(levelData.startingSpeed) - self:emitSignal("levelDataChanged", levelData) + self:emitSignal("levelDataChanged", levelData, self) end function Player:setSpeed(speed) @@ -172,18 +179,11 @@ end -- sets the style of "level" presets the player selects from -- 1 = classic -- 2 = modern --- longterm we want to abandon the concept of "style" on the player / battleRoom level --- just setting difficulty or level should set the levelData and done with it, style is a menu-only concept --- there is no technical reason why someone on level 10 shouldn't be able to play against someone on Hard +-- style is a menu-only concept for UI display +-- derived settings (levelData) should be updated by calling gameMode.updateLocalPlayersDerivedSettings function Player:setStyle(style) if style ~= self.settings.style then self.settings.style = style - if style == GameModes.Styles.MODERN then - self:setLevelData(LevelPresets.getModern(self.settings.level or config.level)) - else - self:setLevelData(LevelPresets.getClassic(self.settings.difficulty or config.endless_difficulty)) - self:setSpeed(self.settings.speed) - end self:emitSignal("styleChanged", style) end end @@ -233,7 +233,7 @@ function Player:unrestrictInputs() end ---@return Player -function Player.getLocalPlayer() +function Player.createLocalPlayerFromConfig() local player = Player(config.name, -1, true) player:setDifficulty(config.endless_difficulty) @@ -246,10 +246,8 @@ function Player.getLocalPlayer() player:setInputMethod(config.inputMethod) if config.endless_level then player:setStyle(GameModes.Styles.MODERN) - player:setLevelData(LevelPresets.getModern(player.settings.level)) else player:setStyle(GameModes.Styles.CLASSIC) - player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) player:setSpeed(config.endless_speed) end @@ -325,16 +323,6 @@ function Player:updateSettings(settings) end if settings.levelData ~= nil then - if settings.level ~= nil then - if settings.levelData.frameConstants.GARBAGE_HOVER then - self:setStyle(GameModes.Styles.MODERN) - self:setLevel(settings.level) - elseif settings.level <= LevelPresets.classicPresetCount then - self:setStyle(GameModes.Styles.CLASSIC) - self:setDifficulty(settings.level) - end - end - self:setLevelData(settings.levelData) end diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 4bb83eab1..d785a431c 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -458,6 +458,7 @@ function NetClient:requestSpectate(roomNumber) end end +---@param gameMode GameMode function NetClient:requestRoom(gameMode) if self:isConnected() then self.tcpClient:sendRequest(ClientMessages.sendRoomRequest(gameMode)) diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index f41efaf34..8ceac0802 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -4,11 +4,11 @@ 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") -- The character select screen scene ---@class CharacterSelect : Scene @@ -57,7 +57,47 @@ function CharacterSelect:load() self.ui.cursors = {} self.ui.characterIcons = {} self.ui.playerInfos = {} + self:customLoad() + + for _, player in ipairs(self.players) do + if player:isHuman() then + if player.isLocal then + self:initializeFromLocalPlayerSettings(player) + end + player:connectSignal("levelDataChanged", self, self.onLevelDataChanged) + self:onLevelDataChanged(player.settings.levelData, player) + end + end +end + +function CharacterSelect:onLevelDataChanged(levelData, player) + local presetInfo = LevelPresets.getStyleAndPreset(levelData) + + if not presetInfo then + -- Custom levelData, default to current settings + return + end + + if presetInfo.style == GameModes.Styles.MODERN then + player:setStyle(GameModes.Styles.MODERN) + if presetInfo.level then + player:setLevel(presetInfo.level) + end + else + player:setStyle(GameModes.Styles.CLASSIC) + if presetInfo.difficulty then + player:setDifficulty(presetInfo.difficulty) + end + end + + self:refresh() +end + +function CharacterSelect:initializeFromLocalPlayerSettings(player) + player:setStyle(GameModes.Styles.MODERN) + player:setLevel(player.settings.level) + player:setLevelData(LevelPresets.getModern(player.settings.level)) end ---@param player Player @@ -893,7 +933,7 @@ function CharacterSelect:createSpeedSlider(player, height, min) return uiElement end -function CharacterSelect:createDifficultyCarousel(player, height) +function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) local passengers = { { id = 1, uiElement = ui.Label({text = "easy", vAlign = "center", hAlign = "center"})}, { id = 2, uiElement = ui.Label({text = "normal", vAlign = "center", hAlign = "center"})}, @@ -918,25 +958,16 @@ function CharacterSelect:createDifficultyCarousel(player, height) -- Just update on every passenger change end - local updateDifficultyData = function(difficultyID) - local levelData = LevelPresets.getClassic(difficultyID) - player:setDifficulty(difficultyID) - if self.battleRoom.mode.name == "endless" and difficultyID == 1 then - -- Endless easy uses 5 colors instead of 6 - levelData:setColorCount(5) - -- and by extension also allows adjacent panels of the same colors - levelData:setAdjacentDenialFrequency(0) - end - player:setLevelData(levelData) - end difficultyCarousel.onPassengerUpdateCallback = function(carousel, selectedPassenger) - updateDifficultyData(selectedPassenger.id) + player:setDifficulty(selectedPassenger.id) + if getPresetFunc then + player:setLevelData(getPresetFunc(selectedPassenger.id)) + end GAME.theme:playMoveSfx() - self:refresh() end - -- Note that this updates the player level data which could be wrong before because of the weird endless case - -- its probably fine for now, but ideally the model should be right when the battle room is created - updateDifficultyData(difficultyCarousel.selectedId) + + -- to update the UI if code gets changed from the backend (e.g. network messages) + player:connectSignal("difficultyChanged", difficultyCarousel, difficultyCarousel.setPassengerById) return difficultyCarousel end diff --git a/client/src/scenes/CharacterSelectVsSelf.lua b/client/src/scenes/CharacterSelectVsSelf.lua index dda09e6e3..59a22c562 100644 --- a/client/src/scenes/CharacterSelectVsSelf.lua +++ b/client/src/scenes/CharacterSelectVsSelf.lua @@ -85,14 +85,13 @@ function CharacterSelectVsSelf:refresh() local level if self.battleRoom then level = self.battleRoom.players[1].settings.level - else - level = GAME.localPlayer.settings.level + self.lastScore = GAME.scores:lastVsScoreForLevel(level) + self.record = GAME.scores:recordVsScoreForLevel(level) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end end - - self.lastScore = GAME.scores:lastVsScoreForLevel(level) - self.record = GAME.scores:recordVsScoreForLevel(level) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) end return CharacterSelectVsSelf \ No newline at end of file diff --git a/client/src/scenes/EndlessGame.lua b/client/src/scenes/EndlessGame.lua index 6f8b0ea91..f8134d49e 100644 --- a/client/src/scenes/EndlessGame.lua +++ b/client/src/scenes/EndlessGame.lua @@ -17,7 +17,7 @@ end ---@param match ClientMatch function EndlessGame:onMatchEnded(match) - if match.players[1].settings.style == GameModes.Styles.CLASSIC then + if match.players[1].stack.difficulty then GAME.scores:saveEndlessScoreForLevel(match.players[1].stack.engine.score, match.players[1].stack.difficulty) end end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index 626c9c600..92bd9d967 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -1,6 +1,7 @@ local CharacterSelect = require("client.src.scenes.CharacterSelect") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local ui = require("client.src.ui") -- Scene for the endless game setup menu @@ -71,7 +72,7 @@ function EndlessMenu:loadUserInterface() }) self.ui.difficultySelection:setTitle("difficulty") - local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height) + local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height, LevelPresets.getClassicEndless) self.ui.difficultySelection:addElement(difficultyCarousel, player) self.ui.levelSelection = ui.MultiPlayerSelectionWrapper({hFill = true, alignment = "top", hAlign = "center", vAlign = "top"}) @@ -79,25 +80,14 @@ function EndlessMenu:loadUserInterface() local levelSlider = self:createLevelSlider(player, 20, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.levelSelection.height) self.ui.levelSelection:addElement(levelSlider, player) - if player.settings.style == GameModes.Styles.MODERN then - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - else - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - end - styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() - self.ui.grid:removeElementsIn(6, 2, 3, 1) if value and player.settings.style ~= GameModes.Styles.MODERN then - player:setStyle(GameModes.Styles.MODERN) - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - self.ui.recordBox:setVisibility(false) + -- Set levelData for modern style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getModern(player.settings.level)) elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then - player:setStyle(GameModes.Styles.CLASSIC) - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - self.ui.recordBox:setVisibility(true) + -- Set levelData for classic endless style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getClassicEndless(player.settings.difficulty)) end end @@ -124,19 +114,42 @@ function EndlessMenu:loadUserInterface() self.ui.cursors[1].raise2Callback = function() self.ui.characterGrid:turnPage(1) end + + player:connectSignal("styleChanged", self, self.onStyleChanged) + self:onStyleChanged(player.settings.style, player) +end + +function EndlessMenu:onStyleChanged(style, player) + if style == GameModes.Styles.MODERN then + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) + self.ui.recordBox:setVisibility(false) + else + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) + self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) + self.ui.recordBox:setVisibility(true) + end +end + +function EndlessMenu:initializeFromLocalPlayerSettings(player) + if player.settings.style == GameModes.Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + else + player:setLevelData(LevelPresets.getClassicEndless(player.settings.difficulty)) + end end function EndlessMenu:refresh() - local difficulty if self.battleRoom then - difficulty = self.battleRoom.players[1].settings.difficulty - else - difficulty = GAME.localPlayer.settings.difficulty + local difficulty = self.battleRoom.players[1].settings.difficulty + self.lastScore = GAME.scores:lastEndlessForLevel(difficulty) + self.record = GAME.scores:recordEndlessForLevel(difficulty) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end end - self.lastScore = GAME.scores:lastEndlessForLevel(difficulty) - self.record = GAME.scores:recordEndlessForLevel(difficulty) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) end return EndlessMenu \ No newline at end of file diff --git a/client/src/scenes/ReplayBrowser.lua b/client/src/scenes/ReplayBrowser.lua index 30d42ad3f..14f0c8d11 100644 --- a/client/src/scenes/ReplayBrowser.lua +++ b/client/src/scenes/ReplayBrowser.lua @@ -223,8 +223,10 @@ function ReplayBrowser:draw() if player.level then GraphicsUtil.print(loc("rp_browser_info_level", player.level), menu_x + offsetX, menu_y + 95) else - GraphicsUtil.print(loc("rp_browser_info_speed", stack.levelData.startingSpeed), menu_x + offsetX, menu_y + 95) - GraphicsUtil.print(loc("rp_browser_info_difficulty", player.difficulty), menu_x + offsetX, menu_y + 110) + if player.difficulty then + GraphicsUtil.print(loc("rp_browser_info_speed", stack.levelData.startingSpeed), menu_x + offsetX, menu_y + 95) + GraphicsUtil.print(loc("rp_browser_info_difficulty", player.difficulty), menu_x + offsetX, menu_y + 110) + end end else ---@cast player SimulatedStackMetadata diff --git a/client/src/scenes/TimeAttackGame.lua b/client/src/scenes/TimeAttackGame.lua index b604eb27d..9a8884255 100644 --- a/client/src/scenes/TimeAttackGame.lua +++ b/client/src/scenes/TimeAttackGame.lua @@ -17,7 +17,7 @@ end ---@param match ClientMatch function TimeAttackGame:onMatchEnded(match) - if match.players[1].settings.style == GameModes.Styles.CLASSIC then + if match.players[1].stack.difficulty then GAME.scores:saveTimeAttack1PScoreForLevel(match.players[1].stack.engine.score, match.players[1].stack.difficulty) end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 75904d97a..1ab39f816 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local CharacterSelect = require("client.src.scenes.CharacterSelect") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local ui = require("client.src.ui") -- Scene for the time attack game setup menu @@ -70,7 +71,7 @@ function TimeAttackMenu:loadUserInterface() }) self.ui.difficultySelection:setTitle("difficulty") - local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height) + local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height, LevelPresets.getClassic) self.ui.difficultySelection:addElement(difficultyCarousel, player) self.ui.levelSelection = ui.MultiPlayerSelectionWrapper({hFill = true, alignment = "top", hAlign = "center", vAlign = "top"}) @@ -78,25 +79,14 @@ function TimeAttackMenu:loadUserInterface() local levelSlider = self:createLevelSlider(player, 20, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.levelSelection.height) self.ui.levelSelection:addElement(levelSlider, player) - if player.settings.style == GameModes.Styles.MODERN then - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - else - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - end - styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() - self.ui.grid:removeElementsIn(6, 2, 3, 1) if value and player.settings.style ~= GameModes.Styles.MODERN then - player:setStyle(GameModes.Styles.MODERN) - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - self.ui.recordBox:setVisibility(false) + -- Set levelData for modern style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getModern(player.settings.level)) elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then - player:setStyle(GameModes.Styles.CLASSIC) - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - self.ui.recordBox:setVisibility(true) + -- Set levelData for classic style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) end end @@ -123,20 +113,42 @@ function TimeAttackMenu:loadUserInterface() self.ui.cursors[1].raise2Callback = function() self.ui.characterGrid:turnPage(1) end + + player:connectSignal("styleChanged", self, self.onStyleChanged) + self:onStyleChanged(player.settings.style, player) end -function TimeAttackMenu:refresh() - local difficulty - if self.battleRoom then - difficulty = self.battleRoom.players[1].settings.difficulty +function TimeAttackMenu:onStyleChanged(style, player) + if style == GameModes.Styles.MODERN then + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) + self.ui.recordBox:setVisibility(false) else - difficulty = GAME.localPlayer.settings.difficulty + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) + self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) + self.ui.recordBox:setVisibility(true) end +end - self.lastScore = GAME.scores:lastTimeAttack1PForLevel(difficulty) - self.record = GAME.scores:recordTimeAttack1PForLevel(difficulty) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) +function TimeAttackMenu:initializeFromLocalPlayerSettings(player) + if player.settings.style == GameModes.Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + else + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) + end +end + +function TimeAttackMenu:refresh() + if self.battleRoom then + local difficulty = self.battleRoom.players[1].settings.difficulty + self.lastScore = GAME.scores:lastTimeAttack1PForLevel(difficulty) + self.record = GAME.scores:recordTimeAttack1PForLevel(difficulty) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end + end end return TimeAttackMenu \ No newline at end of file diff --git a/client/src/scenes/VsSelfGame.lua b/client/src/scenes/VsSelfGame.lua index b3f7063b2..f8f91d64e 100644 --- a/client/src/scenes/VsSelfGame.lua +++ b/client/src/scenes/VsSelfGame.lua @@ -17,7 +17,9 @@ end ---@param match ClientMatch function VsSelfGame:onMatchEnded(match) local P1 = match.players[1].stack - GAME.scores:saveVsSelfScoreForLevel(P1.analytic.data.sent_garbage_lines, P1.level) + if P1.level then + GAME.scores:saveVsSelfScoreForLevel(P1.analytic.data.sent_garbage_lines, P1.level) + end end return VsSelfGame \ No newline at end of file diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index 8d1d1cbbd..2dfc7a2bf 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -91,8 +91,12 @@ function Grid:drawSelf() end end --- removes all gridElements overlapping with the specified box --- the box is top left anchored +--- removes all gridElements overlapping with the specified box +--- the box is top left anchored +---@param x integer +---@param y integer +---@param width integer +---@param height integer function Grid:removeElementsIn(x, y, width, height) height = height or 1 width = width or 1 diff --git a/client/tests/PlayerSettingsTests.lua b/client/tests/PlayerSettingsTests.lua new file mode 100644 index 000000000..680b0dfa7 --- /dev/null +++ b/client/tests/PlayerSettingsTests.lua @@ -0,0 +1,108 @@ +local Player = require("client.src.Player") +local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") +local CharacterSelect = require("client.src.scenes.CharacterSelect") +local BattleRoom = require("client.src.BattleRoom") + +local function testDifficultyCarouselShouldNotMutatePlayerOnCreation() + + local player = Player("TestPlayer", 1, true) + + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local battleRoom = BattleRoom(gameMode) + battleRoom:addPlayer(player) + + local characterSelect = CharacterSelect({battleRoom = battleRoom}) + + local originalLevel = player.settings.level + local originalDifficulty = player.settings.difficulty + local originalStyle = player.settings.style + local originalGarbageHover = player.settings.levelData.frameConstants.GARBAGE_HOVER + local originalColorCount = player.settings.levelData.colors + + local _ = characterSelect:createDifficultyCarousel(player, 100) + + assert(player.settings.level == originalLevel, "createDifficultyCarousel should not change level, but changed from " .. originalLevel .. " to " .. player.settings.level) + assert(player.settings.difficulty == originalDifficulty, "createDifficultyCarousel should not change difficulty, but changed from " .. tostring(originalDifficulty) .. " to " .. tostring(player.settings.difficulty)) + assert(player.settings.style == originalStyle, "createDifficultyCarousel should not change style, but changed from " .. originalStyle .. " to " .. player.settings.style) + assert(player.settings.levelData.frameConstants.GARBAGE_HOVER == originalGarbageHover, "createDifficultyCarousel should not change GARBAGE_HOVER, but changed from " .. tostring(originalGarbageHover) .. " to " .. tostring(player.settings.levelData.frameConstants.GARBAGE_HOVER)) + assert(player.settings.levelData.colors == originalColorCount, "createDifficultyCarousel should not change color count, but changed from " .. originalColorCount .. " to " .. player.settings.levelData.colors) + + battleRoom:shutdown() +end + +testDifficultyCarouselShouldNotMutatePlayerOnCreation() + +local function testEndlessModeClassicDifficulty1SetsCorrectSettings() + + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local battleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) + + assert(battleRoom ~= nil, "BattleRoom should be created successfully") + assert(#battleRoom.players == 1, "BattleRoom should have exactly 1 player") + + local battleRoomPlayer = battleRoom.players[1] + + battleRoomPlayer:setLevelData(LevelPresets.getClassicEndless(1)) + + assert(battleRoomPlayer.settings.levelData.colors == 5, + "Endless mode with classic difficulty 1 should have 5 colors, but got " .. + tostring(battleRoomPlayer.settings.levelData.colors)) + + assert(battleRoomPlayer.settings.levelData.adjacentDenialFrequency == 0, + "Endless mode with classic difficulty 1 should have adjacent denial frequency of 0, but got " .. + tostring(battleRoomPlayer.settings.levelData.adjacentDenialFrequency)) + + battleRoom:shutdown() +end + +testEndlessModeClassicDifficulty1SetsCorrectSettings() + +local function testVsSelfChangesEndlessClassicSettingsToModern() + -- First create an endless battle room with classic difficulty 1 + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local endlessBattleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) + + assert(endlessBattleRoom ~= nil, "Endless BattleRoom should be created successfully") + + local endlessPlayer = endlessBattleRoom.players[1] + + -- Set to classic difficulty 1 (simulating what happens in endless mode) + endlessPlayer:setLevelData(LevelPresets.getClassicEndless(1)) + + -- Verify endless settings + assert(endlessPlayer.settings.style == GameModes.Styles.CLASSIC, + "Player should have classic style before vs self") + assert(endlessPlayer.settings.levelData.colors == 5, + "Player should have 5 colors before vs self") + assert(endlessPlayer.settings.levelData.adjacentDenialFrequency == 0, + "Player should have adjacent denial frequency of 0 before vs self") + + endlessBattleRoom:shutdown() + + -- Now create a vs self battle room which should change settings to modern + local vsSelfGameMode = GameModes.getPreset("ONE_PLAYER_VS_SELF") + local vsSelfBattleRoom = BattleRoom.createLocalFromGameMode(vsSelfGameMode, nil, false) + + assert(vsSelfBattleRoom ~= nil, "Vs self BattleRoom should be created successfully") + + local vsSelfPlayer = vsSelfBattleRoom.players[1] + vsSelfPlayer:setLevelData(LevelPresets.getModern(10)) + + assert(vsSelfPlayer.settings.levelData.frameConstants.GARBAGE_HOVER ~= nil, + "Modern style should have GARBAGE_HOVER set") + + -- Modern levels have 6 colors (not 5 like endless classic difficulty 1) + assert(vsSelfPlayer.settings.levelData.colors == 6, + "Vs self should change color count to 6 (modern default), but got " .. + tostring(vsSelfPlayer.settings.levelData.colors)) + + -- Modern levels have non-zero adjacent denial frequency + assert(vsSelfPlayer.settings.levelData.adjacentDenialFrequency > 0, + "Vs self should have adjacent denial frequency > 0 (modern default), but got " .. + tostring(vsSelfPlayer.settings.levelData.adjacentDenialFrequency)) + + vsSelfBattleRoom:shutdown() +end + +testVsSelfChangesEndlessClassicSettingsToModern() diff --git a/client/tests/StackGraphicsTests.lua b/client/tests/StackGraphicsTests.lua index ddaa19a0d..09db7e0bc 100644 --- a/client/tests/StackGraphicsTests.lua +++ b/client/tests/StackGraphicsTests.lua @@ -26,7 +26,7 @@ local function createEndlessClientMatch(playerCount, theme) playerCount = 1 end for i = 1, playerCount do - local player = Player.getLocalPlayer() + local player = Player.createLocalPlayerFromConfig() player.isLocal = false player:setLevel(10) player:setLevelData(LevelPresets.getModern(10)) diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index 398ffbb77..2e4efbc70 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -1,3 +1,4 @@ +local class = require("common.lib.class") local MatchRules = require("common.data.MatchRules") local TIME_ATTACK_TIME = 120 @@ -13,6 +14,25 @@ local GameModes = {} ---@field gameScene string ---@field style Styles ---@field richPresenceLabel string? +---@field updateLocalPlayersDerivedSettings function +local GameMode = class(function(self, properties) + for key, value in pairs(properties) do + self[key] = value + end +end) + +-- Returns a copy of the game mode data suitable for JSON serialization +-- Removes all methods/functions from the copied data +---@return table +function GameMode:getGameModeJSONData() + local gameModeData = deepcpy(self) + for key, value in pairs(gameModeData) do + if type(value) == "function" then + gameModeData[key] = nil + end + end + return gameModeData +end -- longterm we want to abandon the concept of "style" on the engine and room setup level -- the engine only cares about levelData, style is a menu-only concept @@ -24,8 +44,7 @@ local Styles = { CHOOSE = 0, CLASSIC = 1, MODERN = 2} local StackInteractions = { NONE = 0, VERSUS = 1, SELF = 2, ATTACK_ENGINE = 3 } ---@type GameMode -local OnePlayerVsSelf = { - style = Styles.MODERN, +local OnePlayerVsSelf = GameMode({ gameScene = "VsSelfGame", richPresenceLabel = "1p vs self", -- loc("mm_1_vs"), name = "vsSelf", @@ -40,12 +59,12 @@ local OnePlayerVsSelf = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerTimeAttack = { - style = Styles.CHOOSE, +local OnePlayerTimeAttack = GameMode({ gameScene = "TimeAttackGame", richPresenceLabel = "Time Attack", -- loc("mm_1_time"), name = "timeattack", @@ -60,12 +79,12 @@ local OnePlayerTimeAttack = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerEndless = { - style = Styles.CHOOSE, +local OnePlayerEndless = GameMode({ gameScene = "EndlessGame", richPresenceLabel = "Endless", -- loc("mm_1_endless"), name = "endless", @@ -80,12 +99,12 @@ local OnePlayerEndless = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerTraining = { - style = Styles.MODERN, +local OnePlayerTraining = GameMode({ gameScene = "GameBase", richPresenceLabel = "Training", -- loc("mm_1_training"), name = "training", @@ -100,13 +119,13 @@ local OnePlayerTraining = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local OnePlayerPuzzle = { +local OnePlayerPuzzle = GameMode({ -- flags for battleRoom to evaluate and in some cases offer UI for - style = Styles.MODERN, richPresenceLabel = "Puzzle", -- loc("mm_1_puzzle"), gameScene = "PuzzleGame", name = "puzzle", @@ -124,12 +143,12 @@ local OnePlayerPuzzle = { -- these are extended based on the loaded puzzle stackSetupModifications = {}, doCountdown = false, - } -} + }, + +}) ---@type GameMode -local OnePlayerChallenge = { - style = Styles.MODERN, +local OnePlayerChallenge = GameMode({ gameScene = "Game1pChallenge", richPresenceLabel = "Challenge Mode", -- loc("mm_1_challenge_mode"), name = "challenge", @@ -144,12 +163,12 @@ local OnePlayerChallenge = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) ---@type GameMode -local TwoPlayerVersus = { - style = Styles.MODERN, +local TwoPlayerVersus = GameMode({ gameScene = "GameBase", richPresenceLabel = "2p versus", -- loc("mm_2_vs"), name = "VS", @@ -164,11 +183,11 @@ local TwoPlayerVersus = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true - } -} + }, + +}) ---@type GameMode -local TwoPlayerTimeAttack = { - style = Styles.MODERN, +local TwoPlayerTimeAttack = GameMode({ gameScene = "TimeAttackGame", richPresenceLabel = "2p Time Attack", -- loc("mm_2_time"), name = "2p_timeattack", @@ -182,8 +201,9 @@ local TwoPlayerTimeAttack = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + +}) GameModes.Styles = Styles GameModes.StackInteractions = StackInteractions @@ -213,4 +233,27 @@ function GameModes.getPreset(mode) return deepcpy(privateGameModes[mode]) end +-- Creates a GameMode from server message data by loading the preset and applying overrides +---@param gameModeData table The game mode data from the server message +---@return GameMode +function GameModes.createFromServerData(gameModeData) + local preset = nil + for _, gameMode in pairs(privateGameModes) do + if gameMode.name == gameModeData.name then + preset = gameMode + break + end + end + + assert(preset, "Unknown game mode name: " .. tostring(gameModeData.name)) + + local result = deepcpy(preset) + + for key, value in pairs(gameModeData) do + result[key] = value + end + + return result +end + return GameModes diff --git a/common/data/LevelPresets.lua b/common/data/LevelPresets.lua index fa49c68d8..380ac1aea 100644 --- a/common/data/LevelPresets.lua +++ b/common/data/LevelPresets.lua @@ -1,6 +1,7 @@ -- this file documents presets for level data local LevelData = require("common.data.LevelData") local JsonSafePrecision = require("common.data.JsonSafePrecision") +local GameModes = require("common.data.GameModes") ---@type LevelData[] local modern = {} @@ -326,4 +327,71 @@ end LevelPresets.classicPresetCount = #classic +---@type (table) +local classicEndless = {} +-- Deep copy from classic presets and modify only what's different for endless mode +classicEndless[1] = deepcpy(classic[1]) +-- Endless easy uses 5 colors instead of 6 +classicEndless[1]:setColorCount(5) +-- and allows adjacent panels of the same colors +classicEndless[1]:setAdjacentDenialFrequency(0) +classicEndless.easy = classicEndless[1] + +-- Normal, hard, and ex are identical to classic mode for endless +classicEndless[2] = deepcpy(classic[2]) +classicEndless.normal = classicEndless[2] + +classicEndless[3] = deepcpy(classic[3]) +classicEndless.hard = classicEndless[3] + +classicEndless[4] = deepcpy(classic[4]) +classicEndless.ex = classicEndless[4] + +---@param difficulty number | string the difficulty expressed as index 1 2 3 4 or easy normal hard ex +---@return LevelData # a deepcopy of the classic endless preset +function LevelPresets.getClassicEndless(difficulty) + assert(classicEndless[difficulty], "trying to load inexistent difficulty preset" .. difficulty) + return deepcpy(classicEndless[difficulty]) +end + +LevelPresets.classicEndlessPresetCount = #classicEndless + +---@class PresetInfo +---@field style Styles +---@field level integer? +---@field difficulty integer? +---@field isEndless boolean? + +---@param levelData LevelData +---@return PresetInfo? # style and preset information, or nil if levelData doesn't match any preset +function LevelPresets.getStyleAndPreset(levelData) + if not levelData then + return nil + end + + -- Check modern presets + for level = 1, #modern do + if LevelData.__eq(levelData, modern[level]) then + return {style = GameModes.Styles.MODERN, level = level, difficulty = nil, isEndless = false} + end + end + + -- Check classicEndless presets + for difficulty = 1, #classicEndless do + if LevelData.__eq(levelData, classicEndless[difficulty]) then + return {style = GameModes.Styles.CLASSIC, level = nil, difficulty = difficulty, isEndless = true} + end + end + + -- Check classic presets + for difficulty = 1, #classic do + if LevelData.__eq(levelData, classic[difficulty]) then + return {style = GameModes.Styles.CLASSIC, level = nil, difficulty = difficulty, isEndless = false} + end + end + + -- Doesn't match any preset - return nil + return nil +end + return LevelPresets \ No newline at end of file diff --git a/common/engine/Health.lua b/common/engine/Health.lua index 0d0ce4465..7728929e2 100644 --- a/common/engine/Health.lua +++ b/common/engine/Health.lua @@ -1,6 +1,7 @@ local logger = require("common.lib.logger") local consts = require("common.engine.consts") local class = require("common.lib.class") +local JsonSafePrecision = require("common.data.JsonSafePrecision") ---@class HealthSettings ---@field framesToppedOutToLose number Starting value of framesToppedOutToLose @@ -24,7 +25,7 @@ local Health = class( function(self, framesToppedOutToLose, lineClearGPM, height, riseSpeed) self.framesToppedOutToLose = framesToppedOutToLose self.maxSecondsToppedOutToLose = framesToppedOutToLose - self.lineClearRate = lineClearGPM / 60 + self.lineClearRate = JsonSafePrecision.toSafePrecision(lineClearGPM / 60) self.currentLines = 0 self.height = height self.lastWasFourCombo = false @@ -139,7 +140,7 @@ end function Health:getSettings() return { framesToppedOutToLose = self.maxSecondsToppedOutToLose, - lineClearGPM = self.lineClearRate * 60, + lineClearGPM = JsonSafePrecision.toSafePrecision(self.lineClearRate * 60), lineHeightToKill = self.height, riseSpeed = self.initialRiseSpeed } diff --git a/common/lib/util.lua b/common/lib/util.lua index 3801ccd03..78439b103 100644 --- a/common/lib/util.lua +++ b/common/lib/util.lua @@ -114,7 +114,9 @@ function real_deepcpy(tab) return setmetatable(ret, getmetatable(tab)) end --- copys the full variable deeply +-- Creates a deep copy of a table, recursively copying all nested tables +-- Preserves metatables and handles circular references +-- If the input is not a table, returns it unchanged ---@generic T ---@param tab T ---@return T deepCopy diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 3ed87bf98..5f8d72a98 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -134,11 +134,13 @@ function ClientMessages.sendTaunt(direction, index) } end +---@param gameMode GameMode function ClientMessages.sendRoomRequest(gameMode) + local gameModeData = gameMode:getGameModeJSONData() local roomRequestMessage = { recipient = "server", type = "roomRequest", - content = { gameMode = gameMode } + content = { gameMode = gameModeData } } return { messageType = msgTypes.jsonMessage, diff --git a/server/Game.lua b/server/Game.lua index b585da5c6..28f67a583 100644 --- a/server/Game.lua +++ b/server/Game.lua @@ -77,6 +77,8 @@ function Game.createFromRoomState(room) metadata.level = player.level else -- TODO: https://github.com/panel-attack/panel-game/issues/602 + -- Use this pattern when we are in this area again and testing server + -- local presetInfo = LevelPresets.getStyleAndPreset(levelData) metadata.difficulty = player.level end diff --git a/server/Room.lua b/server/Room.lua index 87cc1d177..e2455db70 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -21,7 +21,7 @@ local ServerGame = require("server.Game") ---@field ratings table[] ratings by player number ---@field matchCount integer ---@field game ServerGame? ----@field gameMode GameMode +---@field gameMode table -- only the data portion of the game mode ---@field ranked boolean if the next match is anticipated to be ranked ---@field rankedReasons string[] ---@overload fun(roomNumber: integer, players: ServerPlayer[], gameMode: GameMode, leaderboard: Leaderboard?): Room @@ -29,7 +29,7 @@ local Room = class( ---@param self Room ---@param roomNumber integer ---@param players ServerPlayer[] ----@param gameMode GameMode +---@param gameMode table -- only the data portion of the game mode ---@param leaderboard Leaderboard? function(self, roomNumber, players, gameMode, leaderboard) self.players = players diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index e830581ab..17ea93d2c 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -269,7 +269,7 @@ local function testSinglePlayer() assert(message.type == "spectatorUpdate") message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectateRequestGranted" and message.content.replay == nil) - assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"))) + assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"):getGameModeJSONData())) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectatorUpdate") diff --git a/testLauncher.lua b/testLauncher.lua index 2d7064e6c..53d23c1cf 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -93,6 +93,7 @@ local allTests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "client.tests.StackGraphicsTests", + "client.tests.PlayerSettingsTests", } -- Check for specific test name argument From 28f30bec7aba1dbce640d312eb2669547cf3206d Mon Sep 17 00:00:00 2001 From: Endaris Date: Sat, 25 Oct 2025 20:01:20 +0200 Subject: [PATCH 12/16] don't send a reason for spectator leaves to clients (#716) --- client/src/network/NetClient.lua | 7 ++++++- server/Room.lua | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index d785a431c..4954bae60 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -125,7 +125,12 @@ local function processLeaveRoomMessage(self, message) -- instead we actively abort the match ourselves self.room.match:abort() self.room.match:deinit() - transition = MessageTransition(love.timer.getTime(), 5, message.reason or "", false) + + if message.reason then + -- the server sends a reason for leaveRoom only if a player (not a spectator) in the room leaves/crashes/disconnects + -- the other player and spectators should be informed why the room is being closed + transition = MessageTransition(love.timer.getTime(), 5, message.reason, false) + end end -- and then shutdown the room diff --git a/server/Room.lua b/server/Room.lua index e2455db70..f9fa247ed 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -192,7 +192,7 @@ function Room:remove_spectator(spectator) self.spectators[i].state = "lobby" logger.debug(spectator.name .. " left " .. self.name .. " as a spectator") table.remove(self.spectators, i) - spectator:removeFromRoom(self, spectator.name .. " left") + spectator:removeFromRoom(self) lobbyChanged = true break end From 30e1316f0a5157b2bac1428700f0661710bbdc67 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sun, 26 Oct 2025 21:37:18 -0700 Subject: [PATCH 13/16] Add Debug Settings Architecture (#705) The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to config.debug and can be accessed anywhere via the DebugSettings singleton. Also provide a way to generate the debug menu and surface it from a debug button. To support this new button and future overlays, add a root element to the game so we can order overlays Updated Bool Selector to size correctly Updated Style Selector creation to make sure sizing and layout is right Reload main menu when you come back to it Make navigation stack a UIElement so it works with touch handling Fixes #697 --- client/src/BattleRoom.lua | 3 +- client/src/ChallengeModePlayerStack.lua | 3 +- client/src/ClientMatch.lua | 5 +- client/src/Game.lua | 69 ++- client/src/NavigationStack.lua | 36 +- client/src/PlayerStack.lua | 7 +- client/src/Shortcuts.lua | 8 +- client/src/config.lua | 31 +- client/src/debug/DebugMenu.lua | 90 ++++ client/src/debug/DebugSettings.lua | 418 ++++++++++++++++++ client/src/developer.lua | 3 +- client/src/scenes/CharacterSelect.lua | 77 ++-- client/src/scenes/DesignHelper.lua | 2 +- client/src/scenes/EndlessMenu.lua | 4 +- client/src/scenes/GameBase.lua | 5 +- client/src/scenes/MainMenu.lua | 15 +- client/src/scenes/ModManagement.lua | 8 +- client/src/scenes/OptionsMenu.lua | 34 +- client/src/scenes/PortraitGame.lua | 5 +- client/src/scenes/ReplayGame.lua | 3 +- client/src/scenes/Scene.lua | 3 +- client/src/scenes/TimeAttackMenu.lua | 4 +- client/src/ui/BoolSelector.lua | 81 ++-- client/src/ui/Carousel.lua | 3 +- client/src/ui/Grid.lua | 3 +- client/src/ui/GridElement.lua | 3 +- client/src/ui/MenuItem.lua | 19 +- client/src/ui/MultiPlayerSelectionWrapper.lua | 1 + client/src/ui/OverlayContainer.lua | 124 ++++++ client/src/ui/PagedUniGrid.lua | 3 +- client/src/ui/StackPanel.lua | 49 +- client/src/ui/Stepper.lua | 3 +- client/src/ui/touchHandler.lua | 14 +- common/engine/Match.lua | 6 +- docs/DebugSettings.md | 117 +++++ main.lua | 9 +- 36 files changed, 1083 insertions(+), 185 deletions(-) create mode 100644 client/src/debug/DebugMenu.lua create mode 100644 client/src/debug/DebugSettings.lua create mode 100644 client/src/ui/OverlayContainer.lua create mode 100644 docs/DebugSettings.md diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 5d1f16432..0beeb5352 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -12,6 +12,7 @@ local BlackFadeTransition = require("client.src.scenes.Transitions.BlackFadeTran local Easings = require("client.src.Easings") local system = require("client.src.system") local GeneratorSource = require("common.engine.GeneratorSource") +local DebugSettings = require("client.src.debug.DebugSettings") -- A Battle Room is a session of matches, keeping track of the room number, player settings, wins / losses etc ---@class BattleRoom : Signal @@ -383,7 +384,7 @@ function BattleRoom:createScene(match) end -- for touch android players load a different scene - if (system.isMobileOS() or DEBUG_ENABLED) and self.gameScene.name ~= "PuzzleGame" and + if (system.isMobileOS() or DebugSettings.simulateMobileOS()) and self.gameScene.name ~= "PuzzleGame" and --but only if they are the only local player cause for 2p vs local using portrait mode would be bad tableUtils.count(self.players, function(p) return p.isLocal and p.human end) == 1 then for _, player in ipairs(self.players) do diff --git a/client/src/ChallengeModePlayerStack.lua b/client/src/ChallengeModePlayerStack.lua index 91d3bb198..eea1a3be5 100644 --- a/client/src/ChallengeModePlayerStack.lua +++ b/client/src/ChallengeModePlayerStack.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local ClientStack = require("client.src.ClientStack") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class ChallengeModePlayerStack : ClientStack ---@field engine SimulatedStack @@ -198,7 +199,7 @@ function ChallengeModePlayerStack:drawMultibar() end function ChallengeModePlayerStack:drawDebug() - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local drawX = self.frameOriginX + self:canvasWidth() / 2 local drawY = 10 local padding = 14 diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index bf71af747..1574a3b0a 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -16,6 +16,7 @@ local Telegraph = require("client.src.graphics.Telegraph") local MatchParticipant = require("client.src.MatchParticipant") local ChallengeModePlayerStack = require("client.src.ChallengeModePlayerStack") local NetworkProtocol = require("common.network.NetworkProtocol") +local DebugSettings = require("client.src.debug.DebugSettings") ---@module "client.src.ChallengeModePlayerStack" ---@class ClientMatch @@ -559,7 +560,7 @@ end function ClientMatch:drawCommunityMessage() -- Draw the community message - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then GraphicsUtil.printf(join_community_msg or "", 0, 668, consts.CANVAS_WIDTH, "center") end end @@ -598,7 +599,7 @@ function ClientMatch:render() end end - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local padding = 14 local drawX = 500 local drawY = -4 diff --git a/client/src/Game.lua b/client/src/Game.lua index 1952ff0c4..22f765e5f 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -33,6 +33,14 @@ local system = require("client.src.system") local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") +local DebugSettings = require("client.src.debug.DebugSettings") +local Button = require("client.src.ui.Button") +local TextButton = require("client.src.ui.TextButton") +local OverlayContainer = require("client.src.ui.OverlayContainer") +local DebugMenu = require("client.src.debug.DebugMenu") +local Label = require("client.src.ui.Label") +local UIElement = require("client.src.ui.UIElement") +local NavigationStack = require("client.src.NavigationStack") -- Provides a scale that is on .5 boundary to make sure it renders well. -- Useful for creating new canvas with a solid DPI @@ -106,12 +114,19 @@ local Game = class( -- time in seconds, can be used by other elements to track the passing of time beyond dt self.timer = love.timer.getTime() + + self.debugOverlay = nil + self.debugButton = nil + + -- Root UI element that contains all UI (scenes + overlays + debug) + self.uiRoot = UIElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) end ) Game.newCanvasSnappedScale = newCanvasSnappedScale function Game:load() + DebugSettings.init() PuzzleLibrary.cleanupDefaultPuzzles(consts.PUZZLES_SAVE_DIRECTORY) -- move to constructor @@ -131,8 +146,11 @@ function Game:load() self.input:importConfigurations(user_input_conf) end - self.navigationStack = require("client.src.NavigationStack") + self.navigationStack = NavigationStack({}) self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) + + -- Add navigation stack to root UI + self.uiRoot:addChild(self.navigationStack) self.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) end @@ -253,6 +271,8 @@ function Game:setupRoutine() self:initializeLocalPlayer() ModController:loadModFor(characters[GAME.localPlayer.settings.characterId], GAME.localPlayer, true) + + self:initializeDebugOverlay() end -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate @@ -366,9 +386,9 @@ function Game:update(dt) handleShortcuts() - prof.push("navigationStack update") - self.navigationStack:update(dt) - prof.pop("navigationStack update") + prof.push("uiRoot update") + self.uiRoot:update(dt) + prof.pop("uiRoot update") if self.backgroundImage then self.backgroundImage:update(dt) @@ -386,7 +406,7 @@ function Game:draw() love.graphics.clear() -- With this, self.globalCanvas is clear and set as our active canvas everything is being drawn to - self.navigationStack:draw() + self.uiRoot:draw() self:drawFPS() self:drawScaleInfo() @@ -402,8 +422,7 @@ function Game:draw() end function Game:drawFPS() - -- Draw the FPS if enabled - if self.config.show_fps then + if self.config.show_fps or DebugSettings.forceFPS() then love.graphics.print("FPS: " .. love.timer.getFPS(), 1, 1) end end @@ -666,4 +685,40 @@ function Game:setLanguage(lang_code) Localization:refresh_global_strings() end +function Game:initializeDebugOverlay() + if not DEBUG_ENABLED then + return + end + + self.debugButton = TextButton({ + x = consts.CANVAS_WIDTH - 50, + y = consts.CANVAS_HEIGHT - 50, + label = Label({ + text = "Debug", + translate = false, + hAlign = "center", + vAlign = "center" + }), + width = 40, + height = 40, + onClick = function() + if self.debugOverlay then + if not self.debugOverlay:isActive() then + self.debugOverlay:open() + end + end + end + }) + + local debugMenu = DebugMenu.makeDebugMenu({height = consts.CANVAS_HEIGHT - 40}) + self.debugOverlay = OverlayContainer({ + content = debugMenu + }) + + -- Add debug UI to root + self.uiRoot:addChild(self.debugButton) + self.uiRoot:addChild(self.debugOverlay) +end + + return Game diff --git a/client/src/NavigationStack.lua b/client/src/NavigationStack.lua index e9ef669c3..89ff75a42 100644 --- a/client/src/NavigationStack.lua +++ b/client/src/NavigationStack.lua @@ -1,11 +1,23 @@ local DirectTransition = require("client.src.scenes.Transitions.DirectTransition") local logger = require("common.lib.logger") - -local NavigationStack = { - scenes = {}, - transition = nil, - callback = nil, -} +local UIElement = require("client.src.ui.UIElement") +local class = require("common.lib.class") +local consts = require("common.engine.consts") + +---@class NavigationStack : UiElement +---@field scenes Scene[] +---@field transition table? +---@field callback function? +local NavigationStack = class( + function(self) + self.scenes = {} + self.transition = nil + self.callback = nil + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + end, + UIElement +) function NavigationStack:push(newScene, transition) local activeScene = self.scenes[#self.scenes] @@ -142,7 +154,7 @@ function NavigationStack:getActiveScene() end end -function NavigationStack:update(dt) +function NavigationStack:updateSelf(dt) if self.transition then self.transition:update(dt) @@ -163,7 +175,7 @@ function NavigationStack:update(dt) end end -function NavigationStack:draw() +function NavigationStack:drawSelf() if self.transition then self.transition:draw() else @@ -174,4 +186,12 @@ function NavigationStack:draw() end end +function NavigationStack:getTouchedElement(x, y) + local activeScene = self:getActiveScene() + if activeScene and activeScene.uiRoot then + return activeScene.uiRoot:getTouchedElement(x, y) + end + return nil +end + return NavigationStack \ No newline at end of file diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index 4b3252096..81f410220 100644 --- a/client/src/PlayerStack.lua +++ b/client/src/PlayerStack.lua @@ -15,6 +15,7 @@ local logger = require("common.lib.logger") require("client.src.analytics") local KeyDataEncoding = require("common.data.KeyDataEncoding") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") ---@module "common.data.LevelData" local floor, min, max = math.floor, math.min, math.max @@ -767,7 +768,7 @@ function PlayerStack:drawPopBurstParticle(atlas, quad, frameIndex, atlasDimensio end function PlayerStack:drawDebug() - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local engine = self.engine local x = self.origin_x + 480 @@ -863,7 +864,7 @@ function PlayerStack:drawDebug() end function PlayerStack:drawDebugPanels(shakeOffset) - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then return end @@ -952,7 +953,7 @@ function PlayerStack:drawRating() local rating if self.player.rating and tonumber(self.player.rating) then rating = self.player.rating - elseif config.debug_mode then + elseif DebugSettings.showStackDebugInfo() then rating = 1544 + self.player.playerNumber end diff --git a/client/src/Shortcuts.lua b/client/src/Shortcuts.lua index 2d428a57f..7c009e3ff 100644 --- a/client/src/Shortcuts.lua +++ b/client/src/Shortcuts.lua @@ -7,7 +7,13 @@ local logger = require("common.lib.logger") local function runSystemCommands() -- toggle debug mode if input.allKeys.isDown["d"] then - config.debug_mode = not config.debug_mode + if GAME.debugOverlay then + if GAME.debugOverlay.active then + GAME.debugOverlay:close() + else + GAME.debugOverlay:open() + end + end -- reload characters elseif input.allKeys.isDown["c"] then characters_reload_graphics() diff --git a/client/src/config.lua b/client/src/config.lua index c83d8b2e2..93c69a3ff 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -3,6 +3,7 @@ json = require("common.lib.dkjson") local util = require("common.lib.util") local fileUtils = require("client.src.FileUtils") local consts = require("common.engine.consts") +local DebugSettings = require("client.src.debug.DebugSettings") require("client.src.globals") -- Default configuration values @@ -27,12 +28,7 @@ require("client.src.globals") ---@field SFX_volume number ---@field music_volume number ---@field enableMenuMusic boolean ----@field debug_mode boolean ----@field debugShowServers boolean ----@field debugShowDesignHelper boolean ----@field debugProfile boolean ----@field debugProfileThreshold integer ----@field debug_vsFramesBehind integer +---@field debug DebugConfig? ---@field show_fps boolean ---@field show_ingame_infos boolean ---@field danger_music_changeback_delay boolean @@ -93,12 +89,8 @@ config = { SFX_volume = 50, music_volume = 50, enableMenuMusic = true, - -- Debug mode flag - debug_mode = false, - debugShowServers = false, - debugShowDesignHelper = false, - debugProfile = false, - debugProfileThreshold = 50, + -- Debug settings persisted separately + debug = DebugSettings.getDefaultConfigValues(), -- Show FPS in the top-left corner of the screen show_fps = false, @@ -226,19 +218,6 @@ config = { if type(read_data.music_volume) == "number" then configTable.music_volume = util.bound(0, read_data.music_volume, 100) end - if type(read_data.debug_mode) == "boolean" then - configTable.debug_mode = read_data.debug_mode - end - if type(read_data.debugShowServers) == "boolean" then - configTable.debugShowServers = read_data.debugShowServers - end - if type(read_data.debugShowDesignHelper) == "boolean" then - configTable.debugShowDesignHelper = read_data.debugShowDesignHelper - end - if type(read_data.debugProfile) == "boolean" then - configTable.debugProfile = read_data.debugProfile - end - -- debugProfileThreshold is not saved to prevent accidental dense profiling if type(read_data.show_fps) == "boolean" then configTable.show_fps = read_data.show_fps end @@ -310,6 +289,8 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end + + configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end end diff --git a/client/src/debug/DebugMenu.lua b/client/src/debug/DebugMenu.lua new file mode 100644 index 000000000..311e352fc --- /dev/null +++ b/client/src/debug/DebugMenu.lua @@ -0,0 +1,90 @@ +local class = require("common.lib.class") +local ui = require("client.src.ui") +local DebugSettings = require("client.src.debug.DebugSettings") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +local function createDebugBoolSelector(debugKey, onChangeFn) + return ui.BoolSelector({ + startValue = DebugSettings.get(debugKey) --[[@as boolean]], + onValueChange = function(selfElement, value) + GAME.theme:playMoveSfx() + DebugSettings.set(debugKey, value) + if onChangeFn then + onChangeFn() + end + end + }) +end + +local function createDebugSlider(debugKey, min, max, onValueChangeFn) + return ui.Slider({ + min = min, + max = max, + value = DebugSettings.get(debugKey) --[[@as number]], + tickLength = math.ceil(100 / max), + onValueChange = function(slider) + DebugSettings.set(debugKey, slider.value) + if onValueChangeFn then + onValueChangeFn(slider) + end + end + }) +end + +local function buildDebugMenuItems(options) + local debugMenuOptions = {} + + for _, def in ipairs(DebugSettings.getDefinitions()) do + if def.type == "boolean" then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createBoolSelectorMenuItem( + def.label, + nil, + false, + createDebugBoolSelector(def.key) + ) + elseif def.type == "number" then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createSliderMenuItem( + def.label, + nil, + false, + createDebugSlider(def.key, def.min or 0, def.max or 100) + ) + end + end + + if DEBUG_ENABLED then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() + GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) + end) + end + + if options.showBackButton then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + GAME.theme:playCancelSfx() + if options.onBack then + options.onBack() + end + end) + end + + return debugMenuOptions +end + +-- Menu for configuring debug settings +---@class DebugMenu : Menu +local DebugMenu = class(function(self, options) + +end, ui.Menu) + +-- We need a factory because menu items must be passed into the base class init +function DebugMenu.makeDebugMenu(options) + options = options or {} + options.x = 0 + options.y = 0 + options.hAlign = "center" + options.vAlign = "center" + options.menuItems = buildDebugMenuItems(options) + return DebugMenu(options) +end + +return DebugMenu diff --git a/client/src/debug/DebugSettings.lua b/client/src/debug/DebugSettings.lua new file mode 100644 index 000000000..43b02a2dc --- /dev/null +++ b/client/src/debug/DebugSettings.lua @@ -0,0 +1,418 @@ +local logger = require("common.lib.logger") + +-- Singleton class for managing runtime debug settings +-- Settings are persisted to the config file under config.debug +---@class DebugSettings +local DebugSettings = {} + +---@class DebugConfig +---@field showStackDebugInfo boolean +---@field showUIElementBorders boolean +---@field simulateMobileOS boolean +---@field forceFPS boolean +---@field drawGraphicsStats boolean +---@field showRuntimeGraph boolean +---@field vsFramesBehind number +---@field showDebugServers boolean +---@field showDesignHelper boolean +---@field profileFrameTimes boolean +---@field profileThreshold number + +---@class DebugSettingDefinition +---@field key string The key used in config.debug table +---@field type "boolean"|"number" The type of the setting +---@field default boolean|number The default value +---@field label string The display label for the UI +---@field min number? Minimum value for number types +---@field max number? Maximum value for number types +---@field debugBuildOnly boolean? Forces the setting off and hides it in non-debug builds + +-- All debug settings defined in one place with their metadata +local settingDefinitions = { + { + key = "showStackDebugInfo", + type = "boolean", + default = false, + label = "Show Stack Debug Info", + debugBuildOnly = false + }, + { + key = "showUIElementBorders", + type = "boolean", + default = false, + label = "Show UI Element Borders", + debugBuildOnly = true + }, + { + key = "simulateMobileOS", + type = "boolean", + default = false, + label = "Simulate Mobile OS", + debugBuildOnly = true + }, + { + key = "forceFPS", + type = "boolean", + default = false, + label = "Force FPS Display", + debugBuildOnly = true + }, + { + key = "drawGraphicsStats", + type = "boolean", + default = false, + label = "Draw Graphics Stats", + debugBuildOnly = true + }, + { + key = "showRuntimeGraph", + type = "boolean", + default = false, + label = "Show Runtime Graph", + debugBuildOnly = true + }, + { + key = "vsFramesBehind", + type = "number", + default = 0, + label = "VS Frames Behind", + min = 0, + max = 200, + debugBuildOnly = true + }, + { + key = "showDebugServers", + type = "boolean", + default = false, + label = "Show Debug Servers", + debugBuildOnly = false + }, + { + key = "showDesignHelper", + type = "boolean", + default = false, + label = "Show Design Helper", + debugBuildOnly = true + }, + { + key = "profileFrameTimes", + type = "boolean", + default = false, + label = "Profile frame times", + debugBuildOnly = false + }, + { + key = "profileThreshold", + type = "number", + default = 50, + label = "Discard frames below duration (ms)", + min = 0, + max = 100, + debugBuildOnly = false + } +} + +local function isNonDebugBuild() + return not DEBUG_ENABLED +end + +local function shouldLockToDefault(def) + return isNonDebugBuild() and def.debugBuildOnly +end + +---Creates a DebugConfig table populated with default values. +---@return DebugConfig +local function createDefaultConfig() + local defaults = {} + for _, def in ipairs(settingDefinitions) do + local value = def.default + defaults[def.key] = value + end + ---@type DebugConfig + return defaults +end + +--- Current settings values (loaded from config.debug) +---@type DebugConfig +local settings = createDefaultConfig() + +local function clampNumber(value, minValue, maxValue) + if minValue then + value = math.max(minValue, value) + end + if maxValue then + value = math.min(maxValue, value) + end + return value +end + +local function findDefinition(key) + for _, def in ipairs(settingDefinitions) do + if def.key == key then + return def + end + end +end + +---Returns default debug configuration values keyed by setting. +---@return DebugConfig +function DebugSettings.getDefaultConfigValues() + return createDefaultConfig() +end + +---Normalizes persisted debug configuration values using definitions. +---@param persisted table|nil +---@return DebugConfig +function DebugSettings.normalizeConfigValues(persisted) + local normalized = createDefaultConfig() + local source = persisted or {} + + for _, def in ipairs(settingDefinitions) do + local value = source[def.key] + if def.type == "boolean" then + if type(value) == "boolean" then + normalized[def.key] = value + end + elseif def.type == "number" then + if type(value) ~= "number" then + value = def.default + end + normalized[def.key] = clampNumber(value, def.min, def.max) + end + end + + return normalized +end + +-- Initializes debug settings from config +function DebugSettings.init() + config.debug = config.debug or {} + + config.debug = DebugSettings.normalizeConfigValues(config.debug) + + for _, def in ipairs(settingDefinitions) do + local value = config.debug[def.key] + if shouldLockToDefault(def) then + settings[def.key] = def.default + else + settings[def.key] = value + end + end +end + +-- Saves current settings to config +local function saveSettings() + config.debug = config.debug or {} + + for _, def in ipairs(settingDefinitions) do + config.debug[def.key] = settings[def.key] + end + + write_conf_file() +end + +local releaseDefinitionCache +-- Returns all setting definitions for UI generation +---@return DebugSettingDefinition[] +function DebugSettings.getDefinitions() + if not isNonDebugBuild() then + return settingDefinitions + end + + if not releaseDefinitionCache then + releaseDefinitionCache = {} + for _, def in ipairs(settingDefinitions) do + if not def.debugBuildOnly then + releaseDefinitionCache[#releaseDefinitionCache + 1] = def + end + end + end + + return releaseDefinitionCache +end + +-- Gets the value of a setting by key +---@param key string +---@return boolean|number +function DebugSettings.get(key) + local def = findDefinition(key) + + if isNonDebugBuild() then + if def then + if shouldLockToDefault(def) then + return def.default + end + + if def.type == "boolean" then + return settings[key] or false + elseif def.type == "number" then + return settings[key] or 0 + end + end + return false + end + + if not def then + return false + end + + return settings[key] +end + +-- Sets the value of a setting by key +---@param key string +---@param value boolean|number +function DebugSettings.set(key, value) + local def = findDefinition(key) + if not def then + logger.warn("Attempted to set unknown debug setting: " .. key) + return + end + + if def.type == "boolean" then + settings[key] = value and true or false + else + local numericValue = type(value) == "number" and value or def.default + settings[key] = clampNumber(numericValue, def.min, def.max) + end + saveSettings() +end + +-- Returns whether to show stack debug information +---@return boolean +function DebugSettings.showStackDebugInfo() + return DebugSettings.get("showStackDebugInfo") --[[@as boolean]] +end + +-- Returns whether to show UI element borders +---@return boolean +function DebugSettings.showUIElementBorders() + return DebugSettings.get("showUIElementBorders") --[[@as boolean]] +end + +-- Returns whether to simulate mobile OS on desktop +---@return boolean +function DebugSettings.simulateMobileOS() + return DebugSettings.get("simulateMobileOS") --[[@as boolean]] +end + +-- Returns whether to force FPS display in debug builds +---@return boolean +function DebugSettings.forceFPS() + return DebugSettings.get("forceFPS") --[[@as boolean]] +end + +-- Returns whether to draw graphics stats (draw calls, texture memory, etc.) +---@return boolean +function DebugSettings.drawGraphicsStats() + return DebugSettings.get("drawGraphicsStats") --[[@as boolean]] +end + +-- Returns whether to show the runtime graph +---@return boolean +function DebugSettings.showRuntimeGraph() + return DebugSettings.get("showRuntimeGraph") --[[@as boolean]] +end + +-- Returns the VS frames behind value (only applicable when showStackDebugInfo is true) +---@return number +function DebugSettings.getVSFramesBehind() + return DebugSettings.get("vsFramesBehind") --[[@as number]] +end + +-- Sets whether to show stack debug information +---@param value boolean +function DebugSettings.setShowStackDebugInfo(value) + DebugSettings.set("showStackDebugInfo", value) +end + +-- Sets whether to show UI element borders +---@param value boolean +function DebugSettings.setShowUIElementBorders(value) + DebugSettings.set("showUIElementBorders", value) +end + +-- Sets whether to simulate mobile OS on desktop +---@param value boolean +function DebugSettings.setSimulateMobileOS(value) + DebugSettings.set("simulateMobileOS", value) +end + +-- Sets whether to force FPS display in debug builds +---@param value boolean +function DebugSettings.setForceFPS(value) + DebugSettings.set("forceFPS", value) +end + +-- Sets whether to draw graphics stats +---@param value boolean +function DebugSettings.setDrawGraphicsStats(value) + DebugSettings.set("drawGraphicsStats", value) +end + +-- Sets whether to show the runtime graph +---@param value boolean +function DebugSettings.setShowRuntimeGraph(value) + DebugSettings.set("showRuntimeGraph", value) +end + +-- Sets the VS frames behind value +---@param value number +function DebugSettings.setVSFramesBehind(value) + DebugSettings.set("vsFramesBehind", value) +end + +-- Returns whether to show debug servers in main menu +---@return boolean +function DebugSettings.showDebugServers() + return DebugSettings.get("showDebugServers") --[[@as boolean]] +end + +-- Sets whether to show debug servers in main menu +---@param value boolean +function DebugSettings.setShowDebugServers(value) + DebugSettings.set("showDebugServers", value) +end + +-- Returns whether to show design helper in main menu +---@return boolean +function DebugSettings.showDesignHelper() + return DebugSettings.get("showDesignHelper") --[[@as boolean]] +end + +-- Sets whether to show design helper in main menu +---@param value boolean +function DebugSettings.setShowDesignHelper(value) + DebugSettings.set("showDesignHelper", value) +end + +-- Returns whether to profile frame times +---@return boolean +function DebugSettings.getProfileFrameTimes() + return DebugSettings.get("profileFrameTimes") --[[@as boolean]] +end + +-- Sets whether to profile frame times +---@param value boolean +function DebugSettings.setProfileFrameTimes(value) + DebugSettings.set("profileFrameTimes", value) + local prof = require("common.lib.zoneProfiler") + prof.enable(value) + prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) +end + +-- Returns the profile threshold in milliseconds +---@return number +function DebugSettings.getProfileThreshold() + return DebugSettings.get("profileThreshold") --[[@as number]] +end + +-- Sets the profile threshold in milliseconds +---@param value number +function DebugSettings.setProfileThreshold(value) + DebugSettings.set("profileThreshold", value) + local prof = require("common.lib.zoneProfiler") + prof.setDurationFilter(value / 1000) +end + +return DebugSettings diff --git a/client/src/developer.lua b/client/src/developer.lua index ff4cc477f..22f52222c 100644 --- a/client/src/developer.lua +++ b/client/src/developer.lua @@ -1,4 +1,5 @@ local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") ---@diagnostic disable: duplicate-set-field -- Put any local development changes you need in here that you don't want commited. @@ -7,7 +8,7 @@ local system = require("client.src.system") local function enableProfiler(threshold) local prof = require("common.lib.zoneProfiler") prof.enable(true) - prof.setDurationFilter((threshold or config.debugProfileThreshold) / 1000) + prof.setDurationFilter((threshold or DebugSettings.getProfileThreshold()) / 1000) end local developerTools = {} diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 8ceac0802..eb29eb46a 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -676,9 +676,18 @@ end ---@param player Player ---@param width number ----@return BoolSelector rankedSelector +---@return StackPanel rankedSelectionContainer function CharacterSelect:createRankedSelection(player, width) - local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vFill = true, width = width, vAlign = "center", hAlign = "center"}) + + -- player number icon + local playerIndex = tableUtils.indexOf(self.players, player) + local playerNumberIcon = ui.ImageContainer({ + image = themes[config.theme].images.IMG_players[playerIndex], + scale = 2, + vAlign = "center" + }) + + local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vAlign = "center"}) rankedSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() player:setWantsRanked(value) @@ -688,31 +697,40 @@ function CharacterSelect:createRankedSelection(player, width) player:connectSignal("wantsRankedChanged", rankedSelector, rankedSelector.setValue) + local container = ui.StackPanel( + { + alignment = "left", + height = rankedSelector.height, + hAlign = "center", + vAlign = "center", + } + ) + container.playerNumberIcon = playerNumberIcon + container.rankedSelector = rankedSelector + container:addElement(playerNumberIcon) + container:addElement(ui.UiElement({width = 8, height = 8})) + container:addElement(rankedSelector) + container:addElement(ui.UiElement({width = 8, height = 8})) + + return container +end + +---@param player Player +---@param width number +---@return StackPanel styleSelectionContainer +---@return BoolSelector styleSelector +function CharacterSelect:createStyleSelection(player, width) -- player number icon local playerIndex = tableUtils.indexOf(self.players, player) local playerNumberIcon = ui.ImageContainer({ image = themes[config.theme].images.IMG_players[playerIndex], - hAlign = "left", - vAlign = "center", - x = 2, scale = 2, + vAlign = "center" }) - rankedSelector.playerNumberIcon = playerNumberIcon - rankedSelector:addChild(rankedSelector.playerNumberIcon) - return rankedSelector -end - ----@param player Player ----@param width number ----@return BoolSelector styleSelector -function CharacterSelect:createStyleSelection(player, width) local styleSelector = ui.BoolSelector({ startValue = (player.settings.style == GameModes.Styles.MODERN), - vFill = true, - width = width, - vAlign = "center", - hAlign = "center", + isEnabled = player.isLocal }) -- onValueChange should get implemented by the caller @@ -729,19 +747,20 @@ function CharacterSelect:createStyleSelection(player, width) end ) - -- player number icon - local playerIndex = tableUtils.indexOf(self.players, player) - local playerNumberIcon = ui.ImageContainer({ - image = themes[config.theme].images.IMG_players[playerIndex], - hAlign = "left", + local container = ui.StackPanel({ + alignment = "left", + height = styleSelector.height, + hAlign = "center", vAlign = "center", - x = 8, - scale = 2, }) - styleSelector.playerNumberIcon = playerNumberIcon - styleSelector:addChild(styleSelector.playerNumberIcon) - - return styleSelector + container.playerNumberIcon = playerNumberIcon + container.styleSelector = styleSelector + container:addElement(playerNumberIcon) + container:addElement(ui.UiElement({width = 8, height = 8})) + container:addElement(styleSelector) + container:addElement(ui.UiElement({width = 8, height = 8})) + + return container, styleSelector end function CharacterSelect:createRecordsBox(lastText) diff --git a/client/src/scenes/DesignHelper.lua b/client/src/scenes/DesignHelper.lua index ac9627716..2fc419301 100644 --- a/client/src/scenes/DesignHelper.lua +++ b/client/src/scenes/DesignHelper.lua @@ -41,7 +41,7 @@ function DesignHelper:loadGrid() end function DesignHelper:loadRankedSelection(width) - local rankedSelector = ui.BoolSelector({startValue = true, vFill = true, width = width, vAlign = "center", hAlign = "center"}) + local rankedSelector = ui.BoolSelector({startValue = true, width = width, vAlign = "center", hAlign = "center"}) return rankedSelector end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index 92bd9d967..a0a00b27b 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -49,8 +49,8 @@ function EndlessMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleSelector, player) + local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleContainer, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/scenes/GameBase.lua b/client/src/scenes/GameBase.lua index 8c8605577..bf1f793ef 100644 --- a/client/src/scenes/GameBase.lua +++ b/client/src/scenes/GameBase.lua @@ -15,6 +15,7 @@ local ui = require("client.src.ui") local FileUtils = require("client.src.FileUtils") local ClientStack = require("client.src.ClientStack") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") -- Scene template for running any type of game instance (endless, vs-self, replays, etc.) ---@class GameBase : Scene @@ -444,14 +445,14 @@ function GameBase:drawHUD() end stack:drawLevel() - if stack.analytic and not config.debug_mode then + if stack.analytic and not DebugSettings.showStackDebugInfo() then --prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() --prof.pop("Stack:drawAnalyticData") end end - if not config.debug_mode and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details + if not DebugSettings.showStackDebugInfo() and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details GraphicsUtil.print(GAME.battleRoom.spectatorString, themes[config.theme].spectators_Pos[1], themes[config.theme].spectators_Pos[2]) end diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index 8054187c7..ffb9f72ca 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -4,6 +4,7 @@ local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") +local DebugSettings = require("client.src.debug.DebugSettings") local EndlessMenu = require("client.src.scenes.EndlessMenu") local PuzzleMenu = require("client.src.scenes.PuzzleMenu") local TimeAttackMenu = require("client.src.scenes.TimeAttackMenu") @@ -40,6 +41,16 @@ local function switchToScene(scene, transition) GAME.navigationStack:push(scene, transition) end +function MainMenu:refresh() + if self.menu then + self.menu:detach() + self.menu = nil + end + + self.menu = self:createMainMenu() + self.uiRoot:addChild(self.menu) +end + function MainMenu:createMainMenu() local menuItems = {ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() @@ -104,12 +115,12 @@ function MainMenu:createMainMenu() } local function addDebugMenuItems() - if config.debugShowServers then + if DebugSettings.showDebugServers() then for i, menuItem in ipairs(debugMenuItems) do menu:addMenuItem(i + 7, menuItem) end end - if config.debugShowDesignHelper then + if DebugSettings.showDesignHelper() then menu:addMenuItem(#menu.menuItems, ui.MenuItem.createButtonMenuItem("Design Helper", nil, nil, function() switchToScene(DesignHelper()) end)) diff --git a/client/src/scenes/ModManagement.lua b/client/src/scenes/ModManagement.lua index 95761b775..5b5bac0d5 100644 --- a/client/src/scenes/ModManagement.lua +++ b/client/src/scenes/ModManagement.lua @@ -167,7 +167,7 @@ function ModManagement:loadStageGrid() if stageId ~= consts.RANDOM_STAGE_SPECIAL_VALUE then local stage = allStages[stageId] local icon = ui.ImageContainer({drawBorders = true, image = stage.images.thumbnail, hFill = true, vFill = true, hAlign = "center", vAlign = "center"}) - local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center"}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() stage:enable(boolSelector.value) @@ -181,7 +181,7 @@ function ModManagement:loadStageGrid() GAME.localPlayer:setStage(stages[consts.RANDOM_STAGE_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center"}) visibilitySelector.onValueChange = function(boolSelector, value) end local name = ui.Label({text = stage.display_name, translate = false, hAlign = "center", vAlign = "center"}) @@ -241,7 +241,7 @@ function ModManagement:loadCharacterGrid() if characterId ~= consts.RANDOM_CHARACTER_SPECIAL_VALUE then local character = allCharacters[characterId] local icon = ui.ImageContainer({drawBorders = true, image = character.images.icon, hFill = true, vFill = true}) - local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center"}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() character:enable(boolSelector.value) @@ -255,7 +255,7 @@ function ModManagement:loadCharacterGrid() GAME.localPlayer:setCharacter(characters[consts.RANDOM_CHARACTER_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center"}) visibilitySelector.onValueChange = function(boolSelector, value) end local displayName = ui.Label({text = character.display_name, translate = false, hAlign = "center", vAlign = "center"}) diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index daf252512..7b7477f37 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -2,6 +2,7 @@ local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local inputManager = require("client.src.inputManager") local save = require("client.src.save") +local DebugMenu = require("client.src.debug.DebugMenu") local consts = require("common.engine.consts") local fileUtils = require("client.src.FileUtils") local analytics = require("client.src.analytics") @@ -15,7 +16,6 @@ local ModManagement = require("client.src.scenes.ModManagement") local system = require("client.src.system") local JsonSafePrecision = require("common.data.JsonSafePrecision") local logger = require("common.lib.logger") -local prof = require("common.lib.zoneProfiler") -- Scene for the options menu local OptionsMenu = class(function(self, sceneParams) @@ -486,30 +486,14 @@ function OptionsMenu:loadSoundMenu() end function OptionsMenu:loadDebugMenu() - local debugMenuOptions = { - ui.MenuItem.createToggleButtonGroupMenuItem("op_debug_mode", nil, nil, createToggleButtonGroup("debug_mode")), - ui.MenuItem.createSliderMenuItem("VS Frames Behind", nil, false, createConfigSlider("debug_vsFramesBehind", 0, 200)), - ui.MenuItem.createToggleButtonGroupMenuItem("Show Debug Servers", nil, false, createToggleButtonGroup("debugShowServers")), - ui.MenuItem.createToggleButtonGroupMenuItem("Show Design Helper", nil, false, createToggleButtonGroup("debugShowDesignHelper")), - ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() - GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) - end), - ui.MenuItem.createToggleButtonGroupMenuItem("Profile frame times", nil, false, createToggleButtonGroup("debugProfile", - function() - prof.enable(config.debugProfile) - prof.setDurationFilter(config.debugProfileThreshold / 1000) - end)), - ui.MenuItem.createSliderMenuItem("Discard frames below duration (ms)", nil, false, createConfigSlider("debugProfileThreshold", 0, 100, - function() - prof.setDurationFilter(config.debugProfileThreshold / 1000) - end)), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() - GAME.theme:playCancelSfx() - self:switchToScreen("baseMenu") - end), - } - - return ui.Menu.createCenteredMenu(debugMenuOptions) + local debugMenu = DebugMenu.makeDebugMenu({ + showBackButton = true, + onBack = function() + self:switchToScreen("baseMenu") + end, + height = themes[config.theme].main_menu_max_height + }) + return debugMenu end function OptionsMenu:loadAboutMenu() diff --git a/client/src/scenes/PortraitGame.lua b/client/src/scenes/PortraitGame.lua index 3f05ab166..d95886cb9 100644 --- a/client/src/scenes/PortraitGame.lua +++ b/client/src/scenes/PortraitGame.lua @@ -6,6 +6,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local ui = require("client.src.ui") local input = require("client.src.inputManager") local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") local PortraitGame = class(function(self, sceneParams) end, @@ -193,7 +194,7 @@ function PortraitGame:flipToPortrait() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_HEIGHT, consts.CANVAS_WIDTH, {dpiscale=GAME:newCanvasSnappedScale()}) local width, height, _ = love.window.getMode() - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then -- flip the window dimensions to portrait love.window.updateMode(height, width, {}) love.window.setFullscreen(true) @@ -245,7 +246,7 @@ function PortraitGame:returnToLandscape() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) -- flip the window dimensions to landscape local width, height, _ = love.window.getMode() - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then love.window.updateMode(height, width, {}) love.window.setFullscreen(false) --GAME:updateCanvasPositionAndScale(width, height) diff --git a/client/src/scenes/ReplayGame.lua b/client/src/scenes/ReplayGame.lua index f2e306a7f..911cc0a9a 100644 --- a/client/src/scenes/ReplayGame.lua +++ b/client/src/scenes/ReplayGame.lua @@ -5,6 +5,7 @@ local util = require("common.lib.util") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local prof = require("common.lib.zoneProfiler") +local DebugSettings = require("client.src.debug.DebugSettings") local ReplayGame = class( function (self, sceneParams) @@ -141,7 +142,7 @@ function ReplayGame:drawHUD() end stack:drawLevel() - if stack.analytic and not DEBUG_ENABLED then + if stack.analytic and not DebugSettings.showStackDebugInfo() then prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() prof.pop("Stack:drawAnalyticData") diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index ea9f9adce..0270b7ccd 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -5,6 +5,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") local SoundController = require("client.src.music.SoundController") local directsFocus = require("client.src.ui.FocusDirector") +local DebugSettings = require("client.src.debug.DebugSettings") ---@alias sceneMusic ("none" | "main" | "title_screen" | "select_screen") @@ -83,7 +84,7 @@ end function Scene:drawCommunityMessage() -- Draw the community message - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then GraphicsUtil.printf(join_community_msg or "", 0, (668 / 720) * GAME.globalCanvas:getHeight(), GAME.globalCanvas:getWidth(), "center") end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 1ab39f816..c846420e7 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -48,8 +48,8 @@ function TimeAttackMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleSelector, player) + local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleContainer, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/ui/BoolSelector.lua b/client/src/ui/BoolSelector.lua index 5d4d7e83e..2827daeb2 100644 --- a/client/src/ui/BoolSelector.lua +++ b/client/src/ui/BoolSelector.lua @@ -3,6 +3,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class BoolSelectorOptions : UiElementOptions ---@field startValue boolean? @@ -11,9 +12,22 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@class BoolSelector : UiElement ---@field value boolean ---@field vertical boolean -local BoolSelector = class(function(boolSelector, options) - boolSelector.value = options.startValue or false - boolSelector.vertical = false +---@field circleRadius number +---@field extraDistance number +---@field lengthPadding number +---@field widthPadding number +local BoolSelector = class(function(self, options) + self.value = options.startValue or false + self.vertical = false + self.circleRadius = 10 + self.extraDistance = 16 + self.lengthPadding = 2 + self.widthPadding = 2 + self.onValueChange = options.onValueChange or function() end + + -- Calculate initial dimensions + self.width = self:calculateWidth() + self.height = self:calculateHeight() end, UiElement) @@ -62,57 +76,60 @@ function BoolSelector:setValue(value) end end +---@return number +function BoolSelector:calculateWidth() + local width = self.circleRadius * 2 + 2 * self.widthPadding + if not self.vertical then + width = width + self.extraDistance + end + return width +end + +---@return number +function BoolSelector:calculateHeight() + local height = self.circleRadius * 2 + 2 * self.lengthPadding + if self.vertical then + height = height + self.extraDistance + end + return height +end + -- other code may implement a callback here -- function BoolSelector.onValueChange() end -local circleRadius = 10 -local extraDistance = 16 -local lengthPadding = 2 -local widthPadding = 2 -local totalWidth = 0 -local totalLength = 0 -local fakeCenteredChild = {hAlign = "center", vAlign = "center", width = totalWidth, height = totalLength} - function BoolSelector:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(0, 0, 1, 1) GraphicsUtil.drawRectangle("line", self.x + 1, self.y + 1, self.width - 2, self.height - 2) GraphicsUtil.setColor(1, 1, 1, 1) end - local circleX = circleRadius + widthPadding - local circleY = circleRadius + lengthPadding - totalWidth = circleRadius * 2 + 2 * widthPadding - totalLength = circleRadius * 2 + 2 * lengthPadding + local drawX = self.x + self.widthPadding + local drawY = self.y + self.lengthPadding + local drawWidth = self.width - 2 * self.widthPadding + local drawHeight = self.height - 2 * self.lengthPadding + + local circleX = self.circleRadius + local circleY = self.circleRadius + if self.vertical then - totalLength = totalLength + extraDistance if self.value == false then - circleY = circleY + extraDistance + circleY = circleY + self.extraDistance end else - totalWidth = totalWidth + extraDistance if self.value then - circleX = circleX + extraDistance + circleX = circleX + self.extraDistance end end - fakeCenteredChild.width = totalWidth - fakeCenteredChild.height = totalLength - - -- we want these to be centered but creating a Rectangle / Circle ui element is maybe a bit too much? - -- so just apply the translation via a fake element with all necessary props - GraphicsUtil.applyAlignment(self, fakeCenteredChild) - love.graphics.translate(self.x, self.y) if self.value then GraphicsUtil.setColor(30/255, 190/255, 67/255, 1) - GraphicsUtil.drawRectangle("fill", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) + GraphicsUtil.drawRectangle("fill", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) GraphicsUtil.setColor(1, 1, 1, 1) end - GraphicsUtil.drawRectangle("line", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) - love.graphics.circle("fill", circleX, circleY, circleRadius) - - GraphicsUtil.resetAlignment() + GraphicsUtil.drawRectangle("line", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) + love.graphics.circle("fill", drawX + circleX, drawY + circleY, self.circleRadius) end return BoolSelector \ No newline at end of file diff --git a/client/src/ui/Carousel.lua b/client/src/ui/Carousel.lua index 4215d017f..bfd71988a 100644 --- a/client/src/ui/Carousel.lua +++ b/client/src/ui/Carousel.lua @@ -4,6 +4,7 @@ local Focusable = require(PATH .. ".Focusable") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") +local DebugSettings = require("client.src.debug.DebugSettings") local function calculateFontSize(height) return math.floor(height / 2) + 1 @@ -87,7 +88,7 @@ function Carousel.setPassengerByIndex(self, index) end function Carousel:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) end end diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index 2dfc7a2bf..aedf54c00 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -3,6 +3,7 @@ local UiElement = require(PATH .. ".UIElement") local GridElement = require(PATH .. ".GridElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local Grid = class(function(self, options) self.unitSize = options.unitSize @@ -73,7 +74,7 @@ function Grid:createElementAt(x, y, width, height, description, uiElement, noPad end function Grid:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 1, 1, 0.5) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/GridElement.lua b/client/src/ui/GridElement.lua index d0725fdf8..ff9165674 100644 --- a/client/src/ui/GridElement.lua +++ b/client/src/ui/GridElement.lua @@ -2,6 +2,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local GridElement = class(function(gridElement, options) if options.content then @@ -16,7 +17,7 @@ local GridElement = class(function(gridElement, options) gridElement.gridHeight = options.gridHeight if options.drawBorders ~= nil then gridElement.drawBorders = options.drawBorders - elseif DEBUG_ENABLED then + elseif DebugSettings.showUIElementBorders() then gridElement.drawBorders = true else gridElement.drawBorders = false diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 7931404d2..400192c1c 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -5,6 +5,7 @@ local TextButton = require(PATH .. ".TextButton") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") -- MenuItem is a specific UIElement that all children of Menu should be local MenuItem = class(function(self, options) @@ -27,7 +28,7 @@ function MenuItem.createMenuItem(label, item) menuItem.width = label.width + (2 * MenuItem.PADDING) - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then label.height = math.max(30, label.height + (2 * MenuItem.PADDING)) menuItem.height = math.max(30, label.height, item and item.height or 0) else @@ -38,7 +39,7 @@ function MenuItem.createMenuItem(label, item) local spaceBetween = 16 item.x = label.width + spaceBetween item.vAlign = "center" - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then item.height = math.max(30, item.height) end menuItem.width = item.x + item.width + MenuItem.PADDING @@ -128,7 +129,19 @@ function MenuItem.createSliderMenuItem(text, replacements, translate, slider) end local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) local menuItem = MenuItem.createMenuItem(label, slider) - + + return menuItem +end + +function MenuItem.createBoolSelectorMenuItem(text, replacements, translate, boolSelector) + assert(text ~= nil) + assert(boolSelector ~= nil) + if translate == nil then + translate = true + end + local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) + local menuItem = MenuItem.createMenuItem(label, boolSelector) + return menuItem end diff --git a/client/src/ui/MultiPlayerSelectionWrapper.lua b/client/src/ui/MultiPlayerSelectionWrapper.lua index c0802d71c..969dba238 100644 --- a/client/src/ui/MultiPlayerSelectionWrapper.lua +++ b/client/src/ui/MultiPlayerSelectionWrapper.lua @@ -18,6 +18,7 @@ end, StackPanel) function MultiPlayerSelectionWrapper:addElement(uiElement, player) + assert(uiElement.receiveInputs) self.wrappedElements[player] = uiElement uiElement.yieldFocus = function() self.yieldFocus() diff --git a/client/src/ui/OverlayContainer.lua b/client/src/ui/OverlayContainer.lua new file mode 100644 index 000000000..e3a414671 --- /dev/null +++ b/client/src/ui/OverlayContainer.lua @@ -0,0 +1,124 @@ +local class = require("common.lib.class") +local UiElement = require("client.src.ui.UIElement") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") + +-- Full-screen semi-transparent overlay that holds a centered UI element +-- Closes on click outside content area +---@class OverlayContainer : UiElement +---@field content UiElement? The centered content element +---@field onClose fun()? Callback invoked when overlay closes +---@field active boolean True when overlay is open and processing input +local OverlayContainer = class( + function(self, options) + options = options or {} + self.content = options.content + self.onClose = options.onClose + self.active = false + + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + self:setVisibility(false) + + if self.content then + self:addChild(self.content) + self.content.hAlign = "center" + self.content.vAlign = "center" + end + end, + UiElement +) + +-- Opens the overlay +function OverlayContainer:open() + self.active = true + self:setVisibility(true) +end + +-- Closes the overlay +function OverlayContainer:close() + if not self.active then + return + end + + self.active = false + self:setVisibility(false) + + if self.onClose then + self.onClose() + end +end + +-- Checks if the overlay is currently active +---@return boolean +function OverlayContainer:isActive() + return self.active +end + +-- Sets the content element for the overlay +---@param content UiElement The content to display in the center +function OverlayContainer:setContent(content) + if self.content then + self.content:detach() + end + + self.content = content + if self.content then + self:addChild(self.content) + self.content.hAlign = "center" + self.content.vAlign = "center" + end +end + +-- Draws the semi-transparent background +function OverlayContainer:drawSelf() + if not self.active then + return + end + + GraphicsUtil.setColor(0, 0, 0, 0.75) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Touch handler - closes overlay if clicking outside content +---@return boolean? True to block touch event propagation +function OverlayContainer:onTouch(x, y) + if not self.active then + return false + end + + if self.content then + local contentX, contentY = self.content:getScreenPos() + local inContentBounds = x >= contentX and x < contentX + self.content.width and + y >= contentY and y < contentY + self.content.height + + if not inContentBounds then + self:close() + return true + end + end + + return true +end + +-- Release handler - blocks event propagation +---@return boolean? True to block release event propagation +function OverlayContainer:onRelease() + if self.active then + return true + end +end + +-- Input handler - closes overlay on ESC key +function OverlayContainer:receiveInputs(inputs, dt) + if not self.active then + return + end + + if inputs.isDown["MenuEsc"] then + self:close() + end +end + +return OverlayContainer diff --git a/client/src/ui/PagedUniGrid.lua b/client/src/ui/PagedUniGrid.lua index 2dfe1bc41..a96458946 100644 --- a/client/src/ui/PagedUniGrid.lua +++ b/client/src/ui/PagedUniGrid.lua @@ -6,6 +6,7 @@ local Grid = require(PATH .. ".Grid") local class = require("common.lib.class") local Signal = require("common.lib.signal") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local function addNewPage(pagedUniGrid) local grid = Grid({ @@ -101,7 +102,7 @@ function PagedUniGrid:refreshPageTurnButtonVisibility() end function PagedUniGrid:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 0, 0, 1) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index e1b226976..a1018b358 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -3,23 +3,26 @@ local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") --- StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting --- Useful for auto-aligning multiple ui elements that only know one of their dimensions +---@class StackPanel : UiElement +---StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting. +---Useful for auto-aligning multiple ui elements that only know one of their dimensions. +---@field alignment "left"|"right"|"top"|"bottom" Direction in which children are stacked +---@field pixelsTaken number Tracks how many pixels are already taken in the stacking direction +---@field TYPE string Class type identifier local StackPanel = class(function(stackPanel, options) - -- all children are aligned automatically towards that option inside the StackPanel - -- possible values: "left", "right", "top", "bottom" + ---@type "left"|"right"|"top"|"bottom" stackPanel.alignment = options.alignment - - -- StackPanels are unidirectional but can go into either direction - -- pixelsTaken tracks how many pixels are already taken in the direction the StackPanel propagates towards + ---@type number stackPanel.pixelsTaken = 0 - -- a stack panel does not have a size limit it's alignment dimension grows with its content end, UiElement) StackPanel.TYPE = "StackPanel" +---Applies positioning and sizing settings to a UI element based on the StackPanel's alignment +---@param uiElement UiElement The element to apply settings to function StackPanel:applyStackPanelSettings(uiElement) if self.alignment == "left" then uiElement.hFill = false @@ -48,19 +51,29 @@ function StackPanel:applyStackPanelSettings(uiElement) end end +---Adds a UI element to the StackPanel, applying proper positioning and resizing +---@param uiElement UiElement The element to add function StackPanel:addElement(uiElement) self:applyStackPanelSettings(uiElement) self:addChild(uiElement) self:resize() + uiElement.yieldFocus = function() + self.yieldFocus() + end end - +---Inserts a UI element at a specific index in the StackPanel +---@param uiElement UiElement The element to insert +---@param index number The position to insert at (1-based) function StackPanel:insertElementAtIndex(uiElement, index) -- add it at the end StackPanel.addElement(self, uiElement) StackPanel.shiftTo(self, uiElement, index) end +---Shifts an element to a specific index by swapping positions with preceding elements +---@param uiElement UiElement The element to shift +---@param index number The target position (1-based) function StackPanel:shiftTo(uiElement, index) -- swap the previous element with it while updating values until it reached the desired index for i = #self.children - 1, index, -1 do @@ -82,6 +95,9 @@ function StackPanel:shiftTo(uiElement, index) end end +---Removes an element from the StackPanel, updating positions and pixel tracking +---IMPORTANT: Use this method instead of element:detach() to maintain proper layout state +---@param uiElement UiElement The element to remove function StackPanel:remove(uiElement) local index = tableUtils.indexOf(self.children, uiElement) @@ -114,8 +130,21 @@ function StackPanel:remove(uiElement) uiElement:detach() end +---Processes user input and forwards it to child elements +---@param input table Input state +---@param dt number Delta time since last frame +function StackPanel:receiveInputs(input, dt) + for _, child in ipairs(self.children) do + if child.receiveInputs then + child:receiveInputs(input, dt) + return + end + end +end + +---Draws the StackPanel's debug borders if enabled in debug settings function StackPanel:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 0, 0, 0.7) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/Stepper.lua b/client/src/ui/Stepper.lua index a342bb645..9aeba5b22 100644 --- a/client/src/ui/Stepper.lua +++ b/client/src/ui/Stepper.lua @@ -5,6 +5,7 @@ local Label = require(PATH .. ".Label") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local NAV_BUTTON_WIDTH = 25 local EMPTY_STEPPER_WIDTH = 160 @@ -110,7 +111,7 @@ function Stepper:refreshLocalization() end function Stepper:drawSelf() - if config.debug_mode then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(self.color) GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(self.borderColor) diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index e297c2646..f44700524 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -10,15 +10,11 @@ local touchHandler = { } function touchHandler:touch(x, y) - local activeScene = GAME.navigationStack:getActiveScene() - -- if there is no active scene that implies an on-going scene switch, no interactions should be possible - if activeScene then - -- prevent multitouch - if not self.touchedElement then - self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) - if self.touchedElement and self.touchedElement.onTouch then - self.touchedElement:onTouch(x, y) - end + -- prevent multitouch + if not self.touchedElement then + self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) + if self.touchedElement and self.touchedElement.onTouch then + self.touchedElement:onTouch(x, y) end end end diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 18172e4ce..2efa110a4 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -12,6 +12,7 @@ local LegacyPanelSource = require("common.compatibility.LegacyPanelSource") local InputCompression = require("common.data.InputCompression") local ReplayV3 = require("common.data.ReplayV3") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class Match ---@field stacks (Stack | SimulatedStack)[] The stacks to run as part of the match @@ -615,10 +616,11 @@ function Match:shouldRun(stack, runsSoFar) end -- In debug mode allow non-local player 2 to fall a certain number of frames behind - if config and config.debug_mode and not stack.is_local and config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then + local framesBehind = DebugSettings.getVSFramesBehind() + if not stack.is_local and framesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then -- Only stay behind if the game isn't over for the local player (=garbageTarget) yet if self.garbageTargets[2][1] and self.garbageTargets[2][1].game_ended and self.garbageTargets[2][1]:game_ended() == false then - if stack.clock + config.debug_vsFramesBehind >= self.garbageTargets[2][1].clock then + if stack.clock + framesBehind >= self.garbageTargets[2][1].clock then return false end end diff --git a/docs/DebugSettings.md b/docs/DebugSettings.md new file mode 100644 index 000000000..36dc2a74b --- /dev/null +++ b/docs/DebugSettings.md @@ -0,0 +1,117 @@ +# Debug Settings System Documentation + +## Overview +The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to `config.debug` and can be accessed anywhere via the `DebugSettings` singleton. + +## Architecture + +### Components +- **DebugSettings** (`client/src/debug/DebugSettings.lua`) - Singleton managing all debug settings +- **OverlayContainer** (`client/src/ui/OverlayContainer.lua`) - Full-screen overlay UI for settings menu +- **Debug Button** (`client/src/Game.lua`) - Bottom-right button that opens the overlay (only visible when `DEBUG_ENABLED`) + +## How Debug-Only Settings Work + +Settings can be marked as `debugBuildOnly = true` to control their availability: + +### Debug Builds (`DEBUG_ENABLED = true`) +- Setting appears in the UI overlay +- Can be toggled on/off by the user +- Value persists to config file +- Returns actual user-configured value + +### Release Builds (`DEBUG_ENABLED = false`) +- Setting is hidden from the UI overlay +- Always returns the default value (typically `false`) +- Persisted value is ignored +- Cannot be changed at runtime + +This is implemented through two mechanisms: + +**1. UI Filtering** - `DebugSettings.getDefinitions()` filters out `debugBuildOnly` settings in release builds + +**2. Value Locking** - `DebugSettings.get()` returns the default value for debug-only settings in release builds + +## Adding New Debug Settings + +### Step 1: Add Setting Definition + +Add a new entry to the `settingDefinitions` table in `DebugSettings.lua`: + +```lua +{ + key = "myNewSetting", + type = "boolean", -- or "number" + default = false, + label = "My New Feature", + debugBuildOnly = true -- false if should be available in release builds +} +``` + +For number settings, add `min` and `max`: +```lua +{ + key = "myNumberSetting", + type = "number", + default = 0, + label = "My Number Setting", + min = 0, + max = 100, + debugBuildOnly = true +} +``` + +### Step 2: Add Accessor Methods + +Add getter and optional setter methods following the naming convention: + +```lua +-- Getter +function DebugSettings.myNewSetting() + return DebugSettings.get("myNewSetting") --[[@as boolean]] +end + +-- Setter (if needed for programmatic access) +function DebugSettings.setMyNewSetting(value) + DebugSettings.set("myNewSetting", value) +end +``` + +### Step 3: Use in Code + +Replace existing debug checks with the new method: + +```lua + +if DebugSettings.myNewSetting() then + -- debug behavior +end +``` + +## Setting Definition Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | Yes | Unique key used in config.debug | +| `type` | "boolean" \| "number" | Yes | Data type of the setting | +| `default` | boolean \| number | Yes | Default value when not configured | +| `label` | string | Yes | Display label shown in UI overlay | +| `min` | number | No | Minimum value (number types only) | +| `max` | number | No | Maximum value (number types only) | +| `debugBuildOnly` | boolean | No | If true, only available in DEBUG_ENABLED builds (defaults to false) | + +## Persistence + +Settings are automatically saved to `config.debug` in the user's config file: + +```lua +config.debug = { + showStackDebugInfo = false, + showUIElementBorders = true, + vsFramesBehind = 0, + -- ... other settings +} +``` + +- Settings load on startup via `DebugSettings.init()` +- Settings save immediately when changed via `DebugSettings.set()` diff --git a/main.lua b/main.lua index 952272a39..bcd2dcb98 100644 --- a/main.lua +++ b/main.lua @@ -1,6 +1,7 @@ local logger = require("common.lib.logger") require("common.lib.mathExtensions") local utf8 = require("common.lib.utf8Additions") +local DebugSettings = require("client.src.debug.DebugSettings") local inputManager = require("client.src.inputManager") require("client.src.globals") local touchHandler = require("client.src.ui.touchHandler") @@ -62,8 +63,8 @@ function love.load(args, rawArgs) GAME:load() if not PROFILE_MEMORY then - prof.enable(config.debugProfile) - prof.setDurationFilter(config.debugProfileThreshold / 1000) + prof.enable(DebugSettings.getProfileFrameTimes()) + prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) end end @@ -78,7 +79,7 @@ end -- Intentional override ---@diagnostic disable-next-line: duplicate-set-field function love.update(dt) - if config.show_fps and config.debug_mode then + if DebugSettings.showRuntimeGraph() then if CustomRun.runTimeGraph == nil then CustomRun.runTimeGraph = RunTimeGraph() end @@ -126,7 +127,7 @@ end function love.draw() GAME:draw() - if DEBUG_ENABLED then + if DebugSettings.drawGraphicsStats() then local stats = love.graphics.getStats() local width, height = love.graphics.getDimensions() From eae0a02b8fd8cd126e3828ed773b5815fa326065 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Tue, 28 Oct 2025 21:47:31 -0700 Subject: [PATCH 14/16] Revert "Add Debug Settings Architecture (#705)" This reverts commit 30e1316f0a5157b2bac1428700f0661710bbdc67. --- client/src/BattleRoom.lua | 3 +- client/src/ChallengeModePlayerStack.lua | 3 +- client/src/ClientMatch.lua | 5 +- client/src/Game.lua | 69 +-- client/src/NavigationStack.lua | 36 +- client/src/PlayerStack.lua | 7 +- client/src/Shortcuts.lua | 8 +- client/src/config.lua | 31 +- client/src/debug/DebugMenu.lua | 90 ---- client/src/debug/DebugSettings.lua | 418 ------------------ client/src/developer.lua | 3 +- client/src/scenes/CharacterSelect.lua | 77 ++-- client/src/scenes/DesignHelper.lua | 2 +- client/src/scenes/EndlessMenu.lua | 4 +- client/src/scenes/GameBase.lua | 5 +- client/src/scenes/MainMenu.lua | 15 +- client/src/scenes/ModManagement.lua | 8 +- client/src/scenes/OptionsMenu.lua | 34 +- client/src/scenes/PortraitGame.lua | 5 +- client/src/scenes/ReplayGame.lua | 3 +- client/src/scenes/Scene.lua | 3 +- client/src/scenes/TimeAttackMenu.lua | 4 +- client/src/ui/BoolSelector.lua | 81 ++-- client/src/ui/Carousel.lua | 3 +- client/src/ui/Grid.lua | 3 +- client/src/ui/GridElement.lua | 3 +- client/src/ui/MenuItem.lua | 19 +- client/src/ui/MultiPlayerSelectionWrapper.lua | 1 - client/src/ui/OverlayContainer.lua | 124 ------ client/src/ui/PagedUniGrid.lua | 3 +- client/src/ui/StackPanel.lua | 49 +- client/src/ui/Stepper.lua | 3 +- client/src/ui/touchHandler.lua | 14 +- common/engine/Match.lua | 6 +- docs/DebugSettings.md | 117 ----- main.lua | 9 +- 36 files changed, 185 insertions(+), 1083 deletions(-) delete mode 100644 client/src/debug/DebugMenu.lua delete mode 100644 client/src/debug/DebugSettings.lua delete mode 100644 client/src/ui/OverlayContainer.lua delete mode 100644 docs/DebugSettings.md diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 0beeb5352..5d1f16432 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -12,7 +12,6 @@ local BlackFadeTransition = require("client.src.scenes.Transitions.BlackFadeTran local Easings = require("client.src.Easings") local system = require("client.src.system") local GeneratorSource = require("common.engine.GeneratorSource") -local DebugSettings = require("client.src.debug.DebugSettings") -- A Battle Room is a session of matches, keeping track of the room number, player settings, wins / losses etc ---@class BattleRoom : Signal @@ -384,7 +383,7 @@ function BattleRoom:createScene(match) end -- for touch android players load a different scene - if (system.isMobileOS() or DebugSettings.simulateMobileOS()) and self.gameScene.name ~= "PuzzleGame" and + if (system.isMobileOS() or DEBUG_ENABLED) and self.gameScene.name ~= "PuzzleGame" and --but only if they are the only local player cause for 2p vs local using portrait mode would be bad tableUtils.count(self.players, function(p) return p.isLocal and p.human end) == 1 then for _, player in ipairs(self.players) do diff --git a/client/src/ChallengeModePlayerStack.lua b/client/src/ChallengeModePlayerStack.lua index eea1a3be5..91d3bb198 100644 --- a/client/src/ChallengeModePlayerStack.lua +++ b/client/src/ChallengeModePlayerStack.lua @@ -1,7 +1,6 @@ local class = require("common.lib.class") local ClientStack = require("client.src.ClientStack") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") ---@class ChallengeModePlayerStack : ClientStack ---@field engine SimulatedStack @@ -199,7 +198,7 @@ function ChallengeModePlayerStack:drawMultibar() end function ChallengeModePlayerStack:drawDebug() - if DebugSettings.showStackDebugInfo() then + if config.debug_mode then local drawX = self.frameOriginX + self:canvasWidth() / 2 local drawY = 10 local padding = 14 diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index 1574a3b0a..bf71af747 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -16,7 +16,6 @@ local Telegraph = require("client.src.graphics.Telegraph") local MatchParticipant = require("client.src.MatchParticipant") local ChallengeModePlayerStack = require("client.src.ChallengeModePlayerStack") local NetworkProtocol = require("common.network.NetworkProtocol") -local DebugSettings = require("client.src.debug.DebugSettings") ---@module "client.src.ChallengeModePlayerStack" ---@class ClientMatch @@ -560,7 +559,7 @@ end function ClientMatch:drawCommunityMessage() -- Draw the community message - if not DebugSettings.showStackDebugInfo() then + if not config.debug_mode then GraphicsUtil.printf(join_community_msg or "", 0, 668, consts.CANVAS_WIDTH, "center") end end @@ -599,7 +598,7 @@ function ClientMatch:render() end end - if DebugSettings.showStackDebugInfo() then + if config.debug_mode then local padding = 14 local drawX = 500 local drawY = -4 diff --git a/client/src/Game.lua b/client/src/Game.lua index 22f765e5f..1952ff0c4 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -33,14 +33,6 @@ local system = require("client.src.system") local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") -local DebugSettings = require("client.src.debug.DebugSettings") -local Button = require("client.src.ui.Button") -local TextButton = require("client.src.ui.TextButton") -local OverlayContainer = require("client.src.ui.OverlayContainer") -local DebugMenu = require("client.src.debug.DebugMenu") -local Label = require("client.src.ui.Label") -local UIElement = require("client.src.ui.UIElement") -local NavigationStack = require("client.src.NavigationStack") -- Provides a scale that is on .5 boundary to make sure it renders well. -- Useful for creating new canvas with a solid DPI @@ -114,19 +106,12 @@ local Game = class( -- time in seconds, can be used by other elements to track the passing of time beyond dt self.timer = love.timer.getTime() - - self.debugOverlay = nil - self.debugButton = nil - - -- Root UI element that contains all UI (scenes + overlays + debug) - self.uiRoot = UIElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) end ) Game.newCanvasSnappedScale = newCanvasSnappedScale function Game:load() - DebugSettings.init() PuzzleLibrary.cleanupDefaultPuzzles(consts.PUZZLES_SAVE_DIRECTORY) -- move to constructor @@ -146,11 +131,8 @@ function Game:load() self.input:importConfigurations(user_input_conf) end - self.navigationStack = NavigationStack({}) + self.navigationStack = require("client.src.NavigationStack") self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) - - -- Add navigation stack to root UI - self.uiRoot:addChild(self.navigationStack) self.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) end @@ -271,8 +253,6 @@ function Game:setupRoutine() self:initializeLocalPlayer() ModController:loadModFor(characters[GAME.localPlayer.settings.characterId], GAME.localPlayer, true) - - self:initializeDebugOverlay() end -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate @@ -386,9 +366,9 @@ function Game:update(dt) handleShortcuts() - prof.push("uiRoot update") - self.uiRoot:update(dt) - prof.pop("uiRoot update") + prof.push("navigationStack update") + self.navigationStack:update(dt) + prof.pop("navigationStack update") if self.backgroundImage then self.backgroundImage:update(dt) @@ -406,7 +386,7 @@ function Game:draw() love.graphics.clear() -- With this, self.globalCanvas is clear and set as our active canvas everything is being drawn to - self.uiRoot:draw() + self.navigationStack:draw() self:drawFPS() self:drawScaleInfo() @@ -422,7 +402,8 @@ function Game:draw() end function Game:drawFPS() - if self.config.show_fps or DebugSettings.forceFPS() then + -- Draw the FPS if enabled + if self.config.show_fps then love.graphics.print("FPS: " .. love.timer.getFPS(), 1, 1) end end @@ -685,40 +666,4 @@ function Game:setLanguage(lang_code) Localization:refresh_global_strings() end -function Game:initializeDebugOverlay() - if not DEBUG_ENABLED then - return - end - - self.debugButton = TextButton({ - x = consts.CANVAS_WIDTH - 50, - y = consts.CANVAS_HEIGHT - 50, - label = Label({ - text = "Debug", - translate = false, - hAlign = "center", - vAlign = "center" - }), - width = 40, - height = 40, - onClick = function() - if self.debugOverlay then - if not self.debugOverlay:isActive() then - self.debugOverlay:open() - end - end - end - }) - - local debugMenu = DebugMenu.makeDebugMenu({height = consts.CANVAS_HEIGHT - 40}) - self.debugOverlay = OverlayContainer({ - content = debugMenu - }) - - -- Add debug UI to root - self.uiRoot:addChild(self.debugButton) - self.uiRoot:addChild(self.debugOverlay) -end - - return Game diff --git a/client/src/NavigationStack.lua b/client/src/NavigationStack.lua index 89ff75a42..e9ef669c3 100644 --- a/client/src/NavigationStack.lua +++ b/client/src/NavigationStack.lua @@ -1,23 +1,11 @@ local DirectTransition = require("client.src.scenes.Transitions.DirectTransition") local logger = require("common.lib.logger") -local UIElement = require("client.src.ui.UIElement") -local class = require("common.lib.class") -local consts = require("common.engine.consts") - ----@class NavigationStack : UiElement ----@field scenes Scene[] ----@field transition table? ----@field callback function? -local NavigationStack = class( - function(self) - self.scenes = {} - self.transition = nil - self.callback = nil - self.width = consts.CANVAS_WIDTH - self.height = consts.CANVAS_HEIGHT - end, - UIElement -) + +local NavigationStack = { + scenes = {}, + transition = nil, + callback = nil, +} function NavigationStack:push(newScene, transition) local activeScene = self.scenes[#self.scenes] @@ -154,7 +142,7 @@ function NavigationStack:getActiveScene() end end -function NavigationStack:updateSelf(dt) +function NavigationStack:update(dt) if self.transition then self.transition:update(dt) @@ -175,7 +163,7 @@ function NavigationStack:updateSelf(dt) end end -function NavigationStack:drawSelf() +function NavigationStack:draw() if self.transition then self.transition:draw() else @@ -186,12 +174,4 @@ function NavigationStack:drawSelf() end end -function NavigationStack:getTouchedElement(x, y) - local activeScene = self:getActiveScene() - if activeScene and activeScene.uiRoot then - return activeScene.uiRoot:getTouchedElement(x, y) - end - return nil -end - return NavigationStack \ No newline at end of file diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index 81f410220..4b3252096 100644 --- a/client/src/PlayerStack.lua +++ b/client/src/PlayerStack.lua @@ -15,7 +15,6 @@ local logger = require("common.lib.logger") require("client.src.analytics") local KeyDataEncoding = require("common.data.KeyDataEncoding") local MatchRules = require("common.data.MatchRules") -local DebugSettings = require("client.src.debug.DebugSettings") ---@module "common.data.LevelData" local floor, min, max = math.floor, math.min, math.max @@ -768,7 +767,7 @@ function PlayerStack:drawPopBurstParticle(atlas, quad, frameIndex, atlasDimensio end function PlayerStack:drawDebug() - if DebugSettings.showStackDebugInfo() then + if config.debug_mode then local engine = self.engine local x = self.origin_x + 480 @@ -864,7 +863,7 @@ function PlayerStack:drawDebug() end function PlayerStack:drawDebugPanels(shakeOffset) - if not DebugSettings.showStackDebugInfo() then + if not config.debug_mode then return end @@ -953,7 +952,7 @@ function PlayerStack:drawRating() local rating if self.player.rating and tonumber(self.player.rating) then rating = self.player.rating - elseif DebugSettings.showStackDebugInfo() then + elseif config.debug_mode then rating = 1544 + self.player.playerNumber end diff --git a/client/src/Shortcuts.lua b/client/src/Shortcuts.lua index 7c009e3ff..2d428a57f 100644 --- a/client/src/Shortcuts.lua +++ b/client/src/Shortcuts.lua @@ -7,13 +7,7 @@ local logger = require("common.lib.logger") local function runSystemCommands() -- toggle debug mode if input.allKeys.isDown["d"] then - if GAME.debugOverlay then - if GAME.debugOverlay.active then - GAME.debugOverlay:close() - else - GAME.debugOverlay:open() - end - end + config.debug_mode = not config.debug_mode -- reload characters elseif input.allKeys.isDown["c"] then characters_reload_graphics() diff --git a/client/src/config.lua b/client/src/config.lua index 93c69a3ff..c83d8b2e2 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -3,7 +3,6 @@ json = require("common.lib.dkjson") local util = require("common.lib.util") local fileUtils = require("client.src.FileUtils") local consts = require("common.engine.consts") -local DebugSettings = require("client.src.debug.DebugSettings") require("client.src.globals") -- Default configuration values @@ -28,7 +27,12 @@ require("client.src.globals") ---@field SFX_volume number ---@field music_volume number ---@field enableMenuMusic boolean ----@field debug DebugConfig? +---@field debug_mode boolean +---@field debugShowServers boolean +---@field debugShowDesignHelper boolean +---@field debugProfile boolean +---@field debugProfileThreshold integer +---@field debug_vsFramesBehind integer ---@field show_fps boolean ---@field show_ingame_infos boolean ---@field danger_music_changeback_delay boolean @@ -89,8 +93,12 @@ config = { SFX_volume = 50, music_volume = 50, enableMenuMusic = true, - -- Debug settings persisted separately - debug = DebugSettings.getDefaultConfigValues(), + -- Debug mode flag + debug_mode = false, + debugShowServers = false, + debugShowDesignHelper = false, + debugProfile = false, + debugProfileThreshold = 50, -- Show FPS in the top-left corner of the screen show_fps = false, @@ -218,6 +226,19 @@ config = { if type(read_data.music_volume) == "number" then configTable.music_volume = util.bound(0, read_data.music_volume, 100) end + if type(read_data.debug_mode) == "boolean" then + configTable.debug_mode = read_data.debug_mode + end + if type(read_data.debugShowServers) == "boolean" then + configTable.debugShowServers = read_data.debugShowServers + end + if type(read_data.debugShowDesignHelper) == "boolean" then + configTable.debugShowDesignHelper = read_data.debugShowDesignHelper + end + if type(read_data.debugProfile) == "boolean" then + configTable.debugProfile = read_data.debugProfile + end + -- debugProfileThreshold is not saved to prevent accidental dense profiling if type(read_data.show_fps) == "boolean" then configTable.show_fps = read_data.show_fps end @@ -289,8 +310,6 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end - - configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end end diff --git a/client/src/debug/DebugMenu.lua b/client/src/debug/DebugMenu.lua deleted file mode 100644 index 311e352fc..000000000 --- a/client/src/debug/DebugMenu.lua +++ /dev/null @@ -1,90 +0,0 @@ -local class = require("common.lib.class") -local ui = require("client.src.ui") -local DebugSettings = require("client.src.debug.DebugSettings") -local GraphicsUtil = require("client.src.graphics.graphics_util") - -local function createDebugBoolSelector(debugKey, onChangeFn) - return ui.BoolSelector({ - startValue = DebugSettings.get(debugKey) --[[@as boolean]], - onValueChange = function(selfElement, value) - GAME.theme:playMoveSfx() - DebugSettings.set(debugKey, value) - if onChangeFn then - onChangeFn() - end - end - }) -end - -local function createDebugSlider(debugKey, min, max, onValueChangeFn) - return ui.Slider({ - min = min, - max = max, - value = DebugSettings.get(debugKey) --[[@as number]], - tickLength = math.ceil(100 / max), - onValueChange = function(slider) - DebugSettings.set(debugKey, slider.value) - if onValueChangeFn then - onValueChangeFn(slider) - end - end - }) -end - -local function buildDebugMenuItems(options) - local debugMenuOptions = {} - - for _, def in ipairs(DebugSettings.getDefinitions()) do - if def.type == "boolean" then - debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createBoolSelectorMenuItem( - def.label, - nil, - false, - createDebugBoolSelector(def.key) - ) - elseif def.type == "number" then - debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createSliderMenuItem( - def.label, - nil, - false, - createDebugSlider(def.key, def.min or 0, def.max or 100) - ) - end - end - - if DEBUG_ENABLED then - debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() - GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) - end) - end - - if options.showBackButton then - debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() - GAME.theme:playCancelSfx() - if options.onBack then - options.onBack() - end - end) - end - - return debugMenuOptions -end - --- Menu for configuring debug settings ----@class DebugMenu : Menu -local DebugMenu = class(function(self, options) - -end, ui.Menu) - --- We need a factory because menu items must be passed into the base class init -function DebugMenu.makeDebugMenu(options) - options = options or {} - options.x = 0 - options.y = 0 - options.hAlign = "center" - options.vAlign = "center" - options.menuItems = buildDebugMenuItems(options) - return DebugMenu(options) -end - -return DebugMenu diff --git a/client/src/debug/DebugSettings.lua b/client/src/debug/DebugSettings.lua deleted file mode 100644 index 43b02a2dc..000000000 --- a/client/src/debug/DebugSettings.lua +++ /dev/null @@ -1,418 +0,0 @@ -local logger = require("common.lib.logger") - --- Singleton class for managing runtime debug settings --- Settings are persisted to the config file under config.debug ----@class DebugSettings -local DebugSettings = {} - ----@class DebugConfig ----@field showStackDebugInfo boolean ----@field showUIElementBorders boolean ----@field simulateMobileOS boolean ----@field forceFPS boolean ----@field drawGraphicsStats boolean ----@field showRuntimeGraph boolean ----@field vsFramesBehind number ----@field showDebugServers boolean ----@field showDesignHelper boolean ----@field profileFrameTimes boolean ----@field profileThreshold number - ----@class DebugSettingDefinition ----@field key string The key used in config.debug table ----@field type "boolean"|"number" The type of the setting ----@field default boolean|number The default value ----@field label string The display label for the UI ----@field min number? Minimum value for number types ----@field max number? Maximum value for number types ----@field debugBuildOnly boolean? Forces the setting off and hides it in non-debug builds - --- All debug settings defined in one place with their metadata -local settingDefinitions = { - { - key = "showStackDebugInfo", - type = "boolean", - default = false, - label = "Show Stack Debug Info", - debugBuildOnly = false - }, - { - key = "showUIElementBorders", - type = "boolean", - default = false, - label = "Show UI Element Borders", - debugBuildOnly = true - }, - { - key = "simulateMobileOS", - type = "boolean", - default = false, - label = "Simulate Mobile OS", - debugBuildOnly = true - }, - { - key = "forceFPS", - type = "boolean", - default = false, - label = "Force FPS Display", - debugBuildOnly = true - }, - { - key = "drawGraphicsStats", - type = "boolean", - default = false, - label = "Draw Graphics Stats", - debugBuildOnly = true - }, - { - key = "showRuntimeGraph", - type = "boolean", - default = false, - label = "Show Runtime Graph", - debugBuildOnly = true - }, - { - key = "vsFramesBehind", - type = "number", - default = 0, - label = "VS Frames Behind", - min = 0, - max = 200, - debugBuildOnly = true - }, - { - key = "showDebugServers", - type = "boolean", - default = false, - label = "Show Debug Servers", - debugBuildOnly = false - }, - { - key = "showDesignHelper", - type = "boolean", - default = false, - label = "Show Design Helper", - debugBuildOnly = true - }, - { - key = "profileFrameTimes", - type = "boolean", - default = false, - label = "Profile frame times", - debugBuildOnly = false - }, - { - key = "profileThreshold", - type = "number", - default = 50, - label = "Discard frames below duration (ms)", - min = 0, - max = 100, - debugBuildOnly = false - } -} - -local function isNonDebugBuild() - return not DEBUG_ENABLED -end - -local function shouldLockToDefault(def) - return isNonDebugBuild() and def.debugBuildOnly -end - ----Creates a DebugConfig table populated with default values. ----@return DebugConfig -local function createDefaultConfig() - local defaults = {} - for _, def in ipairs(settingDefinitions) do - local value = def.default - defaults[def.key] = value - end - ---@type DebugConfig - return defaults -end - ---- Current settings values (loaded from config.debug) ----@type DebugConfig -local settings = createDefaultConfig() - -local function clampNumber(value, minValue, maxValue) - if minValue then - value = math.max(minValue, value) - end - if maxValue then - value = math.min(maxValue, value) - end - return value -end - -local function findDefinition(key) - for _, def in ipairs(settingDefinitions) do - if def.key == key then - return def - end - end -end - ----Returns default debug configuration values keyed by setting. ----@return DebugConfig -function DebugSettings.getDefaultConfigValues() - return createDefaultConfig() -end - ----Normalizes persisted debug configuration values using definitions. ----@param persisted table|nil ----@return DebugConfig -function DebugSettings.normalizeConfigValues(persisted) - local normalized = createDefaultConfig() - local source = persisted or {} - - for _, def in ipairs(settingDefinitions) do - local value = source[def.key] - if def.type == "boolean" then - if type(value) == "boolean" then - normalized[def.key] = value - end - elseif def.type == "number" then - if type(value) ~= "number" then - value = def.default - end - normalized[def.key] = clampNumber(value, def.min, def.max) - end - end - - return normalized -end - --- Initializes debug settings from config -function DebugSettings.init() - config.debug = config.debug or {} - - config.debug = DebugSettings.normalizeConfigValues(config.debug) - - for _, def in ipairs(settingDefinitions) do - local value = config.debug[def.key] - if shouldLockToDefault(def) then - settings[def.key] = def.default - else - settings[def.key] = value - end - end -end - --- Saves current settings to config -local function saveSettings() - config.debug = config.debug or {} - - for _, def in ipairs(settingDefinitions) do - config.debug[def.key] = settings[def.key] - end - - write_conf_file() -end - -local releaseDefinitionCache --- Returns all setting definitions for UI generation ----@return DebugSettingDefinition[] -function DebugSettings.getDefinitions() - if not isNonDebugBuild() then - return settingDefinitions - end - - if not releaseDefinitionCache then - releaseDefinitionCache = {} - for _, def in ipairs(settingDefinitions) do - if not def.debugBuildOnly then - releaseDefinitionCache[#releaseDefinitionCache + 1] = def - end - end - end - - return releaseDefinitionCache -end - --- Gets the value of a setting by key ----@param key string ----@return boolean|number -function DebugSettings.get(key) - local def = findDefinition(key) - - if isNonDebugBuild() then - if def then - if shouldLockToDefault(def) then - return def.default - end - - if def.type == "boolean" then - return settings[key] or false - elseif def.type == "number" then - return settings[key] or 0 - end - end - return false - end - - if not def then - return false - end - - return settings[key] -end - --- Sets the value of a setting by key ----@param key string ----@param value boolean|number -function DebugSettings.set(key, value) - local def = findDefinition(key) - if not def then - logger.warn("Attempted to set unknown debug setting: " .. key) - return - end - - if def.type == "boolean" then - settings[key] = value and true or false - else - local numericValue = type(value) == "number" and value or def.default - settings[key] = clampNumber(numericValue, def.min, def.max) - end - saveSettings() -end - --- Returns whether to show stack debug information ----@return boolean -function DebugSettings.showStackDebugInfo() - return DebugSettings.get("showStackDebugInfo") --[[@as boolean]] -end - --- Returns whether to show UI element borders ----@return boolean -function DebugSettings.showUIElementBorders() - return DebugSettings.get("showUIElementBorders") --[[@as boolean]] -end - --- Returns whether to simulate mobile OS on desktop ----@return boolean -function DebugSettings.simulateMobileOS() - return DebugSettings.get("simulateMobileOS") --[[@as boolean]] -end - --- Returns whether to force FPS display in debug builds ----@return boolean -function DebugSettings.forceFPS() - return DebugSettings.get("forceFPS") --[[@as boolean]] -end - --- Returns whether to draw graphics stats (draw calls, texture memory, etc.) ----@return boolean -function DebugSettings.drawGraphicsStats() - return DebugSettings.get("drawGraphicsStats") --[[@as boolean]] -end - --- Returns whether to show the runtime graph ----@return boolean -function DebugSettings.showRuntimeGraph() - return DebugSettings.get("showRuntimeGraph") --[[@as boolean]] -end - --- Returns the VS frames behind value (only applicable when showStackDebugInfo is true) ----@return number -function DebugSettings.getVSFramesBehind() - return DebugSettings.get("vsFramesBehind") --[[@as number]] -end - --- Sets whether to show stack debug information ----@param value boolean -function DebugSettings.setShowStackDebugInfo(value) - DebugSettings.set("showStackDebugInfo", value) -end - --- Sets whether to show UI element borders ----@param value boolean -function DebugSettings.setShowUIElementBorders(value) - DebugSettings.set("showUIElementBorders", value) -end - --- Sets whether to simulate mobile OS on desktop ----@param value boolean -function DebugSettings.setSimulateMobileOS(value) - DebugSettings.set("simulateMobileOS", value) -end - --- Sets whether to force FPS display in debug builds ----@param value boolean -function DebugSettings.setForceFPS(value) - DebugSettings.set("forceFPS", value) -end - --- Sets whether to draw graphics stats ----@param value boolean -function DebugSettings.setDrawGraphicsStats(value) - DebugSettings.set("drawGraphicsStats", value) -end - --- Sets whether to show the runtime graph ----@param value boolean -function DebugSettings.setShowRuntimeGraph(value) - DebugSettings.set("showRuntimeGraph", value) -end - --- Sets the VS frames behind value ----@param value number -function DebugSettings.setVSFramesBehind(value) - DebugSettings.set("vsFramesBehind", value) -end - --- Returns whether to show debug servers in main menu ----@return boolean -function DebugSettings.showDebugServers() - return DebugSettings.get("showDebugServers") --[[@as boolean]] -end - --- Sets whether to show debug servers in main menu ----@param value boolean -function DebugSettings.setShowDebugServers(value) - DebugSettings.set("showDebugServers", value) -end - --- Returns whether to show design helper in main menu ----@return boolean -function DebugSettings.showDesignHelper() - return DebugSettings.get("showDesignHelper") --[[@as boolean]] -end - --- Sets whether to show design helper in main menu ----@param value boolean -function DebugSettings.setShowDesignHelper(value) - DebugSettings.set("showDesignHelper", value) -end - --- Returns whether to profile frame times ----@return boolean -function DebugSettings.getProfileFrameTimes() - return DebugSettings.get("profileFrameTimes") --[[@as boolean]] -end - --- Sets whether to profile frame times ----@param value boolean -function DebugSettings.setProfileFrameTimes(value) - DebugSettings.set("profileFrameTimes", value) - local prof = require("common.lib.zoneProfiler") - prof.enable(value) - prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) -end - --- Returns the profile threshold in milliseconds ----@return number -function DebugSettings.getProfileThreshold() - return DebugSettings.get("profileThreshold") --[[@as number]] -end - --- Sets the profile threshold in milliseconds ----@param value number -function DebugSettings.setProfileThreshold(value) - DebugSettings.set("profileThreshold", value) - local prof = require("common.lib.zoneProfiler") - prof.setDurationFilter(value / 1000) -end - -return DebugSettings diff --git a/client/src/developer.lua b/client/src/developer.lua index 22f52222c..ff4cc477f 100644 --- a/client/src/developer.lua +++ b/client/src/developer.lua @@ -1,5 +1,4 @@ local system = require("client.src.system") -local DebugSettings = require("client.src.debug.DebugSettings") ---@diagnostic disable: duplicate-set-field -- Put any local development changes you need in here that you don't want commited. @@ -8,7 +7,7 @@ local DebugSettings = require("client.src.debug.DebugSettings") local function enableProfiler(threshold) local prof = require("common.lib.zoneProfiler") prof.enable(true) - prof.setDurationFilter((threshold or DebugSettings.getProfileThreshold()) / 1000) + prof.setDurationFilter((threshold or config.debugProfileThreshold) / 1000) end local developerTools = {} diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index eb29eb46a..8ceac0802 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -676,18 +676,9 @@ end ---@param player Player ---@param width number ----@return StackPanel rankedSelectionContainer +---@return BoolSelector rankedSelector function CharacterSelect:createRankedSelection(player, width) - - -- player number icon - local playerIndex = tableUtils.indexOf(self.players, player) - local playerNumberIcon = ui.ImageContainer({ - image = themes[config.theme].images.IMG_players[playerIndex], - scale = 2, - vAlign = "center" - }) - - local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vAlign = "center"}) + local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vFill = true, width = width, vAlign = "center", hAlign = "center"}) rankedSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() player:setWantsRanked(value) @@ -697,40 +688,31 @@ function CharacterSelect:createRankedSelection(player, width) player:connectSignal("wantsRankedChanged", rankedSelector, rankedSelector.setValue) - local container = ui.StackPanel( - { - alignment = "left", - height = rankedSelector.height, - hAlign = "center", - vAlign = "center", - } - ) - container.playerNumberIcon = playerNumberIcon - container.rankedSelector = rankedSelector - container:addElement(playerNumberIcon) - container:addElement(ui.UiElement({width = 8, height = 8})) - container:addElement(rankedSelector) - container:addElement(ui.UiElement({width = 8, height = 8})) - - return container -end - ----@param player Player ----@param width number ----@return StackPanel styleSelectionContainer ----@return BoolSelector styleSelector -function CharacterSelect:createStyleSelection(player, width) -- player number icon local playerIndex = tableUtils.indexOf(self.players, player) local playerNumberIcon = ui.ImageContainer({ image = themes[config.theme].images.IMG_players[playerIndex], + hAlign = "left", + vAlign = "center", + x = 2, scale = 2, - vAlign = "center" }) + rankedSelector.playerNumberIcon = playerNumberIcon + rankedSelector:addChild(rankedSelector.playerNumberIcon) + return rankedSelector +end + +---@param player Player +---@param width number +---@return BoolSelector styleSelector +function CharacterSelect:createStyleSelection(player, width) local styleSelector = ui.BoolSelector({ startValue = (player.settings.style == GameModes.Styles.MODERN), - isEnabled = player.isLocal + vFill = true, + width = width, + vAlign = "center", + hAlign = "center", }) -- onValueChange should get implemented by the caller @@ -747,20 +729,19 @@ function CharacterSelect:createStyleSelection(player, width) end ) - local container = ui.StackPanel({ - alignment = "left", - height = styleSelector.height, - hAlign = "center", + -- player number icon + local playerIndex = tableUtils.indexOf(self.players, player) + local playerNumberIcon = ui.ImageContainer({ + image = themes[config.theme].images.IMG_players[playerIndex], + hAlign = "left", vAlign = "center", + x = 8, + scale = 2, }) - container.playerNumberIcon = playerNumberIcon - container.styleSelector = styleSelector - container:addElement(playerNumberIcon) - container:addElement(ui.UiElement({width = 8, height = 8})) - container:addElement(styleSelector) - container:addElement(ui.UiElement({width = 8, height = 8})) - - return container, styleSelector + styleSelector.playerNumberIcon = playerNumberIcon + styleSelector:addChild(styleSelector.playerNumberIcon) + + return styleSelector end function CharacterSelect:createRecordsBox(lastText) diff --git a/client/src/scenes/DesignHelper.lua b/client/src/scenes/DesignHelper.lua index 2fc419301..ac9627716 100644 --- a/client/src/scenes/DesignHelper.lua +++ b/client/src/scenes/DesignHelper.lua @@ -41,7 +41,7 @@ function DesignHelper:loadGrid() end function DesignHelper:loadRankedSelection(width) - local rankedSelector = ui.BoolSelector({startValue = true, width = width, vAlign = "center", hAlign = "center"}) + local rankedSelector = ui.BoolSelector({startValue = true, vFill = true, width = width, vAlign = "center", hAlign = "center"}) return rankedSelector end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index a0a00b27b..92bd9d967 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -49,8 +49,8 @@ function EndlessMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleContainer, player) + local styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleSelector, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/scenes/GameBase.lua b/client/src/scenes/GameBase.lua index bf1f793ef..8c8605577 100644 --- a/client/src/scenes/GameBase.lua +++ b/client/src/scenes/GameBase.lua @@ -15,7 +15,6 @@ local ui = require("client.src.ui") local FileUtils = require("client.src.FileUtils") local ClientStack = require("client.src.ClientStack") local MatchRules = require("common.data.MatchRules") -local DebugSettings = require("client.src.debug.DebugSettings") -- Scene template for running any type of game instance (endless, vs-self, replays, etc.) ---@class GameBase : Scene @@ -445,14 +444,14 @@ function GameBase:drawHUD() end stack:drawLevel() - if stack.analytic and not DebugSettings.showStackDebugInfo() then + if stack.analytic and not config.debug_mode then --prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() --prof.pop("Stack:drawAnalyticData") end end - if not DebugSettings.showStackDebugInfo() and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details + if not config.debug_mode and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details GraphicsUtil.print(GAME.battleRoom.spectatorString, themes[config.theme].spectators_Pos[1], themes[config.theme].spectators_Pos[2]) end diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index ffb9f72ca..8054187c7 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -4,7 +4,6 @@ local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") -local DebugSettings = require("client.src.debug.DebugSettings") local EndlessMenu = require("client.src.scenes.EndlessMenu") local PuzzleMenu = require("client.src.scenes.PuzzleMenu") local TimeAttackMenu = require("client.src.scenes.TimeAttackMenu") @@ -41,16 +40,6 @@ local function switchToScene(scene, transition) GAME.navigationStack:push(scene, transition) end -function MainMenu:refresh() - if self.menu then - self.menu:detach() - self.menu = nil - end - - self.menu = self:createMainMenu() - self.uiRoot:addChild(self.menu) -end - function MainMenu:createMainMenu() local menuItems = {ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() @@ -115,12 +104,12 @@ function MainMenu:createMainMenu() } local function addDebugMenuItems() - if DebugSettings.showDebugServers() then + if config.debugShowServers then for i, menuItem in ipairs(debugMenuItems) do menu:addMenuItem(i + 7, menuItem) end end - if DebugSettings.showDesignHelper() then + if config.debugShowDesignHelper then menu:addMenuItem(#menu.menuItems, ui.MenuItem.createButtonMenuItem("Design Helper", nil, nil, function() switchToScene(DesignHelper()) end)) diff --git a/client/src/scenes/ModManagement.lua b/client/src/scenes/ModManagement.lua index 5b5bac0d5..95761b775 100644 --- a/client/src/scenes/ModManagement.lua +++ b/client/src/scenes/ModManagement.lua @@ -167,7 +167,7 @@ function ModManagement:loadStageGrid() if stageId ~= consts.RANDOM_STAGE_SPECIAL_VALUE then local stage = allStages[stageId] local icon = ui.ImageContainer({drawBorders = true, image = stage.images.thumbnail, hFill = true, vFill = true, hAlign = "center", vAlign = "center"}) - local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center"}) + local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() stage:enable(boolSelector.value) @@ -181,7 +181,7 @@ function ModManagement:loadStageGrid() GAME.localPlayer:setStage(stages[consts.RANDOM_STAGE_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center"}) + local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) visibilitySelector.onValueChange = function(boolSelector, value) end local name = ui.Label({text = stage.display_name, translate = false, hAlign = "center", vAlign = "center"}) @@ -241,7 +241,7 @@ function ModManagement:loadCharacterGrid() if characterId ~= consts.RANDOM_CHARACTER_SPECIAL_VALUE then local character = allCharacters[characterId] local icon = ui.ImageContainer({drawBorders = true, image = character.images.icon, hFill = true, vFill = true}) - local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center"}) + local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() character:enable(boolSelector.value) @@ -255,7 +255,7 @@ function ModManagement:loadCharacterGrid() GAME.localPlayer:setCharacter(characters[consts.RANDOM_CHARACTER_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center"}) + local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) visibilitySelector.onValueChange = function(boolSelector, value) end local displayName = ui.Label({text = character.display_name, translate = false, hAlign = "center", vAlign = "center"}) diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index 7b7477f37..daf252512 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -2,7 +2,6 @@ local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local inputManager = require("client.src.inputManager") local save = require("client.src.save") -local DebugMenu = require("client.src.debug.DebugMenu") local consts = require("common.engine.consts") local fileUtils = require("client.src.FileUtils") local analytics = require("client.src.analytics") @@ -16,6 +15,7 @@ local ModManagement = require("client.src.scenes.ModManagement") local system = require("client.src.system") local JsonSafePrecision = require("common.data.JsonSafePrecision") local logger = require("common.lib.logger") +local prof = require("common.lib.zoneProfiler") -- Scene for the options menu local OptionsMenu = class(function(self, sceneParams) @@ -486,14 +486,30 @@ function OptionsMenu:loadSoundMenu() end function OptionsMenu:loadDebugMenu() - local debugMenu = DebugMenu.makeDebugMenu({ - showBackButton = true, - onBack = function() - self:switchToScreen("baseMenu") - end, - height = themes[config.theme].main_menu_max_height - }) - return debugMenu + local debugMenuOptions = { + ui.MenuItem.createToggleButtonGroupMenuItem("op_debug_mode", nil, nil, createToggleButtonGroup("debug_mode")), + ui.MenuItem.createSliderMenuItem("VS Frames Behind", nil, false, createConfigSlider("debug_vsFramesBehind", 0, 200)), + ui.MenuItem.createToggleButtonGroupMenuItem("Show Debug Servers", nil, false, createToggleButtonGroup("debugShowServers")), + ui.MenuItem.createToggleButtonGroupMenuItem("Show Design Helper", nil, false, createToggleButtonGroup("debugShowDesignHelper")), + ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() + GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) + end), + ui.MenuItem.createToggleButtonGroupMenuItem("Profile frame times", nil, false, createToggleButtonGroup("debugProfile", + function() + prof.enable(config.debugProfile) + prof.setDurationFilter(config.debugProfileThreshold / 1000) + end)), + ui.MenuItem.createSliderMenuItem("Discard frames below duration (ms)", nil, false, createConfigSlider("debugProfileThreshold", 0, 100, + function() + prof.setDurationFilter(config.debugProfileThreshold / 1000) + end)), + ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + GAME.theme:playCancelSfx() + self:switchToScreen("baseMenu") + end), + } + + return ui.Menu.createCenteredMenu(debugMenuOptions) end function OptionsMenu:loadAboutMenu() diff --git a/client/src/scenes/PortraitGame.lua b/client/src/scenes/PortraitGame.lua index d95886cb9..3f05ab166 100644 --- a/client/src/scenes/PortraitGame.lua +++ b/client/src/scenes/PortraitGame.lua @@ -6,7 +6,6 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local ui = require("client.src.ui") local input = require("client.src.inputManager") local system = require("client.src.system") -local DebugSettings = require("client.src.debug.DebugSettings") local PortraitGame = class(function(self, sceneParams) end, @@ -194,7 +193,7 @@ function PortraitGame:flipToPortrait() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_HEIGHT, consts.CANVAS_WIDTH, {dpiscale=GAME:newCanvasSnappedScale()}) local width, height, _ = love.window.getMode() - if system.isMobileOS() or DebugSettings.simulateMobileOS() then + if system.isMobileOS() or DEBUG_ENABLED then -- flip the window dimensions to portrait love.window.updateMode(height, width, {}) love.window.setFullscreen(true) @@ -246,7 +245,7 @@ function PortraitGame:returnToLandscape() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) -- flip the window dimensions to landscape local width, height, _ = love.window.getMode() - if system.isMobileOS() or DebugSettings.simulateMobileOS() then + if system.isMobileOS() or DEBUG_ENABLED then love.window.updateMode(height, width, {}) love.window.setFullscreen(false) --GAME:updateCanvasPositionAndScale(width, height) diff --git a/client/src/scenes/ReplayGame.lua b/client/src/scenes/ReplayGame.lua index 911cc0a9a..f2e306a7f 100644 --- a/client/src/scenes/ReplayGame.lua +++ b/client/src/scenes/ReplayGame.lua @@ -5,7 +5,6 @@ local util = require("common.lib.util") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local prof = require("common.lib.zoneProfiler") -local DebugSettings = require("client.src.debug.DebugSettings") local ReplayGame = class( function (self, sceneParams) @@ -142,7 +141,7 @@ function ReplayGame:drawHUD() end stack:drawLevel() - if stack.analytic and not DebugSettings.showStackDebugInfo() then + if stack.analytic and not DEBUG_ENABLED then prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() prof.pop("Stack:drawAnalyticData") diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index 0270b7ccd..ea9f9adce 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -5,7 +5,6 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") local SoundController = require("client.src.music.SoundController") local directsFocus = require("client.src.ui.FocusDirector") -local DebugSettings = require("client.src.debug.DebugSettings") ---@alias sceneMusic ("none" | "main" | "title_screen" | "select_screen") @@ -84,7 +83,7 @@ end function Scene:drawCommunityMessage() -- Draw the community message - if not DebugSettings.showStackDebugInfo() then + if not config.debug_mode then GraphicsUtil.printf(join_community_msg or "", 0, (668 / 720) * GAME.globalCanvas:getHeight(), GAME.globalCanvas:getWidth(), "center") end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index c846420e7..1ab39f816 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -48,8 +48,8 @@ function TimeAttackMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleContainer, player) + local styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleSelector, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/ui/BoolSelector.lua b/client/src/ui/BoolSelector.lua index 2827daeb2..5d4d7e83e 100644 --- a/client/src/ui/BoolSelector.lua +++ b/client/src/ui/BoolSelector.lua @@ -3,7 +3,6 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") ---@class BoolSelectorOptions : UiElementOptions ---@field startValue boolean? @@ -12,22 +11,9 @@ local DebugSettings = require("client.src.debug.DebugSettings") ---@class BoolSelector : UiElement ---@field value boolean ---@field vertical boolean ----@field circleRadius number ----@field extraDistance number ----@field lengthPadding number ----@field widthPadding number -local BoolSelector = class(function(self, options) - self.value = options.startValue or false - self.vertical = false - self.circleRadius = 10 - self.extraDistance = 16 - self.lengthPadding = 2 - self.widthPadding = 2 - self.onValueChange = options.onValueChange or function() end - - -- Calculate initial dimensions - self.width = self:calculateWidth() - self.height = self:calculateHeight() +local BoolSelector = class(function(boolSelector, options) + boolSelector.value = options.startValue or false + boolSelector.vertical = false end, UiElement) @@ -76,60 +62,57 @@ function BoolSelector:setValue(value) end end ----@return number -function BoolSelector:calculateWidth() - local width = self.circleRadius * 2 + 2 * self.widthPadding - if not self.vertical then - width = width + self.extraDistance - end - return width -end - ----@return number -function BoolSelector:calculateHeight() - local height = self.circleRadius * 2 + 2 * self.lengthPadding - if self.vertical then - height = height + self.extraDistance - end - return height -end - -- other code may implement a callback here -- function BoolSelector.onValueChange() end +local circleRadius = 10 +local extraDistance = 16 +local lengthPadding = 2 +local widthPadding = 2 +local totalWidth = 0 +local totalLength = 0 +local fakeCenteredChild = {hAlign = "center", vAlign = "center", width = totalWidth, height = totalLength} + function BoolSelector:drawSelf() - if DebugSettings.showUIElementBorders() then + if DEBUG_ENABLED then GraphicsUtil.setColor(0, 0, 1, 1) GraphicsUtil.drawRectangle("line", self.x + 1, self.y + 1, self.width - 2, self.height - 2) GraphicsUtil.setColor(1, 1, 1, 1) end - local drawX = self.x + self.widthPadding - local drawY = self.y + self.lengthPadding - local drawWidth = self.width - 2 * self.widthPadding - local drawHeight = self.height - 2 * self.lengthPadding - - local circleX = self.circleRadius - local circleY = self.circleRadius - + local circleX = circleRadius + widthPadding + local circleY = circleRadius + lengthPadding + totalWidth = circleRadius * 2 + 2 * widthPadding + totalLength = circleRadius * 2 + 2 * lengthPadding if self.vertical then + totalLength = totalLength + extraDistance if self.value == false then - circleY = circleY + self.extraDistance + circleY = circleY + extraDistance end else + totalWidth = totalWidth + extraDistance if self.value then - circleX = circleX + self.extraDistance + circleX = circleX + extraDistance end end + fakeCenteredChild.width = totalWidth + fakeCenteredChild.height = totalLength + + -- we want these to be centered but creating a Rectangle / Circle ui element is maybe a bit too much? + -- so just apply the translation via a fake element with all necessary props + GraphicsUtil.applyAlignment(self, fakeCenteredChild) + love.graphics.translate(self.x, self.y) if self.value then GraphicsUtil.setColor(30/255, 190/255, 67/255, 1) - GraphicsUtil.drawRectangle("fill", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) + GraphicsUtil.drawRectangle("fill", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) GraphicsUtil.setColor(1, 1, 1, 1) end - GraphicsUtil.drawRectangle("line", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) - love.graphics.circle("fill", drawX + circleX, drawY + circleY, self.circleRadius) + GraphicsUtil.drawRectangle("line", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) + love.graphics.circle("fill", circleX, circleY, circleRadius) + + GraphicsUtil.resetAlignment() end return BoolSelector \ No newline at end of file diff --git a/client/src/ui/Carousel.lua b/client/src/ui/Carousel.lua index bfd71988a..4215d017f 100644 --- a/client/src/ui/Carousel.lua +++ b/client/src/ui/Carousel.lua @@ -4,7 +4,6 @@ local Focusable = require(PATH .. ".Focusable") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") -local DebugSettings = require("client.src.debug.DebugSettings") local function calculateFontSize(height) return math.floor(height / 2) + 1 @@ -88,7 +87,7 @@ function Carousel.setPassengerByIndex(self, index) end function Carousel:drawSelf() - if DebugSettings.showUIElementBorders() then + if DEBUG_ENABLED then GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) end end diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index aedf54c00..2dfc7a2bf 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -3,7 +3,6 @@ local UiElement = require(PATH .. ".UIElement") local GridElement = require(PATH .. ".GridElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") local Grid = class(function(self, options) self.unitSize = options.unitSize @@ -74,7 +73,7 @@ function Grid:createElementAt(x, y, width, height, description, uiElement, noPad end function Grid:drawSelf() - if DebugSettings.showUIElementBorders() then + if DEBUG_ENABLED then GraphicsUtil.setColor(1, 1, 1, 0.5) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/GridElement.lua b/client/src/ui/GridElement.lua index ff9165674..d0725fdf8 100644 --- a/client/src/ui/GridElement.lua +++ b/client/src/ui/GridElement.lua @@ -2,7 +2,6 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") local GridElement = class(function(gridElement, options) if options.content then @@ -17,7 +16,7 @@ local GridElement = class(function(gridElement, options) gridElement.gridHeight = options.gridHeight if options.drawBorders ~= nil then gridElement.drawBorders = options.drawBorders - elseif DebugSettings.showUIElementBorders() then + elseif DEBUG_ENABLED then gridElement.drawBorders = true else gridElement.drawBorders = false diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 400192c1c..7931404d2 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -5,7 +5,6 @@ local TextButton = require(PATH .. ".TextButton") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local system = require("client.src.system") -local DebugSettings = require("client.src.debug.DebugSettings") -- MenuItem is a specific UIElement that all children of Menu should be local MenuItem = class(function(self, options) @@ -28,7 +27,7 @@ function MenuItem.createMenuItem(label, item) menuItem.width = label.width + (2 * MenuItem.PADDING) - if system.isMobileOS() or DebugSettings.simulateMobileOS() then + if system.isMobileOS() or DEBUG_ENABLED then label.height = math.max(30, label.height + (2 * MenuItem.PADDING)) menuItem.height = math.max(30, label.height, item and item.height or 0) else @@ -39,7 +38,7 @@ function MenuItem.createMenuItem(label, item) local spaceBetween = 16 item.x = label.width + spaceBetween item.vAlign = "center" - if system.isMobileOS() or DebugSettings.simulateMobileOS() then + if system.isMobileOS() or DEBUG_ENABLED then item.height = math.max(30, item.height) end menuItem.width = item.x + item.width + MenuItem.PADDING @@ -129,19 +128,7 @@ function MenuItem.createSliderMenuItem(text, replacements, translate, slider) end local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) local menuItem = MenuItem.createMenuItem(label, slider) - - return menuItem -end - -function MenuItem.createBoolSelectorMenuItem(text, replacements, translate, boolSelector) - assert(text ~= nil) - assert(boolSelector ~= nil) - if translate == nil then - translate = true - end - local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) - local menuItem = MenuItem.createMenuItem(label, boolSelector) - + return menuItem end diff --git a/client/src/ui/MultiPlayerSelectionWrapper.lua b/client/src/ui/MultiPlayerSelectionWrapper.lua index 969dba238..c0802d71c 100644 --- a/client/src/ui/MultiPlayerSelectionWrapper.lua +++ b/client/src/ui/MultiPlayerSelectionWrapper.lua @@ -18,7 +18,6 @@ end, StackPanel) function MultiPlayerSelectionWrapper:addElement(uiElement, player) - assert(uiElement.receiveInputs) self.wrappedElements[player] = uiElement uiElement.yieldFocus = function() self.yieldFocus() diff --git a/client/src/ui/OverlayContainer.lua b/client/src/ui/OverlayContainer.lua deleted file mode 100644 index e3a414671..000000000 --- a/client/src/ui/OverlayContainer.lua +++ /dev/null @@ -1,124 +0,0 @@ -local class = require("common.lib.class") -local UiElement = require("client.src.ui.UIElement") -local GraphicsUtil = require("client.src.graphics.graphics_util") -local consts = require("common.engine.consts") - --- Full-screen semi-transparent overlay that holds a centered UI element --- Closes on click outside content area ----@class OverlayContainer : UiElement ----@field content UiElement? The centered content element ----@field onClose fun()? Callback invoked when overlay closes ----@field active boolean True when overlay is open and processing input -local OverlayContainer = class( - function(self, options) - options = options or {} - self.content = options.content - self.onClose = options.onClose - self.active = false - - self.width = consts.CANVAS_WIDTH - self.height = consts.CANVAS_HEIGHT - self:setVisibility(false) - - if self.content then - self:addChild(self.content) - self.content.hAlign = "center" - self.content.vAlign = "center" - end - end, - UiElement -) - --- Opens the overlay -function OverlayContainer:open() - self.active = true - self:setVisibility(true) -end - --- Closes the overlay -function OverlayContainer:close() - if not self.active then - return - end - - self.active = false - self:setVisibility(false) - - if self.onClose then - self.onClose() - end -end - --- Checks if the overlay is currently active ----@return boolean -function OverlayContainer:isActive() - return self.active -end - --- Sets the content element for the overlay ----@param content UiElement The content to display in the center -function OverlayContainer:setContent(content) - if self.content then - self.content:detach() - end - - self.content = content - if self.content then - self:addChild(self.content) - self.content.hAlign = "center" - self.content.vAlign = "center" - end -end - --- Draws the semi-transparent background -function OverlayContainer:drawSelf() - if not self.active then - return - end - - GraphicsUtil.setColor(0, 0, 0, 0.75) - GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) - GraphicsUtil.setColor(1, 1, 1, 1) -end - --- Touch handler - closes overlay if clicking outside content ----@return boolean? True to block touch event propagation -function OverlayContainer:onTouch(x, y) - if not self.active then - return false - end - - if self.content then - local contentX, contentY = self.content:getScreenPos() - local inContentBounds = x >= contentX and x < contentX + self.content.width and - y >= contentY and y < contentY + self.content.height - - if not inContentBounds then - self:close() - return true - end - end - - return true -end - --- Release handler - blocks event propagation ----@return boolean? True to block release event propagation -function OverlayContainer:onRelease() - if self.active then - return true - end -end - --- Input handler - closes overlay on ESC key -function OverlayContainer:receiveInputs(inputs, dt) - if not self.active then - return - end - - if inputs.isDown["MenuEsc"] then - self:close() - end -end - -return OverlayContainer diff --git a/client/src/ui/PagedUniGrid.lua b/client/src/ui/PagedUniGrid.lua index a96458946..2dfe1bc41 100644 --- a/client/src/ui/PagedUniGrid.lua +++ b/client/src/ui/PagedUniGrid.lua @@ -6,7 +6,6 @@ local Grid = require(PATH .. ".Grid") local class = require("common.lib.class") local Signal = require("common.lib.signal") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") local function addNewPage(pagedUniGrid) local grid = Grid({ @@ -102,7 +101,7 @@ function PagedUniGrid:refreshPageTurnButtonVisibility() end function PagedUniGrid:drawSelf() - if DebugSettings.showUIElementBorders() then + if DEBUG_ENABLED then GraphicsUtil.setColor(1, 0, 0, 1) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index a1018b358..e1b226976 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -3,26 +3,23 @@ local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") ----@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 +-- 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 local StackPanel = class(function(stackPanel, options) - ---@type "left"|"right"|"top"|"bottom" + -- all children are aligned automatically towards that option inside the StackPanel + -- possible values: "left", "right", "top", "bottom" stackPanel.alignment = options.alignment - ---@type number + + -- StackPanels are unidirectional but can go into either direction + -- pixelsTaken tracks how many pixels are already taken in the direction the StackPanel propagates towards stackPanel.pixelsTaken = 0 + -- a stack panel does not have a size limit it's alignment dimension grows with its content end, UiElement) StackPanel.TYPE = "StackPanel" ----Applies positioning and sizing settings to a UI element based on the StackPanel's alignment ----@param uiElement UiElement The element to apply settings to function StackPanel:applyStackPanelSettings(uiElement) if self.alignment == "left" then uiElement.hFill = false @@ -51,29 +48,19 @@ function StackPanel:applyStackPanelSettings(uiElement) end end ----Adds a UI element to the StackPanel, applying proper positioning and resizing ----@param uiElement UiElement The element to add function StackPanel:addElement(uiElement) self:applyStackPanelSettings(uiElement) self:addChild(uiElement) self:resize() - uiElement.yieldFocus = function() - self.yieldFocus() - end end ----Inserts a UI element at a specific index in the StackPanel ----@param uiElement UiElement The element to insert ----@param index number The position to insert at (1-based) + function StackPanel:insertElementAtIndex(uiElement, index) -- add it at the end StackPanel.addElement(self, uiElement) StackPanel.shiftTo(self, uiElement, index) end ----Shifts an element to a specific index by swapping positions with preceding elements ----@param uiElement UiElement The element to shift ----@param index number The target position (1-based) function StackPanel:shiftTo(uiElement, index) -- swap the previous element with it while updating values until it reached the desired index for i = #self.children - 1, index, -1 do @@ -95,9 +82,6 @@ function StackPanel:shiftTo(uiElement, index) end end ----Removes an element from the StackPanel, updating positions and pixel tracking ----IMPORTANT: Use this method instead of element:detach() to maintain proper layout state ----@param uiElement UiElement The element to remove function StackPanel:remove(uiElement) local index = tableUtils.indexOf(self.children, uiElement) @@ -130,21 +114,8 @@ function StackPanel:remove(uiElement) uiElement:detach() end ----Processes user input and forwards it to child elements ----@param input table Input state ----@param dt number Delta time since last frame -function StackPanel:receiveInputs(input, dt) - for _, child in ipairs(self.children) do - if child.receiveInputs then - child:receiveInputs(input, dt) - return - end - end -end - ----Draws the StackPanel's debug borders if enabled in debug settings function StackPanel:drawSelf() - if DebugSettings.showUIElementBorders() then + if DEBUG_ENABLED then GraphicsUtil.setColor(1, 0, 0, 0.7) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/Stepper.lua b/client/src/ui/Stepper.lua index 9aeba5b22..a342bb645 100644 --- a/client/src/ui/Stepper.lua +++ b/client/src/ui/Stepper.lua @@ -5,7 +5,6 @@ local Label = require(PATH .. ".Label") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") -local DebugSettings = require("client.src.debug.DebugSettings") local NAV_BUTTON_WIDTH = 25 local EMPTY_STEPPER_WIDTH = 160 @@ -111,7 +110,7 @@ function Stepper:refreshLocalization() end function Stepper:drawSelf() - if DebugSettings.showUIElementBorders() then + if config.debug_mode then GraphicsUtil.setColor(self.color) GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(self.borderColor) diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index f44700524..e297c2646 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -10,11 +10,15 @@ local touchHandler = { } function touchHandler:touch(x, y) - -- prevent multitouch - if not self.touchedElement then - self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) - if self.touchedElement and self.touchedElement.onTouch then - self.touchedElement:onTouch(x, y) + local activeScene = GAME.navigationStack:getActiveScene() + -- if there is no active scene that implies an on-going scene switch, no interactions should be possible + if activeScene then + -- prevent multitouch + if not self.touchedElement then + self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) + if self.touchedElement and self.touchedElement.onTouch then + self.touchedElement:onTouch(x, y) + end end end end diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 2efa110a4..18172e4ce 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -12,7 +12,6 @@ local LegacyPanelSource = require("common.compatibility.LegacyPanelSource") local InputCompression = require("common.data.InputCompression") local ReplayV3 = require("common.data.ReplayV3") local MatchRules = require("common.data.MatchRules") -local DebugSettings = require("client.src.debug.DebugSettings") ---@class Match ---@field stacks (Stack | SimulatedStack)[] The stacks to run as part of the match @@ -616,11 +615,10 @@ function Match:shouldRun(stack, runsSoFar) end -- In debug mode allow non-local player 2 to fall a certain number of frames behind - local framesBehind = DebugSettings.getVSFramesBehind() - if not stack.is_local and framesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then + if config and config.debug_mode and not stack.is_local and config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then -- Only stay behind if the game isn't over for the local player (=garbageTarget) yet if self.garbageTargets[2][1] and self.garbageTargets[2][1].game_ended and self.garbageTargets[2][1]:game_ended() == false then - if stack.clock + framesBehind >= self.garbageTargets[2][1].clock then + if stack.clock + config.debug_vsFramesBehind >= self.garbageTargets[2][1].clock then return false end end diff --git a/docs/DebugSettings.md b/docs/DebugSettings.md deleted file mode 100644 index 36dc2a74b..000000000 --- a/docs/DebugSettings.md +++ /dev/null @@ -1,117 +0,0 @@ -# Debug Settings System Documentation - -## Overview -The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to `config.debug` and can be accessed anywhere via the `DebugSettings` singleton. - -## Architecture - -### Components -- **DebugSettings** (`client/src/debug/DebugSettings.lua`) - Singleton managing all debug settings -- **OverlayContainer** (`client/src/ui/OverlayContainer.lua`) - Full-screen overlay UI for settings menu -- **Debug Button** (`client/src/Game.lua`) - Bottom-right button that opens the overlay (only visible when `DEBUG_ENABLED`) - -## How Debug-Only Settings Work - -Settings can be marked as `debugBuildOnly = true` to control their availability: - -### Debug Builds (`DEBUG_ENABLED = true`) -- Setting appears in the UI overlay -- Can be toggled on/off by the user -- Value persists to config file -- Returns actual user-configured value - -### Release Builds (`DEBUG_ENABLED = false`) -- Setting is hidden from the UI overlay -- Always returns the default value (typically `false`) -- Persisted value is ignored -- Cannot be changed at runtime - -This is implemented through two mechanisms: - -**1. UI Filtering** - `DebugSettings.getDefinitions()` filters out `debugBuildOnly` settings in release builds - -**2. Value Locking** - `DebugSettings.get()` returns the default value for debug-only settings in release builds - -## Adding New Debug Settings - -### Step 1: Add Setting Definition - -Add a new entry to the `settingDefinitions` table in `DebugSettings.lua`: - -```lua -{ - key = "myNewSetting", - type = "boolean", -- or "number" - default = false, - label = "My New Feature", - debugBuildOnly = true -- false if should be available in release builds -} -``` - -For number settings, add `min` and `max`: -```lua -{ - key = "myNumberSetting", - type = "number", - default = 0, - label = "My Number Setting", - min = 0, - max = 100, - debugBuildOnly = true -} -``` - -### Step 2: Add Accessor Methods - -Add getter and optional setter methods following the naming convention: - -```lua --- Getter -function DebugSettings.myNewSetting() - return DebugSettings.get("myNewSetting") --[[@as boolean]] -end - --- Setter (if needed for programmatic access) -function DebugSettings.setMyNewSetting(value) - DebugSettings.set("myNewSetting", value) -end -``` - -### Step 3: Use in Code - -Replace existing debug checks with the new method: - -```lua - -if DebugSettings.myNewSetting() then - -- debug behavior -end -``` - -## Setting Definition Fields - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `key` | string | Yes | Unique key used in config.debug | -| `type` | "boolean" \| "number" | Yes | Data type of the setting | -| `default` | boolean \| number | Yes | Default value when not configured | -| `label` | string | Yes | Display label shown in UI overlay | -| `min` | number | No | Minimum value (number types only) | -| `max` | number | No | Maximum value (number types only) | -| `debugBuildOnly` | boolean | No | If true, only available in DEBUG_ENABLED builds (defaults to false) | - -## Persistence - -Settings are automatically saved to `config.debug` in the user's config file: - -```lua -config.debug = { - showStackDebugInfo = false, - showUIElementBorders = true, - vsFramesBehind = 0, - -- ... other settings -} -``` - -- Settings load on startup via `DebugSettings.init()` -- Settings save immediately when changed via `DebugSettings.set()` diff --git a/main.lua b/main.lua index bcd2dcb98..952272a39 100644 --- a/main.lua +++ b/main.lua @@ -1,7 +1,6 @@ local logger = require("common.lib.logger") require("common.lib.mathExtensions") local utf8 = require("common.lib.utf8Additions") -local DebugSettings = require("client.src.debug.DebugSettings") local inputManager = require("client.src.inputManager") require("client.src.globals") local touchHandler = require("client.src.ui.touchHandler") @@ -63,8 +62,8 @@ function love.load(args, rawArgs) GAME:load() if not PROFILE_MEMORY then - prof.enable(DebugSettings.getProfileFrameTimes()) - prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) + prof.enable(config.debugProfile) + prof.setDurationFilter(config.debugProfileThreshold / 1000) end end @@ -79,7 +78,7 @@ end -- Intentional override ---@diagnostic disable-next-line: duplicate-set-field function love.update(dt) - if DebugSettings.showRuntimeGraph() then + if config.show_fps and config.debug_mode then if CustomRun.runTimeGraph == nil then CustomRun.runTimeGraph = RunTimeGraph() end @@ -127,7 +126,7 @@ end function love.draw() GAME:draw() - if DebugSettings.drawGraphicsStats() then + if DEBUG_ENABLED then local stats = love.graphics.getStats() local width, height = love.graphics.getDimensions() From ea5675f1a93a42a1afa8b6b405803f78871c2abf Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:24:16 -0700 Subject: [PATCH 15/16] Add Debug Settings Architecture Repost of this commit since it was reverted. The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to config.debug and can be accessed anywhere via the DebugSettings singleton. Also provide a way to generate the debug menu and surface it from a debug button. To support this new button and future overlays, add a root element to the game so we can order overlays Updated Bool Selector to size correctly Updated Style Selector creation to make sure sizing and layout is right Reload main menu when you come back to it Make navigation stack a UIElement so it works with touch handling Fixes #697 --- client/src/BattleRoom.lua | 3 +- client/src/ChallengeModePlayerStack.lua | 3 +- client/src/ClientMatch.lua | 5 +- client/src/Game.lua | 69 ++- client/src/NavigationStack.lua | 36 +- client/src/PlayerStack.lua | 7 +- client/src/Shortcuts.lua | 8 +- client/src/config.lua | 31 +- client/src/debug/DebugMenu.lua | 90 ++++ client/src/debug/DebugSettings.lua | 418 ++++++++++++++++++ client/src/developer.lua | 3 +- client/src/scenes/CharacterSelect.lua | 77 ++-- client/src/scenes/DesignHelper.lua | 2 +- client/src/scenes/EndlessMenu.lua | 4 +- client/src/scenes/GameBase.lua | 5 +- client/src/scenes/MainMenu.lua | 15 +- client/src/scenes/ModManagement.lua | 8 +- client/src/scenes/OptionsMenu.lua | 34 +- client/src/scenes/PortraitGame.lua | 5 +- client/src/scenes/ReplayGame.lua | 3 +- client/src/scenes/Scene.lua | 3 +- client/src/scenes/TimeAttackMenu.lua | 4 +- client/src/ui/BoolSelector.lua | 81 ++-- client/src/ui/Carousel.lua | 3 +- client/src/ui/Grid.lua | 3 +- client/src/ui/GridElement.lua | 3 +- client/src/ui/MenuItem.lua | 19 +- client/src/ui/MultiPlayerSelectionWrapper.lua | 1 + client/src/ui/OverlayContainer.lua | 124 ++++++ client/src/ui/PagedUniGrid.lua | 3 +- client/src/ui/StackPanel.lua | 49 +- client/src/ui/Stepper.lua | 3 +- client/src/ui/touchHandler.lua | 14 +- common/engine/Match.lua | 6 +- docs/DebugSettings.md | 117 +++++ main.lua | 9 +- 36 files changed, 1083 insertions(+), 185 deletions(-) create mode 100644 client/src/debug/DebugMenu.lua create mode 100644 client/src/debug/DebugSettings.lua create mode 100644 client/src/ui/OverlayContainer.lua create mode 100644 docs/DebugSettings.md diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 5d1f16432..0beeb5352 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -12,6 +12,7 @@ local BlackFadeTransition = require("client.src.scenes.Transitions.BlackFadeTran local Easings = require("client.src.Easings") local system = require("client.src.system") local GeneratorSource = require("common.engine.GeneratorSource") +local DebugSettings = require("client.src.debug.DebugSettings") -- A Battle Room is a session of matches, keeping track of the room number, player settings, wins / losses etc ---@class BattleRoom : Signal @@ -383,7 +384,7 @@ function BattleRoom:createScene(match) end -- for touch android players load a different scene - if (system.isMobileOS() or DEBUG_ENABLED) and self.gameScene.name ~= "PuzzleGame" and + if (system.isMobileOS() or DebugSettings.simulateMobileOS()) and self.gameScene.name ~= "PuzzleGame" and --but only if they are the only local player cause for 2p vs local using portrait mode would be bad tableUtils.count(self.players, function(p) return p.isLocal and p.human end) == 1 then for _, player in ipairs(self.players) do diff --git a/client/src/ChallengeModePlayerStack.lua b/client/src/ChallengeModePlayerStack.lua index 91d3bb198..eea1a3be5 100644 --- a/client/src/ChallengeModePlayerStack.lua +++ b/client/src/ChallengeModePlayerStack.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local ClientStack = require("client.src.ClientStack") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class ChallengeModePlayerStack : ClientStack ---@field engine SimulatedStack @@ -198,7 +199,7 @@ function ChallengeModePlayerStack:drawMultibar() end function ChallengeModePlayerStack:drawDebug() - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local drawX = self.frameOriginX + self:canvasWidth() / 2 local drawY = 10 local padding = 14 diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index bf71af747..1574a3b0a 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -16,6 +16,7 @@ local Telegraph = require("client.src.graphics.Telegraph") local MatchParticipant = require("client.src.MatchParticipant") local ChallengeModePlayerStack = require("client.src.ChallengeModePlayerStack") local NetworkProtocol = require("common.network.NetworkProtocol") +local DebugSettings = require("client.src.debug.DebugSettings") ---@module "client.src.ChallengeModePlayerStack" ---@class ClientMatch @@ -559,7 +560,7 @@ end function ClientMatch:drawCommunityMessage() -- Draw the community message - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then GraphicsUtil.printf(join_community_msg or "", 0, 668, consts.CANVAS_WIDTH, "center") end end @@ -598,7 +599,7 @@ function ClientMatch:render() end end - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local padding = 14 local drawX = 500 local drawY = -4 diff --git a/client/src/Game.lua b/client/src/Game.lua index 1952ff0c4..22f765e5f 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -33,6 +33,14 @@ local system = require("client.src.system") local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") +local DebugSettings = require("client.src.debug.DebugSettings") +local Button = require("client.src.ui.Button") +local TextButton = require("client.src.ui.TextButton") +local OverlayContainer = require("client.src.ui.OverlayContainer") +local DebugMenu = require("client.src.debug.DebugMenu") +local Label = require("client.src.ui.Label") +local UIElement = require("client.src.ui.UIElement") +local NavigationStack = require("client.src.NavigationStack") -- Provides a scale that is on .5 boundary to make sure it renders well. -- Useful for creating new canvas with a solid DPI @@ -106,12 +114,19 @@ local Game = class( -- time in seconds, can be used by other elements to track the passing of time beyond dt self.timer = love.timer.getTime() + + self.debugOverlay = nil + self.debugButton = nil + + -- Root UI element that contains all UI (scenes + overlays + debug) + self.uiRoot = UIElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) end ) Game.newCanvasSnappedScale = newCanvasSnappedScale function Game:load() + DebugSettings.init() PuzzleLibrary.cleanupDefaultPuzzles(consts.PUZZLES_SAVE_DIRECTORY) -- move to constructor @@ -131,8 +146,11 @@ function Game:load() self.input:importConfigurations(user_input_conf) end - self.navigationStack = require("client.src.NavigationStack") + self.navigationStack = NavigationStack({}) self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) + + -- Add navigation stack to root UI + self.uiRoot:addChild(self.navigationStack) self.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) end @@ -253,6 +271,8 @@ function Game:setupRoutine() self:initializeLocalPlayer() ModController:loadModFor(characters[GAME.localPlayer.settings.characterId], GAME.localPlayer, true) + + self:initializeDebugOverlay() end -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate @@ -366,9 +386,9 @@ function Game:update(dt) handleShortcuts() - prof.push("navigationStack update") - self.navigationStack:update(dt) - prof.pop("navigationStack update") + prof.push("uiRoot update") + self.uiRoot:update(dt) + prof.pop("uiRoot update") if self.backgroundImage then self.backgroundImage:update(dt) @@ -386,7 +406,7 @@ function Game:draw() love.graphics.clear() -- With this, self.globalCanvas is clear and set as our active canvas everything is being drawn to - self.navigationStack:draw() + self.uiRoot:draw() self:drawFPS() self:drawScaleInfo() @@ -402,8 +422,7 @@ function Game:draw() end function Game:drawFPS() - -- Draw the FPS if enabled - if self.config.show_fps then + if self.config.show_fps or DebugSettings.forceFPS() then love.graphics.print("FPS: " .. love.timer.getFPS(), 1, 1) end end @@ -666,4 +685,40 @@ function Game:setLanguage(lang_code) Localization:refresh_global_strings() end +function Game:initializeDebugOverlay() + if not DEBUG_ENABLED then + return + end + + self.debugButton = TextButton({ + x = consts.CANVAS_WIDTH - 50, + y = consts.CANVAS_HEIGHT - 50, + label = Label({ + text = "Debug", + translate = false, + hAlign = "center", + vAlign = "center" + }), + width = 40, + height = 40, + onClick = function() + if self.debugOverlay then + if not self.debugOverlay:isActive() then + self.debugOverlay:open() + end + end + end + }) + + local debugMenu = DebugMenu.makeDebugMenu({height = consts.CANVAS_HEIGHT - 40}) + self.debugOverlay = OverlayContainer({ + content = debugMenu + }) + + -- Add debug UI to root + self.uiRoot:addChild(self.debugButton) + self.uiRoot:addChild(self.debugOverlay) +end + + return Game diff --git a/client/src/NavigationStack.lua b/client/src/NavigationStack.lua index e9ef669c3..89ff75a42 100644 --- a/client/src/NavigationStack.lua +++ b/client/src/NavigationStack.lua @@ -1,11 +1,23 @@ local DirectTransition = require("client.src.scenes.Transitions.DirectTransition") local logger = require("common.lib.logger") - -local NavigationStack = { - scenes = {}, - transition = nil, - callback = nil, -} +local UIElement = require("client.src.ui.UIElement") +local class = require("common.lib.class") +local consts = require("common.engine.consts") + +---@class NavigationStack : UiElement +---@field scenes Scene[] +---@field transition table? +---@field callback function? +local NavigationStack = class( + function(self) + self.scenes = {} + self.transition = nil + self.callback = nil + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + end, + UIElement +) function NavigationStack:push(newScene, transition) local activeScene = self.scenes[#self.scenes] @@ -142,7 +154,7 @@ function NavigationStack:getActiveScene() end end -function NavigationStack:update(dt) +function NavigationStack:updateSelf(dt) if self.transition then self.transition:update(dt) @@ -163,7 +175,7 @@ function NavigationStack:update(dt) end end -function NavigationStack:draw() +function NavigationStack:drawSelf() if self.transition then self.transition:draw() else @@ -174,4 +186,12 @@ function NavigationStack:draw() end end +function NavigationStack:getTouchedElement(x, y) + local activeScene = self:getActiveScene() + if activeScene and activeScene.uiRoot then + return activeScene.uiRoot:getTouchedElement(x, y) + end + return nil +end + return NavigationStack \ No newline at end of file diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index 4b3252096..81f410220 100644 --- a/client/src/PlayerStack.lua +++ b/client/src/PlayerStack.lua @@ -15,6 +15,7 @@ local logger = require("common.lib.logger") require("client.src.analytics") local KeyDataEncoding = require("common.data.KeyDataEncoding") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") ---@module "common.data.LevelData" local floor, min, max = math.floor, math.min, math.max @@ -767,7 +768,7 @@ function PlayerStack:drawPopBurstParticle(atlas, quad, frameIndex, atlasDimensio end function PlayerStack:drawDebug() - if config.debug_mode then + if DebugSettings.showStackDebugInfo() then local engine = self.engine local x = self.origin_x + 480 @@ -863,7 +864,7 @@ function PlayerStack:drawDebug() end function PlayerStack:drawDebugPanels(shakeOffset) - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then return end @@ -952,7 +953,7 @@ function PlayerStack:drawRating() local rating if self.player.rating and tonumber(self.player.rating) then rating = self.player.rating - elseif config.debug_mode then + elseif DebugSettings.showStackDebugInfo() then rating = 1544 + self.player.playerNumber end diff --git a/client/src/Shortcuts.lua b/client/src/Shortcuts.lua index 2d428a57f..7c009e3ff 100644 --- a/client/src/Shortcuts.lua +++ b/client/src/Shortcuts.lua @@ -7,7 +7,13 @@ local logger = require("common.lib.logger") local function runSystemCommands() -- toggle debug mode if input.allKeys.isDown["d"] then - config.debug_mode = not config.debug_mode + if GAME.debugOverlay then + if GAME.debugOverlay.active then + GAME.debugOverlay:close() + else + GAME.debugOverlay:open() + end + end -- reload characters elseif input.allKeys.isDown["c"] then characters_reload_graphics() diff --git a/client/src/config.lua b/client/src/config.lua index c83d8b2e2..93c69a3ff 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -3,6 +3,7 @@ json = require("common.lib.dkjson") local util = require("common.lib.util") local fileUtils = require("client.src.FileUtils") local consts = require("common.engine.consts") +local DebugSettings = require("client.src.debug.DebugSettings") require("client.src.globals") -- Default configuration values @@ -27,12 +28,7 @@ require("client.src.globals") ---@field SFX_volume number ---@field music_volume number ---@field enableMenuMusic boolean ----@field debug_mode boolean ----@field debugShowServers boolean ----@field debugShowDesignHelper boolean ----@field debugProfile boolean ----@field debugProfileThreshold integer ----@field debug_vsFramesBehind integer +---@field debug DebugConfig? ---@field show_fps boolean ---@field show_ingame_infos boolean ---@field danger_music_changeback_delay boolean @@ -93,12 +89,8 @@ config = { SFX_volume = 50, music_volume = 50, enableMenuMusic = true, - -- Debug mode flag - debug_mode = false, - debugShowServers = false, - debugShowDesignHelper = false, - debugProfile = false, - debugProfileThreshold = 50, + -- Debug settings persisted separately + debug = DebugSettings.getDefaultConfigValues(), -- Show FPS in the top-left corner of the screen show_fps = false, @@ -226,19 +218,6 @@ config = { if type(read_data.music_volume) == "number" then configTable.music_volume = util.bound(0, read_data.music_volume, 100) end - if type(read_data.debug_mode) == "boolean" then - configTable.debug_mode = read_data.debug_mode - end - if type(read_data.debugShowServers) == "boolean" then - configTable.debugShowServers = read_data.debugShowServers - end - if type(read_data.debugShowDesignHelper) == "boolean" then - configTable.debugShowDesignHelper = read_data.debugShowDesignHelper - end - if type(read_data.debugProfile) == "boolean" then - configTable.debugProfile = read_data.debugProfile - end - -- debugProfileThreshold is not saved to prevent accidental dense profiling if type(read_data.show_fps) == "boolean" then configTable.show_fps = read_data.show_fps end @@ -310,6 +289,8 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end + + configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end end diff --git a/client/src/debug/DebugMenu.lua b/client/src/debug/DebugMenu.lua new file mode 100644 index 000000000..311e352fc --- /dev/null +++ b/client/src/debug/DebugMenu.lua @@ -0,0 +1,90 @@ +local class = require("common.lib.class") +local ui = require("client.src.ui") +local DebugSettings = require("client.src.debug.DebugSettings") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +local function createDebugBoolSelector(debugKey, onChangeFn) + return ui.BoolSelector({ + startValue = DebugSettings.get(debugKey) --[[@as boolean]], + onValueChange = function(selfElement, value) + GAME.theme:playMoveSfx() + DebugSettings.set(debugKey, value) + if onChangeFn then + onChangeFn() + end + end + }) +end + +local function createDebugSlider(debugKey, min, max, onValueChangeFn) + return ui.Slider({ + min = min, + max = max, + value = DebugSettings.get(debugKey) --[[@as number]], + tickLength = math.ceil(100 / max), + onValueChange = function(slider) + DebugSettings.set(debugKey, slider.value) + if onValueChangeFn then + onValueChangeFn(slider) + end + end + }) +end + +local function buildDebugMenuItems(options) + local debugMenuOptions = {} + + for _, def in ipairs(DebugSettings.getDefinitions()) do + if def.type == "boolean" then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createBoolSelectorMenuItem( + def.label, + nil, + false, + createDebugBoolSelector(def.key) + ) + elseif def.type == "number" then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createSliderMenuItem( + def.label, + nil, + false, + createDebugSlider(def.key, def.min or 0, def.max or 100) + ) + end + end + + if DEBUG_ENABLED then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() + GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) + end) + end + + if options.showBackButton then + debugMenuOptions[#debugMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + GAME.theme:playCancelSfx() + if options.onBack then + options.onBack() + end + end) + end + + return debugMenuOptions +end + +-- Menu for configuring debug settings +---@class DebugMenu : Menu +local DebugMenu = class(function(self, options) + +end, ui.Menu) + +-- We need a factory because menu items must be passed into the base class init +function DebugMenu.makeDebugMenu(options) + options = options or {} + options.x = 0 + options.y = 0 + options.hAlign = "center" + options.vAlign = "center" + options.menuItems = buildDebugMenuItems(options) + return DebugMenu(options) +end + +return DebugMenu diff --git a/client/src/debug/DebugSettings.lua b/client/src/debug/DebugSettings.lua new file mode 100644 index 000000000..43b02a2dc --- /dev/null +++ b/client/src/debug/DebugSettings.lua @@ -0,0 +1,418 @@ +local logger = require("common.lib.logger") + +-- Singleton class for managing runtime debug settings +-- Settings are persisted to the config file under config.debug +---@class DebugSettings +local DebugSettings = {} + +---@class DebugConfig +---@field showStackDebugInfo boolean +---@field showUIElementBorders boolean +---@field simulateMobileOS boolean +---@field forceFPS boolean +---@field drawGraphicsStats boolean +---@field showRuntimeGraph boolean +---@field vsFramesBehind number +---@field showDebugServers boolean +---@field showDesignHelper boolean +---@field profileFrameTimes boolean +---@field profileThreshold number + +---@class DebugSettingDefinition +---@field key string The key used in config.debug table +---@field type "boolean"|"number" The type of the setting +---@field default boolean|number The default value +---@field label string The display label for the UI +---@field min number? Minimum value for number types +---@field max number? Maximum value for number types +---@field debugBuildOnly boolean? Forces the setting off and hides it in non-debug builds + +-- All debug settings defined in one place with their metadata +local settingDefinitions = { + { + key = "showStackDebugInfo", + type = "boolean", + default = false, + label = "Show Stack Debug Info", + debugBuildOnly = false + }, + { + key = "showUIElementBorders", + type = "boolean", + default = false, + label = "Show UI Element Borders", + debugBuildOnly = true + }, + { + key = "simulateMobileOS", + type = "boolean", + default = false, + label = "Simulate Mobile OS", + debugBuildOnly = true + }, + { + key = "forceFPS", + type = "boolean", + default = false, + label = "Force FPS Display", + debugBuildOnly = true + }, + { + key = "drawGraphicsStats", + type = "boolean", + default = false, + label = "Draw Graphics Stats", + debugBuildOnly = true + }, + { + key = "showRuntimeGraph", + type = "boolean", + default = false, + label = "Show Runtime Graph", + debugBuildOnly = true + }, + { + key = "vsFramesBehind", + type = "number", + default = 0, + label = "VS Frames Behind", + min = 0, + max = 200, + debugBuildOnly = true + }, + { + key = "showDebugServers", + type = "boolean", + default = false, + label = "Show Debug Servers", + debugBuildOnly = false + }, + { + key = "showDesignHelper", + type = "boolean", + default = false, + label = "Show Design Helper", + debugBuildOnly = true + }, + { + key = "profileFrameTimes", + type = "boolean", + default = false, + label = "Profile frame times", + debugBuildOnly = false + }, + { + key = "profileThreshold", + type = "number", + default = 50, + label = "Discard frames below duration (ms)", + min = 0, + max = 100, + debugBuildOnly = false + } +} + +local function isNonDebugBuild() + return not DEBUG_ENABLED +end + +local function shouldLockToDefault(def) + return isNonDebugBuild() and def.debugBuildOnly +end + +---Creates a DebugConfig table populated with default values. +---@return DebugConfig +local function createDefaultConfig() + local defaults = {} + for _, def in ipairs(settingDefinitions) do + local value = def.default + defaults[def.key] = value + end + ---@type DebugConfig + return defaults +end + +--- Current settings values (loaded from config.debug) +---@type DebugConfig +local settings = createDefaultConfig() + +local function clampNumber(value, minValue, maxValue) + if minValue then + value = math.max(minValue, value) + end + if maxValue then + value = math.min(maxValue, value) + end + return value +end + +local function findDefinition(key) + for _, def in ipairs(settingDefinitions) do + if def.key == key then + return def + end + end +end + +---Returns default debug configuration values keyed by setting. +---@return DebugConfig +function DebugSettings.getDefaultConfigValues() + return createDefaultConfig() +end + +---Normalizes persisted debug configuration values using definitions. +---@param persisted table|nil +---@return DebugConfig +function DebugSettings.normalizeConfigValues(persisted) + local normalized = createDefaultConfig() + local source = persisted or {} + + for _, def in ipairs(settingDefinitions) do + local value = source[def.key] + if def.type == "boolean" then + if type(value) == "boolean" then + normalized[def.key] = value + end + elseif def.type == "number" then + if type(value) ~= "number" then + value = def.default + end + normalized[def.key] = clampNumber(value, def.min, def.max) + end + end + + return normalized +end + +-- Initializes debug settings from config +function DebugSettings.init() + config.debug = config.debug or {} + + config.debug = DebugSettings.normalizeConfigValues(config.debug) + + for _, def in ipairs(settingDefinitions) do + local value = config.debug[def.key] + if shouldLockToDefault(def) then + settings[def.key] = def.default + else + settings[def.key] = value + end + end +end + +-- Saves current settings to config +local function saveSettings() + config.debug = config.debug or {} + + for _, def in ipairs(settingDefinitions) do + config.debug[def.key] = settings[def.key] + end + + write_conf_file() +end + +local releaseDefinitionCache +-- Returns all setting definitions for UI generation +---@return DebugSettingDefinition[] +function DebugSettings.getDefinitions() + if not isNonDebugBuild() then + return settingDefinitions + end + + if not releaseDefinitionCache then + releaseDefinitionCache = {} + for _, def in ipairs(settingDefinitions) do + if not def.debugBuildOnly then + releaseDefinitionCache[#releaseDefinitionCache + 1] = def + end + end + end + + return releaseDefinitionCache +end + +-- Gets the value of a setting by key +---@param key string +---@return boolean|number +function DebugSettings.get(key) + local def = findDefinition(key) + + if isNonDebugBuild() then + if def then + if shouldLockToDefault(def) then + return def.default + end + + if def.type == "boolean" then + return settings[key] or false + elseif def.type == "number" then + return settings[key] or 0 + end + end + return false + end + + if not def then + return false + end + + return settings[key] +end + +-- Sets the value of a setting by key +---@param key string +---@param value boolean|number +function DebugSettings.set(key, value) + local def = findDefinition(key) + if not def then + logger.warn("Attempted to set unknown debug setting: " .. key) + return + end + + if def.type == "boolean" then + settings[key] = value and true or false + else + local numericValue = type(value) == "number" and value or def.default + settings[key] = clampNumber(numericValue, def.min, def.max) + end + saveSettings() +end + +-- Returns whether to show stack debug information +---@return boolean +function DebugSettings.showStackDebugInfo() + return DebugSettings.get("showStackDebugInfo") --[[@as boolean]] +end + +-- Returns whether to show UI element borders +---@return boolean +function DebugSettings.showUIElementBorders() + return DebugSettings.get("showUIElementBorders") --[[@as boolean]] +end + +-- Returns whether to simulate mobile OS on desktop +---@return boolean +function DebugSettings.simulateMobileOS() + return DebugSettings.get("simulateMobileOS") --[[@as boolean]] +end + +-- Returns whether to force FPS display in debug builds +---@return boolean +function DebugSettings.forceFPS() + return DebugSettings.get("forceFPS") --[[@as boolean]] +end + +-- Returns whether to draw graphics stats (draw calls, texture memory, etc.) +---@return boolean +function DebugSettings.drawGraphicsStats() + return DebugSettings.get("drawGraphicsStats") --[[@as boolean]] +end + +-- Returns whether to show the runtime graph +---@return boolean +function DebugSettings.showRuntimeGraph() + return DebugSettings.get("showRuntimeGraph") --[[@as boolean]] +end + +-- Returns the VS frames behind value (only applicable when showStackDebugInfo is true) +---@return number +function DebugSettings.getVSFramesBehind() + return DebugSettings.get("vsFramesBehind") --[[@as number]] +end + +-- Sets whether to show stack debug information +---@param value boolean +function DebugSettings.setShowStackDebugInfo(value) + DebugSettings.set("showStackDebugInfo", value) +end + +-- Sets whether to show UI element borders +---@param value boolean +function DebugSettings.setShowUIElementBorders(value) + DebugSettings.set("showUIElementBorders", value) +end + +-- Sets whether to simulate mobile OS on desktop +---@param value boolean +function DebugSettings.setSimulateMobileOS(value) + DebugSettings.set("simulateMobileOS", value) +end + +-- Sets whether to force FPS display in debug builds +---@param value boolean +function DebugSettings.setForceFPS(value) + DebugSettings.set("forceFPS", value) +end + +-- Sets whether to draw graphics stats +---@param value boolean +function DebugSettings.setDrawGraphicsStats(value) + DebugSettings.set("drawGraphicsStats", value) +end + +-- Sets whether to show the runtime graph +---@param value boolean +function DebugSettings.setShowRuntimeGraph(value) + DebugSettings.set("showRuntimeGraph", value) +end + +-- Sets the VS frames behind value +---@param value number +function DebugSettings.setVSFramesBehind(value) + DebugSettings.set("vsFramesBehind", value) +end + +-- Returns whether to show debug servers in main menu +---@return boolean +function DebugSettings.showDebugServers() + return DebugSettings.get("showDebugServers") --[[@as boolean]] +end + +-- Sets whether to show debug servers in main menu +---@param value boolean +function DebugSettings.setShowDebugServers(value) + DebugSettings.set("showDebugServers", value) +end + +-- Returns whether to show design helper in main menu +---@return boolean +function DebugSettings.showDesignHelper() + return DebugSettings.get("showDesignHelper") --[[@as boolean]] +end + +-- Sets whether to show design helper in main menu +---@param value boolean +function DebugSettings.setShowDesignHelper(value) + DebugSettings.set("showDesignHelper", value) +end + +-- Returns whether to profile frame times +---@return boolean +function DebugSettings.getProfileFrameTimes() + return DebugSettings.get("profileFrameTimes") --[[@as boolean]] +end + +-- Sets whether to profile frame times +---@param value boolean +function DebugSettings.setProfileFrameTimes(value) + DebugSettings.set("profileFrameTimes", value) + local prof = require("common.lib.zoneProfiler") + prof.enable(value) + prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) +end + +-- Returns the profile threshold in milliseconds +---@return number +function DebugSettings.getProfileThreshold() + return DebugSettings.get("profileThreshold") --[[@as number]] +end + +-- Sets the profile threshold in milliseconds +---@param value number +function DebugSettings.setProfileThreshold(value) + DebugSettings.set("profileThreshold", value) + local prof = require("common.lib.zoneProfiler") + prof.setDurationFilter(value / 1000) +end + +return DebugSettings diff --git a/client/src/developer.lua b/client/src/developer.lua index ff4cc477f..22f52222c 100644 --- a/client/src/developer.lua +++ b/client/src/developer.lua @@ -1,4 +1,5 @@ local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") ---@diagnostic disable: duplicate-set-field -- Put any local development changes you need in here that you don't want commited. @@ -7,7 +8,7 @@ local system = require("client.src.system") local function enableProfiler(threshold) local prof = require("common.lib.zoneProfiler") prof.enable(true) - prof.setDurationFilter((threshold or config.debugProfileThreshold) / 1000) + prof.setDurationFilter((threshold or DebugSettings.getProfileThreshold()) / 1000) end local developerTools = {} diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index 8ceac0802..eb29eb46a 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -676,9 +676,18 @@ end ---@param player Player ---@param width number ----@return BoolSelector rankedSelector +---@return StackPanel rankedSelectionContainer function CharacterSelect:createRankedSelection(player, width) - local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vFill = true, width = width, vAlign = "center", hAlign = "center"}) + + -- player number icon + local playerIndex = tableUtils.indexOf(self.players, player) + local playerNumberIcon = ui.ImageContainer({ + image = themes[config.theme].images.IMG_players[playerIndex], + scale = 2, + vAlign = "center" + }) + + local rankedSelector = ui.BoolSelector({startValue = player.settings.wantsRanked, isEnabled = player.isLocal, vAlign = "center"}) rankedSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() player:setWantsRanked(value) @@ -688,31 +697,40 @@ function CharacterSelect:createRankedSelection(player, width) player:connectSignal("wantsRankedChanged", rankedSelector, rankedSelector.setValue) + local container = ui.StackPanel( + { + alignment = "left", + height = rankedSelector.height, + hAlign = "center", + vAlign = "center", + } + ) + container.playerNumberIcon = playerNumberIcon + container.rankedSelector = rankedSelector + container:addElement(playerNumberIcon) + container:addElement(ui.UiElement({width = 8, height = 8})) + container:addElement(rankedSelector) + container:addElement(ui.UiElement({width = 8, height = 8})) + + return container +end + +---@param player Player +---@param width number +---@return StackPanel styleSelectionContainer +---@return BoolSelector styleSelector +function CharacterSelect:createStyleSelection(player, width) -- player number icon local playerIndex = tableUtils.indexOf(self.players, player) local playerNumberIcon = ui.ImageContainer({ image = themes[config.theme].images.IMG_players[playerIndex], - hAlign = "left", - vAlign = "center", - x = 2, scale = 2, + vAlign = "center" }) - rankedSelector.playerNumberIcon = playerNumberIcon - rankedSelector:addChild(rankedSelector.playerNumberIcon) - return rankedSelector -end - ----@param player Player ----@param width number ----@return BoolSelector styleSelector -function CharacterSelect:createStyleSelection(player, width) local styleSelector = ui.BoolSelector({ startValue = (player.settings.style == GameModes.Styles.MODERN), - vFill = true, - width = width, - vAlign = "center", - hAlign = "center", + isEnabled = player.isLocal }) -- onValueChange should get implemented by the caller @@ -729,19 +747,20 @@ function CharacterSelect:createStyleSelection(player, width) end ) - -- player number icon - local playerIndex = tableUtils.indexOf(self.players, player) - local playerNumberIcon = ui.ImageContainer({ - image = themes[config.theme].images.IMG_players[playerIndex], - hAlign = "left", + local container = ui.StackPanel({ + alignment = "left", + height = styleSelector.height, + hAlign = "center", vAlign = "center", - x = 8, - scale = 2, }) - styleSelector.playerNumberIcon = playerNumberIcon - styleSelector:addChild(styleSelector.playerNumberIcon) - - return styleSelector + container.playerNumberIcon = playerNumberIcon + container.styleSelector = styleSelector + container:addElement(playerNumberIcon) + container:addElement(ui.UiElement({width = 8, height = 8})) + container:addElement(styleSelector) + container:addElement(ui.UiElement({width = 8, height = 8})) + + return container, styleSelector end function CharacterSelect:createRecordsBox(lastText) diff --git a/client/src/scenes/DesignHelper.lua b/client/src/scenes/DesignHelper.lua index ac9627716..2fc419301 100644 --- a/client/src/scenes/DesignHelper.lua +++ b/client/src/scenes/DesignHelper.lua @@ -41,7 +41,7 @@ function DesignHelper:loadGrid() end function DesignHelper:loadRankedSelection(width) - local rankedSelector = ui.BoolSelector({startValue = true, vFill = true, width = width, vAlign = "center", hAlign = "center"}) + local rankedSelector = ui.BoolSelector({startValue = true, width = width, vAlign = "center", hAlign = "center"}) return rankedSelector end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index 92bd9d967..a0a00b27b 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -49,8 +49,8 @@ function EndlessMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleSelector, player) + local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleContainer, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/scenes/GameBase.lua b/client/src/scenes/GameBase.lua index 8c8605577..bf1f793ef 100644 --- a/client/src/scenes/GameBase.lua +++ b/client/src/scenes/GameBase.lua @@ -15,6 +15,7 @@ local ui = require("client.src.ui") local FileUtils = require("client.src.FileUtils") local ClientStack = require("client.src.ClientStack") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") -- Scene template for running any type of game instance (endless, vs-self, replays, etc.) ---@class GameBase : Scene @@ -444,14 +445,14 @@ function GameBase:drawHUD() end stack:drawLevel() - if stack.analytic and not config.debug_mode then + if stack.analytic and not DebugSettings.showStackDebugInfo() then --prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() --prof.pop("Stack:drawAnalyticData") end end - if not config.debug_mode and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details + if not DebugSettings.showStackDebugInfo() and GAME.battleRoom and GAME.battleRoom.spectatorString then -- this is printed in the same space as the debug details GraphicsUtil.print(GAME.battleRoom.spectatorString, themes[config.theme].spectators_Pos[1], themes[config.theme].spectators_Pos[2]) end diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index 8054187c7..ffb9f72ca 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -4,6 +4,7 @@ local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") +local DebugSettings = require("client.src.debug.DebugSettings") local EndlessMenu = require("client.src.scenes.EndlessMenu") local PuzzleMenu = require("client.src.scenes.PuzzleMenu") local TimeAttackMenu = require("client.src.scenes.TimeAttackMenu") @@ -40,6 +41,16 @@ local function switchToScene(scene, transition) GAME.navigationStack:push(scene, transition) end +function MainMenu:refresh() + if self.menu then + self.menu:detach() + self.menu = nil + end + + self.menu = self:createMainMenu() + self.uiRoot:addChild(self.menu) +end + function MainMenu:createMainMenu() local menuItems = {ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() @@ -104,12 +115,12 @@ function MainMenu:createMainMenu() } local function addDebugMenuItems() - if config.debugShowServers then + if DebugSettings.showDebugServers() then for i, menuItem in ipairs(debugMenuItems) do menu:addMenuItem(i + 7, menuItem) end end - if config.debugShowDesignHelper then + if DebugSettings.showDesignHelper() then menu:addMenuItem(#menu.menuItems, ui.MenuItem.createButtonMenuItem("Design Helper", nil, nil, function() switchToScene(DesignHelper()) end)) diff --git a/client/src/scenes/ModManagement.lua b/client/src/scenes/ModManagement.lua index 95761b775..5b5bac0d5 100644 --- a/client/src/scenes/ModManagement.lua +++ b/client/src/scenes/ModManagement.lua @@ -167,7 +167,7 @@ function ModManagement:loadStageGrid() if stageId ~= consts.RANDOM_STAGE_SPECIAL_VALUE then local stage = allStages[stageId] local icon = ui.ImageContainer({drawBorders = true, image = stage.images.thumbnail, hFill = true, vFill = true, hAlign = "center", vAlign = "center"}) - local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local enableSelector = ui.BoolSelector({startValue = not not stages[stage.id], hAlign = "center", vAlign = "center"}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() stage:enable(boolSelector.value) @@ -181,7 +181,7 @@ function ModManagement:loadStageGrid() GAME.localPlayer:setStage(stages[consts.RANDOM_STAGE_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local visibilitySelector = ui.BoolSelector({startValue = stage.isVisible, hAlign = "center", vAlign = "center"}) visibilitySelector.onValueChange = function(boolSelector, value) end local name = ui.Label({text = stage.display_name, translate = false, hAlign = "center", vAlign = "center"}) @@ -241,7 +241,7 @@ function ModManagement:loadCharacterGrid() if characterId ~= consts.RANDOM_CHARACTER_SPECIAL_VALUE then local character = allCharacters[characterId] local icon = ui.ImageContainer({drawBorders = true, image = character.images.icon, hFill = true, vFill = true}) - local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local enableSelector = ui.BoolSelector({startValue = not not characters[character.id], hAlign = "center", vAlign = "center"}) enableSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() character:enable(boolSelector.value) @@ -255,7 +255,7 @@ function ModManagement:loadCharacterGrid() GAME.localPlayer:setCharacter(characters[consts.RANDOM_CHARACTER_SPECIAL_VALUE]) end end - local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center", hFill = true, vFill = true}) + local visibilitySelector = ui.BoolSelector({startValue = character.isVisible, hAlign = "center", vAlign = "center"}) visibilitySelector.onValueChange = function(boolSelector, value) end local displayName = ui.Label({text = character.display_name, translate = false, hAlign = "center", vAlign = "center"}) diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index daf252512..7b7477f37 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -2,6 +2,7 @@ local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local inputManager = require("client.src.inputManager") local save = require("client.src.save") +local DebugMenu = require("client.src.debug.DebugMenu") local consts = require("common.engine.consts") local fileUtils = require("client.src.FileUtils") local analytics = require("client.src.analytics") @@ -15,7 +16,6 @@ local ModManagement = require("client.src.scenes.ModManagement") local system = require("client.src.system") local JsonSafePrecision = require("common.data.JsonSafePrecision") local logger = require("common.lib.logger") -local prof = require("common.lib.zoneProfiler") -- Scene for the options menu local OptionsMenu = class(function(self, sceneParams) @@ -486,30 +486,14 @@ function OptionsMenu:loadSoundMenu() end function OptionsMenu:loadDebugMenu() - local debugMenuOptions = { - ui.MenuItem.createToggleButtonGroupMenuItem("op_debug_mode", nil, nil, createToggleButtonGroup("debug_mode")), - ui.MenuItem.createSliderMenuItem("VS Frames Behind", nil, false, createConfigSlider("debug_vsFramesBehind", 0, 200)), - ui.MenuItem.createToggleButtonGroupMenuItem("Show Debug Servers", nil, false, createToggleButtonGroup("debugShowServers")), - ui.MenuItem.createToggleButtonGroupMenuItem("Show Design Helper", nil, false, createToggleButtonGroup("debugShowDesignHelper")), - ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() - GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) - end), - ui.MenuItem.createToggleButtonGroupMenuItem("Profile frame times", nil, false, createToggleButtonGroup("debugProfile", - function() - prof.enable(config.debugProfile) - prof.setDurationFilter(config.debugProfileThreshold / 1000) - end)), - ui.MenuItem.createSliderMenuItem("Discard frames below duration (ms)", nil, false, createConfigSlider("debugProfileThreshold", 0, 100, - function() - prof.setDurationFilter(config.debugProfileThreshold / 1000) - end)), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() - GAME.theme:playCancelSfx() - self:switchToScreen("baseMenu") - end), - } - - return ui.Menu.createCenteredMenu(debugMenuOptions) + local debugMenu = DebugMenu.makeDebugMenu({ + showBackButton = true, + onBack = function() + self:switchToScreen("baseMenu") + end, + height = themes[config.theme].main_menu_max_height + }) + return debugMenu end function OptionsMenu:loadAboutMenu() diff --git a/client/src/scenes/PortraitGame.lua b/client/src/scenes/PortraitGame.lua index 3f05ab166..d95886cb9 100644 --- a/client/src/scenes/PortraitGame.lua +++ b/client/src/scenes/PortraitGame.lua @@ -6,6 +6,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local ui = require("client.src.ui") local input = require("client.src.inputManager") local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") local PortraitGame = class(function(self, sceneParams) end, @@ -193,7 +194,7 @@ function PortraitGame:flipToPortrait() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_HEIGHT, consts.CANVAS_WIDTH, {dpiscale=GAME:newCanvasSnappedScale()}) local width, height, _ = love.window.getMode() - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then -- flip the window dimensions to portrait love.window.updateMode(height, width, {}) love.window.setFullscreen(true) @@ -245,7 +246,7 @@ function PortraitGame:returnToLandscape() GAME.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) -- flip the window dimensions to landscape local width, height, _ = love.window.getMode() - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then love.window.updateMode(height, width, {}) love.window.setFullscreen(false) --GAME:updateCanvasPositionAndScale(width, height) diff --git a/client/src/scenes/ReplayGame.lua b/client/src/scenes/ReplayGame.lua index f2e306a7f..911cc0a9a 100644 --- a/client/src/scenes/ReplayGame.lua +++ b/client/src/scenes/ReplayGame.lua @@ -5,6 +5,7 @@ local util = require("common.lib.util") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local prof = require("common.lib.zoneProfiler") +local DebugSettings = require("client.src.debug.DebugSettings") local ReplayGame = class( function (self, sceneParams) @@ -141,7 +142,7 @@ function ReplayGame:drawHUD() end stack:drawLevel() - if stack.analytic and not DEBUG_ENABLED then + if stack.analytic and not DebugSettings.showStackDebugInfo() then prof.push("Stack:drawAnalyticData") stack:drawAnalyticData() prof.pop("Stack:drawAnalyticData") diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index ea9f9adce..0270b7ccd 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -5,6 +5,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") local SoundController = require("client.src.music.SoundController") local directsFocus = require("client.src.ui.FocusDirector") +local DebugSettings = require("client.src.debug.DebugSettings") ---@alias sceneMusic ("none" | "main" | "title_screen" | "select_screen") @@ -83,7 +84,7 @@ end function Scene:drawCommunityMessage() -- Draw the community message - if not config.debug_mode then + if not DebugSettings.showStackDebugInfo() then GraphicsUtil.printf(join_community_msg or "", 0, (668 / 720) * GAME.globalCanvas:getHeight(), GAME.globalCanvas:getWidth(), "center") end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 1ab39f816..c846420e7 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -48,8 +48,8 @@ function TimeAttackMenu:loadUserInterface() self.ui.styleSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.styleSelection:setTitle("endless_modern") - local styleSelector = self:createStyleSelection(player, unitSize) - self.ui.styleSelection:addElement(styleSelector, player) + local styleContainer, styleSelector = self:createStyleSelection(player, unitSize) + self.ui.styleSelection:addElement(styleContainer, player) self.ui.grid:createElementAt(5, 2, 1, 1, "styleSelection", self.ui.styleSelection, nil, true) diff --git a/client/src/ui/BoolSelector.lua b/client/src/ui/BoolSelector.lua index 5d4d7e83e..2827daeb2 100644 --- a/client/src/ui/BoolSelector.lua +++ b/client/src/ui/BoolSelector.lua @@ -3,6 +3,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class BoolSelectorOptions : UiElementOptions ---@field startValue boolean? @@ -11,9 +12,22 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@class BoolSelector : UiElement ---@field value boolean ---@field vertical boolean -local BoolSelector = class(function(boolSelector, options) - boolSelector.value = options.startValue or false - boolSelector.vertical = false +---@field circleRadius number +---@field extraDistance number +---@field lengthPadding number +---@field widthPadding number +local BoolSelector = class(function(self, options) + self.value = options.startValue or false + self.vertical = false + self.circleRadius = 10 + self.extraDistance = 16 + self.lengthPadding = 2 + self.widthPadding = 2 + self.onValueChange = options.onValueChange or function() end + + -- Calculate initial dimensions + self.width = self:calculateWidth() + self.height = self:calculateHeight() end, UiElement) @@ -62,57 +76,60 @@ function BoolSelector:setValue(value) end end +---@return number +function BoolSelector:calculateWidth() + local width = self.circleRadius * 2 + 2 * self.widthPadding + if not self.vertical then + width = width + self.extraDistance + end + return width +end + +---@return number +function BoolSelector:calculateHeight() + local height = self.circleRadius * 2 + 2 * self.lengthPadding + if self.vertical then + height = height + self.extraDistance + end + return height +end + -- other code may implement a callback here -- function BoolSelector.onValueChange() end -local circleRadius = 10 -local extraDistance = 16 -local lengthPadding = 2 -local widthPadding = 2 -local totalWidth = 0 -local totalLength = 0 -local fakeCenteredChild = {hAlign = "center", vAlign = "center", width = totalWidth, height = totalLength} - function BoolSelector:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(0, 0, 1, 1) GraphicsUtil.drawRectangle("line", self.x + 1, self.y + 1, self.width - 2, self.height - 2) GraphicsUtil.setColor(1, 1, 1, 1) end - local circleX = circleRadius + widthPadding - local circleY = circleRadius + lengthPadding - totalWidth = circleRadius * 2 + 2 * widthPadding - totalLength = circleRadius * 2 + 2 * lengthPadding + local drawX = self.x + self.widthPadding + local drawY = self.y + self.lengthPadding + local drawWidth = self.width - 2 * self.widthPadding + local drawHeight = self.height - 2 * self.lengthPadding + + local circleX = self.circleRadius + local circleY = self.circleRadius + if self.vertical then - totalLength = totalLength + extraDistance if self.value == false then - circleY = circleY + extraDistance + circleY = circleY + self.extraDistance end else - totalWidth = totalWidth + extraDistance if self.value then - circleX = circleX + extraDistance + circleX = circleX + self.extraDistance end end - fakeCenteredChild.width = totalWidth - fakeCenteredChild.height = totalLength - - -- we want these to be centered but creating a Rectangle / Circle ui element is maybe a bit too much? - -- so just apply the translation via a fake element with all necessary props - GraphicsUtil.applyAlignment(self, fakeCenteredChild) - love.graphics.translate(self.x, self.y) if self.value then GraphicsUtil.setColor(30/255, 190/255, 67/255, 1) - GraphicsUtil.drawRectangle("fill", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) + GraphicsUtil.drawRectangle("fill", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) GraphicsUtil.setColor(1, 1, 1, 1) end - GraphicsUtil.drawRectangle("line", 0, 0, totalWidth, totalLength, nil, nil, nil, nil, circleRadius, circleRadius) - love.graphics.circle("fill", circleX, circleY, circleRadius) - - GraphicsUtil.resetAlignment() + GraphicsUtil.drawRectangle("line", drawX, drawY, drawWidth, drawHeight, nil, nil, nil, nil, self.circleRadius, self.circleRadius) + love.graphics.circle("fill", drawX + circleX, drawY + circleY, self.circleRadius) end return BoolSelector \ No newline at end of file diff --git a/client/src/ui/Carousel.lua b/client/src/ui/Carousel.lua index 4215d017f..bfd71988a 100644 --- a/client/src/ui/Carousel.lua +++ b/client/src/ui/Carousel.lua @@ -4,6 +4,7 @@ local Focusable = require(PATH .. ".Focusable") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") +local DebugSettings = require("client.src.debug.DebugSettings") local function calculateFontSize(height) return math.floor(height / 2) + 1 @@ -87,7 +88,7 @@ function Carousel.setPassengerByIndex(self, index) end function Carousel:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) end end diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index 2dfc7a2bf..aedf54c00 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -3,6 +3,7 @@ local UiElement = require(PATH .. ".UIElement") local GridElement = require(PATH .. ".GridElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local Grid = class(function(self, options) self.unitSize = options.unitSize @@ -73,7 +74,7 @@ function Grid:createElementAt(x, y, width, height, description, uiElement, noPad end function Grid:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 1, 1, 0.5) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/GridElement.lua b/client/src/ui/GridElement.lua index d0725fdf8..ff9165674 100644 --- a/client/src/ui/GridElement.lua +++ b/client/src/ui/GridElement.lua @@ -2,6 +2,7 @@ local PATH = (...):gsub('%.[^%.]+$', '') local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local GridElement = class(function(gridElement, options) if options.content then @@ -16,7 +17,7 @@ local GridElement = class(function(gridElement, options) gridElement.gridHeight = options.gridHeight if options.drawBorders ~= nil then gridElement.drawBorders = options.drawBorders - elseif DEBUG_ENABLED then + elseif DebugSettings.showUIElementBorders() then gridElement.drawBorders = true else gridElement.drawBorders = false diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 7931404d2..400192c1c 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -5,6 +5,7 @@ local TextButton = require(PATH .. ".TextButton") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local system = require("client.src.system") +local DebugSettings = require("client.src.debug.DebugSettings") -- MenuItem is a specific UIElement that all children of Menu should be local MenuItem = class(function(self, options) @@ -27,7 +28,7 @@ function MenuItem.createMenuItem(label, item) menuItem.width = label.width + (2 * MenuItem.PADDING) - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then label.height = math.max(30, label.height + (2 * MenuItem.PADDING)) menuItem.height = math.max(30, label.height, item and item.height or 0) else @@ -38,7 +39,7 @@ function MenuItem.createMenuItem(label, item) local spaceBetween = 16 item.x = label.width + spaceBetween item.vAlign = "center" - if system.isMobileOS() or DEBUG_ENABLED then + if system.isMobileOS() or DebugSettings.simulateMobileOS() then item.height = math.max(30, item.height) end menuItem.width = item.x + item.width + MenuItem.PADDING @@ -128,7 +129,19 @@ function MenuItem.createSliderMenuItem(text, replacements, translate, slider) end local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) local menuItem = MenuItem.createMenuItem(label, slider) - + + return menuItem +end + +function MenuItem.createBoolSelectorMenuItem(text, replacements, translate, boolSelector) + assert(text ~= nil) + assert(boolSelector ~= nil) + if translate == nil then + translate = true + end + local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) + local menuItem = MenuItem.createMenuItem(label, boolSelector) + return menuItem end diff --git a/client/src/ui/MultiPlayerSelectionWrapper.lua b/client/src/ui/MultiPlayerSelectionWrapper.lua index c0802d71c..969dba238 100644 --- a/client/src/ui/MultiPlayerSelectionWrapper.lua +++ b/client/src/ui/MultiPlayerSelectionWrapper.lua @@ -18,6 +18,7 @@ end, StackPanel) function MultiPlayerSelectionWrapper:addElement(uiElement, player) + assert(uiElement.receiveInputs) self.wrappedElements[player] = uiElement uiElement.yieldFocus = function() self.yieldFocus() diff --git a/client/src/ui/OverlayContainer.lua b/client/src/ui/OverlayContainer.lua new file mode 100644 index 000000000..e3a414671 --- /dev/null +++ b/client/src/ui/OverlayContainer.lua @@ -0,0 +1,124 @@ +local class = require("common.lib.class") +local UiElement = require("client.src.ui.UIElement") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") + +-- Full-screen semi-transparent overlay that holds a centered UI element +-- Closes on click outside content area +---@class OverlayContainer : UiElement +---@field content UiElement? The centered content element +---@field onClose fun()? Callback invoked when overlay closes +---@field active boolean True when overlay is open and processing input +local OverlayContainer = class( + function(self, options) + options = options or {} + self.content = options.content + self.onClose = options.onClose + self.active = false + + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + self:setVisibility(false) + + if self.content then + self:addChild(self.content) + self.content.hAlign = "center" + self.content.vAlign = "center" + end + end, + UiElement +) + +-- Opens the overlay +function OverlayContainer:open() + self.active = true + self:setVisibility(true) +end + +-- Closes the overlay +function OverlayContainer:close() + if not self.active then + return + end + + self.active = false + self:setVisibility(false) + + if self.onClose then + self.onClose() + end +end + +-- Checks if the overlay is currently active +---@return boolean +function OverlayContainer:isActive() + return self.active +end + +-- Sets the content element for the overlay +---@param content UiElement The content to display in the center +function OverlayContainer:setContent(content) + if self.content then + self.content:detach() + end + + self.content = content + if self.content then + self:addChild(self.content) + self.content.hAlign = "center" + self.content.vAlign = "center" + end +end + +-- Draws the semi-transparent background +function OverlayContainer:drawSelf() + if not self.active then + return + end + + GraphicsUtil.setColor(0, 0, 0, 0.75) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Touch handler - closes overlay if clicking outside content +---@return boolean? True to block touch event propagation +function OverlayContainer:onTouch(x, y) + if not self.active then + return false + end + + if self.content then + local contentX, contentY = self.content:getScreenPos() + local inContentBounds = x >= contentX and x < contentX + self.content.width and + y >= contentY and y < contentY + self.content.height + + if not inContentBounds then + self:close() + return true + end + end + + return true +end + +-- Release handler - blocks event propagation +---@return boolean? True to block release event propagation +function OverlayContainer:onRelease() + if self.active then + return true + end +end + +-- Input handler - closes overlay on ESC key +function OverlayContainer:receiveInputs(inputs, dt) + if not self.active then + return + end + + if inputs.isDown["MenuEsc"] then + self:close() + end +end + +return OverlayContainer diff --git a/client/src/ui/PagedUniGrid.lua b/client/src/ui/PagedUniGrid.lua index 2dfe1bc41..a96458946 100644 --- a/client/src/ui/PagedUniGrid.lua +++ b/client/src/ui/PagedUniGrid.lua @@ -6,6 +6,7 @@ local Grid = require(PATH .. ".Grid") local class = require("common.lib.class") local Signal = require("common.lib.signal") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local function addNewPage(pagedUniGrid) local grid = Grid({ @@ -101,7 +102,7 @@ function PagedUniGrid:refreshPageTurnButtonVisibility() end function PagedUniGrid:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 0, 0, 1) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index e1b226976..a1018b358 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -3,23 +3,26 @@ local UiElement = require(PATH .. ".UIElement") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") --- StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting --- Useful for auto-aligning multiple ui elements that only know one of their dimensions +---@class StackPanel : UiElement +---StackPanel is a layouting element that stacks up all its children in one direction based on an alignment setting. +---Useful for auto-aligning multiple ui elements that only know one of their dimensions. +---@field alignment "left"|"right"|"top"|"bottom" Direction in which children are stacked +---@field pixelsTaken number Tracks how many pixels are already taken in the stacking direction +---@field TYPE string Class type identifier local StackPanel = class(function(stackPanel, options) - -- all children are aligned automatically towards that option inside the StackPanel - -- possible values: "left", "right", "top", "bottom" + ---@type "left"|"right"|"top"|"bottom" stackPanel.alignment = options.alignment - - -- StackPanels are unidirectional but can go into either direction - -- pixelsTaken tracks how many pixels are already taken in the direction the StackPanel propagates towards + ---@type number stackPanel.pixelsTaken = 0 - -- a stack panel does not have a size limit it's alignment dimension grows with its content end, UiElement) StackPanel.TYPE = "StackPanel" +---Applies positioning and sizing settings to a UI element based on the StackPanel's alignment +---@param uiElement UiElement The element to apply settings to function StackPanel:applyStackPanelSettings(uiElement) if self.alignment == "left" then uiElement.hFill = false @@ -48,19 +51,29 @@ function StackPanel:applyStackPanelSettings(uiElement) end end +---Adds a UI element to the StackPanel, applying proper positioning and resizing +---@param uiElement UiElement The element to add function StackPanel:addElement(uiElement) self:applyStackPanelSettings(uiElement) self:addChild(uiElement) self:resize() + uiElement.yieldFocus = function() + self.yieldFocus() + end end - +---Inserts a UI element at a specific index in the StackPanel +---@param uiElement UiElement The element to insert +---@param index number The position to insert at (1-based) function StackPanel:insertElementAtIndex(uiElement, index) -- add it at the end StackPanel.addElement(self, uiElement) StackPanel.shiftTo(self, uiElement, index) end +---Shifts an element to a specific index by swapping positions with preceding elements +---@param uiElement UiElement The element to shift +---@param index number The target position (1-based) function StackPanel:shiftTo(uiElement, index) -- swap the previous element with it while updating values until it reached the desired index for i = #self.children - 1, index, -1 do @@ -82,6 +95,9 @@ function StackPanel:shiftTo(uiElement, index) end end +---Removes an element from the StackPanel, updating positions and pixel tracking +---IMPORTANT: Use this method instead of element:detach() to maintain proper layout state +---@param uiElement UiElement The element to remove function StackPanel:remove(uiElement) local index = tableUtils.indexOf(self.children, uiElement) @@ -114,8 +130,21 @@ function StackPanel:remove(uiElement) uiElement:detach() end +---Processes user input and forwards it to child elements +---@param input table Input state +---@param dt number Delta time since last frame +function StackPanel:receiveInputs(input, dt) + for _, child in ipairs(self.children) do + if child.receiveInputs then + child:receiveInputs(input, dt) + return + end + end +end + +---Draws the StackPanel's debug borders if enabled in debug settings function StackPanel:drawSelf() - if DEBUG_ENABLED then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(1, 0, 0, 0.7) GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/Stepper.lua b/client/src/ui/Stepper.lua index a342bb645..9aeba5b22 100644 --- a/client/src/ui/Stepper.lua +++ b/client/src/ui/Stepper.lua @@ -5,6 +5,7 @@ local Label = require(PATH .. ".Label") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") local NAV_BUTTON_WIDTH = 25 local EMPTY_STEPPER_WIDTH = 160 @@ -110,7 +111,7 @@ function Stepper:refreshLocalization() end function Stepper:drawSelf() - if config.debug_mode then + if DebugSettings.showUIElementBorders() then GraphicsUtil.setColor(self.color) GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) GraphicsUtil.setColor(self.borderColor) diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index e297c2646..f44700524 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -10,15 +10,11 @@ local touchHandler = { } function touchHandler:touch(x, y) - local activeScene = GAME.navigationStack:getActiveScene() - -- if there is no active scene that implies an on-going scene switch, no interactions should be possible - if activeScene then - -- prevent multitouch - if not self.touchedElement then - self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) - if self.touchedElement and self.touchedElement.onTouch then - self.touchedElement:onTouch(x, y) - end + -- prevent multitouch + if not self.touchedElement then + self.touchedElement = GAME.uiRoot:getTouchedElement(x, y) + if self.touchedElement and self.touchedElement.onTouch then + self.touchedElement:onTouch(x, y) end end end diff --git a/common/engine/Match.lua b/common/engine/Match.lua index 18172e4ce..2efa110a4 100644 --- a/common/engine/Match.lua +++ b/common/engine/Match.lua @@ -12,6 +12,7 @@ local LegacyPanelSource = require("common.compatibility.LegacyPanelSource") local InputCompression = require("common.data.InputCompression") local ReplayV3 = require("common.data.ReplayV3") local MatchRules = require("common.data.MatchRules") +local DebugSettings = require("client.src.debug.DebugSettings") ---@class Match ---@field stacks (Stack | SimulatedStack)[] The stacks to run as part of the match @@ -615,10 +616,11 @@ function Match:shouldRun(stack, runsSoFar) end -- In debug mode allow non-local player 2 to fall a certain number of frames behind - if config and config.debug_mode and not stack.is_local and config.debug_vsFramesBehind and config.debug_vsFramesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then + local framesBehind = DebugSettings.getVSFramesBehind() + if not stack.is_local and framesBehind > 0 and tableUtils.indexOf(self.stacks, stack) == 2 then -- Only stay behind if the game isn't over for the local player (=garbageTarget) yet if self.garbageTargets[2][1] and self.garbageTargets[2][1].game_ended and self.garbageTargets[2][1]:game_ended() == false then - if stack.clock + config.debug_vsFramesBehind >= self.garbageTargets[2][1].clock then + if stack.clock + framesBehind >= self.garbageTargets[2][1].clock then return false end end diff --git a/docs/DebugSettings.md b/docs/DebugSettings.md new file mode 100644 index 000000000..36dc2a74b --- /dev/null +++ b/docs/DebugSettings.md @@ -0,0 +1,117 @@ +# Debug Settings System Documentation + +## Overview +The Debug Settings system provides runtime-configurable debug features through a UI overlay. Settings are persisted to `config.debug` and can be accessed anywhere via the `DebugSettings` singleton. + +## Architecture + +### Components +- **DebugSettings** (`client/src/debug/DebugSettings.lua`) - Singleton managing all debug settings +- **OverlayContainer** (`client/src/ui/OverlayContainer.lua`) - Full-screen overlay UI for settings menu +- **Debug Button** (`client/src/Game.lua`) - Bottom-right button that opens the overlay (only visible when `DEBUG_ENABLED`) + +## How Debug-Only Settings Work + +Settings can be marked as `debugBuildOnly = true` to control their availability: + +### Debug Builds (`DEBUG_ENABLED = true`) +- Setting appears in the UI overlay +- Can be toggled on/off by the user +- Value persists to config file +- Returns actual user-configured value + +### Release Builds (`DEBUG_ENABLED = false`) +- Setting is hidden from the UI overlay +- Always returns the default value (typically `false`) +- Persisted value is ignored +- Cannot be changed at runtime + +This is implemented through two mechanisms: + +**1. UI Filtering** - `DebugSettings.getDefinitions()` filters out `debugBuildOnly` settings in release builds + +**2. Value Locking** - `DebugSettings.get()` returns the default value for debug-only settings in release builds + +## Adding New Debug Settings + +### Step 1: Add Setting Definition + +Add a new entry to the `settingDefinitions` table in `DebugSettings.lua`: + +```lua +{ + key = "myNewSetting", + type = "boolean", -- or "number" + default = false, + label = "My New Feature", + debugBuildOnly = true -- false if should be available in release builds +} +``` + +For number settings, add `min` and `max`: +```lua +{ + key = "myNumberSetting", + type = "number", + default = 0, + label = "My Number Setting", + min = 0, + max = 100, + debugBuildOnly = true +} +``` + +### Step 2: Add Accessor Methods + +Add getter and optional setter methods following the naming convention: + +```lua +-- Getter +function DebugSettings.myNewSetting() + return DebugSettings.get("myNewSetting") --[[@as boolean]] +end + +-- Setter (if needed for programmatic access) +function DebugSettings.setMyNewSetting(value) + DebugSettings.set("myNewSetting", value) +end +``` + +### Step 3: Use in Code + +Replace existing debug checks with the new method: + +```lua + +if DebugSettings.myNewSetting() then + -- debug behavior +end +``` + +## Setting Definition Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `key` | string | Yes | Unique key used in config.debug | +| `type` | "boolean" \| "number" | Yes | Data type of the setting | +| `default` | boolean \| number | Yes | Default value when not configured | +| `label` | string | Yes | Display label shown in UI overlay | +| `min` | number | No | Minimum value (number types only) | +| `max` | number | No | Maximum value (number types only) | +| `debugBuildOnly` | boolean | No | If true, only available in DEBUG_ENABLED builds (defaults to false) | + +## Persistence + +Settings are automatically saved to `config.debug` in the user's config file: + +```lua +config.debug = { + showStackDebugInfo = false, + showUIElementBorders = true, + vsFramesBehind = 0, + -- ... other settings +} +``` + +- Settings load on startup via `DebugSettings.init()` +- Settings save immediately when changed via `DebugSettings.set()` diff --git a/main.lua b/main.lua index 952272a39..bcd2dcb98 100644 --- a/main.lua +++ b/main.lua @@ -1,6 +1,7 @@ local logger = require("common.lib.logger") require("common.lib.mathExtensions") local utf8 = require("common.lib.utf8Additions") +local DebugSettings = require("client.src.debug.DebugSettings") local inputManager = require("client.src.inputManager") require("client.src.globals") local touchHandler = require("client.src.ui.touchHandler") @@ -62,8 +63,8 @@ function love.load(args, rawArgs) GAME:load() if not PROFILE_MEMORY then - prof.enable(config.debugProfile) - prof.setDurationFilter(config.debugProfileThreshold / 1000) + prof.enable(DebugSettings.getProfileFrameTimes()) + prof.setDurationFilter(DebugSettings.getProfileThreshold() / 1000) end end @@ -78,7 +79,7 @@ end -- Intentional override ---@diagnostic disable-next-line: duplicate-set-field function love.update(dt) - if config.show_fps and config.debug_mode then + if DebugSettings.showRuntimeGraph() then if CustomRun.runTimeGraph == nil then CustomRun.runTimeGraph = RunTimeGraph() end @@ -126,7 +127,7 @@ end function love.draw() GAME:draw() - if DEBUG_ENABLED then + if DebugSettings.drawGraphicsStats() then local stats = love.graphics.getStats() local width, height = love.graphics.getDimensions() From 8f84a10edd58c07144ac7175965b2b4d02bca0b6 Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sun, 26 Oct 2025 22:25:39 -0700 Subject: [PATCH 16/16] New User Setup and Input Overlay Language selection setup screen created, shown when language not set Discord information setup screen created, new config for when it has been shown New Input Overlay Scene for picking inputs New controller theme graphics for representing input device type Comprehensive BattleRoom methods for managing players and their input assignments Restore previous assignments in 1P if they have been picked this session Language now defaults to English but not confirmed until picked Set Language method decoupled from saving language to support above InputPromptRenderer for drawing device images and eventually button prompts InputConfiguration has become a full class with device name, image, label etc fully accessible. InputManager manages the input configurations and provides a touch one. Added a second WASD default configuration for when new players don't have any yet and expect to navigate menus with WASD Input Device Overlay is shown in character select when not all human local players are assigned. Added change input device button for changing after you have already assigned Input Config Menu visuals improved, added auto configure joystick Added SceneCoordinator class for auto showing next scenes based on state Added ability for labels to autosize Added ability for menus to size to fit Added a slider menu item Made UIElement properly traverse touch from topmost down Auto show input config on new controller Add way to set TYPE variable on class constructor Added debugging print functions on UIElement Clear previous bindings when reusing key Require all keys bound before exiting config Input manager cleanup --- COPYING-ASSETS | 14 + client/assets/localization.csv | 25 + .../Panel Attack Modern/discord_logo.png | Bin 0 -> 206115 bytes .../input/controller_add.png | Bin 0 -> 1323 bytes .../input/controller_gamecube.png | Bin 0 -> 1917 bytes .../input/controller_generic.png | Bin 0 -> 1658 bytes .../input/controller_n64.png | Bin 0 -> 5403 bytes .../input/controller_playstation1.png | Bin 0 -> 1737 bytes .../input/controller_playstation2.png | Bin 0 -> 1833 bytes .../input/controller_playstation3.png | Bin 0 -> 1857 bytes .../input/controller_playstation4.png | Bin 0 -> 1805 bytes .../input/controller_playstation5.png | Bin 0 -> 1882 bytes .../input/controller_snes.png | Bin 0 -> 4415 bytes .../input/controller_switch_pro.png | Bin 0 -> 1759 bytes .../input/controller_xbox360.png | Bin 0 -> 1868 bytes .../input/controller_xboxone.png | Bin 0 -> 1814 bytes .../input/controller_xboxseries.png | Bin 0 -> 1842 bytes .../input/device_number_0.png | Bin 0 -> 1607 bytes .../input/device_number_1.png | Bin 0 -> 1296 bytes .../input/device_number_2.png | Bin 0 -> 1564 bytes .../input/device_number_3.png | Bin 0 -> 1592 bytes .../input/device_number_4.png | Bin 0 -> 1338 bytes .../input/device_number_5.png | Bin 0 -> 1527 bytes .../input/device_number_6.png | Bin 0 -> 1617 bytes .../input/device_number_7.png | Bin 0 -> 1510 bytes .../input/device_number_8.png | Bin 0 -> 1618 bytes .../input/device_number_9.png | Bin 0 -> 1628 bytes .../Panel Attack Modern/input/error.png | Bin 0 -> 8364 bytes .../Panel Attack Modern/input/keyboard.png | Bin 0 -> 1288 bytes .../Panel Attack Modern/input/mouse.png | Bin 0 -> 1639 bytes .../Panel Attack Modern/input/touch.png | Bin 0 -> 1511 bytes client/src/BattleRoom.lua | 185 +++-- client/src/ChallengeMode.lua | 2 +- client/src/Game.lua | 32 +- client/src/Player.lua | 7 + client/src/config.lua | 11 +- client/src/graphics/InputPromptRenderer.lua | 106 +++ client/src/input/InputConfiguration.lua | 442 +++++++++++ client/src/input/JoystickProvider.lua | 6 + client/src/inputManager.lua | 456 ++++++++++- client/src/localization.lua | 42 +- client/src/mods/Theme.lua | 96 +++ client/src/save.lua | 32 - client/src/scenes/CharacterSelect.lua | 76 +- client/src/scenes/CharacterSelect2p.lua | 4 +- .../src/scenes/CharacterSelectChallenge.lua | 4 +- client/src/scenes/CharacterSelectVsSelf.lua | 4 +- client/src/scenes/DiscordCommunitySetup.lua | 127 ++++ client/src/scenes/EndlessMenu.lua | 3 + client/src/scenes/InputConfigMenu.lua | 370 ++++++--- client/src/scenes/LanguageSelectSetup.lua | 78 ++ client/src/scenes/MainMenu.lua | 4 +- client/src/scenes/OptionsMenu.lua | 28 +- client/src/scenes/PuzzleMenu.lua | 39 +- client/src/scenes/Scene.lua | 5 + client/src/scenes/SceneCoordinator.lua | 110 +++ client/src/scenes/StartUp.lua | 13 +- client/src/scenes/TimeAttackMenu.lua | 3 + client/src/scenes/TitleScreen.lua | 3 +- .../scenes/components/InputDeviceOverlay.lua | 578 ++++++++++++++ .../components/PlayerInputDeviceSlot.lua | 252 +++++++ client/src/ui/ChangeInputButton.lua | 207 +++++ client/src/ui/DiscreteImageSlider.lua | 304 ++++++++ client/src/ui/InputConfigSlider.lua | 164 ++++ client/src/ui/KeyBindingMenuItem.lua | 125 +++ client/src/ui/Label.lua | 11 +- client/src/ui/Menu.lua | 60 +- client/src/ui/MenuItem.lua | 121 ++- client/src/ui/OverlayContainer.lua | 2 +- client/src/ui/SliderMenuItem.lua | 91 +++ client/src/ui/StackPanel.lua | 4 +- client/src/ui/UIElement.lua | 59 +- client/src/ui/init.lua | 5 + client/src/ui/touchHandler.lua | 8 + client/tests/DiscreteImageSliderTests.lua | 387 ++++++++++ client/tests/InputConfigurationTests.lua | 714 ++++++++++++++++++ common/lib/class.lua | 8 +- common/lib/joystickManager.lua | 33 +- docs/InputDeviceSelection.md | 26 + main.lua | 12 + testLauncher.lua | 2 + 81 files changed, 5128 insertions(+), 372 deletions(-) create mode 100644 client/assets/themes/Panel Attack Modern/discord_logo.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_add.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_gamecube.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_generic.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_n64.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation1.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation2.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation3.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation4.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_playstation5.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_snes.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_xbox360.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_xboxone.png create mode 100644 client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_0.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_1.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_2.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_3.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_4.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_5.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_6.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_7.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_8.png create mode 100644 client/assets/themes/Panel Attack Modern/input/device_number_9.png create mode 100644 client/assets/themes/Panel Attack Modern/input/error.png create mode 100644 client/assets/themes/Panel Attack Modern/input/keyboard.png create mode 100644 client/assets/themes/Panel Attack Modern/input/mouse.png create mode 100644 client/assets/themes/Panel Attack Modern/input/touch.png create mode 100644 client/src/graphics/InputPromptRenderer.lua create mode 100644 client/src/input/InputConfiguration.lua create mode 100644 client/src/input/JoystickProvider.lua create mode 100644 client/src/scenes/DiscordCommunitySetup.lua create mode 100644 client/src/scenes/LanguageSelectSetup.lua create mode 100644 client/src/scenes/SceneCoordinator.lua create mode 100644 client/src/scenes/components/InputDeviceOverlay.lua create mode 100644 client/src/scenes/components/PlayerInputDeviceSlot.lua create mode 100644 client/src/ui/ChangeInputButton.lua create mode 100644 client/src/ui/DiscreteImageSlider.lua create mode 100644 client/src/ui/InputConfigSlider.lua create mode 100644 client/src/ui/KeyBindingMenuItem.lua create mode 100644 client/src/ui/SliderMenuItem.lua create mode 100644 client/tests/DiscreteImageSliderTests.lua create mode 100644 client/tests/InputConfigurationTests.lua create mode 100644 docs/InputDeviceSelection.md diff --git a/COPYING-ASSETS b/COPYING-ASSETS index 726fd0e30..0d47177e5 100644 --- a/COPYING-ASSETS +++ b/COPYING-ASSETS @@ -61,6 +61,9 @@ CC BY-SA 4.0 CC BY-NC-SA 4.0 To view a copy of this license, visit http://creativecommons.org/licenses/by-nc-sa/4.0/ +CC0 1.0 + To view a copy of this license, visit http://creativecommons.org/publicdomain/zero/1.0/ + SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 PREAMBLE @@ -230,6 +233,17 @@ themes/Panel Attack Modern/sfx/thud_# The conclusion is that we cannot obtain new licenses by fault of the licensor but the already acquired license is valid. Accordingly these assets should be replaced as soon as convenient or once the licensor becomes available again, maintainers shall obtain additional licenses, whichever is first. +themes/Panel Attack Modern/input/ + Copyright (C) Kenney + License: CC0 1.0 + https://kenney.nl/assets/input-prompts + +themes/Panel Attack Modern/input/controller_snes +themes/Panel Attack Modern/input/controller_n64 +themes/Panel Attack Modern/input/error + Copyright (C) 2025 JamBox + License: CC0 1.0 + Characters ========== diff --git a/client/assets/localization.csv b/client/assets/localization.csv index 87a4baca6..d83032e29 100644 --- a/client/assets/localization.csv +++ b/client/assets/localization.csv @@ -637,3 +637,28 @@ mod_manage_music,Label for music in mod management,Music,Musique,Música,音楽, mod_manage_submods,Label for submods in mod management,Sub Mods,Sous-mods,Submods,サブMOD,Submods,Submods,Submod,ซับม็อด mod_manage_enabled,Label for enabled in mod management,Enabled,Activé,Ativado,有効,Activado,Aktiviert,Abilitato,เปิดใช้งาน mm_2_time,,2P time attack,2J contre la montre,2J contra o tempo,2P スコアアタック,2J Contrareloj,2P Time Attack,2P a tempo,2P time attack +discord_welcome_title,Title for Discord community welcome screen,Welcome to Panel Attack!,Bienvenue dans Panel Attack!,Bem-vindo ao Panel Attack!,パネルアタックへようこそ!,¡Bienvenido a Panel Attack!,Willkommen bei Panel Attack!,Benvenuti in Panel Attack!,ยินดีต้อนรับสู่ Panel Attack! +discord_message_line1,First line of Discord welcome message,"Join our Discord to meet players, share ideas, and talk all things Panel Attack.","Rejoignez notre Discord pour rencontrer des joueurs, partager vos idées et discuter de tout ce qui concerne Panel Attack.","Junte-se ao nosso Discord para conhecer jogadores, compartilhar ideias e falar tudo sobre Panel Attack.","Discordに参加してプレイヤーと出会い、アイデアを共有し、パネルアタックのすべてについて語り合いましょう。","Únete a nuestro Discord para conocer jugadores, compartir ideas y hablar de todo lo relacionado con Panel Attack.","Tritt unserem Discord bei, lerne Spieler kennen, teile Ideen und sprich über alles rund um Panel Attack.","Unisciti al nostro Discord per conoscere giocatori, condividere idee e parlare di tutto ciò che riguarda Panel Attack.","เข้าร่วม Discord ของเราเพื่อพบปะผู้เล่น แชร์ไอเดีย และพูดคุยทุกเรื่องเกี่ยวกับ Panel Attack" +discord_message_line2,Second line of Discord welcome message,"Show off your mods and art, rediscover classic characters and stages, and create exciting new ones.","Présentez vos mods et vos créations, redécouvrez des personnages et des stages classiques, et créez-en de nouveaux passionnants.","Mostre seus mods e artes, redescubra personagens e fases clássicas e crie novidades empolgantes.","自分のMODやアートを披露し、クラシックなキャラクターやステージを再発見し、ワクワクする新しい作品を作りましょう。","Presume tus mods y arte, redescubre personajes y escenarios clásicos y crea otros nuevos emocionantes.","Zeig deine Mods und Kunst, entdecke klassische Charaktere und Bühnen neu und erschaffe spannende neue Kreationen.","Mostra i tuoi mod e le tue opere, riscopri personaggi e stage classici e crea nuove emozionanti creazioni.","โชว์ม็อดและงานศิลป์ของคุณ ค้นพบตัวละครและฉากคลาสสิกอีกครั้ง และสร้างสิ่งใหม่ที่น่าตื่นเต้น" +discord_message_line3,Third line of Discord welcome message,"Compete in monthly tournaments and join events for players of every skill level!","Participez à des tournois mensuels et rejoignez des événements pour tous les niveaux !","Compita em torneios mensais e participe de eventos para jogadores de todos os níveis!","毎月のトーナメントで競い合い、あらゆるスキルレベルのプレイヤー向けのイベントに参加しましょう!","Compite en torneos mensuales y únete a eventos para jugadores de todos los niveles.","Tritt in monatlichen Turnieren an und nimm an Events für Spielende aller Fähigkeitsstufen teil!","Competi nei tornei mensili e partecipa a eventi per giocatori di ogni livello di abilità!","เข้าร่วมแข่งขันในทัวร์นาเมนต์รายเดือนและกิจกรรมสำหรับผู้เล่นทุกระดับฝีมือ!" +discord_join_link,Button to join Discord server,Join Discord Server,Rejoindre le serveur Discord,Entrar no servidor Discord,Discordサーバーに参加,Unirse al servidor Discord,Discord-Server beitreten,Unisciti al server Discord,เข้าร่วมเซิร์ฟเวอร์ Discord +next_button,Text shown to continue to next screen,Next,Suivant,Próximo,次へ,Siguiente,Weiter,Avanti,ถัดไป +input_config_new_controller,Message shown when a new controller is detected and configured,"Input configurations added, please verify the button mappings, especially the Confirm, Cancel and Raise keys",Nouvelle manette détectée ! Veuillez vérifier les mappages des boutons.,Novo controlador detectado! Verifique os mapeamentos dos botões.,新しいコントローラーが検出されました!ボタンマッピングを確認してください。,¡Nuevo controlador detectado! Por favor verifique los mapeos de botones.,Neuer Controller erkannt! Bitte überprüfe die Tastenbelegung.,Nuovo controller rilevato! Verifica le mappature dei pulsanti.,ตรวจพบจอยใหม่! กรุณาตรวจสอบการตั้งค่าปุ่ม +swap1,Input configuration label for first swap button,Swap 1,Échanger 1,Trocar 1,スワップ1,Intercambiar 1,Tausch 1,Scambia 1,สลับ 1 +swap2,Input configuration label for second swap button,Swap 2,Échanger 2,Trocar 2,スワップ2,Intercambiar 2,Tausch 2,Scambia 2,สลับ 2 +raise1,Input configuration label for first raise button,Raise 1,Monter 1,Levantar 1,上げる1,Elevar 1,Heben 1,Alza 1,เลื่อน 1 +raise2,Input configuration label for second raise button,Raise 2,Monter 2,Levantar 2,上げる2,Elevar 2,Heben 2,Alza 2,เลื่อน 2 +tauntup,Input configuration label for taunt up button,Taunt Up,Provocation Haut,Provocação Cima,挑発上,Burla Arriba,Spott Oben,Provocazione Su,เยาะเย้ยขึ้น +tauntdown,Input configuration label for taunt down button,Taunt Down,Provocation Bas,Provocação Baixo,挑発下,Burla Abajo,Spott Unten,Provocazione Giù,เยาะเย้ยลง +change_input_device,Label for changing input device,Change Input Device,Changer de périphérique d'entrée,Alterar dispositivo de entrada,入力デバイスを変更,Cambiar dispositivo de entrada,Eingabegerät ändern,Cambia dispositivo di input,เปลี่ยนอุปกรณ์ควบคุม +press_button_device,Prompt to press a button on desired input device,Press a button on the device you want to use,Appuyez sur un bouton du périphérique que vous souhaitez utiliser,Pressione um botão no dispositivo que deseja usar,使用したいデバイスのボタンを押してください,Presiona un botón en el dispositivo que quieres usar,Drücke eine Taste auf dem Gerät\, das du verwenden möchtest,Premi un pulsante sul dispositivo che vuoi usare,กดปุ่มบนอุปกรณ์ที่คุณต้องการใช้ +or_touch_player_slot,Prompt to touch player slot for touch input,or touch the player slot if you want to use touch,ou touchez l'emplacement du joueur si vous souhaitez utiliser le tactile,ou toque no espaço do jogador se quiser usar toque,またはタッチを使用する場合はプレイヤースロットをタッチしてください,o toca el espacio del jugador si quieres usar táctil,oder berühre das Spielerfeld\, wenn du Touch verwenden möchtest,o tocca lo slot del giocatore se vuoi usare il touch,หรือแตะช่องผู้เล่นหากคุณต้องการใช้ระบบสัมผัส +more_players_than_configs,Error message when there are more local players than input configurations,"There are more local players than input configurations configured. +Please configure enough input configurations and try again","Il y a plus de joueurs locaux que de configurations d'entrée configurées. +Veuillez configurer suffisamment de configurations d'entrée et réessayer.","Há mais jogadores locais do que configurações de entrada configuradas. +Configure configurações de entrada suficientes e tente novamente.","ローカルプレイヤーの数がコンフィグ数より多い。 +十分なインプットコンフィグを設定してもう一度試してください。","Hay más jugadores locales que configuraciones de entrada configuradas. +Configure suficientes configuraciones de entrada e intente de nuevo.","Es gibt mehr lokale Spieler als Eingabekonfigurationen konfiguriert. +Bitte konfigurieren Sie genügend Eingabekonfigurationen und versuchen Sie es erneut.","Ci sono più giocatori locali che configurazioni di input configurate. +Si prega di configurare sufficienti configurazioni di input e riprovare.","มีผู้เล่นท้องถิ่นมากกว่าการกำหนดค่า input +โปรดกำหนดค่า input เพียงพอและลองอีกครั้ง" \ No newline at end of file diff --git a/client/assets/themes/Panel Attack Modern/discord_logo.png b/client/assets/themes/Panel Attack Modern/discord_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..23bfafec245182051068e1f1c712772dcf85aceb GIT binary patch literal 206115 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

#k1XA}pR<$bg)3V}oU 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_add.png b/client/assets/themes/Panel Attack Modern/input/controller_add.png new file mode 100644 index 0000000000000000000000000000000000000000..dd259c2e075b9ae6713285ad04a16cfb3b29394d GIT binary patch literal 1323 zcmb7EL5LJb6s^@P!oVJkLYABgH1?u9T|F}{?lv>4N2dC^*sR(^mpxI2NukhmKz9d9)bO<2>q+iUm-NG5PGwQ zkbN7WFM_+jd|QNrJ!-kRg3$Vnt$A&=&z-HJi5J%n+y`r)syAx;UT3C0&)_?qpZ|ib zuT*VSIaG0*g&Y=?xAJq@oR!TLTDf^UXW51PG0U&A8S`HlcjI-;}j@qm}lZuTHx zW}HC@k4K7n!N+4BJ$d{Xp}kw8wW?Md=WQlElRC1^%^1S;RXiC7l=Zp7ZQd1r$$0zg zZw3}l$+$4ruo^*y_r%g>$X7O-EwpfXbuae47((^u&_X^ zJjv&?;E|0sd`07|9~}aO3{EhrJYt~;l#o8wn6xbis$>`$Jc4FyjGV|K);TcYNk2+b zq+l2q8MCQUp=b{@R+D;s@oVaxmT{5nas==?!WE4B5l=FQJd%Tu@ibioo$Qux6cLg3 zMmD14xM>lCEJ8CxSN}mbpWdOf-C-~Y-J~st5$|hsMulYrzt~irUb6= z?-L`nTruui*8^tam=rhiERvlt8Z{K%o{F|X%dXl1sUT>RAjUbKJzOo`~>JhJ6g{h%pBbe20fv^*FBR`2rzvB~=oax0S;yJsR?|EBYJ* zySVNv9{S)eed^-#sbG*o;&s7?m|_N1VA%oS?PRlX7zu^-C;2~=Ohw#5+K7pSv@uJ& zh@Ec1M8Is2@q~dfo>A_@V1_V(Pj3|t>z_frT5c`<$=0SP?_IVYzB_T$`fT#QO8JlA m;PmVd#iOTw{C#G6@)SZ(wjNyC|4;opJ@WNhv-;rl+LiY=eW98F literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_gamecube.png b/client/assets/themes/Panel Attack Modern/input/controller_gamecube.png new file mode 100644 index 0000000000000000000000000000000000000000..84cf92b667346c7e3c68ffdac1cc31d4bcad6717 GIT binary patch literal 1917 zcmb7_c~BEq9LG1L1>_X1XAxcniKUPYSWzMgM}mR^9)!yS*=!bANp|CA34vCO(uz3B z;Z?PY44^$cKs>0&6cj}3QBVg3tq7hVBI0;+^(8P^XZ)k?%wYD;Wp>O=ZogNw{Vy;FGLnNOC8exiC68Fbr^IX&MpWGM{4iT zj-9r?D=U`nM(5^~?2C-+LlEvcQWnidhlU7oTE)i{w4C7URBGsiAU?i2HHIeD(!e6C_vwBST7PaE4T~BuxPW6O+@) ztcb_60E1{8A0p!nfw82PfJ&%nN@EN$00Y1W$AuWnGNe4&zzV}ugm?}?QADZ_`jIJ! zH+U#XoS-y>F)~AF=wt>b2Eu7zCLfEZPL@L_1+BtJ3QZ#z8uj1{P#!9V`Jjdb{*%!E zPg3zdNp&)#G-js2Q9?&Z00gLBohh zh7a&E_EXGA{ha2b&$otPKo42Q!0`58-*y}5}d(LK+$9KAeV9*#}jUA(Qu!R|y= z&#JS#h6xMzWGwe-&ps`7{58P2-6rR_S0#g`mN;b`>0EM0>azXs*E!0^-PYea>Yv7} z%v%5WP<7VGxWZYdhR2LrYsVbRi_!L#w_h_~W4pf}RCcbOEjs@u)2flz@os1N-jc%7 zc8MpsqIdbEw7#wD7tgjiU!AZoynwC>*OX=NFxlHwS%*xMTGov_`SGv54d%=RpOZ=D6~HNJnP4r~ACf*CVC9i%R>bmRl{0M@P7ufLC9( z^rY)=Hk;LZWoz_x|Lk-xu*3Sq7bG?R5HK!tze?xiD7Fe$Nh8-E1tgjt#$a1 zT8FF)^2NP92V++p@Jo1NDmGV}A-mhUGP}ocYL+>52kO-$s!EV%=IQzAT?L?ei_eXR zFHNd*qvkZsTxSc=k~(a)T|tTb;b*$ch=2p8o7e5noAb&xLpx2{d5!DfHzwihddhsH ze;Vg*(&-|qwFnVg=1(p>hN5Hd;6CkZk09ADN_Z z(jG5pT~o7gRiLT*asCCE~yPZ`%y)zXrpJ>~HI;Y+`m6kDb?+&%Jpldsz?FY=k$ zIaitf`Pkl==T;rx+|G=@%B@A}9$oxn6K$n;iJvP^zfs+#o`CR2IX@KYzcN9pHr(?+ zX)TS@A`<)B<=nX|oZ?Zpp4?WiN)93|cgT8eS-k0-ork#XNo0Pl;Cju3y0)Xg$=})B zFW*)fn@{~*&?sKBeQG+l=}vaY(;Am)OWtjj8jkhg>2|Dle8i0IeseP~&hTps4h)kX I42WC(FIDvrivR!s literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_generic.png b/client/assets/themes/Panel Attack Modern/input/controller_generic.png new file mode 100644 index 0000000000000000000000000000000000000000..fec7494395169dc1684e7b63c2b178ca58bab80f GIT binary patch literal 1658 zcmb7^e@qi+7{`x-0tE&W$efY+8mEU0dPhYaT}y!$0fDaaLqP+0+`Z6~_O9MtX+bB5 zhRqD8W^OT?4(D8Emgr0rwulZlMDP~^nVV5Jn2S?GgyCGoA+opErq0AawoBXhectzd zKF|AnpL@E^Y|0E%&Qf9+7M7i5%tvE``y1qsejg0&dW42W=G?-JOndHQ`I(rQn3A8# z$w@T&7SY6KqAg^$E`B5+_XsK9RDgXoxPu6<*SBM(}+ce91XQgL3c zhURT5is37u%4M@7Ck)f-U3QADh7zcNRV=5&N83Aaz%n{~)#5xd&u)MgHmgp61$CxE zx~`f|W^jFqQtQ$nfDK9%aM`S!sB!6VnX5r__cDP4`G{1l!?SHv71U@lTx?|_&s#y_ zA{9yE2qG|*ntbCjF9KOQ+#*SK4M8}aPL*?!iWjPg#N^~;f>aY~bpkq(Al7mc)bIgsOEt(JJs7Q(wdtSk*V5z3JTDDn=0hMsUD%H({&v+fVzV4^%gRG-3d9=k^<_zC5q@P7^aBKHl`K2uHF0SoUoM)H3nXv`Ek+I zRO{U9T}^9S&zL_Lc%!#bQ=AZ-S4MP_q27;$+FsgQdtl{_CwIT;UEXiFxHVBV>(~A} z<+(XT$RIvLJ-xqWG^;r>HYjo-8Fu+>cl3khW6o(;7d9VkGMtRa-gRb&{{6VeNm(Zp z_dAz1#A-MAkC&KB{&=|kLe{3Mch}7$Cx}lKCq`$fm6uOP8E1MA!5BA3Rtv^4O`ge9uTvLduL8w>v#23$7mvV4msz~=vs9#KZYVGjwXFXSA z`{MRLs4oqlUD>$O@78d0PE6>Qhe1I*L)J{&{X~B#@U^7vcjwRToE{uue$;U!aNqvu zbGQ9I4Bs{q+jVcro%ssW?aljM<};M{_VmBk)*f*D+UcBtt@@DzFYQU4AH2pk^s1uw z_U6fjB}3^kbvFV=HyTSvB9ES`Piz<`2XXM*0`uxCT5DrlP1HzKU&WTcOn$@17Fdp@ z;XfZcc){e?PL5@s7n~|}T6)v!wy?&tpIm4uW6rL5vAXN#`Z>$?=~~{l?cCjV>6KHa zAFtKmM`PG7eE}BSaI+;gHK1W^M>Ljn*;zqRDvGMr>xWf4~dc S4N30*_3R9j@ksjG#(w~Bd5J#& literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_n64.png b/client/assets/themes/Panel Attack Modern/input/controller_n64.png new file mode 100644 index 0000000000000000000000000000000000000000..a722048a7a5771b18fe0292eb2ba4a7f461fe663 GIT binary patch literal 5403 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 YuV3^q^?w{F^ZX`~6H_#e9~n#j1;W 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation4.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation4.png new file mode 100644 index 0000000000000000000000000000000000000000..a18b0e75787e5ab34758e3957789e0466f3e2cda GIT binary patch literal 1805 zcmb7_dr%Ws6o(^^HUu1P2eD$+TSZm`vLO_Zm_#8#K`}!+~5%@P)p-MHD1fE5GC zLyQWoW3^aB>tL&hwJMeR0ClEdX$4z4)(4_fwIHR)R0OMHZxRZ1#y{%L?%q9j@A=L> zzq4m{lTxvGJkOiQ;c&*sM9bshXt7;fXLx^jtNbw>f|aqVsKxr&=dXCQUM|I;`$RTg zk-%w~fzq9OHmq6DH{r;(*0{v=GJg)oxrtOIu}RBfML4bFV`@5;;G1=N=)~cO7nt=J zo=LDEmC%rs1nE1~fB;f0K@vlj36|+)L^>Ir!w?BM3KgD{iHEC^1yY{aEP?ynN9J0$LIz zon`eR6g8Pld{Z!=W;AF}cz8G}5TZh1AY2h>$fj7#97q{vK>`oh8FIpaGo+p+X$shw zSSoE~B?!U;BWN5SCF2Z%v1AqjmC(_Y!5+c^13--9B8+7jGSz5fDKH%&T}n_Cku64F z@(SW@9$FG7C<9@S%n%0J$l!z{oB?L?vUqx9Ds)oQI*g>y9DaisCMn-GTOpT+2me3ItYk)C=P^cN)v>F%Lw?#8H)1hGkvDR)lEG;>jxNSes zv@f)8Ze*nxeGy}$GNdM*HH<+cwCoE|TRB8pjM7LDbD)|?!;D%Mk;-U&w%s!TIMn6? zRXS+^CYs3va2yd>4UCkUU;vv=0NF?tfy_}xAd?9I6cMbTU~L%#rUgp7$Utsa8ORAW ziNnIv6ok_tizG|{12~Kf=ro8N3ljie8b}t{zQk86prdv#Xf+~$U8_-XUQ%Pn6zm1q z73gShL1VN#2@19(1A8yjY0@toj#G7vJVIr@*1M}CrvP{qMLb?yGq~YI4<6>~UM|nt zG1z=DRT{%B=baOHT=@5NzTG?1RI_bfOIL3bbF}i$P6~0;d@4*>>Xq{Kf#xZAc*_s% z%4bL3&pS$c)#o)oYlzJ@te4s_H|B{ZE_;Q>q^fuNj(BnEEKvy{++Q z?~r$#e7#rYSC!OR!BlN{a_yx`UXp(JfbO_O7wM*)zp; zQq$Y38waNQzE(ef@a~>Djo3Hm8r%o_=}S%Jk(O(o1Gm!>^+S_S`UGecctTZ|o3)YK z{MYl-m1Ua+6N>#lhz)7!{LL)sqgD09=C4PP^v#`Uh^=a64Y0VAyi7^j)_w2uwTw)n;ADCHx zu@bkDBQ`1ZNh6l|^c4occ&=^C)rh zTy625n{wWL=OP}M_Ndm`bxtWKM6s~Mb6ilFOV{$^j)8-@4ZFRnI-3-E)w>htCL>p` zcg{V3|8l;yy5{%0-9f?a9RV)0{H6mx^keTeAMWp5??l9f$b?^gRQy_lWnih;Cv*85 vch$mES2I30#~y!DUG<~STSeH|S^eDvpJR=i_F6ZG+I}@LQ3`o&WJ>-&A?euP literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_playstation5.png b/client/assets/themes/Panel Attack Modern/input/controller_playstation5.png new file mode 100644 index 0000000000000000000000000000000000000000..1c56be1131aba9851ed96e14a269f6374c553351 GIT binary patch literal 1882 zcmb7_dr%Ws6o*4dD-RJZ;2XX5(XAA+0SZDSAcj{g0fbl{QXyr?nPasBNh@wNM!W(PFJuVRYnC1s`MtRHQcvgF53Mb!T?(p1t>c=bqoW zXLe^)M5vw3d>a;vWhV*?iiYDB<85ULpN5|OV{q_{3YP?js>3JV@#^T@C57%YndpcZ z7S+8t;9+Ibxm^3m4s`Fg6B)~USS-sHLK4TsiNpCArQ%9uR0_`3tJKhm#qtZ#tEE^v z&VUqLL68FE;m_v~K*$70te2Q4Rtxb|A}pK6W3nS8Sav$*D?kNgk}7aMKUh!5B^0Fu?w(v84}lO`Cg(>7g-jx#B|uUc zM$Jc2oleKqd2%UQfx7$p`l37!)WgFKu5i<2k&IODMrxKqf(%gGDhQ4hRDRB5-N(+m_nFg0Qg}TU&=5vk)k!SBBUzZ|1+E<@hm^| zEmIJ0^iUEQPHJ#dWE$5{S{lR6;WRLlx5ZOyQ=pTKQb`FC&Bkd8b?5R>1oemcpvDAV zOX&Y6DSMTqT1zWUnaMB|SK=z1WHc~_I|?;}o0egG)3zwuXqq*Q!81&T*-{cyi5vF= zP18ct<|bGAp>JZ0RGLtvGMZP=Kqd1A)L0I_Qc5ZWh~7N0Xm9K2XLGeSO&DD45tB;iUZ+f6@k!PMj#aO02C3dCt+=AjZ_JuOd^9Ilgc0n zmk}5&JVin{1u_X-2c!UlkpU%#$X8)p;iQIOfbk^0TLI0rdqb;90Zdv=iSw2kn?J!` zfL(!3^%gXyx)Ud1OVY6SyxC4&EEaoM6ci}Y-x!SlJAW&fZs;+%OicWcKKPY{{n7bf z@rnm^dG3cf)5S|?T5;HUtATll3ZZUI^XzQT8TLN2sq0B=p>yWA?Np!OL@%o2EKFd3elkq-z4&~ebwSA&CD{s(oEna*#L?zh`GJokx83#1yK}-Y zx22lX`fp>?1BWvEts~Fml|zO5xHI!RIAQ&F?Y!S_dA7h}6E^2L_i1@e2%wt-b934k z70bgW`i8@HuilYc7I*Ju1pB+;;|d`(SH8Evq2HrR-_IHg?l@9rnKY)jD?)e4s z%kFVYu8MPeleetO$Ja!ix-)R>PTa&FA+CAocxZ=%cCk-2=A-C**vLA!_&T#`_EvAf z_4r+!rnWFN?RR!=Sz_~*LF*OlLx_4=kFC#eVhf8sBN)i6`+Y@Y!{Z0s0mtIZ%W|a0 zc6(;>rJB&2hrd2wz9Xk~uDH;js<4-2x$Y|t9y-L~xjfiBFTc2)bJi-Mb?yqg-h8cL z|IKyHVMNv1t}aLaqit;pucwwLQx_TPtM@uv`Yke}a(izBCtN&F&n}Maxn*g% zJ@AD6Y@&OV<6#?qA!4AMm`#w*OBz-=HGJ?m-G^Hm&h|Xo^kSJsH?OC1X;T^_Px!`_ z?K(}C*yKASRo<|G4s4l)UbU#Qjr(%nru$bkR--p&WA%yxgZ%mG%Cl;p^nvA)l}?w7 zN;Z4Nr&+lggaqBUPW$+;t3w-r(@!-v#TA|7x__Ga?Cv>M^Y69SKdGyYir=q`?cM2O z73_HH&9RE6)4kfhU(#MXxbXZYzpxM_Uc7c` SmCJ79e@he`5p-%za{hlm`1!y9 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_snes.png b/client/assets/themes/Panel Attack Modern/input/controller_snes.png new file mode 100644 index 0000000000000000000000000000000000000000..da4fcf96f3b99e7cab7ce6c0626286bb206f3816 GIT binary patch literal 4415 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png b/client/assets/themes/Panel Attack Modern/input/controller_switch_pro.png new file mode 100644 index 0000000000000000000000000000000000000000..57326f23e265136deffc126f0c9ab08786eb0e62 GIT binary patch literal 1759 zcmb7_dr%Ws6o;1tgr=wn_@ateeC)JBHUY~(ObD1n!9p4VjbJToc5lc^vKu!W6R8~u z=!lP2p~|2gsurzEtKz7gPSqmV4y{@r)YoW>%&4`Fqu`?=zUa-PSZDmB?(FW}bN8O_ z-19qoW>*;W(+5goBp8Mb%*xc}qOsh2g^AJqL1%R@8j=jz`MT-$?7r7LPMujvBKHU( zSD%MrP^&L zsZuBa6D(m^72b8Q83zol!j13d$a3r&XkjwTc$inF&!@^tsZ<(Qr%9A<1p?ThKmxbT z$~qKo74G9I(6e`%z=3axP^!YSY-9;kD0FVdoX>GqAWxFYWH^HGv{{j>ofbeKONCnm z!LA?(m&+w}B}qBHgpjAErV_G5A~7)mtw?YzVg=Hjz&geuf&};&TIis7#x5`%3%pFy z#5n~Oj!QrQO;P=1iif1YEQCl28^=0)A^b1^loX{P1%YQwPA^MO+F;sykY(W_CGm<^ z2=Dczl$GI>=zyVHc6XwF75EKvq|j*v@b1Wu%(d|4Hco zCrQ6Z((dG~zRYNffL3UOtl&T~5*Ys`B}iI*{6D$kTy1jh?#hE;e8>Me)DO0p#?+?_x}GwHMnc$$W@FYK& zA~**YGSCG`K%vNhGb7}SF!AW6gAsuDOMJZo`fK-+RsjKgTJ?+biW>W;P%ogaAo_a? z8U5V}S=5p|>b+#q&~q3ja%X8X^4*tiZ_KJ~m4#~;*p+l^kJ`3;KupxAwKFPo>Sy!Z zBP9jztf)KMA`3qnIK$|!tIOaqPG4VpYiCDE{r2r|*KUj`iip*Jk{79do-%AmLwxuz z!{w`hWG5kcAdXMJ-uty^@_>0W<0^WMXHzmn!&E`4dy9*r3$UI^joH?q3}gA#*{wS> zN84faHFD3)#|NtA_w73xF4Q;Y+(;4KbHv?!YH$4hiu3PCd=lH^`K>#4itB08L_G3H zVWrS~Qgc#Pw7c%ssYtU(GezD(_Z3CXKDSmr{2R5IUNXmcXGCmcOH%Oa9p$!%7uv>| zD-TV$IAhlu@w&XwN%t}P=g$VWjoiCsR?F>)pHeNC=XVyIF-PBBkv4|g z*H?C8l;q3rp8R>YzvxvaAl0{behLU2h4S=2qypL{-(u z__ndv6UPtIg+!#+$3z#N*{y9qE30ZWc;XvdiNo8wH$``DJ8U@iXuZc^3BL20=y&79 z+zmqxHF+|pnSwt`KE1bN-?l-?6$LkSS0C^nm@lWNG`+ETh&y8(zItfv{-EOHV?EO% z){B~=DykAj@4r#gMCZPFL>~r%I={+mkHiLS9xb}2QXRTqxAu>Q9mC6Khs70dso7;b z(jNK~HhF6Lh*b?AVqa*|H&i7)EL?WJF!9O8=U{8-4>=be{~FpSZVkG*uJY3TNgH*3mQJtTJ+-*vAHf&0 Ap#T5? literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_xbox360.png b/client/assets/themes/Panel Attack Modern/input/controller_xbox360.png new file mode 100644 index 0000000000000000000000000000000000000000..cf192394dc5f7337094685813abcb11854e023bf GIT binary patch literal 1868 zcmb7_dr%Ws6o&(Wh6X`}0kw)=lrhzTYy!oR6aw<51>{`>rn+o4VVh((Y&ImIASmFV zwNyc+_^8EER20zh(Gish3dppGRGg}{K#Nw2qKJw_f!+iLb;dvH&g|Yjd++(qJ->6$ zY*AEXhz<7>E{nyo2@4I3fn%=mwqnEQv+ivpaPWug^z#Wb?y3Q6uQq~Vj^Q% zs~1r9(;xeGU8_NUT)(n7@1S;&#bP%TafwV~M7Ri}lzdb|rQ&>@QU#q@EN@?(3dQ6& z15$ArLHh6>*VXd?A@SjTyU2vFh-3Uo>Z zsTS#ccqXn0z8gOyJYZVF$bEQWN>ql6L_s=28b?tIAoSo11Uv|#B~npLVDKaYT0Xoq zhEa(SM61>EwH|zmmLWnfFE2#kj<~zK!4+=mOp-x$Zlrny@oB|mHt_3K-U}Qi^A@WTaS2(FA7+^e!A6CGL+P$OIqyQ$Zro?$qjVC_A zUVvSJO!XEtrn(a+VN24m_m-JEbh23H_rd~K#OZE7O6o5^0H!&e&)<@$8?W4ZL)7=&q_V!wh8tkkr%=7%f4AY?E`WX6b&qm2y$=%%{cd2aK#o``LYw4wW zX_-JOVSI^136*GTAWN z%IGiKm$}`l9O@c;Sf!hDW39R0rjcf|?gD*EA8V1tS=|!ySKH+cvCa<`SR8wKceHI_ zPIc#)Us3*e+0NN2L+w8gQ#AT?(V*^7bpP}9v)NTMk5}5AP8e?<>`2Nl_P^cHGIK{r%=jkZ zY3C7Zi_lii+(Xxjg!!A*NqyHB%37Z5$DVR}RX>Ft-|CthGAlmxYUc*_DVMUNFVYK2 zzrN7ZQQl+`|LSM|)@sM?-`}uxe99?2k`nZc5%@qRqLm;SBQ>=j)o1f2$fQ{F8Q9 zs*|I`oTD|d4-`Rn%W~@3_b=F97SA_C*y z96ZG5v?R~F*qlcrW`+|^%yPl=J2jbWMtbD6tE11@*Ueovf5XxYkB00`_eXQCbg~b- zu$!Ep*pyilhNm_Aib{(cQ?@1U9Bmv!t#?Se3ct%Xc&|-Znk@0yzQtu`TBRcg9eC9? zUIHE@EFGTp+A{S?`yb(Fay}X`?_XcBKf7!E?Q;rV)(_g;RE&pvVs4L(f2y#c$iQm< HH5>i|?~ek| literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_xboxone.png b/client/assets/themes/Panel Attack Modern/input/controller_xboxone.png new file mode 100644 index 0000000000000000000000000000000000000000..8940b55bb3aef452294c9a93fac858d86199abc6 GIT binary patch literal 1814 zcmb7_drTBp6o-dpZDAFqJX%l-x1x@wu)`zLvV{fQMcD|HmnkZ>JIq{Q%I?fEI|~a^ zMWwBeS~N!4=l!@)kd_^6@^$8+}@ofSQG!Klg!*XbMN`i zJ->5LW_@D9LKoRo8HQmlnpkx*8jEeWlOuW#-Q4mN4IznfsWA%;an?7yE_Q7ok-Mvq zoRETj(MMP=`bJJU$W^X7UixW&!+j%$Ii6)w(}cA6I0eP&?fqynHJ}};(g4s-LArDgu6H=+d94d^)BbBmnvjPG1P#}R> zuVal0a|AAO73kgenZSX#M97K2HF`1&DikqhCNq`ebRZ~19vFxt2v27!lGO{w5Xg$a zvjxGRAc*|@e0hF|oa3{Ips=toA~2W;4h}#o0*nQ$K$-(sqaPy3fXGloBgHcYf#FzS zW0D!1Nr=F485l!T)Hs>qAt^9<5GkSOSfd!i4g(;Zq7*5(q8OVv|^d!R)g^=fnAbB8x6H1g1VN2jY z3H|>h>6b|wOuSCajHU>vgL=pcMie86K$@XV)09Ho7RTF6yM`$+S2XOFju}tfwjX4Q z3&qWitqdn##n`Akla(zPUqYjG!Yfc)ITSjQ&5FRy0W{1cO*#QrsyIV|=;;saYRf@t zHe&?&9G?TwI3_R+Oe_s~AY?U)9HKZ8| z6`o@eoCA3b%m*Z(P-MVmBIL_3{%F$32*7p{->iW4+P$LHm;j&EJR?`pj)X?1I& z4wTfrNb74LT#2f}#@m)N?=+V?e%R|7w^(A?+1dI}>zeqP&eve^;!Zmad<5GG(s+;||GXXIE)KNn}xKjp3raviw$u)BeQt*txZvd!;i! znC0SEmF?L6 zWzTQA1MOe6|83QtlP11@um5!3)zF{sXqQ``_Fmc zcB&d2c>7S$&4TE}4Y@%-rb%T==iM>KoVHmf?wYb~a=YTfBjIe#f~sG9{bME1%06FS zKHBEK$j@P;pH_K$^NEVtw~|us<&B)S?5*}~yJozyS-ZlmerL;!bJ|T<^-4+icP6}( zKL5=?v#T!Pc1u(4)`!w9+a(6?!uIyAt!-N8qS<7 zBW2GdTd0KJsTNd^t}ojosCsgGr?l*Q zYu=@Y`=kS17uxb`I-&L&~0nkJ5KXZ2ufhOoF<8zIM&O DuQA^K literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png b/client/assets/themes/Panel Attack Modern/input/controller_xboxseries.png new file mode 100644 index 0000000000000000000000000000000000000000..40eeba3de1f4e03e47911df2b0d385cdf0efa354 GIT binary patch literal 1842 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_1.png b/client/assets/themes/Panel Attack Modern/input/device_number_1.png new file mode 100644 index 0000000000000000000000000000000000000000..779263038380b5f44f10cc31d852e976f214572e GIT binary patch literal 1296 zcmb7@U1$_n6vxM`O<{H0ND=8Oc%csiZL)VZX>E7h-I(s?V==qLWYs{G&Fr1KyO+$& z9cS*kn?-1}SZXM=eW;W?6d$DZBPf0Hp-60{^uq^15nBpv-h{Rq@TnARJTtp&3w}FWOh^~+>B^CFVaZew0&;8 zziw+}nhI2;Wo9MhB++@B1_>nZ?DDl}9# z4a-&Q3EAhW@a}!aGV)!-bVAOUTA8ZqV4aonoEs?G7f}=$AdX&A$J0YC1Xu~VB807C zT&vY0wY~^<$~YQ}#jw(gdwYApqsN`GgjVmd+}D5*L_Q-;UE(lXFm54_sTFxuBxE^= zT4+MrWa3awu%i@|FuCRWA(}9R;)JN05DqI=JyuRLX>u>MEIJd%>udquJ7F+FEtmR{ z9qRI`LufOc3ze)F&#o5Xkj_nwS-3$Rj-wF;%Qy-3V6T8D3VQY;_0=Nns$=+-=>$`Q zn$!|5#E4?h43gG~>ZipW&unTqL67)G(=u4Qa4#P)eMdjJmS-HV#duVQl`Fzsg$@{E z4b*FgYG_tDA=i6!TGFb9kdrBH&-f?1QB&;*%2$|+YTTJdu(kx&QPt9^gG7a*RI7_9 z)ode5r4$4rqWu>1)^Rlhjrk&@w68KsQ=JiLJhuSO(NRWgNJ9i7BVGdJYM5@=beTZj zpZI(YZ1!%ARxJU1t=i(O)8pnRm<5;$+@38k+S5rb7)b|aZ&%0GJ21IdGUbPZ{@PlnHqEPOgQKR3VS*4O30(A3F=u7PUtgO9H%li%H&`)jdtaQW{?;%0KMey%?KWdUeOOua>0JL+NttmB0;YdC!@b&U7|* z?g@$HZ1`MYV@KbHH$soXu?~uZS&b(;d5_iW~B`sD_cHVc<28&f7*MxKlI=H t!7o4jr@M3hVd!@D@af2%w#wj??88owpSp`(X@1oGH8I7K$)0D%k}Opy|lfncUM{( zmonoL-Ii3kEN(L!{-Nl82qbQc3k!8|g3Dt1hm09rQiel@i*`X1*9mrSuMLchKjtNO z@B7^QzMtoLzRx{(q{UR9E8irSNF=$&216?vdsFw5+30zHsxN_t&Xy)iUA?CXO&{w@ zCX@aB{gFsSp-|vB9*@VP(WupGHJi<`SS%O}vMd`4g@%TP^m={4#z(&*dY;f~YLmqE z(O4*`i1b^FSbWGV>$~uiM3OyDTRMb}<|Ylvx>W?l+Mvqs_J|>A^?naQI-vk;u#0AN zSbQXc0h-cbyDOX3&7NB5pc?`lYzvqyWWY&QQJB73uJvmWzzqcg_}wms*Z6gq$km{E z>NSo5af{&8VMaI61vQ#FKW(?LtP7M?sMKlK_E+qIRwF@!Ev9@r}9;( zSgs2%tE#HP)#Z43xf1PA@;!_|_>~M_f(UXTG7OL>Ioc!8ECW(Z!p3?99frw422GMn zWRin~KzBo=gqvk}(M1{tfR-dRgdlLV&6{GG2sf;5gbV|FwD>AZ2%kFPqDjc`Q1r|} zp7nAhOuOSzCad}Lcx~tq#kvWa!2^(E@iLVf$M9;D51vZkUkUyHB&p>jJzmZwW=4@X zbU`;{1RnV)!;xkvXo}Q`VX<7wOl#N%_lri_a?wkPr{Y1TxKRu)vr~(&#H6Sk-Q^JY zWoV5{SOHCyL*pWtE*<7qQqWF#T>@5J%X)gmlRH6LZ56OMXdd`j&I!<%5tssAhJqXr z91zrIst9V+WdyagYJfxp?F_0d#}h8lB8m(QqRPMkDVjuuXBh-%K{pM3fB+=&3|Kot zF1y)@7I|6#sW0*I3Yf0l3av5%h*~X)vr3KAuTU?buE3Xi3mHq@2^rLq9O}Invo@lZ zmL4`5YApWicdlLOKB&%D=J+tWL4R7BoX&A4s_`nJAw z{s~8k?1OWEWF0B|-IgHo{R+e4<@5FH)Po9sGHasb;@^gcg^98+#@kP<{iQTi^kE=* zuKQT{WoiD;!yrp`rbGJLaIZETeD;Bam0nsXz5BwNnlbJAGpA-1vyunzYYWD2&lwwv z@6D;g)^(=31Xpc*cYxT!n(i&kUrM|k$(Ao}v38ah*{5%A4V%A&pBxVVx#77Rjz_y? z-yT0YIKxM?N2Spp6DM<+i?NbDx{=K}Yr@-~?#*hskla31l}O6M_4`NnN#fArnw3YiZyu5%0_rkc1ERCNp== zoqK=hod20K2Mdf@A@T^hOePD-$u<ArKQE^^WiwIP$*_+XWeeM$z&=kD`Q!<Ci%F*K)9^)@UYctH>k@)9d6Kml^?VP#}QIW@UJ_ON&Wd zHF_48aSTXXgeonTV8Zws<$kJjKL9nZF-05^G zory}8tHk4zl9F&$0-lf%hjzsAHH<*G;ut;>5#&H(7$8q_v|XTC28c|eoOK9V43mQ; zG)XR#Ne&VMy$>QKY%Ie|F8nY6G$g4e1c9T=9U{v}*q|;KG7PNI;A^}>c=3dlCLzN^ z$ukFe*1?g`?~X^AtmV({C`X4V)<)0_UJE%Ek5{U24A-H2a4~`ZB=rB2q*ju&J2Hn&G6>FseKd3e0+7fv zU@H)E#Z4@F$*rq)E$gzLx;S^CK?uEKq)|FIh%Rym`ZS{k{Gv|Yg&oyN_EMenu83&;Mv*i$q8 z+pBd*5MB7Kr*qG;{o4!^;e+EP{k>jIzJC5q_wd~IX-o9)mz5ZlJN<=b0p~Q)j%-$+KGpTo^1YX;dt@Y7ryiC>DiI}#Yf4>fZA{&4O0af zuO}THsZYM6c>eJI+>}qUYd#H%XgqfN-Jr~!n;K)y>(1|ac>Tt^8`^&%Hnj({uN+dP zY(I0p^PlIMf)>o01@G6FNB3(lhfmpV)+utLe+yeo=Z(o}4-5oW3 dFaSwy{cBy(@Uy+fGaU+1_?!%*p*?MH!@ndZLLC4A literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_4.png b/client/assets/themes/Panel Attack Modern/input/device_number_4.png new file mode 100644 index 0000000000000000000000000000000000000000..cc431d953242009a05bba3cd551ac3513437ad1e GIT binary patch literal 1338 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 D58zK! literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_7.png b/client/assets/themes/Panel Attack Modern/input/device_number_7.png new file mode 100644 index 0000000000000000000000000000000000000000..17e3ca0983b1a196d0c890024f9d9546bbfe708e GIT binary patch literal 1510 zcmb7^e@q)?7{?22M2pB|Gn(l6sF^w{hPh}Qov2J*mM⪼r=PgriSQBVhoJLbY!w+nt<LkaPw(nl zUS2LLDk?24O{deTR4N*cB7~Znnmis)EEbE$6r`y%`hI)!cqM6~47n;LjKo8002nQIp3#z~ieY|8R z(q|F`;5j?-)ZPY5L%<3B{K1F}TO*A$8|h-L9O0-m+QJkDh)`ufSQI3M3fl>tOX0cp znk0a}MeVW^ZjtdpimD0ooisuMDBEMUSO^S}xlXFZbs&qtmYwjcYJeiiP$*;$?J*VmY19G4wKR=sZ7`;DLXL12y})EDy+;0R31q{V=`V8RP6*|1X(o8u8>(7 zGAiE#u@WMZ6x~Gz27rxaDMnRg-W$|djf@B@>!2jTUK_d262@yM1fGSG0(H+aR8UZ6 zVaA<;Gg;4HAn3)1I3zN>L`I;D$TG8qB*;pf52+<^M?(KUNp3aCKu{L+%s7^W0u-U7 zD%eLEi8aGPb1bEYg=EdlXxIw7bt7X5{7T|lJlNDX>cM4q+Q_vSjVkj#zpAW4s|9rp zR4WH1Fp|$sgiRdmWP*Z9R60?hS3kKMWYjhT+RrN>gyb%O$E?5{2ud83f$E3AnXMvl zX37YhP7A;yf+r&$-TccH009~sUan`AE<`wP*+!f?XZ((DlJE4SIQpUZvFZaPK218DZ+f_}6 zr~mr(2l+hMRC9vB{QvY#oEa~C zY5M2eV~gU}=jVhUXHze~zwgmsIvzOCzU4m4_RSq_ma1*X!~LhLHvmJZ$T@zqC&}Phqu^*tpY;GeEretPJI({tfr!awxp-YY8woF|!hRFyxeD6v?68>gE(m?k{IY)*EvBM;W7bxCKDvFXYhhu* z=ku{F8xaxV@p#;BH;&_4tu`hmrmd~5tgNi7t7~|8*z5Hc78W)i3iBcQ^FpzqMCSIe zc#L7%~0X8*`<Tu!3eZ zn6KA^0h-caJKii%7T9&LlFq5&U`dU^NY+@$6bjR($x~fw1h7GY04|%A;ngk;CUMp1 zUVMyWK-wZ$G+3^UsDNs9mWwVovaA&(B`cIl3_&=mTwScsUPPcsgH;NGU5(>Tr&Hlf zR0(PoFpG7vVHwhb~2tWL#Od4=%e2`f!PhKEvS z4)UynBcVSXk2+Z`pWR_bhbY!Y&#L%x{3H(3`{E?MjYFw-bh0;c;xW%2R_)3gO<>-n^ zfnSDZScMf((GInhU@A12E0Kcbgu^OeX*$+kEuBmNezg_ASV{B1$#NEe)A^peUz4=I{N#Wd>|H zLN2FCKreY(0OBw4=?wIHw?eB$0VJ)K#975-|06UDXe#if*+Rk6bV3G=B!^~iQ{dWh znJl0$SD#^YP5w1;nHx~92{CQiJrrE|^?{Fn|HpeK@r*8f(`dtIEk7(AJA4O3UOp}U zJFU1b^xu0m_e!r%`^~L=+1~rQS?k&T4P941KQ^*s;tm|@&l&Pk@Mo-U-aTJ1uUaq_ zn(FJ`Ki+xnk8_XCQ((OAQP1>P!8Uv&A2j*+?){4w`+kjpn+S6G?;|Y z#(~gpqh_k6Ew$ZIMK4r!$NxET#QryN=ts;QmmZg!vN_f`mA1ii;%rCl;5AlP7k}K@ za=|=y-V%_I9oTR~6|r$}__vWu*MmL^Gcd2WzWq+)%e@Z|u4^BUvd6u&b3UUZxaH>F zp6jNszKfqaTHoYZGq^Wt_kg!9wLLoTVb_bXhwCRTuRZBm`&R4v-mvhFke=J^s5aes1eG^B^}oXTq+O!tg^@t`?2^VCGN_uU&C;+T{BT=SXr%I?S6 zp|^tBD_sBS;)8uV*Oi}a>bdFRF3r3bfNU80Fo2#io4KobbD}A^Sq6P)rfJW=*cVs# literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/device_number_9.png b/client/assets/themes/Panel Attack Modern/input/device_number_9.png new file mode 100644 index 0000000000000000000000000000000000000000..6b1fbeeca9c2941ba6a8b5cc629293e82b5183a0 GIT binary patch literal 1628 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 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/mouse.png b/client/assets/themes/Panel Attack Modern/input/mouse.png new file mode 100644 index 0000000000000000000000000000000000000000..1bf4b077c2ed7df4ce803ed8803d3ff0405ef101 GIT binary patch literal 1639 zcmb7@e@qi+7{`yolD2AS1ZFf--)zQ<5qho2kQ*&%%dZHfLjAR6ljHi9p0s!8-IbQg zv_tpD)Cm4$icCV{jC10giMqN8b^e$Re=WK#G6zDo>71gO*(6igd)Ed=;vcig^}Xlr zeLm0oe82a6V7C=a&`!}}7&f7(&{~GpkbJ4*(f{M#ErV!Dw--C|3i#sT7yMnhwt+(F zI;qT7jx`MA?7h;lBC&_yN)Jz8``4~_-53^so^dRbmX#EfH0RY*POcK_1702_VOVxf zfT!qcD1k~?#jqxP;A9657^eweno(jX;Vsb36b1!Y9<({=U^SiP#B;Q=XUMNu@ z;PtQ~88G1rmqhpSV*&@t7OC2V7kQ~FNRoL0#^vBR4=|?d4F()R1gDEEv*yPTC^F%0 zN#aR@@caFGf4ZI%st99NRu*BHL(G|zhIXWhwX8%1(pd2|M9>0-VTB?sFucTYERdO0 zCFhe&IIaaTG)<3?X#r9aQv;C_UXB%&5>Xfc*)&a3k|Z#dKAB~sywJQ5vMj94CZ6*O z;pGeuLqk@CN@W3xoKK)(w48`Kd0su;yj62?ZdzA+W?;1eU0cz+y1~BqCVMB5wte@&LOcGO#Kt11oegG%}uJ5u5`x z4DBeR8qk?Dji z8c6}o-s`HA8yKd-imdY;ft&Yt-#S(gbop_4Lvw3~_nW5v;#gR6fa>k5`=`Avmi zf4ws1Snt;6EngeX#1Fms%Cnl`jdPDreDp?2&#IZ9wUWWQr2fBE#wjbG8ox{ED@^7c zapyC!TGg5PckZf8Kd4VMEll5BJ>I78I(6*mGhfb*l*aCzpSR}@N2J?2zNY7Tc!P@Z z9l3p~FER4b;pO91O}q~ccpufI+DGt4bX`Z2BSK{#Vw z=U}ETge3qhPMtjQn;q3#k6Bv32(3)JIxub9u9LORFMsH5a~(VsFKpA!TD_#WLRXj= zF;AY?v1Z!u`&yG((dR&IKAtitD@Ae=hSq zT~fx7HCgT5-`TxBefsR1o8x>5?LY5bvB2EPud46W7BrOJt5;3Uz1etiI~#T#tjO=1 zJUOxS%THfgaoJKePNN=f2@y*VrC)l0ot}H=efLWHyrJ{mmAbRWOXr4uz1sO$j*Iea*7gMz_5T9rAA-98 literal 0 HcmV?d00001 diff --git a/client/assets/themes/Panel Attack Modern/input/touch.png b/client/assets/themes/Panel Attack Modern/input/touch.png new file mode 100644 index 0000000000000000000000000000000000000000..ea1a45847a34bd79970ac70bf15cc43a2c06e7a8 GIT binary patch literal 1511 zcmb7@YfKzf6vqd!E!}`_X{*&xxG9Ze>h8`0Qo^tct-G{ry1W;K#6D(c?(R;R$1*d^ z4x|;Rlv>h4eWa)#d_-$&TBXKnmk`@(n()CON|Y*AX=AKfXegnkmWp_1b_GfJU?-Wm z_uPBW@7(i0a}Ji5ZcR2kV!$vg*;VYUL}QEgCMKZott&6wK|^-A+f%eP=$^gDXYlI| z3b`AV%F-$<{f(~X2gLJ*6FWzH28lAxpXlZmOg67`(8+U{gs1;GseR zmFEPRRPDIVCDB}4CUBr1QR?iti>LgMB#Ttm=MhB?WM*5eRvbYj#z$5t_Y>O!QiOjsbJi?kqWM!Gr3A5ZND3odzt4(hN=N*AgYojA~c~>vbb)aqL{;+I^6zAJi`wJ82^p zVl=A6`U8qQ58ccu3!qv#NRATxc3d?x&_{(h1ut-j!A9NF1fptNfG5DpAS_CC0F5z$ z84wZ}C;=q^fg@H$;E0wHI2=}hLrN=3mXuKM zZH!<2Er!LlxSX3k>d1}n;yT+v(vtG~Rv5qeY4*&?{Jwm5YO>?afk$Vzx1^s~a&nd4 z=Kp>?Q*ZxJQu)PTYnt!XKXRutK3aC|VQ?xX?s7K9{*E!QYty@iVcUgu^-D9$Kt=n? z<=t10EK7W9I{!%Ez*KM2jh8#eK1(viacM1`ImbuGT8HY_KYJ;s?jEuk;%jRkFOnFqfG*}BOhLT zFp%>1s@zF);<0LQx$~~Qc6bPLMb@r#o&8yui9}W_clT~c-t|t`g`Bj(zaHcF^9hGq zH{9HbeHyQ(+<)czaM$?g_Ot6wC4D8d8?VQo8EbpbvZ=56;>WvZj+RaJUNgQ{P`q?% za{8@1+kShcc|3Pt!{^ 0 then - -- assign the first unclaimed input configuration that is used - player:setInputMethod("controller") - logger.debug("Claiming input configuration " .. i .. " for player " .. player.playerNumber) - player:restrictInputs(inputConfiguration) - break +-- Validates that there are enough input configurations for local players and attempts to restore previous assignments +function BattleRoom:restoreInputConfigurations() + local localPlayers = self:getLocalHumanPlayers() + + if #GAME.input:getAssignableDevices() < #localPlayers then + local transition = MessageTransition(GAME.timer, 5, "more_players_than_configs") + GAME.navigationStack:popToTop(transition, function() self:shutdown() end) + return false + end + + -- Try to restore previous device assignments + for _, player in ipairs(localPlayers) do + if player.lastUsedInputConfiguration then + -- Check if the device is available (not already claimed by another player) + local deviceAvailable = true + for _, otherPlayer in ipairs(localPlayers) do + if otherPlayer ~= player and otherPlayer.inputConfiguration == player.lastUsedInputConfiguration then + deviceAvailable = false + break + end end - end - if not player.inputConfiguration and not GAME.input.mouse.claimed then - if tableUtils.length(GAME.input.mouse.isDown) > 0 or tableUtils.length(GAME.input.mouse.isPressed) > 0 then - player:setInputMethod("touch") - logger.debug("Claiming touch configuration for player " .. player.playerNumber) - player:restrictInputs(GAME.input.mouse) + + if deviceAvailable then + local success = self:claimDeviceForPlayer(player, player.lastUsedInputConfiguration) + if success then + logger.debug(string.format("BattleRoom: restored device for player %d", player.playerNumber)) + end end end - else - -- player can always go from controller to touch but not the other way around - player:setInputMethod("controller") - player:unrestrictInputs() end + + return true end --- sets up the process to get an input configuration assigned for every local player --- returns false if there are more players than input configurations -function BattleRoom:assignInputConfigurations() +-- Gets all local human players in the battle room +function BattleRoom:getLocalHumanPlayers() local localPlayers = {} - for i = 1, #self.players do - if self.players[i].isLocal and self.players[i].human then - localPlayers[#localPlayers + 1] = self.players[i] + for _, player in ipairs(self.players) do + if player.isLocal and player.human then + localPlayers[#localPlayers + 1] = player end end + return localPlayers +end + +-- Checks if a player has an assigned input device +function BattleRoom:isPlayerAssigned(player) + assert(player, "player is required") + local assigned = player.inputConfiguration ~= nil + return assigned +end - -- assert that there are enough valid input configurations actually configured - -- 1 is the baseline because you can always use touch without configuration - local validInputConfigurationCount = 1 - for _, inputConfiguration in ipairs(GAME.input.inputConfigurations) do - if inputConfiguration["Swap1"] then - validInputConfigurationCount = validInputConfigurationCount + 1 +-- Checks if all local human players have assigned input devices +function BattleRoom:areLocalPlayersAssigned() + for _, player in ipairs(self:getLocalHumanPlayers()) do + if not self:isPlayerAssigned(player) then + return false end end - if validInputConfigurationCount < #localPlayers then - local messageText = "There are more local players than input configurations configured." .. - "\nPlease configure enough input configurations and try again" - local transition = MessageTransition(GAME.timer, 5, messageText) - GAME.navigationStack:popToTop(transition, function() self:shutdown() end) - return false - else - if #localPlayers == 1 then - -- lock the inputConfiguration whenever the player readies up (and release it when they unready) - -- the ready up press guarantees that at least 1 input config has a key down - localPlayers[1]:connectSignal("wantsReadyChanged", localPlayers[1], self.updateInputConfigurationForPlayer) - elseif #localPlayers > 1 then - -- with multiple local players we need to lock immediately so they can configure - -- set a flag so this is continuously attempted in update - self.tryLockInputs = true + return true +end + +-- Gets player by player number +function BattleRoom:getPlayerByNumber(playerNumber) + for _, player in ipairs(self.players) do + if player.playerNumber == playerNumber then + return player end end + return nil +end + +-- Gets the player currently assigned to a specific input device +function BattleRoom:getPlayerAssignedToDevice(device) + assert(device, "device is required") + logger.debug(string.format("BattleRoom:getPlayerAssignedToDevice device=%s", tostring(device))) + if device.player then + return device.player + end + + for _, player in ipairs(self.players) do + if player.inputConfiguration == device then + return player + end + end + + return nil +end + +-- Claims an input device for a specific player +function BattleRoom:claimDeviceForPlayer(player, device) + assert(player, "player is required") + assert(device, "device is required") + logger.debug(string.format("BattleRoom:claimDeviceForPlayer player=%s device=%s", tostring(player.playerNumber), tostring(device))) + + if player.inputConfiguration == device then + logger.debug("BattleRoom:claimDeviceForPlayer device already assigned to player") + return true + end + + assert(not device.claimed or device.player == player, "device already claimed by another player") + + self:clearPlayerAssignment(player) + + if device.deviceType == "touch" then + player:setInputMethod("touch") + else + player:setInputMethod("controller") + end + + player:restrictInputs(device) return true end --- tries to assign unclaimed input configurations for all local players based on currently used inputs -function BattleRoom:tryAssignInputConfigurations() - if self.tryLockInputs then - for _, player in ipairs(self.players) do - if player.isLocal and player.human and not player.inputConfiguration then - BattleRoom.updateInputConfigurationForPlayer(player, true) - end +-- Clears input device assignment for a player +function BattleRoom:clearPlayerAssignment(player) + assert(player, "player is required") + logger.debug(string.format("BattleRoom:clearPlayerAssignment player=%s", tostring(player.playerNumber))) + + if player.inputConfiguration then + player:unrestrictInputs() + end + + if player.settings.inputMethod ~= "controller" then + player:setInputMethod("controller") + end +end + +-- Releases all input device assignments for local players +function BattleRoom:releaseAllLocalAssignments() + local released = false + for _, player in ipairs(self:getLocalHumanPlayers()) do + if player.inputConfiguration then + self:clearPlayerAssignment(player) + released = true end - self.tryLockInputs = tableUtils.trueForAny(self.players, - function(p) - return p.isLocal and p.human and not p.inputConfiguration - end) end + + return released end function BattleRoom:update(dt) @@ -502,7 +562,6 @@ function BattleRoom:update(dt) if self.state == BattleRoom.states.Setup then -- the setup phase of the room - self:tryAssignInputConfigurations() self:updateLoadingState() self:refreshReadyStates() if self:allReady() then diff --git a/client/src/ChallengeMode.lua b/client/src/ChallengeMode.lua index 680a3ba75..53dae68a7 100644 --- a/client/src/ChallengeMode.lua +++ b/client/src/ChallengeMode.lua @@ -33,7 +33,7 @@ local ChallengeMode = class( self.player = ChallengeModePlayer(#self.players + 1) self.player.settings.difficulty = difficulty self:addPlayer(self.player) - self:assignInputConfigurations() + self:restoreInputConfigurations() self:setStage(stageIndex or 1) end, BattleRoom diff --git a/client/src/Game.lua b/client/src/Game.lua index 22f765e5f..3f05bb2c6 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -15,7 +15,7 @@ local LevelPresets = require("common.data.LevelPresets") local class = require("common.lib.class") local logger = require("common.lib.logger") local analytics = require("client.src.analytics") -local input = require("client.src.inputManager") +local inputManager = require("client.src.inputManager") local PuzzleLibrary = require("client.src.PuzzleLibrary") local save = require("client.src.save") local fileUtils = require("client.src.FileUtils") @@ -34,7 +34,7 @@ local ModController = require("client.src.mods.ModController") local RichPresence = require("client.lib.rich_presence.RichPresence") local DebugSettings = require("client.src.debug.DebugSettings") -local Button = require("client.src.ui.Button") +local SceneCoordinator = require("client.src.scenes.SceneCoordinator") local TextButton = require("client.src.ui.TextButton") local OverlayContainer = require("client.src.ui.OverlayContainer") local DebugMenu = require("client.src.debug.DebugMenu") @@ -74,7 +74,7 @@ end local Game = class( function(self) self.scores = Scores.createFromScoreFile() - self.input = input + self.input = inputManager self.match = nil -- Match - the current match going on or nil if inbetween games self.battleRoom = nil -- BattleRoom - the current room being used for battles self.focused = true -- if the window is focused @@ -141,10 +141,10 @@ function Game:load() else logger.debug("Launching game without updater") end - local user_input_conf = save.read_key_file() - if user_input_conf then - self.input:importConfigurations(user_input_conf) - end + + inputManager:load() + + self:setupInputSignals() self.navigationStack = NavigationStack({}) self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) @@ -241,7 +241,7 @@ end function Game:setupRoutine() -- loading various assets into the game - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) detectHardwareProblems() @@ -372,6 +372,15 @@ function Game:handleResize(newWidth, newHeight) end end +function Game:onJoystickAdded(joystick) + self.input:onJoystickAdded(joystick) +end + +-- Setup signal listener for unconfigured joysticks +function Game:setupInputSignals() + self.input:connectSignal("unconfiguredJoystickAdded", SceneCoordinator, SceneCoordinator.onUnconfiguredJoystickAdded) +end + -- Called every few fractions of a second to update the game -- dt is the amount of time in seconds that has passed. function Game:update(dt) @@ -643,7 +652,7 @@ function Game:refreshCanvasAndImagesForNewScale() characters_reload_graphics() -- Reload loc to get the new font - self:setLanguage(config.language_code) + self:setLanguage(Localization:getCurrentLanguageCode()) end -- Transform from window coordinates to game coordinates @@ -670,13 +679,12 @@ function Game:setLanguage(lang_code) break end end - config.language_code = Localization.codes[Localization.lang_index] if themes[config.theme] and themes[config.theme].font and themes[config.theme].font.path then GraphicsUtil.setGlobalFont(themes[config.theme].font.path, themes[config.theme].font.size, self:newCanvasSnappedScale()) - elseif config.language_code == "JP" then + elseif lang_code == "JP" then GraphicsUtil.setGlobalFont("client/assets/fonts/jp.ttf", 14, self:newCanvasSnappedScale()) - elseif config.language_code == "TH" then + elseif lang_code == "TH" then GraphicsUtil.setGlobalFont("client/assets/fonts/th.otf", 14, self:newCanvasSnappedScale()) else GraphicsUtil.setGlobalFont(nil, 12, self:newCanvasSnappedScale()) diff --git a/client/src/Player.lua b/client/src/Player.lua index 375b5866e..4ff36d926 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -82,6 +82,7 @@ function(self, name, publicId, isLocal) self:createSignal("levelChanged") self:createSignal("levelDataChanged") self:createSignal("inputMethodChanged") + self:createSignal("inputConfigurationChanged") self:createSignal("puzzleSetChanged") self:createSignal("ratingChanged") self:createSignal("leagueChanged") @@ -96,6 +97,10 @@ function Player:reset() self:unrestrictInputs() end +function Player:isLocalHuman() + return self.isLocal and self.human +end + ---@param engineStack Stack ---@return PlayerStack function Player:createClientStack(engineStack) @@ -217,6 +222,7 @@ function Player:restrictInputs(inputConfiguration) error("Player " .. self.playerNumber .. " is trying to claim a second input configuration") end self.inputConfiguration = input:claimConfiguration(self, inputConfiguration) + self:emitSignal("inputConfigurationChanged", self.inputConfiguration) end function Player:unrestrictInputs() @@ -229,6 +235,7 @@ function Player:unrestrictInputs() self.lastUsedInputConfiguration = self.inputConfiguration input:releaseConfiguration(self, self.inputConfiguration) self.inputConfiguration = nil + self:emitSignal("inputConfigurationChanged", nil) end end diff --git a/client/src/config.lua b/client/src/config.lua index 93c69a3ff..cafb3ab77 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -9,7 +9,7 @@ require("client.src.globals") -- Default configuration values ---@class UserConfig ---@field version string ----@field language_code string +---@field language_code string? ---@field theme string ---@field panels string? ---@field character string @@ -51,12 +51,13 @@ require("client.src.globals") ---@field display integer ---@field windowX number? ---@field windowY number? +---@field discordCommunityShown boolean config = { -- The last used engine version version = consts.ENGINE_VERSION, -- Lang used for localization - language_code = "EN", + language_code = nil, -- Last selected theme, panels, character and stage theme = consts.DEFAULT_THEME_DIRECTORY, @@ -130,12 +131,15 @@ config = { display = 1, windowX = nil, windowY = nil, + discordCommunityShown = false, } -- writes to the "conf.json" file function write_conf_file() pcall( function() + local encoded = json.encode(config) + ---@cast encoded string love.filesystem.write("conf.json", json.encode(config)) end ) @@ -289,6 +293,9 @@ config = { if type(read_data.enableMenuMusic) == "boolean" then configTable.enableMenuMusic = read_data.enableMenuMusic end + if type(read_data.discordCommunityShown) == "boolean" then + configTable.discordCommunityShown = read_data.discordCommunityShown + end configTable.debug = DebugSettings.normalizeConfigValues(read_data.debug) end diff --git a/client/src/graphics/InputPromptRenderer.lua b/client/src/graphics/InputPromptRenderer.lua new file mode 100644 index 000000000..8147b3c3b --- /dev/null +++ b/client/src/graphics/InputPromptRenderer.lua @@ -0,0 +1,106 @@ +local GraphicsUtil = require("client.src.graphics.graphics_util") + +-- Rendering utility for drawing input device icons with optional numbering +local InputPromptRenderer = {} + +-- Renders an input prompt icon at the specified position +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param x number +---@param y number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +function InputPromptRenderer.renderIcon(deviceType, x, y, size, alpha, controllerImageVariant) + if not GAME.theme then + return + end + + size = size or 32 + alpha = alpha or 1 + + local icon = GAME.theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not icon then + return + end + + GraphicsUtil.setColor(1, 1, 1, alpha) + + local iconWidth = icon:getWidth() + local iconHeight = icon:getHeight() + local scale = size / math.max(iconWidth, iconHeight) + + love.graphics.draw(icon, x, y, 0, scale, scale) + + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Renders an input prompt icon centered at the specified position +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param centerX number +---@param centerY number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +function InputPromptRenderer.renderIconCentered(deviceType, centerX, centerY, size, alpha, controllerImageVariant) + if not GAME.theme then + return + end + + size = size or 32 + local icon = GAME.theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not icon then + return + end + + local iconWidth = icon:getWidth() + local iconHeight = icon:getHeight() + local scale = size / math.max(iconWidth, iconHeight) + + local scaledWidth = iconWidth * scale + local scaledHeight = iconHeight * scale + + local x = centerX - scaledWidth / 2 + local y = centerY - scaledHeight / 2 + + InputPromptRenderer.renderIcon(deviceType, x, y, size, alpha, controllerImageVariant) +end + +-- Renders an input prompt icon with a device number overlay +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param centerX number +---@param centerY number +---@param size number? icon size (default: 32) +---@param alpha number? alpha/opacity (default: 1) +---@param controllerImageVariant string? specific controller image variant key +---@param deviceNumber integer? device configuration number (1-9, nil for no number) +function InputPromptRenderer.renderIconWithNumber(deviceType, centerX, centerY, size, alpha, controllerImageVariant, deviceNumber) + if not GAME.theme then + return + end + + -- Render the main device icon + InputPromptRenderer.renderIconCentered(deviceType, centerX, centerY, size, alpha, controllerImageVariant) + + -- Render device number overlay if provided + if deviceNumber and deviceNumber > 1 and deviceNumber <= 9 then + local numberIcon = GAME.theme:getDeviceNumberIcon(deviceNumber) + if numberIcon then + GraphicsUtil.setColor(1, 1, 1, alpha) + + local numberSize = math.max(size * 0.4, 16) -- Number should be smaller but visible + local numberWidth = numberIcon:getWidth() + local numberHeight = numberIcon:getHeight() + local numberScale = numberSize / math.max(numberWidth, numberHeight) + + -- Position number in bottom-right corner of device icon + local numberX = centerX + (size * 0.25) - (numberWidth * numberScale * 0.5) + local numberY = centerY + (size * 0.25) - (numberHeight * numberScale * 0.5) + + love.graphics.draw(numberIcon, numberX, numberY, 0, numberScale, numberScale) + + GraphicsUtil.setColor(1, 1, 1, 1) + end + end +end + +return InputPromptRenderer \ No newline at end of file diff --git a/client/src/input/InputConfiguration.lua b/client/src/input/InputConfiguration.lua new file mode 100644 index 000000000..63130e616 --- /dev/null +++ b/client/src/input/InputConfiguration.lua @@ -0,0 +1,442 @@ +local class = require("common.lib.class") +local consts = require("common.engine.consts") +local util = require("common.lib.util") +local joystickManager = require("common.lib.joystickManager") +require("client.src.input.JoystickProvider") + +-- Represents a single input configuration slot with key bindings +---@class InputConfiguration +---@field index number Configuration slot number (1-8) +---@field claimed boolean Whether this config is assigned to a player +---@field player Player? Reference to assigned player (if claimed) +---@field isDown table Input state tracking +---@field isPressed table Input press duration tracking +---@field isUp table Input release state tracking +---@field isPressedWithRepeat function +---@field joystickProvider JoystickProvider +---@field id string Unique identifier (e.g., "config_1") +---@field deviceType string? Device type ("keyboard", "controller", "touch", or nil if empty) +---@field deviceName string? Human-readable device name +---@field controllerImageVariant string? Controller icon variant +---@field deviceNumber number? Device count of this type (e.g., 2nd keyboard) +local InputConfiguration = class( + function(self, index, isPressedWithRepeatFn, joystickProvider) + self.index = index + self.claimed = false + self.player = nil + self.isDown = {} + self.isPressed = {} + self.isUp = {} + self.isPressedWithRepeat = isPressedWithRepeatFn + assert(joystickProvider) + self.joystickProvider = joystickProvider + + -- Cached properties (calculated on init and when bindings change) + self.id = string.format("config_%d", index) + self.deviceType = nil + self.deviceName = nil + self.controllerImageVariant = nil + self.deviceNumber = nil + + -- Calculate initial cached properties + self:updateCachedProperties() + end +) + +-- Recalculates cached properties for this configuration (does not update deviceNumber - use updateAllDeviceNumbers for that) +function InputConfiguration:updateCachedProperties() + self.deviceType = self:getDeviceType() + self.deviceName = self:getDeviceName() + self.controllerImageVariant = self:getControllerImageVariant() +end + +-- Updates this configuration's cached properties when bindings change +-- Note: This does NOT update deviceNumber - caller must update all configs' deviceNumbers +function InputConfiguration:update() + self:updateCachedProperties() +end + +-- Check if this configuration has any key bindings +---@return boolean isEmpty True if no keys are bound +function InputConfiguration:isEmpty() + for _, keyName in ipairs(consts.KEY_NAMES) do + if self[keyName] then + return false + end + end + return true +end + +-- Check if this configuration has all required key bindings +---@return boolean isFullyConfigured True if all required keys are bound +function InputConfiguration:isFullyConfigured() + for _, keyName in ipairs(consts.KEY_NAMES) do + if not self[keyName] then + return false + end + end + return true +end + +-- Parse a binding string to extract GUID, slot, and button ID (static helper) +---@param binding string? Binding string (e.g., "guid:slot:button") +---@return string? guid Controller GUID or nil if not a controller binding +---@return number? slot Controller slot number or nil if not a controller binding +---@return string? buttonId Button identifier or nil if not a controller binding +function InputConfiguration.parseBindingString(binding) + if not binding or not binding:match(":") then + return nil, nil, nil + end + + local guid, slot, buttonId = binding:match("([^:]+):([^:]+):(.+)") + if guid and slot and buttonId then + return guid, tonumber(slot), buttonId + end + + return nil, nil, nil +end + +-- Parse controller binding string to extract GUID and slot +---@param keyName string Key name to parse (e.g., "up", "down", "left") +---@return string? guid Controller GUID or nil if not a controller binding +---@return number? slot Controller slot number or nil if not a controller binding +function InputConfiguration:parseControllerBinding(keyName) + local binding = self[keyName] + local guid, slot, _ = InputConfiguration.parseBindingString(binding) + return guid, slot +end + +-- Determine device type based on the first available binding +---@return "keyboard"|"controller"|"touch"|nil deviceType Type of device or nil if no bindings +function InputConfiguration:getDeviceType() + if self:isEmpty() then + return nil + end + + local firstBinding + for _, keyName in ipairs(consts.KEY_NAMES) do + local binding = self[keyName] + if binding then + firstBinding = binding + break + end + end + + if not firstBinding then + return nil + end + + if firstBinding:find(":", 1, true) then + return "controller" + end + + if firstBinding:match("^mouse") then + return "touch" + end + + return "keyboard" +end + +-- Get a human-readable device name for this configuration +---@return string|nil deviceName Human-readable device name or nil if unknown +function InputConfiguration:getDeviceName() + local deviceType = self:getDeviceType() + + if deviceType == "keyboard" then + return "Keyboard" + elseif deviceType == "touch" then + return "Touch" + elseif deviceType == "controller" then + local guid + local keyNames = consts.KEY_NAMES or {} + + for _, keyName in ipairs(keyNames) do + guid, _ = self:parseControllerBinding(keyName) + if guid then + break + end + end + + if not guid then + return "Controller" + end + + local joysticks = self.joystickProvider:getJoysticks() + for _, joystick in ipairs(joysticks) do + if joystick:getGUID() == guid then + local name = joystick:getName() + if name and #name > 0 then + return name + end + end + end + + return "Controller" + end + + return nil +end + +-- Maps controller names to specific image variants for theme selection (static helper) +---@param controllerName string? Controller name from Love2D +---@return string Image variant key (e.g., "playstation4", "xboxone", "generic") +function InputConfiguration.getControllerImageVariantFromName(controllerName) + if not controllerName then + return "generic" + end + + local name = controllerName:lower() + + -- PlayStation controllers + if name:find("playstation") or name:find("dualshock") or name:find("dualsense") or name:find("ps%d") then + if name:find("5") or name:find("dualsense") then + return "playstation5" + elseif name:find("4") or name:find("dualshock 4") then + return "playstation4" + elseif name:find("3") then + return "playstation3" + elseif name:find("2") then + return "playstation2" + elseif name:find("1") then + return "playstation1" + else + return "playstation4" -- Default to PS4 for generic PlayStation + end + end + + -- Xbox controllers + if name:find("xbox") or name:find("microsoft") then + if name:find("series") or name:find("xbox series") then + return "xboxseries" + elseif name:find("one") or name:find("xbox one") then + return "xboxone" + elseif name:find("360") then + return "xbox360" + else + return "xboxone" -- Default to Xbox One for generic Xbox + end + end + + -- SNES controllers (8BitDo and others) - Check before Switch to avoid "Super Nintendo" matching "Nintendo" + if name:find("snes") or name:find("sn30") or name:find("sf30") or name:find("super nintendo") or name:find("super famicom") then + return "snes" + end + + -- N64 controllers (8BitDo and others) - Check before Switch to avoid "Nintendo 64" matching "Nintendo" + if name:find("n64") or name:find("nintendo 64") or name:find("64 controller") or (name:find("8bitdo") and name:find("64")) then + return "n64" + end + + -- Hyperkin Admiral N64 Controller + if name:find("admiral") then + return "n64" + end + + -- Nintendo Switch controllers + if name:find("switch") or name:find("nintendo") or (name:find("pro controller") and not name:find("sn30") and not name:find("sf30")) then + return "switch_pro" + end + + -- Hyperkin Scout (SNES-style controller) + if name:find("scout") and name:find("hyperkin") then + return "snes" + end + + -- iBuffalo SNES controllers + if name:find("ibuffalo") or name:find("2%-axis 8%-button") then + return "snes" + end + + -- SEGA Genesis/Mega Drive controllers (M30 style) + if name:find("m30") or name:find("genesis") or name:find("mega drive") or name:find("neogeo") then + -- Check it's not a Nintendo M30 variant + if not name:find("nintendo") then + return "generic" -- No SEGA controller image, use generic + end + end + + -- GameCube controllers + if name:find("gamecube") or name:find("game cube") then + return "gamecube" + end + + -- 8BitDo GameCube adapter + if name:find("gbros") then + return "gamecube" + end + + -- Hori Xbox-style controllers (Horipad for Xbox) - Check first + if name:find("hori") and name:find("xbox") then + return "xboxone" + end + + -- Hori Nintendo Switch controllers - Check for explicit Switch mention + if name:find("hori") and name:find("switch") then + return "switch_pro" + end + + -- Hori GameCube-style controllers (Battle Pad and generic Horipad default to GameCube) + -- Generic "Horipad" without Xbox/Switch specifier defaults to GameCube style + if name:find("hori") and (name:find("battle pad") or name:find("horipad")) then + return "gamecube" + end + + -- 8BitDo Pro 2 and Pro 3 - PlayStation style (symmetrical sticks) + if name:find("8bitdo") and (name:find("pro 2") or name:find("pro 3") or name:find("pro2") or name:find("pro3")) then + return "playstation4" -- Use PS4 as the generic PlayStation style + end + + -- 8BitDo Ultimate series - Xbox style (asymmetrical sticks) + if name:find("8bitdo") and name:find("ultimate") then + return "xboxone" -- Use Xbox One as the generic Xbox style + end + + -- GameSir Tarantula - PlayStation style (symmetrical sticks) + if name:find("gamesir") and name:find("tarantula") then + return "playstation4" + end + + -- Default to generic controller for other modern controllers + -- This includes: 8BitDo Lite/Zero/F40/Micro/Arcade, GameSir G7/T4/X2, Hori Fighting Edge, etc. + return "generic" +end + +-- Instance method to get controller image variant for this configuration +---@return string? controllerImageVariant Controller image variant or nil if not a controller +function InputConfiguration:getControllerImageVariant() + if self:getDeviceType() ~= "controller" then + return nil + end + + local controllerName = self:getDeviceName() + return InputConfiguration.getControllerImageVariantFromName(controllerName) +end + +-- Maps gamepad button IDs to display names (static helper) +---@param joystick PanelAttackJoystick? Joystick object +---@param buttonId string Button identifier (e.g., "0", "dpup11", "+y3") +---@return string Display name for the button +function InputConfiguration.getButtonNameFromMapping(joystick, buttonId) + if not joystick or not joystick:isGamepad() then + return buttonId + end + + local gamepadButtonNames = { + dpup = "Up", + dpdown = "Down", + dpleft = "Left", + dpright = "Right", + a = "A", + b = "B", + x = "X", + y = "Y", + leftshoulder = "LB", + rightshoulder = "RB", + leftstick = "LS", + rightstick = "RS", + start = "Start", + back = "Back", + guide = "Guide", + triggerleft = "LT", + triggerright = "RT" + } + + for gamepadButton, displayName in pairs(gamepadButtonNames) do + local inputtype, inputindex, hatdir = joystick:getGamepadMapping(gamepadButton) + if inputtype == "button" then + if tostring(inputindex) == tostring(buttonId) then + return displayName + end + if buttonId == (gamepadButton .. inputindex) then + return displayName + end + elseif inputtype == "hat" then + if buttonId == (gamepadButton .. inputindex) then + return displayName + end + elseif inputtype == "axis" then + local stickIndex = math.floor((1 + inputindex) / 2) + local direction = (inputindex % 2 == 0) and "y" or "x" + local axisString = direction .. stickIndex + + if buttonId == ("+" .. axisString) or buttonId == ("-" .. axisString) then + return displayName + end + end + end + + return buttonId +end + +-- Find a joystick by GUID and slot +---@param guid string Controller GUID +---@param slot number Controller slot number +---@return PanelAttackJoystick? joystick Joystick object or nil if not found +function InputConfiguration:findJoystick(guid, slot) + for _, stick in ipairs(self.joystickProvider:getJoysticks()) do + if stick:getGUID() == guid then + local guidMap = joystickManager.guidsToJoysticks and joystickManager.guidsToJoysticks[guid] + if guidMap and guidMap[stick:getID()] == slot then + return stick + end + end + end + return nil +end + +-- Get human-readable display name for a key binding +---@param keyBinding string? Key binding string (e.g., "space", "guid:slot:button", nil) +---@return string Display name for the key binding +function InputConfiguration:getButtonDisplayName(keyBinding) + if not keyBinding then + return loc("op_none") + end + + local guid, slot, buttonId = InputConfiguration.parseBindingString(keyBinding) + + if not guid or not slot or not buttonId then + -- Not a controller binding, return as-is + return keyBinding + end + + local joystick = self:findJoystick(guid, slot) + if joystick then + return InputConfiguration.getButtonNameFromMapping(joystick, buttonId) + else + return buttonId + end +end + +-- Singleton touch configuration instance +local touchConfiguration = nil + +-- Gets or creates the special Touch InputConfiguration that wraps the mouse +---@return InputConfiguration Touch configuration +function InputConfiguration.getTouchConfiguration() + if not touchConfiguration then + -- Create a special InputConfiguration for touch + -- We pass dummy values since touch doesn't use the normal config system + local dummyFn = function() return false end + touchConfiguration = InputConfiguration(0, dummyFn, love.joystick) + + -- Set touch-specific properties + touchConfiguration.id = "touch" + touchConfiguration.deviceType = "touch" + touchConfiguration.deviceName = "Touch" + touchConfiguration.controllerImageVariant = nil + touchConfiguration.deviceNumber = 1 + touchConfiguration.index = nil + + -- Override isEmpty to return false (touch is always "available") + touchConfiguration.isEmpty = function() return false end + + -- Link to the actual mouse input state + touchConfiguration.isDown = GAME.input.mouse.isDown + touchConfiguration.isPressed = GAME.input.mouse.isPressed + touchConfiguration.isUp = GAME.input.mouse.isUp + end + + return touchConfiguration +end + +return InputConfiguration diff --git a/client/src/input/JoystickProvider.lua b/client/src/input/JoystickProvider.lua new file mode 100644 index 000000000..b753d3299 --- /dev/null +++ b/client/src/input/JoystickProvider.lua @@ -0,0 +1,6 @@ +---@class PanelAttackJoystick +---@field getGUID fun(self): string +---@field getName fun(self): string + +---@class JoystickProvider +---@field getJoysticks fun(self): PanelAttackJoystick[] diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index d5aaf35cb..b450c89fe 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -1,7 +1,11 @@ +local FileUtils = require("client.src.FileUtils") local tableUtils = require("common.lib.tableUtils") local joystickManager = require("common.lib.joystickManager") local consts = require("common.engine.consts") local logger = require("common.lib.logger") +local InputConfiguration = require("client.src.input.InputConfiguration") +local Signal = require("common.lib.signal") +require("client.src.input.JoystickProvider") -- table containing the set of keys in various states -- base structure: @@ -23,19 +27,8 @@ local inputManager = { mouse = {isDown = {}, isPressed = {}, isUp = {}, x = 0, y = 0}, inputConfigurations = {}, maxConfigurations = 8, - defaultKeys = { - Up = "up", - Down = "down", - Left = "left", - Right = "right", - Swap1 = "z", - Swap2 = "x", - TauntUp = "y", - TauntDown = "u", - Raise1 = "c", - Raise2 = "v", - Start = "return" - } + hasUnsavedChanges = false, + unconfiguredJoysticksCache = nil } -- Represents the state of love.run while the key in isDown/isUp is active @@ -87,18 +80,50 @@ function inputManager:keyReleased(key, scancode) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end -function inputManager:joystickPressed(joystick, button) - if not joystickManager.devices[joystick:getID()] then - love.joystickadded(joystick) +function inputManager:onJoystickAdded(joystick) + joystickManager:registerJoystick(joystick) + local unconfiguredJoysticks = self:updateUnconfiguredJoysticksCache() + + -- Check if the newly added joystick is unconfigured + for _, unconfiguredJoystick in ipairs(unconfiguredJoysticks) do + if unconfiguredJoystick == joystick then + self:emitSignal("unconfiguredJoystickAdded", joystick) + break + end + end +end + +function inputManager:onJoystickRemoved(joystick) + -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID + -- the GUID is consistent across sessions + local guid = joystick:getGUID() + -- ID is a per-session identifier for each controller regardless of type + local id = joystick:getID() + + local vendorID, productID, productVersion = joystick:getDeviceInfo() + + logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) + + if joystickManager.guidsToJoysticks[guid] then + joystickManager.guidsToJoysticks[guid][id] = nil + + if tableUtils.length(joystickManager.guidsToJoysticks[guid]) == 0 then + joystickManager.guidsToJoysticks[guid] = nil + end end + + joystickManager.devices[id] = nil + self:updateUnconfiguredJoysticksCache() +end + +function inputManager:joystickPressed(joystick, button) + joystickManager:registerJoystick(joystick) local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isDown[key] = KEY_CHANGE.DETECTED end function inputManager:joystickReleased(joystick, button) - if not joystickManager.devices[joystick:getID()] then - love.joystickadded(joystick) - end + joystickManager:registerJoystick(joystick) local key = joystickManager:getJoystickButtonName(joystick, button) self.allKeys.isUp[key] = KEY_CHANGE.DETECTED end @@ -439,25 +464,75 @@ function inputManager:getSaveKeyMap() return result end +function inputManager:write_key_file() + FileUtils.writeJson("", "keysV3.json", self:getSaveKeyMap()) + self.hasUnsavedChanges = false +end + +-- Saves input configuration mappings to disk +function inputManager:saveInputConfigurationMappings() + self:write_key_file() +end + + +local currentVersionFilename = "keysV3.json" +local previousVersionFilename = "keysV2.json" + +function inputManager:hasKeyFile() + local filename = nil + local migrateInputs = false + if FileUtils.exists(currentVersionFilename) then + filename = currentVersionFilename + elseif FileUtils.exists(previousVersionFilename) then + filename = previousVersionFilename + migrateInputs = true + end + + return filename, migrateInputs +end + +-- Loads input file and setups defaults +function inputManager:load() + local filename, migrateInputs = self:hasKeyFile() + + if filename == nil then + -- No key file exists - set up default keys + inputManager:setupDefaultKeyConfigurations() + return inputManager.inputConfigurations + end + + local inputConfigs = FileUtils.readJsonFile(filename) + + if migrateInputs then + -- migrate old input configs + inputConfigs = inputManager:migrateInputConfigs(inputConfigs) + end + self:importConfigurations(inputConfigs) + + return inputConfigs +end + for i = 1, inputManager.maxConfigurations do - inputManager.inputConfigurations[i] = { - isDown = {}, - isPressed = {}, - isUp = {}, - isPressedWithRepeat = isPressedWithRepeat, - claimed = false, - player = nil - } + inputManager.inputConfigurations[i] = InputConfiguration(i, isPressedWithRepeat, love.joystick) end inputManager.allKeys.isPressedWithRepeat = isPressedWithRepeat +-- Turn inputManager into a Signal emitter +Signal.turnIntoEmitter(inputManager) +inputManager:createSignal("unconfiguredJoystickAdded") + function inputManager:importConfigurations(configurations) for i = 1, #configurations do for key, value in pairs(configurations[i]) do self.inputConfigurations[i][key] = value end end + -- Update all cached properties after importing + for _, config in ipairs(self.inputConfigurations) do + config:updateCachedProperties() + end + self:updateAllDeviceNumbers() end function inputManager:claimConfiguration(player, inputConfiguration) @@ -488,4 +563,331 @@ function inputManager:releaseConfiguration(player, inputConfiguration) self:updateKeyMaps() end +-- Updates deviceNumber for all InputConfigurations based on device type counts +function inputManager:updateAllDeviceNumbers() + local deviceTypeCounters = {} + + for _, config in ipairs(self.inputConfigurations) do + -- Only count non-empty configurations with bindings + if not config:isEmpty() and config.deviceType then + deviceTypeCounters[config.deviceType] = (deviceTypeCounters[config.deviceType] or 0) + 1 + config.deviceNumber = deviceTypeCounters[config.deviceType] + else + config.deviceNumber = nil + end + end +end + +-- Updates a specific InputConfiguration when its bindings change +---@param config InputConfiguration Configuration to update +function inputManager:updateInputConfiguration(config) + config:update() + self:updateAllDeviceNumbers() +end + +-- Clears a button binding from all other input configurations +---@param buttonBinding string The button binding to clear (e.g., "space", "guid:slot:button") +function inputManager:clearButtonFromAllConfigs(buttonBinding) + if not buttonBinding then + return + end + + for _, config in ipairs(self.inputConfigurations) do + for _, keyName in ipairs(consts.KEY_NAMES) do + if config[keyName] == buttonBinding then + config[keyName] = nil + end + end + end +end + +-- Changes a key binding on an input configuration +---@param inputConfiguration InputConfiguration Configuration to modify +---@param keyName string Key name to change (e.g., "Up", "Down", "Swap1") +---@param keyToUse string? New key binding (nil to clear) +---@param skipSave boolean? If true, skip writing to file (for batch operations) +function inputManager:changeKeyBindingOnInputConfiguration(inputConfiguration, keyName, keyToUse, skipSave) + + -- Clear the new binding from all other configurations + if keyToUse then + self:clearButtonFromAllConfigs(keyToUse) + end + + inputConfiguration[keyName] = keyToUse + + self.hasUnsavedChanges = true + self:updateInputConfiguration(inputConfiguration) + self:updateUnconfiguredJoysticksCache() + if not skipSave then + self:write_key_file() + end +end + +-- Clears all key bindings on an input configuration +---@param inputConfiguration InputConfiguration Configuration to clear +function inputManager:clearKeyBindingsOnInputConfiguration(inputConfiguration) + for _, keyName in ipairs(consts.KEY_NAMES) do + inputConfiguration[keyName] = nil + end + self.hasUnsavedChanges = true + self:updateInputConfiguration(inputConfiguration) + self:updateUnconfiguredJoysticksCache() + self:write_key_file() +end + +function inputManager:setupDefaultKeyConfigurations() + local defaultKeys = {} + defaultKeys[#defaultKeys+1] = { + Up = "up", + Down = "down", + Left = "left", + Right = "right", + Swap1 = "z", + Swap2 = "x", + TauntUp = "y", + TauntDown = "u", + Raise1 = "c", + Raise2 = "v", + Start = "return" + } + defaultKeys[#defaultKeys+1] = { + Up = "w", + Down = "s", + Left = "a", + Right = "d", + Swap1 = "j", + Swap2 = "k", + TauntUp = "i", + TauntDown = "l", + Raise1 = "o", + Raise2 = "u", + Start = "space" + } + for i = 1, inputManager.maxConfigurations do + if i <= #defaultKeys then + for keyName, key in pairs(defaultKeys[i]) do + self.inputConfigurations[i][keyName] = key + end + else + for _, keyName in ipairs(consts.KEY_NAMES) do + self.inputConfigurations[i][keyName] = nil + end + end + end + + -- Auto-configure all connected gamepads + local connectedJoysticks = love.joystick.getJoysticks() + for _, joystick in ipairs(connectedJoysticks) do + self:autoConfigureJoystick(joystick, false) + end + + -- Update all cached properties after setting defaults + for _, config in ipairs(self.inputConfigurations) do + config:updateCachedProperties() + end + self:updateAllDeviceNumbers() +end + +---@return table? Input configuration with active input, or nil +function inputManager:detectActiveInputConfiguration() + for i = 1, #self.inputConfigurations do + local config = self.inputConfigurations[i] + for _, keyName in ipairs(consts.KEY_NAMES) do + if config.isDown and config.isDown[keyName] then + return config + end + end + end + + return nil +end + +---@param battleRoom BattleRoom? +---@return boolean True if an unassigned configuration has active input +function inputManager:checkForUnassignedConfigurationInputs(battleRoom) + if not battleRoom then + return false + end + + local activeConfig = self:detectActiveInputConfiguration() + if not activeConfig then + return false + end + + local assignedConfigs = {} + for _, player in ipairs(battleRoom:getLocalHumanPlayers()) do + if player.inputConfiguration then + assignedConfigs[player.inputConfiguration] = true + end + end + + return not assignedConfigs[activeConfig] +end + +-- Gets all configured joystick GUIDs from input configurations +---@return table Map of configured GUIDs +function inputManager:getConfiguredJoystickGuids() + local configuredGuids = {} + for i = 1, self.maxConfigurations do + local config = self.inputConfigurations[i] + if config then + for _, keyName in ipairs(consts.KEY_NAMES) do + local keyMapping = config[keyName] + if keyMapping and type(keyMapping) == "string" then + -- Extract GUID from mapping format like "guid:id:button" + local guid = keyMapping:match("^([^:]+):") + if guid then + configuredGuids[guid] = true + end + end + end + end + end + return configuredGuids +end + +-- Updates the unconfigured joysticks cache immediately +function inputManager:updateUnconfiguredJoysticksCache() + -- Build the list + local unconfiguredJoysticks = {} + local connectedJoysticks = love.joystick.getJoysticks() + local configuredGuids = self:getConfiguredJoystickGuids() + + for _, joystick in ipairs(connectedJoysticks) do + local guid = joystick:getGUID() + -- Check if this joystick could be auto-configured (has gamepad mapping) + local customId = joystickManager.guidsToJoysticks[guid] and joystickManager.guidsToJoysticks[guid][joystick:getID()] + + if customId and not configuredGuids[guid] then + unconfiguredJoysticks[#unconfiguredJoysticks + 1] = joystick + end + end + + -- Update the cache + self.unconfiguredJoysticksCache = unconfiguredJoysticks + return unconfiguredJoysticks +end + +-- Gets a list of joysticks that don't have input configurations +---@return love.Joystick[] Array of unconfigured joysticks +function inputManager:getUnconfiguredJoysticks() + -- Return cached value (always fresh since we update immediately) + return self.unconfiguredJoysticksCache or {} +end + +-- Check if there are any connected joysticks that aren't configured +function inputManager:hasUnconfiguredJoysticks() + local unconfigured = self:getUnconfiguredJoysticks() + return #unconfigured > 0 +end + +-- Finds the next available (empty) input configuration slot +---@return number? Index of empty configuration, or nil if all slots are full +function inputManager:findNextAvailableConfig() + for i = 1, self.maxConfigurations do + local isEmpty = self.inputConfigurations[i]:isEmpty() + if isEmpty then + return i + end + end + -- If all slots are full, return nil to indicate no available slot + return nil +end + +-- Automatically configures a joystick by mapping gamepad buttons to Panel Attack actions +---@param joystick love.Joystick The joystick to configure +---@param shouldSave boolean if the configuration should be saved to disk +---@return number? configIndex The index of the configuration that was set up, or nil if configuration failed +function inputManager:autoConfigureJoystick(joystick, shouldSave) + local configIndex = self:findNextAvailableConfig() + + -- If no available slot, skip configuration + if not configIndex then + return nil + end + + -- Only auto-configure gamepads (devices with known button mappings) + if not joystick:isGamepad() then + return nil + end + + logger.debug("Autoconfiguring joystick at index " .. configIndex) + + local guid = joystick:getGUID() + local customId = joystickManager.guidsToJoysticks[guid] and joystickManager.guidsToJoysticks[guid][joystick:getID()] + + if customId ~= nil then + -- Map Panel Attack actions to Love2D gamepad button names + local gamepadButtonMappings = { + Up = "dpup", + Down = "dpdown", + Left = "dpleft", + Right = "dpright", + Swap1 = "a", + Swap2 = "b", + TauntUp = "y", + TauntDown = "x", + Raise1 = "leftshoulder", + Raise2 = "rightshoulder", + Start = "start" + } + + local basicMapping = {} + + -- Query the actual button mappings from Love2D + for panelAttackAction, gamepadButton in pairs(gamepadButtonMappings) do + local inputtype, inputindex, _ = joystick:getGamepadMapping(gamepadButton) + if inputtype == "button" then + basicMapping[panelAttackAction] = guid .. ":" .. customId .. ":" .. inputindex + elseif inputtype == "hat" then + -- Some controllers use hat for D-pad instead of individual buttons + basicMapping[panelAttackAction] = guid .. ":" .. customId .. ":" .. gamepadButton .. inputindex + end + end + + -- Only proceed if we got at least some mappings + if next(basicMapping) then + -- Ensure the configuration slot has all the keys we need + local config = self.inputConfigurations[configIndex] + for keyName, keyMapping in pairs(basicMapping) do + self:changeKeyBindingOnInputConfiguration(config, keyName, keyMapping, true) + end + + -- Make sure all required keys are set (fill any missing ones with nil to be explicit) + for _, keyName in ipairs(consts.KEY_NAMES) do + if config[keyName] == nil and not basicMapping[keyName] then + self:changeKeyBindingOnInputConfiguration(config, keyName, nil, true) + end + end + + self:updateUnconfiguredJoysticksCache() + if shouldSave then + self:write_key_file() + end + return configIndex + end + end + return nil +end + +-- Gets list of all assignable input devices (controllers, keyboard, touch) +-- Returns InputConfiguration objects directly with all metadata already calculated +---@return InputConfiguration[] Array of InputConfiguration objects +function inputManager:getAssignableDevices() + local devices = {} + + -- Add all non-empty InputConfigurations + for _, config in ipairs(self.inputConfigurations) do + if not config:isEmpty() then + devices[#devices + 1] = config + end + end + + -- Add touch configuration + local touchConfig = InputConfiguration.getTouchConfiguration() + devices[#devices + 1] = touchConfig + + return devices +end + return inputManager diff --git a/client/src/localization.lua b/client/src/localization.lua index 821972eb3..b5ca96a49 100644 --- a/client/src/localization.lua +++ b/client/src/localization.lua @@ -2,7 +2,7 @@ local FILENAME = "client/assets/localization.csv" local consts = require("common.engine.consts") local logger = require("common.lib.logger") -local GraphicsUtil = require("client.src.graphics.graphics_util") +local ui = require("client.src.ui") local class = require("common.lib.class") local fileUtils = require("client.src.FileUtils") @@ -15,11 +15,11 @@ Localization = { init = false, } -function Localization.get_list_codes(self) +function Localization:get_list_codes() return self.codes end -function Localization.get_language(self) +function Localization:get_language() return self.codes[self.lang_index] end @@ -179,4 +179,40 @@ function loc(text_key, ...) return ret end +function Localization:getCurrentLanguageCode() + if config.language_code then + return config.language_code + end + return "EN" +end + +-- Creates language labels by temporarily switching to each language to load proper fonts +-- Returns: array of {code, name} pairs, array of labels with proper fonts +function Localization:getLanguageLabelsWithFonts() + local languageData = {} + local languageLabels = {} + local originalLanguageCode = self:getCurrentLanguageCode() + + for k, languageCode in ipairs(self:get_list_codes()) do + GAME:setLanguage(languageCode) + local languageName = self.data[languageCode]["LANG"] + languageData[#languageData + 1] = {code = languageCode, name = languageName} + languageLabels[#languageLabels + 1] = ui.Label({text = languageName, translate = false}) + end + + GAME:setLanguage(originalLanguageCode) + + return languageData, languageLabels +end + +-- Gets the index of a language code in the list +function Localization:getLanguageIndex(languageCode) + for k, code in ipairs(self:get_list_codes()) do + if code == languageCode then + return k + end + end + return 1 +end + return Localization \ No newline at end of file diff --git a/client/src/mods/Theme.lua b/client/src/mods/Theme.lua index f7c630686..7eccafb7d 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -40,6 +40,7 @@ local flags = { ---@field main_menu_y_max number ---@field main_menu_max_height number ---@field defaultStage Stage +---@field colors table color palette where each value is an RGBA array of four numbers (0-1 range) Theme = class( ---@param self Theme @@ -57,6 +58,22 @@ Theme = self.main_menu_screen_pos = {0, 0} -- the top center position of most menus self.main_menu_y_max = 0 self.main_menu_max_height = 0 + + self.colors = { + menuDefaultBackgroundColor = {1, 1, 1, 0.15}, + menuDefaultBorderColor = {1, 1, 1, 0.15}, + menuSelectedBackgroundColor = {0.6, 0.6, 1, 0.15}, + menuSelectedBorderColor = {0.6, 0.6, 1, 0.15}, + activeBackgroundColor = {0.2, 0.3, 0.4, 0.9}, + darkTransparentBackgroundColor = {0, 0, 0, 0.75}, + highlightTextColor = {1, 1, 0.3, 1}, + inputSlotDefaultBackgroundColor = {0.2, 0.2, 0.2, 0.9}, + inputSlotDefaultBorderColor = {0.4, 0.4, 0.4, 0.9}, + inputSlotSelectedBackgroundColor = {0.2, 0.2, 0.34, 1.0}, + inputSlotSelectedBorderColor = {0.5, 0.5, 0.8, 1.0}, + incompleteConfigBackgroundColor = {0.918, 0.251, 0.275, 1.0}, + configCorrectBackgroundColor = {0.3, .3, .3, 0.7} + } end ) @@ -360,6 +377,42 @@ local function loadPlayerNumberIcons(theme) return theme.images.IMG_players end +local function loadInputPromptIcons(theme) + local icons = {} + + -- Load basic device types + icons.controller = theme:load_theme_img("input/controller") + icons.keyboard = theme:load_theme_img("input/keyboard") + icons.touch = theme:load_theme_img("input/touch") + icons.mouse = theme:load_theme_img("input/mouse") + + -- Load specific controller variants + icons.controller_variants = {} + icons.controller_variants.generic = theme:load_theme_img("input/controller_generic") + icons.controller_variants.playstation1 = theme:load_theme_img("input/controller_playstation1") + icons.controller_variants.playstation2 = theme:load_theme_img("input/controller_playstation2") + icons.controller_variants.playstation3 = theme:load_theme_img("input/controller_playstation3") + icons.controller_variants.playstation4 = theme:load_theme_img("input/controller_playstation4") + icons.controller_variants.playstation5 = theme:load_theme_img("input/controller_playstation5") + icons.controller_variants.xbox360 = theme:load_theme_img("input/controller_xbox360") + icons.controller_variants.xboxone = theme:load_theme_img("input/controller_xboxone") + icons.controller_variants.xboxseries = theme:load_theme_img("input/controller_xboxseries") + icons.controller_variants.switch_pro = theme:load_theme_img("input/controller_switch_pro") + + -- Load add controller icon + icons.controller_add = theme:load_theme_img("input/controller_add") + icons.controller_error = theme:load_theme_img("input/error") + + -- Load device number overlays + icons.device_numbers = {} + for i = 0, 9 do + icons.device_numbers[i] = theme:load_theme_img("input/device_number_" .. i) + end + + theme.images.inputPrompts = icons + return theme.images.inputPrompts +end + function Theme:loadSelectionGraphics() self.images.flags = {} for _, flag in ipairs(flags) do @@ -393,6 +446,7 @@ function Theme:loadSelectionGraphics() self.images.IMG_random_character = self:load_theme_img("random_character") loadPlayerNumberIcons(self) + loadInputPromptIcons(self) loadGridCursors(self) end @@ -1029,6 +1083,48 @@ function Theme:getPlayerNumberIcon(index) return self.images.IMG_players[index] end +---@param deviceType string +---@return love.Texture +function Theme:getInputPromptIcon(deviceType) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + return self.images.inputPrompts[deviceType] +end + +---@param deviceType string "controller", "keyboard", "touch", or "mouse" +---@param controllerImageVariant string? specific controller image variant key +---@return love.Texture +function Theme:getSpecificInputIcon(deviceType, controllerImageVariant) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + + if deviceType == "controller" and controllerImageVariant and self.images.inputPrompts.controller_variants then + local specificIcon = self.images.inputPrompts.controller_variants[controllerImageVariant] + if specificIcon then + return specificIcon + end + end + + -- Fallback to basic device type + return self.images.inputPrompts[deviceType] +end + +---@param number integer the device number (0-9) +---@return love.Texture? +function Theme:getDeviceNumberIcon(number) + if not self.images.inputPrompts then + loadInputPromptIcons(self) + end + + if self.images.inputPrompts.device_numbers and number >= 0 and number <= 9 then + return self.images.inputPrompts.device_numbers[number] + end + + return nil +end + ---@param index integer? ---@return love.Texture function Theme:getHealthBarFrameAbsolute(index) diff --git a/client/src/save.lua b/client/src/save.lua index d3cc425af..6c5aaa4cd 100644 --- a/client/src/save.lua +++ b/client/src/save.lua @@ -1,4 +1,3 @@ -local inputManager = require("client.src.inputManager") local FileUtils = require("client.src.FileUtils") local logger = require("common.lib.logger") @@ -6,37 +5,6 @@ local logger = require("common.lib.logger") local save = {} --- writes to the "keys.txt" file -function save.write_key_file() - FileUtils.writeJson("", "keysV3.json", inputManager:getSaveKeyMap()) -end - --- reads the "keys.txt" file -function save.read_key_file() - local filename - local migrateInputs = false - - if FileUtils.exists("keysV3.json") then - filename = "keysV3.json" - else - filename = "keysV2.txt" - migrateInputs = true - end - - if not FileUtils.exists(filename) then - return inputManager.inputConfigurations - else - local inputConfigs = FileUtils.readJsonFile(filename) - - if migrateInputs then - -- migrate old input configs - inputConfigs = inputManager:migrateInputConfigs(inputConfigs) - end - - return inputConfigs - end -end - -- reads the .txt file of the given path and filename function save.read_txt_file(path_and_filename) local s diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index eb29eb46a..0690c0018 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -4,11 +4,12 @@ local class = require("common.lib.class") local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") local GameModes = require("common.data.GameModes") -local LevelPresets = require("common.data.LevelPresets") local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local Character = require("client.src.mods.Character") +local LevelPresets = require("common.data.LevelPresets") +local InputDeviceOverlay = require("client.src.scenes.components.InputDeviceOverlay") -- The character select screen scene ---@class CharacterSelect : Scene @@ -57,8 +58,12 @@ function CharacterSelect:load() self.ui.cursors = {} self.ui.characterIcons = {} self.ui.playerInfos = {} - self:customLoad() + + self:createInputDeviceOverlay() + + self:setChangeInputButtonVisibility(false) + self:setChangeInputButtonVisibleIfNeeded() for _, player in ipairs(self.players) do if player:isHuman() then @@ -287,6 +292,68 @@ function CharacterSelect:createStageCarousel(player, width) return stageCarousel end +function CharacterSelect:createInputDeviceOverlay() + + self.inputDeviceOverlay = InputDeviceOverlay({ + battleRoom = self.battleRoom, + onClose = function() + self:onInputDeviceOverlayClosed() + end, + onCancel = function() + self:leave() + end + }) + self.uiRoot:addChild(self.inputDeviceOverlay) +end + +function CharacterSelect:onInputDeviceOverlayClosed() + self:setChangeInputButtonVisibleIfNeeded() +end + +function CharacterSelect:setChangeInputButtonVisibleIfNeeded() + if self.ui and self.ui.changeInputButton then + if self.battleRoom and #self.battleRoom:getLocalHumanPlayers() == 0 then + return + end + + self.ui.changeInputButton:setVisibility(true) + end +end + +function CharacterSelect:setChangeInputButtonVisibility(isVisible) + if self.ui and self.ui.changeInputButton then + self.ui.changeInputButton:setVisibility(isVisible) + end +end + +function CharacterSelect:createChangeInputButton() + return ui.ChangeInputButton({ + hFill = true, + vFill = true, + battleRoom = self.battleRoom, + onChangeInputRequested = function() + self:onChangeInputDeviceRequested() + end + }) +end + +function CharacterSelect:onChangeInputDeviceRequested() + if not self.battleRoom then + return + end + + local hasLocalPlayers = #self.battleRoom:getLocalHumanPlayers() > 0 + if not hasLocalPlayers then + return + end + + if self.battleRoom:releaseAllLocalAssignments() then + GAME.theme:playCancelSfx() + else + GAME.theme:playMoveSfx() + end +end + local super_select_pixelcode = [[ uniform float percent; vec4 effect( vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords ) @@ -992,6 +1059,11 @@ function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) end function CharacterSelect:updateSelf(dt) + local overlayActive = self.inputDeviceOverlay and self.inputDeviceOverlay:isActive() + if overlayActive then + return + end + for _, cursor in ipairs(self.ui.cursors) do if cursor.player.isLocal and cursor.player.human then if not cursor.player.inputConfiguration then diff --git a/client/src/scenes/CharacterSelect2p.lua b/client/src/scenes/CharacterSelect2p.lua index 664ccddc1..6bc6285ce 100644 --- a/client/src/scenes/CharacterSelect2p.lua +++ b/client/src/scenes/CharacterSelect2p.lua @@ -37,6 +37,7 @@ function CharacterSelect2p:loadUserInterface() self.ui.pageIndicator = self:createPageIndicator(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() self.ui.rankedSelection = ui.MultiPlayerSelectionWrapper({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) self.ui.rankedSelection:setTitle("ss_ranked") @@ -67,6 +68,7 @@ function CharacterSelect2p:loadUserInterface() self.ui.grid:createElementAt(9, 2, 1, 1, "readyButton", self.ui.readyButton) self.ui.grid:createElementAt(1, 3, characterGridWidth, characterGridHeight, "characterSelection", self.ui.characterGrid, true) self.ui.grid:createElementAt(5, 6, 1, 1, "pageIndicator", self.ui.pageIndicator) + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.characterIcons = {} @@ -108,4 +110,4 @@ function CharacterSelect2p:loadUserInterface() end -return CharacterSelect2p \ No newline at end of file +return CharacterSelect2p diff --git a/client/src/scenes/CharacterSelectChallenge.lua b/client/src/scenes/CharacterSelectChallenge.lua index 90b100d87..48f3fa28f 100644 --- a/client/src/scenes/CharacterSelectChallenge.lua +++ b/client/src/scenes/CharacterSelectChallenge.lua @@ -29,6 +29,7 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.characterGrid = self:createCharacterGrid(characterButtons, self.ui.grid, characterGridWidth, characterGridHeight) self.ui.pageIndicator = self:createPageIndicator(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() local panelHeight local stageWidth @@ -42,6 +43,7 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.grid:createElementAt(9, 2, 1, 1, "readyButton", self.ui.readyButton) self.ui.grid:createElementAt(1, 3, characterGridWidth, characterGridHeight, "characterSelection", self.ui.characterGrid, true) self.ui.grid:createElementAt(5, 6, 1, 1, "pageIndicator", self.ui.pageIndicator) + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.characterIcons = {} @@ -76,4 +78,4 @@ function CharacterSelectChallenge:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) end -return CharacterSelectChallenge \ No newline at end of file +return CharacterSelectChallenge diff --git a/client/src/scenes/CharacterSelectVsSelf.lua b/client/src/scenes/CharacterSelectVsSelf.lua index 59a22c562..6a4ad970e 100644 --- a/client/src/scenes/CharacterSelectVsSelf.lua +++ b/client/src/scenes/CharacterSelectVsSelf.lua @@ -70,6 +70,8 @@ function CharacterSelectVsSelf:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) self.ui.leaveButton = self:createLeaveButton() + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) self.ui.cursors[1] = self:createCursor(self.ui.grid, player) @@ -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 000000000..ea8930a02 --- /dev/null +++ b/client/src/scenes/DiscordCommunitySetup.lua @@ -0,0 +1,127 @@ +local Scene = require("client.src.scenes.Scene") +local ui = require("client.src.ui") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") +local save = require("client.src.save") +local InputConfigMenu = require("client.src.scenes.InputConfigMenu") +local logger = require("common.lib.logger") + +local DiscordCommunitySetup = class(function(self, sceneParams) + assert(sceneParams, "DiscordCommunitySetup requires sceneParams") + assert(sceneParams.triggerNextScene, "DiscordCommunitySetup requires triggerNextScene callback") + + self.music = "main" + + local titleFontSize = 28 + local bodyFontSize = 14 + + -- Create a centered vertical stack panel for all content + local contentStack = ui.StackPanel({ + alignment = "top", + width = consts.CANVAS_WIDTH, + hAlign = "center", + vAlign = "center" + }) + + -- Title + local titleLabel = ui.Label({ + fontSize = titleFontSize, + text = "discord_welcome_title", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(titleLabel) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- Discord logo + local discordLogo = ui.ImageContainer({ + image = love.graphics.newImage("client/assets/themes/Panel Attack Modern/discord_logo.png"), + width = 160, + height = 160, + hAlign = "center" + }) + contentStack:addElement(discordLogo) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- Message lines + local messageLine1 = ui.Label({ + fontSize = bodyFontSize, + text = "discord_message_line1", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(messageLine1) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + local messageLine2 = ui.Label({ + fontSize = bodyFontSize, + text = "discord_message_line2", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(messageLine2) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + local messageLine3 = ui.Label({ + fontSize = bodyFontSize, + text = "discord_message_line3", + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(messageLine3) + + local discordLinkButton = ui.MenuItem.createButtonMenuItem("discord_join_link", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://discord.panelattack.com") + end) + + local continueButton = ui.MenuItem.createButtonMenuItem("next_button", nil, nil, function() + GAME.theme:playValidationSfx() + config.discordCommunityShown = true + write_conf_file() + self.triggerNextScene() + end) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 20 + })) + + -- Menu buttons + local menu = ui.Menu.createCenteredMenu({discordLinkButton, continueButton}, 0) + contentStack:addElement(menu) + self.menu = menu + + self.uiRoot:addChild(contentStack) +end, Scene) + +DiscordCommunitySetup.name = "DiscordCommunitySetup" + +function DiscordCommunitySetup:update(dt) + GAME.theme.images.bg_main:update(dt) + self.menu:receiveInputs() +end + +function DiscordCommunitySetup:draw() + GAME.theme.images.bg_main:draw() + self.uiRoot:draw() +end + +return DiscordCommunitySetup \ No newline at end of file diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index a0a00b27b..ea5936caf 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 ab9187a09..4c12f96f0 100644 --- a/client/src/scenes/InputConfigMenu.lua +++ b/client/src/scenes/InputConfigMenu.lua @@ -2,82 +2,120 @@ local Scene = require("client.src.scenes.Scene") local tableUtils = require("common.lib.tableUtils") local ui = require("client.src.ui") local consts = require("common.engine.consts") -local input = require("client.src.inputManager") -local joystickManager = require("common.lib.joystickManager") -local util = require("common.lib.util") +local inputManager = require("client.src.inputManager") local class = require("common.lib.class") -local save = require("client.src.save") +local InputConfigSlider = require("client.src.ui.InputConfigSlider") +local KeyBindingMenuItem = require("client.src.ui.KeyBindingMenuItem") + +-- Sometimes controllers register buttons as "pressed" even though they aren't. If they have been pressed longer than this they don't count. +local MAX_PRESS_DURATION = 0.5 +local pendingInputText = "__" + +-- Represents the state of the InputConfigMenu +-- NOT_SETTING: when we are not polling for a new key +-- SETTING_KEY_TRANSITION: skip a frame so we don't use the button activation key as the configured key +-- SETTING_KEY: currently polling for a single key +-- SETTING_ALL_KEYS: currently polling for all keys +local KEY_SETTING_STATE = { NOT_SETTING = nil, SETTING_KEY_TRANSITION = 1, SETTING_KEY = 2, SETTING_ALL_KEYS_TRANSITION = 3, SETTING_ALL_KEYS = 4 } -- Scene for configuring input local InputConfigMenu = class( function (self, sceneParams) - self.backgroundImg = themes[config.theme].images.bg_main self.music = "main" self.settingKey = false - self.menu = nil -- set in load - - self:load(sceneParams) + self.menu = nil + self.backgroundImg = nil + self.newInputsConfigured = inputManager.hasUnsavedChanges + self.configIndex = 1 + + self:loadUI() + + self:autoConfigureJoysticks() + + self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) + + -- Listen for unconfigured joysticks being added + inputManager:connectSignal("unconfiguredJoystickAdded", self, self.onUnconfiguredJoystickAdded) end, Scene ) InputConfigMenu.name = "InputConfigMenu" --- Sometimes controllers register buttons as "pressed" even though they aren't. If they have been pressed longer than this they don't count. -local MAX_PRESS_DURATION = 0.5 -local KEY_NAME_LABEL_WIDTH = 180 -local PADDING = 8 -local pendingInputText = "__" - -local function shortenControllerName(name) - local nameToShortName = { - ["Nintendo Switch Pro Controller"] = "Switch Pro Con" - } - return nameToShortName[name] or name -end - --- Represents the state of love.run while the key in isDown/isUp is active --- NOT_SETTING: when we are not polling for a new key --- SETTING_KEY_TRANSITION: skip a frame so we don't use the button activation key as the configured key --- SETTING_KEY: currently polling for a single key --- SETTING_ALL_KEYS: currently polling for all keys --- This is only used within this file, external users should simply treat isDown/isUp as a boolean -local KEY_SETTING_STATE = { NOT_SETTING = nil, SETTING_KEY_TRANSITION = 1, SETTING_KEY = 2, SETTING_ALL_KEYS_TRANSITION = 3, SETTING_ALL_KEYS = 4 } - function InputConfigMenu:setSettingKeyState(keySettingState) self.settingKey = keySettingState ~= KEY_SETTING_STATE.NOT_SETTING self.settingKeyState = keySettingState self.menu:setEnabled(not self.settingKey) + + -- Update back button color based on configuration completeness + if self.backMenuItem and self.backMenuItem.textButton then + if self:allInputConfigurationsValid() then + self.backMenuItem.textButton.backgroundColor = GAME.theme.colors.configCorrectBackgroundColor + else + self.backMenuItem.textButton.backgroundColor = GAME.theme.colors.incompleteConfigBackgroundColor + end + end end -function InputConfigMenu:getKeyDisplayName(key) - local keyDisplayName = key - if key and string.match(key, ":") then - local controllerKeySplit = util.split(key, ":") - local controllerName = shortenControllerName(joystickManager.guidToName[controllerKeySplit[1]] or "Unplugged Controller") - keyDisplayName = string.format("%s (%s-%s)", controllerKeySplit[3], controllerName, controllerKeySplit[2]) +function InputConfigMenu:allInputConfigurationsValid() + local result = true + + for _, config in ipairs(inputManager.inputConfigurations) do + + local isIncomplete = not config:isEmpty() and not config:isFullyConfigured() + if isIncomplete then + result = false + break + end end - return keyDisplayName or loc("op_none") + + return result +end + +function InputConfigMenu:getKeyDisplayName(key) + local config = inputManager.inputConfigurations[self.configIndex] + return config:getButtonDisplayName(key) end -function InputConfigMenu:updateInputConfigMenuLabels(index) - self.configIndex = index +function InputConfigMenu:updateInputConfigMenuLabels() for i, key in ipairs(consts.KEY_NAMES) do - local keyDisplayName = self:getKeyDisplayName(GAME.input.inputConfigurations[self.configIndex][key]) - self:currentKeyLabelForIndex(i + 1):setText(keyDisplayName) + local keyDisplayName = self:getKeyDisplayName(inputManager.inputConfigurations[self.configIndex][key]) + self:currentKeyLabelForIndex(i + 1):setText(keyDisplayName, nil, false) + end +end + +function InputConfigMenu:currentKeyLabelForIndex(index) + -- Index is 1-based for key bindings (1 = first key) + -- Menu item index = index + 1 (account for slider at index 1) + local menuItem = self.menu.menuItems[index] + if menuItem.bindingButton and menuItem.bindingButton.label then + return menuItem.bindingButton.label + else + return menuItem.textButton.children[1] end end function InputConfigMenu:updateKey(key, pressedKey, index) GAME.theme:playValidationSfx() - GAME.input.inputConfigurations[self.configIndex][key] = pressedKey + local config = inputManager.inputConfigurations[self.configIndex] + inputManager:changeKeyBindingOnInputConfiguration(config, key, pressedKey) local keyDisplayName = self:getKeyDisplayName(pressedKey) - self:currentKeyLabelForIndex(index + 1):setText(keyDisplayName) - save.write_key_file() + + -- Update the menu item (index + 1 to account for slider at position 1) + local menuItemIndex = index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(keyDisplayName) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(false) + end + + self:refreshUI() end function InputConfigMenu:setKey(key, index) - local pressedKey = next(input.allKeys.isDown) + local pressedKey = next(inputManager.allKeys.isDown) if pressedKey then self:updateKey(key, pressedKey, index) self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) @@ -85,13 +123,20 @@ function InputConfigMenu:setKey(key, index) end function InputConfigMenu:setAllKeys() - local pressedKey = next(input.allKeys.isDown) + local pressedKey = next(inputManager.allKeys.isDown) if pressedKey then self:updateKey(consts.KEY_NAMES[self.index], pressedKey, self.index) if self.index < #consts.KEY_NAMES then self.index = self.index + 1 - self:currentKeyLabelForIndex(self.index + 1):setText(pendingInputText) - self.menu.selectedIndex = self.index + 1 + local menuItemIndex = self.index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(pendingInputText) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(true) + end + self.menu:setSelectedIndex(menuItemIndex) self:setSettingKeyState(KEY_SETTING_STATE.SETTING_ALL_KEYS_TRANSITION) else self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) @@ -99,10 +144,6 @@ function InputConfigMenu:setAllKeys() end end -function InputConfigMenu:currentKeyLabelForIndex(index) - return self.menu.menuItems[index].textButton.children[1] -end - function InputConfigMenu:setKeyStart(key) GAME.theme:playValidationSfx() self.key = key @@ -113,87 +154,206 @@ function InputConfigMenu:setKeyStart(key) break end end - self:currentKeyLabelForIndex(self.index + 1):setText(pendingInputText) - self.menu.selectedIndex = self.index + 1 + local menuItemIndex = self.index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(pendingInputText) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(true) + end + self.menu:setSelectedIndex(menuItemIndex) self:setSettingKeyState(KEY_SETTING_STATE.SETTING_KEY_TRANSITION) end function InputConfigMenu:setAllKeysStart() GAME.theme:playValidationSfx() self.index = 1 - self:currentKeyLabelForIndex(self.index + 1):setText(pendingInputText) - self.menu:setSelectedIndex(self.index + 1) + local menuItemIndex = self.index + 1 + local menuItem = self.menu.menuItems[menuItemIndex] + if menuItem.setBinding then + menuItem:setBinding(pendingInputText) + end + if menuItem.setSettingKey then + menuItem:setSettingKey(true) + end + self.menu:setSelectedIndex(menuItemIndex) self:setSettingKeyState(KEY_SETTING_STATE.SETTING_ALL_KEYS_TRANSITION) end function InputConfigMenu:clearAllInputs() GAME.theme:playValidationSfx() - for i, key in ipairs(consts.KEY_NAMES) do - GAME.input.inputConfigurations[self.configIndex][key] = nil - local keyName = loc("op_none") - self:currentKeyLabelForIndex(i + 1):setText(keyName) + local config = inputManager.inputConfigurations[self.configIndex] + inputManager:clearKeyBindingsOnInputConfiguration(config) + self:refreshUI() + self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) +end + +function InputConfigMenu:resetToDefault(menuOptions) + GAME.theme:playValidationSfx() + inputManager:setupDefaultKeyConfigurations() + GAME.theme:playMoveSfx() + self.slider:setValue(1) + self.configIndex = 1 + self:refreshUI() + self:setSettingKeyState(KEY_SETTING_STATE.NOT_SETTING) +end + +function InputConfigMenu:autoConfigureJoysticks() + + -- Auto-configure any newly connected joysticks + for _, joystick in ipairs(inputManager:getUnconfiguredJoysticks()) do + -- Use inputManager to perform the actual configuration + local configIndex = inputManager:autoConfigureJoystick(joystick, true) + + if configIndex then + -- Flag that a new controller was just configured + self.newInputsConfigured = true + + self.configIndex = configIndex + self.slider:setValue(configIndex) + self:refreshUI() + end end - save.write_key_file() end -function InputConfigMenu:resetToDefault(menuOptions) - GAME.theme:playValidationSfx() - local i = 1 - for keyName, key in pairs(input.defaultKeys) do - GAME.input.inputConfigurations[1][keyName] = key - self:currentKeyLabelForIndex(i + 1):setText(GAME.input.inputConfigurations[1][keyName]) - i = i + 1 +-- Signal handler called when an unconfigured joystick is added +function InputConfigMenu:onUnconfiguredJoystickAdded(joystick) + -- Auto-configure the joystick + local configIndex = inputManager:autoConfigureJoystick(joystick, true) + + if configIndex then + -- Flag that a new controller was just configured + self.newInputsConfigured = true + + -- Switch to the newly configured input + self.configIndex = configIndex + self.slider:setValue(configIndex) + self:refreshUI() end - for j = 2, input.maxConfigurations do - for _, key in ipairs(consts.KEY_NAMES) do - GAME.input.inputConfigurations[j][key] = nil +end + +function InputConfigMenu:createExitMenuFunction() + return function () + local currentConfig = inputManager.inputConfigurations[self.configIndex] + + -- Check if current configuration is half-configured + if not currentConfig:isEmpty() and not currentConfig:isFullyConfigured() then + return + end + + GAME.theme:playValidationSfx() + if inputManager.hasUnsavedChanges then + inputManager:saveInputConfigurationMappings() + end + if self.triggerNextScene then + self.triggerNextScene() + else + GAME.navigationStack:pop() end end - GAME.theme:playMoveSfx() - self.slider:setValue(1) - self:updateInputConfigMenuLabels(1) - save.write_key_file() end -local function exitMenu() - GAME.theme:playValidationSfx() - GAME.navigationStack:pop() -end +function InputConfigMenu:loadUI() -function InputConfigMenu:load(sceneParams) - self.configIndex = 1 + self.backgroundImg = themes[config.theme].images.bg_main + + -- Create a centered vertical stack panel for all content + local contentStack = ui.StackPanel({ + alignment = "top", + width = consts.CANVAS_WIDTH, + hAlign = "center", + vAlign = "center" + }) + + -- Header text + local headerText = ui.Label({ + text = "config_input_welcome", + hAlign = "center", + vAlign = "center", + fontSize = 16 + }) + contentStack:addElement(headerText) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- New controller message (conditionally visible) + self.newControllerLabel = ui.Label({ + text = "input_config_new_controller", + hAlign = "center", + vAlign = "center", + textColor = GAME.theme.colors.highlightTextColor, + fontSize = 14 + }) + self.newControllerLabel.isVisible = false + contentStack:addElement(self.newControllerLabel) + + contentStack:addElement(ui.UiElement({ + width = 1, + height = 10 + })) + + -- Create menu options local menuOptions = {} - self.slider = ui.Slider({ - min = 1, - max = input.maxConfigurations, - value = 1, - tickLength = 10, - onValueChange = function(slider) self:updateInputConfigMenuLabels(slider.value) end}) - menuOptions[1] = ui.MenuItem.createSliderMenuItem("configuration", nil, nil, self.slider) + + -- 1. Slider + self.slider = InputConfigSlider({ + value = self.configIndex, + onValueChange = function(slider) + self.configIndex = slider.value + self:refreshUI() + end + }) + menuOptions[1] = ui.SliderMenuItem.create({ + labelText = "configuration", + slider = self.slider + }) + + -- 2. Key binding items for i, key in ipairs(consts.KEY_NAMES) do - local clickFunction = function() - if not self.settingKey then - self:setKeyStart(key) + local keyDisplayName = self:getKeyDisplayName(inputManager.inputConfigurations[self.configIndex][key]) + local keyBindingItem = KeyBindingMenuItem.create({ + keyName = key, + bindingText = keyDisplayName, + onActivate = function() + if not self.settingKey then + self:setKeyStart(key) + end end - end - local keyName = self:getKeyDisplayName(GAME.input.inputConfigurations[self.configIndex][key]) - menuOptions[#menuOptions + 1] = ui.MenuItem.createLabeledButtonMenuItem(key, nil, false, keyName, nil, false, clickFunction) + }) + menuOptions[#menuOptions + 1] = keyBindingItem end + + -- 3. Action buttons menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("op_all_keys", nil, nil, function() self:setAllKeysStart() end) menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("Clear All Inputs", nil, false, function() self:clearAllInputs() end) - menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("Reset Keys To Default", nil, false, function() self:resetToDefault(menuOptions) end) - menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) + menuOptions[#menuOptions + 1] = ui.MenuItem.createButtonMenuItem("Reset Keys To Default", nil, false, function() self:resetToDefault() end) + + -- Back button with warning for incomplete configurations + self.backMenuItem = ui.MenuItem.createButtonMenuItem("back", nil, nil, self:createExitMenuFunction()) + menuOptions[#menuOptions + 1] = self.backMenuItem - self.menu = ui.Menu.createCenteredMenu(menuOptions) + self.menu = ui.Menu.createCenteredMenu(menuOptions, 0) + contentStack:addElement(self.menu) - self.uiRoot:addChild(self.menu) + self.uiRoot:addChild(contentStack) end function InputConfigMenu:update(dt) - self.backgroundImg:update(dt) - self.menu:receiveInputs() - local noKeysHeld = (tableUtils.first(input.allKeys.isPressed, function (value) + if self.backgroundImg then + self.backgroundImg:update(dt) + end + + -- Only allow menu navigation when not setting a key + if self.menu and not self.settingKey then + self.menu:receiveInputs() + end + + local noKeysHeld = (tableUtils.first(inputManager.allKeys.isPressed, function (value) return value < MAX_PRESS_DURATION end)) == nil @@ -210,6 +370,14 @@ function InputConfigMenu:update(dt) elseif self.settingKeyState == KEY_SETTING_STATE.SETTING_ALL_KEYS then self:setAllKeys() end + + self:refreshUI() +end + +function InputConfigMenu:refreshUI() + self.slider:refresh() + self.newControllerLabel.isVisible = self.newInputsConfigured + self:updateInputConfigMenuLabels() end function InputConfigMenu:draw() @@ -217,4 +385,4 @@ function InputConfigMenu:draw() self.uiRoot:draw() end -return InputConfigMenu \ No newline at end of file +return InputConfigMenu diff --git a/client/src/scenes/LanguageSelectSetup.lua b/client/src/scenes/LanguageSelectSetup.lua new file mode 100644 index 000000000..a4731cf88 --- /dev/null +++ b/client/src/scenes/LanguageSelectSetup.lua @@ -0,0 +1,78 @@ +local Scene = require("client.src.scenes.Scene") +local ui = require("client.src.ui") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") +local save = require("client.src.save") +local logger = require("common.lib.logger") + +local LanguageSelectSetup = class(function(self, sceneParams) + assert(sceneParams, "LanguageSelectSetup requires sceneParams") + assert(sceneParams.triggerNextScene, "LanguageSelectSetup requires triggerNextScene callback") + + self.music = "main" + self:load(sceneParams) +end, Scene) + +function LanguageSelectSetup:load(sceneParams) + -- Create a centered vertical stack panel for all content + local contentStack = ui.StackPanel({ + alignment = "top", + width = consts.CANVAS_WIDTH, + hAlign = "center", + vAlign = "center" + }) + self.uiRoot:addChild(contentStack) + + -- Title + local titleLabel = ui.Label({ + text = "Select your language", + translate = false, + hAlign = "center", + vAlign = "center" + }) + contentStack:addElement(titleLabel) + + -- Spacer for gap between title and menu + local spacer = ui.UiElement({ + width = 1, + height = 30 + }) + contentStack:addElement(spacer) + + -- Language selection menu + self.menu = self:createLanguageMenu() + contentStack:addElement(self.menu) +end + +LanguageSelectSetup.name = "LanguageSelectSetup" + +function LanguageSelectSetup:createLanguageMenu() + local languageMenuItems = {} + local languageData, languageLabels = Localization:getLanguageLabelsWithFonts() + + for i, language in ipairs(languageData) do + table.insert(languageMenuItems, ui.MenuItem.createButtonMenuItemWithLabel(languageLabels[i], function() + GAME.theme:playValidationSfx() + config.language_code = language.code + GAME:setLanguage(language.code) + write_conf_file() + self.triggerNextScene() + end)) + end + + local menu = ui.Menu.createCenteredMenu(languageMenuItems, 0, {supportsBackButton = false}) + return menu +end + +function LanguageSelectSetup:update(dt) + GAME.theme.images.bg_main:update(dt) + self.menu:receiveInputs() +end + +function LanguageSelectSetup:draw() + GAME.theme.images.bg_main:draw() + self.uiRoot:draw() +end + +return LanguageSelectSetup \ No newline at end of file diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index ffb9f72ca..43e6ee87b 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 7b7477f37..186967886 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 b2fe595b5..48f95dc03 100644 --- a/client/src/scenes/PuzzleMenu.lua +++ b/client/src/scenes/PuzzleMenu.lua @@ -1,4 +1,3 @@ -local Game = require("client.src.Game") local Scene = require("client.src.scenes.Scene") local consts = require("common.engine.consts") local logger = require("common.lib.logger") @@ -10,6 +9,7 @@ local PuzzleSetIterator = require("client.src.PuzzleSetIterator") local PuzzleHierarchyDisplay = require("client.src.graphics.PuzzleHierarchyDisplay") local PuzzleGame = require("client.src.scenes.PuzzleGame") local PuzzleEditorScene = require("client.src.scenes.PuzzleEditorScene") +local InputDeviceOverlay = require("client.src.scenes.components.InputDeviceOverlay") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local LevelPresets = require("common.data.LevelPresets") @@ -27,6 +27,7 @@ local Stack = require("common.engine.Stack") ---@field selectedIndexStack table stores selected menu index for each navigation level ---@field puzzlePreviewStack StackElement ---@field puzzleDescriptionLabel Label +---@field inputDeviceOverlay InputDeviceOverlay local PuzzleMenu = class( function (self, sceneParams) self.music = "select_screen" @@ -74,10 +75,12 @@ end function PuzzleMenu:startGame(puzzleSet, puzzleSetIterator) assert(puzzleSetIterator) - GAME.localPlayer:setLevel(config.puzzle_level) - GAME.localPlayer:setLevelData(LevelPresets.getModern(config.puzzle_level)) local player = self.battleRoom.players[1] + assert(player.inputConfiguration, "Player must have an input configuration assigned before starting puzzle game") + + GAME.localPlayer:setLevel(config.puzzle_level) + GAME.localPlayer:setLevelData(LevelPresets.getModern(config.puzzle_level)) -- Lock character and stage for the entire puzzle session -- This prevents them from changing between puzzles @@ -216,7 +219,25 @@ function PuzzleMenu:load(sceneParams) self.uiRoot:addChild(self.containerStackPanel) self.uiRoot:addChild(self.puzzleHierarchyDisplay) - + + self:createInputDeviceOverlay() +end + +function PuzzleMenu:createInputDeviceOverlay() + self.inputDeviceOverlay = InputDeviceOverlay({ + battleRoom = self.battleRoom, + onClose = function() + self:onInputDeviceOverlayClosed() + end, + onCancel = function() + self:exit() + end + }) + self.uiRoot:addChild(self.inputDeviceOverlay) +end + +function PuzzleMenu:onInputDeviceOverlayClosed() + -- Input device overlay closed, all assignments should be done end function PuzzleMenu:refreshMenu() @@ -675,8 +696,14 @@ function PuzzleMenu:updateCurrentPuzzleSet() end end -function PuzzleMenu:update(dt) - self.menu:receiveInputs() + +function PuzzleMenu:updateSelf(dt) + local overlayActive = self.inputDeviceOverlay and self.inputDeviceOverlay:isActive() + if overlayActive then + return + end + + self.menu:receiveInputs(GAME.input, dt) end function PuzzleMenu:draw() diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index 0270b7ccd..bd64d66bf 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -17,10 +17,12 @@ local DebugSettings = require("client.src.debug.DebugSettings") ---@field music sceneMusic ---@field fallbackMusic sceneMusic ---@field keepMusic boolean +---@field triggerNextScene function? ---@overload fun(sceneParams: table): Scene local Scene = class( ---@param self Scene function (self, sceneParams) + sceneParams = sceneParams or {} self.uiRoot = ui.UiElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) directsFocus(self.uiRoot) -- scenes may specify theme music to use that is played once they are switched to @@ -35,6 +37,9 @@ local Scene = class( -- the scene can alternatively specify it wants to keep the music that is currently playing -- if kept at false, the music will always change at scene switch self.keepMusic = false + -- callback provided by scene creator to trigger the next scene + -- scenes should call this when they complete their purpose + self.triggerNextScene = sceneParams.triggerNextScene end ) diff --git a/client/src/scenes/SceneCoordinator.lua b/client/src/scenes/SceneCoordinator.lua new file mode 100644 index 000000000..e0e94cc39 --- /dev/null +++ b/client/src/scenes/SceneCoordinator.lua @@ -0,0 +1,110 @@ +local logger = require("common.lib.logger") +local input = require("client.src.inputManager") +local TitleScreen = require("client.src.scenes.TitleScreen") +local MainMenu = require("client.src.scenes.MainMenu") +local ModLoader = require("client.src.mods.ModLoader") +local ModValidationScene = require("client.src.scenes.ModValidationScene") +local LanguageSelectSetup = require("client.src.scenes.LanguageSelectSetup") +local DiscordCommunitySetup = require("client.src.scenes.DiscordCommunitySetup") +local InputConfigMenu = require("client.src.scenes.InputConfigMenu") + +---@class SceneCoordinator +---@field joystickAdded boolean +local SceneCoordinator = { + startupComplete = false +} + +-- Called when an unconfigured joystick is added +-- Pushes InputConfigMenu if we're not in the middle of a game +function SceneCoordinator:onUnconfiguredJoystickAdded(joystick) + -- Check if we're in a game (BattleRoom exists and has an active match) + local inGame = false + if GAME.battleRoom and GAME.battleRoom.match ~= nil then + inGame = true + end + + if self.startupComplete and not inGame then + -- Not in a game, so push the InputConfigMenu + GAME.navigationStack:push(InputConfigMenu({})) + end +end + +-- Called when the StartUp scene completes asset loading +-- Begins the setup flow sequence +function SceneCoordinator:handleStartupComplete() + self.startupComplete = true + + if themes[config.theme].images.bg_title then + GAME.navigationStack:replace(TitleScreen({ + triggerNextScene = function() + self:handleTitleScreenComplete() + end + })) + else + self:handleTitleScreenComplete() + end + + if next(ModLoader.invalidMods) then + GAME.navigationStack:replace(ModValidationScene()) + end +end + +function SceneCoordinator:handleTitleScreenComplete() + self:continueSetupFlow() +end + +function SceneCoordinator:continueSetupFlow() + + -- Check language selection + if not config.language_code then + self:showLanguageSelect() + return true + end + + -- Check Discord community welcome + if not config.discordCommunityShown then + self:showDiscordWelcome() + return true + end + + -- Check for unconfigured joysticks + if input.hasUnsavedChanges or input:hasUnconfiguredJoysticks() then + self:showInputConfig() + return true + end + + GAME.navigationStack:replace(MainMenu({})) + return true +end + +-- Shows the language selection scene with completion callback +function SceneCoordinator:showLanguageSelect() + local scene = LanguageSelectSetup({ + triggerNextScene = function() + self:continueSetupFlow() + end + }) + GAME.navigationStack:replace(scene) +end + +-- Shows the Discord welcome scene with completion callback +function SceneCoordinator:showDiscordWelcome() + local scene = DiscordCommunitySetup({ + triggerNextScene = function() + self:continueSetupFlow() + end + }) + GAME.navigationStack:replace(scene) +end + +-- Shows the input configuration scene with completion callback +function SceneCoordinator:showInputConfig() + local scene = InputConfigMenu({ + triggerNextScene = function() + self:continueSetupFlow() + end + }) + GAME.navigationStack:replace(scene) +end + +return SceneCoordinator diff --git a/client/src/scenes/StartUp.lua b/client/src/scenes/StartUp.lua index ab135b3d1..e345a20ce 100644 --- a/client/src/scenes/StartUp.lua +++ b/client/src/scenes/StartUp.lua @@ -50,16 +50,9 @@ function StartUp:updateSelf(dt) if coroutine.status(self.setupRoutine) == "dead" then love.graphics.setFont(GraphicsUtil.getGlobalFont()) - -- we need the indirection for the scenes here because startup initializes localization which following scenes need - if themes[config.theme].images.bg_title then - GAME.navigationStack:replace(require("client.src.scenes.TitleScreen")()) - else - GAME.navigationStack:replace(require("client.src.scenes.MainMenu")()) - end - - if next(ModLoader.invalidMods) then - GAME.navigationStack:push(require("client.src.scenes.ModValidationScene")()) - end + -- Delegate to SceneCoordinator to handle initial scene and setup flow + local SceneCoordinator = require("client.src.scenes.SceneCoordinator") + SceneCoordinator.handleStartupComplete(SceneCoordinator) end end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index c846420e7..41fc73388 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -103,6 +103,9 @@ function TimeAttackMenu:loadUserInterface() self.ui.pageTurnButtons = self:createPageTurnButtons(self.ui.characterGrid) + self.ui.changeInputButton = self:createChangeInputButton() + self.ui.grid:createElementAt(8, 6, 1, 1, "changeInputButton", self.ui.changeInputButton) + self.ui.leaveButton = self:createLeaveButton() self.ui.grid:createElementAt(9, 6, 1, 1, "leaveButton", self.ui.leaveButton) diff --git a/client/src/scenes/TitleScreen.lua b/client/src/scenes/TitleScreen.lua index 05fc98972..93e37ace6 100644 --- a/client/src/scenes/TitleScreen.lua +++ b/client/src/scenes/TitleScreen.lua @@ -4,7 +4,6 @@ local input = require("client.src.inputManager") local tableUtils = require("common.lib.tableUtils") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local MainMenu = require("client.src.scenes.MainMenu") -- The title screen scene local TitleScreen = class( @@ -30,7 +29,7 @@ function TitleScreen:update(dt) local keyPressed = tableUtils.trueForAny(input.allKeys.isDown, function(key) return key end) if love.mouse.isDown(1, 2, 3) or #love.touch.getTouches() > 0 or keyPressed then GAME.theme:playValidationSfx() - GAME.navigationStack:replace(MainMenu()) + self.triggerNextScene() end end diff --git a/client/src/scenes/components/InputDeviceOverlay.lua b/client/src/scenes/components/InputDeviceOverlay.lua new file mode 100644 index 000000000..f718e990e --- /dev/null +++ b/client/src/scenes/components/InputDeviceOverlay.lua @@ -0,0 +1,578 @@ +local class = require("common.lib.class") +local tableUtils = require("common.lib.tableUtils") +local UiElement = require("client.src.ui.UIElement") +local Label = require("client.src.ui.Label") +local TextButton = require("client.src.ui.TextButton") +local StackPanel = require("client.src.ui.StackPanel") +local PlayerInputDeviceSlot = require("client.src.scenes.components.PlayerInputDeviceSlot") +local inputManager = require("client.src.inputManager") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local consts = require("common.engine.consts") +local logger = require("common.lib.logger") +local Scene = require("client.src.scenes.Scene") +local directsFocus = require("client.src.ui.FocusDirector") + +local HOLD_THRESHOLD = 0.25 +local AUTO_CLOSE_DELAY = 0.25 +local PLAYER_SLOT_SIZE = 150 + +-- Modal overlay that blocks game start until all local players have assigned input devices using hold-to-confirm interaction +---@class InputDeviceOverlay : UiElement +---@field battleRoom BattleRoom Reference to battle room for player/device management +---@field holdThreshold number Duration in seconds required to confirm assignment (default 0.25) +---@field active boolean True when overlay is open and processing input +---@field hasFocus boolean True when overlay has keyboard/input focus (managed by FocusDirector) +---@field playerSlots PlayerInputDeviceSlot[] Array of player slot UI elements +---@field deviceState table Tracks hold state per device +---@field touchTargetSlot PlayerInputDeviceSlot? Slot where touch started (locked for duration of touch) +---@field autoCloseTimer number Timer for auto-closing after all assignments complete +---@field escapeHoldTime number Duration escape key has been held +---@field onClose fun()? Callback invoked when overlay closes +---@field onCancel fun()? Callback invoked when user cancels overlay +---@field titleLabel Label Title text element +---@field subtitleLabel Label Subtitle text element +---@field slotsContainer StackPanel Container for player slots +---@field backButton TextButton Button to exit input configuration +---@field cancelHintLabel Label Hint text for escape key to cancel + +---@class InputDeviceOverlayOptions +---@field battleRoom BattleRoom +---@field holdThreshold number? +---@field onClose fun()? +---@field onCancel fun()? Callback when user presses back/cancel + +---@param options InputDeviceOverlayOptions +local InputDeviceOverlay = class(function(self, options) + options = options or {} + self.battleRoom = options.battleRoom + self.holdThreshold = options.holdThreshold or HOLD_THRESHOLD + self.onClose = options.onClose + self.onCancel = options.onCancel + + self.active = false + self.playerSlots = {} + self.deviceState = {} + self.touchTargetSlot = nil + self.autoCloseTimer = 0 + self.escapeHoldTime = 0 + self.width = consts.CANVAS_WIDTH + self.height = consts.CANVAS_HEIGHT + self:setVisibility(false) + + directsFocus(self) + self:buildUi() +end, UiElement, "InputDeviceOverlay") + +---@param config InputConfiguration Input configuration object +---@return number? Maximum hold duration across all checked keys +local function getHoldDurationForInputConfiguration(config) + local maxDuration = 0 + + for _, alias in ipairs(consts.KEY_NAMES) do + local duration = config.isPressed[alias] + if duration and duration > maxDuration then + maxDuration = duration + end + end + + return maxDuration +end + + +-- Builds the main UI elements for the overlay +function InputDeviceOverlay:buildUi() + -- Title + self.titleLabel = Label({ + text = "press_button_device", + hAlign = "center", + vAlign = "top", + y = 60, + fontSize = GraphicsUtil.fontSize + 4 + }) + self:addChild(self.titleLabel) + + -- Subtitle + self.subtitleLabel = Label({ + text = "or_touch_player_slot", + hAlign = "center", + vAlign = "top", + y = 100, + fontSize = GraphicsUtil.fontSize + }) + self:addChild(self.subtitleLabel) + + -- Player slots container + self.slotsContainer = StackPanel({ + alignment = "left", + hAlign = "center", + vAlign = "center", + height = PLAYER_SLOT_SIZE + }) + self:addChild(self.slotsContainer) + + self.backButton = TextButton({ + label = Label({ + text = "back" + }), + hAlign = "center", + vAlign = "bottom", + y = -10, + onClick = function() + self:onBackPressed() + end + }) + self:addChild(self.backButton) +end + +---@return Player[] Array of local human players +function InputDeviceOverlay:getLocalPlayers() + assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") + return self.battleRoom:getLocalHumanPlayers() +end + +-- Gets the next player that needs device assignment +---@return Player? Next unassigned player or nil if all assigned +function InputDeviceOverlay:getNextUnassignedPlayer() + for _, player in ipairs(self:getLocalPlayers()) do + if not self.battleRoom:isPlayerAssigned(player) then + return player + end + end + return nil +end + +---@return PlayerInputDeviceSlot? Player slot under mouse cursor or nil +function InputDeviceOverlay:getPlayerSlotForTouch() + for _, slot in ipairs(self.playerSlots) do + if slot:isMouseOver() then + return slot + end + end + return nil +end + +function InputDeviceOverlay:buildPlayerSlots() + self.playerSlots = {} + while #self.slotsContainer.children > 0 do + self.slotsContainer:remove(self.slotsContainer.children[1]) + end + + local players = self:getLocalPlayers() + for i, player in ipairs(players) do + local slot = PlayerInputDeviceSlot({playerNumber = i, parentOverlay = self}) + self.playerSlots[i] = slot + self.slotsContainer:addElement(slot) + + -- Add spacing after each slot except the last + if i < #players then + local spacer = UiElement({ + width = 20, + height = PLAYER_SLOT_SIZE + }) + self.slotsContainer:addElement(spacer) + end + + -- Check if player is already assigned + local assignedDevice = self:getAssignedDeviceForPlayer(player) + if assignedDevice then + slot:setAssignedDevice(assignedDevice) + end + end +end + +---@param player Player +---@return InputConfiguration? Input configuration if player is assigned, nil otherwise +function InputDeviceOverlay:getAssignedDeviceForPlayer(player) + if not self.battleRoom or not player then + return nil + end + + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config then + local assignedPlayer = self.battleRoom:getPlayerAssignedToDevice(config) + if assignedPlayer == player then + return config + end + end + end + return nil +end + +function InputDeviceOverlay:updatePlayerSlots() + if not self.playerSlots then + return + end + + for i, slot in ipairs(self.playerSlots) do + if slot and slot.setAssignedDevice then + local players = self:getLocalPlayers() + if players then + local player = players[i] + if player then + local assignedDevice = self:getAssignedDeviceForPlayer(player) + slot:setAssignedDevice(assignedDevice) + end + end + end + end +end + + + + +-- Assigns a device to a player and plays feedback +---@param config InputConfiguration Input configuration to assign +---@param targetPlayer Player? Player to assign to, or nil to assign to next unassigned player +function InputDeviceOverlay:assignDevice(config, targetPlayer) + assert(config, "config is required") + assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") + + if not targetPlayer then + targetPlayer = self:getNextUnassignedPlayer() + end + + if not targetPlayer then + return + end + + local success = self.battleRoom:claimDeviceForPlayer(targetPlayer, config) + if success then + if GAME.theme and GAME.theme.playValidationSfx then + GAME.theme:playValidationSfx() + end + self:updatePlayerSlots() + + -- Trigger pop animation on the slot that was just assigned + local players = self:getLocalPlayers() + for i, player in ipairs(players) do + if player == targetPlayer and self.playerSlots[i] then + self.playerSlots[i]:triggerPopAnimation() + break + end + end + + if self.battleRoom:areLocalPlayersAssigned() then + self.autoCloseTimer = AUTO_CLOSE_DELAY + end + end +end + +-- Processes hold input for a configuration device +---@param config InputConfiguration Input configuration for controller/keyboard +---@param dt number Delta time in seconds +function InputDeviceOverlay:processConfigHold(config, dt) + assert(config, "Config is required") + assert(type(dt) == "number", "dt must be numeric") + + if config.claimed == true then + return + end + + local state = self.deviceState[config.id] + if not state then + state = {confirmTriggered = false, holdTime = 0} + self.deviceState[config.id] = state + end + + local confirmDuration = getHoldDurationForInputConfiguration(config) + if confirmDuration and confirmDuration > 0 then + state.holdTime = confirmDuration + else + state.holdTime = 0 + end + + -- Update visual feedback on all slots + local progress = math.min(state.holdTime / self.holdThreshold, 1) + for i, slot in ipairs(self.playerSlots) do + if not slot.assignedDevice and progress > 0 then + self.escapeHoldTime = 0 + slot:setHoldProgress(progress, config.deviceType) + break + end + end + + if state.holdTime >= self.holdThreshold and not state.confirmTriggered then + self:assignDevice(config) + state.confirmTriggered = true + elseif state.holdTime < self.holdThreshold then + state.confirmTriggered = false + end +end + + +-- Updates touch hold state and visual feedback +---@param dt number Delta time in seconds +function InputDeviceOverlay:updateTouchHold(dt) + local touchDescriptor = self:getTouchDescriptor() + if not touchDescriptor then + return + end + + local holding = self:isMouseHolding() + if holding then + self:processTouchHold(dt, touchDescriptor) + else + self:clearTouchTarget() + end +end + +-- Checks if mouse is currently being held down +---@return boolean True if mouse button 1 is held +function InputDeviceOverlay:isMouseHolding() + local mousePressed = inputManager.mouse.isPressed[1] + local mouseDown = inputManager.mouse.isDown[1] + return (type(mousePressed) == "number" and mousePressed > 0) or type(mouseDown) == "number" +end + +-- Checks if touch device is already assigned to any player +---@param touchConfig InputConfiguration Touch input configuration +---@return boolean True if touch is already assigned +function InputDeviceOverlay:isTouchAlreadyAssigned(touchConfig) + if not self.battleRoom then + return false + end + + local assignedPlayer = self.battleRoom:getPlayerAssignedToDevice(touchConfig) + return assignedPlayer ~= nil +end + +-- Processes touch hold logic when mouse is held down +---@param dt number Delta time in seconds +---@param touchConfig InputConfiguration Touch input configuration +function InputDeviceOverlay:processTouchHold(dt, touchConfig) + -- Don't allow claiming another slot if touch is already assigned + if self:isTouchAlreadyAssigned(touchConfig) then + self:clearTouchTarget() + return + end + + -- Lock to initial slot where touch started + if not self.touchTargetSlot then + local slotUnderMouse = self:getPlayerSlotForTouch() + if not slotUnderMouse then + self:clearTouchTarget() + return + end + self.touchTargetSlot = slotUnderMouse + self.touchTargetSlot:setTouchTarget(true) + end + + local targetSlot = self.touchTargetSlot + + local state = self.deviceState[touchConfig.id] + if not state then + state = {confirmTriggered = false, holdTime = 0} + self.deviceState[touchConfig.id] = state + end + + state.holdTime = state.holdTime + dt + self.escapeHoldTime = 0 + + local progress = math.min(state.holdTime / self.holdThreshold, 1) + targetSlot:setHoldProgress(progress, "touch") + + if state.holdTime >= self.holdThreshold and not state.confirmTriggered then + self:assignTouchToSlot(touchConfig, targetSlot) + state.confirmTriggered = true + elseif state.holdTime < self.holdThreshold then + state.confirmTriggered = false + end +end + + + +-- Assigns touch device to specific slot +---@param touchConfig InputConfiguration Touch input configuration +---@param targetSlot PlayerInputDeviceSlot Slot to assign touch to +function InputDeviceOverlay:assignTouchToSlot(touchConfig, targetSlot) + local players = self:getLocalPlayers() + for i, slot in ipairs(self.playerSlots) do + if slot == targetSlot then + local targetPlayer = players[i] + if targetPlayer then + self:assignDevice(touchConfig, targetPlayer) + end + break + end + end +end + +function InputDeviceOverlay:clearTouchTarget() + if self.touchTargetSlot then + self.touchTargetSlot:setTouchTarget(false) + self.touchTargetSlot:setHoldProgress(0, nil) + self.touchTargetSlot = nil + end + -- Clear touch device state + local touchConfig = self:getTouchDescriptor() + if touchConfig then + local state = self.deviceState[touchConfig.id] + if state then + state.holdTime = 0 + state.confirmTriggered = false + end + end +end + +---@return InputConfiguration? Touch input configuration or nil if not found +function InputDeviceOverlay:getTouchDescriptor() + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType == "touch" then + return config + end + end + return nil +end + +-- Checks if any button is currently being pressed on any device +---@return boolean True if any device has active input +function InputDeviceOverlay:isAnyButtonCurrentlyPressed() + -- Check if mouse is being held (for touch) + if self:isMouseHolding() then + return true + end + + if tableUtils.length(inputManager.allKeys.isPressed) > 0 then + return true + end + + return false +end + +---@param dt number Delta time in seconds +function InputDeviceOverlay:updateSelf(dt) + if not self.active then + if not self.battleRoom.spectating and GAME.input:checkForUnassignedConfigurationInputs(self.battleRoom) then + self.battleRoom:releaseAllLocalAssignments() + end + + self:openInputDeviceOverlayIfNeeded() + + return + end + + for _, slot in ipairs(self.playerSlots) do + if not slot.assignedDevice then + slot:setHoldProgress(0, nil) + end + end + + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType ~= "touch" then + self:processConfigHold(config, dt) + end + end + + self:updateTouchHold(dt) + + self:receiveInputs(GAME.input, dt) + + -- Handle auto-close timer + if self.autoCloseTimer > 0 and not self:isAnyButtonCurrentlyPressed() then + self.autoCloseTimer = self.autoCloseTimer - dt + if self.autoCloseTimer <= 0 then + self:close() + end + end +end + +function InputDeviceOverlay:openInputDeviceOverlayIfNeeded() + if self.active then + return + end + + local hasLocalPlayers = #self.battleRoom:getLocalHumanPlayers() > 0 + if not hasLocalPlayers then + return + end + + if not self.battleRoom.hasShutdown and not self.battleRoom:areLocalPlayersAssigned() then + self:open() + end +end + +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function InputDeviceOverlay:drawSelf() + if not self.active then + return + end + + local bgColor = GAME.theme.colors.darkTransparentBackgroundColor + GraphicsUtil.setColor(bgColor[1], bgColor[2], bgColor[3], bgColor[4]) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +function InputDeviceOverlay:open() + assert(self.battleRoom, "InputDeviceOverlay requires a battleRoom reference") + + self.deviceState = {} + self.touchTargetSlot = nil + self.autoCloseTimer = 0 + self.active = true + self:setVisibility(true) + + self:buildPlayerSlots() +end + +function InputDeviceOverlay:close() + if not self.active then + return + end + + self.active = false + self:setVisibility(false) + self:clearTouchTarget() + self.autoCloseTimer = 0 + self:setFocus(nil) + + if self.onClose then + self.onClose() + end +end + +---@return boolean True if overlay is currently active +function InputDeviceOverlay:isActive() + return self.active +end + +-- Handles back button press - closes overlay and invokes cancel callback +function InputDeviceOverlay:onBackPressed() + GAME.theme:playCancelSfx() + self:close() + if self.onCancel then + self.onCancel() + end +end + +---@return boolean? True to block touch event propagation +function InputDeviceOverlay:onTouch() + if self.active then + return true + end +end + +---@return boolean? True to block release event propagation +function InputDeviceOverlay:onRelease() + if self.active then + return true + end +end + +function InputDeviceOverlay:receiveInputs(input, dt) + + if input.isDown["MenuEsc"] then + self.escapeHoldTime = self.escapeHoldTime + dt + elseif input.isPressed["MenuEsc"] and self.escapeHoldTime > 0 then + self.escapeHoldTime = self.escapeHoldTime + dt + if self.escapeHoldTime >= self.holdThreshold then + self:onBackPressed() + self.escapeHoldTime = 0 + end + else + self.escapeHoldTime = 0 + end +end + +return InputDeviceOverlay diff --git a/client/src/scenes/components/PlayerInputDeviceSlot.lua b/client/src/scenes/components/PlayerInputDeviceSlot.lua new file mode 100644 index 000000000..c71f67e68 --- /dev/null +++ b/client/src/scenes/components/PlayerInputDeviceSlot.lua @@ -0,0 +1,252 @@ +local class = require("common.lib.class") +local UiElement = require("client.src.ui.UIElement") +local ImageContainer = require("client.src.ui.ImageContainer") +local inputManager = require("client.src.inputManager") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") + +local PLAYER_SLOT_SIZE = 150 +local DEVICE_ICON_SIZE = 64 + +-- Visual UI element representing one player's input device assignment status +---@class PlayerInputDeviceSlot : UiElement +---@field playerNumber number Visual player index (1, 2, etc.) +---@field assignedDevice InputConfiguration? Input configuration if assigned, nil otherwise +---@field holdProgress number Current hold progress from 0-1 +---@field pendingDeviceType string? Device type being held during assignment (keyboard/controller/touch) +---@field playerImage ImageContainer? Player number icon from theme +---@field deviceIcon UiElement? Device icon showing keyboard/controller/touch type +---@field isTargetedForTouch boolean True when mouse is hovering over this slot for touch assignment +---@field parentOverlay InputDeviceOverlay? Reference to parent overlay for accessing device configs +---@field popScale number Current scale for pop animation (1.0 = normal, >1.0 = enlarged) +---@field popAnimationSpeed number Speed of pop animation decay per second + +---@param options {playerNumber: number, parentOverlay: InputDeviceOverlay} +local PlayerInputDeviceSlot = class(function(self, options) + local playerNumber = options.playerNumber or options + self.playerNumber = playerNumber + self.assignedDevice = nil + self.holdProgress = 0 + self.pendingDeviceType = nil + self.isTargetedForTouch = false + self.parentOverlay = options.parentOverlay + self.popScale = 1.0 + self.maxPopScale = 1.12 + self.popAnimationSpeed = 1 + + -- Set size after parent initialization + self.width = PLAYER_SLOT_SIZE + self.height = PLAYER_SLOT_SIZE + + self:createPlayerNumberImage() +end, UiElement) + +function PlayerInputDeviceSlot:drawSelf() + love.graphics.push() + + -- Apply scale animation from center of slot + if self.popScale ~= 1.0 then + local centerX = self.x + self.width / 2 + local centerY = self.y + self.height / 2 + love.graphics.translate(centerX, centerY) + love.graphics.scale(self.popScale, self.popScale) + love.graphics.translate(-centerX, -centerY) + end + + self:drawSlotBackground(self) + self:drawSlotBorder(self) + + love.graphics.pop() +end + +-- Draws slot background with progress-based color transitions +---@param slot PlayerInputDeviceSlot +function PlayerInputDeviceSlot:drawSlotBackground(slot) + local progress = self.holdProgress or 0 + local bgColor = self:getBackgroundColor(progress) + GraphicsUtil.setColor(bgColor[1], bgColor[2], bgColor[3], bgColor[4]) + GraphicsUtil.drawRectangle("fill", self.x, self.y, slot.width, slot.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Draws slot border with progress-based color transitions +---@param slot PlayerInputDeviceSlot +function PlayerInputDeviceSlot:drawSlotBorder(slot) + local progress = self.holdProgress or 0 + local borderColor = self:getBorderColor(progress) + GraphicsUtil.setColor(borderColor[1], borderColor[2], borderColor[3], borderColor[4]) + GraphicsUtil.drawRectangle("line", self.x, self.y, slot.width, slot.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +-- Gets background color based on assignment and progress +---@param progress number Hold progress from 0-1 +---@return table Color array {r, g, b, a} +function PlayerInputDeviceSlot:getBackgroundColor(progress) + if self.assignedDevice then + return GAME.theme.colors.activeBackgroundColor + else + -- Interpolate from inputSlotDefaultBackgroundColor to inputSlotSelectedBackgroundColor + local defaultColor = GAME.theme.colors.inputSlotDefaultBackgroundColor + local selectedColor = GAME.theme.colors.inputSlotSelectedBackgroundColor + return { + defaultColor[1] + (selectedColor[1] - defaultColor[1]) * progress, + defaultColor[2] + (selectedColor[2] - defaultColor[2]) * progress, + defaultColor[3] + (selectedColor[3] - defaultColor[3]) * progress, + defaultColor[4] + (selectedColor[4] - defaultColor[4]) * progress, + } + end +end + +-- Gets border color based on assignment and progress +---@param progress number Hold progress from 0-1 +---@return table Color array {r, g, b, a} +function PlayerInputDeviceSlot:getBorderColor(progress) + if self.assignedDevice then + return GAME.theme.colors.inputSlotSelectedBorderColor + else + -- Interpolate from inputSlotDefaultBorderColor to inputSlotSelectedBorderColor + local defaultColor = GAME.theme.colors.inputSlotDefaultBorderColor + local selectedColor = GAME.theme.colors.inputSlotSelectedBorderColor + return { + defaultColor[1] + (selectedColor[1] - defaultColor[1]) * progress, + defaultColor[2] + (selectedColor[2] - defaultColor[2]) * progress, + defaultColor[3] + (selectedColor[3] - defaultColor[3]) * progress, + defaultColor[4] + (selectedColor[4] - defaultColor[4]) * progress, + } + end +end + +-- Creates the player number image from theme +function PlayerInputDeviceSlot:createPlayerNumberImage() + local playerIcon = GAME.theme:getPlayerNumberIcon(self.playerNumber) + assert(playerIcon, string.format("Missing player %d icon in current theme", self.playerNumber)) + + self.playerImage = ImageContainer({ + image = playerIcon, + hAlign = "center", + vAlign = "top", + y = 14, + scale = 2 + }) + self:addChild(self.playerImage) +end + +-- Sets the assigned device for this player slot +---@param config InputConfiguration? Input configuration or nil +function PlayerInputDeviceSlot:setAssignedDevice(config) + self.assignedDevice = config +end + +-- Updates the device icon based on current assignment or hold progress +function PlayerInputDeviceSlot:updateDeviceIcon() + if self.deviceIcon then + self.deviceIcon:detach() + self.deviceIcon = nil + end + + -- Show icon for assigned device OR pending device during hold + local deviceType = nil + if self.assignedDevice and self.assignedDevice.deviceType then + deviceType = self.assignedDevice.deviceType + elseif self.pendingDeviceType and self.holdProgress > 0 then + deviceType = self.pendingDeviceType + end + + if deviceType then + local iconElement = UiElement({ + width = DEVICE_ICON_SIZE, + height = DEVICE_ICON_SIZE, + hAlign = "center", + vAlign = "center" + }) + + -- Intentional override + ---@diagnostic disable-next-line: duplicate-set-field + iconElement.drawSelf = function(icon) + -- Device icon transitions from grey to blue based on progress + local progress = self.holdProgress or 0 + local alpha + if self.assignedDevice then + -- Assigned device: full opacity + alpha = 1 + else + -- Pending device: grey to blue transition (fade in) + alpha = 0.4 + progress * 0.6 + end + + local centerX = icon.width / 2 + local centerY = icon.height / 2 + + -- Get pre-calculated controller image variant and device number from config + local controllerImageVariant = nil + local deviceNumber = nil + + if self.assignedDevice then + -- Use pre-calculated values from assigned device config + controllerImageVariant = self.assignedDevice.controllerImageVariant + deviceNumber = self.assignedDevice.deviceNumber + elseif self.pendingDeviceType and self.parentOverlay then + -- For pending devices, find the config being held to show specific controller icon + for _, config in ipairs(inputManager:getAssignableDevices()) do + if config.deviceType == self.pendingDeviceType then + local state = self.parentOverlay.deviceState[config.id] + if state and state.holdTime > 0 then + controllerImageVariant = config.controllerImageVariant + deviceNumber = config.deviceNumber + break + end + end + end + end + + -- Render the device icon with number if applicable + InputPromptRenderer.renderIconWithNumber(deviceType, centerX, centerY, DEVICE_ICON_SIZE, alpha, controllerImageVariant, deviceNumber) + end + + self.deviceIcon = iconElement + self:addChild(iconElement) + end +end + +-- Sets hold progress and pending device type for visual feedback +---@param progress number Hold progress from 0-1 +---@param pendingDeviceType string? Device type being held (keyboard/controller/touch) +function PlayerInputDeviceSlot:setHoldProgress(progress, pendingDeviceType) + self.holdProgress = math.max(0, math.min(1, progress)) + self.pendingDeviceType = pendingDeviceType +end + +---@param isTarget boolean True when mouse is hovering over this slot +function PlayerInputDeviceSlot:setTouchTarget(isTarget) + self.isTargetedForTouch = isTarget +end + +---@param dt number Delta time in seconds +function PlayerInputDeviceSlot:updateSelf(dt) + -- Update device icon if needed during each frame + self:updateDeviceIcon() + + -- Animate pop scale back to 1.0 + if self.popScale > 1.0 then + self.popScale = self.popScale - (self.popAnimationSpeed * dt) + if self.popScale < 1.0 then + self.popScale = 1.0 + end + end +end + +-- Triggers pop animation when assignment completes +function PlayerInputDeviceSlot:triggerPopAnimation() + self.popScale = self.maxPopScale +end + +-- Checks if mouse cursor is over this player slot +---@return boolean True if mouse is over this slot +function PlayerInputDeviceSlot:isMouseOver() + local mx, my = inputManager.mouse.x, inputManager.mouse.y + local x, y = self:getScreenPos() + return mx >= x and mx <= x + self.width and my >= y and my <= y + self.height +end + +return PlayerInputDeviceSlot diff --git a/client/src/ui/ChangeInputButton.lua b/client/src/ui/ChangeInputButton.lua new file mode 100644 index 000000000..1da6f475a --- /dev/null +++ b/client/src/ui/ChangeInputButton.lua @@ -0,0 +1,207 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local Button = require(PATH .. ".Button") +local Label = require(PATH .. ".Label") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local inputManager = require("client.src.inputManager") +local InputPromptRenderer = require("client.src.graphics.InputPromptRenderer") +local StackPanel = require(PATH .. ".StackPanel") +local UiElement = require(PATH .. ".UIElement") + +---@class ChangeInputButtonOptions : ButtonOptions +---@field battleRoom BattleRoom? +---@field onChangeInputRequested fun()? + +-- Button that displays current player input assignments and allows changing them +---@class ChangeInputButton : Button +---@field battleRoom BattleRoom? Reference to battle room for querying player assignments +---@field onChangeInputRequested fun() Callback invoked when button is clicked to change inputs +---@field titleLabel Label Title text label +---@field iconContainer StackPanel Container for player assignment icons +---@field signalConnections table[] Array of signal subscriptions for live updates +local ChangeInputButton = class( + function(self, options) + options = options or {} + + self.battleRoom = options.battleRoom + self.onChangeInputRequested = options.onChangeInputRequested or function() end + self.signalConnections = {} + + local width = 80 + + self.titleLabel = Label({ + -- fontSize = 8, + wrapWidth = width, + text = "change_input_device", + }) + self.titleLabel.hAlign = "center" + + self.iconContainer = StackPanel({ + alignment = "top", + hAlign = "center", + vAlign = "center", + width = width + }) + + self:addChild(self.iconContainer) + + self.onClick = function(selfElement, inputSource, holdTime) + selfElement.onChangeInputRequested() + end + self.onSelect = self.onClick + + -- Subscribe to player signals and update initial state + self:subscribeToPlayerSignals() + self:updateSummary() + end, + Button +) + + +function ChangeInputButton:onResize() + -- Icon container has fixed width now +end + +function ChangeInputButton:updateSummary() + -- Clear existing player rows + while #self.iconContainer.children > 0 do + self.iconContainer:remove(self.iconContainer.children[1]) + end + + if not self.battleRoom then + self.isEnabled = true + return + end + + local players = self.battleRoom:getLocalHumanPlayers() + if #players == 0 then + self.isEnabled = true + return + end + + self.iconContainer:addElement(self.titleLabel) + + local spacer = UiElement({ + width = 1, + height = 4 + }) + self.iconContainer:addElement(spacer) + + -- Create a row for each player + for i, player in ipairs(players) do + self:addPlayerRow(player, i) + + -- Add spacing between player rows (except after last) + if i < #players then + spacer = UiElement({ + width = 1, + height = 4 + }) + self.iconContainer:addElement(spacer) + end + end + + self.isEnabled = true +end + +local iconSize = 20 + +---@param player Player +---@param playerIndex number +function ChangeInputButton:addPlayerRow(player, playerIndex) + -- Create horizontal StackPanel for this player's row + local playerRow = StackPanel({ + alignment = "left", + height = iconSize, + hAlign = "center" + }) + + self:addPlayerIcons(playerRow, player, playerIndex) + self.iconContainer:addElement(playerRow) +end + +---@param playerRow StackPanel +---@param player Player +---@param playerIndex number +function ChangeInputButton:addPlayerIcons(playerRow, player, playerIndex) + + -- Add player number icon (P1, P2, etc.) + local playerIcon = UiElement({ + x = 0, + y = 2, + width = iconSize, + height = iconSize + }) + playerIcon.drawSelf = function(elementSelf) + if GAME.theme then + local playerNumberIcon = GAME.theme:getPlayerNumberIcon(playerIndex) + if playerNumberIcon then + local scale = iconSize / math.max(playerNumberIcon:getWidth(), playerNumberIcon:getHeight()) + love.graphics.draw(playerNumberIcon, elementSelf.x, elementSelf.y, 0, scale, scale) + end + end + end + playerRow:addElement(playerIcon) + + -- Add device type icon and index + if player.inputConfiguration then + -- Small spacing between player icon and device icon + local smallSpacer = UiElement({ + width = 4, + height = iconSize + }) + playerRow:addElement(smallSpacer) + + -- Device icon + local deviceIcon = UiElement({ + width = iconSize, + height = iconSize + }) + deviceIcon.drawSelf = function(elementSelf) + InputPromptRenderer.renderIconWithNumber( + player.inputConfiguration.deviceType, + elementSelf.x + iconSize/2, + elementSelf.y + iconSize/2, + iconSize, + 1, + player.inputConfiguration.controllerImageVariant, + player.inputConfiguration.deviceNumber + ) + end + playerRow:addElement(deviceIcon) + end +end + +---@param battleRoom BattleRoom +function ChangeInputButton:setBattleRoom(battleRoom) + self:unsubscribeFromPlayerSignals() + self.battleRoom = battleRoom + self:subscribeToPlayerSignals() + self:updateSummary() +end + +function ChangeInputButton:subscribeToPlayerSignals() + if not self.battleRoom then + return + end + + local players = self.battleRoom:getLocalHumanPlayers() + for _, player in ipairs(players) do + local connection = player:connectSignal("inputConfigurationChanged", self, self.onInputConfigurationChanged) + self.signalConnections[#self.signalConnections + 1] = {player = player, connection = connection} + end +end + +function ChangeInputButton:unsubscribeFromPlayerSignals() + for _, connectionInfo in ipairs(self.signalConnections) do + connectionInfo.player:disconnectSignal("inputConfigurationChanged", connectionInfo.connection) + end + self.signalConnections = {} +end + +function ChangeInputButton:onInputConfigurationChanged() + self:updateSummary() +end + + +return ChangeInputButton \ No newline at end of file diff --git a/client/src/ui/DiscreteImageSlider.lua b/client/src/ui/DiscreteImageSlider.lua new file mode 100644 index 000000000..0d400b74a --- /dev/null +++ b/client/src/ui/DiscreteImageSlider.lua @@ -0,0 +1,304 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local Slider = require(PATH .. ".Slider") +local StackPanel = require(PATH .. ".StackPanel") +local ImageContainer = require(PATH .. ".ImageContainer") +local UIElement = require(PATH .. ".UIElement") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") + +---@class DiscreteValue +---@field id any Unique identifier for this value +---@field image love.Texture Image to display for this value +---@field selectedImage love.Texture? Optional image to display when selected (defaults to image) +---@field scale number? Scale factor for the image (default: 1) + +---@class DiscreteImageSliderOptions : UiElementOptions +---@field values DiscreteValue[]? Array of discrete values to display +---@field itemSpacing number? Spacing between items in pixels (default: 0) +---@field selectedValue any? Initially selected value ID +---@field onValueChange fun(slider:DiscreteImageSlider)? Callback when value changes + +---@class DiscreteImageSlider: Slider +---@field values DiscreteValue[] Array of discrete values +---@field itemSpacing number Spacing between items +---@field stackPanel StackPanel Layout container for items +---@field valueIdToIndex table Map from value ID to array index +---@overload fun(options: DiscreteImageSliderOptions): DiscreteImageSlider +local DiscreteImageSlider = class( + function(self, options) + self.values = options.values or {} + self.itemSpacing = options.itemSpacing or 0 + self.onValueChange = options.onValueChange or function() end + self.onlyChangeOnRelease = options.onlyChangeOnRelease or false + + -- Create StackPanel for layout (as a child) + self.stackPanel = StackPanel({ + alignment = "left", + hAlign = "left", + vAlign = "top" + }) + + -- Build index map, populate StackPanel, and calculate dimensions + self:rebuildLayout() + + -- Add StackPanel as a child so it draws automatically + self:addChild(self.stackPanel) + + -- Set initial value + local initialValue = options.selectedValue + if initialValue then + self.value = self:getIndexForId(initialValue) or 1 + else + self.value = 1 + end + + -- Update image selection for initial state + self:updateImageSelection() + + -- Set tickAmount for parent Slider compatibility + self.tickAmount = 1 + end, + Slider +) +DiscreteImageSlider.TYPE = "DiscreteImageSlider" + +---@param id any Value identifier +---@return number? index Array index for this ID, or nil if not found +function DiscreteImageSlider:getIndexForId(id) + return self.valueIdToIndex[id] +end + +---@param index number Array index +---@return any? id Value identifier at this index, or nil if out of bounds +function DiscreteImageSlider:getIdForIndex(index) + if index >= 1 and index <= #self.values then + return self.values[index].id + end + return nil +end + +---@return any? id Currently selected value ID +function DiscreteImageSlider:getSelectedId() + return self:getIdForIndex(self.value) +end + +---@param id any Value identifier to select +---@param committed boolean Whether to trigger onValueChange callback +function DiscreteImageSlider:setSelectedId(id, committed) + local index = self:getIndexForId(id) + if index then + self:setValue(index, committed) + end +end + +function DiscreteImageSlider:setValue(newValue, committed) + local oldValue = self.value + + -- Call parent to handle value change and callbacks + Slider.setValue(self, newValue, committed) + + -- Update images if value actually changed + if oldValue ~= self.value then + self:updateImageSelection() + end +end + +function DiscreteImageSlider:updateImageSelection() + for i, imageContainer in ipairs(self.imageContainers) do + local value = imageContainer.discreteValue + local isSelected = (i == self.value) + local newImage = (isSelected and value.selectedImage) or value.image + + if imageContainer.image ~= newImage then + imageContainer:setImage(newImage, nil, nil, value.scale or 1) + end + end +end + +function DiscreteImageSlider:rebuildLayout() + -- Clear existing layout + while #self.stackPanel.children > 0 do + self.stackPanel:remove(self.stackPanel.children[1]) + end + + -- Rebuild index map + self.valueIdToIndex = {} + for i, value in ipairs(self.values) do + self.valueIdToIndex[value.id] = i + end + + -- Store references to ImageContainers for updating selection state + self.imageContainers = {} + + -- Populate StackPanel with ImageContainers for each value + for i, value in ipairs(self.values) do + local scale = value.scale or 1 + local image = value.image + + local imageContainer = ImageContainer({ + image = image, + scale = scale, + hAlign = "left", + vAlign = "top" + }) + + -- Store reference for tracking selection and value + imageContainer.discreteIndex = i + imageContainer.discreteValue = value + self.imageContainers[i] = imageContainer + + self.stackPanel:addElement(imageContainer) + + -- Add spacing after each item except the last + if i < #self.values and self.itemSpacing > 0 then + local spacer = UIElement({ + width = self.itemSpacing, + height = imageContainer.height, + hAlign = "left", + vAlign = "top" + }) + self.stackPanel:addElement(spacer) + end + end + + -- Update dimensions + self.width = self.stackPanel.width + + -- Calculate max height from StackPanel children (the image containers) + local stackPanelMaxHeight = 0 + for _, child in ipairs(self.stackPanel.children) do + if child.height > stackPanelMaxHeight then + stackPanelMaxHeight = child.height + end + end + self.stackPanel.height = stackPanelMaxHeight + + -- Calculate total height including all direct children (e.g., labels in subclasses) + local totalHeight = stackPanelMaxHeight + for _, child in ipairs(self.children) do + if child ~= self.stackPanel then + -- For children positioned below stackPanel (with positive y offset) + local childBottomEdge = child.y + child.height + if childBottomEdge > totalHeight then + totalHeight = childBottomEdge + end + end + end + self.height = totalHeight + + self.min = 1 + self.max = math.max(1, #self.values) +end + +---@param newValues DiscreteValue[] New array of discrete values +function DiscreteImageSlider:setValues(newValues) + self.values = newValues + local oldValue = self.value + self:rebuildLayout() + + -- Try to maintain selection if possible + local newValue + if oldValue > #self.values then + newValue = math.max(1, #self.values) + else + newValue = oldValue + end + + self:setValue(newValue, false) +end + +---@param x number Screen x coordinate +---@return number index Value index for this position +function DiscreteImageSlider:getValueForPos(x) + if #self.values == 0 then + return 1 + end + + local screenX, screenY = self:getScreenPos() + local relativeX = x - screenX + + -- Find which item was clicked based on StackPanel children positions + local bestIndex = 1 + local bestDistance = math.huge + + for i, child in ipairs(self.stackPanel.children) do + if child.discreteIndex then + local itemScreenX = screenX + child.x + local itemCenterX = itemScreenX + child.width / 2 + local distance = math.abs(relativeX - (child.x + child.width / 2)) + + if distance < bestDistance then + bestDistance = distance + bestIndex = child.discreteIndex + end + end + end + + return bestIndex +end + +---@return number x X position of current value's center +function DiscreteImageSlider:getCurrentXForValue() + if self.value < 1 or self.value > #self.values then + return self.x + end + + -- Find the UI element for this value index + for _, child in ipairs(self.stackPanel.children) do + if child.discreteIndex == self.value then + return self.x + child.x + child.width / 2 + end + end + + return self.x +end + +-- Gets the rectangle of the currently selected item (for SliderMenuItem highlighting) +---@return {x: number, y: number, width: number, height: number}? +function DiscreteImageSlider:getSelectedItemRect() + if self.value < 1 or self.value > #self.values then + return nil + end + + local selectedContainer = self.imageContainers[self.value] + if not selectedContainer then + return nil + end + + return { + x = selectedContainer.x, + y = selectedContainer.y, + width = selectedContainer.width, + height = selectedContainer.height + } +end + +function DiscreteImageSlider:drawSelf() + -- Draw simple static border around the currently selected item + if self.value < 1 or self.value > #self.values then + return + end + + -- Find the selected image container + local selectedContainer = self.imageContainers[self.value] + if not selectedContainer then + return + end + + -- Draw static border + local borderColor = GAME.theme.colors.menuDefaultBorderColor + + GraphicsUtil.setColor(borderColor[1], borderColor[2], borderColor[3], 0.8) + GraphicsUtil.drawRectangle( + "line", + self.x + selectedContainer.x, + self.y + selectedContainer.y, + selectedContainer.width, + selectedContainer.height + ) + + -- Reset color + GraphicsUtil.setColor(1, 1, 1, 1) +end + +return DiscreteImageSlider diff --git a/client/src/ui/InputConfigSlider.lua b/client/src/ui/InputConfigSlider.lua new file mode 100644 index 000000000..237b25233 --- /dev/null +++ b/client/src/ui/InputConfigSlider.lua @@ -0,0 +1,164 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local DiscreteImageSlider = require(PATH .. ".DiscreteImageSlider") +local Label = require(PATH .. ".Label") +local ImageContainer = require(PATH .. ".ImageContainer") +local class = require("common.lib.class") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") + +---@class InputConfigSliderOptions : DiscreteImageSliderOptions +---@field onValueChange fun(slider:InputConfigSlider)? callback for whenever the value is changed + +-- A visual slider for input configuration selection showing controller/keyboard icons +---@class InputConfigSlider: DiscreteImageSlider +---@field deviceLabel Label Label showing selected device name +---@field iconSize number Size of device icons +---@overload fun(options: InputConfigSliderOptions): InputConfigSlider +local InputConfigSlider = class( +---@param self InputConfigSlider +---@param options InputConfigSliderOptions + function(self, options) + -- Get controller image width to calculate icon size + local controllerImage = GAME.theme:getInputPromptIcon("controller") + local imageWidth = controllerImage and controllerImage:getWidth() or 128 + local iconSize = imageWidth / 2 + + -- Store iconSize for later use + self.iconSize = iconSize + + self.itemSpacing = 2 + self.selectedValue = options.selectedValue or 1 + + -- Create label for device name + local config = GAME.input.inputConfigurations[options.selectedValue or 1] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel = Label({ + text = labelText, + translate = false, + hAlign = "center", + y = iconSize + 6 + }) + + -- Add label as child + self:addChild(self.deviceLabel) + + self:refresh() + end, + DiscreteImageSlider +) +InputConfigSlider.TYPE = "InputConfigSlider" + +-- Checks if a configuration slot has any key bindings +---@param configIndex number Configuration slot index +---@return boolean +function InputConfigSlider:hasBindings(configIndex) + local config = GAME.input.inputConfigurations[configIndex] + if not config then + return false + end + + return not config:isEmpty() +end + + +-- Finds the first empty configuration slot +---@return number? index of first empty slot or nil if all full +function InputConfigSlider:findNextAvailableSlot() + for i = 1, input.maxConfigurations do + if not self:hasBindings(i) then + return i + end + end + return nil +end + +-- Builds DiscreteValue array from current input configurations +---@return DiscreteValue[] values Array of values for DiscreteImageSlider +function InputConfigSlider:buildValuesArray() + local values = {} + local iconScale = self.iconSize / 128 -- Controller images are 128x128 + local nextAvailableSlot = self:findNextAvailableSlot() + + for i = 1, #input.inputConfigurations do + if self:hasBindings(i) then + -- Get device-specific icon + local config = GAME.input.inputConfigurations[i] + if config and config.deviceType and config.deviceType ~= "touch" then + local icon = GAME.theme:getSpecificInputIcon(config.deviceType, config.controllerImageVariant) + if icon then + values[#values + 1] = { + id = i, + image = icon, + scale = iconScale + } + end + end + elseif i == nextAvailableSlot then + -- Show "+" icon for first empty slot + local plusIcon = GAME.theme:getInputPromptIcon("controller_add") + if plusIcon then + values[#values + 1] = { + id = i, + image = plusIcon, + scale = iconScale + } + end + end + end + + return values +end + +-- Refreshes the slider visual (call when configs change) +function InputConfigSlider:refresh() + local newValues = self:buildValuesArray() + self:setValues(newValues) + + -- Update label text + local config = GAME.input.inputConfigurations[self.value] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel:setText(labelText) +end + +function InputConfigSlider:setValue(newValue, committed) + -- Call parent to handle value change + DiscreteImageSlider.setValue(self, newValue, committed) + + -- Update label text when selection changes + local config = GAME.input.inputConfigurations[self.value] + local labelText = (config and not config:isEmpty() and config.deviceName) or "Empty Slot" + self.deviceLabel:setText(labelText) +end + +function InputConfigSlider:rebuildLayout() + -- Call parent to rebuild layout + DiscreteImageSlider.rebuildLayout(self) + + -- Add error indicators to incomplete configurations + for _, imageContainer in ipairs(self.imageContainers) do + local valueId = imageContainer.discreteValue.id + local config = GAME.input.inputConfigurations[valueId] + + -- Check if configuration is incomplete (has bindings but not all keys) + if config and not config:isEmpty() and not config:isFullyConfigured() then + -- Get error indicator icon + local errorIcon = GAME.theme:getInputPromptIcon("controller_error") + if errorIcon then + -- Create error indicator as child with red tint + local errorIndicator = ImageContainer({ + image = errorIcon, + scale = 0.18 + }) + + -- Position in bottom-right corner of the device icon + errorIndicator.x = imageContainer.width / 2 - (errorIndicator.width / 2) + errorIndicator.y = imageContainer.height - (errorIndicator.height) - 2 + + -- Add as child of the image container + imageContainer:addChild(errorIndicator) + end + end + end +end + +return InputConfigSlider diff --git a/client/src/ui/KeyBindingMenuItem.lua b/client/src/ui/KeyBindingMenuItem.lua new file mode 100644 index 000000000..4ec4559f1 --- /dev/null +++ b/client/src/ui/KeyBindingMenuItem.lua @@ -0,0 +1,125 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local MenuItem = require(PATH .. ".MenuItem") +local Label = require(PATH .. ".Label") +local TextButton = require(PATH .. ".TextButton") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local class = require("common.lib.class") +local logger = require("common.lib.logger") + +---@class KeyBindingMenuItem : MenuItem +local KeyBindingMenuItem = class(function(self, options) + self.selected = false + self.settingKey = false + self.TYPE = "KeyBindingMenuItem" + self.keyName = options and options.keyName or nil + self.onActivate = options and options.onActivate or nil + self.x = 0 + self.y = 0 +end, MenuItem) + +--- Creates a KeyBindingMenuItem with key name label and binding button +---@param options table Options table with keyName, bindingText, onActivate +---@return KeyBindingMenuItem +function KeyBindingMenuItem.create(options) + assert(options.keyName ~= nil) + assert(options.bindingText ~= nil) + + local BUTTON_WIDTH = 120 + local SPACE_BETWEEN = 16 + + local menuItem = KeyBindingMenuItem(options) + + -- Create key name label (left side) + local keyLabel = Label({ + text = string.lower(options.keyName), + vAlign = "center", + fontSize = 12, + width = 96 + }) + + -- Create binding button (right side) + local bindingButton = TextButton({ + label = Label({ + text = options.bindingText, + translate = false, + hAlign = "center", + vAlign = "center", + fontSize = 12 + }), + onClick = options.onActivate, + width = BUTTON_WIDTH + }) + bindingButton.x = keyLabel.width + SPACE_BETWEEN + bindingButton.vAlign = "center" + + -- Store references + menuItem.keyLabel = keyLabel + menuItem.bindingButton = bindingButton + + -- Calculate dimensions + menuItem.width = keyLabel.width + MenuItem.PADDING + bindingButton.width + menuItem.height = math.max(keyLabel.height, bindingButton.height) + + -- Add children + menuItem:addChild(keyLabel) + menuItem:addChild(bindingButton) + + return menuItem +end + +--- Sets the binding text displayed on the button +---@param text string The binding text to display +function KeyBindingMenuItem:setBinding(text) + if self.bindingButton and self.bindingButton.label then + self.bindingButton.label:setText(text, nil, false) + end +end + +--- Sets whether this item is currently setting a key +---@param setting boolean True if actively setting a key +function KeyBindingMenuItem:setSettingKey(setting) + self.settingKey = setting +end + +function KeyBindingMenuItem:drawSelf() + local baseOpacity = 0.15 + + -- Draw subtle glow on button area + local buttonX = self.x + self.bindingButton.x + local buttonY = self.y + self.bindingButton.y + if self.settingKey then + -- Active key setting: strong glow on button area with pulse (regardless of selection state) + local selectedAdditionalOpacity = 0.5 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + elseif self.selected then + -- Normal selection: subtle pulse on button area + local selectedAdditionalOpacity = 0.1 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("fill", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + GraphicsUtil.drawRectangle("line", buttonX, buttonY, self.bindingButton.width, self.bindingButton.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + else + -- no special drawing for base case for now, just the elements + end +end + +function KeyBindingMenuItem:receiveInputs(inputs) + if self.bindingButton then + self.bindingButton:receiveInputs(inputs) + end +end + +return KeyBindingMenuItem diff --git a/client/src/ui/Label.lua b/client/src/ui/Label.lua index 31d6383b4..7cc77d498 100644 --- a/client/src/ui/Label.lua +++ b/client/src/ui/Label.lua @@ -27,7 +27,8 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@field strokeColors table? List of red, green, blue, and alpha color for stroke, no stroke if nil ---@field textColor table? List of red, green, blue, and alpha color for text ---@field drawable love.TextBatch Cached love.TextBatch for redrawing ----@field autoSizeToText boolean true if the text should change the width and height +---@field autoSizeWidth boolean true if the text should change the width +---@field autoSizeHeight boolean true if the text should change the height ---@field paddingTop number Top padding in pixels ---@field paddingRight number Right padding in pixels ---@field paddingBottom number Bottom padding in pixels @@ -38,7 +39,8 @@ local Label = class( self.hAlign = options.hAlign or "left" self.vAlign = options.vAlign or "top" self.hFill = options.hFill or false - self.autoSizeToText = (self.width == 0 or self.height == 0) + self.autoSizeWidth = self.width == 0 + self.autoSizeHeight = self.height == 0 self.wrapWidth = options.wrapWidth or nil self.fontSize = options.fontSize or GraphicsUtil.fontSize local padding = options.padding or 0 @@ -151,8 +153,11 @@ function Label:refreshFormatting() self.drawable:set(text) end - if self.autoSizeToText then + if self.autoSizeWidth then self.width = self.drawable:getWidth() + self.paddingLeft + self.paddingRight + end + + if self.autoSizeHeight then self.height = self.drawable:getHeight() + self.paddingTop + self.paddingBottom end end diff --git a/client/src/ui/Menu.lua b/client/src/ui/Menu.lua index 0bc6fbab0..1b46b178b 100644 --- a/client/src/ui/Menu.lua +++ b/client/src/ui/Menu.lua @@ -24,6 +24,11 @@ local Menu = class( self.totalHeight = 0 self.menuItemYOffsets = {} self.allContentShowing = true + self.sizeToFit = options.height == 0 + self.supportsBackButton = true + if options.supportsBackButton ~= nil and options.supportsBackButton == false then + self.supportsBackButton = false + end self.upIndicator = Label({text = "^", translate = false, isVisible = false, vAlign = "top", hAlign = "center", y = -14}) self.downIndicator = Label({text = "v", translate = false, isVisible = false, vAlign = "bottom", hAlign = "center"}) @@ -31,7 +36,7 @@ local Menu = class( self:addChild(self.downIndicator) -- bogus this should be passed in? - self.centerVertically = themes[config.theme].centerMenusVertically + self.centerVertically = themes[config.theme].centerMenusVertically and not self.sizeToFit self.yOffset = 0 self.firstActiveIndex = 1 @@ -46,16 +51,14 @@ Menu.NAVIGATION_BUTTON_WIDTH = NAVIGATION_BUTTON_WIDTH Menu.BUTTON_HORIZONTAL_PADDING = 0 Menu.BUTTON_VERTICAL_PADDING = 8 -function Menu.createCenteredMenu(items) - local menu = Menu({ - x = 0, - y = 0, - hAlign = "center", - vAlign = "center", - menuItems = items, - height = themes[config.theme].main_menu_max_height - }) +function Menu.createCenteredMenu(items, height, options) + options = options or {} + options.hAlign = "center" + options.vAlign = "center" + options.menuItems = items + options.height = height or themes[config.theme].main_menu_max_height + local menu = Menu(options) return menu end @@ -93,6 +96,17 @@ function Menu:layout() return end + -- If sizeToFit is enabled, recalculate height from content + if self.sizeToFit then + self.height = 0 + for i, menuItem in ipairs(self.menuItems) do + self.height = self.height + menuItem.height + if i < #self.menuItems then + self.height = self.height + Menu.BUTTON_VERTICAL_PADDING + end + end + end + local currentY = 0 local totalMenuHeight = 0 local menuFull = false @@ -104,7 +118,7 @@ function Menu:layout() self.upIndicator:setVisibility(true) end if menuFull == false and realY >= 0 then - if realY + menuItem.height < self.height then + if realY + menuItem.height <= self.height then if self.firstActiveIndex == nil then self.firstActiveIndex = i end @@ -117,18 +131,24 @@ function Menu:layout() menuFull = true end end - currentY = currentY + menuItem.height + Menu.BUTTON_VERTICAL_PADDING + currentY = currentY + menuItem.height + if i < #self.menuItems then + currentY = currentY + Menu.BUTTON_VERTICAL_PADDING + end if menuFull == false then self.lastActiveIndex = i totalMenuHeight = realY + menuItem.height end self.width = math.max(self.width, menuItem.width) - self.totalHeight = self.totalHeight + menuItem.height + Menu.BUTTON_VERTICAL_PADDING + self.totalHeight = self.totalHeight + menuItem.height + if i < #self.menuItems then + self.totalHeight = self.totalHeight + Menu.BUTTON_VERTICAL_PADDING + end end if self.centerVertically then self.y = self.yMin + (self.height / 2) - (totalMenuHeight / 2) - else + elseif not self.sizeToFit then self.y = self.yMin end end @@ -243,11 +263,13 @@ function Menu:receiveInputs(inputs, dt) if self.focused then self.focused:receiveInputs(inputs, dt) elseif inputs.isDown["MenuEsc"] then - if self.selectedIndex ~= #self.menuItems then - self:setSelectedIndex(#self.menuItems) - GAME.theme:playCancelSfx() - else - selectedElement:receiveInputs(inputs, dt) + if self.supportsBackButton then + if self.selectedIndex ~= #self.menuItems then + self:setSelectedIndex(#self.menuItems) + GAME.theme:playCancelSfx() + else + selectedElement:receiveInputs(inputs, dt) + end end elseif inputs:isPressedWithRepeat("MenuUp") then self:scrollUp() diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 400192c1c..e8649425b 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 e3a414671..dfc1c9a91 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 000000000..406882a87 --- /dev/null +++ b/client/src/ui/SliderMenuItem.lua @@ -0,0 +1,91 @@ +local PATH = (...):gsub('%.[^%.]+$', '') +local MenuItem = require(PATH .. ".MenuItem") +local Label = require(PATH .. ".Label") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local class = require("common.lib.class") + +---@class SliderMenuItem : MenuItem +local SliderMenuItem = class(function(self, options) + self.selected = false + self.TYPE = "SliderMenuItem" + self.labelText = options and options.labelText or nil + self.slider = options and options.slider or nil + self.x = 0 + self.y = 0 +end, MenuItem) + +--- Creates a SliderMenuItem with label and slider +---@param options table Options table with labelText, slider +---@return SliderMenuItem +function SliderMenuItem.create(options) + assert(options.labelText ~= nil) + assert(options.slider ~= nil) + + local SPACE_BETWEEN = 16 + + local menuItem = SliderMenuItem(options) + + -- Create label (left side) + local label = Label({ + text = options.labelText, + vAlign = "center" + }) + + -- Position slider (right side) + local slider = options.slider + slider.x = label.width + SPACE_BETWEEN + slider.vAlign = "center" + + -- Store references + menuItem.label = label + menuItem.slider = slider + + -- Calculate dimensions + menuItem.width = label.width + SPACE_BETWEEN + slider.width + MenuItem.PADDING + menuItem.height = math.max(label.height, slider.height) + (2 * MenuItem.PADDING) + + -- Add children + menuItem:addChild(label) + menuItem:addChild(slider) + + return menuItem +end + +function SliderMenuItem:drawSelf() + if self.selected and self.slider.getSelectedItemRect then + -- Get the rectangle of the currently selected slider item + local rect = self.slider:getSelectedItemRect() + + if rect then + -- Use same pulsing effect as regular menu items + local baseOpacity = 0.15 + local selectedAdditionalOpacity = 0.5 + local fillOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 16 + baseOpacity + selectedAdditionalOpacity + local borderOpacity = (math.cos(6 * love.timer.getTime()) + 1) / 4 + baseOpacity + selectedAdditionalOpacity + + -- Convert slider-relative coordinates to absolute screen coordinates + -- Account for vAlign/hAlign offsets that are applied during child drawing + local alignOffsetX, alignOffsetY = GraphicsUtil.getAlignmentOffset(self, self.slider) + local absoluteX = self.x + self.slider.x + alignOffsetX + rect.x + local absoluteY = self.y + self.slider.y + alignOffsetY + rect.y + + -- Draw pulsing background fill + local bgColor = GAME.theme.colors.menuSelectedBackgroundColor + GraphicsUtil.drawRectangle("fill", absoluteX, absoluteY, rect.width, rect.height, + bgColor[1], bgColor[2], bgColor[3], fillOpacity) + + -- Draw pulsing border + local borderColor = GAME.theme.colors.menuSelectedBorderColor + GraphicsUtil.drawRectangle("line", absoluteX, absoluteY, rect.width, rect.height, + borderColor[1], borderColor[2], borderColor[3], borderOpacity) + end + end +end + +function SliderMenuItem:receiveInputs(inputs) + if self.slider then + self.slider:receiveInputs(inputs) + end +end + +return SliderMenuItem diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index a1018b358..da7a729b0 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 4c1bc4c52..0ab2c3980 100644 --- a/client/src/ui/UIElement.lua +++ b/client/src/ui/UIElement.lua @@ -1,5 +1,7 @@ local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local DebugSettings = require("client.src.debug.DebugSettings") +local logger = require("common.lib.logger") ---@class UiElement ---@field x number relative x offset to the parent element (canvas if no parent) @@ -144,10 +146,8 @@ function UIElement:refreshLocalization() end function UIElement:update(dt) - if self.isVisible then - self:updateSelf(dt) - self:updateChildren(dt) - end + self:updateSelf(dt) + self:updateChildren(dt) end -- UiElements can override this method to do custom update logic @@ -163,6 +163,11 @@ end function UIElement:draw() if self.isVisible then + if DebugSettings.showUIElementBorders() then + GraphicsUtil.setColor(0, 0, 1, 1) + GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) + end self:drawSelf() -- if DEBUG_ENABLED then -- GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, 1, 1, 1, 0.5) @@ -174,7 +179,7 @@ function UIElement:draw() end end --- UiElements can overrid this method to do custom drawing +-- UiElements can override this method to do custom drawing -- implementation is optional function UIElement:drawSelf() end @@ -219,10 +224,15 @@ function UIElement:isTouchable() or self.onRelease end +---Returns the foremost visible, enabled element containing the given screen-space coordinates. +---@param x number screen x coordinate of the touch +---@param y number screen y coordinate of the touch +---@return UiElement? element the coordinates intersect, or nil when none match function UIElement:getTouchedElement(x, y) if self.isVisible and self.isEnabled and self:inBounds(x, y) then local touchedElement - for i = 1, #self.children do + -- Check children in reverse order (last drawn = first touched) + for i = #self.children, 1, -1 do touchedElement = self.children[i]:getTouchedElement(x, y) if touchedElement then return touchedElement @@ -259,4 +269,41 @@ function UIElement:handleFocusedInput(inputs, dt) return false -- No focused element found end +---Returns a formatted tree of this element and all children with class name, TYPE, and root position +---@return string +function UIElement:debugTree() + local function getElementInfo(element, depth) + local indent = string.rep(" ", depth) + local typeStr = element.TYPE and (" [" .. element.TYPE .. "]") or "" + local x, y = element:getScreenPos() + local info = string.format("%s%s @ (%.1f, %.1f)", indent, typeStr, x, y) + + local lines = {info} + for _, child in ipairs(element.children) do + local childInfo = getElementInfo(child, depth + 1) + table.insert(lines, childInfo) + end + + return table.concat(lines, "\n") + end + + return getElementInfo(self, 0) +end + +---Returns a formatted list of this element and its direct children only (non-recursive) +---@return string +function UIElement:debugChildren() + local typeStr = self.TYPE and (" [" .. self.TYPE .. "]") or "" + local x, y = self:getScreenPos() + local lines = {string.format("%s @ (%.1f, %.1f)", typeStr, x, y)} + + for _, child in ipairs(self.children) do + local childTypeStr = child.TYPE and (" [" .. child.TYPE .. "]") or "" + local childX, childY = child:getScreenPos() + table.insert(lines, string.format(" %s @ (%.1f, %.1f)", childTypeStr, childX, childY)) + end + + return table.concat(lines, "\n") +end + return UIElement \ No newline at end of file diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index a884de6b9..5b74969dc 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -9,6 +9,9 @@ local ui = { Button = require(PATH .. ".Button"), ButtonGroup = require(PATH .. ".ButtonGroup"), Carousel = require(PATH .. ".Carousel"), + ---@see ChangeInputButton + ---@type fun(options: ChangeInputButtonOptions): ChangeInputButton + ChangeInputButton = require(PATH .. ".ChangeInputButton"), Focusable = require(PATH .. ".Focusable"), FocusDirector = require(PATH .. ".FocusDirector"), Grid = require(PATH .. ".Grid"), @@ -18,6 +21,7 @@ local ui = { ImageButton = require(PATH .. ".ImageButton"), ImageContainer = require(PATH .. ".ImageContainer"), InputField = require(PATH .. ".InputField"), + KeyBindingMenuItem = require(PATH .. ".KeyBindingMenuItem"), ---@see Label ---@type fun(options: LabelOptions): Label Label = require(PATH .. ".Label"), @@ -40,6 +44,7 @@ local ui = { ---@see Slider ---@type fun(options: SliderOptions): Slider Slider = require(PATH .. ".Slider"), + SliderMenuItem = require(PATH .. ".SliderMenuItem"), ---@source StackElement.lua StackElement = require(PATH .. ".StackElement"), StackPanel = require(PATH .. ".StackPanel"), diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index f44700524..d1cfc9e25 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 000000000..ed8f62c78 --- /dev/null +++ b/client/tests/DiscreteImageSliderTests.lua @@ -0,0 +1,387 @@ +local DiscreteImageSlider = require("client.src.ui.DiscreteImageSlider") +local logger = require("common.lib.logger") + +local function createMockImage(width, height) + local imageData = love.image.newImageData(width, height) + return love.graphics.newImage(imageData) +end + +local function createMockValue(id, width, height) + width = width or 50 + height = height or 50 + return { + id = id, + image = createMockImage(width, height), + scale = 1 + } +end + +local function testBasicConstruction() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + selectedValue = "item2" + }) + + assert(slider ~= nil, "Slider should be created") + assert(#slider.values == 3, "Should have 3 values") + assert(slider.value == 2, "Should select item2 (index 2)") + assert(slider:getSelectedId() == "item2", "Should return correct selected ID") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 3, "Max should be 3") + + logger.trace("passed test testBasicConstruction") +end + +local function testEmptyConstruction() + local slider = DiscreteImageSlider({ + values = {} + }) + + assert(slider ~= nil, "Slider should be created with empty values") + assert(#slider.values == 0, "Should have 0 values") + assert(slider.value == 1, "Value should default to 1") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 1, "Max should be 1 even when empty") + + logger.trace("passed test testEmptyConstruction") +end + +local function testIndexIdMapping() + local values = { + createMockValue("alpha", 50, 50), + createMockValue("beta", 50, 50), + createMockValue("gamma", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + assert(slider:getIndexForId("alpha") == 1, "Should map alpha to index 1") + assert(slider:getIndexForId("beta") == 2, "Should map beta to index 2") + assert(slider:getIndexForId("gamma") == 3, "Should map gamma to index 3") + assert(slider:getIndexForId("nonexistent") == nil, "Should return nil for invalid ID") + + assert(slider:getIdForIndex(1) == "alpha", "Should map index 1 to alpha") + assert(slider:getIdForIndex(2) == "beta", "Should map index 2 to beta") + assert(slider:getIdForIndex(3) == "gamma", "Should map index 3 to gamma") + assert(slider:getIdForIndex(0) == nil, "Should return nil for index 0") + assert(slider:getIdForIndex(4) == nil, "Should return nil for out of bounds index") + + logger.trace("passed test testIndexIdMapping") +end + +local function testValueSelection() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + slider:setSelectedId("item3", false) + assert(slider.value == 3, "Should select item3") + assert(slider:getSelectedId() == "item3", "Should return item3") + + slider:setSelectedId("item1", false) + assert(slider.value == 1, "Should select item1") + assert(slider:getSelectedId() == "item1", "Should return item1") + + slider:setSelectedId("nonexistent", false) + assert(slider.value == 1, "Should not change value for invalid ID") + + logger.trace("passed test testValueSelection") +end + +local function testValueChangeCallback() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local callbackCount = 0 + local callbackSlider = nil + + local slider = DiscreteImageSlider({ + values = values, + onValueChange = function(s) + callbackCount = callbackCount + 1 + callbackSlider = s + end + }) + + slider:setSelectedId("item2", true) + assert(callbackCount == 1, "Callback should be called when committed=true") + assert(callbackSlider == slider, "Callback should receive slider instance") + + slider:setSelectedId("item3", false) + assert(callbackCount == 2, "Callback should be called even when committed=false (onlyChangeOnRelease=false)") + + logger.trace("passed test testValueChangeCallback") +end + +local function testValueChangeCallbackOnlyOnRelease() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local callbackCount = 0 + + local slider = DiscreteImageSlider({ + values = values, + onlyChangeOnRelease = true, + onValueChange = function(s) + callbackCount = callbackCount + 1 + end + }) + + slider:setSelectedId("item2", false) + assert(callbackCount == 0, "Callback should not be called when committed=false and onlyChangeOnRelease=true") + + slider:setSelectedId("item3", true) + assert(callbackCount == 1, "Callback should be called when committed=true") + + logger.trace("passed test testValueChangeCallbackOnlyOnRelease") +end + +local function testSetValues() + local values1 = { + createMockValue("a", 50, 50), + createMockValue("b", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values1, + selectedValue = "b" + }) + + assert(slider.value == 2, "Should start at value 2") + assert(slider.max == 2, "Max should be 2") + + local values2 = { + createMockValue("x", 50, 50), + createMockValue("y", 50, 50), + createMockValue("z", 50, 50), + createMockValue("w", 50, 50) + } + + slider:setValues(values2) + assert(#slider.values == 4, "Should have 4 values after setValues") + assert(slider.max == 4, "Max should be 4") + assert(slider.value == 2, "Value should be maintained if valid") + assert(slider:getSelectedId() == "y", "Should now reference new value at index 2") + + logger.trace("passed test testSetValues") +end + +local function testSetValuesWithClampedValue() + local values1 = { + createMockValue("a", 50, 50), + createMockValue("b", 50, 50), + createMockValue("c", 50, 50), + createMockValue("d", 50, 50), + createMockValue("e", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values1, + selectedValue = "e" + }) + + assert(slider.value == 5, "Should start at value 5") + + local values2 = { + createMockValue("x", 50, 50), + createMockValue("y", 50, 50) + } + + slider:setValues(values2) + assert(slider.value == 2, "Value should be clamped to max when reduced") + assert(slider:getSelectedId() == "y", "Should select last item") + + logger.trace("passed test testSetValuesWithClampedValue") +end + +local function testLayoutDimensions() + local values = { + createMockValue("item1", 50, 60), + createMockValue("item2", 40, 60), + createMockValue("item3", 30, 60) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 10 + }) + + -- Expected width: 50 + 10 + 40 + 10 + 30 = 140 + assert(slider.width == 140, "Width should sum all items and spacing") + assert(slider.height == 60, "Height should match item height") + + logger.trace("passed test testLayoutDimensions") +end + +local function testLayoutDimensionsNoSpacing() + local values = { + createMockValue("item1", 50, 60), + createMockValue("item2", 40, 60), + createMockValue("item3", 30, 60) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + -- Expected width: 50 + 40 + 30 = 120 + assert(slider.width == 120, "Width should sum all items without spacing") + + logger.trace("passed test testLayoutDimensionsNoSpacing") +end + +local function testStackPanelLayoutPositions() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 40, 50), + createMockValue("item3", 30, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 5 + }) + + local children = slider.stackPanel.children + local itemCount = 0 + local positions = {} + + for _, child in ipairs(children) do + if child.discreteIndex then + itemCount = itemCount + 1 + positions[child.discreteIndex] = child.x + end + end + + assert(itemCount == 3, "Should have 3 item children") + assert(positions[1] == 0, "Item 1 should be at x=0") + assert(positions[2] == 55, "Item 2 should be at x=55 (50 + 5)") + assert(positions[3] == 100, "Item 3 should be at x=100 (50 + 5 + 40 + 5)") + + logger.trace("passed test testStackPanelLayoutPositions") +end + +local function testGetValueForPos() + local values = { + createMockValue("item1", 50, 50), + createMockValue("item2", 50, 50), + createMockValue("item3", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + slider.x = 100 + slider.y = 100 + + -- Click near center of first item (x=100, width=50, center=125) + local index1 = slider:getValueForPos(125) + assert(index1 == 1, "Should return index 1 for x=125") + + -- Click near center of second item (x=150, width=50, center=175) + local index2 = slider:getValueForPos(175) + assert(index2 == 2, "Should return index 2 for x=175") + + -- Click near center of third item (x=200, width=50, center=225) + local index3 = slider:getValueForPos(225) + assert(index3 == 3, "Should return index 3 for x=225") + + logger.trace("passed test testGetValueForPos") +end + +local function testSingleValue() + local values = { + createMockValue("only", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + assert(slider.value == 1, "Should have value 1") + assert(slider.min == 1, "Min should be 1") + assert(slider.max == 1, "Max should be 1") + assert(slider:getSelectedId() == "only", "Should select the only item") + + logger.trace("passed test testSingleValue") +end + +local function testMixedWidthsAndHeights() + local values = { + createMockValue("small", 20, 30), + createMockValue("medium", 50, 60), + createMockValue("large", 80, 90), + createMockValue("tiny", 10, 15) + } + + local slider = DiscreteImageSlider({ + values = values, + itemSpacing = 0 + }) + + -- Width should be sum: 20 + 50 + 80 + 10 = 160 + assert(slider.width == 160, "Width should handle mixed widths") + -- Height should be max: 90 + assert(slider.height == 90, "Height should be tallest item") + + logger.trace("passed test testMixedWidthsAndHeights") +end + +local function testDuplicateIds() + local values = { + createMockValue("duplicate", 50, 50), + createMockValue("unique", 50, 50), + createMockValue("duplicate", 50, 50) + } + + local slider = DiscreteImageSlider({ + values = values + }) + + -- With duplicate IDs, the last one wins in the map + local index = slider:getIndexForId("duplicate") + assert(index == 3, "Should map to last occurrence of duplicate ID") + + logger.trace("passed test testDuplicateIds") +end + +testBasicConstruction() +testEmptyConstruction() +testIndexIdMapping() +testValueSelection() +testValueChangeCallback() +testValueChangeCallbackOnlyOnRelease() +testSetValues() +testSetValuesWithClampedValue() +testLayoutDimensions() +testLayoutDimensionsNoSpacing() +testStackPanelLayoutPositions() +testGetValueForPos() +testSingleValue() +testMixedWidthsAndHeights() +testDuplicateIds() + +logger.trace("All DiscreteImageSlider tests passed!") diff --git a/client/tests/InputConfigurationTests.lua b/client/tests/InputConfigurationTests.lua new file mode 100644 index 000000000..0071073fc --- /dev/null +++ b/client/tests/InputConfigurationTests.lua @@ -0,0 +1,714 @@ +local InputConfiguration = require("client.src.input.InputConfiguration") +local logger = require("common.lib.logger") + +local function createMockIsPressedWithRepeat(key) + return key == "test" +end + +local function createJoystickProvider(joysticks) + local storedJoysticks = joysticks or {} + local provider = {} + + function provider:getJoysticks() + return storedJoysticks + end + + return provider +end + +local function testBasicConstruction() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config ~= nil, "InputConfiguration should be created") + assert(config.index == 1, "Index should be set to 1") + assert(config.claimed == false, "Should start unclaimed") + assert(config.player == nil, "Should have no player") + assert(type(config.isDown) == "table", "isDown should be a table") + assert(type(config.isPressed) == "table", "isPressed should be a table") + assert(type(config.isUp) == "table", "isUp should be a table") + assert(type(config.isPressedWithRepeat) == "function", "isPressedWithRepeat should be a function") + + logger.trace("passed test testBasicConstruction") +end + +local function testConstructionWithDifferentIndex() + local config1 = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local config2 = InputConfiguration(5, createMockIsPressedWithRepeat, createJoystickProvider()) + local config3 = InputConfiguration(8, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config1.index == 1, "Config1 should have index 1") + assert(config2.index == 5, "Config2 should have index 5") + assert(config3.index == 8, "Config3 should have index 8") + + logger.trace("passed test testConstructionWithDifferentIndex") +end + +local function testIsPressedWithRepeatFunction() + local testFunction = function(key) + return key == "testkey" + end + + local config = InputConfiguration(1, testFunction, createJoystickProvider()) + + assert(config.isPressedWithRepeat("testkey") == true, "isPressedWithRepeat should return true for testkey") + assert(config.isPressedWithRepeat("otherkey") == false, "isPressedWithRepeat should return false for otherkey") + + logger.trace("passed test testIsPressedWithRepeatFunction") +end + +local function testKeyBindingStorage() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + + assert(config.Up == "w", "Up key should be stored") + assert(config.Down == "s", "Down key should be stored") + assert(config.Left == "a", "Left key should be stored") + assert(config.Right == "d", "Right key should be stored") + + logger.trace("passed test testKeyBindingStorage") +end + +local function testControllerBindingStorage() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "03000000de280000ff11000001000000:1:dpdown" + config.SwapL = "03000000de280000ff11000001000000:1:a" + + assert(config.Up == "03000000de280000ff11000001000000:1:dpup", "Controller binding should be stored") + assert(config.Down == "03000000de280000ff11000001000000:1:dpdown", "Controller binding should be stored") + assert(config.SwapL == "03000000de280000ff11000001000000:1:a", "Controller binding should be stored") + + logger.trace("passed test testControllerBindingStorage") +end + +local function testMixedInputBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "03000000de280000ff11000001000000:1:dpdown" + config.SwapL = "space" + + assert(config.Up == "w", "Keyboard binding should work") + assert(config.Down == "03000000de280000ff11000001000000:1:dpdown", "Controller binding should work") + assert(config.SwapL == "space", "Keyboard binding should work") + + logger.trace("passed test testMixedInputBindings") +end + +local function testClaimedProperty() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config.claimed == false, "Should start unclaimed") + + config.claimed = true + assert(config.claimed == true, "Should be claimable") + + config.claimed = false + assert(config.claimed == false, "Should be unclaimable") + + logger.trace("passed test testClaimedProperty") +end + +local function testPlayerProperty() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local mockPlayer = {playerNumber = 1} + + assert(config.player == nil, "Should start with no player") + + config.player = mockPlayer + assert(config.player == mockPlayer, "Should store player reference") + + config.player = nil + assert(config.player == nil, "Should allow clearing player") + + logger.trace("passed test testPlayerProperty") +end + +local function testIsDownTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isDown["w"] = true + config.isDown["s"] = false + + assert(config.isDown["w"] == true, "isDown should track key state") + assert(config.isDown["s"] == false, "isDown should track key state") + + logger.trace("passed test testIsDownTable") +end + +local function testIsPressedTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isPressed["w"] = 5 + config.isPressed["s"] = 10 + + assert(config.isPressed["w"] == 5, "isPressed should track press duration") + assert(config.isPressed["s"] == 10, "isPressed should track press duration") + + logger.trace("passed test testIsPressedTable") +end + +local function testIsUpTable() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.isUp["w"] = true + config.isUp["s"] = false + + assert(config.isUp["w"] == true, "isUp should track release state") + assert(config.isUp["s"] == false, "isUp should track release state") + + logger.trace("passed test testIsUpTable") +end + +local function testEmptyConfiguration() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config.Up == nil, "Empty config should have no Up binding") + assert(config.Down == nil, "Empty config should have no Down binding") + assert(config.Left == nil, "Empty config should have no Left binding") + assert(config.Right == nil, "Empty config should have no Right binding") + assert(config.SwapL == nil, "Empty config should have no SwapL binding") + assert(config.SwapR == nil, "Empty config should have no SwapR binding") + + logger.trace("passed test testEmptyConfiguration") +end + +local function testMultipleConfigurationsAreIndependent() + local config1 = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + local config2 = InputConfiguration(2, createMockIsPressedWithRepeat, createJoystickProvider()) + + config1.Up = "w" + config1.claimed = true + + config2.Up = "i" + config2.claimed = false + + assert(config1.Up == "w", "Config1 should have its own bindings") + assert(config2.Up == "i", "Config2 should have its own bindings") + assert(config1.claimed == true, "Config1 should have its own claimed state") + assert(config2.claimed == false, "Config2 should have its own claimed state") + + logger.trace("passed test testMultipleConfigurationsAreIndependent") +end + +local function testConfigurationIndex() + local configs = {} + for i = 1, 8 do + configs[i] = InputConfiguration(i, createMockIsPressedWithRepeat, createJoystickProvider()) + end + + for i = 1, 8 do + assert(configs[i].index == i, "Config " .. i .. " should have index " .. i) + end + + logger.trace("passed test testConfigurationIndex") +end + +local function testBindingOverwrite() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + assert(config.Up == "w", "Should set initial binding") + + config.Up = "i" + assert(config.Up == "i", "Should overwrite binding") + + config.Up = "03000000de280000ff11000001000000:1:dpup" + assert(config.Up == "03000000de280000ff11000001000000:1:dpup", "Should overwrite with controller binding") + + logger.trace("passed test testBindingOverwrite") +end + +local function testNilBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + assert(config.Up == "w", "Should set binding") + + config.Up = nil + assert(config.Up == nil, "Should allow clearing binding") + + logger.trace("passed test testNilBindings") +end + +local function testAllKeyNames() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + config.SwapL = "space" + config.SwapR = "lshift" + config.TauntUp = "1" + config.TauntDown = "2" + config.Raise = "r" + config.Pause = "escape" + + assert(config.Up == "w", "Up should be set") + assert(config.Down == "s", "Down should be set") + assert(config.Left == "a", "Left should be set") + assert(config.Right == "d", "Right should be set") + assert(config.SwapL == "space", "SwapL should be set") + assert(config.SwapR == "lshift", "SwapR should be set") + assert(config.TauntUp == "1", "TauntUp should be set") + assert(config.TauntDown == "2", "TauntDown should be set") + assert(config.Raise == "r", "Raise should be set") + assert(config.Pause == "escape", "Pause should be set") + + logger.trace("passed test testAllKeyNames") +end + +local function testIsEmptyWithNoBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:isEmpty() == true, "Empty config should return true for isEmpty()") + + logger.trace("passed test testIsEmptyWithNoBindings") +end + +local function testIsEmptyWithOneBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:isEmpty() == false, "Config with one binding should return false for isEmpty()") + + logger.trace("passed test testIsEmptyWithOneBinding") +end + +local function testIsEmptyWithAllBindings() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + config.Right = "d" + config.Swap1 = "space" + config.Swap2 = "lshift" + config.TauntUp = "1" + config.TauntDown = "2" + config.Raise1 = "r" + config.Raise2 = "t" + config.Start = "escape" + + assert(config:isEmpty() == false, "Config with all bindings should return false for isEmpty()") + + logger.trace("passed test testIsEmptyWithAllBindings") +end + +local function testIsEmptyAfterClearing() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "w" + config.Down = "s" + config.Left = "a" + + assert(config:isEmpty() == false, "Config with bindings should return false") + + config.Up = nil + config.Down = nil + config.Left = nil + + assert(config:isEmpty() == true, "Config after clearing all bindings should return true for isEmpty()") + + logger.trace("passed test testIsEmptyAfterClearing") +end + +local function testGetDeviceTypeWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:getDeviceType() == "keyboard", "Keyboard binding should return keyboard device type") + + logger.trace("passed test testGetDeviceTypeWithKeyboardBinding") +end + +local function testGetDeviceTypeWithControllerBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "03000000de280000ff11000001000000:1:dpup" + + assert(config:getDeviceType() == "controller", "Controller binding should return controller device type") + + logger.trace("passed test testGetDeviceTypeWithControllerBinding") +end + +local function testGetDeviceTypeWithTouchBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "mouse1" + + assert(config:getDeviceType() == "touch", "Mouse binding should return touch device type") + + logger.trace("passed test testGetDeviceTypeWithTouchBinding") +end + +local function testGetDeviceTypeWithEmptyConfig() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:getDeviceType() == nil, "Empty configuration should return nil device type") + + logger.trace("passed test testGetDeviceTypeWithEmptyConfig") +end + +local function testParseControllerBindingWithValidBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "03000000de280000ff11000001000000:1:dpup" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == "03000000de280000ff11000001000000", "Should extract GUID correctly") + assert(slot == 1, "Should extract slot correctly") + + logger.trace("passed test testParseControllerBindingWithValidBinding") +end + +local function testParseControllerBindingWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for keyboard binding") + assert(slot == nil, "Should return nil slot for keyboard binding") + + logger.trace("passed test testParseControllerBindingWithKeyboardBinding") +end + +local function testParseControllerBindingWithNilBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for nil binding") + assert(slot == nil, "Should return nil slot for nil binding") + + logger.trace("passed test testParseControllerBindingWithNilBinding") +end + +local function testParseControllerBindingWithMalformedBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "malformed:binding" + + local guid, slot = config:parseControllerBinding("Up") + + assert(guid == nil, "Should return nil GUID for malformed binding") + assert(slot == nil, "Should return nil slot for malformed binding") + + logger.trace("passed test testParseControllerBindingWithMalformedBinding") +end + +local function testParseControllerBindingWithDifferentSlots() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "03000000de280000ff11000001000000:2:dpdown" + config.Left = "03000000de280000ff11000001000000:3:dpleft" + + local guid1, slot1 = config:parseControllerBinding("Up") + local guid2, slot2 = config:parseControllerBinding("Down") + local guid3, slot3 = config:parseControllerBinding("Left") + + assert(slot1 == 1, "Should parse slot 1 correctly") + assert(slot2 == 2, "Should parse slot 2 correctly") + assert(slot3 == 3, "Should parse slot 3 correctly") + + logger.trace("passed test testParseControllerBindingWithDifferentSlots") +end + +local function testParseControllerBindingWithDifferentGUIDs() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + config.Up = "03000000de280000ff11000001000000:1:dpup" + config.Down = "030000005e040000120b000005050000:1:dpdown" + + local guid1, slot1 = config:parseControllerBinding("Up") + local guid2, slot2 = config:parseControllerBinding("Down") + + assert(guid1 == "03000000de280000ff11000001000000", "Should parse first GUID correctly") + assert(guid2 == "030000005e040000120b000005050000", "Should parse second GUID correctly") + + logger.trace("passed test testParseControllerBindingWithDifferentGUIDs") +end + +local function testGetDeviceNameWithKeyboardBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "w" + + assert(config:getDeviceName() == "Keyboard", "Keyboard binding should return 'Keyboard'") + + logger.trace("passed test testGetDeviceNameWithKeyboardBinding") +end + +local function testGetDeviceNameWithTouchBinding() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + config.Up = "mouse1" + + assert(config:getDeviceName() == "Touch", "Mouse binding should return 'Touch'") + + logger.trace("passed test testGetDeviceNameWithTouchBinding") +end + +local function testGetDeviceNameWithConnectedController() + local guid = "03000000de280000ff11000001000000" + local joystick = { + getGUID = function() + return guid + end, + getName = function() + return "Test Controller" + end, + getID = function() + return 1 + end + } + + local provider = createJoystickProvider({joystick}) + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Test Controller", "Connected controller should use joystick name") + + logger.trace("passed test testGetDeviceNameWithConnectedController") +end + +local function testGetDeviceNameWithDisconnectedController() + local guid = "00000000deadbeef0000000000000000" + + local provider = createJoystickProvider() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Controller", "Disconnected controller should fall back to generic name") + + logger.trace("passed test testGetDeviceNameWithDisconnectedController") +end + +local function testGetDeviceNameWithUnknownController() + local guid = "unknown-guid-0000" + + local provider = createJoystickProvider() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, provider) + config.Up = guid .. ":1:dpup" + + local deviceName = config:getDeviceName() + assert(deviceName == "Controller", "Unknown controller should return 'Controller'") + + logger.trace("passed test testGetDeviceNameWithUnknownController") +end + +local function testGetDeviceNameWithEmptyConfig() + local config = InputConfiguration(1, createMockIsPressedWithRepeat, createJoystickProvider()) + + assert(config:getDeviceName() == nil, "Empty configuration should return nil device name") + + logger.trace("passed test testGetDeviceNameWithEmptyConfig") +end + +testBasicConstruction() +testConstructionWithDifferentIndex() +testIsPressedWithRepeatFunction() +testKeyBindingStorage() +testControllerBindingStorage() +testMixedInputBindings() +testClaimedProperty() +testPlayerProperty() +testIsDownTable() +testIsPressedTable() +testIsUpTable() +testEmptyConfiguration() +testMultipleConfigurationsAreIndependent() +testConfigurationIndex() +testBindingOverwrite() +testNilBindings() +testAllKeyNames() +testIsEmptyWithNoBindings() +testIsEmptyWithOneBinding() +testIsEmptyWithAllBindings() +testIsEmptyAfterClearing() +testGetDeviceTypeWithKeyboardBinding() +testGetDeviceTypeWithControllerBinding() +testGetDeviceTypeWithTouchBinding() +testGetDeviceTypeWithEmptyConfig() +testParseControllerBindingWithValidBinding() +testParseControllerBindingWithKeyboardBinding() +testParseControllerBindingWithNilBinding() +testParseControllerBindingWithMalformedBinding() +testParseControllerBindingWithDifferentSlots() +testParseControllerBindingWithDifferentGUIDs() +testGetDeviceNameWithKeyboardBinding() +testGetDeviceNameWithTouchBinding() +testGetDeviceNameWithConnectedController() +testGetDeviceNameWithDisconnectedController() +testGetDeviceNameWithUnknownController() +testGetDeviceNameWithEmptyConfig() + +-- Controller Image Variant Tests (using static helper) +local function testPlayStation5Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS5 Controller") == "playstation5") + assert(InputConfiguration.getControllerImageVariantFromName("DualSense Wireless Controller") == "playstation5") + assert(InputConfiguration.getControllerImageVariantFromName("Sony DualSense") == "playstation5") + logger.trace("passed test testPlayStation5Controllers") +end + +local function testPlayStation4Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS4 Controller") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("DUALSHOCK 4 Wireless Controller") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("Sony DualShock 4") == "playstation4") + logger.trace("passed test testPlayStation4Controllers") +end + +local function testPlayStation3Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS3 Controller") == "playstation3") + assert(InputConfiguration.getControllerImageVariantFromName("Sony PLAYSTATION(R)3 Controller") == "playstation3") + logger.trace("passed test testPlayStation3Controllers") +end + +local function testPlayStation2Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS2 Controller") == "playstation2") + logger.trace("passed test testPlayStation2Controllers") +end + +local function testPlayStation1Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("PS1 Controller") == "playstation1") + assert(InputConfiguration.getControllerImageVariantFromName("PlayStation 1 Controller") == "playstation1") + logger.trace("passed test testPlayStation1Controllers") +end + +local function testXboxSeriesControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Series X Controller") == "xboxseries") + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Series S Controller") == "xboxseries") + logger.trace("passed test testXboxSeriesControllers") +end + +local function testXboxOneControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox One Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Microsoft Xbox One Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Xbox Wireless Controller") == "xboxone") + logger.trace("passed test testXboxOneControllers") +end + +local function testXbox360Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("Xbox 360 Controller") == "xbox360") + assert(InputConfiguration.getControllerImageVariantFromName("Microsoft Xbox 360 Controller") == "xbox360") + logger.trace("passed test testXbox360Controllers") +end + +local function testSwitchProControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Pro Controller") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("Nintendo Switch Pro Controller") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("Switch Pro Controller") == "switch_pro") + logger.trace("passed test testSwitchProControllers") +end + +local function testSNESControllers() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SN30") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SN30 Pro") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SF30") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo SF30 Pro") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("SNES Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Super Nintendo Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Super Famicom Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Scout") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Scout Premium SNES Controller") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("iBuffalo BSGP1204 Series") == "snes") + assert(InputConfiguration.getControllerImageVariantFromName("2-axis 8-button gamepad") == "snes") + logger.trace("passed test testSNESControllers") +end + +local function testN64Controllers() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo 64") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo N64") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo 64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("N64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Nintendo 64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Hyperkin Admiral N64 Controller") == "n64") + assert(InputConfiguration.getControllerImageVariantFromName("Admiral Controller") == "n64") + logger.trace("passed test testN64Controllers") +end + +local function testGameCubeControllers() + assert(InputConfiguration.getControllerImageVariantFromName("GameCube Controller") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Game Cube Controller") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo GameCube") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo GBros") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("GBros Adapter") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Battle Pad") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Horipad") == "gamecube") + assert(InputConfiguration.getControllerImageVariantFromName("HORIPAD") == "gamecube") + logger.trace("passed test testGameCubeControllers") +end + +local function test8BitDoProSeries() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro 2") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro2") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro 3") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Pro3") == "playstation4") + logger.trace("passed test test8BitDoProSeries") +end + +local function test8BitDoUltimateSeries() + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 2") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 2C") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate 3-mode Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Ultimate Wired Controller") == "xboxone") + logger.trace("passed test test8BitDoUltimateSeries") +end + +local function testGameSirTarantula() + assert(InputConfiguration.getControllerImageVariantFromName("GameSir Tarantula") == "playstation4") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir Tarantula Pro") == "playstation4") + logger.trace("passed test testGameSirTarantula") +end + +local function testHoriControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Horipad Pro for Xbox") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("HORI Xbox Controller") == "xboxone") + assert(InputConfiguration.getControllerImageVariantFromName("Horipad for Nintendo Switch") == "switch_pro") + assert(InputConfiguration.getControllerImageVariantFromName("HORI Nintendo Switch Controller") == "switch_pro") + logger.trace("passed test testHoriControllers") +end + +local function testGenericControllers() + assert(InputConfiguration.getControllerImageVariantFromName("Unknown Controller") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("Random Gamepad") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Lite") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Lite 2") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Zero") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Zero 2") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Micro") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo F40") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo Arcade Stick") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("8BitDo M30") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir T4") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("GameSir G7") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName("Hori Fighting Edge") == "generic") + assert(InputConfiguration.getControllerImageVariantFromName(nil) == "generic") + logger.trace("passed test testGenericControllers") +end + +testPlayStation5Controllers() +testPlayStation4Controllers() +testPlayStation3Controllers() +testPlayStation2Controllers() +testPlayStation1Controllers() +testXboxSeriesControllers() +testXboxOneControllers() +testXbox360Controllers() +testSwitchProControllers() +testSNESControllers() +testN64Controllers() +testGameCubeControllers() +test8BitDoProSeries() +test8BitDoUltimateSeries() +testGameSirTarantula() +testHoriControllers() +testGenericControllers() + +logger.trace("All InputConfiguration tests passed!") diff --git a/common/lib/class.lua b/common/lib/class.lua index c85c3f789..71eb8223e 100644 --- a/common/lib/class.lua +++ b/common/lib/class.lua @@ -10,8 +10,9 @@ local classMetaTable = {__call = newTable} ---@param init function function called on new objects of the class after the metatables have been applied ---@param parent any? parent class that has its own constructor called before init +---@param typeName string? optional type name for the class, sets classTable.TYPE if provided ---@return table classTable table acting as metatable for the class and acting as the constructor; uses the parent as its metatable -local class = function(init, parent) +local class = function(init, parent, typeName) local classTable = {} -- class table acts as the metatable for new tables -- all function calls on the table should find the functions on the class table, so set __index @@ -21,6 +22,11 @@ local class = function(init, parent) classTable.__call = newTable -- make parent functions accessible, even if they may be shadowed classTable.super = parent + + -- Set TYPE if provided + if typeName then + classTable.TYPE = typeName + end classTable.initializeObject = function(new, super, ...) if new.super then if not super then diff --git a/common/lib/joystickManager.lua b/common/lib/joystickManager.lua index c62a0d232..069a6aa0f 100644 --- a/common/lib/joystickManager.lua +++ b/common/lib/joystickManager.lua @@ -101,9 +101,12 @@ function joystickManager:getDPadState(joystick, hatIndex) } end --- Intentional override ----@diagnostic disable-next-line: duplicate-set-field -function love.joystickadded(joystick) +function joystickManager:registerJoystick(joystick) + + if joystickManager.devices[joystick:getID()] then + return + end + -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID -- the GUID is consistent across sessions local guid = joystick:getGUID() @@ -167,28 +170,4 @@ function love.joystickadded(joystick) joystickManager.devices[id] = device end --- Intentional override ----@diagnostic disable-next-line: duplicate-set-field -function love.joystickremoved(joystick) - -- GUID identifies the device type, 2 controllers of the same type will have a matching GUID - -- the GUID is consistent across sessions - local guid = joystick:getGUID() - -- ID is a per-session identifier for each controller regardless of type - local id = joystick:getID() - - local vendorID, productID, productVersion = joystick:getDeviceInfo( ) - - logger.info("Disconnecting device " .. vendorID .. ";" .. productID .. ";" .. productVersion .. ";" .. joystick:getName() .. ";" .. guid .. ";" .. id) - - if joystickManager.guidsToJoysticks[guid] then - joystickManager.guidsToJoysticks[guid][id] = nil - - if tableUtils.length(joystickManager.guidsToJoysticks[guid]) == 0 then - joystickManager.guidsToJoysticks[guid] = nil - end - end - - joystickManager.devices[id] = nil -end - return joystickManager \ No newline at end of file diff --git a/docs/InputDeviceSelection.md b/docs/InputDeviceSelection.md new file mode 100644 index 000000000..96da5672c --- /dev/null +++ b/docs/InputDeviceSelection.md @@ -0,0 +1,26 @@ +# Input Device Selection + +## Requirements +- Ensure character select automatically triggers the input device overlay whenever local human slots lack device assignments. +- The overlay does not dismiss until all local players have an input configuration assigned. +- You can't start the game until the overlay is dismissed. +- The overlay should supports all created input configurations plus touch. +- The overlay shows a box for each local player, online players box is not shown. +- Touch is assigned by tapping on the player box you want to use touch with. +- Each player box shows the player number +- When you touch or use a controller the device used shows in the box and becomes active. +- The overlay dismisses once every assignment is made. +- A "Change Input Device" button is on character select to allow you to reselect, triggering the overlay again with all local assignments reset. +- The change input device button shows all assignments in a compact form. +- Any input config should be able to navigate the menus outside of character select. +- When an input configuration is used that isn't assigned, release configs and bring up the overlay again +- When more than one input configuration of a device type are assigned, they should be numbered by order they are in the input configuration, so second keyboard configuration says "2" +- An attempt should be made to show an image close to the input method used. Touch, keyboard, controller shape + +## Testing Plan +- Manual scenarios: + - Keyboard only, controller only, mixed devices, touch selection via mouse. + - Multiple controllers to confirm naming and unique assignments. + - Works in endless, time attack, training, challenge mode, online, 2p vs +- Online/local modes to verify assignments persist and don’t conflict with server expectations. +- Run `love ./testLauncher.lua` post-implementation. diff --git a/main.lua b/main.lua index bcd2dcb98..7f2c5f4e4 100644 --- a/main.lua +++ b/main.lua @@ -187,6 +187,18 @@ function love.joystickreleased(joystick, button) inputManager:joystickReleased(joystick, button) end +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function love.joystickadded(joystick) + GAME:onJoystickAdded(joystick) +end + +-- Intentional override +---@diagnostic disable-next-line: duplicate-set-field +function love.joystickremoved(joystick) + inputManager:onJoystickRemoved(joystick) +end + -- Handle a touch press -- Note we are specifically not implementing this because mousepressed above handles mouse and touch -- function love.touchpressed(id, x, y, dx, dy, pressure) diff --git a/testLauncher.lua b/testLauncher.lua index 53d23c1cf..2029e388c 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -93,6 +93,8 @@ local allTests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "client.tests.StackGraphicsTests", + "client.tests.InputConfigurationTests", + "client.tests.DiscreteImageSliderTests", "client.tests.PlayerSettingsTests", }