C#贪吃蛇实战:从零构建面向对象游戏引擎与WinForms绘图

发布时间:2026/7/27 5:00:44
C#贪吃蛇实战:从零构建面向对象游戏引擎与WinForms绘图 1. 项目概述为什么用C#重写贪吃蛇如果你正在学习C#或者已经有一段时间的.NET开发经验但总觉得那些控制台计算器、学生管理系统不够“带劲”那么亲手实现一个贪吃蛇游戏绝对是一个能让你把C#核心知识点“串”起来的绝佳实践。这不仅仅是画几条线、让一个方块动起来那么简单。一个完整的贪吃蛇项目会逼着你直面面向对象设计、事件驱动、集合操作、绘图与渲染、游戏循环逻辑等一系列核心问题。它比理论教程生动比单纯调用API深刻。很多人觉得贪吃蛇太“古老”了网上源码一抓一大把。但“自己实现”的价值正在于此你不是在复制粘贴而是在用C#这门现代、优雅的语言去解决一个经典的、边界清晰的问题。过程中你会反复遇到并解决那些在真实开发中同样会出现的困境比如如何优雅地管理不断增长的蛇身如何精确控制游戏帧率让移动既流畅又不“鬼畜”如何处理用户的连续按键输入当你用ListPoint来存储蛇身用Timer或Thread来控制游戏心跳用Graphics在窗体上绘制每一个像素时你对C#的理解会从“知道是什么”飞跃到“知道怎么用”。这个项目尤其适合那些已经学完C#语法基础但苦于没有合适练手项目的开发者。它不依赖复杂的第三方库用WinForms或WPF自带的绘图就足够却能让你触及GUI程序、实时应用的核心。接下来我会带你从零开始拆解每一个环节不仅写出能跑的代码更要写出清晰、健壮、易于扩展的代码。你会发现一个简单的贪吃蛇背后是扎实的软件工程实践。2. 整体架构与核心类设计在动手写第一行代码之前花点时间思考架构是值得的。一个混乱的架构会让后续添加功能比如障碍物、关卡、分数排行榜变得举步维艰。我们的目标是高内聚、低耦合。2.1 领域模型定义游戏中的“实体”贪吃蛇游戏里主要有三个核心实体蛇、食物、游戏场地墙或边界。我们需要用类来抽象它们。1. Snake蛇类这是最复杂的类。蛇有长度、方向、身体由一系列节点组成。我们需要思考身体存储用ListPoint是最直观的选择。Point可以记录每个身体节在游戏网格中的坐标。链表LinkedList在频繁的头部插入和尾部删除上更高效但对于贪吃蛇的规模List的简单性优势更大。移动逻辑这是核心。每次移动在头部根据当前方向新增一个节点并移除尾部节点如果没吃到食物。如果吃到食物则不移除尾部从而实现生长。方向控制定义一个Direction枚举上、下、左、右。关键点在于蛇不能直接反向移动比如不能从左立刻变成右这需要在改变方向时进行判断。2. Food食物类相对简单主要属性是一个Point位置。核心方法是GenerateNewPosition它需要在一个指定的游戏区域范围内随机生成一个不与蛇身重叠的位置。这里就涉及到与Snake类的交互。3. GameBoard游戏面板类这个类定义了游戏的“舞台”。它包含网格的尺寸如30列 x 20行、每个网格单元的像素大小。它负责坐标转换将网格坐标(x, y)转换为屏幕像素坐标(x * cellSize, y * cellSize)以及边界碰撞检测。4. GameEngine游戏引擎类这是大脑负责协调所有实体。它持有Snake、Food、GameBoard的实例并驱动游戏主循环。它的职责包括初始化游戏状态。响应定时器Timer在每一个“滴答”里更新游戏逻辑调用蛇的移动、检查碰撞、检查吃食物。根据最新的游戏状态触发界面重绘。管理游戏状态运行、暂停、结束。设计心得将游戏逻辑GameEngine与界面显示Form分离是至关重要的。Form只负责接收输入、调用引擎、根据引擎提供的数据进行绘制。这样未来如果你想将WinForms界面换成WPF甚至控制台只需要换掉Form部分核心游戏逻辑完全不用动。这就是关注点分离的好处。2.2 技术选型WinForms还是WPF对于这个项目两者皆可但起点和侧重点不同。WinForms更简单、直接。使用System.Windows.Forms.Timer和Paint事件可以快速搭建原型。绘图通过Graphics对象在OnPaint或Paint事件中完成。适合专注于游戏逻辑本身不想在UI框架上花费太多精力的初学者。WPF更现代、灵活。可以利用数据绑定、强大的样式和模板做出更炫酷的视觉效果。游戏循环通常用CompositionTarget.Rendering事件或DispatcherTimer驱动。适合想同时练习WPF和MVVM模式的开发者。为了最大限度地聚焦于C#和游戏逻辑本身本系列文章将选择WinForms作为实现载体。它的直接性让我们能更清楚地看到每一行代码是如何影响游戏行为的。3. 核心模块实现详解有了架构蓝图我们开始动手实现每一个核心模块。我会先给出关键代码然后解释其背后的“为什么”。3.1 游戏实体类的实现首先定义方向枚举和基础实体。// Direction.cs public enum Direction { Up, Down, Left, Right }// GameBoard.cs public class GameBoard { public int Width { get; } // 网格列数 public int Height { get; } // 网格行数 public int CellSize { get; } // 每个网格的像素大小 public GameBoard(int width, int height, int cellSize) { Width width; Height height; CellSize cellSize; } // 判断一个点是否在边界内 public bool IsWithinBounds(Point point) { return point.X 0 point.X Width point.Y 0 point.Y Height; } // 将网格坐标转换为屏幕中央的像素坐标用于绘制 public PointF ToPixelCenter(Point gridPoint) { return new PointF(gridPoint.X * CellSize CellSize / 2f, gridPoint.Y * CellSize CellSize / 2f); } }接下来是重头戏Snake类// Snake.cs public class Snake { public ListPoint Body { get; private set; } public Direction CurrentDirection { get; private set; } public Direction NextDirection { get; set; } // 用于缓冲输入 public Snake(Point initialPosition) { Body new ListPoint { initialPosition }; CurrentDirection Direction.Right; NextDirection Direction.Right; } // 移动一步 public void Move(bool grow false) { // 1. 更新当前方向防止反向 UpdateDirection(); // 2. 根据当前方向计算新的头部位置 Point head Body[0]; Point newHead CurrentDirection switch { Direction.Up new Point(head.X, head.Y - 1), Direction.Down new Point(head.X, head.Y 1), Direction.Left new Point(head.X - 1, head.Y), Direction.Right new Point(head.X 1, head.Y), _ head }; // 3. 将新头部插入列表开头 Body.Insert(0, newHead); // 4. 如果没吃到食物移除尾部以实现移动 if (!grow) { Body.RemoveAt(Body.Count - 1); } // 如果grow为true则不移除尾部蛇身变长 } private void UpdateDirection() { // 关键逻辑禁止直接反向移动 if ((NextDirection Direction.Up CurrentDirection ! Direction.Down) || (NextDirection Direction.Down CurrentDirection ! Direction.Up) || (NextDirection Direction.Left CurrentDirection ! Direction.Right) || (NextDirection Direction.Right CurrentDirection ! Direction.Left)) { CurrentDirection NextDirection; } } // 检查蛇头是否撞到自己的身体 public bool CheckSelfCollision() { Point head Body[0]; // 从索引1开始检查因为头部索引0不可能和自己碰撞 for (int i 1; i Body.Count; i) { if (head.Equals(Body[i])) { return true; } } return false; } }关键点解析这里使用了NextDirection作为方向缓冲区。为什么因为游戏循环比如每秒10帧和键盘事件可能每秒上百次是不同步的。如果玩家在两次游戏更新之间快速按了两次方向键直接修改CurrentDirection可能会导致方向被意外覆盖甚至出现“瞬间反向”的Bug。用NextDirection缓冲在每次Move时安全地更新CurrentDirection是更健壮的做法。Food类的实现// Food.cs public class Food { public Point Position { get; private set; } private Random _random new Random(); public Food() { // 初始位置可以后续由GameEngine设定 } // 在指定区域内生成新食物并确保不在蛇身上 public void GenerateNewPosition(int boardWidth, int boardHeight, ListPoint snakeBody) { Point newPos; bool positionValid; do { positionValid true; newPos new Point(_random.Next(boardWidth), _random.Next(boardHeight)); // 检查是否与蛇身重叠 foreach (var segment in snakeBody) { if (newPos.Equals(segment)) { positionValid false; break; } } } while (!positionValid); Position newPos; } }3.2 游戏引擎GameEngine的搭建引擎是粘合剂它创建并管理所有实体执行游戏规则。// GameEngine.cs public class GameEngine { public Snake Snake { get; private set; } public Food Food { get; private set; } public GameBoard Board { get; private set; } public int Score { get; private set; } public bool IsGameOver { get; private set; } public bool IsPaused { get; set; } public event Action? GameUpdated; // 游戏状态更新事件 public event Actionint? GameEnded; // 游戏结束事件附带分数 public GameEngine(int boardWidth 30, int boardHeight 20, int cellSize 20) { Board new GameBoard(boardWidth, boardHeight, cellSize); // 蛇初始在中间位置 Point initPos new Point(boardWidth / 2, boardHeight / 2); Snake new Snake(initPos); Food new Food(); Food.GenerateNewPosition(boardWidth, boardHeight, Snake.Body); Score 0; IsGameOver false; } // 游戏主循环的“一步” public void Update() { if (IsGameOver || IsPaused) return; // 1. 蛇移动 bool hasEatenFood Snake.Body[0].Equals(Food.Position); Snake.Move(hasEatenFood); // 2. 如果吃到食物 if (hasEatenFood) { Score 10; Food.GenerateNewPosition(Board.Width, Board.Height, Snake.Body); } // 3. 碰撞检测 Point head Snake.Body[0]; // 撞墙检测 if (!Board.IsWithinBounds(head)) { EndGame(); return; } // 撞自己检测 if (Snake.CheckSelfCollision()) { EndGame(); return; } // 4. 通知界面更新 GameUpdated?.Invoke(); } // 处理键盘输入改变蛇的下一步方向 public void ChangeDirection(Direction newDirection) { if (!IsGameOver !IsPaused) { Snake.NextDirection newDirection; } } private void EndGame() { IsGameOver true; GameEnded?.Invoke(Score); } public void Restart() { Point initPos new Point(Board.Width / 2, Board.Height / 2); Snake new Snake(initPos); Food.GenerateNewPosition(Board.Width, Board.Height, Snake.Body); Score 0; IsGameOver false; IsPaused false; GameUpdated?.Invoke(); } }事件驱动设计注意GameEngine暴露了GameUpdated和GameEnded事件。这是典型的观察者模式。Form界面订阅这些事件。当引擎内部状态变化蛇移动了、吃食物了、游戏结束了它通过事件通知界面“我变了你该重画了”或“游戏结束了显示分数吧”。这样引擎完全不需要知道界面的存在耦合度极低。3.3 WinForms主窗体的集成最后我们将引擎和界面连接起来。创建一个WinForms项目添加一个Form。// MainForm.cs public partial class MainForm : Form { private GameEngine _gameEngine; private System.Windows.Forms.Timer _gameTimer; private const int TimerInterval 150; // 毫秒控制游戏速度 public MainForm() { InitializeComponent(); this.DoubleBuffered true; // 关键启用双缓冲消除绘制闪烁 this.KeyPreview true; // 允许窗体优先接收按键事件 // 初始化游戏引擎 _gameEngine new GameEngine(30, 20, 20); _gameEngine.GameUpdated OnGameUpdated; _gameEngine.GameEnded OnGameEnded; // 初始化游戏定时器 _gameTimer new System.Windows.Forms.Timer(); _gameTimer.Interval TimerInterval; _gameTimer.Tick GameTimer_Tick; // 设置窗体大小根据面板大小计算 this.ClientSize new Size(_gameEngine.Board.Width * _gameEngine.Board.CellSize, _gameEngine.Board.Height * _gameEngine.Board.CellSize); } private void MainForm_Load(object sender, EventArgs e) { _gameTimer.Start(); } // 定时器触发驱动游戏逻辑更新 private void GameTimer_Tick(object sender, EventArgs e) { _gameEngine.Update(); } // 响应引擎的更新事件触发重绘 private void OnGameUpdated() { this.Invalidate(); // 标记整个窗体区域需要重绘 } // 响应引擎的结束事件 private void OnGameEnded(int score) { _gameTimer.Stop(); MessageBox.Show($游戏结束你的得分是{score}, 贪吃蛇, MessageBoxButtons.OK); // 可以在这里添加重新开始或退出的逻辑 } // 重写OnPaint进行自定义绘制 protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; // 抗锯齿 // 1. 绘制网格背景可选 DrawGrid(g); // 2. 绘制蛇 DrawSnake(g); // 3. 绘制食物 DrawFood(g); } private void DrawGrid(Graphics g) { Pen gridPen new Pen(Color.LightGray, 0.5f); for (int x 0; x _gameEngine.Board.Width; x) { g.DrawLine(gridPen, x * _gameEngine.Board.CellSize, 0, x * _gameEngine.Board.CellSize, this.ClientSize.Height); } for (int y 0; y _gameEngine.Board.Height; y) { g.DrawLine(gridPen, 0, y * _gameEngine.Board.CellSize, this.ClientSize.Width, y * _gameEngine.Board.CellSize); } } private void DrawSnake(Graphics g) { Brush headBrush new SolidBrush(Color.DarkGreen); Brush bodyBrush new SolidBrush(Color.LightGreen); for (int i 0; i _gameEngine.Snake.Body.Count; i) { Point gridPos _gameEngine.Snake.Body[i]; PointF pixelCenter _gameEngine.Board.ToPixelCenter(gridPos); float radius _gameEngine.Board.CellSize / 2f - 1; // 蛇头用不同颜色 Brush brush (i 0) ? headBrush : bodyBrush; g.FillEllipse(brush, pixelCenter.X - radius, pixelCenter.Y - radius, radius * 2, radius * 2); } } private void DrawFood(Graphics g) { Brush foodBrush new SolidBrush(Color.Red); PointF pixelCenter _gameEngine.Board.ToPixelCenter(_gameEngine.Food.Position); float radius _gameEngine.Board.CellSize / 2f - 1; g.FillEllipse(foodBrush, pixelCenter.X - radius, pixelCenter.Y - radius, radius * 2, radius * 2); } // 键盘控制 protected override void OnKeyDown(KeyEventArgs e) { base.OnKeyDown(e); Direction newDir e.KeyCode switch { Keys.W or Keys.Up Direction.Up, Keys.S or Keys.Down Direction.Down, Keys.A or Keys.Left Direction.Left, Keys.D or Keys.Right Direction.Right, Keys.Space { _gameEngine.IsPaused !_gameEngine.IsPaused; return _gameEngine.Snake.NextDirection; }, // 空格暂停 Keys.R { _gameEngine.Restart(); _gameTimer.Start(); return _gameEngine.Snake.NextDirection; }, // R键重启 _ _gameEngine.Snake.NextDirection }; _gameEngine.ChangeDirection(newDir); } }避坑指南双缓冲与闪烁问题在WinForms中直接绘图频繁刷新时会出现严重的闪烁。设置this.DoubleBuffered true;是解决这个问题的标准做法。它会在内存中先完成整个画面的绘制然后一次性输出到屏幕从而消除中间过程的闪烁感。这是开发WinForms游戏或动态图表时必须掌握的一个技巧。4. 进阶优化与功能扩展一个基础版本完成后我们可以从性能、体验和功能上进行优化和扩展这能让你对C#和游戏开发有更深的理解。4.1 性能与体验优化点更精确的游戏循环System.Windows.Forms.Timer精度较低且受UI线程消息队列影响。对于要求稍高的游戏可以使用System.Threading.Timer配合Invoke或者使用基于Stopwatch的自定义游戏循环来计算增量时间Delta Time实现帧率无关的平滑移动。输入处理优化当前实现中快速连续按键可能导致某些按键被“吞掉”。可以维护一个按键队列QueueDirection在每次游戏更新时从队列中取出一个有效的方向指令进行处理确保每个输入都被响应。绘制优化目前每次Invalidate()都会重绘整个窗体包括网格。对于静态背景如网格可以将其绘制到一个Bitmap缓存中每次只重绘动态部分蛇和食物这能显著提升绘制效率。音效与动画添加简单的音效使用System.Media.SoundPlayer和吃食物时的粒子动画比如食物消失时的缩放或颜色变化能极大提升游戏质感。4.2 功能扩展思路关卡系统随着分数增加提高游戏速度减少Timer.Interval或者生成障碍物在GameBoard中维护一个障碍物列表并在碰撞检测中增加对障碍物的判断。多种食物类型创建不同的食物类如NormalFood,SpeedUpFood,SlowDownFood它们继承自一个FoodBase抽象类。每种食物有不同的分数和特殊效果如短时间内改变蛇速。这需要扩展GameEngine.Update逻辑来处理这些效果。游戏状态持久化将最高分记录到本地文件使用System.IO.File或简单的数据库如SQLite。这涉及到文件读写和异常处理。更复杂的AI模式这属于挑战性功能。你可以尝试实现让食物自动躲避蛇的简单AI或者实现一个自动玩游戏的“AI蛇”算法上可以使用BFS广度优先搜索寻找最短路径到食物这能深刻练习算法和数据结构。5. 开发中常见问题与调试技巧即使按照步骤来你也可能会遇到一些“坑”。这里记录了几个典型问题及其解决方法。5.1 蛇的移动“卡顿”或“穿墙”问题描述蛇移动不流畅或者在边界处突然从另一边出现。排查检查GameTimer的Interval属性。值太小如10ms会导致更新太快肉眼难以分辨值太大如500ms则会感觉卡顿。150-200ms是常见的起始值。“穿墙”通常是碰撞检测逻辑错误。确保Board.IsWithinBounds方法判断的是移动后的新蛇头head.X 0 head.X Width注意是小于Width而不是小于等于。检查坐标计算。在Direction的switch语句中确认X和Y的增减是否正确通常屏幕坐标系Y轴向下为正。5.2 按键响应不灵敏或方向瞬间反转问题描述按了键没反应或者蛇突然朝反方向移动导致游戏立刻结束。排查不灵敏确认窗体属性KeyPreview true并且没有其他控件如Button获得焦点并“吃掉”了键盘事件。瞬间反转这是最经典的Bug。根本原因是在一次游戏更新周期内CurrentDirection被连续修改了两次。务必使用NextDirection缓冲机制并在UpdateDirection方法中严格进行反向判断。我们的实现已经处理了这个问题。在OnKeyDown事件中打印或调试输出接收到的按键码确认事件被正确触发。5.3 绘制时严重闪烁问题描述游戏运行时画面不停闪烁影响体验。解决必须设置this.DoubleBuffered true;。这是解决WinForms绘图闪烁的首选和标准方案。在OnPaint方法中避免频繁创建和销毁Pen、Brush等GDI对象。可以将它们作为窗体的成员变量在初始化时创建在窗体销毁时释放。考虑使用BufferedGraphics类进行更高级的双缓冲控制。5.4 食物生成在蛇身体里问题描述新生成的食物位置与蛇身重叠。排查检查Food.GenerateNewPosition方法中的循环逻辑。确保do...while循环的终止条件是positionValid为true。在生成新位置后立即添加调试输出打印食物坐标和当前蛇身所有坐标进行比对。确保传递给该方法的snakeBody参数是最新的包含了刚刚增长后的蛇身。5.5 使用调试器Debugger定位逻辑错误当游戏行为不符合预期时不要只靠“看代码”。熟练使用调试器是程序员的基本功。设置断点在GameEngine.Update、Snake.Move、ChangeDirection等关键方法入口设置断点。监视变量添加对_gameEngine.Snake.Body、_gameEngine.Snake.CurrentDirection、_gameEngine.Food.Position的监视观察每一步更新后它们如何变化。逐语句执行使用F11键逐语句执行跟踪程序的精确流程看是否进入了不该进入的分支如碰撞检测通过的条件判断。亲手实现一个完整的贪吃蛇远不止是让一个方块动起来。它是一次对C#面向对象、事件驱动、集合操作、绘图技术的综合演练。从设计类结构、处理实时输入、管理游戏状态到解决绘制闪烁、调试碰撞逻辑每一个环节都是宝贵的实战经验。当你看到自己编写的程序按照你的逻辑流畅运行时那种成就感是单纯学习语法无法比拟的。这个项目可以作为一个起点尝试去实现前面提到的扩展功能或者用不同的UI框架WPF, Avalonia重写逻辑层你的C#实战能力会在这个过程中得到真正的巩固和飞跃。