C#贪吃蛇实战:300行代码串联核心编程思维
2026/9/21 1:11:57 网站建设 项目流程

简介:一份面向C#初学者的控制台游戏开发实战资源,通过完整实现经典贪吃蛇,帮助开发者巩固类、方法、变量、条件语句、循环、状态机等核心语法与算法逻辑。资源包共33个文件,体积约70KB,主体为18个.cs源码文件,涵盖场景管理、蛇身移动、食物生成、地图绘制等模块,另有可运行的.exe文件方便直接查看效果,以及json/config等配置文件和sqlite数据文件,便于理解工程结构。目前已有217人学习,说明该资源具备一定参考价值。项目代码将游戏拆分为多个场景与对象,条理清晰,适合逐步阅读。通过研读代码并动手修改,读者可以掌握控制台输入输出处理、碰撞检测、游戏主循环设计等技能,也能学习如何将面向对象思想应用到小型游戏项目中,是C#入门阶段难得的完整小项目练习。 如果让我给刚学C#的朋友推荐一个入门练手项目,大概率会是这个:贪吃蛇。别觉得它小,一个控制台版的贪吃蛇,实际上把C#里最有价值的那几块硬骨头——数据类型、集合、面向对象、事件委托、状态机、输入响应、界面刷新——全部串起来了。我见过不少人一上来就啃上位机、搞Socket、做VisionPro联合编程,结果连一个最基础的“帧循环 + 外部输入变更状态”都讲不清楚。贪吃蛇恰恰能把这个思维理顺,而且不到三百行代码就能跑起来,成就感来得特别快。

这篇文章不是给你贴一份完整源码就完事,我会把整个项目的设计思路、核心原理、实操时容易踩的坑都拆开讲。不管你是刚入门想找项目练手,还是准备面试想拿一个能讲透的项目,又或者后面要做C#上位机开发、设备通讯这类偏工业的场景,这篇都值得你认真看一遍。

1. 为什么拿贪吃蛇当C#实践项目

1.1 麻雀虽小,五脏俱全的知识点覆盖

贪吃蛇这个游戏,规则一句话就能说清楚:控制一条蛇吃食物,每吃一个就变长,撞墙或者撞到自己就结束。但恰恰是这么简单的规则,背后需要的技术点几乎覆盖了C#面试和日常开发的高频区域。

我把这个项目里真正被用到的知识点列一张表,你就知道它的含金量了:

知识点在贪吃蛇里的落点实际价值
数据类型(int、byte、char、bool)坐标、方向、游戏状态、按键值类型选择和转换的基础训练
枚举(enum)方向枚举、游戏状态枚举避免魔法数字,代码可读性关键
结构体(struct)坐标点Point值类型使用场景
List 集合存放蛇身节点泛型集合增删改查的典型场景
面向对象(类、职责划分)Snake、Food、GameManager、Renderer单一职责原则的反复实践
委托与事件吃到食物事件、游戏结束事件解耦模块之间的依赖
循环与线程控制游戏主循环、Thread.Sleep调速控制台版帧循环的核心
状态机Ready、Running、Paused、GameOver复杂逻辑梳理利器

很多初学者把这些知识点单独拎出来都能看懂,一放到真实项目里就不知道怎么组合。贪吃蛇就是那个最合适的“组合练习场”。

1.2 游戏循环和上位机开发的思考方式其实是同一套

这一点我想多说两句。热词里出现了一堆“C#上位机”、“扫码枪触发事件”、“Socket通讯”,乍看跟贪吃蛇八竿子打不着,但如果你动手写过贪吃蛇的控制逻辑,再去搞设备通讯,会发现脑子里的模型是一样的:循环等待外部事件,收到事件后更新内部状态,然后刷新界面。

扫码枪触发事件本质是什么?就是设备输入导致系统状态变化,跟你在贪吃蛇里按方向键让蛇转向,机制上没有任何区别。Socket收包后更新界面也是如此:收到数据、处理数据、重绘界面。先在小项目里把这个“事件循环 + 状态更新 + 界面刷新”的骨架做熟了,后面上手真实项目才会觉得似曾相识,而不是两眼一抹黑。

