unity 一些有用的碎片知识整理 之 二 (之 四 持续更新中...)

—— 系列文章链接

Unity 一些有用的碎片知识整理 之 一 点击可跳转链接

Unity 一些有用的碎片知识整理 之 三 点击可跳转链接

Unity 一些有用的碎片知识整理 之 四 点击可跳转链接


目录

二十四、Unity移动端下载Zip文件并解压

二十五、Zip 在 Unity中的 压缩指定文件和解压指定压缩文件的封装代码

二十六、Vuforia 停止识别关闭camera的方法,和切换前后摄像头的方法

二十七、Unity 以圆形阵列方式生成物体

二十八、较新版的 Unity 2018.x.xx 之后的 Standard Assets 在哪里下载? 答:Aseets App

二十九、Unity 中动态修改天空盒子(Skybox)

三十、Unity 中的 AI 简单使用

三十一、Unity中数据的加密和解密的简单使用

三十二、Unity 中的使用 MD5 加密的作用之一

三十三、Unity中使用有限状态机FSM进行游戏开发

三十四、Unity3D游戏开发流程与规范(参考)

三十五、Unity Android JDK 版本更新的Oracle账号(不然可能下载不了)

三十六、Unity打包Android问题记录 "CommandInvokationFailure: Gradle build failed."

三十七、Unity持久化存储之PlayerPrefs的使用

三十八、unity 本地MP3文件读取

三十九、Unity简易实用的Log封装

四十、UNITY所谓的异步加载几乎全部是协程,不是线程;MAP3加载时解压非常慢


二十四、Unity移动端下载Zip文件并解压

ICSharpCode.SharpZipLib.dll下载地址:[官网地址](http://www.icsharpcode.net/opensource/sharpziplib/)


using UnityEngine;
using System.Collections;
using System;
using System.IO;
using ICSharpCode.SharpZipLib.Zip;


public class LoadZipPackageAndUnzip: MonoBehaviour
{

    private string sourcesPath = "";

    private string targetPath = "";

    private string filename = "Fuyou.zip";

    //private string url = "http://xnwy-adm.91uutu.com/static/Untitled_13.zip";
    private string url ;



    public void button()

    {
        url = Application.streamingAssetsPath + "/" +"Fuyou.zip";
        //sourcesPath = Application.persistentDataPath + "/Zip";

        //targetPath = Application.persistentDataPath + "/Resources";
        //Debug.Log("sourcesPaht is:" + sourcesPath + "   " + targetPath);

        StartCoroutine(Wait_LoadDown(filename, url));

    }





    /// <summary>
    /// 下载
    /// </summary>
    /// <param name="ZipID" ZipID的名字,用于存储解压出的每一个Zip文件></param>
    /// <param name="url" Zip下载地址></param>
    /// <returns></returns>
    IEnumerator Wait_LoadDown(string ZipID, string url)
    {
        WWW www = new WWW(url);
        yield return www;
        if (www.isDone)
        {
            if (www.error == null)
            {
                Debug.Log("下载成功");
                string dir = Application.persistentDataPath;
                Debug.Log(dir);
                if (!Directory.Exists(dir))
                    Directory.CreateDirectory(dir);

                yield return new WaitForEndOfFrame();
                //直接使用 将byte转换为Stream,省去先保存到本地在解压的过程
                zip1.SaveZip(ZipID, url, www.bytes, null);

            }
            else
            {
                Debug.Log(www.error);
            }
        }

    }

    /// <summary> 
    /// 解压功能(下载后直接解压压缩文件到指定目录) 
    /// </summary> 
    /// <param name="wwwStream">www下载转换而来的Stream</param> 
    /// <param name="zipedFolder">指定解压目标目录(每一个Obj对应一个Folder)</param> 
    /// <param name="password">密码</param> 
    /// <returns>解压结果</returns> 
    public static bool SaveZip(string ZipID, string url, byte[] ZipByte, string password)
    {
        bool result = true;
        FileStream fs = null;
        ZipInputStream zipStream = null;
        ZipEntry ent = null;
        string fileName;

        ZipID = Application.persistentDataPath + "/" + ZipID;
        Debug.Log("ZipID" + ZipID);
        Debug.Log(ZipID);

        if (!Directory.Exists(ZipID))
        {
            Directory.CreateDirectory(ZipID);
        }
        try
        {
            //直接使用 将byte转换为Stream,省去先保存到本地在解压的过程
            Stream stream = new MemoryStream(ZipByte);
            zipStream = new ZipInputStream(stream);

            if (!string.IsNullOrEmpty(password))
            {
                zipStream.Password = password;
            }
            while ((ent = zipStream.GetNextEntry()) != null)
            {
                if (!string.IsNullOrEmpty(ent.Name))
                {
                    fileName = Path.Combine(ZipID, ent.Name);

                    #region      Android
                    fileName = fileName.Replace('\\', '/');
                    Debug.Log(fileName);
                    if (fileName.EndsWith("/"))
                    {
                        Directory.CreateDirectory(fileName);
                        continue;

                    }
                    #endregion
                    fs = File.Create(fileName);

                    int size = 2048;
                    byte[] data = new byte[size];
                    while (true)
                    {
                        size = zipStream.Read(data, 0, data.Length);
                        if (size > 0)
                        {
                            //fs.Write(data, 0, data.Length);
                            Debug.Log(data.Length);
                            fs.Write(data, 0, size);//解决读取不完整情况
                        }
                        else
                            break;
                    }

                    Debug.Log("解压成功...");
                }
            }
        }
        catch (Exception e)
        {
            Debug.Log(e.ToString());
            result = false;
        }
        finally
        {
            if (fs != null)
            {
                fs.Close();
                fs.Dispose();
            }
            if (zipStream != null)
            {
                zipStream.Close();
                zipStream.Dispose();
            }
            if (ent != null)
            {
                ent = null;
            }
            GC.Collect();
            GC.Collect(1);

            
        }
        return result;
    }
}

在 Android 可能会报错: System.NotSupportedException: Encoding 437 data could not be found. Make sure you have correct international codeset assembly installed and enabled.(无法找到编码437数据的代码。确保安装并启用了正确的国际代码集程序集。)

解决方式:

把自己Unity安装文件夹下的 I18N dll 系列文件导入Unity中和 ICSharpCode.SharpZipLib.dll 一起使用

.......\Unity\Editor\Data\Mono\lib\mono\unity

二十五、Zip 在 Unity中的 压缩指定文件和解压指定压缩文件的封装代码

using ICSharpCode.SharpZipLib.Zip;
using System;
using System.IO;
using UnityEngine;

public class ZipHelper 
{
    /// <summary>  
    /// 功能:解压zip格式的文件。  
    /// </summary>  
    /// <param name="zipFilePath">压缩文件路径 </param>  
    /// <param name="unZipDir">解压文件存放路径,为空时默认与压缩文件同一级目录下,跟压缩文件同名的文件夹</param>  
    /// <returns>解压是否成功</returns>  
    public static bool UnZip(string zipFilePath, string unZipDir)
    {
        try
        {
            if (zipFilePath == string.Empty)
            {
                throw new Exception("压缩文件不能为空!");
            }
            if (!File.Exists(zipFilePath))
            {
                throw new FileNotFoundException("压缩文件不存在!");
            }
            //解压文件夹为空时默认与压缩文件同一级目录下,跟压缩文件同名的文件夹  
            if (unZipDir == string.Empty)
                unZipDir = zipFilePath.Replace(Path.GetFileName(zipFilePath), Path.GetFileNameWithoutExtension(zipFilePath));
            if (!unZipDir.EndsWith("/"))
                unZipDir += "/";
            if (!Directory.Exists(unZipDir))
                Directory.CreateDirectory(unZipDir);
            using (var s = new ZipInputStream(File.OpenRead(zipFilePath)))
            {

                ZipEntry theEntry;
                while ((theEntry = s.GetNextEntry()) != null)
                {
                    string directoryName = Path.GetDirectoryName(theEntry.Name);
                    string fileName = Path.GetFileName(theEntry.Name);
                    if (!string.IsNullOrEmpty(directoryName))
                    {
                        Directory.CreateDirectory(unZipDir + directoryName);
                    }
                    if (directoryName != null && !directoryName.EndsWith("/"))
                    {
                    }
                    if (fileName != String.Empty)
                    {
                        using (FileStream streamWriter = File.Create(unZipDir + theEntry.Name))
                        {

                            int size;
                            byte[] data = new byte[2048];
                            while (true)
                            {
                                size = s.Read(data, 0, data.Length);
                                if (size > 0)
                                {
                                    streamWriter.Write(data, 0, size);
                                }
                                else
                                {
                                    break;
                                }
                            }
                        }
                    }
                }
            }
            return true;
        }
        catch (Exception)
        {

            return false;
        }

    }

    /// <summary>
    /// 压缩所有的文件
    /// </summary>
    /// <param name="filesPath">(目录文件夹的路径(不是具体文件))</param>
    /// <param name="zipFilePath">(xxxx.zip)</param>
    public static void CreateZipFile(string filesPath, string zipFilePath)
    {
        Debug.Log("CreateZipFile");

        if (!Directory.Exists(filesPath))
        {
            return;
        }
        ZipOutputStream stream = new ZipOutputStream(File.Create(zipFilePath));
        stream.SetLevel(0); // 压缩级别 0-9
        byte[] buffer = new byte[4096]; //缓冲区大小
        string[] filenames = Directory.GetFiles(filesPath, "*.*", SearchOption.AllDirectories);
        foreach (string file in filenames)
        {
            ZipEntry entry = new ZipEntry(file.Replace(filesPath, ""));
            entry.DateTime = DateTime.Now;
            stream.PutNextEntry(entry);
            using (FileStream fs = File.OpenRead(file))
            {
                int sourceBytes;
                do
                {
                    sourceBytes = fs.Read(buffer, 0, buffer.Length);
                    stream.Write(buffer, 0, sourceBytes);
                } while (sourceBytes > 0);
            }
        }
        stream.Finish();
        stream.Close();

        Debug.Log("CreateZipFile Success!!!");
    }
}

二十六、Vuforia 停止识别关闭camera的方法,和切换前后摄像头的方法

void OnGUI()  
        {  
  
            if (GUI.Button(new Rect(50, 50, 200, 50), "使用前置摄像头"))  
            {  
  
         
// 停止识别  
                CameraDevice.Instance.Stop();
// 取消实例化摄像机
                CameraDevice.Instance.Deinit();  
  

//实例化相机
                CameraDevice.Instance.Init(CameraDevice.CameraDirection.CAMERA_FRONT);
// 开始识别
                CameraDevice.Instance.Start();  
  
            }  
  
  
            if (GUI.Button(new Rect(50, 150, 200, 50), "使用主摄像头"))  
            {  
  
// 停止识别  
                CameraDevice.Instance.Stop();  
// 取消实例化摄像机
                CameraDevice.Instance.Deinit();  
  
//实例化相机
                CameraDevice.Instance.Init(CameraDevice.CameraDirection.CAMERA_BACK);  
// 开始识别
                CameraDevice.Instance.Start();  
  
   
            }  
        }  

 

二十七、Unity 以圆形阵列方式生成物体

using UnityEngine;

public class TestCircularRing : MonoBehaviour
{
    public GameObject circleModel;
    public int ChangeAngle = 0;
    private int count;
    private float Angle;
    private float r = 10;
    private void Start()
    {
        count = 360 / ChangeAngle;//需要的个数
        for (int i = 0; i < count; i++)
        {
            Vector3 center = circleModel.transform.position;
            GameObject cube = (GameObject)Instantiate(circleModel);

            // 计算出圆形边点的位置
            float hudu = (Angle / 180) * Mathf.PI;
            float xx = center.x + r * Mathf.Cos(hudu);
            float yy = center.y + r * Mathf.Sin(hudu);
            cube.transform.position = new Vector3(xx, yy, 0);
            cube.transform.LookAt(center);
            Angle += ChangeAngle;

        }
    }
}

 

二十八、较新版的 Unity 2018.x.xx 之后的 Standard Assets 在哪里下载? 答:Aseets App

 

二十九、Unity 中动态修改天空盒子(Skybox)

在Unity中动态修改天空盒有三种方法:

1)为每个Texture建立天空盒材质球,需要更换时直接将对应材质球作为天空盒,缺点是建立的材质球太多

    private void ChangeSkybox(Material newSkybox)
    {
        RenderSettings.skybox = newSkybox;
    }

