Puerts を使って TypeScript で Unity する方法

こんなつぶやきを見つけて気になったので入門した。

https://twitter.com/kyubuns/status/1333618632576811011

どうやら中華人民共和国の大手企業テンセントが開発中の、
TypeScript で Unity や Unreal Engine などを使えるようにするライブラリらしい。

https://github.com/Tencent/puerts

ドキュメントが殆ど中国語で大変だったけど、とりあえず基本的な使い方が分かったので書く。

# プロジェクトのサンプル

今回学習するにあたり、
「このリポジトリをクローンして改造すれば、普通に１からやるよりはそれなりに楽」な
サンプルプロジェクトを作った。

https://github.com/katai5plate/example-puerts-typescript-unity

# 環境構築

## Unity 側の設定

1. Unity プロジェクトを用意する
2. `git clone https://github.com/Tencent/puerts.git` する
3. リポジトリの `puerts/unity/Assets/` の中身を Unity プロジェクトの `Assets` にコピー
4. **[このページ](https://github.com/Tencent/puerts/releases)** から `Plugins_Nodejs_***` を解凍し、中の `Plugins` をプロジェクトの `Assets/Plugins` に上書きする
5. **[このファイル](https://github.com/chexiongsheng/puerts_unity_demo/blob/master/Assets/Examples/Editor/PuertsConfig.cs)** を `Assets/Editor` に保存
6. Unity を開き、トップバー `Puerts` -> `Generate index.d.ts` をクリック。すると `Assets/Gen/Typing/csharp/index.d.ts` が生成される

## TypeScript 側の設定

1. `Assets` の親ディレクトリに `TypeScript` ディレクトリを作成  
(まあ別にどんな名前でもいいけど、今回は便宜上このように名づける)
2. **[このファイル](https://github.com/chexiongsheng/puerts_unity_demo/blob/master/TsProj/copyJsFile.js)** を `TypeScript` に保存
3. 後述の package.json と tsconfig.json を `TypeScript` に追加して `npm i`

### package.json

```json
{
  "scripts": {
    "watch-ts": "tsc -w -p tsconfig.json",
    "watch-cp": "nodemon --exec npm run postbuild",
    "postbuild": "node copyJsFile.js output ../Assets/Resources"
  },
  "nodemonConfig": {
    "watch": [
      "./output"
    ]
  },
  "dependencies": {
    "nodemon": "^2.0.15",
    "typescript": "^4.5.4"
  }
}
```

- `npm run watch-ts` を実行すると `TypeScript` 内の TS が変更される度に `TypeScript/output` に JS がコンパイルされる
- `npm run watch-cp` を実行すると `TypeScript/output` が変更される度に `Assets/Resources` に JS がコピーされる

### tsconfig.json

```json
{
  "compilerOptions": {
    "target": "esnext",
    "module": "commonjs",
    "baseUrl": ".",
    "paths": {
      "csharp": ["../Assets/Gen/Typing/csharp/index.d.ts"],
      "puerts": ["../Assets/Puerts/Typing/puerts/index.d.ts"]
    },
    "outDir": "output"
  }
}
```

- 最低限の設定。欲しい機能は適宜追加すること。

# 簡単な使い方

## TypeScript 側の作業

上述の環境構築を終えたら、まず `TypeScript` ディレクトリ自体を VSCode で開く。
そしてターミナルを２つ開いて `npm run watch-ts` と `npm run watch-cp` をそれぞれ起動する。

次に、 `TypeScript/hello.ts` を作り、以下をコピペ。

```ts
import { UnityEngine } from "csharp";
import world from "./world";

console.log("母が恋しいか");
UnityEngine.Debug.Log("じっと我慢の子であった。");
console.log(world());
```

今度は `TypeScript/world.ts` を作り、以下をコピペ。

```ts
export default () => "ボン" + "ボンカレー♪".repeat(2);
```

上手くいけば、`Assets/Resources` に `hello.js.txt` と `world.js.txt` が作られているはず。

## Unity 側の作業

適当な GameObject を作り、`Add Component` -> `New Script` で `hoge.cs` を作成。

以下のように書き換える

```diff
  using System.Collections;
  using System.Collections.Generic;
  using UnityEngine;
+ using Puerts;

  public class hoge : MonoBehaviour
  {
+     JsEnv jsEnv;
 
      // Start is called before the first frame update
      void Start()
      {
+         jsEnv = new JsEnv();
+         jsEnv.Eval("require('hello')");
      }

      // Update is called once per frame
      void Update()
      {
          
      }
+
+     void OnDestroy()
+     {
+         jsEnv.Dispose();
+     }
  }
```

この `require('hello')` が、要するに `hello.js.txt` を呼び出すという意味になる。

さあ、Unity で実行してみよう。
Console タブに、以下のようなログが出ていれば成功。

```
[XX:XX:XX] 母が恋しいか
UnityEngine.Debug.Log (object)

[XX:XX:XX] じっと我慢の子であった。
UnityEngine.Debug.Log (object)

[XX:XX:XX] ボンボンカレー♪ボンカレー♪
UnityEngine.Debug.Log (object)
```

[ちな元ネタ](https://youtu.be/UYqyaIE7oZ4)

# MonoBehaviour 自体を操作したい場合

TypeScript で Start も Update も OnCollisionEnter も書きたいときの方法。

これに関しては、自分でそういうスクリプトを書いたので、
それを使ってもらったほうが早いと思う。

- **[Wrapper.ts](https://github.com/katai5plate/example-puerts-typescript-unity/blob/main/TypeScript/src/utils/Wrapper.ts)**
- **[JSLoader.cs](https://github.com/katai5plate/example-puerts-typescript-unity/blob/main/Assets/Scripts/JSLoader.cs)**

## 使い方

まず `TypeScript` に `Wrapper.ts` を置き、`Assets` に `JSLoader.cs` を置こう。

### TypeScript 側

書きたい処理が決まったら、適当な名前の TS ファイルを作る。
（今回は `hoge.ts` を作ったことにする。）

```ts
import { UnityEngine } from "csharp";
import Wrapper from "./Wrapper";

const { Vector3, Time } = UnityEngine;

export = (...args: unknown[]) =>
  new (class extends Wrapper() {
    constructor(...args: unknown[]) {
      super(args);
    }
    _start(): void {
      // ここに Start() 時の処理
    }
    _update(): void {
      // ここに Update() 時の処理

      // gameObject や transform には this.$ でアクセスできる。
      this.$.transform.Rotate(new Vector3(0, 100, 0));
    }
    // VSCode を使用しているなら、他のイベント関数のインテリセンスが効く。
  })(...args);
```

このように `hello.ts` を書くことによって、MonoBehaviour を操作可能なコードが出来上がる。
対応してるイベント関数は `JSLoader.cs` のソースコードを見ればわかると思う。

### Unity 側

先ほど作った TypeScript (`hoge.ts`) をアタッチしたい GameObject に、
`JSLoader.cs` をコンポーネントとして追加する。

`File Name` には `Assets/Resources` ディレクトリの中にある `*.js.txt` ファイルの「拡張子なしの名前」を入力。
もしディレクトリを挟む場合は `path/to/filename` というように指定することも可能。
（今回の場合は `hoge` と入力）

`Tick Timing` は、メインで使用するループ関数を指定。
（日本語化すると `更新` になっているものは、`Update` のことです）

`Props` (日本語化時は `プロパティー`) は、JSON 形式で JS 側に値を渡せる。
特に決まってなければ `{}` と入力。

何も問題が無ければ、再生しても問題なく動くはず。

### Props を設定する場合の書き方例

```ts
import Wrapper from "./Wrapper";

// Props を定義
interface Props {
  name: string;
  extCount?: number;
}

export = (...args: unknown[]) =>
  new (class extends Wrapper<Props>() { // <-
    constructor(...args: unknown[]) {
      super(args, {
        // デフォルトの Props を設定
        defaultProps: {
          name: "World",
        },
      });
    }
    _start(): void {
      console.log(
        // this.props からアクセス可能
        `Hello, ${this.props.name}${"!".repeat(this.props?.extCount || 1)}`
      );
    }
  })(...args);
```

```
<< Props が未指定のとき >>
[XX:XX:XX] Hello, World!
UnityEngine.Debug.Log (object)

<< Props が {"name": "悪魔博士"} のとき >>
[XX:XX:XX] Hello, 悪魔博士!
UnityEngine.Debug.Log (object)

<< Props が {"name": "(ここでコブラのテーマ) こんちは――――――――――っ", "extCount": 10 } のとき >>
[XX:XX:XX] Hello, (ここでコブラのテーマ) こんちは――――――――――っ!!!!!!!!!!
UnityEngine.Debug.Log (object)
```

# より詳しい使い方

公式ドキュメントを読もう。

- [公式マニュアル](https://github.com/Tencent/puerts/blob/master/doc/unity/manual.md)
- [Unity デモプロジェクト](https://github.com/chexiongsheng/puerts_unity_demo)
- [VSCode デバッグ方法](https://github.com/Tencent/puerts/blob/master/doc/unity/vscode_debug.md)
- [FAQ](https://github.com/Tencent/puerts/blob/master/doc/unity/faq.md)

# トラブルシューティング

個人的に詰まったところを書く。

## GetComponent ってどうすんの

型定義を見てみると `System.Type` を引数に入れろと書かれていて、
なんのこっちゃ分からんかったが、こうすればいいらしい。

```ts
import { UnityEngine } from "csharp";
import { $typeof } from "puerts";
const { MeshFilter, Component } = UnityEngine;

/* 以下、Wrapper のイベント関数内で */

// 自身の GameObject から MeshFilter コンポーネントを取得
const meshFilter = this.$.GetComponent($typeof(MeshFilter)) as MeshFilter;
// すべてのコンポーネントを取得
const allComponents = this.$.GetComponents($typeof(Component));
// その 3 番目を取得
console.log(allComponents.get_Item(2));
```

- [`GetComponents` で得た結果に `GetLength` を使いたいのに定義が見つからない場合はこちら](#明らかに型が足りない)

## JS 側に値を渡したい場合はどうすんの

[そう思って機能追加した。](#props-を設定する場合の書き方例)

## 明らかに型が足りない

Configure ファイルを新たに作る必要がある。
`Assets/Editor` ディレクトリに、正しい書式で書かれた `*.cs` ファイルを置けば動く。
すでにあるファイルを書き換えるもよし、設定ごとに分けて書くもよし。

[書き方はここを参照。](https://github.com/Tencent/puerts/blob/master/doc/unity/manual.md#2c标签介绍)

追加・編集したら、トップバー `Puerts` -> `Generate index.d.ts` をクリックして型定義ファイルを更新するのを忘れずに。

GetComponents で取得した複数のコンポーネントを処理するために、
`GetLength` などの関数が使いたかったので、新たに作ったファイル (`CustomCfg.cs`) はこんな感じ。

```cs
using System.Collections.Generic;
using Puerts;
using System;

[Configure]
public class CustomCfg
{
    [Binding]
    static IEnumerable<Type> Bindings
    {
        get
        {
            return new List<Type>()
            {
                typeof(System.Object),
                typeof(System.Array),
            };
        }
    }
}
```

オリジナルの型定義などもこれでいろいろ出来る模様。

## System.Array を JS の配列に変換したい

System.Array 型の配列はそのままだと map, filter, reduce などが使えない。
なので TypeScript で扱いやすい配列型に変換したい場合はどうすればいいのか。

こういう関数を作ればよい。

```ts
import { System, UnityEngine } from "csharp";
import { $typeof } from "puerts";

/** System.Array 型を JS で扱いやすい配列にする */
export const arrayToArray = <T>(array: System.Array): T[] =>
  [...new Array(array.GetLength(0)).keys()].map((i) => array.GetValue(i));

// 使用例: 自身の GameObject のすべてのコンポーネントの型名リストを取得
const types: System.Type[] = arrayToArray<UnityEngine.Component>(
  this.$.GetComponents($typeof(UnityEngine.Component))
).map((c) => c.GetType());
```

これで TypeScript でも LINQ のようなものができる。

## Generate Code で生成されたコードがエラってる

Generate Code すると処理が軽くなるらしいが、たまに生成コードにエラーが発生する。

直し方は不明。

ただ、単純に Wrap して動かすための関数を生成してるっぽいので、
エラってる関数をまるまる消してもあんまり動作に支障が出なさそう？
応急処置でそうしてみるといいかもしれない。

自信なし。