简介
由于PuerTS官方更倾向于各个项目组自行处理独有机制,官方也没有给更为通用的最佳实践。
不过在项目中落地时,总还是需要一些具体的方案来实现相关需求,因此这里总结了个人实际运用PuerTS时的一些最佳实践。
虚拟机环境
官方提供了继承引擎类功能,这个需要在Project Settings-Plugins-Puerts Settings中设置Enable来开启。这样当引擎运行后,Puerts会自动启动一个JavaScript虚拟机环境。插件会监听TypeScript文件的变更,如果存在符合要求的TypeScript类,就会自动生成对应的蓝图文件,在运行时调用这个蓝图时能调用到对应的JavaScript类中的逻辑。
然而官方目前更推荐自己管理虚拟机环境,也就是通过MakeShared<puerts::FJsEnv>在合适的时机创建一个JavaScript虚拟机环境。通过自己创建虚拟机环境,可以指定一个ModuleName,在虚拟机启动之后会运行这个ModuleName对应的JavaScript文件。这种方式下,也需要自己处理蓝图与JavaScript类之间的绑定。
自动绑定
官方提供了两种关联蓝图与JavaScript类的方式:
- 继承引擎类
- Mixin
官方更推荐手动调用Mixin的方式,避免继承引擎类滥用导致的蓝图与JavaScript类之间的大量跨语言交互。
然而对于项目的具体应用来说,让每个开发者都手动调用蓝图的Mixin函数,可能不太现实。就笔者的项目而言,更希望类似于Unlua那种的方式,指定蓝图和TS类之间的关系,因此编写了一个自动绑定JavaScript类的插件:BoilTask/PuertsAutoMixin。
这个插件实现了在蓝图中继承PuertsInterface之后,通过重写GetJavaScriptModule函数,来指定关联的JavaScript类。对于大部分时候,使用插件自动生成的路径即可符合需求,如果有指定类的需求也可以手动修改。
通过在虚拟机启动后调用BindMixin函数,来关联JavaScript类。
1
2
3
4
| void UMetaGameInstance::BindMixin(const FPuertsAutoMixinDelegate& BindCallback)
{
UPuertsAutoMixinSubsystem::GetInstance().RegisterBindDelegate(JsEnv, BindCallback);
}
|
1
2
| let gameInstance = argv.getByName("GameInstance") as UE.MetaGameInstance;
gameInstance.BindMixin(toDelegate(gameInstance, ToMinix))
|
自动生成JavaScript文件
可以使用TypeScript原生的tsc --watch命令来增加一个生成任务,启动tsc来监视文件变更自动生成对应的JavaScript文件。
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类。
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-map与source-map-support来实现从JavaScript转为TypeScript的信息。
由于没有使用nodejs环境,而使用的v8引擎,因此需要使用项目自己封装的文件操作函数来手动注册path和fs模块。
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();
}
|
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文件名和行号。
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.info、console.warn,对于console.error,还会额外输出调用者堆栈。
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方法。
1
2
3
4
5
| import * as UE from "ue";
UE.Vector2D.prototype.toString = function () {
return `(${this.X}, ${this.Y})`;
};
|
配置参考
package.json核心是typescript、eslint、source-map等。
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配置如下:
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/**/*"
],
}
|