
简介基于C#实现的仿QQ风格俄罗斯方块双人对战源码适合C#初学者与游戏开发爱好者用来理解桌面游戏从界面搭建到逻辑落地的完整流程。压缩包共54个文件、约18.91MB其中18个.cs文件承载核心逻辑与窗体界面9个.wav提供游戏音效5个.resx与5个.resources管理界面资源映射另有可直接运行的exe及项目工程文件目录按窗体、配置、音效等模块划分清晰。已有283人浏览与学习。项目完整实现了方块生成、移动、旋转、消行和计分等经典算法利用矩阵存储游戏板状态并通过C#多线程与事件驱动机制实现同屏实时对战BlockSet.xml等配置文件以及FrmTetris、FrmConfig等多个窗体模块便于逐段研读和二次开发。对想掌握游戏循环、UI交互和多人同步机制的开发者是一份可直接运行、边读边练的实践参考。1. 仿QQ俄罗斯方块两人对战先拆清楚它到底要做什么、难点在哪“仿QQ俄罗斯方块两人对战(C#源码)”这个标题拆开就是三件事一个能玩的俄罗斯方块内核一张支持联网对战的消息通道一层 WinForm 界面把两者包起来。很多 C# 入门的朋友照着网上的单机版打一遍后都会问一句怎么变成两个人打我的答案是别急着写网络先把“消行产生攻击、攻击灌给对方”这个玩法机制定下来。它不复杂但坑集中在同步时机、粘包拆包和线程收尾上。这篇按我实际做过的方案讲适合想用 C# 练手小游戏、或常年写上位机想换个场景验证 TCP 收数逻辑的人。对照标题里“仿QQ”的经典对战玩法下面给的是可落地实现顺序和参数照着敲就能跑起来。2. 俄罗斯方块核心逻辑方块旋转、碰撞检测与消行判定的C#实现对战建立在单机逻辑之上单机逻辑又是方块、网格、消行三个模块的组合。这一章先把它们拆干净。2.1 方块抽象4×4矩阵怎么存下七种方块常见做法是用一个 4×4 的布尔矩阵表示一个方块true 表示有格子false 表示空。七种方块里I 和 O 比较特殊O 旋转后形状不变I 横竖长度是 4 格直接绕中心旋转会出现半格偏移所以很多实现会把 I 单独处理。先看基础定义public enum TetrominoKind { I, O, T, S, Z, J, L } public class Tetromino { public TetrominoKind Kind { get; } private bool[,] _shape; public Tetromino(TetrominoKind kind, bool[,] shape) { Kind kind; _shape shape; } // 顺时针旋转一次先转置再水平翻转 public Tetromino RotateRight() { int n _shape.GetLength(0); bool[,] rotated new bool[n, n]; for (int row 0; row n; row) for (int col 0; col n; col) rotated[col, n - 1 - row] _shape[row, col]; return new Tetromino(Kind, rotated); } }这段代码把旋转写成了“先转置再水平翻转”的数学操作对 T、S、Z、J、L 都成立。参数上有个容易忽略的点bool[,] 的 GetLength(0) 是行数这里强制按方阵处理非方阵会越界所以所有形状定义必须统一成 4×4。I 块直接用这段代码会在落定后出现位置偏移我的处理方式是给 I 单独维护一个偏移修正量或者用“预置四个旋转朝向的矩阵表”替代代数旋转后面避坑章会细说。初始化形状时I 横条放在第二行O 放在左上 2×2 区域static bool[,] I_Shape new bool[4, 4] { { false, false, false, false }, { true, true, true, true }, { false, false, false, false }, { false, false, false, false } }; static bool[,] O_Shape new bool[4, 4] { { true, true, false, false }, { true, true, false, false }, { false, false, false, false }, { false, false, false, false } };T、S、Z、J、L 照同样格式写全是 4×4true 的位置按各自形状放在上半区。这种存法的好处是碰撞检测和旋转都能统一走矩阵运算不需要写一堆 switch 分支。O 的旋转直接返回自身I 的偏移问题单独写一个 Kick 方法去修。2.2 碰撞检测与落定判断“能不能放”的顺序不能乱网格用一个固定大小的二维数组存宽 10 高 20这是俄罗斯方块最经典的比例。碰撞检测要做的是把活动方块矩阵里的每个 true 格子映射到网格坐标上检查是否越界或已经和落定的方块重叠public class Board { private const int W 10; private const int H 20; private bool[,] _field new bool[H, W]; // pos 是活动方块当前在网格上的位置shape 是 4x4 方块矩阵 public bool CanPlace((int row, int col) pos, bool[,] shape) { for (int r 0; r shape.GetLength(0); r) for (int c 0; c shape.GetLength(1); c) { if (!shape[r, c]) continue; int gr pos.row r; int gc pos.col c; if (gc 0 || gc W || gr H) return false; if (gr 0 _field[gr, gc]) return false; } return true; } }逻辑说明这里只检查下边界和左右边界不检查 gr 0因为新方块从顶部生成负行视为合法这也给方块出生区的半截入墙留了余地。参数上C# 的元组 (int row, int col) 在代码里读起来直观但如果你要保持 .NET Framework 4.x 兼容可以改成两个 int 参数。落定Lock就是把 shape 真正写进 _field写之前再调一次 CanPlace 防止方块已经被上下左右挤到非法位置。顺序必须是移动/旋转 → CanPlace → 真改坐标而不是先改坐标再回滚。回滚方案也能用但边角情况很容易漏翻车概率高。2.3 消行判定与计分一次消四行的战斗价值消行的核心是从下往上扫遇到满行就把它之上的所有行整体下移一行顶部补空行。注意这里有陷阱同一帧可能消多行下移后当前行要重新检查一次否则连续消行会漏public int ClearFullRows() { int lines 0; for (int row H - 1; row 0; row--) { bool full true; for (int col 0; col W; col) if (!_field[row, col]) { full false; break; } if (full) { lines; for (int r row; r 0; r--) for (int c 0; c W; c) _field[r, c] _field[r - 1, c]; for (int c 0; c W; c) _field[0, c] false; row; // 上面的行下移了必须重新检查当前位置 } } return lines; }计分可以用一张简单表格比赛里常用的是单消 10、双消 30、三消 50、四消 100。对战规则里四消一般送出 4 行垃圾行所以四消也被叫做“一发全屏”。2.4 从消行到攻击先理清对战送行规则对战样式参考标题里的经典玩法消行越多给对方送的“垃圾行”越多。垃圾行会从对方屏幕底部往上顶直接压缩生存空间。常见送行规则是一次消行数得分送出垃圾行数11012302350341004把送行数从这个方法里返回出来交给网络层打包发送。每送出的垃圾行只在对方屏幕底部生成一行哪一列留洞由对方客户端随机决定这个规则后面单独讲。到这里单机逻辑闭环了接下来才是标题里“两人对战”的主菜。3. 两人对战机制攻击消息协议与Socket同步方案设计很多人一听到“对战”就想全量同步双方棋盘那是做帧同步的思路对 C# 入门项目来说太重。这个游戏只需要同步一件事你消了几行要送几行给对方。网络层只传攻击事件画什么、怎么落每个客户端各自算各自的。3.1 对战到底同步什么不传整个棋盘只传“攻击事件”如果每秒把双方 20×10 的棋盘全量传一遍延迟和带宽都不划算还会带来“谁覆盖谁”的语义问题。更稳的方案是把游戏引擎做成确定性的同一个输入序列在任何一台机器上运行得到的结果完全一致。这样网络只负责传“你消了 2 行”这个事件对方在自己的引擎里插入 2 行垃圾行双方的结果自然一致。这个设计决策让后续所有代码都变简单了。3.2 房主服务器与TcpClient局域网对战的连接框架对战两端的连接采用“一方做服务器一方做客户端”的方式我最常用的是 TcpListener 监听端口另一台机器用 TcpClient 连接。服务器端代码TcpListener _listener new TcpListener(IPAddress.Any, 4567); _listener.Start(); _listener.BeginAcceptTcpClient(AcceptCallback, null); private void AcceptCallback(IAsyncResult ar) { TcpClient client _listener.EndAcceptTcpClient(ar); _clients.Add(client); // 房主记录对战方连接 // 继续接收下一个连接否则第二台设备连不上 _listener.BeginAcceptTcpClient(AcceptCallback, null); }参数说明端口 4567 是示例实际部署要避免跟本机已占用端口冲突监听 IPAddress.Any 表示允许局域网内任何网卡上的连接进来。做过 C# 上位机的人对这个流程会很熟它和上位机收串口数据一个套路只是传输层从 COM 口换成了 TCP。客户端一侧只需要 new TcpClient() 后调用 Connect(ip, 4567)不需要任何监听代码。3.3 二进制协议封包与BeginReceive回调消息格式我习惯做成“1 字节消息类型 4 字节负载长度 UTF8 的 JSON 体”。HTTP 那套头在这里太重自定义二进制就够。封包代码enum MsgType : byte { Attack 3, // 送行攻击负载是垃圾行数 Heartbeat 5, // 心跳 GameOver 6 } static byte[] Pack(MsgType type, string json) { byte[] payload Encoding.UTF8.GetBytes(json); byte[] head new byte[5]; head[0] (byte)type; Array.Copy(BitConverter.GetBytes(payload.Length), 0, head, 1, 4); // 把 head 和 payload 拼成一个数组返回 byte[] result new byte[head.Length payload.Length]; Buffer.BlockCopy(head, 0, result, 0, head.Length); Buffer.BlockCopy(payload, 0, result, head.Length, payload.Length); return result; }消息体走 JSON 是为了调试方便字段一眼能看懂如果后续要塞节点同步之类的二进制数据再改成紧凑结构。这里要注意 BitConverter 在小端机器上输出小端字节序而 C# 的 BitConverter.ToInt32 也读小端自产自销没问题跨语言对接时就要统一字节序。接收端用 BeginReceive 做异步收包这是标题相关热搜里常见的“socket 接收回调”模式private void ReceiveCallback(IAsyncResult ar) { int bytesRead _stream.EndRead(ar); if (bytesRead 0) { HandleDisconnect(); return; } byte[] chunk new byte[bytesRead]; // 把读取的字节追加到_byteBuffer再按协议拆包 _byteBuffer.AddRange(chunk); HandleBuffer(); _stream.BeginReceive(_recvBuffer, 0, _recvBuffer.Length, SocketFlags.None, ReceiveCallback, null); }逻辑说明BeginReceive 每次只读一块TCP 是字节流一次读到的数据可能只有半条消息也可能包含好几条所以必须有一个缓冲区累积数据再按“头 5 字节 长度”去拆包。这就是粘包拆包问题通常新手第一次跑对战都会在这里翻车详见避坑章节。3.4 收到Attack后插入垃圾行洞随机留、重叠判负对方发来 Attack 后本地要往棋盘底部插入指定行数的垃圾行。标准做法是整行填满只在随机一列留一个空洞让玩家还有机会把这些行消掉public void InsertGarbageRows(int count) { if (count 0) return; Random rand new Random(); for (int i 0; i count; i) { // 所有行上移一格顶部补空模拟从底部塞了一行 for (int r H - 1; r 0; r--) for (int c 0; c W; c) _field[r, c] _field[r - 1, c]; for (int c 0; c W; c) _field[0, c] true; int hole rand.Next(W); _field[0, hole] false; // 插入垃圾行后当前活动方块可能和垃圾行重叠 if (!CanPlace(_curPos, _curShape)) { RaiseGameOver(); return; } } }注意几个点Random 不是线程安全的如果 InsertGarbageRows 由网络收包线程调用而游戏主循环也碰棋盘就得加锁我一般用 lock(_field) 把棋盘操作全包进去。洞的位置随机是单机体验简单但高手局里垃圾行洞口位置会成为战术进阶玩法可以约定特定列位置但那需要把序列号也放进协议第 6 章会提。提示垃圾行插入后立刻检查当前方块能否放置如果已经被顶住直接判负。这个“顶死”逻辑不写的话会出现当前方块悬浮在垃圾行里的视觉 Bug。4. WinForm界面与操作GDI绘制、窗体最大化与状态栏更新逻辑层跑通后界面是让这个项目“看起来像游戏”的关键。WinForm 里做俄罗斯方块有两种路线用 PictureBox 刷新或者重写 OnPaint 自绘。我选后者因为网格每次画几百个格子自绘可以完全控制帧率和闪烁。4.1 自绘棋盘与离屏缓冲把格子放大也不闪屏界面闪烁的根源是清屏、绘制两步暴露在了用户视野里。标准解法是离屏缓冲先在一张 Bitmap 上把整帧画完再一次性 Copy 到屏幕。加上窗体最大化后重算格子尺寸就能做到任意拖拽不模糊public class GameForm : Form { private Bitmap _canvas; private int _cellSize 24; // 每个格子像素数 protected override void OnResize(EventArgs e) { base.OnResize(e); // 窗体最大化时按可用高度重算格子大小保证棋盘铺满 _cellSize Math.Max(12, ClientSize.Height / 22); _canvas?.Dispose(); _canvas new Bitmap(ClientSize.Width, ClientSize.Height); Invalidate(); } protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); using (Graphics g Graphics.FromImage(_canvas)) { DrawBoard(g); // 网格背景 落定方块 DrawActiveBlock(g); // 当前活动方块 DrawGarbagePreview(g); // 对方攻击提示可选项 } e.Graphics.DrawImage(_canvas, 0, 0); } }参数说明_cellSize 下限设 12避免窗口缩得太小后格子挤成一团22 是“20 行网格 顶部预留区 边距”的经验值。OnResize 里重建 Bitmap 是因为画布尺寸变了直接沿用旧图会拉伸变形。这套写法对窗体最大化、拖拽改变大小都适用也是标题里“窗体最大化”热搜的实际落点。4.2 下落节奏与键盘控制把输入看成事件流下落逻辑不要写在 OnPaint 里否则刷新频率一变游戏速度就变。我一般用一个 System.Windows.Forms.Timer 驱动“逻辑帧”默认每 500ms 下落一格等级每升一级减少 40ms最低 100ms。键盘处理只要管方向键、空格硬降、上键旋转protected override void OnKeyDown(KeyEventArgs e) { switch (e.KeyCode) { case Keys.Left: TryMove(-1, 0); break; case Keys.Right: TryMove(1, 0); break; case Keys.Down: TryMove(0, 1); break; // 软降 case Keys.Up: TryRotate(); break; case Keys.Space: HardDrop(); break; // 硬降直接落底锁定 } }硬降实现里有个细节不能一次一次地 TryMove 模拟而是直接算落底位置把方块一次性移到那个位置再 Lock然后立刻检查消行。这样手感干脆也避免 Timer 还没 tick 时方块悬空半秒。4.3 状态栏进度条与颜色选择Invoke跨线程更新的标准写法网络收包在后台线程UI 在主线程直接改状态栏控件会抛“跨线程操作无效”。标准写法是判 InvokeRequired 后转回 UI 线程private void UpdateStatus(string text, int progressValue) { if (statusStrip1.InvokeRequired) { statusStrip1.BeginInvoke(new Action(() { UpdateStatus(text, progressValue); })); return; } statusLabel.Text text; attackProgressBar.Value Math.Max(0, Math.Min(100, progressValue)); }进度条可以用在“对方攻击能量”上每收到一行垃圾行涨一格满了就触发一次攻击。BeginInvoke 和 Invoke 的区别是前者不阻塞后台线程收包循环不会被 UI 卡住我这里用 BeginInvoke。颜色主题切换可以直接用 WinForm 自带的 ColorDialog把选到的颜色存成字段然后 Invalidate 重画这是“颜色选择框用法”最省事的路径。5. 避坑排查对战不同步、粘包错乱与线程死锁的5个实战问题这一章是从能跑到能玩之间最容易劝退人的路段每一条都是我自己或同行踩过的。5.1 两个客户端各玩各的状态不同步现象两台机器分别运行A 消了行B 那边毫无反应再过一会儿两边方块堆得完全不一样。原因只做了本地消行没把 Attack 消息发出去或者发了但接收方没解析。解决先在最简单的地方打日志在消行函数 return 前把消行数和送行数写到控制台再在接收回调里打收到的原始消息。日志对不上的那一端就是断点所在。对战消息的时机必须是“消行判定成功 → 立刻发包”不要等下一帧 Timer否则延迟会让它看起来像偶发 Bug。5.2 对方一消行你就崩垃圾行插入的顺序问题现象收到 Attack 后直接往棋盘底部加行结果当前活动方块悬空在垃圾行上面或者直接穿过垃圾行继续下落。原因插入垃圾行时没有重新校验活动方块位置也没有考虑“插入后应该先 Lock 当前块再生成新块”。解决插行的顺序必须是“整体上移 → 填充垃圾行 → 校验当前方块”。如果校验失败先 Lock 当前方块生成下一块再校验一次仍然重叠才判负。这样才是真实的对战“顶死”体验而不是直接异常退出。5.3 粘包拆包错乱一次收到半条或者两条消息现象网络传输量小的时候一切正常一局打久了突然收到一个垃圾行数 132 的异常值之后节奏全乱。原因TCP 是字节流一次 BeginReceive 可能拿到半条消息也可能把两条消息拼在一起你还按“一次接收等于一条消息”去解析。解决写一个缓冲区累积逻辑每次取完 5 字节头部才知道负载多长长度不够就等待下个包到达private void HandleBuffer() { while (true) { if (_buffer.Count 5) return; // 头都没凑齐 byte type _buffer[0]; int len BitConverter.ToInt32(_buffer.ToArray(), 1); if (_buffer.Count 5 len) return; // 负载没凑齐 string json Encoding.UTF8.GetString(_buffer.ToArray(), 5, len); _buffer.RemoveRange(0, 5 len); DispatchMessage((MsgType)type, json); } }这段是拆包的骨架细节上 _buffer 用 List 会频繁复制局域网对战时压力不大但如果你想练手高并发改成 byte[] 加 offset 的环形缓冲更合适。5.4 关掉窗口进程还在跑线程与Timer的收尾现象点 X 关窗体程序界面没了但任务管理器里进程还在或者直接报“正在终止线程”的异常。原因BeginReceive 的后台线程阻塞在 EndRead窗体关闭时没有通知它退出。解决窗体的 FormClosing 事件里先把 _tcpClient.Close() 和 _listener.Stop() 调掉让阻塞的 EndRead 抛出异常退出再用 Thread.Join 等它收尾最后停掉 Timer。顺序反过来的话界面关了线程还挂在 socket 上这是 C# 聊天程序最常见的收尾坑。5.5 界面闪烁与“液态毛玻璃”陷阱渲染优化别走弯路现象方块移动时整个棋盘在闪拖拽窗口时更严重。原因直接在 OnPaint 里用 Control.CreateGraphics 画了几百个格子每个格子都是独立 GDI 绘制WinForm 根本来不及合成。解决第一步开 DoubleBuffered第二步用第 4 章的离屏 Bitmap 方案。我看到有人为了好看直接上“液态毛玻璃”这类半透明特效结果在低配机器上帧率直接掉一半。血泪经验是先把纯色格子跑流畅特效永远排在玩法后面。6. 从能玩到能用录像回放、连击扩展与一致性验证游戏跑通以后下一步不是加特效而是给引擎补上验证工具。我把引擎设计成“只接受输入序列不关心输入来自键盘还是文件”这给后续所有能力开了口子。录像回放其实不用录画面只需记录每一帧玩家按下的键和帧号。回放时把按键序列喂给 GameEngine它能逐帧重建整个局面。这个功能调试价值极高也是“后悔药”对方说某局卡了你让他把键位序列发过来本地一秒重放立刻知道是不是引擎判定问题。连击扩展也只需要加新消息类型。比如定义 ComboAttack 7负载里带连击数接收方按“基础送行数 连击加成”插入垃圾行。协议设计时预留了 MsgType 枚举的冗余空间加消息不用改封包逻辑。最后一致性验证可以写成脚本用固定随机种子初始化两个 GameEngine喂同一段输入逐帧比对棋盘哈希有任何不一致直接抛异常。我习惯每轮改动后立刻跑一遍这个验证再上真人对战。这样能保证“对战不同步”这类问题在进界面之前就被挡在门外。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。