アマゾンバナーリンク

ディスプレイ広告

スポンサーリンク

【Unity】実例で学ぶゲーム制作のためのLINQ&ショートコーディング

2021年2月22日

こんにちは!ジェイです。Unityで使われる言語といえばC#ですが、C言語から学んで、昔ながらの書き方をしている人もいると思います。

確かに知らなくても、Unityは優秀なゲームエンジンなため、ゲーム制作自体に支障はないです。しかし、C#でLINQをマスターすることによって、非常にスマートに効率よくソースコードを書けるようになります。

今回はUnityでゲーム制作をする際に、実際によく使うであろう状況(一番近い敵を検索する)などを想定して、LINQの使い方を紹介していきます。

記事内広告

LINQとは

MSDN公式には以下の用に書かれてます。

統合言語クエリ (LINQ: Language-Integrated Query) は、クエリ機能を C# 言語 (および Visual Basic や場合によってその他の .NET 言語) に直接統合する一連の技術の名前です。 LINQ を使用すると、クエリは、クラス、メソッド、イベントなどと同じように、高度な機能を備えた言語構成要素になります。

クエリを記述する開発者の場合、LINQ で最も違いを認識できる “統合言語" 部分はクエリ式です。 クエリ式は、C# 3.0 で導入された宣言クエリ構文で記述します。 クエリ構文を使用すると、データ ソースに対する複雑なフィルター処理、順序付け、およびグループ化の操作を最小限のコードで実行できます。 同じ基本的なクエリ式のパターンを使用して、SQL データベース、ADO.NET データセット、XML ドキュメントとストリーム、および .NET コレクション内のデータを照会および変換します。

MSDN公式

これを初めて見た人は何のことだかわかりませんよね。簡単にいうと「配列などの複数のデータを簡単に抽出、加工、並べ替えができる機能」と思ってもらえば大丈夫です。

LINQの特徴

  • クエリ構文とメソッド構文の2つの書き方がある
  • IEnumerable型に実装されており、IEnumerable型を返す
  • LINQの処理は遅延実行される
  • for文で操作するよりバグが発生しにくい
  • データの集まりを表現する幅広い型に対応している
  • パフォーマンスの低下を招きやすい

以上の特徴があるので、Unityのゲーム制作で使用する場合には、毎ループ実行するUpdateよりも1度きり実行するStart関数で使うことが多くなるでしょう。

クエリ構文とメソッド構文

var list = new List<int> { 1, 84, 95, 95, 40, 6 };

// クエリ構文
var query = from x in list
            where x % 2 == 0
            orderby x
            select x * 3;

// メソッド構文(この記事ではこちらを記法を採用します)
var query = list
            .Where(x => x % 2 == 0)
            .OrderBy(x => x)
            .Select(x => x * 3);

LINQを使うために覚えておくべき知識

  • 型推論
  • 匿名クラス
  • ラムダ式

以上の3つはLINQを使う上で必要な知識になります。

型推論

メソッド内のローカル変数を宣言する際に、実際乗り型宣言の代わりにvarと宣言することによって、コンパイラが自動で型を判断してくれます。

using System;
using System.Collections.Generic;

namespace LinqTest
{
    class MainClass
    {
        public static void Main(string[] args)
        {
            var list = new List<int> { 2, 3, 5, 5, 4, 6 };
            //List<int> list = new List<int> { 2, 3, 5, 5, 4, 6 }; // C# 2.0 以前の書き方

            foreach (var x in list)
            {
                Console.WriteLine(x);
            }
        }
    }
}

匿名クラス

匿名クラスとはクラス名をつけずにnew演算子だけで作成できるクラスです。

匿名クラスは使う時は以下のように書きます。
new { } の中にプロパティ名 = 値 をカンマ区切りで指定します。

new { プロパティ名 = 値, プロパティ名 = 値, プロパティ名 = 値 }

