うどんてっくメモ

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

【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を実装する方向としています。

【C#】IEquatableの実装をSource Generatorで生やす

Generator.Equals

C#には等価性評価を行うIEquatableがありますが、大抵の実装はメンバのパラメータでの等価性評価を統合したようなものになり、いわばテンプレートのような実装になりがちです。 そこで、実装を自動生成したい!となった時に便利なのがGenerator.Equalsです。Source Generatorを活用して、IEquatableの実装をよしなに生やしてくれます。

github.com

今回は簡単なこちらの使い方を紹介します。

使い方

導入自体はNuget経由で導入可能です。Visual StudioなどのIDEを使用している場合はサクッと入れられます。

Unityのプロジェクトなどの場合はパッケージを直接入れちゃうパターンもあるかと思います。

www.nuget.org

使い方はシンプルで、Attributeで制御するだけです。 対象のclassやstructについてpartialにし、Attributeを次のようにつけます。

// partialにする
[Equatable]
public partial class Sample
{
    public int Number { get; set; }
    public string Id { get; set; }
    public float Value { get; set; }
}

public class SampleTest
{
    public void Test()
    {
        var hoge = new Sample
        {
            Number = 0,
            Id = "hoge",
            Value = 0
        };
        var fuga = new Sample
        {
            Number = 0,
            Id = "fuga",
            Value = 0
        };
        // a == false
        var a = hoge.Equals(fuga);
        Console.WriteLine(a);
        
        // b == true
        hoge.Id = "fuga";
        var b = hoge.Equals(fuga);
        Console.WriteLine(b);
    }
}

非常にシンプルにIEquatableの実装が実現できます。 このEqualsの実装として生えている実装を見に行ってみます。

partial class Sample : global::System.IEquatable<Sample>
{
    /// <summary>
    /// Indicates whether the object on the left is equal to the object on the right.
    /// </summary>
    /// <param name="left">The left object</param>
    /// <param name="right">The right object</param>
    /// <returns>true if the objects are equal; otherwise, false.</returns>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public static bool operator ==(
        global::Plugins.SourceGenerator.Sample? left,
        global::Plugins.SourceGenerator.Sample? right) =>
        global::Generator.Equals.DefaultEqualityComparer<global::Plugins.SourceGenerator.Sample?>.Default
            .Equals(left, right);
    
    /// <summary>
    /// Indicates whether the object on the left is not equal to the object on the right.
    /// </summary>
    /// <param name="left">The left object</param>
    /// <param name="right">The right object</param>
    /// <returns>true if the objects are not equal; otherwise, false.</returns>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public static bool operator !=(global::Plugins.SourceGenerator.Sample? left, global::Plugins.SourceGenerator.Sample? right) =>
        !(left == right);
    
    /// <inheritdoc/>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public override bool Equals(object? obj) =>
        Equals(obj as global::Plugins.SourceGenerator.Sample);
    
    /// <inheritdoc/>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public bool Equals(global::Plugins.SourceGenerator.Sample? other)
    {
        return
            !ReferenceEquals(other, null) && this.GetType() == other.GetType()
            && global::Generator.Equals.DefaultEqualityComparer<global::System.Int32>.Default.Equals(this.Number!, other.Number!)
            && global::Generator.Equals.DefaultEqualityComparer<global::System.String>.Default.Equals(this.Id!, other.Id!)
            && global::Generator.Equals.DefaultEqualityComparer<global::System.Single>.Default.Equals(this.Value!, other.Value!)
            ;
    }
    
    /// <inheritdoc/>
    [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Generator.Equals", "1.0.0.0")]
    public override int GetHashCode()
    {
        var hashCode = new global::System.HashCode();
        
        hashCode.Add(this.GetType());
        hashCode.Add(
            this.Number!,
            global::Generator.Equals.DefaultEqualityComparer<global::System.Int32>.Default);
        hashCode.Add(
            this.Id!,
            global::Generator.Equals.DefaultEqualityComparer<global::System.String>.Default);
        hashCode.Add(
            this.Value!,
            global::Generator.Equals.DefaultEqualityComparer<global::System.Single>.Default);
        
        return hashCode.ToHashCode();
    }
}

EqualsやGetHashCodeがメンバに沿って生成されています。Generator.Equalsでは等価性の評価に組み込むメンバを調整することも可能です。 たとえば無視したいメンバにはIgnoreEqualityをつけることで実現できます。

[Equatable]
public partial class Sample
{
    // 評価に入らない
    [IgnoreEquality]
    public int Number { get; set; }
    public string Id { get; set; }
    public float Value { get; set; }
}

コレクション系統のclassでは内部配列の中身で等価性の評価が求められることもありますが、そう言った需要にも応えられるようになっています。

[Equatable]
public partial class Sample
{
    // SequenceEqualで評価
    [OrderedEquality]
    public int[] Numbers { get; set; }
    // 順序を問わない等価性評価、要素数でチェックする
    [UnorderedEquality]
    public string[] Labels { get; set; }
    // ISet.SetEqualsを利用した等価性評価
    [SetEquality]
    public HashSet<string> LabelSet { get; set; }
}

自前実装のEqualityComparerもAttributeの引数で設定できるようになっています。

[CustomEquality(typeof(CustomEqualityComparer))] 
public string Name1 { get; set; }

内部実装

内部的にはIEqualityComparerをそれぞれの設定に沿って実装し、それを使うようなソースコードを生成しています。 たとえば何もAttributeをつけないメンバではDefaultEqualityComparerによるロジックが適用されています。

public class DefaultEqualityComparer<T> : IEqualityComparer<T>
{
    private static readonly IEqualityComparer<T> _underlying;

