虚幻引擎(UE5)下PuerTS的最佳实践

文章字数:2712

简介

由于PuerTS官方更倾向于各个项目组自行处理独有机制,官方也没有给更为通用的最佳实践。

不过在项目中落地时,总还是需要一些具体的方案来实现相关需求,因此这里总结了个人实际运用PuerTS时的一些最佳实践。

虚拟机环境

官方提供了继承引擎类功能,这个需要在Project Settings-Plugins-Puerts Settings中设置Enable来开启。这样当引擎运行后,Puerts会自动启动一个JavaScript虚拟机环境。插件会监听TypeScript文件的变更,如果存在符合要求的TypeScript类,就会自动生成对应的蓝图文件,在运行时调用这个蓝图时能调用到对应的JavaScript类中的逻辑。

然而官方目前更推荐自己管理虚拟机环境,也就是通过MakeShared<puerts::FJsEnv>在合适的时机创建一个JavaScript虚拟机环境。通过自己创建虚拟机环境,可以指定一个ModuleName,在虚拟机启动之后会运行这个ModuleName对应的JavaScript文件。这种方式下,也需要自己处理蓝图与JavaScript类之间的绑定。

自动绑定

官方提供了两种关联蓝图与JavaScript类的方式:

  1. 继承引擎类
  2. Mixin

官方更推荐手动调用Mixin的方式,避免继承引擎类滥用导致的蓝图与JavaScript类之间的大量跨语言交互。

然而对于项目的具体应用来说,让每个开发者都手动调用蓝图的Mixin函数,可能不太现实。就笔者的项目而言,更希望类似于Unlua那种的方式,指定蓝图和TS类之间的关系,因此编写了一个自动绑定JavaScript类的插件:BoilTask/PuertsAutoMixin

这个插件实现了在蓝图中继承PuertsInterface之后,通过重写GetJavaScriptModule函数,来指定关联的JavaScript类。对于大部分时候,使用插件自动生成的路径即可符合需求,如果有指定类的需求也可以手动修改。

通过在虚拟机启动后调用BindMixin函数,来关联JavaScript类。

cpp 在C++中注册执行mixin的函数
1
2
3
4
void UMetaGameInstance::BindMixin(const FPuertsAutoMixinDelegate& BindCallback)
{
	UPuertsAutoMixinSubsystem::GetInstance().RegisterBindDelegate(JsEnv, BindCallback);
}
typescript 在TypeScript中调用绑定
1
2
let gameInstance = argv.getByName("GameInstance") as UE.MetaGameInstance;
gameInstance.BindMixin(toDelegate(gameInstance, ToMinix))

自动生成JavaScript文件

可以使用TypeScript原生的tsc --watch命令来增加一个生成任务,启动tsc来监视文件变更自动生成对应的JavaScript文件。

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
    "label": "tsc: 监视 - tsconfig.json (大内存)",
    "type": "shell",
    "command": "node",
    "args": [
        "--max-old-space-size=8192",
        "./node_modules/typescript/bin/tsc",
        "--watch",
        "-p",
        "tsconfig.json"
    ],
    "problemMatcher": [
        "$tsc-watch"
    ],
    "group": {
        "kind": "build",
        "isDefault": true
    }
}

热重载HotReload

参考需要代码"热重载"功能中提到的方案,可以实现代码变更之后,自动重新加载JavaScript类。

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
void UMetaGameInstance::StartJavaScriptEnv()
{
#if WITH_EDITOR

	std::function<void(const FString&)> SourceLoadedCallback = nullptr;

	SourceFileWatcher = MakeShared<PUERTS_NAMESPACE::FSourceFileWatcher>(
		[this](const FString& InPath)
		{
			HotReloadJavaScriptEnv(InPath);
		});
	SourceLoadedCallback = [this](const FString& InPath)
	{
		if (SourceFileWatcher.IsValid())
		{
			SourceFileWatcher->OnSourceLoaded(InPath);
		}
	};
#endif

	JsEnv = MakeShared<puerts::FJsEnv>(std::make_unique<puerts::DefaultJSModuleLoader>(
		                                   TEXT("JavaScript")
	                                   )
	                                   , std::make_shared<puerts::FDefaultLogger>()
	                                   , 8080
	                                   , SourceLoadedCallback

	);
	TArray<TPair<FString, UObject*>> Arguments;
	Arguments.Add(TPair<FString, UObject*>(TEXT("GameInstance"), this));
	// JsEnv->WaitDebugger();
	JsEnv->Start("QuickStart", Arguments);
}