2)只创建一个天空盒材质球,通过修改Material的贴图Cubemap来动态替换,优点是不需要创建额外的材质球

  但是使用一般的Material.mainTexture属性无法赋值Cubemap类型的贴图。

  通过查阅Unity - Scripting API,找到一个修改Material的Texture的方法:Material.SetTexture(string name, Texture value)

我们发现通过指定其中的参数name可以获取对应的贴图位置。但是并没有说明Cubemap的参数是什么。因此只能去查看Shader的Property。

选中创建的天空盒材质球,右键点击Shader选择Edit Shader...,打开Shader的详细信息,如下图所示

在打开的Shader详细信息中我们发现了在Properties有这样一行

原来Cubemap的name参数应该填的是“_Tex”,name参数其实指的是Shader中对应的Property属性。

所以通过以下方法可以动态更换天空盒的Cubemap贴图

     private void ChangeSkybox(Texture texture)
     {
         RenderSettings.skybox.SetTexture("_Tex", texture);
     }

3)使用 Skybox 组件,添加到 camera 组件上

using UnityEngine;
using System.Collections;

public class changgeSkybox : MonoBehaviour {

        // Use this for initialization

        public Material[] mat =new Material[5];
        public Skybox sky;

        void Start () {
       
                 sky = GetComponent<Skybox>();
        }
       
        // Update is called once per frame
        void Update () {

                 
                if (Input.GetKeyDown(KeyCode.Space))
                {
                       sky.material = mat[Random.Range(0, mat.Length)];
                }
       
        }
}

 

三十、Unity 中的 AI 简单使用

首先你应该搞清楚的一点AI脚本属于一个工具类脚本,工具类脚本的含义就是他应当是由策划人员来绑定游戏对象使用的。也就是说AI脚本程序员应当写的非常的灵活,策划人员可以通过修改脚本对外的变量数值接口就能控制其中的敌人AI。接着创建一个c#脚本AI.CS ,如下图所示,目前脚本对外留出枚举接口变量,策划人员在使用这条脚本时选择对应敌人类型即可。(注:这里仅仅是示例,细致的话还可以将很多敌人详细的信息写入,如:攻击速度、技能类型、移动速度、命中率、攻击百分比、等等,但是一定要让你的脚本写的比较灵活,策划人员在外面选择即可完成)因为目前是一个示例,所以我在这里只简单的区分的敌人类型。

using UnityEngine;
using System.Collections;
 
//这里是枚举选择敌人类型
public enum EnemyType
{
	Enemy0,
	Enemy1
}
 
public class AI : MonoBehaviour {
 
	//敌人类型枚举 有策划人员选择
	public EnemyType enemyType = EnemyType.Enemy0;
 
	//主角游戏对象
	public GameObject player;
 
	//敌人状态 普通状态 旋转状态 奔跑状态 追击主角状态 攻击主角状态
	private const int EMEMY_NORMAL=0;
	private const int EMEMY_ROTATION=1;
	private const int EMEMY_RUN = 2;
	private const int EMEMY_CHASE = 3;
	private const int EMEMY_ATTACK = 4;
 
	//记录当前敌人状态 根据不同类型 敌人播放不同动画
	private int state;
	//旋转状态,敌人自身旋转
	private int rotation_state;
	//记录敌人上一次思考时间
	private float aiThankLastTime; 
 
	void Start ()
	{
		//初始话标志敌人状态 以及动画为循环播放
		state = EMEMY_NORMAL;
		this.animation.wrapMode = WrapMode.Loop;
	}
 
	void Update ()
	{
		//根据策划选择的敌人类型 这里面会进行不同的敌人AI
		switch(enemyType)
		{
		case EnemyType.Enemy0:
			updateEnemyType0();
			break;
		case EnemyType.Enemy1:
			updateEnemyType1();
			break;
		}
	}
 
	//更新第一种敌人的AI
	void updateEnemyType0()
	{
		//这个AI比较简单, 当主角与他的距离小于10米时,他将始终朝向这主角
		if(Vector3.Distance(player.transform.position,this.transform.position) <= 10)
		{
			this.transform.LookAt(player.transform);
		}
	}
 
	//更新第二种敌人的AI
	void updateEnemyType1()
	{
 
		//判断敌人是否开始思考
		if(isAIthank())
		{
			//敌人开始思考
			AIthankEnemyState(3);
		}else
		{
			//更新敌人状态
			UpdateEmenyState();
		}
	}
 
	int getRandom(int count)
	{
 
		 return new System.Random().Next(count);
 
	}
 
	bool isAIthank()
	{
		//这里表示敌人每3秒进行一次思考
		if(Time.time - aiThankLastTime >=3.0f)
		{
			aiThankLastTime = Time.time;
			return true;
 
		}
		return false;
	}
 
	//敌人在这里进行思考
	void AIthankEnemyState(int count)
	{
		//开始随机数字。
		int d = getRandom(count);
 
		switch(d)
		{
		case 0:
			//设置敌人为站立状态
			setEmemyState(EMEMY_NORMAL);
			break;
		case 1:
			//设置敌人为旋转状态
			setEmemyState(EMEMY_ROTATION);
			break;
		case 2:
			//设置敌人为奔跑状态
			setEmemyState(EMEMY_RUN);
			break;
		}
 
	}
 
	void setEmemyState(int newState)
	{
		if(state == newState)
			return;
		state = newState;
 
		string animName = "Idle";
		switch(state)
		{
		case EMEMY_NORMAL:
			animName  =  "Idle";
			break;
		case EMEMY_RUN:
			animName  =  "Run";
			break;
		case EMEMY_ROTATION:
			animName  =  "Run";
			//当敌人为旋转时, 开始随机旋转的角度系数
			rotation_state = getRandom(4);
			break;
		case EMEMY_CHASE:
			animName  =  "Run";
			//当敌人进入追击状态时,将面朝主角方向奔跑
			this.transform.LookAt(player.transform);
			break;
		case EMEMY_ATTACK:
			animName  =  "Attack";
			//当敌人进入攻击状态时,继续朝向主角开始攻击砍人动画
			this.transform.LookAt(player.transform);
			break;
		}
 
		//避免重复播放动画,这里进行判断
		if(!this.animation.IsPlaying(animName))
		{
			//播放动画
			this.animation.Play(animName);
		}
 
	}
 
