最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 一起聊天吃瓜放烟花-仿微信聊天放烟花特效

    正文概述 掘金(AILHC)   2021-01-28   1142

    打个招呼

    大家好~

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

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

    我是喜欢游戏开发的海潮?

    前言

    社交是人的基本需求。

    互联网时代,基于互联网的社交带给网民们无穷的欢乐和一起聊天吃瓜放烟花-仿微信聊天放烟花特效

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

    最近微信更新了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

    优势

    • 可以快速玩起来,统一的协议语言可以和多种后端语言快乐地玩起来,甚至多人sport???‍?‍?????‍?‍??

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

    ?来看看如何接入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,所以也配置了后端文件导出路径(前后端同时导出=双倍的快乐??✌?)

      /**服务端输出配置 */
      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;
    }
    

    举个栗子?

    我需要使用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

    最后

    我是喜欢游戏开发的海潮?

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

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

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

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

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

    QQ 群: 1103157878

    博客主页: ailhc.github.io/

    掘金: juejin.cn/user/306949…

    github: github.com/AILHC


    起源地下载网 » 一起聊天吃瓜放烟花-仿微信聊天放烟花特效

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元