ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WinForms双人俄罗斯方块:定时器调度与垃圾行结算源码解析

C# WinForms双人俄罗斯方块:定时器调度与垃圾行结算源码解析

简介:C#实例《仿QQ俄罗斯方块两人对战》面向具备一定C#基础、希望进入游戏开发领域的初学者与课程实践者,提供一套可运行、可拆解的同机双人对战俄罗斯方块源码,帮助理解方块生成、移动旋转、消行计分及玩家操作同步等核心逻辑。压缩包共54个文件,约18.91MB,包含18个cs源码文件、9个wav音效、5个resources与5个resx窗体资源,以及exe、xml配置、图标和解决方案文件,覆盖游戏主窗体、方块与方块组、配置、帮助、属性设置等模块。已有284人学习。源码将Windows Forms界面、事件驱动输入、多线程与数组/矩阵状态管理结合,读者可据此梳理双人对战的数据流、界面交互和游戏结束判定,也能借鉴音效、配置与帮助窗体的组织方式,适合作为课程设计或小型桌面游戏项目的参考案例。

1. 仿 QQ 俄罗斯方块双人对战:一块键盘两个人抢节奏,难点根本不在消行

同一块键盘、同一个显示器,两个人各占半边屏幕抢节奏——这份仿 QQ 俄罗斯方块的 C# 双人对战源码,把很多 C# 实例教程里最容易含糊过去的问题摆到了台面上:单人版只要一个下落循环,双人版就得让两个循环互不干扰,还要在消行的那一刻立刻结算攻击行,把垃圾行推给对面。工程本身不大,WinForms 界面加定时器驱动,逻辑集中在网格碰撞和行消除上,属于拿来就能改的那类源码。新手照着步骤能跑通一局完整对战,熟手更该盯住三处:定时器调度、旋转回退、行消除结算。这三点决定了你是只能玩一玩,还是能把它改成联机版、加个 AI 对手。

2. 拆开骨架看:七种方块、旋转回退与双人下落调度

俄罗斯方块的代码量看着吓人,真正撑起结构的其实就三块东西:方块的形状表示、下落的时间控制、双方场地之间的攻击结算。这三块拆明白了,剩下全是画图和按键。下面按「形状 → 时间 → 输入」的顺序往下走,每一段都给出可以直接抄进工程的写法,并说明为什么这么写、参数改成什么会有什么效果。

2.1 七种方块统一用 4×4 矩阵,旋转靠转置算出来

先看形状表示。新手常见的写法是给七个方块各写一套if-else分支来画格子、来判定碰撞,代码很快会膨胀到没法维护。这份源码走的是另一条路:七种方块全部塞进 4×4 的二维数组,空格填 0,实心格填 1。统一尺寸的好处很直接——旋转、碰撞、绘制这三处逻辑都不用再按形状分支,代码量能砍掉一大半。

// 七种方块统一用 4×4 方阵存储,1 表示实心格,0 表示空 // 统一尺寸后,旋转/碰撞/绘制三处都不需要按形状写分支 public static readonly int[][,] Shapes = { new int[4, 4] { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, // I new int[4, 4] { {0,0,0,0}, {0,1,1,0}, {0,1,1,0}, {0,0,0,0} }, // O new int[4, 4] { {0,0,0,0}, {0,1,0,0}, {1,1,1,0}, {0,0,0,0} }, // T new int[4, 4] { {0,0,0,0}, {0,1,1,0}, {1,1,0,0}, {0,0,0,0} }, // S new int[4, 4] { {0,0,0,0}, {1,1,0,0}, {0,1,1,0}, {0,0,0,0} }, // Z new int[4, 4] { {0,0,0,0}, {1,0,0,0}, {1,1,1,0}, {0,0,0,0} }, // J new int[4, 4] { {0,0,0,0}, {0,0,1,0}, {1,1,1,0}, {0,0,0,0} } // L };

这段代码里唯一需要留意的是出生位置:矩阵第 0 行通常是空的,方块真正占的是第 1、2 行,所以初始Y一般要从-1起算,否则方块一出来就贴着顶部,玩家还没来得及调整就锁死了。这个细节在后面排查「方块一出场就卡住」时还会碰到。

