开坑:小丑牌源代码解析与使用的算法

小丑牌基础信息:

相关信息 连接
编程语言 lua语言 https://www.lua.org/
运行引擎 love2d(开源) https://www.love2d.org/

参考资料

一个解决Lua 随机数生成问题的办法
树形图生成器

比较关注的重点:

  • 存储方面:小丑牌的数据结构,以及如何存储的
  • 逻辑方面:洗牌算法,种子码逻辑,小丑牌buff运行逻辑
  • ui方面:crt扫描特效,一回合大得分“生火”特效,小丑牌生效特效等
    👆这些都是一拍脑袋想出来的最想要了解的问题,也是我想学习的第一内容,不过组织不是很严谨
    更严谨的分析框架如下:
  1. 系统架构与模块划分
    • 命名空间管理
    • 依赖关系与通信
    • 生命周期分析等
  2. 资源管理
    • 数据结构:
    • 资产加载
    • 对象池与内存复用等
  3. 核心逻辑
    • 洗牌算法
    • 种子码管理
    • 增、减益与卡牌逻辑
  4. 输入与事件管理
    • 鼠标回调
    • 事件处理
  5. ui与特效
    • crt扫描特效:shader实现原理
    • 大得分特效
    • 卡牌生效特效
  6. 音频系统
    • 音效加载与转换
  7. 配置与脚本
    • 游戏配置保存

