☰
C# MVC 联机扫雷实战:TCP 网络编程与多线程 UI 更新
2026/9/28 2:30:02 网站建设 项目流程

简介:这是一份基于C#与MVC架构的联机扫雷游戏项目源码,由开发者卢山创建,适合具备一定C#基础、希望深入理解网络编程与MVC分层设计的初学者和进阶学习者参考。项目将经典扫雷改造为支持多人在线对战的版本,涉及网络通信、实时同步、游戏状态管理等核心问题,是理论落地到实际项目的典型练习。压缩包共103个文件,约2.16MB,以cs源代码、config配置、xaml界面、png/jpg/bmp图片资源及dll动态库为主,另含csproj工程文件、sln解决方案与少量数据库脚本,覆盖模型、视图、控制器三层结构。已有151人学习下载。通过研读源码,读者可掌握扫雷雷区生成与胜负判定算法、基于Ajax或SignalR的实时通信思路、用户界面动态交互实现,以及控制器处理开始新游戏、保存进度等请求的完整流程,对提升C#编程与MVC架构应用能力有实际帮助。

1. 联机扫雷:从单机玩具到 MVC 分层网络对战,到底难在哪

单机扫雷谁都能写,一个二维数组加递归展开就完事。但把「联机」两个字加进去,事情立刻变得不一样:两个玩家在同一张雷图上实时点格子,谁先踩雷谁输,对方点了哪一格你得马上看到。这背后涉及 C# 网络编程、MVC 分层架构、多线程 UI 更新三个硬骨头。我见过太多人把联机扫雷写成「能跑但一联机就崩」的 demo——要么 UI 线程被网络回调卡死,要么两个客户端状态不一致,要么服务端广播时直接抛异常。这篇笔记就按 C# + MVC 的路子,把联机扫雷从架构设计到可运行代码一步步拆开。适合有 C# 基础、想搞懂 MVC 设计模式怎么落地到实际项目、同时对 TCP 网络编程有兴趣的开发者。读完你能拿到一套可复现的联机扫雷骨架,知道每个参数为什么这么设,以及哪些坑我替你踩过了。

2. MVC 三层怎么切:扫雷的 Model、View、Controller 边界在哪

2.1 为什么扫雷这种小游戏也值得上 MVC

很多人觉得扫雷就一个窗体,拖几个按钮就完事,上 MVC 是过度设计。但一旦联机,逻辑复杂度翻倍:雷区数据要同步、玩家操作要广播、胜负判定要在服务端做。如果全塞进 Form1.cs,改一个网络协议就得翻遍整个文件。MVC 的核心价值在这里不是「好看」,而是让网络层、游戏逻辑层、界面层各自独立演化。

具体到扫雷,我一般这样切:

  • Model(模型层):雷区数据结构、格子状态枚举、游戏规则判定(踩雷、展开、胜负)。这一层完全不知道网络和 UI 的存在,纯 C# 类。
  • View(视图层):WinForms 窗体,负责渲染格子、接收鼠标点击、显示玩家状态。它只跟 Controller 打交道,不直接碰 Model。
  • Controller(控制层):接收 View 的点击事件,调用 Model 更新状态,再通过网络层把操作发给服务端,同时监听服务端广播并驱动 View 刷新。

这样切的好处是:你想把 WinForms 换成 WPF 甚至控制台,Model 和网络协议一行不用改。想加一个 AI 玩家,也只需要在 Controller 层多一个调用入口。

2.2 Model 层的核心数据结构与代码

先定义格子状态和雷区模型。这里用枚举加二维数组,简单直接:

