实战:一天开发一款内置游戏直播的国产版Discord应用附源码

Posted 环信即时通讯云

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了实战:一天开发一款内置游戏直播的国产版Discord应用附源码相关的知识,希望对你有一定的参考价值。

游戏直播是Discord产品的核心功能之一,本教程教大家如何1天内开发一款内置游戏直播的国产版Discord应用,用户不仅可以通过IM聊天,也可以进行语聊,看游戏直播,甚至自己进行游戏直播,无任何实时音视频底层技术的Web开发者同样适用,效果如下:

开整!

Step1 初始化

本项目基于环信超级社区的实例项目, 所以我们先从Circle-Demo-Web这个仓库开启做初始化

  1. 克隆项目 git clone https://github.com/easemob/Circle-Demo-Web.git
  2. 安装依赖 npm install
  3. 设置appKey src/utils/WebIM.js 中设置appKey,AppKey为环信后台项目对应的key,注册环信,https://console.easemob.com/user/register,登录console后台获取Appkey
  4. 运行项目 npm run start

运行后, 登录完毕效果如下,

与discord设计逻辑相似, 左边功能区有

  • 个人信息页
  • 好友会话页
  • 当前加入的频道
  • 创建新频道
  • 加入服务器

超级社区的逻辑为

社区(Server)、频道(Channel) 和子区(Thread) 三层结构

社区为一个独立的结构, 不同社区直接相互隔离, 社区包含不同的频道, 代表了不同的话题, 用户在频道中聊天, 而针对一条单独信息产生的回复为子区.

我们本次的项目主要集中在频道部分, 需要加入一个服务器后, 创建一个测试社区, 保证你具有管理员权限.

Step2 协议设置

我们的目标是尽量利用现有api扩展功能, 有几个问题需要解决

  1. 如何区分普通频道和游戏频道?
  2. 如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?
  3. 多人聊天的状态?

如何区分普通频道和游戏频道

这里直接简单采用频道前缀做特殊区分, 创建频道前缀带video-的识别为游戏频道, 同时将渲染内容做替换

// views/Channel/index.js


const isVideoChannel = useMemo(() => 
	return currentChannelInfo?.name?.startsWith("video-");
, [currentChannelInfo]);

const renderTextChannel = () => 
// 原来的渲染逻辑
return (
  <>
	<MessageList
	  messageInfo=messageInfo
	  channelId=channelId
	  handleOperation=handleOperation
	  className=s.messageWrap
	/>
	<div className=s.iptWrap>
	  <Input chatType=CHAT_TYPE.groupChat fromId=channelId />
	</div>
  </>
);


const renderStreamChannel = () => 
// 先填充一个占位符
return (
	<>This is a Stream Channel<>
);


return (
	...
	<div className=s.contentWrap>
		isVideoChannel ? renderStreamChannel() : renderTextChannel()
	</div>
	...
);

如果需要区分图标, 可以搜索channelNameWrap, 分别在channelItemChannel/components/Header中添加一个css类, 通过这个类设置图标图片

如何区分当前频道是否有玩家直播, 如果有直播如何获取玩家信息, 第二玩家的状态?

我们可以复用在频道中发送消息的机制, 直播开始, 结束都可以当做一条特殊的消息发送, 只不过这条消息不承载用户的信息, 而是表达用户上下播的行为

当然这个机制存在一定实时性的问题, 不过大致是可行的.

首先我们来看一条普通的消息是如何发送的

  // components/input

 //发消息
  const sendMessage = useCallback(() => 
    if (!text) return;
    getTarget().then((target) => 
      let msg = createMsg(
        chatType,
        type: "txt",
        to: target,
        msg: convertToMessage(ref.current.innerhtml),
        isChatThread: props.isThread
      );
      setText("");
      deliverMsg(msg).then(() => 
        if (msg.isChatThread) 
          setThreadMessage(
            message:  ...msg, from: WebIM.conn.user ,
            fromId: target
          );
         else 
          insertChatMessage(
            chatType,
            fromId: target,
            messageInfo: 
              list: [ ...msg, from: WebIM.conn.user ]
            
          );
          scrollBottom();
        
      );
    );
  , [text, props, getTarget, chatType, setThreadMessage, insertChatMessage]);

