ADVENT CALENDAR

Altseed2で軽量な宣言的UIを支援するライブラリAltseed2.BoxUIの紹介

By wraikny

これは AmusementCreators 2020 アドベントカレンダー の 15日目の記事です。

こんにちは

気が向いたので2020年12月25日の14時12分現在この記事を書き始めました。

20時23分現在書き終わりました。 記事を書き始めてからAltseed2のバグを見つけたり今回解説するライブラリのコードを修正したりしてました。 進捗なのでいいことですね!()

Altseed2.BoxUIの概要

オブジェクトプーリングを利用して宣言的なUIを楽に書けるライブラリAltseed2.BoxUIを以前作ったので、使い方を紹介します。 (オブジェクトプーリングとは、オブジェクトを使い回す仕組み)

リポジトリはこちらです。

wraikny/Altseed2.BoxUI - GitHub

使用する際は、submoduleとして追加してlib/Altseed2の下にdllとか置くとビルドが通るはずです。

Altseed2.BoxUIは私が制作したパズルゲームRouteTilesのUIでも利用しています! RouteTilesはF#製ですが、設計的な面でも構文的な面でも相性が良いのでかなり快適に利用できました。

BoxUIではElementというクラスを使ってUIツリーを作成します。

BoxUIで作成されるSpriteNodeRectangleNodeは全てBoxUIRootNodeの子ノードとなり、位置計算は各Elementによって再帰的に計算されます。

BoxUIRootNodeElementとAltseed2の橋渡しをする様なノードで、BoxUIRootNode.SetElementによって登録を行います。

UIツリーを再生成する際には一度プールして取り出すため、大量にnewするコストがかからないようになっています。

カウンターアプリを作ってみる

カウンター、よくこういう例で見ますよね。 Model-View-Update的なノリで作ります。

これは動いている様子です。

コードはここにあります。

examples/CounterExample

なお、幅の関係でnamespaceは省略します。

Program

ここはテンプレなやつ。

/*
using System;
using Altseed2;
using Altseed2.BoxUI;
*/

class Program
{
    [STAThread]
    static void Main(string[] args)
    {
        Engine.Initialize("CounterExample", 800, 600);

        Engine.AddNode(new CounterNode());

        while (Engine.DoEvents())
        {
            // BoxUIの更新
            BoxUISystem.Update();
            Engine.Update();
        }

        // BoxUIの終了
        BoxUISystem.Terminate();
        Engine.Terminate();
    }
}

Altseed2のテンプレートに加えて、BoxUISystemUpdateTerminateを追加します。 Altseed2.Engineの同名メソッドの前に呼び出すだけです。

Model

状態を管理している部分。

/*
using System;
*/

// 状態の更新を表す
interface IMsg
{
    void Update(State state);
}

// アプリケーションの状態を表す
sealed class State
{
    public int Count { get; private set; }

    // インクリメントするメッセージ
    public static readonly IMsg Incr = new Msg(s => s.Count += 1);
    
    // デクリメントするメッセージ
    public static readonly IMsg Decr = new Msg(s => s.Count -= 1);

    // ヘルパー用のクラス
    class Msg : IMsg
    {
        private Action<State> _action;
        public Msg(Action<State> action)
        {
            _action = action;
        }

        void IMsg.Update(State state) => _action(state);
    }
}

Stateは状態を表すクラスで、今回はCountプロパティのみを持っています。

IMsgStateをどのように更新するかを表すインターフェースで、今回はメッセージがStateを更新するUpdateメソッドを実装することにします。

State.IncrState.Decrで具体的なメッセージを実装しています。

View

見た目を作る部分。

定数とか初期化とか

/*
using System;
using Altseed2;
using Altseed2.BoxUI;
using Altseed2.BoxUI.Elements;
*/

sealed class View
{
    static class ZOrders { /* 略 */ }
    static class Colors { /* 略 */ }

    readonly Font _font;

    public View()
    {
        var path = @"TestData/Font/mplus-1m-regular.ttf";
        _font = Font.LoadDynamicFont(path, 70); ;
    }

    /* メソッドは後述 */