有了统一的方阵,旋转就不用维护四张表了,直接算:

// 顺时针旋转 90 度:先转置,再按列翻转,比维护四张旋转表省四分之三的常量 public static int[,] RotateCW(int[,] m) { var r = new int[4, 4]; for (int y = 0; y < 4; y++) for (int x = 0; x < 4; x++) r[x, 3 - y] = m[y, x]; return r; }

r[x, 3 - y] = m[y, x]这一行是整段的核心:原矩阵的第 y 行,旋转后变成新矩阵的第3-y列。用 3 而不是用size-1是因为方阵固定是 4×4,写死反而更直观。O 型方块旋转一次之后矩阵内容不变,TryRotate会返回 true 但看起来什么都没发生——这不是 bug,是对战里经常被误报的现象。

真正容易翻车的是贴墙旋转。方块贴着左墙或者贴着已有方块时,旋转后新姿态可能越界,如果直接赋值就会出现「穿墙」或者「叠块」。所以旋转必须带回退:失败就换个横向偏移再试,全失败就放弃这次旋转,方块保持原姿态。

// 旋转回退:挨着墙或者挨着已有方块时,依次尝试这几个横向补偿 // 顺序很重要,0 优先保证手感自然,±2 基本只对 I 型有意义 private static readonly int[] WallKickX = { 0, -1, 1, -2, 2 }; public bool TryRotate() { var rotated = Tetromino.RotateCW(Shape); foreach (int dx in WallKickX) { if (CanPlace(rotated, X + dx, Y)) // CanPlace 逐格判断越界与重叠 { Shape = rotated; X += dx; return true; } } return false; // 全部失败,放弃这次旋转,不影响当前姿态 }

参数上唯一值得动的是WallKickX的取值顺序。把1放在-1前面会让人感觉「旋转偏爱往右推」,对战的时候靠近右墙会莫名其妙地粘住,我一般保持0, -1, 1这个顺序,±2兜底就够了。CanPlace里要同时判两件事:格子是否超出左右下边界、目标格子在grid里是否已经被占,两件事漏一个都会出现叠块。

2.2 双人下落:一个 UI Timer 驱动两套计时器

双人对战最容易写错的不是逻辑,是时间。很多人第一反应是拖两个Timer控件,一个管左边一个管右边,各自设Interval,跑起来也像那么回事。问题在于 WinForms 的Timer走的是消息队列,两个定时器同时到点时会互相插队,节奏一抖,熟练玩家立刻能感觉到「这一把卡了一下」。血泪经验是:只留一个 UI Timer,双方各自维护一个累加器。

// 只用一个 UI Timer 驱动双方,避免两个 Timer 抢消息队列造成节奏抖动 private int _acc1, _acc2; // 双方各自的下落累加器,单位毫秒 private void gameTimer_Tick(object sender, EventArgs e) { int dt = gameTimer.Interval; // 建议 16ms,约 60 次/秒,和重绘频率对齐 _acc1 += dt; _acc2 += dt; if (_acc1 >= player1.DropInterval) { _acc1 = 0; player1.StepDown(); } if (_acc2 >= player2.DropInterval) { _acc2 = 0; player2.StepDown(); } board.Invalidate(); // 合并成一次重绘,别在 StepDown 里各自 Invalidate }

dt直接取gameTimer.Interval而不是用Stopwatch实测,是因为这个场景对时间精度要求没那么高,用固定步长反而让手感稳定、回放一致。DropInterval是每个玩家自己的字段,单位毫秒,初始值给 800 左右比较接近经典手感,随着消行数增加逐步减小,难度曲线就出来了。

下落的核心在StepDown:能往下就往下,不能往下就固化、结算、出新块。三件事必须按顺序,顺序错了会出现「消行消掉了刚锁定的块」这类玄学问题。

