ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

C++抽卡模拟器:随机数、概率模型与保底机制实战

C++抽卡模拟器:随机数、概率模型与保底机制实战

简介:《原神抽卡模拟器.zip》面向希望用 C++ 复刻抽卡体验的学习者与游戏爱好者,围绕祈愿池、概率分布、保底计数与结果统计等核心机制展开,可用于课程设计、C++ 面向对象练习或小型游戏逻辑原型参考。压缩包共 38 个文件,约 4.04MB,包含 2 个 cpp 源文件及 hpp/h 头文件、sln 与 vcxproj 工程配置、exe 可执行程序,并辅以 txt 数据/说明、md 说明文档和日志记录,便于直接运行、阅读工程结构与对照调试。已有 1606 人学习下载。其源码组织覆盖登录校验、MD5 处理、抽卡函数与愿池定义等模块,配合文本数据可观察用户与抽卡记录;工程文件保留完整,适合拆解随机数生成、保底计数和统计展示的实现思路,也能作为二次开发与界面扩展的基础。README 与 txt 文本数据可辅助理解祈愿池配置与用户记录,日志文件则保留运行过程信息,便于排查抽卡结果异常。

1. 抽卡模拟器不是“随机数+打印”:一个 C++ 小项目能练到哪些真功夫

某天群里有人发了个十连截图,三金两紫,下面一排“吸欧气”。我顺手写了个 C++ 抽卡模拟器跑了一万次十连,发现真实分布和直觉差得远——这就是这类项目的价值:它把“玄学”变成可观测的数字。这个 C++ 抽卡模拟器项目做的事很具体:读卡池配置、按概率和保底规则出卡、记录抽卡历史、支持批量模拟验证。适合两类人:想练 C++ 工程化的新手,以及想给自己项目加抽卡模块的开发者。它不依赖图形界面,命令行就能跑,代码量可控,但概率模型、状态管理、配置解析、随机数这些该踩的坑一个不少。

2. 项目结构与技术选型:从 main.cpp 到卡池配置的分层

2.1 目录骨架与构建方式

先看目录。这个项目没有用第三方框架,标准库加 CMake 就能编。常见骨架是:src/放核心代码,data/放卡池配置,tools/放批量模拟工具。main.cpp只负责命令行参数和交互,gacha_engine管抽卡规则,pool_config管卡池数据,record管历史记录。分层的目的是把“规则”和“输入输出”隔开,后面加新卡池、换 UI 都不用动核心逻辑。

gacha-sim/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── gacha_engine.h │ ├── gacha_engine.cpp │ ├── pool_config.h │ ├── pool_config.cpp │ └── record.h ├── data/ │ └── pools.conf └── tools/ └── simulate.cpp

CMake 配置很朴素,核心逻辑编成静态库,主程序和模拟工具都链接它:

