简介:《原神抽卡模拟器》是一份基于C++开发的本地应用程序资源,面向原神玩家、C++初学者及游戏逻辑爱好者。它完整复刻了游戏内祈愿(抽卡)流程,包含角色池/武器池、不同概率分布、保底计数等核心机制,所有逻辑均通过随机数生成与计数器实现,无需消耗真实原石即可体验抽卡过程,也为学习C++项目组织提供了直观范例。
压缩包共38个文件,约4.04MB,其中包含main.cpp、wish_function.cpp、wish.h等源码文件,以及txt数据文件、Visual Studio工程配置(sln/vcxproj)、日志与可执行exe等,既可直接运行,也便于对照源码研究随机数生成、概率模拟和统计功能。已有1600人学习下载。
工程亮点在于完整、结构清晰,还附带Md5.hpp、cmd_login.hpp等模块,适合逐步拆解;从定义祈愿池、设置随机种子到处理保底规则,再到统计展示,可帮助读者串联C++语法、数据结构和算法知识,是一份兼具娱乐与教学价值的项目资料。
1. C++抽卡模拟器:先把原神概率规则变成可跑代码
用C++写一个原神抽卡模拟器,听着像个练手玩具,真正落地时却比大部分人想得麻烦:五星基础概率、90抽保底、50%歪卡、四星10抽保底,这些规则每一项都能拆出三个坑。这份zip里的C++工程把整条链路做完了——从单抽判定、保底状态机到批量模拟、统计输出,适合两类人:一类是你想搞懂抽卡概率到底怎么算的玩家,另一类是把概率模拟当C++练手项目的开发者。拿到手直接编译就能跑,跑出来能给出一组和官方公示基本吻合的出金率与歪卡率。后面我按自己重写一遍的路径来讲,重点放在规则怎么映射到代码、参数怎么调、哪里最容易翻车。
2. 概率规则与随机数引擎:综合概率不是单抽概率,规则先于代码
2.1 原神抽卡规则拆解:基础概率、保底与UP判定
写模拟器第一步不是写代码,而是把规则列表搞清楚。原神角色池最核心的几条规则分别是:五星基础概率0.600%,加上保底机制后的综合概率约1.600%;90抽内必出五星,前89抽都没出的时候第90抽强制出;四星基础概率5.100%,加上保底综合约13.000%,10抽内必出四星或以上;出五星时50%是当期UP角色,如果歪了,下一次五星必定是UP,这就是常说的大小保底。
这里最容易误解的是“综合概率1.600%”。它不是每抽1.6%的意思,而是把基础概率和90抽保底放在一起统计出来的期望结果。如果直接把1.6%填进代码,跑出来的五星率会比真实规则高出一大截。正确做法是:每抽先判断基础概率0.6%,保底计数器到了就强制给五星,而不是用综合概率去roll。这也是为什么这份资源先把规则写清楚再动代码,顺序反了后面全是玄学调参。
四星同样有保底,但实现上有个隐蔽细节:出五星的那一抽,也同时满足“四星或以上”的条件,所以五星出现时四星保底计数器也应该清零。很多初版模拟器只在出四星时清零四星计数,导致四星率偏高、保底节奏失真。这条规则我后面在代码里会体现。
2.2 随机数引擎选型:为什么不用rand()和取模
C++里做概率模拟,第一反应是rand() % 100 < 6,这在练手项目里能跑,但做十万、百万级抽样时问题就出来了:rand()的实现质量因标准库而异,周期短、低比特位均匀性差,统计结果容易出现肉眼可见的偏差。抽卡模拟要验证的是综合概率是否收敛到1.6%,随机源不均匀直接影响结论可靠性。
常见做法是换用C++11的std::mt19937配合std::bernoulli_distribution。mt19937是梅森旋转算法,周期长达2^19937-1,均匀性远好于rand();bernoulli_distribution直接按概率返回true/false,不用手工比较uniform_real_distribution的结果,代码意图也更直白。从性能上看,mt19937跑百万次抽样在O2优化后也就零点几秒,完全够用。
另外要注意,bernoulli_distribution每次调用都会消耗随机数引擎的状态,单线程下没问题;如果以后要改成多线程并行模拟,每个线程必须持有独立的引擎实例和分布对象,不能共享同一个rng。共享引擎在并发场景下既慢又影响分布结果。
2.3 保底状态机与单抽判定函数
有了规则和随机源,下一步是把状态抽象出来。每个模拟角色需要一个独立的抽取状态:当前五星保底计数、四星保底计数、是否处于大保底。这三个变量就是整个模拟器的“状态机”,所有判定都围绕它们转。
#include <random> struct PullState { int pity5 = 0; // 距离上次五星的抽数,出五星后重置为0 int pity4 = 0; // 距离上次四星及以上的抽数 int guarantee = 0; // 0=小保底,1=大保底(下次五星必UP) }; // 单抽判定:返回 5=五星,4=四星,3=三星 int pullOnce(std::mt19937& rng, PullState& s) { // 分布对象在多次调用间复用,避免重复构造 static std::bernoulli_distribution d5(0.006); static std::bernoulli_distribution d4(0.051); static std::bernoulli_distribution up(0.5); s.pity5++; // 先加计数,再判断,保证第90抽触发 s.pity4++; // 五星判定:90抽保底优先,基础概率其次 if (s.pity5 >= 90) { // 第90抽必出五星 } else if (d5(rng)) { // 基础概率0.6%出五星 } else { // 没出五星,继续判四星 } // ... 完整逻辑见下方 }完整版本里,五星判定命中后要处理UP:先看guarantee是否为1,是则必UP;否则用50%概率roll一次,歪了就把guarantee置1,等下一个五星用掉。出五星后pity5和pity4都要清零,因为五星也算“四星及以上”。四星判定和五星类似,pity4到10就强制出四星,没到就用5.1%的基础概率roll。整个函数返回星级数字,由外部统计汇总。这套写法的好处是把“保底触发”和“概率触发”分开,不会出现保底抽还被概率否决的荒谬情况。
3. 模拟器主程序落地:多用户批量抽卡与统计输出
3.1 工程结构与编译方式
这份zip里的C++工程通常拆成头文件和主程序两个部分:pull_model.h放状态结构和单抽判定函数,simulate.cpp放参数解析、批量循环和统计输出。分离的好处是单抽逻辑可以单独复用,后续要接图形界面或者改成服务端模拟都不用动核心判定代码。编译在Linux下直接一条命令,Windows下用MinGW或者Visual Studio的C++11模式都行。
g++ -std=c++11 -O2 simulate.cpp -o simulate-O2建议加上,批量模拟在高优化等级下速度差距明显。如果编译环境只支持C++11以下的老标准,std::bernoulli_distribution不可用,就得退回uniform_real_distribution手动比较,代码会多几行,但逻辑等价。
3.2 命令行参数设计:用户数、抽数、种子与CSV输出
模拟器不能只跑固定参数,否则没法做第4章的批量验证。我一般会在主程序里加四个命令行参数:--users控制模拟多少位旅行者,--pulls控制每人抽多少抽,--seed指定随机数种子(不指定就用当前时间),--csv让统计结果输出成一行CSV方便脚本收集。
#include <iostream> #include <string> #include <vector> #include <chrono> #include <iomanip> struct Options { int users = 1000; // 模拟旅行者人数 int pulls = 180; // 每人抽数 unsigned seed = 0; // 固定种子 bool fixedSeed = false; bool csv = false; }; Options parseArgs(int argc, char* argv[]) { Options opt; for (int i = 1; i < argc; i++) { std::string arg = argv[i]; if (arg == "--users") opt.users = std::stoi(argv[++i]); else if (arg == "--pulls") opt.pulls = std::stoi(argv[++i]); else if (arg == "--seed") { opt.seed = std::stoul(argv[++i]); opt.fixedSeed = true; } else if (arg == "--csv") opt.csv = true; else { std::cerr << "未知参数: " << arg << "\n"; std::exit(1); } } return opt; }参数解析用了一个很朴素的for循环加std::stoi。真实场景下这样写够用了,不需要引入第三方命令行库。注意--seed和--users这类带值参数,argv[++i]依赖参数顺序,文档里最好写清楚必须成对出现。另外推荐固定种子作为可选项而不是默认值,默认随机能让每次运行结果不一样,固定种子用于验证bug和复现统计结果。
3.3 主循环:独立状态、出金间隔直方图与汇总统计
主循环是整段程序的核心:每位旅行者独立初始化一个PullState,然后连续抽指定次数,每抽完后按星级更新全局计数器。这里最容易错的是“所有人共用一个状态”,那样跑出来的不是一群独立玩家,而是一个连续抽了几十万次的疯子,出金间隔和保底分布全部失真。
#include "pull_model.h" #include <vector> #include <numeric> int main(int argc, char* argv[]) { Options opt = parseArgs(argc, argv); unsigned seed = opt.fixedSeed ? opt.seed : static_cast<unsigned>( std::chrono::steady_clock::now() .time_since_epoch().count()); std::mt19937 rng(seed); // 出金间隔直方图:下标1~90表示间隔n抽出五星的次数 std::vector<unsigned long long> gapHist(91, 0); long long totalPulls = 0; // 总抽数 long long total5 = 0; // 总五星数 long long total4 = 0; // 总四星数 long long hitUp = 0; // UP命中数 long long missUp = 0; // UP歪掉数 for (int u = 0; u < opt.users; u++) { PullState st; // 每个旅行者独立的保底状态 for (int p = 0; p < opt.pulls; p++) { int gapBefore = st.pity5; // 抽之前记录当前已积累抽数 int star = pullOnce(rng, st); // 执行单抽 totalPulls++; if (star == 5) { total5++; gapHist[gapBefore + 1]++; // +1把0基间隔转成实际抽数 if (st.guarantee == 1) { // pullOnce里歪卡后才置1 missUp++; } else { hitUp++; } } else if (star == 4) { total4++; } } } double rate5 = 100.0 * total5 / totalPulls; double rate4 = 100.0 * total4 / totalPulls; double upRate = total5 > 0 ? 100.0 * hitUp / total5 : 0.0; std::cout << std::fixed << std::setprecision(3); if (opt.csv) { std::cout << seed << "," << totalPulls << "," << total5 << "," << rate5 << "," << upRate << "\n"; } else { std::cout << "总抽数: " << totalPulls << "\n"; std::cout << "五星总数: " << total5 << " 五星率: " << rate5 << "%\n"; std::cout << "四星总数: " << total4 << " 四星率: " << rate4 << "%\n"; std::cout << "UP占比(占总五星): " << upRate << "%\n"; std::cout << "大保底触发次数: " << missUp << "\n"; } return 0; }间隔直方图的gapBefore + 1是一个隐蔽点:pity5初始为0,抽完后如果出五星,抽之前的pity5值就是“距上次五星后又抽了多少抽但不含本次”,加1才是真正间隔。比如第1抽出五星,gapBefore=0,间隔就是1。这个直方图是判断模拟器是否还原保底机制的直观证据——正常情况下90抽间隔的柱形会明显高于附近柱形。
输出部分做了两种模式:正常模式打印完整统计给人看,CSV模式只输出一行机器可读数字,方便第4章脚本批量收集。UP占比的统计口径是“出五星中UP角色占比”,长期应接近50%,歪卡越多这个数越低。大保底触发次数等于歪卡总数,因为每次歪卡都会让下一个五星强制UP。
4. 统计验证与参数调优:100万抽验证1.600%和90抽保底是否成立
4.1 为什么必须跑大样本验证
写完之后第一件事不是跑一遍看结果,而是设计验证方案。概率模拟最大的陷阱是“跑得起来但结果不对”,没有参照系很难发现。参照系就是官方公示的综合概率:五星约1.600%,四星约13.000%,50%UP率,90抽保底。每次修改代码后都应该跑一组固定种子的批量数据,把五星率捞出来看是否落在1.6%附近。误差超过0.1个百分点就该回头查逻辑。
单次模拟的随机波动很大:1000抽可能出20个五星也可能出12个五星,波动率约14%。要稳定验证,样本量至少要到10万抽以上,这样五星率的标准误差压到0.12%以内,才能区分“真实1.6%”和“误用综合概率的2.8%”。所以这份资源里的模拟器必须支持大量用户和长抽数,光靠手动点按钮的图形程序根本没法验证。
4.2 用固定种子做可复现实验
验证实验必须可复现,否则调了一版参数后没法横向对比。我的习惯是固定10组不同种子,每组跑2万用户、每人180抽,把五星率记录下来求均值和极差。这样既能看到稳定性,又能保证别人用同样命令能复现我的结果。
#!/bin/bash for seed in 101 202 303 404 505; do ./simulate --users 20000 --pulls 180 --seed $seed --csv done这段脚本用5个固定种子跑了5轮,每轮36万抽,总共180万抽。--csv让每轮只输出一行种子,总抽数,五星数,五星率,UP占比。跑完后可以用awk快速算均值,比如awk -F, '{sum += $4} END {print sum / NR}'。固定种子的好处是哪怕你改代码改到一半,只要种子不变,对比结果就有意义;这也是我后来每次调参都先留一份基准结果的原因,相当于给概率模型上了份后悔药。
跑出来的五星率如果落1.58%~1.62%区间,UP占比落49%~51%区间,基本可以认定这个模拟器忠实还原了官方规则。四星率和13%的对比同理,但四星保底的边界情形需要单独看。
4.3 出金间隔直方图:第90抽的柱形说明保底真实存在
光看五星率还不够,因为有些错误实现也能凑出接近1.6%的五星率,比如把基础概率调到0.9%再硬凑。更严格的验证是看“每次五星之间的间隔分布”。在0.6%基础概率下,绝大多数五星集中在80~90抽区间,因为运气好的人极少,大部分人都是靠保底兜住。这份资源的gapHist直方图正好能输出这个分布。
正常数据下,间隔1~79抽的柱形都很矮,间隔80~89抽慢慢升高,第90抽因为强制保底会有一个明显峰值。如果看到间隔90的柱形和间隔10一样矮,说明保底概率没生效或计数器写错了。这张直方图比单个均值诚实得多,相当于把整个模拟器的行为摊开在桌上,哪里不对一眼就能看出来。
验证通过后,参数调优才有意义。想模拟不同命座、不同抽数策略,或把五星概率改成其他游戏的基准值,只需要改pullOnce里的d5(0.006)和保底上限,不用动主循环。这也是为什么我坚持把单抽逻辑独立成函数——主程序和概率模型解耦后,调参变成改两个数字的事。
5. 避坑与排查:种子、计数器、概率值、编码四类翻车点
5.1 每次运行结果一模一样:随机种子被写死了
现象:程序连续跑多次,输出完全相同。原因:源码里直接写了std::mt19937 rng(42),或者random_device在当前环境失效导致种子恒定。解决:默认用纳秒级时间戳做种子,只有显式传入--seed才用固定值。示例里steady_clock::now().time_since_epoch().count()计算的是程序启动至今的纳秒数,连续两次启动足够产生差异。如果追求更高熵,可以混入当前进程ID。
5.2 五星率超过2.5%:把综合概率当成了单抽概率
现象:100万抽跑完五星率稳定在2.8%附近。原因:代码里写的是d5(0.016),把“综合概率1.600%”直接当基础概率用。解决:区分基础概率和综合概率。综合概率是包含保底后的统计期望,实现时必须用0.6%做普通抽判定,用90抽计数器做保底兜底。验证方法就是跑10万抽看结果是否收敛到1.6%,这个我已经在第4章踩过,现在每改一次概率值就强制跑一遍回归。
5.3 保底提前到手:计数器起始值或判定顺序错了
现象:统计里出现很多间隔89抽的五星,却几乎没有间隔90抽的。原因:pity5初始值设成1,导致第一次单抽的判定就从1开始累加,实际第89抽就满足了90保底条件。解决:统一计数口径。我采用的约定是pity5=0表示“刚出过五星或初始状态”,每次判定前先自增,自增后达到90才触发保底。这样第90抽的间隔一定是90,不会出现89。四星同理,10抽保底的边界不能用>=10和>10混用。
5.4 统计输出乱成一团:浮点精度和编码双坑
现象:五星率打印成1.6000000000000001,或者Windows控制台里出现一堆乱码。原因:cout默认精度是6位有效数字,但累计超大样本量后double内部舍入误差会暴露成不整洁的小数;乱码则是源文件UTF-8编码与控制台GBK不匹配。解决:输出前用std::fixed << std::setprecision(3)固定三位小数,统计累加全部用long long而非float,只在最后一步转成百分比double。中文乱码直接用英文列名,省去setlocale的跨平台麻烦。
5.5 百万抽级性能卡顿:分布对象重复构造和输出拖后腿
现象:100万抽跑了十几秒。原因:每次pullOnce里都构造一个新的bernoulli_distribution,百万次构造开销不小;另外每次循环都cout也会拖慢。解决:把分布对象声明成static复用,主循环只更新内存统计,全部跑完一次性输出。优化后100万抽在我的机器上约0.3秒。如果还想更快,可以把主循环里每个用户的独立状态改成数组批量处理,但这对普通验证没什么必要。
6. 进阶用法:把模拟器改造成任意抽卡模型的验证工具
这份资源的价值不止于复现原神规则。把概率参数从硬编码抽出来,它就能变成通用抽卡验证工具。具体做法是给PullState和pullOnce增加一个ModelConfig结构体,把五星基础概率、保底抽数、四星基础概率、四星保底抽数、UP规则都放进去,然后用命令行参数覆盖默认值。这样其他游戏的卡池也能跑,改动量控制在30行以内。
struct ModelConfig { double p5 = 0.006; // 五星基础概率 int pity5 = 90; // 五星保底抽数 double p4 = 0.051; // 四星基础概率 int pity4 = 10; // 四星保底抽数 bool enableUp = true; // 是否开启UP角色机制 };再往后可以加一个--export参数,把gapHist直方图写成CSV文件,一行一个间隔值,外部用脚本画出资出图。对比不同游戏模型时,直方图并排放在一起就能直观看到保底位置和概率密度的差异。比如某游戏是60抽硬保底、基础概率2%,直方图的峰就会在60抽;而基础概率高、保底长的游戏,峰会更矮更平。这套可视化流程我用来对比过不下五款手游的抽取规则,每次都能快速定位模型的“脾气”。
最容易被忽略的是把模拟器当作回归测试工具。我后来给其他项目写掉落模拟时,每次改完规则都先跑一遍--users 50000 --pulls 200 --seed 888这组固定命令,确认五星率和90抽峰值没跑偏才开始接业务逻辑。那种“差不多就行”的心态在概率代码上行不通,因为概率模型的错误往往要百万样本才显形。从那以后我每次提交模拟类代码前都强制自己走一遍种子复现和概率回归的流程,省下的都是线上查数据的熬夜时间。希望帮到你。
本文还有配套的精品资源,点击获取