LuaFramework 实战

本文转载自 :碧俐千仞的博客http://blog.sina.com.cn/peiyul

1、安装框架

只要在http://www.ulua.org/index.html下载LuaFramework,然后用Unity3D打开,这里用的是LuaFramework_UGUI-1.0.4.109版本以及Unity3D 5.2,其他版本理应相似。打开之后需要点击lua菜单里面的Generate All和LuaFramework菜单里Build XXX Resources,以生成一些必要的文件。

安装过程可以参见http://pan.baidu.com/s/1gd8fG4N里面的01_uLua_Windows.avi和02_SimpleFramework_UGUI_Windows.avi两个视频(如果在windows系统下)。框架结构请参见http://doc.ulua.org/article/ngui/simpleframework_base1.html,这里不再复述。

若运行后能够弹出示范界面,证明安装成功,可以进入下一步。

成功运行示范界面(可要客户端能够运行起来就行)  

​2、运行Lua代码

这一步的目标很简单,就是让框架运行我们自己写的lua代码,显示一句“helloWorld”。下一步再考虑代码的热更新问题。

1)新建场景

在任意物体中添加Main组件。其实Main组件里面只是调用了AppFacade.Instance.StartUp(),这是框架的起点。框架将会自动完成资源加载、热更新等等事项。

添加Main组件

2)删掉示例的调用

现在不需要框架自带的示例了,需要删掉一些代码,使框架只运行我们编写的lua文件。打开Assets\LuaFramework\Scripts\Manager\GameManager.cs,将OnInitalize修改成下图这个样子。这是lua的入口,框架会调用Main.lua的Main方法。

修改GameManager的lua入口

3)编写lua代码

打开Assets\LuaFramework\Lua\main.lua,编写lua代码。这里只添加一句“LuaFramework.Util.Log("HelloWorld");”(如下所示),它的功能相当于Debug.Log("HelloWorld")。

--主入口函数。从这里开始lua逻辑

function Main()

    LuaFramework.Util.Log("HelloWorld");

end

“LuaFramework.Util.Log("HelloWorld")”中的Util是c#里定义的类,供lua中调用。可以打开Assets\LuaFramework\Editor\CustomSettings.cs看到所有可以供lua调用的类,如下图是CustomSettings.cs的部分语句。

CustomSettings.cs的部分语句  

再由具体的类可以查找所有的API(参见下面两个图),如下图是Util类的部分语句。

Util类(Assets\LuaFramework\Scripts\Utility\Util.cs)的部分语句  

4)运行游戏

点击菜单栏中LuaFramework→Build Windows Resource,生成资源文件。然后运行游戏,即可在控制台中看到打印出的HelloWorld。

 

生成资源文件
运行结果  

按照默认的设置,每更改一次lua代码,都需要执行Build XXX Resource才能生效。读者可以将Assets\LuaFramework\Scripts\ConstDefine\AppConst.cs中的LuaBundleMode修改为false,这样代码文件便不会以AssetBundle模式读取,会直接生效,以方便调试。

设置LuaBundleMode

3、热更新的原理

接下来便要尝试代码热更新,让程序下载服务器上的lua文件,然后运行它。在说明热更新之前,需要先看看Unity3D热更新的一般方法。如下图所示,Unity3D的热更新会涉及3个目录。

热更新的过程

游戏资源目录:里面包含Unity3D工程中StreamingAssets文件夹下的文件。安装游戏之后,这些文件将会被一字不差地复制到目标机器上的特定文件夹里,不同平台的文件夹不同,如下所示(上图以windows平台为例)

Mac OS或WindowsApplication.dataPath + "/StreamingAssets";

IOS Application.dataPath + "/Raw";

Androidjar:file://" + Application.dataPath + "!/assets/";

数据目录:由于“游戏资源目录”在Android和IOS上是只读的,不能把网上的下载的资源放到里面,所以需要建立一个“数据目录”,该目录可读可写。第一次开启游戏后,程序将“游戏资源目录”的内容复制到“数据目录中”(步骤1,这个步骤只会执行一次,下次再打开游戏就不复制了)。游戏过程中的资源加载,都是从“数据目录”中获取、解包(步骤3)。不同平台下,“数据目录”的地址也不同,LuaFramework的定义如下:

Android或IOS:Application.persistentDataPath + "/LuaFramework"    

Mac OS或Windows:c:/LuaFramework/

调试模式下:Application.dataPath + "/StreamingAssets/"

注:”LuaFramework”和”StreamingAssets”由配置决定,这里取默认值

 

网络资源地址:存放游戏资源的网址,游戏开启后,程序会从网络资源地址下载一些更新的文件到数据目录。

 

这些目录包含着不同版本的资源文件,以及用于版本控制的files.txt。Files.txt的内容如下图所示,里面存放着资源文件的名称和md5码。程序会先下载“网络资源地址”上的files.txt,然后与“数据目录”中文件的md5码做比较,更新有变化的文件(步骤2)。

files.txt

LuaFramework的热更新代码定义在Assets\LuaFramework\Scripts\Manager\GameManager.cs,真正用到项目时可能还需少许改动。


4、开始热更新代码吧!

那么开始测试热更新代码的功能吧!热更上述实现的“HelloWorld”。

1)修改配置

框架的默认配置是从本地加载文件,需要打开AppConst.cs将UpdateMode设置为true(才会执行步骤2),将LuaBundleMode设置为true,将WebUrl设置成服务器地址。如下图所示。

AppConst的配置

2)配置“网络资源”

笔者使用iis开启本地服务器,然后将StreamingAssets里面的所有内容复制到服务器上面。必要时要配置一些权限,让所有文件都都可以下载。

通过网络访问文件

3)测试热更新

改一下Lua脚本(如将HelloWorld改为Hello Lpy2),点击Build Windows Resource,将“工程目录/StreamingAssets”里面的文件复制到服务器上。再将脚本改成其他内容,然后Build Windows Resource,覆盖掉本地资源。运行游戏,如果程序显示“Hello Lpy2”的代码,证明成功从网上拉取了文件。

代码热更新

 


热更新涉及资源热更新和代码热更新(其实lua代码也是资源),那接下来看看如何动态加载一个模型,然后热更成其他素材。这一部分涉及资源打包、动态创建资源等内容。

1、创建物体

为了调试的方便,笔者先将框架配置为本地模式,待测试热更新时再改成更新模式。

配置为本地模式


先测试个简单的创建物体,新建一个名为go的物体,然后设置它的坐标为(1,1,1)。这段代码虽然不涉及资源加载,但能展示“把物体添加到场景中”的过程。Main.lua的代码如下:

function Main()                                    
        local go = UnityEngine.GameObject ('go')
        go.transform.position = Vector3.one             


end



动态创建一个名为go的空物体  

要热更新资源,便需要制作资源。这里制作一个名为tankPrefab的坦克模型预设,然后存到Assets/Tank目录下。接下来对它做打包,然后动态加载。

坦克预设  

广告时间:这个坦克模型是来自笔者即将出版的一本书《Unity3D网络游戏实战》。该书通过一个完整的多人坦克对战实例,详细介绍网络游戏开发过程中涉及到的知识和技巧。书中还介绍了服务端框架、客户端网络模块、UI系统的架构等内容。相信透过本书,读者能够掌握Unity3D网络游戏开发的大部分知识,也能够从框架设计中了解商业游戏的设计思路,感谢大家支持。

2、资源打包

LuaFramework在打包方面并没有做太多的工作,我们需要手动打包。打开Assets/LuaFramework/Editor/Packager.cs,按照示例的写法,加上下面这一行:将Assets/Tank目录下的所有预设(.prefab)打包成名为tank的包。

修改打包代码

点击“Build Windows Resource”,即可在StreamingAssets中看到打包好的文件。

如下图所示,Unity3D资源包里面包含多个资源,就像一个压缩文件一样。在动态加载的时候,便需要有加载包文件、或取包中的资源两步操作(框架已经帮我们做好了这部分工作,直接调用API即可)。