    public static DefaultEqualityComparer<T> Default { get; } = new DefaultEqualityComparer<T>();

    static DefaultEqualityComparer()
    {
        if (typeof (T).IsSealed)
        DefaultEqualityComparer<T>._underlying = (IEqualityComparer<T>) EqualityComparer<T>.Default;
        else
        DefaultEqualityComparer<T>._underlying = (IEqualityComparer<T>) new DefaultEqualityComparer<T>.ObjectEqualityComparer();
    }

    public bool Equals(T x, T y) => DefaultEqualityComparer<T>._underlying.Equals(x, y);

    public int GetHashCode(T obj) => DefaultEqualityComparer<T>._underlying.GetHashCode(obj);

    private class ObjectEqualityComparer : IEqualityComparer<T>
    {
        public bool Equals(T x, T y) => object.Equals((object) x, (object) y);

        public int GetHashCode(T obj) => (object) obj == null ? 0 : obj.GetHashCode();
    }
}

対処のメンバを列挙し、適切な実装をひとつずつ実行します

// すべてのメンバに対するEqualsの生成処理の抜粋
public static void BuildMembersEquality(
    ITypeSymbol symbol,
    AttributesMetadata attributesMetadata,
    IndentedTextWriter writer,
    bool explicitMode,
    Predicate<ISymbol>? filter = null)
{
    foreach (ISymbol propertiesAndField in symbol.GetPropertiesAndFields())
    {
        if (filter == null || filter(propertiesAndField))
        {
            switch (propertiesAndField)
            {
                case IPropertySymbol memberSymbol1:
                    EqualityGeneratorBase.BuildEquality(attributesMetadata, writer, (ISymbol) memberSymbol1, memberSymbol1.Type, explicitMode);
                    break;
                case IFieldSymbol memberSymbol2:
                    EqualityGeneratorBase.BuildEquality(attributesMetadata, writer, (ISymbol) memberSymbol2, memberSymbol2.Type, explicitMode);
                    break;
                default:
                    DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(29, 1);
                    interpolatedStringHandler.AppendLiteral("Member of type ");
                    interpolatedStringHandler.AppendFormatted<Type>(propertiesAndField.GetType());
                    interpolatedStringHandler.AppendLiteral(" not supported");
                    throw new NotSupportedException(interpolatedStringHandler.ToStringAndClear());
            }
        }
    }
}

// 一つ一つの生成処理部分の抜粋
private static void BuildEquality(
    AttributesMetadata attributesMetadata,
    IndentedTextWriter writer,
    ISymbol memberSymbol,
    ITypeSymbol typeSymbol,
    bool explicitMode)
{
    // ..中略 各種Attributeでの実装
    
    // DefaultEqualityComparerに対する実装部分 
    else
    {
        if (!memberSymbol.HasAttribute(attributesMetadata.DefaultEquality) && (explicitMode || !(memberSymbol is IPropertySymbol)))
            return;
        IndentedTextWriter indentedTextWriter = writer;
        DefaultInterpolatedStringHandler interpolatedStringHandler = new DefaultInterpolatedStringHandler(85, 3);
        interpolatedStringHandler.AppendLiteral("&& global::Generator.Equals.DefaultEqualityComparer<");
        interpolatedStringHandler.AppendFormatted(nullableFqf);
        interpolatedStringHandler.AppendLiteral(">.Default.Equals(this.");
        interpolatedStringHandler.AppendFormatted(fqf);
        interpolatedStringHandler.AppendLiteral("!, other.");
        interpolatedStringHandler.AppendFormatted(fqf);
        interpolatedStringHandler.AppendLiteral("!)");
        string stringAndClear = interpolatedStringHandler.ToStringAndClear();
        indentedTextWriter.WriteLine(stringAndClear);
    }
}

自前でSource Generatorの実装を行う際に、IEquatableのようにテンプレートのような実装になるinterfaceの生成などで参考になりそうです。

【Unity】ワールド上のRectTransformをSceneView上のEditorGUIで表示する

Unityのシーンビュー上のデバッグ機能を作っていて、特定のUIパーツのRectをSceneView上で適当な矩形で塗りつぶして表示したかった時のメモ

RectTransform.positionから表示しようとすると矩形のサイズが合わなくて困っていたのですが、HandleUtilityとRectTransform.GetWorldCornersの組み合わせでいけました。

// ここで任意のRectTransformを指定する
var rectTransform = GetComponent<RectTransform>();

Handles.BeginGUI();
var corners = new Vector3[4];
// 四隅を取得
rectTransform.GetWorldCorners(corners);

// 四隅の座標をSceneView上の座標に変換する
for (int i = 0; i < 4; i++)
{
    corners[i] = HandleUtility.WorldToGUIPoint(corners[i]);
}

// 四隅の座標からRectに変換して描画
var rect = new Rect(corners[0], corners[2] - corners[0]);
EditorGUI.DrawRect(rect, new Color(0.6f, 0.6f, 0.6f, 0.3f));
Handles.EndGUI();

適当なボタンを配置して試した結果がこちらです。

特定のuGUIパーツをSceneView上で確認したかったりする方は覚えておくと役立つかもしれません。

【Godot】Godotの言語サポートとC#スクリプトを実行してみるメモ

はじめに

Godotで開発するにあたって、いろいろなスクリプトの言語の選択肢があります。その中でもC#が使えるとのことで、基本的な知識を公式ドキュメントをもとに和訳したり抜き出したりしてまとめた記事になります。 詳しい情報や補足事項は公式ドキュメントを参考にしてください。

docs.godotengine.org

また、本記事は以下のバージョンで検証を行なっています。バージョンや時期によっては本記事の説明や機能の仕様が違う場合があるのでご了承ください。

