ゲーム化!tomo_manaのブログ

ゲーム化!tomo-manaのブログ

Unityでゲームを作る方法について紹介しています

Unity学習#14 (Unity 2019.4.1f1) 大きさを変えられるリスト

今回は、要素数で大きさを変えられるリストに挑戦しました。第13回で作成したアイテムやコマンド一覧、選択肢の表示に使います。しかし、これが思っていたよりもかなり大変で、理解にかなり時間がかかりました。リストの基本要素と、GameObjectが階層を持っている時の各GameObjectへのアクセスの仕方はそれぞれ別の記事にしました。

リストの基本形

リストの作成にはScrollViewを使用します。ScrollViewを使ったリストの基本形は以下の記事にまとめました。
tomo-mana.hatenablog.com

上の記事の中で、ScrollViewに、リストの子要素としてPanelを1つ追加します。ScrollView の下にある Content にはAdd Component で Vertical Layout Group と Content Size Fitter を付けてあります。
f:id:tomo_mana:20200908073127p:plain

リストの子要素の作り込み

リストの子要素として追加した Panel に、テキストを追加します。テキストは Text と TextMeshPro があります。Text に比べて TextMeshPro の方が拡大縮小などに強く、全体的に高機能なようですので、TextMeshPro を使います。

テキストの追加 (TextMeshPro)

(1) Hierarchyウィンドウで、Panel (子要素としての) 上で右クリック
(2) Create > UI > Text - TextMeshPro を選択

テキストのサイズと行間の設定

テキストのフォントサイズは、今の時点では 16 にします(単位調査中)。16x16ドットのTilemap を Main Camera の Size = 7 で表示すると、デフォルトのフォント(LiberationSans SDF) では 16 にすると、ちょうどTilemap半マス分の大きさでした。

テキストサイズと行間について、初期のファミコンソフトでは、キャラクターサイズが16x16byte、文字サイズは8x8byteが多かったのでは思います。ファミコンの頃はハードの保存領域が非常に小さかったので、保存領域を無駄なく使うために、画像データや文字データなど表示に使用するものはデータサイズと個数が厳密に管理され、大概は2の階乗bitで区切られていました。(念のためドラクエなどいくつかの当時のゲーム画面を確認しました)
また、コマンドは4~8文字、アイテムも8~32文字までが多かったと思います。行間はゲームごとに50~100%だったと思います。最近のデジタル印刷では50~70%程度が読みやすいようですね。

今回は、リストの文字数を8文字、行間を50%としました。文字の左にカーソルかアイコンを入れるか迷っているので、ひとまず+2文字分のスペースを空けます。

TextMeshPro - Text (UI) の設定
パラメータ
Font Style B (Bold)
Font Size 16
f:id:tomo_mana:20200908195242p:plain
TextMeshPro - Text > Main Setting
TextMeshPro - RectTransform の設定
パラメータ
Anchor Preset middle/center
Pos X 32 (2文字分)
Width 160 (10文字分)
Height 16
f:id:tomo_mana:20200908195409p:plain
TMP - Rect Transform
Panel (リストの子要素) - Rect Transform の設定
パラメータ
Anchor Preset left/top
Width 160 (10文字分)
Height 24 (行間50%)
f:id:tomo_mana:20200908195446p:plain
Panel - RectTransform
Panel (リストの子要素) - Layout Element の設定
パラメータ
Min Height 24 (行間50%)
f:id:tomo_mana:20200908195953p:plain
Panel- Layout Element

※TextMeshPro の日本語化とフォントの設定は次回やります。

プレハブ化

作成したリストの子要素をプレハブ (Prefab) にして、一旦リストから削除します。

Panel (子要素としての) をプレハブすると、プレハブ名が Panel になります。そのままの名前だと、何に使うものか分からないので、適当な名前に変えます。

(1) Hierarchy ウィンドウ : Panel (リストの子要素としての)を選択
(2) Inspector ウィンドウ:Panel と書かれている欄をクリック > 適当な名前を入力
(ここではScrollNodeとします)
(3) Panel (子要素としての) を Project ウィンドウにドラッグ&ドロップ

プレハブになると GameObject は青色になります。

f:id:tomo_mana:20200904185446p:plain
Prefab化したGameObject (青色)
f:id:tomo_mana:20200904185644p:plain
Prefab化したGameObject (Projectウィンドウ)

プレハブ化した子要素の削除

プレハブ化したリストの子要素は一旦Hierarchyウィンドウから削除します。Hierarchyウィンドウから先ほどの Panel を消しても、プレハブは残ります。

