Skip to content

项目 1: snek

欢迎来到 61C 的第一个项目!在这个项目中,你将通过创建一个可玩的贪吃蛇游戏来练习 C 语言编码。如果你对贪吃蛇不熟悉,可以从这个链接试玩演示

注意

在开始此项目之前,请确保你已完成实验 0 的设置。

实验 1-2 是项目 1 的必修课。建议使用第 2-4 讲和第 5 讲的内存管理/布局部分。建议参加讨论 1 和 2。

我们将要求所有学生在提交项目 1 之前证明他们尝试过调试。如果没有尝试调试,或者很明显你不了解如何调试,我们将要求您完成实验 2 后再重新提交。

一些通用的测试和调试技巧可在编译、测试和调试以及常见错误页面中找到。

项目概述

贪吃蛇

贪吃蛇游戏可以用由字符组成的网格来表示。网格包含墙、果实和一条或多条蛇。下面是一个游戏示例:

##############
#            #
#    dv      #
#     v   #  #
#     v   #  #
#   s >>D #  #
#   v     #  #
# *A<  *  #  #
#            #
##############

网格包含以下特殊字符:

  • # 表示墙。
  • ` `(空格字符)表示空白空间。
  • * 表示果实。
  • wasd 表示蛇的尾部。
  • ^<v> 表示蛇的身体。
  • WASD 表示蛇的头部。
  • x 表示已死亡的蛇头。

每个字符告诉你蛇当前移动的方向:

  • wW^ 表示向上。
  • aA< 表示向左。
  • sSv 表示向下。
  • dD> 表示向右。

在每个时间步,蛇按以下规则移动:

  • 每条蛇沿头部指示的方向移动一步。
  • 如果头部撞到蛇身或墙壁,则该蛇死亡并停止移动,死亡时头部字符变为 x
  • 如果头部移动到果实位置,蛇会吃掉果实,长度增加 1;每次吃掉果实后,会在棋盘上生成一个新的果实。

在上例中,经过一个时间步后,棋盘会变为:

##############
#         *  #
#     s      #
#     v   #  #
#     v   #  #
#   s >>>D#  #
#   v     #  #
# A<<  *  #  #
#            #
##############

再经过一个时间步后,棋盘会变为:

##############
#         *  #
#     s      #
#     v   #  #
#     v   #  #
#     >>>x#  #
#   s     #  #
#A<<<  *  #  #
#            #
##############

蛇的长度至少保证为三个单位。

给蛇编号

每条蛇根据其尾部在文件中出现的顺序进行编号(先按行从上到下,再按列从左到右)。例如,考虑下列包含四条蛇的棋盘:

#############
#  s  d>>D  #
#  v   A<a  #
#  S    W   #
#       ^   #
#       w   #
#############

尾部为 s 的蛇是蛇 0,尾部为 d 的蛇是蛇 1,尾部为 a 的蛇是蛇 2,尾部为 w 的蛇是蛇 3。 蛇在初始位置编号后,整个游戏过程中蛇的编号保持不变。

游戏棋盘

游戏棋盘是一个字符网格,不一定是矩形。下面是一个非矩形棋盘的示例:

##############
#            #######
#####             ##
#   #             ##
#####             ######
#                 ##   #
#                 ######
#                 ##
#                  #
#      #####       #
########   #########

请注意:

  • 每行可以有不同数量的字符,但都会以 # 开头和结尾。
  • 棋盘是一个封闭空间,因此蛇无法无限制地向任意方向移动。

game_t 结构体

贪吃蛇游戏在内存中存储为 game_t 结构体,该结构体在 game.h 中定义。结构体包含以下字段:

  • unsigned int num_rows: 游戏棋盘的行数。
  • char** board: 内存中的游戏棋盘。board 数组的每个元素都是指向字符数组的 char*,该数组包含一行棋盘。每行必须以换行符结尾,且是一个合法的字符串。
  • unsigned int num_snakes: 棋盘上的蛇的数量。
  • snake_t* snakes: snake_t 结构体数组。

snake_t 结构体

