致阅卷人:请直接跳至“新·贪吃蛇”部分
在作业发布的两个月前,我提前知道了将会有贪吃蛇作业。所以当时熬夜攻关,写出了一个贪吃蛇。然而到等到作业正式发布的时候,我惊讶地发现,我的代码完全不能过关……
元·贪吃蛇
原本的贪吃蛇由于想使用队列来模拟一条蛇,所以使用了C++的STL库。
将游戏的本体单独制作成一个类,主函数负责处理用户输入与系统输出,另外有一个控制蛇自动前进的模块。
本体
一个完整的贪吃蛇游戏有以下组件:
1. 场景
2. 蛇
3. 水果
对于游戏中的任意一个方块,它的状态只有以下几种:
1. 空闲的方块
2. 被蛇的身体占用
3. 被水果占用
4. 是墙
这就方便了我们使用一个枚举类型来表示方块的状态:
typedef enum BLOCK_STATUS {
spare,
wall,
snake,
fruit
} blockStatus;
另外,由于蛇需要自动运动,必须要存储一个变量来记录蛇的运动方向。于是我又创建了一个枚举类型来表示方向:
typedef enum SNAKE_FACING {
snake_up,
snake_down,
snake_left,
snake_right
} snake_facing;
贪吃蛇很重要的部分是蛇的运动。蛇在每一次运动时候都需要对它前方的方块进行特别处理。
大体思路为:
1. 获取蛇尾的方块的指针
2. 将蛇尾的状态设置为“空闲”,即蛇尾先收缩
3. 根据蛇的面朝方向,调整蛇头的xy坐标
4. 根据蛇头的新xy坐标,获取相对应的方块指针
5. 根据新蛇头所在方块的状态,分以下情况:
* 墙和蛇身体:游戏结束,调用postGameOver()处理善后事宜
* 水果:蛇的尾部方块状态重新变成“蛇身”;将蛇头加入队列;将蛇头的方块状态设置为“蛇身”;如果全部方块都被蛇占据,则游戏胜出,否则重新放置水果
* 空闲:将蛇头方块状态设置为“蛇身”;将蛇头加入队列;蛇尾离开队列
根据这样的思路,可以写出如下代码:
void snakeStage::moveSnake(void) {
blockStatus *freedBlock = snake_body.front();
changeBlockStatus(freedBlock, spare);
blockStatus *nextBlock;
switch (currentFacing) {
case snake_up:
snake_head_y--;
break;
case snake_down:
snake_head_y++;
break;
case snake_left:
snake_head_x--;
break;
case snake_right:
snake_head_x++;
break;
}
nextBlock = getStageBlockByXY(snake_head_x, snake_head_y);
switch (*nextBlock) {
case wall:
case snake:
postGameOver();
break;
case fruit:
changeBlockStatus(freedBlock, snake);
snake_body.push(nextBlock);
changeBlockStatus(nextBlock, snake);
if (!generateFruit()) postGameWin();
break;
case spare:
changeBlockStatus(nextBlock, snake);
snake_body.push(nextBlock);
snake_body.pop();
break;
}
}
输入与输出
传统的输入与输出,效果是这样的:
最理想的输入输出就像玩GUI游戏一样,键盘输入,屏幕就会立即出现反应。但是在命令行界面下,这似乎就是不可能完成的任务
……or so you thought. 事实上这是可以实现的。
kbhit 与 curses
由于笔者的系统是Mac,在此使用curses库。
关于curses,网上有很多教程。有需要的可以自行搜索方法。
简而言之,使用curses库可以方便而高效地实现贪吃蛇输入输出的三大需求:
1. 可以使用方向键控制蛇
2. 输入指令之后立即反应在屏幕上,而不需要输入回车
3. 输出方式是“更新屏幕内容”,而不是重新追加打印内容
相关实现(混合式伪)代码如下:
#include <curses.h>
int main(int argc, const char * argv[]) {
initscr();
cbreak();
noecho();
keypad(stdscr, true);
refresh();
while(游戏正在进行) {
switch (getch()) {
case KEY_LEFT:
蛇面向左;
break;
case KEY_RIGHT:
蛇面向右;
break;
case KEY_UP:
蛇面向上;
break;
case KEY_DOWN:
蛇面向下;
break;
}
}
getch();
endwin();
return 0;
}
在上面的代码当中,使用了curses库。
调用curses的功能,必须在使用前调用initscr()
函数初始化,最后调用endwin()
结束。
例如:
* 使用cbreak()
让用户输入立刻返回给程序。
* 使用noecho()
关闭输入字符的回显。
* 使用keypad()
模式让程序可以读取上下左右方向键。
* refresh()
刷新屏幕。
* getch
获取用户输入的字符。
在这样的处理下,贪吃蛇将会达到理想中的交互。
自动运动
虽然有了可以游玩的贪吃蛇,但是现在的贪吃蛇还处于“一输入一动”的状态,蛇不会自己运动。所以我们应该需要一个计时器让蛇可以隔一段时间之后自动运行一步。
一个简单的想法是使用多线程,在新的线程当中使用sleep
阻塞线程。例如如下代码:
#include <curses.h>
#include <thread>
void automove() {
game_stage.printStage();
while (游戏正在进行) {
system("sleep 0.2");
erase();
moveSnake();
move(0, 0);
printStage();
refresh();
}
}
int main(int argc, const char * argv[]) {
...
thread stageDaemon{automove};
stageDaemon.detach();
...
}
其中detach()
模式将不会阻塞主程序的运行。
最终,效果是这样的。
新·贪吃蛇
后来正式要求出炉了。第一行便是:“ANSI C”。
由于必须使用C语言,C++的STL将不能使用。但是我们可以进行一些处理,使得代码在C语言下也可以运行。
元反思
由于要改写程序,我不得不重新审视了一遍代码。
我是不是滥用了枚举类型? 我为什么非得构造一个枚举类型组成的数组作为整个地图? 我为什么也要用枚举类型去表示蛇的运动方向? 除了队列,数组是不是也可以用来储存蛇的信息?
照着以上思考,我修改了代码。
首先,取消了BLOCK_STATUS
枚举对象,取而代之,直接使用不同类型的符号来同时达到“识别”和“输出”两个功能。
其次,取消了蛇的队列,用一个最大长度足够大的数组来分别存储蛇身的X和Y坐标。在C没有模板库、自己又不想实现队列的时候,这也是一个办法。
第三,使用了一个统一的方法,可以使用一个数字代表方向,并且同时解码出蛇运动的dx与dy,节省了代码量。
第四,不使用C++的<thread>
,而使用C的<pthread.h>
,使得在C语言中也可以使用多线程。
最后附上修改后的部分代码:
main.c
#include <stdio.h>
#include <curses.h>
#include <pthread.h>
#include "stage.h"
void automove() {
printStage();
while (!isGameOver()) {
system("sleep 0.2");
erase();
snakeMove();
move(0, 0);
printStage();
refresh();
}
}
int main(int argc, const char * argv[]) {
...
pthread_t autothread;
pthread_create(&autothread, NULL, automove, NULL);
pthread_detach(autothread);
...
return 0;
}
stage.c
#include "stage.h"
int snakeFacing, snakeLength, stageWidth, stageHeight, freeBlockCount, gameOver;
int *snakeX, *snakeY;
char *snakeStage;
void changeDirection(int direction) {
if ((direction + 2) % 4 == snakeFacing) return;
snakeFacing = direction;
}
void snakeMove() {
snakeMoveXY((int)sin(snakeFacing * M_PI_2), (int)cos(snakeFacing * M_PI_2));
}