1. 项目概述:从零到一构建一个经典的桌面游戏
俄罗斯方块,这个诞生于上世纪80年代的经典游戏,几乎刻进了每一个游戏玩家的DNA。它规则简单,上手容易,但策略深度和挑战性却丝毫不减。对于很多C#开发者,尤其是刚接触WinForm桌面开发的朋友来说,亲手实现一个俄罗斯方块,就像是一场“成人礼”。它不像“Hello World”那样简单,也不像大型商业游戏那样复杂,它恰到好处地涵盖了游戏开发的核心循环:输入处理、游戏逻辑更新、图形渲染,以及状态管理。通过这个项目,你能把C#的面向对象思想、WinForm的控件与绘图机制、以及多线程(或计时器)控制等知识点串联起来,形成一个完整的、可运行的作品,成就感直接拉满。
我当年就是从俄罗斯方块入门的游戏逻辑编写,踩过不少坑,也总结了很多让代码更清晰、更易维护的经验。今天,我就以一名老开发者的视角,带你从头开始,用C# WinForm搭建一个功能完整、代码结构清晰的俄罗斯方块游戏。我们不止要实现“能玩”,更要追求“代码优雅”和“易于扩展”。你会发现,把游戏逻辑(Model)和界面显示(View)分离,会让后续的调试和功能添加变得无比轻松。整个项目我们将采用经典的MVC(模型-视图-控制器)思想来架构,虽然WinForm并非为MVC而生,但我们可以借鉴其分层理念。准备好你的Visual Studio,我们这就开始。
2. 核心架构设计与模块拆解
在动手写代码之前,花点时间设计好架构是绝对值得的。一个混乱的俄罗斯方块项目,后期添加一个“下一个方块预览”功能都可能让你头疼不已。我们的目标是高内聚、低耦合。
2.1 为什么选择模型与视图分离?
很多新手教程喜欢把所有代码——方块数据、移动逻辑、绘图——全部塞进Form1.cs的Paint事件里。这样做虽然快,但后果很严重:代码像一团乱麻,调试时你根本分不清是逻辑算错了坐标,还是绘图画错了地方。一旦你想换一种渲染方式(比如用OpenGL),或者移植到WPF,几乎需要重写所有代码。
因此,我们的第一个核心决策就是:将游戏的核心数据与逻辑(Model)和图形界面绘制(View)彻底分离。Model只关心“游戏是什么状态”,比如当前下落的是什么形状的方块、它位于网格的哪个位置、已经固定的方块堆是什么样子、分数是多少。它完全不知道WinForm、Graphics对象或者PictureBox控件的存在。View则负责根据Model提供的数据,在屏幕上画出相应的图形。两者之间通过一个“控制器”(Controller)来协调,控制器负责接收键盘事件,调用Model的方法改变游戏状态,并通知View刷新。
这样做的好处显而易见:
- 可测试性:你可以单独为Model编写单元测试,模拟各种移动和旋转,验证逻辑是否正确,无需启动窗体。
- 可维护性:修改游戏规则(如旋转算法)只需改动Model;修改界面皮肤只需改动View。
- 可移植性:未来若想改用WPF、Avalonia甚至控制台来显示,只需重写View层,Model和大部分Controller逻辑可以复用。
2.2 项目模块划分与职责
基于以上思想,我们将项目划分为以下几个核心模块(在Visual Studio中对应不同的类文件):
GameGrid(游戏网格模型):- 职责:代表游戏底部的“棋盘”。它是一个二维数组(或二维列表),记录每个格子是否被占据,以及被什么颜色的方块占据。
- 核心方法:
IsValidPosition(判断方块位置是否合法)、PlaceBlock(将方块固定到网格)、ClearLines(检测并消除满行)。
Tetromino(方块模型):- 职责:代表七种不同形状的方块(I, J, L, O, S, T, Z)。它封装了方块的形状数据(一个小的二维矩阵)、当前旋转状态、颜色以及位置(左上角坐标)。
- 核心方法:
Rotate(旋转,返回新的形状矩阵)、GetCells(获取方块所占的所有网格坐标)。
GameState(游戏状态模型):- 职责:这是游戏的“大脑”和“数据中心”。它持有
GameGrid、当前下落的Tetromino、下一个Tetromino、分数、等级、游戏是否结束等所有状态。 - 核心方法:
MoveBlockLeft/Right/Down(移动方块)、RotateBlock(旋转方块)、DropBlock(方块硬降)、Update(游戏主更新逻辑,处理自动下落和锁定)。
- 职责:这是游戏的“大脑”和“数据中心”。它持有
GameRenderer(游戏渲染器/视图):- 职责:负责将
GameState中的数据可视化。它接收一个Graphics对象,在指定的区域(如一个Panel或PictureBox)内绘制网格、当前方块、下一个方块预览、分数等信息。 - 核心方法:
Draw(Graphics g, GameState gameState)。
- 职责:负责将
MainForm(主窗体/控制器):- 职责:作为WinForm应用程序的入口和总协调者。
- 它包含:一个用于绘图的
Panel或PictureBox,一个显示下一个方块的区域,以及分数、等级等标签。 - 它负责:初始化
GameState和GameRenderer;启动一个Timer来驱动游戏循环;监听键盘事件并调用GameState的相应方法;在Timer.Tick或绘图事件中调用GameRenderer.Draw来刷新界面。
注意:在实际编码中,
GameRenderer的绘制工作通常在MainForm的Paint事件处理程序或Panel的自定义绘制中调用。Timer用于控制游戏自动更新的节奏(如下落速度),而键盘事件是即时响应的。
3. 核心模型类的详细实现
理论说完了,我们开始敲代码。先从最底层、最纯粹的数据模型开始。
3.1 构建游戏网格:GameGrid类
网格是游戏的基础,我们用一个二维数组来表示。数组的每个元素可以是一个整数(代表颜色索引)或一个自定义的Cell结构体,这里为了简单,我们用int,0表示空,非0表示有方块(其值可对应颜色)。
public class GameGrid { private readonly int[,] grid; // 行,列 public int Rows { get; } public int Columns { get; } // 索引器,方便访问 public int this[int row, int column] { get => grid[row, column]; set => grid[row, column] = value; } public GameGrid(int rows, int columns) { Rows = rows; Columns = columns; grid = new int[rows, columns]; } // 判断给定行列是否在网格范围内 public bool IsInside(int row, int column) { return row >= 0 && row < Rows && column >= 0 && column < Columns; } // 判断格子是否为空(为0) public bool IsEmpty(int row, int column) { return IsInside(row, column) && grid[row, column] == 0; } // 判断一行是否已满 public bool IsRowFull(int row) { for (int col = 0; col < Columns; col++) { if (IsEmpty(row, col)) { return false; } } return true; } // 判断一行是否完全为空(用于优化) public bool IsRowEmpty(int row) { for (int col = 0; col < Columns; col++) { if (!IsEmpty(row, col)) { return false; } } return true; } // 清除一行,并将上面的所有行下移 private void ClearRow(int row) { for (int col = 0; col < Columns; col++) { grid[row, col] = 0; } } private void MoveRowDown(int row, int numRows) { for (int col = 0; col < Columns; col++) { grid[row + numRows, col] = grid[row, col]; grid[row, col] = 0; } } // 清除所有满行,并返回清除的行数(用于计分) public int ClearFullRows() { int cleared = 0; // 从底部开始扫描 for (int row = Rows - 1; row >= 0; row--) { if (IsRowFull(row)) { ClearRow(row); cleared++; } else if (cleared > 0) { // 如果已经清除过上面的行,当前行需要下移 MoveRowDown(row, cleared); } } return cleared; } }关键点解析:
ClearFullRows方法是消除逻辑的核心。它从最底部开始向上扫描,遇到满行就标记清除(cleared++),但并不立即下移。当遇到非满行时,如果之前已经清除过行(cleared > 0),则将该行向下移动cleared行。这个算法只需要遍历网格一次,效率很高。IsInside和IsEmpty是后续进行碰撞检测的基础。
3.2 定义俄罗斯方块:Tetromino类
七种方块,每种有1-4种旋转状态。我们可以用一个小型矩阵(如4x4)来定义每种形状。矩阵中非0的位置代表方块的一部分。
public class Tetromino { // 方块类型枚举 public enum TetrominoType { I, J, L, O, S, T, Z } // 方块数据:一个三维数组 [方块类型, 旋转状态, 行, 列] private static readonly int[,,] _tetrominoShapes = new int[,,] { // I { {0,0,0,0, 1,1,1,1, 0,0,0,0, 0,0,0,0}, // 状态0 {0,0,1,0, 0,0,1,0, 0,0,1,0, 0,0,1,0}, // 状态1 {0,0,0,0, 0,0,0,0, 1,1,1,1, 0,0,0,0}, // 状态2 {0,1,0,0, 0,1,0,0, 0,1,0,0, 0,1,0,0} // 状态3 }, // J, L, O, S, T, Z 的定义类似,此处省略... }; // 方块颜色,与类型对应 private static readonly Color[] _tetrominoColors = new Color[] { Color.Cyan, // I Color.Blue, // J Color.Orange, // L Color.Yellow, // O Color.Green, // S Color.Purple, // T Color.Red // Z }; public TetrominoType Type { get; } public int RotationState { get; private set; } public int RowOffset { get; set; } // 方块在游戏网格中的行偏移 public int ColOffset { get; set; } // 方块在游戏网格中的列偏移 public Color Color => _tetrominoColors[(int)Type]; public Tetromino(TetrominoType type) { Type = type; RowOffset = 0; ColOffset = Columns / 2 - 2; // 初始位置:顶部中间 RotationState = 0; } // 获取当前旋转状态下的形状矩阵(4x4) public int[,] GetCurrentShape() { int[,] shape = new int[4, 4]; int typeIndex = (int)Type; for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { // 从一维数据中映射到二维矩阵 shape[r, c] = _tetrominoShapes[typeIndex, RotationState, r * 4 + c]; } } return shape; } // 获取方块所占的所有有效格子在游戏网格中的坐标 public IEnumerable<(int row, int column)> GetCellPositions() { int[,] shape = GetCurrentShape(); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (shape[r, c] != 0) { yield return (r + RowOffset, c + ColOffset); } } } } // 旋转方块,返回旋转后的新偏移(用于墙踢检测) public void RotateClockwise() { RotationState = (RotationState + 1) % 4; // 假设每种方块有4种旋转状态 } public void RotateCounterClockwise() { RotationState = (RotationState + 3) % 4; // 加3等同于减1取模 } // 移动方法 public void Move(int deltaRow, int deltaCol) { RowOffset += deltaRow; ColOffset += deltaCol; } }实操心得与避坑指南:
- 形状数据存储:上面用了“展平”的一维数组存储在三维数组中,是为了初始化方便。你也可以直接用
int[,,,]四维数组,或者为每种方块单独定义int[,]数组的列表。选择哪种方式取决于个人习惯。 - “墙踢”机制:标准的俄罗斯方块有复杂的“墙踢”规则,即当旋转后位置被阻挡时,系统会尝试将方块向左、右、上或下微调一个单位,寻找一个可放置的位置。我们这里实现的
Rotate方法只是改变了旋转状态,真正的墙踢检测和位置调整逻辑应该放在GameState中,结合GameGrid的碰撞检测来实现。这是一个进阶点,初期可以简化。 - 初始位置:
ColOffset = Columns / 2 - 2是假设网格宽度(Columns)已知,且方块形状矩阵是4x4。这是一个通用计算,确保方块大致出现在顶部中央。
3.3 整合游戏状态:GameState类
这个类是游戏逻辑的核心,它协调网格、当前方块和玩家操作。
public class GameState { public GameGrid Grid { get; } public Tetromino CurrentBlock { get; private set; } public Tetromino NextBlock { get; private set; } public bool GameOver { get; private set; } public int Score { get; private set; } public int Level { get; private set; } public int LinesCleared { get; private set; } // 方块自动下落计时器(由外部Timer驱动,这里记录时间或帧数) // 我们用一个计数器来实现,每N次更新下落一格,N随等级减小 private int _autoDropCounter; private int _autoDropDelay => Math.Max(1, 10 - Level); // 等级越高,延迟越小,下落越快 public GameState(int gridRows = 20, int gridColumns = 10) { Grid = new GameGrid(gridRows, gridColumns); // 初始化第一个和下一个方块 NextBlock = new Tetromino(GetRandomType()); CurrentBlock = new Tetromino(GetRandomType()); SpawnNewBlock(); // 生成当前方块并检查游戏是否结束 Score = 0; Level = 1; LinesCleared = 0; } private Tetromino.TetrominoType GetRandomType() { // 简单随机,更好的做法是使用“7-Bag”随机生成器,保证每7个方块不重复 var values = Enum.GetValues(typeof(Tetromino.TetrominoType)); return (Tetromino.TetrominoType)values.GetValue(new Random().Next(values.Length)); } // 生成新的当前方块,并从NextBlock取下一个 private void SpawnNewBlock() { CurrentBlock = NextBlock; NextBlock = new Tetromino(GetRandomType()); CurrentBlock.RowOffset = 0; CurrentBlock.ColOffset = Grid.Columns / 2 - 2; // 生成即检查是否与已有方块重叠(游戏结束条件) if (!BlockFits()) { GameOver = true; } } // 核心碰撞检测:判断当前方块位置是否合法 private bool BlockFits() { foreach (var (row, col) in CurrentBlock.GetCellPositions()) { if (!Grid.IsInside(row, col) || !Grid.IsEmpty(row, col)) { return false; } } return true; } // 将当前方块固定到网格 private void PlaceBlock() { foreach (var (row, col) in CurrentBlock.GetCellPositions()) { // 这里简单用方块类型的枚举值作为颜色索引存入网格 Grid[row, col] = (int)CurrentBlock.Type + 1; // +1避免0值冲突 } // 放置后,尝试消除行并计分 int lines = Grid.ClearFullRows(); if (lines > 0) { UpdateScore(lines); LinesCleared += lines; Level = (LinesCleared / 10) + 1; // 每消除10行升一级 } // 生成新方块 SpawnNewBlock(); } // 计分规则 private void UpdateScore(int linesCleared) { switch (linesCleared) { case 1: Score += 100 * Level; break; case 2: Score += 300 * Level; break; case 3: Score += 500 * Level; break; case 4: // Tetris! Score += 800 * Level; break; } } // 游戏主更新逻辑(由Timer定期调用) public void Update() { if (GameOver) return; _autoDropCounter++; if (_autoDropCounter >= _autoDropDelay) { MoveBlockDown(); _autoDropCounter = 0; } } // 玩家操作:移动 public bool MoveBlockLeft() { CurrentBlock.Move(0, -1); if (!BlockFits()) { CurrentBlock.Move(0, 1); // 回退 return false; } return true; } public bool MoveBlockRight() { CurrentBlock.Move(0, 1); if (!BlockFits()) { CurrentBlock.Move(0, -1); return false; } return true; } public bool MoveBlockDown() { CurrentBlock.Move(1, 0); if (!BlockFits()) { CurrentBlock.Move(-1, 0); // 回退,意味着触底 PlaceBlock(); // 固定方块 return false; // 移动失败,方块已固定 } return true; } // 玩家操作:旋转(简化版,无墙踢) public bool RotateBlockClockwise() { CurrentBlock.RotateClockwise(); if (!BlockFits()) { CurrentBlock.RotateCounterClockwise(); // 回退 return false; } return true; } // 玩家操作:硬降 public void DropBlock() { while (MoveBlockDown()) { } // MoveBlockDown在触底时会自动调用PlaceBlock } }核心逻辑详解:
- 游戏循环:
Update方法模拟了游戏循环中的“更新”步骤。它由一个外部的System.Windows.Forms.Timer(或更精确的System.Timers.Timer)定期调用。每次更新,_autoDropCounter累加,达到_autoDropDelay后,方块自动下落一格。_autoDropDelay随等级提高而减小,实现加速。 - 碰撞检测:
BlockFits方法是所有移动和旋转操作的安全阀。它在操作后立即检查新位置是否合法(在网格内且不重叠)。不合法则回退操作。 - 状态转移:
MoveBlockDown是核心中的核心。当下落失败(触底)时,它回退一步并调用PlaceBlock将方块固化到网格,然后生成新方块。这个设计非常清晰地将“移动”和“固化”两个状态分开。 - 计分与升级:计分规则是经典俄罗斯方块的规则,消除行数越多,单行得分系数越高,并且分数会乘以当前等级。升级逻辑基于消除的总行数。
4. WinForm界面与渲染器的实现
模型准备好了,现在我们需要一个窗口来展示它。我们将创建一个主窗体,并实现一个渲染器来绘制游戏画面。
4.1 设计主窗体界面
在Visual Studio中,新建一个Windows窗体应用项目。在默认的Form1上(建议重命名为MainForm),拖放以下控件:
- 一个
Panel控件:命名为gamePanel。它将作为游戏主画面的绘制区域。将其BackColor设为深色(如Color.FromArgb(30, 30, 46))。 - 一个
PictureBox或另一个Panel:命名为nextBlockPanel。用于预览下一个方块。尺寸可以小一些。 - 几个
Label控件:用于显示Score、Level、Lines等信息。 - 一个
Button:用于开始/暂停/重新开始游戏。 - 一个
Timer控件:从工具箱拖放一个Timer到窗体上,命名为gameTimer。设置其Interval属性为50(毫秒),这决定了游戏更新的频率。Enabled属性先设为False。
调整布局,使其看起来像一个游戏界面。一个简单的布局可以是:左侧大区域是gamePanel,右侧上方是nextBlockPanel和分数信息,下方是控制按钮。
4.2 实现游戏渲染器GameRenderer
这个类负责将GameState对象“画”出来。它不依赖于特定控件,只接受一个Graphics对象和绘制区域。
public class GameRenderer { // 每个格子的像素大小 public int CellSize { get; set; } = 30; // 绘制游戏主区域 public void Draw(Graphics g, GameState gameState, Rectangle drawArea) { // 1. 绘制背景 g.FillRectangle(Brushes.Black, drawArea); // 计算网格绘制的起始位置,使其居中 int gridWidth = gameState.Grid.Columns * CellSize; int gridHeight = gameState.Grid.Rows * CellSize; int offsetX = (drawArea.Width - gridWidth) / 2; int offsetY = (drawArea.Height - gridHeight) / 2; // 2. 绘制已固定的方块堆 DrawGrid(g, gameState.Grid, offsetX, offsetY); // 3. 绘制当前正在下落的方块(半透明,增强视觉效果) DrawCurrentBlock(g, gameState.CurrentBlock, offsetX, offsetY); // 4. 绘制网格线(可选,有助于看清格子) DrawGridLines(g, gameState.Grid, offsetX, offsetY); } private void DrawGrid(Graphics g, GameGrid grid, int offsetX, int offsetY) { for (int row = 0; row < grid.Rows; row++) { for (int col = 0; col < grid.Columns; col++) { int cellValue = grid[row, col]; if (cellValue > 0) { // 根据存储的值获取对应的方块颜色 Color blockColor = GetColorByIndex(cellValue); RectangleF cellRect = new RectangleF( offsetX + col * CellSize, offsetY + row * CellSize, CellSize, CellSize); // 绘制带立体感的方块 DrawBlockCell(g, cellRect, blockColor); } } } } private void DrawCurrentBlock(Graphics g, Tetromino block, int offsetX, int offsetY) { Color blockColor = block.Color; // 设置半透明效果 using (Brush semiTransBrush = new SolidBrush(Color.FromArgb(180, blockColor))) { foreach (var (row, col) in block.GetCellPositions()) { RectangleF cellRect = new RectangleF( offsetX + col * CellSize, offsetY + row * CellSize, CellSize, CellSize); // 当前下落方块用半透明色填充,或者用另一种风格绘制边框 g.FillRectangle(semiTransBrush, cellRect); g.DrawRectangle(new Pen(Color.White, 2), Rectangle.Round(cellRect)); } } } private void DrawGridLines(Graphics g, GameGrid grid, int offsetX, int offsetY) { using (Pen gridPen = new Pen(Color.FromArgb(50, 255, 255, 255), 1)) // 浅灰色细线 { // 画竖线 for (int col = 0; col <= grid.Columns; col++) { int x = offsetX + col * CellSize; g.DrawLine(gridPen, x, offsetY, x, offsetY + grid.Rows * CellSize); } // 画横线 for (int row = 0; row <= grid.Rows; row++) { int y = offsetY + row * CellSize; g.DrawLine(gridPen, offsetX, y, offsetX + grid.Columns * CellSize, y); } } } // 绘制单个方块格子,增加立体感 private void DrawBlockCell(Graphics g, RectangleF rect, Color baseColor) { // 填充主体 using (Brush mainBrush = new SolidBrush(baseColor)) { g.FillRectangle(mainBrush, rect); } // 绘制高光(左上)和阴影(右下),增强立体感 Color lightColor = Color.FromArgb(100, 255, 255, 255); Color darkColor = Color.FromArgb(100, 0, 0, 0); // 左上高光 g.DrawLine(new Pen(lightColor, 2), rect.Left, rect.Top, rect.Right, rect.Top); g.DrawLine(new Pen(lightColor, 2), rect.Left, rect.Top, rect.Left, rect.Bottom); // 右下阴影 g.DrawLine(new Pen(darkColor, 2), rect.Right, rect.Top, rect.Right, rect.Bottom); g.DrawLine(new Pen(darkColor, 2), rect.Left, rect.Bottom, rect.Right, rect.Bottom); } private Color GetColorByIndex(int index) { // 索引与TetrominoType枚举对应 var colors = new Color[] { Color.Cyan, Color.Blue, Color.Orange, Color.Yellow, Color.Green, Color.Purple, Color.Red }; if (index > 0 && index <= colors.Length) { return colors[index - 1]; } return Color.Gray; // 默认颜色 } // 绘制下一个方块预览 public void DrawNextBlock(Graphics g, Tetromino nextBlock, Rectangle drawArea) { g.Clear(Color.Black); if (nextBlock == null) return; // 计算预览方块绘制的中心位置 int[,] shape = nextBlock.GetCurrentShape(); int blockWidth = 0, blockHeight = 0; // 简单计算形状的边界(非零单元格的范围) // 这里简化处理,假设预览区域足够大,直接按4x4居中绘制 int cellSize = Math.Min(drawArea.Width, drawArea.Height) / 6; // 预览格子小一些 int offsetX = (drawArea.Width - 4 * cellSize) / 2; int offsetY = (drawArea.Height - 4 * cellSize) / 2; for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (shape[r, c] != 0) { RectangleF cellRect = new RectangleF( offsetX + c * cellSize, offsetY + r * cellSize, cellSize, cellSize); DrawBlockCell(g, cellRect, nextBlock.Color); } } } } }绘图技巧与注意事项:
- 双缓冲:直接在
Panel的Paint事件中绘图,如果画面复杂或更新频繁,可能会出现闪烁。WinForm提供了简单的双缓冲支持。你可以在MainForm的构造函数中设置this.DoubleBuffered = true;,或者为gamePanel设置DoubleBuffered属性(需要通过反射或在自定义控件中设置)。更高级的做法是使用BufferedGraphics。 - 绘制优化:
DrawGrid方法遍历了整个网格(20x10=200格)。在每次绘制时,我们可以只绘制发生变化的区域(脏矩形),但对于这个规模,全量重绘性能完全足够。如果网格很大,就需要考虑优化。 - 颜色管理:我们将方块类型索引(+1)存入网格,渲染时再映射回颜色。这保证了数据与显示的分离。你也可以直接存储
Color,但int更节省内存且易于序列化。
4.3 连接一切:MainForm的逻辑
最后,我们在主窗体中将所有部分串联起来。
public partial class MainForm : Form { private GameState _gameState; private GameRenderer _renderer; private bool _isPaused = false; public MainForm() { InitializeComponent(); this.DoubleBuffered = true; // 启用双缓冲减少闪烁 gamePanel.Paint += GamePanel_Paint; nextBlockPanel.Paint += NextBlockPanel_Paint; this.KeyDown += MainForm_KeyDown; this.KeyPreview = true; // 确保窗体能接收到按键事件 gameTimer.Interval = 50; // 20 FPS gameTimer.Tick += GameTimer_Tick; _renderer = new GameRenderer(); StartNewGame(); } private void StartNewGame() { _gameState = new GameState(); _isPaused = false; gameTimer.Start(); UpdateInfoLabels(); // 强制重绘 gamePanel.Invalidate(); nextBlockPanel.Invalidate(); } private void GameTimer_Tick(object sender, EventArgs e) { if (!_isPaused && !_gameState.GameOver) { _gameState.Update(); // 驱动游戏逻辑更新(主要是自动下落) UpdateInfoLabels(); gamePanel.Invalidate(); // 请求重绘游戏区域 nextBlockPanel.Invalidate(); // 请求重绘下一个方块预览 } else if (_gameState.GameOver) { gameTimer.Stop(); MessageBox.Show("游戏结束!最终分数:" + _gameState.Score); } } private void GamePanel_Paint(object sender, PaintEventArgs e) { if (_gameState != null) { _renderer.Draw(e.Graphics, _gameState, gamePanel.ClientRectangle); } } private void NextBlockPanel_Paint(object sender, PaintEventArgs e) { if (_gameState != null && _gameState.NextBlock != null) { _renderer.DrawNextBlock(e.Graphics, _gameState.NextBlock, nextBlockPanel.ClientRectangle); } } private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (_gameState == null || _gameState.GameOver) return; switch (e.KeyCode) { case Keys.Left: _gameState.MoveBlockLeft(); break; case Keys.Right: _gameState.MoveBlockRight(); break; case Keys.Down: _gameState.MoveBlockDown(); // 软降 break; case Keys.Up: _gameState.RotateBlockClockwise(); break; case Keys.Space: _gameState.DropBlock(); // 硬降 break; case Keys.P: _isPaused = !_isPaused; pauseButton.Text = _isPaused ? "继续" : "暂停"; break; case Keys.R: StartNewGame(); break; } // 任何按键操作后都需要刷新画面 gamePanel.Invalidate(); nextBlockPanel.Invalidate(); UpdateInfoLabels(); e.Handled = true; // 阻止系统处理某些键(如方向键滚动窗体) } private void UpdateInfoLabels() { if (_gameState != null) { lblScore.Text = $"分数: {_gameState.Score}"; lblLevel.Text = $"等级: {_gameState.Level}"; lblLines.Text = $"消除行: {_gameState.LinesCleared}"; } } private void pauseButton_Click(object sender, EventArgs e) { _isPaused = !_isPaused; pauseButton.Text = _isPaused ? "继续" : "暂停"; } private void restartButton_Click(object sender, EventArgs e) { StartNewGame(); } }关键事件与线程安全:
- 游戏循环:
gameTimer_Tick是游戏的心脏,它每隔50毫秒跳动一次,驱动GameState.Update(),然后调用Invalidate()请求重绘。注意,Timer的Tick事件是在UI线程上执行的,所以我们可以安全地更新UI控件(如Label)和调用Invalidate()。 - 绘图事件:
Paint事件的处理程序GamePanel_Paint和NextBlockPanel_Paint会在控件需要重绘时被调用(例如窗口被遮挡后恢复、调用Invalidate()后)。在这里,我们调用渲染器进行绘制。永远不要在Paint事件外部获取Graphics对象进行绘制,因为那是临时的。 - 键盘控制:我们监听了窗体的
KeyDown事件。设置KeyPreview = true确保窗体先于内部控件接收到按键。在事件处理程序中,我们根据按键调用GameState的相应方法,然后立即Invalidate()以刷新画面,给玩家即时的反馈。
5. 进阶优化与功能扩展
一个基础版本已经完成了。但要让游戏更专业、体验更好,我们还可以做很多工作。
5.1 实现“下一个方块”预览与“暂存”功能
“下一个方块”预览我们已经实现了。而“暂存”(Hold)功能允许玩家将当前方块存起来,以后随时换出。这需要在GameState中增加一个Tetromino HeldBlock属性和一个bool CanHold标志(防止连续暂存)。操作逻辑是:按某个键(如C键)时,如果CanHold为真,则将当前方块与暂存方块交换(如果暂存为空,则直接生成新方块),并将CanHold设为假,直到当前方块被固定后才重置为真。界面渲染上,需要增加一个区域来绘制HeldBlock。
5.2 改进随机生成器:“7-Bag”算法
完全随机生成方块可能导致长时间不出长条(I),或者连续出同一个方块,影响游戏体验。专业俄罗斯方块使用“7-Bag”算法:将7种方块放入一个“袋子”,随机打乱后依次取出,取完后再重新装满袋子并打乱。这保证了在短期内每种方块出现次数均衡,长期来看又是随机的。你可以修改GameState中的GetRandomType方法来实现它。
5.3 添加音效与动画
- 音效:可以使用
System.Media.SoundPlayer播放简单的WAV音效,在消除行、方块固定、旋转等时机触发。 - 消除行动画:当一行被消除时,不要立即清除,可以先让该行的格子闪烁几次(比如改变颜色或透明度),然后再执行清除和下移逻辑。这需要在
GameState中增加一个状态(如List<int> _linesToClear)和计时器,在Update和Draw方法中处理动画逻辑。
5.4 使用更精确的计时器与游戏循环
System.Windows.Forms.Timer精度较低,且其Tick事件在UI线程执行,如果逻辑复杂可能导致界面卡顿。对于更专业的游戏循环,可以考虑:
- 使用
System.Timers.Timer或System.Threading.Timer:它们在独立的线程触发事件,你需要通过Control.Invoke来更新UI。 - 使用游戏循环线程:创建一个独立的线程,在循环中计算时间差(deltaTime),更新游戏状态,并通过
BeginInvoke请求UI线程重绘。这是游戏开发的常见模式,能提供更稳定流畅的帧率。
5.5 界面美化与自定义控件
- 自定义
GamePanel控件:继承自Panel或Control,在其内部封装GameState和GameRenderer,提供Start、Pause等方法。这样可以将游戏逻辑更好地封装,便于复用。 - 使用双缓冲绘图:在自定义控件中,可以重写
OnPaintBackground方法并留空,在OnPaint中完成所有绘制,并设置SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true);来获得最佳的双缓冲效果,彻底消除闪烁。 - 更换皮肤:让
GameRenderer支持不同的颜色主题或纹理图片,玩家可以切换。
6. 调试技巧与常见问题排查
开发过程中,你肯定会遇到各种Bug。这里分享一些我踩过的坑和解决方法。
6.1 方块“穿墙”或重叠
- 症状:方块可以移动到网格外,或者与已固定的方块重叠。
- 排查:首先检查
BlockFits方法。确保它在检查坐标时,先判断IsInside,再判断IsEmpty。顺序反了会导致数组越界异常。打印出方块尝试移动后的所有格子坐标,看看是否在网格内且对应网格值为0。
6.2 旋转中心点不对或形状错误
- 症状:方块旋转后形状奇怪,或者不是绕着重心旋转。
- 排查:检查
_tetrominoShapes数据定义。每个形状的4种旋转状态数据必须准确。一个技巧是:用纸笔画一个4x4格子,把方块填进去,1表示有,0表示无,然后手动旋转,记录下每个状态的矩阵。对于O型方块(正方形),四种旋转状态应该是一样的。
6.3 游戏速度不受控制或忽快忽慢
- 症状:方块下落速度与设定的
Timer.Interval不符,或者随着游戏进行没有加速。 - 排查:
- 检查
gameTimer的Interval是否设置正确(单位是毫秒)。 - 检查
GameState.Update()中的_autoDropDelay计算逻辑。确保Level随着行数消除正确增加。 - 确保
_autoDropCounter在每次成功下落一格后重置为0,而不是累加。
- 检查
6.4 界面闪烁严重
- 症状:游戏画面在更新时频繁闪烁。
- 解决:
- 为主窗体设置
DoubleBuffered = true。 - 如果使用
Panel,考虑创建一个继承自Panel的自定义控件,并在其构造函数中设置双缓冲样式(如前所述)。 - 确保在
Paint事件中进行所有绘制,不要在别处直接绘图。 - 减少不必要的
Invalidate()调用。例如,可以在GameTimer_Tick中只调用一次gamePanel.Invalidate(),而不是在每次按键后都调用(尽管本例中这样做问题不大)。
- 为主窗体设置
6.5 按键响应不灵敏或冲突
- 症状:按住方向键不放,方块移动不连续;或者按键后没反应。
- 解决:
- 连续移动:
KeyDown事件在按键按住时只会触发一次,然后停顿一下再开始重复。要实现按住连续移动,需要借助KeyPress事件或者自己用Timer来检测按键状态。一个简单方法是:在KeyDown中开始移动并启动一个Timer(比如间隔100ms),在KeyUp中停止这个Timer。 - 按键冲突:检查
KeyDown事件中是否对e.Handled进行了正确的设置。某些系统按键(如Alt)可能会被窗体优先处理。 - 确保窗体
KeyPreview属性为true。
- 连续移动:
6.6 内存泄漏问题
- 症状:游戏运行一段时间后变卡。
- 排查:在绘图代码中,
Pen、Brush、Font等GDI+对象是托管代码,但封装了非托管资源。必须在使用后Dispose。最佳实践是使用using语句块。
在// 正确做法 using (Pen myPen = new Pen(Color.Red)) { g.DrawLine(myPen, 0, 0, 10, 10); } // 或者对于SolidBrush等 using (Brush myBrush = new SolidBrush(Color.Blue)) { g.FillRectangle(myBrush, rect); }GameRenderer的DrawGridLines等方法中,我们已经使用了using语句,这是好习惯。
把这个项目从头到尾实现一遍,你对C# WinForm的绘图、事件、定时器以及面向对象设计会有非常深刻的理解。它不仅仅是一个游戏,更是一个经典的、麻雀虽小五脏俱全的桌面应用程序范例。当你看到自己编写的方块流畅下落、旋转、消除,并伴随着分数上涨时,那种感觉是无与伦比的。