# 前言

最近学习了 C++ STL, 想着自己使用 C++ 实现一个贪吃蛇的小游戏,锻炼一下 C++ 代码能力和软件工程能力。完整代码参见:贪吃蛇小游戏 ——C++ 实现

# 贪吃蛇的基本规则

首先,有一块场地,场地周围是墙,场地内的部分是墙,部分是食物,部分是蛇。贪吃蛇没吃到任何东西,就正常移动;吃到食物,尾巴会增长;如果吃到了自己的尾巴或者撞了墙,游戏结束。

# 游戏基本设定

  1. 场地内部墙的个数在 [1,min (height-2,width-2)] 之间
  2. 1 次只出现一个食物
  3. 用户输入场地规格必须大于 3
  4. 默认蛇的移动方向向右,初始位置随机生成
  5. 如果贪吃蛇吃到了食物,会在新的地方生成食物
  6. 因为本人使用 vscode 编程,无法弹出控制台,因此无法追踪光标位置,也就难以实现原地打印。因此,本程序中使用每 1s 打印一次棋盘的方式.

# 贪吃蛇对象划分

  • 场地:周围用环绕,里面有部分墙,食物
  • 游戏本身
    • 游戏分数:贪吃蛇吃到的食物数量
    • 游戏状态:用 true / false 表示是否结束

# 贪吃蛇对象设计

  • 场地:
    • 场地本身:使用一个二维数组表示,场地内的墙用 # 表示,蛇用 S 表示,食物用 $ 表示,普通平地用 `` 表示。某个单位只能出现一个物种,不可能同时出现两种。
    • 场地的长、宽:用 int 类型表示
    • 蛇:使用一个结构体进行存储,其中几个字段分别为:
      • 整条蛇的位置信息:使用一个 std::deque 存储,其中单个位置信息使用 std::pair<int,int> 来存储
      • 蛇的运动方向:使用 int 存储 (使用一个枚举类型 INPUT_KEY ,来表示对应的 int 值)
  • 游戏:使用一个结构体进行存储,其中几个字段分别为:
    • 游戏分数:使用 int 类型存储
    • 游戏状态:使用 true/false 表示是否结束
    • 用户的名字

# 游戏主逻辑设计

  1. 提示用户输入游戏场地规格:长,宽 / 默认(不输入)
  2. 提示用户输入姓名
  3. 初始化游戏场地
  4. 初始化游戏分数和状态
  5. 初始化默认开始方向
  6. while 游戏没有结束
    1. 获取用户方向输入
    2. 如果没有输入方向,则按照上次的方向继续
    3. 根据用户输入方向更新游戏状态
    4. 打印棋盘
  7. 通知用户游戏结束,打印其姓名和分数,清理程序中所有的值

# 游戏状态更新逻辑

1. 根据蛇头,计算下一个位置的坐标
2. 如果场地中下个位置的值是`S`,判断其是否为蛇尾,如果不是蛇尾或者是墙,游戏结束
3. 如果下个位置是`$`,则(不去掉蛇尾):
   1. 游戏分数+1
4. 否则:
   1. 将场地蛇尾处`S`修改为' '
5. 将下一个位置加载蛇头
6. 将蛇头处' '修改为`S`

# 游戏对象定义 ( game_init.h )

#

#include <deque>
// define the user input
enum INPUT_KEY
{
    RIGHT = 77,
    LEFT = 75,
    UP = 72,
    DOWN = 80,
};
// define the snake
typedef struct
{
    INPUT_KEY direction;
    std::deque<std::pair<int, int>> position;
} snake;

# 游戏

// define the game
typedef struct
{
    int score;
    bool end;
    char *name;
} game;

# 场地

// define the board
typedef struct
{
    char **arr;
    int width;
    int height;
    snake s;
} board;

# 游戏对象初始化( game_init.c

# 游戏初始化

/**
 * @brief Initialize the game
 *
 * @param g the game struct
 * @param name the name of the user
 */
void init_game(game &g, char *name)
{
    g.end = false;
    g.name = name;
    g.score = 0;
}

# 场地初始化

# 初始化内容

