☰
抽卡模拟器概率引擎解析:从保底机制到软保底模拟实现
2026/10/14 19:36:18 网站建设 项目流程

简介:《原神抽卡模拟器》是一款基于C++语言开发的抽卡模拟程序,面向原神玩家和初学C++的读者,旨在还原游戏内祈愿抽卡流程,无需消耗原石即可体验角色抽取,理解概率与保底机制。压缩包共含38个文件,整体大小约4.04MB;内部既有cpp/hpp源码和Visual Studio工程文件(sln/vcxproj/filters),也有exe可执行程序、txt数据与说明文档,以及编译生成的obj、pdb、log等中间产物,既可直接运行试玩,也能按需修改学习。项目代码覆盖随机数生成、祈愿池定义、保底计数和抽卡统计等关键模块,main.cpp与wish_function.cpp完整呈现了从用户操作到结果输出的逻辑链路,Md5.hpp与登录日志则展示账号校验和使用记录的设计思路。对学习C++类、随机数、GUI以及概率算法的人来说是不错的综合练手实例,已有1593人浏览学习,适用于课程设计、游戏逻辑入门或抽卡机制分析等场景。这份资源提供了可直接调用的类封装思路和概率参数配置,具有一定工程参考价值。

1. 抽卡模拟器:别把它当运气测试器,它其实是一台概率引擎

很多人拿到这类 zip 包的第一反应是解压、运行、连续点抽取,看它什么时候冒金光。点多了就觉得这是个测试欧非的玄学玩具。但从工程角度看,抽卡模拟器真正有价值的部分是藏在代码里的概率引擎:基础概率、硬保底、软保底、UP 判定、定轨,这些游戏规则会被翻译成一个随机数函数和一组状态计数器。数值策划拿它验证期望抽数,游戏开发者拿它试改版本概率方案,普通玩家拿它估算准备多少抽才够。这个 zip 里装的其实是这些东西。我按自己做这类项目的顺序,把模型、代码、验证和坑一次讲完,你可以照着写完一个能跑、能信、能调参的版本。

2. 把抽卡规则翻译成可计算的概率模型:三类卡池的保底、软保底与 UP 判定

2.1 三类卡池的五星概率参数与保底结构

抽卡模拟器的核心不是随机数,而是状态。可以这么理解:如果只有一个random.random(),它永远不知道什么叫“90 抽必出”,也不知道什么叫“歪了一次下次必 UP”。保底是规则,规则必须落在代码里的某个计数器上。所以建模第一件事,是把卡池的参数抽出来,做成可配置项,而不是写死在 if 里。

这里给一张基础参数表,数值以公开社区讨论中比较共识的版本为参照,具体版本可能有差异,下文所有实现都围绕这张表展开:

卡池类型五星基础概率硬保底软保底UP / 定轨结构
角色活动池0.6%90 抽73 抽后概率抬升50% UP,歪后下次五星必 UP
武器活动池0.7%80 抽社区说法多认为无定轨命定值,歪两次后必出定轨
常驻池0.6%90 抽73 抽后概率抬升无 UP,纯随机五星

我一般会把这张表直接翻译成一个配置结构,比如一个dataclass或一个 dict,里面每个字段名和表头一一对应。后续写模拟、写校验、写调参,全部引用这份配置,不要在不同函数里各写一遍数字。这样改参数只动一处,跑完对比也有据可查。

2.2 软保底的数学本质:为什么 90 抽之前概率就开始爬坡

如果只有基础概率 0.6%,理论上平均要 167 抽才能出一个五星;就算加上“第 90 抽必出”的硬保底,平均抽数仍然被拖得很高,玩家体验会非常差。所以主流卡池设计里都加入了软保底:在达到硬保底抽数之前,概率开始分段抬升。这一抬,把平均出金抽数从 167 拉到了公开综合概率对应的约 62.5 抽。

