尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C#控制台贪吃蛇实战:从数据结构到游戏循环的完整指南

C#控制台贪吃蛇实战:从数据结构到游戏循环的完整指南 简介面向C#初学者的控制台贪吃蛇实战项目以经典小游戏为载体串联类、方法、变量、条件语句等核心语法并完整覆盖控制台输入输出、按键捕获、主循环、碰撞检测、蛇身增长、随机食物生成、状态更新与字符画面重绘等关键开发环节。资源共33个文件压缩包仅70KB内含18个.cs源码文件以及可执行程序、配置文件、JSON数据、工程文件与调试信息编译运行方便适合边看代码边验证效果。已有217人学习下载。包内源码按场景管理、地图绘制、蛇身逻辑、食物生成等模块清晰组织结构易读适合独立练手或课堂教学演示。完成此项目不仅能加深对面向对象设计和基础算法的理解还能掌握控制台应用从编码、调试到优化的完整流程是巩固C#基础、积累实战经验的实用资料。1. 用 C# 做贪吃蛇为什么这个老项目值得动手写一遍搜C#教程的人最后都会撞上同一个建议找个不超过两千行的经典小游戏自己从零写一遍。贪吃蛇恰好是这类项目里最典型的一个——它小到一天能写完又大到逼你认真处理数据结构、状态转换、输入响应、碰撞检测和绘制策略几乎把 C# 控制台应用的常见问题覆盖了一遍。很多人在这个项目里第一次搞懂了为什么QueueT比ListT更适合模拟蛇身也第一次踩到 方向键按太快蛇会回头自杀 这种玄学 bug。这篇文章按我实际写过一版的控制台贪吃蛇来讲从选型到完整逻辑再到参数调优和踩坑记录每一步你都能在自己的开发环境里复现。适合刚学完 C# 语法、想找第一个综合项目的初学者也适合想快速验证某个 C# 特性的在职开发者。2. 动手前先选型控制台、WinForms 还是 Unity以及数据结构的坑2.1 三个界面方案里控制台为什么最适合练 C# 核心常见做法是用 WinForms、WPF 或 Unity 来做贪吃蛇因为画图、定时器、键盘事件都有现成组件。可一旦用了这些框架代码里一半以上其实是框架代码Timer控件、KeyDown事件、Graphics.DrawRectangle……留给语法练习的空间反而被挤掉了。控制台版不一样它把游戏循环暴露成while循环把绘制暴露成Console.SetCursorPosition和字符串输出把输入暴露成Console.KeyAvailable与Console.ReadKey。这几样恰恰是 C# 新手最容易混淆的东西字符串拼接、数组与集合选型、多线程共享变量、状态枚举。你想用 WPF 做的 UI 效果在控制台里反而要自己设计一个极小的渲染层这个设计过程才是真正的收获。如果你已经写过大点的 C# 工程控制台版的价值更偏验证验证你对垃圾回收下的高频对象分配、对Thread.Sleep精度局限、对 readline 型输入与 keydown 型输入的差异是否有手感。贪吃蛇游戏循环里 150ms 一帧看起来简单实测跑起来会发现Console.Clear()全屏重绘会闪到怀疑人生于是你会去搜双缓冲控制台——这正是从能跑到能玩的必经一步。控制台环境的这些限制并非缺陷它们把性能问题暴露在最直观的层面比 Unity 里的 Profiler 窗口更容易理解。2.2 队列、链表、数组与集合蛇身该用哪种数据结构这一题没有标准答案但有一组很稳的选择。蛇的移动逻辑是头进尾出每次移动新头部坐标加入蛇身尾部的坐标被丢弃吃到食物时尾部不丢。这正是先进先出结构所以QueuePoint是大多数人推荐的方案入队出队都是 O(1)。同样能做到头进尾出的还有LinkedListT它的AddFirst/RemoveLast也是 O(1)但你在遍历蛇身画每个节点时LinkedList的缓存局部性较差控制台贪吃蛇的蛇身一般不超过几百格所以没人能测出差别用Queue就够了代码还短一截。那ListPoint和Point[]呢数组长度固定游戏蛇身长度会增长你得自己管理扩容逻辑List虽然能动态扩容但RemoveAt(0)删除头元素时后面所有元素都要前移平均 O(n)。蛇身几十格时无感可一旦食物机制允许蛇长到几百格每帧移动都做一次全数组搬移就会看到明显的卡顿顿挫。关键是用数组和List写出来的代码会让你不自觉地在蛇头坐标和蛇身列表[0]之间来回转换索引反而容易把游戏逻辑写成把整个数组往后挪一格这种 O(n) 套餐。这就是网热搜里那个问题数组和集合分别是怎么定义、使用上有什么区别最直观的答案数组语义是固定长度的连续槽位集合语义是动态增删的对象容器蛇身是动态增长的且两头都要操作所以应该选有针对性的队列。另一个容易被忽略的集合是HashSetPoint它用来做一处关键功能判断新蛇头是否撞到了蛇身。Queue的Contains是 O(n) 遍历而HashSet是 O(1) 哈希查找。不过HashSet要求类型正确实现GetHashCode和Equals自定义Point结构体时这两个方法要自己写或者用ValueTuple。为了代码清晰我一般这样定义一个最小的结构体public readonly struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X x; Y y; } public override bool Equals(object obj) obj is Point other X other.X Y other.Y; public override int GetHashCode() X * 397 ^ Y; }逻辑说明这个结构体只有 X、Y 两个坐标字段把Equals和GetHashCode重写后HashSetPoint才能正确判断两个坐标是否相同。X * 397 ^ Y是常见的哈希组合方式397 是随便挑的质数能减少不同坐标撞到同一哈希槽的概率。参数说明如果你用的是 C# 7.0 以上的环境可以直接把结构体换成语元组(int X, int Y)哈希方法由编译器默认生成代码更短。不过教学项目里自定义结构体能让你看到集合底层对类型的约束遇到Contains判断失灵时你知道从哪查起。比较一下三种方案数据结构进/出端复杂度蛇身遍历适用场景Point[]手动维护O(n) 搬移快蛇长固定不变的棋盘游戏如围棋ListPointRemoveAt(0) O(n)快蛇身短、不做高频碰撞的草稿版QueuePoint出队入队 O(1)中蛇身贪吃蛇标准选择HashSetPoint增删查 O(1)不支持顺序配合 Queue 做碰撞检测到了这一步选型已经清楚Queue管蛇身顺序HashSet管蛇身占位两者分工分别解决移动和碰撞两个问题。3. 把贪吃蛇跑起来游戏循环、方向输入与可复现的移动逻辑3.1 游戏循环骨架用 Stopwatch 控帧而不是 Thread.Sleep 拍脑袋先看一个完整的Program.cs骨架它把游戏状态、主要字段、入口和游戏循环串在一起。这段代码可以直接复制到控制台项目里运行注意 C# 版本要支持结构体重写.NET 6 以上默认没问题。using System; using System.Collections.Generic; using System.Diagnostics; using System.Text; using System.Threading; namespace SnakeGame { public enum Direction { Up, Down, Left, Right } public enum GameState { Ready, Running, Paused, GameOver } public class Program { const int GameWidth 40; // 游戏区域按字符列数算 const int GameHeight 20; // 游戏区域按字符行数算 const int StartSpeedMs 150; const int MinSpeedMs 60; static Direction direction Direction.Right; static Direction nextDirection Direction.Right; static QueuePoint snake new QueuePoint(); static HashSetPoint snakeBody new HashSetPoint(); static Point food; static Random random new Random(); static int score 0; static int speedMs StartSpeedMs; static GameState state GameState.Ready; static void Main() { Console.OutputEncoding Encoding.UTF8; Console.CursorVisible false; ResetGame(); GameLoop(); } static void ResetGame() { snake.Clear(); snakeBody.Clear(); score 0; speedMs StartSpeedMs; direction Direction.Right; nextDirection Direction.Right; // 初始蛇身头在 (5,5)向左延伸三格 for (int i 0; i 3; i) { Point p new Point(5 - i, 5); snake.Enqueue(p); snakeBody.Add(p); } SpawnFood(); state GameState.Running; } static void GameLoop() { Stopwatch sw Stopwatch.StartNew(); while (state ! GameState.GameOver) { sw.Restart(); HandleInput(); if (state GameState.Running) { MoveSnake(); Draw(); } long elapsed sw.ElapsedMilliseconds; int waitMs (int)(speedMs - elapsed); if (waitMs 0) Thread.Sleep(waitMs); } Console.SetCursorPosition(0, GameHeight 2); Console.WriteLine($Game Over, Score: {score}); } } }逻辑说明这里的游戏循环遵循一个经典模式——固定时间步 尽可能不阻塞地处理输入。Stopwatch在每次循环开始时重启记录这一帧处理HandleInput、MoveSnake、Draw花了多少毫秒然后用Thread.Sleep补足剩余时间。假设一帧目标是 150ms若逻辑和绘制只用了 3ms就睡 147ms若哪帧卡了一下用了 180mswaitMs会被算成负数于是不睡直接进入下一帧保证游戏不会越跑越慢。参数说明StartSpeedMs 150约等于每秒 6.7 步是新手能从容反应的速度。MinSpeedMs 60约每秒 16.7 步再快控制台绘制本身会变成瓶颈。GameWidth和GameHeight决定了游戏区域大小40x20 是控制台默认窗口能容纳的偏大尺寸。注意这里有一个字形宽高比的坑留到第 4 章详细说。3.2 方向输入与蛇头转向为什么直接读按键容易让蛇回头自杀玩贪吃蛇时最容易翻车的操作是快速连按两个方向键蛇正往右走你在一帧内依次按了上和左如果程序把第二次左直接当成下一步方向蛇就会在向右的帧里掉头向左穿进自己刚离开的位置判定撞自己。解决这个问题的通用方案是下一帧才生效的方向变量即direction当帧有效nextDirection留到移动前再同步。static void HandleInput() { while (Console.KeyAvailable) { ConsoleKey key Console.ReadKey(true).Key; if (state GameState.Paused key ConsoleKey.P) { state GameState.Running; continue; } if (state GameState.Running) { switch (key) { case ConsoleKey.UpArrow: case ConsoleKey.W: if (direction ! Direction.Down) nextDirection Direction.Up; break; case ConsoleKey.DownArrow: case ConsoleKey.S: if (direction ! Direction.Up) nextDirection Direction.Down; break; case ConsoleKey.LeftArrow: case ConsoleKey.A: if (direction ! Direction.Right) nextDirection Direction.Left; break; case ConsoleKey.RightArrow: case ConsoleKey.D: if (direction ! Direction.Left) nextDirection Direction.Right; break; case ConsoleKey.P: state GameState.Paused; break; } } } direction nextDirection; }逻辑说明while (Console.KeyAvailable)会一次性把缓冲队列里的所有按键读完。每次按方向都先检查是否和当前方向相反如果相反就忽略这正是防止自杀的关键。循环结束后才把nextDirection赋给direction确保移动方法里读到的方向是本帧的最终方向不会被同一帧里按下的多个键反复改动。参数说明我同时支持方向键和 WASDConsoleKey.W、ConsoleKey.A、ConsoleKey.S、ConsoleKey.D分别映射上下左右。需要提一句Console.ReadKey(true)的第二参数true表示不在屏幕上回显按下的字符不然方向键可能被打印成奇怪的控制符。这里把按键到方向的映射写成switch而不是字典DictionaryConsoleKey, Direction不是为了简单是为了让你看明白如果后续版本想支持按一次键只转一次方向或按住按键持续加速改这个switch比改字典更直白。3.3 移动、吃食物与碰撞检测的完整实现这是整个项目最核心的一段。蛇移动逻辑中最容易写错的是碰撞判定顺序和队尾移除顺序下面这段代码的顺序是我调试过稳的版本static void MoveSnake() { Point head snake.Peek(); Point newHead direction switch { Direction.Up new Point(head.X, head.Y - 1), Direction.Down new Point(head.X, head.Y 1), Direction.Left new Point(head.X - 1, head.Y), Direction.Right new Point(head.X 1, head.Y), _ head }; // 1. 边界碰撞 if (newHead.X 0 || newHead.X GameWidth - 1 || newHead.Y 0 || newHead.Y GameHeight - 1) { state GameState.GameOver; return; } bool ateFood newHead.X food.X newHead.Y food.Y; // 2. 自碰撞检测先排除尾部再判断 if (!ateFood) { Point tail snake.Dequeue(); snakeBody.Remove(tail); } if (snakeBody.Contains(newHead)) { state GameState.GameOver; return; } // 3. 真正把新头加进来 snake.Enqueue(newHead); snakeBody.Add(newHead); if (ateFood) { score; speedMs Math.Max(MinSpeedMs, speedMs - 5); SpawnFood(); } }逻辑说明这里用到 C# 8.0 的 switch 表达式计算新头部坐标。碰撞判定分三段先判边界再处理尾部再判自撞。为什么要先处理尾部因为蛇头移动到下一个坐标时它的尾部同时消失如果蛇头要去的位置恰好是当前尾巴尖的位置此时尾巴已经挪走这个位置不算撞自己。如果你先把蛇头 Enqueue 进去再 Remove 尾巴或者直接用snakeBody.Contains(newHead)而没管尾巴就会出现蛇头撞到尾巴尖但游戏判定生死的随机性。参数说明speedMs Math.Max(MinSpeedMs, speedMs - 5)是简单的固定增量减速方案每吃一个食物把每帧间隔缩短 5ms。Math.Max保证不会低于 60ms。这个写法在第 4 章会继续优化成阶梯式加速先记下这个版本。食物生成也要重试不能随便挑个坐标就放下去static void SpawnFood() { while (true) { int x random.Next(1, GameWidth - 1); int y random.Next(1, GameHeight - 1); Point candidate new Point(x, y); if (!snakeBody.Contains(candidate) !WallAt(x, y)) { food candidate; return; } } } static bool WallAt(int x, int y) { return x 0 || x GameWidth - 1 || y 0 || y GameHeight - 1; }逻辑说明SpawnFood是个无限循环直到随机坐标既不落在蛇身上也不落在边界墙上才返回。因为蛇身密度高了以后随机命中蛇身的概率上升无限循环在理论上有极小概率卡住但对 40x20 的地图和几百格的蛇身来说不会触发。如果你较真可以在循环上加一个计数器超过 500 次就认定游戏区域几乎占满直接判定胜利。最后是绘制方法这里刻意回避了Console.Clear()static void Draw() { // 先画食物 Console.SetCursorPosition(food.X, food.Y); Console.Write(); // 画蛇头 Point head snake.Peek(); Console.SetCursorPosition(head.X, head.Y); Console.Write(O); // 如果蛇尾变化了清掉旧尾巴位置 if (snake.Count 0 !AteLastFrame()) { Point tail snake.PeekLastRemoved(); // 实际代码中记录上帧被移除的尾 Console.SetCursorPosition(tail.X, tail.Y); Console.Write( ); } }逻辑说明这个方法只做增量绘制。食物、蛇头每次变化都需要重画蛇尾只有在没吃到食物时才需要被清成空格。正因为只动了最多三个坐标控制台不会整屏闪烁。AteLastFrame和PeekLastRemoved是示意性写法实际实现时我会在MoveSnake里记录lastRemovedTail字段没吃到食物时它等于出队的尾坐标吃到食物时它是null绘制方法据此决定要不要擦尾。参数说明、O、空格是三个可替换的显示字符。想把画面更方正可以把蛇身画成■或██食物画成●此时每个格子占两个字符列宽游戏区域宽度要重新计算这就是第 4 章的参数话题。4. 从能玩到耐玩网格比例、速度下限与加速曲线的三组参数4.1 网格尺寸、渲染比例与控制台缓冲区很多第一次写控制台贪吃蛇的人会发现一个奇怪现象明明设置了 20 行 40 列画出来的正方形格子却是扁的。原因很简单控制台字符的宽度大约是高度的二分之一显示一行字符占据的矩形区域本来就高大于宽所以你在地图上画一个 40x20 的网格肉眼看像竖条纹的砖墙。两种常见解法第一种把游戏区域宽高比设成 2:1宽度 40、高度 20每个格子用一个字符宽度视觉上大致接近正方形第二种每个蛇身格子用两个字符宽比如██此时地图实际列数要按两倍计算GameWidth设成 80 个字符列但逻辑上还是 40 个格子。我倾向第二种因为显示更接近正方形而且食物、蛇头用实心块字符会好看得多。控制台缓冲区也要手动设置否则窗口默认宽度可能只有 120 个字符而加了边框和状态栏之后就可能挤到换行。Console.SetWindowSize和Console.SetBufferSize的顺序有讲究必须先设 BufferSize 再设 WindowSize而且 WindowSize 不能超过 BufferSize。给一段初始化代码static void InitConsole() { Console.OutputEncoding Encoding.UTF8; Console.CursorVisible false; int bufferWidth GameWidth * 2 2; // 每个逻辑格两个字符列 int bufferHeight GameHeight 5; Console.SetBufferSize(bufferWidth, bufferHeight); Console.SetWindowSize(Math.Min(bufferWidth, Console.LargestWindowWidth), Math.Min(bufferHeight, Console.LargestWindowHeight)); }逻辑说明这里把GameWidth当作逻辑格子数bufferWidth用GameWidth * 2 2是因为每个格子画两个字符宽外加两边边界占位。先设缓冲区是为了给窗口一个可以容纳的下限避免窗口尺寸超过最大允许值时报异常。参数说明GameHeight 5预留了下方几行用来显示分数和日志。不同操作系统上Console.LargestWindowWidth返回的值不同Linux 终端里通常比 Windows 控制台更宽所以用Math.Min兜底比硬编码数值稳妥否则在部分终端上程序会直接抛异常。还要提一个和网格相关的隐藏细节——Console.SetCursorPosition的 X 坐标是按字符列算的如果蛇身一格占两列那new Point(3, 4)在绘制时要写成Console.SetCursorPosition(3 * 2, 4)。常见做法是给绘图层单独写一个转换函数static (int x, int y) ToCursor(Point p) (p.X * 2, p.Y);这样逻辑坐标和绘制坐标分离改动渲染比例时不用动游戏逻辑。这类小转换函数看似多余真到了要给蛇身做半透明遮挡或者打印特殊字符时会发现是后悔药一样的存在。4.2 初始速度、加速步长与下限一版能直接用的数值和算式直接给我现在推荐的一套参数初始speedMs 160每吃 5 个食物加速一次每次把帧间隔缩短 10ms下限50ms。用算式表达就是int milestone score / 5; speedMs Math.Max(50, 160 - milestone * 10);逻辑说明固定每吃一个食物减 5ms 的写法在分数低时加速太明显导致新手还没熟悉操作就进入高速区固定阶梯式加速则更平滑。score / 5计算当前处于第几个 5 分档位每过 5 分整体提速 10ms。初始 160ms 让玩家头几十步有足够反应时间50ms 是控制台绘制的实际瓶颈附近。参数说明具体数值可以根据手感微调——想更休闲初始值调到 200ms、每 10 分提速一次想要硬核初始 120ms、每 3 分提速。Math.Max的下限被我放在最外层保证任何情况下帧间隔不低于 50ms。这样设计的原因是Thread.Sleep在 Windows 上的实际精度是 15.6ms 的倍数设置 50ms 实际可能睡 46ms 或 62ms再低就失去意义了。另一个同样值得调的参数是StartSpeedMs和MinSpeedMs是否为常量。我的习惯是把它做成可配置字段并加上 XML 文档注释/// summary /// 每帧基础间隔毫秒数越小越快。 /// /summary public static int StartSpeedMs 160;这样改参数时不必全工程搜索数字也方便日后接配置文件或做成游戏内选关。如果你用 AI 辅助写代码这种简单清晰的可配置宠物项目正好适合让模型帮你重构——把魔法数字替换成可读性强的常量配置是它最擅长且不容易出错的活。5. 贪吃蛇避坑指南闪烁、乱码、按键残留与碰撞顺序的五条踩坑记录5.1 控制台闪烁整屏 Clear 的代价远超想象现象写完第一版后运行蛇一动整个屏幕都在闪严重时连方向都看不清。原因代码里用了Console.Clear()然后重新画全部网格控制台对全量重绘的渲染效率低且清屏操作本身会触发一次光标回到原点产生明显闪动。解决采用第 3 章的增量绘制方案每次只更新食物、蛇头、尾巴三个坐标顺手把关掉光标闪烁的Console.CursorVisible false写在初始化里。如果绘制内容多到增量也忙不过来可以考虑双缓冲——把整个画面渲染到一个StringBuilder一次性写入控制台但这个方案对字符终端并不是总能消除闪烁因为输出一整屏字符串本身也可能有撕裂感。5.2 中文和特殊字符乱码不是代码问题是编码与字体问题现象把蛇身画成●或■后屏幕上出现两个问号甚至显示成乱码方块。原因控制台默认代码页不是 UTF-8。Windows 的老式控制台默认使用 GBK 或 OEM 936用Console.OutputEncoding Encoding.UTF8切换后Windows 上还需要把终端切换到 Windows Terminal 或设置chcp 65001否则字符渲染仍会异常而某些 Linux 发行版的控制台默认字体里根本没有实心方块字符。解决初始化统一设置Console.OutputEncoding Encoding.UTF8同时尽量减少字符种类优先使用O、#、空格这些 ASCII 兜底字符在老旧环境也能跑。如果你就是想用■实测在 Windows Terminal 和 Windows 11 自带的控制台里是正常的Git Bash 里则会有概率显示不全这种环境差异只能靠脚本或文档标注。5.3 按键残留与方向反转快速连按导致蛇回头自杀现象蛇向右走我快速依次按下上、左蛇没有先往上走而是直接往左掉头撞上原来的蛇身座标后判定 Game Over。原因KeyAvailable检测到缓冲里有两个键HandleInput在一次调用里读了两个方向第一下上合法地把nextDirection改成 Up紧接着左又把它改成 Left而左与当前directionRight恰好相反但检查时只对比了旧direction没对比已经更新过的nextDirection本身。解决在HandleInput的 case 里用和direction不相反作为唯一准入条件移动时才把nextDirection同步到direction。如果想进一步严格处理同一帧内只生效一次方向变化可以在HandleInput开头加bool inputHandled false作为短路开关但实际玩下来单向准入条件已经足够那种细粒度控制在普通按键输入里属于过度设计。5.4 碰撞判定顺序先加头还是先删尾结果完全不同现象蛇头贴着蛇尾前进时偶尔会莫名其妙判定撞到自己有时又能安全通过看起来毫无规律。原因自碰撞检测发生在队尾移除之前。贪吃蛇移动的物理含义是头部到达新坐标的同时尾部离开旧坐标这两个动作在同一瞬间发生。如果蛇头要去的位置正是尾巴尖当前所在格尾巴此刻正在消失所以这个位置应该是空的。先Contains后移除尾巴就会把恰好空出的格子判断成障碍。解决严格按第 3.3 节顺序——先根据是否吃到食物决定尾部是否移除再查snakeBody.Contains(newHead)。注意吃到食物时尾巴不会消失此时蛇头要去的位置如果是旧尾巴尖那就确实是撞自己这才是正确行为。这条逻辑写错了游戏表现会是蛇长到一定程度后头尾交叠区域不可预测新手往往对着坐标变量摸不着头脑。5.5 随机食物生成在蛇身或墙里无限循环之外还要防边界现象食物偶尔刷新不到或者刷新后看起来卡在墙壁里。原因random.Next(1, GameWidth - 1)的上下界在边界附近没算对比如Next(1, GameWidth - 1)能取到GameWidth - 2但GameWidth - 1这个索引才是最后一列墙导致食物可能生成在墙的坐标上也可能随机到蛇身上但Contains用的是snake而不是snakeBody忘了 HashSet 才是存储占位信息的集合。解决统一用WallAt函数判断边界生成时用while重试。还有一个容易忽略的细节移动后的SpawnFood紧跟在snakeBody.Add(newHead)之后此时食物生成时蛇身集合已经包含新蛇头但如果移到新蛇头后吃到了食物这一帧蛇身长度已经加一食物生成检查没问题可绘制层还没画这一帧于是会出现食物先出现在蛇头位置的 1 帧闪烁。解决方式是在绘制方法里保证先处理食物生成再画蛇头或者SpawnFood放在Draw之后——这个顺序问题属于画面时序要在联调时盯着看才能发现。6. 进阶玩法从单局到可暂停可存档再把碰撞坐标交给日志6.1 暂停与恢复用状态机替代布尔开关继续往下走大多数人第一件事是加暂停功能。常见错误是用bool paused加if判断但游戏已经有了 Running、GameOver 等多重状态布尔变量会捂不住暂停时按键处理要不要生效GameOver 时按 P 会不会复活所以我把状态收敛成GameState枚举并在HandleInput里已经被分流Running 时方向键生效Paused 时只有 P 能恢复GameOver 时不再处理任何输入。暂停的实现要在GameLoop里加一层专门逻辑否则暂停帧会一直空转白白占 CPUif (state GameState.Paused) { Thread.Sleep(50); continue; }这段代码的思路是暂停状态不走游戏逻辑和绘制只做Sleep让出 CPU。注意事项Thread.Sleep(50)放在continue之前否则暂停时循环仍然会以 150ms 一帧的节奏空跑虽然无伤大雅但会在任务管理器里看到多余的空转。6.2 碰撞验证把每次碰撞的坐标写进日志而不是靠肉眼猜以前写贪吃蛇时遇到最多的问题是明明逻辑看起来对可蛇就是莫名其妙死靠肉眼盯控制台窗口找原因非常低效。后来我不再用Console.WriteLine临时打印而是直接接一个文件日志每次移动后把蛇头坐标、方向、是否吃到食物、帧间隔写一行纯文本。贪吃蛇的帧率不高一局几百行日志用System.IO.File.AppendAllText完全够用。如果你在用 NLog给这个游戏类配一个Logger实例也很简单但我的经验是简单场景直接写文件更快因为看日志时不用再搭一套 NLog 配置答案往往在 Game Over 前最后十行就出现了。具体验证方法是跑一局故意在转角处连续快速按键然后打开日志看nextDirection被哪些按键覆盖过、覆盖顺序是什么这样就能复现 5.3 的按键残留问题而不是靠运气复现玄学 bug。复盘我自己的教训加暂停和日志时我贪图省事直接把字段改成public static方便从外部访问结果写完发现HandleInput、MoveSnake、Draw三个方法之间共享了太多可变状态每次改动一个参数要检查三处逻辑。如果你继续往这个项目加功能建议尽早把游戏状态收敛到一个SnakeGame类里字段私有方法公开主程序只负责调用。这个重构做完你会发现后续加存档、加关卡、加 AI 自动玩都变得顺手许多因为所有边界的修改都收在了同一个类内部。贪吃蛇这个项目的终点不该是跑起来了四字。跑起来只是把 C# 语法用了一遍把碰撞顺序、按键缓冲、绘制时序这几个环节真正想透并写成自己的代码才算把这个小项目的价值榨干。如果你照着这篇文章的思路自己写一遍大概率会在 5.3 或 5.4 碰一次壁——那正是理解 C# 集合语义和游戏循环的最佳时机。希望帮到你。本文还有配套的精品资源点击获取
返回列表