合成大西瓜?一个人玩不如一起聊天放烟花(实现多人聊天放烟花)

打个招呼

大家好~

游戏开发之路有趣但不易,

玩起来才能一直热情洋溢。

我是喜欢游戏开发的海潮:wink:

前言

社交是人的基本需求。

互联网时代,基于互联网的社交带给网民们无穷的欢乐和

那些能够实时交互的社交软件/游戏,往往会带给我们更多惊喜。

最近微信更新了8.0版本,可以在聊天的时候放炸弹,烟花等动态表情。很多人都玩得不亦乐乎~

在这之前呢,我的框架仓库增加了一个独立的网络模块,可以用于构建长连接网络游戏/应用。

特性:

  1. 跨平台:适用于任意ts/js项目

  2. 灵活、高可扩展:可以根据项目需要进行多层次定制

  3. 零依赖

  4. 强类型:基于TypeScript

  5. 功能强大:提供完整的基本需求实现(消息处理、握手、心跳、重连)

  6. 可靠:完善的单元测试

传送门:enet

那接下来,我带大家借助enet库实现

  1. 一个带烟花效果的socket demo(超简单,三步就可以)

  2. 一个接近真实网络游戏开发的多人聊天室demo

玩起来~

极简聊天放烟花

第一步:引入网络库并初始化

enet这个库,发布于npm公共仓库中。提供多种规范,适用于任何平台。

这次我们直接通过url引入iife格式的js

  1. 创建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>

  1. 初始化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”)

第三步:加上烟花效果

烟花效果网上扒来的

快过年了,用JS让你的网页放烟花吧

在原来的代码里改


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

运行起来,看看效果

简单的仿微信聊天放烟花就这样了

在线demo

源码

接下来,我们搞个大的。

多人聊天放烟花

在实际的网络应用开发中,网络通信的需求会复杂许多。

  1. 可能会使用协议包装通信数据进行传输

  2. 可能会对通信数据进行加密

  3. 可能会使用特殊的socket(socket.io),甚至定制socket

  4. 心跳处理

  5. 握手处理

  6. 断线重连处理

enet模块对上述情况都进行了封装,只需要根据提供的接口进行实现就可(无需改源码)

在这个多人聊天室demo中,我将使用protobuf作为通信协议。

为什么使用protobuf?

什么是protobuf

protobuf(Google Protocol Buffers)是Google提供一个具有高效的协议数据交换格式工具库(类似Json),但相比于Json,Protobuf有更高的转化效率,时间效率和空间效率都是JSON的3-5倍。

protobuf提供了多种编程语言的支持:C++,JAVA,Python,C#,erlang等

优势

  • 可以快速玩起来,统一的协议语言可以和多种后端语言快乐地玩起来,甚至多人sport​:crazy_face::woman:t6::handshake::adult:t6::two_men_holding_hands::man:t5::handshake::man:t3:

  • 不用自己设计协议和实现协议编解码

:eyes:来看看如何接入protobuf

使用protobuf

虽然不用自己设计协议,但怎么接入开发中还是需要滴

常见的protobuf使用方式

  1. 使用protobufjs库加载proto文件,然后进行协议编码解码

  2. 使用protobuf工具将proto文件转成js文件+.d.ts声明文件,在项目中同时引入protobuf库和导出的js文件就可

我这里选择第二种方案

  • 优点:使用方便,适用于多种环境,有类型声明

  • 缺点: 会使js包体大些

为了方便协议的导出,我用自己开发的一个protobuf工具:egf-protobuf

  1. 安装工具到全局或者项目目录

    
    npm install egf-protobuf -g
    
    或者
    
    npm install -S egf-protobuf
    
    
  2. 在package.json写一下npm script

    
    "scripts": {
    
        "genPb": "egf-pb g",
    
        "pbInit": "egf-pb i"
    
    }
    
    
  3. 初始化项目 npm run pbInit

  4. 创建proto文件目录protofiles

  5. 写协议 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;
    
    }
    
    
  6. 修改一下导出配置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,所以也配置了后端文件导出路径(前后端同时导出=双倍的快乐:crossed_fingers:t2::v:t2:)

    
    /**服务端输出配置 */
    
    serverOutputConfig: {
    
        /**protobufjs库输出目录 */
    
        pbjsLibDir: "egf-net-ws-server/libs",
    
        /**生成的proto js文件输出 */
    
        pbjsOutDir: "egf-net-ws-server/protojs",
    
        /**声明文件输出路径 */
    
    dtsOutDir: "egf-net-ws-server/libs"
    
    
    
    }
    
    
  7. 导出js和.d.ts

    
    npm run genPb
    
    
  8. 项目中引入protobufjs库和proto_bundle.js

    1. CocosCreator需要将它们设置为插件

    2. 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;

}

举个栗子:chestnut:

我需要使用protobuf协议进行通信

那我就实现接口写一个protobuf协议处理器。

比如:egf-pbws

简单两步用起来(☞゚ヮ゚)☞

  1. 安装egf-pbws

    
    npm i egf-pbws
    
    
  2. 和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个例子

  1. testcases/websocket-test 纯使用websocket+控制台打印的方式的例子

  2. testcases/simple-test enet简单使用版本,没对协议层进行定制

  3. 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-protobufenet-pbws可轻松实现基于protobuf协议的多人聊天室应用

由于篇幅有限,有些功能没有讲到

  • 自定义握手处理

  • 自定义socket层

  • 自定义网络反馈层(比如:发送请求就弹出请求中遮罩,请求结束自动关闭遮罩等)

  • 心跳处理

  • 重连处理

后续将分享一下,如何设计enet

最后

我是喜欢游戏开发的海潮:wink:

持续学习,持续up,分享游戏开发心得,玩转游戏开发

游戏开发之路有趣但不易,

玩起来才能一直热情洋溢。

欢迎关注我的公众号,更多内容持续更新

公众号搜索:玩转游戏开发

或扫码: img

QQ 群: 1103157878

博客主页: https://ailhc.github.io/

掘金: https://juejin.cn/user/3069492195769469

github: https://github.com/AILHC

12赞

火钳留名。。

一种聊天的消息类型,根据类型处理,还可以发红包

1赞

mark 。

还可以自定义各种红包封面

大佬流啤。mark一下

mark
.

Mark ,感谢大佬