55、实战:贪吃蛇游戏(控制台+方向控制)

贪吃蛇,这大概是每个程序员都绕不开的「入门级实战」。我记得自己刚学C++那会儿,照着书敲了一个控制台版的贪吃蛇,结果蛇不会拐弯,直接穿墙跑了……嗯,调试了一下午才发现是方向锁定的逻辑写反了。

今天咱们就来手写一个完整的控制台贪吃蛇。别小看它,这里面涉及了数据结构、输入处理、游戏循环、碰撞检测这些核心知识点。说白了,它就是一个小而全的「游戏引擎雏形」。

核心知识点:
  • 双缓冲控制台渲染
  • 方向输入的非阻塞处理
  • 蛇身的数据结构设计(链表/队列)
  • 碰撞检测与游戏状态管理

整体架构设计

先别急着写代码。我习惯在动手前先画一张图,把模块之间的关系理清楚。贪吃蛇虽然简单,但模块划分不好,后面加功能会非常痛苦。

贪吃蛇游戏模块架构图 游戏主循环 输入处理模块 逻辑更新模块 渲染输出模块 方向键检测 蛇身移动算法 食物生成逻辑 控制台绘制 数据流方向 键盘输入 → 方向状态 → 蛇身坐标更新 → 碰撞检测 → 屏幕渲染 每帧循环:输入 → 逻辑 → 渲染,帧率由 Sleep() 控制

你看,整个游戏就三个核心模块:输入处理逻辑更新渲染输出。主循环每帧依次调用它们。这个架构其实跟大型游戏引擎的思路是一样的,只不过咱们简化到了极致。

蛇身数据结构:为什么选 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;
    }
};
小技巧:方向防抖处理很重要。如果你不缓存 nextDir,而是直接修改 dir,那么在一帧内用户按了两次方向键,蛇可能会直接掉头撞死自己。我当年就踩过这个坑。

非阻塞输入:让蛇动起来

控制台程序默认是阻塞的——你按一个键它才往下走。但贪吃蛇需要「蛇自己动,你随时可以按方向键改变方向」。这就需要非阻塞输入。

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);
    }
};
注意:WriteConsoleOutput 是 Windows 特有的 API。如果你要跨平台,可以用 ncurses 库。不过咱们这个课程聚焦 C++ 标准,所以平台相关代码我会标注清楚。

游戏主循环:把一切串起来

有了上面的模块,主循环就清晰了:

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 头文件没包含。嗯,动手试试吧,有问题随时翻回来看。