	//在这里更新敌人状态
	void UpdateEmenyState()
	{
		//判断敌人与主角之间的距离
		float distance = Vector3.Distance(player.transform.position,this.transform.position);
		//当敌人与主角的距离小于10 敌人将开始面朝主角追击
		if(distance <= 10)
		{
			//当敌人与主角的距离小与3 敌人将开始面朝主角攻击
			if(distance <= 3)
			{
				setEmemyState(EMEMY_ATTACK);
			}else
			{
			    //否则敌人将开始面朝主角追击
				setEmemyState(EMEMY_CHASE);
			}
 
		}else
		{
			//敌人攻击主角时 主角迅速奔跑 当它们之间的距离再次大于10的时候 敌人将再次进入正常状态 开始思考
			if(state == EMEMY_CHASE || state == EMEMY_ATTACK)
			{
				setEmemyState(EMEMY_NORMAL);
			}
 
		}
 
		switch(state)
		{
		case EMEMY_ROTATION:
			//旋转状态时 敌人开始旋转, 旋转时间为1秒 这样更加具有惯性
			transform.rotation = Quaternion.Lerp (transform.rotation, Quaternion.Euler(0,rotation_state * 90,0),  Time.deltaTime * 1);
			break;
		case EMEMY_RUN:
			//奔跑状态,敌人向前奔跑
			transform.Translate(Vector3.forward *0.02f);
			break;
		case EMEMY_CHASE:
			//追击状态 敌人向前开始追击
			transform.Translate(Vector3.forward *0.02f);
			break;
		case EMEMY_ATTACK:
 
			break;
		}
 
	}
}

 

三十一、Unity中数据的加密和解密的简单使用

using System;
using System.Security.Cryptography;
using System.Text;

/// <summary>
/// 加密和解密数据
/// </summary>
public class EncryptionAndDecryptionDataHelper 
{
    /// <summary>
    /// 加密
    /// </summary>
    /// <param name="_input">在输入框中需要加密内容</param>
    /// <param name="_keyValue">加密的秘钥</param>
    /// <returns>返回加密的结果</returns>
    public static string ConductEncryption(string _input, string _keyValue)
    {
        byte[] keyArray = UTF8Encoding.UTF8.GetBytes(_keyValue);

        //加密格式
        RijndaelManaged encryption = new RijndaelManaged();
        encryption.Key = keyArray;
        encryption.Mode = CipherMode.ECB;
        encryption.Padding = PaddingMode.PKCS7;

        //生成加密锁
        ICryptoTransform cTransform = encryption.CreateEncryptor();
        byte[] _EncryptArray = UTF8Encoding.UTF8.GetBytes(_input);
        byte[] resultArray = cTransform.TransformFinalBlock(_EncryptArray, 0, _EncryptArray.Length);
        return Convert.ToBase64String(resultArray, 0, resultArray.Length);
    }

    /// <summary>
    /// 解密数据
    /// </summary>
    /// <param name="_valueDense">要解密的数据</param>
    /// <param name="_keyValue">解密的秘钥</param>
    /// <returns>返回解密的结果</returns>
    public static string ConductDecrypt(string _valueDense, string _keyValue)
    {
        byte[] keyArray = UTF8Encoding.UTF8.GetBytes(_keyValue);

        RijndaelManaged decipher = new RijndaelManaged();
        decipher.Key = keyArray;
        decipher.Mode = CipherMode.ECB;
        decipher.Padding = PaddingMode.PKCS7;

        ICryptoTransform cTransform = decipher.CreateDecryptor();
        byte[] _EncryptArray = Convert.FromBase64String(_valueDense);
        byte[] resultArray = cTransform.TransformFinalBlock(_EncryptArray, 0, _EncryptArray.Length);
        return UTF8Encoding.UTF8.GetString(resultArray);
    }
}
using UnityEngine;
using UnityEngine.UI;


public class Encryption : MonoBehaviour
{
    /// <summary>
    /// 需要加密内容
    /// </summary>
    public InputField inputCtt;

    /// <summary>
    /// 加密结果
    /// </summary>
    public InputField inputRes;

    /// <summary>
    /// 32位任意数值,作为是加密解码约定数字
    /// </summary>
    private string keyValue = "01234567890123456789012345678901";

    public void OnBtnEncryption()
    {
        if (inputCtt.text.Length != 0)
        {
            string str = EncryptionAndDecryptionDataHelper.ConductEncryption(inputCtt.text, keyValue);
            inputRes.text = str;
        }
        else
        {
            Debug.Log("请输入加密内容");
        }
    }
}
using UnityEngine;
using UnityEngine.UI;


public class Decrypt : MonoBehaviour
{
    /// <summary>
    /// 获得需要解密的字符串
    /// </summary>
    public InputField valueDense;

    /// <summary>
    /// 32位任意数值,作为是加密解码约定数字
    /// </summary>
    private string keyValue = "01234567890123456789012345678901";

    public void OnBtnDecrypt()
    {
        if (valueDense.text.Length != 0)
        {
            string str = EncryptionAndDecryptionDataHelper.ConductDecrypt(valueDense.text, keyValue);
            valueDense.text = str;
        }
        else
        {
            Debug.Log("请输入需要解密的值");
        }
    }
}

 

三十二、Unity 中的使用 MD5 加密的作用之一

大家应该听过换皮类似的名词吧,因为本地的那些游戏资源没有什么保护的机制,只要用类似的游戏资源去替换本地存在游戏资源,这样就可以实现换皮了(资源可以包括声音,图片,模型等等),不知道大家有没有用过英雄联盟的换模型的一个软件,其实原理很简单的,就是把做好的模型资源拖到对应的文件夹,然后改一个配置文件就可以了(有兴趣自己可以网上搜索一下),所以怎么可以预防被别人替换资源呢,接下来MD5加密就出来,可以知道那些文件有没有被替换过,而且手游热更新AB包资源的原理也是如此,现在就给大家来点干货,如图下:
 

