B站直播间弹幕互动小游戏制作(上)

B站直播间弹幕互动小游戏制作(上)

不久之前做了一个直播间弹幕互动小游戏的开发框架,不过一直没有把相关的内容记录下来,也没有使用这个框架完成一部完整的、具有游戏性的小游戏,所以正好忙里偷闲整理一篇博文,顺带做一款大地图的多人扫雷。

本篇主要对框架的技术设计进行阐述,游戏逻辑的编写在下一篇文章再做介绍;

项目背景

什么是直播间弹幕互动游戏
通俗来讲,就是一个运行于主播电脑上的程序,可以获取当前直播间的弹幕信息,并将弹幕信息转化为程序消息事件,从而让正在直播的游戏做出对应的响应,达到互动的效果;
这类游戏可以是具有观赏性的,如修狗舞厅
修狗舞厅.jpg
也可以是多人策略类的,如植物大战僵尸
植物大战僵尸.jpg
考虑到弹幕有延迟,并且指令有限,所以实时性较强的游戏或者操作难度较高的游戏,如众多FPS游戏,就不适合做成这种弹幕互动的游戏形式(尽管如此,还是有勇士尝试过直播间合力通关MC,又或者是著名社会学实验Twitch Plays Pokemon);
这类游戏的实现可以是已有的成品游戏,只需要通过程序将弹幕信息转换为已经预制好的键鼠操作;也可以是高度定制化开发的游戏程序,游戏系统在设计之初便摒弃了单机模式下的键鼠操作,取而代之的是将玩家输入事件系统完全绑定在直播间的弹幕消息上;
在已了解到的互动游戏制作工具中,要么功能不完善性能不稳定,要么简陋闭源有夹带私货的嫌疑,故决定自己动手制作这个不大不小的轮子;
考虑到已有的技术积淀(如EtherEngine),直接采取后者策略反而会具有更小的工作量,于是乎,DanmuGame互动游戏开发框架应运而生。

其实对于模拟键鼠操作在已有成品游戏上操作的设计,目前的DanmuGame框架也已经完成了大半工作量,由于逻辑脚本使用Lua进行编写,有能力的开发者可以使用C扩展模块轻松调用系统API实现鼠标键盘的模拟操作(或许也会有非系统API的键鼠模拟轮子供Lua调用,待论证)

设计总览

设计总览.png
框架的主体部分可以简单地划分为前后端:

  • DanmuUtil作为框架的后端,负责从B站直播间获取弹幕信息等数据,并通过本地端口转发给前端游戏框架;
  • DanmuGame游戏框架作为前端,负责加载并运行游戏脚本,并根据后端传来的数据将游戏画面参照脚本逻辑渲染到窗口上,以方便被直播姬等录制工具采集;

除此之外还有一个设计简陋的本地调试工具,方便脱离B站直播间环境对DanmuGame游戏框架的脚本逻辑进行调试,以代替DanmuUtil的作用,工具运行截图如下:
调试工具运行截图.png

DanmuUtil转发工具设计

DanmuUtil使用Node.js编写,在发布版本中,使用pkg将Node.js的运行环境和逻辑脚本打包到了一起,方便无Node环境的小伙伴使用;
核心代码如下:

const live = new LiveWS(config_json.room)

live.on('open', () => __Log(0, 'Connection is established!'));

live.on('live', () => {
    __Log(0, 'Successfully logged in to the room.');
    live.on('DANMU_MSG', (data) => {
        let _url = util.format("http://127.0.0.1:%d/message", config_json.port);
        request({
            url: _url,
            method: "POST",
            json: true,
            headers: {
                "content-type": "application/json",
            },
            body: JSON.stringify({
                username : data.info[2][1],
                message: data.info[1]
            })
        }, function(error, response, body) {
            if (!error && response.statusCode == 200)
                __Log(0, util.format("Successfully forwarded data to [%s]", _url));
            else
                __Log(1, util.format("Failed to forward data to [%s]", _url));
        });
        __Log(0, util.format("[%s] %s", data.info[2][1], data.info[1]));
    });
    live.on('INTERACT_WORD', (data) => {
        let _url = util.format("http://127.0.0.1:%d/enter", config_json.port);
        request({
            url: _url,
            method: "POST",
            json: true,
            headers: {
                "content-type": "application/json",
            },
            body: JSON.stringify({
                username : data.data.uname
            })
        }, function(error, response, body) {
            if (!error && response.statusCode == 200)
                __Log(0, util.format("Successfully forwarded data to [%s]", _url));
            else
                __Log(1, util.format("Failed to forward data to [%s]", _url));
        });
        __Log(0, util.format("Welcome [%s] to the room!", data.data.uname));
    });
    live.on('close', () => __Log(-1, 'Room disconnected!'));
})

在这里特别感谢bilibili-live-ws项目,此项目提供了成熟稳健的B站直播间弹幕获取的解决方案,给DanmuGame框架的开发提供了极大的便利!

