☰
C#初学者项目实战:手写贪吃蛇掌握控制台游戏开发核心
2026/10/14 6:07:49 网站建设 项目流程

1. 为什么我建议每个C#初学者都手写一遍贪吃蛇

1.1 这个项目到底在练什么

很多人学C#,语法看了一堆,类、继承、委托、事件、LINQ都能说上两句,但真让他从零写一个能跑起来的小东西,立马卡壳。这不是语法问题,是缺少一个"把语法串起来"的载体。贪吃蛇这个项目,恰好就是那个最合适的载体——它足够小,小到一晚上能写完;又足够完整,完整到涵盖了控制台应用从输入到渲染到逻辑判断的全链路。

具体来说,一个简易版贪吃蛇能让你实际用到这些东西:二维坐标的表示与运算、控制台光标定位与颜色控制、非阻塞式键盘输入、定时刷新机制、集合的增删与遍历、碰撞检测、游戏状态管理。这些概念单独拎出来都不难,但把它们揉在一个项目里,你会发现"知道"和"会用"之间隔着一条河。

我见过不少初学者,学完基础语法后去啃那些动辄几十个文件的开源项目,结果被架构和设计模式劝退。贪吃蛇的好处在于,它不需要你懂什么三层架构、依赖注入,一个Program.cs加一个Snake类就能跑起来。等你把这个简易版写通了,再去理解那些复杂项目,心里就有底了。

1.2 适合谁来动手

这个项目适合刚学完C#基础语法、还没做过完整项目的人。如果你已经能熟练写循环、条件判断、数组和List的基本操作,那就可以开始了。不需要你懂WinForm或WPF,纯控制台就能搞定,连Visual Studio的安装都只需要勾选".NET桌面开发"这一个工作负载。

如果你已经写过一些控制台小程序,比如计算器、猜数字,那贪吃蛇是很好的进阶练习。它比计算器多了实时性和状态管理,但又没到图形界面的复杂度。说白了,这是一个"跳一跳够得着"的项目。

另外说一句,别小看控制台版本。很多人觉得控制台太简陋,想直接上WinForm画蛇。我的建议是先把控制台版写明白,因为控制台版逼着你处理坐标系、刷新时机、输入缓冲这些底层问题。等你换到图形界面,这些经验直接迁移,只是把Console.SetCursorPosition换成Graphics.DrawRectangle而已。

1.3 最终做出来是什么样

成品是一个在命令行窗口里运行的贪吃蛇:蛇用绿色方块表示,食物用红色方块表示,蛇吃到食物变长,撞墙或撞到自己就游戏结束,屏幕上显示当前得分。操作上用方向键控制,支持暂停和重新开始。整个程序大概两三百行代码,一个文件就能装下。

听起来简单,但里面有几个坑是初学者必踩的:画面闪烁、按键响应迟钝、蛇身移动逻辑写错导致"穿模"、食物生成在蛇身上。这些坑我在下面会一个个拆开讲,包括我当时是怎么踩进去又怎么爬出来的。

2. 动手之前先把这几个核心问题想清楚

2.1 蛇到底怎么"动"的

初学者最容易犯的错,是把蛇的移动理解成"每个格子往前挪一格"。如果你真这么写,会发现蛇头动了,蛇身没跟上,或者整条蛇一起动,看起来像平移。正确的思路是:蛇的移动本质上是"在头部添加一个新格子,然后删掉尾部最后一个格子"。

为什么这样对?你想想,蛇往前爬一格,从视觉上看,就是头前面多了一节,尾巴少了一节。中间的身体根本没动。所以代码里只需要两步:body.Insert(0, newHead)和body.RemoveAt(body.Count - 1)。吃到食物的时候,只做第一步不做第二步,蛇自然就变长了。

这个思路一旦想通,后面所有逻辑都顺了。碰撞检测只需要判断新蛇头的位置是否撞墙或撞到自己的身体;食物生成只需要找一个不在蛇身上的随机坐标。整个游戏循环就变成了:计算新蛇头位置 → 判断碰撞 → 插入新头 → 判断是否吃到食物 → 决定是否删尾 → 重新渲染。