同样在 game.h 中定义,每个 snake_t 结构体包含以下字段:

  • unsigned int tail_row: 蛇尾所在行。
  • unsigned int tail_col: 蛇尾所在列。
  • unsigned int head_row: 蛇头所在行。
  • unsigned int head_col: 蛇头所在列。
  • bool live: 蛇是否存活,true 表示存活,false 表示已死亡。

请不要修改提供的结构体定义。你只需要修改本项目中的 game.csnake.ccustom_tests.c 文件。

编译、测试与调试

本项目提供了一个 Makefile,因为部分编译命令较为复杂,请不要自己调用 gcc

项目包含两个可执行文件:

  • unit-tests: 包含针对任务 1 到 6 的所有单元测试。
  • snake: 包含完整的贪吃蛇游戏,并在任务 7 的集成测试中使用。

要编译一个可执行文件,可以运行 make executable-name。 例如,要编译单元测试,请运行 make unit-tests。 然后就可以使用 ./unit-tests 运行可执行文件,或在可执行文件上调用 cgdbvalgrind。 确保在修改代码后重新编译!

请注意,单元测试并不全面,通过测试并不能保证您的实现完全正确。 不过,它们应该有助于你开始调试。

任务 1: create_default_game

game.c 中实现 create_default_game 函数。该函数应在内存中创建一个默认的贪吃蛇游戏,使用以下起始游戏状态(可硬编码),并返回指向新创建的 game_t 结构的指针。

####################
#                  #
# d>D    *         #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
#                  #
####################
create_default_game
Arguments None
Return values game_t * 指向新创建的 game_t 结构的指针

提示

  • 棋盘有 18 行,每行 20 列。

  • 果实位于第 2 行第 9 列(从 0 开始计数)。

  • 蛇尾位于第 2 行第 2 列,蛇头位于第 2 行第 4 列。

  • 应将新游戏存储在哪个内存区域(代码段、静态区、栈、堆)?

  • strcpy 可能有所帮助。

  • 每行棋盘都必须以换行符结尾,并且必须是有效的字符串。

测试与调试

  • 提供了单元测试,可通过 unit-tests 可执行文件运行。

  • 有关编译和调试,请参见编译, 测试与调试

任务 2: free_game

game.c 中实现 free_game 函数。此函数应释放给定游戏的所有内存,包括所有 snake 结构体和 game->board 的所有内容。

free_game
Arguments game_t* game 指向要释放的 game_t 结构的指针
Return values None

测试与调试

要测试是否正确释放游戏的内存,可运行 make valgrind-test-free-game 检查内存泄漏。如无泄漏,即通过本任务的单元测试。

任务 3: print_board

game.c 中实现 print_board 函数。此函数应将给定游戏棋盘的内容打印到指定的文件指针。

print_board
Arguments game_t* game 指向要打印的 game_t 结构的指针
FILE* fp 指向打印棋盘的文件对象的指针
Return values None

提示

可使用 fprintf 函数将字符或字符串输出到指定文件指针。

测试与调试

本任务已提供单元测试,可通过 unit-tests 可执行文件运行。有关编译和调试的详细信息,请参阅编译, 测试与调试部分。

如果函数执行成功(未发生段错误或崩溃)但输出不正确,打印的棋盘将保存在 unit-test-out.snk 文件中。正确打印的棋盘应与任务 1 中的默认棋盘一致。

任务 4: update_game

实现 game.c 中的 update_game 函数。该函数需要按照游戏规则让所有蛇前进一个时间步。

辅助函数不会被评分;本任务仅检查 update_game 是否实现正确。

