C#仿QQ俄罗斯方块双人对战:WinForms实现与源码解析

📅 发布时间:2026/10/11 17:06:20
C#仿QQ俄罗斯方块双人对战:WinForms实现与源码解析
简介一款基于C#的仿QQ俄罗斯方块双人同屏对战源码包面向C#学习者和游戏开发初学者完整展示了单机双人对战场景下从游戏逻辑到界面交互的编码过程。项目覆盖方块生成、移动旋转、行消除与计分判定并通过多线程与事件驱动机制处理两名玩家的键盘输入和状态同步确保同屏对战互不干扰界面采用WinForms相关组件搭建配合音频素材强化操作反馈。算法层面用矩阵/数组保存棋盘用栈管理待生成方块序列可对照学习数据结构在游戏中的实际应用。压缩包共54个文件包括18个.cs源码、9个.wav音频以及界面资源、配置与可执行程序等整体大小18.91MB目录结构按模块组织、便于检索。目前已有283人学习下载适合作为课程设计、毕业设计或入门练手项目读者通过完整源码还能深入理解定时器控制、键盘监听、碰撞检测等游戏开发细节并将其迁移到自己的对战类项目中。1. 仿QQ俄罗斯方块两人对战这个C#小项目在解决什么写给谁俄罗斯方块和C#组合在一起很多人第一反应是「太简单了控制台都能写」。但真正把「两人对战」四个字加进去事情立刻变味你得同时维护两个游戏场地、两套键位还得设计攻击行、胜负判定和互坑时机。我见过不少照着教程做单机版的朋友卡在对战互坑这一步最后要么砍掉对战改成单人要么硬凑一个「一人一回合」的假双人。这个标题里的「仿QQ」指的是QQ游戏大厅那种经典对战体验左右两个场地消行会给对方添乱谁先堆到顶谁输。它是一个很适合C#入门到进阶的练手项目——能练到类设计、状态流转、键盘事件、GDI绘制做出来还能发给朋友开黑。适合想找一个「做完真有成就感」的C#项目的人也适合拿它当毕业设计或作品集里的可演示项。接下来我就按自己做过这类项目的习惯把拆解、源码结构和踩过的坑一次讲清楚。2. 开局先定对战路线同屏双人比局域网更适合C#源码落地2.1 同屏双人还是局域网先看三组真实取舍写代码之前最该决定的是对战形态。同样是「两人对战」同屏双人和局域网对战的实现成本差出一个数量级。结合这个标题挂的是「C#源码」我一般会直接建议选同屏双人一个WinForms项目一套键盘两套键位逻辑集中在GameField和BattleRule两个类里调试起来也不用来回切窗口。对比维度同屏双人单机双键盘局域网对战TCP/UDP实现成本一个WinForms项目两三天出可玩版要写网络层和状态同步工程量翻倍同步复杂度无双方共享一套Timer要处理延迟、断线、两块场地的状态一致性玩法体验适合一台电脑开黑QQ游戏大厅当年也有这种房间支持远程对战但源码项目要额外部署服务端代码可读性逻辑集中在两个类里好调试消息协议会占掉一半代码量这个选择也和「桌面软件开发用C#还是QT」这类问题相关C#做这种小游戏的优势就是WinForms拖控件、GDI画格子全是现成API不需要碰网络编程就能把互坑玩法做出来。局域网版本是后面的事等你把单机对战逻辑跑稳了再碰也不迟。2.2 工程骨架一个WinForms解决方案放几个文件项目结构我按「逻辑与界面分离」来拆这个习惯能省掉后面大半麻烦。很多人写小游戏喜欢把代码全塞进窗体事件里前期痛快后期加功能时只能重写。这里给出最常用的文件划分文件职责Program.cs入口启动MainFormMainForm.cs主窗体键盘状态、Timer、绘制Tetromino.cs七种方块的矩阵定义与旋转GameField.cs单个玩家场地网格、碰撞、消行、攻击行BattleRule.cs对战规则消行触发攻击、胜负判定俄罗斯方块场地的标准尺寸是10列×20行这个参数直接写进GameField的常量里。主窗体布局仿QQ游戏大厅左边是玩家1场地右边是玩家2场地中间留一条信息栏显示等级和分数。这个结构的好处是以后换WPF、换控制台输出GameField和Tetromino都能原样复用UI层只负责把矩阵画出来。2.3 游戏循环用Timer而不是while(true)常见误区是用while(true)加Thread.Sleep做游戏循环然后在循环里直接操作TextBox等控件——这是血泪教训。跨线程改控件会抛异常就算用Invoke硬绕过去刷新频率和UI响应也很难控制。WinForms自带的Timer在这个场景里更合适Tick事件天然跑在UI线程不需要处理跨线程问题Interval就是方块的下落间隔改一个属性就能调速。// MainForm.cs 关键片段 public partial class MainForm : Form { private readonly GameField[] _fields { new GameField(), new GameField() }; private readonly HashSetKeys _pressedKeys new HashSetKeys(); private System.Windows.Forms.Timer _fallTimer; public MainForm() { DoubleBuffered true; // 关键参数开双缓冲否则重绘会闪 _fallTimer new System.Windows.Forms.Timer(); _fallTimer.Interval 800; // 初始 800ms 落一行 _fallTimer.Tick OnFallTick; _fallTimer.Start(); } private void OnFallTick(object sender, EventArgs e) { for (int i 0; i _fields.Length; i) { if (_fields[i].Current null) continue; if (_fields[i].CanMove(0, 1)) { _fields[i].Current.Y; } else { _fields[i].Merge(); // 方块固定进场地 int cleared _fields[i].ClearLines(); int attack BattleRule.AttackLines(cleared); if (attack 0) { // 给对方加攻击行若对方被顶死则本局结束 _fields[1 - i].AddAttackLines(attack); } } } UpdateStatusBar(); // 状态栏和进度条在这里统一刷新 Refresh(); // 触发 OnPaint 重绘 } }代码说明Timer的Interval控制下落速度我习惯800ms起步每升一级减50ms下限150ms不然到后面手速完全跟不上。DoubleBufferedtrue在窗体构造函数里设置比手动创建BufferedGraphics省事得多。UpdateStatusBar就是很多人常问的「C# WinForm如何更新状态栏与进度条」的标准答案——在Tick里统一刷新不要在每次按键时单独更新否则界面会频繁重绘状态值也容易来回跳。提示如果还想在两个场地中间放「下一个方块预览」直接在OnPaint中间区域再画一次Next方块即可不要在Tick里反复创建Graphics对象。绘制部分用GDI的FillRectangle就能完成。DrawField函数负责画一个完整场地先画背景网格再遍历GameField.Grid对非0格子填色最后画当前活动方块。private void DrawField(Graphics g, GameField field, int ox, int oy) { const int cell 30; // 每个格子 30x30 像素 for (int y 0; y GameField.Rows; y) for (int x 0; x GameField.Cols; x) { if (field.Grid[y, x] ! 0) { g.FillRectangle(Brushes.CornflowerBlue, ox x * cell, oy y * cell, cell, cell); g.DrawRectangle(Pens.White, ox x * cell, oy y * cell, cell, cell); } } // 画当前活动方块 if (field.Current ! null) for (int y 0; y field.Current.Size; y) for (int x 0; x field.Current.Size; x) if (field.Current.Shape[y, x] 1) g.FillRectangle(Brushes.Gold, ox (field.Current.X x) * cell, oy (field.Current.Y y) * cell, cell, cell); }格子尺寸30是经验值窗口分辨率越高越舒服4K屏可以放大到40。颜色区分只是示意做成仿QQ那种每类方块不同颜色就在Tetromino里加一个Color属性绘制时按类型取色。到这里一个能显示场地、能自动下落的最小框架就成型了接下来才是真正核心的逻辑部分。3. 七种方块的核心逻辑数据结构、旋转碰撞与消行代码3.1 用4×4矩阵表示方块为什么不用更省内存的坐标点俄罗斯方块的基础是七种方块I、O、T、S、Z、J、L。常见做法是每种方块用一个bool矩阵表示我习惯统一用4×4而不是每种形状单独定尺寸。原因很简单旋转算法可以完全统一不用为I方块写特例O方块虽然不旋转放在4×4里也无所谓。这段代码就是整个游戏的数据基石。// Tetromino.cs public class Tetromino { public int[,] Shape { get; private set; } public int X { get; set; } // 方块左上角在场地中的列 public int Y { get; set; } // 方块左上角在场地中的行 public int Size Shape.GetLength(0); // 七种方块统一放进 4x4 矩阵1 表示有实体格子 private static readonly int[][,] Shapes { // I new int[,] { { 0,0,0,0 }, { 1,1,1,1 }, { 0,0,0,0 }, { 0,0,0,0 } }, // O new int[,] { { 0,0,0,0 }, { 0,1,1,0 }, { 0,1,1,0 }, { 0,0,0,0 } }, // T new int[,] { { 0,0,0,0 }, { 0,1,0,0 }, { 1,1,1,0 }, { 0,0,0,0 } }, // S new int[,] { { 0,0,0,0 }, { 0,1,1,0 }, { 1,1,0,0 }, { 0,0,0,0 } }, // Z new int[,] { { 0,0,0,0 }, { 1,1,0,0 }, { 0,1,1,0 }, { 0,0,0,0 } }, // J new int[,] { { 0,0,0,0 }, { 1,0,0,0 }, { 1,1,1,0 }, { 0,0,0,0 } }, // L new int[,] { { 0,0,0,0 }, { 0,0,1,0 }, { 1,1,1,0 }, { 0,0,0,0 } } }; public Tetromino(int typeIndex) { Shape (int[,])Shapes[typeIndex].Clone(); // 必须 Clone否则旋转会污染静态数据 } }代码说明构造函数里对Shapes做Clone是很多人第一次翻车的地方。如果不Clone所有同类型方块共享同一个二维数组引用你旋转了当前I方块下一个I方块的初始形状也跟着变了而且这种bug极难察觉属于典型的黑匣子问题。用Clone把静态模板和实例数据隔离每块方块独立旋转。把方块类型、位置和形状封装在一个类里这正是C#类设计里最常见的「数据行为」组合后面碰撞检测、绘制只需要面对这个类逻辑层不用再关心坐标转换。3.2 旋转与碰撞顺时针公式和「先试后改」原则旋转是整个游戏里最容易写错的环节。顺时针旋转90度的公式可以统一写成新行号 矩阵大小 - 1 - 原列号新列号 原行号。我把旋转和碰撞检测放在一起实现因为旋转后必须立刻检查合法性不合法就回滚。public void Rotate() { int n Size; int[,] rotated new int[n, n]; for (int y 0; y n; y) for (int x 0; x n; x) rotated[n - 1 - x, y] Shape[y, x]; // 顺时针旋转公式 Shape rotated; } // GameField.cs 里的碰撞检测 public bool CanMove(int dx, int dy) { for (int y 0; y Current.Size; y) for (int x 0; x Current.Size; x) { if (Current.Shape[y, x] 0) continue; int nx Current.X x dx; int ny Current.Y y dy; if (nx 0 || nx Cols || ny Rows) return false; // 撞边界 if (ny 0 Grid[ny, nx] ! 0) return false; // 撞已固定方块 } return true; }代码说明CanMove里的ny边界判断写得很讲究。方块生成时可以有一部分在场地顶部之外此时ny可能是负值这部分格子不应该参与场地碰撞所以只在ny 0时才查Grid。如果你把ny 0也算越界方块会在出生瞬间就判定碰撞游戏根本没法定起来。处理旋转和移动的顺序我遵循「先试后改」原则先执行移动或旋转再用CanMove检查不合法就回滚。这个原则配合墙踢Wall Kick使用细节放到避坑章节细说。3.3 消行与固定自下而上扫描的三种细节当方块不能再下落时先Merge把活动方块的格子写进场地再从底部往上扫描满行。消行逻辑里有三个细节直接影响手感扫描方向必须从下往上否则会漏掉连续满行消掉一行后要重查当前行号得分和攻击行数都取决于一次消除的行数。public void Merge() { for (int y 0; y Current.Size; y) for (int x 0; x Current.Size; x) if (Current.Shape[y, x] 1) { int gy Current.Y y; int gx Current.X x; if (gy 0) Grid[gy, gx] 1; // 顶部虚空的格子直接丢弃 } } public int ClearLines() { int cleared 0; for (int y Rows - 1; y 0; y--) { bool full true; for (int x 0; x Cols; x) if (Grid[y, x] 0) { full false; break; } if (full) { cleared; for (int ty y; ty 0; ty--) // 上方所有行下移一格 for (int x 0; x Cols; x) Grid[ty, x] Grid[ty - 1, x]; for (int x 0; x Cols; x) Grid[0, x] 0; y; // 重查当前行防止连续满行漏消 } } return cleared; }代码说明ClearLines里最后那个y是关键。消掉第19行后原第18行移动到了第19行如果这个位置也是满行不重查就会漏掉。y让当前迭代重复一次配合for循环的y--正好抵消用最少的代码处理了「连续多行同时满」的情况。Merge时gy 0的格子直接丢弃是因为方块顶部悬在场地外的部分本来就不算实体这也和CanMove里ny 0跳过碰撞的判断保持一致。到这里单机版的核心逻辑已经闭环生成方块、下落、固定、消行。但对战模式还差一块拼图——怎么让两个玩家真正「互坑」起来。4. 对战互坑玩法攻击行怎么加、胜负怎么判4.1 攻击规则消2行给对方1行的经典配置对战和单机的核心区别在于互坑机制。仿照QQ游戏大厅的经典对战习惯攻击行数和消行数不是线性关系而是鼓励玩家攒大消双消加1行三消加2行四消直接加4行。这个梯度让人更愿意冒风险堆高而不是有行就消。消行数攻击行数说明10单消不触发攻击21双消开始互坑32三消有明显威胁44四消Tetris直接给大招BattleRule类用一行switch表达式就能表达这个配置。我见过有人在这里堆if-else效果一样但可读性差。这类对战规则是典型的「状态机」思维消行数作为输入攻击行数作为输出规则集中在一个地方改起来不会影响场地逻辑。// BattleRule.cs public static class BattleRule { public static int AttackLines(int cleared) { return cleared 4 ? 4 : cleared - 1; // 1-0, 2-1, 3-2, 4-4 } }4.2 攻击行的实现底部插入、顶部挤出、留一个空洞攻击行的核心是「挤」在对方场地底部塞一整行整块场地向上顶最顶部原有的行被挤出场地。为了不让四消直接判死新行要留一个随机空缺给对面一点靠消行自救的可能否则攻势太碾压对局就失去拉扯感。// GameField.cs 里的攻击行方法 public void AddAttackLines(int count) { Random rnd Random.Shared; for (int i 0; i count; i) { // 整体上移一行从第0行到第Rows-2行每行都复制下一行的内容 for (int y 0; y Rows - 1; y) for (int x 0; x Cols; x) Grid[y, x] Grid[y 1, x]; // 底部新行全部填满留一个随机空位 int gap rnd.Next(Cols); for (int x 0; x Cols; x) Grid[Rows - 1, x] (x gap) ? 0 : 1; } }代码说明AddAttackLines的循环顺序不能反。如果从Rows-2向0反向复制上一行的数据会被下一行覆盖整场就乱了。这里从0到Rows-2正向复制是安全的Grid[0]先被Grid[1]覆盖Grid[1]被Grid[2]覆盖由于每个来源行都还没被修改顺序刚好。连续插入多行时务必要逐次调用或逐次循环不能只上移一次再填三行那样顶部只挤出一行攻击效果不对。攻击行插入后对方场地会立刻出现一排新方块。这里有个处理顺序问题要把攻击行和消行的结算顺序定好否则容易出现误判。标准的结算流程是先加攻击行再让场地消一次满行最后判断是否顶部溢出死亡public static bool TryApplyAttack(GameField target, int count) { if (count 0) return false; target.AddAttackLines(count); target.ClearLines(); // 攻击后先让能消的行消掉给一点喘息机会 return target.IsGameOver(); // true 表示对方已被顶死 }这个顺序是我改了三版才定下来的。最早的版本加完攻击行立刻判胜负结果对方消四行反击时经常出现「自己还没反应过来就输了」的局面体验很差。先消行再判死双方都有机会靠操作翻盘对战才有来回。4.3 胜负判定顶部溢出和出生即死胜负判定的标准是场地顶行有方块堆积。更严格的规则还要检查新方块出生位置是否被占用否则会出现「场地看着没满但新方块一生成就死」的尴尬。public bool IsGameOver() { // 顶行任何一格非空即判死 for (int c 0; c Cols; c) if (Grid[0, c] ! 0) return true; return false; } public bool SpawnNext() { Current Next; Next new Tetromino(Random.Shared.Next(7)); Current.X Cols / 2 - Current.Size / 2; // 水平居中 Current.Y 0; // 从顶行开始 // 出生位置被占用直接判负 for (int y 0; y Current.Size; y) for (int x 0; x Current.Size; x) if (Current.Shape[y, x] 1) { int gy Current.Y y; int gx Current.X x; if (gx 0 gx Cols gy 0 Grid[gy, gx] ! 0) return false; } return true; }代码说明只检查Grid[0]是否非空在攻击行场景下可能误判。攻击行把场地整体上顶一格如果原第0行本来有方块AddAttackLines后第0行会被顶出此时IsGameOver检测到的其实是「场外内容」所以正确顺序是SpawnNext失败或Grid[0]非空时才算输。我的做法是以SpawnNext的返回值为准新方块生成位置被占说明场地已经堆到了顶部游戏结束。4.4 双人键位两套控制互不干扰的输入映射同屏双人最敏感的是一套键盘两套键位。玩家1用方向键左移、右移、下移、上键旋转玩家2用W/A/S/DA左移、D右移、S下移、W旋转。输入处理不能用KeyDown直接驱动游戏逻辑因为按键AutoRepeat会在按住时重复触发导致长按一次就瞬移到底。做法是维护一个按下状态集合。private void OnKeyDown(object sender, KeyEventArgs e) { if (!_pressedKeys.Contains(e.KeyCode)) _pressedKeys.Add(e.KeyCode); } private void OnKeyUp(object sender, KeyEventArgs e) { _pressedKeys.Remove(e.KeyCode); } private void HandleInput() { // 玩家1方向键 if (_pressedKeys.Contains(Keys.Left)) MoveOrRotate(0, -1, 0); if (_pressedKeys.Contains(Keys.Right)) MoveOrRotate(0, 1, 0); if (_pressedKeys.Contains(Keys.Up)) MoveOrRotate(0, 0, 0, rotate: true); if (_pressedKeys.Contains(Keys.Down)) MoveOrRotate(0, 0, 1); // 玩家2W/A/S/D if (_pressedKeys.Contains(Keys.A)) MoveOrRotate(1, -1, 0); if (_pressedKeys.Contains(Keys.D)) MoveOrRotate(1, 1, 0); if (_pressedKeys.Contains(Keys.W)) MoveOrRotate(1, 0, 0, rotate: true); if (_pressedKeys.Contains(Keys.S)) MoveOrRotate(1, 0, 1); }代码说明MoveOrRotate接收玩家编号和移动参数内部先对目标场地做CanMove检查旋转也要先尝试再回滚。按键状态集合的做法让移动频率完全由Timer控制每次Tick最多响应一次不会出现长按连发导致方块瞬间落底。两个玩家的按键按玩家编号严格区分绝不写「if left then player1」这种模糊逻辑——后者就是双人抢键的根源。5. 常见问题与避坑闪烁、连发、旋转墙踢、双人抢键5.1 画面闪烁得像霓虹灯双缓冲没开或者重绘范围太大现象每次方块下落一格整个窗体闪成一片白色背景疯狂跳动。原因WinForms窗体默认没有启用双缓冲Refresh()触发OnPaint时先擦掉整个背景再重画两个场地加上所有格子全量重绘屏幕自然闪。另一种可能是你在OnPaint里画了背景图或大面积渐变加剧了闪烁。解决构造函数里设DoubleBuffered true让绘图先画到内存缓冲再一次性显示。如果还闪就缩小重绘区域在OnFallTick里用Invalidate(new Rectangle(...))只刷新两个场地的区域不要Invalidate整个ClientRectangle。5.2 按住方向键方块「瞬移」到最底KeyDown自动连发现象长按右键方块一口气滑到右墙长按下键直接掉到底。原因KeyDown事件本身有系统级AutoRepeat按住时每秒触发几十次同时Timer也在Tick里移动方块两者叠加移动速度完全失控。单机单人可能感觉不明显一旦双人共用键盘这个问题被放大到没法玩。解决用_pressedKeys集合KeyDown只负责Add不处理逻辑KeyUp负责Remove游戏逻辑全部从集合读取。这样移动频率只由Timer控制方向键按住只会等速移动不会瞬移。想提升手感可以单独对左右移动开一个更快的子Timer但别依赖按键事件的原始频率。5.3 方块贴墙时旋转「穿模」需要墙踢Wall Kick现象方块贴着左墙按旋转键后纹丝不动但其实旋转后的形状有一部分可以侧移进场地。原因旋转算法只检查了「旋转后是否越界」没有尝试左右偏移。竞技俄罗斯方块里这叫Wall Kick是必做的细节。原版游戏甚至会在旋转失败后尝试左右各偏移1到2格尽量让旋转成功。解决写一个简化版墙踢。旋转后碰撞失败时按「左移1格、右移1格、左移2格、右移2格」的顺序依次尝试都失败才回滚旋转。对仿QQ对战玩法来说这套规则足够让绝大多数贴墙旋转成功手感立刻正常。public bool TryRotateWithWallKick() { int[,] original (int[,])Current.Shape.Clone(); Current.Rotate(); if (CanMove(0, 0)) return true; int[] kicks { -1, 1, -2, 2 }; // 依次尝试左右偏移 foreach (int dx in kicks) { if (CanMove(dx, 0)) { Current.X dx; return true; } } Current.Shape original; // 全部失败则回滚 return false; }5.4 双人抢键一个键同时控制两个玩家现象玩家2按S加速下落结果玩家1的方块也突然掉到底按方向键时两个场地都在动。原因KeyDown事件里遍历了两个场地按键没有按玩家分组判断也有人按习惯写成「if left then player1 else player2」两个玩家的键位一交叉就乱了。解决输入映射用两个显式键位集合Tick里先判定按键属于哪个集合再操作对应场地。代码里PlayerIndex参数贯穿始终任何人维护这段逻辑都不会混。必要时把主窗体的KeyPreview设为true统一接收按键避免焦点被中间的分数控件抢走。5.5 攻击行加完立刻误判「游戏结束」现象对方四消你场地被顶出几行瞬间判负但顶出的位置本来可以靠消行自救。原因AddAttackLines把场地整体上顶顶部旧行被挤出后IsGameOver检查第0行有方块立刻返回true完全没给对方消行自救的机会。这是对战规则里最容易翻车的顺序问题。解决把结算顺序改成AddAttackLines → ClearLines → IsGameOver。先让能消的行消掉再判断顶部是否溢出。我最早的版本是先判胜负再加行结果对战双方都不敢消行生怕一消就赢体验非常差。现在的顺序让先手优势不至于变成「一发入魂」对局节奏健康得多。6. 从能玩到好玩验证逻辑、难度曲线与三个进阶方向把逻辑层和UI层拆开有一个额外好处GameField不依赖窗体可以直接写测试验证。我这里分享一个最简易的验证方法写个冒烟测试跑一遍核心逻辑能省掉一半调试时间// 简易测试构造一个满行场地ClearLines应返回1且该行清空 GameField field new GameField(); for (int x 0; x GameField.Cols; x) field.Grid[GameField.Rows - 1, x] 1; int cleared field.ClearLines(); Debug.Assert(cleared 1); Debug.Assert(field.Grid[GameField.Rows - 1, 0] 0);再验证攻击行清3行应给对面加2行对面底部变满且留一个空缺。这类断言能跑通说明核心逻辑没坏UI层的问题就能安心交给断点去查。难度曲线方面我习惯每消10行升一级下落间隔从800ms线性递减到150ms封顶等级累计消行数下落间隔ms)10800540500109025015140150进阶可以往三个方向走给方块加AI评分函数按落点清行数、空洞数对每个候选位置打分就能做「人机对战」用SoundPlayer加消行音效攻击行落下时加一段平滑移动动画视觉反馈对互坑玩法很重要再往上是局域网对战走UDP帧同步双方互发操作序列这是大工程能跑通同屏双人再碰它。我第一次做这个项目时把输出逻辑全写在按钮事件里回看代码自己都想笑改个下落速度要翻三处加攻击行怕把界面弄崩。后来把GameField拆成独立类规则变成方法调方法事情才顺过来。现在你按这套结构做遇到的坑基本都被扫过了剩下的是调整参数和配色这些玄学希望帮到你。本文还有配套的精品资源点击获取