从0开始的网游ARPG实战案例:暗黑战神(第五六章:主城角色控制和任务引导系统)

目录

第5章: 主城UI逻辑及角色控制

制作主城场景

主城UI界面制作

跳转主城场景

增加角色属性

主城UI显示逻辑

分段经验条制作

经验条自适应

下线数据清理

主菜单动画制作

动画播放控制

摇杆插件制作1

摇杆插件制作2

摇杆插件制作3

摇杆插件制作4

主角人物制作

主角运动控制

平滑动画过渡

生成与解析主城配置数据

加载主角到主城

主城碰撞体制作

方向输入对接

第6章: 角色展示系统与任务引导系统

角色信息界面制作

常规信息显示

角色与UI混合显示

实现角色旋转触控

详细属性显示

任务引导系统开发思路

任务引导系统配置数据

任务引导系统开发1

任务引导系统开发2

任务引导系统开发3

任务引导系统开发4

任务引导系统开发5

任务引导系统开发6

任务引导系统开发7

NPC对话界面1

NPC对话界面2

定制引导数据网络协议

引导数据网络协议处理

增加服务器数据配置服务

经验升级计算

更新客户端任务

增加文字染色工具


第5章: 主城UI逻辑及角色控制

制作主城场景

导入主城场景,并进行灯光烘焙

贴图文件

主城UI界面制作

跳转主城场景

创建MainCitySys和MainCityWnd两个脚本

//主城UI界面

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MainCityWnd : WindowRoot
{
    protected override void InitWnd()
    {
        base.InitWnd();
    }
}
//主城业务系统

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MainCitySys : SystemRoot
{
    public static MainCitySys Instance = null;

    public MainCityWnd mainCityWnd;

    public override void InitSys()
    {
        base.InitSys();
        Instance = this;
        Debug.Log("Init MainCitySys...");
    }

    public void EnterMainCity()
    {
        resSvc.AsyncLoadScene(Constants.SceneMainCity,()=>
        {
            PECommon.Log("Enter MainCity...");

            //TODO 加载游戏主角

            //打开主城场景UI
            mainCityWnd.SetWndState();

            //播放主城里背景音乐
            audioSvc.PlayBGMusic(Constants.BGMainCity);

            //TODO 设置人物展示相机
        });
    }
}

在GameRoot里面加载主城系统

        MainCitySys mainCitySys = GetComponent<MainCitySys>();
        mainCitySys.InitSys();

在合适的地方调用主城显示的方法,主要是在LoginSys中

    public void RspRename(GameMsg msg)
    {
        GameRoot.Instance.SetPlayerName(msg.rspRename.name);

        //TODO
        //跳转场景进入主城
        //打开主城的界面
        MainCitySys.Instance.EnterMainCity();

        //关闭创建页面
        createWnd.SetWndState(false);
    }
    public void RspLogin(GameMsg msg)
    {
        GameRoot.AddTips("登录成功");
        GameRoot.Instance.SetPlayerData(msg.rspLogin);

        if(msg.rspLogin.playerData.name == "")
        {
            //打开角色创建页面
            createWnd.SetWndState();
        }
        else
        {
            //进入主城
            MainCitySys.Instance.EnterMainCity();
        }
        //关闭登录页面
        loginWnd.SetWndState(false);
    }

运行程序,结果如下

增加角色属性

主城UI显示逻辑

在MainCityWnd中添加UI初始化显示的方法

    public Text txtFight;
    public Text txtPower;
    public Image imgPowerPrg;
    public Text txtLv;
    public Text txtName;
    public Text txtExpPrg;

    private void RefreshUI()
    {
        PlayerData pd = GameRoot.Instance.PlayerData;
        SetText(txtFight, PECommon.GetFightByProps(pd));
        SetText(txtPower, "体力:" + pd.power+"/"+PECommon.GetPowerLimit(pd.lv));
        imgPowerPrg.fillAmount = pd.power * 1.0f / PECommon.GetPowerLimit(pd.lv);
        SetText(txtLv, pd.lv);
        SetText(txtName, pd.name);

        //expprg

    }

上面的方法会用到一些PECommon中的公用方法

    //计算战斗力
    public static int GetFightByProps(PlayerData pd)
    {
        return pd.lv * 100 + pd.ad + pd.ap + pd.addef + pd.addef;
    }

    //计算体力限制
    public static int GetPowerLimit(int lv)
    {
        return (lv - 1) / 10 * 150 + 150;
    }

运行程序,结果如下

分段经验条制作

使用Grid Layout Group组件来进行分段经验条的制作

经验条自适应

首先要取到Transform组件

    public Transform expPrgTrans;

然后在Constants中定义好标准的宽高

    //屏幕标准宽高
    public const int ScreenStandardWidth = 1334;
    public const int ScreenStandardHeight = 750;

利用标准宽高和真实宽高的比例来自适应

        GridLayoutGroup grid = expPrgTrans.GetComponent<GridLayoutGroup>();
        float globalRate = 1.0f * Constants.ScreenStandardHeight / Screen.height;
        float screenWidth = Screen.width * globalRate;
        float width = (screenWidth - 182) / 10;
        grid.cellSize = new Vector2(width, 7);

利用10个进度条来表示当前的经验值

        int expPrgVal = (int)(pd.exp * 1.0f / PECommon.GetExpUpValByLv(pd.lv) * 100);
        SetText(txtExpPrg, expPrgVal + "%");
        int index = expPrgVal / 10;

        for(int i =0;i<expPrgTrans.childCount;i++)
        {
            Image img = expPrgTrans.GetChild(i).GetComponent<Image>();
            if (i < index)
                img.fillAmount = 1;
            else if(i == index)
            {
                img.fillAmount = expPrgVal % 10 * 1.0f / 10;
            }
            else
            {
                img.fillAmount = 0;
            }
        }

下线数据清理

为了清理Session避免错误判断账号在线,我们给每个session设置一个ID,然后对ServerSession进行修改。

using PENet;
using PEProtocol;
/// <summary>
/// 网络会话连接
/// </summary>
public class ServerSession:PESession<GameMsg>
{
    //生成一个ID
    public int sessionID = 0;