去除掉与输入框逻辑耦合的部分, 可以分为两步, createMsg创建消息, deliverMsg发送消息, 这两个功能都是环信SDK功能的封装, 经过查阅文档, 它支持发送自定义消息.
在utils中新建一个stream.js文件来封装直播的逻辑

// utils/stream.js
const sendStreamMessage = (content, channelId) => 
  let msg = createMsg(
    chatType: CHAT_TYPE.groupChat,
    type: "custom",
    to: channelId,
    ext: 
      type: "stream",
      ...content,
    ,
  );
  return deliverMsg(msg)
    .then(() => 
      console.log("发送成功");
    )
    .catch(console.error);
;

它接收content表示我们的额外信息, 用户名和上下播状态, channelId区分不同的channel, 对它的调用可以如下

// 定义在 utils/stream.js 中
const CMD_START_STREAM = "start";
const CMD_END_STREAM = "end";

// 上播
sendStreamMessage(
	
	  user: userInfo?.username,
	  status: CMD_START_STREAM,
	,
	channelId
);
// 下播
sendStreamMessage(
  
	user: userInfo?.username,
	status: CMD_END_STREAM,
  ,
  channelId
);

第二玩家的状态可以类比第一个玩家用额外的自定义消息实现, 这里不做重复.

关于自定义消息, 原本它的作用是邀请用户加入频道, 你可以在components/CustomMsg中找到, 我们要额外识别一下直播消息(可以渲染在消息列表里, 也可以直接屏蔽掉).

// components/CustomMsg/index.js
const isStream = message?.ext?.type === "stream";


// 屏蔽
const renderStream = () => 
return (<>)

if (isStream) 
	return renderStream();
 else 
	...

多人聊天的状态?

我们引入声网RTC sdk, 每个进入直播房间的用户都对应维护一个声网客户端,
通过on事件感知远端视频/音频流.

根据文档 进行如下操作,

  1. 注册声网开发者, 并在后台创建一个测试项目
  2. 项目根目录创建.env文件, 存放api token等信息
# channel, uid 暂时设置为固定
REACT_APP_AGORA_APPID = your app id
REACT_APP_AGORA_CHANNEL = test 
REACT_APP_AGORA_TOKEN = your token
REACT_APP_AGORA_UID = 123xxx
  1. 添加声网sdk依赖 npm install agora-rtc-sdk-ng

我们在下一章中编写接入逻辑

声网RTC接入, 直播与语音实现

接入

views/Channel/components文件夹下新增一个组件StreamHandler, 该组件为后续我们处理游戏房间的组件, 先初步编写声网接入逻辑

// views/Channel/components/StreamHandler/index.js

const options = 
  appId:
    process.env.REACT_APP_AGORA_APPID || "default id",
  channel: process.env.REACT_APP_AGORA_CHANNEL || "test",
  token:
    process.env.REACT_APP_AGORA_TOKEN ||
    "default token",
  uid: process.env.REACT_APP_AGORA_UID || "default uid",
;

const StreamHandler =  (props) => 
  // 组件参数: 用户信息, 当前频道所有消息, 当前频道id, 是否开启本地语音
  const  userInfo, messageInfo, channelId, enableLocalVoice = false  = props;

  const [rtcClient, setRtcClient] = useState(null);
  // 声网client连接完成
  const [connectStatus, setConnectStatus] = useState(false);

  // RTC相关逻辑
  useEffect(() => 
    AgoraRTC.setLogLevel(3);
    const client = AgoraRTC.createClient( mode: "rtc", codec: "vp8" );
    // TODO: use right channel
    client
      .join(options.appId, options.channel, options.token, userInfo?.username)
      .then(() => 
        setConnectStatus(true);
        console.log("[Stream] join channel success");
      )
      .catch((e) => 
        console.log(e);
      );

    setRtcClient(client);
    return () => 
      // 销毁时, 自动退出RTC频道
      client.leave();
      setRtcClient(null);
    ;
  , []);

  return (
	<>
	  !connectStatus && <Spin tip="Loading" size="large" />
	</>
  );



