Unity + FirebaseでインタラクティブなVJシステムを作った話

# 前置き

最近、ライブでインタラクティブなVJシステムを採用しています。
エビフライを飼うことをテーマにした楽曲の時に、お客さんにスマホからエビフライを投げてもらうという演出です。
何を言っているんだ？？？という感じですが、こんな感じ。

https://www.instagram.com/reel/CcNMMcRBiln/?utm_source=ig_web_copy_link

# システム
ざっくりシステムを図にすると、こんな感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2522080/8120414b-4a9e-5912-f16f-c05950d2aa98.png)

Vue.jsで作ったWEBページで、Javascriptでスワイプを検出してFirebase Realtime Databaseに情報を送信。
PCで起動しているNode.jsサーバーでRealtime Databaseの更新通知を受け取り、Unityに送ります。
Unityでキャッチし、エビフライを描画。
Unityの出力映像をSyphonを介してVJソフトに送信、プロジェクターから出力という流れです。

実際のライブでは、WEBページにアクセスするためのQRコードを出して、お客さんに読み込んでもらいエビフライをぽいぽいします。

## WEBページ(Vue.js)側のコード
Vue.jsとFirebase realtime databaseのセットアップはこのあたりを参考に。


https://qiita.com/HyunwookPark/items/f0b7cd15b5b55849cf71


Mounted関数に下記の記述を行います。
スワイプを検出したら、描画している画像の種類とスワイプの速度をFirebaseに送信します。

エビフライの画像などはcanvasに描画していますが、そのあたりは割愛。
スワイプ検知とFirebaseへの送信部分だけ抜き出しますね。


```
    const canvas = this.$refs["canvas"] as any;

    //スワイプ検出
    let startX: number;
    let startY: number;
    let endX: number;
    let endY: number;
    let moveX: number;
    let moveY: number;
    let dist = 30;
    let speed: number;
    let time = 0;
    let animY: number;

    canvas.addEventListener("touchstart", (e: any) => {
      e.preventDefault();
      const touch = e.touches[0];
      const { clientX, clientY } = touch;
      startX = clientX;
      startY = clientY;
      time = 0;
    });


    //スワイプして動かしている間のアニメーション描画
    canvas.addEventListener("touchmove", (e: any) => {
      e.preventDefault();
      const touching = e.changedTouches[0];
      const { clientX, clientY } = touching;
      ctx.fillStyle = "rgba(5, 223, 252, 1)";
      ctx.fillRect(0, 0, wrapperWidth, wrapperHeight);
      ctx.drawImage(
        ebi,
        clientX - imageWidth / 2,
        clientY - imageHeight / 2,
        imageWidth,
        imageHeight
      );
      time += 1;
    });

    //タッチが終わったらFirebaseに送信します
    canvas.addEventListener("touchend", (e: any) => {
      const touchEnd = e.changedTouches[0];
      endX = touchEnd.clientX;
      endY = touchEnd.clientY;

      let distX = endX - startX;
      let distY = (endY - startY) * -1;

      speed = distY / time;

      if (distY > dist) {
        animY = endY;
        requestAnimationFrame(throwAnimation); //投げている途中のアニメーションを再生させる関数。今回は割愛

        //Firebaseに送信！！！
        const object = {
          item: "ebi",
          image: image,
          speed: speed,
        };
        const db = getDatabase();
        const dbRef = ref(db);
        set(child(dbRef, "ebis"), {
          object,
        });

        //最後に投げた戦績を表示させたいので投げた回数をlocalStrageに保存する
        this.throwCount += 1;
        localStorage.setItem("ebiThrowCount", String(this.throwCount));
      }
    });
```
template部分はこんな感じです。
```
<template>
  <div id="wrapper" class="wrapper" ref="wrapper">
    <canvas id="canvas" ref="canvas"></canvas>
  </div>
</template>

```

## Node.jsとUnityのセットアップ
こちらの記事を参考に、Node.jsサーバーとUnityをセットアップしました。

https://techblog.kayac.com/web-firebase-websocket-unity

