C#实战联机扫雷:TCP长连接与MVC架构详解
2026/9/24 18:04:25 网站建设 项目流程

简介:这是一份基于C#与MVC架构实现的联机扫雷游戏项目源码,由开发者卢山创建,适合具备一定C#基础、希望深入理解网络应用开发与游戏逻辑实现的初学者和进阶学习者参考。项目将扫雷规则拆分为模型、视图与控制器三层,模型负责雷区生成、胜负判定与状态存储,视图呈现雷区面板与标记交互,控制器处理点击输入及与服务器的通信,并借助网络编程技术实现多人在线对战与实时同步。压缩包共103个文件,约2.16MB,以cs源码、config配置、xaml界面、png与jpg图片资源、dll与exe程序集为主,另含svc、wsdl、xsd等网络服务描述文件及csproj、sln工程文件,完整覆盖客户端与通信层结构。已有151人学习下载。通过研读源码,读者可掌握扫雷算法、MVC分层组织、Ajax或SignalR实时通信思路以及数据库与界面交互的实现方式,是理解C#网络游戏开发的实用案例。

1. 联机扫雷:从单机玩具到 MVC 架构的 C# 实战

单机扫雷谁都会写,一个二维数组加几个按钮就能跑起来。但一旦加上「联机」两个字,事情就完全不一样了:两个玩家在同一张雷区上操作,谁的点击先到、谁的地图先刷新、踩雷之后怎么同步结束状态,这些全是分布式系统里最经典的坑。这个标题里的 C# 和 MVC 不是装饰,它们决定了你用什么语言写服务端和客户端、用什么结构把「数据模型」「界面渲染」「网络消息处理」拆开。如果你正在找一个能同时练网络编程、状态同步和分层架构的小项目,联机扫雷是个非常好的载体——规则简单到不用解释,但联机部分足够让你踩一遍真实项目才会遇到的坑。适合已经会写 C# 基础语法、想往「能联机的桌面应用」方向走一步的开发者。

2. 先定通信模型:TCP 长连接还是 HTTP 轮询

2.1 为什么扫雷联机不适合用 HTTP 短连接

扫雷联机的核心交互是「一方点击格子,另一方几乎同时看到结果」。如果用 HTTP 短连接,客户端每次点击都要发一个请求,服务端返回最新棋盘状态,另一方靠轮询去拉。问题在于:轮询间隔设短了,请求量爆炸;设长了,对手已经点完三个格子你才刷新一次,体验直接崩掉。更麻烦的是「踩雷即结束」这种事件,轮询模型下很难保证两个客户端在同一时刻收到结束信号,容易出现一方已经看到游戏结束、另一方还在点的情况。

常见做法是用 TCP 长连接。C# 里最直接的就是TcpListener+TcpClient,服务端为每个连进来的客户端开一个独立线程或 Task 去读消息,消息到达后广播给房间里的其他客户端。这样点击事件从产生到对手看到,延迟基本就是网络 RTT,通常几十毫秒以内,体验接近本地。

注意:不要一上来就上 SignalR 或 WebSocket。虽然它们也能做,但扫雷联机的消息量很小、逻辑很直,用裸 TCP 反而更容易看清「消息怎么编解码、连接怎么管理」这些底层问题。等你把 TCP 版本跑通了,再换 SignalR 就是半天的事。

2.2 用 TcpListener 搭一个最小可用的房间服务

下面这段代码是服务端的骨架:监听端口、接受客户端、为每个客户端开一个读循环。先不处理游戏逻辑,只保证「连上来能收到消息、发出去能到达」。