场地的初始化包括初始化如下几个部分:

  • 场地:
  • 场地本身:使用一个二维数组表示,场地内的墙用 # 表示,蛇用 S 表示,食物用 $ 表示,普通平地用 `` 表示。某个单位只能出现一个物种,不可能同时出现两种。
  • 场地的长、宽:用 int 类型表示
  • 蛇:使用一个结构体进行存储,其中几个字段分别为:
    • 整条蛇的位置信息:使用一个 std::deque 存储,其中单个位置信息使用 std::pair<int,int> 来存储
    • 蛇的运动方向:使用 int 存储 (使用一个枚举类型 INPUT_KEY ,来表示对应的 int 值)

# 初始化步骤

初始化的过程分为如下步骤:

  1. 初始化场地的长和宽
  2. 初始化一个对应长和宽的二维数组,且其值为 ``
  3. 让二维数组的四周为 #
  4. 使用随机数生成器生成一个 [1,min (height-2,width-2)] 内随机的内部墙数量
  5. 随机生成内部墙的坐标
  6. 在对应位置处放置 #
  7. 随机生成食物的坐标
  8. 在对应位置处放置 $
  9. 随机生成蛇的坐标
  10. 初始化蛇,方向向右
  11. 在对应位置处放置 S

# 辅助函数 1:随机数生成器

C++ std::rand() 函数生成一个 [0, RAND_MAX ) 之间的数字。如果我们想生成一个 [0,num] 范围内的数字,需要 (1 + std::rand()) % num 。如果 num<=0 ,我们不生成,直接返回 0。

/**
 * @brief generate a random number between [1,num]
 *
 * @param num the maximum of the random number
 * @return int the random number generated
 */
int random(int num)
{
    if (num <= 0)
    {
        return 0;
    }
    return (1 + std::rand()) % num + 1;
}

# 辅助函数 2: place 函数

foodsnake 一次只放置 1 个。 food 在初始化和每次被蛇吃掉后都需要重新放置(后期 update )中还需要调用,而 snake 只有在初始化时候需要放置。因为其每次都是持续搜索,直到找到标记为 `` 的位置,因此我们写了一个 place 函数用于放置内容。该函数返回一个 pair ,用于 snake 初始化时将坐标压到 deque 中。

/**
 * @brief the generic method to place some char in the board
 *
 * @param b board
 * @param p the character to be placed
 */
std::pair<int, int> place(board &b, char p)
{
    int xPos_max = b.height - 2;
    int yPos_max = b.width - 2;
    int x = random(xPos_max);
    int y = random(yPos_max);
    if (b.arr[x][y] != ' ')
    {
        return place(b, p);
    }
    else
    {
        b.arr[x][y] = p;
        return std::make_pair(x, y);
    }
}

# 辅助函数 3: place_food & place_snake

place_food 中,我们直接调用 place 函数,将传入的 char 设定为 $ ;而在 place_snake 中,除了需要放置食物外,还需要将 place 函数返回的坐标压到 deque 中,并且初始化方向。

/**
 * @brief initialize the snake in the board
 *
 * @param b the game board
 */
void place_snake(board &b)
{
    std::pair<int, int> snake_pos = place(b, 'S');
    b.s.position.push_back(snake_pos);
    b.s.direction = RIGHT;
}

# 辅助函数 4: print_board

/**
 * @brief print the board of the game
 *
 * @param b the game board
 */
void print_board(board &b)
{
    int head_x = b.s.position[0].first;
    int head_y = b.s.position[0].second;
    for (int i = 0; i < b.height; i++)
    {
        for (int j = 0; j < b.width; j++)
        {
            if(i == head_x && j == head_y) {
                std::cout << "\033[0;31m" << b.arr[i][j] << "\033[0m" << " ";
            }
            else{
                std::cout << b.arr[i][j] << " ";
            }
        }
        std::cout << std::endl;
    }
}

# 初始化 board

该步骤遵循前文初始化步骤。

/**
 * @brief Initialize the board
 *
 * @param b the board struct
 * @param width the width of the board
 * @param height the height of the board
 */