Godotの言語サポート

まずGodot自体はC++製のゲームエンジンです。OSSGitHub上にてコードが公開されています。

GitHub - godotengine/godot: Godot Engine – Multi-platform 2D and 3D game engine

しかし、Godotで開発を行うエンジニアはC++で実装を行う必要はなく、Godotが提供するGDScriptでの実装が基本となります。これは独自のスクリプト言語で、直感的な記述でコンテンツを作り上げるためのGodotの戦略です。

docs.godotengine.org

C++のモジュールを実装し、組み込むことも可能です。公式ではGDScriptを推奨していますが、外部のC++のライブラリを導入したり、パフォーマンスを要するコアな部分を実装する時にはC++での実装もありという形で説明しています。

docs.godotengine.org

そして、Godotのメジャーバージョンが3を迎えて、.NETをサポートしてC#での実装も行えるようになりました。仕組みとしてはMonoによる変換です。 Unityを使われている方は馴染みが深く、Godotを使う際にはC#からやってみるのも良さそうです。まだ新しめの機能なのでドキュメントがやや整ってなかったりするので、少し注意は必要です。

docs.godotengine.org

また、Godotの大きな特徴のひとつがさまざまな言語でのスクリプトによるプラグインが使えることです。公式としては独自のスクリプト言語であるGDScriptをはじめとして、C#C++をサポートしています。そして、非公式なコミュニティではRustやPython、Kotlinといった言語のプラグインも開発されています。次に示すリンクはRustのプラグインです。

github.com

これらのプラグインを実現しているのがGDNaitiveという仕組みです。公式の説明を引用します。

GDNative is a Godot-specific technology that lets the engine interact with native shared libraries at run-time. You can use it to run native code without compiling it with the engine.

docs.godotengine.org

GDNativeによるネイティブコードはGodot上でランタイムでの呼び出しが可能になります。例としてCおよびC++でのGDNativeの実装例が公式ドキュメントで紹介されています。

docs.godotengine.org

GDNativeによるスクリプトをGDNativeLibraryというライブラリの形にビルドし、Godot内で紐付けて参照を行います。コミュニティが開発を行う他の言語のプラグインもこれに則り、GodotのAPIをそれぞれの言語から呼ぶような実装ができるようバインドし、GDNativeLibraryを吐けるようにしたものになります。

以上のように

  • GDScript
  • C++
  • C#
  • GDNative

というスクリプトの選択肢があるのがGodotの言語サポートになります。

GodotのC#サポートと実際に動かしてみる

前項にあるように、Godotにはさまざまな言語で動かす選択肢があります。GDScriptを使う人が多いかと思うので、本記事ではC#で動かすのを試してみます。 本記事を書いているタイミングでは、実装的にMonoの.NET 6.xフレームワークで行われており、C#8.0までの機能が使えるようになっています。 実行には.NET SDKのインストールが必要となります。この時64bit版のGodotであれば当然SDKも64bit版でないといけないのは注意してください。

dotnet.microsoft.com

そして、.NETがサポートされているMono対応バージョンのGodotをインストールします。 公式サイトにわかりやすく用意されているので、そちらから用意するのが手っ取り早いです。

Godot側でスクリプトを実装する際には、言語選択でC#を選びます。サポートされていないGodotだとこの段階で候補に出てこないので注意してください。

テンプレートをもとにスクリプトが作成され、Godot上で表示されます。ログだけ追加したテンプレートのスクリプトを次に示します。

using Godot;
using System;

public class Spatial : Godot.Spatial
{
    // Declare member variables here. Examples:
    // private int a = 2;
    // private string b = "text";

    // Called when the node enters the scene tree for the first time.
    public override void _Ready()
    {
        GD.Print("テスト");
    }

//  // Called every frame. 'delta' is the elapsed time since the previous frame.
//  public override void _Process(float delta)
//  {
//      
//  }
}

これをアタッチしたオブジェクトを用意して「テスト」というログが確認できます。Godotのエディタ上でもサクッとC#をかけるのも特徴です。一定のシンタックスハイライトや ですが、やはりIDEやお気に入りのエディタで実装したいのがエンジニアでしょう。現状Visual StudioやRider、VS Codeといった有名どころの開発はGodotも想定しています。 エディタ設定のMonoのところからExternal Editorを設定すると機能します。ただし、サポートしているエディタが現状だと限定されているので注意してください(Visual StudioVS Code、Rider、ModeDevelopを現状はサポートしています)。

ちなみに余談ですが、RiderだとMarket PlaceにGodotでC#を書くためのプラグインが存在します。導入するとRider側でのGodotの実行やデバッグなどが快適に行えるようになります。

github.com

次にオブジェクトやそのスクリプトへの干渉を行うスクリプトを見てみます。 GDScriptという動的型付けのスクリプト言語で実装する際には型は意識しませんが、C#は静的型付けの言語なので型を明示的に定義する必要があります。

たとえばGetNodeというNodeを参照する基本的なメソッドであれば、ジェネリクスで型を定義します。この辺はUnityを使っている方はGetComponentを意識してもらえればわかるかと思います。 公式ドキュメントで紹介されているGDScriptとC#スクリプトの例を挙げます。

# GDScriptのコード
var mySprite = GetNode("Spatial");
mySprite.SetFrame(0);
Sprite mySprite = GetNode<Sprite>("MySprite");
mySprite.SetFrame(0);

さらなる詳しい文法などは公式ドキュメントを読んでもらえると理解が深まるかと思います。

docs.godotengine.org

おわりに

Godotは多様な実装の手段を用意しており、なかなか柔軟さを感じるゲームエンジンです。 とくにC#はUnityという非常に使用者の多いゲームエンジンでも採用されている言語で、Unityを触っているが、Godotが気になっているというかたも気軽に試すことが可能です。