// CellState.cs - 格子状态枚举 public enum CellState { Hidden, // 未翻开 Revealed, // 已翻开 Flagged, // 插旗 Mine // 地雷(游戏结束后显示) } // Cell.cs - 单个格子 public class Cell { public bool IsMine { get; set; } // 是否是雷 public int AdjacentMines { get; set; } // 周围雷数 public CellState State { get; set; } = CellState.Hidden; } // Board.cs - 雷区模型,纯逻辑,无 UI 无网络 public class Board { public int Rows { get; } public int Cols { get; } public int MineCount { get; } public Cell[,] Cells { get; private set; } public bool IsGameOver { get; private set; } public bool IsWin { get; private set; } public Board(int rows, int cols, int mineCount) { Rows = rows; Cols = cols; MineCount = mineCount; Cells = new Cell[rows, cols]; for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) Cells[r, c] = new Cell(); } // 布雷:首点安全,第一次点击的位置及其周围不布雷 public void PlaceMines(int safeRow, int safeCol, Random rand) { int placed = 0; while (placed < MineCount) { int r = rand.Next(Rows); int c = rand.Next(Cols); // 跳过安全区 if (Math.Abs(r - safeRow) <= 1 && Math.Abs(c - safeCol) <= 1) continue; if (Cells[r, c].IsMine) continue; Cells[r, c].IsMine = true; placed++; } // 计算每个格子周围雷数 for (int r = 0; r < Rows; r++) for (int c = 0; c < Cols; c++) Cells[r, c].AdjacentMines = CountAdjacentMines(r, c); } private int CountAdjacentMines(int row, int col) { int count = 0; for (int dr = -1; dr <= 1; dr++) for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = row + dr, nc = col + dc; if (nr >= 0 && nr < Rows && nc >= 0 && nc < Cols && Cells[nr, nc].IsMine) count++; } return count; } // 翻开格子,返回本次翻开的所有坐标(用于广播) public List<(int r, int c)> Reveal(int row, int col) { var revealed = new List<(int, int)>(); if (IsGameOver || Cells[row, col].State != CellState.Hidden) return revealed; if (Cells[row, col].IsMine) { Cells[row, col].State = CellState.Mine; IsGameOver = true; return revealed; } // BFS 展开空白区域 var queue = new Queue<(int, int)>(); queue.Enqueue((row, col)); while (queue.Count > 0) { var (r, c) = queue.Dequeue(); if (Cells[r, c].State != CellState.Hidden) continue; Cells[r, c].State = CellState.Revealed; revealed.Add((r, c)); if (Cells[r, c].AdjacentMines == 0) { for (int dr = -1; dr <= 1; dr++) for (int dc = -1; dc <= 1; dc++) { if (dr == 0 && dc == 0) continue; int nr = r + dr, nc = c + dc; if (nr >= 0 && nr < Rows && nc >= 0 && nc < Cols && Cells[nr, nc].State == CellState.Hidden) queue.Enqueue((nr, nc)); } } } CheckWin(); return revealed; } private void CheckWin() { int hidden = 0; for (int r = 0; r < Rows; r++) for (int c = 0; c < Cols; c++) if (Cells[r, c].State == CellState.Hidden && !Cells[r, c].IsMine) hidden++; if (hidden == 0) { IsWin = true; IsGameOver = true; } } }

这段代码的关键设计点:Reveal方法返回本次翻开的所有坐标列表,而不是让调用方自己去遍历。为什么?因为联机场景下,服务端需要把这个列表广播给所有客户端,客户端拿到列表直接更新对应格子的 UI,不需要重新跑一遍展开逻辑。这是保证多端状态一致的核心——逻辑只在服务端跑一次,结果广播出去。

参数方面,Rows、Cols、MineCount建议从配置文件读,不要硬编码。我一般用 16×16 配 40 雷,跟经典扫雷一致。Random实例要注意线程安全,服务端多线程环境下用Random.Shared(.NET 6+)或者加锁。

2.3 Controller 层怎么串联网络与 UI

Controller 的职责是「翻译」:把 View 的点击翻译成 Model 操作,再把 Model 的变化翻译成网络消息。核心方法就两个——处理本地点击、处理远程广播:

// GameController.cs - 控制层 public class GameController { private Board _board; private readonly NetworkClient _network; private readonly IGameView _view; // 接口,方便替换 UI 实现 public GameController(IGameView view, NetworkClient network) { _view = view; _network = network; _network.OnMessageReceived += HandleServerMessage; } // 本地玩家点击格子 public void OnCellClicked(int row, int col) { // 先发请求给服务端,等服务端确认后再更新本地 var msg = new RevealRequest { Row = row, Col = col }; _network.Send(msg); } // 处理服务端广播 private void HandleServerMessage(GameMessage msg) { switch (msg.Type) { case MessageType.RevealResult: // 服务端返回翻开的坐标列表,更新本地 Model 和 View foreach (var (r, c) in msg.RevealedCells) _board.Cells[r, c].State = CellState.Revealed; _view.RefreshCells(msg.RevealedCells); break; case MessageType.GameOver: _view.ShowGameOver(msg.IsWin); break; } } }

注意这里有个关键决策:本地点击不直接改 Model,而是先发给服务端。这叫「服务端权威」模式。好处是杜绝了客户端作弊和状态不一致;代价是每次点击都有网络延迟。局域网下延迟可以忽略,公网环境建议加乐观更新——先本地显示,服务端拒绝时再回滚。但回滚逻辑复杂,新手建议先用服务端权威模式跑通。

IGameView接口的定义很简单,就是几个刷新方法:

public interface IGameView { void RefreshCells(List<(int r, int c)> cells); void ShowGameOver(bool isWin); void UpdatePlayerStatus(string status); }

WinForms 窗体实现这个接口,Controller 完全不依赖具体控件类型。这就是 MVC 分层的实际收益。

3. 联机通信怎么搭:TCP 协议设计、粘包处理与多客户端广播

3.1 消息协议设计:用长度前缀解决粘包

TCP 是字节流协议,没有消息边界。你发两条消息,对方可能一次收到一条半。这是联机扫雷最容易翻车的地方——本地测试好好的,一上网络就各种解析异常。常见做法是长度前缀协议:每条消息前面加 4 个字节表示消息体长度,接收方先读 4 字节拿到长度,再读对应长度的字节。

消息体本身用 JSON 序列化,简单直观。先定义消息类型和结构:

// GameMessage.cs - 消息定义 public enum MessageType { Join, // 加入游戏 RevealRequest, // 请求翻开 RevealResult, // 翻开结果广播 GameOver, // 游戏结束 PlayerList // 玩家列表更新 } public class GameMessage { public MessageType Type { get; set; } public int Row { get; set; } public int Col { get; set; } public List<CellPos> RevealedCells { get; set; } public bool IsWin { get; set; } public string PlayerName { get; set; } } public class CellPos { public int R { get; set; } public int C { get; set; } }

序列化用System.Text.Json,.NET 内置,不需要额外 NuGet 包。注意CellPos的字段用短名R、C而不是Row、Col,因为广播消息里坐标列表可能很长,短字段名能省不少带宽。这是实战中的小优化,新手容易忽略。

3.2 服务端:TcpListener 多客户端管理与广播

服务端核心就三件事:接受连接、维护客户端列表、收到消息后广播给所有人。用TcpListener加异步 Accept:

// GameServer.cs - 服务端主循环 public class GameServer { private readonly TcpListener _listener; private readonly List<ClientSession> _clients = new(); private readonly Board _board; private readonly object _lock = new(); public GameServer(int port) { _listener = new TcpListener(IPAddress.Any, port); _board = new Board(16, 16, 40); } public async Task StartAsync() { _listener.Start(); Console.WriteLine("服务端已启动,等待连接..."); while (true) { var tcpClient = await _listener.AcceptTcpClientAsync(); var session = new ClientSession(tcpClient); lock (_lock) { _clients.Add(session); } _ = Task.Run(() => HandleClientAsync(session)); } } private async Task HandleClientAsync(ClientSession session) { try { while (true) { var msg = await session.ReceiveAsync(); if (msg == null) break; if (msg.Type == MessageType.RevealRequest) { // 在服务端执行翻开逻辑 var revealed = _board.Reveal(msg.Row, msg.Col); var result = new GameMessage { Type = MessageType.RevealResult, RevealedCells = revealed.Select(p => new CellPos { R = p.r, C = p.c }).ToList() }; await BroadcastAsync(result); if (_board.IsGameOver) { await BroadcastAsync(new GameMessage { Type = MessageType.GameOver, IsWin = _board.IsWin }); } } } } catch (Exception ex) { Console.WriteLine($"客户端异常: {ex.Message}"); } finally { lock (_lock) { _clients.Remove(session); } session.Close(); } } private async Task BroadcastAsync(GameMessage msg) { List<ClientSession> snapshot; lock (_lock) { snapshot = new List<ClientSession>(_clients); } foreach (var client in snapshot) { try { await client.SendAsync(msg); } catch { /* 单个客户端发送失败不影响其他 */ } } } }

