简介一套面向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和FoodSnake和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不删尾部蛇身长度加一。如果你用 QueueGrow只需入队头部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;反向判断逻辑是当前方向是 RightNextDir 是 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缓冲写进去不给自己留“先跑起来再说”的余地。这套贪吃蛇项目和源码包里给出的完整工程是我同一个版本的产出跟着敲一遍再去改功能比如加墙壁、加障碍物、加多种食物计分规则都能在这个框架上比较顺地延伸。希望帮到你。本文还有配套的精品资源点击获取