void UMetaGameInstance::HotReloadJavaScriptEnv(const FString& Path)
{
	if (JsEnv.IsValid())
	{
		TArray<uint8> Source;
		if (FFileHelper::LoadFileToArray(Source, *Path))
		{
			LOG_CATEGORY_INFO(Puerts, "start ReloadSource %s", *Path);
			JsEnv->ReloadSource(Path, puerts::PString((const char*)Source.GetData(), Source.Num()));
			LOG_CATEGORY_INFO(Puerts, "end ReloadSource %s", *Path);
		}
		else
		{
			UE_LOG(Puerts, Error, TEXT("read file fail for %s"), *Path);
		}
	}
}

日志扩展

从JavaScript转为TypeScript

可以使用source-mapsource-map-support来实现从JavaScript转为TypeScript的信息。

由于没有使用nodejs环境,而使用的v8引擎,因此需要使用项目自己封装的文件操作函数来手动注册pathfs模块。

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
  * 获取文件所在目录路径
  */
FString UPlatformLibrary::GetDirectoryName(const FString& Path)
{
	return FPaths::GetPath(Path);
}

/**
  * 合并路径
  */
FString UPlatformLibrary::CombinePath(const FString& Directory, const FString& File)
{
	return FPaths::Combine(Directory, File);
}

/**
  * 检查文件是否存在
  */
bool UPlatformLibrary::FileExists(const FString& Path)
{
	return FPaths::FileExists(Path);
}

/**
  * 读取文件所有文本内容
  */
FString UPlatformLibrary::ReadAllText(const FString& Path)
{
	if (!FPaths::FileExists(Path))
	{
		LOG_CATEGORY_WARN(LogMetaSystem, "File not exists, Path:{}", Path);
		return FString();
	}
	FString Result;
	if (FFileHelper::LoadFileToString(Result, *Path))
	{
		return Result;
	}
	LOG_CATEGORY_ERROR(LogMetaSystem, "Read file failed, Path:{}", Path);
	return FString();
}
typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import * as UE from "ue";
var puerts = require("puerts");

puerts.registerBuildinModule("path", {
    dirname(path) {
        return UE.PlatformLibrary.GetDirectoryName(path);
    },
    resolve(dir, url) {
        url = url.replace(/\\/g, "/");
        while (url.startsWith("../")) {
            dir = UE.PlatformLibrary.GetDirectoryName(dir);
            url = url.substr(3);
        }
        return UE.PlatformLibrary.CombinePath(dir, url);
    },
});
puerts.registerBuildinModule("fs", {
    existsSync(path) {
        return UE.PlatformLibrary.FileExists(path);
    },
    readFileSync(path) {
        return UE.PlatformLibrary.ReadAllText(path);
    },
});
(function () {
    let global = this ?? globalThis;
    global["Buffer"] = global["Buffer"] ?? {};
})();

require('source-map-support').install();

输出调用者信息

可以在输出日志的时候附带当前调用的TypeScript文件名和行号。

typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
const originalLog = console.log;

