ARTICLE DETAIL

资讯详情

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

C# WinForm抽卡模拟器:概率建模、保底机制与十万次分布验证

C# WinForm抽卡模拟器:概率建模、保底机制与十万次分布验证

做抽卡模拟这件事,最早源于一个很朴素的疑问:为什么有人十连双黄,有人连续吃满保底还能歪?嘴上说“玄学”,心里其实都清楚这是概率,但概率这东西光看公告上的百分比是没有体感的。我前后写过三版抽卡模拟工具,前两版要么是控制台里滚动的文字,要么是网页上一堆看不懂的图表,最后一版我干脆用 WinForm 重写了一遍,做成一个能双击运行、点一下就出结果、还能把十万次模拟跑完画成分布图的小软件。这个项目的定位很明确:它把某款开放世界二次元手游初始风之国区域的混合卡池抽卡规则,完整地搬进了一个 Windows 桌面程序里,用可视化的方式回答“这个池子到底值不值得抽”“平均多少抽能出一个五星”这类问题。它适合三类人:想验证自己运气是否真的差的普通玩家、想学习 C# 桌面开发和概率建模的入门开发者、以及想把开源代码拿去改成自己项目模拟器的人。下面我把这个软件从设计思路到代码实现、从编译运行到踩坑排查,完整地摊开讲一遍。

1. 先搞清楚这个模拟器到底在解决什么问题

1.1 从“抽卡玄学”到可验证的概率模型

大部分人抱怨抽卡,抱怨的其实是“不确定性带来的失控感”。公告上写着五星基础概率 0.6%,综合概率 1.6%,这两个数字放一起,很多人第一反应是“那我抽 100 次应该有 1.6 个五星”,实际上并不是这么算的。综合概率里包含了保底和概率递增带来的补偿,它是一个长期期望值,不是单次抽卡的即时概率。模拟器要做的第一件事,就是把这个“公告数字”和“玩家体感”之间的鸿沟填上。

我在软件里做了三件事来落实这一点。第一是逐抽记录:每一次抽取都会记下当前是第几抽、当前实际生效的五星概率是多少、有没有触发保底、出的是常驻还是限定。第二是多次实验的分布统计,不是跑一次就完事,而是跑一万轮完整抽卡,统计每一轮的出货抽数,最后画出分布曲线。第三是结果可导出,导出成 CSV,方便你自己在表格软件里再算一遍,别信我写的程序,信你自己算出来的数。

注意:模拟器里的所有概率数值,都应该来自游戏内公示的实际数据,代码里只是把数据结构留出来。我在示例配置里填的是演示用的数值,你自己用的时候必须按实际公示填,否则跑出来的结论一文不值。

1.2 为什么选 WinForm 而不是更时髦的框架

这个问题我被问过很多次。现在做桌面程序,可选项太多了:WPF、Avalonia、Electron、甚至直接用浏览器做个本地页面。我最后还是选了 WinForm,理由很实在。

第一是部署成本。WinForm 编译出来就是一个 exe,加上几个 dll,扔给一个完全不懂技术的朋友,他双击就能跑,不需要装运行时(.NET Framework 版本在 Windows 上基本是自带的),不需要开浏览器,不需要信任什么本地服务。第二是开发速度。这个软件的 UI 复杂度很低,就是一个参数面板 + 一个结果列表 + 一个图表区域,WinForm 拖控件半小时能出来雏形,剩下的时间全花在概率逻辑和性能上,这才是我真正愿意花时间的地方。第三是资源占用。跑十万次模拟的时候,内存和 CPU 都吃紧,Electron 那套东西光是空载就几百兆,对一个纯计算型工具来说是浪费。

当然 WinForm 的短板也很明显:界面丑、高分屏适配差、动画基本没有。我的处理方式是接受它,不硬凹。这个工具不是给普通用户看的产品,是个分析工具,UI 干净、能做该做的事就够了。我见过有人非要给模拟器加抽卡动画和音效,结果性能全被 UI 吃掉了,十万次模拟跑了两分钟还没出结果,这就是本末倒置。

1.3 功能边界与谁会真的用得上

说清楚它能做什么,也要说清楚它不能做什么。这个软件能做:单池或多池混合抽取模拟、保底状态跟踪、大批量重复实验、结果分布统计与导出、不同参数方案对比。它不能做:预测你下一抽会不会出、修改任何游戏内数据、替代官方的概率公示。

