1. 为什么我建议每个C#初学者都从贪吃蛇开始练手
如果你刚学完C#的变量、循环和条件判断,正对着控制台里那几行Console.WriteLine发呆,不知道该拿这些语法做点什么——我的建议很直接:写一个贪吃蛇。不是因为它简单,恰恰是因为它"麻雀虽小,五脏俱全"。一个最简易的贪吃蛇,代码量大概在两百到三百行之间,但它逼着你把数组、循环、条件分支、随机数、控制台光标定位、键盘输入这些东西全部串起来用一遍。你写完之后会发现,之前零散学的语法点突然连成了一条线。
我见过不少初学者一上来就想做带图形界面的项目,结果卡在窗体设计器里出不来,连最基本的游戏循环都没搞明白。贪吃蛇的好处在于,它把"游戏循环"这个核心概念用最朴素的方式摆在你面前:清屏、画蛇、画食物、读键盘、判断碰撞、更新位置,然后重复。这个循环就是所有游戏的骨架,你把它吃透了,以后学任何游戏框架都会觉得眼熟。
这篇文章面向的是刚接触C#不久、想通过一个完整小项目把基础知识用起来的人。我会从零开始,把每一步的设计理由、代码写法、容易踩的坑都讲清楚。你不需要任何游戏开发经验,只要会写for循环和if判断就能跟上。代码我全部实测跑过,用的是.NET 6以上的控制台项目,Visual Studio或者VS Code都能直接跑。
2. 动手之前先把这几个设计问题想明白
2.1 用控制台还是用窗体,这是个关键选择
初学者最容易纠结的就是"我到底该用Console还是WinForms"。我的答案很明确:第一版一定用控制台。原因有三层。第一,控制台没有UI设计器的干扰,你的注意力全部在逻辑上,这对建立编程思维至关重要。第二,控制台的"画面"本质上就是一个二维字符网格,蛇的坐标、食物的坐标、墙壁的坐标,全部可以用int x, y来表示,这让你能非常直观地理解"游戏世界就是一堆坐标点"这个本质。第三,控制台版本的调试成本极低,你不需要处理控件重绘、线程冲突这些复杂问题。
等你把控制台版本写通了,再迁移到WinForms或者Unity,你会发现只是把"在光标位置打印字符"换成了"在画布上绘制矩形",核心逻辑一行都不用改。这个迁移路径是最平滑的。
2.2 蛇到底用什么数据结构来存
这是整个项目里最重要的一个设计决策。我见过有人用List<Point>,有人用数组,还有人用队列。对于初学者版本,我推荐用List<Point>配合一个固定大小的数组来做,但更简单的方式是直接用List。为什么?因为蛇的移动本质上是"头部加一节,尾部减一节"(没吃到食物时),或者"头部加一节,尾部不动"(吃到食物时)。用List的话,Insert(0, newHead)加RemoveAt(lastIndex)就能完美表达这个逻辑。
但这里有个性能上的小坑:List的Insert(0, ...)是O(n)操作,因为要把后面所有元素往后挪。对于一条长度几十节的蛇来说,这点开销完全可以忽略,但如果你以后想做更复杂的游戏,就要考虑用LinkedList或者环形缓冲区。初学阶段,可读性优先于性能,用List没问题。
2.3 游戏循环的节奏怎么控制
控制台游戏最大的问题是"怎么让画面动起来又不闪"。如果你用while(true)死循环加Console.Clear(),画面会疯狂闪烁,根本没法玩。正确的做法是用Thread.Sleep()控制帧率,同时用Console.SetCursorPosition()只重绘变化的部分,而不是整个清屏。
我的经验是,贪吃蛇的刷新间隔设在100到150毫秒之间比较合适。太快了蛇跑得你反应不过来,太慢了又显得卡顿。这个值可以做成一个变量,方便你调试时调整。另外,键盘输入不能用Console.ReadLine(),因为那会阻塞整个循环。要用Console.KeyAvailable配合Console.ReadKey(true)来非阻塞地读取按键。
3. 从零搭建:核心代码逐块拆解
3.1 游戏区域的坐标系统设计
先定义游戏区域的宽高。我一般用宽40、高20,这个尺寸在大多数终端里都能完整显示,不会出现换行错位。坐标原点设在左上角(0, 0),x向右递增,y向下递增。这个坐标系和数组的索引方式一致,处理起来最顺手。
const int Width = 40; const int Height = 20;墙壁的处理有两种方案:一种是画一圈边框字符,蛇撞到边界就死;另一种是不画边框,蛇从右边出去就从左边进来(穿墙模式)。初学者版本我建议用撞墙死亡,因为逻辑更简单,而且能让你清楚地处理边界判断。
// 判断是否撞墙 bool HitWall(Point p) { return p.X <= 0 || p.X >= Width - 1 || p.Y <= 0 || p.Y >= Height - 1; }注意这里用了<= 0和>= Width - 1,因为第0行和第0列要留给边框。这个细节很容易写错,写成< 0的话蛇会跑到边框外面去,画面就乱了。
3.2 蛇的移动逻辑与方向缓冲
蛇的移动方向用一个枚举来表示:
enum Direction { Up, Down, Left, Right }这里有个初学者必踩的坑:如果你直接根据当前按键改变方向,当蛇向右移动时,玩家快速按下"上"再按"左",蛇会在同一帧内先转向上再转向左,结果就是蛇直接掉头撞到自己。解决办法是引入一个"方向缓冲"机制——每帧只允许改变一次方向,而且新方向不能和当前方向相反。
Direction currentDir = Direction.Right; Direction nextDir = Direction.Right; // 在读取键盘时 if (key.Key == ConsoleKey.UpArrow && currentDir != Direction.Down) nextDir = Direction.Up; // ... 其他方向同理 // 在更新逻辑开始时 currentDir = nextDir;这个"当前方向"和"下一方向"分离的设计,是很多初学者想不到但非常关键的技巧。它保证了蛇不会在一帧内做出非法转向。
3.3 食物的随机生成与合法性校验
食物生成看起来简单,其实有个隐蔽的坑:随机生成的位置可能正好在蛇身上。如果你不检查,食物就会"消失"在蛇的身体里,玩家永远吃不到。
Point GenerateFood(List<Point> snake) { Random rand = new Random(); Point food; do { food = new Point(rand.Next(1, Width - 1), rand.Next(1, Height - 1)); } while (snake.Contains(food)); return food; }这里用do-while循环反复生成,直到位置不在蛇身上为止。当蛇很短的时候,这个循环几乎一次就成功;当蛇很长的时候,可能会循环很多次。对于初学者版本,蛇最长也就几十节,游戏区域有几百个格子,效率完全没问题。但你要知道这个写法在蛇极长时会有性能问题,进阶版本可以用"收集所有空格子然后随机选一个"的方式优化。
另外,Random对象不要在循环里反复new,因为如果时间间隔太短,多次new Random()可能产生相同的种子,导致食物位置重复。把Random声明为类的成员变量,全局只用一个实例。
3.4 碰撞检测:撞墙、撞自己、吃食物
每帧更新蛇的位置后,要做三次判断,顺序很重要:
Point newHead = GetNextHead(snake[0], currentDir); // 1. 先判断撞墙 if (HitWall(newHead)) { GameOver(); } // 2. 再判断撞自己(注意:尾巴那一节即将移走,可以不算) if (snake.Take(snake.Count - 1).Contains(newHead)) { GameOver(); } // 3. 判断是否吃到食物 if (newHead == food) { snake.Insert(0, newHead); // 头加一节,尾不动 food = GenerateFood(snake); score++; } else { snake.Insert(0, newHead); // 头加一节 snake.RemoveAt(snake.Count - 1); // 尾减一节 }这里有个精妙之处:判断撞自己时,我用了Take(snake.Count - 1)排除了尾巴。为什么?因为尾巴在这一帧会移走,如果新头正好移动到尾巴的位置,实际上不会撞到,因为尾巴已经让开了。这个细节如果你不注意,蛇在追着自己尾巴转圈时会莫名其妙死掉,玩家会觉得是bug。
4. 让画面不闪、按键不卡:控制台渲染的实战技巧
4.1 双缓冲思路在控制台里的简化实现
控制台没有真正的双缓冲,但我们可以用"只重绘变化区域"来达到类似效果。具体做法是:维护一个和游戏区域等大的字符数组作为"上一帧画面",每帧计算新画面,只把和上一帧不同的位置用Console.SetCursorPosition加Console.Write更新。
对于初学者版本,其实有更简单的办法:不清屏,而是用空格覆盖旧蛇身的位置。但这样代码会复杂。我的折中方案是:每帧只清空蛇尾移走的那一格和食物被吃掉的那一格,其他位置不动。这样画面几乎不闪。
// 蛇尾移走时,把那一格清空 Console.SetCursorPosition(oldTail.X, oldTail.Y); Console.Write(' ');但要注意,如果蛇尾移走的位置正好是墙壁或者食物,就不能清空。所以清空之前要判断一下。这个逻辑稍微有点绕,但写一次就明白了。
4.2 键盘输入的非阻塞读取
前面提到用Console.KeyAvailable,这里展开说下具体写法:
while (Console.KeyAvailable) { ConsoleKeyInfo key = Console.ReadKey(true); switch (key.Key) { case ConsoleKey.UpArrow: if (currentDir != Direction.Down) nextDir = Direction.Up; break; // ... 其他方向 } }用while而不是if,是为了把这一帧内积累的所有按键都处理掉,避免按键丢失。ReadKey(true)的参数true表示不把按键回显到屏幕上,否则你的游戏画面上会多出一堆箭头字符。
这里还有个体验上的细节:方向键和WASD最好都支持。有些人的键盘方向键位置不顺手,用WASD会更舒服。加几个case的事,但体验提升明显。
4.3 帧率控制与游戏速度的动态调整
int baseDelay = 120; // 毫秒 int delay = Math.Max(50, baseDelay - score * 3); Thread.Sleep(delay);这段代码让蛇随着得分增加而加速,每吃一个食物快3毫秒,但最快不低于50毫秒。这个"动态难度"是贪吃蛇让人上瘾的关键——前期轻松,后期紧张。你可以根据自己的手感调整baseDelay和加速系数。我试过baseDelay=150、每分减5毫秒,节奏也很舒服。
注意:
Thread.Sleep的精度受系统调度影响,实际间隔可能比设定值略长。对于贪吃蛇这种对精度要求不高的游戏,完全够用。如果你以后做需要精确计时的游戏,要用Stopwatch配合忙等待或者专门的游戏循环库。
5. 那些只有真正写过才会知道的坑
5.1 光标定位越界导致的异常
Console.SetCursorPosition(x, y)如果传入的坐标超出了控制台窗口的实际大小,会抛出ArgumentOutOfRangeException。而控制台窗口大小是可以被用户拖拽改变的。如果你把游戏区域设成40x20,但用户把窗口拖得很小,程序就会崩溃。
解决办法有两个:一是在程序启动时用Console.SetWindowSize强制设置窗口大小,并提示用户不要调整;二是在每次SetCursorPosition之前做边界检查。初学者版本我建议用第一种,简单粗暴但有效:
Console.SetWindowSize(Width + 2, Height + 2); Console.SetBufferSize(Width + 2, Height + 2);注意SetWindowSize和SetBufferSize在某些系统上有限制,窗口不能小于某个最小值。如果设置失败会抛异常,所以最好用try-catch包一下,失败了就提示用户手动调整窗口。
5.2 蛇身字符和食物字符的视觉区分
蛇头、蛇身、食物、墙壁,这四个元素要用不同的字符和颜色区分。我的习惯是:蛇头用@,蛇身用o,食物用*,墙壁用#。颜色方面,蛇头用绿色,蛇身用深绿色,食物用红色,墙壁用灰色。这样一眼就能看清游戏状态。
Console.ForegroundColor = ConsoleColor.Green; Console.Write('@'); Console.ResetColor();记得每次改完颜色要ResetColor(),否则后面的输出都会带着颜色,画面会很乱。这个坑我踩过,调试了半天才发现是颜色没重置。
5.3 游戏结束后的重开逻辑
初学者版本很容易忽略"游戏结束后怎么办"。如果直接return退出程序,玩家想再玩一局就得重新运行,体验很差。正确的做法是把游戏主逻辑包在一个外层循环里,游戏结束后询问玩家是否重开。
while (true) { RunGame(); // 一局游戏 Console.SetCursorPosition(0, Height + 1); Console.Write("游戏结束!按R重开,按Q退出:"); var key = Console.ReadKey(true).Key; if (key == ConsoleKey.Q) break; }这里要注意,重开之前要把分数、蛇、食物全部重置。我建议把这些状态封装成一个GameState类,重开时直接new一个新实例,比手动重置每个变量更不容易出错。
5.4 分数显示和最高分记录
分数显示在游戏区域上方或下方,用Console.SetCursorPosition定位到固定位置输出。最高分可以存在内存里,程序关闭就丢失;也可以写到一个文本文件里,下次启动时读取。对于初学者版本,存内存就够了,但写文件是个很好的练习,涉及File.ReadAllText和File.WriteAllText,你可以试试。
// 读取最高分 int highScore = 0; if (File.Exists("highscore.txt")) int.TryParse(File.ReadAllText("highscore.txt"), out highScore); // 保存最高分 if (score > highScore) File.WriteAllText("highscore.txt", score.ToString());int.TryParse比int.Parse安全,因为文件内容可能被手动改坏,TryParse不会抛异常。
6. 从能跑到好用:几个提升体验的进阶思路
6.1 用结构体封装坐标,让代码更清爽
Point是.NET自带的,但它是引用类型(实际上System.Drawing.Point是结构体,但需要引用额外的程序集)。我建议自己定义一个简单的结构体:
struct Position { public int X; public int Y; public Position(int x, int y) { X = x; Y = y; } public static bool operator ==(Position a, Position b) => a.X == b.X && a.Y == b.Y; public static bool operator !=(Position a, Position b) => !(a == b); public override bool Equals(object obj) => obj is Position p && this == p; public override int GetHashCode() => HashCode.Combine(X, Y); }重写==和Equals之后,List.Contains才能正确判断两个坐标是否相同。如果你不重写,Contains用的是引用比较,永远返回false,食物生成会死循环。这个坑非常隐蔽,我第一次写的时候就卡在这里。
6.2 把游戏逻辑和渲染分离
初学者容易把"更新蛇的位置"和"在屏幕上画蛇"混在一起写。更好的做法是分成两个方法:Update()只改数据,Render()只负责画。这样调试的时候你可以单独测试Update()的逻辑,不用管画面。
void Update() { /* 移动蛇、判断碰撞、生成食物 */ } void Render() { /* 根据当前数据画画面 */ }这个分离思想是游戏开发的基础,你现在养成习惯,以后学任何引擎都会受益。
6.3 用枚举代替魔法数字
方向用枚举,游戏状态也用枚举:
enum GameState { Running, Paused, GameOver }这样代码里就不会出现if (state == 1)这种看不懂的写法。可读性提升不是一点半点。
6.4 暂停功能的实现
暂停很简单,用一个GameState判断,暂停时不执行Update(),但继续响应键盘。按空格切换暂停状态。这个功能代码量很小,但很实用,尤其是你调试的时候。
if (state == GameState.Paused) { // 只处理键盘,不更新逻辑 Thread.Sleep(50); continue; }7. 我踩过的三个真实坑和修复过程
7.1 蛇追尾巴时莫名死亡
前面提到过,判断撞自己时要把尾巴排除。我当时的代码是snake.Contains(newHead),结果蛇在追自己尾巴转圈时,新头的位置正好是尾巴的位置,Contains返回true,游戏结束。玩家明明没撞到,却死了。修复方法就是snake.Take(snake.Count - 1).Contains(newHead)。这个bug我找了半个小时,最后是打印出蛇的所有坐标才定位到的。
7.2 快速连按方向键导致蛇掉头
这个坑前面也说了,用方向缓冲解决。但还有个变种:如果玩家在蛇向右移动时快速按"上"然后按"左",在缓冲机制下,nextDir先被设为Up,然后被设为Left。但Left和当前方向Right相反,所以第二个按键被忽略了,蛇只会向上转。这其实是正确行为,但玩家可能觉得"我按了左怎么没反应"。解决办法是在按键处理时,用nextDir而不是currentDir来判断是否相反:
case ConsoleKey.LeftArrow: if (nextDir != Direction.Right) nextDir = Direction.Left; break;这样连续按"上"再按"左",nextDir是Up,Left和Up不相反,所以nextDir变成Left,蛇会先向上再向左,符合玩家预期。
7.3 食物生成在蛇身上导致死循环
前面说过do-while加Contains。但如果Contains因为没重写Equals而永远返回false,这个循环就永远不会退出,程序卡死。我当时的症状是:吃到食物后程序无响应。排查方法是加了一行Console.WriteLine在循环里,发现疯狂输出,立刻定位到Contains失效。修复就是重写Equals和GetHashCode。
8. 写完这个之后,你还能往哪个方向继续折腾
贪吃蛇写完之后,你手里其实已经有了一套最小可用的游戏框架:游戏循环、状态管理、输入处理、碰撞检测、渲染。基于这个框架,你可以做很多扩展。比如加"障碍物"——在游戏区域随机放几个固定的墙,撞到就死,这只需要在碰撞检测里多加一个判断。比如加"特殊食物"——吃到一个金色的食物加3分,但存在时间只有5秒,这需要引入计时器。比如做"双人对战"——两条蛇,各自用不同的按键控制,撞到对方身体就输,这需要把蛇的数据结构从单个List改成两个List,碰撞检测也要互相判断。
再往远了说,你可以把这个控制台版本迁移到WinForms,用Graphics.DrawRectangle画方块,用Timer控制帧率,用KeyDown事件处理输入。核心逻辑几乎不用改,只是把Console.SetCursorPosition换成Graphics的绘制调用。这个过程会让你深刻体会到"逻辑和渲染分离"的好处。
我个人在实际操作中的体会是,贪吃蛇这个项目最大的价值不在于它本身多好玩,而在于它逼着你把C#的基础语法在一个有反馈的环境里用一遍。你写的时候会遇到各种"为什么蛇不动了""为什么食物不见了""为什么按键没反应",每一个问题的排查过程,都比你看十页教程学到的多。所以别怕写错,写错了调试,调试通了你就真的会了。