ゴミ箱

くだらないことからUnityの知識共有まで

画面に引いた線の認識をしたい

はじめに

 今回はとあるプロジェクトのプロトタイプで作っていたものが御破算になったので、未来の自分のための記録としてここにコードを残したいと思います。

元ネタ

www.google.com

 完成度の高いゲームです。画面に引いた線を縦、横、V字、逆V字、雷、ハートの6種類に判定し、敵を倒していくゲームです。ブラウザゲームなのでPCでもスマホでもできます。ぜひ一度やってみてください。

実装

 企画段階では画面に引く線を書道の線のようにしたい、とのことだったのでそれっぽくしています。

youtu.be

 とにかく実装できるかのテストだったのでif文ゴリゴリで書いてます。コード直すのも面倒だったので演出の処理も入っていますが気にしないでください。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class LineCalculation : MonoBehaviour
{
    [Header("<設定用変数>")]
    [SerializeField] private float judgDist = 0.1f;
    [SerializeField] private float judgHriRange = 15;
    [SerializeField] private float judgVerRange = 20;
    [SerializeField] private float judgDiaRange = 50;
    [SerializeField] private float judgSirRange = 320;

    [SerializeField] private PRODUCTION productionType = PRODUCTION.AFTER_PARTICLE_EFFECT;

    [SerializeField] private float maxMoveDist = 5f;

    [SerializeField] private LineRenderer lrMain;
    [SerializeField] private Transform brush;

    [SerializeField] private ParticleSystem particle;

    [Header("<確認用変数>")]
    [SerializeField] private STATE state;
    [SerializeField] private LINE_TYPE lineType;

    /// <summary>フレームごとの点</summary>
    private Vector3 nowPos = Vector3.zero;
    private Vector3 beforePos = Vector3.zero;

    /// <summary>角度</summary>
    [SerializeField] private float theta;

    /// <summary>移動量を一時的に保存</summary>
    [SerializeField] private float moveDist;
    /// <summary>移動量の合計</summary>
    [SerializeField] private float totalMoveDist;

    private float firstLineHorizontal = 0;
    private float lastLineHorizontal = 0;

    private Vector3 firstHornPos;
    private float thetaHorn;

    [SerializeField] private bool isHorn = false;
    private int hornPosNum = 0;

    /// <summary>打った点座標</summary>
    [SerializeField] private List<Vector3> pos;

    /// <summary>計算するかしないか</summary>
    private bool isCalculation = false;

    private enum LINE_TYPE
    {
        NONE,
        HORIZONTAL,
        VERTICAL,
        V_CHARA,
        V_REVERSE,
        THUNDER,
        CIRCLE
    }

    Dictionary<LINE_TYPE, Color> lineTypeDic = new Dictionary<LINE_TYPE, Color>()
    {
        {LINE_TYPE.NONE, Color.black},
        {LINE_TYPE.HORIZONTAL, Color.red},
        {LINE_TYPE.VERTICAL, Color.blue},
        {LINE_TYPE.V_CHARA, Color.green},
        {LINE_TYPE.V_REVERSE, Color.yellow},
        {LINE_TYPE.THUNDER, Color.white},
        {LINE_TYPE.CIRCLE, Color.cyan}
    };

    private enum STATE
    {
        NONE,
        WAIT,
        MOVE,
    }

    /// <summary>演出の種類</summary>
    private enum PRODUCTION
    {
        NONE,
        AFTER_PARTICLE_EFFECT,
        CORRECT_LINE_DISPLAY
    }

    void Start ()
    {
        Init ();
    }

    void Update ()
    {
        if(Input.GetMouseButtonDown(0) && state == STATE.NONE)
        {
            state = STATE.MOVE;
        }

        if (Input.GetMouseButton(0) && state == STATE.MOVE)
        {   
            Move();
            GetMoveDist();
            JudgDist();
            Calculation();//計算
            UpdateLineColor();
            SetNextPos();
        }

        if (Input.GetMouseButtonUp(0) && state == STATE.MOVE)
        {
            state = STATE.WAIT;

            //! lineTypeによって何かする

            //! 演出の方法と初期化のタイミング
            switch(productionType)
            {
            case PRODUCTION.NONE:
                Init ();
                break;
            case PRODUCTION.AFTER_PARTICLE_EFFECT:
                StartCoroutine (EraseLine (() => {
                    Init ();
                }));
                break;
            case PRODUCTION.CORRECT_LINE_DISPLAY:
                Init ();
                break;
            }
        }
    }

    /// <summary>
    /// 線の点ごとに消す
    /// </summary>
    private IEnumerator EraseLine(System.Action callback)
    {
        brush.gameObject.SetActive (false);
        int count = lrMain.positionCount;
        for(int i = 0; i < count; i++)
        {
//         yield return new WaitForSeconds (0.001f);
            yield return null;
            Vector3[] lrNext = new Vector3[lrMain.positionCount - 1];
            for(int j = 0; j < lrNext.Length; j++)
            {
                lrNext [j] = lrMain.GetPosition (j + 1);
            }
            particle.gameObject.transform.position = lrMain.GetPosition (0);
            particle.Emit(10);
            lrMain.positionCount = lrNext.Length;
            lrMain.SetPositions(lrNext);
        }
        particle.Stop ();
        callback ();
        yield break;
    }

    /// <summary>
    /// 初期化
    /// </summary>
    private void Init()
    {
        lrMain.positionCount = 0;
        brush.gameObject.SetActive (false);

        nowPos = Vector3.zero;
        beforePos = Vector3.zero;
        moveDist = 0;
        totalMoveDist = 0;
        theta = 0;
        firstLineHorizontal = 0;
        lastLineHorizontal = 0;

        isHorn = false;
        thetaHorn = 0;

        pos = new List<Vector3> ();
        state = STATE.NONE;
        lineType = LINE_TYPE.NONE;
    }

    /// <summary>
    /// 移動
    /// </summary>
    private void Move()
    {
        //移動制限
        if (totalMoveDist > maxMoveDist) return;

        nowPos = Camera.main.ScreenToWorldPoint (new Vector3 (Input.mousePosition.x, Input.mousePosition.y, Camera.main.nearClipPlane + 1.0f));
    }

    /// <summary>
    /// 前のフレームと今のフレームでの位置から距離を計算
    /// </summary>
    private void GetMoveDist()
    {
        if(beforePos != Vector3.zero)
        {
            float frameDist = Vector3.Distance (nowPos, beforePos);
            moveDist += frameDist;
            totalMoveDist += frameDist;
        }
    }

    /// <summary>
    /// 次フレームで使うためのベクトルをセット
    /// </summary>
    private void SetNextPos()
    {
        beforePos = nowPos;
    }

    /// <summary>
    /// 一定距離離れたかの判定
    /// </summary>
    private void JudgDist()
    {
        if(pos.Count == 0)
        {
            lrMain.positionCount++;
            lrMain.SetPosition (lrMain.positionCount - 1, nowPos);
            pos.Add(nowPos);//初期点

            brush.gameObject.SetActive (true);
            brush.position = nowPos;
        }

        // 一定距離以上のとき点を追加する
        if(moveDist > judgDist)
        {
            isCalculation = true;


            int count = (int)(moveDist / judgDist);
            for(int i = 0; i < count; i++)
            {
                Vector3 lerp = Vector3.Lerp (beforePos, nowPos, (1f / (float)count) * ((float)i + 1f));
                lrMain.positionCount++;
                lrMain.SetPosition (lrMain.positionCount - 1, lerp);
                moveDist -= judgDist;
            }
            pos.Add (nowPos);
        }
    }

    /// <summary>
    /// 内積の加算と判定
    /// </summary>
    private void Calculation()
    {
        // 一定距離進んだ時以外は判定しない
        if (!isCalculation) return;

        isCalculation = false;

        // 点座標リストが少ないときは計算しない
        if (pos.Count < 3) return;

        //点ベクトルから線ベクトルへ
        Vector3 lineVecA = pos[pos.Count - 2] - pos[pos.Count - 3];
        Vector3 lineVecB = pos[pos.Count - 1] - pos[pos.Count - 2];

        // シータに加算
        theta += GetInnerProduct(lineVecA, lineVecB);
        // 初期座標から最終点までの線分ベクトルと、Xの単位ベクトルで内積
        firstLineHorizontal = GetInnerProduct(pos[pos.Count - 1] - pos[0], Vector3.right);
        // 中点から最終点の線分ベクトルと、Xの単位ベクトルで内積
        lastLineHorizontal = GetInnerProduct (pos[(int)(pos.Count / 2)] - pos[0], Vector3.right);

        // 横線の判定
        if(-judgHriRange < firstLineHorizontal && firstLineHorizontal < judgHriRange && -judgHriRange < lastLineHorizontal && lastLineHorizontal < judgHriRange ||
            180 - judgHriRange  < firstLineHorizontal && firstLineHorizontal < 180 + judgHriRange && 180 - judgHriRange < lastLineHorizontal && lastLineHorizontal < 180 + judgHriRange ||
            180 - judgHriRange < firstLineHorizontal && firstLineHorizontal < 180 + judgHriRange && - judgHriRange < lastLineHorizontal && lastLineHorizontal < judgHriRange)
        {
            lineType = LINE_TYPE.HORIZONTAL;
        }
        // V字判定
        else if(110 - judgDiaRange < theta && theta < 110 + judgDiaRange)
        {
            if(pos[0].y > pos[(int)(pos.Count/2)].y && pos[(int)(pos.Count/2)].y < pos[pos.Count - 1].y)
            {
                lineType = LINE_TYPE.V_CHARA;
            }
            else if(pos[0].y < pos[(int)(pos.Count/2)].y && pos[(int)(pos.Count/2)].y > pos[pos.Count - 1].y)
            {
                lineType = LINE_TYPE.V_REVERSE;
            }

            if (!isHorn)
            {
                isHorn = true;
                firstHornPos = pos [pos.Count - 2];
                hornPosNum = pos.Count - 2;
            }
        }
        // 縦線の判定
        else if(90 - judgVerRange < firstLineHorizontal && firstLineHorizontal < 90 + judgVerRange && 90 - judgVerRange <  lastLineHorizontal && lastLineHorizontal < 90 + judgVerRange)
        {
            lineType = LINE_TYPE.VERTICAL;
        }
        else if(judgSirRange < theta)
        {
            lineType = LINE_TYPE.CIRCLE;
        }
        else
        {
            lineType = LINE_TYPE.NONE;
        }

        // かみなりの判定
        if(isHorn)
        {
            thetaHorn = GetInnerProduct (firstHornPos - pos[hornPosNum + (int)((pos.Count - hornPosNum) / 2)], pos[hornPosNum + (int)((pos.Count - hornPosNum) / 2)] - pos[pos.Count - 1]);
            if(110 - judgDiaRange < thetaHorn && thetaHorn < 110 + judgDiaRange)
            {
                if(pos[0].y > firstHornPos.y && firstHornPos.y > pos[pos.Count - 1].y || 
                    pos[0].y < firstHornPos.y && firstHornPos.y < pos[pos.Count - 1].y)
                {
                    lineType = LINE_TYPE.THUNDER;
                }
            }
        }
    }

    /// <summary>
    /// 線の色を更新
    /// </summary>
    private void UpdateLineColor()
    {
        Color blendColor = lineTypeDic [lineType] - (Color.white / totalMoveDist * 0.5f);
        lrMain.startColor = Color.black;
        lrMain.endColor = blendColor;
    }

    /// <summary>
    /// 内積計算
    /// </summary>
    private float GetInnerProduct(Vector3 lineVecA, Vector3 lineVecB)
    {
        return Mathf.Acos (Vector3.Dot (lineVecA, lineVecB) / (lineVecA.magnitude * lineVecB.magnitude)) * Mathf.Rad2Deg;
    }
}

 一応、一式をパッケージ化したものを置いておきます。 github.com

