在游戏开发中对游戏绑定的脚本语言的编写方法进行一个总结。

一、逻辑上的同级脚本

这种绑定使用方式的特点是脚本语言拥有与原宿主语言的类型等级相同,在上层开发中可以将他们视为平等的,并拥有同一套类型系统和类型识别方式(但并不一定就是语言内置的类型系统,也可以自定义一套)。比如UE和蓝图之间就是这种关系。而在unity中,UnityJavascript与C#属于同级的,同时使用同一套类型系统。但现有的Lua绑定并未实现类型系统相关的修改,这里举一个让Lua能和C#同级的例子。

为了让Lua能和C#同级,首先需要规范一个Lua脚本的格式,比如类名应该怎么写,需要继承哪个类型等等,在这里需要编写一个分析器,对这个Lua脚本进行分析。通过分析拿到这个Lua的一些信息,类名、成员属性、成员方法和一个可以创建该对象的函数。然后通过这个信息去生成一个代理的Type,在类型构建器中用添加IL码的方式去执行Lua解释器中对应的函数(动态添加类型的文章可以找我以前C#实现Java匿名接口的文章,如不使用动态构建类型,那么也可以通过IL注入器等方式实现)。因为使用相同的类型系统,所以Unity可以直接通过Type来识别Lua类型和通过代理的对象去执行对应的函数。

流程为:Unity识别(通过类型系统的Type)  ->  LuaObject (动态构建的代理类型,函数是IL实现的转发到Lua的调用)  ->   Lua解释器(执行对应的脚本)

当然以上只是实现的一个思路,你还可以自己在C#的Type的类型系统中创建代理类型,然后从原C#Type类型系统与Lua代理类型系统上抽象一个新的类型系统(这就好比Unity引擎自身的RTTI和Mono的Type类型系统之间的关系),然后通过编辑器扩展等方式让编辑器也能识别这种Lua的代理类型。这里提供一个抽象类型系统编写方法的思路

类型系统的简单抽象

class MType
{
    private Type type;
    private MType base; //基类
    public bool IsLuaType() => type is typeof(LuaObject);
    public MFieldInfo GetField(string name);
    public MMethodInfo GetMethod(string name);
    ...
}

接口

interface IMType
{
    MType GetMType();
}

Lua对象

class LuaObject : IMType
{
    LuaState state;
    
    MType GetMType();

    public T GetVar<T>(string name);
    public object Invoke(object[] parm);
}

Unity自定义的实现新类型系统的基类

class MMonoBehaviour : MonoBehaviour, IMType
{
    public MType GetMType();
}

也可以通过特殊的方式让C#继承Lua类

class LuaBase : IMType
{
    //lua基类
    MType luaBaseType;
    //调用Lua基类构造函数
    publilc LuaBase(...)
    {
        luaBaseType.ctor(this, ...)
        //其他代码
    }
}

这样在上层开发时我们就使用了同一个类型的鉴别方式:MType。

以上就是我总结的两种使用同一套类型系统的方法,使用同一套类型系统可以让互相调用非常方便,但其中可能会产生相当大量的跨语言调用,相对脚本解释器执行速度来说跨语言调用一般会更加耗费昂贵。

 

二、Mixin方式

Mixin顾名思义就是混入的意思,可以将脚本看做一个补丁,补到某个原有系统的类型上去,替换或拓展这个原有类型的一部分功能,经常用于游戏MOD开发、以及热修复补丁上。Mixin是在原有的类型系统和对象上添加的一个组件。

这里做一个简单的例子,可以通过Lua脚本动态的修改原有对象中的逻辑。在一些Lua的绑定框架中该步骤可能是通过标记Attribute和IL注入来自动完成的。

class PlayerCharacter : MonoBahaviour
{
    LuaState state;
    
    public void Fire()
    {
        // 如果Lua修改了该逻辑,那么就重定向到Lua执行
        if(state.HasKey("Fire")) 
        {
            state["Fire"].Invoke();
            return;
        }
        // 本类自身的逻辑
        ...
    }
}

这种开发方式比较灵活,但只在一些特定场景下比较好用,如果希望其他对象能访问这个Mixin的成员的话,还需要将变量或者函数定义到宿主对象上来,然后在Mixin中进行修改。

这里假设有一个可以自定注入的框架,如果逻辑全部写在Lua那么其他对象将难以访问该对象的成员,所以在类里定义但不使用,而在Lua中使用也是一种兼容的做法。

[MixinLua]
class Player
{
    public float damage;

    [MixinLua]
    public void Fire() {}
}

Lua去实现代码

local Player = Mixin("Player")

function Player:Fire()
    self:Attack(self.damage)
end

return Player

这样在C#中其他对象可以访问成员,同时在Lua里也可以修改。

 

三、两侧分割

两侧分割指的是两套代码执行方式,两套生命周期管理方式,两套类型系统等等。算是一个非常经典的做法,也是性能最高的做法,这种做法的一个特点就是一般都会将一整套框架和业务逻辑写在同一侧,这样就减少了大量的跨语言调用(尤其是在数学运算的逻辑上,因为如果向量运算也要跨语言去调用宿主库的话那么运行开销会非常的大),但难以和编辑器兼容,会在开发流程上有很多不方便。

如Unity绑定了Lua或Python等,那么我们可以完完全全的把所有逻辑全部写在Lua或Python侧(但因为Lua语言自身的精小所以C#还是会提供一些例如网络的基础封装),而C#侧只需要将Unity生命周期事件(Start、Update、Destroy)传递给Lua侧。Lua侧需要有Lua的类型来编写逻辑,为了更好的管理对象也需要在Lua中实现一个简单的类型识别系统(有类型系统的不用,比如python)。

这里贴一个Lua伪代码,其中construct函数里的第二行成员名为gameObject,是模拟了C#侧的Component编写习惯,同时通过一个工具拿到了C#侧的gameObject对象引用,这样就可以通过这个对象引用去更新实际的Unity对象。

local Player = class.extends("Player", Enitty)
local base = Entity

function Player:construct(...)
   base.construct(self, ...)
   self.gameObject = GameObjectManager.Get("Player")
   self.name = "aabb"
end

function Player:Update(dt)
    self.gameObject.transform.Translate(dt)
end

return Player

C#侧事件转发调用Lua侧的伪代码:

public void Update()
{
    luaState.GetKey("TickManager").Tick(Time.deltaTime);
}

Lua侧接受,并通过一个对象列表去更新所有的Lua对象的伪代码:

TickManager = {}

Entities = {}

function TickManager.Tick(dt)
    for entity ipairs(Entities) do
        entity.Update(dt)
    end
end

可见,Lua侧持可以池有大量的UnityC#对象,而不用像Mixin一样每个脚本都Tick一次,而是直接对整个Lua进行了一次Tick,包括向量和系统逻辑运算全部在Lua侧执行,仅更新Unity对象时才会有跨语言调用的开销。