diff --git a/compatibility/Preview/InitPreview.lua b/compatibility/Preview/InitPreview.lua index d1fcf106..e09995ea 100644 --- a/compatibility/Preview/InitPreview.lua +++ b/compatibility/Preview/InitPreview.lua @@ -26,7 +26,7 @@ function FN.PRE.start_new_coroutine() -- Defaults are vanilla: free preview, slow (5s) delay. -- Layers like pressure_timer set both via ruleset scalars. local timer_delay, timer_cost = 0, 0 - if MP.LOBBY and MP.LOBBY.code and not MP.is_pvp_boss() then + if MP.LOBBY and MP.LOBBY.code and MP.LOBBY.config.timer and not MP.is_pvp_boss() then timer_delay = 5 timer_cost = 0 @@ -50,6 +50,10 @@ function FN.PRE.start_new_coroutine() timer_cost > 0 and FN.PRE.data and not FN.PRE.data.empty and MP.LOBBY.code and not MP.is_pvp_boss() + and MP.LOBBY.config.timer + and not MP.GAME.timer_started + and not MP.GAME.nemesis_timer_started + and not MP.GAME.timer_consumed then MP.UI.consume_timer(timer_cost, nil, math.max(10, timer_cost)) end diff --git a/core.lua b/core.lua index 09a171ee..299b38a6 100644 --- a/core.lua +++ b/core.lua @@ -110,6 +110,10 @@ function MP.is_major_league_ruleset() return MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.ruleset == "ruleset_mp_majorleague" and MP.LOBBY.code end +function MP.current_ruleset() + return {} +end + function MP.load_mp_file(file) local chunk, err = SMODS.load_file(file, "Multiplayer") if chunk then @@ -292,7 +296,7 @@ MP.load_mp_file(networking_dir .. "/action_handlers.lua") MP.load_mp_dir("gamemodes") MP.load_mp_dir("layers") -MP.load_mp_dir("rulesets") +MP.load_mp_dir("rulesets", true) MP.load_mp_dir("ui", true) if MP.LOBBY.config.weekly then -- this could be a function but why bother diff --git a/layers/_layers.lua b/layers/_layers.lua index 7fb87ba9..9b416f54 100644 --- a/layers/_layers.lua +++ b/layers/_layers.lua @@ -25,10 +25,7 @@ end -- Build an mp_include closure that returns true iff any of the named layers is active. local function layer_membership_include(owning_layers) return function(_) - for _, layer_name in ipairs(owning_layers) do - if MP.is_layer_active(layer_name) then return true end - end - return false + return MP.is_any_layer_active(owning_layers) end end @@ -190,8 +187,16 @@ function MP.RunLayerHooks(hook_name) end function MP.is_layer_active(layer_name) + if not layer_name then return false end for _, name in ipairs(MP.active_layer_chain()) do if name == layer_name then return true end end return false end + +function MP.is_any_layer_active(layers) + for _, layer_name in pairs(layers) do + if MP.is_layer_active(layer_name) then return true end + end + return false +end \ No newline at end of file diff --git a/layers/no_anim_timer.lua b/layers/no_anim_timer.lua index a03d2d55..1a0d8983 100644 --- a/layers/no_anim_timer.lua +++ b/layers/no_anim_timer.lua @@ -1,5 +1,5 @@ MP.Layer("no_animation_timer", { preview_calculate_delay = 1.5, preview_calculate_cost = 3.5, - timer_base_multiplier = 4/5, + timer_base_multiplier = 2/3, }) diff --git a/layers/pressure_timer.lua b/layers/pressure_timer.lua index c366809d..e8fa334f 100644 --- a/layers/pressure_timer.lua +++ b/layers/pressure_timer.lua @@ -8,3 +8,7 @@ MP.Layer("pressure_timer", { timer_speedup_multiplier = 2, timer_base_multiplier = 2, }) + +MP.Layer("pressure_timer_plus", { + timer_hand_played_increment_seconds = 15 +}) \ No newline at end of file diff --git a/layers/pvp_timer.lua b/layers/pvp_timer.lua new file mode 100644 index 00000000..b0aa2282 --- /dev/null +++ b/layers/pvp_timer.lua @@ -0,0 +1,4 @@ +MP.Layer("pvp_timer", { + pvp_timer_base_seconds = 90, + pvp_timer_hand_played_increment_seconds = 15, +}) diff --git a/layers/ranked.lua b/layers/ranked.lua index 61d689f5..6f418123 100644 --- a/layers/ranked.lua +++ b/layers/ranked.lua @@ -1,6 +1,6 @@ MP.Layer("ranked", { forced_lobby_options = true, - is_disabled = function(self) + is_disabled = function(self) return MP.UTILS.check_smods_version() or MP.UTILS.check_lovely_version() end, force_lobby_options = function(self) diff --git a/lib/insane_int.lua b/lib/insane_int.lua index d96b2085..028eef85 100644 --- a/lib/insane_int.lua +++ b/lib/insane_int.lua @@ -51,6 +51,7 @@ end -- This doesn't really fit with the comment at the top, -- but I needed a way to compare highscores without storing this value seperately for no reason MP.INSANE_INT.greater_than = function(insane_int_display1, insane_int_display2) + if not insane_int_display1 or not insane_int_display2 then return false end if insane_int_display1.e_count ~= insane_int_display2.e_count then return tonumber(insane_int_display1.e_count) > tonumber(insane_int_display2.e_count) end @@ -62,6 +63,14 @@ MP.INSANE_INT.greater_than = function(insane_int_display1, insane_int_display2) return tonumber(insane_int_display1.coeffiocient) > tonumber(insane_int_display2.coeffiocient) end +MP.INSANE_INT.equal = function(insane_int_display1, insane_int_display2) + if not insane_int_display1 or not insane_int_display2 then return false end + if insane_int_display1.e_count ~= insane_int_display2.e_count then return false end + if insane_int_display1.exponent ~= insane_int_display2.exponent then return false end + if insane_int_display1.coeffiocient ~= insane_int_display2.coeffiocient then return false end + return true +end + -- ignore deprected warning for math.pow -- math.pow is used instead of ^ to avoid conflicts with talisman's __pow override -- theoretically the talisman override only applies to their special big number types and using '^' would be fine, diff --git a/lib/ruleset_utils.lua b/lib/ruleset_utils.lua index 7e7d147c..dd2db6d1 100644 --- a/lib/ruleset_utils.lua +++ b/lib/ruleset_utils.lua @@ -6,8 +6,20 @@ end -- (set by layers like pressure_timer). The multiplier is applied at timer-init sites, -- so the lobby UI keeps showing the unmultiplied base. function MP.UTILS.timer_base() - local base = MP.LOBBY.config.timer_base_seconds or 150 - local mult = MP.current_ruleset and MP.current_ruleset().timer_base_multiplier or 1 + local ruleset = MP.current_ruleset() + local base = MP.LOBBY.config.timer_base_seconds or ruleset.timer_base_seconds or 150 + local mult = ruleset.timer_base_multiplier or 1 + return base * mult +end + +-- Base PvP timer in seconds, accounting for the active ruleset's pvp_timer_base_multiplier +-- (set by layers like pvp_timer). The multiplier is applied at timer-init sites, +-- so the lobby UI keeps showing the unmultiplied base. +function MP.UTILS.pvp_timer_base() + if not MP.is_layer_active("pvp_timer") then return MP.UTILS.timer_base() end + local ruleset = MP.current_ruleset() + local base = MP.LOBBY.config.pvp_timer_base_seconds or ruleset.pvp_timer_base_seconds or 90 + local mult = ruleset.pvp_timer_base_multiplier or 1 return base * mult end diff --git a/localization/en-us.lua b/localization/en-us.lua index e34f380f..8c2da030 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -1286,7 +1286,23 @@ return { k_opts_showdown_starting_antes = "Showdown Starts at Ante", k_opts_pvp_timer_increment = "Timer Increment", k_opts_pvp_countdown_seconds = "PvP Countdown Seconds", - k_opts_modifier_timer = "Timer Modifier", + k_opts_modifier_timer = "Timer Implementation", + k_experimental_modifiers_timers = { + "- Default: regular {C:chips}150{} {C:inactive}(1x){} seconds timer", + " ", + "- No Animations: {C:chips}100{} {C:inactive}(0.67x){} seconds timer {C:attention}minus animations{}", + " ", + "- Pressure: {C:chips}300{} {C:inactive}(2x){} seconds timer {C:attention}minus animations{}", + " which starts {C:attention}immediately{}", + " ", + "- Pressure+: Same as {C:attention}Pressure{} plus {C:chips}15{} seconds per hand played", + }, + b_opts_modifier_pvp_timer = "PvP Timer", + k_experimental_modifiers_pvp_timer = { + "- Timer which available during {C:mult}PvP{} rounds.", + " {C:chips}90{} seconds plus {C:chips}15{} seconds per hand played {C:attention}minus animations{}.", + " Can \"timer\" opponent only when you have {C:attention}higher{} score" + }, k_bl_life = "Life", k_bl_or = "or", k_bl_death = "Death", @@ -1303,6 +1319,8 @@ return { k_experimental = "Experimental", k_experimental_standard = "Experimental (Standard)", k_experimental_description = "Standard's bleeding edge.\n\nHeavier balance changes being trialed\nfor a future Standard ruleset.\nExpect things to shift between versions.\n\n(See bans and reworks tabs for details)", + k_experimental_no_balance = "Experimental (No Balance)", + k_experimental_no_balance_description = "Standard's bleeding edge.\n\nNo balance changes, only new features.\nExpect things to shift between versions.", k_traditional = "Traditional", k_traditional_description = "Multiplayer content without time pressure.\n\nIncludes Multiplayer jokers and balance changes,\nbut removes time-based mechanics for methodical play.\n\nTime-based jokers are banned.\nTimer is disabled.\n\n(See bans and reworks tabs for details)", k_majorleague = "Major League", @@ -1334,7 +1352,7 @@ return { k_release = "Release Ver.", k_release_description = "Allan please add details", k_mp_ruleset_tab_general = "General", - k_mp_ruleset_tab_torunaments = "Tournaments", + k_mp_ruleset_tab_tournaments = "Tournaments", k_mp_ruleset_tab_experimental = "Experimental", k_cost_up = "Cost Up", k_destabilized = "Destabilized", @@ -1393,8 +1411,9 @@ return { }, ml_mp_modifier_timer_opt = { "Default", - "No Animation", + "No Animations", "Pressure", + "Pressure+", }, k_sc_title = "SHORTCUTS", k_sc_hint = "Press key or release TAB to close", diff --git a/networking/action_handlers.lua b/networking/action_handlers.lua index 7697b952..08dddffe 100644 --- a/networking/action_handlers.lua +++ b/networking/action_handlers.lua @@ -241,7 +241,6 @@ local function action_start_game(seed, stake_str) end G.FUNCS.lobby_start_run(nil, { seed = seed, stake = stake }) MP.LOBBY.ready_to_start = false - end local function begin_pvp_blind() @@ -252,13 +251,14 @@ local function begin_pvp_blind() end end -local function action_start_blind() +local function action_start_blind(first_player) MP.GAME.ready_blind = false - MP.GAME.pvp_reached = false + MP.GAME.pvp_reached = false MP.GAME.timer_started = false MP.GAME.nemesis_timer_started = false - MP.GAME.timer_consumed = false - MP.GAME.timer = MP.UTILS.timer_base() + MP.GAME.timer_consumed = false + MP.GAME.timer = MP.UTILS.pvp_timer_base() + MP.GAME.pvp_reached_first = (MP.LOBBY.is_host and "host" or "guest") == first_player MP.UI.start_pvp_countdown(begin_pvp_blind) end @@ -272,17 +272,21 @@ local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str local skips = tonumber(skips_str) local lives = tonumber(lives_str) - if MP.GAME.enemy.skips ~= skips then + -- No-animation timer: If opponent skip, add time immediately + if skips and MP.GAME.enemy.skips ~= skips then for i = 1, skips - MP.GAME.enemy.skips do MP.GAME.enemy.spent_in_shop[#MP.GAME.enemy.spent_in_shop + 1] = 0 - if - MP.GAME.enemy.skips < skips - and MP.is_layer_active("no_animation_timer") - and not MP.GAME.timer_started - and (MP.LOBBY.config.timer_increment_seconds or 0) > 0 - then - MP.GAME.timer = MP.GAME.timer + MP.LOBBY.config.timer_increment_seconds - end + if + MP.GAME.enemy.skips < skips + and MP.LOBBY.config.timer + and not MP.GAME.timer_started + and not MP.GAME.nemesis_timer_started + and not MP.GAME.timer_consumed + and MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) + and (MP.LOBBY.config.timer_increment_seconds or 0) > 0 + then + MP.UI.restore_timer(MP.LOBBY.config.timer_increment_seconds) + end end end @@ -293,6 +297,17 @@ local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str if MP.INSANE_INT.greater_than(score, MP.GAME.enemy.highest_score) then MP.GAME.enemy.highest_score = score end + -- PvP timer: stop timer according to score + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if MP.INSANE_INT.greater_than(MP.GAME.score, score) then + MP.GAME.nemesis_timer_started = false + elseif MP.INSANE_INT.equal(MP.GAME.score, score) and MP.GAME.pvp_reached_first then + MP.GAME.nemesis_timer_started = false + else + MP.GAME.timer_started = false + end + end + G.E_MANAGER:add_event(Event({ blockable = false, blocking = false, @@ -338,10 +353,10 @@ local function action_enemy_info(score_str, hands_left_str, skips_str, lives_str play_sound("holo1", 0.865, 0.9) play_sound("gong", 0.765, 0.4) end - if MP.GAME.enemy.skips < skips then - play_sound('negative', 0.865, 0.4) - play_sound("gong", 0.765, 0.4) - end + if MP.GAME.enemy.skips < skips then + play_sound("negative", 0.865, 0.4) + play_sound("gong", 0.765, 0.4) + end MP.GAME.enemy.hands = hands_left MP.GAME.enemy.skips = skips @@ -359,14 +374,22 @@ local function action_stop_game() MP.UTILS.emit_log_checksum() end -local function action_end_pvp() +local function action_end_pvp(lost, pvpTimerLost) + if lost and pvpTimerLost then + if G.GAME.current_round.hands_left > 0 then + stop_use() + SMODS.calculate_context({ mp_pvp_loss = true, mp_hands_left = G.GAME.current_round.hands_left }) + end + end MP.GAME.end_pvp = true MP.GAME.timer = MP.UTILS.timer_base() - MP.GAME.timer_consumed = false + MP.GAME.timer_consumed = false MP.GAME.timer_started = false MP.GAME.nemesis_timer_started = false MP.GAME.ready_blind = false - MP.GAME.pvp_reached = false + MP.GAME.pvp_reached = false + MP.GAME.pvp_reached_first = false + MP.GAME.score = nil end ---@param lives number @@ -867,29 +890,33 @@ local function action_start_ante_timer(time, from_nemesis) })) end end - -- Under pressure_timer the two players' local timers are intentionally desynced; - -- never overwrite ours from the network. - if not (MP.is_layer_active("pressure_timer") or MP.is_layer_active("no_animation_timer")) then + -- Old timer: sync timers between players + if + not MP.is_any_layer_active({ "pressure_timer", "no_animation_timer", MP.is_pvp_boss() and "pvp_timer" or nil }) + then if type(time) == "string" then time = tonumber(time) end if time then MP.GAME.timer = time end end - if from_nemesis then - MP.GAME.nemesis_timer_started = true - else - MP.GAME.timer_started = true - end + if from_nemesis then + MP.GAME.nemesis_timer_started = true + else + MP.GAME.timer_started = true + end end local function action_pause_ante_timer(time, from_nemesis) - if not (MP.is_layer_active("pressure_timer") or MP.is_layer_active("no_animation_timer")) then + -- Old timer: sync timers between players + if + not MP.is_any_layer_active({ "pressure_timer", "no_animation_timer", MP.is_pvp_boss() and "pvp_timer" or nil }) + then if type(time) == "string" then time = tonumber(time) end if time then MP.GAME.timer = time end end - if from_nemesis then - MP.GAME.nemesis_timer_started = false - else - MP.GAME.timer_started = false - end + if from_nemesis then + MP.GAME.nemesis_timer_started = false + else + MP.GAME.timer_started = false + end end -- #region Client to Server @@ -1019,9 +1046,22 @@ function MP.ACTIONS.play_hand(score, hands_left) fixed_score = string.gsub(fixed_score, ",", "") -- Remove commas local insane_int_score = MP.INSANE_INT.from_string(fixed_score) + MP.GAME.score = insane_int_score if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.highest_score) then MP.GAME.highest_score = insane_int_score end + + -- Stop PvP timers according to score + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if MP.INSANE_INT.greater_than(insane_int_score, MP.GAME.enemy.score) then + MP.GAME.nemesis_timer_started = false + elseif MP.INSANE_INT.equal(insane_int_score, MP.GAME.enemy.score) and MP.GAME.pvp_reached_first then + MP.GAME.nemesis_timer_started = false + else + MP.GAME.timer_started = false + end + end + Client.send({ action = "playHand", score = fixed_score, @@ -1174,6 +1214,11 @@ function MP.ACTIONS.fail_timer() action = "failTimer", }) end +function MP.ACTIONS.fail_pvp_timer() + Client.send({ + action = "failPvPTimer", + }) +end function MP.ACTIONS.sync_client() Client.send({ @@ -1295,13 +1340,13 @@ function Game:update(dt) elseif parsedAction.action == "startGame" then action_start_game(parsedAction.seed, parsedAction.stake) elseif parsedAction.action == "startBlind" then - action_start_blind() + action_start_blind(parsedAction.firstPlayer) elseif parsedAction.action == "enemyInfo" then action_enemy_info(parsedAction.score, parsedAction.handsLeft, parsedAction.skips, parsedAction.lives) elseif parsedAction.action == "stopGame" then action_stop_game() elseif parsedAction.action == "endPvP" then - action_end_pvp() + action_end_pvp(parsedAction.lost, parsedAction.pvpTimerLost) elseif parsedAction.action == "playerInfo" then action_player_info(parsedAction.lives) elseif parsedAction.action == "winGame" then diff --git a/objects/jokers/standard/ice_cream.lua b/objects/jokers/standard/ice_cream.lua new file mode 100644 index 00000000..591cb589 --- /dev/null +++ b/objects/jokers/standard/ice_cream.lua @@ -0,0 +1,23 @@ +local old_seltzer_calculate = G.P_CENTERS.j_ice_cream.calculate or function(self, card, context) end +SMODS.Joker:take_ownership("j_ice_cream", { + calculate = function(self, card, context) + if context.mp_pvp_loss and not context.blueprint then + local hands_decrease = context.mp_hands_left or 1 + local chips_decrease = card.ability.extra.chip_mod * hands_decrease + if card.ability.extra.chips - chips_decrease <= 0 then + SMODS.destroy_cards(card, nil, nil, true) + return { + message = localize('k_melted_ex'), + colour = G.C.CHIPS + } + else + card.ability.extra.chips = card.ability.extra.chips - chips_decrease + return { + message = localize { type = 'variable', key = 'a_chips_minus', vars = { chips_decrease } }, + colour = G.C.CHIPS + } + end + end + return old_seltzer_calculate(self, card, context) + end, +}, true) \ No newline at end of file diff --git a/objects/jokers/standard/seltzer.lua b/objects/jokers/standard/seltzer.lua index 5a96a2af..17fd10e8 100644 --- a/objects/jokers/standard/seltzer.lua +++ b/objects/jokers/standard/seltzer.lua @@ -16,15 +16,16 @@ SMODS.Joker({ if context.repetition and context.cardarea == G.play then return { repetitions = 1, } end - if context.after and not context.blueprint then - if card.ability.extra.hands_left - 1 <= 0 then + if (context.after or context.mp_pvp_loss) and not context.blueprint then + local hands_decrease = context.mp_pvp_loss and context.mp_hands_left or 1 + if card.ability.extra.hands_left - hands_decrease <= 0 then SMODS.destroy_cards(card, nil, nil, true) return { message = localize("k_drank_ex"), colour = G.C.FILTER, } else - card.ability.extra.hands_left = card.ability.extra.hands_left - 1 + card.ability.extra.hands_left = card.ability.extra.hands_left - hands_decrease return { message = card.ability.extra.hands_left .. "", colour = G.C.FILTER, @@ -33,3 +34,27 @@ SMODS.Joker({ end end, }) + + +local old_seltzer_calculate = G.P_CENTERS.j_selzer.calculate or function(self, card, context) end +SMODS.Joker:take_ownership("j_selzer", { + calculate = function(self, card, context) + if context.mp_pvp_loss and not context.blueprint then + local hands_decrease = context.mp_hands_left or 1 + if card.ability.extra - hands_decrease <= 0 then + SMODS.destroy_cards(card, nil, nil, true) + return { + message = localize("k_drank_ex"), + colour = G.C.FILTER, + } + else + card.ability.extra = card.ability.extra - hands_decrease + return { + message = card.ability.extra .. "", + colour = G.C.FILTER, + } + end + end + return old_seltzer_calculate(self, card, context) + end, +}, true) \ No newline at end of file diff --git a/rulesets/experimental/_modifiers_ui.lua b/rulesets/experimental/_modifiers_ui.lua new file mode 100644 index 00000000..46af46f8 --- /dev/null +++ b/rulesets/experimental/_modifiers_ui.lua @@ -0,0 +1,157 @@ +-- Modifier toggles render inline inside the ruleset info panel. The handlers +-- write MP.MODIFIERS directly (no network) — the host's lobby_options push at +-- start_lobby carries the serialized list to the guest. +local function timer_modifier_to_index() + if MP.has_modifier("pressure_timer_plus") then return 4 end + if MP.has_modifier("pressure_timer") then return 3 end + if MP.has_modifier("no_animation_timer") then return 2 end + return 1 +end + +-- Indices line up with localization ml_mp_modifier_timer_opt: 1=default, 2=no_anim, 3=pressure +G.FUNCS.change_modifier_timer = function(args) + MP.remove_modifier("no_animation_timer") + MP.remove_modifier("pressure_timer") + MP.remove_modifier("pressure_timer_plus") + if args.to_key == 2 then + MP.add_modifier("no_animation_timer") + elseif args.to_key == 3 then + MP.add_modifier("pressure_timer") + elseif args.to_key == 4 then + MP.add_modifier("pressure_timer") + MP.add_modifier("pressure_timer_plus") + end +end + +G.FUNCS.open_experimental_medifiers = function(e) + local timer_cycle = MP.UI.Disableable_Option_Cycle({ + id = "modifier_timer_option", + enabled_ref_table = { val = true }, + enabled_ref_value = "val", + label = localize("k_opts_modifier_timer"), + scale = 0.8, + options = localize("ml_mp_modifier_timer_opt"), + current_option = timer_modifier_to_index(), + opt_callback = "change_modifier_timer", + minw = 4, + w = 4, + }) + + -- local smallworld_toggle = create_toggle({ + -- id = "modifier_smallworld_toggle", + -- label = localize("b_opts_modifier_smallworld"), + -- ref_table = { val = MP.has_modifier("smallworld") }, + -- ref_value = "val", + -- callback = function(new_val) + -- if new_val then + -- MP.add_modifier("smallworld") + -- else + -- MP.remove_modifier("smallworld") + -- end + -- end, + -- }) + + local pvp_timer_toggle = create_toggle({ + id = "modifier_pvp_timer_toggle", + label = localize("b_opts_modifier_pvp_timer"), + ref_table = { val = MP.has_modifier("pvp_timer") }, + ref_value = "val", + callback = function(new_val) + if new_val then + MP.add_modifier("pvp_timer") + else + MP.remove_modifier("pvp_timer") + end + end, + }) + + local function create_entry(option, loc_key) + local message_table = localize(loc_key) + local result_text = {} + for _, line in ipairs(message_table) do + table.insert(result_text, { + n = G.UIT.R, + config = { minw = 8.5, maxw = 8.5 }, + nodes = SMODS.localize_box(loc_parse_string(line), { + default_col = G.C.UI.TEXT_LIGHT, + }), + }) + end + + return { + n = G.UIT.R, + config = { + padding = 0.25, + align = "cm", + r = 0.25, + colour = { 1, 1, 1, 0.1 }, + }, + nodes = { + { + n = G.UIT.C, + config = { minw = 5, align = "cm" }, + nodes = { + option, + }, + }, + { + n = G.UIT.C, + config = { align = "cm" }, + nodes = result_text, + }, + }, + } + end + + G.FUNCS.overlay_menu({ + definition = create_UIBox_generic_options({ + back_func = "create_lobby", + contents = { + { + n = G.UIT.R, + config = { align = "cm", padding = 0.25, colour = G.C.BLACK, r = 0.25 }, + nodes = { + { + n = G.UIT.R, + nodes = { + create_entry(timer_cycle, "k_experimental_modifiers_timers"), + { n = G.UIT.R, config = { minh = 0.25 } }, + create_entry(pvp_timer_toggle, "k_experimental_modifiers_pvp_timer"), + }, + }, + { n = G.UIT.R }, + MP.UI.get_continue_button(e.config.ref_table.ruleset, e.config.ref_table.mode), + }, + }, + }, + }), + }) +end + +G.UIDEF.mp_experimental_modifiers_ui = function(ruleset, mode) + return { + n = G.UIT.R, + config = { align = "cm" }, + nodes = { + MP.UI.Disableable_Button({ + button = "open_experimental_medifiers", + align = "cm", + padding = 0.05, + r = 0.1, + minw = 8, + minh = 0.8, + colour = G.C.ORANGE, + hover = true, + shadow = true, + label = { "Modifiers" }, + scale = 0.5, + enabled_ref_table = { val = true }, + enabled_ref_value = "val", + ref_table = { + ruleset = ruleset, + mode = mode, + }, + }), + }, + } +end diff --git a/rulesets/experimental.lua b/rulesets/experimental/experimental.lua similarity index 61% rename from rulesets/experimental.lua rename to rulesets/experimental/experimental.lua index 69efe3b8..3f7f5635 100644 --- a/rulesets/experimental.lua +++ b/rulesets/experimental/experimental.lua @@ -6,4 +6,8 @@ MP.Ruleset({ MP.LOBBY.config.the_order = true return false end, + hide_continue_button = true, + get_modifiers_ui = function(self, mode) + return G.UIDEF.mp_experimental_modifiers_ui(self, mode) + end, }):inject() diff --git a/rulesets/experimental_legacy.lua b/rulesets/experimental/experimental_legacy.lua similarity index 84% rename from rulesets/experimental_legacy.lua rename to rulesets/experimental/experimental_legacy.lua index 8c11930d..592a52d7 100644 --- a/rulesets/experimental_legacy.lua +++ b/rulesets/experimental/experimental_legacy.lua @@ -25,4 +25,8 @@ MP.Ruleset({ "j_mp_hanging_chad", "j_mp_lets_go_gambling", }, + hide_continue_button = true, + get_modifiers_ui = function(self, mode) + return G.UIDEF.mp_experimental_modifiers_ui(self, mode) + end, }):inject() diff --git a/rulesets/experimental/experimental_no_balance.lua b/rulesets/experimental/experimental_no_balance.lua new file mode 100644 index 00000000..b402bfbd --- /dev/null +++ b/rulesets/experimental/experimental_no_balance.lua @@ -0,0 +1,13 @@ +MP.Ruleset({ + key = "experimental_no_balance", + layers = { "standard" }, + forced_gamemode = "gamemode_mp_attrition", + force_lobby_options = function(self) + MP.LOBBY.config.the_order = true + return false + end, + hide_continue_button = true, + get_modifiers_ui = function(self, mode) + return G.UIDEF.mp_experimental_modifiers_ui(self, mode) + end, +}):inject() diff --git a/ui/game/functions.lua b/ui/game/functions.lua index 5793e4d2..6c2bd225 100644 --- a/ui/game/functions.lua +++ b/ui/game/functions.lua @@ -74,12 +74,18 @@ local skip_blind_ref = G.FUNCS.skip_blind G.FUNCS.skip_blind = function(e) skip_blind_ref(e) if MP.LOBBY.code then - -- pressure_timer applies pressure throughout the round, so skipping must not buy time. - if not (MP.is_layer_active("pressure_timer")) + -- Old timer: add time from skipping to own timer when not timered and not timering + if + MP.LOBBY.config.timer and not MP.GAME.timer_started - and (MP.LOBBY.config.timer_increment_seconds or 0) > 0 then - MP.GAME.timer = MP.GAME.timer + MP.LOBBY.config.timer_increment_seconds + and not MP.GAME.nemesis_timer_started + and not MP.GAME.timer_consumed + and not MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) + and (MP.LOBBY.config.timer_increment_seconds or 0) > 0 + then + MP.UI.restore_timer(MP.LOBBY.config.timer_increment_seconds) end + MP.ACTIONS.skip(G.GAME.skips) --Update the furthest blind diff --git a/ui/game/timer.lua b/ui/game/timer.lua index 3753ddcb..e3b4c659 100644 --- a/ui/game/timer.lua +++ b/ui/game/timer.lua @@ -1,18 +1,28 @@ -- ease_round override moved to game/round.lua +function MP.UI.cam_timer_opponent() + if not MP.LOBBY.config.timer then return false end + if MP.GAME.pvp_countdown_in_progress then return false end + if MP.is_pvp_boss() and MP.is_layer_active("pvp_timer") then + if G.STATE == G.STATES.ROUND_EVAL or G.STATE == G.STATES.NEW_ROUND then return false end + if MP.INSANE_INT.greater_than(MP.GAME.score, MP.GAME.enemy.score) then return true end + if MP.INSANE_INT.equal(MP.GAME.score, MP.GAME.enemy.score) then return MP.GAME.pvp_reached_first end + return false + end + return MP.GAME.ready_blind +end + function G.FUNCS.mp_timer_button(e) -- Under pressure_timer the local timer auto-ticks regardless of timer_started, -- but the button still needs to fire — pressing it broadcasts startAnteTimer, -- which is what flips the opponent's nemesis_timer_started and triggers 2x. - if MP.LOBBY.config.timer then - if MP.GAME.ready_blind then - if MP.GAME.timer <= 0 then - return - elseif not MP.GAME.timer_started then - MP.ACTIONS.start_ante_timer() - else - MP.ACTIONS.pause_ante_timer() - end + if MP.UI.cam_timer_opponent() then + if MP.GAME.timer <= 0 then + return + elseif not MP.GAME.timer_started then + MP.ACTIONS.start_ante_timer() + else + MP.ACTIONS.pause_ante_timer() end end end @@ -63,17 +73,23 @@ function MP.UI.timer_hud() n = G.UIT.O, config = { object = DynaText({ - string = MP.is_layer_active("speedlatro_timer") and ">>" - or { { ref_table = setmetatable({}, { - __index = function() - if not MP.GAME.timer then return 0 end - -- All numbers bigger then 10 - display as integer - -- Also accounting for rounding to prevent 10.0 to be displayed - if MP.GAME.timer > 9.95 then return string.format("%d", MP.GAME.timer) end - -- Less than 10 - display decimal part - return string.format("%.1f", MP.GAME.timer) - end, - }), ref_value = "timer" } }, -- sorry + string = MP.is_layer_active("speedlatro_timer") and ">>" or { + { + ref_table = setmetatable({}, { + __index = function() + if not MP.GAME.timer then return 0 end + -- All numbers bigger then 10 - display as integer + -- Also accounting for rounding to prevent 10.0 to be displayed + if MP.GAME.timer > 9.95 then + return string.format("%d", MP.GAME.timer) + end + -- Less than 10 - display decimal part + return string.format("%.1f", MP.GAME.timer) + end, + }), + ref_value = "timer", + }, + }, -- sorry colours = { G.C.UI.TEXT_DARK }, shadow = true, scale = 0.8, @@ -94,6 +110,7 @@ function MP.UI.start_pvp_countdown(callback) if MP.LOBBY and MP.LOBBY.config and MP.LOBBY.config.pvp_countdown_seconds then seconds = MP.LOBBY.config.pvp_countdown_seconds end + MP.GAME.pvp_countdown_in_progress = true MP.GAME.pvp_countdown = seconds G.CONTROLLER.locks.enter_pvp = true @@ -101,6 +118,19 @@ function MP.UI.start_pvp_countdown(callback) local function show_next() if MP.GAME.pvp_countdown <= 0 then if callback then callback() end + G.E_MANAGER:add_event(Event({ + blocking = false, + func = function() + G.E_MANAGER:add_event(Event({ + blocking = false, + func = function() + MP.GAME.pvp_countdown_in_progress = nil + return true + end, + })) + return true + end, + })) G.E_MANAGER:add_event(Event({ no_delete = true, trigger = "after", @@ -148,10 +178,9 @@ function MP.UI.start_pvp_countdown(callback) })) end - SMODS.Gradient({ key = "timer_accelerated", - cycle = 1, + cycle = 1, colours = { mix_colours(G.C.WHITE, G.C.IMPORTANT, 0.55), G.C.IMPORTANT, @@ -159,167 +188,231 @@ SMODS.Gradient({ G.C.IMPORTANT, G.C.IMPORTANT, }, - update = function(self, dt) - if #self.colours < 2 or not MP.LOBBY.config.ruleset then return end - local speedup = MP.GAME.timer_started and 1 or MP.current_ruleset().timer_speedup_multiplier or 1 - - -- When you "timering" opponent, timer stops and you cannot see is button pressed - -- So we need switch to real timer to make it flush - local time_value = MP.GAME.timer_started and G.TIMERS.REAL or -(MP.GAME.timer or 0) - local timer = (time_value / speedup)%self.cycle - local start_index = math.ceil(timer*#self.colours/self.cycle) - if start_index == 0 then start_index = 1 end - local end_index = start_index == #self.colours and 1 or start_index+1 - local start_colour, end_colour = self.colours[start_index], self.colours[end_index] - local partial_timer = (timer%(self.cycle/#self.colours))*#self.colours/self.cycle - for i = 1, 4 do - if self.interpolation == 'linear' then - self[i] = start_colour[i] + partial_timer*(end_colour[i]-start_colour[i]) - elseif self.interpolation == 'trig' then - self[i] = start_colour[i] + 0.5*(1-math.cos(partial_timer*math.pi))*(end_colour[i]-start_colour[i]) - end - end - end + update = function(self, dt) + if #self.colours < 2 or not MP.LOBBY.config.ruleset then return end + local speedup = MP.GAME.timer_started and 1 or MP.current_ruleset().timer_speedup_multiplier or 1 + + -- When you "timering" opponent, timer stops and you cannot see is button pressed + -- So we need switch to real timer to make it flush + local time_value = (MP.GAME.timer_started and G.TIMERS.REAL or -(MP.GAME.timer or 0)) + local timer = (time_value / speedup) % self.cycle + local start_index = math.ceil(timer * #self.colours / self.cycle) + if start_index == 0 then start_index = 1 end + local end_index = start_index == #self.colours and 1 or start_index + 1 + local start_colour, end_colour = self.colours[start_index], self.colours[end_index] + local partial_timer = (timer % (self.cycle / #self.colours)) * #self.colours / self.cycle + for i = 1, 4 do + if self.interpolation == "linear" then + self[i] = start_colour[i] + partial_timer * (end_colour[i] - start_colour[i]) + elseif self.interpolation == "trig" then + self[i] = start_colour[i] + + 0.5 * (1 - math.cos(partial_timer * math.pi)) * (end_colour[i] - start_colour[i]) + end + end + end, }) SMODS.Gradient({ key = "speedlatro_timer_accelerated", - cycle = 1, + cycle = 1, colours = { - G.C.WHITE, + G.C.WHITE, G.C.WHITE, G.C.WHITE, G.C.WHITE, mix_colours(G.C.IMPORTANT, G.C.WHITE, 0.55), }, - update = function(self, dt) - if #self.colours < 2 or not MP.speedlatro_timer then return end - local speedup = MP.current_ruleset().timer_speedup_multiplier or 1 - - local timer = MP.GAME.ready_blind and 0 or (-(MP.speedlatro_timer.real or 0) / speedup)%self.cycle - local start_index = math.ceil(timer*#self.colours/self.cycle) - if start_index == 0 then start_index = 1 end - local end_index = start_index == #self.colours and 1 or start_index+1 - local start_colour, end_colour = self.colours[start_index], self.colours[end_index] - local partial_timer = (timer%(self.cycle/#self.colours))*#self.colours/self.cycle - for i = 1, 4 do - if self.interpolation == 'linear' then - self[i] = start_colour[i] + partial_timer*(end_colour[i]-start_colour[i]) - elseif self.interpolation == 'trig' then - self[i] = start_colour[i] + 0.5*(1-math.cos(partial_timer*math.pi))*(end_colour[i]-start_colour[i]) - end - end - end + update = function(self, dt) + if #self.colours < 2 or not MP.speedlatro_timer then return end + local speedup = MP.current_ruleset().timer_speedup_multiplier or 1 + + local timer = MP.GAME.ready_blind and 0 or ((-(MP.speedlatro_timer.real or 0) / speedup) % self.cycle) + 0.5 + local start_index = math.ceil(timer * #self.colours / self.cycle) + if start_index == 0 then start_index = 1 end + local end_index = start_index == #self.colours and 1 or start_index + 1 + local start_colour, end_colour = self.colours[start_index], self.colours[end_index] + local partial_timer = (timer % (self.cycle / #self.colours)) * #self.colours / self.cycle + for i = 1, 4 do + if self.interpolation == "linear" then + self[i] = start_colour[i] + partial_timer * (end_colour[i] - start_colour[i]) + elseif self.interpolation == "trig" then + self[i] = start_colour[i] + + 0.5 * (1 - math.cos(partial_timer * math.pi)) * (end_colour[i] - start_colour[i]) + end + end + end, }) function G.FUNCS.set_timer_box(e) if MP.LOBBY.config.timer then if MP.GAME.timer_started or MP.GAME.nemesis_timer_started then e.config.colour = G.C.DYN_UI.BOSS_DARK - -- Pulse if it's pressure timer only - e.children[1].config.object.colours = { - MP.GAME.timer > 0 and (MP.is_layer_active("pressure_timer") or MP.is_layer_active("no_animation_timer") or MP.is_layer_active("speedlatro")) - and SMODS.Gradients["mp_timer_accelerated"] or G.C.IMPORTANT - } + -- Pulse because why not + e.children[1].config.object.colours = { + MP.GAME.timer > 0 and SMODS.Gradients["mp_timer_accelerated"] or G.C.IMPORTANT, + } return end - if not MP.GAME.timer_started and MP.GAME.ready_blind then + if not MP.GAME.timer_started and MP.UI.cam_timer_opponent() then e.config.colour = G.C.IMPORTANT e.children[1].config.object.colours = { G.C.UI.TEXT_LIGHT } return end e.config.colour = G.C.DYN_UI.BOSS_DARK - -- Attention text if pressure timer - e.children[1].config.object.colours = { MP.is_layer_active("pressure_timer") and not MP.is_pvp_boss() and G.C.IMPORTANT or G.C.UI.TEXT_DARK } + e.children[1].config.object.colours = + { + (not MP.is_pvp_boss() and not MP.GAME.pvp_countdown_in_progress and MP.is_layer_active("pressure_timer")) + and G.C.IMPORTANT or G.C.UI.TEXT_DARK + } end end local animation_budget_capacity = 40 local animation_budget_restore_rate = 2.5 -local animation_budget_decay_rate = 1 +local animation_budget_decay_rate = 0 + +MP.TIMER_ANIMATION_BUDGET = animation_budget_capacity local gameUpdateRef = Game.update ---@diagnostic disable-next-line: duplicate-set-field function Game:update(dt) - gameUpdateRef(self, dt) - - -- If I let timer tick only when we're in MP context - -- then big jump of dt will happend between state changes. - -- So we need count time all the time. Sad! - - -- Again, we cannot rely on any variant of dt since game does not - -- update at all while window is grabbed, - -- and when you release it dt does not reflect time wasted - - -- This thing cost NOTHING im comparision to game drawing and UI updating - -- We can afford some inefficiencies. - local new_time = love.timer.getTime() - local timer_dt = new_time - (MP.TIMER_CLOCK or new_time) - MP.TIMER_CLOCK = new_time - MP.TIMER_ANIMATION_BUDGET = math.min(animation_budget_capacity, (MP.TIMER_ANIMATION_BUDGET or animation_budget_capacity) + timer_dt * animation_budget_restore_rate) - MP.TIMER_FORCE_GAMESPEED = false - - -- Bail fast: not an MP PvP-timer context - if G.STATE == G.STATES.GAME_OVER then return end - if not MP.LOBBY.code then return end - if not MP.LOBBY.config.timer then return end - if MP.GAME.timer_consumed then return end - if not MP.GAME.timer or MP.GAME.timer <= 0 then return end - if MP.is_layer_active("speedlatro_timer") then return end - - - local is_no_animation_timer = MP.is_layer_active("no_animation_timer") - local is_pressure_timer = MP.is_layer_active("pressure_timer") - -- Tick gating differs by layer: - -- pressure_timer ON -> tick during regular play (not ready_blind, not pvp boss) - -- pressure_timer OFF -> tick whenever someone pressed a timer button. - -- timer_started = YOU pressed it; nemesis_timer_started = OPPONENT pressed it - -- (i.e. they're timering you). Either way your local timer should tick. - if is_pressure_timer or is_no_animation_timer then - if is_pressure_timer or (is_no_animation_timer and MP.GAME.nemesis_timer_started) then - MP.TIMER_FORCE_GAMESPEED = true - end - if MP.GAME.ready_blind or MP.is_pvp_boss() then return end - -- Tick when "unready" blind or old timer, and opponent "timering" you - if (MP.GAME.pvp_reached or is_no_animation_timer) and not MP.GAME.nemesis_timer_started then return end + gameUpdateRef(self, dt) - -- Don't tick during animations, unless the user is paused or has a menu open - local interactive = not (G.CONTROLLER.locked or (G.GAME.STOP_USE or 0) > 0) - local menu_or_paused = G.SETTINGS.paused or G.OVERLAY_MENU + -- If I let timer tick only when we're in MP context + -- then big jump of dt will happend between state changes. + -- So we need count time all the time. Sad! - if not (interactive or menu_or_paused) then - MP.TIMER_ANIMATION_BUDGET = math.max(0, MP.TIMER_ANIMATION_BUDGET - timer_dt * (animation_budget_restore_rate + animation_budget_decay_rate)) - if MP.TIMER_ANIMATION_BUDGET > 0 then return end - end - else - if not (MP.GAME.timer_started or MP.GAME.nemesis_timer_started) then return end - end + -- Again, we cannot rely on any variant of dt since game does not + -- update at all while window is grabbed, + -- and when you release it dt does not reflect time wasted - local speedup = MP.current_ruleset().timer_speedup_multiplier or 1 - local tick_mult = MP.GAME.nemesis_timer_started and speedup or 1 - MP.GAME.timer = math.max(0, MP.GAME.timer - timer_dt * tick_mult) + -- This thing cost NOTHING im comparision to game drawing and UI updating + -- We can afford some inefficiencies. + local new_time = love.timer.getTime() + local timer_dt = new_time - (MP.TIMER_CLOCK or new_time) + MP.TIMER_CLOCK = new_time - if MP.GAME.timer == 0 then - MP.GAME.timer_consumed = true - if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then - MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1 - else - MP.ACTIONS.fail_timer() - end - end + -- Remove gamespeed force setted up for previous frame + MP.TIMER_FORCE_GAMESPEED = false + + -- Restore animation budget (used to prevent skipping too much time by animations) + MP.TIMER_ANIMATION_BUDGET = + math.min(animation_budget_capacity, MP.TIMER_ANIMATION_BUDGET + timer_dt * animation_budget_restore_rate) + + -- Bail fast: not an MP PvP-timer context + if G.STATE == G.STATES.GAME_OVER then return end + if not MP.LOBBY.code then return end + if not MP.LOBBY.config.timer then return end + if MP.GAME.timer_consumed then return end + if not MP.GAME.timer or MP.GAME.timer <= 0 then return end + if MP.is_layer_active("speedlatro_timer") then return end + + local is_no_animation_timer = MP.is_layer_active("no_animation_timer") + local is_pressure_timer = MP.is_layer_active("pressure_timer") + local is_pvp_boss = MP.is_pvp_boss() + local is_pvp_timer = MP.is_layer_active("pvp_timer") and is_pvp_boss + + -- Tick gating differs by layer: + -- pressure_timer ON -> tick during regular play (not ready_blind, not pvp boss) + -- pressure_timer OFF -> tick whenever someone pressed a timer button. + -- timer_started = YOU pressed it; nemesis_timer_started = OPPONENT pressed it + -- (i.e. they're timering you). Either way your local timer should tick. + local should_check_animations = false + + if is_pvp_timer then + -- PvP timer: tick when opponent timering, stop when animations, state checks, pvp blind only + if not MP.GAME.nemesis_timer_started then return end + if G.GAME.current_round.hands_left <= 0 then return end + if G.STATE == G.STATES.NEW_ROUND or G.STATE == G.STATES.ROUND_EVAL then return end + should_check_animations = true + elseif is_pressure_timer then + -- Pressure timer: tick from the start of a game, stop when reached pvp (unless timered) or animations, not in pvp blind + if MP.GAME.pvp_reached and not MP.GAME.nemesis_timer_started then return end + if MP.GAME.ready_blind or is_pvp_boss then return end + should_check_animations = true + elseif is_no_animation_timer then + -- No-animation timer: tick when opponen timering, stop when animations, not in pvp + if not MP.GAME.nemesis_timer_started then return end + if MP.GAME.ready_blind or is_pvp_boss then return end + should_check_animations = true + else + -- Old timer: tick when opponent timering, not in pvp + if is_pvp_boss then return end + if not (MP.GAME.timer_started or MP.GAME.nemesis_timer_started) then return end + end + + if should_check_animations then + MP.TIMER_FORCE_GAMESPEED = true + + -- Don't tick during animations, unless the user is paused or has a menu open + local interactive = not ((G.CONTROLLER.locked and not G.CONTROLLER.locks.frame) or (G.GAME.STOP_USE or 0) > 0) + local menu_or_paused = G.SETTINGS.paused or G.OVERLAY_MENU + + -- Consume animations time from budget + if not (interactive or menu_or_paused) then + MP.TIMER_ANIMATION_BUDGET = math.max( + 0, + MP.TIMER_ANIMATION_BUDGET - timer_dt * (animation_budget_restore_rate + animation_budget_decay_rate) + ) + if MP.TIMER_ANIMATION_BUDGET > 0 then return end + end + end + + local speedup = is_pvp_timer and 1 or MP.current_ruleset().timer_speedup_multiplier or 1 + local tick_mult = MP.GAME.nemesis_timer_started and speedup or 1 + MP.GAME.timer = math.max(0, MP.GAME.timer - timer_dt * tick_mult) + + if MP.GAME.timer == 0 then + MP.GAME.timer_consumed = true + if is_pvp_timer then + -- PvP timer: end PvP immediately as a loss + MP.ACTIONS.fail_pvp_timer() + else + -- Old, No-animations, Pressure timers: lose a live + if MP.GAME.timers_forgiven < MP.LOBBY.config.timer_forgiveness then + MP.GAME.timers_forgiven = MP.GAME.timers_forgiven + 1 + else + MP.ACTIONS.fail_timer() + end + end + end end function MP.UI.consume_timer(amount, silent, min_timer) - if - amount > 0 - and MP.LOBBY.config.timer - and MP.GAME.timer - and MP.GAME.timer > (min_timer or 0) - then - MP.GAME.timer = math.max(0, MP.GAME.timer - amount) - if not silent then - local timer_ui = G.HUD:get_UIE_by_ID("timer_UI_count") - if timer_ui then - timer_ui.config.object:juice_up() + if amount > 0 and MP.LOBBY.config.timer and MP.GAME.timer and MP.GAME.timer > (min_timer or 0) then + MP.GAME.timer = math.max(0, MP.GAME.timer - amount) + if not silent then + local timer_ui = G.HUD:get_UIE_by_ID("timer_UI_count") + if timer_ui then timer_ui.config.object:juice_up() end + end + end +end + +function MP.UI.restore_timer(amount, silent, max_timer) + if amount > 0 and MP.LOBBY.config.timer and MP.GAME.timer and (not max_timer or MP.GAME.timer < max_timer) then + MP.GAME.timer = math.max(0, MP.GAME.timer + amount) + if not silent then + local timer_ui = G.HUD:get_UIE_by_ID("timer_UI_count") + if timer_ui then timer_ui.config.object:juice_up() end + end + end +end + +local old_play = G.FUNCS.play_cards_from_highlighted +function G.FUNCS.play_cards_from_highlighted(...) + old_play(...) + if G.play and G.play.cards[1] then return end + if MP.LOBBY.code and MP.LOBBY.config.timer and not MP.GAME.timer_consumed then + if MP.is_pvp_boss() then + -- PvP timer: Increment timer when hand is played during pvp + if MP.is_layer_active("pvp_timer") then + local increment = MP.LOBBY.config.pvp_timer_hand_played_increment_seconds or MP.current_ruleset().pvp_timer_hand_played_increment_seconds or 0 + MP.UI.restore_timer(increment) + end + else + -- No-animation, Pressure timers: Increment timer when hand is played during regular blinds + if MP.is_any_layer_active({ "no_animation_timer", "pressure_timer" }) then + local increment = MP.LOBBY.config.timer_hand_played_increment_seconds or MP.current_ruleset().timer_hand_played_increment_seconds or 0 + MP.UI.restore_timer(increment) end end end diff --git a/ui/lobby/_lobby_options/modifiers_tab.lua b/ui/lobby/_lobby_options/modifiers_tab.lua index 5722a3ad..f231cac1 100644 --- a/ui/lobby/_lobby_options/modifiers_tab.lua +++ b/ui/lobby/_lobby_options/modifiers_tab.lua @@ -5,7 +5,8 @@ G.FUNCS.change_starting_pvp_round = function(args) end G.FUNCS.change_timer_base_seconds = function(args) - MP.LOBBY.config.timer_base_seconds = tonumber(args.to_val:sub(1, -2)) + local mult = MP.LOBBY.config.timer_base_multiplier or MP.current_ruleset().timer_base_multiplier or 1 + MP.LOBBY.config.timer_base_seconds = tonumber(args.to_val:sub(1, -2)) / mult send_lobby_options() end @@ -25,6 +26,14 @@ G.FUNCS.change_pvp_countdown_seconds = function(args) end function MP.UI.create_gamemode_modifiers_tab() + local mult = MP.LOBBY.config.timer_base_multiplier or MP.current_ruleset().timer_base_multiplier or 1 + + local time_options = { 30, 60, 90, 120, 150, 180, 210, 240 } + local result_timer_options = {} + for i, option in ipairs(time_options) do + result_timer_options[i] = tostring(option * mult) .. "s" + end + return { n = G.UIT.ROOT, config = { @@ -45,8 +54,11 @@ function MP.UI.create_gamemode_modifiers_tab() "pvp_timer_seconds_option", "k_opts_pvp_timer", 0.85, - { "30s", "60s", "90s", "120s", "150s", "180s", "210s", "240s" }, - MP.LOBBY.config.timer_base_seconds / 30, + result_timer_options, + MP.UTILS.get_array_index_by_value( + time_options, + MP.LOBBY.config.timer_base_seconds + ), "change_timer_base_seconds" ), create_lobby_option_cycle( @@ -59,7 +71,7 @@ function MP.UI.create_gamemode_modifiers_tab() MP.LOBBY.config.timer_increment_seconds ), "change_timer_increment_seconds" - ), + ) or nil, create_lobby_option_cycle( "pvp_round_start_option", "k_opts_pvp_start_round", diff --git a/ui/main_menu/play_button/ruleset_selection.lua b/ui/main_menu/play_button/ruleset_selection.lua index 57545d83..8538956f 100644 --- a/ui/main_menu/play_button/ruleset_selection.lua +++ b/ui/main_menu/play_button/ruleset_selection.lua @@ -53,7 +53,7 @@ local rulesets_tabs = { }, }, { - name = "k_mp_ruleset_tab_torunaments", + name = "k_mp_ruleset_tab_tournaments", data = { { name = "k_tournament", @@ -79,6 +79,7 @@ local rulesets_tabs = { name = "k_experimental", buttons = { { button_id = "experimental_ruleset_button", button_localize_key = "k_experimental_standard" }, + { button_id = "experimental_no_balance_ruleset_button", button_localize_key = "k_experimental_no_balance" }, { button_id = "experimental_legacy_ruleset_button", button_localize_key = "k_experimental_legacy" }, }, }, @@ -193,33 +194,6 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) config = { align = "cm" }, }) - local ruleset_disabled = ruleset.is_disabled() - - -- Different button config for SP vs MP vs Practice - local button_config - if mode == "sp" then - button_config = { - id = "start_sp_button", - button = "start_sp_run", - label = { localize("b_play_cap") }, - colour = G.C.GREEN, - } - elseif mode == "practice" then - button_config = { - id = "start_practice_button", - button = "start_practice_run", - label = { localize("b_play_cap") }, - colour = G.C.GREEN, - } - else - button_config = { - id = "select_gamemode_button", - button = ruleset.forced_gamemode and "force_" .. ruleset.forced_gamemode or "select_gamemode", - label = { ruleset.forced_gamemode and localize("b_create_lobby") or localize("b_next") }, - colour = G.C.BLUE, - } - end - local content_nodes = { { n = G.UIT.R, @@ -272,31 +246,14 @@ function G.UIDEF.ruleset_info(ruleset_name, mode) } end - local show_modifiers = (mode == "mp" or mode == "practice") and not ruleset.forced_lobby_options - if show_modifiers then content_nodes[#content_nodes + 1] = MP.UI.build_modifier_row() end - content_nodes[#content_nodes + 1] = { - n = G.UIT.R, - config = { align = "cm" }, - nodes = { - MP.UI.Disableable_Button({ - id = button_config.id, - button = button_config.button, - align = "cm", - padding = 0.05, - r = 0.1, - minw = 8, - minh = 0.8, - colour = button_config.colour, - hover = true, - shadow = true, - label = button_config.label, - scale = 0.5, - enabled_ref_table = { val = not ruleset_disabled }, - enabled_ref_value = "val", - disabled_text = { ruleset_disabled }, - }), - }, - } + if (mode == "mp" or mode == "practice") then + local modifiers_row = MP.UI.build_modifier_row(ruleset, mode) + if modifiers_row then content_nodes[#content_nodes + 1] = modifiers_row end + end + + if not ruleset.hide_continue_button then + content_nodes[#content_nodes + 1] = MP.UI.get_continue_button(ruleset, mode) + end return { n = G.UIT.ROOT, @@ -719,60 +676,56 @@ function G.UIDEF.ruleset_cardarea_definition(args) end end --- Modifier toggles render inline inside the ruleset info panel. The handlers --- write MP.MODIFIERS directly (no network) — the host's lobby_options push at --- start_lobby carries the serialized list to the guest. -local function timer_modifier_to_index() - if MP.has_modifier("no_animation_timer") then return 2 end - if MP.has_modifier("pressure_timer") then return 3 end - return 1 +function MP.UI.build_modifier_row(ruleset, mode) + if type(ruleset.get_modifiers_ui) == "function" then return ruleset:get_modifiers_ui(mode) end end --- Indices line up with localization ml_mp_modifier_timer_opt: 1=default, 2=no_anim, 3=pressure -G.FUNCS.change_modifier_timer = function(args) - MP.remove_modifier("no_animation_timer") - MP.remove_modifier("pressure_timer") - if args.to_key == 2 then - MP.add_modifier("no_animation_timer") - elseif args.to_key == 3 then - MP.add_modifier("pressure_timer") +function MP.UI.get_continue_button(ruleset, mode) + local ruleset_disabled = ruleset.is_disabled() + local button_config + if mode == "sp" then + button_config = { + id = "start_sp_button", + button = "start_sp_run", + label = { localize("b_play_cap") }, + colour = G.C.GREEN, + } + elseif mode == "practice" then + button_config = { + id = "start_practice_button", + button = "start_practice_run", + label = { localize("b_play_cap") }, + colour = G.C.GREEN, + } + else + button_config = { + id = "select_gamemode_button", + button = ruleset.forced_gamemode and "force_" .. ruleset.forced_gamemode or "select_gamemode", + label = { ruleset.forced_gamemode and localize("b_create_lobby") or localize("b_next") }, + colour = G.C.BLUE, + } end -end - -function MP.UI.build_modifier_row() - local timer_cycle = MP.UI.Disableable_Option_Cycle({ - id = "modifier_timer_option", - enabled_ref_table = { val = true }, - enabled_ref_value = "val", - label = localize("k_opts_modifier_timer"), - scale = 0.6, - options = localize("ml_mp_modifier_timer_opt"), - current_option = timer_modifier_to_index(), - opt_callback = "change_modifier_timer", - }) - - local smallworld_proxy = { val = MP.has_modifier("smallworld") } - local smallworld_toggle = create_toggle({ - id = "modifier_smallworld_toggle", - label = localize("b_opts_modifier_smallworld"), - ref_table = smallworld_proxy, - ref_value = "val", - callback = function(new_val) - if new_val then - MP.add_modifier("smallworld") - else - MP.remove_modifier("smallworld") - end - end, - }) - return { n = G.UIT.R, - config = { align = "cm", padding = 0.05 }, + config = { align = "cm" }, nodes = { - timer_cycle, - { n = G.UIT.B, config = { w = 0.4, h = 0.1 } }, - smallworld_toggle, + MP.UI.Disableable_Button({ + id = button_config.id, + button = button_config.button, + align = "cm", + padding = 0.05, + r = 0.1, + minw = 8, + minh = 0.8, + colour = button_config.colour, + hover = true, + shadow = true, + label = button_config.label, + scale = 0.5, + enabled_ref_table = { val = not ruleset_disabled }, + enabled_ref_value = "val", + disabled_text = { ruleset_disabled }, + }), }, } end