終わりに

 考え方としては点を打ってベクトルの内積で角度を出してその角度で判定するって感じです。かなりいい加減ですが参考になれば幸いです。

ビット演算の考え方が面白い

ビット演算ってなんだ

Wikipedia先生より

ビット演算(ビットえんざん、bitwise operation: 直訳すると「ビット毎操作」)とは、固定長のワードなどといった「ビットのカタマリ」(コンピュータの数値表現なども参照)に対して、各のビット全てに対する論理演算をいっぺんに行う演算操作である。 実装の観点からは、現在一般的な二進法(ディジタル)式の電子式コンピュータでは、加減算ではビットあたり数個程度の論理ゲートに加え多少複雑なキャリー伝搬の処理が、乗除算では多段に渡る処理が必要であるのに対し、ビット演算は1個か高々2個の論理ゲートで行えるため、多くの場合、最短サイクルしか必要としない。そのことから、高性能なプログラムを実現するための機械語コーディングではビット演算の使いこなしは重要なテクニックである。

コンピュータの世界では全てのデータを二進数で扱っていて、ビットに対して演算を行う方法は高速な処理を実現できる。ってことですかね…。 私みたいにUnityからゲーム開発を始めた新参者にはなかなか想像しづらいのですが、普段使っているintなどもコンピュータ上では二進数で計算が行われているってことですね。 そして二進数で扱っているからこそできる演算方法がビット演算です。

