Unityを使って学園祭でVRスプラトゥーンを自作して展示した話


この記事は [Unity #3 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/unity3) ・ [mast Advent Calendar 2019](https://adventar.org/calendars/4325) の8日目の記事です！

ごめんなさい:bow_tone1:!!!3日も遅れてしまいました...(一番下の展示した話は*絶賛執筆中*

# TL;DR
11月の頭に学園祭で下のようなVRゲームを展示した。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">【5C棟511でプレイ！】<br><br>初めまして！<br>RE:REALでは雙峰祭内の芸術祭1日目2日目に5C棟511教室にてゲーム作品を展示します🎮<br>実際の511教室を、VR空間上に再現！教室で普段できないことができます✨<br><br>みんなのプレイ結果が <a href="https://twitter.com/hashtag/playrereal?src=hash&amp;ref_src=twsrc%5Etfw">#playrereal</a> にあがるのでチェックしてね✌︎ <br><br>↓開発中の映像( ∩&#39;-&#39;📷⊂ ) <a href="https://t.co/geJsZvXJJA">pic.twitter.com/geJsZvXJJA</a></p>&mdash; RE:REAL@雙峰祭5C511 (@rereal2019) <a href="https://twitter.com/rereal2019/status/1190170268808802305?ref_src=twsrc%5Etfw">November 1, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

その技術まとめログを残して置きたくまとめた。
Gitのコミットログをたどってトピックを時系列で書いてく。

# 構想
まずは、 **作りたいもの**・**テーマ**・**要件** を決めるところから始めた。
2019年7月の時点で、**作りたいもの**は「**ゲーム**」ということだけが決まっていた。
あとは、どんなテーマでどんなゲームを作るかを考えて行くだけであるが、そのまま夏休みを消化してしまった。

9月の終わり、「このままでは学園祭間に合わねぇ」となり、構想を急ピッチで固めることとなった。スマホやゲーム端末でどこでも簡単にゲームをできるからこそ、**学園祭の「その日」「その場所」でしかできないもの**を目指すことにした。

作るゲームは「VRスプラトゥーン(仮)」、その名の通りVRでスプラトゥーンをするというゲームである。
アイデアが思いついてしたのは、まずは**「先人はいないか」** **「二番煎じではないのか」**をサーベイしていくことに、そんな中見つけたのは以下のインターフェースである。

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/%E3%82%B9%E3%83%97%E3%83%A9%E3%83%88%E3%82%A5%E3%83%BC%E3%83%B3?src=hash&amp;ref_src=twsrc%5Etfw">#スプラトゥーン</a> の新しいインタフェース作った <a href="https://t.co/rqedtePWAY">pic.twitter.com/rqedtePWAY</a></p>&mdash; kougaku (@kougaku) <a href="https://twitter.com/kougaku/status/744777773454004225?ref_src=twsrc%5Etfw">June 20, 2016</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

ツイートにあるインターフェースの詳細は [こちら](http://kougaku-navi.net/Splatoon/)

これは**新しいインターフェース**であり、ゲームシステム自体は任天堂の端末で動いてるので、**ゲームシステムから自分で実装していけば、二番煎じならない** と踏み開発にとりかかることにした。

部屋が借りらたので、現実と全く同じようにモデリングした空間をステージにすることにした。
現実と **同じ見た目** の空間をステージにすることで、 **「その日」「その場所」でしかできない** という目指すべき点を満たした。同時に、メディアアートとしてのコンセプトが完成した。

*目に映っているのは現実であるが、実際には現実では無く*
*体験している本人からは、現実の物理的な世界でできないことが、*
*目に映っている現実と同じように見える情報的な世界で、実現できる*

あとは、実際に以下のような要件を考えていくことにした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/f41e526c-b639-3868-ce6a-072bab63914e.png)

ゲームを作りたいといっても、制作するゲームは展示のためで、プレイしてもらう以上、自己満足は避けるべきである。
開発において、自己満足ルートに入らないように、先に要件を練って決めておくのは大切だった。

<!--
そこで、ゲームに大事な点、**目的**・**達成感**・**Shareable**を意識して開発にとりかるために、
**目的**: 目的とは、ユーザーがゲームにおいてどのような行動を取ればよいのかという指針になるもので、基本的にはクリア条件などにあたる。スコアを競うゲームにおいては、より高いスコアを出す、対戦ゲームにおいては、勝つがあげられる。この目的が明確出
-->

# Unityで簡単お手軽VR
*2019/9/29*
`Oculus無ェ、Viveも無ェ、お金もそれほど持って無ェ` ですが、VRゲームを作っていく。Oculus RiftもGoもQuestもHTC Viveもありませんが、スマートフォンがあれば十分。

Unityの`Player Settings > (Android)XR Settings > Virtual Reality Supported`にチェックを入れて、下のリストにVRプラットフォームを追加するだけ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/675a5114-3609-f58d-299c-c4701fe8a961.png)
今回は、`Cardboard` を使いました。

これで、Unityのカメラに映っている空間が自動で左目用、右目用に分かれてレンダリングされ、カメラの向きがジャイロセンサーに追従するようになった。
![output00.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/38ec183c-117f-88d5-6807-7415fa013c89.gif)
↑ これはスマホの画面です。

*Google VR*
https://docs.unity3d.com/ja/current/Manual/googlevr_sdk_overview.html

# スマホ用のVRコントローラー
*2019/10/3*
基本、VRヘッドセットには専用のコントローラーがあるが、スマートフォンにはないのでスマートフォン用のVRコントローラーを探す(後々思えばDaydreamでも良かったなぁ)。Wiiリモコンだと持ちやすいし、傾きも取れるし、BluetoothでつながるがAndroid 8.0ではデータを取れない。そんな中ベストなソリューションとして見つけたのが、エレコムの [ヴルームSDK専用コントローラ](https://www.elecom.co.jp/products/JC-VRR02VBK.html)。[SDK](https://vroom.wonderleague.co.jp/sdk/)もあれば、Qiitaでの記事もある。これは買いだと思いAmazonでポチった。

*【Vroom SDK】5230円で構築したオレオレVR開発環境が割とよかった話*
https://qiita.com/drumath2237/items/479eda46afaadfb55ae4

*UnityでVroom開発*
https://qiita.com/TakaoWing/items/17758537d2a848f24191
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/ad576c23-70d1-3b03-624b-9e7e9bc8a98f.png)
[エレコム VR リモコン Bluetooth [VR/AR ヴルームSDK対応] ブラック JC-VRR02VBK](https://www.amazon.co.jp/gp/product/B076X4M7BH/)
※PRとかステマとかではなく **普通に良かった**

Unityへの導入は上のQiitaの記事を参考にした。
実際にAndroid端末でVroomコントローラーを使うのには、Androidの位置情報のパーミッションを許可する必要があった。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/c6032a44-1404-d9b4-dc95-1fedb038f2b7.png)

# 部屋の計測
*2019/10/15*
実際の部屋がステージなので、モデリングするために計測をした。サクサクっと部屋の計測をしたかったのでレーザー距離計を導入した。`1.測りたい起点にレーザー距離計を当てる` `2.ボタンを押してレーザーポイントが測りたい終点を確認` `3.もう一度ボタンを押すとディスプレイに距離がでる` これだけ部屋の大体の大きさがわかった。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/b6f5458e-03a3-6cdb-d757-ab3fbe8a51c8.png)
[BOSCH(ボッシュ) レーザー距離計 GLM30 【正規品】](https://www.amazon.co.jp/gp/product/B00IKIEBW2/)

大体の形で距離を測っておいて、測れない部分は全体の長さから調整して割り出した。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/8d6e28a9-0cf0-456f-a550-9f993a5bbde0.png)

