基于C#的游戏服务开发(五)

ca2e1b618b85fba0a7d17f9d0c045c20.png

点击蓝字关注我哦

5107a43028c0f7132807f11899682a9b.png

77f2e0006c581ad095fb35d2dd711ef2.png

9a384b7a3a91bbf10e1d7a87b4567f46.gif

abeacfedec37cefab14bb99c53b66915.png

乱斗游戏(二)

47d8e9b92c9cdd30996c6b0100b9c61d.png

我们对于之前的机制,做个小小的改动,之前的方法都是写在一个主函数中的,其实以我们的经验来看,却是不是一个明智的做法,肯定需要重构,那么我们怎么重构呢?需要什么方法来重构呢?

我们用到是发射.反射用法文章之前也有解释,可以去翻翻看:链接

我们使用反射,就是希望用协议名来调用函数,我们修改一下服务器端的代码,首先在Program引入反射的命名空间:

using System.Reflection;

那么既然是通过协议名来调用某个函数方法,那么假设我们第一个调用的为”Enter”协议,那么我们要怎么做呢? 我们需要新建一个脚本:MsgHandler,用来专门存放消息处理的函数.

b2f390884f8e4c558f5f45137470d3d4.png

我们打开MsgHandler函数,并写好MsgEnter的函数.

using EchoServer;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
//通过反射调用
namespace EchoServer
{
    class MsgHandler
    {
        pulic static void MsgEnter(ClientState c, string msgArgs)
        {
        }
    }
}

虽然是个空的函数,但是我们会在Program函数中调用到它.那么回到我们的Program函数中,当读取客户端消息的时候,我们就开始解析客户端的消息,根据客户端的协议类型,去调用相对应的函数:

//处理每个在线客户端的消息
        public static bool ReadClientfd(Socket clientfd)
        {
            ClientState state = clients[clientfd];
            //接收字节
            int count = 0;
            try
            {
                count = clientfd.Receive(state.readBuff);
            }
            catch (SocketException e)
            {
                //下线时调用反射
                MethodInfo mei = typeof(EventHandler).GetMethod("OnDisConnect");
                object[] ob = { state };
                mei.Invoke(null, ob);

                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("异常报告:" + e.Message);
                return false;
            }

            //如果客户端强行下线
            if (count <= 0)
            {
                //下线时调用反射
                MethodInfo mei = typeof(EventHandler).GetMethod("OnDisConnect");
                object[] ob = { state };
                mei.Invoke(null, ob);

                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("socket 已关闭");
                return false;
            }

            //数据处理
            string recvStr = Encoding.Default.GetString(state.readBuff, 0, count);
            string[] split = recvStr.Split('|');
            Console.WriteLine("Receive :" + recvStr);

            string msgName = split[0];
            string msgArgs = split[1];
            string funName = "Msg" + msgName;
            MethodInfo mi = typeof(MsgHandler).GetMethod(funName);
            object[] o = { state, msgArgs };
            mi.Invoke(null, o);
            //客户端需要发送的
            //string sendStr = recvStr;
            //byte[] sendBytes = Encoding.Default.GetBytes(sendStr);
            //foreach (ClientState item in clients.Values)
            //{
            //    item.socket.Send(sendBytes);
            //}
            return true;
        }

如你所见,我们将客户端的发送代码注释掉了,所以我们也要单独写一个方法,来广播消息:

//发送
        public static void Send(ClientState cs, string sendStr)
        {
            byte[] sendBytes = System.Text.Encoding.Default.GetBytes(sendStr);
            cs.socket.Send(sendBytes);
        }

比如客户端来的消息是:Enter|127.0.0.1:12315,那么代码会自动调用MsgEnter的代码逻辑

既然这一个自动调用的步骤有了,玩家打开就会有个Enter的请求发送进来.那么Enter这个函数做什么呢?首先是消息的拆分,前文说过,这条消息包含玩家的位置,旋转,然后是对这个玩家的消息进行广播.那么MsgEnter的消息应当这么写:

public static void MsgEnter(ClientState c, string msgArgs)
        {
            string[] split = msgArgs.Split(',');
            string desc = split[0];
            float x = float.Parse(split[1]);
            float y = float.Parse(split[2]);
            float z = float.Parse(split[3]);
            float eulY = float.Parse(split[4]);
            //赋值
            c.hp = 100;
            c.x = x;
            c.y = y;
            c.z = z;
            c.eulY = eulY;
            //广播
            string sendStr = "Enter|" + msgArgs;
            foreach (ClientState cs in  Program. clients.Values)
            {
                Program.Send(cs ,sendStr );
            } 
        }

那么,仅仅如此就可以了吗?当然没有,因为你目前的做法,只是广播给各个客户端,说有个玩家进来了,然后就没有任何操作了,那么客户端知道 有玩家进来了,但是它不知道该在哪里去生成这个玩家,也不知道玩家的旋转角是多少,所以运行程序来看,与之前的文章的效果还是一样的.所以,我们还要在客户端Enter协议发送之后,再发送一个List协议的请求,这个请求会让服务器会把目前在线的玩家数据统统广播出去,所以,首先在客户端中发送完Enter协议,再发送List协议,用来获取在线玩家列表,这个List的协议如下:

假如目前服务器接收了2位玩家登录.

List|127.0.0.1:4565,3,0,5,0,100,127.0.0.1:4556,4,0,9,0,100,

void Start():
    {
        NetManager.AddListener("Enter", OnEnter);
        NetManager.AddListener("List", OnList);
        NetManager.AddListener("Move", OnMove);
        NetManager.AddListener("Attack", OnAttack);
        NetManager.AddListener("Die", OnDie);
        NetManager.AddListener("Leave", OnLeave);
        NetManager.Connect("127.0.0.1", 8888);

        GameObject go = Instantiate(humanPrefab) as GameObject;
        float x = Random.Range(-5, 5);
        float z = Random.Range(-5, 5);
        go.transform.position = new Vector3(x, 0, z);
        myHuman = go.AddComponent<CtrlHuman>();
        myHuman.desc = NetManager.Getdesc();

        //发送协议
        Vector3 pos = myHuman.transform.position;
        Vector3 eul = myHuman.transform.eulerAngles;
        string sendStr = "Enter|";
        sendStr += NetManager.Getdesc() + ",";
        sendStr += pos.x + ",";
        sendStr += pos.y + ",";
        sendStr += pos.z + ",";
        sendStr += eul.y + ",";
        NetManager.Send(sendStr);
        //请求玩家列表
        NetManager.Send("List|");
}

当然,只有最后一句代码是新加的,我写上是怕读者看的乱,所以宁愿字数多,也不让读者读着模糊.

那么既然发送了List请求,客户端接受到了服务器发来的消息,也应该有OnList的方法供给调用.那么OnList里面应当有所有已连接的客户端信息.

void OnList(string msgArgs)
    {
        Debug.Log("OnList" + msgArgs);
        string[] split = msgArgs.Split(',');
        int count = (split.Length - 1) / 6;//玩家的数量
        for (int i = 0; i < count; i++)
        {
            string desc = split[i * 6 + 0];
            float x = float.Parse(split[i * 6 + 1]);
            float y = float.Parse(split[i * 6 + 2]);
            float z = float.Parse(split[i * 6 + 3]);
            float eulY = float.Parse(split[i * 6 + 4]);
            int hp = int.Parse(split[i * 6 + 5]);
            //如果是自己
            if (desc == NetManager.Getdesc())
            {
                continue;
            }
            //否则添加角色到场景
            GameObject obj = GameObject.Instantiate(humanPrefab);
            obj.transform.position = new Vector3(x, y, z);
            obj.transform.eulerAngles = new Vector3(0, eulY, 0);
            BaseHuman h = obj.AddComponent<syncHuman>();
            h.desc = desc;
            otherHumans.Add(desc, h);
        }
}

不过不要忘了,增加监听:

NetManager.AddListener("List", OnList);

通过公式计算,服务器有几台客户端在连接着,解析出它们的参数,并实例化在场景中.

服务器中要做的就是遍历所有连接着的客户端,并把它们打包成一条信息,然后广播出去:

public static void MsgList(ClientState c, string msgArgs)
        {
            string sendStr = "List|";
            foreach (ClientState cs  in Program .clients.Values)
            {
                sendStr += cs.socket.RemoteEndPoint.ToString()+",";
                sendStr += cs.x.ToString() + ",";
                sendStr += cs.y.ToString() + ",";
                sendStr += cs.z.ToString() + ",";
                sendStr += cs.eulY.ToString() + ",";
                sendStr += cs.hp.ToString() + ",";
            }
            Program.Send(c, sendStr);
        }

那么测试一下,打开服务器,并再打开两个客户端,即可发现两个客户端都可以知道有新加的玩家,多添加几个也可以识别到:

f71c27fdf396088ac78fe6997b7edd62.png

那么既然有了上线两个客户端都显示上线玩家.但是关掉某个客户端,却发现其他客户端没有正确的删除下线的玩家,所以我们还需要一个下线的处理,包括玩家异常掉线,与玩家失败被踢出房间.

我们添加一个事件处理的类,用来处理玩家下线的功能,这个类名为:EventHandler,当然,我们使用反射来调用,那么在哪里来调用呢?在客户端关闭,或者是客户端接收消息为空的时候,(其实这个时候已经是客户端异常了,游戏中当然会做掉线重连,但是这里我们默认消息通讯优秀,没有网络波动)

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using EchoServer;
class EventHandler
{
    public static void OnDisConnect(ClientState c)
    {
        Console.WriteLine("Disconnect"); 
    }
}

我们在接收消息异常和客户端关闭时调用:

//处理每个在线客户端的消息
        public static bool ReadClientfd(Socket clientfd)
        {
            ClientState state = clients[clientfd];
            //接收字节
            int count = 0;
            try
            {
                count = clientfd.Receive(state.readBuff);
            }
            catch (SocketException e)
            {
                //下线时调用反射
                MethodInfo mei = typeof(EventHandler).GetMethod("OnDisConnect");
                object[] ob = { state };
                mei.Invoke(null, ob);

                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("异常报告:" + e.Message);
                return false;
            }

            //如果客户端强行下线
            if (count <= 0)
            {
                //下线时调用反射
                MethodInfo mei = typeof(EventHandler).GetMethod("OnDisConnect");
                object[] ob = { state };
                mei.Invoke(null, ob);

                clientfd.Close();
                clients.Remove(clientfd);
                Console.WriteLine("socket 已关闭");
                return false;
            }

         ...//省略
        }

那么,在客户端异常时,我们会调用这个方法,那么客户端,又如何得知,此时应该处理掉线的用户呢?

我们在调用OnDisconnect的时候,广播一个Leave协议,告诉所有的客户端,某个客户端下线了.

public static void OnDisConnect(ClientState c)
    {
        Console.WriteLine("Disconnect");
        string desc = c.socket.RemoteEndPoint.ToString();
        string sendStr = "Leave|" + desc + ",";
        foreach (ClientState cs in Program .clients.Values )
        {
            Program.Send(cs, sendStr);
        }
    }

那么客户端接收到这条消息,该做何处理呢?依照之前的经验,无阿给是写上OnLeave方法,然后添加监听:

//玩家离开/踢出
    void OnLeave(string msgArgs)
    {
        Debug.Log("OnLeave" + msgArgs);
        string[] split = msgArgs.Split(',');
        string desc = split[0];
        if (!otherHumans.ContainsKey(desc))
            return;
        BaseHuman h = otherHumans[desc];
        Destroy(h.gameObject);
        otherHumans.Remove(desc);
}

添加监听:

NetManager.AddListener("Leave", OnLeave);

然后我们测试一下,即可发现,我们测试一下,打开服务器,再打开两个 客户端,再关闭其中一个,即可发现一个客户端中也隐藏了该角色.

我们做到这里,依然不够,因为玩家只可以看到自己客户端的人物在移动,另一个客户端并没有在移动.这是因为,另一个客户端根本不知道该玩家移动了,因为没有广播移动的协议.那么这个协议该如何广播呢?就是在客户端按下鼠标左键的时候,将此客户端的鼠标点击位置进行广播.然后另一台客户端解析协议,控制sync玩家走到该地点.

