55、实战:贪吃蛇游戏(控制台+方向控制)
贪吃蛇,这大概是每个程序员都绕不开的「入门级实战」。我记得自己刚学C++那会儿,照着书敲了一个控制台版的贪吃蛇,结果蛇不会拐弯,直接穿墙跑了……嗯,调试了一下午才发现是方向锁定的逻辑写反了。
今天咱们就来手写一个完整的控制台贪吃蛇。别小看它,这里面涉及了数据结构、输入处理、游戏循环、碰撞检测这些核心知识点。说白了,它就是一个小而全的「游戏引擎雏形」。
- 双缓冲控制台渲染
- 方向输入的非阻塞处理
- 蛇身的数据结构设计(链表/队列)
- 碰撞检测与游戏状态管理
整体架构设计
先别急着写代码。我习惯在动手前先画一张图,把模块之间的关系理清楚。贪吃蛇虽然简单,但模块划分不好,后面加功能会非常痛苦。
你看,整个游戏就三个核心模块:输入处理、逻辑更新、渲染输出。主循环每帧依次调用它们。这个架构其实跟大型游戏引擎的思路是一样的,只不过咱们简化到了极致。
蛇身数据结构:为什么选 deque?
蛇身怎么存?很多人第一反应是用数组。但你想啊,蛇每走一步,尾巴要删掉,头要加一个。这明显是个「先进先出」的队列操作。
我推荐用 std::deque。为什么不用 std::queue?因为咱们不仅要操作头尾,有时候还需要遍历整个蛇身来做碰撞检测。deque 支持随机访问,遍历起来方便。
#include <deque>
#include <utility> // for std::pair
// 用 pair 表示坐标 (x, y)
using Position = std::pair<int, int>;
class Snake {
private:
std::deque<Position> body; // 蛇身,front是尾巴,back是头
Direction dir; // 当前方向
Direction nextDir; // 下一帧要转向的方向(防抖)
public:
Snake(int startX, int startY) {
// 初始长度为3,水平放置
body.push_back({startX, startY});
body.push_back({startX - 1, startY});
body.push_back({startX - 2, startY});
dir = Direction::RIGHT;
nextDir = Direction::RIGHT;
}
void setDirection(Direction d) {
// 防止180度掉头
if (d != opposite(dir)) {
nextDir = d;
}
}
bool move(bool grow) {
dir = nextDir;
// 计算新头部位置
Position head = body.back();
Position newHead = head;
switch (dir) {
case UP: newHead.second--; break;
case DOWN: newHead.second++; break;
case LEFT: newHead.first--; break;
case RIGHT: newHead.first++; break;
}
// 插入新头
body.push_back(newHead);
// 如果不增长,就删掉尾巴
if (!grow) {
body.pop_front();
}
// 检查是否撞到自己
return !selfCollision();
}
private:
bool selfCollision() {
Position head = body.back();
// 检查头部是否与身体其他部分重叠(除了尾巴,因为尾巴刚被删掉或即将被删掉)
for (size_t i = 0; i < body.size() - 1; ++i) {
if (body[i] == head) return true;
}
return false;
}
};
非阻塞输入:让蛇动起来
控制台程序默认是阻塞的——你按一个键它才往下走。但贪吃蛇需要「蛇自己动,你随时可以按方向键改变方向」。这就需要非阻塞输入。
Windows 下用 _kbhit() 和 _getch(),Linux 下需要设置终端为非规范模式。这里我以 Windows 为例:
#include <conio.h> // _kbhit, _getch
void processInput(Snake& snake) {
if (_kbhit()) { // 有按键按下
int ch = _getch();
switch (ch) {
case 72: snake.setDirection(UP); break; // 上箭头
case 80: snake.setDirection(DOWN); break; // 下箭头
case 75: snake.setDirection(LEFT); break; // 左箭头
case 77: snake.setDirection(RIGHT); break; // 右箭头
}
}
}
注意,_getch() 返回的是扩展码,方向键需要两次调用。第一次返回 224(或 0),第二次才是真正的键值。上面的代码简化了,实际使用时需要处理这个细节。
双缓冲渲染:告别屏幕闪烁
直接往控制台打印,屏幕会疯狂闪烁。为什么?因为你每次清屏再重绘,中间有个「空白期」。解决方案是双缓冲:先在内存里画好,然后一次性输出。
#include <windows.h> // SetConsoleCursorPosition
class Renderer {
private:
HANDLE hConsole;
CHAR_INFO* buffer;
int width, height;
public:
Renderer(int w, int h) : width(w), height(h) {
hConsole = GetStdHandle(STD_OUTPUT_HANDLE);
buffer = new CHAR_INFO[w * h];
}
~Renderer() { delete[] buffer; }
void clear() {
for (int i = 0; i < width * height; ++i) {
buffer[i].Char.AsciiChar = ' ';
buffer[i].Attributes = 7; // 灰色
}
}
void draw(int x, int y, char ch, int color = 7) {
if (x >= 0 && x < width && y >= 0 && y < height) {
int idx = y * width + x;
buffer[idx].Char.AsciiChar = ch;
buffer[idx].Attributes = color;
}
}
void flush() {
COORD bufferSize = {width, height};
COORD bufferCoord = {0, 0};
SMALL_RECT writeRegion = {0, 0, width - 1, height - 1};
WriteConsoleOutput(hConsole, buffer, bufferSize, bufferCoord, &writeRegion);
}
};
游戏主循环:把一切串起来
有了上面的模块,主循环就清晰了:
int main() {
const int WIDTH = 40;
const int HEIGHT = 20;
Snake snake(WIDTH / 2, HEIGHT / 2);
Food food(WIDTH, HEIGHT);
Renderer renderer(WIDTH, HEIGHT);
bool gameOver = false;
int score = 0;
while (!gameOver) {
// 1. 输入处理
processInput(snake);
// 2. 逻辑更新
bool grow = snake.getHead() == food.getPosition();
if (grow) {
score++;
food.respawn(WIDTH, HEIGHT);
}
gameOver = !snake.move(grow);
// 3. 边界碰撞
Position head = snake.getHead();
if (head.first < 0 || head.first >= WIDTH ||
head.second < 0 || head.second >= HEIGHT) {
gameOver = true;
}
// 4. 渲染
renderer.clear();
// 画蛇
for (auto& p : snake.getBody()) {
renderer.draw(p.first, p.second, 'O', 10);
}
// 画食物
Position fp = food.getPosition();
renderer.draw(fp.first, fp.second, '*', 12);
// 画边界
for (int x = 0; x < WIDTH; ++x) {
renderer.draw(x, 0, '#', 8);
renderer.draw(x, HEIGHT - 1, '#', 8);
}
for (int y = 0; y < HEIGHT; ++y) {
renderer.draw(0, y, '#', 8);
renderer.draw(WIDTH - 1, y, '#', 8);
}
renderer.flush();
// 控制帧率
Sleep(100); // 100ms = 10 FPS
}
// 显示 Game Over
std::cout << "Game Over! Score: " << score << std::endl;
return 0;
}
避坑指南:我踩过的几个雷
- 方向反转:我曾经在 setDirection 里忘了检查 opposite,结果蛇一按左就掉头撞死。调试了半小时才发现。
- 食物生成在蛇身上:随机生成食物时,一定要检查是否与蛇身重叠。否则食物长在蛇肚子里,永远吃不到。
- 帧率与速度:Sleep(100) 是固定的。但蛇越长,应该越快。我习惯用
max(50, 200 - score * 2)这样的公式动态调整。 - 控制台光标闪烁:用
SetConsoleCursorInfo隐藏光标,否则画面会一闪一闪的。
扩展思路:还能怎么玩?
基础版写完了,你可以试试这些改进:
- 增加「穿墙模式」:蛇从一边出去,从另一边进来
- 增加「障碍物」:随着分数增加,地图上随机生成障碍
- 增加「加速道具」:吃了之后蛇加速跑几秒
- 保存最高分:用文件读写记录历史最高分
贪吃蛇虽然简单,但它是理解「游戏循环 + 状态机」的最佳入门项目。你把它吃透了,后面做俄罗斯方块、推箱子都会轻松很多。
总结一下:
- 用 deque 存蛇身,方便头尾操作和遍历
- 非阻塞输入用 _kbhit(),注意方向防抖
- 双缓冲渲染解决闪烁问题
- 主循环 = 输入 → 逻辑 → 渲染,循环往复
好了,代码都在上面了。你可以直接复制到 VS 里跑起来试试。如果遇到编译问题,多半是 Windows 头文件没包含。嗯,动手试试吧,有问题随时翻回来看。