记录一下普洱TS的安装、代码打包、调试与FairyGUI集成等,以及使用过程中遇到的问题。
基本使用
安装
按照官方手册,拷贝puerts/unity/Assets下的所有内容到您项目的Assets目录下,在
release中下载插件并解压覆盖到Plugins目录,插件有不同的js引擎版本,不知道选什么的话建议用v8。
Unity示例 在另一个仓库,是独立的Unity工程,看完里面的示例基本上就能明白大致使用方法了。
Hello Kitty
按照国际惯例,先来写个Hello Kitty。
配置
如果仅安装了Puerts,没有拷贝示例代码,则需要在Unity中做一些准备工作。为了快速看效果,只做一个简单的配置,Assets下新建Editor目录,其下新建PuertsConfig.cs:
PuertsConfig.cs
[Configure]
public class PuertsConfig
{
[Binding]
static IEnumerable Bindings =>
new List()
{
typeof(Debug),
typeof(Vector3),
typeof(List),
typeof(Dictionary>),
typeof(Time),
typeof(Transform),
typeof(Component),
typeof(GameObject),
typeof(UnityEngine.Object),
typeof(Delegate),
typeof(System.Object),
typeof(Type),
typeof(ParticleSystem),
typeof(Canvas),
typeof(RenderMode),
typeof(Behaviour),
typeof(MonoBehaviour),
};
}
执行菜单Puerts->Generate index.d.ts:
将会生成对应的类型声明文件:
TyepeScript工程
在项目根目录下新建一个TsProject文件夹(官方示例中为TsProj),作为TypeScript工程目录。
用vscode打开它,在这之前请确保已经安装好了vscode、node、npm、typescript,新建tsconfig.json,加入如下配置:
tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"jsx": "react",
"sourceMap": true,
"noImplicitAny": true,
"typeRoots": [
"../Assets/Puerts/Typing",
"../Assets/Gen/Typing",
"./node_modules/@types"
],
"outDir": "output"
}
}
typeRoots
中指定了C#侧的类型声明文件目录,如果你的ts工程目录或者Puerts目录有变更,这里需要修改正确。
outDir
指定了编译后js文件的输出目录。其他的配置没什么好说的,可以根据个人喜好调整,更多配置项说明可以查看TypeScript的官方文档。
新建package.json,加入如下配置:
package.json
{
"name": "tsproj",
"version": "1.0.0",
"description": "ts project",
"scripts": {
"build": "tsc -p tsconfig.json",
"postbuild": "node copyJsFile.js output ../Assets/Resources"
}
}
这两个文件也可以用npm init
与tsc --init
创建。
把官方示例的TsProj文件夹里的copyJsFile.js拷贝过来,新建index.ts,编写Hello World:
index.ts
console.log('Hello Kitty!');
终端里运行:
npm run build
可以看到output文件夹输出了编译后的index.js文件与map文件:
并且这些文件被拷贝到了Recources目录下:
执行
Scripts下新建JsManager.cs,编写执行代码:
JsManager.cs
namespace LearnPuerts
{
public class JsManager : MonoBehaviour
{
private static JsEnv jsEnv;
private void Awake()
{
jsEnv ??= new JsEnv(new DefaultLoader());
jsEnv.Eval("require('index');");
}
private void Update()
{
jsEnv.Tick();
}
private void OnDestroy()
{
jsEnv.Dispose();
}
}
}
脚本挂到场景中,运行即可看到效果:
打包与调试
打包
在冻手之前,先看看默认的build都干了些什么:
首先tsc编译,文件输出到output文件夹下,然后执行copyJsFile.js将文件拷贝到了Assets/Resources目录下。
那么打包过程依葫芦画瓢即可,先打包,再拷贝。官方说明中用的是webpack,个人更习惯用esbuild,也差不了太多。
先把esbuild装好,终端里执行:
npm install esbuild --save-dev
拷贝过程懒得自己写了,直接用copyJsFile.js,修改它的代码,导出拷贝方法:
copyJsFile.js
// if (process.argv.length == 4) {
// copyFolderRecursiveSync(process.argv[2], process.argv[3]);
// } else {
// console.error('invalid arguments');
// }
exports.copyFolder = copyFolderRecursiveSync
新建build.js,加入相应依赖,指定输出目录与拷贝目标目录:
build.js
var copyFolder = require('./copyJsFile').copyFolder;
var outputFolder = 'output';
var targetFolder = '../Assets/Resources';
编写打包配置:
build.js
// https://esbuild.github.io/api/#build-api
var options = {
bundle: true,
entryPoints: ["index.ts"],
incremental: true,
minify: process.env.NODE_ENV === "production",
outfile: outputFolder + "/bundle.js",
platform: "node",
tsconfig: "./tsconfig.json",
sourcemap: process.env.NODE_ENV === "production" ? false : true,
external: ['csharp', 'puerts', 'path', 'fs'],
treeShaking: true,
logLevel: 'error'
};
根据说明,csharp、puerts、path、fs在打包时需要排除,其他配置可以根据个人需求调整。
同时希望打包支持watch,这样ts代码有改动就能同步更新输出文件,通过获取命令行参数,判断当前是否为watch模式:
build.js
var watchMode = false;
for (let i = 2; i < process.argv.length; i++) {
if (process.argv[i] == 'watch') {
watchMode = true;
break;
}
}
如果为watch模式,则增加对应watch配置,在Rebuild时将输出文件拷贝到目标目录下:
if (watchMode) {
options.watch = {
onRebuild(error, result) {
if (error) {
console.error('watch build failed:', error);
} else {
copyFolder(outputFolder, targetFolder);
console.log('watch build succeeded:', result);
}
}
}
} else if (process.env.NODE_ENV === "production") {
// 正式打包时将删除输出目录下所有文件
var fs = require('fs');
var path = require('path');
fs.rmSync(path.dirname(options.outfile), { recursive: true, force: true })
}
最后执行:
require('esbuild').build(options)
.then(() => {
copyFolder(outputFolder, targetFolder);
})
.then(() => {
if (watchMode)
console.log('Watching...');
else {
console.log('Build finished.');
process.exit(0);
}
});
build.js写完了,接下来修改package.json:
...
"scripts": {
"build-product": "cross-env NODE_ENV=production node build.js",
"build": "node build.js",
"watch": "node build.js watch"
},
...
记得把cross-env装一下:
npm install cross-env --save-dev
随便写点东西,运行npm run watch
或npm run build
,可以看到打好包的bundle.js:
记得修改执行处的文件名:
JsManager.cs
private void Awake()
{
jsEnv ??= new JsEnv(new DefaultLoader());
jsEnv.Eval("require('bundle');");
}
调试
调试可以参考官方文档,按文档配置一遍,Unity中运行后,再在vscode中启动调试器即可。这里记录一些我在瞎搞过程中遇到的问题。
调试器连不上
检查launch.json中的端口是否与C#代码中的一致,并且端口未被占用,OnDestroy中需要调用jsEnv.Dispose()销毁,避免退出运行后端口依然处于占用状态。
断点无效
断点为灰色,并提示“Unbound breakpoint”:
这种情况一般是source map出了问题,可以从这几个方面检查:
- tsconfig.json里有没有开启source map
- 打包代码(build.js)里有没有开启source map
- source map文件生成了没有
- source map文件中的源文件路径是否正确(一般没问题)
- C#中是否指定了正确的js输出目录
- 加载Resources子目录下的js文件时,js输出目录要保持同样的结构
对于第六点,比如js文件不是拷贝到Resources根目录,而是拷贝到Resources/tsbuild目录中:
jsEnv.Eval("require('tsbuild/bundle');");
那么需要让输出目录也保持这个结构:
不过一般不会直接从Resources下加载,用Addressable或AssetBundle的情况比较多。
如果出现程序运行得太快,有些断点没进的情况,并已使用了jsEnv的等待调试器连接,那么可以尝试在launch.json中开启pauseForSourceMap。
Source Map Support
在index.ts中报个异常试试:
JSON.parse('aa');
并不能追踪到源码的报错位置:
在官方faq文档中有解决方法,使用source-map-support。通常只需要require之后install就行,但由于source-map-support是一个nodejs模块,它引用到了node的path与fs,其他js引擎中没有这两个模块,所以需要按照文档中将它们改为C# System.IO的实现。
如果按文档做了一遍还是不行的话,可以尝试修改source map文件的获取过程,在install中加入自定义的处理逻辑:
// require('source-map-support').install();
require('source-map-support').install({
retrieveSourceMap: function (source: string) {
if (source.endsWith('bundle.js')) {
let mapFile = csharp.System.IO.Path.Combine(csharp.UnityEngine.Application.dataPath, '../TsProject/output/bundle.js.map');
if (csharp.System.IO.File.Exists(mapFile)) {
return {
url: source,
map: csharp.System.IO.File.ReadAllText(mapFile)
};
}
}
return null;
}
});
可以追踪到报错位置:
FairyGUI
FairyGUI官方有Puerts的使用说明,按文档搞就完事了。这里主要介绍一个FairyGUI Puerts插件,可以直接生成TypeScript的UI代码,喜欢的话请给作者一个Star。
首先按官方的使用说明在Unity中安装FairyGUI SDK,并做好相关配置,然后随便建个UI工程,目录与Assets、TsProject同级:
将插件仓库克隆到UiProject下的plugins目录,重启FairyGUI编辑器,可以看到新增的插件:
发布设置中设置发布路径:
包设置中记得勾选“为本包生成代码”:
发布即可看到生成的UI代码:
生成的UI代码放在发布路径的包名文件夹下,比如这里包名为DefaultPackage。
然后就可以使用了:
index.ts
import { FairyGUI } from 'csharp';
import UI_Main from './src/gen/ui/DefaultPackage/UI_Main';
import { bind } from './src/gen/ui/DefaultPackage/fairygui';
// 加载包
FairyGUI.UIPackage.AddPackage('fgui/DefaultPackage');
// 继承生成的组件类
class UIMain extends UI_Main {
protected override onConstruct(): void {
super.onConstruct();
this.m_guguButton.onClick.Add(() => {
this.m_guguText.text += '咕';
});
}
}
// 绑定到FairyGUI
bind(UIMain);
// 创建实例
let uiMain = UIMain.createInstance();
// 设置设计分辨率
FairyGUI.GRoot.inst.SetContentScaleFactor(800, 600);
// 添加到UI
FairyGUI.GRoot.inst.AddChild(uiMain);
这里定义一个子类UIMain继承生成的UI_Main,在点击按钮时添加一个”咕“。
运行效果:
生成的代码是如何工作的?
fairygui.ts中提供了一个bind函数,调用FairyGUI提供的API将传入的ts类扩展为组件,并将C#侧会调用的__onConstruct等方法绑定到ts类的对应方法上:
XXXBinder.ts中将所有ts组件类绑定,这里没有用到这个类,而是手动调用bind绑定。
UI_Main.ts的onConstruct中,获取了所有子组件,所以可以直接使用:
个人认为createInstance中的as T有点可疑,毕竟ts中的as只是类型断言,不像C#中有类型转换的功能,这里仅起到类型检查的作用。实际测试中,如果bind父类UI_Main而非子类UIMain,UIMain.createInstance实际返回的依然是父类UI_Main的对象,自然也不会执行子类的方法。总之如果有扩展子类,那么记得手动bind一下子类。