合成大西瓜?不如写个可以一起聊天吃瓜放烟花的聊天室

打个招呼

大家好~

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

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

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

前言

社交是人的基本需求。

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

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

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

img

第三步:加上烟花效果

烟花效果网上扒来的
快过年了,用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>

运行起来,看看效果

img

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

在线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🤪👩🏿‍🤝‍🧑🏿👬👨🏾‍🤝‍👨🏼

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

👀来看看如何接入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,分享游戏开发心得,玩转游戏开发

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

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

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

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

或扫码:img

QQ 群: 1103157878

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

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

github: https://github.com/AILHC

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值