# 部屋のモデリング
*2019/10/21*
`Autodesk Fusion 360`を使って、実際に測った寸法どおりにモデリングした。壁や床の色はテクスチャでつけるのでモデリングでは形だけを作っていく。**細かいパーツはとりあえず後回し**で、**80%でも良いからゲームをプレイできるよう**に、大まかなに部屋を作っていく。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/1fe30f00-3068-1560-5f5a-e337ccfcfdda.png)
※ 最初は窓も省略していた

# 素材の購入 
*2019/10/22*
概形のモデリングを終え、Unity内に壁と天井と床を作成したが、壁だけだとどうにも寂しい。教室の最低限の要素として、`黒板`と`蛍光灯`は必要ではありそう。だが、計測してないしモデリングの手間を考えると、教室のアセットを買うべきだと思って購入。このアセットから黒板と蛍光灯のモデルを拝借し、サンプルシーンから部屋のライティングがどうなっているかも確認した。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/ff3b4c1e-8be0-c369-d993-2a1422c149aa.png)
[Japanese School Classroom](https://assetstore.unity.com/packages/3d/environments/japanese-school-classroom-18392)

# ライティングがよくわからない
*2019/10/22*
**CGの華はやっぱりレンダリング！**
よりリアルを目指すなら[Physically based rendering](https://en.wikipedia.org/wiki/Physically_based_rendering)だけど、Unityでのレンダリングは初めてで右も左もわからない。レイトレーシングを自分で書いたことがあっても、UnityにはUnityのお作法があるので、下の記事を参考に部屋のライティングを作り込んでいく。(Unityでのレンダリングについて、他にも資料/本がありましたらコメントにお願いします。

*【Unity】入門ライティング設定！*
https://qiita.com/Nekomasu/items/8845d076c4356809f0ff

*【Unity】ライティングを理解するためにコーネルボックスを作って遊ぶ*
http://tsubakit1.hateblo.jp/entry/2016/03/14/230904

*【Unity】良い感じに見える（屋内向け）ライティングの設定手順*
http://tsubakit1.hateblo.jp/entry/2018/01/04/235520

*【Unity】"家具を動かせる" 部屋（ステージ）の見栄えを、出来る限り良い感じにする*
http://tsubakit1.hateblo.jp/entry/2018/01/17/001302

*Unityで、PBRなライティング環境をセットアップしてみよう*
http://techblog.sega.jp/entry/2019/04/25/100000

理想は、[Unity Japan Officeプロジェクト](http://aec.unity3d.jp/)
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">「Unity Japan Officeプロジェクト」早速DL。<a href="https://t.co/0IvHjiUQWV">https://t.co/0IvHjiUQWV</a><br><br>これはすごい。<br>ため息が出る美しさ。<br><br>しかもプロジェクトデータも公開されるとか！ <a href="https://t.co/nDhpL56xKK">pic.twitter.com/nDhpL56xKK</a></p>&mdash; 龍 lilea (@lileaLab) <a href="https://twitter.com/lileaLab/status/1148450927025672193?ref_src=twsrc%5Etfw">July 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

実際に作成してできたのはこんな感じの教室
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/d27c39ec-887f-1993-ed3c-a270af928f64.png)
まだまだ改善の余地はあるが、まだゲームのシステムには手をつけていないので作り込みはシステムができてから...

# InkPainterの導入
*2019/10/22*
スプラトゥーンはインクを飛ばしてステージを汚していくゲームですが、どうすればUnityで実装できるでしょうか...。Unityならアセットに頼って行くのがよさそうなのでアセットを探します。制作するゲームでは、[いーす](https://twitter.com/Es_Program) さんが作成した [InkPainter](http://esprog.hatenablog.com/entry/2017/04/12/203913) を使います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/db591e06-fb5c-6386-7c97-6f500bba3bdc.png)
[InkPainter](https://assetstore.unity.com/packages/tools/particles-effects/ink-painter-86210)

# InkPainterはテクスチャが設定されてないと塗れない
*2019/10/22*
インクを付けたい`Mesh`には`Mesh Collider`と`InkPainter.cs`アタッチする。`MousePainter.cs`を`Main Camera`にアタッチするとマウスでクリックしたところにインクが付く(マウスから直線上にRayを飛ばして、最初にオブジェクトと交差する交点にインクが付く
しかし、テクスチャが未設定だとインクが付かないので、`Albedo`にテクスチャをセットしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/53407bad-0708-a2fa-6229-234289bcf00e.png)

# InkPainterはモデルがUV展開されてないと塗れない
*2019/10/22*
ふぇ〜、壁をクリックした瞬間全部塗られてしまった...
この時点で「テクスチャがめちゃくちゃ小さくて、一瞬でテクスチャを全部塗りつぶしてしまった？」とか考えて、こねこねした。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/50476c1b-cd75-1abb-3cc9-7b7def37221a.png)
この記事をみるとどうやらモデルが**UV展開されていないと一回で塗り潰れる**と書いてある。
*石膏像にInk Painterでペイントする【Unity】*
http://bibinbaleo.hatenablog.com/entry/2017/12/10/164535

Autodesk Mayaを使ってUV展開した。この際、展開するUV座標の範囲は`X:0.0-1.0 Y:0.0-1.0`じゃないといけないので注意。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/afc751d3-a291-e80f-36cc-e5a5d5070bc9.png)

この時点の進捗
![output01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/7101f603-633d-84b9-edd1-8a6ac9d2360a.gif)

# モデルがRead/Write Enabledになってないと塗れない
*2019/10/25*
とりあえず、このままAndroid用にプロジェクトに書き出す。そうするとUnity Editor上では動作していたが、Android上では最初はインクを塗れなかった。

<!--
**エラー対処の基本は、ログをみるところから**、 `Development Build`にチェックを入れて`AndrdoidStudio`の`Logcat`を使ってログをみる。

```text:Error
Could not get the point on the surface.
```
-->

*Splatoonの塗りみたいのを再現したい その５*
http://esprog.hatenablog.com/entry/2016/05/08/212355
この記事をみてみると
> メッシュのインポート設定でRead/Write Enabledをチェックしないと正常に動作しない場合がある

とあり、次の記事をリンクしている。

*RaycastHitのTexcoordが常に0ベクトルを返すのですよ*
http://esprog.hatenablog.com/entry/2016/05/06/161729
>Texcoordを取得してその位置にインクを塗る、といった処理をしているのですが、見ての通り正常に描画されない場合、Texcoordが常に0ベクトルを返しています。
> 解決するのはものすごく簡単です。メッシュのインポート設定でRead/Write Enabledにチェックを入れるだけです。

とりあえず、 **メッシュのインポート設定でRead/Write Enabledにチェックを入れる** ことで塗れない問題は解決した。

# InkPainterでペイントのサイズを補正できるようにした
*2019/10/25*
一通りインクを塗れるようにして、Android用に書き出して試し塗りをしてみると、床/天井と壁でブラシのサイズが違うことに気づいた。原因はわかっている。モデルをUV展開したときの縮尺が統一されていないからである。
![output02.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/b6474664-c5fc-6357-5d4e-1cdb9dae2eec.gif)
モデルの縮尺を合わせるコストの方が高そうなので、InkPainter側で**モデルごと**にブラシサイズに係数をかけられるようにした。
※`brush.Scale`は全体で設定されている値

```C#:InkCanvas.cs
/*-------- 省略 -------- */
/// <summary>
/// To set the data needed to paint shader.
/// </summary>
/// <param name="brush">Brush data.</param>
/// <param name="uv">UV coordinates for the hit location.</param>
private void SetPaintMainData(Brush brush, Vector2 uv, float paintScale) // <-- 引数を追加
{
	paintMainMaterial.SetVector(paintUVPropertyID, uv);
	paintMainMaterial.SetTexture(brushTexturePropertyID, brush.BrushTexture);
	paintMainMaterial.SetFloat(brushScalePropertyID, brush.Scale * paintScale); // <-- 係数をかける
	paintMainMaterial.SetFloat(brushRotatePropertyID, brush.RotateAngle);
	paintMainMaterial.SetVector(brushColorPropertyID, brush.Color);

/*-------- 省略 -------- */
```

# Sphereを飛ばして塗れるように
*2019/10/28*
*【Unity】オブジェクトを放物線を描くように目的地まで射出する*
https://qiita.com/udo_nba/items/a71e11c8dd039171f86c

*【Vroom SDK】5230円で構築したオレオレVR開発環境が割とよかった話* 
https://qiita.com/drumath2237/items/479eda46afaadfb55ae4

を参考に`VirtualController.cs`を作成し、コントローラー代わりのGameObjectにアタッチする。

```C#:VirtualController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class VirtualController : MonoBehaviour
{
    
    public GameObject camera;

    public GameObject ThrowingObject;

    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
    
#if UNITY_IPHONE || UNITY_ANDROID
        transform.rotation = VvrController.Orientation();
        transform.transform.Rotate(new Vector3(0, 1, 0), -90);
#endif

        if (Input.GetMouseButtonDown(0) || VvrController.Trigger())
        {
            // マウス左クリックでボールを射出する
            ThrowingBall();
        }

        //transform.forward
    }

    private void ThrowingBall(){
        GameObject ball = Instantiate(ThrowingObject, this.transform.position, Quaternion.identity);

        Rigidbody rid = ball.GetComponent<Rigidbody>();
        //Vector3 a = new Vector3(10f, 10f, 10f);
        rid.AddForce(this.transform.right * rid.mass * 2.0f, ForceMode.Impulse);
    }
}
```
Vroomコントローラーのトリガーが引かれたら、シーン内にインスタンス化してあるインクボールがコピーされて、コントローラーの向いている方向へ発射するプログラムになってる。
ThrowingObjectはインクボールで`RigitBody`,`Sphere Collider`,`CollisionPainter.cs`をアタッチしてある。

# Unityの当たり判定は難しい
*2019/10/28*
どうしても玉が壁や床に当たらないことがあった。

*Unityで速い攻撃モーションの時に当たり判定がされない問題の対応をする*
https://gametukurikata.com/mesh/trailcollider

*【Unity】RigidbodyのCollision Detection(衝突の検知)を変えて実験*
https://ekulabo.com/rigidbody-collision-detection

*衝突判定のあれこれ*
https://qiita.com/moscoara_nico/items/8eee1de552601a8a8f1f

*Unity-今更ながらRigidBodyのCollision Detectionについて表にまとめてみた*
https://qiita.com/take_shi/items/fea7304fe9868a74d561

どうやら、速度が速い場合にはデフォルトの判定は効かないらしく `Collision Detection` を変更する必要がある。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/d7def902-0f2b-3f06-b92c-682b595dd761.png)

`CollisionPainter.cs`には`OnCollisionStay`が実装されているので、Sphereが壁や床に**あたっている間**に呼ばれる。

```C#:CollisionPainter.cs
public void OnCollisionStay(Collision collision)
{
	if(waitCount < wait)
		return;
	waitCount = 0;

	foreach(var p in collision.contacts)
	{
		var canvas = p.otherCollider.GetComponent<InkCanvas>();
		if((canvas != null) && isPaintable)
			canvas.Paint(brush, p.point);
	}
}
```

# 玉が当たったら破棄
*2019/10/28*
インクボールが壁に当たっても玉が存在し続けるのはおかしいのでインクボールはオブジェクトにあたったら削除する。削除には`Destroy`関数を使う。`OnCollisionEnter`なので、`あたった時`に関数が呼ばれるが、 `CollisionPainter.cs`では`OnCollisionStay`を塗りの判定に使っていのですぐに削除するとインクがつかない。*オブジェクトを破壊するまでのディレイ時間*を設定しておけば塗りの判定のあとにちょうどオブジェクトが消える算段である。

*Object.Destroy*
https://docs.unity3d.com/ja/current/ScriptReference/Object.Destroy.html

*public static void Destroy (Object obj, float t= 0.0F);*

| パラメータ | |
|:---|:---|
|obj|破壊するオブジェクト|
|t|オブジェクトを破壊するまでのディレイ時間|

```C#:DestroySphere.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DestroySphere : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {
        
    }

    void OnCollisionEnter(Collision collision)
    {
        //Debug.Log("当たった!");
        Destroy(this.gameObject,0.5);
    }

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

# 塗り面積を計算してスコアにする
*2019/10/29*
本家は対戦ゲームで塗った面積比で勝敗が決まる。作成したゲームは対戦ではなく、1人でプレイするので塗った面積をスコアをする必要があった。なので、`InkPainter.cs`に、「オリジナルのテクスチャ」と「塗ったあとのテクスチャ」を比較して、変化があったピクセルの数を計算するプログラムを追加した。

![output03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/0a2a6d46-9635-5da3-7bf0-099c5891f8af.gif)

InkPainter.csにもともとあった`SaveRenderTextureToPNG`関数を参考にプログラムを作成。
`PaintSet#mainTexture (Texture)`がオリジナルの画像で`PaintSet#paintMainTexture (RenderTexture)`がインクの付いた画像になっている。`Texture`と`RenderTexture`の内容は単純比較できなさそうなので、どちらも`Color[]`に変換して単純に色を比較した。**この処理はむちゃくちゃ重いです。**

*Texture から Texture2D への変換*
http://nakamura001.hatenablog.com/entry/20171012/1507810457

```C#:InkPainter.cs
public int CompareRenderTexture(){
	var ps = paintSet[0];
	int diff_count = 0;
	if(ps != null){
		var renderTexture = ps.paintMainTexture;
		var newTex = new Texture2D(renderTexture.width, renderTexture.height);
		RenderTexture.active = renderTexture;
		newTex.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0);
		newTex.Apply();

		Texture mainTexture = ps.mainTexture; // Material のメインテクスチャを取得
		Texture2D texture2D = new Texture2D(mainTexture.width, mainTexture.height, TextureFormat.RGBA32, false);

		RenderTexture currentRT = RenderTexture.active;

		RenderTexture _renderTexture = new RenderTexture(mainTexture.width, mainTexture.height, 32);
		// mainTexture のピクセル情報を renderTexture にコピー
		Graphics.Blit(mainTexture, _renderTexture);

		// renderTexture のピクセル情報を元に texture2D のピクセル情報を作成
		RenderTexture.active = _renderTexture;
		texture2D.ReadPixels(new Rect(0, 0, _renderTexture.width, _renderTexture.height), 0, 0);
		texture2D.Apply();

		Color[] origin_pixels = texture2D.GetPixels();

		RenderTexture.active = currentRT;

		Color[] paintedTex = newTex.GetPixels();

		for(int i = 0;i < origin_pixels.Length;i++){
			if(origin_pixels[i] != paintedTex[i])
				diff_count++;
		}

		Debug.Log(diff_count);
	}
	return diff_count;
}
```

# Unityの重力を変える
*2019/10/29*
インクボールの速度が遅いと、玉が重力によって壁に到達する前に落ちてしまう。だからといって玉の速度が速いとあたり判定に失敗することがあるので、ちょうど良い放物線描くようにしたいと思った。物体が下まで落ちるのに関係する係数は**重力加速度**です。高校物理で習いました。

*物体の速度は時間に比例する*
$$ v = gt $$
*物体の移動距離は時間の2乗に比例する*
$$ y = \frac{1}{2}gt^2 $$


*落下運動を調べる　～重力加速度～*
https://www.nhk.or.jp/kokokoza/tv/butsurikiso/archive/resume006.html

*【Unity】 重力を変更する*
https://qiita.com/t_Kaku_7/items/fdf5bab18b65f6f9dcb4

上のQiitaの記事を参考に、重力加速度を`-2`にすることでちょうど良い放物線でインクボールが飛びました。

# 色をランダムに塗れるようにする
*2019/10/29*
インクの色が1つしかないと絵的に寂しいのでランダムでインクボールの色が変わるようにした。`CollisionPainter#Brush#Color`が塗りの色なのでこれを変える。インクボールの色はマテリアルでセットしているのでそれに同じ色を当ててあげる。

```C#:VirtualController.cs
private void ThrowingBall(){
    GameObject ball = Instantiate(ThrowingObject, this.transform.position, Quaternion.identity);


    CollisionPainter paint = ball.GetComponent<CollisionPainter>();
    
    Debug.Log(paint.brush.Color);

    paint.brush.Color = def_color[(int)Random.Range(0, 6 + 1)];
    ball.GetComponent<Renderer>().material.color = paint.brush.Color;
    
    Rigidbody rid = ball.GetComponent<Rigidbody>();
    //Vector3 a = new Vector3(10f, 10f, 10f);
    rid.AddForce(this.transform.right * rid.mass * 4.0f, ForceMode.Impulse);
}
```

# CubeMapを360度画像に変換する
*2019/10/30*
インクを塗った教室を共有できるように、VR空間を画像にする必要が出てきた。教室のある面だけを写してスクリーンショットを撮るか、テクスチャをサーバーに転送して専用のビュアーとかで見れるようにするか、とか色々アイデアを考えたが、プレイヤーがどこをよく塗ってどう共有したいかがわからない以上は、全部を画像に落とし込みたかった。

サーベイの中参考になったのは下の記事で、ツールか`CubeMap`を使うのが良さそうだった。

*SphericalImageCam　Unityエディタ内で超綺麗な360度全天周パノラマ映像を撮影する「日本作者さん」のスクリプト*
http://www.asset-sale.net/entry/SphericalImageCam

*Unityの基本機能だけを使ってSceneのキューブマップ（全天球画像）を作る*
https://qiita.com/ELIXIR/items/c71ee67eb259bfa7d2c7

*Unity でステレオ VR 動画を作成する (ほぼ完全 (?) 版)*
https://qiita.com/tan-y/items/941de5c8bc3309f835d5

*Unity Recorder の使い方*
https://qiita.com/tan-y/items/644760a18484cbe71d43

 ![2019-11-03-12-33-06-score-3609.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/5e4128c1-7745-5511-a75c-25dd314ffee4.jpeg)
↑変換した画像

実際には下のQiitaの記事のプログラムを参考に、スコアが計算されたらカメラに映っている画像をキューブマップに落とし込んで、Shaderを使ってEquirectangularな画像を生成/保存できるようにした。

*Unityでカメラのequirectangular画像を作成してみる*
https://qiita.com/mechamogera/items/0b47e5947f1eee2467da

```C#:VirtualController.cs
private IEnumerator CalcResult() {
    int total_score = 0;
    for(int i = 0;i < inkcanvas.Length;i++){
        total_score += inkcanvas[i].CompareRenderTexture();
        yield return null;
    }
    // スコア計算
    textMesh2.text = $"{total_score:D}";
    yield return null;

    // ここで360度画像を生成
    // カメラの位置とか調製した方がいいかな？
    model.SetActive(false);
    Camera cam = camera.GetComponent<Camera>();
    Material convMaterial;

    int outputWidth = 4096;
    int outputHeight = 2048;
    int cubeWidth = 1280;

    Texture2D equirectangularTexture;

    Cubemap cubemap = new Cubemap(cubeWidth, TextureFormat.RGBA32, false);

    string path = Application.dataPath + @"/all" + "_painted.png";

    Shader conversionShader = Shader.Find("Conversion/CubemapToEquirectangular");
    convMaterial = new Material(conversionShader);
    
    RenderTexture currentRT = RenderTexture.active;

    RenderTexture renderTexture = new RenderTexture(outputWidth, outputHeight, 24);
    equirectangularTexture = new Texture2D(outputWidth, outputHeight, TextureFormat.RGB24, false);

    cam.RenderToCubemap(cubemap);

    Graphics.Blit(cubemap, renderTexture, convMaterial);
    //Graphics.Blit(cubemap, renderTexture);

    // renderTexture のピクセル情報を元に texture2D のピクセル情報を作成
    RenderTexture.active = renderTexture;
    equirectangularTexture.ReadPixels(new Rect(0, 0, outputWidth, outputHeight), 0, 0, false);
    equirectangularTexture.Apply();

    RenderTexture.active = currentRT;

    byte[] pngData = equirectangularTexture.EncodeToPNG();
    if(pngData != null)
    {
        File.WriteAllBytes(path, pngData);
    }
    Debug.Log(path);
    // https://qiita.com/ELIXIR/items/c71ee67eb259bfa7d2c7
    // https://qiita.com/mechamogera/items/0b47e5947f1eee2467da

    model.SetActive(true);

    yield return null;
}
```

**`Project Settings > Graphics > Built-in Shader Settings`の`Always Included Shaders`にプロジェクトに追加した`Conversion/CubemapToEquirectangular`を追加しないとAndroid上で動作しないので注意**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/8e835346-ea5c-d8f4-5467-012f75aab7fd.png)


