Topppy / hexo-blog

my hexo new blog
https://topppy.github.io/hexo-blog/
2 stars 0 forks source link

直播弹幕互动游戏 #82

Open Topppy opened 1 year ago

Topppy commented 1 year ago

简介

弹幕互动游戏 是近年来在游戏(误:直播)行业中越来越受到欢迎的游戏形式。这种游戏通过收集玩家的弹幕信息,将其实时显示在游戏画面中,增加了互动性和趣味性,在抖音、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模式,这个一会我们可以体验一下。


Topppy commented 1 year ago

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

几个要素

我们以B站为例


直播


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

[哔哩哔哩直播姬下载](https://live.bilibili.com/liveHime?source=activity)

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


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

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

image

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

Untitled

OBS 提供了捕捉

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

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

弹幕


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

[哔哩哔哩直播开放平台](https://open-live.bilibili.com/)

开发者可以

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

[哔哩哔哩饭贩](https://play-live.bilibili.com/)


有了密钥之后

获取直播间弹幕数据

image

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


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,同样可以抓到弹幕信息。

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

Topppy commented 1 year ago

游戏

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


网页版红白机游戏

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


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

核心使用代码:

// 实例化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个控制键

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

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

image

我们要做的就是


遇到了第一个问题:

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

在弹幕订阅页:

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游戏页面

componentDidMount() {
    window.addEventListener('message', e => {
      console.log('msg=====',e.data)
      const { key ,opt} = e.data
      const evt = new KeyboardEvent(key, opt)
      document.dispatchEvent(evt)
    })
  }

超级玛丽

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

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

image

思路:

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


// 发送模拟键盘事件给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)
      }
    }
        // ...
  }

扫雷

模式是类似的

不同的点在于

image

扫雷的操作方式:

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

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

L0 0
R0 1

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


代码

弹幕订阅器


/**
 * 扫雷
 */

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

游戏页


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的互动游戏可以分为三层结构

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