jynew 项目中的 xLua 集成教程:Lua 文件加载、C 与 Lua 双向互调实战
2026/9/16 13:38:18 网站建设 项目流程

jynew 项目中的 xLua 集成教程:Lua 文件加载、C# 与 Lua 双向互调实战

【免费下载链接】jynewJinYongLegend-like RPG Game Framework with full Modding support and 10+ hours playable samples of game.项目地址: https://gitcode.com/GitHub_Trending/jy/jynew

导读

本教程以 jyx2/Assets/XLua/Doc/XLua教程.md 为骨架,系统讲解金庸群侠传 3D 重制版(jynew)所采用的 xLua 热更新方案中,Lua 脚本的三种加载方式、C# 主动访问 Lua 数据结构的四种映射手段,以及 Lua 调用 C# 的完整语法体系。读完本文,你将掌握LuaEnv生命周期管理、自定义 Loader 扩展、[CSharpCallLua]/[LuaCallCSharp]代码生成特性,并能对照 jynew 的 LuaManager.cs 与 Jyx2LuaBridge.cs 真实工程,把 xLua 应用到自己的游戏逻辑热更与 Mod 开发中。

说明:xLua 由腾讯开源(MIT 协议),jynew 将其作为核心热更组件集成在jyx2/Assets/XLua/目录下;本文所有代码示例均可从仓库中的 XLua/Tutorial 目录找到对应工程源码。

一、Lua 文件加载:从字符串到文件再到自定义 Loader

1.1 执行字符串(DoString)

最基本的方式是直接用LuaEnv.DoString执行一段符合 Lua 语法的字符串:

luaenv.DoString("print('hello world')");

完整示例见 Tutorial/LoadLuaScript/ByString/ByString.cs。从示例源码可以看到一个标准LuaEnv的完整生命周期模板,这也是所有示例共用的骨架:

public class ByString : MonoBehaviour { LuaEnv luaenv = null; void Start() { luaenv = new LuaEnv(); // 1. 创建 Lua 虚拟机 luaenv.DoString("print('hello world')"); // 2. 执行字符串 } void Update() { if (luaenv != null) luaenv.Tick(); // 3. 每帧驱动(触发 GC 与委托回调等) } void OnDestroy() { luaenv.Dispose(); // 4. 销毁虚拟机,释放资源 } }

文档中明确指出:DoString 执行字符串这种加载方式并不建议,原因在于把业务代码硬编码进 C# 字符串里,既难以维护也无法热更。更推荐下面基于require的文件加载方式。

1.2 通过 require 加载 Lua 文件

用 Lua 原生require函数加载文件:

luaenv.DoString("require 'byfile'");

完整示例见 Tutorial/LoadLuaScript/ByFile/ByFile.cs,对应的 Lua 文件放在 Tutorial/LoadLuaScript/ByFile/Resources/byfile.lua.txt。

这里有两个关键知识点:

  1. Loader 链机制require实际上会依次调用一个个 loader 去加载,只要有一个成功就不再往下尝试;全部失败则报"文件找不到"。
  2. Resources 加载限制:xLua 除原生 loader 外,还添加了从 UnityResources目录加载的 loader。由于Resources只支持有限的文件后缀,放在 Resources 下的 lua 文件必须加上.txt后缀(如示例中的byfile.lua.txt),加载时require 'byfile'会自动匹配到该文件。

官方推荐的工程级加载方式是:整个程序只执行一次DoString("require 'main'"),然后在main.lua中加载其它脚本——这与命令行执行lua main.lua的模式一致,便于形成清晰的脚本入口与依赖树。

jynew 工程正是按此思路落地:根入口脚本 Assets/LuaScripts/InitLuaScripts.lua 中通过require "LuaModuleList"读取模块清单,再循环require各业务模块并挂载到全局Jyx2表上(Jyx2:AddModule/Jyx2:GetModule),实现了"一个入口、按需加载全部模块"的架构。

1.3 自定义 Loader:支持下载、解压、解密等任意来源

如果 Lua 文件是运行时下载的、需要从自定义文件格式中解压、或者需要解密,require默认 loader 无法满足——此时用 xLua 的自定义 Loader。它只涉及一个接口:

public delegate byte[] CustomLoader(ref string filepath); public void LuaEnv.AddLoader(CustomLoader loader)

使用要点:

  • 通过AddLoader注册回调;Lua 代码里调用require时,require 的参数会原样透传给回调
  • 回调内根据该参数去加载指定文件;如果需要支持调试,必须把filepath修改为真实路径后传出
  • 返回值是byte[]:返回null表示该 loader 找不到文件(会继续尝试下一个 loader),否则返回 Lua 文件内容。

完整示例见 Tutorial/LoadLuaScript/Loader/CustomLoader.cs,其核心演示了"从内存字符串构造 Lua chunk"的自定义 loader:

luaenv.AddLoader((ref string filename) => { if (filename == "InMemory") { string script = "return {ccc = 9999}"; return System.Text.Encoding.UTF8.GetBytes(script); } return null; // 找不到就返回 null,交给下一个 loader }); luaenv.DoString("print('InMemory.ccc=', require('InMemory').ccc)");

文档指出,有了自定义 Loader,"用 IIPS 的 IFS?没问题,写个 loader 调用 IIPS 的接口读文件内容即可;文件已经加密?没问题,自己写 loader 读取文件解密后返回即可"。

jynew 工程实战:LuaManager.cs 在Init中注册了多个链式 loader:

luaEnv = new LuaEnv(); // loader 1:require 时优先去 Assets/LuaScripts/ 下查找 luaEnv.AddLoader((ref string filename) => { var luaFile = ResLoader.LoadAssetSync<TextAsset>($"Assets/LuaScripts/{filename}.lua"); if (luaFile != null) return Encoding.UTF8.GetBytes(luaFile.text); return null; }); // loader 2:require 时默认去 Assets/BuildSource/Lua/ 下加载 luaEnv.AddLoader((ref string filename) => { var luaFile = ResLoader.LoadAssetSync<TextAsset>($"Assets/BuildSource/Lua/{filename}.lua"); if (luaFile != null) return Encoding.UTF8.GetBytes(luaFile.text); return null; });

这正好是"多个 loader 依次尝试"机制的工程级范例:先查Assets/LuaScripts/(业务 Lua 主目录),未命中再查Assets/BuildSource/Lua/,均未命中则返回null交给后续 loader(最终由原生 loader 报错)。

1.4 LuaEnv 生命周期要点

结合 ByString.cs 与 jynew 的 LuaManager.cs 可以看到完整生命周期规范:

  • 创建new LuaEnv(),一个LuaEnv即一个独立的 Lua 虚拟状态机;
  • 驱动:每帧调用luaenv.Tick(),用于推进内部回调、GC 等;
  • 销毁luaenv.Dispose()释放虚拟机。jynew 的LuaManager.Clear()中还演示了销毁前依次执行LuaMod_DeInit()、调用Jyx2:DeInit()反初始化 Lua 侧模块、释放缓存的LuaFunction_cachedFunc)的完整清理顺序(见 LuaManager.cs)。

另外 jynew 还提供了 Editor 下的热重载支持:LuaManagerHOTRELOAD_LUA_IN_EDITOR = true时,LoadLua会直接从Assets/Mods/{curMod}/Lua/磁盘路径读取.lua文件,从而"可以不重启游戏进行编辑"(见 LuaManager.cs)。

二、C# 访问 Lua:全局数据的四种取值姿势

"C# 访问 Lua"指 C# 主动发起对 Lua 数据结构的访问,所有示例集中在 Tutorial/CSharpCallLua/CSCallLua.cs,对应的 Lua 侧全局数据如下:

a = 1 b = 'hello world' c = true d = { f1 = 12, f2 = 34, 1, 2, 3, add = function(self, a, b) return a + b end } function e() print('i am e') end function f(a, b) return 1, {f1 = 1024} end function ret_e() return e end

2.1 获取全局基本数据类型

访问LuaEnv.Global的模板Get方法,指定返回类型即可:

luaenv.Global.Get<int>("a") luaenv.Global.Get<string>("b") luaenv.Global.Get<bool>("c")

2.2 访问全局 table:四种映射方式

映射目标传值/传引用是否依赖生成代码特点
普通 class / structby value(值拷贝)简单直观,字段对应即可;复杂类型拷贝代价大
interfaceby ref(无生成代码会抛InvalidCastExceptionget/set 属性实时读写 table 字段,可经接口方法调用 Lua 函数
Dictionary<>/List<>by value轻量,要求 table 的 key/value 类型一致
LuaTableby ref无需生成代码;速度慢(比 interface 慢一个数量级)、无类型检查

方式一:映射到普通 class/struct。定义一个含对应 public 字段、带无参构造函数的 class 即可,例如{f1 = 100, f2 = 100}对应public class DClass { public int f1; public int f2; }。xLua 会自动new一个实例并把字段赋值过去。注意:table 的字段可以多于或少于 class 的属性,且可以嵌套其它复杂类型;但该过程是值拷贝——修改 class 字段不会同步回 table,反向亦然。开销问题可通过把类型加入 GCOptimize 生成来降低,详见 configure.md 与 XLua复杂值类型(struct)gc优化指南.md。

方式二:映射到 interface(推荐用于频繁交互)。依赖生成代码:代码生成器会生成该 interface 的实例,get 属性即读取对应 table 字段,set 属性即写入,甚至可通过 interface 方法访问 Lua 函数。示例:

[CSharpCallLua] public interface ItfD { int f1 { get; set; } int f2 { get; set; } int add(int a, int b); } ItfD d3 = luaenv.Global.Get<ItfD>("d"); d3.f2 = 1000; // 写回 table 字段 Debug.Log("_G.d:add(1, 2)=" + d3.add(1, 2)); // 调用 table 内的 Lua 函数

方式三:映射到Dictionary<>/List<>。不想定义 class 或 interface 时的轻量选择:

Dictionary<string, double> d1 = luaenv.Global.Get<Dictionary<string, double>>("d"); List<double> d2 = luaenv.Global.Get<List<double>>("d");

方式四:映射到LuaTable。无需生成代码,但慢且无类型检查:

LuaTable d4 = luaenv.Global.Get<LuaTable>("d"); int f1 = d4.Get<int>("f1");

2.3 访问全局 function:delegate 与 LuaFunction

映射到 delegate(推荐):性能好、类型安全,缺点是需要生成代码(否则抛InvalidCastException)。声明规则:

  • function 的每个参数对应 delegate 的一个输入类型参数;
  • 多返回值从左往右映射到 C# 的输出参数(输出参数包括:返回值、out参数、ref参数);
  • 参数/返回值支持各种复杂类型,out/ref修饰,甚至可以返回另一个 delegate。

示例(注意[CSharpCallLua]特性标记):

[CSharpCallLua] public delegate int FDelegate(int a, string b, out DClass c); FDelegate f = luaenv.Global.Get<FDelegate>("f"); DClass d_ret; int f_ret = f(100, "John", out d_ret); // Lua 多返回值 {1, {f1=1024}} 分别落到返回值与 out 参数

映射到LuaFunction:优缺点与第一种相反,无需生成代码但慢且非类型安全。LuaFunction有变参Call函数,可传任意类型、任意个数的参数,返回object[]数组,对应于 Lua 的多返回值:

LuaFunction d_e = luaenv.Global.Get<LuaFunction>("e"); d_e.Call();

2.4 使用建议(来自原文档)

  1. 访问 Lua 全局数据(尤其 table 与 function)代价较大,应尽量少做:例如在初始化时把要调用的 Lua function 一次性取出映射为 delegate 并保存,后续直接调用该 delegate;table 同理。
  2. 如果 Lua 侧实现都以 delegate 和 interface 方式提供,使用方可以与 xLua 完全解耦:由一个专门模块负责 xLua 初始化与 delegate/interface 映射,再把映射结果注入到需要它们的地方。

jynew 工程实战:这两条建议在 LuaManager.cs 中有直接体现——_cachedFunc字典缓存已取出的LuaFunctiongetCachedFunction命中缓存则直接复用,未命中才通过luaEnv.Global.Get<LuaFunction>(name)获取一次;同时 LuaExecutor.cs 与 Jyx2LuaBridge.cs 就是"专门的桥接模块"——后者用[LuaCallCSharp]标记的 2369 行静态方法为 Lua 侧暴露对话(Talk)、UI 面板、剧情引擎等全套游戏能力。

三、Lua 调用 C#:从 new 对象到复杂类型互转

本章实例全部位于 Tutorial/LuaCallCSharp/LuaCallCs.cs,被调用的 C# 类型(BaseClassDerivedClassTestEnumICalc等)也在同一文件中,均以[LuaCallCSharp]标记。

3.1 new C# 对象与重载

C# 侧var newGameObj = new UnityEngine.GameObject();对应 Lua 侧:

local newGameObj = CS.UnityEngine.GameObject() -- 无参构造 local newGameObj2 = CS.UnityEngine.GameObject('helloworld') -- 带 string 参数的重载构造

两个基本规则:Lua 中没有new关键字所有 C# 相关内容(构造函数、静态成员/属性/方法)都放在CS命名空间下。xLua 支持构造函数重载。

3.2 访问静态属性、方法

-- 读静态属性 CS.UnityEngine.Time.deltaTime -- 写静态属性 CS.UnityEngine.Time.timeScale = 0.5 -- 静态方法 CS.UnityEngine.GameObject.Find('helloworld')

性能小技巧:需要经常访问的类先用局部变量引用,既减少敲代码时间又提升性能:

local GameObject = CS.UnityEngine.GameObject GameObject.Find('helloworld')

3.3 访问成员属性、方法

testobj.DMF -- 读成员属性 testobj.DMF = 1024 -- 写成员属性 testobj:DMFunc() -- 调用成员方法,注意:第一个参数需传该对象本身,建议用冒号语法糖

父类成员:xLua 支持通过派生类访问基类的静态属性/方法,以及通过派生类实例访问基类的成员属性/方法(示例中DerivedClass可访问BaseClassBSFBSFunc()BMFBMFunc())。

3.4 out / ref 参数与多返回值

  • 参数规则:C# 普通参数算一个输入形参,ref修饰的也算一个输入形参,out不算;然后从左往右对应 Lua 调用侧的实参列表。
  • 返回值规则:C# 函数返回值(如果有)算一个返回值,out算一个返回值,ref也算一个返回值;从左往右对应 Lua 的多返回值。

示例(ComplexFunc(Param1 p1, ref int p2, out string p3, Action luafunc, out Action csfunc)):

local ret, p2, p3, csfunc = testobj:ComplexFunc({x=3, y = 'john'}, 100, function() print('i am lua callback') end) csfunc() -- 通过 out 拿回的 C# delegate,直接调用

3.5 重载方法与类型二义性

直接通过不同的参数类型访问重载函数:

testobj:TestFunc(100) -- 命中 TestFunc(int) testobj:TestFunc('hello') -- 命中 TestFunc(string)

注意:xLua 只在一定程度上支持重载调用。因为 Lua 类型远不如 C# 丰富,存在"一对多"(如 C# 的int/float/double都对应 Lua 的number)。上例若TestFunc存在intfloat等重载,第一行将无法区分,只能调用到其中一个(生成代码中排在前面的那个)。

3.6 操作符

支持的操作符有:+-*/==、一元-<<=%[]。示例中DerivedClass重载了operator +,Lua 侧(testobj + testobj2).DMF可直接计算。

3.7 默认值、可变参数与扩展方法

  • 默认值参数:与 C# 调用一致,实参少于形参时用默认值补齐:testobj:DefaultValueFunc(1)
  • 可变参数void VariableParamsFunc(int a, params string[] strs)对应 Luatestobj:VariableParamsFunc(5, 'hello', 'john')
  • Extension methods:C# 里定义后 Lua 可直接使用(示例testobj:GetSomeData()testobj:GetSomeBaseData())。
  • 泛型方法不直接支持,但可以通过 Extension methods 封装后调用——示例中GenericMethodOfString()内部调用obj.GenericMethod<string>()

3.8 枚举类型

枚举值就像枚举类型下的静态属性一样:

local e = testobj:EnumTestFunc(CS.Tutorial.TestEnum.E1) print(e, e == CS.Tutorial.TestEnum.E2)

枚举类支持__CastFrom方法,从整数或字符串转换到枚举值:

CS.Tutorial.TestEnum.__CastFrom(1) CS.Tutorial.TestEnum.__CastFrom('E1')

3.9 delegate 的调用、组合(+)与移除(-)

  • 调用:和调用普通 Lua 函数一样:testobj.TestDelegate('hello')
  • +操作符:对应 C# 的+,把两个 delegate 串成调用链,右操作数可以是同类型 C# delegate 或 Lua 函数;
  • -操作符:从调用链中移除一个 delegate;
  • delegate 属性可用一个 luafunction 来赋值
local function lua_delegate(str) print('TestDelegate in lua:', str) end testobj.TestDelegate = lua_delegate + testobj.TestDelegate -- combine testobj.TestDelegate = testobj.TestDelegate - lua_delegate -- remove

3.10 event

对 C# 的public event Action TestEvent;,Lua 侧通过字符串操作符'+'/'-'增删回调:

testobj:TestEvent('+', lua_event_callback1) -- 增加事件回调 testobj:CallEvent() -- 触发 testobj:TestEvent('-', lua_event_callback1) -- 移除事件回调

3.11 64 位整数支持

  • Lua53 版本:64 位整数(long/ulong)映射到原生 64 位整数;
  • luajit 版本(等价 Lua 5.1 标准)本身不支持 64 位,xLua 做了扩展库,将 C# 的longulong映射到userdata,支持在 Lua 中进行 64 位运算、比较、打印,也支持与 Lua number 的运算、比较;
  • 注意:64 位扩展库中实际只有int64ulong会先强转成long再传递到 Lua;对ulong的运算、比较采用与 Java 类似的 API 支持方式,详见 XLua_API.md。

示例(public ulong TestLong(long n)):

local l = testobj:TestLong(11) print(type(l), l, l + 100, 10000 + l)

3.12 C# 复杂类型与 table 的自动转换

对有无参构造函数的 C# 复杂类型,Lua 侧可直接用一个 table 代替——table 含该类型的 public 字段即可,支持函数参数传递、属性赋值等场景:

public struct A { public int a; } public struct B { public A b; public double c; } // 某类有成员函数:void Foo(B b)

Lua 侧直接传 table:

obj:Foo({b = {a = 100}, c = 200})

3.13 typeof 与"强转" cast

  • 获取类型(typeof):获取 C# 类型信息,例如typeof(CS.UnityEngine.ParticleSystem)(示例中用于newGameObj:AddComponent(typeof(...)))。
  • cast"强转":Lua 无类型,但可以告诉 xLua 用指定的生成代码去访问一个对象。典型场景:第三方库对外暴露 interface/抽象类而实现类隐藏,无法对实现类做代码生成,实现类会被识别为"未生成代码"而走反射访问,高频调用性能受影响。此时把 interface/抽象类加入生成列表,再用cast指定:
local calc = testobj:GetCalc() -- 实际返回内部类 InnerCalc 实例 print('assess via reflection', calc:add(1, 2)) -- 走反射,id 可见 cast(calc, typeof(CS.Tutorial.ICalc)) -- 改走 ICalc 的生成代码 print('cast to interface ICalc', calc:add(1, 2)) assert(calc.id == nil) -- 生成代码只暴露接口成员

四、xLua 代码生成与特性标记(配置核心)

教程中反复出现"需要生成代码"、"把类型加入 GCOptimize"、"加入生成列表"等要求,这对应 xLua 的静态代码生成机制,相关配置详见 configure.md(英文版 Configure_EN.md)与 custom_generate.md:

  • [LuaCallCSharp]:标记需要从 Lua 调用的 C# 类型(类、枚举、接口、扩展方法类均可),见示例中的DerivedClassTestEnumICalc
  • [CSharpCallLua]:标记需要从 C# 调用的 Lua 侧委托与接口(delegate / interface),见FDelegateItfDGetE
  • GCOptimize:把值类型/复杂类型加入生成可降低访问开销,详见 XLua复杂值类型(struct)gc优化指南.md;
  • 未生成代码的类型会退化为反射访问,功能可用但性能较差——这正是cast存在的意义。

jynew 的桥接层 Jyx2LuaBridge.cs 整体以[LuaCallCSharp]标记,为 Lua 剧情脚本暴露Talk对话、UI 面板、任务、战斗等全部玩法接口,是"Lua 调 C#"在真实 RPG 工程中的典型规模化应用。

五、在 jynew 工程中实践:从教程到业务

把教程中的知识映射到 jynew 实际代码路径,可以形成如下实践路线:

  1. 加载入口LuaEnv由 LuaManager.cs 单例管理,初始化时通过DoString执行根脚本,require机制与自定义 loader 负责拉取全部业务脚本;
  2. 模块组织Assets/LuaScripts/下的脚本通过 InitLuaScripts.lua + LuaModuleList.lua 统一注册为Jyx2表的模块,对应"一个 main 入口加载其它脚本"的最佳实践;
  3. C# 调 Lua:业务层通过 LuaExecutor.cs 的CallLua<T>(funName, ...)系列方法与LuaManagergetCachedFunction缓存机制调用 Lua 侧剧情/战斗逻辑;
  4. Lua 调 C#:剧情脚本(如 Mods/JYX2/Lua 下的.lua文件)通过Jyx2LuaBridge暴露的[LuaCallCSharp]接口驱动对话、UI 与事件系统;
  5. 热重载:Editor 下开启HOTRELOAD_LUA_IN_EDITOR后可直接改磁盘上的.lua文件实现"改脚本不重启游戏"的调试体验。

进一步阅读:热更新机制见 hotfix.md,完整 API 清单见 XLua_API.md,常见问题见 faq.md,功能总览见 features.md,性能分析工具见 XLua性能分析工具.md。

结语

本文以 XLua教程.md 为主线,完整覆盖了 xLua 的三大核心交互:Lua 文件加载(DoString / require / 自定义 Loader)、C# 访问 Lua(基本类型、四种 table 映射、delegate 与 LuaFunction)、Lua 调用 C#(对象、静态与成员、重载、操作符、默认值/可变参数、枚举、delegate/event、64 位整数、复杂类型转换、typeof/cast)。结合 jynew 的 LuaManager、LuaExecutor、Jyx2LuaBridge 三份核心源码可以看到:教程中的每一条最佳实践(单入口 require、缓存全局函数、专门桥接模块、Loader 链)都已沉淀为可运行的工程代码。掌握这些知识后,你既能读懂 jynew 的 Lua 剧情/战斗系统,也能在自己的 Unity 项目里搭建一套干净、高效、可热更的 C#-Lua 双层架构。

【免费下载链接】jynewJinYongLegend-like RPG Game Framework with full Modding support and 10+ hours playable samples of game.项目地址: https://gitcode.com/GitHub_Trending/jy/jynew

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询