1. 项目概述与核心思路
最近在整理硬盘里的老项目,翻出来一个用C#写的俄罗斯方块。这玩意儿可以说是每个程序员成长路上的“必修课”之一,它麻雀虽小,五脏俱全,涵盖了游戏循环、碰撞检测、状态管理、用户输入处理、图形绘制等核心游戏开发概念。对于想从业务开发转向游戏逻辑,或者想巩固C#面向对象和事件驱动编程的朋友来说,自己动手实现一遍俄罗斯方块,收益绝对比看十篇理论文章要大。
我这个版本是几年前用WinForms写的,当时的目标很明确:逻辑与界面彻底分离。这意味着游戏的核心规则,比如方块旋转、消行判断、分数计算,全部封装在一个独立的类库(DLL)里,不依赖任何UI框架(WinForms、WPF等)。而界面层(这里就是WinForms)只负责两件事:一是把游戏数据(下一个方块是什么、当前分数、游戏板状态)画出来;二是把用户的键盘操作(左、右、下、旋转)翻译成游戏逻辑能理解的指令。这样做的好处是,哪天我想把界面从WinForms换成WPF、Avalonia甚至用控制台字符画来显示,核心的游戏逻辑代码一行都不用改,直接拿来就能用。
整个项目的代码结构非常清晰,主要分为两大块:Tetris.Core(核心逻辑库)和Tetris.WinForms(Windows窗体客户端)。接下来,我就带你一层层拆解这个经典小游戏的实现细节,从数据结构设计到游戏循环的构建,再到那些实际编码中容易踩的“坑”。
2. 核心逻辑库(Tetris.Core)设计与实现
2.1 游戏核心数据模型
游戏的核心是“棋盘”和“方块”。我们先从最基础的数据结构开始。
游戏棋盘(GameBoard)棋盘本质上是一个二维数组,用来记录每个格子当前的状态(是空的,还是已经被固定的方块占据)。我定义了一个Board类来封装这个二维数组以及相关的操作。
namespace Tetris.Core { public class Board { // 棋盘的宽和高(以格子数为单位) public const int Width = 10; public const int Height = 20; // 核心数据:一个二维数组,记录每个格子的状态。0表示空,大于0的值可以表示不同颜色。 private readonly int[,] _grid = new int[Height, Width]; // 索引器,方便外部访问 public int this[int row, int col] { get => _grid[row, col]; set => _grid[row, col] = value; } // 检查一个位置是否在棋盘范围内且为空 public bool IsCellEmpty(int row, int col) { return row >= 0 && row < Height && col >= 0 && col < Width && _grid[row, col] == 0; } // 检查一行是否已满(用于消行判断) public bool IsRowFull(int row) { for (int col = 0; col < Width; col++) { if (_grid[row, col] == 0) return false; } return true; } // 清除一行,并将上面的行整体下移 public void ClearRow(int rowToClear) { for (int row = rowToClear; row > 0; row--) { for (int col = 0; col < Width; col++) { _grid[row, col] = _grid[row - 1, col]; } } // 清空最顶行 for (int col = 0; col < Width; col++) { _grid[0, col] = 0; } } } }这里有几个设计点值得注意:
- 常量定义:将棋盘的宽度和高度定义为常量,避免在代码中散落着“魔术数字”(magic number),提高可读性和可维护性。如果想改变棋盘大小,只需修改这两个常量。
- 私有字段与公共索引器:
_grid是私有的,保护了内部数据。通过公共的索引器(this[int row, int col])提供受控的访问,你可以在set访问器中加入边界检查和逻辑验证(虽然这里没加,但预留了可能性)。 - 业务方法封装:
IsCellEmpty、IsRowFull、ClearRow这些方法封装了针对棋盘数据的核心操作。外部(比如游戏逻辑类)不需要知道_grid是如何实现的,只需要调用这些语义清晰的方法。这是面向对象“封装”思想的体现。
俄罗斯方块(Tetromino)俄罗斯方块有7种基本形状(I, J, L, O, S, T, Z)。每种形状由4个小方块(我们称之为“Minos”)组成,并且可以旋转产生不同的形态。
我采用了一种经典且高效的方式来表示方块:使用一个4x4的布尔矩阵(或整数矩阵)来定义一种旋转形态。一个方块的所有旋转形态,就构成了一个三维数组。
namespace Tetris.Core { // 方块类型枚举 public enum TetrominoType { I, J, L, O, S, T, Z } public class Tetromino { // 预定义所有方块的所有旋转形态。 // 这是一个三维数组:[方块类型][旋转状态][行][列] // 用1表示该位置有方块,0表示空。 private static readonly int[][,,] _shapes = new int[][,,] { // I new int[,,] { { {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} }, { {0,0,0,0}, {0,0,0,0}, {1,1,1,1}, {0,0,0,0} }, { {0,1,0,0}, {0,1,0,0}, {0,1,0,0}, {0,1,0,0} } }, // J, L, O, S, T, Z ... 类似定义,此处省略 }; public TetrominoType Type { get; } public int RotationState { get; private set; } // 当前旋转状态索引 (0-3) public int Row { get; set; } // 方块左上角在棋盘中的行坐标 public int Col { get; set; } // 方块左上角在棋盘中的列坐标 public Tetromino(TetrominoType type) { Type = type; RotationState = 0; // 初始位置:通常出现在棋盘顶部中央 Row = 0; Col = Board.Width / 2 - 2; // 因为形状是4x4,需要向左偏移2格使其居中 } // 获取当前旋转状态下的形状矩阵 public int[,] GetCurrentShape() { var shape = new int[4, 4]; var sourceShape = _shapes[(int)Type]; for (int r = 0; r < 4; r++) for (int c = 0; c < 4; c++) shape[r, c] = sourceShape[RotationState, r, c]; return shape; } // 旋转(尝试旋转,返回旋转后的新实例,用于碰撞检测) public Tetromino Rotate() { var newTetromino = new Tetromino(this.Type) { Row = this.Row, Col = this.Col, RotationState = (this.RotationState + 1) % 4 // 循环旋转 0->1->2->3->0 }; return newTetromino; } // 克隆当前方块(用于生成“下一个方块”预览) public Tetromino Clone() { return new Tetromino(Type) { Row = this.Row, Col = this.Col, RotationState = this.RotationState }; } } }注意:这里
_shapes数组的初始化代码很长,为了节省篇幅我只展示了I型方块的。在实际项目中,你需要补全其他6种方块的定义。网上有很多关于这7种方块4种旋转状态的标准定义,可以直接参考。
设计心得:
- 数据驱动:将方块形状定义为静态数据,而不是在代码里写死一堆
if-else来判断形状和旋转。这样代码更清晰,也更容易修改或扩展(比如你想增加新方块)。 - 状态独立:
Tetromino类只负责存储一个方块当前的状态(类型、位置、旋转)。它不关心自己怎么被画出来,也不直接操作棋盘。它提供Rotate()、Clone()等方法,返回一个新的方块实例,而不是修改自身。这符合“不可变对象”的思想,能减少状态混乱带来的Bug。 - 坐标系统:注意方块的
Row和Col是它4x4形状矩阵的左上角在棋盘_grid中的坐标。在碰撞检测时,需要遍历这个4x4矩阵,将其中值为1的格子映射到棋盘的坐标上进行判断。
2.2 游戏引擎(GameEngine)—— 大脑
有了棋盘和方块,我们需要一个“大脑”来协调一切,这就是GameEngine类。它负责游戏的主循环逻辑、状态管理、分数计算和对外通知。
namespace Tetris.Core { public class GameEngine { private readonly Board _board; private Tetromino _currentPiece; private Tetromino _nextPiece; private Random _random; private System.Timers.Timer _gameTimer; public int Score { get; private set; } public int Level { get; private set; } public bool IsGameOver { get; private set; } // 定义事件,用于通知UI更新 public event Action OnBoardUpdated; public event Action OnScoreUpdated; public event Action OnGameOver; public GameEngine() { _board = new Board(); _random = new Random(); _nextPiece = GenerateRandomPiece(); SpawnNewPiece(); Score = 0; Level = 1; // 游戏计时器,控制方块自动下落 _gameTimer = new System.Timers.Timer(1000); // 初始1秒下落一格 _gameTimer.Elapsed += (s, e) => MovePieceDown(); _gameTimer.AutoReset = true; _gameTimer.Start(); } // 生成随机方块 private Tetromino GenerateRandomPiece() { var types = Enum.GetValues(typeof(TetrominoType)); var type = (TetrominoType)types.GetValue(_random.Next(types.Length)); return new Tetromino(type); } // 生成新的当前方块(从_nextPiece取) private void SpawnNewPiece() { _currentPiece = _nextPiece; _nextPiece = GenerateRandomPiece(); // 生成新方块后,立即检查是否与已有方块重叠(即游戏结束) if (!IsValidPosition(_currentPiece)) { IsGameOver = true; _gameTimer.Stop(); OnGameOver?.Invoke(); } OnBoardUpdated?.Invoke(); // 通知UI,棋盘和下一个方块都变了 } // 核心:碰撞检测 private bool IsValidPosition(Tetromino piece) { var shape = piece.GetCurrentShape(); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { // 只检查形状矩阵中为1的格子 if (shape[r, c] != 0) { int boardRow = piece.Row + r; int boardCol = piece.Col + c; // 条件1:是否超出棋盘左右或底部边界? if (boardCol < 0 || boardCol >= Board.Width || boardRow >= Board.Height) return false; // 条件2:是否与棋盘上已固定的方块重叠?(注意顶部溢出是允许的,用于初始生成) if (boardRow >= 0 && !_board.IsCellEmpty(boardRow, boardCol)) return false; } } } return true; } // 将当前方块固定到棋盘上 private void LockPiece() { var shape = _currentPiece.GetCurrentShape(); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (shape[r, c] != 0) { int boardRow = _currentPiece.Row + r; int boardCol = _currentPiece.Col + c; if (boardRow >= 0) // 只固定落在棋盘内的部分 { // 这里可以用一个颜色映射,根据方块类型赋值不同数字代表颜色 _board[boardRow, boardCol] = (int)_currentPiece.Type + 1; } } } } // 固定后检查消行 CheckForCompletedLines(); // 生成下一个方块 SpawnNewPiece(); } // 检查并消除完整的行 private void CheckForCompletedLines() { int linesCleared = 0; // 从下往上检查更高效 for (int row = Board.Height - 1; row >= 0; row--) { if (_board.IsRowFull(row)) { _board.ClearRow(row); linesCleared++; row++; // 清除一行后,当前行变成了上一行下移来的,需要再检查一次 } } if (linesCleared > 0) { // 计分规则:一次消1行得100分,2行得300分,3行得500分,4行得800分(经典规则) int[] scoreTable = { 0, 100, 300, 500, 800 }; Score += scoreTable[linesCleared] * Level; // 分数乘以当前等级 Level = Score / 1000 + 1; // 每得1000分升一级,等级影响下落速度 _gameTimer.Interval = Math.Max(100, 1000 - (Level - 1) * 100); // 速度递增,最低100ms OnScoreUpdated?.Invoke(); OnBoardUpdated?.Invoke(); } } // 玩家操作:左移 public bool MoveLeft() { _currentPiece.Col--; if (!IsValidPosition(_currentPiece)) { _currentPiece.Col++; // 无效则回退 return false; } OnBoardUpdated?.Invoke(); return true; } // 玩家操作:右移、下移、旋转、硬降落的实现类似... public bool MoveRight() { /* ... */ } public bool RotatePiece() { /* ... */ } // 方块自动下落(由计时器触发) private void MovePieceDown() { _currentPiece.Row++; if (!IsValidPosition(_currentPiece)) { _currentPiece.Row--; // 无效,说明到底了 LockPiece(); } else { OnBoardUpdated?.Invoke(); } } // 硬降落(一键到底) public void HardDrop() { while (MovePieceDown()) { } // 一直向下移动直到锁定 LockPiece(); } // 获取棋盘数据和下一个方块数据,供UI绘制 public int[,] GetBoardData() { // 这里需要返回一个副本,或者将当前方块也“画”到棋盘数据上再返回。 // 更常见的做法是:UI绘制时,分别绘制固定的棋盘和当前活动的方块。 // 我们提供一个方法,返回固定棋盘的拷贝。 var data = new int[Board.Height, Board.Width]; Array.Copy(_board.Data, data, _board.Data.Length); // 假设Board有一个公开的Data属性返回_grid return data; } public Tetromino GetCurrentPiece() => _currentPiece?.Clone(); public Tetromino GetNextPiece() => _nextPiece?.Clone(); } }关键逻辑解析与避坑指南:
- 事件驱动通信:
GameEngine是纯逻辑,它不知道UI的存在。它通过定义OnBoardUpdated、OnScoreUpdated等事件来通知外部(UI层)“有状态变化了,该刷新显示了”。UI层订阅这些事件,并在事件触发时更新界面。这是实现“逻辑与界面分离”的关键。 - 计时器与线程:我们使用
System.Timers.Timer来驱动方块自动下落。注意,这个计时器的Elapsed事件是在线程池线程上触发的。这意味着MovePieceDown()方法可能不在UI线程上执行。如果在这个方法里直接操作UI控件(比如更新Label),会引发跨线程访问异常。解决方案:在UI层订阅事件时,如果需要更新UI控件,必须通过Control.Invoke或Dispatcher.Invoke(WPF)将调用封送到UI线程。更好的做法是,在GameEngine中只触发事件,由UI层的事件处理程序来负责线程安全的UI更新。 - 碰撞检测的细节:
IsValidPosition是游戏逻辑中最核心的函数之一。注意检查条件:boardRow >= Board.Height表示触底。boardCol < 0 || boardCol >= Board.Width表示超出左右边界。boardRow >= 0 && !_board.IsCellEmpty(...)表示与已有方块重叠。这里boardRow >= 0的判断很重要,它允许方块的形状矩阵有一部分在棋盘顶部之外(新方块生成时常见),这部分我们不进行重叠检查。
- 消行逻辑的循环:在
CheckForCompletedLines中,清除一行后,我们执行了row++。这是因为当第row行被清除,上面的行下移后,新的第row行(原来的第row-1行)可能也是满的,需要再次检查。这是一个经典的小技巧。 - 速度与等级:下落速度随等级提升而加快,这是通过动态调整计时器
Interval实现的。注意Math.Max(100, ...)设置了一个下限(100ms),防止速度过快导致游戏无法操作。
3. WinForms界面层(Tetris.WinForms)实现
核心逻辑完成后,UI层的工作就相对简单了,主要是绘制和事件响应。
3.1 主窗体与游戏控制
创建一个WinForms项目,引用Tetris.CoreDLL。主窗体MainForm主要包含:
- 一个
Panel(命名为gamePanel)作为游戏主画面的绘制区域。 - 一个
Panel(命名为nextPiecePanel)用于预览下一个方块。 - 几个
Label用于显示分数、等级。 - 开始、暂停、重置按钮。
在窗体加载时,初始化GameEngine并订阅其事件。
public partial class MainForm : Form { private GameEngine _engine; private Bitmap _bufferBitmap; // 双缓冲用,减少闪烁 public MainForm() { InitializeComponent(); InitializeGame(); this.KeyPreview = true; // 允许窗体接收按键事件 this.KeyDown += MainForm_KeyDown; } private void InitializeGame() { _engine = new GameEngine(); _engine.OnBoardUpdated += Engine_OnBoardUpdated; _engine.OnScoreUpdated += Engine_OnScoreUpdated; _engine.OnGameOver += Engine_OnGameOver; _bufferBitmap = new Bitmap(gamePanel.Width, gamePanel.Height); UpdateDisplay(); } private void Engine_OnBoardUpdated() { // 注意:这个事件可能由非UI线程的计时器触发 if (this.InvokeRequired) { this.Invoke(new Action(UpdateDisplay)); } else { UpdateDisplay(); } } private void Engine_OnScoreUpdated() { if (this.InvokeRequired) { this.Invoke(new Action(() => { lblScore.Text = _engine.Score.ToString(); lblLevel.Text = _engine.Level.ToString(); })); } else { lblScore.Text = _engine.Score.ToString(); lblLevel.Text = _engine.Level.ToString(); } } private void Engine_OnGameOver() { MessageBox.Show("游戏结束!", "俄罗斯方块", MessageBoxButtons.OK, MessageBoxIcon.Information); } private void UpdateDisplay() { // 在缓冲区绘制 DrawGame(_bufferBitmap); // 将缓冲区一次性绘制到Panel gamePanel.CreateGraphics().DrawImage(_bufferBitmap, 0, 0); DrawNextPiece(); } }重要提示:
Engine_OnBoardUpdated和Engine_OnScoreUpdated事件处理函数中,都使用了this.InvokeRequired和this.Invoke()。这是WinForms中处理跨线程访问UI控件的标准模式。它检查当前代码是否运行在创建控件的线程(UI线程)上,如果不是,则通过Invoke将一个委托排队到UI线程执行。这确保了UI更新的线程安全。
3.2 图形绘制
绘制分为两部分:绘制主游戏棋盘和绘制下一个方块预览。
private void DrawGame(Bitmap bitmap) { using (Graphics g = Graphics.FromImage(bitmap)) { g.Clear(Color.Black); // 清空背景 int cellSize = 20; // 每个格子的像素大小 var boardData = _engine.GetBoardData(); var currentPiece = _engine.GetCurrentPiece(); // 1. 绘制已固定的方块 for (int row = 0; row < Board.Height; row++) { for (int col = 0; col < Board.Width; col++) { if (boardData[row, col] != 0) { Color color = GetColorByBlockType(boardData[row, col]); DrawBlock(g, color, col * cellSize, row * cellSize, cellSize); } } } // 2. 绘制当前正在下落的方块 if (currentPiece != null) { var shape = currentPiece.GetCurrentShape(); Color color = GetColorByBlockType((int)currentPiece.Type + 1); for (int r = 0; r < 4; r++) { for (int c = 0; c < 4; c++) { if (shape[r, c] != 0) { int x = (currentPiece.Col + c) * cellSize; int y = (currentPiece.Row + r) * cellSize; // 只绘制在棋盘可见区域内的部分 if (y >= 0) { DrawBlock(g, color, x, y, cellSize); } } } } } // 3. 绘制网格线(可选) using (Pen gridPen = new Pen(Color.DarkGray, 1)) { for (int x = 0; x <= Board.Width; x++) g.DrawLine(gridPen, x * cellSize, 0, x * cellSize, Board.Height * cellSize); for (int y = 0; y <= Board.Height; y++) g.DrawLine(gridPen, 0, y * cellSize, Board.Width * cellSize, y * cellSize); } } } private void DrawNextPiece() { // 类似DrawGame,但只在nextPiecePanel上绘制下一个方块的预览 // ... } private Color GetColorByBlockType(int type) { // 根据方块类型返回对应的颜色 switch (type) { case 1: return Color.Cyan; // I case 2: return Color.Blue; // J case 3: return Color.Orange; // L case 4: return Color.Yellow; // O case 5: return Color.Green; // S case 6: return Color.Purple; // T case 7: return Color.Red; // Z default: return Color.Gray; } } private void DrawBlock(Graphics g, Color color, int x, int y, int size) { // 绘制一个带立体感的小方块 using (Brush fillBrush = new SolidBrush(color)) using (Brush lightBrush = new SolidBrush(Color.FromArgb(150, Color.White))) using (Brush darkBrush = new SolidBrush(Color.FromArgb(150, Color.Black))) { g.FillRectangle(fillBrush, x, y, size, size); g.FillRectangle(lightBrush, x, y, size - 2, 2); // 高光上边 g.FillRectangle(lightBrush, x, y, 2, size - 2); // 高光左边 g.FillRectangle(darkBrush, x + size - 2, y, 2, size); // 阴影右边 g.FillRectangle(darkBrush, x, y + size - 2, size, 2); // 阴影下边 } }绘制优化技巧:
- 双缓冲:直接在主Panel上绘制,在频繁刷新时会出现严重的闪烁。我们创建了一个与Panel同尺寸的
Bitmap作为缓冲区(_bufferBitmap),所有绘制操作先在内存中的这个位图上完成,然后一次性将整个位图DrawImage到Panel上。这是消除闪烁最有效的方法。 - 分层绘制:先绘制背景和已固定的方块(底层),再绘制当前活动的方块(上层)。这样当前方块就能盖在已有方块之上,符合视觉逻辑。
- 只绘制可见部分:绘制当前方块时,通过
if (y >= 0)判断,只绘制在棋盘区域内的部分,避免绘制到顶部之外。
3.3 键盘输入处理
在窗体的KeyDown事件中,将按键映射到游戏引擎的操作。
private void MainForm_KeyDown(object sender, KeyEventArgs e) { if (_engine.IsGameOver) return; bool handled = false; switch (e.KeyCode) { case Keys.Left: handled = _engine.MoveLeft(); break; case Keys.Right: handled = _engine.MoveRight(); break; case Keys.Down: handled = _engine.MoveDown(); // 需要实现一个加速下落的方法 break; case Keys.Up: case Keys.Z: // 旋转 handled = _engine.RotatePiece(); break; case Keys.Space: // 硬降落 _engine.HardDrop(); handled = true; break; case Keys.P: // 暂停/继续 TogglePause(); handled = true; break; } if (handled) { e.Handled = true; // 标记已处理,防止其他控件响应 // 如果操作成功,可以立即请求重绘,让响应更跟手 // UpdateDisplay(); // 但通常OnBoardUpdated事件已经触发了 } } private void TogglePause() { // 控制GameEngine内部计时器的启停来实现暂停 // 需要在GameEngine中暴露一个Paused属性和Pause/Resume方法 }操作手感优化:
- 按键重复:WinForms的
KeyDown事件在按住键时会持续触发(有初始延迟和重复间隔)。对于俄罗斯方块,我们通常希望按住左右键时能连续移动。你可以通过处理KeyDown和KeyUp事件,结合一个计时器来实现“按下-延迟-连续移动”的效果,但这会复杂一些。一个简单的做法是,在KeyDown事件中直接调用引擎方法,依靠系统默认的按键重复速率,虽然可能不够平滑,但实现简单。 - 旋转的“踢墙”:标准的俄罗斯方块有“踢墙”机制(Wall Kick),即当旋转因碰撞而失败时,系统会尝试将方块向左、右或上轻微移动一格,如果移动后能旋转就允许。这能极大改善操作体验。你可以在
GameEngine.RotatePiece()方法中实现这个逻辑:先尝试旋转,如果失败,则尝试几个预设的偏移位置(如(0, -1), (1, 0), (-1, 0)等),任何一个成功就应用旋转和偏移。
4. 项目构建、调试与扩展思路
4.1 项目结构与编译
- 在Visual Studio中创建两个项目:一个“类库(.NET Framework或.NET Core/6/8)”项目,命名为
Tetris.Core;一个“Windows窗体应用”项目,命名为Tetris.WinForms。 - 在
Tetris.WinForms项目中,添加对Tetris.Core项目的引用。 - 将上述代码分别放入对应的项目中。注意窗体设计器代码(
MainForm.Designer.cs)由VS自动生成,你只需要在MainForm.cs中编写逻辑代码。 - 编译运行。确保
Tetris.Core的输出路径正确,或者将Tetris.Core设置为Tetris.WinForms的依赖项,这样在生成时会自动复制DLL。
4.2 常见问题与调试技巧
方块旋转后位置错乱或穿墙:
- 原因:最可能是方块形状矩阵
_shapes的定义有误。每个4x4矩阵必须精确表示该旋转状态下4个小方块的位置。建议用纸笔画一下,或者在网上找一份标准的“超级旋转系统”(SRS)数据来核对。 - 调试:在
IsValidPosition方法中设置断点,打印出旋转后方块每个小方块的棋盘坐标,看是否符合预期。
- 原因:最可能是方块形状矩阵
游戏运行越来越卡:
- 原因:可能是内存泄漏。检查
DrawGame方法中创建的Graphics、Pen、Brush等GDI+对象是否都正确包裹在using语句中或手动Dispose()了。特别是Graphics.FromImage创建的Graphics对象。 - 检查事件订阅:确保在游戏结束或窗体关闭时,取消对
GameEngine事件的订阅(-=),否则引擎可能无法被垃圾回收。
- 原因:可能是内存泄漏。检查
UI更新延迟或闪烁:
- 确保使用了双缓冲:如
_bufferBitmap。 - 在Panel属性中设置双缓冲:
gamePanel.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.DoubleBuffer, true);。 - 减少不必要的绘制:在
UpdateDisplay中,可以判断游戏状态是否真的发生了变化,如果没有,可以不进行重绘。
- 确保使用了双缓冲:如
按键无响应或响应异常:
- 确认窗体
KeyPreview属性为true。 - 确认没有其他控件(如按钮)获得了焦点并“吃掉”了按键事件。
- 在
KeyDown事件处理函数开始处设置断点,检查e.KeyCode是否正确。
- 确认窗体
4.3 功能扩展与优化建议
这个基础版本已经是一个可玩的游戏,但还有很多可以打磨和扩展的地方:
- 实现“踢墙”旋转:如前所述,参考标准俄罗斯方块指南(如Tetris Guideline)实现SRS(标准旋转系统),这会大幅提升操作手感。
- 增加“Hold”功能:允许玩家暂存一个方块。需要在
GameEngine中增加一个_holdPiece字段和相应的交换逻辑,并在UI上增加一个预览区域。 - 添加音效和背景音乐:使用
System.Media.SoundPlayer播放简单的WAV音效(消行、移动、旋转、游戏结束)。 - 美化界面:使用更精美的方块贴图、背景图、平滑的动画(如方块锁定前的闪烁、消行时的动画)。这需要更复杂的绘制逻辑,可能要用到GDI+的图形变换或者考虑使用游戏框架(如MonoGame)。
- 游戏状态持久化:保存最高分记录到本地文件或注册表。
- 重构为MVVM模式:如果你想用WPF重写UI,可以将
GameEngine作为Model,创建对应的ViewModel来绑定数据,实现更彻底的解耦。 - 网络对战:这是更大的挑战,需要设计网络协议来同步游戏状态,处理延迟和输入预测。
这个C#俄罗斯方块项目,从核心逻辑的抽象,到界面与逻辑的分离,再到事件驱动、多线程UI更新等具体问题的解决,完整地走了一遍一个小型桌面应用开发的核心流程。它虽然代码量不大,但涉及的知识点非常典型。自己动手实现一遍,遇到问题并解决它,你对C#和游戏逻辑的理解会上一个坚实的台阶。代码我已经整理好,你可以基于这个框架,尽情添加你想要的功能。