注意:Insert(0, ...)在List上的时间复杂度是O(n),因为要把后面所有元素后移。对于贪吃蛇这种规模(蛇身最多几十节),完全无所谓。但如果你以后做更复杂的游戏,可以考虑用LinkedList,头尾操作都是O(1)。

2.2 为什么不能用Console.ReadLine

新手写控制台交互,第一反应是Console.ReadLine()。但贪吃蛇需要的是不阻塞的实时输入——游戏在跑,你随时按键,它随时响应,不能停下来等你按回车。所以必须用Console.KeyAvailable配合Console.ReadKey(true)。

Console.KeyAvailable返回一个布尔值,告诉你当前有没有按键在缓冲区里。有的话再调Console.ReadKey(true)读出来,参数true表示不把按键回显到屏幕上。这两个配合起来,就能实现"有按键就处理,没按键就继续跑游戏循环"的效果。

这里有个细节:如果你在游戏主循环里每次都调Console.ReadKey(),程序会卡在那里等输入,蛇就不动了。所以正确做法是每帧先检查KeyAvailable,有才读,没有就跳过。这个模式在游戏开发里叫"轮询输入",是最基础也是最通用的做法。

2.3 画面闪烁的根源在哪

很多人第一次写出来,蛇一动整个屏幕就闪一下,体验极差。闪烁的原因很简单:你每次刷新都调了Console.Clear(),整个屏幕先被清空再重画,中间那一瞬间就是黑的,人眼就能感知到。

解决办法有两个。方案一是只重绘变化的部分:蛇头新增的格子画上去,蛇尾删掉的格子用背景色覆盖掉,食物位置变了就重画食物。这样屏幕上大部分内容不动,自然不闪。方案二是用双缓冲,先在内存里拼好一整帧,再一次性输出。控制台没有原生双缓冲,但你可以用Console.SetBufferSize配合Console.SetWindowSize来减少闪烁,或者干脆接受轻微闪烁,把刷新间隔调大一点。

我当时的做法是方案一,因为贪吃蛇的变化区域很小,只重绘头尾和食物就够了。具体来说,每次移动后,在新蛇头位置画绿色方块,在旧蛇尾位置画一个空格(背景色),如果吃到食物就在食物位置画绿色。这样屏幕上只有两三个格子在变,完全不闪。

2.4 坐标系和边界怎么定

控制台的坐标系和数学坐标系不一样:原点在左上角,x轴向右,y轴向下。这个一定要记住,不然你写"向上移动"的时候会写成y减一还是y加一搞混。

边界的话,我建议留出边框。比如窗口宽度80、高度25,那游戏区域可以设为x从1到78,y从1到23,四周留一圈画边框。这样蛇撞到边框就是撞墙,视觉上也清晰。如果你不想画边框,那就直接用0到Width-1、0到Height-1作为边界,但要注意控制台窗口大小和缓冲区大小的关系,别让蛇跑到看不见的地方去。

提示:Console.WindowWidth和Console.WindowHeight获取的是可见窗口大小,Console.BufferWidth和Console.BufferHeight是缓冲区大小。缓冲区通常比窗口大,有滚动条。做游戏时建议把缓冲区设成和窗口一样大,避免滚动条干扰。

3. 从零开始搭建游戏骨架

3.1 项目创建与控制台初始化

打开Visual Studio,新建一个"控制台应用"项目,目标框架选.NET 6或.NET 8都行,语言选C#。项目建好后,先别急着写逻辑,把控制台窗口初始化好。

初始化要做几件事:设置窗口大小、设置缓冲区大小、隐藏光标、设置标题。隐藏光标很重要,不然那个闪烁的光标在游戏画面上很碍眼。代码大概长这样:

Console.Title = "贪吃蛇"; Console.WindowWidth = 80; Console.WindowHeight = 25; Console.BufferWidth = 80; Console.BufferHeight = 25; Console.CursorVisible = false; Console.Clear();

这里有个坑:WindowWidth和BufferWidth不能随便设,如果设得比当前屏幕能容纳的还大,会抛异常。稳妥的做法是先设缓冲区再设窗口,或者用try-catch包起来。另外,不同系统的控制台行为略有差异,如果设不了就跳过,用默认大小也能玩。

