MonoGame #5 – Własny interfejs użytkownika – rozmieszczanie elementów

W poprzednim wpisie wspomniałem, że ku mojemu zdziwieniu nie ma w MonoGame prostego sposobu na interfejs użytkownika. Czeka mnie więc dodatkowa praca, ale chętnie spróbuję coś na to poradzić. Zacząłem ostatnio modelować pewien pomysł i to właśnie jemu poświęcony jest ten wpis.
Zwykle gdy zaczynam rzeźbić nowe rozwiązanie, zaczynam od pisania nieistniejących obiektów i metod. Następnie gdy widzę, że napisany kawałek pseudokodu ma sens i wygląda to przyjaźnie, zaczynam definiować użyte klasy.

Nie ma znaczenia, czy piszemy zwykły pseudokod w nowym pliku, czy formalizujemy to w postaci testów jednostkowych. Drugie podejście jednak polecam, ponieważ pozwala na dodatkową weryfikację kawałka kodu oraz tworzy dobre zaplecze w naszym kodzie na przyszłość. W końcu testy mają pilnować czy nasz kod nadal robi to, co rzeczywiście miał robić!

Podchodząc do UI założyłem, że użytkownik (w tym przypadku inny programista) powinien mieć możliwość logicznego definiowania tworzonego interfejsu. Jako, że cześć mojego doświadczenia pochodzi z dziedziny tworzenia aplikacji internetowych, nie wymyśliłem nic lepszego niż reprezentowanie struktury interfejsu w formie drzewa. Oznacza to więc, że zaczynamy od głównego elementu (root), który następnie może dowolnie rozgałęziać się na wewnętrzne elementy (np. kontenery, czyli listy elementów) , a liśćmi tego abstrakcyjnego drzewa są konkretne, wizualne  i interaktywne elementy interfejsu (przyciski, pola edycji, panele, obrazy).

Dobrze jest sobie zaplanować jakiś prosty obraz interfejsu, np. całe okno podzielone na 3 wiersze, w tym środkowy będzie jeszcze podzielony poziomo na dwie kolumny. Ot taki losowy interfejs użytkownika.

Siadam więc i pisze pseudokod, który reprezentuje wyżej opisany schemat. W MonoGame kod tego typu wyląduje w głównej klasie gry, w metodzie Initalize.

var screenWidth = GraphicsDevice.PresentationParameters.Bounds.Width;
var screenHeight = GraphicsDevice.PresentationParameters.Bounds.Height;

_rootNode = new VerticalContainer();
_rootNode.Position = new Rectangle(0, 0, screenWidth, screenHeight);

var elem1 = new TextField();
elem1.BgColor = Color.Aquamarine;
elem1.Weight = 1.0f;

var elem2 = new HorizontalContainer();
elem2.Weight = 2.0f;
elem2.BgColor = Color.Coral;

var elem3 = new TextField();
elem3.Weight = 1.0f;
elem3.BgColor = Color.DarkMagenta;

var col1 = new TextField();
col1.Weight = 2.5f;
col1.BgColor = Color.Gold;
var col2 = new TextField();
col2.Weight = 5.0f;
col2.BgColor = Color.Khaki;

var col3 = new TextField();
col3.Weight = 2.5f;
col3.BgColor = Color.IndianRed;

elem2.AppendChild(col1);
elem2.AppendChild(col2);
elem2.AppendChild(col3);

_rootNode.AppendChild(elem1);
_rootNode.AppendChild(elem2);
_rootNode.AppendChild(elem3);

_rootNode.Initialise(null);

Na początku szedłem z ideą wielu konstruktorów, z których każdy pozwala zainicjować elementy z wybranymi właściwościami. Zmieniłem jednak zdanie. Każdy element ma jeden konstruktor a wszystkie opcje (kolor, rozmiar, pozycja, …) ustawiamy bezpośrednio, bez domysłów. Pomińmy na razie kwestie rysowania tego wszystkiego. Więc w idealnym świecie, po odpaleniu aplikacji powinno przełożyć się to na następujący widok:

monogame5_podzial

Natomiast w postaci drzewa elementów (zgodnie z kodem) wygląda to tak:

monogame5_drzewo