软保底本质是一个分段函数。以角色池为例,数学形式是这样的:

  • 第 1~73 抽:保持基础概率 0.6%
  • 第 74~89 抽:概率线性递增,写成p(n) = 0.006 + slope * (n - 74),最大截断到 1.0
  • 第 90 抽:硬保底,概率直接为 100%

这里的slope就是软保底斜率,它决定了概率爬坡的速度,也直接决定了综合出率最终落在哪。这个值不能拍脑袋乱填,后面第 4 章会讲怎么用模拟反向校准它。值得注意的是,n的起点是 1,不是 0,如果你把第一次抽取当成第 0 抽,整个保底区间会平移一位,最后统计出来的均值差一点点,但分布形状会明显错位。

2.3 UP 与歪的规则:一个只有 0/1 的状态位

星级判定和 UP 判定是两步,顺序不能反。先判断这一抽是不是五星,如果连五星都不是,根本不会进入 UP 判定。如果是五星,再根据当前状态和随机数决定出的是不是 UP。

这里只需要一个 0/1 状态位,通常叫guarantee,含义是“大保底是否激活”。状态迁移可以用一张小表说清楚:

当前状态出金结果状态迁移
小保底(guarantee=0)UP 五星保持小保底
小保底(guarantee=0)非 UP 五星进入大保底
大保底(guarantee=1)任意五星回到小保底

注意pity和guarantee是两个独立计数器,千万别混在一个变量里。pity只关心“连续多少抽没出五星”,任何五星出现都会让它清零;guarantee只关心“上一次五星歪没歪”,只有非 UP 五星才置 1。很多翻车实现都是把这两个状态揉在一起,后面第 5 章专门讲。

3. 从均匀随机数到抽卡函数:写一个带保底状态的 GachaSystem

3.1 均匀随机数如何变成按概率抽五星

Python 的random.random()返回[0, 1)均匀分布,要把它翻译成离散的星级结果,最常见做法是区间切分。先算一个随机数,落在哪个区间就返回哪个星级。

import random def roll_rarity(base_5=0.006, base_4=0.051, rng=None): rng = rng or random r = rng.random() # [0, 1) 均匀分布 if r < base_5: return 5 if r < base_5 + base_4: return 4 return 3

这里的逻辑是:r落在[0, 0.006)记五星,落在[0.006, 0.057)记四星,其余记三星。base_4是绝对概率,不是“排除五星之后的条件概率”,因为四星概率本身包含在整套星级概率里,写成[0.006, 0.057)这种累积区间就不会出错。

为什么不用random.choice(["五星", "四星", "三星"], weights=[...])?因为这里的五星概率会随着保底抽数变化,每个时刻的 weights 都不一样,用区间切分写起来更直白,也更容易和后面的current_rate()组合。这个函数本身是“无状态”的,保底逻辑在下一小节的类里处理。

3.2 抽卡状态类:pity 与 guarantee 两个计数器的维护

把前面两张表落进代码,就是一个类加两个计数器。我习惯把draw_one和五星后的处理逻辑分开,这样后面扩展武器池定轨时只需要重写一个方法。

class GachaSystem: BASE_RATE = 0.006 SOFT_PITY_START = 73 # 连续 73 抽未出金后,下一抽开始爬坡 SLOPE = 0.005 # 软保底近似斜率,建议用 4.2 节方法校准 HARD_PITY = 90 UP_PROB = 0.5 def __init__(self): self.pity = 0 # 连续未出五星的次数 self.guarantee = 0 # 0=小保底,1=大保底,下一次五星必为 UP def current_rate(self): if self.pity >= self.HARD_PITY - 1: return 1.0 if self.pity >= self.SOFT_PITY_START: return min(1.0, self.BASE_RATE + self.SLOPE * (self.pity - self.SOFT_PITY_START + 1)) return self.BASE_RATE def draw_one(self, rng=None): rng = rng or random if rng.random() < self.current_rate(): return self._on_five(rng) self.pity += 1 return (3, False) # 非五星先统一返回,四星保底逻辑见 3.3 def _on_five(self, rng): self.pity = 0 # 任意五星出现,pity 立刻清零 if self.guarantee == 1: self.guarantee = 0 return (5, True) # 大保底必然 UP if rng.random() < self.UP_PROB: return (5, True) self.guarantee = 1 return (5, False)