Unity3D的资源包

3、动态加载模型

编写如下lua代码(main.lua),使用框架提供的资源管理器(resMgr)加载tank包的TankPrefab文件,加载完成后回调OnLoadFinish方法。在OnLoadFinish中使用Instantiate实例化对象。

--主入口函数。从这里开始lua逻辑
function Main()                                 
        LuaHelper = LuaFramework.LuaHelper;
        resMgr = LuaHelper.GetResManager();
        resMgr:LoadPrefab('tank', { 'TankPrefab' }, OnLoadFinish);
end

--加载完成后的回调--
function OnLoadFinish(objs)
        local go = UnityEngine.GameObject.Instantiate(objs[0]);
        LuaFramework.Util.Log("Finish");        


end



完成后运行游戏,即可看到动态加载出来的模型。

动态加载出来的模型


4、加载资源的过程

只有理解了动态加载,即LoadPrefab的过程,才能算是真正的理解了热更新。LoadPrefab为ResourceManager中定义的方法,在Assets\LuaFramework\Scripts\Manager\ResourceManager.cs中实现,建议配合代码看下面的解释。

LoadPrefab的流程如下所示,先是判定当前是否正在加载该资源包,如果没有则调用OnLoadAsset加载资源包、然后解包获取资源、调用回调函数。

  LoadPrefab的流程


ResourceManager类定义了m_AssetBundleManifest、m_Dependencies、m_LoadedAssetBundles、m_LoadRequests这4个变量,只要理解了这几个变量的用途,也就能够理解了资源加载的全过程了。这4个变量的类型如下:

ResourceManager定义的几个变量


m_AssetBundleManifest

理解m_AssetBundleManifest之前,需要先理解Unity3D的依赖打包。前面的tank.unity3D中,坦克的预设、坦克的贴图等资源都被打包到一起,没有依赖关系(只要打包时不给贴图单独打包,Unity3D会自动将预设相关的资源都打包进来)。如下图所示。

前面加载坦克制作的资源包

假如有两个坦克预设共用一套贴图,如果像上面那样打包,每个坦克预设各自包含一份贴图,资源会比较大。更好的办法是将公共贴图放到一个包里,每个坦克预设不再包含贴图(如下图)。这种打包方式下,加载TankPrefab前,需要先加载依赖包common.unity3D,坦克预设才能找到贴图。

依赖打包

打包后,Unity3D会产生一个名为AssetBundle.manifest的文件(框架会将该文件放在StreamingAssets中),该文件包含所有包的依赖信息。所以在加载资源前需要先加载这个文件,m_AssetBundleManifest便是指向这个包的变量。相关代码如下:

m_AssetBundleManifest便是指向AssetBundle.manifest的变量

加载这个包后,便可以使用下面的语句获取某个包所依赖的所有包名,然后加载它们。

string[] dependencies = m_AssetBundleManifest.GetAllDependencies(包名);

注:更多Unity3D的依赖打包的解释,可以参见这篇文章:

http://liweizhaolili.blog.163.com/blog/static/16230744201541410275298/

m_LoadedAssetBundles

字典类型的m_LoadedAssetBundles保存了所有已经加载资源包。如果某个包已经被加载过,那下次需要用到它时,直接从字典中取出即可,减少重复加载。简化后的代码如下:

IEnumerator OnLoadAsset(XXX)
{
      AssetBundleInfo bundle = GetLoadedAssetBundle(XXX);  
      if(!bundle)
            bundle  =  OnLoadAssetBundle(名字);  
      加载资源
      回调函数处理
}

其中GetLoadedAssetBundle方法会判断资源包是否存在于m_LoadedAssetBundles中,并返回资源包。OnLoadAssetBundle为重新加载资源包的方法。

加载资源包后,只需通过 bundle.LoadAssetAsync(资源名,类型)便可加载所需的资源。

m_Dependencies

m_Dependencies记录了所有已加载资源的依赖包,以便在GetLoadedAssetBundle方法中判断资源包是否被完全加载(主体包和所有依赖包都被加载才算完成加载)。简化后的代码如下:

IEnumerator OnLoadAssetBundle(包名)
{
      //获取依赖包
      string[] dependencies = m_AssetBundleManifest.GetAllDependencies(abName);
      m_Dependencies.Add(abName, dependencies); //更新依赖表
      //加载依赖包
      for (int i = 0; i < dependencies.Length; i++)
            OnLoadAssetBundle(XXX)  
  //然后加载主体资源
       download = http://WWW.LoadFromCacheOrDownload(包名)
       //更新加载表
       m_LoadedAssetBundles.Add(XXX)
}
AssetBundleInfo GetLoadedAssetBundle(包名)
 {
       //判断加载表
       AssetBundleInfo bundle = m_LoadedAssetBundles[包名];
       if (bundle == null) return null;
       //判断依赖包
       foreach (string 依赖包名 in m_Dependencies[包名])
       {
              if (m_LoadedAssetBundles[依赖包名]== null) 
                     return null;
       }
       return bundle;


}



m_LoadRequests

m_LoadRequests是一个>类型的字典,LoadAssetRequest的定义如下,它用于异步加载后的回调。填写图片摘要(选填)

LoadAssetRequest  

由于采用异步加载,加载资源的过程中,程序有可能发起同一个请求,多次加载似乎有些浪费,如下图所示。

两次加载同一资源

更好的办法是,在收到第2次请求时先做判断,如果该资源正在加载,那先记录请求2的回调函数,待资源加载完成,调用所有请求该资源的回调函数,如下图所示。m_LoadRequests便记录每种资源的请求,使程序可以判断该资源是否正在加载,并从m_LoadRequests获取各个资源请求的回调函数。

  记录请求2的回调函数

简化后的代码如下:

void LoadAsset(包名)
{
       If(m_LoadRequests[abName] == null)
       {
              m_LoadRequests[包名].Add(回调函数等);
              OnLoadAsset();
       }
       else
       {
              m_LoadRequests[包名].Add(回调函数等);
       }
}

IEnumerator OnLoadAsset(XXX)
{
       加载包
       加载资源
       foreach( request in  m_LoadRequests[包名] )
       {
              Request.回调函数();
       }


}


为实现代码热更新,在Unity3D中使用lua,然而为此也需付出不少代价。其一,使代码结构混乱(尽管可以优化),其二降低了运行速度,其三增加学习成本(还要多学一门语言)。为了热更新,所有的逻辑都要用lua编写,那么怎样用lua编写游戏逻辑呢?

By 罗培羽 (知乎 @罗培羽)

1、Lua的Update方法

第一篇“代码热更新”演示了用lua打印HelloWorld的方法,第二篇“资源热更新”演示了加载坦克模型的方法。这一篇要把两者结合起来,用lua实现“用键盘控制坦克移动”的功能。用Lua和用c#编写的Unity3D程序大同小异,只需正确使用API即可,Lua语言的知识请参见《programing in lua》这本书。

1)Update方法

出于效率的考虑,tolua提供了名为UpdateBeat的对象,在LuaFramework中,只需给UpdateBeat添加回调函数,该函数便会每帧执行,相当于Monobehaviour的Update方法。Lua代码如下所示:

function Main()                                 
    UpdateBeat:Add(Update, self)
end
 
function Update()
        LuaFramework.Util.Log("每帧执行一次");
end



除了UpdateBeat,tolua还提供了LateUpdateBeat和FixedUpdateBeat,对应于Monobehaviour中的LateUpdate和FixedUpdate。

2)控制坦克

现在编写“用键盘控制坦克移动”的lua代码,加载坦克模型后,使用UpdateBeat注册每帧执行的Update方法,然后在Update方法中调用UnityEngine.Input等API实现功能。代码如下:

local go; --加载的坦克模型

--主入口函数。从这里开始lua逻辑function Main()
                                        
        LuaHelper = LuaFramework.LuaHelper;
        resMgr = LuaHelper.GetResManager();
        resMgr:LoadPrefab('tank', { 'TankPrefab' }, OnLoadFinish);