这里用了一个DLL库,这个库是有md5加密功能的,调用了一下加密的接口,可以看到每一个文件会生成不同的密钥(如果里面的文件内容是相同的,密钥也是一样的,和文件的名字无关),大家可以自己网上查找一下有没有类似的DLL,或者自己写一个md5加密算法。这样需要保护的游戏资源,在游戏启动之前先计算那些游戏资源的密钥,然后和远程服务器的密钥进行对比,把密钥不一致的资源存到一个数据结构中,这些资源是需要重新下载的,这里有一个优化的方法就是这类型的资源再生成一个汇总文件然后对这个汇总文件生成一个md5秘钥,这样校验资源的时候直接校验这个文件就可以了,当然如果汇总秘钥不一致,可以选择这类型的资源一个个校验或者这一块的游戏资源文件需要全部重新下载(比如声音的汇总文件的秘钥不一致了,所有的声音资源需要重新下载了)。如果你需要对单机游戏资源进行校验的话,因为没有服务器的关系,我们可以生成md5密钥列表,把密钥存到代码中,然后本地去校验这个资源有没有被替换,如果被替换了就让玩家重新下载游戏。
 

using UnityEngine;

/// <summary>
/// 获取游戏数据
/// 1、MD5 加密数据
/// 2、如果原始数据被修改,与 MD5 值不一样,就会提示数据被篡改破坏
/// </summary>
public class scoreHandler : MonoBehaviour
{

    private int _score = 0;//当前分手
    private int _bestscore;//最高分

    // Use this for initialization
    void Start()
    {
        _bestscore = getHighScoreFromDb();
        Debug.Log("_bestscore:"+ _bestscore);

        _score = 100;
        sendToHighScore();
    }

    // Update is called once per frame
    void Update()
    {
        // 获取游戏数据
        if (Input.GetKeyDown(KeyCode.Space)) {
            _bestscore = getHighScoreFromDb();
            Debug.Log("_bestscore:" + _bestscore);
        }

        // 重新保存游戏数据
        if (Input.GetKeyDown(KeyCode.R)) {
            _score = Random.Range(_score, 2 * _score);
            sendToHighScore();
        }

        // 串改游戏数据
        if (Input.GetKeyDown(KeyCode.S))
        {
            PlayerPrefs.SetInt("score", Random.Range(0,1000));//存分数
        }
    }


    public int Points
    {
        get
        {
            return _score;
        }
        set
        {
            _score = value;
        }
    }

    /// <summary>
    /// MD5 加密
    /// </summary>
    /// <param name="s"></param>
    /// <returns></returns>
    static string Md5Sum(string s)
    {//加密
        s += "随便写";     // 加密的尾缀
        System.Security.Cryptography.MD5 h = System.Security.Cryptography.MD5.Create();//使用系统的md5加密算法
        //System.Text.Encoding.Default.GetBytes(s)将字符串转为字节数组
        byte[] data = h.ComputeHash(System.Text.Encoding.Default.GetBytes(s));//计算指定字节数组的哈希值
        System.Text.StringBuilder sb = new System.Text.StringBuilder();
        for (int i = 0; i < data.Length; i++)
        {
            sb.Append(data[i].ToString("x2"));//将字节数组转化为16进制俩位数(假设有两个数10和26,正常情况十六进制显示0xA、0x1A,这样看起来不整齐,为了好看,可以指定"X2",这样显示出来就是:0x0A、0x1A。)
        }
        return sb.ToString();
    }

    public void saveVal(int val)
    {//保存信息
        string tmpV = Md5Sum(val.ToString());
        PlayerPrefs.SetString("score_hash", tmpV);//将分数加密后的值存起来 (有服务器,可以保存到服务器)
        PlayerPrefs.SetInt("score", val);//存分数,可以存在本地数据
    }

    private int dec(string val)
    {//读取信息
        int tmpV = 0;
        if (val == "")
        {
            saveVal(tmpV);//一开始没有分数,默认为0
        }
        else
        {
            if (val.Equals(Md5Sum(PlayerPrefs.GetInt("score").ToString())))
            {//比较score_hash的值与保存的分数的score_hash是否相等
                tmpV = PlayerPrefs.GetInt("score");
            }
            else
            {
                saveVal(0);
                Debug.Log("数据已经破坏篡改,恢复 0 值");
            }
        }
        return tmpV;
    }

    private int getHighScoreFromDb()
    {
        return dec(PlayerPrefs.GetString("score_hash"));//读取最高分
    }

    public void sendToHighScore()
    {
        if (_score > _bestscore)
        {//如果当前分手大于最高分则将分手存下来
            saveVal(_score);
        }
    }
}

三十三、Unity中使用有限状态机FSM进行游戏开发

总的来说,有限状态机系统,是指在不同阶段会呈现出不同的运行状态的系统,这些状态是有限的、不重叠的。这样的系统在某一时刻一定会处于其所有状态中的一个状态,此时它接收一部分允许的输入,产生一部分可能的响应,并且迁移到一部分可能的状态。
五个要素:状态,事件,条件,动作,迁移。


CS 角色FSM图

 

使用switch (){case….}实现简单的有限状态机。

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

public class SimleFSM : MonoBehaviour {
    public enum State
    {
        Idle,
        Patrol,
        Chase,
        Attack
    }
    private State state = State.Idle;
    void Update () {
        switch (state)
        {
            case State.Idle:
                ProcessStateIdle();
                break;
            case State.Patrol:
                ProcessStatePatrol();
                break;
            case State.Chase:
                ProcessStateChase();
                break;
            case State.Attack:
                ProcessStateAttack();
                break;
            default:
                break;
        }
    }

    void ProcessStateIdle()
    {
    }
    void ProcessStatePatrol()
    {
    }
    void ProcessStateChase()
    {
    }
    void ProcessStateAttack()
    {
    }
}

Unity中FSM有限状态机系统的构成和功能的简单实现:


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

public class FSMSystem  {
    private Dictionary<StateID, FSMState> states = new Dictionary<StateID, FSMState>();

    private StateID currentStateID;
    private FSMState currentState;

    public void Update(GameObject npc)
    {
        currentState.Act(npc);
        currentState.Reason(npc);
    }