DanmuGame游戏主框架设计

DanmuGame主框架是一个简单的游戏引擎,通过内置的Lua虚拟机加载并执行开发者编写的Lua脚本逻辑,并通过SDL2全家桶实现了游戏的画面渲染和音媒体播控,C层面的API实现大多拷贝自我的另一个项目EtherEngineRefactor
DanmuGame主框架的启动和执行逻辑如下:

  1. 初始化SDL2等相关底层库,并加载配置文件,解析其中关于窗口大小、标题和最大帧率等信息;
  2. 使用cpp-httplib在新线程创建服务端对象,用以监听接收本地的前端转发程序DanmuUtil发送的直播间弹幕信息;
  3. 创建Lua虚拟机,开启标准库并将先前封装好的CAPI压入虚拟机环境,执行逻辑脚本文件使用户自定义的回调函数注册到虚拟机环境中;
  4. 进入游戏主循环,逐帧调用渲染回调函数,并在合适的时机调用用户定义的弹幕消息回调函数,从而实现直播间弹幕消息的逻辑控制;
  5. 游戏主循环退出时,关闭服务器对象,并回收服务端线程,依次关闭Lua虚拟机和SDL2等相关库,释放资源。

通过SDL2的事件系统自定义用户事件,并且通过事件队列实现跨线程通信;
项目代码结构较为复杂,考虑到篇幅和阅读体验不方便选择性展示,感兴趣的小伙伴可以直接去项目仓库查看;
Lua脚本中可以使用的API,以及回调函数的定义规范和调用规则等问题,在项目的README文档末尾给出了详细的介绍,具体查看项目主页。

本地调试工具设计

本地调试工具存在的目的就是为了避免游戏开发阶段在直播间调试程序社死;
此工具是整个框架中最没有神秘感的一部分,仅仅是通过cpp-httplib库创建客户端对象,以替代DanmuUtil向DanmuGame框架监听的端口发送模拟消息,GUI使用imgui配合SDL2后端实现,通信线程甚至和GUI线程放到了一起(主要是考虑到本地端口通信正常情况下效率较高,是不会出现耗时久卡死的情况,一言以蔽之,懒);
核心代码如下:

if (ImGui::Button("   发送   "))
        {
            // event - ENTER
            if (!item_current)
            {
                cJSON* pJSON = cJSON_CreateObject();
                cJSON_AddItemToObject(pJSON, "username", cJSON_CreateString(strUsername));
                // strRawJSON like this: {"username":"Voidmatrix"}
                char* strRawJSON = cJSON_PrintUnformatted(pJSON);
                std::string strJSON = strRawJSON;
                std::string::size_type szPos = -2;
                while ((szPos = strJSON.find('\"', szPos + 2)) != std::string::npos)
                    strJSON.replace(szPos, 1, "\\\"");
                strJSON = std::string("\"").append(strJSON).append("\"");
                // current strJSON like this: "{\"username\":\"Voidmatrix\"}"
                auto response = client.Post("/message", strJSON, "application/json");
                if (!response || response->status != 200)
                {
                    SDL_ShowSimpleMessageBox(
                        SDL_MESSAGEBOX_WARNING,
                        "Send Data Failed",
                        ("Reason: " + httplib::to_string(response.error())).c_str(),
                        g_pWindow
                    );
                }
                cJSON_Delete(pJSON); pJSON = nullptr;
                free(strRawJSON); strRawJSON = nullptr;
            }
            // event - MESSAGE
            else
            {
                cJSON* pJSON = cJSON_CreateObject();
                cJSON_AddItemToObject(pJSON, "username", cJSON_CreateString(strUsername));
                cJSON_AddItemToObject(pJSON, "message", cJSON_CreateString(strMessage));
                // process raw json string like before
                char* strRawJSON = cJSON_PrintUnformatted(pJSON);
                std::string strJSON = strRawJSON;
                std::string::size_type szPos = -2;
                while ((szPos = strJSON.find('\"', szPos + 2)) != std::string::npos)
                    strJSON.replace(szPos, 1, "\\\"");
                strJSON = std::string("\"").append(strJSON).append("\"");
                auto response = client.Post("/message", strJSON, "application/json");
                if (response->status != 200)
                {
                    SDL_ShowSimpleMessageBox(
                        SDL_MESSAGEBOX_WARNING,
                        ("Reason: " + httplib::to_string(response.error())).c_str(),
                        httplib::to_string(response.error()).append(" : ")
                        .append(std::to_string(response->status)).c_str(),
                        g_pWindow
                    );
                }
                cJSON_Delete(pJSON); pJSON = nullptr;
                free(strRawJSON); strRawJSON = nullptr;
            }
        }

写在最后

关于游戏内容的开发,会在下篇文章进行讲述;
到GitHub上赏颗星⭐吧!