diff --git a/.luarc.json b/.luarc.json index 1484447a..4e267e9d 100644 --- a/.luarc.json +++ b/.luarc.json @@ -21,6 +21,11 @@ "hint.semicolon": "Disable", + "runtime.plugin": "common/lib/luaLsPlugin.lua", + + // to support mixin union types where a table is both types rather than just one + "type.weakUnionCheck": true, + "workspace.checkThirdParty": false, "workspace.ignoreDir": [ ".vscode", @@ -34,6 +39,9 @@ "${3rd}/love2d/library", "common/lib/socket.lua", "common/lib/dkjson.lua", - "common/lib/csprng.lua" + "common/lib/csprng.lua", + "common/lib/luaLsPlugin.lua", + "common/lib/import.lua", + "${addons}/lsqlite3/module/library" ] } \ No newline at end of file diff --git a/.vscode/launch.json.template b/.vscode/launch.json.template index e6cfd20e..c9933f13 100644 --- a/.vscode/launch.json.template +++ b/.vscode/launch.json.template @@ -24,6 +24,9 @@ // the profiler itself preallocates a lot of memory to reduce the odds of it causing spikes in frame time / memory alloc on its own //,"profileFrameTimes" //,"profileMemory" + + // to make the game take code branches intended specifically for mobile devices (e.g. increased font size) + //,"simulateMobileOS" ] }, // using the testLauncher.lua file to only run tests diff --git a/client/assets/themes/Panel Attack Modern/default/stage/thumbnail.png b/client/assets/themes/Panel Attack Modern/default/stage/thumbnail.png index 76c468a4..aba6f28a 100644 Binary files a/client/assets/themes/Panel Attack Modern/default/stage/thumbnail.png and b/client/assets/themes/Panel Attack Modern/default/stage/thumbnail.png differ diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 207b825a..602321ad 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -11,7 +11,7 @@ local ClientMatch = require("client.src.ClientMatch") local GameBase = require("client.src.scenes.GameBase") local BlackFadeTransition = require("client.src.scenes.Transitions.BlackFadeTransition") local Easings = require("client.src.Easings") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local system = require("client.src.system") local GeneratorSource = require("common.engine.GeneratorSource") @@ -374,7 +374,7 @@ end function BattleRoom:createScene(match) -- for touch android players load a different scene - if (system.isMobileOS() or DEBUG_ENABLED) and self.gameScene.name ~= "PuzzleGame" and + if system.isMobileOS() 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/ClientMatch.lua b/client/src/ClientMatch.lua index 2df18d69..1755ffac 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -3,7 +3,7 @@ local class = require("common.lib.class") local logger = require("common.lib.logger") local StageLoader = require("client.src.mods.StageLoader") local ModController = require("client.src.mods.ModController") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local tableUtils = require("common.lib.tableUtils") local GameModes = require("common.data.GameModes") local ChallengeModePlayer = require("client.src.ChallengeModePlayer") @@ -562,7 +562,7 @@ end function ClientMatch:drawCommunityMessage() -- Draw the community message if not config.debug_mode then - GraphicsUtil.printf(join_community_msg or "", 0, 668, consts.CANVAS_WIDTH, "center") + GraphicsUtil.printf(join_community_msg or "", 0, 668, love.graphics.getWidth(), "center") end end @@ -593,8 +593,8 @@ function ClientMatch:render() -- let the spectator know the game is about to die local iconSize = 60 local icon_width, icon_height = themes[config.theme].images.IMG_bug:getDimensions() - local x = (consts.CANVAS_WIDTH / 2) - (iconSize / 2) - local y = (consts.CANVAS_HEIGHT / 2) - (iconSize / 2) + local x = (love.graphics.getWidth() / 2) - (iconSize / 2) + local y = (love.graphics.getHeight() / 2) - (iconSize / 2) GraphicsUtil.draw(themes[config.theme].images.IMG_bug, x, y, 0, iconSize / icon_width, iconSize / icon_height) end end @@ -660,20 +660,21 @@ end -- Draw the pause menu function ClientMatch:draw_pause() + local width, height = love.graphics.getDimensions() if not self.renderDuringPause then local image = themes[config.theme].images.pause - local scale = consts.CANVAS_WIDTH / math.max(image:getWidth(), image:getHeight()) -- keep image ratio + local scale = width / math.max(image:getWidth(), image:getHeight()) -- keep image ratio -- adjust coordinates to be centered - local x = consts.CANVAS_WIDTH / 2 - local y = consts.CANVAS_HEIGHT / 2 + local x = width / 2 + local y = height / 2 local xOffset = math.floor(image:getWidth() * 0.5) local yOffset = math.floor(image:getHeight() * 0.5) GraphicsUtil.draw(image, x, y, 0, scale, scale, xOffset, yOffset) end local y = 260 - GraphicsUtil.printf(loc("pause"), 0, y, consts.CANVAS_WIDTH, "center", nil, 1, 10) - GraphicsUtil.printf(loc("pl_pause_help"), 0, y + 30, consts.CANVAS_WIDTH, "center", nil, 1) + GraphicsUtil.printf(loc("pause"), 0, y, width, "center", nil, 1, "big") + GraphicsUtil.printf(loc("pl_pause_help"), 0, y + 30, width, "center", nil, 1) end function ClientMatch:getWinners() diff --git a/client/src/ClientStack.lua b/client/src/ClientStack.lua index 7d9b0237..e57e79d7 100644 --- a/client/src/ClientStack.lua +++ b/client/src/ClientStack.lua @@ -1,5 +1,5 @@ local class = require("common.lib.class") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local Signal = require("common.lib.signal") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -201,14 +201,18 @@ function ClientStack:drawNumber(number, themePositionOffset, scale, cameFromLega GraphicsUtil.drawPixelFont(number, self.assets.numberPixelFont, x, y, scale, scale, "center", 0) end -function ClientStack:drawString(string, themePositionOffset, cameFromLegacyScoreOffset, fontSize) +---@param str string +---@param themePositionOffset number[] +---@param cameFromLegacyScoreOffset boolean +---@param fontSize FontSize +function ClientStack:drawString(str, themePositionOffset, cameFromLegacyScoreOffset, fontSize) if cameFromLegacyScoreOffset == nil then cameFromLegacyScoreOffset = false end local x = self:elementOriginXWithOffset(themePositionOffset, cameFromLegacyScoreOffset) local y = self:elementOriginYWithOffset(themePositionOffset, cameFromLegacyScoreOffset) - local limit = consts.CANVAS_WIDTH - x + local limit = love.graphics.getWidth() - x local alignment = "left" if themes[config.theme]:offsetsAreFixed() then if self.renderIndex == 1 then @@ -218,12 +222,7 @@ function ClientStack:drawString(string, themePositionOffset, cameFromLegacyScore end end - if fontSize == nil then - fontSize = GraphicsUtil.fontSize - end - local fontDelta = fontSize - GraphicsUtil.fontSize - - GraphicsUtil.printf(string, x, y, limit, alignment, nil, nil, fontDelta) + GraphicsUtil.printf(str, x, y, limit, alignment, nil, nil, fontSize) end -- Positions the stack draw position for the given player @@ -237,7 +236,7 @@ function ClientStack:moveForRenderIndex(renderIndex) self.mirror_x = -1 self.multiplication = 1 end - local centerX = (GAME.globalCanvas:getWidth() / 2) + local centerX = love.graphics.getWidth() / 2 local stackWidth = self:canvasWidth() local innerStackXMovement = 100 local outerStackXMovement = stackWidth + innerStackXMovement @@ -402,14 +401,14 @@ function ClientStack:drawAbsoluteMultibar(stop_time, shake_time, pre_stop_time) end if remainingSeconds > 0 then - self:drawString(string.format("%." .. themes[config.theme].multibar_LeftoverTime_Decimals .. "f", remainingSeconds), overtimePos, false, 20) + self:drawString(string.format("%." .. themes[config.theme].multibar_LeftoverTime_Decimals .. "f", remainingSeconds), overtimePos, false, "big") end end end function ClientStack:drawPlayerName() local username = (self.player.name or "") - self:drawString(username, themes[config.theme].name_Pos, true, themes[config.theme].name_Font_Size) + self:drawString(username, themes[config.theme].name_Pos, true, "big") end function ClientStack:drawWinCount() diff --git a/client/src/Game.lua b/client/src/Game.lua index 542ca053..68d1cf4f 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -1,3 +1,4 @@ +require("common.lib.stringExtensions") require("client.src.localization") require("common.lib.Queue") require("client.src.server_queue") @@ -8,7 +9,7 @@ require("client.src.mods.Theme") -- The main game object for tracking everything in Panel Attack. -- Not to be confused with "Match" which is the current battle / instance of the game. -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") local logger = require("common.lib.logger") @@ -42,7 +43,6 @@ end ---@class PanelAttack ---@field netClient NetClient ---@field battleRoom BattleRoom? ----@field globalCanvas love.Canvas ---@field muteSound boolean ---@field rich_presence table ---@field input table @@ -66,7 +66,7 @@ local Game = class( self.backgroundImage = nil -- the background image for the game, should always be set to something with the proper dimensions self.netClient = NetClient() self.server_queue = ServerQueue() - self.main_menu_screen_pos = {consts.CANVAS_WIDTH / 2 - 108 + 50, consts.CANVAS_HEIGHT / 2 - 111} + self.main_menu_screen_pos = {love.graphics.getWidth() / 2 - 108 + 50, love.graphics.getHeight() / 2 - 111} self.config = config self.localization = Localization self.replay = {} @@ -81,9 +81,6 @@ local Game = class( self.canvasYScale = 1 self.backgroundColor = { 0.0, 0.0, 0.0 } - -- depends on canvasXScale - self.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=newCanvasSnappedScale(self)}) - self.automaticScales = {1, 1.5, 2, 2.5, 3} -- specifies a time that is compared against self.timer to determine if GameScale should be shown self.showGameScaleUntil = 0 @@ -126,7 +123,6 @@ function Game:load() self.navigationStack = require("client.src.NavigationStack") self.navigationStack:push(StartUp({setupRoutine = self.setupRoutine})) - self.globalCanvas = love.graphics.newCanvas(consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT, {dpiscale=GAME:newCanvasSnappedScale()}) end local function detectHardwareProblems() @@ -354,13 +350,26 @@ function Game:updateMouseVisibility(dt) end function Game:handleResize(newWidth, newHeight) + local activeScene = GAME.navigationStack:getActiveScene() + if activeScene then + local _, _, flags = love.window.getMode() + local screenWidth, screenHeight = love.window.getDesktopDimensions(flags.display) + newWidth = math.min(newWidth, screenWidth) + newHeight = math.min(newHeight, screenHeight) + activeScene.uiRoot.minWidth = newWidth + activeScene.uiRoot.minHeight = newHeight + activeScene.uiRoot.layout.resize(activeScene.uiRoot, newWidth, newHeight) + if activeScene.uiRoot.width ~= newWidth or activeScene.uiRoot.height ~= newHeight then + GraphicsUtil.updateMode(activeScene.uiRoot.width, activeScene.uiRoot.height) + end + end self:updateCanvasPositionAndScale(newWidth, newHeight) if self.battleRoom and self.battleRoom.match then self.needsAssetReload = true else - self:refreshCanvasAndImagesForNewScale() + --self:refreshCanvasAndImagesForNewScale() end - self.showGameScaleUntil = self.timer + 5 + --self.showGameScaleUntil = self.timer + 5 end -- Called every few fractions of a second to update the game @@ -391,25 +400,13 @@ function Game:update(dt) end function Game:draw() - -- Setting the canvas means everything we draw is drawn to the canvas instead of the screen - love.graphics.setCanvas({self.globalCanvas, stencil = true}) love.graphics.setBackgroundColor(unpack(self.backgroundColor)) love.graphics.clear() - -- With this, self.globalCanvas is clear and set as our active canvas everything is being drawn to self.navigationStack:draw() self:drawFPS() self:drawScaleInfo() - - -- resetting the canvas means everything we draw is drawn to the screen - love.graphics.setCanvas() - - love.graphics.setBlendMode("alpha", "premultiplied") - -- now we draw the finished canvas at scale - -- this way we don't have to worry about scaling singular elements, just draw everything at 1280x720 to the canvas - love.graphics.draw(self.globalCanvas, self.canvasX, self.canvasY, 0, self.canvasXScale, self.canvasYScale, self.globalCanvas:getWidth() / 2, self.globalCanvas:getHeight() / 2) - love.graphics.setBlendMode("alpha", "alphamultiply") end function Game:drawFPS() @@ -421,13 +418,13 @@ end function Game:drawScaleInfo() if self.showGameScaleUntil > self.timer then - local scaleString = "Scale: " .. self.canvasXScale .. " (" .. consts.CANVAS_WIDTH * self.canvasXScale .. " x " .. consts.CANVAS_HEIGHT * self.canvasYScale .. ")" + local scaleString = "Scale: " .. self.canvasXScale .. " (" .. love.graphics.getWidth() * self.canvasXScale .. " x " .. love.graphics.getHeight() * self.canvasYScale .. ")" local newPixelWidth = love.graphics.getWidth() - if consts.CANVAS_WIDTH * self.canvasXScale > newPixelWidth then + if love.graphics.getWidth() * self.canvasXScale > newPixelWidth then scaleString = scaleString .. " Clipped " end - love.graphics.printf(scaleString, GraphicsUtil.getGlobalFontWithSize(30), 5, 5, 2000, "left") + love.graphics.printf(scaleString, GraphicsUtil.getGlobalFontWithSize("huge"), 5, 5, 2000, "left") end end @@ -570,7 +567,7 @@ function Game:updateCanvasPositionAndScale(newWindowWidth, newWindowHeight) if config.gameScaleType == "fit" then local w, h - local canvasWidth, canvasHeight = self.globalCanvas:getDimensions() + local canvasWidth, canvasHeight = love.graphics.getDimensions() if newWindowHeight / canvasHeight > newWindowWidth / canvasWidth then w = newWindowWidth h = canvasHeight * newWindowWidth / canvasWidth @@ -589,7 +586,7 @@ function Game:updateCanvasPositionAndScale(newWindowWidth, newWindowHeight) local newScale = 0.5 for i= #availableScales, 1, -1 do local scale = availableScales[i] - if (newWindowWidth >= self.globalCanvas:getWidth() * scale and newWindowHeight >= self.globalCanvas:getHeight() * scale) then + if (newWindowWidth >= love.graphics.getWidth() * scale and newWindowHeight >= love.graphics.getHeight() * scale) then newScale = scale break end @@ -610,7 +607,6 @@ function Game:refreshCanvasAndImagesForNewScale() self:drawLoadingString(loc("ld_characters")) coroutine.yield() - self.globalCanvas = love.graphics.newCanvas(GAME.globalCanvas:getWidth(), GAME.globalCanvas:getHeight(), {dpiscale=self:newCanvasSnappedScale()}) -- We need to reload all assets and fonts to get the new scaling info and filters -- Reload theme to get the new resolution assets @@ -629,7 +625,7 @@ end -- Transform from window coordinates to game coordinates function Game:transform_coordinates(x, y) - local newX, newY = (x - self.canvasX) / self.canvasXScale + self.globalCanvas:getWidth() / 2, (y - self.canvasY) / self.canvasYScale + self.globalCanvas:getHeight() / 2 + local newX, newY = (x - self.canvasX) / self.canvasXScale + love.graphics.getWidth() / 2, (y - self.canvasY) / self.canvasYScale + love.graphics.getHeight() / 2 return newX, newY end @@ -638,10 +634,10 @@ function Game:drawLoadingString(loadingString) local textMaxWidth = 300 local textHeight = 40 local x = 0 - local y = consts.CANVAS_HEIGHT/2 - textHeight/2 + local y = love.graphics.getHeight()/2 - textHeight/2 local backgroundPadding = 10 - GraphicsUtil.drawRectangle("fill", consts.CANVAS_WIDTH / 2 - (textMaxWidth / 2) , y - backgroundPadding, textMaxWidth, textHeight, 0, 0, 0, 0.5) - GraphicsUtil.printf(loadingString, x, y, consts.CANVAS_WIDTH, "center", nil, nil, 10) + GraphicsUtil.drawRectangle("fill", love.graphics.getWidth() / 2 - (textMaxWidth / 2) , y - backgroundPadding, textMaxWidth, textHeight, 0, 0, 0, 0.5) + GraphicsUtil.printf(loadingString, x, y, love.graphics.getWidth(), "center", nil, nil, "big") end function Game:setLanguage(lang_code) @@ -653,14 +649,19 @@ function Game:setLanguage(lang_code) end config.language_code = Localization.codes[Localization.lang_index] + local baseOffset = 0 + if system.isMobileOS() then + baseOffset = 4 + end + 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()) + GraphicsUtil.setGlobalFont(themes[config.theme].font.path, (themes[config.theme].font.size or 12) - 12 + baseOffset, self:newCanvasSnappedScale()) elseif config.language_code == "JP" then - GraphicsUtil.setGlobalFont("client/assets/fonts/jp.ttf", 14, self:newCanvasSnappedScale()) + GraphicsUtil.setGlobalFont("client/assets/fonts/jp.ttf", 2 + baseOffset, self:newCanvasSnappedScale()) elseif config.language_code == "TH" then - GraphicsUtil.setGlobalFont("client/assets/fonts/th.otf", 14, self:newCanvasSnappedScale()) + GraphicsUtil.setGlobalFont("client/assets/fonts/th.otf", 2 + baseOffset, self:newCanvasSnappedScale()) else - GraphicsUtil.setGlobalFont(nil, 12, self:newCanvasSnappedScale()) + GraphicsUtil.setGlobalFont(nil, baseOffset, self:newCanvasSnappedScale()) end Localization:refresh_global_strings() diff --git a/client/src/MatchParticipant.lua b/client/src/MatchParticipant.lua index 80819269..08b7f105 100644 --- a/client/src/MatchParticipant.lua +++ b/client/src/MatchParticipant.lua @@ -1,5 +1,5 @@ local class = require("common.lib.class") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local Signal = require("common.lib.signal") local logger = require("common.lib.logger") local CharacterLoader = require("client.src.mods.CharacterLoader") diff --git a/client/src/Player.lua b/client/src/Player.lua index 40d8ad91..a1aad96f 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -3,7 +3,7 @@ local GameModes = require("common.data.GameModes") local LevelPresets = require("common.data.LevelPresets") local input = require("client.src.inputManager") local MatchParticipant = require("client.src.MatchParticipant") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local CharacterLoader = require("client.src.mods.CharacterLoader") local PlayerStack = require("client.src.PlayerStack") require("client.src.network.PlayerStack") diff --git a/client/src/PlayerStack.lua b/client/src/PlayerStack.lua index a7e3211f..a6f56515 100644 --- a/client/src/PlayerStack.lua +++ b/client/src/PlayerStack.lua @@ -3,7 +3,7 @@ local class = require("common.lib.class") require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") local TouchDataEncoding = require("common.data.TouchDataEncoding") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local prof = require("common.lib.zoneProfiler") local EngineStack = require("common.engine.Stack") require("common.engine.checkMatches") @@ -1104,9 +1104,10 @@ function PlayerStack:drawAnalyticData() local paddingToAnalytics = 16 local width = 160 local height = 600 + local screenWidth = love.graphics.getWidth() local x = paddingToAnalytics + backgroundPadding if self.renderIndex == 2 then - x = consts.CANVAS_WIDTH - paddingToAnalytics - width + backgroundPadding + x = screenWidth - paddingToAnalytics - width + backgroundPadding end local y = self.frameOriginY * self.gfxScale + backgroundPadding @@ -1119,14 +1120,14 @@ function PlayerStack:drawAnalyticData() local icon_width local icon_height - local font = GraphicsUtil.getGlobalFontWithSize(GraphicsUtil.fontSize + fontIncrement) + local font = GraphicsUtil.getGlobalFontWithSize("big") GraphicsUtil.setFont(font) -- Background GraphicsUtil.drawRectangle("fill", x - backgroundPadding , y - backgroundPadding, width, height, 0, 0, 0, 0.5) -- Panels cleared panels[self.panels_dir]:drawPanelFrame(1, "face", x, y, iconSize) - GraphicsUtil.printf(analytic.data.destroyed_panels, x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.data.destroyed_panels, x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement @@ -1135,21 +1136,21 @@ function PlayerStack:drawAnalyticData() -- Garbage sent icon_width, icon_height = self.character.images.face:getDimensions() GraphicsUtil.draw(self.character.images.face, x, y, 0, iconSize / icon_width, iconSize / icon_height) - GraphicsUtil.printf(analytic.data.sent_garbage_lines, x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.data.sent_garbage_lines, x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement -- GPM icon_width, icon_height = self.theme.images.IMG_gpm:getDimensions() GraphicsUtil.draw(self.theme.images.IMG_gpm, x, y, 0, iconSize / icon_width, iconSize / icon_height) - GraphicsUtil.printf(analytic.lastGPM .. "/m", x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.lastGPM .. "/m", x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement -- Moves icon_width, icon_height = self.theme.images.IMG_cursorCount:getDimensions() GraphicsUtil.draw(self.theme.images.IMG_cursorCount, x, y, 0, iconSize / icon_width, iconSize / icon_height) - GraphicsUtil.printf(analytic.data.move_count, x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.data.move_count, x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement @@ -1158,7 +1159,7 @@ function PlayerStack:drawAnalyticData() icon_width, icon_height = self.theme.images.IMG_swap:getDimensions() GraphicsUtil.draw(self.theme.images.IMG_swap, x, y, 0, iconSize / icon_width, iconSize / icon_height) end - GraphicsUtil.printf(analytic.data.swap_count, x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.data.swap_count, x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement @@ -1167,7 +1168,7 @@ function PlayerStack:drawAnalyticData() icon_width, icon_height = self.theme.images.IMG_apm:getDimensions() GraphicsUtil.draw(self.theme.images.IMG_apm, x, y, 0, iconSize / icon_width, iconSize / icon_height) end - GraphicsUtil.printf(analytic.lastAPM .. "/m", x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.lastAPM .. "/m", x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement @@ -1185,7 +1186,7 @@ function PlayerStack:drawAnalyticData() if cardImage then icon_width, icon_height = cardImage:getDimensions() GraphicsUtil.draw(cardImage, x, y, 0, iconSize / icon_width, iconSize / icon_height) - GraphicsUtil.printf(analytic.data.reached_chains[i], x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.data.reached_chains[i], x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) y = y + nextIconIncrement end end @@ -1194,7 +1195,7 @@ function PlayerStack:drawAnalyticData() if chainCountAboveLimit > 0 then local cardImage = self.theme:chainImage(0) GraphicsUtil.draw(cardImage, x, y, 0, iconSize / icon_width, iconSize / icon_height) - GraphicsUtil.printf(chainCountAboveLimit, x + iconToTextSpacing, y - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(chainCountAboveLimit, x + iconToTextSpacing, y - 2, screenWidth, "left", nil, 1) end -- Draw the combo images @@ -1206,7 +1207,7 @@ function PlayerStack:drawAnalyticData() if cardImage then icon_width, icon_height = cardImage:getDimensions() GraphicsUtil.draw(cardImage, xCombo, yCombo, 0, iconSize / icon_width, iconSize / icon_height) - GraphicsUtil.printf(analytic.data.used_combos[i], xCombo + iconToTextSpacing, yCombo - 2, consts.CANVAS_WIDTH, "left", nil, 1) + GraphicsUtil.printf(analytic.data.used_combos[i], xCombo + iconToTextSpacing, yCombo - 2, screenWidth, "left", nil, 1) yCombo = yCombo + nextIconIncrement end end diff --git a/client/src/PuzzleLibrary.lua b/client/src/PuzzleLibrary.lua index 7f5238a7..291e27b2 100644 --- a/client/src/PuzzleLibrary.lua +++ b/client/src/PuzzleLibrary.lua @@ -1,5 +1,5 @@ local class = require("common.lib.class") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local FileUtils = require("client.src.FileUtils") local logger = require("common.lib.logger") local Puzzle = require("common.engine.Puzzle") diff --git a/client/src/RunTimeGraph.lua b/client/src/RunTimeGraph.lua index a01c84b0..285a54c0 100644 --- a/client/src/RunTimeGraph.lua +++ b/client/src/RunTimeGraph.lua @@ -1,5 +1,5 @@ local BarGraph = require("client.lib.BarGraph") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local class = require("common.lib.class") local RunTimeGraph = class(function(self) @@ -7,7 +7,7 @@ local RunTimeGraph = class(function(self) local valueCount = 60 local width = valueCount * 8 local height = 50 - local x = consts.CANVAS_WIDTH - width + local x = love.graphics.getWidth() - width local y = 4 local padding = 80 self.graphs = {} @@ -76,7 +76,7 @@ function RunTimeGraph:draw() -- in order to not sully the draw data of the actual game, the RunTimeGraph is drawn separately -- these transformations assure it uses the same game coordinates as love.draw - love.graphics.translate(GAME.canvasX - GAME.globalCanvas:getWidth() / 2 * GAME.canvasXScale, GAME.canvasY - GAME.globalCanvas:getHeight() / 2 * GAME.canvasYScale) + love.graphics.translate(GAME.canvasX - love.graphics.getWidth() / 2 * GAME.canvasXScale, GAME.canvasY - love.graphics.getHeight() / 2 * GAME.canvasYScale) love.graphics.scale(GAME.canvasXScale, GAME.canvasYScale) BarGraph.drawGraphs(self.graphs) diff --git a/client/src/config.lua b/client/src/config.lua index 4c81d6a6..81261d5e 100644 --- a/client/src/config.lua +++ b/client/src/config.lua @@ -1,7 +1,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 consts = require("client.src.consts") require("client.src.globals") -- Default configuration values diff --git a/client/src/consts.lua b/client/src/consts.lua new file mode 100644 index 00000000..e3697ee9 --- /dev/null +++ b/client/src/consts.lua @@ -0,0 +1,33 @@ +local clientConstants = { + CANVAS_WIDTH = 1280, + CANVAS_HEIGHT = 720, + DEFAULT_THEME_DIR = "Panel Attack", + RANDOM_CHARACTER_SPECIAL_VALUE = "__RandomCharacter", + RANDOM_STAGE_SPECIAL_VALUE = "__RandomStage", + MOUSE_POINTER_TIMEOUT = 1.5, --seconds + KEY_NAMES = {"Up", "Down", "Left", "Right", "Swap1", "Swap2", "TauntUp", "TauntDown", "Raise1", "Raise2", "Start"}, + FRAME_RATE = 1 / 60, + KEY_DELAY = .25, + KEY_REPEAT_PERIOD = .05, + MENU_PADDING = 10 +} + +clientConstants.PUZZLES_SAVE_DIRECTORY = "puzzles" + +clientConstants.SERVER_SAVE_DIRECTORY = "servers/" +clientConstants.LEGACY_SERVER_LOCATION = "18.188.43.50" +clientConstants.SERVER_LOCATION = "panelattack.com" +clientConstants.DEFAULT_THEME_DIRECTORY = "Panel Attack Modern" + +clientConstants.ATTACK_TYPE = { combo=0, chain=1, shock=2 } + +local engineConstants = require("common.engine.consts") + +for key, value in pairs(engineConstants) do + if clientConstants[key] then + error("key collision between client and engine constants for key " .. key) + end + clientConstants[key] = value +end + +return clientConstants \ No newline at end of file diff --git a/client/src/developer.lua b/client/src/developer.lua index ff4cc477..9bbcea9c 100644 --- a/client/src/developer.lua +++ b/client/src/developer.lua @@ -35,6 +35,8 @@ function developerTools.processArgs(args) -- drop the updater directory of the updater in for debugging purposes GAME_UPDATER_STATES = { idle = 0, checkingForUpdates = 1, downloading = 2} GAME_UPDATER = require("updater.gameUpdater") + elseif value == "simulateMobileOS" then + SIMULATE_MOBILE_OS = true else for match in string.gmatch(value, "user%-id=(.*)") do CUSTOM_USER_ID = match diff --git a/client/src/globals.lua b/client/src/globals.lua index 45cf48e1..171ef44d 100644 --- a/client/src/globals.lua +++ b/client/src/globals.lua @@ -1,4 +1,4 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") score_mode = consts.SCOREMODE_TA GARBAGE_TRANSIT_TIME = 45 -- the amount of time the garbage attack animation plays before getting to the telegraph diff --git a/client/src/graphics/graphics_util.lua b/client/src/graphics/graphics_util.lua index 2c871ba5..590f2577 100644 --- a/client/src/graphics/graphics_util.lua +++ b/client/src/graphics/graphics_util.lua @@ -1,13 +1,26 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local logger = require("common.lib.logger") local FileUtils = require("client.src.FileUtils") +local util = require("common.lib.util") + +---@enum (key) FontSize +local fontSizes = { + small = 8, + normal = 12, + medium = 16, + big = 20, + huge = 30, + gigantic = 42, +} -- Utility methods for drawing local GraphicsUtil = { fontFile = nil, - fontSize = 12, + fontSizeDelta = 0, fontDpiScale = 1, fontCache = {}, + ---@type table + textCache = util.getWeaklyKeyedTable(), ---@type love.Quad[] quadPool = {} } @@ -234,24 +247,36 @@ local function privateMakeFont(fontPath, size, dpiScale) end -- Creates a new font based on the current font and a delta +---@param fontSize FontSize function GraphicsUtil.getGlobalFontWithSize(fontSize) - local f = GraphicsUtil.fontCache[fontSize] + local realSize = GraphicsUtil.getEffectiveFontSize(fontSize) + local f = GraphicsUtil.fontCache[realSize] if not f or f:getDPIScale() ~= GraphicsUtil.fontDpiScale then - f = privateMakeFont(GraphicsUtil.fontFile, fontSize, GraphicsUtil.fontDpiScale) - GraphicsUtil.fontCache[fontSize] = f + f = privateMakeFont(GraphicsUtil.fontFile, realSize, GraphicsUtil.fontDpiScale) + GraphicsUtil.fontCache[realSize] = f end return f end -function GraphicsUtil.setGlobalFont(filepath, size, dpiScale) +---@param fontSize FontSize +---@return integer +function GraphicsUtil.getEffectiveFontSize(fontSize) + return fontSizes[fontSize] + GraphicsUtil.fontSizeDelta +end + +---@param filepath string +---@param fontSizeDelta integer? +---@param dpiScale number? +function GraphicsUtil.setGlobalFont(filepath, fontSizeDelta, dpiScale) GraphicsUtil.setFontDpiScale(dpiScale) GraphicsUtil.fontCache = {} GraphicsUtil.fontFile = filepath - GraphicsUtil.fontSize = size - local createdFont = GraphicsUtil.getGlobalFontWithSize(size) + GraphicsUtil.fontSizeDelta = fontSizeDelta or 0 + local createdFont = GraphicsUtil.getGlobalFontWithSize("normal") love.graphics.setFont(createdFont) end +---@param dpiScale number? function GraphicsUtil.setFontDpiScale(dpiScale) if dpiScale and tonumber(dpiScale) then GraphicsUtil.fontDpiScale = dpiScale @@ -260,7 +285,21 @@ end -- Returns the current global font function GraphicsUtil.getGlobalFont() - return GraphicsUtil.getGlobalFontWithSize(GraphicsUtil.fontSize) + return GraphicsUtil.getGlobalFontWithSize("normal") +end + +---@param fontSize FontSize +---@param text string +---@param limit number +---@param hAlign ("left" | "center" | "right") +function GraphicsUtil.getTextHeightForWidth(fontSize, text, limit, hAlign) + local font = GraphicsUtil.getGlobalFontWithSize(fontSize) + if not GraphicsUtil.textCache[font] then + GraphicsUtil.textCache[font] = GraphicsUtil.newText(font) + end + local t = GraphicsUtil.textCache[font] + t:setf(text, limit, hAlign) + return t:getHeight() end function GraphicsUtil.setFont(font) @@ -290,17 +329,26 @@ function GraphicsUtil.print(str, x, y, color, scale) end -- Draws a font with a given font delta from the standard font -function GraphicsUtil.printf(str, x, y, limit, halign, color, scale, font_delta_size) +---@param str string +---@param x number +---@param y number +---@param limit number? +---@param halign string? +---@param color number[]? +---@param scale number? +---@param fontSize FontSize? +function GraphicsUtil.printf(str, x, y, limit, halign, color, scale, fontSize) x = x or 0 y = y or 0 scale = scale or 1 color = color or nil - limit = limit or consts.CANVAS_WIDTH - font_delta_size = font_delta_size or 0 + limit = limit or love.graphics.getWidth() + fontSize = fontSize or "normal" halign = halign or "left" GraphicsUtil.setColor(0, 0, 0, 1) - if font_delta_size ~= 0 then - GraphicsUtil.setFont(GraphicsUtil.getGlobalFontWithSize(GraphicsUtil.fontSize + font_delta_size)) + local font = GraphicsUtil.getGlobalFontWithSize(fontSize) + if font ~= love.graphics.getFont() then + GraphicsUtil.setFont(font) end love.graphics.printf(str, x+1, y+1, limit, halign, 0, scale) @@ -311,9 +359,6 @@ function GraphicsUtil.printf(str, x, y, limit, halign, color, scale, font_delta_ GraphicsUtil.setColor(r,g,b,a) love.graphics.printf(str, x, y, limit, halign, 0, scale) - if font_delta_size ~= 0 then - GraphicsUtil.setFont(GraphicsUtil.getGlobalFont()) - end GraphicsUtil.setColor(1,1,1,1) end @@ -346,18 +391,18 @@ end function GraphicsUtil.getAlignmentOffset(parentElement, childElement) local xOffset, yOffset - if childElement.hAlign == "center" then + if parentElement.hAlign == "center" then xOffset = parentElement.width / 2 - childElement.width / 2 - elseif childElement.hAlign == "right" then + elseif parentElement.hAlign == "right" then xOffset = parentElement.width - childElement.width else -- if hAlign == "left" then -- default xOffset = 0 end - if childElement.vAlign == "center" then + if parentElement.vAlign == "center" then yOffset = parentElement.height / 2 - childElement.height / 2 - elseif childElement.vAlign == "bottom" then + elseif parentElement.vAlign == "bottom" then yOffset = parentElement.height - childElement.height else --if uiElement.vAlign == "top" then -- default @@ -387,4 +432,12 @@ else GraphicsUtil.newText = love.graphics.newText end +---@param width number # Window width. +---@param height number # Window height. +---@param settings {fullscreen: boolean, fullscreentype: love.FullscreenType, vsync: boolean, msaa: number, resizable: boolean, borderless: boolean, centered: boolean, display: number, minwidth: number, minheight: number, highdpi: boolean, x: number, y: number}? # The settings table with the following optional fields. Any field not filled in will use the current value that would be returned by love.window.getMode. +---@return boolean success # True if successful, false otherwise. +function GraphicsUtil.updateMode(width, height, settings) + return love.window.updateMode(width, height, settings or {}) +end + return GraphicsUtil diff --git a/client/src/inputManager.lua b/client/src/inputManager.lua index d5aaf35c..4a2fb053 100644 --- a/client/src/inputManager.lua +++ b/client/src/inputManager.lua @@ -1,8 +1,16 @@ local tableUtils = require("common.lib.tableUtils") local joystickManager = require("common.lib.joystickManager") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local logger = require("common.lib.logger") +---@alias InputKeys ("Up" | "Down" | "Left" | "Right" | "Swap1" | "Swap2" | "TauntUp" | "TauntDown" | "Raise1" | "Raise2" | "Start" | "MenuUp" | "MenuDown" | "MenuLeft" | "MenuRight" | "MenuEsc" | "MenuSelect") + +---@class KeyConfiguration +---@field isDown table +---@field isPressed table +---@field isUp table +---@field isPressedWithRepeat fun(inputs: KeyConfiguration, key: InputKeys, delay: number?, repeatPeriod: number?): boolean + -- table containing the set of keys in various states -- base structure: -- isDown: table of {key: true} pairs if the key was pressed in the current frame @@ -21,6 +29,7 @@ local inputManager = { isUp = {}, allKeys = {isDown = {}, isPressed = {}, isUp = {}}, mouse = {isDown = {}, isPressed = {}, isUp = {}, x = 0, y = 0}, + ---@type KeyConfiguration[] inputConfigurations = {}, maxConfigurations = 8, defaultKeys = { @@ -58,7 +67,6 @@ local menuReservedKeysMap = { MenuPrevPage = {{"pagedown"}, {"Raise2"}}, MenuBack = {{"backspace"}, {}}, MenuSelect = {{"return", "kpenter", "z"}, {"Swap1", "Start"}}, - FrameAdvance = {{"\\"}, {"TauntUp"}} } -- useful alternate representations of the above information diff --git a/client/src/localization.lua b/client/src/localization.lua index 3fecfe5f..fb11b5ba 100644 --- a/client/src/localization.lua +++ b/client/src/localization.lua @@ -1,6 +1,6 @@ -- TODO rename local FILENAME = "client/assets/localization.csv" -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") diff --git a/client/src/mods/Character.lua b/client/src/mods/Character.lua index 9d603416..8c37f7dc 100644 --- a/client/src/mods/Character.lua +++ b/client/src/mods/Character.lua @@ -7,7 +7,7 @@ local class = require("common.lib.class") local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") local fileUtils = require("client.src.FileUtils") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local StageTrack = require("client.src.music.StageTrack") local DynamicStageTrack = require("client.src.music.DynamicStageTrack") diff --git a/client/src/mods/CharacterLoader.lua b/client/src/mods/CharacterLoader.lua index 3bfab808..a2635884 100644 --- a/client/src/mods/CharacterLoader.lua +++ b/client/src/mods/CharacterLoader.lua @@ -1,7 +1,7 @@ local Character = require("client.src.mods.Character") local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local ModLoader = require("client.src.mods.ModLoader") local CharacterLoader = {} diff --git a/client/src/mods/Panels.lua b/client/src/mods/Panels.lua index ddd907d8..968c4e0f 100644 --- a/client/src/mods/Panels.lua +++ b/client/src/mods/Panels.lua @@ -52,8 +52,19 @@ local DEFAULT_PANEL_ANIM = -- The class representing the panel image data -- Not to be confused with "Panel" which is one individual panel in the game stack model -Panels = - class( +---@class PanelSet +---@field path string +---@field id string +---@field name string +---@field size integer +---@field images {metals: {left: love.Texture, mid: love.Texture, right: love.Texture, flash: love.Texture}} +---@field sheets love.Texture[] +---@field sheetConfig table +---@field batches love.SpriteBatch[] +local Panels = class( +---@param self PanelSet +---@param full_path string +---@param folder_name string function(self, full_path, folder_name) self.path = full_path -- string | path to the panels folder content self.id = folder_name -- string | id of the panel set, is also the name of its folder by default, may change in json_init @@ -595,19 +606,21 @@ function Panels:getDrawProps(panel, x, y, dangerCol, dangerTimer, stopTime) return conf, frame, x, y end --- adds the panel to a batch for later drawing --- x, y: relative coordinates on the stack canvas --- clock: Stack.clock to calculate animation frames --- danger: nil - no danger, false - regular danger, true - panic --- dangerTimer: remaining time for which the danger animation continues --- stopTime: the remaining stop time -function Panels:addToDraw(panel, x, y, stackScale, danger, dangerTimer, stopTime) +-- adds the panel to a batch for later drawing or, if color 9, draws it directly +---@param panel Panel +---@param x integer relative x coordinate to the parent / stack +---@param y integer relative y coordinate to the parent / stack +---@param stackScale number +---@param dangerCol boolean[] danger state per column, true if the column touches top +---@param dangerTimer integer +---@param stopTime integer +function Panels:addToDraw(panel, x, y, stackScale, dangerCol, dangerTimer, stopTime) if panel.color == 9 then love.graphics.draw(self.greyPanel, x * stackScale, y * stackScale, 0, self.scale * stackScale) else local batch = self.batches[panel.color] local conf, frame - conf, frame, x, y = self:getDrawProps(panel, x, y, danger, dangerTimer, stopTime) + conf, frame, x, y = self:getDrawProps(panel, x, y, dangerCol, dangerTimer, stopTime) if conf then self.quad:setViewport((frame - 1) * self.size, (conf.row - 1) * self.size, self.size, self.size) diff --git a/client/src/mods/Stage.lua b/client/src/mods/Stage.lua index 27832e8a..eab08869 100644 --- a/client/src/mods/Stage.lua +++ b/client/src/mods/Stage.lua @@ -1,7 +1,7 @@ local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") local fileUtils = require("client.src.FileUtils") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local StageTrack = require("client.src.music.StageTrack") local DynamicStageTrack = require("client.src.music.DynamicStageTrack") @@ -129,7 +129,7 @@ function stages_reload_graphics() stages[match.stageId]:graphics_init(true, false) -- for reasons, this is not drawn directly from the stage but from background image -- so override this while in a match - GAME.backgroundImage = UpdatingImage(stages[match.stageId].images.background, false, 0, 0, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) + GAME.backgroundImage = UpdatingImage(stages[match.stageId].images.background, false, 0, 0, love.graphics.getDimensions()) end end end diff --git a/client/src/mods/StageLoader.lua b/client/src/mods/StageLoader.lua index 3850d2d3..26035160 100644 --- a/client/src/mods/StageLoader.lua +++ b/client/src/mods/StageLoader.lua @@ -1,5 +1,5 @@ local Stage = require("client.src.mods.Stage") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local tableUtils = require("common.lib.tableUtils") local logger = require("common.lib.logger") local ModLoader = require("client.src.mods.ModLoader") @@ -8,7 +8,15 @@ local StageLoader = {} -- initializes the stage class function StageLoader.initStages() - allStages, stageIds, stages, visibleStages = ModLoader.initMods(Stage) + local all, ids, filtered, visible = ModLoader.initMods(Stage) + ---@type table + allStages = all + ---@type string[] + stageIds = ids + ---@type table + stages = filtered +---@type string[] + visibleStages = visible StageLoader.loadBundleThumbnails() end diff --git a/client/src/mods/Theme.lua b/client/src/mods/Theme.lua index 53b5e96d..c33cf06a 100644 --- a/client/src/mods/Theme.lua +++ b/client/src/mods/Theme.lua @@ -1,4 +1,4 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local class = require("common.lib.class") local logger = require("common.lib.logger") local fileUtils = require("client.src.FileUtils") @@ -313,17 +313,18 @@ function Theme:loadFont() end function Theme:loadMenuGraphics() - self.images.bg_main = UpdatingImage(self:load_theme_img("background/main"), self.bg_main_is_tiled, self.bg_main_speed_x, self.bg_main_speed_y, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) + local width, height = love.graphics.getDimensions() + self.images.bg_main = UpdatingImage(self:load_theme_img("background/main"), self.bg_main_is_tiled, self.bg_main_speed_x, self.bg_main_speed_y, width, height) self:loadFont() local titleImage = self:load_theme_img("background/title", false) if titleImage then - self.images.bg_title = UpdatingImage(titleImage, self.bg_title_is_tiled, self.bg_title_speed_x, self.bg_title_speed_y, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) + self.images.bg_title = UpdatingImage(titleImage, self.bg_title_is_tiled, self.bg_title_speed_x, self.bg_title_speed_y, width, height) end - self.images.bg_select_screen = UpdatingImage(self:load_theme_img("background/select_screen"), self.bg_select_screen_is_tiled, self.bg_select_screen_speed_x, self.bg_select_screen_speed_y, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) - self.images.bg_readme = UpdatingImage(self:load_theme_img("background/readme"), self.bg_readme_is_tiled, self.bg_readme_speed_x, self.bg_readme_speed_y, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) + self.images.bg_select_screen = UpdatingImage(self:load_theme_img("background/select_screen"), self.bg_select_screen_is_tiled, self.bg_select_screen_speed_x, self.bg_select_screen_speed_y, width, height) + self.images.bg_readme = UpdatingImage(self:load_theme_img("background/readme"), self.bg_readme_is_tiled, self.bg_readme_speed_x, self.bg_readme_speed_y, width, height) self.images.IMG_bug = self:load_theme_img("bug") end @@ -356,11 +357,15 @@ local function loadPlayerNumberIcons(theme) return theme.images.IMG_players end -function Theme:loadSelectionGraphics() +function Theme:loadFlags() self.images.flags = {} for _, flag in ipairs(flags) do self.images.flags[flag] = self:load_theme_img("flags/" .. flag) end +end + +function Theme:loadSelectionGraphics() + self:loadFlags() self.images.IMG_level_cursor = self:load_theme_img("level/level_cursor") self.images.IMG_levels = {} @@ -393,6 +398,7 @@ function Theme:loadSelectionGraphics() end function Theme:loadIngameGraphics() + local width, height = love.graphics.getDimensions() local bgOverlay = self:load_theme_img("background/bg_overlay") local fgOverlay = self:load_theme_img("background/fg_overlay") if bgOverlay then @@ -400,8 +406,8 @@ function Theme:loadIngameGraphics() image = bgOverlay, hAlign = "center", vAlign = "center", - width = consts.CANVAS_WIDTH, - height = consts.CANVAS_HEIGHT + width = width, + height = height }) end @@ -410,8 +416,8 @@ function Theme:loadIngameGraphics() image = fgOverlay, hAlign = "center", vAlign = "center", - width = consts.CANVAS_WIDTH, - height = consts.CANVAS_HEIGHT + width = width, + height = height }) end @@ -776,12 +782,11 @@ function Theme:final_init() if self.images.bg_title then menuYPadding = 100 self.main_menu_screen_pos = {532, menuYPadding} - self.main_menu_y_max = consts.CANVAS_HEIGHT - menuYPadding else self.main_menu_screen_pos = {532, 249} - self.main_menu_y_max = consts.CANVAS_HEIGHT - menuYPadding self.centerMenusVertically = false end + self.main_menu_y_max = love.graphics.getHeight() - menuYPadding self.main_menu_max_height = (self.main_menu_y_max - self.main_menu_screen_pos[2]) self.main_menu_y_center = self.main_menu_screen_pos[2] + (self.main_menu_max_height / 2) @@ -875,7 +880,7 @@ function theme_init() themes[config.theme]:load() if themes[config.theme].font.path then - GraphicsUtil.setGlobalFont(themes[config.theme].font.path, themes[config.theme].font.size) + GraphicsUtil.setGlobalFont(themes[config.theme].font.path, (themes[config.theme].font.size or 12) - 12) end end @@ -893,7 +898,7 @@ end ---@param index integer? ---@return love.Texture[] -function Theme:getGridCursor(index) +function Theme:getCursorImages(index) index = index or 1 if not (self.images.IMG_char_sel_cursors and self.images.IMG_char_sel_cursors[index]) then loadGridCursors(self) @@ -1093,12 +1098,26 @@ end function Theme:getSelectionAssetPack(index) local pack = { playerNumberIcon = self:getPlayerNumberIcon(index), - gridCursor = self:getGridCursor(index), + gridCursor = self:getCursorImages(index), } return pack end +---@param flagName string? +---@return love.Texture? +function Theme:getFlag(flagName) + if not flagName then + return nil + end + + if not self.images.flags then + self:loadFlags() + end + + return self.images.flags[flagName] +end + function Theme:reload() self:deinitializeGraphics() self:json_init() diff --git a/client/src/scenes/ChallengeModeMenu.lua b/client/src/scenes/ChallengeModeMenu.lua index c124e70e..0be885c3 100644 --- a/client/src/scenes/ChallengeModeMenu.lua +++ b/client/src/scenes/ChallengeModeMenu.lua @@ -32,7 +32,7 @@ function ChallengeModeMenu:load(sceneParams) local difficultyLabels = {} local challengeModes = {} for i = 1, ChallengeMode.numDifficulties do - table.insert(difficultyLabels, ui.Label({text = "challenge_difficulty_" .. i})) + table.insert(difficultyLabels, ui.Label({id = "challenge_difficulty_" .. i})) table.insert(challengeModes, i) end @@ -48,15 +48,25 @@ function ChallengeModeMenu:load(sceneParams) } ) - local menuItems = { - ui.MenuItem.createStepperMenuItem("difficulty", nil, nil, difficultyStepper), - ui.MenuItem.createButtonMenuItem("go_", nil, nil, function() - self:goToCharacterSelect(difficultyStepper.value) - end), - ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) - } + local challengeModeDifficulty = ui.MenuItem.createStepperMenuItem("difficulty", nil, nil, difficultyStepper) + local go = ui.MenuItem.createButtonMenuItem("go_", nil, nil, function() + self:goToCharacterSelect(difficultyStepper.value) + end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) + + self.menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + self.menu:addChild(challengeModeDifficulty) + self.menu:addChild(go) + self.menu:addChild(back) - self.menu = ui.Menu.createCenteredMenu(menuItems) self.uiRoot:addChild(self.menu) end diff --git a/client/src/scenes/ChallengeModeRecapScene.lua b/client/src/scenes/ChallengeModeRecapScene.lua index 84420fe7..b5e72259 100644 --- a/client/src/scenes/ChallengeModeRecapScene.lua +++ b/client/src/scenes/ChallengeModeRecapScene.lua @@ -1,4 +1,4 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local Scene = require("client.src.scenes.Scene") local class = require("common.lib.class") @@ -20,7 +20,7 @@ local ChallengeModeRecapScene = class( function(self, sceneParams) self.backgroundImg = GAME.theme.images.bg_main self.challengeMode = sceneParams.challengeMode - self.timeSplitElement = ChallengeModeTimeSplitsUIElement({x = consts.CANVAS_WIDTH / 2, y = 200}, self.challengeMode) + self.timeSplitElement = ChallengeModeTimeSplitsUIElement({x = love.graphics.getWidth() / 2, y = 200}, self.challengeMode) self.uiRoot:addChild(self.timeSplitElement) self.recapStartTime = love.timer.getTime() self.minDisplayTime = 2 -- the minimum amount of seconds the scene will be displayed for @@ -48,23 +48,23 @@ end function ChallengeModeRecapScene:draw() self.backgroundImg:draw() - - local drawX = consts.CANVAS_WIDTH / 2 + local width, height = love.graphics.getDimensions() + local drawX = width / 2 local drawY = 20 - local limit = consts.CANVAS_WIDTH + local limit = width local message = "Congratulations!\n You beat " .. self.challengeMode.difficultyName .. "!" - GraphicsUtil.printf(message, 0, drawY, limit, "center", nil, nil, 30) + GraphicsUtil.printf(message, 0, drawY, limit, "center", nil, nil, "gigantic") self.uiRoot:draw() local limit = 400 drawY = drawY + 120 - GraphicsUtil.printf("Continues", drawX - limit / 2, drawY, limit, "center", nil, nil, 4) + GraphicsUtil.printf("Continues", drawX - limit / 2, drawY, limit, "center", nil, nil, "medium") drawY = drawY + 20 - GraphicsUtil.printf(self.challengeMode.continues, drawX - limit / 2, drawY, limit, "center", nil, nil, 4) + GraphicsUtil.printf(self.challengeMode.continues, drawX - limit / 2, drawY, limit, "center", nil, nil, "medium") local font = GraphicsUtil.getGlobalFont() - GraphicsUtil.print(loc("continue_button"), (consts.CANVAS_WIDTH - font:getWidth(loc("continue_button"))) / 2, consts.CANVAS_HEIGHT - 60) + GraphicsUtil.print(loc("continue_button"), (width - font:getWidth(loc("continue_button"))) / 2, height - 60) end return ChallengeModeRecapScene diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index c06b0c82..380dcb58 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -1,4 +1,4 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local input = require("client.src.inputManager") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") @@ -159,13 +159,13 @@ function CharacterSelect:createReadyButton() local readyButton = ui.TextButton({ hFill = true, vFill = true, - label = ui.Label({text = "ready"}), + label = ui.Label({id = "ready"}), backgroundColor = {1, 1, 1, 0}, outlineColor = {1, 1, 1, 1} }) -- assign player generic callback - readyButton.onClick = function(self, inputSource, holdTime) + readyButton.action = function(self, inputSource, holdTime) local player if inputSource and inputSource.player then player = inputSource.player @@ -174,7 +174,7 @@ function CharacterSelect:createReadyButton() end player:setWantsReady(not player.settings.wantsReady) end - readyButton.onSelect = readyButton.onClick + readyButton.onSelect = readyButton.action return readyButton end @@ -184,15 +184,15 @@ function CharacterSelect:createLeaveButton() leaveButton = ui.TextButton({ hFill = true, vFill = true, - label = ui.Label({text = "leave"}), + label = ui.Label({id = "leave"}), backgroundColor = {1, 1, 1, 0}, outlineColor = {1, 1, 1, 1}, - onClick = function() + action = function() GAME.theme:playCancelSfx() self:leave() end }) - leaveButton.onSelect = leaveButton.onClick + leaveButton.onSelect = leaveButton.action return leaveButton end @@ -310,7 +310,7 @@ function CharacterSelect:getCharacterButtons() -- assign player generic callbacks for i = 1, #characterButtons do local characterButton = characterButtons[i] - characterButton.onClick = function(selfElement, inputSource, holdTime) + characterButton.action = function(selfElement, inputSource, holdTime) local character = characters[selfElement.characterId] local player if inputSource and inputSource.player then @@ -322,15 +322,6 @@ function CharacterSelect:getCharacterButtons() end GAME.theme:playValidationSfx() if character then - if character:canSuperSelect() and holdTime > consts.SUPER_SELECTION_START + consts.SUPER_SELECTION_DURATION then - -- super select - if character.panels and panels[character.panels] then - player:setPanels(character.panels) - end - if character.stage and stages[character.stage] then - player:setStage(character.stage) - end - end character:playSelectionSfx() end player:setCharacter(selfElement.characterId) @@ -340,7 +331,7 @@ function CharacterSelect:getCharacterButtons() if characters[characterButton.characterId] and characters[characterButton.characterId]:canSuperSelect() then self.applySuperSelectInteraction(characterButton) else - characterButton.onSelect = characterButton.onClick + characterButton.onSelect = characterButton.action end end @@ -348,20 +339,7 @@ function CharacterSelect:getCharacterButtons() end local function updateSuperSelectShader(image, timer) - if timer > consts.SUPER_SELECTION_START then - if image.isVisible == false then - image:setVisibility(true) - end - local progress = (timer - consts.SUPER_SELECTION_START) / consts.SUPER_SELECTION_DURATION - if progress <= 1 then - image.shader:send("percent", progress) - end - else - if image.isVisible then - image:setVisibility(false) - end - image.shader:send("percent", 0) - end + end ---@param characterButton Button @@ -393,7 +371,7 @@ function CharacterSelect.applySuperSelectInteraction(characterButton) characterButton.onRelease = function(self, x, y, timeHeld) self.updateSuperSelectShader(self.superSelectImage, 0) if self:inBounds(x, y) then - self:onClick(input.mouse, timeHeld) + self:action(input.mouse, timeHeld) end end @@ -408,7 +386,7 @@ function CharacterSelect.applySuperSelectInteraction(characterButton) else self:yieldFocus() -- apply the actual click on release with the held time and reset it afterwards - self:onClick(inputs, self.holdTime) + self:action(inputs, self.holdTime) self.holdTime = 0 end self.updateSuperSelectShader(self.superSelectImage, self.holdTime) @@ -760,7 +738,7 @@ function CharacterSelect:createPlayerInfo(player) stackPanel.winrateLabel = ui.Label({ x = 4, - text = "ss_winrate" + id = "ss_winrate" }) stackPanel.winrateValueLabel = ui.Label({ @@ -812,9 +790,9 @@ function CharacterSelect:createRankedStatusPanel() vAlign = "top" }) if self.battleRoom.ranked then - rankedStatus.rankedLabel:setText("ss_ranked") + rankedStatus.rankedLabel:setId("ss_ranked") else - rankedStatus.rankedLabel:setText("ss_casual") + rankedStatus.rankedLabel:setId("ss_casual") end rankedStatus.commentLabel = ui.Label({ text = self.battleRoom.rankedComments or "", @@ -827,11 +805,11 @@ function CharacterSelect:createRankedStatusPanel() rankedStatus.update = function(self, ranked, comments) if ranked then - rankedStatus.rankedLabel:setText("ss_ranked") + rankedStatus.rankedLabel:setId("ss_ranked") else - rankedStatus.rankedLabel:setText("ss_casual") + rankedStatus.rankedLabel:setId("ss_casual") end - rankedStatus.commentLabel:setText(comments, nil, false) + rankedStatus.commentLabel:setText(comments) end self.battleRoom:connectSignal("rankedStatusChanged", rankedStatus, rankedStatus.update) @@ -877,10 +855,10 @@ end function CharacterSelect:createDifficultyCarousel(player, height) local passengers = { - { id = 1, uiElement = ui.Label({text = "easy", vAlign = "center", hAlign = "center"})}, - { id = 2, uiElement = ui.Label({text = "normal", vAlign = "center", hAlign = "center"})}, - { id = 3, uiElement = ui.Label({text = "hard", vAlign = "center", hAlign = "center"})}, - { id = 4, uiElement = ui.Label({text = "ss_ex_mode", vAlign = "center", hAlign = "center"})}, + { id = 1, uiElement = ui.Label({id = "easy", vAlign = "center", hAlign = "center"})}, + { id = 2, uiElement = ui.Label({id = "normal", vAlign = "center", hAlign = "center"})}, + { id = 3, uiElement = ui.Label({id = "hard", vAlign = "center", hAlign = "center"})}, + { id = 4, uiElement = ui.Label({id = "ss_ex_mode", vAlign = "center", hAlign = "center"})}, } local difficultyCarousel = ui.Carousel({ isEnabled = player.isLocal, diff --git a/client/src/scenes/DesignHelper.lua b/client/src/scenes/DesignHelper.lua index 3bd1ac31..591ba36f 100644 --- a/client/src/scenes/DesignHelper.lua +++ b/client/src/scenes/DesignHelper.lua @@ -1,7 +1,8 @@ local class = require("common.lib.class") local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") -local input = require("client.src.inputManager") +local inputs = require("client.src.inputManager") +local prof = require("common.lib.zoneProfiler") local DesignHelper = class(function(self, sceneParams) self:load(sceneParams) @@ -9,35 +10,303 @@ end, Scene) DesignHelper.name = "DesignHelper" -function DesignHelper:load() - self:loadGrid() - --self:loadPanels() - --self:loadStages() - --self.grid:createElementAt(1, 2, 2, 1, "stage", self.stageCarousel) - self.rankedSelection = ui.StackPanel({vFill = true, alignment = "left", hAlign = "center", vAlign = "center"}) - local trueLabel = ui.Label({text = "ss_ranked", vAlign = "top", hAlign = "center"}) - local falseLabel = ui.Label({text = "ss_casual", vAlign = "bottom", hAlign = "center"}) - self.rankedSelection:addChild(trueLabel) - self.rankedSelection:addChild(falseLabel) - self.grid:createElementAt(3, 2, 2, 1, "ranked", self.rankedSelection) - self.rankedSelection:addElement(self:loadRankedSelection(96)) - self.rankedSelection:addElement(self:loadRankedSelection(96)) +local function getSelectorTemplate(id) + local selector = ui.UiElement({ + layout = ui.Layouts.VerticalFlexLayout, + childGap = 4, + vAlign = "center", + hAlign = "center", + vFill = true, + }) + local button = ui.Button({ + hAlign = "center", + vAlign = "center", + minWidth = 84, + minHeight = 84, + backgroundColor = {1, 1, 1, 0}, + }) + local label = ui.Label({id = id}) + selector:addChild(button) + selector:addChild(label) + ui.CursorInteractable(selector, function(selector, cursor, dt) + button:receiveInputs(cursor, dt) + end) + + return selector, button +end + +local function createCharacterSelect(scene) + local scrollContainer = ui.ScrollContainer({ + scrollOrientation = "vertical", + minHeight = 400, + hFill = true, + vFill = true, + maxHeight = 800, + hAlign = "center", + --maxWidth = 1000, + }) + + scene.characterSelect = ui.UniSizedContainer({ + childrenWidth = 84, + childrenHeight = 84, + childGap = 16, + onYield = function (self) + scene.characterSelectContainer:detach() + end + }) + + local releaseFocus = function() + scene.cursor:releaseFocus(scene.characterSelect) + end + + for i, characterId in ipairs(visibleCharacters) do + local button = ui.CharacterButton({character = characters[characterId]}) + button.onAction = releaseFocus + scene.characterSelect:addChild(button) + end + + scrollContainer:addChild(scene.characterSelect) + + return scrollContainer +end + +local function createStageSelect(scene) + local scrollContainer = ui.ScrollContainer({ + scrollOrientation = "vertical", + minHeight = 400, + hFill = true, + vFill = true, + maxHeight = 800, + hAlign = "center", + }) + + scene.stageSelect = ui.UniSizedContainer({ + childrenWidth = 112, + childrenHeight = 84, + childGap = 16, + onYield = function (self) + scene.stageSelectContainer:detach() + end + }) + + local releaseFocus = function() + scene.cursor:releaseFocus(scene.stageSelect) + end + + for i, stageId in ipairs(visibleStages) do + local button = ui.StageButton({stage = stages[stageId]}) + button.onAction = releaseFocus + scene.stageSelect:addChild(button) + end + + scrollContainer:addChild(scene.stageSelect) + + return scrollContainer +end + +local function createPanelSetSelect(scene) + scene.panelSetSelect = ui.VerticalMenu({ + childGap = 8, + minHeight = 400, + hFill = true, + --vFill = true, + maxHeight = 800, + hAlign = "center", + onYield = function (self) + self:detach() + end + }) + + for panelSetId, panelSet in pairs(panels) do + local button = ui.PanelSetButton({panelSet = panelSet, hFill = true, maxWidth = 48 * 9}) + scene.panelSetSelect:addChild(button) + end + + -- alphabetical order I guess? + table.sort(scene.panelSetSelect.children, function(a, b) + return a.panelSet.id < b.panelSet.id + end) + + scene.panelSetSelect:addChild(ui.TextButton({ + label = ui.Label({id = "back"}), + action = function() scene.cursor:releaseFocus(scene.panelSetSelect) end + })) + + return scene.panelSetSelect end -function DesignHelper:loadGrid() - self.grid = ui.Grid({x = 180, y = 60, unitSize = 108, gridWidth = 9, gridHeight = 6, unitMargin = 6}) - self.uiRoot:addChild(self.grid) - -- self.cursor = GridCursor({ - -- grid = self.grid, - -- activeArea = {x1 = 1, y1 = 2, x2 = 9, y2 = 5}, - -- translateSubGrids = true, - -- startPosition = {x = 9, y = 2}, - -- playerNumber = 1 - -- }) - -- self.uiRoot:addChild(self.cursor) - -- self.cursor.escapeCallback = function() - -- SoundController:playSfx(themes[config.theme].sounds.menu_cancel) - -- end +function DesignHelper:load() + self.characterSelectContainer = createCharacterSelect(self) + self.stageSelectContainer = createStageSelect(self) + self.panelSetSelect = createPanelSetSelect(self) + self.uiRoot.layout = ui.Layouts.VerticalFlexLayout + self.uiRoot.childGap = 8 + ui.CursorNavigable(self.uiRoot) + + local roomMode = ui.UiElement({ + childGap = 8, + padding = 8, + hAlign = "center", + --hFill = true, + backgroundColor = {1, 0, 0, 0.5}, + layout = ui.Layouts.HorizontalFlexLayout, + }) + + roomMode:addChild(ui.Label({text = "Battle", hAlign = "center", vAlign = "center"})) + roomMode:addChild(ui.Label({text = "Arcade", hAlign = "center", vAlign = "center"})) + + ui.CursorInteractable(roomMode, function() end) + + self.uiRoot:addChild(roomMode) + + local gameMode = ui.UiElement({ + childGap = 8, + padding = 8, + backgroundColor = {0, 0, 1, 0.5}, + hAlign = "center", + vAlign = "center", + layout = ui.Layouts.HorizontalFlexLayout, + -- hFill = true, + }) + + gameMode:addChild(ui.Label({text = "VS"})) + gameMode:addChild(ui.Label({text = "VS Self"})) + gameMode:addChild(ui.Label({text = "Time Attack"})) + gameMode:addChild(ui.Label({text = "Endless"})) + gameMode:addChild(ui.Label({text = "Puzzle"})) + gameMode:addChild(ui.Label({text = "Training"})) + gameMode:addChild(ui.Label({text = "Line Clear"})) + + ui.CursorInteractable(gameMode, function() end) + + self.uiRoot:addChild(gameMode) + + local subSelectionSelector = ui.UniSizedContainer({ + childGap = 32, + padding = 8, + backgroundColor = {0, 1, 0, 0.5}, + childrenWidth = 112, + childrenHeight = 112, + hAlign = "center", + vAlign = "center", + --scrollOrientation = "horizontal", + }) + + local characterImage = ui.ImageContainer({ + image = characters[GAME.localPlayer.settings.selectedCharacterId].images.icon, + drawBorders = true, + outlineColor = {1, 1, 1, 1}, + hFill = true, + vFill = true, + }) + local characterSelectionSelector, characterButton = getSelectorTemplate("character") + characterButton:addChild(characterImage) + characterButton.action = function() + self.subSelection:addChild(self.characterSelectContainer) + self.cursor:deepenFocus(self.characterSelect) + end + + local stageImage = ui.ImageContainer({ + image = stages[GAME.localPlayer.settings.selectedStageId].images.thumbnail, + drawBorders = true, + outlineColor = {1, 1, 1, 1}, + hFill = true, + vFill = true, + }) + local stageSelectionSelector, stageButton = getSelectorTemplate("stage") + stageButton:addChild(stageImage) + stageButton.action = function() + self.subSelection:addChild(self.stageSelectContainer) + self.cursor:deepenFocus(self.stageSelect) + end + + local panelSelectionSelector, panelButton = getSelectorTemplate("panels") + + local panelSize = 28 + local panelContainer = ui.UniSizedContainer({ + maxWidth = 3 * panelSize, + childrenWidth = panelSize, + childrenHeight = panelSize, + hAlign = "center", + vAlign = "center", + }) + + for color = 1, 8 do + local panelImage = ui.ImageContainer({ + image = panels[GAME.localPlayer.settings.panelId].displayIcons[color], + width = panelSize, + height = panelSize, + }) + panelContainer:addChild(panelImage) + end + panelContainer:addChild(ui.ImageContainer({ + image = panels[GAME.localPlayer.settings.panelId].greyPanel, + width = panelSize, + height = panelSize, + })) + panelButton.padding = 4 + panelSelectionSelector.childGap = 0 + panelButton:addChild(panelContainer) + panelButton.action = function() + self.subSelection:addChild(self.panelSetSelect) + self.cursor:deepenFocus(self.panelSetSelect) + end + + local levelImage = ui.ImageContainer({ + image = GAME.theme.images.IMG_levels[GAME.localPlayer.settings.level or 1], + hFill = true, + vFill = true, + }) + local levelSelectionSelector, levelButton = getSelectorTemplate("level") + levelButton:addChild(levelImage) + + + subSelectionSelector:addChild(characterSelectionSelector) + subSelectionSelector:addChild(stageSelectionSelector) + subSelectionSelector:addChild(panelSelectionSelector) + --subSelectionSelector:addChild(ui.Label({text = "Ranked"})) + subSelectionSelector:addChild(levelSelectionSelector) + --subSelectionSelector:addChild(ui.Label({text = "Input Selection"})) + --subSelectionSelector:addChild(ui.Label({text = "Puzzle"})) + --subSelectionSelector:addChild(ui.Label({text = "Attack File"})) + + local readyButton = ui.TextButton({ + label = ui.Label({id = "ready"}), + hAlign = "center", + vAlign = "center", + minWidth = 84, + minHeight = 84, + maxWidth = 84, + maxHeight = 84, + }) + + local leaveButton = ui.TextButton({ + label = ui.Label({id = "leave"}), + hAlign = "center", + vAlign = "center", + minWidth = 84, + minHeight = 84, + maxWidth = 84, + maxHeight = 84, + }) + + subSelectionSelector:addChild(readyButton) + subSelectionSelector:addChild(leaveButton) + self.uiRoot:addChild(subSelectionSelector) + + self.subSelection = ui.UiElement({ + hAlign = "center", + hFill = true, + minHeight = 200, + vFill = true, + padding = 8, + backgroundColor = {0, 1, 0, 0.5},--{0.7, 0, 0.5, 1}, + }) + self.subSelection.debug = true + + self.uiRoot:addChild(self.subSelection) + + self.cursor = ui.ImageCursor(self.uiRoot, nil, GAME.theme:getCursorImages(1)) end function DesignHelper:loadRankedSelection(width) @@ -56,14 +325,16 @@ function DesignHelper:loadStages() self.stageCarousel:loadCurrentStages() end -function DesignHelper:update() - if input.allKeys.isDown["MenuEsc"] then +function DesignHelper:update(dt) + if inputs.isDown["MenuEsc"] and not self.cursor.focused then GAME.navigationStack:pop() end + self.cursor:receiveInputs(dt) end function DesignHelper:draw() - self.grid:draw() + self.uiRoot:draw() + self.cursor:draw() end return DesignHelper diff --git a/client/src/scenes/Game1pChallenge.lua b/client/src/scenes/Game1pChallenge.lua index 39fb0656..4b83ce26 100644 --- a/client/src/scenes/Game1pChallenge.lua +++ b/client/src/scenes/Game1pChallenge.lua @@ -1,6 +1,6 @@ local GameBase = require("client.src.scenes.GameBase") local class = require("common.lib.class") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local ChallengeModeTimeSplitsUIElement = require("client.src.graphics.ChallengeModeTimeSplitsUIElement") local ChallengeModeRecapScene = require("client.src.scenes.ChallengeModeRecapScene") @@ -9,7 +9,7 @@ local ChallengeModeRecapScene = require("client.src.scenes.ChallengeModeRecapSce local Game1pChallenge = class(function(self, sceneParams) self.totalTimeQuads = {} self.stageIndex = GAME.battleRoom.stageIndex - self.timeSplitElement = ChallengeModeTimeSplitsUIElement({x = consts.CANVAS_WIDTH / 2, y = 280}, GAME.battleRoom, GAME.battleRoom.stageIndex) + self.timeSplitElement = ChallengeModeTimeSplitsUIElement({x = love.graphics.getWidth() / 2, y = 280}, GAME.battleRoom, GAME.battleRoom.stageIndex) self.uiRoot:addChild(self.timeSplitElement) end, GameBase) @@ -42,10 +42,10 @@ end function Game1pChallenge:drawHUD() if GAME.battleRoom then - local drawX = consts.CANVAS_WIDTH / 2 + local drawX = love.graphics.getWidth() / 2 local drawY = 110 local width = 200 - local height = consts.CANVAS_HEIGHT - drawY + local height = love.graphics.getHeight() - drawY -- Background GraphicsUtil.drawRectangle("fill", drawX - width / 2, drawY, width, height, 0, 0, 0, 0.5) @@ -78,21 +78,21 @@ end function Game1pChallenge:drawDifficultyName(drawX, drawY) local limit = 400 - GraphicsUtil.printf(loc("difficulty"), drawX - limit / 2, drawY, limit, "center", nil, nil, 10) - GraphicsUtil.printf(GAME.battleRoom.difficultyName, drawX - limit / 2, drawY + 26, limit, "center", nil, nil, 10) + GraphicsUtil.printf(loc("difficulty"), drawX - limit / 2, drawY, limit, "center", nil, nil, "big") + GraphicsUtil.printf(GAME.battleRoom.difficultyName, drawX - limit / 2, drawY + 26, limit, "center", nil, nil, "big") end function Game1pChallenge:drawStageInfo(drawX, drawY) local limit = 400 - GraphicsUtil.printf("Stage", drawX - limit / 2, drawY, limit, "center", nil, nil, 10) + GraphicsUtil.printf("Stage", drawX - limit / 2, drawY, limit, "center", nil, nil, "big") GraphicsUtil.drawPixelFont(self.stageIndex, themes[config.theme].fontMaps.numbers[2], drawX, drawY + 26, themes[config.theme].win_Scale, themes[config.theme].win_Scale, "center", 0) end function Game1pChallenge:drawContinueInfo(drawX, drawY) local limit = 400 - GraphicsUtil.printf("Continues", drawX - limit / 2, drawY, limit, "center", nil, nil, 4) - GraphicsUtil.printf(GAME.battleRoom.continues, drawX - limit / 2, drawY + 20, limit, "center", nil, nil, 4) + GraphicsUtil.printf("Continues", drawX - limit / 2, drawY, limit, "center", nil, nil, "medium") + GraphicsUtil.printf(GAME.battleRoom.continues, drawX - limit / 2, drawY + 20, limit, "center", nil, nil, "medium") end return Game1pChallenge diff --git a/client/src/scenes/GameBase.lua b/client/src/scenes/GameBase.lua index 3b648611..aa34865b 100644 --- a/client/src/scenes/GameBase.lua +++ b/client/src/scenes/GameBase.lua @@ -5,7 +5,7 @@ local logger = require("common.lib.logger") local analytics = require("client.src.analytics") local input = require("client.src.inputManager") local tableUtils = require("common.lib.tableUtils") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local StageLoader = require("client.src.mods.StageLoader") local ModController = require("client.src.mods.ModController") local SoundController = require("client.src.music.SoundController") @@ -175,35 +175,34 @@ function GameBase:load() self.match:connectSignal("countdownEnded", self, self.onGameStart) self.stage = stages[self.match.stageId] - self.backgroundImage = UpdatingImage(self.stage.images.background, false, 0, 0, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) + self.backgroundImage = UpdatingImage(self.stage.images.background, false, 0, 0, love.graphics.getDimensions()) self.stageTrack = self:getStageTrack() - local pauseMenuItems = { - ui.MenuItem.createButtonMenuItem("pause_resume", nil, true, function() - GAME.theme:playValidationSfx() - self.pauseMenu:setVisibility(false) - self.match:togglePause() - if self.stageTrack and self.pauseState.musicWasPlaying then - SoundController:playMusic(self.stageTrack) - end - self:initializeFrameInfo() - end), - ui.MenuItem.createButtonMenuItem("back", nil, true, function() - GAME.theme:playCancelSfx() - self.match:abort() - self:startNextScene() - end), - } - - self.pauseMenu = ui.Menu({ - x = 0, - y = 0, + local resume = ui.MenuItem.createButtonMenuItem("pause_resume", nil, true, function() + GAME.theme:playValidationSfx() + self.pauseMenu:setVisibility(false) + self.match:togglePause() + if self.stageTrack and self.pauseState.musicWasPlaying then + SoundController:playMusic(self.stageTrack) + end + self:initializeFrameInfo() + end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, true, function() + GAME.theme:playCancelSfx() + self.match:abort() + self:startNextScene() + end) + + self.pauseMenu = ui.VerticalMenu({ hAlign = "center", - vAlign = "center", - menuItems = pauseMenuItems, - height = 200 + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + isVisible = false, }) - self.pauseMenu:setVisibility(false) + self.pauseMenu:addChild(resume) + self.pauseMenu:addChild(back) self.uiRoot:addChild(self.pauseMenu) leftover_time = 1 / 120 diff --git a/client/src/scenes/GameCatchUp.lua b/client/src/scenes/GameCatchUp.lua index 0724e24e..9d3aab46 100644 --- a/client/src/scenes/GameCatchUp.lua +++ b/client/src/scenes/GameCatchUp.lua @@ -1,7 +1,7 @@ local class = require("common.lib.class") local Scene = require("client.src.scenes.Scene") local GraphicsUtil = require("client.src.graphics.graphics_util") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local ModLoader = require("client.src.mods.ModLoader") local SoundController = require("client.src.music.SoundController") local logger = require("common.lib.logger") @@ -102,11 +102,12 @@ function GameCatchUp:update(dt) end function GameCatchUp:draw() + local w, h = love.graphics.getDimensions() local match = self.match GraphicsUtil.setColor(1, 1, 1, 1) - GraphicsUtil.drawRectangle("line", consts.CANVAS_WIDTH / 4 - 5, consts.CANVAS_HEIGHT / 2 - 25, consts.CANVAS_WIDTH / 2 + 10, 50) - GraphicsUtil.drawRectangle("fill", consts.CANVAS_WIDTH / 4, consts.CANVAS_HEIGHT / 2 - 20, consts.CANVAS_WIDTH / 2 * self.progress, 40) - GraphicsUtil.printf("Catching up: " .. match.engine.clock .. " out of " .. #match.stacks[1].engine.confirmedInput .. " frames", 0, 500, consts.CANVAS_WIDTH, "center") + GraphicsUtil.drawRectangle("line", w / 4 - 5, h / 2 - 25, w / 2 + 10, 50) + GraphicsUtil.drawRectangle("fill", w / 4, h / 2 - 20, w / 2 * self.progress, 40) + GraphicsUtil.printf("Catching up: " .. match.engine.clock .. " out of " .. #match.stacks[1].engine.confirmedInput .. " frames", 0, 500, w, "center") end return GameCatchUp \ No newline at end of file diff --git a/client/src/scenes/InputConfigMenu.lua b/client/src/scenes/InputConfigMenu.lua index ab9187a0..7333e857 100644 --- a/client/src/scenes/InputConfigMenu.lua +++ b/client/src/scenes/InputConfigMenu.lua @@ -1,7 +1,7 @@ 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 consts = require("client.src.consts") local input = require("client.src.inputManager") local joystickManager = require("common.lib.joystickManager") local util = require("common.lib.util") @@ -53,7 +53,7 @@ end function InputConfigMenu:getKeyDisplayName(key) local keyDisplayName = key if key and string.match(key, ":") then - local controllerKeySplit = util.split(key, ":") + local controllerKeySplit = string.split(key, ":") local controllerName = shortenControllerName(joystickManager.guidToName[controllerKeySplit[1]] or "Unplugged Controller") keyDisplayName = string.format("%s (%s-%s)", controllerKeySplit[3], controllerName, controllerKeySplit[2]) end @@ -100,7 +100,7 @@ function InputConfigMenu:setAllKeys() end function InputConfigMenu:currentKeyLabelForIndex(index) - return self.menu.menuItems[index].textButton.children[1] + return self.menu.menuItems[index].item.children[1] end function InputConfigMenu:setKeyStart(key) @@ -162,29 +162,43 @@ end function InputConfigMenu:load(sceneParams) self.configIndex = 1 - 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) + onValueChange = function(slider) self:updateInputConfigMenuLabels(slider.value) end + }) + + local configSelection = ui.MenuItem.createSliderMenuItem("configuration", nil, nil, self.slider) + local setAllKeys = ui.MenuItem.createButtonMenuItem("op_all_keys", nil, nil, function() self:setAllKeysStart() end) + local clearAllKeys = ui.MenuItem.createButtonMenuItem("Clear All Inputs", nil, false, function() self:clearAllInputs() end) + local resetToDefault = ui.MenuItem.createButtonMenuItem("Reset Keys To Default", nil, false, function() self:resetToDefault(menuOptions) end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) + + self.menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 840, + childGap = 8, + padding = 32, + width = 600, + }) + + self.menu:addChild(configSelection) for i, key in ipairs(consts.KEY_NAMES) do - local clickFunction = function() + local clickFunction = function() if not self.settingKey then self:setKeyStart(key) 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) + self.menu:addChild(ui.MenuItem.createLabeledButtonMenuItem(key, nil, false, keyName, nil, false, clickFunction)) end - 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) - - self.menu = ui.Menu.createCenteredMenu(menuOptions) + self.menu:addChild(setAllKeys) + self.menu:addChild(clearAllKeys) + self.menu:addChild(resetToDefault) + self.menu:addChild(back) self.uiRoot:addChild(self.menu) end diff --git a/client/src/scenes/Lobby.lua b/client/src/scenes/Lobby.lua index 48455457..558d68be 100644 --- a/client/src/scenes/Lobby.lua +++ b/client/src/scenes/Lobby.lua @@ -13,7 +13,7 @@ local Lobby = class(function(self, sceneParams) -- ui self.leaderboard = ui.Leaderboard({isVisible = false, x = 200, hAlign = "center", vAlign = "center"}) - self.lobbyMessage = ui.Label({text = "lb_select_player"}) + self.lobbyMessage = ui.Label({id = "lb_select_player"}) self.backgroundImg = themes[config.theme].images.bg_main self.lobbyMenu = nil self.lobbyMenuXoffsetMap = { @@ -58,34 +58,47 @@ function Lobby:load(sceneParams) end function Lobby:initLobbyMenu() - local menuItems = { - ui.MenuItem.createMenuItem(self.lobbyMessage), - ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_ENDLESS")) - end), - ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK")) - end), - ui.MenuItem.createButtonMenuItem("mm_1_vs", nil, nil, function() - if GAME.localPlayer.settings.style ~= GameModes.Styles.MODERN then - GAME.localPlayer:setStyle(GameModes.Styles.MODERN) - GAME.netClient:sendPlayerSettings(GAME.localPlayer) - end - GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_VS_SELF")) - end), - ui.MenuItem.createButtonMenuItem("lb_show_board", nil, nil, function() - if self.leaderboard.hasFocus then - self.leaderboard:yieldFocus() - else - self:toggleLeaderboard() - end - end), - ui.MenuItem.createButtonMenuItem("lb_back", nil, nil, exitMenu) - } - self.leaderboardToggleLabel = menuItems[5].textButton.children[1] + local lobbyMessage = ui.MenuItem.createMenuItem(self.lobbyMessage) + local endless = ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() + GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_ENDLESS")) + end) + local time = ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() + GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK")) + end) + local vsSelf = ui.MenuItem.createButtonMenuItem("mm_1_vs", nil, nil, function() + if GAME.localPlayer.settings.style ~= GameModes.Styles.MODERN then + GAME.localPlayer:setStyle(GameModes.Styles.MODERN) + GAME.netClient:sendPlayerSettings(GAME.localPlayer) + end + GAME.netClient:requestRoom(GameModes.getPreset("ONE_PLAYER_VS_SELF")) + end) + local showLeaderboard = ui.MenuItem.createButtonMenuItem("lb_show_board", nil, nil, function() + if self.leaderboard.hasFocus then + self.leaderboard:yieldFocus() + else + self:toggleLeaderboard() + end + end) + local back = ui.MenuItem.createButtonMenuItem("lb_back", nil, nil, exitMenu) + self.leaderboardToggleLabel = showLeaderboard.children[1] self.lobbyMenuStartingUp = true - self.lobbyMenu = ui.Menu.createCenteredMenu(menuItems) + self.lobbyMenu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 840, + childGap = 8, + padding = 32, + width = 600, + }) + + self.lobbyMenu:addChild(lobbyMessage) + self.lobbyMenu:addChild(endless) + self.lobbyMenu:addChild(time) + self.lobbyMenu:addChild(vsSelf) + self.lobbyMenu:addChild(showLeaderboard) + self.lobbyMenu:addChild(back) + self.lobbyMenu.x = self.lobbyMenuXoffsetMap[false] self.uiRoot:addChild(self.lobbyMenu) @@ -98,11 +111,11 @@ end function Lobby:toggleLeaderboard() GAME.theme:playMoveSfx() if not self.leaderboard.isVisible then - self.leaderboardToggleLabel:setText("lb_hide_board") + self.leaderboardToggleLabel:setId("lb_hide_board") GAME.netClient:requestLeaderboard() self.lobbyMenu:setFocus(self.leaderboard, function() self:toggleLeaderboard() end) else - self.leaderboardToggleLabel:setText("lb_show_board") + self.leaderboardToggleLabel:setId("lb_show_board") end self.leaderboard:setVisibility(not self.leaderboard.isVisible) self.lobbyMenu.x = self.lobbyMenuXoffsetMap[self.leaderboard.isVisible] @@ -144,13 +157,13 @@ end -- rebuilds the UI based on the new lobby information function Lobby:onLobbyStateUpdate(lobbyState) local previousText - if self.lobbyMenu.menuItems[self.lobbyMenu.selectedIndex].textButton then - previousText = self.lobbyMenu.menuItems[self.lobbyMenu.selectedIndex].textButton.children[1].text + if self.lobbyMenu.children[self.lobbyMenu.selectedIndex].children[1] then + previousText = self.lobbyMenu.children[self.lobbyMenu.selectedIndex].children[1].text end local desiredIndex = self.lobbyMenu.selectedIndex -- cleanup previous lobby menu - while #self.lobbyMenu.menuItems > 6 do + while #self.lobbyMenu.children > 6 do self.lobbyMenu:removeMenuItemAtIndex(2) end self.lobbyMenu:setSelectedIndex(1) @@ -164,7 +177,7 @@ function Lobby:onLobbyStateUpdate(lobbyState) if lobbyState.willingPlayers[v] then unmatchedPlayer = unmatchedPlayer .. " " .. loc("lb_received") end - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(unmatchedPlayer, nil, false, self:requestGameFunction(v))) + self.lobbyMenu:addChild(ui.MenuItem.createButtonMenuItem(unmatchedPlayer, nil, false, self:requestGameFunction(v)), 2) end end for _, room in ipairs(lobbyState.spectatableRooms) do @@ -172,10 +185,10 @@ function Lobby:onLobbyStateUpdate(lobbyState) local playerA = room.a .. self:playerRatingString(room.a) local playerB = room.b .. self:playerRatingString(room.b) local roomName = loc("lb_spectate") .. " " .. playerA .. " vs " .. playerB .. " (" .. room.state .. ")" - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room))) + self.lobbyMenu:addChild(ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room)), 2) else local roomName = loc("lb_spectate") .. " " .. room.name .. " (" .. room.state .. ")" - self.lobbyMenu:addMenuItem(2, ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room))) + self.lobbyMenu:addChild(ui.MenuItem.createButtonMenuItem(roomName, nil, false, self:requestSpectateFunction(room)), 2) end end @@ -183,13 +196,13 @@ function Lobby:onLobbyStateUpdate(lobbyState) self.lobbyMenu:setSelectedIndex(2) self.lobbyMenuStartingUp = false else - for i = 1, #self.lobbyMenu.menuItems do - if self.lobbyMenu.menuItems[i].textButton and self.lobbyMenu.menuItems[i].textButton.children[1].text == previousText then + for i = 1, #self.lobbyMenu.children do + if self.lobbyMenu.children[i].children[1] and self.lobbyMenu.children[i].children[1].text == previousText then desiredIndex = i break end end - self.lobbyMenu:setSelectedIndex(util.bound(2, desiredIndex, #self.lobbyMenu.menuItems)) + self.lobbyMenu:setSelectedIndex(util.bound(2, desiredIndex, #self.lobbyMenu.children)) end end @@ -205,9 +218,9 @@ function Lobby:update(dt) else if GAME.timer > GAME.netClient.loginTime + 5 then if #GAME.netClient.lobbyData.players == 1 then - self.lobbyMessage:setText("lb_alone", nil, true) + self.lobbyMessage:setId("lb_alone") else - self.lobbyMessage:setText("lb_select_player", nil, true) + self.lobbyMessage:setId("lb_select_player") end end self.lobbyMenu:receiveInputs() @@ -233,7 +246,7 @@ end function Lobby:onLoginFinish(result) if result.loggedIn then - self.lobbyMessage:setText(result.message, nil, false) + self.lobbyMessage:setText(result.message) else local messageTransition = MessageTransition(love.timer.getTime(), 5, result.message) GAME.navigationStack:pop(messageTransition) diff --git a/client/src/scenes/MainMenu.lua b/client/src/scenes/MainMenu.lua index bf13cb74..d4a5834d 100644 --- a/client/src/scenes/MainMenu.lua +++ b/client/src/scenes/MainMenu.lua @@ -1,5 +1,5 @@ local Scene = require("client.src.scenes.Scene") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local class = require("common.lib.class") @@ -18,6 +18,7 @@ local SetNameMenu = require("client.src.scenes.SetNameMenu") local OptionsMenu = require("client.src.scenes.OptionsMenu") local DesignHelper = require("client.src.scenes.DesignHelper") local system = require("client.src.system") +local inputs = require("client.src.inputManager") local TimeAttackGame = require("client.src.scenes.TimeAttackGame") local EndlessGame = require("client.src.scenes.EndlessGame") @@ -29,6 +30,7 @@ local PuzzleGame = require("client.src.scenes.PuzzleGame") local MainMenu = class(function(self, sceneParams) self.music = "main" self.menu = self:createMainMenu() + self.cursor = ui.Cursor(self.menu) self.uiRoot:addChild(self.menu) end, Scene) @@ -40,92 +42,124 @@ local function switchToScene(sceneName, transition) end function MainMenu:createMainMenu() + local menuContainer = ui.VerticalMenu({ + hAlign = "center", + vAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + backgroundColor = {1, 0, 0, 0.2}, + }) + + local endless = ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_ENDLESS"), EndlessGame) + if GAME.battleRoom then + switchToScene(EndlessMenu({battleRoom = GAME.battleRoom})) + end + end) - local menuItems = {ui.MenuItem.createButtonMenuItem("mm_1_endless", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_ENDLESS"), EndlessGame) - if GAME.battleRoom then - switchToScene(EndlessMenu({battleRoom = GAME.battleRoom})) - end - end), - ui.MenuItem.createButtonMenuItem("mm_1_puzzle", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_PUZZLE"), PuzzleGame) - if GAME.battleRoom then - switchToScene(PuzzleMenu({battleRoom = GAME.battleRoom})) - end - end), - ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK"), TimeAttackGame) - if GAME.battleRoom then - switchToScene(TimeAttackMenu({battleRoom = GAME.battleRoom})) - end - end), - ui.MenuItem.createButtonMenuItem("mm_1_vs", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_VS_SELF"), VsSelfGame) - if GAME.battleRoom then - switchToScene(CharacterSelectVsSelf({battleRoom = GAME.battleRoom})) - end - end), - ui.MenuItem.createButtonMenuItem("mm_1_training", nil, nil, function() - switchToScene(TrainingMenu()) - end), - ui.MenuItem.createButtonMenuItem("mm_1_challenge_mode", nil, nil, function() - switchToScene(ChallengeModeMenu()) - end), - ui.MenuItem.createButtonMenuItem("mm_2_vs_online", {""}, nil, function() - switchToScene(Lobby({serverIp = "panelattack.com"})) - end), - ui.MenuItem.createButtonMenuItem("mm_2_vs_local", nil, nil, function() - GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("TWO_PLAYER_VS"), GameBase) - if GAME.battleRoom then - switchToScene(CharacterSelect2p({battleRoom = GAME.battleRoom})) - end - end), - ui.MenuItem.createButtonMenuItem("mm_replay_browser", nil, nil, function() - switchToScene(ReplayBrowser()) - end), - ui.MenuItem.createButtonMenuItem("mm_configure", nil, nil, function() - switchToScene(InputConfigMenu()) - end), - ui.MenuItem.createButtonMenuItem("mm_set_name", nil, nil, function() - switchToScene(SetNameMenu()) - end), - ui.MenuItem.createButtonMenuItem("mm_options", nil, nil, function() - switchToScene(OptionsMenu()) - end), - ui.MenuItem.createButtonMenuItem("mm_fullscreen", {"\n(Alt+Enter)"}, nil, function() - GAME.theme:playValidationSfx() - GAME:toggleFullscreen() - end), - ui.MenuItem.createButtonMenuItem("mm_quit", nil, nil, function() love.event.quit() end ) - } - - local menu = ui.Menu.createCenteredMenu(menuItems) - - local debugMenuItems = {ui.MenuItem.createButtonMenuItem("Beta Server", nil, nil, function() switchToScene(Lobby({serverIp = "betaserver.panelattack.com", serverPort = 59569})) end), - ui.MenuItem.createButtonMenuItem("Localhost Server", nil, nil, function() switchToScene(Lobby({serverIp = "Localhost"})) end) - } - - local function addDebugMenuItems() - if config.debugShowServers then - for i, menuItem in ipairs(debugMenuItems) do - menu:addMenuItem(i + 7, menuItem) - end + local puzzle = ui.MenuItem.createButtonMenuItem("mm_1_puzzle", nil, nil, function() + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_PUZZLE"), PuzzleGame) + if GAME.battleRoom then + switchToScene(PuzzleMenu({battleRoom = GAME.battleRoom})) end - if config.debugShowDesignHelper then - menu:addMenuItem(#menu.menuItems, ui.MenuItem.createButtonMenuItem("Design Helper", nil, nil, function() - switchToScene(DesignHelper()) - end)) + end) + + local time = ui.MenuItem.createButtonMenuItem("mm_1_time", nil, nil, function() + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_TIME_ATTACK"), TimeAttackGame) + if GAME.battleRoom then + switchToScene(TimeAttackMenu({battleRoom = GAME.battleRoom})) end - end + end) - local function removeDebugMenuItems() - for i, menuItem in ipairs(debugMenuItems) do - menu:removeMenuItem(menuItem[1].id) + local vsSelf = ui.MenuItem.createButtonMenuItem("mm_1_vs", nil, nil, function() + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("ONE_PLAYER_VS_SELF"), VsSelfGame) + if GAME.battleRoom then + switchToScene(CharacterSelectVsSelf({battleRoom = GAME.battleRoom})) end + end) + local training = ui.MenuItem.createButtonMenuItem("mm_1_training", nil, nil, function() + switchToScene(TrainingMenu()) + end) + + local challenge = ui.MenuItem.createButtonMenuItem("mm_1_challenge_mode", nil, nil, function() + switchToScene(ChallengeModeMenu()) + end) + + local vsOnline = ui.MenuItem.createButtonMenuItem("mm_2_vs_online", {""}, nil, function() + switchToScene(Lobby({serverIp = "panelattack.com"})) + end) + + local vsLocal = ui.MenuItem.createButtonMenuItem("mm_2_vs_local", nil, nil, function() + GAME.battleRoom = BattleRoom.createLocalFromGameMode(GameModes.getPreset("TWO_PLAYER_VS"), GameBase) + if GAME.battleRoom then + switchToScene(CharacterSelect2p({battleRoom = GAME.battleRoom})) + end + end) + + local betaServer = ui.MenuItem.createButtonMenuItem("Beta Server", nil, false, function() + switchToScene(Lobby({serverIp = "betaserver.panelattack.com", serverPort = 59569})) + end) + + local localServer = ui.MenuItem.createButtonMenuItem("Localhost Server", nil, false, function() + switchToScene(Lobby({serverIp = "Localhost"})) + end) + + local replayBrowser = ui.MenuItem.createButtonMenuItem("mm_replay_browser", nil, nil, function() + switchToScene(ReplayBrowser()) + end) + + local inputConfig = ui.MenuItem.createButtonMenuItem("mm_configure", nil, nil, function() + switchToScene(InputConfigMenu()) + end) + + local setName = ui.MenuItem.createButtonMenuItem("mm_set_name", nil, nil, function() + switchToScene(SetNameMenu()) + end) + + local options = ui.MenuItem.createButtonMenuItem("mm_options", nil, nil, function() + switchToScene(OptionsMenu()) + end) + + local fullscreenToggle = ui.MenuItem.createButtonMenuItem("mm_fullscreen", {"\n(Alt+Enter)"}, nil, function() + GAME.theme:playValidationSfx() + GAME:toggleFullscreen() + end) + + local quit = ui.MenuItem.createButtonMenuItem("mm_quit", nil, nil, function() + love.event.quit() + end) + + local designHelper = ui.MenuItem.createButtonMenuItem("Design Helper", nil, false, function() + switchToScene(DesignHelper()) + end) + + menuContainer:addChild(endless) + menuContainer:addChild(puzzle) + menuContainer:addChild(time) + menuContainer:addChild(vsSelf) + menuContainer:addChild(training) + menuContainer:addChild(challenge) + menuContainer:addChild(vsOnline) + menuContainer:addChild(vsLocal) + if config.debugShowServers then + menuContainer:addChild(betaServer) + menuContainer:addChild(localServer) + end + menuContainer:addChild(replayBrowser) + menuContainer:addChild(inputConfig) + if config.name == "" then + menuContainer:addChild(setName) + end + menuContainer:addChild(options) + menuContainer:addChild(fullscreenToggle) + if config.debugShowDesignHelper then + menuContainer:addChild(designHelper) end + menuContainer:addChild(quit) - addDebugMenuItems() - return menu + return menuContainer end local nextUpdate = 900 @@ -151,7 +185,7 @@ end function MainMenu:update(dt) GAME.theme.images.bg_main:update(dt) - self.menu:receiveInputs() + self.cursor:receiveInputs(dt) self:checkForUpdates() end @@ -159,12 +193,14 @@ end function MainMenu:draw() GAME.theme.images.bg_main:draw() self.uiRoot:draw() + self.cursor:draw() local fontHeight = GraphicsUtil.getGlobalFont():getHeight() local infoYPosition = 705 - fontHeight / 2 + local screenWidth = love.graphics.getWidth() local loveString = system.loveVersionString() if loveString == "11.3.0" then - GraphicsUtil.printf(loc("love_version_warning"), -5, infoYPosition, consts.CANVAS_WIDTH, "right") + GraphicsUtil.printf(loc("love_version_warning"), -5, infoYPosition, screenWidth, "right") infoYPosition = infoYPosition - fontHeight end @@ -179,7 +215,7 @@ function MainMenu:draw() version = "PA Version: " .. GAME.updater.activeReleaseStream.name .. " " .. (GAME.updater.activeVersion and GAME.updater.activeVersion.version or "dev") end end - GraphicsUtil.printf(version, -5, infoYPosition, consts.CANVAS_WIDTH, "right") + GraphicsUtil.printf(version, -5, infoYPosition, screenWidth, "right") infoYPosition = infoYPosition - fontHeight @@ -196,7 +232,7 @@ function MainMenu:draw() end if showUpdaterUpdateWarning then - GraphicsUtil.printf(loc("auto_updater_version_warning") .. " https://panelattack.com", -5, infoYPosition, consts.CANVAS_WIDTH, "right") + GraphicsUtil.printf(loc("auto_updater_version_warning") .. " https://panelattack.com", -5, infoYPosition, screenWidth, "right") infoYPosition = infoYPosition - fontHeight end end diff --git a/client/src/scenes/ModManagement.lua b/client/src/scenes/ModManagement.lua index e9724e5d..13460df2 100644 --- a/client/src/scenes/ModManagement.lua +++ b/client/src/scenes/ModManagement.lua @@ -3,7 +3,7 @@ local class = require("common.lib.class") local ui = require("client.src.ui") local inputs = require("client.src.inputManager") local tableUtils = require("common.lib.tableUtils") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local CharacterLoader = require("client.src.mods.CharacterLoader") local SoundController = require("client.src.music.SoundController") local system = require("client.src.system") @@ -32,7 +32,7 @@ function ModManagement:load() self.headerLabel = ui.Label({ text = "placeholder", hAlign = "center", - fontSize = 16, + fontSize = "medium", }) self.headLine = self:loadGridHeader() @@ -55,7 +55,7 @@ function ModManagement:load() self.cursor.onMove = function(c) local newOffset = c.target.unitSize * (c.selectedGridPos.y - 1) if self.scrollContainer then - self.scrollContainer:keepVisible(-newOffset, c.target.unitSize) + self.scrollContainer:keepVisible(newOffset, c.target.unitSize) end end @@ -75,7 +75,7 @@ function ModManagement:load() if self.scrollContainer then self.stackPanel:remove(self.scrollContainer) end - self.headerLabel:setText("characters") + self.headerLabel:setId("characters") self.scrollContainer = self:newScrollContainer() self.scrollContainer:addChild(self.characterGrid) self.stackPanel:addElement(self.scrollContainer) @@ -92,7 +92,7 @@ function ModManagement:load() if self.scrollContainer then self.stackPanel:remove(self.scrollContainer) end - self.headerLabel:setText("stages") + self.headerLabel:setId("stages") self.scrollContainer = self:newScrollContainer() self.scrollContainer:addChild(self.stageGrid) self.stackPanel:addElement(self.scrollContainer) @@ -115,26 +115,24 @@ function ModManagement:load() end ) - local menuItems = { - self.manageCharactersButton, - self.manageStagesButton, - self.backButton - } - - if system.supportsFileBrowserOpen() then - table.insert(menuItems, 3, self.openSaveDirectoryButton) - end - - self.menu = ui.Menu({ - menuItems = menuItems, + self.menu = ui.VerticalMenu({ x = 100, y = 0, hAlign = "left", vAlign = "center", - width = 200, - height = 300, + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, }) + self.menu:addChild(self.manageCharactersButton) + self.menu:addChild(self.manageStagesButton) + if system.supportsFileBrowserOpen() then + self.menu:addChild(self.openSaveDirectoryButton) + end + self.menu:addChild(self.backButton) + self.uiRoot:addChild(self.menu) end diff --git a/client/src/scenes/OptionsMenu.lua b/client/src/scenes/OptionsMenu.lua index 2238b124..d6dfef65 100644 --- a/client/src/scenes/OptionsMenu.lua +++ b/client/src/scenes/OptionsMenu.lua @@ -2,7 +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 consts = require("common.engine.consts") +local consts = require("client.src.consts") local fileUtils = require("client.src.FileUtils") local analytics = require("client.src.analytics") local class = require("common.lib.class") @@ -25,11 +25,13 @@ end, Scene) OptionsMenu.name = "OptionsMenu" -local SCROLL_STEP = 14 - function OptionsMenu:loadScreens() local menus = {} + local function onMenuDetach(menu) + self.cursor:releaseFocus(menu) + end + menus.baseMenu = self:loadBaseMenu() menus.generalMenu = self:loadGeneralMenu() menus.graphicsMenu = self:loadGraphicsMenu() @@ -38,15 +40,15 @@ function OptionsMenu:loadScreens() menus.aboutMenu = self:loadAboutMenu() menus.modifyUserIdMenu = self:loadModifyUserIdMenu() menus.systemInfo = self:loadInfoScreen(self:getSystemInfo()) - menus.aboutThemes = self:loadInfoScreen(save.read_txt_file("docs/themes.md")) - menus.aboutCharacters = self:loadInfoScreen(save.read_txt_file("docs/characters.md")) - menus.aboutStages = self:loadInfoScreen(save.read_txt_file("docs/stages.md")) - menus.aboutPanels = self:loadInfoScreen(save.read_txt_file("docs/panels.md")) - menus.aboutAttackFiles = self:loadInfoScreen(save.read_txt_file("docs/training.txt")) - menus.installingMods = self:loadInfoScreen(save.read_txt_file("docs/installMods.md")) - - if #menus.modifyUserIdMenu.menuItems == 1 then - menus.baseMenu:removeMenuItemAtIndex(7) + + for menuName, menu in pairs(menus) do + if menuName ~= "baseMenu" then + menu.onDetach = onMenuDetach + end + end + + if #menus.modifyUserIdMenu.children == 1 then + menus.modifyUserIdMenu:detach() end return menus @@ -76,14 +78,21 @@ function OptionsMenu:updateMenuLanguage() end function OptionsMenu:switchToScreen(screenName) - self.menus[self.activeMenuName]:detach() - self.uiRoot:addChild(self.menus[screenName]) + local oldMenu = self.menus[self.activeMenuName] + local newMenu = self.menus[screenName] + oldMenu:detach() + if not self.cursor.focusToHover[newMenu] then + self.cursor:deepenFocus(newMenu) + else + self.cursor:releaseFocus(oldMenu) + end + self.uiRoot:addChild(newMenu) self.activeMenuName = screenName end local function createToggleButtonGroup(configField, onChangeFn) return ui.ButtonGroup({ - buttons = {ui.TextButton({width = 60, label = ui.Label({text = "op_off"})}), ui.TextButton({width = 60, label = ui.Label({text = "op_on"})})}, + buttons = {ui.TextButton({width = 60, label = ui.Label({id = "op_off"})}), ui.TextButton({width = 60, label = ui.Label({id = "op_on"})})}, values = {false, true}, selectedIndex = config[configField] and 2 or 1, onChange = function(group, value) @@ -175,40 +184,56 @@ function OptionsMenu:loadBaseMenu() end }) - local baseMenuOptions = { - ui.MenuItem.createStepperMenuItem("op_language", nil, nil, languageStepper), - ui.MenuItem.createButtonMenuItem("op_general", nil, nil, function() - GAME.theme:playValidationSfx() - self:switchToScreen("generalMenu") - end), - ui.MenuItem.createButtonMenuItem("op_graphics", nil, nil, function() - GAME.theme:playValidationSfx() - self:switchToScreen("graphicsMenu") - end), - ui.MenuItem.createButtonMenuItem("op_audio", nil, nil, function() - GAME.theme:playValidationSfx() - self:switchToScreen("audioMenu") - end), - ui.MenuItem.createButtonMenuItem("op_debug", nil, nil, function() - GAME.theme:playValidationSfx() - self:switchToScreen("debugMenu") - end), - ui.MenuItem.createButtonMenuItem("op_about", nil, nil, function() - GAME.theme:playValidationSfx() - self:switchToScreen("aboutMenu") - end), - ui.MenuItem.createButtonMenuItem("Modify User ID", nil, false, function() - GAME.theme:playValidationSfx() - self:switchToScreen("modifyUserIdMenu") - end), - ui.MenuItem.createButtonMenuItem("Manage Mods", nil, false, function() - GAME.theme:playValidationSfx() - GAME.navigationStack:push(ModManagement()) - end), - ui.MenuItem.createButtonMenuItem("back", nil, nil, self.exit) - } + local languageSelection = ui.MenuItem.createStepperMenuItem("op_language", nil, nil, languageStepper) + local generalMenu = ui.MenuItem.createButtonMenuItem("op_general", nil, nil, function() + GAME.theme:playValidationSfx() + self:switchToScreen("generalMenu") + end) + local graphicsMenu = ui.MenuItem.createButtonMenuItem("op_graphics", nil, nil, function() + GAME.theme:playValidationSfx() + self:switchToScreen("graphicsMenu") + end) + local audioMenu = ui.MenuItem.createButtonMenuItem("op_audio", nil, nil, function() + GAME.theme:playValidationSfx() + self:switchToScreen("audioMenu") + end) + local debugMenu = ui.MenuItem.createButtonMenuItem("op_debug", nil, nil, function() + GAME.theme:playValidationSfx() + self:switchToScreen("debugMenu") + end) + local aboutMenu = ui.MenuItem.createButtonMenuItem("op_about", nil, nil, function() + GAME.theme:playValidationSfx() + self:switchToScreen("aboutMenu") + end) + local modifyUserId = ui.MenuItem.createButtonMenuItem("Modify User ID", nil, false, function() + GAME.theme:playValidationSfx() + self:switchToScreen("modifyUserIdMenu") + end) + local modManagement = ui.MenuItem.createButtonMenuItem("Manage Mods", nil, false, function() + GAME.theme:playValidationSfx() + GAME.navigationStack:push(ModManagement()) + end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, self.exit) + + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + menu:addChild(languageSelection) + menu:addChild(generalMenu) + menu:addChild(graphicsMenu) + menu:addChild(audioMenu) + menu:addChild(debugMenu) + menu:addChild(aboutMenu) + menu:addChild(modifyUserId) + menu:addChild(modManagement) + menu:addChild(back) - local menu = ui.Menu.createCenteredMenu(baseMenuOptions) return menu end @@ -216,8 +241,8 @@ function OptionsMenu:loadGeneralMenu() local saveReplaysPubliclyIndexMap = {["with my name"] = 1, ["anonymously"] = 2, ["not at all"] = 3} local publicReplayButtonGroup = ui.ButtonGroup({ buttons = { - ui.TextButton({label = ui.Label({text = "op_replay_public_with_name"})}), - ui.TextButton({label = ui.Label({text = "op_replay_public_anonymously"})}), ui.TextButton({label = ui.Label({text = "op_replay_public_no"})}) + ui.TextButton({label = ui.Label({id = "op_replay_public_with_name"})}), + ui.TextButton({label = ui.Label({id = "op_replay_public_anonymously"})}), ui.TextButton({label = ui.Label({id = "op_replay_public_no"})}) }, values = {"with my name", "anonymously", "not at all"}, selectedIndex = saveReplaysPubliclyIndexMap[config.save_replays_publicly], @@ -284,26 +309,21 @@ function OptionsMenu:loadGeneralMenu() ---@cast index integer index = util.bound(1, index, #group.buttons) -- simulate changing to the button that replaces the one that got removed due to no attached versions - group.buttons[index]:onClick(nil, 0) + group.buttons[index]:action(nil, 0) end end }) end - local generalMenuOptions = { - ui.MenuItem.createToggleButtonGroupMenuItem("op_fps", nil, nil, createToggleButtonGroup("show_fps")), - ui.MenuItem.createToggleButtonGroupMenuItem("op_ingame_infos", nil, nil, createToggleButtonGroup("show_ingame_infos")), - ui.MenuItem.createToggleButtonGroupMenuItem("op_analytics", nil, nil, createToggleButtonGroup("enable_analytics", function() - analytics.init() - end)), - ui.MenuItem.createToggleButtonGroupMenuItem("op_replay_public", nil, nil, publicReplayButtonGroup), - } + local showFps = ui.MenuItem.createToggleButtonGroupMenuItem("op_fps", nil, nil, createToggleButtonGroup("show_fps")) + local showIngameInfos = ui.MenuItem.createToggleButtonGroupMenuItem("op_ingame_infos", nil, nil, createToggleButtonGroup("show_ingame_infos")) + local enableAnalytics = ui.MenuItem.createToggleButtonGroupMenuItem("op_analytics", nil, nil, createToggleButtonGroup("enable_analytics", function() + analytics.init() + end)) + local saveReplaysPublicly = ui.MenuItem.createToggleButtonGroupMenuItem("op_replay_public", nil, nil, publicReplayButtonGroup) - if releaseStreamSelection then - generalMenuOptions[#generalMenuOptions+1] = ui.MenuItem.createToggleButtonGroupMenuItem("Release Stream", nil, false, releaseStreamSelection) - end - generalMenuOptions[#generalMenuOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() GAME.theme:playCancelSfx() self:switchToScreen("baseMenu") @@ -314,11 +334,42 @@ function OptionsMenu:loadGeneralMenu() end end) - local menu = ui.Menu.createCenteredMenu(generalMenuOptions) + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + menu:addChild(showFps) + menu:addChild(showIngameInfos) + menu:addChild(enableAnalytics) + menu:addChild(saveReplaysPublicly) + + if releaseStreamSelection then + menu:addChild(ui.MenuItem.createToggleButtonGroupMenuItem("Release Stream", nil, false, releaseStreamSelection)) + end + + menu:addChild(back) + return menu end function OptionsMenu:loadGraphicsMenu() + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + minWidth = 600, + maxWidth = 900, + hFill = true, + backgroundColor = {1, 0, 0, 0.4} + }) + local themeIndex local themeLabels = {} for i, v in ipairs(themeIds) do @@ -337,7 +388,7 @@ function OptionsMenu:loadGraphicsMenu() config.theme = value GAME.theme = themes[value] SoundController:stopMusic() - GraphicsUtil.setGlobalFont(themes[config.theme].font.path, themes[config.theme].font.size) + GraphicsUtil.setGlobalFont(themes[config.theme].font.path, (themes[config.theme].font.size or 12) - 12) self:updateMenuLanguage() self.backgroundImage = themes[config.theme].images.bg_main self:applyMusic() @@ -375,16 +426,16 @@ function OptionsMenu:loadGraphicsMenu() getFixedScaleSlider()) local function updateFixedButtonGroupVisibility() if config.gameScaleType ~= "fixed" then - self.menus.graphicsMenu:removeMenuItem(fixedScaleSlider.id) + fixedScaleSlider:detach() else - if self.menus.graphicsMenu:containsMenuItemID(fixedScaleSlider.id) == false then - self.menus.graphicsMenu:addMenuItem(3, fixedScaleSlider) + if not fixedScaleSlider.parent then + menu:addChild(fixedScaleSlider, 3) end end end local scaleTypeData = { - {value = "auto", text = "op_scale_auto"}, {value = "fit", text = "op_scale_fit"}, {value = "fixed", text = "op_scale_fixed"} + {value = "auto", id = "op_scale_auto"}, {value = "fit", id = "op_scale_fit"}, {value = "fixed", id = "op_scale_fixed"} } for index, value in ipairs(scaleTypeData) do value.index = index @@ -392,7 +443,7 @@ function OptionsMenu:loadGraphicsMenu() local scaleButtonGroup = ui.ButtonGroup({ buttons = tableUtils.map(scaleTypeData, function(scaleType) - return ui.TextButton({label = ui.Label({text = scaleType.text})}) + return ui.TextButton({label = ui.Label({id = scaleType.id})}) end), values = tableUtils.map(scaleTypeData, function(scaleType) return scaleType.value @@ -422,25 +473,33 @@ function OptionsMenu:loadGraphicsMenu() return slider end - local graphicsMenuOptions = { - ui.MenuItem.createStepperMenuItem("op_theme", nil, nil, themeStepper), - ui.MenuItem.createToggleButtonGroupMenuItem("op_scale", nil, nil, scaleButtonGroup), - ui.MenuItem.createSliderMenuItem("op_portrait_darkness", nil, nil, createConfigSlider("portrait_darkness", 0, 100)), - ui.MenuItem.createToggleButtonGroupMenuItem("op_popfx", nil, nil, createToggleButtonGroup("popfx")), - ui.MenuItem.createToggleButtonGroupMenuItem("op_renderTelegraph", nil, nil, createToggleButtonGroup("renderTelegraph")), - ui.MenuItem.createToggleButtonGroupMenuItem("op_renderAttacks", nil, nil, createToggleButtonGroup("renderAttacks")), - ui.MenuItem.createSliderMenuItem("op_shakeIntensity", nil, nil, getShakeIntensitySlider()), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + local themeSelection = ui.MenuItem.createStepperMenuItem("op_theme", nil, nil, themeStepper) + local scaleType = ui.MenuItem.createToggleButtonGroupMenuItem("op_scale", nil, nil, scaleButtonGroup) + local portraitDarkness = ui.MenuItem.createSliderMenuItem("op_portrait_darkness", nil, nil, createConfigSlider("portrait_darkness", 0, 100)) + local popFx = ui.MenuItem.createToggleButtonGroupMenuItem("op_popfx", nil, nil, createToggleButtonGroup("popfx")) + local telegraph = ui.MenuItem.createToggleButtonGroupMenuItem("op_renderTelegraph", nil, nil, createToggleButtonGroup("renderTelegraph")) + local attacks = ui.MenuItem.createToggleButtonGroupMenuItem("op_renderAttacks", nil, nil, createToggleButtonGroup("renderAttacks")) + local shakeIntensity = ui.MenuItem.createSliderMenuItem("op_shakeIntensity", nil, nil, getShakeIntensitySlider()) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() GAME.showGameScaleUntil = GAME.timer GAME.theme:playCancelSfx() self:switchToScreen("baseMenu") end) - } - local menu = ui.Menu.createCenteredMenu(graphicsMenuOptions) + menu:addChild(themeSelection) + menu:addChild(scaleType) + if config.gameScaleType == "fixed" then - menu:addMenuItem(3, fixedScaleSlider) + menu:addChild(fixedScaleSlider, 3) end + + menu:addChild(portraitDarkness) + menu:addChild(popFx) + menu:addChild(telegraph) + menu:addChild(attacks) + menu:addChild(shakeIntensity) + menu:addChild(back) + return menu end @@ -448,8 +507,8 @@ function OptionsMenu:loadSoundMenu() local musicFrequencyIndexMap = {["stage"] = 1, ["often_stage"] = 2, ["either"] = 3, ["often_characters"] = 4, ["characters"] = 5} local musicFrequencyStepper = ui.Stepper({ labels = { - ui.Label({text = "op_only_stage"}), ui.Label({text = "op_often_stage"}), ui.Label({text = "op_stage_characters"}), - ui.Label({text = "op_often_characters"}), ui.Label({text = "op_only_characters"}) + ui.Label({id = "op_only_stage"}), ui.Label({id = "op_often_stage"}), ui.Label({id = "op_stage_characters"}), + ui.Label({id = "op_often_characters"}), ui.Label({id = "op_only_characters"}) }, values = {"stage", "often_stage", "either", "often_characters", "characters"}, selectedIndex = musicFrequencyIndexMap[config.use_music_from], @@ -459,115 +518,170 @@ function OptionsMenu:loadSoundMenu() end }) - local audioMenuOptions = { - ui.MenuItem.createSliderMenuItem("op_vol", nil, nil, createConfigSlider("master_volume", 0, 100, function(slider) + local masterVolume = ui.MenuItem.createSliderMenuItem("op_vol", nil, nil, createConfigSlider("master_volume", 0, 100, function(slider) SoundController:setMasterVolume(slider.value) - end)), - ui.MenuItem.createSliderMenuItem("op_vol_sfx", nil, nil, createConfigSlider("SFX_volume", 0, 100, function() + end)) + local sfxVolume = ui.MenuItem.createSliderMenuItem("op_vol_sfx", nil, nil, createConfigSlider("SFX_volume", 0, 100, function() SoundController:applyConfigVolumes() - end)), - ui.MenuItem.createSliderMenuItem("op_vol_music", nil, nil, createConfigSlider("music_volume", 0, 100, function() + end)) + local musicVolume = ui.MenuItem.createSliderMenuItem("op_vol_music", nil, nil, createConfigSlider("music_volume", 0, 100, function() SoundController:applyConfigVolumes() - end)), - ui.MenuItem.createToggleButtonGroupMenuItem("op_menu_music", nil, nil, createToggleButtonGroup("enableMenuMusic", function() self:applyMusic() end)), - ui.MenuItem.createStepperMenuItem("op_use_music_from", nil, nil, musicFrequencyStepper), - ui.MenuItem.createToggleButtonGroupMenuItem("op_music_delay", nil, nil, createToggleButtonGroup("danger_music_changeback_delay")), - ui.MenuItem.createButtonMenuItem("mm_music_test", nil, nil, function() + end)) + local menuMusic = ui.MenuItem.createToggleButtonGroupMenuItem("op_menu_music", nil, nil, createToggleButtonGroup("enableMenuMusic", function() self:applyMusic() end)) + local musicSource = ui.MenuItem.createStepperMenuItem("op_use_music_from", nil, nil, musicFrequencyStepper) + local dangerChangeBackDelay = ui.MenuItem.createToggleButtonGroupMenuItem("op_music_delay", nil, nil, createToggleButtonGroup("danger_music_changeback_delay")) + local musicTest = ui.MenuItem.createButtonMenuItem("mm_music_test", nil, nil, function() GAME.navigationStack:push(SoundTest()) - end), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() GAME.theme:playCancelSfx() self:switchToScreen("baseMenu") end) - } - local menu = ui.Menu.createCenteredMenu(audioMenuOptions) + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + menu:addChild(masterVolume) + menu:addChild(sfxVolume) + menu:addChild(musicVolume) + menu:addChild(menuMusic) + menu:addChild(musicSource) + menu:addChild(dangerChangeBackDelay) + menu:addChild(musicTest) + menu:addChild(back) + return menu end function OptionsMenu:loadDebugMenu() - local debugMenuOptions = { - ui.MenuItem.createToggleButtonGroupMenuItem("op_debug", 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), - } + local debugMode = ui.MenuItem.createToggleButtonGroupMenuItem("op_debug", nil, nil, createToggleButtonGroup("debug_mode")) + local vsFramesBehind = ui.MenuItem.createSliderMenuItem("VS Frames Behind", nil, false, createConfigSlider("debug_vsFramesBehind", 0, 200)) + local debugServers = ui.MenuItem.createToggleButtonGroupMenuItem("Show Debug Servers", nil, false, createToggleButtonGroup("debugShowServers")) + local designHelper = ui.MenuItem.createToggleButtonGroupMenuItem("Show Design Helper", nil, false, createToggleButtonGroup("debugShowDesignHelper")) + local windowSizeTester = ui.MenuItem.createButtonMenuItem("Window Size Tester", nil, false, function() + GAME.navigationStack:push(require("client.src.scenes.WindowSizeTester")()) + end) + local profilingFilter = ui.MenuItem.createSliderMenuItem("Discard frames below duration (ms)", nil, false, createConfigSlider("debugProfileThreshold", 0, 100, + function() + prof.setDurationFilter(config.debugProfileThreshold / 1000) + end)) + local profiling = ui.MenuItem.createToggleButtonGroupMenuItem("Profile frame times", nil, false, createToggleButtonGroup("debugProfile", + function() + prof.enable(config.debugProfile) + prof.setDurationFilter(config.debugProfileThreshold / 1000) + profilingFilter:setEnabled(config.debugProfile) + end)) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + GAME.theme:playCancelSfx() + self:switchToScreen("baseMenu") + end) - return ui.Menu.createCenteredMenu(debugMenuOptions) + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + menu:addChild(debugMode) + menu:addChild(vsFramesBehind) + menu:addChild(debugServers) + menu:addChild(designHelper) + menu:addChild(windowSizeTester) + menu:addChild(profiling) + menu:addChild(profilingFilter) + menu:addChild(back) + + return menu end function OptionsMenu:loadAboutMenu() - local aboutMenuOptions = { - ui.MenuItem.createButtonMenuItem("op_about_themes", nil, nil, function() - GAME.theme:playValidationSfx() - love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/themes.md") - end), - ui.MenuItem.createButtonMenuItem("op_about_characters", nil, nil, function() - GAME.theme:playValidationSfx() - love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/characters.md") - end), - ui.MenuItem.createButtonMenuItem("op_about_stages", nil, nil, function() - GAME.theme:playValidationSfx() - love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/stages.md") - end), - ui.MenuItem.createButtonMenuItem("op_about_panels", nil, nil, function() - GAME.theme:playValidationSfx() - love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/panels.md") - end), - ui.MenuItem.createButtonMenuItem("About Attack Files", nil, nil, function() - GAME.theme:playValidationSfx() - love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/training.txt") - end), - ui.MenuItem.createButtonMenuItem("Installing Mods", nil, nil, function() - GAME.theme:playValidationSfx() - love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/installMods.md") - end), - ui.MenuItem.createButtonMenuItem("System Info", nil, nil, function() - GAME.theme:playValidationSfx() - self:switchToScreen("systemInfo") - end), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() - GAME.theme:playCancelSfx() - self:switchToScreen("baseMenu") - end) - } + local aboutThemes = ui.MenuItem.createButtonMenuItem("op_about_themes", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/themes.md") + end) + local aboutCharacters = ui.MenuItem.createButtonMenuItem("op_about_characters", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/characters.md") + end) + local aboutStages = ui.MenuItem.createButtonMenuItem("op_about_stages", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/stages.md") + end) + local aboutPanels = ui.MenuItem.createButtonMenuItem("op_about_panels", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/panels.md") + end) + local aboutAttackFiles = ui.MenuItem.createButtonMenuItem("About Attack Files", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/training.txt") + end) + local aboutInstallingMods = ui.MenuItem.createButtonMenuItem("Installing Mods", nil, nil, function() + GAME.theme:playValidationSfx() + love.system.openURL("https://github.com/panel-attack/panel-game/blob/beta/docs/installMods.md") + end) + local systemInfo = ui.MenuItem.createButtonMenuItem("System Info", nil, nil, function() + GAME.theme:playValidationSfx() + self:switchToScreen("systemInfo") + end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + GAME.theme:playCancelSfx() + self:switchToScreen("baseMenu") + end) + + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + menu:addChild(aboutThemes) + menu:addChild(aboutCharacters) + menu:addChild(aboutStages) + menu:addChild(aboutPanels) + menu:addChild(aboutAttackFiles) + menu:addChild(aboutInstallingMods) + menu:addChild(systemInfo) + menu:addChild(back) - local menu = ui.Menu.createCenteredMenu(aboutMenuOptions) return menu end function OptionsMenu:loadModifyUserIdMenu() - local modifyUserIdOptions = {} + local menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) local userIDDirectories = fileUtils.getFilteredDirectoryItems("servers") for i = 1, #userIDDirectories do if love.filesystem.getInfo("servers/" .. userIDDirectories[i] .. "/user_id.txt", "file") then - modifyUserIdOptions[#modifyUserIdOptions + 1] = ui.MenuItem.createButtonMenuItem(userIDDirectories[i], nil, false, function() + menu:addChild(ui.MenuItem.createButtonMenuItem(userIDDirectories[i], nil, false, function() GAME.navigationStack:push(SetUserIdMenu({serverIp = userIDDirectories[i]})) - end) + end)) end end - modifyUserIdOptions[#modifyUserIdOptions + 1] = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() GAME.theme:playCancelSfx() self:switchToScreen("baseMenu") end) - return ui.Menu.createCenteredMenu(modifyUserIdOptions) + menu:addChild(back) + return menu end function OptionsMenu:load() @@ -575,15 +689,17 @@ function OptionsMenu:load() self.backgroundImage = themes[config.theme].images.bg_main self.uiRoot:addChild(self.menus.baseMenu) + self.cursor = ui.Cursor(self.menus.baseMenu) end function OptionsMenu:update(dt) self.backgroundImage:update(dt) - self.menus[self.activeMenuName]:receiveInputs(inputManager) + self.cursor:receiveInputs(dt) end function OptionsMenu:draw() self.backgroundImage:draw() + self.cursor:draw() self.uiRoot:draw() end diff --git a/client/src/scenes/PortraitGame.lua b/client/src/scenes/PortraitGame.lua index 4e39eafc..9d03e0b7 100644 --- a/client/src/scenes/PortraitGame.lua +++ b/client/src/scenes/PortraitGame.lua @@ -1,6 +1,6 @@ local GameBase = require("client.src.scenes.GameBase") local class = require("common.lib.class") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local Telegraph = require("client.src.graphics.Telegraph") local GraphicsUtil = require("client.src.graphics.graphics_util") local ui = require("client.src.ui") @@ -36,7 +36,7 @@ function PortraitGame:customLoad() self.uiRoot.height = consts.CANVAS_WIDTH local communityMessage = ui.Label({ - text = "join_community", + id = "join_community", replacements = {"\ndiscord." .. consts.SERVER_LOCATION}, translate = true, hAlign = "center", @@ -138,7 +138,7 @@ function PortraitGame:drawMultibar(stack) local x = math.floor((stack.frameOriginX + stack.panelOriginXOffset + overtimePos[1] / 3) * stack.gfxScale) local y = stack.panelOriginY * stack.gfxScale - local limit = GAME.globalCanvas:getWidth() - x + local limit = love.graphics.getWidth() - x local alignment = "right" limit = x - GraphicsUtil.getGlobalFont():getWidth(formattedSeconds) / 2 x = 0 @@ -189,13 +189,11 @@ function PortraitGame:draw() end function PortraitGame:flipToPortrait() - -- recreate the global canvas in portrait dimensions - 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() then -- flip the window dimensions to portrait - love.window.updateMode(height, width, {}) + GraphicsUtil.updateMode(height, width, {}) love.window.setFullscreen(true) --GAME:updateCanvasPositionAndScale(width, height) end @@ -206,13 +204,13 @@ function PortraitGame:flipToPortrait() local stack = player.stack stack.gfxScale = 5 -- force center it horizontally - local frameX = (GAME.globalCanvas:getWidth() / 2 - stack:canvasWidth() / 2) + local frameX = (love.graphics.getWidth() / 2 - stack:canvasWidth() / 2) -- and anchor at the bottom - local frameY = (GAME.globalCanvas:getHeight() - stack:canvasHeight()) + local frameY = (love.graphics.getHeight() - stack:canvasHeight()) stack:moveToPosition(frameX, frameY) -- create a raise button that interacts with the touch controller - local raiseButton = ui.TextButton({label = ui.Label({text = "raise", fontSize = 20}), hAlign = "right", vAlign = "bottom", height = player.stack:canvasHeight() / 2}) + local raiseButton = ui.TextButton({label = ui.Label({id = "raise", fontSize = "big"}), hAlign = "right", vAlign = "bottom", height = player.stack:canvasHeight() / 2}) raiseButton.onTouch = function(button, x, y) button.backgroundColor[4] = 1 stack.touchInputDetector.touchingRaise = true @@ -231,7 +229,7 @@ function PortraitGame:flipToPortrait() local stack = player.stack stack.gfxScale = 1 stack.canvas = true - local frameX = (GAME.globalCanvas:getWidth() - stack:canvasWidth()) - 12 + local frameX = (love.graphics.getWidth() - stack:canvasWidth()) - 12 local frameY = 10 stack:moveToPosition(frameX, frameY) end @@ -239,12 +237,10 @@ function PortraitGame:flipToPortrait() end function PortraitGame:returnToLandscape() - -- recreate the global canvas in landscape dimensions - 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 - love.window.updateMode(height, width, {}) + if system.isMobileOS() then + GraphicsUtil.updateMode(height, width, {}) love.window.setFullscreen(false) --GAME:updateCanvasPositionAndScale(width, height) end diff --git a/client/src/scenes/PuzzleGame.lua b/client/src/scenes/PuzzleGame.lua index 405e64cf..6ec6490e 100644 --- a/client/src/scenes/PuzzleGame.lua +++ b/client/src/scenes/PuzzleGame.lua @@ -4,7 +4,7 @@ local tableUtils = require("common.lib.tableUtils") local MessageTransition = require("client.src.scenes.Transitions.MessageTransition") local GraphicsUtil = require("client.src.graphics.graphics_util") local InputCompression = require("common.data.InputCompression") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local FileUtils = require("client.src.FileUtils") -- Scene for a puzzle mode instance of the game diff --git a/client/src/scenes/PuzzleMenu.lua b/client/src/scenes/PuzzleMenu.lua index f3cc612c..3e109de2 100644 --- a/client/src/scenes/PuzzleMenu.lua +++ b/client/src/scenes/PuzzleMenu.lua @@ -1,5 +1,5 @@ local Scene = require("client.src.scenes.Scene") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local logger = require("common.lib.logger") local ui = require("client.src.ui") local PuzzleLibrary = require("client.src.PuzzleLibrary") @@ -12,7 +12,7 @@ local Stack = require("common.engine.Stack") -- Scene for the puzzle selection menu ---@class PuzzleMenu : Scene ----@field menu Menu +---@field menu VerticalMenu ---@field puzzleLabel Label ---@field levelSlider LevelSlider ---@field randomColorButtons ButtonGroup @@ -97,8 +97,8 @@ function PuzzleMenu:load(sceneParams) self.randomColorsButtons = ui.ButtonGroup( { buttons = { - ui.TextButton({label = ui.Label({text = "op_off"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), - ui.TextButton({label = ui.Label({text = "op_on"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), + ui.TextButton({label = ui.Label({id = "op_off"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), + ui.TextButton({label = ui.Label({id = "op_on"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), }, values = {false, true}, selectedIndex = config.puzzle_randomColors and 2 or 1, @@ -112,8 +112,8 @@ function PuzzleMenu:load(sceneParams) self.randomlyFlipPuzzleButtons = ui.ButtonGroup( { buttons = { - ui.TextButton({label = ui.Label({text = "op_off"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), - ui.TextButton({label = ui.Label({text = "op_on"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), + ui.TextButton({label = ui.Label({id = "op_off"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), + ui.TextButton({label = ui.Label({id = "op_on"}), width = BUTTON_WIDTH, height = BUTTON_HEIGHT}), }, values = {false, true}, selectedIndex = config.puzzle_randomFlipped and 2 or 1, diff --git a/client/src/scenes/ReplayGame.lua b/client/src/scenes/ReplayGame.lua index 0c0f3c75..c9769857 100644 --- a/client/src/scenes/ReplayGame.lua +++ b/client/src/scenes/ReplayGame.lua @@ -1,6 +1,6 @@ local GameBase = require("client.src.scenes.GameBase") local input = require("client.src.inputManager") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local util = require("common.lib.util") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -114,7 +114,7 @@ ReplayGame.runGameOver = ReplayGame.runGame function ReplayGame:customDraw() local textPos = themes[config.theme].gameover_text_Pos local playbackText = self.playbackSpeeds[self.playbackSpeedIndex] .. "x" - GraphicsUtil.printf(playbackText, textPos[0], textPos[1], consts.CANVAS_WIDTH, "center", nil, 1, 10) + GraphicsUtil.printf(playbackText, textPos[0], textPos[1], love.graphics.getWidth(), "center", nil, 1, "big") end function ReplayGame:drawHUD() diff --git a/client/src/scenes/Scene.lua b/client/src/scenes/Scene.lua index d2e7fb07..368a4fc2 100644 --- a/client/src/scenes/Scene.lua +++ b/client/src/scenes/Scene.lua @@ -1,6 +1,6 @@ local class = require("common.lib.class") local ui = require("client.src.ui") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") local SoundController = require("client.src.music.SoundController") @@ -19,7 +19,22 @@ local SoundController = require("client.src.music.SoundController") local Scene = class( ---@param self Scene function (self, sceneParams) - self.uiRoot = ui.UiElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) + local _, _, flags = love.window.getMode() + local maxWidth, maxHeight = love.window.getDesktopDimensions(flags.display) + self.uiRoot = ui.UiElement({ + x = 0, + y = 0, + width = love.graphics.getWidth(), + maxWidth = maxWidth, + height = love.graphics.getHeight(), + maxHeight = maxHeight, + padding = 16, + layout = ui.Layouts.AdaptiveFlexLayout, + hAlign = "center", + vAlign = "center", + }) + self.uiRoot.controlsWindow = true + -- self.uiRoot.debug = true -- scenes may specify theme music to use that is played once they are switched to -- eligible labels: -- main @@ -71,12 +86,13 @@ end function Scene:refreshLocalization() self.uiRoot:refreshLocalization() + self.uiRoot.layout.resize(self.uiRoot, self.uiRoot.width, self.uiRoot.height) end function Scene:drawCommunityMessage() -- Draw the community message if not config.debug_mode then - GraphicsUtil.printf(join_community_msg or "", 0, (668 / 720) * GAME.globalCanvas:getHeight(), GAME.globalCanvas:getWidth(), "center") + GraphicsUtil.printf(join_community_msg or "", 0, (668 / 720) * love.graphics.getHeight(), love.graphics.getWidth(), "center") end end diff --git a/client/src/scenes/SetNameMenu.lua b/client/src/scenes/SetNameMenu.lua index 208a2ad4..277c720e 100644 --- a/client/src/scenes/SetNameMenu.lua +++ b/client/src/scenes/SetNameMenu.lua @@ -18,7 +18,7 @@ SetNameMenu.name = "SetNameMenu" function SetNameMenu:load() local x, y = unpack(themes[config.theme].main_menu_screen_pos) self.promptLabel = ui.Label({ - text = "op_enter_name", + id = "op_enter_name", vAlign = "top", hAlign = "center", y = y @@ -57,11 +57,11 @@ function SetNameMenu:load() self.uiRoot:addChild(self.nameLengthLabel) self.confirmationButton = ui.TextButton({ - label = ui.Label({text = "mm_set_name"}), + label = ui.Label({id = "mm_set_name"}), y = y + 100, vAlign = "top", hAlign = "center", - onClick = function(selfElement, inputSource, holdTime) + action = function(selfElement, inputSource, holdTime) self:confirmName() end }) @@ -87,7 +87,7 @@ end function SetNameMenu:update(dt) self.backgroundImg:update(dt) if self.validationLabel.text ~= "" and self.nameField.value ~= "" then - self.validationLabel:setText("", nil, false) + self.validationLabel:setText("") end if input.allKeys.isDown["return"] then @@ -102,7 +102,7 @@ function SetNameMenu:update(dt) if self.nameField.hasFocus then self.nameLengthLabel:setText("(" .. self.nameField.value:len() .. "/" .. NAME_LENGTH_LIMIT .. ")") if self.nameField.value == "" then - self.validationLabel:setText("op_username_blank_warning", nil, true) + self.validationLabel:setId("op_username_blank_warning") end self.confirmationButton:setEnabled(self.nameField.value ~= "") end diff --git a/client/src/scenes/SetUserIdMenu.lua b/client/src/scenes/SetUserIdMenu.lua index f01bdac1..f3643dfd 100644 --- a/client/src/scenes/SetUserIdMenu.lua +++ b/client/src/scenes/SetUserIdMenu.lua @@ -30,11 +30,11 @@ function SetUserIdMenu:load(sceneParams) }) self.confirmationButton = ui.TextButton({ - label = ui.Label({text = "go_"}), + label = ui.Label({id = "go_"}), x = menuX, y = menuY + 60, vAlign = "top", - onClick = function() self:confirmId() end + action = function() self:confirmId() end }) self.warningLabel = ui.Label({ @@ -48,7 +48,7 @@ function SetUserIdMenu:load(sceneParams) hAlign = "center", vAlign = "bottom", y = -50, - fontSize = 20, + fontSize = "big", }) self.idInputField:setFocus(0, 0) diff --git a/client/src/scenes/SoundTest.lua b/client/src/scenes/SoundTest.lua index 325d941a..5a65d0de 100644 --- a/client/src/scenes/SoundTest.lua +++ b/client/src/scenes/SoundTest.lua @@ -144,9 +144,9 @@ function SoundTest:load() playButtonGroup = ui.ButtonGroup( { buttons = { - ui.TextButton({label = ui.Label({text = "op_off"})}), - ui.TextButton({label = ui.Label({text = "character"})}), - ui.TextButton({label = ui.Label({text = "stage"})}), + ui.TextButton({label = ui.Label({id = "op_off"})}), + ui.TextButton({label = ui.Label({id = "character"})}), + ui.TextButton({label = ui.Label({id = "stage"})}), }, values = {"", "character", "stage"}, selectedIndex = 1, @@ -162,9 +162,9 @@ function SoundTest:load() end } ) - + local labels, values = createSfxMenuInfo(characterStepper.value) - + sfxStepper = ui.Stepper( { labels = labels, @@ -175,33 +175,46 @@ function SoundTest:load() end } ) - + local playCharacterSFXFn = function() if #sfxStepper.labels > 0 then love.audio.play(love.audio.newSource(characters[characterStepper.value].path.."/"..sfxStepper.value, "static")) end end - local menuLabelWidth = 120 - local soundTestMenuOptions = { - ui.MenuItem.createStepperMenuItem("character", nil, nil, characterStepper), - ui.MenuItem.createStepperMenuItem("stage", nil, nil, stageStepper), - ui.MenuItem.createToggleButtonGroupMenuItem("op_music_type", nil, nil, musicTypeButtonGroup), - ui.MenuItem.createToggleButtonGroupMenuItem("Background", nil, false, playButtonGroup), - ui.MenuItem.createStepperMenuItem("op_music_sfx", nil, nil, sfxStepper), - ui.MenuItem.createButtonMenuItem("op_music_play", nil, nil, playCharacterSFXFn), - ui.MenuItem.createButtonMenuItem("back", nil, nil, function() - SoundController:stopMusic() - love.audio.stop() - themes[config.theme].sounds.menu_validate = menuValidateSound - GAME.navigationStack:pop() - end) - } - - self.soundTestMenu = ui.Menu.createCenteredMenu(soundTestMenuOptions) + local characterSelection = ui.MenuItem.createStepperMenuItem("character", nil, nil, characterStepper) + local stageSelection = ui.MenuItem.createStepperMenuItem("stage", nil, nil, stageStepper) + local musicType = ui.MenuItem.createToggleButtonGroupMenuItem("op_music_type", nil, nil, musicTypeButtonGroup) + local musicPlayback = ui.MenuItem.createToggleButtonGroupMenuItem("Background", nil, false, playButtonGroup) + local sfxSelection = ui.MenuItem.createStepperMenuItem("op_music_sfx", nil, nil, sfxStepper) + local sfxPlayback = ui.MenuItem.createButtonMenuItem("op_music_play", nil, nil, playCharacterSFXFn) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, function() + SoundController:stopMusic() + love.audio.stop() + themes[config.theme].sounds.menu_validate = menuValidateSound + GAME.navigationStack:pop() + end) + + self.soundTestMenu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + self.soundTestMenu:addChild(characterSelection) + self.soundTestMenu:addChild(stageSelection) + self.soundTestMenu:addChild(musicType) + self.soundTestMenu:addChild(musicPlayback) + self.soundTestMenu:addChild(sfxSelection) + self.soundTestMenu:addChild(sfxPlayback) + self.soundTestMenu:addChild(back) self.uiRoot:addChild(self.soundTestMenu) - + self.cursor = ui.Cursor(self.soundTestMenu) + self.backgroundImg = themes[config.theme].images.bg_main -- stop main music @@ -215,12 +228,13 @@ function SoundTest:load() end function SoundTest:update(dt) - self.soundTestMenu:receiveInputs() + self.cursor:receiveInputs(dt) self.backgroundImg:update(dt) end function SoundTest:draw() self.backgroundImg:draw() + self.cursor:draw() self.uiRoot:draw() end diff --git a/client/src/scenes/StartUp.lua b/client/src/scenes/StartUp.lua index dc764174..351784cd 100644 --- a/client/src/scenes/StartUp.lua +++ b/client/src/scenes/StartUp.lua @@ -1,6 +1,6 @@ local class = require("common.lib.class") local Scene = require("client.src.scenes.Scene") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local logger = require("common.lib.logger") local fileUtils = require("client.src.FileUtils") @@ -21,7 +21,7 @@ local StartUp = class(function(scene, sceneParams) logger.debug(scene.migrationMessage) end - love.graphics.setFont(GraphicsUtil.getGlobalFontWithSize(GraphicsUtil.fontSize + 10)) + love.graphics.setFont(GraphicsUtil.getGlobalFontWithSize("big")) end, Scene) StartUp.name = "StartUp" @@ -67,9 +67,9 @@ end function StartUp:drawLoadingString(loadingString) local textHeight = 40 local x = 0 - local y = consts.CANVAS_HEIGHT / 2 - textHeight / 2 + local y = love.graphics.getHeight() / 2 - textHeight / 2 GraphicsUtil.setColor(1, 1, 1, 1) - love.graphics.printf(loadingString, x, y, consts.CANVAS_WIDTH, "center", 0, 1) + love.graphics.printf(loadingString, x, y, love.graphics.getWidth(), "center", 0, 1) end function StartUp:draw() @@ -115,7 +115,7 @@ function StartUp:migrate() self.migrationPath = nil self.migrationMessage = nil readConfigFile(config) - love.window.updateMode(config.windowWidth, config.windowHeight, + GraphicsUtil.updateMode(config.windowWidth, config.windowHeight, { x = config.windowX, y = config.windowY, diff --git a/client/src/scenes/TitleScreen.lua b/client/src/scenes/TitleScreen.lua index 05fc9897..91b24821 100644 --- a/client/src/scenes/TitleScreen.lua +++ b/client/src/scenes/TitleScreen.lua @@ -1,5 +1,5 @@ local Scene = require("client.src.scenes.Scene") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local input = require("client.src.inputManager") local tableUtils = require("common.lib.tableUtils") local class = require("common.lib.class") @@ -18,11 +18,11 @@ local TitleScreen = class( TitleScreen.name = "TitleScreen" local function titleDrawPressStart(percent) - local textMaxWidth = consts.CANVAS_WIDTH - 40 + local textMaxWidth = love.graphics.getWidth() - 40 local textHeight = 40 - local x = (consts.CANVAS_WIDTH / 2) - (textMaxWidth / 2) - local y = consts.CANVAS_HEIGHT * 0.75 - GraphicsUtil.printf(loc("continue_button"), x, y, textMaxWidth, "center", {1,1,1,percent}, nil, 16) + local x = (love.graphics.getWidth() / 2) - (textMaxWidth / 2) + local y = love.graphics.getHeight() * 0.75 + GraphicsUtil.printf(loc("continue_button"), x, y, textMaxWidth, "center", {1,1,1,percent}, nil, "huge") end function TitleScreen:update(dt) diff --git a/client/src/scenes/TrainingMenu.lua b/client/src/scenes/TrainingMenu.lua index b6445db9..98945e51 100644 --- a/client/src/scenes/TrainingMenu.lua +++ b/client/src/scenes/TrainingMenu.lua @@ -9,9 +9,7 @@ local save = require("client.src.save") local TrainingMenu = class( function (self, sceneParams) - self.backgroundImg = themes[config.theme].images.bg_main self.keepMusic = true - self.menu = nil -- set in load self:load(sceneParams) end, Scene @@ -84,39 +82,52 @@ function TrainingMenu:load(sceneParams) ) local widthSlider = ui.Slider({ - min = 1, - max = 6, + min = 1, + max = 6, value = 1, tickLength = 15, onValueChange = function() garbagePatternStepper:setState(1) end }) local heightSlider = ui.Slider({ - min = 1, - max = 99, + min = 1, + max = 99, value = 1, onValueChange = function() garbagePatternStepper:setState(1) end }) - local menuItems = { - ui.MenuItem.createStepperMenuItem("Garbage Pattern", nil, false, garbagePatternStepper), - ui.MenuItem.createSliderMenuItem("width", nil, nil, widthSlider), - ui.MenuItem.createSliderMenuItem("height", nil, nil, heightSlider), - ui.MenuItem.createButtonMenuItem("go_", nil, nil, function() self:goToCharacterSelect(garbagePatternStepper.value, widthSlider.value, heightSlider.value) end), - ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) - } - self.menu = ui.Menu.createCenteredMenu(menuItems) + local presets = ui.MenuItem.createStepperMenuItem("Garbage Pattern", nil, false, garbagePatternStepper) + local width = ui.MenuItem.createSliderMenuItem("width", nil, nil, widthSlider) + local height = ui.MenuItem.createSliderMenuItem("height", nil, nil, heightSlider) + local go = ui.MenuItem.createButtonMenuItem("go_", nil, nil, function() self:goToCharacterSelect(garbagePatternStepper.value, widthSlider.value, heightSlider.value) end) + local back = ui.MenuItem.createButtonMenuItem("back", nil, nil, exitMenu) + + self.menu = ui.VerticalMenu({ + hAlign = "center", + minHeight = 480, + maxHeight = 540, + childGap = 8, + padding = 32, + width = 600, + }) + + self.menu:addChild(presets) + self.menu:addChild(width) + self.menu:addChild(height) + self.menu:addChild(go) + self.menu:addChild(back) + self.uiRoot:addChild(self.menu) end function TrainingMenu:update(dt) - self.backgroundImg:update(dt) + GAME.theme.images.bg_main:update(dt) self.menu:receiveInputs() end function TrainingMenu:draw() - self.backgroundImg:draw() + GAME.theme.images.bg_main:draw() self.uiRoot:draw() end diff --git a/client/src/scenes/Transitions/BlackFadeTransition.lua b/client/src/scenes/Transitions/BlackFadeTransition.lua index 11e2de2c..4332fc75 100644 --- a/client/src/scenes/Transitions/BlackFadeTransition.lua +++ b/client/src/scenes/Transitions/BlackFadeTransition.lua @@ -1,6 +1,6 @@ local class = require("common.lib.class") local Transition = require("client.src.scenes.Transitions.Transition") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") local BlackFadeTransition = class(function(transition, startTime, duration, easing) @@ -18,7 +18,7 @@ function BlackFadeTransition:draw() self.newScene:draw() end GraphicsUtil.setColor(0, 0, 0, alpha) - GraphicsUtil.drawRectangle("fill", 0, 0, consts.CANVAS_WIDTH, consts.CANVAS_HEIGHT) + GraphicsUtil.drawRectangle("fill", 0, 0, love.graphics.getDimensions()) GraphicsUtil.setColor(1, 1, 1, 1) end diff --git a/client/src/scenes/Transitions/Transition.lua b/client/src/scenes/Transitions/Transition.lua index 89fdfa30..c787c154 100644 --- a/client/src/scenes/Transitions/Transition.lua +++ b/client/src/scenes/Transitions/Transition.lua @@ -1,6 +1,6 @@ local class = require("common.lib.class") local UiElement = require("client.src.ui.UIElement") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") -- A transition, or more specifically a scene transition represents an object that handles going from one scene to the next -- For the duration of handover, the transition is responsible for handling both update and draw calls on the scenes @@ -21,7 +21,7 @@ local Transition = class(function(transition, startTime, duration) -- these are set by the navigationStack transition.oldScene = nil transition.newScene = nil - transition.uiRoot = UiElement({x = 0, y = 0, width = consts.CANVAS_WIDTH, height = consts.CANVAS_HEIGHT}) + transition.uiRoot = UiElement({x = 0, y = 0, width = love.graphics.getWidth(), height = love.graphics.getWidth()}) end) diff --git a/client/src/scenes/WindowSizeTester.lua b/client/src/scenes/WindowSizeTester.lua index 5e4aa029..54624d1e 100644 --- a/client/src/scenes/WindowSizeTester.lua +++ b/client/src/scenes/WindowSizeTester.lua @@ -43,8 +43,8 @@ function WindowSizeTester:load() local maximizedLabel = ui.ValueLabel({valueFunction = function() return "Maximized: " .. tostring(love.window.isMaximized()) end}) self.uiRoot.fullscreenSelection = ui.ButtonGroup({ buttons = { - ui.TextButton({width = 60, label = ui.Label({text = "op_off"})}), - ui.TextButton({width = 60, label = ui.Label({text = "op_on"})}) + ui.TextButton({width = 60, label = ui.Label({id = "op_off"})}), + ui.TextButton({width = 60, label = ui.Label({id = "op_on"})}) }, values = {false, true}, selectedIndex = flags.fullscreen and 2 or 1, @@ -126,7 +126,7 @@ function WindowSizeTester:load() grid:createElementAt(1, 1, 20, 1, "desktopDimensions", desktopSizeLabel) grid:createElementAt(1, 2, 20, 1, "maximized", maximizedLabel) grid:createElementAt(1, 3, 20, 1, nil, ui.TextButton({label = ui.Label({text = "maximize", translate = false}), - onClick = function() love.window.maximize() end})) + action = function() love.window.maximize() end})) grid:createElementAt(1, 4, 2, 1, nil, ui.Label({text = "fullscreen", translate = false})) grid:createElementAt(3, 4, 20, 1, "fullscreen", self.uiRoot.fullscreenSelection) grid:createElementAt(1, 5, 2, 1, nil, ui.Label({text = "width", translate = false})) @@ -139,7 +139,7 @@ function WindowSizeTester:load() grid:createElementAt(3, 8, 20, 1, "y", self.uiRoot.ySlider) grid:createElementAt(1, 9, 20, 1, "back", ui.TextButton({ label = ui.Label({text = "back"}), - onClick = + action = function() love.resize = self.originalResize GAME.navigationStack:pop() diff --git a/client/src/system.lua b/client/src/system.lua index 9dde9fcb..fab2ee17 100644 --- a/client/src/system.lua +++ b/client/src/system.lua @@ -12,6 +12,10 @@ function system.isMobileOS() return true end + if SIMULATE_MOBILE_OS then + return true + end + return false end diff --git a/client/src/ui/BoolSelector.lua b/client/src/ui/BoolSelector.lua index 5d4d7e83..a4ae64ca 100644 --- a/client/src/ui/BoolSelector.lua +++ b/client/src/ui/BoolSelector.lua @@ -1,6 +1,6 @@ -local PATH = (...):gsub('%.[^%.]+$', '') - -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local addCursorInteractionInterface = import("./CursorInteractable") +local UiElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -9,11 +9,13 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") --- A BoolSelector is a UIElement that shows if a setting is on or off and lets you toggle it. ---@class BoolSelector : UiElement +---@operator call(BoolSelectorOptions): BoolSelector ---@field value boolean ---@field vertical boolean local BoolSelector = class(function(boolSelector, options) boolSelector.value = options.startValue or false boolSelector.vertical = false + addCursorInteractionInterface(boolSelector, boolSelector.receiveInputs) end, UiElement) @@ -31,27 +33,30 @@ function BoolSelector:onSelect(boolSelector, selector) self:setValue(not self.value) end -function BoolSelector:receiveInputs(input) - if self.isFocusable then - if (input:isPressedWithRepeat("Right") and self.vertical == false) or - (input:isPressedWithRepeat("Up") and self.vertical) then - self:setValue(true) - elseif (input:isPressedWithRepeat("Left") and self.vertical == false) or - (input:isPressedWithRepeat("Down") and self.vertical) then - self:setValue(false) - elseif input.isDown["Swap1"] then - GAME.theme:playValidationSfx() - self:yieldFocus() - elseif input.isDown["Swap2"] then - GAME.theme:playCancelSfx() - self:yieldFocus() - end - else +---@param cursor Cursor +---@param dt number? +function BoolSelector:receiveInputs(cursor, dt) + local input = cursor.keyInput + -- if self.isFocusable then + -- if (input:isPressedWithRepeat("Right") and self.vertical == false) or + -- (input:isPressedWithRepeat("Up") and self.vertical) then + -- self:setValue(true) + -- elseif (input:isPressedWithRepeat("Left") and self.vertical == false) or + -- (input:isPressedWithRepeat("Down") and self.vertical) then + -- self:setValue(false) + -- elseif input.isDown["Swap1"] then + -- GAME.theme:playValidationSfx() + -- self:yieldFocus() + -- elseif input.isDown["Swap2"] then + -- GAME.theme:playCancelSfx() + -- self:yieldFocus() + -- end + -- else if input.isDown["Swap1"] then GAME.theme:playValidationSfx() self:setValue(not self.value) end - end + --end end function BoolSelector:setValue(value) @@ -76,7 +81,7 @@ local fakeCenteredChild = {hAlign = "center", vAlign = "center", width = totalWi function BoolSelector:drawSelf() 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.drawRectangle("line", 1, 1, self.width - 2, self.height - 2) GraphicsUtil.setColor(1, 1, 1, 1) end diff --git a/client/src/ui/Button.lua b/client/src/ui/Button.lua index 766ab7ba..023192a3 100644 --- a/client/src/ui/Button.lua +++ b/client/src/ui/Button.lua @@ -1,65 +1,81 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UIElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local input = require("client.src.inputManager") +local addCursorInteractionInterface = import("./CursorInteractable") ---@class ButtonOptions : UiElementOptions ---@field backgroundColor number[]? ---@field outlineColor number[]? ----@field onClick fun(button: Button?, input: table?, timeHeld: number?)? +---@field receiveInputs fun(button: Button, cursor: Cursor, dt: number?)? +---@field action fun(button: Button?, input: table?, timeHeld: number?)? Callback for the main (often UI agnostic) action that should happen, e.g. setting character for a player and playing its selection SFX +---@field onAction fun(button: Button?, input: table?, timeHeld: number?)? Callback for UI specific actions that are unrelated to the action, e.g. redirecting cursor focus or playing validation SFX ----@class Button : UiElement +---@class Button : UiElement, CursorInteractable +---@operator call(ButtonOptions): Button ---@field backgroundColor number[] ---@field outlineColor number [] ----@field onClick fun(button: Button?, input: table?, timeHeld: number?) +---@field action fun(button: Button?, input: table?, timeHeld: number?)? Callback for the main (often UI agnostic) action that should happen, e.g. setting character for a player and playing its selection SFX +---@field onAction fun(button: Button?, input: table?, timeHeld: number?) Callback for UI specific actions that are unrelated to the action, e.g. redirecting cursor focus or playing validation SFX local Button = class( function(self, options) + self.hoveredBackgroundColor = {.5, .5, .5, .7} self.backgroundColor = options.backgroundColor or {.3, .3, .3, .7} self.outlineColor = options.outlineColor or {.5, .5, .5, .7} -- callbacks - self.onClick = options.onClick or function() - GAME.theme:playValidationSfx() + self.action = options.action or self.action + if options.onAction then + self.onAction = options.onAction end + + addCursorInteractionInterface(self, options.receiveInputs or self.receiveInputs) end, UIElement ) Button.TYPE = "Button" -function Button:onTouch(x, y) - self.backgroundColor[4] = 1 -end - function Button:onRelease(x, y, timeHeld) - self.backgroundColor[4] = 0.7 - if self:inBounds(x, y) then - -- first argument non-self of onClick is the input source to accomodate inputs via controllers from different players - self:onClick(input.mouse, timeHeld) + if self.action and self:inBounds(x, y) then + -- first argument non-self of action is the input source to accomodate inputs via controllers from different players + self:action(input.mouse, timeHeld) + self:onAction() end end -function Button:receiveInputs(input) +function Button:onAction() + GAME.theme:playValidationSfx() +end + +---@param cursor Cursor +---@param dt number +function Button:receiveInputs(cursor, dt) + local input = cursor.keyInput if input.isDown["MenuSelect"] then - self:onClick(input) + -- these are always called together but the separation is (an optional) semantic one for cases where the action can be fully independent of the UI + self:action(input) + self:onAction() -- this is a really stupid way to make sure you can activate back buttons with escape elseif input.isDown["MenuEsc"] then - self:onClick(input) + self:action(input) end end function Button:drawBackground() - if self.backgroundColor[4] > 0 then + if self.action and self:isHovered() then + GraphicsUtil.setColor(self.hoveredBackgroundColor) + else GraphicsUtil.setColor(self.backgroundColor) - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) - GraphicsUtil.setColor(1, 1, 1, 1) end + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) end function Button:drawOutline() GraphicsUtil.setColor(self.outlineColor) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) end diff --git a/client/src/ui/ButtonGroup.lua b/client/src/ui/ButtonGroup.lua index 72ca5af8..18e31a09 100644 --- a/client/src/ui/ButtonGroup.lua +++ b/client/src/ui/ButtonGroup.lua @@ -1,20 +1,22 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local addCursorInteractionInterface = import("./CursorInteractable") +local UIElement = import("./UIElement") local class = require("common.lib.class") local util = require("common.lib.util") local tableUtils = require("common.lib.tableUtils") +local HorizontalFlexLayout = import("./Layouts.HorizontalFlexLayout") local BUTTON_PADDING = 5 -- UIElement representing a set of buttons which share state (think radio buttons) --- forced override for each of the button's onClick function +-- forced override for each of the button's action function -- this allows buttons to have individual custom behaviour while also triggering the global state change local function genButtonGroupFn(self, button) - local onClick = button.onClick + local action = button.action return function(b, inputSource, holdTime) self:buttonClicked(b) - onClick(b, inputSource, holdTime) + action(b, inputSource, holdTime) self:onChange(self.value) end end @@ -39,7 +41,8 @@ local function setButtons(self, buttons, values, selectedIndex) button.x = self.buttons[i - 1].x + self.buttons[i - 1].width + BUTTON_PADDING overallWidth = overallWidth + BUTTON_PADDING end - button.onClick = genButtonGroupFn(self, button) + button.action = genButtonGroupFn(self, button) + button.vAlign = "center" self:addChild(button) overallHeight = math.max(overallHeight, button.height) end @@ -52,7 +55,7 @@ end local function setActiveButton(self, selectedIndex) local newIndex = util.bound(1, selectedIndex, #self.buttons) if self.selectedIndex ~= newIndex then - self.buttons[newIndex]:onClick(nil, 0) + self.buttons[newIndex]:action(nil, 0) end end @@ -66,13 +69,21 @@ local ButtonGroup = class( function(self, options) self.selectedIndex = options.selectedIndex or 1 + self.padding = options.padding or 4 + self.childGap = options.childGap or 8 + if options.hFill == nil then + self.hFill = true + end + self.onChange = options.onChange or function() end setButtons(self, options.buttons, options.values, self.selectedIndex) + addCursorInteractionInterface(self, self.receiveInputs) end, UIElement ) ButtonGroup.TYPE = "ButtonGroup" +ButtonGroup.layout = HorizontalFlexLayout -- changes state for the button group -- updates the color of the selected button @@ -85,7 +96,10 @@ function ButtonGroup:buttonClicked(button) self.selectedIndex = i end -function ButtonGroup:receiveInputs(input) +---@param cursor Cursor +---@param dt number? +function ButtonGroup:receiveInputs(cursor, dt) + local input = cursor.keyInput if input:isPressedWithRepeat("Left") then self:setActiveButton(self.selectedIndex - 1) elseif input:isPressedWithRepeat("Right") then diff --git a/client/src/ui/Carousel.lua b/client/src/ui/Carousel.lua index 914d1c55..409bc9e4 100644 --- a/client/src/ui/Carousel.lua +++ b/client/src/ui/Carousel.lua @@ -1,6 +1,6 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") -local Focusable = require(PATH .. ".Focusable") +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local Focusable = import("./Focusable") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") local tableUtils = require("common.lib.tableUtils") @@ -88,7 +88,7 @@ end function Carousel:drawSelf() if DEBUG_ENABLED then - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) end end diff --git a/client/src/ui/CharacterButton.lua b/client/src/ui/CharacterButton.lua new file mode 100644 index 00000000..d21163d6 --- /dev/null +++ b/client/src/ui/CharacterButton.lua @@ -0,0 +1,218 @@ +local import = require("common.lib.import") +local class = require("common.lib.class") +local Button = import("./Button") +local consts = require("client.src.consts") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") + +local SUPER_SELECTION_DURATION = 0.5 -- time held in seconds at which super select actually happens +local SUPER_SELECTION_START = 0.1 -- time held in seconds at which super select is considered started + +---@class CharacterButtonOptions : ButtonOptions +---@field character Character + +---@class CharacterButton : Button +---@operator call(CharacterButtonOptions): CharacterButton +---@field character Character +---@field characterId string +---@field displayName string +---@field characterIcon love.Texture +---@field flagIcon love.Texture? +---@field stageIcon love.Texture? +---@field panelIcon love.Texture? +---@field holdTime number +---@field superSelectVisible boolean +---@overload fun(options: CharacterButtonOptions): CharacterButton +---@type CharacterButton +local CharacterButton = class( +---@param self CharacterButton +---@param options CharacterButtonOptions +function (self, options) + assert(options.character) + self.character = options.character + self.characterId = self.character.id + if self.characterId == consts.RANDOM_CHARACTER_SPECIAL_VALUE then + self.displayName = loc("random") + else + self.displayName = self.character.display_name + end + + self.characterIcon = self.character.images.icon + self.flagIcon = GAME.theme:getFlag(self.character.flag) + if self.character.stage and stages[self.character.stage] then + self.stageIcon = stages[self.character.stage].images.thumbnail + end + if self.character.panels and panels[self.character.panels] then + self.panelIcon = panels[self.character.panels].displayIcons[1] + end + + self.holdTime = 0 + self.holding = false + self.superSelectVisible = false +end, +Button) + +local super_select_pixelcode = [[ + uniform float percent; + vec4 effect( vec4 color, Image tex, vec2 texture_coords, vec2 screen_coords ) + { + vec4 c = Texel(tex, texture_coords) * color; + if( texture_coords.x < percent ) + { + return c; + } + float ret = (c.x+c.y+c.z)/3.0; + return vec4(ret, ret, ret, c.a); + } + ]] + +CharacterButton.extraIconScale = 0.1904 +CharacterButton.padding = 2 +CharacterButton.superSelectShader = love.graphics.newShader(super_select_pixelcode) +CharacterButton.standardSize = 84 + +function CharacterButton:updateSuperSelectShader(timer) + if timer > SUPER_SELECTION_START then + if self.superSelectVisible == false then + self.superSelectVisible = true + end + local progress = (timer - SUPER_SELECTION_START) / SUPER_SELECTION_DURATION + if progress <= 1 then + CharacterButton.superSelectShader:send("percent", progress) + end + else + if self.superSelectVisible then + self.superSelectVisible = false + end + CharacterButton.superSelectShader:send("percent", 0) + end +end + +function CharacterButton:addChild() + error("CharacterButtons cannot have children") +end + +function CharacterButton:action(inputSource, holdTime) + local character = self.character + character:playSelectionSfx() + + if not inputSource or not inputSource.player then + return + else + local player = inputSource.player + GAME.theme:playValidationSfx() + if character:canSuperSelect() and holdTime > SUPER_SELECTION_START + SUPER_SELECTION_DURATION then + -- super select + if character.panels and panels[character.panels] then + player:setPanels(character.panels) + end + if character.stage and stages[character.stage] then + player:setStage(character.stage) + end + end + player:setCharacter(self.characterId) + end +end + +-- touch interaction +-- by implementing onHold we can provide updates to the shader +function CharacterButton:onHold(timer) + self:updateSuperSelectShader(timer) +end + + -- we need to override the standard onRelease to reset the shader +function CharacterButton:onRelease(x, y, timeHeld) + self:updateSuperSelectShader(0) + if self:inBounds(x, y) then + self:action(input.mouse, timeHeld) + self:onAction() + end + self.holdTime = 0 +end + +---@param cursor Cursor +---@param dt number? +function CharacterButton:receiveInputs(cursor, dt) + local inputs = cursor.keyInput + if not self.holding then + if inputs.isDown.Swap1 then + self.holding = true + end + else + if inputs.isPressed.Swap1 then + -- measure the time the press is held for + self.holdTime = inputs.isPressed.Swap1 + else + -- apply the actual click on release with the held time and reset it afterwards + self:action(inputs, self.holdTime) + self:onAction() + self.holdTime = 0 + self.holding = false + end + end + self:updateSuperSelectShader(self.holdTime) +end + +---@param scale number +---@return FontSize +local function getFontSizeByScale(scale) + if scale < 0.8 then + return "small" + elseif scale < 1.2 then + return "normal" + elseif scale < 1.8 then + return "medium" + else + return "big" + end +end + +function CharacterButton:drawSelf() + GraphicsUtil.setColor(1, 1, 1, 1) + local imageWidth, imageHeight = self.characterIcon:getDimensions() + local scale = math.min(self.width / imageWidth, self.height / imageHeight) + + GraphicsUtil.draw(self.characterIcon, 0, 0, 0, scale, scale) + GraphicsUtil.printf(self.displayName, 0, 0, self.width, "center", nil, nil, getFontSizeByScale(self.width / CharacterButton.standardSize)) + + local leftOffset = CharacterButton.padding * scale + local rightOffset = self.width - CharacterButton.padding * scale + local bottomOffset = self.height - CharacterButton.padding * scale + + + if self.flagIcon then + imageWidth, imageHeight = self.flagIcon:getDimensions() + scale = math.round(self.width * CharacterButton.extraIconScale) / imageWidth + GraphicsUtil.draw(self.flagIcon, rightOffset, bottomOffset, 0, scale, scale, imageWidth, imageHeight) + end + + if self.panelIcon then + imageWidth, imageHeight = self.panelIcon:getDimensions() + scale = math.round(self.width * CharacterButton.extraIconScale) / imageWidth + GraphicsUtil.draw(self.panelIcon, leftOffset, bottomOffset, 0, scale, scale, 0, imageHeight) + end + + if self.stageIcon and self:isHovered() then + imageWidth, imageHeight = self.stageIcon:getDimensions() + local xScale = math.round(self.width * CharacterButton.extraIconScale * 2) / imageWidth + local yScale = math.round(self.width * CharacterButton.extraIconScale) / imageHeight + GraphicsUtil.draw(self.stageIcon, self.width / 2, bottomOffset, 0, xScale, yScale, imageWidth / 2, imageHeight) + love.graphics.setLineWidth(0.5) + love.graphics.rectangle("line", (self.width / 2 - imageWidth * xScale / 2), bottomOffset - imageHeight * yScale, imageWidth * xScale, imageHeight * yScale) + love.graphics.setLineWidth(1) + end + + if self.superSelectVisible then + imageWidth, imageHeight = GAME.theme.images.IMG_super:getDimensions() + scale = math.min(self.width / imageWidth, self.height / imageHeight) + GraphicsUtil.setShader(CharacterButton.superSelectShader) + GraphicsUtil.draw(GAME.theme.images.IMG_super, self.width / 2, self.height / 2, 0, scale, scale, imageWidth / 2, imageHeight / 2) + GraphicsUtil.setShader() + end + + GraphicsUtil.setColor(1, 1, 1, 0.4) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) + GraphicsUtil.setColor(1, 1, 1, 1) +end + +return CharacterButton \ No newline at end of file diff --git a/client/src/ui/Cursor.lua b/client/src/ui/Cursor.lua new file mode 100644 index 00000000..eafea0ec --- /dev/null +++ b/client/src/ui/Cursor.lua @@ -0,0 +1,99 @@ +local class = require("common.lib.class") +local consts = require("client.src.consts") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") + +---@class CursorOptions +---@field target UiElement +---@field keyInput KeyConfiguration? + +---@class Cursor +---@operator call(CursorOptions): Cursor +---@overload fun(target: CursorNavigable | UiElement, keyInput: KeyConfiguration?): Cursor +---@field keyInput KeyConfiguration +---@field focusStack CursorNavigable[] A stack of focused UiElement, new elements go on top, as focus is released it goes back down +---@field focusToHover table Persists the last hovered element for each navigable UiElement so that the last position can be reconstructed when focus is released +---@field focused CursorNavigable The navigable UiElement that is currently consuming the cursor's inputs +local Cursor = class( +function(self, target, keyInput) + self.keyInput = keyInput or input + self:setFocus(target) +end) + +---@param currentFocus UiElement | CursorNavigable +---@param newFocus UiElement | CursorNavigable +function Cursor:moveFocus(currentFocus, newFocus) + self:releaseFocus(currentFocus) + self:deepenFocus(newFocus) +end + +---@param uiElement UiElement | CursorNavigable +function Cursor:setFocus(uiElement) + self.focusStack = {} + self.focusToHover = {} + self.focused = uiElement + self.focusStack = { uiElement } + uiElement:receiveFocus(self) +end + +---@param uiElement UiElement | CursorNavigable +function Cursor:deepenFocus(uiElement) + self.focusToHover[self.focused]:setHover(self, false) + + self.focusStack[#self.focusStack+1] = uiElement + self.focused = uiElement + uiElement:receiveFocus(self) +end + +---@param uiElement UiElement | CursorNavigable +function Cursor:releaseFocus(uiElement) + if #self.focusStack >= 1 then + for i = #self.focusStack, 2, -1 do + local focused = self.focusStack[i] + local hovered = self.focusToHover[focused] + self.focusStack[i] = nil + self.focusToHover[focused] = nil + hovered:setHover(self, false) + if focused == uiElement then + break + end + end + if uiElement.onYield then + uiElement:onYield() + end + + self.focused = self.focusStack[#self.focusStack] + self.focusToHover[self.focused]:setHover(self, true) + end +end + +function Cursor:updateHover(focused, newHovered) + local currentHovered = self.focusToHover[focused] + self.focusToHover[focused] = newHovered + if focused == self.focused then + if currentHovered then + currentHovered:setHover(self, false) + end + newHovered:setHover(self, true) + end +end + +function Cursor:receiveInputs(dt) + self.focused:receiveInputs(self, dt) +end + +function Cursor:draw() + -- GraphicsUtil.setColor(0, 0, 0, 0.2) + -- love.graphics.rectangle("fill", self.target.x, self.target.y, self.target.width, self.target.height) + if self.focused then + local uiElement = self.focusToHover[self.focused] + if not uiElement.receiveInputs then + GraphicsUtil.setColor(1, 1, 1, 0.2) + local x, y = uiElement:getScreenPos() + love.graphics.rectangle("fill", x, y, uiElement.width, uiElement.height) + GraphicsUtil.setColor(1, 1, 1, 1) + end + end +end + +return Cursor \ No newline at end of file diff --git a/client/src/ui/CursorInteractable.lua b/client/src/ui/CursorInteractable.lua new file mode 100644 index 00000000..0929a5a8 --- /dev/null +++ b/client/src/ui/CursorInteractable.lua @@ -0,0 +1,47 @@ + + +---@class CursorInteractable +---@field receiveInputs fun(cursorInteractable: CursorInteractable | UiElement, cursor: Cursor, dt: number?) +---@field setHover fun(cursorInteractable: CursorInteractable | UiElement, cursor: Cursor, hovering: boolean) +---@field isHovered fun(cursorInteractable: CursorInteractable | UiElement): boolean +---@field hoveringCursors table + +---@param cursorInteractable CursorInteractable | UiElement +---@param cursor Cursor +---@param hovering boolean +local function setHover(cursorInteractable, cursor, hovering) + if not hovering then + cursorInteractable.hoveringCursors[cursor] = nil + else + cursorInteractable.hoveringCursors[cursor] = true + end +end + +---@param cursorInteractable CursorInteractable | UiElement +local function isHovered(cursorInteractable) + if next(cursorInteractable.hoveringCursors) then + return true + else + return false + end +end + +-- always call this on an instance, not the class +-- that is to make sure that the per-instance fields are set on the instance +---@param uiElement UiElement +---@param receiveInputs fun(cursorInteractable: CursorInteractable | UiElement, cursor: Cursor, dt: number?) +---@return UiElement | CursorInteractable +local function addCursorInteractionInterface(uiElement, receiveInputs) + uiElement.hoveringCursors = {} + uiElement.setHover = setHover + uiElement.isHovered = isHovered + if not uiElement.receiveInputs or uiElement.receiveInputs ~= receiveInputs then + uiElement.receiveInputs = receiveInputs + end + uiElement.isNavigable = false + + ---@cast uiElement +CursorInteractable + return uiElement +end + +return addCursorInteractionInterface \ No newline at end of file diff --git a/client/src/ui/CursorNavigable.lua b/client/src/ui/CursorNavigable.lua new file mode 100644 index 00000000..3ca815af --- /dev/null +++ b/client/src/ui/CursorNavigable.lua @@ -0,0 +1,120 @@ +local consts = require("client.src.consts") +local tableUtils = require("common.lib.tableUtils") +local import = require("common.lib.import") +local addCursorInteractionInterface = import("./CursorInteractable") + +---@class CursorNavigableOptions +---@field onFocus fun(cursorNavigable: CursorNavigable | UiElement)? +---@field onYield fun(cursorNavigable: CursorNavigable | UiElement)? + +---@class CursorNavigable : CursorInteractable +---@field isNavigable boolean +---@field receiveFocus fun(cursorNavigable: CursorNavigable | UiElement, cursor: Cursor) +---@field receiveInputs fun(cursorNavigable: CursorNavigable | UiElement, cursor: Cursor, dt: number?) +---@field moveToNext fun(cursorNavigable: CursorNavigable | UiElement, cursor: Cursor) +---@field moveToPrevious fun(cursorNavigable: CursorNavigable | UiElement, cursor: Cursor) +---@field onFocus fun(cursorNavigable: CursorNavigable | UiElement)? +---@field onYield fun(cursorNavigable: CursorNavigable | UiElement)? + +---@param cursorNavigable CursorNavigable | UiElement +---@param cursor Cursor +local function receiveFocus(cursorNavigable, cursor) + if cursorNavigable.onFocus then + cursorNavigable:onFocus() + end + + for i, child in ipairs(cursorNavigable.children) do + if child.receiveInputs and child.isVisible and child.isEnabled then + cursor:updateHover(cursorNavigable, child) + break + end + end +end + +---@param cursorNavigable CursorNavigable | UiElement +---@param cursor Cursor +local function moveToNext(cursorNavigable, cursor) + local hoveredIndex = tableUtils.indexOf(cursorNavigable.children, cursor.focusToHover[cursorNavigable]) + for i = hoveredIndex + 1, hoveredIndex + #cursorNavigable.children do + local index = wrap(1, i, #cursorNavigable.children) + local child = cursorNavigable.children[index] + if child.receiveInputs and child.isEnabled and child.isVisible then + cursor:updateHover(cursorNavigable, child) + break + end + end +end + +---@param cursorNavigable CursorNavigable | UiElement +---@param cursor Cursor +local function moveToPrevious(cursorNavigable, cursor) + local hoveredIndex = tableUtils.indexOf(cursorNavigable.children, cursor.focusToHover[cursorNavigable]) + for i = hoveredIndex - 1, hoveredIndex - #cursorNavigable.children, -1 do + local index = wrap(1, i, #cursorNavigable.children) + local child = cursorNavigable.children[index] + if child.receiveInputs and child.isEnabled and child.isVisible then + cursor:updateHover(cursorNavigable, child) + break + end + end +end + +---@param cursorNavigable CursorNavigable | UiElement +---@param cursor Cursor +---@param dt number +local function defaultReceiveInputs(cursorNavigable, cursor, dt) + local selected = cursor.focusToHover[cursorNavigable] + local inputs = cursor.keyInput + if inputs.isDown.Swap2 then + GAME.theme:playCancelSfx() + cursor:releaseFocus(cursorNavigable) + elseif selected.isNavigable and (inputs.isDown.Swap1 or inputs.isDown.Start) then + GAME.theme:playValidationSfx() + cursor:deepenFocus(selected) + elseif cursorNavigable.layout.characteristic == "horizontal" then + if inputs:isPressedWithRepeat("Left", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + GAME.theme:playMoveSfx() + cursorNavigable:moveToPrevious(cursor) + elseif inputs:isPressedWithRepeat("Right", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + GAME.theme:playMoveSfx() + cursorNavigable:moveToNext(cursor) + elseif selected.receiveInputs and not selected.isNavigable then + selected:receiveInputs(cursor, dt) + end + elseif cursorNavigable.layout.characteristic == "vertical" then + if inputs:isPressedWithRepeat("Up", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + GAME.theme:playMoveSfx() + cursorNavigable:moveToPrevious(cursor) + elseif inputs:isPressedWithRepeat("Down", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + GAME.theme:playMoveSfx() + cursorNavigable:moveToNext(cursor) + elseif selected.receiveInputs and not selected.isNavigable then + selected:receiveInputs(cursor, dt) + end + else + GAME.theme:playCancelSfx() + end +end + +-- always call this on an instance, not the class +-- that is to make sure that the per-instance fields are set on the instance +---@param uiElement UiElement +---@param receiveInputs fun(cursorNavigable: CursorNavigable | UiElement, cursor: Cursor, dt: number?)? +---@return UiElement | CursorNavigable +local function addCursorNavigationInterface(uiElement, receiveInputs) + addCursorInteractionInterface(uiElement, receiveInputs or defaultReceiveInputs) + uiElement.receiveFocus = receiveFocus + if not uiElement.moveToNext then + uiElement.moveToNext = moveToNext + end + if not uiElement.moveToPrevious then + uiElement.moveToPrevious = moveToPrevious + end + uiElement.isNavigable = true + + ---@cast uiElement +CursorNavigable + return uiElement +end + + +return addCursorNavigationInterface \ No newline at end of file diff --git a/client/src/ui/FocusDirector.lua b/client/src/ui/FocusDirector.lua index 18568ce8..62e0155d 100644 --- a/client/src/ui/FocusDirector.lua +++ b/client/src/ui/FocusDirector.lua @@ -1,5 +1,9 @@ -- use in tandem with Focusable.lua +---@class FocusDirector +---@field setFocus fun(director: table, focusable: table, callback: function?) +---@field focused table? + local function directsFocus(uiElement) uiElement.focused = nil uiElement.setFocus = function(table, focusable, callback) diff --git a/client/src/ui/Focusable.lua b/client/src/ui/Focusable.lua index c61c6d23..6734fbc4 100644 --- a/client/src/ui/Focusable.lua +++ b/client/src/ui/Focusable.lua @@ -1,5 +1,11 @@ -- use in tandem with FocusDirector.lua +---@class Focusable +---@field receiveInputs fun(any, table, number?) +---@field isFocusable boolean +---@field hasFocus boolean? +---@field yieldFocus fun()? + local function focusable(uiElement) uiElement.isFocusable = true uiElement.hasFocus = false diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index 9e44c3e3..971a611e 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -1,8 +1,9 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") -local GridElement = require(PATH .. ".GridElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local GridElement = import("./GridElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local StaticLayout = import("./Layouts.StaticLayout") local Grid = class(function(self, options) self.unitSize = options.unitSize @@ -19,9 +20,11 @@ local Grid = class(function(self, options) -- self.grid[row][col] = {} -- end end - self.TYPE = "Grid" end, UiElement) +Grid.TYPE = "Grid" +Grid.layout = StaticLayout + -- width and height are sizes relative to the unitSize of the grid -- id is a string identificator to indiate what kind of uiElement resides here -- uiElement is the actual element on display that will perform user interaction when selected @@ -74,18 +77,18 @@ end function Grid:drawSelf() if DEBUG_ENABLED then GraphicsUtil.setColor(1, 1, 1, 0.5) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) -- draw all units - local right = self.x + self.width - local bottom = self.y + self.height + local right = self.width + local bottom = self.height for i = 1, self.gridHeight - 1 do - local y = self.y + self.unitSize * i - GraphicsUtil.drawStraightLine(self.x, y, right, y, 1, 1, 1, 0.5) + local y = self.unitSize * i + GraphicsUtil.drawStraightLine(0, y, right, y, 1, 1, 1, 0.5) end for i = 1, self.gridWidth - 1 do - local x = self.x + self.unitSize * i - GraphicsUtil.drawStraightLine(x, self.y, x, bottom, 1, 1, 1, 0.5) + local x = self.unitSize * i + GraphicsUtil.drawStraightLine(x, 0, x, bottom, 1, 1, 1, 0.5) end end end diff --git a/client/src/ui/GridCursor.lua b/client/src/ui/GridCursor.lua index b4c2f6f7..b194414d 100644 --- a/client/src/ui/GridCursor.lua +++ b/client/src/ui/GridCursor.lua @@ -1,9 +1,10 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") -local directsFocus = require(PATH .. ".FocusDirector") -local consts = require("common.engine.consts") +local directsFocus = import("./FocusDirector") +local consts = require("client.src.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") +local StaticLayout = import("./Layouts.StaticLayout") -- create a new cursor that can navigate on the specified grid -- grid: the target grid that is navigated on @@ -38,9 +39,10 @@ local GridCursor = class(function(self, options) self.trapped = false self.drawClock = 0 - self.TYPE = "GridCursor" end, UiElement) +GridCursor.TYPE = "GridCursor" +GridCursor.layout = StaticLayout GridCursor.directions = {up = {x = 0, y = -1}, down = {x = 0, y = 1}, left = {x = -1, y = 0}, right = {x = 1, y = 0}} function GridCursor:setTarget(grid, startPosition, activeArea) diff --git a/client/src/ui/GridElement.lua b/client/src/ui/GridElement.lua index d0725fdf..da4539a5 100644 --- a/client/src/ui/GridElement.lua +++ b/client/src/ui/GridElement.lua @@ -1,7 +1,8 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local StaticLayout = import("./Layouts.StaticLayout") local GridElement = class(function(gridElement, options) if options.content then @@ -24,9 +25,11 @@ local GridElement = class(function(gridElement, options) gridElement.TYPE = "GridElement" end, UiElement) +GridElement.layout = StaticLayout + function GridElement:drawSelf() if self.drawBorders then - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) end end diff --git a/client/src/ui/ImageContainer.lua b/client/src/ui/ImageContainer.lua index 3be6db1c..6a4c0b9c 100644 --- a/client/src/ui/ImageContainer.lua +++ b/client/src/ui/ImageContainer.lua @@ -1,27 +1,54 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local ImageContainer = class(function(self, options) +---@class ImageOptions : UiElementOptions +---@field image love.Texture +---@field forceIntegerScaling boolean? force the image scale to snap to integer scales even if it doesn't fill up the available space, e.g. 1/4, 1/3, 1/2, 1/1, 2/1, 3/1 etc. +---@field minScale number? + +---@class Image : UiElement +---@operator call(ImageOptions): Image +---@overload fun(options: ImageOptions): Image +---@field image love.Texture +---@field drawBorders boolean +---@field outlineColor color +---@field forceIntegerScaling boolean if the image scale is forced to snap to a fraction number with 1 in numerator or denominator +---@field minScale number the minimum the image can be scaled down to +local ImageContainer = class( +function(self, options) self.drawBorders = options.drawBorders or false self.outlineColor = options.outlineColor or {1, 1, 1, 1} + if options.forceIntegerScaling ~= nil then + self.forceIntegerScaling = options.forceIntegerScaling + else + self.forceIntegerScaling = false + end + + self.minScale = 0.125 + self:setImage(options.image, options.width, options.height, options.scale) -end, UiElement) +end, +UiElement) + +ImageContainer.TYPE = "Image" function ImageContainer:setImage(image, width, height, scale) self.image = image self.imageWidth, self.imageHeight = self.image:getDimensions() + self.minWidth = math.max(self.imageWidth * self.minScale, self.minWidth) + self.minHeight = math.max(self.imageHeight * self.minScale, self.minHeight) if self.hFill and self.vFill then self.scale = math.min(self.width / self.imageWidth, self.height / self.imageHeight) else scale = scale or 1 - + local scaledImageWidth = self.imageWidth * scale local scaledImageHeight = self.imageHeight * scale - + if width and height then -- scale is getting capped to what width and height actually give us self.scale = math.min(width / scaledImageWidth, height / scaledImageHeight) @@ -36,21 +63,42 @@ function ImageContainer:setImage(image, width, height, scale) end end -function ImageContainer:onResize() +function ImageContainer:onResized() self.scale = math.min(self.width / self.imageWidth, self.height / self.imageHeight) - self.width = self.imageWidth * self.scale - self.height = self.imageHeight * self.scale + if self.forceIntegerScaling then + if self.scale >= 1 then + self.scale = math.floor(self.scale) + else + for i = 1, math.floor(1 / self.minScale) do + if 1 / i < self.scale then + self.scale = 1 / i + break + end + end + end + end end function ImageContainer:drawSelf() - GraphicsUtil.draw(self.image, self.x, self.y, 0, self.scale, self.scale) + UiElement.drawSelf(self) + + local x, y = GraphicsUtil.getAlignmentOffset(self, {width = self.imageWidth * self.scale, height = self.imageHeight * self.scale}) + GraphicsUtil.draw(self.image, x, y, 0, self.scale, self.scale) if self.drawBorders then -- border is just drawn on top, not around GraphicsUtil.setColor(self.outlineColor) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) end end +function ImageContainer:getPreferredWidth() + return self.imageWidth +end + +function ImageContainer:getPreferredHeight() + return self.imageHeight * (self.width / self.imageWidth) +end + return ImageContainer diff --git a/client/src/ui/ImageCursor.lua b/client/src/ui/ImageCursor.lua new file mode 100644 index 00000000..fe60a551 --- /dev/null +++ b/client/src/ui/ImageCursor.lua @@ -0,0 +1,66 @@ +local import = require("common.lib.import") +local Cursor = import("./Cursor") +local class = require("common.lib.class") +local consts = require("client.src.consts") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") + + +---@class ImageCursor : Cursor +---@field images love.Texture[] +---@field framesPerImage number +---@field private imageWidth integer +---@field private imageHeight integer +---@field quads table +---@field thickness integer +---@field blinking boolean +local ImageCursor = class( +function (self, target, keyInput, cursorImages) + self.images = cursorImages + self.imageWidth, self.imageHeight = self.images[1]:getDimensions() + local quadWidth = self.imageWidth / 2 + local quadHeight = self.imageHeight / 2 + self.quads = { + topLeft = GraphicsUtil:newRecycledQuad(0, 0, quadWidth, quadHeight, self.imageWidth, self.imageHeight), + topRight = GraphicsUtil:newRecycledQuad(quadWidth, 0, quadWidth, quadHeight, self.imageWidth, self.imageHeight), + bottomLeft = GraphicsUtil:newRecycledQuad(0, quadHeight, quadWidth, quadHeight, self.imageWidth, self.imageHeight), + bottomRight = GraphicsUtil:newRecycledQuad(quadWidth, quadHeight, quadWidth, quadHeight, self.imageWidth, self.imageHeight) + } + self.thickness = 3 + + self.framesPerImage = 8 + self.blinking = false +end, +Cursor) + +function ImageCursor:draw() + if self.focused then + local uiElement = self.focusToHover[self.focused] + GraphicsUtil.setColor(1, 1, 1, 1) + local x, y = uiElement:getScreenPos() + + local image + if not self.blinking then + local n = consts.FRAME_RATE * self.framesPerImage + local imageIndex = math.ceil((love.timer.getTime() % (n * #self.images)) / n) + image = self.images[imageIndex] + else + -- TODO: Player offset blinking so that cursors blink in turns + local playerNumber = 1 + if (math.floor(math.round(love.timer.getTime() / consts.FRAME_RATE) / self.framesPerImage) + playerNumber) % 2 + 1 == playerNumber then + return + else + image = self.images[1] + end + end + + local scale = math.min(uiElement.width / self.imageWidth, uiElement.height / self.imageHeight) + local thickness = math.round(self.thickness * scale) + GraphicsUtil.drawQuad(image, self.quads.topLeft, x - thickness, y - thickness, 0, scale) + GraphicsUtil.drawQuad(image, self.quads.topRight, x + uiElement.width + thickness, y - thickness, 0, scale, scale, self.imageWidth / 2) + GraphicsUtil.drawQuad(image, self.quads.bottomLeft, x - thickness, y + uiElement.height / 2 + thickness, 0, scale) + GraphicsUtil.drawQuad(image, self.quads.bottomRight, x + uiElement.width + thickness, y + uiElement.height / 2 + thickness, 0, scale, scale, self.imageWidth / 2) + end +end + +return ImageCursor \ No newline at end of file diff --git a/client/src/ui/InputField.lua b/client/src/ui/InputField.lua index 4050b391..839e43af 100644 --- a/client/src/ui/InputField.lua +++ b/client/src/ui/InputField.lua @@ -1,9 +1,9 @@ local utf8 = require("common.lib.utf8Additions") local util = require("common.lib.util") -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") -local inputFieldManager = require(PATH .. ".inputFieldManager") +local import = require("common.lib.import") +local UIElement = import("./UIElement") +local inputFieldManager = import("./inputFieldManager") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -69,12 +69,12 @@ end function InputField:getCursorPos() if self.offset == 0 then - return self.x + textOffset + return textOffset end local byteoffset = utf8.offset(self.value, self.offset) local text = string.sub(self.value, 1, byteoffset) - return self.x + textOffset + GraphicsUtil.newText(love.graphics.getFont(), text):getWidth() + return textOffset + GraphicsUtil.newText(love.graphics.getFont(), text):getWidth() end function InputField:unfocus() @@ -138,22 +138,22 @@ local valueColor = {1, 1, 1, 1} local placeholderColor = {.5, .5, .5, 1} function InputField:drawSelf() GraphicsUtil.setColor(self.outlineColor) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(self.backgroundColor) - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) local text = self.value ~= "" and self.text or self.placeholderText local textColor = self.value ~= "" and valueColor or placeholderColor local textHeight = text:getHeight() GraphicsUtil.setColor(textColor) - GraphicsUtil.draw(text, self.x + textOffset, self.y + (self.height - textHeight) / 2, 0, 1, 1) + GraphicsUtil.draw(text, textOffset, (self.height - textHeight) / 2, 0, 1, 1) if self.hasFocus then local cursorFlashPeriod = .5 if (math.floor(love.timer.getTime() / cursorFlashPeriod)) % 2 == 0 then GraphicsUtil.setColor(1, 1, 1, 1) - GraphicsUtil.draw(textCursor, self:getCursorPos(), self.y + (self.height - textHeight) / 2, 0, 1, 1) + GraphicsUtil.draw(textCursor, self:getCursorPos(), (self.height - textHeight) / 2, 0, 1, 1) end end GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/Label.lua b/client/src/ui/Label.lua index 88a45b57..090bd018 100644 --- a/client/src/ui/Label.lua +++ b/client/src/ui/Label.lua @@ -1,131 +1,130 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UIElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +local HorizontalWrapLayout = import("./Layouts.HorizontalWrapLayout") ---@class LabelOptions : UiElementOptions ----@field text string The raw text or localization key ----@field translate boolean? Whether the game looks for a localization for text or not +---@field id string? The localization key; nil if there should be no translation +---@field text string? The raw text; ignored if there is a localization key ---@field replacements string[]? Additional strings to perform string format on a localized key with parts marked for replacement ----@field fontSize integer? The size of the font +---@field fontSize FontSize? The size of the font ---@field wrap boolean? If the font should wrap around ----@field wrapRatio number? By which % of the unwrapped text the text should wrap ---@class Label : UiElement ----@field text string The raw text or localization key ----@field translate boolean Whether the game looks for a localization for text or not +---@operator call(LabelOptions): Label +---@field id string? The localization key ---@field replacementTable string[]? Additional strings to perform string format on a localized key with parts marked for replacement ----@field fontSize integer The size of the font ----@field wrap boolean If the font should wrap around ----@field wrapRatio number By which % of the unwrapped text the text should wrap +---@field text string The raw text or localization key ---@field font love.Font Cached font for recreating the love.Text on changes ----@field drawable love.Text Cached love.Text for redrawing +---@field fontSize FontSize The size of the font +---@field wrap boolean If the font should wrap around ---@overload fun(options: LabelOptions): Label +---@type Label local Label = class( function(self, options) - self.hAlign = options.hAlign or "left" - self.vAlign = options.vAlign or "top" + self.id = options.id - self.hFill = options.hFill or true + if self.id then + self.replacementTable = options.replacements or {} + self.text = loc(self.id, unpack(self.replacementTable)) + else + self.text = options.text or "" + end - self.wrap = options.wrap or false - self.wrapRatio = options.wrapRatio or 1 + self.hAlign = options.hAlign or "center" + self.vAlign = options.vAlign or "center" - self.fontSize = options.fontSize or GraphicsUtil.fontSize + self.fontSize = options.fontSize or "normal" + local font = GraphicsUtil.getGlobalFontWithSize(self.fontSize) - self:setText(options.text, options.replacements, options.translate) + if options.wrap ~= nil then + self.wrap = options.wrap + else + self.wrap = true + end + -- min sizes are set with this function + self:recalculateSizes() + self.width = options.width or self.preferredWidth + self.maxWidth = options.maxWidth or math.huge + self.height = options.height or font:getHeight() + self.maxHeight = options.maxHeight or math.huge + + self.hFill = true end, UIElement ) -Label.TYPE = "Label" -function Label:getEffectiveDimensions() - return self.drawable:getDimensions() -end - -function Label:setText(text, replacementTable, translate) - if text == self.text and replacementTable == self.replacementTable and self.translate == translate then - return - end - - -- whether we should translate the label or not - if translate ~= nil then - self.translate = translate - elseif self.translate == nil then - self.translate = true - end - - if replacementTable then - -- list of parameters for translating the label (e.g. numbers/names to replace placeholders with) - self.replacementTable = replacementTable - elseif not self.replacementTable then - self.replacementTable = {} - end - - if text then - self.text = text +Label.TYPE = "Label" +Label.layout = HorizontalWrapLayout + +function Label:recalculateSizes() + local font = GraphicsUtil.getGlobalFontWithSize(self.fontSize) + local words = self.text:split() + local maxWordWidth = font:getWidth(words[1]) + for i = 2, #words do + maxWordWidth = math.max(maxWordWidth, font:getWidth(words[i])) end - if self.translate then - -- always need a new text cause the font might have changed - self.drawable = GraphicsUtil.newText(GraphicsUtil.getGlobalFontWithSize(self.fontSize), loc(self.text, unpack(self.replacementTable))) + self.minHeight = font:getHeight() + self.preferredWidth = font:getWidth(self.text) + if self.wrap then + self.minWidth = maxWordWidth else - if not self.drawable then - self.drawable = GraphicsUtil.newText(GraphicsUtil.getGlobalFontWithSize(self.fontSize), self.text) - end + self.minWidth = self.preferredWidth end - - self:refreshFormatting() - - self.width = self.drawable:getWidth() - self.height = self.drawable:getHeight() end -function Label:setWrap(wrapRatio, hAlign) - self.wrap = not not wrapRatio - self.wrapRatio = wrapRatio - self.hAlign = hAlign or self.hAlign - self:refreshFormatting() +---@param fontSize FontSize +function Label:setFontSize(fontSize) + self.fontSize = fontSize + self:recalculateSizes() end -function Label:refreshFormatting() - local text = self.text +---@param id string +---@param replacements table? +function Label:setId(id, replacements) + self.id = id + self.replacementTable = replacements or {} + self.text = loc(self.id, unpack(self.replacementTable)) + self:recalculateSizes() +end - if self.translate then - text = loc(self.text, unpack(self.replacementTable)) - end +---@param text string +function Label:setText(text) + self.text = text + self:recalculateSizes() +end - if self.wrap then - self.drawable:setf(text, self.wrapRatio * self.width, self.hAlign) - self.height = self.drawable:getHeight() - else - self.drawable:set(text) +function Label:refreshLocalization() + if self.id then + self.text = loc(self.id, unpack(self.replacementTable)) end + self:recalculateSizes() end -function Label:onResize() - if self.wrap then - self.width = math.max(self.width, self.drawable:getWidth()) - else - self.width = self.drawable:getWidth() +function Label:drawSelf() + UIElement.drawSelf(self) + local y = 0 + if self.vAlign == "center" then + y = self.height / 2 - GraphicsUtil.getTextHeightForWidth(self.fontSize, self.text, self.width, self.hAlign) / 2 + elseif self.vAlign == "bottom" then + y = self.height - GraphicsUtil.getTextHeightForWidth(self.fontSize, self.text, self.width, self.hAlign) end - self.height = self.drawable:getHeight() - self:refreshFormatting() + GraphicsUtil.printf(self.text, 0, y, self.width, self.hAlign, nil, nil, self.fontSize) end -function Label:refreshLocalization() - if self.translate then - local font = GraphicsUtil.getGlobalFontWithSize(self.fontSize) +function Label:getPreferredWidth() + return self.preferredWidth +end - -- always need a new text cause the font might have changed - self.drawable = GraphicsUtil.newText(font, loc(self.text, unpack(self.replacementTable))) - self.width, self.height = self.drawable:getDimensions() - end +function Label:getMinHeight() + return GraphicsUtil.getTextHeightForWidth(self.fontSize, self.text, self.width, self.hAlign) end -function Label:drawSelf() - GraphicsUtil.drawClearText(self.drawable, math.round(self.x), math.round(self.y)) +function Label:addChild() + error("Labels cannot have children") end return Label \ No newline at end of file diff --git a/client/src/ui/Layouts/AdaptiveFlexLayout.lua b/client/src/ui/Layouts/AdaptiveFlexLayout.lua new file mode 100644 index 00000000..3a5ae329 --- /dev/null +++ b/client/src/ui/Layouts/AdaptiveFlexLayout.lua @@ -0,0 +1,23 @@ +local import = require("common.lib.import") +local Layout = import("./Layout") +local VerticalFlexLayout = import("./VerticalFlexLayout") +local HorizontalFlexLayout = import("./HorizontalFlexLayout") + +---@class AdaptiveFlexLayout : FlexLayout +---@diagnostic disable-next-line: assign-type-mismatch +local AdaptiveFlexLayout = setmetatable({}, {__index = HorizontalFlexLayout}) + +function AdaptiveFlexLayout.resize(uiElement, width, height) + local mt = getmetatable(AdaptiveFlexLayout) + if width and height then + if width < height and mt.__index ~= VerticalFlexLayout then + mt.__index = VerticalFlexLayout + elseif height < width and mt.__index ~= HorizontalFlexLayout then + mt.__index = HorizontalFlexLayout + end + end + + Layout.resize(uiElement, width, height) +end + +return AdaptiveFlexLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/FlexLayout.lua b/client/src/ui/Layouts/FlexLayout.lua new file mode 100644 index 00000000..a0bbab76 --- /dev/null +++ b/client/src/ui/Layouts/FlexLayout.lua @@ -0,0 +1,80 @@ +local import = require("common.lib.import") +local Layout = import("./Layout") +local util = require("common.lib.util") + +---@class FlexLayout : Layout +local FlexLayout = setmetatable({}, {__index = Layout}) + +---@param uiElement UiElement +function FlexLayout.fitSizeWidth(uiElement) + for _, child in ipairs(uiElement.children) do + if child.layout.fitSizeWidth then + child.layout.fitSizeWidth(child) + end + end + local w = uiElement.layout.getPreferredWidth(uiElement) + uiElement.newWidth = math.max(w, uiElement:getPreferredWidth(), uiElement.minWidth) +end + +---@param uiElement UiElement +function FlexLayout.fitSizeHeight(uiElement) + for _, child in ipairs(uiElement.children) do + if child.layout.fitSizeHeight then + child.layout.fitSizeHeight(child) + end + end + local h = uiElement.layout.getPreferredHeight(uiElement) + uiElement.newHeight = math.max(h, uiElement:getPreferredHeight(), uiElement.minHeight) +end + +function FlexLayout.setWidth(uiElement, width) + if not uiElement.newWidth then + uiElement.layout.fitSizeWidth(uiElement) + end + + local minWidth = uiElement.layout.getMinWidth(uiElement) + if not uiElement.controlsWindow then + minWidth = math.max(minWidth, uiElement.minWidth) + end + if width then + if width > minWidth then + uiElement.width = util.bound(minWidth, uiElement.newWidth, width) + else + uiElement.width = minWidth + end + else + uiElement.width = util.bound(minWidth, uiElement.newWidth, uiElement.maxWidth) + end + + uiElement.newWidth = nil +end + +function FlexLayout.setHeight(uiElement, height) + if not uiElement.newHeight then + uiElement.layout.fitSizeHeight(uiElement) + end + + local minHeight = uiElement.layout.getMinHeight(uiElement) + if height then + if height > minHeight then + uiElement.height = util.bound(minHeight, uiElement.newHeight, height) + else + uiElement.height = minHeight + end + else + uiElement.height = util.bound(minHeight, uiElement.newHeight, uiElement.maxHeight) + end + uiElement.newHeight = nil +end + +---@param uiElement UiElement +function FlexLayout.finalizeChildrenWidths(uiElement) + error("FlexLayout does not implement finalizeChildrenWidths") +end + +---@param uiElement UiElement +function FlexLayout.finalizeChildrenHeights(uiElement) + error("FlexLayout does not implement finalizeChildrenHeights") +end + +return FlexLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/HorizontalFlexLayout.lua b/client/src/ui/Layouts/HorizontalFlexLayout.lua new file mode 100644 index 00000000..bdad792d --- /dev/null +++ b/client/src/ui/Layouts/HorizontalFlexLayout.lua @@ -0,0 +1,260 @@ +local import = require("common.lib.import") +local FlexLayout = import("./FlexLayout") + +---@class HorizontalFlexLayout : FlexLayout +local HorizontalFlexLayout = setmetatable({characteristic = "horizontal"}, {__index = FlexLayout}) + +---@param uiElement UiElement +---@return number # the minimum width of the element as dictated by its children +function HorizontalFlexLayout.getMinWidth(uiElement) + local w = uiElement.padding * 2 + uiElement.childGap * (#uiElement.children - 1) + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + w = w + math.max(child.layout.getMinWidth(child), child.minWidth) + end + end + + return w +end + +function HorizontalFlexLayout.getPreferredWidth(uiElement) + local w = uiElement.padding * 2 + uiElement.childGap * (#uiElement.children - 1) + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + w = w + math.max(child.layout.getMinWidth(child), child:getPreferredWidth()) + end + end + + return w +end + +---@param uiElement UiElement +function HorizontalFlexLayout.getMinHeight(uiElement) + local h = uiElement.padding * 2 + local maxChildHeight = 0 + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + maxChildHeight = math.max(maxChildHeight, child.newHeight) + end + end + + h = h + maxChildHeight + + if uiElement.getMinHeight then + return math.max(h, uiElement:getMinHeight()) + else + return h + end +end + +function HorizontalFlexLayout.getPreferredHeight(uiElement) + local h = uiElement.padding * 2 + local maxChildHeight = 0 + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + maxChildHeight = math.max(maxChildHeight, child.layout.getMinHeight(child), child:getPreferredHeight()) + end + end + + return h + maxChildHeight +end + +local growables = {} +local shrinkables = {} + +---@param uiElement UiElement +function HorizontalFlexLayout.finalizeChildrenWidths(uiElement) + if #uiElement.children == 0 then + return + end + + local remainingWidth = uiElement.width - uiElement.padding * 2 + + for _, child in ipairs(uiElement.children) do + if uiElement.isVisible then + remainingWidth = remainingWidth - child.newWidth + remainingWidth = remainingWidth - uiElement.childGap + end + end + + remainingWidth = remainingWidth + uiElement.childGap + + if remainingWidth >= 1 then + table.clear(growables) + + for i, child in ipairs(uiElement.children) do + if child.isVisible and child.hFill and child.newWidth < child.maxWidth then + growables[#growables+1] = child + end + end + + if #growables > 0 then + + while #growables > 0 and remainingWidth > 0 do + local smallest = growables[1].newWidth + local secondSmallest = math.huge + -- if growables[1] is already the smallest, it will increment the counter at some point within the loop so we can't count it yet + local smallestCount = 1 + + for i = 2, #growables do + local growable = growables[i] + if growable.newWidth < smallest then + secondSmallest = smallest + smallest = growable.newWidth + smallestCount = 1 + elseif growable.newWidth > smallest then + secondSmallest = math.min(secondSmallest, growable.newWidth) + else + smallestCount = smallestCount + 1 + end + end + + local delta = secondSmallest - smallest + local widthToAdd = math.min(delta, remainingWidth / smallestCount) + + if delta * smallestCount >= remainingWidth then + for _, growable in ipairs(growables) do + if growable.newWidth == smallest then + local toAdd = math.min(widthToAdd, growable.maxWidth - growable.newWidth) + growable.newWidth = growable.newWidth + toAdd + remainingWidth = remainingWidth - toAdd + end + end + if remainingWidth < 1 then + -- we could run into floating point shenanigans here + remainingWidth = 0 + end + else + for _, growable in ipairs(growables) do + if growable.newWidth == smallest then + local toAdd = math.min(widthToAdd, growable.maxWidth - growable.newWidth) + growable.newWidth = growable.newWidth + toAdd + remainingWidth = remainingWidth - toAdd + end + end + end + + for i = #growables, 1, -1 do + local growable = growables[i] + if growable.newWidth >= growable.maxWidth then + table.remove(growables, i) + end + end + end + end + elseif remainingWidth <= -1 then + table.clear(shrinkables) + + for i, child in ipairs(uiElement.children) do + if child.isVisible and child.newWidth > child.minWidth then + shrinkables[#shrinkables+1] = child + end + end + + if #shrinkables > 0 then + while #shrinkables > 0 and remainingWidth < 0 do + local biggest = shrinkables[1].newWidth + local secondBiggest = 0 + -- if growables[1] is already the smallest, it will increment the counter at some point within the loop so we can't count it yet + local biggestCount = 1 + + for i = 2, #shrinkables do + local shrinkable = shrinkables[i] + if shrinkable.newWidth > biggest then + secondBiggest = biggest + biggest = shrinkable.newWidth + biggestCount = 1 + elseif shrinkable.newWidth < biggest then + secondBiggest = math.max(secondBiggest, shrinkable.newWidth) + else + biggestCount = biggestCount + 1 + end + end + + local delta = math.abs(secondBiggest - biggest) + local widthToSubtract = math.min(delta, math.abs(remainingWidth / biggestCount)) + + if delta * biggestCount >= -remainingWidth then + for _, shrinkable in ipairs(shrinkables) do + if shrinkable.newWidth == biggest then + local toSubtract = math.min(widthToSubtract, shrinkable.newWidth - shrinkable.minWidth) + shrinkable.newWidth = shrinkable.newWidth - toSubtract + remainingWidth = remainingWidth + toSubtract + end + end + if remainingWidth < 1 then + -- we could run into floating point shenanigans here + remainingWidth = 0 + end + else + for _, shrinkable in ipairs(shrinkables) do + if shrinkable.newWidth == biggest then + local toSubtract = math.min(widthToSubtract, shrinkable.newWidth - shrinkable.minWidth) + shrinkable.newWidth = shrinkable.newWidth - toSubtract + remainingWidth = remainingWidth + toSubtract + end + end + end + + for i = #shrinkables, 1, -1 do + local shrinkable = shrinkables[i] + if shrinkable.newWidth <= shrinkable.minWidth then + table.remove(shrinkables, i) + end + end + end + end + end +end + +---@param uiElement UiElement +function HorizontalFlexLayout.finalizeChildrenHeights(uiElement) + for _, child in ipairs(uiElement.children) do + if child.vFill then + child.newHeight = math.min(uiElement.height - uiElement.padding * 2, child.maxHeight) + end + end +end + +---@param uiElement UiElement +function HorizontalFlexLayout.positionChildren(uiElement) + local remainingWidth = uiElement.width - (uiElement.padding * 2 + uiElement.childGap * (#uiElement.children - 1)) + for _, child in ipairs(uiElement.children) do + if child.isVisible then + remainingWidth = remainingWidth - child.width + else + -- we subtracted the childgap for invisible children earlier + remainingWidth = remainingWidth + uiElement.childGap + end + end + + local x = uiElement.padding + + if uiElement.hAlign == "left" then + elseif uiElement.hAlign == "center" then + x = x + remainingWidth / 2 + elseif uiElement.hAlign == "right" then + x = x + remainingWidth + end + for _, child in ipairs(uiElement.children) do + if child.isVisible then + child.x = math.round(x) + x = x + uiElement.childGap + child.width + + if uiElement.vAlign == "top" then + child.y = uiElement.padding + elseif uiElement.vAlign == "center" then + child.y = (uiElement.height - child.height) / 2 + elseif uiElement.vAlign == "bottom" then + child.y = (uiElement.height - child.height) - uiElement.padding + end + child.y = math.round(child.y) + end + end +end + +return HorizontalFlexLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/HorizontalScrollLayout.lua b/client/src/ui/Layouts/HorizontalScrollLayout.lua new file mode 100644 index 00000000..b74b89b7 --- /dev/null +++ b/client/src/ui/Layouts/HorizontalScrollLayout.lua @@ -0,0 +1,41 @@ +local import = require("common.lib.import") +local HorizontalFlexLayout = import("./HorizontalFlexLayout") +local util = require("common.lib.util") + +---@class HorizontalScrollLayout : HorizontalFlexLayout +local HorizontalScrollLayout = setmetatable({}, {__index = HorizontalFlexLayout}) + +function HorizontalScrollLayout.getMinWidth(uiElement) + return util.bound(uiElement.minWidth, HorizontalFlexLayout.getMinWidth(uiElement), uiElement.maxWidth) +end + +function HorizontalScrollLayout.getPreferredWidth(uiElement) + return util.bound(uiElement.minWidth, HorizontalFlexLayout.getPreferredWidth(uiElement), uiElement.maxWidth) +end + +function HorizontalScrollLayout.finalizeChildrenWidths(uiElement) + -- no growing because we scroll in this direction +end + +---@param uiElement UiElement +function HorizontalScrollLayout.positionChildren(uiElement) + local x = uiElement.padding + for _, child in ipairs(uiElement.children) do + if child.isVisible then + child.x = x + + if uiElement.vAlign == "top" then + child.y = uiElement.padding + elseif uiElement.vAlign == "center" then + child.y = (uiElement.height - child.height) / 2 + elseif uiElement.vAlign == "bottom" then + child.y = (uiElement.height - child.height) - uiElement.padding + end + child.x = math.round(child.x) + child.y = math.round(child.y) + x = x + uiElement.childGap + child.width + end + end +end + +return HorizontalScrollLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/HorizontalWrapLayout.lua b/client/src/ui/Layouts/HorizontalWrapLayout.lua new file mode 100644 index 00000000..afae448b --- /dev/null +++ b/client/src/ui/Layouts/HorizontalWrapLayout.lua @@ -0,0 +1,73 @@ +local import = require("common.lib.import") +local HorizontalFlexLayout = import("./HorizontalFlexLayout") +local util = require("common.lib.util") + +---@class HorizontalWrapLayout : HorizontalFlexLayout +local HorizontalWrapLayout = setmetatable({}, {__index = HorizontalFlexLayout}) + +function HorizontalWrapLayout.resize(uiElement, width, height) + HorizontalFlexLayout.resize(uiElement, width, height) +end + +function HorizontalWrapLayout.getMinWidth(uiElement) + local w = uiElement.padding * 2 + local maxWidth = 0 + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + maxWidth = math.max(maxWidth, child.newWidth, child.minWidth) + end + end + + return w + maxWidth +end + +---@param uiElement UiElement +function HorizontalWrapLayout.getMinHeight(uiElement) + return uiElement:getMinHeight() +end + +---@param uiElement UiElement +function HorizontalWrapLayout.positionChildren(uiElement) + if not uiElement.tempRows or #uiElement.tempRows == 1 then + HorizontalFlexLayout.positionChildren(uiElement) + else + -- subtracting a childGap for every child, so compensating one in advance + local remainingWidth = uiElement.width - uiElement.padding * 2 + uiElement.childGap + for _, child in ipairs(uiElement.children) do + if child.isVisible and child.tempRow == 1 then + remainingWidth = remainingWidth - child.width - uiElement.childGap + end + end + + local startX = uiElement.padding + if uiElement.hAlign == "left" then + elseif uiElement.hAlign == "center" then + startX = startX + remainingWidth / 2 + elseif uiElement.hAlign == "right" then + startX = startX + remainingWidth + end + + local x = startX + local y = uiElement.padding + local row = 1 + for _, child in ipairs(uiElement.children) do + if child.isVisible then + if child.tempRow > row then + x = startX + y = y + uiElement.tempRows[row] + uiElement.childGap + row = child.tempRow + end + + child.x = math.round(x) + child.y = math.round(y) + x = x + uiElement.childGap + child.width + child.tempRow = nil + end + end + uiElement.tempRows = nil + end + +end + +return HorizontalWrapLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/Layout.lua b/client/src/ui/Layouts/Layout.lua new file mode 100644 index 00000000..3954da15 --- /dev/null +++ b/client/src/ui/Layouts/Layout.lua @@ -0,0 +1,117 @@ +---@enum LayoutCharacteristic +local LayoutCharacteristics = { none = "none", horizontal = "horizontal", vertical = "vertical"} + +---@class Layout +---@field characteristic LayoutCharacteristic +local Layout = { + characteristic = LayoutCharacteristics.none, + Characteristics = LayoutCharacteristics +} + +---@param uiElement UiElement +---@param width number? +---@param height number? +function Layout.resize(uiElement, width, height) + Layout.updateWidths(uiElement, width) + Layout.updateHeights(uiElement, height) + Layout.updatePositions(uiElement) + Layout.runResizedCallbacks(uiElement) +end + +function Layout.runResizedCallbacks(uiElement) + if uiElement.onResized then + uiElement:onResized() + end + + for _, child in ipairs(uiElement.children) do + Layout.runResizedCallbacks(child) + end +end + +---@param uiElement UiElement +function Layout.updateWidths(uiElement, width) + uiElement.layout.setWidth(uiElement, width) + uiElement.layout.finalizeChildrenWidths(uiElement) + + for i, child in ipairs(uiElement.children) do + if child.isVisible then + child.layout.updateWidths(child, child.newWidth) + end + end +end + +---@param uiElement UiElement +---@param width integer? +function Layout.setWidth(uiElement, width) + error("Layout does not implement setWidth") +end + +function Layout.finalizeChildrenWidths(uiElement) + error("Layout does not implement finalizeChildrenWidths") +end + +---@param uiElement UiElement +function Layout.updateHeights(uiElement, height) + -- about the most reasonable way to troubleshoot why a certain element is laid out in an expected way + if uiElement.debug then + local phi = 5 + end + uiElement.layout.setHeight(uiElement, height) + uiElement.layout.finalizeChildrenHeights(uiElement) + + for i, child in ipairs(uiElement.children) do + if child.isVisible then + child.layout.updateHeights(child, child.newHeight) + end + end +end + +function Layout.setHeight(uiElement, height) + error("Layout does not implement setHeight") +end + +function Layout.finalizeChildrenHeights(uiElement) + error("Layout does not implement finalizeChildrenHeights") +end + +function Layout.updatePositions(uiElement) + uiElement.layout.positionChildren(uiElement) + for _, child in ipairs(uiElement.children) do + if child.isVisible then + child.layout.updatePositions(child) + end + end +end + +---@param uiElement UiElement +function Layout.positionChildren(uiElement) + error("Layout does not implement positionChildren") +end + +---@param uiElement UiElement +---@return number # the minimum width of the element as dictated by its children +function Layout.getMinWidth(uiElement) + error("Layout does not implement getMinWidth") +end + +---@param uiElement UiElement +---@return number # the minimum height of the element as dictated by its children +function Layout.getMinHeight(uiElement) + error("Layout does not implement getMinHeight") +end + +---@param uiElement UiElement +---@return number # the preferred width of the element based on the preferred widths of its children +function Layout.getPreferredWidth(uiElement) + error("Layout does not implement getPreferredWidth") +end + +---@param uiElement UiElement +---@return number # the preferred height of the element based on the preferred heights of its children +function Layout.getPreferredHeight(uiElement) + error("Layout does not implement getPreferredHeight") +end + + + +return Layout \ No newline at end of file diff --git a/client/src/ui/Layouts/StaticLayout.lua b/client/src/ui/Layouts/StaticLayout.lua new file mode 100644 index 00000000..f9f997ed --- /dev/null +++ b/client/src/ui/Layouts/StaticLayout.lua @@ -0,0 +1,35 @@ +local import = require("common.lib.import") +local Layout = import("./Layout") + +---@class StaticLayout : Layout +local StaticLayout = setmetatable({}, {__index = Layout}) + +---@param uiElement UiElement +function StaticLayout.setWidth(uiElement, width) +end + +---@param uiElement UiElement +function StaticLayout.setHeight(uiElement, height) +end + +---@param uiElement UiElement +function StaticLayout.positionChildren(uiElement) +end + +---@param uiElement UiElement +---@return number +function StaticLayout.getMinWidth(uiElement) + return uiElement.width +end + +function StaticLayout.getPreferredWidth(uiElement) + return uiElement.width +end + +---@param uiElement UiElement +---@return number +function StaticLayout.getMinHeight(uiElement) + return uiElement.height +end + +return StaticLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/VerticalFlexLayout.lua b/client/src/ui/Layouts/VerticalFlexLayout.lua new file mode 100644 index 00000000..f1b13ebb --- /dev/null +++ b/client/src/ui/Layouts/VerticalFlexLayout.lua @@ -0,0 +1,264 @@ +local import = require("common.lib.import") +local FlexLayout = import("./FlexLayout") + +---@class VerticalFlexLayout : FlexLayout +local VerticalFlexLayout = setmetatable({characteristic = "vertical"}, {__index = FlexLayout}) + +---@param uiElement UiElement +---@return number # the minimum width of the element as dictated by its children +function VerticalFlexLayout.getMinWidth(uiElement) + local w = uiElement.padding * 2 + local maxChildWidth = 0 + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + maxChildWidth = math.max(maxChildWidth, child.layout.getMinWidth(child)) + end + end + + return w + maxChildWidth +end + +function VerticalFlexLayout.getPreferredWidth(uiElement) + local w = uiElement.padding * 2 + local maxChildWidth = 0 + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + maxChildWidth = math.max(maxChildWidth, child.layout.getMinWidth(child), child:getPreferredWidth()) + end + end + + return w + maxChildWidth +end + +---@param uiElement UiElement +function VerticalFlexLayout.getMinHeight(uiElement) + local h = uiElement.padding * 2 + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + h = h + math.max(child.layout.getMinHeight(child), child.minHeight) + uiElement.childGap + end + end + + h = h - uiElement.childGap + + if uiElement.getMinHeight then + return math.max(h, uiElement:getMinHeight()) + else + return h + end +end + +function VerticalFlexLayout.getPreferredHeight(uiElement) + local h = uiElement.padding * 2 + uiElement.childGap * (#uiElement.children - 1) + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + h = h + math.max(child.layout.getMinHeight(child), child:getPreferredHeight()) + end + end + + return h +end + +local growables = {} +local shrinkables = {} + +---@param uiElement UiElement +function VerticalFlexLayout.finalizeChildrenHeights(uiElement) + if #uiElement.children == 0 then + return + end + + local remainingHeight = uiElement.height - uiElement.padding * 2 + + for _, child in ipairs(uiElement.children) do + if uiElement.isVisible then + remainingHeight = remainingHeight - child.newHeight + remainingHeight = remainingHeight - uiElement.childGap + end + end + + remainingHeight = remainingHeight + uiElement.childGap + + if remainingHeight >= 1 then + table.clear(growables) + + for i, child in ipairs(uiElement.children) do + if child.isVisible and child.vFill and child.newHeight < child.maxHeight then + growables[#growables+1] = child + end + end + + if #growables > 0 then + while #growables > 0 and remainingHeight > 0 do + local smallest = growables[1].newHeight + local secondSmallest = math.huge + -- if growables[1] is already the smallest, it will increment the counter at some point within the loop so we can't count it yet + local smallestCount = 1 + + for i = 2, #growables do + local growable = growables[i] + if growable.newHeight < smallest then + secondSmallest = smallest + smallest = growable.newHeight + smallestCount = 1 + elseif growable.newHeight > smallest then + secondSmallest = math.min(secondSmallest, growable.newHeight) + else + smallestCount = smallestCount + 1 + end + end + + local delta = secondSmallest - smallest + local heightToAdd = math.min(delta, remainingHeight / smallestCount) + + if delta * smallestCount >= remainingHeight then + for _, growable in ipairs(growables) do + if growable.newHeight == smallest then + local toAdd = math.min(heightToAdd, growable.maxHeight - growable.newHeight) + growable.newHeight = growable.newHeight + toAdd + remainingHeight = remainingHeight - toAdd + end + end + if remainingHeight < 1 then + -- we could run into floating point shenanigans here + remainingHeight = 0 + end + else + for _, growable in ipairs(growables) do + if growable.newHeight == smallest then + local toAdd = math.min(heightToAdd, growable.maxHeight - growable.newHeight) + growable.newHeight = growable.newHeight + toAdd + remainingHeight = remainingHeight - toAdd + end + end + end + + for i = #growables, 1, -1 do + local growable = growables[i] + if growable.newHeight >= growable.maxHeight then + table.remove(growables, i) + end + end + end + end + elseif remainingHeight <= -1 then + table.clear(shrinkables) + + for i, child in ipairs(uiElement.children) do + if child.isVisible and child.newHeight > child.minHeight then + shrinkables[#shrinkables+1] = child + end + end + + if #shrinkables > 0 then + while #shrinkables > 0 and remainingHeight < 0 do + local biggest = shrinkables[1].newHeight + local secondBiggest = 0 + -- if growables[1] is already the smallest, it will increment the counter at some point within the loop so we can't count it yet + local biggestCount = 1 + + for i = 2, #shrinkables do + local shrinkable = shrinkables[i] + if shrinkable.newHeight > biggest then + secondBiggest = biggest + biggest = shrinkable.newHeight + biggestCount = 1 + elseif shrinkable.newHeight < biggest then + secondBiggest = math.max(secondBiggest, shrinkable.newHeight) + else + biggestCount = biggestCount + 1 + end + end + + local delta = math.abs(secondBiggest - biggest) + local heightToSubtract = math.min(delta, math.abs(remainingHeight / biggestCount)) + + if delta * biggestCount >= -remainingHeight then + for _, shrinkable in ipairs(shrinkables) do + if shrinkable.newHeight == biggest then + local toSubtract = math.min(heightToSubtract, shrinkable.newHeight - shrinkable.minHeight) + shrinkable.newHeight = shrinkable.newHeight - toSubtract + remainingHeight = remainingHeight + toSubtract + end + end + if remainingHeight < 1 then + -- we could run into floating point shenanigans here + remainingHeight = 0 + end + else + for _, shrinkable in ipairs(shrinkables) do + if shrinkable.newHeight == biggest then + local toSubtract = math.min(heightToSubtract, shrinkable.newHeight - shrinkable.minHeight) + shrinkable.newHeight = shrinkable.newHeight - toSubtract + remainingHeight = remainingHeight + toSubtract + end + end + end + + for i = #shrinkables, 1, -1 do + local shrinkable = shrinkables[i] + if shrinkable.newHeight <= shrinkable.minHeight then + table.remove(shrinkables, i) + end + end + end + end + end +end + +---@param uiElement UiElement +function VerticalFlexLayout.finalizeChildrenWidths(uiElement) + local maxWidth = uiElement.width - uiElement.padding * 2 + for _, child in ipairs(uiElement.children) do + if child.hFill then + child.newWidth = math.min(maxWidth, child.maxWidth) + else + child.newWidth = math.min(maxWidth, child.newWidth) + end + end +end + +---@param uiElement UiElement +function VerticalFlexLayout.positionChildren(uiElement) + local remainingHeight = uiElement.height - (uiElement.padding * 2 + uiElement.childGap * (#uiElement.children - 1)) + for _, child in ipairs(uiElement.children) do + if child.isVisible then + remainingHeight = remainingHeight - child.height + else + -- we subtracted the childgap for this child earlier + remainingHeight = remainingHeight + uiElement.childGap + end + end + + local y = uiElement.padding + + if uiElement.vAlign == "top" then + elseif uiElement.vAlign == "center" then + y = y + remainingHeight / 2 + elseif uiElement.vAlign == "bottom" then + y = y + remainingHeight + end + + for _, child in ipairs(uiElement.children) do + if child.isVisible then + child.y = y + + if uiElement.hAlign == "left" then + child.x = uiElement.padding + elseif uiElement.hAlign == "center" then + child.x = (uiElement.width - child.width) / 2 + elseif uiElement.hAlign == "right" then + child.x = (uiElement.width - child.width) - uiElement.padding + end + child.x = math.round(child.x) + child.y = math.round(child.y) + y = y + uiElement.childGap + child.height + end + end +end + +return VerticalFlexLayout \ No newline at end of file diff --git a/client/src/ui/Layouts/VerticalScrollLayout.lua b/client/src/ui/Layouts/VerticalScrollLayout.lua new file mode 100644 index 00000000..0f142ed6 --- /dev/null +++ b/client/src/ui/Layouts/VerticalScrollLayout.lua @@ -0,0 +1,37 @@ +local import = require("common.lib.import") +local VerticalFlexLayout = import("./VerticalFlexLayout") +local util = require("common.lib.util") + +---@class VerticalScrollLayout : VerticalFlexLayout +local VerticalScrollLayout = setmetatable({}, {__index = VerticalFlexLayout}) + +function VerticalScrollLayout.getMinHeight(uiElement) + return uiElement.minHeight +end + +function VerticalScrollLayout.finalizeChildrenHeights(uiElement) + -- no growing because we scroll in this direction +end + +---@param uiElement UiElement +function VerticalScrollLayout.positionChildren(uiElement) + local y = uiElement.padding + for _, child in ipairs(uiElement.children) do + if child.isVisible then + child.y = y + + if uiElement.hAlign == "left" then + child.x = uiElement.padding + elseif uiElement.hAlign == "center" then + child.x = (uiElement.width - child.width) / 2 + elseif uiElement.hAlign == "right" then + child.x = (uiElement.width - child.width) - uiElement.padding + end + child.x = math.round(child.x) + child.y = math.round(child.y) + y = y + uiElement.childGap + child.height + end + end +end + +return VerticalScrollLayout \ No newline at end of file diff --git a/client/src/ui/Leaderboard.lua b/client/src/ui/Leaderboard.lua index fc2680ee..93f1d918 100644 --- a/client/src/ui/Leaderboard.lua +++ b/client/src/ui/Leaderboard.lua @@ -1,7 +1,7 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") -local Label = require(PATH .. ".Label") -local Focusable = require(PATH .. ".Focusable") +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local Label = import("./Label") +local Focusable = import("./Focusable") local class = require("common.lib.class") local util = require("common.lib.util") diff --git a/client/src/ui/LevelSlider.lua b/client/src/ui/LevelSlider.lua index 7d68f87c..1b5533b6 100644 --- a/client/src/ui/LevelSlider.lua +++ b/client/src/ui/LevelSlider.lua @@ -1,10 +1,11 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local Slider = require(PATH .. ".Slider") +local import = require("common.lib.import") +local Slider = import("./Slider") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") ---@class LevelSlider : Slider +---@operator call(UiElementOptions): LevelSlider local LevelSlider = class( function(self, options) self.min = 1 @@ -34,10 +35,10 @@ function LevelSlider:drawSelf() for i, level_img in ipairs(themes[config.theme].images.IMG_levels) do local img = i <= self.value and level_img or themes[config.theme].images.IMG_levels_unfocus[i] - GraphicsUtil.draw(img, self.x + (i - 1) * self.tickLength, self.y, 0, self.tickLength / img:getWidth(), self.tickLength / img:getHeight(), 0, 0) + GraphicsUtil.draw(img, (i - 1) * self.tickLength, 0, 0, self.tickLength / img:getWidth(), self.tickLength / img:getHeight(), 0, 0) end local cursor_image = themes[config.theme].images.IMG_level_cursor - GraphicsUtil.draw(cursor_image, self.x + (self.value - 1 + .5) * self.tickLength, self.y + self.tickLength, 0, 1, 1, cursor_image:getWidth() / 2, 0) + GraphicsUtil.draw(cursor_image, (self.value - 1 + .5) * self.tickLength, self.tickLength, 0, 1, 1, cursor_image:getWidth() / 2, 0) end return LevelSlider \ No newline at end of file diff --git a/client/src/ui/Menu.lua b/client/src/ui/Menu.lua deleted file mode 100644 index 0bc6fbab..00000000 --- a/client/src/ui/Menu.lua +++ /dev/null @@ -1,336 +0,0 @@ -local table = table - -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") -local Label = require(PATH .. ".Label") -local directsFocus = require(PATH .. ".FocusDirector") -local class = require("common.lib.class") -local input = require("client.src.inputManager") - -local NAVIGATION_BUTTON_WIDTH = 30 - --- Menu is a collection of buttons that stack vertically and supports scrolling and keyboard navigation. --- It requires the passed in menu items to have valid widths and adds padding between each. The height also must be passed in --- and the width is the maximum of all buttons. ----@class Menu : UiElement -local Menu = class( - function(self, options) - ---@class Menu - self = self - self.TYPE = "VerticalScrollingButtonMenu" - - self.selectedIndex = 1 - self.yMin = self.y - self.totalHeight = 0 - self.menuItemYOffsets = {} - self.allContentShowing = true - - 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"}) - self:addChild(self.upIndicator) - self:addChild(self.downIndicator) - - -- bogus this should be passed in? - self.centerVertically = themes[config.theme].centerMenusVertically - - self.yOffset = 0 - self.firstActiveIndex = 1 - self.lastActiveIndex = 1 - self:setMenuItems(options.menuItems) - directsFocus(self) - end, - UIElement -) - -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 - }) - - return menu -end - --- Sets the menu items for this menu --- menuItems: a list of UIElement tuples of the form: --- {{Label/Button, ButtonGroup/Stepper/Slider}, ...} --- the actual self.menuItems list is formated slightly differently, consisting of a list of Labels or Buttons --- each of which may have a ButtonGroup, Stepper, or Slider child element which controls the action for that item -function Menu:setMenuItems(menuItems) - if self.menuItems then - for i, menuItem in ipairs(self.menuItems) do - menuItem:detach() - end - end - - self.menuItems = {} - - for i, menuItem in ipairs(menuItems) do - self:addChild(menuItem) - self.menuItems[#self.menuItems + 1] = menuItem - end - self:setSelectedIndex(1) -end - -function Menu:layout() - self.upIndicator:setVisibility(false) - self.downIndicator:setVisibility(false) - self.allContentShowing = self.yOffset == 0 - self.firstActiveIndex = nil - self.lastActiveIndex = nil - self.width = 0 - self.totalHeight = 0 - - if #self.menuItems == 0 then - return - end - - local currentY = 0 - local totalMenuHeight = 0 - local menuFull = false - for i, menuItem in ipairs(self.menuItems) do - self.menuItemYOffsets[i] = currentY - menuItem:setVisibility(false) - local realY = currentY - self.yOffset - if realY < 0 then - self.upIndicator:setVisibility(true) - end - if menuFull == false and realY >= 0 then - if realY + menuItem.height < self.height then - if self.firstActiveIndex == nil then - self.firstActiveIndex = i - end - menuItem.x = Menu.BUTTON_HORIZONTAL_PADDING - menuItem.y = realY - menuItem:setVisibility(true) - else - self.allContentShowing = false - self.downIndicator:setVisibility(true) - menuFull = true - end - end - currentY = currentY + menuItem.height + Menu.BUTTON_VERTICAL_PADDING - 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 - end - - if self.centerVertically then - self.y = self.yMin + (self.height / 2) - (totalMenuHeight / 2) - else - self.y = self.yMin - end -end - -function Menu:addMenuItem(index, menuItem) - local needsIncreasedIndex = false - if index <= self.selectedIndex then - needsIncreasedIndex = true - end - table.insert(self.menuItems, index, menuItem) - self:addChild(menuItem) - if needsIncreasedIndex then - self:setSelectedIndex(self.selectedIndex + 1) - end - self:layout() -end - -function Menu:removeMenuItemAtIndex(index) - return self:removeMenuItem(self.menuItems[index].id) -end - -function Menu:indexOfMenuItemID(menuItemId) - local menuItemIndex = nil - for i, menuItem in ipairs(self.menuItems) do - if menuItemId == menuItem.id then - menuItemIndex = i - break - end - end - return menuItemIndex -end - -function Menu:containsMenuItemID(menuItemId) - return self:indexOfMenuItemID(menuItemId) ~= nil -end - -function Menu:removeMenuItem(menuItemId) - local menuItemIndex = self:indexOfMenuItemID(menuItemId) - - if menuItemIndex == nil then - return - end - - local needsDecreasedIndex = false - if menuItemIndex <= self.selectedIndex then - needsDecreasedIndex = true - end - - local menuItem = table.remove(self.menuItems, menuItemIndex) - menuItem:detach() - - if needsDecreasedIndex then - self:setSelectedIndex(self.selectedIndex - 1) - end - - self:layout() - return menuItem -end - --- Updates the selected index of the menu --- Also updates the scroll state to show the button if off screen -function Menu:setSelectedIndex(index) - if index <= 0 then - index = 1 -- 1 index is the default if no items - end - - if #self.menuItems >= self.selectedIndex then - self.menuItems[self.selectedIndex]:setSelected(false) - end - if self.firstActiveIndex > index then - self.yOffset = self.menuItemYOffsets[index] - elseif self.lastActiveIndex < index then - local currentIndex = 1 - local bottomOfDesiredIndex = self.menuItemYOffsets[index] + self.menuItems[index].height - while self.menuItemYOffsets[currentIndex] + self.height < bottomOfDesiredIndex do - currentIndex = currentIndex + 1 - if currentIndex >= #self.menuItems then - break - end - end - self.yOffset = self.menuItemYOffsets[currentIndex] - end - self.selectedIndex = index - if #self.menuItems > 0 then - self.menuItems[self.selectedIndex]:setSelected(true) - end - self:layout() -end - -function Menu:scrollUp() - self:setSelectedIndex(wrap(1, self.selectedIndex - 1, #self.menuItems)) - GAME.theme:playMoveSfx() -end - -function Menu:scrollDown() - self:setSelectedIndex(wrap(1, self.selectedIndex + 1, #self.menuItems)) - GAME.theme:playMoveSfx() -end - -function Menu:receiveInputs(inputs, dt) - if not self.isEnabled then - return - end - - if not inputs then - -- if we don't get inputs passed, use the global input table - inputs = input - end - - local selectedElement = self.menuItems[self.selectedIndex] - - 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) - end - elseif inputs:isPressedWithRepeat("MenuUp") then - self:scrollUp() - elseif inputs:isPressedWithRepeat("MenuDown") then - self:scrollDown() - else - if inputs.isDown["MenuSelect"] and selectedElement.isFocusable then - self:setFocus(selectedElement) - else - selectedElement:receiveInputs(inputs, dt) - end - end -end - -function Menu:update(dt) - -end - -function Menu:drawSelf() - -end - -function Menu:onTouch(x, y) - self.swiping = true - self.initialTouchX = x - self.initialTouchY = y - self.originalY = self.yOffset - local realTouchedElement = UIElement.getTouchedElement(self, x, y) - if realTouchedElement and realTouchedElement ~= self then - self.touchedChild = realTouchedElement - self.touchedChild:onTouch(x, y) - end -end - -function Menu:onDrag(x, y) - if not self.touchedChild or not self.touchedChild.onDrag then - local yOffset = y - self.initialTouchY - if self.height < self.totalHeight then - if yOffset > 0 then - self.yOffset = math.max(self.originalY - yOffset, -50)-- - 2 * NAVIGATION_BUTTON_WIDTH) - else - self.yOffset = math.min(self.totalHeight - self.height + 50, self.originalY - yOffset) - end - self:layout() - end - else - self.touchedChild:onDrag(x, y) - end -end - -function Menu:onRelease(x, y) - if not self.touchedChild or not self.touchedChild.onRelease then - self:onDrag(x, y) - else - if self.yOffset ~= self.originalY then - -- we dragged so trigger with the original touch coordinates - -- that way the button will only trigger its on-click if it still touches the start coords - self.touchedChild:onRelease(self.initialTouchX, self.initialTouchY) - else - self.touchedChild:onRelease(x, y) - end - end - - self.swiping = false - self.touchedChild = nil -end - --- overwrite the default callback to always return itself --- while keeping a reference to the really touched element -function Menu:getTouchedElement(x, y) - if self.isVisible and self.isEnabled and self:inBounds(x, y) then - if self.allContentShowing then - local touchedElement - for i = 1, #self.children do - touchedElement = self.children[i]:getTouchedElement(x, y) - if touchedElement then - return touchedElement - end - end - else - return self - end - end -end - -return Menu \ No newline at end of file diff --git a/client/src/ui/MenuItem.lua b/client/src/ui/MenuItem.lua index 99f83ba0..b71dc541 100644 --- a/client/src/ui/MenuItem.lua +++ b/client/src/ui/MenuItem.lua @@ -1,77 +1,85 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") -local Label = require(PATH .. ".Label") -local TextButton = require(PATH .. ".TextButton") +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local Label = import("./Label") +local Button = import("./Button") +local TextButton = import("./TextButton") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") -local system = require("client.src.system") +local HorizontalFlexLayout = import("./Layouts.HorizontalFlexLayout") +local addCursorInteractionInterface = import("./CursorInteractable") --- MenuItem is a specific UIElement that all children of Menu should be +-- MenuItem is a wrapper UIElement that bundles a label element with another interactable UIElement and passes through inputs to that second element +---@class MenuItem : UiElement, CursorInteractable local MenuItem = class(function(self, options) - self.selected = false - self.TYPE = "MenuItem" + addCursorInteractionInterface(self, self.receiveInputs) end, UiElement) +MenuItem.TYPE = "MenuItem" MenuItem.PADDING = 2 +MenuItem.layout = HorizontalFlexLayout -- 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 Label +---@param item UiElement +---@return UiElement | CursorInteractable function MenuItem.createMenuItem(label, item) assert(label ~= nil) - label.vAlign = "center" - label.x = MenuItem.PADDING - - local menuItem = MenuItem({x = 0, y = 0}) + local menuItem = MenuItem({ + hAlign = "center", + vAlign = "center", + layout = HorizontalFlexLayout, + childGap = 16, + hFill = true, + backgroundColor = {math.random(), math.random(), math.random(), 0.4} + }) menuItem.width = label.width + (2 * MenuItem.PADDING) - 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 - menuItem.height = math.max(menuItem.height, math.max(label.height, item and item.height or 0) + (2 * MenuItem.PADDING)) - end - + label.vAlign = "center" + label.hAlign = "right" + label.hFill = true + menuItem:addChild(label) if item ~= nil then - local spaceBetween = 16 - item.x = label.width + spaceBetween + item.hFill = true item.vAlign = "center" - if system.isMobileOS() or DEBUG_ENABLED then - item.height = math.max(30, item.height) - end - menuItem.width = item.x + item.width + MenuItem.PADDING menuItem:addChild(item) + menuItem.item = item end - menuItem:addChild(label) - return menuItem end --- Creates a menu item with just a button -function MenuItem.createButtonMenuItem(text, replacements, translate, onClick) +-- Creates just a button as buttons already have their own hover draw +function MenuItem.createButtonMenuItem(text, replacements, translate, action) assert(text ~= nil) - local BUTTON_WIDTH = 140 - if translate == nil then - translate = true + local id + if translate == nil or translate then + id = text + text = nil end - local textButton = TextButton({ - label = Label({ - text = text, - replacements = replacements, - translate = translate, - hAlign = "center", - vAlign = "center" - }), - onClick = onClick, width = BUTTON_WIDTH - }) - local menuItem = MenuItem.createMenuItem(textButton) - menuItem.textButton = textButton + local label = Label({ + id = id, + text = text, + replacements = replacements, + hAlign = "center", + vAlign = "center", + wrap = false + }) + local button = Button({ + width = 140, + maxWidth = 300, + padding = 8, + action = action, + hAlign = "center", + vAlign = "center", + }) + button:addChild(label) - return menuItem + return button end -- Creates a menu item with a label followed by a button @@ -86,60 +94,81 @@ function MenuItem.createLabeledButtonMenuItem(labelText, labelTextReplacements, if buttonTextTranslate == nil then buttonTextTranslate = true end + local id + if labelTextTranslate == nil or labelTextTranslate then + id = text + text = nil + end + local label = Label({ + id = id, + text = text, + replacements = labelTextReplacements, + vAlign = "center" + }) + local textButton = TextButton({ + label = Label({ + text = buttonText, + replacements = buttonTextReplacements, + translate = buttonTextTranslate, + hAlign = "center", + vAlign = "center" + }), + action = buttonOnClick, + width = BUTTON_WIDTH + }) - local label = Label({text = labelText, replacements = labelTextReplacements, translate = labelTextTranslate, vAlign = "center"}) - local textButton = TextButton({label = Label({text = buttonText, replacements = buttonTextReplacements, translate = buttonTextTranslate, hAlign = "center", vAlign = "center"}), onClick = buttonOnClick, width = BUTTON_WIDTH}) - - local menuItem = MenuItem.createMenuItem(label, textButton) - menuItem.textButton = textButton - - return menuItem + return MenuItem.createMenuItem(label, textButton) end function MenuItem.createStepperMenuItem(text, replacements, translate, stepper) assert(text ~= nil) - assert(stepper ~= nil) - if translate == nil then - translate = true + local id + if translate == nil or translate then + id = text + text = nil end - local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) - local menuItem = MenuItem.createMenuItem(label, stepper) - - return menuItem + local label = Label({ + id = id, + text = text, + replacements = replacements, + vAlign = "center" + }) + + return MenuItem.createMenuItem(label, stepper) end function MenuItem.createToggleButtonGroupMenuItem(text, replacements, translate, toggleButtonGroup) assert(text ~= nil) - assert(toggleButtonGroup ~= nil) - if translate == nil then - translate = true + local id + if translate == nil or translate then + id = text + text = nil end - local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) - local menuItem = MenuItem.createMenuItem(label, toggleButtonGroup) - - return menuItem + local label = Label({ + id = id, + text = text, + replacements = replacements, + vAlign = "center" + }) + return MenuItem.createMenuItem(label, toggleButtonGroup) end function MenuItem.createSliderMenuItem(text, replacements, translate, slider) assert(text ~= nil) - assert(slider ~= nil) - if translate == nil then - translate = true - end - local label = Label({text = text, replacements = replacements, translate = translate, vAlign = "center"}) - local menuItem = MenuItem.createMenuItem(label, slider) - - return menuItem -end - -function MenuItem:setSelected(selected) - self.selected = selected - if selected and self.onSelectedFunction then - self.onSelectedFunction() + local id + if translate == nil or translate then + id = text + text = nil end + local label = Label({ + id = id, + text = text, + replacements = replacements, + vAlign = "center" + }) + return MenuItem.createMenuItem(label, slider) end - local DEFAULT_BACKGROUND_COLOR = {1, 1, 1} local SELECTED_BACKGROUND_COLOR = {0.6, 0.6, 1} local DEFAULT_BORDER_COLOR = {1, 1, 1} @@ -147,25 +176,21 @@ local SELECTED_BORDER_COLOR = {0.6, 0.6, 1} function MenuItem:drawSelf() local baseOpacity = 0.15 - if self.selected then + if next(self.hoveringCursors) 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) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height, SELECTED_BACKGROUND_COLOR[1], SELECTED_BACKGROUND_COLOR[2], SELECTED_BACKGROUND_COLOR[3], fillOpacity) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height, SELECTED_BORDER_COLOR[1], SELECTED_BORDER_COLOR[2], SELECTED_BORDER_COLOR[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) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height, DEFAULT_BACKGROUND_COLOR[1], DEFAULT_BACKGROUND_COLOR[2], DEFAULT_BACKGROUND_COLOR[3], baseOpacity) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height, DEFAULT_BORDER_COLOR[1], DEFAULT_BORDER_COLOR[2], DEFAULT_BORDER_COLOR[3], baseOpacity) end end --- inputs as a passthrough in case we ever implement player specific menus -function MenuItem:receiveInputs(inputs) - for _, child in ipairs(self.children) do - if child.receiveInputs then - child:receiveInputs(inputs) - return - end +function MenuItem:receiveInputs(cursor, dt) + if self.item then + self.item:receiveInputs(cursor, dt) end end diff --git a/client/src/ui/MultiPlayerSelectionWrapper.lua b/client/src/ui/MultiPlayerSelectionWrapper.lua index f2f2ce44..4c1f8656 100644 --- a/client/src/ui/MultiPlayerSelectionWrapper.lua +++ b/client/src/ui/MultiPlayerSelectionWrapper.lua @@ -1,8 +1,8 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local Label = require(PATH .. ".Label") -local StackPanel = require(PATH .. ".StackPanel") +local import = require("common.lib.import") +local Label = import("./Label") +local StackPanel = import("./StackPanel") local class = require("common.lib.class") -local Focusable = require(PATH .. ".Focusable") +local Focusable = import("./Focusable") -- forms a layer of abstraction between a player specific selector (e.g. GridCursor) and UiElements that exist per player -- the MultiPlayerSelectionWrapper displays the UiElements of all players but upon selection only redirects inputs to the @@ -23,7 +23,6 @@ function MultiPlayerSelectionWrapper:addElement(uiElement, player) end self:applyStackPanelSettings(uiElement) self:addChild(uiElement) - self:resize() end function MultiPlayerSelectionWrapper:insertElementAtIndex(uiElement, index, player) diff --git a/client/src/ui/PagedUniGrid.lua b/client/src/ui/PagedUniGrid.lua index 2dfe1bc4..6b6c0c1e 100644 --- a/client/src/ui/PagedUniGrid.lua +++ b/client/src/ui/PagedUniGrid.lua @@ -1,8 +1,8 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") -local Label = require(PATH .. ".Label") -local TextButton = require(PATH .. ".TextButton") -local Grid = require(PATH .. ".Grid") +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local Label = import("./Label") +local TextButton = import("./TextButton") +local Grid = import("./Grid") local class = require("common.lib.class") local Signal = require("common.lib.signal") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -50,7 +50,7 @@ local PagedUniGrid = class(function(self, options) vAlign = "top", width = self.unitSize / 2, height = self.unitSize / 2, - onClick = function(selfElement, inputSource, holdTime) self:turnPage(-1) end, + action = function(selfElement, inputSource, holdTime) self:turnPage(-1) end, }) self.pageTurnButtons.right = TextButton({ label = Label({text = ">", translate = false}), @@ -58,7 +58,7 @@ local PagedUniGrid = class(function(self, options) vAlign = "top", width = self.unitSize / 2, height = self.unitSize / 2, - onClick = function(selfElement, inputSource, holdTime) self:turnPage(1) end, + action = function(selfElement, inputSource, holdTime) self:turnPage(1) end, }) addNewPage(self) goToPage(self, 1) @@ -103,7 +103,7 @@ end function PagedUniGrid:drawSelf() if DEBUG_ENABLED then GraphicsUtil.setColor(1, 0, 0, 1) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) end end diff --git a/client/src/ui/PanelCarousel.lua b/client/src/ui/PanelCarousel.lua index 3d544b1c..3eeb5a80 100644 --- a/client/src/ui/PanelCarousel.lua +++ b/client/src/ui/PanelCarousel.lua @@ -1,8 +1,8 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local Carousel = require(PATH .. ".Carousel") +local import = require("common.lib.import") +local Carousel = import("./Carousel") local class = require("common.lib.class") -local StackPanel = require(PATH .. ".StackPanel") -local ImageContainer = require(PATH .. ".ImageContainer") +local StackPanel = import("./StackPanel") +local ImageContainer = import("./ImageContainer") local PanelCarousel = class(function(carousel, options) carousel.colorCount = 5 diff --git a/client/src/ui/PanelSetButton.lua b/client/src/ui/PanelSetButton.lua new file mode 100644 index 00000000..5a4e9665 --- /dev/null +++ b/client/src/ui/PanelSetButton.lua @@ -0,0 +1,85 @@ +local import = require("common.lib.import") +local class = require("common.lib.class") +local Button = import("./Button") +local consts = require("client.src.consts") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") +local Panel = require("common.engine.Panel") +local LevelPresets = require("common.data.LevelPresets") + + +---@class PanelButtonOptions : ButtonOptions +---@field panelSet PanelSet + +---@class PanelButton : Button +---@field panelSet PanelSet +---@field dangerTimer integer +---@field panels Panel[] +local PanelButton = class( +function (self, options) + self.panelSet = options.panelSet + self.panels = {} + + local frameTimes = LevelPresets.getModern(5).frameConstants + if GAME.localPlayer and GAME.localPlayer.settings.levelData then + frameTimes = GAME.localPlayer.settings.levelData.frameConstants + end + + for i = 1, 9 do + local panel = Panel(1, 1, i, frameTimes) + panel.color = i + self.panels[i] = panel + end + + self.minWidth = 9 * 16 + + self.dangerTimer = 0 +end, +Button) + +PanelButton.TYPE = "PanelButton" + +---@param inputSource table +function PanelButton:action(inputSource) + ---@type MatchParticipant + local player + if inputSource and inputSource.player then + player = inputSource.player + else + player = GAME.localPlayer + end + player:setPanels(self.panelSet.id) +end + +function PanelButton:getMinHeight() + return math.ceil(self.width / 9) + 4 + GraphicsUtil.getTextHeightForWidth("normal", self.panelSet.name or self.panelSet.id, self.width, "center") +end + +function PanelButton:drawSelf() + self.panelSet:prepareDraw() + local width = math.floor(self.width / 9) + + if self:isHovered() then + self.dangerTimer = self.dangerTimer + 1 + else + self.dangerTimer = 0 + end + + local x = 0 + local scale = width / 16 + + for i = 1, 9 do + -- 16 because panelSet draw multiplies the location by the scale argument as well and 16 is the base value + x = (i - 1) * 16 + local panel = self.panels[i] + self.panelSet:addToDraw(panel, x, 0, scale, { self:isHovered() }, self.dangerTimer, 0) + end + + self.panelSet:drawBatch() + + GraphicsUtil.printf(self.panelSet.name or self.panelSet.id, 0, width + 4, self.width, "center", nil, nil, "normal") +end + + + +return PanelButton \ No newline at end of file diff --git a/client/src/ui/PixelFontLabel.lua b/client/src/ui/PixelFontLabel.lua index 2b513200..3fecf847 100644 --- a/client/src/ui/PixelFontLabel.lua +++ b/client/src/ui/PixelFontLabel.lua @@ -1,5 +1,5 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -11,6 +11,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@field fontMap PixelFontMap? ---@class PixelFontLabel : UiElement +---@operator call(PixelFontLabelOptions): PixelFontLabel ---@field text string ---@field charSpacing integer ---@field xScale number @@ -18,6 +19,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@field fontMap PixelFontMap ---@field charDistanceScaled number ---@overload fun(options: PixelFontLabelOptions): PixelFontLabel +---@type PixelFontLabel local PixelFontLabel = class( ---@param self PixelFontLabel ---@param options PixelFontLabelOptions @@ -57,10 +59,10 @@ function PixelFontLabel:drawSelf() for i = 1, self.text:len(), 1 do local char = self.text:sub(i, i) if char ~= " " then - local characterX = self.x + ((i - 1) * self.charDistanceScaled) + local characterX = ((i - 1) * self.charDistanceScaled) -- Render it at the proper digit location - GraphicsUtil.drawQuad(self.fontMap.atlas, self.fontMap.charToQuad[char], characterX, self.y, 0, self.xScale, self.yScale) + GraphicsUtil.drawQuad(self.fontMap.atlas, self.fontMap.charToQuad[char], characterX, 0, 0, self.xScale, self.yScale) end end end diff --git a/client/src/ui/ScrollContainer.lua b/client/src/ui/ScrollContainer.lua index fe674170..4e53ba74 100644 --- a/client/src/ui/ScrollContainer.lua +++ b/client/src/ui/ScrollContainer.lua @@ -1,22 +1,31 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") local util = require("common.lib.util") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local logger = require("common.lib.logger") +local VerticalScrollLayout = import("./Layouts.VerticalScrollLayout") +local HorizontalScrollLayout = import("./Layouts.HorizontalScrollLayout") ---@class ScrollContainerOptions : UiElementOptions ---@field scrollOrientation ("vertical" | "horizontal" | nil) ---@class ScrollContainer : UiElement +---@operator call(ScrollContainerOptions): ScrollContainer ---@field scrollOrientation string "vertical" or "horizontal" ---@field scrollOffset number by how many pixels the children are translated in the orientation ---@field maxScrollOffset number maximum allowed value for scrollOffset the object will bound to - ----@class ScrollContainer ---@overload fun(options: ScrollContainerOptions): ScrollContainer +---@type ScrollContainer local ScrollContainer = class( ---@param self ScrollContainer function(self, options) - self.scrollOrientation = "vertical" or options.scrollOrientation + self.scrollOrientation = options.scrollOrientation or "vertical" + if self.scrollOrientation == "vertical" then + self.layout = VerticalScrollLayout + else + self.layout = HorizontalScrollLayout + end self.scrollOffset = 0 self.maxScrollOffset = 0 end, @@ -26,21 +35,35 @@ ScrollContainer.TYPE = "ScrollContainer" -- bounds the scrollOffset of the container to the desired value between 0 and -maxScrollOffset ---@param value number desired scrollOffset value function ScrollContainer:setScrollOffset(value) + logger.debug("Firing ScrollContainer.setScrollOffset " .. tostring(value)) + logger.debug("maxScrollOffset " .. tostring(self.maxScrollOffset)) self.scrollOffset = util.bound(-self.maxScrollOffset, value, 0) end -- update the scroll offset so the element with passed scroll offset + size remains visible -- usually we want a cursor type of object to call this on move to automatically advance the scrollContainer ----@param offset number the offset of the element to be kept visible ----@param size number the size of the element to be kept visible -function ScrollContainer:keepVisible(offset, size) - -- with increasing negative value we scroll further down/right +---@param cursor Cursor +function ScrollContainer:keepVisible(cursor) + local hoveredElement = cursor.focusToHover[self] + local offset -- the offset of the element to be kept visible + local size -- the size of the element to be kept visible local refSize - if self.scrollOrientation == "vertical" then - refSize = self.height - else + if self.scrollOrientation == "horizontal" then + offset = hoveredElement.x + size = hoveredElement.width refSize = self.width + else + offset = hoveredElement.y + size = hoveredElement.height + refSize = self.height end + logger.debug("Firing ScrollContiner.keepVisible") + -- weird implementation detail that with scrolling scrolloffset goes negative + -- childGap is added to guarantee some whitespace if the container is built around it + offset = -offset + self.childGap / 2 + size = size + self.childGap + -- with increasing negative value we scroll further down/right + if self.scrollOffset - refSize > offset - size then self:setScrollOffset(offset - size + refSize) elseif offset > self.scrollOffset then @@ -60,41 +83,42 @@ local function getTranslatedOffset(scrollContainer, x, y) end function ScrollContainer:onTouch(x, y) + logger.debug("Firing ScrollContainer.onTouch") + self.initialTouchX = x + self.initialTouchY = y + logger.debug("initialTouchY: " .. self.initialTouchY) + self.originalOffset = self.scrollOffset + logger.debug("originalOffset: " .. self.originalOffset) + local realTouchedElement = self:getTouchedChildElement(x, y) if realTouchedElement then self.touchedChild = realTouchedElement if self.touchedChild.onTouch then - x, y = getTranslatedOffset(self, x, y) self.touchedChild:onTouch(x, y) end - else - self.scrolling = true - self.initialTouchX = x - self.initialTouchY = y - self.originalOffset = self.scrollOffset end end function ScrollContainer:onDrag(x, y) - if not self.touchedChild then + logger.debug("Firing ScrollContainer.onDrag") + logger.debug("y: " .. y) + if not self.touchedChild or not self.touchedChild.onDrag then if self.scrollOrientation == "vertical" then self:setScrollOffset(self.originalOffset + (y - self.initialTouchY)) elseif self.scrollOrientation == "horizontal" then self:setScrollOffset(self.originalOffset + (x - self.initialTouchX)) end - elseif self.touchedChild.onDrag then - x, y = getTranslatedOffset(self, x, y) + else self.touchedChild:onDrag(x, y) end end function ScrollContainer:onRelease(x, y, duration) - if not self.touchedChild then - self:onDrag(x, y) - self.scrolling = false - else + logger.debug("Firing ScrollContainer.onRelease") + self:onDrag(x, y) + + if self.touchedChild then if self.touchedChild.onRelease then - x, y = getTranslatedOffset(self, x, y) self.touchedChild:onRelease(x, y) end self.touchedChild = nil @@ -126,6 +150,7 @@ function ScrollContainer:draw() else love.graphics.translate(self.x + self.scrollOffset, self.y) end + UiElement.drawSelf(self) -- and then just render everything -- by combining stencil + translate only the elements positioned within the stencil after the translate get drawn self:drawChildren() @@ -136,6 +161,25 @@ function ScrollContainer:draw() else love.graphics.setStencilTest() end + + if self.maxScrollOffset > 0 then + local fontSize = GraphicsUtil.getEffectiveFontSize("normal") + if self.scrollOrientation == "vertical" then + if self.scrollOffset < 0 then + GraphicsUtil.print("^", self.x + self.width / 2 - fontSize / 2, self.y - 20) + end + if math.abs(self.scrollOffset) < self.maxScrollOffset then + GraphicsUtil.print("v", self.x + self.width / 2 - fontSize / 2, self.y + self.height + 8) + end + else + if self.scrollOffset < 0 then + GraphicsUtil.print("<", self.x - 20, self.y + self.height / 2 - fontSize / 2) + end + if math.abs(self.scrollOffset) < self.maxScrollOffset then + GraphicsUtil.print(">", self.x + self.width + 8, self.y + self.height / 2 - fontSize / 2) + end + end + end end end @@ -149,7 +193,6 @@ end -- in order for the "offscreen" children to pass the inBounds check, the touch coordinates get corrected by the scroll offset before recursing down the ui function ScrollContainer:getTouchedChildElement(x, y) - x, y = getTranslatedOffset(self, x, y) local touchedElement for i = 1, #self.children do touchedElement = self.children[i]:getTouchedElement(x, y) @@ -159,14 +202,48 @@ function ScrollContainer:getTouchedChildElement(x, y) end end --- adds the uiElement and updates the maxScrollOffset if the addition extends the sensible scroll area -function ScrollContainer:addChild(uiElement) - UiElement.addChild(self, uiElement) - if self.scrollOrientation == "vertical" then - self.maxScrollOffset = math.max(self.maxScrollOffset, (uiElement.y + uiElement.height) - self.height) +function ScrollContainer:recalculateMaxScrollOffset() + local lastChild = self.children[#self.children] + if lastChild then + if self.scrollOrientation == "vertical" then + self.maxScrollOffset = math.max(0, lastChild.y + lastChild.height - self.height + self.padding) + else + self.maxScrollOffset = math.max(0, lastChild.x + lastChild.width - self.width + self.padding) + end else - self.maxScrollOffset = math.max(self.maxScrollOffset, (uiElement.x + uiElement.width) - self.width) + self.maxScrollOffset = 0 end end +function ScrollContainer:onResized() + self:recalculateMaxScrollOffset() +end + +function ScrollContainer:getPreferredWidth() + return self.minWidth +end + +---@param whoIsAsking table? +---@return integer x +---@return integer y +function ScrollContainer:getScreenPos(whoIsAsking) + local x, y = 0, 0 + if self.parent then + x, y = self.parent:getScreenPos(self) + end + + x = x + self.x + y = y + self.y + + -- for children the scrolloffset needs to be applied, otherwise not; + -- as whoIsAsking is recursively calling upwards, whoIsAsking will be a direct child even if the call originates from further down in the tree + if whoIsAsking and whoIsAsking.parent and whoIsAsking.parent == self then + local xOffset, yOffset = getTranslatedOffset(self, 0, 0) + x = x - xOffset + y = y - yOffset + end + + return x, y +end + return ScrollContainer \ No newline at end of file diff --git a/client/src/ui/ScrollText.lua b/client/src/ui/ScrollText.lua index edcc5fe6..f6e521a3 100644 --- a/client/src/ui/ScrollText.lua +++ b/client/src/ui/ScrollText.lua @@ -1,6 +1,6 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") -local Focusable = require(PATH .. ".Focusable") +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local Focusable = import("./Focusable") local class = require("common.lib.class") -- technically this value should be derived from the font size set for the label diff --git a/client/src/ui/Slider.lua b/client/src/ui/Slider.lua index ed01953f..04be3d46 100644 --- a/client/src/ui/Slider.lua +++ b/client/src/ui/Slider.lua @@ -1,8 +1,9 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UIElement = import("./UIElement") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") +local addCursorInteractionInterface = import("./CursorInteractable") local handleRadius = 7.5 local xPadding = 8 @@ -23,7 +24,8 @@ local sliderBarThickness = 6 ---@field height nil height is calculated internally based on min, max, tickLength and tickAmount -- A horizontal Slider element ----@class Slider: UiElement +---@class Slider: UiElement, CursorInteractable +---@operator call(SliderOptions): Slider ---@field min number minimum value ---@field max number maximum value ---@field tickLength integer how many pixels represent a value change of tickAmount @@ -37,6 +39,7 @@ local sliderBarThickness = 6 ---@field isFocusable boolean? only present if the individual object has been marked as focusable ---@field yieldFocus fun()? only present if the individual object has been marked as focusable, yields focus back to the parent element ---@overload fun(options: SliderOptions): Slider +---@type Slider local Slider = class( ---@param self Slider ---@param options SliderOptions @@ -51,14 +54,21 @@ local Slider = class( self.value = self:getBoundedValue(value) -- don't use set value as not everything is setup yet self.onlyChangeOnRelease = options.onlyChangeOnRelease or false - self.minText = GraphicsUtil.newText(love.graphics.getFont(), tostring(self.min)) - self.maxText = GraphicsUtil.newText(love.graphics.getFont(), tostring(self.max)) - self.valueText = GraphicsUtil.newText(love.graphics.getFont(), tostring(self.value)) + if options.hFill == nil then + self.hFill = true + end + + self.minText = GraphicsUtil.newText(GraphicsUtil.getGlobalFontWithSize("small"), tostring(self.min)) + self.maxText = GraphicsUtil.newText(GraphicsUtil.getGlobalFontWithSize("small"), tostring(self.max)) + self.valueText = GraphicsUtil.newText(GraphicsUtil.getGlobalFontWithSize("small"), tostring(self.value)) local valueTextWidth, valueTextHeight = self.valueText:getDimensions() local textWidth, textHeight = self.maxText:getDimensions() self.width = self.tickLength * self:tickCount() + xPadding + math.max(xPadding, textWidth / 2) - self.height = yPadding * 2 + handleRadius * 2 + valueTextHeight + textHeight + self.height = handleRadius * 2 + valueTextHeight + textHeight + self.minHeight = self.height + + addCursorInteractionInterface(self, self.receiveInputs) end, UIElement ) @@ -76,7 +86,10 @@ function Slider:onRelease(x, y) self:setValueFromPos(x, true) end -function Slider:receiveInputs(input) +---@param cursor Cursor +---@param dt number? +function Slider:receiveInputs(cursor, dt) + local input = cursor.keyInput if input:isPressedWithRepeat("Left") then self:setValue(self.value - self.tickAmount, true) elseif input:isPressedWithRepeat("Right") then @@ -148,27 +161,27 @@ function Slider:drawSelf() -- Slider bar GraphicsUtil.setColor(gray, gray, gray, alpha) - GraphicsUtil.drawRectangle("fill", self.x + xPadding, self.y + yPadding + valueTextHeight, barWidth, sliderBarThickness) + GraphicsUtil.drawRectangle("fill", xPadding, yPadding + valueTextHeight, barWidth, sliderBarThickness) -- Slider circle GraphicsUtil.setColor(unpack(SLIDER_CIRCLE_COLOR)) - love.graphics.circle("fill", currentX, self.y + yPadding + valueTextHeight + sliderBarThickness / 2, handleRadius, 32) + love.graphics.circle("fill", currentX, yPadding + valueTextHeight + sliderBarThickness / 2, handleRadius, 32) -- Value background GraphicsUtil.setColor(gray, gray, gray, alpha) - GraphicsUtil.drawRectangle("fill", currentX - valueTextWidth / 2 - valueBackgroundPaddingX, self.y + yPadding - valueBackgroundPaddingY, valueTextWidth + valueBackgroundPaddingX*2, valueTextHeight + valueBackgroundPaddingY*2) + GraphicsUtil.drawRectangle("fill", currentX - valueTextWidth / 2 - valueBackgroundPaddingX, yPadding - valueBackgroundPaddingY, valueTextWidth + valueBackgroundPaddingX*2, valueTextHeight + valueBackgroundPaddingY*2) -- Value centered at top GraphicsUtil.setColor(1, 1, 1, 1) - GraphicsUtil.draw(self.valueText, currentX - valueTextWidth / 2, self.y + yPadding, 0, 1, 1, 0, 0) + GraphicsUtil.draw(self.valueText, currentX - valueTextWidth / 2, yPadding, 0, 1, 1, 0, 0) GraphicsUtil.setColor(lightGray, lightGray, lightGray, 1) local textWidth, textHeight = self.minText:getDimensions() - GraphicsUtil.draw(self.minText, self.x + xPadding - textWidth / 2, self.y + yPadding + sliderBarThickness + textHeight, 0, 1, 1, 0, 0) + GraphicsUtil.draw(self.minText, xPadding - textWidth / 2, yPadding + sliderBarThickness + textHeight, 0, 1, 1, 0, 0) textWidth, textHeight = self.maxText:getDimensions() - GraphicsUtil.draw(self.maxText, self.x + xPadding + barWidth - textWidth / 2, self.y + yPadding + sliderBarThickness + textHeight, 0, 1, 1, 0, 0) + GraphicsUtil.draw(self.maxText, xPadding + barWidth - textWidth / 2, yPadding + sliderBarThickness + textHeight, 0, 1, 1, 0, 0) GraphicsUtil.setColor(1, 1, 1, 1) diff --git a/client/src/ui/StackPanel.lua b/client/src/ui/StackPanel.lua index e1b22697..5f9af82a 100644 --- a/client/src/ui/StackPanel.lua +++ b/client/src/ui/StackPanel.lua @@ -1,5 +1,5 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") local tableUtils = require("common.lib.tableUtils") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -51,7 +51,6 @@ end function StackPanel:addElement(uiElement) self:applyStackPanelSettings(uiElement) self:addChild(uiElement) - self:resize() end @@ -117,7 +116,7 @@ end function StackPanel:drawSelf() if DEBUG_ENABLED then GraphicsUtil.setColor(1, 0, 0, 0.7) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) end end diff --git a/client/src/ui/StageButton.lua b/client/src/ui/StageButton.lua new file mode 100644 index 00000000..fdf76cb0 --- /dev/null +++ b/client/src/ui/StageButton.lua @@ -0,0 +1,70 @@ +local import = require("common.lib.import") +local class = require("common.lib.class") +local Button = import("./Button") +local Label = import("./Label") +local Image = import("./ImageContainer") +local VerticalFlexLayout = import("./Layouts/VerticalFlexLayout") +local UiElement = import("./UIElement") + +---@class StageButtonOptions : ButtonOptions +---@field stage Stage + +---@class StageButton : Button +---@operator call(StageButtonOptions): StageButton +---@overload fun(options: StageButtonOptions): StageButton +---@field stage Stage +local StageButton = class( +---@param self StageButton +---@param options StageButtonOptions +function(self, options) + self.stage = options.stage + + self.childGap = 4 + self.debug = true + + self.hAlign = "center" + self.vAlign = "center" + + --self.backgroundColor = {0.7, 0.3, 0.2, 0.6} + + self.image = Image({ + image = self.stage.images.thumbnail, + hFill = true, + vFill = true, + hAlign = "center", + vAlign = "bottom", + --backgroundColor = {0.2, 0.7, 0.3, 0.6}, + forceIntegerScaling = true, + }) + + self.label = Label({ + text = self.stage.display_name, + hAlign = "center", + vAlign = "top", + vFill = true, + --backgroundColor = {0.2, 0.1, 0.7, 0.6} + }) + + self:addChild(self.image) + self:addChild(self.label) +end, +Button) + +StageButton.TYPE = "StageButton" +StageButton.layout = VerticalFlexLayout + +function StageButton:action(inputSource) + if not inputSource or not inputSource.player then + return + else + local player = inputSource.player + player:setStage(self.stage.id) + GAME.theme:playValidationSfx() + end +end + +function StageButton:drawSelf() + UiElement.drawSelf(self) +end + +return StageButton \ No newline at end of file diff --git a/client/src/ui/StageCarousel.lua b/client/src/ui/StageCarousel.lua index 299b174b..0468e548 100644 --- a/client/src/ui/StageCarousel.lua +++ b/client/src/ui/StageCarousel.lua @@ -1,10 +1,10 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local Carousel = require(PATH .. ".Carousel") -local StackPanel = require(PATH .. ".StackPanel") -local Label = require(PATH .. ".Label") -local ImageContainer = require(PATH .. ".ImageContainer") +local import = require("common.lib.import") +local Carousel = import("./Carousel") +local StackPanel = import("./StackPanel") +local Label = import("./Label") +local ImageContainer = import("./ImageContainer") local class = require("common.lib.class") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local Stage = require("client.src.mods.Stage") local StageCarousel = class(function(carousel, options) diff --git a/client/src/ui/Stepper.lua b/client/src/ui/Stepper.lua index a342bb64..4f279d0b 100644 --- a/client/src/ui/Stepper.lua +++ b/client/src/ui/Stepper.lua @@ -1,98 +1,105 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UIElement = require(PATH .. ".UIElement") -local TextButton = require(PATH .. ".TextButton") -local Label = require(PATH .. ".Label") +local import = require("common.lib.import") +local UIElement = import("./UIElement") +local TextButton = import("./TextButton") +local Label = import("./Label") local class = require("common.lib.class") local util = require("common.lib.util") local GraphicsUtil = require("client.src.graphics.graphics_util") +local HorizontalFlexLayout = import("./Layouts.HorizontalFlexLayout") +local addCursorInteractionInterface = import("./CursorInteractable") local NAV_BUTTON_WIDTH = 25 -local EMPTY_STEPPER_WIDTH = 160 - -local function setLabels(self, labels, values, selectedIndex) - self.selectedIndex = selectedIndex - self.values = values - self.labels = labels - - self:removeLabelChildren() - - if (#labels == 0) then - self.rightButton:setVisibility(false); - self.leftButton:setVisibility(false); - self.width = EMPTY_STEPPER_WIDTH - return - end - - for _, label in ipairs(labels) do - label.hAlign = "center" - label.vAlign = "center" - self.width = math.max(label.width + 10 + NAV_BUTTON_WIDTH * 2, self.width) - self.height = math.max(label.height + 4, self.height) - - self:addChild(label) - label:setVisibility(false) - end - - self.labels[self.selectedIndex]:setVisibility(true) - self.value = self.values[self.selectedIndex] - self.rightButton.x = self.width - NAV_BUTTON_WIDTH - self.rightButton:setVisibility(true); - self.leftButton:setVisibility(true); -end - -local function setState(self, i) - local new_index = util.bound(1, i, #self.labels) - if i ~= new_index then - return - end - - self.labels[self.selectedIndex]:setVisibility(false) - self.selectedIndex = new_index - self.value = self.values[new_index] - self.labels[new_index]:setVisibility(true) - self.onChange(self.value) -end -- UIElement representing a scrolling list of options +---@class Stepper : UiElement, CursorInteractable local Stepper = class( function(self, options) self.onChange = options.onChange or function() end self.selectedIndex = options.selectedIndex or 1 + self.childGap = options.childGap or 8 local navButtonWidth = 25 self.leftButton = TextButton({ width = navButtonWidth, vAlign = "center", - label = Label({text = "<", translate = false}), - onClick = function(selfElement, inputSource, holdTime) - setState(self, self.selectedIndex - 1) + hAlign = "center", + label = Label({text = "<"}), + action = function(selfElement, inputSource, holdTime) + self:setState(self.selectedIndex - 1) end }) + self.labelContainer = UIElement({ + vAlign = "center", + hAlign = "center", + }) self.rightButton = TextButton({ width = navButtonWidth, vAlign = "center", - label = Label({text = ">", translate = false}), - onClick = function(selfElement, inputSource, holdTime) - setState(self, self.selectedIndex + 1) + hAlign = "center", + label = Label({text = ">"}), + action = function(selfElement, inputSource, holdTime) + self:setState(self.selectedIndex + 1) end }) self:addChild(self.leftButton) + self:addChild(self.labelContainer) self:addChild(self.rightButton) + self:setLabels(options.labels, options.values, self.selectedIndex) self.color = {.5, .5, 1, .7} self.borderColor = {.7, .7, 1, .7} - setLabels(self, options.labels, options.values, self.selectedIndex) - - self.TYPE = "Stepper" + addCursorInteractionInterface(self, self.receiveInputs) end, UIElement ) -Stepper.setLabels = setLabels -Stepper.setState = setState +Stepper.TYPE = "Stepper" +Stepper.layout = HorizontalFlexLayout + +function Stepper:setLabels(labels, values, selectedIndex) + self.selectedIndex = selectedIndex + self.values = values + self.labels = labels -function Stepper:receiveInputs(input) + for i = #self.labelContainer.children, 1, -1 do + self.labelContainer.children[i]:detach() + end + + local minWidth = 0 + for _, label in ipairs(labels) do + label.hAlign = "center" + label.vAlign = "center" + label:setVisibility(false) + minWidth = math.max(minWidth, label:getPreferredWidth()) + + self.labelContainer:addChild(label) + end + self.labelContainer.minWidth = minWidth + self.labelContainer.width = minWidth + self.labelContainer.maxWidth = minWidth + + self.labels[self.selectedIndex]:setVisibility(true) + self.value = self.values[self.selectedIndex] +end + +function Stepper.setState(self, i) + local new_index = util.bound(1, i, #self.labels) + if i ~= new_index then + return + end + + self.labels[self.selectedIndex]:setVisibility(false) + self.selectedIndex = new_index + self.value = self.values[new_index] + self.labels[new_index]:setVisibility(true) + self.onChange(self.value) +end + +---@param cursor Cursor +---@param dt number? +function Stepper:receiveInputs(cursor, dt) + local input = cursor.keyInput if input:isPressedWithRepeat("Left") then self:setState(self.selectedIndex - 1) elseif input:isPressedWithRepeat("Right") then @@ -112,18 +119,11 @@ end function Stepper:drawSelf() if config.debug_mode then GraphicsUtil.setColor(self.color) - GraphicsUtil.drawRectangle("fill", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("fill", 0, 0, self.width, self.height) GraphicsUtil.setColor(self.borderColor) - GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height) + GraphicsUtil.drawRectangle("line", 0, 0, self.width, self.height) GraphicsUtil.setColor(1, 1, 1, 1) end end --- Remove all attached labels, preserving the navigation buttons -function Stepper:removeLabelChildren() - for i = #self.children, 3, -1 do - self.children[i]:detach() - end -end - return Stepper \ No newline at end of file diff --git a/client/src/ui/TextButton.lua b/client/src/ui/TextButton.lua index f4c7bb4c..8bb591e4 100644 --- a/client/src/ui/TextButton.lua +++ b/client/src/ui/TextButton.lua @@ -1,28 +1,22 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local Button = require(PATH .. ".Button") +local import = require("common.lib.import") +local Button = import("./Button") local class = require("common.lib.class") -local TEXT_WIDTH_PADDING = 6 -local TEXT_HEIGHT_PADDING = 6 - ---@class TextButtonOptions : ButtonOptions ---@field label Label -- A TextButton is a button that sets itself apart from Button by automatically scaling its own size to fit the text inside -- This is different from the regular button that scales its content to fit inside itself ---@class TextButton : Button +---@operator call(TextButtonOptions): TextButton ---@field label Label local TextButton = class(function(self, options) self.label = options.label self.label.hAlign = "center" self.label.vAlign = "center" + self.hFill = true + self.padding = options.padding or 8 self:addChild(self.label) - - -- stretch to fit text - local width, height = self.label:getEffectiveDimensions() - self.width = math.max(width + TEXT_WIDTH_PADDING, self.width) - self.height = math.max(height + TEXT_HEIGHT_PADDING, self.height) - end, Button) TextButton.TYPE = "TextButton" diff --git a/client/src/ui/UIElement.lua b/client/src/ui/UIElement.lua index 927e84c5..69194308 100644 --- a/client/src/ui/UIElement.lua +++ b/client/src/ui/UIElement.lua @@ -1,11 +1,17 @@ local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") +---@alias color { [1]: number, [2]: number, [3]: number, [4]: number } + ---@class UiElement ---@field x number relative x offset to the parent element (canvas if no parent) ---@field y number relative y offset to the parent element (canvas if no parent) +---@field minWidth number the minimum width of the element, independent of children and layout ---@field width number width of the element for the sake of resizing children and touch hitboxes +---@field maxWidth number +---@field minHeight number the minimum height of the element, independent of children and layout ---@field height number height of the element for the sake of resizing children and touch hitboxes +---@field maxHeight number ---@field hAlign ("left" | "center" | "right") determines the horizontal alignment relative to the parent ---@field vAlign ("top" | "center" | "bottom") determines the vertical alignment relative to the parent ---@field hFill boolean if the element's width should fill out the entire parent's width @@ -14,24 +20,38 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") ---@field isEnabled boolean if the element is currently eligible for touch interaction ---@field parent UiElement the element's parent it is getting position relative to via its properties ---@field children UiElement[] the element's children that get positioned relative to it +---@field backgroundColor color +---@field padding number +---@field childGap number ---@field id integer unique identifier of the element +---@field layout Layout ---@field onTouch function? touch callback for when the element is being touched ---@field onDrag function? touch callback for when the mouse touching the element is dragged across the screen ---@field onRelease function? touch callback for when the mouse touching the element is released ---@field onHold function? touch callback for when a touch is held on the element for a longer duration +---@field onResized function? layout callback for when a UIElement and all of its children have been resized and positioned +---@field onDetach function? callback for when a UIElement detaches from its parent (aka the parent loses its child) ---@field [any] any ---@class UiElementOptions ---@field x number? relative x offset to the parent element (canvas if no parent) ---@field y number? relative y offset to the parent element (canvas if no parent) +---@field minWidth number? ---@field width number? width of the element for the sake of resizing children and touch hitboxes +---@field maxWidth number? +---@field minHeight number? ---@field height number? height of the element for the sake of resizing children and touch hitboxes +---@field maxHeight number? ---@field hAlign ("left" | "center" | "right")? determines the horizontal alignment relative to the parent ---@field vAlign ("top" | "center" | "bottom")? determines the vertical alignment relative to the parent ---@field hFill boolean? if the element's width should fill out the entire parent's width ---@field vFill boolean? if the element's height should fill out the entire parent's height ---@field isVisible boolean? if the element is currently visible for rendering (also disables touch interaction) ---@field isEnabled boolean? if the element is currently eligible for touch interaction +---@field layout Layout? +---@field backgroundColor color? +---@field padding number? +---@field childGap number? local uniqueId = 0 @@ -39,6 +59,7 @@ local uniqueId = 0 -- takes in a options table for setting default values -- all valid base options are defined in the constructor ---@class UiElement +---@operator call(UiElementOptions): UiElement ---@overload fun(options: UiElementOptions): UiElement local UIElement = class( ---@param self UiElement @@ -47,9 +68,23 @@ local UIElement = class( self.x = options.x or 0 self.y = options.y or 0 + self.backgroundColor = options.backgroundColor or {1, 1, 1, 0} + self.layout = options.layout + self.padding = options.padding or 0 + self.childGap = options.childGap or 0 + self.wraps = options.wraps or false + -- ui dimensions + self.minWidth = options.minWidth or options.width or 0 self.width = options.width or 0 + self.maxWidth = options.maxWidth or options.width or math.huge + self.minHeight = options.minHeight or options.height or 0 self.height = options.height or 0 + self.maxHeight = options.maxHeight or options.height or math.huge + + if self.padding * 2 > self.maxHeight or self.padding * 2 > self.maxWidth then + error("The size of the padding cannot exceed the UIElements maximum dimensions") + end -- how to align the element inside the parent element self.hAlign = options.hAlign or "left" @@ -78,34 +113,50 @@ local UIElement = class( UIElement.TYPE = "UIElement" -function UIElement:addChild(uiElement) - if uiElement.parent ~= self then - self.children[#self.children + 1] = uiElement - uiElement.parent = self - uiElement:resize() - end -end - -function UIElement:resize() - if self.hFill and self.parent then - self.width = self.parent.width - end - - if self.vFill and self.parent then - self.height = self.parent.height - end +---@param self UiElement +local function onChildrenChanged(self) + if self.parent then + -- resizing segments does not make much sense if the segments have to be resized later anyway + -- so bubble just straight up + onChildrenChanged(self.parent) + else + -- and then only resize from the root element + local oWidth = self.width + local oHeight = self.height - self:onResize() + -- if DEBUG_ENABLED then + -- self.layout.resize(self) + -- end - if self.hFill or self.vFill then - for _, child in ipairs(self.children) do - child:resize() + if self.controlsWindow then + --if not DEBUG_ENABLED then + -- we want to keep window size if possible + self.layout.resize(self, self.width, self.height) + --end + -- but if it changed (due to the min size increasing with the added element), resize the window so everything fits + if oWidth ~= self.width or oHeight ~= self.height then + GraphicsUtil.updateMode(self.width, self.height, {}) + end end end end --- overridable function to define extra behaviour to the element itself on resize -function UIElement:onResize() +---@param uiElement UiElement +---@param index integer? +function UIElement:addChild(uiElement, index) + if uiElement.parent then + if uiElement.parent ~= self then + error("Tried to give a uiElement more than one parent") + end + else + if index then + table.insert(self.children, index, uiElement) + else + self.children[#self.children + 1] = uiElement + end + uiElement.parent = self + onChildrenChanged(self) + end end function UIElement:detach() @@ -113,7 +164,10 @@ function UIElement:detach() for i, child in ipairs(self.parent.children) do if child.id == self.id then table.remove(self.parent.children, i) - self:onDetach() + if self.onDetach then + self:onDetach() + end + onChildrenChanged(self.parent) self.parent = nil break end @@ -122,18 +176,16 @@ function UIElement:detach() end end -function UIElement:onDetach() -end - -function UIElement:getScreenPos() +---@param whoIsAsking table? +---@return integer x +---@return integer y +function UIElement:getScreenPos(whoIsAsking) local x, y = 0, 0 - local xOffset, yOffset = 0, 0 if self.parent then - x, y = self.parent:getScreenPos() - xOffset, yOffset = GraphicsUtil.getAlignmentOffset(self.parent, self) + x, y = self.parent:getScreenPos(self) end - return x + self.x + xOffset, y + self.y + yOffset + return x + self.x, y + self.y end -- passes a retranslation request through the tree to reach all Labels @@ -145,12 +197,12 @@ end function UIElement:draw() if self.isVisible then + love.graphics.push("transform") + love.graphics.translate(self.x, self.y) self:drawSelf() -- if DEBUG_ENABLED then -- GraphicsUtil.drawRectangle("line", self.x, self.y, self.width, self.height, 1, 1, 1, 0.5) -- end - love.graphics.push("transform") - love.graphics.translate(self.x, self.y) self:drawChildren() love.graphics.pop() end @@ -159,14 +211,22 @@ end -- UiElements containing children draw the children-independent part in this function -- implementation is optional so layout elements don't have to function UIElement:drawSelf() + love.graphics.setColor(self.backgroundColor) + love.graphics.rectangle("fill", 0, 0, self.width, self.height) + love.graphics.setColor(1, 1, 1, 1) + --if self.padding > 0 then + -- love.graphics.rectangle("line", self.padding, self.padding, 1, self.height - self.padding * 2) + -- love.graphics.rectangle("line", self.padding, elf.padding, self.width - self.padding * 2, 1) + -- love.graphics.rectangle("line", self.width - self.padding, self.padding, 1, self.height - self.padding * 2) + -- love.graphics.rectangle("line", self.padding, self.height - self.padding, self.width - self.padding *2, 1) + --end + --love.graphics.print(self.width .. ", " .. self.height, 5, 5) end function UIElement:drawChildren() for _, uiElement in ipairs(self.children) do if uiElement.isVisible then - GraphicsUtil.applyAlignment(self, uiElement) uiElement:draw() - GraphicsUtil.resetAlignment() end end end @@ -175,18 +235,20 @@ end -- if you want to stop drawing an element, e.g. due to changing a subscreen, -- the more opportune method is to simply remove it from the ui tree via detach() function UIElement:setVisibility(isVisible) - self.isVisible = isVisible - self:onVisibilityChanged() + if self.isVisible ~= isVisible then + self.isVisible = isVisible + self:onVisibilityChanged() + end end function UIElement:onVisibilityChanged() + if self.parent then + onChildrenChanged(self.parent) + end end function UIElement:setEnabled(isEnabled) self.isEnabled = isEnabled - for _, uiElement in ipairs(self.children) do - uiElement:setEnabled(isEnabled) - end end function UIElement:inBounds(x, y) @@ -217,4 +279,26 @@ function UIElement:getTouchedElement(x, y) end end +--- defaults to minWidth; elements that can wrap have to override this getter with a function returning their preferred width +---@return integer # the width the UIElement would prefer to take up if any space is available +function UIElement:getPreferredWidth() + return self.minWidth +end + +--- defaults to minHeight; elements that can wrap have to override this getter with a function returning their preferred height +---@return integer # the height the UIElement would prefer to take up if any space is available +function UIElement:getPreferredHeight() + return self.minHeight +end + +--- Use this function to change the layout of an UIElement after its creation
+--- will trigger a layout update of the UI tree +---@param layout Layout +function UIElement:setLayout(layout) + if self.layout ~= layout then + self.layout = layout + onChildrenChanged(self.parent) + end +end + return UIElement \ No newline at end of file diff --git a/client/src/ui/UniSizedContainer.lua b/client/src/ui/UniSizedContainer.lua new file mode 100644 index 00000000..2d98d3ef --- /dev/null +++ b/client/src/ui/UniSizedContainer.lua @@ -0,0 +1,253 @@ +local import = require("common.lib.import") +local UiElement = import("./UIElement") +local class = require("common.lib.class") +local HorizontalWrapLayout = import("./Layouts.HorizontalWrapLayout") +local Focusable = import("./Focusable") +local consts = require("client.src.consts") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local input = require("client.src.inputManager") +local addCursorNavigationInterface = import("./CursorNavigable") +local tableUtils = require("common.lib.tableUtils") + +---@class UniSizedContainerOptions : UiElementOptions, CursorNavigableOptions +---@field childrenWidth integer +---@field childrenHeight integer + +-- a layout and navigation focused container that arranges children horizontally
+-- when not enough width available it will automatically wrap around further elements to the next row
+-- due to all children being forced to the same size, it can automatically adjust navigation to offer grid-like navigation +-- but it's not a real grid! +---@class UniSizedContainer : UiElement, CursorNavigable +---@operator call(UniSizedContainerOptions): UniSizedContainer +---@field childrenWidth integer +---@field childrenHeight integer +---@field rows UiElement[][] +---@field childToRow table +---@overload fun(options: UniSizedContainerOptions): UniSizedContainer +local UniSizedContainer = class( +function(self, options) + assert(options.childrenHeight and options.childrenWidth) + + self.childrenWidth = options.childrenWidth + self.childrenHeight = options.childrenHeight + self.minHeight = self.padding * 2 + self.childrenHeight + + addCursorNavigationInterface(self, self.receiveInputs) + self.onFocus = options.onFocus + self.onYield = options.onYield +end, +UiElement) + +UniSizedContainer.TYPE = "UniSizedContainer" +UniSizedContainer.layout = HorizontalWrapLayout + +---@param uiElement UiElement +---@param index integer? +function UniSizedContainer:addChild(uiElement, index) + uiElement.minWidth = self.childrenWidth + uiElement.maxWidth = self.childrenWidth + uiElement.minHeight = self.childrenHeight + uiElement.maxHeight = math.min(self.childrenHeight, uiElement.maxHeight) + + UiElement.addChild(self, uiElement, index) +end + +---@param cursor Cursor +---@return boolean? # if the movement was successful +function UniSizedContainer:moveToPreviousRow(cursor) + if not self.rows or #self.rows == 1 then + return false + end + + local selectedChild = cursor.focusToHover[self] + local currentRow = self.childToRow[selectedChild] + local currentCol = tableUtils.indexOf(self.rows[currentRow], selectedChild) + local nextRow = wrap(1, currentRow - 1, #self.rows) + if not self.rows[nextRow][currentCol] then + -- if the wrapped elements don't extend into the selected columns, redo the wrap without considering that row + nextRow = wrap(1, currentRow - 1, #self.rows - 1) + end + if self.rows[nextRow][currentCol] then + cursor:updateHover(self, self.rows[nextRow][currentCol]) + return true + else + return false + end +end + +---@param cursor Cursor +---@return boolean? # if the movement was successful +function UniSizedContainer:moveToNextRow(cursor) + if not self.rows or #self.rows == 1 then + return false + end + + local selectedChild = cursor.focusToHover[self] + local currentRow = self.childToRow[selectedChild] + local currentCol = tableUtils.indexOf(self.rows[currentRow], selectedChild) + local nextRow = wrap(1, currentRow + 1, #self.rows) + if not self.rows[nextRow][currentCol] then + -- if the wrapped elements don't extend into the selected columns, redo the wrap without considering that row + nextRow = wrap(1, currentRow + 1, #self.rows - 1) + end + if self.rows[nextRow][currentCol] then + cursor:updateHover(self, self.rows[nextRow][currentCol]) + return true + else + return false + end +end + +---@param cursor Cursor +---@return boolean? # if the movement was successful +function UniSizedContainer:moveToPrevious(cursor) + local selectedChild = cursor.focusToHover[self] + local currentRow = self.childToRow[selectedChild] + + if not self.rows or not self.rows[currentRow] or #self.rows[currentRow] == 1 then + return false + end + + local currentCol = tableUtils.indexOf(self.rows[currentRow], selectedChild) + local nextCol = wrap(1, currentCol - 1, #self.rows[currentRow]) + cursor:updateHover(self, self.rows[currentRow][nextCol]) + return true +end + +---@param cursor Cursor +---@return boolean? # if the movement was successful +function UniSizedContainer:moveToNext(cursor) + local selectedChild = cursor.focusToHover[self] + local currentRow = self.childToRow[selectedChild] + + if not self.rows or not self.rows[currentRow] or #self.rows[currentRow] == 1 then + return false + end + + local currentCol = tableUtils.indexOf(self.rows[currentRow], selectedChild) + local nextCol = wrap(1, currentCol + 1, #self.rows[currentRow]) + cursor:updateHover(self, self.rows[currentRow][nextCol]) + return true +end + +---@param cursor Cursor +---@param dt number? +function UniSizedContainer:receiveInputs(cursor, dt) + local inputs = cursor.keyInput + if inputs.isDown.Swap2 then + GAME.theme:playCancelSfx() + cursor:releaseFocus(self) + elseif inputs:isPressedWithRepeat("Left", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + if self:moveToPrevious(cursor) then + GAME.theme:playMoveSfx() + else + GAME.theme:playCancelSfx() + end + elseif inputs:isPressedWithRepeat("Right", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + if self:moveToNext(cursor) then + GAME.theme:playMoveSfx() + else + GAME.theme:playCancelSfx() + end + elseif inputs:isPressedWithRepeat("Up", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + if self:moveToPreviousRow(cursor) then + GAME.theme:playMoveSfx() + else + GAME.theme:playCancelSfx() + end + elseif inputs:isPressedWithRepeat("Down", consts.KEY_DELAY, consts.KEY_REPEAT_PERIOD) then + if self:moveToNextRow(cursor) then + GAME.theme:playMoveSfx() + else + GAME.theme:playCancelSfx() + end + else + local selectedChild = cursor.focusToHover[self] + if selectedChild.isNavigable and (inputs.isDown.Swap1 or inputs.isDown.Start) then + GAME.theme:playValidationSfx() + cursor:deepenFocus(selectedChild) + elseif selectedChild.receiveInputs then + selectedChild:receiveInputs(cursor, dt) + else + GAME.theme:playCancelSfx() + end + end +end + +function UniSizedContainer:onResized() + if #self.children == 0 then + return + end + + local rowIndex = 1 + local rows = {{}} + local childToRow = {} + local yOffset = self.children[1].y + for i, child in ipairs(self.children) do + if child.y > yOffset then + yOffset = child.y + rowIndex = rowIndex + 1 + rows[rowIndex] = {} + end + table.insert(rows[rowIndex], child) + childToRow[child] = rowIndex + end + + self.rows = rows + self.childToRow = childToRow +end + +function UniSizedContainer:drawSelf() + UiElement.drawSelf(self) + if self:isHovered() then + if input.isDown["Left"] or input.isPressed["Left"] then + GraphicsUtil.printf("Left", 0, 0) + elseif input.isDown["Right"] or input.isPressed["Right"] then + GraphicsUtil.printf("Right", 0, 0) + elseif input.isDown["Up"] or input.isPressed["Up"] then + GraphicsUtil.printf("Up", 0, 0) + elseif input.isDown["Down"] or input.isPressed["Down"] then + GraphicsUtil.printf("Down", 0, 0) + end + end +end + +function UniSizedContainer:getMinHeight() + local h = self.padding * 2 + local maxHeight = 0 + self.tempRows = {} + + local childrenInCurrentRow = 0 + local rowCount = 1 + local width = self.padding + for i, child in ipairs(self.children) do + if child.isVisible then + maxHeight = math.max(maxHeight, child.minHeight, child.layout.getMinHeight(child)) + if width + child.width + self.padding + childrenInCurrentRow * self.childGap > self.width then + self.tempRows[rowCount] = maxHeight + rowCount = rowCount + 1 + width = self.padding + child.width + childrenInCurrentRow = 1 + maxHeight = child.newHeight + else + childrenInCurrentRow = childrenInCurrentRow + 1 + width = width + child.width + end + child.tempRow = rowCount + end + end + + self.tempRows[rowCount] = maxHeight + h = h + (#self.tempRows - 1) * self.childGap + for i = 1, #self.tempRows do + h = h + self.tempRows[i] + end + + return math.max(h, self.minHeight) +end + +function UniSizedContainer:getPreferredWidth() + return self.padding * 2 + (self.childrenWidth + self.childGap) * #self.children - self.childGap +end + +return UniSizedContainer \ No newline at end of file diff --git a/client/src/ui/ValueLabel.lua b/client/src/ui/ValueLabel.lua index edb00dbc..e9501b08 100644 --- a/client/src/ui/ValueLabel.lua +++ b/client/src/ui/ValueLabel.lua @@ -1,5 +1,5 @@ -local PATH = (...):gsub('%.[^%.]+$', '') -local UiElement = require(PATH .. ".UIElement") +local import = require("common.lib.import") +local UiElement = import("./UIElement") local class = require("common.lib.class") local GraphicsUtil = require("client.src.graphics.graphics_util") @@ -11,7 +11,7 @@ end, UiElement) function ValueLabel:drawSelf() - love.graphics.print(self.valueFunction(), self.x, self.y) + love.graphics.print(self.valueFunction(), 0, 0) end return ValueLabel \ No newline at end of file diff --git a/client/src/ui/VerticalMenu.lua b/client/src/ui/VerticalMenu.lua new file mode 100644 index 00000000..15c6071e --- /dev/null +++ b/client/src/ui/VerticalMenu.lua @@ -0,0 +1,133 @@ +local import = require("common.lib.import") +local ScrollContainer = import("./ScrollContainer") +local class = require("common.lib.class") +local util = require("common.lib.util") +local GraphicsUtil = require("client.src.graphics.graphics_util") +local VerticalFlexLayout = import("./Layouts.VerticalFlexLayout") +local VerticalScrollLayout = import("./Layouts.VerticalScrollLayout") +local tableUtils = require("common.lib.tableUtils") +local addCursorNavigationInterface = import("./CursorNavigable") + +---@class VerticalMenu : ScrollContainer, Focusable +---@operator call(ScrollContainerOptions): VerticalMenu +---@overload fun(options: ScrollContainerOptions): VerticalMenu +---@type VerticalMenu +local VerticalMenu = class( +function(self, options) + self.selectedIndex = nil + self.scrollOrientation = "vertical" + self.layout = VerticalScrollLayout + addCursorNavigationInterface(self, self.receiveInputs) + + self.onYield = options.onYield + self.onFocus = options.onFocus +end, +ScrollContainer) + +VerticalMenu.TYPE = "VerticalMenu" + +---@param cursor Cursor +function VerticalMenu:selectPrevious(cursor) + local hoveredIndex = tableUtils.indexOf(self.children, cursor.focusToHover[self]) + for i = hoveredIndex - 1, hoveredIndex - #self.children, -1 do + local index = wrap(1, i, #self.children) + local child = self.children[index] + if child.receiveInputs and child.isEnabled and child.isVisible then + cursor:updateHover(self, self.children[index]) + break + end + end + self:keepVisible(cursor) + GAME.theme:playMoveSfx() +end + +---@param cursor Cursor +function VerticalMenu:selectNext(cursor) + local hoveredIndex = tableUtils.indexOf(self.children, cursor.focusToHover[self]) + for i = hoveredIndex + 1, hoveredIndex + #self.children do + local index = wrap(1, i, #self.children) + local child = self.children[index] + if child.receiveInputs and child.isEnabled and child.isVisible then + cursor:updateHover(self, self.children[index]) + break + end + end + self:keepVisible(cursor) + GAME.theme:playMoveSfx() +end + +---@param cursor Cursor +function VerticalMenu:selectLast(cursor) + cursor:updateHover(self, self:getLast()) + self:keepVisible(cursor) +end + +---@return CursorInteractable | UiElement +function VerticalMenu:getLast() + for i = #self.children, 1, -1 do + local child = self.children[i] + if child.receiveInputs and child.isEnabled and child.isVisible then + return child + end + end + + return self.children[1] +end + +---@param cursor Cursor +---@param dt number? +function VerticalMenu:receiveInputs(cursor, dt) + if not self.isEnabled then + return + end + + local inputs = cursor.keyInput + local selectedElement = cursor.focusToHover[self] + + if inputs.isDown["MenuEsc"] then + if self:getLast() ~= selectedElement then + self:selectLast(cursor) + GAME.theme:playCancelSfx() + else + selectedElement:receiveInputs(cursor, dt) + end + elseif inputs:isPressedWithRepeat("MenuUp") then + self:selectPrevious(cursor) + elseif inputs:isPressedWithRepeat("MenuDown") then + self:selectNext(cursor) + else + if inputs.isDown["MenuSelect"] and selectedElement.isNavigable then + self:deepenFocus(selectedElement) + else + selectedElement:receiveInputs(cursor, dt) + end + end +end + +function VerticalMenu:setSelectedIndex(index) + self.selectedIndex = util.bound(1, index, #self.children) +end + +function VerticalMenu:drawChildren() + for i, uiElement in ipairs(self.children) do + if uiElement.isVisible then + if i == self.selectedIndex then + GraphicsUtil.setColor(0.6, 0.6, 1, 0.5) + love.graphics.rectangle("fill", uiElement.x, uiElement.y, uiElement.width, uiElement.height) + love.graphics.rectangle("line", uiElement.x, uiElement.y, uiElement.width, uiElement.height) + end + uiElement:draw() + end + end +end + +function VerticalMenu:onResized() + ScrollContainer.onResized(self) + for _, child in ipairs(self.children) do + for cursor, _ in pairs(child.hoveringCursors) do + self:keepVisible(cursor) + end + end +end + +return VerticalMenu \ No newline at end of file diff --git a/client/src/ui/init.lua b/client/src/ui/init.lua index 6cf18ae4..fa8eb4ae 100644 --- a/client/src/ui/init.lua +++ b/client/src/ui/init.lua @@ -1,52 +1,101 @@ -local PATH = (...):gsub('%.init$', '') +local import = require("common.lib.import") + +--[[ +tag each with +---@source relative path +that way "Go to source" on an import of ui elsewhere will lead to the respective source instead of this one +the "./" is assumed given for relative paths but it's still a path so adding the extension is necessary +when addressing files in subdirectories (layouts) use forward slashes as the path separator +https://luals.github.io/wiki/annotations/#source +]] local ui = { - ---@see BoolSelector - ---@type fun(options: BoolSelectorOptions): BoolSelector - BoolSelector = require(PATH .. ".BoolSelector"), - ---@see Button - ---@type fun(options: ButtonOptions): Button - Button = require(PATH .. ".Button"), - ButtonGroup = require(PATH .. ".ButtonGroup"), - Carousel = require(PATH .. ".Carousel"), - Focusable = require(PATH .. ".Focusable"), - FocusDirector = require(PATH .. ".FocusDirector"), - Grid = require(PATH .. ".Grid"), - GridCursor = require(PATH .. ".GridCursor"), - ImageContainer = require(PATH .. ".ImageContainer"), - InputField = require(PATH .. ".InputField"), - ---@see Label - ---@type fun(options: LabelOptions): Label - Label = require(PATH .. ".Label"), - Leaderboard = require(PATH .. ".Leaderboard"), - ---@see LevelSlider - ---@type fun(options: SliderOptions): LevelSlider - LevelSlider = require(PATH .. ".LevelSlider"), - Menu = require(PATH .. ".Menu"), - MenuItem = require(PATH .. ".MenuItem"), - MultiPlayerSelectionWrapper = require(PATH .. ".MultiPlayerSelectionWrapper"), - PagedUniGrid = require(PATH .. ".PagedUniGrid"), - PanelCarousel = require(PATH .. ".PanelCarousel"), - ---@see PixelFontLabel - ---@type fun(options: PixelFontLabelOptions): PixelFontLabel - PixelFontLabel = require(PATH .. ".PixelFontLabel"), - ---@see ScrollContainer - ---@type fun(options: ScrollContainerOptions): ScrollContainer - ScrollContainer = require(PATH .. ".ScrollContainer"), - ScrollText = require(PATH .. ".ScrollText"), - ---@see Slider - ---@type fun(options: SliderOptions): Slider - Slider = require(PATH .. ".Slider"), - StackPanel = require(PATH .. ".StackPanel"), - StageCarousel = require(PATH .. ".StageCarousel"), - Stepper = require(PATH .. ".Stepper"), - ---@see TextButton - ---@type fun(options: TextButtonOptions): TextButton - TextButton = require(PATH .. ".TextButton"), - ---@see UiElement - ---@type fun(options:UiElementOptions): UiElement - UiElement = require(PATH .. ".UIElement"), - ValueLabel = require(PATH .. ".ValueLabel"), + ---@source BoolSelector.lua + BoolSelector = import("./BoolSelector"), + ---@source Button.lua + Button = import("./Button"), + ---@source ButtonGroup.lua + ButtonGroup = import("./ButtonGroup"), + Carousel = import("./Carousel"), + ---@source CharacterButton.lua + ---@type CharacterButton + CharacterButton = import("./CharacterButton"), + ---@source Cursor.lua + ---@type Cursor + Cursor = import("./Cursor"), + ---@source CursorInteractable + CursorInteractable = import("./CursorInteractable"), + ---@source CursorNavigable.lua + CursorNavigable = import("./CursorNavigable"), + Focusable = import("./Focusable"), + FocusDirector = import("./FocusDirector"), + Grid = import("./Grid"), + GridCursor = import("./GridCursor"), + ---@source ImageContainer.lua + ---@type Image + ImageContainer = import("./ImageContainer"), + ---@source ImageCursor.lua + ImageCursor = import("./ImageCursor"), + ---@source InputField.lua + InputField = import("./InputField"), + ---@source Label.lua + ---@type Label + Label = import("./Label"), + Layouts = { + ---@source Layouts/AdaptiveFlexLayout.lua + AdaptiveFlexLayout = import("./Layouts.AdaptiveFlexLayout"), + ---@source Layouts/HorizontalFlexLayout.lua + HorizontalFlexLayout = import("./Layouts.HorizontalFlexLayout"), + ---@source Layouts/HorizontalWrapLayout.lua + HorizontalWrapLayout = import("./Layouts.HorizontalWrapLayout"), + ---@source Layouts/VerticalFlexLayout.lua + VerticalFlexLayout = import("./Layouts.VerticalFlexLayout"), + }, + Leaderboard = import("./Leaderboard"), + ---@source LevelSlider.lua + ---@type LevelSlider + LevelSlider = import("./LevelSlider"), + ---@source MenuItem.lua + ---@type MenuItem + MenuItem = import("./MenuItem"), + MultiPlayerSelectionWrapper = import("./MultiPlayerSelectionWrapper"), + PagedUniGrid = import("./PagedUniGrid"), + PanelCarousel = import("./PanelCarousel"), + ---@source PanelSetButton.lua + PanelSetButton = import("./PanelSetButton"), + ---@source PixelFontLabel.lua + ---@type PixelFontLabel + PixelFontLabel = import("./PixelFontLabel"), + ---@source ScrollContainer.lua + ---@type ScrollContainer + ScrollContainer = import("./ScrollContainer"), + ScrollText = import("./ScrollText"), + ---@source Slider.lua + ---@type Slider + Slider = import("./Slider"), + StackPanel = import("./StackPanel"), + ---@source StageButton.lua + ---@type StageButton + StageButton = import("./StageButton"), + StageCarousel = import("./StageCarousel"), + Stepper = import("./Stepper"), + ---@source TextButton.lua + ---@type TextButton + TextButton = import("./TextButton"), + ---@source UiElement.lua + ---@type UiElement + ---@class UiElement + UiElement = import("./UIElement"), + ---@source UniSizedContainer.lua + ---@type UniSizedContainer + UniSizedContainer = import("./UniSizedContainer"), + ValueLabel = import("./ValueLabel"), + ---@source VerticalMenu.lua + ---@type VerticalMenu + VerticalMenu = import("./VerticalMenu"), } +-- the default layout +ui.UiElement.layout = ui.Layouts.VerticalFlexLayout + return ui \ No newline at end of file diff --git a/client/src/ui/touchHandler.lua b/client/src/ui/touchHandler.lua index e297c264..f9e8a794 100644 --- a/client/src/ui/touchHandler.lua +++ b/client/src/ui/touchHandler.lua @@ -1,6 +1,5 @@ local input = require("client.src.inputManager") -- handles all touch interactions --- all elements that implement touch interactions must register themselves with the touch handler on construction local touchHandler = { ---@type UiElement? @@ -17,6 +16,9 @@ function touchHandler:touch(x, y) if not self.touchedElement then self.touchedElement = activeScene.uiRoot:getTouchedElement(x, y) if self.touchedElement and self.touchedElement.onTouch then + if self.touchedElement.hoveringCursors then + self.touchedElement.hoveringCursors["mouse"] = true + end self.touchedElement:onTouch(x, y) end end @@ -35,6 +37,9 @@ end function touchHandler:release(x, y) if self.touchedElement then if self.touchedElement.onRelease then + if self.touchedElement.hoveringCursors then + self.touchedElement.hoveringCursors["mouse"] = nil + end self.touchedElement:onRelease(x, y, self.holdTimer) end self.touchedElement = nil diff --git a/client/tests/ModControllerTests.lua b/client/tests/ModControllerTests.lua index 7a4c8470..8e2814c1 100644 --- a/client/tests/ModControllerTests.lua +++ b/client/tests/ModControllerTests.lua @@ -3,7 +3,7 @@ local ModController = require("client.src.mods.ModController") local Player = require("client.src.Player") local StageLoader = require("client.src.mods.StageLoader") local CharacterLoader = require("client.src.mods.CharacterLoader") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local Stage = require("client.src.mods.Stage") -- caveat: this test is not as effective if you have no character bundles installed diff --git a/client/tests/SoundGroupTests.lua b/client/tests/SoundGroupTests.lua index a0a52a27..7a45df90 100644 --- a/client/tests/SoundGroupTests.lua +++ b/client/tests/SoundGroupTests.lua @@ -1,4 +1,4 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local logger = require("common.lib.logger") local FileGroup = require("client.src.FileGroup") local fileUtils = require("client.src.FileUtils") diff --git a/client/tests/StackGraphicsTests.lua b/client/tests/StackGraphicsTests.lua index c4f91a93..66930475 100644 --- a/client/tests/StackGraphicsTests.lua +++ b/client/tests/StackGraphicsTests.lua @@ -1,7 +1,7 @@ local ClientMatch = require("client.src.ClientMatch") local GameModes = require("common.data.GameModes") local Player = require("client.src.Player") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local GeneratorSource = require("common.engine.GeneratorSource") local logger = require("common.lib.logger") local LevelPresets = require("common.data.LevelPresets") diff --git a/client/tests/TcpClientTests.lua b/client/tests/TcpClientTests.lua index e4634926..9c7ce463 100644 --- a/client/tests/TcpClientTests.lua +++ b/client/tests/TcpClientTests.lua @@ -1,5 +1,5 @@ local TcpClient = require("client.src.network.TcpClient") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local ClientMessages = require("common.network.ClientProtocol") local NetworkProtocol = require("common.network.NetworkProtocol") diff --git a/client/tests/ThemeTests.lua b/client/tests/ThemeTests.lua index 05aaeb18..8ab0362a 100644 --- a/client/tests/ThemeTests.lua +++ b/client/tests/ThemeTests.lua @@ -1,4 +1,4 @@ -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local fileUtils = require("client.src.FileUtils") local Theme = require("client.src.mods.Theme") diff --git a/common/engine/consts.lua b/common/engine/consts.lua index 7f371a0c..ca157a8b 100644 --- a/common/engine/consts.lua +++ b/common/engine/consts.lua @@ -1,59 +1,28 @@ ---[[ - TODO: - consts is currently kind of the "all over the place" collection - it should get split into - 1. constants decisively important for the engine - 2. constants decisively important for the client -]] -require("common.lib.util") local tableUtils = require("common.lib.tableUtils") -local consts = { - CANVAS_WIDTH = 1280, - CANVAS_HEIGHT = 720, - DEFAULT_THEME_DIR = "Panel Attack", - RANDOM_CHARACTER_SPECIAL_VALUE = "__RandomCharacter", - RANDOM_STAGE_SPECIAL_VALUE = "__RandomStage", - MOUSE_POINTER_TIMEOUT = 1.5, --seconds - KEY_NAMES = {"Up", "Down", "Left", "Right", "Swap1", "Swap2", "TauntUp", "TauntDown", "Raise1", "Raise2", "Start"}, - FRAME_RATE = 1 / 60, - KEY_DELAY = .25, - KEY_REPEAT_PERIOD = .05, - MENU_PADDING = 10 -} +local engineConstants = {} -- The values in this file are constants (except in this file perhaps) and are expected never to change during the game, not to be confused with globals! -consts.ENGINE_VERSIONS = {} -consts.ENGINE_VERSIONS.PRE_TELEGRAPH = "045" -consts.ENGINE_VERSIONS.TELEGRAPH_COMPATIBLE = "046" -consts.ENGINE_VERSIONS.TOUCH_COMPATIBLE = "047" -consts.ENGINE_VERSIONS.LEVELDATA = "048" -consts.ENGINE_VERSIONS.WIGGLE_PUNISH = "049" +engineConstants.ENGINE_VERSIONS = {} +engineConstants.ENGINE_VERSIONS.PRE_TELEGRAPH = "045" +engineConstants.ENGINE_VERSIONS.TELEGRAPH_COMPATIBLE = "046" +engineConstants.ENGINE_VERSIONS.TOUCH_COMPATIBLE = "047" +engineConstants.ENGINE_VERSIONS.LEVELDATA = "048" +engineConstants.ENGINE_VERSIONS.WIGGLE_PUNISH = "049" -consts.ENGINE_VERSION = consts.ENGINE_VERSIONS.WIGGLE_PUNISH -- The current engine version -consts.VERSION_MIN_VIEW = consts.ENGINE_VERSIONS.LEVELDATA -- The lowest version number that can be watched +engineConstants.ENGINE_VERSION = engineConstants.ENGINE_VERSIONS.WIGGLE_PUNISH -- The current engine version +engineConstants.VERSION_MIN_VIEW = engineConstants.ENGINE_VERSIONS.LEVELDATA -- The lowest version number that can be watched -consts.COUNTDOWN_CURSOR_SPEED = 4 --one move every this many frames -consts.COUNTDOWN_START = 8 -consts.COUNTDOWN_LENGTH = 180 --3 seconds at 60 fps +engineConstants.COUNTDOWN_CURSOR_SPEED = 4 --one move every this many frames +engineConstants.COUNTDOWN_START = 8 +engineConstants.COUNTDOWN_LENGTH = 180 --3 seconds at 60 fps -consts.PUZZLES_SAVE_DIRECTORY = "puzzles" - -consts.SERVER_SAVE_DIRECTORY = "servers/" -consts.LEGACY_SERVER_LOCATION = "18.188.43.50" -consts.SERVER_LOCATION = "panelattack.com" - -consts.SUPER_SELECTION_DURATION = 0.5 -- seconds -consts.SUPER_SELECTION_START = 0.1 -- time held at which super enable is considered started - -consts.DEFAULT_THEME_DIRECTORY = "Panel Attack Modern" - -consts.SCOREMODE_TA = 1 -consts.SCOREMODE_PDP64 = 2 -- currently not used +engineConstants.SCOREMODE_TA = 1 +engineConstants.SCOREMODE_PDP64 = 2 -- currently not used -- Yes, 2 is slower than 1 and 50..99 are the same. -consts.SPEED_TO_RISE_TIME = tableUtils.map( +engineConstants.SPEED_TO_RISE_TIME = tableUtils.map( {942, 983, 838, 790, 755, 695, 649, 604, 570, 515, 474, 444, 394, 370, 347, 325, 306, 289, 271, 256, 240, 227, 213, 201, 189, 178, 169, 158, 148, 138, @@ -71,6 +40,4 @@ consts.SPEED_TO_RISE_TIME = tableUtils.map( -- on stage 1, the increases occur at increments of: -- 20, 15, 15, 15, 10, 10, 10 -consts.ATTACK_TYPE = { combo=0, chain=1, shock=2 } - -return consts \ No newline at end of file +return engineConstants \ No newline at end of file diff --git a/common/lib/import.lua b/common/lib/import.lua new file mode 100644 index 00000000..4f2e0de2 --- /dev/null +++ b/common/lib/import.lua @@ -0,0 +1,68 @@ +--[[ +A library to provide a relative require. +Makes sense to use wherever we have grouped files that assuredly only ever move together. +Otherwise require is probably still better. + +MIT License + +Copyright (c) 2023 Justin van der Leij + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local function extractPathComponents(path) + local components = {} + for component in path:gmatch("[^/]+") do + table.insert(components, component) + end + + return components +end + +local import = function(path) + local callerPath = debug.getinfo(2, "S").source:sub(2) + + local pathStack = {} + + if (path:sub(1, 1) == ".") then + local components = extractPathComponents(callerPath) + + for i = 1, #components - 1 do + pathStack[i] = components[i] + end + end + + local components = extractPathComponents(path) + + for _, component in ipairs(components) do + if (component == ".") then + -- Skip + elseif (component == "..") then + table.remove(pathStack, #pathStack) + else + table.insert(pathStack, component) + end + end + + local out = table.concat(pathStack, ".") + + return require(out) +end + +return import \ No newline at end of file diff --git a/common/lib/luaLsPlugin.lua b/common/lib/luaLsPlugin.lua new file mode 100644 index 00000000..f98d7143 --- /dev/null +++ b/common/lib/luaLsPlugin.lua @@ -0,0 +1,83 @@ +--[[ +A plugin for the language server to resolve the import libraries requires +Intellisense becomes available for the returned types despite them technically being just a lua function call + +MIT License + +Copyright (c) 2024 Elmārs Āboliņš, including code from Justin van der Leij + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +]] + +local workspace = require "workspace" + +local function extractPathComponents(path) + local components = {} + for component in path:gmatch("[^/]+") do + table.insert(components, component) + end + + return components +end + +local import = function(path, fileUri) + local callerPath = fileUri + + local pathStack = {} + + if (path:sub(1, 1) == ".") then + local components = extractPathComponents(callerPath) + + for i = 1, #components - 1 do + pathStack[i] = components[i] + end + end + + local components = extractPathComponents(path) + + for _, component in ipairs(components) do + if (component == ".") then + -- Skip + elseif (component == "..") then + table.remove(pathStack, #pathStack) + else + table.insert(pathStack, component) + end + end + + local out = table.concat(pathStack, ".") + + return "require(\""..out.."\")" +end + +function OnSetText(uri, text) + local diffs = {} + + local transformedUri = uri:sub(#workspace.rootUri+2) + + for startPos, path, finish in text:gmatch '()import%(([^%(%)]+)%)()' do + diffs[#diffs+1] = { + start = startPos, + finish = finish - 1, + text = import(path:gsub('\"',""), transformedUri), + } + end + + return diffs +end \ No newline at end of file diff --git a/common/lib/stringExtensions.lua b/common/lib/stringExtensions.lua index bd364abe..30711725 100644 --- a/common/lib/stringExtensions.lua +++ b/common/lib/stringExtensions.lua @@ -1,5 +1,7 @@ local utf8 = require("common.lib.utf8Additions") +---@param self string +---@return string[] function string.toCharTable(self) local t = {} for _, codePoint in utf8.codes(self) do @@ -7,4 +9,19 @@ function string.toCharTable(self) t[#t+1] = character end return t +end + +---@param inputstr string +---@param separator string? can be a pattern +---@return string[]? +---@diagnostic disable-next-line: duplicate-set-field +function string.split(inputstr, separator) + separator = separator or "%s" + local t = {} + for field, s in string.gmatch(inputstr, "([^" .. separator .. "]*)(" .. separator .. "?)") do + t[#t+1] = field + if s == "" then + return t + end + end end \ No newline at end of file diff --git a/common/lib/tableUtils.lua b/common/lib/tableUtils.lua index df177898..fe62aa5a 100644 --- a/common/lib/tableUtils.lua +++ b/common/lib/tableUtils.lua @@ -104,10 +104,21 @@ end -- returns true if the table contains the given element or an identical copy of it, otherwise false -- may result in a deathloop if there are recursive references -function tableUtils.contains(tab, element) - for _, value in pairs(tab) do - if deep_content_equal(value, element) then - return true +---@param tab table +---@param element any +---@param referenceEquality boolean? if true, don't check equality by content for tables but instead check equality by reference only ("===" in other languages) +function tableUtils.contains(tab, element, referenceEquality) + if referenceEquality then + for _, value in pairs(tab) do + if value == element then + return true + end + end + else + for _, value in pairs(tab) do + if deep_content_equal(value, element) then + return true + end end end diff --git a/common/lib/util.lua b/common/lib/util.lua index 7b75aa6b..c583882a 100644 --- a/common/lib/util.lua +++ b/common/lib/util.lua @@ -1,3 +1,4 @@ +require("common.lib.stringExtensions") local tableUtils = require("common.lib.tableUtils") local pairs, type, setmetatable, getmetatable = pairs, type, setmetatable, getmetatable @@ -193,21 +194,6 @@ function frames_to_time_string(frame_count, include_centiseconds) return ret end --- split the input string on some separator, returns table ----@param inputstr string ----@param sep string? ----@return string[]? -function util.split(inputstr, sep) - sep = sep or "%s" - local t = {} - for field, s in string.gmatch(inputstr, "([^" .. sep .. "]*)(" .. sep .. "?)") do - table.insert(t, field) - if s == "" then - return t - end - end -end - -- Remove white space from the ends of a string ---@param s string ---@return string trimmed @@ -281,7 +267,7 @@ function util.addToCPath(path) -- Example: luasocket's dynamic core library's entry point is -- luaopen_socket_core -- meaning it has to be required as "socket.core", otherwise it cannot be opened - local cPathDirs = util.split(package.cpath, ";") + local cPathDirs = string.split(package.cpath, ";") local fileExtension = string.sub(cPathDirs[1], -4) if fileExtension == "?.so" then path = path:gsub("%?%?", "?.so") diff --git a/docs/Understanding the Codebase.md b/docs/Understanding the Codebase.md index bd6c8045..93e80a5d 100644 --- a/docs/Understanding the Codebase.md +++ b/docs/Understanding the Codebase.md @@ -85,6 +85,8 @@ The exact startup mechanism differs a bit based on the system due to specific is You can find the current updater at https://github.com/panel-attack/panel-updater with its own documentation. +For developing or troubleshooting updater related things, copy the updater's `updater` directory into this directory and pass `updaterTest` as an extra argument to love. + ## Broad Client Structure Panel Attack has many files, maybe too many and not all of them are in an intuitive place. @@ -201,40 +203,61 @@ In the current iteration GameModes are thought to be mainly defined by a set of #### Stack Interactions Defining how the stacks interact with each other. The currently possible settings are + - NONE, if garbage is not sent anywhere, nor is any received - VERSUS, if garbage is sent to another stack and received from another stack - SELF, if garbage is sent to yourself - ATTACK_ENGINE, if garbage is sent by an attack engine -#### Game Win Conditions -These define a set of zero or more conditions that cause a `Stack` to stop running as soon as one is met. +#### Stack Win Conditions +These define a set of zero or more conditions that cause a `Stack` to stop running in a winning state as soon as one is met. The currently possible settings are -- NO_MATCHABLE_PANELS, if there are no remaining panels of color 1-8 -- NO_MATCHABLE_GARBAGE, if there is no unmatched garbage left on the board -#### Game Over Conditions -These define a set of zero or more conditions that cause a `Stack` to go game over as soon as one is met. +- MATCHABLE_PANELS, which can be set to an integer. If that number of matchable panels is left on the board, the stack wins. +- MATCHABLE_GARBAGE_PANELS, same as MATCHABLE_PANELS, except it refers to only unmatched panels making up garbage. +- SCORE, this is not implemented yet but the idea is that once the Stack reaches a certain score it wins, e.g. for 99999 point racing. + +#### Stack Over Conditions +These define a set of zero or more conditions that cause a `Stack` to stop running in a losing state as soon as one is met. The currently possible settings are -- NEGATIVE_HEALTH, results in game over if `health` reaches 0 -- NO_MOVES_LEFT, results in game over if the amount of available moves for a puzzle has been met -- CHAIN_DROPPED, results in game over if the active chain was dropped + +- HEALTH, which can be set to an integer. If the stack's `health` is equal or smaller, the stack loses. +- SWAPS, which can be set to an integer. If the stack's `swapCount` property equals or exceeds that number, the stack loses. +- CHAIN, which can be set to a boolean. When set to false, the stack loses upon dropping its chain. When set to true, the stack loses upon forming a chain. #### Match Win Conditions + These define a set of zero or more conditions to determine a winner between multiple Stacks inside a Match. +All match win conditions are defined through comparative statements between all players in a game. + +- HIGHEST means that the stack with the highest number wins and generally higher is better if there are more than 2 players. +- LOWEST means that the stack with the lowest number wins and generally lower is better if there are more than 2 players. + The currently possible settings are -- LAST_ALIVE, the last stack alive wins, typically used with no conditions for game win on the Stack level -- SCORE, the stack with the highest score wins -- TIME, the stack with the lowest clock time wins + +- GAME_OVER_CLOCK, this is suitable for elimination and VS game modes; not having went game over is considered as infinity +- TIME, this is suitable for non-elimination modes like score races where generally all stacks are expected to survive +- SCORE Match win conditions are order sensitive and are evaluated until the last one while a tie is present. Example: -In a hypothetical 99999 point race capped to 10 minutes with the match win condition { SCORE, LAST_ALIVE, TIME }, player 1 and player 2 manage to reach 99999 points before time is up, player 3 self destructs at 72000 points before time is up while player 4 only manages to reach 43000 points when time is up. +In a hypothetical 99999 point race capped to 10 minutes with the match win condition { SCORE, GAME_OVER_CLOCK, TIME }, player 1 and player 2 manage to reach 99999 points before time is up, player 3 self destructs at 72000 points before time is up while player 4 only manages to reach 43000 points when time is up. First SCORE is evaluated. Player 3 and player 4 are both eliminated as potential winners because player 1 and 2 beat them in points. -Second LAST_ALIVE is evaluated. Both player 1 and 2 finished in a winning state so they aren't game over, they are still tied. +Second GAME_OVER_CLOCK is evaluated. Both player 1 and 2 finished in a winning state so they aren't game over, they are still tied. Finally TIME is evaluated, player 2 has a lower clock time than player 1 so they are determined winner. In the future each condition should also act as a tiebreaker so that player 3 would be determined to beat player 4 because they win in the score win condition. -Besides the LAST_ALIVE they're not in use so the existing implementations may actually not work. +Besides the GAME_OVER_CLOCK they're not in use so the existing implementations may actually not work. + +#### Match End Conditions + +These define a set of conditions that cause the `Match` to conclude the moment one of them is met. + +- STACKS_ACTIVE, which can be set to an integer. The moment the amount of Stack's still playing equals or falls below that number, the game finishes. +A stack is considered active when it has neither met a Stack Win, nor a Stack Over condition. +- TIME_LIMIT, which can be set to an integer. This causes the match to stop simulating Stacks the moment they reach that frame number. Once all have reached it, the match ends. + +If you have a TIME_LIMIT you usually want at least STACKS_ACTIVE = 0 as an extra condition, otherwise if everyone loses before the time limit is up you will get a bricked match that never ends. #### Stack behaviours Stack possesses certain behaviour flags under its `behaviours` table that can toggle major functions. @@ -259,12 +282,13 @@ If you read the comments for `Stack.simulate`, you may notice a suspicious absen ### Replays and Interoperability -common/data defines some classes and functions that serve as interop between server and client. +common/data defines some classes and functions that create, represent or modify data that is used to exchange information between server and client. Currently these are: -Replay, ReplayPlayer, LevelData, LevelPresets and TouchDataEncoding and (yet to be moved there) GameModes. +GameModes, StackBehaviours, ReplayV3, LevelData, LevelPresets, InputCompression, KeyDataEncoding and TouchDataEncoding and (yet to be moved there) GameModes. -If client or server have further needs than these classes provide, they should generally aim to create their own components that do not inherit from these to guarantee that client and server internals can be edited and refactored without impacting networking. +If client or server have domain specific needs that go beyond what these classes provide, they should generally aim to create their own components that do not inherit from these to guarantee that client and server internals can be edited and refactored without impacting networking. +It's a lot of boilerplate but ultimately it guarantees that you don't change something on the client and suddenly have to update the server for a completely unrelated client feature and lets the maintainer sleep at night. Besides these standard formats that are used in communication, both client and server implement an abstraction layer called ClientMessages or ServerMessages respectively whose sole responsibility it is to convert incoming messages to a format the recipient understands. @@ -288,36 +312,111 @@ All scenes have an `uiRoot` that is traversed by the touch handler through `UIEl ### UIElement composite For general menu design, UIElements should be used. -There is a lot to say about UIElements and nothing at the same time. +There is a lot to say about UIElements and nothing at the same time because the implementations are...rather individual and sometimes quirky. There are some generic UIElements with very basic functionality such as Button, Label, Slider, Stepper. -There are some UIElements for layouting such as Grid, StackPanel or ScrollContainer. +There are some UIElements for organizing a layout such as UniSizedContainer or ScrollContainer. There are some rather specific UIElements for certain purposes such as LevelSlider, StageCarousel or MultiPlayerSelectionWrapper. You may find some of these to be rather unfit for the general purposes their names imply and some are in need of a rewrite. -Via UIElement the composite tree supports the use of alignments and horizontal and vertical fill flags to automatically position and size a UIElement relative to its parent. -For example to have an element centered within its parent you would choose `hAlign = "center"`, `vAlign = "center"`, `x = 0`, `y = 0`. -Note that this comes with some limitations, e.g. if your top level element does not specify a width and the child wants to horizontally fill its parent, this won't work, even if children of the child have a size they would want to fill out. Meaning to say, the top level element in a tree needs sane settings for x, y, width, height and may not use any of the align/fill settings. -This is realised on `Scene`s by having a standard `uiRoot` which is just a basic UIElement with canvas size that provides a container to add all other UIElements to. +### Layout + +The standard layouts for Panel Attack are akin to a FlexBox and the goal of layouts is to provide a way to design UI for scenes so that they provide a good experience on desktop while still being a functional compromise in portrait mode dimensions on mobile. + +To achieve this, the layout logic is implemented as mostly separated from UIElements themselves and the layout of most UIElements can be changed just by assigning it a different static layout table. + +There are some exceptions to this as some UIElements are built with a specific orientation in mind while providing extra functionality. +Examples of this include: + +- ScrollContainers +You can still change the orientation and layout but usually the contents are designed with the dimensions in mind and thus changing layout orientation will look bad +- Horizontally oriented containers that automatically wrap around as their width reduces +As they already adjust to portrait dimensions on their own, there is no good reason to change the layout + +Layout updates always originate from the UIElement at the root and only if it is marked with `controlsWindow = true`. + +The biggest design points to note are the following: + +1. `x` and `y` are generally managed by the Layout. Placement of children depends on their order within the parent's `children` table. +There are some exceptions to this, some layouts will not perform any placement so you can still manually place but these Layouts will never be the default for any UIElement so you have to go out of your way to do it. +2. Alignment of children is based on the parent. That means to center a Button within a UIElement, the UIElement needs to be center aligned, not the Button. The previous iteration based alignment on the children so don't trip over this! + +#### General layout idea + +A general idea for organizing menu layout is the following: -If you can, don't use `Menu` for now, a menu redesign is planned and it will likely die with it. +The root level UIElement that controls the window dimensions and reacts to resizes uses an `AdaptiveFlexLayout` that automatically changes its layout between horizontal and vertical orientation based on its dimensions. +The root level UIElement has two children that use a `VerticalFlexLayout` each. +The first child is used for navigation and controls, the second displays info / preview information. +When in portrait dimensions, the root UIElement utilizes a vertical layout so that the display is a little akin to the Nintendo DS with two "screens". -#### Internal working +#### Layout mechanism + +For future reference and the creation of new layouts, a brief overview on how the Layout/FlexLayout works. + +The first core problem of automatic layout with an arbitrary resolution is that you have to know how big your widgets are before you can start placing them. +The second core problem is wrapping. Text and other tailor-made widgets can trade width for height. + +To address these problems, the layout logic follows a multi-step process in which each step traverses and works the entire UI tree before going to the next step. + +The first core problem has led to the introduction of a bunch more fields and functions to determine size: + +- minWidth, minHeight, defaults to 0 +- maxWidth, maxHeight, defaults to math.inf +- getPreferredWidth(), getPreferredHeight() +- hFill, vFill + +There are some more subtle problems hidden in this but the baseline is that the width of an element does not depend on its height but as per core problem #2 the height may depend on its width. +That means we first need to find out how wide an element is. +The only true answer at the start is "we don't know yet" so each UIElement is initialized with a temporary `newWidth` property that is not necessarily the final result but forms a meaningful basis for following calculations. +For any type of container that contains more than one child, the size also depends on the children so `newWidth` is assigned by recursively drilling down to the leaf nodes to set their `newWidth` first and calculating the parent with the children's information as it returns from the recursion until `newWidth` is assigned for the entire tree. + +At first each element's `newWidth` defaults to the highest of the following values: +- the UIElement's `minWidth` property +- the preferred width required by the children in accordance with the layout +in a vertical layout it would depend on the `newWidth` of the widest element only but in a horizontal it would be a sum +- the preferred width of the UIElement itself; this defaults to the UIElement's `minWidth` property but in particular wrappable elements like Labels will instead return their width in an unwrapped state. + +Now each element has a valid `newWidth` that makes sense but does not have a relation to the window size yet. + +The root element also has preliminary `newWidth` and with the delta of that value to the actual window width we can adjust the existing `newWidth` values in a top-down traversal: + +If the delta is positive, it is distributed between all children with the `hFill` property that have not reached their `maxWidth` yet. +If the delta is negative, it is distributed between all children that have a lower `minWidth` than `newWidth`, starting with the widest element. This will typically hit wrappables that return a preferred width much higher than their minimum width. (Note: it should also consider minWidth based on children on top of the property which it does not do right now). + +By traversing downwards, the extra width compared to the initial estimate is thus spent where possible and `newWidth` is finalized as `width`. + +For height the same steps are essentially repeated. For wrappable elements their width-based minimum height is accessed through a `getMinHeight()` function that is only required by the HorizontalWrapLayout but the exact implementation of this width-to-height mechanism might change later. + +To sum up, a valid and desirable estimate is first made for widths, than corrected with the true width using only operations that are known to be valid so the final outcome is guaranteed to be valid and related to the window size. +The same happens for height with special consideration to the width values. + +In the following positioning step, each parent assigns `x` and `y` to its children according to its layout. The values are relative to the parent. + +As the final step the tree is once more traversed recursively to call optional callbacks on each UIElement to signify that resizing finished. +This is so that layout oriented UIElements that provide navigation options can make adjustments to how inputs affect selection. + + +#### Drawing + +The layout logic assigns `x` and `y` positions that are relative to its parent. + +Each element is called via `UIElement.draw` which first calls `drawSelf`, then performs a coordinate translation and finally calls the predefined `UIElement.drawChildren` that just calls `draw` on all visible children. +This mechanism is mainly intended for the children to be able to be drawn directly using `self.x` and `self.y` without relying on their parent. +While this is the default, both `draw` and `drawChildren` can be overwritten for any class to have it exert more fine control over how its children are drawn. +Notably, since all of these functions reside on metatables, they can also be shadowed on a per-instance basis for tailor-made behaviour. -The way the relative positioning works is that each element has its `drawSelf` function. -Each element is called via `UIElement.draw` however which first calls `drawSelf` and then calls the predefined `UIElement.drawChildren` that manages the offset for each child with the alignment before calling `draw` on it. -This mechanism is mainly intended for the children to be able to be drawn on their own without relying on their parent. -While this is the default, both `draw` and `drawChildren` can be overwritten for a certain element to have it exert more fine control over how its children are drawn. -Given that functions are overridable on ANY table, even individual ones, it is however also possible to simply overwrite the respective child's `drawSelf` function to rely on its parent instead which can be the easier approach as it allows use of alignments + relative offset without extra setup. ## Localization We have a cool localization.csv file. In the first column is the codename of a string, then the traductions into the different languages. When adding text to the game, we can reference it by `loc(codename)` so that the loc function can automatically fetch the correct string based on the language configuration. -For text that is properly embedded within the new UI structure, `Label`s are used for display. `Label` possesses a `translate` attribute that defaults to `true` if not explicitly passed as `false` so that the codenames for the strings can often be passed directly. +For text that is properly embedded within the new UI structure, `Label`s are used for display. `Label` have to be initialized with either an `id` or a `text`. +When using an `id` the `loc` function is used to fill the `Label`'s `text` property. Otherwise the text is filled directly and is not being translated. If there are placeholders in a localized string, the `replacements` field can be passed with the values that should be used as replacements. If you add new localization entries please make sure to **always** add them at the bottom. There is a google doc we pull from where non-developers can submit changes / new localizations and it spoils any syncing attempt if we get new entries in the middle of the file. ## Mods and Assets + The default assets can be found in the `client/assets folder`. `client/assets/default_data` contains mods that ship with the game while the other directories contain fallbacks for user mods that don't provide certain assets. Each graphic asset type has its own file for managing the loading process in `client/src/mods`: @@ -326,6 +425,7 @@ Loading of characters and stages is described in the next section. Panels always For characters, panels and stages, a table with id by index and a table with the actual mod by id is created for global access. ### Mod loading + PA has a (still experimental) `ModController` component that tries to automatically load balance the loaded mods. `ModController:loadModFor` is a function that lazy loads a mod for a certain user (this can be a player or match) and holds a table with mods that have been loaded. Additionally each mod also holds by who it is loaded via weak tables. @@ -353,7 +453,7 @@ Draws a bar graph used for per frame value display in debug mode with FPS counte ### [batteries](https://github.com/1bardesign/batteries) A powerful library seeking to fill in the huge gaps in Lua's own standard library. -In Panel Attack we only use the standalone `manual_gc.lua` which offers some functionality for manually collecting garbage on the client. +In Panel Attack we used to use the standalone `manual_gc.lua` which offers some functionality for manually collecting garbage on the client. ### common diff --git a/main.lua b/main.lua index ac86dbb5..aed371e7 100644 --- a/main.lua +++ b/main.lua @@ -11,7 +11,7 @@ local GraphicsUtil = require("client.src.graphics.graphics_util") local prof = require("common.lib.zoneProfiler") local ReplayV3 = require("common.data.ReplayV3") require("common.lib.util") -local consts = require("common.engine.consts") +local consts = require("client.src.consts") local system = require("client.src.system") local Game = require("client.src.Game") @@ -43,7 +43,7 @@ function love.load(args, rawArgs) love.window.restore() end local offset = math.ceil(desktopHeight / 32) - love.window.updateMode(desktopWidth, desktopHeight - offset, flags) + GraphicsUtil.updateMode(desktopWidth, desktopHeight - offset, flags) love.window.setPosition(x, offset, displayIndex) end @@ -265,7 +265,7 @@ function love.errorhandler(msg) end love.graphics.reset() - local s, font = pcall(GraphicsUtil.getGlobalFontWithSize, GraphicsUtil.fontSize + 4) + local s, font = pcall(GraphicsUtil.getGlobalFontWithSize, "medium") if s then love.graphics.setFont(font) else