简介:这是一套面向初学者的C#控制台贪吃蛇实战项目,围绕类、方法、变量、条件语句等核心语法,帮助开发者系统练习面向对象设计与控制台交互开发。项目中完整实现了蛇的移动、转向、吃食物增长、撞墙及自撞失败判定等核心逻辑,包含游戏主循环、状态机更新、碰撞检测和随机食物生成等关键环节;同时用字符绘制游戏画面,覆盖Console输入输出、数组/列表存储蛇身、循环与条件判断的综合运用。压缩包共33个文件,以.cs源码为主,另有可执行的.exe程序、项目配置、缓存及说明文档,整体仅70KB,轻量易用,已有217人学习下载。拿到后可直接阅读源码结构、运行体验,也可参考场景划分与类设计思路,用来对比和优化自己的贪吃蛇实现;项目将流程拆分为蛇身管理、地图绘制、食物生成和场景切换等模块,对培养良好编码习惯很有帮助。 很多学C#的朋友都问过我同一个问题:语法书翻完了,接下来练点什么好?我给出的答案一直很固执——写个贪吃蛇。先别急着嫌它简单,这个小游戏看起来不起眼,却能把C#这门语言里最常用的核心知识点一次性串起来:类与对象、泛型集合、枚举、事件委托,还有定时器和键盘输入。更关键的是,它小到周末就能写完,又有足够的结构复杂度,能让你切身体会什么叫“界面与逻辑分离”,这个思想在你以后做上位机、扫码枪程序、桌面工具时完全是同一个套路。这篇文章就把我完整实现一个Windows Forms版贪吃蛇的过程拆开来讲,从类设计到碰撞检测再到实战坑点,适合刚学完C#基础、想找个有含金量的练手项目,或者准备面试前临时抱佛脚的朋友,照着做一遍,你的代码组织能力会明显不一样。
1. 为什么是贪吃蛇:项目价值与整体设计思路
1.1 一个小游戏能练到哪些C#核心技能
很多初学者容易陷入一个误区:总觉得项目越大越能锻炼能力。其实对一个还没写过完整程序的C#新手来说,一上来就是三层架构加数据库,只会被一堆概念吓退。贪吃蛇这个项目巧妙就巧妙在,它麻雀虽小但五脏俱全,几乎覆盖了日常开发八成的高频知识点。
- 面向对象设计:你需要抽象出Snake(蛇)、Food(食物)、Game(游戏主逻辑)三个类,这正是练习职责划分的最佳粒度。
- 泛型集合:蛇的身体天然就是一个
List<Point>,你会真正用到Add、RemoveAt、Contains这些方法,而不是在教程里干看。 - 枚举:方向键映射成 Direction 枚举,配合C#9以后的 switch 表达式,代码干净又优雅。
- 事件与委托:得分变化、游戏结束都要通知UI刷新,这正是事件机制最自然的应用场景。
- 定时器与游戏循环:每个游戏都有一个“心跳”,Timer就是最简单直接的心跳实现。
说白了,只要把这个项目从头到尾写明白,你的C#基础语法就算扎扎实实过了一遍。而且面试的时候,面试官如果问“说说你做过什么”,你能把这个项目的类设计讲清楚,比背十道面试题都管用。
1.2 技术选型:为什么用 WinForms
做贪吃蛇其实有好几条路:控制台程序、Unity游戏引擎、WPF,还有WinForms。我为什么建议用WinForms?不推荐控制台,是因为控制台模式下绘制动画非常别扭,你得不停地清屏重绘,还要自己处理光标位置,精力全耗在输出排版上,完全偏离了“练习C#核心”的初衷。不推荐Unity,是因为Unity的组件体系、场景管理这些概念会把语言本身的学习复杂度盖过去,你学了半天,记住的是引擎API而不是C#语法。不推荐WPF,虽然WPF很强大,但XAML、绑定、数据模板这些概念对新手来说学习曲线太陡,很容易劝退。
WinForms的优势在于它足够简单,又足够贴近实际的桌面业务开发。你拖一个Panel当画布,挂一个Timer做心跳,键盘事件直接绑在窗体上,所有UI交互都是最直观的消息驱动。更重要的是,WinForms让我们可以很自然地做到“界面与逻辑分离”:
- 逻辑层(Game、Snake、Food类):不引用任何控件,完全独立。
- 界面层(MainForm):只负责绘制画面、转发按键、显示分数。
这样设计最大的好处是,以后你想把游戏换成WPF版或者Unity版,逻辑层代码一行都不用改。这个思想在做C#上位机的时候尤其重要——串口通讯逻辑和界面显示逻辑必须分离,不然项目一复杂就全乱套了。
2. 核心类设计与解耦思路
2.1 从数据模型出发:Point、Snake、Food
我写代码的习惯是,不管界面长什么样,先把数据模型想清楚。贪吃蛇的数据模型其实特别简单,核心就三个东西:坐标点、蛇、食物。
首先是一个坐标点Point。这里我直接用C# 9的record struct,自带值相等比较,省去冗余代码:
public record struct Point(int X, int Y); public enum Direction { Up, Down, Left, Right }然后是蛇Snake。蛇的本质就是一个坐标点列表,头部是列表第一个元素,尾巴是最后一个元素。构造函数里生成初始长度三格的蛇,朝右移动:
public class Snake { public List<Point> Body { get; private set; } public Direction CurrentDirection { get; set; } public Snake(Point start, int initialLength = 3) { Body = new List<Point>(); for (var i = 0; i < initialLength; i++) { Body.Add(new Point(start.X - i, start.Y)); } CurrentDirection = Direction.Right; } public Point Move(Direction direction) { return direction switch { Direction.Up => new Point(Body[0].X, Body[0].Y - 1), Direction.Down => new Point(Body[0].X, Body[0].Y + 1), Direction.Left => new Point(Body[0].X - 1, Body[0].Y), Direction.Right => new Point(Body[0].X + 1, Body[0].Y), _ => Body[0] }; } }最后是食物Food。食物更简单,本质上就是一个Position属性,但我会把“随机生成不撞蛇身”的逻辑封装到这个类里,让Game主类保持清爽。你看,这三个类型一出来,整个游戏的数据结构就清楚了,接下来所有逻辑都是围绕怎么操作这份数据展开的。
2.2 游戏主类 Game:把逻辑从 UI 里拔出来
Game类是核心中的核心,它负责管理整个游戏的状态流转,包括:开始、暂停、结束、移动、碰撞检测、得分,还有生成食物。这里有一条铁律:Game类里绝对不出现任何控件引用,不碰Panel、不碰Timer,甚至不碰Form。它只通过事件对外广播“得分变了”“游戏结束了”,UI层收到通知后自己更新界面。
public class Game { public const int GridWidth = 20; public const int GridHeight = 20; private readonly Snake _snake; private readonly Random _random = new(); private Food _food; private readonly Queue<Direction> _directionQueue = new(); private int _speedLevel = 1; public Point FoodPosition => _food.Position; public IReadOnlyList<Point> SnakeBody => _snake.Body; public int Score { get; private set; } public bool IsRunning { get; private set; } public event EventHandler<int> ScoreChanged; public event EventHandler GameOver; public Game() { _snake = new Snake(new Point(GridWidth / 2, GridHeight / 2)); GenerateFood(); } public void QueueDirection(Direction direction) { _directionQueue.Enqueue(direction); } public void Start() { IsRunning = true; } public void Pause() { IsRunning = false; } public void Step() { if (!IsRunning) return; // 消费方向队列,防止同帧内多次按键导致反向 if (_directionQueue.Count > 0) { var next = _directionQueue.Dequeue(); // 不允许掉头 if (!IsOpposite(next, _snake.CurrentDirection)) { _snake.CurrentDirection = next; } } var newHead = _snake.Move(_snake.CurrentDirection); // 撞墙检测 if (newHead.X < 0 || newHead.X >= GridWidth || newHead.Y < 0 || newHead.Y >= GridHeight) { EndGame(); return; } // 头部先插入新位置 _snake.Body.Insert(0, newHead); if (newHead == _food.Position) { Score += 10; ScoreChanged?.Invoke(this, Score); GenerateFood(); // 吃到食物时尾部不删除,长度自然增加1 } else { _snake.Body.RemoveAt(_snake.Body.Count - 1); } // 撞自己检测(跳过头部,其余身体坐标与头部相同即碰撞) if (_snake.Body.Skip(1).Contains(newHead)) { EndGame(); } } private void EndGame() { IsRunning = false; GameOver?.Invoke(this, EventArgs.Empty); } private void GenerateFood() { while (true) { var candidate = new Point(_random.Next(GridWidth), _random.Next(GridHeight)); if (!_snake.Body.Contains(candidate)) { _food = new Food(candidate); return; } } } private static bool IsOpposite(Direction a, Direction b) { return (a == Direction.Up && b == Direction.Down) || (a == Direction.Down && b == Direction.Up) || (a == Direction.Left && b == Direction.Right) || (a == Direction.Right && b == Direction.Left); } }注意看Step()方法里的顺序:先取方向,再计算新头部,然后撞墙检测,接着插入头部,判断是否吃到食物,最后撞自己检测。这个顺序不能乱,尤其“先插入头部再判断撞自己”这一点很关键,因为新头部必须已经在身体列表里,Contains才能判断出重叠。
3. 实操过程与核心环节实现
3.1 搭建界面与游戏循环
项目结构上我建议建一个WinForms项目,直接在Form1里加一个Panel作为画布,加一个Label显示分数,窗体属性KeyPreview设为true,这样才能确保键盘事件先被窗体捕获,而不是被焦点控件吞掉。
游戏循环我用的是System.Windows.Forms.Timer,原因很简单:它的Tick事件在UI线程触发,可以直接更新界面,不用考虑跨线程问题。这个特性在你以后做串口上位机时会深有体会——工作线程里收到的数据想更新界面,必须Invoke回UI线程,非常麻烦,而WinForms Timer天然避开了这个坑。
public partial class MainForm : Form { private readonly Game _game = new(); private readonly Timer _timer; private const int CellSize = 20; public MainForm() { InitializeComponent(); gamePanel.DoubleBuffered = true; _timer = new Timer { Interval = 220 }; _timer.Tick += (_, _) => { _game.Step(); gamePanel.Invalidate(); }; _game.ScoreChanged += (_, score) => lblScore.Text = $"得分: {score}"; _game.GameOver += (_, _) => lblState.Text = "游戏结束! 按空格重新开始"; } protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { switch (keyData) { case Keys.Up: _game.QueueDirection(Direction.Up); return true; case Keys.Down: _game.QueueDirection(Direction.Down); return true; case Keys.Left: _game.QueueDirection(Direction.Left); return true; case Keys.Right: _game.QueueDirection(Direction.Right); return true; case Keys.Space: if (!_game.IsRunning) { _game = new Game(); lblScore.Text = "得分: 0"; lblState.Text = ""; } _game.Start(); _timer.Start(); return true; } return base.ProcessCmdKey(ref msg, keyData); } }这里要重点说一个新手最容易踩的坑:直接用KeyDown事件捕获方向键是失败的。因为方向键在WinForms里默认被当作对话框导航键处理,焦点控件会优先消费这个按键事件。最干净的办法是重写ProcessCmdKey,它能在消息分发的最早阶段拦下按键,不管哪个控件有焦点都能生效。
3.2 移动、吃食物与碰撞检测的精髓
Step()方法里几个细节值得展开讲讲。
移动逻辑其实是“头进尾出”的队列操作:每次先把新头部Insert到列表最前面,如果没吃到食物,就把尾部RemoveAt掉,整体长度不变,蛇就朝当前方向前进了一格;如果吃到了食物,尾部不删除,长度加一。这个思路和现实中的蛇完全一致——吃东西长身体,不吃东西保持原有长度移动。
方向处理的精髓在于方向队列。为什么不用一个字段直接存当前方向?因为如果你在极短的时间内连续按了“上”,再按“左”,实际效果可能变成反方向撞死自己。方向队列会把两次按键都记录下来,每次Step()只消费一个,既不丢失操作,又能防止同帧内多次改变方向导致误判。
碰撞检测分两种情况。撞墙检测好理解,新头部坐标超出网格边界就结束。撞自己要注意一个细节:用_snake.Body.Skip(1).Contains(newHead),跳过头部判断剩余节点中是否存在与新头部相同坐标的节点。为什么不直接Body.Contains(newHead)?因为新头部已经被Insert进列表了,不跳过第一个元素就永远算撞到自己,游戏一开局就结束,那代码就白写了。
3.3 绘制与视觉细节
绘制逻辑写在Panel的Paint事件里。核心思路是:逻辑坐标全是格子数,绘制坐标要把格子数乘以CellSize换算成像素。比如食物坐标为(5, 3),它实际绘制位置就是x=5*20=100, y=3*20=60,绘制一个20x20的矩形。
private void gamePanel_Paint(object sender, PaintEventArgs e) { var g = e.Graphics; g.Clear(Color.FromArgb(20, 20, 20)); // 画食物 var foodRect = new Rectangle( _game.FoodPosition.X * CellSize, _game.FoodPosition.Y * CellSize, CellSize, CellSize); g.FillEllipse(Brushes.OrangeRed, foodRect); // 画蛇身 for (var i = 0; i < _game.SnakeBody.Count; i++) { var p = _game.SnakeBody[i]; var rect = new Rectangle( p.X * CellSize + 1, p.Y * CellSize + 1, CellSize - 2, CellSize - 2); g.FillRectangle(i == 0 ? Brushes.YellowGreen : Brushes.Green, rect); } }绘图这里有一个性能细节:gamePanel.DoubleBuffered = true。这个属性一定要设,不然蛇身和食物在快速移动时会出现明显的闪烁。原因是WinForms内建控件默认没有双缓冲,每次Invalidate触发重绘时,背景擦除和前景绘制之间有间隙,肉眼看起来就是闪。开启双缓冲后,所有绘制先生成在内存画布上,再一次拷贝到屏幕,闪烁就消失了。这条经验在做任何WinForms绘图程序时都通用。
3.4 随机食物与难度控制
生成食物的GenerateFood()用了死循环:随机产生一个坐标,如果落在蛇身上就重新随机,直到找到空闲格子为止。理论上存在极端情况——蛇身占满整个网格导致死循环,但实际操作中这几乎不可能发生,因为20x20网格有400个格子,等蛇长到那么大,游戏早就该赢了。你可以在后面加一个判断,当分数达到一定值宣告胜利,也算是给游戏加个最终结局。
难度控制我建议用速度档位,而不是线性加速:
private void UpdateSpeed() { var newInterval = Math.Max(70, 220 - (Score / 50) * 15); _timer.Interval = newInterval; }每得50分,定时器间隔减少15毫秒,下限控制在70毫秒。为什么不设得更快?因为WinForms Timer基于消息循环,本身精度就有几十毫秒的波动,调到50毫秒以下会出现肉眼可见的“卡顿感”,玩家反而会觉得不稳定。真正要做高精度计时,应该用Stopwatch配合自绘循环,但那是另一套玩法了,对贪吃蛇来说系统Timer足够。
4. 常见问题与排查技巧实录
4.1 新手最容易踩的四个坑
| 问题现象 | 根本原因 | 解决方法 |
|---|---|---|
| 方向键按了没反应 | 方向键被窗体默认导航逻辑吞掉 | 重写ProcessCmdKey方法处理按键 |
| 蛇身闪烁严重 | 控件没有开启双缓冲 | 设置gamePanel.DoubleBuffered = true |
| 连续快速按两个方向后蛇反而撞死 | 同帧内多次修改当前方向导致反向 | 使用Queue<Direction>方向队列,每帧只消费一个 |
| 食物一直生成在蛇身上 | 随机坐标没有排除蛇身占据的格子 | 用while (true)循环重新随机,直到坐标不与蛇身重叠 |
第一条是WinForms特有的坑,我在开发中至少被问过十次。第二条是绘图类程序通用的问题,画质不高级往往就是差一个双缓冲。第三条是我自己写第一个版本时的血泪教训——当时没做方向队列,连续按“上+左”就会出现蛇瞬间掉头、从自己身体中间穿过去,看起来就像开了穿墙挂。第四条属于逻辑严谨性,面试官如果问“食物生成在蛇身上怎么办”,能答出“坐标排除+循环随机”就算过关。
4.2 调试技巧和几个值得琢磨的扩展点
调试技巧分享几条。第一,把GridWidth和GridHeight临时改成5x5,这样几秒就能撞墙、吃食物、撞自己,不用每次等蛇长到很长才能验证边界逻辑。第二,在Step()方法开头加一句Debug.WriteLine($"{DateTime.Now:HH:mm:ss.fff} Step"),能直观看到定时器实际触发频率,排查“是不是Timer不准”这类问题。第三,因为这版代码的Game类不依赖UI,你可以直接写一个控制台程序调用Game类做单元测试,模拟几个场景断言结果,这是最能体现“逻辑与界面分离”价值的地方。
我发现很多初学者做完基础版就停了,其实这里还有不少延伸方向,而且每一个都能让你学到新东西:加一张障碍物地图、每局随机生成几个障碍方块,这练的是二维数组和规则判断;做本地最高分排行榜,顺便练一下JSON序列化和文件读写;把游戏打包成WPF版,你会发现换界面层其实没那么难,因为整个Game类都是现成的。更进一步,这个项目里的事件驱动思想和你以后做C#上位机开发时“串口收到数据触发事件”“扫码枪扫码后触发事件”本质上完全一致;理解了Game类和UI解耦,你就能理解为什么上位机程序要把通讯逻辑和界面逻辑分开。聊到这儿顺便说一句,面试时如果有人问你“C#的委托和事件有什么实际用途”,你完全可以拿这个贪吃蛇项目来说——得分通知UI更新就是一个标准的事件应用。
我个人最大的体会是:贪吃蛇这个项目我写过三遍,第一遍抄别人的代码,第二遍丢掉参考自己重写,第三遍才真正开始想“怎么让代码更清晰”。每一次都有新收获,每一次都能发现自己上一遍的代码有多糙。编程这东西,看十遍教程不如亲手写一遍代码,如果你能把这个项目从头到尾自己做出来,再试着把它拆成逻辑层和界面层加上测试,你对C#的信心和手感会完全不一样。
本文还有配套的精品资源,点击获取