    public ElementRoot MakeView(State state, Action<IMsg> dispatch) { /* 略*/ }

    Text MakeText(string text) { /* 略 */ }

    Element MakeButton(string text, Action<IBoxUICursor> action) { /* 略 */ }
}

割愛しますが、ZOrderColorを静的クラス内で定義してます。

コンストラクタでフォントを読み込んでます。

Elementを生成する

今回のキモの部分です!

少しずつ見ていきます。

// 最終的な見た目を作る
public ElementRoot MakeView(State state, Action<IMsg> dispatch)
{
    // ColumnでY方向に分割
    var content = Column.Create(ColumnDir.Y)
        .SetMargin(LengthScale.RelativeMin, 0.05f)
        .With(
            // 中心にテキスト
            MakeText(text:$"{state.Count}").SetAlign(Align.Center),
            // ColumnでX方向に分割
            Column.Create(ColumnDir.X).With(
                MakeButton("+", _ => dispatch(State.Incr)),
                MakeButton("-", _ => dispatch(State.Decr))
            )
        );

    // ウィンドウ全体
    return Window.Create()
        // 上下左右それぞれにマージンを設ける
        .SetMargin(LengthScale.RelativeMin, 0.25f)
        .With(
            // 背景
            Rectangle.Create(color: Colors.Background, zOrder: ZOrders.Background),
            content
        );
}

これがUIの見た目を作る関数です。Stateを受け取り、それをもとに見た目を作っています。

Altseed2.BoxUIでは、Createという静的メソッドを利用してElementを作成します。これは新しいインスタンスを作成する場合と、プールされているオブジェクトを使い回す場合とに分かれるからです。

WithメソッドはElementに子要素を追加するメソッドで、チェーンして記述できるようになっています。 オーバーロードを複数用意しているので、まとめて追加できます。

contentではColumnを使って矩形領域を分割し、MakeTextMakeButton(後述)で要素を作成しています。

WindowSetMarginを使うことでウィンドウの中央に矩形領域を指定して、そこに背景とcontentを追加しています。 なお、LengthScale列挙体を指定してマージンの長さの決め方を指定できます。RelativeMinは矩形領域の縦横への相対的な長さのうち小さい方を採用します。

State.IncrState.Decrは先程定義したメッセージでした。引数で受け取ったdispatchというメソッドを適用することで、メッセージの発火を表します。

では、MakeTextMakeButtonの実装を見てみます。

// テキスト作成する
Text MakeText(string text)
{
    return Text.Create(
        text: text,
        font: _font,
        color: Colors.Text,
        zOrder: ZOrders.Text
    );
}

// ボタンを作成する
Element MakeButton(string text, Action<IBoxUICursor> action)
{
    // ボタンの背景`Element`
    var background = Rectangle.Create(zOrder: ZOrders.Button);

    // 当たり判定とイベントの定義
    var button = Button.Create()
        // ボタンが離されたとき
        .OnRelease(action)
        // ボタンが離れているとき
        .OnFree(_ => background.Node.Color = Colors.ButtonHover)
        // ボタンが押されているとき
        .OnHold(_ => background.Node.Color = Colors.ButtonHold)
        // カーソルが衝突していないとき
        .WhileNotCollided(() => background.Node.Color = Colors.ButtonDefault);

    return background
        .SetMargin(LengthScale.RelativeMin, 0.05f)
        .With(
            MakeText(text).SetAlign(Align.Center),
            button
        );
}

MakeTextはただのText.Createラップです。

MakeButtonではRectangleTextButtonElementを使ってボタンを作成しています。

Buttonはマウスカーソル(後述)との衝突時に呼び出すイベントを指定していて、背景の色を変えたり、指定したアクションを発火したりしています。

CounterNode

ModelViewを接続していきます。

/*
using System;
using System.Collections.Generic;
using Altseed2;
using Altseed2.BoxUI;
*/

sealed class CounterNode : Node
{
    // アプリケーションの状態
    readonly State _state;
    // メッセージを貯めるQueue
    readonly Queue<IMsg> _msgQueue;
    // アプリケーションの見た目を作成するクラス
    readonly View _view;

