多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WPF C# 扫雷项目实战:从拆包到自动求解器

WPF C# 扫雷项目实战:从拆包到自动求解器 简介这是一份面向C#与WPF初学者及桌面开发爱好者的扫雷游戏完整源码包基于.NET Framework构建帮助读者理解如何用XAML与C#结合实现经典扫雷的界面布局、事件处理与游戏逻辑。压缩包共47个文件约186KB以cs代码文件、xaml界面文件、json与cache缓存配置、dll与exe可执行文件为主另含csproj项目文件、sln解决方案及少量pdb调试符号覆盖从项目配置到编译输出的完整结构。已有428人学习下载适合作为WPF入门练手项目。读者可从中获得主窗口代码与界面分离的编写思路、应用程序启动与资源字典的组织方式以及游戏逻辑与用户交互事件的具体实现便于对照调试、二次修改或作为课程设计参考。1. 从 winmine.zip 说起一个 WPF C# 扫雷项目能拿来练什么很多人看到 winmine.zip 这个名字第一反应是「又一个扫雷复刻」。但如果你正在学 WPF或者想找一个能同时练到 XAML 布局、C# 事件模型、MVVM 分层和自定义控件绘制的完整小项目这个包的价值就完全不一样了。它不是一个塞满炫技代码的玩具而是一个结构清晰、体量可控的桌面应用样本——棋盘渲染、雷区生成、递归展开、计时器、胜负判定这些核心逻辑都在代码量不至于让你翻三天还找不到入口。适合谁刚学完 C# 基础语法、想找一个能跑起来看到界面的练手项目的人也适合已经会 WinForm、想迁移到 WPF 看看数据绑定和命令机制到底怎么落地的人。下面我按实际拆包和复现的顺序把这个项目从结构到跑通再到改出花样的路走一遍。2. 拆开 winmine.zipWPF 扫雷的工程结构与核心机制拿到一个压缩包别急着双击 .sln。先看目录结构判断它用的是哪种 WPF 组织方式——是纯 Code-Behind 还是带了 MVVM是 .NET Framework 还是 .NET Core/5这决定了你后面改代码时要不要动 csproj。2.1 先看目录判断项目类型和依赖解压后常见的结构大致是这样winmine/ ├── winmine.sln ├── winmine/ │ ├── App.xaml │ ├── App.xaml.cs │ ├── MainWindow.xaml │ ├── MainWindow.xaml.cs │ ├── Models/ │ │ └── Cell.cs │ ├── ViewModels/ │ │ └── MainViewModel.cs │ ├── Views/ │ │ └── BoardView.xaml │ └── winmine.csproj拿到手先做三件事。第一打开 .csproj 看TargetFramework如果是net6.0-windows或net8.0-windows说明需要对应版本的 .NET SDK如果是net48那就是 .NET Framework 4.8Windows 自带运行时但你要确认 Visual Studio 装了「.NET 桌面开发」工作负载。第二看有没有packages.config或PackageReference判断是否引了第三方库——扫雷项目一般不需要外部依赖如果引了 MVVM 框架比如 CommunityToolkit.Mvvm 或 Prism你得先还原 NuGet 包。第三看 App.xaml 里的StartupUri指向哪个窗口这是程序入口。提示如果解压后没有 .sln 文件只有一堆 .cs 和 .xaml那可能是源码片段而非完整工程需要自己新建 WPF 项目再把文件拖进去。2.2 棋盘数据结构Cell 类怎么设计扫雷的核心数据模型就是一个格子。一个合格的 Cell 类至少要有这些字段public class Cell : INotifyPropertyChanged { public int Row { get; set; } public int Col { get; set; } private bool _isMine; public bool IsMine { get _isMine; set { _isMine value; OnPropertyChanged(); } } private bool _isRevealed; public bool IsRevealed { get _isRevealed; set { _isRevealed value; OnPropertyChanged(); } } private bool _isFlagged; public bool IsFlagged { get _isFlagged; set { _isFlagged value; OnPropertyChanged(); } } private int _adjacentMines; public int AdjacentMines { get _adjacentMines; set { _adjacentMines value; OnPropertyChanged(); } } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged([CallerMemberName] string name null) PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); }这里的关键点是INotifyPropertyChanged。WPF 的数据绑定靠它来感知属性变化——你改了IsRevealed界面上那个格子才会从「未翻开」变成「已翻开」的样式。如果你用的是纯 Code-Behind 写法可能没有这个接口而是直接在按钮的 Click 事件里改按钮的 Content 和 Background那种写法能跑但不好维护。判断标准很简单看 XAML 里有没有{Binding ...}有就是走了绑定路线。AdjacentMines这个字段存的是周围八格的地雷数范围 0 到 8。0 的时候触发递归展开这是扫雷最核心的交互逻辑后面会细说。2.3 雷区生成随机算法与首击保护雷区生成看起来简单——随机撒 N 个雷就完了。但真正影响体验的是两个细节第一首次点击不能踩雷第二雷的分布要均匀。常见做法是延迟生成玩家第一次点击某个格子后再排除该格及其周围八格从剩余位置里随机选 N 个放雷。这样保证首击安全也顺带保证了第一下点开就是一片空白区域体验好很多。public void GenerateMines(int excludeRow, int excludeCol, int mineCount) { var candidates new ListCell(); foreach (var cell in _allCells) { // 排除首击格及其周围八格 if (Math.Abs(cell.Row - excludeRow) 1 Math.Abs(cell.Col - excludeCol) 1) continue; candidates.Add(cell); } var rng new Random(); // Fisher-Yates 部分洗牌取前 mineCount 个 for (int i 0; i mineCount i candidates.Count; i) { int j rng.Next(i, candidates.Count); (candidates[i], candidates[j]) (candidates[j], candidates[i]); candidates[i].IsMine true; } // 计算每格周围雷数 foreach (var cell in _allCells) { if (cell.IsMine) continue; cell.AdjacentMines CountAdjacentMines(cell); } }参数说明excludeRow、excludeCol是首击坐标mineCount是总雷数。用 Fisher-Yates 而不是OrderBy(x rng.Next())是因为后者在大棋盘上性能差且分布不够均匀。CountAdjacentMines就是遍历周围八格数IsMine为 true 的数量注意边界处理——角落格子只有三个邻居。注意如果你拿到的项目里雷是在构造函数里一次性生成的没有首击保护那第一下就可能踩雷。这不是 bug 但体验差建议按上面的思路改掉。2.4 递归展开空白区域的 Flood Fill点到一个AdjacentMines 0的格子时要自动展开周围所有相邻格子遇到数字格停止。这就是经典的 Flood Fill用 BFS 或 DFS 都行但递归深度在大型棋盘上可能爆栈建议用队列做迭代。public void RevealCell(Cell start) { if (start.IsRevealed || start.IsFlagged) return; var queue new QueueCell(); queue.Enqueue(start); while (queue.Count 0) { var cell queue.Dequeue(); if (cell.IsRevealed || cell.IsFlagged) continue; cell.IsRevealed true; // 只有空白格才继续扩散 if (cell.AdjacentMines 0 !cell.IsMine) { foreach (var neighbor in GetNeighbors(cell)) { if (!neighbor.IsRevealed !neighbor.IsFlagged) queue.Enqueue(neighbor); } } } }逻辑说明从起始格开始标记已翻开如果该格周围没有雷就把未翻开且未标记的邻居加入队列。注意IsFlagged的格子要跳过——玩家插旗表示怀疑有雷自动展开不应该覆盖旗子。GetNeighbors返回周围最多八个格子需要做边界裁剪。这个逻辑跑通之后你会发现点一下空白区域「哗」地展开一大片这就是扫雷最爽的反馈。如果项目里没有这个功能或者展开时把旗子也翻开了那就是需要修的地方。3. 把项目跑起来环境配置、编译与首次运行拆完结构下一步是让它在你机器上跑起来。WPF 项目的环境门槛不高但版本对不上就是一堆编译错误这一章把常见情况都覆盖掉。3.1 确认 SDK 版本与工作负载先看 csproj 里的 TargetFramework。如果是net8.0-windows你需要 .NET 8 SDK如果是net6.0-windows需要 .NET 6 SDK。用命令行确认dotnet --list-sdks输出里要有对应的主版本号。如果没有去微软官网下对应版本的 SDK 安装包。注意 WPF 项目必须带-windows后缀的目标框架纯net8.0是不行的因为 WPF 依赖 Windows 桌面运行时。如果你用 Visual Studio打开安装器确认勾选了「.NET 桌面开发」工作负载。这个工作负载包含 WPF 设计器、XAML 智能提示和必要的构建目标。少了它打开 .xaml 文件可能显示为纯文本。3.2 还原与编译dotnet CLI 和 Visual Studio 两条路命令行方式cd winmine dotnet restore dotnet buildrestore拉取 NuGet 依赖build编译。如果报NETSDK1045错误说明当前 SDK 版本低于项目要求要么装新 SDK要么把 csproj 里的 TargetFramework 降到你已有的版本但要注意 API 兼容性。Visual Studio 方式更直观双击 .sln等解决方案资源管理器加载完右键解决方案选「还原 NuGet 包」然后按 F6 或 CtrlShiftB 生成。底部输出窗口会显示编译结果有错误会列在「错误列表」里。常见编译错误对照错误码含义处理方式CS0246找不到类型或命名空间检查 using 指令确认引用了对应程序集MC3074XAML 中引用了不存在的类型检查 xmlns 命名空间和类名拼写NETSDK1045SDK 版本不支持目标框架升级 SDK 或降低 TargetFrameworkMSB3644找不到引用程序集安装对应版本的 .NET Framework 开发包3.3 首次运行界面出来了但点不动怎么办编译通过后按 F5 启动应该能看到扫雷窗口。如果界面出来了但点击格子没反应按以下顺序排查第一检查事件绑定。如果是 MVVM 模式格子的点击应该绑定到 ViewModel 的 Command 上。打开 XAML 找到格子的模板看有没有Command{Binding RevealCommand}之类的绑定。如果绑定的 Command 名字和 ViewModel 里的属性名不一致绑定会静默失败——不报错但也不执行。第二检查 DataContext。在 MainWindow 的构造函数里应该有DataContext new MainViewModel()这样的代码。如果 DataContext 没设所有 Binding 都找不到源界面就是死的。第三如果是 Code-Behind 模式检查按钮的 Click 事件有没有在 XAML 里挂上比如ClickCell_Click以及后台代码里有没有对应的方法签名。提示WPF 的绑定失败默认不抛异常只在输出窗口打一行调试信息。调试时把 Visual Studio 的「输出」窗口打开筛选「绑定」相关的消息能省很多时间。4. 改出你自己的版本难度参数、计时器与样式定制跑通之后这个项目最大的价值在于它是一个可改的基底。扫雷的规则简单但每个参数背后都有可调的空间改一改就能变成你自己的东西。4.1 难度参数棋盘尺寸、雷数与平衡性标准扫雷有三档难度初级 9×9 十雷、中级 16×16 四十雷、高级 30×16 九十九雷。这些参数一般定义在 ViewModel 或配置类里public class GameConfig { public int Rows { get; set; } public int Cols { get; set; } public int MineCount { get; set; } public static GameConfig Beginner new() { Rows 9, Cols 9, MineCount 10 }; public static GameConfig Intermediate new() { Rows 16, Cols 16, MineCount 40 }; public static GameConfig Expert new() { Rows 30, Cols 16, MineCount 99 }; }雷密度MineCount / (Rows × Cols)直接影响难度。初级约 12.3%中级约 15.6%高级约 20.6%。如果你想自定义难度建议雷密度控制在 10% 到 25% 之间——低于 10% 太简单没意思高于 25% 基本靠猜体验很差。改难度时要注意界面适配。棋盘从 9×9 变成 30×16格子如果还是固定 40×40 像素窗口宽度会超过 1200 像素。常见做法是把格子尺寸做成可计算的根据窗口可用宽度除以列数取一个合理的最小值。或者干脆用Viewbox包裹棋盘让它自动缩放。4.2 计时器与计数器DispatcherTimer 的正确用法扫雷界面上的计时器从第一下点击开始走到游戏结束停止。WPF 里用DispatcherTimer而不是System.Timers.Timer因为前者在 UI 线程上触发 Tick可以直接更新界面元素不需要Dispatcher.Invoke。private DispatcherTimer _timer; private int _seconds; private void StartTimer() { _seconds 0; _timer new DispatcherTimer { Interval TimeSpan.FromSeconds(1) }; _timer.Tick (s, e) { _seconds; TimerText _seconds.ToString(D3); // 格式化为三位数 }; _timer.Start(); } private void StopTimer() { _timer?.Stop(); _timer null; }参数说明Interval设为 1 秒D3格式让显示始终是三位数001、012、123。注意游戏结束时必须调StopTimer否则计时器还在后台跑重新开局时会有多个计时器叠加时间跳得飞快——这是很常见的翻车点。剩余雷数计数器就是MineCount - FlagCount每次插旗或取消旗子时更新。如果允许插旗数超过总雷数这个值会变成负数界面上显示负号不好看建议在插旗逻辑里加个判断旗子数达到总雷数时不再允许插旗。4.3 样式定制从默认按钮到自定义 ControlTemplateWPF 最大的优势之一是控件模板可以完全替换。默认的 Button 有边框、圆角和悬停效果直接拿来当格子用会显得很「系统自带」。要做出扫雷那种扁平方格效果需要重写 ControlTemplateStyle x:KeyCellButtonStyle TargetTypeButton Setter PropertyTemplate Setter.Value ControlTemplate TargetTypeButton Border x:NamecellBorder Background#C0C0C0 BorderBrush#808080 BorderThickness2 Width30 Height30 ContentPresenter HorizontalAlignmentCenter VerticalAlignmentCenter FontWeightBold FontSize14/ /Border ControlTemplate.Triggers Trigger PropertyIsMouseOver ValueTrue Setter TargetNamecellBorder PropertyBackground Value#D0D0D0/ /Trigger Trigger PropertyIsPressed ValueTrue Setter TargetNamecellBorder PropertyBackground Value#A0A0A0/ /Trigger /ControlTemplate.Triggers /ControlTemplate /Setter.Value /Setter /Style这段模板把按钮变成了一个 30×30 的灰色方块悬停时稍微变亮按下时变暗。数字颜色可以按AdjacentMines的值用 DataTrigger 区分——经典扫雷里 1 是蓝色、2 是绿色、3 是红色这套配色方案沿用几十年是有道理的辨识度高。注意自定义 ControlTemplate 后按钮的默认点击区域可能变化。确保 Border 的 Background 不为 Transparent否则鼠标事件穿透点不到。4.4 右键插旗ContextMenu 还是 MouseRightButtonDown扫雷的插旗操作是右键点击格子。WPF 里右键默认会触发 ContextMenu如果你给按钮加了右键菜单插旗逻辑就会被菜单挡住。正确做法是禁用 ContextMenu直接处理MouseRightButtonDown事件Button MouseRightButtonDownCell_RightClick ContextMenu{x:Null} ... /private void Cell_RightClick(object sender, MouseButtonEventArgs e) { if (sender is Button btn btn.DataContext is Cell cell) { if (!cell.IsRevealed) { cell.IsFlagged !cell.IsFlagged; } e.Handled true; // 阻止事件继续冒泡 } }e.Handled true很重要不加的话事件可能冒泡到父容器触发其他逻辑。另外ContextMenu{x:Null}显式清空右键菜单避免默认行为干扰。5. 避坑与排查WPF 扫雷项目里最容易翻车的五个地方这个项目体量不大但 WPF 本身的坑不少。以下是我实际拆改过程中遇到或见到的典型问题按「现象 → 原因 → 解决」整理。5.1 界面卡死递归展开没有收敛条件现象点击一个空白格后程序无响应窗口变白几秒后可能崩溃。原因递归展开时没有正确判断格子是否已经翻开导致同一批格子被反复加入队列形成无限循环。或者GetNeighbors返回了自身。解决在入队前检查!neighbor.IsRevealed在出队后再次检查if (cell.IsRevealed) continue;。双重检查看起来冗余但能防止并发修改导致的状态不一致。另外确认GetNeighbors里排除了当前格。5.2 绑定不生效DataContext 没设或属性名拼错现象界面能显示但点击格子后数字不更新、旗子不显示。原因XAML 里的{Binding IsRevealed}找不到对应的属性或者 DataContext 没有正确设置。WPF 绑定失败不报错只在输出窗口打一行提示。解决打开 Visual Studio 的「输出」窗口看有没有System.Windows.Data Error: 40开头的消息里面会写明哪个属性绑定失败。检查 ViewModel 里的属性名大小写是否和 XAML 里一致——C# 属性是 PascalCaseXAML 绑定也必须是 PascalCase。如果 DataContext 是在构造函数里设的确认那行代码在InitializeComponent()之后。5.3 计时器叠加重新开局后时间跳变现象第一局正常点「重新开始」后计时器每秒跳 2 或 3越往后越快。原因旧的 DispatcherTimer 没有 Stop新的又创建了一个多个计时器同时 Tick每次都加 1 秒。解决在创建新计时器之前先调_timer?.Stop()并把_timer置空。更好的做法是把计时器做成单例只创建一次通过 Start/Stop 控制启停而不是每次重新 new。5.4 高 DPI 下格子错位像素对齐问题现象在 125% 或 150% 缩放的显示器上格子之间出现缝隙或重叠棋盘看起来歪了。原因WPF 使用设备无关像素但 Border 的 BorderThickness 在高 DPI 下可能产生小数像素渲染时取整导致错位。解决给格子的 Width/Height 设整数值避免用百分比或星号分配。在 App.xaml.cs 里可以设置RenderOptions.SetEdgeMode但更简单的办法是把格子尺寸设为偶数比如 30 而不是 29减少取整误差。如果用了 Viewbox 缩放确保 Stretch 模式是 Uniform。5.5 发布后缺少运行时目标机器打不开现象自己电脑上跑得好好的拷给别人双击 exe 没反应或报错。原因目标机器没装对应版本的 .NET 运行时。框架依赖发布默认需要目标机器有运行时独立发布才能自带。解决发布时用--self-contained参数dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFiletrue这样生成的 exe 自带运行时拷到任何 Windows 机器都能跑。代价是文件体积从几百 KB 涨到几十 MB。如果不想这么大就在目标机器上装对应版本的 .NET Desktop Runtime用框架依赖模式发布。6. 进阶玩法把扫雷改成可验证的算法练习场跑通、改完、避完坑之后这个项目还能往哪走我的习惯是把它当成一个算法验证的可视化壳子——扫雷的棋盘本质上是一个二维网格很多网格算法都可以套进来跑。一个具体的方向是自动求解器。扫雷的自动求解分两层确定性推理和概率推理。确定性推理很简单——如果一个已翻开的数字格周围未翻开格数等于剩余雷数那这些格子全是雷插旗如果数字格周围已插旗数等于该数字那其余未翻开格全是安全的点开。这两条规则循环执行能解掉大部分简单局面。public bool ApplyDeterministicRules() { bool changed false; foreach (var cell in _allCells.Where(c c.IsRevealed c.AdjacentMines 0)) { var neighbors GetNeighbors(cell).Where(n !n.IsRevealed).ToList(); var flagged neighbors.Count(n n.IsFlagged); var unflagged neighbors.Where(n !n.IsFlagged).ToList(); // 规则一未翻开格数 剩余雷数全部插旗 if (neighbors.Count cell.AdjacentMines - flagged) { foreach (var n in unflagged) { n.IsFlagged true; changed true; } } // 规则二已插旗数 数字其余全安全 if (flagged cell.AdjacentMines) { foreach (var n in unflagged) { RevealCell(n); changed true; } } } return changed; }把这段逻辑包在一个循环里反复执行直到changed false就得到了一个基础求解器。你可以加一个「自动求解」按钮每点一次执行一轮观察棋盘怎么一步步被解开。这比干看代码有意思得多也能直观感受到哪些局面是确定性推理解不掉的。再进一步可以记录求解器的胜率。跑一百局初级统计确定性规则能赢多少——大概在 70% 到 80% 之间。剩下的局面需要猜而猜的策略选概率最低的格子又是另一个话题了。这个数据不是拍脑袋来的你自己跑一遍就有体感。还有一个更实用的技巧把棋盘状态序列化成 JSON方便复现和调试。public string SerializeBoard() { var state new { Rows _rows, Cols _cols, Cells _allCells.Select(c new { c.Row, c.Col, c.IsMine, c.IsRevealed, c.IsFlagged, c.AdjacentMines }) }; return JsonSerializer.Serialize(state); }遇到一个解不开的局面序列化存下来下次直接反序列化恢复不用重新随机。调试求解器的时候这个习惯能省大量时间。从那以后我每次拆这类小项目都会先跑通默认配置再找一个可验证的扩展点动手改——扫雷的扩展点就是自动求解。改完能跑、能出数据这个项目才算真正吃透了。希望帮到你。本文还有配套的精品资源点击获取
返回列表