简介:本资源是一套基于C#开发的仿QQ风格双人对战俄罗斯方块游戏源码,面向C#初学者与游戏开发入门者,聚焦桌面应用开发、事件驱动编程、多线程协同及经典游戏逻辑实现。项目完整呈现了方块生成、旋转、堆叠、消行判定、实时双人同步对战等核心机制,特别适合通过实战理解Windows Forms UI构建、键盘输入响应、游戏状态管理与玩家间操作隔离设计。压缩包含54个文件,以18个.cs源码文件为主体(涵盖主窗体FrmTetris、方块类Block、配置类Config、IPC通信模块等),辅以9个音效.wav、5个.resx本地化资源、3个可执行.exe及.sln/.csproj工程文件,整体18.91MB,结构清晰、模块解耦度高。目前已有283人学习下载,读者可直接编译运行,深入剖析双线程控制双玩家逻辑、共享游戏板状态同步策略,以及基于数组矩阵的游戏规则算法实现细节。
1. 为什么用 C# 写一个仿 QQ 风格的俄罗斯方块两人对战,至今仍是练手、面试、教学的「稳态选择」?
不是因为 C# 过时了,而是它在「GUI 交互 + 实时逻辑 + 网络同步」这个三角里,依然保持着罕见的平衡:WinForms/WPF 能快速搭出带按钮、计分板、暂停键的完整界面;System.Threading.Timer和DispatcherTimer控制下落节奏不飘;TcpClient/TcpListener或UdpClient搭建局域网对战足够轻量,不用引入 Unity 或 .NET MAUI 的重量级依赖。更关键的是——QQ 当年那个经典对战版(非网页 Flash 版)的交互逻辑:双人同屏、消除同步触发对方「垃圾行」、实时显示对手当前方块预览、胜负判定带动画反馈——这些都不是炫技需求,而是真实存在的用户心智模型。你用 Python PyGame 或 JavaScript Canvas 也能做,但调试 UI 布局、处理键盘焦点抢占、控制帧率抖动时,C# 的强类型 + Visual Studio 实时调试 + Windows 原生消息循环,会让你少掉至少三根头发。本文就带你从零跑通一个可编译、可局域网联机、可直接用于课程设计或技术面试演示的 C# 两人对战俄罗斯方块——所有代码基于 .NET 6+,不依赖第三方 UI 框架,WinForms 为主,WPF 为辅,源码结构清晰到能一眼看出「游戏状态机」「网络协议层」「渲染分离」三个核心模块。
2. 用 WinForms 搭建双屏对战界面:布局、坐标系与双画布渲染
2.1 主窗体结构:左右分屏 + 中央控制区的物理约束设计
WinForms 不是 WPF,没有数据绑定和 MVVM 天然优势,但胜在「所见即所得」。我们不搞 DockPanel 嵌套嵌套再嵌套,而是用最朴素的TableLayoutPanel划分三列:左玩家区(40%)、中央控制区(20%,含开始/暂停/重连按钮、比分牌、倒计时)、右玩家区(40%)。每个玩家区再嵌套一个Panel作为游戏画布容器,设置AutoScroll = false,SizeMode = PictureBoxSizeMode.StretchImage(后续用Graphics绘制时会覆盖此设置,但留着防误点缩放)。
提示:不要用
PictureBox.Image直接赋位图——每帧都 new Bitmap 会导致 GC 频繁,内存暴涨。必须用BufferedGraphicsContext双缓冲,否则 60fps 下画面撕裂肉眼可见。
// 在主窗体构造函数中初始化双缓冲 private BufferedGraphicsContext _context; private BufferedGraphics _leftBuffer, _rightBuffer; public MainForm() { InitializeComponent(); _context = BufferedGraphicsManager.Current; _leftBuffer = _context.Allocate(leftCanvas.CreateGraphics(), leftCanvas.ClientRectangle); _rightBuffer = _context.Allocate(rightCanvas.CreateGraphics(), rightCanvas.ClientRectangle); }leftCanvas和rightCanvas是两个Panel控件,它们的Paint事件不直接绘制,只作兜底(比如窗口大小改变时触发重绘)。真正绘制由独立的RenderLoop定时器驱动:
private void StartRenderLoop() { var timer = new System.Windows.Forms.Timer { Interval = 16 }; // ~60fps timer.Tick += (s, e) => { RenderLeftPlayer(); RenderRightPlayer(); _leftBuffer.Render(leftCanvas.CreateGraphics()); _rightBuffer.Render(rightCanvas.CreateGraphics()); }; timer.Start(); }2.2 游戏坐标系:像素 vs 方块格子的映射与抗锯齿处理
俄罗斯方块本质是离散网格游戏,但 WinForms 默认渲染是像素级。我们必须定义「逻辑格子」:标准 Tetris 是 10×20(宽×高),每个格子设为 24×24 像素(可调),这样整个游戏区就是 240×480 像素。但注意:Panel容器尺寸不能硬设为 240×480——要预留边框、标题栏、状态栏空间。实际做法是:
- 在
Panel.SizeChanged事件中动态计算可用区域:
private void leftCanvas_SizeChanged(object sender, EventArgs e) { var clientRect = leftCanvas.ClientRectangle; int cellSize = Math.Min(clientRect.Width / 10, clientRect.Height / 20); _cellSize = Math.Max(16, Math.Min(32, cellSize)); // 限制格子大小在 16~32px _gridWidth = clientRect.Width / _cellSize; _gridHeight = clientRect.Height / _cellSize; }- 绘制时用
Graphics.SmoothingMode = SmoothingMode.AntiAlias抗锯齿,但仅对轮廓线启用,填充色块仍用SmoothingMode.None(否则方块边缘发虚):
using (var g = _leftBuffer.Graphics) { g.SmoothingMode = SmoothingMode.None; // 填充色块 foreach (var block in currentGrid) { var rect = new Rectangle( block.X * _cellSize, block.Y * _cellSize, _cellSize, _cellSize ); g.FillRectangle(Brushes.Blue, rect); } g.SmoothingMode = SmoothingMode.AntiAlias; // 边框 using (var pen = new Pen(Color.Black, 1.5f)) { for (int x = 0; x <= _gridWidth; x++) g.DrawLine(pen, x * _cellSize, 0, x * _cellSize, _gridHeight * _cellSize); for (int y = 0; y <= _gridHeight; y++) g.DrawLine(pen, 0, y * _cellSize, _gridWidth * _cellSize, y * _cellSize); } }2.3 双人状态隔离:避免 UI 线程阻塞的「游戏世界快照」机制
WinForms 是单线程 UI 模型,但游戏逻辑(下落、旋转、消行判断)必须与渲染解耦。常见错误是把GameLoop放在Timer.Tick里直接操作 UI 控件——一旦逻辑耗时超过 16ms,界面就卡死。正确做法是:
- 定义
GameState结构体(不可变)存储当前网格、活动方块、分数、等级等; - 游戏逻辑运行在独立
Task.Run或Thread中(推荐Task.Run,避免线程管理复杂度); - 每次逻辑更新后,通过
Invoke将新GameState快照提交给 UI 线程; - UI 线程只负责「读取快照 → 渲染」,绝不修改状态。
// 游戏逻辑线程中 private async Task RunGameLoop() { while (_isRunning) { await Task.Delay(_dropInterval); // 动态间隔,随等级提升 var newState = _game.Update(); // 返回新 GameState this.Invoke((MethodInvoker)(() => { _leftState = newState; // 左玩家状态 _renderNeeded = true; // 标记需重绘 })); } }_renderNeeded是 volatile bool,RenderLoop检测到它为 true 才执行绘制,避免空转。
3. 实现核心游戏逻辑:方块生成、碰撞检测与消行反馈链
3.1 方块数据结构:用枚举 + 静态数组定义七种形状,而非硬编码坐标
硬编码new Point[] { new(0,0), new(1,0), ... }看似简单,但旋转、镜像、碰撞检测时极易出错。C# 枚举配合静态只读数组才是正解:
public enum TetrominoType { I, O, T, S, Z, J, L } public static class TetrominoShapes { public static readonly Point[][] Shapes = new Point[][] { // I: 一行四格 new[] { new Point(0, 0), new Point(1, 0), new Point(2, 0), new Point(3, 0) }, // O: 2x2 方块 new[] { new Point(0, 0), new Point(1, 0), new Point(0, 1), new Point(1, 1) }, // T: T 字形 new[] { new Point(0, 0), new Point(1, 0), new Point(2, 0), new Point(1, 1) }, // S: S 形 new[] { new Point(0, 1), new Point(1, 1), new Point(1, 0), new Point(2, 0) }, // Z: Z 形 new[] { new Point(0, 0), new Point(1, 0), new Point(1, 1), new Point(2, 1) }, // J: J 形 new[] { new Point(0, 0), new Point(0, 1), new Point(0, 2), new Point(1, 2) }, // L: L 形 new[] { new Point(1, 0), new Point(1, 1), new Point(1, 2), new Point(0, 2) } }; public static Point[][] Rotations => new Point[][][] { // I 的旋转:0°, 90°, 180°, 270° —— 实际只有两种形态,但为统一接口保留四组 new[] { Shapes[0], Shapes[0], Shapes[0], Shapes[0] }, // I 不变 new[] { Shapes[1], Shapes[1], Shapes[1], Shapes[1] }, // O 不变 // T 的四种旋转(需手动计算,此处省略具体数组,实际代码中完整定义) ... }; }关键点:Rotations是三维数组,[type][rotationIndex][pointIndex],访问TetrominoShapes.Rotations[(int)type][currentRotation][i]即可获取当前朝向的第 i 个格子坐标。
3.2 碰撞检测:从「预测位置」到「边界 + 已落定方块」的双重校验
碰撞检测不是「能不能放进去」,而是「移动/旋转后是否与边界或已落定方块重叠」。必须分两步:
- 边界检测:新位置的任意格子 X < 0 或 X ≥ 宽度 或 Y < 0 或 Y ≥ 高度 → 碰撞;
- 网格检测:遍历新位置所有格子,查
grid[x, y] != null→ 碰撞。
但注意:Y < 0 允许短暂存在(方块刚生成时顶部可能超出上边界),只要不落地就不算碰撞。真正的「落地判定」是:尝试下移一格后发生碰撞,且当前 Y ≥ 0 → 触发锁定。
public bool CanMove(Tetromino tetromino, int offsetX, int offsetY) { foreach (var p in tetromino.GetAbsolutePositions(offsetX, offsetY)) { if (p.X < 0 || p.X >= _width || p.Y >= _height) return false; // 边界 if (p.Y >= 0 && _grid[p.X, p.Y] != null) return false; // 已落定方块 } return true; } // 锁定逻辑 private void LockTetromino() { foreach (var p in _currentTetromino.GetAbsolutePositions(_currentX, _currentY)) { if (p.Y >= 0) // 只锁定在可视区域内的格子 _grid[p.X, p.Y] = _currentTetromino.Type; } }3.3 消行反馈链:从「行满判定」到「垃圾行注入」的跨玩家状态同步
单人模式消行只是清空、加分、加速;对战模式的核心在于「你的消行 → 对方收到垃圾行」。这里必须建立明确的协议:
- 每消 1 行 → 对方加 1 行「垃圾」(底部随机空缺 1~2 格);
- 消 2 行 → 加 3 行;
- 消 3 行 → 加 6 行;
- 消 4 行(Tetris)→ 加 10 行(这是 QQ 对战版经典系数,非标准规则)。
但「垃圾行」不是直接插入对方网格顶部——那样会瞬间压垮对手。真实做法是:在对方网格底部之上插入新行,并缓慢向上推(类似重力效果)。实现时,我们维护一个garbageQueue队列,每帧从队列取一行插入,直到清空。
// 对手消行后,发送垃圾行数量 public void SendGarbageToOpponent(int lineCount) { int garbageLines = lineCount switch { 1 => 1, 2 => 3, 3 => 6, 4 => 10, _ => 0 }; _opponentGarbageQueue.Enqueue(garbageLines); } // 每帧处理垃圾行(在游戏逻辑线程中) private void ProcessGarbage() { if (_opponentGarbageQueue.Count > 0 && _garbageInsertTimer <= 0) { int lines = _opponentGarbageQueue.Dequeue(); InsertGarbageLines(lines); _garbageInsertTimer = 30; // 每 30 帧插入一行,制造压迫感 } _garbageInsertTimer--; }InsertGarbageLines的实现:生成lines行,每行随机选 1~2 个位置留空(用null表示),其余填GarbageBlock类型,然后从底部逐行上推。
4. 实现局域网对战:TCP 同步协议设计与心跳保活
4.1 协议设计:用二进制序列化替代 JSON,降低延迟与带宽
对战不是聊天,不需要可读性。JSON 序列化体积大、解析慢,不适合 10~20ms 级别的状态同步。C# 原生BinaryWriter/BinaryReader+ 自定义结构体是最优解:
[Serializable] public struct GameStateSync { public int PlayerId; // 0=左, 1=右 public long Timestamp; // 毫秒级时间戳,用于插值 public byte CurrentTetromino; // 枚举值 (byte)TetrominoType public byte Rotation; // 0~3 public short X, Y; // 当前方块位置 public byte[] GridState; // 压缩后的网格状态:每行用 byte[10],0=空,1~7=方块类型 public int Score; public int Level; public int LinesCleared; }GridState不传完整二维数组,而是按行压缩:每行 10 格,用 10 个byte表示(0 表示空,1~7 表示方块类型),总长 200 字节(20 行 × 10 字节)。比传int[10,20](1600 字节)小 8 倍。
发送端:
using (var ms = new MemoryStream()) using (var bw = new BinaryWriter(ms)) { bw.Write((byte)_syncData.PlayerId); bw.Write(_syncData.Timestamp); bw.Write(_syncData.CurrentTetromino); bw.Write(_syncData.Rotation); bw.Write(_syncData.X); bw.Write(_syncData.Y); bw.Write(_syncData.GridState); bw.Write(_syncData.Score); bw.Write(_syncData.Level); bw.Write(_syncData.LinesCleared); _networkStream.Write(ms.ToArray(), 0, (int)ms.Length); }接收端用BinaryReader逆向读取,顺序必须严格一致。
4.2 TCP 连接管理:服务端监听 + 客户端连接 + 断线重连策略
WinForms 不适合写异步服务器,我们采用「主机-加入者」模式(非 P2P):
- 主机启动
TcpListener监听IPAddress.Any:8080; - 加入者用
TcpClient.Connect("主机IP", 8080); - 连接成功后,双方进入「同步循环」:每 50ms 发送一次
GameStateSync,同时接收对方数据。
关键细节:
TcpClient.NoDelay = true关闭 Nagle 算法,避免小包合并导致延迟;TcpClient.ReceiveTimeout = 5000,超时即断开;- 断线后自动尝试重连(最多 3 次,间隔 1s),失败则弹窗提示。
private async Task ConnectToHost(string hostIp) { try { _client = new TcpClient(); _client.NoDelay = true; _client.ReceiveTimeout = 5000; await _client.ConnectAsync(hostIp, 8080); _stream = _client.GetStream(); _isConnected = true; BeginReceiveLoop(); } catch (Exception ex) { MessageBox.Show($"连接失败: {ex.Message}"); _isConnected = false; } }4.3 心跳与状态同步:用时间戳 + 插值解决网络抖动
即使局域网,TCP 也可能出现 20~50ms 抖动。直接渲染接收到的坐标会导致方块「瞬移」或「卡顿」。解决方案:接收端维护一个「状态历史队列」,按Timestamp排序,渲染时取最近两个状态,用线性插值计算当前位置:
private Vector2 InterpolatePosition(GameStateSync prev, GameStateSync curr, long now) { float t = (float)(now - prev.Timestamp) / (curr.Timestamp - prev.Timestamp); return Vector2.Lerp( new Vector2(prev.X, prev.Y), new Vector2(curr.X, curr.Y), t ); }Vector2是自定义结构体(非System.Numerics.Vector2,避免额外引用),仅含X,Y两个float。插值让方块移动丝滑,彻底告别「跳跃感」。
5. 避坑:C# 俄罗斯方块对战开发中踩过的 5 个真实血泪坑
5.1 现象:按下方向键后,方块连续移动多格,松开键后还继续移
原因:WinForms 的KeyDown事件在按键长按时会重复触发,但KeyUp并不保证成对出现(尤其 Alt+Tab 切换后);若用KeyDown启动移动、KeyUp停止,极易丢失KeyUp导致失控。
解决:改用PreviewKeyDown+KeyPreview = true捕获全局按键,用HashSet<Keys>记录当前按下的键,Timer每帧检查集合状态驱动移动,KeyDown/KeyUp只负责增删集合项。这样即使KeyUp丢失,下次KeyDown也会覆盖旧状态。
5.2 现象:双人对战时,一方消行后另一方屏幕闪一下,但垃圾行没出现
原因:垃圾行插入逻辑写在Paint事件里,而Paint是 UI 线程,当网络接收和 UI 渲染都在同一线程排队时,InsertGarbageLines()可能被延迟执行,导致视觉不同步。
解决:垃圾行插入必须放在游戏逻辑线程中(即RunGameLoop里),与网络接收、消行判定同线程。UI 线程只负责读取_garbageRowsPending变量并渲染,绝不执行插入逻辑。
5.3 现象:局域网联机时,偶尔出现「对方方块消失」或「自己方块卡在半空」
原因:TCP 是流式协议,NetworkStream.Read()不保证一次读完完整包。可能一次读到 0.5 个包,下一次才读到剩下部分,导致BinaryReader解析失败、抛异常、丢弃整包。
解决:自定义「包头」:每个包前 4 字节写int包长度,接收端先读 4 字节得长度n,再循环读满n字节,最后解析。代码封装为SafeReadBinary方法,内部处理Read返回值小于预期的场景。
5.4 现象:调整窗口大小后,方块绘制错位,格子线偏移 1 像素
原因:Panel.ClientSize在Resize事件中返回的尺寸,可能包含系统 DPI 缩放导致的非整数像素(如 125% 缩放时,240px 宽实际是 300 设备像素),而Graphics.DrawRectangle坐标是设备像素。
解决:禁用 DPI 感知(在app.manifest中注释掉<dpiAware>true</dpiAware>),或在SizeChanged中用Graphics.DpiX/Y校正:_cellSize = (int)(baseCellSize * g.DpiX / 96),确保逻辑格子与设备像素对齐。
5.5 现象:游戏运行 10 分钟后,内存占用飙升到 1GB,然后崩溃
原因:BufferedGraphics分配后未释放,每次SizeChanged都新建BufferedGraphics,旧对象被 GC 滞后回收;同时Timer未Dispose(),导致资源泄漏。
解决:在SizeChanged中先Dispose()旧缓冲区,再新建;所有Timer、TcpClient、NetworkStream在窗体FormClosed事件中显式Dispose();用using包裹Graphics对象,杜绝 GDI 句柄泄漏。
6. 进阶技巧:用 WPF 替换 WinForms 实现平滑动画与主题切换
6.1 为什么值得迁移到 WPF?三个不可替代的优势
WinForms 能跑通,但 WPF 在以下三点上是质变:
| 维度 | WinForms | WPF | 迁移价值 |
|---|---|---|---|
| 动画 | 需手动Timer+Invalidate()重绘,易卡顿 | Storyboard+DoubleAnimation声明式动画,GPU 加速,60fps 稳定 | 消行爆炸、垃圾行上升、方块旋转特效可原生实现 |
| 主题 | 修改BackColor/ForeColor治标不治本,控件样式碎片化 | ResourceDictionary+ControlTemplate,一键切换深色/浅色/复古 QQ 风格 | 教学演示时,学生一眼看到「主题切换」能力 |
| 分辨率适配 | AutoScaleMode不可靠,高 DPI 下文字模糊 | ViewBox包裹整个 UI,自动缩放,逻辑像素与设备像素分离 | 适配 4K 屏幕无需改代码 |
迁移不是重写,而是「渐进式替换」:保留 WinForms 主窗体,用WindowsFormsHost嵌入 WPFUserControl作为游戏画布。这样既复用现有逻辑,又获得 WPF 渲染优势。
6.2 WPF 游戏画布实现:用Canvas+Path绘制方块,支持硬件加速
WPF 不用Graphics,改用矢量Path。定义TetrominoDataTemplate:
<DataTemplate x:Key="TetrominoTemplate"> <Path Data="{Binding Geometry}" Fill="{Binding Brush}" Stroke="Black" StrokeThickness="0.5" RenderOptions.BitmapScalingMode="HighQuality"/> </DataTemplate>Geometry是StreamGeometry,预生成七种方块路径(M0,0 L24,0 L24,24 L0,24 Z等),Brush绑定到TetrominoType的颜色字典。Canvas.SetLeft/SetTop设置位置,RenderTransform控制旋转——全部声明式,无手动坐标计算。
6.3 主题切换实战:用DynamicResource实现运行时换肤
WPF 主题核心是DynamicResource:资源字典中的颜色、字体、模板,只要DynamicResource引用,更换字典后自动刷新。
<!-- Themes/QQDark.xaml --> <SolidColorBrush x:Key="GridLineColor" Color="#333333"/> <SolidColorBrush x:Key="BlockBrush_I" Color="#00CCFF"/> <SolidColorBrush x:Key="BlockBrush_O" Color="#FFCC00"/>// 切换主题 Application.Current.Resources.MergedDictionaries.Clear(); Application.Current.Resources.MergedDictionaries.Add( new ResourceDictionary { Source = new Uri("Themes/QQDark.xaml", UriKind.Relative) } );DynamicResource绑定的控件(如Background="{DynamicResource GridLineColor}")立即响应变化,无需重启。
我带过三届毕业设计,学生用 WinForms 版交差,用 WPF 版拿优秀答辩——不是因为 WPF 多高级,而是它把「动画流畅度」「主题一致性」「高 DPI 适配」这些隐藏成本,变成了几行 XAML 就能解决的问题。当年我第一次用Storyboard实现消行粒子效果时,盯着屏幕看了十分钟,那种「原来 UI 还能这么玩」的震撼,至今记得。希望帮到你。
本文还有配套的精品资源,点击获取