## Unityのコード
上記記事のClientObject.csを目的に合わせて改変しています。
こんな感じ。
```
using UnityEngine;
using WebSocketSharp;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using System.Threading;

public class ClientObject : MonoBehaviour
{
    // 可動範囲をinspector側で指定できるように
    [SerializeField]
    private Vector2 movableRange = new Vector2(5, 5);
    public Sprite ebi_1;
    public Sprite ebi_2;
    public Sprite ebi_3;

    private WebSocket ws;
    private Vector3 position;
    private SpriteRenderer ebiImage;
    private GameObject ebiOriginal;



    // Start is called before the first frame update
    void Start()
    {
        ebiImage = gameObject.GetComponent<SpriteRenderer>();
        ebiOriginal = gameObject;

        //  WebSocketのインスタンスを生成。ポートはNode.js側のWebSocketで開いたポートに合わせる
        ws = new WebSocket("ws://localhost:8080");
        // 接続が開いたときのイベント
        ws.OnOpen += (sender, e) =>
        {
            Debug.Log("open");
        };
        var context = SynchronizationContext.Current;

        // 通知があったときのイベント。e.Dataで送られてきたデータを取得できる。
        ws.OnMessage += (sender, e) =>
        {
            var data = e.Data;
            // [x座標, y座標]の配列になる
            string[] objectInfo = data.Split(',');
            string imageInfo = objectInfo[0].Split('.')[0];
            float speed = float.Parse(objectInfo[1]);
            Debug.Log(imageInfo);


            context.Post(state => {
              changeEbi(imageInfo,speed);
            }, e.Data);
        };
        // エラー時のイベント
        ws.OnError += (sender, e) =>
        {
            Debug.Log("error: " + e.Message);
        };
        // 接続が閉じたときのイベント
        ws.OnClose += (sender, e) =>
        {
            Debug.Log("close");
        };
        // 接続開始
        ws.Connect();
    }

    void changeEbi(string imageInfo,float speed) {
      float ebiX = Random.Range(-8.0f,8.0f);
      float ebiY = Random.Range(2.0f,4.0f);
      var ebiPos = new Vector3(ebiX,ebiY,0);
      var ebiRotate = Quaternion.Euler(0f,0f,0f);

      //エビフライを送信されてきた画像に応じたspriteで複製
      GameObject ebiCopy = Instantiate(
      ebiOriginal,ebiPos,ebiRotate);
      GameObject.Destroy(ebiCopy.GetComponent<ClientObject>());
      ebiCopy.tag = "Clone";
      var Force = new Vector2(0f,speed*-20);
      var scale =  new Vector3(0.25f,0.25f,0.25f);
      ebiCopy.GetComponent<Rigidbody2D>().AddForce(Force);
      ebiCopy.GetComponent<RectTransform>().Rotate(0f,0f,0f);
      ebiCopy.GetComponent<RectTransform>().localScale = scale;
      switch(imageInfo){
        case "ebi01":
          ebiCopy.GetComponent<SpriteRenderer>().sprite = ebi_1;
        break;
        case "ebi02":
          ebiCopy.GetComponent<SpriteRenderer>().sprite = ebi_2;
        break;
        case "ebi03":
          ebiCopy.GetComponent<SpriteRenderer>().sprite = ebi_3;
        break;
      }
    }

    // Update is called once per frame
    void Update()
    {
    }

    void OnDestroy()
    {
    }
}
```

ついでに、エビフライが投げられ続けると画面が破綻してしまうので、任意の回数他のエビフライとぶつかると爆散するように設定しました。
下記スクリプトをエビフライのコピーもとオブジェクトにアタッチします。
Tweenというライブラリを使って、他のエビフライとぶつかったけどまだ爆散しないときのアニメーションをつけています。
```
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using DG.Tweening;

public class Spark : MonoBehaviour
{
    private int counter;
    private ParticleSystem particle;
    private ParticleSystem explosion;
    private RectTransform RectTransform;
    public GameObject explosionObject;
    // Start is called before the first frame update
    void Start()
    {
      counter =  0;

      //他のエビフライとぶつかったときに再生するパーティクル
      particle = gameObject.GetComponent<ParticleSystem>();

      //爆発する時に再生するParticle
      explosion = explosionObject.GetComponent<ParticleSystem>();
      RectTransform = gameObject.GetComponent<RectTransform>();

    }

    // Update is called once per frame
    void Update()
    {

    }
    void OnCollisionEnter2D(Collision2D collision)
    {
        counter+= 1;
    
        //15回ぶつかると爆散
        if(counter == 15 && gameObject.CompareTag("Clone")) {
          var ebiPos =  gameObject.transform.position;
          explosion.transform.position = ebiPos;
          explosion.Play();
          Destroy(gameObject);
        }
        else {
          var shake = new Vector3(0.2f,0.2f,0.2f);
          gameObject.transform.DOShakePosition(0.5f, shake, 3, 10f, false,true).OnComplete(OnTweenComplete);
          particle.Play();
        }
    }
    private void OnTweenComplete(){
        RectTransform.Rotate(0f,0f,0f);
        RectTransform.localScale = new Vector3(0.25f,0.25f,0.25f);
    }
}

```

## UnityをSyphonに繋げる
こちらのプラグインを使います。

https://github.com/keijiro/KlakSyphon

インストールしたら、Main CameraにSyphon Serverをアタッチします。
そうするとVDMX側にUnityのカメラ画像を送れるので、任意の映像の上でエビフライをぽいぽいしたりエフェクトかけたりできるという算段です。

# おわり
ライブにインタラクティブな要素があるとやっぱり盛り上がります。
今回は割愛しましたが、Firebaseの方に「受付中」「受付終了」のフラグを持たせておき、「終了」ならlocalStorageに保存しておいた戦績を表示する、ということもしてあります。
今後は投げ銭と繋げて、なかなか消えないエビフライや自分のアイコンをつけたエビフライを投げられるようにするアプデを画策中です。
決済システムは扱える自信ないので、アナログな方法になるかと思いますが…

同時に投げまくると大変なことになるのをなんとか解決すれば、音声でお客さんがライブに絡めるようにするのも面白いだろうなと思っています。
任意の歓声を投げるとか…

ある程度プログラミングの素養があればまるっとパクれるシステムだとは思っていますが、インディーズアーティストの中で最初にやったのは私だ、ということを主張したくQiitaに書き残しました。

みなさまも、よきプログラミングインディーズミュージシャンライフを！！！