# Androidスマートフォンに画像を保存
*2019/10/30*
現状だと、`File.WriteAllBytes`で指定しているパスは、`Application.dataPath`なので当然、写真アプリからはみることができない。写真アプリから見れれば最悪手動で共有をできるし、データ管理もしやすいので、写真アプリから見れるところに生成した画像を保存できるようにした。

*Application.dataPath*
https://docs.unity3d.com/ja/current/ScriptReference/Application-dataPath.html

*【Unity3D】ファイル保存パス*
https://qiita.com/bokkuri_orz/items/c37b2fd543458a189d4d

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/4b92752c-b037-e0fb-deb4-427c37e8c148.png)

`Unity Native Gallery Plugin`を使うとユーザー領域にデータを保存できるようになるので導入、GitHubの[ページ](https://github.com/yasirkula/UnityNativeGallery)から`NativeGallery.unitypackage`をダウンロードしてインポートで導入完了。あとは、保存するプログラムを書き換えるだけで終わった。
*【Unity】iOS の写真や Andoid のギャラリーに画像や動画を保存できる「Unity Native Gallery Plugin」紹介*
http://baba-s.hatenablog.com/entry/2017/12/26/210500

```C#:VirtualController.cs
byte[] pngData = equirectangularTexture.EncodeToPNG();
if(pngData != null)
{
#if UNITY_IPHONE || UNITY_ANDROID
    NativeGallery.SaveImageToGallery(equirectangularTexture,"UnityGames",DateTime.Now.ToString("yyyy-MM-dd-hh-mm-ss")+".png");
#else
    File.WriteAllBytes(path, pngData);
#endif
}
Debug.Log(path);
```

# 共有システムにFirebaseを導入
*2019/10/31*
スコアの算出と共有用の画像の生成ができたら、その2つを共有できるシステムを組むことに。共有まわりのシステムにはFirebaseを使った。単に、Firebaseを使ってみたく導入を決めた。

Firebaseとは何かは適当な記事を参照してほしい。
*Firebaseの始め方*
https://qiita.com/kohashi/items/43ea22f61ade45972881

- Unityからデータを受け取ったり共有ツイートをしたりするコントローラー的役割に**Functions**
- 画像データそのもの自体を保存しておくのに**Storage**
- ユーザー名、スコア、画像ファイル名、ツイートURLなどを管理するのに**Database**
- このゲームのWebサイトのために、**Hosting**
これらの4つの機能を使って作っていくことにした。

下の記事を参考にFirebaseをinitした。

*Firebase で Cloud Functions を簡単にはじめよう*
https://qiita.com/tdkn/items/2ed2b01f2656fc50da8c

*Cloud Functions for Firebaseが最高だった話*
https://qiita.com/HALU5071/items/e43729ac5b06b0506fbe

# multipart/form-dataをアップロードに対応
*2019/10/31*
サーバーにファイルを上げるとなるとPOSTメソッド+`multipart/form-data`でアップロードすることになる。POSTメソッドを叩けるエンドポイントをFunctionsに実装した。基本は公式のドキュメントを見ながら、実装例を参考にしていく。ポストしたデータはStorageに保存されるようになっている。

*Cloud Functions > ドキュメント > ガイド HTTP関数*
https://cloud.google.com/functions/docs/writing/http

*Cloud Functionsから、Storageにアップロードする*
https://shuheitagawa.com/upload-to-storage-via-cloudfunctions/

*FirebaseのHostingとCloud Functionsを利用してStorageへファイルをアップロードするデモアプリケーション*
https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365

```javascript:index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const path = require('path');
const os = require('os');
const fs = require('fs');
const Busboy = require('busboy');

admin.initializeApp(functions.config().firebase);

//-------省略--------

exports.scoreUpload = functions.https.onRequest((req, res) => {
    if (req.method === 'POST') {
        const busboy = new Busboy({ headers: req.headers });
        // This object will accumulate all the uploaded files, keyed by their name.
        const uploads = {}
        const allowMimeTypes = ['image/png', 'image/jpg'];
        const tmpdir = os.tmpdir();

        const bucket = admin.storage().bucket();

        // This callback will be invoked for each file uploaded.
        busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {

            if (!allowMimeTypes.includes(mimetype.toLocaleLowerCase())) {
                //console.warn('disallow mimetype: ' + mimetype);
                return;
            }

            // Note that os.tmpdir() is an in-memory file system, so should
            // only be used for files small enough to fit in memory.
            const filepath = path.join(tmpdir, filename)
            uploads[fieldname] = filepath;
            file.pipe(fs.createWriteStream(filepath));
            
            // ここでアップロード処理
            file.on('end', () => {
                bucket.upload(filepath,{ destination: `${filename}`,metadata: { cacheControl: 'public,max-age=31536000',contentType: mimetype } })
                .then(() => {
                    console.log('file upload success: ' + filepath);
                    return new Promise((resolve, reject) => {
                        fs.unlink(filepath, (err) => {
                            if (err) {
                                reject(err);
                            } else {
                                resolve();
                            }
                        });
                    });
                })
                .catch(err => {
                    console.error(err);
                    // TODO error handling
                });
            });
        });

        busboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => {
            console.log('Field [' + fieldname + ']: value: ' + val);
        });

        // This callback will be invoked after all uploaded files are saved.
        busboy.on('finish', () => {
            res.end();
        });

        // The raw bytes of the upload will be in req.rawBody. Send it to
        // busboy, and get a callback when it's finished.
        busboy.end(req.rawBody);
    } else {
        // Client error - only support POST.
        res.status(405).end();
    }
});
```

# Unityから画像をアップロード
*2019/10/31*
作成したFunctionsへPOSTメソッドを使って画像とスコアをアップロードした。標準でネットワーク機能も備わっているのでUnityは本当に楽

*Unityで画像をPOSTし，PHPでサーバに保存する*
https://qiita.com/s_ktmr/items/6fcc770ea89fe885b8bd

*UnityWebRequest*
https://docs.unity3d.com/ja/current/ScriptReference/Networking.UnityWebRequest.html

```C#:VirtualController.cs
IEnumerator UploadFile(byte[] pngData,int score) {
    string fileName = DateTime.Now.ToString("yyyy-MM-dd-hh-mm-ss")+".png";

    // formにバイナリデータを追加
    WWWForm form = new WWWForm ();
    form.AddBinaryData ("file", pngData, fileName, "image/png");
    form.AddField( "score", $"{score}" );
    // HTTPリクエストを送る
    UnityWebRequest request = UnityWebRequest.Post ("https://*********.cloudfunctions.net/scoreUpload", form);
    yield return request.Send();

    if (request.isHttpError) {
        // POSTに失敗した場合，エラーログを出力
        Debug.Log (request.error);
        textMesh.text = "Ready";
    } else {
        // POSTに成功した場合，レスポンスコードを出力
        Debug.Log (request.responseCode);
        textMesh.text = "Ready";
    }
    yield return null;
}

```

# アップロードを高速化
*2019/10/31*
どうしても端末でスコアを計算して、2-3MBのPNG画像をアップロードするのは時間がかかりすぎるので、高速化したかった。**PNGは可逆圧縮なのでファイルサイズは大きくなる**、別にTwitterに投稿する用の画像ならJPEGでも全然良いので、サーバーにアップロードするファイルはJPEGでエンコード。JPEGにするとファイルサイズが600KBぐらいになったのでStorageにも優しい。

```C#:VirtualController.cs
// ----- 省略 ------
StartCoroutine(UploadFile(equirectangularTexture.EncodeToJPG(),total_score));
// ----- 省略 ------
IEnumerator UploadFile(byte[] pngData,int score) {
    string fileName = DateTime.Now.ToString("yyyy-MM-dd-hh-mm-ss")+"-score-" + score + ".jpg";
 
    // formにバイナリデータを追加
    WWWForm form = new WWWForm ();
    form.AddBinaryData ("file", pngData, fileName, "image/jpg");
// ----- 省略 ------
```

# FirebaseからTwitterを投稿
*2019/10/31*
Unityからアップロードした画像をFirebaseを介してTwitterへ投稿する。Node.js + Twitterで検索したら次の記事が出てきたので助かった。multipart/form-dataのアップロードに対応したときに、アップロードしたファイルを一時的なパスに保存してあるので、アップロードする画像のパスにそれを指定すればいい。

*Node.jsでTwitterに画像投稿するメモ*
https://qiita.com/n0bisuke/items/6b269f61152e9f336c35

```javascript:index.js
const data = fs.readFileSync('/path/to/file');
const mes = `RE:REALをプレイしました！ \nスコア:${mScore} \n5C511でプレイ！ \n\n #playrereal #雙峰祭 #5C511`; //投稿するメッセージ

(async () => {
    //画像のアップロード
    const media = await client.post('media/upload', {media: data});
    console.log(media);

    //Twitterに投稿
    const status = {
        status: mes,
        media_ids: media.media_id_string // Pass the media id string
    }
    const response = await client.post('statuses/update', status);
    console.log(response);
    console.log(response.entities["media"][0]["media_url"]);
    res.end();
})();
```

上のプログラムをデプロイしたが、Firebaseで403エラーが帰ってきた...
どうやら従量課金制じゃないとTwitterAPIを叩けないらしいので従量課金制に変更した。学園祭2日で大量の処理が走るわけではないので

*FirebaseでTwitterのAPIが叩きたかった*
https://blog.nagatech.work/post/twitterbot/361

> 無料プランだと外部のHTTPリクエスト(ここではTwitterのAPIを叩くこと)は作れない
> 従量課金制のBlazeプランに入ると使える

# Firebase FunctionでDatabaseへの書き込み
*2019/11/1*
今後、ユーザーによるプレイのランキングページなどを作ることを見越してゲームのプレイデータを記録できるようにした。FunctionにDatabaseの書き込みを実装した。

*ホーム >Firebase Realtime Database データの保存*
https://firebase.google.com/docs/database/admin/save-data?hl=ja

*Firebase Cloud Functions, Realtime DatabaseでCRUD REST WebAPIを作る*
https://qiita.com/devnokiyo/items/26de016b0baf60b26c90

上の記事を参考に、Tweetのレスポンスとともにデータを記録。`push`でJavascriptのObjectの書き方で記録できる。

```javascript:index.js
const ref = admin.database().ref('record');
ref.push({
    id: response.id_str,
    user_name: "noname",
    score: mScore,
    tweet_id: response.id_str,
    tweet_url: "https://twitter.com/rereal2019/status/" + response.id_str,
    tweet_media_uri: response.entities["media"][0]["media_url"],
    media_uri: mFilename,
    created_time: parseInt( new Date() /1000 )
}).then(data => {
    return res.end();
})
.catch(error => {
    // TODO ERRORハンドリング
    return res.end();
});
```

# ゲーム内のスタートを改善
*2019/11/1*
今までは、Readyと表示された状態でコントローラーのボタンを押したらゲームをスタートできたが、これだと展示の際にコントローラーを渡したら誤って押してしまい突然ゲームが始まることがあると思った。なので、簡単なチュートリアルを兼ねて、箱にインクボールを当てるとスタートというシステムに改良した。箱には、`OnCollisionStay`を実装したクラスをアタッチしており当たったインクボールの数をカウントする。箱の上部には当てるべきインクボールが数が表示されており、インクボールが当たれば減る。その数が0になれば`OK`と表示され、すべてが`OK`になるとゲームが始められる。(文章で書くと説明が長いのでこのシステムは良くないと今思っている...

![output04.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/71a16eaa-e472-69c9-6c56-afe7ea03e9e2.gif)

# メモリ使用が800MBを超えるとアプリが落ちる
*2019/11/1*
アプリを終了せずともゲームをスタート/ストップできるようになったのだが、どうも3,4回目をプレイして塗りを計算したあたりでアプリが落ちる。コードを見る限り、プレイ回数に依存して配列外アクセス、nullアクセスして、`NullReferenceException`とかではなさそうだった(今思えばログ見ればよかったのでは...。とりあえずこういった問題には、Profilerを使う。Android StudioにはAndroid Profilerがあるので、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/1ad6fef3-3fc2-a8f5-ae3a-605dd2b2bb27.png)

デバイスをmacにつないだままプレイしてCPUやメモリの状態を観察すると、ゲームが終了するごとに約200MBぐらい増えている。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/0197e327-4e03-ac5d-8ef1-031f63713f74.png)

800MBあたりを越えた時点でアプリが終了してメモリがパージされている。これはつまりメモリリークってやつですね。ゲームが1回終わるごとにアプリをリセットすれば良いが、忘れたりでもしたらプレイ結果が消えるので、これは解決するべき問題。(人間がシステムに対して行わなければいけないことは最小限にしたい
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/2bfcc7b8-2a89-6f10-fbfd-6c6e3956f8a8.png)

メモリリークに対してあまり詳しくないのでサーベイ。調べると、**Texture系はnewして使わなくなったらDestroy**しようということなのでプログラムにDestroyを追加

```C#:InkCanvas.cs
Destroy(_renderTexture);
Destroy(newTex);
Destroy(texture2D);
```

cubemapは1280x1280x6のテクスチャ、renderTextureは4096x2048でどちらもRGB24なので、これだけで約53MBもあるわけだ...

```C#:VirtualController.cs
Destroy(cubemap);
Destroy(renderTexture);
Destroy(convMaterial);
```

*Unityでメモリ節約*
https://qiita.com/consolesoup/items/769fdf9e5748aa3e7f05

*【Unity】開発中のアプリがメモリリークで強制終了するようになった時に対応したこと*
http://baba-s.hatenablog.com/entry/2017/06/29/100000

*Unity RenderTextureのメモリ確保と解放タイミングの落とし穴*
https://www.shibuya24.info/entry/rendertexture_mem_alloc

*Unity コンポーネントがDestroyされても、オブジェクトがGCで回収されないかもしれない話*
https://qiita.com/toRisouP/items/4574a30622f43ddbde79

*UnityでTextureを明示的に破棄*
https://qiita.com/tempura/items/b87eb07568d974664671



# 玉が一定範囲を越えたら消えるように
*2019/11/1*
窓にインクをつけるかつけないかを悩んで、つけないことにしたので窓に当たり判定をつけなかった。だから、インクボールが窓の外に出たらインスタンスが残ってしまう。重力が設定されているので、大量にインスタンスが残るとゲーム自体のパフォーマンスが悪くなってしまうので、原点からの距離を測って一定範囲外にでたら消すようにした。

*unity > 物体がある範囲外に移動した時に消えるようにする > Destroy(gameObject);*
https://qiita.com/7of9/items/f9843c6668eb269678de

```C#:DestroySphere.cs
    [SerializeField]
    public float DestroySphereRange = 20.0f;

    // Update is called once per frame
    void Update()
    {
        float dist = Vector3.Distance(new Vector3(0, 0, 0),transform.position);
        if (dist > DestroySphereRange) {
            Destroy(this.gameObject);
        }
    }
```

# 塗りをリセット
*2019/11/1*
InkPainterでインクの塗りをプログラムから消すには、`InkCanvas#ResetPainit`を呼べば良い。なのでInkCanvasを全部取得してforeachを回すだけ。

```C#:VirtualController.cs
public void canvasReset(){
    foreach(var canvas in FindObjectsOfType<InkCanvas>())
	canvas.ResetPaint();
}
```

# UnityでJSONを扱う
*2019/11/1*
現在プレイしているユーザー名は何か、次のゲームはコンティニューなのかどうか、プレイ時間は何秒かといったゲームに関わるパラメータは動的に変更できるようにするべきなので、ゲーム開始前にパラメータをFirebaseにとりにいくようにした。パラメータはChromeからサクッと手動で追加してプログラムを追加した。

*ホーム > Firebase Realtime Database データの取得*
https://firebase.google.com/docs/database/admin/retrieve-data?hl=ja

↓Datebaseに記録したパラメータを取得してJSONにして返すエンドポイントのプログラム

```javascript:index.js
exports.nextConfig = functions.https.onRequest((req, res) => {
 
    const db = admin.database().ref('next_config');
    db.once("value").then(snapshot => {
        const result = {
            is_continue : snapshot.child("is_continue").val(),
            next_user_name : snapshot.child("next_user_name").val()
        }
        res.send(JSON.stringify(result));
    })  
    .catch(error => {
        // TODO ERRORハンドリング
        return res.end();
    });
});
```

これで、`https://*********.cloudfunctions.net/nextConfig`にGETメソッドでアクセスすれば、パラメータがJSONになってダウンロードできます。

*JsonUtility をつかって Unity で JSON を取り扱う方法*
https://qiita.com/sea_mountain/items/6513b330983ffa003959

この記事を参考にJSONをパースしてゲームに組み込みます。

```C#:VirtualController.cs
[Serializable]
public class NextConfig
{
    public bool is_continue;
    public string next_user_name;
}

IEnumerator getConfing() {
    UnityWebRequest request = UnityWebRequest.Get("https://*********.cloudfunctions.net/nextConfig"); 
    // リクエスト送信
    yield return request.Send();

    // 通信エラーチェック
    if (request.isHttpError) {
        Debug.Log(request.error);
    } else {
        if (request.responseCode == 200) {
            // UTF8文字列として取得する
            string text = request.downloadHandler.text;
            //Debug.Log(text);
            NextConfig item = JsonUtility.FromJson<NextConfig>(text);
            //Debug.Log("" + item.is_continue);
            //Debug.Log("" + item.next_user_name);
            if(item.is_continue == false){
                canvasReset();
            }
            isConfig = true;
        }
    }
}
```

# インクボールの色を変えれるようにする
*2019/11/1*
今まではインクボールの色がランダムで出ていたが、プレイヤーが好きな色を使えると良いと思いジョイスティックで色を選択できるようにした。ジョイスティックがある方向に倒れているかの状態しかAPIからは取得できないので、矢印キーを1回押す感覚で左右に倒すことはできない(Unityでいう、GetKeyはあるが、GetKeyDownがない感じ)。なのでジョイスティックを倒したというイベントを1回限りで取れるようにした。

FPGA開発で`VerilogHDL`を書いた気持ちで、立ち上がり検出をC#で書く

```C#:VirtualController.cs
    // 立ち上がり検知
    private bool prev_left = false;
    private bool curt_left = false;
/*-------- 省略 -------- */
    void Update(){
/*-------- 省略 -------- */
        // 左方向検知
        curt_left = VvrController.JoystickAction() == VvrJoystickAction.Left;
        if (Input.GetKeyDown(KeyCode.LeftArrow) || ((prev_left == false) && (curt_left == true))){
            colorPalette = ((colorPalette + 8) - 1) % 8;
            //Debug.Log(""+colorPalette);
            setPalette();
        }
        prev_left = curt_left;
/*-------- 省略 -------- */
}
```
これでジョイスティックを傾けると色が変わるようになった。
![output05.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/69133d58-0b43-f995-e8ca-746d37d3dd6a.gif)

# UnityXRで動き回るにはカメラの親オブジェクトを移動させる必要がある
*2019/11/1*
ジョイスティックの上入力と下入力の機能に空きがあるので、前と後ろに移動できるようにしてみる。

![output06.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/fd8c1f22-72c7-d6bb-81b4-860415ac4ac6.gif)

*Unity標準のVR機能（UnityEngine.XR）メモ*
*# Virtual Reality Supportedをオンにすると何が起きるの？*
https://framesynthesis.jp/tech/unity/xr/#virtual-reality-supported%E3%82%92%E3%82%AA%E3%83%B3%E3%81%AB%E3%81%99%E3%82%8B%E3%81%A8%E4%BD%95%E3%81%8C%E8%B5%B7%E3%81%8D%E3%82%8B%E3%81%AE

> また、実行時にカメラのPositionとRotationがトラッキングの動きで上書きされるようになります。たとえばカメラをスクリプト等で移動しているような場合、スクリプトでのTransformの更新が上書きされて無効化されてしまいます。対策としては、カメラを別のゲームオブジェクトの子オブジェクトにして親のほうをスクリプトで動かしてください。

MainCameraのPositionを動かしても無意味なので、上の記事通り、MainCameraに親オブジェクトを作成して、そのPositionを動かすことにした。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/da67cd1f-f621-aebe-4a6f-210caaf754ff.png)

