Multi‐layer cards - Oinite12/tiwmig-mod GitHub Wiki

By default, cards in Balatro only have two layers: the main sprite, and the "soul" sprite, which is usually an icon that "floats" in front of the main sprite, and is exempt from edition shaders. TIWMIG adds a system that allows for more layers to be added:

  • above the main sprite, and before the "soul" sprite - These layers are subject to edition shaders
  • above the "soul" sprite - These layers are not subject to edition shaders

The system is implemented entirely in items/multi-layer cards.

Adding layers

When defining a Joker (or some other card), the pos table can take an additional key, layers, which is an ordered table that contains keyed tables of the form {x=#,y=#}, similar to the usual pos syntax. These layers are ordered from bottom to top.

The same principle applies to soul_pos.

The atlas defined in atlas will be used by all layers.

Technical details

DISCLAIMER: I dont have the fullest fuckin' idea on what most of this does. This is just my understanding of how the system works.

When setting the layer sprites, Card:set_sprites(), or an appending of it, is called on a card (hereby referred to as self). self contains a table children, which contains Sprite objects associated with self.

The appending to Card:set_sprites() adds several Sprites to children with keys of the form tiwmig_<type>_sprite_<id>, where id is an integer, and <type> is either "main" or "soul". (This key is defined by G_TWMG.layer_name(type, id) - thus from now on these Sprites will be referred to as such.) Specifically for each layer in pos.layers and soul_pos.layers, a sprite is added. And then something about role.draw_major and all that stuff, idk what they do but it's based on what's coded for vanilla background and soul sprites, so...

{...}
for i,coords in pairs(_center.<pos/soul_pos>.layers) do if i <= max_layers then
    self.children[G_TWMG.layer_name(type, i)] = Sprite(
        self.T.x, self.T.y, self.T.w, self.T.h,
        G.ASSET_ATLAS[_center.atlas or _center.set],
        coords
    )
    self.children[G_TWMG.layer_name(type, i)].role.draw_major = self
    self.children[G_TWMG.layer_name(type, i)].states.hover.can = false
    self.children[G_TWMG.layer_name(type, i)].states.click.can = false
end end
{...}

This alone does not suffice to render the additional layers; the layers may not follow the card itself, or the layers are rendered twice, with one instance not following the card itself; not to mention, the layers do not have that floaty animation that all cards have.

To resolve this, a DrawStep is added for main and soul layers. Both DrawSteps target each G_TWMG.layer_name(type, id) on self. More specifically, shaders are drawn for each G_TWMG.layer_name(type, id) that, at minimum, give the layers the floaty animation. The layer index (or #) is used in the calculation of the scale and rotation modifier (responsible for the floaty animation) so that each layer does not strictly follow the animation of the background sprite. For main layers, an additional shader is drawn for editions, while for soul layers, edition shaders are only drawn if the edition shader can be applied to floating sprites (i.e. Edition.apply_to_float is true).

SMODS.DrawStep({
{...}
local sprite = self.children[G_TWMG.layer_name(type, id)]
-- incorporate i to offset layers a bit so it's not all static
local scale_mod = 0.03 + (i/100)/2 + 0.02*math.sin(1.8*G.TIMERS.REAL)
local rotate_mod = 0.02*math.sin(1.219*G.TIMERS.REAL + (i-1)/2)
-- For soul layers, 0.03 -> 0.07, and 0.02 -> 0.05

sprite:draw_shader("dissolve",   0, nil, nil, self.children.center, scale_mod, rotate_mod, nil, 0.1 + 0.03*math.sin(1.8*G.TIMERS.REAL), nil, 0.6)
-- not sure why "dissolve" is called twice, but it's like that in vanilla
sprite:draw_shader("dissolve", nil, nil, nil, self.children.center, scale_mod, rotate_mod)

if self.edition then for k, v in pairs(G.P_CENTER_POOLS.Edition) do
    if self.edition[v.key:sub(3)] then
        sprite:draw_shader(v.shader, nil, nil, nil, self.children.center, scale_mod, rotate_mod)
    end
end end
{...}
})

Finally, each G_TWMG.layer_name(type, id) are added to the SMODS table SMODS.draw_ignore_keys. I'm not sure exactly what this table is for, but it does ultimately fix the non-following sprite problem.

for i = 1,max_layers do
    SMODS.draw_ignore_keys[G_TWMG.layer_name(type, id)] = true
end

This very step, however, requires each key to be specifically defined, and is not dynamic, i.e. dependent on the maximum number of layers of some Joker card. Because of this, a specific layer count limit needs to be defined, which, for TIWMIG, is G_TWMG.max_card_layers.

Acknowledgements

Much of the code responsible for multi-layer cards is based on the code for three-layer Jokers in lib/ui.lua and lib/misc.lua for the Cryptid mod, which are used in Exotic Jokers. This served as inspiration for me to create a system that adds an arbitrary number of layers to a card.

In turn, their code (and my code) is based on pre-existing game code from the vanilla game, used to render the main and soul sprites.

⚠️ **GitHub.com Fallback** ⚠️