Tiếp theo bài viết p1 (https://tulip4attoo.github.io/balatro-p1/), mình muốn đào sâu hơn về cách Balatro tạo ra và sử dụng joker trong game của mình, về mặt code.
Hệ thống card của Balatro
Trong Balatro, Card là 1 trong những cấu trúc dữ liệu chính. Card ngoài là Playing card, Joker, tarot card, còn là base cho voucher, booster.
Về cơ bản, các card được difine ở game.lua, trong đó Playing cards được define riêng, còn lại các card đặc biệt thì được nhét vào 1 bảng P_CENTERS:
self.P_CENTERS = {
c_base={max = 500, freq = 1, line = 'base', name = "Default Base", pos = {x=1,y=0}, set = "Default", label = 'Base Card', effect = "Base", cost_mult = 1.0, config = {}},
--Jokers
j_joker= {order = 1, unlocked = true, start_alerted = true, discovered = true, blueprint_compat = true, perishable_compat = true, eternal_compat = true, rarity = 1, cost = 2, name = "Joker", pos = {x=0,y=0}, set = "Joker", effect = "Mult", cost_mult = 1.0, config = {mult = 4}},
j_greedy_joker= {order = 2, unlocked = true, discovered = false, blueprint_compat = true, perishable_compat = true, eternal_compat = true, rarity = 1, cost = 5, name = "Greedy Joker", pos = {x=6,y=1}, set = "Joker", effect = "Suit Mult", cost_mult = 1.0, config = {extra = {s_mult = 3, suit = 'Diamonds'}}},
j_lusty_joker= {order = 3, unlocked = true, discovered = false, blueprint_compat = true, perishable_compat = true, eternal_compat = true, rarity = 1, cost = 5, name = "Lusty Joker", pos = {x=7,y=1}, set = "Joker", effect = "Suit Mult", cost_mult = 1.0, config = {extra = {s_mult = 3, suit = 'Hearts'}}},
j_wrathful_joker= {order = 4, unlocked = true, discovered = false, blueprint_compat = true, perishable_compat = true, eternal_compat = true, rarity = 1, cost = 5, name = "Wrathful Joker", pos = {x=8,y=1}, set = "Joker", effect = "Suit Mult", cost_mult = 1.0, config = {extra = {s_mult = 3, suit = 'Spades'}}},
j_gluttenous_joker= {order = 5, unlocked = true, discovered = false, blueprint_compat = true, perishable_compat = true, eternal_compat = true, rarity = 1, cost = 5, name = "Gluttonous Joker", pos = {x=9,y=1}, set = "Joker", effect = "Suit Mult", cost_mult = 1.0, config = {extra = {s_mult = 3, suit = 'Clubs'}}},
Mỗi joker, tarot, consumable, voucher,… đều được define ở P_CENTERS. Ta có thể dễ dàng đọc và hiểu phần lớn các option của mỗi cards. Chú ý có thể 1 số thứ không được dùng, hoặc mang ý nghĩa về mặt giúp dễ phân loại trong quá trình code (như effect = "Suit Mult").
Với Special Card (Joker/Tarot/Planet), flow tạo ra các card này là:
create_card(_type, area, legendary, _rarity, skip_materialize, soulable, forced_key)
Flow:
- Có thể force card cụ thể (forced_key)
- Hoặc random từ pool theo type + rarity
- Tạo Card object với center từ P_CENTERS
- Apply edition nếu có (holo/foil/polychrome/negative)
- Animation materialize
Cách tạo ra Joker instance
Về cơ bản thì cơ chế tạo các instance Joker là thế này:
Definition → Instance → State
┌─────────────────────────────────────────────────────────────────┐
│ P_CENTERS (Immutable) │
│ j_joker = { config = { mult = 4 }, blueprint_compat = true } │
└─────────────────────────────────────────────────────────────────┘
↓ reference
┌─────────────────────────────────────────────────────────────────┐
│ Card Object (Instance) │
│ self.config.center ──→ P_CENTERS.j_joker │
│ self.ability = { mult = 4, bonus = 0, ... } ← Mutable copy │
└─────────────────────────────────────────────────────────────────┘
Ý nghĩa:
- P_CENTERS = Template (không đổi)
- card.ability = State (có thể thay đổi trong game)
Tại sao cần State? Vì có rất nhiều joker sẽ thay đổi trong quá trình chơi (vd như Ceremonial Dagger hay hàng loạt các lá bài khác).
Ta không đi quá sâu vào hệ thống cards, hay việc truy xuất type của card như thế nào (ability.set), mình thấy việc này không cần thiết lắm.
CardArea - Quản Lý Nhóm Cards (cardarea.lua)
Cái này thật ra cũng không cần quá chú ý làm gì. Nhưng nếu như làm mod thì sẽ biết khá rõ, vì nó liên quan trực tiếp tới việc xử lý API những thành phần quan trọng
CardArea = Moveable:extend()
-- Các loại area:
G.deck -- Bộ bài
G.hand -- Bài trên tay
G.play -- Bài đang đánh
G.discard -- Bài đã bỏ
G.jokers -- Joker slots
G.consumeables -- Tarot/Planet/Spectral slots
G.shop -- Shop area
Key methods:
CardArea:emplace(card, location, stay_flipped) -- Thêm card
CardArea:remove_card(card) -- Xóa card
CardArea:shuffle(seed) -- Xáo bài
CardArea:draw_to_hand() -- Rút bài
Về logic effect của Joker
Card:calculate_joker(context) - Logic Effect (~1,770 dòng)
Ta đã nhắc khá nhiều về hàm này ở p1 và p2. Đây là god function xử lý toàn bộ Joker effects. Context là gì?
-- Các context khác nhau trigger các effect khác nhau:
{joker_main = true} -- Main scoring phase
{discard = true} -- Khi discard
{selling_self = true} -- Khi bán joker này
{selling_card = true} -- Khi bán card khác
{open_booster = true} -- Khi mở booster pack
{individual = true} -- Check từng card trong scoring hand
{end_of_round = true} -- Kết thúc round
Cấu trúc function:
function Card:calculate_joker(context)
if self.debuff then return nil end
-- Blueprint/Brainstorm: copy joker khác
if self.ability.name == "Blueprint" then
-- Tìm joker bên phải, gọi calculate_joker của nó
end
-- Dispatch theo context
if context.open_booster then
if self.ability.name == 'Hallucination' then
-- Tạo Tarot card ngẫu nhiên
end
elseif context.selling_self then
if self.ability.name == 'Luchador' then
-- Disable boss blind
end
if self.ability.name == 'Diet Cola' then
-- Thêm double tag
end
elseif context.discard then
if self.ability.name == 'Ramen' then ... end
if self.ability.name == 'Castle' then ... end
-- ... 20+ jokers
elseif context.joker_main then
if self.ability.name == 'Joker' then
return { mult_mod = self.ability.mult }
end
if self.ability.name == 'Greedy Joker' then
-- Count diamonds, add mult
end
-- ... 100+ jokers
end
end
Return value có thể gồm những phần như sau. Chú ý, nó chỉ return 1 phần nhỏ (vd, +mul) vì mỗi joker cũng không có quá nhiều thứ để thay đổi.
{
mult = 10, -- Cộng mult
mult_mod = 4, -- Cộng mult (alias)
Xmult_mod = 1.5, -- Nhân mult
x_mult = 2, -- Nhân mult (alias)
chips = 30, -- Cộng chips
message = "+4 Mult", -- Hiển thị UI
colour = G.C.MULT, -- Màu message
}
Flow Tổng Thể: Từ Definition → Scoring
1. Game Start
└─ load P_CENTERS, P_CARDS vào memory
2. Create Card
└─ create_card("Joker") hoặc create_playing_card()
└─ Card:init() → Card:set_ability() → copy ability từ P_CENTERS
3. Card vào CardArea
└─ CardArea:emplace(card) → thêm vào G.jokers / G.hand / etc.
4. Player chơi bài
└─ evaluate_play() (state_events.lua)
├─ get_poker_hand_info() → xác định hand type
├─ Loop: eval_card() cho từng scoring card
├─ Loop: eval_card() cho từng joker
│ └─ Card:calculate_joker(context)
│ └─ If-else chain → return {mult, chips, ...}
└─ Tổng hợp score → G.GAME.chips
Chú ý là cái logic và flow ở mục 4 đã rất là rõ ràng và tường minh. Điều này vô cùng quan trọng để tránh đi các vấn đề về loop hay lỗi logic khi xử lý score, trigger joker khi tính toán.
Hệ thống được thiết kế đơn giản nhưng hiệu quả
- Data-driven: Mọi card được định nghĩa trong P_CENTERS/P_CARDS
- Definition + Instance: Template bất biến + state có thể thay đổi
- Context-based evaluation: Joker effects trigger dựa trên game context
- God function: Tất cả joker logic nằm trong calculate_joker() ~1,770 dòng
- CardArea containers: Quản lý nhóm card theo chức năng
Chúng ta sẽ chú ý, ở đây, Balatro hoàn toàn không dùng pattern kiểu Components over Inheritance (nói chung là không dùng cả Inheritance và Components) trong việc tạo và kiểm soát Joker.
Cái mà joker “trả về” khi trigger chỉ là 1 table gồm 1 số data, như ta đã nói ở trên.
return {
mult_mod = 4, -- +mult
Xmult_mod = 1.5, -- xmult
chip_mod = 30, -- +chips
h_dollars = 3, -- +tiền
message = "+4 Mult", -- hiển thị UI
}
Đó chỉ là 1 table (dạng dạng dictionary trong python). Không có lớp lang, các attributes gì phức tạp, không có behavior, không có lifecycle, không có component nào attach vào card.
So sánh 1 cách đơn giản
Component pattern (kiểu Unity/ECS):
Card
├── MultModifier component (+4 mult)
├── TriggerCondition component (on_discard, on_score...)
├── SelfDestruct component (khi hết charges)
└── Animation component
Balatro thực tế:
Card
└── ability table (mấy con số)
└── calculate_joker() check tên → trả về mấy con số
Mọi joker dù phức tạp đến đâu, cuối cùng vẫn quy về một thời điểm, trả về một table số, rồi evaluate_play() cộng/nhân vào score. Không có component nào attach vào card, không có system nào tick qua component.
Kết luận
Hệ thống joker trong balatro hóa ra lại rất đơn giản, được implement 1 cách có phần lạ lẫm, thậm chí là kỳ cục, nhưng hóa ra lại làm việc rất tốt.
Việc các joker được xử lý tuần tự, kèm theo cơ chế animation queue (logic chạy trước, anim chạy sau) khiến cho ta gần như không care về perf trong logic lắm. Ngoài ra, do logic game, ta có thể thoải mái tính toán tuần tự mà không lo bị sót, hay có giá trị joker nào thay đổi real time cả (và thay đổi cũng không sao, ai chơi balatro cũng biết nếu thay đổi thứ tự joker trong khi tính điểm, nó sẽ thay đổi thứ order of execution. Nhưng đó là 1 intented bug và chẳng sao cả).
Từ đó, ta rút ra kết luận là nếu như có thể làm logic, như game của mình dự kiến là buff phase diễn ra trước combat, thì chẳng cần components gì cho căng, cứ code bình thường, chú ý logic tí thôi là ok rồi.
Vẫn rất mê cách làm rất đơn giản của balatro:
- define toàn bộ các special card 1 nơi (1 table)
- cho chạy trên context, tuần tự, cứ cho if-else chạy 1 lượt, tới trường hợp nào thì xử lý trường hợp đó.
- khó nghĩ nhất là có nên dùng god functions như balatro hay không?