基于 Linux 系统调用的联机贪吃蛇

背景

有一段时间对 Linux 特别感兴趣(现在也一直拿来当桌面系统),因此利用实验课的机会着手开发了一个小游戏,是一个可以多人联机的贪吃蛇游戏,看起来挺 low 的,但是涉及到了 Linux 课上所学的大部分知识,答辩后也得到了不错的结果。代码不是特别规范,下面强行分享一波。
完整代码

系统设计

的保存结构为双向链表,优点是可以从尾部向头部遍历,在移动的时候方便修改尾部节点。
为了节省空间,我没有记录每一段蛇身的位置,而是记录了蛇头、蛇尾及每一个转折点,初始化时蛇只有蛇头和蛇尾。
X

显示/隐藏代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef struct Snake {
Body *head;
Body *tail;
Dir oldDir;
Dir dir;
int isAlive;
} Snake;

/* 创建一个蛇,它只有两个节点分别是头和尾
* 初始头在起点,尾出了边界,方向朝右
*/
Snake *newSnake() {
Snake *s = (Snake*) malloc(sizeof(Snake));
s->head = newBody(newPoint(X_INIT, Y_INIT));
s->tail = newBody(newPoint(X_INIT, Y_INIT - 1));
connectBody(s->head, s->tail);
s->oldDir = RIGHT;
s->dir = RIGHT;
s->isAlive = 1;
return s;
}

每次移动时只需要增长头部及收缩尾部,当蛇转向时,需要新建一个头部,在收缩尾部时,需要判断尾部是否到达了转折点:

显示/隐藏代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/* 蛇的增长(头部) */
void snake_grow(Snake *snake) {
int tmp_x, tmp_y;
tmp_x = snake->head->pos->x, tmp_y = snake->head->pos->y;
switch(snake->dir) {
case LEFT: tmp_y -= 1; break;
case RIGHT: tmp_y += 1; break;
case UP: tmp_x -= 1; break;
case DOWN: tmp_x += 1; break;
default: return ; /* sth fucked */
}

if(isRedir(snake)) {
/* 若已转向,则将新头补上,并改变oldDir */
enqueueHead(snake, newPoint(tmp_x, tmp_y));
snake->oldDir = snake->dir;
} else {
/* 若无转向,直接更新到头 */
snake->head->pos->x = tmp_x;
snake->head->pos->y = tmp_y;
}
}

/* 收缩(尾部) */
void snake_shrink(Snake *snake) {
Body *tail = snake->tail;
Body *lastBody = snake_body_last(snake);
if(isAdjacent(tail->pos, lastBody->pos)) {
/* 若尾与身体最后截相邻,则去掉尾部 */
free(dequeueTail(snake));
} else {
/* 不相邻,直接更新到尾部 */
int delt;
if((delt = lastBody->pos->x - tail->pos->x) != 0) {
tail->pos->x += delt / abs(delt);
}
if((delt = lastBody->pos->y - tail->pos->y) != 0) {
tail->pos->y += delt / abs(delt);
}
}
}

/* 蛇移动 */
void snake_move(Snake *snake) {
/* 头部(增长) */
snake_grow(snake);

/* 尾部(收缩) */
snake_shrink(snake);
}

这种实现方法也存在缺点,在碰撞检测的时候会需要更多的计算量。

食物

食物的主体是一个点。

显示/隐藏代码
1
2
3
4
5
typedef struct Food {
Point *pos;
int h;
int count;
} Food;

食物每隔一段时间会出现在界面边界内的任意地方,食物对象有一个 count 域,每次时钟滴答会更新这个域,当达到 0 的时候就会重新刷新食物的位置(这个逻辑应该和以前玩的 Nokia 小板砖上的贪吃蛇一样):