十進数と二進数

f:id:blacker1017:20180225203712p:plain
前提知識として十進数と二進数の対応表を張っておきます。 見てわかる通り0と1で表されていて、なんかこう桁が上がっていくんですよ(語彙力)。 詳しくはググってください。

ビット演算の使用例

早速ですが実際にどんなときに使えるのかというと、複数のフラグを管理したいときです。 まずはビット演算を使わない例を下のコードに示します。言語はC#です。

public class Smple1
{
    public class ProgrammingSkill
    {
        public bool c = false;
        public bool cpp = false;
        public bool cs = false;
        public bool java = false;
        public bool php = false;
    }
    
    public static void Main()
    {
        ProgrammingSkill mySkill = new ProgrammingSkill();
        mySkill.cpp = true;
        mySkill.cs = true;
        
        OutPut("c", mySkill.c);
        OutPut("cpp", mySkill.cpp);
        OutPut("cs", mySkill.cs);
        OutPut("java", mySkill.java);
        OutPut("php", mySkill.php);
    }
    
    public static void OutPut(string skillName, bool skillFlag)
    {
        string temp = skillFlag ? "はできます!" : "はできません…";
        System.Console.WriteLine(skillName + temp);
    }
}

出力結果

cはできません…
cppはできます!
csはできます!
javaはできません…
phpはできません…