end
 
--加载完成后的回调--
function OnLoadFinish(objs)
        go = UnityEngine.GameObject.Instantiate(objs[0]);
        LuaFramework.Util.Log("LoadFinish");
        
        UpdateBeat:Add(Update, self)
end
 
--每帧执行
function Update()
        LuaFramework.Util.Log("每帧执行");
        
        local Input = UnityEngine.Input;
        local horizontal = Input.GetAxis("Horizontal");
        local verticla = Input.GetAxis("Vertical");
        
        local x = go.transform.position.x + horizontal
        local z = go.transform.position.z + verticla
        go.transform.position = Vector3.New(x,0,z)
end



运行游戏,即可用键盘的控制坦克移动。

用键盘的控制坦克移动  


2、自定义API

框架中提供了数十个可供lua调用的c#类,但这些往往不够用,需要自己添加,本节将介绍添加自定义API的方法。

1)编写c#类

例如,编写TestLuaFun.类,它包含一个静态方法Log,会打印出“《Unity3D网络游戏实战》是一本好书!”和“《手把手教你用c#制作rpg游戏》也是一本好书!”两行文本。

using UnityEngine;
using System.Collections;
 
public class TestLuaFun 
{
        public static void Log() 
        {
                Debug.Log("《Unity3D网络游戏实战》是一本好书!");
                Debug.Log("《手把手教你用c#制作rpg游戏》也是一本好书!");
        }
}



2)修改CustomSetting

打开CustomSetting.cs,在customTypeList中添加一句“_GT(typeof(TestLuaFun))”。

修改CustomSetting


3)生成wrap文

点击菜单栏的Lua→Clear wrap files和Lua→Generate All,重新生成wrap文件。由于刚刚在customTypeList添加了类,所以会生成TestLuaFun类的wrap文件TestLuaFunWrap.cs。

打开TestLuaFunWrap.cs,可以看到TestLuaFun注册了Log方法。关于static int Log(IntPtr L)的具体含义,请参见《programing in lua》中lua与c++交互的章节。

TestLuaFunWrap

4)测试API

修改main.lua,调用TestLuaFun.Log() ,即可看到效果。

--主入口函数。从这里开始lua逻辑
function Main()                                 
        TestLuaFun.Log()  
end
调用自定义API

3、原理

tolua实现了LuaInterface,抛开luaFramework,只需创建lua虚拟机,便能在c#中调用lua代码,如下所示。

using UnityEngine;
using System.Collections;
using LuaInterface;
 
public class test : MonoBehaviour
{
        void Start () 
        {
                //初始化
                LuaState lua = new LuaState();
                LuaBinder.Bind(lua);
                //lua代码
                string luaStr =
                        @"                
                        print('hello tolua#, 广告招租')
                        LuaFramework.Util.Log('HelloWorld');
                        TestLuaFun.Log()                
                ";
                //执行lua脚本
                lua.DoString(luaStr);
        }



创建lua虚拟机,执行lua代码

实际上LuaFramework也是用了相似的方法,框架启动后,会创建LuaManager、LuaLooper的实例。LuaManager创建lua虚拟机并调用Main.lua的Main方法,LuaLooper处理了UpdateBeat相关的事情。如下所示:

LuaManager的启动过程



基于组件的编程模式是Unity3D的核心思想之一,然而使用纯lua编程,基本就破坏了这一模式。那么有没有办法做一些封装,让Lua脚本也能挂载到游戏物体上,作为组件呢?

1、设计思想

在需要添加Lua组件的游戏物体上添加一个LuaComponent组件,LuaComponent引用一个lua表,这个lua表包含lua组件的各种属性以及Awake、Start等函数,由LuaComponent适时调用Lua表所包含的函数。

下面列举lua组件的文件格式,它包含一个表(如Component),这个表包含property1 、property2 等属性,包含Awake、Start等方法。表中必须包含用于派生对象的New方法,它会创建一个继承自Component的表o,供LuaComponent调用。

Component=    --组件表

{

        property1 = 100,

        property2 = “helloWorld”

}

 function Component:Awake() 

        print("TankCmp Awake name = "..self.name );

end

 

function Component:Start() 

        print("TankCmp Start name = "..self.name );

End

 

--更多方法略

 

function Component:New(obj) 

        local o = {} 

        setmetatable(o, self)  

        self.__index = self  

        return o

end  

 

2、LuaComponent组件

LuaComponent主要有Get和Add两个静态方法,其中Get相当于UnityEngine中的GetComponent方法,Add相当于AddComponent方法,只不过这里添加的是lua组件不是c#组件。每个LuaComponent拥有一个LuaTable(lua表)类型的变量table,它既引用上述的Component表。

Add方法使用AddComponent添加LuaComponent,调用参数中lua表的New方法,将其返回的表赋予table。

Get方法使用GetComponents获取游戏对象上的所有LuaComponent(一个游戏对象可能包含多个lua组件,由参数table决定需要获取哪一个),通过元表地址找到对应的LuaComponent,返回lua表。代码如下:

using UnityEngine;

using System.Collections;

using LuaInterface;

using LuaFramework;

public class LuaComponent : MonoBehaviour

{

        //Lua表

        public LuaTable table;

        

        //添加LUA组件  

        public static LuaTable Add(GameObject go, LuaTable tableClass)  

        {  

                LuaFunction fun = tableClass.GetLuaFunction("New");

                if (fun == null)

                        return null;

                object[] rets = fun.Call (tableClass);

                if (rets.Length != 1)

                        return null;

                LuaComponent cmp = go.AddComponent();  

                cmp.table = (LuaTable)rets[0];

                cmp.CallAwake ();

                return cmp.table;

        }  

 

        //获取lua组件

        public static LuaTable Get(GameObject go,LuaTable table)  

        {  

                LuaComponent[] cmps = go.GetComponents();  

                foreach (LuaComponent cmp in cmps)

                {

                        string mat1 = table.ToString();

                        string mat2 = cmp.table.GetMetaTable().ToString();

                        if(mat1 == mat2)

                        {

                                return cmp.table;

                        }        

                }

                return null;  

        }  

        //删除LUA组件的方法略,调用Destory()即可  

        void CallAwake ()

        {

                LuaFunction fun = table.GetLuaFunction("Awake");

                if (fun != null)

                        fun.Call (table, gameObject);

        }

 

        void Start ()

        {

                LuaFunction fun = table.GetLuaFunction("Start");

                if (fun != null)

                fun.Call (table, gameObject);

        }

 

        void Update ()

        {

                //效率问题有待测试和优化

                //可在lua中调用UpdateBeat替代

                LuaFunction fun = table.GetLuaFunction("Update");

                if (fun != null)

                fun.Call (table, gameObject);

        }

 

        void OnCollisionEnter(Collision collisionInfo)

        {

                //略

        }

    //更多函数略

}

3、调试LuaCompomemt

现在编写名为TankCmp的lua组件,测试LuaCompomemt的功能,TankCmp会在Awake、Start和Update打印出属性name。TankCmp.lua的代码如下:

TankCmp =

{

        --里面可以放一些属性

        Hp = 100,

        att = 50,

        name = "good tank",

}

 

function TankCmp:Awake()

        print("TankCmp Awake name = "..self.name );

end

 

function TankCmp:Start()

        print("TankCmp Start name = "..self.name );

end

 

function TankCmp:Update()

        print("TankCmp Update name = "..self.name );

end

 

--创建对象

function TankCmp:New(obj)

        local o = {}

       setmetatable(o, self)  

       self.__index = self  

      return o

end  

 

编写Main.lua,给游戏对象添加lua组件。

require "TankCmp"

--主入口函数。从这里开始lua逻辑

function Main()

        --组件1

        local go = UnityEngine.GameObject ('go')

        local tankCmp1 = LuaComponent.Add(go,TankCmp)

        tankCmp1.name = "Tank1"

        --组件2

        local go2 = UnityEngine.GameObject ('go2')

        LuaComponent.Add(go2,TankCmp)