    protected override void OnConnected()
    {
        sessionID = ServerRoot.Instance.GetSessionID();
        PECommon.Log("SessionID" + sessionID + " Client Connect");
    }

    protected override void OnReciveMsg(GameMsg msg)
    {
        PECommon.Log("SessionID" + sessionID + " RcvPack CMD:" +((CMD)msg.cmd).ToString());
        NetSvc.Instance.AddMsgQue(new MsgPack(this,msg));
    }

    protected override void OnDisConnected()
    {
        LoginSys.Instance.ClearOfflineData(this);
        PECommon.Log("SessionID" + sessionID + " Client Offline");
    }
}

其中ClearOfflineData()用来让队列中的session出队。

    public void ClearOfflineData(ServerSession session)
    {
        cacheSvc.AcctOffLine(session);
    }
    public void AcctOffLine(ServerSession session)
    {
        foreach(var item in onLineAcctDic)
        {
            if(item.Value == session)
            {
                onLineAcctDic.Remove(item.Key);
                break;
            }
        }

        bool succ = onLineSessionDic.Remove(session);
        PECommon.Log("Offline Result: SessionID" + session.sessionID + " " + succ);
    }

主菜单动画制作

在MenuRoot上添加两个动画,分别控制主菜单的打开和关闭

动画播放控制

首先需要定义好使用的物体和组件

    public Animation menuAni;
    public Button btnMenu;

    private bool menuState = true;//用来判断当前菜单的状态

然后在Constants中添加需要播放的音乐

    public const string UIExtenBtn = "uiExtenBtn";

最后完成点击菜单按钮的函数,然后将其关联到指定按钮上

    public void ClickMenuBtn()
    {
        audioSvc.PlayUIMusic(Constants.UIExtenBtn);
        menuState = !menuState;
        AnimationClip clip = null;
        if (menuState)
            clip = menuAni.GetClip("OpenMCMenu");
        else
            clip = menuAni.GetClip("CloseMCMenu");

        menuAni.Play(clip.name);
    }

摇杆插件制作1

首先创建三张图片来表示出摇杆

然后在MainCityWnd中进行定义

    public Image imgTouch;
    public Image imgDirBg;
    public Image imgDirPoint;

在初始的时候,需要隐藏imgDirPoint,之后调用摇杆的方法

    protected override void InitWnd()
    {
        base.InitWnd();
        SetActive(imgDirPoint, false);

        RegisterTouchEvts();
        RefreshUI();
    }
    public void RegisterTouchEvts()
    {

    }

摇杆插件制作2

在Common文件夹中创建一个PEListener的脚本用来进行UI事件的监听

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System;

/// <summary>
/// UI事件监听插件
/// </summary>
public class PEListener : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IDragHandler
{

    public Action<PointerEventData> onClickDown;
    public Action<PointerEventData> onClickUp;
    public Action<PointerEventData> onDrag;

    public void OnPointerDown(PointerEventData eventData)//按下事件
    {
        if (onClickDown != null)
            onClickDown(eventData);
    }

    public void OnPointerUp(PointerEventData eventData)//抬起事件
    {
        if (onClickUp != null)
            onClickUp(eventData);
    }

    public void OnDrag(PointerEventData eventData)//拖拽事件
    {
        if (onDrag != null)
            onDrag(eventData);
    }
}

在MainCityWnd中给imgTouch添加上这个脚本,用来处理按下、抬起和拖拽事件,下面是处理按下事件

    public void RegisterTouchEvts()
    {
        PEListener listener = imgTouch.gameObject.AddComponent<PEListener>();
        listener.onClickDown = (PointerEventData evt) =>
          {
              imgDirBg.transform.position = evt.position;
          };
    }

摇杆插件制作3

在GameRoot里封装好这三个方法,方便之后的调用

    //判断是否添加组件
    protected T GetOrAddComponent<T>(GameObject go) where T:Component
    {
        T t = go.GetComponent<T>();
        if (t == null)
            t = go.AddComponent<T>();
        return t;
    }

    protected void OnClickDown(GameObject go,Action<PointerEventData> cb)
    {
        PEListener listener = GetOrAddComponent<PEListener>(go);
        listener.onClickDown = cb;
    }

    protected void OnClickUp(GameObject go, Action<PointerEventData> cb)
    {
        PEListener listener = GetOrAddComponent<PEListener>(go);
        listener.onClickUp = cb;
    }

    protected void OnDrag(GameObject go, Action<PointerEventData> cb)
    {
        PEListener listener = GetOrAddComponent<PEListener>(go);
        listener.onDrag = cb;
    }

之前的按下案列封装后的写法

        OnClickDown(imgTouch.gameObject, (PointerEventData evt) =>
         {
             imgDirBg.transform.position = evt.position;
         });

摇杆插件制作4

这节完成松开事件和拖拽事件的方法,首先需要定义一些变量

    private bool menuState = true;
    private float pointDis;
    private Vector2 startPos = Vector2.zero;
    private Vector2 defaultPos = Vector2.zero;
    //摇杆点标准距离
    public const int ScreenOPDis = 90;

然后进行初始哈

        //通过真实的高度和标准的高度对比算出摇杆的最大距离
        pointDis = Screen.height * 1.0f / Constants.ScreenStandardHeight * Constants.ScreenOPDis;
        defaultPos = imgDirBg.transform.position;

下面是三个函数的代码

    public void RegisterTouchEvts()
    {
        PEListener listener = imgTouch.gameObject.AddComponent<PEListener>();
        OnClickDown(imgTouch.gameObject, (PointerEventData evt) =>
         {
             startPos = evt.position;
             SetActive(imgDirPoint);
             imgDirBg.transform.position = evt.position;
         });

        OnClickUp(imgTouch.gameObject, (PointerEventData evt) =>
        {
            imgDirBg.transform.position = defaultPos;
            SetActive(imgDirPoint, false);
            imgDirPoint.transform.localPosition = Vector2.zero;
            //TODO方向信息传递
            Debug.Log(Vector2.zero);
        });

        OnDrag(imgTouch.gameObject, (PointerEventData evt) =>
        {
            Vector2 dir = evt.position - startPos;
            float len = dir.magnitude;
            if (len > pointDis)
            {
                //将长度限制到范围以内
                Vector2 clampDir = Vector2.ClampMagnitude(dir, pointDis);
                imgDirPoint.transform.position = startPos + clampDir;
            }
            else
                imgDirPoint.transform.position = evt.position;
            //TODO方向信息传递
            Debug.Log(dir.normalized);
        });
    }