2. 整体设计与思路拆解

2.1 技术选型:控制台版还是WinForms版

做贪吃蛇,第一步要定的就是形态。网上有控制台版、WinForms版、WPF版,我给的结论很直接:第一次做,就做控制台版

表格对比一下:

方案优点缺点适合人群
控制台版无任何UI依赖、代码聚焦核心逻辑、启动快画面简陋、刷新机制需自己处理新手入门、面试讲原理
WinForms版有画面、System.Windows.Forms.Timer处理刷新引入UI线程概念、复杂度上升有一定基础、想练UI事件
WPF版画面漂亮、MVVM可拓展数据绑定和渲染逻辑对新手不友好只推荐进阶者尝试

控制台版最大的好处是“逼着你手动处理游戏循环”。在WinForms里你拖一个Timer控件,Tick事件一写就完事了,很多底层机制反而被框架藏起来了。控制台版不一样,每一步都由你亲手控制:什么频率刷新、什么时候读键盘、怎么重绘蛇身,全部可见,这才叫吃透了原理。

2.2 数据结构的选型思路

整个游戏里最核心的数据结构就三样:方向、坐标、蛇身集合。

方向用一个枚举:

public enum Direction { Up, Down, Left, Right }

为什么不用int或者字符串?因为方向只有四个取值,用枚举在编译期就能排除非法值,你在代码里写direction = Direction.Up,任何智商正常的程序员一眼就能读懂。你要是用direction = 1,三天后你自己都忘了1是上还是下。

坐标点用一个结构体:

public struct Point { public int X { get; set; } public int Y { get; set; } public Point(int x, int y) { X = x; Y = y; } }

C#里Point结构体的一个天然优势是值比较,两个Point的X和Y相同,它们就相等,List.Contains就能直接判断某个点是否在蛇身上。你要是用类(class)来做,默认是引用比较,每次还得手动比较X、Y,麻烦不少。这里用struct比用class更合适,也是很多面试官会追问的点。

蛇身集合用List :

private List<Point> snake = new List<Point>();

为什么不用数组?因为蛇的长度是动态变化的,数组定长不可变,每次吃食物扩容还得你自己写逻辑,纯粹给自己找麻烦。为什么不用LinkedList?看起来蛇头插入蛇尾删除是链表的长处,但在控制台贪吃蛇里,Snake需要频繁按下标访问节点做碰撞检测,List的随机访问优势更明显。这个场景里,List就是综合最优解。

顺带提一个细节,坐标类型我用了int,其实用byte也能装下,因为控制台窗口宽高一般不会超过255。但实际项目中,为了后续扩展不出问题,int更稳妥。不是所有场景都要“最省内存”,代码的可维护性永远排在无谓的优化前面

2.3 单例模式与职责划分

我见过很多人写贪吃蛇,所有代码全塞在Main里,写了两三百行,逻辑一团浆糊。这个项目虽然小,但我建议一开始就用面向对象的思维来拆:

  • Snake类:维护蛇身集合和移动逻辑
  • Food类:生成食物、判断是否被吃到
  • GameManager类:游戏主循环、状态管理、碰撞检测
  • Renderer类:专门负责绘制界面

为什么这样拆?因为有明确的单一职责:Snake只管自己怎么动,Food只管自己出现在哪,Renderer只管画,GameManager只管协调。以后你改食物颜色、改蛇的移动速度、加个暂停功能,都只动对应模块,不用在几百行代码里大海捞针。

GameManager可以考虑用单例模式实现。因为这个游戏的状态全局只有一份,不管从哪个模块访问,都应该是同一个GameManager实例,避免出现“这个模块改了一份状态、那个模块读的是另一份”的诡异问题。C#写单例最简洁的方式:

public sealed class GameManager { private static readonly Lazy<GameManager> _instance = new Lazy<GameManager>(() => new GameManager()); public static GameManager Instance => _instance.Value; private GameManager() { } }

