直播弹幕互动游戏

简介

弹幕互动游戏 是近年来在游戏(误:直播)行业中越来越受到欢迎的游戏形式。这种游戏通过收集玩家的弹幕信息,将其实时显示在游戏画面中,增加了互动性和趣味性,在抖音、B站等直播平台,目前已经有很多高人气的弹幕互动类游戏。其中既有第三方开发的也有平台自身研发的。

image


特点

弹幕互动游戏最大的特点就是弹幕互动。传统的游戏模式往往是单向的,玩家只是被动地接受游戏的内容。而弹幕互动游戏则不同,玩家可以在游戏中发射弹幕,通过与其他玩家互动,增加了游戏的趣味性和互动性。此外,弹幕互动游戏还具有以下特点:多样化的游戏模式、实时互动的体验、全球玩家的互动等。


游戏模式多样化:

  1. 玩家阵营对抗,用户通过弹幕选择阵营,生成AI小兵做阵营对抗,点赞 or 消费不同金额的礼物可以获取额外的优势(氪金外挂),帮助己方阵营获胜。eg.抖音《森林派对》

image

  1. 玩家与主播同阵营,对抗第三方:AI角色/障碍,
  2. 玩家对抗主播:玩家通过弹幕生产AI角色/障碍来阻碍主播获得胜利。eg。《是兄弟就来砍我》

image

  1. 主播间的对抗,结合2、3玩法,玩家可以选择帮助自己支持的直播间,给同阵营主播提供帮助,给对方阵营主播使绊子,以达到己方获胜的目的。如果说1、2、3更多像一个单机游戏,那么4更像一个网游。
  2. 主播授权给观众操作传统游戏角色的权利,有点儿偏向于社会学实验的性质,操作难度极高,最早是在国外游戏直播平台twitch上出现,代表案例:累计有超过百万名观众通过弹幕参与通关神奇宝贝,后来国内主播也有效仿之作,比如B站的万人原神:

https://www.bilibili.com/video/BV1xQ4y1Q7CU/?vd_source=13a87a9b97c2b7b5b32c8f91714ede90


实时又不“实时”


传统游戏直播模式,

