资讯动态

基于WinForm的抽卡概率模拟器:C#桌面开发实战

发布时间:2026/10/26 11:22:19 来源:尧图企业网站定制
做桌面小工具这件事我踩过最深的坑往往不在业务逻辑而在要不要用 WinForm这个最初五秒钟的决定上。前阵子我把一款热门开放世界手游里那个让人又爱又恨的混合卡池做成了一个本地跑的抽卡模拟器——项目代号就叫蒙德篇混池模拟器。它是一个纯 WinForm 的开源小程序核心功能就三样按真实的保底与歪保底规则模拟单抽和十连、把每一次结果落盘成可回溯的记录、把大样本下的实际出率用图表画出来让你自己验证概率。写它的初衷很朴素群里天天有人吵到底多少次出金才算正常歪了之后下一发是不是必中与其嘴炮不如让代码跑一百万次给你看。这篇文章面向两类人想拿它练手 C# 桌面开发的新手以及想搞清楚抽卡概率模型到底怎么用代码落地的老哥。代码结构不复杂但概率模型、随机数、UI 线程、存档这几块该有的坑一个不少正好拿来当一份能直接抄的实战笔记。1. 决定用 WinForm 之前我先把需求拆成了三道题1.1 这个模拟器真正要解决的问题只有三件很多人做小工具上来就选框架选完发现框架能力强得用不上或者反过来被框架的复杂度拖死。我先反过来做把需求列成清单看它到底需要什么。第一件是卡池状态维护。混合卡池的混体现在它不是单一概率而是一整套会随抽数变化的状态距上次五星多少抽、距上次四星多少抽、当前是不是大保底上个五星是不是歪的、武器池的命定值攒到几层。这些状态必须能存档、能读档、能在界面上实时显示你还有 12 抽到硬保底。第二件是高频但不密集的交互。用户点一次十连程序要在极短时间内算出十个结果、更新保底面板、追加十行记录、刷新统计图表。这是典型的瞬时计算 界面刷新而不是长时间后台运算。第三件是可验证性。这是我觉得最容易被忽略的一点一个抽卡模拟器如果只给你看结果不给统计数据那它和扔骰子没区别。用户真正想确认的是综合出率是不是接近那个理论值所以必须支持批量模拟比如一口气跑十万次并把分布画出来。这意味着程序要能在几秒内完成十万次状态机推进——纯 CPU 计算不需要数据库、不需要网络、不需要图形加速。把这三条摆出来答案基本就定了一个单机、无服务、界面元素以表格和图表为主、计算量集中在 CPU 的桌面程序。1.2 WinForm 在这个场景里的三个实在优势我不否认 WPF 的布局体系和数据绑定更优雅但对于这个具体项目WinForm 有三点是实打实省时间的。启动速度。WinForm 程序冷启动通常在几百毫秒级别用户双击图标到能点按钮几乎没有等待感。抽卡模拟器这种打开就想抽两把的娱乐型工具启动延迟会直接劝退一半人。WPF 也能做到很快但前提是你别在启动时加载一堆 XAML 资源字典和主题包——大部分新手做不到。单文件发布的成熟度。.NET 从 5 开始支持单文件发布WinForm 在这个模式下表现相当稳。我把项目配置成PublishSingleFiletrue加SelfContainedtrue产物是一个几十兆的 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 层攒满后下一把五星必为定轨武器概率计算的核心函数长这样注意我用的是当前是第几抽这个累计值而不是在外部维护一个递增的 doubleprivate 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 Listint(); 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() { Spanbyte 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 WeightedTableT { 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 ApplicationHighDpiModePerMonitorV2/ApplicationHighDpiMode /PropertyGroupPerMonitorV2的意思是每个显示器各自按自己的缩放比例渲染用户把窗口从 2K 屏拖到 1080p 屏界面会重新布局而不是拉伸。代价是你要保证布局是自适应的——这里又回到 Anchor 和 Dock如果你的控件全是绝对坐标切换显示器后位置会错乱。我的主界面用TableLayoutPanel做了三层划分四个区域分别设置 Dock缩放比例变化时自动重排基本不用写额外代码。表格的列宽我设置了FillWeight而不是固定像素这也是为了适配缩放。4.4 用 Chart 控件画分布图比想象中好使要让人相信综合出率真的是 1.6%光给个数字没用得给图。.NET 自带的Chart控件画直方图完全够用不需要引第三方库。具体做法把每次五星出现的抽数距离上一次五星间隔了多少抽统计成频次数组0 到 90 共 91 个桶然后填进 Chartprivate 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 SaveAtomicT(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-8var 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 双击没反应或者闪退这是发布版本收到最多的反馈。按可能性从高到低排症状可能原因处理方式双击后一闪而过无任何提示未打包运行时或单文件解压失败改用自包含发布SelfContainedtrue弹出缺少 .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 设置了TargetFrameworknet6.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得配一次大版本号我还在犹豫值不值。最后分享一个我自己用下来最有价值的习惯任何涉及随机的功能第一件事就是把种子暴露出来第二件事就是把原始随机数记进日志。这两件事加起来不到二十行代码但它能让概率不对这种最模糊、最难扯清的反馈变成一条可以精确复现的证据链。我做过的几个带随机逻辑的小工具最后真正省下大量沟通成本的都是这个习惯而不是那些精心设计的架构。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