```C#:VirtualController.cs
void Update(){
/*-------- 省略 -------- */
        // 上方向検知
        // https://framesynthesis.jp/tech/unity/xr/
        // 親オブジェクトを動かすしかない
        if (Input.GetKey(KeyCode.UpArrow) || (VvrController.JoystickAction() == VvrJoystickAction.Up)){
            //if(isPlaying)
            Vector3 pos = cameraMover.transform.position;
            pos.x += 0.1f;
            if(pos.x >= -1.8f){
                pos.x = -1.8f;
            }
            cameraMover.transform.position = pos;
        }

        // 下方向検知
        if (Input.GetKey(KeyCode.DownArrow) || (VvrController.JoystickAction() == VvrJoystickAction.Down)){
            Vector3 pos = cameraMover.transform.position;
            pos.x -= 0.1f;
            if(pos.x <= -5.6f){
                pos.x = -5.6f;
            }
            cameraMover.transform.position = pos;
        }
/*-------- 省略 -------- */
}
```

# Unityで音をならす
*2019/11/3*
ゲームの見た目はだいたいできてきたので、BGMとSEを追加。SEは重複ありなので、`Play`ではなく`PlayOneShot`関数を使った。プログラムから音を鳴らしていくのに下のQiitaの記事がとても参考になった。

