项目 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
表示已死亡的蛇头。
每个字符告诉你蛇当前移动的方向:
w
、W
或^
表示向上。a
、A
或<
表示向左。s
、S
或v
表示向下。d
、D
或>
表示向右。
在每个时间步,蛇按以下规则移动:
- 每条蛇沿头部指示的方向移动一步。
- 如果头部撞到蛇身或墙壁,则该蛇死亡并停止移动,死亡时头部字符变为
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.c
、snake.c
和 custom_tests.c
文件。
编译、测试与调试¶
本项目提供了一个 Makefile
,因为部分编译命令较为复杂,请不要自己调用 gcc
!
项目包含两个可执行文件:
unit-tests
: 包含针对任务 1 到 6 的所有单元测试。snake
: 包含完整的贪吃蛇游戏,并在任务 7 的集成测试中使用。
要编译一个可执行文件,可以运行 make executable-name
。 例如,要编译单元测试,请运行 make unit-tests
。 然后就可以使用 ./unit-tests
运行可执行文件,或在可执行文件上调用 cgdb
和 valgrind
。 确保在修改代码后重新编译!
请注意,单元测试并不全面,通过测试并不能保证您的实现完全正确。 不过,它们应该有助于你开始调试。
任务 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)
:- 若
c
为v
、s
、S
,返回cur_row + 1
- 若
c
为^
、w
、W
,返回cur_row - 1
- 否则返回
cur_row
unsigned int get_next_col(unsigned int cur_col, char c)
:- 若
c
为>
、d
、D
,返回cur_col + 1
- 若
c
为<
、a
、A
,返回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_at
和 set_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
)和其他任意文件流读取,所以不能使用 fseek
、rewind
或重新打开文件等不支持 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_row
和 tail_col
)已被填入。该函数的任务是沿着游戏棋盘中的蛇身轨迹追踪,直到找到蛇头位置,并将其填入结构体 snake_t
的 head_row
和 head_col
字段中。
find_head |
||
---|---|---|
Arguments | game_t* game |
指向要分析的 game_t 结构体 |
int snum |
要分析的蛇的索引 | |
Return values | 无 | 无返回值 |
例如:
##############
# #
# * #
# #
# d>v #
# v #
# W v #
# ^<<< #
# #
##############
调用 find_head(game, 0)
后,蛇 0 的 head_row
和 head_col
会被填成 6 和 3。
任务 6.2: initialize_snake
¶
用 find_head
在 game.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.snk
把 TESTNAME
替换成 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
调整游戏速度(单位秒)。 - 游戏中按
]
加速,按[
减速。