计算机技术学习札记

使用 C 语言写游戏后端

我校《面向对象的软件构造实践》课程的大作业,是合作开发一款 Android 平台上的「飞机大战」主题的游戏,其中多人游戏功能是一个考核项。本文记录我用纯 C 语言开发多人游戏服务端的过程。

正经人是不会用纯 C 语言来写游戏后端的——比起 C 这种高级汇编,诸如 Go、Python 乃至 Java、C++ 之流都要更加适合这项工作。选择 C 语言来做这件事,实在是「自讨苦吃」。不过,对于一个课程大作业这样的非生产环境、非高并发、非利益相关的场景,语言的特性就并不重要了——享受整个过程才是重要的事情。

因为是第一次进行游戏开发,文中介绍的多人游戏逻辑也只是我们自己的想法。文中有疏漏之处,还请见谅。

多人游戏流程

我们设计的多人游戏世界观是:游戏每个「房间」容纳两名玩家,两名玩家加入游戏后,分别操作一架「战机」,并肩与地图上的自动生成的 NPC「敌机」射击对战。一旦有任一一名玩家「死亡」(生命值降到 0),整局游戏结束,统计每个人「击杀」敌机的得分并进行排名。

在游戏开始之前,玩家需要使用用户名和密码登录到服务器,并可以选择创建新的「房间」并等待他人加入,或者加入一个目前空闲的房间。这个过程的流程图如下。

多人游戏必然涉及到多个客户端之前「状态」的一致性,部分运算和逻辑的处理必须在服务器上进行。但又为了使得整个后端简化——不使用多线程,因为纯 C 语言实现多线程很容易写出不安全的代码——我们选择将那些非后端处理不可的事件在后端处理,其余重要事件,由房间的创建者「房主」向服务器通报。具体来说:

  • 地图上 NPC 的定时生成和消失(飞出地图而消失),由房主计算并向服务器通报,服务器将新生成的 NPC、因飞出世界而消失的 NPC 信息转发给另一客户端「房客」。服务器只维护场上存活 NPC 的名单,但不记录它们的位置。

  • 玩家一旦产生移动动作,即将新的位置上报服务器,服务器只是无条件地将位置转发给另一方。服务器不维护每个玩家的位置。

  • 玩家自己的子弹击伤、击杀 NPC 敌机由每个客户端自己计算并上报服务器,服务器统计所有 NPC 的生命值,并在 NPC 被击杀(生命值降为 0)时为最后一击的一方加分,同时告知双方将此 NPC 从地图上移除,并依据 NPC 的类型决定是否产生道具。

  • 玩家「捡」到道具时,向服务器通报道具的 ID 等信息,服务器维护有一个场上有效道具列表,因此可以决定此道具能否生效,以及具体的效果。

对上面的需求进行分析,我们可以整理出后端所要使用的技术栈。

后端技术栈

网络通信

显然,整个多人游戏都是基于实时的网络通信的。实时性意味着 HTTP 这种轮询式的协议自然不合适,我们应该选用长连接来维护通信。具体的,我们选择的是 WebSocket 长连接:由每个客户端向服务器建立一个 WebSocket 长连接来实现多人游戏。由于后端运行在 Linux 环境下,我们使用 libwebsockets 库来方便地处理 WebSocket 连接。

既然使用了 WebSocket,我们自然也就使用 JSON 来作为通信的消息载体了。无论是对 Android 客户端还是对我们的后端,解析 JSON 都不是难事。我们使用 cJSON 库来解析和构造 JSON 消息。

数据库

由于多人游戏的第一步是双方进行登录验证,因此需要使用某种方式来存储玩家的用户名等信息。此外,每场游戏后用户的分数都会被登记,因此也需要存储所有用户的历史分数。

我们使用 MariaDB 作为后端数据库。相应地,在 C 中使用 libmariadb 来实现程序与数据库的连接。

程序编写

下面介绍后端的代码编写。后端所有代码开源在 GitHub 仓库 https://github.com/criwits/saws/

整体结构

整个后端项目使用 CMake 进行编译控制。src 文件夹存放代码,而 include 文件夹存放一些全局头文件。代码分为五个部分:database 与数据库相关,game 为游戏逻辑相关的模型,server 是网络相关的代码,power 控制整个服务器的启动和关闭,utils 存放一些工具代码。

下面我们也顺着这个思路来解读所有的代码。

数据库

数据库相关的代码在 src/database 下,由负责数据库连接的 mysql.c,以及排名 DAO ranking.c 和用户数据 DAO user.c