主角人物制作

主角运动控制

在主角身上添加一个PlayerController脚本

/****************************************************
    文件:PlayerController.cs
	作者:Sou
    邮箱: 947662512@qq.com
    日期:2019/7/26 19:54:28
	功能:角色控制器
*****************************************************/

using UnityEngine;

public class PlayerController : MonoBehaviour
{

    private Transform camTrans;
    private Vector3 camOffset;

    public Animator ani;
    public CharacterController ctrl;

    private Vector2 dir = Vector2.zero;
    public Vector2 Dir
    {
        get
        {
            return dir;
        }
        set
        {
            if (value == Vector2.zero)
                isMove = false;
            else
                isMove = true;
            dir = value;
        }
    }
    private bool isMove = false;

    private void Start()
    {
        camTrans = Camera.main.transform;
        camOffset = transform.position - camTrans.position;
    }

    private void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        Vector2 _dir = new Vector2(h, v).normalized;
        if (_dir != Vector2.zero)
            Dir = _dir;
        else
            Dir = Vector2.zero;

        if(isMove)
        {
            //设置方向
            SetDir();
            //产生移动
            SetMove();
            //相机跟随
            SetCam();
        }
    }

    private void SetDir()
    {
        float angle = Vector2.SignedAngle(Dir, new Vector2(0, 1));//当前的朝向的角度是相当于z轴计算的
        Vector3 eulerAngles = new Vector3(0, angle, 0);
        transform.localEulerAngles = eulerAngles;
    }

    private void SetMove()
    {
        ctrl.Move(transform.forward * Time.deltaTime * Constants.PlayerMoveSpeed);
    }

    private void SetCam()
    {
        if(camTrans!=null)
        {
            camTrans.position = transform.position - camOffset;
        }
    }
}

速度在Constants中设置

    //角色移动速度
    public const int PlayerMoveSpeed = 8;
    public const int MonsterMoveSpeed = 4;

平滑动画过渡

为了使动画柔和,需要给Blend进行一个平滑过渡的机制

首先在Constants中设置一些属性

    //混合参数
    public const int BlendIdle = 0;
    public const int BlendWalk = 1;

    //运动平滑加速度
    public const float AccelerSpeed = 5;

然后修改PlayerController中的一些方法

using UnityEngine;

public class PlayerController : MonoBehaviour
{

    private Transform camTrans;
    private Vector3 camOffset;

    public Animator ani;
    public CharacterController ctrl;

    private Vector2 dir = Vector2.zero;
    public Vector2 Dir
    {
        get
        {
            return dir;
        }
        set
        {
            if (value == Vector2.zero)
                isMove = false;
            else
                isMove = true;
            dir = value;
        }
    }
    private bool isMove = false;

    private float targetBlend;
    private float currentBlend;


    private void Start()
    {
        camTrans = Camera.main.transform;
        camOffset = transform.position - camTrans.position;
    }

    private void Update()
    {
        float h = Input.GetAxis("Horizontal");
        float v = Input.GetAxis("Vertical");

        Vector2 _dir = new Vector2(h, v).normalized;
        if (_dir != Vector2.zero)
        {
            Dir = _dir;
            SetBlend(Constants.BlendWalk);
        }       
        else
        {
            Dir = Vector2.zero;
            SetBlend(Constants.BlendIdle);
        }

        if(currentBlend!=targetBlend)
            UpdateMixBlend();
           
        if(isMove)
        {
            //设置方向
            SetDir();
            //产生移动
            SetMove();
            //相机跟随
            SetCam();
        }
    }

    private void SetDir()
    {
        float angle = Vector2.SignedAngle(Dir, new Vector2(0, 1));//当前的朝向的角度是相当于z轴计算的
        Vector3 eulerAngles = new Vector3(0, angle, 0);
        transform.localEulerAngles = eulerAngles;
    }

    private void SetMove()
    {
        ctrl.Move(transform.forward * Time.deltaTime * Constants.PlayerMoveSpeed);
    }

    private void SetCam()
    {
        if(camTrans!=null)
        {
            camTrans.position = transform.position - camOffset;
        }
    }

    private void SetBlend(float blend)
    {
        targetBlend = blend;
    }

    private void UpdateMixBlend()
    {
        if(Mathf.Abs(currentBlend - targetBlend)<Constants.AccelerSpeed*Time.deltaTime)
        {
            currentBlend = targetBlend;
        }
        else if(currentBlend>targetBlend)
        {
            currentBlend -= Constants.AccelerSpeed * Time.deltaTime;
        }
        else
        {
            currentBlend += Constants.AccelerSpeed * Time.deltaTime;
        }
        ani.SetFloat("Blend", currentBlend);
    }

}

生成与解析主城配置数据

导入一个xml文件

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<root xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
	<item ID="10000">
		<mapName>圣光主城</mapName>
		<sceneName>SceneMainCity</sceneName>
		<mainCamPos>17.4,7,50</mainCamPos>
		<mainCamRote>45,135,0</mainCamRote>
		<playerBornPos>22,-1.1,45</playerBornPos>
		<playerBornRote>0,0,0</playerBornRote>
	</item>
</root>

在PathDefine中新建xml文件的地址

public const string MapCfg = "ResCfgs/map";

生成一个新的类,用到接收xml解析出来的信息

using UnityEngine;
/// <summary>
/// 配置数据类
/// </summary>
/// 
public class MapCfg:BaseData<MapCfg>
{
    public string mapName;
    public string sceneName;
    public Vector3 mainCamPos;
    public Vector3 mainCamRote;
    public Vector3 playerBornPos;
    public Vector3 playerBornRote;
}

public class BaseData<T>
{
    public int ID;
}