cmake_minimum_required(VERSION 3.16) project(gacha_sim LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(gacha_core src/gacha_engine.cpp src/pool_config.cpp ) target_include_directories(gacha_core PUBLIC src) add_executable(gacha_sim src/main.cpp) target_link_libraries(gacha_sim PRIVATE gacha_core) add_executable(gacha_simulate tools/simulate.cpp) target_link_libraries(gacha_simulate PRIVATE gacha_core)

参数说明:CMAKE_CXX_STANDARD 17是因为后面会用std::optional和结构化绑定;target_include_directories用PUBLIC,链接方不用重复写头文件路径。编译命令如下,-DCMAKE_BUILD_TYPE=Release别省,批量模拟十万次的时候 Debug 版能慢出好几倍,这是血泪经验。

cmake -S . -B build -DCMAKE_BUILD_TYPE=Release cmake --build build -j

2.2 核心数据结构:角色、卡池、抽卡结果

数据结构不用花哨,三个结构体撑住。Item表示单个可抽取物品,Pool表示一个卡池的静态配置,DrawResult表示一次抽卡的结果快照。把“配置”和“结果”分开,后面做统计和存档会省很多事。

// 单个可抽取物品 struct Item { std::string id; // 唯一标识,如 "char_a" std::string name; // 显示名 int rarity; // 3 / 4 / 5 bool isUp; // 是否当期 UP }; // 一个卡池的静态配置 struct Pool { std::string name; std::vector<Item> items; int hardPity = 90; // 硬保底抽数 int softPityStart = 74; // 软保底起始抽数 double baseFiveStar = 0.006; double baseFourStar = 0.051; }; // 一次抽卡结果 struct DrawResult { Item item; int pityAfter; // 抽完后的五星保底计数 bool isGuaranteed; // 本次是否吃的大保底 };

isUp决定了大保底逻辑:如果上一次五星不是 UP,这次五星强制 UP。pityAfter记录状态,方便写存档和统计。这里用int rarity而不是枚举,是为了配置解析省事;如果后期要扩展稀有度,换成enum class更稳。

2.3 概率与保底状态放在哪一层

保底状态不能放在Pool里,因为卡池是配置,状态是玩家自己的。常见做法是单独一个PlayerState,跟着存档走,抽卡时传引用进去,抽完更新。把状态和卡池分开的好处是:换卡池不会丢保底计数(如果规则允许继承),也方便做“模拟一万个玩家”的批量测试。

struct PlayerState { int fiveStarPity = 0; // 距上次五星的抽数 int fourStarPity = 0; // 距上次四星的抽数 bool guaranteedUp = false; // 下次五星是否必 UP };

提示:保底计数从 0 开始还是从 1 开始,一定要在项目里统一。我见过一个版本一半代码从 0 算一半从 1 算,最后十连保底提前一抽触发,查了半天。

3. 核心抽卡链路拆解:一次十连在代码里到底发生了什么

3.1 随机数引擎与均匀分布的正确用法

随机数是这类项目的黑匣子。很多新手会在drawOne()里写std::mt19937 gen(std::random_device{}());,每抽一次重新播种。结果就是:同一毫秒内连续抽十次,种子可能一样,十连全是一个结果。正确做法是引擎只初始化一次,挂在引擎对象上:

class Rng { public: explicit Rng(uint32_t seed = std::random_device{}()) : gen_(seed), seed_(seed) {} // 返回 [0,1) 的均匀分布 double nextUnit() { return std::uniform_real_distribution<double>(0.0, 1.0)(gen_); } // 给需要整数分布的调用方用 std::mt19937& engine() { return gen_; } uint32_t seed() const { return seed_; } private: std::mt19937 gen_; uint32_t seed_; };

参数说明:std::mt19937是 32 位梅森旋转引擎,周期 2^19937-1,对抽卡模拟足够;std::random_device只在构造时用一次,拿真随机种子。nextUnit()每次构造分布对象开销很小,也可以把分布对象做成成员,但要注意uniform_real_distribution的状态问题。更稳的写法是用std::generate_canonical<double, 53>(gen_),它不依赖分布对象的内部实现。

注意:std::uniform_real_distribution在不同标准库实现上生成的序列可能不同。这不是 bug,是标准没规定具体算法。如果要跨平台复现,别用它,自己用整数取高位或者generate_canonical。

3.2 保底计数器:软保底、硬保底与大保底

抽卡规则的核心就三段:基础概率、软保底递增、硬保底兜底。先算五星概率,函数接收“抽之前已经累计了多少次没出五星”,返回当前这一抽的五星概率:

double fiveStarProbability(const Pool& pool, int pity) { // pity 是“已经抽了多少次没出五星”,从 0 开始 if (pity + 1 >= pool.hardPity) { return 1.0; // 硬保底,这一抽必出 } if (pity + 1 >= pool.softPityStart) { // 软保底:从起始抽开始,每抽增加 6% double extra = (pity + 1 - pool.softPityStart + 1) * 0.06; return std::min(1.0, pool.baseFiveStar + extra); } return pool.baseFiveStar; }

逻辑说明:pity是抽之前已经累计的次数,所以当前是第pity + 1抽。硬保底判断放在最前面,避免软保底公式超过 1。软保底公式里(pity + 1 - softPityStart + 1)这个+1容易写错,建议拿纸算一遍:softPityStart = 74时,第 74 抽pity = 73,73 + 1 - 74 + 1 = 1,额外 6%,概率 6.6%。如果写成pity - softPityStart,第 74 抽额外是 0,整体晚一抽,玩家体感会差很多。

大保底在出五星之后判断。先决定这次是不是 UP,再从对应候选池里随机选一个物品:

Item pickFiveStar(const Pool& pool, PlayerState& state, Rng& rng) { std::vector<Item> fiveStars; for (const auto& item : pool.items) { if (item.rarity == 5) fiveStars.push_back(item); } // 先决定是不是 UP:大保底必 UP,否则 50% 概率 bool mustUp = state.guaranteedUp; bool isUp = mustUp || (rng.nextUnit() < 0.5); std::vector<Item> candidates; for (const auto& item : fiveStars) { if (item.isUp == isUp) candidates.push_back(item); } // 配置里没有对应候选时回退,避免空池崩溃 if (candidates.empty()) candidates = fiveStars; if (candidates.empty()) { throw std::runtime_error("卡池中没有五星物品"); } std::uniform_int_distribution<size_t> pick(0, candidates.size() - 1); Item result = candidates[pick(rng.engine())]; // 歪了下次必 UP,没歪下次恢复 50% state.guaranteedUp = !isUp; return result; }

参数说明:0.5是 UP 概率,实际项目里可以放到Pool配置里;guaranteedUp是“下一次五星是否必 UP”的标志。注意状态更新时机:抽完立刻更新,不要等十连结束再统一更新,否则十连里出两个五星时第二个会算错。drawOne里记录本次是否吃大保底,要在调用pickFiveStar之前把旧状态存下来:

if (rng.nextUnit() < p5) { bool wasGuaranteed = state.guaranteedUp; // 先存旧状态 out.item = pickFiveStar(pool, state, rng); out.isGuaranteed = wasGuaranteed; // 本次是否吃大保底 state.fiveStarPity = 0; state.fourStarPity = 0; return out; }

3.3 十连循环与结果聚合

十连不是简单循环十次。常见规则是“十连必出四星及以上”,所以要有保底位。实现思路:先抽前九次,记录有没有出四星及以上;第十次如果前面都没有,强制四星起步。注意强制四星不能压掉五星,五星优先级永远最高。

std::vector<DrawResult> drawTen(Pool& pool, PlayerState& state, Rng& rng) { std::vector<DrawResult> results; results.reserve(10); bool hasFourStarOrAbove = false; for (int i = 0; i < 10; ++i) { bool forceFourStar = (i == 9 && !hasFourStarOrAbove); DrawResult r = drawOne(pool, state, rng, forceFourStar); if (r.item.rarity >= 4) hasFourStarOrAbove = true; results.push_back(r); } return results; }

drawOne里要处理forceFourStar:先按正常概率判五星,没出五星再判四星,如果强制四星且没出四星,就从四星池里随机一个。这样十连的最后一个位置至少是四星,同时不会抢掉五星硬保底。

DrawResult drawOne(Pool& pool, PlayerState& state, Rng& rng, bool forceFourStar) { DrawResult out; // 1. 先判五星 double p5 = fiveStarProbability(pool, state.fiveStarPity); if (rng.nextUnit() < p5) { bool wasGuaranteed = state.guaranteedUp; out.item = pickFiveStar(pool, state, rng); out.isGuaranteed = wasGuaranteed; state.fiveStarPity = 0; state.fourStarPity = 0; return out; } // 2. 没出五星,判四星 double p4 = fourStarProbability(pool, state.fourStarPity); bool hitFour = rng.nextUnit() < p4; if (hitFour || forceFourStar) { out.item = pickFourStar(pool, rng); state.fourStarPity = 0; } else { out.item = pickThreeStar(pool, rng); } // 3. 没出五星才走到这里,计数递增 state.fiveStarPity++; state.fourStarPity++; return out; }

注意state.fiveStarPity++放在最后:五星分支里已经清零并提前返回,不会多加。四星保底计数在出四星时清零,出三星时递增。这段逻辑如果顺序写反,会出现“保底计数永远不涨”或者“出了五星还继续涨”的情况。

3.4 卡池配置的加载与校验

配置格式不用复杂,键值对就够。[pool_standard]这种 section 表示卡池,[item_char_a]表示物品,加载时先读 section,再按 key 赋值。常见做法是自己写一个百来行的解析器,不引入 JSON 库,减少编译依赖。

[pool_standard] name=标准池 hard_pity=90 soft_pity_start=74 base_five_star=0.006 base_four_star=0.051 [item_char_a] pool=pool_standard name=角色A rarity=5 is_up=true

解析完必须校验,别让程序带着半截配置跑。校验内容包括:保底抽数为正、软保底起始小于硬保底、每个卡池至少有一个三星一个四星一个五星。校验失败直接抛异常,启动阶段就暴露问题,比跑到一半崩溃好查得多。

void validatePool(const Pool& pool) { if (pool.hardPity <= 0 || pool.softPityStart <= 0) { throw std::invalid_argument("保底抽数必须为正"); } if (pool.softPityStart >= pool.hardPity) { throw std::invalid_argument("软保底起始必须小于硬保底"); } bool has3 = false, has4 = false, has5 = false; for (const auto& item : pool.items) { if (item.rarity == 3) has3 = true; if (item.rarity == 4) has4 = true; if (item.rarity == 5) has5 = true; } if (!has3 || !has4 || !has5) { throw std::invalid_argument("卡池必须包含 3/4/5 星物品"); } }

4. 编译、运行与验证:把概率跑出来而不是“感觉差不多”

4.1 编译命令与依赖

项目只依赖标准库和 CMake,Linux 和 macOS 下按前面的两条命令就能编。Windows 下用 Visual Studio 生成器或者 MinGW 都行,关键是把CMAKE_BUILD_TYPE设成 Release:

cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release cmake --build build ./build/gacha_sim --pool data/pools.conf

如果编译器报std::optional找不到,检查CMAKE_CXX_STANDARD是不是被覆盖了;如果是 GCC 7 以下,升编译器,别硬改代码。命令行参数至少支持--pool、--seed、--draws、--batch四个,--seed一定要暴露出来,否则复现问题只能靠猜。

4.2 命令行交互与输出格式

main.cpp的参数解析很朴素,交互模式下输入1单抽、10十连、q退出。输出格式建议固定,方便后面写脚本分析:

[十连] 第 1 抽: 3星 普通物品A | 保底计数 12 [十连] 第 2 抽: 4星 角色B | 保底计数 13 ... [统计] 五星 1 个,四星 2 个,三星 7 个

固定格式的好处是可以用awk或 Python 直接统计,不用改 C++ 代码。每行带上抽后保底计数,手动排查“保底为什么没触发”时一眼就能看出计数有没有涨。输出到终端的同时可以追加到history.log,方便后面做离线分析。

4.3 用批量模拟验证概率收敛

单抽几次看不出概率对不对,得跑批量。tools/simulate.cpp的核心逻辑是连续单抽,记录每次出五星时的实际抽数,最后统计五星率和平均出金抽数:

int main() { Pool pool = loadPool("data/pools.conf", "pool_standard"); PlayerState state; Rng rng(42); // 固定种子,保证可复现 const int trials = 1'000'000; int fiveStarCount = 0; long long totalPullsToFive = 0; for (int i = 0; i < trials; ++i) { int pityBefore = state.fiveStarPity; DrawResult r = drawOne(pool, state, rng, false); if (r.item.rarity == 5) { fiveStarCount++; totalPullsToFive += (pityBefore + 1); } } if (fiveStarCount == 0) { std::cout << "样本内没有出五星,检查配置\n"; return 1; } double rate = static_cast<double>(fiveStarCount) / trials; std::cout << "五星率: " << rate << "\n"; std::cout << "平均出金抽数: " << static_cast<double>(totalPullsToFive) / fiveStarCount << "\n"; }

参数说明:trials是总抽数,可以先改成 10 万快速验证;seed固定为 42 保证每次结果一样。这里统计的是“每次出金时实际抽了多少抽”的平均值,比用总抽数除以出金次数更准,因为最后一次可能没出金。跑完看两个数:五星率应该在 1.6% 左右(含保底),平均出金抽数在 62 抽上下。如果五星率明显偏低,先查软保底公式的+1有没有写错;如果平均抽数接近 90,说明硬保底生效了但软保底没生效。

4.4 存档与抽卡记录

存档不用数据库,一个文本文件就够。读取时逐行解析,写入时覆盖。注意guaranteed用 0/1 而不是true/false,因为std::boolalpha不设的话输出就是 0/1,解析端别一边读true一边写1。抽卡记录追加到history.log,每行一条,字段顺序固定为pull_index,rarity,item_id,pity_after,后面换语言分析也不用改解析。

pity=37 four_pity=2 guaranteed=0 last_five=char_a

读档时如果文件不存在,就用默认状态,不要直接崩溃。写档建议先写临时文件再重命名,避免程序中途退出把存档写坏。这个习惯在命令行工具里看起来多余,但玩家手动抽了几百次之后存档损坏,没有后悔药可吃。

5. 常见问题排查:概率不对、保底不触发、结果复现不了

5.1 现象:十连里同一个角色出现多次,以为是 bug

现象:十连出了两个相同四星,玩家觉得“池子里是不是只有这一个”。

原因:抽卡是独立随机事件,同一个物品重复出现是正常的。如果代码里每次从items里按概率抽,没有做“已出排除”,重复概率本来就不低。

解决:先确认这是不是设计预期。如果卡池规则要求“十连内不重复”,那要在drawTen里维护一个已出集合,抽到重复就重抽;但注意重抽会改变实际概率分布,必须在文档里写清楚。多数抽卡项目不做去重,重复是特性不是 bug。

5.2 现象:抽了几百次五星不出,怀疑保底失效

现象:手动抽了 120 次没出五星,硬保底写着 90。

原因:最常见的是pity计数没存住。比如每次drawOne传的是PlayerState的副本,函数里++了但外面没变;或者存档读档时把pity重置了。

解决:在drawOne里加一行调试输出,打印进入时的pity和概率;再检查PlayerState是不是按引用传的。另一个坑是硬保底判断用了>而不是>=,导致第 90 抽概率还是软的,第 91 抽才必出。把pity + 1 >= pool.hardPity和pity + 1 > pool.hardPity两种情况各跑一次,看第 90 抽的输出就能定位。

5.3 现象:同样 seed 两次结果不同

现象:命令行传了--seed 42,两次运行十连结果不一样。

原因:一是在抽卡过程中又用std::random_device重新播种了;二是用了std::uniform_real_distribution,不同标准库实现序列不同;三是多线程批量模拟时共用了同一个引擎,线程调度导致顺序不一致。

解决:全局只保留一个Rng实例,构造后不再播种;需要跨平台复现就换成std::generate_canonical或自己写整数映射;多线程时每个线程一个引擎,种子用主引擎派生。验证方法是同一 seed 跑两次,把history.log做 diff,有差异就说明还有隐藏的随机源。

5.4 现象:换机器后概率明显不一样

现象:本地跑出来五星率 1.6%,换台机器变成 1.2%。

原因:除了上面的分布实现差异,还可能是int和size_t混用导致候选池索引越界,或者配置文件没跟着走,读到了代码里的默认值。不同编译器对std::uniform_int_distribution的参数类型检查严格程度也不一样,32 位和 64 位平台上的 `

本文还有配套的精品资源,点击获取

返回列表