*[Unity] Unity×音についてざっくりまとめ*
https://qiita.com/lycoris102/items/5d5359b2015a8fdebaaa

*【Unity】音声（BGM・SE）の再生・ループ・フェードアウトなどの設定方法を徹底解説！*
https://xr-hub.com/archives/18550

*その５ SEの同時発生数問題を考えてみる*
http://marupeke296.com/UNI_SND_No5_NumberOfSE.html

*【Unity】重複ありのBGMの鳴らし方「Play()」「PlayOneShot()」*
https://squmarigames.com/2018/12/19/unity-beginner-audiosource/

# ゲームのサイトを作成
*2019/11/3*
`firebase init`したときにHostingも選択しているので、プロジェクトフォルダ下の`public`ディレクトリをサクッと編集して、`firebase deploy`コマンドを打ち込むだけ。しかし、これだとFunctionの方もデプロイが走るので下のコマンドでデプロイする。

```bash:
firebase deploy --only hosting
```

実際にデプロイしたサイト
https://sohosai2019-rereal.firebaseapp.com/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/23bea25e-48cb-1cde-9d16-57a7610fff43.png)

# プレイヤーネームの登録を作成
*2019/11/3*
デプロイしたサイトに新規ゲームのためにプレイヤーの名前を登録できるフォームのあるページを作成した。念を入れてプレイ時間とインクの塗りをリセットするか?を変えられるようなInputも作成した。プレイヤーには名前を入れるだけのフォームに見えるように、フォームのSubmitボタンを上にして、下の方にオプションの項目を並べている。