3.2 定义蛇和食物的数据结构

蛇用什么存?最简单的是List<Point>,Point是System.Drawing里的结构体,有X和Y两个属性。但控制台项目默认不引用System.Drawing,你可以自己定义一个简单的结构体,或者直接用List<(int x, int y)>这种元组。我倾向于自己定义一个Position结构体,因为后面要比较相等、要作为字典键,自己定义更可控。

struct Position { public int X; public int Y; public Position(int x, int y) { X = x; Y = y; } }

食物就是一个Position。方向用一个枚举表示:上、下、左、右。游戏状态用一个枚举:运行中、暂停、结束。这些定义好了,整个程序的数据模型就清晰了。

蛇的初始状态:长度3,水平放在中间,头朝右。食物随机生成在游戏区域内,且不能和蛇身重叠。生成食物的逻辑要循环随机直到找到一个空位,虽然理论上可能死循环(蛇占满整个区域时),但实际游戏中蛇几乎不可能占满,所以不用太担心。真要严谨的话,可以维护一个"空闲格子列表",但那是优化,初学版没必要。

3.3 游戏主循环的节奏控制

游戏主循环是整个程序的心脏。它的结构是:处理输入 → 更新逻辑 → 渲染画面 → 等待一小段时间 → 重复。这个"等待一小段时间"就是游戏的速度控制。

怎么等待?不能用Thread.Sleep,因为它会阻塞整个线程,按键响应会变迟钝。更好的做法是记录上一帧的时间,然后在一个while循环里不断检查是否到了下一帧的时间。但初学版用Thread.Sleep也凑合,只要把睡眠时间设短一点(比如100毫秒),按键延迟在可接受范围内。

速度控制的核心参数是"每帧间隔多少毫秒"。间隔越小蛇跑得越快。我一般设150毫秒起步,随着得分增加逐渐减小,最低到50毫秒左右。这样游戏有难度递增的感觉。实现上就是维护一个speed变量,吃到食物就减几毫秒,但设一个下限。

int speed = 150; // 吃到食物后 speed = Math.Max(50, speed - 5); Thread.Sleep(speed);

注意:Thread.Sleep的精度受系统调度影响,实际睡眠时间可能比设定值长。对于贪吃蛇这种对精度要求不高的游戏,完全够用。如果你以后做需要精确帧率的游戏,就得用Stopwatch来做帧时间累加。

4. 核心逻辑逐个击破

4.1 蛇的移动与增长

前面说了,移动就是头插尾删。但有个细节:新蛇头的位置是根据当前方向算出来的。如果方向是右,新头就是(head.X + 1, head.Y);左就是减一,上就是Y减一,下就是Y加一。这里一定要注意控制台的Y轴向下,所以"上"是Y减一。

方向不能随便改。比如蛇正在向右走,你按左键,如果直接改方向,蛇会瞬间掉头撞到自己。所以需要一个"当前方向"和"待处理方向"的机制:按键只改待处理方向,每帧开始时才把待处理方向赋给当前方向,并且要判断新方向不能和当前方向相反。

// 按键处理 if (key == ConsoleKey.UpArrow && currentDir != Direction.Down) nextDir = Direction.Up; // 每帧开始 currentDir = nextDir;

吃到食物的判断很简单:新蛇头的位置和食物位置相等。相等的话,不删尾,蛇就长了一节,然后重新生成食物,加分,加速。

4.2 碰撞检测的三种情况

碰撞检测要判断三件事:撞墙、撞自己、吃到食物。撞墙就是新蛇头的X或Y超出了游戏区域边界。撞自己就是新蛇头的位置和蛇身中任意一节重合。注意,蛇尾那一节在移动后会消失,所以严格来说,如果新蛇头正好在蛇尾位置,不算撞——但初学版为了简单,可以认为撞到任何一节都算死,影响不大。

吃到食物前面说了,就是位置相等。这三个判断的顺序有讲究:先判断吃食物,再判断撞墙撞自己。因为如果新蛇头既吃到食物又撞墙(理论上可能,比如食物在墙角),应该算吃到还是算死?一般算死,因为撞墙优先级更高。但实际游戏中食物不会生成在墙上,所以这个边界情况不用太纠结。

