diff --git a/assets/1x/j_balloons.png b/assets/1x/j_balloons.png new file mode 100644 index 00000000..4d543cc8 Binary files /dev/null and b/assets/1x/j_balloons.png differ diff --git a/assets/1x/j_dynamic_duo.png b/assets/1x/j_dynamic_duo.png new file mode 100644 index 00000000..5c4b883d Binary files /dev/null and b/assets/1x/j_dynamic_duo.png differ diff --git a/assets/1x/j_jester_in_yellow.png b/assets/1x/j_jester_in_yellow.png new file mode 100644 index 00000000..8c1d4d92 Binary files /dev/null and b/assets/1x/j_jester_in_yellow.png differ diff --git a/assets/1x/j_last_laugh.png b/assets/1x/j_last_laugh.png new file mode 100644 index 00000000..1e3b5c3e Binary files /dev/null and b/assets/1x/j_last_laugh.png differ diff --git a/assets/1x/j_line_cutter.png b/assets/1x/j_line_cutter.png new file mode 100644 index 00000000..ea132268 Binary files /dev/null and b/assets/1x/j_line_cutter.png differ diff --git a/assets/1x/j_occultist.png b/assets/1x/j_occultist.png new file mode 100644 index 00000000..9cd1a1ff Binary files /dev/null and b/assets/1x/j_occultist.png differ diff --git a/assets/2x/j_balloons.png b/assets/2x/j_balloons.png new file mode 100644 index 00000000..d923934f Binary files /dev/null and b/assets/2x/j_balloons.png differ diff --git a/assets/2x/j_dynamic_duo.png b/assets/2x/j_dynamic_duo.png new file mode 100644 index 00000000..369e2c49 Binary files /dev/null and b/assets/2x/j_dynamic_duo.png differ diff --git a/assets/2x/j_jester_in_yellow.png b/assets/2x/j_jester_in_yellow.png new file mode 100644 index 00000000..bf2918fd Binary files /dev/null and b/assets/2x/j_jester_in_yellow.png differ diff --git a/assets/2x/j_last_laugh.png b/assets/2x/j_last_laugh.png new file mode 100644 index 00000000..5ab29b98 Binary files /dev/null and b/assets/2x/j_last_laugh.png differ diff --git a/assets/2x/j_line_cutter.png b/assets/2x/j_line_cutter.png new file mode 100644 index 00000000..b9b3a065 Binary files /dev/null and b/assets/2x/j_line_cutter.png differ diff --git a/assets/2x/j_occultist.png b/assets/2x/j_occultist.png new file mode 100644 index 00000000..54599592 Binary files /dev/null and b/assets/2x/j_occultist.png differ diff --git a/localization/en-us.lua b/localization/en-us.lua index 927e7149..3d950a17 100644 --- a/localization/en-us.lua +++ b/localization/en-us.lua @@ -234,6 +234,70 @@ return { "not in a {C:attention}PvP Blind{}", }, }, + j_mp_last_laugh = { + name = "Last Laugh", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to destroy {C:attention}#1#{}", + "random cards in hand", + }, + }, + j_mp_line_cutter = { + name = "Line Cutter", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to create", + "a {C:attention}Voucher Tag{}", + }, + }, + j_mp_balloons = { + name = "Balloons", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to create a", + "random {C:attention}Tag{} for each", + "empty Joker slot", + "{C:inactive}(Max of {C:attention}#1#{} {C:inactive}Tags)", + }, + }, + j_mp_occultist = { + name = "Occultist", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to create a", + "{C:attention}Charm Tag{} for each", + "empty Joker slot", + "{C:inactive}(Max of {C:attention}#1#{} {C:inactive}Tags)", + }, + }, + j_mp_jester_in_yellow = { + name = "Jester in Yellow", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to add", + "{C:money}Rental{} to a random", + "{X:purple,C:white}Nemesis{} Joker", + }, + }, + j_mp_dynamic_duo = { + name = "Dynamic Duo", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to remove", + "{C:dark_edition}Editions{} and {C:attention}Stickers{}", + "from players' Jokers", + }, + }, + j_mp_dynamic_duo_overflow = { + name = "Dynamic Duo", + text = { + "Sell this card during", + "{C:attention}PvP Blind{} to remove", + "{C:dark_edition}Editions{} and {C:attention}Stickers{}", + "from players' Jokers", + "{C:inactive}(Cards can overflow)", + }, + }, j_mp_hanging_chad = { name = "Hanging Chad", text = { diff --git a/objects/jokers/_pvp_sell_utils.lua b/objects/jokers/_pvp_sell_utils.lua new file mode 100644 index 00000000..052ba46e --- /dev/null +++ b/objects/jokers/_pvp_sell_utils.lua @@ -0,0 +1,205 @@ +MP.PVP_SELL = MP.PVP_SELL or { handlers = {} } + +function MP.PVP_SELL.is_active() + return G and G.STAGE == G.STAGES.RUN and MP.is_pvp_boss() +end + +function MP.PVP_SELL.register(center_key, handler) + MP.PVP_SELL.handlers[center_key] = handler +end + +local function sold_phantom_queue() + MP.GAME = MP.GAME or {} + MP.GAME.pvp_sold_phantom_keys = MP.GAME.pvp_sold_phantom_keys or {} + return MP.GAME.pvp_sold_phantom_keys +end + +function MP.PVP_SELL.show_sold_phantom(center_key) + if not center_key or not (MP.LOBBY and MP.LOBBY.code) then return end + if not (MP.ACTIONS and MP.ACTIONS.send_phantom) then return end + table.insert(sold_phantom_queue(), center_key) + MP.ACTIONS.send_phantom(center_key) +end + +function MP.PVP_SELL.clear_sold_phantoms() + local queue = sold_phantom_queue() + if #queue < 1 then return end + if MP.LOBBY and MP.LOBBY.code and MP.ACTIONS and MP.ACTIONS.remove_phantom then + for _, center_key in ipairs(queue) do + MP.ACTIONS.remove_phantom(center_key) + end + end + MP.GAME.pvp_sold_phantom_keys = {} +end + +function MP.PVP_SELL.empty_joker_slots_after_sale(card) + if not (G and G.jokers and G.jokers.config and G.jokers.cards) then return 0 end + local joker_count = #G.jokers.cards + if card and card.area == G.jokers then joker_count = math.max(0, joker_count - 1) end + return math.max((G.jokers.config.card_limit or 0) - joker_count, 0) +end + +function MP.PVP_SELL.random_hand_cards(count, seed) + local picked = {} + if not (G and G.hand and G.hand.cards and #G.hand.cards > 0) then return picked end + local candidates = {} + for _, hand_card in ipairs(G.hand.cards) do candidates[#candidates + 1] = hand_card end + pseudoshuffle(candidates, pseudoseed(seed or "mp_pvp_sell_hand")) + for i = 1, math.min(count or 0, #candidates) do picked[#picked + 1] = candidates[i] end + return picked +end + +function MP.PVP_SELL.add_tag_with_sfx(tag_key) + add_tag(Tag(tag_key)) + play_sound("generic1", 0.9 + math.random() * 0.1, 0.8) + play_sound("holo1", 1.2 + math.random() * 0.1, 0.4) +end + +function MP.PVP_SELL.apply_rental(card) + if not card or card.REMOVED then return false end + card.ability = card.ability or {} + if card.ability.rental then return false end + if card.set_rental then + card:set_rental(true) + else + card.ability.rental = true + if card.set_cost then card:set_cost() end + end + card:juice_up(0.4, 0.5) + return true +end + +function MP.PVP_SELL.clear_editions_and_stickers(card) + if not card or card.REMOVED then return false end + local changed = false + + if card.edition then + if card.edition.type == "mp_phantom" and remove_phantom then remove_phantom(card) end + card:set_edition(nil, true, true) + changed = true + end + + card.ability = card.ability or {} + if card.ability.eternal then + if card.set_eternal then card:set_eternal(false) else card.ability.eternal = false end + changed = true + end + if card.ability.perishable or card.ability.perish_tally ~= nil + or card.ability.perishable_tally ~= nil or card.ability.perishable_rounds ~= nil then + -- Steamodded's Perishable sticker keeps its countdown in perish_tally. + -- Clear through the registered sticker first, then force-reset every + -- vanilla field so the badge/effect cannot survive on either client. + local perishable_sticker = SMODS.Stickers and SMODS.Stickers.perishable + if perishable_sticker and perishable_sticker.apply then + perishable_sticker:apply(card, false) + end + if card.set_perishable then card:set_perishable(false) end + card.ability.perishable = nil + card.ability.perish_tally = nil + card.ability.perishable_tally = nil + card.ability.perishable_rounds = nil + changed = true + end + if card.ability.rental then + if card.set_rental then card:set_rental(false) else card.ability.rental = false end + changed = true + end + + for _, key in ipairs({ + "mp_sticker_balanced", + "mp_sticker_extra_credit", + "mp_sticker_persistent", + "mp_sticker_unreliable", + "mp_sticker_draining", + "mp_sticker_nemesis", + }) do + if card.ability[key] then + local sticker = SMODS.Stickers and SMODS.Stickers[key] + if sticker and sticker.apply then sticker:apply(card, false) else card.ability[key] = false end + changed = true + end + end + + if changed then + if card.set_cost then card:set_cost() end + card:juice_up(0.25, 0.25) + end + return changed +end + +function MP.PVP_SELL.clear_area(area, excluded_card) + local changed = 0 + if area and area.cards then + for _, joker in ipairs(area.cards) do + if joker ~= excluded_card and MP.PVP_SELL.clear_editions_and_stickers(joker) then changed = changed + 1 end + end + end + return changed +end + +function MP.PVP_SELL.clear_nemesis_display_stickers() + local changed = 0 + if not (MP.shared and MP.shared.cards) then return changed end + for _, joker in ipairs(MP.shared.cards) do + if joker and joker.ability then + if joker.ability.rental then + if joker.set_rental then joker:set_rental(false) else joker.ability.rental = false end + changed = changed + 1 + end + -- Keep the Phantom edition/Nemesis marker: those identify the opponent's + -- display copy and are not actual modifiers on their owned Joker. + end + end + return changed +end + +local function apply_jester_rental_to_area(area, requested_index) + if not (area and area.cards and #area.cards > 0) then return false end + local target = requested_index and area.cards[requested_index] or nil + if target and not (target.ability and target.ability.rental) then return MP.PVP_SELL.apply_rental(target) end + local candidates = {} + for _, joker in ipairs(area.cards) do + if joker and not joker.REMOVED and not (joker.ability and joker.ability.rental) then candidates[#candidates + 1] = joker end + end + if #candidates < 1 then return false end + return MP.PVP_SELL.apply_rental(pseudorandom_element(candidates, pseudoseed("j_mp_jester_in_yellow_receive"))) +end + +MP.register_mod_action("pvp_jester_rental", function(payload) + apply_jester_rental_to_area(G.jokers, tonumber(payload and payload.index)) +end) + +MP.register_mod_action("pvp_dynamic_duo", function() + MP.PVP_SELL.clear_area(G.jokers) +end) + +function MP.PVP_SELL.send_to_nemesis(action, params) + if MP.LOBBY and MP.LOBBY.code and MP.ACTIONS and MP.ACTIONS.modded then + MP.ACTIONS.modded(MP.id or "Multiplayer", action, params) + end +end + +local sell_card_ref = Card.sell_card +function Card:sell_card(...) + local center_key = self.config and self.config.center and self.config.center.key + local handler = center_key and MP.PVP_SELL.handlers[center_key] + if handler and not self.mp_pvp_sell_triggered and MP.PVP_SELL.is_active() then + self.mp_pvp_sell_triggered = true + -- These six Jokers remain visible to the opponent as Phantom copies for + -- the rest of this PvP Blind, even though the original card was sold. + MP.PVP_SELL.show_sold_phantom(center_key) + local ok, err = pcall(handler, self) + if not ok then + sendErrorMessage("PvP sell Joker failed (" .. tostring(center_key) .. "): " .. tostring(err), "MULTIPLAYER") + end + end + return sell_card_ref(self, ...) +end + +-- Cash Out is the lifetime boundary for sold PvP Joker Phantoms. Sending one +-- removal per queued sale also handles multiple copies of the same Joker. +local cash_out_ref = G.FUNCS.cash_out +function G.FUNCS.cash_out(...) + MP.PVP_SELL.clear_sold_phantoms() + return cash_out_ref(...) +end diff --git a/objects/jokers/balloons.lua b/objects/jokers/balloons.lua new file mode 100644 index 00000000..7d51d463 --- /dev/null +++ b/objects/jokers/balloons.lua @@ -0,0 +1,56 @@ +SMODS.Atlas({ key = "balloons", path = "j_balloons.png", px = 71, py = 95 }) + +SMODS.Joker({ + key = "balloons", atlas = "balloons", rarity = 2, cost = 6, + unlocked = true, discovered = true, blueprint_compat = false, + eternal_compat = false, perishable_compat = true, + config = { extra = { max_tags = 3 } }, + loc_vars = function(self, info_queue, card) return { vars = { card.ability.extra.max_tags } } end, + mp_include = function(self) + return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_layer_active("sandbox") + end, + mp_credits = { art = { "Kars" } }, +}) + +local BALLOONS_VANILLA_TAGS = { + "tag_uncommon", + "tag_foil", + "tag_holo", + "tag_polychrome", + "tag_voucher", + "tag_standard", + "tag_charm", + "tag_meteor", + "tag_buffoon", + "tag_handy", + "tag_garbage", + "tag_ethereal", + "tag_coupon", + "tag_double", + "tag_d_six", + "tag_top_up", + "tag_skip", + "tag_orbital", + "tag_economy", +} + +MP.PVP_SELL.register("j_mp_balloons", function(card) + local count = math.min(card.ability.extra.max_tags, MP.PVP_SELL.empty_joker_slots_after_sale(card)) + if count < 1 then + card_eval_status_text(card, "extra", nil, nil, nil, { message = "No empty slots", colour = G.C.RED }) + return + end + local pool = {} + for _, tag_key in ipairs(BALLOONS_VANILLA_TAGS) do + -- Exact whitelist: custom Multiplayer/modded Tags can never enter the pool. + if G.P_TAGS and G.P_TAGS[tag_key] then pool[#pool + 1] = tag_key end + end + if #pool < 1 then return end + for i = 1, count do + local tag_key = pseudorandom_element(pool, pseudoseed("j_mp_balloons_" .. tostring(i))) + MP.PVP_SELL.add_tag_with_sfx(tag_key) + end + card_eval_status_text(card, "extra", nil, nil, nil, { + message = "+" .. tostring(count) .. " Tag" .. (count == 1 and "" or "s"), colour = G.C.FILTER, + }) +end) diff --git a/objects/jokers/dynamic_duo.lua b/objects/jokers/dynamic_duo.lua new file mode 100644 index 00000000..7a700594 --- /dev/null +++ b/objects/jokers/dynamic_duo.lua @@ -0,0 +1,33 @@ +SMODS.Atlas({ key = "dynamic_duo", path = "j_dynamic_duo.png", px = 71, py = 95 }) + +SMODS.Joker({ + key = "dynamic_duo", atlas = "dynamic_duo", rarity = 2, cost = 5, + unlocked = true, discovered = true, blueprint_compat = false, + eternal_compat = false, perishable_compat = true, + loc_vars = function(self, info_queue, card) + local has_negative = false + if G and G.jokers and G.jokers.cards then + for _, joker in ipairs(G.jokers.cards) do + if joker and joker.edition + and (joker.edition.negative or joker.edition.type == "negative") then + has_negative = true + break + end + end + end + return { key = has_negative and "j_mp_dynamic_duo_overflow" or "j_mp_dynamic_duo" } + end, + mp_include = function(self) + return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_layer_active("sandbox") + end, + mp_credits = { art = { "Kars" } }, +}) + +MP.PVP_SELL.register("j_mp_dynamic_duo", function(card) + local changed = MP.PVP_SELL.clear_area(G.jokers, card) + changed = changed + MP.PVP_SELL.clear_nemesis_display_stickers() + MP.PVP_SELL.send_to_nemesis("pvp_dynamic_duo") + card_eval_status_text(card, "extra", nil, nil, nil, { + message = changed > 0 and "Stripped!" or "No modifiers", colour = changed > 0 and G.C.FILTER or G.C.RED, + }) +end) diff --git a/objects/jokers/jester_in_yellow.lua b/objects/jokers/jester_in_yellow.lua new file mode 100644 index 00000000..bccd080d --- /dev/null +++ b/objects/jokers/jester_in_yellow.lua @@ -0,0 +1,33 @@ +SMODS.Atlas({ key = "jester_in_yellow", path = "j_jester_in_yellow.png", px = 71, py = 95 }) + +SMODS.Joker({ + key = "jester_in_yellow", atlas = "jester_in_yellow", rarity = 2, cost = 4, + unlocked = true, discovered = true, blueprint_compat = false, + eternal_compat = false, perishable_compat = true, + mp_include = function(self) + return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_layer_active("sandbox") + end, + mp_credits = { art = { "Kars" } }, +}) + +MP.PVP_SELL.register("j_mp_jester_in_yellow", function(card) + local area = MP.shared + if not (area and area.cards and #area.cards > 0) then + card_eval_status_text(card, "extra", nil, nil, nil, { message = "No Nemesis Joker", colour = G.C.RED }) + return + end + local candidates = {} + for i, joker in ipairs(area.cards) do + if joker and not joker.REMOVED and not (joker.ability and joker.ability.rental) then + candidates[#candidates + 1] = { index = i, card = joker } + end + end + if #candidates < 1 then + card_eval_status_text(card, "extra", nil, nil, nil, { message = "Already Rental", colour = G.C.RED }) + return + end + local selected = pseudorandom_element(candidates, pseudoseed("j_mp_jester_in_yellow")) + MP.PVP_SELL.apply_rental(selected.card) + MP.PVP_SELL.send_to_nemesis("pvp_jester_rental", { index = selected.index }) + card_eval_status_text(card, "extra", nil, nil, nil, { message = "Rental!", colour = G.C.MONEY }) +end) diff --git a/objects/jokers/last_laugh.lua b/objects/jokers/last_laugh.lua new file mode 100644 index 00000000..ac7b8ac1 --- /dev/null +++ b/objects/jokers/last_laugh.lua @@ -0,0 +1,33 @@ +SMODS.Atlas({ key = "last_laugh", path = "j_last_laugh.png", px = 71, py = 95 }) + +SMODS.Joker({ + key = "last_laugh", atlas = "last_laugh", rarity = 2, cost = 5, + unlocked = true, discovered = true, blueprint_compat = false, + eternal_compat = false, perishable_compat = true, + config = { extra = { destroy = 5 } }, + loc_vars = function(self, info_queue, card) return { vars = { card.ability.extra.destroy } } end, + mp_include = function(self) + return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_layer_active("sandbox") + end, + mp_credits = { art = { "Kars" } }, +}) + +MP.PVP_SELL.register("j_mp_last_laugh", function(card) + local targets = MP.PVP_SELL.random_hand_cards(card.ability.extra.destroy, "j_mp_last_laugh") + if #targets < 1 then + card_eval_status_text(card, "extra", nil, nil, nil, { message = "No cards", colour = G.C.RED }) + return + end + for _, target in ipairs(targets) do + G.E_MANAGER:add_event(Event({ func = function() + if target and not target.destroyed then + play_sound("tarot1") + target.T.r = -0.2 + target:juice_up(0.3, 0.4) + end + return true + end })) + end + SMODS.destroy_cards(targets) + card_eval_status_text(card, "extra", nil, nil, nil, { message = "Destroyed " .. tostring(#targets), colour = G.C.RED }) +end) diff --git a/objects/jokers/line_cutter.lua b/objects/jokers/line_cutter.lua new file mode 100644 index 00000000..e2c18aef --- /dev/null +++ b/objects/jokers/line_cutter.lua @@ -0,0 +1,20 @@ +SMODS.Atlas({ key = "line_cutter", path = "j_line_cutter.png", px = 71, py = 95 }) + +SMODS.Joker({ + key = "line_cutter", atlas = "line_cutter", rarity = 2, cost = 4, + unlocked = true, discovered = true, blueprint_compat = false, + eternal_compat = false, perishable_compat = true, + loc_vars = function(self, info_queue, card) + info_queue[#info_queue + 1] = { key = "tag_voucher", set = "Tag" } + return { vars = {} } + end, + mp_include = function(self) + return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_layer_active("sandbox") + end, + mp_credits = { art = { "Kars" }}, +}) + +MP.PVP_SELL.register("j_mp_line_cutter", function(card) + MP.PVP_SELL.add_tag_with_sfx("tag_voucher") + card_eval_status_text(card, "extra", nil, nil, nil, { message = "+1 Voucher Tag!", colour = G.C.FILTER }) +end) diff --git a/objects/jokers/occultist.lua b/objects/jokers/occultist.lua new file mode 100644 index 00000000..416f544d --- /dev/null +++ b/objects/jokers/occultist.lua @@ -0,0 +1,25 @@ +SMODS.Atlas({ key = "occultist", path = "j_occultist.png", px = 71, py = 95 }) + +SMODS.Joker({ + key = "occultist", atlas = "occultist", rarity = 2, cost = 6, + unlocked = true, discovered = true, blueprint_compat = false, + eternal_compat = false, perishable_compat = true, + config = { extra = { max_tags = 3 } }, + loc_vars = function(self, info_queue, card) + info_queue[#info_queue + 1] = { key = "tag_charm", set = "Tag" } + return { vars = { card.ability.extra.max_tags } } + end, + mp_include = function(self) + return MP.LOBBY.code and MP.LOBBY.config.multiplayer_jokers and not MP.is_layer_active("sandbox") + end, + mp_credits = { art = { "Kars" } }, +}) + +MP.PVP_SELL.register("j_mp_occultist", function(card) + local count = math.min(card.ability.extra.max_tags, MP.PVP_SELL.empty_joker_slots_after_sale(card)) + for i = 1, count do MP.PVP_SELL.add_tag_with_sfx("tag_charm") end + card_eval_status_text(card, "extra", nil, nil, nil, { + message = count > 0 and ("+" .. tostring(count) .. " Charm Tag" .. (count == 1 and "" or "s")) or "No empty slots", + colour = count > 0 and G.C.FILTER or G.C.RED, + }) +end)