在ResSvc中添加解析地图xml的代码

    private Dictionary<int, MapCfg> mapCfgDataDic = new Dictionary<int, MapCfg>();
    private void InitMapCfg(string path)
    {
        TextAsset xml = Resources.Load<TextAsset>(path);
        if (!xml)
            Debug.LogError("xml file:" + path + "not exist");
        else
        {
            XmlDocument doc = new XmlDocument();
            doc.LoadXml(xml.text);

            XmlNodeList nodLst = doc.SelectSingleNode("root").ChildNodes;
            for (int i = 0; i < nodLst.Count; i++)
            {
                XmlElement ele = nodLst[i] as XmlElement;
                if (ele.GetAttributeNode("ID") == null)
                    continue;
                int ID = Convert.ToInt32(ele.GetAttributeNode("ID").InnerText);

                MapCfg mc = new MapCfg
                {
                    ID = ID
                };
                
                foreach (XmlElement e in nodLst[i].ChildNodes)
                {
                    switch (e.Name)
                    {
                        case "mapName":
                            mc.mapName = e.InnerText;
                            break;
                        case "sceneName":
                            mc.sceneName = e.InnerText;
                            break;
                        case "mainCamPos":
                            {
                                string[] valArr = e.InnerText.Split(',');
                                mc.mainCamPos = new Vector3(float.Parse(valArr[0]), float.Parse(valArr[1]), float.Parse(valArr[2]));
                            }
                            break;
                        case "mainCamRote":
                            {
                                string[] valArr = e.InnerText.Split(',');
                                mc.mainCamRote = new Vector3(float.Parse(valArr[0]), float.Parse(valArr[1]), float.Parse(valArr[2]));
                            }
                            break;
                        case "playerBornPos":
                            {
                                string[] valArr = e.InnerText.Split(',');
                                mc.playerBornPos = new Vector3(float.Parse(valArr[0]), float.Parse(valArr[1]), float.Parse(valArr[2]));
                            }
                            break;
                        case "playerBornRote":
                            {
                                string[] valArr = e.InnerText.Split(',');
                                mc.playerBornRote = new Vector3(float.Parse(valArr[0]), float.Parse(valArr[1]), float.Parse(valArr[2]));
                            }
                            break;
                    }
                }

                mapCfgDataDic.Add(ID, mc);
            }
        }
    }

    public MapCfg GetMapCfgData(int id)
    {
        MapCfg data;
        if (mapCfgDataDic.TryGetValue(id, out data))
            return data;
        return null;
    }

加载主角到主城

首先修改Constants中的场景名称为ID

    public const int MainCityMapID = 10000;
    //public const string SceneMainCity = "SceneMainCity";

然后在ResSvc中添加加载预制体的方法

    private Dictionary<string, GameObject> goDic = new Dictionary<string, GameObject>();
    public GameObject LoadPrefab(string path, bool cache = false)
    {
        GameObject prefab = null;
        if (!goDic.TryGetValue(path, out prefab))
        {
            prefab = Resources.Load<GameObject>(path);
            if(cache)
            {
                goDic.Add(path, prefab);
            }
        }

        GameObject go = null;
        if (prefab != null)
            go = Instantiate(prefab);
        return go;           
    }

添加player的加载地址

public const string AssissnCityPlayerPrefab = "PrefabPlayer/AssassinCity";

修改和添加MainCitySys中的方法

    public void EnterMainCity()
    {
        MapCfg mapData = resSvc.GetMapCfgData(Constants.MainCityMapID);
        resSvc.AsyncLoadScene(mapData.sceneName,()=>
        {
            PECommon.Log("Enter MainCity...");

            //加载游戏主角
            LoadPlayer(mapData);

            //打开主城场景UI
            mainCityWnd.SetWndState();

            //播放主城里背景音乐
            audioSvc.PlayBGMusic(Constants.BGMainCity);

            //TODO 设置人物展示相机
        });
    }

    private void LoadPlayer(MapCfg mapData)
    {
        GameObject player = resSvc.LoadPrefab(PathDefine.AssissnCityPlayerPrefab);
        player.transform.position = mapData.playerBornPos;
        player.transform.localEulerAngles = mapData.playerBornRote;
        player.transform.localScale = new Vector3(1.5f, 1.5f, 1.5f);

        //相机初始化
        Camera.main.transform.position = mapData.mainCamPos;
        Camera.main.transform.localEulerAngles = mapData.mainCamRote;
    }

主城碰撞体制作

大部分是有美工来完成,这里直接使用导入好的文件。

方向输入对接

首先添加一个PlayerController,然后对其进行初始化

private PlayerController playerCtrl;

在LoadPlayer里面进行初始化

        playerCtrl = player.GetComponent<PlayerController>();
        playerCtrl.Init();

在注册点击事件中修改方法,让其能完成方向对接

        OnClickUp(imgTouch.gameObject, (PointerEventData evt) => {
            imgDirBg.transform.position = defaultPos;
            SetActive(imgDirPoint, false);
            imgDirPoint.transform.localPosition = Vector2.zero;

            MainCitySys.Instance.SetMoveDir(Vector2.zero);
        });
        OnDrag(imgTouch.gameObject, (PointerEventData evt) => {
            Vector2 dir = evt.position - startPos;
            float len = dir.magnitude;
            if (len > pointDis) {
                Vector2 clampDir = Vector2.ClampMagnitude(dir, pointDis);
                imgDirPoint.transform.position = startPos + clampDir;
            }
            else {
                imgDirPoint.transform.position = evt.position;
            }

            MainCitySys.Instance.SetMoveDir(dir.normalized);
        });

其中SetMoveDir的方法如下

    public void SetMoveDir(Vector2 dir) {
        if (dir == Vector2.zero) {
            playerCtrl.SetBlend(Constants.BlendIdle);
        }
        else {
            playerCtrl.SetBlend(Constants.BlendWalk);
        }
        playerCtrl.Dir = dir;
    }

在PlayerController还需要注意真实移动方向和照相机角度的问题

float angle = Vector2.SignedAngle(Dir, new Vector2(0, 1)) + camTrans.eulerAngles.y;

第6章: 角色展示系统与任务引导系统

角色信息界面制作

常规信息显示

建立一个InfoWnd的脚本,添加更新UI的方法

using PEProtocol;
using UnityEngine;
using UnityEngine.UI;

public class InfoWnd : WindowRoot 
{
    #region UI Define
    public Text txtInfo;
    public Text txtExp;
    public Image imgExpPrg;
    public Text txtPower;
    public Image imgPowerPrg;