Kolory służą wskazaniu, o których elementach mowa. Mylące może być użycie nazwy TextField, ale chwilowo korzystam z takich klas w swoim kodzie, używam pole tekstowe bez żadnego tekstu zamiast tworzyć klasy w rodzaju Panelu.

Czy brzmi to jakkolwiek sensownie? Zapewne zapytasz o co chodzi z tymi wagami. Jeżeli miałeś do czynienia z Androidem (ale nie tylko) na pewno przyznasz mi rację, że nie jest to najgorszy pomysł, żeby elementy miały swoje wagi. Waga oznacza po prostu jaką cześć z całej długości kontenera zajmie dany element. Czyli jeżeli np.:

  • dwa elementy mają taka sama wagę, każdy z nich zajmie po połowie długości obiektu, w którym „siedzą”
  • jeżeli w obiekcie jest tylko jeden element, będzie zajmował 100% długości niezależnie od wagi
  • trzy elementy, z wagami zdefniiowanymi kolejno 2.5, 2.5 i 5 będą zajmowaly odpowiednio 25%, 25% i ostatni 50% długości kontenera

Idea jest taka, że liczby dla wag mogą być dowolne, wszystko zależy od pomysłu projektanta a matematyki i tak nie oszukamy. 🙂

Spójrzmy więc jak można zrealizować poprawne liczenie wagi. Brzmi to jak idealny scenariusz dla testów jednostkowych, więc od nich zaczynam. Używam jak zwykle frameworka nUnit (w wersji 2.6.4) no i VisualStudio.

[TestFixture]
public class UILayout_GivenAVerticalContainerAndTwoChildElements
{
	private UiContainer _rootNode;
	UiNode _node1;
	UiNode _node2;

	[SetUp]
	public void SetUp()
	{
		_rootNode = new VerticalContainer();
		_rootNode.Position = new Rectangle(0, 0, 800, 600);

		_node1 = new Button();
		_node1.Weight = 2.5f;
		_node2 = new Button();
		_node1.Weight = 7.5f;

		_rootNode.AppendChild(_node1);
		_rootNode.AppendChild(_node2);

		_rootNode.Initialise(null);
	}

	[Test]
	public void TheVerticalChildCountIsUpdatedAfterAppending()
	{
		Assert.AreEqual(_rootNode.ChildCount, 2);
	}

	[Test]
	public void TheVerticalPositionIsCalculatedCorrectly()
	{
		Assert.AreEqual(0, _node1.Position.Y);
		Assert.AreEqual(150, _node2.Position.Y);

		Assert.AreEqual(150, _node1.Position.Height);
		Assert.AreEqual(450, _node2.Position.Height);
	}
}

Prosty przykład, w którym w głównym elemencie mamy dwa elementy (przyciski, ale to bez znaczenia), które otrzymują wagi kolejno 2.5 oraz 7.5. Przy założeniu, że okno na którym działamy ma 800×600 pikseli, sprawdzamy, czy przeliczone (Initialise) rozmiary i pozycje elementów są zgodne z rzeczywistością. Przy okazji sprawdzamy też ilość elementów podrzędnych (child), co zapewnia nas, że metoda AppendChild poprawnie składa drzewo interfejsu.

Napisałem jeszcze kilka testów tego typu, ale niewiele różnią się od podanego tutaj, testują po prostu różne inne scenariusze.

Powstało trochę kodu, ale najbardziej interesujące wydaje się na ten moment przeliczanie wag na długości. Dlatego pominę wszelkie inne klasy, które głównie składają się z getterów i setterów. Na pierwszy ogień klasa bazowa kontenerów:

public class UiContainer : UiNode
{
	protected List<UiNode> _children = new List<UiNode>();
	public List<UiNode> Children { get { return _children; } }
	[JsonIgnore]
	public int ChildCount { get { return _children.Count; } }

	protected float SumOfWeight { get { var sum = 0.0f; foreach (var childNode in _children) sum += childNode.Weight; return sum; } }

	public void AppendChild(UiNode childNode)
	{
		_children.Add(childNode);
	}
// (...)
}