这里有几个关键点。第一,_clients列表用lock保护,因为AcceptTcpClientAsync和客户端断开可能在不同线程上操作列表。第二,广播前先做快照(new List<ClientSession>(_clients)),避免遍历时列表被修改导致InvalidOperationException。第三,单个客户端发送失败要吞掉异常,不能因为一个断线的客户端让整个广播挂掉。

3.3 客户端:TcpClient 连接与异步接收

客户端用TcpClient连接服务端,接收循环跑在独立 Task 里,收到消息后通过事件回调通知 UI:

// NetworkClient.cs - 客户端网络层 public class NetworkClient { private TcpClient _client; private NetworkStream _stream; public event Action<GameMessage> OnMessageReceived; public async Task ConnectAsync(string host, int port) { _client = new TcpClient(); await _client.ConnectAsync(host, port); _stream = _client.GetStream(); _ = Task.Run(ReceiveLoopAsync); } private async Task ReceiveLoopAsync() { var lengthBuffer = new byte[4]; while (true) { // 先读 4 字节长度 int read = 0; while (read < 4) { int n = await _stream.ReadAsync(lengthBuffer, read, 4 - read); if (n == 0) return; // 连接断开 read += n; } int length = BitConverter.ToInt32(lengthBuffer, 0); // 再读消息体 var bodyBuffer = new byte[length]; read = 0; while (read < length) { int n = await _stream.ReadAsync(bodyBuffer, read, length - read); if (n == 0) return; read += n; } var msg = JsonSerializer.Deserialize<GameMessage>(bodyBuffer); OnMessageReceived?.Invoke(msg); } } public async Task SendAsync(GameMessage msg) { var body = JsonSerializer.SerializeToUtf8Bytes(msg); var lengthPrefix = BitConverter.GetBytes(body.Length); await _stream.WriteAsync(lengthPrefix, 0, 4); await _stream.WriteAsync(body, 0, body.Length); } }

接收循环里那个while (read < 4)是必须的。ReadAsync不保证一次读满你要求的字节数,可能只返回 1 个字节。新手最常见的错误就是await _stream.ReadAsync(buffer, 0, 4)然后直接BitConverter.ToInt32,结果长度解析出来是个天文数字,后面直接内存溢出。这个坑我踩过不止一次。

3.4 UI 线程安全:Invoke 与 SynchronizationContext

网络回调跑在后台线程,直接更新 WinForms 控件会抛InvalidOperationException。正确做法是用Control.Invoke或BeginInvoke切回 UI 线程:

// MainForm.cs - View 层处理跨线程更新 public partial class MainForm : Form, IGameView { private GameController _controller; public void RefreshCells(List<(int r, int c)> cells) { if (this.InvokeRequired) { this.BeginInvoke(new Action(() => RefreshCells(cells))); return; } foreach (var (r, c) in cells) { var btn = _grid[r, c]; btn.Enabled = false; btn.Text = _board.Cells[r, c].AdjacentMines.ToString(); } } }

InvokeRequired判断是必须的,因为RefreshCells可能从 UI 线程直接调用(比如本地操作),也可能从网络线程调用。BeginInvoke比Invoke好,因为它是异步的,不会阻塞网络接收循环。如果用Invoke,网络线程会等 UI 处理完才继续,高频率广播时容易卡顿。

4. 避坑与排查:联机扫雷最容易翻车的 5 个地方

4.1 现象:客户端点击后没反应,服务端日志显示收到消息但客户端不刷新

原因:客户端接收循环里OnMessageReceived事件绑定的方法抛了异常,但异常被吞掉了。常见于 JSON 反序列化失败——服务端序列化时用了CellPos类,客户端版本不一致导致字段对不上。