次にビット演算を使うと以下のようになります。 出力結果は変わりません。

public class Smple2
{
    public static int c = 1;    // 00001
    public static int cpp = 2;  // 00010
    public static int cs = 4;   // 00100
    public static int java = 8; // 01000
    public static int php = 16; // 10000
    
    public static void Main()
    {
        //! cppとcsのフラグを立てる
        int mySkill = cpp | cs; // 00110
        
        OutPut("c", (mySkill & c) != 0);
        OutPut("cpp", (mySkill & cpp) != 0);
        OutPut("cs", (mySkill & cs) != 0);
        OutPut("java", (mySkill & java) != 0);
        OutPut("php", (mySkill & php) != 0);
    }
    
    public static void OutPut(string skillName, bool skillFlag)
    {
        string temp = skillFlag ? "はできます!" : "はできません…";
        System.Console.WriteLine(skillName + temp);
    }
}

複数のboolフラグで管理していたmySkillが一つのintで表すこととができています。 考え方としては、intで保存している二進数はそれぞれの桁が必ず0か1なので0がfalse、1がtrueのフラグを桁数分持っていてそれによって判定を行う、ってかんじですかね。

実際に使う演算子をコメントで説明しようとして失敗しているのが下のコードです。 今回のようにintをフラグとして利用する時に使う演算の一例です。 理解できなかったらググってください…

public class Sample3
{
    public static void Main()
    {
        //! 0001 (1)
        //! 0010 (2)
        int temp = 1 | 2;
        //! 0011 (3) ←OR演算の結果
        
        //! 0011 (3)
        //! 0100 (4) 
        temp |= 4;
        //! 0111 (7) ←OR演算の結果
        
        //! 0111 (7)
        //! 1101 (2の反転)
        temp &= ~2;
        //! 0101 (5) ←AND演算の結果
        
        //! tmpは2のフラグが立っているか
        System.Console.WriteLine((temp & 2) != 0);
        //! 出力結果 False
        
        //! 最終的には1と4のフラグが立った状態
    }
}


終わりに

正直、Smple1のように複数のフラグをクラスで管理していた方がイメージしやすく拡張性もあると思うので、 Sample2のようなビット演算の使いどころは慎重に考える必要があると思います。 ただ、二進数のintを複数のフラグとする考え方は面白いと思ったので今回の記事を書きました。 こういう考え方もあるんだ、と知っていただけたら幸いです。

最後にSmple2にあるintのビットデータ宣言を簡単にする方法を以下のコードに記します。

public class Smple4
{
    public int c = 1;           // 00001
    public int cpp = c << 1;    // 00010
    public int cs = cpp << 1;   // 00100
    public int java = cs << 1;  // 01000
    public int php = java << 1; // 10000
}

「<<」はシフト演算?と言うらしいです。 意味としては1の位置を左に一個移動するってことらしいです。 ビットデータを一つずつ手で宣言するより正確なのでこっちをオススメします。