显示/隐藏代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void food_locate(Food *food) {
if(food->count == 0) {
int x = rand_interval(0, WIN_HEIGHT);
int y = rand_interval(0, WIN_WIDTH / SYMBOL_WIDTH);
if(food->pos == NULL) {
food->pos = newPoint(x, y);
} else {
food->pos->x = x;
food->pos->y = y;
}
food->count = food->h;
} else {
food->count--;
}
}

碰撞检测

凡是可以用方向键的游戏基本都少不了碰撞检测,贪吃蛇中涉及到的碰撞检测包括蛇对食物的、蛇与蛇的(包括自己对自己的碰撞)。
计算蛇是否与食物相撞,需要先计算出蛇在行进方向上的下个位置是否有食物,如果有食物则将食物的位置信息删除,并增长蛇体。

显示/隐藏代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 判断蛇是否吃到食物
* 即判断蛇下一步是否能走到食物位置
*/
void snake_eat_food(Snake *snake, Food *food) {
if(food->pos != NULL) {
int x = snake->head->pos->x, y = snake->head->pos->y;
switch(snake->dir) {
case LEFT: y--; break;
case RIGHT: y++; break;
case UP: x--; break;
case DOWN: x++; break;
default: return ;
}

if(shift_posx(x) == food->pos->x &&
shift_posy(y) == food->pos->y) {
/* 若食物在蛇的前进方向上则吃掉 */
snake_grow(snake);
deletePoint(food->pos);
food->pos = NULL;
}
}
}

计算蛇是否与蛇相撞,需要判断蛇头的下一位置是否与另一条蛇的一截身体相重合。

显示/隐藏代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* 判断蛇1是否会撞上蛇2
* 即判断蛇1下一步是否会走到蛇2的身体的某节位置,自撞也是说得通的
*/
int snake_hit_snake(Snake *snake1, Snake *snake2) {
int x = snake1->head->pos->x, y = snake1->head->pos->y;
switch(snake1->dir) {
case LEFT: y--; break;
case RIGHT: y++; break;
case UP: x--; break;
case DOWN: x++; break;
default: return 0;
}

Body *begin = snake2->head, *end = begin->next;
while(end) {
/* 是否在区间内 */
if(isInInterval(x, y, begin->pos, end->pos)) {
return 1;
}
/* 取下一截 */
begin = end;
end = begin->next;
}
return 0;
}

代码里没有考虑到平局的情况,但这只需要多加几条判断即可。

通信