任务 4.1: 辅助函数(Helpers

以下为已提供但需你自行实现的辅助函数定义。它们与游戏棋盘或蛇本身无关,只接收单个字符并返回该字符的相关信息。

  • bool is_tail(char c): 若 c 属于蛇尾(字符集 wasd),返回 true;否则返回 false
  • bool is_head(char c): 若 c 属于蛇头(字符集 WASDx),返回 true;否则返回 false
  • bool is_snake(char c): 若 c 属于蛇的任意部分(字符集 wasd^<v>WASDx),返回 true;否则返回 false
  • char body_to_tail(char c): 将蛇身字符(^<v>)转换为对应的蛇尾字符(wasd)。若输入不是蛇身字符,则输出未定义。
  • char head_to_body(char c): 将蛇头字符(WASD)转换为对应的蛇身字符(^<v>)。若输入不是蛇头字符,则输出未定义。
  • unsigned int get_next_row(unsigned int cur_row, char c):
  • cvsS,返回 cur_row + 1
  • c^wW,返回 cur_row - 1
  • 否则返回 cur_row
  • unsigned int get_next_col(unsigned int cur_col, char c):
  • c>dD,返回 cur_col + 1
  • c<aA,返回 cur_col - 1
  • 否则返回 cur_col

我们没有为这些辅助函数提供单元测试,因此您必须在 custom_tests.c 中编写自己的测试,以确保这些函数按预期工作。 请确保这些测试能全面测试您的辅助函数--我们的自动跟踪器会在有错误的实现上运行您的测试,以确保您的测试能捕捉到错误!

编写单元测试时,如果测试失败,测试函数应返回 false;如果测试通过,则返回 true。 您可以使用 printf 来打印调试信息。 asserts.h 中的一些 assert 辅助函数可能会有用。

一旦编写了自己的单元测试,就可以使用 make custom-tests 对其进行编译,从而生成可运行或调试的 custom-tests 可执行文件。

任务 4.2: next_square

game.c 中执行 next_square 辅助函数。该函数返回指定蛇移动到的单元格中的字符。 该函数不应修改内存中存储的任何游戏内容。

next_square
Arguments game_t* game 指向要读取的 game_t 结构的指针
int snum 所要读取的蛇的索引
Return values char 所读取的蛇移动到的单元格中的字符

举例来说,请看下面的棋盘:

##############
#            #
#            #
#            #
#   d>D*     #
#            #
#       s    #
#       v    #
#       S    #
##############

假设 game 是指向 game_t 的指针,那么 next_square(game,0)应该返回 *,因为蛇 0 的头部正在移动到一个包含 * 的单元格中。 同样,对于蛇 1,next_square(game, 1) 应该返回 #

您之前编写的辅助函数可能会对该函数(以及本任务的其他部分)有所帮助。 此外,还可以查看 get_board_atset_board_at,它们是我们为您编写的辅助函数。

使用 make unit-tests 编译提供的单元测试。 您还可以使用 p print_board(game,stdout)cgdb 中调试时打印出整个棋盘。

任务 4.3: update_head

game.c 中执行 update_head 函数,该函数将更新蛇的头部。

请记住,您需要更新游戏棋盘和 snake_t 结构中的蛇头。 在游戏棋盘上,在蛇移动的位置添加一个新的头部,并将旧头部从头部字符 (WASD) 改为身体字符 (^)。 在 snake_t 结构中,更新蛇头的行和列。

update_head
Arguments game_t* game 指向要更新的 game_t 结构的指针
int snum 所要更新的蛇的索引
Return values None

举例来说,请看下面的棋盘:

##############
#   d>D      #
#        *   #
#        W   #
#        ^   #
#        ^   #
#        w   #
#            #
#            #
##############

假设 game 是指向此 game_t 的指针,那么 update_head(game, 0) 将移动蛇 0 的头部,而其他蛇的头部保持不变。 在蛇 0 对应的 snake_t 结构中,head_col 的值应该从 6 更新到 7,head_row 的值应该保持 1 不变:

##############
#   d>>D     #
#        *   #
#        W   #
#        ^   #
#        ^   #
#        w   #
#            #
#            #
##############

请注意,在移动头部时,该函数会忽略食物、墙壁和蛇体。

使用 make unit-tests 编译提供的单元测试。 您还可以使用 p print_board(game,stdout)cgdb 中调试时打印出整个棋盘。

任务 4.4: update_tail

game.c 中执行 update_tail 函数,该函数将更新蛇的尾巴。

请记住,您需要同时更新游戏棋盘和 snake_t 结构中的尾部。 在游戏棋盘上,将当前尾部空白,并将新尾部从正文字符 (^<v>) 改为尾部字符 (wasd)。 在 snake_t 结构中,更新尾部的行和列。

update_tail
Arguments game_t* game 指向要更新的 game_t 结构的指针
int snum 所要更新的蛇的索引
Return values None

举例来说,请看下面的棋盘:

##############
#   d>D      #
#        *   #
#        W   #
#        ^   #
#        ^   #
#        w   #
#            #
#            #
##############

假设 game 是指向此 game_t 的指针,那么 update_tail(game, 1) 将移动蛇 1 的尾巴,其他蛇的尾巴保持不变。 在蛇 1 对应的 snake_t 结构中,tail_row 的值应该从 6 更新到 5,而 tail_col 的值应该保持 9 不变:

##############
#   d>D      #
#        *   #
#        W   #
#        ^   #
#        w   #
#            #
#            #
#            #
##############

使用 make unit-tests 编译提供的单元测试。 您还可以使用 p print_board(game,stdout)cgdb 中调试时打印出整个棋盘。

任务 4.5: update_game

使用您创建的辅助函数,在 game.c 中实现 update_game。

在此提醒,移动蛇的规则如下:

  • 每条蛇向头部方向移动一步。

  • 如果蛇头撞上蛇身或墙壁,蛇就会死亡并停止移动。 蛇死后,蛇头会被 "x "代替。

  • 如果蛇头撞上了果实,蛇就会吃掉果实,并增长 1 个单位的长度。 (您可以通过更新蛇头而不更新蛇尾来实现长度增加 1 个单位)。 每吃一个水果,棋盘上就会生成一个新的水果。

int (*add_food)(game_t* game) 参数是一个函数指针,这意味着 add_food 是指向内存代码段的指针。 add_food 指向的代码是一个以 game_t* game 为参数并返回 int 的函数。 您可以用 add_food(x)调用这个函数,用您的参数替换 x,在棋盘上添加一个水果。

update_game
Arguments game_t* game 指向要更新的 game_t 结构的指针
int (*add_food)(game_t* game) 指向在棋盘上添加水果的函数指针
Return values None

测试与调试

单元测试只针对 update_game,可通过 unit-tests 可执行文件运行。 有关编译和调试,请参阅编译, 测试与调试

任务 5: load_board

game.c 中实现 load_board 函数。这个函数会从一个文件流(FILE *)中读取游戏棋盘数据,并存入内存。你的实现必须支持从标准输入(stdin)和其他任意文件流读取,所以不能使用 fseekrewind 或重新打开文件等不支持 stdin 的操作。

注意,每行棋盘的列数可能不同。你要高效地分配内存,避免浪费。比如一行只有 3 个字符,就不应该给它分配 100 字节的空间。

你必须使用 fgets 来读取文件内容,禁止使用其他函数读取文件。允许用其他字符串函数,如 strchr 来处理字符串。不用担心对 fgets 调用的错误处理。

提示:realloc 可能会帮你实现动态内存调整。

任务 5 和 6 会一起构建一个完整的 game_t 结构体。在本任务里,把 num_snakes 设为 0,snakes 指针设为 NULL,因为这两个会在任务 6 中初始化。

任务 5.1: read_line

实现 read_line 函数,给定一个 FILE *file,从文件中读一行字符串,存到堆上返回。如果 fgets 出错或到达文件尾,返回 NULL

read_line
Arguments FILE* file 一个可以读取字符串的文件指针
Return values char * 新读出的字符串指针,出错或 EOF 返回 NULL

任务 5.2: load_board

使用 read_line 实现 load_board 函数。

load_board
Arguments FILE* file 一个可以读取棋盘数据的文件指针
Return values game_t * 指向新建 game_t 结构体的指针,出错返回 NULL

测试与调试

  • 单元测试已提供,可通过 unit-tests 可执行文件运行。
  • 编译和调试请参考:编译、测试与调试

任务 6: initialize_snake

game.c 实现 initialize_snake 函数,根据游戏棋盘创建 snake_t 结构体数组。

任务 6.1: find_head

实现 find_head 函数,给定一个带有蛇尾位置的 snake_t 结构体,沿着棋盘找到蛇头位置并填写进结构体。

game.c 中实现 find_head 函数。这个函数接收一个 snake_t 结构体,其中蛇的尾部位置( tail_rowtail_col )已被填入。该函数的任务是沿着游戏棋盘中的蛇身轨迹追踪,直到找到蛇头位置,并将其填入结构体 snake_thead_rowhead_col 字段中。

find_head
Arguments game_t* game 指向要分析的 game_t 结构体
int snum 要分析的蛇的索引
Return values 无返回值

例如:

##############
#            #
#        *   #
#            #
#   d>v      #
#     v      #
#  W  v      #
#  ^<<<      #
#            #
##############

调用 find_head(game, 0) 后,蛇 0 的 head_rowhead_col 会被填成 6 和 3。

任务 6.2: initialize_snake

find_headgame.c 中实现 initialize_snake 函数。你可以假设传入的 game 是调用过 load_board 后得到的结果,也就是说,棋盘相关的数据都已经填好了, 但你**不能假设** snakes 数组已经存在。这意味着你要自己设置 num_snakes,并动态创建 snakes 数组。

假设棋盘上的所有蛇开始都是存活的。

initialize_snakes
Arguments game_t* game 要填充的 game_t 结构体指针
Return values game_t* game 填好字段的 game_t*,可以是传入的同一个结构体

测试与调试

  • 单元测试已提供,同样通过 unit-tests 可执行文件运行。
  • 详细编译和调试指南同上

任务 7: main

利用你在前面任务中实现的函数,完成 snake.c 文件中的 main 函数逻辑。每当你运行一次 snake.c 程序,游戏棋盘就会更新一步。

你可以通过运行 make run-integration-tests 来测试你的完整实现。这个命令会自动帮你编译程序,相当于执行了 make snake

如果需要调试程序,可以运行以下命令:cgdb --args ./snake -i tests/TESTNAME-in.snk -o tests/TESTNAME-out.snk。如果你想检测内存泄漏或者越界访问,可以使用:valgrind ./snake -i tests/TESTNAME-in.snk -o tests/TESTNAME-out.snkTESTNAME 替换成 tests 文件夹中的某个测试用例,比如:

  • 01-simple
  • 02-direction
  • 03-tail
  • 04-food
  • 05-wall
  • 06-small
  • 07-medium
  • 08-multisnake
  • 09-everything
  • 10-filled
  • 11-manyclose
  • 12-corner
  • 13-sus
  • 14-orochi
  • 15-hydra
  • 16-huge
  • 17-wide
  • 18-tall
  • 19-101-127
  • 20-long-line
  • 21-bigL

类似的,你也可以测试从标准输入(stdin)加载数据的功能。方法如下: ./snake --stdin -o tests/TESTNAME-out.snk < tests/TESTNAME-in.snk,然后用 diff 命令检查输出是否一致:diff tests/TESTNAME-ref.snk tests/TESTNAME-out.snk。如果你想调试这类用法,可以:cgdb ./snake,然后在调试器中运行:set args --stdin -o tests/TESTNAME-out.snk < tests/TESTNAME-in.snk或者直接用:valgrind ./snake --stdin -o tests/TESTNAME-out.snk < tests/TESTNAME-in.snk。注意:这种从 stdin 输入的行为不是单元测试(unit tests)或 make run-integration-tests 的一部分,但它会在自动评分系统中被测试。

每次修改代码后,请记得重新编译(除非你用的是 make run-integration-tests,它会自动编译)。

另外,你可以运行下面的命令,检查当输入文件不存在时,程序是否能正确以错误码 -1 退出:make run-nonexistent-input-file-test

尽情的玩耍吧!

  • 运行 make interactive-snake,然后 ./interactive-snake 运行游戏。
  • wasd 控制蛇。
  • 可用 ./interactive-snake -d 0.5 调整游戏速度(单位秒)。
  • 游戏中按 ] 加速,按 [ 减速。