↓実際のサイト
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/64879f99-5988-a30f-eebb-21f0eb69ba48.png)

ユーザーネームを登録できるエンドポイントはFunctionに実装しておく。もともとあったデータをDatebaseから取得して処理を進めたかったので、`await`を使えるように`functions.https.onRequest`の引数の関数には、asyncをつけている。

*async/await 入門（JavaScript）*
https://qiita.com/soarflat/items/1a9613e023200bbebcb3

```javascript:index.js
exports.newGame = functions.https.onRequest(async(request, response) => {
    if (request.method === 'POST') {

        console.log(request.body.iscontinue);
        console.log(request.body.username);
        console.log(request.body.options);
        const db = admin.database().ref('next_config');

        const old_user_name = (await admin.database().ref('next_config/next_user_name').once('value')).val();

        if (typeof request.body.iscontinue !== 'undefined') {
            //onのとき
            //usernameが0なら更新しない、あれば更新
            db.set(
                {
                    next_user_name: (request.body.username.length != 0)? request.body.username : old_user_name,
                    is_continue: true,
                    play_time : Number(request.body.options)
                }
            );

        }else{
            //offのとき
            //usernameが0はnoname、あれば更新
            db.set(
                {
                    next_user_name: (request.body.username.length != 0)? request.body.username : "noname",
                    is_continue: false,
                    play_time : Number(request.body.options)
                }
            );

        }
        // ルールとかにすると良い
        response.redirect('https://sohosai2019-rereal.firebaseapp.com/new.html')
    }
});
```