以下はただのサンプルで、この様に書くことはありません。

    var person = new { Name = "Taro", Id = 0 };
    Console.WriteLine (person.Name); // Taroと表示
    Console.WriteLine (person.Id == 0); // Trueと表示

実際には以下の様に使います。index番目の要素がvalueの値を参照しています。

using System;
using System.Collections.Generic;
using System.Linq;

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            List<string> list = new List<string> { "a", "i", "u", "e", "o" };

            foreach (var item in list.Select((value, index) => new { value, index }))
            {
                Console.WriteLine("value = {0}, index = {1}", item.value, item.index);
            }
        }
    }
]

ラムダ式

ラムダ式とは一言でいうと「簡単に短くその場で書ける関数」の事です。

// 普通の関数記法
private int Add(int x, int y)
{
    return x + y;
}

// ラムダ式の記法
(int x, int y) => { return x + y; };

// 徹底的に省略した記法
// 関数本体が1行で済むなら{}とreturnを省略できる
(x, y) => x + y;

ラムダ式はその場で使い捨てにするような使い方をするためにあります。特にメソッド構文のLINQを使う場合は、その場でしか使わないことも多いので、わざわざ長い普通の関数を定義する必要はありません。

ハローワールドを極限まで短くする

プログラミングで一番最初にやることと言えばHelllo World!の出力ですが、C#で普通に書くと以下の様になります。

using System;
class Program
{
	static void Main(string[] arg)
	{
		Console.WriteLine("Hello World!");
	}
}
class Program { static void Main() => System.Console.WriteLine("Hello World!") }

こんな感じで短く書くこともできます。知っておくとかなり可読性を上げられるので、バグの発生原因の特定や予防にも非常に役に立ちます。

実践UnityでLINQとショートコーディング

これで前提の知識は揃ったので、いよいよUnityでゲーム制作をする際によくある場面でのLINQの実例テクニックと短く書く技術を紹介していきます。

条件演算子とnull合体演算子

よくif文でnullでない場合は処理をするという記述をすることは多いと思います。aがnullでないならbを、bがnullでないならcを、cがnullでないならbを、aがnullでないならbを、コンソールに表示するというものです。

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

public class Sample : MonoBehaviour
{
    private void Start()
    {
        string a = null, b = null, c = null, d = "abcd";
        if (a != null) Debug.Log(a);
        else if (b != null) Debug.Log(b);
        else if (c != null) Debug.Log(c);
        else if (d != null) Debug.Log(d);
    }
}

これを短く書くと以下のようになります。これで効率よくnullチェックができるようになります。

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

public class Sample : MonoBehaviour
{
    private void Start()
    {
        string a = null, b = null, c = null, d = "abcd";
        Debug.Log(a ?? b ?? c ?? d);
    }
}

null合体演算子(??)は「値がnullの時に何かの処理を実行する」というものです。

null条件演算子

null合体演算子では、null時の値は指定できるが、null時には何もしないことを指定できません。

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

public class Sample : MonoBehaviour
{
    private string MyToLower(string s)
    {
        if (s == null) return null;
        return s.ToLower();
    }
    private void Start()
    {
        Debug.Log(MyToLower(null));
    }
}

今回の様な「値がnullでない時に何かの処理を実行させる」場合はnull条件演算子(.?)を使います。上のMyToLowerを一行で書くと以下のようになります。

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

public class Sample : MonoBehaviour
{
    private string MyToLower(string s) => s?.ToLower();

    private void Start()
    {
        Debug.Log(MyToLower(null));
    }
}

ここまではLINQは関係ないですが、オンラインゲーム制作で非常にたくさんのnullチェックを行う機会があったので紹介しました。

はじめてのLINQステートメント

まず簡単なことから始めます。GameObject.FindGameObjectsWithTagで検索されるゲームオブジェクトと紐付けされた Transform の配列を作成してみます。

  • 全てのゲームオブジェクトを見つける
  • ゲームオブジェクトの Transform を選択
  • 結果を配列に変換