// 我们需要全局状态中的userinfo, 映射一下到当前组件的props中
const mapStateToProps = ( app ) => 
  return 
    userInfo: app.userInfo,
  ;
;
export default memo(connect(mapStateToProps)(StreamHandler));

然后回到Channel中, 在之前的renderStreamChannel函数中添加上StreamHandler组件

// view/Channel/index.js
const [enableVoice, setEnableVoice] = useState(false);
const toggleVoice = () => 
	setEnableVoice((enable) => 
	  return !enable;
	);


// 保留了输入窗口, 可以在它的菜单栏中添加游戏频道独有的一些逻辑, 
// 这里我加入了开关本地语音的逻辑, 拓展Input的细节可以参照完整版代码
const renderStreamChannel = () => 
    return (
      <>
        <div className=s.messageRowWrap>
		  <StreamHandler messageInfo=messageInfo channelId=channelId enableLocalVoice=enableVoice />
        </div>
        <div className=s.iptWrap>
          <Input chatType=CHAT_TYPE.groupChat fromId=channelId extraMenuItems=renderStreamMenu() />
        </div>
      </>
    );
  

const renderStreamMenu = () => 
    return [
      
        key: "voice",
        label: (
          <div
            className="circleDropItem"
            onClick=toggleVoice
          >
            <Icon
              name="person_wave_slash"
              size="24px"
              iconClass="circleDropMenuIcon"
            />
            <span className="circleDropMenuOp">
              enableVoice ? "关闭语音" : "开启语音"
            </span>
          </div>
        ),
      
    ];
  

此时我们创建一个video-开题的游戏频道, 应该可以看到命令行中输出了RTC连接成功信息. [Stream] join channel success

音视频推流

接下来我们继续做实质的RTC推流逻辑, 及用户上下播的入口. 但在那之前, 先简单过一下声网RTC中的一些概念.

参考以下步骤实现音视频通话的逻辑:

  1. 调用 createClient 方法创建 AgoraRTCClient 对象。
  2. 调用 join 方法加入一个 RTC 频道,你需要在该方法中传入 App ID 、用户 ID、Token、频道名称。
  3. 先调用 createMicrophoneAudioTrack 通过麦克风采集的音频创建本地音频轨道对象,调用 createCameraVideoTrack 通过摄像头采集的视频创建本地视频轨道对象;然后调用 publish 方法,将这些本地音视频轨道对象当作参数即可将音视频发布到频道中。
  4. 当一个远端用户加入频道并发布音视频轨道时:
  5. 监听 client.on(“user-published”) 事件。当 SDK 触发该事件时,在这个事件回调函数的参数中你可以获取远端用户 AgoraRTCRemoteUser 对象 。
  6. 调用 subscribe 方法订阅远端用户 AgoraRTCRemoteUser 对象,获取远端用户的远端音频轨道 RemoteAudioTrack 和远端视频轨道 RemoteVideoTrack 对象。

(以上内容来自声网官方文档)

在上面的接入中, 我们已经完成了创建对象并加入频道两步.
在RTC中, 可以传输音频和视频信号, 由于单个RTC客户端要传输不同种类的数据, 每个单独的音视频源被分成不同的track(由于它们都是实时不断产生的, 我们称作流), 随后通过publish方法, 将我们本地的信号源交付给RTC客户端传输.
随后通过user-published事件的回调来在其他用户发布信号源时进行处理, 首先需要subscribe该用户来获取后续数据, 随后根据不同类型的信号流做处理.
离开时需要关闭本地当前的信号源, 并退出RTC客户端.
最后通过user-unpublished事件监听其他用户退出, 移除它们对应的信号流.