void init_board(board &b, int width, int height)
{
    // init the width and height
    b.width = width;
    b.height = height;
    // init the board string and fill it with ` `
    b.arr = new char *[height];
    for (int i = 0; i < height; i++)
    {
        b.arr[i] = new char[width];
        std::fill(b.arr[i][0], b.arr[i][width - 1], ' ');
    }
    // init the wall around the board
    std::fill(b.arr[0][0], b.arr[0][width - 1], '#');
    for (int i = 1; i < height - 1; i++)
    {
        b.arr[i][0] = '#';
        b.arr[i][width - 1] = '#';
    }
    std::fill(b.arr[height - 1][0], b.arr[height - 1][width - 1], '#');
    // calculate the scope of xPos and yPos
    int xPos_max = height - 2;
    int yPos_max = width - 2;
    // init the wall in inside the board
    int wall_num = random(std::min(height - 2, width - 2));
    for (int i = 0; i < wall_num; i++)
    {
        int x = random(xPos_max);
        int y = random(yPos_max);
        b.arr[x][y] = '#';
    }
    // init the food
    place_food(b);
    // init the snake
    place_snake(b);
}

# 游戏逻辑实现

  1. 提示用户输入游戏场地规格:长,宽 / 默认(不输入), 如果长或宽小于 3, 则提示用户重新输入
  2. 提示用户输入姓名
  3. 初始化游戏场地
  4. 初始化游戏分数和状态
  5. 初始化默认开始方向
  6. while 游戏没有结束:
  7. 获取用户方向输入
  8. 如果没有输入方向,则按照上次的方向继续
  9. 根据用户输入方向更新游戏状态
  10. 通知用户游戏结束,打印其姓名和分数,清理程序中所有的值
// the game loop
    while (!g.end)
    {
        if (_kbhit())
        {
            _getch();
            int key = _getch();
            b.s.direction = static_cast<INPUT_KEY>(key);
        }
        update(g, b);
        print_board(b);
        sleep(1);
    }

# 游戏结束

// end the game
  std::cout << "Game Over!" << std::endl;
  std::cout << "Your Score is: " << g.score << std::endl;
  // clear the game state
  delete[] name;
  for (int i = 0; i < b.height; i++)
  {
      delete[] b.arr[i];
  }
  delete[] b.arr;

# update 函数更新游戏状态

# 步骤

1. 根据蛇头,计算下一个位置的坐标
2. 如果场地中下个位置的值是`S`,判断其是否为蛇尾,如果不是蛇尾或者是墙,游戏结束
3. 如果下个位置是`$`,则(不去掉蛇尾):
   1. 游戏分数+1
4. 否则:
   1. 将场地蛇尾处`S`修改为' '
5. 将下一个位置加载蛇头
6. 将蛇头处' '修改为`S`

# update 函数

void update(game &g, board &b)
{
    // get the current snake head
    std::pair<int, int> head = b.s.position[0];
    int x = head.first;
    int y = head.second;
    // get the snake tail
    std::pair<int, int> tail = b.s.position.back();
    int tail_x = tail.first;
    int tail_y = tail.second;
    // obtain the next position
    switch (b.s.direction)
    {
    case UP:
        x -= 1;
        break;
    case DOWN:
        x += 1;
        break;
    case RIGHT:
        y += 1;
        break;
    case LEFT:
        y -= 1;
        break;
    default:
        break;
    }
    // if the next position is '#' or snake body, exit the game
    if (b.arr[x][y] == '#' || (b.arr[x][y] == 'S' && x != tail_x && y != tail_y))
    {
        g.end = true;
        return;
    }
    // if the next poistion is food
    if (b.arr[x][y] == '$')
    {
        g.score += 1;
    }
    else
    {
        b.arr[tail_x][tail_y] = ' ';
        b.s.position.pop_back();
    }
    b.s.position.push_front(std::pair<int, int>(x, y));
    b.arr[x][y] = 'S';
}

# bug 修复

  1. random() 随机数产生需要在后面 +1
  2. board 新增 plain 字段,让蛇占据所有空间后,通知用户通关!
  3. 长度为 1 时,可以反方向运动