// 解析调用者信息
function getCallerInfo() {
    const stack = new Error().stack;
    if (!stack) return null;

    // 获取调用者行(跳过前两行:Error构造函数和getCallerInfo本身)
    const stackLines = stack.split("\n");
    const callerLine = stackLines[3]; // 第4行才是真正的调用者

    if (!callerLine) return null;

    // 解析函数名和位置信息
    // 匹配格式:at functionName (filePath:line:column)
    // 或者:at filePath:line:column (匿名函数)
    const match = callerLine.match(/at\s+(?:([^\s(]+)\s+)?\(([^)]+)\)|at\s+([^\s]+)/);

    if (!match) return null;

    let functionName = match[1] || "anonymous";
    let location = match[2] || match[3];

    // 简化函数名,特别是UE5蓝图相关的函数名
    // 对于格式如 /Game/Path/Blueprint.Blueprint_C.FunctionName 的函数名,只保留FunctionName
    if (functionName.includes(".")) {
        const parts = functionName.split(".");
        functionName = parts[parts.length - 1];
    }

    // 对于格式如 ClassName::FunctionName 的函数名,保留 ClassName::FunctionName
    // 对于匿名函数,保持原样

    if (!location) return null;

    // 尝试从位置信息中解析文件路径和行号
    // 对于TypeScript项目,位置信息可能包含源映射信息
    // 格式可能是:file.js:line:column
    const locationMatch = location.match(/^(.*):(\d+):(\d+)$/);

    if (!locationMatch) return null;

    const filePath = locationMatch[1];
    const lineNumber = locationMatch[2];

    // 尝试从filePath中提取TypeScript源文件信息
    // 如果启用了source map,可能需要额外处理
    // 这里简单地尝试将JavaScript路径转换为TypeScript路径
    // 只保留文件名部分
    const fileName = filePath.split(/[\\/]/).pop() || filePath.split(/[\\/]/).pop() || "unknown";

    return {
        functionName,
        fileName,
        lineNumber,
    };
}

console.log = function (...args: any[]) {
    const callerInfo = getCallerInfo();
    if (callerInfo) {
        args.unshift(`[${callerInfo.fileName}:${callerInfo.lineNumber}:${callerInfo.functionName}]`);
    }
    // 将多个参数合并成一个字符串,用空格分隔,避免底层库使用逗号分隔
    const combinedArgs = args.map((arg) => String(arg)).join("\t");
    originalLog.apply(console, [combinedArgs]);
};

同理可以处理console.infoconsole.warn,对于console.error,还会额外输出调用者堆栈。

typescript
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
console.error = function (...args: any[]) {
    const callerInfo = getCallerInfo();
    if (callerInfo) {
        args.unshift(`[${callerInfo.fileName}:${callerInfo.lineNumber}:${callerInfo.functionName}]`);
    }
    // 将多个参数合并成一个字符串,用空格分隔,避免底层库使用逗号分隔
    const combinedArgs = args.map((arg) => String(arg)).join("\t");
    // 获取完整的堆栈信息
    const stack = new Error().stack;
    const errorOutput = stack ? `${combinedArgs}\n${stack}` : combinedArgs;
    originalError.apply(console, [errorOutput]);
};

自定义对象输出格式

可以使用JavaScript的原型机制,调整某个类型在输出日志时默认的toString方法。

typescript
1
2
3
4
5
import * as UE from "ue";

UE.Vector2D.prototype.toString = function () {
    return `(${this.X}, ${this.Y})`;
};

配置参考

package.json核心是typescripteslintsource-map等。

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
    "name": "metagame",
    "version": "1.0.0",
    "description": "UE5项目",
    "main": "index.js",
    "scripts": {
        "format": "prettier --write .",
        "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
    },
    "author": "",
    "license": "ISC",
    "devDependencies": {
        "@rushstack/eslint-patch": "^1.11.0",
        "@types/node": "^16.11.56",
        "eslint": "^8.22.0",
        "prettier": "^3.3.2",
        "typescript": "^5.5.4"
    },
    "dependencies": {
        "source-map": "^0.7.6",
        "source-map-support": "^0.5.21"
    }
}

tsconfig.json配置如下:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "experimentalDecorators": true,
    "jsx": "react",
    "sourceMap": true,
    "inlineSources": true,
    "typeRoots": [
      "Typing",
      "./node_modules/@types"
    ],
    "outDir": "Content/JavaScript"
  },
  "include": [
    "TypeScript/**/*"
  ],
}
该内容采用 CC BY-NC-SA 4.0许可协议。如果对您有帮助或存在意见建议,欢迎在下方评论交流。
本页面浏览次数 加载中...
本页面访客数 加载中...

加载中...