9、C++项目实战:贪吃蛇游戏(控制台版)

贪吃蛇,这玩意儿几乎是每个程序员的入门必修课。我当年在学校机房,用Turbo C敲出第一条能动的蛇时,那种兴奋感至今还记得。说白了,这个项目虽然小,但五脏俱全——你得处理输入、管理状态、控制帧率,还得防着自己把自己绕死。

今天咱们就用C++,在控制台里把这个经典游戏复现一遍。我会把踩过的坑、用过的技巧都抖出来。

核心设计思路

先别急着写代码。你想想看,贪吃蛇最核心的是什么?

  • 蛇身:一串坐标点的集合,会动会变长
  • 食物:随机出现,吃了就长
  • 地图:边界撞上就死
  • 方向控制:WASD或者方向键
  • 游戏循环:不断刷新、检测、渲染

我个人习惯把数据结构和逻辑分开。蛇身用双端队列(deque)最合适——头插尾删,完美模拟移动。

关键点:蛇的移动不是每个格子都动,而是“头前进一格,尾删除一格”。吃到食物时,尾不删,长度就+1了。

数据结构设计

先定义几个基础类型:

struct Point {
    int x, y;
    Point(int x = 0, int y = 0) : x(x), y(y) {}
    bool operator==(const Point& other) const {
        return x == other.x && y == other.y;
    }
};

enum class Direction { UP, DOWN, LEFT, RIGHT };
enum class GameState { RUNNING, PAUSED, GAME_OVER };

蛇身我用deque:

std::deque<Point> snake;
Point food;
Direction dir;
GameState state;
int score;

嗯,这里要注意:deque支持头尾快速插入删除,正好符合蛇的移动逻辑。我早期用vector写过,每次移动都要搬移数据,效率差不说,代码还丑。

游戏循环——心脏

控制台游戏的核心就是一个死循环,不断做三件事:

  1. 处理输入:检测键盘,改变方向
  2. 更新逻辑:移动蛇、检测碰撞、吃食物
  3. 渲染画面:清屏、画地图、画蛇、画食物
while (state == GameState::RUNNING) {
    handleInput();
    update();
    render();
    Sleep(100); // 控制速度
}

我曾经犯过一个低级错误——把Sleep放在handleInput前面,结果方向响应延迟严重,蛇都撞墙了键盘还没反应。后来改成先处理输入再更新,手感立马好了。

移动逻辑——别把自己绕死

蛇的移动分三步:

void update() {
    Point head = snake.front();
    Point next = head;

    switch (dir) {
        case Direction::UP:    next.y--; break;
        case Direction::DOWN:  next.y++; break;
        case Direction::LEFT:  next.x--; break;
        case Direction::RIGHT: next.x++; break;
    }

    // 检测是否吃到食物
    if (next == food) {
        snake.push_front(next);  // 头前进,尾不动
        score += 10;
        spawnFood();
    } else {
        snake.push_front(next);  // 头前进
        snake.pop_back();        // 尾删除
    }

    // 碰撞检测
    checkCollision();
}

这里有个坑:你不能让蛇直接掉头。比如当前方向是UP,你按DOWN,蛇头会直接撞到自己身体。我一般加个方向锁:

void handleInput() {
    if (_kbhit()) {
        char ch = _getch();
        Direction newDir = dir;
        switch (ch) {
            case 'w': case 'W': newDir = Direction::UP; break;
            case 's': case 'S': newDir = Direction::DOWN; break;
            case 'a': case 'A': newDir = Direction::LEFT; break;
            case 'd': case 'D': newDir = Direction::RIGHT; break;
        }
        // 禁止反向
        if ((dir == Direction::UP && newDir != Direction::DOWN) ||
            (dir == Direction::DOWN && newDir != Direction::UP) ||
            (dir == Direction::LEFT && newDir != Direction::RIGHT) ||
            (dir == Direction::RIGHT && newDir != Direction::LEFT)) {
            dir = newDir;
        }
    }
}

注意:_kbhit()和_getch()是Windows下的控制台函数。如果你在Linux/macOS上编译,需要用termios库替代。跨平台的话,建议封装一层输入接口。

碰撞检测——生死一线