プレハブ化した子要素を見たい時は

プレハブ化したリストの子要素の内容を見るには、Project フォルダにあるプレハブのアイコンをダブルクリックします。通常の GameObject と同じく、プレハブも編集ができます。また、編集した内容は自動的に保存されます。

f:id:tomo_mana:20200904185914p:plain
Prefabの確認

プレハブの読み込み

プレハブ化した GameObject は、スクリプトから読み出します(必要な間だけコピーを作ることができます)。以下に各操作を行う時のスクリプトをまとめます。

プレハブの読み込み(コピーの作成)は Object.Instantiate() で行います。

[SerializeField] GameObject itemPrefab;

リストの子要素にテキストを代入する

プレハブ化したリストの子要素にテキストを代入するには、以下のようにします。

子オブジェクトの取り出し
GameObject itemTextObj = gameObject.transform.GetChild(0).gameObject;
テキストの書き換え
using TMPro;            // TextMeshProUGUI

TextMeshProUGUI itemText = itemTextObj.GetComponent<TextMeshProUGUI>();
itemText.text = "test";

リストに子要素を追加する

transform.SetParent( Transform );

今回のように、リストの子要素にアクセスする場合、階層化されたGameObjectへのアクセスは少し分かりにくい点があります。また、ここまでは見様見真似でコーディングできましたが、これ以上はゲームオブジェクトの基本的な構造を知らないと続けられないと感じました。ゲームオブジェクトが階層化されている時の各要素へのアクセス方法と、ゲームオブジェクトを構成しているGameObject、Component、Transformそれぞれにアクセスする方法については、以下にまとめました。

tomo-mana.hatenablog.com

試しにスクリプトからプレハブになったリストの子要素を3つ作成して、画面に表示できることを確認します。

上記を合わせたコード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI

public class ContentManager : MonoBehaviour
{
    [SerializeField] GameObject scrollNodePrefab;
    
    // Start is called before the first frame update
    void Start()
    {
        int i;
        
        for(i = 0; i < 3; i++){
            GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
            
            // 子オブジェクトの取得には、GameObject から Transform 経由で 子Transform を取得し、そのGameObject を取得
            // オブジェクトのコンポーネントを取得するには、GameObject.GetComponent<T>(); を使う
            TextMeshProUGUI itemText = itemObj.transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            itemText.text = "test" + i;
            
            // オブジェクトを親に登録するためにも、Transform を使用する
            itemObj.transform.SetParent( gameObject.transform, false );
        }
    }
}

Content に C#スクリプトを付けて、試しに動かします。

(1) Projectウィンドウで右クリック > Create > C# Script
名前を付けてから保存
(ここでは ContentManager にします)
(2) 上記のコードを作成
(3) Hierarchyウィンドウ > Content を選択
(4) Projectウィンドウの C#スクリプト を Content に ドラッグ&ドロップ

出力すると以下のようになります。

f:id:tomo_mana:20200904230607p:plain
リストの子要素 (Panel) 出力テスト

リストの大きさを変える

さて、ここから本題に入ります。

リストに表示する内容によって、リストの個数を変えられるようにしたいので、リストの内容と個数を受け取れる箱を準備します。リストの内容は string 配列を使います。最初はテスト用に string配列を SerializeField に指定します。SeralizeField に指定された string配列は、Inspectorウィンドウ上で配列の長さと、それぞれの箱に入れる文字を指定できます。

[SerializeFIeld] string[] itemStr;
f:id:tomo_mana:20200905061133p:plain
String サイズ入力前
f:id:tomo_mana:20200905061145p:plain
String サイズ入力後


次に string配列 の受け皿になるプレハブのリストを作ります。

プレハブを使ってリストを作る場合に限らず、プログラム全体として new() や instantiate() が何回呼ばれるかは注意が必要です。Unityに限らず、C#などのオブジェクト指向言語は、これまでメモリに存在していなかったオブジェクトを一時的に作るために、一時的に借りる領域(動的領域。ヒープ領域などとも呼ばれます)が用意されています。一時的に作ったオブジェクトがもう再利用されないと判断されると、そのオブジェクトのために貸し出していた領域を解放する処理(ガベージコレクション)を行います。ガベージコレクションはいつ動くかを意識しないように作ってあるため、通常はプログラムを作る側からは分からないタイミングでメモリを開放しますが、プログラム全体の参照を走査するので大概は重たい処理になります。また、タブレットで画面の縦横回転を繰り返したり、画面をスリープさせてからプログラムを再開させた時に、プログラムが突然終了したりする場合は、メモリ不足(ヒープ領域の取得失敗)を疑うことがあります。