コードにすると次のようになります。

var transformArray = GameObject.FindGameObjectsWithTag("MyTag")
    .Select(go => go.transform)
    .ToArray();

MyTagと名付けたオブジェクトを配列として集めSelectメソッドにより、GameObject型からTransform型に変換してToArrayメソッドで配列に変換してます。go => go.transformの部分が先ほど説明したラムダ式です。

クエリ実行

それでは次に、現在の位置からワールド座標で 10 単位ほど離れたオブジェクトの Transform を全て取得してみます。

var transformArray = GameObject.FindGameObjectsWithTag("MyTag")
    .Select(go => go.transform)
    .Where(t => Vector3.Distance(t.position - transform.position) < 10
    .ToArray();

ここでは Where関数をselect の後に入れたので、Transform の列挙体になってます。Whereラムダ式は真または偽を返す必要があるため、列挙体のTransform と現在のTransformの間の距離を計算するし10より小さいかどうか調べてます。

ソート実行

Danger という名前のスクリプトが、オブジェクトにアタッチされているとして、このスクリプトには dangerLevelというfloat変数を含むものとします。

次にTransform のリストをこのdangerLevel によってソートします。

var transformArray = GameObject.FindGameObjectsWithTag("MyTag")
    .Select(go => go.transform)
    .Where(t => Vector3.Distance(t.position - transform.position) < 10
    .OrderByDescending(t => {
       var danger = t.GetComponent();
       return danger ? danger.dangerLevel : 0;
      })
    .ToArray();

ここでは OrderByDescending を追加しました(降順に並べます)。最初にコンポーネントを取得して、次にその値を使用するために複数行に渡る関数を記述しました。この様に配列に対して、様々な操作をメソッドチェーンで繋げていくことによって、複雑な操作も簡単に見やすく実行できる事がわかります。

ここまで実行してきた手順を説明すると

  1. MyTagが付いたオブジェクトを取得
  2. SelectメソッドでGameObject型からTransform型に変更
  3. Whereメソッドで現在のオブジェクトから10より距離が短いオブジェクトのみを抽出
  4. 1つのオブジェクトに対してGetComponentを実行し、Dangerスクリプトが存在しないなら0を返す
  5. Dangerスクリプトが存在したら、dangerLevelという変数を取り出しその値を返す
  6. 返されるのはIEnumerable型なので配列にする

ドリルダウン

LINQを使用して、リスト項目のメンバに当たるコレクションのコンテンツにドリルダウンすることも出来きます。
前述の例ではタグが一致した全てのアイテムの transform を戻していました。 もし全てのレンダラを下の階層分も全てほしい場合はどうすれば良いでしょう?ここで SelectMany が役立ちます。

var transformArray = GameObject.FindGameObjectsWithTag("MyTag")
    .SelectMany(go => go.GetComponentsInChildren())
    .ToArray();

SelectMany により、戻り値のコレクションの集合を連結してひとつのコレクションに入れるため、SelectMany を実行した後は全てのアイテムの全てのレンダラが含まれます。

つまりSelectManyメソッドはSelectメソッドによって2次元で返されたコレクションを1次元のコレクションにして(平坦化して)返します。

LINQの中にLINQ

ここで仮に前述の例で期待どおりでなくて、レンダラはあくまでオブジェクト自身と直接の子オブジェクトのみが本来は取得したかったとします。この場合は、LINQの中にLINQを使用することになります。

var transformArray = GameObject.FindGameObjectsWithTag("MyTag")
    .SelectMany(go => go.transform.Cast()
       .Select(t=>t.renderer)
       .Concat(new [] { go.renderer })
       .Where(r=>r!=null)
     )
    .ToArray();

Transformを取得してTransform のコレクションとなるように 型キャストし、次に各々の子オブジェクトのレンダラを選択し、親オブジェクトのレンダラを格納する配列に列挙体を連結していき、次の行でnull でないものだけにこの処理を限定しています。

最も近いオブジェクトを見つける

ゲーム制作でよくあるパターンで今いる座標から一番近いオブジェクトを見つけるというパターンです。

var closestGameObject = GameObject.FindGameObjectsWithTag("MyTag")
    .OrderBy(go => Vector3.Distance(go.transform.position, transform.position)
    .FirstOrDefault();

まずは単純にMyTagの集合を集めて、すべての要素を今いる座標から距離近い順番に並べ変えて、その一番最初の要素を取り出すというものです。

ここで問題は、リストのソートが必要だということです。最も近いオブジェクトを取得するだけのことにソートしてしまうと時間が多くかかってしまいます。

 次にAggregateを使用して、それを回避するように書き直してみます。

var closestGameObject = GameObject.FindGameObjectsWithTag("MyTag")
   .Aggregate((current, next)=> Vector3.Distance(current.transform.position, transform.position) < Vector3.Distance(next.transform.position, transform.position)
      ? current : next);

これはリストを一回だけ実行し、各ステップ毎に二つの候補のアイテムのうち近い方を戻します。リストは一回だけ渡すため何回も処理は行われなくて済むのでソートするより高速です。更に最適化すると

//最適化した C#
var currentPos = transform.position;    //高価なのでキャッシュして節約
var closestGameObject = GameObject.FindGameObjectsWithTag("MyTag")
   .Select( go => new { go = go, position  = go.transform.position })
   .Aggregate((current, next)=>
      (current.position - currentPosition).sqrMagnitude <
      (next.position - currentPosition).sqrMagnitude
      ? current : next).go;

最初のSelectでは二つのメンバつきの新しいクラスを作成しました。その時点で列挙体に入っているのはそれだけです。次にキャッシュされた位置を使って近い方を見つけます。この時にコストの高い平方根演算を回避して sqrMagnitudeを使用してます。最後に近いオブジェクトが得られるが、匿名クラスになっているので、go変数を使用してGameObject型に変換します。

リストおよびディクショナリ

もちろん作成が出来るのは配列だけでなく.ToList() を使用してリストを作成できるし、ディクショナリだって出来ます。

ディクショナリは明確にキーも持っているのでどうやって作成するか見ていくとしましょう。では、シーンにある全てのタグつきのゲームオブジェクトを見つけて、そのタグに基づいてディクショナリに格納してみます。

var lookupByTag = GameObject.FindObjectsOfType(typeof(GameObject))
     .Cast()
     .Where(go=>!string.IsNullOrEmpty(go.tag))
     .ToLookup(go => go.tag);

これにより特殊なディクショナリが作成され、そこで lookupByTag[“タグ名"] によりゲームオブジェクトの一覧にアクセス出来るようになります。例えば、ゲームオブジェクトまでの距離を示すか通常のディクショナリを作成することが出来る(ゲームオブジェクトを関数の引数にするとターゲット地点までの距離を戻す)。いくつものゲームオブジェクトで頻繁に計算が実行される場合にも十分に速い処理です。

var objectToDistance = GameObject.FindObjectsOfType(typeof(GameObject))
     .Cast().ToDictionary(go=>go, go=>Vector3.Distance(go.transform.position, transform.position));

// ゲームオブジェクトの距離を後に取得する時:
var distance = objectToDistance[someGameObject];

ToDictionary の最初の引数がキーであり、二つ目が必要とするディクショナリの値であることに注目してほしい。これはDictionary<GameObject, float>を戻します。

最後に

LINQやラムダ式を使いこなす事で配列,List,Dictionaryなどのあらゆる集合に対して、加工やフィルタリングなどを多重ループやif文を使わずに、直感的でわかりやすいコードが書けることがわかったと思います。一度理解してしまえば、難しいことはないので、速度との折り合いを見てガンガン使っていきましょう!