# ランキング機能を追加
*2019/11/3*
スコアがでるゲームにはインセンティブとしてランキングが必要なので、Twitterに投稿する文でこのスコアが何位なのかわかるようにした。ランキングは、**自身のスコアより高いスコアのデータの個数+1が自身の順位**なので、`orderByChild`でスコアをソート、`startAt`で自身より高いスコアのデータのみにフィルタリング、`numChildren`でデータの個数を取得で順位を算出した。

*ホーム > Firebase Realtime Database*
*データの取得 指定した子キーでの並べ替え*
https://firebase.google.com/docs/database/admin/retrieve-data?hl=ja#%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E5%AD%90%E3%82%AD%E3%83%BC%E3%81%A7%E3%81%AE%E4%B8%A6%E3%81%B9%E6%9B%BF%E3%81%88

```javascript:index.js
let rank_res = 0;
const db = await admin.database().ref('records').orderByChild("score").startAt(Number(fields["score"])).once("value").then(snapshot => {
    rank_res = snapshot.numChildren() + 1;
    //console.log("in:"+rank_res);
});
            
console.log(rank_res);

const data = fs.readFileSync(uploads['file']);
const mes = ((fields["user_name"] != "noname")?`${fields["user_name"]} さんが \n`:"")+`RE:REALをプレイしました！ \nスコア:${Number(fields["score"])} \n現在の順位は${rank_res}位！ \n\n雙峰祭5C511でプレイ！ \n\n #playrereal #雙峰祭 #5C511`; //投稿するメッセージ
```

# ランキングページを作成するために、FirebaseでCORSを許可する
*2019/11/3*
`https://*********.cloudfunctions.net/getRank`をPOSTメソッドで叩くと、JSONでランキングのデータがダウンロードできるようにした。FunctionにgetRankのエンドポイントを実装して、ページにはAjaxを使ってPOSTを叩くプログラムを実装した。まぁ簡単にはいかず、`firebaseapp.com`から`cloudfunctions.net`のドメインのAPIを叩くのだから、当然`No 'Access-Control-Allow-Origin' header is present on the requested resource.` とChromeに言われる。