Move协议

首先客户端将位置发送过去,在玩家按下鼠标左键的时候,发送Move协议,包含玩家需要移动到的终点.

if (Input.GetMouseButtonDown(0))
        {
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;
            Physics.Raycast(ray,out hit);
            if (hit.collider.tag.Equals("Terrain"))
            {
                MoveTo(hit.point);

                //终点消息发送到服务器
                string sendStr = "Move|";
                sendStr += NetManager.Getdesc() + ",";
                sendStr += hit.point.x + ",";
                sendStr += hit.point.y + ",";
                sendStr += hit.point.z + ",";
                NetManager.Send(sendStr);
            }
        }

那么服务接收到这条消息,当然要调用相对应的MsgMove方法,和上面的OnEnter方法类似:

public static void MsgMove(ClientState c, string msgArgs)
        {
            string[] split = msgArgs.Split(',');
            string desc = split[0];
            float x = float.Parse(split[1]);
            float y = float.Parse(split[2]);
            float z = float.Parse(split[3]);
            c.x = x;
            c.y = y;
            c.z = z;
            //组合消息
            string sendStr = "Move|" + msgArgs ;
            forach (ClientState cs in Program .clients.Values )
            {
                Program.Send(cs, sendStr);
            }
        }

可能你会有一个疑问,为什么要给C赋值,因为如果再有新的客户端加入进来,服务器广播的就是C的新位置.而不是一开始出生位置.

那么客户端接收到这条消息了该怎么处理呢?首先,仍旧时切割,然后控制sync的玩家走到这个位置.

void OnMove(string msgArgs)
    {
        Debug.Log("OnMove" + msgArgs);
        string[] split = msgArgs.Split(',');
        string desc = split[0];
        float x = float.Parse(split[1]);
        float y = float.Parse(split[2]);
        float z = float.Parse(split[3]);
        if (!otherHumans.ContainsKey(desc))
            return;

        BaseHuman h = otherHumans[desc];
        Vector3 targetPos = new Vector3(x, y, z);
        h.MoveTo(targetPos);

    }

写完之后,我们测试一下:

c037e25188f50db479fab4f9a9d61e63.png

也许你会发现,它仍旧会出现,一个客户端出现玩家,另一个客户端并没有出现玩家的现象,这是因为这个客户端没有做消息的正确验证等,所以我们的程序还是有问题的.这个bug后续会解决的.

既然是战斗游戏,当然少不了攻击与清算,那么我们按照顺序先做攻击,攻击具有攻击到了和没有攻击到两种情况,攻击到一定次数,玩家失败退出,调用OnDisconnect .

我们先把攻击动作放到Player上:

28a36d1a58d6bd39f0b61ae088cb3c5d.png

攻击动作自己找一个就好了,因为这里时基础中的基础,我就一笔带过.在客户端中把玩家按下鼠标右键改为攻击:当然这是公共的方法,我们要写在Base Human中:

internal bool isAttacking = false;
internal float attackTime = float.MinValue;

 public void Attack()
    {
        isAttacking = true;
        attackTime = Time.time;
        animator.SetBool("isAttacking", true);
    }

  public void AttackUpdate()
    {
        if (!isAttacking) return;
        if (Time.time - attackTime < 1.2f) return;
        isAttacking = false;
        animator.SetBool("isAttacking", false);
    }
// Update is called once per frame
    public void Update () {
        MoveUpdate();
        AttackUpdate();

    }

在CtrlHuman中控制角色播放此动画:

if (Input.GetMouseButtonDown(1))
        {
            if (isAttacking) return;
            if (isMoving) return;
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            RaycastHit hit;
            Physics.Raycast(ray,out hit);
            transform.LookAt(hit.point);
            Attack();
            string sendStr = "Attack|";
            sendStr += NetManager.Getdesc()+",";
            sendStr += transform.eulerAngles.y + ",";
            NetManager.Send(sendStr); 

        }