        local tankCmp2 = LuaComponent.Get(go2,TankCmp)

        tankCmp2.name = "Tank2"

end

运行游戏,即可看到lua组件的运行结果:

程序运行结果  
程序运行结果


4、坦克组件

下面代码演示用lua组件实现“用键盘控制坦克移动”的功能,TankCmp.lua的代码如下:

TankCmp =

{

        name = "good tank",

}

 

function TankCmp:Update(gameObject)

        print("TankCmp Update name = "..self.name );

        local Input = UnityEngine.Input;

        local horizontal = Input.GetAxis("Horizontal");

        local verticla = Input.GetAxis("Vertical");

        local x = gameObject.transform.position.x + horizontal

        local z = gameObject.transform.position.z + verticla

        gameObject.transform.position = Vector3.New(x,0,z)

end

 

--创建对象

function TankCmp:New(obj)

local o = {}

        setmetatable(o, self)  

        self.__index = self  

        return o

end  

Main.lua先加载坦克模型,然后给他添加lua组件,代码如下:

require "TankCmp"

 

--主入口函数。从这里开始lua逻辑

function Main()

        LuaHelper = LuaFramework.LuaHelper;

        resMgr = LuaHelper.GetResManager();

        resMgr:LoadPrefab('tank', { 'TankPrefab' }, OnLoadFinish);

end

 

--加载完成后的回调--

function OnLoadFinish(objs)

        go = UnityEngine.GameObject.Instantiate(objs[0]);

        LuaComponent.Add(go,TankCmp)

end

 

运行游戏,即可用键盘的控制坦克移动。

坦克组件运行结果



界面系统在游戏中占据重要地位。游戏界面是否友好,很大程度上决定了玩家的体验;界面开发是否便利,也影响着游戏的开发进度。Unity3D UGUI系统,使用户可以“可视化地”开发界面,那么怎样用Lua去调用UGUI呢?

1、显示UI界面

下面演示如何显示一个UI界面。由于UI界面也是一种资源,使用第二篇“资源热更新”的方法即可。这个例子中,制作一个含有按钮的界面,然后组成名为Panel1的UI预设,存放到Tank目录下。

图:Panel1

前面(第二篇)已在Packager类HandleExampleBundle方法中添加了一句“AddBuildMap("tank" + AppConst.ExtName, "*.prefab", "Assets/Tank");”(当然也可以添加到其他地方),它会把Tank目录下的所有预设打包成名为tank的资源包。故而点击“Build xxx Resource”后,Panel1也会被打包到tank资源包中。

修改Lua入口函数Main.lua中的Main方法,在加载资源后把panel1放到Canvas下(需要在场景中添加画布),然后调整它的位置和大小。


--主入口函数。从这里开始lua逻辑

function Main()

        LuaHelper = LuaFramework.LuaHelper;

        resMgr = LuaHelper.GetResManager();

        resMgr:LoadPrefab('tank', { 'Panel1' }, OnLoadFinish);

end

--加载完成后的回调--

function OnLoadFinish(objs)

        --显示面板

        go = UnityEngine.GameObject.Instantiate(objs[0]);

        local parent = UnityEngine.GameObject.Find("Canvas")

        go.transform:SetParent(parent.transform);

        go.transform.localScale = Vector3.one;

        go.transform.localPosition = Vector3.zero;

end


运行游戏,即可看到加载出来的界面。

图:加载出来的界面

2、事件响应

c#中可以使用事件监听的方法给UI组件添加事件。例如,添加按钮点击事件的方法如下:

                Button btn = go.GetComponent 

                btn.onClick.AddListener

                (

                        delegate() 

                        {

                                this.OnClick(go)

                        }

                );

 

然而在LuaFramework的API中,没能找到合适的方法,只能根据第三篇中“自定义API”的方法,自己编写一套了。编写UIEvent类,它包含用于添加监听事件的AdonClick和清除监听事件的ClearButtonClick方法,代码如下所示(完成后记得要“修改CustomSetting”和“生成wrap文件”)。

using UnityEngine;

using System.Collections;

using LuaInterface;

using UnityEngine.UI;

public class UIEvent 

{

        //添加监听

        public static void AdonClick(GameObject go, LuaFunction luafunc) 

        {

                if (go == null || luafunc == null) 

                        return;

                Button btn = go.GetComponent 

                if (btn == null)

                        return;

                btn.onClick.AddListener

                (

                        delegate() 

                        {

                                luafunc.Call(go);

                        }

                );

        }

        //清除监听

        public static void ClearButtonClick(GameObject go) 

        {

                if (go == null) 

                        return;

                

                Button btn = go.GetComponent 

                if (btn == null)

                        return;

                

                btn.onClick.RemoveAllListeners();

        }

}

接下来测试下这套API,修改Main.lua,代码如下:

--主入口函数。从这里开始lua逻辑

function Main()                                        

        略

end

--加载完成后的回调--

function OnLoadFinish(objs)

        --显示面板

        略

        --事件处理

        local btn = go.transform:FindChild("Button").gameObject

        UIEvent.AdonClick(btn, OnClick)

end

function OnClick()

        print("触发按钮事件")

end

运行游戏,点击按钮,OnClick方法即被调用。

图:按钮的事件响应

读者可以使用相似的方法监听其他UI组件,这里就只演示按钮事件了。

3、界面管理器

LuaFramework提供了一套简单的(不完善的)界面管理器,具体代码请参见PanelManager类。PanelManager类的CreatePanel方法完成异步加载资源,在加载完成后,会设置面板的大小和位置,然后调用回调函数。与上面用lua加载界面的方法完全一样。

图:PanelManager的CreatePanel方法


LuaFramework会给每个界面添加名为LuaBehaviour的组件,它拥有用于添加按钮监听的AddClick方法,相关代码如下,与UIEventAdonClick方法相似。

图:LuaBehaviour的AddClick方法


在LuaFramework的PureMVC架构中,如果要添加一个界面需要编写对应的ControllerView,以及修改3个框架自带的lua文件,比较繁琐。因此在实际项目中有必要重写PanelManager,由它实现界面的加载及事件处理。


LuaFramework内置了网络模块(NetworkManager、SocketClient、ByteBuffer、Converter、Protocal),本篇将会介绍该模块的调用方法以及其原理。

1、发起连接

发起连接是客户端网络通信的第一步,LuaFramewor中,只需通过LuaFramework.AppConst.SocketAddress和LuaFramework.AppConst.SocketPort设置ip和端口,然后调用NetworkManager的SendConnect方法即可发起连接。Main.lua的代码如下:

require "Network"
 
--主入口函数。从这里开始lua逻辑
function Main()		
	local LuaHelper = LuaFramework.LuaHelper
	local networkMgr = LuaHelper.GetNetManager()
	local AppConst = LuaFramework.AppConst
	
    AppConst.SocketPort = 1234;
    AppConst.SocketAddress = "127.0.0.1";
	networkMgr:SendConnect();
end

在收到服务端回应后,LuaFramework会调用Network的OnSocket方法(写死)。新建名为Network.lua的文件,处理消息回调。在如下的代码中,Protocal代表协议号,比如“连接服务器”(Protocal.Connect)的协议号是101,在OnSocket的参数中,key便是收到的协议号,data是收到的数据。

Network = {};
 
--协议
Protocal = {
	Connect		= '101';	--连接服务器
	Exception   = '102';	--异常掉线
	Disconnect  = '103';	--正常断线   
	Message		= '104';	--接收消息
}
 
--Socket消息--
function Network.OnSocket(key, data)
	if key == 101 then
		LuaFramework.Util.Log('OnSocket Connect');		
	else
		LuaFramework.Util.Log('OnSocket Other');	
	end
end

为了测试网络功能,需要编写服务端,这里使用c#编写一套简单的服务端程序,仅为调试使用,代码如下:

using System;
using System.Net;
using System.Net.Sockets;
using System.Linq;
 
class MainClass
{
	public static void Main(string[] args)
	{
		Console.WriteLine("Hello World!");
		//Socket
		Socket listenfd = new Socket(AddressFamily.InterNetwork,
		                             SocketType.Stream, ProtocolType.Tcp);
		//Bind
		IPAddress ipAdr = IPAddress.Parse("127.0.0.1");
		IPEndPoint ipEp = new IPEndPoint(ipAdr, 1234);
		listenfd.Bind(ipEp);
		//Listen
		listenfd.Listen(0);
		Console.WriteLine("[服务器]启动成功");
		while (true)
		{
			//Accept
			Socket connfd = listenfd.Accept();
			Console.WriteLine("[服务器]Accept");
		}
	}
}

运行服务端和客户端,客户端会发起连接,服务端accept该连接后回应,客户端会显示“OnSocket Connect”

图:服务端

图:客户端

此时把服务端关掉(断开连接),客户端会收到协议号为102的消息,即异常掉线(Exception)。

图:异常掉线

调用NetworkManager.SendConnect实际是调用BeginConnect发起连接。连接之后,回调OnConnect方法。

图:连接过程

OnConnect方法调用NetworkManager.AddEvent,排除设计模式的内容,相当于调用Network.lua的OnSocket方法。传入OnSocket的第1个参数为101(Protocal.Connect),指代协议名,第2个参数是空的字节流。网络模块中定义了101、102、103这3个固定的协议号,分别代表连接服务器、异常断线和正常断线。


图:连接回调

2、发送和接收

接下来尝试发送和接收数据。LuaFramework默认(如果不去改它的代码)使用的协议格式如下图所示,前面的2个字节为消息长度,用于处理沾包分包,随后的2个字节代表协议号(如上面的101、102、103),最后才是消息的内容。

图:协议

修改Network.lua,在连接成功后(OnSocket方法的101协议),调用send发送一串协议号为104的数据。服务端收到数据后回射给客户端,客户端在收到回应后(OnSocket方法的104协议),读取并显示出来。

send方法中新建了一个buffer,然后往buffer中添加协议号(104)和协议内容(字符串:《Unity3D网络游戏实战》是一本好书!),最后调用networkMgr:SendMessage()发送数据。networkMgr:SendMessage()会自动计算协议长度,并附加到buffer上发送出去。

--Socket消息--
function Network.OnSocket(key, data)
	if key == 101 then
		LuaFramework.Util.Log('OnSocket Connect');	
		Send()
	elseif key == 104 then
		LuaFramework.Util.Log('OnSocket Message ');
		local str = data:ReadString();
		LuaFramework.Util.Log('收到的字符串:'..str);
	else
		LuaFramework.Util.Log('OnSocket Other '..key);	
	end
end
 
function Send()
	--组装数据
    local buffer = LuaFramework.ByteBuffer.New();
    buffer:WriteShort(Protocal.Message);
    buffer:WriteString("《Unity3D网络游戏实战》是一本好书!");
	--发送
	local LuaHelper = LuaFramework.LuaHelper
	local networkMgr = LuaHelper.GetNetManager()
    networkMgr:SendMessage(buffer);
	LuaFramework.Util.Log('数据发送完毕');	
end

修改服务端程序,读出接收到的内容,并echo回去。

	public static void Main(string[] args)
	{
		略,没有改动
		while (true)
		{
			//Accept
			Socket connfd = listenfd.Accept();
			Console.WriteLine("[服务器]Accept");
			//Recv 不考虑各种意外,只做测试
			byte[] readBuff = new byte[100];
			int count = connfd.Receive(readBuff);
			//显示字节流
			string showStr = "";
			for (int i = 0; i < count; i++)
			{
				int b = (int)readBuff[i];
				showStr += b.ToString() + " ";
			}
			Console.WriteLine("[服务器接收]字节流:"+ showStr);
			//解析协议
			Int16 messageLen = BitConverter.ToInt16(readBuff,0);
			Int16 protocal = BitConverter.ToInt16(readBuff,2);
			Int16 strLen = BitConverter.ToInt16(readBuff,4);
			string str = System.Text.Encoding.UTF8.GetString(readBuff, 6, strLen);
			Console.WriteLine("[服务器接收] 长度:" + messageLen);
			Console.WriteLine("[服务器接收] 协议号:" + protocal);
			Console.WriteLine("[服务器接收] 字符串:" + str);
			//Send(echo)
			byte[] writeBuff = new byte[count];
			Array.Copy(readBuff,writeBuff,count);
			connfd.Send(writeBuff);
		}
	}

运行游戏,可以看到服务端收到的如图所示的信息。字节流的前两位“53 0”表示消息长度为53字节,紧跟着的“104 0”代表协议号104。在字符串的封装中(buffer:WriteString),程序会先在buffer中添加字符串的长度,最后才是字符串的内容。“49 0”即表示“《Unity3D网络游戏实战》是一本好书!”占用49个字节(14个中文符号,每个3字节,7个英文符号,每个1字节)。协议长度53字节 = 协议号2个字节 + 字符串长度2字节 + 字符串内容49字节。

图:服务端收到的信息

客户端收到服务端回射的消息后,也会显示出来,如下图所示。

图:客户端收到的消息

在lua中调用networkMgr:SendMessage(buffer)时,实际上相当于调用了SocketClient的WriteMessage方法,该方法会计算协议的长度,然后将长度和内容组装在一起,调用BeginWrite发送数据。

图:发送数据

在建立连接后,SocketClient会调用BeginRead,当收到服务端的消息时,回调OnRead方法。OnRead又调用了OnReceive方法。

图:接收数据过程

OnReceive方法完成沾包分包处理,然后调用AddEvent方法分发消息(相当于调用了lua中NetWork表的OnSocket方法)。

图:解析数据过程

关于BeginRead、BeginConnect等方法的介绍,读者可以查看c#网络编程的资料或参照《Unity3D网络游戏实战》第6章“网络基础”。

3、消息分发

一款游戏往往涉及很多条网络通信协议,在Network.OnSocket中,如果只用ifelse语句处理不同协议,代码往往会混乱不堪。LuaFramework集成了消息分发的方法,用法如下所示。

1、引用LuaFramework\Lua\events.lua,然后使用Event.AddListener添加监听,例如“Event.AddListener(Protocal.Connect, Network.OnConnect); ”表示当收到101协议(Protocal.Connect)时,回调Network.OnConnect方法。Main.lua代码如下:

require "Network"
Event = require 'events'
 
--主入口函数。从这里开始lua逻辑
function Main()		
	local LuaHelper = LuaFramework.LuaHelper
	local networkMgr = LuaHelper.GetNetManager()
	local AppConst = LuaFramework.AppConst
	
    AppConst.SocketPort = 1234;
    AppConst.SocketAddress = "127.0.0.1";
	
	Event.AddListener(Protocal.Connect, Network.OnConnect); 
    Event.AddListener(Protocal.Message, Network.OnMessage); 
	
	networkMgr:SendConnect();
end

2、在需要分发消息的地方调用Event.Brocast,然后编写相应的回调函数。Network.lua的部分代码如下:

--Socket消息--
function Network.OnSocket(key, data)
	LuaFramework.Util.Log('OnSocket 消息分发:'..key);
	Event.Brocast(tostring(key), data);
end
 
function Network.OnConnect(data) 
    LuaFramework.Util.Log('Network.OnConnect');	
	Send()
end
 
function Network.OnMessage(data) 
    LuaFramework.Util.Log('Network.OnMessage');
	local str = data:ReadString();
	LuaFramework.Util.Log('收到的字符串:'..str);
end

运行游戏,可以看到消息分发的结果。

图:消息分发

调用Event.AddListener,实际上是在一个表中添加数据,把某个协议号对应于某个方法的信息记录起来。

图:AddListener的过程

当调用Event.Brocast时,程序会查找这份表,然后执行回调方法。这里使用了协程来调用回调函数。使用协程的目的应该是不让回调逻辑阻碍主体逻辑,然而由于协程是单线程的,这点不起作用。除非回调函数也使用协程,相互配合。所以这里应该可以不用协程的。

图:Brocast的过程


LuaFramework使用了PureMVC框架。百度百科上说:“PureMVC是在基于模型、视图和控制器MVC模式建立的一个轻量级的应用框架”。PureMVC框架可以做到较好的解耦,减少游戏代码的相互调用。然而LuaFramework整合PureMVC属于“杀鸡用牛刀”,实质上只用到了事件分发(也可能是我理解得不够透彻)。如果单纯写一套事件分发系统,可能不到100行代码就能完成。

1、解耦的好处

如果没有很好的解耦设计,游戏功能越多,代码就越乱,最后没人敢改动。举个例子,假如游戏中背包(item)和成就(Achieve)两项功能,各用一个类实现。当玩家获得100个经验豆(一种道具)时,会获得“拥有100个经验豆”的成就;当成就点数达到300时,会获得道具奖励。一种常见的实现方法是调用对方的public函数,代码如下所示。然而如果一款游戏有几百上千个类,之间又相互调用,如果某些功能需要大改(例如删掉成就功能),那其他的类也得改动。

Class Item
{
    public AddItem()
    {
        if(经验豆 > 100)
            achieve.AddAchieve(“拥有100个经验豆”)
    }
}
 
Class Achieve
{
    public AddAchieve()
    {
        成就点数 + 10
        if(成就点数 > 300)
            item.AddItem(宝石)
    }
}

如果使用事件分发,各个类之间的联系就减弱了。如下所示的代码中背包类(Item)监听了消息“添加道具”,成就类(Achieve)监听了消息“添加成就”。如果达成成就需要添加奖励,只需派发“添加道具”这条消息,由背包类去执行。这样类与类之间不存在相互调用,就算大改功能甚至删掉功能,其他类都受到的影响比较小。

Class Item
{
    Start()
    {
         监听(“添加道具”,AddItem)
    }

    private AddItem()
    {
        if(经验豆 > 100)
            分发(“添加成就”,“拥有100个经验豆”)
    }
}
 
Class Achieve
{
    Start()
    {
         监听(“添加成就”,AddAchieve)
    }
 
    private AddAchieve()
    {
        成就点数 + 10
        If(成就点数 > 300)
            分发(“添加道具”, 宝石)
    }
}

2、MVC的使用方法

LuaFramework中的Framwork目录存放着PureMVC框架的代码,个人认为在LuaFramework中属于过度设计(毕竟从其他地方拷过来的)。它的原理并不复杂,用一个列表把监听信息保存起来,在派发消息时,查找对应的监听表,找到需要回调的对象。

PureMVC框架便是实现了“注册/分发”模式(发布/订阅、观察者模式),可以调用RegisterCommand注册消息(命令),调用SendMessageCommand方法分发消息。RegisterCommand方法可以把某个继承ControllerCommand 的类注册到指定的消息下,在事件分发时调用该类的Execute方法。

例如新建一个名为TestCommand的类,让它继承ControllerCommand,然后编写Execute方法处理具体事务。

using UnityEngine;
using System.Collections;
 
public class TestCommand : ControllerCommand 
{
	public override void Execute(IMessage message) 
	{
		Debug.Log("name=" + message.Name);
		Debug.Log("type=" + message.Type);
	}
}

接着,编写另一个类来处理消息。这个类先调用AppFacade.Instance.RegisterCommand()将TestCommand类注册到“TestMessage”消息下。然后使用SendMessageCommand()派发“TestMessage”消息。框架将会创建一个TestCommand实例,并调用它的Execute方法。

public class Main : MonoBehaviour 
{
        void Start() 
	{
		AppFacade.Instance.RegisterCommand ("TestMessage", 
							typeof(TestCommand));
		AppFacade.Instance.SendMessageCommand ("TestMessage");
        }
}

运行结果如下所示,可以看到分发消息后,TestCommand的Execute方法被调用。

Execute方法的参数message包含了Name,Body,Type三个成员(如下图所示)。其中Name是命令名,Body是一个任意类型的参数。

如下代码所示,在SendMessageCommand中可以给消息的Body传值,相应的Execute方法便可以获取它。

void Start() 
{
	AppFacade.Instance.RegisterCommand ("TestMessage", 
							typeof(TestCommand));
	AppFacade.Instance.SendMessageCommand ("TestMessage", "这是字符串");
}

运行结果如下图所示。

总而言之,LuaFramework中所谓的pureMVC只是一套“注册/分发”机制,完全可以用c#的事件来实现。另《Unity3D网络游戏实战》中的客户端网络模块部分也使用的“注册/分发”机制,有兴趣的读者可以看看。

3、MVC与Unity3D组件的结合

pureMVC与Unity3D组件之间有一些封装,只要让组件继承View类(View类继承MonoBehavior),即使用pureMVC框架的RegisterMessage和SendMessageComman方法实现“注册/分发”机制。

例如,新建一个继承自View的TestManage组件,在Start 方法中它注册了“msg1”、“msg2”、“msg3”三个消息的监听。在Update方法中,当按下空格键时,分发消息“msg1”。

当接收到消息后,指定对象(这里指定this)的OnMessage方法会被调用,参数message里面包含了命令名、Body等信息。代码如下所示。

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
 
public class TestManage : View 
{
 
	// Use this for initialization
	void Start () 
	{
		List<string> regList = new List<string>();
		regList.Add("msg1");
		regList.Add("msg2");
		regList.Add("msg3");
 
		RegisterMessage(this,regList);
	}
	
	// Update is called once per frame
	void Update () 
	{
		if (Input.GetKeyUp (KeyCode.Space)) 
		{
			facade.SendMessageCommand("msg1", null);
		}
	}
 
	public override void OnMessage(IMessage message) 
	{
		Debug.Log ("OnMessage " + message.Name);
	}
}

此外LuaFramework的各个Manager(如GameManager,LuaManager,SoundManager等)也都继承自View类,可以使用“注册/分发”机制。

LuaFramework内置的管理器包括GameManager(处理热更新)、luaManager(lua脚本管理器)、PanelManager(界面管理器)、NetworkManager(网络管理器)、ResourceManager(资源管理器)、TimerManager(时间管理器)、线程管理器(ThreadManager)和SoundManager(声音管理器)。其中GameManager、luaManager、PanelManager、NetworkManager、ResourceManager在前面的文章中已经有过介绍,这一篇讲讲讲播放声音相关的SoundManager。

1、使用方法

SoundManager估计是从其他地方拷过来的,并不能很好的与框架结合,这里我们先看看原来的SoundManager的使用方法,再介绍它的不足之处及改进方法。虽然SoundManager定义了好几个方法,但能直接在lua中使用的只有用于播放背景音乐的PlayBacksound。

编写播放声音的代码前,需要在GameManager上挂载AudioSource组件,以播放背景音乐。

把声音文件放到Resource目录下,由于SoundManager使用Resources.Load加载声音文件,声音文件必须放到这个目录下。如下图所示。

然后编写lua代码,调用soundMgr:PlayBacksound即可,它的第一个参数指明Resource目录下的文件名,第二个参数为true表示开始播放,false表示停止播放。

--主入口函数。从这里开始lua逻辑
function Main()					
	LuaHelper = LuaFramework.LuaHelper;
	soundMgr = LuaHelper.GetSoundManager();
	soundMgr:PlayBacksound("motor", true)
end

运行游戏,即可听到音效。关于Unity3D播放声音的内容,大家也可以参考《Unity3D网络游戏实战》哦!

2、代码解析

SoundManager的示意代码如下,实际上是使用Resources.Load来加载资源的,所以声音文件必须放在Resources目录下。

这种用法违背了热更新框架的设计,因为在Resources目录下的文件并不能热更。

3、改进的声音管理器

这一部分我们需要改进声音管理器,实现这么几个功能:

1)从本地“数据目录”读取打包后的声音文件,使它支持热更新;

2)添加播放/停止背景音乐的PlayBackSound/StopBackSound和播放音效的PlaySound方法;

3)使用缓存存储加载后的声音文件,以提高运行效率。

为了支持加载AudioClip的加载,在ResourceManager中添加LoadAudioClip方法,该方法将会加载资源包abName的资源assetName,加载完AudioClip资源后调用回调函数func,代码如下。

//载入音效资源
public void LoadAudioClip(string abName, 
                                  string assetName, Action<UObject[]> func) 
{
	LoadAsset<AudioClip>(abName, new string[] { assetName }, func);
}

修改SoundManager,使用Hashtable类型的sounds存储加载后的声音,包含PlayBackSound、StopBackSound和PlaySound三个API。代码如下所示。

先是整体kuan

using UnityEngine;
using System.Collections;
using System.Collections.Generic;
 
namespace LuaFramework 
{
    public class SoundManager : Manager 
    {
	private AudioSource audio;
	private Hashtable sounds = new Hashtable();
	string backSoundKey = "";
 
	void Start() 
	{
		audio = GetComponent<AudioSource>();
		if (audio == null)
			gameObject.AddComponent<AudioSource> ();
	}
 
	//回调函数原型
	private delegate void GetBack(AudioClip clip, string key);
 
	//获取声音资源
	private void Get(string abName, string assetName, GetBack cb)
	{
		string key = abName + "." + assetName;
		if(sounds [key] == null) 
		{
			ResManager.LoadAudioClip(abName, assetName, (objs)=>
			{
				if(objs == null || objs[0] == null)
				{
					Debug.Log("PlayBackSound fail);
					cb(null,key);
					return;
				}
				else
				{
					sounds.Add(key, objs[0]);
					cb(objs[0] as AudioClip ,key);
					return;
				}
			});
		} 
		else 
		{
			cb(sounds [key] as AudioClip,key);
			return;
		}
	}
  }
}

PlayBackSound:

	//播放背景音乐
	public void PlayBackSound(string abName, string assetName)
	{
		backSoundKey = abName + "." + assetName;
		Get(abName, assetName,(clip, key)=>
		{
			if(clip == null)
				return;
			if(key != backSoundKey)
				return;
 
			audio.loop = true;
			audio.clip = clip;
			audio.Play();
		});
	}

StopBackSound:

	//停止背景音乐
	public void StopBackSound()
	{
		backSoundKey = "";
		audio.Stop ();
	}

 	//播放音效
	public void PlaySound(string abName, string assetName)
	{
		Get(abName, assetName,(clip, key)=>
		{
 
			if(clip == null)
				return;
			if(Camera.main == null)
				return;
		AudioSource.PlayClipAtPoint(clip, 
					Camera.main.transform.position); 
		});
	}

修改代码后,需要重新生成wrap文件(点击菜单栏的Lua→Clear wrap files和Lua→Generate All)。然后编写lua代码调试它吧!这里演示的是先播放背景音乐,3秒后停止播放,每隔0.3秒播放一次音效的功能。

--主入口函数。从这里开始lua逻辑
function Main()					
	LuaHelper = LuaFramework.LuaHelper;
	soundMgr = LuaHelper.GetSoundManager();
	soundMgr:PlayBackSound("sound", "motor")
	UpdateBeat:Add(Update, self)
end
 
 
local lastTime = 0
 
function Update()
	if Time.time > 3 then
		soundMgr:StopBackSound();
	end
	
	
	if Time.time - lastTime > 0.3 then
		soundMgr:PlaySound("sound", "shoot")
		lastTime = Time.time
	end
end

LuaFramework内置了线程管理器ThreadManager,一开始我以为这是个创建线程、终止线程等方法的封装。然而不是,它是热更新时使用线程下载资源的具体实现。那让我们来看看线程管理器的工作原理吧。

@罗培羽

1、GameManager的调用

那么先看看在热更新过程中哪些地方调用到ThreadManager。热更新由GameManager执行(相关代码如下图所示),它在对比本地文件和网络资源的差异后,将需要下载的文件名存放到列表中,然后遍历列表,调用BeginDownload下载。从代码可以看出,它通过ISDownOK判断该文件是否下载完成,然后下载下一个文件,一个个的下载文件。

BeginDownload(代码如下所示)便调用ThreadManager的AddEvent方法。ThreadManager并不是真正意义上的线程管理器,它只管理一条“下载线程”,通过AddEvent将要下载的文件名放到“代办列表”中,该线程依次下载它们。其中的OnThreadCompleted是“回调函数”,在下载该文件后,会通过消息的方式回调它。

在“下载线程”下载完一个文件后,它以通知的形式调用“回调函数”OnThreadCompleted(代码如下所示),该方法将会设置“下载完成列表”downloadFiles。

再看看IsDownOk(代码如下所示)方法,当“下载完成列表”包含该文件时,说明下载已经完成,可以进行下一个文件的下载。

2、ThreadManager的启动

ThreadManager启动时,开启一个线程“下载线程”,相关代码如下所示。由此ThreadManager仅仅是管理一条线程,而不是真正意义的线程管理器。


3、AddEvent方法

AddEvent是给线程添加任务的方法,代码如下,其实就是给events队列添加一个值。

Events的定义如下所示:

ThreadEvent包含事件名key和参数evParams,代码如下所示。

3、下载过程

“下载线程”执行了OnUpdate方法(代码如下所示),它调用OnDownloadFile下载文件。

OnDownloadFile(代码如下所示)又调用了DownloadFileAsync下载文件,下载文件过程中ProgressChanged方法会被调用。

ProgressChanged方法记录了下载进度,当进度为100%时,使用m_SyncEvent发送通知,相当于调用“回调函数”OnThreadCompleted。


4、改进

这套线程管理器依然有“杀鸡用牛刀”之嫌,“任务列表”并没有实际作用(因为GameManager控制了下载进度,一个个下载),消息分发部分也太复杂,实际上只用回调函数之类的方法便能够实现。

个人认为线程管理器应当提供线程调度的方法,具体的下载逻辑可在GameManager中实现。而且下载功能不一定非要用线程,协程也能够解决,而且更简单。代码如下所示。

由于热更新需要下载不少的文件,一个个下载实在太慢。如果能开启多个线程,同时下载,可在一定程度上提高下载速度。

框架并没有处理下载失败的情况,一般情况下,当一个文件下载失败,应当重试,在重试多次依然无法下载时,才弹出错误。



终于到了本系列完结的时候了。

现在,大家对LuaFramework有个全方位的理解了吧!接下来通过一个例子总结ulua,作为“lua逻辑”的延伸,说明lua的写法。这个例子中玩家能够控制2D游戏角色走动,并且发射炮弹。

1、目标

制作如图所示的游戏,玩家可以通过键盘控制角色上下左右移动,角色有4个面向,走动过程中会播放行走动画。当玩家点击鼠标左键,角色会发射一颗炮弹。

2、游戏资源

使用下图所示的图片作为游戏角色(该图片来自rpg maker),在导入Unity后将它切割成12张小图。


使用如下图所示的图片作为炮弹。

在游戏场景中新建画布,画布下摆放一个名为Panel的面板,代表游戏场景。面板下有res和map两个子物体,map(Image)为一张场景图,role(Image)为游戏中的角色,bullet(Image)为游戏中的子弹(同一时间只能发射一颗子弹)。

Res子物体存放12张角色图片(Image),之后会使用这些资源替换map.role的图片,以实现动画效果。

然后将Panel做成预设,存放到SimpleGame目录下。并且在GameManager空物体上添加Game组件,以启动框架。

修改Packager.cs,在HandleExampleBundle添加如下代码,将SimpleGame目录下的预设打包。然后点击LuaFramework→Build Windows Resource打包。

//小游戏
AddBuildMap("SimpleGame" + AppConst.ExtName, "*.prefab", "Assets/SimpleGame");

具体的框架设置请参加第一篇和第二篇,这里仅做简单描述。

3、编写行走代码

游戏使用UI组件,在CustomSettings.cs中添加如下两行,使tolua生成Image和Sprite相关的调用。

_GT(typeof(Image)),
_GT(typeof(Sprite)),

打开main.lua(如何运行main.lua请参见第一篇)开始编写代码。程序从Main方法开始执行,使用LoadPrefab(请参见第二篇)加载之前打包的资源文件Panel。这里还定义几个变量,其中map代表游戏场景(panel.map),role代表游戏角色(panel.map.role),roleImage是游戏角色中的图片组件,roleRes代表各个面向的角色图片,比如roleRes[“UP”]将会包含panel.res中3张角色朝上的图。roleAnm代表当前角色的动画,每个面向有3个动画,对应于不同的图片。lastAnmTime代表展现角色动画帧的时间,用于控制动画播放速度。

--主入口函数。从这里开始lua逻辑
function Main()					
	LuaHelper = LuaFramework.LuaHelper;
	resMgr = LuaHelper.GetResManager();
	resMgr:LoadPrefab('SimpleGame', { 'Panel' }, OnLoadFinish);
end

local map
local role
local roleImage
local roleRes = {
	["UP"] = {},
	["DOWN"] = {},
	["LEFT"] = {},
	["RIGHT"] = {},
}
local roleAnm = 1;
local lastAnmTime = 0;

--加载完成后的回调--
function OnLoadFinish(objs)
    --暂略
end

接着编写加载完成的回调方法OnLoadFinish,它处理下面几件事情。

1、使用Instantiate实例化面板,并且设置面板的坐标,具体请参见第5篇。

2、获取面板中的部件,给map、role、roleImage赋值。

3、获取素材res中的图片,赋值给roleRes,之后roleRes ["UP"],roleRes ["DOWN"],roleRes ["LEFT"],roleRes ["RIGHT"]都包含3张同面向不同动画的图片。

4、使用UpdateBeat:Add()初始化Update方法(具体参照第三篇)。

function OnLoadFinish(objs)
	--显示面板
	go = UnityEngine.GameObject.Instantiate(objs[0])
	local parent = UnityEngine.GameObject.Find("Canvas")
    go.transform:SetParent(parent.transform, false)
	--获取元素
	map = go.transform:FindChild("map").gameObject
	role = map.transform:FindChild("role").gameObject
	roleImage = role:GetComponent("Image")
	--获取素材
	local res = go.transform:FindChild("res").gameObject
	roleRes["DOWN"][1] = res.transform:FindChild("role (0)").gameObject:GetComponent("Image").sprite
	roleRes["DOWN"][2] = res.transform:FindChild("role (1)").gameObject:GetComponent("Image").sprite
	roleRes["DOWN"][3] = res.transform:FindChild("role (2)").gameObject:GetComponent("Image").sprite
	roleRes["LEFT"][1] = res.transform:FindChild("role (3)").gameObject:GetComponent("Image").sprite
	roleRes["LEFT"][2] = res.transform:FindChild("role (4)").gameObject:GetComponent("Image").sprite
	roleRes["LEFT"][3] = res.transform:FindChild("role (5)").gameObject:GetComponent("Image").sprite
	roleRes["RIGHT"][1] = res.transform:FindChild("role (6)").gameObject:GetComponent("Image").sprite
	roleRes["RIGHT"][2] = res.transform:FindChild("role (7)").gameObject:GetComponent("Image").sprite
	roleRes["RIGHT"][3] = res.transform:FindChild("role (8)").gameObject:GetComponent("Image").sprite
	roleRes["UP"][1] = res.transform:FindChild("role (9)").gameObject:GetComponent("Image").sprite
	roleRes["UP"][2] = res.transform:FindChild("role (10)").gameObject:GetComponent("Image").sprite
	roleRes["UP"][3] = res.transform:FindChild("role (11)").gameObject:GetComponent("Image").sprite
	--UpdateBeat
	UpdateBeat:Add(Update, self)
end

编写Update方法,它根据用户输入改变坐标(具体参见第三篇),并且根据不同的移动方向设置角色图片素材,将roleImage.sprite替换成roleRes[方向][动画索引]。最后判断“if Time.time - lastAnmTime > 0.1 then”,每隔0.1秒切换一次动画。

--每帧执行
function Update()
	
