コンテンツへスキップ

【Unity】TypeScript Importer – TypeScriptを用いたスクリプトの記述

  • Unity

Unity向けにTypeScript Importerというライブラリを新しくリリースしました!今回もすでにGithubに上がってます。

これは何かというと、Unity上でTypeScriptをコンパイルするためのエディタ拡張です。Unityに.tsファイルを追加・編集するだけで、自動的にJavaScriptへのトランスパイル→ScripableObjectの作成を行なってくれます!

また、TypeScriptToLuaにも対応していて、.tsをJavaScriptではなくLuaにトランスパイルすることも可能です。

……と言ってみたものの、この説明だけでは「何でUnityにTypeScript?」という感じでしょう。というわけで、実際これが何の役に立つのか、一つずつ解説していきます。

スクリプト言語の選択肢

Unityで、C#とは別に何らかのスクリプト言語を使いたいシチュエーションはよくあります。例えばノベルゲームなどでシナリオを書いていく場合、演出を組み込んだり複雑な分岐を扱うにはエディタ拡張だけで何とかするよりも、サクッと何らかの言語で記述できたほうが便利でしょう。

そして、このようにアプリケーション内に組み込む用途の言語としては今なおLuaがほぼ一強という状況です。Luaの最大の特長は軽量さと簡潔な仕様による組み込みの技術的なハードルの低さで、この手の用途では最も広く用いられている言語でしょう。実際、スクリプターがLuaで演出を作るような場面は多いのではないでしょうか。UnityではxLuaやMoonSharpが利用できるほか、私もLua-CSharpというC#実装のLua処理系を公開しています。

また、Lua以外ではmrubyという選択肢もあります。これはRubyの作者であるMatz氏によって開発された、組み込み特化の軽量なRuby実装です。最近ではVContainerの作者であるハダシAさんがVitalRouter.MRubyを提供していたりして、Unityでの組み込みも比較的容易に行えるようになりました。

また、Squirrelが使われている例も時々見かけます。これはLuaに似た言語仕様を持つ言語ですが、文法面はC寄りになっています。実装はC++で、Lua並にコンパクトになっているので組み込みも容易です。以前スクエア・エニックスが自社タイトルにこれを用いたことで話題になりました。

Haxeという選択肢もあるでしょう。これはC#やJavaに似た文法を持つ言語で、専用のHashLink(Nekoの後継)というVMが存在するほか、様々な言語へのトランスパイルをサポートしています。Haxeはポケットモンスター ソード・シールドのライセンス表記で名前が見られる(Luaにトランスパイルして利用しているらしい?)ほか、Dead Cellsなどでも採用されています。

他にも、C#実装ではJintなどのJavaScriptインタプリタや、MiniScriptというLua-likeな言語、NaninovelというUnity向けのアセットではNaniScriptという独自の言語が用いられていたりと、さまざまな選択肢が存在します。最近ではるっちょさんによって開発されたWaaSというwasmインタプリタが登場したりもしています。

とまあこのような感じで、現在では様々な言語が選択肢として挙げられるようになっています。スクリプト言語としてはやはりLuaが一番オーソドックスな選択肢にも思えますが、あえてそれ以外の言語を選ぶという選択も十分アリではあるでしょう。

動的型付け vs 静的型付け

そして、前項であげた言語のほとんどは動的型付けとなっています。LuaやRubyはもちろん、Squirrel、JavaScript、MiniScriptあたりも全て動的型付け言語です。

このようにスクリプト言語では動的型付けが主流となっています。動的型付けは文法面が簡潔になりやすいほか、型に縛られない柔軟性の高さはスクリプト言語においては利点となり得ます。また、動的型付け言語は静的型付け言語に比べて実装が単純な傾向にあり、それゆえ組み込みが比較的容易であることもその理由の一つでしょう。

一方、大規模な開発で静的型付け言語が有用なのは大多数の人が認めるところです。型がドキュメントになる安全性・保守性の高さや、コンパイル時点で型が解析可能であることからIDEの強力なサポートを享受しやすいという点は、コードをメンテナンスをしていく上で重要です。

とはいえ、静的型付け言語をスクリプト言語として採用するには流石に重すぎるのでは、という意見はもっともです。例えばC#でもRoslynを使えばスクリプト言語的な用途も実現できるのですが、さすがにLuaで書くようなコードまでC#で書きたいとはあまり思わないでしょう。(そもそもRoslyn ScriptingがAOTセーフでない・パフォーマンスが悪いから使いたくないだけ、というのはありますが)