    public Text txtJob;
    public Text txtFight;
    public Text txtHp;
    public Text txtHurt;
    public Text txtDef;
    #endregion


    protected override void InitWnd()
    {
        base.InitWnd();
        RefreshUI();
    }

    private void RefreshUI()
    {
        PlayerData pd = GameRoot.Instance.PlayerData;
        SetText(txtInfo, pd.name + " LV." + pd.lv);
        SetText(txtExp, pd.exp + "/" + PECommon.GetExpUpValByLv(pd.lv));
        imgExpPrg.fillAmount = pd.exp * 1.0f / PECommon.GetExpUpValByLv(pd.lv);
        SetText(txtPower, pd.power + "/" + PECommon.GetPowerLimit(pd.lv));
        imgPowerPrg.fillAmount = pd.power * 1.0f / PECommon.GetPowerLimit(pd.lv);
        SetText(txtJob, " 职业  暗影刺客");
        SetText(txtFight, " 战力  " + PECommon.GetFightByProps(pd));
        SetText(txtHp, " 血量  " + pd.hp);
        SetText(txtHurt, " 伤害  " + (pd.ad + pd.ap));
        SetText(txtDef, " 防御  " + (pd.addef + pd.apdef));

        //detail TODO
    }
}

在MainCitySys中进行封装,在MainCityWnd中进行调用

    public void OpenInfoWnd()
    {
        infoWnd.SetWndState();
    }
    public void ClickHeadBtn()
    {
        audioSvc.PlayUIAudio(Constants.UIOpenPage);
        MainCitySys.Instance.OpenInfoWnd();
    }

角色与UI混合显示

在MainCity的场景中添加一个相机,用来拍摄主角并将其显示在信息中

在MainCitySys中添加部分代码,让我的信息中显示角色

private Transform charCamTrans;

在EnterCity中添加下面的代码

            //设置人物展示相机
            if(charCamTrans!=null)
            {
                charCamTrans.gameObject.SetActive(false);
            }

在OpenInfoWnd中添加下面的代码

        if(charCamTrans==null)
        {
            charCamTrans = GameObject.FindGameObjectWithTag("CharShowCam").transform;
        }

        //设置人物展示相机的相对位置
        charCamTrans.localPosition = playerCtrl.transform.position + playerCtrl.transform.forward * 3.8f + new Vector3(0, 1.2f, 0);
        charCamTrans.localEulerAngles = new Vector3(0, 180 + playerCtrl.transform.localEulerAngles.y, 0);
        charCamTrans.localScale = Vector3.one;
        charCamTrans.gameObject.SetActive(true);

实现角色旋转触控

InfoWnd里面添加下面的代码,让在鼠标x轴的滑动对应人物在y轴上的转动

    private Vector2 startPos;

    private void RegTouchEvts()
    {
        OnClickDown(imgChar.gameObject,(PointerEventData evt)=>
        {
            startPos = evt.position;
            MainCitySys.Instance.SetStartRotate();
        });
        OnDrag(imgChar.gameObject, (PointerEventData evt) =>
        {
            float rotate = -(evt.position.x - startPos.x) * 0.4f;
            MainCitySys.Instance.SetPlayerRotate(rotate);
        });
    }

其中

    private float startRotate = 0;
    public void SetStartRotate()
    {
        startRotate = playerCtrl.transform.localEulerAngles.y;
    }
    public void SetPlayerRotate(float rotate)
    {
        playerCtrl.transform.localEulerAngles = new Vector3(0, startRotate + rotate, 0);
    }

详细属性显示

首先创建一个详细属性显示的界面

然后在InfoWnd里面进行代码的添加

    public Button btnDetail;
    public Button btnClose;
    public Button btnCloseDetail;
    public Transform transDetail;

    public Text dtxhp;
    public Text dtxad;
    public Text dtxap;
    public Text dtxaddef;
    public Text dtxapdef;
    public Text dtxdodge;
    public Text dtxpierce;
    public Text dtxcritical;

然后在RefreshUI中进行数据的显示

        SetText(dtxhp, pd.hp);
        SetText(dtxad, pd.ad);
        SetText(dtxap, pd.ap);
        SetText(dtxaddef, pd.addef);
        SetText(dtxapdef, pd.apdef);
        SetText(dtxdodge, pd.dodge+"%");
        SetText(dtxpierce, pd.pierce + "%");
        SetText(dtxcritical, pd.critical + "%");

按钮的打开与关闭

    public void ClickDetailBtn()
    {
        audioSvc.PlayUIAudio(Constants.UIClickBtn);
        SetActive(transDetail);
    }

    public void ClickCloseBtnDetail()
    {
        audioSvc.PlayUIAudio(Constants.UIClickBtn);
        SetActive(transDetail, false);
    }

任务引导系统开发思路

将xls文件导出成xml

任务引导系统配置数据

和之前解析随机名字和场景一样的来解析任务配置数据

public class AutoGuideCfg:BaseData<AutoGuideCfg>
{
    public int npcID;//触发任务NPC索引号
    public string dilogArr;
    public int actID;
    public int coin;
    public int exp;
}
    private Dictionary<int, AutoGuideCfg> guideTaskDic = new Dictionary<int, AutoGuideCfg>();
    private void InitGuideCfg(string path)
    {
        TextAsset xml = Resources.Load<TextAsset>(path);
        if (!xml)
        {
            PECommon.Log("xml file:" + path + " not exist", LogType.Error);
        }
        else
        {
            XmlDocument doc = new XmlDocument();
            doc.LoadXml(xml.text);

            XmlNodeList nodLst = doc.SelectSingleNode("root").ChildNodes;

            for (int i = 0; i < nodLst.Count; i++)
            {
                XmlElement ele = nodLst[i] as XmlElement;

                if (ele.GetAttributeNode("ID") == null)
                {
                    continue;
                }
                int ID = Convert.ToInt32(ele.GetAttributeNode("ID").InnerText);
                AutoGuideCfg mc = new AutoGuideCfg
                {
                    ID = ID
                };

                foreach (XmlElement e in nodLst[i].ChildNodes)
                {
                    switch (e.Name)
                    {
                        case "npcID":
                            mc.npcID = int.Parse(e.InnerText);
                            break;
                        case "diloagArr":
                            mc.dilogArr = e.InnerText;
                            break;
                        case "actID":
                            mc.npcID = int.Parse(e.InnerText);
                            break;
                        case "coin":
                            mc.coin = int.Parse(e.InnerText);
                            break;
                        case "exp":
                            mc.exp = int.Parse(e.InnerText);
                            break;
                    }
                }
                guideTaskDic.Add(ID, mc);
            }
        }
    }
    public AutoGuideCfg GetAutoGuideData(int id)
    {
        AutoGuideCfg agc = null;
        if (guideTaskDic.TryGetValue(id, out agc))
            return agc;
        return null;
    }