关键点在于执行顺序:第 1 抽时pity=0,current_rate()返回基础概率;如果没出货,pity变成 1;当pity走到 89 时,说明前面 89 抽都没出金,current_rate()直接返回 1.0,这就是硬保底。

五星分支里,_on_five先把pity清零,再判断 UP。guarantee=1时不再 roll 那一下,直接出 UP;否则按 50% 概率决定歪不歪。这里SLOPE的单位是绝对概率增量,不是百分比,改成0.05就代表每抽多 5% 概率,跑起来综合出率会暴涨,调参时注意单位。

3.3 十连抽:逐抽判定,四星保底单独处理

十连最容易写错的地方,是有人想“一次 roll 一个随机数,决定这十连整体运势”。这种做法会让金色要么铺满、要么全黑,完全失真。正确做法是逐抽调用draw_one,每抽独立判定。四星有一个十连内保底:如果前 9 抽都没出四星及以上,第 10 抽强制至少四星。

def draw_ten(self, rng=None): rng = rng or random results = [] for _ in range(9): # 前 9 抽正常抽取 results.append(self.draw_one(rng)) if any(r[0] >= 4 for r in results): results.append(self.draw_one(rng)) # 已有四星,第 10 抽正常 else: # 十连保底:第 10 抽至少四星,出五星按正常流程 results.append(self._guaranteed_4(rng)) return results def _guaranteed_4(self, rng): if rng.random() < self.current_rate(): return self._on_five(rng) self.pity += 1 return (4, False) # 强制四星,不消耗五星保底

这里的_guaranteed_4先按当前五星概率尝试一次,如果出了五星就走_on_five,等于这第 10 抽本来就是个五星,只是靠保底把下限抬到四星;如果没出,就固定返回四星,同时pity继续加 1,因为这次抽取确实不是五星。

一个扩展点:这里实现的是“单次十连内保底四星”。如果游戏规则是跨十连累计的四星保底,就需要给四星也建一个独立的pity计数器,逻辑和五星完全一样,只是数值改成 9 抽一保底。做之前先确认目标游戏的规则再动手。

4. 跑十万次模拟验证:分布形状、均值与软保底斜率校准

4.1 出金抽数直方图与分布形状

模型写完了,第一件事不是看代码漂不漂亮,而是跑批量模拟验证结果对不对。我习惯模拟 10 万个“从零开始抽到第一个五星就停”的样本,统计每个抽数对应的出货频次。

from collections import Counter def simulate_first_five(n=100_000, seed=2024): rng = random.Random(seed) dist = Counter() for _ in range(n): g = GachaSystem() for pulls in range(1, GachaSystem.HARD_PITY + 1): if g.draw_one(rng)[0] == 5: dist[pulls] += 1 break mean = sum(k * v for k, v in dist.items()) / n return dist, mean

每次模拟都新建一个GachaSystem,是为了避免上一次模拟的pity和guarantee残留到下一次。这个细节很重要,很多人直接在外面 new 一个对象然后循环跑,最后统计出来的出金分布会依赖上一次抽数的剩余状态,均值被拉低。

跑完看两个数:平均出金抽数应该在 62 附近,对应综合概率 1.6%;第 73 抽之前出金的样本占比约 35%,剩下约 65% 的人要吃软保底或硬保底。如果占比严重偏掉,优先检查SOFT_PITY_START和SLOPE的取值。

4.2 用二分搜索校准软保底斜率:不要手解方程

软保底斜率直接决定综合出率,但因为有截断、有硬保底、有四星保底这些非线性规则,手解方程很麻烦。工程上更常见的做法是把整个模拟当成一个黑匣子,用二分搜索把斜率逼到目标综合出率。