Lazy<T>是C#里实现线程安全单例最优雅的方式,启动时才真正创建实例,不用你手动处理lock。贪吃蛇本身是单线程的,用不上并发保护,但提前掌握这个写法,以后做多线程项目直接用就行。

3. 核心细节解析与实操要点

3.1 方向控制与按键缓冲:别让蛇“反向穿身”

蛇的移动逻辑很好理解:每次在蛇头方向插入一个新节点,如果吃到食物就保留尾节点,没吃到就移除尾节点。但新手最容易犯的错误是——允许180度掉头。比如蛇正在向左走,你按一下右键,蛇头直接朝右,瞬间撞到第二节身体,游戏结束。

这个问题的本质是:一个合法的新方向,不能和当前方向相反。处理方式是在改变方向的入口统一拦截:

private void ChangeDirection(Direction newDirection) { if (newDirection == Direction.Up && direction == Direction.Down) return; if (newDirection == Direction.Down && direction == Direction.Up) return; if (newDirection == Direction.Left && direction == Direction.Right) return; if (newDirection == Direction.Right && direction == Direction.Left) return; direction = newDirection; }

还有个细节很容易忽略:如果用户一帧内快速按了两个方向键,比如先按上再按左,但此刻蛇还在向右走,这两次按键都生效的话,蛇会先向右上移动再立即向左上移动,看起来就像“穿过自己”。更合理的做法是只允许每帧处理一个输入,或者用队列存储输入、每帧只取一个。控制台版贪吃蛇对精度要求不高,我给的方案是在一次循环里只处理一次按键:

if (Console.KeyAvailable) { ConsoleKeyInfo key = Console.ReadKey(true); // 只处理当前读取到的这一次按键 }

这样能避免输入缓冲叠加导致的鬼畜转向,实测下来手感最正常。

注意:Console.ReadKey(true)里的参数true表示不把按下的字符回显到控制台,这个必须带上,否则屏幕上会出现一堆残留按键字符。

3.2 碰撞检测的边界与细节

碰撞检测是游戏的胜负手,分两种:撞墙和撞自己。

撞墙的检测比较简单,拿到新蛇头坐标,看是否越过控制台的绘制区域边界:

private bool IsWallCollision(Point head) { return head.X <= 0 || head.X >= Console.WindowWidth - 1 || head.Y <= 0 || head.Y >= Console.WindowHeight - 1; }

这里有个细节值得注意:控制台窗口的坐标原点在左上角,X轴向右,Y轴向下,最右和最下可绘制的坐标是WindowWidth-1和WindowHeight-1。所以边界判断时,要防止蛇头进入那个会触发自动换行的坐标。保留边距一方面为了视觉舒适,另一方面是为了避免蛇身字符被控制台缓冲自动换行搞乱。

撞自己有两种实现思路。一种是遍历蛇身所有节点,逐一和蛇头比较坐标;另一种是用一个HashSet<Point>来存储蛇身坐标,查重复杂度O(1)。控制台贪吃蛇的蛇身长度一般不会超过几百,List遍历完全够用,但如果你想把代码写得有点“性能意识”,可以用HashSet做辅助检测。我记得C#里的hashset有Contains方法。不过要注意蛇头在移动后的瞬间,旧蛇尾可能还没移除,判断时应该排除蛇尾所在节点,否则会造成“自撞误判”。

3.3 绘制与刷新:千万别用Console.Clear

这是控制台贪吃蛇里最影响体验的细节。很多新手图省事,每帧直接Console.Clear()然后整个重画,一跑起来就发现屏幕疯狂闪烁,眼睛都快瞎了。

原因在于Console.Clear()清空整个缓冲区再逐字重绘,这个开销很大,而且会有明显的闪烁。正确的做法是定点绘制:只在新蛇头位置画一个方块,在旧蛇尾位置画一个空格,食物变化时再单独更新食物位置。

核心绘制逻辑大致是这样:

// 绘制新头 Console.SetCursorPosition(newHead.X, newHead.Y); Console.Write("■"); // 绘制食物 Console.SetCursorPosition(food.X, food.Y); Console.Write("●"); // 如果没有吃到食物,擦掉旧尾 if (!isEating) { Console.SetCursorPosition(oldTail.X, oldTail.Y); Console.Write(" "); }

每帧只写几个字符,闪烁问题自然消失。这也是控制台编程里一个很重要的思想:局部刷新代替全量刷新,放到上位机开发里就是UI更新的性能优化思路,原理一模一样。

4. 实操过程与核心环节实现

4.1 搭建项目骨架与全局配置

先创建一个控制台项目,我用的是.NET 6以上的模板,直接用Console.WriteLine做输出。项目结构建议按职责建四个文件:Snake.cs、Food.cs、Renderer.cs、GameManager.cs。

先定义全局配置类,把边界、初始速度、初始蛇身都集中管理:

public static class GameConfig { public const int Width = 60; // 地图宽度 public const int Height = 20; // 地图高度 public const int InitialSpeed = 120; // 初始帧间隔ms public const int SpeedStep = 5; // 每吃一个加速多少ms public static Point StartHead => new Point(Width / 2, Height / 2); public static Point StartTail => new Point(Width / 2 + 1, Height / 2); }

把配置独立出来的意义在于:以后想调整地图大小,改GameConfig一处即可,不用在逻辑代码里到处翻魔法数字。

4.2 游戏主循环与事件响应

游戏主循环是整个项目的发动机,我建议用while循环加Thread.Sleep控制帧率,而不是用Timer。道理很简单:控制台应用里用Timer,事件回调在不同线程,还得处理线程同步;用while + Sleep所有逻辑都在主线程顺序执行,代码逻辑更直观。

主循环的骨架:

public void Run() { Initialize(); DrawAll(); while (gameState == GameState.Running) { HandleInput(); Update(); Renderer.Instance.Draw(this); Thread.Sleep(currentSpeed); } Renderer.Instance.ShowGameOver(score); }

这里我用了状态机而不是简单的bool标记。GameState枚举我至少定义四个值:Ready、Running、Paused、GameOver。有了状态机以后,加暂停、加游戏结束界面都特别顺手。

键盘响应依赖Console.KeyAvailableConsole.ReadKey配合:

private void HandleInput() { if (!Console.KeyAvailable) return; ConsoleKeyInfo key = Console.ReadKey(true); switch (key.Key) { case ConsoleKey.UpArrow: case ConsoleKey.W: ChangeDirection(Direction.Up); break; case ConsoleKey.DownArrow: case ConsoleKey.S: ChangeDirection(Direction.Down); break; case ConsoleKey.LeftArrow: case ConsoleKey.A: ChangeDirection(Direction.Left); break; case ConsoleKey.RightArrow: case ConsoleKey.D: ChangeDirection(Direction.Right); break; case ConsoleKey.Spacebar: TogglePause(); break; } }

KeyAvailable的作用是非阻塞检查:有按键才读取,没按键直接返回,保证游戏循环不会卡在ReadKey那里等输入。这个API的语义很清晰:查询 + 读取。这里一定要先查再读,不然读操作会把循环阻塞住。

4.3 食物生成、计分和游戏状态管理

更新逻辑里最核心的就是蛇的移动:

private void Update() { Point newHead = GetNextHead(); bool isEating = newHead.Equals(food); // 保存旧尾部,用于擦除 Point oldTail = snake[snake.Count - 1]; // 插入新头 snake.Insert(0, newHead); if (isEating) { score++; currentSpeed = Math.Max(40, currentSpeed - GameConfig.SpeedStep); GenerateFood(); OnFoodEaten?.Invoke(this, EventArgs.Empty); } else { // 没吃到就移除尾部 snake.RemoveAt(snake.Count - 1); Renderer.Instance.ClearPoint(oldTail); } if (IsCollision(newHead)) { gameState = GameState.GameOver; OnGameOver?.Invoke(this, EventArgs.Empty); } }

食物生成要注意:不能生成到蛇身上。用do-while循环配合snake.Contains来检测:

private void GenerateFood() { Random rnd = new Random(); Point newFood; do { newFood = new Point(rnd.Next(1, GameConfig.Width - 1), rnd.Next(1, GameConfig.Height - 1)); } while (snake.Contains(newFood)); food = newFood; Renderer.Instance.DrawFood(food); }

这段代码里还埋了一个事件——FoodEaten。别小看这个事件,它是我刻意加进来的解耦设计:GameManager只管发事件,至于积分UI怎么刷新、要不要播放音效、要不要写日志,都是订阅者的事。这正是C#上位机开发里最常用的模块解耦方式,你在处理扫码枪触发、Socket收包通知UI刷新时,用的也是这一套。

5. 常见问题与排查技巧

5.1 典型Bug和排查实录

控制台贪吃蛇虽然简单,但我在帮人review代码时见过不少重复的坑,把这些真实问题整理成下表,你写的时候对照着自查:

现象根本原因解决方法与心得
画面疯狂闪烁每帧调用Console.Clear全量重绘改成SetCursorPosition定点绘制,只更新蛇头、蛇尾、食物位置
蛇可以180度掉头方向切换时没有检查当前方向统一收口到ChangeDirection方法,返回前判断是否反向
按键响应不灵,卡顿感明显直接调用Console.ReadKey导致主循环阻塞使用Console.KeyAvailable先查询是否有输入,再读取
食物生成在蛇身上随机坐标只调用一次,未做冲突检查使用do-while循环,结合snake.Contains重新生成
分数显示区域被蛇身覆盖绘制顺序错误,比分和地图共用一块区域预留固定区域画UI,蛇移动时避开该区域
蛇加速后越来越难控制每吃一个食物就大幅减少Sleep时间用Math.Max设置最小间隔下限,避免无限加速

其中最经典的就是方向掉头和闪烁问题,这两个几乎是每个新手必踩的坑,也是面试官最爱问的“你在这个项目里遇到过什么问题”的绝佳素材。能把背后的原因和解决方案讲清楚,这个项目的含金量在面试里完全够用了。

5.2 从贪吃蛇到真实项目的扩展方向

如果你不满足于让蛇跑起来,这里给你几个思考方向,每一个都对应着实际工程里的某项能力:

方向一:加入事件驱动机制改造。把吃到食物、游戏结束、暂停恢复都改成事件发布,让计分、绘制、音效都作为订阅方存在。做完这一步,你就基本掌握了C#里事件和委托的实战用法,后面做上位机扫码枪触发、Socket收包通知界面刷新,都顺理成章。

方向二:把控制台换成WinForms或WPF。用Timer替换Thread.Sleep,用控件事件替换Console.ReadKey。你会直观地感受到UI线程和后台任务的区别,这是做任何带界面的C#项目都绕不开的知识点。

方向三:加入序列化和存档系统。把最高分保存到本地文件,启动时读取。这一步能帮你吃透FileStreamReader/WriterJsonSerializer,这些在真实项目里几乎是每天都要用的。

方向四:网络版贪吃蛇。用TCP或UDP实现双人对战,一个人控制一条蛇。涉及Socket编程、序列化协议、多线程,这已经摸到成熟网络项目的门槛了。

热词里还有像“反射”、“单例模式”、“委托事件”这种东西,我的建议是:贪吃蛇这个项目重点用单例和事件就够了,反射这类进阶特性不要硬塞进来,容易把项目搞得复杂无比。先把基础打牢,后面单独练反射不迟。

根据我个人经验,做完贪吃蛇后,我建议你顺手把它改动几次:第一次改成用事件解耦,第二次改成存读档,第三次试着包一层简单的UI。这个过程比照着教程敲十遍代码都管用。等你把这几步都走完,回头再碰“上位机开发”、“Socket通讯”这些话题,你会发现那些高深概念的内核早就见过了——无非是事件循环、状态更新、界面刷新这三件事的循环往复。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询