任务引导系统开发1

这节首先先在数据库的表中添加一个guideid的属性,用来表示当前正在进行的任务。

然后在服务器中修改各类增删改查数据库的操作,把guideid添加进去。

在客户端中,我们需要定义任务图标的路径和id,然后把它们设置到自动任务的图标上去。

    public const string TaskHead = "ResImages/task";
    public const string WiseManHead = "ResImages/wiseman";
    public const string GeneralHead = "ResImages/general";
    public const string ArtisanHead = "ResImages/artisan";
    public const string TraderHead = "ResImages/trader";
    //AutoGuideNpc
    public const int NPCWiseMan = 0;
    public const int NPCGeneral = 1;
    public const int NPCArtisan = 2;
    public const int NPCTrader = 3;

在MainCityWnd中写入新的方法

    private AutoGuideCfg curtTaskData;
    public Button btnGuide;    

    private void SetGuideBtnIcon(int npcID)
    {
        string spPath = "";
        Image image = btnGuide.GetComponent<Image>();
        switch(npcID)
        {
            case Constants.NPCWiseMan:
                spPath = PathDefine.WiseManHead;
                break;
            case Constants.NPCGeneral:
                spPath = PathDefine.GeneralHead;
                break;
            case Constants.NPCArtisan:
                spPath = PathDefine.ArtisanHead;
                break;
            case Constants.NPCTrader:
                spPath = PathDefine.TraderHead;
                break;
        }
    }

在RefreshUI中进行调用

        //设置自动任务图标
        curtTaskData = resSvc.GetAutoGuideData(pd.guideid);
        if(curtTaskData != null)
        {
            SetGuideBtnIcon(curtTaskData.npcID);
        }
        else
        {
            SetGuideBtnIcon(-1);
        }

任务引导系统开发2

封装通过路径加载图片的方法在ResSvc中

    private Dictionary<string, Sprite> spDic = new Dictionary<string, Sprite>();
    public Sprite LoadSprite(string path,bool cache = false)
    {
        Sprite sp = null;
        if(!spDic.TryGetValue(path,out sp))
        {
            sp = Resources.Load<Sprite>(path);
            if (cache)
            {
                spDic.Add(path, sp);
            }
        }

        return sp;
    }

然后在WindowRoot中完成设置图片的方法

    protected void SetSprite(Image img,string path)
    {
        Sprite sp = resSvc.LoadSprite(path,true);
        img.sprite = sp;
    }

最后在之前的SetGuideBtnIcon的方法中,完成最后一步赋值

SetSprite(image, spPath);

因为默认是1001白魔法师的图片 

任务引导系统开发3

首先给Guide按钮添加点击事件

    public void ClickGuideBtn()
    {
        audioSvc.PlayUIAudio(Constants.UIClickBtn);
        if(curtTaskData!=null)
        {
            MainCitySys.Instance.RunTask(curtTaskData);
        }
        else
        {
            GameRoot.AddTips("更多引导任务,正在开发中...");
        }
    }

然后在MainCitySys中进行任务处理

    private AutoGuideCfg curtTaskData;
    public void RunTask(AutoGuideCfg agc)
    {
        if (agc != null)
        {
            curtTaskData = agc;
        }

        //解析任务数据
        if(curtTaskData.npcID != -1)
        {

        }
        else
        {
            OpenGuideWnd();
        }
    }

    private void OpenGuideWnd()
    {
        //TODO
    }

任务引导系统开发4

首先在主城中创建好各个npc,然后每个npc创建一个空物体,用它的坐标来表示玩家和npc交互的位置。

建立一个MainCityMap脚本,用来存储npc交互的位置

using UnityEngine;

public class MainCityMap : MonoBehaviour 
{
    public Transform[] NpcPosTrans;
}

然后在MainCitySys中进行使用,修改EnterMainCity方法

private Transform[] npcPosTrans;
            //加载NPC的位置
            GameObject map = GameObject.FindGameObjectWithTag("MapRoot");
            MainCityMap mcm = map.GetComponent<MainCityMap>();
            npcPosTrans = mcm.NpcPosTrans;

任务引导系统开发5

使用Navigation烘焙自动寻路的路径,然后在角色身上挂上navigation的脚本。

平常使用Character Controller进行移动,导航时激活Nav Mesh Agent进行移动。

任务引导系统开发6

在MainCitySys中添加关于导航控制的代码,首先先拿到挂载的组件

private NavMeshAgent nav;

nav = player.GetComponent<NavMeshAgent>();

然后添加方法

    private bool isNavGuide = false;
    public void RunTask(AutoGuideCfg agc)
    {
        if (agc != null)
        {
            curtTaskData = agc;
        }

        //解析任务数据
        if(curtTaskData.npcID != -1)
        {
            float dis = Vector3.Distance(playerCtrl.transform.position, npcPosTrans[agc.npcID].position);
            if(dis<0.5f)//停止导航
            {
                isNavGuide = false;
                nav.isStopped = true;
                nav.enabled = false;
                playerCtrl.SetBlend(Constants.BlendIdle);

                OpenGuideWnd();
            }
            else//开始导航
            {
                isNavGuide = true;
                nav.enabled = true;
                nav.speed = Constants.PlayerMoveSpeed;
                nav.SetDestination(npcPosTrans[agc.npcID].position);
                playerCtrl.SetBlend(Constants.BlendWalk);
            }
        }
        else
        {
            OpenGuideWnd();
        }
    }

任务引导系统开发7