不过按不按这个方法分析就看我自己了(

part1.系统架构与模块划分

找到小丑牌源代码,你也能看

我们先来看看小丑牌的文件架构:

  • 导航到你的steam\steamapps\common\Balatro文件夹中,项目文件结构是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Balatro/ 
├── Balatro.exe
├── https.dll
├── license.txt
├── love.dll
├── lua51.dll
├── luasteam.dll
├── mpg123.dll
├── msvcp120.dll
├── msvcr120.dll
├── OpenAL32.dll
├── readme.txt
├── SDL2.dll
├── steam_api64.dll
└── steam_appid.txt

从上文可见,除了Balatro.exe其他的都是动态库或者声明等,所以我们将Balatro.exe直接解压到随意一个文件夹里,就能看见源代码组织了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
file/ 
├── engine/
│ ├── animatedsprite.lua
│ ├── controller.lua
│ ├── event.lua
│ ├── http_manager.lua
│ ├── moveable.lua
│ ├── node.lua
│ ├── object.lua
│ ├── particals.lua
│ ├── profile.lua
│ ├── save_manager.lua
│ ├── sound_manager.lua
│ ├── sprite.lua
│ ├── string_packer.lua
│ ├── text.lua
│ └── ui.lua
├── functions/
│ ├── button_callbacks.lua
│ ├── common_events.lua
│ ├── misc_functions.lua
│ ├── state_events.lua
│ ├── test_funtions.lua
│ └── UI_definitions.lua
├── localization/
│ ├── en-us
│ ├── ...
│ └── zh_CN
├── resources/
│ ├── fonts/
│ │ └── ...
│ ├── shaders/
│ │ └── ...
│ ├── sounds/
│ │ └── ...
│ ├── textures/
│ │ └── ...
│ └── gamecontrollerdb.txt
├── back.lua
├── blind.lua
├── card.lua
├── card_character.lua
├── cardarea.lua
├── challenges.lua
├── conf.lua
├── game.lua
├── globals.lua
├── main.lua
├── tag.lua
└── version.jkr

小丑牌的游戏入口是main.lua,engine文件夹里存放着引擎逻辑,而resources则是存放着游戏资源,小丑牌的shader,游戏卡面,以及音乐都在这里

总体的文件结构就是如上,目前来看还是容易区分的
让我们迫不及待的看代码吧!

状态与记忆的最初:全局变量——globals.lua

在阅览其他部分代码时,我发现一个关键——一个总类G——很多关键的变量都是这个类的成员,即使命名很直观,但是不理解全部的类有什么,代码的阅读也很难推进。

而且在阅读的时候,我也有些很惊讶的发现。

btw,globals.lua有很多变量,具体的这里就不一一讲解了,这里就过一遍逻辑以及一些重要的变量。

逻辑上,globals.lua先定义了Game类,然后再创建了方法set_globals()再在该方法里完成基础变量的创建与设置。基础变量大类包括(按代码排列顺序):平台特色,时间变量,设置变量,配置变量,渲染比例变量,游戏状态变量,实例变量,全局常数,颜色常数,以及一些枚举常数。

在globals.lua最后,将Game类实例化成G:

1
G = Game()

在平台特色变量设置里,完成对不同平台的特色设置,例如在手机端的小丑牌就没有退出游戏键,而电脑端就有,这部分通过love引擎的love.system.getOS()方法获得平台字符串从而匹配特色设置,以下是部分代码示例(虽然这部分好像没有什么区别):

1
2
3
4
5
6
7
8
9
10
11
12
13
if love.system.getOS() == 'Windows' then
        self.F_DISCORD = true
        self.F_SAVE_TIMER = 5
        self.F_ENGLISH_ONLY = false
        self.F_CRASH_REPORTS = false
    end
   
if love.system.getOS() == 'OS X' then
        self.F_SAVE_TIMER = 5
        self.F_DISCORD = true
        self.F_ENGLISH_ONLY = false
        self.F_CRASH_REPORTS = false
    end

命名规则是全大写,F开头代表Feature,后跟描述,下划线连接

而在时间变量这一块,则就变得不一样了,最重要的变量设置:

1
self.SEED = os.time()

简单讲,具体怎么伪随机怎么算就先略了。这里显示的种子码就是os方法获得的时间戳,会获得一个类似于1751901072的时间戳,具体是获取当前的时间,变换时区获得utc时间,计算1970年1月1日到今天的总天数,变成秒,再将当天的时分秒加上,就得到了unix时间戳

该格式与传奇钢K种子9OUU79格式相去甚远,等待更进一步的发掘


2025.9.1

在game.lua的Game:update(dt),也就是游戏的主体函数里, 通过self.STATE实现了游戏状态机的转移:

1
2
3
if self.STATE == self.STATES.SHOP then
self:update_shop(dt)
end

而在game.lua中找到Game:update_shop(dt),可以找到这样的封装函数:

1
2
3
4
5
6
7
8
if G.load_shop_jokers then
--从G.shop_jokers.cards池子里获取并渲染ui
G.load_shop_jokers = nil
else
for i = 1, G.GAME.shop.joker_max - #G.shop_jokers.cards do
G.shop_jokers:emplace(create_card_for_shop(G.shop_jokers))
end
end

找到create_card_for_shop(G.shop_jokers)原文定义过长,这里简化逻辑:
先检查强制发卡的两个途径(教学关卡或者tag强制)如果都不触发,则按加权随机在五个大类里抽取一个(小丑牌、塔罗牌、星球牌、手牌<基本、增强>、幻灵牌)中创建卡与ui、再给tag一次后处理机会;如果抽到的是手牌且持有代金卷,则还会额外判断一次是否带有特殊版本(基本、增强)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[入口]
|
|--(1) 教程强制卡? -------------- 是 --> 生成指定center卡 -> 建UI -> 返回
| 否
|--(2) Tag强制生成? -------------- 是 --> Tag可再修改一次 -> 返回
| 否
|--(3) 加权随机抽“类别桶”
| |-- Joker / Tarot / Planet / PlayingCard / Spectral
| '-- 命中某桶 -> create_card(...) -> create_shop_card_ui(...)
| \
| '--(4) Tag后处理:store_joker_modify
|
'--(5) (仅扑克牌 & 有 v_illusion)
|-- 20% 赋予特殊版本?
'-- 是 -> foil/holo/polychrome 三选一


随机部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
local total_rate = joker_rate + tarot_rate + planet_rate + playing_card_rate + spectral_rate
local polled_rate = pseudorandom(pseudoseed('cdt'..G.GAME.round_resets.ante)) * total_rate
local check_rate = 0

for _, v in ipairs({
{type='Joker', val=G.GAME.joker_rate},
{type='Tarot', val=G.GAME.tarot_rate},
{type='Planet', val=G.GAME.planet_rate},
{type=(G.GAME.used_vouchers["v_illusion"] and pseudorandom(pseudoseed('illusion')) > 0.6)
and 'Enhanced' or 'Base', val=G.GAME.playing_card_rate},
{type='Spectral', val=G.GAME.spectral_rate},
}) do
if polled_rate > check_rate and polled_rate <= check_rate + v.val then
-- 命中 v.type 这一类:创建卡与UI,然后跳出
end
check_rate = check_rate + v.val -- 推进累积区间
end

这样就很直观了,先伪随机生成一个概率,然后逐个扫描这五个区间,如果匹配,则生成卡牌创建ui。

  • 先将总概率求和,得到总概率区间
  • 再由伪随机生成一个随机数(区间是0-1),与总区间相乘,相当于在该区间内抽样
  • 再逐个扫描每个区间,检测是否是在这个区间内,如果是就创建卡牌与ui
  • 这个概率模型是加权随机数,这样概率的带权随机数是公平的
  • lua的随机数函数是对c语言随机数函数(线性同余法)的简单封装,所以要快速开局时间戳很可能类似,使得序列相同,不知是否是这样的实现

我们继续寻找随机数函数的秘密
找到经常嵌套使用的两个伪随机函数的是实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function pseudoseed(key, predict_seed)
if key == 'seed' then return math.random() end
--伪随机种子
if predict_seed then
local _pseed = pseudohash(key..(predict_seed or ''))
_pseed = math.abs(tonumber(string.format("%.13f", (2.134453429141+_pseed*1.72431234)%1)))
return (_pseed + (pseudohash(predict_seed) or 0))/2
end

if not G.GAME.pseudorandom[key] then
G.GAME.pseudorandom[key] = pseudohash(key..(G.GAME.pseudorandom.seed or ''))
end

G.GAME.pseudorandom[key] = math.abs(tonumber(string.format("%.13f", (2.134453429141+G.GAME.pseudorandom[key]*1.72431234)%1)))
return (G.GAME.pseudorandom[key] + (G.GAME.pseudorandom.hashed_seed or 0))/2
end

function pseudorandom(seed, min, max)
--伪随机
if type(seed) == 'string' then seed = pseudoseed(seed) end
math.randomseed(seed)
if min and max then return math.random(min, max)
else return math.random() end
end

pseudoseed的作用

1
2
3
4
5
6
[入口]
├── (1)如果key为字符串'seed'-----------是->则由math.random()返回随机数
├── (2)如果预测种子为真----------是->将key字符串与'predict_seed'做拼接,将这个字符串哈成0-1的浮点数,再线性推进一轮,取小数点后十三位,最后返回'pseudohash(predict_seed)'的平均值
|
├── (3)如果伪随机的key不曾出现在队列里,则将key与该局种子码拼接,哈成初始值并加入队列
└── (4)如果该key存在,则线性推进一轮并加入该key所在队列,并返回与'G.GAME.pseudorandom.hashed_seed'的平均值

而pseudorandom:

1
2
3
4
5
6
[入口]
├── (1)如果seed的类型是字符串
│ ├── seed = pseudoseed(seed),这会推进一次key的队列
│ └── 调用一次math.randomseed(seed)改变全局rng,立刻获取一次random()
├── (2)如果有指定max、min,则返回[min,max)的随机数
└── (3)没有指定,则返回[0,1)的随机数

这样保证可复现:

  • 初始条件一致
    • G.GAME.pseudorandom.seed
    • G.GAME.pseudorandom.hashed_seed
  • 调用序列一致
    • 相同顺序、次序对应相同的key调用pseudoseed/pseudorandom
      这样就能实现:使用不同的key,管理不同的随机数生成序列,相同的key可以得到相同的随机数序列而且return的都是math.ramdom()可以保证随机性与公平性

为什么我在商店就是刷不出某张我想要的卡?

依照上文我们解析的随机数算法,我在matlab里写了个测试程序,复刻了这个算法,并统计命中情况:

  • 为了简化模型,没有对joker池子里每个卡牌稀有度进行区分,默认所有卡牌取得的概率相同
  • 没有加入特殊小丑牌的效果,是按照默认概率设置的:
1
2
3
4
5
joker_rate = 20,
tarot_rate = 4,
planet_rate = 4,
spectral_rate = 0,
playing_card_rate = 0,

1
2
3
4
5
6
7
8
9
10
=== 类别分布(T=200000) ===
Joker : obs=0.71617 exp=0.71429
Tarot : obs=0.14285 exp=0.14286
Planet : obs=0.14098 exp=0.14286
Chi-square p-value vs expected: 0.05163 (df=2)

=== 池内均匀性卡方检验(与等概率对比) ===
Joker : chi2=160.90 df=149 p=0.239
Tarot : chi2=20.42 df=21 p=0.495
Planet : chi2=14.65 df=11 p=0.199

可以看到,在大量的抽取(200000次)下,总体分布均匀,确实是相对平滑的,这个算法是公平的
可是我们一场游戏商店不会刷新这么多次,在可观的游戏周期内,每个商店刷新十次,一层刷三个商店,一共八层,一共240次,可以观测到(’9OUU79’):

第66号小丑牌生成次数最多,出现了六次
而多次出现的小丑牌(48)与只出现一次的小丑牌(44)数量接近相等,而有六十来张是抽不到的。
而将抽取次数调至500次,有如下结果:

在500次抽取下,仍然会出现有抽不到的卡牌

在1000次抽取下,还有两个未能抽取到,这已经远远超出了标准游戏流程
更何况我们没有加入稀有,罕见等要素,想要每局都抽到这些卡更是难上加难
想要每张牌都抽到一次,则抽取次数来到了1400:(对于该种子码来说)

所以换言之,每一个种子在游戏周期内一定有无法抽取到的卡牌,而想要抽取到你想要的那个“关键卡牌”很可能在随机队列的1400张之后。
不过对于肉鸽游戏来说,这样的随机才好玩一点,不然每一轮都是固定的套路,那就很没有意思了。


这种随机算法确实很有意思,加权抽样检测生成卡牌,一局里一定有无法抽取的卡牌。过段时间开坑种子生成生成逻辑


开坑:小丑牌源代码解析与使用的算法
https://explore-ist.github.io/2025/07/02/开坑:小丑牌源代码解析与使用的算法/
作者
Exploreist
发布于
2025年7月2日
许可协议