逻辑理清楚后代码就很容易看懂了

// views/Channel/components/StreamHandler/index.js
const StreamHandler = (props) => 
  ...
  // 本地视频元素
  const localVideoEle = useRef(null);
  // 远程视频元素
  const canvasEle = useRef(null);
  const [rtcClient, setRtcClient] = useState(null);
  const [connectStatus, setConnectStatus] = useState(false);
  // 当前直播的用户
  const [remoteUser, setRemoteUser] = useState(null);
  // 远程音视频track
  const [remoteVoices, setRemoteVoices] = useState([]);
  const [remoteVideo, setRemoteVideo] = useState(null);

  // RTC相关逻辑
  useEffect(() => 
    ...
    // client.join 后

	// 监听新用户加入
    client.on("user-published", async (user, mediaType) => 
      // auto subscribe when users coming
      await client.subscribe(user, mediaType);
      console.log("[Stream] subscribe success on user ", user);
      if (mediaType === "video") 
        // 获取直播流
        if (remoteUser && remoteUser.uid !== user.uid) 
          // 只能有一个用户推视频流
          console.error(
            "already in a call, can not subscribe another user ",
            user
          );
          return;
        
        // 播放并记录下视频流
        const remoteVideoTrack = user.videoTrack;
        remoteVideoTrack.play(localVideoEle.current);
        setRemoteVideo(remoteVideoTrack);
        // can only have one remote video user
        setRemoteUser(user);
      
      if (mediaType === "audio") 
        // 获取音频流
        const remoteAudioTrack = user.audioTrack;
        // 去重
        if (remoteVoices.findIndex((item) => item.uid === user.uid) == -1) 
		  remoteAudioTrack.play();
          // 添加到数组中
          setRemoteVoices([
            ...remoteVoices,
             audio: remoteAudioTrack, uid: user.uid ,
          ]);
        
      
    );

    client.on("user-unpublished", (user) => 
      // 用户离开, 去除流信息
      console.log("[Stream] user-unpublished", user);
      removeUserStream(user);
    );
    setRtcClient(client);
    return () => 
      client.leave();
      setRtcClient(null);
    ;
  , []);

  const removeUserStream = (user) => 
    if (remoteUser && remoteUser.uid === user.uid) 
      setRemoteUser(null);
      setRemoteVideo(null);
    
    setRemoteVoices(remoteVoices.filter((voice) => voice.uid !== user.uid));
  ;

接着我们根据之前提到的自定义消息判断当前在播状态, 以最后一条自定义消息为准.

// views/Channel/components/StreamHandler/index.js
const StreamHandler = (props) => 
  const  userInfo, messageInfo, channelId, enableLocalVoice = false  = props;

  // 第一条 stream 消息, 用于判断直播状态
  const firstStreamMessage = useMemo(() => 
    return messageInfo?.list?.find(
      (item) => item.type === "custom" && item?.ext?.type === "stream"
    );
  , [messageInfo]);
  
  // 是否有直播
  const hasRemoteStream =
    firstStreamMessage?.ext?.status === CMD_START_STREAM &&
    firstStreamMessage?.ext?.user !== userInfo?.username;
  // 本地直播状态
  const [localStreaming, setLocalStreaming]以上是关于实战:一天开发一款内置游戏直播的国产版Discord应用附源码的主要内容,如果未能解决你的问题,请参考以下文章

斗鱼TV安卓版下载|斗鱼TV安卓版免费下载

今年第4款国产游戏登上Steam热销榜,这次是一款恐怖独立游戏

副业赚钱 | 休闲游戏开发

Steam近2万在线,国产独游《归家异途2》怎么样?

ios和安卓版:归家异途~破解版

cocos2d-x 3D实战开发一款体素游戏--1. 准备工作