    public void AddState(FSMState s)
    {
        if (s == null)
        {
            Debug.LogError("FSMState不能为空");return;
        }
        if (currentState == null)
        {
            currentState = s;
            currentStateID = s.ID;
        }
        if (states.ContainsKey(s.ID))
        {
            Debug.LogError("状态" + s.ID + "已经存在,无法重复添加");return;
        }
        states.Add(s.ID, s);
    }
    public void DeleteState(StateID id)
    {
        if (id == StateID.NullStateID)
        {
            Debug.LogError("无法删除空状态");return;
        }
        if (states.ContainsKey(id) == false)
        {
            Debug.LogError("无法删除不存在的状态:" + id);return;
        }
        states.Remove(id);
    }

    public void PerformTransition(Transition trans)
    {
        if (trans == Transition.NullTransition)
        {
            Debug.LogError("无法执行空的转换条件");return;
        }
        StateID id= currentState.GetOutputState(trans);
        if (id == StateID.NullStateID)
        {
            Debug.LogWarning("当前状态" + currentStateID + "无法根据转换条件" + trans + "发生转换");return;
        }
        if (states.ContainsKey(id) == false)
        {
            Debug.LogError("在状态机里面不存在状态" + id + ",无法进行状态转换!");return;
        }
        FSMState state = states[id];
        currentState.DoAfterLeaving();
        currentState = state;
        currentStateID = id;
        currentState.DoBeforeEntering();
    }
} 
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public enum Transition
{
    NullTransition=0,
    SeePlayer,
    LostPlayer
}
public enum StateID
{
    NullStateID=0,
    Patrol,
    Chase
}


public abstract class FSMState{

    protected StateID stateID;
    public StateID ID { get { return stateID; } }
    protected Dictionary<Transition, StateID> map = new Dictionary<Transition, StateID>();
    protected FSMSystem fsm;

    public FSMState(FSMSystem fsm)
    {
        this.fsm = fsm;
    }


    public void AddTransition(Transition trans,StateID id)
    {
        if (trans == Transition.NullTransition)
        {
            Debug.LogError("不允许NullTransition");return;
        }
        if (id == StateID.NullStateID)
        {
            Debug.LogError("不允许NullStateID"); return;
        }
        if (map.ContainsKey(trans))
        {
            Debug.LogError("添加转换条件的时候," + trans + "已经存在于map中");return;
        }
        map.Add(trans, id);
    }
    public void DeleteTransition(Transition trans)
    {
        if (trans == Transition.NullTransition)
        {
            Debug.LogError("不允许NullTransition"); return;
        }
        if (map.ContainsKey(trans)==false)
        {
            Debug.LogError("删除转换条件的时候," + trans + "不存在于map中"); return;
        }
        map.Remove(trans);
    }
    public StateID GetOutputState(Transition trans)
    {
        if (map.ContainsKey(trans))
        {
            return map[trans];
        }
        return StateID.NullStateID;
    }