一方、動的型付け言語はどうしても型が実行時に決定される性質上、静的型付け言語と比べるとIDEのサポートが得づらいです。特にLuaは(LuaLSなどがない場合は)型注釈が存在しないため、複雑なコードを書くのには向いていません。

このように静的型付け・動的型付けは一長一短であり、その用途に応じてどちらが有用かであるかは異なります。ただ、あくまでスクリプト言語としての用途に限定すると、動的型付け言語の軽さがスクリプトの記述に向いているのは間違いないと思います。

スクリプト言語としてのTypeScript

しかし、静的型付け言語派の人間としてはスクリプト言語にも最低限の型は欲しいかなーという気はしています。ていうかとにかくLuaを書くのが辛すぎる。言語仕様が貧弱すぎて、IDEの支援が全然効かない。まだ1ファイルの.luaで完結してるようなコードならいいんですが、ホスト言語側(今回はUnityなのでC#)で定義された関数を持ち込むような形だったり、コードが複数ファイルに分割されていたりする場合はかなり辛いものがあります。また、文法面であまり簡潔とは言い難いのも気になるところです。

そこで「静的型付けで、かつスクリプト言語として運用可能な軽量さ・簡潔な文法を持つ」という条件で様々な言語を検討した結果、良さげだと感じたのがTypeScriptでした。「JavaScript + 静的型付け」というコンセプトから想起される通りの素直な文法で、IDEでの補完も完璧に効くので書き心地も快適です。例えば、以下はシナリオをスクリプトで記述する際のLuaとTypeScriptによる比較です。分岐周りがやや複雑に見えますが、swichtが使えること、IDEで補完が効くことなどを考えれば悪くはないんじゃないでしょうか。

-- Lua
campanella.talk("川の遠くを飛んでいたって、ぼくはきっと見える。")
campanella.look_around()
campanella.talk("この地図はどこで買ったの。黒曜石でできてるねえ。")

local c = choose({
  "銀河ステーションで、もらったんだ。君もらわなかったの。",
  "さあ、よくわからない。"
})

if c == 0 then
  campanella.talk("ああ、ぼく銀河ステーションを通ったろうか。")
elif c == 1 then
  campanella.talk("三次空間から持ってきたんだろうか。")
end

campanella.talk("いまぼくたちの居るとこ、ここだろう。")
// TypeScript
campanella.talk("川の遠くを飛んでいたって、ぼくはきっと見える。")
campanella.lookAround()
campanella.talk("この地図はどこで買ったの。黒曜石でできてるねえ。")

const c = choose([
  "銀河ステーションで、もらったんだ。君もらわなかったの。",
  "さあ、よくわからない。"
])

switch (c) {
  case 0:
    campanella.talk("ああ、ぼく銀河ステーションを通ったろうか。")
    break
  case 1:
    campanella.talk("三次空間から持ってきたんだろうか。")
    break
}

campanella.talk("いまぼくたちの居るとこ、ここだろう。")

また、環境構築もNode.jsとTypeScriptを入れて、プロジェクト内にtsconfig.jsonを置けばそれだけで動作します。TypeScript Importerではランタイムでの実行はJavaScriptやLuaのインタプリタを使う想定なので、エディタでコンパイルが動作すれば十分です。

TypeScriptToLuaの存在もTypeScriptを選択する理由の一つでした。C#実装のJavaScriptインタプリタは非常に少なく、UnityにJavaScriptを組み込もうとすると実質的な選択肢がJint一択になってしまいます。バインディングで他言語の実装を持ち込んでも良いですが、マルチプラットフォーム対応を考えるとかなり大変でしょう。

一方Luaであれば様々な実装が存在しているので、都度最適なライブラリを選択できます。また、Haxeなどは互換性の理由から非常に複雑なLuaコードを生成してきますが、TypeScriptToLuaはTypeScriptから素直で分かりやすいLuaコードを生成してくれるのが魅力です。これを利用することで「スクリプトはTypeScriptで書きつつ、実行自体はLuaインタプリタで行う」という運用がかなり現実的になります。

また、型定義ファイルの存在も利点の一つです。これはJavaScriptで記述されたライブラリなどに外部から型をつけられるもので、以下のように型定義だけを記述することが可能です。

// example.d.ts

interface Person {
  firstName: string;
  lastName: string;
}

declare function greeter(person: Person): string;

これを用いることで、ホスト言語側で定義した関数に型をつけることができます。例えば、エンジニアがC#実装とTypeScriptの型定義/ドキュメントコメントを記述し、それを参照しつつスクリプターがスクリプトを書くという分担も可能になるわけです。これはかなり嬉しいポイントじゃないでしょうか。

TypeScript Importer

これでようやく、UnityにTypeScriptを持ち込むだけの利点があることを理解していただけだけたでしょうか。長くなりましたが、ようやくTypeScript Importerの解説です。

TypeScriptを持ち込む上での最大の障壁は、あらかじめコンパイル(トランスパイル)を行う必要があることです。流石にスクリプトを書き直すたびに手動でコンパイルして…とかしてたらやってられないので、最低限このプロセスは自動化する必要があります。

この辺りの自動化を行うのがTypeScript Importerの役割です。UnityのAssetPostProcessorとScriptedImporterを用いて、.tsファイルを自動的にTypeScriptAssetというScriptableObjectに変換します。これにはオリジナルのソースコードとトランスパイル済みのJavaScriptコードを含んでおり、SerializeFieldやResources、Addressablesなどからアクセスできます。

あくまでTypeScript Importerの役割はコンパイルの自動化であり、JavaScriptのインタプリタは含まれていないので、適宜NugetForUnity等でJintなどのライブラリを追加してください。以下はJintを用いてTypeScriptを実行するサンプルです。

using System;
using UnityEngine;
using TypeScriptImporter;
using Jint;

public class Example : MonoBehaviour
{
    [SerializeField] TypeScriptAsset script;

    void Start()
    {
        // JavaScriptの実行エンジンを作成
        var engine = new Engine();

        // TypeScriptAssetからJavaScriptのコードを取得し、実行する
        engine.Execute(script.JavaScriptSource);
    }
}

JavaScriptではなくLuaを使いたい場合は、追加でTypeScriptImporter.LuaパッケージとTypeScirptToLuaが必要になります。これらを追加するとInspectorからImporterを切り替えられるようになるので、これをTS2LuaImporterに変更します。この場合はTypeScriptToLuaAssetになるので、それからLuaコードにアクセスできます。以下はLua-CSharpを用いたサンプルです。

using System;
using UnityEngine;
using TypeScriptImporter;
using Lua;

public class Example : MonoBehaviour
{
    [SerializeField] TypeScriptToLuaAsset script;

    async void Start()
    {
        // Luaの実行環境を作成
        var state = LuaState.Create();

        // TypeScriptToLuaAssetからLuaのコードを取得し、実行する
        await state.DoStringAsync(script.LuaSource, cancellationToken: destroyCancellationToken);
    }
}

また、Project Settingsから利用するtsconfig.jsonを差し替えることも可能です。これは必須ではなく、指定がない場合はTypeScript Importerが一時的なtsconfig.jsonをコンパイル時に作成し、終了後に自動で削除されます。

使い方に関する詳細はREADMEを参照してください。

vs WaaS RustImporter

元々このライブラリのアイデアや実装はWaaSのRust Importerから着想を得たものです。WaaS自体はwasmのインタプリタですが、UnityにRustを持ち込むための機能として、Rustからwasmへのコンパイルを自動化するRust Importerが搭載されています。

WaaSのコンセプトや実装はとても素晴らしく、wasmをUnity上で動かせるというロマンはあります。しかし、個人的にはスクリプト言語としてRustを持ち込むのはどうなのかなーという印象です。Rustはその制約の強さがネックで、型に関して厳格なチェックを要求するので、スクリプトを記述するにも冗長な記述が多くなってしまう気がします。また学習難易度の高さも問題で、特に所有権周りの理解をスクリプターに要求するのはキツいかな、と。

その点MoonBitは有用な選択肢になりそうですが、まだ安定感ないので今後に期待という感じですね。今年中くらいにはv1.0が出るらしい?

まとめ

スクリプト言語について色々と書いてきましたが、いかがでしょうか。あんまりフロントエンドは触らないのでTypeScriptはほとんど使ったことがなかったんですが、さすがC#作ってる人がデザインしてるだけあって、手触りや開発体験は最高です。静的型付けでありながら文法面も軽量で、スクリプト言語としてのポテンシャルも十分じゃないかという気がしています。

というわけでTypeScript Importer、ぜひ試してみてください!TypeScriptはいいぞ!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です