スマホカバーは甘え説

最近はあまり見ませんが以前はスマホの液晶画面にヒビが入っている人を多く見かけました。 実際私の友達も私の目の前で手を滑らせスマホを地面に落とし、液晶画面を粉々にしてくれました。

そういった液晶画面にヒビが入っている人は意外にもスマホカバーをしている人が多かったのです。 そこで一つの仮説が生まれました。

そう、スマホカバーは甘え説」です。

スマホカバーをしている人は心のどこかで"落としても大丈夫だろう"と、慢心が生まれます。 そして普段からスマホを雑に扱い、落としても液晶画面は無事だったというケースを多く経験してしまうことで、人はスマホを落とすことを完全に警戒しなくなっていきます。 こうなってしまうと事故が起きるのは必然と言っても過言ではないでしょう…(震え)

カバー以外にもバンカーリングなどの防衛装置も存在しますが、これらも装着することで慢心が生まれいずれスマホを落とし画面を割ります。

逆にスマホカバーをつけずに2年ほど使ったiPhone5s時代の私は一度もスマホを(腰以上の高さから)落としたことはありません。 これはスマホカバーをつけていないことでスマホを扱う際に必ず"落としてしまうのではないか"という警戒心があったからだと思います。

つまりスマホは何もつけないのが最&高なわけです。

みなさんもスマホにカバーやバンカーリングをつけている人は今すぐ外し、正常な警戒心を取り戻しましよう。 ちなみに私はスマホカバーについているパスケースを使うためだけにスマホカバーをつけています。 断じて甘えではありません。断じて。

※この記事を見てスマホカバーを外し画面にヒビが入ってたとしても、もちろん私は一切責任を追いません。

Unityでオブジェクトのpool化?

pool化とはなんぞや

poolとは...

ソフトウェアの分野では、プログラムの実行時に何度も繰り返し利用する資源をいちいち生成・破棄する手間を軽減するため、必要なくなった資源を回収してすぐに再利用できるように一時的に貯めておく仕組みや保管領域のことをプールという。

プールとは - IT用語辞典

きっかけ

昨日、ある知人(ミリオタ)がFPSゲーム作っていて
銃からでる弾をpool化していなかったので友人と考えながら実装しました。
今回はその実装内容を忘れないように書き残します。

そもそもpool化しない場合

まあ、銃から弾を出すわけですが
出る弾の数が決まっている訳ではないので、
「任意のタイミングで弾を生成する処理」と
「一定時間経ったら弾を削除する処理」が必要になります。

Unityでオブジェクトを生成するにはInstantiate関数を使います。
また、オブジェクトを削除するにはDestroy関数を使います。

実はこの二つの関数が問題です。

自分で検証したことはありませんが、
InstantiateDestroyは処理が重い」とよく聞きます。

そこで、pool化するわけです。

じゃあpool化したい…

pool化するとしても必要な処理は対して変わりません。

「任意のタイミングで弾が必要なら生成する処理」と
「一定時間経ったら弾を非表示にする処理」

生成は必要最低限だけにして削除せずに見えなくするってことです。
※本来は生成される最大数がわかってることが多いですが、
今回は最大数がわからないことを想定して動的に生成します。

下準備

先に発射される弾を作ります。
適当に球のオブジェクトを作って「bullet」に名前を変更します。

次に以下のコードを作成し適用します。

using System.Collections;
using UnityEngine;

public class BulletMove : MonoBehaviour {

    void Start()
    {
        StartCoroutine("ObjectSetActive");
    }

    // activeになったときもコルーチンを始める
    void OnEnable()
    {
        StartCoroutine("ObjectSetActive");
    }

    // コルーチン
    private IEnumerator ObjectSetActive()
    {
        // 2秒後にオブジェクトを非表示にする
        yield return new WaitForSeconds(2.0f);
        gameObject.SetActive(false);
        yield break;
    }

    void Update()
    {
        // 移動処理
        transform.position += Vector3.forward;
    }
}


そして、AssetsフォルダにResourcesフォルダを作成し
作ったオブジェクトをプレハブ化します。

これで下準備は完了です。

ようやく処理を書きます

また何かオブジェクトを作って、以下のコードを作成し適用します。

using System.Collections.Generic;
using UnityEngine;

public class BulletPool : MonoBehaviour {

    private GameObject BulletPrefab;