MySQL 数据库的 C 语言连接表现为一个「句柄」,为 MYSQL 类型的指针,在 mariadb/mysql.h 中定义。使用 mysql_real_connect() 就能让句柄用给定的用户名和密码连接上数据库。

MYSQL *conn;
conn = mysql_real_connect(conn, "localhost", username, password, database, 0, NULL, 0)

mysql_query() 函数,则用于在数据库上执行 SQL 语句——增删改查都可以。例如,

mysql_query(conn, "SELECT * FROM USERS");

就能在 conn 句柄所连接的数据库上,执行 SQL 语句 SELECT * FROM USERS。执行结果会被存储在一个缓冲区中,使用 mysql_store_result() 函数就能将结果指针调出,类型为 MYSQL_RES

与前面的分析相对应,我们在数据库中有两张表——表 users 存储用户信息,有 uidusernamepassword 字段;表 rankings 存储历次排名,有 uidscoreenroll_datedifficulty 字段。配合合适的 SQL 语句,借助前面的连接句柄和 mysql_query() 函数,我们就实现了后端到数据库的互通。

JSON 解析与合成

客户端与后端之间使用 JSON 作为消息的载体,各种 JSON 消息的格式定义我们已经写在 GitHub 仓库的 README.md 文件之中了。

我们使用 cJSON 作为 JSON 库。简便起见,我们直接将 cJSON 的源文件 cJSON.c 放在 src/utils 下,将 cJSON.h 放在 include/utils 下。cJSON 是一个 ANSI C 的 JSON 库,因此这样使用是不容易出现编译时问题的。

我们希望能将每个 JSON 消息都解析到一个结构体——一个与消息类型相匹配的结构体。例如,对于消息

{
  "type": "game_end",
  "reason": 1,
  "this_score": 1700,
  "teammate_score": 1870
}

我们希望能使用结构体

struct game_end_s {
  int reason;
  int this_score;
  int teammate_score;
};

来构造和解析它。首先我们看 JSON 的解析,由于不同 JSON 消息的区分是看其 type 字段,因此我们先建立一个数组来存放所有的 type

const char *msg_recv_type[] = {
    "user_query",
    "room_info",
    "create_room",
    "join_room",
    "resolution",
    "movement",
    "damage",
    "npc_upload",
    "remove_aircraft",
    "prop_action",
    "game_end_request",
    "get_rankings"
};

在收到一个 JSON 消息时,我们用一个 for 循环来逐一匹配其中的 type 字段。假设一共有 RECV_MSG_CNT 种不同的消息,那么当 for 循环结束时的下标小于 RECV_MSG_CNT 时,即匹配成功;如果下标已经等于 RECV_MSG_CNT 了,那就说明失败了——for 循环已经遍历过最后一项了。

匹配之后,我们自然需要为不同的 JSON 来编写对应的解析函数。我们让所有的「将消息解析到结构体的函数」都具有相同的形参模式:

void user_query(cJSON *json_note(root), void **msg_struct) {
  // 将 JSON 节点 root 解析到消息结构体 *msg_struct
  // 适用于 user_query 类型消息的解析代码
}


void npc_upload(cJSON *json_node(root), void **msg_struct) {
  // 同样,但这是适用于 npc_upload 类型消息的代码
}

然后将这些解析函数的指针放到一个数组里:

typedef void (*msg_handler_t)(cJSON *json_node(root), void **msg_struct);
msg_handler_t msg_handler[] = {
    user_query, room_info, create_room, join_room,
    resolution, movement, damage, npc_upload, remove_aircraft,
    prop_action, game_end_request, get_rankings,
    NULL
};

这样做带来了一个巨大的好处:之前我们不是用 for 循环匹配消息类型吗?匹配成功后,我们得到了这种消息类型在 msg_recv_type[] 里的编号,如果我们让 msg_recv_type[] 中的顺序和 msg_handler[] 里函数指针的顺序一样,那么就可以用

msg_handler[index](root, msg);

来直接解析所有种类的消息!同样的做法也可以类似地运用在消息的合成之中。打开 src/game/msg.c,你就能看到事实上我们也是这样做的。不同的是,我们使用了一些宏定义来简化上面的代码——诸如每个解析函数都是使用宏 def_msg_handler 声明的,事实上 def_msg_handler(pattern) 就是 static inline void pattern(cJSON *json_node(root), void **msg_struct) 这一长串的缩写。

经过上面的一番魔法变换,我们将消息的合成和解析封装成两个函数:

int decode_msg(const char *msg, void **msg_struct);
// 用来解析一则 JSON 消息 msg,将其解析到结构体 msg_struct,同时返回类型下标。
char *encode_msg(const void *msg_struct, int type);
// 用来合成一则 JSON 消息,由 type 指明其消息类型,内容来自于 msg_struct 结构体。

各消息结构体定义在 include/game/msg.h 中,而各消息解析器和合成器则在 src/game/msg.c 中。

网络通信

接着,我们来到后端的深水区——网络通信。前文已经提及,我们使用 libwebsockets 库来实现 WebSocket 通信。作为一个 C 语言的网络库,libwebsockets 采用事件循环的机制——我们注册一个 WebSocket 服务器后,服务器接收到网络事件时,将调用一个回调函数来处理这个事件,随后进入下一轮循环。这个循环定义在 src/server/core.c 中:

int lws_loop() {
  saws_log("Start listening with WebSocket protocol");
  int n = 0;
  while (n >= 0 && !interrupted) {
    n = lws_service(context, 0);
  }
  return n;
}

其中的 lws_service() 函数由 libwebsockets 提供,调用它即等待事件到来。只要返回值非负,就说明可以进入下一次循环。因此,除非手动打断(interrupted 为真)或出错,这个服务器就可以运行下去。

事件发生时,lws_service() 就会调用一个回调函数,把事件的具体细节告诉这个回调函数并让它来处理事件。显然,我们关于消息的处理和游戏逻辑的运转,就是在这个回调函数中进行的。它位于 src/server/main_event.c 中。下面是它的声明:

int callback_event(struct lws *wsi, enum lws_callback_reasons reason,
                   void *user, void *in, size_t len);

简单说明一下它的几个参数。*wsi 是一个代表「连接」的指针,与一个连接挂钩。reason 是一个枚举类型,它标记回调的原因——我 lws_server() 是因为什么调用了你。*user 是代表连接中客户端的指针,而 *in 是消息本身,len 是消息的长度。不难想象这个函数内部的画风——

switch(reason) {
  case 连接启动:
    // 做点啥
    break;
  case 连接断开:
    // 做点啥
    break;
  case 收到消息:
    switch(消息类型) {
      case ...
      case ...
    }
    break;
  // ...
}

事实也的确如此。当然,除了上面这个巨大的 switch-case 之外,还有一些 libwebsockets 工作必需的代码,限于篇幅这里不再赘述。读者可以自行阅读 callback_event() 函数的内容。

游戏逻辑

最后,我们来到了游戏的核心部分——游戏逻辑的实现。首先来看「房间」对象的定义。C 语言原生没有对象的概念,但我们可以用结构体来实现面向对象的思想(Go:这个我熟)。每一个房间都是一个结构体 type_t

typedef struct room_s {
  struct room_s *prev;
  struct room_s *next;
  int room_id;
  bool running;
  
  int host_uid;
  int guest_uid;

  struct per_session_data_saws *host;
  struct per_session_data_saws *guest;
  struct per_vhost_data_saws *vhost;

  int host_width;
  int host_height;
  int guest_width;
  int guest_height;

  int difficulty;

  int npc_cnt;
  aircraft_t *npc_list;

  int prop_id;
  int prop_cnt;
  prop_t *prop_list;

  int host_score;
  int guest_score;
} room_t;

不难发现它就是一个双向链表的一个结点。事实上,不仅是「房间」,NPC 的列表、场上道具的列表都是用双向链表实现的。它们的定义在 include/game 下的各个头文件中。

有了各种游戏内对象的模型,实现游戏逻辑还是难事吗?简化起见,我们将游戏逻辑直接写在了之前那个回调函数 callback_event() 中,记得回过头去看看哦。

不足和收获

因为这一课程周期很短,因此我们并没有对项目进行深入的打磨。整个后端鲁棒性相当差——甚至于前端的错误动作也可能让后端崩溃。很多地方代码写得混乱而复杂,例如游戏逻辑没有从网络事件中抽离。但也正是因为课程周期较短,我们不得不集中精力投入开发,去沉浸在一个项目的实现中。选用 C 而不是其他便于使用的语言来做后端,对我的代码输出能力有着不小的锻炼——尤其是使用的技术栈 libwebsockets 甚至没有多少中文资料的情况下。犹记与队友一同实现一项项功能时的喜悦,也记得向全班展示我们同屏对战的自豪。

不管怎么说,用 C 语言写一门「面向对象」课的后端,想来也觉得很酷,不是吗?😎