解决:在ReceiveLoopAsync里加 try-catch,把异常打出来。同时确保服务端和客户端引用同一份消息定义文件,最好用共享类库项目。我一般会把GameMessage.cs放在一个单独的Shared项目里,两边都引用它。

4.2 现象:两个客户端同时点同一格,一个显示翻开一个显示未翻开

原因:客户端做了乐观更新,本地先翻开再等服务端确认,但服务端只广播给了另一个客户端,没有广播给发起者。或者广播时漏掉了发起者。

解决:服务端广播时不要排除任何人,统一广播给所有客户端包括发起者。发起者收到广播后覆盖本地状态。如果做乐观更新,收到广播时要做幂等处理——已经翻开的格子跳过。

4.3 现象:游戏运行几分钟后客户端突然断开,服务端抛「远程主机强迫关闭了一个现有的连接」

原因:TCP 连接空闲时间过长被中间设备断开,或者客户端异常退出没有发 FIN 包。另外TcpClient默认没有心跳机制,长时间不通信会被某些网络设备回收。

解决:加心跳包。客户端每 30 秒发一个MessageType.Heartbeat,服务端收到后回复。服务端如果 90 秒没收到任何消息就主动断开。心跳包体可以为空,只占 4 字节长度前缀加 1 字节类型。

4.4 现象:服务端广播时抛「集合已修改,可能无法执行枚举操作」

原因:BroadcastAsync遍历_clients时,另一个线程正好在添加或移除客户端。List<T>不是线程安全的。

解决:广播前先lock做快照,如 3.2 节代码所示。或者直接用ConcurrentBag<ClientSession>,但注意ConcurrentBag遍历时也不保证一致性,快照仍然是最稳妥的做法。

4.5 现象:本地测试正常,部署到另一台机器后连不上

原因:服务端TcpListener绑定的是IPAddress.Any,但防火墙没放行端口。或者客户端填的 IP 是127.0.0.1,在另一台机器上应该填服务端的局域网 IP。

解决:服务端机器上放行对应端口(Windows 防火墙入站规则)。客户端连接地址做成可配置的,不要硬编码。调试时先用telnet 服务端IP 端口确认端口通不通,再跑程序。

5. 进阶技巧:用状态快照做断线重连与观战模式

断线重连是联机游戏的刚需。玩家网络抖一下断了,重连后应该看到当前雷区的完整状态,而不是一片空白。实现思路很简单:服务端维护一份完整的雷区状态快照,客户端重连时先发Join消息,服务端收到后把当前Board的所有格子状态序列化发回去。

具体做法是在GameMessage里加一个Snapshot类型,携带所有已翻开格子的坐标和状态:

// 服务端处理重连 if (msg.Type == MessageType.Join) { var snapshot = new GameMessage { Type = MessageType.Snapshot, RevealedCells = GetAllRevealedCells() // 遍历 Board 收集所有已翻开格子 }; await session.SendAsync(snapshot); }

客户端收到Snapshot后,先清空本地 UI,再按列表逐格刷新。注意快照消息可能比较大(16×16 全翻开是 256 个坐标),但 JSON 序列化后也就几 KB,完全可接受。

观战模式的实现更简单:观战客户端只收广播不发请求。服务端在ClientSession里加一个IsSpectator标志,收到RevealRequest时如果是观战者直接忽略。广播时观战者照常收到,UI 层把格子设为不可点击即可。

还有一个实用技巧是操作回放。服务端把每次RevealResult按顺序存到一个列表里,游戏结束后可以导出成回放文件。客户端加载回放文件后,按时间间隔逐条应用消息,就能还原整局游戏。这个功能对调试联机同步问题特别有用——出问题时把回放文件拿来一看,就知道是哪一步状态不一致了。

最后说一个我自己的习惯:每次改网络协议,先写一个最简单的命令行客户端跑通收发,确认协议没问题再接 UI。UI 层的变量太多,网络问题混在里面排查起来就是玄学。命令行客户端能排除 90% 的干扰因素,这个习惯帮我省了无数个加班的夜晚。希望帮到你。

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

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

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

立即咨询