[Unity]Puerts for Unity使用笔记

记录一下普洱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 inittsc --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();
        }
    }
}

脚本挂到场景中,运行即可看到效果:

C__Users_OSoleMio_OneDrive_文档_Blog_Puerts_hello_world.png

打包与调试

打包

在冻手之前,先看看默认的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 watchnpm 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出了问题,可以从这几个方面检查:

  1. tsconfig.json里有没有开启source map
  2. 打包代码(build.js)里有没有开启source map
  3. source map文件生成了没有
  4. source map文件中的源文件路径是否正确(一般没问题)
  5. C#中是否指定了正确的js输出目录
  6. 加载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一下子类。

你可能感兴趣的:([Unity]Puerts for Unity使用笔记)