客户端和服务端采用 socket 方式通信,创建 socket 后会返回一个文件描述符,传输数据相当于向文件进行读写,一切都是文件,这也是*nix 哲学的一部分。一个 socket 由一个线程来控制,这样能够提高系统整体的效率,总共开启了三个线程分别操作三个 socket:

  • login_sock,接收登录请求,并将蛇的 id 返回,每个客户端对应一个蛇 id,操作时需要将蛇的 id 传给服务端;
    显示/隐藏代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /* 初始化蛇,并将最新的蛇id返回 */
    void process_login(int fd) {
    int id = -1;
    FILE *fp = fdopen(fd, "w");

    pthread_mutex_lock(&snakes_lock);
    // puts("process_login locked");

    /* 找出下一个可用id */
    while(snakes[++id]);
    /* 分配空间并传回其id */
    snakes[id] = newSnake();

    // puts("process_login unlocked");
    pthread_mutex_unlock(&snakes_lock);

    fprintf(fp, "%d", id);
    printf("login: %d\n", id);
    fclose(fp);
    }
  • op_sock,接收用户的操作请求,更新对应蛇的属性;
    显示/隐藏代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    /* 读取操作,并调整对应的蛇
    * id dir
    */
    void process_op(int fd) {
    FILE *fp = fdopen(fd, "r");
    int id;
    int cmd;
    fscanf(fp, "%d %d", &id, &cmd);
    // printf("snake %d move: %d\n", id, newDir);
    fclose(fp);

    pthread_mutex_lock(&snakes_lock);
    // puts("process_op locked");

    /* 接受命令(调整方向/退出),为什么要判断是否存在呢,
    是因为服务端蛇死亡后,客户端可能来不及反应 */
    if(snakes[id]) {
    if(cmd == 27) {
    printf("%d号蛇退出\n", id);
    deleteSnake(snakes[id]);
    snakes[id] = NULL;
    } else {
    snake_redir(snakes[id], cmd);
    }
    }

    // puts("process_op unlocked");
    pthread_mutex_unlock(&snakes_lock);
    }
  • obj_sock,向客户端输出当前食物和所有蛇的位置,客户端接收后绘制出来,也就是说,客户端不会做蛇的运动等计算性工作,而只是将当前的画面画出来。
    显示/隐藏代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    /* 接受请求,返回食物和蛇的数据
    * 第一行为食物,之后的是蛇,比如:
    * x y
    * id x y x y x y
    * id x y x y
    */
    void process_obj(int fd) {
    FILE *fp = fdopen(fd, "w");
    if(food->pos) {
    fprintf(fp, "%d %d\n", food->pos->x, food->pos->y);
    } else {
    fprintf(fp, "-1 -1\n");
    }

    pthread_mutex_lock(&snakes_lock);
    // puts("process_obj locked");

    /* 蛇体 */
    int id;
    for(id = 0; id < MAX_SNAKE; id++) {
    if(snakes[id]) {
    fprintf(fp, "%d", id);
    Body *head = snakes[id]->head,
    *next = snake_body_first(snakes[id]);
    fprintf(fp, " %d %d", head->pos->x, head->pos->y);
    while(next) {
    fprintf(fp, " %d %d", next->pos->x, next->pos->y);
    head = next;
    next = head->next;
    }
    fprintf(fp, "\n");
    }
    }
    fclose(fp);

    // puts("process_obj unlocked");
    pthread_mutex_unlock(&snakes_lock);
    }

Client 端与 Server 端的通信过程如下图所示。
X

线程同步

上面介绍的三种线程处理函数都涉及到了线程互斥锁的占有和释放(pthread_mutex_lock、pthread_mutex_unlock),主要是为了保证对蛇的并发操作的安全性。

异步非阻塞通信

前面提到的 socket 是一种同步阻塞的通信模式,在请求发起后,客户端和服务端需要等待直到数据传输完毕才能继续进行下一步操作,效率是偏低的。
同样的情况还体现在客户端接收用户输入的时候,代码里在实现客户端键盘输入时采取了异步非阻塞的模式,相对 getch 等阻塞方法来说效率更高,但是编程难度也更大。

现在登录多个客户端后,频繁地操作仍会有界面闪烁的问题,可以考虑将客户端和服务器之间的通信也换成异步非阻塞的模式。

界面

Client 端使用 curses 库来绘制界面,需要先安装 curses 库:

1
2
3
sudo apt-get update
sudo apt-get install libncurses5-dev
sudo apt-get install libncursesw5-dev

上面第二个 curses 库是支持全角的,可以在画面上输出中文字符。
画蛇时需要遍历整个链表,每两个节点画一条线段。

显示/隐藏代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 画蛇
* 因为蛇至少有2段(头尾),所以没有特殊情况
*/
void addSnake(Snake *snake, char *symbol) {
Body *cur = snake->head;
Body *next = cur->next;

while(next) {
addLine(cur->pos, next->pos, symbol);
cur = next;
next = cur->next;
}
move(LINES - 1, COLS - 1);
refresh();
}

需要注意的是每次画完后需要调用 move(LINES - 1, COLS - 1)将光标移动到界面以外,然后 refresh()刷新界面。

总结

  • 在*nix 中一切都是文件,包括普通的文件读写、socket、设备等,所以写 IO 程序是相对容易的,但是要注意选择适当的 IO 模型,可以考虑采用 select、epoll、aio 等方式提高通信效率;
  • 规范很重要,项目应该遵循一定的结构(我没做好),应该为关键代码都加上注释,这样读起来会更方便;