ADVENT CALENDAR
Altseed2で軽量な宣言的UIを支援するライブラリAltseed2.BoxUIの紹介
By wraikny
これは AmusementCreators 2020 アドベントカレンダー の 25日目の記事です。
こんにちは
気が向いたので2020年12月25日の14時12分現在この記事を書き始めました。
20時23分現在書き終わりました。 記事を書き始めてからAltseed2のバグを見つけたり今回解説するライブラリのコードを修正したりしてました。 進捗なのでいいことですね!()
Altseed2.BoxUIの概要
オブジェクトプーリングを利用して宣言的なUIを楽に書けるライブラリAltseed2.BoxUIを以前作ったので、使い方を紹介します。 (オブジェクトプーリングとは、オブジェクトを使い回す仕組み)
Altseed2.BoxUI は NuGet Gallery からインストール可能です!
Altseed2.BoxUI - NuGet Gallery
また、リポジトリはこちらです。
wraikny/Altseed2.BoxUI - GitHub
NuGetではなく最新のバージョンを使用する際は、submoduleとして追加してlib/Altseed2
の下にdllとか置くとビルドが通るはずです。
Altseed2.BoxUIは私が制作したパズルゲームRouteTilesのUIでも利用しています! RouteTilesはF#製ですが、設計的な面でも構文的な面でも相性が良いのでかなり快適に利用できました。
BoxUIではElement
というクラスを使ってUIツリーを作成します。
BoxUI
で作成されるSpriteNode
やRectangleNode
は全てBoxUIRootNode
の子ノードとなり、位置計算は各Element
によって再帰的に計算されます。
BoxUIRootNode
はElement
とAltseed2の橋渡しをする様なノードで、BoxUIRootNode.SetElement
によって登録を行います。
UIツリーを再生成する際には一度プールして取り出すため、大量にnew
するコストがかからないようになっています。
カウンターアプリを作ってみる
カウンター、よくこういう例で見ますよね。 Model-View-Update的なノリで作ります。
これは動いている様子です。
コードはここにあります。
なお、幅の関係で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のテンプレートに加えて、BoxUISystem
のUpdate
とTerminate
を追加します。
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
プロパティのみを持っています。
IMsg
はState
をどのように更新するかを表すインターフェースで、今回はメッセージがState
を更新するUpdate
メソッドを実装することにします。
State.Incr
とState.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); ;
}
/* メソッドは後述 */
public ElementRoot MakeView(State state, Action<IMsg> dispatch) { /* 略*/ }
Text MakeText(string text) { /* 略 */ }
Element MakeButton(string text, Action<IBoxUICursor> action) { /* 略 */ }
}
割愛しますが、ZOrder
とColor
を静的クラス内で定義してます。
コンストラクタでフォントを読み込んでます。
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
を使って矩形領域を分割し、MakeText
とMakeButton
(後述)で要素を作成しています。
Window
とSetMargin
を使うことでウィンドウの中央に矩形領域を指定して、そこに背景とcontent
を追加しています。
なお、LengthScale
列挙体を指定してマージンの長さの決め方を指定できます。RelativeMin
は矩形領域の縦横への相対的な長さのうち小さい方を採用します。
State.Incr
とState.Decr
は先程定義したメッセージでした。引数で受け取ったdispatch
というメソッドを適用することで、メッセージの発火を表します。
では、MakeText
とMakeButton
の実装を見てみます。
// テキスト作成する
Text MakeText(string text)
{
return Text.Create(
text: text,
font: _font,
fontSize: 70,
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
ではRectangle
、Text
、Button
のElement
を使ってボタンを作成しています。
Button
はマウスカーソル(後述)との衝突時に呼び出すイベントを指定していて、背景の色を変えたり、指定したアクションを発火したりしています。
CounterNode
Model
とView
を接続していきます。
/*
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に貯まるようにしています。得られたElement
をBoxUIRootNode.SetElement
にわたすことで、Altseed2の見た目に反映されることになります。
所感
よかったですね。
今回はマウスでボタンを押すようにしましたが、もちろんButton
を利用せずにキーボードやジョイスティックの入力を使ってメッセージを追加してもいいですね。
また、マージンなどを使わずにFixedArea
などをつかって予めデザイナーが指定した位置に配置することもできますし、好きに利用可能です。
組み込みのElement
上の例ではWindow
とかText
とか使っていましたが、組み込みでは以下のElement
が実装されています。
ElementRoot
クラスは親を必要とせずに矩形領域を計算できるElement
で、BoxUIRootNode
に登録するのはこのクラスを継承する必要があります
ElementRoot
Window
FixedArea
Element
Button
Column
Empty
FixedSize
KeepAspect
Rectangle
Sprite
Text
詳しくはこちら
これらの要素だけでは不十分な気がしますね。
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);
}
Node
はBoxUIRootNode
に紐つけて取得する必要があるので、OnAdded
の中でRoot
プロパティ経由で取り出します。
この時、自動的にRoot
の子ノードとして追加されています。
ReturnSelf
では、先にNode
をRoot
経由で返却してから、自身を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
Tweet