简介:《原神抽卡模拟器》是一套基于C++开发的祈愿模拟程序,面向原神玩家和有初步C++基础的学习者,用于离线体验角色抽取流程,并深入理解随机数、概率分布与保底机制的实现思路,尤其适合想弄明白抽卡概率背后逻辑的玩家。资源包共38个文件、约4.04MB,除两个cpp源文件、两个hpp头文件和工程配置文件外,还包含可直接运行的exe、txt数据文件、日志及README说明,形成“源码—配置—程序—数据—文档”的完整小项目。目前已有1600人浏览学习。通过源码可掌握 库的随机数用法、计数器保底逻辑、不同祈愿池概率的差异化配置,以及命令行交互设计;项目还附带抽卡结果记录、登录相关功能模块,并留有改造成图形界面的空间。整体适合作为C++课程设计、游戏逻辑练习或二次开发蓝本。
1. 原神抽卡模拟器:用 C++ 复刻一套概率引擎,把玄学变成可验证的数
玩抽卡游戏的人,多多少少都有过这种经历:新角色池开了,群里有人说「玄学时间到了」,也有人说「池子概率被调低了」。口说无凭,概率这种事,只有跑过足够多样本才有说服力。这个用 C++ 写的原神抽卡模拟器,把整套抽卡规则——基础概率、软保底、硬保底、歪与不歪、四星保底——全部代码化,做成一个支持单抽、十连、百万次批量模拟并输出统计报表的独立程序。它不连服务器,不调真实接口,只做一件事:按接近真实的数值模型,把概率分布跑给你看。适合对游戏数值机制好奇的玩家,也适合想练 C++ 随机数和状态机设计的开发者。
2. 项目骨架与运行方式:目录结构、编译参数与随机数选型
2.1 先看目录:三个模块的分工
解压后看到的是一套很干净的 C++ 工程。它没有引入任何第三方库,编译器只认标准库,这意味着任何装了 C/C++ 工具链的机器都能直接构建。目录划分遵循一个很朴素的原则:「抽到什么」和「怎么统计」彻底分开。
wish_simulator/ ├── include/ │ ├── gacha_engine.h # 抽卡引擎:概率判定、保底状态管理 │ ├── wish_record.h # 抽卡结果记录的数据结构 │ └── stats.h # 统计汇总:出货率、平均抽数、CSV导出 ├── src/ │ ├── gacha_engine.cpp # 核心实现 │ ├── wish_record.cpp # 记录落盘 │ ├── stats.cpp # 统计聚合 │ └── main.cpp # 命令行入口 ├── CMakeLists.txt └── README.mdgacha_engine 是整份源码的枢纽。它内部维护三个状态:五星保底计数、四星保底计数、大保底标记位。这三个状态共同决定一次抽卡的输出,也是后续章节里反复提到的核心。统计模块 stats 不持有任何概率逻辑,它只接收一个个 WishRecord 结构体,然后计算出货率、平均出货抽数、稀有度分布。
我一般拿到一份陌生的 C++ 工程,会先看 include 目录里公开了哪些头文件,由此推断模块边界。这份代码的边界画得很清楚:引擎对上层只暴露 pull()、pullTen()、reset() 三个方法,统计模块对上层只暴露 addRecord() 和 exportCsv()。方法少、职责单一,属于适合新手通读一遍的小工程。
2.2 编译与运行:CMake 三步跑起来
构建部分没什么花活,CMake 是最稳妥的选型。项目根目录下的 CMakeLists.txt 指定了 C++17 标准,输出一个命令行可执行文件。完整流程是:
mkdir build && cd build cmake .. cmake --build . --config Release跑起来之后,先来一次 100 抽感受一下输出:
./wish_simulator -n 100 -p character参数含义如下:
- -n:单抽次数,必须为正整数
- -p:池子 ID,character 是角色池,weapon 是武器池
- --ten-pull:切换为十连模式,每次循环内连续抽 10 次
- --seed:指定随机数种子,用于可复现调试
- --format:输出格式,text 或 csv
建议第一次跑的时候别加 --silent,逐条看输出,观察五星出在第几抽、歪了之后下一次 UP 是不是立刻出现。这些输出能直观感受到保底状态机的运转过程。批量模拟时再加上 --silent 只保留汇总,避免终端被十万行结果刷爆。
2.3 随机数选型:mt19937 背后的工程判断
抽卡模拟器最敏感的部分是随机数。项目没有用 C 时代的 rand(),而是选了 C++11 库的 mt19937,这是一个值得在 README 里单独写一段的决策。
rand() 有两个回避不了的问题:第一,很多实现里 rand() 的周期只有 2^31,在十万次甚至百万次批量模拟时,样本会开始重复;第二,经典的 rand() % N 取模方式会让低位分布不均,产生模偏差。这两点对「验证 1.6% 综合概率」这种精度需求是致命的。
// 引擎初始化时的随机数设置 std::random_device rd; std::mt19937 rng(rd()); std::uniform_real_distribution<double> dist(0.0, 1.0);random_device 负责提供种子,mt19937 负责产生长周期随机序列,uniform_real_distribution 保证落到 [0.0, 1.0) 的每个浮点数概率相等。三个组件各管一段,是 C++ 里处理随机数的标准姿势。
如果是调试场景,把种子固定下来:
std::mt19937 rng(20240517); // 固定种子,序列可复现固定种子是抽卡模拟器开发的「后悔药」:当你发现概率曲线异常时,能用同一条序列重现问题,比每次随机重跑一遍好排查得多。我一般会在 main.cpp 里留两个分支,默认 random_device,--seed 有值时用固定种子。
3. 核心抽卡引擎拆解:一次单抽要经过几道概率判定
3.1 五星判定:软保底区间的分段函数
进入核心模块。角色池的公开规则是:基础五星概率 0.6%,90 抽内必出;从第 75 抽开始,概率开始大幅提升。引擎把这条规则实现成一个分段函数:
double GachaEngine::getFiveStarRate() const { if (pityCount_ < 74) { return 0.006; // 第1-74抽:基础概率 0.6% } // 第75抽起线性提升,每抽增加 6% return 0.006 + 0.06 * (pityCount_ - 73); }pityCount_ 是「距离上次五星的抽数」,每次非五星结果它加一,出五星立即归零。注意第 74 抽以内用固定值,从第 75 抽开始走线性增长,这是对软保底机制的常见建模方式。实际游戏的概率曲线可能不是严格线性,但对模拟器来说,线性公式已经足够逼近真实期望。
然后单抽函数做判定:
GachaResult GachaEngine::pull() { double roll = uniformDist_(rng_); if (roll < getFiveStarRate()) { return handleFiveStar(); // 命中五星分支 } pityCount_++; // 没出五星,计数加一 return handleFourStarOrLower(); }这段代码是单向判定链路:先取一个 [0.0, 1.0) 的随机数,与当前抽数对应的五星概率比较;命中了进入五星分支,没命中则保底计数加一,继续走四星判定。顺序不能反——必须优先判五星,再判四星,最后落到三星,否则概率区间会重叠,导致实际出货率高于配置。
这里的参数说明值得记一笔:把 0.006 和 0.06 抽成常量比硬编码在函数里更利于后续调参。项目里如果想把软保底起点从 75 改成 80,只需改一处起点判断和斜率系数,不用动调用方。
3.2 UP 判定与大保底:状态机的两个关键分支
出五星之后,紧接着要回答另一个问题:出的是当期 UP 角色还是常驻角色。这里有个著名的「大保底」机制——如果上一次五星歪了,下一次五星必定是 UP。实现上用两个状态变量就够了:
GachaResult GachaEngine::handleFiveStar() { bool isFeatured; if (hasGuarantee_) { isFeatured = true; // 大保底状态,必出UP } else { isFeatured = (uniformDist_(rng_) < 0.5); // 50%概率 } pityCount_ = 0; // 五星保底计数重置 if (isFeatured) { hasGuarantee_ = false; return featuredCharacter_; } else { hasGuarantee_ = true; // 歪了,下一次五星必UP return randomStandardCharacter(); } }这段代码的精妙之处在于把「歪」这个动作和「保底状态」绑定在一起:歪了之后立即把 hasGuarantee_ 置为 true,但此时五星已经出了,必须等下一次五星才生效,所以不会造成同一抽既歪又保底的矛盾。至于非 UP 五星怎么选,就是从常驻角色列表里用均匀随机挑一个,在 randomStandardCharacter() 里实现。
这里有一个容易踩的细节:hasGuarantee_ 的语义是「下一次五星必定 UP」,而不是「当前这一抽」。如果把重置时机写错——比如在进入 handleFiveStar 之前就清零——会导致歪了之后立刻又出 UP,也就是状态机的时序污染。正确做法是只在 isFeatured 为 true 的分支里消费掉这个标志位。
3.3 十连的本质:循环里藏着的四星保底
十连不是独立算法,它只是单抽循环十次,但四星有自己的保底规则:10 抽内必出四星或以上。实现上的关键点是:四星保底和五星保底是两个独立的计数器,互不干扰。
std::vector<GachaResult> GachaEngine::pullTen() { std::vector<GachaResult> results; results.reserve(10); for (int i = 0; i < 10; i++) { results.push_back(pull()); fourStarPity_++; // 四星保底:计数器到10则强制四星以上 if (fourStarPity_ >= 10 && results.back().rarity < 4) { results.back() = forceFourStarOrAbove(); fourStarPity_ = 0; } } return results; }更严谨的工程做法是在 pull() 内部通过判断 fourStarPity_ 提前把四星概率调高,而不是事后替换。上面的写法是为了直观展示「计数器分离」的思想:四星保底每抽都递增,但五星保底不受影响,两者状态独立。我设计这类多保底系统时,会给每个稀有度单独建一个计数器,而不是用一个 for 循环倒推,因为后者的状态耦合会让 bug 非常隐蔽。
十连的另一个价值是观察「双黄」现象。软保底让第 75 抽之后的出五星概率大幅上升,如果十连恰好覆盖这个区间,确实可能一次出两个五星——这不是运气爆棚,而是概率曲线爬升后的必然结果。模拟器跑多了,对这种「伪奇迹」会越来越脱敏。
4. 统计模块与概率验证:十万次模拟后数据告诉你真相
4.1 抽卡记录的生成与落盘
模拟器不能只看终端输出,还得能存档、能分析。wish_record 模块负责把每次抽卡结果落盘,常见做法是每抽追加一条记录:
struct WishRecord { int pullIndex; // 第几抽 int rarity; // 3 / 4 / 5 std::string name; // 角色或武器名 bool isFeatured; // 是否UP long long timestamp; // 时间戳 };五个字段足够支撑后续所有统计维度的计算:出货间隔、UP 率、歪率、平均出货抽数。落盘格式我建议默认用 CSV,因为可以直接拖进表格软件做透视表;如果后续想接程序化分析,再加一个 --format json 切换结构文本。两种格式共用一个记录结构体,序列化函数分开写即可。
4.2 十万次模拟与综合概率收敛
这个项目最有说服力的功能是批量模拟。跑十万次单抽,统计五星出货次数和平均出货抽数:
./wish_simulator -n 100000 -p character --silentsilent 模式不逐条打印,只输出汇总。理论上,包含软保底和硬保底的角色池,综合五星概率应该收敛到 1.6% 左右,平均出货抽数落在 62 抽上下。这两个数值就是用来验证引擎正确性的锚点。
void Stats::addRecord(const WishRecord& rec) { totalDraws_++; if (rec.rarity == 5) { fiveStarCount_++; // 记录最近一次五星的抽数,用于计算出货间隔 int interval = totalDraws_ - lastFiveStarDraw_; pityHistory_.push_back(interval); lastFiveStarDraw_ = totalDraws_; } }统计逻辑不复杂,但 pityHistory_ 这个数组很重要——它是后续画出货分布图的原材料。把每个五星的出货间隔收集起来,能看出软保底导致的「75-80 抽出货扎堆」现象,比单纯看一个平均抽数直观得多。
4.3 从控制台到 CSV:把分布曲线导出来
stats 模块的最后一环是把汇总结果写到文件:
void Stats::exportCsv(const std::string& filename) { std::ofstream out(filename); out << "total_draws,five_star_count,avg_pity,up_rate\n"; out << totalDraws_ << "," << fiveStarCount_ << "," << (fiveStarCount_ ? static_cast<double>(totalDraws_) / fiveStarCount_ : 0.0) << "," << upCount_ / static_cast<double>(fiveStarCount_) << "\n"; }导出之后,用表格软件或 Python 画一下出货间隔分布,就能直观看到概率爬升曲线。很多玩家关心的「多少抽出货最划算」的问题,跑一次分布图立刻有答案:出货集中在 75-80 抽区间,0-60 抽出货的比例显著偏低,这正是软保底机制的效果——它让运气差的人不至于吃满 90 抽,但也不会让运气好的人占太多便宜。
顺带一提,判断一个模拟器有没有认真写,就看它的统计输出里有没有「出货间隔分布」,而不只是平均数。平均数会掩盖分布形状,只有分布才能反映软保底的真实手感。
5. 常见问题与避坑指南:随机数、精度与状态污染的排查记录
5.1 固定种子运行结果一模一样:这是 bug 还是特性
现象:用了 --seed 之后,每次跑出来的抽卡序列完全相同,用户以为程序「坏了」。
原因:mt19937 在相同种子下产生的序列是确定性的。这是随机数生成器的特性,不是 bug。
解决:在 README 里明确区分两种场景——默认用 random_device 保证每次运行随机;调试场景才指定 --seed 做复现。代码里把随机数初始化独立成一个私有方法,避免有人误把固定种子写死在构造函数里。
5.2 十万抽的五星出货率只有 1.2%,远低于 1.6%
现象:批量模拟跑完,汇总显示五星出货率只有 1.2%,和理论综合概率对不上。
原因:最常见的是软保底起点写错了。如果误把软保底起点设在第 60 抽,概率提升过早,平均出货抽数会被拉低;反过来设在 85 抽,出货率就会明显偏低。另一个常见错误是把 0.6% 用整数比较实现,比如 rand() % 1000 < 6,精度在大量样本下必然偏移。
解决:先把软保底起点确认到 75 抽,概率用 double 类型比较。然后看平均出货抽数是否落在 62 附近——这个值比总出货率更敏感,能快速暴露曲线建模误差。
5.3 歪了之后连续出两个 UP:大保底没被消费
现象:第一次五星歪了,下一次五星是 UP,但再下一次五星还是 UP,保底状态没有归零。
原因:出 UP 时忘记把 hasGuarantee_ 置回 false,导致状态泄漏。最常见的位置是把 hasGuarantee_ = false 写在了 if 分支外面,或者干脆漏了这行。
解决:在 handleFiveStar 中,isFeatured 为 true 的分支里无条件重置 hasGuarantee_。更稳妥的做法是给状态机写一个 reset() 方法,在每次批量模拟开始前强制调用,保证状态干净。
5.4 四星保底被五星重置:两个计数器串扰
现象:出了五星之后,四星保底计数也跟着归零,导致四星出货间隔异常。
原因:五星分支里错误地调用了 resetAllPity(),把四星计数器一并清了。两个计数器在代码里如果共用一个变量,也会出现同样的串扰。
解决:每个稀有度独立计数。五星出货只重置五星计数器,四星计数器保持递增;四星出货只重置四星计数器。改完代码后跑一个针对性用例:在第 8 抽出五星,确认后续第 2 抽仍触发四星保底。
5.5 武器池的定轨逻辑被当成角色池处理
现象:切到武器池后,保底行为不符合预期——武器池的定轨机制和角色池的大保底并不是一回事。
原因:把角色池的 hasGuarantee_ 逻辑直接搬到了武器池,但武器池是三选一定轨,状态维度完全不同。
解决:把池子类型作为引擎的构造参数,不同池子走不同的保底状态机,而不是共用一套字段。如果配置文件里定义了多个池子,每个池子初始化时单独创建引擎实例,避免状态互相污染。
6. 二次开发思路:把模拟器改造成你的抽卡分析工具
这个项目最有改造价值的点在于:概率引擎和统计模块已经分离,你不需要改动核心抽卡逻辑,就能把它变成一个通用的抽卡实验平台。
第一个改造方向是自定义概率模型。目前软保底的线性系数是硬编码的,你可以从配置文件读取基础概率、软保底起点、斜率系数,甚至替换成任意分段函数。验证方法是改完参数后跑十万次模拟,看平均出货抽数是否符合新模型的理论期望。这样一来,你就可以拿它测各种「如果池子概率改到 1%」的下沉假设。
第二个改造方向是接真实抽卡数据。把 WishRecord 的落盘格式和你手头的真实抽卡记录对齐,做一个导入函数,把历史记录喂进统计模块,就能算出自己的平均出货抽数、歪率和当前垫抽。这个改造很小,但实用性最强——它让模拟器从「玩具」变成了「分析工具」。
第三个方向是给引擎加一个 GUI 外壳。控制台版跑十万次模拟没有任何性能问题,但如果你想让非技术朋友也用,可以把引擎编译成静态库,前端用简单的 Qt 或 Web 界面调用。注意不要碰核心状态机,只替换输入输出层。
有一次我把基础概率从 0.6% 改成 0.7% 后忘了重跑验证,差点发布了一个期望值完全不对的版本。从那以后,我每次改完概率参数,都会强制先跑一遍十万次模拟,确认平均出货抽数落在理论区间内再谈其他。希望这个习惯也能帮到你。
本文还有配套的精品资源,点击获取