游戏直播是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
    • appKey为环信后台项目对应的key, 如何开通可见开通配置环信即时通讯 IM 服务
  4. 运行项目 npm run start

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

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

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

超级社区的逻辑为

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

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

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

Step2 协议设置

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

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

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

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

// views/Channel/index.jsconst isVideoChannel = useMemo(() => {return currentChannelInfo?.name?.startsWith("video-");
}, [currentChannelInfo]);const renderTextChannel = () => {
// 原来的渲染逻辑
return (<><MessageListmessageInfo={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.jsconst 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 channelclient.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: (<divclassName="circleDropItem"onClick={toggleVoice}><Iconname="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);// 远程音视频trackconst [remoteVoices, setRemoteVoices] = useState([]);const [remoteVideo, setRemoteVideo] = useState(null);// RTC相关逻辑useEffect(() => {...// client.join 后// 监听新用户加入client.on("user-published", async (user, mediaType) => {// auto subscribe when users comingawait 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 usersetRemoteUser(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] = useState(firstStreamMessage?.ext?.status === CMD_START_STREAM &&firstStreamMessage?.ext?.user === userInfo?.username);// 本地直播流状态const toggleLocalGameStream = () => {if (hasRemoteStream) {return;}setLocalStreaming(!localStreaming);};// 根据直播状态选择渲染return (<>{!connectStatus && <Spin tip="Loading" size="large" />}{hasRemoteStream ? (<RemoteStreamHandlerremoteUser={firstStreamMessage?.ext?.user}localVideoRef={localVideoEle}channelId={channelId}userInfo={userInfo}rtcClient={rtcClient}/>) : (<LocalStreamHandlerlocalStreaming={localStreaming}canvasRef={canvasEle}toggleLocalGameStream={toggleLocalGameStream}rtcClient={rtcClient}userInfo={userInfo}channelId={channelId}/>)}</>);
}

我们根据hasRemoteStream分成两种逻辑RemoteStreamHandlerLocalStreamHandler(可以先用div+文字的空实现占位), 首先我们来看本地游戏的逻辑

// view/Channel/components/StreamHandler/local_stream.js
const LocalStreamHandler = (props) => {const {toggleLocalGameStream,canvasRef,localStreaming,rtcClient,userInfo,channelId,} = props;const [localVideoStream, setLocalVideoStream] = useState(false);const localPlayerContainerRef = useRef(null);// 开启本地视频流useEffect(() => {if (!localPlayerContainerRef.current) return;const f = async () => {// 暂时使用视频代替游戏流let lgs = await AgoraRTC.createCameraVideoTrack();lgs.play(localPlayerContainerRef.current);setLocalGameStream(lgs);}f();}, [localPlayerContainerRef])const renderLocalStream = () => {return (<div style={{ height: "100%" }} ref={localPlayerContainerRef}></div>)}// 控制上下播const renderFloatButtons = () => {return (<FloatButton.Groupicon={<DesktopOutlined />}trigger="click"style={{ left: "380px" }}><FloatButtononClick={toggleLocalGameStream}icon={localStreaming ? <VideoCameraFilled /> : <VideoCameraOutlined />}tooltip={<div>{localStreaming ? "停止直播" : "开始直播"}</div>}/></FloatButton.Group>);};// 渲染: 悬浮窗和本地流return (<><div style={{ height: "100%" }}>{renderFloatButtons()}{renderLocalStream()}</div></>);
}

现在我们进入直播房间已经可以看到本地摄像头的内容了, 但我们还没有将视频流投放到RTC中, 且上播逻辑也没有处理

// view/Channel/components/StreamHandler/local_stream.jsuseEffect(() => {// 发布直播推流if (!localStreaming || !rtcClient || !localVideoStream) {return;}console.log("height", canvasRef.current.height);console.log("publishing local stream", localVideoStream);// 将流publish到rtc中rtcClient.publish(localVideoStream).then(() => {// 频道中发布一条消息, 表示开始直播sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId).then(() => {message.success({content: "start streaming",});});});return () => {// 用户退出的清理工作,// unpublish流(远程), 停止播放流(本地), 发送直播关闭消息(频道)if (localVideoStream) {rtcClient.unpublish(localVideoStream);localVideoStream.stop();sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);message.info({content: "stop streaming",});}};}, [rtcClient, localStreaming, canvasRef, userInfo, channelId, localVideoStream]);

为了测试直播效果, 我们需要登录第二个账号(使用浏览器的匿名/开其他的浏览器, 此时cookie没有共享, 可以多账号登录), 进入相同频道, 开启直播, 此时第一个账号应该会自动刷新状态(如果没有则手动切换一下频道), 进入到RemoteStreamHandler, 说明我们直播的逻辑已经完成.

本地语音的逻辑也是类似的, 这里就不再重复.

接下来是远程流的渲染逻辑, 它的逻辑相对简单, 观看者可以选择开始/停止观看直播流

// view/Channel/components/StreamHandler/remote_stream.js
const RemoteStreamHandler = (props) => {const {remoteUser,localVideoRef,toggleRemoteVideo,channelId,userInfo,rtcClient,} = props;// 这里加一个强制t人的开关, 由于debugconst enableForceStop = true;const forceStopStream = () => {sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);};const renderRemoteStream = () => {return (<div style={{ height: "100%" }}><divid="remote-player"style={{width: "100%",height: "90%",border: "1px solid #fff",}}ref={localVideoRef}/><divstyle={{display: "flex",justifyContent: "center",marginTop: "10px",}}><span style={{ color: "#0ECD0A" }}>{remoteUser}</span>&nbsp; is playing{" "}</div></div>);};const renderFloatButtons = () => {return (<FloatButton.Groupicon={<DesktopOutlined />}trigger="click"style={{ left: "380px" }}><FloatButtononClick={toggleRemoteVideo}icon={<VideoCameraAddOutlined />}tooltip={<div>观看/停止观看直播</div>}/>{enableForceStop && (<FloatButtononClick={forceStopStream}icon={<VideoCameraAddOutlined />}tooltip={<div>强制停止直播</div>}/>)}</FloatButton.Group>);};return (<><div style={{ height: "100%" }}>{renderFloatButtons()}{renderRemoteStream()}</div></>);
}

开关远程流的代码在StreamHander中, 作为参数传给RemoteStream

// views/Channel/components/StreamHandler/index.jsconst toggleRemoteVideo = () => {if (!hasRemoteStream) {return;}console.log("[Stream] set remote video to ", !enableRemoteVideo);// 当前是关闭状态,需要打开// 开关远程音频的逻辑也与此类型.if (enableRemoteVideo) {remoteVideo?.stop();} else {remoteVideo?.play(localVideoEle.current);}setEnableRemoteVideo(!enableRemoteVideo);};

ok, 现在我们已经实现了基于声网RTC, 在环信超级社区集成视频直播的功能.

直播替换为游戏流

接下来我们来将直播流升级一下, 替换成模拟器包, 为了方便测试, 我们直接使用打包好的版本(https://github.com/a71698422/web-0.1.1 ), pkg包解压后直接放置到项目根目录,

RustNESEmulator 是一个基于Rust语言的NES模拟器, 我们在web平台可以使用它编译好的wasm版本

并将mario.nes文件放到src/assets目录下, 这是初代马里奥游戏的ROM文件(你也可以使用你喜欢的nes游戏, 如果遇到问题, 欢迎到RustNESEmulator中提issue)

加入前端的模拟器适配代码

// views/Channel/components/StreamHandler
// from tetanes.import * as wasm from "@/pkg";
class State {constructor() {this.sample_rate = 44100;this.buffer_size = 1024;this.nes = null;this.animation_id = null;this.empty_buffers = [];this.audio_ctx = null;this.gain_node = null;this.next_start_time = 0;this.last_tick = 0;this.mute = false;this.setup_audio();console.log("[NES]: create state");}load_rom(rom) {this.nes = wasm.WebNes.new(rom, "canvas", this.sample_rate);this.run();}toggleMute() {this.mute = !this.mute;}setup_audio() {const AudioContext = window.AudioContext || window.webkitAudioContext;if (!AudioContext) {console.error("Browser does not support audio");return;}this.audio_ctx = new AudioContext();this.gain_node = this.audio_ctx.createGain();this.gain_node.gain.setValueAtTime(1, 0);}run() {const now = performance.now();this.animation_id = requestAnimationFrame(this.run.bind(this));if (now - this.last_tick > 16) {this.nes.do_frame();this.queue_audio();this.last_tick = now;}}get_audio_buffer() {if (!this.audio_ctx) {throw new Error("AudioContext not created");}if (this.empty_buffers.length) {return this.empty_buffers.pop();} else {return this.audio_ctx.createBuffer(1, this.buffer_size, this.sample_rate);}}queue_audio() {if (!this.audio_ctx || !this.gain_node) {throw new Error("Audio not set up correctly");}this.gain_node.gain.setValueAtTime(1, this.audio_ctx.currentTime);const audioBuffer = this.get_audio_buffer();this.nes.audio_callback(this.buffer_size, audioBuffer.getChannelData(0));if (this.mute) {return;}const source = this.audio_ctx.createBufferSource();source.buffer = audioBuffer;source.connect(this.gain_node).connect(this.audio_ctx.destination);source.onended = () => {this.empty_buffers.push(audioBuffer);};const latency = 0.032;const audio_ctxTime = this.audio_ctx.currentTime + latency;const start = Math.max(this.next_start_time, audio_ctxTime);source.start(start);this.next_start_time = start + this.buffer_size / this.sample_rate;}// ...
}export default State;

改造local_stream

// view/Channel/components/StreamHandler/local_stream.jsimport mario_url from "@/assets/mario.nes";
import * as wasm_emulator from "@/pkg";
import State from "./state";const LocalStreamHandler = (props) => {// 模拟器 stateconst stateRef = useRef(new State());// 注意要将原来的代码注释掉/* const [localVideoStream, setLocalVideoStream] = useState(false);const localPlayerContainerRef = useRef(null);// 开启本地视频流useEffect(() => {if (!localPlayerContainerRef.current) return;const f = async () => {// 暂时使用视频代替游戏流let lgs = await AgoraRTC.createCameraVideoTrack();lgs.play(localPlayerContainerRef.current);setLocalGameStream(lgs);}f();}, [localPlayerContainerRef])// 推流的函数也暂时注释useEffet...*/useEffect(() => {// 本地游戏if (!canvasRef) {return;}// 开启键盘监听等全局事件wasm_emulator.wasm_main();fetch(mario_url, {headers: { "Content-Type": "application/octet-stream" },}).then((response) => response.arrayBuffer()).then((data) => {let mario = new Uint8Array(data);// 加载 rom数据stateRef.current.load_rom(mario);});}, [canvasRef]);// 更新本地流渲染const renderLocalStream = () => {return (<div style={{ height: "100%" }}><canvasid="canvas"style={{ width: 600, height: 500 }}width="600"height="500"ref={canvasRef}/></div>);};
}

这一步完成后, 我们就可以在本地试玩马里奥游戏了, 键盘绑定为

A      = J
B      = K
Select = RShift
Start  = Return
Up     = W
Down   = S
Left   = A
Right  = D

将推本地视频流改为游戏流

  useEffect(() => {// 发布直播推流if (!localStreaming || !rtcClient) {return;}// 只修改了流获取部分// canvas的captureStream接口支持获取视频流// 我们用这个视频流构造一个声网的自定义视频流let stream = canvasRef.current.captureStream(30);let localVideoStream = AgoraRTC.createCustomVideoTrack({mediaStreamTrack: stream.getVideoTracks()[0],});console.log("height", canvasRef.current.height);console.log("publishing local stream", localVideoStream);rtcClient.publish(localVideoStream).then(() => {sendStreamMessage({user: userInfo?.username,status: CMD_START_STREAM,},channelId).then(() => {message.success({content: "start streaming",});});});return () => {if (localVideoStream) {rtcClient.unpublish(localVideoStream);localVideoStream.stop();sendStreamMessage({user: userInfo?.username,status: CMD_END_STREAM,},channelId);message.info({content: "stop streaming",});}};}, [rtcClient, localStreaming, canvasRef, userInfo, channelId]);

最后总结一下房间的流程图

至此该项目的完整流程就算结束啦,如果有哪些步骤细节不太明确, 可以参照完整版项目
环信超级社区项目
注册环信
模拟器直播项目github源码获取

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

  1. (上)小程序从0快速入门到实战项目打造个性简历,让你轻松脱颖而出吸引面试官眼球(附源码)

    前言 分享之前我们先来认识一下小程序,官方定义的微信小程序是一种新的开放能力,开发者可以快速地开发一个小程序.更是一种全新的连接用户与服务的方式,它可以在微信内被便捷地获取和传播,同时具有出色的使用体 ...

  2. 一款免费开源的排课教务管理软件(附源码)

    介绍: 这是一套支持私有化部署的教培行业教务管理系统,专为教培行业提供云化管理解决方案,是一套纯springboot+mysql微服务项目.系统在功能上注重教务管理,具有灵活的排课.消课等核心业务功能 ...

  3. 基于云开发的答题活动小程序v2.0-完整项目分享(附源码)

    简介 答题活动小程序v2.0,是一个微信小程序答题软件,它基于微信原生小程序+云开发实现. 它使用了最新的前端技术栈,具有原生APP体验服务的小程序框架,小程序视图层描述语言 WXML 和 WXSS, ...

  4. Java开发的超级马里奥小游戏410 相对简单 功能非常齐全 完整源码

    今天为大家继续分享泡泡堂小游戏的开发与制作 410,目前系统已经完成了初步功能,后续会进一步完善.整个系统界面漂亮,有完整得源码,希望大家可以喜欢.喜欢的帮忙点赞和关注.一起编程.一起进步!! 开发环 ...

  5. php+mysql+基于PHP MYSQL开发的化妆品店会员管理网站的设计与实现 毕业设计-附源码131102

    PHP化妆品店会员管理系统 摘 要 在经济全球化的今天,建立以客户为中心的经营战略,可以加强企业和客户间的联系.对企业来讲,为客户提供一流的服务,是企业建立品牌.实现差别化服务.取得竞争的利器.一直以 ...

  6. 基于大华SDK的二次开发,用于网络摄像头图像的实时显示和捕获(附源码)

    目录 1. 文章背景 2. 主要模块 2.1 初始化SDK 2.2 登录账号 2.3 捕获图片 2.4 实时显示 2.5 断开时释放 2.6 回调函数 3. 源代码 3.1 头文件 3.2 源文件 1 ...

  7. python数据分析实战项目—运用matplotlib可视化分析10000条北京各大区二手房区域信息(附源码)

    文章目录 开发工具 数据内容 实现代码 运行效果 10000条二手房信息下载地址 总结 开发工具 python版本:Python 3.6.1 python开发工具:JetBrains PyCharm ...

  8. Python数据分析实战-将一维列表和二维列表内容保存到本地excel文件(附源码和实现效果)

    前面我介绍了可视化的一些方法以及机器学习在预测方面的应用,分为分类问题(预测值是离散型)和回归问题(预测值是连续型).同时做了关于图像识别的系列文章,让读者理解python进行图像识别的过程.原理和方 ...

  9. Python数据分析实战-提取DataFrame(Excel)某列(字段)最全操作(附源码和实现效果)

    实现功能: Python数据分析实战-提取DataFrame(Excel)某列(字段)最全操作,代码演示了单列提取和多列提取两种情况,其中单列提取有返回series格式和dataframe两种情况,在 ...

最新文章

  1. java 逻辑校验工具_SpringBoot2.0实战(10)整合fluent-validator优雅业务校验
  2. pandas使用isna函数和any函数计算返回dataframe中包含缺失值的数据行(rows with missing values in dataframe)
  3. 服务器无响应 错误691,错误 691:由于域上的用户名和/密码无效而拒绝访问的原因及解决办法...
  4. 鸿蒙系统8月9日发布,8月9日,华为发布EMUI10.0系统+展示鸿蒙系统
  5. 装了xcode3.2 后再装xcode4.1 出现的问题 永远build不过
  6. 在Windows系统下,Nginx反向代理缓存配置
  7. Java数据类型转换超详解
  8. python没有pygame_教程丨python 做一个简单的MP3
  9. 神策分析1.7重磅上线 三大功能直指精益数据分析
  10. 从“杀猪盘”到杀洋盘,短信里藏了多少套路?
  11. VGA、DVI、HDMI都是什么意思?
  12. 一秒等于多少毫秒_新知|一秒有多长?你以为的“一瞬间”有多快?
  13. 解密新晋信息窃取木马Spymel
  14. hive实现获取当前季度第一天
  15. 广联达产品经理笔试题目
  16. 网站搭建之三(APMServ5.2.6)
  17. 3D Question Answering
  18. C#阿里云oss接口上传和下载文件
  19. RKMPP库快速上手--(二)MPP关键配置
  20. rgb加色模式与cmyk减色模式

热门文章

  1. 课程《网络攻击与防御》期末复习大纲详解【仅供参考】
  2. php反调试,反调试原理
  3. 完全用 Linux 工作
  4. PRL学习(priority roaming list 优先漫游列表) .
  5. 中国计算机学会推荐国际学术刊物
  6. IBM服务器装系统看不到硬盘,IBM SATA硬盘的笔记本安装系统找不到硬盘解决方案...
  7. mysql mapinfo_MapInfo TAB和MIF/MID
  8. C#学习 仿windows计算器 完结
  9. 五种方法限制计算机禁用USB接口
  10. 蛋蛋读NVMe之二: 吉祥三宝