C#实现俄罗斯方块:从核心逻辑到WinForms界面的完整游戏开发指南
1. 项目概述与核心思路最近在整理硬盘里的老项目翻出来一个用C#写的俄罗斯方块。这玩意儿可以说是每个程序员成长路上的“必修课”之一它麻雀虽小五脏俱全涵盖了游戏循环、碰撞检测、状态管理、用户输入处理、图形绘制等核心游戏开发概念。对于想从业务开发转向游戏逻辑或者想巩固C#面向对象和事件驱动编程的朋友来说自己动手实现一遍俄罗斯方块收益绝对比看十篇理论文章要大。我这个版本是几年前用WinForms写的当时的目标很明确逻辑与界面彻底分离。这意味着游戏的核心规则比如方块旋转、消行判断、分数计算全部封装在一个独立的类库DLL里不依赖任何UI框架WinForms、WPF等。而界面层这里就是WinForms只负责两件事一是把游戏数据下一个方块是什么、当前分数、游戏板状态画出来二是把用户的键盘操作左、右、下、旋转翻译成游戏逻辑能理解的指令。这样做的好处是哪天我想把界面从WinForms换成WPF、Avalonia甚至用控制台字符画来显示核心的游戏逻辑代码一行都不用改直接拿来就能用。整个项目的代码结构非常清晰主要分为两大块Tetris.Core核心逻辑库和Tetris.WinFormsWindows窗体客户端。接下来我就带你一层层拆解这个经典小游戏的实现细节从数据结构设计到游戏循环的构建再到那些实际编码中容易踩的“坑”。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.InvokeWPF将调用封送到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#和游戏逻辑的理解会上一个坚实的台阶。代码我已经整理好你可以基于这个框架尽情添加你想要的功能。