C#贪吃蛇实战:从状态机到数据结构的完整练手指南

发布时间:2026/10/11 13:45:25
C#贪吃蛇实战:从状态机到数据结构的完整练手指南
简介面向C#初学者的一套控制台小游戏实战资源用经典贪吃蛇串联起面向对象、基础语法与交互逻辑适合正在学习C#核心语法、想通过小项目加深理解的开发者。资源共33个文件主体为18个.cs源码覆盖游戏场景、蛇身与食物、地图绘制等核心模块同时包含2个exe可运行程序、2个json及配置缓存等辅助文件方便直接运行并对照阅读。整个压缩包仅70KB轻量紧凑目录按场景和对象拆分便于定位代码。已有217人学习下载。通过完整源码可清晰看到从游戏开始界面、主循环、按键处理、碰撞检测到结束判定的全过程既能积累控制台编程经验也能在此基础上扩展计分、加速、关卡等玩法是巩固C#基础、锻炼逻辑思维的实用练手项目。1. 语法都学完了为什么还是写不出程序贪吃蛇这个项目到底在练什么很多人在学完 C# 的类、集合、继承、委托之后卡在了同一个地方语法全会一写就废。看别人敲代码都懂轮到自己面对空白的解决方案窗口不知道第一行该写什么。“C#核心-实践小项目贪吃蛇”这个标题其实就是给这类人准备的。贪吃蛇看着简单却能把 C# 核心语法全部串起来状态流转、集合增删、类的职责划分、帧循环控制甚至还能往状态机和自动寻路的方向延伸。它不像图书管理系统那样要纠结数据库设计也不像上位机项目那样依赖硬件环境一个控制台窗口就能跑起来却足够覆盖从“会语法”到“会写程序”之间那段最关键的过渡。这篇实战笔记就从架构设计讲起一步步走到能玩的版本再告诉你最容易在哪翻车。2. 写贪吃蛇前先画架构状态机、数据结构和类的职责划分2.1 从游戏循环到状态机蛇的一生只有四个状态贪吃蛇表面上是“移动 吃食物”本质是一个有限状态机。我见过很多人上来就写while (true)把逻辑全堆在循环里写到一半想加暂停功能发现代码已经改不动了。一个干净的贪吃蛇状态机只需要四个状态Menu等待开始、Playing游戏中、Paused暂停、GameOver结束。用 C# 的枚举来定义public enum GameState { Menu, Playing, Paused, GameOver }在游戏主循环里每一帧先看当前状态再决定执行什么逻辑。状态切换用switch或状态机模式都能做初学者用switch足够。提示把状态枚举放在单独的.cs文件里或者和 Game 类放一起不要散落在各个窗体代码中。为什么一定要先定义状态因为“暂停”这个功能没有状态机时只能用一个bool isPaused来控制但游戏里还有“游戏结束”“回到菜单”等场景bool 会越堆越多。状态机让每个状态的进入和退出都清晰可控这是 C# 核心面向对象思想里“职责单一”的第一步实践。2.2 蛇身用什么数据结构List 是新手最常见的正确选择蛇身是一个不断在头部添加新格子、在尾部移除旧格子的线性结构。初学者最容易纠结的是用ListPoint、QueuePoint还是二维数组。我的建议是第一版用ListPoint。public class Snake { public ListPoint Body { get; private set; } public Direction CurrentDirection { get; set; } public Snake(Point startPosition) { Body new ListPoint { startPosition }; CurrentDirection Direction.Right; } }Queue的入队出队语义和蛇移动很像但有一个问题游戏需要频繁判断“蛇头是否撞到身体的某一节”Queue不支持随机访问每次都要ToArray()转一遍代码很别扭。List支持按下标访问检查碰撞时直接Body.GetRange(0, Body.Count - 1).Contains(newHead)就能完成。至于二维数组它适合表示整张地图的静态格子比如哪个格子是墙、哪个格子是食物。但蛇身是在动态移动的每次移动都要修改数组中多个坐标更新成本高。所以我在项目里会用二维数组做地图静态层用ListPoint做蛇身动态层两者各司其职。2.3 三个类打天下Game、Snake、Food 的职责边界很多人写贪吃蛇把所有代码塞进一个Program.cs或Form1.cs。这样写到 200 行时就会开始混乱。我的习惯是拆成三个类外加一个枚举文件。类名职责关键成员Game主循环、状态流转、碰撞判定GameState StateRun()Snake蛇身数据、移动逻辑、转向ListPoint BodyMove()Food食物位置、随机生成Point PositionGenerate()划分逻辑很简单和蛇身体相关的操作放在Snake和食物相关的放在Food两者之间的交互由Game协调。比如“蛇吃到食物”这件事Snake只负责提供蛇头位置Food只负责判断食物是否在某个坐标真正推进游戏进程的是Game。这个划分在 C# 面试里会很加分。面试官看到你主动拆分了类而不是把 300 行代码全堆在按钮点击事件里至少说明你理解了封装和职责分离。做这个练习时刻意把“蛇头移动”的动作封装成Move(Point newHead)将来要扩展成 AI 自动寻路只需要改Game里的策略Snake类一行都不用动。3. 用控制台跑通最小贪吃蛇核心循环、移动算法与碰撞判定3.1 主循环与帧控制Thread.Sleep 是最好的入门节奏器控制台版贪吃蛇不需要依赖任何第三方库核心就是一个while循环。关键在于两点如何控制每一帧的间隔以及如何非阻塞地读取按键。public void Run() { while (_state ! GameState.GameOver) { if (_state GameState.Playing) { HandleInput(); MoveSnake(); CheckCollision(); Render(); } Thread.Sleep(_intervalMs); } }这里的Thread.Sleep(_intervalMs)就是帧率控制。_intervalMs表示每帧之间的间隔毫秒数值越小蛇跑得越快。初学者容易犯的错是把这个间隔设得太小比如 10ms结果蛇快得根本来不及按键——我用 150ms 起步等手感上来了再逐步降到 80ms。注意Thread.Sleep会阻塞当前线程但在控制台游戏里这就是我们要的效果让循环固定节奏地跑而不是空转占满 CPU。3.2 蛇身移动的核心算法从尾部向前推蛇移动的本质是“身体每一节都移动到前一节的位置”。用ListPoint实现时如果不做任何优化你会写出两层循环每个格子都往前挪一位。这种写法能跑但会出现一个严重问题蛇的身体整体平移会有延迟感而且代码很绕。正确做法是“先算新蛇头再决定是否保留蛇尾”public void Move(Point newHead, bool grow false) { Body.Insert(0, newHead); if (!grow) { Body.RemoveAt(Body.Count - 1); } }这段逻辑很关键。Insert(0, newHead)把新蛇头插到最前面没有吃到食物时RemoveAt把最后一节尾巴移除看起来就是蛇整体向前挪了一格。grow参数在吃到食物时传true不删除尾巴蛇就变长一节。整个过程时间复杂度 O(n)其中 n 是蛇身长度以贪吃蛇的长度规模完全够用。计算新蛇头的位置就要看当前方向Point head _snake.Body[0]; int newX head.X; int newY head.Y; switch (_snake.CurrentDirection) { case Direction.Up: newY--; break; case Direction.Down: newY; break; case Direction.Left: newX--; break; case Direction.Right: newX; break; } Point newHead new Point(newX, newY); _snake.Move(newHead, targetIsFood(newHead));方向枚举的值和地图坐标的对应关系是新手第一个容易晕的地方。控制台和窗体版的 Y 轴方向不一致控制台里 Y 向下是正方向窗体里 Y 向下也是正方向但如果你用 WinForms 的PictureBox且开启了某些坐标变换方向就可能反了。这个我在第 5 章的避坑部分还会再提。3.3 碰撞判定与随机食物三个边界条件缺一不可碰撞判定是贪吃蛇的灵魂。撞墙、撞自己、吃到食物三种逻辑要在每次移动后马上检查。下面的代码把三种情况合并成一个方法private void CheckCollision() { Point head _snake.Body[0]; // 撞墙检测 if (head.X 0 || head.X _mapWidth || head.Y 0 || head.Y _mapHeight) { _state GameState.GameOver; return; } // 撞自己检测从下标1开始查蛇头永远不会和蛇头相撞 for (int i 1; i _snake.Body.Count; i) { if (_snake.Body[i] head) { _state GameState.GameOver; return; } } // 吃到食物检测 if (head _food.Position) { _snake.Move(head, grow: true); // 这点是关键先移动再补一次生长移动 _score; _food.Generate(_snake.Body); } }这里有一个非常有迷惑性的坑。你在 MoveSnake 方法里已经把蛇移动过一次了到了 CheckCollision 里如果蛇头正好停在食物上再调用一次_snake.Move(head, grow: true)相当于蛇头原地不动只是身体多了一节。这种“两次移动”的写法容易让人困惑。更清晰的做法是在MoveSnake()返回“蛇头是否踩到食物”的布尔值主循环里根据这个值决定要不要二次调用。我自己的习惯是分两步走。移动方法只负责移动返回当前帧是否吃到食物碰撞检测只负责结果判定。把“移动”和“判定”混在一起是后面调 Bug 时最难受的体验。食物生成要避开蛇身这是随机数边界问题public void Generate(ListPoint snakeBody) { Random rnd new Random(); Point newPos; do { newPos new Point(rnd.Next(1, _mapWidth - 1), rnd.Next(1, _mapHeight - 1)); } while (snakeBody.Contains(newPos)); Position newPos; }do-while保证生成的坐标不会落在蛇身上但也要注意Random实例不要每次调用都新建。放在类的构造函数里初始化或者作为静态字段否则在高频率生成时可能产生大量重复随机序列。3.4 非阻塞按键读取Console.KeyAvailable 的正确用法控制台的Console.ReadKey()是阻塞式的。你按一下它读一下。如果游戏循环里每帧调用一次蛇会卡住等你按键只有按了键才继续跑。这在贪吃蛇里会变成“按一下走一下”完全没法玩。正确做法是每帧先检查是否有按键有才读取private void HandleInput() { if (Console.KeyAvailable) { ConsoleKeyInfo key Console.ReadKey(true); switch (key.Key) { case ConsoleKey.W: _snake.CurrentDirection Direction.Up; break; case ConsoleKey.S: _snake.CurrentDirection Direction.Down; break; case ConsoleKey.A: _snake.CurrentDirection Direction.Left; break; case ConsoleKey.D: _snake.CurrentDirection Direction.Right; break; } } }Console.KeyAvailable返回当前缓冲区里是否有按键等待读取不会阻塞。Console.ReadKey(true)的true表示不把按键回显到控制台否则你玩两分钟屏幕上全是 WASD 字母。这段代码还有个隐患转向判定没有做“不能反方向”的检查。比如蛇正在向右移动你按了 A 键蛇会直接掉头往左然后撞到自己。这个逻辑我在第 5 章专门讲但你现在就可以在 switch 分支里加上判断——比如if (_snake.CurrentDirection ! Direction.Left) _snake.CurrentDirection Direction.Right;。4. 从黑框框升级到 WinFormsTimer 节奏与双缓冲绘图4.1 为什么控制台版够练核心但还是建议升到窗体版控制台版练的是 C# 核心语法集合操作、枚举、状态机、类设计。但它有一个天然短板——没有图形界面所有画面都用字符拼凑看不到真正的“游戏感”。另一方面做上位机的朋友应该知道WinForms 里有一个很核心的控件用法是Timer、PictureBox和双缓冲绘图贪吃蛇恰好能把这三样一次性练到。把控制台版升级到 WinForms 版核心逻辑基本不用改Snake类、Food类、状态枚举全部原样复用。重点是把Console的字符渲染换成 GDI 绘图。这个过程会让你真实体会到“UI 层与业务逻辑分离”的价值——逻辑类没动只换了一层皮。4.2 Timer 参数怎么设真正的驱动核心是 Tick 事件WinForms 里没有游戏循环取而代之的是System.Windows.Forms.Timer。它的Interval属性就是帧间隔和Thread.Sleep参数含义相同。private Timer _gameTimer; private void InitTimer() { _gameTimer new Timer(); _gameTimer.Interval 150; _gameTimer.Tick OnGameTick; _gameTimer.Start(); } private void OnGameTick(object sender, EventArgs e) { HandleInput(); MoveSnake(); CheckCollision(); gamePanel.Invalidate(); // 请求重绘触发 Paint 事件 }Interval 150代表每 150ms 触发一次Tick事件一秒钟大约 6.7 帧这是新手比较容易上手的节奏。想提高难度就调小想放慢就调大。注意 WinForms 的Timer是基于 Windows 消息循环的最小有效精度约 15ms如果你设置Interval 1实际上最快也只能达到 15ms 左右的间隔这是系统级的限制跟游戏逻辑无关。4.3 双缓冲与 Paint 事件让画面不再闪成“万花筒”窗体版第一个让人崩溃的问题就是闪烁。每次Invalidate()后Paint事件里要画整幅画面背景、食物、蛇身。如果逐笔绘制画面会明显闪烁。解决方法是双缓冲WinForms 里只需要一行设置public MainForm() { InitializeComponent(); DoubleBuffered true; }DoubleBuffered true让控件先在内存画布上绘制完成后一次性拷贝到屏幕彻底消除闪烁。这行代码要在构造函数里设置而且最好设置在承载画布的那个控件上比如PictureBox或自定义Panel。有些人设置了DoubleBuffered但设置错了对象窗体整体还是闪原因就在这里。如果你要绘制大量格子自定义一个继承Control的类并开启双缓冲更合适public class GameCanvas : Control { public GameCanvas() { DoubleBuffered true; BackColor Color.Black; } }在Paint事件里绘制蛇身时用Graphics.FillRectangle按格子大小刷色块而不是用DrawLine画线这样画面更清晰性能也更好。墙的厚度、格子的颜色都可以做成参数方便后面调难度。5. 贪吃蛇项目避坑指南新手翻车率最高的 5 个细节5.1 反方向掉头蛇为什么突然撞死自己现象蛇正在向右移动你按了 A 键蛇没有向左拐弯而是直接原地掉头然后撞在自己身体上。很多新手会以为这是渲染问题反复改绘图代码实际上根源在输入检测。原因方向检测里没有排除“当前方向的相反方向”。当蛇头朝右时蛇身第一节在左边向右移动的新蛇头坐标是(head.X 1, head.Y)如果此时把方向改成向左新蛇头坐标变成(head.X - 1, head.Y)这个位置正好是原来蛇身的脖子位置撞自己导致 GameOver。解决在HandleInput里对每个方向做合法性判断。更优雅的方案是在Snake类里加一个TryChangeDirection(Direction newDirection)方法内部判断newDirection不能是CurrentDirection的相反方向。这比在主循环写如果if (key A direction ! Right)要规范得多。我以往的经验是处理这个 Bug 的最好方式不是等撞了再修而是在Snake.SetDirection内部把非法转向当成“忽略本次输入”处理而不是报错。游戏体验上玩家快速按两下方向键是常事灵敏的响应和防反串逻辑要同时存在。5.2 蛇身移动用错数据结构List 插入头部 vs 尾部的性能陷阱现象蛇吃到 30 个食物左右开始出现明显卡顿蛇身移动不像之前流畅。原因ListPoint.Insert(0, newHead)在头部插入时内部要把所有已有元素向后移动一位复杂度 O(n)。当蛇身长度到达 50、100 时每帧都要移动 100 个元素加上碰撞检测的Contains又是 O(n)双重叠加导致明显变慢。解决贪吃蛇的蛇身长度一般不超过几百个点这个性能问题在现实里并不会真的让游戏崩溃但关键在于“每帧做多少事”。如果你在Move方法里做了插入、删除、碰撞检测三次 O(n) 操作长度 100 时每帧就是 300 次操作。优化做法是碰撞检测时优先判断蛇头周围的八个点而不是全表扫描或者用HashSetPoint保存蛇身坐标的哈希集合用空间换时间但更新时要注意同步。我给初学者的建议是先老老实实用List跑通卡顿问题大概率出现在你已经把游戏做完整之后。这时候来优化才能体会到数据结构的真实价值。5.3 Console.ReadKey 阻塞导致游戏卡死现象控制台版游戏启动后没有任何反应蛇不动按方向键才有反应且蛇只走一步就停。原因游戏主循环里用了Console.ReadKey()而不是Console.KeyAvailable ReadKey(true)。ReadKey阻塞当前线程直到有按键输入所以蛇的移动被卡在了等待输入上。解决按第 3 章的方式改成KeyAvailable轮询。如果你的循环结构是“每帧先读输入再移动”ReadKey就会成为最大瓶颈。注意Console.KeyAvailable也并非万无一失——在某些远程终端、调试模式或特殊控制台环境下KeyAvailable可能始终返回false这时游戏会表现成“蛇不会动”。这是环境差异问题不是逻辑问题代码里不要依赖KeyAvailable为唯一输入通道。我在调试时遇到过Console.ReadKey(true)在 Visual Studio 输出窗口和独立控制台窗口里行为不一致的情况。建议发布前在任何环境下都先跑一遍。5.4 食物生成出界或生成在蛇身内Random 边界和死循环现象食物偶尔显示在墙壁里或蛇身上又或者游戏在生成食物时“卡死”几分钟。原因Random.Next(min, max)的max是上限不包含值。如果地图是40x30格你写rnd.Next(0, _mapWidth)可能得到 39 但不可能得到 40这没问题但如果你把坐标直接映射到绘图区域时忘了乘格子大小食物就会画到界外。死循环则是因为地图太满、随机生成点与蛇身冲突后一直在重试。解决食物生成坐标的两个维度都落在 [1, width-2] 和 [1, height-2] 区间留出边界墙的位置。生成逻辑里加一个最大重试次数Point GenerateFoodPosition(ListPoint snakeBody, int width, int height) { Random rnd new Random(); int maxAttempts 1000; for (int i 0; i maxAttempts; i) { int x rnd.Next(1, width - 1); int y rnd.Next(1, height - 1); Point pos new Point(x, y); if (!snakeBody.Contains(pos)) return pos; } return new Point(1, 1); // 兜底地图几乎被蛇填满直接胜利 }如果没有这个兜底逻辑当蛇身几乎占满地图时随机生成会陷入指数级增长的等待时间——这就是你看到“卡死”真相。处理边界问题时也算是把随机数和循环边界都摸清了。5.5 WinForms 闪屏和 Timer 不准时现象WinForms 版蛇身移动时画面一帧一帧闪烁像走马灯或者游戏速度越来越快。原因闪屏八成是DoubleBuffered没有设置在真正的画布控件上。速度越来越快则是因为多个Timer实例被反复启动——比如每次点击“开始游戏”按钮就new Timer()没有释放旧的导致多个 Tick 事件叠加执行。解决开启双缓冲的方法见 4.3 节。对 Timer 生命周期必须严格管理在Form_Load里初始化一次在Form_Closed里Stop并Dispose每次重新开始游戏时只Start()不重新 new。private void RestartGame() { _snake.Reset(); _score 0; _gameTimer.Stop(); _gameTimer.Start(); // 同一实例先停再启 }这个写法看起来平平无奇但能让你的游戏避免越玩越快的玄学问题。每次new Timer()等于多一个独立的消息循环驱动器旧的那个又没有停它们的 Tick 事件都在改蛇的坐标会出现速度叠加。凡是遇到“速度逐渐加快”的情况先检查是不是有多个 Timer 在跑。6. 加餐给蛇装上 BFS 自动寻路再顺手把最高分存下来6.1 BFS 自动寻路用广度优先搜索找到最短路径做完一个能玩的贪吃蛇这项目其实已经可以达到“看懂”的阶段。但如果你想在面试里聊出彩或者想锻炼算法能力下一步就是给蛇写一个自动寻路玩法——蛇自己去找食物。C# 最合适的选择是 BFS广度优先搜索。public QueuePoint? FindPath(Point start, Point goal, ListPoint obstacles) { var visited new HashSetPoint(); var parent new DictionaryPoint, Point(); var queue new QueuePoint(); visited.Add(start); queue.Enqueue(start); while (queue.Count 0) { Point current queue.Dequeue(); if (current goal) break; foreach (Point next in GetNeighbors(current)) { if (visited.Contains(next) || obstacles.Contains(next)) continue; visited.Add(next); parent[next] current; queue.Enqueue(next); } } if (!parent.ContainsKey(goal)) return null; // 回溯路径并转为移动序列 var path new StackPoint(); Point node goal; while (node ! start) { path.Push(node); node parent[node]; } return new QueuePoint(path); }完整的自动寻路比这复杂得多因为蛇吃食物后身体会变长路径必须实时重算且要保证蛇头不会钻进死角。如果只是做“自动吃食物”的演示上面这段已经足够。把队列里的路径点一个个取出作为Move的目标即可。我实际测试下来的体验是这条贪吃蛇经常把自己走成一个螺旋而死因为 BFS 只能找到“当前时刻”的最短路径不具备前瞻性。改进方案是在每一步移动前模拟下一步的状态提前预测是否会把自己困死。这部分可以作为进阶练习留给读者能跑通简单场景就算达标了。6.2 最高分持久化二进制序列化和 JSON 二选一游戏做完最自然的收尾就是“最高分保存”。C# 核心语法里的文件 IO 和序列化在这个小功能里都能用到。最简单的做法是把最高分存成文本文件private void SaveHighScore(int score) { File.WriteAllText(highscore.dat, score.ToString()); } private int LoadHighScore() { if (File.Exists(highscore.dat)) return int.Parse(File.ReadAllText(highscore.dat)); return 0; }这种做法够用但不灵活。如果你后续想存多一条“玩家名 分数 时间”就应该用 JSON 序列化。加入System.Text.Jsonpublic class ScoreRecord { public string PlayerName { get; set; } public int Score { get; set; } public DateTime Time { get; set; } } // 序列化 string json JsonSerializer.Serialize(record); // 反序列化 var record JsonSerializer.DeserializeScoreRecord(json);文件 IO 让我学到的一条教训是英语里的位置问题当前程序所在的相对路径在 IDE 里调试和在发布环境下不一样。用Environment.CurrentDirectory或AppDomain.CurrentDomain.BaseDirectory显式拼接路径比裸的highscore.dat更容易排查问题。6.3 建议做完这个项目后再做的一个验证贪吃蛇做完了你可以做一个验证来确认自己是否真的掌握了 C# 核心在不动Snake类的前提下把渲染层从控制台换成 WinForms再把输入层从键盘换成鼠标点击。如果这两个替换都能在半天内完成说明你的类职责划分是合格的。如果改起来很痛苦说明你把逻辑和界面耦合了——这就是下一轮重构的方向。我自己当年做完贪吃蛇后最大的收获不是会写游戏循环而是明白了“边界值”在代码里的威力。越靠近墙越容易撞死Random.Next的区间差一位就出界Timer多一个实例就加速。这些坑多得让我一度以为写游戏全凭玄学后来才发现每一处都有确定性的原因。这个项目值得你投入一个周末做完之后你会发现自己看 C# 项目源码的感觉完全不一样了。希望帮到你。本文还有配套的精品资源点击获取