C#实战联机扫雷:TCP长连接与MVC架构详解
简介这是一份基于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# 里最直接的就是TcpListenerTcpClient服务端为每个连进来的客户端开一个独立线程或 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 ConcurrentDictionarystring, ConcurrentBagNetworkStream _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 ConcurrentBagNetworkStream()).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本机点击和远程消息都可以调。参数说明rows、cols控制棋盘大小常见 9x9 或 16x16mineCount控制雷数9x9 一般 10 颗雷。Random没做种子固定联机时应该由服务端生成棋盘后把雷区布局发给客户端避免两边随机结果不一致。3.3 View 层用按钮矩阵渲染棋盘View 的职责只有一件事根据 Model 当前状态把每个格子画成该有的样子。WinForms 里可以用TableLayoutPanel加Button矩阵。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.Reveal和view.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,c和BOOM:r,c是自定义协议你可以加更多字段比如时间戳。注意IsGameOver的 setter 在 Model 里是 private这里需要改成 public 或加一个EndGame()方法。4. 避坑与排查联机扫雷最容易翻车的 4 个地方4.1 现象两个客户端棋盘不一致一方看到雷另一方没看到原因棋盘由各自客户端随机生成两边雷区布局不同。解决棋盘必须由服务端生成客户端连上来后先收服务端的雷区布局消息再开始游戏。服务端生成一次BoardModel把IsMine矩阵序列化后发给房间内所有客户端。4.2 现象点击后对手半天没反应或者消息顺序乱了原因TCP 是字节流没有消息边界。你发两条消息对方可能一次Read全收到也可能一条消息分两次收到。解决定义消息边界最简单的是每条消息末尾加\n接收端按\n切分。或者消息头带长度字段先读 4 字节长度再读内容。4.3 现象客户端断开后服务端还在往已关闭的流写数据抛异常原因ConcurrentBag里没有及时移除断开的客户端流。解决在HandleClientAsync的finally里把当前流从房间集合中移除。ConcurrentBag不支持直接删除可以换成ConcurrentDictionaryNetworkStream, byte断开时TryRemove。4.4 现象UI 线程卡死点击按钮没反应原因在 UI 线程里同步调用了stream.Read或Connect。解决所有网络读写都用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 秒没收到任何消息就认为该客户端已离线从房间移除。我自己做这个项目时最大的教训是不要等到联机逻辑全写完才去测断线。应该在只有「加入房间」功能的时候就拔网线、关客户端、模拟超时把连接管理的坑先踩完。否则后面棋盘逻辑一多你根本分不清是网络问题还是游戏逻辑问题。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →