1. 为什么选贪吃蛇作为第一个C#项目
1.1 从“能跑起来”到“理解游戏循环”
很多人学C#,语法看了一大堆,类、对象、继承、委托都能说出个大概,但真让他从零写一个能玩的东西,立马卡壳。这不是语法问题,是缺少一个“把知识串起来”的载体。贪吃蛇这个项目,恰好就是那个最合适的载体——它足够小,小到一晚上就能写出雏形;又足够完整,完整到包含了游戏开发的所有核心要素:游戏循环、状态管理、输入处理、碰撞检测、渲染刷新。
我当初带过几个刚入门的同学,让他们直接上手做计算器或者通讯录管理,做着做着就变成了“面向过程的一堆if-else”。但换成贪吃蛇之后,他们自然而然就开始思考:蛇怎么表示?食物怎么随机?撞墙怎么判断?这些问题逼着你去用类、用集合、用定时器,而不是硬堆代码。
这个简易版本的目标很明确:控制台环境、不依赖任何第三方库、纯C#原生实现。你只需要装好.NET SDK,打开终端就能跑。最终成品是一个在控制台窗口里用字符画出来的贪吃蛇,方向键控制移动,吃到食物变长,撞墙或咬到自己就结束。听起来简单,但里面藏着不少值得说道的细节。
1.2 这个版本适合谁,不适合谁
先说适合的人群。如果你刚学完C#的基础语法,知道int、string、for循环、if判断,但对“怎么把这些东西组合成一个能交互的程序”还没有概念,那这个项目就是为你准备的。它不需要你懂LINQ、不需要你懂异步、不需要你懂任何设计模式,只需要你有一颗“我想做个东西出来”的心。
不适合的人群也说清楚。如果你已经写过WinForms或者Unity项目,那这个控制台版本对你来说太基础了,你更应该去看状态机、寻路算法、帧同步这些进阶话题。另外,如果你期待的是那种带图形界面、有音效、有动画的贪吃蛇,这个版本也给不了你——它就是一个字符界面下的极简实现,重点在于逻辑而不是表现。
提示:控制台版本的贪吃蛇有一个天然优势——你不需要处理任何图形API、窗口消息、渲染管线,所有精力都可以放在游戏逻辑本身。这对于建立“游戏是怎么运转的”这个认知非常有帮助。
2. 动手之前先把这几个核心问题想清楚
2.1 蛇到底用什么数据结构来表示
这是整个项目第一个需要做决策的地方。我见过不少初学者直接用两个数组分别存x坐标和y坐标,然后靠下标对应。这种做法在蛇短的时候没问题,但一旦蛇变长、需要增删节点,下标管理就会变得极其痛苦。
更合理的做法是用一个List<Point>,其中Point可以自己定义一个简单的结构体,包含X和Y两个整数。蛇头就是List的第一个元素,蛇尾是最后一个元素。每次移动的时候,在头部插入一个新位置,然后根据是否吃到食物来决定要不要删除尾部。吃到食物就不删,蛇自然变长;没吃到就删掉尾部,蛇的长度保持不变。
struct Point { public int X; public int Y; public Point(int x, int y) { X = x; Y = y; } } List<Point> snake = new List<Point>();为什么用List而不是LinkedList?因为蛇的操作几乎全部集中在头部和尾部,理论上LinkedList的插入删除是O(1),看起来更优。但实际在这个规模下(蛇最长也就几十节),List的Insert(0, ...)和RemoveAt性能完全够用,而且List的遍历和随机访问更方便,代码写起来也更直观。在性能不是瓶颈的时候,优先选代码更简单的方案,这是我一直坚持的原则。
2.2 游戏循环用定时器还是主循环
控制台程序默认是“执行完就退出”的线性流程,但游戏需要持续运行、不断刷新。实现这个“持续运行”有两种常见思路。
第一种是用Thread.Sleep配合while(true)主循环。每一帧先处理输入、再更新逻辑、最后渲染,然后睡个几十毫秒进入下一帧。这种方式的优点是逻辑清晰,所有事情都在一个线程里顺序执行,不用担心并发问题。缺点是Thread.Sleep的精度有限,而且如果某一帧逻辑耗时过长,整个游戏节奏就会被打乱。
第二种是用System.Timers.Timer或者System.Threading.Timer,每隔固定时间触发一次Tick事件,在事件里更新和渲染。这种方式更接近真实游戏引擎的“帧回调”模型,但会引入多线程问题——定时器回调运行在另一个线程上,而控制台的输入读取在主线程上,两者同时操作游戏状态就可能出问题。
对于这个简易版本,我强烈建议用第一种方案:主循环加Thread.Sleep。原因很简单,你不需要处理任何线程同步,不需要加锁,不需要担心竞态条件。代码写起来就是从上到下一溜到底,对初学者最友好。
while (isRunning) { HandleInput(); UpdateGame(); Render(); Thread.Sleep(100); // 控制游戏速度 }这里的Thread.Sleep(100)意味着每秒大约10帧。贪吃蛇不需要60帧的流畅度,10帧足够了,而且蛇的移动速度慢一点反而更好操作。你可以根据实际体验调整这个值,比如改成80或者120,找到自己觉得舒服的节奏。
2.3 方向键怎么读,为什么不能用Console.ReadKey
Console.ReadKey()是一个阻塞方法,调用它会一直等在那里直到用户按键。如果你在主循环里直接调用它,整个游戏就会卡住——蛇不走了,画面也不刷新了,直到你按下一个键才继续。这显然不是我们想要的效果。
正确的做法是用Console.KeyAvailable先判断有没有按键在等待,如果有再调用ReadKey(true)去读取。ReadKey(true)的参数true表示“不把按下的键显示在控制台上”,否则你按一下方向键,控制台上就会多出一个字符,画面会乱掉。
if (Console.KeyAvailable) { ConsoleKeyInfo key = Console.ReadKey(true); // 根据key.KeyCode更新方向 }还有一个细节:方向键的ConsoleKey枚举值是ConsoleKey.UpArrow、ConsoleKey.DownArrow、ConsoleKey.LeftArrow、ConsoleKey.RightArrow。但你不能让蛇直接掉头——比如蛇正在向右走,你按左键,如果直接改变方向,蛇就会咬到自己。所以需要一个“方向缓冲”机制:记录当前方向和待改变的方向,在每一帧更新时再真正应用。
2.4 撞墙和咬自己的判断逻辑
碰撞检测是这个游戏里最容易写错的地方。撞墙的判断很简单:蛇头的X坐标小于0、大于等于窗口宽度、Y坐标小于0、大于等于窗口高度,任何一个成立就游戏结束。
咬自己的判断稍微麻烦一点。你不能简单地检查蛇头坐标是否出现在蛇身列表里,因为蛇头本身就是列表的第一个元素。正确的做法是从列表的第二个元素开始遍历,看是否有和蛇头坐标相同的。
for (int i = 1; i < snake.Count; i++) { if (snake[i].X == head.X && snake[i].Y == head.Y) { isRunning = false; break; } }还有一个容易忽略的点:当蛇的长度只有1或者2的时候,咬自己的判断永远不会触发,这是正常的。但随着蛇变长,这个判断就变得至关重要。我建议在开发阶段故意让蛇快速变长(比如每次吃食物加3节),这样可以更快地测试到咬自己的情况。
3. 从零开始搭建项目骨架
3.1 创建项目与控制台参数设置
打开终端,用dotnet new console -n SnakeGame创建一个新的控制台项目。进入项目目录后,你会看到一个Program.cs文件,这就是我们的主战场。
在写任何逻辑之前,先设置好控制台窗口的基本参数。窗口大小决定了游戏区域的大小,我建议设置成宽40、高20左右,这样既不会太大导致蛇移动太慢,也不会太小导致没走几步就撞墙。
Console.WindowWidth = 40; Console.WindowHeight = 20; Console.CursorVisible = false; // 隐藏光标,避免闪烁 Console.Clear();CursorVisible = false这一行很重要。如果不隐藏光标,你会看到光标在画面上乱跳,非常影响观感。另外,控制台的缓冲区大小和窗口大小是两个概念,WindowWidth和WindowHeight控制的是可见区域,而BufferWidth和BufferHeight控制的是可滚动区域。对于这个项目,我们只需要设置窗口大小就够了。
注意:在某些终端环境下,直接设置
WindowWidth可能会抛出异常,因为窗口大小不能超过缓冲区大小。稳妥的做法是先设置BufferWidth和BufferHeight,再设置WindowWidth和WindowHeight。如果还是报错,可以用try-catch包起来,或者干脆不设置,用默认大小也能玩。
3.2 定义游戏状态与初始化
游戏需要维护几个核心状态:蛇的位置列表、食物的位置、当前移动方向、分数、游戏是否运行中。这些状态可以用静态字段来保存,因为整个程序只有一个游戏实例。
static List<Point> snake = new List<Point>(); static Point food; static Direction currentDirection = Direction.Right; static Direction nextDirection = Direction.Right; static int score = 0; static bool isRunning = true; static Random random = new Random();方向用一个枚举来表示,比用整数更清晰:
enum Direction { Up, Down, Left, Right }初始化的时候,蛇放在画面中央,长度设为3节,方向向右。食物用随机数生成,但要注意不能生成在蛇身上。
static void InitGame() { snake.Clear(); int centerX = Console.WindowWidth / 2; int centerY = Console.WindowHeight / 2; snake.Add(new Point(centerX, centerY)); snake.Add(new Point(centerX - 1, centerY)); snake.Add(new Point(centerX - 2, centerY)); currentDirection = Direction.Right; nextDirection = Direction.Right; score = 0; isRunning = true; GenerateFood(); }GenerateFood方法需要循环随机直到找到一个不在蛇身上的位置。虽然理论上可能无限循环,但实际上蛇占用的格子远少于总格子数,所以平均几次就能找到合适的位置。
static void GenerateFood() { int x, y; do { x = random.Next(0, Console.WindowWidth); y = random.Next(0, Console.WindowHeight); } while (snake.Any(p => p.X == x && p.Y == y)); food = new Point(x, y); }这里用了LINQ的Any方法,如果你还没学到LINQ,可以用一个foreach循环代替,效果是一样的。
3.3 输入处理与方向缓冲
输入处理的核心逻辑是:读取按键,判断是否是方向键,如果是就更新nextDirection,但要防止直接掉头。
static void HandleInput() { if (!Console.KeyAvailable) return; ConsoleKeyInfo key = Console.ReadKey(true); switch (key.KeyCode) { case ConsoleKey.UpArrow: if (currentDirection != Direction.Down) nextDirection = Direction.Up; break; case ConsoleKey.DownArrow: if (currentDirection != Direction.Up) nextDirection = Direction.Down; break; case ConsoleKey.LeftArrow: if (currentDirection != Direction.Right) nextDirection = Direction.Left; break; case ConsoleKey.RightArrow: if (currentDirection != Direction.Left) nextDirection = Direction.Right; break; } }为什么要有currentDirection和nextDirection两个变量?因为在一帧之内,玩家可能快速按下多个方向键。如果直接修改currentDirection,可能会出现“向右走的时候按了上再按左,结果蛇直接向左走咬到自己”的情况。用nextDirection作为缓冲,在每一帧更新时只应用一次方向变化,就能避免这个问题。
4. 核心逻辑的逐行实现
4.1 蛇的移动与食物吞噬判定
移动逻辑是整个游戏的心脏。每一帧,根据当前方向计算新的蛇头位置,然后判断这个位置是否越界、是否撞到自己、是否吃到食物。
static void UpdateGame() { currentDirection = nextDirection; Point head = snake[0]; Point newHead = head; switch (currentDirection) { case Direction.Up: newHead.Y--; break; case Direction.Down: newHead.Y++; break; case Direction.Left: newHead.X--; break; case Direction.Right: newHead.X++; break; } // 撞墙判断 if (newHead.X < 0 || newHead.X >= Console.WindowWidth || newHead.Y < 0 || newHead.Y >= Console.WindowHeight) { isRunning = false; return; } // 咬自己判断 for (int i = 0; i < snake.Count - 1; i++) { if (snake[i].X == newHead.X && snake[i].Y == newHead.Y) { isRunning = false; return; } } snake.Insert(0, newHead); // 吃食物判断 if (newHead.X == food.X && newHead.Y == food.Y) { score += 10; GenerateFood(); } else { snake.RemoveAt(snake.Count - 1); } }注意咬自己判断里的循环条件是i < snake.Count - 1,而不是i < snake.Count。因为蛇尾在这一帧之后就会移走,所以蛇头移动到当前蛇尾的位置是合法的。如果写成i < snake.Count,就会出现“蛇头碰到蛇尾就死”的bug,而实际上蛇尾马上就要让出这个位置了。
这个细节我当初踩过坑。第一次写的时候没注意,测试的时候发现蛇的长度到4节之后,每次转弯都会莫名其妙死掉,排查了半天才发现是蛇尾判断的问题。
4.2 渲染刷新与画面闪烁处理
渲染最简单的方式是每一帧先Console.Clear(),然后重新画出所有元素。但这样做的后果是画面会严重闪烁,因为清屏和重绘之间有一个短暂的空窗期。
解决闪烁有两种方案。第一种是只清除变化的部分,比如蛇尾移走的位置和食物被吃掉的位置,其他保持不变。第二种是用双缓冲,先在内存里构建好整个画面,然后一次性输出。
对于这个简易版本,我推荐一种更简单的做法:把光标移到左上角,然后逐行覆盖输出。因为控制台默认的缓冲区大小和窗口大小一致,你输出的内容会直接覆盖旧内容,不需要清屏。
static void Render() { Console.SetCursorPosition(0, 0); for (int y = 0; y < Console.WindowHeight; y++) { for (int x = 0; x < Console.WindowWidth; x++) { if (snake.Any(p => p.X == x && p.Y == y)) Console.Write("O"); else if (food.X == x && food.Y == y) Console.Write("*"); else Console.Write(" "); } Console.WriteLine(); } Console.WriteLine($"Score: {score}"); }但这样每帧都要遍历整个画面(40x20=800个格子),而且每个格子都要用Any去查蛇列表,效率其实很低。更好的做法是先构建一个二维字符数组,把蛇和食物填进去,然后一次性输出。
static void Render() { char[,] buffer = new char[Console.WindowHeight, Console.WindowWidth]; for (int y = 0; y < Console.WindowHeight; y++) for (int x = 0; x < Console.WindowWidth; x++) buffer[y, x] = ' '; foreach (var p in snake) buffer[p.Y, p.X] = 'O'; buffer[food.Y, food.X] = '*'; Console.SetCursorPosition(0, 0); for (int y = 0; y < Console.WindowHeight; y++) { for (int x = 0; x < Console.WindowWidth; x++) Console.Write(buffer[y, x]); Console.WriteLine(); } Console.WriteLine($"Score: {score}"); }这种“先构建缓冲区再输出”的思路,其实就是最朴素的双缓冲。虽然在这个规模下性能差异微乎其微,但养成这个习惯对以后写更复杂的渲染逻辑很有帮助。
4.3 游戏结束与重新开始
游戏结束后,不应该直接退出程序,而是给玩家一个选择:按R重新开始,按Q退出。这样体验会好很多。
static void GameOver() { Console.SetCursorPosition(0, Console.WindowHeight / 2); Console.WriteLine("Game Over! Press R to restart, Q to quit."); while (true) { var key = Console.ReadKey(true); if (key.KeyCode == ConsoleKey.R) { InitGame(); return; } if (key.KeyCode == ConsoleKey.Q) { Environment.Exit(0); } } }然后在主循环里,当isRunning变为false时调用GameOver(),如果玩家选择重新开始,InitGame()会把所有状态重置,主循环继续运行。
5. 常见问题与排查技巧实录
5.1 蛇移动速度忽快忽慢
这个问题通常是因为Thread.Sleep的精度不够,或者某一帧的逻辑耗时波动太大。Thread.Sleep(100)并不保证精确睡眠100毫秒,操作系统可能会让它睡101毫秒或者105毫秒。对于贪吃蛇来说,这点误差完全可以接受,但如果你发现蛇的移动明显不流畅,可以改用Stopwatch来精确控制帧间隔。
Stopwatch sw = Stopwatch.StartNew(); while (isRunning) { long start = sw.ElapsedMilliseconds; HandleInput(); UpdateGame(); Render(); long elapsed = sw.ElapsedMilliseconds - start; int sleepTime = (int)(100 - elapsed); if (sleepTime > 0) Thread.Sleep(sleepTime); }这样每一帧的总耗时(逻辑+渲染+睡眠)会尽量接近100毫秒,蛇的移动节奏会更稳定。
5.2 按方向键没反应或者反应迟钝
最常见的原因是Console.KeyAvailable在某些终端环境下表现不一致。如果你在Windows Terminal或者VS Code的集成终端里运行,按键检测通常是正常的。但如果你在传统的cmd窗口里运行,有时候需要先点击一下窗口获得焦点,按键才会被捕获。
另一个可能的原因是Thread.Sleep的时间太长,导致按键被“吞掉”。比如你按了一下上键,但那一瞬间主循环正在睡眠,等它醒来的时候KeyAvailable可能已经变成false了。解决方法是缩短睡眠时间,比如从100毫秒改成50毫秒,让输入检测更频繁。
5.3 食物生成在蛇身上导致无法吃到
虽然GenerateFood里有循环检查,但如果蛇的长度非常长(比如占满了大半个屏幕),随机生成可能会循环很多次才找到空位。极端情况下甚至可能死循环。一个更稳妥的做法是:先收集所有空白格子,然后从空白格子里随机选一个。
static void GenerateFood() { List<Point> emptyCells = new List<Point>(); for (int y = 0; y < Console.WindowHeight; y++) for (int x = 0; x < Console.WindowWidth; x++) if (!snake.Any(p => p.X == x && p.Y == y)) emptyCells.Add(new Point(x, y)); if (emptyCells.Count == 0) { isRunning = false; // 蛇占满全屏,通关 return; } food = emptyCells[random.Next(emptyCells.Count)]; }这个方案不仅避免了死循环,还顺便实现了“蛇占满全屏就通关”的逻辑。虽然在这个简易版本里蛇几乎不可能长到占满全屏,但加上这个判断也不费什么事。
5.4 画面撕裂与字符错位
有时候你会看到蛇的身体出现“断裂”或者“重影”,这通常是因为渲染速度跟不上逻辑更新速度,或者控制台的光标位置没有正确重置。确保每一帧渲染前都调用Console.SetCursorPosition(0, 0),并且输出的行数和列数严格等于窗口大小。
另外,如果你在渲染时用了Console.WriteLine输出分数,注意这会占用一行,导致画面整体下移。解决办法是把分数画在画面内部的某个角落,而不是额外占一行。
// 把分数画在右上角 string scoreText = $"Score: {score}"; for (int i = 0; i < scoreText.Length; i++) buffer[0, Console.WindowWidth - scoreText.Length + i] = scoreText[i];5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 蛇移动忽快忽慢 | Thread.Sleep精度不足 | 改用Stopwatch精确控制帧间隔 |
| 按键没反应 | 终端焦点丢失或睡眠时间过长 | 点击窗口获取焦点,缩短睡眠时间 |
| 食物生成死循环 | 蛇太长导致随机碰撞概率高 | 改为从空白格子列表中随机选取 |
| 画面闪烁严重 | 每帧Console.Clear | 改用光标复位+覆盖输出 |
| 蛇头碰到蛇尾就死 | 咬自己判断范围错误 | 循环条件改为i < snake.Count - 1 |
| 方向键导致直接掉头 | 没有方向缓冲机制 | 用nextDirection缓冲,每帧只应用一次 |
| 控制台窗口设置报错 | 窗口大小超过缓冲区 | 先设Buffer再设Window,或用try-catch |
提示:如果你在调试的时候发现蛇的行为不符合预期,最有效的办法是在
UpdateGame里加几行Console.WriteLine输出蛇头坐标和方向,然后单步跟踪。控制台程序的好处就是输出调试信息极其方便,不需要任何额外的工具。
6. 几个让代码更“专业”的小改造
6.1 把游戏逻辑抽成独立的类
目前所有代码都堆在Program.cs里,虽然能跑,但看起来不够清爽。一个自然的改造是把游戏状态和逻辑抽到一个Game类里,Program.cs只负责创建实例和启动主循环。
class Game { private List<Point> snake; private Point food; private Direction currentDirection; private Direction nextDirection; private int score; private bool isRunning; private Random random; public void Run() { Init(); while (isRunning) { HandleInput(); Update(); Render(); Thread.Sleep(100); } GameOver(); } // ... 其他方法 }这样做的好处是状态被封装在类内部,不会到处散落静态字段。而且如果以后想同时运行多个游戏实例(比如做AI训练),只需要创建多个Game对象就行了。
6.2 用ConsoleColor给蛇和食物上色
控制台虽然简陋,但支持前景色和背景色。给蛇头、蛇身、食物分别设置不同的颜色,观感会提升不少。
Console.ForegroundColor = ConsoleColor.Green; Console.Write("O"); Console.ForegroundColor = ConsoleColor.Red; Console.Write("*"); Console.ResetColor();注意每次改变颜色后都要ResetColor(),否则后续的输出都会沿用上一个颜色。另外,频繁切换颜色会影响渲染性能,建议在构建缓冲区的时候同时记录每个格子的颜色,然后一次性输出。
6.3 加入暂停功能
按空格键暂停游戏,再按一次继续。这个功能实现起来很简单,只需要在输入处理里加一个isPaused标志。
if (key.KeyCode == ConsoleKey.Spacebar) { isPaused = !isPaused; }然后在主循环里,如果isPaused为true,就跳过UpdateGame(),只执行Render()和Thread.Sleep。这样画面保持不动,但程序仍在运行,按键仍然能被捕获。
6.4 记录最高分
用一个静态变量保存历史最高分,游戏结束时如果当前分数超过最高分就更新。虽然程序关闭后最高分会丢失,但至少在同一次运行期间,玩家有个追求的目标。
static int highScore = 0; // 游戏结束时 if (score > highScore) highScore = score;如果想持久化保存,可以把最高分写到一个文本文件里,启动时读取,结束时写入。这就涉及到文件IO操作,算是给这个项目增加一点额外的练习。
7. 从这个小项目里能带走什么
写完这个贪吃蛇,你实际上已经接触到了游戏开发中最核心的几个概念:游戏循环、状态管理、输入处理、碰撞检测、渲染刷新。这些概念在Unity、Godot、甚至自研引擎里都是通用的,只是表现形式不同而已。
更重要的是,你体验了一个完整的“从零到能跑”的过程。这个过程里你会遇到各种小问题——蛇走得太快、按键没反应、画面闪烁、食物生成在蛇身上——每一个问题的排查和解决,都比单纯看教程收获大得多。
我个人的习惯是,每学一门新语言或者新框架,都先用它写一个贪吃蛇。因为贪吃蛇的规则足够简单,不会让你在业务逻辑上分心,同时它又足够完整,能让你把语言的基础特性都用一遍。C#版本的贪吃蛇写完之后,你可以试着用同样的思路去写俄罗斯方块、扫雷、2048,你会发现很多逻辑是相通的。
最后分享一个我常用的调试技巧:在UpdateGame的开头加一行Console.Title = $"Dir:{currentDirection} Head:{snake[0].X},{snake[0].Y} Score:{score}",这样窗口标题栏会实时显示当前状态,不用打断游戏就能看到关键数据。这个技巧在调试碰撞检测和方向逻辑的时候特别管用。