[VRChat]Udonを使ってテトリスを作った話

本記事は [VRChat Advent Calendar 2021](https://adventar.org/calendars/6466) の8日目の記事です。
前回はaki_lua87さんの「[VRChat + AWS Serverless で何かやりたかった話](https://blog.akakitune87.net/posts/vrc-adcal-2021/)」でした。

# はじめに

VRChatのSDK3とUdonSharpを利用してテトリスワールドを作ってみました。せっかくなので開発にあたっての知見をまとめたいと思います。
(主にUdon/UdonSharp関連についてでテトリス自体の実装については省略します)

ソースコードはこちら(有料アセット類は除外してあります)
https://github.com/tar-bin/udoris-core

こちらのURLから実際に動作するVRChatワールドにいけます。
https://vrchat.com/home/launch?worldId=wrld_c9135512-eded-4272-bf0c-b8afc51b9629

# 開発環境について
以下を利用しています。実際のワールドは演出用にその他アセットがありますが必須なのは以下のみです。

+ Unity 2019.4.31f1
+ [VRChat SDK3 Worlds](https://vrchat.com/home/download)
+ [Udon Sharp](https://github.com/MerlinVR/UdonSharp)

# テトリスのルール
実装しているテトリスのルールと参考リンクのまとめです。
テトリスの細かいルールはシリーズによって差異があるのでいい感じにピックアップして採用しています。

+ 全般
    + https://harddrop.com/wiki/Tetris_Wiki
+ Score
    + https://tetris.wiki/Scoring
+ T-Spin
    + https://harddrop.com/wiki/T-Spin_Guide
+ Back to Back
    + https://harddrop.com/wiki/Back-to-Back
+ Super Rotation System (SRS) : 回転の仕様
    + https://harddrop.com/wiki/SRS
    + https://tetris.wiki/Super_Rotation_System
+ Gravity : レベルごとの落下速度の仕様
    + https://harddrop.com/wiki/Tetris_Worlds#Gravity
+ Delayed Auto Shift (DAS) : 移動キーを長押ししたときの仕様
    + https://nestetrisjp.github.io/das-introduction/
+ Initial Rotation System (IRS)
+ Temporary Landing System (TLS) / Ghost

その他細かい仕様

+ ゲーム開始時に「Z字形」「S字形」「四角形」のテトリミノが来ないようにする
+ ピースの抽選は完全ランダムではなく7つセットで重複がこないようにする
+ 16レベル以上ではゴーストを非表示

ここらへんの仕様については[phiさん](https://twitter.com/phi16_)にかなりお世話になりました。ありがとうございました。

# 実装について
ちょっとテトリスの作り方の解説をしてると長くなってしまうのでソースコードを参照してください・・・。
https://github.com/tar-bin/udoris-core/blob/master/Assets/USharpTetris/Script/TetrisBlockController.cs

見てもらうとわかると思いますが、このあとに記載するUdon/UdonSharpの制限により独特な書き方になってたりします。

# UdonSharpの困りごとと対策
Udon(UdonSharp)にはC#と比べるとまだ色々と制限があります(2021/12/02時点)。
今回困った点としては以下などが挙げられます。

+ Listが使えない
+ 配列の初期化子が使えない
+ Enumが使えない
+ クラス/構造体が使えない
+ ローカルのファイルにアクセスできない
+ 同期するデータのサイズに制限がある

## Listが使えない
Listは使えません。多次元配列(`int[,]`)も使えません。
ジャグ配列(`int[][]`)はいけるのでこれを使いましょう。

## ジャグ配列の初期化子が使えない
以下のような初期化ができません。これはそのうちできるようになるかも？

```C#
int[][] jaggedArray3 =
{
    new int[] { 1, 3, 5, 7, 9 },
    new int[] { 0, 2, 4, 6 },
    new int[] { 11, 22 }
};
```

今回は頻出するので初期化用のメソッド作りました。

```C#
    private int[][] CreateIntArrayMxN(int m, int n) {
        var data = new int[m][];
        for (var i = 0; i < m; i++) {
            data[i] = new int[n];
        }
        return data;
    }
```

## Enumが使えない
使えません。愚直にprivate constでも定義しましょう。

```C#
    private const int Angle0 = 0;
    private const int Angle90 = 1;
    private const int Angle180 = 2;
    private const int Angle270 = 3;
```

## クラス/構造体が使えない
使えません。ただジャグ配列は使えるので実質的には構造体は定義できます。
あとはその構造体を扱うメソッド群を定義してやればいいんじゃないかなと思います。

```C#
    // ==============================
    // Pieceクラスの定義
    // ==============================

    private int[][][] CreatePiece(int type, int[][] data, int[][] preview) {
        var piece = new int[3][][];
        piece[0] = new int[3][];
        piece[0][0] = new int[5];
        //type
        piece[0][0][0] = type;
        //angle
        piece[0][0][1] = Angle0;
        //data position
        piece[0][0][2] = PositionSpawnX;
        piece[0][0][3] = PositionSpawnY;
        //ghost position Y
        piece[0][0][4] = PositionSpawnY;
        //data
        piece[1] = data;
        //preview
        piece[2] = preview;
        return piece;
    }

    private int[][] GetPieceData(int[][][] piece) {
        return piece[1];
    }

    private void SetPieceData(int[][][] piece, int[][] data) {
        piece[1] = data;
    }

    private int[][] GetPiecePreview(int[][][] piece) {
        return piece[2];
    }

    private int GetPieceType(int[][][] piece) {
        return piece[0][0][0];
    }

    private int GetPieceAngle(int[][][] piece) {
        return piece[0][0][1];
    }

    private void SetPieceAngle(int[][][] piece, int angle) {
        piece[0][0][1] = angle;
    }

    private int GetPiecePosX(int[][][] piece) {
        return piece[0][0][2];
    }

    private void SetPiecePosX(int[][][] piece, int pos) {
        piece[0][0][2] = pos;
    }

    private int GetPiecePosY(int[][][] piece) {
        return piece[0][0][3];
    }

    private void SetPiecePosY(int[][][] piece, int pos) {
        piece[0][0][3] = pos;
    }

    private int GetPieceGhostPosY(int[][][] piece) {
        return piece[0][0][4];
    }

    private void SetPieceGhostPosY(int[][][] piece, int pos) {
        piece[0][0][4] = pos;
    }

    private int GetPieceSize(int[][] data) {
        return data.Length;
    }
```

## ローカルのリソースにアクセスできない
Udonからプロジェクトのファイルにはアクセスできません。
これで何がこまるかというと、TextureとかMaterialとかを差し替えたいときにどうするかという問題が出てきます。
今回はピースの色の表現でMaterialを動的に差し替えたかったので困りました。

ただこの件の解決方法は単純で、Udonから既存のGameObjectにはアクセスできるので
差し替え用のMaterialを持つGameObject群をはじめから用意しておき、そこから引っ張ってくる方式としました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/174284/55aa867c-1816-65bd-0a8f-952e2f1af3ba.png)

HierarchyのData以下にMaterialを持つオブジェクト群を定義してあります。
あとはそれをインデックスでアクセスして、以下のような感じでsharedMaterialを取得してあげればいいかなと思います。

```C#
    private void InitMaterials() {
        _colorsCache = new Material[materials.transform.childCount];
        for (var i = 0; i < _colorsCache.Length; i++) {
            _colorsCache[i] = GetMaterial(i);
        }
    }

    private Material GetMaterial(int index) {
        return materials.transform.GetChild(index).GetComponent<MeshRenderer>().sharedMaterial;
    }

    private void SetMaterial(MeshRenderer render, int blockType) {
        if (render.sharedMaterial != _colorsCache[blockType]) {
            render.sharedMaterial = _colorsCache[blockType];
        }
    }
```

## 同期するデータのサイズに制限がある
他のユーザーのプレイ状況を同期したいなと思ったんですが、その際にUdonの変数の同期の制限に引っかかりました。

一度に同期できるstringの長さは126 charactersまでです。
https://docs.vrchat.com/docs/network-details#data-and-specs

プレイ状況を他プレイヤーに送りたいんですが少なくとも可視領域が20*10 = 200セルあり、このまま1セル1文字にBase64変換しても同期できません。
なので隣接する2セルを1文字に圧縮する方式としました。以下のような感じです。

```C#
var val = (val1 << 3) + val2;
temp += ConvertToBase64String(val);
```

セルは空白+テトリミノが7種類の8種類なので`000 ~ 111`で収まります。2セル分が`2^6`なのでBase64でぴったりですね。
これでデータサイズが半分の100文字になりました。26文字も余ったのでNextとかHoldとかその他も詰め込んだのが以下です。

```C#
    private string SerializeField(int[][] field) {
        //更新カウント
        if (_updatePreviewCount < 63) {
            _updatePreviewCount++;
        } else {
            //0は受信側の初期化用で送信側は1から始める
            _updatePreviewCount = 1;
        }

        var temp = ConvertToBase64String(_updatePreviewCount);

        //Next
        temp += ConvertToBase64String(GetPieceType(_nextPiece[0]));
        temp += ConvertToBase64String(GetPieceType(_nextPiece[1]));
        temp += ConvertToBase64String(GetPieceType(_nextPiece[2]));
        temp += ConvertToBase64String(GetPieceType(_nextPiece[3]));

        //Hold
        if (_holdPiece == null) {
            temp += ConvertToBase64String(0);
        } else {
            temp += ConvertToBase64String(GetPieceType(_holdPiece));
        }

        if (_state == StateGameOver) {
            temp += "&";
        }

        for (var i = 0; i < MaxY - 5; i++) {
            for (var j = 0; j < MaxX; j += 2) {
                var val1Ghost = false;
                var val2Ghost = false;
                var val1Delete = false;
                var val2Delete = false;

                var val1 = field[i + 5][j];
                if (val1 > 8 && val1 < 16) {
                    //Ghost
                    val1Ghost = true;
                    val1 -= 8;
                } else if (val1 > 15 && val1 < 23) {
                    //Delete
                    val1Delete = true;
                    val1 -= 15;
                }
                var val2 = field[i + 5][j + 1];
                if (val2 > 8 && val2 < 16) {
                    //Ghost
                    val2Ghost = true;
                    val2 -= 8;
                } else if (val2 > 15 && val2 < 23) {
                    //Delete
                    val2Delete = true;
                    val2 -= 15;
                }
                if (val1Ghost && !val2Ghost) {
                    temp += "!";
                }
                if (!val1Ghost && val2Ghost) {
                    temp += "@";
                }
                if (val1Ghost && val2Ghost) {
                    temp += "#";
                }
                if (val1Delete && !val2Delete) {
                    temp += "$";
                }
                if (!val1Delete && val2Delete) {
                    temp += "%";
                }
                if (val1Delete && val2Delete) {
                    temp += "^";
                }
                if (val1 == 8 || val2 == 8) {
                    val1 = (val1 == 8) ? 1 : 0;
                    val2 = (val2 == 8) ? 1 : 0;
                }
                var val = (val1 << 3) + val2;
                temp += ConvertToBase64String(val);
            }
        }
        return temp;
    }
```

あとは受け取ったプレビュー側でデシリアライズしてあげれば大丈夫でした。
https://github.com/tar-bin/udoris-core/blob/master/Assets/USharpTetris/Script/TetrisBlockPreviewController.cs

# おわりに
Udon/UdonSharpはまだ色々と制限がありますが、頑張ればテトリスぐらいは作れるよというお話でした。
今回のU#ソースは以下にMIT Lisenceで公開してありますのでよければ参考にしてみてください。改変して公開しても可です。

https://github.com/tar-bin/udoris-core

以上です。ありがとうございました。