From 99ed374656ae8292fa12608b2076f41265c9401f Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Sat, 18 Oct 2025 22:09:20 -0700 Subject: [PATCH 1/2] Refactor game mode system to fix level data synchronization Fixes #698 Various problems existed with the style system and classic endless mode and updating settings. The style wasn't updated all the time, and it wasn't preserved when opening the game again. In addition style settings could carry over to other game types. To fix this I have made a formal GameMode class that makes sure all the derived settings are updated when settings like style update. I also added a preferred style for tracking what the user picked, similar to how stages and characters work. The other clients only know about your "style" not preferred style, and thats only derived since we don't currently send style through the server messages. - Convert GameMode to proper class with methods - Separate UI style preference from game state style setting - Centralize level data updates in GameMode callbacks - Ensure consistent settings between client and server - Also fixed a challenge mode JSON serialization crash by using our JSON sanitizer again. --- client/src/BattleRoom.lua | 77 ++++++------- client/src/Game.lua | 19 +-- client/src/Player.lua | 36 ++++-- client/src/network/NetClient.lua | 1 + client/src/scenes/CharacterSelect.lua | 18 +-- client/src/scenes/EndlessMenu.lua | 7 +- client/src/scenes/TimeAttackMenu.lua | 5 +- client/src/ui/Grid.lua | 8 +- client/tests/PlayerSettingsTests.lua | 141 +++++++++++++++++++++++ client/tests/StackGraphicsTests.lua | 2 +- common/data/GameModes.lua | 160 ++++++++++++++++++++------ common/engine/Health.lua | 5 +- common/lib/util.lua | 4 +- common/network/ClientProtocol.lua | 4 +- server/Room.lua | 4 +- server/tests/ServerTests.lua | 2 +- testLauncher.lua | 1 + 17 files changed, 359 insertions(+), 135 deletions(-) create mode 100644 client/tests/PlayerSettingsTests.lua diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 9eb433cb5..1fa9b5a5a 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -15,7 +15,7 @@ local GeneratorSource = require("common.engine.GeneratorSource") -- A Battle Room is a session of matches, keeping track of the room number, player settings, wins / losses etc ---@class BattleRoom : Signal ----@field mode GameMode +---@field mode GameMode The game mode configuration defining rules, player count, and match settings for this battle room ---@field players Player[] ---@field spectators string[] ---@field spectating boolean @@ -59,7 +59,8 @@ end) BattleRoom.states = { Setup = 1, MatchInProgress = 2 } function BattleRoom.createFromServerMessage(message) - local battleRoom = BattleRoom(message.gameMode) + local gameMode = GameModes.createFromServerData(message.gameMode) + local battleRoom = BattleRoom(gameMode) if message.spectate_request_granted then logger.debug("Joining a match as spectator") @@ -108,16 +109,12 @@ function BattleRoom.createFromServerMessage(message) p = Player(player.name, player.publicId or -i, false) end - -- order is important here as setting style will indirectly also override levelData so it needs to be before updateSettings - if gameMode.style ~= GameModes.Styles.CHOOSE then - p:setStyle(gameMode.style) - else - if player.settings.levelData then - if player.settings.levelData.frameConstants.GARBAGE_HOVER then - p:setStyle(GameModes.Styles.MODERN) - else - p:setStyle(GameModes.Styles.CLASSIC) - end + -- Not great, but the server doesn't know about style for now + if player.settings.levelData then + if player.settings.levelData.frameConstants.GARBAGE_HOVER then + p:setStyle(GameModes.Styles.MODERN) + else + p:setStyle(GameModes.Styles.CLASSIC) end end @@ -141,30 +138,33 @@ function BattleRoom.createFromServerMessage(message) return battleRoom end -function BattleRoom.createLocalFromGameMode(gameMode, gameScene) +-- Creates a local (offline) BattleRoom from a GameMode configuration. +-- For single-player modes, uses the game's main local player. For multi-player modes, +-- creates temporary local players that don't persist settings changes. +---@param gameMode GameMode The game mode configuration defining rules and player count +---@param gameScene table? Optional scene class to use for matches (defaults to mode's gameScene) +---@param settingCHangesUpdateConfig boolean? If true, setting changes update config (default: true). Only applies to single-player modes. +---@return BattleRoom? battleRoom The created battle room, or nil if input configuration assignment fails +function BattleRoom.createLocalFromGameMode(gameMode, gameScene, settingCHangesUpdateConfig) + if settingCHangesUpdateConfig == nil then + settingCHangesUpdateConfig = true + end + local battleRoom = BattleRoom(gameMode, gameScene) - if gameMode.playerCount == 1 then + if settingCHangesUpdateConfig and gameMode.playerCount == 1 then -- always use the game client's local player battleRoom:addPlayer(GAME.localPlayer) else -- with more than 1 local player we can't be sure which player is the "real" regular user -- so make them both local players that don't update config settings for i = 1, gameMode.playerCount do - local player = Player.getLocalPlayer() + local player = Player.createLocalPlayerFromConfig() player.name = loc("player_n", i) battleRoom:addPlayer(player) end end - if gameMode.style ~= GameModes.Styles.CHOOSE then - for i, player in ipairs(battleRoom.players) do - if player.human then - battleRoom.players[i]:setStyle(gameMode.style) - end - end - end - if battleRoom:assignInputConfigurations() then return battleRoom else @@ -271,6 +271,15 @@ function BattleRoom:addPlayer(player) end self.players[#self.players + 1] = player + if player.isLocal and player.human and self.mode.updateLocalPlayersDerivedSettings then + -- Initial update + self.mode.updateLocalPlayersDerivedSettings(player) + + -- Connect signals to update derived settings when style/difficulty + player:connectSignal("preferredStyleChanged", self.mode, function() self.mode.updateLocalPlayersDerivedSettings(player) end) + player:connectSignal("difficultyChanged", self.mode, function() self.mode.updateLocalPlayersDerivedSettings(player) end) + player:connectSignal("levelChanged", self.mode, function() self.mode.updateLocalPlayersDerivedSettings(player) end) + end if player.isLocal then self:connectSignal("allAssetsLoadedChanged", player, player.setLoaded) end @@ -404,28 +413,6 @@ function BattleRoom:createScene(match) end end --- sets the style of "level" presets the players select from --- 1 = classic --- 2 = modern --- longterm we want to abandon the concept of "style" on the player / battleRoom level --- just setting difficulty or level should set the levelData and done with it, style is a menu-only concept --- there is no technical reason why someone on level 10 shouldn't be able to play against someone on Hard --- for now it's a battleRoom wide setting and players have to match -function BattleRoom:setStyle(styleChoice) - -- style could be configurable per play instead but let's not for now - if self.mode.style == GameModes.Styles.CHOOSE then - self.style = styleChoice - self.onStyleChanged(styleChoice) - else - error("Trying to set difficulty style in a game mode that doesn't support style selection") - end -end - --- not player specific, so this gets a separate callback that can only be overwritten once --- so the UI can update and load up the different controls for it -function BattleRoom.onStyleChanged(style, player) -end - function BattleRoom:startLoadingNewAssets() if ModLoader.loading_mod == nil then for _, player in ipairs(self.players) do diff --git a/client/src/Game.lua b/client/src/Game.lua index 5ea3a22d6..acad375ca 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -257,7 +257,7 @@ end -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate -- it basically represents the player that is operating the client (and thus binds to its configuration) function Game:initializeLocalPlayer() - self.localPlayer = Player.getLocalPlayer() + self.localPlayer = Player.createLocalPlayerFromConfig() self.localPlayer:connectSignal("selectedCharacterIdChanged", config, function(config, newId) config.character = newId end) self.localPlayer:connectSignal("selectedStageIdChanged", config, function(config, newId) config.stage = newId end) self.localPlayer:connectSignal("panelIdChanged", config, function(config, newId) config.panels = newId end) @@ -266,7 +266,7 @@ function Game:initializeLocalPlayer() self.localPlayer:connectSignal("difficultyChanged", config, function(config, difficulty) config.endless_difficulty = difficulty end) self.localPlayer:connectSignal("levelChanged", config, function(config, level) config.level = level end) self.localPlayer:connectSignal("wantsRankedChanged", config, function(config, wantsRanked) config.ranked = wantsRanked end) - self.localPlayer:connectSignal("styleChanged", config, function(config, style) + self.localPlayer:connectSignal("preferredStyleChanged", config, function(config, style) if style == GameModes.Styles.CLASSIC then config.endless_level = nil else @@ -305,21 +305,6 @@ function Game:createDirectoriesIfNeeded() end end -function Game:runUnitTests() - coroutine.yield("Running Unit Tests") - - -- GAME.localPlayer is the standard player for battleRooms that don't get started from replays/spectate - -- basically the player that is operating the client - GAME.localPlayer = Player.getLocalPlayer() - -- we need to overwrite the local player as all replay related tests need a non-local player - GAME.localPlayer.isLocal = false - - logger.info("Running Unit Tests...") - GAME.muteSound = true - --require("client.tests.Tests") - SoundController:applyConfigVolumes() -end - function Game:runPerformanceTests() coroutine.yield("Running Performance Tests") require("tests.StackReplayPerformanceTests") diff --git a/client/src/Player.lua b/client/src/Player.lua index a7711081d..5c0af3d40 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -20,6 +20,7 @@ local StackBehaviours = require("common.data.StackBehaviours") ---@field speed integer ---@field levelData LevelData ---@field style Styles +---@field preferredStyle Styles ---@field wantsRanked boolean ---@field inputMethod InputMethod @@ -53,6 +54,7 @@ function(self, name, publicId, isLocal) ---@type LevelData settings.levelData = LevelPresets.getModern(1) settings.style = GameModes.Styles.MODERN + settings.preferredStyle = GameModes.Styles.MODERN settings.characterId = "" settings.stageId = "" settings.panelId = "" @@ -77,6 +79,7 @@ function(self, name, publicId, isLocal) -- they can register a callback with each signal via Signal.connectSignal -- there are a few more signals in MatchParticipant (which is why we don't have to explicitly declare us as emitting Signals again) self:createSignal("styleChanged") + self:createSignal("preferredStyleChanged") self:createSignal("difficultyChanged") self:createSignal("startingSpeedChanged") self:createSignal("levelChanged") @@ -172,22 +175,31 @@ end -- sets the style of "level" presets the player selects from -- 1 = classic -- 2 = modern --- longterm we want to abandon the concept of "style" on the player / battleRoom level --- just setting difficulty or level should set the levelData and done with it, style is a menu-only concept --- there is no technical reason why someone on level 10 shouldn't be able to play against someone on Hard +-- style is a menu-only concept for UI display +-- derived settings (levelData) should be updated by calling gameMode.updateLocalPlayersDerivedSettings function Player:setStyle(style) if style ~= self.settings.style then + logger.debug("setting style " .. style) self.settings.style = style - if style == GameModes.Styles.MODERN then - self:setLevelData(LevelPresets.getModern(self.settings.level or config.level)) - else - self:setLevelData(LevelPresets.getClassic(self.settings.difficulty or config.endless_difficulty)) - self:setSpeed(self.settings.speed) - end self:emitSignal("styleChanged", style) end end +-- sets the preferred style when loading UI +-- not sent over network +-- 1 = classic +-- 2 = modern +-- style is a menu-only concept for UI display +-- derived settings (levelData) should be updated by calling gameMode.updateLocalPlayersDerivedSettings +function Player:setPreferredStyle(preferredStyle) + if preferredStyle ~= self.settings.preferredStyle then + logger.debug("setting preferred style " .. preferredStyle) + self.settings.preferredStyle = preferredStyle + self:emitSignal("preferredStyleChanged", preferredStyle) + end +end + + function Player:setRating(rating) if self.rating and tonumber(self.rating) then -- only save a rating if we actually have one, tonumber assures that rating does not track placement progress instead @@ -233,7 +245,7 @@ function Player:unrestrictInputs() end ---@return Player -function Player.getLocalPlayer() +function Player.createLocalPlayerFromConfig() local player = Player(config.name, -1, true) player:setDifficulty(config.endless_difficulty) @@ -245,11 +257,11 @@ function Player.getLocalPlayer() player:setWantsRanked(config.ranked) player:setInputMethod(config.inputMethod) if config.endless_level then + player:setPreferredStyle(GameModes.Styles.MODERN) player:setStyle(GameModes.Styles.MODERN) - player:setLevelData(LevelPresets.getModern(player.settings.level)) else + player:setPreferredStyle(GameModes.Styles.CLASSIC) player:setStyle(GameModes.Styles.CLASSIC) - player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) player:setSpeed(config.endless_speed) end diff --git a/client/src/network/NetClient.lua b/client/src/network/NetClient.lua index 4bb83eab1..d785a431c 100644 --- a/client/src/network/NetClient.lua +++ b/client/src/network/NetClient.lua @@ -458,6 +458,7 @@ function NetClient:requestSpectate(roomNumber) end end +---@param gameMode GameMode function NetClient:requestRoom(gameMode) if self:isConnected() then self.tcpClient:sendRequest(ClientMessages.sendRoomRequest(gameMode)) diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index f41efaf34..a8caaea57 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -668,7 +668,7 @@ end ---@return BoolSelector styleSelector function CharacterSelect:createStyleSelection(player, width) local styleSelector = ui.BoolSelector({ - startValue = (player.settings.style == GameModes.Styles.MODERN), + startValue = (player.settings.preferredStyle == GameModes.Styles.MODERN), vFill = true, width = width, vAlign = "center", @@ -918,25 +918,11 @@ function CharacterSelect:createDifficultyCarousel(player, height) -- Just update on every passenger change end - local updateDifficultyData = function(difficultyID) - local levelData = LevelPresets.getClassic(difficultyID) - player:setDifficulty(difficultyID) - if self.battleRoom.mode.name == "endless" and difficultyID == 1 then - -- Endless easy uses 5 colors instead of 6 - levelData:setColorCount(5) - -- and by extension also allows adjacent panels of the same colors - levelData:setAdjacentDenialFrequency(0) - end - player:setLevelData(levelData) - end difficultyCarousel.onPassengerUpdateCallback = function(carousel, selectedPassenger) - updateDifficultyData(selectedPassenger.id) + player:setDifficulty(selectedPassenger.id) GAME.theme:playMoveSfx() self:refresh() end - -- Note that this updates the player level data which could be wrong before because of the weird endless case - -- its probably fine for now, but ideally the model should be right when the battle room is created - updateDifficultyData(difficultyCarousel.selectedId) return difficultyCarousel end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index 626c9c600..b6e86354f 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -88,13 +88,16 @@ function EndlessMenu:loadUserInterface() styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() - self.ui.grid:removeElementsIn(6, 2, 3, 1) if value and player.settings.style ~= GameModes.Styles.MODERN then + player:setPreferredStyle(GameModes.Styles.MODERN) player:setStyle(GameModes.Styles.MODERN) + self.ui.grid:removeElementsIn(6, 2, 3, 1) self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) self.ui.recordBox:setVisibility(false) - elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then + elseif value == false and player.settings.preferredStyle ~= GameModes.Styles.CLASSIC then + player:setPreferredStyle(GameModes.Styles.CLASSIC) player:setStyle(GameModes.Styles.CLASSIC) + self.ui.grid:removeElementsIn(6, 2, 3, 1) self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) self.ui.recordBox:setVisibility(true) diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 75904d97a..42dacabb2 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -87,13 +87,16 @@ function TimeAttackMenu:loadUserInterface() styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() - self.ui.grid:removeElementsIn(6, 2, 3, 1) if value and player.settings.style ~= GameModes.Styles.MODERN then + player:setPreferredStyle(GameModes.Styles.MODERN) player:setStyle(GameModes.Styles.MODERN) + self.ui.grid:removeElementsIn(6, 2, 3, 1) self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) self.ui.recordBox:setVisibility(false) elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then + player:setPreferredStyle(GameModes.Styles.CLASSIC) player:setStyle(GameModes.Styles.CLASSIC) + self.ui.grid:removeElementsIn(6, 2, 3, 1) self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) self.ui.recordBox:setVisibility(true) diff --git a/client/src/ui/Grid.lua b/client/src/ui/Grid.lua index 8d1d1cbbd..2dfc7a2bf 100644 --- a/client/src/ui/Grid.lua +++ b/client/src/ui/Grid.lua @@ -91,8 +91,12 @@ function Grid:drawSelf() end end --- removes all gridElements overlapping with the specified box --- the box is top left anchored +--- removes all gridElements overlapping with the specified box +--- the box is top left anchored +---@param x integer +---@param y integer +---@param width integer +---@param height integer function Grid:removeElementsIn(x, y, width, height) height = height or 1 width = width or 1 diff --git a/client/tests/PlayerSettingsTests.lua b/client/tests/PlayerSettingsTests.lua new file mode 100644 index 000000000..447394f04 --- /dev/null +++ b/client/tests/PlayerSettingsTests.lua @@ -0,0 +1,141 @@ +local Player = require("client.src.Player") +local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") +local CharacterSelect = require("client.src.scenes.CharacterSelect") +local BattleRoom = require("client.src.BattleRoom") + +local function testDifficultyCarouselShouldNotMutatePlayerOnCreation() + + local player = Player("TestPlayer", 1, true) + player:setStyle(GameModes.Styles.MODERN) + player:setDifficulty(1) + player:setLevel(10) + player:setLevelData(LevelPresets.getModern(10)) + + local originalLevel = player.settings.level + local originalDifficulty = player.settings.difficulty + local originalStyle = player.settings.style + local originalGarbageHover = player.settings.levelData.frameConstants.GARBAGE_HOVER + local originalColorCount = player.settings.levelData.colors + + assert(originalLevel == 10, "Initial level should be 10") + assert(originalStyle == GameModes.Styles.MODERN, "Initial style should be MODERN") + assert(originalGarbageHover == 4, "Initial GARBAGE_HOVER should be 4") + assert(originalColorCount == 6, "Initial color count should be 6") + + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local battleRoom = BattleRoom(gameMode) + battleRoom:addPlayer(player) + + local characterSelect = CharacterSelect({battleRoom = battleRoom}) + + local _ = characterSelect:createDifficultyCarousel(player, 100) + + assert(player.settings.level == originalLevel, "createDifficultyCarousel should not change level, but changed from " .. originalLevel .. " to " .. player.settings.level) + assert(player.settings.difficulty == originalDifficulty, "createDifficultyCarousel should not change difficulty, but changed from " .. tostring(originalDifficulty) .. " to " .. tostring(player.settings.difficulty)) + assert(player.settings.style == originalStyle, "createDifficultyCarousel should not change style, but changed from " .. originalStyle .. " to " .. player.settings.style) + assert(player.settings.levelData.frameConstants.GARBAGE_HOVER == originalGarbageHover, "createDifficultyCarousel should not change GARBAGE_HOVER, but changed from " .. tostring(originalGarbageHover) .. " to " .. tostring(player.settings.levelData.frameConstants.GARBAGE_HOVER)) + assert(player.settings.levelData.colors == originalColorCount, "createDifficultyCarousel should not change color count, but changed from " .. originalColorCount .. " to " .. player.settings.levelData.colors) + + battleRoom:shutdown() +end + +testDifficultyCarouselShouldNotMutatePlayerOnCreation() + +local function testAllModernLevelsHaveGarbageHover() + for level = 1, 11 do + local levelData = LevelPresets.getModern(level) + assert(levelData.frameConstants.GARBAGE_HOVER ~= nil, "Modern level " .. level .. " should have GARBAGE_HOVER") + assert(levelData.frameConstants.GARBAGE_HOVER ~= nil, + "Modern level " .. level .. " GARBAGE_HOVER should not be nil") + end +end + +testAllModernLevelsHaveGarbageHover() + +local function testAllClassicLevelsLackGarbageHover() + for difficulty = 1, 4 do + local levelData = LevelPresets.getClassic(difficulty) + assert(levelData.frameConstants.GARBAGE_HOVER == nil, + "Classic difficulty " .. difficulty .. " should not have GARBAGE_HOVER but got " .. + tostring(levelData.frameConstants.GARBAGE_HOVER)) + end +end + +testAllClassicLevelsLackGarbageHover() + +local function testEndlessModeClassicDifficulty1SetsCorrectSettings() + + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local battleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) + + assert(battleRoom ~= nil, "BattleRoom should be created successfully") + assert(#battleRoom.players == 1, "BattleRoom should have exactly 1 player") + + local battleRoomPlayer = battleRoom.players[1] + + battleRoomPlayer:setPreferredStyle(GameModes.Styles.CLASSIC) + battleRoomPlayer:setDifficulty(1) + + assert(battleRoomPlayer.settings.levelData.colors == 5, + "Endless mode with classic difficulty 1 should have 5 colors, but got " .. + tostring(battleRoomPlayer.settings.levelData.colors)) + + assert(battleRoomPlayer.settings.levelData.adjacentDenialFrequency == 0, + "Endless mode with classic difficulty 1 should have adjacent denial frequency of 0, but got " .. + tostring(battleRoomPlayer.settings.levelData.adjacentDenialFrequency)) + + battleRoom:shutdown() +end + +testEndlessModeClassicDifficulty1SetsCorrectSettings() + +local function testVsSelfChangesEndlessClassicSettingsToModern() + -- First create an endless battle room with classic difficulty 1 + local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") + local endlessBattleRoom = BattleRoom.createLocalFromGameMode(gameMode, nil, false) + + assert(endlessBattleRoom ~= nil, "Endless BattleRoom should be created successfully") + + local endlessPlayer = endlessBattleRoom.players[1] + + -- Set to classic difficulty 1 (simulating what happens in endless mode) + endlessPlayer:setPreferredStyle(GameModes.Styles.CLASSIC) + endlessPlayer:setDifficulty(1) + + -- Verify endless settings + assert(endlessPlayer.settings.style == GameModes.Styles.CLASSIC, + "Player should have classic style before vs self") + assert(endlessPlayer.settings.levelData.colors == 5, + "Player should have 5 colors before vs self") + assert(endlessPlayer.settings.levelData.adjacentDenialFrequency == 0, + "Player should have adjacent denial frequency of 0 before vs self") + + endlessBattleRoom:shutdown() + + -- Now create a vs self battle room which should change settings to modern + local vsSelfGameMode = GameModes.getPreset("ONE_PLAYER_VS_SELF") + local vsSelfBattleRoom = BattleRoom.createLocalFromGameMode(vsSelfGameMode, nil, false) + + assert(vsSelfBattleRoom ~= nil, "Vs self BattleRoom should be created successfully") + + local vsSelfPlayer = vsSelfBattleRoom.players[1] + vsSelfPlayer:setLevel(10) + + assert(vsSelfPlayer.settings.levelData.frameConstants.GARBAGE_HOVER ~= nil, + "Modern style should have GARBAGE_HOVER set") + + -- Modern levels have 6 colors (not 5 like endless classic difficulty 1) + assert(vsSelfPlayer.settings.levelData.colors == 6, + "Vs self should change color count to 6 (modern default), but got " .. + tostring(vsSelfPlayer.settings.levelData.colors)) + + -- Modern levels have non-zero adjacent denial frequency + assert(vsSelfPlayer.settings.levelData.adjacentDenialFrequency > 0, + "Vs self should have adjacent denial frequency > 0 (modern default), but got " .. + tostring(vsSelfPlayer.settings.levelData.adjacentDenialFrequency)) + + vsSelfBattleRoom:shutdown() +end + +testVsSelfChangesEndlessClassicSettingsToModern() diff --git a/client/tests/StackGraphicsTests.lua b/client/tests/StackGraphicsTests.lua index ddaa19a0d..09db7e0bc 100644 --- a/client/tests/StackGraphicsTests.lua +++ b/client/tests/StackGraphicsTests.lua @@ -26,7 +26,7 @@ local function createEndlessClientMatch(playerCount, theme) playerCount = 1 end for i = 1, playerCount do - local player = Player.getLocalPlayer() + local player = Player.createLocalPlayerFromConfig() player.isLocal = false player:setLevel(10) player:setLevelData(LevelPresets.getModern(10)) diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index 398ffbb77..fc7193dfe 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -1,4 +1,6 @@ +local class = require("common.lib.class") local MatchRules = require("common.data.MatchRules") +local LevelPresets = require("common.data.LevelPresets") local TIME_ATTACK_TIME = 120 local GameModes = {} @@ -13,6 +15,25 @@ local GameModes = {} ---@field gameScene string ---@field style Styles ---@field richPresenceLabel string? +---@field updateLocalPlayersDerivedSettings function +local GameMode = class(function(self, properties) + for key, value in pairs(properties) do + self[key] = value + end +end) + +-- Returns a copy of the game mode data suitable for JSON serialization +-- Removes all methods/functions from the copied data +---@return table +function GameMode:getGameModeJSONData() + local gameModeData = deepcpy(self) + for key, value in pairs(gameModeData) do + if type(value) == "function" then + gameModeData[key] = nil + end + end + return gameModeData +end -- longterm we want to abandon the concept of "style" on the engine and room setup level -- the engine only cares about levelData, style is a menu-only concept @@ -23,9 +44,52 @@ local Styles = { CHOOSE = 0, CLASSIC = 1, MODERN = 2} ---@enum StackInteractions local StackInteractions = { NONE = 0, VERSUS = 1, SELF = 2, ATTACK_ENGINE = 3 } +-- Updates player with modern-style level data based on their level setting +---@param player Player +local function updateModernSettings(player) + player:setStyle(Styles.MODERN) + player:setLevelData(LevelPresets.getModern(player.settings.level)) +end + +-- Updates player with level data based on their style selection (modern or classic) +---@param player Player +local function updateStyleBasedSettings(player) + if player.settings.style ~= player.settings.preferredStyle then + player.settings.style = player.settings.preferredStyle + end + if player.settings.preferredStyle == Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + elseif player.settings.preferredStyle == Styles.CLASSIC then + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) + else + assert("Expected a set style") + end +end + +-- Updates player with level data for endless mode, applying style-specific settings and endless easy difficulty adjustments +---@param player Player +local function updateEndlessStyleBasedSettings(player) + if player.settings.style ~= player.settings.preferredStyle then + player.settings.style = player.settings.preferredStyle + end + if player.settings.preferredStyle == Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + elseif player.settings.preferredStyle == Styles.CLASSIC then + local levelData = LevelPresets.getClassic(player.settings.difficulty) + if player.settings.difficulty == 1 then + -- Endless easy uses 5 colors instead of 6 + levelData:setColorCount(5) + -- and allows adjacent panels of the same colors + levelData:setAdjacentDenialFrequency(0) + end + player:setLevelData(levelData) + else + assert("Expected a set style") + end +end + ---@type GameMode -local OnePlayerVsSelf = { - style = Styles.MODERN, +local OnePlayerVsSelf = GameMode({ gameScene = "VsSelfGame", richPresenceLabel = "1p vs self", -- loc("mm_1_vs"), name = "vsSelf", @@ -40,12 +104,13 @@ local OnePlayerVsSelf = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + + updateLocalPlayersDerivedSettings = updateModernSettings +}) ---@type GameMode -local OnePlayerTimeAttack = { - style = Styles.CHOOSE, +local OnePlayerTimeAttack = GameMode({ gameScene = "TimeAttackGame", richPresenceLabel = "Time Attack", -- loc("mm_1_time"), name = "timeattack", @@ -60,12 +125,13 @@ local OnePlayerTimeAttack = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + + updateLocalPlayersDerivedSettings = updateStyleBasedSettings +}) ---@type GameMode -local OnePlayerEndless = { - style = Styles.CHOOSE, +local OnePlayerEndless = GameMode({ gameScene = "EndlessGame", richPresenceLabel = "Endless", -- loc("mm_1_endless"), name = "endless", @@ -80,12 +146,13 @@ local OnePlayerEndless = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + + updateLocalPlayersDerivedSettings = updateEndlessStyleBasedSettings +}) ---@type GameMode -local OnePlayerTraining = { - style = Styles.MODERN, +local OnePlayerTraining = GameMode({ gameScene = "GameBase", richPresenceLabel = "Training", -- loc("mm_1_training"), name = "training", @@ -100,13 +167,14 @@ local OnePlayerTraining = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + + updateLocalPlayersDerivedSettings = updateModernSettings +}) ---@type GameMode -local OnePlayerPuzzle = { +local OnePlayerPuzzle = GameMode({ -- flags for battleRoom to evaluate and in some cases offer UI for - style = Styles.MODERN, richPresenceLabel = "Puzzle", -- loc("mm_1_puzzle"), gameScene = "PuzzleGame", name = "puzzle", @@ -124,12 +192,13 @@ local OnePlayerPuzzle = { -- these are extended based on the loaded puzzle stackSetupModifications = {}, doCountdown = false, - } -} + }, + + updateLocalPlayersDerivedSettings = updateModernSettings +}) ---@type GameMode -local OnePlayerChallenge = { - style = Styles.MODERN, +local OnePlayerChallenge = GameMode({ gameScene = "Game1pChallenge", richPresenceLabel = "Challenge Mode", -- loc("mm_1_challenge_mode"), name = "challenge", @@ -144,12 +213,13 @@ local OnePlayerChallenge = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + + updateLocalPlayersDerivedSettings = updateModernSettings +}) ---@type GameMode -local TwoPlayerVersus = { - style = Styles.MODERN, +local TwoPlayerVersus = GameMode({ gameScene = "GameBase", richPresenceLabel = "2p versus", -- loc("mm_2_vs"), name = "VS", @@ -164,11 +234,12 @@ local TwoPlayerVersus = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true - } -} + }, + + updateLocalPlayersDerivedSettings = updateModernSettings +}) ---@type GameMode -local TwoPlayerTimeAttack = { - style = Styles.MODERN, +local TwoPlayerTimeAttack = GameMode({ gameScene = "TimeAttackGame", richPresenceLabel = "2p Time Attack", -- loc("mm_2_time"), name = "2p_timeattack", @@ -182,8 +253,10 @@ local TwoPlayerTimeAttack = { stackWinConditions = {}, stackSetupModifications = {}, doCountdown = true, - } -} + }, + + updateLocalPlayersDerivedSettings = updateModernSettings +}) GameModes.Styles = Styles GameModes.StackInteractions = StackInteractions @@ -213,4 +286,27 @@ function GameModes.getPreset(mode) return deepcpy(privateGameModes[mode]) end +-- Creates a GameMode from server message data by loading the preset and applying overrides +---@param gameModeData table The game mode data from the server message +---@return GameMode +function GameModes.createFromServerData(gameModeData) + local preset = nil + for _, gameMode in pairs(privateGameModes) do + if gameMode.name == gameModeData.name then + preset = gameMode + break + end + end + + assert(preset, "Unknown game mode name: " .. tostring(gameModeData.name)) + + local result = deepcpy(preset) + + for key, value in pairs(gameModeData) do + result[key] = value + end + + return result +end + return GameModes diff --git a/common/engine/Health.lua b/common/engine/Health.lua index 0d0ce4465..7728929e2 100644 --- a/common/engine/Health.lua +++ b/common/engine/Health.lua @@ -1,6 +1,7 @@ local logger = require("common.lib.logger") local consts = require("common.engine.consts") local class = require("common.lib.class") +local JsonSafePrecision = require("common.data.JsonSafePrecision") ---@class HealthSettings ---@field framesToppedOutToLose number Starting value of framesToppedOutToLose @@ -24,7 +25,7 @@ local Health = class( function(self, framesToppedOutToLose, lineClearGPM, height, riseSpeed) self.framesToppedOutToLose = framesToppedOutToLose self.maxSecondsToppedOutToLose = framesToppedOutToLose - self.lineClearRate = lineClearGPM / 60 + self.lineClearRate = JsonSafePrecision.toSafePrecision(lineClearGPM / 60) self.currentLines = 0 self.height = height self.lastWasFourCombo = false @@ -139,7 +140,7 @@ end function Health:getSettings() return { framesToppedOutToLose = self.maxSecondsToppedOutToLose, - lineClearGPM = self.lineClearRate * 60, + lineClearGPM = JsonSafePrecision.toSafePrecision(self.lineClearRate * 60), lineHeightToKill = self.height, riseSpeed = self.initialRiseSpeed } diff --git a/common/lib/util.lua b/common/lib/util.lua index 3801ccd03..78439b103 100644 --- a/common/lib/util.lua +++ b/common/lib/util.lua @@ -114,7 +114,9 @@ function real_deepcpy(tab) return setmetatable(ret, getmetatable(tab)) end --- copys the full variable deeply +-- Creates a deep copy of a table, recursively copying all nested tables +-- Preserves metatables and handles circular references +-- If the input is not a table, returns it unchanged ---@generic T ---@param tab T ---@return T deepCopy diff --git a/common/network/ClientProtocol.lua b/common/network/ClientProtocol.lua index 3ed87bf98..5f8d72a98 100644 --- a/common/network/ClientProtocol.lua +++ b/common/network/ClientProtocol.lua @@ -134,11 +134,13 @@ function ClientMessages.sendTaunt(direction, index) } end +---@param gameMode GameMode function ClientMessages.sendRoomRequest(gameMode) + local gameModeData = gameMode:getGameModeJSONData() local roomRequestMessage = { recipient = "server", type = "roomRequest", - content = { gameMode = gameMode } + content = { gameMode = gameModeData } } return { messageType = msgTypes.jsonMessage, diff --git a/server/Room.lua b/server/Room.lua index 87cc1d177..e2455db70 100644 --- a/server/Room.lua +++ b/server/Room.lua @@ -21,7 +21,7 @@ local ServerGame = require("server.Game") ---@field ratings table[] ratings by player number ---@field matchCount integer ---@field game ServerGame? ----@field gameMode GameMode +---@field gameMode table -- only the data portion of the game mode ---@field ranked boolean if the next match is anticipated to be ranked ---@field rankedReasons string[] ---@overload fun(roomNumber: integer, players: ServerPlayer[], gameMode: GameMode, leaderboard: Leaderboard?): Room @@ -29,7 +29,7 @@ local Room = class( ---@param self Room ---@param roomNumber integer ---@param players ServerPlayer[] ----@param gameMode GameMode +---@param gameMode table -- only the data portion of the game mode ---@param leaderboard Leaderboard? function(self, roomNumber, players, gameMode, leaderboard) self.players = players diff --git a/server/tests/ServerTests.lua b/server/tests/ServerTests.lua index e830581ab..17ea93d2c 100644 --- a/server/tests/ServerTests.lua +++ b/server/tests/ServerTests.lua @@ -269,7 +269,7 @@ local function testSinglePlayer() assert(message.type == "spectatorUpdate") message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectateRequestGranted" and message.content.replay == nil) - assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"))) + assert(tableUtils.deep_content_equal(message.content.gameMode, GameModes.getPreset("ONE_PLAYER_VS_SELF"):getGameModeJSONData())) message = alice.connection.outgoingMessageQueue:pop().messageText assert(message.type == "spectatorUpdate") diff --git a/testLauncher.lua b/testLauncher.lua index 119c52ef9..19393cec8 100644 --- a/testLauncher.lua +++ b/testLauncher.lua @@ -92,6 +92,7 @@ local allTests = { "client.tests.TcpClientTests", "client.tests.ThemeTests", "client.tests.StackGraphicsTests", + "client.tests.PlayerSettingsTests", } -- Check for specific test name argument From 08708de8a1d0551b9196e1d69d825a708a504a2a Mon Sep 17 00:00:00 2001 From: JamBox <8935453+JamesVanBoxtel@users.noreply.github.com> Date: Tue, 21 Oct 2025 21:53:45 -0700 Subject: [PATCH 2/2] Update style level and difficulty via preset lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI changes → setLevelData() → levelDataChanged signal → auto-updates style/difficulty/level/UI/config Each character select subclass knows what difficulty / level / style mean and update accordingly. The server doesn't need to know about style at all. LevelPresets.lua : Added classicEndless presets (5 colors for easy), getStyleAndPreset() to detect which preset a levelData matches This lets us support more presets later and update the UI to show something reasonable when level data doesn't match a preset. --- client/src/BattleRoom.lua | 30 +++------ client/src/ClientMatch.lua | 8 +-- client/src/Game.lua | 17 ++++-- client/src/Player.lua | 50 ++++----------- client/src/scenes/CharacterSelect.lua | 53 ++++++++++++++-- client/src/scenes/CharacterSelectVsSelf.lua | 13 ++-- client/src/scenes/EndlessGame.lua | 2 +- client/src/scenes/EndlessMenu.lua | 66 +++++++++++--------- client/src/scenes/ReplayBrowser.lua | 6 +- client/src/scenes/TimeAttackGame.lua | 2 +- client/src/scenes/TimeAttackMenu.lua | 65 +++++++++++--------- client/src/scenes/VsSelfGame.lua | 4 +- client/tests/PlayerSettingsTests.lua | 51 +++------------- common/data/GameModes.lua | 53 ---------------- common/data/LevelPresets.lua | 68 +++++++++++++++++++++ server/Game.lua | 2 + 16 files changed, 254 insertions(+), 236 deletions(-) diff --git a/client/src/BattleRoom.lua b/client/src/BattleRoom.lua index 1fa9b5a5a..03190c149 100644 --- a/client/src/BattleRoom.lua +++ b/client/src/BattleRoom.lua @@ -109,15 +109,8 @@ function BattleRoom.createFromServerMessage(message) p = Player(player.name, player.publicId or -i, false) end - -- Not great, but the server doesn't know about style for now - if player.settings.levelData then - if player.settings.levelData.frameConstants.GARBAGE_HOVER then - p:setStyle(GameModes.Styles.MODERN) - else - p:setStyle(GameModes.Styles.CLASSIC) - end - end - + -- updateSettings will set levelData which triggers levelDataChanged signal + -- which will automatically update style based on the levelData p:updateSettings(player.settings) if player.ratingInfo then @@ -143,16 +136,16 @@ end -- creates temporary local players that don't persist settings changes. ---@param gameMode GameMode The game mode configuration defining rules and player count ---@param gameScene table? Optional scene class to use for matches (defaults to mode's gameScene) ----@param settingCHangesUpdateConfig boolean? If true, setting changes update config (default: true). Only applies to single-player modes. +---@param settingChangesUpdateConfig boolean? If true, setting changes update config (default: true). Only applies to single-player modes. ---@return BattleRoom? battleRoom The created battle room, or nil if input configuration assignment fails -function BattleRoom.createLocalFromGameMode(gameMode, gameScene, settingCHangesUpdateConfig) - if settingCHangesUpdateConfig == nil then - settingCHangesUpdateConfig = true +function BattleRoom.createLocalFromGameMode(gameMode, gameScene, settingChangesUpdateConfig) + if settingChangesUpdateConfig == nil then + settingChangesUpdateConfig = true end local battleRoom = BattleRoom(gameMode, gameScene) - if settingCHangesUpdateConfig and gameMode.playerCount == 1 then + if settingChangesUpdateConfig and gameMode.playerCount == 1 then -- always use the game client's local player battleRoom:addPlayer(GAME.localPlayer) else @@ -271,15 +264,6 @@ function BattleRoom:addPlayer(player) end self.players[#self.players + 1] = player - if player.isLocal and player.human and self.mode.updateLocalPlayersDerivedSettings then - -- Initial update - self.mode.updateLocalPlayersDerivedSettings(player) - - -- Connect signals to update derived settings when style/difficulty - player:connectSignal("preferredStyleChanged", self.mode, function() self.mode.updateLocalPlayersDerivedSettings(player) end) - player:connectSignal("difficultyChanged", self.mode, function() self.mode.updateLocalPlayersDerivedSettings(player) end) - player:connectSignal("levelChanged", self.mode, function() self.mode.updateLocalPlayersDerivedSettings(player) end) - end if player.isLocal then self:connectSignal("allAssetsLoadedChanged", player, player.setLoaded) end diff --git a/client/src/ClientMatch.lua b/client/src/ClientMatch.lua index 8fb72fa95..dc55b77bd 100644 --- a/client/src/ClientMatch.lua +++ b/client/src/ClientMatch.lua @@ -396,10 +396,10 @@ function ClientMatch:finalizeReplay() ---@cast player Player metadata.name = player.name metadata.publicId = player.publicId - if player.settings.style == GameModes.Styles.MODERN then - metadata.level = player.settings.level - else - metadata.difficulty = player.settings.difficulty + if stack.level then + metadata.level = stack.level + elseif stack.difficulty then + metadata.difficulty = stack.difficulty end metadata.analytics = player.stack.analytic.data ---@diagnostic disable-next-line: inject-field diff --git a/client/src/Game.lua b/client/src/Game.lua index acad375ca..36eeffb4f 100644 --- a/client/src/Game.lua +++ b/client/src/Game.lua @@ -11,6 +11,7 @@ require("client.src.mods.Theme") -- Not to be confused with "Match" which is the current battle / instance of the game. local consts = require("common.engine.consts") local GraphicsUtil = require("client.src.graphics.graphics_util") +local LevelPresets = require("common.data.LevelPresets") local class = require("common.lib.class") local logger = require("common.lib.logger") local analytics = require("client.src.analytics") @@ -266,11 +267,17 @@ function Game:initializeLocalPlayer() self.localPlayer:connectSignal("difficultyChanged", config, function(config, difficulty) config.endless_difficulty = difficulty end) self.localPlayer:connectSignal("levelChanged", config, function(config, level) config.level = level end) self.localPlayer:connectSignal("wantsRankedChanged", config, function(config, wantsRanked) config.ranked = wantsRanked end) - self.localPlayer:connectSignal("preferredStyleChanged", config, function(config, style) - if style == GameModes.Styles.CLASSIC then - config.endless_level = nil - else - config.endless_level = config.level + + self.localPlayer:connectSignal("levelDataChanged", config, function(config, levelData, player) + local presetInfo = LevelPresets.getStyleAndPreset(levelData) + if presetInfo then + if presetInfo.style == GameModes.Styles.MODERN then + config.level = presetInfo.level + config.endless_level = presetInfo.level + else + config.endless_difficulty = presetInfo.difficulty + config.endless_level = nil + end end end) end diff --git a/client/src/Player.lua b/client/src/Player.lua index 5c0af3d40..375b5866e 100644 --- a/client/src/Player.lua +++ b/client/src/Player.lua @@ -20,7 +20,6 @@ local StackBehaviours = require("common.data.StackBehaviours") ---@field speed integer ---@field levelData LevelData ---@field style Styles ----@field preferredStyle Styles ---@field wantsRanked boolean ---@field inputMethod InputMethod @@ -54,7 +53,6 @@ function(self, name, publicId, isLocal) ---@type LevelData settings.levelData = LevelPresets.getModern(1) settings.style = GameModes.Styles.MODERN - settings.preferredStyle = GameModes.Styles.MODERN settings.characterId = "" settings.stageId = "" settings.panelId = "" @@ -79,7 +77,6 @@ function(self, name, publicId, isLocal) -- they can register a callback with each signal via Signal.connectSignal -- there are a few more signals in MatchParticipant (which is why we don't have to explicitly declare us as emitting Signals again) self:createSignal("styleChanged") - self:createSignal("preferredStyleChanged") self:createSignal("difficultyChanged") self:createSignal("startingSpeedChanged") self:createSignal("levelChanged") @@ -110,12 +107,19 @@ function Player:createClientStack(engineStack) player = self, } - if self.settings.style == GameModes.Styles.MODERN then - args.level = self.settings.level - else - args.difficulty = self.settings.difficulty + local presetInfo = LevelPresets.getStyleAndPreset(self.settings.levelData) + if presetInfo then + if presetInfo.style == GameModes.Styles.MODERN then + if presetInfo.level then + args.level = self.settings.level + end + else + if presetInfo.difficulty then + args.difficulty = self.settings.difficulty + end + end end - + self.stack = PlayerStack(args) return self.stack @@ -147,7 +151,7 @@ end function Player:setLevelData(levelData) self.settings.levelData = levelData self:setSpeed(levelData.startingSpeed) - self:emitSignal("levelDataChanged", levelData) + self:emitSignal("levelDataChanged", levelData, self) end function Player:setSpeed(speed) @@ -179,27 +183,11 @@ end -- derived settings (levelData) should be updated by calling gameMode.updateLocalPlayersDerivedSettings function Player:setStyle(style) if style ~= self.settings.style then - logger.debug("setting style " .. style) self.settings.style = style self:emitSignal("styleChanged", style) end end --- sets the preferred style when loading UI --- not sent over network --- 1 = classic --- 2 = modern --- style is a menu-only concept for UI display --- derived settings (levelData) should be updated by calling gameMode.updateLocalPlayersDerivedSettings -function Player:setPreferredStyle(preferredStyle) - if preferredStyle ~= self.settings.preferredStyle then - logger.debug("setting preferred style " .. preferredStyle) - self.settings.preferredStyle = preferredStyle - self:emitSignal("preferredStyleChanged", preferredStyle) - end -end - - function Player:setRating(rating) if self.rating and tonumber(self.rating) then -- only save a rating if we actually have one, tonumber assures that rating does not track placement progress instead @@ -257,10 +245,8 @@ function Player.createLocalPlayerFromConfig() player:setWantsRanked(config.ranked) player:setInputMethod(config.inputMethod) if config.endless_level then - player:setPreferredStyle(GameModes.Styles.MODERN) player:setStyle(GameModes.Styles.MODERN) else - player:setPreferredStyle(GameModes.Styles.CLASSIC) player:setStyle(GameModes.Styles.CLASSIC) player:setSpeed(config.endless_speed) end @@ -337,16 +323,6 @@ function Player:updateSettings(settings) end if settings.levelData ~= nil then - if settings.level ~= nil then - if settings.levelData.frameConstants.GARBAGE_HOVER then - self:setStyle(GameModes.Styles.MODERN) - self:setLevel(settings.level) - elseif settings.level <= LevelPresets.classicPresetCount then - self:setStyle(GameModes.Styles.CLASSIC) - self:setDifficulty(settings.level) - end - end - self:setLevelData(settings.levelData) end diff --git a/client/src/scenes/CharacterSelect.lua b/client/src/scenes/CharacterSelect.lua index a8caaea57..8ceac0802 100644 --- a/client/src/scenes/CharacterSelect.lua +++ b/client/src/scenes/CharacterSelect.lua @@ -4,11 +4,11 @@ local class = require("common.lib.class") local logger = require("common.lib.logger") local tableUtils = require("common.lib.tableUtils") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local Scene = require("client.src.scenes.Scene") local ui = require("client.src.ui") local GraphicsUtil = require("client.src.graphics.graphics_util") local Character = require("client.src.mods.Character") -local LevelPresets = require("common.data.LevelPresets") -- The character select screen scene ---@class CharacterSelect : Scene @@ -57,7 +57,47 @@ function CharacterSelect:load() self.ui.cursors = {} self.ui.characterIcons = {} self.ui.playerInfos = {} + self:customLoad() + + for _, player in ipairs(self.players) do + if player:isHuman() then + if player.isLocal then + self:initializeFromLocalPlayerSettings(player) + end + player:connectSignal("levelDataChanged", self, self.onLevelDataChanged) + self:onLevelDataChanged(player.settings.levelData, player) + end + end +end + +function CharacterSelect:onLevelDataChanged(levelData, player) + local presetInfo = LevelPresets.getStyleAndPreset(levelData) + + if not presetInfo then + -- Custom levelData, default to current settings + return + end + + if presetInfo.style == GameModes.Styles.MODERN then + player:setStyle(GameModes.Styles.MODERN) + if presetInfo.level then + player:setLevel(presetInfo.level) + end + else + player:setStyle(GameModes.Styles.CLASSIC) + if presetInfo.difficulty then + player:setDifficulty(presetInfo.difficulty) + end + end + + self:refresh() +end + +function CharacterSelect:initializeFromLocalPlayerSettings(player) + player:setStyle(GameModes.Styles.MODERN) + player:setLevel(player.settings.level) + player:setLevelData(LevelPresets.getModern(player.settings.level)) end ---@param player Player @@ -668,7 +708,7 @@ end ---@return BoolSelector styleSelector function CharacterSelect:createStyleSelection(player, width) local styleSelector = ui.BoolSelector({ - startValue = (player.settings.preferredStyle == GameModes.Styles.MODERN), + startValue = (player.settings.style == GameModes.Styles.MODERN), vFill = true, width = width, vAlign = "center", @@ -893,7 +933,7 @@ function CharacterSelect:createSpeedSlider(player, height, min) return uiElement end -function CharacterSelect:createDifficultyCarousel(player, height) +function CharacterSelect:createDifficultyCarousel(player, height, getPresetFunc) local passengers = { { id = 1, uiElement = ui.Label({text = "easy", vAlign = "center", hAlign = "center"})}, { id = 2, uiElement = ui.Label({text = "normal", vAlign = "center", hAlign = "center"})}, @@ -920,10 +960,15 @@ function CharacterSelect:createDifficultyCarousel(player, height) difficultyCarousel.onPassengerUpdateCallback = function(carousel, selectedPassenger) player:setDifficulty(selectedPassenger.id) + if getPresetFunc then + player:setLevelData(getPresetFunc(selectedPassenger.id)) + end GAME.theme:playMoveSfx() - self:refresh() end + -- to update the UI if code gets changed from the backend (e.g. network messages) + player:connectSignal("difficultyChanged", difficultyCarousel, difficultyCarousel.setPassengerById) + return difficultyCarousel end diff --git a/client/src/scenes/CharacterSelectVsSelf.lua b/client/src/scenes/CharacterSelectVsSelf.lua index dda09e6e3..59a22c562 100644 --- a/client/src/scenes/CharacterSelectVsSelf.lua +++ b/client/src/scenes/CharacterSelectVsSelf.lua @@ -85,14 +85,13 @@ function CharacterSelectVsSelf:refresh() local level if self.battleRoom then level = self.battleRoom.players[1].settings.level - else - level = GAME.localPlayer.settings.level + self.lastScore = GAME.scores:lastVsScoreForLevel(level) + self.record = GAME.scores:recordVsScoreForLevel(level) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end end - - self.lastScore = GAME.scores:lastVsScoreForLevel(level) - self.record = GAME.scores:recordVsScoreForLevel(level) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) end return CharacterSelectVsSelf \ No newline at end of file diff --git a/client/src/scenes/EndlessGame.lua b/client/src/scenes/EndlessGame.lua index 655bf50d9..cd12d3dcf 100644 --- a/client/src/scenes/EndlessGame.lua +++ b/client/src/scenes/EndlessGame.lua @@ -16,7 +16,7 @@ function EndlessGame:customLoad() end function EndlessGame:onMatchEnded(match) - if match.players[1].settings.style == GameModes.Styles.CLASSIC then + if match.players[1].stack.difficulty then GAME.scores:saveEndlessScoreForLevel(match.players[1].stack.engine.score, match.players[1].stack.difficulty) end end diff --git a/client/src/scenes/EndlessMenu.lua b/client/src/scenes/EndlessMenu.lua index b6e86354f..92bd9d967 100644 --- a/client/src/scenes/EndlessMenu.lua +++ b/client/src/scenes/EndlessMenu.lua @@ -1,6 +1,7 @@ local CharacterSelect = require("client.src.scenes.CharacterSelect") local class = require("common.lib.class") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local ui = require("client.src.ui") -- Scene for the endless game setup menu @@ -71,7 +72,7 @@ function EndlessMenu:loadUserInterface() }) self.ui.difficultySelection:setTitle("difficulty") - local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height) + local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height, LevelPresets.getClassicEndless) self.ui.difficultySelection:addElement(difficultyCarousel, player) self.ui.levelSelection = ui.MultiPlayerSelectionWrapper({hFill = true, alignment = "top", hAlign = "center", vAlign = "top"}) @@ -79,28 +80,14 @@ function EndlessMenu:loadUserInterface() local levelSlider = self:createLevelSlider(player, 20, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.levelSelection.height) self.ui.levelSelection:addElement(levelSlider, player) - if player.settings.style == GameModes.Styles.MODERN then - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - else - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - end - styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() if value and player.settings.style ~= GameModes.Styles.MODERN then - player:setPreferredStyle(GameModes.Styles.MODERN) - player:setStyle(GameModes.Styles.MODERN) - self.ui.grid:removeElementsIn(6, 2, 3, 1) - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - self.ui.recordBox:setVisibility(false) - elseif value == false and player.settings.preferredStyle ~= GameModes.Styles.CLASSIC then - player:setPreferredStyle(GameModes.Styles.CLASSIC) - player:setStyle(GameModes.Styles.CLASSIC) - self.ui.grid:removeElementsIn(6, 2, 3, 1) - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - self.ui.recordBox:setVisibility(true) + -- Set levelData for modern style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getModern(player.settings.level)) + elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then + -- Set levelData for classic endless style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getClassicEndless(player.settings.difficulty)) end end @@ -127,19 +114,42 @@ function EndlessMenu:loadUserInterface() self.ui.cursors[1].raise2Callback = function() self.ui.characterGrid:turnPage(1) end + + player:connectSignal("styleChanged", self, self.onStyleChanged) + self:onStyleChanged(player.settings.style, player) +end + +function EndlessMenu:onStyleChanged(style, player) + if style == GameModes.Styles.MODERN then + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) + self.ui.recordBox:setVisibility(false) + else + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) + self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) + self.ui.recordBox:setVisibility(true) + end +end + +function EndlessMenu:initializeFromLocalPlayerSettings(player) + if player.settings.style == GameModes.Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + else + player:setLevelData(LevelPresets.getClassicEndless(player.settings.difficulty)) + end end function EndlessMenu:refresh() - local difficulty if self.battleRoom then - difficulty = self.battleRoom.players[1].settings.difficulty - else - difficulty = GAME.localPlayer.settings.difficulty + local difficulty = self.battleRoom.players[1].settings.difficulty + self.lastScore = GAME.scores:lastEndlessForLevel(difficulty) + self.record = GAME.scores:recordEndlessForLevel(difficulty) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end end - self.lastScore = GAME.scores:lastEndlessForLevel(difficulty) - self.record = GAME.scores:recordEndlessForLevel(difficulty) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) end return EndlessMenu \ No newline at end of file diff --git a/client/src/scenes/ReplayBrowser.lua b/client/src/scenes/ReplayBrowser.lua index 30d42ad3f..14f0c8d11 100644 --- a/client/src/scenes/ReplayBrowser.lua +++ b/client/src/scenes/ReplayBrowser.lua @@ -223,8 +223,10 @@ function ReplayBrowser:draw() if player.level then GraphicsUtil.print(loc("rp_browser_info_level", player.level), menu_x + offsetX, menu_y + 95) else - GraphicsUtil.print(loc("rp_browser_info_speed", stack.levelData.startingSpeed), menu_x + offsetX, menu_y + 95) - GraphicsUtil.print(loc("rp_browser_info_difficulty", player.difficulty), menu_x + offsetX, menu_y + 110) + if player.difficulty then + GraphicsUtil.print(loc("rp_browser_info_speed", stack.levelData.startingSpeed), menu_x + offsetX, menu_y + 95) + GraphicsUtil.print(loc("rp_browser_info_difficulty", player.difficulty), menu_x + offsetX, menu_y + 110) + end end else ---@cast player SimulatedStackMetadata diff --git a/client/src/scenes/TimeAttackGame.lua b/client/src/scenes/TimeAttackGame.lua index 504f77767..1fd7af4a8 100644 --- a/client/src/scenes/TimeAttackGame.lua +++ b/client/src/scenes/TimeAttackGame.lua @@ -16,7 +16,7 @@ function TimeAttackGame:customLoad() end function TimeAttackGame:onMatchEnded(match) - if match.players[1].settings.style == GameModes.Styles.CLASSIC then + if match.players[1].stack.difficulty then GAME.scores:saveTimeAttack1PScoreForLevel(match.players[1].stack.engine.score, match.players[1].stack.difficulty) end end diff --git a/client/src/scenes/TimeAttackMenu.lua b/client/src/scenes/TimeAttackMenu.lua index 42dacabb2..1ab39f816 100644 --- a/client/src/scenes/TimeAttackMenu.lua +++ b/client/src/scenes/TimeAttackMenu.lua @@ -1,6 +1,7 @@ local class = require("common.lib.class") local CharacterSelect = require("client.src.scenes.CharacterSelect") local GameModes = require("common.data.GameModes") +local LevelPresets = require("common.data.LevelPresets") local ui = require("client.src.ui") -- Scene for the time attack game setup menu @@ -70,7 +71,7 @@ function TimeAttackMenu:loadUserInterface() }) self.ui.difficultySelection:setTitle("difficulty") - local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height) + local difficultyCarousel = self:createDifficultyCarousel(player, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.difficultySelection.height, LevelPresets.getClassic) self.ui.difficultySelection:addElement(difficultyCarousel, player) self.ui.levelSelection = ui.MultiPlayerSelectionWrapper({hFill = true, alignment = "top", hAlign = "center", vAlign = "top"}) @@ -78,28 +79,14 @@ function TimeAttackMenu:loadUserInterface() local levelSlider = self:createLevelSlider(player, 20, self.ui.grid.unitSize - self.ui.grid.unitMargin * 2 - self.ui.levelSelection.height) self.ui.levelSelection:addElement(levelSlider, player) - if player.settings.style == GameModes.Styles.MODERN then - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - else - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - end - styleSelector.onValueChange = function(boolSelector, value) GAME.theme:playValidationSfx() if value and player.settings.style ~= GameModes.Styles.MODERN then - player:setPreferredStyle(GameModes.Styles.MODERN) - player:setStyle(GameModes.Styles.MODERN) - self.ui.grid:removeElementsIn(6, 2, 3, 1) - self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) - self.ui.recordBox:setVisibility(false) + -- Set levelData for modern style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getModern(player.settings.level)) elseif value == false and player.settings.style ~= GameModes.Styles.CLASSIC then - player:setPreferredStyle(GameModes.Styles.CLASSIC) - player:setStyle(GameModes.Styles.CLASSIC) - self.ui.grid:removeElementsIn(6, 2, 3, 1) - self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) - self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) - self.ui.recordBox:setVisibility(true) + -- Set levelData for classic style - this will trigger levelDataChanged signal which updates UI + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) end end @@ -126,20 +113,42 @@ function TimeAttackMenu:loadUserInterface() self.ui.cursors[1].raise2Callback = function() self.ui.characterGrid:turnPage(1) end + + player:connectSignal("styleChanged", self, self.onStyleChanged) + self:onStyleChanged(player.settings.style, player) end -function TimeAttackMenu:refresh() - local difficulty - if self.battleRoom then - difficulty = self.battleRoom.players[1].settings.difficulty +function TimeAttackMenu:onStyleChanged(style, player) + if style == GameModes.Styles.MODERN then + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 3, 1, "levelSelection", self.ui.levelSelection, nil, true) + self.ui.recordBox:setVisibility(false) else - difficulty = GAME.localPlayer.settings.difficulty + self.ui.grid:removeElementsIn(6, 2, 3, 1) + self.ui.grid:createElementAt(6, 2, 2, 1, "speedSelection", self.ui.speedSelection, nil, true) + self.ui.grid:createElementAt(8, 2, 1, 1, "difficultySelection", self.ui.difficultySelection, nil, true) + self.ui.recordBox:setVisibility(true) end +end - self.lastScore = GAME.scores:lastTimeAttack1PForLevel(difficulty) - self.record = GAME.scores:recordTimeAttack1PForLevel(difficulty) - self.ui.recordBox:setLastResult(self.lastScore) - self.ui.recordBox:setRecord(self.record) +function TimeAttackMenu:initializeFromLocalPlayerSettings(player) + if player.settings.style == GameModes.Styles.MODERN then + player:setLevelData(LevelPresets.getModern(player.settings.level)) + else + player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) + end +end + +function TimeAttackMenu:refresh() + if self.battleRoom then + local difficulty = self.battleRoom.players[1].settings.difficulty + self.lastScore = GAME.scores:lastTimeAttack1PForLevel(difficulty) + self.record = GAME.scores:recordTimeAttack1PForLevel(difficulty) + if self.ui.recordBox then + self.ui.recordBox:setLastResult(self.lastScore) + self.ui.recordBox:setRecord(self.record) + end + end end return TimeAttackMenu \ No newline at end of file diff --git a/client/src/scenes/VsSelfGame.lua b/client/src/scenes/VsSelfGame.lua index b638a7345..c61dba5f1 100644 --- a/client/src/scenes/VsSelfGame.lua +++ b/client/src/scenes/VsSelfGame.lua @@ -16,7 +16,9 @@ end function VsSelfGame:onMatchEnded(match) local P1 = match.players[1].stack - GAME.scores:saveVsSelfScoreForLevel(P1.analytic.data.sent_garbage_lines, P1.level) + if P1.level then + GAME.scores:saveVsSelfScoreForLevel(P1.analytic.data.sent_garbage_lines, P1.level) + end end return VsSelfGame \ No newline at end of file diff --git a/client/tests/PlayerSettingsTests.lua b/client/tests/PlayerSettingsTests.lua index 447394f04..680b0dfa7 100644 --- a/client/tests/PlayerSettingsTests.lua +++ b/client/tests/PlayerSettingsTests.lua @@ -7,27 +7,18 @@ local BattleRoom = require("client.src.BattleRoom") local function testDifficultyCarouselShouldNotMutatePlayerOnCreation() local player = Player("TestPlayer", 1, true) - player:setStyle(GameModes.Styles.MODERN) - player:setDifficulty(1) - player:setLevel(10) - player:setLevelData(LevelPresets.getModern(10)) - - local originalLevel = player.settings.level - local originalDifficulty = player.settings.difficulty - local originalStyle = player.settings.style - local originalGarbageHover = player.settings.levelData.frameConstants.GARBAGE_HOVER - local originalColorCount = player.settings.levelData.colors - - assert(originalLevel == 10, "Initial level should be 10") - assert(originalStyle == GameModes.Styles.MODERN, "Initial style should be MODERN") - assert(originalGarbageHover == 4, "Initial GARBAGE_HOVER should be 4") - assert(originalColorCount == 6, "Initial color count should be 6") local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") local battleRoom = BattleRoom(gameMode) battleRoom:addPlayer(player) local characterSelect = CharacterSelect({battleRoom = battleRoom}) + + local originalLevel = player.settings.level + local originalDifficulty = player.settings.difficulty + local originalStyle = player.settings.style + local originalGarbageHover = player.settings.levelData.frameConstants.GARBAGE_HOVER + local originalColorCount = player.settings.levelData.colors local _ = characterSelect:createDifficultyCarousel(player, 100) @@ -42,28 +33,6 @@ end testDifficultyCarouselShouldNotMutatePlayerOnCreation() -local function testAllModernLevelsHaveGarbageHover() - for level = 1, 11 do - local levelData = LevelPresets.getModern(level) - assert(levelData.frameConstants.GARBAGE_HOVER ~= nil, "Modern level " .. level .. " should have GARBAGE_HOVER") - assert(levelData.frameConstants.GARBAGE_HOVER ~= nil, - "Modern level " .. level .. " GARBAGE_HOVER should not be nil") - end -end - -testAllModernLevelsHaveGarbageHover() - -local function testAllClassicLevelsLackGarbageHover() - for difficulty = 1, 4 do - local levelData = LevelPresets.getClassic(difficulty) - assert(levelData.frameConstants.GARBAGE_HOVER == nil, - "Classic difficulty " .. difficulty .. " should not have GARBAGE_HOVER but got " .. - tostring(levelData.frameConstants.GARBAGE_HOVER)) - end -end - -testAllClassicLevelsLackGarbageHover() - local function testEndlessModeClassicDifficulty1SetsCorrectSettings() local gameMode = GameModes.getPreset("ONE_PLAYER_ENDLESS") @@ -74,8 +43,7 @@ local function testEndlessModeClassicDifficulty1SetsCorrectSettings() local battleRoomPlayer = battleRoom.players[1] - battleRoomPlayer:setPreferredStyle(GameModes.Styles.CLASSIC) - battleRoomPlayer:setDifficulty(1) + battleRoomPlayer:setLevelData(LevelPresets.getClassicEndless(1)) assert(battleRoomPlayer.settings.levelData.colors == 5, "Endless mode with classic difficulty 1 should have 5 colors, but got " .. @@ -100,8 +68,7 @@ local function testVsSelfChangesEndlessClassicSettingsToModern() local endlessPlayer = endlessBattleRoom.players[1] -- Set to classic difficulty 1 (simulating what happens in endless mode) - endlessPlayer:setPreferredStyle(GameModes.Styles.CLASSIC) - endlessPlayer:setDifficulty(1) + endlessPlayer:setLevelData(LevelPresets.getClassicEndless(1)) -- Verify endless settings assert(endlessPlayer.settings.style == GameModes.Styles.CLASSIC, @@ -120,7 +87,7 @@ local function testVsSelfChangesEndlessClassicSettingsToModern() assert(vsSelfBattleRoom ~= nil, "Vs self BattleRoom should be created successfully") local vsSelfPlayer = vsSelfBattleRoom.players[1] - vsSelfPlayer:setLevel(10) + vsSelfPlayer:setLevelData(LevelPresets.getModern(10)) assert(vsSelfPlayer.settings.levelData.frameConstants.GARBAGE_HOVER ~= nil, "Modern style should have GARBAGE_HOVER set") diff --git a/common/data/GameModes.lua b/common/data/GameModes.lua index fc7193dfe..2e4efbc70 100644 --- a/common/data/GameModes.lua +++ b/common/data/GameModes.lua @@ -1,6 +1,5 @@ local class = require("common.lib.class") local MatchRules = require("common.data.MatchRules") -local LevelPresets = require("common.data.LevelPresets") local TIME_ATTACK_TIME = 120 local GameModes = {} @@ -44,50 +43,6 @@ local Styles = { CHOOSE = 0, CLASSIC = 1, MODERN = 2} ---@enum StackInteractions local StackInteractions = { NONE = 0, VERSUS = 1, SELF = 2, ATTACK_ENGINE = 3 } --- Updates player with modern-style level data based on their level setting ----@param player Player -local function updateModernSettings(player) - player:setStyle(Styles.MODERN) - player:setLevelData(LevelPresets.getModern(player.settings.level)) -end - --- Updates player with level data based on their style selection (modern or classic) ----@param player Player -local function updateStyleBasedSettings(player) - if player.settings.style ~= player.settings.preferredStyle then - player.settings.style = player.settings.preferredStyle - end - if player.settings.preferredStyle == Styles.MODERN then - player:setLevelData(LevelPresets.getModern(player.settings.level)) - elseif player.settings.preferredStyle == Styles.CLASSIC then - player:setLevelData(LevelPresets.getClassic(player.settings.difficulty)) - else - assert("Expected a set style") - end -end - --- Updates player with level data for endless mode, applying style-specific settings and endless easy difficulty adjustments ----@param player Player -local function updateEndlessStyleBasedSettings(player) - if player.settings.style ~= player.settings.preferredStyle then - player.settings.style = player.settings.preferredStyle - end - if player.settings.preferredStyle == Styles.MODERN then - player:setLevelData(LevelPresets.getModern(player.settings.level)) - elseif player.settings.preferredStyle == Styles.CLASSIC then - local levelData = LevelPresets.getClassic(player.settings.difficulty) - if player.settings.difficulty == 1 then - -- Endless easy uses 5 colors instead of 6 - levelData:setColorCount(5) - -- and allows adjacent panels of the same colors - levelData:setAdjacentDenialFrequency(0) - end - player:setLevelData(levelData) - else - assert("Expected a set style") - end -end - ---@type GameMode local OnePlayerVsSelf = GameMode({ gameScene = "VsSelfGame", @@ -106,7 +61,6 @@ local OnePlayerVsSelf = GameMode({ doCountdown = true, }, - updateLocalPlayersDerivedSettings = updateModernSettings }) ---@type GameMode @@ -127,7 +81,6 @@ local OnePlayerTimeAttack = GameMode({ doCountdown = true, }, - updateLocalPlayersDerivedSettings = updateStyleBasedSettings }) ---@type GameMode @@ -148,7 +101,6 @@ local OnePlayerEndless = GameMode({ doCountdown = true, }, - updateLocalPlayersDerivedSettings = updateEndlessStyleBasedSettings }) ---@type GameMode @@ -169,7 +121,6 @@ local OnePlayerTraining = GameMode({ doCountdown = true, }, - updateLocalPlayersDerivedSettings = updateModernSettings }) ---@type GameMode @@ -194,7 +145,6 @@ local OnePlayerPuzzle = GameMode({ doCountdown = false, }, - updateLocalPlayersDerivedSettings = updateModernSettings }) ---@type GameMode @@ -215,7 +165,6 @@ local OnePlayerChallenge = GameMode({ doCountdown = true, }, - updateLocalPlayersDerivedSettings = updateModernSettings }) ---@type GameMode @@ -236,7 +185,6 @@ local TwoPlayerVersus = GameMode({ doCountdown = true }, - updateLocalPlayersDerivedSettings = updateModernSettings }) ---@type GameMode local TwoPlayerTimeAttack = GameMode({ @@ -255,7 +203,6 @@ local TwoPlayerTimeAttack = GameMode({ doCountdown = true, }, - updateLocalPlayersDerivedSettings = updateModernSettings }) GameModes.Styles = Styles diff --git a/common/data/LevelPresets.lua b/common/data/LevelPresets.lua index fa49c68d8..380ac1aea 100644 --- a/common/data/LevelPresets.lua +++ b/common/data/LevelPresets.lua @@ -1,6 +1,7 @@ -- this file documents presets for level data local LevelData = require("common.data.LevelData") local JsonSafePrecision = require("common.data.JsonSafePrecision") +local GameModes = require("common.data.GameModes") ---@type LevelData[] local modern = {} @@ -326,4 +327,71 @@ end LevelPresets.classicPresetCount = #classic +---@type (table) +local classicEndless = {} +-- Deep copy from classic presets and modify only what's different for endless mode +classicEndless[1] = deepcpy(classic[1]) +-- Endless easy uses 5 colors instead of 6 +classicEndless[1]:setColorCount(5) +-- and allows adjacent panels of the same colors +classicEndless[1]:setAdjacentDenialFrequency(0) +classicEndless.easy = classicEndless[1] + +-- Normal, hard, and ex are identical to classic mode for endless +classicEndless[2] = deepcpy(classic[2]) +classicEndless.normal = classicEndless[2] + +classicEndless[3] = deepcpy(classic[3]) +classicEndless.hard = classicEndless[3] + +classicEndless[4] = deepcpy(classic[4]) +classicEndless.ex = classicEndless[4] + +---@param difficulty number | string the difficulty expressed as index 1 2 3 4 or easy normal hard ex +---@return LevelData # a deepcopy of the classic endless preset +function LevelPresets.getClassicEndless(difficulty) + assert(classicEndless[difficulty], "trying to load inexistent difficulty preset" .. difficulty) + return deepcpy(classicEndless[difficulty]) +end + +LevelPresets.classicEndlessPresetCount = #classicEndless + +---@class PresetInfo +---@field style Styles +---@field level integer? +---@field difficulty integer? +---@field isEndless boolean? + +---@param levelData LevelData +---@return PresetInfo? # style and preset information, or nil if levelData doesn't match any preset +function LevelPresets.getStyleAndPreset(levelData) + if not levelData then + return nil + end + + -- Check modern presets + for level = 1, #modern do + if LevelData.__eq(levelData, modern[level]) then + return {style = GameModes.Styles.MODERN, level = level, difficulty = nil, isEndless = false} + end + end + + -- Check classicEndless presets + for difficulty = 1, #classicEndless do + if LevelData.__eq(levelData, classicEndless[difficulty]) then + return {style = GameModes.Styles.CLASSIC, level = nil, difficulty = difficulty, isEndless = true} + end + end + + -- Check classic presets + for difficulty = 1, #classic do + if LevelData.__eq(levelData, classic[difficulty]) then + return {style = GameModes.Styles.CLASSIC, level = nil, difficulty = difficulty, isEndless = false} + end + end + + -- Doesn't match any preset - return nil + return nil +end + return LevelPresets \ No newline at end of file diff --git a/server/Game.lua b/server/Game.lua index b585da5c6..28f67a583 100644 --- a/server/Game.lua +++ b/server/Game.lua @@ -77,6 +77,8 @@ function Game.createFromRoomState(room) metadata.level = player.level else -- TODO: https://github.com/panel-attack/panel-game/issues/602 + -- Use this pattern when we are in this area again and testing server + -- local presetInfo = LevelPresets.getStyleAndPreset(levelData) metadata.difficulty = player.level end