vault backup: 2026-05-31 15:35:41

This commit is contained in:
2026-05-31 15:35:41 +08:00
parent a507664f3e
commit 273d136c75
5 changed files with 1996 additions and 0 deletions

View File

@@ -0,0 +1,693 @@
---
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` 条件编译