ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C#实现俄罗斯方块:从零构建经典游戏的完整指南

C#实现俄罗斯方块:从零构建经典游戏的完整指南 1. 项目概述与核心价值最近在社区里看到不少朋友对用C#做小游戏感兴趣尤其是像俄罗斯方块这种经典项目。作为一个从WinForm时代就开始折腾C#的老码农我深感这个项目远不止是“画几个方块下落”那么简单。它就像一把瑞士军刀能帮你一次性串联起C#里图形绘制、事件处理、数据结构、游戏循环、状态管理等多个核心概念。很多人觉得用Unity或者MonoGame做游戏才“专业”但对于想深入理解C#语言特性和Windows桌面开发底层逻辑的开发者来说从零开始用WinForms或WPF手搓一个俄罗斯方块其收获远比调用现成引擎API要大得多。这个项目的核心价值在于“麻雀虽小五脏俱全”。你不仅是在实现一个游戏更是在构建一个微型的、完整的软件系统。你需要考虑如何用二维数组或列表来表示游戏网格如何设计七种不同形状的方块及其旋转逻辑如何实现一个稳定且与帧率无关的游戏主循环如何处理键盘输入来控制方块移动和旋转以及如何计算得分和判断游戏结束。整个过程会迫使你思考类的设计比如方块类、游戏引擎类、渲染类是否应该分离、数据与视图的分离甚至是简单的状态模式应用。对于初学者这是绝佳的OOP面向对象编程实践场对于有经验的开发者则是重构和优化代码、应用设计模式的良好实验田。接下来我将拆解整个实现过程分享我从设计思路到具体编码再到调试优化的一手经验。2. 整体架构设计与核心思路在动手写第一行代码之前花点时间思考架构是值得的。一个混乱的俄罗斯方块代码很快就会变成“面条代码”难以维护和扩展。我的核心思路是遵循“模型-视图-控制器”MVC的变体将游戏逻辑、数据状态和界面渲染分离。2.1 核心类职责划分我通常会设计以下几个核心类TetrisGame (游戏引擎/模型)这是大脑。它不关心界面长什么样只负责维护游戏状态。包括int[,] GameBoard一个二维整数数组表示固定的游戏区域比如10列20行。0表示空非0表示已被固定的方块可以用不同数字代表不同颜色。Tetromino CurrentPiece当前正在下落的方块对象。Tetromino NextPiece下一个将要出现的方块对象用于预览。游戏状态是否进行中、是否已暂停、当前分数、已消除的行数、当前等级决定下落速度。核心逻辑方法SpawnNewPiece()生成新方块、MovePiece()移动、RotatePiece()旋转、LockPiece()将当前方块固定到面板、ClearLines()检查并消除满行、UpdateGame()驱动游戏主循环的单次更新。Tetromino (方块类)代表一个俄罗斯方块。每种形状I, J, L, O, S, T, Z有其固定的形状矩阵一个二维布尔数组或整数数组。这个类需要存储形状数据。记录当前方块在游戏板上的位置左上角坐标。提供旋转方法顺时针/逆时针实质上是变换其形状矩阵。提供“获取方块所有单元格的绝对坐标”的方法用于碰撞检测和绘制。GameRenderer (视图/渲染器)负责将TetrisGame模型的状态绘制到屏幕上。在WinForms中这可能是一个自定义的UserControl在其OnPaint方法中根据游戏板数组和当前方块对象使用Graphics对象进行绘制。它不应该包含任何游戏逻辑。GameController (控制器)负责处理用户输入键盘事件并将操作指令左移、右移、旋转、加速下落、暂停传递给TetrisGame引擎。在WinForms中这通常是主窗体的键盘事件处理程序。为什么这么分分离后测试游戏逻辑TetrisGame可以完全不依赖UI更换渲染方式比如从WinForms切换到控制台或WPF只需重写GameRenderer处理不同平台的输入只需修改GameController。这种清晰的责任划分让代码的健壮性和可维护性大大提升。2.2 游戏主循环的实现选择这是游戏的心脏决定了方块如何“自动”下落。在WinForms桌面应用中通常有两种主流方案Timer定时器驱动这是最简单直观的方式。放置一个System.Windows.Forms.Timer组件设置其Interval间隔属性比如初始为500毫秒。在Timer的Tick事件处理程序中调用TetrisGame.UpdateGame()方法该方法会让当前方块下落一行。随着等级提高逐步减少Timer的Interval值。这种方式的优点是简单与UI线程兼容性好。缺点是精度受系统计时器和UI消息队列影响在复杂场景下可能不够平滑且Interval是毫秒级对于高速游戏比如极高等级控制不够精细。多线程高精度计时循环这是更专业游戏的做法。创建一个独立的后台工作线程如使用Thread或Task在该线程中运行一个while循环。在循环体内使用高精度计时器如Stopwatch或System.Diagnostics.Stopwatch来计算上一帧到这一帧经过的时间deltaTime。根据当前等级对应的“下落一行所需秒数”和deltaTime累积时间当累积时间超过阈值时执行一次下落逻辑并重置累积时间。同时这个循环还需要以固定的高频率如60FPS触发UI线程去重绘界面通过Control.Invoke。这种方式能实现帧率独立、平滑的游戏体验但复杂度高需要处理线程同步和UI线程安全的问题。对于初学者和大多数俄罗斯方块实现我强烈推荐使用Timer方案。它完全够用且能让你更专注于游戏逻辑本身避免过早陷入多线程的泥潭。本项目的后续实现也将基于此方案。3. 核心数据结构与算法详解有了架构我们来深入每个核心模块的实现细节。3.1 游戏板与方块的表示游戏板 (GameBoard) 我使用一个int[Width, Height]的二维数组。Width和Height是游戏区域的格子数比如经典的10x20。数组的值有特定含义0该格子为空。1到7代表已被固定的方块同时这个数字可以映射到对应的颜色1青色(I), 2蓝色(J), 3橙色(L), 4黄色(O), 5绿色(S), 6紫色(T), 7红色(Z)。这种表示法非常高效。检查一个位置是否被占用只需判断GameBoard[x, y] ! 0。消除一行后将上面所有行的数据下移也只需要数组拷贝操作。方块 (Tetromino) 每种方块形状可以用一个小的二维数组如4x4来定义其中true或1表示有方块false或0表示无。我们预定义这七种形状的初始状态。public class Tetromino { public int[,,] Shapes; // 一个三维数组[方块类型, 旋转状态, 行, 列] public int TypeIndex; // 当前方块类型 (0-6) public int Rotation; // 当前旋转状态 (0-3) public int X, Y; // 方块在游戏板上的坐标通常指其形状矩阵的左上角 public Tetromino() { // 定义七种方块每种四个旋转状态。以4x4矩阵表示。 Shapes new int[,,] { // I 型 { {0,0,0,0}, {1,1,1,1}, {0,0,0,0}, {0,0,0,0} }, { {0,0,1,0}, {0,0,1,0}, {0,0,1,0}, {0,0,1,0} }, // ... 其他旋转状态和其他6种方块J, L, O, S, T, Z的定义 }; } // 获取当前形状矩阵中某个位置是否有方块 public int GetCell(int row, int col) { return Shapes[TypeIndex, Rotation, row, col]; } // 获取当前方块所有“实体”格子在游戏板上的绝对坐标 public ListPoint GetAbsoluteCells() { var cells new ListPoint(); for (int r 0; r 4; r) { for (int c 0; c 4; c) { if (GetCell(r, c) ! 0) { cells.Add(new Point(X c, Y r)); } } } return cells; } }注意这里用4x4矩阵来容纳所有形状对于O型方块2x2会有一些冗余的空行空列但这简化了旋转和碰撞检测的逻辑因为所有形状都使用统一的边界。这是一种常见的空间换时间的取舍。3.2 碰撞检测的实现碰撞检测是游戏逻辑的核心发生在移动、旋转和固定方块之前。主要检查两种碰撞边界碰撞方块是否移出了游戏板的左、右或下边界。方块碰撞方块是否与游戏板上已固定的方块重叠。我通常在TetrisGame类中实现一个IsValidPosition(Tetromino piece, int testX, int testY, int testRotation)方法。private bool IsValidPosition(Tetromino piece, int testX, int testY, int testRotation) { // 临时获取在测试位置和旋转状态下方块的所有格子 var testCells GetCellsForPosition(piece, testX, testY, testRotation); foreach (var cell in testCells) { // 1. 检查是否超出左右下边界 if (cell.X 0 || cell.X BoardWidth || cell.Y BoardHeight) return false; // 注意顶部溢出是允许的新方块生成时可能部分在顶部以上 // 2. 检查是否与已固定的方块重叠 (Y0 时才检查因为Y可能为负) if (cell.Y 0 GameBoard[cell.X, cell.Y] ! 0) return false; } return true; }在移动(MovePiece)或旋转(RotatePiece)操作前先调用此方法检查目标位置是否有效。如果无效则操作被阻止。对于旋转有一个重要的细节墙踢Wall Kick。当方块旋转后因为紧贴墙壁或其它方块而导致位置无效时许多标准俄罗斯方块规则会尝试将方块向左、右或上轻微移动一个单位如果新位置有效则允许旋转并移动。实现墙踢逻辑会稍微复杂一些需要预定义一组针对每种方块和旋转状态的“踢测试”偏移量。对于第一个版本可以先不实现墙踢这会让游戏难度稍高但完全可玩。3.3 方块旋转算法旋转的本质是变换其形状矩阵。对于4x4矩阵绕中心点顺时针旋转90度的坐标变换公式为 新行 原列 新列 3 - 原行 假设矩阵索引从0到3我们可以预先计算好所有方块的所有旋转状态就像上面Shapes数组做的那样这样在运行时旋转就只是改变Rotation索引值效率极高。这就是为什么我选择预定义形状矩阵的原因。public void RotateClockwise() { int newRotation (Rotation 1) % 4; // 假设有4种旋转状态 if (gameEngine.IsValidPosition(this, X, Y, newRotation)) { Rotation newRotation; } else { // 可选在这里实现墙踢逻辑 } }3.4 消行与分数计算当方块被固定(LockPiece)后需要检查是否有行被填满。private int ClearLines() { int linesCleared 0; // 从最底部一行开始向上检查 for (int row BoardHeight - 1; row 0; row--) { bool lineFull true; for (int col 0; col BoardWidth; col) { if (GameBoard[col, row] 0) { lineFull false; break; } } if (lineFull) { linesCleared; // 将这一行以上的所有行向下移动一行 for (int rowToMove row; rowToMove 0; rowToMove--) { for (int col 0; col BoardWidth; col) { GameBoard[col, rowToMove] GameBoard[col, rowToMove - 1]; } } // 最顶行清空 for (int col 0; col BoardWidth; col) { GameBoard[col, 0] 0; } // 因为当前行被清空并下移了需要再次检查同一行现在已经是上一行的内容 row; } } if (linesCleared 0) { // 计算得分一次消除的行数越多得分倍数越高 int scoreToAdd 0; switch (linesCleared) { case 1: scoreToAdd 100 * Level; break; case 2: scoreToAdd 300 * Level; break; case 3: scoreToAdd 500 * Level; break; case 4: scoreToAdd 800 * Level; break; // Tetris! } Score scoreToAdd; Lines linesCleared; // 每消除10行升一级 Level (Lines / 10) 1; // 升级后提高下落速度减少Timer间隔 UpdateGameSpeed(); } return linesCleared; }实操心得消行后“row”这行代码非常关键。因为它让我们在消除一行后能重新检查当前行现在已经是原来上一行的内容避免连续多行消除时出现遗漏。这是新手容易出错的地方。4. 使用WinForms进行图形渲染游戏逻辑是大脑渲染则是面孔。在WinForms中我们通常在自定义控件或Panel的Paint事件中进行绘制。4.1 双缓冲与绘制基础首先为了避免画面闪烁必须开启双缓冲。可以在自定义控件的构造函数中设置this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer, true); this.UpdateStyles();在OnPaint方法中我们拿到Graphics对象进行绘制protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.Clear(Color.Black); // 清空背景 // 1. 绘制已固定的方块 for (int x 0; x gameEngine.BoardWidth; x) { for (int y 0; y gameEngine.BoardHeight; y) { int cellValue gameEngine.GameBoard[x, y]; if (cellValue 0) { DrawBlock(g, x, y, GetColorByValue(cellValue)); } } } // 2. 绘制当前下落的方块半透明或边框以示区别 var currentPiece gameEngine.CurrentPiece; if (currentPiece ! null) { var cells currentPiece.GetAbsoluteCells(); foreach (var cell in cells) { if (cell.Y 0) // 只绘制在板内的部分 { DrawBlock(g, cell.X, cell.Y, GetColorByValue(currentPiece.TypeIndex 1), isCurrent: true); } } } // 3. 绘制网格线可选 DrawGrid(g); }DrawBlock方法负责绘制一个单独的方块。通常有两种风格经典实心风格用FillRectangle填充一个矩形然后用DrawRectangle画一个稍浅或稍深的边框来营造立体感。现代渐变风格使用LinearGradientBrush填充效果更炫酷。private void DrawBlock(Graphics g, int gridX, int gridY, Color baseColor, bool isCurrent false) { int pixelX gridX * BlockSize; int pixelY gridY * BlockSize; Rectangle rect new Rectangle(pixelX, pixelY, BlockSize, BlockSize); // 实心风格示例 using (SolidBrush brush new SolidBrush(isCurrent ? Color.FromArgb(180, baseColor) : baseColor)) // 当前方块半透明 { g.FillRectangle(brush, rect); } // 绘制边框增加立体感 using (Pen lightPen new Pen(Color.FromArgb(100, Color.White))) using (Pen darkPen new Pen(Color.FromArgb(100, Color.Black))) { g.DrawLine(lightPen, rect.Left, rect.Top, rect.Right, rect.Top); g.DrawLine(lightPen, rect.Left, rect.Top, rect.Left, rect.Bottom); g.DrawLine(darkPen, rect.Right, rect.Top, rect.Right, rect.Bottom); g.DrawLine(darkPen, rect.Left, rect.Bottom, rect.Right, rect.Bottom); } }4.2 实现“下一个方块”预览和游戏信息显示除了主游戏区域通常还需要在界面侧边或上方显示下一个方块预览单独绘制一个小区域调用类似DrawBlock的方法绘制gameEngine.NextPiece。分数、行数、等级使用Graphics.DrawString方法在指定位置绘制文本。游戏状态如“Paused”、“Game Over”等提示。这些信息的绘制也应在OnPaint方法中完成确保界面统一更新。5. 游戏循环、输入控制与状态管理5.1 基于Timer的游戏主循环在窗体构造函数或加载事件中初始化游戏引擎和Timerprivate System.Windows.Forms.Timer gameTimer; private TetrisGame gameEngine; public MainForm() { InitializeComponent(); gameEngine new TetrisGame(10, 20); // 10x20 的游戏板 gameTimer new Timer(); gameTimer.Interval 500; // 初始下落间隔500ms gameTimer.Tick GameTimer_Tick; // 开始游戏 gameEngine.StartGame(); gameTimer.Start(); } private void GameTimer_Tick(object sender, EventArgs e) { // 调用引擎的更新方法驱动方块下落 bool gameContinues gameEngine.Update(); // Update方法返回游戏是否继续 if (!gameContinues) { gameTimer.Stop(); MessageBox.Show(Game Over!); } // 请求重绘界面 gamePanel.Invalidate(); }TetrisGame.Update()方法的核心是让当前方块下落一格如果下落失败碰撞则固定方块、消行并生成新方块。如果新方块生成即碰撞则游戏结束。public bool Update() { if (!IsRunning || IsPaused) return true; // 尝试将当前方块下落一格 if (IsValidPosition(CurrentPiece, CurrentPiece.X, CurrentPiece.Y 1, CurrentPiece.Rotation)) { CurrentPiece.Y; return true; } else { // 无法下落固定方块 LockPiece(); // 消行并计分 ClearLines(); // 生成新方块 if (!SpawnNewPiece()) { // 生成即碰撞游戏结束 IsRunning false; return false; } return true; } }5.2 键盘输入控制在窗体的KeyDown事件中处理用户输入private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (!gameEngine.IsRunning) return; switch (e.KeyCode) { case Keys.Left: gameEngine.MovePiece(-1, 0); // 左移 break; case Keys.Right: gameEngine.MovePiece(1, 0); // 右移 case Keys.Up: gameEngine.RotatePiece(); // 旋转 break; case Keys.Down: // 加速下落直接调用多次Update或者实现一个“软降”逻辑 if (gameEngine.IsValidPosition(...下落一格...)) { gameEngine.MovePiece(0, 1); Score 1; // 软降奖励分 } break; case Keys.Space: // 硬降一直落到不能落为止 while (gameEngine.IsValidPosition(...下落一格...)) { gameEngine.MovePiece(0, 1); Score 2; // 硬降奖励分更高 } gameEngine.LockPiece(); // 立即固定 gameEngine.ClearLines(); gameEngine.SpawnNewPiece(); break; case Keys.P: gameEngine.IsPaused !gameEngine.IsPaused; // 暂停/继续 gameTimer.Enabled !gameEngine.IsPaused; break; } gamePanel.Invalidate(); // 每次操作后刷新界面 }注意事项键盘事件处理中特别是加速下落和硬降一定要记得在操作后立即调用Invalidate()重绘否则玩家会感觉操作有延迟。另外为了防止按键连发过快可以考虑记录上一次按键时间做一个简单的输入冷却。5.3 游戏状态管理游戏引擎需要清晰的状态标识IsRunning: 游戏是否正在进行非结束状态。IsPaused: 游戏是否暂停。Score,Lines,Level: 当前分数、消除行数、等级。开始游戏(StartGame)、暂停游戏、继续游戏、结束游戏(GameOver)都应作为公开方法并妥善更新这些状态和Timer的启停。6. 性能优化与进阶技巧当基础功能完成后可以考虑以下优化和增强让你的俄罗斯方块更专业、更流畅。6.1 解决画面闪烁与卡顿即使开启了双缓冲在频繁、大面积重绘时仍可能感到轻微卡顿。优化方法局部重绘在OnPaint中通过e.ClipRectangle获取需要重绘的区域只更新该区域内的方块。这需要更精细的计算。离屏渲染在内存中创建一个和控件一样大的Bitmap所有的绘制操作先在这个Bitmap上进行然后在OnPaint中一次性将这个Bitmap绘制到控件上。这能极大减少闪烁。限制帧率如果使用多线程循环可以控制重绘的频率如60FPS避免不必要的CPU占用。6.2 实现“影子”预览专业俄罗斯方块游戏会显示当前方块的“影子”即如果立即硬降方块会落在哪里。这能极大提升游戏体验。 实现方法在TetrisGame中增加一个方法GetGhostPiece()它复制当前方块然后模拟硬降过程直到碰撞返回这个最终位置的方块副本。在渲染时用半透明或虚线边框的方式绘制这个“影子”方块。6.3 音效与动画音效在消行、旋转、移动、固定方块、游戏结束时使用System.Media.SoundPlayer播放简单的WAV音效能立刻让游戏生动起来。消行动画消行时不要立即删除行可以先将该行的方块颜色闪烁或变白延迟几帧后再执行清除和下落逻辑。这需要引入一个简单的动画状态机。6.4 代码重构与设计模式应用当功能越来越多代码会变得臃肿。可以考虑将渲染逻辑完全分离定义IRenderer接口然后分别实现WinFormsRenderer、ConsoleRenderer等。使用状态模式管理游戏状态将“进行中”、“暂停中”、“已结束”等状态抽象成不同的类每个类负责处理该状态下的输入和更新逻辑。这使状态转换更清晰。使用工厂模式创建方块集中管理七种方块的创建逻辑。7. 常见问题与调试技巧实录在开发过程中你几乎一定会遇到下面这些问题。这里是我的排查记录和解决方案。7.1 方块旋转时“卡”进墙里或现有方块里问题描述方块在靠近边界或其它方块时旋转结果部分单元格穿墙而过或与固定方块重叠。根本原因旋转后的新形状其包围盒Bounding Box的左上角坐标没变但形状本身可能向左或向上“伸展”了导致部分单元格坐标超出边界或与已有方块冲突。解决方案实现墙踢Wall Kick这是标准解决方案。你需要为每种方块类型定义一组“踢测试”向量。当旋转失败时依次尝试将这些向量加到方块位置上进行测试第一个成功的位置即作为旋转后的新位置。SRSSuper Rotation System是官方标准其踢表可以在网上找到。简化版处理如果不想实现复杂的墙踢可以在IsValidPosition中对于旋转检测允许方块的Y坐标临时为负数即部分单元格在游戏板顶部以上但X坐标必须在边界内且不能与固定方块重叠。这能解决大部分顶部旋转问题但对侧边旋转帮助有限。7.2 游戏速度随等级提高变得过快或失控问题描述使用Timer时Interval值设置过小比如小于15ms会导致Timer事件堆积游戏逻辑更新过快甚至导致界面无响应。解决方案不要将Interval设置为小于一个合理阈值比如50ms。更好的方法是在Update方法中根据等级计算出一个“下落间隔帧数”。在Timer的固定间隔如50ms触发时累积一个帧计数器。只有当计数器超过“下落间隔帧数”时才执行一次下落逻辑。这样Timer的间隔固定游戏速度由逻辑帧控制更稳定。private int fallDelayFrames 10; // 下落一格需要的帧数 private int frameCounter 0; private void GameTimer_Tick(object sender, EventArgs e) { frameCounter; if (frameCounter fallDelayFrames) { gameEngine.Update(); frameCounter 0; } // ... 其他每帧都执行的逻辑如输入响应 gamePanel.Invalidate(); }升级时只需减少fallDelayFrames的值即可。7.3 键盘响应不灵敏或过于灵敏问题描述按住方向键时移动一下后停顿然后才开始连续移动或者移动速度太快难以控制。解决方案Windows键盘重复延迟和重复速度是系统设置。为了获得更好的游戏手感我们需要自己实现按键状态跟踪。在类中定义字段记录每个关心按键的按下状态和按下时间。private DictionaryKeys, bool keyStates new DictionaryKeys, bool(); private DictionaryKeys, DateTime keyPressTime new DictionaryKeys, DateTime(); private const int initialRepeatDelay 300; // 首次重复延迟(ms) private const int repeatInterval 50; // 重复间隔(ms)在KeyDown事件中设置按键状态为true并记录时间。在KeyUp事件中设置按键状态为false。在游戏主循环Timer Tick中检查那些处于按下状态的键。如果按下时间超过initialRepeatDelay则每隔repeatInterval执行一次对应的移动操作。这样就能实现按下立即响应一次短暂延迟后开始连续、匀速的移动。7.4 游戏界面在最小化或失去焦点后逻辑混乱问题描述游戏进行中如果切换到其他窗口Timer可能仍在运行但键盘输入失效回来时游戏状态可能异常。解决方案在窗体的Deactivate失去焦点事件中自动暂停游戏。private void MainForm_Deactivate(object sender, EventArgs e) { if (gameEngine.IsRunning !gameEngine.IsPaused) { gameEngine.IsPaused true; gameTimer.Stop(); } }在Activated获得焦点事件中可以提示用户或自动继续。同时在窗体重新激活时最好调用一次Invalidate()强制重绘避免画面残留。从零开始用C#实现俄罗斯方块是一个充满乐趣和挑战的过程。它强迫你从全局思考架构从细节处理碰撞在调试中理解状态。当你看到自己亲手编写的方块流畅下落、旋转、消行并伴随着自己添加的音效时那种成就感是调用现成引擎API无法比拟的。这个项目代码量不大但涵盖的知识点非常密集是检验和提升你C#综合能力的绝佳试金石。我建议你在完成基础版本后不断挑战自己加上分数排行榜、多种游戏模式、更华丽的皮肤和特效甚至尝试用WPF或Avalonia重写一遍。每一次重构和功能添加都会让你对软件设计和C#的理解更深一层。
返回列表