    // BoxUIのElementを登録するノード
    readonly BoxUIRootNode _uiRootNode;
    
    public CounterNode()
    {
        _state = new State();
        _view = new View();
        _msgQueue = new Queue<IMsg>();

        _uiRootNode = new BoxUIRootNode();
        AddChildNode(_uiRootNode);

        // マウスカーソルを表すノード
        var cursor = new BoxUIMouseCursorNode();
        AddChildNode(cursor);

        // BoxUIRootNodeにカーソルを追加する
        _uiRootNode.Cursors.Add(cursor);

        // 最初のViewを登録する
        // dispatchに_msgQueue.Enqueueを渡して、キューに貯めるようにする
        _uiRootNode.SetElement(_view.MakeView(_state, _msgQueue.Enqueue));
    }

    protected override void OnUpdate()
    {
        if (_msgQueue.Count > 0)
        {
            // キューからメッセージを取り出して適用する
            while (_msgQueue.TryDequeue(out var msg))
            {
                msg.Update(_state);
            }

            // 新しいElementを作成する前にはClearElementを呼び出す。
            _uiRootNode.ClearElement();
            _uiRootNode.SetElement(_view.MakeView(_state, _msgQueue.Enqueue));
        }
    }
}

BoxUIMouseCursorクラスを利用して、マウスカーソルをBoxUIRootNodeに追加することで、マウスでボタンを選択可能になります。

OnUpdateの中ではメッセージをキューから取り出して繰り返し適用した後に、MakeViewを呼び出してElementを作成し直します。

Elementを再生成するまえにBoxUIRootNode.ClearElementを呼び出す事が重要です!  これによって現在のElementやノードがプールされて、その後に呼び出すMakeViewでは作成済みのElementやノードが使い回される仕組みになっています。毎回newするコストがないため軽量ということです。

MakeViewでは、dispatch_msgQueue.Enqueue渡して、ボタンを押したときにメッセージがQueueに貯まるようにしています。得られたElementBoxUIRootNode.SetElementにわたすことで、Altseed2の見た目に反映されることになります。

所感

よかったですね。

今回はマウスでボタンを押すようにしましたが、もちろんButtonを利用せずにキーボードやジョイスティックの入力を使ってメッセージを追加してもいいですね。

また、マージンなどを使わずにFixedAreaなどをつかって予めデザイナーが指定した位置に配置することもできますし、好きに利用可能です。

組み込みのElement

上の例ではWindowとかTextとか使っていましたが、組み込みでは以下のElementが実装されています。

ElementRootクラスは親を必要とせずに矩形領域を計算できるElementで、BoxUIRootNodeに登録するのはこのクラスを継承する必要があります

  • ElementRoot
    • Window
    • FixedArea
  • Element
    • Button
    • Column
    • Empty
    • FixedSize
    • KeepAspect
    • Rectangle
    • Sprite
    • Text

詳しくはこちら

src/Altseed2.BoxUI/Elements

これらの要素だけでは不十分な気がしますね。

BoxUIでは自作のElementを簡単に作成可能で、組み込みのElementもすべてライブラリレベルで実装可能です。

CustomElement

組み込みElementの実装を例に解説します。

/* using System; */

// sealedで継承不可にする
[Serializable]
public sealed class Empty : Element
{
    // コンストラクタは見えなくする
    private Empty() { }

    // staticメソッドを定義する
    public static Empty Create()
    {
        return BoxUISystem.RentOrNull<Empty>() ?? new Empty();
    }

    // 自身をプールに返却する
    protected override void ReturnSelf()
    {
        /* フィールドがある場合などはnullを代入して初期化する */

        BoxUISystem.Return(this);
    }

    // 親の矩形領域を元に自身の矩形領域を計算する
    protected override Vector2F CalcSize(Vector2F size) => size;

    // 矩形領域の計算が行われる際の処理
    protected override void OnResize(RectF area)
    {
        // AlignとMarginを適用した領域に変換する
        area = LayoutArea(area);

        // 子要素の矩形領域を指定する
        foreach (var c in Children)
        {
            c.Resize(area);
        }
    }
}

