UE C++ 调用 Lua 的方法详解(基于 UnLua)
一、前置知识:C++ 为什么能调用 Lua?
回顾一下 UnLua 的核心架构:
┌──────────────┐ ┌──────────────┐
│ C++ 代码 │ │ Lua 脚本 │
│ │ │ │
│ 调用 UFunction ──→ UnLua 中转 ──→ 执行 Lua 函数 │
│ │ │ │
│ ← 返回值 ────── Lua 栈传回 ←──── push 返回值 │
└──────────────┘ └──────────────┘
核心原理:UnLua 在绑定时,将 UFunction 的执行指针替换为自己的中转函数。当 C++ 调用该 UFunction 时,中转函数会将参数通过 Lua 栈 传给 Lua 虚拟机,执行 Lua 函数,再把返回值通过栈传回 C++。
C++ 侧完全不知道 Lua 的存在 ------它只是调用了一个 UFunction,至于这个函数最终由蓝图执行还是 Lua 执行,C++ 不关心。
二、方式一:BlueprintImplementableEvent(最推荐)
2.1 原理
C++ 声明一个"蓝图可实现事件",不提供 C++ 实现。Lua 覆写这个函数后,C++ 调用时自动走到 Lua。
2.2 代码示例
C++ 声明(.h 文件):
cpp
UCLASS()
class AMyCharacter : public ACharacter
{
GENERATED_BODY()
public:
// 声明一个蓝图可实现事件
// C++ 不写实现体,由 Lua(或蓝图)来实现
UFUNCTION(BlueprintImplementableEvent, Category = "Skill")
void OnSkillActivated(int32 SkillId, float CooldownTime);
};
C++ 调用(.cpp 文件):
cpp
void AMyCharacter::UseSkill(int32 SkillId)
{
// 像调用普通函数一样调用,不需要任何 Lua 相关代码
OnSkillActivated(SkillId, 3.0f);
}
Lua 覆写:
lua
-- Characters/MyCharacter.lua
local MyCharacter = UnLua.Class()
function MyCharacter:OnSkillActivated(SkillId, CooldownTime)
print(string.format("技能 %d 激活,冷却 %.1f 秒", SkillId, CooldownTime))
-- 在这里写 Lua 侧的逻辑
self:StartCooldownTimer(CooldownTime)
end
return MyCharacter
2.3 调用链详解
C++ 调用 OnSkillActivated(1001, 3.0)
│
▼
UE 引擎执行 UFunction("OnSkillActivated")
│
▼
UFunction 的执行指针已被 UnLua 替换 → 进入 UnLua 中转函数
│
├── 1. 通过 UObject 映射找到对应的 Lua Table
├── 2. 在 Table 中查找 "OnSkillActivated" 函数
├── 3. 将 C++ 参数转换并 push 到 Lua 栈:
│ push self (userdata)
│ push 1001 (integer)
│ push 3.0 (number)
├── 4. lua_pcall 调用 Lua 函数
└── 5. Lua 执行完毕,从栈上取返回值(如果有)传回 C++
2.4 带返回值的情况
cpp
// C++ 声明
UFUNCTION(BlueprintImplementableEvent)
float CalculateDamageMultiplier(int32 SkillLevel);
// C++ 使用返回值
float Multiplier = CalculateDamageMultiplier(5);
float FinalDamage = BaseDamage * Multiplier;
lua
function MyCharacter:CalculateDamageMultiplier(SkillLevel)
return 1.0 + SkillLevel * 0.2 -- 等级5 → 2.0倍
end
2.5 适用场景
- 游戏逻辑事件:技能激活、角色死亡、任务完成等
- UI 更新通知:血量变化、经验值变化等
- 流程控制:关卡开始、关卡结束、存档等
三、方式二:BlueprintNativeEvent(有默认实现)
3.1 与方式一的区别
BlueprintImplementableEvent |
BlueprintNativeEvent |
|
|---|---|---|
| C++ 默认实现 | 无 | 有 (_Implementation 后缀) |
| Lua 未覆写时 | 什么都不做 | 执行 C++ 默认实现 |
| Lua 覆写后 | 执行 Lua | 执行 Lua(可选调用 C++ 默认实现) |
| 适用场景 | 纯 Lua 实现的逻辑 | 有合理默认行为,Lua 可选择定制 |
3.2 代码示例
C++ 声明 + 默认实现:
cpp
// .h 文件
UFUNCTION(BlueprintNativeEvent, Category = "Combat")
float CalculateDamage(float BaseDamage, int32 ArmorLevel);
// .cpp 文件 ------ 注意函数名加 _Implementation 后缀
float AMyCharacter::CalculateDamage_Implementation(float BaseDamage, int32 ArmorLevel)
{
// C++ 默认实现:简单的护甲减伤
float ArmorReduction = ArmorLevel * 5.0f;
return FMath::Max(BaseDamage - ArmorReduction, 0.0f);
}
C++ 调用:
cpp
void AMyCharacter::ApplyDamage(float BaseDamage)
{
// 直接调用,不带 _Implementation 后缀
float FinalDamage = CalculateDamage(BaseDamage, CurrentArmorLevel);
Health -= FinalDamage;
}
Lua 覆写(可选):
lua
function MyCharacter:CalculateDamage(BaseDamage, ArmorLevel)
-- 自定义伤害计算:百分比减伤
local Reduction = 1.0 - (ArmorLevel * 0.05)
return BaseDamage * math.max(Reduction, 0.1)
end
3.3 在 Lua 中调用 C++ 默认实现
如果 Lua 想在自定义逻辑的基础上,也执行 C++ 的默认实现:
lua
function MyCharacter:CalculateDamage(BaseDamage, ArmorLevel)
-- 先执行 C++ 默认实现
local DefaultDamage = self.Overridden.CalculateDamage(self, BaseDamage, ArmorLevel)
-- 在默认结果上做额外处理
if self:HasBuff("IronSkin") then
return DefaultDamage * 0.5 -- 铁皮 buff 再减半
end
return DefaultDamage
end
self.Overridden.XXX是 UnLua 提供的语法,用于调用被覆写前的原始 C++ 实现。
四、方式三:直接操作 Lua C API(底层方式)
4.1 什么时候需要?
当你需要:
- 调用 Lua 全局函数(不属于任何 UObject)
- 调用指定 Lua 模块中的函数
- 在非 UObject 上下文中与 Lua 交互
4.2 代码示例
C++ 侧:
cpp
#include "lua.hpp"
#include "UnLuaBase.h"
void AMyManager::CallLuaGlobalFunction()
{
// ========== 1. 获取 Lua 虚拟机 ==========
lua_State* L = UnLua::GetState();
if (!L) return;
// ========== 2. 将要调用的函数压栈 ==========
// lua_getglobal 会在 Lua 全局表中查找 "OnGameEvent" 函数
// 找到后将其压入栈顶
lua_getglobal(L, "OnGameEvent");
// 检查栈顶是否是函数
if (!lua_isfunction(L, -1))
{
lua_pop(L, 1); // 不是函数,弹出并返回
return;
}
// ========== 3. 压入参数 ==========
// 参数按顺序压栈,先压的是第一个参数
lua_pushstring(L, "PlayerDied"); // 参数1: 事件名(string)
lua_pushinteger(L, 1001); // 参数2: 玩家ID(integer)
lua_pushnumber(L, 3.14); // 参数3: 某个数值(number)
lua_pushboolean(L, true); // 参数4: 是否重生(boolean)
// ========== 4. 调用函数 ==========
// lua_pcall(L, 参数个数, 返回值个数, 错误处理函数索引)
// 错误处理函数索引传 0 表示使用默认错误处理
int Result = lua_pcall(L, 4, 1, 0);
if (Result != LUA_OK)
{
// 调用失败,栈顶是错误信息
const char* ErrorMsg = lua_tostring(L, -1);
UE_LOG(LogTemp, Error, TEXT("Lua call failed: %s"), UTF8_TO_TCHAR(ErrorMsg));
lua_pop(L, 1); // 弹出错误信息
return;
}
// ========== 5. 获取返回值 ==========
// 调用成功后,返回值在栈顶
if (lua_isinteger(L, -1))
{
int ReturnValue = lua_tointeger(L, -1);
UE_LOG(LogTemp, Log, TEXT("Lua returned: %d"), ReturnValue);
}
lua_pop(L, 1); // 弹出返回值,清理栈
}
Lua 侧:
lua
-- 全局函数
function OnGameEvent(EventName, PlayerId, Value, bRespawn)
print(string.format("事件: %s, 玩家: %d, 值: %.2f, 重生: %s",
EventName, PlayerId, Value, tostring(bRespawn)))
return 42 -- 返回值
end
4.3 Lua 栈操作图解
操作过程中 Lua 栈的变化:
初始状态: getglobal 后: push 参数后: pcall 后:
┌──────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────┐
│ 空 │ │ OnGameEvent │ │ true │ │ 42 │
│ │ │ (function) │ │ 3.14 │ │ (返回值) │
│ │ │ │ │ 1001 │ └──────────┘
│ │ │ │ │ "PlayerDied" │
└──────┘ └──────────────┘ │ OnGameEvent │
栈底 ──→ 栈顶 └──────────────┘
栈底 ──→ 栈顶
lua_pcall 会消耗
函数和所有参数,
把返回值压到栈顶
4.4 调用 Lua 模块中的函数
如果目标函数不是全局函数,而是在某个模块的 table 中:
cpp
void CallModuleFunction(lua_State* L)
{
// 获取模块 table
lua_getglobal(L, "require");
lua_pushstring(L, "GameLogic.EventSystem");
lua_pcall(L, 1, 1, 0); // require("GameLogic.EventSystem") → 栈顶是模块 table
// 从 table 中获取函数
lua_getfield(L, -1, "HandleEvent"); // 栈顶是 HandleEvent 函数
// 压入参数并调用
lua_pushstring(L, "OnDamage");
lua_pcall(L, 1, 0, 0);
lua_pop(L, 1); // 弹出模块 table
}
五、方式四:UnLua 辅助 API
UnLua 在底层 Lua C API 之上封装了一些便捷函数,减少手动操作 Lua 栈的工作:
5.1 调用绑定对象的 Lua 方法
cpp
#include "UnLuaBase.h"
void AMyActor::NotifyLuaSide()
{
lua_State* L = UnLua::GetState();
if (!L) return;
// 检查对象是否已绑定 Lua
if (UnLua::IsUObjectBound(this))
{
// 调用绑定的 Lua 实例上的方法
// 相当于 Lua 中的 self:OnCppNotify(100, "hello")
UnLua::Call(L, this, "OnCppNotify", 100, "hello");
}
}
5.2 调用 Lua table 的函数
这种是全局变量
cpp
// 调用指定模块中的函数
UnLua::CallTableFunc(L, "Utils.MathHelper", "Clamp", Value, MinVal, MaxVal);
六、各方式对比总结
推荐程度(高→低):
BlueprintImplementableEvent ★★★★★ 零耦合,最规范
BlueprintNativeEvent ★★★★★ 有默认实现,灵活
UnLua 辅助 API ★★★☆☆ 特殊场景,中等耦合
Lua C API (lua_pcall) ★★☆☆☆ 底层操作,高耦合
| 维度 | BlueprintImplementableEvent | BlueprintNativeEvent | UnLua API | Lua C API |
|---|---|---|---|---|
| 耦合度 | 零(不知道 Lua 存在) | 零 | 中(依赖 UnLua) | 高(直接操作栈) |
| 类型安全 | 有(UE 反射) | 有(UE 反射) | 弱 | 无 |
| 有返回值 | ✅ | ✅ | ✅ | ✅ |
| 需要 UObject | ✅ | ✅ | ✅ | ❌ |
| 可热更新 | ✅ | ✅ | ✅ | ✅ |
| 错误处理 | UE 自动处理 | UE 自动处理 | UnLua 处理 | 手动处理 |
| 适用场景 | 游戏逻辑接口 | 有默认行为的接口 | 特殊调用需求 | 全局函数/工具调用 |
七、最佳实践
7.1 设计原则
C++ 负责: Lua 负责:
├── 引擎底层、性能敏感逻辑 ├── 游戏玩法逻辑
├── 定义接口(UFUNCTION 声明) ├── 实现接口(覆写 BlueprintEvent)
├── 基础框架和系统 ├── 运营活动、可热更内容
└── 调用接口(不关心谁实现) └── UI 流程、技能配置
7.2 常见模式
模式一:事件通知(无返回值)
cpp
// C++ 在适当时机通知 Lua
UFUNCTION(BlueprintImplementableEvent)
void OnLevelLoaded(const FString& LevelName);
// C++ 内部
void AMyGameMode::HandleLevelLoaded()
{
// ... C++ 逻辑 ...
OnLevelLoaded(CurrentLevelName); // 通知 Lua
}
模式二:策略委托(有返回值)
cpp
// C++ 向 Lua 请求决策
UFUNCTION(BlueprintNativeEvent)
bool ShouldAttackTarget(AActor* Target);
// C++ 默认实现
bool AMyAI::ShouldAttackTarget_Implementation(AActor* Target)
{
return true; // 默认:见谁打谁
}
// C++ 使用
if (ShouldAttackTarget(Enemy))
{
StartAttack(Enemy);
}
lua
-- Lua 可以实现更复杂的判断
function MyAI:ShouldAttackTarget(Target)
if Target:HasBuff("Invisible") then
return false -- 隐身目标不打
end
if self:GetHP() < 100 then
return false -- 血量低不打
end
return true
end
模式三:数据获取
cpp
UFUNCTION(BlueprintImplementableEvent)
TArray<FString> GetAvailableSkills();
// C++ 调用
TArray<FString> Skills = GetAvailableSkills();
for (const FString& Skill : Skills)
{
// 处理每个技能
}
7.3 避免的做法
cpp
// ❌ 不要在 C++ 中直接 #include Lua 头文件来调用 Lua(除非必要)
#include "lua.hpp"
lua_getglobal(L, "SomeFunction");
lua_pcall(L, 0, 0, 0);
// ✅ 应该通过 UE 反射系统间接调用
UFUNCTION(BlueprintImplementableEvent)
void SomeFunction();
cpp
// ❌ 不要在 Tick 中频繁调用 Lua(性能问题)
void Tick(float DeltaTime)
{
OnLuaTick(DeltaTime); // 每帧调 Lua,开销大
}
// ✅ 用事件驱动代替每帧轮询
void OnHealthChanged(float NewHealth)
{
OnHealthUpdate(NewHealth); // 只在变化时调用
}
八、总结
C++ 调用 Lua = C++ 调用 UFunction → UnLua 拦截 → 转发到 Lua 虚拟机
核心选择:
┌─ 需要 Lua 提供完整实现 ──→ BlueprintImplementableEvent
├─ 需要 C++ 有默认行为 ──→ BlueprintNativeEvent
├─ 需要调用特定对象方法 ──→ UnLua::Call()
└─ 需要调用全局 Lua 函数 ──→ lua_pcall()(尽量避免)
记住一个原则:让 C++ 不知道 Lua 的存在。通过 UE 反射系统做桥梁,C++ 定义接口、调用接口,Lua 负责实现接口。这样既解耦又支持热更新。