会真正用上它的人大概有三类。一类是想搞明白“我这个号算不算非”的玩家,跑个一万轮模拟,把结果和自己的抽卡记录对比一下,心里就有数了。第二类是学 C# 的同学,这个项目麻雀虽小五脏俱全,有界面的数据绑定、有异步任务、有随机数正确用法、有文件读写,是个不错的练手材料。第三类是想做类似模拟器的人,比如模拟别的游戏的卡池、模拟盲盒、模拟开箱,代码结构可以直接搬。

2. 核心机制拆解:卡池概率与保底逻辑怎么落到代码里

2.1 混池的构成:权重分配是第一道坎

“混池”这个词听起来简单,实际拆起来是有层次的。一个混合卡池至少包含三层结构:池子层(这期有几个可抽的池子,各自的抽取权重是多少)、稀有度层(抽到这一抽后,落进五星、四星、三星的概率分别是多少)、具体条目层(确定星级后,同星级里的若干个角色或物品,各自被抽中的概率是多少)。

很多粗糙的模拟器只做了第二层,把所有五星揉成一坨,结果就是“出了五星但不知道是谁”,这在实际使用中是没有意义的,因为你真正关心的是“限定角色什么时候出”。所以我在数据结构上强制分了这三层:

public sealed class PoolItem { public string Id { get; init; } = string.Empty; public string DisplayName { get; init; } = string.Empty; public int Rarity { get; init; } // 3 / 4 / 5 public double Weight { get; init; } = 1.0; // 同星级内的相对权重 public bool IsLimited { get; init; } // 是否属于限定条目 } public sealed class GachaPool { public string PoolName { get; init; } = string.Empty; public double PoolWeight { get; init; } = 1.0; // 池子层的抽取权重 public Dictionary<int, double> RarityRate { get; init; } = new(); public List<PoolItem> Items { get; init; } = new(); }

这里有个细节值得说:同星级内的权重,绝大多数情况下是均分的,但有些池子会做“偏向”,比如某个五星在双 UP 期间权重略高。所以我把Weight做成了可配置项,默认都填 1.0,需要偏向的时候改数字就行,不用改代码。池子层的PoolWeight同理,混合池里角色池和武器池按什么比例进入抽取,是一个参数,不是一个硬编码。

2.2 概率递增与硬保底的数学表达

保底机制的本质是“条件概率的补偿”。基础概率固定的话,长期出货率会显著低于公告的综合概率,所以设计上会加两套补偿:软保底的递增区间和硬保底的强制出货。

我把它抽象成三个参数:SoftPityStart(从第几抽开始递增)、HardPity(第几抽必定出货)、RateStep(每抽递增多少)。计算当前抽的实际概率时是这样的:

public double GetActualRate(int pityCountBeforeDraw) { int currentDraw = pityCountBeforeDraw + 1; // 硬保底:这一抽无论如何都出 if (currentDraw >= HardPity) return 1.0; // 还没进入递增区间,就是基础概率 if (currentDraw <= SoftPityStart) return BaseRate; // 进入递增区间,逐抽叠加 double rate = BaseRate + (currentDraw - SoftPityStart) * RateStep; return Math.Min(rate, 1.0); }

这段代码只有十行,但它解释了为什么“综合概率”会高于“基础概率”。假设基础概率 0.6%,从第 74 抽开始递增,每抽加 6%,硬保底 90 抽。那么第 74 抽是 0.6%,第 75 抽 6.6%,第 76 抽 12.6%……到第 89 抽已经接近 100%。这套递增让绝大多数出货集中在 75 到 85 抽之间,只有极少数倒霉蛋才会吃满 90。综合算下来,数学期望确实会落在 1.6% 左右。你可以自己拿纸笔推导一遍,验证参数之间的自洽性,这是判断一份卡池配置是否可信的最快方法。

实操心得:调试概率参数时,先别急着跑十万次。写一个单元测试,跑一千次一万次,看 5 星出货的抽数中位数是否落在你预期的区间。中位数比平均值更敏感,参数改错了中位数会立刻跑偏,平均值反而因为长尾效应显得没那么明显。

2.3 随机数的正确姿势:别用一把梭的 Random

这一块是我认为所有模拟器项目里最容易被写错的地方,没有之一。System.Random在 .NET Framework 下的默认构造是拿系统时间做种子的,精度有限。如果你在循环里反复new Random(),很可能会拿到同一批种子,产生完全相同的序列——表现出来就是“每次跑结果一模一样”,很多人会以为是概率算错了,其实是随机数写错了。

正确的做法有三种。第一种是全局只创建一个实例,所有抽取共享,这是最简单也最常用的方案。第二种是在 .NET 6 及以上用Random.Shared,它是线程安全的,多线程场景下直接用。第三种是用RandomNumberGenerator做密码学级别的随机,性能差一个数量级,但对这个工具来说完全没必要,我们不需要防作弊,只需要统计意义上的均匀。

还有一个细节:Random.NextDouble()返回的是[0, 1)区间的值,判断概率时必须用<而不是<=。别笑,这个边界写错会导致概率为 0 的条目偶尔被抽中,排查起来非常痛苦。

// 全局唯一实例,别在方法里 new private static readonly Random Rng = new Random(); public static bool Roll(double probability) { if (probability <= 0) return false; if (probability >= 1) return true; return Rng.NextDouble() < probability; // 注意是小于号 } // 按权重随机选一个条目 public static PoolItem PickWeighted(IReadOnlyList<PoolItem> items) { double total = items.Sum(i => i.Weight); double r = Rng.NextDouble() * total; double acc = 0; foreach (var item in items) { acc += item.Weight; if (r < acc) return item; } return items[^1]; // 浮点误差兜底 }

PickWeighted里最后那个return items[^1]是浮点累加的保险。理论上r一定小于total,但浮点运算 occasionally 会因为精度问题让累加和差那么一点点,兜底一行代码能省掉后面几小时的诡异排查。

2.4 概率验算:跑十万次看分布长什么样

写完逻辑,一定要验算。我在软件里内置了一个“校准模式”,跑 100000 次单抽,统计各星级实际出现的频率,和配置的理论概率对比。如果偏差超过千分之五,说明权重分配或者计算路径有问题。

除了单抽,更要紧的是出货抽数的分布。这个分布是右偏的,大部分人集中在 75 到 85 抽,尾部拖到 90。我在统计模块里输出四个数字:平均值、中位数、P90(90% 的人在这个抽数内出货)、最大值。中位数告诉你“普通人体验”,P90 告诉你“非到什么程度算异常”,最大值告诉你“理论上最坏情况”。这三个数比一个平均值有用得多。

还有一个容易被忽视的验证:保底状态的正确清算。出了五星之后,保底计数必须归零;如果是常驻歪了,限定保底要累积;如果抽的是混池,还要考虑跨池的保底继承。这块逻辑是最容易出 bug 的地方,我见过不止一个模拟器在这里算错,导致跑出来的期望抽数比实际低一大截。

3. WinForm 工程实现里的关键细节

3.1 项目结构:分成四层,改起来才不痛苦

我把整个项目拆成了四个文件夹,这个结构是我改了第三版才定下来的:

GachaSim/ ├── Core/ // 纯逻辑:概率、抽卡、保底状态机 │ ├── GachaPool.cs │ ├── GachaEngine.cs │ └── PityState.cs ├── Data/ // 配置加载:JSON / XML 读取与校验 │ ├── PoolConfigLoader.cs │ └── ConfigValidator.cs ├── Stats/ // 统计:直方图、分位数、CSV 导出 │ ├── RunStatistics.cs │ └── CsvExporter.cs └── UI/ // 窗体、控件、事件 ├── MainForm.cs └── MainForm.Designer.cs

分层的好处很直接:Core 层完全不引用任何 WinForm 命名空间。这意味着我可以把 Core 单独拎出来写单元测试,也可以在别的项目里直接复用,比如做一个控制台版本的批量跑分工具,或者将来想加个 Web 前端,核心逻辑一行都不用改。很多人的 WinForm 项目写到最后,逻辑全塞在按钮的 Click 事件里,想改个概率算法得翻 800 行界面代码,那个痛苦我经历过一次就不想再来。

3.2 UI 线程与模拟循环:绝不能在主线程里跑十万次

这是 WinForm 开发最经典的坑。如果你在按钮点击事件里直接写一个十万次的循环,会发生什么?窗体完全卡死,Windows 会给它盖上“未响应”的白雾,用户以为程序崩了,直接强杀。而你辛辛苦苦跑的数据,全没了。

正确的做法是把计算扔到后台线程,用Task.Run包起来,通过IProgress<T>回传进度,用CancellationToken支持中途取消:

private CancellationTokenSource? _cts; private async void btnRun_Click(object sender, EventArgs e) { if (_cts != null) return; // 防止重复点击 _cts = new CancellationTokenSource(); var token = _cts.Token; btnRun.Enabled = false; btnCancel.Enabled = true; int rounds = (int)numRounds.Value; int drawsPerRound = (int)numDraws.Value; // 进度回调会自动切回 UI 线程,不用手动 Invoke var progress = new Progress<int>(done => { progressBar.Value = done * 100 / rounds; lblStatus.Text = $"已完成 {done} / {rounds} 轮"; }); try { var result = await Task.Run( () => _engine.RunBatch(rounds, drawsPerRound, progress, token), token); BindResult(result); // 回到 UI 线程更新图表 } catch (OperationCanceledException) { lblStatus.Text = "已取消"; } finally { _cts.Dispose(); _cts = null; btnRun.Enabled = true; btnCancel.Enabled = false; } }

这里有三个点值得展开。Progress<T>内部会捕获创建时的SynchronizationContext,所以在 UI 线程创建的Progress<int>,它的回调会自动 marshal 回 UI 线程,你不需要写Invoke,这是它比裸委托方便的地方。await之后的代码会在 UI 线程继续执行,所以BindResult可以直接操作控件。取消不能靠一个 bool 标志位硬中断,必须用token.ThrowIfCancellationRequested(),并且在能快速返回的循环里调用,否则用户点了取消还要等半分钟才响应。

注意:Progress<T>的回调是投递到消息队列的,如果后台跑得比 UI 刷新快,会积压大量消息导致界面更卡。我的处理是每 100 轮才报告一次进度,而不是每轮都报。

3.3 图片资源与界面闪烁:两个小问题,处理不好很影响体验

WinForm 加载图片最坑的地方是文件锁。用Image.FromFile(path)加载的图片,会一直占着那个文件句柄,直到 Image 被释放。表现出来就是:程序运行期间,你去删或者改资源目录里的图片,会提示“文件正在被使用”。解决办法是用流加载,加载完立刻释放流:

private static Image LoadImageSafe(string path) { using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); using var ms = new MemoryStream(); fs.CopyTo(ms); ms.Position = 0; return Image.FromStream(ms); // 依赖内存流,不依赖文件句柄 }

另一个问题是界面闪烁。结果列表刷新得比较频繁,默认的绘制方式会先擦背景再画内容,一刷就是一片白闪。解决方法是在窗体的构造函数里打开双缓冲:

public MainForm() { InitializeComponent(); SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); UpdateStyles(); }

OptimizedDoubleBuffer让绘制在后台缓冲区完成再整体贴上来,AllPaintingInWmPaint跳过擦背景那一步,两个一起开才能真的消掉闪烁。只开第一个效果有限,这是我实测出来的。

3.4 结果持久化:CSV 比数据库更适合这个场景

有人问我为什么不用 SQLite。理由很简单:这个工具的数据量不大(十万轮也就几 MB),用户真正需要的是能在 Excel 里打开看一眼,而不是查 SQL。CSV 在这件事上完胜。

导出的内容我设计成两个文件:一个是summary.csv,每行一轮,记录这一轮出了几个五星、限定出了几个、总共抽了多少抽;另一个是detail.csv,每一抽一行,记录抽数、星级、具体条目、当抽实际概率。前者用来做统计,后者用来复盘。

public static void ExportDetail(string path, IEnumerable<DrawRecord> records) { // UTF-8 BOM,不然 Excel 打开中文是乱码 using var writer = new StreamWriter(path, false, new UTF8Encoding(true)); writer.WriteLine("DrawIndex,Rarity,ItemName,ActualRate,IsPity"); foreach (var r in records) { writer.WriteLine($"{r.Index},{r.Rarity},{r.ItemName}," + $"{r.ActualRate:F4},{r.IsPity}"); } }

那个new UTF8Encoding(true)里的true就是 BOM 开关。不加的话,Excel 打开会把中文识别成乱码,用户第一反应是“这软件有 bug”,其实是编码问题。这种细节不写进去,就是给自己找差评。

3.5 状态机设计:保底计数怎么管才不乱

保底状态的管理,我单独抽了一个PityState类,因为它涉及到跨池继承、歪了之后的累积、不同池子之间的隔离。如果把这些字段散落在引擎里,代码会很快烂掉。

public sealed class PityState { public int SinceLast5Star { get; private set; } public bool GuaranteedLimited { get; private set; } // 下次五星是否必为限定 public int SinceLast4Star { get; private set; } public void OnDraw(PoolItem item, bool isLimitedPool) { if (item.Rarity == 5) { SinceLast5Star = 0; if (isLimitedPool) { // 出了限定就清空大保底,歪了就点亮 GuaranteedLimited = !item.IsLimited; } } else { SinceLast5Star++; } SinceLast4Star = item.Rarity == 4 ? 0 : SinceLast4Star + 1; } public PityState Clone() => (PityState)MemberwiseClone(); }

Clone方法是给批量模拟用的:每一轮开始前克隆一份初始状态,跑完丢弃,这样不同轮次之间互不污染。如果直接复用同一个实例,后面的轮次会带着前面的保底状态,结果全乱。这个坑我踩过,排查了半天才发现是状态没重置。

4. 从源码到跑起来:完整上手流程

4.1 环境准备与编译

这个项目的依赖非常干净,不需要装任何第三方包。要求只有两条:Windows 系统 + 安装了 .NET 桌面开发工作负载的开发环境。如果你想用 .NET Framework 4.8 编译,那连 SDK 都不用额外装,Windows 自带的运行时就能跑。

编译步骤我写得很啰嗦,因为真的有人卡在这一步:

  1. 拉取源码,解压到任意不含中文和空格的路径下。这一条看着像废话,但路径里有中文导致编译失败的情况我见过太多次。
  2. 用开发工具打开解决方案文件,或者直接在项目根目录打开终端。
  3. 如果用的是命令行,执行dotnet build -c Release;如果用 IDE,直接选 Release 配置后生成。
  4. 生成完成后,去输出目录找那个 exe,双击运行。

实操心得:第一次编译失败,九成是目标框架没装对。报错里如果有“找不到 .NETFramework,Version=vX.X 的引用程序集”,去装对应的开发包就行,别去网上瞎搜一堆偏方。

4.2 参数配置:先填对数据,再谈跑分

程序启动后,左侧是参数区,右侧是结果区。参数区里最关键的几个输入项:

参数名含义常见填法填错的后果
基础五星概率未进入递增区间时的单抽概率按公示数据填整体期望全偏
软保底起始抽数从第几抽开始概率递增按公示数据填分布形状完全变形
每抽递增幅度递增区间内每抽加多少按公示数据填出货集中在错误区间
硬保底抽数第几抽必定出货按公示数据填出现不可能的极端值
限定判定概率出五星后判定为限定的比例按公示数据填大小保底统计失真

表格里我特意没写具体数字,因为这些数字会随版本变化,写死了反而是误导。你的正确做法是打开游戏内的概率公示页,把数值抄进来。抄完之后不要急着跑,先点“校准”按钮跑一次单抽统计,看看各个星级的实际频率和理论值能不能对上。

4.3 一次完整的模拟流程与结果解读

假设我要评估“攒 180 抽能不能稳拿一个限定”。操作流程是这样的:

先把轮数设成 10000,每轮抽数设成 180,然后点开始。后台会跑一万轮,每轮从零状态开始抽 180 次,记录这一轮是否拿到了限定、在第几抽出货、歪了几次。跑完之后右侧会出三块内容:一个出货抽数的直方图、一个统计摘要、一个明细列表。

结果解读有几个要点。看 P90 而不是看平均值:平均值告诉你长期期望,P90 告诉你“最坏的那 10% 是什么体验”,做资源规划的时候应该按 P90 甚至 P95 来准备,而不是按平均值。看“未获得比例”:如果 180 抽没拿到限定的比例有 15%,那你就要做好心理准备,这个概率不算低。看歪的次数分布:如果大保底机制生效,理论上最多歪一次,如果你发现统计里有歪两次的情况,那一定是状态机写错了。

4.4 数据核对:别盲目相信程序输出

每次改完参数或者改完代码,我都会做一轮交叉验证。方法是:把同样的参数在纸上或者表格里用简化模型算一遍理论期望,然后和程序跑出来的结果比。差距在 3% 以内算正常(随机波动),超过 10% 就一定是哪里错了。

举个具体的核对例子:如果基础概率 0.6%,硬保底 90 抽,不算递增的话,期望抽数应该是 1/0.006 ≈ 166 抽,但这显然超过了硬保底,说明这个简化模型不成立——硬保底会截断长尾。加上硬保底之后,期望会大幅下降。这种“先算再跑”的习惯,比跑完之后盯着数字发呆有用得多。

5. 常见问题与排查实录

5.1 编译与运行阶段的坑

问题一:程序能编译,但双击 exe 闪退。最常见的原因是缺少配置文件。这个软件启动时会去读同目录下的卡池配置,如果文件不存在或者 JSON 格式有语法错误(多一个逗号、少一个引号),反序列化会抛异常,程序直接挂掉。排查方法是在开发环境里用调试模式跑一遍,看异常堆栈的第一行。正式版本里我加了全局异常捕获,会把错误写进日志文件,方便非开发用户反馈。

问题二:运行后界面一片空白。大概率是高分屏缩放导致的。WinForm 默认不会自动适配高 DPI,在 150% 缩放的显示器上,控件可能被画到可视区域外面。解决办法是在项目属性里开启 DPI 感知,或者在程序入口加一行配置声明。这个问题我改了三个版本才彻底解决。

问题三:杀掉进程后,资源目录里的图片删不掉。前面提过,Image.FromFile会锁文件。如果你在代码里还有别的地方直接用了这个方法,就会复现。全局搜一遍FromFile,全部换成流加载。

5.2 结果异常与统计偏差

问题一:每次跑出来的结果完全一样。这是随机数种子问题,前面详细讲过。检查一下是不是在循环内部new Random()了,或者是不是把种子写死成了固定值。

问题二:五星出货率明显高于理论值。优先检查递增区间的起点是不是设得太早,或者每抽递增幅度设得太大。还有一种可能是保底计数没有正确重置:出了五星之后如果没归零,后面每一抽都会带着很高的计数,相当于一直在保底边缘,出货率当然虚高。

问题三:限定角色的出现比例不对。检查“歪”的判定逻辑。正常的逻辑是:出五星 → 判断是否触发大保底 → 触发则必为限定 → 未触发则按公示比例判定,判定失败则歪,同时点亮大保底。如果你的代码里把“点亮大保底”写在了判定之前,那每次都会必出限定,比例直接翻倍。

问题四:跑十万轮特别慢,要等好几分钟。性能瓶颈通常在两个地方:一是每轮都在做深拷贝,如果状态对象很大,拷贝开销会累积;二是 UI 的进度更新太频繁。前者可以通过复用对象池或者只拷贝必要字段解决,后者把进度上报频率降到每 100 轮一次就能明显改善。

5.3 常见问题速查表

现象最可能的原因快速验证方式处理办法
启动闪退配置文件缺失或格式错误看同目录日志文件补齐配置或修正 JSON
界面空白或控件错位高 DPI 缩放未适配把系统缩放调回 100% 再看开启 DPI 感知配置
每次结果一致随机数种子重复打印前几个随机数看是否相同改为全局单实例
出货率虚高保底计数未重置打印每轮结束时的计数值出货后立即归零
限定比例异常大保底判定顺序写反单步调试判定分支调整判定与点亮的先后
批量跑分卡死计算跑在主线程看任务管理器是否未响应改 Task.Run 异步执行
导出 CSV 中文乱码未写 UTF-8 BOM用记事本打开看编码流写入时开启 BOM
图片文件被占用用了 FromFile 直接加载尝试删除资源文件改为内存流加载

这张表是我自己踩坑踩出来的,每一条都对应过一次真实的排查。新手遇到问题,先按这张表对一遍,能省掉大量时间。

5.4 几个不那么常见但很坑的问题

还有一个隐蔽的坑:浮点数比较。判断“概率是否达到 1”的时候,如果写成if (rate == 1.0),在某些情况下会因为浮点精度差一点点而判定失败,导致本该必出的抽数没出。正确写法是if (rate >= 1.0 - 1e-9)。这种问题在十万次模拟里可能只出现几次,但足以让你的最大值统计出错。

另一个是多线程访问共享状态。如果你为了提速把批量模拟改成了并行,一定要注意Random实例不是线程安全的,多个线程同时调用NextDouble()可能返回 0,甚至让内部状态损坏。解决办法是每个线程用独立的随机实例,或者改用线程安全的随机源。我建议前一种,性能更好,实现也简单:用ThreadLocal<Random>包一层。

最后一个:统计结果的样本量陷阱。跑 100 轮得出的平均值,波动可能高达 20%,这种数据拿去做判断毫无意义。我一般建议至少跑 10000 轮,最好是 100000 轮,跑一次也就几十秒,比你反复怀疑数据准不准省时间得多。

6. 开源之后:代码怎么改、怎么扩展

6.1 想加一个新卡池,需要动哪几个文件

这是被问得最多的问题。答案是好消息:只需要加配置,不需要改代码。因为我把池子的结构完全数据化了,加一个新池子就是在配置文件里加一段 JSON:

{ "poolName": "示例混合池", "poolWeight": 1.0, "rarityRate": { "5": 0.006, "4": 0.051, "3": 0.943 }, "items": [ { "id": "c001", "displayName": "限定条目A", "rarity": 5, "weight": 1.0, "isLimited": true }, { "id": "c002", "displayName": "常驻条目B", "rarity": 5, "weight": 1.0, "isLimited": false }, { "id": "c003", "displayName": "四星条目C", "rarity": 4, "weight": 1.0, "isLimited": false } ] }

rarityRate里三个数加起来必须等于 1,这一点要在加载时做校验,不然跑出来的分布会莫名其妙。我在ConfigValidator里写了这个检查,误差允许 1e-6,超过就抛异常并提示具体是哪一项。

6.2 想做更复杂的扩展,往哪个方向改

如果你想把这个模拟器改造成别的用途,有几个扩展方向是比较顺的。

方向一:改成通用开箱模拟。只要把“星级”的概念换成“品质等级”,把保底机制换成“必出机制”,核心引擎一行不用动。很多盲盒、卡牌、掉落系统都是这个结构。

方向二:加策略模拟。现在软件是“无脑抽到底”,你可以加一个策略层,比如“抽到限定就停”“预算用完就停”“歪了就停”,然后统计不同策略下的资源消耗和收益。这个方向很有意思,能回答“怎么抽最划算”这类实际问题。

方向三:加可视化。现在的直方图是用基础绘图画的,比较朴素。可以换成更专业的图表库,做出累计分布曲线、置信区间。不过我一直没做,因为 WinForm 加图表库会引入额外依赖,和“双击即用”的初衷冲突。

提示:二次开发前,强烈建议先把 Core 层单独编译成类库,用单元测试把概率逻辑的边界情况全部覆盖。改引擎不改测试,等于蒙着眼睛改代码。

7. 我个人在实际操作中的几点体会

这个项目断断续续维护了挺长时间,最大的收获不是学会写 WinForm,而是学会用数据说话。

刚开始我也是凭感觉抽卡的人,觉得“这次该出了吧”,后来跑了几十万次模拟才明白,所谓的“该出了”在概率面前毫无意义,每一次抽卡都是独立的判定,保底只是人为加的补偿机制。真正有意义的判断是:我准备了多少资源,在这个资源量下拿到目标的概率是多少,这个概率我能不能接受。这三个问题,模拟器都能给你答案。

另一个体会是,参数的准确性比程序的复杂度重要一百倍。我见过有人把模拟器写得非常花哨,界面炫酷动画流畅,结果保底参数填错了,跑出来的期望抽数比实际低了三十抽,这种数据拿去做决策是要吃亏的。所以我在文档里反复强调:先用校准模式验证参数,再拿去算别的。

最后一个想法是,如果你也想写一个类似的工具,从最简单的控制台版本开始,别一上来就搞界面。把概率逻辑跑通、验证对、单元测试写完,再花时间去做 UI。我前两版失败就是因为顺序反了,先做界面后做逻辑,结果逻辑一改界面全崩。这个顺序上的经验,比任何技术点都值钱。

返回列表