これが基本的なElementの定義の仕方です。

ポイントはBoxUISystem.RentOrNull<Empty>()BoxUISystem.Return<Empty>(this);です。 ここでBoxUISystemからオブジェクトプーリングからの取り出しと返却を行っています。

ジェネリックで自身の型を指定する必要があるので気をつけてください。(オブジェクトプールがジェネリック静的クラスで実装しているため)

OnResizeでは子のElementの矩形領域を求めます。ここでは自身の領域をそのまま渡していますが、ここを変更することで様々な配置の仕方が可能になります。

例えば以下のように実装すると

protected override void OnResize(RectF area)
{
    area = LayoutArea(area);

    var count = Children.Count;
    if (count == 0) return;

    var size = area.Size.Y / count;
    var offset = new Vector2F(0.0f, size.Y);

    for(int i = 0; i < count; i++)
    {
        Children[i].Resize(new RectF(area.Position + offset * i, size));
    }
}

矩形領域を縦に等分し、それぞれの子要素に指定するElementを作ることができます。

CustomElement(Node)

RectangleなどのAltseed2のノードに対応したElementの作り方です。

ただし、コードが少し長いのでかいつまんで説明します。 コード全体は以下を参照してください。

src/Altseed2.BoxUI/Elements/Rectangle

public static Rectangle Create(
    ulong cameraGroup = 0,
    bool horizontalFlip = false,
    bool verticalFlip = false,
    Color? color = null,
    int zOrder = 0,
    Material material = null,
    TextureBase texture = null,
    RectF? src = null
)
{
    var elem = BoxUISystem.RentOrNull<Rectangle>() ?? new Rectangle();
    elem.cameraGroup_ = cameraGroup;
    elem.horizontalFlip_ = horizontalFlip;
    elem.verticalFlip_ = verticalFlip;
    elem.color_ = color ?? new Color(255, 255, 255, 255);
    elem.zOrder_ = zOrder;
    elem.material_ = material;
    elem.texture_ = texture;
    elem.src_ = src ?? new RectF(Vector2FExt.Zero, texture?.Size.To2F() ?? Vector2FExt.Zero);
    return elem;
}

// BoxUIRootNodeに登録されたときに呼び出される
protected override void OnAdded()
{
    // Root経由でNodeを取り出す
    Node = Root.RentOrCreate<RectangleNode>();

    // Nodeのプロパティを全部初期化する
    Node.CameraGroup = cameraGroup_;
    Node.HorizontalFlip = horizontalFlip_;
    Node.VerticalFlip = verticalFlip_;
    Node.Color = color_;
    Node.ZOrder = zOrder_;
    Node.Material = material_;
    Node.Texture = texture_;
    Node.Src = src_;
    material_ = null;
}

protected override void ReturnSelf()
{
    // 先にRoot経由でNodeを返却する
    Root.Return<RectangleNode>(Node);
    // nullで初期化する
    Node = null;
    OnUpdateEvent = null;
    BoxUISystem.Return<Rectangle>(this);
}

NodeBoxUIRootNodeに紐つけて取得する必要があるので、OnAddedの中でRootプロパティ経由で取り出します。 この時、自動的にRootの子ノードとして追加されています。

ReturnSelfでは、先にNodeRoot経由で返却してから、自身をBoxUISystemに返却します。

内部実装の解説

Elementを継承したクラスはAltseed2.BoxUI.BoxUIBool<T>でプーリングされます。

ジェネリックな静的クラスを型に対する辞書として利用することで、高速にアクセスできるようです。

Altseed2.Nodeを継承したクラスは、Altseed2.BoxUI.NodePool<T>でプーリングされます。

BoxUIRootNodeから外れたNodeは共有のプールに入れられるので、複数のBoxUIRootNodeを利用している場合でも無駄が少なくNodeの再利用が行えます。

おわりに

Altseed2.BoxUIは現状一人で開発しているので、何か改善があったりExampleを追加したかったり組み込みのElementを追加したかったりする場合はぜひissueやPRをしてください!

wraikny/Altseed2.BoxUI - GitHub

SHARE THIS POST