*Cloud Functions for Firebase でCORSを許可する方法*
https://qiita.com/seya/items/0f12bd09c8e856123bc3
> `npm install cors` もしくは `yarn add cors` でインストールして、CORSを許可したい関数を以下のようにwrapします。

上の記事を参考に、`npm install cors`して処理をwrapで解決した。

```javascript:index.js
exports.getRank = functions.https.onRequest((req, res) => {
    cors(req, res, async() => {
/*-------- CROSを許可したい処理 -------- */
    });
});
```

Ajaxを使ってランキングを表示する部分(Javascriptの書き方が混ざっていてやべぇなw

```javascript:rank.html
var xhr = new XMLHttpRequest();
 
 xhr.open('POST', 'https://*********.cloudfunctions.net/getRank');
 xhr.setRequestHeader('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
 xhr.send( 'limit=100' );
 xhr.onreadystatechange = function() {
 
 if(xhr.readyState === 4 && xhr.status === 200) {

     //console.log( xhr.responseText );

    let _parse = JSON.parse( xhr.responseText );

    let rank = 1;
    _parse.forEach(element => {
      console.log(element);

      let _div = document.createElement('div');
            _div.className = "mdl-card mdl-shadow--2dp";
            _div.setAttribute("style", "width:512px;");
            _div.innerHTML = "<div class=\"mdl-card__title\">"+
                              "<h2 class=\"mdl-card__title-text\">"+rank+"位</h2>"+
                            "</div>"+
                            "<div class=\"mdl-card__media\">"+
                              "<img src=\""+element.tweet_media_uri+"\" height=\"150\" alt=\"\""+
                               "class=\"test-img\">"+
                            "</div>"+
                            "<div class=\"mdl-card__supporting-text\">"+
                                "<h5>"+element.user_name+"</h5>"+
                                "<h5>スコア:"+element.score+"</h5>"+
                            "</div>"+
                            "<div class=\"mdl-card__actions\">"+
                               "<a href=\""+element.tweet_url+"\" target=\"_blank\">ツイートをみる</a>"+
                            "</div>"+
                          "</br>";
            document.getElementById("rank_column").appendChild(_div);
            document.getElementById("rank_column").appendChild(document.createElement('br'));

            rank++;

    });
   
 }
}
```

Firebase Functionの部分

```javascript:index.js
exports.getRank = functions.https.onRequest((req, res) => {
    cors(req, res, async() => {
        let result = [];
        if (req.method === 'POST') {
            //console.log(req.body.limit);
            await admin.database().ref('records').orderByChild("score").limitToLast(Number(req.body.limit)).once("value").then(snapshot => {
                    snapshot.forEach(e =>{
                        result.push({
                            id: e.child("id").val(),
                            user_name: e.child("user_name").val(),
                            score: e.child("score").val(),
                            tweet_id: e.child("tweet_id").val(),
                            tweet_url: e.child("tweet_url").val(),
                            tweet_media_uri: e.child("tweet_media_uri").val(),
                            created_time: e.child("created_time").val()
                        });
                    });
                }
            );
        }
        res.send(JSON.stringify(result.reverse()));
    });
});
```

*【JavaScript入門】FormやAjaxのPOST送信・取得の方法まとめ！*
*AjaxによるPOST*
https://www.sejuku.net/blog/53627

*FirebaseのCloud FunctionsでCORSが~とかAccess-Control-Allow-Originが~と言われたらこれ*
https://qiita.com/qrusadorz/items/40234ac0b5c5c2315cad

# [WIP]展示
*2019/11/3-4*
11月3日 11:00-16:00、11月4日 10:00-12:00 14:45-16:00の8.25時間で、総プレイ数は60回といった感じだった。普通に学園祭は人が来るので、またゲームを展示して行けると良かったりと思った。結局、当日までコーディングをしていたが、自分としては書き出したAndroidアプリが途中でクラッシュすることもFirebase上でバグを踏むこともなく動いたのにはとても満足した。必要だと思ったことは当日色々思ったがメモするのを忘れてしまったので、随時展示について細かいことをここに書いて行きます。今のところ、**ゲームに関するインストラクションを作るべき**なのと、**水分が必要**なの点が大事だとここに書いておきます。

# [WIP]反省
*絶賛執筆中*

# 最後に
![basic2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/224134/663a699b-275f-08aa-f1a7-cc83387d27af.png)

**Unity**と**Firebase**は**神**だった