うどんてっくメモ

技術的なメモをまったりと

【Unity】Anjinを使ってシーン中のuGUIを自動で操作する仕組み作り

はじめに

本記事の検証環境は以下になります。バージョンが違う場合には動作しない場合がありますので、ご留意ください。
また、AnjinはUniTaskを内部的に利用しているため、本記事の非同期処理はUniTaskで記述を行なっています。

  • Anjin 1.0.1
  • Unity 2022.3.0f1
  • UniTask 2.3.3

Anjinとは

AnjinはDeNAが提供しているOSSのUnity向けオートパイロットフレームワークです。

github.com

詳しくは実際に開発に携わっている @nowsprinting さんの記事で説明されています。

swet.dena.com

本記事ではこのAnjinの使い方の紹介として、uGUIで構成されたUIシーンを自動で操作する仕組みの実装を紹介します。 Anjinのセットアップについては省略するので、公式のREADMEを参考にしてください。

Anjinの構成

Anjinの構成は大元の自動操作を行う設定となるAutopilotSettingsと、シーン中の処理を定義するAgentの二つで構成されています。
実行するシーンやAgent、制限時間やエラーハンドリングなどの設定をAutoPilotSettingsで行い、シーン中の特定のボタンを押すといった自動で実行したい操作をAgentとして実装するという流れです。

Agentはある程度Anjin側で用意されていますが、自前のAgentを実装することも可能です。本記事では、uGUIの操作機能を実装したベースとなるAgentを実装し、それを元に具体的にシーン上のUIを操作するAgentを実装します。*1

uGUIを操作するAgentの実装

自前でAgentを実装する際には、AbstractAgentを実装したクラスを実装します。 uGUIの参照と処理を行う関数を加えて、「UIControlAgent」という抽象クラスとして実装します。

using System.Collections.Generic;
using System.Linq;
using Cysharp.Threading.Tasks;
using DeNA.Anjin.Agents;
using UnityEngine;
using UnityEngine.Assertions;
using UnityEngine.EventSystems;

/// <summary>
/// UIの操作を行う機能を持つAgent
/// </summary>
public abstract class UIControlAgent : AbstractAgent
{
    /// <summary>
    /// UIの対象となるカメラ
    /// </summary>
    protected Camera _camera;
    
    protected void ClickObject(string objectName)
    {
        var target = GameObject.Find(objectName);
        Assert.IsNotNull(target, $"対象が見つかりませんでした: {objectName}");
        
        SimulateClick(target.transform);
    }
    
    protected void ClickObject(string objectName, string parentName)
    {
        var parent = GameObject.Find(parentName);
        var target = parent.GetComponentsInChildren<Transform>()
            .FirstOrDefault(x => x.name == objectName);
        Assert.IsNotNull(target, $"対象が見つかりませんでした: {parentName}-{objectName}");
        
        SimulateClick(target.transform);
    }
    
    protected async UniTask DragObject(string objectName, Vector2 startOffset, Vector2 endOffset, double duration)
    {
        var target = GameObject.Find(objectName);
        Assert.IsNotNull(target, $"対象が見つかりませんでした: {objectName}");
        
        await SimulateDrag(target, startOffset, endOffset, duration);
    }
    
    protected void SimulateClick(Transform target)
    {
        // クリック位置の定義
        var eventData = new PointerEventData(EventSystem.current)
        {
            position = RectTransformUtility.WorldToScreenPoint(_camera, target.position)
        };

        // EventSystems経由でクリック
        ExecuteEvents.Execute(target.gameObject, eventData, ExecuteEvents.pointerClickHandler);
    }

    protected async UniTask SimulateDrag(GameObject target, Vector3 startOffset, Vector3 endOffset, double duration)
    {
        // ドラッグの始点と終点の定義
        var targetPosition = target.transform.position;
        var startPositionWorld = targetPosition + startOffset;
        var endPositionWorld = targetPosition + endOffset;
        var startPosition = RectTransformUtility.WorldToScreenPoint(_camera, startPositionWorld);
        var endPosition = RectTransformUtility.WorldToScreenPoint(_camera, endPositionWorld);
        
        var eventData = new PointerEventData(EventSystem.current)
        {
            position = startPosition,
            pressPosition = startPosition,
        };
        
        var raycastResults = new List<RaycastResult>();
        EventSystem.current.RaycastAll(eventData, raycastResults);
        eventData.pointerCurrentRaycast = raycastResults.FirstOrDefault(x => x.gameObject == target.gameObject);

        // 始点からEventSystems経由でドラッグ開始
        ExecuteEvents.Execute(target.gameObject, eventData, ExecuteEvents.pointerDownHandler);
        ExecuteEvents.Execute(target.gameObject, eventData, ExecuteEvents.beginDragHandler);
        
        // durationにそって移動
        var elapsedTime = 0d;
        while (elapsedTime < duration)
        {
            // EventSystems経由でドラッグしたまま位置を調整
            eventData.position = Vector2.Lerp(startPosition, endPosition, (float)(elapsedTime / duration));
            ExecuteEvents.Execute(target.gameObject, eventData, ExecuteEvents.dragHandler);
            elapsedTime += Time.deltaTime;
            await UniTask.Yield();
        }
        
        // 終点でEventSystems経由でドラッグ終了
        ExecuteEvents.Execute(target.gameObject, eventData, ExecuteEvents.endDragHandler);
        ExecuteEvents.Execute(target.gameObject, eventData, ExecuteEvents.pointerUpHandler);
    }
}