    public virtual void DoBeforeEntering() { }
    public virtual void DoAfterLeaving() { }
    public abstract void Act(GameObject npc);
    public abstract void Reason(GameObject npc);
}
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour {

    private FSMSystem fsm;

    void Start () {
        InitFSM();
    }

    void InitFSM()
    {
        fsm = new FSMSystem();

        FSMState patrolState = new PatrolState(fsm);
        patrolState.AddTransition(Transition.SeePlayer, StateID.Chase);

        FSMState chaseState = new ChaseState(fsm);
        chaseState.AddTransition(Transition.LostPlayer, StateID.Patrol);

        fsm.AddState(patrolState);
        fsm.AddState(chaseState);
    }

    void Update () {
        fsm.Update(this.gameObject);
    }
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class PatrolState : FSMState
{
    private List<Transform> path = new List<Transform>();
    private int index = 0;
    private Transform playerTransform;

    public PatrolState(FSMSystem fsm) : base(fsm)
    {
        stateID = StateID.Patrol;
        Transform pathTransform = GameObject.Find("Path").transform;
        Transform[] children= pathTransform.GetComponentsInChildren<Transform>();
        foreach(Transform child in children)
        {
            if (child != pathTransform)
            {
                path.Add(child);
            }
        }
        playerTransform = GameObject.Find("Player").transform;
    }

    public override void Act(GameObject npc)
    {
        npc.transform.LookAt(path[index].position);
        npc.transform.Translate(Vector3.forward * Time.deltaTime * 3);
        if (Vector3.Distance(npc.transform.position, path[index].position) < 1)
        {
            index++;
            index %= path.Count;
        }
    }

    public override void Reason(GameObject npc)
    {
        if (Vector3.Distance(playerTransform.position, npc.transform.position) < 3)
        {
            fsm.PerformTransition(Transition.SeePlayer);
        }
    }
}
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ChaseState : FSMState
{
    private Transform playerTransform;
    public ChaseState(FSMSystem fsm) : base(fsm)
    {
        stateID = StateID.Chase;
        playerTransform = GameObject.Find("Player").transform;
    }

    public override void Act(GameObject npc)
    {
        npc.transform.LookAt(playerTransform.position);
        npc.transform.Translate(Vector3.forward * 2 * Time.deltaTime);
    }
    public override void Reason(GameObject npc)
    {
        if (Vector3.Distance(playerTransform.position, npc.transform.position) > 6)
        {
            fsm.PerformTransition(Transition.LostPlayer);
        }
    }
}

三十四、Unity3D游戏开发流程与规范(参考)

为什么要有规范

  1. 游戏开发相对其他软件开发难度大

  2. 要求策划、程序、美术等高度协同配合完成

  3. 确保游戏品质与开发进度的良好推进

规范的目的

  • 让团队每个人都明确

    1. 要做什么(To Do)

    2. 什么时候完成 (Done)

    3. 完成到什么程度? (Doing)

  • 悲剧

    1. 前期松散 (需求不明确,跟进不到位)

    2. 效率低下 (制作流程)

    3. 后期加班 (强制恶性加班)

    4. 工作量无从估计 (任务落实不到位)

项目版本阶段

  1. 原型阶段 (Demo)

    1. 需求:实现游戏的必要技术验证等

    2. 产出:一个最简单的只有1关或者1个场景的游戏原型、美术效果图等

  2. Alpha阶段

    1. 需求:完善游戏逻辑,定义完整的数据结构和关卡配置、制作游戏UI等

    2. 产出:一个能玩若干关卡的版本

  3. Beta阶段

    1. 需求:完善逻辑 、批量制作美术、关卡等游戏内容及细化UI等 ,加入IAP、GameCenter等

    2. 产出:完整的可玩版本,具备所有的游戏内容、关卡等

  4. 产品阶段

    1. 需求: 测试、修Bug,图标, 截图, 多语言说明 ,视频录制等准备上线需要做的一切事情 。 提交上线。

    2. 产出:可以提交上线的App包。

目录结构

├──Client
│ ├──Doc
│ ├──Tools
│ ├──Workspace
│ │ ├── BuildAndroid
│ │ ├── BuildiOS
│ │ └── UnityProject
...
├──Common
│ ├──Doc
│ ├──Tools
│ ├──Excel
│ └──Art
...
├──Server
│ ├──Doc
│ ├──Tools
│ ├──Workspace
│ │ ├── Agent
│ │ ├── Auth
│ │ ├── Game
│ │ └── Lib

....

文件名规则

  1. "见名思意"原则

  2. 所有资源原始素材统一使用小写命名,通过下划线"_"来拼接

  3. 预设(Prefab)、图集(Atlas)等处理后的资源,命名大写开头

  4. 目录名大写开头

需求文档

推荐Trello、禅道等管理需求任务

代码规范

  1. 类名: 大小写 例如:UILogin.cs

  2. 类变量命名:首字母小写(例如:value),或者m开头(例如:mValue)

  3. 方法内变量命名:全部小写+下划线 (例如:new_value)或者 下划线开头(例如:_value)

美术规范

  • 图片输出规范

    1. 遵循以上文件名命名规则

    2. 预览效果图

    3. 统一使用png

    4. 图片大小尽量使用正方形(2^n)

    5. 图片可拉伸的尽量使用九宫格

    6. 图片1/2或者1/4切图拼接的 尽量少用 在UGUI中会有1个像素左右的缝隙

    7. 单张图最大不超过1024x1024,除非全屏背景图,例如:750x1334(iPhone 7)

    8. 可使用TinyPng压缩大原图


图片原始素材导入Raw/Texture

  • 2D美术规范

    1. 以iPhone 7 (750x1334)为例,需要兼容iPhone X等(1125x2436)

    2. 安全区域控制

    3. 兼容iPhone、iPad

    4. 横屏的话,兼容iPhone、iPad、PC、Mac

尺寸参考

  • UI素材管理

    1. 通用资源放在Common文件夹 例如:按钮、标题栏

    2. 场景之间尽量不要共用图集(Atlas) 例如:游戏战斗内外

    3. 利用好Texture Import Settings (Android、iOS不同平台设置不一样)

    4. 提高资源复用

    5. 按钮、标题栏、背景必须分开,不要放在同一个图集(Atlas)

    6. 单个图集不要超过1024x1024

  • 3D美术规范

    1. 尽量使用原画背景,添加物件方式来达到3D视觉效果 (降低制作成本)

    2. 最终导出的模型文件,每个面片都必须取名字,英文或者拼音都可以

    3. 所有模型、面片、材质的命名,不能使用中文

    4. 模型精度、面数依据具体项目而定 ,在移动设备上尤为重要

    5. 场景烘焙不能超过8张512x512

  • 3D动作规范

    model.fbx 蒙皮 必须绑定骨骼 ​ model@idle.fbx 待机动作 ​ model@atk.fbx 攻击动作 ...


模型FBX素材导入Raw/Model

 

三十五、Unity Android JDK 版本更新的Oracle账号(不然可能下载不了)

目前在官网下载低于jdk1.8的java jdk的时候需要登陆,这边分享一个账号,方便下载 
2696671285@qq.com 
密码:Oracle123

注:请不要改密码,大家共同使用!!!

三十六、Unity打包Android问题记录 "CommandInvokationFailure: Gradle build failed."

Unity在Gradle打包模式导出Android APK时,报错CommandInvokationFailure: Gradle build failed. 

看一下具体报错

* What went wrong:
A problem occurred configuring root project 'gradleOut'.
> No toolchains found in the NDK toolchains folder for ABI with prefix: mips64el-linux-android

在NDK中缺少了文件"mips64el-linux-android"

打开Unity -> Preferences -> External Tools面板

找到NDK路径(这个是自己下载的),打开NDK所在文件夹,在"NDK文件夹/toolchains"文件夹下:

奇怪,明明有这个缺失的"mips64el-linux-android"文件

Android的SDK是通过Android Studio下载的,NDK是自己下载的,打开SDK路径,SDK文件夹下有一个通过Android Studio下载的NDK,文件夹名字"ndk-bundle",打开"ndk-bundle/toolchains"文件夹,这个NDK确实没有"mips64el-linux-android"文件,将另一份NDK中的"mips64el-linux-android"文件复制到该路径下,打包,成功!
 

还是奇怪,在Unity中选择的是自己下载的NDK,可是看上面的实验结果,在Gradle打包是明显用的是通过AS下载的NDK

 

三十七、Unity持久化存储之PlayerPrefs的使用

unity3d提供了一个用于本地持久化保存与读取的类——PlayerPrefs。工作原理非常简单,以键值对的形式将数据保存在文件中,然后程序可以根据这个名称取出上次保存的数值。
APlayerPrefs类支持3中数据类型的保存和读取,浮点型,整形,和字符串型。
      分别对应的函数为:

  1. SetInt();保存整型数据;
  2. GetInt();读取整形数据;
  3. SetFloat();保存浮点型数据;
  4. GetFlost();读取浮点型数据;
  5. SetString();保存字符串型数据;
  6. GetString();读取字符串型数据;

  注:这些函数的用法基本一致使用Set进行保存,使用Get进行读取。
B、使用
  PlayerPrefs.SetString("YourKey", "Your_Value"); 这个方法中第一个参数表示存储数据的名称,第二的参数表示具体存储的数值。

  MyValue=PlayerPrefs.GetString("YourKey"); 这个方法中第一个数据表示读取数据的名称,本来还有第二的参数,表示默认值,如果通过数据名称没有找到对应的值,那么就返回默认值,这个值也可以写,则返回空值。

C、补充

  在PlayerPrefs 类中还提供了

  • PlayerPrefs.DeleteKey (key : string)删除指定数据;
  • PlayerPrefs.DeleteAll() 删除全部键 ;
  • PlayerPrefs.HasKey (key : string)判断数据是否存在的方法;

D、保存文件在不同平台上的的存储位置:

  1.PC端

    ①打开注册表:regedit 

    ②打开Unity中Edit----->Project Settings---->Player 

                   

              ③注册表中---->Software

                  

               ④找到Unity

                  

                ⑤找到写入的文件

2.Android 端:

  apk安装在内置flash存储器上时,
  PlayerPrefs的位置是\data\data\com.company.product\shared_prefs\com.company.product.xml

  apk安装在内置SD卡存储器上时,PlayerPrefs的位置是/sdcard/Android/data/com.company.product\shared_prefs\com.company.product.xml

    shared_prefs是SharedPreferences的缩写,是Android平台上一个轻量级的存储类,用来保存应用的一些常用配置,比如Activity状态,Activity暂停时,将此activity的状态保存到SharedPereferences中;当Activity重载,系统回调方法onSaveInstanceState时,再       从SharedPreferences中将值取出。SharedPreferences 可以用来进行数据的共享,包括应用程序之间,或者同一个应用程序中的不同组件。比如两个activity除了通过Intent传递数据之外,也可以通过ShreadPreferences来共享数据.Unity中通过PlayerPref来存储游戏的一些数据,特别是单机游戏。在android平台就是存储到上述位置。 另外,是否安装到sd卡可在PlayerSetting->Other Settings->Install Location 设置。

  3.Mac

     在Mac OS X上PlayerPrefs存储在~/Library/PlayerPrefs文件夹,名为unity.[company name].[product name].plist,这里company和product名是在Project Setting中设置的,相同的plist用于在编辑器中运行的工程和独立模式.On iOS, the PlayerPrefs can be found in   your app's plist file. It's located at:/Apps/ your app's folder /Library/Preferences/ your app's .plist
  在Windows独立模式下,PlayerPrefs被存储在注册表的 HKCU\Software\[company name]\[product name]键下,这里company和product名是在Project Setting中设置的.在Windows编辑器模式下,PlayerPrefs被存储在注册表的 HKCU\Software\Unity\UnityEditor\          [company name]\[product name]键下,这里company和product名是在Project Setting中设置的。

 

三十八、unity 本地MP3文件读取

看到网上对本地MP3文件的读取多采用WWW加NAudio的方式。其中NAudio将MP3文件转为wav,再由WWW将wav文件加载为unity的AudioClip。

这里这么做的原因是WWW不支持MP3格式。这种做法较为陈旧。

unity新版的UnityWebRequestMultimedia已经支持了MP3格式。用法如下
 

private IEnumerator LoadMusic(string filepath)
    {
        filepath = "file://" + filepath;
        using (var uwr = UnityWebRequestMultimedia.GetAudioClip(filepath, AudioType.MPEG))
        {
            yield return uwr.SendWebRequest();
            if (uwr.isNetworkError)
            {
                Debug.LogError(uwr.error);
            }
            else { 
                AudioClip clip = DownloadHandlerAudioClip.GetContent(uwr);
                // use audio clip
                audioSource.clip = clip;                
            }
        }
    }
StartCoroutine(LoadMusic("/storage/emulated/0/a.mp3"));
StartCoroutine(LoadMusic("D:/a.mp3"));

这里测试了pc和安卓平台。

 

版本:unity2017.4.3f1 x64

ps:为了读取文件,需要对f应的权限。

edit->project settings->player->configuration

write permission设置为external

---------------------------------

补充 (20190610) :

看了下面的评论, 今天重新试了一下, 发现已经不行了(on windows), android 平台应该仍然可以(这里没有测试,不过评论里反映的基本上都是windows下不行).所以在windows下大家还是采用其他的方式, 比如

1. 使用 ogg 或 wav 等格式, 不使用 mp3 格式文件.

2. 使用NAudio 等库在加载时将 mp3 文件 转换为 wav 格式, 再进行加载.
 

三十九、Unity简易实用的Log封装

using System;


/// <summary>
/// Log 封装
/// </summary>
public class Log  {

    #region 开发调试的时候用
    public static Action<object> Debug = UnityEngine.Debug.Log;
    public static Action<object> Error = UnityEngine.Debug.LogError;
    public static Action<object> Warning = UnityEngine.Debug.LogWarning;
    #endregion

    #region 发布的时候用,避免过度的打印占用性能

    //public static void Debug(object obj) { }
    //public static void Error(object obj) { }
    //public static void Warning(object obj) { }

    #endregion
}

四十、UNITY所谓的异步加载几乎全部是协程,不是线程;MAP3加载时解压非常慢

实践证明,以下东西都是协程,并非线程(thread):

1,WWW

2,AssetBundle.LoadFromFileAsync

3,LoadSceneAsync

其它未经测试

此问题的提出是由于一个约3.5M的MP3背景音乐文件的加载。

测试中发现该文件加载时角色走路会有一个短暂的卡顿,经测试是加载此MP3造成的约0.3S的卡顿。

为解决此问题,决定使用LoadFromFileAsync代替LoadFromFile来进行异步加载,结果发现没有任何作用,证明此异步函数只是协程。

然后尝试使用WWW来加载,在UPDATE里询问加载结果:isdone,结果仍然同样的卡,没有任何改变。证明WWW也只是协程,不是多线程的。

------------------------------------------------------

其实MP3只有3M左右,不应该那么卡,原因是在其压缩选项设置:decompressOnLoad,表示加载时解压,U3D文档特别强调了对于大的MP3,使用此选项是非常愚蠢的。

原以为大的MP3是指10M以上的,没想到这就算大的了。。。

最后通过修改压缩选项为compressInMemory解决问题,丝毫不卡。

--------------------------------------

本来想利用C#的Thread类来加载磁盘上的.mp3到内存,然后使用UNITY的 assetbundle.loadFromMemory(bytes[])来加载,如果行得通则肯定不卡。

有点麻烦,有时间再搞。

  • 2
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

仙魁XAN

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值