def estimate_5star_rate(slope, n_pulls=200_000, seed=1): rng = random.Random(seed) old = GachaSystem.SLOPE GachaSystem.SLOPE = slope try: g = GachaSystem() five = 0 for _ in range(n_pulls): if g.draw_one(rng)[0] == 5: five += 1 return five / n_pulls finally: GachaSystem.SLOPE = old def calibrate_slope(target=0.016, lo=0.0, hi=0.01): for _ in range(30): mid = (lo + hi) / 2 rate = estimate_5star_rate(mid) if rate < target: lo = mid else: hi = mid return (lo + hi) / 2

逐个解释参数:n_pulls是每次评估用的总抽数,越大结果越稳但越慢,20 万次在单机 Python 里也就是几秒;lo和hi是二分搜索的区间,斜率不可能为负,也不太可能超过 0.01,这个上下界够用;target=0.016是综合出率目标。

有个坑必须提醒:estimate_5star_rate每次必须传同一个seed,否则每次模拟的随机序列不同,评估结果自带噪声,二分搜索会在目标值附近来回抖,不收敛。固定种子后,虽然结果仍不等于真实值,但每次评估的相对波动被固定下来,二分的收敛方向就是对的。上面临时改类属性的写法只适合脚本演示,如果要做并行模拟,应该把SLOPE改成实例属性,每个 worker 各持一份配置。

4.3 平均出金抽数与解析预期对照

模拟结果出来之后,要跟解析预期对照,而不是只看“数字差不多”。我用下面这张表做最终验收:

检查项解析预期模拟合理范围
平均出金抽数1 / 0.016 ≈ 62.562.0 ~ 63.0
90 抽硬保底触底角占比约 19% 上下视斜率浮动
第 73 抽前出金占比约 35%34% ~ 36%

平均出金抽数偏离超过 1 抽,肯定哪里有问题,别急着换参数,先回去查边界条件:current_rate里是不是把硬保底写在软保底前面了?pity初始值是 0 还是 1?这两个都是最容易犯的低级错误。

模拟和解析本来就有抽样误差,10 万次模拟下平均值的标准误差大约在 0.1 抽左右,所以 62.2 和 62.8 都是正常的。真正不正常的,是跑出 58 或 68 这种明显偏离,那大概率不是玄学,是代码写错了。

5. 抽卡模拟器避坑:5 个让模拟结果失真、代码翻车的常见问题

5.1 十连抽“一次 roll 一个随机数”导致全金或全黑

现象:模拟里十连的结果要么一连串金色、要么全黑,且和单抽十次的分布对不上。

原因:把十连当成了“一次抽取乘 10”,只生成一个随机数决定整组结果,丢失了每个位置独立判定的特性。

解决:十连必须逐抽调用draw_one,每一抽都重新取一个随机数、重新读取当前pity。十连里的四星保底是额外规则,也只影响保底位的判定,不影响其他位置的随机性。

5.2 保底重置写成“出了 UP 才重置”

现象:模拟中出了非 UP 五星后,下一次出金仍然按 50% 判断 UP,大保底从未生效。

原因:把“五星事件”和“UP 事件”绑在一起处理,代码里只有一条if is_up: self.pity = 0,非 UP 五星没有重置pity。

解决:pity清零的条件只有一个,就是出了五星,不管它是 UP 还是非 UP。guarantee才是只管 UP 的状态:非 UP 五星置 1,UP 五星清零,任何时候出了五星pity都必须先归零,再处理guarantee。

5.3 软保底概率没有封顶,导致综合出率虚高

现象:模拟均值跑到 50 抽以下,明显低于综合概率对应的 62.5。

原因:current_rate返回的线性递增值在某个抽数越过了 1.0,但代码里没有min(1.0, ...),导致超区间的概率被当成 100% 以上使用,等价于每抽必出。

解决:软保底区间里始终封顶;硬保底那一抽单独返回 1.0,不要依赖线性公式自然到位。检查时重点看pity >= HARD_PITY - 1这个条件是不是写在软保底分支前面,写反了概率会在第 89 抽就被截断。