碰撞分两种:撞墙和撞自己。

void checkCollision() {
    Point head = snake.front();

    // 撞墙
    if (head.x < 0 || head.x >= WIDTH || 
        head.y < 0 || head.y >= HEIGHT) {
        state = GameState::GAME_OVER;
        return;
    }

    // 撞自己(从第2个节点开始检查)
    for (size_t i = 1; i < snake.size(); ++i) {
        if (head == snake[i]) {
            state = GameState::GAME_OVER;
            return;
        }
    }
}

为什么从第2个节点开始?因为蛇头就是第一个节点,自己跟自己比肯定相等。我刚开始写的时候忘了这个,蛇一出生就game over,调试了半天才发现。

渲染——控制台也能好看

控制台渲染说白了就是打印字符。我用二维数组表示地图:

void render() {
    system("cls"); // 清屏

    // 初始化地图
    char map[HEIGHT][WIDTH];
    memset(map, ' ', sizeof(map));

    // 画边界
    for (int i = 0; i < WIDTH; ++i) {
        map[0][i] = '#';
        map[HEIGHT-1][i] = '#';
    }
    for (int i = 0; i < HEIGHT; ++i) {
        map[i][0] = '#';
        map[i][WIDTH-1] = '#';
    }

    // 画蛇
    for (auto& p : snake) {
        map[p.y][p.x] = 'O';
    }
    // 蛇头特殊标记
    map[snake.front().y][snake.front().x] = '@';

    // 画食物
    map[food.y][food.x] = '$';

    // 打印
    for (int y = 0; y < HEIGHT; ++y) {
        for (int x = 0; x < WIDTH; ++x) {
            std::cout << map[y][x];
        }
        std::cout << '\n';
    }

    std::cout << "Score: " << score << '\n';
}

小技巧:用system("cls")清屏虽然方便,但会闪烁。如果你追求流畅,可以用SetConsoleCursorPosition直接定位光标重绘,不过代码量会大一些。我一般教学用cls,够用了。

食物生成——别生到蛇身上

食物不能出现在蛇身上,这个逻辑要写清楚:

void spawnFood() {
    do {
        food.x = rand() % (WIDTH - 2) + 1;
        food.y = rand() % (HEIGHT - 2) + 1;
    } while (std::find(snake.begin(), snake.end(), food) != snake.end());
}

随机数记得先srand(time(NULL))初始化。我见过有人忘了这步,结果每次启动游戏食物都在同一个位置,那还玩个啥。

完整项目结构

我把代码组织成三个文件,这样清晰:

  • SnakeGame.h:声明类、枚举、常量
  • SnakeGame.cpp:实现所有逻辑
  • main.cpp:入口,创建游戏对象并启动

类的大概样子:

class SnakeGame {
public:
    SnakeGame(int width, int height);
    void run();
private:
    void handleInput();
    void update();
    void render();
    void checkCollision();
    void spawnFood();

    std::deque<Point> snake;
    Point food;
    Direction dir;
    GameState state;
    int score;
    int width, height;
};

SVG流程图:贪吃蛇核心逻辑

贪吃蛇游戏核心逻辑流程图 游戏启动 初始化地图、蛇、食物 游戏循环(while) 处理键盘输入 更新蛇位置 & 碰撞检测 渲染画面

避坑指南

我总结几个新手容易踩的坑:

  • 方向反转:上面说过了,加个方向锁就行
  • 蛇身重叠:移动时先加头再删尾,顺序别反。反了的话,蛇头会吃到刚删掉的尾
  • 食物刷在蛇身上:用do-while循环保证位置合法
  • 控制台闪烁:如果介意,用双缓冲或者直接操作控制台缓冲区
  • 速度控制:Sleep的时间不要固定,可以随着分数增加逐渐减少,游戏会越来越刺激

扩展思路:做完基础版后,你可以加些花样——穿墙模式、加速道具、障碍物、排行榜。我有个学生加了“毒食物”,吃了会缩短蛇身,结果成了全班最受欢迎的游戏。

好了,贪吃蛇的核心就这些。代码量不大,但每个环节都考验你对C++基础的理解。动手写一遍,比看十遍都管用。


公众号:蓝海资料掘金营,微信deep3321