Co tu mamy? Każdy element w moim rozwiązaniu dziedziczy z klasy UiNode, która stanowi abstrakcję elementu interfejsu. Posiada unikalny identyfikator, pozycje, wagę i w tym momencie również kolor tła. Klasa UiContainer rozwiją ją o listę elementów podrzędnych i wprowadza opcję liczenia sumy wag elementów, która będzie za chwilę potrzebna.

public class UiContainer : UiNode
{
// (...)
	public override void Draw(SpriteBatch batch)
	{
		if (!Visible)
			return;

		foreach (var childNode in _children)
			childNode.Draw(batch);
	}
	public override void Initialise(UIManager manager)
	{
		base.Initialise(manager);

		foreach (var childNode in _children)
			childNode.Initialise(manager);
	}
// (...)
}

Następnie mamy odrysowywanie elementu, które jest pomijane jeżeli element nie jest widzialny (pole Visible pochodzi z bazowej klasy UiNode). Rysowanie w przypadku kontenera elementów polega na odrysowanie wszystkich elementów podrzędnych. To samo dotyczy inicjalizacji. Po prostu przekazujemy całą posiadaną informację w dół. W tym przypadku jest to uchwyt do klasy UiManager, którą nie przejmujemy się w tym momencie.

public class VerticalContainer : UiNode
{
	public override void Initialise(UIManager manager)
	{
		if (SumOfWeight == 0.0f)
			return;

		var sumSoFar = 0.0f;
		var sumOfWeights = SumOfWeight;
		for (var i = 0; i < _children.Count; i++) {
			var weighingHeight = _children[i].Weight / sumOfWeights * Position.Height;

			_children[i].Position = new Rectangle(
					Position.X + _children[i].Margin.X, 
					(int)(Position.Y + sumSoFar) + _children[i].Margin.Y, 
					Position.Width - (_children[i].Margin.X + _children[i].Margin.Width), 
					(int)weighingHeight - (_children[i].Margin.Y + _children[i].Margin.Height)
			);
										
			sumSoFar += weighingHeight;
		}

		base.Initialise(manager);
	}
}

Spójrzmy teraz na jedną z klas pochodnych kontenera. Jedyne co tak naprawdę dzieje się na tym poziomie, to specyficzne dla tej klasy obliczanie wymiarów i pozycji elementu. Przechodzimy więc przez kolejne elementy podrzędne i sprawdzamy jaką część wagi (weighinWeight) stanowi każdy z nich względem wszystkich elementów (SumOfWeights). Dzięki tej informacji przekładamy to na długość w pikselach mnożąc przez wysokość kontenera (Position.Height). Jeżeli jest to element główny (root), to wysokością będzie wysokość okna.

W ramach ćwiczeń możesz spróbować napisać wersję metody Initialise dla drugiego z kontenerów (HorizontalContainer, czyli poziomy). Pełną wersję kodu znajdziesz wkrótce na moim repozytorium, ponieważ zamierzam cały projekt umieścić na githubie w celu zdobycia cennych opinii i wskazówek.

Na koniec jeszcze wspomnę o tym jak rysuję elementy na ekranie. Jako, że używam do przykładów nieszczęsnego TextField, to tego się będę trzymał.

public class TextField: UiNode
{
	public override void Draw(SpriteBatch batch)
	{
		base.Draw(batch);

		if (Visible && BgColor.A != 0)
			batch.Draw(_uiTexture, Position, BgColor);

		if (!string.IsNullOrEmpty(Text))
			batch.DrawString(_font, Text, _textPosition, Color.GreenYellow);
	}
}

Od MonoGame w metodzie Draw otrzymuję obiekt SpriteBatch, więc przekazuje go przez całe drzewo interfejsu w dół i tam gdzie trzeba używam go do odrysowania prostokąta o ustalonym kolorze i obliczonej przed chwilą pozycji i rozmiarze (Position). Obiekt _uiTexture to sztucznie utworzona biała tekstura o rozmiarze 2×2, ponieważ moja „biblioteka” nie wspiera jeszcze obrazków w interfejsie. 🙂

Domyślam się, że całość może wydawać się zagmatwana, ale w kolejnej części postaram się wyjaśnić szerzej zastosowany pomysł i pozostałe klasy. Tymczasem to tyle!