コマンドやアイテム表示のために使うプレハブなどのように、ゲーム内で頻繁に使うことが分かっている場合は、表示するたびに new() するとガベージコレクションの頻度が増えるため、最初から作り置き(プール)して表示・非表示を変えるのが良いかと思います。


先ほど作ったコードを少し変更します。

先ほど、リストの子要素を3つ表示するために作った for文 を プールを作成する処理に変更します。あらかじめSerializeFieldに指定されたリスト最大サイズに従って、ゲームの起動時にプレハブをインスタンス化します。また、先ほどのコードから、プレハブのインスタンス化とテキストの代入を分けて、それぞれ別々にfor文を回します。

コード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI

public class ContentManager : MonoBehaviour
{
    [SerializeField] GameObject scrollNodePrefab;
        
    // リストに表示する内容
    public string[] itemString;

    // 表示用のリストの子要素 GameObject のプール
    // リストは最大8個まで表示するとする。
    private List<GameObject> itemPool;
    private int itemMax = 8;
    
    // Start is called before the first frame update
    void Start()
    {
        // リスト表示数を制限
        if( itemMax > 8 ){
            itemMax = 0;
        } else if( itemMax < 2 ){
            itemMax = 2;
        }
        
        // リストの子要素のプールを作成
        itemPool = new List<GameObject>();
        
        // プレハブのインスタンス化
        int i;
        for(i = 0; i < itemMax; i++){
            GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
            itemPool.Add( itemObj );
        }
        
        SetContent();
    }

    // SetContent で string を取り込み、content に連結
    void SetContent()
    {
        int i = 0;
        
        int max = itemString.Length;
        if( max > itemMax ){
            max = itemMax;
        }
        for ( i = 0; i < max; i++ ){
            // 子オブジェクトの取得には、GameObject から Transform 経由で 子Transform を取得し、そのGameObject を取得
            // オブジェクトのコンポーネントを取得するには、GameObject.GetComponent<T>(); を使う
            TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            
            // TextMeshPro も内部のテキスト要素は .text でアクセスする
            // string は = で代入できる
            itemText.text = itemString[i];
            
            // オブジェクトを親に登録するためにも、Transform を使用する
            itemPool[i].transform.SetParent( gameObject.transform, false );
        }
    }

    // disable で Pool を解放
    void Disable()
    {
        // ウィンドウの子要素を開放する
        gameObject.transform.DetachChildren();
    }
}

リストの子要素の数に応じてリストのサイズを計算する

次に、リストの子要素の数に応じてリスト全体の画面上の大きさを計算します。以下に、リストの子要素 (Rect Transform) のサイズを取得する方法と、リスト全体の画面上の大きさを計算する処理をまとめます。

リストの子要素のサイズを取得する

リストの子要素のサイズは、RectTransform から取得します。RectTransform は Transform を継承したクラスのため、Transform と同じ方法で取得します。また、RectTransform は 2つのサイズ rect と sizeDelta を持っています。rect は sizeDelta より広範囲な情報を持っていて、要素の大きさや座標を取得するのに適しています。大きさの変更には sizeDelta を使用します。

rect
x, y: 開始位置。
width, height: 大きさ。
xMin, xMax, yMin, yMax: x, y, width, height で定義される大きさの四角を座標にプロットしたもの。

sizeDelta
親子の大きさが異なる場合は自身のサイズを返すが、親と一致する場合は倍率を返します。
→ 大きさを取得する時は rect を使用します。

rect は内部で計算された自身のサイズで、読み出し専用と思われます。
その場合、変更できるのは sizeDelta のみです。

// リストの子要素の大きさ(RectTransform)
private Vector2 itemSize;

// リストの子要素のサイズは RectTransform.rect から取得する
// RectTransform は Transform の継承クラスで、transform のキャストで取得可能
RectTransform rectTransform = itemPool[0].transform as RectTransform;

// リストのサイズをVector2形式で保存しておく
itemSize = new Vector2(
    rectTransform.rect.width,
    rectTransform.rect.height
);
リストのサイズを計算する
// リストの子要素の大きさ(RectTransform)
private Vector2 itemSize;

// リストの要素数
private int max;

// リストサイズ
private Vector2 listSize;

listSize = new Vector2(
    itemSize.x,
    itemSize.y * max
);

