资讯详情

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

📅 2026/10/24 0:22:35 | 华诺云谱 👁 阅读
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索引到底哪一头是顶。上面这段假设y0是顶部、yHeight-1是底部所以「上移」是把大索引往小索引搬。如果你改成了y0在底部整段逻辑要反过来写否则垃圾行会从天上掉下来。这种方向约定工程里最好只在一处定义别两个文件各写各的。2.3 键位映射一个 HashSet 挡住系统键重复双人同屏第一个真实障碍是键位。左右手各一套键还要避开系统快捷键和输入法。这份源码用的是 Form 级的KeyDown配一个HashSetKeys记录当前按下的键。// Form 上必须设 KeyPreview true否则方向键会被按钮等子控件先吃掉 private readonly HashSetKeys _down new HashSetKeys(); 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拉起主窗体有没有开EnableVisualStylesMainForm.cs窗体、Timer、键盘事件、双方画面绘制KeyPreview是否为 trueGameBoard.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:ConfigurationDebug编译过了只是第一步真正要看的是下面这几条自检项任何一条不过都说明逻辑有问题别急着改玩法方块从顶部出现能左右移动到墙边停住不越界。贴左墙、贴右墙各旋转一次方块自动内移一格而不是穿墙。单消、双消、三消、四消各打一次对面的垃圾行数量与映射表一致。一方顶到天花板游戏结束提示正确弹出另一方的计时器停下。连打三局不重启内存占用没有持续上涨。第 5 条最容易被忽略。很多这类工程在OnPaint里每帧new Bitmap、new Font画完不Dispose单人玩十分钟没事双人对战帧率高、跑得久GDI 对象很快就堆上去了。任务管理器里看「GDI 对象」这一列只涨不跌就是这个问题。3.3 手感参数表改这几个数最见效源码能跑之后第一件事通常不是加功能是把手感调顺。下面这几个参数改起来最快见效参数位置常用值调大的效果调小的效果DropIntervalPlayer初始化800ms 起方块下落变慢新手友好节奏加快操作压力大每消 10 行的间隔衰减消行结算处-60ms难度爬升平缓后期极快容易失误锁定延迟StepDown冲突分支400500ms触底后还能微调手感好一碰就锁硬核但易误判场地宽高GameBoard10 × 20宽场更适合双人窄场垃圾行压力大垃圾行映射GarbageRows1/2/4对战更激进拉锯战考验耐心锁定延迟这一项源码里如果没有单独实现常见做法是在StepDown遇到冲突时先记一个时间戳超过阈值才真正LockAndSpawn期间允许玩家继续左右移动和旋转。加上这 400ms操作手感会有质的变化。4. 避坑与排查双人对战里最容易翻车的五个地方这一节全是踩过的坑按「现象 → 原因 → 解决」写遇到对应症状可以直接对号入座。4.1 按一下方向键方块连跳两格现象轻点一下左键方块直接横移两格连续点几次就冲到墙边完全没法精确定位。原因KeyDown被系统按键重复触发了。Windows 在你按住键的前 500ms 后会以固定频率重复发KeyDown而很多写法直接在KeyDown里调MoveLeft()没有任何去重。解决用一个HashSetKeys记录按键状态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 次完全没问题。从那以后我每次拿到这种对战类源码都强制先跑一遍「旋转贴墙 四消攻击 连打三局」这套动作再去动任何一行逻辑。这三步能揪出九成的坐标和生命周期问题比看代码快得多。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