打个招呼
大家好~
游戏开发之路有趣但不易,
玩起来才能一直热情洋溢。
我是喜欢游戏开发的海潮
前言
社交是人的基本需求。
互联网时代,基于互联网的社交带给网民们无穷的欢乐和瓜。
那些能够实时交互的社交软件/游戏,往往会带给我们更多惊喜。
最近微信更新了8.0版本,可以在聊天的时候放炸弹,烟花等动态表情。很多人都玩得不亦乐乎~
在这之前呢,我的框架仓库增加了一个独立的网络模块,可以用于构建长连接网络游戏/应用。
特性:
-
跨平台:适用于任意ts/js项目
-
灵活、高可扩展:可以根据项目需要进行多层次定制
-
零依赖
-
强类型:基于TypeScript
-
功能强大:提供完整的基本需求实现(消息处理、握手、心跳、重连)
-
可靠:完善的单元测试
传送门:enet
那接下来,我带大家借助enet库实现
-
一个带烟花效果的socket demo(超简单,三步就可以)
-
一个接近真实网络游戏开发的多人聊天室demo
玩起来~
极简聊天放烟花
第一步:引入网络库并初始化
enet这个库,发布于npm公共仓库中。提供多种规范,适用于任何平台。
这次我们直接通过url引入iife格式的js
- 创建html文件,引入enet库
<!DOCTYPE html>
<html>
<div id="container"></div>
<script src="https://cdn.jsdelivr.net/npm/@ailhc/enet@1.0.0/dist/iife/lib/index.js"></script>
</body>
</html>
- 初始化enet
<script>
var netNode = new enet.NetNode();
//定制网络事件反馈逻辑
netNode.init({
netEventHandler: {
//开始连接事件
onStartConnenct: () => {
console.log(`开始连接服务器`);
},
//连接成功事件
onConnectEnd: () => {
console.log(`连接服务器成功👌`);
}
}
});
</script>
第二步: 写上收发消息的逻辑
就几句代码,so easy~
<script>
//省略初始化逻辑..
//连上一个公用的websocket测试服务器,它会原本不动的返回发出的消息
netNode.connect("wss://echo.websocket.org/");
window.netNode = netNode;
//封装发送消息逻辑,相当于微信发送按钮
window.sendMsgToServer = function (msg) {
if (!netNode.socket.isConnected) {
console.warn(`服务器还没连上`);
return;
}
netNode.notify("msg", msg);
}
//监听服务器消息返回
netNode.onPush("msg", function (dpkg) {
console.log(`服务器返回:`, dpkg.data);
})
</script>
这个时候,我们就可以运行看看效果了
等待服务器连接成功(因为那个公用的测试服务器有时慢有时快)
在控制台输入 sendMsgToServer(“hello enet”)
第三步:加上烟花效果
烟花效果网上扒来的
在原来的代码里改
<script>
//省略
window.sendMsgToServer = function (msg) {
/**省略*/
checkAndFire(msg, true);
}
netNode.onPush("msg", function (dpkg) {
console.log(`服务器返回:`, dpkg.data);
checkAndFire(dpkg.data, false);
})
function checkAndFire(msg, left) {
if (msg.includes("烟花") | msg.includes("🎇")) {
fire(window.innerWidth * (left ? 1 / 3 : 2 / 3), window.innerHeight / 2);
}
}
</script>
运行起来,看看效果
简单的仿微信聊天放烟花就这样了
接下来,我们搞个大的。
多人聊天放烟花
在实际的网络应用开发中,网络通信的需求会复杂许多。
-
可能会使用协议包装通信数据进行传输
-
可能会对通信数据进行加密
-
可能会使用特殊的socket(socket.io),甚至定制socket
-
心跳处理
-
握手处理
-
断线重连处理
enet模块对上述情况都进行了封装,只需要根据提供的接口进行实现就可(无需改源码)
在这个多人聊天室demo中,我将使用protobuf作为通信协议。
为什么使用protobuf?
什么是protobuf
protobuf(Google Protocol Buffers)是Google提供一个具有高效的协议数据交换格式工具库(类似Json),但相比于Json,Protobuf有更高的转化效率,时间效率和空间效率都是JSON的3-5倍。
protobuf提供了多种编程语言的支持:C++,JAVA,Python,C#,erlang等
优势
-
可以快速玩起来,统一的协议语言可以和多种后端语言快乐地玩起来,甚至多人sport
-
不用自己设计协议和实现协议编解码
来看看如何接入protobuf
使用protobuf
虽然不用自己设计协议,但怎么接入开发中还是需要滴
常见的protobuf使用方式
-
使用protobufjs库加载proto文件,然后进行协议编码解码
-
使用protobuf工具将proto文件转成js文件+.d.ts声明文件,在项目中同时引入protobuf库和导出的js文件就可
我这里选择第二种方案
-
优点:使用方便,适用于多种环境,有类型声明
-
缺点: 会使js包体大些。
为了方便协议的导出,我用自己开发的一个protobuf工具:egf-protobuf
-
安装工具到全局或者项目目录
npm install egf-protobuf -g 或者 npm install -S egf-protobuf
-
在package.json写一下npm script
"scripts": { "genPb": "egf-pb g", "pbInit": "egf-pb i" }
-
初始化项目 npm run pbInit
-
创建proto文件目录protofiles
-
写协议 pb_base.proto
package pb_test; message User { required uint32 uid = 1; required string name = 2; } //登录请求 message Cs_Login { required string name = 1; } //登录返回 message Sc_Login { required uint32 uid = 1; repeated User users = 2; } //用户进来推送 message Sc_userEnter { required User user = 1; } //用户离开推送 message Sc_userLeave { required uint32 uid = 2; } //消息结构 message ChatMsg { required uint32 uid = 1; required string msg = 2; } //客户端发送消息 message Cs_SendMsg { required ChatMsg msg = 1; } //服务器推送消息 message Sc_Msg { required ChatMsg msg =1; }
-
修改一下导出配置protobuf/epbconfig.js
/**.proto 文件夹路径 */ sourceRoot: "protofiles",//指向创建的proto文件目录 /**输出js文件名 */ outFileName: "proto_bundle", /**生成js的输出路径 */ outputDir: "egf-ccc-net-ws/assets/protojs",//客户端js文件输出目录 /**声明文件输出路径 */ dtsOutDir: "egf-ccc-net-ws/libs",//客户端声明文件输出目录
ps:由于后端用ts,所以也配置了后端文件导出路径(前后端同时导出=双倍的快乐
)
/**服务端输出配置 */ serverOutputConfig: { /**protobufjs库输出目录 */ pbjsLibDir: "egf-net-ws-server/libs", /**生成的proto js文件输出 */ pbjsOutDir: "egf-net-ws-server/protojs", /**声明文件输出路径 */ dtsOutDir: "egf-net-ws-server/libs" }
-
导出js和.d.ts
npm run genPb
-
项目中引入protobufjs库和proto_bundle.js
-
CocosCreator需要将它们设置为插件
-
nodejs项目,需要使用require加载它们
require("../libs/protobuf.js"); require("../protojs/proto_bundle.js");
-
这样就可以在业务里愉快地使用protobuf来进行协议的编码解码了
//编码
const uint8arr = pb_test.ChatMsg.encode({ msg: "hello world", uid: 1 }).finish();
//解码
const msg: pb_test.IChatMsg = pb_test.ChatMsg.decode(uint8arr);
//结果: { msg: "hello world", uid: 1 }
将enet和protobuf结合起来
enet中如果需要自定义协议处理则需要实现enet.IProtoHandler接口
interface IProtoHandler<ProtoKeyType = any> {
/**
* 协议key转字符串key
* @param protoKey
*/
protoKey2Key(protoKey: ProtoKeyType): string;
/**
* 编码数据包
* @param pkg
* @param useCrypto 是否加密
*/
encodePkg<T>(pkg: enet.IPackage<T>, useCrypto?: boolean): NetData;
/**
* 编码消息数据包
* @param msg 消息包
* @param useCrypto 是否加密
*/
encodeMsg<T>(msg: enet.IMessage<T, ProtoKeyType>, useCrypto?: boolean): NetData;
/**
* 解码网络数据包,
* @param data
*/
decodePkg<T>(data: NetData): IDecodePackage<T>;
/**
* 心跳配置
*/
heartbeatConfig: enet.IHeartBeatConfig;
}
举个栗子
我需要使用protobuf协议进行通信
那我就实现接口写一个protobuf协议处理器。
比如:egf-pbws
简单两步用起来(☞゚ヮ゚)☞
-
安装egf-pbws
npm i egf-pbws
-
和enet结合
import { NetNode } from "@ailhc/enet"; import { PbProtoHandler } from "@ailhc/enet-pbws"; const netMgr = new NetNode<string>(); this._net = netMgr; //将协议编解码对象注入 我这里是pb_test const protoHandler = new PbProtoHandler(pb_test); netMgr.init({ netEventHandler: this, protoHandler: protoHandler })
准备工作做好了,开始写客户端
CocosCreator2.4.2实现多人聊天客户端
这个客户端项目中写了3个例子
-
testcases/websocket-test 纯使用websocket+控制台打印的方式的例子
-
testcases/simple-test enet简单使用版本,没对协议层进行定制
-
testcases/protobuf-test protobuf协议定制版(今天的主角)
由于篇幅有限,UI组件的实现就不讲了,都是很简单的实现,具体可以直接看源码
传送门:聊天客户端实现
核心逻辑实现
const { ccclass, property } = cc._decorator;
import { NetNode } from "@ailhc/enet";
import { PbProtoHandler } from "@ailhc/enet-pbws";
import MsgPanel from "../../comps/msgPanel/MsgPanel";
@ccclass
export default class ProtobufNetTest extends cc.Component implements enet.INetEventHandler {
//省略
private _uid: number;
userMap: { [key: number]: string } = {};
private _userName: string;
onLoad() {
const netMgr = new NetNode<string>();
this._net = netMgr;
const protoHandler = new PbProtoHandler(pb_test);
netMgr.init({
netEventHandler: this,
protoHandler: protoHandler
})
//监听消息推送
netMgr.onPush<pb_test.ISc_Msg>("Sc_Msg", { method: this.onMsgPush, context: this });
//监听用户进来
netMgr.onPush<pb_test.ISc_userEnter>("Sc_userEnter", { method: this.onUserEnter, context: this });
//监听用户离开
netMgr.onPush<pb_test.ISc_userLeave>("Sc_userLeave", { method: this.onUserLeave, context: this });
}
/**
* 连接服务器
*/
connectSvr() {
this._net.connect("ws://localhost:8181");
}
/**
* 登录服务器
*/
loginSvr() {
let nameStr = this.nameInputEdit.string;
if (!nameStr || !nameStr.length) {
nameStr = "User";
}
this._net.request<pb_test.ICs_Login, pb_test.ISc_Login>("Cs_Login", { name: nameStr }, (dpkg) => {
if (!dpkg.errorMsg) {
this._userName = nameStr;
this._uid = dpkg.data.uid;
const users = dpkg.data.users;
if (users && users.length) {
for (let i = 0; i < users.length; i++) {
const user = users[i];
this.userMap[user.uid] = user.name;
}
}
this.hideLoginPanel();
this.showChatPanel();
}
})
}
/**
* 发送消息
*/
sendMsg() {
const msg = this.msgInputEdit.string;
if (!msg) {
console.error(`请输入消息文本`)
return;
}
this.msgInputEdit.string = "";
this._net.notify<pb_test.ICs_SendMsg>("Cs_SendMsg", { msg: { uid: this._uid, msg: msg } })
}
//用户进来处理
onUserEnter(dpkg: enet.IDecodePackage<pb_test.ISc_userEnter>) {
if (!dpkg.errorMsg) {
const enterUser = dpkg.data.user;
this.userMap[enterUser.uid] = enterUser.name;
this.msgPanelComp.addMsg({ name: "系统", msg: `[${enterUser.name}]进来了` });
} else {
console.error(dpkg.errorMsg);
}
}
//用户离开处理
onUserLeave(dpkg: enet.IDecodePackage<pb_test.ISc_userLeave>) {
if (!dpkg.errorMsg) {
if (this.userMap[dpkg.data.uid]) {
const leaveUserName = this.userMap[dpkg.data.uid];
this.msgPanelComp.addMsg({ name: "系统", msg: `[${leaveUserName}]离开了` });
delete this.userMap[dpkg.data.uid];
}
} else {
console.error(dpkg.errorMsg);
}
}
//消息下发处理
onMsgPush(dpkg: enet.IDecodePackage<pb_test.ISc_Msg>) {
if (!dpkg.errorMsg) {
const svrMsg = dpkg.data.msg;
let userName: string;
let isSelf: boolean;
if (this._uid === svrMsg.uid) {
userName = "我";
isSelf = true;
} else if (this.userMap[svrMsg.uid]) {
userName = this.userMap[svrMsg.uid];
} else {
console.error(`没有这个用户:${svrMsg.uid}`)
}
if (userName) {
const msgData = { name: userName, msg: svrMsg.msg }
//判断是否放烟花
this.checkAndFire(svrMsg.msg, isSelf);
this.msgPanelComp.addMsg(msgData);
}
} else {
console.error(dpkg.errorMsg);
}
}
//#region 遮罩提示面板
public showMaskPanel() {
if (!this.maskPanel.active) this.maskPanel.active = true;
if (!isNaN(this._hideMaskTimeId)) {
clearTimeout(this._hideMaskTimeId);
}
}
public updateMaskPanelTips(tips: string) {
this.maskTips.string = tips;
}
private _hideMaskTimeId: number;
public hideMaskPanel() {
this._hideMaskTimeId = setTimeout(() => {
this.maskPanel.active = false;
}, 1000) as any;
}
//#endregion
//#region 连接面板
showConnectPanel() {
this.connectPanel.active = true;
}
hideConnectPanel() {
this.connectPanel.active = false;
}
//#endregion
//#region 登录面板
showLoginPanel() {
this.loginPanel.active = true;
}
hideLoginPanel() {
this.loginPanel.active = false;
}
//#endregion
//#region 聊天面板
showChatPanel() {
this.chatPanel.active = true;
}
hideChatPanel() {
this.chatPanel.active = false;
}
//#endregion
onStartConnenct?(connectOpt: enet.IConnectOptions<any>): void {
this.showMaskPanel()
this.updateMaskPanelTips("连接服务器中");
}
onConnectEnd?(connectOpt: enet.IConnectOptions<any>): void {
this.updateMaskPanelTips("连接服务器成功");
this.hideMaskPanel();
this.showLoginPanel();
}
//判断并放烟花
checkAndFire(msg: string, left: boolean) {
if (msg.includes("烟花") || msg.includes("🎇")) {
window.fire(window.innerWidth * 1 / 2 + (left ? -100 : 100), window.innerHeight / 2);
}
}
//省略。。。
}
烟花效果代码实现
//烟花代码,稍微修改一下
(function () {
var cdom = document.createElement("canvas");
cdom.id = "myCanvas"; cdom.style.position = "fixed"; cdom.style.left = "0"; cdom.style.top = "0";
cdom.style.zIndex = 1; document.body.appendChild(cdom); var canvas = document.getElementById('myCanvas'); var context = canvas.getContext('2d');
cdom.style.background = "rgba(255,255,255,0)"//背景透明
cdom.style.pointerEvents = "none";//让这个canvas的点击穿透
function resizeCanvas() {
canvas.width = window.innerWidth; canvas.height = window.innerHeight;
}
window.addEventListener('resize', resizeCanvas, false); resizeCanvas(); clearCanvas();
function clearCanvas() {
// context.fillStyle = '#000000';
// context.fillRect(0, 0, canvas.width, canvas.height);
}
var rid;
window.fire = function fire(x, y) {
createFireworks(x, y); function tick() { context.globalCompositeOperation = 'destination-out'; context.fillStyle = 'rgba(0,0,0,' + 10 / 100 + ')'; context.fillRect(0, 0, canvas.width, canvas.height); context.globalCompositeOperation = 'lighter'; drawFireworks(); rid = requestAnimationFrame(tick); } cancelAnimationFrame(rid); tick();
}
var particles = [];
function createFireworks(sx, sy) {
particles = []; var hue = Math.floor(Math.random() * 51) + 150; var hueVariance = 30; var count = 100; for (var i = 0; i < count; i++) { var p = {}; var angle = Math.floor(Math.random() * 360); p.radians = angle * Math.PI / 180; p.x = sx; p.y = sy; p.speed = (Math.random() * 5) + .4; p.radius = p.speed; p.size = Math.floor(Math.random() * 3) + 1; p.hue = Math.floor(Math.random() * ((hue + hueVariance) - (hue - hueVariance))) + (hue - hueVariance); p.brightness = Math.floor(Math.random() * 31) + 50; p.alpha = (Math.floor(Math.random() * 61) + 40) / 100; particles.push(p); }
}
function drawFireworks() {
clearCanvas(); for (var i = 0; i < particles.length; i++) {
var p = particles[i]; var vx = Math.cos(p.radians) * p.radius; var vy = Math.sin(p.radians) * p.radius + 0.4; p.x += vx; p.y += vy; p.radius *= 1 - p.speed / 100; p.alpha -= 0.005; context.beginPath(); context.arc(p.x, p.y, p.size, 0, Math.PI * 2, false); context.closePath();
context.fillStyle = 'hsla(' + p.hue + ', 100%, ' + p.brightness + '%, ' + p.alpha + ')'; context.fill();
}
}
// document.addEventListener('mousedown', mouseDownHandler, false);
})();
界面效果图
node+TypeScript实现简易后端
我最熟悉node,而且可以共用enet-pbws的这个protobuf协议处理库
就几行代码
import WebSocket = require("ws")
import config from "./config";
import { PackageType, PbProtoHandler } from "@ailhc/enet-pbws";
//引入protobuf库
require("../libs/protobuf.js");
//引入转译后的protojs文件
require("../protojs/proto_bundle.js");
import { } from "@ailhc/enet"
export class App {
private _svr: WebSocket.Server;
private _clientMap: Map<number, ClientAgent>;
private _uid: number = 1;
public protoHandler: PbProtoHandler;
constructor() {
this.protoHandler = new PbProtoHandler(global.pb_test)
const wsvr = new WebSocket.Server({ port: config.port });
this._svr = wsvr;
this._clientMap = new Map();
wsvr.on('connection', (clientWs) => {
console.log('client connected');
this._clientMap.set(this._uid, new ClientAgent(this, this._uid, clientWs));
this._uid++;
});
wsvr.on("close", () => {
});
console.log(`服务器启动:监听端口:${config.port}`);
}
sendToAllClient(data: enet.NetData) {
this._clientMap.forEach((client) => {
client.ws.send(data);
})
}
sendToOhterClient(uid: number, data: enet.NetData) {
this._clientMap.forEach((client) => {
if (client.uid !== uid) {
client.ws.send(data);
}
})
}
sendToClient(uid: number, data: enet.NetData) {
const client = this._clientMap.get(uid);
client.ws.send(data);
}
onUserLogin(user: pb_test.IUser, reqId: number) {
const users: pb_test.IUser[] = [];
const encodeData = this.protoHandler.encodeMsg<pb_test.Sc_Login>({ key: "Sc_Login", data: { uid: user.uid, users: users }, reqId: reqId });
this.sendToClient(user.uid, encodeData);
const enterEncodeData = this.protoHandler.encodeMsg<pb_test.Sc_userEnter>({ key: "Sc_userEnter", data: { user: user } })
this.sendToOhterClient(user.uid, enterEncodeData);
}
}
//客户端代理
export class ClientAgent {
private loginData: pb_test.ICs_Login;
constructor(public app: App, public uid: number, public ws: WebSocket) {
ws.on('message', this.onMessage.bind(this));
ws.on("close", this.onClose.bind(this));
ws.on("error", this.onError.bind(this));
}
public get user(): pb_test.IUser {
return { uid: this.uid, name: this.loginData.name };
}
private onMessage(message) {
if (typeof message === "string") {
//TODO 字符串处理
} else {
//protobuf处理
const dpkg = this.app.protoHandler.decodePkg(message);
if (dpkg.errorMsg) {
console.error(`解析客户端uid:${this.uid}消息错误:`, dpkg.errorMsg);
return;
}
if (dpkg.type === PackageType.DATA) {
this[dpkg.key] && this[dpkg.key](dpkg)
}
}
}
private Cs_Login(dpkg: enet.IDecodePackage<pb_test.Cs_Login>) {
this.loginData = dpkg.data;
this.app.onUserLogin(this.user, dpkg.reqId);
}
private Cs_SendMsg(dpkg: enet.IDecodePackage<pb_test.Cs_SendMsg>) {
const encodeData = this.app.protoHandler.encodeMsg<pb_test.Sc_Msg>({ key: "Sc_Msg", data: dpkg.data });
this.app.sendToAllClient(encodeData);
}
private onError(err: Error) {
console.error(err);
}
private onClose(code: number, reason: string) {
console.error(`${this.uid} 断开连接:code${code},reason:${reason}`);
const leaveEncodeData = this.app.protoHandler.encodeMsg<pb_test.Sc_userLeave>({ key: "Sc_userLeave", data: { uid: this.uid } })
this.app.sendToOhterClient(this.uid, leaveEncodeData);
}
}
(new App())
开启多人Sport聊天
启动项目
-
初始化项目
在/examples/egf-net-ws目录,打开终端
npm install
如果有yarn则可以
yarn install
-
启动服务器(还是在刚刚的目录下)
npm run star-svr 或者 npm run dev_svr
服务器启动成功:
服务器启动:监听端口:8181
-
启动客户端:用CocosCreator2.4.2打开项目
最终效果
一起聊天放烟花
总结
-
第一个demo,借助enet通过简单的几句代码就可以实现socket收发消息
-
第二个demo,借助enet以及egf-protobuf和enet-pbws可轻松实现基于protobuf协议的多人聊天室应用
由于篇幅有限,有些功能没有讲到
-
自定义握手处理
-
自定义socket层
-
自定义网络反馈层(比如:发送请求就弹出请求中遮罩,请求结束自动关闭遮罩等)
-
心跳处理
-
重连处理
后续将分享一下,如何设计enet
最后
我是喜欢游戏开发的海潮
持续学习,持续up,分享游戏开发心得,玩转游戏开发
游戏开发之路有趣但不易,
玩起来才能一直热情洋溢。
欢迎关注我的公众号,更多内容持续更新
公众号搜索:玩转游戏开发
或扫码:
QQ 群: 1103157878
博客主页: https://ailhc.github.io/
掘金: https://juejin.cn/user/3069492195769469
github: https://github.com/AILHC