上述代码,已经把攻击的具体信息传给了服务器,其实服务器并不需要做太多工作,仅仅时记录攻击时的转向,然后再广播给所有客户端.

//攻击动作
        public static void MsgAttack(ClientState c, string msgArgs)
        {
            string sendStr = "Attack|" + msgArgs;
            foreach (ClientState cs  in Program .clients.Values )
            {
                Program.Send(cs, sendStr);
            }
        }

那么同样的客户端收到了这条攻击消息,该如何做呢?

还是写一个OnAttack方法,并增加监听.

void OnAttack(string msgArgs)
    {
        Debug.Log("OnAttack" + msgArgs);
        string[] split = msgArgs.Split(',');
        string desc = split[0];
        float eulY = float.Parse(split[1]);
        //攻击动作
        if (!otherHumans.ContainsKey(desc))
            return;
        syncHuman h = (syncHuman)otherHumans[desc];
        h.SyncAttack(eulY);

    }

        NetManager.AddListener("Attack", OnAttack);

那么你可能看到OnAttack中控制sync角色的播放动画,所以我们要打开SyncHuman脚本,调用攻击父类的攻击代码:

public void SyncAttack(float eulY)
    {
        transform.eulerAngles = new Vector3(0, eulY, 0);
        Attack();
    }

那么测试一下,两个客户端同时播放某个玩家在攻击,那么还有一个受伤的的判断,如果玩家仅仅是播放了攻击动作,并没有攻击到另一个玩家,那就不发攻击的协议,如果攻击到了,就发送攻击协议,并广播.

我们这里用简单的射线检测来判断攻击到没有.所以要给玩家添加上碰撞器

1c4aaf7ca0cda165a048cfa95ad27fda.png

并在做攻击动作的时候做一个攻击判定:

if (Input.GetMouseButtonDown(1))
        {
          ...

            //攻击判定
            Vector3 lineEnd = transform.position + 0.5f * Vector3.up;
            Vector3 lineStart = lineEnd + 20 * transform.forward;
            if (Physics.Linecast(lineStart, lineEnd, out hit))
            {
                Debug.DrawLine(lineStart, lineEnd);
                GameObject hitObj = hit.collider.gameObject;
                if (hitObj == gameObject)
                    return;
                syncHuman h = (syncHuman)hitObj.GetComponent<syncHuman>();
                if (h == null)
                    return;
                sendStr = "Hit|";
                sendStr += NetManager.Getdesc() + ",";
                sendStr += h.desc + ",";
                NetManager.Send(sendStr);
                Debug.Log(sendStr);
            }

        }

既然发送了Hit协议,服务器当然要广播出去,

如果血量<0就代表玩家死亡,并发送Die协议,告诉所有的客户端

服务器端的攻击方法:

//攻击hit
        public static void MsgHit(ClientState c, string msgArgs)
        {
            //解析参数
            string[] split = msgArgs.Split(',');
            string attDesc = split[0];
            string hitDesc = split[1];
            //找出被攻击的角色
            ClientState hitCS = null;
            foreach (ClientState cs in Program .clients .Values )
            {
                if (cs.socket.RemoteEndPoint.ToString() == hitDesc)
                {
                    hitCS = cs;
                }
            }
            if (hitCS == null)
            {
                return;
            }
            hitCS.hp -= 25;

            //死亡
            if (hitCS.hp <= 0)
            {

                string sendStr = "Die|" + hitCS.socket.RemoteEndPoint.ToString();
                foreach (ClientState cs in Program .clients .Values )
                {
                    Program.Send(cs, sendStr);
                }
            }
        }

Die协议:

Die协议不需要客户端发送,服务器来判断,其实这个也可以是客户端判断,这个要根据需求来定.客户端接到Die协议,就准备隐藏某个玩家:

void OnDie(string msgArgs)
    {
        Debug.Log("OnDie" + msgArgs);
        string[] split = msgArgs.Split(',');
        string attDesc = split[0];
        string hitDesc = split[0];

        if (hitDesc == myHuman.desc)
        {
            Debug.Log("gameOver");
            return;
        }

        //自己死了
        if (!otherHumans.ContainsKey(hitDesc))
        {
            return;
        }
        syncHuman h = (syncHuman)otherHumans[hitDesc];
        h.gameObject.SetActive(false);
    }