完善导航系统

    private bool isNavGuide = false;
    public void RunTask(AutoGuideCfg agc)
    {
        if (agc != null)
        {
            curtTaskData = agc;
        }

        nav.enabled = true;
        //解析任务数据
        if(curtTaskData.npcID != -1)
        {
            float dis = Vector3.Distance(playerCtrl.transform.position, npcPosTrans[agc.npcID].position);
            if(dis<0.5f)//停止导航
            {
                isNavGuide = false;
                nav.isStopped = true;
                nav.enabled = false;
                playerCtrl.SetBlend(Constants.BlendIdle);
                Debug.Log("stop");
                OpenGuideWnd();
            }
            else//开始导航
            {
                isNavGuide = true;
                nav.enabled = true;
                nav.speed = Constants.PlayerMoveSpeed;
                nav.SetDestination(npcPosTrans[agc.npcID].position);
                playerCtrl.SetBlend(Constants.BlendWalk);
            }
        }
        else
        {
            OpenGuideWnd();
        }
    }

    private void IsArriveNavPos()
    {
        float dis = Vector3.Distance(playerCtrl.transform.position, npcPosTrans[curtTaskData.npcID].position);
        if (dis < 0.5f)//停止导航
        {
            isNavGuide = false;
            nav.isStopped = true;
            nav.enabled = false;
            playerCtrl.SetBlend(Constants.BlendIdle);
            Debug.Log("stop");
            OpenGuideWnd();
        }
    }

    private void Update()
    {
        if (isNavGuide)
        {
            IsArriveNavPos();
            playerCtrl.SetCam();
        }           
    }

    private void StopNavTask()
    {
        if(isNavGuide)
        {
            isNavGuide = false;

            nav.isStopped = true;
            nav.enabled = false;
            playerCtrl.SetBlend(Constants.BlendIdle);
        }
    }

在自己控制人物移动和打开界面的时候调用StopNavTask()方法,停止导航。

NPC对话界面1

完成GuideWnd的设计,给它添加同名脚本,并在MainCitySys中进行调用

    private void OpenGuideWnd()
    {
        guideWnd.SetWndState();
    }

NPC对话界面2

首先把在PathDefine中,定义好各个NPC的图片位置

    public const string SelfIcon = "ResImages/assassin";
    public const string GuideIcon = "ResImages/npcguide";
    public const string WiseManIcon = "ResImages/npc0";
    public const string GenralIcon = "ResImages/npc1";
    public const string AretisanIcon = "ResImages/npc2";
    public const string TraderIcon = "ResImages/npc3";

然后在GuideWnd中完成对话功能

public class GuideWnd : WindowRoot 
{
    public Text txtName;
    public Text txtTalk;
    public Image imgIcon;

    private PlayerData pd;
    private AutoGuideCfg curtTaskData;
    private string[] dilogArr;
    private int index;

    protected override void InitWnd()
    {
        base.InitWnd();

        pd = GameRoot.Instance.PlayerData;
        curtTaskData = MainCitySys.Instance.GetCurtTaskData();
        dilogArr = curtTaskData.dilogArr.Split('#');
        index = 1;

        SetTalk();
    }

    private void SetTalk()
    {
        string[] talkArr = dilogArr[index].Split('|');
        if(talkArr[0]=="0")
        {
            //自己
            SetSprite(imgIcon,PathDefine.SelfIcon);
            SetText(txtName,pd.name);
        }
        else
        {
            //对话NPC
            switch(curtTaskData.npcID)
            {
                case 0:
                    SetSprite(imgIcon, PathDefine.WiseManIcon);
                    SetText(txtName, "智者");
                    break;
                case 1:
                    SetSprite(imgIcon, PathDefine.GenralIcon);
                    SetText(txtName, "将军");
                    break;
                case 2:
                    SetSprite(imgIcon, PathDefine.AretisanIcon);
                    SetText(txtName, "工匠");
                    break;
                case 3:
                    SetSprite(imgIcon, PathDefine.TraderIcon);
                    SetText(txtName, "商人");
                    break;
                default:
                    SetSprite(imgIcon, PathDefine.GuideIcon);
                    SetText(txtName, "真夏糖");
                    break;
            }
        }

        imgIcon.SetNativeSize();
        SetText(txtTalk, talkArr[1].Replace("$name", pd.name));
    }

    public void ClickNextBtn()
    {
        audioSvc.PlayUIAudio(Constants.UIClickBtn);
        index += 1;
        if(index == dilogArr.Length)
        {
            //TODO 发送引导任务完成信息
            SetWndState(false);        
        }
        else
        {
            SetTalk();
        }
    }
}

定制引导数据网络协议

在服务器端的GameMsg中添加引导相关的属性

    [Serializable]
    public class ReqGuide
    {
        public int guideid;
    }

    [Serializable]
    public class RspGuide
    {
        public int guideid;
        public int coin;
        public int lv;
        public int exp;
    }

给引导相关的属性添加CMD号

        //主城相关 200
        ReqGuide = 200,
        RspGuide = 201,

在GameMsg类中添加这两个属性

    [Serializable]
    public class GameMsg : PEMsg {
        public ReqLogin reqLogin;
        public RspLogin rspLogin;

        public ReqRename reqRename;
        public RspRename rspRename;

        public ReqGuide reqGuide;
        public RspGuide rspGuide;
    }

从客户端向服务器端发送ReqGuide

            //发送引导任务完成信息
            GameMsg msg = new GameMsg
            {
                cmd = (int)CMD.ReqGuide,
                reqGuide = new ReqGuide
                {
                    guideid = curtTaskData.ID
                }
            };
            netSvc.SendMsg(msg);

引导数据网络协议处理

服务器端创建GuideSys用来处理引导信息

//引导业务系统

using PEProtocol;

class GuideSys
{
    private static GuideSys instance = null;
    public static GuideSys Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new GuideSys();
            }
            return instance;
        }
    }
    private CacheSvc cacheSvc = null;

    public void Init()
    {
        cacheSvc = CacheSvc.Instance;
        PECommon.Log("GuideSysInit Done.");
    }

    public void ReqGuide(MsgPack pack)
    {
        ReqGuide data = pack.msg.reqGuide;

        GameMsg msg = new GameMsg
        {
            cmd = (int)CMD.RspGuide

        };
        PlayerData pd = cacheSvc.GetPlayerDataBySession(pack.session);
        //更新引导ID
        if(pd.guideid == data.guideid)
        {
            pd.guideid += 1;
            //更新玩家数据


        }
        else
        {
            msg.err = (int)ErrorCode.ServerDataError;
        }
        //发送回客户端
        pack.session.SendMsg(msg);
    }
}