5.4 固定随机种子导致“每次运行结果完全一样”

现象:发布给别人用的版本,每次运行模拟,抽卡结果一毛一样,被质疑程序是假的。

原因:代码里写死random.seed(42),随机序列被固定了。

解决:区分两种使用场景。测试和调参时要固定种子,保证可复现;交付给用户运行时不要传种子,让系统熵初始化。工程上可以在命令行加一个--seed参数,默认不传,传了就复现,这样两边都不耽误。

5.5 小样本模拟下结论是玄学

现象:只跑 1000 次模拟,综合出率算出来 1.7%,换一个种子变成 1.4%,于是断定模型写错了。

原因:五星出金抽数方差很大,加上保底机制尾部权重高,1000 次样本的标准误差太大,任何结论都站不住。

解决:日常验证至少 5 万次起步;做参数对比时用固定种子,并记录平均抽数和触底角占比。粗略算误差的话,10 万次模拟下综合出率的标准误差约 0.08%,也就是说 1.6% 的估计值在 1.52%~1.68% 之间波动都属于正常,做判断时别盯着小数点后第二位不放。

6. 把模拟器变成数值实验台:条件抽数预估与武器池定轨扩展

6.1 “垫了 70 抽”条件分布:直接改状态,不要随机跑过去

抽卡玩家经常问一个问题:我已经垫了 70 抽还没出金,接下来平均还要多少抽出?这个问题的答案不能靠随机跑 70 抽再筛样本,因为“垫了 70 抽没出金”本身是一个条件事件,随机跑的过程中已经出货的样本会被筛掉,剩下的分布是截断后的条件分布,跟你直接设置状态得到的结果不一样。

def conditional_first_five(pity=70, guarantee=0, n=100_000, seed=7): rng = random.Random(seed) dist = Counter() for _ in range(n): g = GachaSystem() g.pity = pity # 直接构造“已经连续N抽未出金”的初始状态 g.guarantee = guarantee for extra in range(1, GachaSystem.HARD_PITY - pity + 1): if g.draw_one(rng)[0] == 5: dist[extra] += 1 break mean = sum(k * v for k, v in dist.items()) / n return dist, mean

直接给g.pity = pity赋值,等价于“我已经确认前面 N 抽都没出金”这个前提,然后继续模拟。这样能看出垫到 70 抽之后,因为已经踩进软保底区间,接下来出金的平均抽数会明显低于全量均值。这个条件分布对做活动投放和玩家攻略都很有用,是模拟器从“玩具”变成“工具”的关键一步。

6.2 武器池定轨扩展:命定值状态迁移

武器池和角色池共用了五星保底逻辑,差别只在“出了五星之后怎么决定给哪把武器”。所以扩展时不需要改draw_one,只要重写_on_five。

class WeaponGacha(GachaSystem): BASE_RATE = 0.007 HARD_PITY = 80 def __init__(self): super().__init__() self.fate_points = 0 # 命定值:0/1/2 def _on_five(self, rng): self.pity = 0 if self.fate_points >= 2: # 连续歪两次后,下一把必为定轨 self.fate_points = 0 return (5, True) if rng.random() < 0.5: # 示例概率,按实际规则填入 self.fate_points = 0 return (5, True) self.fate_points += 1 return (5, False)

命定值的核心状态迁移是:出定轨就清零;歪了就加 1;加到 2 之后再出五星,强制出定轨并清零。这里 0.5 是示例概率,实际武器池是否 50% 出定轨要看目标规则,我一般把这类数字也提成类常量,方便改。继承的好处是pity的硬保底和软保底逻辑完全复用,武器池只需关注自己的五星归属规则。

我现在改完一个概率公式,第一件事绝不是自己抽几发看感觉,而是直接跑 5 万次模拟,对比均值、触底角占比、前 73 抽出货占比三个数;改参数前把旧参数的模拟结果存一份,改完并排对比,一眼就知道斜率调大了还是调小了。这个习惯帮我避掉了不少拍脑袋调参的坑,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询