这里有句判断,如果是自身,可以退出app.

那么测试一下,发现仍有概率出现问题.因为这套框架并不完整,没有做消息验证,没有做断线重连等等,这些功能我们后面的文章会一一解决.

本案例的客户端与服务器源码下载方法:公众号回复  服务器开发 即可.

…END…

1282ef02a143bc696c7e8e7849fa4bf4.png

技术群:添加小编微信并备注进群

小编微信:mm1552923   

公众号:dotNet编程大全      

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,下面是基于C#控制台应用的贪吃蛇小游戏的代码实现。 ```csharp using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace ConsoleSnake { class Program { static void Main(string[] args) { Console.CursorVisible = false; //隐藏光标 Console.SetWindowSize(60, 30); //设置窗口大小 Console.SetBufferSize(60, 30); //设置缓冲区大小 //画边框 for (int i = 0; i < 60; i++) { Console.SetCursorPosition(i, 0); Console.Write("#"); Console.SetCursorPosition(i, 29); Console.Write("#"); } for (int i = 0; i < 30; i++) { Console.SetCursorPosition(0, i); Console.Write("#"); Console.SetCursorPosition(59, i); Console.Write("#"); } //初始化蛇 List<Point> snake = new List<Point>(); snake.Add(new Point(30, 15)); snake.Add(new Point(29, 15)); snake.Add(new Point(28, 15)); snake.Add(new Point(27, 15)); snake.Add(new Point(26, 15)); //初始化食物 Point food = new Point(RandomNumber(1, 58), RandomNumber(1, 28)); Console.SetCursorPosition(food.x, food.y); Console.Write("*"); //初始化方向 Direction direction = Direction.Right; //初始化分数 int score = 0; while (true) { //获取键盘输入 if (Console.KeyAvailable) { ConsoleKeyInfo key = Console.ReadKey(true); switch (key.Key) { case ConsoleKey.UpArrow: if (direction != Direction.Down) direction = Direction.Up; break; case ConsoleKey.DownArrow: if (direction != Direction.Up) direction = Direction.Down; break; case ConsoleKey.LeftArrow: if (direction != Direction.Right) direction = Direction.Left; break; case ConsoleKey.RightArrow: if (direction != Direction.Left) direction = Direction.Right; break; } } //移动蛇 Point head = snake[0]; Point newHead = new Point(head.x, head.y); switch (direction) { case Direction.Up: newHead.y--; break; case Direction.Down: newHead.y++; break; case Direction.Left: newHead.x--; break; case Direction.Right: newHead.x++; break; } snake.Insert(0, newHead); Console.SetCursorPosition(newHead.x, newHead.y); Console.Write("@"); if (newHead.x == food.x && newHead.y == food.y) //吃到食物 { food = new Point(RandomNumber(1, 58), RandomNumber(1, 28)); Console.SetCursorPosition(food.x, food.y); Console.Write("*"); score += 10; } else //没有吃到食物 { Point tail = snake[snake.Count - 1]; Console.SetCursorPosition(tail.x, tail.y); Console.Write(" "); snake.RemoveAt(snake.Count - 1); } //判断是否撞到边框或自己 if (newHead.x == 0 || newHead.x == 59 || newHead.y == 0 || newHead.y == 29) break; for (int i = 1; i < snake.Count; i++) { if (newHead.x == snake[i].x && newHead.y == snake[i].y) { Console.SetCursorPosition(0, 31); Console.Write("Game Over! Your score is: " + score); return; } } //延迟一段时间 System.Threading.Thread.Sleep(100); } } static int RandomNumber(int min, int max) //生成指定范围内的随机数 { Random random = new Random(); return random.Next(min, max + 1); } } enum Direction //方向枚举 { Up, Down, Left, Right } class Point //坐标类 { public int x; public int y; public Point(int x, int y) { this.x = x; this.y = y; } } } ``` 希望这个代码对你有帮助!

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值