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

20 KiB
Raw Blame History

tags, created
tags created
UE
Puerts
EasyEditorPlugin
改造方案
TypeScript
架构
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 加载

当前代码

// EasyEditorPlugin.cpp InitJsEnv()
JsEnv = MakeShared<puerts::FJsEnv>(
    std::make_shared<puerts::DefaultJSModuleLoader>(TEXT("EasyEditorScripts")),
    ...);

问题DefaultJSModuleLoader 只在项目 Content 目录搜索,不会搜索插件目录。

改造后

新建 PluginJSModuleLoader.h

#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()

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

问题

class FEasyEditorPluginModule {
private:
    TSharedPtr<puerts::FJsEnv> JsEnv;   // ← 外部无法访问
};

外部 C++ 只能通过 Eval 执行 JS 字符串,无类型安全、无返回值。

改造:公开 JsEnv 访问和 FJsObject 获取

EasyEditorPlugin.h 中新增:

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

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 对象

核心问题FJsEnvFJsEnvImpl 成员是私有的,没有公开的 "通过名称获取 JS 变量" API。需要通过以下方式实现

方案 A通过 puerts::FJsEnv 包装器暴露新方法

需要在 Puerts 的 FJsEnv 中新增一个方法:

// 在 JsEnv.h 中新增
class JSENV_API FJsEnv
{
public:
    // ... 现有 API ...

    /**
     * 获取全局作用域中的一个值
     * @param Name 全局变量/函数名
     * @return 包装了该 JS 值的 FJsObject如果不存在或类型不对则返回空
     */
    FJsObject GetGlobalValue(const FString& Name);
};

实现(在 JsEnvImpl.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.hJsEnvImpl.cpp 中增加代码),对于已经安装的 Puerts 插件不太方便。

方案 B在 EasyEditorPlugin 中注入 JS 辅助函数(推荐,无需修改 Puerts

思路:在 InitJsEnv() 时,向 JS 全局注入一个 __eep_getExport 函数,同时在 C++ 中存储引用。

// 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++ 函数:

// 在 EasyEditorPlugin.cpp 的 AutoRegisterForEEP 中新增绑定:

struct FEasyEditorBridge
{
    static puerts::Function GetGlobalFunction;
    static FJsObject GetGlobalObject;

    // 注册到 Puerts binding
};

然后在 Main.ts 中:

// 将需要暴露给 C++ 的函数注册到 global 对象
import { MyBusinessLogic } from './business/BusinessLogic';

(global as any).__exports = {
    processAsset: MyBusinessLogic.processAsset,
    validateLevel: MyBusinessLogic.validateLevel,
    // ...
};

C++ 侧通过 Eval 获取:

FJsObject FEasyEditorPluginModule::GetJsExport(const FString& Name) const
{
    // 利用 Eval 机制代理
    // 实际方案:在 V8 中执行 "__exports[Name]",捕获结果
    // 但当前 Eval 是 void 返回的...

    // 需要改造 Eval 为有返回值的版本,见下文
}

方案 D改造 Eval 为有返回值的版本(最小侵入)

// 在 EasyEditorPlugin.h 中
class FEasyEditorPluginModule
{
public:
    // 替代原来的 void Eval使用可返回值的版本
    std::function<FJsObject(const FString&)> EvalWithReturn;
};

InitJsEnv() 中注册对应的 C++ 绑定:

// 利用 Puerts 的 FV8Utils 实现有返回值的 Eval
// 在 C++ 侧: 持有 v8::Isolate 和 v8::Context直接 eval 并封装结果

方案 E最简单且实用的方案 —— 通过 Console 命令 + 注册回调

不暴露 JsEnv而是让项目 C++ 注册一个回调TS 通过 EasyEditorPlugin.AddConsoleCommand 或其他绑定来调用它:

// 项目 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

{
    "FriendlyName": "MyBusinessPlugin",
    "Modules": [{
        "Name": "MyBusinessPlugin",
        "Type": "Runtime",               // ← Runtime可在打包游戏中运行
        "LoadingPhase": "Default"
    }],
    "Plugins": [{
        "Name": "Puerts",
        "Enabled": true
    }]
}

Build.cs

PublicDependencyModuleNames.AddRange(new string[] {
    "Core",
    "CoreUObject",
    "Engine",
    "JsEnv"           // ← 依赖 Puerts 的 JsEnv
});

MyBusinessPluginModule.h

#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

#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++ 的调用接口

#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++ 调用方式

// 项目 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 监听的是正确的目录:

// 插件 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 需要正确实现 SearchLoad 两个方法
  5. Puerts 版本:不同 Puerts 版本的 V8 API 可能不同V8 8.x vs 11.x注意 V8_MAJOR_VERSION 条件编译