Files
BlueRoseNote/07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md

694 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags:
- UE
- Puerts
- EasyEditorPlugin
- 改造方案
- TypeScript
- 架构
created: 2026-05-31
---
# 改造方案:将业务 TS 代码嵌入插件
## 需求背景
目前 EasyEditorPlugin 将业务 TypeScript 代码放在**项目的 `Content/EasyEditorScripts/` 目录**中,插件本身只是一个薄桥接层。我们希望能够:
1. **业务 TS 代码跟随插件分发**(写在插件的 Content/Resources 目录中)
2. **项目 C++ 代码直接调用插件中的 TS 函数**(类型安全,有返回值)
3. **项目代码只需最少量的调用**LoadModule + 少量 API 调用)
## 可行性评估
| 需求 | 可行性 | 改造量 |
|------|--------|--------|
| TS 代码嵌入插件 Content | ✅ 可行,自定义 IJSModuleLoader | 小(~50 行 C++ |
| C++ 调用插件 TS 函数 | ✅ 可行,暴露 FJsObject 获取 API | 中(~100 行 C++ |
| TS 代码热重载 | ✅ 已有基础设施,仅需调整路径 | 无需改动 |
| 运行时(非 Editor支持 | ✅ 可行,但需创建新 Runtime 模块 | 大(架构变更) |
## 方案一:改造 EasyEditorPlugin编辑器工具场景
适用场景:**编辑器工具扩展**,业务代码是编辑器工具逻辑(如资产处理、关卡自动化等),不需要在打包后的游戏中运行。
### 改造点 1自定义 IJSModuleLoader从插件 Content 加载
#### 当前代码
```cpp
// EasyEditorPlugin.cpp InitJsEnv()
JsEnv = MakeShared<puerts::FJsEnv>(
std::make_shared<puerts::DefaultJSModuleLoader>(TEXT("EasyEditorScripts")),
...);
```
**问题**`DefaultJSModuleLoader` 只在项目 Content 目录搜索,不会搜索插件目录。
#### 改造后
新建 `PluginJSModuleLoader.h`
```cpp
#pragma once
#include "JSModuleLoader.h"
#include "Misc/Paths.h"
class FPluginJSModuleLoader : public PUERTS_NAMESPACE::IJSModuleLoader
{
public:
explicit FPluginJSModuleLoader(const FString& InPluginContentSubDir)
: ScriptRoot(InPluginContentSubDir)
{
// 获取插件 Content 目录
// EasyEditorPlugin 中: FPaths::ProjectPluginsDir() / "EasyEditorPlugin/Content"
PluginContentDir = DeterminePluginContentDir();
}
virtual bool Search(const FString& RequiredDir, const FString& RequiredModule,
FString& Path, FString& AbsolutePath) override
{
// 搜索顺序:
// 1. 先在插件的 Content/ScriptRoot/ 下搜索
// 2. 再走默认的 DefaultJSModuleLoader 逻辑(项目 Content、node_modules 等)
// 插件目录搜索
FString PluginDir = PluginContentDir / ScriptRoot;
if (SearchModuleInDir(PluginDir, RequiredModule, Path, AbsolutePath))
return true;
// 回退到默认搜索逻辑
return DefaultLoader.Search(RequiredDir, RequiredModule, Path, AbsolutePath);
}
virtual bool Load(const FString& Path, TArray<uint8>& Content) override
{
return DefaultLoader.Load(Path, Content);
}
virtual FString& GetScriptRoot() override
{
return ScriptRoot;
}
static FString DeterminePluginContentDir()
{
// 获取当前插件自身的 Content 目录
// 方案 A通过 IPluginManager 查询
TSharedPtr<IPlugin> Plugin = IPluginManager::Get()
.FindPlugin(TEXT("EasyEditorPlugin"));
if (Plugin.IsValid())
{
return Plugin->GetContentDir();
}
// 方案 B手动拼路径兼容旧版本
return FPaths::ProjectPluginsDir() / TEXT("EasyEditorPlugin/Content");
}
private:
FString ScriptRoot;
FString PluginContentDir;
PUERTS_NAMESPACE::DefaultJSModuleLoader DefaultLoader;
bool SearchModuleInDir(const FString& Dir, const FString& RequiredModule,
FString& Path, FString& AbsolutePath)
{
// 尝试扩展名: .js, .mjs, .cjs, .mbc, .cbc, /index.js, package.json
static const TArray<FString> Extensions = {
TEXT(".js"), TEXT(".mjs"), TEXT(".cjs"),
TEXT(".mbc"), TEXT(".cbc")
};
for (const auto& Ext : Extensions)
{
FString TestPath = Dir / RequiredModule + Ext;
if (FPaths::FileExists(TestPath))
{
Path = RequiredModule + Ext;
AbsolutePath = FPaths::ConvertRelativePathToFull(TestPath);
return true;
}
}
// 尝试目录 + index.js
FString IndexPath = Dir / RequiredModule / TEXT("index.js");
if (FPaths::FileExists(IndexPath))
{
Path = RequiredModule / TEXT("index.js");
AbsolutePath = FPaths::ConvertRelativePathToFull(IndexPath);
return true;
}
return false;
}
};
```
#### 修改 InitJsEnv()
```cpp
void FEasyEditorPluginModule::InitJsEnv()
{
SourceFileWatcher = MakeShared<puerts::FSourceFileWatcher>(
[this](const FString& InPath) { /* 同上 */ });
// 使用自定义 Loader从插件 Content/BusinessScripts/ 加载
JsEnv = MakeShared<puerts::FJsEnv>(
std::make_shared<FPluginJSModuleLoader>(TEXT("BusinessScripts")),
std::make_shared<puerts::FDefaultLogger>(), -1,
[this](const FString& InPath) { /* 同上 */ });
}
```
#### 插件目录结构变为
```
EasyEditorPlugin/
├── Content/
│ └── BusinessScripts/ ← TS 业务代码(随插件分发)
│ ├── Main.ts ← 入口
│ ├── commands/
│ │ ├── AssetCommands.ts
│ │ └── LevelCommands.ts
│ ├── ui/
│ │ ├── MenuSetup.ts
│ │ └── DetailPanel.ts
│ └── utils/
│ └── helpers.ts
├── Source/
│ └── ...
└── EasyEditorPlugin.uplugin
```
---
### 改造点 2暴露 JsEnv支持 C++ 类型安全调用 TS
#### 问题
```cpp
class FEasyEditorPluginModule {
private:
TSharedPtr<puerts::FJsEnv> JsEnv; // ← 外部无法访问
};
```
外部 C++ 只能通过 `Eval` 执行 JS 字符串,无类型安全、无返回值。
#### 改造:公开 JsEnv 访问和 FJsObject 获取
`EasyEditorPlugin.h` 中新增:
```cpp
class FEasyEditorPluginModule : public IModuleInterface
{
public:
// ===== 新增 API =====
/**
* 从 JS 全局作用域获取一个函数/对象,返回 FJsObject 用于类型安全调用
* 示例:
* auto& Mod = FModuleManager::LoadModuleChecked<FEasyEditorPluginModule>("EasyEditorPlugin");
* FJsObject MyFunc = Mod.GetJsExport("myBusinessFunction");
* int32 Result = MyFunc.Func<int32, int32, FString>(42, TEXT("hello"));
*/
FJsObject GetJsExport(const FString& Name) const;
/**
* 从 JS 模块(已 require 的)中获取一个导出
* 示例:
* FJsObject Handler = Mod.GetJsModuleExport("commands/AssetCommands", "handleAsset");
* Handler.Action(TEXT("/Game/MyAsset"));
*/
FJsObject GetJsModuleExport(const FString& ModuleName, const FString& ExportName) const;
/**
* 获取底层的 IJsEnv 接口(高级用法)
*/
TSharedPtr<puerts::FJsEnv> GetJsEnv() const { return JsEnv; }
// ===== 原有 API =====
std::function<void()> OnJsEnvPreReload;
std::function<void(const FString&)> Eval;
FSimpleMulticastDelegate OnJsEnvCleanup;
TSparseArray<TUniquePtr<FAutoConsoleCommand>> TsConsoleCommands;
private:
// ...
};
```
#### 实现(`EasyEditorPlugin.cpp`
```cpp
FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const
{
if (!JsEnv.IsValid())
{
UE_LOG(Puerts, Error, TEXT("JsEnv is not valid when getting export '%s'"), *Name);
return {};
}
// 通过 Eval 机制获取全局变量
// 实际实现需要利用 Puerts 的内部 API
// 方案见下方"实现细节"
}
FJsObject FEasyEditorPluginModule::GetJsModuleExport(
const FString& ModuleName, const FString& ExportName) const
{
if (!JsEnv.IsValid())
{
return {};
}
// 需要从模块缓存中查找
// 方案见下方"实现细节"
}
```
#### 实现细节:如何从 JsEnv 中获取 JS 对象
**核心问题**`FJsEnv``FJsEnvImpl` 成员是私有的,没有公开的 "通过名称获取 JS 变量" API。需要通过以下方式实现
**方案 A通过 `puerts::FJsEnv` 包装器暴露新方法**
需要在 Puerts 的 `FJsEnv` 中新增一个方法:
```cpp
// 在 JsEnv.h 中新增
class JSENV_API FJsEnv
{
public:
// ... 现有 API ...
/**
* 获取全局作用域中的一个值
* @param Name 全局变量/函数名
* @return 包装了该 JS 值的 FJsObject如果不存在或类型不对则返回空
*/
FJsObject GetGlobalValue(const FString& Name);
};
```
实现(在 `JsEnvImpl.cpp` 中):
```cpp
FJsObject FJsEnv::GetGlobalValue(const FString& Name)
{
if (!GameScript) return {};
auto Impl = static_cast<FJsEnvImpl*>(GameScript.get());
auto Isolate = Impl->MainIsolate;
auto Context = Impl->DefaultContext.Get(Isolate);
#ifdef THREAD_SAFE
v8::Locker Locker(Isolate);
#endif
v8::Isolate::Scope IsolateScope(Isolate);
v8::HandleScope HandleScope(Isolate);
v8::Context::Scope ContextScope(Context);
auto Global = Context->Global();
auto V8Name = FV8Utils::ToV8String(Isolate, Name);
auto MaybeValue = Global->Get(Context, V8Name);
v8::Local<v8::Value> Val;
if (MaybeValue.ToLocal(&Val) && Val->IsObject())
{
return FJsObject(Context, Val.As<v8::Object>());
}
return {};
}
```
但这种方式需要 **修改 Puerts 源码**(在 `JsEnv.h``JsEnvImpl.cpp` 中增加代码),对于已经安装的 Puerts 插件不太方便。
**方案 B在 EasyEditorPlugin 中注入 JS 辅助函数(推荐,无需修改 Puerts**
思路:在 `InitJsEnv()` 时,向 JS 全局注入一个 `__eep_getExport` 函数,同时在 C++ 中存储引用。
```cpp
// EasyEditorPlugin.cpp
// 存储注入的 JS 辅助函数
static FJsObject G_GetExportFunc;
void FEasyEditorPluginModule::InitJsEnv()
{
// ... 创建 JsEnv ...
JsEnv->Start(TEXT("Main"));
// Main.ts 执行后,获取注入的辅助函数引用
// Main.ts 中需要暴露:
// global.__eep_getExport = (name) => { ... return eval(name); }
// global.__eep_getModuleExport = (moduleName, exportName) => { ... }
}
```
但这形成了一个鸡生蛋蛋生鸡的问题 —— 在 Main.ts 执行前无法获取引用。
**方案 C使用 Puerts 的 JIT stub 机制(最务实)**
在 EasyEditorPlugin 的绑定代码中注册可被 `require()` 调用的 C++ 函数:
```cpp
// 在 EasyEditorPlugin.cpp 的 AutoRegisterForEEP 中新增绑定:
struct FEasyEditorBridge
{
static puerts::Function GetGlobalFunction;
static FJsObject GetGlobalObject;
// 注册到 Puerts binding
};
```
然后在 Main.ts 中:
```typescript
// 将需要暴露给 C++ 的函数注册到 global 对象
import { MyBusinessLogic } from './business/BusinessLogic';
(global as any).__exports = {
processAsset: MyBusinessLogic.processAsset,
validateLevel: MyBusinessLogic.validateLevel,
// ...
};
```
C++ 侧通过 Eval 获取:
```cpp
FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const
{
// 利用 Eval 机制代理
// 实际方案:在 V8 中执行 "__exports[Name]",捕获结果
// 但当前 Eval 是 void 返回的...
// 需要改造 Eval 为有返回值的版本,见下文
}
```
**方案 D改造 Eval 为有返回值的版本(最小侵入)**
```cpp
// 在 EasyEditorPlugin.h 中
class FEasyEditorPluginModule
{
public:
// 替代原来的 void Eval使用可返回值的版本
std::function<FJsObject(const FString&)> EvalWithReturn;
};
```
`InitJsEnv()` 中注册对应的 C++ 绑定:
```cpp
// 利用 Puerts 的 FV8Utils 实现有返回值的 Eval
// 在 C++ 侧: 持有 v8::Isolate 和 v8::Context直接 eval 并封装结果
```
**方案 E最简单且实用的方案 —— 通过 Console 命令 + 注册回调**
不暴露 JsEnv而是让项目 C++ 注册一个回调TS 通过 `EasyEditorPlugin.AddConsoleCommand` 或其他绑定来调用它:
```cpp
// 项目 C++ 代码中使用 EasyEditorPlugin
void FMyProjectModule::StartupModule()
{
auto& EEP = FModuleManager::LoadModuleChecked<FEasyEditorPluginModule>("EasyEditorPlugin");
// 在 JS 环境就绪后注册
EEP.OnJsEnvCreated.AddLambda([this]() {
// 告诉 TS 侧C++ 准备好了
EEP.Eval(TEXT("if (global.__onCppReady) global.__onCppReady()"));
});
}
```
但这种模式是 **TS 调用 C++**,而非用户需求的 **C++ 调用 TS**
---
### 推荐的 C++ → TS 调用方案总结
| 方案 | 侵入性 | 类型安全 | 需要修改 Puerts | 推荐度 |
|------|--------|---------|----------------|--------|
| A: 扩展 FJsEnv::GetGlobalValue | 低 | ✅ | ❌ 需要 | ⭐⭐⭐ |
| B: 注入 JS 辅助函数 | 中 | ✅ | 不需要 | ⭐⭐ |
| C: require() 注册 | 中 | ✅ | 不需要 | ⭐⭐ |
| D: Eval 改造成有返回值 | 低 | ❌ 需手动转换 | 不需要 | ⭐⭐ |
| E: 回调注册 | 低 | ❌ | 不需要 | ⭐(反向模式) |
**最佳实践:方案 CPUERTS_MODULE addon+ 方案 A扩展 FJsEnv**
在实际项目中,建议**对 Puerts 做少量扩展**fork 或 patch因为 Puerts 本身设计就预留了扩展点。
---
## 方案二:基于 Puerts 创建自己的 Runtime 插件(运行时代码场景)
如果业务代码需要在**打包后的游戏**中运行Gameplay 逻辑、AI、战斗系统等EasyEditorPlugin 的 Editor-only 限制是致命的。
此时正确的做法是:**直接基于 Puerts 创建自己的 Runtime 插件**。
### 插件骨架
```
MyBusinessPlugin/
├── MyBusinessPlugin.uplugin
├── Content/
│ └── BusinessScripts/ ← TS 业务代码
│ ├── Main.ts ← 入口
│ ├── combat/
│ ├── ai/
│ └── ui/
└── Source/
└── MyBusinessPlugin/
├── MyBusinessPlugin.Build.cs
├── Public/
│ └── MyBusinessPluginModule.h
│ └── BusinessAPI.h ← 公开给项目 C++ 的 API
└── Private/
├── MyBusinessPluginModule.cpp
└── MyBinding.cpp ← PUERTS_MODULE 注册
```
### .uplugin
```json
{
"FriendlyName": "MyBusinessPlugin",
"Modules": [{
"Name": "MyBusinessPlugin",
"Type": "Runtime", // ← Runtime可在打包游戏中运行
"LoadingPhase": "Default"
}],
"Plugins": [{
"Name": "Puerts",
"Enabled": true
}]
}
```
### Build.cs
```csharp
PublicDependencyModuleNames.AddRange(new string[] {
"Core",
"CoreUObject",
"Engine",
"JsEnv" // ← 依赖 Puerts 的 JsEnv
});
```
### `MyBusinessPluginModule.h`
```cpp
#pragma once
#include "CoreMinimal.h"
#include "Modules/ModuleManager.h"
#include "JsEnv.h"
#include "JsObject.h"
class FMyBusinessPluginModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
// 公开给项目 C++ 的 API
FJsObject GetExport(const FString& Name) const;
TSharedPtr<puerts::FJsEnv> GetJsEnv() const { return JsEnv; }
private:
TSharedPtr<puerts::FJsEnv> JsEnv;
void InitJsEnv();
void UnInitJsEnv();
};
```
### `MyBusinessPluginModule.cpp`
```cpp
#include "MyBusinessPluginModule.h"
#include "JSModuleLoader.h"
#include "Misc/Paths.h"
#include "Interfaces/IPluginManager.h"
void FMyBusinessPluginModule::StartupModule()
{
InitJsEnv();
}
void FMyBusinessPluginModule::ShutdownModule()
{
UnInitJsEnv();
}
void FMyBusinessPluginModule::InitJsEnv()
{
// 获取插件 Content 目录
TSharedPtr<IPlugin> Plugin = IPluginManager::Get()
.FindPlugin(TEXT("MyBusinessPlugin"));
FString PluginContent = Plugin.IsValid()
? Plugin->GetContentDir()
: FPaths::ProjectPluginsDir() / TEXT("MyBusinessPlugin/Content");
// 创建自定义 ModuleLoader从插件 Content/BusinessScripts/ 加载
auto Loader = MakeShared<FPluginJSModuleLoader>(
PluginContent / TEXT("BusinessScripts"));
JsEnv = MakeShared<puerts::FJsEnv>(
Loader,
MakeShared<puerts::FDefaultLogger>(),
-1, // debugPort, -1 = 禁用调试
nullptr,
FString(),
nullptr, nullptr);
// 启动业务代码入口
JsEnv->Start(TEXT("Main"));
}
void FMyBusinessPluginModule::UnInitJsEnv()
{
JsEnv.Reset();
}
FJsObject FMyBusinessPluginModule::GetExport(const FString& Name) const
{
// 实现同方案一的讨论
// 推荐方式: 对 Puerts 做 patch给 FJsEnv 加 GetGlobalValue
}
```
### `BusinessAPI.h` — 给项目 C++ 的调用接口
```cpp
#pragma once
#include "CoreMinimal.h"
#include "JsObject.h"
namespace BusinessAPI
{
// 获取插件的 JsEnv
TSharedPtr<PUERTS_NAMESPACE::FJsEnv> GetBusinessJsEnv();
// 获取 JS 导出函数
PUERTS_NAMESPACE::FJsObject GetExport(const FString& Name);
// 便捷调用封装
inline int32 ValidateLevel(const FString& LevelPath)
{
auto Func = GetExport(TEXT("validateLevel"));
if ( /* Func is valid */ )
{
return Func.Func<int32, FString>(LevelPath);
}
return -1;
}
}
```
### 项目 C++ 调用方式
```cpp
// 项目 C++ 代码 —— 极简调用
void AMyGameMode::BeginPlay()
{
Super::BeginPlay();
// 加载插件(自动启动 TS 业务逻辑)
FModuleManager::Get().LoadModule("MyBusinessPlugin");
// 调用 TS 函数
auto Func = BusinessAPI::GetExport(TEXT("onGameStart"));
Func.Action(this); // 传递 UObject自动转换为 TS 侧 UE 类型
}
```
---
## 热重载支持
无论方案一还是方案二,热重载都是可用的。只需确保 `FSourceFileWatcher` 监听的是正确的目录:
```cpp
// 插件 Content 目录下的 BusinessScripts/
FString WatchDir = PluginContentDir / TEXT("BusinessScripts");
SourceFileWatcher = MakeShared<puerts::FSourceFileWatcher>(
[this](const FString& InPath) {
if (JsEnv.IsValid()) {
TArray<uint8> Source;
if (FFileHelper::LoadFileToArray(Source, *InPath)) {
JsEnv->ReloadSource(InPath,
puerts::PString((const char*)Source.GetData(), Source.Num()));
}
}
});
```
---
## 改造清单
### 最小改造(编辑器工具,方案一)
| 步骤 | 文件 | 改动 |
|------|------|------|
| 1 | 新建 `PluginJSModuleLoader.h` | 自定义模块加载器,从插件 Content 搜索 |
| 2 | `EasyEditorPlugin.cpp:InitJsEnv()` | 切换为 `FPluginJSModuleLoader` |
| 3 | `EasyEditorPlugin.h` | 添加 `GetJsEnv()``GetJsExport()` |
| 4 | 插件 `Content/BusinessScripts/` | 移入 TS 业务代码 |
| 5 | (可选) Puerts `JsEnv.h/cpp` patch | 添加 `GetGlobalValue()` 方法 |
### 完整改造Runtime 支持,方案二)
| 步骤 | 文件 | 改动 |
|------|------|------|
| 1 | 创建新插件 `MyBusinessPlugin` | `.uplugin`, `Build.cs`, 目录结构 |
| 2 | `PluginJSModuleLoader.h`(复用) | 自定义模块加载器 |
| 3 | `MyBusinessPluginModule.h/cpp` | 模块实现,参考 EasyEditorPlugin |
| 4 | `BusinessAPI.h` | 公开 C++ 调用 API |
| 5 | 插件 `Content/BusinessScripts/` | 移入 TS 业务代码 |
| 6 | Puerts `JsEnv.h/cpp` patch | 添加 `GetGlobalValue()` 方法 |
| 7 | 项目 `.uproject` | 添加插件依赖 |
---
## 注意事项
1. **线程安全**FJsObject 在 `THREAD_SAFE` 模式下自动加锁,但建议所有 TS 调用都在 GameThread 进行
2. **JsEnv 生命周期**:确保在 `ShutdownModule()` 中正确销毁 JsEnv避免 V8 isolate 泄漏
3. **GC 联动**:参考 EasyEditorPlugin 的地图卸载时 GC 处理,避免 "GC Memory Leak" 断言
4. **模块解析**:自定义 `IJSModuleLoader` 需要正确实现 `Search``Load` 两个方法
5. **Puerts 版本**:不同 Puerts 版本的 V8 API 可能不同V8 8.x vs 11.x注意 `V8_MAJOR_VERSION` 条件编译