
简介这是一份基于C#实现的仿QQ俄罗斯方块双人对战游戏源码适合C#初学者与游戏开发爱好者学习参考。项目完整模拟了经典俄罗斯方块玩法并加入同一台计算机上的双人对战机制涉及C#编程、游戏逻辑、多线程、事件驱动、数据结构与算法、界面设计等多个知识点。资源包共54个文件以18个.cs源代码文件为主辅以9个.wav音频文件、5个.resources资源文件、5个.resx资源描述文件、3个.exe可执行文件及2个.xml配置等压缩包大小约18.91MB结构清晰便于查看和运行。目前已有283人浏览学习。通过阅读源码可以理解方块生成、旋转、消除与计分规则的实现方式学习如何运用多线程和事件处理完成双人实时对战同时借鉴Windows窗体/WPF的UI设计思路掌握从游戏规则到界面交互的完整开发流程是一份具实用价值的C#游戏开发实例。 前段时间整理旧项目时翻出一个用C#写的仿QQ俄罗斯方块双人对战Demo。重新看了一遍代码虽然很多地方写得比较糙但整体思路确实值得拿出来聊聊。这个项目最初的想法很朴素单机俄罗斯方块玩腻了想做一个能和朋友在同一台电脑或者局域网里互相攻击的小游戏。如果你正在找一个既能练Windows窗体开发、又能碰一碰Socket网络编程的C#练手项目这份源码的思路应该能给你不少启发。尤其是“两人对战”这四个字不是简单把两个单机版放在左右两边而是要把“消行攻击对手”的规则真正实现出来。1. 仿QQ对战版到底“仿”的是什么规则1.1 经典俄罗斯方块的那些基础约定先回顾一下经典俄罗斯方块的基本规则。绝大多数人会脱口而出7种方块10列20行的游戏场地方块从顶部下落玩家可以左右移动、旋转、加速下落满一行就消掉方块堆到顶部就结束。这些规则看起来简单真正写成代码时难点主要集中在数据结构和碰撞检测。比如方块下落时怎么判断能不能继续下移左右移动时会不会撞到边界旋转之后会不会卡进已有方块里。这些判断如果不做统一处理游戏跑起来就会有一堆奇奇怪怪的bug。在做这个仿QQ项目的时候我把它拆成了一个核心模型10列20行的二维数组表示场地每个格子用0表示空用非0整数表示不同颜色。落地后的方块写进这个数组移动中的方块单独维护坐标。这样所有的碰撞检测都变成“目标坐标是否越界”和“目标坐标对应的格子里是否已经有值”这两类检查。思路一旦简化后面写消行、写攻击逻辑都会顺很多。1.2 对战模式消行就是攻击仿QQ版和单机版最大的不同在于“消行攻击对手”。场地上消除的每一行不会凭空消失而是会转化为攻击值送给对手。对手收到攻击后场地底部会增加若干行“垃圾行”——也就是带缺口的、会抬高方块落底位置的干扰行。如果垃圾行一直堆到场地顶部对手就输了。不同消行数对应的攻击强度不同版本差异很大。我做的时候参考了常见的设定一次消1行不产生攻击连续消2行攻击1层消3行攻击2层一次消4行也就是常见的Tetris攻击4层。是否发送“连续攻击”要看具体实现我倾向于把每次消行事件都独立发出去简单直接。这个规则是整个对战的灵魂如果在代码里没有单独设计一个规则引擎后续扩展连击、全消奖励会非常痛苦。1.3 先定状态机再写代码写对战游戏最怕一上来就写界面。俄罗斯方块本身有明确的状态等待开始、游戏中、暂停、结束。对战时还要增加连接房间、双方准备、收到攻击、发送攻击。我当时用了一个简单枚举管理游戏状态再把网络消息和游戏主逻辑解耦。这个小设计帮我省了很多事——比如对手掉线时只需要在网络层把状态切成“对局中断”游戏主循环甚至根本不需要知道网络是怎么断的。2. 方块与场地从图形思维到二维数组2.1 10×20场地与颜色标记游戏里看到的彩色方块落地后其实是写进了一个int[,] board new int[20, 10];数组。用整数当颜色索引渲染的时候再对应到画刷颜色。这样做的好处是判断满行只要扫描每一行是否有0判断方块能否移动也只是把预期坐标映射到数组下标不用关心画面上的像素位置。很多C#初学者喜欢用PictureBox或者控件数组来拼格子结果一旦数量多了界面刷新就卡得不行。这个项目里我全部换成自定义绘制只在一个Panel上画80×200的格子性能明显好很多。2.2 方块数据用相对坐标比矩阵更省心七种方块一个常见表示方法是4×4矩阵方块占位为1其余为0。矩阵直观但旋转时要按矩阵转置操作还要处理不同方块中心点不一样的问题。我后来改用相对坐标每个方块就是一个Point数组最多4个点。比如长条“I”可以表示为[(0,0), (1,0), (2,0), (3,0)]这里的坐标是方块内部坐标表示四个格子相对于方块原点的位置。public class Tetromino { public Point[] Cells { get; private set; } // 4个格子的相对坐标 public Point Position { get; set; } // 方块左上角在场地上的位置 public int ColorIndex { get; set; } public Tetromino(Point[] cells, int colorIndex) { Cells cells; ColorIndex colorIndex; } }场地上的实际格子坐标等于Position.X cell.X和Position.Y cell.Y碰撞检测直接拿这组实际坐标去查board数组即可。2.3 旋转、碰撞检测的原理坐标数组在旋转时非常简单。顺时针旋转90度对于坐标(x, y)会变成(-y, x)逆时针则是(y, -x)。但这里有个容易踩坑的点旋转后的相对坐标可能产生负数而方块的落地位置必须始终在场地范围内。所以旋转前要先备份原坐标旋转后如果发现任何格子越界或重叠就回退。碰撞检测我封装成一个方法传入一组格子坐标返回是否可放置。这个方法的逻辑很直白遍历每个格子X小于0或大于等于10、Y大于等于20都算越界如果场地数组在对应位置已经有值也说明碰撞。移动、旋转、落底这些操作全部复用这一个方法代码量小逻辑也集中。提示写俄罗斯方块算法最容易出现的问题是旋转后“穿墙”。尤其是方块贴着左右边界旋转时坐标直接超出边界。我在处理时做了一层简单的“踢墙”也就是如果旋转后的坐标越界就试着左右平移1到2格直到找到合法位置或确认不能旋转。3. 消行与攻击一场比赛的核心引擎3.1 满行消除与计数器一个方块落地后程序要把方块占用的格子写入场地数组然后立刻扫描所有行。满行就消除同时记录本次消除的行数供后续攻击力换算。消除逻辑不是简单删除一行而是把被消除行上面的所有行整体下移一行。这个操作如果用循环从下往上处理注意下标变化就行。我最初最笨的写法是把每行数组复制到下一行结果发现行号越界的概率很高后来改成从row到1倒序遍历用board[i, col] board[i - 1, col]完成下移。private int ClearFullRows() { int cleared 0; for (int row Rows - 1; row 0; row--) { bool full true; for (int col 0; col Cols; col) { if (board[row, col] 0) { full false; break; } } if (full) { cleared; RemoveRow(row); row; // 消掉一行后继续检查这一位置上的新行 } } return cleared; } private void RemoveRow(int row) { for (int i row; i 1; i--) { for (int col 0; col Cols; col) { board[i, col] board[i - 1, col]; } } }写这个RemoveRow之前我一度以为只要把上面所有行下移一行即可实际上还需要把顶部第一行清空。否则多次消行后最上面一行会残留旧数据游戏越玩越怪。3.2 攻击力换算与“垃圾行”生成消除行数本身只是一个整数它要经过规则引擎转换成攻击层数。我维护了一个简单的换算表消除行数攻击层数说明10不攻击只得分21送1层垃圾行32送2层垃圾行44一次四消攻击最狠有了这个规则双人战斗就不是各玩各的了。攻击发生的瞬间本机可以弹一行文字提示“已攻击对手 X 层”同时将攻击消息通过Socket发给对面。收到攻击的一方场地底部会被添加垃圾行。3.3 垃圾行的放置与胜负判定垃圾行不是完全实心的一行那样对手直接没法玩。正确做法是每一行随机保留一个缺口缺口位置尽量分散。生成时把现有场地整体上移一行最底下那一行填入垃圾行数据颜色可以用固定灰色方便玩家一眼看出是干扰块。如果生成垃圾行前场地顶部已经有方块那么新的垃圾行会把顶部方块顶出场地直接判负。private void AddGarbageRows(int count) { Random rnd new Random(); for (int i 0; i count; i) { // 整体上移一行 for (int row 1; row Rows; row) { for (int col 0; col Cols; col) { board[row - 1, col] board[row, col]; } } int hole rnd.Next(Cols); for (int col 0; col Cols; col) { board[Rows - 1, col] (col hole) ? 0 : GARBAGE_COLOR; } } }这里有个细节要特别留意上移的方向。如果从第1行到第19行逐行覆盖会丢掉第0行原本的数据同时底部留出来给垃圾行。方向写反了会导致所有数据从底部溢出看起来就是一种“方块穿透地板”的bug。4. 联机方案选择同一键盘还是跨机器对战4.1 两条路线各自适合什么场景“两人对战”在实现上有两条常见路线。第一种是本地同屏双人两个人共用一个键盘比如A用WASD控制B用方向键控制这种实现简单但没有“对战”的感觉。第二种是局域网Socket联机双方各开一个客户端通过TCP通信同步关键消息。我在这个项目里选择的是TCP因为它能模拟真实网络对战的流程而且C#里TcpListener和TcpClient封装得很好不需要引入第三方库。4.2 用Socket做TCP客户端/服务器简单方案是把其中一方作为“主机”启动一个TcpListener监听指定端口另一方作为客户端连接主机IP。我用的是端口6666实际使用时要确保防火墙放行。测试阶段建议先在本机用127.0.0.1连接确认协议没问题再换局域网IP。TcpListener listener new TcpListener(IPAddress.Any, 6666); listener.Start(); TcpClient remote listener.AcceptTcpClient(); NetworkStream stream remote.GetStream(); // 后续读写都用这个stream客户端连接则更短TcpClient client new TcpClient(); client.Connect(192.168.1.100, 6666); NetworkStream stream client.GetStream();要注意的是TcpClient在读数据时不能假设一次Receive就能拿到完整消息。TCP是流式协议消息可能被拆成多个包也可能几个包粘在一起。所以我在项目里自定义了一个简单消息头第一个字节表示消息类型接下来四个字节表示内容长度再往后是消息内容。接收方先读取5个字节的头部再根据长度读取完整正文。4.3 事件同步与数据协议设计很多初学者做联机游戏会忍不住把整个棋盘数组定期发给对方这种做法在10×20的小棋盘上也能跑但会产生大量无用流量而且网络抖动时双方画面很难保持一致。我更推荐“事件同步”把玩家操作和游戏事件发给对方而不是发完整状态。双方各自运行自己的俄罗斯方块逻辑只通过网络传递关键事件。协议可以设计得很轻消息类型值消息内容CMD_READY0x01准备开始CMD_START0x02开局CMD_ATTACK0x03攻击层数1个字节CMD_GAMEOVER0x04结束CMD_PAUSE0x05暂停收到攻击消息时本机才调用AddGarbageRows(count)其余时间双方都不需要知道对手场地上的方块长什么样。这种做法把逻辑复杂度控制得很好。我印象很深的是一开始我想同步双方状态结果调试时要么延迟要么丢包后来改成事件同步后整个代码瞬间清爽了。5. C#界面刷新的坑WinForm里跑游戏循环为什么卡5.1 双缓冲与自定义绘画WinForm开发中直接在窗体上绘制游戏画面最常见的坑就是闪烁。原因很简单每次重绘都先擦除背景再画格子一擦一画之间屏幕就会闪。解决办法是开启双缓冲。最简单的做法是在目标控件上设置DoubleBuffered true或者在自定义控件构造函数里写SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);这样画面绘制会先在内存缓冲完成再一次性拷贝到屏幕闪烁问题基本能解决。5.2 Timer选型UI线程 vs 后台线程WinForm做游戏循环很多人会直接放一个System.Windows.Forms.TimerTick事件里刷新界面。这个方案简单但问题在于Timer的触发依赖UI消息循环当界面被拖动、系统菜单弹出时定时器可能临时卡住游戏就明显掉帧。另一个选择是System.Threading.Timer或System.Diagnostics.Stopwatch 独立线程。独立线程里做逻辑更新再用BeginInvoke通知UI线程重绘。这样可以避免逻辑和绘制互相卡顿。我的做法是主窗口上挂一个System.Windows.Forms.Timer设置间隔为15ms在Tick里完成下落判断和Invalidate()重绘。因为这个项目逻辑简单单线程够用。但如果以后加入复杂特效、音效或者录屏建议拆成独立线程否则UI卡顿会让你怀疑人生。5.3 按键响应与焦点处理还有一个很容易被忽略的问题是键盘焦点。如果用WinForm窗体必须要获得焦点才能收到KeyDown事件。很多玩家点击了按钮之后焦点跑到按钮上游戏里按方向键就没有反应了。我在主窗口上设置KeyPreview true这样键盘事件会先经过窗体再传给控件就不会出现点了个按钮后方向键失灵的情况。同时要注意按键的按下和抬起状态。俄罗斯方块需要按住方向键持续移动但Windows的键盘重复延迟会让移动速度很奇怪。比较好的方法是维护一个HashSetKeys记录按下的键在游戏循环里轮询而不是依赖KeyDown事件被动触发。这样处理连续移动更平滑也方便同时按多个键。注意写C#游戏循环时不要在处理绘制的方法里调用Thread.Sleep去控制速度。Sleep会阻塞UI线程画面会变成一卡一卡的看起来像程序死了。下落速度应该用计时器动态判断而不是靠Sleep硬撑。6. 源码结构、运行步骤与进一步扩展6.1 项目文件划分这个项目的源码结构我大概是这么分的每个类职责单一方便修改和复用Program.cs程序入口启动主窗体。GameBoard.cs场地数据、消行、垃圾行生成、胜负检测。Tetromino.cs方块数据结构和旋转算法。TetrisEngine.cs游戏主逻辑负责方块下落、碰撞检测、计分。NetworkManager.csSocket连接、消息收发和协议解析。MainForm.cs界面包含绘制逻辑和键盘输入。这样拆分的好处在于如果把WinForm界面换成WPF甚至Unity核心的GameBoard和TetrisEngine都可以直接复用。6.2 从源码到可运行程序的步骤如果你拿到源码想自己跑起来建议按下面步骤操作用Visual Studio 2022或更新版本创建一个新的Windows Forms应用.NET Framework 4.8或.NET 6/8都行。把上述类文件分别加入项目替换默认生成的Form1。构建项目先不启用联机功能把NetworkManager作为可选模块测试单机运行。单机确定没问题后在你本机运行两个进程一个监听6666端口一个连接127.0.0.1验证对战流程。换到局域网环境防火墙放行TCP 6666端口再用本机IP连接。运行环境里比较常见的坑是端口被占用、防火墙拦截、以及.NET版本不匹配。我建议统一目标框架为.NET Framework 4.8兼容性最省心。6.3 遇到的常见坑及解决方案下面这些坑都是我自己实际调试时踩过的每个都能让程序表现得很诡异。问题现象根本原因解决办法方块旋转后穿墙旋转后坐标在边界外未处理旋转前备份旋转后做踢墙检测消多行时只消掉一部分行下移方向搞反从下往上扫描消除后行号回退对战不同步直接同步棋盘数据改成事件同步只发攻击和关键状态画面闪烁没有双缓冲设置OptimizedDoubleBuffer点击按钮后方向键失效焦点在按钮上设置KeyPreview true本机TCP连接成功局域网不行防火墙拦截端口添加防火墙入站规则6.4 后续还能怎么玩这个项目不只是写来怀旧。它已经把俄罗斯方块的核心玩法和局域网对战跑通了后续想扩展方向很多。你可以加一个简单的人机AI让玩家在联机之外还能打电脑也可以把消行产生的攻击得分和等级挂钩做成类似QQ游戏的排位积分系统甚至可以把网络层换成WebSocket或SignalR让两个不同局域网的玩家也能联机。我个人觉得最有意思的扩展是给每一步操作录像回放便于复盘对战过程这对学习游戏状态同步很有帮助。回到这个项目本身如果用一句话说心得写双人俄罗斯方块重要的不是把方块画得多好看而是把场地模型、攻击规则和网络协议这三层关系理清楚。我当初最得意的一点就是把攻击消息和垃圾行生成逻辑隔离清楚后面不管怎么改UI和网络传输游戏核心都能很稳定地运行。如果你正在练C#项目建议你也从小游戏开始把这三层拆明白比单纯跟着视频敲代码管用得多。本文还有配套的精品资源点击获取