	--移动
	local Input = UnityEngine.Input;
	local horizontal = Input.GetAxis("Horizontal");
	local verticla = Input.GetAxis("Vertical");
	
	local x = role.transform.position.x + horizontal
	local y = role.transform.position.y + verticla
	role.transform.position = Vector3.New(x,y,0)
	--转向
	if horizontal < 0 then
		roleImage.sprite = roleRes["LEFT"][roleAnm]
	elseif horizontal > 0 then
		roleImage.sprite = roleRes["RIGHT"][roleAnm]
	elseif verticla > 0 then
		roleImage.sprite = roleRes["UP"][roleAnm]
	elseif verticla < 0 then
		roleImage.sprite = roleRes["DOWN"][roleAnm]
	end
	--步伐(动画索引)
	if Time.time - lastAnmTime > 0.1 then
		roleAnm = roleAnm+1
		if roleAnm > 3 then roleAnm = 1 end
		lastAnmTime = Time.time
	end
	
end

运行游戏,玩家可以通过键盘的方向键控制角色移动。

3、编写射击代码

在main.lua中添加炮弹相关的变量,如下所示。其中bullet代表炮弹元件(panel.map.bullet),lastShootTime 代表上一次发射炮弹的时间,bulletSpeedX代表炮弹的水平移动速度,bulletSpeedY代表炮弹的垂直移动速度,roleFace代表角色的面向。然后在OnLoadFinish中给bullet赋值。

local bullet
local lastShootTime = -100
local bulletSpeedX = 0
local bulletSpeedY = 0
local roleFace = 0

function OnLoadFinish(objs)
	……
	--子弹元素
	bullet = map.transform:FindChild("bullet").gameObject
End

在Update中给roleFace赋值。

--每帧执行
function Update()
	
	--移动
	……
	--转向
	if horizontal < 0 then
		roleImage.sprite = roleRes["LEFT"][roleAnm]
		roleFace = 1
	elseif horizontal > 0 then
		roleImage.sprite = roleRes["RIGHT"][roleAnm]
		roleFace = 2
	elseif verticla > 0 then
		roleImage.sprite = roleRes["UP"][roleAnm]
		roleFace = 3
	elseif verticla < 0 then
		roleImage.sprite = roleRes["DOWN"][roleAnm]
		roleFace = 4
	end
	--步伐
	……
end

在Update中添加处理炮弹的代码,它处理如下几件事情。

1、炮弹在飞行1.2秒后,燃尽消失;

2、当玩家按下鼠标左键时,发射炮弹,根据角色面向,bulletSpeedX和bulletSpeedY会有不同的值。

3、根据bulletSpeedX和bulletSpeedY移动炮弹。

--每帧执行
function Update()
	……
	--子弹
	if Time.time - lastShootTime > 1.2 then
		--消失
		if bullet.transform.position.x ~= -999 then
			bullet.transform.position = Vector3.New(-999,-999,0)
		end
		--发射
		if Input.GetMouseButton(0) then
			bullet.transform.position = Vector3.New(x,y,0)
			if roleFace == 1 then
				bulletSpeedX = -10
				bulletSpeedY = 0
			elseif roleFace == 2  then
				bulletSpeedX = 10
				bulletSpeedY = 0
			elseif roleFace == 3 then
				bulletSpeedX = 0
				bulletSpeedY = 10
			elseif roleFace == 4  then
				bulletSpeedX = 0
				bulletSpeedY = -10
			end
			lastShootTime = Time.time
		end
	else
		--运动
		local x = bullet.transform.position.x + bulletSpeedX
		local y = bullet.transform.position.y + bulletSpeedY
		bullet.transform.position = Vector3.New(x,y,0)
	end
end

运行游戏,点击鼠标左键,角色发射炮弹。另外也可以用基于组件的方法实现,这里就不展开了。




猜你喜欢

转载自blog.csdn.net/cxihu/article/details/78654294
今日推荐