vault backup: 2026-05-31 15:35:41
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
---
|
||||
tags:
|
||||
- UE
|
||||
- EasyEditorPlugin
|
||||
- Puerts
|
||||
- Editor
|
||||
- TypeScript
|
||||
created: 2026-05-31
|
||||
---
|
||||
|
||||
# EasyEditorPlugin 架构分析
|
||||
|
||||
## 概述
|
||||
|
||||
EasyEditorPlugin 是 `johnche`(腾讯,也是 Puerts 的作者)开发的 Unreal Engine **编辑器快速扩展插件**。它将 V8 JavaScript 运行时(通过 Puerts 的 `FJsEnv`)嵌入 Unreal Editor,让开发者可以用 TypeScript/JavaScript 编写编辑器扩展,而非 C++。
|
||||
|
||||
- **仓库**: `D:\MatrixTA\EasyEditorPlugin`
|
||||
- **许可证**: MIT
|
||||
- **引擎兼容**: UE 5.3+
|
||||
- **核心价值**: 几行 TypeScript 完成编辑器菜单、工具栏、控制台命令、Detail 面板定制,支持热重载
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
EasyEditorPlugin/
|
||||
├── .gitignore
|
||||
├── EasyEditorPlugin.uplugin # UE 插件描述符
|
||||
├── LICENSE # MIT
|
||||
├── README.md # 使用文档(英文)
|
||||
├── Resources/
|
||||
│ └── Icon128.png # 插件图标
|
||||
├── Doc/
|
||||
│ ├── extension.md # 第三方 C++ 库扩展指南
|
||||
│ └── Pic/
|
||||
│ └── easyeditor.gif # 演示动画
|
||||
└── Source/
|
||||
└── EasyEditorPlugin/
|
||||
├── EasyEditorPlugin.Build.cs # UE 构建规则
|
||||
├── Public/
|
||||
│ └── EasyEditorPlugin.h # 模块头文件(公开 API)
|
||||
└── Private/
|
||||
└── EasyEditorPlugin.cpp # 完整实现(~320 行)
|
||||
```
|
||||
|
||||
**单模块 Editor 插件**,无 Runtime/Gameplay 模块。
|
||||
|
||||
## .uplugin 定义
|
||||
|
||||
```json
|
||||
{
|
||||
"FileVersion": 3,
|
||||
"Version": 1,
|
||||
"VersionName": "1.0",
|
||||
"FriendlyName": "EasyEditorPlugin",
|
||||
"CanContainContent": true,
|
||||
"Modules": [{
|
||||
"Name": "EasyEditorPlugin",
|
||||
"Type": "Editor", // ← 仅在 Unreal Editor 中加载
|
||||
"LoadingPhase": "Default"
|
||||
}],
|
||||
"Plugins": [{
|
||||
"Name": "Puerts",
|
||||
"Enabled": true // ← 硬依赖 Puerts
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
## Build.cs 模块依赖
|
||||
|
||||
| 依赖 | 类型 | 用途 |
|
||||
|------|------|------|
|
||||
| `Core` | Public | 基础 UE 类型(`FString`, `TSharedPtr` 等) |
|
||||
| `CoreUObject` | Private | UObject 系统 |
|
||||
| `Engine` | Private | Engine 层类型 |
|
||||
| `Slate` | Private | Slate UI 框架 |
|
||||
| `SlateCore` | Private | 核心 Slate 类型(`FSlateIcon`) |
|
||||
| `ToolMenus` | Private | UE5 可扩展菜单系统 |
|
||||
| `UnrealEd` | Private | 编辑器 API(地图变更通知等) |
|
||||
| `ContentBrowserData` | Private | Content Browser 上下文菜单 |
|
||||
| **`JsEnv`** | Private | **Puerts JavaScript 环境** |
|
||||
|
||||
## 公开 API(`EasyEditorPlugin.h`)
|
||||
|
||||
```cpp
|
||||
class FEasyEditorPluginModule : public IModuleInterface
|
||||
{
|
||||
public:
|
||||
virtual void StartupModule() override;
|
||||
virtual void ShutdownModule() override;
|
||||
|
||||
std::function<void()> OnJsEnvPreReload; // JS 环境重启前回调
|
||||
std::function<void(const FString&)> Eval; // 执行 JS 字符串
|
||||
TSparseArray<TUniquePtr<FAutoConsoleCommand>> TsConsoleCommands; // TS 注册的控制台命令
|
||||
FSimpleMulticastDelegate OnJsEnvCleanup; // JS 环境清理广播
|
||||
|
||||
private:
|
||||
TSharedPtr<puerts::FJsEnv> JsEnv; // ← 私有!外部无法访问
|
||||
TSharedPtr<puerts::FSourceFileWatcher> SourceFileWatcher;
|
||||
TUniquePtr<FAutoConsoleCommand> ConsoleCommand;
|
||||
bool StartupScriptCalled = false;
|
||||
|
||||
void OnPostEngineInit();
|
||||
void InitJsEnv();
|
||||
void UnInitJsEnv();
|
||||
bool Tick(float);
|
||||
void HandleMapChanged(UWorld* InWorld, EMapChangeType InMapChangeType);
|
||||
};
|
||||
```
|
||||
|
||||
**关键点:`JsEnv` 是私有成员,外部 C++ 代码无法直接获取 JS 函数/对象。**
|
||||
|
||||
## Puerts 绑定(`AutoRegisterForEEP`)
|
||||
|
||||
静态初始化器在模块加载前(`StartupModule` 之前)自动注册以下 C++ 类型到 V8:
|
||||
|
||||
| C++ 类型 | 暴露的功能 |
|
||||
|-----------|----------|
|
||||
| `FSlateIcon` | 三个构造函数重载 |
|
||||
| `FToolMenuEntry` | `InitMenuEntry`, `InitToolBarButton`, `InitComboButton` |
|
||||
| `EasyEditorPlugin` | `SetOnJsEnvPreReload`, `SetEval`, `AddConsoleCommand`, `RemoveConsoleCommand` |
|
||||
| `FContentBrowserItem` | `IsFolder`, `IsFile`, `GetItemName`, `GetItemPhysicalPath` |
|
||||
|
||||
## 生命周期
|
||||
|
||||
```
|
||||
Module Load → AutoRegisterForEEP 构造(注册 Puerts 绑定)
|
||||
│
|
||||
▼
|
||||
StartupModule()
|
||||
├── 设置 V8 --expose-gc 标志(允许显式 GC)
|
||||
├── 注册 OnPostEngineInit 回调
|
||||
└── 注册 OnMapChanged 处理器(地图卸载时 GC)
|
||||
│
|
||||
▼
|
||||
OnPostEngineInit()
|
||||
├── InitJsEnv()
|
||||
│ ├── 创建 FSourceFileWatcher(热重载)
|
||||
│ └── 创建 FJsEnv,ModuleLoader 指向 "EasyEditorScripts"
|
||||
├── 注册每帧 Tick
|
||||
├── 注册 "EasyEditor.Restart" 控制台命令
|
||||
└── 注册 "TypeScript" UToolMenus 字符串命令处理器
|
||||
│
|
||||
▼
|
||||
First Tick()
|
||||
└── JsEnv->Start("Main") // 执行 Main.ts
|
||||
│
|
||||
▼
|
||||
[热重载循环]
|
||||
SourceFileWatcher 检测文件变更
|
||||
→ 读取文件
|
||||
→ JsEnv->ReloadSource(InPath, Content)
|
||||
│
|
||||
▼
|
||||
ShutdownModule()
|
||||
└── UnInitJsEnv()
|
||||
├── Broadcast OnJsEnvCleanup
|
||||
├── 清除 Eval / OnJsEnvPreReload
|
||||
├── 清空 TsConsoleCommands
|
||||
└── 重置 JsEnv 和 SourceFileWatcher
|
||||
```
|
||||
|
||||
## `InitJsEnv()` 详细分析
|
||||
|
||||
```cpp
|
||||
void FEasyEditorPluginModule::InitJsEnv()
|
||||
{
|
||||
// 1. 创建文件监听器(用于热重载)
|
||||
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()));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 创建 JsEnv
|
||||
// 关键: DefaultJSModuleLoader("EasyEditorScripts")
|
||||
// → 从项目 Content/EasyEditorScripts/ 加载 TS 文件
|
||||
JsEnv = MakeShared<puerts::FJsEnv>(
|
||||
std::make_shared<puerts::DefaultJSModuleLoader>(TEXT("EasyEditorScripts")),
|
||||
std::make_shared<puerts::FDefaultLogger>(), -1,
|
||||
[this](const FString& InPath) {
|
||||
if (SourceFileWatcher.IsValid()) {
|
||||
SourceFileWatcher->OnSourceLoaded(InPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**当前限制**:
|
||||
- `DefaultJSModuleLoader("EasyEditorScripts")` 只在项目 `Content/` 目录下搜索
|
||||
- 不搜索插件 Content 目录
|
||||
- 不能直接从插件的 `Resources/` 或 `Content/` 加载 TS
|
||||
|
||||
## 编辑器集成能力
|
||||
|
||||
### 菜单扩展(`UToolMenus`)
|
||||
- `FToolMenuEntry.InitMenuEntry` — 添加普通菜单项
|
||||
- `FToolMenuEntry.InitToolBarButton` — 添加工具栏按钮
|
||||
- `FToolMenuEntry.InitComboButton` — 添加下拉工具栏按钮
|
||||
|
||||
### Content Browser 上下文菜单
|
||||
- `FContentBrowserItem.IsFolder()`, `IsFile()`, `GetItemName()`, `GetItemPhysicalPath()`
|
||||
|
||||
### 控制台命令
|
||||
- `EasyEditorPlugin.AddConsoleCommand(name, help, callback)` — 注册控制台命令
|
||||
- `EasyEditorPlugin.RemoveConsoleCommand(index)` — 注销
|
||||
|
||||
### String Command Handler
|
||||
`UToolMenus::RegisterStringCommandHandler("TypeScript", ...)` 让菜单项可以触发任意 TypeScript 代码。
|
||||
|
||||
### 重启命令
|
||||
内置 `EasyEditor.Restart` 控制台命令:销毁 JS 环境 → 重新创建 → 重新加载 Main 脚本。
|
||||
|
||||
## 扩展点(给其他插件使用)
|
||||
|
||||
| 扩展点 | 类型 | 用途 |
|
||||
|--------|------|------|
|
||||
| `OnJsEnvCleanup` | `FSimpleMulticastDelegate` | JS 环境销毁前,其他插件清理状态 |
|
||||
| `OnJsEnvPreReload` | `std::function<void()>` | 重启前执行 |
|
||||
| `Eval` | `std::function<void(const FString&)>` | 执行任意 JS 字符串 |
|
||||
|
||||
其他插件的集成方式(以 EasyEditor_ImGui 为例):
|
||||
|
||||
```cpp
|
||||
// 注册清理回调
|
||||
FModuleManager::LoadModuleChecked<FEasyEditorPluginModule>("EasyEditorPlugin")
|
||||
.OnJsEnvCleanup.AddLambda([]() {
|
||||
// 清理状态
|
||||
});
|
||||
```
|
||||
|
||||
## 脚本组织
|
||||
|
||||
- **脚本目录**: `Content/EasyEditorScripts/`(项目 Content 下)
|
||||
- **入口文件**: `Main.js` 或 `Main.ts`
|
||||
- **语言**: TypeScript(主要)/ JavaScript
|
||||
- **热重载**: ✅ 支持
|
||||
|
||||
## 当前缺少的能力
|
||||
|
||||
1. **JsEnv 不公开** — 外部 C++ 无法获取 JS 函数引用(`FJsObject`)
|
||||
2. **ModuleLoader 不灵活** — 只支持项目 Content 目录,不支持插件 Content 目录
|
||||
3. **Editor-Only** — 无法用于游戏运行时业务逻辑
|
||||
4. **无返回值调用** — `Eval` 只能执行字符串,无法获取返回值
|
||||
5. **无结构化的 C++ → TS 调用 API** — 只能通过字符串 Eval 或 UE 反射间接调用
|
||||
|
||||
## 总结
|
||||
|
||||
| 方面 | 详情 |
|
||||
|------|------|
|
||||
| 设计目标 | 编辑器快速扩展 |
|
||||
| 插件类型 | Editor-only |
|
||||
| 运行时引擎 | V8(通过 `puerts::FJsEnv`) |
|
||||
| 脚本语言 | TypeScript / JavaScript |
|
||||
| 脚本入口 | `Main.ts`,从 `EasyEditorScripts/` 加载 |
|
||||
| C++ 代码量 | ~320 行(极简) |
|
||||
| 热重载 | ✅ |
|
||||
| 可扩展性 | 其他插件可通过 `OnJsEnvCleanup` 回调集成 |
|
||||
| 核心限制 | JsEnv 私有、脚本路径固定、Editor-Only |
|
||||
363
07-Other/AI/AI Agent/UnrealEngine/Puerts/Puerts 架构深度分析.md
Normal file
363
07-Other/AI/AI Agent/UnrealEngine/Puerts/Puerts 架构深度分析.md
Normal file
@@ -0,0 +1,363 @@
|
||||
---
|
||||
tags:
|
||||
- UE
|
||||
- Puerts
|
||||
- TypeScript
|
||||
- V8
|
||||
- 架构分析
|
||||
created: 2026-05-31
|
||||
---
|
||||
|
||||
# Puerts 架构深度分析
|
||||
|
||||
## 概述
|
||||
|
||||
Puerts(发音 "pu-erts")是腾讯开源的 Unreal Engine TypeScript/JavaScript 运行时。它将 V8 JavaScript 引擎直接嵌入 UE 的模块系统,实现 C++ 和 TypeScript 的双向互操作。
|
||||
|
||||
- **仓库**: `D:\MatrixTA\puerts`
|
||||
- **UE 端源码**: `puerts\unreal\Puerts\`
|
||||
- **许可证**: BSD 3-Clause
|
||||
|
||||
## 插件结构
|
||||
|
||||
`Puerts.uplugin` 定义了 **6 个模块**,有严格的初始化顺序:
|
||||
|
||||
| 模块 | 类型 | LoadingPhase | 用途 |
|
||||
|------|------|-------------|------|
|
||||
| **WasmCore** | Runtime | PreDefault | WebAssembly (wasm3) 支持 |
|
||||
| **JsEnv** | Runtime | PreDefault | 核心 V8 嵌入引擎 |
|
||||
| **ParamDefaultValueMetas** | Program | PostConfigInit | UHT 插件,参数默认值 |
|
||||
| **Puerts** | Runtime | PostEngineInit | 高层模块:设置、JsEnv 生命周期、PIE 钩子 |
|
||||
| **DeclarationGenerator** | Editor | Default | 从 UE 反射生成 `*.d.ts` |
|
||||
| **PuertsEditor** | Editor | PostEngineInit | 编辑器集成、TS 编译监听 |
|
||||
|
||||
### 依赖链
|
||||
|
||||
```
|
||||
WasmCore → Core, CoreUObject, Engine
|
||||
JsEnv → Core, CoreUObject, Engine, UMG, ParamDefaultValueMetas, WasmCore, Json
|
||||
Puerts → Core, CoreUObject, Engine, Serialization, OpenSSL, UMG, JsEnv
|
||||
PuertsEditor → Puerts, JsEnv, UnrealEd, LevelEditor, DirectoryWatcher, AssetRegistry
|
||||
DeclarationGenerator → Puerts, JsEnv, UnrealEd
|
||||
```
|
||||
|
||||
JsEnv 链接 V8 静态库(`wee8.lib` / `v8_11.8.172`),也可切换为 QuickJS 或 Node.js 后端。
|
||||
|
||||
## 启动流程
|
||||
|
||||
### 1. V8 Isolate 初始化
|
||||
|
||||
`FJsEnvImpl` 构造函数(`JsEnvImpl.cpp:347-646`):
|
||||
|
||||
1. 创建 `v8::Isolate`,分配默认 ArrayBuffer 分配器
|
||||
2. 创建 `v8::Context` 存入 `DefaultContext`
|
||||
3. 将 `this` 指针存为 Isolate 数据:`Isolate->SetData(0, static_cast<IObjectMapper*>(this))`
|
||||
|
||||
### 2. C++ → JS 桥接注册
|
||||
|
||||
构造函数注册 C++ 回调为全局 JavaScript 函数:
|
||||
|
||||
```cpp
|
||||
MethodBindingHelper<&FJsEnvImpl::EvalScript>::Bind(..., "__tgjsEvalScript", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::SearchModule>::Bind(..., "__tgjsSearchModule", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::LoadModule>::Bind(..., "__tgjsLoadModule", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::FindModule>::Bind(..., "__tgjsFindModule", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::LoadUEType>::Bind(..., puertsObj, "loadUEType", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::LoadCppType>::Bind(..., puertsObj, "loadCPPType", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::NewObjectByClass>::Bind(..., "__tgjsNewObject", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::MakeUClass>::Bind(..., "__tgjsMakeUClass", ...);
|
||||
MethodBindingHelper<&FJsEnvImpl::Mixin>::Bind(..., "__tgjsMixin", ...);
|
||||
// + setTimeout, setInterval, dumpStatisticsLog, FNameToArrayBuffer 等
|
||||
```
|
||||
|
||||
### 3. Bootstrap JS 模块(按顺序执行)
|
||||
|
||||
```cpp
|
||||
ExecuteModule("puerts/first_run.js"); // Inspector 存根(可选)
|
||||
ExecuteModule("puerts/polyfill.js"); // JS polyfills(非 NodeJS 模式)
|
||||
ExecuteModule("puerts/log.js"); // console.log → UE_LOG
|
||||
ExecuteModule("puerts/modular.js"); // CommonJS/ESM 模块系统
|
||||
ExecuteModule("puerts/uelazyload.js"); // UE 类型懒加载 (Proxy)
|
||||
ExecuteModule("puerts/events.js"); // EventEmitter
|
||||
ExecuteModule("puerts/promises.js"); // Promise 支持
|
||||
ExecuteModule("puerts/argv.js"); // 命令行参数
|
||||
ExecuteModule("puerts/jit_stub.js"); // JIT 模块存根
|
||||
ExecuteModule("puerts/hot_reload.js"); // V8 Inspector 热重载
|
||||
ExecuteModule("puerts/pesaddon.js"); // PESAPI 原生 addon 加载
|
||||
```
|
||||
|
||||
### 4. `Start()` 入口
|
||||
|
||||
`FJsEnvImpl::Start()` 调用 `require(ModuleName)` 执行用户入口模块。
|
||||
|
||||
## 模块解析系统
|
||||
|
||||
### 核心文件
|
||||
- `puerts/modular.js` — JS 端 CommonJS/ESM 模块系统
|
||||
- `JSModuleLoader.h` — C++ `IJSModuleLoader` 接口
|
||||
- `DefaultJSModuleLoader.cpp` — 文件系统解析实现
|
||||
|
||||
### `genRequire()` 工厂
|
||||
|
||||
`modular.js` 实现 Node.js 兼容的 CommonJS 系统:
|
||||
|
||||
```javascript
|
||||
// require() 解析流程
|
||||
1. 尝试 org_require (Node.js 原生,如果使用 Node.js 后端)
|
||||
2. 检查 buildinModule 缓存 ("ue", "puerts", "cpp")
|
||||
3. 调用 __tgjsFindModule(moduleName) — 搜索 PUERTS_MODULE 注册的 C++ addon
|
||||
4. 调用 __tgjsSearchModule(moduleName, requiringDir) — 文件系统搜索(见下)
|
||||
5. 调用 __tgjsLoadModule(fullPath) — 加载文件
|
||||
6. CommonJS wrapper 执行: (function(exports, require, module, __filename, __dirname) { ... })
|
||||
7. ES 模块 (.mjs/.mbc): 使用 V8 原生 v8::Module API
|
||||
```
|
||||
|
||||
### 文件系统搜索(`DefaultJSModuleLoader::Search`)
|
||||
|
||||
搜索算法:
|
||||
1. 在 `RequiredDir/RequiredModule` 查找(尝试扩展名:`.js`, `.mjs`, `.cjs`, `.mbc`, `.cbc`, `package.json`, `index.js`)
|
||||
2. 在 `RequiredDir/node_modules/RequiredModule` 查找
|
||||
3. 向上遍历目录树,搜索每个父目录的 `node_modules`
|
||||
4. 回退到 `Content/ScriptRoot/RequiredModule`(默认 `Content/JavaScript/`)
|
||||
5. 如果 ScriptRoot 不是 `"JavaScript"`,也回退到 `Content/JavaScript/`
|
||||
|
||||
**注意:搜索范围不包括插件 Content 目录。**
|
||||
|
||||
## JsEnv 数据流
|
||||
|
||||
### `puerts/uelazyload.js` — UE 类型懒加载
|
||||
|
||||
这是最关键的模块之一:
|
||||
1. 创建全局 `UE` 对象作为 **Proxy**,访问属性时懒加载 UClass 类型
|
||||
2. 创建全局 `CPP` 对象用于注册的 C++ 类型
|
||||
3. 将 `UE` 注册为内置模块:`require('ue')` 返回它
|
||||
4. 暴露:`NewObject`, `NewStruct`, `FNameLiteral`, `NewArray/Set/Map`, `makeUClass`, `mixin`, `blueprint.tojs/load/unload`
|
||||
5. 定义 UE 反射常量:`FunctionFlags`, `ClassFlags`, `PropertyFlags`
|
||||
6. Decorator 存根:`uclass`, `ufunction`, `uproperty`, `uparam` 等
|
||||
|
||||
### `puerts/callable.js`(旧版/替代模式)
|
||||
|
||||
使用 `sendRequestSync`(同步 C++ 桥接)通过 JSON 序列化和 Proxy 包装调用 UE 方法。这是旧的 callable 模式。
|
||||
|
||||
### `puerts/hot_reload.js`
|
||||
|
||||
使用 V8 Inspector 协议 (`Debugger.setScriptSource`) 在运行时热重载 JS 模块,无需重启游戏。触发 `'HMR.prepare'` 和 `'HMR.finish'` 事件。
|
||||
|
||||
### `puerts/pesaddon.js`
|
||||
|
||||
包装 `puerts.load()` 加载原生 `.dll`/`.dylib`/`.so` addon,使用 PESAPI(Portable Embedded Scripting API)。
|
||||
|
||||
## Puerts 模块(`FPuertsModule`)
|
||||
|
||||
### 关键文件
|
||||
- `PuertsModule.h` — `IPuertsModule` 接口
|
||||
- `PuertsModule.cpp` — 实现
|
||||
- `PuertsSetting.h` — 设置 UObject
|
||||
|
||||
### `StartupModule()`
|
||||
|
||||
```cpp
|
||||
void FPuertsModule::StartupModule() {
|
||||
RegisterSettings(); // 从 DefaultPuerts.ini 加载设置
|
||||
// Editor: 绑定 PIE PreBeginPIE/EndPIE 委托
|
||||
// 绑定热重载委托
|
||||
if (Settings.AutoModeEnable) {
|
||||
Enable(); // 创建 JsEnv,注册对象监听器
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `MakeSharedJsEnv()` — JsEnv 工厂
|
||||
|
||||
根据 `UPuertsSetting` 创建:
|
||||
- **单一 JsEnv** (`FJsEnv`) — 普通模式
|
||||
- **JsEnvGroup** (`FJsEnvGroup`) — 多隔离模式(2-9 个实例)
|
||||
|
||||
关键设置:
|
||||
- `RootPath` — JS 源码根目录(默认 `"JavaScript"`,相对于 `Content/`)
|
||||
- `AutoModeEnable` — 模块加载时自动启动
|
||||
- `DebugEnable` / `DebugPort` — V8 Inspector 调试
|
||||
- `NumberOfJsEnv` — 多隔离模式数量
|
||||
|
||||
### UObject 创建监听
|
||||
|
||||
`FPuertsModule` 同时实现 `FUObjectCreateListener`,每个新创建的 `UObject` 触发 `NotifyUObjectCreated()` → `JsEnv->TryBindJs(InObject)`。这就是 Puerts 自动将 TypeScript 行为注入 Blueprint 生成对象的机制。
|
||||
|
||||
## TS → C++ 绑定架构
|
||||
|
||||
### `UTypeScriptGeneratedClass` 和 `UTypeScriptBlueprint`
|
||||
|
||||
- `UTypeScriptBlueprint` — TypeScript 类的 Blueprint 资产类型
|
||||
- `UTypeScriptGeneratedClass` — TS 生成的运行时 UClass:
|
||||
- `DynamicInvoker` — `TWeakPtr<ITsDynamicInvoker>`,回调 `FJsEnvImpl`
|
||||
- `FunctionToRedirect` — 需要从 C++ 重定向到 JS 的函数名集合
|
||||
- `HasConstructor` — TS 类是否有 `Constructor` 方法
|
||||
- `StaticConstructor` — 替换原生 C++ 构造函数
|
||||
|
||||
### `UJSGeneratedFunction` 和 Magic Bytecode
|
||||
|
||||
`UJSGeneratedFunction` 是动态创建的 `UFunction`,通过 **magic bytecode trick** 重定向到 JS:
|
||||
1. 在 UFunction 的 `Script` bytecode 中存储自身指针(3 字节 magic marker + 8 字节指针)
|
||||
2. `execCallJS` 执行时恢复指针
|
||||
3. 调用 `DynamicInvoker->InvokeJsMethod()` → `FJsEnvImpl::InvokeJsMethod()`
|
||||
4. `FunctionTranslator->CallJs()` 将所有参数编组到 V8,调用 JS 函数
|
||||
|
||||
### `MakeUClass` / `Mixin` — 运行时类创建
|
||||
|
||||
- **`MakeUClass`** (`puerts.makeUClass(ctor)`):接收 JS 构造函数、原型、类名、方法对象、父 UClass,创建 `UJSGeneratedClass`,遍历父类 `FUNC_BlueprintEvent`,为每个覆盖创建 `UJSGeneratedFunction`
|
||||
- **`Mixin`** (`puerts.blueprint.mixin(to, mixinMethods)`):接收现有 UClass,添加方法覆盖
|
||||
|
||||
## C++ 调用 TS 的方式
|
||||
|
||||
### 方式 1:`FJsObject`(直接 JS 函数包装器)
|
||||
|
||||
```cpp
|
||||
// FJsObject 是 USTRUCT,持有持久化 V8 引用
|
||||
FJsObject MyFunc;
|
||||
MyFunc.Action<int32, FString>(42, TEXT("hello")); // 无返回值
|
||||
int32 Result = MyFunc.Func<int32, int32, FString>(42, TEXT("hello")); // 有返回值
|
||||
int32 Val = MyObj.Get<int32>("someProperty"); // 读取属性
|
||||
MyObj.Set("someProperty", 42); // 设置属性
|
||||
```
|
||||
|
||||
自动使用 `puerts::v8_impl::Converter<T>` 进行 C++ ↔ V8 类型转换。线程安全(`THREAD_SAFE` 模式下使用 `v8::Locker`)。
|
||||
|
||||
### 方式 2:UFUNCTION / BlueprintEvent 覆盖
|
||||
|
||||
标准 UE 模式:
|
||||
```cpp
|
||||
// C++ 中定义
|
||||
UFUNCTION(BlueprintImplementableEvent)
|
||||
void OnSomethingHappened(int32 Param);
|
||||
|
||||
// TypeScript 中实现
|
||||
class MyActor extends UE.Actor {
|
||||
OnSomethingHappened(Param: number): void {
|
||||
// 被 C++ 调用时执行
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 方式 3:委托
|
||||
|
||||
```typescript
|
||||
import { toDelegate, toManualReleaseDelegate } from "puerts";
|
||||
let d = toDelegate(myObject, myCallback); // 绑定到对象
|
||||
let d2 = toManualReleaseDelegate(myCallback); // 独立委托
|
||||
```
|
||||
|
||||
C++ 侧,`FJsEnvImpl` 维护 `DelegateMap` 将委托指针映射到 JS 回调函数。
|
||||
|
||||
### 方式 4:`ITsDynamicInvoker` 接口
|
||||
|
||||
- `IDynamicInvoker::InvokeJsMethod` — 从 `UJSGeneratedFunction::execCallJS` 调用
|
||||
- `IDynamicInvoker::JsConstruct` — 运行 JS 构造函数
|
||||
- `ITsDynamicInvoker::TsConstruct` — TS 特定构造路径
|
||||
|
||||
`FJsEnvImpl` 通过内部类 `DynamicInvokerImpl` 和 `TsDynamicInvokerImpl` 实现这些接口。
|
||||
|
||||
### 方式 5:`PUERTS_MODULE` 宏(Addon 模块系统)
|
||||
|
||||
C++ 代码注册为 addon 模块:
|
||||
|
||||
```cpp
|
||||
// 在任何 C++ 文件中
|
||||
PUERTS_MODULE(MyPlugin, [](v8::Local<v8::Context> Context, v8::Local<v8::Object> Exports) {
|
||||
// 注册类、函数等
|
||||
Exports->Set(...);
|
||||
});
|
||||
```
|
||||
|
||||
使用静态自动注册:
|
||||
```cpp
|
||||
#define PUERTS_MODULE(Name, RegFunc) \
|
||||
static struct FAutoRegisterFor##Name { \
|
||||
FAutoRegisterFor##Name() { \
|
||||
PUERTS_NAMESPACE::RegisterAddon(#Name, (RegFunc)); \
|
||||
} \
|
||||
} _AutoRegisterFor##Name
|
||||
```
|
||||
|
||||
JS 端:`require('MyPlugin')` → `FindModule` → 执行注册函数 → 获取 exports。
|
||||
|
||||
## 类型声明文件
|
||||
|
||||
位于 `puerts\unreal\Puerts\Typing\`:
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| `puerts/index.d.ts` | 核心 Puerts API:`$Ref`, `blueprint`, `toDelegate`, `on/off/emit`, `merge`, `load` |
|
||||
| `ue/puerts.d.ts` | UE 绑定类型:`$Delegate`, `TArray`, `TSet`, `TMap`, `NewObject`, `NewStruct` |
|
||||
| `ue/puerts_decorators.d.ts` | 装饰器:`rpc.flags()`, `edit_on_instance()`, `uclass`, `ufunction` |
|
||||
| `ue/index.d.ts` | 聚合所有声明(通过 `/// <reference path="..." />`) |
|
||||
|
||||
`ue.d.ts` 和 `ue_bp.d.ts` 由 `DeclarationGenerator` 模块使用 UE 反射系统自动生成。
|
||||
|
||||
## 架构图
|
||||
|
||||
```
|
||||
[Game/Editor 启动]
|
||||
│
|
||||
▼
|
||||
FPuertsModule::StartupModule()
|
||||
├── RegisterSettings() → 读取 DefaultPuerts.ini
|
||||
└── Enable()
|
||||
├── GUObjectArray::AddUObjectCreateListener(this) // 拦截所有 UObject 创建
|
||||
└── MakeSharedJsEnv()
|
||||
└── FJsEnv(ScriptRoot) 包装器
|
||||
└── FJsEnvImpl 构造
|
||||
├── v8::Isolate::New() + v8::Context::New()
|
||||
├── 注册全局 C++ 回调 (__tgjs* + puerts.*)
|
||||
├── ExecuteModule() ×13 (bootstrap JS 文件)
|
||||
└── 捕获 global.require, global.__reload
|
||||
└── Start(ModuleName) → require(ModuleName) → 用户代码执行
|
||||
│
|
||||
▼
|
||||
[运行时: UObject 创建(如 Blueprint 实例化)]
|
||||
│
|
||||
▼
|
||||
FPuertsModule::NotifyUObjectCreated()
|
||||
└── JsEnv->TryBindJs(InObject)
|
||||
├── TypeScriptGeneratedClass? → MakeSureInject (绑定 JS 原型)
|
||||
└── JSGeneratedClass? → JsConstruct (调用 JS 构造函数)
|
||||
|
||||
[C++ 调用 TS]
|
||||
UFUNCTION(BlueprintEvent) _Implementation → UE VM → execCallJS
|
||||
→ DynamicInvoker->InvokeJsMethod()
|
||||
→ FJsEnvImpl::InvokeJsMethod()
|
||||
→ FunctionTranslator->CallJs() [参数编组]
|
||||
→ JS 函数被调用
|
||||
|
||||
[TS 调用 C++]
|
||||
require('ue') → Proxy → loadUEType → UETypeToJsClass → DataTransfer::FindOrAddObject
|
||||
new MyActor() → NewObjectByClass → UObject 创建并调用构造函数
|
||||
actor.K2_SomeMethod() → UFUNCTION → 原生 C++ 代码执行
|
||||
```
|
||||
|
||||
## 关键文件索引
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `unreal/Puerts/Puerts.uplugin` | 插件定义(模块列表、加载阶段) |
|
||||
| `Source/Puerts/Public/PuertsModule.h` | `IPuertsModule` 接口 |
|
||||
| `Source/Puerts/Private/PuertsModule.cpp` | 模块启动/关闭,JsEnv 生命周期,PIE 钩子 |
|
||||
| `Source/JsEnv/Public/JsEnv.h` | `FJsEnv` 公开包装器 |
|
||||
| `Source/JsEnv/Private/JsEnvImpl.h` | `FJsEnvImpl` 完整定义 |
|
||||
| `Source/JsEnv/Private/JsEnvImpl.cpp` | V8 初始化、bootstrap、模块执行、绑定 |
|
||||
| `Source/JsEnv/Public/JSModuleLoader.h` | `IJSModuleLoader` 接口 + `DefaultJSModuleLoader` |
|
||||
| `Source/JsEnv/Private/DefaultJSModuleLoader.cpp` | 文件系统搜索/加载实现 |
|
||||
| `Source/JsEnv/Public/JsObject.h` | `FJsObject` — JS 函数/对象的 C++ 包装器 |
|
||||
| `Source/JsEnv/Public/DataTransfer.h` | `DataTransfer` — C++/JS 类型转换 |
|
||||
| `Source/JsEnv/Public/DynamicInvoker.h` | `IDynamicInvoker` 接口 |
|
||||
| `Source/JsEnv/Public/TsDynamicInvoker.h` | `ITsDynamicInvoker` 接口 |
|
||||
| `Source/JsEnv/Public/TypeScriptGeneratedClass.h` | `UTypeScriptGeneratedClass` |
|
||||
| `Source/JsEnv/Private/JSGeneratedClass.h` | `UJSGeneratedClass` |
|
||||
| `Source/JsEnv/Private/JSGeneratedFunction.h` | `UJSGeneratedFunction` (magic bytecode) |
|
||||
| `Source/JsEnv/Private/FunctionTranslator.h` | `FFunctionTranslator` — C++/JS 参数编组 |
|
||||
| `Source/JsEnv/Public/JSClassRegister.h` | `JSClassDefinition`, `PUERTS_MODULE` 宏 |
|
||||
| `Source/JsEnv/Public/JsEnvGroup.h` | `FJsEnvGroup` — 多隔离模式 |
|
||||
| `Content/JavaScript/puerts/modular.js` | JS 端 CommonJS/ESM 模块系统 |
|
||||
| `Content/JavaScript/puerts/uelazyload.js` | UE 类型懒加载, makeUClass, mixin |
|
||||
| `Content/JavaScript/puerts/callable.js` | 替代 Proxy callable 模式 |
|
||||
| `Content/JavaScript/puerts/hot_reload.js` | V8 Inspector 热重载 |
|
||||
| `Content/JavaScript/puerts/pesaddon.js` | PESAPI 原生 addon 加载器 |
|
||||
693
07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md
Normal file
693
07-Other/AI/AI Agent/UnrealEngine/Puerts/改造方案 - 将业务 TS 代码嵌入插件.md
Normal 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: 回调注册 | 低 | ❌ | 不需要 | ⭐(反向模式) |
|
||||
|
||||
**最佳实践:方案 C(PUERTS_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` 条件编译
|
||||
Reference in New Issue
Block a user