using System; using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; class GameServer { // 房间号 -> 该房间所有客户端的流 static ConcurrentDictionary<string, ConcurrentBag<NetworkStream>> _rooms = new(); static async Task Main() { var listener = new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine("Server started on port 9000"); while (true) { var client = await listener.AcceptTcpClientAsync(); _ = HandleClientAsync(client); // 不 await,每个客户端独立处理 } } static async Task HandleClientAsync(TcpClient client) { var stream = client.GetStream(); var buffer = new byte[1024]; string roomId = null; try { while (true) { int len = await stream.ReadAsync(buffer, 0, buffer.Length); if (len == 0) break; // 客户端断开 string msg = Encoding.UTF8.GetString(buffer, 0, len); // 第一条消息约定为 "JOIN:房间号" if (roomId == null && msg.StartsWith("JOIN:")) { roomId = msg.Substring(5).Trim(); _rooms.GetOrAdd(roomId, _ => new ConcurrentBag<NetworkStream>()).Add(stream); Console.WriteLine($"Client joined room {roomId}"); continue; } // 后续消息广播给同房间其他人 if (roomId != null && _rooms.TryGetValue(roomId, out var bag)) { foreach (var s in bag) { if (s == stream) continue; // 不回发给自己 var data = Encoding.UTF8.GetBytes(msg); await s.WriteAsync(data, 0, data.Length); } } } } catch (Exception ex) { Console.WriteLine($"Client error: {ex.Message}"); } finally { client.Close(); } } }

逻辑说明:AcceptTcpClientAsync负责接受新连接,每个连接交给HandleClientAsync独立跑,互不阻塞。ConcurrentDictionary存房间到客户端流的映射,ConcurrentBag保证多线程下添加和遍历不炸。第一条消息用JOIN:前缀约定房间号,之后所有消息原样广播给同房间其他人。

参数说明:端口 9000 可以改成任意未占用端口;缓冲区 1024 字节对扫雷消息足够,因为一条点击消息也就几十字节;Encoding.UTF8保证中文房间名不乱码。如果你要支持更多客户端,把ConcurrentBag换成带锁的List也行,但并发写入时记得加锁。

2.3 客户端怎么接:TcpClient 加一个独立收消息线程

客户端这边,主线程负责 UI,另开一个 Task 专门收服务端消息。收到消息后不能直接改 UI 控件,得用Invoke切回 UI 线程。

using System.Net.Sockets; using System.Text; using System.Threading.Tasks; using System.Windows.Forms; class GameClient { TcpClient _client; NetworkStream _stream; public async Task ConnectAsync(string ip, int port, string roomId) { _client = new TcpClient(); await _client.ConnectAsync(ip, port); _stream = _client.GetStream(); // 先发加入房间消息 var joinMsg = Encoding.UTF8.GetBytes($"JOIN:{roomId}"); await _stream.WriteAsync(joinMsg, 0, joinMsg.Length); // 开收消息循环 _ = ReceiveLoopAsync(); } async Task ReceiveLoopAsync() { var buffer = new byte[1024]; while (true) { int len = await _stream.ReadAsync(buffer, 0, buffer.Length); if (len == 0) break; string msg = Encoding.UTF8.GetString(buffer, 0, len); // 切回 UI 线程更新棋盘 // 假设 form 是当前窗体,UpdateBoard 是你自己写的刷新方法 // form.Invoke(new Action(() => form.UpdateBoard(msg))); } } public async Task SendAsync(string msg) { var data = Encoding.UTF8.GetBytes(msg); await _stream.WriteAsync(data, 0, data.Length); } }

逻辑说明:ConnectAsync先建立 TCP 连接,然后立刻发JOIN:消息告诉服务端自己进哪个房间,最后启动ReceiveLoopAsync持续收消息。SendAsync供 UI 点击格子时调用。

参数说明:ip填服务端地址,本地测试用127.0.0.1roomId自己约定,比如"room1"。收消息循环里读到的msg需要你自己定义协议格式,比如"CLICK:3,5"表示点击第 3 行第 5 列,"BOOM:3,5"表示踩雷。

3. MVC 分层:把棋盘数据、界面刷新和网络消息拆开

3.1 为什么扫雷联机需要 MVC,而不是全塞进 Form

很多人写 WinForms 扫雷,直接把二维数组定义在 Form 类里,按钮点击事件里改数组、刷按钮、发网络消息全写在一起。单机版没问题,联机版就会乱:你收到对手的点击消息时,需要更新数据模型、刷新界面,但这个消息不是本机按钮触发的,你没法复用按钮事件里的代码。MVC 的价值就在这里——把「棋盘数据」抽成 Model,「界面渲染」抽成 View,「点击处理 + 网络收发」抽成 Controller。这样本机点击和远程消息走的是同一条更新路径:改 Model,然后通知 View 刷新。

3.2 Model 层:棋盘状态和游戏规则

Model 只关心数据:雷区布局、每个格子是否被翻开、是否插旗、游戏是否结束。它不引用任何 UI 控件,也不管网络。

public class Cell { public bool IsMine { get; set; } public bool IsRevealed { get; set; } public bool IsFlagged { get; set; } public int AdjacentMines { get; set; } } public class BoardModel { public int Rows { get; } public int Cols { get; } public Cell[,] Cells { get; } public bool IsGameOver { get; private set; } public BoardModel(int rows, int cols, int mineCount) { Rows = rows; Cols = cols; Cells = new Cell[rows, cols]; // 初始化所有格子 for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) Cells[r, c] = new Cell(); PlaceMines(mineCount); CalculateAdjacent(); } void PlaceMines(int count) { var rand = new Random(); int placed = 0; while (placed < count) { int r = rand.Next(Rows), c = rand.Next(Cols); if (Cells[r, c].IsMine) continue; Cells[r, c].IsMine = true; placed++; } } void CalculateAdjacent() { for (int r = 0; r < Rows; r++) for (int c = 0; c < Cols; c++) { if (Cells[r, c].IsMine) continue; int cnt = 0; for (int dr = -1; dr <= 1; dr++) for (int dc = -1; dc <= 1; dc++) { int nr = r + dr, nc = c + dc; if (nr >= 0 && nr < Rows && nc >= 0 && nc < Cols && Cells[nr, nc].IsMine) cnt++; } Cells[r, c].AdjacentMines = cnt; } } // 返回是否踩雷 public bool Reveal(int r, int c) { if (IsGameOver || Cells[r, c].IsRevealed || Cells[r, c].IsFlagged) return false; Cells[r, c].IsRevealed = true; if (Cells[r, c].IsMine) { IsGameOver = true; return true; } return false; } }

逻辑说明:PlaceMines随机撒雷,CalculateAdjacent算每个格子周围雷数,Reveal翻开格子并返回是否踩雷。Model 不关心谁调用了Reveal,本机点击和远程消息都可以调。

参数说明:rowscols控制棋盘大小,常见 9x9 或 16x16;mineCount控制雷数,9x9 一般 10 颗雷。Random没做种子固定,联机时应该由服务端生成棋盘后把雷区布局发给客户端,避免两边随机结果不一致。

3.3 View 层:用按钮矩阵渲染棋盘

View 的职责只有一件事:根据 Model 当前状态,把每个格子画成该有的样子。WinForms 里可以用TableLayoutPanelButton矩阵。

public class BoardView { Button[,] _buttons; TableLayoutPanel _panel; public BoardView(TableLayoutPanel panel, int rows, int cols) { _panel = panel; _buttons = new Button[rows, cols]; _panel.RowCount = rows; _panel.ColumnCount = cols; for (int r = 0; r < rows; r++) for (int c = 0; c < cols; c++) { var btn = new Button { Dock = DockStyle.Fill, Tag = (r, c) }; _buttons[r, c] = btn; _panel.Controls.Add(btn, c, r); } } public void Refresh(BoardModel model) { for (int r = 0; r < model.Rows; r++) for (int c = 0; c < model.Cols; c++) { var cell = model.Cells[r, c]; var btn = _buttons[r, c]; if (!cell.IsRevealed) { btn.Text = cell.IsFlagged ? "F" : ""; btn.Enabled = true; } else { btn.Enabled = false; btn.Text = cell.IsMine ? "*" : (cell.AdjacentMines == 0 ? "" : cell.AdjacentMines.ToString()); } } } public Button GetButton(int r, int c) => _buttons[r, c]; }

逻辑说明:构造函数创建按钮矩阵并塞进TableLayoutPanelRefresh遍历 Model 把所有按钮的文字和可用状态更新一遍。GetButton供 Controller 绑定点击事件。

参数说明:Dock = DockStyle.Fill让按钮填满单元格;Tag存坐标方便事件里取。刷新时不要重新创建按钮,只改属性,否则性能很差。

3.4 Controller 层:本机点击和远程消息走同一条路

Controller 持有 Model、View 和网络客户端。本机点击时,调model.Reveal,然后view.Refresh,再通过SendAsync把点击消息发给对手。收到远程消息时,解析出坐标,同样调model.Revealview.Refresh

public class GameController { BoardModel _model; BoardView _view; GameClient _client; public GameController(BoardModel model, BoardView view, GameClient client) { _model = model; _view = view; _client = client; // 绑定本机点击 for (int r = 0; r < model.Rows; r++) for (int c = 0; c < model.Cols; c++) { int rr = r, cc = c; _view.GetButton(r, c).Click += async (s, e) => { if (_model.IsGameOver) return; bool boom = _model.Reveal(rr, cc); _view.Refresh(_model); await _client.SendAsync($"CLICK:{rr},{cc}"); if (boom) await _client.SendAsync($"BOOM:{rr},{cc}"); }; } } // 收到远程消息时调用 public void OnRemoteMessage(string msg) { if (msg.StartsWith("CLICK:")) { var parts = msg.Substring(6).Split(','); int r = int.Parse(parts[0]), c = int.Parse(parts[1]); _model.Reveal(r, c); _view.Refresh(_model); } else if (msg.StartsWith("BOOM:")) { _model.IsGameOver = true; // 需要把 setter 改成 public 或加方法 _view.Refresh(_model); } } }

逻辑说明:本机点击和远程消息最终都落到_model.Reveal+_view.Refresh,保证两边棋盘状态一致。BOOM消息用来同步游戏结束。

参数说明:CLICK:r,cBOOM:r,c是自定义协议,你可以加更多字段比如时间戳。注意IsGameOver的 setter 在 Model 里是 private,这里需要改成 public 或加一个EndGame()方法。

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

4.1 现象:两个客户端棋盘不一致,一方看到雷另一方没看到

原因:棋盘由各自客户端随机生成,两边雷区布局不同。解决:棋盘必须由服务端生成,客户端连上来后先收服务端的雷区布局消息,再开始游戏。服务端生成一次BoardModel,把IsMine矩阵序列化后发给房间内所有客户端。

4.2 现象:点击后对手半天没反应,或者消息顺序乱了

原因:TCP 是字节流,没有消息边界。你发两条消息,对方可能一次Read全收到,也可能一条消息分两次收到。解决:定义消息边界,最简单的是每条消息末尾加\n,接收端按\n切分。或者消息头带长度字段,先读 4 字节长度再读内容。

4.3 现象:客户端断开后服务端还在往已关闭的流写数据,抛异常

原因:ConcurrentBag里没有及时移除断开的客户端流。解决:在HandleClientAsyncfinally里把当前流从房间集合中移除。ConcurrentBag不支持直接删除,可以换成ConcurrentDictionary<NetworkStream, byte>,断开时TryRemove

4.4 现象:UI 线程卡死,点击按钮没反应

原因:在 UI 线程里同步调用了stream.ReadConnect。解决:所有网络读写都用async/await,收消息循环跑在独立 Task 里,更新 UI 时用Invoke切回 UI 线程。WinForms 里Invoke是同步的,BeginInvoke是异步的,刷新棋盘用BeginInvoke更流畅。

5. 进阶技巧:用消息序列号做状态校验和断线重连

联机扫雷跑通之后,最值得加的一个机制是消息序列号。每条从客户端发出的点击消息带一个自增序号,服务端广播时也带上。客户端收到消息后检查序号是否连续,如果发现跳号,说明中间丢了消息,可以主动向服务端请求全量棋盘状态。这个机制在真实项目里非常常见,也是你从「能跑」到「可靠」的关键一步。

具体做法:在GameClient里加一个int _seq,每次SendAsync_seq++,消息格式变成CLICK:3,5:12。服务端广播时原样转发。客户端收到后解析出序号,和本地记录的_lastSeq比较,如果seq != _lastSeq + 1,就发一条SYNC:消息给服务端,服务端收到后把当前完整棋盘状态发回来。棋盘状态可以用简单的字符串表示,比如每行用0表示未翻开、1表示已翻开、2表示插旗,雷区单独一行M表示雷。

断线重连的思路类似:客户端重连后先发JOIN:加房间号,服务端发现该房间已有游戏进行中,就把当前棋盘状态和游戏是否结束发给新连进来的客户端。这里要注意,如果原客户端只是网络抖动而不是真的退出,服务端需要有一个超时机制,比如 30 秒没收到任何消息就认为该客户端已离线,从房间移除。

我自己做这个项目时,最大的教训是:不要等到联机逻辑全写完才去测断线。应该在只有「加入房间」功能的时候就拔网线、关客户端、模拟超时,把连接管理的坑先踩完。否则后面棋盘逻辑一多,你根本分不清是网络问题还是游戏逻辑问题。希望帮到你。

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

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

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

立即咨询