--- 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 OnJsEnvPreReload; // JS 环境重启前回调 std::function Eval; // 执行 JS 字符串 TSparseArray> TsConsoleCommands; // TS 注册的控制台命令 FSimpleMulticastDelegate OnJsEnvCleanup; // JS 环境清理广播 private: TSharedPtr JsEnv; // ← 私有!外部无法访问 TSharedPtr SourceFileWatcher; TUniquePtr 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( [this](const FString& InPath) { if (JsEnv.IsValid()) { TArray 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( std::make_shared(TEXT("EasyEditorScripts")), std::make_shared(), -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` | 重启前执行 | | `Eval` | `std::function` | 执行任意 JS 字符串 | 其他插件的集成方式(以 EasyEditor_ImGui 为例): ```cpp // 注册清理回调 FModuleManager::LoadModuleChecked("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 |