增加服务器数据配置服务

服务器端创建CfgSvc,用来从服务器端读取引导信息

//配置数据服务
using System;
using System.Collections.Generic;
using System.Xml;
public class CfgSvc
{
    private static CfgSvc instance = null;
    public static CfgSvc Instance
    {
        get
        {
            if (instance == null)
            {
                instance = new CfgSvc();
            }
            return instance;
        }
    }

    public void Init()
    {
        InitGuideCfg();
        PECommon.Log("CfgSvc Init Done.");
    }

    #region 自动引导服务
    private Dictionary<int, GuideCfg> guideDic = new Dictionary<int, GuideCfg>();
    private void InitGuideCfg()
    {
            XmlDocument doc = new XmlDocument();
            doc.Load(@"D:\u3dworkspace\DarkGod_第五章\Client\Assets\Resources\ResCfgs\guide.xml");

            XmlNodeList nodLst = doc.SelectSingleNode("root").ChildNodes;

            for (int i = 0; i < nodLst.Count; i++)
            {
                XmlElement ele = nodLst[i] as XmlElement;

                if (ele.GetAttributeNode("ID") == null)
                {
                    continue;
                }
                int ID = Convert.ToInt32(ele.GetAttributeNode("ID").InnerText);
                GuideCfg mc = new GuideCfg
                {
                    ID = ID
                };

                foreach (XmlElement e in nodLst[i].ChildNodes)
                {
                    switch (e.Name)
                    {
                        case "coin":
                            mc.coin= int.Parse(e.InnerText);
                            break;
                        case "exp":
                            mc.exp = int.Parse(e.InnerText);
                            break;
                    }
                }
                guideDic.Add(ID, mc);
            }
        }
    public GuideCfg GetGuideData(int id)
    {
        GuideCfg agc = null;
        if (guideDic.TryGetValue(id, out agc))
            return agc;
        return null;
    }
}
    #endregion
public class GuideCfg : BaseData<GuideCfg>
{
    public int coin;
    public int exp;

}

public class BaseData<T>
{
    public int ID;
}

经验升级计算

添加一个计算经验值和升级的方法

    private void CalcExp(PlayerData pd,int addExp)
    {
        int curtLv = pd.lv;
        int curtExp = pd.exp;
        int addRestExp = addExp;
        while(true)
        {
            int upNeedExp = PECommon.GetExpUpValByLv(curtLv) - curtExp;
            if(addRestExp>=upNeedExp)
            {
                curtLv += 1;
                curtExp = 0;
                addRestExp -= upNeedExp;
            }
            else
            {
                pd.lv = curtLv;
                pd.exp = curtExp + addRestExp;
                break;
            }
        }
    }

完善处理ReqGuide的方法

    public void ReqGuide(MsgPack pack)
    {
        ReqGuide data = pack.msg.reqGuide;

        GameMsg msg = new GameMsg
        {
            cmd = (int)CMD.RspGuide

        };
        PlayerData pd = cacheSvc.GetPlayerDataBySession(pack.session);
        GuideCfg gc = cfgSvc.GetGuideData(data.guideid);

        //更新引导ID
        if(pd.guideid == data.guideid)
        {
            pd.guideid += 1;

            //更新玩家数据
            pd.coin += gc.coin;
            CalcExp(pd, gc.exp);

            if(!cacheSvc.UpdatePlayerData(pd.id,pd))
            {
                msg.err = (int)ErrorCode.UpdateDBError;
            }
            else
            {
                msg.rspGuide = new RspGuide
                {
                    guideid = pd.guideid,
                    coin = pd.coin,
                    lv = pd.lv,
                    exp = pd.exp
                };
            }
        }
        else
        {
            msg.err = (int)ErrorCode.ServerDataError;
        }
        //发送回客户端
        pack.session.SendMsg(msg);
    }

更新客户端任务

在客户端MainCitySys中处理RspGuide

    public void RspGuide(GameMsg msg)
    {
        RspGuide data = msg.rspGuide;
        GameRoot.AddTips("任务奖励 金币+" + curtTaskData.coin+" 经验+"+curtTaskData.exp);
        switch(curtTaskData.actID)
        {
            case 0:
                //与智者对话
                break;
            case 1:
                //进入副本
                break;
            case 2:
                //强化界面
                break;
            case 3:
                //体力购买
                break;
            case 4:
                //金币铸造
                break;
            case 5:
                //世界聊天
                break;
        }
        GameRoot.Instance.SetPlayerDataByGuide(data);
        maincityWnd.RefreshUI();
    }

其中SetPlayerDataByGuide()

    public void SetPlayerDataByGuide(RspGuide data)
    {
        PlayerData.coin = data.coin;
        playerData.exp = data.exp;
        playerData.lv = data.lv;
        playerData.guideid = data.guideid;
    }

增加文字染色工具

在Constants中添加颜色和染色工具

public enum TxtColor
{
    Red,
    Green,
    Blue,
    Yellow
}

    private const string ColorRed = "<color=#FF0000FF>";
    private const string ColorGreen = "<color=#00FF00FF>";
    private const string ColorBlue = "<color=#00B4FFFF>";
    private const string ColorYellow = "<color=#FFFF00FF>";
    private const string ColorEnd = "</color>";

    public static string Color(string str,TxtColor c)
    {
        string result = "";
        switch(c)
        {
            case TxtColor.Red:
                result = ColorRed + str + ColorEnd;
                break;
            case TxtColor.Green:
                result = ColorGreen + str + ColorEnd;
                break;
            case TxtColor.Blue:
                result = ColorBlue + str + ColorEnd;
                break;
            case TxtColor.Yellow:
                result = ColorYellow + str + ColorEnd;
                break;
        }
        return result;
    }

在适当的地方进行调用

GameRoot.AddTips(Constants.Color("任务奖励 金币+" + curtTaskData.coin+" 经验+"+curtTaskData.exp,TxtColor.Blue));

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

  • 10
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值