先ほどのコードをさらに修正して、リストに表示する内容を外から SetContent(string[]) で設定して、リストサイズを GetContentSize() で取得するようにします。

コード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using TMPro;            // TextMeshProUGUI

public class ContentManager : MonoBehaviour
{
    [SerializeField] GameObject scrollNodePrefab;
    
    // 表示用のリストの子要素 GameObject のプール
    // リストは最大8個まで表示するとする。
    private List<GameObject> itemPool;
    private int itemMax = 8;
    
    // リストに表示する内容
    private string[] itemString;
    private int max;
    
    // アイテムセット
    public void SetContent(string[] str)
    {
        itemString = str;
        
        max = itemString.Length;
        if( max > itemMax ){
            max = itemMax;
        }
        
        for ( int i = 0; i < max; i++ ){
            // 子オブジェクトの取得には、GameObject から Transform 経由で 子Transform を取得し、そのGameObject を取得
            // オブジェクトのコンポーネントを取得するには、GameObject.GetComponent<T>(); を使う
            TextMeshProUGUI itemText = itemPool[i].transform.GetChild(0).gameObject.GetComponent<TextMeshProUGUI>();
            
            // TextMeshPro も内部のテキスト要素は .text でアクセスする
            // string は = で代入できる
            itemText.text = itemString[i];
            
            // オブジェクトを親に登録するためにも、Transform を使用する
            itemPool[i].transform.SetParent( gameObject.transform, false );
        }
    }
    
    // アイテムサイズ取得
    public Vector2 GetContentSize()
    {
        RectTransform rectTransform = itemPool[0].transform as RectTransform;
        
        return new Vector2(
            rectTransform.rect.width,
            rectTransform.rect.height * max
        );
    }
    
    // Start is called before the first frame update
    void Start()
    {
        // リスト表示数を制限
        if( itemMax > 8 ){
            itemMax = 0;
        } else if( itemMax < 2 ){
            itemMax = 2;
        }
        
        // リストの子要素のプールを作成
        itemPool = new List<GameObject>();
        
        // プレハブのインスタンス化
        int i;
        for(i = 0; i < itemMax; i++){
            GameObject itemObj = Object.Instantiate( scrollNodePrefab ) as GameObject;
            itemPool.Add( itemObj );
        }
    }
    
    // disable で Pool を解放
    void Disable()
    {
        // ウィンドウの子要素を開放する
        gameObject.transform.DetachChildren();
    }
}
リストサイズの反映

リストの見た目上の大きさは、Content でなく ScrollView 側の RectTransform に反映する必要があります。

f:id:tomo_mana:20200901224749p:plain
ScrollView と Content (Unity学習#14-1)

ScrollView の RectTransform は ScrollRect が使用しています。ScrollRect は Viewport 用のスペースの他に、Vertical Scrollbar と Horizontal Scrollbar の領域を持っています。Scrollbar は表示の必要が無い場合は ScrollRect の計算で非表示になるため、純粋なContentのサイズを ScrollView の RectTransform に反映します。

今度は ScrollView に C#スクリプトを付けます。

(1) Projectウィンドウで右クリック > Create > C# Script
名前を付けてから保存
(ここでは ScrollViewController にします)
(2) 下記のコードを作成
(3) Hierarchyウィンドウ > Content を選択
(4) Projectウィンドウの C#スクリプト を Content に ドラッグ&ドロップ

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;   // ScrollRect

public class ScrollViewController : MonoBehaviour
{
    [SerializeField] string[] itemString;
    private ContentManager content;
    
    // Start is called before the first frame update
    void Start()
    {
        content = gameObject.transform.GetChild(0).GetChild(0).GetComponent<ContentManager>();
        
        // テスト
        Enable();
    }
    
    void Enable()
    {
        content.SetContent( itemString );
        Vector2 contentSize = content.GetContentSize();
        
        // ScrollView を Content と同じサイズにする
        RectTransform viewRect = gameObject.transform as RectTransform;
        Vector2 view = viewRect.sizeDelta;
        view.x = contentSize.x;
        view.y = contentSize.y;
        viewRect.sizeDelta = view;
    }
}

出力すると以下のようになります。

f:id:tomo_mana:20200909084731p:plain
出力イメージ

参考にしたサイト

かなりいろんなサイトを参考にさせていただきました。それぞれかなり役に立ったのですが、どこを調べたか途中で分からなくなりました。
ScrollView全体について以下のサイトが参考になりました。
qiita.com

次にやること

テキストの日本語化とフォントの追加