筆者はGDScript、C#、Rustでの実装を触ってみてますが、シンプルにコンテンツを作る部分ではGDScriptを、コアな実装やパフォーマンスに起因する部分の実装はC#プラグインを、といった使い分けができるのはなかなかおもしろいなと感じました。

また、余談ですがエディタ自体もGodotで実装されているので、最近ではAndroid用のエディタというものがついにベータ版としてリリースされました。スマホスマホのゲームが作れる面白い試みです。

godotengine.org

何かと面白い仕組みがあるGodot、今後も追っていきたいです。

【Unity】Unity Gaming Serviceのサンプルプロジェクトを触ってみる

はじめに

本記事はUnity Gaming Serviceが2023年1月現在で提供しているドキュメント、およびGitHub上でのサンプルプロジェクトについての手順などを紹介する記事になります。 検証環境は以下になります。バージョンが違う場合には動作しない場合がありますので、ご留意ください。

  • Unity2020.3.20f1
  • Unity Gaming Services Use Cases 1.8.0

Unity Gaming Service

Unity Gaming Service(以下、UGS)はゲームでよく必要となる機能を使いやすい形でUnityが提供しているサービスです。

unity.com

たとえばモバイルゲームでありがちなユーザー認証やデータ保存だったりとか、多人数のゲームで必要になりがちなマッチング機能やボイスチャットだったりとか、汎用的に需要のある機能について開発者が実装することなく、このサービスを使えばサクッと実現できますよというものです。

使い放題かというとそういうわけではなく、機能によって無料と有料の部分があったり、従量課金制だったりします。詳しい料金形態は公式を参照してください。

unity.com

サンプルプロジェクトの導入

導入にはまずGitHubリポジトリ上に公開されているサンプルを落としてきます。

github.com

UGSはUnity Service用のアカウントが必要になるのですが、このサンプルにはテスト用のエディタ上でだけ使用するアカウントが紐付けされています。なので自身のUnity Serviceアカウントを紐付けたりする必要はありません。(その代わり、エディタ上でアカウントについての警告が出たり、ビルドできないといった制約があります)

Note: This project is tied to a Unity Services Account that allows read-only testing in the Editor. The messages "Unable to link project to Unity Services" in Project Settings and "Unable to access Unity Services" in Build Settings are expected. Additionally, you will be unable to create a device build of this project.

起動するだけで最低限の準備は完了となります。サンプルは以下の用意があり、それぞれシーンとして作成されています。 サンプルごとに必要な手順などを実行します。手順についてはREADMEとして用意されています。

ひとつひとつのサービスを紹介するというよりは、ゲームでありがちな部分のサンプルです。Assets/Use Case Samplesにシーンやスクリプトが配置されています。 本記事ではこれらの中から「Idle Clicker Game」について概要や処理を追ってみつつ紹介します。

Idle Clicker game

Idle Clicker gameは時間経過とともに進捗する箱庭系のゲームのサンプルです。ルールは下記の通りです。

  • 5*5のマス目上のマスと水がある
  • マスの状態には何もない、井戸、岩がある
  • 何もないマスをクリックすると水を消費して井戸になる
  • 井戸は時間経過で水を補給する
  • 井戸同士を合成してより強力な井戸にできる。合成できるのは同じ合成回数の井戸同士のみ
  • 井戸の合成回数によってより強力な井戸(合成回数の多い井戸)が解禁されていく

細かいルールについてはREADMEのFunctionの部分に書いてあるので、そちらを参照してください。井戸を設置して水を貯めて井戸を強化して、というルーチンを繰り返して水を増やすゲームとなっています。 最近のゲームだと「トップウォー」などをイメージしてもらえるとわかりやすいかと思います。

このサンプルでは以下のUGSを利用し、ゲームスキームを構築しています。

  • Authentication : ユーザー単位でゲーム情報を管理するための認証
  • Cloud Code : ユーザーの初期データ処理、通貨や時間経過によるロジックの遂行
  • Cloud Save : ユーザーのゲーム進捗やタイムスタンプ、機能の解放状態などの保存
  • Economy : 通貨(このサンプルでは水のこと)情報の管理

とりあえずシーンを開いて実行してみます。マスのランダムな初期状態の策定と水の初期量が配布されてゲームが開始されます。

ログを見てみると、まずAuthenticationによってユーザーとそのIdを生成、そしてゲームの初期化処理が走っていそうなことがわかります。

実際の実装を追ってみます。 IdleClickerGameSceneManagerという部分でAuthentificationおよびEconomyによるユーザー情報の取得や通貨情報の取得を行なっています。