以玩家作为信息的接收方为主,部分主播会制定自己的私人规则,来提升玩家的参与度,比如:

  1. 礼物贡献高的玩家可以直接参与游戏(多人网游场景

image

  1. 主播阅读弹幕互动,“谢谢xxx送的xxxx” 🔥

互动弹幕游戏模式

虽然弹幕互动游戏声称自己是实时的,但是直播弹幕互动实际上是高延迟的一个操作。具体体现在几个阶段:

  1. 用户收到主播游戏画面的延迟
  2. 主播延迟收到用户弹幕
  3. 弹幕作用于游戏的效果再通过直播流延迟播放给用户

用户完成一次弹幕交互,至少需要3次通信,而且是远远滞后的。

image

这就限制了弹幕互动游戏的种类,高实时操作性的游戏,在弹幕互动场景下变成了hard模式,这个一会我们可以体验一下。


我们来试着整一个直播弹幕互动游戏玩一下

几个要素

  • 直播
  • 弹幕
  • 游戏
  • 弹幕和游戏的连接

我们以B站为例


直播


使用b站的官方直播软件:(目前不兼容非M1的mac)

哔哩哔哩直播姬下载

实际上,B站自己的直播端在直播游戏这个场景不太好用,亲测同设备的情况下,为满足直播+游戏性能,清晰度很低画质很烂。


OBS+第三方插件 + b站直播服务器地址和推流码

在B站开启直播间后,可以在个人中心:我的直播间,看到服务器地址和推流码

image

在obs的直播设置中填写服务器地址和推流码

Untitled

OBS 提供了捕捉

  • 窗口
  • 网页
  • 屏幕

等能力,所以我们可以采取的方案可以有

  • 自己开发一个网页,在网页中订阅弹幕+控制游戏,OBS捕捉该网页
  • 自己开发一个弹幕订阅+ 执行native方法的软件, native游戏【客户端同学可以尝试一下

下面分别说一下弹幕和游戏的part

弹幕


b站开放了主播直播端的插件开发开放平台

哔哩哔哩直播开放平台

开发者可以

  1. 申请开发密钥
  2. 开发互动应用
  3. 上架B站的商城

哔哩哔哩饭贩


有了密钥之后

获取直播间弹幕数据

image

B站给开发这提供了获取直播间数据的流程和demo代码。

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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122

import asyncio
import json
import websockets
import requests
import time
import hashlib
import hmac
import random
from hashlib import sha256
import proto

class BiliClient:
def __init__(self, roomId, key, secret, host = 'live-open.biliapi.com'):
self.roomId = roomId
self.key = key
self.secret = secret
self.host = host
pass

# 事件循环
def run(self):
loop = asyncio.get_event_loop()
websocket = loop.run_until_complete(self.connect())
tasks = [
asyncio.ensure_future(self.recvLoop(websocket)),
asyncio.ensure_future(self.heartBeat(websocket)),
]
loop.run_until_complete(asyncio.gather(*tasks))

# http的签名
def sign(self, params):
key = self.key
secret = self.secret
md5 = hashlib.md5()
md5.update(params.encode())
ts = time.time()
nonce = random.randint(1,100000)+time.time()
md5data = md5.hexdigest()
headerMap = {
"x-bili-timestamp": str(int(ts)),
"x-bili-signature-method": "HMAC-SHA256",
"x-bili-signature-nonce": str(nonce),
"x-bili-accesskeyid": key,
"x-bili-signature-version": "1.0",
"x-bili-content-md5": md5data,
}

headerList = sorted(headerMap)
headerStr = ''

for key in headerList:
headerStr = headerStr+ key+":"+str(headerMap[key])+"\n"
headerStr = headerStr.rstrip("\n")

appsecret = secret.encode()
data = headerStr.encode()
signature = hmac.new(appsecret, data, digestmod=sha256).hexdigest()
headerMap["Authorization"] = signature
headerMap["Content-Type"] = "application/json"
headerMap["Accept"] = "application/json"
return headerMap

# 获取长链信息
def websocketInfoReq(self, postUrl, params):
headerMap = self.sign(params)
r = requests.post(url=postUrl, headers=headerMap, data=params, verify=False)
data = json.loads(r.content)
print(data)
return "ws://" + data['data']['host'][0]+":"+str(data['data']['ws_port'][0])+"/sub", data['data']['auth_body']

# 长链的auth包
async def auth(self, websocket, authBody):
req = proto.Proto()
req.body = authBody
req.op = 7
await websocket.send(req.pack())
buf = await websocket.recv()
resp = proto.Proto()
resp.unpack(buf)
respBody = json.loads(resp.body)
if respBody["code"] != 0:
print("auth 失败")
else:
print("auth 成功")

# 长链的心跳包
async def heartBeat(self, websocket):
while True:
await asyncio.ensure_future(asyncio.sleep(20))
req = proto.Proto()
req.op = 2
await websocket.send(req.pack())
print("[BiliClient] send heartBeat success")

# 长链的接受循环
async def recvLoop(self, websocket):
print("[BiliClient] run recv...")
while True:
recvBuf = await websocket.recv()
resp = proto.Proto()
resp.unpack(recvBuf)

async def connect(self):
postUrl = "https://%s/v1/common/websocketInfo"%self.host
params = '{"room_id":%s}'%self.roomId
addr, authBody = self.websocketInfoReq(postUrl, params)
print(addr, authBody)
websocket = await websockets.connect(addr)
await self.auth(websocket, authBody)
return websocket

if __name__=='__main__':
try:
cli = BiliClient(
roomId = 23105976,
key = "",
secret = "",
host = "live-open.biliapi.com")
cli.run()
except Exception as e:
print("err", e)

参考这个流程那么互动弹幕的核心逻辑就是:

  1. 获取ws地址端口
  2. 建立ws链接
  3. 收发数据
  4. 解析出弹幕
  5. 执行游戏指令/操作

我们可以看一下效果

image

这里演示的是开源项目https://github.com/xfgryujk/blivechat的本地python服务器,这里就是实现了上述流程(mock版本)

如果我们把room ID换成B站线上正在开播的直播间ID,同样可以抓到弹幕信息。

好,弹幕我们已经搞到了,下一步,选择游戏

游戏

这里为了对比出效果,我选择了两类游戏, 实时操作类 和非实时解谜类,代表作


网页版红白机游戏

网页版红白机游戏的基本原理

  • JavaScript NES 模拟器
  • 加载 .nes 文件
  • 注册事件监听
  • 循环frameTicker
    • 使用canvas渲染每一帧
    • audioContext播放音频采样

我们以模拟器https://github.com/bfirsh/jsnes 为例

核心使用代码:

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
// 实例化NES模拟器
this.nes = new NES({
onFrame: this.screen.setBuffer, // canvas
onStatusUpdate: console.log,
onAudioSample: this.speakers.writeSample, // 音频
sampleRate: this.speakers.getSampleRate()
});

// 事件
this.gamepadController = new GamepadController({
onButtonDown: this.nes.buttonDown,
onButtonUp: this.nes.buttonUp
});

this.keyboardController = new KeyboardController({
onButtonDown: this.gamepadController.disableIfGamepadEnabled(
this.nes.buttonDown
),
onButtonUp: this.gamepadController.disableIfGamepadEnabled(
this.nes.buttonUp
)
});

// Load keys from localStorage (if they exist)
this.keyboardController.loadKeys();
document.addEventListener("keydown", this.keyboardController.handleKeyDown);
document.addEventListener("keyup", this.keyboardController.handleKeyUp);
document.addEventListener(
"keypress",
this.keyboardController.handleKeyPress
);

// 加载.nes:ROM
this.nes.loadROM(this.props.romData);

其Web UI

image

好我们目前至少跑起来了一个游戏了,下一步


如何把游戏跟弹幕连接起来

一个思路:解析弹幕执行游戏指令

红白机游戏的游戏内只有6个控制键

  • up
  • down
  • left
  • right
  • A
  • B

游戏外当然还有start\pause等(暂时先不管

在js的NES 模拟器中,这些控制键被映射成为了键盘的的按键

image

我们要做的就是

  • 收到用户’wasd’弹幕
  • 解析出来每个字符
  • 模拟触发浏览器的键盘事件
  • 控制游戏

遇到了第一个问题:

为了方便插拔游戏,我把游戏加载在iframe中,遇到了iframe跨域问题,无法获取iframe的内容窗口并派发键盘事件,这个解决方案非常常见就是使用postMessage

在弹幕订阅页:

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
51
52
53
import KEY_MAP from '../keyboard'
/**
* 忍者神龟4等NES游戏
*/

const delay = sec => new Promise(resolve => setTimeout(resolve, sec))

export default class TurtleTrigger {
constructor() {
this.reg = /([A-Za-z0-9])/g
// iframe
this.dom = document.getElementById('iframeContain').contentWindow
this.processing = false
}

// 发送模拟键盘事件给iframe
_run = async key => {
const evtOpt = KEY_MAP[key.toUpperCase()]
this.dom.postMessage({ key: 'keydown', opt: evtOpt }, "*")
return new Promise(resolve => {
setTimeout(() => {
this.dom.postMessage({ key: 'keyup', opt: evtOpt }, "*")
resolve()
}, 100)
})
}

// 弹幕处理函数
process = async danmu => {
if (this.processing) {
console.log('trigger proccessing')
return
}
this.processing = true

// 正则把字母提取出来
const matched = danmu.match(this.reg)
console.log('matched', matched)
if (!matched) {
this.processing = false
return false
}
// console.log('run matched', matched)

// 逐一执行
for (const value of matched) {
await this._run(value)
await delay(30)
}
this.processing = false
}
}

在NES游戏页面

1
2
3
4
5
6
7
8
9
componentDidMount() {
window.addEventListener('message', e => {
console.log('msg=====',e.data)
const { key ,opt} = e.data
const evt = new KeyboardEvent(key, opt)
document.dispatchEvent(evt)
})
}


超级玛丽

这个游戏遇到了一个问题,超级玛丽中,长按和短按事有不同效果的

  • 短按jump,跳得矮
  • 长按jump:跳得高
  • 短按方向键:短暂拥有一下下加速度
  • 长按方向键:一直加速到最高

而游戏中关卡被设计得是必须长按才能过去的,因此这里处理弹幕到时候,得实现长按效果

image

思路:

合并相同key,延长按压时间

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
51
52

// 发送模拟键盘事件给iframe
_run = async(key, duration = 1) => {
const evtOpt = KEY_MAP[key.toUpperCase()]
this.dom.postMessage({ key: 'keydown', opt: evtOpt }, "*")
return new Promise(resolve => {
setTimeout(() => {
this.dom.postMessage({ key: 'keyup', opt: evtOpt }, "*")
resolve()
// 可调节按压时长
}, duration * 100)
})
}

// 合并相同按键
sumSame(chars) {
const bucket = []
let temp = {
key: chars[0],
count: 1
}
let i = 1
while (i <= chars.length - 1) {
if (temp.key === chars[i]) {
temp.count++
} else {
bucket.push(temp)
temp = {
key: chars[i],
count: 1
}
}
i++
}
bucket.push(temp)
console.log(bucket)
return bucket
}

process = async danmu => {
// ...
if (this.mergeSameKey) {
const sum = this.sumSame(matched)
for (const value of sum) {
console.log(value)
await this._run(value.key, value.count)
await delay(30)
}
}
// ...
}


扫雷

模式是类似的

  • ws订阅
  • 弹幕提取
  • 发消息给游戏窗口

不同的点在于

image

扫雷的操作方式:

  • 左键点击坐标:翻开
  • 右键点击格子:插旗

这里如果转化为弹幕操作我们需要提取三个数据

  • 操作类型: left or right
  • x坐标
  • y坐标

首先设定弹幕格式为4部分,

  • 首字符L或者R,表示操作类型
  • 数字表示x坐标
  • 空格,用来分割两个数字
  • 数字表示y坐标
1
2
3
L0 0
R0 1

那么整体的代码流程就很清晰了


代码

弹幕订阅器

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

/**
* 扫雷
*/

export default class SweeperTrigger {
constructor(props) {
this.reg = /^([lLRr])([0-9]+)\s([0-9]+)/
this.dom = document.getElementById('iframeContain').contentWindow
this.processing = false
this.mergeSameKey = (props && props.mergeSameKey) || false
}

_run = async(key, x, y) => {
console.log('_run', key, x, y)
this.dom.postMessage({ key: key, opt: [x, y] }, "*")
}

process = async danmu => {
if (this.processing) {
console.log('trigger proccessing')
return
}
this.processing = true

const matched = this.reg.exec(danmu)
console.log('matched', danmu, matched)
// 非法过滤
if (!matched
|| matched.length !== 4
|| !['L', 'l', 'R', 'r'].includes(matched[1])
|| isNaN(parseInt(matched[2]))
|| isNaN(parseInt(matched[3]))) {
console.log('非法指令')
this.processing = false
return false
}

await this._run(matched[1].toUpperCase(), parseInt(matched[2]), parseInt(matched[3]))

this.processing = false
}
}

游戏页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19


componentDidMount() {
window.addEventListener('message', (e) => {
const { key, opt } = e.data
if (!['L', 'R'].includes(key)) return
console.log('msg=====', e.data)
// 边界检测
if (opt[0] < 0 || opt[0] > this.props.rowNum || opt[1] < 0 || opt[1] > this.props.rowNum) {
return
}
// 左键右键
if (key === 'L') {
this.handleSquareClick(opt[1], opt[0])
} else {
this.handleSquareContextMenu(opt[1], opt[0])
}
})
}

总结&展望

web的互动游戏可以分为三层结构

  • 订阅层:处理ws等链接
  • Trigger 层: 根据每个游戏设定的规则来处理弹幕转化为游戏指令
  • 游戏层:接受指令,执行操作,渲染游戏

未来发展中,可以探索的几个方向

  • 抽离专门的Trigger编辑器,支持自定义规则
  • 开发Native Trigger,接入单机游戏尝试
  • 针对直播弹幕场景专门设计游戏:既有的游戏无法很好的适配弹幕互动长江,导致很多体验是有问题的
  • 互动游戏的平衡性设计
    • 多人参与感,而不是头部用户
    • 避免金钱至上,保证直播间流量