本帖最后由 lilipo 于 2023-8-12 17:45 编辑
C语言学习进入尾声时,老师布置了作业,让我们用前一段时间学习的C语言知识写一些小游戏。提到小游戏,我首先想到的就是“俄罗斯方块”和“贪吃蛇”了。因为“俄罗斯方块”前几天老师刚刚带着大家写过了。所以,这一次的作业我打算写“贪吃蛇”。 对于一个大作业,我是这样做的:先分析竞品,然后构思自己的程序,再然后整理素材,再写出设计文档,最后就开始编写代码了。如图: 下面,我选取其中的一部分来说明一下。 在竞品分析这一个阶段,我会从网上搜索一些文案、图片、视频。通过仔细的观查了解这个小游戏的信息,比如,有哪些元素,有怎样的用户操作,游戏的规则又是怎么的。把这些信息整理出来,再结合自己独特的想法。就可以整理出下面的图来。如图: 这次做的贪吃蛇小游戏,是想模仿诺基亚手机上贪吃蛇游戏的样子。是像素风格的。所以,素材的准备上也很简单。不论是蛇、还是食物都是由一个一个的小方块组成的。至于这些小方块如何拼成图案,把素材截图、再放大,就可以很清晰的知道啦。如图: 来到写设计文档环节,我会把写程序中会遇到的重点和难点罗列出来,写出这些地方的思路。像这次的贪吃蛇小游戏,我会列出蛇节点、食物、存档文件等的结构体;像界面菜单的绘制、界面的切换、蛇的移动、如何吃食物、如何刷新食物、如何刷新出奖励等等,也是会先写出思路来。这样在接下来写程序时,就可以方便的写出一个“外壳”和“很多的TODO”来。然后,逐个按着上面的思路来消除这些“TODO”,这样程序就被一步一步写出来了。 接下来,我将挑选部分代码来讲一下。 1、 结构体 比如,节点结构体,在程序中用节点的地方还是挺多的。比如墙(墙的某一个小方块)、蛇身(蛇身的某一小节)。所以,我采用了嵌套结构体的方式。 // 占内存大小为0x4 typedefstruct linear_node { // 节点指针域 struct linear_node *pre; struct linear_node *next; } LinearNode; // 蛇节点结构体 typedefstruct { LinearNode base; // 用于方便操作链表 enum snake_node_type type; // 节点类型 enum snake_node_direction direction; // 当前节点方向 enum snake_node_direction next_direction; // 下一步节点方向 int x; // 节点坐标x轴值 int y; // 节点坐标y轴值 enum snake_node_direction elbow; int virtual_head; }SnakeNode; // 墙结构体 typedefstruct { LinearNode base; // 用于方便操作链表 int x; // 节点坐标x轴值 int y; // 节点坐标y轴值 }SnakeWall; LinearNode结构体包含了节点的最基础的信息:“前一个节点”和“后一个节点”这两个指针。 SnakeNode结构体中的第一个成员是LinearNode结构体类型。然后才是蛇节点独有的信息。这样嵌套的结构体,就形成了类似于“父类”和“子类”的层级关系。方便在后续的代码中使用基于LinearNode结构体的链表相关算法的函数。 SnakeWall结构体也是一样。第一个成员是LinearNode结构体类型。然后才是墙节点独有的信息。 2、 链表 为了使用方便,使用双向链表。定义的结构体如下: typedefstruct { LinearNode *head; LinearNode *rear; int count; }DList; 双向链表,就是在结构体中,定义头节点指针和尾节点指针。这两个成员是必须的。有了这两个成员,就可以往链表中插入数据、查找数据、删除数据和遍历链表。第三个成员int count;是附加的信息。这些附加的信息可以让自己的链表拥有更丰富的功能。比如:可以方便的判断是链表是不是空链表;快速的返回链表中的元素个数。 下面,把链表中常用的函数也列出来: // 双向链表初始化 voiddouble_link_list_init(DList *dlist) { if (!dlist) { printf("数据不正确!"); return ; } dlist->head = NULL; dlist->rear = NULL; dlist->count = 0; } // 释放双向链表 voiddouble_link_list_free(DList *dlist) { LinearNode *head; LinearNode *temp; head = dlist->head; while (head) { temp = head->next; free(head); head = temp; } dlist->head = NULL; dlist->rear = NULL; dlist->count = 0; } // 双向链表追加数据 void* double_link_list_append(DList *dlist, size_t data_size) { LinearNode *new_data; if (!dlist) { printf("数据不正确!"); return NULL; } // 分配内存 new_data = (LinearNode *) malloc(data_size); if (!new_data) { printf("分配内存失败!"); return NULL; } // new_data->data = val; new_data->pre = dlist->rear; new_data->next = NULL; if (dlist->count == 0) { dlist->head = dlist->rear = new_data; } else { dlist->rear->next = new_data; dlist->rear = new_data; } dlist->count ++; return new_data; } // 双向链表插入数据 void* double_link_list_insert(DList *dlist, int idx, size_t data_size) { LinearNode *temp; LinearNode *new_data; int i = 0; if (idx < 0 || idx > dlist->count -1) { printf("插入位置错误"); return NULL; } temp = dlist->head; while (temp && i < idx) { temp = temp->next; i++; } // 分配内存 new_data = (LinearNode *) malloc(data_size); if (!new_data) { printf("分配内存失败!"); return NULL; } // new_data->data = val; if (dlist->count == 0) { new_data->pre = NULL; new_data->next = NULL; dlist->head = dlist->rear = new_data; } else { new_data->pre = temp->pre; new_data->next = temp; if (temp->pre) { temp->pre->next = new_data; } else { dlist->head = new_data; } temp->pre = new_data; } dlist->count ++; return new_data; } // 双向链表返回指定位置的元素 LinearNode*double_link_list_get(DList *dlist, int idx) { LinearNode *temp; int i = 0; if (idx < 0 || idx > dlist->count -1) { printf("删除位置错误!"); return NULL; } if (dlist->count == 0) { printf("链表是空!"); return NULL; } temp = dlist->head; while (temp && i < idx) { temp = temp->next; i++; } return temp; } // 双向链表删除数据 intdouble_link_list_delete(DList *dlist, int idx) { LinearNode *temp; int i = 0; if (idx < 0 || idx > dlist->count -1) { printf("删除位置错误!"); return 0; } if (dlist->count == 0) { printf("链表是空,先添加数据!"); return 0; } temp = dlist->head; while (temp && i < idx) { temp = temp->next; i++; } // *val = temp->data; // 分配内存 if (temp->pre) { temp->pre->next = temp->next; } else { dlist->head = temp->next; } if (temp->next) { dlist->rear = temp->pre; } else { temp->next->pre = temp->pre; } free(temp); dlist->count --; return 1; } // 双向链表返回元素的个数 intdouble_link_list_size(DList *dlist) { return dlist->count; } // 遍历队双向链表有元素(从第一个元素到最后一个元素) voiddouble_link_list_traversal(DList *dlist, void (*traversal_item)(int , void *)) { LinearNode *temp; int i; i = 0; temp = dlist->head; while (temp) { if (traversal_item) { traversal_item(i, temp); } i++; temp = temp->next; } } // 双向链表冒泡排序 voiddouble_link_list_sort_bubble(DList *dlist, int (*compare)(void *, void *), void(*swap)(void *, void *)) { int i; int j; LinearNode *p, *q; int size; size = double_link_list_size(dlist); for (i = 0; i < size-1; i++) { j = size - i - 1; p = dlist->head; q = p->next; while (j--) { if( compare(p, q) ) { swap(p, q); } p = q; q = q->next; } } } 需要注意的是double_link_list_traversal函数和double_link_list_sort_bubble函数。这两个函数用到了函数指针。比如double_link_list_traversal函数,这个函数的第二个参数是一个函数指针。函数指针有两个参数,分别是当前遍历元素的下标和当前遍历元素的指针。采用函数指针的方案,是为了这些工具函数更具有通用性。“使用者”在应对不同的场景时,只需要更换函数指针即可,而不用担心“遍历”、“排序”这些主要的逻辑受到影响。 3、 界面切换 程序中会有多个界面,如:“菜单界面”、“操作说明界面”、“游戏界面”还有“关于界面”。 打开游戏,首先进入的是“菜单界面”。在“菜单界面”中,用户可以通过控制一个“选择条”的上下移动来进入不同的界面。我们可以把这个“选择条”选中的结果看作是一个数字,像1、2、3、4。然后判断具体是哪个数字就进入到哪个界面。说到这里,你是不是已经想到了解决方法了呢?是的,switch … case,用switch语句来处理在适合不过了。来看代码: menu = -1; has_save = read_game_save(&seri); do { switch(menu) { case 1: // 关于 menu = enter_about_page(); break; case 2: // 操作说明 menu = enter_help_page(); break; case 3: // 继续游戏 menu = enter_game_page(has_save,&seri); break; case 4: // 新游戏 has_save = 0; menu = enter_game_page(has_save,&seri); has_save = read_game_save(&seri); break; default: // 菜单 menu = enter_menu_page(has_save); break; } } while(menu); 4、 绘制蛇 绘制蛇,是这个小小的游戏程序中最难的部分了。不过,不用担心,和“最难”相伴随的是“更有挑战性”、“更有趣”和“更有成就感”。接下来,我就带着大家一步一步到绘制蛇的问题解决了。 我们先把这个“大功能”细分成多个“小功能”。如:把绘制蛇,细分成“绘制蛇头”、“绘制蛇身”、“绘制蛇尾”。 其中“绘制蛇身”还可以细分为绘制正常的蛇身(直的),和绘制特殊的蛇身(弯部)。 以上提到的“蛇头”、“蛇身”、“蛇尾”,虽然有各自的名称,其实他们都有一个共同的特点:都是一个“蛇节点”。所以,不要被这众多的名称吓倒。因为,经过咱们的“抽象”,只需要写“一个函数”就可以了,就是“绘制蛇节点”。如果是普通的贪吃蛇游戏,抽象到这一步就可以了。只不过呢,我们这次做的蛇稍微形象一点:蛇头上有五官,蛇身上有花纹,蛇尾上还有小尖尖,爬行的时候腰还一扭一扭的。这就要求我们的“蛇节点”有两点新增的信息:1、独有的形状;2、方向。 形状我们可以用二维数组来定义,如: // 右向 staticconst int shape_snake_head_1[5][5] = { {0, 1, 1, 0, 0}, {1, 1, 0, 1, 1}, {1, 1, 1, 1, 1}, {1, 1, 0, 1, 1}, {1, 1, 1, 0, 0}, }; 需要注意的是,当我们定义这个二维数组时,我们需要标注上这个二维数组针对的哪个方向。因为,在绘制不同的方向时,我们不需要再定义另一个数组,只需要把这个数组旋转即可。 细心的朋友,会发现代码中还定义了一个相似的二维数组: // 右向 staticconst int shape_snake_head_2[5][5] = { {1, 1, 1, 0, 0}, {1, 1, 0, 1, 1}, {1, 1, 1, 1, 1}, {1, 1, 0, 1, 1}, {0, 1, 1, 0, 0}, }; 定义这个数组的目的是为了“摇摆起来”。就是上文提到的“爬行的时候腰还一扭一扭的”。 有了形状的定义之后,接下来,就可以写那个抽象的绘制蛇节点了。还记得我们说过有方向的事吗?所以,绘制蛇节点就有4个函数,分别对应上、下、左、右。如下(4个函数在代码中的书写顺序是上右下左,是为了好记,和css样式的顺序一致): // 画蛇节点(向上) staticvoid draw_snake_node_up(int base_x, int base_y, const int (*shape)[5]); // 画蛇节点(向右) staticvoid draw_snake_node_right(int base_x, int base_y, const int (*shape)[5]); // 画蛇节点(向下) staticvoid draw_snake_node_down(int base_x, int base_y, const int (*shape)[5]); // 画蛇节点(向左) staticvoid draw_snake_node_left(int base_x, int base_y, const int (*shape)[5]); 我们选其中一个把代码贴出来: // 画蛇节点(向上) staticvoid draw_snake_node_up(int base_x, int base_y, const int (*shape)[5]) { int i; int j; int left, top, right, bottom; for (j = 4; j >= 0; j--) { for (i = 0; i < 5; i++) { if (shape[j]) { left = base_x + i * MIN_PIXEL; top = base_y + (4-j) * MIN_PIXEL; right = left + MIN_PIXEL; bottom = top + MIN_PIXEL; solidrectangle(left, top, right,bottom); } } } } 我们这里选了“画蛇节点(向上)”。注意看,函数中两层for循环的i、j变量,并不是i在外,j在内;初始值也不是0。这就是上面提到的应对不同方向时的“旋转”。两层for循环中有if判断,当是1时,就是需要绘制的部分。里面的MIN_PIXEL表示的是,一个“小黑点”对应电脑屏幕上的多少个像素。 有了绘制抽象“蛇节点”的函数后,绘制“蛇头”、“蛇身”、“蛇尾”这些代码就好写了。只需要提供具体的形状和具体的方向,然后调用绘制抽象“蛇节点”的函数就可以了。代码以绘制蛇头为例: // 画蛇头 staticvoid draw_snake_head(SnakeNode *snake_node, const int (*shape)[5]) { switch(snake_node->direction) { case direction_up: draw_snake_node_up(snake_node->x,snake_node->y, shape); break; case direction_right: draw_snake_node_right(snake_node->x,snake_node->y, shape); break; case direction_down: draw_snake_node_down(snake_node->x,snake_node->y, shape); break; case direction_left: draw_snake_node_left(snake_node->x,snake_node->y, shape); break; default: break; } } 代码写在这里,绘制“蛇头”、“蛇身”、“蛇身弯部”、“蛇尾”的函数都已经准备好了。接下来,就可以用这些函数来拼装出完整的绘制蛇的函数了。代码如下: staticvoid draw_snake(DList *snake_node_list) { int i; int count; SnakeNode *temp_snake_node; count =double_link_list_size(snake_node_list); for (i = 0; i < count; i++) { temp_snake_node = (SnakeNode*)double_link_list_get(snake_node_list, i); if (temp_snake_node->type == snake_head){ if (move_count_odd_even % 2 == 0) { draw_snake_head(temp_snake_node,shape_snake_head_1); } else { draw_snake_head(temp_snake_node,shape_snake_head_2); } } else if (temp_snake_node->type ==snake_body) { if (temp_snake_node->next_direction== temp_snake_node->direction) { if (move_count_odd_even % 2 == 0) { if (i % 2 == 0) { draw_snake_body(temp_snake_node,shape_snake_body_1); } else { draw_snake_body(temp_snake_node,shape_snake_body_2); } } else { if (i % 2 != 0) { draw_snake_body(temp_snake_node,shape_snake_body_1); } else { draw_snake_body(temp_snake_node,shape_snake_body_2); } } } else { draw_snake_elbow(temp_snake_node); } } else if (temp_snake_node->type ==snake_tail) { if (move_count_odd_even % 2 == 0) { draw_snake_tail(temp_snake_node,shape_snake_tail_1); } else { draw_snake_tail(temp_snake_node,shape_snake_tail_2); } } } } 可以看到,我们的这个函数的参数竟然是Dlist指针类型。是的,没有错。因为,在最初我们讲嵌套结构体时就提到了“蛇节点”结构体的第一个成员就是“普通的节点”类型。所以,用“蛇节点”组成的链表,就可以使用在链表的工具函数上。像:“count = double_link_list_size(snake_node_list);”、“temp_snake_node = (SnakeNode *)double_link_list_get(snake_node_list,i);”这些语句都是例子。现在是不是对嵌套结构体的体会更深刻了? 由于我们前面已经把绘制各部分的函数都写好了,所以这个函数中其余的代码逻辑就很简单了,就是判断“部位”,然后调用绘制对应部位的函数。再然后,就是根据“一二一、一二一”这样的口号,传入不同的奇偶形状,让我们绘制的这条小蛇活灵活现的扭动起来。 5、 存档 代码写到这里,大部分的TODO已经被我们消灭了。这款小游戏也已经可以玩起来了。为了提升游戏体验,或者为了方便给好朋友分享自己的游戏精彩时刻,再添加一个存档的功能会更好。 存档其实很简单,还记得开篇时,我分析这款小游戏中有哪些元素吗?像:蛇、墙、食物等等,还记得不?存档就是把这些元素对应的数据保存成文件。把数据写文件用到的函数有fopen和fwrite。这些元素的信息很多,比如食物,有食物的位置,还有食物消失的剩余时间;如果我们每一个元素的数据单独保存的话太多了。于是,我们可以采用定义存档结构体的方式,保存时,直接往文件中写入存档结构体的二进制数据即可以。存档结构体的定义如下: // 存档结构体 // 魔数、游戏模式、得分数、关卡数、新方向、剩余奖励时间数、食物结构体数量、食物结构体数组、蛇结构体数量、蛇结构体数组、墙坐标数量、墙坐标数组。 structsnake_serialization { // 魔数 char magic_number[32]; // 游戏结束 int game_over; // 得分数 int score; // 新方向 enum snake_node_direction new_direction; // 剩余奖励时间数 int remainder_reward_second; // 食物结构体数量 int food_count; // 蛇结构体数量 int snake_node_count; }; 有了结构体以后,写存档的函数就简单了。代码逻辑就是:先创建结构体,然后二进制写入。上代码: // 写游戏存档 intwrite_game_save(int game_over, int score, enum snake_node_directionnew_direction, int remainder_reward_second, SnakeFood *bean, SnakeFood *apple,DList *snake_node_list) { struct snake_serialization game_save; FILE *file; int count; int i; SnakeNode *temp_snake_node; file = fopen(GAME_SAVE_FILE_NAME,"wb"); if (!file) { return 0; } memset(game_save.magic_number, '\0', 32); strncpy(game_save.magic_number, "snakev1.0.0 lilipo", 32); game_save.game_over = game_over; game_save.score = score; game_save.new_direction = new_direction; game_save.remainder_reward_second =remainder_reward_second; game_save.food_count = 0; if (bean) { game_save.food_count++; } if (apple) { game_save.food_count++; } game_save.snake_node_count =double_link_list_size(snake_node_list); count = fwrite(&game_save, sizeof(structsnake_serialization), 1, file); if (count == 0) { fclose(file); return 0; } if (bean) { count = fwrite(bean, sizeof(SnakeFood), 1,file); if (count == 0) { fclose(file); return 0; } } if (apple) { count = fwrite(apple, sizeof(SnakeFood), 1,file); if (count == 0) { fclose(file); return 0; } } for (i = 0; i <game_save.snake_node_count; i++) { temp_snake_node = (SnakeNode*)double_link_list_get(snake_node_list, i); count = fwrite(temp_snake_node, sizeof(SnakeNode),1, file); if (count == 0) { fclose(file); return 0; } } fclose(file); return 1; } 好了,现在代码编写完了。运行起来试玩一下,没有问题的话就可以把程序包发你的好朋友了。也把这份快乐分享出去。
完整代码:见附件
补充内容 (2023-8-16 09:23):
另外,代码中用到了 EasyX 图形库,这个库下载、安装都很方便。有需要有朋友可以留言给我。我可以发你们这个库的安装包。 |