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);
}

哈哈哈哈哈哈