简介:一套面向C#初学者的控制台贪吃蛇实践项目,以经典小游戏为载体重温类、方法、条件语句与循环等核心语法,适合正在学习.NET基础并希望动手验证的开发者。压缩包共33个文件、约70KB,主体为18个.cs源代码文件,对应地图、蛇身、食物、场景切换等模块,另含config配置文件、可直接运行的exe程序及pdb调试符号,解压后即可编译体验。项目通过主循环统一处理按键输入、蛇的移动与增长、食物随机生成及碰撞结束判定,并用字符重绘界面实现简单动画;代码中类与场景的划分清晰,便于对照学习控制台游戏的状态组织方式。已有217人学习下载,这份轻量完整的实战代码很适合用于串联C#面向对象、控制台交互和基础算法设计。
1. 贪吃蛇控制台项目:C#核心语法的落地练手
C# 入门最大的尴尬是语法看完了,却找不到一个能把类、继承、集合、状态机全串起来的练手项目。贪吃蛇这个控制台游戏刚好卡在“有点简单但又不只是玩具”的位置上:窗口是二维坐标系,蛇身是典型的连续数据结构,食物生成和碰撞检测直接依赖坐标运算,整局流程天然就是一个状态机。把它做出来,你基本就把 C# 控制台游戏的核心套路摸了一遍。这篇笔记按我自己的拆解顺序来讲,从建模写到循环、碰撞、避坑,最后给几个从“能玩”到“能看”的收尾技巧,适合跟着敲一遍源码包的读者对照落盘。
2. 面向对象建模:贪吃蛇项目的类图取舍与依赖方向
很多初学者拿到需求就开写Program.cs,一个 Main 函数堆几百行,最后蛇都跑不起来。实际动手前先花十分钟画一下类图,后续改逻辑会轻松很多。
2.1 为什么拆成三个类而不是一坨 Main
我拆这套小项目时只用了三个核心类:Snake、Food、GameManager。Snake负责蛇身坐标和移动方向,Food负责食物生成和得分标记,GameManager持有两者并调度整个游戏循环。键盘输入单独放在GameManager里处理,不单独建一个InputHandler类——控制台场景只有一个输入源,拆太细反而让调用链变长。
界面渲染放在哪里?这版我选择让它成为GameManager的内部私有方法,避免为“将来要换成 Unity”这种不一定发生的需求提前抽象。控制台项目和 GUI 项目的渲染机制差异太大,硬抠一个IRenderer接口出来,大概率是过度设计。如果你以后确实要移植到 WPF 或 Unity,再把渲染逻辑从 GameManager 里提出去也不迟,这个阶段的代码量撑得起这种重构。
依赖方向只允许GameManager引用Snake和Food,Snake和Food互不感知。这样改任何一方都不会波及另一方。贪吃蛇的逻辑本身就简单,依赖关系越少,后面调试越省事。
public enum Direction { Up, Down, Left, Right } public class Snake { public List<(int Row, int Col)> Body { get; private set; } public Direction CurrentDir { get; set; } = Direction.Right; public Direction NextDir { get; set; } = Direction.Right; public Snake(int startRow, int startCol, int initLength = 3) { Body = new List<(int, int)>(); for (int i = 0; i < initLength; i++) { Body.Add((startRow, startCol - i)); } } }这段定义了蛇的核心结构:Body是(int Row, int Col)元组的 List,下标 0 代表蛇头;NextDir是我专门加的大小写,用来做输入缓冲(后面讲按键时会细说)。这样写的好处是蛇身坐标天然可枚举,移动时只需在头部插入新坐标、尾部移除旧坐标就能完成“爬行”。
2.2 蛇身数据结构:List、Queue 还是链表
蛇身可以用Queue<(int, int)>,因为每次移动只需要在尾部出队、头部入队。但我最后选了List<(int, int)>,原因是你能直接通过body[0]拿蛇头坐标去判断是否撞墙、是否吃到食物,不需要额外维护一个head变量。用 Queue 时你还得再开一个字段记录头坐标,否则每次 Peek 出来的只是最早进去的尾巴。
public void Move((int Row, int Col) newHead) { Body.Insert(0, newHead); Body.RemoveAt(Body.Count - 1); } public void Grow((int Row, int Col) newHead) { Body.Insert(0, newHead); }Move是普通移动,吃掉食物时调Grow。注意Grow不删尾部,蛇身长度加一。如果你用 Queue,Grow只需入队头部,Move需要出队加入队,逻辑上等价,但Body[0]这种索引访问在 List 上是 O(1),而在 Queue 上做不到。
顺便说一个选 LinkedList 的坑:链表在头部插入和尾部删除确实都是 O(1),但你在做“检查某个坐标是否撞到蛇身”时,LinkedList 的线性扫描比 List 慢不少,因为 List 底层是数组,CPU 缓存命中率高,遍历起来反而更快。蛇身一般不超过几百节,List 完全够用,别为了追求“理论最优复杂度”选一个写着别扭的数据结构。
3. 游戏循环与键盘输入:从阻塞读取到按键缓冲
控制台游戏的核心是一个“更新-渲染-等待”的死循环。循环的节奏感决定了手感,这里有很多可以抠细节的地方。
3.1 游戏循环的三种写法与帧节奏控制
最简单的循环是while (true) { state.Update(); state.Render(); }。这么写的缺陷是循环跑满 CPU,蛇的移动速度完全取决于机器性能,一台 i9 一台 i3 跑出两种速度,没法玩。
常见做法是用Thread.Sleep控制刷新间隔。贪吃蛇不是动作游戏,不需要 60fps 的渲染帧率,移动一次屏幕内容就重绘一次,帧间隔可以直接当作移动间隔。
const int IntervalMs = 120; while (running) { HandleInput(); Update(); Render(); Thread.Sleep(IntervalMs); }IntervalMs = 120表示每秒大约移动 8 格,这个速度多数新手能跟上。你想让难度递进,可以把这个值设成变量,每吃五个食物减 10,但别减到小于 50,否则蛇会快到键盘跟不上,游戏体验反而变差。
3.2 非阻塞键盘输入:反直觉的 KeyAvailable 用法
新手最容易翻车的点就是按键读取。直接用Console.ReadKey(true)会让主线程卡在等待输入上,蛇在原地不动,按一个键才动一格,这完全不是游戏逻辑。正确打开方式是Console.KeyAvailable先判断缓冲区里有没有按键,有才去 ReadKey,没有就继续跑主循环。
private void HandleInput() { if (!Console.KeyAvailable) return; ConsoleKey key = Console.ReadKey(true).Key; Direction dir = key switch { ConsoleKey.W or ConsoleKey.UpArrow => Direction.Up, ConsoleKey.S or ConsoleKey.DownArrow => Direction.Down, ConsoleKey.A or ConsoleKey.LeftArrow => Direction.Left, ConsoleKey.D or ConsoleKey.RightArrow => Direction.Right, _ => Direction.None }; if (dir != Direction.None) { snake.NextDir = dir; } }这个写法里有一个关键设计:键盘只改NextDir,不让它直接改CurrentDir。因为一帧内你可能按了多次方向键,最后一次按键覆盖掉之前的值,最终移动时只认最后一次。如果没有缓冲,当你快速按“上”再按“左”,而蛇当前正在向右移动时,上一帧的方向可能还没生效就被覆盖掉,输入就丢失了。
移动方向还要加一个防反转判断:
snake.CurrentDir = GameManager.IsOpposite(snake.CurrentDir, snake.NextDir) ? snake.CurrentDir : snake.NextDir;反向判断逻辑是:当前方向是 Right,NextDir 是 Left,就保持 Right。不然蛇头会直接穿进自己身体,而这个判断必须在生成新蛇头坐标之前完成,位置错了后面所有碰撞检测都是白做。
3.3 坐标更新:朝方向走一格的计算方式
方向确定了,新蛇头坐标就是“当前头 + 方向向量”。我习惯把方向到坐标增量的映射写成一个静态方法,这样碰撞检测和渲染都共用同一套坐标逻辑。
public static (int, int) GetHeadOffset(Direction dir) { return dir switch { Direction.Up => (-1, 0), Direction.Down => (1, 0), Direction.Left => (0, -1), Direction.Right => (0, 1), _ => (0, 0) }; }这里把 (Row, Col) 映射到二维数组下标:Row 对应 Console 的 Y 轴,Col 对应 X 轴。每走一步就是在当前蛇头基础上加一个偏移量,方向向量如果搞反了,蛇会反向移动或者撞墙。常见的错误是把 Up 写成 (0, 1),结果蛇往上走变成了往右走,看起来就像按键和方向完全对不上。
4. 碰撞检测与游戏状态机:边界、自撞与暂停恢复
移动逻辑做完,考验的是状态切换的完整性。蛇什么时候死、游戏什么时候暂停、结束后怎么再来一局,这些都是状态机要管的。
4.1 边界检测的两种策略与坐标单位
控制台窗口的宽高是用字符数算的,不是像素。Console.WindowWidth和Console.WindowHeight给出的是字符网格的尺寸。蛇头撞墙的判断很简单:Row 超出 [0, WindowHeight - 1] 或 Col 超出 [0, WindowWidth - 1]。
private bool IsHitWall((int, int) head) { return head.Item1 < 0 || head.Item1 >= Console.WindowHeight || head.Item2 < 0 || head.Item2 >= Console.WindowWidth; }一个容易踩的低级错误是Console.BufferWidth和Console.WindowWidth混用。BufferWidth 表示缓冲区完整尺寸,WindowWidth 表示当前窗口可视宽度。当你滚动过控制台内容或者窗口被 resize 过,两者不相等。绘制游戏界面时统一用 WindowWidth 和 WindowHeight,按窗口而非缓冲区来约束坐标,否则蛇会跑出视线范围。
要不要支持窗口 resize?我的建议是直接禁止或忽略。控制台窗口在运行期间被拖拽改变大小,坐标体系瞬间改变,蛇可能在 resize 之后直接撞墙。最省事的策略是游戏启动时固定一个最小尺寸,在 GameManager 里加一个校验,窗口尺寸变了就强制 GameOver。
4.2 自撞检测的逻辑位置
自撞检测要放在“蛇头插入”之前做。先用 NextDir 计算出虚拟的新头坐标,拿这个坐标和蛇身现有坐标比对,有重叠就死亡。注意比对时要排除当前蛇尾,因为移动后蛇尾会消失,头正好走到尾巴原来的位置是合法的。
private bool IsHitSelf((int, int) newHead) { for (int i = 0; i < snake.Body.Count - 1; i++) { if (snake.Body[i] == newHead) return true; } return false; }这个循环从头部索引 0 查到倒数第二个,最后一个坐标是蛇尾,这一帧移动之后会被移除,如果新头恰好落在蛇尾的原坐标上,不算撞到。如果你把整个 Body 都查一遍,会出现“蛇头追上自己尾巴就死”的假阳性,在蛇比较长的时候特别容易误判。
4.3 游戏状态机:Running、Paused 与 GameOver 的切换
我用一个枚举管理游戏状态,把状态转换的触发点全部收敛到 GameManager 里。
public enum GameState { Ready, Running, Paused, GameOver }经典错误是以为“暂停”就是“不更新”,用if (!isPaused) Update();来处理。这种做法的问题是:暂停期间不做渲染,窗口上还停着上一帧的画面,没法给玩家“当前是暂停”的反馈。正确做法是状态机里显式处理Paused,单独渲染一个带提示的界面。
switch (state) { case GameState.Running: Update(); Render(); break; case GameState.Paused: RenderPausedText(); break; case GameState.GameOver: RenderGameOverText(); break; case GameState.Ready: RenderStartTip(); break; }这样每个状态下玩家看到的画面都是“有反应”的,按 P 暂停能看到暂停提示,按任意键能恢复运行,按 R 能重开游戏。如果你的循环里只写if (!isPaused) UpdateAndRender();,暂停时窗口画面冻结,玩家根本分不清是卡死还是暂停,这是游戏体验上的硬伤。
4.4 食物生成:随机但避开蛇身
食物生成是“每次随机一个坐标,然后在蛇身上查重”的循环。查重失败就重新随机,直到找到不在蛇身上的点。
private void SpawnFood() { Random rand = new Random(); while (true) { int row = rand.Next(0, Console.WindowHeight); int col = rand.Next(0, Console.WindowWidth); if (!snake.Body.Contains((row, col))) { food = (row, col); break; } } }这段代码有两个需要注意的点。Random实例只创建一次,放在字段里,不要每次 SpawnFood 都new Random(),否则在同一毫秒内创建的多个 Random 实例会产生相同的序列,食物会刷在固定位置。查重用的是List.Contains,它是 O(n),蛇身短时完全无所谓,蛇身长到几百节时也就几微秒的开销,不用手动优化。
5. 避坑:控制台贪吃蛇最常见的六类翻车现场
这部分是我自己调试这个项目时真实踩过的坑,每一条都是“现象、原因、解决”三件套。
5.1 按方向键时出现 D 键的字符
现象:按方向键或 WASD 键时,控制台上多出一个不相关的字符,比如按向上键后画面上多了一个字母 “D”。
原因:Console.ReadKey(true)中参数为 false,导致按键内容回显到控制台。方向键被映射为ConsoleKey.UpArrow,但它对应的字符仍然会输出。
解决:给Console.ReadKey传入true抑制按键回显。如果你写的代码是Console.ReadKey()而没带参数,按键字符就会污染渲染画面。这是控制台游戏最基础的写法之一,但也是最容易漏掉的参数。
5.2 蛇移动速度时快时慢
现象:蛇在吃食后明显停顿一下,或者在某些机器上跑得特别快,画面像是加速播放。
原因:把Thread.Sleep(120)放在了 Update 之前或之后的不同位置,实际帧间隔不稳定。另外Thread.Sleep的时间粒度是 15ms 左右,Windows 系统时钟精度不够,120ms 的休眠实际误差在 50ms 上下。
解决:用Stopwatch做精确计时,不要在循环里裸用固定 Sleep。一个简单做法是记录每一帧的耗时,然后只 Sleep 剩余时间。如果运行循环耗时已经超过目标帧间隔,就跳过 Sleep,避免蛇越走越慢。
Stopwatch sw = Stopwatch.StartNew(); long targetTicks = TimeSpan.FromMilliseconds(IntervalMs).Ticks; while (running) { sw.Restart(); HandleInput(); Update(); Render(); long elapsed = sw.ElapsedTicks; int sleepTime = (int)((targetTicks - elapsed) / TimeSpan.TicksPerMillisecond); if (sleepTime > 0) Thread.Sleep(sleepTime); }这样处理之后,即使某帧渲染出现瞬时卡顿,下一帧的 Sleep 时间也会自动缩短,整体节奏稳定很多。
5.3 蛇吃了食物但长度没变
现象:食物被吃掉,得分增加,但蛇身长度没有变化,下一次移动还是原来的长度。
原因:Grow方法没有正确调用,或者在移动逻辑里始终只调用了Move。典型的错误是吃到食物后把Grow写在碰撞检测之前,导致食物坐标被覆盖后判定失效。
解决:把吃食物的检测放在生成新蛇头之后,先检查新蛇头坐标是否等于食物坐标,等于则调Grow,否则调Move。
(bool, int) newHead = MoveAndCheckEat(snake, food); if (ateFood) { snake.Grow(newHead); } else { snake.Move(newHead); }这一步逻辑不复杂,但务必要清楚“吃了食物就多保留一节尾巴”这个语义——Grow不删尾,Move删尾,两者只有一个会被调用。
5.4 暂停恢复后蛇瞬间移动了两步
现象:暂停时按下 P 键恢复运行,蛇突然往前跳了一格,有时候一格不够,直接穿墙。
原因:HandleInput在暂停时也执行了,按 P 键既切换了暂停状态,又触发了一次方向更新,相当于恢复了之后立刻执行一次移动。同时KeyAvailable判断本身不会清除缓冲区,你按一下键,循环里可能连续触发两次动作。
解决:暂停状态下跳过HandleInput,或者把暂停切换键和方向键分开判定,处理完 P 键后直接 return。
if (state == GameState.Paused) { if (Console.KeyAvailable) { var key = Console.ReadKey(true).Key; if (key == ConsoleKey.P) state = GameState.Running; if (key == ConsoleKey.R) ResetGame(); } return; }5.5 渲染时画面撕裂或留下残影
现象:蛇移动后,原来蛇尾的位置留下一个方块残影,或者整个画面闪个不停。
原因:每次都调用Console.Clear()清空屏幕再重画,控制台刷新率很低,清屏和重画之间有时间差,视觉上就表现为闪烁和残影。还有一个隐藏问题是光标位置没有重置,绘制内容错位。
解决:用“覆盖渲染”替代清屏。只画蛇、食物和固定边框时,先算出新的蛇头位置和要删除的蛇尾位置,直接SetCursorPosition到指定坐标改写字符,不整屏重绘。具体代码放在下一章的 6.1 节。
5.6 窗口 resize 后坐标错乱
现象:拖拽控制台窗口大小后,蛇坐标错位,甚至直接看不见蛇。
原因:Width或Height改变后,之前随机生成的食物坐标可能已经在窗口范围之外。蛇头的坐标也可能因为边界判断失效而跑出可视区。
解决:启动时固定窗口大小,并在游戏循环里检测窗口宽高变化,发现尺寸变了直接进入 GameOver 状态,不去尝试“自适应”。控制台窗口 resize 事件本来就不稳定,对这个规模的项目来说,阻止变化比适配变化更省心。
int fixedWidth = 40, fixedHeight = 20; if (Console.WindowWidth != fixedWidth || Console.WindowHeight != fixedHeight) { Console.SetWindowSize(fixedWidth, fixedHeight); }6. 从能玩到能看:双缓冲渲染、变速控制与存档恢复
核心逻辑跑通之后,这个项目还有三个能明显提升完成度的手法可以做。
6.1 覆盖渲染而非全屏清空
把Console.Clear()换成按坐标覆盖。蛇移动后只有蛇头新增、蛇尾消失,这两处坐标是确定的,我只需要在两个位置写字。
private void Render() { // 画食物 Console.SetCursorPosition(food.Item2, food.Item1); Console.Write('@'); // 画蛇头 var head = snake.Body[0]; Console.SetCursorPosition(head.Item2, head.Item1); Console.Write('O'); // 清掉尾巴 var tail = snake.Body[snake.Body.Count - 1]; Console.SetCursorPosition(tail.Item2, tail.Item1); Console.Write(' '); }这里用了Console.SetCursorPosition精确控制绘制位置,不会出现整屏闪烁。绘制边框和得分栏时也可以只在初始化时画一次,不需要每帧重画。这种“增量渲染”的做法在控制台游戏里比Console.Clear()性能好得多。
6.2 变速控制
根据分数动态调整移动间隔。我用一个线性公式:得分越高,间隔越短,但设置下限。
int GetIntervalMs(int score) { return Math.Max(50, 120 - score / 5); }每吃一个食物得分加一,间隔下限卡在 50ms。如果你用上 Stopwatch 精确计时,这个变速在游戏手感上是渐进式的,不会从 120ms 突然跳到 50ms。
6.3 蛇身存档与恢复
把蛇身坐标、当前方向、得分序列化到本地文件,下次启动时直接恢复一局未结束的游戏。控制台游戏根本不需要引入数据库或复杂的序列化框架,直接写 JSON 文本文件就够用。
string path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "snake_save.json"); SaveGame(snake, score, path); public static void SaveGame(Snake snake, int score, string path) { var data = new { Score = score, Body = snake.Body.Select(p => new { p.Item1, p.Item2 }).ToArray(), Direction = snake.CurrentDir.ToString() }; File.WriteAllText(path, JsonSerializer.Serialize(data)); }恢复时反序列化这个结构,重新填充蛇身 List 和 CurrentDir。要注意的是恢复后的方向不能和蛇的运动方向反向,否则蛇会瞬间自撞,这个逻辑要加一个校验,比如恢复时强制把 NextDir 设置成与 CurrentDir 一致的初始方向。
我之前做这个项目时没太在意帧率稳定这回事,过了一个月重新打开代码,发现移动时钟乱跳、按键偶发丢失,最后才意识到是计时和输入缓冲没拧紧。从那以后我每次写控制台游戏,都强制在第一步就把 Stopwatch 精确计时和NextDir缓冲写进去,不给自己留“先跑起来再说”的余地。
这套贪吃蛇项目和源码包里给出的完整工程是我同一个版本的产出,跟着敲一遍再去改功能,比如加墙壁、加障碍物、加多种食物计分规则,都能在这个框架上比较顺地延伸。希望帮到你。
本文还有配套的精品资源,点击获取