public void StepDown() { if (CanPlace(Shape, X, Y + 1)) { Y++; return; } // 还能往下,直接走 LockAndSpawn(); // 落不下去:固化 → 结算消行 → 生成下一块 }

对战和单人最大的差别在结算这一步。消行之后不是清掉就完事,还得算出送多少垃圾行给对手。这个映射表基本决定了整局节奏:

// 消行数与送出的垃圾行数:这个比例直接决定对战是快节奏还是拉锯战 private static int GarbageRows(int cleared) { if (cleared <= 1) return 0; // 单消不送,否则满地垃圾没法玩 if (cleared == 2) return 1; if (cleared == 3) return 2; return 4; // 四行全消,直接压死对面 }

垃圾行的插入方式也要讲究,常见做法是整体上移再在底部补一行:

// 给对手塞垃圾行:整场上移,底部补一行随机留一个洞 public void AddGarbage(int rows) { for (int i = 0; i < rows; i++) { for (int y = 0; y < Height - 1; y++) grid[y] = (int[])grid[y + 1].Clone(); // 逐行上移一位 grid[Height - 1] = NewGarbageRow(_rng.Next(Width)); // 底部新行挖一个洞 } if (IsTopOut()) GameOver(this); // 顶到天花板直接判负 }

这里有个必须统一的约定:grid的y索引到底哪一头是顶。上面这段假设y=0是顶部、y=Height-1是底部,所以「上移」是把大索引往小索引搬。如果你改成了y=0在底部,整段逻辑要反过来写,否则垃圾行会从天上掉下来。这种方向约定,工程里最好只在一处定义,别两个文件各写各的。

2.3 键位映射:一个 HashSet 挡住系统键重复

双人同屏第一个真实障碍是键位。左右手各一套键,还要避开系统快捷键和输入法。这份源码用的是 Form 级的KeyDown,配一个HashSet<Keys>记录当前按下的键。

// Form 上必须设 KeyPreview = true,否则方向键会被按钮等子控件先吃掉 private readonly HashSet<Keys> _down = new HashSet<Keys>(); private void Form1_KeyDown(object sender, KeyEventArgs e) { if (!_down.Add(e.KeyCode)) return; // 系统键重复会连发,这里只认第一次按下 switch (e.KeyCode) { case Keys.A: player1.MoveLeft(); break; case Keys.D: player1.MoveRight(); break; case Keys.S: player1.StepDown(); break; case Keys.W: player1.TryRotate(); break; case Keys.Space: player1.HardDrop(); break; case Keys.Left: player2.MoveLeft(); break; case Keys.Right: player2.MoveRight(); break; case Keys.Down: player2.StepDown(); break; case Keys.Up: player2.TryRotate(); break; case Keys.Enter: player2.HardDrop(); break; } e.Handled = true; // 挡住方向键的系统提示音和焦点切换 } private void Form1_KeyUp(object sender, KeyEventArgs e) => _down.Remove(e.KeyCode);

_down.Add返回 false 就直接 return,这一行挡掉的是 Windows 的按键重复。不挡的后果很具体:按住左键不放,方块会以系统重复率一路滑到墙边,玩家根本控制不住。另一个细节是空格和回车做硬降,必须防重复,否则一次按下会把两块方块连着砸到底。

如果还想要「按住方向键连续移动」的手感,别指望系统键重复,自己写一套 DAS:维护一个小计时器,键按下后先延迟 150ms,之后每 50ms 触发一次移动,KeyUp时清掉状态。这套东西写起来十几行,但对战手感差别非常大。

3. 把源码跑起来:从打开解决方案到打完第一局

拿到一份 C# 源码,最怕的是双击.sln之后一片红。这一节按「看骨架 → 编译 → 自检」的顺序走一遍,顺带把几个改起来最见效的参数列清楚。

3.1 先看工程骨架:几个文件各管什么

别急着按 F5,先花两分钟扫一遍目录。这类 WinForms 小工程的典型结构大致是这样,文件名可能不同,职责划分基本一致:

文件 / 目录主要职责动手前要确认什么
Program.cs入口,Application.Run拉起主窗体有没有开EnableVisualStyles
MainForm.cs窗体、Timer、键盘事件、双方画面绘制KeyPreview是否为 true
GameBoard.cs网格数据、碰撞判定、消行、垃圾行场地宽高和y轴方向约定
Player.cs当前方块、坐标、下落间隔、输入响应DropInterval的初始值
Tetromino.cs七种方阵、旋转、回退表出生矩阵是否第 0 行留空
Resources/方块贴图、音效、字体图片的「生成操作」是否为「嵌入的资源」

.csproj里重点看两处:TargetFrameworkVersion决定你装哪套运行时,<UseWindowsForms>或者引用列表里的System.Windows.Forms决定它是不是 WinForms 工程。老式.csproj和 SDK 风格工程的编译命令不一样,别混用。

3.2 编译运行与首局自检

命令行确认环境,再决定用 IDE 还是走 CLI:

# 看看本机装了哪些运行时,决定目标框架能不能直接跑 dotnet --list-runtimes # SDK 风格工程直接跑;老式 .csproj 用 msbuild 编解决方案 dotnet run --project ./TetrisBattle.csproj msbuild TetrisBattle.sln /p:Configuration=Debug

编译过了只是第一步,真正要看的是下面这几条自检项,任何一条不过都说明逻辑有问题,别急着改玩法:

  1. 方块从顶部出现,能左右移动到墙边停住,不越界。
  2. 贴左墙、贴右墙各旋转一次,方块自动内移一格而不是穿墙。
  3. 单消、双消、三消、四消各打一次,对面的垃圾行数量与映射表一致。
  4. 一方顶到天花板,游戏结束提示正确弹出,另一方的计时器停下。
  5. 连打三局不重启,内存占用没有持续上涨。

第 5 条最容易被忽略。很多这类工程在OnPaint里每帧new Bitmap、new Font,画完不Dispose,单人玩十分钟没事,双人对战帧率高、跑得久,GDI 对象很快就堆上去了。任务管理器里看「GDI 对象」这一列,只涨不跌就是这个问题。

3.3 手感参数表:改这几个数最见效

源码能跑之后,第一件事通常不是加功能,是把手感调顺。下面这几个参数改起来最快见效:

参数位置常用值调大的效果调小的效果
DropIntervalPlayer初始化800ms 起方块下落变慢,新手友好节奏加快,操作压力大
每消 10 行的间隔衰减消行结算处-60ms难度爬升平缓后期极快,容易失误
锁定延迟StepDown冲突分支400~500ms触底后还能微调,手感好一碰就锁,硬核但易误判
场地宽高GameBoard10 × 20宽场更适合双人窄场垃圾行压力大
垃圾行映射GarbageRows1/2/4对战更激进拉锯战,考验耐心

锁定延迟这一项源码里如果没有单独实现,常见做法是在StepDown遇到冲突时先记一个时间戳,超过阈值才真正LockAndSpawn,期间允许玩家继续左右移动和旋转。加上这 400ms,操作手感会有质的变化。

4. 避坑与排查:双人对战里最容易翻车的五个地方

这一节全是踩过的坑,按「现象 → 原因 → 解决」写,遇到对应症状可以直接对号入座。

4.1 按一下方向键,方块连跳两格

现象:轻点一下左键,方块直接横移两格;连续点几次就冲到墙边,完全没法精确定位。

原因:KeyDown被系统按键重复触发了。Windows 在你按住键的前 500ms 后会以固定频率重复发KeyDown,而很多写法直接在KeyDown里调MoveLeft(),没有任何去重。

解决:用一个HashSet<Keys>记录按键状态,KeyDown里if (!_down.Add(e.KeyCode)) return;,KeyUp里移除。想保留「按住连续移动」就把连发逻辑自己写进计时器,别依赖系统重复。

4.2 贴墙旋转时方块穿墙或直接卡死

现象:方块贴左墙按旋转,有一半格子跑到场地外面;或者旋转后和已有方块重叠,之后再也动不了。

原因:旋转后直接Shape = rotated,没有做落点合法性检查;或者检查了但只判了实心格是否越界,忘了判是否和grid里已有方块重叠。

解决:旋转必须走「尝试 + 回退」,按0, -1, 1, -2, 2的顺序挨个试,第一个能放下的就用,全都放不下就保持原姿态。CanPlace里两个条件都要判,缺一个都会出问题。另外注意 O 型方块,它旋转后矩阵不变,返回 true 是正常行为。

4.3 垃圾行把对手已经堆好的方块「吃掉」

现象:对面被塞了垃圾行之后,原本堆到底部的方块凭空少了几行,或者出现一段断层。

原因:AddGarbage里上移用的是浅拷贝。grid[y] = grid[y + 1]只是把引用指过去了,下一轮再改grid[y],实际改的是同一块内存,几轮下来行数据全乱。

解决:逐行Clone()做深拷贝,或者干脆用一维数组按索引偏移整块复制。改完之后顺手加一个断言:插入前记录总实心格数,插入后应该是「原数 + 新增行实心格数」,对不上就是复制逻辑还有问题。

4.4 打完两局越来越卡,帧率肉眼可见地掉

现象:第一局流畅,第二局开始重绘变慢,切出去再切回来更明显,任务管理器里 GDI 对象数一路往上。

原因:OnPaint里每次new Bitmap(...)、new SolidBrush(...)、new Font(...),用完没Dispose。这类对象不受 GC 及时回收,量一上来就卡。

解决:能用using的全部using,笔刷和字体提到字段里只创建一次。方块的贴图也一样,别每帧从Resources里取。改完看 GDI 对象数应该稳定在一个常数附近,不再单调上涨。

4.5 高分辨率屏幕上格子错位、边缘发虚

现象:笔记本上正常,外接显示器或者缩放 125% 之后,方块边缘糊成一片,格子之间出现一条缝。

原因:绘制时用浮点数算坐标再直接传给 GDI+,加上进程没声明 DPI 感知,系统做了位图拉伸。

解决:格子坐标统一取整,x = col * CellSize里CellSize用整数;绘制用PixelOffsetMode.Half配合整数坐标;在Program.cs里声明 DPI 感知,或者在工程清单里开启。缩放比例一变就出问题的,基本都是坐标没取整。

5. 二次开发:把同屏对战改成 AI 对手,关键在评估函数

源码跑顺之后,很多人第一反应是加联机。联机改造的工作量主要在同步:双方各自跑一份GameBoard,只把「锁定后的方块位置」和「垃圾行事件」发给对面,而不是同步每一次移动,这样网络抖动只影响画面延迟,不影响判定。真正有意思的二次开发方向是加一个 AI 对手,工作量小得多,还能把对战逻辑的所有分支都压测一遍。

AI 的思路很朴素:当前方块有 4 个旋转姿态、10 个列位置,加上硬降落点,总共不到 40 种选择,全部模拟一遍,打分取最高。打分函数才是关键:

// 对每个候选落点模拟硬降后的棋盘,打分取最高 double Score(GameBoard b) => b.ClearedLines * 0.76 // 消行收益最高,权重给足 - b.AggregateHeight * 0.51 // 总高度越低越安全 - b.Holes * 0.36 // 空洞无法消除,惩罚要重 - b.Bumpiness * 0.18; // 相邻列高度差,越平越好

四个指标里,Holes是「被方块盖住、上方还有实心格」的格子总数,Bumpiness是相邻两列高度差绝对值之和。权重不是拍脑袋来的,把这几个数做成可调参数,让 AI 对着自己打上几百局,慢慢就能调出一组不太容易被压死的值。我自己的经验是空洞权重给低了,AI 会在十几行之后突然崩盘,因为它一直在制造无法消除的死格。

一个容易忽略的细节:AI 的思考要放在硬降之后,别影响当前方块的位置。常见做法是克隆一份GameBoard,在副本上模拟落点,原盘不动。副本用值类型数组复制,开销很小,一帧算 40 次完全没问题。

从那以后我每次拿到这种对战类源码,都强制先跑一遍「旋转贴墙 + 四消攻击 + 连打三局」这套动作,再去动任何一行逻辑。这三步能揪出九成的坐标和生命周期问题,比看代码快得多。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进