// IdleClickerGameSceneManagerのStart
async void Start()
{
    try
    {
        await UnityServices.InitializeAsync();

        // Check that scene has not been unloaded while processing async wait to prevent throw.
        if (this == null)
            return;

        if (!AuthenticationService.Instance.IsSignedIn)
        {
            await AuthenticationService.Instance.SignInAnonymouslyAsync();
            if (this == null)
                return;
        }

        Debug.Log($"Player id:{AuthenticationService.Instance.PlayerId}");

        // Economy configuration should be refreshed every time the app initializes.
        // Doing so updates the cached configuration data and initializes for this player any items or
        // currencies that were recently published.
        // 
        // It's important to do this update before making any other calls to the Economy or Remote Config
        // APIs as both use the cached data list. (Though it wouldn't be necessary to do if only using Remote
        // Config in your project and not Economy.)
        await EconomyManager.instance.RefreshEconomyConfiguration();
        if (this == null)
            return;

        await GetUpdatedState();
        if (this == null)
            return;

        await EconomyManager.instance.RefreshCurrencyBalances();
        if (this == null)
            return;

        ShowStateAndStartSimulating();

        sceneView.SetInteractable();

        Debug.Log("Initialization and signin complete.");
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}
  1. Authentificationを通して匿名サインイン (AuthenticationService.Instance.SignInAnonymouslyAsync)
  2. Economyから通貨情報を取得 (EconomyManager.instance.RefreshEconomyConfiguration)
  3. ユーザーの開始タイミングでの状態を通貨情報と照らし合わせて取得(GetUpdatedState)
  4. 現在の通貨残高を取得(EconomyManager.instance.RefreshCurrencyBalances())
  5. ゲームのUIに情報を反映

モバイルゲームでよくバックエンド側で実装を行うユーザー管理や通貨管理といった部分がUGSによって成立していることがわかるかと思います。 3の手順をもう少し深く追ってみましょう。

async Task GetUpdatedState()
{
    try
    {
        var updatedState = await CloudCodeManager.instance.CallGetUpdatedStateEndpoint();
        if (this == null)
            return;

        UpdateState(updatedState);
        Debug.Log($"Starting State: {updatedState}");
    }
    catch (CloudCodeResultUnavailableException)
    {
        // Exception already handled by CloudCodeManager
    }
    catch (Exception e)
    {
        Debug.LogException(e);
    }
}

void UpdateState(IdleClickerResult updatedState)
{
    EconomyManager.instance.SetCurrencyBalance(k_WellGrantCurrency, updatedState.currencyBalance);
    SimulatedCurrencyManager.instance.UpdateServerTimestampOffset(updatedState.timestamp);

    m_AllWells[0] = SetWellLevels(updatedState.wells_level1, 1);
    m_AllWells[1] = SetWellLevels(updatedState.wells_level2, 2);
    m_AllWells[2] = SetWellLevels(updatedState.wells_level3, 3);
    m_AllWells[3] = SetWellLevels(updatedState.wells_level4, 4);

    m_Obstacles = updatedState.obstacles;

    UnlockManager.instance.SetUnlockCounters(updatedState.unlockCounters);
}

/// CloudCodeManagerより抜粋
public async Task<IdleClickerResult> CallGetUpdatedStateEndpoint()
{
    try
    {
        var updatedState = await CloudCodeService.Instance.CallEndpointAsync<IdleClickerResult>(
            "IdleClicker_GetUpdatedState",
            new Dictionary<string, object>());

        return updatedState;
    }
    catch (CloudCodeException e)
    {
        HandleCloudCodeException(e);
        throw new CloudCodeResultUnavailableException(e,
            "Handled exception in CallGetUpdatedStateEndpoint.");
    }
}

CloudCodeServiceからCallEndpointAsyncを介して更新処理を実行し、その結果をIdleClickerResultという形で受け取っています。

Cloud Codeはゲームロジックなどの実装をUGSが提供するクラウド側を介して呼び出せるようにするものです。これによって、アプリの更新なしでゲームロジックを更新したり、悪意のあるアプリへの攻撃からゲームロジックを守ることができます。ユーザー情報はサーバー側で管理し、ログイン、カードの強化、ガチャ、といったユーザーの情報を更新するような処理はサーバーと通信して、サーバーが処理してその結果を返す形です。これによってそれらの処理に更新やメンテナンスが必要であればサーバー側の実装を更新すれば済みますし、アプリに対する攻撃で不正なロジックを実行しようとも通信しないといけないのでそのタイミングで弾くことができます。

こういった仕組みをバックエンド側の開発をしなくとも導入したい...!という方に向けたサービスがCloud Codeです(と思っています)。バックエンド側の開発という課題をパスして、ゲームロジックをCloud Code側に委譲することで、アプリ側にゲームロジックをおかないという仕組みを作ることができます。

ここの処理では「IdleClicker_GetUpdatedState」の処理をアプリ側からCloud Code側に依頼し、その結果を取得しています。 Cloud Codeで実行できるスクリプトJavaScriptで、ロジック自体はつまりJavaScriptで実装することになります。今回のサンプルのAssets -> Use Case Samples -> Idle Clicker Game -> Cloud CodeにCloud Code側で実際に実行されているJavaScriptが用意されています。

// Entry point for the Cloud Code script 
module.exports = async ({ params, context, logger }) => {
  try {
    const { projectId, playerId, accessToken } = context;
    const cloudSaveApi = new DataApi({ accessToken });
    const currencyApi = new CurrenciesApi({ accessToken });

    let instance = { projectId, playerId, cloudSaveApi, currencyApi, logger };

    const timestamp = getCurrentTimestamp();
    instance.currentTimestamp = timestamp;

    // Cloud Save側で保存されているユーザー情報を見に行く
    instance.state = await readState(instance);
    if (instance.state) {

      logger.info("Read start state: " + JSON.stringify(instance.state));

    // ユーザー情報が無ければ初期ユーザーなので、ランダムな初期状態を構築する
    } else {

      createRandomState(instance);
      logger.info("Created random start state: " + JSON.stringify(instance.state));

      await setInitialCurrencyBalance(instance);
    }

    // 時間経過による状態の更新を行う(井戸の影響など)
    await updateState(instance);
    logger.info("Updated state: " + JSON.stringify(instance.state));

    // 更新した状態をCloud Saveに保存する
    await saveGameState(instance);

    // 保存後、レスポンスに更新された水の量の情報を付与する
    instance.state.currencyBalance = instance.currencyBalance;

    return instance.state;

  } catch (error) {
    transformAndThrowCaughtError(error);
  }
}

具体的なCloud Codeでの実装や、デプロイの方法などはこちらのCloud Code自体の公式のドキュメントを参考にしてもらえると幸いです。

docs.unity.com

また、Cloud Codeの利点としてCloud Saveと連携ができるという部分もあげられます。サンプルのゲームではユーザー情報はCloud Saveに保存していますが、この部分ではCloud CodeがユーザーのCloud Saveに問い合わせて情報を取得してIdleClickerResultという形で返しています。UGS同士が連携してロジックを構成できるのも利点のひとつです。

[Serializable]
public struct IdleClickerResult
{
    public long timestamp;
    public long currencyBalance;
    public List<WellInfo> wells_level1;
    public List<WellInfo> wells_level2;
    public List<WellInfo> wells_level3;
    public List<WellInfo> wells_level4;
    public List<Coord> obstacles;
    public Dictionary<string, int> unlockCounters;

    public override string ToString()
    {
        var unlockCountersStr = string.Join(",", unlockCounters.Select(kv => $"{kv.Key}={kv.Value}"));
        return $"timestamp:{timestamp}, " + 
            $"currencyBalance:{currencyBalance}, " + 
            $"wells_level1:[{string.Join(",", wells_level1)}], " +
            $"wells_level2:[{string.Join(",", wells_level2)}], " +
            $"wells_level3:[{string.Join(",", wells_level3)}], " +
            $"wells_level4:[{string.Join(",", wells_level4)}], " +
            $"obstacles:[{string.Join(",", obstacles)}], " +
            $"unlockCounters:[{unlockCountersStr}]";
    }
}

初期化処理の部分だけではなく、実際に井戸を作ったり合成したりする部分も見てみましょう。試しに適当に井戸を配置してみます。

井戸を作成したというログが流れてきます。

実装を見てみます。前述したCloudCodeManagerにゲームに干渉する一連の処理が実装されています。 返り値としてIdleClickerResultを受け取る形になっており、ロジックによる結果だけを反映するフローです。

// 井戸の配置
public async Task<IdleClickerResult> CallPlaceWellEndpoint(Vector2 coord)
{
    try
    {
        var updatedState = await CloudCodeService.Instance.CallEndpointAsync<IdleClickerResult>(
            "IdleClicker_PlaceWell",
            new Dictionary<string, object> {
                { "coord", new Coord { x = (int)coord.x, y = (int)coord.y }}
            });

        return updatedState;
    }
    catch (CloudCodeException e)
    {
        HandleCloudCodeException(e);
        throw new CloudCodeResultUnavailableException(e,
            "Handled exception in CallPlaceWellEndpoint.");
    }
}

// 井戸の合成
public async Task<IdleClickerResult> CallMergeWellsEndpoint(Vector2 drag, Vector2 drop)
{
    try
    {
        var updatedState = await CloudCodeService.Instance.CallEndpointAsync<IdleClickerResult>(
            "IdleClicker_MergeWells",
            new Dictionary<string, object> {
                { "drag", new Coord { x = (int)drag.x, y = (int)drag.y }},
                { "drop", new Coord { x = (int)drop.x, y = (int)drop.y }}
            });

        return updatedState;
    }
    catch (CloudCodeException e)
    {
        HandleCloudCodeException(e);
        throw new CloudCodeResultUnavailableException(e,
            "Handled exception in CallMergeWellsEndpoint.");
    }
}

井戸の操作を行ったタイミングでCloud Code側で処理が走り、その情報もCloud Save側に保存されます。すべての操作がこうなっているため、アプリを終了してもその時の状態に復帰できる形となっています。Cloud Code側の実装を次に示します。

// Entry point for the Cloud Code script 
module.exports = async ({ params, context, logger }) => {
  try {
    logger.info("Script parameters: " + JSON.stringify(params));

    const { projectId, playerId, accessToken } = context;
    const cloudSaveApi = new DataApi({ accessToken });
    const currencyApi = new CurrenciesApi({ accessToken });
    const purchasesApi = new PurchasesApi({ accessToken });

    let coord = params.coord;

    let instance = { projectId, playerId, cloudSaveApi, currencyApi, purchasesApi, logger };

    const timestamp = getCurrentTimestamp();
    instance.currentTimestamp = timestamp;

    // Update the current state by granting Water and updating timestamp.
    instance.state = await readState(instance);
    if (!instance.state) {
      throw new StateMissingError("PlaceWell script executed without valid state setup. Be sure to call GetUpdatedState script before merging wells.");
    }
    logger.info("Start state: " + JSON.stringify(instance.state));

    // 時間経過による状態の更新を行う(井戸の影響など)
    await updateState(instance);
    logger.info("Updated state: " + JSON.stringify(instance.state));

    // 更新した状態をCloud Saveに保存する
    await saveGameState(instance);

    // 井戸を置こうとした場所が変ならエラー
    throwIfLocationInvalid(instance, coord);

    // 井戸を置こうとした場所が使用ずみならエラー
    throwIfSpaceOccupied(instance, coord);

    // 井戸を水で購入
    await purchaseWell(instance);

    // 井戸を配置
    addWellToState(instance, coord);

    // 状態を保存
    await saveGameState(instance);

    // レスポンスに含める情報として、井戸の分減った水の量を入れる
    instance.state.currencyBalance = instance.currencyBalance - wellCurrencyCost;

    return instance.state;

  } catch (error) {
    transformAndThrowCaughtError(error);
  }
}

他にもロック解放処理やゲームのリセットなどの機能もありますが、これらもCloud Codeを介して実装されています。 本記事では以上の初期化処理と井戸を置く部分についての紐解きで終了となりますが、ぜひ気になった方は実際にサンプルを動かしてみてください。

おわりに

Unity Gaming Serviceは最近Unityから色々と発表されているものの、どういう機能があるんだろうか、どういう場面で有用なんだろうかと様子見してる方もいるかと思います。 サンプルプロジェクトとドキュメントがユースケースごとに用意されているので、そういう方は一度触ってみるのが良さそうです。 一定の規模感があるとなかなか要件が難しく使うことができない場面もありますが、個人開発の方などは一度使ってみると良さそうな機能が揃っていそうです。

2023年の記事の書き初めになりますが、今年もよろしくお願いいたします。

参考文献

公式ドキュメント

docs.unity.com

2022年の振り返りと2023年の抱負

2022年にやったことを振り返りつつ、2023年頑張ることを書いていきます。

2022年やったこと

個人ブログ投稿 10本

この技術ブログである「うどんてっくメモ」、今年は10個の記事を投稿しました。 毎月1個が目標なんですが、ちょっと本を書いたり仕事が忙しかったりゲームが忙しかったりと想定していたペースよりやや落ちてしまいました。

myudon.hatenablog.com

仕事の関係でUIと向き合うことが多く、uPaletteやTextMeshProといったUnityでUIを実装する上での便利機能やTipsのような記事を書くことが多かったです。 また、Rustが楽しく、Rust×ゲームという部分に着目してゲームエンジンを触ったりGodotの実装をRustで行ったりもしました。

myudon.hatenablog.com

ただ、Rust自体の知見の深さや良さの掘り下げはあまりできてない気がしていて、ゲーム以外のアプリケーションだったり、言語そのものの知見を深めたりということも進めたいです。

会社ブログ投稿 2本

所属しているQualiArtsという会社のエンジニアブログにも2本の記事を投稿しました。

technote.qualiarts.jp

technote.qualiarts.jp

実は業務的に技術広報的な立ち位置におり、このエンジニアブログ自体の運用も行なっているのですが、自分で執筆するのも何回か経験しております。 会社のブログを書くときには「業務ではこういう見方でこういうアプローチを行った」という会社の知見ならではの記事を意識して書いています。 今年書いた記事もQualiArtsのUI開発やCI周りの知見が詰まっているので、興味のある方はぜひ覗いてみてください。

Zenn投稿 1本

ちょっとしたレベルの知見はZennに置こうと思っていたのですが、あまり手元に溜まらず一件だけZennの方に投稿させていただきました。

zenn.dev

さすがに使い所がかなり限定される記事ですが、刺さる人には刺さるかなと思います(笑)。 いわゆる小ネタ系の記事は思いついたらZennに投げることを徹底してやりたいなあと思っています。

技術書典の出典 会社と個人

技術書典で「UniTips」というUnityの技術書を定期的に会社で出しているのですが、2022年も執筆に参加しました。

creator.game.cyberagent.co.jp

個人執筆の本も2冊出させていただきました。

techbookfest.org

techbookfest.org

個人で出したのはUnityとテストに関する本、そしてIDEであるRiderの本と、どちらかというと仕事で個人的に得た知見を本にした形になります。 どちらもニッチというか、人を選ぶかなと思っていたのですが、想定を上回る数購入していただきました。 Twitter上でも購入したという声を多く聞けて嬉しかったです。今後も続けていきます。

商業誌執筆

ご縁がありまして、去年に引き続き技術書典で出していた個人執筆の本を商業誌として刊行していただきました。

nextpublishing.jp

商業誌で出すのも2冊目となり、より自分の制作物が世の市場に出回るようになって個人的にもアウトプットのモチベーションにつながっています。

CA.unityの継続開催と登壇

CA.unityを2021年にはじめ、2022年はその継続回となるCA.unity #2~5を開催いたしました。また、#5では実際に自ら登壇も行いました。

meetup.unity3d.jp

learning.unity3d.jp

ありがたいことに多くの企業様にご協力いただきまして、開催を続けられております。本当にありがたい限りです。開催したアーカイブについてもすべてUnity Learning Materialsに掲載していただいており、通算で20を超えるアーカイブがすでに揃っております。ありがたいことに専用のカテゴライズページも用意してもらえるようになりました。

learning.unity3d.jp

「企業の持つノウハウをしっかり業界に還元しつつ、業界の知見が共有される場を作る」という目的ではじめたこの企画ですが、コンスタントに続けられるのはひとえに協力してくれる皆様と見にきていただいている業界の皆様のおかげです。

今後も多くの企業の知見を届けられるよう運用を続けていく予定です。

FP3級取得

あまりエンジニア的な観点では関係ないですが、社会人として一定のお金の知識をつけたく、FPの勉強を行い、3級を取得しました。

ここでいうお金の知識というのは投資やFXをあれこれして金銭を増やすという知識ではなく、社会人としての税金の知識や確定申告、保険、不動産などの知識を指します。

実は2022年にマンションのローン購入に踏み切ったのですが、FPで得た知識は非常に武器になりました。固定資産税の評価や借地権の評価、住宅ローン控除や贈与税などなど多くの知識によってあまりお金周りの大損をせずに契約に向き合うことができました。 また、親が所有資産や不動産まわりのやりくりで困っていたこともあり、それに対処する知識を身につけるという点でも大きく役立ちました。

FPは社会人として生きていく上でのお金の必須知識がかなり詰まっているので、個人的にはおすすめしたい資格のひとつです。 余談ですが、2023年には2級を取得したいと考えており、ひそかに勉強中です。

Qiitaアドベントカレンダー 2つ参加

毎年恒例アドベントカレンダー、2022年も会社のものとUnityのものに参加させていただきました。

technote.qualiarts.jp

myudon.hatenablog.com

今年も会社のアドベントカレンダーを運用して広報したりしていました。2022年はなかなかニッチな(?)ネタが集まったアドカレが仕上がったと思います。 ぜひぜひご覧ください。

qiita.com

2023年の抱負

より深くインプットし、より広く大きくアウトプットする

2022年はさまざまなものをインプットしながら広く学ぶ意味合いが大きい年でした。業務の担当が広くなったこともあり、単純なゲームシステム実装にとどまらない学びが多かったです。 2023年は広く学んだところからゲーム開発にフォーカスしてどのような使い方ができるか、掛け合わせで有用なことができるか、より具象化しつつ深く学んでいきたいと感じています。自分が目指すのはゲーム業界に貢献できるエンジニアなので、その軸をブラさないような知識の有効利用を考えていきます。

また、アウトプットについても色々な登壇機会に挑戦したり、もっと多くの人にみてもらえるようなアウトプットの大成を頑張りたいなという気持ちがあります。 これについてはまだまだ何も構想がないので、来年いちから考えるところから向き合っていきたいです。

知識獲得のための資格勉強

FP3級という資格獲得を経て、資格そのものというよりは必要な勉強による体系立てた知識の確立を魅力に感じました。 元々資格自体にさほど関心がなかったのですが、取っても無駄とは元々思っておらず、学んでみたいとはふんわり感じていました。それがFP3級を経て大きくなった形です。 情報系であればIPAの実施するものがありますし、お金周りで言えばFPなどが挙げられます。自分が学びに興味があるのがこの二軸なので、2023年はさらに挑戦してみたいという気持ちがあります。

最後に

2022年は何かと仕事が忙しかったなあ、と思いつつ実は一番忙しかったのはゲームをすることでした(笑)。一番やりこんでいるポケモンが新作出したり、スプラトゥーンをやったり、ソーシャルゲームの新作と向き合ったりなど、ゲーム好きとしてのインプットが結果的に濃い一年だったと感じています。 2023年もゲームに仕事に忙しくなりそうなので、私生活での時間を配分をしっかり戦略立てないとなあと感じています。しっかり学びつつ遊びつつを両立した日々の過ごし方を確立していきたいですね。目標にもある広い学びとこのゲームの経験をしっかり活かした生産的な時間を目指していきます。

【Unity】新しいUnity Searchの隠れた機能紹介

はじめに

この記事はUnity Advent Calendar 2022の11日目の記事です。 今年も多くの記事が上がっているので、ぜひぜひチェックしてみてください!

qiita.com

本記事で紹介および検証を行なっているツールのバージョンは次の通りです。

  • Unity 2023.1.0a14

バージョンによっては挙動に差異がある場合もありますので、ご了承ください。

Unity Search

Unity上で開発する上で息をするように使うのがアセットやスクリプトなどの検索機能です。

Unityではこの検索機能に最近注力していて、Unity Searchの進化やパフォーマンス改善、設定の拡充などさまざまな改修が入っています。 詳しくは公式で動画としてUnity Learning Materialsにまとめられているのでそちらを参考にしてください。

learning.unity3d.jp

実は2022年の頭に自分も記事を書いています。

myudon.hatenablog.com

そんなUnity Searchの機能について、今回の記事では基本的な機能というよりはあまり知られていなさそうな機能について紹介します。 Query Builder ModeといったUnity Searchの機能を前提に話したりするので、わからない単語など出てきた際には前述の公式資料や自分の記事などを読んでいただければと思います。

検索条件の保存

色々な検索条件が指定できるUnity Searchですが、検索条件のパターンを保存して使いまわすことが可能になっています。 検索条件を入力している状態で、左に表示されているSearchesから保存アイコンを押すと入力されている検索条件を保存できます。

また、保存した検索条件の名前も変えることが可能です。複雑な条件は文字列だけ見ても分かりづらいので、直感的な名前をつけるとより使いやすいです。

そしてさらに便利なところが、ユーザーのスコープで保存するものとプロジェクトのスコープで保存するものを指定できる点です。 チーム内全員に需要があるような検索条件は積極的に共有すると捗ります。よく使うPrefabだったり、特定の機能に使うパーツだったり、パッと出てくると嬉しい検索条件をまとめて、プロジェクトのスコープで保存しておくとよいでしょう。

Search AssetStore

実はAssetStoreもSearch Windowによって検索することが可能です。 検索欄の最初に「store: 」とつけるか、Query Builder Modeで「in Asset Store」を設定するとAsset Store内の検索を行います。

ちょっと個人でモックを作っていてAsset Storeにいい感じのものがないかな、という際にサクッとキーワードをもとに検索できるのでたまに便利だったりします。 Component条件なども指定できちゃうのかな?と思って試しましたが、さすがに効かないようでした。あくまでもキーワードのみのようですが、もし何かしらのフィルタリングができそうであれば指摘していただけると助かります。

また、Window -> Search -> Asset Storeを実行すると最初からAsset Storeを対象にしたWindowを開くことも可能です。

Performance

エディタ上で実行されている処理の呼び出し回数や処理時間などを一覧にして確認する機能も実装されています。 Window -> Search -> Performance Trackersを実行すると専用のWindowが開きます。

処理の関数名とパラメータが一覧になっており、リアルタイムで更新されます。呼び出し回数や処理時間について突出しているものについては黄色や赤色の文字でハイライトされるためわかりやすいです。 何かしらエディタの動作が重かったり、独自で重めの拡張処理などを実装した際には名前で検索することでフィルタリングできます。

何かしらのエディタ上での便利機能やUI Toolkitの実装などを行なった際に、パフォーマンスに不安があれば一度確認してみるといいでしょう。

おわりに

Unity Searchはかなり進化していて、使いこなすことで普段の何気ない作業がかなり効率化されます。 他にも機能が用意されているので色々と試してみるのをおすすめします。