Linux下的俄罗斯方块小游戏(C++)
补上之前的..
简介
之前写的俄罗斯方块的小游戏,一开始拿mac的unix写的,然后写着写着发现,好多库没有
心酸,最后换了服务器用linux写完了。
参考了一些别人的博客,但当时参考完就被我关了,现在也不清楚当时到底看了哪些了..
总体来说写的还是有点乱,而且并不完善
丑是丑了点,害
效果图:
消行后:
堆积方块:
主要思路
在终端设置一篇区域作为俄罗斯方块下降的地图,方块会定时下落,当落到最下面时,堆积到原本的地图上,方块每隔一秒下降一格,同时监测键盘是否在按方向键而改变方块的位置。行满时会消行。新刷新的方块不能再下落时,游戏结束。
简单的逻辑
我的代码的所有函数都是用类的,但是下面单独的每个函数我没表示出类,实际上每个函数前面都要有 Map:: 的。
一、Linux终端显示控制
用的 VT100,通过 printf 的格式化输出,在终端显示不同的方块颜色。
如,
printf("\033[%d;%dH", x, y); // 设置光标位置
printf("\033[%dm\033[%dm ", color, color); // 对光标处上色
将终端上坐标 (x, y) 处设置为颜色color。x,y,color均为传入的参数。
具体用法可以参考如下:
https://blog.csdn.net/aiwangtingyun/article/details/79524899
https://blog.csdn.net/liufei191010/article/details/81015445
突然发现,我应该隐藏光标的,忘记了...害
具体封装的函数:
void Map::setColor(int x, int y, int color) {
++x; // 第一行会没掉
++y;
y *= 2; // 每个 y 占两格,所以乘 2
printf("\033[%d;%dH", x, y); // \033[y;xH 设置光标位置
// \033[40m \033[40m将一个空格 背景变为黑色
printf("\033[%dm\033[%dm ", color, color);
fflush(stdout);
printf("\033[0m\033[0m"); // 恢复
printf("\033[30;0H");
}
二、背景地图
#define HEIGHT 10
#define WIDTH 20
int map[HEIGHT][WIDTH];
在析构函数中,初始化map数组:
for (int i = 0; i < HEIGHT; ++i) {
for (int j = 0; j < WIDTH; ++j) {
// 用的47而不是0是因为,前景色是47,方便到时候涂色
// 47代表地图的该处无障碍物,不是47代表该处有障碍物
map[i][j] = 47;
}
}
对地图上色,绘制出初始地图:
void Map::setMapColor() {
for (int i = 0; i < HEIGHT; ++i) {
for (int j = 0; j < WIDTH; ++j) {
setColor(i, j, map[i][j]);
}
}
}
一开始就是这样的(忽略掉那个蓝的):
三、绘制即将要下落的方块
一共七种,每种都可以用4x4的数组来表示:
int figure[7][4][4] = {
/*
0010 0100 0010 0110 0100 0010 0010
0010 0100 0010 0110 0110 0110 0110
0010 0110 0010 0000 0010 0100 0010
0010 0000 0000 0000 0000 0000 0000
*/
{{0,0,1,0},{0,0,1,0},{0,0,1,0},{0,0,1,0}},
{{0,1,0,0},{0,1,0,0},{0,1,1,0},{0,0,0,0}},
{{0,0,1,0},{0,0,1,0},{0,1,1,0},{0,0,0,0}},
{{0,1,1,0},{0,1,1,0},{0,0,0,0},{0,0,0,0}},
{{0,1,0,0},{0,1,1,0},{0,0,1,0},{0,0,0,0}},
{{0,0,1,0},{0,1,1,0},{0,1,0,0},{0,0,0,0}},
{{0,0,1,0},{0,1,1,0},{0,0,1,0},{0,0,0,0}}
};
经过旋转后,又可以变出更多种。后面再讲旋转。
每次在七种里面选一个,用随机函数来选:
int Map::random_num() {
return rand() % 7;
}
当前这个图形下落到了哪里,用一个结构体表示他的坐标:
struct Axis {
int x;
int y;
} axis;
并在析构函数中对坐标进行初始化:
axis = {0, WIDTH / 2 - 2};
对每个图形的颜色初始化(除了黑色,其他颜色是41~46):
for (int t = 0; t < 7; ++t) {
int cl = rand() % 6 + 41;
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
if (figure[t][i][j]) {
figure[t][i][j] = cl;
}
}
}
}
在终端上绘制即将下落的图形:
void Map::drawing() {
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
if (figure[num][i][j]) setColor(axis.x + i, axis.y + j, figure[num][i][j]);
}
}
}
四、旋转方块
没啥好讲的,简单推理下就能推出来:
void Map::rotate() {
int B[4][4];
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
B[j][4 - i - 1] = figure[num][i][j];
}
}
// 这里是要判断旋转后会不会装上障碍物。或者出边界,后面细讲
if (can_change(axis.x, axis.y, B) == 0) return;
// 旋转完了要把以前的图形消掉,绘制新的图形
de_drawing(); // 消掉原来的图形
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
figure[num][i][j] = B[i][j];
}
}
drawing(); // 绘制新的图形
}
关于消除旋转前的图形:
void Map::de_drawing() {
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
if (figure[num][i][j]) setColor(axis.x + i, axis.y + j, 47); // 47就是背景色
}
}
}
五、左移和右移
和前面那个 rotate 的思路差不多。先判断能不能移动,消除原本的图形,移动,再绘制新的图形。
void Map::left() {
if (can_change(axis.x, axis.y - 1, figure[num]) == 0)
return;
de_drawing();
--axis.y;
drawing();
}
void Map::right() {
if (can_change(axis.x, axis.y + 1, figure[num]) == 0)
return;
de_drawing();
++axis.y;
drawing();
}
难点
六、终端的非规范模式
所谓终端的非规范模式,即缓存和编辑功能关闭,这也挺好理解的,有缓存咋能按一个键立马图形就变了呢。
int tcgetattr(int fd, struct termios *termios_p);
用于获取与终端相关的参数,成功返回零;失败返回非零。
参数 fd 为终端的文件描述符,返回的结果保存在 termios 结构体中,
termios 结构体中有:
tcflag_t c_lflag
c_lflag中,ECHO宏(打开,输入了什么会显示在屏幕,所以要关闭)
c_lflag中,ICANON是规范模式(所以要关掉这个标志,就变成非规范模式)
这两个都关闭的话:
t.c_lflag &= ~(ECHO | ICANON); // 关闭c_lflag中的ECHO和ICANON
设置终端属性:
int tcsetattr(int fd, int when, struct termios *info);
void Map::set_termio() {
struct termios t;
tcgetattr(0, &t); // 0:标准输入
t.c_lflag &= ~(ECHO | ICANON); // 关闭c_lflag中的ECHO和ICANON
t.c_lflag &= ~ISIG;
tcsetattr(0, TCSANOW, &t); // TCSANOW 表示立即
}
简单来说就是获取原本的终端参数,修改ECHO宏和ICANON,最后再作为参数传入,修改终端参数。
恢复终端参数:
void Map::recover_termio() {
struct termios t;
tcgetattr(0, &t); // 0:标准输入
t.c_lflag |= ECHO | ICANON; // 打开c_lflag中的ECHO和ICANON
// 设置终端属性
// int tcsetattr(int fd, int when, struct termios *info);
tcsetattr(0, TCSANOW, &t); // TCSANOW 表示立即
}
这,看着放吧,我的代码里好多地方都放了,比如,按q退出的时候放了个,game over 的时候放了个。
七、获取用户的按键
用了一个枚举类型,看着好看点吧。
enum Direction {
CHANGE = 65, // 方向键上
DOWN = 66,
RIGHT = 67,
LEFT = 68,
QUIT = 'q',
};
试了几次才发现,按方向键的话,前面会出现几个其他的字符,第一个的 ascii 是27,后面好像还有 [[ 来着,这里设置方向键向上是旋转,向下是一键到底(后面讲)。
void Map::get_char() {
fflush(stdin);
char c = getchar();
if (c == QUIT) {
recover_termio();
exit(0);
} else if (c == 27) {
c = getchar();
c = getchar();
switch (c) {
case CHANGE: rotate();break;
case DOWN: fall(); break;
case RIGHT: right(); break;
case LEFT: left(); break;
}
}
}
八、I/O多路复用
进入最难的地方,也是我花时间最多的地方。
一开始也没明白为啥要并发,后来写着写着发现不得不用。因为原本的逻辑是一秒向下移一个,但是同时还需要处理用户键盘的输入,不得不并发。
最开始想的是用 fork 两个子进程,一个子进程监测键盘,有输入了就发个信号,一个子进程监测时钟,一秒发一次信号。
然后写着写着发现,这不行啊..信号处理函数只能穿信号这个参数,那我啥都干不了啊..网上和书上找来找去,只有在信号处理函数里面搞个输出的,也没去其他的例子。
后来看到 sigqueue 这个函数,可以传值或者指针,本来想试试,然而不知道为啥,我的 mac上的 signal.h 里面没有这个函数,心累。
后来又看了网上很多人用的 I/O多路复用,用了 select。用 timerfd_create 函数创建一个时钟的句柄。
但是 timerfd_create 是Linux专用的,最后换了服务器写的。
函数原型:
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
nfds:待测试的描述符个数,它的值是待测试的最大描述符加1。
readfds:监测读文件的变化,要监测的文件的句柄放在这个集合内,stdin(标准输入)也将其看做文件,句柄为 STDIN_FILENO(实际上就是0),原本 select 会使进程处于阻塞状态,当该集合中任意一个读 被检测到,就会停止阻塞,我们就可以判断是哪个读文件发生了变化,进而去读取内容。
writefds:监测写文件的变化,和上面的读原理一样,我们这里不用,用 NULL 即可。
exceptfds:这个参数用来检测文件有无异常情况发生。
timeout:最大的阻塞的时间,即超时时间后也没有读写发生,就会停止阻塞,因为我们这里无论如何每秒都会产生一格读使得方块下移,所以不设置这个时间(即 时间为无限长),用 NULL 即可。
标准输入的文件描述符比较简答:STDIN_FILENO,但是还需要有一个定时器描述符,每隔一秒发生一个读。
timerfd_create函数将时间变成一个文件描述符,在超时的时候变成可读:
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
timerfd_settime函数对时间句柄进行设置,如每隔多长时间变得可读:
int timerfd_settime(int fd, int flags, const struct itimerspec *new_value, struct itimerspec *old_value);
timespec 和 itimerspec 都是结构体,如下:
struct timespec {
time_t tv_sec; // 秒
long tv_nsec; // 毫秒
};
struct itimerspec {
struct timespec it_interval; //每隔多长时间超时
struct timespec it_value; // 第一次超时时间
};
具体代码如下,标了注释,应该挺好理解的:
int tfd = timerfd_create(CLOCK_MONOTONIC, 0);
struct itimerspec it = {{1, 0}, {1, 0}};
timerfd_settime(tfd, 0, &it, NULL);
int tt = 300;
while(tt--) {
fd_set read_set; // 描述符集合 读集合
FD_ZERO(&read_set); // 创建一个空的读集合
FD_SET(STDIN_FILENO, &read_set); // 将标准输入描述符加入读集合
FD_SET(tfd, &read_set);
//struct timeval tvl = {1, 0}; // 最多阻塞一秒
// 阻塞,直到集合中某个描述符可以读
select(tfd + 1, &read_set, NULL, NULL, NULL);
// FD_ISSET 判断哪个哪个描述符已经可以读了
if (FD_ISSET(STDIN_FILENO, &read_set)) {
get_char();
} else {
// tfd
int ret = down();
if (ret == 0) re_init();
uint64_t data;
read(tfd, &data, sizeof data);
}
}
close(tfd);
收尾
九、判断是否能够变形或下移、消行
主要是看,如果在4x4内是有方块的,那么这一格超出边界就返回0;如果会撞上地图的障碍物,也返回0.
bool can_change(int x, int y, int Mo[][4]) {
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
if (Mo[i][j]
&& (x + i >= HEIGHT || y + j >= WIDTH || x + i < 0 || y + j < 0)) {
return 0;
}
if (Mo[i][j] && map[x + i][y + j]-47) return 0;
}
}
return 1;
}
bool can_down() {
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
if (figure[num][i][j] && (axis.x+i+1 >= HEIGHT || axis.y+j >= WIDTH)) {
return 0;
}
if (figure[num][i][j] && map[axis.x+i+1][axis.y+j]-47) return 0;
}
}
return 1;
}
十、方块图形堆积到地图上
当不能再下移时,就把图形堆积到地图上。
if (can_down()) return;
// 不可以下移
for (int i = 0; i < 4; ++i) {
for (int j = 0; j < 4; ++j) {
if (figure[num][i][j]) map[axis.x + i][axis.y + j] = figure[num][i][j];
}
}
十一、判断游戏结束
当发现新刷新出来的图形,不能下落时,判断游戏结束:
if (can_down() == 0) {\
printf("\033[2J"); // 清屏
printf("game over\n");
recover_termio();
exit(0);
}
哈哈哈哈哈哈