Position newHead = GetNextHead(); if (IsWall(newHead) || IsSelfCollision(newHead)) { gameState = GameState.GameOver; break; } if (newHead == food) { snake.Insert(0, newHead); score += 10; GenerateFood(); speed = Math.Max(50, speed - 5); } else { snake.Insert(0, newHead); snake.RemoveAt(snake.Count - 1); }

4.3 食物生成的随机策略

食物生成看起来简单,其实有个小坑:随机生成的位置可能落在蛇身上。所以要用一个循环,不断随机直到找到空位。判断是否在蛇身上,就是遍历蛇身列表看有没有坐标相等。

Random rand = new Random(); Position food; do { food = new Position(rand.Next(1, 78), rand.Next(1, 23)); } while (snake.Contains(food));

这里snake.Contains(food)能工作,是因为Position是结构体,默认的相等比较是按字段逐个比较的。如果你用类而不是结构体,就得重写Equals和GetHashCode,否则Contains永远返回false。这是初学者常踩的坑,记住就行。

另外,Random不要在循环里反复new,因为如果种子相同(默认用时间做种子),短时间内多次new会得到相同的随机序列。正确做法是在类级别创建一个Random实例,全程复用。

4.4 渲染:只画变化的部分

渲染是控制台游戏最容易出问题的地方。前面说了不要用Console.Clear(),那怎么画?答案是用Console.SetCursorPosition定位到具体格子,然后设置前景色画方块。

画一个格子大概是这样:

Console.SetCursorPosition(pos.X, pos.Y); Console.ForegroundColor = ConsoleColor.Green; Console.Write("■"); Console.ResetColor();

蛇头可以用亮绿色,蛇身用深绿色,食物用红色。这样视觉上有层次。擦除一个格子就是定位过去写一个空格,或者用背景色写空格。

每帧的渲染流程:在新蛇头位置画蛇头,在旧蛇头位置画蛇身(因为旧蛇头变成了身体),在旧蛇尾位置擦除,如果吃到食物就在食物位置画蛇头并生成新食物。这样每帧最多画三个格子,效率极高,完全不闪。

提示:Console.Write("■")这个方块字符在某些字体下可能显示不全,可以用"█"或者两个空格加背景色代替。如果方块显示有问题,用Console.BackgroundColor设置背景色然后写空格,效果一样。

5. 那些只有自己写过才会知道的坑

5.1 按键缓冲导致的"连跳"

你有没有遇到过这种情况:快速按两下方向键,蛇好像一次转了两次弯,直接撞死。这是因为控制台的按键缓冲区会把你按的每个键都存下来,游戏循环每帧只读一个,但如果你按得快,缓冲区里堆了好几个键,接下来几帧会连续处理,蛇就连续转向。

解决办法是每帧只处理一个按键,并且处理完后清空缓冲区。清空的方法是在一个while循环里不断检查KeyAvailable并读取,直到没有按键为止,但只保留最后一个有效方向。或者更简单:每帧只读一个键,但把Thread.Sleep设短一点,让按键尽快被消费掉。