ButtonやToggleなどの押したい対象のGameObjectを名前で指定し、その座標に対してEventSystemsから入力処理を行います。シーンの検証などを目的として実行するだけを想定しているので、特段パフォーマンスなども気にしていません。また、フリックなどのここにないUI操作は別途実装が必要です。
ButtonなどのComponentを取得し、onClickなどを発火させればいいのではという方もいるかもしれません。それだと何かしらの処理でButtonの入力をブロックしていたり、上に別のUIが重なっているときでも押せてしまいます。なので、直接uGUIの処理を呼ばずに、座標ベースで入力をシミュレートする形で実装を行なっています。

後は、これを継承したuGUIの操作フローを担当するAgentを実装します。

UIControlAgentの実行

まずuGUIの操作を行うシーンのサンプルとして、公式が出している「UI Samples」を利用します。 「UI Samples」にはいくつかのサンプルシーンが用意されていますが、その中の「Menu 3D」というシーンを使用します。

assetstore.unity.com

適当なUIを操作するシナリオを構築し、UIControlAgentを継承したクラスに実装します。

Agentを実装するときのルールとして、AbstractAgentを継承した具象クラスは実行部分となるRunという関数を実装する必要があります。 そこにUIControlAgentの機能を使って、UIを操作するフローを実装します。

using System;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

/// <summary>
/// UI SamplesのMenu 3Dを適当に操作するAgent
/// </summary>
[CreateAssetMenu(fileName = "SampleUIControlAgent", menuName = "Anjin/UGUIControl Sample/SampleUIControlAgent")]
public class SampleUIControlAgent : UIControlAgent
{
    public override async UniTask Run(CancellationToken token)
    {
        _camera = GameObject.Find("GUI Camera").GetComponent<Camera>();
        
        // 最初のアニメーションを待ってみる
        await UniTask.Delay(TimeSpan.FromSeconds(2d), cancellationToken: token);
        
        // Settingsを押す
        ClickObject("Settings");
        await UniTask.Delay(TimeSpan.FromSeconds(1.5d), cancellationToken: token);
        
        // Videoを押す
        ClickObject("Video");
        await UniTask.Delay(TimeSpan.FromSeconds(1.5d), cancellationToken: token);
        
        // Quality Sliderをいじる
        // 本来はOffsetは色々計算する必要があるが、ここでは解像度や角度や動的なサイズを無視して、1920*1080の環境を想定した固定値のOffsetとする
        await DragObject("Quality Slider", new Vector3(-36f, 0f), new Vector2(-42f, 0f), 2.0d);
        await UniTask.Delay(TimeSpan.FromSeconds(1.0d), cancellationToken: token);
        
        // View Distance Sliderをいじる
        // 本来はOffsetは色々計算する必要があるが、ここでは角度や動的なサイズを無視して、1920*1080の環境を想定した固定値のOffsetとする
        await DragObject("View Distance Slider", new Vector3(-36f, 0f), new Vector2(-42f, 0f), 2.0d);
        await UniTask.Delay(TimeSpan.FromSeconds(1.0d), cancellationToken: token);
        
        // Anti-Aliasing toggleを押す
        ClickObject("Anti-Aliasing toggle");
        await UniTask.Delay(TimeSpan.FromSeconds(1.5d), cancellationToken: token);
        
        // AO Toggleを押す
        ClickObject("AO Toggle");
        await UniTask.Delay(TimeSpan.FromSeconds(1.5d), cancellationToken: token);
        
        // Closeを押す
        ClickObject("Close", "VideoWindow");
    }
}

UIControlAgentによって、UIを操作する流れを直感的に実装を行うことができました。

ドラッグについては、本来ドラッグしたい長さに対してちゃんとした長さを解像度やRectに合わせて計算する必要があります。今回は1920*1080の解像度での実行を想定した決め打ちの値となっているので注意してください。

実装したら、エディタ上でAgentのAssetを作成します。今回MenuItemのAttributeもつけたので、メニューからAnjin/UGUIControl Sample/SampleUIControlAgentを選択して作成できます。

次にAutoPilotSettingの設定を行います。UI SamplesのシーンでSampleUIControlAgentが動けばいいので、次のように最低限の設定を行います。

あとはAutoPilotSettingから実行します。下の方にあるRunを押下すると、シーンがPlayされて自動で処理が行われます。

以上でUIを一通り触る仕組みが完成しました。

サンプルのシーンをただ押しているだけなので失敗する要素がないですが、何かしら更新がある開発途中のシーンであれば、UIによる機能が正常かどうかを確かめることができます。 このようにAnjinを使ってシーン上での順序立てた操作を手軽に自動実行することが可能です。

また、Agentで実装した処理が失敗した場合、Slackに通知したり、エラーを出力したりできるようになっています。さらにはCIとして回すためにCLIでの実行などもサポートされています。 本記事ではAnjinによって自動でUIを動かすところまでで説明を終えますが、Anjinを自動テストの仕組みとして運用する場合にはそういった仕組みも活用してみてください。

*1:AnjinではUnity公式パッケージであるAutomated QAの併用も想定しており、Playbackという操作の記録機能のJSONデータを利用する「UGUIPlaybackAgent」も用意されています。 Playbackのデータを用意してこれを利用してもいいのですが、手順が面倒だったりそもそもAutomated QAを理解する必要があります。本記事ではその部分は省略するため、自前でAgentを実装する方向としています。