基于WinForm的抽卡概率模拟器:C#桌面开发实战
做桌面小工具这件事,我踩过最深的坑往往不在业务逻辑,而在"要不要用 WinForm"这个最初五秒钟的决定上。前阵子我把一款热门开放世界手游里那个让人又爱又恨的混合卡池,做成了一个本地跑的抽卡模拟器——项目代号就叫"蒙德篇混池模拟器"。它是一个纯 WinForm 的开源小程序,核心功能就三样:按真实的保底与歪保底规则模拟单抽和十连、把每一次结果落盘成可回溯的记录、把大样本下的实际出率用图表画出来让你自己验证概率。写它的初衷很朴素:群里天天有人吵"到底多少次出金才算正常""歪了之后下一发是不是必中",与其嘴炮,不如让代码跑一百万次给你看。这篇文章面向两类人:想拿它练手 C# 桌面开发的新手,以及想搞清楚抽卡概率模型到底怎么用代码落地的老哥。代码结构不复杂,但概率模型、随机数、UI 线程、存档这几块该有的坑一个不少,正好拿来当一份能直接抄的实战笔记。
1. 决定用 WinForm 之前,我先把需求拆成了三道题
1.1 这个模拟器真正要解决的问题只有三件
很多人做小工具上来就选框架,选完发现框架能力强得用不上,或者反过来被框架的复杂度拖死。我先反过来做:把需求列成清单,看它到底需要什么。
第一件是卡池状态维护。混合卡池的"混"体现在它不是单一概率,而是一整套会随抽数变化的状态:距上次五星多少抽、距上次四星多少抽、当前是不是大保底(上个五星是不是歪的)、武器池的命定值攒到几层。这些状态必须能存档、能读档、能在界面上实时显示"你还有 12 抽到硬保底"。
第二件是高频但不密集的交互。用户点一次"十连",程序要在极短时间内算出十个结果、更新保底面板、追加十行记录、刷新统计图表。这是典型的"瞬时计算 + 界面刷新",而不是长时间后台运算。
第三件是可验证性。这是我觉得最容易被忽略的一点:一个抽卡模拟器如果只给你看结果,不给统计数据,那它和扔骰子没区别。用户真正想确认的是"综合出率是不是接近那个理论值",所以必须支持批量模拟(比如一口气跑十万次)并把分布画出来。这意味着程序要能在几秒内完成十万次状态机推进——纯 CPU 计算,不需要数据库、不需要网络、不需要图形加速。
把这三条摆出来,答案基本就定了:一个单机、无服务、界面元素以表格和图表为主、计算量集中在 CPU 的桌面程序。
1.2 WinForm 在这个场景里的三个实在优势
我不否认 WPF 的布局体系和数据绑定更优雅,但对于这个具体项目,WinForm 有三点是实打实省时间的。
启动速度。WinForm 程序冷启动通常在几百毫秒级别,用户双击图标到能点按钮几乎没有等待感。抽卡模拟器这种"打开就想抽两把"的娱乐型工具,启动延迟会直接劝退一半人。WPF 也能做到很快,但前提是你别在启动时加载一堆 XAML 资源字典和主题包——大部分新手做不到。
单文件发布的成熟度。.NET 从 5 开始支持单文件发布,WinForm 在这个模式下表现相当稳。我把项目配置成PublishSingleFile=true加SelfContained=true,产物是一个几十兆的 exe,发给完全没装运行时的朋友也能双击就跑。这一点对开源小工具太重要了——你永远不知道下载的人电脑上是什么环境。
拖拽式布局在"固定尺寸工具"上的效率。WinForm 的绝对定位和 Anchor/Dock 组合,在做一个尺寸固定、控件不随窗口任意缩放的小工具时,效率极高。你不需要写一行样式代码,TableLayoutPanel 拖几个格子,Panel 上放按钮,半小时界面就出来了。而用 Web 技术做同样的事,你得先搭一个运行时、装依赖、处理打包,光是让 exe 双击能起来就要折腾一天。
我当时做过一个简单的对比,结论是:这个项目的界面复杂度在 WinForm 的舒适区内,选它省下的是"造轮子的时间",而这些时间我全部投进了概率模型和随机数这两块真正值得较真的地方。
| 方案 | 开发耗时(估算) | 冷启动 | 单文件分发 | 适合本项目程度 |
|---|---|---|---|---|
| WinForm (.NET 6+) | 1 人 3 天可出可用版 | 约 0.3-0.6 秒 | 原生支持,配置简单 | 高,界面为表格+按钮 |
| WPF | 需要额外写样式与 MVVM 骨架 | 约 0.8-1.5 秒 | 支持,但资源文件处理繁琐 | 中,收益不明显 |
| 内嵌浏览器方案 | 需要前端构建链 + 打包器 | 1 秒以上 | 产物体积大,依赖多 | 低,属于过度设计 |
| 控制台程序 | 最快 | 最快 | 最方便 | 低,无法满足图表需求 |
1.3 什么时候你不应该选 WinForm
说优点就得说边界,否则容易误导人。以下三种情况我会毫不犹豫放弃 WinForm。
需要复杂动画和自绘界面。抽卡游戏最有感觉的部分是"出金特效",那是一条金色光柱划开屏幕的动画。WinForm 能做到,但要用 Timer 逐帧改控件属性或者直接上 GDI+ 手绘,做出来的效果和高刷屏上的流畅感差得远。我的处理是干脆放弃花哨特效,用最简单的"结果卡片弹出 + 变色边框"代替——我做的是概率验证工具,不是游戏复刻。如果你要做的是视觉向的东西,直接上 WPF 或游戏引擎。
需要跨平台。WinForm 只能跑在 Windows 上。虽然 .NET 6 之后有了一些第三方兼容方案,但生产环境别指望。这个模拟器的目标用户群体基本都在 Windows 上打游戏,所以无所谓;如果你的工具要发给用其他系统的朋友,从一开始就该选 Avalonia 或 MAUI。
界面元素超过两屏。WinForm 没有成熟的布局引擎,控件一多,Anchor 和 Dock 的组合会变成一场灾难,改一个控件位置能引发连锁错位。我的主界面控制在三个区域:左侧卡池配置、中间抽卡按钮和结果展示、右侧记录表格,总共十几个控件,这是它的舒适区上限。
提示:如果你正在犹豫要不要用 WinForm,判断标准很简单——你的界面是不是"一堆表格、按钮、文本框的排列组合"。是就选它,不是就换。
2. 混合卡池的概率模型:状态机比公式更重要
2.1 基础概率、软保底、硬保底是三层叠加而不是三个选项
新手最容易犯的错,是把"基础概率""软保底""硬保底"当成三个并列的概率值,然后写成三个 if 分支。实际它们是一条随抽数递增的概率曲线,硬保底只是这条曲线的终止点。
我用的是这类卡池常见的参数结构,写在配置里,随时可改(下面这组数字是模拟器内置的默认值,只用于学习和自娱,不代表任何官方数据,请勿当成权威依据):
| 参数 | 默认值 | 说明 |
|---|---|---|
| 五星基础概率 | 0.6% | 未进入保底区间时的单抽概率 |
| 软保底起点 | 第 74 抽 | 从这一抽开始概率递增 |
| 软保底增量 | 每抽 +6% | 累加到基础概率上 |
| 硬保底 | 第 90 抽 | 概率强制为 100% |
| 四星基础概率 | 5.1% | 独立计算,不占用五星概率 |
| 四星保底 | 每 10 抽 | 连续 9 抽无四星则第 10 抽必出 |
| 限定五星歪率 | 50% | 即"大小保底",歪后下一只必为当期 |
| 武器池命定上限 | 2 层 | 攒满后下一把五星必为定轨武器 |
概率计算的核心函数长这样,注意我用的是"当前是第几抽"这个累计值,而不是在外部维护一个递增的 double:
private double ResolveFiveStarRate(int pullIndex) { if (pullIndex >= pool.HardPity) return 1.0; if (pullIndex < pool.SoftPityStart) return pool.BaseRate; int step = pullIndex - pool.SoftPityStart + 1; double rate = pool.BaseRate + step * pool.SoftPityStep; return Math.Min(rate, 1.0); }这段代码看起来平平无奇,但有一个关键点:软保底的增量是加在基础概率上的绝对值,不是概率的倍数。很多人写成rate * 1.06的连乘形式,那是指数增长,第 80 抽的概率会高到离谱,最终算出来的综合出率完全对不上。
另一个必须注意的细节是四星和五星的判定顺序。如果先判四星再判五星,那么当两者都命中时四星会吃掉这次机会,导致五星实际出率被稀释。正确做法是先判五星,未命中再在剩余概率空间里判四星。我在代码里用"命中五星则直接返回,跳过四星判定"的方式处理。
2.2 大小保底和命定值本质是一个小状态机
卡池的"混"主要在保底状态的转移上。我用一个不可变的状态结构体来承载,每次抽卡返回结果和新的状态,这样批量模拟时可以做到无副作用、方便并行:
public readonly record struct PoolState( int SinceFiveStar, int SinceFourStar, bool IsGuaranteed, // true 表示大保底,下个五星必中当期 int FatePoints); // 武器池命定值 public PullOutcome Pull(GachaPool pool, ref PoolState state, double roll) { state = state with { SinceFiveStar = state.SinceFiveStar + 1, SinceFourStar = state.SinceFourStar + 1 }; double fiveRate = ResolveFiveStarRate(state.SinceFiveStar); if (roll < fiveRate) { bool hit = pool.IsWeaponPool ? state.FatePoints >= pool.FateLimit : state.IsGuaranteed; var newState = state with { SinceFiveStar = 0, SinceFourStar = 0, IsGuaranteed = !hit, FatePoints = pool.IsWeaponPool ? (hit ? 0 : state.FatePoints + 1) : 0 }; state = newState; return new PullOutcome(hit ? Rarity.FiveStarUp : Rarity.FiveStarMiss, ...); } // 四星与三星分支省略 }用record struct而不是 class,是批量模拟时的一个实测优化:十万次模拟里状态被创建上百万次,struct 走栈分配,不进 GC,实测比 class 版本快 30% 左右。这个量级谈不上必需,但既然一开始就知道要跑大样本,顺手做掉没什么成本。
这里有个容易写错的地方:大保底判定和计数器清零的顺序。如果先清零SinceFiveStar再判断命中与否,代码读起来没问题,但一旦你后面加"歪了之后是否重置四星计数"这种规则,顺序就会出错。我的习惯是先把新状态算完,一次性赋值,避免中间态泄漏。
2.3 综合出率是跑出来的,不是算出来的
界面上那个"综合五星出率"的数字,我一开始想用数学期望推导,推了两小时发现软保底那段是分段函数,手算容易错。后来我干脆让程序自己跑:启动时在后台线程跑 100 万次模拟,把综合出率、平均出金抽数、90 抽内出金的百分比都算出来,写在配置旁边做参照值。
实测下来,用上面那组参数,综合出率稳定在 1.6% 上下浮动,平均出金抽数在 62 到 63 之间。这个数字的一阶验证方法很简单:如果你改了软保底起点,综合出率会明显变化;如果你把软保底起点从 74 改到 60,综合出率会显著上升。跑几次就能直观感受到软保底参数对整体出率的杠杆效应,比看公式快多了。
批量模拟这块我建议一定要做,它就是这个小工具区别于"随机数生成器"的核心价值:
public SimulationReport RunBatch(GachaPool pool, int iterations, int? seed = null) { var rng = seed.HasValue ? new Random(seed.Value) : Random.Shared; var state = pool.InitialState; var fiveStarPulls = new List<int>(); int current = 0; for (int i = 0; i < iterations; i++) { current++; double roll = rng.NextDouble(); var outcome = Pull(pool, ref state, roll); if (outcome.Rarity is Rarity.FiveStarUp or Rarity.FiveStarMiss) { fiveStarPulls.Add(current); current = 0; } } return new SimulationReport(fiveStarPulls, iterations); }注意:跑批量模拟时不要在循环里更新 UI 控件,也不要在循环里写日志。十万次循环里哪怕有一次
textBox.AppendText,整体耗时都会从 200 毫秒飙到十秒以上。我的做法是全部算完再一次性回填界面。
3. 随机数这一层,坑比业务代码还多
3.1new Random()写在循环里,你的"随机"就不随机了
这是我在这个项目里见过最多的新手错误,也是我自己早期踩过的。.NET 的Random在无参构造时,用的是基于系统时钟的种子。如果你的代码长这样:
for (int i = 0; i < 10; i++) { var r = new Random(); // 错!十次循环拿到的是同一个种子 results.Add(r.NextDouble()); }那么十次循环极大概率拿到完全相同的数值序列。循环体执行太快,系统时钟的分辨率跟不上,种子没变,伪随机序列当然一样。在抽卡模拟器里这个 bug 的表现非常诡异:十连抽出来十个一模一样的稀有度,用户截图一发,评论区直接炸。
正确姿势是一个实例活到整个会话结束,把它作为类的字段或者在需要的地方传进去:
private readonly Random _rng = new Random(); // 或者 .NET 6+ 直接用共享实例(注意:共享实例不支持自定种子) double roll = Random.Shared.NextDouble();Random.Shared的好处是线程安全且省事,缺点是它不能指定种子,所以调试和复现时没法用。我的方案是双轨制:正常运行走Random.Shared,需要复现时(比如用户点了"用种子 X 重跑")新建一个带种子的实例传进模拟函数。
3.2 可复现的种子机制,是排查"概率不对"的唯一手段
用户反馈"我抽了一百次一个金都没有"的时候,你怎么办?如果程序不能复现,你只能回一句"运气不好",这话谁都不服。所以我给每一次抽卡会话都分配一个种子,记录在存档里,界面上显示一小串十六进制。用户报问题时把种子发过来,我在本地用同一个种子重跑,就能看到他到底抽出了什么。
种子生成我用了加密安全的随机源,而不是Random本身:
public static int NewSessionSeed() { Span<byte> buffer = stackalloc byte[4]; RandomNumberGenerator.Fill(buffer); return BitConverter.ToInt32(buffer) & 0x7FFFFFFF; }用& 0x7FFFFFFF是为了避免负数种子在某些日志输出场景下带负号、影响文件名拼接。这个细节不重要,但省事。
种子机制还有个附带好处:单元测试。概率模型的测试如果只做统计断言(跑一万次,出金数落在某个区间),测试会偶发失败,非常烦人。有了种子,你可以直接写"种子 12345 的十万次模拟必须产出 1601 个五星",完全确定,跑一万遍结果都一样。
3.3 稀有度判定别用 if-else 堆,上权重表
一开始我是这么写的:先判五星,再判四星,再判三星,靠if (roll < 0.006) ... else if (roll < 0.057) ...。能跑,但一旦要加"三星里再分武器和材料""四星里再分角色和武器",嵌套层级就控制不住了,而且每次改概率都要重算阈值。
后来换成了累积权重表,一个通用的工具类,哪里需要随机加权就复用:
public sealed class WeightedTable<T> { private readonly double[] _cumulative; private readonly T[] _items; public WeightedTable(IReadOnlyList<(T item, double weight)> entries) { _items = entries.Select(e => e.item).ToArray(); _cumulative = new double[entries.Count]; double sum = 0; for (int i = 0; i < entries.Count; i++) { sum += entries[i].weight; _cumulative[i] = sum; } // 归一化,防止调用方忘记把权重加起来等于 1 for (int i = 0; i < _cumulative.Length; i++) _cumulative[i] /= sum; } public T Pick(double unitRoll) { int idx = Array.BinarySearch(_cumulative, unitRoll); if (idx < 0) idx = ~idx; return _items[Math.Min(idx, _items.Length - 1)]; } }用二分查找而不是线性扫描,是因为武器池里那把三星武器池有十几件物品,线性扫虽然也就十几步,但十连抽乘上批量模拟的十万次,累计起来是几千万次比较。改成二分之后,批量模拟的耗时肉眼可见地降了。这个优化不值一提,但它是"顺手做掉"的那类改动。
有个坑要提醒:Array.BinarySearch在找不到精确值时返回的是按位取反的插入位置,必须用~idx还原。我第一次忘了取反,结果负数下标直接抛异常,还以为是权重表算错了。
4. 界面层的四个真实难题,每个都让我改了不止一遍
4.1 十连动画卡住主线程,用户以为程序死了
最早期的版本,十连抽的写法是:循环十次、每次抽完Thread.Sleep(120)制造"逐张翻开"的节奏感、直接改控件显示。运行起来界面完全冻结,点关闭按钮都没反应,任务管理器里显示"未响应"。
原因很直白:WinForm 的控件只能在创建它的线程(UI 线程)上访问,而 UI 线程被Sleep占住了,消息循环停摆,窗口自然画不出来。解决路径有两条:async/await或者Task.Run加Invoke。我选前者,因为代码可读性好太多:
private async void btnTenPull_Click(object sender, EventArgs e) { btnTenPull.Enabled = false; try { for (int i = 0; i < 10; i++) { var outcome = _engine.PullOnce(); // 纯计算,很快 AppendResultCard(outcome); // UI 更新,回到 UI 线程 await Task.Delay(90); // 非阻塞等待 } } finally { btnTenPull.Enabled = true; } }await Task.Delay和Thread.Sleep的区别是前者把控制权交还给消息循环,窗口照样能拖动、能重绘。这个改动之后,用户反馈里"卡死"类的抱怨直接归零。
另一个细节是btnTenPull.Enabled = false放在 try 的外面、恢复放在finally里。这样即使抽卡过程中抛异常,按钮也不会永远变灰。我第一版把禁用写在了 try 内部,有一次文件写入失败导致异常,按钮再也没亮起来,只能重启程序。
4.2 记录表格上千行之后的掉帧,用虚拟模式解决
抽卡记录我用的是DataGridView。数据量小的时候完全没问题,但用户跑完十万次批量模拟之后,如果把这些记录全塞进表格,滚动会卡成幻灯片。原因不是数据量大,而是DataGridView默认为每一行都创建了真实的控件对象和样式对象,一万行就是几万个对象。
解决方案是打开虚拟模式,让它只在需要显示时才向你要数据:
recordsGrid.VirtualMode = true; recordsGrid.RowCount = _records.Count; recordsGrid.CellValueNeeded += (s, ev) => { if (ev.RowIndex < 0 || ev.RowIndex >= _records.Count) return; var r = _records[ev.RowIndex]; ev.Value = ev.ColumnIndex switch { 0 => r.Index, 1 => r.PoolName, 2 => r.RarityText, 3 => r.ItemName, 4 => r.IsGuaranteed ? "大保底" : "", _ => null }; };配合RowCount的设置,滚动瞬间顺滑。这里有个容易忽略的点:排序功能会失效。虚拟模式下你不能指望控件自己排序,得自己在数据源上排完再重置 RowCount 并Invalidate()。我一开始没注意,用户点列头排序后表格内容错乱,调了半天才发现是虚拟模式的锅。
还有一个更隐蔽的问题:CellValueNeeded里不要做任何耗时操作,包括字符串拼接和 LINQ 查询。这个方法在滚动时会被高频调用,你在里面写个_records.Where(...),滚动立刻卡。所有数据格式化都应该在抽卡的时候就做掉,存成字符串直接返回。
4.3 高 DPI 屏幕上糊成一片,一行配置解决
这个问题困扰了我很久。同一份程序,在 1080p 显示器上清清楚楚,在 2K 笔记本上字全糊了,按钮的边框变成一片灰。原因是 WinForm 默认按 96 DPI 设计,系统强行拉伸整个窗口,于是所有文字都被位图放大,糊是必然的。
.NET Core 3.0 之后一行就能解决:
// Program.cs [STAThread] static void Main() { ApplicationConfiguration.Initialize(); // 自动应用项目里配置的 DPI 模式 Application.Run(new MainForm()); }项目文件里配合设置:
<PropertyGroup> <ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode> </PropertyGroup>PerMonitorV2的意思是"每个显示器各自按自己的缩放比例渲染",用户把窗口从 2K 屏拖到 1080p 屏,界面会重新布局而不是拉伸。代价是你要保证布局是自适应的——这里又回到 Anchor 和 Dock,如果你的控件全是绝对坐标,切换显示器后位置会错乱。
我的主界面用TableLayoutPanel做了三层划分,四个区域分别设置 Dock,缩放比例变化时自动重排,基本不用写额外代码。表格的列宽我设置了FillWeight而不是固定像素,这也是为了适配缩放。
4.4 用 Chart 控件画分布图,比想象中好使
要让人相信"综合出率真的是 1.6%",光给个数字没用,得给图。.NET 自带的Chart控件画直方图完全够用,不需要引第三方库。
具体做法:把每次五星出现的抽数(距离上一次五星间隔了多少抽)统计成频次数组,0 到 90 共 91 个桶,然后填进 Chart:
private void RenderDistribution(int[] histogram, int maxPull) { chartDist.Series[0].Points.Clear(); chartDist.Series[0].ChartType = SeriesChartType.Column; for (int i = 1; i <= maxPull; i++) { chartDist.Series[0].Points.AddXY(i, histogram[i]); } chartDist.ChartAreas[0].AxisX.Title = "距上次五星的抽数"; chartDist.ChartAreas[0].AxisY.Title = "出现次数"; chartDist.ChartAreas[0].RecalculateAxesScale(); }画出来的图很有说服力:第 1 到 73 抽是一段几乎贴地的低平区(因为基础概率只有 0.6%,单抽命中的次数很少),从 74 开始陡然抬升,在 75 到 80 之间形成一座高峰,然后在 90 处有一根孤零零的高柱——那是硬保底。任何人看一眼这张图,立刻明白"软保底"是什么意思,比讲十分钟理论都管用。
提示:
Chart控件在大数据量下渲染慢,91 个点完全没问题,但别把十万次模拟的原始记录直接扔进去。统计成频次再画,是这类可视化的通用做法。
5. 存档、导出与配置:小工具的数据层别写得太随意
5.1 存档写到一半断电,用户数据就没了
抽卡记录对用户来说是有情感价值的,一旦丢失肯定挨骂。所以存档逻辑必须防"写到一半崩了"这种情况。我用的方案是临时文件 + 原子替换:
public static void SaveAtomic<T>(string path, T data) { var json = JsonSerializer.Serialize(data, JsonOptions); var tmp = path + ".tmp"; File.WriteAllText(tmp, json, new UTF8Encoding(false)); if (File.Exists(path)) File.Replace(tmp, path, path + ".bak"); else File.Move(tmp, path); }File.Replace在大多数文件系统上是原子操作,要么完全替换成功,要么保持原文件不变。多出来的.bak文件还能兜底:如果用户误操作覆盖了,至少还能从备份里捞回来。
这里有个我踩过的坑:序列化配置里一定要加WriteIndented和中文不转义。默认的JsonSerializer会把中文全部转成\uXXXX形式,存档打开是一堆乱码,用户看到了会以为文件坏了。
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };UnsafeRelaxedJsonEscaping这个名字听着吓人,但它只是让中文和常见符号原样输出。用于本地存档完全没有问题,别把它用在返回给浏览器的接口上就行。
5.2 导出 CSV 打开是乱码,问题出在 BOM 上
用户抽完一波想把记录导出来做表格分析,我一开始用的是File.WriteAllText(path, csv),默认 UTF-8 无 BOM。结果用某些表格软件打开,中文全是乱码。
原因很简单:没有 BOM 的 UTF-8 文件,部分软件会按系统默认编码去猜,猜错了就乱码。解决办法是明确写入带 BOM 的 UTF-8:
var sb = new StringBuilder(); sb.AppendLine("序号,卡池,稀有度,物品名,是否保底,时间"); foreach (var r in records) sb.AppendLine($"{r.Index},{r.PoolName},{r.RarityText},{r.ItemName},{r.IsGuaranteed},{r.Time:yyyy-MM-dd HH:mm:ss}"); File.WriteAllText(path, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));new UTF8Encoding(true)这个true就是"写 BOM"。另一个小细节是 CSV 字段里如果包含逗号或引号,必须用双引号包裹并把内部引号转义成两个引号,否则列会错位。物品名里通常没逗号,但卡池名我写了"限定角色池(蒙德)",括号里的内容一旦带逗号就会炸。我加了一个简单的转义函数处理。
5.3 卡池参数外置,别硬编码在代码里
把保底数值写死在代码里的后果是:每次想调参数做对比实验,都要改代码、重新编译。这在开发阶段非常低效。我的做法是全部外置成 JSON 文件,程序启动时读,读不到就用内置默认值:
{ "pools": [ { "id": "character-limited", "name": "限定角色池(蒙德)", "isWeaponPool": false, "baseRate": 0.006, "softPityStart": 74, "softPityStep": 0.06, "hardPity": 90, "baseRate4": 0.051, "pity4": 10, "guaranteeRatio": 0.5 } ] }反序列化时有个坑:缺字段的容错。用户如果手贱改了配置,删掉某个字段,程序会抛异常。解决办法是给所有字段在类里设默认值,并且把反序列化的Required检查关掉(JsonSerializerOptions默认就不要求所有属性存在,这一点还算友好)。我在加载时用 try-catch 包住,解析失败就弹个提示、回落到默认配置,而不是直接崩溃。
6. 常见问题排查手册:按症状查,别瞎猜
6.1 双击没反应或者闪退
这是发布版本收到最多的反馈。按可能性从高到低排:
| 症状 | 可能原因 | 处理方式 |
|---|---|---|
| 双击后一闪而过,无任何提示 | 未打包运行时,或单文件解压失败 | 改用自包含发布SelfContained=true |
| 弹出"缺少 .NET 运行时" | 框架依赖模式发布 | 指导安装运行时,或改自包含 |
| 双击后进程出现又消失 | 启动时读取配置文件抛异常且未捕获 | 在Program.Main外层加全局异常捕获并弹窗 |
| 提示"不是有效的 Win32 应用" | 32/64 位不匹配,或下载不完整 | 重新下载,或发布 AnyCPU/双版本 |
全局异常捕获这个东西,我建议所有桌面小工具都加上,成本极低,收益极高:
static void Main() { Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); Application.ThreadException += (s, e) => MessageBox.Show(e.Exception.ToString(), "程序出错了"); AppDomain.CurrentDomain.UnhandledException += (s, e) => MessageBox.Show((e.ExceptionObject as Exception)?.ToString(), "程序出错了"); ApplicationConfiguration.Initialize(); Application.Run(new MainForm()); }有了它,用户遇到问题时至少能把错误信息截图发你,而不是一句"打不开"。
6.2 "概率明显不对"的两种真实原因
用户说"我抽了 200 次都没出金",你要分清是模型错了还是运气问题。
排查链路是这样的:先让他把会话种子发过来,用同一个种子本地重跑,看能不能复现。如果能复现且确实 200 抽没出金,那说明保底计数器被错误重置了——最常见的情况是用户切换卡池时,我把SinceFiveStar一起清零了,但角色池和武器池的保底应该是各自独立维护的。这个 bug 我早期版本真的有,切一次卡池就能无限重置保底。
如果不能复现,那基本就是运气,但你不能只回一句"运气不好"。我的做法是让程序在每次抽卡后记录对应的随机数原值,用户可以导出日志,看到"这一抽的 roll 是 0.7321,阈值是 0.006,所以没中"。这种透明度能迅速平息争议。
另一个常见原因是用户把"批量模拟"的结果当成真实抽卡。批量模拟用的是独立的随机源和独立的状态副本,不影响主界面记录。我在界面上用明显的配色把两块区分开,避免混淆。
6.3 抽卡记录莫名其妙少了几条
这个 bug 我排查了挺久。现象是:抽完十连,表格里只有 7 条、8 条,数量不固定。
根因不在抽卡逻辑,而在保存时机。我原来的设计是每次抽卡后立即写一次存档文件。十连抽会连续写十次,如果用户在中间点了关闭窗口,或者程序正好在做文件替换,就可能出现只写了部分记录的情况。而且高频写文件本身也很浪费——十连抽十次 IO,用户可以明显感觉到卡顿。
改法是:抽卡只在内存里追加,写盘做防抖处理,即最后一次操作后延迟 1 秒写入,同时程序退出时强制刷盘:
private readonly System.Windows.Forms.Timer _saveDebounce = new() { Interval = 1000 }; private void ScheduleSave() { _saveDebounce.Stop(); _saveDebounce.Start(); } // 在 FormClosing 里再调一次强制保存 private void MainForm_FormClosing(object sender, FormClosingEventArgs e) { _saveDebounce.Stop(); SaveNow(); }那一次FormClosing的强制保存非常关键。我第一版忘了加,用户点右上角关闭,最后 1 秒内的抽卡记录全丢,被吐槽了好几次。
6.4 杀毒软件报毒,这个真没办法完全避免
单文件自包含发布的 exe,体积几十兆,内部是一个自解压结构,加上没有代码签名,被某些安全软件标记成可疑是常态。我试过几种缓解方式:
第一,优先提供框架依赖版本,体积小(几百 KB),误报率明显低。第二,把源码放在仓库里,README 里写清楚这是纯本地程序、不联网、不读写系统目录,附上行为说明。第三,如果确实需要单文件版,在发布说明里写明"因为是自解压所以体积大且可能被误报,介意请用依赖版"。
我没有去做代码签名,因为个人开发者证书成本不低,对于这种免费小工具不太现实。把源码开源本身就是最好的自证——任何人都能编译一遍,看到里面只有抽卡逻辑和文件读写,没有任何奇怪的东西。这也是我把这个项目开源出去的核心原因之一。
7. 开源结构和关键类怎么组织
7.1 目录划分:让陌生人能在五分钟内看懂
开源项目最怕的是"作者自己看得懂"。我把项目按职责切成四层,每个文件夹的名字都能自解释:
GachaSim/ GachaSim.Core/ # 纯逻辑,不引用任何 WinForm 程序集 GachaPool.cs # 卡池定义 PoolState.cs # 保底状态 GachaEngine.cs # 抽卡核心 WeightedTable.cs # 加权随机 SimulationReport.cs # 批量模拟结果 GachaSim.Data/ # 存档、导出、配置 RecordStore.cs CsvExporter.cs PoolConfigLoader.cs GachaSim.App/ # WinForm 界面 MainForm.cs Controls/ Program.cs GachaSim.Tests/ # 单元测试 PityTests.cs关键设计是Core 项目零 UI 引用。这样我可以直接在测试项目里跑概率验证,不用启动窗体;将来如果想换个前端,Core 一行不用改。这一点在做小工具时经常被忽视,但它是"能不能被别人复用"的分水岭。我见过太多开源小工具,抽卡逻辑和按钮点击事件死死绑在一起,别人想拿去当库用根本没门。
.csproj里我给 Core 设置了TargetFramework=net6.0(不带-windows后缀),这就是一条硬约束,一旦有人不小心在 Core 里using System.Windows.Forms,编译直接失败。这种用工程文件来强制架构的做法,比写在文档里的约定可靠得多。
7.2 三个核心类的职责边界
GachaPool是纯数据,只描述"卡池怎么配",不含任何行为。PoolState是纯状态,只记录"当前攒了多少保底"。GachaEngine是唯一的行为体,接收池子和状态,吐出一个结果。
public sealed class GachaEngine { private readonly Random _rng; private PoolState _state; public GachaEngine(GachaPool pool, PoolState initialState, int? seed = null) { Pool = pool; _state = initialState; _rng = seed.HasValue ? new Random(seed.Value) : Random.Shared; } public GachaPool Pool { get; } public PoolState State => _state; public PullOutcome PullOnce() => Pull(Pool, ref _state, _rng.NextDouble()); public SimulationReport RunBatch(int iterations, bool cloneState = true) { var workState = cloneState ? _state : default; // 循环体见第 2.3 节 } }这里有个设计上的取舍值得说:RunBatch要不要影响当前状态?我给了个cloneState参数,默认 true(不影响主状态),因为绝大多数用户把"批量模拟"当成一次沙盒实验,不希望它污染自己的抽卡记录。这个默认真的是经过用户反馈调整的——早期版本会污染,被吐槽之后改的。
PullOutcome我设计成只读的结构体,包含稀有度、物品名、是否歪了、当时的抽数、以及那一抽的随机数原值。最后一个字段看起来多余,但它是事后复盘的唯一依据,我在 6.2 节说过它的价值。
7.3 开源前必须做的清理,不做会后悔
我第一次把代码推上去的时候犯了个低级错误:把本地调试用的绝对路径一起提交了,里面有我的用户名。虽然不是什么机密,但看起来很业余。以下是我现在每次开源前的固定检查清单。
第一,检查硬编码路径和用户信息。搜一遍代码里的C:\Users\,全部换成Environment.GetFolderPath(SpecialFolder.ApplicationData)之类的相对路径。
第二,把本地存档、日志、bin/obj 全部加进 .gitignore。尤其是存档文件,里面可能有测试数据,提交上去会让仓库体积变大,也可能暴露你跑了多少次模拟这种无关信息。
第三,写一份能让人照着跑起来的 README。必备内容:环境要求(.NET 6 SDK 或更高)、怎么编译(一行dotnet build)、怎么运行、截图一张、参数配置说明、许可证。我见过太多仓库只有代码没有说明,点进去一脸懵,直接关掉。
第四,明确标注参数来源和免责说明。这一点对这类项目尤为重要:所有概率参数都是模拟器自己设定的、用于学习和娱乐,不构成任何官方数据,用户自行修改配置产生的任何结果与项目无关。把这句话写在 README 最显眼的位置,既是保护自己,也是提醒使用者理性看待。
第五,选许可证。个人小工具我用的是 MIT,别人拿去改、拿去二次分发都行,只要保留版权声明。如果你希望衍生作品也必须开源,那就 GPL。这个选择没有对错,想清楚就行。
提示:开源不等于把文件夹压缩上传。一个好用的仓库,README 的质量往往比代码本身更影响别人是否愿意用。我花在 README 上的时间,大概和写核心逻辑的时间差不多。
8. 一些后面还想加的东西
这个模拟器目前停在"功能完整、够用"的状态:能抽、能存、能统计、能导出、能改配置。但我在使用过程中攒了几个想做但还没做的点,写出来给同样在做类似工具的人参考。
第一个是多卡池并行对比。现在的统计一次只能看一个池子的分布,如果想直观比较"限定角色池"和"武器池"的出金曲线差异,得来回切换。理想的做法是一个窗口里叠两条曲线,用不同颜色区分,X 轴共用。技术上不难,Chart 控件支持多 Series,主要是界面布局要重新排。
第二个是历史记录的按池筛选和统计。现在表格是平铺的,记录一多就难找。加一个下拉筛选,选中某个池子后只显示该池的记录并重新算统计,这个应该优先做,实用价值最高。
第三个是把概率模型抽成一个可插拔的接口。现在GachaEngine里的概率曲线是写死的那套"基础值 + 线性递增 + 硬保底",如果想来点好玩的,比如"概率按对数增长"或者"每 50 抽额外送一次判定",就得改核心代码。抽成IRateCurve接口之后,配置里写个类型名就能切换,实验性质的功能会好做很多。不过这个改动会动到 Core 的公开 API,得配一次大版本号,我还在犹豫值不值。
最后分享一个我自己用下来最有价值的习惯:任何涉及随机的功能,第一件事就是把种子暴露出来,第二件事就是把原始随机数记进日志。这两件事加起来不到二十行代码,但它能让"概率不对"这种最模糊、最难扯清的反馈,变成一条可以精确复现的证据链。我做过的几个带随机逻辑的小工具,最后真正省下大量沟通成本的,都是这个习惯,而不是那些精心设计的架构。