while (Console.KeyAvailable) { var key = Console.ReadKey(true).Key; // 只记录最后一个有效方向 if (key == ConsoleKey.UpArrow && currentDir != Direction.Down) nextDir = Direction.Up; // ... 其他方向 }

这样即使缓冲区里堆了多个键,也只会用最后一个有效方向,不会连续转向。

5.2 蛇身"穿模"的真相

有时候你会看到蛇头穿过了自己的身体,看起来像bug。这其实不是渲染问题,是逻辑问题。原因通常是方向更新和位置更新的顺序错了。如果你在计算新蛇头之前就更新了方向,而新方向又恰好和当前方向相反(比如当前向右,你按了左,但判断逻辑没拦住),蛇头就会直接跳到身体里面。

正确的顺序是:先根据当前方向计算新蛇头位置,判断碰撞,然后再更新方向。或者更严格地说,方向更新应该发生在每帧的最开始,且必须校验不能反向。我当时的做法是在按键处理时就校验方向,不合法就直接忽略,这样nextDir永远是合法的。

5.3 游戏结束后的处理

游戏结束后,不能直接return退出程序,那样窗口一闪就没了,玩家看不到得分。正确的做法是显示"游戏结束,得分XX,按R重新开始,按Q退出",然后进入一个等待输入的循环。

Console.SetCursorPosition(30, 12); Console.WriteLine($"游戏结束!得分:{score}"); Console.WriteLine("按 R 重新开始,按 Q 退出"); while (true) { var key = Console.ReadKey(true).Key; if (key == ConsoleKey.R) { ResetGame(); break; } if (key == ConsoleKey.Q) return; }

重新开始就是把蛇、食物、得分、速度、方向全部重置到初始状态,然后继续主循环。这里要注意把游戏状态改回"运行中",否则主循环会立刻又结束。

5.4 常见问题速查表

问题现象可能原因解决方法
画面闪烁严重每帧调用了Console.Clear改为只重绘变化区域
按键没反应用了Console.ReadLine改用KeyAvailable+ReadKey
蛇移动方向错乱方向未校验反向按键时判断不能与当前方向相反
食物生成在蛇身上随机后未检查循环随机直到不在蛇身
蛇头穿过身体方向更新顺序错误先算新头再更新方向
游戏结束窗口闪退直接return加等待输入的循环
方块显示为问号字体不支持换用背景色+空格方案
蛇跑出边界边界判断用了窗口大小而非缓冲区统一用游戏区域常量

6. 写完基础版之后还能怎么玩

6.1 加个计分板和最高分记录

基础版只有当前得分,你可以加一个最高分记录,存到文件里,下次打开还在。用File.ReadAllText和File.WriteAllText就能搞定,路径用AppDomain.CurrentDomain.BaseDirectory拼一个文件名。每次游戏结束比较得分和最高分,破了就写文件。

这个功能虽小,但让你第一次接触"数据持久化"的概念。以后做更复杂的应用,无非是把文本文件换成数据库或配置文件,思路是一样的。

6.2 加障碍物和特殊食物

在游戏区域里随机放几个障碍物,撞到就死。或者加一种"金色食物",吃到加50分但只存在5秒。这些都是在现有框架上做加法:障碍物就是一个List<Position>,碰撞检测时多判断一次;特殊食物就是多一个计时器变量,每帧减一,到零就重新生成。

这些扩展能让你练习"状态管理"——游戏里同时有多个需要跟踪的变量,怎么组织它们、怎么保证它们同步更新,是游戏开发的核心技能之一。

6.3 从控制台迁移到图形界面

如果你已经把控制台版写通了,可以试试用WinForm或WPF重写。核心逻辑(蛇的移动、碰撞、食物生成)几乎不用改,只需要把渲染层换掉:Console.SetCursorPosition换成Graphics.FillRectangle,Console.ReadKey换成KeyDown事件,Thread.Sleep换成Timer控件。

这个迁移过程会让你深刻理解"逻辑与渲染分离"的价值。如果你在控制台版里把渲染代码和逻辑代码混在一起,迁移时就会很痛苦;如果你一开始就分开了,迁移就是换个渲染函数的事。这也是为什么我建议初学者要有意识地做分层,哪怕是在一个小项目里。

6.4 我个人的一点体会

写贪吃蛇最大的收获,不是学会了某个API,而是理解了"游戏循环"这个模式。输入、更新、渲染、等待,这四步循环是所有实时交互程序的骨架,从贪吃蛇到3A大作,本质都一样。你把这个骨架吃透了,以后学任何游戏引擎、任何UI框架,都能快速上手。

另外,别怕代码写得丑。我第一版贪吃蛇所有代码都堆在Main函数里,两百多行,变量名还是a、b、c。但能跑起来,能玩,这就够了。先让它跑起来,再谈优化和重构。很多初学者卡在"想写出完美代码"上,结果一行没写。先写出来,再改好,这个顺序不能反。

最后分享一个小技巧:如果你觉得蛇跑得太快反应不过来,把speed初始值设成200甚至250,等熟练了再慢慢调低。游戏难度是可以自己控制的,别一上来就挑战极限速度,那样只会打击信心。写代码和玩游戏一样,循序渐进才是正道。

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

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

立即咨询