
1. 项目概述从零到一用C#构建经典俄罗斯方块最近在整理旧项目时翻出了几年前用C#写的俄罗斯方块游戏源码。这个项目虽然不大但麻雀虽小五脏俱全它几乎涵盖了桌面应用开发中UI绘制、游戏逻辑、状态管理、事件处理等核心知识点。对于刚学完C#语法、想找个综合项目练手的朋友或者想重温WinForm/GDI绘图技术的开发者来说自己动手实现一遍俄罗斯方块绝对是性价比极高的选择。它不像大型游戏引擎那样复杂又能让你真切地感受到“创造一个可交互程序”的完整闭环。今天我就把这个项目的核心实现思路、关键代码以及我踩过的那些坑毫无保留地分享出来。你可以把这篇文章看作一份“保姆级”的复现指南跟着做你也能拥有一个属于自己的、带完整源码的俄罗斯方块游戏。这个项目主要使用C#和WinForm来实现。选择WinForm而不是WPF主要是考虑到其绘图APIGDI对于这种网格化、像素化的游戏逻辑更为直观和轻量。整个游戏的核心可以拆解为几个模块游戏区域一个二维网格、下落的方块由四个小方块组成的不同形状、方块的控制旋转、移动、加速下落、行的消除判断与计分。听起来不复杂对吧但真正写起来如何优雅地管理方块状态、如何高效地重绘界面、如何处理用户的连续按键每一个点都有讲究。接下来我们就深入代码内部看看这些模块是如何协同工作的。2. 游戏核心架构与数据结构设计实现俄罗斯方块第一步不是急着写界面而是设计好后台的数据模型。一个清晰的数据结构是游戏逻辑稳定的基石。俄罗斯方块的本质是在一个固定的矩形网格内操作一个由四个小方块我们称之为“方块单元”组成的“积木”进行移动、旋转并最终固定在底部。2.1 游戏区域与方块的数据模型首先我们需要定义游戏的主区域。我使用一个二维的int数组来表示比如int[20, 10]。数组的行数20代表高度列数10代表宽度。数组中的每个元素代表该位置的状态0表示空非0比如1-7表示已被某种颜色的方块占据。这种表示法简单直接便于进行碰撞检测和行满判断。public class GameBoard { private int[,] _grid; // 主游戏区域网格 private int _rows; // 行数 private int _columns; // 列数 public GameBoard(int rows, int columns) { _rows rows; _columns columns; _grid new int[rows, columns]; Clear(); // 初始化时清空网格 } public void Clear() { for (int i 0; i _rows; i) { for (int j 0; j _columns; j) { _grid[i, j] 0; } } } // ... 其他方法如判断某位置是否为空、设置值等 }接下来是核心的“方块”类。一个方块由四个单元组成并且有七种经典形状I, J, L, O, S, T, Z。我设计了一个Block类它需要记录形状类型用一个枚举来定义七种形状。旋转状态方块有0、90、180、270度四种朝向。中心坐标通常我们以方块的某个参考点比如形状矩阵的中心或某个角作为其位置坐标方便进行旋转计算。单元局部坐标存储组成该方块的四个小方块相对于中心坐标的偏移量。更高效的做法是预先为七种形状的四种旋转状态定义好它们四个单元的局部坐标。我们可以用一个三维数组int[7, 4, 4, 2]来存储第一维是形状第二维是旋转状态第三维是四个单元第四维是单元的(x, y)偏移量。这样任何时刻的方块我们只需要知道它的形状类型、旋转状态和中心位置就能立刻计算出它四个单元在游戏网格中的实际坐标。public enum BlockType { I, J, L, O, S, T, Z } public class Block { public BlockType Type { get; private set; } public int Rotation { get; private set; } // 0, 1, 2, 3 代表 0, 90, 180, 270度 public int PosX { get; set; } // 中心点X坐标列索引 public int PosY { get; set; } // 中心点Y坐标行索引 // 获取当前方块四个单元在网格中的绝对坐标 public Point[] GetCellPositions() { // 根据Type和Rotation从预定义的表中查找局部偏移 int[,,,] localCoords BlockData.Coordinates; // 假设BlockData是存储预定义数据的静态类 Point[] cells new Point[4]; for (int i 0; i 4; i) { int offsetX localCoords[(int)Type, Rotation, i, 0]; int offsetY localCoords[(int)Type, Rotation, i, 1]; cells[i] new Point(PosX offsetX, PosY offsetY); } return cells; } public void Rotate() { Rotation (Rotation 1) % 4; } // ... 移动方法 }注意关于方块旋转的“墙踢”机制。简单实现中旋转后如果与墙壁或其他方块重叠则取消这次旋转。但标准俄罗斯方块有复杂的“踢墙”规则即当旋转受阻时会尝试将方块向左、右或上轻微移动一格看是否能完成旋转。我们的初级版本可以先实现简单的“旋转失败则回退”逻辑这已经能提供不错的游戏体验。2.2 游戏状态机与主循环游戏处于不同的阶段等待开始、方块下落、方块锁定、消除行、游戏结束。我们需要一个清晰的状态机来管理。游戏的主驱动通常是一个定时器System.Windows.Forms.Timer。每隔一定时间如500毫秒定时器触发一次Tick事件在这个事件处理函数中我们执行一次“游戏帧”的逻辑下落让当前活动方块向下移动一格。碰撞检测检查下落后的方块是否与底部边界或已固定的方块碰撞。锁定如果碰撞则将当前方块的单元“固化”到游戏网格_grid中并生成一个新的随机方块。消除检查检查网格中是否有已填满的行如果有则消除该行并让上面的行下落。结束判断检查新生成的方块是否在初始位置就与已有方块碰撞如果是则游戏结束。public partial class MainForm : Form { private GameBoard _board; private Block _currentBlock; private Timer _gameTimer; private int _score; public MainForm() { InitializeComponent(); _board new GameBoard(20, 10); _gameTimer new Timer(); _gameTimer.Interval 500; // 初始下落间隔 _gameTimer.Tick GameTimer_Tick; StartNewGame(); } private void GameTimer_Tick(object sender, EventArgs e) { // 1. 尝试让当前方块下落一格 if (!MoveCurrentBlock(0, 1)) // 向下移动失败说明碰到底部了 { // 2. 锁定方块到网格 LockCurrentBlock(); // 3. 检查并消除满行 int linesCleared _board.ClearFullLines(); UpdateScore(linesCleared); // 更新分数 // 4. 生成新方块 SpawnNewBlock(); // 5. 如果新方块出生即碰撞游戏结束 if (CheckCollision(_currentBlock.GetCellPositions())) { _gameTimer.Stop(); MessageBox.Show(游戏结束最终得分 _score); } } // 6. 触发界面重绘 this.Invalidate(); // 通知窗体需要重绘游戏区域 } }实操心得游戏的主循环逻辑一定要放在定时器的Tick事件中而不是试图用while循环。while循环会阻塞UI线程导致界面卡死、无法响应按键。WinForm的定时器是在UI线程中触发事件的虽然对于非常高速的游戏可能不够精确但对于俄罗斯方块来说完全足够并且能天然地与UI操作如重绘兼容。3. 核心功能模块的详细实现有了整体的架构我们来深入每个核心功能的实现细节。这里会遇到很多具体的问题比如如何绘制、如何响应键盘、如何实现“硬降”等。3.1 游戏区域的绘制GDI绘图我们需要在WinForm的一个Panel或PictureBox控件上绘制游戏网格和方块。绘制逻辑在控件的Paint事件处理程序中完成。绘制网格背景先绘制一个填充的矩形作为背景然后画上网格线这样能清晰地区分每个小方格。绘制已固定的方块遍历游戏网格_grid对于值非0的位置根据其值代表颜色索引绘制一个填充了对应颜色的小矩形。绘制当前活动方块获取当前活动方块_currentBlock的四个单元坐标绘制四个颜色鲜艳的矩形。为了更好看可以给活动方块加上边框或高光效果。绘制预览方块Next Block在侧边区域绘制下一个即将出现的方块让玩家有所准备。private void GamePanel_Paint(object sender, PaintEventArgs e) { Graphics g e.Graphics; g.Clear(Color.Black); // 清空背景 int cellSize 25; // 每个小方格的像素大小 // 1. 绘制网格线 Pen gridPen new Pen(Color.DarkGray, 1); for (int x 0; x _board.Columns; x) { g.DrawLine(gridPen, x * cellSize, 0, x * cellSize, _board.Rows * cellSize); } for (int y 0; y _board.Rows; y) { g.DrawLine(gridPen, 0, y * cellSize, _board.Columns * cellSize, y * cellSize); } // 2. 绘制已固定的方块 for (int y 0; y _board.Rows; y) { for (int x 0; x _board.Columns; x) { int cellValue _board.GetCell(x, y); if (cellValue 0) { Brush blockBrush _colorPalette[cellValue]; // _colorPalette是颜色数组 g.FillRectangle(blockBrush, x * cellSize, y * cellSize, cellSize, cellSize); // 绘制方块边框增加立体感 g.DrawRectangle(Pens.White, x * cellSize, y * cellSize, cellSize, cellSize); } } } // 3. 绘制当前活动方块 if (_currentBlock ! null) { Brush currentBrush _colorPalette[(int)_currentBlock.Type 1]; foreach (Point cell in _currentBlock.GetCellPositions()) { // 注意检查坐标是否在可视范围内防止绘制时越界 if (cell.Y 0) // 方块可能有一部分在顶部“出生区”暂时不绘制 { g.FillRectangle(currentBrush, cell.X * cellSize, cell.Y * cellSize, cellSize, cellSize); g.DrawRectangle(Pens.Yellow, cell.X * cellSize, cell.Y * cellSize, cellSize, cellSize); } } } }踩坑记录绘制性能。如果游戏区域较大且频繁重绘直接在Paint事件中进行复杂的绘图计算可能会卡顿。一个优化点是将游戏区域的背景网格线和已固定的方块绘制到一个离屏的Bitmap上。只有当网格内容发生变化如方块锁定、行消除时才更新这个Bitmap。在Paint事件中只需要将这个Bitmap和当前活动方块绘制出来即可这能显著提升流畅度。3.2 键盘控制与用户输入处理玩家通过键盘控制方块移动、旋转和加速下落。我们需要在窗体的KeyDown事件中处理这些输入。private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (_gameTimer.Enabled) // 确保游戏正在进行 { switch (e.KeyCode) { case Keys.Left: MoveCurrentBlock(-1, 0); // 左移 break; case Keys.Right: MoveCurrentBlock(1, 0); // 右移 break; case Keys.Up: RotateCurrentBlock(); // 旋转 break; case Keys.Down: MoveCurrentBlock(0, 1); // 软降加速下落 break; case Keys.Space: HardDropCurrentBlock(); // 硬降瞬间落到底部 break; case Keys.P: _gameTimer.Enabled !_gameTimer.Enabled; // 暂停/继续 break; } this.Invalidate(); // 每次操作后都需要重绘 } }这里的关键是MoveCurrentBlock和RotateCurrentBlock方法它们内部必须包含碰撞检测。private bool MoveCurrentBlock(int deltaX, int deltaY) { // 1. 计算移动后的新位置 Block testBlock _currentBlock.Clone(); // 需要一个Clone方法 testBlock.PosX deltaX; testBlock.PosY deltaY; // 2. 碰撞检测 if (!CheckCollision(testBlock.GetCellPositions())) { // 无碰撞应用移动 _currentBlock.PosX testBlock.PosX; _currentBlock.PosY testBlock.PosY; return true; } // 有碰撞移动失败 return false; } private bool CheckCollision(Point[] cells) { foreach (Point cell in cells) { // 检查是否超出左右边界 if (cell.X 0 || cell.X _board.Columns) return true; // 检查是否超出底部边界 if (cell.Y _board.Rows) return true; // 检查是否与已固定的方块重叠 (Y0 才检查因为方块可能还在顶部出生区) if (cell.Y 0 _board.GetCell(cell.X, cell.Y) ! 0) return true; } return false; }硬降Hard Drop的实现是让方块一直向下移动直到碰撞为止然后立刻执行锁定和消除检查。注意硬降成功后通常有额外的分数奖励。private void HardDropCurrentBlock() { int dropDistance 0; // 模拟下落计算能下落多少格 while (MoveCurrentBlock(0, 1)) { dropDistance; } // 此时方块已经无法下落处于碰撞前的最后一格 // 立刻执行锁定、消除、生成新方块等逻辑这部分逻辑与定时器Tick中碰撞后的逻辑相同 LockCurrentBlockAndProcess(); // 奖励分数例如每下落一格奖励2分 _score dropDistance * 2; }注意事项键盘响应延迟与重复。WinForm默认的键盘事件处理在用户长按一个键时会有初始延迟然后才开始快速重复触发。对于游戏来说我们希望按键响应更灵敏。可以通过设置系统键盘重复速率或者更高级的做法是在KeyDown事件中设置一个标志并在游戏主循环或另一个更快的定时器中根据这个标志来连续移动方块这样可以实现更平滑、可自定义的操控手感。3.3 行消除与分数更新逻辑当一行中的所有格子都被方块填满时这一行就需要被消除上方的所有行整体下移。这是俄罗斯方块最爽快的时刻之一。public int ClearFullLines() { int linesCleared 0; // 从最底部开始向上扫描 for (int row _rows - 1; row 0; row--) { bool isFull true; for (int col 0; col _columns; col) { if (_grid[row, col] 0) { isFull false; break; } } if (isFull) { // 消除这一行将这一行以上的所有行向下移动一行 for (int moveRow row; moveRow 0; moveRow--) { for (int col 0; col _columns; col) { _grid[moveRow, col] _grid[moveRow - 1, col]; } } // 最顶行清空 for (int col 0; col _columns; col) { _grid[0, col] 0; } linesCleared; row; // 因为当前行已被移下来的新行填充需要再次检查这一行现在它是原来row-1行的内容 } } return linesCleared; }分数计算通常采用非线性奖励机制鼓励一次性消除多行。经典的计分规则是消除1行100分 * 当前等级消除2行300分 * 当前等级消除3行500分 * 当前等级消除4行Tetris800分 * 当前等级同时随着分数的增加游戏的等级提升方块下落的速度定时器Interval减小也会加快增加游戏难度。private void UpdateScore(int linesCleared) { if (linesCleared 0) return; int baseScore 0; switch (linesCleared) { case 1: baseScore 100; break; case 2: baseScore 300; break; case 3: baseScore 500; break; case 4: baseScore 800; break; } int level _score / 1000 1; // 简单的等级计算每1000分升一级 int earnedScore baseScore * level; _score earnedScore; // 更新等级并调整游戏速度 int newLevel _score / 1000 1; if (newLevel level) { // 等级提升加快速度。确保Interval不小于一个最小值如50ms _gameTimer.Interval Math.Max(50, 500 - (newLevel - 1) * 50); } // 更新UI上的分数和等级显示 lblScore.Text _score.ToString(); lblLevel.Text newLevel.ToString(); }4. 项目进阶优化与扩展思路一个基础可玩的俄罗斯方块完成后我们可以从工程化和体验上对它进行优化让它更健壮、更专业。4.1 代码结构优化与设计模式应用最初的代码可能把所有逻辑都塞在MainForm里随着功能增加会变得难以维护。我们可以进行重构分离关注点GameEngine类纯粹的游戏逻辑核心包含GameBoard、Block、分数、等级、状态机等。它不依赖任何UI。MainForm类负责UI展示绘图和用户输入。它持有一个GameEngine实例在Paint事件中根据引擎状态绘图在KeyDown事件中调用引擎的方法。Renderer类专门负责绘制。将绘图代码从MainForm中抽离出来使得更换渲染方式比如改用WPF更容易。使用事件驱动让GameEngine在状态改变时如方块锁定、行消除、游戏结束触发事件。MainForm订阅这些事件来更新UI。这样引擎就不需要知道UI的存在耦合度更低。public class GameEngine { public event Actionint ScoreUpdated; // 分数更新事件 public event Actionint LinesCleared; // 消除行事件 public event Action GameOver; // 游戏结束事件 private void OnLinesCleared(int lines) { LinesCleared?.Invoke(lines); // 内部更新分数 UpdateScore(lines); ScoreUpdated?.Invoke(_score); } private void OnGameOver() { GameOver?.Invoke(); } }4.2 提升游戏体验的实用功能“幽灵方块”预览在游戏区域中用半透明的轮廓显示当前方块如果硬降会落在什么位置。这能极大帮助玩家进行预判。实现方法是在绘制当前方块前先计算其硬降后的位置并绘制一组半透明的矩形。方块暂存Hold允许玩家将当前方块暂存起来并立即切换到下一个方块。每个回合只能使用一次暂存功能。这需要新增一个_holdBlock变量和相应的逻辑。下一个方块队列不仅显示下一个而是显示接下来3-5个方块的预览。这需要维护一个方块队列。使用一个ListBlockType或QueueBlockType在生成新方块时从队列头部取并补充新的随机方块到队列尾部。注意标准的随机生成算法如“7-Bag”随机生成器比完全随机更公平它能保证在连续7个方块中每种形状恰好出现一次。音效与动画为移动、旋转、锁定、消除行、游戏结束等动作添加音效。消除行时可以增加一个简单的动画效果比如被消除的行闪烁几下再消失上面的行再落下来。这可以通过在游戏状态机中增加一个“消除动画中”的状态并在定时器中处理动画帧来实现。本地高分记录将玩家的高分记录包括分数、等级、行数保存到本地文件如XML或JSON或注册表中并在游戏开始时加载显示。4.3 从WinForm迁移到其他框架如果你掌握了核心的游戏逻辑GameEngine那么换一个UI框架会非常容易。迁移到WPFWPF的绘图能力更强可以使用Canvas和Rectangle来绘制方块利用数据绑定和ObservableCollection来管理网格数据实现更MVVM化的架构。游戏主循环可以使用CompositionTarget.Rendering事件或DispatcherTimer。迁移到控制台应用这听起来有点复古但确实可以。用字符如[]来表示方块通过监听控制台键盘输入来实现。这能极大地锻炼你的逻辑抽象能力因为你需要完全将游戏逻辑与渲染分离。迁移到Unity在Unity中每个小方块可以是一个GameObject带SpriteRenderer游戏区域是一个空物体。逻辑脚本挂在主摄像机或一个管理物体上使用Update函数作为游戏循环通过Input.GetKeyDown处理输入。物理碰撞可以用Collider但更简单的还是用我们自己的网格数据做逻辑碰撞。5. 常见问题排查与调试技巧在开发过程中你肯定会遇到各种奇怪的Bug。这里记录几个典型问题及其解决方法。5.1 方块旋转或移动时“穿墙”或卡住问题描述方块旋转后部分单元跑到墙外或者移动时感觉不跟手有时会卡进已固定的方块里。排查思路检查坐标系统确认游戏网格的坐标系。通常(0,0)是左上角X向右增加Y向下增加。确保方块的局部坐标偏移量在这个坐标系下计算正确。验证碰撞检测范围在CheckCollision方法中打印出待检测方块的四个单元坐标看是否在预期的网格范围内0到Columns-1 0到Rows-1。特别注意Y坐标方块的某些部分可能在网格顶部以上Y为负这些部分在碰撞检测时应被忽略或视为与顶部边界碰撞但在绘制时也应被忽略。检查“固化”逻辑当方块锁定LockCurrentBlock时确保是将方块的单元坐标正确地写入到_grid数组中并且写入的值颜色索引是正确的。解决方案最可能的原因是旋转后的局部坐标表定义错误。仔细核对BlockData.Coordinates这个四维数组。对于每种形状的每种旋转四个单元的(x,y)偏移量必须准确。一个有效的调试方法是在旋转时将计算出的新坐标在控制台输出或者临时绘制出来与预期效果对比。5.2 游戏运行越来越卡特别是消除多行时问题描述游戏运行一段时间后感觉变卡顿尤其是在一次性消除多行后。排查思路内存泄漏检查Paint事件中是否每次都创建了新的Pen、Brush、Font等GDI对象而没有释放。这些对象是非托管资源必须手动Dispose。绘制效率是否在Paint事件中进行了全网格的重绘计算尝试使用离屏Bitmap缓存静态背景。定时器堆积确保没有创建多个Timer实例或者Tick事件处理函数执行时间过长导致事件堆积。解决方案资源释放将Pen、Brush等作为窗体的成员变量在初始化时创建在窗体关闭时释放。或者在using语句中创建局部绘图对象。// 错误做法每次Paint都创建新对象 // g.DrawRectangle(new Pen(Color.Red), ...); // 正确做法使用成员变量或using using (Pen myPen new Pen(Color.Red)) { g.DrawRectangle(myPen, ...); }双缓冲启用WinForm的双缓冲功能可以显著减少闪烁和提升绘制性能。可以在窗体构造函数中设置this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer, true); this.UpdateStyles();背景缓存如3.1节所述将不变的背景绘制到Bitmap中。5.3 键盘控制不灵敏或有重复输入问题问题描述按下键后没反应或者长按时移动不连续有时又一下移动过多。排查思路焦点问题确保游戏窗体或游戏面板Panel获得了焦点。可以在窗体Load事件或游戏开始时调用this.Focus()。键盘事件被拦截检查是否有其他控件如按钮、文本框获得了焦点截获了键盘事件。可以设置窗体的KeyPreview属性为true让窗体先处理按键。系统键盘重复设置长按的延迟和重复速率受系统设置影响。解决方案设置KeyPreview为true。实现更精确的输入控制使用一个DictionaryKeys, bool来记录每个按键的按下状态。在KeyDown事件中将对应键设为true在KeyUp事件中设为false。然后在游戏主循环Timer_Tick中检查这些状态如果某个移动键为true则执行移动并可以引入一个小的计数器来实现“按下后稍等片刻再开始连续移动”的效果这比系统默认的体验更好。private DictionaryKeys, bool _keyStates new DictionaryKeys, bool(); private int _leftRepeatCounter 0; private const int REPEAT_DELAY 10; // 连续移动的间隔帧数 private void GameTimer_Tick(object sender, EventArgs e) { // ... 原有的下落逻辑 // 处理连续按键移动 if (_keyStates.ContainsKey(Keys.Left) _keyStates[Keys.Left]) { _leftRepeatCounter; if (_leftRepeatCounter 1 || _leftRepeatCounter REPEAT_DELAY) { MoveCurrentBlock(-1, 0); // 重置计数器以实现“首次立即响应之后按固定间隔重复” if (_leftRepeatCounter REPEAT_DELAY) _leftRepeatCounter REPEAT_DELAY - 2; } } else { _leftRepeatCounter 0; } // ... 处理其他方向键 }5.4 行消除后上方方块下落不自然有“跳帧”感问题描述消除一行后上面的方块瞬间就掉下来了缺乏视觉过渡。解决方案这是基础实现的特点。要增加下落动画需要修改状态机。当检测到需要消除的行时不要立即清除和下落而是进入一个“消除动画”状态。在这个状态下标记要消除的行比如在_grid中用一个特殊值表示。在Paint事件中被标记的行用闪烁或高亮的效果绘制。启动一个短暂的动画定时器比如100毫秒触发3次在第三次触发时才真正执行清除行和上方方块下落的逻辑然后退出“消除动画”状态恢复正常游戏。在动画期间游戏主定时器应暂停方块停止下落。实现这个功能后游戏的视觉反馈会专业很多。它虽然增加了复杂度但很好地演示了如何将“游戏状态”与“渲染状态”分离。你的GameEngine需要增加一个GameState枚举包含Playing,ClearingLines,GameOver等状态并在主循环中根据不同状态执行不同的逻辑。