    // バイトメンバーリスト
    [SerializeField, Header ("弾のリスト")]
    private List<GameObject> Bullets = new List<GameObject>();

    void Start()
    {
        // Resourcesフォルダからbulletプレハブを取得
        // コイツがクローン体の素体
        BulletPrefab = Resources.Load("bullet") as GameObject;
    }

    void Update()
    {
        if(Input.GetKeyDown(KeyCode.Space))
        {
            // 最初はバイトメンバーが0人なので
            // 必ず人員を追加する(きっとコイツがバイトリーダー)
            if (Bullets.Count == 0)
            {
                NewIns();
            }
            // それ以降はバイトメンバーリストで働いていないやつを探す
            else
            {
                bool insRequest = true;

                for (int i = 0; i < Bullets.Count; i++)
                {
                    // 休憩中のやつを探し出してしっかり働かせる
                    if (Bullets[i].activeSelf == false)
                    {
                        ReuseBullet(Bullets[i]);
                        insRequest = false;
                        break;
                    }
                }

                // Bullesリスト内のやつらが全員働いていたので
                // 仕方なく増員
                if (insRequest == true)
                {
                    NewIns();
                }
            }
        }
    }

    // 新しく弾を生成(増員)
    private void NewIns()
    {
        GameObject GO = Instantiate(BulletPrefab,transform.position,Quaternion.identity,transform);
        Bullets.Add(GO);
    }

    // 弾を再利用(「休憩は終わりだ!」)
    private void ReuseBullet(GameObject bullet)
    {
        bullet.transform.position = transform.position;
        bullet.SetActive(true);
    }
}


見てわかる通りリストを作って生成された弾を入れておいて、
任意のタイミングでリスト内で非表示のオブジェクトがあったら使いまわしています。

コメントアウトでは弾をバイト人員に見立てて
説明を面白くしようとしている努力が見られます…

うまく動作していると、
スペースキーを押した時に非表示のオブジェクトが
あれば使いまわし
なければ生成が行われます。

Unityでマルチタッチの情報が少ないと思ったら公式リファレンスにあった件

公式リファレンス↓

Unity - マルチタッチスクリーンの入力

つい先日までスマホゲームを作っていて、
二本の指それぞれの位置を別々に取得して
いろいろしたかったのです。

制作が終わって軽く調べていると以下のコードを見つけました。

using UnityEngine;
using System.Collections;

public class TouchTest : MonoBehaviour 
{
    void Update () 
    {
        Touch myTouch = Input.GetTouch(0);

        Touch[] myTouches = Input.touches;
        for(int i = 0; i < Input.touchCount; i++)
        {
            //タッチすると行う何かをここに記入
        }
    }
}

公式リファレンスより

全てはここに集約されてました。
つまりTouch変数にそのフレームで取得している全ての指の情報が入っていたわけです。
わかりやすくこれをいじって…

using UnityEngine;
using System.Collections;

public class TouchTest : MonoBehaviour
{
    //Canvasで適当にImageをたくさん作って配列に入れてください
    public Transform[] Image;

    void Update()
    {  
        if (Input.touchCount > 0)
        {
            //touchCountが0のときに呼ばれるとエラーでます
            //このフレームでのタッチ情報を取得
            Touch[] myTouches = Input.touches;

            //検出されている指の数だけ回して
            //指の位置にImageを移動
            for (int i = 0; i < myTouches.Length; i++)
            {
                Image[i].position = myTouches[i].position;
            }
        }
    }
}

f:id:blacker1017:20170722232443p:plain
こんな感じに配置して実行すると…
f:id:blacker1017:20170722233349g:plain
超簡単にマルチタッチが実装できました!

分かりずらいですが、
赤い板が一本目、緑の板が二本目、青の板が三本目の指に追従しています。

実行してもらえばわかるのですが、
二本タッチして一本目を離すと、
二本目の指が一本目扱いになります…(意味深)

これを回避するために、
一本目が離れたときに二本目と入れ替えるとか
いろいろ頑張ったのですが結局今回のゲームでは
仕様変更で無駄になりました…
この頑張りを次回にでも書こうかなと思います。

補足

ちなみに普通はUnity Editor上でマルチタッチはできません。
画面タッチに対応しているPCだとEditor上で動作します。
もちろんAndroidiOSにビルドすれば問題なく動作します。
お気を付け下さい…

<動作環境> Surface pro4,windows10,core i7,8GB