多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C# WinForm俄罗斯方块开发:从MVC架构到游戏逻辑实现

C# WinForm俄罗斯方块开发:从MVC架构到游戏逻辑实现 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结构体这里为了简单我们用int0表示空非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方法遍历了整个网格20x10200格。在每次绘制时我们可以只绘制发生变化的区域脏矩形但对于这个规模全量重绘性能完全足够。如果网格很大就需要考虑优化。颜色管理我们将方块类型索引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中增加一个状态如Listint _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的绘图、事件、定时器以及面向对象设计会有非常深刻的理解。它不仅仅是一个游戏更是一个经典的、麻雀虽小五脏俱全的桌面应用程序范例。当你看到自己编写的方块流畅下落、旋转、消除并伴随着分数上涨时那种感觉是无与伦比的。
返回列表